Merge branch 'main' into brennanb2025/sta-3515-rss-harness-fix

This commit is contained in:
Brennan Benson
2026-08-27 23:49:41 -07:00
committed by GitHub
426 changed files with 31635 additions and 3351 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.
+22 -3
View File
@@ -17,6 +17,10 @@ on:
description: JSON array of changed specs; empty runs the full suite
required: false
type: string
ssh_source_changed:
description: '"true" when the PR touches SSH execution source; gates the Docker-SSH lane'
required: false
type: string
workflow_dispatch:
inputs:
ref:
@@ -221,14 +225,21 @@ jobs:
ssh-docker-watcher-isolation:
name: ssh docker watcher isolation
needs: build
# Why ssh_source_changed first: this lane used to trigger on SSH source only as a side
# effect of one route listing a startup-readiness spec — pruning that spec would have
# silently retired the whole lane. The signal is now derived from the SSH routes directly.
# The two spec clauses stay for their honest purpose: changed-e2e hands these specs to this
# lane, so editing one must still run it here.
if: >-
inputs.test_files == '' ||
inputs.ssh_source_changed == 'true' ||
contains(inputs.test_files, 'tests/e2e/ssh-startup-exec-readiness.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/paired-startup-exec-readiness.spec.ts')
runs-on: ubuntu-latest
# Why 35: the parking, retention, startup-exec, and paired parity specs run
# serially on isolated Electron/SSH fixtures after watcher isolation.
timeout-minutes: 35
# Why 60: this lane now also runs the remaining Docker-SSH specs serially. They average
# ~18s but several budget 4-10 minutes per test, so a slow run lands far above the old 35
# — and the sharded lanes already show that a lane which times out is a lane nobody trusts.
timeout-minutes: 60
steps:
- name: Checkout
@@ -260,6 +271,14 @@ jobs:
if: always()
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker-terminal-parking
# Why here rather than the sharded lanes: the shards set no ORCA_E2E_SSH_DOCKER, so every
# spec below skipped itself while the shard still reported green. Running them on this one
# VM pays the fixture image build once instead of ten times, and keeps an SSH regression
# legible as an SSH-named failure.
- name: Run remaining Docker SSH E2E
if: always()
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker
- name: Upload watcher isolation traces
if: failure()
uses: actions/upload-artifact@v7
+20
View File
@@ -0,0 +1,20 @@
name: Node next compatibility
on:
schedule:
# Full future-runtime coverage is useful, but not worth doubling every PR matrix.
- cron: '0 10 * * *'
workflow_dispatch:
concurrency:
group: node-next-compat
cancel-in-progress: true
permissions:
contents: read
jobs:
test:
uses: ./.github/workflows/unit-tests.yml
with:
node_versions: '["26"]'
+13 -51
View File
@@ -444,16 +444,12 @@ jobs:
src/shared/posix-command-path-lookup.test.ts
# Cache-key input changes would otherwise make every shard compile the same
# native addon concurrently. Prime each Node ABI once before the matrix fans out.
# native addon concurrently. Prime the supported Node ABI before the matrix fans out.
test_native_cache:
name: prepare test native cache node ${{ matrix.node }}
name: prepare test native cache node 24
needs: [code_paths]
if: needs.code_paths.outputs.native_cache_changed == 'true'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node: ['24', '26']
steps:
- name: Checkout
@@ -464,58 +460,17 @@ jobs:
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: node
node-version: ${{ matrix.node }}
node-version: '24'
test:
name: tests node ${{ matrix.node }} ${{ matrix.shard }}/${{ matrix.shard_total }}
needs: [code_paths, test_native_cache]
if: >-
always() &&
needs.code_paths.outputs.test == 'true' &&
(needs.test_native_cache.result == 'success' || needs.test_native_cache.result == 'skipped')
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node: ['24', '26']
shard: [1, 2, 3, 4, 5, 6, 7, 8]
shard_total: [8]
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: node
node-version: ${{ matrix.node }}
- name: Install Electron package binary for tests
run: node config/scripts/install-electron-package-binary.mjs
- name: Test shard
run: |
pnpm exec vitest run --config config/vitest.config.ts \
--exclude=src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts \
--exclude=src/main/daemon/shell-ready.test.ts \
--exclude=src/main/daemon/node-pty-fd-leak.test.ts \
--exclude=src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts \
--exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \
--exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \
--exclude=src/main/shell-startup-feature-channel.test.ts \
--exclude=src/main/terminal-history-fish-session.node-pty.test.ts \
--exclude=src/main/zsh-scoped-histfile.live-shell.test.ts \
--exclude=src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts \
--exclude=src/main/zsh-wrapper-version-mismatch.live-shell.test.ts \
--exclude=src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \
--exclude=src/shared/fish-query-reply-child-stdin.node-pty.test.ts \
--exclude=src/shared/pty-reply-echo-shapes.node-pty.test.ts \
--exclude=src/shared/startup-shell-portability.live-shell.test.ts \
--exclude=src/shared/posix-command-path-lookup.test.ts \
--exclude=tests/e2e/cross-version-wire/** \
--shard=${{ matrix.shard }}/${{ matrix.shard_total }}
uses: ./.github/workflows/unit-tests.yml
with:
node_versions: '["24"]'
# Why a separate job: the test needs a real Chrome, and the sharded `test` matrix
# would pay for it on every shard to run one file in whichever shard it landed in.
@@ -804,6 +759,7 @@ jobs:
outputs:
should_run: ${{ steps.filter.outputs.should_run }}
test_files: ${{ steps.filter.outputs.test_files }}
ssh_source_changed: ${{ steps.filter.outputs.ssh_source_changed }}
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -826,6 +782,11 @@ jobs:
# authorities, exclusions, and sentinels without evaluating workflow shell.
TEST_FILES_JSON="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-e2e-source-routing.mjs)"
echo "test_files=$TEST_FILES_JSON" >> "$GITHUB_OUTPUT"
# Why a separate signal: the Docker-SSH lane must trigger on SSH source, not on a
# spec name surviving in a route's list. Same routes, so the two cannot drift.
SSH_SOURCE_CHANGED="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-e2e-source-routing.mjs --ssh-source)"
echo "ssh_source_changed=$SSH_SOURCE_CHANGED" >> "$GITHUB_OUTPUT"
echo "SSH source changed: $SSH_SOURCE_CHANGED"
if [ "$TEST_FILES_JSON" != '[]' ]; then
echo "should_run=true" >> "$GITHUB_OUTPUT"
echo "Changed E2E specs: $TEST_FILES_JSON"
@@ -844,6 +805,7 @@ jobs:
uses: ./.github/workflows/e2e.yml
with:
test_files: ${{ needs.e2e-paths.outputs.test_files }}
ssh_source_changed: ${{ needs.e2e-paths.outputs.ssh_source_changed }}
verify:
if: always()
+59
View File
@@ -0,0 +1,59 @@
name: Unit tests
on:
workflow_call:
inputs:
node_versions:
description: JSON array of Node.js major versions to test.
required: true
type: string
permissions:
contents: read
jobs:
test:
name: tests node ${{ matrix.node }} ${{ matrix.shard }}/${{ matrix.shard_total }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node: ${{ fromJSON(inputs.node_versions) }}
shard: [1, 2, 3, 4, 5, 6, 7, 8]
shard_total: [8]
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: node
node-version: ${{ matrix.node }}
- name: Install Electron package binary for tests
run: node config/scripts/install-electron-package-binary.mjs
- name: Test shard
run: |
pnpm exec vitest run --config config/vitest.config.ts \
--exclude=src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts \
--exclude=src/main/daemon/shell-ready.test.ts \
--exclude=src/main/daemon/node-pty-fd-leak.test.ts \
--exclude=src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts \
--exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \
--exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \
--exclude=src/main/shell-startup-feature-channel.test.ts \
--exclude=src/main/terminal-history-fish-session.node-pty.test.ts \
--exclude=src/main/zsh-scoped-histfile.live-shell.test.ts \
--exclude=src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts \
--exclude=src/main/zsh-wrapper-version-mismatch.live-shell.test.ts \
--exclude=src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \
--exclude=src/shared/fish-query-reply-child-stdin.node-pty.test.ts \
--exclude=src/shared/pty-reply-echo-shapes.node-pty.test.ts \
--exclude=src/shared/startup-shell-portability.live-shell.test.ts \
--exclude=src/shared/posix-command-path-lookup.test.ts \
--exclude=tests/e2e/cross-version-wire/** \
--shard=${{ matrix.shard }}/${{ matrix.shard_total }}
@@ -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'
const ROOT = resolve(import.meta.dirname, '..', '..')
@@ -67,13 +69,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(
@@ -83,6 +88,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')
@@ -109,6 +166,7 @@ function main() {
if (!existsSync(PACKAGE_DIR)) {
throw new Error(`${PACKAGE_DIR} is missing. Run pnpm install first.`)
}
applyWindowsProcessTreeBuildFixes()
assertPatchApplied()
console.log(`[windows-process-tree] building ${arch} from ${PACKAGE_DIR}`)
+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', () => {
@@ -467,10 +467,11 @@ describe('Electron runtime package contract', () => {
expect(copyStep.run).toContain('git add "$CASK_PATH"')
})
it('installs the Electron package binary in PR checks without changing native module ABI', () => {
const prWorkflow = readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8')
const parsedWorkflow = parse(prWorkflow)
const installStep = parsedWorkflow.jobs.test.steps.find(
it('installs the Electron package binary in the shared unit-test workflow', () => {
const unitTestWorkflow = parse(
readFileSync(join(projectDir, '.github/workflows/unit-tests.yml'), 'utf8')
)
const installStep = unitTestWorkflow.jobs.test.steps.find(
(step) => step.name === 'Install Electron package binary for tests'
)
+5 -1
View File
@@ -281,7 +281,11 @@ describe('PR Checks skip wiring', () => {
expect(prWorkflow.jobs.test_native_cache.if).toBe(
"needs.code_paths.outputs.native_cache_changed == 'true'"
)
expect(prWorkflow.jobs.test_native_cache.strategy.matrix.node).toEqual(['24', '26'])
expect(prWorkflow.jobs.test_native_cache.strategy).toBeUndefined()
const primerInstall = prWorkflow.jobs.test_native_cache.steps.find(
(step) => step.uses === './.github/actions/install-node-dependencies'
)
expect(primerInstall.with['node-version']).toBe('24')
})
it('skips e2e detection on docs-only PRs without dropping the draft gate', () => {
+157 -2
View File
@@ -1,9 +1,14 @@
import { existsSync, readFileSync } from 'node:fs'
import { existsSync, readdirSync, readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { parse as parseJsonc } from 'jsonc-parser'
import { describe, expect, it } from 'vitest'
import { parse as parseYaml } from 'yaml'
import { PR_E2E_SOURCE_ROUTES, selectPrE2eSpecs } from './pr-e2e-source-routing.mjs'
import {
hasSshSourceChange,
PR_E2E_SOURCE_ROUTES,
selectPrE2eSpecs,
SSH_SOURCE_ROUTE_IDS
} from './pr-e2e-source-routing.mjs'
const projectDir = resolve(import.meta.dirname, '../..')
const prWorkflow = parseYaml(readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8'))
@@ -209,6 +214,8 @@ describe('PR E2E gate contract', () => {
'src/main/providers/ssh-',
'src/main/ipc/pty',
'src/relay/',
'src/shared/ssh-',
'src/renderer/src/store/slices/direct-ssh-',
'src/renderer/src/components/terminal-pane/remote-runtime-'
]
for (const authority of sshSourceAuthorities) {
@@ -217,10 +224,24 @@ describe('PR E2E gate contract', () => {
)
}
// Why named files rather than prefixes: these seams are single modules, and a prefix
// here would route their unrelated neighbours.
for (const file of [
'src/main/runtime/public-ssh-state.ts',
'src/renderer/src/startup/ssh-startup-reconnect.ts',
'src/renderer/src/store/slices/ssh.ts'
]) {
expect(selectPrE2eSpecs([file]), file).toContain(
'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts'
)
}
const mappedSpecs = [
'tests/e2e/pty-input-write-queue-ssh.spec.ts',
'tests/e2e/ssh-cold-activation-restore.spec.ts',
'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts',
'tests/e2e/ssh-port-forward-lifecycle.spec.ts',
'tests/e2e/ssh-reconnect-tab-destruction.spec.ts',
'tests/e2e/ssh-startup-exec-readiness.spec.ts',
'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts'
]
@@ -249,6 +270,140 @@ describe('PR E2E gate contract', () => {
expect(changedInstall.run).toContain('openssh-client')
})
it('routes direct-SSH workspace and tab restore from its unnamed source seams', () => {
// Why by name: none of these carry "ssh", so the SSH authorities above never reach them
// — a closed-tab tombstone and a dropped default-tabs marker both shipped through it.
for (const file of [
'src/renderer/src/hooks/remote-workspace-session-merge.ts',
'src/main/ipc/remote-workspace-snapshot-normalization.ts',
'src/renderer/src/lib/worktree-initial-terminal-seeding.ts',
'src/renderer/src/lib/worktree-default-terminal-tabs.ts',
'src/shared/remote-workspace-session-projection.ts',
'src/renderer/src/components/terminal/initial-terminal.ts'
]) {
const specs = selectPrE2eSpecs([file])
expect(specs, file).toContain('tests/e2e/ssh-cold-activation-restore.spec.ts')
expect(specs, file).toContain('tests/e2e/ssh-reconnect-tab-destruction.spec.ts')
}
expect(
selectPrE2eSpecs(['src/renderer/src/hooks/remote-workspace-session-merge.test.ts'])
).toEqual([])
})
it('triggers the Docker-SSH lane from SSH source, not from a spec name', () => {
// The behavioural half of the invariant, and the part that actually matters: an SSH source
// edit is recognised as one, through the same routes that select the specs.
for (const file of [
'src/main/ssh/connection.ts',
'src/relay/pty-handler.ts',
'src/renderer/src/store/slices/direct-ssh-pane-retry-ledger.ts',
'src/renderer/src/hooks/remote-workspace-session-merge.ts',
'src/main/ipc/remote-workspace-snapshot-normalization.ts'
]) {
expect(hasSshSourceChange([file]), file).toBe(true)
}
for (const file of [
'src/main/git/git-status.ts',
'src/renderer/src/components/tab-bar/BrowserTab.tsx',
'src/main/ssh/connection.test.ts'
]) {
expect(hasSshSourceChange([file]), file).toBe(false)
}
// Why: the signal must stay derived from the routes. A route id that no longer exists would
// silently narrow it to nothing.
for (const id of SSH_SOURCE_ROUTE_IDS) {
expect(
PR_E2E_SOURCE_ROUTES.map((route) => route.id),
id
).toContain(id)
}
// Why text and not structure: a job `if:` is only ever available as a string. The strongest
// available assertion is that the source signal is its own disjunct, so the lane no longer
// depends on a spec name surviving in a route's spec list.
const sshLaneCondition = e2eWorkflow.jobs['ssh-docker-watcher-isolation'].if
expect(sshLaneCondition).toContain("inputs.ssh_source_changed == 'true' ||")
expect(e2eWorkflow.on.workflow_call.inputs.ssh_source_changed.type).toBe('string')
expect(prWorkflow.jobs['e2e-paths'].outputs.ssh_source_changed).toBe(
'${{ steps.filter.outputs.ssh_source_changed }}'
)
expect(prWorkflow.jobs.e2e.with.ssh_source_changed).toBe(
'${{ needs.e2e-paths.outputs.ssh_source_changed }}'
)
expect(filterStep.run).toContain('pr-e2e-source-routing.mjs --ssh-source')
expect(filterStep.run).toContain('ssh_source_changed=$SSH_SOURCE_CHANGED')
})
it('gives every Docker-gated SSH spec a lane that runs it', () => {
// Why this shape: the sharded lanes set no ORCA_E2E_SSH_DOCKER, so a Docker-gated spec
// that no runner names runs nowhere and still reports green — the silent skip this file
// exists to prevent. Asserting reachability rather than a literal keeps that true when
// the lanes move.
// Why these two are exempt: each needs something CI cannot give it, recorded in
// run-ssh-docker-e2e.mjs so the gap stays legible rather than looking like coverage.
const unreachableSpecs = new Set([
'tests/e2e/ssh-docker-relay-perf.spec.ts',
'tests/e2e/ssh-codex-display-artifacts-repro.spec.ts',
'tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts'
])
// Why comments are stripped: this file's own runner lists the two exempt specs by name in a
// prose comment. A substring scan over raw text would count any spec merely *discussed* in a
// runner as claimed by it -- the silent skip this assertion exists to catch, re-entering
// through the documentation.
const stripComments = (text) =>
text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '')
const laneRunners = [
'run-ssh-docker-e2e.mjs',
'run-ssh-docker-watcher-isolation-e2e.mjs',
'run-ssh-docker-terminal-parking-e2e.mjs'
].map((file) => stripComments(readFileSync(join(projectDir, 'config/scripts', file), 'utf8')))
// Why a comparison and not the bare name: preview and demo specs cite the flag in a
// "how to run me" comment without gating on it. Why a regex rather than one literal: an
// equally-valid spelling (double quotes, or a `!==` guard) would escape a fixed-string scan
// and the spec would silently leave the contract.
const dockerGateExpression = /ORCA_E2E_SSH_DOCKER\s*[!=]==\s*['"]1['"]/
const dockerGatedSpecs = readdirSync(join(projectDir, 'tests/e2e'))
.filter((file) => file.endsWith('.spec.ts'))
.map((file) => `tests/e2e/${file}`)
.filter((spec) => dockerGateExpression.test(readFileSync(join(projectDir, spec), 'utf8')))
expect(dockerGatedSpecs.length).toBeGreaterThan(0)
const unclaimed = dockerGatedSpecs.filter(
(spec) => !unreachableSpecs.has(spec) && !laneRunners.some((runner) => runner.includes(spec))
)
expect(
unclaimed,
`Docker-gated specs claimed by no lane runner: ${unclaimed.join(', ')}`
).toEqual([])
// Why: an exemption that outlives its spec would quietly excuse a real gap.
for (const spec of unreachableSpecs) {
expect(dockerGatedSpecs, spec).toContain(spec)
// Why also assert absence from every runner: `unreachableSpecs` short-circuits the
// unclaimed check above, so a spec could be documented as exempt while a runner still
// invokes it -- an exemption that reads as coverage removal but changes nothing, and a
// lane that stays red for a reason the file says it excluded.
for (const runner of laneRunners) {
expect(runner.includes(spec), `${spec} is exempt but still invoked by a lane runner`).toBe(
false
)
}
}
const laneStep = e2eWorkflow.jobs['ssh-docker-watcher-isolation'].steps.find(
(step) => step.name === 'Run remaining Docker SSH E2E'
)
expect(laneStep.run).toContain('test:e2e:ssh-docker')
// Why: the added serial tests, several budgeting 4-10 minutes each, do not fit the old 35.
expect(
e2eWorkflow.jobs['ssh-docker-watcher-isolation']['timeout-minutes']
).toBeGreaterThanOrEqual(60)
})
it('scopes the VM rollback oracle to the PR range and recipe schema authorities', () => {
expect(rollbackStep.run).toContain('--merge-base "$BASE_SHA" "$HEAD_SHA"')
expect(rollbackStep.run).toContain('src/shared/ephemeral-vm-recipes.ts')
+39 -3
View File
@@ -18,12 +18,32 @@ export const PR_E2E_SOURCE_ROUTES = [
'tests/e2e/pty-input-write-queue-ssh.spec.ts',
'tests/e2e/ssh-cold-activation-restore.spec.ts',
'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts',
'tests/e2e/ssh-port-forward-lifecycle.spec.ts',
'tests/e2e/ssh-reconnect-tab-destruction.spec.ts',
'tests/e2e/ssh-startup-exec-readiness.spec.ts',
'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts'
],
// Why the store/startup/shared additions: the SSH-named authorities stop at the main
// process and the pane component, but the reconnect ledgers and retained-payload
// admission that decide whether a pane rebinds live in the renderer store.
matches: (file) =>
isProductSource(file) &&
/^(?:src\/main\/ssh\/|src\/main\/providers\/ssh-|src\/main\/ipc\/(?:ssh-|pty)|src\/relay\/|src\/renderer\/src\/components\/terminal-pane\/(?:pty-|ssh-|remote-runtime-|terminal-parked-pty))/.test(
/^(?:src\/main\/ssh\/|src\/main\/providers\/ssh-|src\/main\/ipc\/(?:ssh-|pty)|src\/main\/runtime\/(?:public-ssh-state|ssh-file-explorer-chunk-read)\.ts|src\/relay\/|src\/shared\/(?:ssh-|skill-ssh-relay-contract)|src\/renderer\/src\/startup\/(?:ssh-startup-reconnect|startup-ssh-connection-restore)\.ts|src\/renderer\/src\/store\/slices\/(?:ssh|direct-ssh-)|src\/renderer\/src\/components\/terminal-pane\/(?:pty-|ssh-|remote-runtime-|terminal-parked-pty))/.test(
file
)
},
{
// Why a sibling route rather than more paths on ssh-terminal-source: these modules carry
// no "ssh" in their names, and only the two restore specs gate them. Folding them in
// would run the whole SSH terminal list for a tab-tombstone edit.
id: 'ssh-workspace-session-restore',
specs: [
'tests/e2e/ssh-cold-activation-restore.spec.ts',
'tests/e2e/ssh-reconnect-tab-destruction.spec.ts'
],
matches: (file) =>
isProductSource(file) &&
/^(?:src\/main\/ipc\/remote-workspace|src\/shared\/remote-workspace-|src\/renderer\/src\/hooks\/remote-workspace-|src\/renderer\/src\/lib\/worktree-(?:initial-terminal-seeding|default-terminal-tabs)\.ts|src\/renderer\/src\/components\/terminal\/initial-terminal)/.test(
file
)
},
@@ -131,6 +151,18 @@ export function selectPrE2eSpecs(changedPaths, reportRoute = () => undefined) {
return [...specs].sort((left, right) => left.localeCompare(right))
}
/** Routes whose authorities are SSH execution source, and so require the Docker-SSH lane. */
export const SSH_SOURCE_ROUTE_IDS = ['ssh-terminal-source', 'ssh-workspace-session-restore']
// Why derive this from the routes instead of a second path list: the Docker-SSH lane used to
// trigger only because one route happened to list a startup-readiness spec, so pruning that
// spec would have silently retired the lane. Two lists that must agree is how that drifted.
export function hasSshSourceChange(changedPaths) {
return PR_E2E_SOURCE_ROUTES.filter((route) => SSH_SOURCE_ROUTE_IDS.includes(route.id)).some(
(route) => changedPaths.some(route.matches)
)
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
let input = ''
process.stdin.setEncoding('utf8')
@@ -138,6 +170,10 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
input += chunk
}
const changedPaths = input.split(/\r?\n/).filter(Boolean)
const specs = selectPrE2eSpecs(changedPaths, (message) => console.error(message))
process.stdout.write(`${JSON.stringify(specs)}\n`)
if (process.argv.includes('--ssh-source')) {
process.stdout.write(`${hasSshSourceChange(changedPaths)}\n`)
} else {
const specs = selectPrE2eSpecs(changedPaths, (message) => console.error(message))
process.stdout.write(`${JSON.stringify(specs)}\n`)
}
}
+24 -13
View File
@@ -3,6 +3,8 @@ import { parse } from 'yaml'
import { describe, expect, it } from 'vitest'
const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8'))
const unitTestWorkflow = parse(readFileSync('.github/workflows/unit-tests.yml', 'utf8'))
const nodeNextWorkflow = parse(readFileSync('.github/workflows/node-next-compat.yml', 'utf8'))
const dependencyAction = parse(
readFileSync('.github/actions/install-node-dependencies/action.yml', 'utf8')
)
@@ -47,28 +49,34 @@ describe('PR workflow parallelism', () => {
expect(workflow.permissions).toEqual({ contents: 'read' })
})
it('shards the general test suite across Node 24 and Node 26', () => {
expect(workflow.jobs.test.strategy.matrix.node).toEqual(['24', '26'])
expect(workflow.jobs.test.strategy.matrix.shard).toEqual(
Array.from({ length: 8 }, (_, index) => index + 1)
it('runs Node 24 on PRs and the same eight-shard suite on Node 26 daily', () => {
const sharedTest = unitTestWorkflow.jobs.test
const testStep = sharedTest.steps.find((step) => step.name === 'Test shard')
const installStep = sharedTest.steps.find(
(step) => step.uses === './.github/actions/install-node-dependencies'
)
expect(workflow.jobs.test.strategy.matrix.shard_total).toEqual([8])
const testStep = workflow.jobs.test.steps.find((step) => step.name === 'Test shard')
const installStep = workflow.jobs.test.steps.find(
const primerInstall = workflow.jobs.test_native_cache.steps.find(
(step) => step.uses === './.github/actions/install-node-dependencies'
)
expect(workflow.jobs.test.uses).toBe('./.github/workflows/unit-tests.yml')
expect(JSON.parse(workflow.jobs.test.with.node_versions)).toEqual(['24'])
expect(nodeNextWorkflow.jobs.test.uses).toBe('./.github/workflows/unit-tests.yml')
expect(JSON.parse(nodeNextWorkflow.jobs.test.with.node_versions)).toEqual(['26'])
expect(nodeNextWorkflow.on.schedule).toHaveLength(1)
expect(nodeNextWorkflow.on.workflow_dispatch).toBeNull()
expect(sharedTest.strategy.matrix.node).toBe('${{ fromJSON(inputs.node_versions) }}')
expect(sharedTest.strategy.matrix.shard).toEqual(
Array.from({ length: 8 }, (_, index) => index + 1)
)
expect(sharedTest.strategy.matrix.shard_total).toEqual([8])
expect(installStep.with['node-version']).toBe('${{ matrix.node }}')
expect(testStep.run).toContain('--shard=${{ matrix.shard }}/${{ matrix.shard_total }}')
for (const testFile of nativeShellContractFiles) {
expect(testStep.run).toContain(`--exclude=${testFile}`)
}
const primerInstall = workflow.jobs.test_native_cache.steps.find(
(step) => step.uses === './.github/actions/install-node-dependencies'
)
expect(workflow.jobs.test_native_cache.strategy.matrix.node).toEqual(['24', '26'])
expect(primerInstall.with['native-runtime']).toBe('node')
expect(primerInstall.with['node-version']).toBe('${{ matrix.node }}')
expect(primerInstall.with['node-version']).toBe('24')
expect(workflow.jobs.test.needs).toContain('test_native_cache')
})
@@ -241,13 +249,16 @@ describe('PR workflow parallelism', () => {
workflow.jobs[jobName].steps.find(
(step) => step.uses === './.github/actions/install-node-dependencies'
)
const sharedTestInstall = unitTestWorkflow.jobs.test.steps.find(
(step) => step.uses === './.github/actions/install-node-dependencies'
)
for (const jobName of ['typecheck', 'git_compatibility', 'xterm_patch_sync']) {
expect(installFor(jobName).with, jobName).toBeUndefined()
}
expect(installFor('static_analysis').with['native-runtime']).toBe('node')
expect(installFor('shell_contracts').with['native-runtime']).toBe('node')
expect(installFor('test').with['native-runtime']).toBe('node')
expect(sharedTestInstall.with['native-runtime']).toBe('node')
expect(installFor('package').with['native-runtime']).toBe('electron')
expect(installFor('package_windows').with['native-runtime']).toBe('node')
expect(installFor('package_windows').with['persist-native-cache']).toBe('false')
@@ -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')
+95
View File
@@ -0,0 +1,95 @@
import { spawnSync } from 'node:child_process'
const rawExtraArgs = process.argv.slice(2)
const extraArgs = rawExtraArgs[0] === '--' ? rawExtraArgs.slice(1) : rawExtraArgs
const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
const env = {
...process.env,
ORCA_E2E_SSH_DOCKER: '1',
ORCA_E2E_WEB_CLIENT: '1'
}
// Why: Node's CVE-2024-27980 hardening rejects .cmd spawns without shell on Windows.
const spawnOptions = {
stdio: 'inherit',
env,
shell: process.platform === 'win32'
}
const runtime = spawnSync(pnpm, ['run', 'ensure:electron-runtime'], spawnOptions)
if (runtime.status !== 0) {
process.exit(runtime.status ?? 1)
}
// Why one explicit list: these specs self-skip without ORCA_E2E_SSH_DOCKER and no sharded lane
// sets it, so a spec in no runner runs nowhere. The gate contract proves every flag-reading
// spec is claimed here, by the watcher-isolation or parking runner, or by a listed exclusion.
//
// Deliberately absent, and therefore still covered by no CI trigger:
// ssh-docker-relay-perf.spec.ts — wall-clock latency thresholds; flaky budgets here would
// cost the lane its credibility. NOTE: a runner script test:e2e:ssh-docker-perf exists in
// package.json but NO workflow invokes it, so this spec currently runs in no CI lane at
// all. Recorded as a real gap, not as coverage living somewhere else.
// ssh-codex-display-artifacts-repro.spec.ts — installs a real remote codex binary that CI
// runners do not have (observed as `spawn codex ENOENT`). Runs in no CI lane at all.
// ssh-docker-bulk-open-freeze-repro.spec.ts — two reasons, both disqualifying:
// (a) it is a perf oracle, not a correctness one: SOFT_FREEZE_LAG_MS=2500 /
// HARD_FREEZE_LAG_MS=5000 measured by a renderer lag probe under a deliberate
// 5-pane output flood on a 420s budget. Same rule as ssh-docker-relay-perf above.
// (b) it is ROTTED: four call sites are out of date against terminal.ts's current
// helpers — execInTerminal gained a ptyId parameter and splitActiveTerminalPane
// gained a direction, so it cannot compile, let alone pass. Repairing it needs two
// semantic decisions (which ptyId to capture, which split direction) that change
// what the repro measures. Tracked in stablyai/orca#16764.
//
// Why both projects: ssh-port-forward-lifecycle is @headful, which the headless project
// grep-inverts away.
//
// Known gaps in SSH e2e coverage, recorded here because nothing else names them:
// - The job that runs this is still called `ssh-docker-watcher-isolation`, though watcher
// isolation is now one spec of many. Renaming it changes the GitHub check name and can
// break required-check config, so the name understates the job on purpose.
// - E2E does not gate merges: `verify.needs` in pr.yml omits `e2e` while the suite is red on
// main. Nothing in this lane blocks a PR yet. pr.yml's Require-successful-checks comment
// has the exact wiring to flip it, and the gate contract asserts the current state.
// - Five specs and one unit test are gated on env vars no workflow sets, so they run nowhere
// and are not Docker-gated, which puts them outside this file's contract:
// local-ssh-browser-routing (ORCA_E2E_LOCAL_SSH_BROWSER)
// ssh-client-hosted-browser-drop-reconnect (ORCA_E2E_SSH_CLIENT_HOSTED_BROWSER)
// nested-runtime-ssh-lifecycle, nested-runtime-ssh-routing (ORCA_E2E_NESTED_RUNTIME_SSH)
// ssh-localhost (ORCA_E2E_SSH_LOCALHOST)
// ssh-browser-network-execution-route.docker.unit.test.ts (ORCA_RUN_DOCKER_SSH_BROWSER_E2E)
// Runner scripts for the first four sit unused in package.json; no workflow calls them.
const result = spawnSync(
pnpm,
[
'exec',
'playwright',
'test',
'tests/e2e/pty-input-write-queue-ssh.spec.ts',
'tests/e2e/ssh-ai-vault-session-history.spec.ts',
'tests/e2e/ssh-cold-activation-restore.spec.ts',
'tests/e2e/ssh-cold-hydration-gap-tab-seeding.spec.ts',
'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts',
'tests/e2e/ssh-external-image-preview.spec.ts',
'tests/e2e/ssh-lost-kill-tab-resurrection.spec.ts',
'tests/e2e/ssh-pi-compatible-agent-title.spec.ts',
'tests/e2e/ssh-port-forward-lifecycle.spec.ts',
'tests/e2e/ssh-reconnect-tab-destruction.spec.ts',
'tests/e2e/ssh-restart-tab-accumulation.spec.ts',
'tests/e2e/ssh-skill-installation.spec.ts',
'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts',
'--config',
'tests/playwright.config.ts',
'--project',
'electron-headless',
'--project',
'electron-headful',
'--workers=1',
...extraArgs
],
spawnOptions
)
process.exit(result.status ?? 1)
@@ -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;
+1
View File
@@ -111,6 +111,7 @@
"test:e2e:terminal-perf:html-report": "node config/scripts/generate-terminal-perf-html-report.mjs",
"test:e2e:ssh-docker-perf": "node config/scripts/run-ssh-docker-perf-e2e.mjs",
"test:e2e:ssh-docker-watcher-isolation": "node config/scripts/run-ssh-docker-watcher-isolation-e2e.mjs",
"test:e2e:ssh-docker": "node config/scripts/run-ssh-docker-e2e.mjs",
"test:e2e:ssh-client-hosted-browser": "node config/scripts/run-ssh-client-hosted-browser-drop-reconnect-e2e.mjs",
"test:e2e:local-ssh-browser": "node config/scripts/run-local-ssh-browser-routing-e2e.mjs",
"test:e2e:ssh-docker-terminal-parking": "node config/scripts/run-ssh-docker-terminal-parking-e2e.mjs",
+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
@@ -229,7 +229,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)
@@ -407,7 +407,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
@@ -9605,7 +9605,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
@@ -9638,7 +9638,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)
@@ -12046,7 +12046,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,77 @@
import { describe, expect, it } from 'vitest'
import { estimateCostUsd } from './claude-model-pricing'
describe('estimateCostUsd cache-write TTL rates', () => {
it('bills 5-minute cache writes at 1.25x base input', () => {
expect(estimateCostUsd('claude-opus-5', 0, 0, 0, 1_000_000, 0)).toBeCloseTo(6.25)
})
it('keeps the legacy five-argument call billing every write at the 5-minute rate', () => {
expect(estimateCostUsd('claude-opus-5', 0, 0, 0, 1_000_000)).toBeCloseTo(6.25)
})
it('bills 1-hour cache writes at 2x base input', () => {
expect(estimateCostUsd('claude-opus-5', 0, 0, 0, 1_000_000, 1_000_000)).toBeCloseTo(10)
})
it('splits a mixed write bucket without double-billing the 1-hour share', () => {
expect(estimateCostUsd('claude-opus-5', 0, 0, 0, 1_000_000, 400_000)).toBeCloseTo(7.75)
})
it('clamps a 1-hour count that exceeds the reported write total', () => {
expect(estimateCostUsd('claude-opus-5', 0, 0, 0, 1_000, 5_000)).toBeCloseTo(0.01)
})
it('applies the long-context tier to 1-hour writes', () => {
expect(estimateCostUsd('claude-sonnet-4-6', 0, 0, 0, 400_000, 400_000)).toBeCloseTo(3.6)
})
it('shares one long-context allowance across both TTL buckets', () => {
// 400k writes split evenly: 200k @ (3.75/7.5) and 200k @ (6/12), each tier
// getting half of the 200k allowance.
expect(estimateCostUsd('claude-sonnet-4-6', 0, 0, 0, 400_000, 200_000)).toBeCloseTo(2.925)
})
it('never lowers a long-context estimate as writes shift from 5-minute to 1-hour', () => {
const costs = [0, 50_000, 100_000, 200_000, 300_000, 400_000].map(
(write1h) => estimateCostUsd('claude-sonnet-4-6', 0, 0, 0, 400_000, write1h)!
)
for (let index = 1; index < costs.length; index++) {
expect(costs[index]).toBeGreaterThan(costs[index - 1])
}
})
it('still returns null for unknown models', () => {
expect(estimateCostUsd('gpt-5', 0, 0, 0, 1_000_000, 1_000_000)).toBeNull()
})
})
// Published Claude Sonnet 5 rates (platform.claude.com/docs/en/about-claude/pricing):
// $2 base input, $10 output, $0.20 cache hit, $2.50 5m write, $4 1h write per MTok.
// The $2/$10 launch price, announced as introductory through 2026-08-31, is now the
// standard price — the scheduled 2026-09-01 rise to $3/$15 was cancelled — so the rate
// is date-independent.
describe('estimateCostUsd Claude Sonnet 5 rates', () => {
it('bills base input at $2/MTok', () => {
expect(estimateCostUsd('claude-sonnet-5', 1_000_000, 0, 0, 0, 0)).toBeCloseTo(2)
})
it('bills output at $10/MTok', () => {
expect(estimateCostUsd('claude-sonnet-5', 0, 1_000_000, 0, 0, 0)).toBeCloseTo(10)
})
it('bills cache hits at $0.20/MTok', () => {
expect(estimateCostUsd('claude-sonnet-5', 0, 0, 1_000_000, 0, 0)).toBeCloseTo(0.2)
})
it('bills 5-minute cache writes at $2.50/MTok and 1-hour writes at $4/MTok', () => {
expect(estimateCostUsd('claude-sonnet-5', 0, 0, 0, 1_000_000, 0)).toBeCloseTo(2.5)
expect(estimateCostUsd('claude-sonnet-5', 0, 0, 0, 1_000_000, 1_000_000)).toBeCloseTo(4)
})
it('keeps the full 1M window at flat rates with no long-context tier', () => {
expect(estimateCostUsd('claude-sonnet-5', 1_000_000, 0, 0, 0, 0)).toBeCloseTo(
estimateCostUsd('claude-sonnet-5', 500_000, 0, 0, 0, 0)! * 2
)
})
})
+53 -19
View File
@@ -2,12 +2,16 @@ type ClaudeModelPricing = {
input: number
output: number
cacheRead: number
/** 5-minute TTL cache write (1.25x base input). */
cacheWrite: number
/** 1-hour TTL cache write (2x base input). */
cacheWrite1h: number
thresholdTokens?: number
inputAboveThreshold?: number
outputAboveThreshold?: number
cacheReadAboveThreshold?: number
cacheWriteAboveThreshold?: number
cacheWrite1hAboveThreshold?: number
}
const LONG_CONTEXT_THRESHOLD_TOKENS = 200_000
@@ -16,26 +20,29 @@ const SONNET_LONG_CONTEXT_PRICING = {
inputAboveThreshold: 6,
outputAboveThreshold: 22.5,
cacheReadAboveThreshold: 0.6,
cacheWriteAboveThreshold: 7.5
cacheWriteAboveThreshold: 7.5,
cacheWrite1hAboveThreshold: 12
} satisfies Partial<ClaudeModelPricing>
const MODEL_PRICING: Record<string, ClaudeModelPricing> = {
'claude-fable-5': { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 },
'claude-opus-5': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
'claude-fable-5': { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5, cacheWrite1h: 20 },
'claude-opus-5': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite1h: 10 },
// Why: Sonnet 5 bills its full 1M window at flat rates, so no long-context tier here.
// Why: standard rates, not the $2/$10 introductory rate ending 2026-08-31 — no date dimension.
'claude-sonnet-5': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
'claude-opus-4-8': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
'claude-opus-4-7': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
'claude-opus-4-6': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
'claude-opus-4-5': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
'claude-opus-4-1': { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 },
'claude-opus-4': { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 },
// Why: $2/$10 needs no date dimension — it launched as introductory pricing through
// 2026-08-31 but is now the standard price; the 2026-09-01 rise to $3/$15 was cancelled.
'claude-sonnet-5': { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5, cacheWrite1h: 4 },
'claude-opus-4-8': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite1h: 10 },
'claude-opus-4-7': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite1h: 10 },
'claude-opus-4-6': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite1h: 10 },
'claude-opus-4-5': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite1h: 10 },
'claude-opus-4-1': { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75, cacheWrite1h: 30 },
'claude-opus-4': { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75, cacheWrite1h: 30 },
'claude-sonnet-4-6': {
input: 3,
output: 15,
cacheRead: 0.3,
cacheWrite: 3.75,
cacheWrite1h: 6,
...SONNET_LONG_CONTEXT_PRICING
},
'claude-sonnet-4-5': {
@@ -43,6 +50,7 @@ const MODEL_PRICING: Record<string, ClaudeModelPricing> = {
output: 15,
cacheRead: 0.3,
cacheWrite: 3.75,
cacheWrite1h: 6,
...SONNET_LONG_CONTEXT_PRICING
},
'claude-sonnet-4': {
@@ -50,13 +58,20 @@ const MODEL_PRICING: Record<string, ClaudeModelPricing> = {
output: 15,
cacheRead: 0.3,
cacheWrite: 3.75,
cacheWrite1h: 6,
...SONNET_LONG_CONTEXT_PRICING
},
'claude-sonnet-3-7': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
'claude-sonnet-3-5': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
'claude-haiku-4-5': { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 },
'claude-haiku-3-5': { input: 0.8, output: 4, cacheRead: 0.08, cacheWrite: 1 },
'claude-haiku-3': { input: 0.25, output: 1.25, cacheRead: 0.03, cacheWrite: 0.3 }
'claude-sonnet-3-7': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75, cacheWrite1h: 6 },
'claude-sonnet-3-5': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75, cacheWrite1h: 6 },
'claude-haiku-4-5': { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25, cacheWrite1h: 2 },
'claude-haiku-3-5': { input: 0.8, output: 4, cacheRead: 0.08, cacheWrite: 1, cacheWrite1h: 1.6 },
'claude-haiku-3': {
input: 0.25,
output: 1.25,
cacheRead: 0.03,
cacheWrite: 0.3,
cacheWrite1h: 0.5
}
}
const MODEL_ALIASES: Record<string, string> = {
@@ -179,18 +194,31 @@ function calculateTieredCost(
return belowTokens * basePrice + aboveTokens * abovePrice
}
/**
* @param cacheWriteTokens every cache write, both TTLs
* @param cacheWrite1hTokens the 1-hour-TTL subset of `cacheWriteTokens`
*/
export function estimateCostUsd(
model: string | null,
inputTokens: number,
outputTokens: number,
cacheReadTokens: number,
cacheWriteTokens: number
cacheWriteTokens: number,
cacheWrite1hTokens = 0
): number | null {
const normalized = normalizeModelForPricing(model)
if (!normalized) {
return null
}
const pricing = MODEL_PRICING[normalized]
const write1hTokens = Math.min(Math.max(cacheWrite1hTokens, 0), cacheWriteTokens)
// Why: both TTL buckets share one long-context allowance. Giving each its own
// would make a split bucket cheaper than the same tokens billed entirely at 5m.
const write1hShare = cacheWriteTokens > 0 ? write1hTokens / cacheWriteTokens : 0
const write5mThreshold =
pricing.thresholdTokens === undefined ? undefined : pricing.thresholdTokens * (1 - write1hShare)
const write1hThreshold =
pricing.thresholdTokens === undefined ? undefined : pricing.thresholdTokens * write1hShare
return (
(calculateTieredCost(
inputTokens,
@@ -211,10 +239,16 @@ export function estimateCostUsd(
pricing.thresholdTokens
) +
calculateTieredCost(
cacheWriteTokens,
cacheWriteTokens - write1hTokens,
pricing.cacheWrite,
pricing.cacheWriteAboveThreshold,
pricing.thresholdTokens
write5mThreshold
) +
calculateTieredCost(
write1hTokens,
pricing.cacheWrite1h,
pricing.cacheWrite1hAboveThreshold,
write1hThreshold
)) /
1_000_000
)
@@ -106,6 +106,7 @@ export async function resolveAutomationRunUsage(
acc.outputTokens += entry.outputTokens
acc.cacheReadTokens += entry.cacheReadTokens
acc.cacheWriteTokens += entry.cacheWriteTokens
acc.cacheWrite1hTokens += entry.cacheWrite1hTokens
return acc
},
{
@@ -113,7 +114,8 @@ export async function resolveAutomationRunUsage(
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0
cacheWriteTokens: 0,
cacheWrite1hTokens: 0
}
)
const estimatedCostUsd = estimateCostUsd(
@@ -121,7 +123,8 @@ export async function resolveAutomationRunUsage(
totals.inputTokens,
totals.outputTokens,
totals.cacheReadTokens,
totals.cacheWriteTokens
totals.cacheWriteTokens,
totals.cacheWrite1hTokens
)
return {
@@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest'
import type { ClaudeUsageDailyAggregate, ClaudeUsagePersistedState } from './types'
import { buildSummary } from './claude-usage-report-aggregation'
function dailyRow(day: string, model: string): ClaudeUsageDailyAggregate {
return {
day,
model,
projectKey: 'project',
projectLabel: 'project',
repoId: null,
worktreeId: 'worktree-1',
turnCount: 1,
zeroCacheReadTurnCount: 0,
inputTokens: 1_000_000,
outputTokens: 1_000_000,
cacheReadTokens: 0,
cacheWriteTokens: 0,
cacheWrite1hTokens: 0
}
}
function stateWithDaily(dailyAggregates: ClaudeUsageDailyAggregate[]): ClaudeUsagePersistedState {
return {
schemaVersion: 6,
worktreeFingerprint: null,
processedFiles: [],
sessions: [],
dailyAggregates,
scanState: {
enabled: true,
lastScanStartedAt: null,
lastScanCompletedAt: null,
lastScanError: null
}
}
}
describe('buildSummary Claude Sonnet 5 pricing over time', () => {
// Why: the "All" range re-prices historical days on every render, so a day inside the
// announced 2026-08-31 introductory window has to price the same as a later day —
// the scheduled $3/$15 increase was cancelled and never took effect.
it('prices an introductory-window day and a post-window day at the same $2/$10 rate', () => {
const inWindow = buildSummary(
stateWithDaily([dailyRow('2026-08-15', 'claude-sonnet-5')]),
'all',
'all'
)
const afterWindow = buildSummary(
stateWithDaily([dailyRow('2026-09-15', 'claude-sonnet-5')]),
'all',
'all'
)
expect(inWindow.estimatedCostUsd).toBeCloseTo(12)
expect(afterWindow.estimatedCostUsd).toBeCloseTo(12)
})
it('sums both days in the All range without re-pricing history upward', () => {
const summary = buildSummary(
stateWithDaily([
dailyRow('2026-08-15', 'claude-sonnet-5'),
dailyRow('2026-09-15', 'claude-sonnet-5')
]),
'all',
'all'
)
expect(summary.estimatedCostUsd).toBeCloseTo(24)
})
})
@@ -47,7 +47,8 @@ export function buildSummary(
row.inputTokens,
row.outputTokens,
row.cacheReadTokens,
row.cacheWriteTokens
row.cacheWriteTokens,
row.cacheWrite1hTokens
)
if (cost !== null) {
hasAnyBillableCost = true
@@ -111,6 +112,8 @@ export function buildBreakdown(
kind: ClaudeUsageBreakdownKind
): ClaudeUsageBreakdownRow[] {
const rows = new Map<string, ClaudeUsageBreakdownRow>()
// Why: the 1h cache-write split only feeds pricing, so it stays off the renderer row type.
const cacheWrite1hTokensByKey = new Map<string, number>()
const filteredDaily = getFilteredDaily(state, scope, range)
const filteredSessions = getFilteredSessions(state, scope, range)
@@ -133,6 +136,10 @@ export function buildBreakdown(
existing.outputTokens += daily.outputTokens
existing.cacheReadTokens += daily.cacheReadTokens
existing.cacheWriteTokens += daily.cacheWriteTokens
cacheWrite1hTokensByKey.set(
key,
(cacheWrite1hTokensByKey.get(key) ?? 0) + daily.cacheWrite1hTokens
)
rows.set(key, existing)
}
@@ -168,7 +175,8 @@ export function buildBreakdown(
row.inputTokens,
row.outputTokens,
row.cacheReadTokens,
row.cacheWriteTokens
row.cacheWriteTokens,
cacheWrite1hTokensByKey.get(row.key) ?? 0
)
}
}

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