mirror of
https://github.com/stablyai/orca.git
synced 2026-10-24 16:03:45 +00:00
Merge branch 'main' into brennanb2025/sta-3515-rss-harness-fix
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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': {
|
||||
|
||||
+506
-110
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}`)
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -55,7 +55,7 @@ export function formatEndpoint(endpoint: string): string {
|
||||
const url = new URL(endpoint)
|
||||
return url.host
|
||||
} catch {
|
||||
return endpoint
|
||||
return 'invalid endpoint'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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): {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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' }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+9
-9
@@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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
Reference in New Issue
Block a user