Merge remote-tracking branch 'origin/main' into tmp/native-chat-recovery-main-integration

This commit is contained in:
Brennan Benson
2026-08-27 23:43:34 -07:00
274 changed files with 21896 additions and 2532 deletions
@@ -87,7 +87,9 @@ runs:
run: |
pnpm install --frozen-lockfile --ignore-scripts
# Job containers can run composite steps from a source mirror without .git.
git -C "$GITHUB_WORKSPACE" diff --exit-code -- package.json pnpm-lock.yaml
if [ "$(git -C "$GITHUB_WORKSPACE" rev-parse --is-inside-work-tree 2>/dev/null)" = true ]; then
git -C "$GITHUB_WORKSPACE" diff --exit-code -- package.json pnpm-lock.yaml
fi
# Why cached: `--ignore-scripts` leaves node-pty without build/Release, so
# ensure-native-runtime node-gyp-compiles it in every job that asks for a runtime.
@@ -1,26 +1,31 @@
diff --git a/binding.gyp b/binding.gyp
index 855bd4b86f0a3c18c7594212c0e42b6e35bc4001..5f15551cb1520af996f216500a83ac68b57f5104 100644
index 855bd4b86f0a3c18c7594212c0e42b6e35bc4001..33774e7ae296f0de39dd94156673c9e773638bf4 100644
--- a/binding.gyp
+++ b/binding.gyp
@@ -3,7 +3,7 @@
@@ -3,7 +3,6 @@
{
"target_name": "windows_process_tree",
"dependencies": [
- "<!(node -p \"require('node-addon-api').targets\"):node_addon_api_except",
+ "<!(node -p \"require.resolve('node-addon-api/node_addon_api.gyp')\"):node_addon_api_except",
],
"conditions": [
['OS=="win"', {
@@ -16,9 +16,6 @@
@@ -15,14 +14,13 @@
"src/process_commandline.cc"
],
"include_dirs": [],
- "include_dirs": [],
+ "include_dirs": ["deps/node-addon-api"],
+ "defines": ["NAPI_CPP_EXCEPTIONS", "_HAS_EXCEPTIONS=1"],
"libraries": [ 'psapi.lib' ],
- "msvs_configuration_attributes": {
- "SpectreMitigation": "Spectre"
- },
"msvs_settings": {
"VCCLCompilerTool": {
+ "ExceptionHandling": 1,
"AdditionalOptions": [
"/guard:cf",
"/sdl",
diff --git a/src/process.cc b/src/process.cc
index 3eea92077c4d1d433119361d5c432881859131e9..1998f4addd4d7e9aba946ea6f7f7a4a5d13291bc 100644
--- a/src/process.cc
File diff suppressed because one or more lines are too long
+9 -1
View File
@@ -2,13 +2,21 @@ diff --git a/binding.gyp b/binding.gyp
index 5f63978b07ab50aaf7523219a2170ec737a6b5db..bbd9e06136e8922f40b5779e35d4fc835f1479ab 100644
--- a/binding.gyp
+++ b/binding.gyp
@@ -1,13 +1,10 @@
@@ -1,13 +1,18 @@
{
'target_defaults': {
'dependencies': [
- "<!(node -p \"require('node-addon-api').targets\"):node_addon_api_except",
+ "<!(node -p \"require.resolve('node-addon-api/node_addon_api.gyp')\"):node_addon_api_except",
],
+ # Orca: GCC 9 (Ubuntu 20.04 floor) accepts C++20 as gnu++2a, but rejects
+ # the newer gnu++20 spelling emitted by Node 24's gyp flags.
+ 'cflags_cc!': [
+ '-std=gnu++20'
+ ],
+ 'cflags_cc': [
+ '-std=gnu++2a'
+ ],
'conditions': [
['OS=="win"', {
- 'msvs_configuration_attributes': {
File diff suppressed because one or more lines are too long
@@ -26,9 +26,11 @@ import {
mkdirSync,
openSync,
readFileSync,
readSync
readSync,
writeFileSync
} from 'node:fs'
import { join, resolve } from 'node:path'
import { createRequire } from 'node:module'
import { dirname, join, resolve } from 'node:path'
import { RELAY_WINDOWS_PROCESS_TREE_FILENAME } from '../../src/shared/relay-artifacts.ts'
import {
nodeGypRebuildInvocation,
@@ -70,13 +72,16 @@ function assertPatchApplied() {
'config/patches/@vscode__windows-process-tree@0.8.0.patch; run pnpm install.'
)
}
if (!bindingGyp.includes("require.resolve('node-addon-api/node_addon_api.gyp')")) {
if (bindingGyp.includes('node_addon_api.gyp')) {
throw new Error(
'binding.gyp still uses require("node-addon-api").targets. That path is ' +
'cwd-relative and misses node_addon_api.gyp under pnpm on Windows. ' +
'binding.gyp still depends on node_addon_api.gyp. pnpm and node-gyp rewrite that ' +
'project path incorrectly on Windows. ' +
'pnpm did not apply config/patches/@vscode__windows-process-tree@0.8.0.patch; run pnpm install.'
)
}
if (!bindingGyp.includes('"include_dirs": ["deps/node-addon-api"]')) {
throw new Error('binding.gyp does not use the staged node-addon-api headers.')
}
const processCc = readFileSync(join(PACKAGE_DIR, 'src', 'process.cc'), 'utf8')
if (processCc.includes('process_count < 1024')) {
throw new Error(
@@ -86,6 +91,58 @@ function assertPatchApplied() {
}
}
// pnpm can materialize this CRLF package without applying its patch. Repair the
// load-bearing build settings before node-gyp so the release build stays safe.
function applyWindowsProcessTreeBuildFixes() {
const bindingPath = join(PACKAGE_DIR, 'binding.gyp')
const processPath = join(PACKAGE_DIR, 'src', 'process.cc')
const nodeAddonApiDir = dirname(
createRequire(join(PACKAGE_DIR, 'package.json')).resolve('node-addon-api/package.json')
)
const stagedHeaderDir = join(PACKAGE_DIR, 'deps', 'node-addon-api')
let bindingGyp = readFileSync(bindingPath, 'utf8')
let processCc = readFileSync(processPath, 'utf8')
const originalBinding = bindingGyp
const originalProcess = processCc
for (const dynamicDependency of [
String.raw`<!(node -p \"require('node-addon-api').targets\"):node_addon_api_except`,
String.raw`<!(node -p \"require.resolve('node-addon-api/node_addon_api.gyp')\"):node_addon_api_except`,
'../../node-addon-api/node_addon_api.gyp:node_addon_api_except'
]) {
bindingGyp = bindingGyp.replace(`"${dynamicDependency}",`, '')
}
bindingGyp = bindingGyp.replace(
'"include_dirs": []',
'"include_dirs": ["deps/node-addon-api"],\n "defines": ["NAPI_CPP_EXCEPTIONS", "_HAS_EXCEPTIONS=1"]'
)
if (!bindingGyp.includes('"ExceptionHandling": 1')) {
bindingGyp = bindingGyp.replace(
'"VCCLCompilerTool": {',
'"VCCLCompilerTool": {\n "ExceptionHandling": 1,'
)
}
bindingGyp = bindingGyp.replace(
/\r?\n\s*"msvs_configuration_attributes": \{\s*"SpectreMitigation": "Spectre"\s*\},?/s,
''
)
processCc = processCc.replace(/process_count < 1024 && /, '')
if (bindingGyp !== originalBinding) {
writeFileSync(bindingPath, bindingGyp)
}
if (processCc !== originalProcess) {
writeFileSync(processPath, processCc)
}
mkdirSync(stagedHeaderDir, { recursive: true })
for (const header of ['napi.h', 'napi-inl.h', 'napi-inl.deprecated.h']) {
copyFileSync(join(nodeAddonApiDir, header), join(stagedHeaderDir, header))
}
if (bindingGyp !== originalBinding || processCc !== originalProcess) {
console.warn('[windows-process-tree] Repaired un-applied pnpm patch hunks before build.')
}
}
/** Read the PE machine field, so an arm64 request cannot ship an x64 binary. */
function readPeMachine(binaryPath) {
const fd = openSync(binaryPath, 'r')
@@ -112,6 +169,7 @@ function main() {
if (!existsSync(PACKAGE_DIR)) {
throw new Error(`${PACKAGE_DIR} is missing. Run pnpm install first.`)
}
applyWindowsProcessTreeBuildFixes()
assertPatchApplied()
const gyp = nodeGypRebuildInvocation(arch)
+5 -1
View File
@@ -378,11 +378,15 @@ function rebuildNodeRuntimeModules(moduleNames) {
function runPnpm(args, { cwd = projectDir } = {}) {
const command = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
const env =
process.platform === 'linux' && args.includes('node-gyp')
? { ...process.env, CXXFLAGS: `${process.env.CXXFLAGS ?? ''} -std=gnu++2a`.trim() }
: process.env
const result = spawnSync(command, args, {
cwd,
stdio: 'inherit',
shell: process.platform === 'win32',
env: process.env
env
})
if (result.error || result.status !== 0) {
@@ -29,6 +29,7 @@ describe('ensure-native-runtime', () => {
const binDir = join(projectDir, 'bin')
copyFileSync(sourceScriptPath, scriptPath)
writeFakeNativeModules(projectDir)
writeNodePtyPatchFile(projectDir)
writeFakePnpm(binDir)
const result = spawnSync(process.execPath, [scriptPath, '--runtime=node'], {
@@ -44,6 +45,9 @@ describe('ensure-native-runtime', () => {
const log = readFileSync(logPath, 'utf8')
expect(log).toContain('pnpm exec node-gyp rebuild\n')
expect(log).toContain(join('node_modules', 'node-pty'))
if (process.platform === 'linux') {
expect(log).toMatch(/^cxxflags=(?:.*\s)?-std=gnu\+\+2a$/m)
}
expect(log.split('\n').filter((line) => line.startsWith('node-pty child '))).toEqual([
expect.stringMatching(/^node-pty child (?:conpty|pty) marker=false$/),
expect.stringMatching(/^node-pty child (?:conpty|pty) marker=true$/)
@@ -239,6 +243,14 @@ exports.loadNativeModule = function loadNativeModule(nativeName) {
if (!markerExists) {
throw new Error('ABI mismatch sentinel')
}
return {
dir: '../build/Release/',
module: {
listJobProcessIds() {},
terminateJob() {},
assignCurrentProcessToJob() {}
}
}
}
`
)
@@ -337,6 +349,10 @@ appendFileSync(
process.env.ORCA_NATIVE_TEST_LOG,
\`npm_config_build_from_source=\${process.env.npm_config_build_from_source || ''}\\n\`
)
appendFileSync(
process.env.ORCA_NATIVE_TEST_LOG,
\`cxxflags=\${process.env.CXXFLAGS || ''}\\n\`
)
writeFileSync(process.env.ORCA_NATIVE_TEST_MARKER, 'rebuilt')
`
)
@@ -47,6 +47,33 @@ function executeInstallScript(fixture) {
}
describe('install-node-dependencies action', () => {
it('skips the lockfile diff when a job container has no Git metadata', () => {
const fixture = createFixture()
try {
rmSync(join(fixture.workspace, '.git'), { recursive: true, force: true })
const result = executeInstallScript(fixture)
expect(result.status, result.stderr || result.stdout).toBe(0)
expect(`${result.stdout}\n${result.stderr}`).not.toMatch(/^diff --git /m)
} finally {
rmSync(fixture.root, { recursive: true, force: true })
}
})
it('skips the lockfile diff for a bare repository', () => {
const fixture = createFixture()
try {
rmSync(join(fixture.workspace, '.git'), { recursive: true, force: true })
expect(run('git', ['init', '--bare', '-q'], { cwd: fixture.workspace }).status).toBe(0)
const result = executeInstallScript(fixture)
expect(result.status, result.stderr || result.stdout).toBe(0)
expect(`${result.stdout}\n${result.stderr}`).not.toMatch(/^diff --git /m)
} finally {
rmSync(fixture.root, { recursive: true, force: true })
}
})
it.each([
['package.json', '{"name":"changed"}\n'],
['pnpm-lock.yaml', 'lockfileVersion: 9\nchanged: true\n']
@@ -17,11 +17,11 @@ describe('Electron runtime package contract', () => {
'utf8'
)
expect(patch).toContain('diff --git a/src/Types.ts b/src/Types.ts')
expect(patch).toContain('readonly clearModelGeneration: number')
expect(patch).toContain('const generation = this._atlas.clearModelGeneration')
expect(patch).toContain('this.clearModelGeneration++')
expect(patch).toContain('this._atlas._clearModelGeneration||0')
expect(patch.match(/\^\(\?:\[1-8\]\\d\{2\}\|900\)\$/g)).toHaveLength(3)
})
it('keeps root postinstall as the single Electron binary install owner', () => {
@@ -434,6 +434,20 @@ describe('check-mode reporting', () => {
// These run without network or a build, so ordinary `pnpm test` catches the two
// desyncs that would otherwise only surface in the heavy xterm_patch_sync job.
describe('committed xterm patch artifacts', () => {
it('normalizes Chromium font-weight serialization in every WebGL runtime copy', async () => {
const patch = await readFile(
path.join(REPO_ROOT, 'config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch'),
'utf8'
)
expect(patch).not.toMatch(/desiredWeight !== '400'|orcaProbeWeight!=="400"/)
expect(
patch.match(/(?:actualWeightToken === 'normal'|orcaActualWeightToken==="normal")/g)
).toHaveLength(3)
expect(
patch.match(/(?:actualWeight !== desiredWeight|orcaActualWeight!==orcaProbeWeight)/g)
).toHaveLength(3)
})
it('records the lockfile hash pnpm derives from the patch file', async () => {
const manifest = JSON.parse(await readFile(MANIFEST_PATH, 'utf8'))
const lockfile = await readFile(path.join(REPO_ROOT, 'pnpm-lock.yaml'), 'utf8')
@@ -9,26 +9,30 @@ const PATCH = readFileSync(
'utf8'
)
const PACKAGE_DIR = join(projectDir, 'node_modules', '@vscode', 'windows-process-tree')
const ABSOLUTE_GYP = "require.resolve('node-addon-api/node_addon_api.gyp')"
const RESOLVED_GYP = "require.resolve('node-addon-api/node_addon_api.gyp')"
describe('windows-process-tree node-addon-api gyp path', () => {
it('keeps the gyp project path absolute so pnpm Windows source builds find it', () => {
expect(PATCH).toContain(
`+ "<!(node -p \\"require.resolve('node-addon-api/node_addon_api.gyp')\\"):node_addon_api_except"`
it('stages headers without a pnpm-sensitive gyp dependency', () => {
expect(PATCH).not.toContain('+ "../../node-addon-api')
expect(PATCH).toContain('+ "include_dirs": ["deps/node-addon-api"],')
expect(PATCH).toContain('+ "defines": ["NAPI_CPP_EXCEPTIONS", "_HAS_EXCEPTIONS=1"],')
const buildScript = readFileSync(
join(projectDir, 'config/scripts/build-windows-process-tree-relay-addon.mjs'),
'utf8'
)
const bindingGyp = readFileSync(join(PACKAGE_DIR, 'binding.gyp'), 'utf8')
expect(bindingGyp).toContain(ABSOLUTE_GYP)
expect(bindingGyp).not.toContain("require('node-addon-api').targets")
expect(
readFileSync(
join(projectDir, 'config/scripts/build-windows-process-tree-relay-addon.mjs'),
'utf8'
)
).toContain(ABSOLUTE_GYP)
expect(buildScript).toContain(
"for (const header of ['napi.h', 'napi-inl.h', 'napi-inl.deprecated.h'])"
)
expect(buildScript).toContain("import { createRequire } from 'node:module'")
expect(buildScript).toContain("import { dirname, join, resolve } from 'node:path'")
expect(buildScript).toContain(
"createRequire(join(PACKAGE_DIR, 'package.json')).resolve('node-addon-api/package.json')"
)
expect(buildScript).toContain('Repaired un-applied pnpm patch hunks before build.')
})
it('resolves node_addon_api.gyp to a real file from the package directory', () => {
const resolved = execFileSync(process.execPath, ['-p', ABSOLUTE_GYP], {
const resolved = execFileSync(process.execPath, ['-p', RESOLVED_GYP], {
cwd: PACKAGE_DIR,
encoding: 'utf8'
}).trim()
+1
View File
@@ -209,6 +209,7 @@ export const electronViteConfig: UserConfig = {
index: resolve('src/main/index.ts'),
// Why: sandboxed webview preloads cannot load Rollup helper chunks.
'browser-window-close-preload': resolve('src/preload/browser-window-close.ts'),
'doc-preview-link-preload': resolve('src/preload/doc-preview-link.ts'),
'daemon-entry': resolve('src/main/daemon/daemon-entry.ts'),
'plugin-host-entry': resolve('src/main/plugins/plugin-host-entry.ts'),
'computer-sidecar': resolve('src/main/computer/sidecar-entry.ts'),
+209 -23
View File
@@ -1,20 +1,35 @@
import { useCallback, useEffect, useState, useSyncExternalStore } from 'react'
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react'
import { View, Text, StyleSheet, Pressable, Platform } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useRouter } from 'expo-router'
import { useLocalSearchParams, useRouter } from 'expo-router'
import * as Clipboard from 'expo-clipboard'
import Constants from 'expo-constants'
import { ChevronLeft, Copy, Check } from 'lucide-react-native'
import { ChevronLeft, Copy, Check, Send } from 'lucide-react-native'
import { colors, spacing, typography } from '../src/theme/mobile-theme'
import { ConnectionLog } from '../src/components/ConnectionLog'
import { loadHosts } from '../src/transport/host-store'
import { connectionLogStore } from '../src/transport/connection-log-buffer'
import { useHostClient } from '../src/transport/client-context'
import { connectionLogStore } from '../src/transport/persisted-connection-log-store'
import { useHostClient, useRpcClientContext } from '../src/transport/client-context'
import {
useLastConnectedAt,
useConnectionPathStatus,
useReconnectAttempt
} from '../src/transport/client-context-connection-metrics'
import { buildConnectionDiagnosticsReport } from '../src/diagnostics/connection-diagnostics-report'
import {
diagnoseConnection,
getReportableConnectionIncidentId
} from '../src/diagnostics/connection-diagnostics-analysis'
import { submitConnectionDiagnostics } from '../src/diagnostics/connection-diagnostics-submission'
import {
readHydratedConnectionLog,
readConnectionDiagnosticsSnapshot,
resolveDiagnosticsHostId,
getDiagnosticsSubmissionState,
updateDiagnosticsSubmissionState,
type DiagnosticsSubmissionStates
} from '../src/diagnostics/connection-diagnostics-screen-data'
import { useHostStatusGates } from '../src/transport/host-status-gates'
import { loadHostAppVersion } from '../src/transport/host-app-version-store'
import type { ConnectionLogEntry, HostProfile } from '../src/transport/types'
// Why: getSnapshot must be referentially stable when there's no data —
@@ -25,11 +40,19 @@ const EMPTY_ENTRIES: readonly ConnectionLogEntry[] = []
// screen also *acquires* the host client — opening it kicks a dial and the
// log fills live instead of showing a stale tail.
export default function ConnectionLogScreen() {
const clientContext = useRpcClientContext()
const router = useRouter()
const params = useLocalSearchParams<{ hostId?: string }>()
const insets = useSafeAreaInsets()
const routeKey = useMemo(() => ({}), [params.hostId])
const [hosts, setHosts] = useState<HostProfile[]>([])
const [selectedId, setSelectedId] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
const [manualSelection, setManualSelection] = useState<{
hostId: string
requestedHostId: string | undefined
routeKey: object
} | null>(null)
const [copiedHostId, setCopiedHostId] = useState<string | null>(null)
const [submissionStates, setSubmissionStates] = useState<DiagnosticsSubmissionStates>({})
useEffect(() => {
let stale = false
@@ -38,17 +61,28 @@ export default function ConnectionLogScreen() {
return
}
setHosts(loaded)
setSelectedId((prev) => prev ?? loaded[0]?.id ?? null)
})
return () => {
stale = true
}
}, [])
const selectedId = resolveDiagnosticsHostId(hosts, params.hostId, manualSelection, routeKey)
const selected = hosts.find((h) => h.id === selectedId) ?? null
const { state } = useHostClient(selected?.id)
const { client, state } = useHostClient(selected?.id)
const { desktopAppVersion: liveDesktopAppVersion } = useHostStatusGates({
hostId: selected?.id,
client,
connState: state
})
const reconnectAttempts = useReconnectAttempt(selected?.id)
const lastConnectedAt = useLastConnectedAt(selected?.id)
const { activePath, pendingPath } = useConnectionPathStatus(selected?.id)
useEffect(() => {
if (selectedId) {
void readHydratedConnectionLog(connectionLogStore, selectedId)
}
}, [selectedId])
const subscribe = useCallback(
(listener: () => void) =>
@@ -60,25 +94,93 @@ export default function ConnectionLogScreen() {
[selectedId]
)
const entries = useSyncExternalStore(subscribe, getSnapshot)
const diagnosis = selected
? diagnoseConnection({ endpoint: selected.endpoint, state, activePath, pendingPath, entries })
: null
const incidentId = selected
? getReportableConnectionIncidentId({
endpoint: selected.endpoint,
state,
activePath,
pendingPath,
entries
})
: null
const submissionKey = selected && incidentId ? `${selected.id}:${incidentId}` : null
const submissionState = getDiagnosticsSubmissionState(submissionStates, submissionKey)
const copied = copiedHostId === selectedId
const copyDiagnostics = useCallback(async () => {
if (!selected) {
return
}
const desktopAppVersion = liveDesktopAppVersion ?? (await loadHostAppVersion(selected.id))
const snapshot = await readConnectionDiagnosticsSnapshot(
clientContext,
connectionLogStore,
selected.id
)
const report = buildConnectionDiagnosticsReport({
hostName: selected.name,
endpoint: selected.endpoint,
state,
reconnectAttempts,
lastConnectedAt,
state: snapshot.state,
reconnectAttempts: snapshot.reconnectAttempts,
lastConnectedAt: snapshot.lastConnectedAt,
platform: `${Platform.OS} ${Platform.Version ?? ''}`.trim(),
appVersion: Constants.expoConfig?.version ?? 'unknown',
entries
desktopAppVersion,
entries: snapshot.entries,
activePath: snapshot.activePath,
pendingPath: snapshot.pendingPath
})
await Clipboard.setStringAsync(report)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}, [selected, state, reconnectAttempts, lastConnectedAt, entries])
setCopiedHostId(selected.id)
setTimeout(() => setCopiedHostId((hostId) => (hostId === selected.id ? null : hostId)), 2000)
}, [selected, liveDesktopAppVersion, clientContext])
const sendDiagnostics = useCallback(async () => {
if (!selected || !submissionKey || submissionState === 'sending') {
return
}
const startedKey = submissionKey
setSubmissionStates((states) => updateDiagnosticsSubmissionState(states, startedKey, 'sending'))
const appVersion = Constants.expoConfig?.version ?? 'unknown'
const platform = `${Platform.OS} ${Platform.Version ?? ''}`.trim()
const desktopAppVersion = liveDesktopAppVersion ?? (await loadHostAppVersion(selected.id))
const snapshot = await readConnectionDiagnosticsSnapshot(
clientContext,
connectionLogStore,
selected.id
)
const currentIncidentId = getReportableConnectionIncidentId({
endpoint: selected.endpoint,
state: snapshot.state,
activePath: snapshot.activePath,
pendingPath: snapshot.pendingPath,
entries: snapshot.entries
})
if (`${selected.id}:${currentIncidentId ?? ''}` !== startedKey) {
setSubmissionStates((states) => updateDiagnosticsSubmissionState(states, startedKey, null))
return
}
const report = buildConnectionDiagnosticsReport({
hostName: selected.name,
endpoint: selected.endpoint,
state: snapshot.state,
reconnectAttempts: snapshot.reconnectAttempts,
lastConnectedAt: snapshot.lastConnectedAt,
platform,
appVersion,
desktopAppVersion,
entries: snapshot.entries,
activePath: snapshot.activePath,
pendingPath: snapshot.pendingPath
})
const result = await submitConnectionDiagnostics({ report, appVersion, platform })
setSubmissionStates((states) =>
updateDiagnosticsSubmissionState(states, startedKey, result.ok ? 'sent' : 'failed')
)
}, [selected, submissionKey, submissionState, liveDesktopAppVersion, clientContext])
return (
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
@@ -86,7 +188,7 @@ export default function ConnectionLogScreen() {
<Pressable style={styles.backButton} onPress={() => router.back()}>
<ChevronLeft size={22} color={colors.textSecondary} />
</Pressable>
<Text style={styles.heading}>Connection log</Text>
<Text style={styles.heading}>Network diagnostics</Text>
</View>
{hosts.length > 1 && (
@@ -95,7 +197,9 @@ export default function ConnectionLogScreen() {
<Pressable
key={host.id}
style={[styles.hostChip, host.id === selectedId && styles.hostChipActive]}
onPress={() => setSelectedId(host.id)}
onPress={() =>
setManualSelection({ hostId: host.id, requestedHostId: params.hostId, routeKey })
}
>
<Text
style={[styles.hostChipText, host.id === selectedId && styles.hostChipTextActive]}
@@ -121,14 +225,49 @@ export default function ConnectionLogScreen() {
) : (
<Copy size={14} color={colors.textSecondary} />
)}
<Text style={styles.copyButtonText}>{copied ? 'Copied' : 'Copy diagnostics'}</Text>
<Text style={styles.copyButtonText}>{copied ? 'Copied' : 'Copy report'}</Text>
</Pressable>
</View>
{diagnosis && (
<View style={styles.diagnosisCard}>
<Text style={styles.diagnosisHeading}>What this suggests</Text>
<Text style={styles.diagnosisText}>{diagnosis.likelyCause}</Text>
<Text style={styles.diagnosisNext}>{diagnosis.nextStep}</Text>
{diagnosis.reportability === 'orca-relay' && (
<>
<Text style={styles.privacyHint}>
Sends a size-limited redacted report including host name, endpoint, versions,
connection state, and events—never terminal contents or credentials.
</Text>
<Pressable
style={styles.sendButton}
onPress={() => void sendDiagnostics()}
disabled={submissionState === 'sending'}
>
{submissionState === 'sent' ? (
<Check size={14} color={colors.statusGreen} />
) : (
<Send size={14} color={colors.textPrimary} />
)}
<Text style={styles.sendButtonText}>
{submissionState === 'sending'
? 'Sending…'
: submissionState === 'sent'
? 'Diagnostics sent'
: submissionState === 'failed'
? 'Retry sending'
: 'Send diagnostics to Orca'}
</Text>
</Pressable>
</>
)}
</View>
)}
{entries.length > 0 ? (
<ConnectionLog entries={[...entries]} title={selected.name} />
<ConnectionLog entries={[...entries]} title={selected.name} fillAvailableHeight />
) : (
<Text style={styles.emptyText}>
No connection events yet this session. Events appear as the app dials this host.
No connection events yet. Events appear as the app dials this host.
</Text>
)}
</>
@@ -199,6 +338,53 @@ const styles = StyleSheet.create({
fontSize: typography.metaSize,
color: colors.textSecondary
},
diagnosisCard: {
backgroundColor: colors.bgPanel,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.borderSubtle,
borderRadius: 10,
padding: spacing.md,
marginBottom: spacing.md
},
diagnosisHeading: {
fontSize: typography.metaSize,
fontWeight: '600',
color: colors.textPrimary,
marginBottom: spacing.xs
},
diagnosisText: {
fontSize: typography.metaSize,
color: colors.textPrimary,
lineHeight: 18
},
diagnosisNext: {
fontSize: typography.metaSize,
color: colors.textSecondary,
lineHeight: 18,
marginTop: spacing.xs
},
privacyHint: {
marginTop: spacing.sm,
fontSize: 11,
lineHeight: 15,
color: colors.textMuted
},
sendButton: {
marginTop: spacing.md,
alignSelf: 'flex-start',
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
borderRadius: 8,
backgroundColor: colors.bgRaised
},
sendButtonText: {
fontSize: typography.metaSize,
fontWeight: '600',
color: colors.textPrimary
},
copyButton: {
flexDirection: 'row',
alignItems: 'center',
+12
View File
@@ -63,6 +63,7 @@ import { ConfirmModal } from '../../../src/components/ConfirmModal'
import { BottomDrawer } from '../../../src/components/BottomDrawer'
import { useHostProtocolGates } from '../../../src/components/HostProtocolGate'
import { AuthFailedBanner } from '../../../src/components/AuthFailedBanner'
import { HostDiagnosticsLink } from '../../../src/components/HostDiagnosticsLink'
import { HostRouteNoticeBanner } from '../../../src/components/HostRouteNoticeBanner'
import { visibleHostRouteNotice } from '../../../src/host-route-notice'
import { MobileSearchField } from '../../../src/components/MobileSearchField'
@@ -1091,6 +1092,17 @@ export function HostScreen({
/>
)}
{connState !== 'connected' &&
!relayRecovery.pairingRejected &&
reconnectAttempts >= 3 &&
hostId ? (
<HostDiagnosticsLink
onPress={() =>
router.push({ pathname: '/connection-log', params: { hostId: String(hostId) } })
}
/>
) : null}
{/* Why a bounced route landed here (e.g. the workspace was deleted on the desktop). */}
{routeNotice && (
<HostRouteNoticeBanner
+1 -1
View File
@@ -222,7 +222,7 @@ export default function TroubleshootScreen() {
onPress={() => router.push('/connection-log')}
>
<ScrollText size={16} color={colors.textPrimary} />
<Text style={styles.diagnosticButtonLabel}>View connection log</Text>
<Text style={styles.diagnosticButtonLabel}>View network diagnostics</Text>
</Pressable>
{checks.length > 0 && (
@@ -0,0 +1,81 @@
import { createElement } from 'react'
import { act, create } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ConnectionLogEntry } from '../transport/types'
import { ConnectionLog } from './ConnectionLog'
vi.mock('react-native', () => ({
ScrollView: 'ScrollView',
StyleSheet: { create: <T,>(styles: T) => styles, hairlineWidth: 1 },
Text: 'Text',
View: 'View'
}))
const entries: ConnectionLogEntry[] = [
{ id: 'event-1', ts: 1, level: 'info', message: 'Opening WebSocket' }
]
const duplicateEntries: ConnectionLogEntry[] = [
{ id: 'relay-1', ts: 1, level: 'info', message: 'Relay recovery started' },
{ id: 'relay-1', ts: 2, level: 'warn', message: 'Relay retry scheduled' }
]
describe('ConnectionLog', () => {
type RenderedNode = { props: { style?: unknown } }
type Renderer = {
root: {
findAllByType: (type: unknown) => RenderedNode[]
findByType: (type: unknown) => RenderedNode
}
unmount: () => void
}
let renderer: Renderer | null = null
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
})
function renderLog(fillAvailableHeight = false): Renderer {
act(() => {
renderer = create(
createElement(ConnectionLog, { entries, fillAvailableHeight })
) as unknown as Renderer
})
return renderer as Renderer
}
it('keeps the compact height in pairing flows', () => {
const instance = renderLog()
const containerStyles = instance.root.findAllByType('View')[0]!.props.style
expect(containerStyles).toContainEqual({ maxHeight: 240 })
})
it('fills the available diagnostics viewport', () => {
const instance = renderLog(true)
const containerStyles = instance.root.findAllByType('View')[0]!.props.style
const scroll = instance.root.findByType('ScrollView')
expect(containerStyles).toContainEqual({ flex: 1 })
expect(scroll.props.style).toEqual({ flex: 1 })
})
it('does not emit duplicate-key warnings for repeated persisted event IDs', () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
act(() => {
renderer = create(
createElement(ConnectionLog, { entries: duplicateEntries })
) as unknown as Renderer
})
expect(
consoleError.mock.calls.some(([message]) =>
String(message).includes('Encountered two children with the same key')
)
).toBe(false)
consoleError.mockRestore()
})
})
+40 -20
View File
@@ -8,6 +8,7 @@ type Props = {
// Tag printed before the first entry so it's clear what's being logged
// (e.g. 'Pairing' vs 'Reconnect').
title?: string
fillAvailableHeight?: boolean
}
const LEVEL_COLOR: Record<ConnectionLogEntry['level'], string> = {
@@ -37,42 +38,53 @@ function formatTime(ts: number, baseTs: number): string {
return `+${Math.round(elapsed)}s`
}
export function ConnectionLog({ entries, title }: Props) {
export function ConnectionLog({ entries, title, fillAvailableHeight = false }: Props) {
const scrollRef = useRef<ScrollView | null>(null)
if (entries.length === 0) {
return null
}
const baseTs = entries[0]!.ts
const keyOccurrences = new Map<string, number>()
return (
<View style={styles.container}>
<View
style={[
styles.container,
fillAvailableHeight ? styles.fillContainer : styles.boundedContainer
]}
>
{title && <Text style={styles.title}>{title}</Text>}
<ScrollView
ref={scrollRef}
style={styles.scroll}
style={fillAvailableHeight ? styles.fillScroll : styles.boundedScroll}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
onContentSizeChange={() => scrollRef.current?.scrollToEnd({ animated: true })}
>
{entries.map((entry) => (
<View key={entry.id} style={styles.row}>
<Text style={styles.timestamp}>{formatTime(entry.ts, baseTs)}</Text>
<Text style={[styles.glyph, { color: LEVEL_COLOR[entry.level] }]}>
{LEVEL_GLYPH[entry.level]}
</Text>
<View style={styles.rowText}>
<Text style={[styles.message, { color: LEVEL_COLOR[entry.level] }]}>
{entry.message}
{entries.map((entry) => {
const occurrence = keyOccurrences.get(entry.id) ?? 0
keyOccurrences.set(entry.id, occurrence + 1)
const renderKey = occurrence === 0 ? entry.id : `${entry.id}:${occurrence}`
return (
<View key={renderKey} style={styles.row}>
<Text style={styles.timestamp}>{formatTime(entry.ts, baseTs)}</Text>
<Text style={[styles.glyph, { color: LEVEL_COLOR[entry.level] }]}>
{LEVEL_GLYPH[entry.level]}
</Text>
{entry.detail && (
<Text style={styles.detail} numberOfLines={2}>
{entry.detail}
<View style={styles.rowText}>
<Text style={[styles.message, { color: LEVEL_COLOR[entry.level] }]}>
{entry.message}
</Text>
)}
{entry.detail && (
<Text style={styles.detail} numberOfLines={2}>
{entry.detail}
</Text>
)}
</View>
</View>
</View>
))}
)
})}
</ScrollView>
</View>
)
@@ -81,7 +93,6 @@ export function ConnectionLog({ entries, title }: Props) {
const styles = StyleSheet.create({
container: {
width: '100%',
maxHeight: 240,
backgroundColor: colors.bgPanel,
borderRadius: radii.card,
borderWidth: StyleSheet.hairlineWidth,
@@ -89,6 +100,12 @@ const styles = StyleSheet.create({
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md
},
boundedContainer: {
maxHeight: 240
},
fillContainer: {
flex: 1
},
title: {
fontSize: typography.metaSize,
fontFamily: typography.monoFamily,
@@ -97,9 +114,12 @@ const styles = StyleSheet.create({
letterSpacing: 1,
marginBottom: spacing.xs
},
scroll: {
boundedScroll: {
maxHeight: 200
},
fillScroll: {
flex: 1
},
scrollContent: {
gap: 6
},
@@ -0,0 +1,38 @@
import { ChevronRight } from 'lucide-react-native'
import { Pressable, StyleSheet, Text } from 'react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
export function HostDiagnosticsLink({ onPress }: { onPress: () => void }): React.JSX.Element {
return (
<Pressable
style={styles.link}
onPress={onPress}
accessibilityRole="button"
accessibilityLabel="View network diagnostics"
>
<Text style={styles.text}>View network diagnostics</Text>
<ChevronRight size={16} color={colors.textSecondary} />
</Pressable>
)
}
const styles = StyleSheet.create({
link: {
marginHorizontal: spacing.md,
marginBottom: spacing.sm,
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
borderRadius: radii.button,
backgroundColor: colors.bgPanel,
borderWidth: 1,
borderColor: colors.borderSubtle,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between'
},
text: {
color: colors.textSecondary,
fontSize: typography.metaSize,
fontWeight: '600'
}
})
@@ -0,0 +1,240 @@
import { describe, expect, it } from 'vitest'
import {
diagnoseConnection,
getReportableConnectionIncidentId
} from './connection-diagnostics-analysis'
import type { ConnectionLogEntry } from '../transport/types'
function event(message: string, detail?: string): ConnectionLogEntry {
return { id: message, ts: 1, level: 'error', message, detail }
}
describe('diagnoseConnection', () => {
it('reports the current healthy path instead of a historical failure', () => {
expect(
diagnoseConnection({
endpoint: 'ws://100.88.90.25:6768',
state: 'connected',
activePath: 'relay',
entries: [event('WebSocket connect timeout')]
})
).toEqual({
likelyCause: 'Connection is healthy via Relay.',
nextStep: 'No action needed.',
reportability: 'none'
})
})
it('distinguishes an invalid Relay credential from a transient outage', () => {
expect(
diagnoseConnection({
endpoint: 'ws://100.88.90.25:6768',
state: 'reconnecting',
pendingPath: 'relay',
entries: [event('Relay: relay dial failed', 'relay director resolve failed (401)')]
})
).toEqual({
likelyCause: 'Relay rejected the saved resume credential.',
nextStep: 'Try a direct connection; if Relay keeps returning 401, pair this device again.',
reportability: 'none'
})
})
it('identifies the direct Tailscale timeout while Relay recovery is pending', () => {
expect(
diagnoseConnection({
endpoint: 'ws://100.88.90.25:6768',
state: 'reconnecting',
activePath: 'tailscale',
pendingPath: 'relay',
entries: [event('WebSocket connect timeout')]
})
).toEqual({
likelyCause: 'The saved Tailscale endpoint did not answer before the connection timeout.',
nextStep: 'Relay recovery is in progress; keep Orca open while it retries.',
reportability: 'none'
})
})
it('identifies an authenticated Relay liveness failure', () => {
expect(
diagnoseConnection({
endpoint: 'ws://192.168.1.2:6768',
state: 'reconnecting',
activePath: 'relay',
entries: [
{
...event('Relay health check failed'),
code: 'liveness-timeout',
path: 'relay'
}
]
})
).toEqual({
likelyCause: 'Relay stopped answering authenticated health checks.',
nextStep: 'Orca closed the stale session and started recovery.',
reportability: 'orca-relay'
})
})
it('separates an active Relay close from a failed Relay dial', () => {
expect(
diagnoseConnection({
endpoint: 'ws://192.168.1.2:6768',
state: 'reconnecting',
activePath: 'relay',
entries: [
{
...event('Relay: active relay session failed', 'RelayOuterError: close code 4408'),
code: 'relay-session-failed',
path: 'relay'
}
]
})
).toEqual({
likelyCause: 'The active Relay session closed unexpectedly.',
nextStep: 'Orca started Relay recovery; the event history includes the cell close reason.',
reportability: 'orca-relay'
})
})
it('marks authenticated Relay liveness failures as safe to send', () => {
expect(
diagnoseConnection({
endpoint: 'ws://192.168.1.2:6768',
state: 'reconnecting',
activePath: 'relay',
entries: [
{
...event('Relay health check failed'),
code: 'liveness-timeout',
path: 'relay'
}
]
}).reportability
).toBe('orca-relay')
})
it.each([
['direct timeout', 'connect-timeout', 'tailscale'],
['handshake timeout', 'handshake-timeout', 'relay'],
['invalid credential', 'relay director resolve failed (401)', 'relay'],
['ambiguous recovery', 'retry scheduled', 'relay']
] as const)('does not offer submission for %s', (_name, message, path) => {
expect(
diagnoseConnection({
endpoint: 'ws://100.88.90.25:6768',
state: 'reconnecting',
pendingPath: 'relay',
entries: [{ ...event(message), path }]
}).reportability
).toBe('none')
})
it('does not offer submission for a bounded Relay director outage', () => {
expect(
diagnoseConnection({
endpoint: 'ws://100.88.90.25:6768',
state: 'reconnecting',
pendingPath: 'relay',
entries: [event('Relay: relay dial failed', 'relay director resolve failed (503)')]
}).reportability
).toBe('none')
})
it('classifies the newest failure instead of an older persisted 401', () => {
expect(
diagnoseConnection({
endpoint: 'ws://100.88.90.25:6768',
state: 'reconnecting',
pendingPath: 'relay',
entries: [
event('Relay: relay dial failed', 'relay director resolve failed (401)'),
{ ...event('WebSocket connect timeout'), id: 'newer', ts: 2, code: 'connect-timeout' }
]
}).likelyCause
).toBe('The saved Tailscale endpoint did not answer before the connection timeout.')
})
it('requires structured Relay evidence before offering submission', () => {
expect(
diagnoseConnection({
endpoint: 'ws://192.168.1.2:6768',
state: 'reconnecting',
activePath: 'relay',
entries: [event('active relay session failed')]
}).reportability
).toBe('none')
})
it('ignores failures from before the current app resume boundary', () => {
expect(
diagnoseConnection({
endpoint: 'ws://192.168.1.2:6768',
state: 'connecting',
activePath: 'lan',
entries: [
{
...event('Relay health check failed'),
code: 'liveness-timeout',
path: 'relay'
},
{
...event('App returned to foreground'),
id: 'resume',
ts: 2,
code: 'app-resumed'
},
{ ...event('WebSocket closed'), id: 'closed', ts: 3, code: 'socket-closed' }
]
}).reportability
).toBe('none')
})
it('uses a structured direct liveness path ahead of the current active path', () => {
expect(
diagnoseConnection({
endpoint: 'ws://192.168.1.2:6768',
state: 'reconnecting',
activePath: 'relay',
entries: [
{
...event('Connection health check failed'),
code: 'liveness-timeout',
path: 'lan'
}
]
})
).toEqual({
likelyCause: 'The connected host stopped answering authenticated health checks.',
nextStep: 'Orca closed the stale session and started recovery.',
reportability: 'none'
})
})
it('keys reportability to the current structured incident', () => {
const args = {
endpoint: 'ws://192.168.1.2:6768',
state: 'reconnecting' as const,
activePath: 'relay' as const,
entries: [
{ ...event('Authenticated'), code: 'direct-connected' as const, path: 'lan' as const },
{
...event('Relay health check failed'),
id: 'current-relay-failure',
ts: 2,
code: 'liveness-timeout' as const,
path: 'relay' as const
}
]
}
expect(getReportableConnectionIncidentId(args)).toBe('current-relay-failure')
expect(
getReportableConnectionIncidentId({
...args,
entries: [...args.entries, { ...event('Network changed'), ts: 3, code: 'network-changed' }]
})
).toBeNull()
})
})
@@ -0,0 +1,168 @@
import { isTailscaleEndpoint } from '../../../src/shared/remote-runtime-tailscale-hint'
import type {
ConnectionLogEntry,
ConnectionState,
MobileConnectionDiagnosticPath
} from '../transport/types'
export type ConnectionDiagnosis = {
likelyCause: string
nextStep: string
reportability: 'none' | 'orca-relay'
}
type DiagnoseConnectionArgs = {
endpoint: string
state: ConnectionState
activePath?: MobileConnectionDiagnosticPath
pendingPath?: MobileConnectionDiagnosticPath | null
entries: readonly ConnectionLogEntry[]
}
export function diagnoseConnection(args: DiagnoseConnectionArgs): ConnectionDiagnosis {
if (args.state === 'connected') {
return {
likelyCause: `Connection is healthy${args.activePath ? ` via ${formatPath(args.activePath)}` : ''}.`,
nextStep: 'No action needed.',
reportability: 'none'
}
}
const failure = findCurrentDiagnosticFailure(args.entries)
const evidence = failure ? `${failure.code ?? ''} ${failure.message} ${failure.detail ?? ''}` : ''
if (/relay director resolve failed \(401\)/i.test(evidence)) {
return {
likelyCause: 'Relay rejected the saved resume credential.',
nextStep: 'Try a direct connection; if Relay keeps returning 401, pair this device again.',
reportability: 'none'
}
}
if (/relay director resolve failed \(503\)/i.test(evidence)) {
const retryMs = parseRetryDelayMs(evidence)
return {
likelyCause: `Relay service was temporarily unavailable${retryMs == null ? '.' : ` and asked Orca to retry in ${formatDelay(retryMs)}.`}`,
nextStep: 'Keep Orca open; recovery should retry automatically.',
reportability: 'none'
}
}
if (/liveness-timeout|liveness timeout|connection health check failed/i.test(evidence)) {
const relayLiveness = failure?.code === 'liveness-timeout' && failure.path === 'relay'
const structuredDirectLiveness =
failure?.code === 'liveness-timeout' &&
(failure.path === 'lan' || failure.path === 'tailscale')
const path =
relayLiveness || (!structuredDirectLiveness && args.activePath === 'relay')
? 'Relay'
: 'The connected host'
return {
likelyCause: `${path} stopped answering authenticated health checks.`,
nextStep: 'Orca closed the stale session and started recovery.',
reportability: relayLiveness ? 'orca-relay' : 'none'
}
}
if (/relay-session-failed|active relay session failed/i.test(evidence)) {
return {
likelyCause: 'The active Relay session closed unexpectedly.',
nextStep: 'Orca started Relay recovery; the event history includes the cell close reason.',
reportability:
failure?.code === 'relay-session-failed' && failure.path === 'relay' ? 'orca-relay' : 'none'
}
}
if (/authentication-rejected|unauthorized|pairing may be revoked/i.test(evidence)) {
return {
likelyCause: 'The desktop rejected this device during authentication.',
nextStep: 'Confirm the device is still paired; pair it again if the rejection repeats.',
reportability: 'none'
}
}
if (/connect-timeout|websocket connect timeout/i.test(evidence)) {
return {
likelyCause: isTailscaleEndpoint(args.endpoint)
? 'The saved Tailscale endpoint did not answer before the connection timeout.'
: 'The saved direct endpoint did not answer before the connection timeout.',
nextStep:
args.pendingPath === 'relay'
? 'Relay recovery is in progress; keep Orca open while it retries.'
: 'Check the local/VPN network and confirm the desktop is awake.',
reportability: 'none'
}
}
if (/handshake-timeout|handshake timeout/i.test(evidence)) {
return {
likelyCause: 'The endpoint opened, but the encrypted Orca handshake did not finish.',
nextStep: 'Confirm the desktop is running a compatible Orca version and retry.',
reportability: 'none'
}
}
if (args.pendingPath === 'relay') {
return {
likelyCause: 'Relay recovery is selected, but no more specific failure is recorded yet.',
nextStep: 'Keep this page open while the next recovery event is recorded.',
reportability: 'none'
}
}
return {
likelyCause: 'No single failure cause can be determined from the recorded events.',
nextStep: 'Run diagnostics and copy the report again after the next connection attempt.',
reportability: 'none'
}
}
export function getReportableConnectionIncidentId(args: DiagnoseConnectionArgs): string | null {
if (diagnoseConnection(args).reportability !== 'orca-relay') {
return null
}
return findCurrentDiagnosticFailure(args.entries)?.id ?? null
}
function findCurrentDiagnosticFailure(
entries: readonly ConnectionLogEntry[]
): ConnectionLogEntry | undefined {
const boundaryIndex = entries.findLastIndex(isDiagnosticBoundary)
return entries
.slice(boundaryIndex + 1)
.toReversed()
.find(isDiagnosticFailure)
}
function isDiagnosticBoundary(entry: ConnectionLogEntry): boolean {
return (
entry.code === 'client-session-started' ||
entry.code === 'app-resumed' ||
entry.code === 'network-changed' ||
entry.code === 'relay-connected' ||
entry.code === 'direct-connected' ||
entry.message === 'Authenticated'
)
}
function isDiagnosticFailure(entry: ConnectionLogEntry): boolean {
const evidence = `${entry.code ?? ''} ${entry.message} ${entry.detail ?? ''}`
return /relay director resolve failed \((?:401|503)\)|liveness-timeout|liveness timeout|connection health check failed|relay-session-failed|active relay session failed|authentication-rejected|unauthorized|pairing may be revoked|connect-timeout|websocket connect timeout|handshake-timeout|handshake timeout/i.test(
evidence
)
}
function parseRetryDelayMs(evidence: string): number | null {
const match = /retry(?:-|\s)?after(?:=|\s)(\d+)ms/i.exec(evidence)
return match ? Number(match[1]) : null
}
function formatDelay(ms: number): string {
return ms < 60_000 ? `${Math.round(ms / 1000)}s` : `${Math.round(ms / 60_000)}m`
}
function formatPath(path: MobileConnectionDiagnosticPath): string {
if (path === 'relay') {
return 'Relay'
}
return path === 'tailscale' ? 'Tailscale/direct' : 'LAN/direct'
}
@@ -13,6 +13,7 @@ describe('buildConnectionDiagnosticsReport', () => {
lastConnectedAt: NOW - 5 * 60_000,
platform: 'ios 26.5.1',
appVersion: '0.0.29',
desktopAppVersion: '1.4.191',
entries: [
{
id: 'log-1',
@@ -26,6 +27,7 @@ describe('buildConnectionDiagnosticsReport', () => {
})
expect(report).toContain('App: Orca Mobile 0.0.29 · ios 26.5.1')
expect(report).toContain('Host Orca version: 1.4.191')
expect(report).toContain('Endpoint: 100.65.9.106:6768 (Tailscale)')
expect(report).toContain('State: reconnecting (reconnect attempts: 12)')
expect(report).toContain('(5m 0s ago)')
@@ -48,8 +50,96 @@ describe('buildConnectionDiagnosticsReport', () => {
})
expect(report).toContain('Endpoint: 192.168.1.50:6768')
expect(report).toContain('Host Orca version: unknown')
expect(report).not.toContain('(Tailscale)')
expect(report).toContain('Last connected: never this session')
expect(report).toContain('No connection events recorded this session.')
expect(report).toContain('No connection events recorded.')
})
it('explains the most likely cause and redacts credentials before copying', () => {
const report = buildConnectionDiagnosticsReport({
hostName: 'Host 3',
endpoint: 'ws://100.88.90.25:6768',
state: 'reconnecting',
reconnectAttempts: 4,
lastConnectedAt: null,
platform: 'android 36',
appVersion: '0.0.46',
activePath: 'tailscale',
pendingPath: 'relay',
entries: [
{
id: 'relay-failure',
ts: NOW - 5_000,
level: 'error',
message: 'Relay: relay dial failed',
detail:
'RelayDirectorHttpError: relay director resolve failed (503); retry after 30000ms; resumeToken=secret-resume-token'
}
],
nowMs: NOW
})
expect(report).toContain(
'Likely cause: Relay service was temporarily unavailable and asked Orca to retry in 30s.'
)
expect(report).toContain('Path: active=tailscale; recovery=relay')
expect(report).toContain('Next step: Keep Orca open; recovery should retry automatically.')
expect(report).toContain('resumeToken=[redacted]')
expect(report).not.toContain('secret-resume-token')
})
it('redacts quoted JSON credentials and never echoes an invalid endpoint', () => {
const report = buildConnectionDiagnosticsReport({
hostName: 'Host 4',
endpoint: 'not-a-url?token=endpoint-secret',
state: 'reconnecting',
reconnectAttempts: 1,
lastConnectedAt: null,
platform: 'android 36',
appVersion: '0.0.47',
entries: [
{
id: 'json-secret',
ts: NOW,
level: 'error',
message: 'Relay failed',
detail: '{"resumeToken":"json-secret","authorization":"Bearer bearer-secret"}'
}
],
nowMs: NOW
})
expect(report).toContain('Endpoint: invalid endpoint')
expect(report).not.toContain('endpoint-secret')
expect(report).not.toContain('json-secret')
expect(report).not.toContain('bearer-secret')
})
it('bounds a single event line before submission while preserving its identity', () => {
const report = buildConnectionDiagnosticsReport({
hostName: 'Host 5',
endpoint: 'ws://192.168.1.2:6768',
state: 'reconnecting',
reconnectAttempts: 1,
lastConnectedAt: null,
platform: 'android 36',
appVersion: '0.0.47',
entries: [
{
id: 'oversized',
ts: NOW,
level: 'error',
message: `newest oversized ${'😀'.repeat(2_000)}`
}
],
nowMs: NOW
})
expect(report).toContain('newest oversized')
expect(report).toContain('[truncated]')
expect(new TextEncoder().encode(report.split('\n').at(-1)!).byteLength).toBeLessThanOrEqual(
2048
)
})
})
@@ -1,6 +1,16 @@
import { isTailscaleEndpoint } from '../../../src/shared/remote-runtime-tailscale-hint'
import type { ConnectionLogEntry, ConnectionState } from '../transport/types'
import type {
ConnectionLogEntry,
ConnectionState,
MobileConnectionDiagnosticPath
} from '../transport/types'
import { normalizeHostAppVersion } from '../transport/host-app-version-store'
import { formatEndpoint } from './host-reachability'
import { diagnoseConnection } from './connection-diagnostics-analysis'
import { redactConnectionLogEntry, redactConnectionLogText } from './connection-log-redaction'
const MAX_EVENT_LINE_BYTES = 2 * 1024
const EVENT_TRUNCATION_MARKER = ' … [truncated]'
// Why: one shareable text blob answering everything we historically had to
// ask reporters one message at a time (endpoint type, state, attempt count,
@@ -13,37 +23,83 @@ export function buildConnectionDiagnosticsReport(args: {
lastConnectedAt: number | null
platform: string
appVersion: string
desktopAppVersion?: string | null
entries: readonly ConnectionLogEntry[]
activePath?: MobileConnectionDiagnosticPath
pendingPath?: MobileConnectionDiagnosticPath | null
nowMs?: number
}): string {
const now = args.nowMs ?? Date.now()
const entries = args.entries.map(redactConnectionLogEntry)
const diagnosis = diagnoseConnection({
endpoint: args.endpoint,
state: args.state,
activePath: args.activePath,
pendingPath: args.pendingPath,
entries
})
const lines: string[] = []
lines.push('Orca Mobile connection diagnostics')
lines.push(`Generated: ${new Date(now).toISOString()}`)
lines.push(`App: Orca Mobile ${args.appVersion} · ${args.platform}`)
lines.push(`Host: ${args.hostName}`)
const desktopAppVersion = normalizeHostAppVersion(args.desktopAppVersion)
lines.push(`Host Orca version: ${desktopAppVersion ?? 'unknown'}`)
lines.push(`Host: ${redactConnectionLogText(args.hostName)}`)
lines.push(
`Endpoint: ${formatEndpoint(args.endpoint)}${isTailscaleEndpoint(args.endpoint) ? ' (Tailscale)' : ''}`
)
lines.push(`State: ${args.state} (reconnect attempts: ${args.reconnectAttempts})`)
if (args.activePath) {
lines.push(
`Path: active=${args.activePath}${args.pendingPath ? `; recovery=${args.pendingPath}` : ''}`
)
}
lines.push(
args.lastConnectedAt == null
? 'Last connected: never this session'
: `Last connected: ${new Date(args.lastConnectedAt).toISOString()} (${formatAgo(now - args.lastConnectedAt)} ago)`
)
lines.push('')
if (args.entries.length === 0) {
lines.push('No connection events recorded this session.')
lines.push(`Likely cause: ${diagnosis.likelyCause}`)
lines.push(`Next step: ${diagnosis.nextStep}`)
lines.push('')
if (entries.length === 0) {
lines.push('No connection events recorded.')
} else {
lines.push(`Connection log (${args.entries.length} events, oldest first):`)
for (const entry of args.entries) {
lines.push(`Recent connection history (${entries.length} events, oldest first):`)
for (const entry of entries) {
const detail = entry.detail ? ` — ${entry.detail}` : ''
lines.push(`${new Date(entry.ts).toISOString()} [${entry.level}] ${entry.message}${detail}`)
const evidence = [entry.code, entry.path].filter(Boolean).join(' · ')
lines.push(
truncateUtf8WithMarker(
`${new Date(entry.ts).toISOString()} [${entry.level}]${evidence ? ` [${evidence}]` : ''} ${entry.message}${detail}`,
MAX_EVENT_LINE_BYTES,
EVENT_TRUNCATION_MARKER
)
)
}
}
return lines.join('\n')
}
function truncateUtf8WithMarker(value: string, maxBytes: number, marker: string): string {
if (new TextEncoder().encode(value).byteLength <= maxBytes) {
return value
}
const markerBytes = new TextEncoder().encode(marker).byteLength
const characters: string[] = []
let bytes = 0
for (const character of value) {
const characterBytes = new TextEncoder().encode(character).byteLength
if (bytes + characterBytes + markerBytes > maxBytes) {
break
}
characters.push(character)
bytes += characterBytes
}
return `${characters.join('')}${marker}`
}
function formatAgo(ms: number): string {
const seconds = Math.max(0, Math.round(ms / 1000))
if (seconds < 60) {
@@ -0,0 +1,107 @@
import { describe, expect, it, vi } from 'vitest'
import {
getDiagnosticsSubmissionState,
readConnectionDiagnosticsSnapshot,
readHydratedConnectionLog,
resolveDiagnosticsHostId,
selectDiagnosticsHostId,
updateDiagnosticsSubmissionState
} from './connection-diagnostics-screen-data'
import type { ConnectionLogEntry, HostProfile } from '../transport/types'
const host = (id: string): HostProfile => ({
id,
name: id,
endpoint: 'ws://192.168.1.2:6768',
deviceToken: 'token',
publicKeyB64: 'key',
lastConnected: 0
})
describe('connection diagnostics screen data', () => {
it('prefers a valid route host when navigation changes', () => {
expect(selectDiagnosticsHostId([host('a'), host('b')], 'b', 'a')).toBe('b')
expect(selectDiagnosticsHostId([host('a')], 'missing', 'missing')).toBe('a')
})
it('applies route changes synchronously without discarding a chip choice for the same route', () => {
const hosts = [host('a'), host('b')]
expect(resolveDiagnosticsHostId(hosts, undefined, null)).toBe('a')
expect(resolveDiagnosticsHostId(hosts, 'b', { hostId: 'a', requestedHostId: 'a' })).toBe('b')
expect(resolveDiagnosticsHostId(hosts, 'b', { hostId: 'a', requestedHostId: 'b' })).toBe('a')
})
it('does not revive a stale manual choice when routing away and back', () => {
const hosts = [host('a'), host('b'), host('c')]
const firstRouteA = {}
const routeC = {}
const secondRouteA = {}
const manualSelection = { hostId: 'b', requestedHostId: 'a', routeKey: firstRouteA }
expect(resolveDiagnosticsHostId(hosts, 'c', manualSelection, routeC)).toBe('c')
expect(resolveDiagnosticsHostId(hosts, 'a', manualSelection, secondRouteA)).toBe('a')
})
it('keeps async submission completion scoped to its host incident', () => {
let states = updateDiagnosticsSubmissionState({}, 'host-a:incident-1', 'sending')
states = updateDiagnosticsSubmissionState(states, 'host-b:incident-2', 'sending')
states = updateDiagnosticsSubmissionState(states, 'host-a:incident-1', 'sent')
expect(getDiagnosticsSubmissionState(states, 'host-a:incident-1')).toBe('sent')
expect(getDiagnosticsSubmissionState(states, 'host-b:incident-2')).toBe('sending')
})
it('waits for hydration and then reads the refreshed log', async () => {
const entries: ConnectionLogEntry[] = []
const store = {
hydrate: vi.fn(async () => {
entries.push({ id: 'stored', ts: 1, level: 'info', message: 'stored event' })
}),
get: vi.fn(() => entries)
}
await expect(readHydratedConnectionLog(store, 'host-a')).resolves.toEqual(entries)
expect(store.get).toHaveBeenCalledAfter(store.hydrate)
})
it('retries one transient hydration failure before reading', async () => {
const entries = [{ id: 'stored', ts: 1, level: 'info' as const, message: 'stored event' }]
const store = {
hydrate: vi
.fn()
.mockRejectedValueOnce(new Error('storage unavailable'))
.mockResolvedValueOnce(undefined),
get: vi.fn(() => entries)
}
await expect(readHydratedConnectionLog(store, 'host-a')).resolves.toEqual(entries)
expect(store.hydrate).toHaveBeenCalledTimes(2)
})
it('reads connection metadata after hydration completes', async () => {
let state: 'connecting' | 'reconnecting' = 'connecting'
const store = {
hydrate: vi.fn(async () => {
state = 'reconnecting'
}),
get: vi.fn(() => [{ id: 'new', ts: 2, level: 'warn' as const, message: 'new event' }])
}
const context = {
getState: vi.fn(() => state),
getReconnectAttempt: vi.fn(() => 2),
getLastConnectedAt: vi.fn(() => 1),
getActivePath: vi.fn(() => 'relay' as const),
getPendingPath: vi.fn(() => null)
}
await expect(readConnectionDiagnosticsSnapshot(context, store, 'host-a')).resolves.toEqual({
state: 'reconnecting',
reconnectAttempts: 2,
lastConnectedAt: 1,
activePath: 'relay',
pendingPath: null,
entries: [{ id: 'new', ts: 2, level: 'warn', message: 'new event' }]
})
expect(context.getState).toHaveBeenCalledAfter(store.hydrate)
})
})
@@ -0,0 +1,94 @@
import type { ConnectionLogStore } from '../transport/connection-log-buffer'
import type { ConnectionLogEntry, HostProfile } from '../transport/types'
import type { RpcClientContextValue } from '../transport/rpc-client-context-contract'
export type DiagnosticsHostSelection = {
hostId: string
requestedHostId: string | undefined
routeKey?: object
}
export type DiagnosticsSubmissionState = 'sending' | 'sent' | 'failed'
export type DiagnosticsSubmissionStates = Readonly<Record<string, DiagnosticsSubmissionState>>
export function selectDiagnosticsHostId(
hosts: readonly HostProfile[],
requestedHostId: string | undefined,
previousHostId: string | null
): string | null {
if (requestedHostId && hosts.some((host) => host.id === requestedHostId)) {
return requestedHostId
}
if (previousHostId && hosts.some((host) => host.id === previousHostId)) {
return previousHostId
}
return hosts[0]?.id ?? null
}
export function resolveDiagnosticsHostId(
hosts: readonly HostProfile[],
requestedHostId: string | undefined,
manualSelection: DiagnosticsHostSelection | null,
routeKey?: object
): string | null {
const selected = manualSelection
if (selected && selected.requestedHostId === requestedHostId && selected.routeKey === routeKey) {
const manualHostId = selected.hostId
if (hosts.some((host) => host.id === manualHostId)) {
return manualHostId
}
}
return selectDiagnosticsHostId(hosts, requestedHostId, null)
}
export function getDiagnosticsSubmissionState(
states: DiagnosticsSubmissionStates,
key: string | null
): DiagnosticsSubmissionState | 'idle' {
return key ? (states[key] ?? 'idle') : 'idle'
}
export function updateDiagnosticsSubmissionState(
states: DiagnosticsSubmissionStates,
key: string,
state: DiagnosticsSubmissionState | null
): DiagnosticsSubmissionStates {
const next = { ...states }
if (state) {
next[key] = state
} else {
delete next[key]
}
return next
}
export async function readHydratedConnectionLog(
store: Pick<ConnectionLogStore, 'hydrate' | 'get'>,
hostId: string
): Promise<readonly ConnectionLogEntry[]> {
try {
await store.hydrate(hostId)
} catch {
await store.hydrate(hostId).catch(() => {})
}
return store.get(hostId)
}
export async function readConnectionDiagnosticsSnapshot(
context: Pick<
RpcClientContextValue,
'getState' | 'getReconnectAttempt' | 'getLastConnectedAt' | 'getActivePath' | 'getPendingPath'
>,
store: Pick<ConnectionLogStore, 'hydrate' | 'get'>,
hostId: string
) {
const entries = await readHydratedConnectionLog(store, hostId)
return {
state: context.getState(hostId),
reconnectAttempts: context.getReconnectAttempt(hostId),
lastConnectedAt: context.getLastConnectedAt(hostId),
activePath: context.getActivePath(hostId),
pendingPath: context.getPendingPath(hostId),
entries
}
}
@@ -0,0 +1,71 @@
import { describe, expect, it, vi } from 'vitest'
import {
boundConnectionDiagnosticsReport,
submitConnectionDiagnostics
} from './connection-diagnostics-submission'
describe('submitConnectionDiagnostics', () => {
it('sends a bounded report through the diagnostics lane', async () => {
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(new Response(null, { status: 204 }))
const result = await submitConnectionDiagnostics(
{ report: 'x'.repeat(100_000), appVersion: '0.0.47', platform: 'android 36' },
fetchImpl
)
expect(result).toEqual({ ok: true })
expect(fetchImpl).toHaveBeenCalledWith(
'https://www.onorca.dev/v1/feedback',
expect.objectContaining({ method: 'POST' })
)
const request = fetchImpl.mock.calls[0]?.[1]
const body = JSON.parse(String(request?.body)) as { feedback: string; submissionType: string }
expect(body.submissionType).toBe('connection_diagnostics')
expect(new TextEncoder().encode(body.feedback).byteLength).toBeLessThanOrEqual(64 * 1024)
})
it('returns a safe failure for a rejected response', async () => {
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(new Response(null, { status: 503 }))
await expect(
submitConnectionDiagnostics(
{ report: 'report', appVersion: '0.0.47', platform: 'android' },
fetchImpl
)
).resolves.toEqual({ ok: false, error: 'status 503' })
})
it('preserves the newest complete events when bounding a UTF-8 report', () => {
const report = [
'Orca Mobile connection diagnostics',
'State: reconnecting',
'',
'Recent connection history (3 events, oldest first):',
`oldest ${'😀'.repeat(20)}`,
`middle ${'😀'.repeat(20)}`,
`newest ${'😀'.repeat(20)}`
].join('\n')
const bounded = boundConnectionDiagnosticsReport(report, 180)
expect(new TextEncoder().encode(bounded).byteLength).toBeLessThanOrEqual(180)
expect(bounded).toContain('newest')
expect(bounded).not.toContain('oldest')
expect(bounded).toMatch(/older omitted/)
})
it('retains a bounded form of the newest event when that event exceeds the budget', () => {
const report = [
'Orca Mobile connection diagnostics',
'State: reconnecting',
'',
'Recent connection history (2 events, oldest first):',
'old event',
`newest oversized ${'😀'.repeat(1_000)}`
].join('\n')
const bounded = boundConnectionDiagnosticsReport(report, 220)
expect(new TextEncoder().encode(bounded).byteLength).toBeLessThanOrEqual(220)
expect(bounded).toContain('newest oversized')
expect(bounded).toContain('[truncated]')
})
})
@@ -0,0 +1,125 @@
const CONNECTION_DIAGNOSTICS_ENDPOINT = 'https://www.onorca.dev/v1/feedback'
const SUBMISSION_TIMEOUT_MS = 10_000
const MAX_SUBMISSION_BYTES = 64 * 1024
export type ConnectionDiagnosticsSubmission = {
report: string
appVersion: string
platform: string
}
export type ConnectionDiagnosticsSubmissionResult = { ok: true } | { ok: false; error: string }
/** Sends only the already-redacted, bounded report after an explicit user tap. */
export async function submitConnectionDiagnostics(
submission: ConnectionDiagnosticsSubmission,
fetchImpl: typeof fetch = fetch
): Promise<ConnectionDiagnosticsSubmissionResult> {
const report = boundConnectionDiagnosticsReport(submission.report)
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), SUBMISSION_TIMEOUT_MS)
try {
const response = await fetchImpl(CONNECTION_DIAGNOSTICS_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
feedback: report,
submissionType: 'connection_diagnostics',
githubLogin: null,
githubEmail: null,
appVersion: submission.appVersion,
platform: `mobile-${submission.platform}`,
osRelease: 'unknown',
arch: 'unknown'
}),
signal: controller.signal
})
return response.ok ? { ok: true } : { ok: false, error: `status ${response.status}` }
} catch (error) {
return {
ok: false,
error: controller.signal.aborted
? 'request timed out'
: error instanceof Error
? error.message
: 'request failed'
}
} finally {
clearTimeout(timeout)
}
}
export function boundConnectionDiagnosticsReport(
report: string,
maxBytes: number = MAX_SUBMISSION_BYTES
): string {
if (utf8Bytes(report) <= maxBytes) {
return report
}
const lines = report.split('\n')
const historyIndex = lines.findIndex((line) => line.startsWith('Recent connection history ('))
if (historyIndex === -1) {
return truncateUtf8(report, maxBytes)
}
const header = lines.slice(0, historyIndex)
const events = lines.slice(historyIndex + 1)
let kept: string[] = []
for (let index = events.length - 1; index >= 0; index--) {
const candidateEvents = [events[index]!, ...kept]
const candidate = formatBoundedReport(header, candidateEvents, events.length)
if (utf8Bytes(candidate) > maxBytes) {
break
}
kept = candidateEvents
}
if (kept.length === 0 && events.length > 0) {
return formatReportWithTruncatedNewestEvent(header, events, maxBytes)
}
return truncateUtf8(formatBoundedReport(header, kept, events.length), maxBytes)
}
function formatReportWithTruncatedNewestEvent(
header: string[],
events: string[],
maxBytes: number
): string {
const history = `Recent connection history (1 newest event; ${events.length - 1} older omitted):`
const prefix = [...header, history].join('\n') + '\n'
const availableBytes = maxBytes - utf8Bytes(prefix)
if (availableBytes <= 0) {
return truncateUtf8(prefix, maxBytes)
}
const marker = ' … [truncated]'
const markerBytes = utf8Bytes(marker)
if (availableBytes <= markerBytes) {
return truncateUtf8(prefix, maxBytes)
}
return `${prefix}${truncateUtf8(events.at(-1)!, availableBytes - markerBytes)}${marker}`
}
function formatBoundedReport(header: string[], events: string[], totalEvents: number): string {
const omitted = totalEvents - events.length
return [
...header,
`Recent connection history (${events.length} newest events; ${omitted} older omitted):`,
...events
].join('\n')
}
function truncateUtf8(value: string, maxBytes: number): string {
const characters: string[] = []
let bytes = 0
for (const character of value) {
const characterBytes = utf8Bytes(character)
if (bytes + characterBytes > maxBytes) {
break
}
characters.push(character)
bytes += characterBytes
}
return characters.join('')
}
function utf8Bytes(value: string): number {
return new TextEncoder().encode(value).byteLength
}
@@ -0,0 +1,51 @@
import type { ConnectionLogEntry } from '../transport/types'
const SECRET_JSON_ASSIGNMENT =
/(["'])(resumeToken|deviceToken|authorization|credential|token|publicKeyB64)\1(\s*:\s*)(["'])(?:Bearer\s+)?(?:\\.|(?!\4)[^\\\r\n])*\4/gi
const SECRET_QUOTED_VALUE_ASSIGNMENT =
/\b(resumeToken|deviceToken|authorization|credential|token|publicKeyB64)(\s*[:=]\s*)(["'])(?:Bearer\s+)?(?:\\.|(?!\3)[^\\\r\n])*\3/gi
const SECRET_UNTERMINATED_JSON_ASSIGNMENT =
/(["'])(resumeToken|deviceToken|authorization|credential|token|publicKeyB64)\1(\s*:\s*)(["'])(?:Bearer\s+)?(?:\\.|(?!\4)[^\\\r\n])*(?=\r?\n|$)/gi
const SECRET_UNTERMINATED_QUOTED_VALUE_ASSIGNMENT =
/\b(resumeToken|deviceToken|authorization|credential|token|publicKeyB64)(\s*[:=]\s*)(["'])(?:Bearer\s+)?(?:\\.|(?!\3)[^\\\r\n])*(?=\r?\n|$)/gi
const SECRET_ASSIGNMENT =
/\b(resumeToken|deviceToken|authorization|credential|token|publicKeyB64)(\s*[:=]\s*)(?:Bearer\s+)?([^\s;,}"']+)/gi
const SECRET_QUERY = /([?&](?:token|credential|resumeToken|deviceToken)=)[^&#\s]+/gi
const URL_USERINFO = /((?:wss?|https?):\/\/)[^\s/?#]*@/gi
export function redactConnectionLogText(value: string): string {
return value
.replace(
SECRET_JSON_ASSIGNMENT,
(_match, keyQuote: string, label: string, separator: string, valueQuote: string) =>
`${keyQuote}${label}${keyQuote}${separator}${valueQuote}[redacted]${valueQuote}`
)
.replace(
SECRET_QUOTED_VALUE_ASSIGNMENT,
(_match, label: string, separator: string, valueQuote: string) =>
`${label}${separator}${valueQuote}[redacted]${valueQuote}`
)
.replace(
SECRET_UNTERMINATED_JSON_ASSIGNMENT,
(_match, keyQuote: string, label: string, separator: string, valueQuote: string) =>
`${keyQuote}${label}${keyQuote}${separator}${valueQuote}[redacted]`
)
.replace(
SECRET_UNTERMINATED_QUOTED_VALUE_ASSIGNMENT,
(_match, label: string, separator: string, valueQuote: string) =>
`${label}${separator}${valueQuote}[redacted]`
)
.replace(SECRET_ASSIGNMENT, (_match, label: string, separator: string) => {
return `${label}${separator}[redacted]`
})
.replace(SECRET_QUERY, '$1[redacted]')
.replace(URL_USERINFO, '$1[redacted]@')
}
export function redactConnectionLogEntry(entry: ConnectionLogEntry): ConnectionLogEntry {
return {
...entry,
message: redactConnectionLogText(entry.message),
...(entry.detail ? { detail: redactConnectionLogText(entry.detail) } : {})
}
}
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { testHostReachability, unreachableHostDetail } from './host-reachability'
import { formatEndpoint, testHostReachability, unreachableHostDetail } from './host-reachability'
describe('unreachableHostDetail', () => {
it('points at Tailscale for tailnet CGNAT endpoints', () => {
@@ -21,6 +21,10 @@ describe('unreachableHostDetail', () => {
it('does not treat non-CGNAT 100.x addresses as Tailscale', () => {
expect(unreachableHostDetail('ws://100.20.1.5:6768')).toBe('Cannot reach 100.20.1.5:6768')
})
it('does not echo malformed endpoints that may contain credentials', () => {
expect(formatEndpoint('not-a-url?token=secret')).toBe('invalid endpoint')
})
})
describe('testHostReachability', () => {
+1 -1
View File
@@ -55,7 +55,7 @@ export function formatEndpoint(endpoint: string): string {
const url = new URL(endpoint)
return url.host
} catch {
return endpoint
return 'invalid endpoint'
}
}
+2
View File
@@ -177,6 +177,8 @@ export function MobileHomeScreen() {
onDismiss: () => setActionTarget(null),
onReconnect: (hostId) => void forceReconnectHost(hostId),
onDisconnect: disconnectHostClient,
onDiagnostics: (hostId) =>
data.router.push({ pathname: '/connection-log', params: { hostId } }),
onEdit: openMobileHostEdit,
onRemove: (host) => setConfirmRemove(host)
})}
@@ -3,6 +3,7 @@ import { getHostListActionSheetActions } from './host-list-action-sheet-actions'
import type { ConnectionState, HostProfile } from './transport/types'
vi.mock('lucide-react-native', () => ({
Activity: vi.fn(),
Edit3: vi.fn(),
PowerOff: vi.fn(),
RefreshCw: vi.fn()
@@ -22,6 +23,7 @@ function build(overrides: { state?: ConnectionState; hasEverConnected?: boolean
onDismiss: vi.fn(),
onReconnect: vi.fn(),
onDisconnect: vi.fn(),
onDiagnostics: vi.fn(),
onEdit: vi.fn(),
onRemove: vi.fn()
}
@@ -35,14 +37,17 @@ function build(overrides: { state?: ConnectionState; hasEverConnected?: boolean
}
describe('getHostListActionSheetActions', () => {
// Why: both open a second drawer. Presenting one while this sheet's native
// Why: these navigate or open a second drawer. Presenting while this sheet's native
// Modal is still up freezes the whole screen on iOS — issue #8791.
it.each(['Edit host', 'Remove'])('defers %s until the action sheet has closed', (label) => {
const { actions } = build()
expect(actions.find((action) => action.label === label)).toMatchObject({
closeBeforePress: true
})
})
it.each(['Network diagnostics', 'Edit host', 'Remove'])(
'defers %s until the action sheet has closed',
(label) => {
const { actions } = build()
expect(actions.find((action) => action.label === label)).toMatchObject({
closeBeforePress: true
})
}
)
it('leaves the in-place actions undeferred so they fire on tap', () => {
const { actions, spies } = build()
@@ -65,15 +70,23 @@ describe('getHostListActionSheetActions', () => {
expect(spies.onEdit).toHaveBeenCalledWith(HOST.id)
})
it('routes Network diagnostics with the selected host', () => {
const { actions, spies } = build()
actions.find((action) => action.label === 'Network diagnostics')?.onPress()
expect(spies.onDiagnostics).toHaveBeenCalledWith(HOST.id)
})
it('offers Disconnect only while the socket is live', () => {
expect(build({ state: 'reconnecting' }).actions.map((action) => action.label)).toEqual([
'Reconnect',
'Disconnect',
'Network diagnostics',
'Edit host',
'Remove'
])
expect(build({ state: 'disconnected' }).actions.map((action) => action.label)).toEqual([
'Connect',
'Network diagnostics',
'Edit host',
'Remove'
])
@@ -93,6 +106,7 @@ describe('getHostListActionSheetActions', () => {
onDismiss: vi.fn(),
onReconnect: vi.fn(),
onDisconnect: vi.fn(),
onDiagnostics: vi.fn(),
onEdit: vi.fn(),
onRemove: vi.fn()
})
+12 -3
View File
@@ -1,9 +1,9 @@
import { Edit3, PowerOff, RefreshCw } from 'lucide-react-native'
import { Activity, Edit3, PowerOff, RefreshCw } from 'lucide-react-native'
import type { ActionSheetAction } from './components/ActionSheetModal'
import type { ConnectionState, HostProfile } from './transport/types'
/** Builds the home-screen host long-press menu. Edit and Remove open a second
* drawer, so both must defer until this sheet's native Modal has unmounted —
/** Builds the home-screen host long-press menu. Navigation and second drawers
* defer until this sheet's native Modal has unmounted —
* presenting into a live one freezes the whole screen on iOS (issue #8791). */
export function getHostListActionSheetActions(args: {
host: HostProfile | null
@@ -13,6 +13,7 @@ export function getHostListActionSheetActions(args: {
onDismiss: () => void
onReconnect: (hostId: string) => void
onDisconnect: (hostId: string) => void
onDiagnostics: (hostId: string) => void
onEdit: (hostId: string) => void
onRemove: (host: HostProfile) => void
}): ActionSheetAction[] {
@@ -47,6 +48,14 @@ export function getHostListActionSheetActions(args: {
}
]
: []),
{
label: 'Network diagnostics',
icon: Activity,
closeBeforePress: true,
onPress: () => {
args.onDiagnostics(host.id)
}
},
{
label: 'Edit host',
icon: Edit3,
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'
import { useRpcClientContext, type RpcClientContextValue } from './client-context'
import { useRpcClientContext } from './client-context'
import type { RpcClientContextValue } from './rpc-client-context-contract'
import type { MobileConnectionPath } from './stable-logical-rpc-client'
export function useReconnectAttempt(hostId: string | undefined): number {
@@ -10,6 +11,20 @@ export function useLastConnectedAt(hostId: string | undefined): number | null {
return useHostMetric(hostId, (context, id) => context.getLastConnectedAt(id), null)
}
export function useConnectionPathStatus(hostId: string | undefined): {
activePath: MobileConnectionPath
pendingPath: MobileConnectionPath | null
} {
return useHostMetric(
hostId,
(context, id) => ({
activePath: context.getActivePath(id),
pendingPath: context.getPendingPath(id)
}),
{ activePath: 'lan', pendingPath: null }
)
}
// Both inputs classifyConnection needs about a relay recovery, read from one
// subscription so a screen cannot render half of the escalation.
export function useRelayRecoveryStatus(hostId: string | undefined): {
+3 -3
View File
@@ -21,6 +21,7 @@ import {
import { HostOpenRetryScheduler } from './host-open-retry-scheduler'
import { openHostClientEntry, type HostClientStoreEntry } from './host-entry-opener'
import { shouldPreserveActiveRelay } from './relay-reconnect-preservation'
import { recordConnectionRevival } from './persisted-connection-log-store'
import {
createHostClientSelectors,
listHostClients,
@@ -35,8 +36,6 @@ import type { ConnectionState, HostProfile } from './types'
import type { RpcClientContextValue } from './rpc-client-context-contract'
type StoreEntry = HostClientStoreEntry
export type { HostClientAcquisition } from './host-client-acquisition-registry'
export type { RpcClientContextValue } from './rpc-client-context-contract'
const Ctx = createContext<RpcClientContextValue | null>(null)
@@ -311,7 +310,8 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
for (const hostId of pendingAcquisitionsRef.current.keys()) {
retrySchedulerRef.current?.expedite(hostId)
}
for (const entry of storeRef.current.values()) {
for (const [hostId, entry] of storeRef.current) {
recordConnectionRevival(hostId, reason)
try {
entry.client.notifyForeground(reason)
} catch {
@@ -53,4 +53,154 @@ describe('connection log buffer', () => {
store.append('host-a', entry(2))
expect(onA).toHaveBeenCalledTimes(1)
})
it('hydrates persisted history without dropping events recorded during app startup', async () => {
let finishLoad: (entries: readonly ConnectionLogEntry[]) => void = () => {}
const load = vi.fn(
() =>
new Promise<readonly ConnectionLogEntry[]>((resolve) => {
finishLoad = resolve
})
)
const save = vi.fn(async () => {})
const store = createConnectionLogStore(3, { load, save })
store.append('host-a', entry(3))
finishLoad([entry(1), entry(2)])
await store.hydrate('host-a')
expect(store.get('host-a').map((e) => e.id)).toEqual(['log-1', 'log-2', 'log-3'])
await vi.waitFor(() => expect(save).toHaveBeenCalled())
expect(save).toHaveBeenLastCalledWith('host-a', [entry(1), entry(2), entry(3)])
})
it('redacts credentials before retaining or persisting an event', async () => {
const save = vi.fn(async () => {})
const store = createConnectionLogStore(3, { load: async () => [], save })
store.append('host-a', {
...entry(1),
detail: 'resumeToken=do-not-copy deviceToken:also-secret'
})
await store.hydrate('host-a')
expect(store.get('host-a')[0]?.detail).toBe('resumeToken=[redacted] deviceToken:[redacted]')
await vi.waitFor(() => expect(save).toHaveBeenCalled())
expect(JSON.stringify(save.mock.calls)).not.toContain('do-not-copy')
expect(JSON.stringify(save.mock.calls)).not.toContain('also-secret')
})
it('redacts quoted credential values when the object key is unquoted', async () => {
const store = createConnectionLogStore(3, { load: async () => [], save: async () => {} })
store.append('host-a', {
...entry(1),
detail: `resumeToken: 'secret-one' deviceToken="secret-two"`
})
await store.hydrate('host-a')
expect(store.get('host-a')[0]?.detail).toBe(
`resumeToken: '[redacted]' deviceToken="[redacted]"`
)
})
it('redacts the full quoted credential when its value contains an escaped quote', async () => {
const store = createConnectionLogStore(3, { load: async () => [], save: async () => {} })
store.append('host-a', {
...entry(1),
detail: String.raw`{"token":"secret\"tail"} token: 'secret\'tail'`
})
await store.hydrate('host-a')
expect(store.get('host-a')[0]?.detail).toBe(`{"token":"[redacted]"} token: '[redacted]'`)
})
it('redacts unterminated quoted credentials', async () => {
const store = createConnectionLogStore(3, { load: async () => [], save: async () => {} })
store.append('host-a', {
...entry(1),
message: 'token="secret',
detail: '{"authorization":"Bearer also-secret'
})
await store.hydrate('host-a')
expect(store.get('host-a')[0]).toMatchObject({
message: 'token="[redacted]',
detail: '{"authorization":"[redacted]'
})
})
it('redacts URL userinfo through the last authority separator', async () => {
const store = createConnectionLogStore(3, { load: async () => [], save: async () => {} })
store.append('host-a', {
...entry(1),
message: 'wss://user:p@ss@example.com/x',
detail: 'https://user:pass@example.com/x'
})
await store.hydrate('host-a')
expect(store.get('host-a')[0]).toMatchObject({
message: 'wss://[redacted]@example.com/x',
detail: 'https://[redacted]@example.com/x'
})
})
it('does not overwrite persisted history when hydration fails and retries later', async () => {
const load = vi
.fn<() => Promise<readonly ConnectionLogEntry[]>>()
.mockRejectedValueOnce(new Error('storage unavailable'))
.mockResolvedValueOnce([entry(1)])
const save = vi.fn(async () => {})
const store = createConnectionLogStore(3, { load, save })
store.append('host-a', entry(2))
await expect(store.hydrate('host-a')).rejects.toThrow('storage unavailable')
expect(save).not.toHaveBeenCalled()
await store.hydrate('host-a')
expect(store.get('host-a').map((value) => value.id)).toEqual(['log-1', 'log-2'])
})
it('preserves legacy entries that reused the same event id', async () => {
const store = createConnectionLogStore(3, {
load: async () => [entry(1), { ...entry(2), id: 'log-1' }],
save: async () => {}
})
await store.hydrate('host-a')
expect(store.get('host-a').map((value) => value.message)).toEqual(['event 1', 'event 2'])
})
it('retries one transient persistence failure without requiring another append', async () => {
const save = vi.fn().mockResolvedValue(undefined)
const store = createConnectionLogStore(3, { load: async () => [], save })
await store.hydrate('host-a')
await vi.waitFor(() => expect(save).toHaveBeenCalled())
save.mockReset()
save.mockRejectedValueOnce(new Error('storage unavailable')).mockResolvedValueOnce(undefined)
store.append('host-a', entry(1))
await vi.waitFor(() => expect(save).toHaveBeenCalledTimes(2))
expect(save).toHaveBeenLastCalledWith('host-a', [entry(1)])
})
it('does not retry failed automatic hydration for every appended event', async () => {
const load = vi.fn().mockRejectedValue(new Error('storage unavailable'))
const store = createConnectionLogStore(3, { load, save: async () => {} })
store.append('host-a', entry(1))
await expect(store.hydrate('host-a')).rejects.toThrow('storage unavailable')
store.append('host-a', entry(2))
store.append('host-a', entry(3))
await Promise.resolve()
expect(load).toHaveBeenCalledTimes(1)
await expect(store.hydrate('host-a')).rejects.toThrow('storage unavailable')
expect(load).toHaveBeenCalledTimes(2)
})
})
+98 -14
View File
@@ -1,4 +1,5 @@
import type { ConnectionLogEntry } from './types'
import { redactConnectionLogEntry } from '../diagnostics/connection-log-redaction'
// Why: the rpc-client's onLog entries were only wired during pairing; for
// long-lived host connections everything went to console.log, invisible to
@@ -12,20 +13,108 @@ const MAX_ENTRIES_PER_HOST = 200
export type ConnectionLogStore = {
append: (hostId: string, entry: ConnectionLogEntry) => void
get: (hostId: string) => readonly ConnectionLogEntry[]
hydrate: (hostId: string) => Promise<void>
subscribe: (hostId: string, listener: () => void) => () => void
}
export type ConnectionLogPersistence = {
load: (hostId: string) => Promise<readonly ConnectionLogEntry[]>
save: (hostId: string, entries: readonly ConnectionLogEntry[]) => Promise<void>
}
export function createConnectionLogStore(
maxEntriesPerHost: number = MAX_ENTRIES_PER_HOST
maxEntriesPerHost: number = MAX_ENTRIES_PER_HOST,
persistence?: ConnectionLogPersistence
): ConnectionLogStore {
const entriesByHost = new Map<string, ConnectionLogEntry[]>()
const listenersByHost = new Map<string, Set<() => void>>()
const hydratedHosts = new Set<string>()
const hydrationFailedHosts = new Set<string>()
const hydrationByHost = new Map<string, Promise<void>>()
const saveByHost = new Map<string, Promise<void>>()
// Why: useSyncExternalStore compares snapshots by reference — getSnapshot
// must return the SAME array until the data actually changes, or React
// loops re-rendering. Cache per host; invalidate on append.
const snapshotByHost = new Map<string, readonly ConnectionLogEntry[]>()
const EMPTY: readonly ConnectionLogEntry[] = []
const trim = (entries: ConnectionLogEntry[]): void => {
if (entries.length > maxEntriesPerHost) {
entries.splice(0, entries.length - maxEntriesPerHost)
}
}
const notify = (hostId: string): void => {
snapshotByHost.delete(hostId)
const listeners = listenersByHost.get(hostId)
if (listeners) {
for (const listener of listeners) {
listener()
}
}
}
const persist = (hostId: string): void => {
if (!persistence || !hydratedHosts.has(hostId)) {
return
}
const snapshot = [...(entriesByHost.get(hostId) ?? [])]
const previous = saveByHost.get(hostId) ?? Promise.resolve()
const pending = previous
.catch(() => {})
.then(async () => {
try {
await persistence.save(hostId, snapshot)
} catch {
await persistence.save(hostId, snapshot)
}
})
.catch(() => {})
saveByHost.set(hostId, pending)
}
const hydrateHost = async (hostId: string, retryAfterFailure: boolean): Promise<void> => {
if (!persistence || hydratedHosts.has(hostId)) {
return
}
const existing = hydrationByHost.get(hostId)
if (existing) {
return existing
}
if (!retryAfterFailure && hydrationFailedHosts.has(hostId)) {
return
}
const pending = persistence
.load(hostId)
.then((stored) => {
const live = entriesByHost.get(hostId) ?? []
const seen = new Set<string>()
const merged: ConnectionLogEntry[] = []
for (const entry of [...stored, ...live]) {
const redacted = redactConnectionLogEntry(entry)
const fingerprint = JSON.stringify(redacted)
if (!seen.has(fingerprint)) {
seen.add(fingerprint)
merged.push(redacted)
}
}
merged.sort((a, b) => a.ts - b.ts)
trim(merged)
entriesByHost.set(hostId, merged)
hydratedHosts.add(hostId)
hydrationFailedHosts.delete(hostId)
notify(hostId)
persist(hostId)
})
.catch((error: unknown) => {
hydrationFailedHosts.add(hostId)
throw error
})
.finally(() => hydrationByHost.delete(hostId))
hydrationByHost.set(hostId, pending)
return pending
}
return {
append(hostId, entry) {
let entries = entriesByHost.get(hostId)
@@ -33,17 +122,12 @@ export function createConnectionLogStore(
entries = []
entriesByHost.set(hostId, entries)
}
entries.push(entry)
if (entries.length > maxEntriesPerHost) {
entries.splice(0, entries.length - maxEntriesPerHost)
}
snapshotByHost.delete(hostId)
const listeners = listenersByHost.get(hostId)
if (listeners) {
for (const listener of listeners) {
listener()
}
}
entries.push(redactConnectionLogEntry(entry))
trim(entries)
notify(hostId)
void hydrateHost(hostId, false)
.then(() => persist(hostId))
.catch(() => {})
},
get(hostId) {
@@ -60,6 +144,8 @@ export function createConnectionLogStore(
return snapshot
},
hydrate: (hostId) => hydrateHost(hostId, true),
subscribe(hostId, listener) {
let listeners = listenersByHost.get(hostId)
if (!listeners) {
@@ -80,5 +166,3 @@ export function createConnectionLogStore(
}
}
}
export const connectionLogStore = createConnectionLogStore()
@@ -0,0 +1,46 @@
import { isTailscaleEndpoint } from '../../../src/shared/remote-runtime-tailscale-hint'
import type { LivenessTimeoutEvidence } from './rpc-session-liveness-watchdog'
import type {
ConnectionLogEntry,
ConnectionLogLevel,
ConnectionLogSink,
MobileConnectionDiagnosticPath
} from './types'
export class DirectConnectionLog {
private sequence = 0
private readonly path: MobileConnectionDiagnosticPath
constructor(
endpoint: string,
private readonly sink?: ConnectionLogSink
) {
this.path = isTailscaleEndpoint(endpoint) ? 'tailscale' : 'lan'
}
emit = (
level: ConnectionLogLevel,
message: string,
detail?: string,
evidence?: Pick<ConnectionLogEntry, 'code' | 'path'>
): void => {
this.sink?.({
id: `log-${++this.sequence}-${Date.now()}`,
ts: Date.now(),
level,
message,
detail,
...evidence,
path: evidence?.path ?? this.path
})
}
livenessTimeout = (evidence: LivenessTimeoutEvidence): void => {
this.emit(
'error',
'Connection health check failed',
`${evidence.reason}; ${evidence.missedProbes}/${evidence.missedProbeLimit} probes missed; last authenticated activity ${evidence.lastInboundAgeMs}ms ago`,
{ code: 'liveness-timeout' }
)
}
}
+16 -23
View File
@@ -1,4 +1,5 @@
import type { ConnectOptions, RpcClient, SendRequestOptions } from './rpc-client'
import { DirectConnectionLog } from './direct-connection-log'
import { RpcClientAuthenticationRetry } from './rpc-client-authentication-retry'
import { RpcClientConnectionState } from './rpc-client-connection-state'
import {
@@ -16,12 +17,7 @@ import {
} from './rpc-client-stream-registry'
import { RpcSessionLivenessWatchdog } from './rpc-session-liveness-watchdog'
import { isStaleForegroundDial } from './rpc-stale-dial'
import type {
ConnectionLogLevel,
ConnectionState,
ForegroundNudgeReason,
RpcResponse
} from './types'
import type { ConnectionState, ForegroundNudgeReason, RpcResponse } from './types'
const LIVENESS_REQUEST_ID_PREFIX = 'mobile-liveness-'
@@ -36,7 +32,7 @@ export class DirectRpcClient implements RpcClient {
private readonly authenticationRetry: RpcClientAuthenticationRetry
private readonly socketClose: RpcClientSocketCloseController
private requestCounter = 0
private logCounter = 0
private readonly connectionLog: DirectConnectionLog
private intentionallyClosed = false
private authenticationGeneration = 0
private livenessSession: RpcClientSocketSession | null = null
@@ -47,10 +43,12 @@ export class DirectRpcClient implements RpcClient {
serverPublicKeyB64: string,
private readonly options: ConnectOptions
) {
this.connectionLog = new DirectConnectionLog(endpoint, options.onLog)
this.reconnect = new RpcClientReconnectSchedule({
openConnection: () => this.openConnection(),
rejectConnectWaiters: (reason) => this.connectionState.rejectWaiters(reason),
emitLog: (message, detail) => this.emitLog('info', message, detail)
emitLog: (message, detail) =>
this.connectionLog.emit('info', message, detail, { code: 'retry-scheduled' })
})
this.connectionState = new RpcClientConnectionState({
endpoint,
@@ -78,7 +76,8 @@ export class DirectRpcClient implements RpcClient {
if (identity === this.livenessSession && this.socketSession === this.livenessSession) {
this.socketClose.forceClose(this.livenessSession)
}
}
},
onTimeout: this.connectionLog.livenessTimeout
})
this.socketFactory = new RpcClientSocketFactory({
endpoint,
@@ -89,7 +88,7 @@ export class DirectRpcClient implements RpcClient {
getReconnectAttempt: () => this.getReconnectAttempt(),
getLastConnectedAt: () => this.getLastConnectedAt(),
isIntentionallyClosed: () => this.intentionallyClosed,
emitLog: (level, message, detail) => this.emitLog(level, message, detail),
emitLog: this.connectionLog.emit,
onHandshakeStarted: () => this.connectionState.publish('handshaking'),
onAuthenticated: (session) => this.handleAuthenticated(session),
onAuthRejected: (reason) => this.authenticationRetry.reject(reason),
@@ -102,7 +101,8 @@ export class DirectRpcClient implements RpcClient {
this.authenticationRetry = new RpcClientAuthenticationRetry({
endpoint,
stopLiveness: () => this.stopLiveness(),
emitWarning: (message, detail) => this.emitLog('warn', message, detail),
emitWarning: (message, detail) =>
this.connectionLog.emit('warn', message, detail, { code: 'authentication-rejected' }),
retry: (reason) => this.retryAuthentication(reason),
latchFailure: (reason) => this.latchAuthenticationFailure(reason)
})
@@ -122,7 +122,8 @@ export class DirectRpcClient implements RpcClient {
this.stopLiveness()
}
},
emitWarning: (message, detail) => this.emitLog('warn', message, detail)
emitWarning: (message, detail, evidence) =>
this.connectionLog.emit('warn', message, detail, evidence)
})
this.openConnection()
}
@@ -232,7 +233,9 @@ export class DirectRpcClient implements RpcClient {
this.reconnect.authenticated()
this.authenticationRetry.accepted()
this.connectionState.publish('connected')
this.emitLog('success', 'Authenticated', 'Channel ready for RPC')
this.connectionLog.emit('success', 'Authenticated', 'Channel ready for RPC', {
code: 'direct-connected'
})
this.streams.replayAfterAuthentication()
}
@@ -311,14 +314,4 @@ export class DirectRpcClient implements RpcClient {
private nextId(): string {
return `rpc-${++this.requestCounter}-${Date.now()}`
}
private emitLog(level: ConnectionLogLevel, message: string, detail?: string): void {
this.options.onLog?.({
id: `log-${++this.logCounter}-${Date.now()}`,
ts: Date.now(),
level,
message,
detail
})
}
}
@@ -0,0 +1,52 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const asyncStorageMock = vi.hoisted(() => ({
getItem: vi.fn(),
setItem: vi.fn()
}))
vi.mock('@react-native-async-storage/async-storage', () => ({
default: asyncStorageMock
}))
import {
loadHostAppVersion,
normalizeHostAppVersion,
recordHostAppVersion
} from './host-app-version-store'
describe('host app version store', () => {
beforeEach(() => {
vi.clearAllMocks()
asyncStorageMock.getItem.mockResolvedValue(null)
asyncStorageMock.setItem.mockResolvedValue(undefined)
})
it('persists a bounded version once per host', async () => {
asyncStorageMock.getItem.mockResolvedValueOnce(null).mockResolvedValueOnce('1.4.191')
await recordHostAppVersion('host-1', ' 1.4.191 ')
await recordHostAppVersion('host-1', '1.4.191')
expect(asyncStorageMock.setItem).toHaveBeenCalledOnce()
expect(asyncStorageMock.setItem).toHaveBeenCalledWith(
'orca:host-app-version:v1:host-1',
'1.4.191'
)
})
it('loads a previously observed version without requiring a live host', async () => {
asyncStorageMock.getItem.mockResolvedValue('1.4.188')
await expect(loadHostAppVersion('host-1')).resolves.toBe('1.4.188')
})
it('rejects multiline, empty, and oversized host-provided values', async () => {
expect(normalizeHostAppVersion('1.4.191\nInjected: value')).toBeNull()
expect(normalizeHostAppVersion(' ')).toBeNull()
expect(normalizeHostAppVersion('x'.repeat(65))).toBeNull()
await recordHostAppVersion('host-1', '1.4.191\nInjected: value')
expect(asyncStorageMock.setItem).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,47 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
const STORAGE_KEY_PREFIX = 'orca:host-app-version:v1:'
const MAX_VERSION_LENGTH = 64
export function normalizeHostAppVersion(value: unknown): string | null {
if (typeof value !== 'string') {
return null
}
const normalized = value.trim()
if (
normalized.length === 0 ||
normalized.length > MAX_VERSION_LENGTH ||
normalized.includes('\n') ||
normalized.includes('\r')
) {
return null
}
return normalized
}
export async function loadHostAppVersion(hostId: string): Promise<string | null> {
try {
return normalizeHostAppVersion(await AsyncStorage.getItem(storageKey(hostId)))
} catch {
return null
}
}
export async function recordHostAppVersion(hostId: string, value: unknown): Promise<void> {
const appVersion = normalizeHostAppVersion(value)
if (!appVersion) {
return
}
try {
const key = storageKey(hostId)
if (normalizeHostAppVersion(await AsyncStorage.getItem(key)) !== appVersion) {
await AsyncStorage.setItem(key, appVersion)
}
} catch {
// Best-effort diagnostic metadata must never affect host connectivity.
}
}
function storageKey(hostId: string): string {
return `${STORAGE_KEY_PREFIX}${hostId}`
}
+6 -1
View File
@@ -1,4 +1,7 @@
import { connectionLogStore } from './connection-log-buffer'
import {
connectionLogStore,
recordConnectionClientSessionStart
} from './persisted-connection-log-store'
import { loadHosts } from './host-store'
import { openHostLogicalClient } from './host-logical-client'
import type { HostClientOpenRegistry } from './host-client-open-registry'
@@ -65,6 +68,7 @@ export async function openHostClientEntry(
id: `host-open-${ticket.generation}-${Date.now()}`,
ts: Date.now(),
level: 'error',
code: 'host-open-failed',
message: 'Host client open failed',
detail: `${category}; retry ${retry.nextDelayMs}ms (failure ${retry.failureCount})`
})
@@ -98,6 +102,7 @@ export async function openHostClientEntry(
let client: RpcClient
try {
recordConnectionClientSessionStart(hostId)
client = openHostLogicalClient(host, (entry) => connectionLogStore.append(hostId, entry))
} catch {
failCurrentOpen('client-construction')
@@ -4,6 +4,13 @@ import { describe, expect, it, vi } from 'vitest'
import type { RpcClient } from './rpc-client'
import { useHostStatusGates, type HostStatusGates } from './host-status-gates'
const recordHostAppVersionMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined))
vi.mock('./host-app-version-store', () => ({
normalizeHostAppVersion: (value: unknown) => (typeof value === 'string' ? value : null),
recordHostAppVersion: (...args: unknown[]) => recordHostAppVersionMock(...args)
}))
describe('useHostStatusGates', () => {
it('clears every prior-host gate and ignores its late response while the client is replaced', async () => {
let resolveOldStatus: ((response: unknown) => void) | null = null
@@ -76,6 +83,7 @@ describe('useHostStatusGates', () => {
const sendRequest = vi.fn().mockResolvedValue({
ok: true,
result: {
appVersion: '1.4.191',
capabilities: ['browser.screencast.v1'],
floatingWorkspaceEnabled: true
}
@@ -95,11 +103,13 @@ describe('useHostStatusGates', () => {
await Promise.resolve()
})
expect(gates).toMatchObject({
desktopAppVersion: '1.4.191',
hostCapabilities: ['browser.screencast.v1'],
floatingWorkspaceEnabled: true
})
expect(sendRequest).toHaveBeenCalledOnce()
expect(recordHostAppVersionMock).toHaveBeenCalledWith('host-1', '1.4.191')
} finally {
renderer?.unmount()
}
+11
View File
@@ -3,10 +3,12 @@ import type { RpcClient } from './rpc-client'
import type { ConnectionState, RpcSuccess } from './types'
import { evaluateCompat, type CompatVerdict } from './protocol-compat'
import type { DesktopStatus } from '../worktree/host-worktree-rpc-types'
import { normalizeHostAppVersion, recordHostAppVersion } from './host-app-version-store'
export type HostStatusGates = {
hostCapabilities: string[]
floatingWorkspaceEnabled: boolean
desktopAppVersion: string | null
compatVerdict: CompatVerdict
statusPending: boolean
}
@@ -53,6 +55,7 @@ export function useHostStatusGates(args: {
settle({
hostCapabilities: [],
floatingWorkspaceEnabled: false,
desktopAppVersion: null,
compatVerdict: { kind: 'ok' }
})
return
@@ -64,9 +67,14 @@ export function useHostStatusGates(args: {
desktopProtocolVersion: status.protocolVersion,
desktopMinCompatibleMobileVersion: status.minCompatibleMobileVersion
})
const desktopAppVersion = normalizeHostAppVersion(status.appVersion)
if (hostId && desktopAppVersion) {
void recordHostAppVersion(hostId, desktopAppVersion)
}
settle({
hostCapabilities: status.capabilities ?? [],
floatingWorkspaceEnabled: status.floatingWorkspaceEnabled === true,
desktopAppVersion,
compatVerdict: verdict
})
if (verdict.kind === 'blocked') {
@@ -84,6 +92,7 @@ export function useHostStatusGates(args: {
settle({
hostCapabilities: [],
floatingWorkspaceEnabled: false,
desktopAppVersion: null,
compatVerdict: { kind: 'ok' }
})
}
@@ -100,6 +109,7 @@ export function useHostStatusGates(args: {
return {
hostCapabilities: EMPTY_HOST_CAPABILITIES,
floatingWorkspaceEnabled: false,
desktopAppVersion: null,
compatVerdict: { kind: 'ok' },
statusPending: connState === 'connected' && client !== null
}
@@ -107,6 +117,7 @@ export function useHostStatusGates(args: {
return {
hostCapabilities: proven.hostCapabilities,
floatingWorkspaceEnabled: proven.floatingWorkspaceEnabled,
desktopAppVersion: proven.desktopAppVersion,
compatVerdict: proven.compatVerdict,
// Why (F10): unchanged pending timing — the reconnect refetch is still "unknown", it just no
// longer blanks the capabilities this same host already proved.
@@ -93,7 +93,8 @@ function createSupervisor(
resumeCredentialVersion: credential.version,
resumeConfirmReqId: confirmReqId,
deviceToken: host.deviceToken,
desktopPublicKeyB64: host.publicKeyB64
desktopPublicKeyB64: host.publicKeyB64,
onLog
}),
resolveRelay: resolveMobileRelayEndpoint,
readBundle: readMobileRelayCredentialBundle,
@@ -234,8 +234,10 @@ describe('mobile endpoint supervisor', () => {
.fn()
.mockReturnValueOnce(new FakeRelaySession('connected', new RelayOuterError(4429)))
.mockImplementation(() => new FakeRelaySession('connected'))
const onLog = vi.fn()
const deps = dependencies({
openRelay,
onLog,
randomBytes: () => new Uint8Array([128, 0])
})
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
@@ -245,6 +247,13 @@ describe('mobile endpoint supervisor', () => {
expect(openRelay).toHaveBeenCalledOnce()
expect(logical.getPendingPath()).toBe('relay')
expect(onLog).toHaveBeenCalledWith(
expect.objectContaining({
code: 'relay-session-failed',
path: 'relay',
message: 'Relay: active relay session failed'
})
)
await vi.advanceTimersByTimeAsync(249)
expect(openRelay).toHaveBeenCalledOnce()
await vi.advanceTimersByTimeAsync(1)
@@ -22,6 +22,11 @@ import * as recoveryPresentation from './mobile-relay-recovery-presentation'
import type { StableLogicalRpcClient } from './stable-logical-rpc-client'
import type { ForegroundNudgeReason, HostProfile } from './types'
import { MobileRelayBackgroundGrace } from './mobile-relay-background-grace'
import {
logRelayConnected,
logRelayCredentialUnavailable,
logRelayDialFailure
} from './mobile-relay-diagnostic-log'
export type { MobileEndpointSupervisorDependencies } from './mobile-endpoint-supervisor-contract'
@@ -98,7 +103,7 @@ export class MobileEndpointSupervisor {
recordMigration: () => {
this.relayRotationPending = false
this.hysteresis.recordMigration(dependencies.now())
this.logRelay('runtime channel migrated to relay')
logRelayConnected(this.logRelay)
},
scheduleLease: (expiry) =>
this.leaseRotation.scheduleFromLease(
@@ -107,8 +112,7 @@ export class MobileEndpointSupervisor {
scheduleDirectProbe: () => this.directProbe.schedule(),
onBookkeepingError: (error) =>
this.logRelay('relay bookkeeping failed after migration', error.message.slice(0, 80)),
onDialFailure: (error) =>
this.logRelay('relay dial failed', `${error.name}: ${String(error.message).slice(0, 80)}`)
onDialFailure: (error) => logRelayDialFailure(this.logRelay, error)
})
this.directProbe = new DirectReturnProbe(dependencies, {
hysteresis: this.hysteresis,
@@ -166,7 +170,8 @@ export class MobileEndpointSupervisor {
// Why: the direct client enters reconnecting after its first failed
// dial and may never publish disconnected while its retry loop lives.
recoveryPresentation.onActiveFailure(this.logical, this.relayReconnect, state, this.bundle)
this.relayReconnect.handleStateFailure(this.logical, state)
const relayFailure = this.relayReconnect.handleStateFailure(this.logical, state)
logRelayDialFailure(this.logRelay, relayFailure, 'active-session')
}
})
if (this.relayReconnect.needsRecovery(this.logical.getState())) {
@@ -249,11 +254,7 @@ export class MobileEndpointSupervisor {
this.logical.setRecoveryPath(null)
// Why: "expired" vs "missing" separates a sleep-past-expiry phone
// (needs re-pair or LAN) from a Keychain failure in field reports.
this.logRelay(
selection.bundle
? 'relay credential expired or rejected; slow reprobe armed'
: 'no relay credential bundle; slow reprobe armed'
)
logRelayCredentialUnavailable(this.logRelay, selection.bundle !== null)
this.relayReconnect.armCredentialReprobe()
if (ownsRecovery) {
// Why: no dial happened — keep the session and the intent; the reprobe
@@ -0,0 +1,38 @@
import type { RelayRecoveryLog } from './mobile-relay-recovery-log'
import { RelayDirectorHttpError } from './mobile-relay-resume-director'
export function logRelayConnected(log: RelayRecoveryLog): void {
log('runtime channel migrated to relay', undefined, {
level: 'success',
code: 'relay-connected'
})
}
export function logRelayDialFailure(
log: RelayRecoveryLog,
error: Error | null,
source: 'dial' | 'active-session' = 'dial'
): void {
if (!error) {
return
}
const base = `${error.name}: ${String(error.message).slice(0, 80)}`
const detail =
error instanceof RelayDirectorHttpError && error.retryAfterMs != null
? `${base}; retry-after=${error.retryAfterMs}ms`
: base
log(source === 'dial' ? 'relay dial failed' : 'active relay session failed', detail, {
level: 'error',
code: source === 'dial' ? 'relay-dial-failed' : 'relay-session-failed'
})
}
export function logRelayCredentialUnavailable(log: RelayRecoveryLog, hasBundle: boolean): void {
log(
hasBundle
? 'relay credential expired or rejected; slow reprobe armed'
: 'no relay credential bundle; slow reprobe armed',
undefined,
{ level: 'warn', code: 'relay-credential-unavailable' }
)
}
@@ -46,9 +46,7 @@ export class RelayReconnectController {
this.credentials = new RelayCredentialEligibility(dependencies.now)
}
getFailureCount(): number {
return this.failureCount.current()
}
getFailureCount = (): number => this.failureCount.current()
reportRecoveryTo(logical: StableLogicalRpcClient): void {
this.failureCount.reportTo(logical.setRecoveryAttempt)
@@ -97,17 +95,17 @@ export class RelayReconnectController {
return 'recover'
}
handleStateFailure(logical: StableLogicalRpcClient, state: ConnectionState): void {
handleStateFailure(logical: StableLogicalRpcClient, state: ConnectionState): Error | null {
if (!this.needsRecovery(state)) {
return
return null
}
this.registerActiveFailure(logical)
const failure = this.registerActiveFailure(logical)
this.onRetry()
return failure
}
needsRecovery(state: ConnectionState): boolean {
return state !== 'connected' && state !== 'connecting' && state !== 'handshaking'
}
needsRecovery = (state: ConnectionState): boolean =>
state !== 'connected' && state !== 'connecting' && state !== 'handshaking'
suspendActiveRelay(logical: StableLogicalRpcClient): void {
if (logical.getActivePath() !== 'relay') {
@@ -207,9 +205,9 @@ export class RelayReconnectController {
recordRejectedCredential = (version: number): void => this.credentials.recordRejected(version)
registerActiveFailure(logical: StableLogicalRpcClient): void {
registerActiveFailure(logical: StableLogicalRpcClient): Error | null {
if (logical.getActivePath() !== 'relay') {
return
return null
}
const failure = this.activeSession?.getFailure()
this.activeSession = null
@@ -219,6 +217,7 @@ export class RelayReconnectController {
} else {
this.activeRelayConnectedAt = null
}
return failure ?? null
}
// True when the caller is still inside the cooldown window and must not
@@ -0,0 +1,14 @@
import { describe, expect, it, vi } from 'vitest'
import { createRelayRecoveryLog } from './mobile-relay-recovery-log'
import type { ConnectionLogEntry } from './types'
describe('createRelayRecoveryLog', () => {
it('uses distinct ids across logger instances', () => {
const entries: ConnectionLogEntry[] = []
const sink = vi.fn((entry: ConnectionLogEntry) => entries.push(entry))
createRelayRecoveryLog(() => 1, sink)('first')
createRelayRecoveryLog(() => 1, sink)('second')
expect(entries[0]?.id).not.toBe(entries[1]?.id)
})
})
@@ -1,6 +1,12 @@
import type { ConnectionLogSink } from './types'
import type { ConnectionDiagnosticCode, ConnectionLogLevel, ConnectionLogSink } from './types'
export type RelayRecoveryLog = (message: string, detail?: string) => void
export type RelayRecoveryLog = (
message: string,
detail?: string,
evidence?: { level?: ConnectionLogLevel; code?: ConnectionDiagnosticCode }
) => void
let relayLoggerInstanceSequence = 0
// Why: relay recovery failed silently in production for weeks; every decision
// must reach logcat and the in-app connection log.
@@ -9,12 +15,15 @@ export function createRelayRecoveryLog(
onLog?: ConnectionLogSink
): RelayRecoveryLog {
let sequence = 0
return (message, detail) => {
const instanceId = `${Date.now().toString(36)}-${(++relayLoggerInstanceSequence).toString(36)}`
return (message, detail, evidence) => {
console.log(`[relay] ${message}`, detail ?? '')
onLog?.({
id: `relay-${++sequence}`,
id: `relay-${instanceId}-${++sequence}`,
ts: now(),
level: 'info',
level: evidence?.level ?? 'info',
path: 'relay',
...(evidence?.code ? { code: evidence.code } : {}),
message: `Relay: ${message}`,
...(detail ? { detail } : {})
})
@@ -21,6 +21,7 @@ vi.mock('./mobile-relay-e2ee-link', () => ({
}))
import { connectMobileRelayRpcSession } from './mobile-relay-rpc-session'
import type { ConnectionLogSink } from './types'
const relay = {
v: 1 as const,
@@ -31,7 +32,7 @@ const relay = {
e2eeFraming: 2 as const
}
async function authenticateSession() {
async function authenticateSession(onLog?: ConnectionLogSink) {
const session = connectMobileRelayRpcSession({
relay,
resumeToken: 'resume-secret',
@@ -39,7 +40,8 @@ async function authenticateSession() {
resumeConfirmReqId: 'confirm-1',
deviceToken: 'device-token',
desktopPublicKeyB64: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
requestTimeoutMs: 30_000
requestTimeoutMs: 30_000,
onLog
})
fakes.linkOptions!.onHello({
type: 'relay-hello',
@@ -103,7 +105,8 @@ describe('mobile relay RPC session liveness', () => {
})
it('disconnects after two fair foreground misses', async () => {
const session = await authenticateSession()
const onLog = vi.fn<ConnectionLogSink>()
const session = await authenticateSession(onLog)
session.notifyForeground('focus')
expect(sentRequests().map(({ method }) => method)).toEqual(['status.get'])
@@ -114,6 +117,38 @@ describe('mobile relay RPC session liveness', () => {
expect(session.getState()).toBe('disconnected')
expect(fakes.close).toHaveBeenCalledOnce()
expect(onLog).toHaveBeenCalledWith(
expect.objectContaining({
code: 'liveness-timeout',
path: 'relay',
message: 'Relay health check failed',
detail: expect.stringMatching(
/^probe-timeout; 2\/2 probes missed; last authenticated activity \d+ms ago$/
)
})
)
})
it('uses distinct liveness evidence IDs for sessions created in the same millisecond', async () => {
vi.setSystemTime(1_000)
const firstLog = vi.fn<ConnectionLogSink>()
const first = await authenticateSession(firstLog)
first.notifyForeground('focus')
await vi.advanceTimersByTimeAsync(8_000)
const firstId = firstLog.mock.calls[0]?.[0].id
vi.clearAllMocks()
fakes.sendText.mockReturnValue(true)
vi.setSystemTime(1_000)
const secondLog = vi.fn<ConnectionLogSink>()
const second = await authenticateSession(secondLog)
second.notifyForeground('focus')
await vi.advanceTimersByTimeAsync(8_000)
const secondId = secondLog.mock.calls[0]?.[0].id
expect(firstId).toBeTruthy()
expect(secondId).toBeTruthy()
expect(secondId).not.toBe(firstId)
})
it('rate-limits foreground sequences without suppressing a retry', async () => {
@@ -11,11 +11,12 @@ import { openRpcRequestBudget, resolvePostConnectRequestTimeout } from './rpc-re
import { isRpcResponse } from './rpc-response-shape'
import { RpcSessionLivenessWatchdog } from './rpc-session-liveness-watchdog'
import type { RpcClient } from './rpc-client'
import type { ConnectionState, RpcResponse } from './types'
import type { ConnectionLogSink, ConnectionState, RpcResponse } from './types'
const RELAY_PROBE_TIMEOUT_MS = 4_000
const RELAY_MISSED_PROBE_LIMIT = 2
const RELAY_FOREGROUND_PROBE_MIN_INTERVAL_MS = 10_000
let relayRpcSessionSequence = 0
type PendingRequest = {
resolve: (response: RpcResponse) => void
@@ -41,6 +42,7 @@ export function connectMobileRelayRpcSession(args: {
desktopPublicKeyB64: string
requestTimeoutMs?: number
createSocket?: (url: string) => WebSocket
onLog?: ConnectionLogSink
}): MobileRelayRpcSession {
const requestTimeoutMs = args.requestTimeoutMs ?? 30_000
const pending = new Map<string, PendingRequest>()
@@ -53,6 +55,8 @@ export function connectMobileRelayRpcSession(args: {
let resumeConfirmation: DeviceResumeConfirmed | null = null
let failure: Error | null = null
let closed = false
let logSequence = 0
const logSessionId = `${Date.now().toString(36)}-${(++relayRpcSessionSequence).toString(36)}`
const livenessIdentity = {}
const streams = new MobileRelayRpcStreams({
nextId,
@@ -145,6 +149,17 @@ export function connectMobileRelayRpcSession(args: {
voluntaryProbeMinIntervalMs: RELAY_FOREGROUND_PROBE_MIN_INTERVAL_MS,
sendProbe: () =>
state === 'connected' && sendFrame({ id: nextId(), method: 'status.get', params: undefined }),
onTimeout: (evidence) => {
args.onLog?.({
id: `relay-liveness-${logSessionId}-${++logSequence}`,
ts: Date.now(),
level: 'error',
code: 'liveness-timeout',
path: 'relay',
message: 'Relay health check failed',
detail: `${evidence.reason}; ${evidence.missedProbes}/${evidence.missedProbeLimit} probes missed; last authenticated activity ${evidence.lastInboundAgeMs}ms ago`
})
},
terminate: () => fail(new Error('relay session liveness timeout'))
})
return client
@@ -0,0 +1,57 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ConnectionLogEntry } from './types'
vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
getItem: vi.fn(),
setItem: vi.fn()
}
}))
describe('persisted connection log store', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
vi.mocked(AsyncStorage.getItem).mockReset()
vi.mocked(AsyncStorage.setItem).mockReset()
vi.mocked(AsyncStorage.setItem).mockResolvedValue(undefined)
})
afterEach(() => {
vi.useRealTimers()
vi.resetModules()
})
it('keeps a new client-session boundary when a restart shares the prior timestamp', async () => {
const stored: ConnectionLogEntry[] = [
{
id: 'client-session-1000',
ts: 1_000,
level: 'info',
code: 'client-session-started',
message: 'Mobile client session started'
},
{
id: 'relay-failure',
ts: 1_000,
level: 'error',
code: 'relay-session-failed',
message: 'Relay: active relay session failed'
}
]
vi.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify(stored))
vi.resetModules()
const { connectionLogStore, recordConnectionClientSessionStart } =
await import('./persisted-connection-log-store')
recordConnectionClientSessionStart('host-a')
await connectionLogStore.hydrate('host-a')
expect(connectionLogStore.get('host-a').map((entry) => entry.code)).toEqual([
'client-session-started',
'relay-session-failed',
'client-session-started'
])
})
})
@@ -0,0 +1,77 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import { createConnectionLogStore } from './connection-log-buffer'
import type { ConnectionLogEntry } from './types'
const STORAGE_PREFIX = 'orca.mobile.connection-log.v1.'
const clientSessionId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`
const sessionStartedHosts = new Set<string>()
export const connectionLogStore = createConnectionLogStore(200, {
async load(hostId) {
const raw = await AsyncStorage.getItem(storageKey(hostId))
if (!raw) {
return []
}
try {
const parsed: unknown = JSON.parse(raw)
return Array.isArray(parsed) ? parsed.filter(isConnectionLogEntry) : []
} catch {
return []
}
},
save(hostId, entries) {
return AsyncStorage.setItem(storageKey(hostId), JSON.stringify(entries))
}
})
export function recordConnectionRevival(
hostId: string,
reason: 'app-resume' | 'network-change'
): void {
const now = Date.now()
connectionLogStore.append(hostId, {
id: `revival-${reason}-${now}`,
ts: now,
level: 'info',
code: reason === 'app-resume' ? 'app-resumed' : 'network-changed',
message: reason === 'app-resume' ? 'App returned to foreground' : 'Network changed',
detail: 'Connection recovery notified'
})
}
export function recordConnectionClientSessionStart(hostId: string): void {
if (sessionStartedHosts.has(hostId)) {
return
}
sessionStartedHosts.add(hostId)
const now = Date.now()
connectionLogStore.append(hostId, {
id: `client-session-${clientSessionId}-${now}`,
ts: now,
level: 'info',
code: 'client-session-started',
message: 'Mobile client session started'
})
}
function storageKey(hostId: string): string {
return `${STORAGE_PREFIX}${encodeURIComponent(hostId)}`
}
function isConnectionLogEntry(value: unknown): value is ConnectionLogEntry {
if (!value || typeof value !== 'object') {
return false
}
const entry = value as Partial<ConnectionLogEntry>
return (
typeof entry.id === 'string' &&
typeof entry.ts === 'number' &&
Number.isFinite(entry.ts) &&
(entry.level === 'info' ||
entry.level === 'success' ||
entry.level === 'warn' ||
entry.level === 'error') &&
typeof entry.message === 'string' &&
(entry.detail === undefined || typeof entry.detail === 'string')
)
}
@@ -6,6 +6,7 @@ import type { RpcClientSocketFactory } from './rpc-client-socket-factory'
import type { RpcClientSocketSession } from './rpc-client-socket-session'
import type { RpcClientStreamRegistry } from './rpc-client-stream-registry'
import { RpcSynthesizedCloseIndex } from './rpc-socket-close-evidence'
import type { ConnectionLogEntry } from './types'
const UNAUTHORIZED_CLOSE_CODE = 4001
@@ -21,7 +22,11 @@ type SocketCloseControllerOptions = {
getAuthenticationGeneration: () => number
isIntentionallyClosed: () => boolean
stopLiveness: (session: RpcClientSocketSession) => void
emitWarning: (message: string, detail: string) => void
emitWarning: (
message: string,
detail: string,
evidence?: Pick<ConnectionLogEntry, 'code' | 'path'>
) => void
}
export class RpcClientSocketCloseController {
@@ -80,7 +85,11 @@ export class RpcClientSocketCloseController {
streamCount: this.options.streams.size(),
attempt: this.options.reconnect.getAttempt()
})
this.options.emitWarning('WebSocket closed', 'Will attempt to reconnect')
this.options.emitWarning(
'WebSocket closed',
`${closeCode == null ? 'Close code unavailable' : `Close code ${closeCode}`}; reconnect scheduled`,
{ code: 'socket-closed' }
)
this.options.requests.rejectAll('Connection interrupted', { deliveryUnknown: true })
this.options.connectionState.publish('reconnecting')
this.options.reconnect.schedule()
@@ -1,7 +1,7 @@
import { publicKeyFromBase64 } from './e2ee'
import { RpcClientSocketSession } from './rpc-client-socket-session'
import { redactSocketEndpoint } from './socket-event-debug'
import type { ConnectionLogLevel, ConnectionState, RpcResponse } from './types'
import type { ConnectionLogEmitter, ConnectionState, RpcResponse } from './types'
type SocketFactoryOptions = {
endpoint: string
@@ -12,7 +12,7 @@ type SocketFactoryOptions = {
getReconnectAttempt: () => number
getLastConnectedAt: () => number | null
isIntentionallyClosed: () => boolean
emitLog: (level: ConnectionLogLevel, message: string, detail?: string) => void
emitLog: ConnectionLogEmitter
onHandshakeStarted: () => void
onAuthenticated: (session: RpcClientSocketSession) => void
onAuthRejected: (reason: string) => void
@@ -9,7 +9,7 @@ import {
import { isRpcResponse } from './rpc-response-shape'
import { isStaleRpcSocketEvent, logRpcSocketClose } from './rpc-socket-close-evidence'
import { describeSocketEvent, redactSocketEndpoint } from './socket-event-debug'
import type { ConnectionLogLevel, ConnectionState, RpcResponse } from './types'
import type { ConnectionLogEmitter, ConnectionState, RpcResponse } from './types'
import { websocketPayloadToUint8 } from './websocket-payload-bytes'
const CONNECT_TIMEOUT_MS = 12_000
@@ -24,7 +24,7 @@ type SocketSessionOptions = {
getState: () => ConnectionState
getReconnectAttempt: () => number
isIntentionallyClosed: () => boolean
emitLog: (level: ConnectionLogLevel, message: string, detail?: string) => void
emitLog: ConnectionLogEmitter
onHandshakeStarted: () => void
onAuthenticated: (session: RpcClientSocketSession) => void
onAuthRejected: (reason: string) => void
@@ -261,7 +261,8 @@ export class RpcClientSocketSession {
this.options.emitLog(
'error',
'WebSocket connect timeout',
`No TCP/WS handshake within ${CONNECT_TIMEOUT_MS / 1000}s — endpoint unreachable?`
`No TCP/WS handshake within ${CONNECT_TIMEOUT_MS / 1000}s — endpoint unreachable?`,
{ code: 'connect-timeout' }
)
this.options.onForcedClose(this)
}
@@ -280,7 +281,8 @@ export class RpcClientSocketSession {
this.options.emitLog(
'error',
'Handshake timeout',
`No e2ee_ready/e2ee_authenticated within ${HANDSHAKE_TIMEOUT_MS / 1000}s`
`No e2ee_ready/e2ee_authenticated within ${HANDSHAKE_TIMEOUT_MS / 1000}s`,
{ code: 'handshake-timeout' }
)
this.options.onForcedClose(this)
}, HANDSHAKE_TIMEOUT_MS)
@@ -46,6 +46,37 @@ describe('RpcSessionLivenessWatchdog', () => {
expect(terminate).toHaveBeenCalledWith(identity)
})
it('reports causal evidence before terminating a stale session', async () => {
const onTimeout = vi.fn()
const terminate = vi.fn()
const identity = {}
const watchdog = new RpcSessionLivenessWatchdog({
transport: 'relay',
idleProbeMs: null,
probeTimeoutMs: 4_000,
missedProbeLimit: 2,
sendProbe: () => true,
terminate,
onTimeout,
now: Date.now
})
watchdog.start(identity)
watchdog.probeNow(identity)
await vi.advanceTimersByTimeAsync(8_000)
expect(onTimeout).toHaveBeenCalledWith({
transport: 'relay',
reason: 'probe-timeout',
missedProbes: 2,
missedProbeLimit: 2,
lastInboundAgeMs: 8_000
})
expect(onTimeout.mock.invocationCallOrder[0]).toBeLessThan(
terminate.mock.invocationCallOrder[0]!
)
})
it('authenticated activity resets suspicion', async () => {
const { identity, terminate, watchdog } = fixture()
watchdog.probeNow(identity)
@@ -8,6 +8,7 @@ type WatchdogOptions = {
transport: 'direct' | 'relay'
sendProbe: (identity: RpcSessionIdentity) => boolean
terminate: (identity: RpcSessionIdentity) => void
onTimeout?: (evidence: LivenessTimeoutEvidence) => void
idleProbeMs?: number | null
probeTimeoutMs?: number
missedProbeLimit?: number
@@ -17,6 +18,14 @@ type WatchdogOptions = {
clearTimer?: typeof clearTimeout
}
export type LivenessTimeoutEvidence = {
transport: 'direct' | 'relay'
reason: 'probe-send-failed' | 'probe-timeout'
missedProbes: number
missedProbeLimit: number
lastInboundAgeMs: number
}
export class RpcSessionLivenessWatchdog {
private identity: RpcSessionIdentity | null = null
private timer: ReturnType<typeof setTimeout> | null = null
@@ -138,7 +147,7 @@ export class RpcSessionLivenessWatchdog {
sent = false
}
if (!sent) {
this.terminateCurrent(identity)
this.terminateCurrent(identity, 'probe-send-failed')
return
}
this.timer = this.setTimer(() => this.handleProbeTimeout(identity, sentAt), this.probeTimeoutMs)
@@ -161,7 +170,7 @@ export class RpcSessionLivenessWatchdog {
}
this.missedProbes += 1
if (this.missedProbes >= this.missedProbeLimit) {
this.terminateCurrent(identity)
this.terminateCurrent(identity, 'probe-timeout')
return
}
console.log('[net] activity-probe timeout tolerated', {
@@ -172,7 +181,10 @@ export class RpcSessionLivenessWatchdog {
this.startProbe(identity)
}
private terminateCurrent(identity: RpcSessionIdentity): void {
private terminateCurrent(
identity: RpcSessionIdentity,
reason: LivenessTimeoutEvidence['reason']
): void {
if (this.identity !== identity) {
return
}
@@ -184,6 +196,13 @@ export class RpcSessionLivenessWatchdog {
missedProbes: this.missedProbes,
missedProbeLimit: this.missedProbeLimit
})
this.options.onTimeout?.({
transport: this.options.transport,
reason,
missedProbes: this.missedProbes,
missedProbeLimit: this.missedProbeLimit,
lastInboundAgeMs: Math.max(0, this.now() - this.lastInboundAt)
})
this.options.terminate(identity)
}
+28
View File
@@ -39,6 +39,25 @@ export type RpcResponse = RpcSuccess | RpcFailure
export type ConnectionLogLevel = 'info' | 'success' | 'warn' | 'error'
export type MobileConnectionDiagnosticPath = 'lan' | 'tailscale' | 'relay'
export type ConnectionDiagnosticCode =
| 'client-session-started'
| 'app-resumed'
| 'network-changed'
| 'connect-timeout'
| 'handshake-timeout'
| 'authentication-rejected'
| 'socket-closed'
| 'liveness-timeout'
| 'retry-scheduled'
| 'relay-dial-failed'
| 'relay-session-failed'
| 'relay-connected'
| 'direct-connected'
| 'relay-credential-unavailable'
| 'host-open-failed'
export type ConnectionLogEntry = {
id: string
ts: number
@@ -47,10 +66,19 @@ export type ConnectionLogEntry = {
message: string
// Optional second line for endpoint/error/elapsed detail.
detail?: string
code?: ConnectionDiagnosticCode
path?: MobileConnectionDiagnosticPath
}
export type ConnectionLogSink = (entry: ConnectionLogEntry) => void
export type ConnectionLogEmitter = (
level: ConnectionLogLevel,
message: string,
detail?: string,
evidence?: Pick<ConnectionLogEntry, 'code' | 'path'>
) => void
export type ConnectionState =
| 'connecting'
| 'handshaking'
+1 -1
View File
@@ -3,7 +3,7 @@ import type { RpcClient } from './rpc-client'
import type { MobileConnectionPath } from './stable-logical-rpc-client'
import type { ConnectionState } from './types'
import { useRpcClientContext } from './client-context'
import type { HostClientAcquisition } from './client-context'
import type { HostClientAcquisition } from './host-client-acquisition-registry'
type UseAllHostClientsOptions = {
autoConnectHostIds?: readonly string[]
@@ -3,6 +3,7 @@ import type { ExecutionHostId } from '../../../src/shared/execution-host'
// Locally-typed subset of the desktop status payload read from status.get.
export type DesktopStatus = {
appVersion?: string
protocolVersion?: number
minCompatibleMobileVersion?: number
// Why: absent on hosts that predate the mobile Floating Workspace entry;
+9 -9
View File
@@ -9,7 +9,7 @@ overrides:
patchedDependencies:
'@vscode/windows-process-tree@0.8.0':
hash: 73a90530e6b95c05dac50f2c48787fb51cb292587773016ebe57eb05e41075a0
hash: 946ffb2b1026bcb554d9920681fb00f546c28fc5de6d95a877d198b49bf73988
path: config/patches/@vscode__windows-process-tree@0.8.0.patch
'@xterm/addon-ligatures@0.11.0-beta.287':
hash: 47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920
@@ -18,7 +18,7 @@ patchedDependencies:
hash: af3b156143ed2ee9903b753145b63dd4a2ee72cadd7ef333ab0f4d5d3ebfc32c
path: config/patches/@xterm__addon-serialize@0.15.0-beta.287.patch
'@xterm/addon-webgl@0.20.0-beta.286':
hash: 6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258
hash: 118f649459ba56a51627d46603e2d5d81d837ac9c4dc393f5cc20263d0ac1b91
path: config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch
'@xterm/xterm@6.1.0-beta.287':
hash: 46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7
@@ -27,7 +27,7 @@ patchedDependencies:
hash: 7333b3837f80a7fbd045964db6d76ba4fc118e49134bdbabb00585b6b7b60673
path: config/patches/lint-staged@16.4.0.patch
node-pty@1.1.0:
hash: 9c665d9e41571341cb8986607d531b953a7f32c0dd60e1e74e633c098d80f2c1
hash: 9a2eedbf2448b8ff1387a8a740ee5e4e968bf8484d7d80e12a3f6a2dc26b0e17
path: config/patches/node-pty@1.1.0.patch
importers:
@@ -69,7 +69,7 @@ importers:
version: 3.3.1
node-pty:
specifier: ^1.1.0
version: 1.1.0(patch_hash=9c665d9e41571341cb8986607d531b953a7f32c0dd60e1e74e633c098d80f2c1)
version: 1.1.0(patch_hash=9a2eedbf2448b8ff1387a8a740ee5e4e968bf8484d7d80e12a3f6a2dc26b0e17)
posthog-node:
specifier: ^5.33.3
version: 5.33.3
@@ -235,7 +235,7 @@ importers:
version: 0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))
'@xterm/addon-webgl':
specifier: 0.20.0-beta.286
version: 0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))
version: 0.20.0-beta.286(patch_hash=118f649459ba56a51627d46603e2d5d81d837ac9c4dc393f5cc20263d0ac1b91)(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))
'@xterm/xterm':
specifier: 6.1.0-beta.287
version: 6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7)
@@ -413,7 +413,7 @@ importers:
optionalDependencies:
'@vscode/windows-process-tree':
specifier: 0.8.0
version: 0.8.0(patch_hash=73a90530e6b95c05dac50f2c48787fb51cb292587773016ebe57eb05e41075a0)
version: 0.8.0(patch_hash=946ffb2b1026bcb554d9920681fb00f546c28fc5de6d95a877d198b49bf73988)
sherpa-onnx-darwin-arm64:
specifier: 1.12.37
version: 1.12.37
@@ -9617,7 +9617,7 @@ snapshots:
convert-source-map: 2.0.0
tinyrainbow: 3.1.0
'@vscode/windows-process-tree@0.8.0(patch_hash=73a90530e6b95c05dac50f2c48787fb51cb292587773016ebe57eb05e41075a0)':
'@vscode/windows-process-tree@0.8.0(patch_hash=946ffb2b1026bcb554d9920681fb00f546c28fc5de6d95a877d198b49bf73988)':
dependencies:
node-addon-api: 7.1.0
optional: true
@@ -9650,7 +9650,7 @@ snapshots:
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7)
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))':
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=118f649459ba56a51627d46603e2d5d81d837ac9c4dc393f5cc20263d0ac1b91)(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7)
@@ -12058,7 +12058,7 @@ snapshots:
node-int64@0.4.0: {}
node-pty@1.1.0(patch_hash=9c665d9e41571341cb8986607d531b953a7f32c0dd60e1e74e633c098d80f2c1):
node-pty@1.1.0(patch_hash=9a2eedbf2448b8ff1387a8a740ee5e4e968bf8484d7d80e12a3f6a2dc26b0e17):
dependencies:
node-addon-api: 7.1.1
@@ -0,0 +1,210 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const browserMocks = vi.hoisted(() => ({
appGetPathMock: vi.fn(() => '/downloads'),
shellOpenExternalMock: vi.fn(),
browserWindowFromWebContentsMock: vi.fn(),
menuBuildFromTemplateMock: vi.fn(),
guestOffMock: vi.fn(),
guestOnMock: vi.fn(),
guestSetBackgroundThrottlingMock: vi.fn(),
guestSetWindowOpenHandlerMock: vi.fn(),
guestOpenDevToolsMock: vi.fn(),
webContentsFromIdMock: vi.fn(),
screenGetCursorScreenPointMock: vi.fn(() => ({ x: 0, y: 0 })),
openPopupWithOriginBarMock: vi.fn()
}))
vi.mock('electron', () => ({
app: { getPath: browserMocks.appGetPathMock },
BrowserWindow: { fromWebContents: browserMocks.browserWindowFromWebContentsMock },
clipboard: { writeText: vi.fn() },
shell: { openExternal: browserMocks.shellOpenExternalMock },
Menu: { buildFromTemplate: browserMocks.menuBuildFromTemplateMock },
screen: { getCursorScreenPoint: browserMocks.screenGetCursorScreenPointMock },
webContents: { fromId: browserMocks.webContentsFromIdMock }
}))
vi.mock('./popup-origin-bar-window', () => ({
openPopupWithOriginBar: browserMocks.openPopupWithOriginBarMock
}))
import { browserManager } from './browser-manager'
import type { BrowserAnnotationViewportBridgeOptions } from '../../shared/browser-annotation-viewport-bridge'
import {
rendererWebContentsId,
resetBrowserManagerMocks,
resetBrowserManagerState
} from './browser-manager-test-harness'
import {
createViewportGuestFactory,
flushViewportOps
} from './browser-manager-viewport-test-fixtures'
const { webContentsFromIdMock } = browserMocks
const makeGuest = createViewportGuestFactory(browserMocks)
const BRIDGE_OPTIONS: BrowserAnnotationViewportBridgeOptions = {
emitViewport: false,
enabled: true,
markers: [],
token: 'annotationviewporttoken'
}
function registerPage(pageId: string, guest: Record<string, unknown>): void {
webContentsFromIdMock.mockReturnValue(guest)
browserManager.attachGuestPolicies(guest as never)
browserManager.registerGuest({
browserPageId: pageId,
webContentsId: guest.id as number,
rendererWebContentsId
})
}
/** The production resolver's shape: read the registry now, not when the request was made. */
function resolveFromRegistry(pageId: string): () => Electron.WebContents | null {
return () => browserManager.getAuthorizedGuest(pageId, rendererWebContentsId)
}
describe('browserManager.setAnnotationViewportBridge', () => {
beforeEach(() => {
resetBrowserManagerMocks(browserMocks)
resetBrowserManagerState()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('installs the bridge in an isolated world', async () => {
const { guest } = makeGuest(4646)
registerPage('tab-annotations', guest)
const ok = await browserManager.setAnnotationViewportBridge(
'tab-annotations',
BRIDGE_OPTIONS,
resolveFromRegistry('tab-annotations')
)
expect(ok).toBe(true)
expect(guest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledWith(
expect.any(Number),
[
expect.objectContaining({
code: expect.stringContaining('__orcaBrowserAnnotationViewportBridge')
})
],
false
)
})
// Why this is the case that matters: a cross-process navigation re-registers the same page id
// with a new WebContents and leaves the retired one alive, so a bridge op that resolved its
// guest when the request arrived would inject into a page nobody is looking at — and the
// annotation badges would stop tracking the document on screen.
it('injects into the guest the page has when a queued op finally runs, not the one it was asked with', async () => {
const { guest: firstGuest } = makeGuest(5101)
let releaseFirstInjection = (): void => {}
// One shared gate for every call, so a wrongly-routed second injection still settles and the
// test fails on where it landed rather than on a timeout.
const firstInjectionGate = new Promise<void>((resolve) => {
releaseFirstInjection = () => resolve()
})
firstGuest.executeJavaScriptInIsolatedWorld = vi.fn(() => firstInjectionGate)
registerPage('tab-swap', firstGuest)
const firstDone = browserManager.setAnnotationViewportBridge(
'tab-swap',
BRIDGE_OPTIONS,
resolveFromRegistry('tab-swap')
)
await flushViewportOps()
// A second request arrives while the first still holds the chain — at this moment the page is
// still on the first guest, which is the guest a request-time resolution would capture.
const secondDone = browserManager.setAnnotationViewportBridge(
'tab-swap',
BRIDGE_OPTIONS,
resolveFromRegistry('tab-swap')
)
await flushViewportOps()
// Only now does the page swap renderer processes, while the second op is still queued.
const { guest: secondGuest } = makeGuest(5102)
registerPage('tab-swap', secondGuest)
releaseFirstInjection()
await expect(firstDone).resolves.toBe(true)
await expect(secondDone).resolves.toBe(true)
expect(secondGuest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledTimes(1)
expect(firstGuest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledTimes(1)
})
// Why this is the resolver's cleanup and not the bridge's: the authority that reads the registry
// is the one that can see the guest is gone. The bridge only reports the refusal.
it('refuses when its guest died while the op was queued, and the resolver drops the registration', async () => {
const { guest } = makeGuest(5103)
registerPage('tab-dies', guest)
expect(browserManager.getGuestWebContentsId('tab-dies')).toBe(5103)
webContentsFromIdMock.mockReturnValue(null)
await expect(
browserManager.setAnnotationViewportBridge(
'tab-dies',
BRIDGE_OPTIONS,
resolveFromRegistry('tab-dies')
)
).resolves.toBe(false)
// Why assert the registry and not just the answer: a stale guest has to clear every per-tab
// entry, or the page keeps a dead WebContents id that later ops resolve against.
expect(browserManager.getGuestWebContentsId('tab-dies')).toBeNull()
})
// Why this one exists: an unresolved guest is not the same as a dead one. A request addressed by
// the wrong renderer names a page that is alive and on screen, and answering it with teardown
// would cancel that page's in-flight downloads and grabs over a misaddressed message.
it('refuses a request from the wrong renderer without tearing down the healthy page it named', async () => {
const { guest } = makeGuest(5105)
registerPage('tab-mismatch', guest)
const unregisterGuest = vi.spyOn(browserManager, 'unregisterGuest')
await expect(
browserManager.setAnnotationViewportBridge('tab-mismatch', BRIDGE_OPTIONS, () =>
browserManager.getAuthorizedGuest('tab-mismatch', rendererWebContentsId + 1)
)
).resolves.toBe(false)
expect(unregisterGuest).not.toHaveBeenCalled()
expect(browserManager.getGuestWebContentsId('tab-mismatch')).toBe(5105)
expect(guest.executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled()
})
it('refuses a destroyed guest without injecting into it', async () => {
const { guest } = makeGuest(5104)
registerPage('tab-destroyed', guest)
;(guest.isDestroyed as ReturnType<typeof vi.fn>).mockReturnValue(true)
await expect(
browserManager.setAnnotationViewportBridge(
'tab-destroyed',
BRIDGE_OPTIONS,
() => guest as never
)
).resolves.toBe(false)
expect(guest.executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled()
})
// Why: a document page owns no browsing state, so treating its refusal as a stale page would run
// teardown against an id the browsing registry never held.
it('does not run page teardown when a document page resolves to nothing', async () => {
const unregisterGuest = vi.spyOn(browserManager, 'unregisterGuest')
await expect(
browserManager.setAnnotationViewportBridge('doc-page-1', BRIDGE_OPTIONS, () => null)
).resolves.toBe(false)
expect(unregisterGuest).not.toHaveBeenCalled()
})
})
@@ -45,6 +45,32 @@ import {
resetBrowserManagerMocks,
resetBrowserManagerState
} from './browser-manager-test-harness'
import { installDocPreviewGuestPolicy } from './doc-preview-guest-policy'
import { mintDocPreviewGrant, revokeAllDocPreviewGrants } from './doc-preview-grant-registry'
import { buildDocPreviewUrl } from '../../shared/doc-preview-scheme'
/**
* A page the document half of the registry really holds. Built rather than named: membership is
* what both doors refuse on now, so an id that merely looks like a preview's would be admitted.
*/
function registerWorkspaceDocPage(browserPageId: string): void {
const grant = mintDocPreviewGrant({
owner: { kind: 'ssh', connectionId: 'ssh-1' },
root: '/home/alice/docs',
entryRelativePath: 'index.html',
browserPageId
})
const guest = {
isFocused: () => false,
isDestroyed: () => false,
getURL: () => buildDocPreviewUrl(grant.id, grant.entryRelativePath),
on: vi.fn(),
once: vi.fn(),
setWindowOpenHandler: vi.fn(),
setWebRTCIPHandlingPolicy: vi.fn()
}
installDocPreviewGuestPolicy(guest as never, { id: rendererWebContentsId, send: vi.fn() })
}
const {
guestOffMock,
@@ -60,6 +86,7 @@ describe('browserManager', () => {
beforeEach(() => {
resetBrowserManagerMocks(browserMocks)
resetBrowserManagerState()
revokeAllDocPreviewGrants()
})
afterEach(() => {
@@ -92,6 +119,77 @@ describe('browserManager', () => {
expect(browserManager.getSessionProfileIdForTab('browser-1')).toBe('work')
})
// Why both doors: one id in both halves of the registry would make the tool door answer with a
// document guest for a page the reader is browsing in.
it.each(['registerGuest', 'registerOffscreenGuest'] as const)(
'refuses %s for a page the document registry already holds',
(entryPoint) => {
const guest = {
id: 129,
isDestroyed: vi.fn(() => false),
getType: vi.fn(() => 'webview'),
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
on: guestOnMock,
off: guestOffMock,
openDevTools: guestOpenDevToolsMock
}
webContentsFromIdMock.mockReturnValue(guest)
browserManager.attachGuestPolicies(guest as never)
const browserPageId = 'doc-page-1'
registerWorkspaceDocPage(browserPageId)
if (entryPoint === 'registerGuest') {
expect(
browserManager.registerGuest({
browserPageId,
webContentsId: guest.id,
rendererWebContentsId
})
).toBe(false)
} else {
expect(
browserManager.registerOffscreenGuest({ browserPageId, webContentsId: guest.id })
).toBe(false)
}
expect(browserManager.getGuestWebContentsId(browserPageId)).toBeNull()
}
)
// Why this answer is load-bearing: the headless backend destroys its window on false, so a true
// for a guest that is already gone would leave a page id registered onto nothing.
it.each(['missing', 'destroyed'] as const)(
'refuses registerOffscreenGuest when the named guest is %s',
(guestState) => {
webContentsFromIdMock.mockReturnValue(
guestState === 'missing' ? null : { id: 137, isDestroyed: vi.fn(() => true) }
)
expect(
browserManager.registerOffscreenGuest({ browserPageId: 'offscreen-1', webContentsId: 137 })
).toBe(false)
expect(browserManager.getGuestWebContentsId('offscreen-1')).toBeNull()
}
)
// Why the exit door needs the same check: a document page withdraws by revoking its grant, so its
// id here is misaddressed — and unregistering opens by evicting whatever grab that id names.
it('refuses unregisterGuest for a page the document registry holds', () => {
registerWorkspaceDocPage('doc-page-2')
const cancelGrabOp = vi.spyOn(browserManager, 'cancelGrabOp')
browserManager.unregisterGuest('doc-page-2')
expect(cancelGrabOp).not.toHaveBeenCalled()
// The presence half: the same door does evict a browsing page's grab.
browserManager.unregisterGuest('browser-page-1')
expect(cancelGrabOp).toHaveBeenCalledWith('browser-page-1', 'evicted')
cancelGrabOp.mockRestore()
})
it('blocks non-web guest navigations after attach', () => {
const guest = {
isDestroyed: vi.fn(() => false),
@@ -0,0 +1,250 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const browserMocks = vi.hoisted(() => ({
appGetPathMock: vi.fn(() => '/downloads'),
shellOpenExternalMock: vi.fn(),
browserWindowFromWebContentsMock: vi.fn(),
menuBuildFromTemplateMock: vi.fn(),
guestOffMock: vi.fn(),
guestOnMock: vi.fn(),
guestSetBackgroundThrottlingMock: vi.fn(),
guestSetWindowOpenHandlerMock: vi.fn(),
guestOpenDevToolsMock: vi.fn(),
webContentsFromIdMock: vi.fn(),
screenGetCursorScreenPointMock: vi.fn(() => ({ x: 0, y: 0 })),
openPopupWithOriginBarMock: vi.fn()
}))
vi.mock('electron', () => ({
app: { getPath: browserMocks.appGetPathMock },
BrowserWindow: { fromWebContents: browserMocks.browserWindowFromWebContentsMock },
clipboard: { writeText: vi.fn() },
shell: { openExternal: browserMocks.shellOpenExternalMock },
Menu: { buildFromTemplate: browserMocks.menuBuildFromTemplateMock },
screen: { getCursorScreenPoint: browserMocks.screenGetCursorScreenPointMock },
webContents: { fromId: browserMocks.webContentsFromIdMock }
}))
vi.mock('./popup-origin-bar-window', () => ({
openPopupWithOriginBar: browserMocks.openPopupWithOriginBarMock
}))
import { browserManager } from './browser-manager'
import { resetBrowserManagerMocks, resetBrowserManagerState } from './browser-manager-test-harness'
import { getWorkspaceDocPageGuest } from './doc-preview-guest-policy'
import { mintDocPreviewGrant, revokeAllDocPreviewGrants } from './doc-preview-grant-registry'
import { buildDocPreviewUrl } from '../../shared/doc-preview-scheme'
type GuestFake = {
id: number
url: string
listeners: Map<string, ((...args: never[]) => void)[]>
windowOpenHandler: ((details: { url: string; frameName: string }) => unknown) | null
webRtcPolicy: string | null
isDestroyed: () => boolean
isFocused: () => boolean
getURL: () => string
getType: () => string
setBackgroundThrottling: (value: boolean) => void
setWebRTCIPHandlingPolicy: (policy: string) => void
setWindowOpenHandler: (handler: (details: { url: string; frameName: string }) => unknown) => void
executeJavaScriptInIsolatedWorld: ReturnType<typeof vi.fn>
debugger: {
isAttached: () => boolean
attach: ReturnType<typeof vi.fn>
sendCommand: ReturnType<typeof vi.fn>
}
on: (event: string, listener: (...args: never[]) => void) => void
once: (event: string, listener: (...args: never[]) => void) => void
off: (event: string, listener: (...args: never[]) => void) => void
}
function createGuest(id: number, url: string): GuestFake {
const listeners = new Map<string, ((...args: never[]) => void)[]>()
const guest: GuestFake = {
id,
url,
listeners,
windowOpenHandler: null,
webRtcPolicy: null,
isDestroyed: () => false,
isFocused: () => true,
getURL: () => guest.url,
getType: () => 'webview',
setBackgroundThrottling: vi.fn(),
setWebRTCIPHandlingPolicy: (policy: string) => {
guest.webRtcPolicy = policy
},
setWindowOpenHandler: (handler) => {
guest.windowOpenHandler = handler
},
executeJavaScriptInIsolatedWorld: vi.fn(async () => undefined),
debugger: {
isAttached: () => true,
attach: vi.fn(),
sendCommand: vi.fn(async () => undefined)
},
on: (event, listener) => {
listeners.set(event, [...(listeners.get(event) ?? []), listener])
},
once: (event, listener) => {
listeners.set(event, [...(listeners.get(event) ?? []), listener])
},
off: (event, listener) => {
listeners.set(
event,
(listeners.get(event) ?? []).filter((entry) => entry !== listener)
)
}
}
return guest
}
function listenerCount(guest: GuestFake, event: string): number {
return guest.listeners.get(event)?.length ?? 0
}
/** Drives the guest's own will-navigate listeners and reports whether they refused. */
function navigateTo(guest: GuestFake, url: string): boolean {
let prevented = false
const event = { preventDefault: () => (prevented = true) } as never
for (const listener of guest.listeners.get('will-navigate') ?? []) {
;(listener as (event: unknown, url: string) => void)(event, url)
}
return prevented
}
const host = { id: 5001, send: vi.fn() } as unknown as Electron.WebContents
beforeEach(() => {
resetBrowserManagerMocks(browserMocks)
resetBrowserManagerState()
revokeAllDocPreviewGrants()
browserMocks.guestSetBackgroundThrottlingMock.mockReturnValue(undefined)
})
describe('guest policy profiles', () => {
function attachPreviewGuest(id = 301): {
guest: GuestFake
grantId: string
browserPageId: string
} {
const browserPageId = `doc-page-${id}`
const grant = mintDocPreviewGrant({
owner: { kind: 'ssh', connectionId: 'ssh-1' },
root: '/home/alice/docs',
entryRelativePath: 'index.html',
browserPageId
})
const guest = createGuest(id, buildDocPreviewUrl(grant.id, 'index.html'))
browserManager.attachGuestPolicies(guest as never, null, { profile: 'workspace-doc', host })
return { guest, grantId: grant.id, browserPageId }
}
// The presence half of every absence below: a browsing guest observably takes all of it through
// the same method, so a profile that fenced nothing — or an attach path that stopped installing
// anything at all — cannot pass these by being uniformly empty.
it('gives a browsing guest link routing, popups and anti-detection', () => {
const guest = createGuest(300, 'https://example.com/')
browserManager.attachGuestPolicies(guest as never)
expect(listenerCount(guest, 'dom-ready')).toBe(1)
expect(listenerCount(guest, 'frame-created')).toBe(1)
expect(listenerCount(guest, 'did-create-window')).toBe(1)
expect(guest.debugger.sendCommand).toHaveBeenCalled()
expect(navigateTo(guest, 'https://elsewhere.example/')).toBe(false)
})
it('gives a workspace-document guest none of it', () => {
const { guest } = attachPreviewGuest()
expect(listenerCount(guest, 'dom-ready')).toBe(0)
expect(listenerCount(guest, 'frame-created')).toBe(0)
expect(listenerCount(guest, 'did-create-window')).toBe(0)
expect(guest.debugger.sendCommand).not.toHaveBeenCalled()
expect(guest.executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled()
})
it('holds a workspace-document guest inside the grant it is showing', () => {
const { guest, grantId } = attachPreviewGuest()
expect(navigateTo(guest, buildDocPreviewUrl(grantId, 'other.html'))).toBe(false)
expect(navigateTo(guest, 'https://example.com/')).toBe(true)
expect(guest.webRtcPolicy).toBe('disable_non_proxied_udp')
})
it('denies every window a workspace-document guest asks for', () => {
const { guest } = attachPreviewGuest()
expect(guest.windowOpenHandler?.({ url: 'https://example.com/', frameName: '' })).toEqual({
action: 'deny'
})
})
// Why teardown and not just install: registration and teardown both key on the guest having been
// policy-attached, so a profile that installs outside that bookkeeping leaves the id marked
// attached forever and stays answerable to tools after the surface is gone.
it('tears a workspace-document guest down through the same policy cleanup', () => {
const { guest, browserPageId } = attachPreviewGuest(302)
expect(getWorkspaceDocPageGuest(browserPageId, host.id)).toBe(guest as never)
for (const listener of guest.listeners.get('destroyed') ?? []) {
listener()
}
expect(getWorkspaceDocPageGuest(browserPageId, host.id)).toBeNull()
// The manager's own bookkeeping was reached too: a second attach is refused while the id is
// still marked policy-attached, and accepted once its teardown has run.
browserManager.attachGuestPolicies(guest as never)
expect(listenerCount(guest, 'dom-ready')).toBe(1)
})
// The seam the whole split rests on: one public door answers for both halves, and each page id
// resolves in exactly one of them. Asserted against the real manager, because the IPC census
// test has to mock it.
describe('the one door across both halves of the registry', () => {
const BROWSING_PAGE_ID = 'browser-page-1'
function registerBrowsingGuest(id = 400): GuestFake {
const guest = createGuest(id, 'https://example.com/')
browserMocks.webContentsFromIdMock.mockReturnValue(guest)
browserManager.attachGuestPolicies(guest as never)
browserManager.registerGuest({
browserPageId: BROWSING_PAGE_ID,
workspaceId: 'workspace-1',
worktreeId: 'wt-1',
webContentsId: id,
rendererWebContentsId: host.id
})
return guest
}
it('answers each page with the guest of its own half', () => {
const browsing = registerBrowsingGuest()
const { guest: document, browserPageId } = attachPreviewGuest()
expect(browserManager.getAuthorizedGuest(BROWSING_PAGE_ID, host.id)).toBe(browsing as never)
expect(browserManager.getAuthorizedGuest(browserPageId, host.id)).toBe(document as never)
})
// Why this is the containment claim and not a lookup detail: page management, agent commands,
// download routing and certificate attribution all read the browsing map directly, so a
// document page being absent from it is what fences them without a guard of their own.
it('keeps a document page out of the browsing map entirely', () => {
registerBrowsingGuest()
const { browserPageId } = attachPreviewGuest()
expect(browserManager.getGuestWebContentsId(browserPageId)).toBeNull()
expect([...browserManager.getWebContentsIdByTabId().keys()]).toEqual([BROWSING_PAGE_ID])
})
})
it('attaches a guest once whatever profile it was asked for', () => {
const { guest } = attachPreviewGuest(303)
browserManager.attachGuestPolicies(guest as never)
expect(listenerCount(guest, 'dom-ready')).toBe(0)
expect(listenerCount(guest, 'will-navigate')).toBe(1)
})
})
@@ -882,34 +882,5 @@ describe('browserManager', () => {
expect(debuggerAttach).toHaveBeenCalledWith('1.3')
expect(debuggerSendCommand).not.toHaveBeenCalled()
})
it('installs annotation viewport bridge in an isolated world', async () => {
const { guest } = makeGuest(4646)
webContentsFromIdMock.mockReturnValue(guest)
browserManager.attachGuestPolicies(guest as never)
browserManager.registerGuest({
browserPageId: 'tab-annotations',
webContentsId: guest.id as number,
rendererWebContentsId
})
const ok = await browserManager.setAnnotationViewportBridge('tab-annotations', {
emitViewport: false,
enabled: true,
markers: [],
token: 'annotationviewporttoken'
})
expect(ok).toBe(true)
expect(guest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledWith(
expect.any(Number),
[
expect.objectContaining({
code: expect.stringContaining('__orcaBrowserAnnotationViewportBridge')
})
],
false
)
})
})
})
+91 -15
View File
@@ -63,6 +63,11 @@ import {
BROWSER_ANNOTATION_VIEWPORT_BRIDGE_WORLD_ID,
buildBrowserAnnotationViewportBridgeScript
} from '../../shared/browser-annotation-viewport-bridge'
import {
getWorkspaceDocPageGuest,
installDocPreviewGuestPolicy,
isWorkspaceDocPageId
} from './doc-preview-guest-policy'
import type { KeybindingOverrides } from '../../shared/keybindings'
import {
BrowserCertificateTrustController,
@@ -157,6 +162,15 @@ type PopupOwnerContext = {
browserTabId: string
rootGuestWebContentsId: number
}
/**
* What a guest is allowed to be. A browsing guest is the web — popups, clicked-link routing and
* anti-detection all apply. A workspace-document guest renders one granted document and gets none
* of that; `host` is the renderer that minted its grant, and the only sink for what it reports.
*/
export type BrowserGuestPolicy =
| { profile: 'browsing' }
| { profile: 'workspace-doc'; host: Electron.WebContents }
const BROWSING_GUEST_POLICY: BrowserGuestPolicy = { profile: 'browsing' }
type PendingMainFrameNavigation = {
currentUrl: string
supersededUrls: string[]
@@ -671,12 +685,20 @@ export class BrowserManager {
attachGuestPolicies(
guest: Electron.WebContents,
inheritedOwnerContext: PopupOwnerContext | null = null
inheritedOwnerContext: PopupOwnerContext | null = null,
policy: BrowserGuestPolicy = BROWSING_GUEST_POLICY
): void {
if (this.policyAttachedGuestIds.has(guest.id)) {
return
}
this.policyAttachedGuestIds.add(guest.id)
// Why one door with a profile rather than a second installer beside it: whether a guest was
// policy-attached at all is what registration and teardown both key on, so a guest that took
// another path into the app is invisible to both.
if (policy.profile === 'workspace-doc') {
this.attachWorkspaceDocGuestPolicies(guest, policy.host)
return
}
if (inheritedOwnerContext) {
this.popupOwnerContextByGuestId.set(guest.id, inheritedOwnerContext)
}
@@ -1016,6 +1038,30 @@ export class BrowserManager {
})
}
/**
* A workspace document is not the web: no popups, no link routing, no anti-detection, and no
* navigation bookkeeping for chrome it does not have. What it does share with a browsing guest is
* this method's teardown, so a retired preview drops its listeners on the same path.
*/
private attachWorkspaceDocGuestPolicies(
guest: Electron.WebContents,
host: Electron.WebContents
): void {
const disposeDocPolicy = installDocPreviewGuestPolicy(guest, host)
const handleDestroyed = (): void => {
this.cleanupGuestPolicyAttachment(guest.id)
}
guest.on('destroyed', handleDestroyed)
this.policyCleanupByGuestId.set(guest.id, () => {
disposeDocPolicy()
try {
guest.off('destroyed', handleDestroyed)
} catch {
// guest may already be destroyed
}
})
}
// Why: navigator.userAgent (read by Google's auth JS) reflects the WebContents UA,
// not the request header, so the header-level Firefox switch in setupClientHintsOverride
// must be matched here per navigation or the two layers disagree — itself a bot tell.
@@ -1318,7 +1364,9 @@ export class BrowserManager {
rendererWebContentsId
}: BrowserGuestRegistration): boolean {
const browserTabId = browserPageId ?? legacyBrowserTabId
if (!browserTabId) {
// Why refuse rather than overwrite: the two halves of the registry must stay disjoint, or one
// id resolves in both and the tool door silently prefers the document guest over the page.
if (!browserTabId || isWorkspaceDocPageId(browserTabId)) {
return false
}
// Why: on guest-surface swap, cancel any grab bound to the old guest's listeners so it doesn't strand on a stale webContents.
@@ -1377,6 +1425,12 @@ export class BrowserManager {
}
unregisterGuest(browserTabId: string): void {
// Why the check on the exit door too: a document page withdraws by revoking its grant, never
// through here, so its id arriving is misaddressed — and the cancel below would evict that
// preview's live grab on the strength of it.
if (isWorkspaceDocPageId(browserTabId)) {
return
}
// Why: teardown mid-grab must cancel it so the renderer gets a signal, not a dangling Promise.
this.cancelGrabOp(browserTabId, 'evicted')
@@ -1444,10 +1498,15 @@ export class BrowserManager {
sessionProfileId?: string | null
userAgentMode?: BrowserSessionUserAgentMode
webContentsId: number
}): void {
}): boolean {
// Why the same check on both registration doors: one id resolving in both halves is the exact
// confusion the split registries exist to prevent.
if (isWorkspaceDocPageId(browserPageId)) {
return false
}
const guest = webContents.fromId(webContentsId)
if (!guest || guest.isDestroyed()) {
return
return false
}
// Why: offscreen pages have no renderer webview listeners, so main owns their load-failure lifecycle.
this.offscreenGuestIds.add(webContentsId)
@@ -1468,6 +1527,7 @@ export class BrowserManager {
this.worktreeIdByTabId.set(browserPageId, worktreeId)
}
this.certificateTrustController?.onGuestRegistered(webContentsId, browserPageId)
return true
}
unregisterAll(): void {
@@ -1843,12 +1903,13 @@ export class BrowserManager {
async setAnnotationViewportBridge(
browserTabId: string,
options: BrowserAnnotationViewportBridgeOptions
options: BrowserAnnotationViewportBridgeOptions,
resolveGuest: () => Electron.WebContents | null
): Promise<boolean> {
const prev = this.annotationViewportBridgeOpsByTabId.get(browserTabId) ?? Promise.resolve()
const next = prev
.catch(() => {})
.then(() => this.doSetAnnotationViewportBridgeImpl(browserTabId, options))
.then(() => this.doSetAnnotationViewportBridgeImpl(options, resolveGuest))
this.annotationViewportBridgeOpsByTabId.set(browserTabId, next)
try {
return await next
@@ -1859,18 +1920,22 @@ export class BrowserManager {
}
}
// Why the caller resolves the guest: the same bridge serves browsing pages and workspace
// documents, which live in different halves of the page registry.
// Why a resolver and not the guest itself: this op may have waited behind another one, and a
// cross-process navigation meanwhile swaps the tab's contents without destroying the old one —
// injecting into the guest the request named would bridge a page nobody is looking at.
// Why no tab id: with teardown gone this reaches only the guest the resolver hands back, and
// taking an id it cannot act on would invite the next reader to act on it.
private async doSetAnnotationViewportBridgeImpl(
browserTabId: string,
options: BrowserAnnotationViewportBridgeOptions
options: BrowserAnnotationViewportBridgeOptions,
resolveGuest: () => Electron.WebContents | null
): Promise<boolean> {
const webContentsId = this.webContentsIdByTabId.get(browserTabId)
if (!webContentsId) {
return false
}
const guest = webContents.fromId(webContentsId)
// Why no teardown here: the resolver already unregisters a page whose guest died, and the only
// case it uniquely leaves is an ownership mismatch on a healthy page — where tearing down would
// cancel that page's in-flight downloads and grabs over a request that was merely misaddressed.
const guest = resolveGuest()
if (!guest || guest.isDestroyed()) {
// Why: a stale guest must clear every per-tab registry entry, not just the WebContents maps.
this.unregisterGuest(browserTabId)
return false
}
@@ -1977,10 +2042,21 @@ export class BrowserManager {
// --- Browser Context Grab — main-owned operations ---
/** Validate that the sender owns browserTabId; returns the guest WebContents or null. */
/**
* The guest a request from `senderWebContentsId` may act on, across both halves of the page
* registry. This is the only door taught about workspace-document guests: they are kept out of
* the browsing maps entirely, so page management, agent commands, download routing and
* certificate attribution all miss them without a guard of their own — and a reader who opens a
* tool on the document in front of them still gets an answer.
*/
getAuthorizedGuest(
browserTabId: string,
senderWebContentsId: number
): Electron.WebContents | null {
const docGuest = getWorkspaceDocPageGuest(browserTabId, senderWebContentsId)
if (docGuest) {
return docGuest
}
const registeredRenderer = this.rendererWebContentsIdByTabId.get(browserTabId)
if (registeredRenderer == null || registeredRenderer !== senderWebContentsId) {
return null
@@ -0,0 +1,235 @@
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(),
clearBrowserWebAuthnAccessHandlers: vi.fn(),
installBrowserWebAuthnAccessHandlers: 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: mocks.clearBrowserWebAuthnAccessHandlers,
installBrowserWebAuthnAccessHandlers: mocks.installBrowserWebAuthnAccessHandlers
}))
type PartitionPolicyInstaller = (
profile: BrowserSessionProfile,
options?: { downloads?: 'route' | 'deny'; permissions?: 'browser' | '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)
})
})
describe('partition permission policy', () => {
it('keeps ordinary browser partitions on the browser permission policy', async () => {
const install = await loadInstaller()
install(profileFor('persist:browsing-1'))
expect(mocks.installBrowserWebAuthnAccessHandlers).toHaveBeenCalledWith(
sessionsByPartition.get('persist:browsing-1')
)
expect(mocks.clearBrowserWebAuthnAccessHandlers).not.toHaveBeenCalled()
})
it('denies every request and check on a strict partition without WebAuthn handlers', async () => {
const install = await loadInstaller()
install(profileFor('orca-doc-preview'), { permissions: 'deny' })
const sess = sessionsByPartition.get('orca-doc-preview')
if (!sess) {
throw new Error('Expected the preview session')
}
const requestHandler = sess.setPermissionRequestHandler.mock.calls[0]?.[0] as (
webContents: Electron.WebContents,
permission: string,
callback: (allowed: boolean) => void
) => void
const checkHandler = sess.setPermissionCheckHandler.mock.calls[0]?.[0] as (
webContents: Electron.WebContents,
permission: string
) => boolean
const displayMediaHandler = sess.setDisplayMediaRequestHandler.mock.calls[0]?.[0] as (
request: Electron.DisplayMediaRequestHandlerHandlerRequest,
callback: (streams: { video?: Electron.WebFrameMain; audio?: 'loopback' }) => void
) => void
for (const permission of ['media', 'clipboard-read', 'notifications', 'fullscreen']) {
let decision: boolean | null = null
requestHandler({} as Electron.WebContents, permission, (allowed) => (decision = allowed))
expect(decision).toBe(false)
expect(checkHandler({} as Electron.WebContents, permission)).toBe(false)
}
expect(mocks.installBrowserWebAuthnAccessHandlers).not.toHaveBeenCalled()
expect(mocks.clearBrowserWebAuthnAccessHandlers).toHaveBeenCalledWith(sess)
let displayMediaDecision: { video?: Electron.WebFrameMain; audio?: 'loopback' } | null = null
displayMediaHandler({} as Electron.DisplayMediaRequestHandlerHandlerRequest, (decision) => {
displayMediaDecision = decision
})
expect(displayMediaDecision).toEqual({ video: undefined, audio: undefined })
})
})
@@ -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,17 @@ 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 type BrowserPartitionPermissionPolicy = 'browser' | 'deny'
export function installBrowserSessionPartitionPolicies(
profile: BrowserSessionProfile,
options?: {
downloads?: BrowserPartitionDownloadPolicy
permissions?: BrowserPartitionPermissionPolicy
}
): void {
const { partition } = profile
const sess = session.fromPartition(partition)
setBrowserSessionUserAgentMode(sess, profile.userAgentMode ?? 'clean')
@@ -51,62 +79,72 @@ export function installBrowserSessionPartitionPolicies(profile: BrowserSessionPr
sess.setUserAgent(cleanUA)
setupClientHintsOverride(sess, cleanUA)
}
sess.setPermissionRequestHandler((webContents, permission, callback, details) => {
// Why: defer media to macOS TCC; denying at the session layer throws NotAllowedError even after the user granted Camera/Mic to the OS.
if (permission === 'media') {
// Capture before async handling; opaque frames cannot be attributed to a named site.
const rawUrl = resolvePermissionNoticeUrl(webContents, details)
void requestSystemMediaAccess(
details as Electron.MediaAccessPermissionRequest | undefined
).then(
(granted) => {
if (!granted) {
if (options?.permissions === 'deny') {
sess.setPermissionRequestHandler((_webContents, _permission, callback) => callback(false))
sess.setPermissionCheckHandler(() => false)
clearBrowserWebAuthnAccessHandlers(sess)
} else {
sess.setPermissionRequestHandler((webContents, permission, callback, details) => {
// Why: defer media to macOS TCC; denying at the session layer throws NotAllowedError even after the user granted Camera/Mic to the OS.
if (permission === 'media') {
// Capture before async handling; opaque frames cannot be attributed to a named site.
const rawUrl = resolvePermissionNoticeUrl(webContents, details)
void requestSystemMediaAccess(
details as Electron.MediaAccessPermissionRequest | undefined
).then(
(granted) => {
if (!granted) {
browserManager.notifyPermissionDenied({
guestWebContentsId: webContents.id,
permission,
rawUrl
})
}
callback(granted)
},
(error: unknown) => {
console.error('[permissions] Browser media access failed:', error)
browserManager.notifyPermissionDenied({
guestWebContentsId: webContents.id,
permission,
rawUrl
})
callback(false)
}
callback(granted)
},
(error: unknown) => {
console.error('[permissions] Browser media access failed:', error)
browserManager.notifyPermissionDenied({
guestWebContentsId: webContents.id,
permission,
rawUrl
})
callback(false)
}
)
return
}
const allowed = isAutoGrantedBrowserSessionPermission(permission)
if (!allowed) {
const rawUrl = resolvePermissionNoticeUrl(webContents, details)
browserManager.notifyPermissionDenied({
guestWebContentsId: webContents.id,
permission,
rawUrl
})
}
callback(allowed)
})
sess.setPermissionCheckHandler((_webContents, permission, _origin, details) => {
if (permission === 'media') {
return hasSystemMediaAccess(details?.mediaType)
}
if (allowsBrowserWebAuthnPermission(permission, details)) {
return true
}
return isAutoGrantedBrowserSessionPermission(permission)
})
installBrowserWebAuthnAccessHandlers(sess)
)
return
}
const allowed = isAutoGrantedBrowserSessionPermission(permission)
if (!allowed) {
const rawUrl = resolvePermissionNoticeUrl(webContents, details)
browserManager.notifyPermissionDenied({
guestWebContentsId: webContents.id,
permission,
rawUrl
})
}
callback(allowed)
})
sess.setPermissionCheckHandler((_webContents, permission, _origin, details) => {
if (permission === 'media') {
return hasSystemMediaAccess(details?.mediaType)
}
if (allowsBrowserWebAuthnPermission(permission, details)) {
return true
}
return isAutoGrantedBrowserSessionPermission(permission)
})
installBrowserWebAuthnAccessHandlers(sess)
}
sess.setDisplayMediaRequestHandler((_request, callback) => {
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 +153,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,126 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
publishDocPreviewFailure: vi.fn(),
boundGrantIdByGuest: new Map<object, string>(),
revocationListener: null as null | ((grant: { id: string }) => void)
}))
vi.mock('./doc-preview-failure-notice', () => ({
publishDocPreviewFailure: mocks.publishDocPreviewFailure
}))
vi.mock('./doc-preview-guest-policy', () => ({
readDocPreviewGuestBoundGrantId: (guest: object) => mocks.boundGrantIdByGuest.get(guest) ?? null
}))
vi.mock('./doc-preview-grant-registry', () => ({
onDocPreviewGrantRevoked: (listener: (grant: { id: string }) => void) => {
mocks.revocationListener = listener
return vi.fn()
}
}))
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()
mocks.revocationListener = null
// 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'
})
})
it('forgets a preview as soon as its grant is revoked', async () => {
const notice = await loadNotifier()
const guest = guestBoundTo(GRANT_ID)
notice(guest)
mocks.revocationListener?.({ id: GRANT_ID })
notice(guest)
expect(mocks.publishDocPreviewFailure).toHaveBeenCalledTimes(2)
})
// 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,37 @@
import { publishDocPreviewFailure } from './doc-preview-failure-notice'
import { onDocPreviewGrantRevoked } from './doc-preview-grant-registry'
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>()
onDocPreviewGrantRevoked((grant) => noticedAtByGrantId.delete(grant.id))
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,377 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
callRuntimeEnvironment: vi.fn(),
readDocPreviewFile: 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 { docPreviewContentType, readDocPreviewFile } from './doc-preview-file-reader'
import {
authorizeDocPreviewDirectory,
mintDocPreviewGrant,
revokeAllDocPreviewGrants
} from './doc-preview-grant-registry'
// Why the fixtures approve the document directory up front: these tests exercise the transport
// half of a read — an entry-only grant's approval flow is pinned in its own test below.
function sshGrant(): ReturnType<typeof mintDocPreviewGrant> {
const grant = mintDocPreviewGrant({
owner: { kind: 'ssh', connectionId: 'ssh-1' },
root: '/home/alice/docs',
entryRelativePath: 'index.html',
browserPageId: 'page-1'
})
authorizeDocPreviewDirectory(grant.id, grant.entryRelativePath)
return grant
}
function runtimeGrant(root = '/srv/repo/docs'): ReturnType<typeof mintDocPreviewGrant> {
const grant = mintDocPreviewGrant({
owner: {
kind: 'runtime',
environmentId: 'env-1',
worktreeSelector: 'id:wt-1',
worktreeRoot: '/srv/repo'
},
root,
entryRelativePath: 'index.html',
browserPageId: 'page-1'
})
authorizeDocPreviewDirectory(grant.id, grant.entryRelativePath)
return grant
}
beforeEach(() => {
vi.clearAllMocks()
revokeAllDocPreviewGrants()
mocks.requireSshFilesystemProvider.mockReturnValue({
readDocPreviewFile: mocks.readDocPreviewFile
})
})
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.readDocPreviewFile.mockResolvedValue({ content: '<h1>hi</h1>', isBinary: false })
const outcome = await readDocPreviewFile(sshGrant(), 'index.html')
expect(mocks.requireSshFilesystemProvider).toHaveBeenCalledWith('ssh-1')
expect(mocks.readDocPreviewFile).toHaveBeenCalledWith({
boundaryPath: '/home/alice/docs',
entryPath: '/home/alice/docs/index.html',
implicitRootPath: null,
authorizedRootPaths: ['/home/alice/docs'],
targetPath: '/home/alice/docs/index.html',
maxTextBytes: 10 * 1024 * 1024,
maxBinaryBytes: 10 * 1024 * 1024
})
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.readDocPreviewFile.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.readDocPreviewFile.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.readDocPreviewFile.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.readDocPreviewFile.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' })
})
it('404s when the host cannot canonicalize the path at all', async () => {
mocks.readDocPreviewFile.mockRejectedValue(new Error('no such file'))
expect(await readDocPreviewFile(sshGrant(), 'index.html')).toMatchObject({
ok: false,
status: 404
})
expect(mocks.readDocPreviewFile).toHaveBeenCalledOnce()
})
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('requires approval for a sibling directory before touching the SSH provider', async () => {
const grant = mintDocPreviewGrant({
owner: { kind: 'ssh', connectionId: 'ssh-1' },
requestBase: '/home/alice',
root: '/home/alice/docs',
entryRelativePath: 'docs/index.html',
browserPageId: 'page-1'
})
await expect(readDocPreviewFile(grant, 'assets/logo.png')).resolves.toMatchObject({
ok: false,
status: 403,
reason: 'authorization-required'
})
expect(mocks.requireSshFilesystemProvider).not.toHaveBeenCalled()
authorizeDocPreviewDirectory(grant.id, 'assets/logo.png')
mocks.readDocPreviewFile.mockResolvedValue({ content: 'logo', isBinary: false })
await expect(readDocPreviewFile(grant, 'assets/logo.png')).resolves.toMatchObject({ ok: true })
expect(mocks.readDocPreviewFile).toHaveBeenCalledWith(
expect.objectContaining({
boundaryPath: '/home/alice',
implicitRootPath: '/home/alice/docs',
authorizedRootPaths: ['/home/alice/assets'],
targetPath: '/home/alice/assets/logo.png'
})
)
})
it('reports an over-cap SSH file as too large rather than unreadable', async () => {
mocks.readDocPreviewFile.mockRejectedValue(new Error('file_too_large'))
expect(await readDocPreviewFile(sshGrant(), 'huge.html')).toMatchObject({
ok: false,
status: 413
})
})
it('404s when the provider read fails', async () => {
mocks.readDocPreviewFile.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 the host-enforced preview method', async () => {
mocks.callRuntimeEnvironment.mockResolvedValue({
ok: true,
result: { content: '<h1>remote</h1>', isBinary: false }
})
const outcome = await readDocPreviewFile(runtimeGrant(), 'index.html')
expect(mocks.callRuntimeEnvironment).toHaveBeenCalledWith(
'/user-data',
'env-1',
'files.readDocPreview',
{
worktree: 'id:wt-1',
relativePath: 'docs/index.html',
entryRelativePath: 'docs/index.html',
implicitRootRelativePath: null,
authorizedRootRelativePaths: ['docs']
},
15_000
)
expect(outcome).toEqual({
ok: true,
bytes: Buffer.from('<h1>remote</h1>', 'utf8'),
contentType: 'text/html; charset=utf-8'
})
})
it('reads a base64 asset through the same host-enforced method', async () => {
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47])
mocks.callRuntimeEnvironment.mockResolvedValue({
ok: true,
result: {
content: png.toString('base64'),
isBinary: true,
mimeType: 'image/png'
}
})
const outcome = await readDocPreviewFile(runtimeGrant(), 'assets/logo.png')
expect(mocks.callRuntimeEnvironment).toHaveBeenCalledWith(
'/user-data',
'env-1',
'files.readDocPreview',
expect.objectContaining({ relativePath: 'docs/assets/logo.png' }),
15_000
)
expect(outcome).toEqual({ ok: true, bytes: png, contentType: 'image/png' })
})
it('fails closed when an old server does not implement the scoped method', async () => {
mocks.callRuntimeEnvironment.mockResolvedValue({
ok: false,
error: { code: 'method_not_found', message: 'Unknown method: files.readDocPreview' }
})
expect(await readDocPreviewFile(runtimeGrant(), 'assets/logo.png')).toMatchObject({
ok: false,
status: 404,
reason: 'unreadable',
// Why: fail-closed is deliberate, so the reader is told the host is old, not that it broke.
message:
'Secure document previews require a newer Orca on the paired machine. Update it and try again.'
})
expect(mocks.callRuntimeEnvironment).toHaveBeenCalledOnce()
})
it('serves an empty paired asset the host typed rather than reporting a refusal', async () => {
mocks.callRuntimeEnvironment.mockResolvedValue({
ok: true,
result: { content: '', isBinary: true, mimeType: 'image/png' }
})
expect(await readDocPreviewFile(runtimeGrant(), 'assets/logo.png')).toEqual({
ok: true,
bytes: Buffer.alloc(0),
contentType: 'image/png'
})
})
it('refuses an over-cap text read instead of serving partial bytes', async () => {
mocks.callRuntimeEnvironment.mockResolvedValue({
ok: false,
error: { code: 'runtime_error', message: 'file_too_large' }
})
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>', isBinary: false }
})
expect(await readDocPreviewFile(runtimeGrant(), 'index.html')).toMatchObject({ ok: true })
})
it('reports the host rejecting an over-cap binary as too large', async () => {
mocks.callRuntimeEnvironment.mockResolvedValue({
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()
})
it('requires approval for a sibling directory before touching the runtime', async () => {
const grant = mintDocPreviewGrant({
owner: {
kind: 'runtime',
environmentId: 'env-1',
worktreeSelector: 'id:wt-1',
worktreeRoot: '/srv/repo'
},
requestBase: '/srv/repo',
root: '/srv/repo/docs',
entryRelativePath: 'docs/index.html',
browserPageId: 'page-1'
})
await expect(readDocPreviewFile(grant, 'assets/app.js')).resolves.toMatchObject({
ok: false,
status: 403,
reason: 'authorization-required'
})
expect(mocks.callRuntimeEnvironment).not.toHaveBeenCalled()
authorizeDocPreviewDirectory(grant.id, 'assets/app.js')
mocks.callRuntimeEnvironment.mockResolvedValue({
ok: true,
result: { content: 'console.log(1)', isBinary: false }
})
await expect(readDocPreviewFile(grant, 'assets/app.js')).resolves.toMatchObject({ ok: true })
expect(mocks.callRuntimeEnvironment).toHaveBeenCalledWith(
'/user-data',
'env-1',
'files.readDocPreview',
{
worktree: 'id:wt-1',
relativePath: 'assets/app.js',
entryRelativePath: 'docs/index.html',
implicitRootRelativePath: 'docs',
authorizedRootRelativePaths: ['assets']
},
15_000
)
})
})
+230
View File
@@ -0,0 +1,230 @@
import { extname } from 'node:path'
import type { RuntimeFilePreviewResult } 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 {
resolveDocPreviewAuthorityPaths,
resolveDocPreviewCandidatePath,
resolveDocPreviewTargetPath,
toRuntimeWorktreeRelativeDirectoryPath,
toRuntimeWorktreeRelativePath,
type DocPreviewGrant
} from './doc-preview-grant-registry'
const DOC_PREVIEW_READ_TIMEOUT_MS = 15_000
const DIRECT_SSH_DOC_PREVIEW_TEXT_MAX_BYTES = 10 * 1024 * 1024
const DIRECT_SSH_DOC_PREVIEW_BINARY_MAX_BYTES = 10 * 1024 * 1024
/** 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'
/** Same stance as the SSH relay message: previews fail closed on a host without scoped reads. */
const RUNTIME_DOC_PREVIEW_UPDATE_REQUIRED_MESSAGE =
'Secure document previews require a newer Orca on the paired machine. Update it and try again.'
/** 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,
entryRelativePath: string,
implicitRootRelativePath: string | null,
authorizedRootRelativePaths: string[]
): Promise<PreviewFileBytes> {
const userDataPath = getCanonicalUserDataPath()
const response = await callRuntimeEnvironment(
userDataPath,
environmentId,
'files.readDocPreview',
{
worktree: worktreeSelector,
relativePath,
entryRelativePath,
implicitRootRelativePath,
authorizedRootRelativePaths
},
DOC_PREVIEW_READ_TIMEOUT_MS
)
if (!response.ok) {
// Why the rewrite: fail-closed on an old host is deliberate, so tell the reader what to do —
// the raw method_not_found wording reads as a broken preview, not an out-of-date machine.
throw new Error(
response.error.code === 'method_not_found'
? RUNTIME_DOC_PREVIEW_UPDATE_REQUIRED_MESSAGE
: response.error.message
)
}
const preview = response.result as RuntimeFilePreviewResult
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 candidatePath = resolveDocPreviewCandidatePath(grant, relativePath)
if (!candidatePath) {
return notFoundOutcome()
}
const absolutePath = resolveDocPreviewTargetPath(grant, relativePath)
if (!absolutePath) {
return {
ok: false,
status: 403,
reason: 'authorization-required',
message: 'This file needs permission before the preview can read it.'
}
}
const contentType = docPreviewContentType(relativePath)
try {
if (grant.owner.kind === 'ssh') {
const provider = requireSshFilesystemProvider(grant.owner.connectionId)
const authority = resolveDocPreviewAuthorityPaths(grant)
if (!authority.entryPath || !provider.readDocPreviewFile) {
return notFoundOutcome()
}
return toOutcome(
await provider.readDocPreviewFile({
boundaryPath: grant.requestBase,
entryPath: authority.entryPath,
implicitRootPath: authority.implicitRootPath,
authorizedRootPaths: authority.authorizedRootPaths,
targetPath: absolutePath,
maxTextBytes: DIRECT_SSH_DOC_PREVIEW_TEXT_MAX_BYTES,
maxBinaryBytes: DIRECT_SSH_DOC_PREVIEW_BINARY_MAX_BYTES
}),
contentType
)
}
const runtimeOwner = grant.owner
const worktreeRelativePath = toRuntimeWorktreeRelativePath(
runtimeOwner.worktreeRoot,
absolutePath
)
const authority = resolveDocPreviewAuthorityPaths(grant)
const entryRelativePath = authority.entryPath
? toRuntimeWorktreeRelativePath(runtimeOwner.worktreeRoot, authority.entryPath)
: null
const implicitRootRelativePath = authority.implicitRootPath
? toRuntimeWorktreeRelativeDirectoryPath(
runtimeOwner.worktreeRoot,
authority.implicitRootPath
)
: null
const authorizedRootRelativePaths = authority.authorizedRootPaths
.map((root) => toRuntimeWorktreeRelativeDirectoryPath(runtimeOwner.worktreeRoot, root))
.filter((root): root is string => root !== null)
if (!worktreeRelativePath || !entryRelativePath) {
// Why: files.read is worktree-scoped, so a doc outside the worktree has no client-side channel.
return notFoundOutcome()
}
return toOutcome(
await readRuntimeDocPreviewFile(
runtimeOwner.environmentId,
runtimeOwner.worktreeSelector,
worktreeRelativePath,
entryRelativePath,
implicitRootRelativePath,
authorizedRootRelativePaths
),
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,289 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
authorizeDocPreviewDirectory,
getDocPreviewGrant,
mintDocPreviewGrant,
resolveCanonicalDocPreviewPath,
resolveDocPreviewTargetPath,
revokeAllDocPreviewGrants,
revokeDocPreviewGrant,
toRuntimeWorktreeRelativeDirectoryPath,
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', () => {
// A grant whose root IS its request base — a document at the workspace root, or outside any
// workspace — starts with the entry file alone: that directory is where secrets live, and a
// DNS-prefetch beacon needs no click, so nothing beside the entry reads without the reader.
it('reads only the entry document until the reader approves its directory', () => {
const grant = mintPosixGrant()
expect(resolveDocPreviewTargetPath(grant, 'index.html')).toBe('/srv/repo/docs/index.html')
expect(resolveDocPreviewTargetPath(grant, 'assets/logo.png')).toBeNull()
expect(resolveDocPreviewTargetPath(grant, 'secrets.env')).toBeNull()
expect(authorizeDocPreviewDirectory(grant.id, 'assets/logo.png')).toBe(true)
expect(resolveDocPreviewTargetPath(grant, 'assets/logo.png')).toBe(
'/srv/repo/docs/assets/logo.png'
)
expect(resolveDocPreviewTargetPath(grant, 'secrets.env')).toBeNull()
})
it('keeps silent authority over a document directory strictly inside the workspace, and only there', () => {
const nested = mintDocPreviewGrant({
owner: sshOwner,
requestBase: '/srv/repo',
root: '/srv/repo/docs',
entryRelativePath: 'docs/report.html',
browserPageId: 'page-1'
})
expect(resolveDocPreviewTargetPath(nested, 'docs/styles.css')).toBe('/srv/repo/docs/styles.css')
const atWorkspaceRoot = mintDocPreviewGrant({
owner: sshOwner,
requestBase: '/srv/repo',
root: '/srv/repo',
entryRelativePath: 'report.html',
browserPageId: 'page-2'
})
expect(resolveDocPreviewTargetPath(atWorkspaceRoot, 'report.html')).toBe(
'/srv/repo/report.html'
)
expect(resolveDocPreviewTargetPath(atWorkspaceRoot, '.env')).toBeNull()
expect(resolveDocPreviewTargetPath(atWorkspaceRoot, 'docs/styles.css')).toBeNull()
})
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(authorizeDocPreviewDirectory(windowsGrant.id, 'assets/logo.png')).toBe(true)
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()
})
it('authorizes only the requested sibling directory', () => {
const grant = mintDocPreviewGrant({
owner: sshOwner,
requestBase: '/srv/repo',
root: '/srv/repo/docs',
entryRelativePath: 'docs/report.html',
browserPageId: 'page-1'
})
expect(resolveDocPreviewTargetPath(grant, 'docs/report.html')).toBe(
'/srv/repo/docs/report.html'
)
expect(resolveDocPreviewTargetPath(grant, 'assets/chart.js')).toBeNull()
expect(resolveDocPreviewTargetPath(grant, '.env')).toBeNull()
expect(authorizeDocPreviewDirectory(grant.id, 'assets/chart.js')).toBe(true)
expect(resolveDocPreviewTargetPath(grant, 'assets/chart.js')).toBe('/srv/repo/assets/chart.js')
expect(resolveDocPreviewTargetPath(grant, 'assets/theme.css')).toBe(
'/srv/repo/assets/theme.css'
)
expect(resolveDocPreviewTargetPath(grant, '.env')).toBeNull()
})
it('refuses malformed authorization requests and unknown grants', () => {
const grant = mintDocPreviewGrant({
owner: sshOwner,
requestBase: '/srv/repo',
root: '/srv/repo/docs',
entryRelativePath: 'docs/report.html',
browserPageId: 'page-1'
})
expect(authorizeDocPreviewDirectory(grant.id, '../outside/secret.txt')).toBe(false)
expect(authorizeDocPreviewDirectory(grant.id, 'assets\\secret.txt')).toBe(false)
expect(authorizeDocPreviewDirectory('0'.repeat(32), 'assets/chart.js')).toBe(false)
expect(grant.authorizedRoots).toEqual([])
})
it('uses Windows semantics when authorizing a sibling directory', () => {
const grant = mintDocPreviewGrant({
owner: sshOwner,
requestBase: 'C:\\srv\\repo',
root: 'C:\\srv\\repo\\docs',
entryRelativePath: 'docs/report.html',
browserPageId: 'page-1'
})
expect(authorizeDocPreviewDirectory(grant.id, 'assets/chart.js')).toBe(true)
expect(resolveDocPreviewTargetPath(grant, 'assets/chart.js')).toBe(
'C:\\srv\\repo\\assets\\chart.js'
)
expect(authorizeDocPreviewDirectory(grant.id, '../outside/secret.txt')).toBe(false)
})
})
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/index.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/index.html', async (path) => path)
).resolves.toBe('/srv/repo/docs/index.html')
})
it('refuses a sibling of an entry-only document even when it canonicalizes cleanly', async () => {
const grant = mintPosixGrant()
await expect(
resolveCanonicalDocPreviewPath(grant, '/srv/repo/docs/notes.txt', async (path) => path)
).resolves.toBeNull()
})
it('keeps an approved SSH directory inside the canonical workspace boundary', async () => {
const grant = mintDocPreviewGrant({
owner: sshOwner,
requestBase: '/srv/repo',
root: '/srv/repo/docs',
entryRelativePath: 'docs/report.html',
browserPageId: 'page-1'
})
authorizeDocPreviewDirectory(grant.id, 'assets/chart.js')
await expect(
resolveCanonicalDocPreviewPath(grant, '/srv/repo/assets/chart.js', async (path) => {
if (path === '/srv/repo/assets') {
return '/srv/repo/assets'
}
if (path === '/srv/repo/assets/chart.js') {
return '/etc/shadow'
}
return path
})
).resolves.toBeNull()
})
})
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()
})
it('represents an explicitly authorized worktree root as the empty relative directory', () => {
expect(toRuntimeWorktreeRelativeDirectoryPath('/srv/repo', '/srv/repo')).toBe('')
expect(toRuntimeWorktreeRelativeDirectoryPath('C:\\srv\\repo', 'C:\\srv\\repo')).toBe('')
})
})
@@ -0,0 +1,327 @@
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
/** Directory relative preview URLs resolve against. */
requestBase: string
/**
* Containing directory of the opened document, on the owning host. It carries silent read
* authority only while it sits strictly inside `requestBase`: a document at the workspace root —
* or outside any workspace, where its directory IS the request base — starts with the entry file
* alone, because that directory is where secrets live and a DNS-prefetch beacon needs no click.
*/
root: string
/** Additional directories the reader approved for this grant. */
authorizedRoots: string[]
/** Path of the opened document relative to `requestBase`. */
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
requestBase?: string
root: string
entryRelativePath: string
browserPageId: string
}): DocPreviewGrant {
const requestBase = normalizeRootPath(params.requestBase ?? params.root)
const root = normalizeRootPath(params.root)
const flavor = pathFlavorFor(requestBase)
if (!isAtOrInsideRoot(requestBase, root, flavor)) {
throw new Error('Document preview root is outside its request base')
}
const grant: DocPreviewGrant = {
id: randomBytes(16).toString('hex'),
owner: params.owner,
requestBase,
root,
authorizedRoots: [],
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 safe request inside the workspace boundary, before grant authorization. */
export function resolveDocPreviewCandidatePath(
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.requestBase)
const resolved = flavor.normalize(flavor.join(grant.requestBase, ...segments))
return isInsideRoot(grant.requestBase, 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)
}
function isAtOrInsideRoot(
root: string,
candidate: string,
flavor: typeof posix | typeof win32
): boolean {
return candidate === root || isInsideRoot(root, candidate, flavor)
}
function directoryAuthorityRoots(grant: DocPreviewGrant): string[] {
return grant.root === grant.requestBase
? [...grant.authorizedRoots]
: [grant.root, ...grant.authorizedRoots]
}
export function resolveDocPreviewAuthorityPaths(grant: DocPreviewGrant): {
entryPath: string | null
implicitRootPath: string | null
authorizedRootPaths: string[]
} {
return {
entryPath: resolveEntryAbsolutePath(grant),
implicitRootPath: grant.root === grant.requestBase ? null : grant.root,
authorizedRootPaths: [...grant.authorizedRoots]
}
}
/** The one path an entry-only grant can read before the reader approves a directory. */
function resolveEntryAbsolutePath(grant: DocPreviewGrant): string | null {
return resolveDocPreviewCandidatePath(grant, grant.entryRelativePath)
}
/** Resolves a request only when it is the entry document or its directory is authorized. */
export function resolveDocPreviewTargetPath(
grant: DocPreviewGrant,
relativePath: string
): string | null {
const resolved = resolveDocPreviewCandidatePath(grant, relativePath)
if (!resolved) {
return null
}
if (resolved === resolveEntryAbsolutePath(grant)) {
return resolved
}
const flavor = pathFlavorFor(grant.requestBase)
return directoryAuthorityRoots(grant).some((root) => isInsideRoot(root, resolved, flavor))
? resolved
: null
}
/** Expands a live grant to the directory containing one reader-approved request. */
export function authorizeDocPreviewDirectory(grantId: string, relativePath: string): boolean {
const grant = grantsById.get(grantId)
if (!grant) {
return false
}
const candidate = resolveDocPreviewCandidatePath(grant, relativePath)
if (!candidate) {
return false
}
const flavor = pathFlavorFor(grant.requestBase)
const directory = normalizeRootPath(flavor.dirname(candidate))
if (!isAtOrInsideRoot(grant.requestBase, directory, flavor)) {
return false
}
if (!directoryAuthorityRoots(grant).some((root) => isAtOrInsideRoot(root, directory, flavor))) {
grant.authorizedRoots.push(directory)
canonicalRootByGrantId.delete(grant.id)
}
return true
}
/** Why: realpath is a host round-trip, and a grant's root is fixed for its lifetime. */
const canonicalRootByGrantId = new Map<
string,
Promise<{ boundary: string; roots: string[]; entry: string | null }>
>()
/**
* 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 canonicalRoots = canonicalRootByGrantId.get(grant.id)
if (!canonicalRoots) {
const entryAbsolute = resolveEntryAbsolutePath(grant)
canonicalRoots = Promise.all([
realpath(grant.requestBase),
entryAbsolute === null ? Promise.resolve(null) : realpath(entryAbsolute),
...directoryAuthorityRoots(grant).map((root) => realpath(root))
]).then(([boundaryPath, entryPath, ...rootPaths]) => {
const boundary = normalizeRootPath(boundaryPath)
const flavor = pathFlavorFor(boundary)
return {
boundary,
roots: rootPaths
.map(normalizeRootPath)
.filter((root) => isAtOrInsideRoot(boundary, root, flavor)),
entry: entryPath !== null && isInsideRoot(boundary, entryPath, flavor) ? entryPath : null
}
})
canonicalRootByGrantId.set(grant.id, canonicalRoots)
}
const [{ boundary, roots, entry }, canonicalPath] = await Promise.all([
canonicalRoots,
realpath(absolutePath)
])
const flavor = pathFlavorFor(boundary)
return isInsideRoot(boundary, canonicalPath, flavor) &&
(canonicalPath === entry || roots.some((root) => 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, '/')
}
export function toRuntimeWorktreeRelativeDirectoryPath(
worktreeRoot: string,
absolutePath: string
): string | null {
const normalizedRoot = normalizeRootPath(worktreeRoot)
return normalizeRootPath(absolutePath) === normalizedRoot
? ''
: toRuntimeWorktreeRelativePath(worktreeRoot, absolutePath)
}
@@ -0,0 +1,552 @@
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("does not mistake Electron's false webview focus flag for an untrusted click", () => {
const { guest } = boundGuest()
guest.isFocused.mockReturnValue(false)
reportClick(guest, 'https://example.com/docs')
expect(guest.send).toHaveBeenCalledExactlyOnceWith('docPreview:externalLink', {
url: 'https://example.com/docs'
})
})
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,240 @@
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
}
/**
* 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
})
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 isolated preload must
* report a trusted anchor click, the sender must be a live preview guest still bound to a grant,
* and the target must be the web. Anything else is dropped without a trace the document can 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
}
const externalUrl = normalizeExternalBrowserUrl(rawUrl)
if (!externalUrl || !isWebUrl(externalUrl)) {
return
}
registration.host.send(DOC_PREVIEW_EXTERNAL_LINK_CHANNEL, { url: externalUrl })
}
@@ -0,0 +1,334 @@
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 still use the shared installer for certificate, UA, permission and
// download hooks, with a stricter decision than ordinary browsing partitions.
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()
)
})
it('denies downloads and ambient browser permissions on the preview partition', () => {
installDocPreviewProtocolHandler()
expect(mocks.installBrowserSessionPartitionPolicies).toHaveBeenCalledWith(expect.anything(), {
downloads: 'deny',
permissions: '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)
})
})
+146
View File
@@ -0,0 +1,146 @@
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. Electron 43 still
* resolves explicit DNS-prefetch hints outside session hooks; the accepted residual is covered by
* `browser-route-dns-prefetch.electron.test.ts` and can beacon grant-readable bytes in DNS labels.
*/
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 network requests even if an element bypasses CSP. DNS-prefetch does not reach this hook.
previewSession.webRequest.onBeforeRequest((details, callback) => {
callback({ cancel: !isAllowedDocPreviewRequestUrl(details.url) })
})
// Why: the shared installer still owns certificate, UA, download and permission hooks, while
// preview content receives no ambient browser/device permissions.
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', permissions: 'deny' }
)
}
@@ -2,7 +2,7 @@ import { EventEmitter } from 'node:events'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
windows: [] as { webContents: MockWebContents }[],
windows: [] as MockBrowserWindow[],
BrowserWindow: vi.fn(),
finishLoads: true
}))
@@ -50,6 +50,41 @@ vi.mock('./browser-session-registry', () => ({
}))
import { OffscreenBrowserBackend } from './offscreen-browser-backend'
import { installDocPreviewGuestPolicy, isWorkspaceDocPageId } from './doc-preview-guest-policy'
import { mintDocPreviewGrant } from './doc-preview-grant-registry'
import { buildDocPreviewUrl } from '../../shared/doc-preview-scheme'
/** The real door's answer, so the backend is tested against the refusal it will actually get. */
function registerOffscreenGuestLikeBrowserManager({
browserPageId
}: {
browserPageId: string
}): boolean {
return !isWorkspaceDocPageId(browserPageId)
}
/**
* A page the document half of the registry really owns. Built rather than named, because the door
* refuses on registry membership now — a made-up id would be admitted and prove nothing.
*/
function registerWorkspaceDocPage(browserPageId: string): void {
const grant = mintDocPreviewGrant({
owner: { kind: 'ssh', connectionId: 'ssh-1' },
root: '/home/alice/docs',
entryRelativePath: 'index.html',
browserPageId
})
const guest = {
isFocused: () => false,
isDestroyed: () => false,
getURL: () => buildDocPreviewUrl(grant.id, grant.entryRelativePath),
on: vi.fn(),
once: vi.fn(),
setWindowOpenHandler: vi.fn(),
setWebRTCIPHandlingPolicy: vi.fn()
}
installDocPreviewGuestPolicy(guest as never, { id: 1, send: vi.fn() })
}
describe('OffscreenBrowserBackend lifecycle', () => {
beforeEach(() => {
@@ -73,7 +108,7 @@ describe('OffscreenBrowserBackend lifecycle', () => {
vi.useFakeTimers()
mocks.finishLoads = false
const browserManager = {
registerOffscreenGuest: vi.fn(),
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
unregisterGuest: vi.fn()
}
const backend = new OffscreenBrowserBackend(browserManager as never)
@@ -94,9 +129,36 @@ describe('OffscreenBrowserBackend lifecycle', () => {
vi.useRealTimers()
})
// Why the unregister assertion and not just the destroy: a refused id is one the document half
// of the registry owns, and the teardown hook would cancel that preview's work on the way out.
it('destroys a window whose registration was refused without unregistering the id', async () => {
const browserManager = {
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
unregisterGuest: vi.fn()
}
const backend = new OffscreenBrowserBackend(browserManager as never)
registerWorkspaceDocPage('doc-page-1')
await expect(
backend.createTab({
browserPageId: 'doc-page-1',
url: 'https://example.com',
worktreeId: 'wt'
})
).rejects.toThrow('was refused')
expect(mocks.windows[0].isDestroyed()).toBe(true)
expect(browserManager.unregisterGuest).not.toHaveBeenCalled()
// Why shutdown and not the map: a refused id left behind is invisible until teardown walks it
// and hands that id to the other authority after all.
await backend.destroyAll()
expect(browserManager.unregisterGuest).not.toHaveBeenCalled()
})
it('unregisters a closing page before awaiting owner retirement', async () => {
const browserManager = {
registerOffscreenGuest: vi.fn(),
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
unregisterGuest: vi.fn()
}
let releaseOwnerRetirement!: () => void
@@ -126,7 +188,7 @@ describe('OffscreenBrowserBackend lifecycle', () => {
it('preserves a replacement page when the old window finishes closing', async () => {
const browserManager = {
registerOffscreenGuest: vi.fn(),
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
unregisterGuest: vi.fn()
}
let releaseOwnerRetirement!: () => void
@@ -152,7 +214,7 @@ describe('OffscreenBrowserBackend lifecycle', () => {
it('retires the helper when an offscreen renderer is destroyed unexpectedly', async () => {
const browserManager = {
registerOffscreenGuest: vi.fn(),
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
unregisterGuest: vi.fn()
}
const onPageClosed = vi.fn(async () => {})
@@ -167,7 +229,7 @@ describe('OffscreenBrowserBackend lifecycle', () => {
it('cleans every helper owner during backend shutdown', async () => {
const browserManager = {
registerOffscreenGuest: vi.fn(),
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
unregisterGuest: vi.fn()
}
const onPageClosed = vi.fn(async () => {})
@@ -186,7 +248,7 @@ describe('OffscreenBrowserBackend lifecycle', () => {
it('rejects a concurrent create while shutdown is draining owned pages', async () => {
const browserManager = {
registerOffscreenGuest: vi.fn(),
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
unregisterGuest: vi.fn()
}
let releaseOwnerRetirement!: () => void
@@ -215,7 +277,7 @@ describe('OffscreenBrowserBackend lifecycle', () => {
it('joins owner retirement started by an unexpected renderer destroy', async () => {
const browserManager = {
registerOffscreenGuest: vi.fn(),
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
unregisterGuest: vi.fn()
}
let releaseOwnerRetirement!: () => void
@@ -244,7 +306,7 @@ describe('OffscreenBrowserBackend lifecycle', () => {
it('bounds concurrent helper retirements during shutdown', async () => {
const browserManager = {
registerOffscreenGuest: vi.fn(),
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
unregisterGuest: vi.fn()
}
let activeRetirements = 0
@@ -284,7 +346,10 @@ describe('OffscreenBrowserBackend lifecycle', () => {
})
it('closes the page even when daemon retirement throws', async () => {
const browserManager = { registerOffscreenGuest: vi.fn(), unregisterGuest: vi.fn() }
const browserManager = {
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
unregisterGuest: vi.fn()
}
const backend = new OffscreenBrowserBackend(browserManager as never, {
getAgentBrowserBridge: () => ({
onPageClosed: vi.fn(async () => {
+27 -17
View File
@@ -66,9 +66,33 @@ export class OffscreenBrowserBackend implements BrowserBackend {
this.windowsByPageId.set(browserPageId, win)
// Why: if the offscreen window is destroyed out from under us (crash, app
// teardown), drop the registry entry so commands fail cleanly instead of
// resolving a dead WebContents.
// Why: register the guest and return immediately so the new tab appears
// without waiting for the page to finish loading. Previously createTab
// awaited the full navigation, so clicking "New Browser Tab" did nothing for
// up to a second on real URLs. The page loads asynchronously and streams
// once it paints; a failed load leaves the (usable) tab open, matching how a
// normal browser tab survives a failed navigation.
const registered = this.browserManager.registerOffscreenGuest({
browserPageId,
worktreeId: params.worktreeId,
sessionProfileId: profile?.id ?? null,
userAgentMode: profile?.userAgentMode,
webContentsId: win.webContents.id
})
if (!registered) {
// Why destroy rather than carry on: the window already exists but carries none of the guest
// policies registration installs, so leaving it would navigate an unvalidated URL with no
// policy on it and hand back a page id nothing can drive. The renderer door aborts its mount
// the same way; this is that abort.
this.windowsByPageId.delete(browserPageId)
win.destroy()
throw new Error(`Browser page ${browserPageId} was refused`)
}
// Why only once registration took: this teardown unregisters the page id, and a refused id is
// one the other authority may own — cancelling its work would be the confusion we just refused.
// Why at all: if the window dies out from under us (crash, app teardown), drop the registry
// entry so commands fail cleanly instead of resolving a dead WebContents.
win.webContents.once('destroyed', () => {
// Explicit close removes the page first and performs awaited cleanup;
// only an unexpected destruction still owns the bridge retirement here.
@@ -80,20 +104,6 @@ export class OffscreenBrowserBackend implements BrowserBackend {
this.browserManager.unregisterGuest(browserPageId)
})
// Why: register the guest and return immediately so the new tab appears
// without waiting for the page to finish loading. Previously createTab
// awaited the full navigation, so clicking "New Browser Tab" did nothing for
// up to a second on real URLs. The page loads asynchronously and streams
// once it paints; a failed load leaves the (usable) tab open, matching how a
// normal browser tab survives a failed navigation.
this.browserManager.registerOffscreenGuest({
browserPageId,
worktreeId: params.worktreeId,
sessionProfileId: profile?.id ?? null,
userAgentMode: profile?.userAgentMode,
webContentsId: win.webContents.id
})
const url = params.url || 'about:blank'
void this.loadUrl(win, url).catch((error) => {
console.warn(
@@ -0,0 +1,281 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { GitHubPRRefreshCandidate } from '../../shared/github/pull-request-refresh-types'
const { coordinatorMocks, moduleMocks } = await vi.hoisted(async () => {
const moduleMocks = await import('./pr-refresh-coordinator-test-mocks')
return { coordinatorMocks: moduleMocks.createPRRefreshCoordinatorMocks(), moduleMocks }
})
vi.mock('electron', () => moduleMocks.electronModuleMock(coordinatorMocks))
vi.mock('./client', () => moduleMocks.clientModuleMock(coordinatorMocks))
vi.mock('./github-api-repository', () =>
moduleMocks.githubApiRepositoryModuleMock(coordinatorMocks)
)
vi.mock('./rate-limit', () => moduleMocks.rateLimitModuleMock(coordinatorMocks))
vi.mock('../ipc/ui', () => moduleMocks.ipcUiModuleMock(coordinatorMocks))
import { makeCandidate, makePR } from './pr-refresh-coordinator-test-harness'
const { getPRForBranchOutcomeMock } = coordinatorMocks
const WORKTREES = 20
const LINKED_PR_KEY = 'local::runtime:host::/repo::pr::42'
function visibleCandidates(): GitHubPRRefreshCandidate[] {
return Array.from({ length: WORKTREES }, (_, i) =>
makeCandidate({
cacheKey: `/repo::feature/${i}`,
branch: `feature/${i}`,
worktreeId: `wt-${i}`,
cachedFetchedAt: null
})
)
}
describe('pr-refresh queue growth bounds', () => {
beforeEach(() => {
moduleMocks.resetPRRefreshCoordinatorMocks(coordinatorMocks)
getPRForBranchOutcomeMock.mockImplementation(async () => ({
kind: 'found' as const,
pr: makePR({ checksStatus: 'pending' as const }),
fetchedAt: Date.now()
}))
})
afterEach(() => {
vi.useRealTimers()
})
it('keeps the queue bounded across repeated visible reports and background pauses', async () => {
const { reportVisiblePRRefreshCandidates, _getPRRefreshQueueSizeForTests } =
await import('./pr-refresh-coordinator')
for (let cycle = 0; cycle < 200; cycle += 1) {
reportVisiblePRRefreshCandidates(visibleCandidates(), cycle + 1, 1)
await vi.advanceTimersByTimeAsync(30_000)
}
// 200 report cycles * 20 candidates = 4000 enqueues; the queue coalesces to one entry per key.
expect(_getPRRefreshQueueSizeForTests()).toBeLessThanOrEqual(WORKTREES)
})
it('keeps visibility windows and error backoff bounded when renderer windows churn', async () => {
const {
reportVisiblePRRefreshCandidates,
_getVisiblePRRefreshWindowCountForTests,
_getPRRefreshErrorBackoffCountForTests
} = await import('./pr-refresh-coordinator')
getPRForBranchOutcomeMock.mockImplementation(async () => ({
kind: 'upstream-error' as const,
errorType: 'unknown' as const,
message: 'boom',
fetchedAt: Date.now()
}))
for (let windowId = 1; windowId <= 300; windowId += 1) {
coordinatorMocks.getAllWebContentsMock.mockReturnValue([
{ id: windowId, isDestroyed: () => false, send: coordinatorMocks.sendMock }
])
reportVisiblePRRefreshCandidates(visibleCandidates(), 1, windowId)
await vi.advanceTimersByTimeAsync(60_000)
}
expect(_getVisiblePRRefreshWindowCountForTests()).toBeLessThanOrEqual(1)
expect(_getPRRefreshErrorBackoffCountForTests()).toBeLessThanOrEqual(WORKTREES)
})
it("drops a worktree's stale branch aliases instead of fanning out to its whole history", async () => {
const {
reportVisiblePRRefreshCandidates,
_getPRRefreshAliasCountForTests,
_getPRRefreshQueueSizeForTests
} = await import('./pr-refresh-coordinator')
// A linked-PR refreshKey ignores the branch, so one entry survives every
// branch switch, and each switch used to add an alias that only the next
// drain could shed — so the fan-out tracked branch churn instead of the one
// live branch. (Draining between switches caps the unfixed count in the low
// single digits here; the parked-entry case below is where it really piles up.)
for (let i = 0; i < 300; i += 1) {
reportVisiblePRRefreshCandidates(
[
makeCandidate({
cacheKey: `/repo::churn/${i}`,
branch: `churn/${i}`,
worktreeId: 'wt-churn',
linkedPRNumber: 42,
cachedFetchedAt: null
})
],
i + 1,
1
)
await vi.advanceTimersByTimeAsync(20_000)
}
expect(_getPRRefreshQueueSizeForTests()).toBe(1)
expect(_getPRRefreshAliasCountForTests(LINKED_PR_KEY)).toBe(1)
const broadcastAliasCounts = coordinatorMocks.sendMock.mock.calls
.filter((call) => call[0] === 'gh:prRefreshEvent')
.map((call) => (call[1] as { aliases: unknown[] }).aliases.length)
expect(Math.max(...broadcastAliasCounts)).toBe(1)
})
it('keeps the newer alias when a branch switch lands mid-request', async () => {
const { reportVisiblePRRefreshCandidates, _getPRRefreshAliasCountForTests } =
await import('./pr-refresh-coordinator')
const churnCandidate = (index: number): GitHubPRRefreshCandidate =>
makeCandidate({
cacheKey: `/repo::churn/${index}`,
branch: `churn/${index}`,
worktreeId: 'wt-churn',
linkedPRNumber: 42,
cachedFetchedAt: null
})
let switched = false
getPRForBranchOutcomeMock.mockImplementation(async () => {
if (!switched) {
switched = true
// The entry is already out of the queue here, so this re-enqueue creates the
// entry the in-flight request's stale follow-up aliases merge back into.
reportVisiblePRRefreshCandidates([churnCandidate(2)], 2, 1)
}
return {
kind: 'found' as const,
pr: makePR({ checksStatus: 'pending' as const }),
fetchedAt: Date.now()
}
})
reportVisiblePRRefreshCandidates([churnCandidate(1)], 1, 1)
await vi.advanceTimersByTimeAsync(120_000)
expect(_getPRRefreshAliasCountForTests(LINKED_PR_KEY)).toBe(1)
const broadcasts = coordinatorMocks.sendMock.mock.calls.filter(
(call) => call[0] === 'gh:prRefreshEvent'
) as [string, { aliases: { cacheKey: string }[] }][]
const lastBroadcast = broadcasts.at(-1)
expect(lastBroadcast?.[1].aliases.map((alias) => alias.cacheKey)).toEqual(['/repo::churn/2'])
})
it('does not restore an older candidate over a fresher branch switch', async () => {
const { reportVisiblePRRefreshCandidates } = await import('./pr-refresh-coordinator')
const first = makeCandidate({
cacheKey: '/repo::churn/1',
branch: 'churn/1',
worktreeId: 'wt-churn',
linkedPRNumber: 42,
cachedFetchedAt: null
})
const switched = makeCandidate({
cacheKey: '/repo::churn/2',
branch: 'churn/2',
worktreeId: 'wt-churn',
linkedPRNumber: 42,
cachedFetchedAt: Date.now(),
cachedHasPR: true,
cachedPRState: 'open',
cachedChecksStatus: 'success'
})
let didSwitch = false
getPRForBranchOutcomeMock.mockImplementation(async () => {
if (!didSwitch) {
didSwitch = true
reportVisiblePRRefreshCandidates([switched], 2, 1)
}
return {
kind: 'found' as const,
pr: makePR({ checksStatus: 'pending' as const }),
fetchedAt: Date.now()
}
})
reportVisiblePRRefreshCandidates([first], 1, 1)
await vi.advanceTimersByTimeAsync(100_000)
expect(getPRForBranchOutcomeMock.mock.calls.map((call) => call[1])).toEqual(['churn/1'])
await vi.advanceTimersByTimeAsync(500_001)
expect(getPRForBranchOutcomeMock.mock.calls.map((call) => call[1])).toEqual([
'churn/1',
'churn/2'
])
})
it('keeps distinct worktrees sharing one linked-PR key in the fan-out', async () => {
const { enqueuePRRefresh, _getPRRefreshAliasCountForTests } =
await import('./pr-refresh-coordinator')
for (let i = 0; i < 4; i += 1) {
enqueuePRRefresh(
makeCandidate({
cacheKey: `/repo::shared/${i}`,
branch: `shared/${i}`,
worktreeId: `wt-${i}`,
linkedPRNumber: 42,
cachedFetchedAt: Date.now() + 60_000
}),
'visible',
40,
1
)
}
expect(_getPRRefreshAliasCountForTests(LINKED_PR_KEY)).toBe(4)
})
it('coalesces branch churn into one alias while the entry waits to drain', async () => {
const { enqueuePRRefresh, _getPRRefreshAliasCountForTests, _getPRRefreshQueueSizeForTests } =
await import('./pr-refresh-coordinator')
// No timer advance: the entry stays queued, so every enqueue lands on the same
// alias map and the coalescing bound is what keeps it from growing.
for (let i = 0; i < 200; i += 1) {
enqueuePRRefresh(
makeCandidate({
cacheKey: `/repo::churn/${i}`,
branch: `churn/${i}`,
worktreeId: 'wt-churn',
linkedPRNumber: 42,
cachedFetchedAt: null
}),
'swr',
10,
1
)
}
expect(_getPRRefreshQueueSizeForTests()).toBe(1)
expect(_getPRRefreshAliasCountForTests(LINKED_PR_KEY)).toBe(1)
await vi.advanceTimersByTimeAsync(60_000)
const outcomeAliases = coordinatorMocks.sendMock.mock.calls
.filter((call) => call[0] === 'gh:prRefreshEvent')
.map((call) => (call[1] as { aliases: { cacheKey: string }[] }).aliases)
expect(outcomeAliases.every((aliases) => aliases.length === 1)).toBe(true)
expect(outcomeAliases.at(-1)?.[0].cacheKey).toBe('/repo::churn/199')
})
it('keeps a manually refreshed worktree to one alias across branch churn', async () => {
const { reportVisiblePRRefreshCandidates, refreshPRNow, _getPRRefreshAliasCountForTests } =
await import('./pr-refresh-coordinator')
const churnCandidate = (index: number): GitHubPRRefreshCandidate =>
makeCandidate({
cacheKey: `/repo::churn/${index}`,
branch: `churn/${index}`,
worktreeId: 'wt-churn',
linkedPRNumber: 42,
cachedFetchedAt: null
})
// A manual refresh merges its alias into its own copy of the entry's map and
// writes that map back, so the coalescing bound has to hold on re-entry too.
reportVisiblePRRefreshCandidates([churnCandidate(0)], 1, 1)
await vi.advanceTimersByTimeAsync(1_000)
for (let i = 1; i <= 30; i += 1) {
await refreshPRNow(churnCandidate(i))
await vi.advanceTimersByTimeAsync(1)
}
expect(_getPRRefreshAliasCountForTests(LINKED_PR_KEY)).toBe(1)
})
})
+88 -4
View File
@@ -32,6 +32,80 @@ export type PRRefreshEnqueue = {
coalesced: boolean
}
/** A worktree has one live branch at a time, so a second cacheKey for it is a
* branch it moved off. Drop those: a linked-PR key survives every branch
* switch, so while its entry is parked (rate-limit pause, error backoff, or
* just waiting to drain) every switch used to add a cacheKey that the drain
* snapshot then carries forward, and each dead one costs IPC payload and a
* renderer cache write on every later broadcast. */
function setLiveAlias(
aliases: Map<string, GitHubPRRefreshAlias>,
alias: GitHubPRRefreshAlias
): void {
if (alias.worktreeId) {
for (const [cacheKey, existing] of aliases) {
if (cacheKey !== alias.cacheKey && existing.worktreeId === alias.worktreeId) {
aliases.delete(cacheKey)
}
}
}
aliases.set(alias.cacheKey, alias)
}
/** Follow-up aliases were captured before the request ran, so anything enqueued
* while it was in flight is newer. Return that preserved alias so callers do
* not pair it with the stale request candidate. */
function mergeFollowUpAlias(
aliases: Map<string, GitHubPRRefreshAlias>,
alias: GitHubPRRefreshAlias
): GitHubPRRefreshAlias | undefined {
if (alias.worktreeId) {
for (const existing of aliases.values()) {
if (existing.worktreeId === alias.worktreeId) {
return existing
}
}
}
setLiveAlias(aliases, alias)
return undefined
}
function sameAliasRequestIdentity(
left: GitHubPRRefreshAlias,
right: GitHubPRRefreshAlias
): boolean {
return (
left.cacheKey === right.cacheKey &&
left.repoId === right.repoId &&
left.repoPath === right.repoPath &&
left.branch === right.branch &&
left.worktreeId === right.worktreeId &&
left.connectionId === right.connectionId &&
left.executionHostId === right.executionHostId &&
left.linkedPRNumber === right.linkedPRNumber &&
left.fallbackPRNumber === right.fallbackPRNumber &&
left.fallbackPRSource === right.fallbackPRSource &&
left.currentHeadOid === right.currentHeadOid
)
}
/** A manual refresh merges its alias into its own copy of the map and writes it
* back, so re-entry through `set` has to re-apply the same bound; later
* insertions are the newer branch and win. */
function dropSupersededWorktreeAliases(aliases: Map<string, GitHubPRRefreshAlias>): void {
const liveCacheKeyByWorktree = new Map<string, string>()
for (const [cacheKey, alias] of aliases) {
if (!alias.worktreeId) {
continue
}
const superseded = liveCacheKeyByWorktree.get(alias.worktreeId)
if (superseded !== undefined) {
aliases.delete(superseded)
}
liveCacheKeyByWorktree.set(alias.worktreeId, cacheKey)
}
}
export class PRRefreshQueue {
private readonly entries = new Map<string, PRRefreshQueueEntry>()
private order = 0
@@ -47,6 +121,7 @@ export class PRRefreshQueue {
}
set(key: string, entry: PRRefreshQueueEntry): void {
dropSupersededWorktreeAliases(entry.aliases)
this.entries.set(key, entry)
}
@@ -92,7 +167,7 @@ export class PRRefreshQueue {
return { alias, key, dueAt, coalesced: false }
}
existing.aliases.set(alias.cacheKey, alias)
setLiveAlias(existing.aliases, alias)
const shouldPromote =
priority > existing.priority ||
reason === 'manual' ||
@@ -190,20 +265,29 @@ export class PRRefreshQueue {
setVisibleFollowUp(entry: PRRefreshQueueEntry): void {
const existing = this.entries.get(entry.key)
if (!existing) {
this.entries.set(entry.key, entry)
this.set(entry.key, entry)
return
}
let candidateSuperseded = false
for (const alias of entry.aliases.values()) {
existing.aliases.set(alias.cacheKey, alias)
const preserved = mergeFollowUpAlias(existing.aliases, alias)
if (
preserved &&
alias.worktreeId === entry.candidate.worktreeId &&
!sameAliasRequestIdentity(preserved, alias)
) {
candidateSuperseded = true
}
}
if (
candidateSuperseded ||
bypassesFreshnessDelay(existing.reason) ||
existing.priority > entry.priority ||
existing.dueAt <= entry.dueAt
) {
return
}
this.entries.set(entry.key, { ...entry, aliases: existing.aliases })
this.set(entry.key, { ...entry, aliases: existing.aliases })
}
ordered(
+11
View File
@@ -319,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'
@@ -963,6 +968,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()
@@ -2443,6 +2451,9 @@ void app.whenReady().then(async () => {
} catch {
console.warn('[proxy] Failed to apply network proxy settings')
}
// Why: the preview session is protocol-scoped, so the handler must exist before any preview webview attaches.
installDocPreviewProtocolHandler()
registerDocPreviewGrantHandlers()
// Why: browser sessions serve desktop webviews and runtime profile commands, so init at app startup rather than via a renderer IPC path.
initializeBrowserSessionsForApp({
orcaProfileId: activeOrcaProfile.profile.id,
+19
View File
@@ -1,5 +1,6 @@
import { ipcMain } from 'electron'
import { browserManager } from '../browser/browser-manager'
import { onDocPreviewGrantRevoked } from '../browser/doc-preview-grant-registry'
import { isTrustedBrowserRenderer } from './browser-renderer-trust'
import { waitForNextTabRegistration } from './browser-tab-registration-wait'
import type {
@@ -48,7 +49,25 @@ export function disposeGrabModeStateForPage(browserPageId: string): void {
grabModeOperationByPageId.delete(browserPageId)
}
/**
* Why previews need their own disposal path: a browser page announces its own death on
* `browser:unregisterGuest`, and a preview never does — it withdraws by revoking its grant, which
* is also what a re-mint does. Without this, every grant leaves an intent entry behind forever.
*/
let disposePreviewGrantSubscription: (() => void) | null = null
function subscribeToPreviewGrantRevocation(): void {
disposePreviewGrantSubscription?.()
disposePreviewGrantSubscription = onDocPreviewGrantRevoked((grant) => {
// Why cancel first: a grab still armed on that guest would otherwise leave the renderer's
// await hanging on a surface the reader has already closed.
browserManager.cancelGrabOp(grant.browserPageId, 'evicted')
disposeGrabModeStateForPage(grant.browserPageId)
})
}
export function registerBrowserGrabHandlers(): void {
subscribeToPreviewGrantRevocation()
ipcMain.removeHandler('browser:setGrabMode')
ipcMain.removeHandler('browser:awaitGrabSelection')
ipcMain.removeHandler('browser:cancelGrab')
+20 -6
View File
@@ -85,12 +85,26 @@ export function registerBrowserGuestViewHandlers(): void {
) {
return false
}
return browserManager.setAnnotationViewportBridge(args.browserPageId, {
enabled: args.enabled,
emitViewport: args.emitViewport,
markers: args.markers,
token: args.token
})
// Why resolve here: this is a tool acting on a guest the reader is looking at, so it answers
// for a workspace document too — and routing through the authority also pins the request to
// the renderer that owns the target, which page-id-only resolution never checked.
const resolveGuest = (): Electron.WebContents | null =>
browserManager.getAuthorizedGuest(args.browserPageId, event.sender.id)
if (!resolveGuest()) {
return false
}
// Why hand over the resolver rather than that guest: the op is serialized per tab, and the
// one it finally runs against must be the one on screen then, not the one this request saw.
return browserManager.setAnnotationViewportBridge(
args.browserPageId,
{
enabled: args.enabled,
emitViewport: args.emitViewport,
markers: args.markers,
token: args.token
},
resolveGuest
)
}
)
@@ -0,0 +1,510 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as DocPreviewGuestPolicyModule from '../browser/doc-preview-guest-policy'
import type * as TabRegistrationWaitModule from './browser-tab-registration-wait'
const {
handleMock,
removeHandlerMock,
getAuthorizedGuestMock,
setGrabModeMock,
awaitGrabSelectionMock,
cancelGrabOpMock,
captureSelectionScreenshotMock,
extractHoverPayloadMock,
setAnnotationViewportBridgeMock,
openDevToolsMock,
setViewportOverrideMock,
previewAuthoritySpy,
registrationWaitSpy
} = vi.hoisted(() => ({
handleMock: vi.fn(),
removeHandlerMock: vi.fn(),
getAuthorizedGuestMock: vi.fn(),
setGrabModeMock: vi.fn().mockResolvedValue(true),
awaitGrabSelectionMock: vi.fn().mockResolvedValue({ opId: 'op', kind: 'cancelled' }),
cancelGrabOpMock: vi.fn(),
captureSelectionScreenshotMock: vi.fn().mockResolvedValue(null),
extractHoverPayloadMock: vi.fn().mockResolvedValue(null),
setAnnotationViewportBridgeMock: vi.fn().mockResolvedValue(true),
openDevToolsMock: vi.fn().mockResolvedValue(true),
setViewportOverrideMock: vi.fn().mockResolvedValue(true),
previewAuthoritySpy: vi.fn(),
registrationWaitSpy: vi.fn()
}))
vi.mock('electron', () => ({
BrowserWindow: { fromWebContents: vi.fn() },
ipcMain: { removeHandler: removeHandlerMock, handle: handleMock },
webContents: { fromId: vi.fn(() => ({ isDestroyed: () => false })) }
}))
vi.mock('../browser/browser-manager', () => ({
browserCertificateTrustController: { proceed: vi.fn(() => ({ ok: true })) },
browserManager: {
registerGuest: vi.fn(() => true),
attachGuestPolicies: vi.fn(),
unregisterGuest: vi.fn(),
getGuestWebContentsId: vi.fn(),
getWebContentsIdByTabId: vi.fn(() => new Map()),
getWorktreeIdForTab: vi.fn(),
getAuthorizedGuest: getAuthorizedGuestMock,
setGrabMode: setGrabModeMock,
awaitGrabSelection: awaitGrabSelectionMock,
cancelGrabOp: cancelGrabOpMock,
captureSelectionScreenshot: captureSelectionScreenshotMock,
extractHoverPayload: extractHoverPayloadMock,
setAnnotationViewportBridge: setAnnotationViewportBridgeMock,
openDevTools: openDevToolsMock,
setViewportOverride: setViewportOverrideMock,
cancelDownload: vi.fn()
}
}))
// Why the real policy module behind a spy: the point of these tests is which half of the page
// registry a channel reads, so a stub of it would prove nothing about what was consulted.
vi.mock('../browser/doc-preview-guest-policy', async (importOriginal) => {
const actual = await importOriginal<typeof DocPreviewGuestPolicyModule>()
return {
...actual,
getWorkspaceDocPageGuest: (browserPageId: string, senderWebContentsId: number) => {
previewAuthoritySpy(browserPageId, senderWebContentsId)
return actual.getWorkspaceDocPageGuest(browserPageId, senderWebContentsId)
}
}
})
// Why spy rather than stub: the wait is real machinery the browser-page path still depends on;
// only whether a preview target enters it is under test.
vi.mock('./browser-tab-registration-wait', async (importOriginal) => {
const actual = await importOriginal<typeof TabRegistrationWaitModule>()
return {
...actual,
waitForNextTabRegistration: (...args: Parameters<typeof actual.waitForNextTabRegistration>) => {
registrationWaitSpy(args[0])
return actual.waitForNextTabRegistration(...args)
}
}
})
import { registerBrowserHandlers } from './browser'
import { browserManager } from '../browser/browser-manager'
import {
getWorkspaceDocPageGuest,
installDocPreviewGuestPolicy
} from '../browser/doc-preview-guest-policy'
import {
mintDocPreviewGrant,
revokeAllDocPreviewGrants,
revokeDocPreviewGrant
} from '../browser/doc-preview-grant-registry'
import { buildDocPreviewUrl } from '../../shared/doc-preview-scheme'
const HOST_RENDERER_ID = 91
const OTHER_RENDERER_ID = 92
/** Mirrors browser-grab-ipc's own wait, so elapsing it here settles the same parked request. */
const GRAB_REGISTRATION_WAIT_MS = 1_000
/**
* Every `browser:*` invoke channel, split by whether it acts on a guest the reader is looking at
* (a tool) or manages browser-page, session and profile state. The split is asserted to be total,
* so a new channel cannot be added without deciding which side of the preview seam it belongs on.
*/
const TOOL_CHANNELS = [
'browser:setGrabMode',
'browser:awaitGrabSelection',
'browser:cancelGrab',
'browser:captureSelectionScreenshot',
'browser:extractHoverPayload',
'browser:setAnnotationViewportBridge'
]
const BROWSER_PAGE_CHANNELS = [
'browser:registerGuest',
'browser:prepareSshWorkspacePartition',
'browser:repairGuestRegistration',
'browser:isGuestRegistered',
'browser:unregisterGuest',
'browser:respondWebAuthnAccount',
'browser:proceedCertificate',
'browser:activeTabChanged',
'browser:openDevTools',
'browser:setViewportOverride',
'browser:publishClientPageMetadata',
'browser:cancelDownload',
'browser:session:listProfiles',
'browser:session:createProfile',
'browser:session:deleteProfile',
'browser:session:importCookies',
'browser:session:resolvePartition',
'browser:session:clearDefaultCookies',
'browser:session:importFromBrowserForClientHost',
'browser:session:clientRouteImportSources',
'browser:session:detectBrowsers',
'browser:session:detectBrowsersForClientHost',
'browser:session:importFromBrowser'
]
type Handler = (event: { sender: Electron.WebContents }, args: unknown) => unknown
function registeredHandlers(): Map<string, Handler> {
const handlers = new Map<string, Handler>()
for (const [channel, handler] of handleMock.mock.calls as [string, Handler][]) {
handlers.set(channel, handler)
}
return handlers
}
function trustedSender(id: number): { sender: Electron.WebContents } {
return {
sender: {
id,
isDestroyed: () => false,
getType: () => 'window',
getURL: () => 'file:///renderer/index.html'
} as unknown as Electron.WebContents
}
}
let nextDocPageOrdinal = 0
function grantForNewDocPage(): { id: string; browserPageId: string } {
nextDocPageOrdinal += 1
const browserPageId = `doc-page-${nextDocPageOrdinal}`
const grant = mintDocPreviewGrant({
owner: { kind: 'ssh', connectionId: 'ssh-1' },
root: '/home/alice/docs',
entryRelativePath: 'index.html',
browserPageId
})
return { id: grant.id, browserPageId }
}
/** A preview guest already showing its document, which is the only state a tool can act in. */
function renderPreviewForGrant(
grant: { id: string; browserPageId: string },
hostId: number = HOST_RENDERER_ID
): {
grantId: string
browserPageId: string
contents: object
markContentsDestroyed: () => void
} {
const browserPageId = grant.browserPageId
const handlers: Record<string, (...args: never[]) => void> = {}
const register = (event: string, handler: (...args: never[]) => void): void => {
handlers[event] = handler
}
// Why the guest already reports its URL: the embedder hands a preview over mid-load, so this is
// the state the policy really installs into.
const documentUrl = buildDocPreviewUrl(grant.id, 'index.html')
let contentsDestroyed = false
const guest = {
isFocused: () => true,
isDestroyed: () => contentsDestroyed,
getURL: () => documentUrl,
on: vi.fn(register),
once: vi.fn(register),
setWindowOpenHandler: vi.fn(),
setWebRTCIPHandlingPolicy: vi.fn()
}
installDocPreviewGuestPolicy(guest as never, { id: hostId, send: vi.fn() })
handlers['did-start-navigation']?.({ url: documentUrl, isMainFrame: true } as never)
return {
grantId: grant.id,
browserPageId,
contents: guest,
// Why without the `destroyed` event: Chromium tears the contents down before main runs that
// listener, so this is the window the authority has to answer for on its own.
markContentsDestroyed: () => {
contentsDestroyed = true
}
}
}
function liveRenderedPreview(
hostId: number = HOST_RENDERER_ID
): ReturnType<typeof renderPreviewForGrant> {
return renderPreviewForGrant(grantForNewDocPage(), hostId)
}
/** Minimal well-formed args per channel, so a refusal is authorization and not shape validation. */
function toolArgs(channel: string, browserPageId: string): Record<string, unknown> {
switch (channel) {
case 'browser:setGrabMode':
return { browserPageId, enabled: true }
case 'browser:awaitGrabSelection':
return { browserPageId, opId: 'op-1' }
case 'browser:captureSelectionScreenshot':
return { browserPageId, rect: { x: 0, y: 0, width: 10, height: 10 } }
case 'browser:setAnnotationViewportBridge':
return {
browserPageId,
enabled: true,
emitViewport: true,
markers: [],
token: 'annotation-bridge-token-1'
}
default:
return { browserPageId }
}
}
/** The viewport bridge is handed a resolver rather than the contents, so unwrap one call argument. */
function resolvesToGuest(argument: unknown, guest: object): boolean {
return argument === guest || (typeof argument === 'function' && argument() === guest)
}
const GUEST_RECEIVING_MOCKS = [
setGrabModeMock,
awaitGrabSelectionMock,
captureSelectionScreenshotMock,
extractHoverPayloadMock,
setAnnotationViewportBridgeMock
]
beforeEach(() => {
vi.stubEnv('ELECTRON_RENDERER_URL', '')
// Why before clearing: revoking the previous test's grants disposes their page state through the
// mocked manager, and those calls belong to that test, not this one.
revokeAllDocPreviewGrants()
vi.clearAllMocks()
// Why the mocked manager still answers for documents: this file's subject is which channels may
// reach a document guest, not how the manager splits its registry — that lives in
// browser-manager-guest-policy-profile.test.ts, against the real manager.
getAuthorizedGuestMock.mockImplementation((browserPageId: string, senderWebContentsId: number) =>
getWorkspaceDocPageGuest(browserPageId, senderWebContentsId)
)
setGrabModeMock.mockResolvedValue(true)
awaitGrabSelectionMock.mockResolvedValue({ opId: 'op-1', kind: 'cancelled' })
captureSelectionScreenshotMock.mockResolvedValue(null)
extractHoverPayloadMock.mockResolvedValue(null)
setAnnotationViewportBridgeMock.mockResolvedValue(true)
registerBrowserHandlers()
// Why fake timers for the whole file: a tool asking for a page whose guest has not attached parks
// in the registration wait, and several cases here are exactly the ones that never attach. Real
// time would spend that wait for each of them.
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
/** Settles a tool request, elapsing the registration wait it may be parked in. */
async function settle<T>(pending: Promise<T> | T | undefined): Promise<T | undefined> {
await vi.advanceTimersByTimeAsync(GRAB_REGISTRATION_WAIT_MS)
return pending
}
describe('doc preview tool authorization', () => {
it('classifies every registered browser channel as a tool or a browser-page channel', () => {
const registered = [...registeredHandlers().keys()].sort()
const classified = [...TOOL_CHANNELS, ...BROWSER_PAGE_CHANNELS].sort()
expect(registered).toEqual(classified)
})
it.each(TOOL_CHANNELS)('drives the preview guest from %s', async (channel) => {
const preview = liveRenderedPreview()
const handler = registeredHandlers().get(channel)
await handler?.(trustedSender(HOST_RENDERER_ID), toolArgs(channel, preview.browserPageId))
// Why assert on the guest and not on a return value: the whole point of the seam is which
// WebContents the tool ends up acting on.
const receivedGuest = GUEST_RECEIVING_MOCKS.some((mock) =>
mock.mock.calls.some((args) => args.some((arg) => resolvesToGuest(arg, preview.contents)))
)
expect(receivedGuest || cancelGrabOpMock.mock.calls.length > 0).toBe(true)
})
// The load-bearing containment claim: page and session management never reads the document half
// of the registry, so a document page can never be resolved as, or managed like, a browsing one.
// Nothing here is a per-channel guard — a document guest is simply not in the map these read.
it.each(BROWSER_PAGE_CHANNELS)('never reads the document registry from %s', async (channel) => {
const preview = liveRenderedPreview()
const handler = registeredHandlers().get(channel)
try {
await handler?.(trustedSender(HOST_RENDERER_ID), {
browserPageId: preview.browserPageId,
profileId: preview.browserPageId,
environmentId: preview.browserPageId
})
} catch {
// A malformed-for-this-channel payload may throw; the claim is about which registry was read.
}
expect(previewAuthoritySpy).not.toHaveBeenCalled()
})
it.each(TOOL_CHANNELS)(
'refuses %s from a renderer that does not host the preview',
async (channel) => {
const preview = liveRenderedPreview()
const handler = registeredHandlers().get(channel)
await settle(
handler?.(trustedSender(OTHER_RENDERER_ID), toolArgs(channel, preview.browserPageId))
)
for (const mock of [...GUEST_RECEIVING_MOCKS, cancelGrabOpMock]) {
expect(mock).not.toHaveBeenCalled()
}
}
)
it.each(TOOL_CHANNELS)('refuses %s for a page no preview rendered', async (channel) => {
const handler = registeredHandlers().get(channel)
await settle(
handler?.(trustedSender(HOST_RENDERER_ID), toolArgs(channel, 'doc-page-unrendered'))
)
for (const mock of [...GUEST_RECEIVING_MOCKS, cancelGrabOpMock]) {
expect(mock).not.toHaveBeenCalled()
}
})
// Why the contents check has to be its own condition: the grant is still live and the sender is
// still the host, so nothing else in the authority notices that the guest is gone.
it.each(TOOL_CHANNELS)('refuses %s once the preview contents are destroyed', async (channel) => {
const preview = liveRenderedPreview()
preview.markContentsDestroyed()
const handler = registeredHandlers().get(channel)
await settle(
handler?.(trustedSender(HOST_RENDERER_ID), toolArgs(channel, preview.browserPageId))
)
for (const mock of [...GUEST_RECEIVING_MOCKS, cancelGrabOpMock]) {
expect(mock).not.toHaveBeenCalled()
}
})
// Why this inverts what the split registries used to assert: a document page now registers under
// its own id, so the wait is reachable and useful — a tool opened while the guest is still
// attaching parks here instead of answering not-ready at the reader.
it('waits for a document page whose guest has not attached yet, and arms when it does', async () => {
const grant = grantForNewDocPage()
const handler = registeredHandlers().get('browser:setGrabMode')
const pending = handler?.(trustedSender(HOST_RENDERER_ID), {
browserPageId: grant.browserPageId,
enabled: true
})
await vi.advanceTimersByTimeAsync(0)
expect(registrationWaitSpy).toHaveBeenCalledWith(grant.browserPageId)
const preview = renderPreviewForGrant(grant)
await expect(pending).resolves.toEqual({ ok: true })
expect(setGrabModeMock).toHaveBeenCalledWith(grant.browserPageId, true, preview.contents)
})
// The absence half, with the same presence precondition: a page nothing ever renders still gives
// the reader an answer rather than hanging on the full timeout.
it('answers not-ready once the wait for an unrendered page elapses', async () => {
const handler = registeredHandlers().get('browser:setGrabMode')
await expect(
settle(
handler?.(trustedSender(HOST_RENDERER_ID), {
browserPageId: 'doc-page-never-rendered',
enabled: true
})
)
).resolves.toEqual({ ok: false, reason: 'not-ready' })
})
it('still waits for a browser page whose registration may be in flight', async () => {
const handler = registeredHandlers().get('browser:setGrabMode')
await settle(
handler?.(trustedSender(HOST_RENDERER_ID), {
browserPageId: 'browser-page-1',
enabled: true
})
)
expect(registrationWaitSpy).toHaveBeenCalledWith('browser-page-1')
})
// Why the grant and not the guest: a preview withdraws by revoking, which is also what a
// re-mint does, and nothing else tells main that this tool target will never be used again.
it('disposes the grab state a preview target accumulated when its grant is revoked', async () => {
const preview = liveRenderedPreview()
const handler = registeredHandlers().get('browser:setGrabMode')
await handler?.(trustedSender(HOST_RENDERER_ID), {
browserPageId: preview.browserPageId,
enabled: true
})
cancelGrabOpMock.mockClear()
revokeDocPreviewGrant(preview.grantId)
expect(cancelGrabOpMock).toHaveBeenCalledWith(preview.browserPageId, 'evicted')
})
// Why both halves of this door: the manager refuses a preview id on its own, but the grab
// disposal beside it takes a renderer-supplied id, and the intent it drops is compared by
// identity — dropping it makes the grab settle ok without ever arming the guest.
it('leaves an in-flight preview grab armed when unregisterGuest names its target', async () => {
const preview = liveRenderedPreview()
const handlers = registeredHandlers()
const pending = handlers.get('browser:setGrabMode')?.(trustedSender(HOST_RENDERER_ID), {
browserPageId: preview.browserPageId,
enabled: true
})
// Why synchronously here: the intent is recorded before the queued operation runs, so this is
// the exact window in which a disposal at this door would be invisible to the caller.
const unregistered = handlers.get('browser:unregisterGuest')?.(
trustedSender(HOST_RENDERER_ID),
{ browserPageId: preview.browserPageId }
)
// Why the guest and not the result: a dropped intent settles as ok either way, so only the
// guest actually being driven separates an armed grab from a silent no-op.
await expect(pending).resolves.toEqual({ ok: true })
expect(setGrabModeMock).toHaveBeenCalledWith(preview.browserPageId, true, preview.contents)
expect(unregistered).toBe(false)
expect(vi.mocked(browserManager.unregisterGuest)).not.toHaveBeenCalled()
})
// The converse, so the guard above cannot be widened into a door that stops closing tabs.
it('still disposes a browser page grab through the same door', async () => {
getAuthorizedGuestMock.mockReturnValue({ isDestroyed: () => false })
const handlers = registeredHandlers()
const pending = handlers.get('browser:setGrabMode')?.(trustedSender(HOST_RENDERER_ID), {
browserPageId: 'browser-page-1',
enabled: true
})
const unregistered = handlers.get('browser:unregisterGuest')?.(
trustedSender(HOST_RENDERER_ID),
{ browserPageId: 'browser-page-1' }
)
expect(unregistered).toBe(true)
await expect(pending).resolves.toEqual({ ok: true })
expect(setGrabModeMock).not.toHaveBeenCalled()
expect(vi.mocked(browserManager.unregisterGuest)).toHaveBeenCalledWith('browser-page-1')
})
// Why with a live preview standing beside it: the halves are looked up in one door now, so the
// thing to prove is that a browsing id is not answered by whatever document happens to be open.
it('never hands a browsing page id the guest of a document that is open', async () => {
const preview = liveRenderedPreview()
const handler = registeredHandlers().get('browser:extractHoverPayload')
await handler?.(trustedSender(HOST_RENDERER_ID), { browserPageId: 'browser-page-1' })
expect(getAuthorizedGuestMock).toHaveBeenCalledWith('browser-page-1', HOST_RENDERER_ID)
expect(previewAuthoritySpy).toHaveBeenCalledWith('browser-page-1', HOST_RENDERER_ID)
expect(extractHoverPayloadMock).not.toHaveBeenCalled()
// The presence half: the same channel does reach that guest under the page it really renders.
await handler?.(trustedSender(HOST_RENDERER_ID), { browserPageId: preview.browserPageId })
expect(extractHoverPayloadMock).toHaveBeenCalledWith(preview.browserPageId, preview.contents)
})
})
@@ -0,0 +1,108 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
getWebContentsIdByTabId: vi.fn(() => new Map<string, number>()),
getWorktreeIdForTab: vi.fn(() => undefined as string | undefined),
getGuestWebContentsId: vi.fn(() => null as number | null),
webContentsFromId: vi.fn(() => null as unknown)
}))
vi.mock('electron', () => ({ webContents: { fromId: mocks.webContentsFromId } }))
vi.mock('../browser/browser-manager', () => ({
browserManager: {
getWebContentsIdByTabId: mocks.getWebContentsIdByTabId,
getWorktreeIdForTab: mocks.getWorktreeIdForTab,
getGuestWebContentsId: mocks.getGuestWebContentsId
}
}))
import {
waitForAnyTabRegistration,
waitForNextTabRegistration,
waitForWorktreeTabRegistration
} from './browser-tab-registration-wait'
import { installDocPreviewGuestPolicy } from '../browser/doc-preview-guest-policy'
import {
mintDocPreviewGrant,
revokeAllDocPreviewGrants
} from '../browser/doc-preview-grant-registry'
import { buildDocPreviewUrl } from '../../shared/doc-preview-scheme'
const WAIT_MS = 1_000
/** Registers a document guest the way the attach door does, which is what notifies the waiters. */
function attachDocumentGuest(browserPageId: string): void {
const grant = mintDocPreviewGrant({
owner: { kind: 'ssh', connectionId: 'ssh-1' },
root: '/home/alice/docs',
entryRelativePath: 'index.html',
browserPageId
})
const guest = {
isFocused: () => false,
isDestroyed: () => false,
getURL: () => buildDocPreviewUrl(grant.id, grant.entryRelativePath),
on: vi.fn(),
once: vi.fn(),
setWindowOpenHandler: vi.fn(),
setWebRTCIPHandlingPolicy: vi.fn()
}
installDocPreviewGuestPolicy(guest as never, { id: 91, send: vi.fn() })
}
/** Whether a wait has settled, without letting its rejection escape as an unhandled one. */
function track(pending: Promise<void>): () => 'pending' | 'resolved' | 'rejected' {
let state: 'pending' | 'resolved' | 'rejected' = 'pending'
pending.then(
() => {
state = 'resolved'
},
() => {
state = 'rejected'
}
)
return () => state
}
beforeEach(() => {
revokeAllDocPreviewGrants()
vi.clearAllMocks()
mocks.getWebContentsIdByTabId.mockReturnValue(new Map())
mocks.getGuestWebContentsId.mockReturnValue(null)
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
describe('a workspace document registering', () => {
it('settles a wait that already names its page', async () => {
const settled = track(waitForNextTabRegistration('doc-page-1', WAIT_MS))
attachDocumentGuest('doc-page-1')
await vi.advanceTimersByTimeAsync(0)
expect(settled()).toBe('resolved')
})
// Why these two stay pending: they are how the CLI and agents ask for a browser tab to drive, and
// a preview is neither drivable by them nor visible to them. Satisfying either would hand the
// caller a surface that answers nothing, instead of letting it keep waiting for a real tab.
it('leaves the waits that ask for any browser tab still waiting', async () => {
const worktreeWait = track(waitForWorktreeTabRegistration('wt-1', WAIT_MS))
const anyWait = track(waitForAnyTabRegistration(WAIT_MS))
attachDocumentGuest('doc-page-2')
await vi.advanceTimersByTimeAsync(0)
expect(worktreeWait()).toBe('pending')
expect(anyWait()).toBe('pending')
// The presence half: those waits do settle, so a mutant that never resolves them would pass the
// assertions above by being uniformly stuck.
await vi.advanceTimersByTimeAsync(WAIT_MS)
expect(worktreeWait()).toBe('rejected')
expect(anyWait()).toBe('rejected')
})
})
@@ -1,5 +1,6 @@
import { webContents } from 'electron'
import { browserManager } from '../browser/browser-manager'
import { onWorkspaceDocGuestRegistered } from '../browser/doc-preview-guest-policy'
// Why: CLI-driven tab creation must wait until the renderer mounts the webview
// and calls registerGuest, so the tab has a webContentsId and is operable by
@@ -109,6 +110,18 @@ export function waitForAnyTabRegistration(timeoutMs = 8_000): Promise<void> {
return waitForRegistrationSet(pendingAnyTabRegistrations, timeoutMs, () => {})
}
/**
* Why a document page resolves only its own waiters: the worktree-wide and any-tab waits are how
* the CLI and agents ask for a browser tab to drive, and a preview is neither drivable by them nor
* visible to them. Only a request already naming this page — a tool the reader opened on the
* document — is waiting for this.
*/
onWorkspaceDocGuestRegistered((browserPageId) => {
const pendingResolves = pendingTabRegistrations.get(browserPageId)
pendingTabRegistrations.delete(browserPageId)
resolvePendingRegistrations(pendingResolves)
})
export function resolveTabRegistrationWaiters(browserPageId: string, worktreeId: string): void {
const pendingResolves = pendingTabRegistrations.get(browserPageId)
pendingTabRegistrations.delete(browserPageId)
+48 -6
View File
@@ -794,6 +794,8 @@ describe('registerBrowserHandlers', () => {
it('validates annotation viewport bridge requests before syncing to the guest', async () => {
registerBrowserHandlers()
const guest = { isDestroyed: () => false } as Electron.WebContents
getAuthorizedGuestMock.mockReturnValue(guest)
const syncHandler = handleMock.mock.calls.find(
([channel]) => channel === 'browser:setAnnotationViewportBridge'
@@ -818,12 +820,52 @@ describe('registerBrowserHandlers', () => {
)
expect(result).toBe(true)
expect(setAnnotationViewportBridgeMock).toHaveBeenCalledWith('page-1', {
emitViewport: false,
enabled: true,
markers: [],
token: 'annotationviewporttoken'
})
expect(setAnnotationViewportBridgeMock).toHaveBeenCalledWith(
'page-1',
{
emitViewport: false,
enabled: true,
markers: [],
token: 'annotationviewporttoken'
},
// Why a resolver and not the guest: the op is serialized per page, so it has to read the
// registry when it runs — a navigation while it waited may have swapped the contents.
// Which guest it then resolves is pinned in browser-manager-annotation-bridge.test.ts.
expect.any(Function)
)
})
// Why this is new: the channel used to hand a page id straight to the manager, so any trusted
// renderer could drive any page's guest. It now resolves through the same authority the grab
// channels use, which pins the request to the renderer that registered the page.
it('refuses an annotation viewport bridge request from a renderer that does not own the page', async () => {
registerBrowserHandlers()
getAuthorizedGuestMock.mockReturnValue(null)
const syncHandler = handleMock.mock.calls.find(
([channel]) => channel === 'browser:setAnnotationViewportBridge'
)?.[1] as (event: { sender: Electron.WebContents }, args: unknown) => Promise<boolean> | boolean
const result = await syncHandler(
{
sender: {
id: 91,
isDestroyed: () => false,
getType: () => 'window',
getURL: () => 'file:///renderer/index.html'
} as Electron.WebContents
},
{
browserPageId: 'page-1',
emitViewport: false,
enabled: true,
markers: [],
token: 'annotationviewporttoken'
}
)
expect(result).toBe(false)
expect(setAnnotationViewportBridgeMock).not.toHaveBeenCalled()
})
it('rejects invalid annotation viewport bridge requests', async () => {
+8
View File
@@ -2,6 +2,7 @@ import { ipcMain, webContents } from 'electron'
import { browserCertificateTrustController, browserManager } from '../browser/browser-manager'
import type { AgentBrowserBridge } from '../browser/agent-browser-bridge'
import { browserSessionRegistry } from '../browser/browser-session-registry'
import { isWorkspaceDocPageId } from '../browser/doc-preview-guest-policy'
import { isTrustedBrowserRenderer } from './browser-renderer-trust'
import {
isLiveBrowserWebContentsId,
@@ -160,6 +161,13 @@ export function registerBrowserHandlers(): void {
if (!isTrustedBrowserRenderer(event.sender)) {
return false
}
// Why the whole door and not just the manager call: a document page shares this renderer, and
// the grab disposal below drops the intent an in-flight preview grab compares by identity —
// that grab would then answer ok without ever arming. A document page withdraws by revoking
// its grant, so its id arriving here is misaddressed however it got here.
if (typeof args?.browserPageId !== 'string' || isWorkspaceDocPageId(args.browserPageId)) {
return false
}
// Why: notify bridge before unregistering so it can destroy the session
// process and proxy. Must happen before unregisterGuest clears the mapping.
const wcId = browserManager.getGuestWebContentsId(args.browserPageId)
+194
View File
@@ -0,0 +1,194 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
handlers: new Map<string, (event: unknown, ...args: unknown[]) => unknown>(),
listeners: new Map<string, (event: unknown, ...args: unknown[]) => unknown>(),
isTrustedBrowserRenderer: vi.fn(),
reportDocPreviewLinkClick: vi.fn(),
getGuestWebContentsId: vi.fn()
}))
vi.mock('electron', () => ({
ipcMain: {
handle: (channel: string, handler: (event: unknown, ...args: unknown[]) => unknown) => {
mocks.handlers.set(channel, handler)
},
on: (channel: string, listener: (event: unknown, ...args: unknown[]) => unknown) => {
mocks.listeners.set(channel, listener)
}
}
}))
vi.mock('./browser-renderer-trust', () => ({
isTrustedBrowserRenderer: mocks.isTrustedBrowserRenderer
}))
vi.mock('../browser/doc-preview-guest-policy', () => ({
reportDocPreviewLinkClick: mocks.reportDocPreviewLinkClick
}))
vi.mock('../browser/browser-manager', () => ({
browserManager: { getGuestWebContentsId: mocks.getGuestWebContentsId }
}))
import {
registerDocPreviewGrantHandlers,
type DocPreviewGrantRequest
} from './doc-preview-grant-ipc'
import {
getDocPreviewGrant,
revokeAllDocPreviewGrants
} from '../browser/doc-preview-grant-registry'
import {
DOC_PREVIEW_AUTHORIZE_DIRECTORY_CHANNEL,
DOC_PREVIEW_LINK_CLICK_CHANNEL,
DOC_PREVIEW_MINT_GRANT_CHANNEL,
DOC_PREVIEW_REVOKE_GRANT_CHANNEL,
parseDocPreviewUrl
} from '../../shared/doc-preview-scheme'
const REQUEST: DocPreviewGrantRequest = {
owner: { kind: 'ssh', connectionId: 'ssh-1' },
requestBase: '/home/alice/docs',
root: '/home/alice/docs',
entryRelativePath: 'index.html',
browserPageId: 'doc-page-1'
}
const sender = { id: 7 }
function mint(request: DocPreviewGrantRequest = REQUEST): { grantId: string; url: string } {
const handler = mocks.handlers.get(DOC_PREVIEW_MINT_GRANT_CHANNEL)
if (!handler) {
throw new Error('mint handler not registered')
}
return handler({ sender }, request) as { grantId: string; url: string }
}
function revoke(grantId: string): boolean {
const handler = mocks.handlers.get(DOC_PREVIEW_REVOKE_GRANT_CHANNEL)
if (!handler) {
throw new Error('revoke handler not registered')
}
return handler({ sender }, grantId) as boolean
}
function authorize(grantId: unknown, relativePath: unknown): boolean {
const handler = mocks.handlers.get(DOC_PREVIEW_AUTHORIZE_DIRECTORY_CHANNEL)
if (!handler) {
throw new Error('authorize handler not registered')
}
return handler({ sender }, grantId, relativePath) as boolean
}
function reportLinkClick(url: unknown): void {
const listener = mocks.listeners.get(DOC_PREVIEW_LINK_CLICK_CHANNEL)
if (!listener) {
throw new Error('link click listener not registered')
}
listener({ sender }, url)
}
beforeEach(() => {
mocks.handlers.clear()
mocks.listeners.clear()
vi.clearAllMocks()
revokeAllDocPreviewGrants()
mocks.isTrustedBrowserRenderer.mockReturnValue(true)
mocks.getGuestWebContentsId.mockReturnValue(null)
registerDocPreviewGrantHandlers()
})
describe('document preview grant handlers', () => {
it('mints a grant addressable by the URL it returns', () => {
const result = mint()
expect(parseDocPreviewUrl(result.url)).toEqual({
grantId: result.grantId,
relativePath: 'index.html'
})
expect(getDocPreviewGrant(result.grantId)?.root).toBe('/home/alice/docs')
expect(mocks.isTrustedBrowserRenderer).toHaveBeenCalledWith(sender)
})
it('revokes a grant it minted', () => {
const result = mint()
expect(revoke(result.grantId)).toBe(true)
expect(getDocPreviewGrant(result.grantId)).toBeNull()
})
it('rejects a request that names no root, entry document or page', () => {
expect(() => mint({ ...REQUEST, root: ' ' })).toThrow(/Invalid/)
expect(() => mint({ ...REQUEST, entryRelativePath: '' })).toThrow(/Invalid/)
expect(() => mint({ ...REQUEST, browserPageId: ' ' })).toThrow(/Invalid/)
})
// Why this is refused here and not left to registration: this is where a page becomes a document
// page, and the two halves of the page registry have to stay disjoint. A page already hosting a
// browsing guest would otherwise resolve in both, and the tool door prefers the document one.
it('refuses to make a page that already hosts a browsing guest into a document page', () => {
mocks.getGuestWebContentsId.mockReturnValue(42)
expect(() => mint()).toThrow(/browsing page/)
expect(mocks.getGuestWebContentsId).toHaveBeenCalledWith('doc-page-1')
})
// Why: this channel hands out filesystem-read authority, so an untrusted sender must leave with
// nothing rather than with a grant id the scheme handler would honor.
it('mints nothing for a sender that is not the trusted renderer', () => {
mocks.isTrustedBrowserRenderer.mockReturnValue(false)
expect(() => mint()).toThrow(/Untrusted/)
})
it('refuses to revoke on behalf of a sender that is not the trusted renderer', () => {
const result = mint()
mocks.isTrustedBrowserRenderer.mockReturnValue(false)
expect(revoke(result.grantId)).toBe(false)
expect(getDocPreviewGrant(result.grantId)).not.toBeNull()
})
it('lets only the trusted renderer authorize a valid requested directory', () => {
const result = mint({
...REQUEST,
requestBase: '/home/alice',
root: '/home/alice/docs',
entryRelativePath: 'docs/index.html'
})
expect(authorize(result.grantId, 'assets/app.js')).toBe(true)
expect(getDocPreviewGrant(result.grantId)?.authorizedRoots).toEqual(['/home/alice/assets'])
mocks.isTrustedBrowserRenderer.mockReturnValue(false)
expect(authorize(result.grantId, 'secrets/token.txt')).toBe(false)
expect(getDocPreviewGrant(result.grantId)?.authorizedRoots).toEqual(['/home/alice/assets'])
})
it('refuses malformed directory authorization arguments', () => {
const result = mint()
expect(authorize(result.grantId, '../secret.txt')).toBe(false)
expect(authorize(result.grantId, null)).toBe(false)
expect(authorize(null, 'assets/app.js')).toBe(false)
expect(authorize('0'.repeat(32), 'assets/app.js')).toBe(false)
})
// Why this channel skips the trusted-renderer check: its sender is a preview guest rendering a
// workspace document, which is the untrusted side by construction. The guest policy holds the
// gate, so all this listener owes is the sender and a string.
it('hands a reported link click to the guest policy with the sender that reported it', () => {
reportLinkClick('https://example.com/docs')
expect(mocks.reportDocPreviewLinkClick).toHaveBeenCalledExactlyOnceWith(
sender,
'https://example.com/docs'
)
expect(mocks.isTrustedBrowserRenderer).not.toHaveBeenCalled()
})
it('ignores a reported click that is not a string at all', () => {
reportLinkClick({ url: 'https://example.com/docs' })
reportLinkClick(undefined)
expect(mocks.reportDocPreviewLinkClick).not.toHaveBeenCalled()
})
})
+109
View File
@@ -0,0 +1,109 @@
import { ipcMain } from 'electron'
import {
buildDocPreviewUrl,
DOC_PREVIEW_AUTHORIZE_DIRECTORY_CHANNEL,
DOC_PREVIEW_LINK_CLICK_CHANNEL,
DOC_PREVIEW_MINT_GRANT_CHANNEL,
DOC_PREVIEW_REVOKE_GRANT_CHANNEL
} from '../../shared/doc-preview-scheme'
import { browserManager } from '../browser/browser-manager'
import { reportDocPreviewLinkClick } from '../browser/doc-preview-guest-policy'
import {
authorizeDocPreviewDirectory,
mintDocPreviewGrant,
revokeDocPreviewGrant,
type DocPreviewOwner
} from '../browser/doc-preview-grant-registry'
import { isTrustedBrowserRenderer } from './browser-renderer-trust'
export type DocPreviewGrantRequest = {
owner: DocPreviewOwner
/** Directory relative preview URLs resolve against. */
requestBase: string
/** Containing directory of the opened document, on the owning host. */
root: string
/** Opened document, relative to `requestBase`. */
entryRelativePath: string
/** Browser page the reader is opening the document in; main registers the guest under it. */
browserPageId: string
}
export type DocPreviewGrantResult = { grantId: string; url: string }
function isValidGrantRequest(request: DocPreviewGrantRequest): boolean {
if (!request.requestBase.trim() || !request.root.trim() || !request.entryRelativePath.trim()) {
return false
}
if (typeof request.browserPageId !== 'string' || !request.browserPageId.trim()) {
return false
}
if (request.owner.kind === 'ssh') {
return Boolean(request.owner.connectionId.trim())
}
return Boolean(
request.owner.environmentId.trim() &&
request.owner.worktreeSelector.trim() &&
request.owner.worktreeRoot.trim()
)
}
/**
* Minting never widens what the renderer can already read: an SSH grant reads
* through the same provider as `fs:readFile`, and a runtime grant through the
* same worktree-scoped `files.read` RPC the renderer can call directly.
*/
export function registerDocPreviewGrantHandlers(): void {
ipcMain.handle(
DOC_PREVIEW_MINT_GRANT_CHANNEL,
(event, request: DocPreviewGrantRequest): DocPreviewGrantResult => {
// Why gate a channel guests cannot reach today: this one hands out filesystem-read
// authority, so it holds the same sender check its sibling browser channels do rather than
// relying on guests never gaining an ipcRenderer.
if (!isTrustedBrowserRenderer(event.sender)) {
throw new Error('Untrusted document preview grant request')
}
if (!isValidGrantRequest(request)) {
throw new Error('Invalid document preview grant request')
}
// Why the other half of the registry is consulted here: this is where a page first becomes a
// document page, and the two halves must stay disjoint. Naming a page that already hosts a
// browsing guest would make one id resolve in both.
if (browserManager.getGuestWebContentsId(request.browserPageId) !== null) {
throw new Error('Document preview grant names a browsing page')
}
const grant = mintDocPreviewGrant({
owner: request.owner,
requestBase: request.requestBase,
root: request.root,
entryRelativePath: request.entryRelativePath,
browserPageId: request.browserPageId
})
return {
grantId: grant.id,
url: buildDocPreviewUrl(grant.id, grant.entryRelativePath)
}
}
)
ipcMain.handle(DOC_PREVIEW_REVOKE_GRANT_CHANNEL, (event, grantId: string): boolean =>
isTrustedBrowserRenderer(event.sender) ? revokeDocPreviewGrant(grantId) : false
)
ipcMain.handle(
DOC_PREVIEW_AUTHORIZE_DIRECTORY_CHANNEL,
(event, grantId: unknown, relativePath: unknown): boolean =>
isTrustedBrowserRenderer(event.sender) &&
typeof grantId === 'string' &&
typeof relativePath === 'string' &&
authorizeDocPreviewDirectory(grantId, relativePath)
)
// Why no trusted-renderer check here: the sender is a preview guest rendering a workspace
// document, which is exactly the untrusted side. `reportDocPreviewLinkClick` is the gate — a
// live bound grant and a web URL — and it drops everything else silently.
ipcMain.on(DOC_PREVIEW_LINK_CLICK_CHANNEL, (event, url: unknown) => {
if (typeof url === 'string') {
reportDocPreviewLinkClick(event.sender, url)
}
})
}
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import { getDefaultWorkspaceSession } from '../../shared/constants'
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
import {
mergeWorkspaceSessions,
removeRepoFromWorkspaceSession
@@ -84,6 +85,57 @@ describe('profile project session state', () => {
)
})
it('rekeys document-preview ownership during project transfer', () => {
const session = {
...getDefaultWorkspaceSession(),
browserTabsByWorktree: {
[REMOVED_WORKTREE_ID]: [
{
id: 'browser-1',
worktreeId: REMOVED_WORKTREE_ID,
docLocation: {
kind: 'workspace-doc',
worktreeId: REMOVED_WORKTREE_ID,
filePath: '/removed/docs/report.html'
}
}
]
},
browserPagesByWorkspace: {
'browser-1': [
{
id: 'page-1',
workspaceId: 'browser-1',
worktreeId: REMOVED_WORKTREE_ID,
docLocation: {
kind: 'workspace-doc',
worktreeId: REMOVED_WORKTREE_ID,
filePath: '/removed/docs/report.html'
}
}
]
}
}
const result = extractSessionForTransfer(
session as unknown as WorkspaceSessionState,
REMOVED_REPO_ID,
'repo-c'
)
const transferredWorktreeId = 'repo-c::/removed'
expect(result.browserTabsByWorktree?.[transferredWorktreeId]?.[0]?.docLocation).toEqual({
kind: 'workspace-doc',
worktreeId: transferredWorktreeId,
filePath: '/removed/docs/report.html'
})
expect(result.browserPagesByWorkspace?.['browser-1']?.[0]?.docLocation).toEqual({
kind: 'workspace-doc',
worktreeId: transferredWorktreeId,
filePath: '/removed/docs/report.html'
})
})
it('prunes terminal membership authority records with a removed repo', () => {
const session = {
...getDefaultWorkspaceSession(),
@@ -1,6 +1,11 @@
import { getDefaultWorkspaceSession } from '../../shared/constants'
import type { ExecutionHostId } from '../../shared/execution-host'
import type { BrowserPage, BrowserWorkspace } from '../../shared/browser-workspace-types'
import type {
BrowserPage,
BrowserPageDocLocation,
BrowserWorkspace
} from '../../shared/browser-workspace-types'
import { remapBrowserPageDocLocation } from '../../shared/browser-page-doc-location'
import type { Tab, TabGroup } from '../../shared/tab-types'
import type { TerminalTab } from '../../shared/terminal-tab-types'
import type {
@@ -175,6 +180,9 @@ function rekeyBrowserWorkspace(
return {
...structuredClone(workspace),
worktreeId: rekeyWorktreeId(oldRepoId, newRepoId, workspace.worktreeId),
...(workspace.docLocation
? { docLocation: rekeyBrowserDocLocation(workspace.docLocation, oldRepoId, newRepoId) }
: {}),
// Why: both the session profile and the resolved partition string are
// source-profile-scoped; carrying either across would point the restored
// pane at a partition the target profile's allowlist rejects.
@@ -186,10 +194,22 @@ function rekeyBrowserWorkspace(
function rekeyBrowserPage(page: BrowserPage, oldRepoId: string, newRepoId: string): BrowserPage {
return {
...structuredClone(page),
worktreeId: rekeyWorktreeId(oldRepoId, newRepoId, page.worktreeId)
worktreeId: rekeyWorktreeId(oldRepoId, newRepoId, page.worktreeId),
...(page.docLocation
? { docLocation: rekeyBrowserDocLocation(page.docLocation, oldRepoId, newRepoId) }
: {})
}
}
function rekeyBrowserDocLocation(
location: BrowserPageDocLocation,
oldRepoId: string,
newRepoId: string
): BrowserPageDocLocation {
const nextWorktreeId = rekeyWorktreeId(oldRepoId, newRepoId, location.worktreeId)
return remapBrowserPageDocLocation(location, location.worktreeId, nextWorktreeId)
}
function copyBrowserPages(
pagesByWorkspace: Record<string, BrowserPage[]> | undefined,
workspaceIds: ReadonlySet<string>,
@@ -243,10 +243,33 @@ describe('Store.migrateWorktreeIdentity', () => {
},
activeFileIdByWorktree: { [OLD]: '/ws/cunner/a.ts' },
browserTabsByWorktree: {
[OLD]: [{ id: 'browser1', worktreeId: OLD, title: 'Browser', url: 'about:blank' }]
[OLD]: [
{
id: 'browser1',
worktreeId: OLD,
title: 'Browser',
url: 'about:blank',
docLocation: {
kind: 'workspace-doc',
worktreeId: OLD,
filePath: '/ws/cunner/docs/report.html'
}
}
]
},
browserPagesByWorkspace: {
browser1: [{ id: 'page1', workspaceId: 'browser1', worktreeId: OLD }]
browser1: [
{
id: 'page1',
workspaceId: 'browser1',
worktreeId: OLD,
docLocation: {
kind: 'workspace-doc',
worktreeId: OLD,
filePath: '/ws/cunner/docs/report.html'
}
}
]
},
activeBrowserTabIdByWorktree: { [OLD]: 'browser1' },
activeTabTypeByWorktree: { [OLD]: 'browser' },
@@ -327,6 +350,16 @@ describe('Store.migrateWorktreeIdentity', () => {
expect(session.browserTabsByWorktree?.[OLD]).toBeUndefined()
expect(session.browserTabsByWorktree?.[NEW]?.[0]?.worktreeId).toBe(NEW)
expect(session.browserPagesByWorkspace?.browser1?.[0]?.worktreeId).toBe(NEW)
expect(session.browserTabsByWorktree?.[NEW]?.[0]?.docLocation).toEqual({
kind: 'workspace-doc',
worktreeId: NEW,
filePath: '/ws/worktree-creation-spinner/docs/report.html'
})
expect(session.browserPagesByWorkspace?.browser1?.[0]?.docLocation).toEqual({
kind: 'workspace-doc',
worktreeId: NEW,
filePath: '/ws/worktree-creation-spinner/docs/report.html'
})
expect(session.activeBrowserTabIdByWorktree?.[NEW]).toBe('browser1')
expect(session.activeTabTypeByWorktree?.[NEW]).toBe('browser')
expect(session.activeWorktreeId).toBe(NEW)

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