mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 16:03:49 +00:00
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:
@@ -103,6 +103,7 @@ docs/**
|
||||
!docs/reference/git-compatibility.md
|
||||
!docs/reference/headless-linux-server.md
|
||||
!docs/reference/linux-glibc-compatibility.md
|
||||
!docs/reference/macos-press-and-hold.md
|
||||
!docs/reference/orcad-operations.md
|
||||
!docs/reference/relay-grace-time-reconfiguration.md
|
||||
!docs/reference/windows-process-enumeration.md
|
||||
|
||||
+505
-110
File diff suppressed because one or more lines are too long
@@ -5,6 +5,7 @@
|
||||
"../src/shared/**/*",
|
||||
"../src/main/agent-state-file-reader.ts",
|
||||
"../src/main/agent-hooks/hook-stdin-contract.ts",
|
||||
"../src/main/agent-hooks/hook-post-command.ts",
|
||||
"../src/main/agent-hooks/hook-config-write-path.ts",
|
||||
"../src/main/agent-hooks/hooks-json-read.ts",
|
||||
"../src/main/agent-hooks/installer-utils.ts",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 30m">
|
||||
<title>downloads: 30m</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 31m">
|
||||
<title>downloads: 31m</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
@@ -15,7 +15,7 @@
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
|
||||
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
|
||||
<text x="37" y="14">downloads</text>
|
||||
<text x="90" y="15" fill="#010101" fill-opacity=".3">30m</text>
|
||||
<text x="90" y="14">30m</text>
|
||||
<text x="90" y="15" fill="#010101" fill-opacity=".3">31m</text>
|
||||
<text x="90" y="14">31m</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 935 B After Width: | Height: | Size: 935 B |
@@ -0,0 +1,58 @@
|
||||
# macOS press-and-hold and key repeat
|
||||
|
||||
macOS opens the accent picker when a key is held unless an application opts out in its preferences
|
||||
domain. That prevents held keys from repeating in terminal applications such as vim. On the first
|
||||
eligible launch, Orca writes:
|
||||
|
||||
```sh
|
||||
defaults write com.stablyai.orca ApplePressAndHoldEnabled -bool false
|
||||
```
|
||||
|
||||
The write is scoped to Orca's packaged bundle domain. Bare Electron development bundles and
|
||||
non-macOS platforms are left untouched. A fresh write is conservatively treated as taking effect
|
||||
on the next launch.
|
||||
|
||||
## Precedence and decision record
|
||||
|
||||
Orca checks for an explicit domain value before writing. Either `true` or `false` is treated as a
|
||||
user choice and preserved. Only an unset key receives the `false` default.
|
||||
|
||||
The decision is stored once in
|
||||
`<userData>/macos-press-and-hold-default.json`. An `applied` or
|
||||
`kept-user-preference` decision prevents future launches from touching the domain again.
|
||||
Probe and write failures remain retryable so a transient failure does not permanently disable the
|
||||
fix.
|
||||
|
||||
`defaults read <domain> <key>` is used instead of
|
||||
`systemPreferences.getUserDefault`: the Electron API cannot distinguish an unset key from an
|
||||
explicit `false`. Only the missing-key exit status is interpreted as unset; spawn failures,
|
||||
timeouts, and other exit statuses leave the preference alone.
|
||||
|
||||
## Restoring the accent picker
|
||||
|
||||
Set the preference explicitly, then restart Orca:
|
||||
|
||||
```sh
|
||||
defaults write com.stablyai.orca ApplePressAndHoldEnabled -bool true
|
||||
```
|
||||
|
||||
After Orca has recorded its one-time decision, deleting the key also restores the macOS default
|
||||
without Orca recreating it:
|
||||
|
||||
```sh
|
||||
defaults delete com.stablyai.orca ApplePressAndHoldEnabled
|
||||
```
|
||||
|
||||
Development and prerelease channels may use a channel-suffixed Orca bundle identifier; use that
|
||||
domain instead when applicable.
|
||||
|
||||
## Reverting
|
||||
|
||||
Deleting the startup code is not enough. AppKit reads the persisted preference, so a code revert
|
||||
must also arrange to delete the key for users who ran an affected build.
|
||||
|
||||
## Test coverage
|
||||
|
||||
Unit tests cover platform guards, explicit-value preservation, retry behavior, domain ownership,
|
||||
record persistence, and subprocess exit interpretation on CI. A macOS-only test additionally pins
|
||||
the real `defaults(1)` behavior, but current PR CI does not execute tests on macOS.
|
||||
@@ -209,6 +209,7 @@ export const electronViteConfig: UserConfig = {
|
||||
index: resolve('src/main/index.ts'),
|
||||
// Why: sandboxed webview preloads cannot load Rollup helper chunks.
|
||||
'browser-window-close-preload': resolve('src/preload/browser-window-close.ts'),
|
||||
'doc-preview-link-preload': resolve('src/preload/doc-preview-link.ts'),
|
||||
'daemon-entry': resolve('src/main/daemon/daemon-entry.ts'),
|
||||
'plugin-host-entry': resolve('src/main/plugins/plugin-host-entry.ts'),
|
||||
'computer-sidecar': resolve('src/main/computer/sidecar-entry.ts'),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -92,6 +92,7 @@ describe('agent hooks CLI handler', () => {
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
vi.restoreAllMocks()
|
||||
rmSync(userDataPath, { recursive: true, force: true })
|
||||
})
|
||||
@@ -139,6 +140,33 @@ describe('agent hooks CLI handler', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('forwards WSL pane routing to the runtime exactly once without using the host installer', async () => {
|
||||
const home = '/home/jin/.local/share/orca/codex-runtime-home/home'
|
||||
vi.stubEnv('CODEX_HOME', home)
|
||||
vi.stubEnv('ORCA_CODEX_HOME', home)
|
||||
vi.stubEnv('WSL_DISTRO_NAME', 'Ubuntu-24.04')
|
||||
callMock.mockResolvedValue({ result: { state: 'installed' } })
|
||||
|
||||
await main(['agent', 'hooks', 'prepare-codex'], userDataPath)
|
||||
|
||||
expect(callMock).toHaveBeenCalledExactlyOnceWith(
|
||||
'agentHooks.prepareCodexForWslPane',
|
||||
{ codexHome: home, orcaCodexHome: home, wslDistro: 'Ubuntu-24.04' },
|
||||
{ timeoutMs: 50_000 }
|
||||
)
|
||||
expect(prepareManagedCodexHomeBeforeShellLaunchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails open when WSL runtime preparation is unavailable', async () => {
|
||||
vi.stubEnv('CODEX_HOME', '/home/jin/.local/share/orca/codex-runtime-home/home')
|
||||
vi.stubEnv('ORCA_CODEX_HOME', '/home/jin/.local/share/orca/codex-runtime-home/home')
|
||||
vi.stubEnv('WSL_DISTRO_NAME', 'Ubuntu')
|
||||
callMock.mockRejectedValue(new Error('method_not_found'))
|
||||
|
||||
await expect(main(['agent', 'hooks', 'prepare-codex'], userDataPath)).resolves.toBeUndefined()
|
||||
expect(prepareManagedCodexHomeBeforeShellLaunchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('honors Codex-specific disablement when the runtime is unavailable', async () => {
|
||||
const state = getDefaultPersistedState(userDataPath)
|
||||
state.settings.disabledTuiAgents = ['codex']
|
||||
|
||||
@@ -28,6 +28,9 @@ type AgentHookCommandResult = {
|
||||
statuses: AgentHookInstallStatus[]
|
||||
}
|
||||
|
||||
// Covers managed-home verification, WSL identity, trust grant, and bounded app-server reap.
|
||||
const WSL_CODEX_PREPARE_TIMEOUT_MS = 50_000
|
||||
|
||||
function getDataPath(): string {
|
||||
const userDataPath = getDefaultUserDataPath()
|
||||
const indexPath = join(userDataPath, 'orca-profile-index.json')
|
||||
@@ -207,6 +210,22 @@ async function setAgentHooksEnabled(
|
||||
|
||||
export const AGENT_HOOK_HANDLERS: Record<string, CommandHandler> = {
|
||||
'agent hooks prepare-codex': async ({ client }) => {
|
||||
if (process.env.WSL_DISTRO_NAME?.trim()) {
|
||||
try {
|
||||
await client.call(
|
||||
'agentHooks.prepareCodexForWslPane',
|
||||
{
|
||||
codexHome: process.env.CODEX_HOME ?? '',
|
||||
orcaCodexHome: process.env.ORCA_CODEX_HOME ?? '',
|
||||
wslDistro: process.env.WSL_DISTRO_NAME
|
||||
},
|
||||
{ timeoutMs: WSL_CODEX_PREPARE_TIMEOUT_MS }
|
||||
)
|
||||
} catch {
|
||||
// Best effort: old or unavailable runtimes must not block Codex launch.
|
||||
}
|
||||
return
|
||||
}
|
||||
const settings = await readHookSettings(client)
|
||||
await prepareManagedCodexHomeBeforeShellLaunch({
|
||||
userDataPath: getDefaultUserDataPath(),
|
||||
|
||||
@@ -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
@@ -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)',
|
||||
|
||||
@@ -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
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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.', '')
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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' }
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>.`
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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❯')
|
||||
})
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { AgentHookSource } from '../../shared/agent-hook-relay'
|
||||
import { ORCA_HOOK_RAW_JSON_TRANSPORT } from '../../shared/agent-hook-types'
|
||||
|
||||
export function buildPosixAgentHookPostCommand(
|
||||
source: AgentHookSource,
|
||||
options: { curlCommand?: string; indent?: string } = {}
|
||||
): string[] {
|
||||
const curlCommand = options.curlCommand ?? 'curl'
|
||||
const indent = options.indent ?? ' '
|
||||
return [
|
||||
`if [ "\${ORCA_AGENT_HOOK_TRANSPORT:-}" = "${ORCA_HOOK_RAW_JSON_TRANSPORT}" ] && command -v base64 >/dev/null 2>&1 && command -v tr >/dev/null 2>&1; then`,
|
||||
` orca_hook_metadata=$(printf '%s\\037%s\\037%s\\037%s\\037%s\\037%s' "$ORCA_PANE_KEY" "$ORCA_TAB_ID" "$ORCA_AGENT_LAUNCH_TOKEN" "$ORCA_WORKTREE_ID" "$ORCA_AGENT_HOOK_ENV" "$ORCA_AGENT_HOOK_VERSION" | base64 | tr -d '\\n') && \\`,
|
||||
` [ -n "$orca_hook_metadata" ] && \\`,
|
||||
` printf '%s' "$payload" | ${curlCommand} -sS -X POST "http://127.0.0.1:\${ORCA_AGENT_HOOK_PORT}/hook/${source}" \\`,
|
||||
` ${indent}--connect-timeout "\${connect_timeout:-0.5}" --max-time "\${max_time:-1.5}" \\`,
|
||||
` ${indent}--noproxy "127.0.0.1" \\`,
|
||||
` ${indent}-H "Content-Type: application/json" \\`,
|
||||
` ${indent}-H "X-Orca-Agent-Hook-Token: \${ORCA_AGENT_HOOK_TOKEN}" \\`,
|
||||
` ${indent}-H "X-Orca-Agent-Hook-Meta-Encoding: base64" \\`,
|
||||
` ${indent}-H "X-Orca-Agent-Hook-Meta: \${orca_hook_metadata}" \\`,
|
||||
` ${indent}--data-binary @-`,
|
||||
'else',
|
||||
` printf '%s' "$payload" | ${curlCommand} -sS -X POST "http://127.0.0.1:\${ORCA_AGENT_HOOK_PORT}/hook/${source}" \\`,
|
||||
` ${indent}--connect-timeout "\${connect_timeout:-0.5}" --max-time "\${max_time:-1.5}" \\`,
|
||||
` ${indent}--noproxy "127.0.0.1" \\`,
|
||||
` ${indent}-H "Content-Type: application/x-www-form-urlencoded" \\`,
|
||||
` ${indent}-H "X-Orca-Agent-Hook-Token: \${ORCA_AGENT_HOOK_TOKEN}" \\`,
|
||||
` ${indent}--data-urlencode "paneKey=\${ORCA_PANE_KEY}" \\`,
|
||||
` ${indent}--data-urlencode "tabId=\${ORCA_TAB_ID}" \\`,
|
||||
` ${indent}--data-urlencode "launchToken=\${ORCA_AGENT_LAUNCH_TOKEN}" \\`,
|
||||
` ${indent}--data-urlencode "worktreeId=\${ORCA_WORKTREE_ID}" \\`,
|
||||
` ${indent}--data-urlencode "env=\${ORCA_AGENT_HOOK_ENV}" \\`,
|
||||
` ${indent}--data-urlencode "version=\${ORCA_AGENT_HOOK_VERSION}" \\`,
|
||||
` ${indent}--data-urlencode "payload@-"`,
|
||||
'fi'
|
||||
]
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
writeHooksJson,
|
||||
type HooksConfig
|
||||
} from './installer-utils'
|
||||
import { buildPosixAgentHookPostCommand } from './hook-post-command'
|
||||
import { POSIX_HOOK_STDIN_DRAIN_COMMAND } from './hook-stdin-contract'
|
||||
import { wrapRuntimeHomeHookCommand } from './runtime-home-hook-command'
|
||||
|
||||
@@ -882,6 +883,26 @@ describe('buildWindowsAgentHookPostCommand', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildPosixAgentHookPostCommand', () => {
|
||||
it('uses raw JSON only when the listener advertises support', () => {
|
||||
const command = buildPosixAgentHookPostCommand('claude').join('\n')
|
||||
|
||||
expect(command).toContain('ORCA_AGENT_HOOK_TRANSPORT:-}')
|
||||
expect(command).toContain('raw-json-v1')
|
||||
expect(command).toContain('command -v base64')
|
||||
expect(command).toContain('command -v tr')
|
||||
expect(command).toContain('Content-Type: application/json')
|
||||
expect(command).toContain('X-Orca-Agent-Hook-Meta-Encoding: base64')
|
||||
expect(command).toContain('X-Orca-Agent-Hook-Meta: ${orca_hook_metadata}')
|
||||
expect(command).toContain("printf '%s\\037%s\\037%s\\037%s\\037%s\\037%s'")
|
||||
expect(command).toContain('$ORCA_PANE_KEY')
|
||||
expect(command).toContain('$ORCA_WORKTREE_ID')
|
||||
expect(command).toContain('--data-binary @-')
|
||||
expect(command).toContain('Content-Type: application/x-www-form-urlencoded')
|
||||
expect(command).toContain('--data-urlencode "payload@-"')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildWindowsAgentHookCurlPostCommand', () => {
|
||||
it('posts form fields via curl.exe and reads the payload from stdin', () => {
|
||||
const command = buildWindowsAgentHookCurlPostCommand('codex')
|
||||
|
||||
@@ -284,7 +284,7 @@ describe('remote hook service installers', () => {
|
||||
hooks: Record<string, { hooks: { command: string }[] }[]>
|
||||
}
|
||||
expect(hooks.hooks.Stop?.[0]?.hooks?.[0]?.command).toContain(
|
||||
'/home/dev/.orca/agent-hooks/codex-hook.sh'
|
||||
'/home/dev/.local/share/orca/codex-runtime-home/home/.orca/agent-hooks/codex-hook.sh'
|
||||
)
|
||||
expect(fs.files.get(`${runtimeHome}/config.toml`)).toContain(
|
||||
`${runtimeHome}/hooks.json:stop:0:0`
|
||||
|
||||
@@ -66,6 +66,7 @@ describe('Endpoint file lifecycle', () => {
|
||||
expect(contents).toContain(`${prefix}ORCA_AGENT_HOOK_TOKEN=${expectedToken}`)
|
||||
expect(contents).toContain(`${prefix}ORCA_AGENT_HOOK_ENV=development`)
|
||||
expect(contents).toContain(`${prefix}ORCA_AGENT_HOOK_VERSION=1`)
|
||||
expect(contents).toContain(`${prefix}ORCA_AGENT_HOOK_TRANSPORT=raw-json-v1`)
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
|
||||
@@ -44,6 +44,38 @@ async function postClaudeHook(
|
||||
}
|
||||
|
||||
describe('AgentHookServer listener replay', () => {
|
||||
it('accepts raw JSON hook bodies with base64 metadata headers', async () => {
|
||||
const server = new AgentHookServer()
|
||||
await server.start({ env: 'production' })
|
||||
try {
|
||||
const env = server.buildPtyEnv()
|
||||
const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN,
|
||||
'X-Orca-Agent-Hook-Meta-Encoding': 'base64',
|
||||
'X-Orca-Agent-Hook-Meta': Buffer.from(
|
||||
[PANE, 'tab-1', '', 'wt-1', 'production', ''].join('\x1f')
|
||||
).toString('base64')
|
||||
},
|
||||
body: JSON.stringify({ hook_event_name: 'UserPromptSubmit', prompt: 'raw JSON' })
|
||||
})
|
||||
|
||||
expect(response.status).toBe(204)
|
||||
expect(server.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({
|
||||
paneKey: PANE,
|
||||
worktreeId: 'wt-1',
|
||||
state: 'working',
|
||||
prompt: 'raw JSON'
|
||||
})
|
||||
])
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('caches and notifies status/main/plugin before retry scheduling and HTTP response', async () => {
|
||||
const server = new AgentHookServer()
|
||||
await server.start({ env: 'production' })
|
||||
|
||||
@@ -8,7 +8,10 @@ import { join } from 'node:path'
|
||||
import { track } from '../telemetry/client'
|
||||
import { getCohortAtEmit } from '../telemetry/cohort-classifier'
|
||||
import { AGENT_KIND_VALUES, type AgentKind } from '../../shared/telemetry-events'
|
||||
import { ORCA_HOOK_PROTOCOL_VERSION } from '../../shared/agent-hook-types'
|
||||
import {
|
||||
ORCA_HOOK_PROTOCOL_VERSION,
|
||||
ORCA_HOOK_RAW_JSON_TRANSPORT
|
||||
} from '../../shared/agent-hook-types'
|
||||
import {
|
||||
clearAllListenerCaches,
|
||||
clearPaneCacheState,
|
||||
@@ -46,6 +49,7 @@ import {
|
||||
} from '../../shared/agent-hook-listener/listener-limits'
|
||||
import { isNewTurnEvent } from '../../shared/agent-hook-listener/provider-event-routing'
|
||||
import { normalizeHookPayload } from '../../shared/agent-hook-listener'
|
||||
import { mergeAgentHookRequestHeaders } from '../../shared/agent-hook-listener/hook-envelope'
|
||||
import {
|
||||
parseFormEncodedBody,
|
||||
readRequestBody
|
||||
@@ -2616,8 +2620,9 @@ export class AgentHookServer {
|
||||
return
|
||||
}
|
||||
|
||||
trackEmptyPaneKeyHook(body)
|
||||
const aliasedBody = this.normalizeHookBodyPaneKeyAlias(body)
|
||||
const hookBody = mergeAgentHookRequestHeaders(body, req.headers)
|
||||
trackEmptyPaneKeyHook(hookBody)
|
||||
const aliasedBody = this.normalizeHookBodyPaneKeyAlias(hookBody)
|
||||
const normalized = this.normalizeLocalHookPayload(source, aliasedBody)
|
||||
const statusDisposition = normalized.event
|
||||
? this.getAgentStatusDisposition(normalized.event.paneKey, {
|
||||
@@ -3129,7 +3134,8 @@ export class AgentHookServer {
|
||||
ORCA_AGENT_HOOK_PORT: String(this.port),
|
||||
ORCA_AGENT_HOOK_TOKEN: this.token,
|
||||
ORCA_AGENT_HOOK_ENV: this.env,
|
||||
ORCA_AGENT_HOOK_VERSION: ORCA_HOOK_PROTOCOL_VERSION
|
||||
ORCA_AGENT_HOOK_VERSION: ORCA_HOOK_PROTOCOL_VERSION,
|
||||
ORCA_AGENT_HOOK_TRANSPORT: ORCA_HOOK_RAW_JSON_TRANSPORT
|
||||
}
|
||||
// Why: hooks source this file at invocation; dev namespaces it so parallel `pnpm dev` runs don't steal each other's hooks.
|
||||
if (this.endpointFileWritten && this.endpointFilePathCache) {
|
||||
@@ -3156,7 +3162,8 @@ export class AgentHookServer {
|
||||
port: this.port,
|
||||
token: this.token,
|
||||
env: this.env,
|
||||
version: ORCA_HOOK_PROTOCOL_VERSION
|
||||
version: ORCA_HOOK_PROTOCOL_VERSION,
|
||||
transport: ORCA_HOOK_RAW_JSON_TRANSPORT
|
||||
})
|
||||
this.endpointFileWritten = ok
|
||||
}
|
||||
|
||||
@@ -12,19 +12,23 @@ import {
|
||||
type ManagedHookDetectionSettings
|
||||
} from './managed-hook-detection-commands'
|
||||
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
|
||||
import { wslCodexRuntimeHomeForGuestHome } from '../pty/codex-home-wsl-env'
|
||||
import { WSL_HOOK_FS_METHODS, type WslFsResult } from '../../shared/wsl-hook-relay-contract'
|
||||
import type { AgentHookInstallStatus } from '../../shared/agent-hook-types'
|
||||
|
||||
/** Run the shared remote hook installers against a WSL guest over the relay's fs bridge. */
|
||||
export async function installWslGuestHooks(options: {
|
||||
mux: SshChannelMultiplexer
|
||||
guestHome: string
|
||||
codexHomePath: string | null
|
||||
distro: string
|
||||
installHooks: typeof installRemoteManagedAgentHooks
|
||||
settings: ManagedHookDetectionSettings
|
||||
warn: (message: string) => void
|
||||
/** Canonical runtime-host installer for redirected Codex homes. */
|
||||
installCodex: (runtimeHomePath: string, distro: string) => Promise<AgentHookInstallStatus | null>
|
||||
}): Promise<void> {
|
||||
const { mux, guestHome, distro, installHooks, settings, warn } = options
|
||||
const { mux, guestHome, codexHomePath, distro, installHooks, settings, warn, installCodex } =
|
||||
options
|
||||
let agents
|
||||
try {
|
||||
const detected = (await mux.request('preflight.detectAgents', {
|
||||
@@ -42,12 +46,25 @@ export async function installWslGuestHooks(options: {
|
||||
if (agents.length === 0) {
|
||||
return
|
||||
}
|
||||
if (agents.includes('codex') && codexHomePath) {
|
||||
try {
|
||||
const status = await installCodex(codexHomePath, distro)
|
||||
if (status?.state === 'error') {
|
||||
warn(
|
||||
`[agent-hooks] WSL Codex hook install for '${distro}' failed: ${status.detail ?? 'unknown error'}`
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
warn(
|
||||
`[agent-hooks] WSL Codex hook install for '${distro}' failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
// Codex is redirected into the runtime home and must use the canonical
|
||||
// runtime-host writer above; the relay adapter owns all other agents.
|
||||
const remoteAgents = agents.filter((agent) => agent !== 'codex')
|
||||
const results = await installHooks(createWslHookSftpAdapter(mux), guestHome, {
|
||||
agents,
|
||||
// WSL Codex launches use Orca's managed runtime CODEX_HOME, not ~/.codex.
|
||||
// Keep relay hooks in that active home so status callbacks are received.
|
||||
codexHomeDir: wslCodexRuntimeHomeForGuestHome(guestHome),
|
||||
deferTrustUntilConfigToml: true
|
||||
agents: remoteAgents
|
||||
})
|
||||
const failed = results.filter((r) => r.state === 'error').length
|
||||
if (failed > 0) {
|
||||
|
||||
@@ -9,6 +9,8 @@ import { agentHookServer } from './server'
|
||||
import type { ManagedHookDetectionSettings } from './managed-hook-detection-commands'
|
||||
import { installRemoteManagedAgentHooks } from './remote-managed-hook-installers'
|
||||
import { getOpenCodePluginSource } from '../opencode/hook-service'
|
||||
import { codexHookService } from '../codex/hook-service'
|
||||
import type { AgentHookInstallStatus } from '../../shared/agent-hook-types'
|
||||
import type { PluginSources } from '../../relay/plugin-overlay'
|
||||
import {
|
||||
isWslDistroRunning,
|
||||
@@ -61,6 +63,7 @@ export type WslHookRelayManagerDeps = {
|
||||
waitForSentinel: typeof waitForWslRelaySentinel
|
||||
ingest: (envelope: Record<string, unknown>, connectionId: string) => void
|
||||
installHooks: typeof installRemoteManagedAgentHooks
|
||||
installCodex: (runtimeHomePath: string, distro: string) => Promise<AgentHookInstallStatus | null>
|
||||
managedHookSettings: () => ManagedHookDetectionSettings
|
||||
/** Plugin source strings shipped to the guest relay so an Orca update needn't redeploy the relay bundle. */
|
||||
pluginSources: () => PluginSources
|
||||
@@ -102,6 +105,11 @@ export const defaultWslHookRelayDeps: WslHookRelayManagerDeps = {
|
||||
connectionId
|
||||
),
|
||||
installHooks: installRemoteManagedAgentHooks,
|
||||
installCodex: (runtimeHomePath, distro) =>
|
||||
codexHookService.installForRuntimeHomeSerialized(runtimeHomePath, {
|
||||
runtime: 'wsl',
|
||||
wslDistro: distro
|
||||
}),
|
||||
managedHookSettings: () => null,
|
||||
// Why: only OpenCode is in scope for WSL now; the payload shape stays identical to SSH so Pi/OMP are additive later.
|
||||
pluginSources: () => ({ opencodePluginSource: getOpenCodePluginSource() }),
|
||||
|
||||
@@ -6,13 +6,14 @@ import type { ManagedHookDetectionSettings } from './managed-hook-detection-comm
|
||||
import type { installRemoteManagedAgentHooks } from './remote-managed-hook-installers'
|
||||
import { requestGuestOpenCodeOverlayDir } from './wsl-guest-plugin-install'
|
||||
import { installWslGuestHooks } from './wsl-hook-fs-adapter'
|
||||
import { REINSTALL_MIN_INTERVAL_MS } from './wsl-hook-relay-deps'
|
||||
import { REINSTALL_MIN_INTERVAL_MS, type WslHookRelayManagerDeps } from './wsl-hook-relay-deps'
|
||||
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
|
||||
import type { PluginSources } from '../../relay/plugin-overlay'
|
||||
|
||||
/** Structural slice of WslHookRelayManagerDeps — only what an install pass uses. */
|
||||
type GuestInstallDeps = {
|
||||
installHooks: typeof installRemoteManagedAgentHooks
|
||||
installCodex: WslHookRelayManagerDeps['installCodex']
|
||||
managedHookSettings: () => ManagedHookDetectionSettings
|
||||
pluginSources: () => PluginSources
|
||||
warn: (message: string) => void
|
||||
@@ -23,6 +24,7 @@ type GuestInstallState = {
|
||||
distro: string
|
||||
mux?: SshChannelMultiplexer
|
||||
guestHome?: string
|
||||
codexHomePath?: string
|
||||
opencodeOverlayDir?: string
|
||||
lastInstallAt?: number
|
||||
}
|
||||
@@ -37,8 +39,10 @@ export async function runWslRelayGuestInstall(
|
||||
await installWslGuestHooks({
|
||||
mux,
|
||||
guestHome,
|
||||
codexHomePath: state.codexHomePath ?? null,
|
||||
distro: state.distro,
|
||||
installHooks: deps.installHooks,
|
||||
installCodex: deps.installCodex,
|
||||
settings: deps.managedHookSettings(),
|
||||
warn: deps.warn
|
||||
})
|
||||
|
||||
@@ -13,7 +13,8 @@ import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { AgentHookServer } from './server'
|
||||
import { WslHookRelayManager } from './wsl-hook-relay-manager'
|
||||
import { wslCodexRuntimeHomeForGuestHome } from '../pty/codex-home-wsl-env'
|
||||
import { createManagedHookLocalFilesystem } from './managed-hook-local-filesystem'
|
||||
import { codexHookService } from '../codex/hook-service'
|
||||
|
||||
const BUNDLE_DIR = join(process.cwd(), 'out', 'relay', 'wsl')
|
||||
const BUNDLE_JS = join(BUNDLE_DIR, 'wsl-agent-hook-relay.js')
|
||||
@@ -75,6 +76,7 @@ describe.skipIf(process.platform === 'win32')(
|
||||
const server = orcaServer
|
||||
|
||||
const warns: string[] = []
|
||||
const codexHome = join(fakeHome, '.local', 'share', 'orca', 'codex-runtime-home', 'home')
|
||||
manager = new WslHookRelayManager({
|
||||
platform: () => 'win32',
|
||||
remoteHooksEnabled: () => true,
|
||||
@@ -102,6 +104,11 @@ describe.skipIf(process.platform === 'win32')(
|
||||
envelope as Parameters<AgentHookServer['ingestRemote']>[0],
|
||||
connectionId
|
||||
),
|
||||
installCodex: (runtimeHomePath) =>
|
||||
codexHookService.installRemote(createManagedHookLocalFilesystem(), fakeHome, {
|
||||
codexHomeDir: runtimeHomePath,
|
||||
deferTrustUntilConfigToml: true
|
||||
}),
|
||||
managedHookSettings: () => ({
|
||||
agentCmdOverrides: {
|
||||
claude: process.execPath,
|
||||
@@ -112,15 +119,17 @@ describe.skipIf(process.platform === 'win32')(
|
||||
transientRetryDelayMs: 1
|
||||
})
|
||||
|
||||
manager.ensureForDistro('LiveDistro')
|
||||
manager.ensureForDistro('LiveDistro', codexHome)
|
||||
|
||||
// Waiting on Codex's artifact (not Claude's, which is written first) keeps the
|
||||
// assertions behind the still-running 14-agent installer loop.
|
||||
const codexHome = wslCodexRuntimeHomeForGuestHome(fakeHome)
|
||||
await vi.waitFor(() => expect(existsSync(join(codexHome, 'hooks.json'))).toBe(true), {
|
||||
timeout: 15_000
|
||||
})
|
||||
expect(existsSync(join(fakeHome, '.claude', 'settings.json'))).toBe(true)
|
||||
await vi.waitFor(
|
||||
() => expect(existsSync(join(fakeHome, '.claude', 'settings.json'))).toBe(true),
|
||||
{ timeout: 15_000 }
|
||||
)
|
||||
const claudeScript = readFileSync(
|
||||
join(fakeHome, '.orca', 'agent-hooks', 'claude-hook.sh'),
|
||||
'utf8'
|
||||
|
||||
@@ -147,6 +147,8 @@ describe('WslHookRelayManager', () => {
|
||||
// hosts — installHooks is mocked here, so the fs bridge only ever serves
|
||||
// the wslfs.home request and never touches the real filesystem.
|
||||
const home = '/home/wsl-test-user'
|
||||
const codexHome =
|
||||
'\\\\wsl.localhost\\Ubuntu\\home\\wsl-test-user\\.local\\share\\orca\\codex-runtime-home\\home'
|
||||
const opencodeOverlayDir = `${home}/.orca-relay/opencode-overlays/deadbeefcafe`
|
||||
let harnesses: GuestHarness[]
|
||||
|
||||
@@ -229,6 +231,13 @@ describe('WslHookRelayManager', () => {
|
||||
waitForSentinel: vi.fn(async () => guestTransport()),
|
||||
ingest: vi.fn(),
|
||||
installHooks: vi.fn(async () => []),
|
||||
installCodex: vi.fn(async () => ({
|
||||
agent: 'codex' as const,
|
||||
state: 'installed' as const,
|
||||
configPath: `${home}/.local/share/orca/codex-runtime-home/home/hooks.json`,
|
||||
managedHooksPresent: true,
|
||||
detail: null
|
||||
})),
|
||||
managedHookSettings: () => null,
|
||||
pluginSources: () => ({ opencodePluginSource: '// opencode plugin source' }),
|
||||
warn: vi.fn(),
|
||||
@@ -240,14 +249,14 @@ describe('WslHookRelayManager', () => {
|
||||
|
||||
it('starts one relay per distro, installs hooks, exposes the guest endpoint path, and forwards envelopes', async () => {
|
||||
const { manager, deps } = createManager({})
|
||||
manager.ensureForDistro('Ubuntu')
|
||||
manager.ensureForDistro('Ubuntu')
|
||||
manager.ensureForDistro('Ubuntu', codexHome)
|
||||
manager.ensureForDistro('Ubuntu', codexHome)
|
||||
await vi.waitFor(() => expect(deps.installHooks).toHaveBeenCalledTimes(1))
|
||||
expect(deps.spawnRelay).toHaveBeenCalledTimes(1)
|
||||
expect(deps.installCodex).toHaveBeenCalledWith(codexHome, 'Ubuntu')
|
||||
// Codex is owned by the canonical runtime-host writer, not the relay adapter.
|
||||
expect(deps.installHooks).toHaveBeenCalledWith(expect.anything(), home, {
|
||||
agents: ['codex'],
|
||||
codexHomeDir: `${home}/.local/share/orca/codex-runtime-home/home`,
|
||||
deferTrustUntilConfigToml: true
|
||||
agents: []
|
||||
})
|
||||
|
||||
expect(manager.getGuestEndpointFilePath('Ubuntu')).toBe(
|
||||
@@ -272,9 +281,23 @@ describe('WslHookRelayManager', () => {
|
||||
manager.disposeAll()
|
||||
})
|
||||
|
||||
it('reinstalls into a newly resolved runtime home without restarting the relay', async () => {
|
||||
const { manager, deps } = createManager({})
|
||||
manager.ensureForDistro('Ubuntu', codexHome)
|
||||
await vi.waitFor(() => expect(deps.installCodex).toHaveBeenCalledTimes(1))
|
||||
const nextHome = codexHome.replace('codex-runtime-home', 'codex-accounts\\account-2')
|
||||
|
||||
manager.ensureForDistro('ubuntu', nextHome)
|
||||
await vi.waitFor(() => expect(deps.installCodex).toHaveBeenCalledTimes(2))
|
||||
|
||||
expect(deps.installCodex).toHaveBeenLastCalledWith(nextHome, 'Ubuntu')
|
||||
expect(deps.spawnRelay).toHaveBeenCalledTimes(1)
|
||||
manager.disposeAll()
|
||||
})
|
||||
|
||||
it('ships the OpenCode plugin to the guest and exposes the overlay dir', async () => {
|
||||
const { manager } = createManager({})
|
||||
manager.ensureForDistro('Ubuntu')
|
||||
manager.ensureForDistro('Ubuntu', codexHome)
|
||||
await vi.waitFor(() => expect(manager.getOpenCodeOverlayDir('Ubuntu')).toBe(opencodeOverlayDir))
|
||||
manager.disposeAll()
|
||||
})
|
||||
@@ -397,7 +420,7 @@ describe('WslHookRelayManager', () => {
|
||||
it('stops live relays and refuses to revive them once agent status hooks are switched off', async () => {
|
||||
const settings = { agentStatusHooksEnabled: true }
|
||||
const { manager, deps } = createManager({ managedHookSettings: () => settings })
|
||||
manager.ensureForDistro('Ubuntu')
|
||||
manager.ensureForDistro('Ubuntu', codexHome)
|
||||
await vi.waitFor(() => expect(deps.installHooks).toHaveBeenCalledTimes(1))
|
||||
|
||||
settings.agentStatusHooksEnabled = false
|
||||
@@ -414,6 +437,8 @@ describe('WslHookRelayManager', () => {
|
||||
settings.agentStatusHooksEnabled = true
|
||||
manager.resumeStoppedRelays()
|
||||
await vi.waitFor(() => expect(deps.spawnRelay).toHaveBeenCalledTimes(2))
|
||||
await vi.waitFor(() => expect(deps.installCodex).toHaveBeenCalledTimes(2))
|
||||
expect(deps.installCodex).toHaveBeenLastCalledWith(codexHome, 'Ubuntu')
|
||||
manager.disposeAll()
|
||||
})
|
||||
|
||||
|
||||
@@ -29,6 +29,10 @@ import {
|
||||
WSL_HOOK_FS_METHODS,
|
||||
wslHookRelayEndpointFilePath
|
||||
} from '../../shared/wsl-hook-relay-contract'
|
||||
import {
|
||||
recordManagedWslCodexHome,
|
||||
wslRuntimeHomePathsEqual
|
||||
} from '../codex/managed-wsl-codex-home-registry'
|
||||
|
||||
type DistroState = {
|
||||
/** Original casing for wsl.exe argv and breadcrumbs; map keys are lowercased. */
|
||||
@@ -37,6 +41,7 @@ type DistroState = {
|
||||
child?: ChildProcessWithoutNullStreams
|
||||
mux?: SshChannelMultiplexer
|
||||
guestHome?: string
|
||||
codexHomePath?: string
|
||||
guestEndpointFilePath?: string
|
||||
opencodeOverlayDir?: string
|
||||
failures: number
|
||||
@@ -52,7 +57,7 @@ export class WslHookRelayManager {
|
||||
private recovery: WslRelayRecovery
|
||||
private states = new Map<string, DistroState>()
|
||||
/** Distros a hooks-off teardown stopped, so re-enabling can put them back. */
|
||||
private stoppedByHooksOff = new Set<string>()
|
||||
private stoppedByHooksOff = new Map<string, string | undefined>()
|
||||
private defaultDistro: string | null = null
|
||||
private disposed = false
|
||||
private warnedBundleMissing = false
|
||||
@@ -64,7 +69,7 @@ export class WslHookRelayManager {
|
||||
warn: (message) => this.deps.warn(message),
|
||||
isDisposed: () => this.disposed,
|
||||
isCurrent: (state) => this.states.get(wslHookRelayStateKey(state.distro)) === state,
|
||||
restart: (distro) => this.ensureForDistro(distro),
|
||||
restart: (distro) => this.ensureForDistro(distro, this.stateFor(distro)?.codexHomePath),
|
||||
dropState: (state) => {
|
||||
// Why: identity-guarded — a fresh ensure() may own this key by now;
|
||||
// deleting by key alone would orphan its live relay child.
|
||||
@@ -81,11 +86,11 @@ export class WslHookRelayManager {
|
||||
}
|
||||
|
||||
/** Fire-and-forget from every WSL PTY spawn-env build; errors breadcrumb. */
|
||||
ensureForDistro(distro: string | null): void {
|
||||
ensureForDistro(distro: string | null, codexHomePath?: string | null): void {
|
||||
if (this.disposed || !isWslHookRelayAllowed(this.deps)) {
|
||||
return
|
||||
}
|
||||
void this.ensureInternal(distro).catch((err) => {
|
||||
void this.ensureInternal(distro, codexHomePath ?? undefined).catch((err) => {
|
||||
const detail = err instanceof Error ? err.message : String(err)
|
||||
this.deps.warn(`[agent-hooks] WSL hook relay ensure failed: ${detail}`)
|
||||
})
|
||||
@@ -118,7 +123,7 @@ export class WslHookRelayManager {
|
||||
state.mux?.dispose()
|
||||
state.child?.kill()
|
||||
if (!permanent) {
|
||||
this.stoppedByHooksOff.add(state.distro)
|
||||
this.stoppedByHooksOff.set(state.distro, state.codexHomePath)
|
||||
}
|
||||
}
|
||||
this.states.clear()
|
||||
@@ -129,26 +134,39 @@ export class WslHookRelayManager {
|
||||
resumeStoppedRelays(): void {
|
||||
const distros = [...this.stoppedByHooksOff]
|
||||
this.stoppedByHooksOff.clear()
|
||||
for (const distro of distros) {
|
||||
for (const [distro, codexHomePath] of distros) {
|
||||
void this.deps
|
||||
.isDistroRunning(distro)
|
||||
.then((running) => {
|
||||
if (running) {
|
||||
this.ensureForDistro(distro)
|
||||
this.ensureForDistro(distro, codexHomePath)
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureInternal(requestedDistro: string | null): Promise<void> {
|
||||
private async ensureInternal(
|
||||
requestedDistro: string | null,
|
||||
requestedCodexHomePath?: string
|
||||
): Promise<void> {
|
||||
const distro = requestedDistro ?? (await this.resolveDefaultDistro())
|
||||
if (!distro || this.disposed) {
|
||||
return
|
||||
}
|
||||
const key = wslHookRelayStateKey(distro)
|
||||
const existing = this.states.get(key)
|
||||
if (requestedCodexHomePath) {
|
||||
recordManagedWslCodexHome(distro, requestedCodexHomePath)
|
||||
}
|
||||
if (existing) {
|
||||
if (
|
||||
requestedCodexHomePath &&
|
||||
!wslRuntimeHomePathsEqual(existing.codexHomePath, requestedCodexHomePath)
|
||||
) {
|
||||
existing.codexHomePath = requestedCodexHomePath
|
||||
existing.lastInstallAt = 0
|
||||
}
|
||||
if (existing.phase === 'running') {
|
||||
void maybeRerunWslRelayGuestInstall(this.deps, existing)
|
||||
return
|
||||
@@ -184,6 +202,7 @@ export class WslHookRelayManager {
|
||||
// Why: instance-keyed and on the distro's persistent fs, so it outlives a relay
|
||||
// crash — dropping it would blank status on panes spawned mid-relaunch.
|
||||
opencodeOverlayDir: existing?.opencodeOverlayDir,
|
||||
codexHomePath: requestedCodexHomePath ?? existing?.codexHomePath,
|
||||
cooldownUntil: 0
|
||||
}
|
||||
this.states.set(key, state)
|
||||
|
||||
@@ -0,0 +1,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',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -63,6 +63,11 @@ import {
|
||||
BROWSER_ANNOTATION_VIEWPORT_BRIDGE_WORLD_ID,
|
||||
buildBrowserAnnotationViewportBridgeScript
|
||||
} from '../../shared/browser-annotation-viewport-bridge'
|
||||
import {
|
||||
getWorkspaceDocPageGuest,
|
||||
installDocPreviewGuestPolicy,
|
||||
isWorkspaceDocPageId
|
||||
} from './doc-preview-guest-policy'
|
||||
import type { KeybindingOverrides } from '../../shared/keybindings'
|
||||
import {
|
||||
BrowserCertificateTrustController,
|
||||
@@ -157,6 +162,15 @@ type PopupOwnerContext = {
|
||||
browserTabId: string
|
||||
rootGuestWebContentsId: number
|
||||
}
|
||||
/**
|
||||
* What a guest is allowed to be. A browsing guest is the web — popups, clicked-link routing and
|
||||
* anti-detection all apply. A workspace-document guest renders one granted document and gets none
|
||||
* of that; `host` is the renderer that minted its grant, and the only sink for what it reports.
|
||||
*/
|
||||
export type BrowserGuestPolicy =
|
||||
| { profile: 'browsing' }
|
||||
| { profile: 'workspace-doc'; host: Electron.WebContents }
|
||||
const BROWSING_GUEST_POLICY: BrowserGuestPolicy = { profile: 'browsing' }
|
||||
type PendingMainFrameNavigation = {
|
||||
currentUrl: string
|
||||
supersededUrls: string[]
|
||||
@@ -671,12 +685,20 @@ export class BrowserManager {
|
||||
|
||||
attachGuestPolicies(
|
||||
guest: Electron.WebContents,
|
||||
inheritedOwnerContext: PopupOwnerContext | null = null
|
||||
inheritedOwnerContext: PopupOwnerContext | null = null,
|
||||
policy: BrowserGuestPolicy = BROWSING_GUEST_POLICY
|
||||
): void {
|
||||
if (this.policyAttachedGuestIds.has(guest.id)) {
|
||||
return
|
||||
}
|
||||
this.policyAttachedGuestIds.add(guest.id)
|
||||
// Why one door with a profile rather than a second installer beside it: whether a guest was
|
||||
// policy-attached at all is what registration and teardown both key on, so a guest that took
|
||||
// another path into the app is invisible to both.
|
||||
if (policy.profile === 'workspace-doc') {
|
||||
this.attachWorkspaceDocGuestPolicies(guest, policy.host)
|
||||
return
|
||||
}
|
||||
if (inheritedOwnerContext) {
|
||||
this.popupOwnerContextByGuestId.set(guest.id, inheritedOwnerContext)
|
||||
}
|
||||
@@ -1016,6 +1038,30 @@ export class BrowserManager {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* A workspace document is not the web: no popups, no link routing, no anti-detection, and no
|
||||
* navigation bookkeeping for chrome it does not have. What it does share with a browsing guest is
|
||||
* this method's teardown, so a retired preview drops its listeners on the same path.
|
||||
*/
|
||||
private attachWorkspaceDocGuestPolicies(
|
||||
guest: Electron.WebContents,
|
||||
host: Electron.WebContents
|
||||
): void {
|
||||
const disposeDocPolicy = installDocPreviewGuestPolicy(guest, host)
|
||||
const handleDestroyed = (): void => {
|
||||
this.cleanupGuestPolicyAttachment(guest.id)
|
||||
}
|
||||
guest.on('destroyed', handleDestroyed)
|
||||
this.policyCleanupByGuestId.set(guest.id, () => {
|
||||
disposeDocPolicy()
|
||||
try {
|
||||
guest.off('destroyed', handleDestroyed)
|
||||
} catch {
|
||||
// guest may already be destroyed
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Why: navigator.userAgent (read by Google's auth JS) reflects the WebContents UA,
|
||||
// not the request header, so the header-level Firefox switch in setupClientHintsOverride
|
||||
// must be matched here per navigation or the two layers disagree — itself a bot tell.
|
||||
@@ -1318,7 +1364,9 @@ export class BrowserManager {
|
||||
rendererWebContentsId
|
||||
}: BrowserGuestRegistration): boolean {
|
||||
const browserTabId = browserPageId ?? legacyBrowserTabId
|
||||
if (!browserTabId) {
|
||||
// Why refuse rather than overwrite: the two halves of the registry must stay disjoint, or one
|
||||
// id resolves in both and the tool door silently prefers the document guest over the page.
|
||||
if (!browserTabId || isWorkspaceDocPageId(browserTabId)) {
|
||||
return false
|
||||
}
|
||||
// Why: on guest-surface swap, cancel any grab bound to the old guest's listeners so it doesn't strand on a stale webContents.
|
||||
@@ -1377,6 +1425,12 @@ export class BrowserManager {
|
||||
}
|
||||
|
||||
unregisterGuest(browserTabId: string): void {
|
||||
// Why the check on the exit door too: a document page withdraws by revoking its grant, never
|
||||
// through here, so its id arriving is misaddressed — and the cancel below would evict that
|
||||
// preview's live grab on the strength of it.
|
||||
if (isWorkspaceDocPageId(browserTabId)) {
|
||||
return
|
||||
}
|
||||
// Why: teardown mid-grab must cancel it so the renderer gets a signal, not a dangling Promise.
|
||||
this.cancelGrabOp(browserTabId, 'evicted')
|
||||
|
||||
@@ -1444,10 +1498,15 @@ export class BrowserManager {
|
||||
sessionProfileId?: string | null
|
||||
userAgentMode?: BrowserSessionUserAgentMode
|
||||
webContentsId: number
|
||||
}): void {
|
||||
}): boolean {
|
||||
// Why the same check on both registration doors: one id resolving in both halves is the exact
|
||||
// confusion the split registries exist to prevent.
|
||||
if (isWorkspaceDocPageId(browserPageId)) {
|
||||
return false
|
||||
}
|
||||
const guest = webContents.fromId(webContentsId)
|
||||
if (!guest || guest.isDestroyed()) {
|
||||
return
|
||||
return false
|
||||
}
|
||||
// Why: offscreen pages have no renderer webview listeners, so main owns their load-failure lifecycle.
|
||||
this.offscreenGuestIds.add(webContentsId)
|
||||
@@ -1468,6 +1527,7 @@ export class BrowserManager {
|
||||
this.worktreeIdByTabId.set(browserPageId, worktreeId)
|
||||
}
|
||||
this.certificateTrustController?.onGuestRegistered(webContentsId, browserPageId)
|
||||
return true
|
||||
}
|
||||
|
||||
unregisterAll(): void {
|
||||
@@ -1843,12 +1903,13 @@ export class BrowserManager {
|
||||
|
||||
async setAnnotationViewportBridge(
|
||||
browserTabId: string,
|
||||
options: BrowserAnnotationViewportBridgeOptions
|
||||
options: BrowserAnnotationViewportBridgeOptions,
|
||||
resolveGuest: () => Electron.WebContents | null
|
||||
): Promise<boolean> {
|
||||
const prev = this.annotationViewportBridgeOpsByTabId.get(browserTabId) ?? Promise.resolve()
|
||||
const next = prev
|
||||
.catch(() => {})
|
||||
.then(() => this.doSetAnnotationViewportBridgeImpl(browserTabId, options))
|
||||
.then(() => this.doSetAnnotationViewportBridgeImpl(options, resolveGuest))
|
||||
this.annotationViewportBridgeOpsByTabId.set(browserTabId, next)
|
||||
try {
|
||||
return await next
|
||||
@@ -1859,18 +1920,22 @@ export class BrowserManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Why the caller resolves the guest: the same bridge serves browsing pages and workspace
|
||||
// documents, which live in different halves of the page registry.
|
||||
// Why a resolver and not the guest itself: this op may have waited behind another one, and a
|
||||
// cross-process navigation meanwhile swaps the tab's contents without destroying the old one —
|
||||
// injecting into the guest the request named would bridge a page nobody is looking at.
|
||||
// Why no tab id: with teardown gone this reaches only the guest the resolver hands back, and
|
||||
// taking an id it cannot act on would invite the next reader to act on it.
|
||||
private async doSetAnnotationViewportBridgeImpl(
|
||||
browserTabId: string,
|
||||
options: BrowserAnnotationViewportBridgeOptions
|
||||
options: BrowserAnnotationViewportBridgeOptions,
|
||||
resolveGuest: () => Electron.WebContents | null
|
||||
): Promise<boolean> {
|
||||
const webContentsId = this.webContentsIdByTabId.get(browserTabId)
|
||||
if (!webContentsId) {
|
||||
return false
|
||||
}
|
||||
const guest = webContents.fromId(webContentsId)
|
||||
// Why no teardown here: the resolver already unregisters a page whose guest died, and the only
|
||||
// case it uniquely leaves is an ownership mismatch on a healthy page — where tearing down would
|
||||
// cancel that page's in-flight downloads and grabs over a request that was merely misaddressed.
|
||||
const guest = resolveGuest()
|
||||
if (!guest || guest.isDestroyed()) {
|
||||
// Why: a stale guest must clear every per-tab registry entry, not just the WebContents maps.
|
||||
this.unregisterGuest(browserTabId)
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1977,10 +2042,21 @@ export class BrowserManager {
|
||||
// --- Browser Context Grab — main-owned operations ---
|
||||
|
||||
/** Validate that the sender owns browserTabId; returns the guest WebContents or null. */
|
||||
/**
|
||||
* The guest a request from `senderWebContentsId` may act on, across both halves of the page
|
||||
* registry. This is the only door taught about workspace-document guests: they are kept out of
|
||||
* the browsing maps entirely, so page management, agent commands, download routing and
|
||||
* certificate attribution all miss them without a guard of their own — and a reader who opens a
|
||||
* tool on the document in front of them still gets an answer.
|
||||
*/
|
||||
getAuthorizedGuest(
|
||||
browserTabId: string,
|
||||
senderWebContentsId: number
|
||||
): Electron.WebContents | null {
|
||||
const docGuest = getWorkspaceDocPageGuest(browserTabId, senderWebContentsId)
|
||||
if (docGuest) {
|
||||
return docGuest
|
||||
}
|
||||
const registeredRenderer = this.rendererWebContentsIdByTabId.get(browserTabId)
|
||||
if (registeredRenderer == null || registeredRenderer !== senderWebContentsId) {
|
||||
return null
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { BrowserSessionProfile } from '../../shared/browser-workspace-types'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
handleGuestWillDownload: vi.fn(),
|
||||
noticeDocPreviewDownloadBlocked: vi.fn()
|
||||
}))
|
||||
|
||||
type WillDownloadListener = (
|
||||
event: { preventDefault: () => void },
|
||||
item: { id: string },
|
||||
webContents: { id: number }
|
||||
) => void
|
||||
|
||||
type FakeSession = {
|
||||
listeners: WillDownloadListener[]
|
||||
on: ReturnType<typeof vi.fn>
|
||||
removeListener: ReturnType<typeof vi.fn>
|
||||
getUserAgent: () => string
|
||||
setUserAgent: ReturnType<typeof vi.fn>
|
||||
setPermissionRequestHandler: ReturnType<typeof vi.fn>
|
||||
setPermissionCheckHandler: ReturnType<typeof vi.fn>
|
||||
setDisplayMediaRequestHandler: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
const sessionsByPartition = new Map<string, FakeSession>()
|
||||
|
||||
function fakeSession(): FakeSession {
|
||||
const listeners: WillDownloadListener[] = []
|
||||
return {
|
||||
listeners,
|
||||
on: vi.fn((event: string, listener: WillDownloadListener) => {
|
||||
if (event === 'will-download') {
|
||||
listeners.push(listener)
|
||||
}
|
||||
}),
|
||||
removeListener: vi.fn((event: string, listener: WillDownloadListener) => {
|
||||
if (event !== 'will-download') {
|
||||
return
|
||||
}
|
||||
const index = listeners.indexOf(listener)
|
||||
if (index !== -1) {
|
||||
listeners.splice(index, 1)
|
||||
}
|
||||
}),
|
||||
getUserAgent: () => 'Mozilla/5.0 Orca',
|
||||
setUserAgent: vi.fn(),
|
||||
setPermissionRequestHandler: vi.fn(),
|
||||
setPermissionCheckHandler: vi.fn(),
|
||||
setDisplayMediaRequestHandler: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
session: {
|
||||
fromPartition: (partition: string) => {
|
||||
const existing = sessionsByPartition.get(partition)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
const created = fakeSession()
|
||||
sessionsByPartition.set(partition, created)
|
||||
return created
|
||||
}
|
||||
}
|
||||
}))
|
||||
vi.mock('./browser-manager', () => ({
|
||||
browserManager: {
|
||||
handleGuestWillDownload: mocks.handleGuestWillDownload,
|
||||
installCertificateRequestGuard: vi.fn(),
|
||||
removeCertificateRequestGuard: vi.fn(),
|
||||
notifyPermissionDenied: vi.fn()
|
||||
}
|
||||
}))
|
||||
vi.mock('./doc-preview-download-block-notice', () => ({
|
||||
noticeDocPreviewDownloadBlocked: mocks.noticeDocPreviewDownloadBlocked
|
||||
}))
|
||||
vi.mock('./browser-media-access', () => ({
|
||||
hasSystemMediaAccess: () => false,
|
||||
requestSystemMediaAccess: async () => false
|
||||
}))
|
||||
vi.mock('./browser-session-ua', () => ({
|
||||
cleanElectronUserAgent: (userAgent: string) => userAgent,
|
||||
setupClientHintsOverride: vi.fn()
|
||||
}))
|
||||
vi.mock('./browser-session-user-agent-mode', () => ({
|
||||
setBrowserSessionUserAgentMode: vi.fn()
|
||||
}))
|
||||
vi.mock('./browser-webauthn-access', () => ({
|
||||
allowsBrowserWebAuthnPermission: () => false,
|
||||
clearBrowserWebAuthnAccessHandlers: vi.fn(),
|
||||
installBrowserWebAuthnAccessHandlers: vi.fn()
|
||||
}))
|
||||
|
||||
type PartitionPolicyInstaller = (
|
||||
profile: BrowserSessionProfile,
|
||||
options?: { downloads?: 'route' | 'deny' }
|
||||
) => void
|
||||
|
||||
// Why imported per test rather than at the top: the installer remembers which partitions it has
|
||||
// already configured in module state, so a shared import would make the second test's install a
|
||||
// no-op and leave it reading the first test's listener.
|
||||
async function loadInstaller(): Promise<PartitionPolicyInstaller> {
|
||||
const module = await import('./browser-session-partition-policies')
|
||||
return module.installBrowserSessionPartitionPolicies
|
||||
}
|
||||
|
||||
function profileFor(partition: string): BrowserSessionProfile {
|
||||
return {
|
||||
id: partition,
|
||||
scope: 'isolated',
|
||||
partition,
|
||||
label: partition,
|
||||
source: null,
|
||||
userAgentMode: 'clean'
|
||||
}
|
||||
}
|
||||
|
||||
/** Fires the partition's real `will-download` listener and reports what it decided. */
|
||||
function fireWillDownload(partition: string): { cancelled: boolean } {
|
||||
const sess = sessionsByPartition.get(partition)
|
||||
if (!sess || sess.listeners.length !== 1) {
|
||||
throw new Error(`expected exactly one will-download listener on ${partition}`)
|
||||
}
|
||||
let cancelled = false
|
||||
sess.listeners[0]({ preventDefault: () => (cancelled = true) }, { id: 'item-1' }, { id: 42 })
|
||||
return { cancelled }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
sessionsByPartition.clear()
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
describe('partition download policy', () => {
|
||||
// The presence half: without it, a deny assertion passes for a partition that installed no
|
||||
// listener at all, and would keep passing if the whole download path were removed.
|
||||
it('routes a download on a partition that did not ask for the deny', async () => {
|
||||
const install = await loadInstaller()
|
||||
install(profileFor('persist:browsing-1'))
|
||||
|
||||
expect(fireWillDownload('persist:browsing-1').cancelled).toBe(false)
|
||||
expect(mocks.handleGuestWillDownload).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ guestWebContentsId: 42 })
|
||||
)
|
||||
})
|
||||
|
||||
it('cancels a download on a partition that asked for the deny, routing nothing', async () => {
|
||||
const install = await loadInstaller()
|
||||
install(profileFor('orca-doc-preview'), { downloads: 'deny' })
|
||||
|
||||
expect(fireWillDownload('orca-doc-preview').cancelled).toBe(true)
|
||||
expect(mocks.handleGuestWillDownload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Why in the same run as the routing test above: a refusal the reader cannot see is a pressed
|
||||
// button that does nothing, and a notice on the routing partition would announce a download that
|
||||
// is about to arrive normally.
|
||||
it('tells the reader about the refusal, and only on the partition that refused', async () => {
|
||||
const install = await loadInstaller()
|
||||
install(profileFor('orca-doc-preview'), { downloads: 'deny' })
|
||||
install(profileFor('persist:browsing-1'))
|
||||
|
||||
fireWillDownload('persist:browsing-1')
|
||||
expect(mocks.noticeDocPreviewDownloadBlocked).not.toHaveBeenCalled()
|
||||
|
||||
fireWillDownload('orca-doc-preview')
|
||||
expect(mocks.noticeDocPreviewDownloadBlocked).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 42 })
|
||||
)
|
||||
})
|
||||
|
||||
// Why both partitions in one run: the listener is module state shared across sessions, so a deny
|
||||
// installed for one partition must not follow the next partition that installs after it.
|
||||
it('keeps each partition on its own decision', async () => {
|
||||
const install = await loadInstaller()
|
||||
install(profileFor('orca-doc-preview'), { downloads: 'deny' })
|
||||
install(profileFor('persist:browsing-1'))
|
||||
|
||||
expect(fireWillDownload('orca-doc-preview').cancelled).toBe(true)
|
||||
expect(fireWillDownload('persist:browsing-1').cancelled).toBe(false)
|
||||
expect(mocks.handleGuestWillDownload).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
clearBrowserWebAuthnAccessHandlers,
|
||||
installBrowserWebAuthnAccessHandlers
|
||||
} from './browser-webauthn-access'
|
||||
import { noticeDocPreviewDownloadBlocked } from './doc-preview-download-block-notice'
|
||||
|
||||
// Why: one shared installer keeps every partition's deny-by-default permission/download policies from drifting apart.
|
||||
const configuredPartitions = new Set<string>()
|
||||
@@ -22,6 +23,23 @@ const handleWillDownload = (
|
||||
browserManager.handleGuestWillDownload({ guestWebContentsId: webContents.id, item })
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a second listener instead of a branch inside the shared one: `will-download` is a session
|
||||
* event that names no partition, so the only place the decision can be keyed by partition is which
|
||||
* listener that partition's session got. A workspace-document guest has no page of its own to
|
||||
* attribute a download to, so routing one lands it in this desktop's Downloads folder under a
|
||||
* remote-authored name that nothing in the UI accounts for.
|
||||
*/
|
||||
const handleDeniedWillDownload = (
|
||||
event: Electron.Event,
|
||||
_item: Electron.DownloadItem,
|
||||
webContents: Electron.WebContents
|
||||
): void => {
|
||||
event.preventDefault()
|
||||
// The page gets nothing back; the reader gets a sentence, or a pressed button just does nothing.
|
||||
noticeDocPreviewDownloadBlocked(webContents)
|
||||
}
|
||||
|
||||
function resolvePermissionNoticeUrl(
|
||||
webContents: Electron.WebContents,
|
||||
details: Electron.PermissionRequest | undefined
|
||||
@@ -37,7 +55,13 @@ function resolvePermissionNoticeUrl(
|
||||
}
|
||||
}
|
||||
|
||||
export function installBrowserSessionPartitionPolicies(profile: BrowserSessionProfile): void {
|
||||
/** `route` hands the item to the owning page's download flow; `deny` cancels it before it starts. */
|
||||
export type BrowserPartitionDownloadPolicy = 'route' | 'deny'
|
||||
|
||||
export function installBrowserSessionPartitionPolicies(
|
||||
profile: BrowserSessionProfile,
|
||||
options?: { downloads?: BrowserPartitionDownloadPolicy }
|
||||
): void {
|
||||
const { partition } = profile
|
||||
const sess = session.fromPartition(partition)
|
||||
setBrowserSessionUserAgentMode(sess, profile.userAgentMode ?? 'clean')
|
||||
@@ -106,7 +130,11 @@ export function installBrowserSessionPartitionPolicies(profile: BrowserSessionPr
|
||||
callback({ video: undefined, audio: undefined })
|
||||
})
|
||||
sess.removeListener('will-download', handleWillDownload)
|
||||
sess.on('will-download', handleWillDownload)
|
||||
sess.removeListener('will-download', handleDeniedWillDownload)
|
||||
sess.on(
|
||||
'will-download',
|
||||
options?.downloads === 'deny' ? handleDeniedWillDownload : handleWillDownload
|
||||
)
|
||||
configuredPartitions.add(partition)
|
||||
}
|
||||
|
||||
@@ -115,6 +143,7 @@ export function clearBrowserSessionPartitionPolicies(partition: string, sess: Se
|
||||
configuredPartitions.delete(partition)
|
||||
browserManager.removeCertificateRequestGuard(sess)
|
||||
sess.removeListener('will-download', handleWillDownload)
|
||||
sess.removeListener('will-download', handleDeniedWillDownload)
|
||||
clearBrowserWebAuthnAccessHandlers(sess)
|
||||
sess.setPermissionRequestHandler(null)
|
||||
sess.setPermissionCheckHandler(null)
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
publishDocPreviewFailure: vi.fn(),
|
||||
boundGrantIdByGuest: new Map<object, string>()
|
||||
}))
|
||||
|
||||
vi.mock('./doc-preview-failure-notice', () => ({
|
||||
publishDocPreviewFailure: mocks.publishDocPreviewFailure
|
||||
}))
|
||||
vi.mock('./doc-preview-guest-policy', () => ({
|
||||
readDocPreviewGuestBoundGrantId: (guest: object) => mocks.boundGrantIdByGuest.get(guest) ?? null
|
||||
}))
|
||||
|
||||
const GRANT_ID = 'a'.repeat(32)
|
||||
const OTHER_GRANT_ID = 'b'.repeat(32)
|
||||
|
||||
/** Only the identity matters: the module asks the guest registry what grant this contents holds. */
|
||||
function guestBoundTo(grantId: string | null): Electron.WebContents {
|
||||
const guest = {} as Electron.WebContents
|
||||
if (grantId !== null) {
|
||||
mocks.boundGrantIdByGuest.set(guest, grantId)
|
||||
}
|
||||
return guest
|
||||
}
|
||||
|
||||
async function loadNotifier(): Promise<(guest: Electron.WebContents) => void> {
|
||||
const module = await import('./doc-preview-download-block-notice')
|
||||
return module.noticeDocPreviewDownloadBlocked
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.boundGrantIdByGuest.clear()
|
||||
// Why per test: the module remembers which grants it has already told the reader about.
|
||||
vi.resetModules()
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-08-27T00:00:00.000Z'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('noticeDocPreviewDownloadBlocked', () => {
|
||||
it('tells the shell which preview had a download refused', async () => {
|
||||
const notice = await loadNotifier()
|
||||
|
||||
notice(guestBoundTo(GRANT_ID))
|
||||
|
||||
expect(mocks.publishDocPreviewFailure).toHaveBeenCalledWith({
|
||||
grantId: GRANT_ID,
|
||||
reason: 'download-blocked'
|
||||
})
|
||||
})
|
||||
|
||||
// Why: a document can ask in a loop. The reader learns nothing from the second notice, and every
|
||||
// attempt would otherwise cross the IPC boundary and re-render the strip.
|
||||
it('says it once however often the document asks', async () => {
|
||||
const notice = await loadNotifier()
|
||||
const guest = guestBoundTo(GRANT_ID)
|
||||
|
||||
notice(guest)
|
||||
notice(guest)
|
||||
vi.advanceTimersByTime(1_500)
|
||||
notice(guest)
|
||||
|
||||
expect(mocks.publishDocPreviewFailure).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('says it again for an attempt long after the last one', async () => {
|
||||
const notice = await loadNotifier()
|
||||
const guest = guestBoundTo(GRANT_ID)
|
||||
|
||||
notice(guest)
|
||||
vi.advanceTimersByTime(2_500)
|
||||
notice(guest)
|
||||
|
||||
expect(mocks.publishDocPreviewFailure).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
// Why not one throttle for the whole app: two previews are two readers, and silencing the second
|
||||
// because the first just refused something leaves that press unexplained.
|
||||
it('throttles each preview on its own', async () => {
|
||||
const notice = await loadNotifier()
|
||||
|
||||
notice(guestBoundTo(GRANT_ID))
|
||||
notice(guestBoundTo(OTHER_GRANT_ID))
|
||||
|
||||
expect(mocks.publishDocPreviewFailure).toHaveBeenCalledTimes(2)
|
||||
expect(mocks.publishDocPreviewFailure).toHaveBeenLastCalledWith({
|
||||
grantId: OTHER_GRANT_ID,
|
||||
reason: 'download-blocked'
|
||||
})
|
||||
})
|
||||
|
||||
// The absence half of the first test: no shell is showing this contents, so there is no preview
|
||||
// to put a notice on. Without the presence tests above, this would pass on a module that never
|
||||
// published anything at all.
|
||||
it('says nothing for a contents no preview is bound to', async () => {
|
||||
const notice = await loadNotifier()
|
||||
|
||||
notice(guestBoundTo(null))
|
||||
|
||||
expect(mocks.publishDocPreviewFailure).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
import { publishDocPreviewFailure } from './doc-preview-failure-notice'
|
||||
import { readDocPreviewGuestBoundGrantId } from './doc-preview-guest-policy'
|
||||
|
||||
/**
|
||||
* Why the reader is told at all, when the point of the fence is that nothing happens: a press that
|
||||
* produces no file and no explanation reads as Orca being broken. The notice is chrome the document
|
||||
* cannot see or read back, so the refusal stays as silent to the page as it was.
|
||||
*
|
||||
* Why a floor between notices: a document can ask in a loop, and every attempt would otherwise
|
||||
* cross the IPC boundary and re-render the strip. The reader learns nothing from the thousandth.
|
||||
*/
|
||||
const NOTICE_MIN_INTERVAL_MS = 2_000
|
||||
const noticedAtByGrantId = new Map<string, number>()
|
||||
|
||||
export function noticeDocPreviewDownloadBlocked(guest: Electron.WebContents): void {
|
||||
const grantId = readDocPreviewGuestBoundGrantId(guest)
|
||||
// Nothing to route a notice to: no shell is showing this contents as a preview.
|
||||
if (grantId === null) {
|
||||
return
|
||||
}
|
||||
const now = Date.now()
|
||||
// Doubles as the expiry check, so what survives is exactly "noticed inside the window", and a
|
||||
// grant that stops downloading stops being remembered.
|
||||
for (const [noticedGrantId, noticedAt] of noticedAtByGrantId) {
|
||||
if (now - noticedAt >= NOTICE_MIN_INTERVAL_MS) {
|
||||
noticedAtByGrantId.delete(noticedGrantId)
|
||||
}
|
||||
}
|
||||
if (noticedAtByGrantId.has(grantId)) {
|
||||
return
|
||||
}
|
||||
noticedAtByGrantId.set(grantId, now)
|
||||
publishDocPreviewFailure({ grantId, reason: 'download-blocked' })
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { DOC_PREVIEW_LOAD_FAILURE_CHANNEL } from '../../shared/doc-preview-scheme'
|
||||
import { publishDocPreviewFailure, setDocPreviewFailureSink } from './doc-preview-failure-notice'
|
||||
|
||||
afterEach(() => {
|
||||
setDocPreviewFailureSink(null)
|
||||
})
|
||||
|
||||
describe('publishDocPreviewFailure', () => {
|
||||
it('sends the grant, path, and reason on the failure channel', () => {
|
||||
const send = vi.fn()
|
||||
setDocPreviewFailureSink({ send })
|
||||
|
||||
publishDocPreviewFailure({
|
||||
grantId: 'a'.repeat(32),
|
||||
relativePath: 'index.html',
|
||||
reason: 'too-large'
|
||||
})
|
||||
|
||||
expect(send).toHaveBeenCalledWith(DOC_PREVIEW_LOAD_FAILURE_CHANNEL, {
|
||||
grantId: 'a'.repeat(32),
|
||||
relativePath: 'index.html',
|
||||
reason: 'too-large'
|
||||
})
|
||||
})
|
||||
|
||||
// Why: reads can outlive the window that asked for them; a missing sink must not throw inside
|
||||
// the protocol handler.
|
||||
it('is a no-op with no sink registered', () => {
|
||||
expect(() =>
|
||||
publishDocPreviewFailure({
|
||||
grantId: 'b'.repeat(32),
|
||||
relativePath: 'index.html',
|
||||
reason: 'unreadable'
|
||||
})
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it('sends nothing into a destroyed window', () => {
|
||||
const send = vi.fn()
|
||||
setDocPreviewFailureSink({ send, isDestroyed: () => true })
|
||||
|
||||
publishDocPreviewFailure({
|
||||
grantId: 'c'.repeat(32),
|
||||
relativePath: 'index.html',
|
||||
reason: 'unreadable'
|
||||
})
|
||||
|
||||
expect(send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Why: a WebContents can be torn down between the liveness check and the send.
|
||||
it('swallows a throwing sink and stops using it', () => {
|
||||
const send = vi.fn(() => {
|
||||
throw new Error('Object has been destroyed')
|
||||
})
|
||||
setDocPreviewFailureSink({ send })
|
||||
const failure = {
|
||||
grantId: 'd'.repeat(32),
|
||||
relativePath: 'index.html',
|
||||
reason: 'unreadable' as const
|
||||
}
|
||||
|
||||
expect(() => publishDocPreviewFailure(failure)).not.toThrow()
|
||||
publishDocPreviewFailure(failure)
|
||||
|
||||
expect(send).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
import {
|
||||
DOC_PREVIEW_LOAD_FAILURE_CHANNEL,
|
||||
type DocPreviewFailure
|
||||
} from '../../shared/doc-preview-scheme'
|
||||
|
||||
type DocPreviewFailureSink = {
|
||||
send: (channel: string, payload: DocPreviewFailure) => void
|
||||
isDestroyed?: () => boolean
|
||||
}
|
||||
|
||||
let failureSink: DocPreviewFailureSink | null = null
|
||||
|
||||
export function setDocPreviewFailureSink(sink: DocPreviewFailureSink | null): void {
|
||||
failureSink = sink
|
||||
}
|
||||
|
||||
/**
|
||||
* The preview shell cannot read the guest's HTTP status, and a 4xx body renders as
|
||||
* if it were the document. Pushing the reason lets the shell replace that with a
|
||||
* localized notice for the failure the user actually hit.
|
||||
*/
|
||||
export function publishDocPreviewFailure(failure: DocPreviewFailure): void {
|
||||
const sink = failureSink
|
||||
// Why: a read can outlive the window that asked for it, and sending into torn-down
|
||||
// WebContents throws — an unreadable asset must not take the protocol handler with it.
|
||||
if (!sink || sink.isDestroyed?.()) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
sink.send(DOC_PREVIEW_LOAD_FAILURE_CHANNEL, failure)
|
||||
} catch {
|
||||
failureSink = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
callRuntimeEnvironment: vi.fn(),
|
||||
readFile: vi.fn(),
|
||||
realpath: vi.fn(),
|
||||
requireSshFilesystemProvider: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../ipc/runtime-environment-transport-routing', () => ({
|
||||
callRuntimeEnvironment: mocks.callRuntimeEnvironment
|
||||
}))
|
||||
vi.mock('../persistence', () => ({ getCanonicalUserDataPath: () => '/user-data' }))
|
||||
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
|
||||
requireSshFilesystemProvider: mocks.requireSshFilesystemProvider
|
||||
}))
|
||||
|
||||
import { FileReadCapExceededError } from '../ssh/ssh-filesystem-stream-reader'
|
||||
import { docPreviewContentType, readDocPreviewFile } from './doc-preview-file-reader'
|
||||
import { mintDocPreviewGrant, revokeAllDocPreviewGrants } from './doc-preview-grant-registry'
|
||||
|
||||
function sshGrant(): ReturnType<typeof mintDocPreviewGrant> {
|
||||
return mintDocPreviewGrant({
|
||||
owner: { kind: 'ssh', connectionId: 'ssh-1' },
|
||||
root: '/home/alice/docs',
|
||||
entryRelativePath: 'index.html',
|
||||
browserPageId: 'page-1'
|
||||
})
|
||||
}
|
||||
|
||||
function runtimeGrant(root = '/srv/repo/docs'): ReturnType<typeof mintDocPreviewGrant> {
|
||||
return mintDocPreviewGrant({
|
||||
owner: {
|
||||
kind: 'runtime',
|
||||
environmentId: 'env-1',
|
||||
worktreeSelector: 'id:wt-1',
|
||||
worktreeRoot: '/srv/repo'
|
||||
},
|
||||
root,
|
||||
entryRelativePath: 'index.html',
|
||||
browserPageId: 'page-1'
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
revokeAllDocPreviewGrants()
|
||||
// Why: an unsymlinked host canonicalizes to the path it was given.
|
||||
mocks.realpath.mockImplementation((path: string) => Promise.resolve(path))
|
||||
mocks.requireSshFilesystemProvider.mockReturnValue({
|
||||
readFile: mocks.readFile,
|
||||
realpath: mocks.realpath
|
||||
})
|
||||
})
|
||||
|
||||
describe('docPreviewContentType', () => {
|
||||
it('maps document and asset extensions, defaulting to octet-stream', () => {
|
||||
expect(docPreviewContentType('index.html')).toBe('text/html; charset=utf-8')
|
||||
expect(docPreviewContentType('assets/app.CSS')).toBe('text/css; charset=utf-8')
|
||||
expect(docPreviewContentType('assets/logo.png')).toBe('image/png')
|
||||
expect(docPreviewContentType('data.bin')).toBe('application/octet-stream')
|
||||
})
|
||||
})
|
||||
|
||||
describe('readDocPreviewFile — ssh owner', () => {
|
||||
it('reads text through the SSH filesystem provider', async () => {
|
||||
mocks.readFile.mockResolvedValue({ content: '<h1>hi</h1>', isBinary: false })
|
||||
|
||||
const outcome = await readDocPreviewFile(sshGrant(), 'index.html')
|
||||
|
||||
expect(mocks.requireSshFilesystemProvider).toHaveBeenCalledWith('ssh-1')
|
||||
expect(mocks.readFile).toHaveBeenCalledWith('/home/alice/docs/index.html')
|
||||
expect(outcome).toEqual({
|
||||
ok: true,
|
||||
bytes: Buffer.from('<h1>hi</h1>', 'utf8'),
|
||||
contentType: 'text/html; charset=utf-8'
|
||||
})
|
||||
})
|
||||
|
||||
it('decodes a base64 binary asset', async () => {
|
||||
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47])
|
||||
mocks.readFile.mockResolvedValue({ content: png.toString('base64'), isBinary: true })
|
||||
|
||||
const outcome = await readDocPreviewFile(sshGrant(), 'assets/logo.png')
|
||||
|
||||
expect(outcome).toEqual({ ok: true, bytes: png, contentType: 'image/png' })
|
||||
})
|
||||
|
||||
// Why: the SSH reader rejects an over-cap file rather than clamping it, so a completed read is
|
||||
// always whole and needs no truncation flag.
|
||||
it('serves a whole SSH read that carries no truncation flag', async () => {
|
||||
mocks.readFile.mockResolvedValue({ content: '<h1>whole</h1>', isBinary: false })
|
||||
|
||||
expect(await readDocPreviewFile(sshGrant(), 'index.html')).toMatchObject({ ok: true })
|
||||
})
|
||||
|
||||
// Why: the SSH read path only serves images and PDFs as bytes, so a font is refused there by
|
||||
// design — the failure must name the file type, not a stale server.
|
||||
it('reports a file type the host will not send as unsupported-asset', async () => {
|
||||
mocks.readFile.mockResolvedValue({ content: '', isBinary: true })
|
||||
|
||||
const outcome = await readDocPreviewFile(sshGrant(), 'assets/font.woff2')
|
||||
|
||||
expect(outcome).toMatchObject({ ok: false, status: 415, reason: 'unsupported-asset' })
|
||||
})
|
||||
|
||||
// Why: a host that still named the type read a 0-byte file, so 0 bytes is the honest answer —
|
||||
// reporting it as a refused format would be a failure the workspace never reported.
|
||||
it('serves an empty file the host still typed instead of calling it unsupported', async () => {
|
||||
mocks.readFile.mockResolvedValue({ content: '', isBinary: true, mimeType: 'image/png' })
|
||||
|
||||
const outcome = await readDocPreviewFile(sshGrant(), 'assets/logo.png')
|
||||
|
||||
expect(outcome).toEqual({ ok: true, bytes: Buffer.alloc(0), contentType: 'image/png' })
|
||||
})
|
||||
|
||||
// Why: containment above is lexical, and the SSH read RPC enforces no root of its own, so a
|
||||
// symlink inside the grant would otherwise read anything the account can reach.
|
||||
it('404s a path that canonicalizes outside the grant root', async () => {
|
||||
mocks.realpath.mockImplementation((path: string) =>
|
||||
Promise.resolve(path === '/home/alice/docs/escape.html' ? '/etc/shadow' : path)
|
||||
)
|
||||
|
||||
const outcome = await readDocPreviewFile(sshGrant(), 'escape.html')
|
||||
|
||||
expect(outcome).toMatchObject({ ok: false, status: 404 })
|
||||
expect(mocks.readFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reads the canonical path once containment holds', async () => {
|
||||
mocks.realpath.mockImplementation((path: string) =>
|
||||
Promise.resolve(path === '/home/alice/docs/link.html' ? '/home/alice/docs/real.html' : path)
|
||||
)
|
||||
mocks.readFile.mockResolvedValue({ content: '<h1>real</h1>', isBinary: false })
|
||||
|
||||
expect(await readDocPreviewFile(sshGrant(), 'link.html')).toMatchObject({ ok: true })
|
||||
expect(mocks.readFile).toHaveBeenCalledWith('/home/alice/docs/real.html')
|
||||
})
|
||||
|
||||
// Why: a symlinked root is legitimate; containment must be judged on what both sides resolve to.
|
||||
it('keeps serving a grant whose own root is a symlink', async () => {
|
||||
mocks.realpath.mockImplementation((path: string) =>
|
||||
Promise.resolve(path.replace('/home/alice/docs', '/mnt/data/docs'))
|
||||
)
|
||||
mocks.readFile.mockResolvedValue({ content: '<h1>hi</h1>', isBinary: false })
|
||||
|
||||
expect(await readDocPreviewFile(sshGrant(), 'index.html')).toMatchObject({ ok: true })
|
||||
expect(mocks.readFile).toHaveBeenCalledWith('/mnt/data/docs/index.html')
|
||||
})
|
||||
|
||||
it('404s when the host cannot canonicalize the path at all', async () => {
|
||||
mocks.realpath.mockRejectedValue(new Error('no such file'))
|
||||
|
||||
expect(await readDocPreviewFile(sshGrant(), 'index.html')).toMatchObject({
|
||||
ok: false,
|
||||
status: 404
|
||||
})
|
||||
expect(mocks.readFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('404s a path outside the grant root without touching the provider', async () => {
|
||||
const outcome = await readDocPreviewFile(sshGrant(), '../../etc/passwd')
|
||||
|
||||
expect(outcome).toMatchObject({ ok: false, status: 404 })
|
||||
expect(mocks.requireSshFilesystemProvider).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports an over-cap SSH file as too large rather than unreadable', async () => {
|
||||
mocks.readFile.mockRejectedValue(new FileReadCapExceededError('exceeds client cap'))
|
||||
|
||||
expect(await readDocPreviewFile(sshGrant(), 'huge.html')).toMatchObject({
|
||||
ok: false,
|
||||
status: 413
|
||||
})
|
||||
})
|
||||
|
||||
it('404s when the provider read fails', async () => {
|
||||
mocks.readFile.mockRejectedValue(new Error('no such file'))
|
||||
|
||||
expect(await readDocPreviewFile(sshGrant(), 'missing.html')).toMatchObject({
|
||||
ok: false,
|
||||
status: 404
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('readDocPreviewFile — paired runtime owner', () => {
|
||||
it('reads text over worktree-relative files.read', async () => {
|
||||
mocks.callRuntimeEnvironment.mockResolvedValue({
|
||||
ok: true,
|
||||
result: { content: '<h1>remote</h1>', truncated: false, byteLength: 15 }
|
||||
})
|
||||
|
||||
const outcome = await readDocPreviewFile(runtimeGrant(), 'index.html')
|
||||
|
||||
expect(mocks.callRuntimeEnvironment).toHaveBeenCalledWith(
|
||||
'/user-data',
|
||||
'env-1',
|
||||
'files.read',
|
||||
{ worktree: 'id:wt-1', relativePath: 'docs/index.html' },
|
||||
15_000
|
||||
)
|
||||
expect(outcome).toEqual({
|
||||
ok: true,
|
||||
bytes: Buffer.from('<h1>remote</h1>', 'utf8'),
|
||||
contentType: 'text/html; charset=utf-8'
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to the base64 preview RPC for a binary asset', async () => {
|
||||
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47])
|
||||
mocks.callRuntimeEnvironment
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
error: { code: 'runtime_error', message: 'binary_file' }
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
result: {
|
||||
content: png.toString('base64'),
|
||||
isBinary: true,
|
||||
isImage: true,
|
||||
mimeType: 'image/png'
|
||||
}
|
||||
})
|
||||
|
||||
const outcome = await readDocPreviewFile(runtimeGrant(), 'assets/logo.png')
|
||||
|
||||
expect(mocks.callRuntimeEnvironment).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/user-data',
|
||||
'env-1',
|
||||
'files.readPreview',
|
||||
{ worktree: 'id:wt-1', relativePath: 'docs/assets/logo.png' },
|
||||
15_000
|
||||
)
|
||||
expect(outcome).toEqual({ ok: true, bytes: png, contentType: 'image/png' })
|
||||
})
|
||||
|
||||
it('degrades on an old server whose empty binary preview carries no metadata', async () => {
|
||||
mocks.callRuntimeEnvironment
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
error: { code: 'runtime_error', message: 'binary_file' }
|
||||
})
|
||||
.mockResolvedValueOnce({ ok: true, result: { content: '', isBinary: true } })
|
||||
|
||||
expect(await readDocPreviewFile(runtimeGrant(), 'assets/logo.png')).toMatchObject({
|
||||
ok: false,
|
||||
status: 415,
|
||||
reason: 'unsupported-asset'
|
||||
})
|
||||
})
|
||||
|
||||
it('serves an empty paired asset the host typed rather than reporting a refusal', async () => {
|
||||
mocks.callRuntimeEnvironment
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
error: { code: 'runtime_error', message: 'binary_file' }
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
result: { content: '', isBinary: true, isImage: true, mimeType: 'image/png' }
|
||||
})
|
||||
|
||||
expect(await readDocPreviewFile(runtimeGrant(), 'assets/logo.png')).toEqual({
|
||||
ok: true,
|
||||
bytes: Buffer.alloc(0),
|
||||
contentType: 'image/png'
|
||||
})
|
||||
})
|
||||
|
||||
// Why: files.read clamps text at the host cap and only says so in `truncated`; serving the
|
||||
// clamped bytes renders a document that silently stops halfway.
|
||||
it('refuses a truncated text read instead of serving the clamped bytes', async () => {
|
||||
mocks.callRuntimeEnvironment.mockResolvedValue({
|
||||
ok: true,
|
||||
result: { content: '<h1>half of', truncated: true, byteLength: 40_000_000 }
|
||||
})
|
||||
|
||||
const outcome = await readDocPreviewFile(runtimeGrant(), 'index.html')
|
||||
|
||||
expect(outcome).toMatchObject({ ok: false, status: 413 })
|
||||
expect(outcome).not.toMatchObject({ ok: true })
|
||||
})
|
||||
|
||||
it('serves a read the host reports as complete', async () => {
|
||||
mocks.callRuntimeEnvironment.mockResolvedValue({
|
||||
ok: true,
|
||||
result: { content: '<h1>all</h1>', truncated: false, byteLength: 12 }
|
||||
})
|
||||
|
||||
expect(await readDocPreviewFile(runtimeGrant(), 'index.html')).toMatchObject({ ok: true })
|
||||
})
|
||||
|
||||
// Why: the binary RPC has no `truncated` field — it rejects an over-cap asset with this error.
|
||||
it('reports the host rejecting an over-cap binary as too large', async () => {
|
||||
mocks.callRuntimeEnvironment
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
error: { code: 'runtime_error', message: 'binary_file' }
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
error: { code: 'runtime_error', message: 'file_too_large' }
|
||||
})
|
||||
|
||||
expect(await readDocPreviewFile(runtimeGrant(), 'assets/huge.png')).toMatchObject({
|
||||
ok: false,
|
||||
status: 413
|
||||
})
|
||||
})
|
||||
|
||||
it('does not treat an unrelated RPC failure as a binary fallback', async () => {
|
||||
mocks.callRuntimeEnvironment.mockResolvedValue({
|
||||
ok: false,
|
||||
error: { code: 'runtime_error', message: 'permission_denied' }
|
||||
})
|
||||
|
||||
expect(await readDocPreviewFile(runtimeGrant(), 'index.html')).toMatchObject({
|
||||
ok: false,
|
||||
status: 404
|
||||
})
|
||||
expect(mocks.callRuntimeEnvironment).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('404s a document outside the worktree, which files.read cannot address', async () => {
|
||||
const outcome = await readDocPreviewFile(runtimeGrant('/tmp/agent-docs'), 'index.html')
|
||||
|
||||
expect(outcome).toMatchObject({ ok: false, status: 404 })
|
||||
expect(mocks.callRuntimeEnvironment).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,198 @@
|
||||
import { extname } from 'node:path'
|
||||
import type {
|
||||
RuntimeFilePreviewResult,
|
||||
RuntimeFileReadResult
|
||||
} from '../../shared/runtime-file-contracts'
|
||||
import type { DocPreviewFileFailureReason } from '../../shared/doc-preview-scheme'
|
||||
import { callRuntimeEnvironment } from '../ipc/runtime-environment-transport-routing'
|
||||
import { FileReadCapExceededError } from '../ssh/ssh-filesystem-stream-reader'
|
||||
import { getCanonicalUserDataPath } from '../persistence'
|
||||
import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
|
||||
import {
|
||||
resolveCanonicalDocPreviewPath,
|
||||
resolveDocPreviewTargetPath,
|
||||
toRuntimeWorktreeRelativePath,
|
||||
type DocPreviewGrant
|
||||
} from './doc-preview-grant-registry'
|
||||
|
||||
const DOC_PREVIEW_READ_TIMEOUT_MS = 15_000
|
||||
|
||||
/** Why not "needs a newer server": the SSH read path only ever serves images and PDFs as bytes, so
|
||||
* a font is refused there by design, not by version. Name the file type, not the host's age. */
|
||||
const UNSERVABLE_ASSET_PREVIEW_MESSAGE = 'This workspace cannot send this file type to a preview.'
|
||||
|
||||
/** `files.read` clamps text at the host's cap and reports it; serving the clamped bytes would
|
||||
* render a silently half-finished document. */
|
||||
const TRUNCATED_PREVIEW_MESSAGE = 'This document is too large for the server to send in full.'
|
||||
|
||||
/** The paired host rejects an over-cap asset outright instead of clamping it. */
|
||||
const RUNTIME_TOO_LARGE_ERROR = 'file_too_large'
|
||||
|
||||
/** Both owners refuse an over-cap file; only their error shapes differ. */
|
||||
function isTooLargeReadError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof FileReadCapExceededError ||
|
||||
(error instanceof Error && error.message === RUNTIME_TOO_LARGE_ERROR)
|
||||
)
|
||||
}
|
||||
|
||||
export type DocPreviewReadOutcome =
|
||||
| { ok: true; bytes: Buffer; contentType: string }
|
||||
| { ok: false; status: number; reason: DocPreviewFileFailureReason; message: string }
|
||||
|
||||
const DOC_PREVIEW_CONTENT_TYPES: Record<string, string> = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.htm': 'text/html; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.mjs': 'text/javascript; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.txt': 'text/plain; charset=utf-8',
|
||||
'.md': 'text/plain; charset=utf-8',
|
||||
'.csv': 'text/plain; charset=utf-8',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
'.bmp': 'image/bmp',
|
||||
'.ico': 'image/x-icon',
|
||||
'.pdf': 'application/pdf',
|
||||
'.woff': 'font/woff',
|
||||
'.woff2': 'font/woff2',
|
||||
'.ttf': 'font/ttf',
|
||||
'.otf': 'font/otf'
|
||||
}
|
||||
|
||||
export function docPreviewContentType(relativePath: string): string {
|
||||
return (
|
||||
DOC_PREVIEW_CONTENT_TYPES[extname(relativePath).toLowerCase()] ?? 'application/octet-stream'
|
||||
)
|
||||
}
|
||||
|
||||
type PreviewFileBytes = {
|
||||
content: string
|
||||
isBinary: boolean
|
||||
truncated?: boolean
|
||||
/** Set by every owner that agreed to serve the bytes, so it also survives a 0-byte file. */
|
||||
mimeType?: string
|
||||
}
|
||||
|
||||
function toOutcome(source: PreviewFileBytes, contentType: string): DocPreviewReadOutcome {
|
||||
if (source.truncated) {
|
||||
return { ok: false, status: 413, reason: 'too-large', message: TRUNCATED_PREVIEW_MESSAGE }
|
||||
}
|
||||
if (!source.isBinary) {
|
||||
return { ok: true, bytes: Buffer.from(source.content, 'utf8'), contentType }
|
||||
}
|
||||
if (source.content) {
|
||||
return { ok: true, bytes: Buffer.from(source.content, 'base64'), contentType }
|
||||
}
|
||||
// Why: an empty binary body is two different answers. A host that still named the file's type
|
||||
// read a 0-byte file, and 0 bytes is what it should serve; a host that named no type declined
|
||||
// the format outright and has nothing to send.
|
||||
return source.mimeType
|
||||
? { ok: true, bytes: Buffer.alloc(0), contentType }
|
||||
: {
|
||||
ok: false,
|
||||
status: 415,
|
||||
reason: 'unsupported-asset',
|
||||
message: UNSERVABLE_ASSET_PREVIEW_MESSAGE
|
||||
}
|
||||
}
|
||||
|
||||
async function readRuntimeDocPreviewFile(
|
||||
environmentId: string,
|
||||
worktreeSelector: string,
|
||||
relativePath: string
|
||||
): Promise<PreviewFileBytes> {
|
||||
const userDataPath = getCanonicalUserDataPath()
|
||||
const response = await callRuntimeEnvironment(
|
||||
userDataPath,
|
||||
environmentId,
|
||||
'files.read',
|
||||
{ worktree: worktreeSelector, relativePath },
|
||||
DOC_PREVIEW_READ_TIMEOUT_MS
|
||||
)
|
||||
if (response.ok) {
|
||||
const result = response.result as RuntimeFileReadResult
|
||||
return { content: result.content, isBinary: false, truncated: result.truncated === true }
|
||||
}
|
||||
// Why: files.read rejects binaries with a typed error; the base64 preview RPC serves
|
||||
// images and fonts the same way it does for markdown previews. Match the exact
|
||||
// message so an unrelated failure can't spoof the fallback.
|
||||
if (response.error.message !== 'binary_file') {
|
||||
throw new Error(response.error.message)
|
||||
}
|
||||
const previewResponse = await callRuntimeEnvironment(
|
||||
userDataPath,
|
||||
environmentId,
|
||||
'files.readPreview',
|
||||
{ worktree: worktreeSelector, relativePath },
|
||||
DOC_PREVIEW_READ_TIMEOUT_MS
|
||||
)
|
||||
if (!previewResponse.ok) {
|
||||
throw new Error(previewResponse.error.message)
|
||||
}
|
||||
const preview = previewResponse.result as RuntimeFilePreviewResult
|
||||
// Why: readPreview never clamps — it rejects an over-cap asset — so its body is whole or absent.
|
||||
return {
|
||||
content: preview.content,
|
||||
isBinary: preview.isBinary,
|
||||
...(preview.mimeType ? { mimeType: preview.mimeType } : {})
|
||||
}
|
||||
}
|
||||
|
||||
function notFoundOutcome(message = 'Not found'): DocPreviewReadOutcome {
|
||||
return { ok: false, status: 404, reason: 'unreadable', message }
|
||||
}
|
||||
|
||||
/** Reads one in-grant path over the same channel the editor uses for that owner. */
|
||||
export async function readDocPreviewFile(
|
||||
grant: DocPreviewGrant,
|
||||
relativePath: string
|
||||
): Promise<DocPreviewReadOutcome> {
|
||||
const absolutePath = resolveDocPreviewTargetPath(grant, relativePath)
|
||||
if (!absolutePath) {
|
||||
return notFoundOutcome()
|
||||
}
|
||||
const contentType = docPreviewContentType(relativePath)
|
||||
try {
|
||||
if (grant.owner.kind === 'ssh') {
|
||||
const provider = requireSshFilesystemProvider(grant.owner.connectionId)
|
||||
// Why: the SSH read RPC enforces no root of its own, so containment has to survive a symlink
|
||||
// before the read — the lexical check above only proves the requested path looked contained.
|
||||
const canonicalPath = await resolveCanonicalDocPreviewPath(grant, absolutePath, (path) =>
|
||||
provider.realpath(path)
|
||||
)
|
||||
if (!canonicalPath) {
|
||||
return notFoundOutcome()
|
||||
}
|
||||
// Why: the SSH reader rejects an over-cap file outright, so its result is never partial.
|
||||
return toOutcome(await provider.readFile(canonicalPath), contentType)
|
||||
}
|
||||
const worktreeRelativePath = toRuntimeWorktreeRelativePath(
|
||||
grant.owner.worktreeRoot,
|
||||
absolutePath
|
||||
)
|
||||
if (!worktreeRelativePath) {
|
||||
// Why: files.read is worktree-scoped, so a doc outside the worktree has no client-side channel.
|
||||
return notFoundOutcome()
|
||||
}
|
||||
// Why no realpath pass here: the host resolves this path through resolveAuthorizedPath, which
|
||||
// canonicalizes and re-checks the worktree root server-side before reading.
|
||||
return toOutcome(
|
||||
await readRuntimeDocPreviewFile(
|
||||
grant.owner.environmentId,
|
||||
grant.owner.worktreeSelector,
|
||||
worktreeRelativePath
|
||||
),
|
||||
contentType
|
||||
)
|
||||
} catch (error) {
|
||||
return isTooLargeReadError(error)
|
||||
? { ok: false, status: 413, reason: 'too-large', message: TRUNCATED_PREVIEW_MESSAGE }
|
||||
: notFoundOutcome(error instanceof Error ? error.message : undefined)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
getDocPreviewGrant,
|
||||
mintDocPreviewGrant,
|
||||
resolveCanonicalDocPreviewPath,
|
||||
resolveDocPreviewTargetPath,
|
||||
revokeAllDocPreviewGrants,
|
||||
revokeDocPreviewGrant,
|
||||
toRuntimeWorktreeRelativePath,
|
||||
type DocPreviewGrant
|
||||
} from './doc-preview-grant-registry'
|
||||
|
||||
const sshOwner = { kind: 'ssh', connectionId: 'ssh-1' } as const
|
||||
|
||||
function mintPosixGrant(root = '/srv/repo/docs'): DocPreviewGrant {
|
||||
return mintDocPreviewGrant({
|
||||
owner: sshOwner,
|
||||
root,
|
||||
entryRelativePath: 'index.html',
|
||||
browserPageId: 'page-1'
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
revokeAllDocPreviewGrants()
|
||||
})
|
||||
|
||||
describe('doc preview grants', () => {
|
||||
it('mints unguessable ids and looks them up', () => {
|
||||
const first = mintPosixGrant()
|
||||
const second = mintPosixGrant()
|
||||
|
||||
expect(first.id).toMatch(/^[0-9a-f]{32}$/)
|
||||
expect(first.id).not.toBe(second.id)
|
||||
expect(getDocPreviewGrant(first.id)).toBe(first)
|
||||
})
|
||||
|
||||
it('returns nothing for an unknown or revoked grant', () => {
|
||||
const grant = mintPosixGrant()
|
||||
|
||||
expect(getDocPreviewGrant('0'.repeat(32))).toBeNull()
|
||||
expect(revokeDocPreviewGrant(grant.id)).toBe(true)
|
||||
expect(getDocPreviewGrant(grant.id)).toBeNull()
|
||||
expect(revokeDocPreviewGrant(grant.id)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveDocPreviewTargetPath', () => {
|
||||
it('resolves paths inside the grant root', () => {
|
||||
const grant = mintPosixGrant()
|
||||
|
||||
expect(resolveDocPreviewTargetPath(grant, 'index.html')).toBe('/srv/repo/docs/index.html')
|
||||
expect(resolveDocPreviewTargetPath(grant, 'assets/logo.png')).toBe(
|
||||
'/srv/repo/docs/assets/logo.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses parent traversal, absolute escapes and empty paths', () => {
|
||||
const grant = mintPosixGrant()
|
||||
|
||||
expect(resolveDocPreviewTargetPath(grant, '../secret.env')).toBeNull()
|
||||
expect(resolveDocPreviewTargetPath(grant, 'assets/../../secret.env')).toBeNull()
|
||||
expect(resolveDocPreviewTargetPath(grant, '..')).toBeNull()
|
||||
expect(resolveDocPreviewTargetPath(grant, '')).toBeNull()
|
||||
expect(resolveDocPreviewTargetPath(grant, 'a//b')).toBeNull()
|
||||
})
|
||||
|
||||
it('refuses backslash and NUL segments that could re-split on the owning host', () => {
|
||||
const grant = mintPosixGrant()
|
||||
|
||||
expect(resolveDocPreviewTargetPath(grant, '..\\secret.env')).toBeNull()
|
||||
expect(resolveDocPreviewTargetPath(grant, 'index.html\0.png')).toBeNull()
|
||||
})
|
||||
|
||||
// Why this is a traversal test and not a containment test: every request path that names a
|
||||
// sibling directory has to climb out of the root first, so the `..` segment guard answers it
|
||||
// before the prefix check runs. Sibling containment is exercised where it is reachable —
|
||||
// against a canonicalized path, below.
|
||||
it('refuses a sibling directory by refusing the traversal that reaches it', () => {
|
||||
const grant = mintPosixGrant()
|
||||
|
||||
expect(resolveDocPreviewTargetPath(grant, '../docs-private/secret.html')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps a Windows drive root addressable instead of turning it drive-relative', () => {
|
||||
const grant = mintDocPreviewGrant({
|
||||
owner: sshOwner,
|
||||
root: 'C:\\',
|
||||
entryRelativePath: 'index.html',
|
||||
browserPageId: 'page-1'
|
||||
})
|
||||
|
||||
expect(resolveDocPreviewTargetPath(grant, 'index.html')).toBe('C:\\index.html')
|
||||
})
|
||||
|
||||
it('follows the owning host path flavor rather than this process platform', () => {
|
||||
const windowsGrant = mintDocPreviewGrant({
|
||||
owner: sshOwner,
|
||||
root: 'C:\\srv\\repo\\docs',
|
||||
entryRelativePath: 'index.html',
|
||||
browserPageId: 'page-1'
|
||||
})
|
||||
|
||||
expect(resolveDocPreviewTargetPath(windowsGrant, 'assets/logo.png')).toBe(
|
||||
'C:\\srv\\repo\\docs\\assets\\logo.png'
|
||||
)
|
||||
expect(resolveDocPreviewTargetPath(windowsGrant, '../secret.env')).toBeNull()
|
||||
})
|
||||
|
||||
it('normalizes a trailing separator on the root', () => {
|
||||
const grant = mintDocPreviewGrant({
|
||||
owner: sshOwner,
|
||||
root: '/srv/repo/docs/',
|
||||
entryRelativePath: 'index.html',
|
||||
browserPageId: 'page-1'
|
||||
})
|
||||
|
||||
expect(resolveDocPreviewTargetPath(grant, 'index.html')).toBe('/srv/repo/docs/index.html')
|
||||
expect(resolveDocPreviewTargetPath(grant, '../secret.env')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveCanonicalDocPreviewPath', () => {
|
||||
// Why here and not above: a canonical path is the one input that can name a sibling directory
|
||||
// without traversing — the host resolved a symlink to it — so this is where the prefix check
|
||||
// is the only thing standing between the grant and `/srv/repo/docs-private`.
|
||||
it('refuses a canonical path in a sibling directory that shares the root prefix', async () => {
|
||||
const grant = mintPosixGrant()
|
||||
|
||||
await expect(
|
||||
resolveCanonicalDocPreviewPath(grant, '/srv/repo/docs/report.html', async (path) =>
|
||||
path === grant.root ? path : '/srv/repo/docs-private/secret.html'
|
||||
)
|
||||
).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('answers the canonical path when it stays inside the canonical root', async () => {
|
||||
const grant = mintPosixGrant()
|
||||
|
||||
await expect(
|
||||
resolveCanonicalDocPreviewPath(grant, '/srv/repo/docs/report.html', async (path) => path)
|
||||
).resolves.toBe('/srv/repo/docs/report.html')
|
||||
})
|
||||
})
|
||||
|
||||
describe('toRuntimeWorktreeRelativePath', () => {
|
||||
it('produces a worktree-relative path for files inside the worktree', () => {
|
||||
expect(toRuntimeWorktreeRelativePath('/srv/repo', '/srv/repo/docs/index.html')).toBe(
|
||||
'docs/index.html'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects paths outside the worktree, which files.read cannot address', () => {
|
||||
expect(toRuntimeWorktreeRelativePath('/srv/repo', '/tmp/agent/report.html')).toBeNull()
|
||||
expect(toRuntimeWorktreeRelativePath('/srv/repo', '/srv/repo')).toBeNull()
|
||||
})
|
||||
|
||||
it('uses Windows semantics for a Windows worktree root', () => {
|
||||
expect(toRuntimeWorktreeRelativePath('C:\\srv\\repo', 'C:\\srv\\repo\\docs\\index.html')).toBe(
|
||||
'docs/index.html'
|
||||
)
|
||||
expect(toRuntimeWorktreeRelativePath('C:\\srv\\repo', 'D:\\other\\index.html')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,208 @@
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { posix, win32 } from 'node:path'
|
||||
import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path'
|
||||
|
||||
/**
|
||||
* A preview grant is the only authority that turns an `orca-preview://` request
|
||||
* into bytes: it names the host that owns the file and the single directory
|
||||
* subtree requests may resolve inside. No grant, no bytes.
|
||||
*/
|
||||
export type DocPreviewOwner =
|
||||
| { kind: 'ssh'; connectionId: string }
|
||||
| {
|
||||
kind: 'runtime'
|
||||
environmentId: string
|
||||
/** Selector the runtime resolves `files.read` against. */
|
||||
worktreeSelector: string
|
||||
/** Worktree root on the runtime host; `files.read` only accepts paths inside it. */
|
||||
worktreeRoot: string
|
||||
}
|
||||
|
||||
export type DocPreviewGrant = {
|
||||
id: string
|
||||
owner: DocPreviewOwner
|
||||
/** Containing directory of the opened document, on the owning host. */
|
||||
root: string
|
||||
/** Path of the opened document relative to `root`. */
|
||||
entryRelativePath: string
|
||||
/**
|
||||
* Browser page the reader opened this document in. Main registers the guest under it once the
|
||||
* guest commits to the grant, so the surface a tool names is the page the reader is looking at
|
||||
* and not the grant, which a re-mint replaces underneath the same page.
|
||||
*/
|
||||
browserPageId: string
|
||||
}
|
||||
|
||||
const grantsById = new Map<string, DocPreviewGrant>()
|
||||
|
||||
function pathFlavorFor(root: string): typeof posix | typeof win32 {
|
||||
return isWindowsAbsolutePathLike(root) ? win32 : posix
|
||||
}
|
||||
|
||||
function normalizeRootPath(root: string): string {
|
||||
const flavor = pathFlavorFor(root)
|
||||
const normalized =
|
||||
flavor === win32 ? flavor.normalize(root.replace(/\//g, '\\')) : flavor.normalize(root)
|
||||
// Why: `C:\` is the whole root, and trimming its separator would make win32.join answer the
|
||||
// drive-relative `C:x`, which resolves against the host's cwd instead of inside the grant.
|
||||
if (flavor === win32 && /^[a-zA-Z]:\\$/.test(normalized)) {
|
||||
return normalized
|
||||
}
|
||||
// Why: a trailing separator would make the containment prefix check accept a sibling directory.
|
||||
return normalized.length > 1 && normalized.endsWith(flavor.sep)
|
||||
? normalized.slice(0, -1)
|
||||
: normalized
|
||||
}
|
||||
|
||||
export function mintDocPreviewGrant(params: {
|
||||
owner: DocPreviewOwner
|
||||
root: string
|
||||
entryRelativePath: string
|
||||
browserPageId: string
|
||||
}): DocPreviewGrant {
|
||||
const grant: DocPreviewGrant = {
|
||||
id: randomBytes(16).toString('hex'),
|
||||
owner: params.owner,
|
||||
root: normalizeRootPath(params.root),
|
||||
entryRelativePath: params.entryRelativePath.replace(/\\/g, '/'),
|
||||
browserPageId: params.browserPageId
|
||||
}
|
||||
grantsById.set(grant.id, grant)
|
||||
return grant
|
||||
}
|
||||
|
||||
export function getDocPreviewGrant(grantId: string): DocPreviewGrant | null {
|
||||
return grantsById.get(grantId) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Why anything listens at all: a grant is the only thing that names a preview's lifetime. State
|
||||
* elsewhere in main keyed by a preview's tool target — grab intent, a queued grab chain — has no
|
||||
* other signal telling it the surface is gone, and would otherwise accrete one entry per grant
|
||||
* for the life of the process.
|
||||
*/
|
||||
const revocationListeners = new Set<(grant: DocPreviewGrant) => void>()
|
||||
|
||||
/** Why the whole grant and not its id: it is already gone from the registry when listeners run. */
|
||||
export function onDocPreviewGrantRevoked(listener: (grant: DocPreviewGrant) => void): () => void {
|
||||
revocationListeners.add(listener)
|
||||
return () => revocationListeners.delete(listener)
|
||||
}
|
||||
|
||||
function notifyRevoked(grant: DocPreviewGrant): void {
|
||||
for (const listener of revocationListeners) {
|
||||
listener(grant)
|
||||
}
|
||||
}
|
||||
|
||||
export function revokeDocPreviewGrant(grantId: string): boolean {
|
||||
canonicalRootByGrantId.delete(grantId)
|
||||
const grant = grantsById.get(grantId)
|
||||
if (!grant) {
|
||||
return false
|
||||
}
|
||||
grantsById.delete(grantId)
|
||||
notifyRevoked(grant)
|
||||
return true
|
||||
}
|
||||
|
||||
export function revokeAllDocPreviewGrants(): void {
|
||||
canonicalRootByGrantId.clear()
|
||||
const revoked = [...grantsById.values()]
|
||||
grantsById.clear()
|
||||
for (const grant of revoked) {
|
||||
notifyRevoked(grant)
|
||||
}
|
||||
}
|
||||
|
||||
function hasUnsafeSegment(segments: string[]): boolean {
|
||||
return segments.some(
|
||||
(segment) =>
|
||||
segment.length === 0 ||
|
||||
segment === '.' ||
|
||||
segment === '..' ||
|
||||
segment.includes('\0') ||
|
||||
segment.includes('\\')
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a request path to an absolute path on the owning host, or null when
|
||||
* it would escape the grant's root. Path flavor follows the root (the owning
|
||||
* host may be Windows while this client is not), never `process.platform`.
|
||||
*/
|
||||
export function resolveDocPreviewTargetPath(
|
||||
grant: DocPreviewGrant,
|
||||
relativePath: string
|
||||
): string | null {
|
||||
const segments = relativePath.split('/').filter((segment, index, all) => {
|
||||
// Why: keep empty segments visible to the safety check except a single trailing one from `dir/`.
|
||||
return !(segment === '' && index === all.length - 1)
|
||||
})
|
||||
if (segments.length === 0 || hasUnsafeSegment(segments)) {
|
||||
return null
|
||||
}
|
||||
const flavor = pathFlavorFor(grant.root)
|
||||
const resolved = flavor.normalize(flavor.join(grant.root, ...segments))
|
||||
return isInsideRoot(grant.root, resolved, flavor) ? resolved : null
|
||||
}
|
||||
|
||||
function isInsideRoot(
|
||||
root: string,
|
||||
candidate: string,
|
||||
flavor: typeof posix | typeof win32
|
||||
): boolean {
|
||||
const rootPrefix = root.endsWith(flavor.sep) ? root : `${root}${flavor.sep}`
|
||||
return candidate.startsWith(rootPrefix)
|
||||
}
|
||||
|
||||
/** Why: realpath is a host round-trip, and a grant's root is fixed for its lifetime. */
|
||||
const canonicalRootByGrantId = new Map<string, Promise<string>>()
|
||||
|
||||
/**
|
||||
* Second containment pass for hosts where the lexical one is not enough: a symlink
|
||||
* inside the root can point anywhere, and the SSH read RPC applies no root of its
|
||||
* own. Both sides are canonicalized on the owning host before the prefix re-check;
|
||||
* a host that cannot canonicalize a path answers nothing.
|
||||
*/
|
||||
export async function resolveCanonicalDocPreviewPath(
|
||||
grant: DocPreviewGrant,
|
||||
absolutePath: string,
|
||||
realpath: (path: string) => Promise<string>
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
let canonicalRoot = canonicalRootByGrantId.get(grant.id)
|
||||
if (!canonicalRoot) {
|
||||
canonicalRoot = realpath(grant.root).then(normalizeRootPath)
|
||||
canonicalRootByGrantId.set(grant.id, canonicalRoot)
|
||||
}
|
||||
const [root, canonicalPath] = await Promise.all([canonicalRoot, realpath(absolutePath)])
|
||||
const flavor = pathFlavorFor(root)
|
||||
return isInsideRoot(root, canonicalPath, flavor) ? canonicalPath : null
|
||||
} catch {
|
||||
// Why: a root that no longer canonicalizes must not fall back to the lexical answer.
|
||||
canonicalRootByGrantId.delete(grant.id)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Path a runtime `files.read` can address, i.e. relative to the worktree root.
|
||||
* Returns null when the grant root sits outside the worktree — the runtime file
|
||||
* RPCs are worktree-scoped, so those documents are unreadable client-side.
|
||||
*/
|
||||
export function toRuntimeWorktreeRelativePath(
|
||||
worktreeRoot: string,
|
||||
absolutePath: string
|
||||
): string | null {
|
||||
const flavor = pathFlavorFor(worktreeRoot)
|
||||
const normalizedRoot = normalizeRootPath(worktreeRoot)
|
||||
const relative = flavor.relative(normalizedRoot, absolutePath)
|
||||
if (!relative || relative === '..' || relative.startsWith(`..${flavor.sep}`)) {
|
||||
return null
|
||||
}
|
||||
if (flavor === win32 && /^[a-zA-Z]:/.test(relative)) {
|
||||
return null
|
||||
}
|
||||
return relative.replace(/\\/g, '/')
|
||||
}
|
||||
@@ -0,0 +1,550 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
getWorkspaceDocPageGuest,
|
||||
installDocPreviewGuestPolicy,
|
||||
readDocPreviewGuestBoundGrantId,
|
||||
reportDocPreviewLinkClick
|
||||
} from './doc-preview-guest-policy'
|
||||
import { buildDocPreviewUrl } from '../../shared/doc-preview-scheme'
|
||||
import {
|
||||
mintDocPreviewGrant,
|
||||
revokeAllDocPreviewGrants,
|
||||
revokeDocPreviewGrant
|
||||
} from './doc-preview-grant-registry'
|
||||
|
||||
type GuestHandlers = Record<string, (...args: never[]) => void>
|
||||
|
||||
const HOST_RENDERER_ID = 42
|
||||
|
||||
function installOnFakeGuest(
|
||||
hostId: number = HOST_RENDERER_ID,
|
||||
/** What the guest is already showing when the embedder hands it over, as a real one usually is. */
|
||||
initialUrl = ''
|
||||
): {
|
||||
contents: object
|
||||
handlers: GuestHandlers
|
||||
hostId: number
|
||||
isFocused: ReturnType<typeof vi.fn>
|
||||
send: ReturnType<typeof vi.fn>
|
||||
setWebRTCIPHandlingPolicy: ReturnType<typeof vi.fn>
|
||||
windowOpenHandler: (details: { url: string }) => { action: string }
|
||||
/** Why without the `destroyed` event: Chromium tears the contents down before main runs it. */
|
||||
markContentsDestroyed: () => void
|
||||
} {
|
||||
const handlers: GuestHandlers = {}
|
||||
let contentsDestroyed = false
|
||||
const send = vi.fn()
|
||||
const isFocused = vi.fn(() => true)
|
||||
const setWebRTCIPHandlingPolicy = vi.fn()
|
||||
let windowOpenHandler: (details: { url: string }) => { action: string } = () => ({
|
||||
action: 'deny'
|
||||
})
|
||||
const register = (event: string, handler: (...args: never[]) => void): void => {
|
||||
handlers[event] = handler
|
||||
}
|
||||
const guest = {
|
||||
isFocused,
|
||||
isDestroyed: () => contentsDestroyed,
|
||||
getURL: () => initialUrl,
|
||||
on: vi.fn(register),
|
||||
once: vi.fn(register),
|
||||
setWindowOpenHandler: vi.fn((handler: (details: { url: string }) => { action: string }) => {
|
||||
windowOpenHandler = handler
|
||||
}),
|
||||
setWebRTCIPHandlingPolicy
|
||||
}
|
||||
installDocPreviewGuestPolicy(guest as never, { id: hostId, send })
|
||||
return {
|
||||
contents: guest,
|
||||
handlers,
|
||||
hostId,
|
||||
isFocused,
|
||||
send,
|
||||
setWebRTCIPHandlingPolicy,
|
||||
windowOpenHandler: (details) => windowOpenHandler(details),
|
||||
markContentsDestroyed: () => {
|
||||
contentsDestroyed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type FakeGuest = ReturnType<typeof installOnFakeGuest>
|
||||
|
||||
function startMainFrameNavigation(guest: FakeGuest, url: string): void {
|
||||
guest.handlers['did-start-navigation']?.({ url, isMainFrame: true } as never)
|
||||
}
|
||||
|
||||
/** A guest already showing a document, which is the only state a link can be pressed in. */
|
||||
function boundGuest(): { grant: ReturnType<typeof mintGrant>; guest: FakeGuest } {
|
||||
const grant = mintGrant()
|
||||
const guest = installOnFakeGuest()
|
||||
startMainFrameNavigation(guest, buildDocPreviewUrl(grant.id, 'index.html'))
|
||||
return { grant, guest }
|
||||
}
|
||||
|
||||
function reportClick(guest: FakeGuest, url: string): void {
|
||||
reportDocPreviewLinkClick(guest.contents as never, url)
|
||||
}
|
||||
|
||||
// Why a fresh page per grant: the registry is keyed by the page now, so two grants sharing one
|
||||
// would have the second silently replace the first rather than stand beside it.
|
||||
let nextDocPageOrdinal = 0
|
||||
|
||||
function mintGrant(): ReturnType<typeof mintDocPreviewGrant> {
|
||||
nextDocPageOrdinal += 1
|
||||
return mintDocPreviewGrant({
|
||||
owner: { kind: 'ssh', connectionId: 'ssh-1' },
|
||||
root: '/home/alice/docs',
|
||||
entryRelativePath: 'index.html',
|
||||
browserPageId: `doc-page-${nextDocPageOrdinal}`
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
revokeAllDocPreviewGrants()
|
||||
})
|
||||
|
||||
describe('doc preview guest policy', () => {
|
||||
it('allows relative navigation within the bound grant', () => {
|
||||
const { grant, guest } = boundGuest()
|
||||
const preventDefault = vi.fn()
|
||||
|
||||
guest.handlers['will-navigate']?.(
|
||||
{ preventDefault } as never,
|
||||
buildDocPreviewUrl(grant.id, 'guide.html') as never
|
||||
)
|
||||
|
||||
expect(preventDefault).not.toHaveBeenCalled()
|
||||
expect(guest.send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Why an unlatched guest is refused rather than trusted: the renderer-set src is
|
||||
// browser-initiated and never reaches will-navigate, so a navigation arriving before the latch is
|
||||
// one the guest started for itself.
|
||||
it('blocks a navigation the guest starts before a document has bound it', () => {
|
||||
const grant = mintGrant()
|
||||
const guest = installOnFakeGuest()
|
||||
const preventDefault = vi.fn()
|
||||
|
||||
guest.handlers['will-navigate']?.(
|
||||
{ preventDefault } as never,
|
||||
buildDocPreviewUrl(grant.id, 'index.html') as never
|
||||
)
|
||||
|
||||
expect(preventDefault).toHaveBeenCalledOnce()
|
||||
expect(guest.send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('blocks navigation into a different live grant once bound', () => {
|
||||
const { guest } = boundGuest()
|
||||
const otherGrant = mintGrant()
|
||||
const preventDefault = vi.fn()
|
||||
|
||||
guest.handlers['will-navigate']?.(
|
||||
{ preventDefault } as never,
|
||||
buildDocPreviewUrl(otherGrant.id, 'index.html') as never
|
||||
)
|
||||
|
||||
expect(preventDefault).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('blocks a revoked grant even when it matches the bound id', () => {
|
||||
const { grant, guest } = boundGuest()
|
||||
revokeAllDocPreviewGrants()
|
||||
const preventDefault = vi.fn()
|
||||
|
||||
guest.handlers['will-navigate']?.(
|
||||
{ preventDefault } as never,
|
||||
buildDocPreviewUrl(grant.id, 'guide.html') as never
|
||||
)
|
||||
|
||||
expect(preventDefault).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('blocks an external navigation without offering it to the renderer', () => {
|
||||
const { guest } = boundGuest()
|
||||
const preventDefault = vi.fn()
|
||||
|
||||
guest.handlers['will-navigate']?.({ preventDefault } as never, 'https://example.com/' as never)
|
||||
|
||||
expect(preventDefault).toHaveBeenCalledOnce()
|
||||
expect(guest.send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('blocks a file: navigation', () => {
|
||||
const { guest } = boundGuest()
|
||||
const preventDefault = vi.fn()
|
||||
|
||||
guest.handlers['will-navigate']?.({ preventDefault } as never, 'file:///etc/passwd' as never)
|
||||
|
||||
expect(preventDefault).toHaveBeenCalledOnce()
|
||||
expect(guest.send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('applies the same rule to redirects', () => {
|
||||
const { guest } = boundGuest()
|
||||
const preventDefault = vi.fn()
|
||||
|
||||
guest.handlers['will-redirect']?.({ preventDefault } as never, 'https://evil.test/' as never)
|
||||
|
||||
expect(preventDefault).toHaveBeenCalledOnce()
|
||||
expect(guest.send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('denies every popup and routes none of them', () => {
|
||||
const { guest } = boundGuest()
|
||||
|
||||
expect(guest.windowOpenHandler({ url: 'https://example.com/docs' })).toEqual({
|
||||
action: 'deny'
|
||||
})
|
||||
expect(guest.windowOpenHandler({ url: 'file:///etc/passwd' })).toEqual({ action: 'deny' })
|
||||
expect(guest.send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Why: will-navigate never fires for a subframe, so an <iframe src="https://…"> would load
|
||||
// off-machine even though the top frame cannot.
|
||||
it('blocks a subframe navigating outside the grant, without opening a tab for it', () => {
|
||||
const { guest } = boundGuest()
|
||||
const preventDefault = vi.fn()
|
||||
|
||||
guest.handlers['will-frame-navigate']?.({
|
||||
preventDefault,
|
||||
url: 'https://tracker.test/pixel',
|
||||
isMainFrame: false
|
||||
} as never)
|
||||
|
||||
expect(preventDefault).toHaveBeenCalledOnce()
|
||||
expect(guest.send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('lets a subframe load an in-grant asset', () => {
|
||||
const { grant, guest } = boundGuest()
|
||||
const preventDefault = vi.fn()
|
||||
|
||||
guest.handlers['will-frame-navigate']?.({
|
||||
preventDefault,
|
||||
url: buildDocPreviewUrl(grant.id, 'chart.html'),
|
||||
isMainFrame: false
|
||||
} as never)
|
||||
|
||||
expect(preventDefault).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Why this group exists: the external-link route ends in a real browser tab with full network, so
|
||||
// a document that can read its grant and reach that route can exfiltrate it. Only a reader's own
|
||||
// press on a link may, and only the guest's preload can tell that a press was one.
|
||||
describe('the trusted-click route out of the preview', () => {
|
||||
// The headline. The earlier design read a recent-input timestamp, so a script that navigated
|
||||
// shortly after any genuine press was routed out as if the press had asked for it. Here the
|
||||
// navigation sinks route nothing at all, so when the input happened cannot matter.
|
||||
it('routes nothing for a scripted location change or window.open, whenever input happened', () => {
|
||||
const { guest } = boundGuest()
|
||||
const preventDefault = vi.fn()
|
||||
|
||||
// Genuine input the guest would report, delivered immediately before the document acts.
|
||||
guest.handlers['input-event']?.({} as never, { type: 'mouseDown' } as never)
|
||||
guest.handlers['before-input-event']?.({} as never, { type: 'keyDown' } as never)
|
||||
guest.handlers['will-navigate']?.(
|
||||
{ preventDefault } as never,
|
||||
'https://attacker.test/?d=secret' as never
|
||||
)
|
||||
const popup = guest.windowOpenHandler({ url: 'https://attacker.test/?d=secret' })
|
||||
|
||||
expect(preventDefault).toHaveBeenCalledOnce()
|
||||
expect(popup).toEqual({ action: 'deny' })
|
||||
expect(guest.send).not.toHaveBeenCalled()
|
||||
// Why assert the absence of the listeners too: with them gone there is no timing a document
|
||||
// could hit, rather than a window it merely failed to hit in this test.
|
||||
expect(guest.handlers['input-event']).toBeUndefined()
|
||||
expect(guest.handlers['before-input-event']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('routes a reported click on an external link exactly once', () => {
|
||||
const { guest } = boundGuest()
|
||||
|
||||
reportClick(guest, 'https://example.com/docs')
|
||||
|
||||
expect(guest.send).toHaveBeenCalledExactlyOnceWith('docPreview:externalLink', {
|
||||
url: 'https://example.com/docs'
|
||||
})
|
||||
})
|
||||
|
||||
it('drops a click reported while the guest is not the contents the reader is looking at', () => {
|
||||
const { guest } = boundGuest()
|
||||
guest.isFocused.mockReturnValue(false)
|
||||
|
||||
reportClick(guest, 'https://example.com/docs')
|
||||
|
||||
expect(guest.send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('drops a click reported by a sender that is not a preview guest', () => {
|
||||
const { guest } = boundGuest()
|
||||
|
||||
reportDocPreviewLinkClick({ isFocused: () => true } as never, 'https://example.com/docs')
|
||||
|
||||
expect(guest.send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('drops a click reported by a guest that never bound a grant', () => {
|
||||
mintGrant()
|
||||
const guest = installOnFakeGuest()
|
||||
|
||||
reportClick(guest, 'https://example.com/docs')
|
||||
|
||||
expect(guest.send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("drops a click once the guest's bound grant is revoked", () => {
|
||||
const { guest } = boundGuest()
|
||||
revokeAllDocPreviewGrants()
|
||||
|
||||
reportClick(guest, 'https://example.com/docs')
|
||||
|
||||
expect(guest.send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
'file:///etc/passwd',
|
||||
'javascript:fetch("https://attacker.test")',
|
||||
'orca-preview://a/b',
|
||||
'/Users/alice/secrets.txt',
|
||||
''
|
||||
])('drops a reported click on %s, which is not the web', (url) => {
|
||||
const { guest } = boundGuest()
|
||||
|
||||
reportClick(guest, url)
|
||||
|
||||
expect(guest.send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stops routing for a guest that has been destroyed', () => {
|
||||
const { guest } = boundGuest()
|
||||
|
||||
guest.handlers['destroyed']?.()
|
||||
reportClick(guest, 'https://example.com/docs')
|
||||
|
||||
expect(guest.send).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// Why: a peer connection is UDP straight off the network stack — the response CSP and the
|
||||
// session's request filter both miss it, so this is the only place it can be refused.
|
||||
it('denies the guest non-proxied WebRTC UDP at attach', () => {
|
||||
const guest = installOnFakeGuest()
|
||||
|
||||
expect(guest.setWebRTCIPHandlingPolicy).toHaveBeenCalledWith('disable_non_proxied_udp')
|
||||
})
|
||||
|
||||
// Why: latching from a subframe would let an in-document iframe decide which grant the guest
|
||||
// belongs to, and every later main-frame check would be measured against that.
|
||||
it('binds the guest from the main frame only', () => {
|
||||
const grant = mintGrant()
|
||||
const otherGrant = mintGrant()
|
||||
const guest = installOnFakeGuest()
|
||||
|
||||
guest.handlers['did-start-navigation']?.({
|
||||
url: buildDocPreviewUrl(otherGrant.id, 'index.html'),
|
||||
isMainFrame: false
|
||||
} as never)
|
||||
startMainFrameNavigation(guest, buildDocPreviewUrl(grant.id, 'index.html'))
|
||||
const preventDefault = vi.fn()
|
||||
|
||||
guest.handlers['will-navigate']?.(
|
||||
{ preventDefault } as never,
|
||||
buildDocPreviewUrl(otherGrant.id, 'index.html') as never
|
||||
)
|
||||
|
||||
expect(preventDefault).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
describe('tool authorization', () => {
|
||||
it('answers the hosting renderer for a guest bound to a live grant', () => {
|
||||
const { grant, guest } = boundGuest()
|
||||
|
||||
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID)).toBe(guest.contents)
|
||||
})
|
||||
|
||||
it('refuses a renderer that does not host the preview', () => {
|
||||
const { grant } = boundGuest()
|
||||
|
||||
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID + 1)).toBeNull()
|
||||
})
|
||||
|
||||
// Why this is the ordinary case and not an edge one: the embedder hands the guest over after
|
||||
// it has already started loading its src, so the navigation that binds most previews to their
|
||||
// grant happens before any listener here exists. Missing it leaves the tools unable to name
|
||||
// the guest for as long as the reader stays on the page they opened.
|
||||
it('binds a guest that is already showing its document when the policy installs', () => {
|
||||
const grant = mintGrant()
|
||||
const guest = installOnFakeGuest(HOST_RENDERER_ID, buildDocPreviewUrl(grant.id, 'index.html'))
|
||||
|
||||
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID)).toBe(guest.contents)
|
||||
})
|
||||
|
||||
// The same miss, one event later: the load started before the listener and commits after it.
|
||||
it('binds a guest whose first navigation only reaches the commit', () => {
|
||||
const grant = mintGrant()
|
||||
const guest = installOnFakeGuest()
|
||||
|
||||
guest.handlers['did-navigate']?.(
|
||||
{} as never,
|
||||
buildDocPreviewUrl(grant.id, 'index.html') as never
|
||||
)
|
||||
|
||||
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID)).toBe(guest.contents)
|
||||
})
|
||||
|
||||
// Why: the src commit is what proves this guest is showing that grant. Before it, the id names
|
||||
// a document nothing has been asked to render.
|
||||
it('refuses a guest that has not committed a document yet', () => {
|
||||
const grant = mintGrant()
|
||||
installOnFakeGuest()
|
||||
|
||||
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID)).toBeNull()
|
||||
})
|
||||
|
||||
// Why: revoking is how a closed tab withdraws its preview, and it happens while the guest is
|
||||
// still being torn down — a tool must stop answering at revoke, not at destroy.
|
||||
it('stops answering once the grant is revoked', () => {
|
||||
const { grant } = boundGuest()
|
||||
|
||||
revokeDocPreviewGrant(grant.id)
|
||||
|
||||
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID)).toBeNull()
|
||||
})
|
||||
|
||||
it('drops the guest when it is destroyed', () => {
|
||||
const { grant, guest } = boundGuest()
|
||||
|
||||
guest.handlers['destroyed']?.()
|
||||
|
||||
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID)).toBeNull()
|
||||
})
|
||||
|
||||
// Why this is separate from the destroy event: the contents die before main runs that listener,
|
||||
// so in that window the registration still looks live and only this check refuses it.
|
||||
it('refuses a guest whose contents died before the destroy event arrived', () => {
|
||||
const { grant, guest } = boundGuest()
|
||||
|
||||
guest.markContentsDestroyed()
|
||||
|
||||
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID)).toBeNull()
|
||||
})
|
||||
|
||||
it('answers nothing for a grant no preview ever rendered', () => {
|
||||
const grant = mintGrant()
|
||||
|
||||
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID)).toBeNull()
|
||||
})
|
||||
|
||||
// Why this is what makes three latch call sites safe: install, did-start-navigation and
|
||||
// did-navigate all feed the same latch, so without the no-rebind guard the second and third
|
||||
// would let a later navigation move a bound guest onto another grant — and a tool asking for
|
||||
// that grant would be handed a guest showing someone else's document.
|
||||
it('never rebinds a bound guest onto a second grant', () => {
|
||||
const { grant, guest } = boundGuest()
|
||||
const other = mintGrant()
|
||||
|
||||
startMainFrameNavigation(guest, buildDocPreviewUrl(other.id, 'index.html'))
|
||||
guest.handlers['did-navigate']?.(
|
||||
{} as never,
|
||||
buildDocPreviewUrl(other.id, 'index.html') as never
|
||||
)
|
||||
|
||||
expect(getWorkspaceDocPageGuest(other.browserPageId, HOST_RENDERER_ID)).toBeNull()
|
||||
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID)).toBe(guest.contents)
|
||||
})
|
||||
|
||||
// Why the replacement is registered before the old guest's teardown runs: a re-mint attaches
|
||||
// the new guest first, and Chromium runs the outgoing guest's destroyed listener afterwards. A
|
||||
// teardown that deleted by page alone would unregister the guest the reader is now looking at.
|
||||
it('leaves a re-minted preview registered when the guest it replaced tears down', () => {
|
||||
const first = mintGrant()
|
||||
const outgoing = installOnFakeGuest(
|
||||
HOST_RENDERER_ID,
|
||||
buildDocPreviewUrl(first.id, 'index.html')
|
||||
)
|
||||
const remint = mintDocPreviewGrant({
|
||||
owner: { kind: 'ssh', connectionId: 'ssh-1' },
|
||||
root: '/home/alice/docs',
|
||||
entryRelativePath: 'index.html',
|
||||
browserPageId: first.browserPageId
|
||||
})
|
||||
const incoming = installOnFakeGuest(
|
||||
HOST_RENDERER_ID,
|
||||
buildDocPreviewUrl(remint.id, 'index.html')
|
||||
)
|
||||
expect(getWorkspaceDocPageGuest(first.browserPageId, HOST_RENDERER_ID)).toBe(
|
||||
incoming.contents
|
||||
)
|
||||
|
||||
outgoing.handlers['destroyed']?.()
|
||||
|
||||
expect(getWorkspaceDocPageGuest(first.browserPageId, HOST_RENDERER_ID)).toBe(
|
||||
incoming.contents
|
||||
)
|
||||
})
|
||||
|
||||
// Why the grant has to be live at the latch and not only at each later request: registering the
|
||||
// page a dead grant names would put a guest that can read nothing into the registry the tool
|
||||
// door answers from, under a page a live grant may later want.
|
||||
it('registers nothing for a guest showing a grant that is already revoked', () => {
|
||||
const grant = mintGrant()
|
||||
revokeDocPreviewGrant(grant.id)
|
||||
|
||||
const guest = installOnFakeGuest(HOST_RENDERER_ID, buildDocPreviewUrl(grant.id, 'index.html'))
|
||||
|
||||
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID)).toBeNull()
|
||||
// The presence half: the same install does register when the grant is still live.
|
||||
const live = mintGrant()
|
||||
installOnFakeGuest(HOST_RENDERER_ID, buildDocPreviewUrl(live.id, 'index.html'))
|
||||
expect(getWorkspaceDocPageGuest(live.browserPageId, HOST_RENDERER_ID)).not.toBeNull()
|
||||
expect(guest.contents).toBeDefined()
|
||||
})
|
||||
|
||||
// Why both directions: two previews open at once must not be able to drive each other's guest.
|
||||
it('keeps two live previews on their own guests', () => {
|
||||
const first = boundGuest()
|
||||
const second = boundGuest()
|
||||
|
||||
expect(getWorkspaceDocPageGuest(first.grant.browserPageId, HOST_RENDERER_ID)).toBe(
|
||||
first.guest.contents
|
||||
)
|
||||
expect(getWorkspaceDocPageGuest(second.grant.browserPageId, HOST_RENDERER_ID)).toBe(
|
||||
second.guest.contents
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// Session events name the contents and nothing else, so this is how a fence firing on the whole
|
||||
// partition works out which preview — if any — the reader should be told about.
|
||||
describe('naming the grant a contents is showing', () => {
|
||||
it('answers the bound grant for a live preview guest', () => {
|
||||
const { grant, guest } = boundGuest()
|
||||
|
||||
expect(readDocPreviewGuestBoundGrantId(guest.contents as never)).toBe(grant.id)
|
||||
})
|
||||
|
||||
it('answers nothing for a contents that is not a preview guest', () => {
|
||||
boundGuest()
|
||||
|
||||
expect(readDocPreviewGuestBoundGrantId({} as never)).toBeNull()
|
||||
})
|
||||
|
||||
it('answers nothing for a preview guest that has committed no document', () => {
|
||||
const guest = installOnFakeGuest()
|
||||
|
||||
expect(readDocPreviewGuestBoundGrantId(guest.contents as never)).toBeNull()
|
||||
})
|
||||
|
||||
it('answers nothing once the guest is gone', () => {
|
||||
const { guest } = boundGuest()
|
||||
|
||||
guest.handlers['destroyed']?.()
|
||||
|
||||
expect(readDocPreviewGuestBoundGrantId(guest.contents as never)).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,247 @@
|
||||
import {
|
||||
DOC_PREVIEW_EXTERNAL_LINK_CHANNEL,
|
||||
parseDocPreviewUrl
|
||||
} from '../../shared/doc-preview-scheme'
|
||||
import { normalizeExternalBrowserUrl } from '../../shared/browser-url'
|
||||
import { enforceBrowserRouteWebRtcPolicy } from './browser-route-webrtc-policy'
|
||||
import { getDocPreviewGrant } from './doc-preview-grant-registry'
|
||||
|
||||
/** The trusted renderer hosting the preview: the sink for link reports and the only sender allowed to drive tools on it. */
|
||||
type PreviewHostRenderer = {
|
||||
id: number
|
||||
send: (channel: string, payload: { url: string }) => void
|
||||
}
|
||||
|
||||
type PreviewGuestRegistration = {
|
||||
host: PreviewHostRenderer
|
||||
readBoundGrantId: () => string | null
|
||||
isFocused: () => boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a registry: the click report arrives on an IPC channel, where the only thing main holds is
|
||||
* the sender. Without this, "is this a live preview guest, and which grant is it bound to" has no
|
||||
* answer, and any WebContents that learned the channel name would be routed out.
|
||||
*/
|
||||
const previewGuests = new WeakMap<object, PreviewGuestRegistration>()
|
||||
|
||||
/**
|
||||
* The workspace-document half of the browser page registry, keyed by the page the reader opened
|
||||
* the document in. It is deliberately a separate map from the browsing one: page management,
|
||||
* agent commands, download routing and certificate attribution all read that map directly, in
|
||||
* more places than a guard could be remembered in, so a document guest is simply not in it. The
|
||||
* one door taught about both is `browserManager.getAuthorizedGuest`, because acting on the guest
|
||||
* the reader is looking at is the single operation that legitimately spans them.
|
||||
*/
|
||||
const docGuestsByPageId = new Map<
|
||||
string,
|
||||
{ guest: Electron.WebContents; hostId: number; grantId: string }
|
||||
>()
|
||||
|
||||
const registrationListeners = new Set<(browserPageId: string) => void>()
|
||||
|
||||
/** Why anything listens: a tool request can beat the guest's own attach, and the wait it parks in lives in the IPC layer. */
|
||||
export function onWorkspaceDocGuestRegistered(
|
||||
listener: (browserPageId: string) => void
|
||||
): () => void {
|
||||
registrationListeners.add(listener)
|
||||
return () => registrationListeners.delete(listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the document guest a browser tool should act on. Grants the guest nothing — it answers
|
||||
* only whether the trusted renderer asking is the one hosting a live, grant-bound preview on that
|
||||
* page. Reached only through `browserManager.getAuthorizedGuest`.
|
||||
*/
|
||||
export function getWorkspaceDocPageGuest(
|
||||
browserPageId: string,
|
||||
senderWebContentsId: number
|
||||
): Electron.WebContents | null {
|
||||
const registration = docGuestsByPageId.get(browserPageId)
|
||||
if (!registration || registration.hostId !== senderWebContentsId) {
|
||||
return null
|
||||
}
|
||||
// Why: revocation is how a closed tab withdraws its preview, and it happens before the guest is torn down.
|
||||
if (!getDocPreviewGrant(registration.grantId)) {
|
||||
return null
|
||||
}
|
||||
if (registration.guest.isDestroyed()) {
|
||||
docGuestsByPageId.delete(browserPageId)
|
||||
return null
|
||||
}
|
||||
return registration.guest
|
||||
}
|
||||
|
||||
/** True when the page renders a workspace document, so the browsing registry must never hold it. */
|
||||
export function isWorkspaceDocPageId(browserPageId: string): boolean {
|
||||
return docGuestsByPageId.has(browserPageId)
|
||||
}
|
||||
|
||||
/**
|
||||
* The grant a live preview guest is bound to, or null for any other WebContents — the same identity
|
||||
* question `reportDocPreviewLinkClick` asks, for a session event that hands over only the contents.
|
||||
*/
|
||||
export function readDocPreviewGuestBoundGrantId(guest: Electron.WebContents): string | null {
|
||||
return previewGuests.get(guest)?.readBoundGrantId() ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* The preview guest renders a workspace document, not the web: it may only move within its own
|
||||
* grant, and nothing it does by itself leaves the preview. The one route out is
|
||||
* `reportDocPreviewLinkClick`, which answers for a click the reader really made.
|
||||
*
|
||||
* Reached only through `browserManager.attachGuestPolicies` under the workspace-doc profile, so
|
||||
* every guest in the app is policy-attached by one door whatever it renders. Returns the disposal
|
||||
* that door stores: without it a retired guest keeps its listeners and stays answerable through the
|
||||
* grant-keyed authority until the WebContents itself is collected.
|
||||
*/
|
||||
export function installDocPreviewGuestPolicy(
|
||||
guest: Electron.WebContents,
|
||||
host: PreviewHostRenderer
|
||||
): () => void {
|
||||
// Why: the first commit is the renderer-set src, already admitted by will-attach-webview;
|
||||
// latching it pins every later navigation to that one grant.
|
||||
let boundGrantId: string | null = null
|
||||
let boundPageId: string | null = null
|
||||
|
||||
const latchGrantFromUrl = (rawUrl: string): void => {
|
||||
if (boundGrantId !== null) {
|
||||
return
|
||||
}
|
||||
const grantId = parseDocPreviewUrl(rawUrl)?.grantId ?? null
|
||||
// Why the grant must still be live: registering under the page a dead grant names would put a
|
||||
// guest nothing can read into the registry the tool door answers from.
|
||||
const grant = grantId === null ? null : getDocPreviewGrant(grantId)
|
||||
if (!grant) {
|
||||
return
|
||||
}
|
||||
boundGrantId = grant.id
|
||||
boundPageId = grant.browserPageId
|
||||
docGuestsByPageId.set(boundPageId, { guest, hostId: host.id, grantId: grant.id })
|
||||
for (const listener of registrationListeners) {
|
||||
listener(boundPageId)
|
||||
}
|
||||
}
|
||||
|
||||
previewGuests.set(guest, {
|
||||
host,
|
||||
readBoundGrantId: () => boundGrantId,
|
||||
isFocused: () => guest.isFocused()
|
||||
})
|
||||
const forgetGuest = (): void => {
|
||||
previewGuests.delete(guest)
|
||||
// Why the identity check: a re-mint registers the replacement under the same page before this
|
||||
// guest's own teardown runs, and deleting by key alone would unregister the live one.
|
||||
if (boundPageId !== null && docGuestsByPageId.get(boundPageId)?.guest === guest) {
|
||||
docGuestsByPageId.delete(boundPageId)
|
||||
}
|
||||
}
|
||||
guest.once('destroyed', forgetGuest)
|
||||
|
||||
const isAllowedPreviewNavigation = (rawUrl: string): boolean => {
|
||||
const target = parseDocPreviewUrl(rawUrl)
|
||||
if (!target || !getDocPreviewGrant(target.grantId)) {
|
||||
return false
|
||||
}
|
||||
// Why the latch is required and not just consistent: the renderer-set src is browser-initiated,
|
||||
// so will-navigate never fires for it. Anything reaching here before the latch is the guest
|
||||
// moving itself, which no grant has admitted yet.
|
||||
return boundGrantId !== null && target.grantId === boundGrantId
|
||||
}
|
||||
|
||||
/**
|
||||
* Why deny-only, with nothing routed: a navigation the guest starts cannot be attributed to the
|
||||
* reader. The document may read its whole grant over `connect-src 'self'`, so routing an
|
||||
* unattributable URL to a real browser tab with full network is how those bytes would get out.
|
||||
*/
|
||||
const navigationGuard = (event: Electron.Event, url: string): void => {
|
||||
if (isAllowedPreviewNavigation(url)) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
// Why here and not only on navigation events: the guest is already loading its src by the time
|
||||
// the embedder hands it to us, so the only navigation most previews ever make has already
|
||||
// started. Latching what it is on now is what binds the usual preview to its grant at all.
|
||||
latchGrantFromUrl(guest.getURL())
|
||||
const latchFromMainFrameNavigation = (details: { isMainFrame: boolean; url: string }): void => {
|
||||
// Why: only the top document defines which grant this guest belongs to. Latching from a
|
||||
// subframe would let an in-document iframe rebind the guest to another grant.
|
||||
if (!details.isMainFrame) {
|
||||
return
|
||||
}
|
||||
latchGrantFromUrl(details.url)
|
||||
}
|
||||
// Why a committed URL too: a navigation that started before this listener existed still commits
|
||||
// after it, and a preview that never navigates again would otherwise stay bound to nothing.
|
||||
const latchFromCommittedUrl = (_event: Electron.Event, url: string): void =>
|
||||
latchGrantFromUrl(url)
|
||||
// Why: will-navigate never fires for a subframe, so without this an <iframe src="https://…">
|
||||
// inside a previewed document would load off-machine even though the top frame cannot.
|
||||
const frameNavigationGuard = (details: {
|
||||
isMainFrame: boolean
|
||||
url: string
|
||||
preventDefault: () => void
|
||||
}): void => {
|
||||
if (details.isMainFrame || isAllowedPreviewNavigation(details.url)) {
|
||||
return
|
||||
}
|
||||
details.preventDefault()
|
||||
}
|
||||
guest.on('did-start-navigation', latchFromMainFrameNavigation)
|
||||
guest.on('did-navigate', latchFromCommittedUrl)
|
||||
guest.on('will-navigate', navigationGuard)
|
||||
guest.on('will-redirect', navigationGuard)
|
||||
guest.on('will-frame-navigate', frameNavigationGuard)
|
||||
// Why deny with nothing routed: previews own no native child windows, and a popup the document
|
||||
// asked for is the document asking, not the reader. A link the reader presses is intercepted
|
||||
// before Chromium ever considers a popup.
|
||||
guest.setWindowOpenHandler(() => ({ action: 'deny' }))
|
||||
// Why a second fence for one API: WebRTC opens UDP straight from the network stack, so neither the
|
||||
// response CSP nor the session's request filter ever sees it. This is the only layer that can.
|
||||
enforceBrowserRouteWebRtcPolicy(guest, () => {})
|
||||
|
||||
return () => {
|
||||
forgetGuest()
|
||||
if (guest.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
guest.off('destroyed', forgetGuest)
|
||||
guest.off('did-start-navigation', latchFromMainFrameNavigation)
|
||||
guest.off('did-navigate', latchFromCommittedUrl)
|
||||
guest.off('will-navigate', navigationGuard)
|
||||
guest.off('will-redirect', navigationGuard)
|
||||
guest.off('will-frame-navigate', frameNavigationGuard)
|
||||
}
|
||||
}
|
||||
|
||||
function isWebUrl(url: string): boolean {
|
||||
return url.startsWith('http://') || url.startsWith('https://')
|
||||
}
|
||||
|
||||
/**
|
||||
* The only way a URL leaves a preview. Every condition is load-bearing: the sender must be a live
|
||||
* preview guest still bound to a grant, it must be the contents the reader is looking at, and the
|
||||
* target must be the web. Anything else is dropped without a trace the document could observe.
|
||||
*/
|
||||
export function reportDocPreviewLinkClick(sender: Electron.WebContents, rawUrl: string): void {
|
||||
const registration = previewGuests.get(sender)
|
||||
if (!registration) {
|
||||
return
|
||||
}
|
||||
const boundGrantId = registration.readBoundGrantId()
|
||||
if (boundGrantId === null || !getDocPreviewGrant(boundGrantId)) {
|
||||
return
|
||||
}
|
||||
// Why focus and not just the preload's trusted-click check: that check runs inside the guest, so
|
||||
// it holds only while the guest renderer does. Focus is the half main can verify for itself.
|
||||
if (!registration.isFocused()) {
|
||||
return
|
||||
}
|
||||
const externalUrl = normalizeExternalBrowserUrl(rawUrl)
|
||||
if (!externalUrl || !isWebUrl(externalUrl)) {
|
||||
return
|
||||
}
|
||||
registration.host.send(DOC_PREVIEW_EXTERNAL_LINK_CHANNEL, { url: externalUrl })
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
readDocPreviewFile: vi.fn(),
|
||||
installBrowserSessionPartitionPolicies: vi.fn()
|
||||
}))
|
||||
|
||||
function createFakeSession(): {
|
||||
protocol: { isProtocolHandled: () => boolean; handle: ReturnType<typeof vi.fn> }
|
||||
webRequest: { onBeforeRequest: ReturnType<typeof vi.fn> }
|
||||
} {
|
||||
return {
|
||||
protocol: { isProtocolHandled: () => false, handle: vi.fn() },
|
||||
webRequest: { onBeforeRequest: vi.fn() }
|
||||
}
|
||||
}
|
||||
|
||||
// Why one session per partition and a distinct default: installing the handler on the default
|
||||
// session instead of the preview session is otherwise invisible — every read would come back
|
||||
// from the same object.
|
||||
const previewSession = createFakeSession()
|
||||
const defaultSession = createFakeSession()
|
||||
vi.mock('electron', () => ({
|
||||
protocol: { registerSchemesAsPrivileged: vi.fn() },
|
||||
session: {
|
||||
get defaultSession() {
|
||||
return defaultSession
|
||||
},
|
||||
fromPartition: (partition: string) => {
|
||||
if (partition !== 'orca-doc-preview') {
|
||||
throw new Error(`unexpected partition ${partition}`)
|
||||
}
|
||||
return previewSession
|
||||
}
|
||||
}
|
||||
}))
|
||||
vi.mock('./doc-preview-file-reader', () => ({ readDocPreviewFile: mocks.readDocPreviewFile }))
|
||||
vi.mock('./browser-session-partition-policies', () => ({
|
||||
installBrowserSessionPartitionPolicies: mocks.installBrowserSessionPartitionPolicies
|
||||
}))
|
||||
|
||||
import { protocol } from 'electron'
|
||||
import {
|
||||
getDocPreviewSession,
|
||||
handleDocPreviewRequest,
|
||||
installDocPreviewProtocolHandler,
|
||||
isAllowedDocPreviewRequestUrl,
|
||||
isDocPreviewSession,
|
||||
registerDocPreviewSchemePrivileges
|
||||
} from './doc-preview-protocol'
|
||||
import {
|
||||
mintDocPreviewGrant,
|
||||
revokeAllDocPreviewGrants,
|
||||
revokeDocPreviewGrant
|
||||
} from './doc-preview-grant-registry'
|
||||
import {
|
||||
buildDocPreviewUrl,
|
||||
DOC_PREVIEW_LOAD_FAILURE_CHANNEL
|
||||
} from '../../shared/doc-preview-scheme'
|
||||
import { setDocPreviewFailureSink } from './doc-preview-failure-notice'
|
||||
|
||||
function mintGrant(): ReturnType<typeof mintDocPreviewGrant> {
|
||||
return mintDocPreviewGrant({
|
||||
owner: { kind: 'ssh', connectionId: 'ssh-1' },
|
||||
root: '/home/alice/docs',
|
||||
entryRelativePath: 'index.html',
|
||||
browserPageId: 'page-1'
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
revokeAllDocPreviewGrants()
|
||||
setDocPreviewFailureSink(null)
|
||||
mocks.readDocPreviewFile.mockResolvedValue({
|
||||
ok: true,
|
||||
bytes: Buffer.from('<h1>hi</h1>', 'utf8'),
|
||||
contentType: 'text/html; charset=utf-8'
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleDocPreviewRequest', () => {
|
||||
it('serves an in-grant document with no-store so reload re-reads the workspace', async () => {
|
||||
const grant = mintGrant()
|
||||
|
||||
const response = await handleDocPreviewRequest(
|
||||
new Request(buildDocPreviewUrl(grant.id, 'index.html'))
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('Content-Type')).toBe('text/html; charset=utf-8')
|
||||
expect(response.headers.get('Cache-Control')).toBe('no-store')
|
||||
expect(await response.text()).toBe('<h1>hi</h1>')
|
||||
expect(mocks.readDocPreviewFile).toHaveBeenCalledWith(grant, 'index.html')
|
||||
})
|
||||
|
||||
it('falls back to the granted entry document for a root request', async () => {
|
||||
const grant = mintGrant()
|
||||
|
||||
await handleDocPreviewRequest(new Request(`orca-preview://${grant.id}/`))
|
||||
|
||||
expect(mocks.readDocPreviewFile).toHaveBeenCalledWith(grant, 'index.html')
|
||||
})
|
||||
|
||||
it('decodes percent-encoded segments before resolving', async () => {
|
||||
const grant = mintGrant()
|
||||
|
||||
await handleDocPreviewRequest(new Request(buildDocPreviewUrl(grant.id, 'a b/c#d.html')))
|
||||
|
||||
expect(mocks.readDocPreviewFile).toHaveBeenCalledWith(grant, 'a b/c#d.html')
|
||||
})
|
||||
|
||||
it('404s an unknown grant without reading anything', async () => {
|
||||
const response = await handleDocPreviewRequest(
|
||||
new Request(`orca-preview://${'0'.repeat(32)}/index.html`)
|
||||
)
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
expect(mocks.readDocPreviewFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('404s a revoked grant', async () => {
|
||||
const grant = mintGrant()
|
||||
revokeDocPreviewGrant(grant.id)
|
||||
|
||||
const response = await handleDocPreviewRequest(
|
||||
new Request(buildDocPreviewUrl(grant.id, 'index.html'))
|
||||
)
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
expect(mocks.readDocPreviewFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('404s a malformed grant id', async () => {
|
||||
const response = await handleDocPreviewRequest(new Request('orca-preview://not-a-grant/x.html'))
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
expect(mocks.readDocPreviewFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('propagates the reader status for an unservable asset', async () => {
|
||||
const grant = mintGrant()
|
||||
mocks.readDocPreviewFile.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 415,
|
||||
reason: 'unsupported-asset',
|
||||
message: 'cannot send this file type'
|
||||
})
|
||||
|
||||
const response = await handleDocPreviewRequest(
|
||||
new Request(buildDocPreviewUrl(grant.id, 'a.woff2'))
|
||||
)
|
||||
|
||||
expect(response.status).toBe(415)
|
||||
expect(await response.text()).toBe('cannot send this file type')
|
||||
})
|
||||
|
||||
// Why: previewed documents are agent-authored, so an outbound request is an exfiltration channel
|
||||
// for everything else the grant can read.
|
||||
it('serves documents under a self-only content security policy', async () => {
|
||||
const grant = mintGrant()
|
||||
|
||||
const response = await handleDocPreviewRequest(
|
||||
new Request(buildDocPreviewUrl(grant.id, 'index.html'))
|
||||
)
|
||||
|
||||
const policy = response.headers.get('Content-Security-Policy') ?? ''
|
||||
expect(policy).toContain("default-src 'self'")
|
||||
expect(policy).toContain("connect-src 'self'")
|
||||
expect(policy).toContain("object-src 'none'")
|
||||
expect(policy).toContain("img-src 'self' data:")
|
||||
expect(policy).not.toContain('https:')
|
||||
// Why assert an absence: `webrtc 'block'` is the obvious directive to reach for here and this
|
||||
// Chromium does not implement it — it logs "Unrecognized Content-Security-Policy directive
|
||||
// 'webrtc'" and gathers candidates regardless. Adding it back would document a fence that is
|
||||
// not there; the guest's IP-handling policy is what actually refuses.
|
||||
expect(policy).not.toContain('webrtc')
|
||||
})
|
||||
|
||||
// Why: the guest paints a 4xx body as if it were the document, so the shell only learns the
|
||||
// reason from this push.
|
||||
it('pushes the failure reason for the requested path', async () => {
|
||||
const send = vi.fn()
|
||||
setDocPreviewFailureSink({ send })
|
||||
const grant = mintGrant()
|
||||
mocks.readDocPreviewFile.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 413,
|
||||
reason: 'too-large',
|
||||
message: 'too large'
|
||||
})
|
||||
|
||||
await handleDocPreviewRequest(new Request(buildDocPreviewUrl(grant.id, 'index.html')))
|
||||
|
||||
expect(send).toHaveBeenCalledWith(DOC_PREVIEW_LOAD_FAILURE_CHANNEL, {
|
||||
grantId: grant.id,
|
||||
relativePath: 'index.html',
|
||||
reason: 'too-large'
|
||||
})
|
||||
})
|
||||
|
||||
it('pushes nothing when the document is served', async () => {
|
||||
const send = vi.fn()
|
||||
setDocPreviewFailureSink({ send })
|
||||
const grant = mintGrant()
|
||||
|
||||
await handleDocPreviewRequest(new Request(buildDocPreviewUrl(grant.id, 'index.html')))
|
||||
|
||||
expect(send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Why: a grant revoked underneath a live guest 404s like a missing file, and without this push
|
||||
// the shell leaves the handler's "Not found" body on screen as if it were the document.
|
||||
it('pushes a failure for a revoked grant so the shell can replace the 404 body', async () => {
|
||||
const send = vi.fn()
|
||||
const grant = mintGrant()
|
||||
revokeDocPreviewGrant(grant.id)
|
||||
setDocPreviewFailureSink({ send })
|
||||
|
||||
await handleDocPreviewRequest(new Request(buildDocPreviewUrl(grant.id, 'index.html')))
|
||||
|
||||
expect(send).toHaveBeenCalledWith(DOC_PREVIEW_LOAD_FAILURE_CHANNEL, {
|
||||
grantId: grant.id,
|
||||
relativePath: 'index.html',
|
||||
reason: 'unreadable'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('installDocPreviewProtocolHandler', () => {
|
||||
// Why the default session is named here: it is the session every other Electron API reaches for
|
||||
// by default, and handling the scheme there would serve preview bytes to ordinary browsing.
|
||||
it('handles the scheme on the preview session and nowhere else', () => {
|
||||
installDocPreviewProtocolHandler()
|
||||
|
||||
expect(previewSession.protocol.handle).toHaveBeenCalledWith(
|
||||
'orca-preview',
|
||||
handleDocPreviewRequest
|
||||
)
|
||||
expect(previewSession.webRequest.onBeforeRequest).toHaveBeenCalled()
|
||||
expect(defaultSession.protocol.handle).not.toHaveBeenCalled()
|
||||
expect(defaultSession.webRequest.onBeforeRequest).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cancels every request the preview session should never carry', () => {
|
||||
installDocPreviewProtocolHandler()
|
||||
|
||||
const filter = previewSession.webRequest.onBeforeRequest.mock.calls.at(-1)?.[0] as (
|
||||
details: { url: string },
|
||||
callback: (response: { cancel: boolean }) => void
|
||||
) => void
|
||||
const cancelled = (url: string): boolean => {
|
||||
let response: { cancel: boolean } | null = null
|
||||
filter({ url }, (value) => {
|
||||
response = value
|
||||
})
|
||||
return (response as { cancel: boolean } | null)?.cancel === true
|
||||
}
|
||||
|
||||
expect(cancelled('https://cdn.example.com/tracker.js')).toBe(true)
|
||||
expect(cancelled(`orca-preview://${'a'.repeat(32)}/index.html`)).toBe(false)
|
||||
})
|
||||
|
||||
// Why: preview guests are webviews like any other, so they must not skip the deny-by-default
|
||||
// permission and display-media policy every browser partition gets.
|
||||
it('applies the shared browser partition policies to the preview session', () => {
|
||||
installDocPreviewProtocolHandler()
|
||||
|
||||
expect(mocks.installBrowserSessionPartitionPolicies).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ partition: 'orca-doc-preview', userAgentMode: 'clean' }),
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
// Why downloads and nothing else: the browser download flow attributes a file to the page that
|
||||
// asked for it, and a previewed document is no page. Routed, it would write remote-authored bytes
|
||||
// into this desktop's Downloads folder with no prompt and no tab to name as the source.
|
||||
it('asks for downloads to be denied on the preview partition', () => {
|
||||
installDocPreviewProtocolHandler()
|
||||
|
||||
expect(mocks.installBrowserSessionPartitionPolicies).toHaveBeenCalledWith(expect.anything(), {
|
||||
downloads: 'deny'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('registerDocPreviewSchemePrivileges', () => {
|
||||
// Why the whole literal and not a subset: every privilege this scheme does not claim is one the
|
||||
// document cannot use to escape it. `allowServiceWorkers` would outlive the tab that was granted
|
||||
// the read, and `bypassCSP` would undo the self-only policy the handler serves with.
|
||||
it('claims exactly the privileges the preview document needs', () => {
|
||||
registerDocPreviewSchemePrivileges()
|
||||
|
||||
expect(protocol.registerSchemesAsPrivileged).toHaveBeenCalledWith([
|
||||
{
|
||||
scheme: 'orca-preview',
|
||||
privileges: {
|
||||
standard: true,
|
||||
secure: true,
|
||||
supportFetchAPI: true,
|
||||
corsEnabled: true,
|
||||
stream: true
|
||||
}
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('isAllowedDocPreviewRequestUrl', () => {
|
||||
// Why: the session refuses to carry the request at all, so a CSP bypass in one element type
|
||||
// still reaches nothing off-machine.
|
||||
it('admits in-document schemes and refuses everything that leaves the machine', () => {
|
||||
expect(isAllowedDocPreviewRequestUrl(`orca-preview://${'a'.repeat(32)}/index.html`)).toBe(true)
|
||||
expect(isAllowedDocPreviewRequestUrl('devtools://devtools/bundled/inspector.html')).toBe(true)
|
||||
expect(isAllowedDocPreviewRequestUrl('data:image/png;base64,AAA')).toBe(true)
|
||||
expect(isAllowedDocPreviewRequestUrl('blob:orca-preview://abc/123')).toBe(true)
|
||||
expect(isAllowedDocPreviewRequestUrl('https://cdn.example.com/app.css')).toBe(false)
|
||||
expect(isAllowedDocPreviewRequestUrl('http://127.0.0.1:9999/exfil')).toBe(false)
|
||||
expect(isAllowedDocPreviewRequestUrl('ws://evil.example.com/socket')).toBe(false)
|
||||
expect(isAllowedDocPreviewRequestUrl('file:///etc/passwd')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isDocPreviewSession', () => {
|
||||
it('claims no session until the preview session has been materialized', () => {
|
||||
expect(isDocPreviewSession({} as never)).toBe(false)
|
||||
})
|
||||
|
||||
it('matches only the memoized preview session', () => {
|
||||
const created = getDocPreviewSession()
|
||||
|
||||
expect(isDocPreviewSession(created)).toBe(true)
|
||||
expect(isDocPreviewSession({} as never)).toBe(false)
|
||||
expect(getDocPreviewSession()).toBe(created)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,144 @@
|
||||
import { protocol, session } from 'electron'
|
||||
import {
|
||||
DOC_PREVIEW_PARTITION,
|
||||
DOC_PREVIEW_SCHEME,
|
||||
parseDocPreviewUrl
|
||||
} from '../../shared/doc-preview-scheme'
|
||||
import { installBrowserSessionPartitionPolicies } from './browser-session-partition-policies'
|
||||
import { readDocPreviewFile } from './doc-preview-file-reader'
|
||||
import { publishDocPreviewFailure } from './doc-preview-failure-notice'
|
||||
import { getDocPreviewGrant } from './doc-preview-grant-registry'
|
||||
|
||||
/** Must run before `app.whenReady()`; Electron freezes the privileged scheme table at ready. */
|
||||
export function registerDocPreviewSchemePrivileges(): void {
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{
|
||||
scheme: DOC_PREVIEW_SCHEME,
|
||||
privileges: {
|
||||
standard: true,
|
||||
secure: true,
|
||||
supportFetchAPI: true,
|
||||
corsEnabled: true,
|
||||
stream: true
|
||||
}
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
let docPreviewSession: Electron.Session | null = null
|
||||
|
||||
/** Non-persistent session, so preview bytes never land in a browsing profile's storage. */
|
||||
export function getDocPreviewSession(): Electron.Session {
|
||||
docPreviewSession ??= session.fromPartition(DOC_PREVIEW_PARTITION)
|
||||
return docPreviewSession
|
||||
}
|
||||
|
||||
/** Pure identity check: every guest attach consults it, and none should materialize a session. */
|
||||
export function isDocPreviewSession(candidate: Electron.Session): boolean {
|
||||
return docPreviewSession !== null && candidate === docPreviewSession
|
||||
}
|
||||
|
||||
/**
|
||||
* Product decision, not a hardening default: previewed documents are agent-authored, so any
|
||||
* outbound request they can make is an exfiltration channel for whatever else the page can read.
|
||||
* Self-contained documents — inline CSS/JS/SVG and in-grant assets — render in full; a CDN
|
||||
* stylesheet, font, script, or analytics beacon deliberately does not load.
|
||||
*/
|
||||
const DOC_PREVIEW_CONTENT_SECURITY_POLICY = [
|
||||
"default-src 'self'",
|
||||
"script-src 'self' 'unsafe-inline'",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data:",
|
||||
"font-src 'self' data:",
|
||||
"connect-src 'self'",
|
||||
"frame-src 'self'",
|
||||
"object-src 'none'"
|
||||
// Why no `webrtc 'block'` here, though a peer connection is exactly the outbound channel this
|
||||
// policy is meant to close: Chromium 43-era answers that directive with "Unrecognized
|
||||
// Content-Security-Policy directive 'webrtc'" and gathers candidates anyway, so listing it would
|
||||
// read as a fence while fencing nothing. The guest's IP-handling policy is the one that holds —
|
||||
// see `installDocPreviewGuestPolicy`.
|
||||
].join('; ')
|
||||
|
||||
function notFound(message: string): Response {
|
||||
return new Response(message, {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'text/plain; charset=utf-8' }
|
||||
})
|
||||
}
|
||||
|
||||
export async function handleDocPreviewRequest(request: Request): Promise<Response> {
|
||||
const target = parseDocPreviewUrl(request.url)
|
||||
if (!target) {
|
||||
return notFound('Not found')
|
||||
}
|
||||
const grant = getDocPreviewGrant(target.grantId)
|
||||
if (!grant) {
|
||||
// Why: a revoked or unknown grant is indistinguishable from a missing file by design — but the
|
||||
// shell still needs to know, or the guest paints this body where the document should be.
|
||||
publishDocPreviewFailure({
|
||||
grantId: target.grantId,
|
||||
relativePath: target.relativePath,
|
||||
reason: 'unreadable'
|
||||
})
|
||||
return notFound('Not found')
|
||||
}
|
||||
const relativePath = target.relativePath || grant.entryRelativePath
|
||||
const outcome = await readDocPreviewFile(grant, relativePath)
|
||||
if (!outcome.ok) {
|
||||
publishDocPreviewFailure({ grantId: target.grantId, relativePath, reason: outcome.reason })
|
||||
return new Response(outcome.message, {
|
||||
status: outcome.status,
|
||||
headers: { 'Content-Type': 'text/plain; charset=utf-8' }
|
||||
})
|
||||
}
|
||||
return new Response(new Uint8Array(outcome.bytes), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': outcome.contentType,
|
||||
'Content-Security-Policy': DOC_PREVIEW_CONTENT_SECURITY_POLICY,
|
||||
// Why: reload must re-read the workspace disk, so nothing may be cached.
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** `data:`/`blob:` never leave the document; every other scheme would reach off-machine. */
|
||||
export function isAllowedDocPreviewRequestUrl(url: string): boolean {
|
||||
return (
|
||||
url.startsWith(`${DOC_PREVIEW_SCHEME}://`) ||
|
||||
url.startsWith('devtools://') ||
|
||||
url.startsWith('data:') ||
|
||||
url.startsWith('blob:')
|
||||
)
|
||||
}
|
||||
|
||||
export function installDocPreviewProtocolHandler(): void {
|
||||
const previewSession = getDocPreviewSession()
|
||||
if (previewSession.protocol.isProtocolHandled(DOC_PREVIEW_SCHEME)) {
|
||||
return
|
||||
}
|
||||
previewSession.protocol.handle(DOC_PREVIEW_SCHEME, handleDocPreviewRequest)
|
||||
// Why: the response CSP is the document's own promise to obey; this is the session refusing to
|
||||
// carry the request at all, so a CSP bypass in one element type still reaches nothing.
|
||||
previewSession.webRequest.onBeforeRequest((details, callback) => {
|
||||
callback({ cancel: !isAllowedDocPreviewRequestUrl(details.url) })
|
||||
})
|
||||
// Why: preview guests are webviews like any other, so they inherit the same deny-by-default
|
||||
// permission, display-media and user-agent policy every browser partition gets.
|
||||
installBrowserSessionPartitionPolicies(
|
||||
{
|
||||
id: DOC_PREVIEW_PARTITION,
|
||||
scope: 'isolated',
|
||||
partition: DOC_PREVIEW_PARTITION,
|
||||
label: 'Document preview',
|
||||
source: null,
|
||||
userAgentMode: 'clean'
|
||||
},
|
||||
// Why downloads are the one policy that does not carry over: the browser download flow needs a
|
||||
// page to attribute the file to, and a previewed document is not one. Routed here it would
|
||||
// reserve a name in this desktop's Downloads folder and write remote-authored bytes into it
|
||||
// with nothing in the UI naming the tab that asked, and no prompt in front of it.
|
||||
{ downloads: 'deny' }
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { EventEmitter } from 'node:events'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
windows: [] as { webContents: MockWebContents }[],
|
||||
windows: [] as MockBrowserWindow[],
|
||||
BrowserWindow: vi.fn(),
|
||||
finishLoads: true
|
||||
}))
|
||||
@@ -50,6 +50,41 @@ vi.mock('./browser-session-registry', () => ({
|
||||
}))
|
||||
|
||||
import { OffscreenBrowserBackend } from './offscreen-browser-backend'
|
||||
import { installDocPreviewGuestPolicy, isWorkspaceDocPageId } from './doc-preview-guest-policy'
|
||||
import { mintDocPreviewGrant } from './doc-preview-grant-registry'
|
||||
import { buildDocPreviewUrl } from '../../shared/doc-preview-scheme'
|
||||
|
||||
/** The real door's answer, so the backend is tested against the refusal it will actually get. */
|
||||
function registerOffscreenGuestLikeBrowserManager({
|
||||
browserPageId
|
||||
}: {
|
||||
browserPageId: string
|
||||
}): boolean {
|
||||
return !isWorkspaceDocPageId(browserPageId)
|
||||
}
|
||||
|
||||
/**
|
||||
* A page the document half of the registry really owns. Built rather than named, because the door
|
||||
* refuses on registry membership now — a made-up id would be admitted and prove nothing.
|
||||
*/
|
||||
function registerWorkspaceDocPage(browserPageId: string): void {
|
||||
const grant = mintDocPreviewGrant({
|
||||
owner: { kind: 'ssh', connectionId: 'ssh-1' },
|
||||
root: '/home/alice/docs',
|
||||
entryRelativePath: 'index.html',
|
||||
browserPageId
|
||||
})
|
||||
const guest = {
|
||||
isFocused: () => false,
|
||||
isDestroyed: () => false,
|
||||
getURL: () => buildDocPreviewUrl(grant.id, grant.entryRelativePath),
|
||||
on: vi.fn(),
|
||||
once: vi.fn(),
|
||||
setWindowOpenHandler: vi.fn(),
|
||||
setWebRTCIPHandlingPolicy: vi.fn()
|
||||
}
|
||||
installDocPreviewGuestPolicy(guest as never, { id: 1, send: vi.fn() })
|
||||
}
|
||||
|
||||
describe('OffscreenBrowserBackend lifecycle', () => {
|
||||
beforeEach(() => {
|
||||
@@ -73,7 +108,7 @@ describe('OffscreenBrowserBackend lifecycle', () => {
|
||||
vi.useFakeTimers()
|
||||
mocks.finishLoads = false
|
||||
const browserManager = {
|
||||
registerOffscreenGuest: vi.fn(),
|
||||
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
|
||||
unregisterGuest: vi.fn()
|
||||
}
|
||||
const backend = new OffscreenBrowserBackend(browserManager as never)
|
||||
@@ -94,9 +129,36 @@ describe('OffscreenBrowserBackend lifecycle', () => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
// Why the unregister assertion and not just the destroy: a refused id is one the document half
|
||||
// of the registry owns, and the teardown hook would cancel that preview's work on the way out.
|
||||
it('destroys a window whose registration was refused without unregistering the id', async () => {
|
||||
const browserManager = {
|
||||
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
|
||||
unregisterGuest: vi.fn()
|
||||
}
|
||||
const backend = new OffscreenBrowserBackend(browserManager as never)
|
||||
registerWorkspaceDocPage('doc-page-1')
|
||||
|
||||
await expect(
|
||||
backend.createTab({
|
||||
browserPageId: 'doc-page-1',
|
||||
url: 'https://example.com',
|
||||
worktreeId: 'wt'
|
||||
})
|
||||
).rejects.toThrow('was refused')
|
||||
|
||||
expect(mocks.windows[0].isDestroyed()).toBe(true)
|
||||
expect(browserManager.unregisterGuest).not.toHaveBeenCalled()
|
||||
|
||||
// Why shutdown and not the map: a refused id left behind is invisible until teardown walks it
|
||||
// and hands that id to the other authority after all.
|
||||
await backend.destroyAll()
|
||||
expect(browserManager.unregisterGuest).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('unregisters a closing page before awaiting owner retirement', async () => {
|
||||
const browserManager = {
|
||||
registerOffscreenGuest: vi.fn(),
|
||||
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
|
||||
unregisterGuest: vi.fn()
|
||||
}
|
||||
let releaseOwnerRetirement!: () => void
|
||||
@@ -126,7 +188,7 @@ describe('OffscreenBrowserBackend lifecycle', () => {
|
||||
|
||||
it('preserves a replacement page when the old window finishes closing', async () => {
|
||||
const browserManager = {
|
||||
registerOffscreenGuest: vi.fn(),
|
||||
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
|
||||
unregisterGuest: vi.fn()
|
||||
}
|
||||
let releaseOwnerRetirement!: () => void
|
||||
@@ -152,7 +214,7 @@ describe('OffscreenBrowserBackend lifecycle', () => {
|
||||
|
||||
it('retires the helper when an offscreen renderer is destroyed unexpectedly', async () => {
|
||||
const browserManager = {
|
||||
registerOffscreenGuest: vi.fn(),
|
||||
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
|
||||
unregisterGuest: vi.fn()
|
||||
}
|
||||
const onPageClosed = vi.fn(async () => {})
|
||||
@@ -167,7 +229,7 @@ describe('OffscreenBrowserBackend lifecycle', () => {
|
||||
|
||||
it('cleans every helper owner during backend shutdown', async () => {
|
||||
const browserManager = {
|
||||
registerOffscreenGuest: vi.fn(),
|
||||
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
|
||||
unregisterGuest: vi.fn()
|
||||
}
|
||||
const onPageClosed = vi.fn(async () => {})
|
||||
@@ -186,7 +248,7 @@ describe('OffscreenBrowserBackend lifecycle', () => {
|
||||
|
||||
it('rejects a concurrent create while shutdown is draining owned pages', async () => {
|
||||
const browserManager = {
|
||||
registerOffscreenGuest: vi.fn(),
|
||||
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
|
||||
unregisterGuest: vi.fn()
|
||||
}
|
||||
let releaseOwnerRetirement!: () => void
|
||||
@@ -215,7 +277,7 @@ describe('OffscreenBrowserBackend lifecycle', () => {
|
||||
|
||||
it('joins owner retirement started by an unexpected renderer destroy', async () => {
|
||||
const browserManager = {
|
||||
registerOffscreenGuest: vi.fn(),
|
||||
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
|
||||
unregisterGuest: vi.fn()
|
||||
}
|
||||
let releaseOwnerRetirement!: () => void
|
||||
@@ -244,7 +306,7 @@ describe('OffscreenBrowserBackend lifecycle', () => {
|
||||
|
||||
it('bounds concurrent helper retirements during shutdown', async () => {
|
||||
const browserManager = {
|
||||
registerOffscreenGuest: vi.fn(),
|
||||
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
|
||||
unregisterGuest: vi.fn()
|
||||
}
|
||||
let activeRetirements = 0
|
||||
@@ -284,7 +346,10 @@ describe('OffscreenBrowserBackend lifecycle', () => {
|
||||
})
|
||||
|
||||
it('closes the page even when daemon retirement throws', async () => {
|
||||
const browserManager = { registerOffscreenGuest: vi.fn(), unregisterGuest: vi.fn() }
|
||||
const browserManager = {
|
||||
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
|
||||
unregisterGuest: vi.fn()
|
||||
}
|
||||
const backend = new OffscreenBrowserBackend(browserManager as never, {
|
||||
getAgentBrowserBridge: () => ({
|
||||
onPageClosed: vi.fn(async () => {
|
||||
|
||||
@@ -66,9 +66,33 @@ export class OffscreenBrowserBackend implements BrowserBackend {
|
||||
|
||||
this.windowsByPageId.set(browserPageId, win)
|
||||
|
||||
// Why: if the offscreen window is destroyed out from under us (crash, app
|
||||
// teardown), drop the registry entry so commands fail cleanly instead of
|
||||
// resolving a dead WebContents.
|
||||
// Why: register the guest and return immediately so the new tab appears
|
||||
// without waiting for the page to finish loading. Previously createTab
|
||||
// awaited the full navigation, so clicking "New Browser Tab" did nothing for
|
||||
// up to a second on real URLs. The page loads asynchronously and streams
|
||||
// once it paints; a failed load leaves the (usable) tab open, matching how a
|
||||
// normal browser tab survives a failed navigation.
|
||||
const registered = this.browserManager.registerOffscreenGuest({
|
||||
browserPageId,
|
||||
worktreeId: params.worktreeId,
|
||||
sessionProfileId: profile?.id ?? null,
|
||||
userAgentMode: profile?.userAgentMode,
|
||||
webContentsId: win.webContents.id
|
||||
})
|
||||
if (!registered) {
|
||||
// Why destroy rather than carry on: the window already exists but carries none of the guest
|
||||
// policies registration installs, so leaving it would navigate an unvalidated URL with no
|
||||
// policy on it and hand back a page id nothing can drive. The renderer door aborts its mount
|
||||
// the same way; this is that abort.
|
||||
this.windowsByPageId.delete(browserPageId)
|
||||
win.destroy()
|
||||
throw new Error(`Browser page ${browserPageId} was refused`)
|
||||
}
|
||||
|
||||
// Why only once registration took: this teardown unregisters the page id, and a refused id is
|
||||
// one the other authority may own — cancelling its work would be the confusion we just refused.
|
||||
// Why at all: if the window dies out from under us (crash, app teardown), drop the registry
|
||||
// entry so commands fail cleanly instead of resolving a dead WebContents.
|
||||
win.webContents.once('destroyed', () => {
|
||||
// Explicit close removes the page first and performs awaited cleanup;
|
||||
// only an unexpected destruction still owns the bridge retirement here.
|
||||
@@ -80,20 +104,6 @@ export class OffscreenBrowserBackend implements BrowserBackend {
|
||||
this.browserManager.unregisterGuest(browserPageId)
|
||||
})
|
||||
|
||||
// Why: register the guest and return immediately so the new tab appears
|
||||
// without waiting for the page to finish loading. Previously createTab
|
||||
// awaited the full navigation, so clicking "New Browser Tab" did nothing for
|
||||
// up to a second on real URLs. The page loads asynchronously and streams
|
||||
// once it paints; a failed load leaves the (usable) tab open, matching how a
|
||||
// normal browser tab survives a failed navigation.
|
||||
this.browserManager.registerOffscreenGuest({
|
||||
browserPageId,
|
||||
worktreeId: params.worktreeId,
|
||||
sessionProfileId: profile?.id ?? null,
|
||||
userAgentMode: profile?.userAgentMode,
|
||||
webContentsId: win.webContents.id
|
||||
})
|
||||
|
||||
const url = params.url || 'about:blank'
|
||||
void this.loadUrl(win, url).catch((error) => {
|
||||
console.warn(
|
||||
|
||||
@@ -539,12 +539,14 @@ describe('ClaudeHookService.installRemote', () => {
|
||||
expect(script!.indexOf('printf "{}\\n"')).toBe(
|
||||
script!.indexOf('#!/bin/sh') + '#!/bin/sh\n'.length
|
||||
)
|
||||
// Why: payload is piped to curl via stdin (`payload@-`) so it never lands
|
||||
// on the curl command line (EDR oversized-command-line false positive),
|
||||
// matching the Windows curl.exe hook post.
|
||||
// Why: payload stays on stdin, while metadata headers avoid URL-encoded IDS signatures.
|
||||
expect(script).toContain('printf \'%s\' "$payload" | curl')
|
||||
expect(script).toContain('-H "Content-Type: application/json"')
|
||||
expect(script).toContain('orca_hook_metadata=$(printf')
|
||||
expect(script).toContain('unset ORCA_AGENT_HOOK_TRANSPORT')
|
||||
expect(script).toContain('-H "X-Orca-Agent-Hook-Meta: ${orca_hook_metadata}"')
|
||||
expect(script).toContain('--data-binary @-')
|
||||
expect(script).toContain('--data-urlencode "payload@-"')
|
||||
expect(script).not.toContain('--data-urlencode "payload=${payload}"')
|
||||
expect(fs.modes.get('/home/dev/.orca/agent-hooks/claude-hook.sh')).toBe(0o755)
|
||||
// Why: no remote statusLine — this path serves SSH remotes and WSL guests, whose relay
|
||||
// listener doesn't route /statusline/claude and whose accounts aren't attributable locally.
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
writeManagedScript,
|
||||
type HooksConfig
|
||||
} from '../agent-hooks/installer-utils'
|
||||
import { buildPosixAgentHookPostCommand } from '../agent-hooks/hook-post-command'
|
||||
import {
|
||||
readHooksJsonRemote,
|
||||
writeHooksJsonRemote,
|
||||
@@ -114,25 +115,17 @@ function getManagedScript(
|
||||
// Why: refresh endpoint coordinates for PTYs surviving an Orca restart.
|
||||
// Why: suppress parse errors so they neither leak nor trip outer set -e.
|
||||
'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then',
|
||||
' unset ORCA_AGENT_HOOK_TRANSPORT',
|
||||
' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :',
|
||||
'fi',
|
||||
'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then',
|
||||
' spool_hook_event',
|
||||
' exit 0',
|
||||
'fi',
|
||||
// Why: post form fields because path-bearing payloads are unsafe in hand-built JSON.
|
||||
// Why: pipe payload to curl stdin to keep large output off the command line.
|
||||
'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/claude" \\',
|
||||
' --connect-timeout 0.5 --max-time 1.5 \\',
|
||||
' -H "Content-Type: application/x-www-form-urlencoded" \\',
|
||||
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
|
||||
' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\',
|
||||
' --data-urlencode "tabId=${ORCA_TAB_ID}" \\',
|
||||
' --data-urlencode "launchToken=${ORCA_AGENT_LAUNCH_TOKEN}" \\',
|
||||
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
|
||||
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
|
||||
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
|
||||
' --data-urlencode "payload@-" >/dev/null 2>&1 || spool_hook_event',
|
||||
// Why: keep full hook JSON off the command line and avoid IDS-friendly URL-encoded paths.
|
||||
...buildPosixAgentHookPostCommand('claude').map((line, index, lines) =>
|
||||
index === lines.length - 1 ? `${line} >/dev/null 2>&1 || spool_hook_event` : line
|
||||
),
|
||||
'exit 0',
|
||||
''
|
||||
].join('\n')
|
||||
|
||||
@@ -108,4 +108,36 @@ describe('runExclusivelyForCodexTrustConfig', () => {
|
||||
await queued
|
||||
expect(secondStarted).toBe(true)
|
||||
})
|
||||
|
||||
it('coalesces WSL UNC aliases without folding the case-sensitive Linux path', async () => {
|
||||
const aliasGate = deferred()
|
||||
let aliasStarted = false
|
||||
const blockedAlias = runExclusivelyForCodexTrustConfig(
|
||||
String.raw`\\wsl.localhost\Ubuntu\home\Alice\.codex\config.toml`,
|
||||
() => aliasGate.promise
|
||||
)
|
||||
const queuedAlias = runExclusivelyForCodexTrustConfig(
|
||||
String.raw`\\wsl$\ubuntu\home\Alice\.codex\config.toml`,
|
||||
() => {
|
||||
aliasStarted = true
|
||||
return Promise.resolve()
|
||||
}
|
||||
)
|
||||
await Promise.resolve()
|
||||
expect(aliasStarted).toBe(false)
|
||||
|
||||
let distinctStarted = false
|
||||
await runExclusivelyForCodexTrustConfig(
|
||||
String.raw`\\wsl.localhost\Ubuntu\home\alice\.codex\config.toml`,
|
||||
async () => {
|
||||
distinctStarted = true
|
||||
}
|
||||
)
|
||||
expect(distinctStarted).toBe(true)
|
||||
|
||||
aliasGate.resolve()
|
||||
await blockedAlias
|
||||
await queuedAlias
|
||||
expect(aliasStarted).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
type CodexTrustEntry
|
||||
} from './config-toml-trust'
|
||||
import {
|
||||
CodexHookService,
|
||||
_internals,
|
||||
createCodexWslRuntimeHookInstallPlan,
|
||||
type CodexWslRuntimeHookInstallPlan
|
||||
@@ -82,7 +83,67 @@ function expectedManagedCommand(scriptPath: string): string {
|
||||
}
|
||||
|
||||
describe('Codex WSL runtime hook install', () => {
|
||||
it('plans WSL hook files with Linux command and trust paths', async () => {
|
||||
it('coalesces aliases of one runtime home without blocking independent homes', async () => {
|
||||
const service = new CodexHookService()
|
||||
const releases: (() => void)[] = []
|
||||
const started: string[] = []
|
||||
vi.spyOn(service, 'installForRuntimeHome').mockImplementation(async (runtimeHomePath) => {
|
||||
started.push(runtimeHomePath!)
|
||||
await new Promise<void>((resolve) => releases.push(resolve))
|
||||
return null
|
||||
})
|
||||
const firstHome = '\\\\wsl$\\Ubuntu\\home\\Alice\\.local\\share\\orca\\codex-runtime-home\\home'
|
||||
const alias = firstHome.replace('\\\\wsl$', '\\\\wsl.localhost')
|
||||
const independent = firstHome.replace('\\Alice\\', '\\Bob\\')
|
||||
|
||||
const first = service.installForRuntimeHomeSerialized(firstHome)
|
||||
const second = service.installForRuntimeHomeSerialized(alias)
|
||||
const third = service.installForRuntimeHomeSerialized(independent)
|
||||
await vi.waitFor(() => expect(started).toEqual([firstHome, independent]))
|
||||
expect(second).toBe(first)
|
||||
|
||||
releases.splice(0).forEach((release) => release())
|
||||
await Promise.all([first, second, third])
|
||||
expect(started).toEqual([firstHome, independent])
|
||||
})
|
||||
|
||||
it('does not coalesce one drive-backed home across WSL distros', async () => {
|
||||
const service = new CodexHookService()
|
||||
const started: string[] = []
|
||||
vi.spyOn(service, 'installForRuntimeHome').mockImplementation(async (_runtimeHome, target) => {
|
||||
started.push(target?.wslDistro ?? '')
|
||||
return null
|
||||
})
|
||||
const home = 'D:\\wsl-home\\.local\\share\\orca\\codex-runtime-home\\home'
|
||||
|
||||
await Promise.all([
|
||||
service.installForRuntimeHomeSerialized(home, { runtime: 'wsl', wslDistro: 'Ubuntu' }),
|
||||
service.installForRuntimeHomeSerialized(home, { runtime: 'wsl', wslDistro: 'Debian' })
|
||||
])
|
||||
|
||||
expect(started).toEqual(['Ubuntu', 'Debian'])
|
||||
})
|
||||
|
||||
it('keeps case-distinct Linux runtime homes on separate queues', async () => {
|
||||
const service = new CodexHookService()
|
||||
const started: string[] = []
|
||||
vi.spyOn(service, 'installForRuntimeHome').mockImplementation(async (runtimeHomePath) => {
|
||||
started.push(runtimeHomePath!)
|
||||
return null
|
||||
})
|
||||
const upper =
|
||||
'\\\\wsl.localhost\\Ubuntu\\home\\Alice\\.local\\share\\orca\\codex-runtime-home\\home'
|
||||
const lower = upper.replace('\\Alice\\', '\\alice\\')
|
||||
|
||||
await Promise.all([
|
||||
service.installForRuntimeHomeSerialized(upper),
|
||||
service.installForRuntimeHomeSerialized(lower)
|
||||
])
|
||||
|
||||
expect(started).toEqual([upper, lower])
|
||||
})
|
||||
|
||||
it('plans WSL hook files with Linux command and trust paths', () => {
|
||||
const runtimeHome =
|
||||
'\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\codex-runtime-home\\home'
|
||||
|
||||
@@ -275,11 +336,15 @@ describe('Codex WSL runtime hook install', () => {
|
||||
it('generates a POSIX hook that bridges WSL loopback failures through Windows curl', async () => {
|
||||
const script = _internals.getManagedScript('posix')
|
||||
expect(script).toContain('load_hook_endpoint()')
|
||||
expect(script).toContain('unset ORCA_AGENT_HOOK_TRANSPORT')
|
||||
expect(script).toContain('"set ORCA_AGENT_HOOK_TOKEN="*)')
|
||||
expect(script).toContain('post_codex_hook()')
|
||||
expect(script).toContain('is_wsl_runtime()')
|
||||
expect(script).toContain('WSL_DISTRO_NAME')
|
||||
expect(script).toContain('windows_curl=$(command -v curl.exe 2>/dev/null || true)')
|
||||
expect(script).toContain('-H "Content-Type: application/json"')
|
||||
expect(script).toContain('-H "X-Orca-Agent-Hook-Meta-Encoding: base64"')
|
||||
expect(script).toContain('--data-binary @-')
|
||||
expect(script).toContain('--data-urlencode "payload@-"')
|
||||
expect(script).toContain('if post_codex_hook curl >/dev/null 2>&1; then')
|
||||
expect(script).toContain('post_codex_hook "$windows_curl" 3 5 >/dev/null 2>&1 || true')
|
||||
@@ -302,6 +367,7 @@ describe('Codex WSL runtime hook install', () => {
|
||||
'set ORCA_AGENT_HOOK_TOKEN=fresh-token',
|
||||
'set ORCA_AGENT_HOOK_ENV=development',
|
||||
'set ORCA_AGENT_HOOK_VERSION=1',
|
||||
'set ORCA_AGENT_HOOK_TRANSPORT=raw-json-v1',
|
||||
''
|
||||
].join('\r\n'),
|
||||
'utf-8'
|
||||
@@ -333,6 +399,7 @@ describe('Codex WSL runtime hook install', () => {
|
||||
const posted = readFileSync(capturePath, 'utf-8')
|
||||
expect(posted).toContain('http://127.0.0.1:43210/hook/codex')
|
||||
expect(posted).toContain('X-Orca-Agent-Hook-Token: fresh-token')
|
||||
expect(posted).toContain('Content-Type: application/json')
|
||||
expect(posted).not.toContain('stale-token')
|
||||
}
|
||||
)
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
writeManagedScript,
|
||||
type HookDefinition
|
||||
} from '../agent-hooks/installer-utils'
|
||||
import { buildPosixAgentHookPostCommand } from '../agent-hooks/hook-post-command'
|
||||
import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh'
|
||||
import { resolveHooksJsonWritePath } from '../agent-hooks/hook-config-write-path'
|
||||
import { writeFileAtomically } from '../codex-accounts/fs-utils'
|
||||
@@ -823,6 +824,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
// Why: sourcing refreshes PORT/TOKEN/ENV/VERSION from the current Orca so a surviving PTY keeps reporting after a restart (see claude/hook-service.ts).
|
||||
'load_hook_endpoint() {',
|
||||
' endpoint_path="$1"',
|
||||
' unset ORCA_AGENT_HOOK_TRANSPORT',
|
||||
' case "$endpoint_path" in',
|
||||
' *.cmd)',
|
||||
// Why: Windows passes endpoint.cmd into WSL via WSLENV; parse only Orca's known assignments since cmd.exe `set` lines aren't shell syntax.
|
||||
@@ -834,6 +836,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
' "set ORCA_AGENT_HOOK_TOKEN="*) ORCA_AGENT_HOOK_TOKEN=${endpoint_line#*=} ;;',
|
||||
' "set ORCA_AGENT_HOOK_ENV="*) ORCA_AGENT_HOOK_ENV=${endpoint_line#*=} ;;',
|
||||
' "set ORCA_AGENT_HOOK_VERSION="*) ORCA_AGENT_HOOK_VERSION=${endpoint_line#*=} ;;',
|
||||
' "set ORCA_AGENT_HOOK_TRANSPORT="*) ORCA_AGENT_HOOK_TRANSPORT=${endpoint_line#*=} ;;',
|
||||
' esac',
|
||||
' done < "$endpoint_path"',
|
||||
' ;;',
|
||||
@@ -853,20 +856,11 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
' curl_bin="$1"',
|
||||
' connect_timeout="${2:-0.5}"',
|
||||
' max_time="${3:-1.5}"',
|
||||
// Why: worktreeId embeds a path, so hand-building JSON in shell is unsafe with quotes/newlines; post raw payload plus metadata as form fields instead.
|
||||
// Why: pipe payload to curl's stdin (`payload@-`) not an inline arg, so tens-of-KB tool output stays off the command line (EDR false positives).
|
||||
' printf \'%s\' "$payload" | "$curl_bin" -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/codex" \\',
|
||||
' --connect-timeout "$connect_timeout" --max-time "$max_time" \\',
|
||||
' --noproxy "127.0.0.1" \\',
|
||||
' -H "Content-Type: application/x-www-form-urlencoded" \\',
|
||||
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
|
||||
' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\',
|
||||
' --data-urlencode "tabId=${ORCA_TAB_ID}" \\',
|
||||
' --data-urlencode "launchToken=${ORCA_AGENT_LAUNCH_TOKEN}" \\',
|
||||
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
|
||||
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
|
||||
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
|
||||
' --data-urlencode "payload@-"',
|
||||
// Why: keep full hook JSON off the command line and avoid URL-encoding paths/commands into IDS-friendly traversal signatures.
|
||||
...buildPosixAgentHookPostCommand('codex', {
|
||||
curlCommand: '"$curl_bin"',
|
||||
indent: ' '
|
||||
}).map((line) => ` ${line}`),
|
||||
'}',
|
||||
'is_wsl_runtime() {',
|
||||
' [ -n "$WSL_DISTRO_NAME" ] && return 0',
|
||||
@@ -1081,6 +1075,7 @@ export class CodexHookService {
|
||||
}
|
||||
|
||||
private readonly wslReconciliationGeneration = new Map<string, number>()
|
||||
private readonly wslInstallsInFlight = new Map<string, Promise<AgentHookInstallStatus | null>>()
|
||||
|
||||
private supersedeWslReconciliation(runtimeHomePath: string | null | undefined): number {
|
||||
if (!runtimeHomePath) {
|
||||
@@ -1177,6 +1172,30 @@ export class CodexHookService {
|
||||
}
|
||||
}
|
||||
|
||||
installForRuntimeHomeSerialized(
|
||||
runtimeHomePath: string | null | undefined,
|
||||
target?: CodexWslRuntimeHookTarget
|
||||
): Promise<AgentHookInstallStatus | null> {
|
||||
if (!runtimeHomePath) {
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
const targetKey = target?.runtime === 'wsl' ? target.wslDistro?.trim().toLowerCase() : ''
|
||||
const key = `${getWslReconciliationKey(runtimeHomePath)}\0${targetKey ?? ''}`
|
||||
const active = this.wslInstallsInFlight.get(key)
|
||||
if (active) {
|
||||
return active
|
||||
}
|
||||
const install = this.installForRuntimeHome(runtimeHomePath, target)
|
||||
this.wslInstallsInFlight.set(key, install)
|
||||
const clear = (): void => {
|
||||
if (this.wslInstallsInFlight.get(key) === install) {
|
||||
this.wslInstallsInFlight.delete(key)
|
||||
}
|
||||
}
|
||||
void install.then(clear, clear)
|
||||
return install
|
||||
}
|
||||
|
||||
refreshRuntimeUserHooksForRuntimeHome(
|
||||
runtimeHomePath: string | null | undefined,
|
||||
target?: CodexWslRuntimeHookTarget
|
||||
@@ -1492,7 +1511,13 @@ export class CodexHookService {
|
||||
options?.codexHomeDir?.replace(/\/$/, '') ?? `${remoteHome.replace(/\/$/, '')}/.codex`
|
||||
const remoteConfigPath = `${codexHomeBase}/hooks.json`
|
||||
const remoteTomlPath = `${codexHomeBase}/config.toml`
|
||||
const remoteScriptPath = `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/codex-hook.sh`
|
||||
// Redirected WSL homes must use the same script location and command shape
|
||||
// as the runtime installer; two representations of one hooks.json race
|
||||
// into stale trust keys. Plain SSH keeps its guest-home script contract.
|
||||
const redirectedCodexHome = options?.codexHomeDir?.replace(/\/$/, '')
|
||||
const remoteScriptPath = redirectedCodexHome
|
||||
? `${redirectedCodexHome}/.orca/agent-hooks/codex-hook.sh`
|
||||
: `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/codex-hook.sh`
|
||||
try {
|
||||
const config = await readHooksJsonRemote(sftp, remoteConfigPath)
|
||||
if (!config) {
|
||||
@@ -1505,7 +1530,9 @@ export class CodexHookService {
|
||||
}
|
||||
}
|
||||
|
||||
const command = wrapPosixHookCommand(remoteScriptPath)
|
||||
const command = redirectedCodexHome
|
||||
? wrapReadablePosixHookCommand(remoteScriptPath)
|
||||
: wrapPosixHookCommand(remoteScriptPath)
|
||||
const nextHooks = { ...config.hooks }
|
||||
const managedEvents = new Set<string>(CODEX_EVENTS)
|
||||
const isManagedCommand = createManagedCommandMatcher('codex-hook.sh')
|
||||
@@ -1529,11 +1556,13 @@ export class CodexHookService {
|
||||
const definition: HookDefinition = {
|
||||
hooks: [buildManagedCommandHook(command)]
|
||||
}
|
||||
nextHooks[eventName] = [...cleaned, definition]
|
||||
nextHooks[eventName] = redirectedCodexHome
|
||||
? [definition, ...cleaned]
|
||||
: [...cleaned, definition]
|
||||
trustEntries.push({
|
||||
sourcePath: remoteConfigPath,
|
||||
eventLabel: CODEX_EVENT_LABEL[eventName],
|
||||
groupIndex: cleaned.length,
|
||||
groupIndex: redirectedCodexHome ? 0 : cleaned.length,
|
||||
handlerIndex: 0,
|
||||
command,
|
||||
timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS
|
||||
|
||||
@@ -2,10 +2,25 @@ import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { wslRealpathMock } = vi.hoisted(() => ({
|
||||
wslRealpathMock: vi.fn(async (path: string) => path)
|
||||
}))
|
||||
|
||||
vi.mock('node:fs/promises', () => ({ realpath: wslRealpathMock }))
|
||||
|
||||
import {
|
||||
prepareManagedCodexHomeBeforeShellLaunch,
|
||||
resolveManagedCodexShellPreflightHome
|
||||
} from './managed-home-shell-preflight'
|
||||
import {
|
||||
prepareManagedWslCodexHomeBeforeShellLaunch,
|
||||
resolveManagedWslCodexShellPreflightTarget
|
||||
} from './managed-wsl-home-shell-preflight'
|
||||
import {
|
||||
_internals as managedWslHomeRegistryInternals,
|
||||
recordManagedWslCodexHome
|
||||
} from './managed-wsl-codex-home-registry'
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
@@ -16,6 +31,8 @@ function makeRoot(): string {
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
wslRealpathMock.mockClear()
|
||||
managedWslHomeRegistryInternals.clearRecordedManagedWslCodexHomes()
|
||||
for (const root of roots.splice(0)) {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
@@ -160,3 +177,123 @@ describe('managed Codex shell preflight', () => {
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('managed WSL Codex shell preflight', () => {
|
||||
const home = '/home/jin/.local/share/orca/codex-runtime-home/home'
|
||||
const runtimeHome =
|
||||
'\\\\wsl.localhost\\Ubuntu-24.04\\home\\jin\\.local\\share\\orca\\codex-runtime-home\\home'
|
||||
const env = {
|
||||
CODEX_HOME: home,
|
||||
ORCA_CODEX_HOME: home,
|
||||
WSL_DISTRO_NAME: 'Ubuntu-24.04'
|
||||
}
|
||||
|
||||
it('targets the pane-selected managed home through its UNC twin', () => {
|
||||
recordManagedWslCodexHome('Ubuntu-24.04', runtimeHome)
|
||||
expect(resolveManagedWslCodexShellPreflightTarget(env)).toEqual({
|
||||
runtimeHomePath: runtimeHome,
|
||||
wslDistro: 'Ubuntu-24.04'
|
||||
})
|
||||
})
|
||||
|
||||
it('reconstructs a validated managed home when a restarted runtime has no record', () => {
|
||||
expect(resolveManagedWslCodexShellPreflightTarget(env)).toEqual({
|
||||
runtimeHomePath: runtimeHome,
|
||||
wslDistro: 'Ubuntu-24.04'
|
||||
})
|
||||
})
|
||||
|
||||
it('installs once through the WSL runtime-home lane while hooks are enabled', async () => {
|
||||
recordManagedWslCodexHome('Ubuntu-24.04', runtimeHome)
|
||||
const status = {
|
||||
agent: 'codex' as const,
|
||||
state: 'installed' as const,
|
||||
configPath: 'config.toml',
|
||||
managedHooksPresent: true,
|
||||
detail: null
|
||||
}
|
||||
const install = vi.fn(() => status)
|
||||
|
||||
await expect(
|
||||
prepareManagedWslCodexHomeBeforeShellLaunch({ env, hooksEnabled: true, install })
|
||||
).resolves.toBe(status)
|
||||
expect(install).toHaveBeenCalledExactlyOnceWith(runtimeHome, {
|
||||
runtime: 'wsl',
|
||||
wslDistro: 'Ubuntu-24.04'
|
||||
})
|
||||
|
||||
await expect(
|
||||
prepareManagedWslCodexHomeBeforeShellLaunch({ env, hooksEnabled: false, install })
|
||||
).resolves.toBeNull()
|
||||
expect(install).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
'a user home',
|
||||
{ ...env, CODEX_HOME: '/home/jin/.codex', ORCA_CODEX_HOME: '/home/jin/.codex' }
|
||||
],
|
||||
['unequal routing markers', { ...env, ORCA_CODEX_HOME: `${home}-other` }],
|
||||
[
|
||||
'a parent traversal',
|
||||
{
|
||||
...env,
|
||||
CODEX_HOME: `/home/jin/../jin${home.slice('/home/jin'.length)}`,
|
||||
ORCA_CODEX_HOME: `/home/jin/../jin${home.slice('/home/jin'.length)}`
|
||||
}
|
||||
],
|
||||
[
|
||||
'a dot segment',
|
||||
{
|
||||
...env,
|
||||
CODEX_HOME: `/home/./jin${home.slice('/home/jin'.length)}`,
|
||||
ORCA_CODEX_HOME: `/home/./jin${home.slice('/home/jin'.length)}`
|
||||
}
|
||||
],
|
||||
[
|
||||
'a host path',
|
||||
{ ...env, CODEX_HOME: 'C:\\Users\\jin\\.codex', ORCA_CODEX_HOME: 'C:\\Users\\jin\\.codex' }
|
||||
],
|
||||
['a missing distro', { ...env, WSL_DISTRO_NAME: '' }],
|
||||
['a distro path escape', { ...env, WSL_DISTRO_NAME: 'Ubuntu\\..\\host' }],
|
||||
[
|
||||
'a repeated separator',
|
||||
{
|
||||
...env,
|
||||
CODEX_HOME: home.replace('/jin/', '/jin//'),
|
||||
ORCA_CODEX_HOME: home.replace('/jin/', '/jin//')
|
||||
}
|
||||
]
|
||||
])('rejects %s', (_label, candidate) => {
|
||||
recordManagedWslCodexHome('Ubuntu-24.04', runtimeHome)
|
||||
expect(resolveManagedWslCodexShellPreflightTarget(candidate)).toBeNull()
|
||||
})
|
||||
|
||||
it('preserves a recorded runtime spelling for a managed account home', () => {
|
||||
const directHome = '/home/jin/.local/share/orca/codex-accounts/account-1/home'
|
||||
const directRuntimeHome =
|
||||
'\\\\wsl$\\Ubuntu-24.04\\home\\jin\\.local\\share\\orca\\codex-accounts\\account-1\\home'
|
||||
recordManagedWslCodexHome('Ubuntu-24.04', directRuntimeHome)
|
||||
|
||||
expect(
|
||||
resolveManagedWslCodexShellPreflightTarget({
|
||||
CODEX_HOME: directHome,
|
||||
ORCA_CODEX_HOME: directHome,
|
||||
WSL_DISTRO_NAME: 'ubuntu-24.04'
|
||||
})
|
||||
).toEqual({ runtimeHomePath: directRuntimeHome, wslDistro: 'ubuntu-24.04' })
|
||||
})
|
||||
|
||||
it('never authorizes a WSL system home even when it was offered by a PTY lane', () => {
|
||||
const systemHome = '/home/jin/.codex'
|
||||
recordManagedWslCodexHome('Ubuntu-24.04', '\\\\wsl.localhost\\Ubuntu-24.04\\home\\jin\\.codex')
|
||||
|
||||
expect(
|
||||
resolveManagedWslCodexShellPreflightTarget({
|
||||
CODEX_HOME: systemHome,
|
||||
ORCA_CODEX_HOME: systemHome,
|
||||
WSL_DISTRO_NAME: 'Ubuntu-24.04'
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { win32 as pathWin32 } from 'node:path'
|
||||
import {
|
||||
foldWslUncPathCaseInsensitiveParts,
|
||||
parseWslUncPath,
|
||||
toWindowsWslPath
|
||||
} from '../../shared/wsl-paths'
|
||||
|
||||
type ManagedWslCodexHome = {
|
||||
runtimeHomePath: string
|
||||
linuxHomePath: string
|
||||
}
|
||||
|
||||
const managedHomesByDistro = new Map<string, Map<string, ManagedWslCodexHome>>()
|
||||
|
||||
function distroKey(distro: string): string {
|
||||
return distro.trim().toLowerCase()
|
||||
}
|
||||
|
||||
export function isAbsolutePosixPathWithoutDotSegments(value: string): boolean {
|
||||
const segments = value.split('/').slice(1)
|
||||
return (
|
||||
value.startsWith('/') &&
|
||||
!value.startsWith('//') &&
|
||||
!value.includes('\\') &&
|
||||
segments.every((segment) => segment && segment !== '.' && segment !== '..')
|
||||
)
|
||||
}
|
||||
|
||||
function isOrcaManagedWslCodexHome(linuxHomePath: string): boolean {
|
||||
const segments = linuxHomePath.split('/').filter(Boolean)
|
||||
const orcaIndex = segments.findIndex(
|
||||
(segment, index) =>
|
||||
segment === 'orca' && segments[index - 1] === 'share' && segments[index - 2] === '.local'
|
||||
)
|
||||
if (orcaIndex === -1) {
|
||||
return false
|
||||
}
|
||||
const tail = segments.slice(orcaIndex + 1)
|
||||
return (
|
||||
(tail.length === 2 && tail[0] === 'codex-runtime-home' && tail[1] === 'home') ||
|
||||
(tail.length === 3 && tail[0] === 'codex-accounts' && Boolean(tail[1]) && tail[2] === 'home')
|
||||
)
|
||||
}
|
||||
|
||||
function linuxHomeForRuntimePath(runtimeHomePath: string, distro: string): string | null {
|
||||
const wsl = parseWslUncPath(runtimeHomePath)
|
||||
if (wsl) {
|
||||
return wsl.distro.toLowerCase() === distroKey(distro) ? wsl.linuxPath : null
|
||||
}
|
||||
const drive = runtimeHomePath.match(/^([A-Za-z]):[/\\](.*)$/)
|
||||
return drive ? `/mnt/${drive[1].toLowerCase()}/${drive[2].replace(/\\/g, '/')}` : null
|
||||
}
|
||||
|
||||
export function recordManagedWslCodexHome(distro: string, runtimeHomePath: string): void {
|
||||
const normalizedDistro = distro.trim()
|
||||
const linuxHomePath = linuxHomeForRuntimePath(runtimeHomePath, normalizedDistro)
|
||||
if (
|
||||
!normalizedDistro ||
|
||||
/[\\/\r\n]/.test(normalizedDistro) ||
|
||||
!linuxHomePath ||
|
||||
!isAbsolutePosixPathWithoutDotSegments(linuxHomePath) ||
|
||||
!isOrcaManagedWslCodexHome(linuxHomePath)
|
||||
) {
|
||||
return
|
||||
}
|
||||
const homes = managedHomesByDistro.get(distroKey(normalizedDistro)) ?? new Map()
|
||||
homes.set(linuxHomePath, { runtimeHomePath, linuxHomePath })
|
||||
managedHomesByDistro.set(distroKey(normalizedDistro), homes)
|
||||
}
|
||||
|
||||
export function resolveRecordedManagedWslCodexHome(
|
||||
distro: string,
|
||||
linuxHomePath: string
|
||||
): string | null {
|
||||
if (
|
||||
!distro.trim() ||
|
||||
/[\\/\r\n]/.test(distro) ||
|
||||
!isAbsolutePosixPathWithoutDotSegments(linuxHomePath)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return managedHomesByDistro.get(distroKey(distro))?.get(linuxHomePath)?.runtimeHomePath ?? null
|
||||
}
|
||||
|
||||
export function resolveManagedWslCodexHome(distro: string, linuxHomePath: string): string | null {
|
||||
if (
|
||||
!distro.trim() ||
|
||||
/[\\/\r\n]/.test(distro) ||
|
||||
!isAbsolutePosixPathWithoutDotSegments(linuxHomePath) ||
|
||||
!isOrcaManagedWslCodexHome(linuxHomePath)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
resolveRecordedManagedWslCodexHome(distro, linuxHomePath) ??
|
||||
toWindowsWslPath(linuxHomePath, distro)
|
||||
)
|
||||
}
|
||||
|
||||
export function wslRuntimeHomePathsEqual(left: string | undefined, right: string): boolean {
|
||||
if (!left) {
|
||||
return false
|
||||
}
|
||||
const leftWsl = foldWslUncPathCaseInsensitiveParts(left)
|
||||
const rightWsl = foldWslUncPathCaseInsensitiveParts(right)
|
||||
if (leftWsl || rightWsl) {
|
||||
return leftWsl !== null && leftWsl === rightWsl
|
||||
}
|
||||
return pathWin32.normalize(left).toLowerCase() === pathWin32.normalize(right).toLowerCase()
|
||||
}
|
||||
|
||||
export const _internals = {
|
||||
clearRecordedManagedWslCodexHomes: () => managedHomesByDistro.clear(),
|
||||
isOrcaManagedWslCodexHome
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { realpath } from 'node:fs/promises'
|
||||
import type { AgentHookInstallStatus } from '../../shared/agent-hook-types'
|
||||
import { withTimeout } from '../../shared/promise-timeout-fallback'
|
||||
import type { CodexWslRuntimeHookTarget } from './codex-wsl-hook-install-plan'
|
||||
import { codexHookService } from './hook-service'
|
||||
import {
|
||||
isAbsolutePosixPathWithoutDotSegments,
|
||||
resolveManagedWslCodexHome,
|
||||
wslRuntimeHomePathsEqual
|
||||
} from './managed-wsl-codex-home-registry'
|
||||
|
||||
type WslShellPreflightEnvironment = {
|
||||
CODEX_HOME?: string
|
||||
ORCA_CODEX_HOME?: string
|
||||
WSL_DISTRO_NAME?: string
|
||||
}
|
||||
|
||||
const WSL_MANAGED_HOME_REALPATH_TIMEOUT_MS = 5_000
|
||||
|
||||
export type ManagedWslCodexShellPreflightTarget = {
|
||||
runtimeHomePath: string
|
||||
wslDistro: string
|
||||
}
|
||||
|
||||
export function resolveManagedWslCodexShellPreflightTarget(
|
||||
env: WslShellPreflightEnvironment
|
||||
): ManagedWslCodexShellPreflightTarget | null {
|
||||
const codexHome = env.CODEX_HOME?.trim()
|
||||
const orcaCodexHome = env.ORCA_CODEX_HOME?.trim()
|
||||
const wslDistro = env.WSL_DISTRO_NAME?.trim()
|
||||
if (
|
||||
!codexHome ||
|
||||
codexHome !== orcaCodexHome ||
|
||||
!wslDistro ||
|
||||
/[\\/\r\n]/.test(wslDistro) ||
|
||||
!isAbsolutePosixPathWithoutDotSegments(codexHome)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const runtimeHomePath = resolveManagedWslCodexHome(wslDistro, codexHome)
|
||||
return runtimeHomePath ? { runtimeHomePath, wslDistro } : null
|
||||
}
|
||||
|
||||
export async function prepareManagedWslCodexHomeBeforeShellLaunch(args: {
|
||||
env: WslShellPreflightEnvironment
|
||||
hooksEnabled: boolean
|
||||
install?: (
|
||||
runtimeHomePath: string,
|
||||
target: CodexWslRuntimeHookTarget
|
||||
) => AgentHookInstallStatus | Promise<AgentHookInstallStatus | null> | null
|
||||
}): Promise<AgentHookInstallStatus | null> {
|
||||
if (!args.hooksEnabled) {
|
||||
return null
|
||||
}
|
||||
const target = resolveManagedWslCodexShellPreflightTarget(args.env)
|
||||
if (!target) {
|
||||
return null
|
||||
}
|
||||
const realHome = await withTimeout(
|
||||
realpath(target.runtimeHomePath),
|
||||
WSL_MANAGED_HOME_REALPATH_TIMEOUT_MS,
|
||||
null
|
||||
)
|
||||
if (!realHome || !wslRuntimeHomePathsEqual(realHome, target.runtimeHomePath)) {
|
||||
return null
|
||||
}
|
||||
const install =
|
||||
args.install ??
|
||||
((home: string, hookTarget: CodexWslRuntimeHookTarget) =>
|
||||
codexHookService.installForRuntimeHomeSerialized(home, hookTarget))
|
||||
return await install(target.runtimeHomePath, {
|
||||
runtime: 'wsl',
|
||||
wslDistro: target.wslDistro
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user