Merge remote-tracking branch 'origin/main' into brennanb2025/sta-3919-mobile-picker-lag

This commit is contained in:
Brennan Benson
2026-08-27 16:36:45 -07:00
347 changed files with 14885 additions and 21444 deletions
@@ -1,5 +1,5 @@
name: Install Node dependencies
description: Installs the Node toolchain and repository dependencies for Linux CI jobs.
description: Installs the Node toolchain and repository dependencies for CI jobs.
inputs:
native-runtime:
@@ -10,6 +10,18 @@ inputs:
description: Node.js version override; defaults to the version declared in package.json.
required: false
default: ''
persist-native-cache:
description: Save restored native modules at job end. Set false when a later step overwrites the same path with a different ABI.
required: false
default: 'true'
outputs:
node-version:
description: Resolved Node.js version used for the install.
value: ${{ steps.requested-node.outputs.node-version || steps.default-node.outputs.node-version }}
native-cache-scope:
description: Operating-system image scope used by the native module cache.
value: ${{ steps.native-cache-scope.outputs.scope }}
runs:
using: composite
@@ -51,7 +63,7 @@ runs:
# pnpm's bundled gyp_main.py is not executable on fresh Linux runners.
- name: Use external node-gyp
if: inputs.native-runtime != 'none'
if: runner.os == 'Linux' && inputs.native-runtime != 'none'
shell: bash
run: |
npm install -g node-gyp@11.5.0
@@ -80,12 +92,42 @@ runs:
# ensure-native-runtime node-gyp-compiles it in every job that asks for a runtime.
# The artifacts are ABI-bound, so the key carries the target runtime, the resolved
# Node version, and the patch whose contents the build has to match.
- name: Restore compiled native modules
# Windows extra globs are empty on Linux. No restore-keys: a partial-match key is
# an ABI-mismatched build, and ensure-native-runtime would recompile it anyway.
# Native addons built on a newer Linux image can require glibc symbols
# missing from an older runner/container. ImageOS distinguishes hosted
# Windows/macOS images; /etc/os-release also distinguishes Linux containers.
- name: Resolve native cache scope
id: native-cache-scope
if: inputs.native-runtime != 'none'
shell: bash
run: |
scope="${ImageOS:-$RUNNER_OS}"
if [ -r /etc/os-release ]; then
. /etc/os-release
scope="${ID:-linux}-${VERSION_ID:-unknown}"
fi
echo "scope=$scope" >> "$GITHUB_OUTPUT"
- name: Restore compiled native modules
if: inputs.native-runtime != 'none' && inputs.persist-native-cache != 'false'
uses: actions/cache@v5
with:
path: node_modules/.pnpm/node-pty@*/node_modules/node-pty/build
key: native-modules-${{ runner.os }}-${{ runner.arch }}-${{ inputs.native-runtime }}-node${{ steps.default-node.outputs.node-version }}${{ steps.requested-node.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', 'config/patches/node-pty@1.1.0.patch') }}
path: |
node_modules/.pnpm/node-pty@*/node_modules/node-pty/build
node_modules/.pnpm/windows-native-registry@*/node_modules/windows-native-registry/build
node_modules/.pnpm/@vscode+windows-process-tree@*/node_modules/@vscode/windows-process-tree/build
key: native-modules-${{ runner.os }}-${{ steps.native-cache-scope.outputs.scope }}-${{ runner.arch }}-${{ inputs.native-runtime }}-node${{ steps.default-node.outputs.node-version }}${{ steps.requested-node.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch') }}
- name: Restore compiled native modules without saving
if: inputs.native-runtime != 'none' && inputs.persist-native-cache == 'false'
uses: actions/cache/restore@v5
with:
path: |
node_modules/.pnpm/node-pty@*/node_modules/node-pty/build
node_modules/.pnpm/windows-native-registry@*/node_modules/windows-native-registry/build
node_modules/.pnpm/@vscode+windows-process-tree@*/node_modules/@vscode/windows-process-tree/build
key: native-modules-${{ runner.os }}-${{ steps.native-cache-scope.outputs.scope }}-${{ runner.arch }}-${{ inputs.native-runtime }}-node${{ steps.default-node.outputs.node-version }}${{ steps.requested-node.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch') }}
- name: Prepare native runtime
if: inputs.native-runtime != 'none'
+5 -22
View File
@@ -71,30 +71,17 @@ jobs:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: actions/setup-node@v6
with:
node-version-file: package.json
- uses: pnpm/action-setup@v6
with:
run_install: false
- if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y python3 python3-gi gir1.2-atspi-2.0 at-spi2-core gedit xvfb xclip xdotool
# Why: pnpm's bundled node-gyp can ship gyp_main.py without execute
# permission on Linux runners; node-pty's install fallback then fails
# before this smoke job can exercise the native package.
- name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
if: runner.os == 'Linux'
run: |
npm install -g node-gyp@11.5.0
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
- run: pnpm install --frozen-lockfile
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: node
# Why: without --config, bare vitest ignores config/vitest.config.ts (there
# is no root config) and falls back to the 5s default timeout with no
# Windows worker cap, so the real csc.exe launcher-compile tests time out
# on hosted Windows. Use the shared config so this job matches pnpm test.
- run: >-
pnpm vitest run --config config/vitest.config.ts
config/scripts/build-windows-cli-launcher.test.mjs
src/main/ssh/ssh-remote-cli-launcher.test.ts
config/scripts/computer-e2e-workflow.test.mjs
config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs
@@ -159,13 +146,9 @@ jobs:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: actions/setup-node@v6
- uses: ./.github/actions/install-node-dependencies
with:
node-version-file: package.json
- uses: pnpm/action-setup@v6
with:
run_install: false
- run: pnpm install --frozen-lockfile
native-runtime: electron
- name: Owner-loss benchmark process cleanup
run: >-
pnpm vitest run
+13 -100
View File
@@ -40,36 +40,9 @@ jobs:
with:
ref: ${{ inputs.ref || github.ref }}
# Why: the E2E build compiles native modules via node-gyp. Mirrors the
# install step in pr.yml's verify job so E2E doesn't hit missing-toolchain
# errors.
- name: Install native build tools
run: sudo apt-get update && sudo apt-get install -y build-essential python3
# Why pnpm first: setup-node needs pnpm on PATH to locate the store it caches.
# Without that cache every E2E job re-downloaded the whole dependency set.
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
# Why: this job runs the same pnpm install path as pr.yml's verify
# job, so it needs the same pinned node-gyp override to avoid pnpm's
# broken bundled gyp_main.py on Linux.
- name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
if: runner.os == 'Linux'
run: |
npm install -g node-gyp@11.5.0
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Why no native-runtime: this job only produces JS bundles. Native modules
# are prepared in the consumer shards that actually launch Electron.
- uses: ./.github/actions/install-node-dependencies
# Why: building here avoids parallel builds inside Playwright globalSetup;
# paired-browser specs also need the standalone web bundle.
@@ -130,11 +103,11 @@ jobs:
with:
ref: ${{ inputs.ref || github.ref }}
# Why: pnpm install rebuilds native modules, and those postinstall
# scripts still need the Linux toolchain even though this shard reuses
# the prebuilt Electron output.
# Why: pnpm install used to rebuild native modules here; the composite
# action restores them from cache and only compiles on a miss. The
# toolchain is still required for that miss path, and for paired Quick
# Open coverage which exercises the resource-bounded host search.
- name: Install native build tools
# Why: paired Quick Open coverage exercises the resource-bounded host search.
run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk python3 ripgrep zsh
# Why: Electron on Linux needs an X display even when the app
@@ -143,33 +116,9 @@ jobs:
- name: Install xvfb
run: sudo apt-get install -y xvfb
# Why pnpm first: setup-node needs pnpm on PATH to locate the store it caches.
# Without that cache every E2E job re-downloaded the whole dependency set.
- name: Setup pnpm
uses: pnpm/action-setup@v6
- uses: ./.github/actions/install-node-dependencies
with:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
# Why: this job runs the same pnpm install path as pr.yml's verify
# job, so it needs the same pinned node-gyp override to avoid pnpm's
# broken bundled gyp_main.py on Linux. Gate on runner.os matches
# release.yml so the invariant "this workaround is Linux-only" is
# consistent across all three workflows, even though this job
# currently pins runs-on: ubuntu-latest.
- name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
if: runner.os == 'Linux'
run: |
npm install -g node-gyp@11.5.0
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
- name: Install dependencies
run: pnpm install --frozen-lockfile
native-runtime: electron
- name: Download E2E build output
uses: actions/download-artifact@v8
@@ -222,25 +171,9 @@ jobs:
# lane now receives those specs from pr.yml's SSH source mapping.
run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk openssh-client python3 ripgrep xvfb zsh
# Why pnpm first: setup-node needs pnpm on PATH to locate the store it caches.
# Without that cache every E2E job re-downloaded the whole dependency set.
- name: Setup pnpm
uses: pnpm/action-setup@v6
- uses: ./.github/actions/install-node-dependencies
with:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
- name: Use external node-gyp to avoid pnpm's bundled copy
run: |
npm install -g node-gyp@11.5.0
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
- name: Install dependencies
run: pnpm install --frozen-lockfile
native-runtime: electron
- name: Download E2E build output
uses: actions/download-artifact@v8
@@ -305,29 +238,9 @@ jobs:
- name: Install native build and headless UI tools
run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk openssh-client python3 xvfb zsh
# Why pnpm first: setup-node needs pnpm on PATH to locate the store it caches.
# Without that cache every E2E job re-downloaded the whole dependency set.
- name: Setup pnpm
uses: pnpm/action-setup@v6
- uses: ./.github/actions/install-node-dependencies
with:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
# Why: same Linux-only node-gyp pin as build/e2e jobs so the workaround
# stays consistent across workflows even while this job is ubuntu-latest.
- name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
if: runner.os == 'Linux'
run: |
npm install -g node-gyp@11.5.0
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
- name: Install dependencies
run: pnpm install --frozen-lockfile
native-runtime: electron
- name: Download E2E build output
uses: actions/download-artifact@v8
+107 -77
View File
@@ -16,15 +16,28 @@ permissions:
contents: read
jobs:
# Why: a README/docs-only PR used to start the full matrix (32 test shards,
# Why: a README/docs-only PR used to start the full matrix (test shards,
# two package jobs, typecheck, git compat, xterm, shell contracts). Path
# filters on `on.pull_request` would drop the `verify` check entirely; this
# detector keeps verify as the required aggregate and skips the expensive jobs.
# Per-job outputs also skip git-compat/xterm/packaging/shell when those
# inputs are unchanged; empty diffs fail closed and run everything.
code_paths:
name: detect code-relevant changes
runs-on: ubuntu-latest
outputs:
should_run: ${{ steps.filter.outputs.should_run }}
static_analysis: ${{ steps.filter.outputs.static_analysis }}
typecheck: ${{ steps.filter.outputs.typecheck }}
git_compatibility: ${{ steps.filter.outputs.git_compatibility }}
xterm_patch_sync: ${{ steps.filter.outputs.xterm_patch_sync }}
shell_contracts: ${{ steps.filter.outputs.shell_contracts }}
test: ${{ steps.filter.outputs.test }}
orcad_browser: ${{ steps.filter.outputs.orcad_browser }}
cross-version-wire: ${{ steps.filter.outputs.cross-version-wire }}
managed_hook_node18: ${{ steps.filter.outputs.managed_hook_node18 }}
package: ${{ steps.filter.outputs.package }}
package_windows: ${{ steps.filter.outputs.package_windows }}
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -48,14 +61,12 @@ jobs:
CHANGED="$(git diff --name-only --no-renames --diff-filter=ACDMR --merge-base "$BASE_SHA" "$HEAD_SHA")"
echo "Changed paths:"
printf '%s\n' "$CHANGED"
SHOULD_RUN="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-code-change-scope.mjs)"
echo "should_run=$SHOULD_RUN" >> "$GITHUB_OUTPUT"
echo "should_run=$SHOULD_RUN"
printf '%s\n' "$CHANGED" | node config/scripts/pr-code-change-scope.mjs | tee -a "$GITHUB_OUTPUT"
static_analysis:
name: static analysis
needs: [code_paths]
if: needs.code_paths.outputs.should_run == 'true'
if: needs.code_paths.outputs.static_analysis == 'true'
runs-on: ubuntu-latest
steps:
@@ -70,6 +81,8 @@ jobs:
persist-credentials: false
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: node
- name: Lint
run: pnpm exec oxlint --format github
@@ -188,7 +201,7 @@ jobs:
typecheck:
needs: [code_paths]
if: needs.code_paths.outputs.should_run == 'true'
if: needs.code_paths.outputs.typecheck == 'true'
runs-on: ubuntu-latest
steps:
@@ -216,7 +229,7 @@ jobs:
git_compatibility:
name: Git compatibility
needs: [code_paths]
if: needs.code_paths.outputs.should_run == 'true'
if: needs.code_paths.outputs.git_compatibility == 'true'
runs-on: ubuntu-latest
steps:
@@ -283,7 +296,7 @@ jobs:
xterm_patch_sync:
name: xterm patch sync
needs: [code_paths]
if: needs.code_paths.outputs.should_run == 'true'
if: needs.code_paths.outputs.xterm_patch_sync == 'true'
runs-on: ubuntu-latest
steps:
@@ -313,7 +326,7 @@ jobs:
shell_contracts:
name: shell contracts
needs: [code_paths]
if: needs.code_paths.outputs.should_run == 'true'
if: needs.code_paths.outputs.shell_contracts == 'true'
runs-on: ubuntu-latest
# Why: this job's cost is almost entirely package download, and a stalled mirror has
# no wall-clock bound of its own. A successful run finishes in ~4.5 minutes, so this
@@ -432,14 +445,14 @@ jobs:
test:
name: tests node ${{ matrix.node }} ${{ matrix.shard }}/${{ matrix.shard_total }}
needs: [code_paths]
if: needs.code_paths.outputs.should_run == 'true'
if: needs.code_paths.outputs.test == 'true'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node: ['24', '26']
shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
shard_total: [16]
shard: [1, 2, 3, 4, 5, 6, 7, 8]
shard_total: [8]
steps:
- name: Checkout
@@ -477,12 +490,12 @@ jobs:
--exclude=tests/e2e/cross-version-wire/** \
--shard=${{ matrix.shard }}/${{ matrix.shard_total }}
# Why a separate job: the test needs a real Chrome, and the 32-way `test` matrix
# would pay for it 32 times to run one file in whichever shard it landed in.
# 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.
orcad_browser:
name: orcad browser provider
needs: [code_paths]
if: needs.code_paths.outputs.should_run == 'true'
if: needs.code_paths.outputs.orcad_browser == 'true'
runs-on: ubuntu-latest
steps:
@@ -518,7 +531,7 @@ jobs:
cross-version-wire:
name: cross-version wire compatibility
needs: [code_paths]
if: needs.code_paths.outputs.should_run == 'true'
if: needs.code_paths.outputs.cross-version-wire == 'true'
runs-on: ubuntu-latest
steps:
@@ -550,7 +563,7 @@ jobs:
managed_hook_node18:
name: managed hooks on Node 18
needs: [code_paths]
if: needs.code_paths.outputs.should_run == 'true'
if: needs.code_paths.outputs.managed_hook_node18 == 'true'
runs-on: ubuntu-latest
steps:
@@ -575,7 +588,7 @@ jobs:
package:
name: package
needs: [code_paths]
if: needs.code_paths.outputs.should_run == 'true'
if: needs.code_paths.outputs.package == 'true'
runs-on: ubuntu-latest
steps:
@@ -647,7 +660,7 @@ jobs:
package_windows:
name: package (windows)
needs: [code_paths]
if: needs.code_paths.outputs.should_run == 'true'
if: needs.code_paths.outputs.package_windows == 'true'
runs-on: windows-2022
timeout-minutes: 30
@@ -657,17 +670,6 @@ jobs:
with:
persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
- name: Cache electron-builder downloads
uses: actions/cache@v5
with:
@@ -678,18 +680,22 @@ jobs:
restore-keys: |
electron-builder-windows-
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Why persist-native-cache false: this job later rebuilds the same path for
# Electron. A post-job save would store the Electron ABI under the Node key.
- uses: ./.github/actions/install-node-dependencies
id: deps
with:
native-runtime: node
persist-native-cache: 'false'
# Why: node-pty prefers its upstream prebuild, which does not contain
# Orca's Windows patch, so the job-object exports would be absent and the
# suite below would test an unpatched binary. build_from_source removes
# the prebuild, and the package's postinstall restores the ConPTY runtime
# files that a bare node-gyp rebuild would miss.
- name: Rebuild node-pty from patched source
env:
npm_config_build_from_source: 'true'
run: pnpm rebuild node-pty
- name: Save compiled Node native modules
uses: actions/cache/save@v5
with:
path: |
node_modules/.pnpm/node-pty@*/node_modules/node-pty/build
node_modules/.pnpm/windows-native-registry@*/node_modules/windows-native-registry/build
node_modules/.pnpm/@vscode+windows-process-tree@*/node_modules/@vscode/windows-process-tree/build
key: native-modules-${{ runner.os }}-${{ steps.deps.outputs.native-cache-scope }}-${{ runner.arch }}-node-node${{ steps.deps.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch') }}
- name: Test Windows-specific boundaries
run: >-
@@ -724,9 +730,26 @@ jobs:
# Why the :parallel variant: identical to build:release except the three
# electron-vite targets overlap instead of running back to back. The Linux package
# job already packages and smoke-tests an AppImage built that way.
- name: Cache Windows CLI launcher
uses: actions/cache@v5
with:
path: native/windows-cli-launcher/.build
key: windows-cli-launcher-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('native/windows-cli-launcher/**', 'config/scripts/build-windows-cli-launcher.mjs') }}
- name: Build package inputs
env:
ORCA_REUSE_WINDOWS_CLI_LAUNCHER: '1'
run: pnpm run build:release:parallel
- name: Restore compiled Electron native modules
uses: actions/cache@v5
with:
path: |
node_modules/.pnpm/node-pty@*/node_modules/node-pty/build
node_modules/.pnpm/windows-native-registry@*/node_modules/windows-native-registry/build
node_modules/.pnpm/@vscode+windows-process-tree@*/node_modules/@vscode/windows-process-tree/build
key: native-modules-${{ runner.os }}-${{ steps.deps.outputs.native-cache-scope }}-${{ runner.arch }}-electron-node${{ steps.deps.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch') }}
- name: Prepare Electron native runtime
run: node config/scripts/ensure-native-runtime.mjs --runtime=electron
@@ -807,6 +830,7 @@ jobs:
- shell_contracts
- test
- orcad_browser
- cross-version-wire
- managed_hook_node18
- package
- package_windows
@@ -826,57 +850,63 @@ jobs:
CODE_PATHS: ${{ needs.code_paths.result }}
SHOULD_RUN: ${{ needs.code_paths.outputs.should_run }}
STATIC_ANALYSIS: ${{ needs.static_analysis.result }}
STATIC_ANALYSIS_SHOULD_RUN: ${{ needs.code_paths.outputs.static_analysis }}
ROOT_DIRECTORY_GUARD: ${{ needs.root_directory_guard.result }}
TYPECHECK: ${{ needs.typecheck.result }}
TYPECHECK_SHOULD_RUN: ${{ needs.code_paths.outputs.typecheck }}
GIT_COMPATIBILITY: ${{ needs.git_compatibility.result }}
GIT_COMPATIBILITY_SHOULD_RUN: ${{ needs.code_paths.outputs.git_compatibility }}
XTERM_PATCH_SYNC: ${{ needs.xterm_patch_sync.result }}
XTERM_PATCH_SYNC_SHOULD_RUN: ${{ needs.code_paths.outputs.xterm_patch_sync }}
SHELL_CONTRACTS: ${{ needs.shell_contracts.result }}
SHELL_CONTRACTS_SHOULD_RUN: ${{ needs.code_paths.outputs.shell_contracts }}
TEST: ${{ needs.test.result }}
TEST_SHOULD_RUN: ${{ needs.code_paths.outputs.test }}
ORCAD_BROWSER: ${{ needs.orcad_browser.result }}
ORCAD_BROWSER_SHOULD_RUN: ${{ needs.code_paths.outputs.orcad_browser }}
CROSS_VERSION_WIRE: ${{ needs.cross-version-wire.result }}
CROSS_VERSION_WIRE_SHOULD_RUN: ${{ needs.code_paths.outputs.cross-version-wire }}
MANAGED_HOOK_NODE18: ${{ needs.managed_hook_node18.result }}
MANAGED_HOOK_NODE18_SHOULD_RUN: ${{ needs.code_paths.outputs.managed_hook_node18 }}
PACKAGE: ${{ needs.package.result }}
PACKAGE_SHOULD_RUN: ${{ needs.code_paths.outputs.package }}
PACKAGE_WINDOWS: ${{ needs.package_windows.result }}
PACKAGE_WINDOWS_SHOULD_RUN: ${{ needs.code_paths.outputs.package_windows }}
run: |
if [ "$CODE_PATHS" != "success" ]; then
exit 1
fi
if [ "$ROOT_DIRECTORY_GUARD" != "success" ]; then
exit 1
fi
if [ "$SHOULD_RUN" != "true" ]; then
echo "Docs-only change; expensive PR checks skipped."
if [ "$ROOT_DIRECTORY_GUARD" != "success" ]; then
exit 1
fi
for result in \
"$STATIC_ANALYSIS" \
"$TYPECHECK" \
"$GIT_COMPATIBILITY" \
"$XTERM_PATCH_SYNC" \
"$SHELL_CONTRACTS" \
"$TEST" \
"$ORCAD_BROWSER" \
"$MANAGED_HOOK_NODE18" \
"$PACKAGE" \
"$PACKAGE_WINDOWS"; do
if [ "$result" != "skipped" ]; then
exit 1
fi
done
exit 0
fi
# Require success when the PR has code-relevant changes
for result in \
"$CODE_PATHS" \
"$STATIC_ANALYSIS" \
"$ROOT_DIRECTORY_GUARD" \
"$TYPECHECK" \
"$GIT_COMPATIBILITY" \
"$XTERM_PATCH_SYNC" \
"$SHELL_CONTRACTS" \
"$TEST" \
"$ORCAD_BROWSER" \
"$MANAGED_HOOK_NODE18" \
"$PACKAGE" \
"$PACKAGE_WINDOWS"; do
if [ "$result" != "success" ]; then
exit 1
failed=0
check_job() {
local name="$1" result="$2" should="$3"
if [ "$should" = "true" ]; then
if [ "$result" != "success" ]; then
echo "$name: expected success, got $result"
failed=1
fi
else
if [ "$result" != "skipped" ]; then
echo "$name: expected skipped, got $result"
failed=1
fi
fi
done
}
# Require success when the PR has code-relevant changes
check_job static_analysis "$STATIC_ANALYSIS" "$STATIC_ANALYSIS_SHOULD_RUN"
check_job typecheck "$TYPECHECK" "$TYPECHECK_SHOULD_RUN"
check_job git_compatibility "$GIT_COMPATIBILITY" "$GIT_COMPATIBILITY_SHOULD_RUN"
check_job xterm_patch_sync "$XTERM_PATCH_SYNC" "$XTERM_PATCH_SYNC_SHOULD_RUN"
check_job shell_contracts "$SHELL_CONTRACTS" "$SHELL_CONTRACTS_SHOULD_RUN"
check_job test "$TEST" "$TEST_SHOULD_RUN"
check_job orcad_browser "$ORCAD_BROWSER" "$ORCAD_BROWSER_SHOULD_RUN"
check_job cross-version-wire "$CROSS_VERSION_WIRE" "$CROSS_VERSION_WIRE_SHOULD_RUN"
check_job managed_hook_node18 "$MANAGED_HOOK_NODE18" "$MANAGED_HOOK_NODE18_SHOULD_RUN"
check_job package "$PACKAGE" "$PACKAGE_SHOULD_RUN"
check_job package_windows "$PACKAGE_WINDOWS" "$PACKAGE_WINDOWS_SHOULD_RUN"
exit "$failed"
-25
View File
@@ -2,8 +2,6 @@
# This is a RATCHET: the list may only SHRINK. Do NOT add entries to get CI green —
# split the oversized file instead (AGENTS.md → "Do Not Disable Max Lines").
# Regenerate/prune: pnpm check:max-lines-ratchet --prune (removes stale entries only)
inline src/cli/handlers/automations.ts
inline src/cli/help.ts
inline src/main/agent-hooks/server.ts
inline src/main/automations/external-manager.ts
inline src/main/automations/hermes-cron-output.ts
@@ -12,7 +10,6 @@ inline src/main/browser/browser-cookie-import.ts
inline src/main/browser/browser-manager.ts
inline src/main/browser/cdp-bridge.ts
inline src/main/claude-accounts/runtime-auth-service.ts
inline src/main/cli/wsl-cli-installer.ts
inline src/main/codex-accounts/runtime-home-service.ts
inline src/main/codex-accounts/service.ts
inline src/main/codex/hook-service.ts
@@ -23,14 +20,8 @@ inline src/main/index.ts
inline src/main/ipc/filesystem-watcher.ts
inline src/main/ipc/filesystem.ts
inline src/main/ipc/worktree-remote.ts
inline src/main/keybindings/keybinding-file.ts
inline src/main/linear/issues.ts
inline src/main/memory/collector.ts
inline src/main/ports/advertised-url-watcher.ts
inline src/main/ports/local-workspace-port-scanner.ts
inline src/main/project-groups/nested-repo-discovery.ts
inline src/main/providers/local-pty-provider.ts
inline src/main/rate-limits/claude-pty.ts
inline src/main/rate-limits/service.ts
inline src/main/runtime/orca-runtime-browser.ts
inline src/main/runtime/orca-runtime-files.ts
@@ -69,7 +60,6 @@ inline src/renderer/src/components/right-sidebar/PortsPanel.tsx
inline src/renderer/src/components/settings/AccountsPane.tsx
inline src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx
inline src/renderer/src/components/settings/Settings.tsx
inline src/renderer/src/components/sidebar/use-workspace-kanban-area-selection.ts
inline src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx
inline src/renderer/src/components/status-bar/StatusBar.tsx
inline src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx
@@ -82,40 +72,25 @@ inline src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts
inline src/renderer/src/lib/pane-manager/pane-tree-ops.ts
inline src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts
inline src/renderer/src/runtime/runtime-file-client.ts
inline src/renderer/src/runtime/runtime-linear-client.ts
inline src/renderer/src/runtime/sync-runtime-graph.ts
inline src/renderer/src/runtime/web-runtime-session.ts
inline src/renderer/src/runtime/web-session-tabs-sync.ts
inline src/renderer/src/store/slices/agent-status.ts
inline src/renderer/src/store/slices/browser.ts
inline src/renderer/src/store/slices/diffComments.ts
inline src/renderer/src/store/slices/hosted-review.ts
inline src/renderer/src/store/slices/linear.ts
inline src/renderer/src/store/slices/tabs.ts
inline src/renderer/src/store/slices/ui.ts
inline src/shared/automation-schedules.ts
inline src/shared/commit-message-agent-spec.ts
inline src/shared/constants.ts
inline src/shared/keybindings.ts
inline src/shared/marine-creatures.ts
inline src/shared/telemetry-events.ts
inline src/shared/text-search.ts
inline tests/e2e/helpers/terminal.ts
mobile-config app/h/*/files/*.tsx
mobile-config app/h/*/index.tsx
mobile-config app/h/*/session/*.tsx
mobile-config app/h/*/source-control/*.tsx
mobile-config app/h/*/tasks.tsx
mobile-config app/index.tsx
mobile-config app/pair-scan.tsx
mobile-config app/troubleshoot.tsx
mobile-config scripts/mock-server.ts
mobile-config scripts/repro-terminal-colors.ts
mobile-config scripts/repro-worktree-startup-stream.ts
mobile-config src/browser/MobileBrowserPane.tsx
mobile-config src/components/CustomKeyModal.tsx
mobile-config src/components/NewWorktreeModal.tsx
mobile-config src/components/mobile-rich-markdown-editor-html.ts
mobile-config src/terminal/terminal-accessory-keys.ts
mobile-config src/terminal/terminal-webview-html.ts
mobile-config src/transport/rpc-client.ts
File diff suppressed because one or more lines are too long
+60 -33
View File
@@ -1,41 +1,24 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process'
import { existsSync, mkdirSync } from 'node:fs'
import { existsSync, mkdirSync, statSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
if (process.platform !== 'win32') {
// Why: electron-builder treats a skipped native build like success and can
// continue toward a Windows package whose declared orca.exe does not exist.
throw new Error(
'Windows CLI launcher compilation requires a Windows host; refusing to package without it.'
)
}
const repoRoot = resolve(import.meta.dirname, '../..')
const sourcePath = join(repoRoot, 'native', 'windows-cli-launcher', 'OrcaCliLauncher.cs')
const outputPath = readArg('--output') ?? defaultOutputPath(repoRoot)
const compilerPath = findFrameworkCompiler(process.env)
if (!compilerPath) {
throw new Error('Unable to find the .NET Framework C# compiler required for orca.exe.')
}
mkdirSync(dirname(outputPath), { recursive: true })
const result = spawnSync(
compilerPath,
['/nologo', '/target:exe', '/optimize+', '/warnaserror+', `/out:${outputPath}`, sourcePath],
{ cwd: repoRoot, stdio: 'inherit' }
)
if (result.signal) {
process.kill(process.pid, result.signal)
}
if (result.error) {
throw result.error
}
if (result.status !== 0) {
process.exit(result.status ?? 1)
export function shouldReuseCompiledWindowsCliLauncher(
outputPath,
sourcePath,
{ reuseCached = false } = {}
) {
if (!existsSync(outputPath)) {
return false
}
// Why reuseCached: Actions cache keys already hash the C# source, but restore
// does not preserve mtimes, so a hit would look stale and recompile anyway.
if (reuseCached) {
return true
}
return statSync(outputPath).mtimeMs >= statSync(sourcePath).mtimeMs
}
function defaultOutputPath(projectRoot) {
@@ -58,3 +41,47 @@ function readArg(name) {
const index = process.argv.indexOf(name)
return index !== -1 ? process.argv[index + 1] : undefined
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
if (process.platform !== 'win32') {
// Why: electron-builder treats a skipped native build like success and can
// continue toward a Windows package whose declared orca.exe does not exist.
throw new Error(
'Windows CLI launcher compilation requires a Windows host; refusing to package without it.'
)
}
const repoRoot = resolve(import.meta.dirname, '../..')
const sourcePath = join(repoRoot, 'native', 'windows-cli-launcher', 'OrcaCliLauncher.cs')
const outputPath = readArg('--output') ?? defaultOutputPath(repoRoot)
const compilerPath = findFrameworkCompiler(process.env)
if (!compilerPath) {
throw new Error('Unable to find the .NET Framework C# compiler required for orca.exe.')
}
mkdirSync(dirname(outputPath), { recursive: true })
if (
shouldReuseCompiledWindowsCliLauncher(outputPath, sourcePath, {
reuseCached: process.env.ORCA_REUSE_WINDOWS_CLI_LAUNCHER === '1'
})
) {
console.log(`[native-build] reusing Windows CLI launcher at ${outputPath}`)
process.exit(0)
}
const result = spawnSync(
compilerPath,
['/nologo', '/target:exe', '/optimize+', '/warnaserror+', `/out:${outputPath}`, sourcePath],
{ cwd: repoRoot, stdio: 'inherit' }
)
if (result.signal) {
process.kill(process.pid, result.signal)
}
if (result.error) {
throw result.error
}
if (result.status !== 0) {
process.exit(result.status ?? 1)
}
}
@@ -5,12 +5,15 @@ import {
mkdtempSync,
readFileSync,
rmSync,
statSync,
utimesSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { spawnSync } from 'node:child_process'
import { describe, expect, it } from 'vitest'
import { shouldReuseCompiledWindowsCliLauncher } from './build-windows-cli-launcher.mjs'
const itCrossHost = process.platform === 'win32' ? it.skip : it
const projectRoot = resolve(import.meta.dirname, '../..')
@@ -37,6 +40,33 @@ function itWindows(name, test) {
}
describe('Windows CLI launcher', () => {
it('reuses a compiled launcher that is at least as new as the C# source', () => {
const root = mkdtempSync(join(tmpdir(), 'orca-cli-launcher-reuse-'))
try {
const sourcePath = join(root, 'OrcaCliLauncher.cs')
const outputPath = join(root, '.build', 'orca.exe')
mkdirSync(join(root, '.build'))
writeFileSync(sourcePath, 'source\n')
writeFileSync(outputPath, 'binary\n')
const later = new Date(statSync(sourcePath).mtimeMs + 1_000)
utimesSync(outputPath, later, later)
expect(shouldReuseCompiledWindowsCliLauncher(outputPath, sourcePath)).toBe(true)
writeFileSync(sourcePath, 'changed\n')
const sourceLater = new Date(statSync(outputPath).mtimeMs + 1_000)
utimesSync(sourcePath, sourceLater, sourceLater)
expect(shouldReuseCompiledWindowsCliLauncher(outputPath, sourcePath)).toBe(false)
expect(
shouldReuseCompiledWindowsCliLauncher(outputPath, sourcePath, { reuseCached: true })
).toBe(true)
expect(shouldReuseCompiledWindowsCliLauncher(join(root, 'missing.exe'), sourcePath)).toBe(
false
)
} finally {
removeFixtureTree(root)
}
})
itCrossHost('fails closed when the Windows launcher cannot be compiled on this host', () => {
const outputRoot = mkdtempSync(join(tmpdir(), 'orca cross-host launcher '))
try {
@@ -76,7 +76,6 @@ describe('computer-use e2e workflow', () => {
)
const regressionRun = nativeSmokeRuns.find((run) => run.includes('pnpm vitest run'))
const expectedRegressionFiles = [
'config/scripts/computer-e2e-workflow.test.mjs',
'config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs',
'config/scripts/macos-computer-helper-owner-loss-processes.test.mjs',
'config/scripts/computer-use-modifier-safety.test.mjs',
@@ -126,10 +125,14 @@ describe('computer-use e2e workflow', () => {
const job = workflow.jobs['mac-native-owner-smoke']
const runs = job.steps.map((step) => step.run).filter((run) => typeof run === 'string')
const checkout = job.steps.find((step) => step.uses === 'actions/checkout@v6')
const install = job.steps.find(
(step) => step.uses === './.github/actions/install-node-dependencies'
)
expect(job.if).toBe("github.event_name == 'pull_request'")
expect(job['runs-on']).toBe('macos-15')
expect(checkout.with['persist-credentials']).toBe(false)
expect(install.with['native-runtime']).toBe('electron')
expect(runs).toContain('pnpm bench:macos-computer-helper-owner-loss --expect reaped --trials 1')
const cleanupRun = runs.find((run) =>
run.includes('config/scripts/macos-computer-helper-owner-loss-processes.test.mjs')
+42 -16
View File
@@ -4,7 +4,7 @@ import { spawnSync } from 'node:child_process'
import { createRequire } from 'node:module'
import { existsSync, readFileSync } from 'node:fs'
import { release } from 'node:os'
import { basename, resolve } from 'node:path'
import { basename, dirname, resolve } from 'node:path'
const require = createRequire(import.meta.url)
const { assertNodePtyJobOwnership } = require('./node-pty-job-ownership.cjs')
@@ -67,7 +67,12 @@ function ensureNodeRuntime() {
if (!initial.ok) {
printCheckError(initial)
}
runPnpm(['rebuild', 'node-pty'])
const failedModules = initial.failures.map((failure) => failure.moduleName)
const rebuildModules = [
'node-pty',
...failedModules.filter((moduleName) => moduleName !== 'node-pty')
]
rebuildNodeRuntimeModules(rebuildModules)
verifyNodeRuntimeAfterRebuild()
return
}
@@ -77,7 +82,7 @@ function ensureNodeRuntime() {
`[native-runtime] ${formatRuntimeLabel('node')} cannot load native modules; rebuilding ${failedModules.join(', ')} for Node.`
)
printCheckError(initial)
runPnpm(['rebuild', ...failedModules])
rebuildNodeRuntimeModules(failedModules)
verifyNodeRuntimeAfterRebuild()
}
@@ -312,14 +317,10 @@ function getPatchedNodePtyRebuildReason() {
return null
}
// Why: a loadable upstream node-pty prebuild is not enough; Orca's Unix
// patch only lands in the source-built build/Release artifacts.
// Why: a loadable upstream node-pty prebuild is not enough; Orca's Unix and
// Windows patches only land in the source-built build/Release artifacts.
const nodePtyDir = resolve(projectDir, 'node_modules', 'node-pty')
const artifactPaths = [resolve(nodePtyDir, 'build', 'Release', 'pty.node')]
// Why: node-pty only builds spawn-helper on macOS; Linux builds only pty.node.
if (process.platform === 'darwin') {
artifactPaths.push(resolve(nodePtyDir, 'build', 'Release', 'spawn-helper'))
}
const artifactPaths = patchedNodePtyArtifactPaths(nodePtyDir)
const missingArtifact = artifactPaths.find((artifactPath) => !existsSync(artifactPath))
if (!missingArtifact) {
@@ -329,11 +330,24 @@ function getPatchedNodePtyRebuildReason() {
return 'Patched node-pty build artifacts are missing; rebuilding native deps.'
}
function requiresPatchedNodePtySourceBuild() {
function patchedNodePtyArtifactPaths(nodePtyDir) {
if (process.platform === 'win32') {
return false
const releaseDir = resolve(nodePtyDir, 'build', 'Release')
return [
resolve(releaseDir, 'conpty.node'),
...NODE_PTY_CONPTY_RUNTIME_FILES.map((filename) => resolve(releaseDir, 'conpty', filename))
]
}
const artifactPaths = [resolve(nodePtyDir, 'build', 'Release', 'pty.node')]
// Why: node-pty only builds spawn-helper on macOS; Linux builds only pty.node.
if (process.platform === 'darwin') {
artifactPaths.push(resolve(nodePtyDir, 'build', 'Release', 'spawn-helper'))
}
return artifactPaths
}
function requiresPatchedNodePtySourceBuild() {
const nodePtyPatchPath = resolve(projectDir, 'config', 'patches', 'node-pty@1.1.0.patch')
if (!existsSync(nodePtyPatchPath)) {
return false
@@ -351,16 +365,28 @@ function getWindowsBuildNumber() {
return match && match.length === 4 ? Number.parseInt(match[3], 10) : 0
}
function runPnpm(args) {
function rebuildNodeRuntimeModules(moduleNames) {
for (const moduleName of moduleNames) {
const moduleDir = dirname(require.resolve(`${moduleName}/package.json`))
console.warn(`[native-runtime] Rebuilding ${moduleName} with node-gyp.`)
runPnpm(['exec', 'node-gyp', 'rebuild'], { cwd: moduleDir })
if (moduleName === 'node-pty' && process.platform === 'win32') {
runNodeScript([resolve(moduleDir, 'scripts', 'post-install.js')])
}
}
}
function runPnpm(args, { cwd = projectDir } = {}) {
const command = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
const result = spawnSync(command, args, {
cwd: projectDir,
cwd,
stdio: 'inherit',
shell: process.platform === 'win32'
shell: process.platform === 'win32',
env: process.env
})
if (result.error || result.status !== 0) {
console.error(`[native-runtime] ${command} ${args.join(' ')} failed.`)
console.error(`[native-runtime] ${command} ${args.join(' ')} failed in ${cwd}.`)
if (result.error) {
console.error(formatError(result.error))
}
+87 -10
View File
@@ -42,7 +42,8 @@ describe('ensure-native-runtime', () => {
expect(result.status, result.stderr).toBe(0)
const log = readFileSync(logPath, 'utf8')
expect(log).toContain('pnpm rebuild node-pty\n')
expect(log).toContain('pnpm exec node-gyp rebuild\n')
expect(log).toContain(join('node_modules', 'node-pty'))
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$/)
@@ -52,6 +53,41 @@ describe('ensure-native-runtime', () => {
}
})
it.skipIf(process.platform !== 'win32')(
'rebuilds other failed Windows addons with patched node-pty',
() => {
const projectDir = mkTempProject()
try {
const scriptPath = join(projectDir, 'config', 'scripts', 'ensure-native-runtime.mjs')
const logPath = join(projectDir, 'native-runtime.log')
const markerPath = join(projectDir, 'rebuilt.marker')
const binDir = join(projectDir, 'bin')
copyFileSync(sourceScriptPath, scriptPath)
writeFakeNativeModules(projectDir, { windowsRegistryRequiresMarker: true })
writeNodePtyPatchFile(projectDir)
writeFakePnpm(binDir)
const result = spawnSync(process.execPath, [scriptPath, '--runtime=node'], {
cwd: projectDir,
encoding: 'utf8',
env: envWithPrependedPath(binDir, {
ORCA_NATIVE_TEST_LOG: logPath,
ORCA_NATIVE_TEST_MARKER: markerPath
})
})
expect(result.status, result.stderr).toBe(0)
const log = readFileSync(logPath, 'utf8')
expect(log.match(/pnpm exec node-gyp rebuild\n/g)).toHaveLength(2)
expect(log).toContain(join('node_modules', 'node-pty'))
expect(log).toContain(join('node_modules', 'windows-native-registry'))
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
}
)
it.skipIf(process.platform === 'win32')(
'rebuilds patched node-pty artifacts even when the Node load check passes',
() => {
@@ -80,7 +116,7 @@ describe('ensure-native-runtime', () => {
expect(result.stderr).toContain(
'Patched node-pty build artifacts are missing; rebuilding native deps.'
)
expect(readFileSync(logPath, 'utf8')).toContain('pnpm rebuild node-pty\n')
expect(readFileSync(logPath, 'utf8')).toContain('pnpm exec node-gyp rebuild\n')
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
@@ -114,7 +150,7 @@ describe('ensure-native-runtime', () => {
expect(result.status, result.stderr).toBe(0)
expect(result.stderr).toContain("expected build/Release so Orca's node-pty patch is active")
expect(readFileSync(logPath, 'utf8')).toContain('pnpm rebuild node-pty\n')
expect(readFileSync(logPath, 'utf8')).toContain('pnpm exec node-gyp rebuild\n')
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
@@ -148,7 +184,7 @@ describe('ensure-native-runtime', () => {
expect(result.status, result.stderr).toBe(0)
expect(result.stderr).not.toContain('Patched node-pty build artifacts are missing')
expect(readFileSync(logPath, 'utf8')).not.toContain('pnpm rebuild node-pty')
expect(readFileSync(logPath, 'utf8')).not.toContain('pnpm exec node-gyp rebuild')
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
@@ -178,9 +214,15 @@ function envWithPrependedPath(binDir, extraEnv) {
}
}
function writeFakeNativeModules(projectDir) {
function writeFakeNativeModules(projectDir, { windowsRegistryRequiresMarker = false } = {}) {
const nodePtyDir = join(projectDir, 'node_modules', 'node-pty')
mkdirSync(join(nodePtyDir, 'lib'), { recursive: true })
writeFileSync(
join(nodePtyDir, 'package.json'),
'{"name":"node-pty","version":"1.1.0","main":"index.js"}\n'
)
mkdirSync(join(nodePtyDir, 'scripts'), { recursive: true })
writeFileSync(join(nodePtyDir, 'scripts', 'post-install.js'), '')
writeFileSync(join(nodePtyDir, 'index.js'), 'module.exports = {}\n')
writeFileSync(
@@ -200,12 +242,18 @@ exports.loadNativeModule = function loadNativeModule(nativeName) {
}
`
)
writeFakeWindowsRegistry(projectDir)
writeFakeWindowsRegistry(projectDir, { requiresMarker: windowsRegistryRequiresMarker })
}
function writeLoadableNativeModules(projectDir, { nativeDir = null } = {}) {
const nodePtyDir = join(projectDir, 'node_modules', 'node-pty')
mkdirSync(join(nodePtyDir, 'lib'), { recursive: true })
writeFileSync(
join(nodePtyDir, 'package.json'),
'{"name":"node-pty","version":"1.1.0","main":"index.js"}\n'
)
mkdirSync(join(nodePtyDir, 'scripts'), { recursive: true })
writeFileSync(join(nodePtyDir, 'scripts', 'post-install.js'), '')
writeFileSync(join(nodePtyDir, 'index.js'), 'module.exports = {}\n')
writeFileSync(
@@ -218,23 +266,40 @@ exports.loadNativeModule = function loadNativeModule(nativeName) {
const dir = ${JSON.stringify(nativeDir)} ??
(rebuilt ? '../build/Release/' : '../prebuilds/' + process.platform + '-' + process.arch + '/')
appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, \`node-pty load \${nativeName} dir=\${dir}\\n\`)
return { dir, module: {} }
return {
dir,
module: {
listJobProcessIds: () => [],
terminateJob: () => true,
assignCurrentProcessToJob: () => true
}
}
}
`
)
writeFakeWindowsRegistry(projectDir)
}
function writeFakeWindowsRegistry(projectDir) {
function writeFakeWindowsRegistry(projectDir, { requiresMarker = false } = {}) {
if (process.platform !== 'win32') {
return
}
const registryDir = join(projectDir, 'node_modules', 'windows-native-registry')
mkdirSync(registryDir, { recursive: true })
writeFileSync(
join(registryDir, 'index.js'),
'exports.HK = { CU: 0x80000001 }; exports.getRegistryKey = () => ({})\n'
join(registryDir, 'package.json'),
'{"name":"windows-native-registry","version":"3.2.2","main":"index.js"}\n'
)
const markerGate = requiresMarker
? `if (!require('node:fs').existsSync(process.env.ORCA_NATIVE_TEST_MARKER)) { throw new Error('registry ABI mismatch sentinel') }`
: ''
writeFileSync(
join(registryDir, 'index.js'),
`exports.HK = { CU: 0x80000001 }; exports.getRegistryKey = () => { ${markerGate}; return {} }\n`
)
const processTreeDir = join(projectDir, 'node_modules', '@vscode', 'windows-process-tree')
mkdirSync(processTreeDir, { recursive: true })
writeFileSync(join(processTreeDir, 'index.js'), 'module.exports = {}\n')
}
function writeNodePtyPatchFile(projectDir) {
@@ -245,6 +310,13 @@ function writeNodePtyPatchFile(projectDir) {
function writePatchedNodePtyBuildArtifacts(projectDir) {
const buildDir = join(projectDir, 'node_modules', 'node-pty', 'build', 'Release')
mkdirSync(buildDir, { recursive: true })
if (process.platform === 'win32') {
writeFileSync(join(buildDir, 'conpty.node'), '')
mkdirSync(join(buildDir, 'conpty'), { recursive: true })
writeFileSync(join(buildDir, 'conpty', 'conpty.dll'), '')
writeFileSync(join(buildDir, 'conpty', 'OpenConsole.exe'), '')
return
}
writeFileSync(join(buildDir, 'pty.node'), '')
if (process.platform === 'darwin') {
writeFileSync(join(buildDir, 'spawn-helper'), '')
@@ -260,6 +332,11 @@ function writeFakePnpm(binDir) {
const { appendFileSync, writeFileSync } = require('node:fs')
appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, \`pnpm \${process.argv.slice(2).join(' ')}\\n\`)
appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, \`cwd=\${process.cwd()}\\n\`)
appendFileSync(
process.env.ORCA_NATIVE_TEST_LOG,
\`npm_config_build_from_source=\${process.env.npm_config_build_from_source || ''}\\n\`
)
writeFileSync(process.env.ORCA_NATIVE_TEST_MARKER, 'rebuilt')
`
)
+252 -2
View File
@@ -16,6 +16,183 @@ const DOCS_ONLY_FILES = new Set([
const DOCS_ONLY_PREFIXES = ['docs/', '.github/ISSUE_TEMPLATE/']
export const PR_CHECK_JOBS = [
'static_analysis',
'typecheck',
'git_compatibility',
'xterm_patch_sync',
'shell_contracts',
'test',
'orcad_browser',
'cross-version-wire',
'managed_hook_node18',
'package',
'package_windows'
]
const ALWAYS_ON_CODE_JOBS = new Set(['static_analysis', 'typecheck', 'test'])
const GLOBAL_FORCE_PREFIXES = [
'.github/workflows/pr.yml',
'.github/actions/install-node-dependencies/',
'config/scripts/pr-code-change-scope'
]
const GLOBAL_FORCE_FILES = new Set(['package.json', 'pnpm-lock.yaml'])
const GIT_COMPAT_PREFIXES = [
'src/shared/git-',
'src/shared/review-head-tracking-ref',
'src/main/git/',
'src/relay/git-',
'config/scripts/git-binary-compatibility'
]
const XTERM_PREFIXES = [
'config/patches/xterm-upstream.json',
'config/patches/@xterm',
'config/patches/xterm-src/',
'config/scripts/regenerate-xterm-patches'
]
const SHELL_PREFIXES = [
'src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec',
'src/main/daemon/shell-ready',
'src/main/daemon/daemon-bash-shell-ready',
'src/main/daemon/daemon-shell-ready-wrapper',
'src/main/daemon/node-pty-fd-leak',
'src/main/providers/local-pty-shell-ready',
'src/main/providers/__tests__/shell-ready-framework-example',
'src/main/pty/',
'src/main/shell-templates',
'src/main/shell-startup-',
'src/main/shell-wrapper-',
'src/main/terminal-history-fish',
'src/main/zsh-',
'src/renderer/src/components/terminal-pane/fish-color-scheme',
'src/shared/fish-',
'src/shared/pty-reply-echo-shapes',
'src/shared/startup-shell-portability',
'src/shared/posix-command-path-lookup',
'config/patches/node-pty@',
'config/scripts/ensure-native-runtime',
'config/scripts/node-pty-job-ownership'
]
const ORCAD_BROWSER_PREFIXES = [
'src/main/orcad/external-chromium-',
'src/main/orcad/orcad-browser-provider',
'src/main/orcad/orcad-agent-browser-binary',
'src/main/orcad/electron-serve-browser-process'
]
const CROSS_VERSION_WIRE_PREFIXES = [
'tests/e2e/cross-version-wire/',
'src/shared/protocol-version',
'src/shared/terminal-stream-protocol',
'src/shared/browser-client-host-protocol',
'src/shared/browser-network-tunnel-protocol',
'src/shared/browser-client-host-placement',
'src/main/runtime/rpc/dispatcher',
'src/main/runtime/rpc/methods/browser-tab-create-schema',
'src/main/runtime/rpc/methods/terminal',
'src/renderer/src/runtime/remote-runtime-terminal-multiplexer'
]
const MANAGED_HOOK_PREFIXES = [
'config/scripts/smoke-managed-hook-runtime-node18',
'config/scripts/build-relay',
'src/relay/',
'src/shared/agent-hook',
'src/main/agent-hooks/'
]
const NATIVE_RUNTIME_PREFIXES = [
'config/scripts/ensure-native-runtime',
'config/scripts/rebuild-native-deps',
'config/scripts/node-pty-job-ownership',
'config/scripts/electron-builder-native-rebuild',
'config/patches/node-pty@',
'config/patches/@vscode__windows-process-tree'
]
const SHARED_PACKAGE_PREFIXES = [
'electron.vite.config.ts',
'config/electron-builder',
'config/packaged-runtime',
'config/build-plugins/',
'config/scripts/build-',
'config/scripts/smoke-packaged',
'config/scripts/install-electron-package-binary',
'config/scripts/verify-packaged',
'config/scripts/verify-linux-glibc',
'config/scripts/run-electron-vite',
'skills/',
'skill-guides/',
'resources/build/',
'resources/onboarding/',
'resources/plugins/',
'resources/skills/',
...NATIVE_RUNTIME_PREFIXES
]
const LINUX_PACKAGE_PREFIXES = [
...SHARED_PACKAGE_PREFIXES,
'native/computer-use-linux/',
'resources/linux/',
'config/scripts/run-headless-serve'
]
const WINDOWS_PACKAGE_PREFIXES = [
...SHARED_PACKAGE_PREFIXES,
'native/windows-cli-launcher/',
'native/computer-use-windows/',
'resources/win32/',
'config/scripts/build-windows-cli-launcher',
'config/scripts/windows-pty-native-capability',
'tests/tools/windows-pty-native-capability-smoke/'
]
const LINUX_PACKAGE_TESTS = [
'src/main/browser/browser-client-page-renderer-lifecycle.electron.test.ts',
'src/main/browser/browser-route-tcp-egress.electron.test.ts',
'src/main/browser/browser-route-webrtc-egress.electron.test.ts',
'src/main/browser/browser-route-h3-egress.electron.test.ts',
'src/main/browser/browser-route-dns-prefetch.electron.test.ts'
]
const WINDOWS_PACKAGE_TESTS = [
...LINUX_PACKAGE_TESTS,
'config/scripts/rebuild-native-deps.test.mjs',
'src/main/providers/windows-conpty-wide-char-duplication.node-pty.test.ts',
'src/main/providers/pty-repaint-wide-char-buffer.node-pty.test.ts',
'src/shared/child-process/windows-command-line.win32.test.ts',
'src/main/agent-hooks/windows-hook-payload-delivery.test.ts',
'src/main/windows/windows-pty-job.win32.test.ts',
'src/main/windows/windows-host-job.win32.test.ts',
'src/main/wsl/wsl-runner.test.ts',
'src/main/wsl/wsl-guest-environment.test.ts',
'src/main/wsl/wsl-invocation-boundary.test.ts',
'src/main/wsl/wsl-executable-path.win32.test.ts',
'src/main/wsl/wsl-w1-w3-contract.test.ts',
'src/shared/source-scan/source-tree-scan.test.ts',
'src/main/cli/wsl-cli-powershell-boundary.test.ts',
'src/main/cursor/hook-service.test.ts',
'src/main/orca-profiles/profile-index-store.test.ts',
'src/main/runtime/repo-worktree-admin-fingerprint.test.ts',
'src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts',
'src/shared/secure-file-fsync-flags.test.ts',
'src/main/ipc/pty-codex-account-attribution.test.ts',
'src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts'
]
const DESKTOP_IRRELEVANT_PREFIXES = [
'mobile/',
'.github/workflows/mobile.yml',
'.github/workflows/mobile-ios-release.yml',
'.github/workflows/mobile-android-release.yml'
]
export function isDocsOnlyPath(file) {
if (DOCS_ONLY_FILES.has(file)) {
return true
@@ -32,10 +209,83 @@ export function shouldRunPrChecks(changedFiles) {
if (changedFiles.length === 0) {
return true
}
return changedFiles.some((file) => !isDocsOnlyPath(file))
return changedFiles.some((file) => !isDocsOnlyPath(file) && !isDesktopIrrelevantPath(file))
}
export function classifyPrJobs(changedFiles) {
const emptyDiff = changedFiles.length === 0
const shouldRun = shouldRunPrChecks(changedFiles)
const forceAll = emptyDiff || changedFiles.some(isGlobalForcePath)
const jobs = Object.fromEntries(
PR_CHECK_JOBS.map((job) => [
job,
shouldRun && (forceAll || ALWAYS_ON_CODE_JOBS.has(job) || jobDetector(job)(changedFiles))
])
)
return { should_run: shouldRun, ...jobs }
}
function jobDetector(job) {
switch (job) {
case 'git_compatibility':
return (files) => files.some((file) => matchesPrefix(file, GIT_COMPAT_PREFIXES))
case 'xterm_patch_sync':
return (files) => files.some((file) => matchesPrefix(file, XTERM_PREFIXES))
case 'shell_contracts':
return (files) => files.some((file) => matchesPrefix(file, SHELL_PREFIXES))
case 'orcad_browser':
return (files) => files.some((file) => matchesPrefix(file, ORCAD_BROWSER_PREFIXES))
case 'cross-version-wire':
return (files) => files.some((file) => matchesPrefix(file, CROSS_VERSION_WIRE_PREFIXES))
case 'managed_hook_node18':
return (files) => files.some((file) => matchesPrefix(file, MANAGED_HOOK_PREFIXES))
case 'package':
return (files) => files.some(isLinuxPackagePath)
case 'package_windows':
return (files) => files.some(isWindowsPackagePath)
default:
return () => true
}
}
function isLinuxPackagePath(file) {
return LINUX_PACKAGE_TESTS.includes(file) || isProductBundlePath(file, LINUX_PACKAGE_PREFIXES)
}
function isWindowsPackagePath(file) {
return WINDOWS_PACKAGE_TESTS.includes(file) || isProductBundlePath(file, WINDOWS_PACKAGE_PREFIXES)
}
function isProductBundlePath(file, extraPrefixes) {
if (isTestFile(file)) {
return false
}
if (file.startsWith('src/')) {
return true
}
return matchesPrefix(file, extraPrefixes)
}
function isTestFile(file) {
return /\.(?:test|spec)\.(?:js|cjs|mjs|ts|tsx)$/.test(file) || file.includes('/__tests__/')
}
function isDesktopIrrelevantPath(file) {
return matchesPrefix(file, DESKTOP_IRRELEVANT_PREFIXES)
}
function isGlobalForcePath(file) {
return GLOBAL_FORCE_FILES.has(file) || matchesPrefix(file, GLOBAL_FORCE_PREFIXES)
}
function matchesPrefix(file, prefixes) {
return prefixes.some((prefix) => file === prefix || file.startsWith(prefix))
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const files = readFileSync(0, 'utf8').split('\n').filter(Boolean)
process.stdout.write(shouldRunPrChecks(files) ? 'true\n' : 'false\n')
const classification = classifyPrJobs(files)
for (const [name, value] of Object.entries(classification)) {
process.stdout.write(`${name}=${value ? 'true' : 'false'}\n`)
}
}
+197 -16
View File
@@ -1,13 +1,18 @@
import { spawnSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { parse } from 'yaml'
import { isDocsOnlyPath, shouldRunPrChecks } from './pr-code-change-scope.mjs'
import {
classifyPrJobs,
isDocsOnlyPath,
PR_CHECK_JOBS,
shouldRunPrChecks
} from './pr-code-change-scope.mjs'
const projectDir = resolve(import.meta.dirname, '../..')
const prWorkflow = parse(readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8'))
const gatedIf = "needs.code_paths.outputs.should_run == 'true'"
const expensiveJobs = [
'static_analysis',
'typecheck',
@@ -15,12 +20,34 @@ const expensiveJobs = [
'xterm_patch_sync',
'shell_contracts',
'test',
'orcad_browser',
'cross-version-wire',
'managed_hook_node18',
'package',
'package_windows'
]
const ALWAYS_ON = ['static_analysis', 'typecheck', 'test']
function expectedJobs(overrides, { alwaysOn = true } = {}) {
return Object.fromEntries(
PR_CHECK_JOBS.map((job) => [
job,
(alwaysOn && ALWAYS_ON.includes(job)) || Boolean(overrides[job])
])
)
}
function expectClassification(files, overrides) {
const result = classifyPrJobs(files)
const shouldRun = shouldRunPrChecks(files)
expect(result.should_run).toBe(shouldRun)
expect(result).toMatchObject({
should_run: shouldRun,
...expectedJobs(overrides, { alwaysOn: shouldRun })
})
}
describe('docs-only path classification', () => {
it('treats the WeChat README PR files as docs-only', () => {
expect(
@@ -54,9 +81,154 @@ describe('docs-only path classification', () => {
it('runs PR Checks when the diff is empty rather than skipping by accident', () => {
expect(shouldRunPrChecks([])).toBe(true)
})
it('does not start desktop PR Checks for mobile-only diffs', () => {
expect(shouldRunPrChecks(['mobile/src/App.tsx', 'mobile/package.json'])).toBe(false)
})
})
describe('PR Checks docs-only skip wiring', () => {
describe('per-job path classification', () => {
it('runs every expensive job on an empty diff rather than skipping by accident', () => {
const result = classifyPrJobs([])
expect(result.should_run).toBe(true)
for (const job of PR_CHECK_JOBS) {
expect(result[job], job).toBe(true)
}
})
it('skips every expensive job for docs-only diffs', () => {
expectClassification(['README.md', 'docs/readme/README.zh-CN.md'], {})
})
it('runs packaging and always-on jobs for product source, not git/xterm/shell lanes', () => {
expectClassification(['src/renderer/src/components/tab-bar/TabBar.tsx'], {
package: true,
package_windows: true
})
})
it('runs Git compatibility only when git capability inputs change', () => {
expectClassification(['src/shared/git-capability-cache.ts'], {
git_compatibility: true,
package: true,
package_windows: true
})
expectClassification(['src/shared/git-binary-compatibility.test.ts'], {
git_compatibility: true
})
})
it('runs xterm patch sync only when xterm inputs change', () => {
expectClassification(['config/patches/xterm-upstream.json'], {
xterm_patch_sync: true
})
expectClassification(['config/patches/@xterm__xterm@6.1.0-beta.287.patch'], {
xterm_patch_sync: true
})
})
it('runs native package jobs only for the platform that ships the changed native', () => {
expectClassification(['native/windows-cli-launcher/OrcaCliLauncher.cs'], {
package_windows: true
})
expectClassification(['native/computer-use-linux/runtime.py'], {
package: true
})
expectClassification(['native/computer-use-macos/Package.swift'], {})
})
it('runs shell contracts when live-shell inputs change', () => {
expectClassification(['src/main/daemon/shell-ready.ts'], {
shell_contracts: true,
package: true,
package_windows: true
})
})
it('runs shell contracts when wrapper templates or live-shell fixtures change', () => {
expectClassification(['src/main/shell-templates.ts'], {
shell_contracts: true,
package: true,
package_windows: true
})
expectClassification(['src/main/shell-startup-launch-intent-fixtures.ts'], {
shell_contracts: true,
package: true,
package_windows: true
})
})
it('runs orcad browser when Chrome launch, session, or tab modules change', () => {
for (const file of [
'src/main/orcad/external-chromium-browser-session.ts',
'src/main/orcad/external-chromium-command-arguments.ts',
'src/main/orcad/external-chromium-tab-registry.ts',
'src/main/orcad/external-chromium-tab-projection.ts'
]) {
expectClassification([file], {
orcad_browser: true,
package: true,
package_windows: true
})
}
expectClassification(['src/main/orcad/orcad-native-preflight.ts'], {
package: true,
package_windows: true
})
})
it('runs cross-version wire checks for every working-tree wire module', () => {
for (const file of [
'src/shared/protocol-version.ts',
'src/shared/terminal-stream-protocol.ts',
'src/main/runtime/rpc/dispatcher.ts',
'src/main/runtime/rpc/methods/browser-tab-create-schema.ts',
'src/main/runtime/rpc/methods/terminal.ts',
'src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts'
]) {
expectClassification([file], {
'cross-version-wire': true,
package: true,
package_windows: true
})
}
expectClassification(
['tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts'],
{ 'cross-version-wire': true }
)
})
it('runs workflow-self-change and lockfile diffs as force-all', () => {
const result = classifyPrJobs(['.github/workflows/pr.yml'])
expect(result.should_run).toBe(true)
for (const job of PR_CHECK_JOBS) {
expect(result[job], job).toBe(true)
}
expect(classifyPrJobs(['pnpm-lock.yaml']).git_compatibility).toBe(true)
})
it('keeps unit-test-only diffs out of packaging', () => {
expectClassification(['src/main/git/git-status.test.ts'], {
git_compatibility: true
})
})
it('emits GitHub output pairs from the shipped CLI', () => {
const result = spawnSync(process.execPath, ['config/scripts/pr-code-change-scope.mjs'], {
cwd: projectDir,
encoding: 'utf8',
input: 'config/patches/xterm-upstream.json\n'
})
expect(result.status, result.stderr).toBe(0)
expect(result.stdout).toContain('should_run=true\n')
expect(result.stdout).toContain('xterm_patch_sync=true\n')
expect(result.stdout).toContain('git_compatibility=false\n')
expect(result.stdout).toContain('package=false\n')
expect(result.stdout).toContain('test=true\n')
})
})
describe('PR Checks skip wiring', () => {
it('classifies the PR range with a tested script and expands renames', () => {
const classify = prWorkflow.jobs.code_paths.steps.find(
(step) => step.name === 'Classify changed paths'
@@ -65,9 +237,12 @@ describe('PR Checks docs-only skip wiring', () => {
expect(classify.run).toContain('--no-renames')
expect(classify.run).toContain('--merge-base "$BASE_SHA" "$HEAD_SHA"')
expect(classify.run).toContain('node config/scripts/pr-code-change-scope.mjs')
expect(prWorkflow.jobs.code_paths.outputs.should_run).toBe(
'${{ steps.filter.outputs.should_run }}'
)
expect(classify.run).toContain('tee -a "$GITHUB_OUTPUT"')
for (const jobName of ['should_run', ...expensiveJobs]) {
expect(prWorkflow.jobs.code_paths.outputs[jobName], jobName).toBe(
`\${{ steps.filter.outputs.${jobName} }}`
)
}
})
it('keeps the cheap root-directory guard on docs-only PRs', () => {
@@ -75,10 +250,12 @@ describe('PR Checks docs-only skip wiring', () => {
expect(prWorkflow.jobs.root_directory_guard.needs).toBeUndefined()
})
it('skips expensive jobs unless the detector says the PR has code', () => {
it('gates each expensive job on its own classifier output', () => {
for (const jobName of expensiveJobs) {
expect(prWorkflow.jobs[jobName].needs, jobName).toEqual(['code_paths'])
expect(prWorkflow.jobs[jobName].if, jobName).toBe(gatedIf)
expect(prWorkflow.jobs[jobName].if, jobName).toBe(
`needs.code_paths.outputs.${jobName} == 'true'`
)
}
})
@@ -89,19 +266,23 @@ describe('PR Checks docs-only skip wiring', () => {
)
})
it('lets verify pass when expensive jobs are skipped for docs-only PRs', () => {
it('lets verify pass skipped jobs the classifier turned off', () => {
const verifyStep = prWorkflow.jobs.verify.steps.find(
(step) => step.name === 'Require successful checks'
)
expect(prWorkflow.jobs.verify.needs[0]).toBe('code_paths')
expect(verifyStep.env.SHOULD_RUN).toBe('${{ needs.code_paths.outputs.should_run }}')
expect(verifyStep.run).toContain('"$SHOULD_RUN" != "true"')
const docsOnlyBranch = verifyStep.run.slice(
0,
verifyStep.run.indexOf('# Require success when the PR has code-relevant changes')
)
expect(docsOnlyBranch).toContain('if [ "$result" != "skipped" ]')
expect(docsOnlyBranch).not.toContain('"$result" != "success"')
expect(verifyStep.run).toContain('"$ROOT_DIRECTORY_GUARD" != "success"')
expect(verifyStep.run).toContain('# Require success when the PR has code-relevant changes')
expect(verifyStep.run).toContain('expected skipped')
expect(verifyStep.run).toContain('expected success')
for (const job of prWorkflow.jobs.verify.needs) {
if (job === 'code_paths' || job === 'root_directory_guard') {
continue
}
const envVar = `${job.replaceAll('-', '_').toUpperCase()}_SHOULD_RUN`
expect(verifyStep.env[envVar]).toBe(`\${{ needs.code_paths.outputs.${job} }}`)
expect(verifyStep.run).toContain(`"$${envVar}"`)
}
})
})
+20 -6
View File
@@ -100,17 +100,19 @@ describe('PR E2E gate contract', () => {
// job without adding it to the strict loop fails here instead of silently
// leaving that job unenforced. This is what caught GIT_COMPATIBILITY and
// SHELL_CONTRACTS being absent from an earlier hardcoded list.
// Why lastIndexOf: the docs-only branch has its own loop that allows skipped.
const successMarker = '# Require success when the PR has code-relevant changes'
const successLoop = verifyStep.run.slice(
verifyStep.run.indexOf(successMarker),
verifyStep.run.lastIndexOf('done')
)
const successLoop = verifyStep.run.slice(verifyStep.run.indexOf(successMarker))
expect(successLoop.length).toBeGreaterThan(0)
expect(verifyStep.run).toContain('"$CODE_PATHS" != "success"')
expect(verifyStep.run).toContain('"$ROOT_DIRECTORY_GUARD" != "success"')
for (const job of prWorkflow.jobs.verify.needs) {
const envVar = job.toUpperCase()
const envVar = job.replaceAll('-', '_').toUpperCase()
expect(verifyStep.env[envVar]).toBe(`\${{ needs.${job}.result }}`)
if (job === 'code_paths' || job === 'root_directory_guard') {
continue
}
expect(successLoop).toContain(`"$${envVar}"`)
expect(verifyStep.env[`${envVar}_SHOULD_RUN`]).toBe(`\${{ needs.code_paths.outputs.${job} }}`)
}
})
@@ -157,6 +159,18 @@ describe('PR E2E gate contract', () => {
expect(sshDockerRunner).toContain("'electron-headful'")
})
it('reuses the composite install action instead of duplicating pnpm setup', () => {
const installFor = (jobName) =>
e2eWorkflow.jobs[jobName].steps.find(
(step) => step.uses === './.github/actions/install-node-dependencies'
)
expect(installFor('build').with).toBeUndefined()
for (const jobName of ['e2e', 'changed-e2e', 'ssh-docker-watcher-isolation']) {
expect(installFor(jobName).with['native-runtime'], jobName).toBe('electron')
}
})
it('installs zsh in every Linux lane that can run paired startup readiness', () => {
for (const jobName of ['e2e', 'changed-e2e', 'ssh-docker-watcher-isolation']) {
const installStep = e2eWorkflow.jobs[jobName].steps.find((step) =>
+42 -15
View File
@@ -50,9 +50,9 @@ describe('PR workflow parallelism', () => {
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: 16 }, (_, index) => index + 1)
Array.from({ length: 8 }, (_, index) => index + 1)
)
expect(workflow.jobs.test.strategy.matrix.shard_total).toEqual([16])
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(
(step) => step.uses === './.github/actions/install-node-dependencies'
@@ -233,21 +233,20 @@ describe('PR workflow parallelism', () => {
(step) => step.uses === './.github/actions/install-node-dependencies'
)
for (const jobName of [
'static_analysis',
'typecheck',
'git_compatibility',
'xterm_patch_sync'
]) {
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(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')
expect(dependencyAction.inputs['persist-native-cache'].default).toBe('true')
expect(
dependencyAction.runs.steps.find((step) => step.name === 'Use external node-gyp').if
).toBe("inputs.native-runtime != 'none'")
).toBe("runner.os == 'Linux' && inputs.native-runtime != 'none'")
const dependencyInstall = dependencyAction.runs.steps.find(
(step) => step.name === 'Install dependencies'
)
@@ -295,14 +294,39 @@ describe('PR workflow parallelism', () => {
// overwritten and one after the rebuild would never save a hit.
expect(installIndex).toBeLessThan(cacheIndex)
expect(cacheIndex).toBeLessThan(prepareIndex)
expect(steps[cacheIndex].if).toBe("inputs.native-runtime != 'none'")
expect(steps[cacheIndex].if).toBe(
"inputs.native-runtime != 'none' && inputs.persist-native-cache != 'false'"
)
const restoreOnly = steps.find(
(step) => step.name === 'Restore compiled native modules without saving'
)
expect(restoreOnly.if).toBe(
"inputs.native-runtime != 'none' && inputs.persist-native-cache == 'false'"
)
expect(restoreOnly.uses).toBe('actions/cache/restore@v5')
// Native artifacts are ABI-bound: a key missing either dimension serves a build
// that cannot load, and ensure-native-runtime would recompile it anyway.
expect(steps[cacheIndex].with.key).toContain('${{ inputs.native-runtime }}')
expect(steps[cacheIndex].with.key).toContain('steps.requested-node.outputs.node-version')
expect(steps[cacheIndex].with.key).toContain('config/patches/node-pty@1.1.0.patch')
// No restore-keys: a partial-match key is exactly the ABI-mismatched build above.
expect(steps[cacheIndex].with['restore-keys']).toBeUndefined()
for (const cacheStep of [steps[cacheIndex], restoreOnly]) {
expect(cacheStep.with.key).toContain('${{ inputs.native-runtime }}')
expect(cacheStep.with.key).toContain('${{ runner.os }}')
expect(cacheStep.with.key).toContain('${{ runner.arch }}')
expect(cacheStep.with.key).toContain('steps.requested-node.outputs.node-version')
expect(cacheStep.with.key).toContain('steps.native-cache-scope.outputs.scope')
expect(cacheStep.with.key).toContain('config/patches/node-pty@1.1.0.patch')
expect(cacheStep.with.key).toContain(
'config/patches/@vscode__windows-process-tree@0.8.0.patch'
)
expect(cacheStep.with.path).toContain('node-pty@*/node_modules/node-pty/build')
expect(cacheStep.with.path).toContain('windows-native-registry@')
expect(cacheStep.with.path).toContain('@vscode+windows-process-tree@')
expect(cacheStep.with['restore-keys']).toBeUndefined()
}
const cacheScope = steps.find((step) => step.name === 'Resolve native cache scope')
expect(cacheScope.if).toBe("inputs.native-runtime != 'none'")
expect(cacheScope.run).toContain('/etc/os-release')
expect(dependencyAction.outputs['native-cache-scope'].value).toBe(
'${{ steps.native-cache-scope.outputs.scope }}'
)
})
it('reuses TypeScript incremental state across typecheck runs', () => {
@@ -346,6 +370,7 @@ describe('PR workflow parallelism', () => {
'shell_contracts',
'test',
'orcad_browser',
'cross-version-wire',
'managed_hook_node18',
'package',
'package_windows'
@@ -359,5 +384,7 @@ describe('PR workflow parallelism', () => {
// ORCA_BROWSER_EXECUTABLE, so it only guards anything if verify actually reads it.
expect(verifyStep.env.ORCAD_BROWSER).toBe('${{ needs.orcad_browser.result }}')
expect(verifyStep.run).toContain('"$ORCAD_BROWSER"')
expect(verifyStep.env.CROSS_VERSION_WIRE).toBe('${{ needs.cross-version-wire.result }}')
expect(verifyStep.run).toContain('"$CROSS_VERSION_WIRE"')
})
})
+16 -10
View File
@@ -401,14 +401,23 @@ function getPatchedNodePtyRebuildReason() {
return null
}
// Why: Orca patches node-pty's native Unix spawn path; upstream prebuilds can
// load successfully in Electron while missing the patched fd/error handling.
// Why: Orca patches node-pty's native Unix spawn path and Windows job-object
// exports; upstream prebuilds can load while missing those patches.
const nodePtyDir = resolve(projectDir, 'node_modules', 'node-pty')
const artifactPaths = [resolve(nodePtyDir, 'build', 'Release', 'pty.node')]
// Why: node-pty only builds spawn-helper on macOS; Linux builds only pty.node.
if (process.platform === 'darwin') {
artifactPaths.push(resolve(nodePtyDir, 'build', 'Release', 'spawn-helper'))
}
const artifactPaths =
rebuildPlatform === 'win32'
? [
resolve(nodePtyDir, 'build', 'Release', 'conpty.node'),
...NODE_PTY_CONPTY_RUNTIME_FILES.map((filename) =>
resolve(nodePtyDir, 'build', 'Release', 'conpty', filename)
)
]
: [
resolve(nodePtyDir, 'build', 'Release', 'pty.node'),
...(osPlatform() === 'darwin'
? [resolve(nodePtyDir, 'build', 'Release', 'spawn-helper')]
: [])
]
const missingArtifact = artifactPaths.find((artifactPath) => !existsSync(artifactPath))
if (!missingArtifact) {
@@ -422,9 +431,6 @@ function requiresPatchedNodePtySourceBuild() {
if (!onlyModules.includes('node-pty')) {
return false
}
if (rebuildPlatform === 'win32') {
return false
}
if (rebuildPlatform !== osPlatform() || rebuildArch !== process.arch) {
return false
}
@@ -586,6 +586,13 @@ function writeNodePtyPatchFile(projectDir) {
function writePatchedNodePtyBuildArtifacts(projectDir) {
const buildDir = join(projectDir, 'node_modules', 'node-pty', 'build', 'Release')
mkdirSync(buildDir, { recursive: true })
if (process.platform === 'win32') {
writeFileSync(join(buildDir, 'conpty.node'), '')
mkdirSync(join(buildDir, 'conpty'), { recursive: true })
writeFileSync(join(buildDir, 'conpty', 'conpty.dll'), '')
writeFileSync(join(buildDir, 'conpty', 'OpenConsole.exe'), '')
return
}
writeFileSync(join(buildDir, 'pty.node'), '')
if (process.platform === 'darwin') {
writeFileSync(join(buildDir, 'spawn-helper'), '')
@@ -14,6 +14,7 @@ describe('packaged skills CLI PR gates', () => {
expect(job['runs-on']).toBe('windows-2022')
expect(buildStep.run).toBe('pnpm run build:release:parallel')
expect(buildStep.env.ORCA_REUSE_WINDOWS_CLI_LAUNCHER).toBe('1')
expect(prepareStep.run).toBe('node config/scripts/ensure-native-runtime.mjs --runtime=electron')
expect(packageStep.run).toContain('electron-builder')
expect(packageStep.run).toContain('--dir')
@@ -28,8 +28,14 @@ describe('packaged Windows PTY native capability routing', () => {
it('keeps patched source rebuild, release build, runtime reuse, and aggregate routing intact', () => {
const job = workflow.jobs.package_windows
const sourceRebuild = job.steps.find(
(step) => step.name === 'Rebuild node-pty from patched source'
const install = job.steps.find(
(step) => step.uses === './.github/actions/install-node-dependencies'
)
const nodeCacheSave = job.steps.find(
(step) => step.name === 'Save compiled Node native modules'
)
const electronCache = job.steps.find(
(step) => step.name === 'Restore compiled Electron native modules'
)
const build = job.steps.find((step) => step.name === 'Build package inputs')
const prepare = job.steps.find((step) => step.name === 'Prepare Electron native runtime')
@@ -37,10 +43,17 @@ describe('packaged Windows PTY native capability routing', () => {
const verify = workflow.jobs.verify.steps.find(
(step) => step.name === 'Require successful checks'
)
const ensureNativeRuntime = readFileSync('config/scripts/ensure-native-runtime.mjs', 'utf8')
expect(sourceRebuild.env.npm_config_build_from_source).toBe('true')
expect(sourceRebuild.run).toBe('pnpm rebuild node-pty')
expect(install.with['native-runtime']).toBe('node')
expect(install.with['persist-native-cache']).toBe('false')
expect(nodeCacheSave.uses).toBe('actions/cache/save@v5')
expect(nodeCacheSave.with.key).toContain('-node-node')
expect(electronCache.with.key).toContain('-electron-node')
expect(ensureNativeRuntime).toContain("runPnpm(['exec', 'node-gyp', 'rebuild']")
expect(ensureNativeRuntime).toContain("resolve(moduleDir, 'scripts', 'post-install.js')")
expect(build.run).toBe('pnpm run build:release:parallel')
expect(build.env.ORCA_REUSE_WINDOWS_CLI_LAUNCHER).toBe('1')
expect(prepare.run).toBe('node config/scripts/ensure-native-runtime.mjs --runtime=electron')
expect(packageStep.env.ORCA_REUSE_PREPARED_NATIVE_RUNTIME).toBe('1')
expect(workflow.jobs.verify.needs).toContain('package_windows')
@@ -326,8 +326,6 @@ the Windows CI job rebuilds from source before running the win32 suites.
it is true before asserting anything else, so an unpatched binary fails loudly
instead of passing every case vacuously. That guard is what caught this.
`requiresPatchedNodePtySourceBuild()` in `ensure-native-runtime.mjs` still
exempts win32, on the premise that the patch is Unix-only. That premise is now
false, but lifting the exemption also needs `pnpm rebuild` to force a source
build — otherwise the assertion fires and the remedy does not fix it. Left as a
follow-up rather than changed blind.
`requiresPatchedNodePtySourceBuild()` in `ensure-native-runtime.mjs` now covers
win32 as well, and `pnpm rebuild node-pty` sets `npm_config_build_from_source`
so the patched source build actually replaces the upstream prebuild.
-1
View File
@@ -209,7 +209,6 @@ 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'),
-36
View File
@@ -75,54 +75,18 @@
"max-lines": ["error", { "max": 1074, "skipBlankLines": true, "skipComments": true }]
}
},
{
"files": ["src/components/mobile-rich-markdown-editor-html.ts"],
"rules": {
"max-lines": ["error", { "max": 648, "skipBlankLines": true, "skipComments": true }]
}
},
{
"files": ["src/components/CustomKeyModal.tsx"],
"rules": {
"max-lines": ["error", { "max": 645, "skipBlankLines": true, "skipComments": true }]
}
},
{
"files": ["app/pair-scan.tsx"],
"rules": {
"max-lines": ["error", { "max": 531, "skipBlankLines": true, "skipComments": true }]
}
},
{
"files": ["scripts/mock-server.ts"],
"rules": {
"max-lines": ["error", { "max": 407, "skipBlankLines": true, "skipComments": true }]
}
},
{
"files": ["src/terminal/terminal-accessory-keys.ts"],
"rules": {
"max-lines": ["error", { "max": 388, "skipBlankLines": true, "skipComments": true }]
}
},
{
"files": ["app/troubleshoot.tsx"],
"rules": {
"max-lines": ["error", { "max": 436, "skipBlankLines": true, "skipComments": true }]
}
},
{
"files": ["scripts/repro-worktree-startup-stream.ts"],
"rules": {
"max-lines": ["error", { "max": 326, "skipBlankLines": true, "skipComments": true }]
}
},
{
"files": ["scripts/repro-terminal-colors.ts"],
"rules": {
"max-lines": ["error", { "max": 319, "skipBlankLines": true, "skipComments": true }]
}
},
{
"files": ["app/h/*/files/*.tsx"],
"rules": {
+2 -185
View File
@@ -2,7 +2,6 @@ import { useState, useRef, useCallback } from 'react'
import {
View,
Text,
StyleSheet,
Pressable,
ActivityIndicator,
Linking,
@@ -19,13 +18,14 @@ import {
} from '../src/transport/pre-profile-pairing-coordinator'
import type { ConnectionLogEntry, PairingOffer } from '../src/transport/types'
import { useRefreshHostClient } from '../src/transport/client-context'
import { colors, spacing, radii, typography } from '../src/theme/mobile-theme'
import { colors, spacing } from '../src/theme/mobile-theme'
import { TextInputModal } from '../src/components/TextInputModal'
import { ConnectionLog } from '../src/components/ConnectionLog'
import {
loadMobileOnboardingSteps,
mobileOnboardingDestination
} from '../src/onboarding/mobile-onboarding-plan'
import { pairScanStyles as styles } from '../src/pair-scan-styles'
// Why: see pair-confirm.tsx — cap initial-pair "Connecting…" so a broken
// route surfaces as a real error with the log visible instead of a
@@ -356,186 +356,3 @@ export default function PairScanScreen() {
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bgBase,
padding: spacing.lg
},
backButton: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
marginBottom: spacing.sm
},
steps: {
gap: spacing.sm,
marginBottom: spacing.lg,
marginLeft: 7
},
step: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm
},
stepBadge: {
width: 22,
height: 22,
borderRadius: 11,
backgroundColor: colors.bgRaised,
alignItems: 'center',
justifyContent: 'center'
},
stepNumber: {
fontSize: 12,
fontWeight: '700',
color: colors.textSecondary
},
stepText: {
fontSize: typography.bodySize,
color: colors.textSecondary
},
cameraWrap: {
flex: 1,
borderRadius: radii.camera,
overflow: 'hidden'
},
// Why: holds the layout slot while the camera is unmounted during
// paste, so the bottom action button doesn't snap up to fill the
// empty space.
cameraPlaceholder: {
flex: 1,
backgroundColor: colors.bgPanel,
borderRadius: radii.camera
},
camera: {
...StyleSheet.absoluteFillObject
},
reticle: {
...StyleSheet.absoluteFillObject,
alignItems: 'center',
justifyContent: 'center'
},
reticleFrame: {
position: 'relative'
},
corner: {
position: 'absolute',
width: 28,
height: 28,
borderColor: 'rgba(255,255,255,0.7)'
},
cornerTL: {
top: 0,
left: 0,
borderTopWidth: 2.5,
borderLeftWidth: 2.5,
borderTopLeftRadius: 6
},
cornerTR: {
top: 0,
right: 0,
borderTopWidth: 2.5,
borderRightWidth: 2.5,
borderTopRightRadius: 6
},
cornerBL: {
bottom: 0,
left: 0,
borderBottomWidth: 2.5,
borderLeftWidth: 2.5,
borderBottomLeftRadius: 6
},
cornerBR: {
bottom: 0,
right: 0,
borderBottomWidth: 2.5,
borderRightWidth: 2.5,
borderBottomRightRadius: 6
},
centered: {
flex: 1,
alignItems: 'center',
justifyContent: 'center'
},
title: {
fontSize: typography.titleSize,
fontWeight: '600',
color: colors.textPrimary,
marginBottom: spacing.sm
},
subtitle: {
maxWidth: 310,
fontSize: typography.bodySize,
color: colors.textSecondary,
textAlign: 'center',
marginBottom: spacing.xl,
lineHeight: 20
},
connectingText: {
color: colors.textSecondary,
fontSize: typography.bodySize,
marginTop: spacing.lg
},
logSlot: {
width: '100%',
marginTop: spacing.lg,
paddingHorizontal: spacing.sm
},
errorText: {
color: colors.statusRed,
fontSize: typography.bodySize,
textAlign: 'center',
marginBottom: spacing.xl,
lineHeight: 20
},
primaryButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.xs,
backgroundColor: colors.textPrimary,
paddingHorizontal: spacing.xl,
paddingVertical: spacing.sm + 2,
borderRadius: radii.button
},
primaryButtonText: {
color: colors.bgBase,
fontSize: typography.bodySize,
fontWeight: '600'
},
pasteButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.xs,
marginTop: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radii.button
},
pasteButtonPressed: {
opacity: 0.6
},
pasteButtonText: {
color: colors.textSecondary,
fontSize: typography.bodySize,
fontWeight: '500'
},
errorActions: {
alignItems: 'center',
gap: spacing.sm
},
secondaryButton: {
paddingHorizontal: spacing.xl,
paddingVertical: spacing.sm,
borderRadius: radii.button
},
secondaryButtonText: {
color: colors.textSecondary,
fontSize: typography.bodySize,
fontWeight: '500'
}
})
@@ -0,0 +1,60 @@
import { mkdirSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
const ESC = String.fromCharCode(27)
export type TerminalColorSnapshot = {
label: string
handle: string
cols: number | null
rows: number | null
serialized: string
lines: string
}
function countMatches(value: string, pattern: RegExp): number {
return value.match(pattern)?.length ?? 0
}
export function summarizeTerminalColorSnapshot(
snapshot: TerminalColorSnapshot
): Record<string, string | number | null> {
const data = snapshot.serialized
return {
label: snapshot.label,
handle: snapshot.handle,
cols: snapshot.cols,
rows: snapshot.rows,
serializedBytes: Buffer.byteLength(data),
sgrTotal: countMatches(data, new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')),
sgrColor: countMatches(
data,
new RegExp(
`${ESC}\\[(?:[0-9;:]*[;:])?(?:3[0-7]|4[0-7]|9[0-7]|10[0-7]|38[;:]|48[;:])[0-9;:]*m`,
'g'
)
),
sgrReset: countMatches(data, new RegExp(`${ESC}\\[(?:0|39|49|0;39;49)m`, 'g')),
altScreen: data.includes(`${ESC}[?1049h`) ? 'yes' : 'no',
containsTruecolor: data.includes('38;2') || data.includes('38:2') ? 'yes' : 'no',
containsPaletteColor: data.includes('38;5') || data.includes('38:5') ? 'yes' : 'no'
}
}
export function saveTerminalColorSnapshots(snapshots: TerminalColorSnapshot[]): string {
const dir = join(process.cwd(), 'terminal-color-repro')
mkdirSync(dir, { recursive: true })
for (const snapshot of snapshots) {
const base = snapshot.label.replace(/[^a-z0-9_-]/gi, '-')
writeFileSync(join(dir, `${base}.ansi`), snapshot.serialized || snapshot.lines)
writeFileSync(
join(dir, `${base}.escaped.txt`),
JSON.stringify(snapshot.serialized || snapshot.lines)
)
}
writeFileSync(
join(dir, 'summary.json'),
JSON.stringify(snapshots.map(summarizeTerminalColorSnapshot), null, 2)
)
return dir
}
+17 -59
View File
@@ -10,10 +10,13 @@
* ORCA_MOBILE_WS_URL=ws://127.0.0.1:6768 \
* pnpm exec tsx scripts/repro-terminal-colors.ts <deviceToken> <serverPublicKeyB64> <worktreeSelector> [handleA] [handleB]
*/
import { mkdirSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import nacl from 'tweetnacl'
import WebSocket from 'ws'
import {
saveTerminalColorSnapshots,
summarizeTerminalColorSnapshot,
type TerminalColorSnapshot
} from './repro-terminal-color-output'
const WS_URL = process.env.ORCA_MOBILE_WS_URL ?? 'ws://127.0.0.1:6768'
const token = process.argv[2]
@@ -21,7 +24,6 @@ const serverPublicKeyB64 = process.argv[3]
const worktreeSelector = process.argv[4]
const explicitHandleA = process.argv[5]
const explicitHandleB = process.argv[6]
const ESC = String.fromCharCode(27)
type RpcResponse = {
id: string
@@ -37,15 +39,6 @@ type PendingRequest = {
reject: (error: Error) => void
}
type Snapshot = {
label: string
handle: string
cols: number | null
rows: number | null
serialized: string
lines: string
}
if (!token || !serverPublicKeyB64 || !worktreeSelector) {
console.error(
'Usage: pnpm exec tsx scripts/repro-terminal-colors.ts <deviceToken> <serverPublicKeyB64> <worktreeSelector> [handleA] [handleB]'
@@ -158,9 +151,13 @@ async function ensureSecondHandle(ws: WebSocket, handleA: string): Promise<strin
return handle
}
async function captureSnapshot(ws: WebSocket, label: string, handle: string): Promise<Snapshot> {
async function captureSnapshot(
ws: WebSocket,
label: string,
handle: string
): Promise<TerminalColorSnapshot> {
const id = nextId()
const snapshot = await new Promise<Snapshot>((resolve, reject) => {
const snapshot = await new Promise<TerminalColorSnapshot>((resolve, reject) => {
const timeout = setTimeout(() => {
pending.delete(id)
streamListeners.delete(id)
@@ -207,48 +204,6 @@ async function captureSnapshot(ws: WebSocket, label: string, handle: string): Pr
return snapshot
}
function countMatches(value: string, pattern: RegExp): number {
return value.match(pattern)?.length ?? 0
}
function summarize(snapshot: Snapshot): Record<string, string | number | null> {
const data = snapshot.serialized
return {
label: snapshot.label,
handle: snapshot.handle,
cols: snapshot.cols,
rows: snapshot.rows,
serializedBytes: Buffer.byteLength(data),
sgrTotal: countMatches(data, new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')),
sgrColor: countMatches(
data,
new RegExp(
`${ESC}\\[(?:[0-9;:]*[;:])?(?:3[0-7]|4[0-7]|9[0-7]|10[0-7]|38[;:]|48[;:])[0-9;:]*m`,
'g'
)
),
sgrReset: countMatches(data, new RegExp(`${ESC}\\[(?:0|39|49|0;39;49)m`, 'g')),
altScreen: data.includes(`${ESC}[?1049h`) ? 'yes' : 'no',
containsTruecolor: data.includes('38;2') || data.includes('38:2') ? 'yes' : 'no',
containsPaletteColor: data.includes('38;5') || data.includes('38:5') ? 'yes' : 'no'
}
}
function saveSnapshots(snapshots: Snapshot[]): string {
const dir = join(process.cwd(), 'terminal-color-repro')
mkdirSync(dir, { recursive: true })
for (const snapshot of snapshots) {
const base = snapshot.label.replace(/[^a-z0-9_-]/gi, '-')
writeFileSync(join(dir, `${base}.ansi`), snapshot.serialized || snapshot.lines)
writeFileSync(
join(dir, `${base}.escaped.txt`),
JSON.stringify(snapshot.serialized || snapshot.lines)
)
}
writeFileSync(join(dir, 'summary.json'), JSON.stringify(snapshots.map(summarize), null, 2))
return dir
}
async function run(ws: WebSocket): Promise<void> {
ws.send(
JSON.stringify({
@@ -303,11 +258,14 @@ async function run(ws: WebSocket): Promise<void> {
const firstB = await captureSnapshot(ws, 'b-during-switch', handleB)
const secondA = await captureSnapshot(ws, 'a-after-switch', handleA)
const snapshots = [firstA, firstB, secondA]
const dir = saveSnapshots(snapshots)
const dir = saveTerminalColorSnapshots(snapshots)
console.table(snapshots.map(summarize))
console.table(snapshots.map(summarizeTerminalColorSnapshot))
console.log(`saved: ${dir}`)
if (summarize(firstA).sgrColor !== summarize(secondA).sgrColor) {
if (
summarizeTerminalColorSnapshot(firstA).sgrColor !==
summarizeTerminalColorSnapshot(secondA).sgrColor
) {
console.log(
'color SGR count changed between A snapshots: serialization/state changed on desktop'
)
@@ -0,0 +1,75 @@
import { mkdirSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
const ESC = String.fromCharCode(27)
export type StartupTerminalCapture = {
handle: string
title: string | null
scrollback: Record<string, unknown> | null
chunks: string[]
}
function stripAnsi(value: string): string {
return (
value
// eslint-disable-next-line no-control-regex -- intentional terminal escape stripping for repro summaries
.replace(/\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g, '')
// eslint-disable-next-line no-control-regex -- intentional terminal escape stripping for repro summaries
.replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, '')
.replace(/\r/g, '\n')
)
}
function normalizePreview(value: string): string {
return stripAnsi(value)
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.slice(0, 8)
.join('\n')
}
export function summarizeStartupCapture(capture: StartupTerminalCapture): Record<string, unknown> {
const serialized =
typeof capture.scrollback?.serialized === 'string' ? capture.scrollback.serialized : ''
const live = capture.chunks.join('')
const serializedPreview = normalizePreview(serialized)
const livePreview = normalizePreview(live)
return {
handle: capture.handle,
title: capture.title,
cols: capture.scrollback?.cols ?? null,
rows: capture.scrollback?.rows ?? null,
serializedBytes: Buffer.byteLength(serialized),
liveBytes: Buffer.byteLength(live),
liveChunks: capture.chunks.length,
serializedSgr: (serialized.match(new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')) ?? []).length,
liveSgr: (live.match(new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')) ?? []).length,
livePreviewContainedInSerialized:
livePreview.length > 0 && stripAnsi(serialized).includes(livePreview.split('\n')[0] ?? ''),
serializedPreview,
livePreview
}
}
export function saveStartupCaptures(
captures: StartupTerminalCapture[],
worktreeName: string
): string {
const dir = join(process.cwd(), 'terminal-startup-repro', worktreeName)
mkdirSync(dir, { recursive: true })
for (const capture of captures) {
const base = capture.handle.replace(/[^a-z0-9_-]/gi, '-')
const serialized =
typeof capture.scrollback?.serialized === 'string' ? capture.scrollback.serialized : ''
writeFileSync(join(dir, `${base}.serialized.ansi`), serialized)
writeFileSync(join(dir, `${base}.live.ansi`), capture.chunks.join(''))
writeFileSync(join(dir, `${base}.json`), JSON.stringify(capture, null, 2))
}
writeFileSync(
join(dir, 'summary.json'),
JSON.stringify(captures.map(summarizeStartupCapture), null, 2)
)
return dir
}
@@ -4,10 +4,15 @@
* Usage:
* pnpm exec tsx mobile/scripts/repro-worktree-startup-stream.ts <repoSelector> <worktreeName> [startupCommand]
*/
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import nacl from 'tweetnacl'
import WebSocket from 'ws'
import {
saveStartupCaptures,
summarizeStartupCapture,
type StartupTerminalCapture
} from './repro-worktree-startup-output'
const WS_URL = process.env.ORCA_MOBILE_WS_URL ?? 'ws://127.0.0.1:6768'
const USER_DATA =
@@ -15,7 +20,6 @@ const USER_DATA =
const repoSelector = process.argv[2]
const worktreeName = process.argv[3]
const startupCommand = process.argv[4] || 'claude'
const ESC = String.fromCharCode(27)
type RpcResponse = {
id: string
@@ -36,13 +40,6 @@ type TerminalInfo = {
title: string | null
}
type Capture = {
handle: string
title: string | null
scrollback: Record<string, unknown> | null
chunks: string[]
}
if (!repoSelector || !worktreeName) {
console.error(
'Usage: pnpm exec tsx mobile/scripts/repro-worktree-startup-stream.ts <repoSelector> <worktreeName> [startupCommand]'
@@ -141,26 +138,6 @@ function formatError(response: RpcResponse): string {
return JSON.stringify(response.error ?? response.result ?? response).slice(0, 1000)
}
function stripAnsi(value: string): string {
return (
value
// eslint-disable-next-line no-control-regex -- intentional terminal escape stripping for repro summaries
.replace(/\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g, '')
// eslint-disable-next-line no-control-regex -- intentional terminal escape stripping for repro summaries
.replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, '')
.replace(/\r/g, '\n')
)
}
function normalizePreview(value: string): string {
return stripAnsi(value)
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.slice(0, 8)
.join('\n')
}
async function waitForTerminals(ws: WebSocket, worktreeId: string): Promise<TerminalInfo[]> {
const deadline = Date.now() + 60_000
while (Date.now() < deadline) {
@@ -185,7 +162,7 @@ async function waitForTerminals(ws: WebSocket, worktreeId: string): Promise<Term
throw new Error('Timed out waiting for startup terminals')
}
async function subscribe(ws: WebSocket, capture: Capture): Promise<void> {
async function subscribe(ws: WebSocket, capture: StartupTerminalCapture): Promise<void> {
const id = nextId()
streamListeners.set(id, (result) => {
if (result.type === 'scrollback') {
@@ -204,44 +181,6 @@ async function subscribe(ws: WebSocket, capture: Capture): Promise<void> {
})
}
function summarize(capture: Capture): Record<string, unknown> {
const serialized =
typeof capture.scrollback?.serialized === 'string' ? capture.scrollback.serialized : ''
const live = capture.chunks.join('')
const serializedPreview = normalizePreview(serialized)
const livePreview = normalizePreview(live)
return {
handle: capture.handle,
title: capture.title,
cols: capture.scrollback?.cols ?? null,
rows: capture.scrollback?.rows ?? null,
serializedBytes: Buffer.byteLength(serialized),
liveBytes: Buffer.byteLength(live),
liveChunks: capture.chunks.length,
serializedSgr: (serialized.match(new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')) ?? []).length,
liveSgr: (live.match(new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')) ?? []).length,
livePreviewContainedInSerialized:
livePreview.length > 0 && stripAnsi(serialized).includes(livePreview.split('\n')[0] ?? ''),
serializedPreview,
livePreview
}
}
function save(captures: Capture[], worktreeName: string): string {
const dir = join(process.cwd(), 'terminal-startup-repro', worktreeName)
mkdirSync(dir, { recursive: true })
for (const capture of captures) {
const base = capture.handle.replace(/[^a-z0-9_-]/gi, '-')
const serialized =
typeof capture.scrollback?.serialized === 'string' ? capture.scrollback.serialized : ''
writeFileSync(join(dir, `${base}.serialized.ansi`), serialized)
writeFileSync(join(dir, `${base}.live.ansi`), capture.chunks.join(''))
writeFileSync(join(dir, `${base}.json`), JSON.stringify(capture, null, 2))
}
writeFileSync(join(dir, 'summary.json'), JSON.stringify(captures.map(summarize), null, 2))
return dir
}
async function run(ws: WebSocket): Promise<void> {
ws.send(
JSON.stringify({
@@ -312,8 +251,8 @@ async function run(ws: WebSocket): Promise<void> {
for (const capture of captures) {
await send(ws, 'terminal.unsubscribe', { subscriptionId: capture.handle }).catch(() => null)
}
const dir = save(captures, worktreeName)
console.table(captures.map(summarize))
const dir = saveStartupCaptures(captures, worktreeName)
console.table(captures.map(summarizeStartupCapture))
console.log(`saved: ${dir}`)
}
@@ -0,0 +1,276 @@
import { StyleSheet } from 'react-native'
import { colors, spacing, radii, typography } from '../theme/mobile-theme'
export const customKeyModalStyles = StyleSheet.create({
header: {
flexDirection: 'row',
alignItems: 'center',
paddingBottom: spacing.sm
},
backButton: {
width: 30,
height: 30,
borderRadius: 15,
alignItems: 'center',
justifyContent: 'center'
},
backButtonPressed: {
backgroundColor: colors.bgRaised
},
backSpacer: {
width: 30
},
title: {
flex: 1,
fontSize: 15,
fontWeight: '600',
color: colors.textPrimary,
textAlign: 'center'
},
group: {
backgroundColor: colors.bgPanel,
borderRadius: 12,
overflow: 'hidden'
},
separator: {
height: StyleSheet.hairlineWidth,
backgroundColor: colors.borderSubtle,
marginHorizontal: spacing.md
},
row: {
paddingVertical: spacing.md,
paddingHorizontal: spacing.md + 2
},
rowPressed: {
backgroundColor: colors.bgRaised
},
rowLabel: {
fontSize: typography.bodySize,
fontWeight: '500',
color: colors.textPrimary,
marginBottom: 1
},
rowHint: {
fontSize: 12,
color: colors.textMuted
},
shortcutForm: {
paddingTop: spacing.sm
},
preview: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.sm,
paddingVertical: spacing.lg + spacing.xs,
flexWrap: 'wrap'
},
previewKeycapRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm
},
previewPlus: {
color: colors.textMuted,
fontSize: 16
},
keycap: {
minWidth: 48,
height: 48,
paddingHorizontal: spacing.md,
borderRadius: 10,
backgroundColor: colors.bgPanel,
borderWidth: 1,
borderColor: colors.borderSubtle,
alignItems: 'center',
justifyContent: 'center'
},
keycapModifier: {
minWidth: 0
},
keycapWarn: {
borderColor: colors.statusAmber
},
keycapText: {
color: colors.textPrimary,
fontFamily: typography.monoFamily,
fontSize: 17,
fontWeight: '600'
},
keycapTextWarn: {
color: colors.statusAmber
},
keycapModifierText: {
color: colors.textSecondary,
fontFamily: typography.monoFamily,
fontSize: 14,
fontWeight: '600'
},
section: {
marginTop: spacing.md
},
sectionLabel: {
fontSize: 11,
color: colors.textMuted,
textTransform: 'uppercase',
letterSpacing: 0.8,
marginBottom: spacing.sm,
paddingLeft: 2
},
mods: {
flexDirection: 'row',
gap: spacing.sm
},
chip: {
flex: 1,
height: 40,
borderRadius: 8,
backgroundColor: colors.bgPanel,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 4
},
chipSelected: {
backgroundColor: colors.textPrimary
},
chipPressed: {
backgroundColor: colors.bgRaised
},
chipText: {
color: colors.textSecondary,
fontSize: 14,
fontWeight: '500'
},
chipTextSelected: {
color: colors.bgBase
},
chipGlyph: {
color: colors.textMuted,
fontSize: 13,
fontFamily: typography.monoFamily
},
chipGlyphSelected: {
color: 'rgba(10,10,10,0.5)'
},
keyInput: {
width: '100%',
height: 56,
borderRadius: 10,
backgroundColor: colors.bgPanel,
borderWidth: 1,
borderColor: colors.borderSubtle,
color: colors.textPrimary,
fontFamily: typography.monoFamily,
fontSize: 22,
fontWeight: '600',
textAlign: 'center'
},
moreLink: {
paddingVertical: spacing.sm,
alignItems: 'center'
},
moreLinkPressed: {
opacity: 0.6
},
moreLinkText: {
color: colors.textSecondary,
fontSize: 13,
textDecorationLine: 'underline'
},
specialKeysForm: {
paddingTop: spacing.xs,
paddingBottom: spacing.md,
gap: spacing.md
},
specialGroup: {
gap: spacing.xs
},
specialGroupTitle: {
fontSize: 11,
color: colors.textMuted,
textTransform: 'uppercase',
letterSpacing: 0.8,
paddingLeft: 2,
marginBottom: spacing.xs
},
keyGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
marginHorizontal: -spacing.xs / 2
},
keyCellWrap: {
paddingHorizontal: spacing.xs / 2,
paddingVertical: spacing.xs / 2
},
keyCell: {
height: 40,
borderRadius: 8,
backgroundColor: colors.bgPanel,
alignItems: 'center',
justifyContent: 'center'
},
keyCellPressed: {
backgroundColor: colors.bgRaised
},
keyCellSelected: {
backgroundColor: colors.textPrimary
},
keyCellText: {
fontSize: 13,
fontWeight: '600',
color: colors.textPrimary,
fontFamily: typography.monoFamily
},
keyCellTextSelected: {
color: colors.bgBase
},
macroForm: {
padding: spacing.md,
gap: spacing.sm
},
fieldLabel: {
fontSize: 13,
fontWeight: '500',
color: colors.textSecondary
},
fieldInput: {
backgroundColor: colors.bgBase,
color: colors.textPrimary,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
fontSize: 14,
fontFamily: typography.monoFamily,
borderWidth: 1,
borderColor: colors.borderSubtle
},
switchRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: spacing.xs
},
switchLabel: {
fontSize: typography.bodySize,
color: colors.textPrimary
},
saveButton: {
marginTop: spacing.md,
backgroundColor: colors.textPrimary,
paddingVertical: spacing.md,
borderRadius: 10,
alignItems: 'center'
},
saveButtonDisabled: {
backgroundColor: colors.bgRaised
},
saveButtonText: {
color: colors.bgBase,
fontSize: 15,
fontWeight: '600'
},
saveButtonTextDisabled: {
color: colors.textMuted
}
})
+3 -276
View File
@@ -1,8 +1,8 @@
import { useCallback, useMemo, useState } from 'react'
import { View, Text, Pressable, TextInput, StyleSheet, Switch } from 'react-native'
import { View, Text, Pressable, TextInput, Switch } from 'react-native'
import { ChevronLeft } from 'lucide-react-native'
import AsyncStorage from '@react-native-async-storage/async-storage'
import { colors, spacing, radii, typography } from '../theme/mobile-theme'
import { colors } from '../theme/mobile-theme'
import { BottomDrawer } from './BottomDrawer'
import {
buildTerminalShortcutKey,
@@ -11,6 +11,7 @@ import {
type TerminalShortcutModifier,
type TerminalShortcutSpecialKey
} from '../terminal/terminal-accessory-keys'
import { customKeyModalStyles as styles } from './CustomKeyModal.styles'
const CUSTOM_ACCESSORY_KEYS_STORAGE_KEY = 'orca:custom-accessory-keys'
@@ -409,277 +410,3 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged, onManageShortc
</BottomDrawer>
)
}
const styles = StyleSheet.create({
header: {
flexDirection: 'row',
alignItems: 'center',
paddingBottom: spacing.sm
},
backButton: {
width: 30,
height: 30,
borderRadius: 15,
alignItems: 'center',
justifyContent: 'center'
},
backButtonPressed: {
backgroundColor: colors.bgRaised
},
backSpacer: {
width: 30
},
title: {
flex: 1,
fontSize: 15,
fontWeight: '600',
color: colors.textPrimary,
textAlign: 'center'
},
group: {
backgroundColor: colors.bgPanel,
borderRadius: 12,
overflow: 'hidden'
},
separator: {
height: StyleSheet.hairlineWidth,
backgroundColor: colors.borderSubtle,
marginHorizontal: spacing.md
},
row: {
paddingVertical: spacing.md,
paddingHorizontal: spacing.md + 2
},
rowPressed: {
backgroundColor: colors.bgRaised
},
rowLabel: {
fontSize: typography.bodySize,
fontWeight: '500',
color: colors.textPrimary,
marginBottom: 1
},
rowHint: {
fontSize: 12,
color: colors.textMuted
},
shortcutForm: {
paddingTop: spacing.sm
},
preview: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.sm,
paddingVertical: spacing.lg + spacing.xs,
flexWrap: 'wrap'
},
previewKeycapRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm
},
previewPlus: {
color: colors.textMuted,
fontSize: 16
},
keycap: {
minWidth: 48,
height: 48,
paddingHorizontal: spacing.md,
borderRadius: 10,
backgroundColor: colors.bgPanel,
borderWidth: 1,
borderColor: colors.borderSubtle,
alignItems: 'center',
justifyContent: 'center'
},
keycapModifier: {
minWidth: 0
},
keycapWarn: {
borderColor: colors.statusAmber
},
keycapText: {
color: colors.textPrimary,
fontFamily: typography.monoFamily,
fontSize: 17,
fontWeight: '600'
},
keycapTextWarn: {
color: colors.statusAmber
},
keycapModifierText: {
color: colors.textSecondary,
fontFamily: typography.monoFamily,
fontSize: 14,
fontWeight: '600'
},
section: {
marginTop: spacing.md
},
sectionLabel: {
fontSize: 11,
color: colors.textMuted,
textTransform: 'uppercase',
letterSpacing: 0.8,
marginBottom: spacing.sm,
paddingLeft: 2
},
mods: {
flexDirection: 'row',
gap: spacing.sm
},
chip: {
flex: 1,
height: 40,
borderRadius: 8,
backgroundColor: colors.bgPanel,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 4
},
chipSelected: {
backgroundColor: colors.textPrimary
},
chipPressed: {
backgroundColor: colors.bgRaised
},
chipText: {
color: colors.textSecondary,
fontSize: 14,
fontWeight: '500'
},
chipTextSelected: {
color: colors.bgBase
},
chipGlyph: {
color: colors.textMuted,
fontSize: 13,
fontFamily: typography.monoFamily
},
chipGlyphSelected: {
color: 'rgba(10,10,10,0.5)'
},
keyInput: {
width: '100%',
height: 56,
borderRadius: 10,
backgroundColor: colors.bgPanel,
borderWidth: 1,
borderColor: colors.borderSubtle,
color: colors.textPrimary,
fontFamily: typography.monoFamily,
fontSize: 22,
fontWeight: '600',
textAlign: 'center'
},
moreLink: {
paddingVertical: spacing.sm,
alignItems: 'center'
},
moreLinkPressed: {
opacity: 0.6
},
moreLinkText: {
color: colors.textSecondary,
fontSize: 13,
textDecorationLine: 'underline'
},
specialKeysForm: {
paddingTop: spacing.xs,
paddingBottom: spacing.md,
gap: spacing.md
},
specialGroup: {
gap: spacing.xs
},
specialGroupTitle: {
fontSize: 11,
color: colors.textMuted,
textTransform: 'uppercase',
letterSpacing: 0.8,
paddingLeft: 2,
marginBottom: spacing.xs
},
keyGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
marginHorizontal: -spacing.xs / 2
},
keyCellWrap: {
paddingHorizontal: spacing.xs / 2,
paddingVertical: spacing.xs / 2
},
keyCell: {
height: 40,
borderRadius: 8,
backgroundColor: colors.bgPanel,
alignItems: 'center',
justifyContent: 'center'
},
keyCellPressed: {
backgroundColor: colors.bgRaised
},
keyCellSelected: {
backgroundColor: colors.textPrimary
},
keyCellText: {
fontSize: 13,
fontWeight: '600',
color: colors.textPrimary,
fontFamily: typography.monoFamily
},
keyCellTextSelected: {
color: colors.bgBase
},
macroForm: {
padding: spacing.md,
gap: spacing.sm
},
fieldLabel: {
fontSize: 13,
fontWeight: '500',
color: colors.textSecondary
},
fieldInput: {
backgroundColor: colors.bgBase,
color: colors.textPrimary,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
fontSize: 14,
fontFamily: typography.monoFamily,
borderWidth: 1,
borderColor: colors.borderSubtle
},
switchRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: spacing.xs
},
switchLabel: {
fontSize: typography.bodySize,
color: colors.textPrimary
},
saveButton: {
marginTop: spacing.md,
backgroundColor: colors.textPrimary,
paddingVertical: spacing.md,
borderRadius: 10,
alignItems: 'center'
},
saveButtonDisabled: {
backgroundColor: colors.bgRaised
},
saveButtonText: {
color: colors.bgBase,
fontSize: 15,
fontWeight: '600'
},
saveButtonTextDisabled: {
color: colors.textMuted
}
})
@@ -0,0 +1,279 @@
export const MOBILE_RICH_MARKDOWN_EDITOR_BODY_PRIMARY = [
';',
' --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;',
' --font-sans: Geist, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;',
' }',
' * { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }',
' html, body {',
' width: 100%;',
' min-height: 100%;',
' margin: 0;',
' background: var(--editor-surface);',
' color: var(--foreground);',
' font-family: var(--font-sans);',
' overscroll-behavior: contain;',
' }',
' body { overflow: auto; }',
' #editor {',
' min-height: 100vh;',
' padding: 18px 16px 112px;',
' outline: none;',
' font-size: 14px;',
' line-height: 1.7;',
' word-wrap: break-word;',
' overflow-wrap: anywhere;',
' caret-color: var(--foreground);',
' }',
' #editor[contenteditable="false"] {',
' opacity: 0.78;',
' }',
' #editor:empty::before,',
' #editor p.is-empty:first-child::before {',
' content: attr(data-placeholder);',
' color: var(--muted-foreground);',
' pointer-events: none;',
' }',
' #editor > :first-child { margin-top: 0; }',
' h1, h2, h3, h4, h5, h6 {',
' margin: 1.5em 0 0.5em;',
' font-weight: 600;',
' line-height: 1.3;',
' letter-spacing: 0;',
' }',
' h1 { font-size: 1.85em; font-weight: 700; }',
' h2 { font-size: 1.4em; }',
' h3 { font-size: 1.15em; }',
' p, ul, ol, blockquote { margin: 0.75em 0; }',
' ul, ol { padding-left: 1.5em; }',
' ul { list-style: disc; }',
' ol { list-style: decimal; }',
' li { margin: 0.15em 0; }',
' li > p { margin: 0; }',
' ul[data-type="taskList"] {',
' padding-left: 0;',
' list-style: none;',
' }',
' ul[data-type="taskList"] > li {',
' display: flex;',
' align-items: flex-start;',
' gap: 6px;',
' }',
' ul[data-type="taskList"] > li > label {',
' flex-shrink: 0;',
' display: flex;',
' align-items: center;',
' height: 1.55em;',
' user-select: none;',
' }',
' ul[data-type="taskList"] input[type="checkbox"] {',
' appearance: none;',
' width: 16px;',
' height: 16px;',
' margin: 0;',
' border: 1.5px solid color-mix(in srgb, var(--foreground) 55%, transparent);',
' border-radius: 4px;',
' background: transparent;',
' position: relative;',
' }',
' ul[data-type="taskList"] input[type="checkbox"]:checked {',
' background: var(--primary);',
' border-color: var(--primary);',
' }',
' ul[data-type="taskList"] input[type="checkbox"]:checked::after {',
' content: "";',
' position: absolute;',
' left: 4px;',
' top: 1px;',
' width: 5px;',
' height: 9px;',
' border: solid var(--primary-foreground);',
' border-width: 0 2px 2px 0;',
' transform: rotate(45deg);',
' }',
' ul[data-type="taskList"] input[type="checkbox"]:disabled {',
' opacity: 0.65;',
' }',
' ul[data-type="taskList"] > li > div {',
' flex: 1;',
' min-width: 0;',
' }',
' ul[data-type="taskList"] > li[data-checked="true"] > div {',
' text-decoration: line-through;',
' color: var(--muted-foreground);',
' }',
' blockquote {',
' padding: 0.5em 1em;',
' border-left: 3px solid var(--border);',
' border-radius: 0 6px 6px 0;',
' color: var(--muted-foreground);',
' background: color-mix(in srgb, var(--foreground) 2%, transparent);',
' }',
' table {',
' width: 100%;',
' margin: 1em 0;',
' border-collapse: collapse;',
' font-size: 0.95em;',
' }',
' th, td {',
' padding: 8px 14px;',
' border: 1px solid var(--border);',
' text-align: left;',
' vertical-align: top;',
' }',
' th {',
' font-weight: 600;',
' background: color-mix(in srgb, var(--foreground) 4%, transparent);',
' }',
' tr:nth-child(odd) td {',
' background: color-mix(in srgb, var(--foreground) 1.5%, transparent);',
' }',
' code {',
' padding: 0.2em 0.4em;',
' border-radius: 5px;',
' background: color-mix(in srgb, var(--foreground) 8%, transparent);',
' font-size: 0.88em;',
' font-family: var(--font-mono);',
' }',
' pre {',
' margin: 0.75em 0;',
' padding: 14px 18px;',
' border-radius: 8px;',
' border: 1px solid color-mix(in srgb, var(--foreground) 6%, transparent);',
' overflow-x: auto;',
' line-height: 1.55;',
' background: color-mix(in srgb, var(--foreground) 6%, transparent);',
' font-family: var(--font-mono);',
' white-space: pre-wrap;',
' }',
' pre::before {',
' content: attr(data-language);',
' display: block;',
' min-height: 13px;',
' margin-bottom: 4px;',
' color: var(--muted-foreground);',
' font-size: 11px;',
' text-transform: uppercase;',
' }',
' pre code {',
' padding: 0;',
' border-radius: 0;',
' background: transparent;',
' font-size: 0.92em;',
' white-space: pre-wrap;',
' }',
' hr {',
' margin: 1.5em 0;',
' border: none;',
' border-top: 1px solid var(--border);',
' }',
' a {',
' color: var(--accent-link);',
' text-decoration: underline;',
' text-decoration-color: color-mix(in srgb, currentColor 40%, transparent);',
' text-underline-offset: 2px;',
' }',
' img {',
' display: block;',
' max-width: 100%;',
' margin: 0.75em 0;',
' border-radius: 8px;',
' }',
' </style>',
'</head>',
'<body>',
' <main id="editor" contenteditable="true" data-placeholder="Start writing..."></main>',
' <script>',
' (function () {',
" var editor = document.getElementById('editor');",
" var lastMarkdown = '';",
' var inputTimer = null;',
' var documentGeneration = 0;',
' var editable = true;',
' var suppressInput = false;',
'',
' function post(message) {',
' window.ReactNativeWebView && window.ReactNativeWebView.postMessage(JSON.stringify(message));',
' }',
'',
' function decodeMarkdownEntities(value) {',
' return String(value).replace(/&(#x[0-9a-f]+|#\\d+|amp|lt|gt|quot|apos);/gi, function (match, entity) {',
' var lower = String(entity).toLowerCase();',
" if (lower === 'amp') return '&';",
" if (lower === 'lt') return '<';",
" if (lower === 'gt') return '>';",
" if (lower === 'quot') return '\"';",
" if (lower === 'apos') return \"'\";",
" if (lower.indexOf('#x') === 0) {",
' var hex = Number.parseInt(lower.slice(2), 16);',
' return Number.isFinite(hex) && hex >= 0 && hex <= 0x10ffff ? String.fromCodePoint(hex) : match;',
' }',
" if (lower.indexOf('#') === 0) {",
' var code = Number.parseInt(lower.slice(1), 10);',
' return Number.isFinite(code) && code >= 0 && code <= 0x10ffff ? String.fromCodePoint(code) : match;',
' }',
' return match;',
' });',
' }',
'',
' function escapeHtml(value) {',
' return decodeMarkdownEntities(value).replace(/[&<>"\']/g, function (char) {',
" return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;' })[char];",
' });',
' }',
'',
' function escapeAttr(value) {',
" return escapeHtml(value).replace(/\\n/g, ' ');",
' }',
'',
' function isSafeUrl(value) {',
" var trimmed = String(value || '').trim();",
' return !/^javascript:/i.test(trimmed);',
' }',
'',
' function splitTableRow(line) {',
" return line.trim().replace(/^\\|/, '').replace(/\\|$/, '').split('|').map(function (cell) {",
' return cell.trim();',
' });',
' }',
'',
' function isTableSeparator(line) {',
' var cells = splitTableRow(line);',
' return cells.length > 0 && cells.every(function (cell) {',
' return /^:?-{3,}:?$/.test(cell);',
' });',
' }',
'',
' function renderInline(text) {',
" var output = '';",
' var pattern = /(!\\[[^\\]]*\\]\\([^)]+\\)|`[^`]+`|~~[^~]+~~|\\*\\*[^*]+\\*\\*|__[^_]+__|\\*[^*\\n]+\\*|_[^_\\n]+_|\\[[^\\]]+\\]\\([^)]+\\)|https?:\\/\\/[^\\s<]+)/g;',
' var lastIndex = 0;',
' var match;',
' while ((match = pattern.exec(text))) {',
' output += escapeHtml(text.slice(lastIndex, match.index));',
' var token = match[0];',
' var image = token.match(/^!\\[([^\\]]*)\\]\\(([^)]+)\\)$/);',
' var link = token.match(/^\\[([^\\]]+)\\]\\(([^)]+)\\)$/);',
' if (image && isSafeUrl(image[2])) {',
" output += '<img src=\"' + escapeAttr(image[2]) + '\" alt=\"' + escapeAttr(image[1] || '') + '\" />';",
' } else if (link && isSafeUrl(link[2])) {',
" output += '<a href=\"' + escapeAttr(link[2]) + '\">' + renderInline(link[1]) + '</a>';",
' } else if (/^https?:\\/\\//i.test(token)) {',
" output += '<a href=\"' + escapeAttr(token) + '\">' + escapeHtml(token) + '</a>';",
" } else if (token.indexOf('`') === 0) {",
" output += '<code>' + escapeHtml(token.slice(1, -1)) + '</code>';",
" } else if (token.indexOf('~~') === 0) {",
" output += '<s>' + renderInline(token.slice(2, -2)) + '</s>';",
" } else if (token.indexOf('**') === 0 || token.indexOf('__') === 0) {",
" output += '<strong>' + renderInline(token.slice(2, -2)) + '</strong>';",
' } else {',
" output += '<em>' + renderInline(token.slice(1, -1)) + '</em>';",
' }',
' lastIndex = pattern.lastIndex;',
' }',
' output += escapeHtml(text.slice(lastIndex));',
' return output;',
' }',
'',
' function isBlockStart(line) {',
' return /^(```|#{1,6}\\s+|>\\s?|\\s*(?:[-*+]|\\d+[.)])\\s+|\\s*(-{3,}|\\*{3,}|_{3,})\\s*$)/.test(line);'
].join('\n')
@@ -0,0 +1,278 @@
export const MOBILE_RICH_MARKDOWN_EDITOR_BODY_SECONDARY = [
' }',
'',
' function indentationWidth(value) {',
" return String(value || '').replace(/\\t/g, ' ').length;",
' }',
'',
' function parseListLine(line) {',
" var match = String(line || '').match(/^(\\s*)((?:[-*+])|(?:\\d+[.)]))\\s+(.+)$/);",
' if (!match) return null;',
" var rawText = match[3] || '';",
' var task = rawText.match(/^\\[([ xX])\\]\\s+(.+)$/);',
' return {',
" indent: indentationWidth(match[1] || ''),",
" ordered: /^\\d/.test(match[2] || ''),",
" orderedNumber: /^\\d/.test(match[2] || '') ? Number.parseInt(match[2], 10) : null,",
" task: task ? task[1].toLowerCase() === 'x' : null,",
' text: task ? task[2] : rawText,',
' children: []',
' };',
' }',
'',
' function listKind(item) {',
" if (item.task !== null) return 'task';",
" return item.ordered ? 'ol' : 'ul';",
' }',
'',
' function parseListTree(lines, startIndex) {',
' var root = { indent: -1, children: [] };',
' var stack = [root];',
' var index = startIndex;',
' while (index < lines.length) {',
" var item = parseListLine(lines[index] || '');",
' if (!item) break;',
' while (stack.length > 1 && item.indent <= stack[stack.length - 1].indent) {',
' stack.pop();',
' }',
' stack[stack.length - 1].children.push(item);',
' stack.push(item);',
' index += 1;',
' }',
' return { items: root.children, nextIndex: index };',
' }',
'',
' function renderListItems(items) {',
' var html = [];',
' var index = 0;',
' while (index < items.length) {',
' var kind = listKind(items[index]);',
' var group = [];',
' while (index < items.length && listKind(items[index]) === kind) {',
' group.push(items[index]);',
' index += 1;',
' }',
" var tag = kind === 'ol' ? 'ol' : 'ul';",
" var attrs = kind === 'task' ? ' data-type=\"taskList\"' : kind === 'ol' && group[0].orderedNumber !== null ? ' start=\"' + group[0].orderedNumber + '\"' : '';",
" html.push('<' + tag + attrs + '>' + group.map(function (item) {",
" var children = item.children.length ? renderListItems(item.children) : '';",
" if (kind === 'task') {",
' var checked = item.task === true;',
" return '<li data-checked=\"' + String(checked) + '\"><label contenteditable=\"false\"><input type=\"checkbox\" ' + (checked ? 'checked ' : '') + (editable ? '' : 'disabled ') + '/></label><div><p>' + renderInline(item.text) + '</p>' + children + '</div></li>';",
' }',
" var orderedAttrs = kind === 'ol' && item.orderedNumber !== null ? ' value=\"' + item.orderedNumber + '\" data-list-number=\"' + item.orderedNumber + '\"' : '';",
" return '<li' + orderedAttrs + '><p>' + renderInline(item.text) + '</p>' + children + '</li>';",
" }).join('') + '</' + tag + '>');",
' }',
" return html.join('');",
' }',
'',
' function markdownToHtml(markdown) {',
" var lines = String(markdown || '').replace(/\\r\\n?/g, '\\n').split('\\n');",
' var html = [];',
' var index = 0;',
' while (index < lines.length) {',
" var line = lines[index] || '';",
' if (!line.trim()) {',
' index += 1;',
' continue;',
' }',
' var fence = line.match(/^\\```([^\\s`]*)\\s*$/);',
' if (fence) {',
' index += 1;',
' var code = [];',
" while (index < lines.length && !/^\\```\\s*$/.test(lines[index] || '')) {",
" code.push(lines[index] || '');",
' index += 1;',
' }',
' if (index < lines.length) index += 1;',
" html.push('<pre data-language=\"' + escapeAttr(fence[1] || '') + '\"><code>' + escapeHtml(code.join('\\n')) + '</code></pre>');",
' continue;',
' }',
' if (/^\\s*(-{3,}|\\*{3,}|_{3,})\\s*$/.test(line)) {',
" html.push('<hr />');",
' index += 1;',
' continue;',
' }',
" if (line.indexOf('|') >= 0 && index + 1 < lines.length && isTableSeparator(lines[index + 1] || '')) {",
' var headers = splitTableRow(line);',
' index += 2;',
' var rows = [];',
" while (index < lines.length && (lines[index] || '').indexOf('|') >= 0 && (lines[index] || '').trim()) {",
" rows.push(splitTableRow(lines[index] || ''));",
' index += 1;',
' }',
" html.push('<table><thead><tr>' + headers.map(function (cell) { return '<th>' + renderInline(cell) + '</th>'; }).join('') + '</tr></thead><tbody>' + rows.map(function (row) {",
" return '<tr>' + headers.map(function (_, cellIndex) { return '<td>' + renderInline(row[cellIndex] || '') + '</td>'; }).join('') + '</tr>';",
" }).join('') + '</tbody></table>');",
' continue;',
' }',
' var heading = line.match(/^(#{1,6})\\s+(.+)$/);',
' if (heading) {',
" html.push('<h' + heading[1].length + '>' + renderInline(heading[2].trim()) + '</h' + heading[1].length + '>');",
' index += 1;',
' continue;',
' }',
' if (/^>\\s?/.test(line)) {',
' var quote = [];',
" while (index < lines.length && /^>\\s?/.test(lines[index] || '')) {",
" quote.push((lines[index] || '').replace(/^>\\s?/, ''));",
' index += 1;',
' }',
" html.push('<blockquote><p>' + renderInline(quote.join('\\n').trim()).replace(/\\n/g, '<br />') + '</p></blockquote>');",
' continue;',
' }',
' if (/^\\s*(?:[-*+]|\\d+[.)])\\s+/.test(line)) {',
' var list = parseListTree(lines, index);',
' html.push(renderListItems(list.items));',
' index = list.nextIndex;',
' continue;',
' }',
' var paragraph = [];',
" while (index < lines.length && (lines[index] || '').trim() && !isBlockStart(lines[index] || '') && !(index + 1 < lines.length && (lines[index] || '').indexOf('|') >= 0 && isTableSeparator(lines[index + 1] || ''))) {",
" paragraph.push(lines[index] || '');",
' index += 1;',
' }',
" html.push('<p>' + renderInline(paragraph.join('\\n')).replace(/\\n/g, '<br />') + '</p>');",
' }',
" return html.join('\\n') || '<p class=\"is-empty\"><br /></p>';",
' }',
'',
' function textContent(node) {',
" return (node.textContent || '').replace(/\\u00a0/g, ' ');",
' }',
'',
' function inlineMarkdown(node) {',
" if (!node) return '';",
' if (node.nodeType === Node.TEXT_NODE) return textContent(node);',
" if (node.nodeType !== Node.ELEMENT_NODE) return '';",
' var el = node;',
' var tag = el.tagName.toLowerCase();',
" if (tag === 'br') return '\\n';",
" if (tag === 'strong' || tag === 'b') return '**' + inlineChildren(el) + '**';",
" if (tag === 'em' || tag === 'i') return '*' + inlineChildren(el) + '*';",
" if (tag === 's' || tag === 'del' || tag === 'strike') return '~~' + inlineChildren(el) + '~~';",
" if (tag === 'code' && el.parentElement && el.parentElement.tagName.toLowerCase() !== 'pre') return '`' + textContent(el) + '`';",
" if (tag === 'a') return '[' + inlineChildren(el) + '](' + (el.getAttribute('href') || '') + ')';",
" if (tag === 'img') return '![' + (el.getAttribute('alt') || '') + '](' + (el.getAttribute('src') || '') + ')';",
" if (tag === 'label') return '';",
' return inlineChildren(el);',
' }',
'',
' function inlineChildren(el) {',
" return Array.prototype.map.call(el.childNodes, inlineMarkdown).join('');",
' }',
'',
' function listItemText(li) {',
' var clone = li.cloneNode(true);',
" Array.prototype.forEach.call(clone.querySelectorAll('label'), function (label) { label.remove(); });",
" Array.prototype.forEach.call(clone.querySelectorAll('ul, ol'), function (list) { list.remove(); });",
' return inlineChildren(clone).trim();',
' }',
'',
' function directNestedLists(li) {',
" return Array.prototype.filter.call(li.querySelectorAll('ul, ol'), function (list) {",
" return list.closest('li') === li;",
' });',
' }',
'',
' function listMarkdown(el, depth) {',
' var tag = el.tagName.toLowerCase();',
" var isTask = el.getAttribute('data-type') === 'taskList';",
" var orderedStart = tag === 'ol' ? Number.parseInt(el.getAttribute('start') || '1', 10) : 1;",
' if (!Number.isFinite(orderedStart)) orderedStart = 1;',
" var indent = ' '.repeat(depth);",
' return Array.prototype.map.call(el.children, function (li, index) {',
" if (li.tagName.toLowerCase() !== 'li') return '';",
' var marker;',
' if (isTask) {',
' var input = li.querySelector(\'input[type="checkbox"]\');',
" marker = '- [' + (input && input.checked ? 'x' : ' ') + '] ';",
' } else {',
' var listNumber =',
' li.getAttribute &&',
" (li.getAttribute('data-list-number') || li.getAttribute('value'));",
" marker = tag === 'ol' ? String(listNumber || orderedStart + index) + '. ' : '- ';",
' }',
' var line = indent + marker + listItemText(li);',
' var nested = directNestedLists(li).map(function (list) {',
' return listMarkdown(list, depth + 1);',
" }).filter(Boolean).join('\\n');",
" return nested ? line + '\\n' + nested : line;",
" }).filter(Boolean).join('\\n');",
' }',
'',
' function blockMarkdown(node) {',
' if (node.nodeType === Node.TEXT_NODE) return textContent(node).trim();',
" if (node.nodeType !== Node.ELEMENT_NODE) return '';",
' var el = node;',
' var tag = el.tagName.toLowerCase();',
" if (tag.match(/^h[1-6]$/)) return '#'.repeat(Number(tag.slice(1))) + ' ' + inlineChildren(el).trim();",
" if (tag === 'p' || tag === 'div') return inlineChildren(el).trim();",
" if (tag === 'blockquote') {",
" return inlineChildren(el).trim().split('\\n').map(function (line) { return '> ' + line; }).join('\\n');",
' }',
" if (tag === 'pre') {",
" var lang = el.getAttribute('data-language') || '';",
" var code = textContent(el.querySelector('code') || el).replace(/\\n+$/g, '');",
" return '```' + lang + '\\n' + code + '\\n```';",
' }',
" if (tag === 'ul' || tag === 'ol') {",
' return listMarkdown(el, 0);',
' }',
" if (tag === 'table') {",
" var rows = Array.prototype.slice.call(el.querySelectorAll('tr'));",
" if (rows.length === 0) return '';",
' var cellsFor = function (row) {',
' return Array.prototype.map.call(row.children, function (cell) { return inlineChildren(cell).trim(); });',
' };',
' var headers = cellsFor(rows[0]);',
' var bodyRows = rows.slice(1).map(cellsFor);',
" return '| ' + headers.join(' | ') + ' |\\n| ' + headers.map(function () { return '---'; }).join(' | ') + ' |' + (bodyRows.length ? '\\n' + bodyRows.map(function (row) { return '| ' + row.join(' | ') + ' |'; }).join('\\n') : '');",
' }',
" if (tag === 'hr') return '---';",
" if (tag === 'img') return inlineMarkdown(el);",
' return inlineChildren(el).trim();',
' }',
'',
' function currentMarkdown() {',
' return Array.prototype.map.call(editor.childNodes, blockMarkdown).filter(function (block) {',
' return block.trim().length > 0;',
" }).join('\\n\\n').trimEnd();",
' }',
'',
' function syncTaskCheckboxesDisabled() {',
' Array.prototype.forEach.call(editor.querySelectorAll(\'input[type="checkbox"]\'), function (input) {',
' input.disabled = !editable;',
' });',
' }',
'',
' function emitChange() {',
' if (suppressInput || !editable) return;',
' window.clearTimeout(inputTimer);',
' var pendingGeneration = documentGeneration;',
' lastMarkdown = currentMarkdown();',
" post({ type: 'change', markdown: lastMarkdown, generation: pendingGeneration });",
' }',
'',
' function setMarkdown(markdown, generation) {',
' window.clearTimeout(inputTimer);',
' documentGeneration = Number(generation) || 0;',
' suppressInput = true;',
" // Why: replacing innerHTML detaches the remembered caret's nodes.",
' savedSelectionRange = null;',
' selectionDroppedOnBlur = false;',
" lastMarkdown = String(markdown || '');",
' editor.innerHTML = markdownToHtml(lastMarkdown);',
' syncTaskCheckboxesDisabled();',
' suppressInput = false;',
' }',
'',
' function setEditable(nextEditable) {',
' editable = Boolean(nextEditable);',
" editor.setAttribute('contenteditable', editable ? 'true' : 'false');",
' syncTaskCheckboxesDisabled();',
' }',
'',
''
].join('\n')
@@ -0,0 +1,93 @@
export const MOBILE_RICH_MARKDOWN_EDITOR_AFTER_KEYBOARD_DISMISS = [
'',
' function runCommand(command) {',
" if (!editable || editor.getAttribute('contenteditable') !== 'true') return;",
' restoreSelectionOrEnd();',
" if (command === 'paragraph') document.execCommand('formatBlock', false, 'p');",
" else if (command === 'heading1') document.execCommand('formatBlock', false, 'h1');",
" else if (command === 'heading2') document.execCommand('formatBlock', false, 'h2');",
" else if (command === 'heading3') document.execCommand('formatBlock', false, 'h3');",
" else if (command === 'bold') document.execCommand('bold');",
" else if (command === 'italic') document.execCommand('italic');",
" else if (command === 'strike') document.execCommand('strikeThrough');",
" else if (command === 'bulletList') document.execCommand('insertUnorderedList');",
" else if (command === 'orderedList') document.execCommand('insertOrderedList');",
" else if (command === 'quote') document.execCommand('formatBlock', false, 'blockquote');",
" else if (command === 'inlineCode') wrapSelection('code');",
" else if (command === 'codeBlock') document.execCommand('insertHTML', false, '<pre data-language=\"\"><code>code</code></pre><p><br></p>');",
' else if (command === \'taskList\') document.execCommand(\'insertHTML\', false, \'<ul data-type="taskList"><li data-checked="false"><label contenteditable="false"><input type="checkbox" /></label><div><p>Task</p></div></li></ul>\');',
" else if (command === 'link') {",
" var href = window.prompt('Link URL');",
" if (href && isSafeUrl(href)) document.execCommand('createLink', false, href);",
" } else if (command === 'image') {",
" var src = window.prompt('Image URL');",
" if (src && isSafeUrl(src)) document.execCommand('insertImage', false, src);",
' }',
' syncTaskCheckboxesDisabled();',
' emitChange();',
' }',
'',
" editor.addEventListener('input', function () {",
' selectionDroppedOnBlur = false;',
' if (editable) emitChange();',
' });',
" editor.addEventListener('change', function (event) {",
' var input = event.target && event.target.closest && event.target.closest(\'input[type="checkbox"]\');',
' if (input) {',
' if (!editable) {',
' event.preventDefault();',
' return;',
' }',
" var li = input.closest('li');",
" if (li) li.setAttribute('data-checked', input.checked ? 'true' : 'false');",
' }',
' if (editable) emitChange();',
' });',
" editor.addEventListener('click', function (event) {",
" var link = event.target && event.target.closest && event.target.closest('a[href]');",
' if (link) {',
' event.preventDefault();',
" post({ type: 'openLink', url: link.getAttribute('href') || '' });",
' return;',
' }',
' var input = event.target && event.target.closest && event.target.closest(\'input[type="checkbox"]\');',
' if (!input) {',
' if (!editable) return;',
' // Why: a task-list label forwards its click to the checkbox, so refocusing here would steal it and re-open the keyboard.',
' var uneditable = event.target && event.target.closest && event.target.closest(\'[contenteditable="false"]\');',
' if (uneditable && uneditable !== editor) return;',
' selectionDroppedOnBlur = false;',
' if (document.activeElement === editor) return;',
' // Why: refocusing after a dismissal otherwise types at the stale caret, not where the user tapped.',
' var caret = caretRangeAtPoint(event.clientX, event.clientY);',
' focusEditor();',
' if (caret && editor.contains(caret.commonAncestorContainer)) applySelectionRange(caret);',
' return;',
' }',
' if (!editable) {',
' event.preventDefault();',
' return;',
' }',
" var li = input.closest('li');",
" if (li) li.setAttribute('data-checked', input.checked ? 'true' : 'false');",
' emitChange();',
' });',
" editor.addEventListener('keydown', function (event) {",
" if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'b') {",
' event.preventDefault();',
" runCommand('bold');",
' }',
' });',
'',
' window.__orcaRichMarkdown = { setMarkdown: setMarkdown, setEditable: setEditable, runCommand: runCommand, currentMarkdown: currentMarkdown, dismissKeyboard: dismissKeyboard };',
''
].join('\n')
export const MOBILE_RICH_MARKDOWN_EDITOR_DOCUMENT_END = [
'',
" post({ type: 'ready' });",
' })();',
' </script>',
'</body>',
'</html>'
].join('\n')
@@ -2,6 +2,12 @@ import { colors } from '../theme/mobile-theme'
import { MOBILE_RICH_MARKDOWN_KEYBOARD_DISMISS_SCRIPT } from './mobile-rich-markdown-keyboard-dismiss-script'
import { MOBILE_RICH_MARKDOWN_KEYBOARD_INSET_SCRIPT } from './mobile-rich-markdown-editor-keyboard-inset-script'
import { MOBILE_RICH_MARKDOWN_SELECTION_SCRIPT } from './mobile-rich-markdown-selection-script'
import { MOBILE_RICH_MARKDOWN_EDITOR_BODY_PRIMARY } from './mobile-rich-markdown-editor-body-primary'
import { MOBILE_RICH_MARKDOWN_EDITOR_BODY_SECONDARY } from './mobile-rich-markdown-editor-body-secondary'
import {
MOBILE_RICH_MARKDOWN_EDITOR_AFTER_KEYBOARD_DISMISS,
MOBILE_RICH_MARKDOWN_EDITOR_DOCUMENT_END
} from './mobile-rich-markdown-editor-document-suffix'
export function escapeInjectedJavaScriptString(value: string): string {
return JSON.stringify(value).replace(/<\/script/gi, '<\\/script')
@@ -24,644 +30,7 @@ export function buildMobileRichMarkdownEditorHtml(): string {
--border: ${colors.borderSubtle};
--primary: ${colors.textPrimary};
--primary-foreground: ${colors.bgBase};
--accent-link: ${colors.accentBlue};
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
--font-sans: Geist, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html, body {
width: 100%;
min-height: 100%;
margin: 0;
background: var(--editor-surface);
color: var(--foreground);
font-family: var(--font-sans);
overscroll-behavior: contain;
}
body { overflow: auto; }
#editor {
min-height: 100vh;
padding: 18px 16px 112px;
outline: none;
font-size: 14px;
line-height: 1.7;
word-wrap: break-word;
overflow-wrap: anywhere;
caret-color: var(--foreground);
}
#editor[contenteditable="false"] {
opacity: 0.78;
}
#editor:empty::before,
#editor p.is-empty:first-child::before {
content: attr(data-placeholder);
color: var(--muted-foreground);
pointer-events: none;
}
#editor > :first-child { margin-top: 0; }
h1, h2, h3, h4, h5, h6 {
margin: 1.5em 0 0.5em;
font-weight: 600;
line-height: 1.3;
letter-spacing: 0;
}
h1 { font-size: 1.85em; font-weight: 700; }
h2 { font-size: 1.4em; }
h3 { font-size: 1.15em; }
p, ul, ol, blockquote { margin: 0.75em 0; }
ul, ol { padding-left: 1.5em; }
ul { list-style: disc; }
ol { list-style: decimal; }
li { margin: 0.15em 0; }
li > p { margin: 0; }
ul[data-type="taskList"] {
padding-left: 0;
list-style: none;
}
ul[data-type="taskList"] > li {
display: flex;
align-items: flex-start;
gap: 6px;
}
ul[data-type="taskList"] > li > label {
flex-shrink: 0;
display: flex;
align-items: center;
height: 1.55em;
user-select: none;
}
ul[data-type="taskList"] input[type="checkbox"] {
appearance: none;
width: 16px;
height: 16px;
margin: 0;
border: 1.5px solid color-mix(in srgb, var(--foreground) 55%, transparent);
border-radius: 4px;
background: transparent;
position: relative;
}
ul[data-type="taskList"] input[type="checkbox"]:checked {
background: var(--primary);
border-color: var(--primary);
}
ul[data-type="taskList"] input[type="checkbox"]:checked::after {
content: "";
position: absolute;
left: 4px;
top: 1px;
width: 5px;
height: 9px;
border: solid var(--primary-foreground);
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
ul[data-type="taskList"] input[type="checkbox"]:disabled {
opacity: 0.65;
}
ul[data-type="taskList"] > li > div {
flex: 1;
min-width: 0;
}
ul[data-type="taskList"] > li[data-checked="true"] > div {
text-decoration: line-through;
color: var(--muted-foreground);
}
blockquote {
padding: 0.5em 1em;
border-left: 3px solid var(--border);
border-radius: 0 6px 6px 0;
color: var(--muted-foreground);
background: color-mix(in srgb, var(--foreground) 2%, transparent);
}
table {
width: 100%;
margin: 1em 0;
border-collapse: collapse;
font-size: 0.95em;
}
th, td {
padding: 8px 14px;
border: 1px solid var(--border);
text-align: left;
vertical-align: top;
}
th {
font-weight: 600;
background: color-mix(in srgb, var(--foreground) 4%, transparent);
}
tr:nth-child(odd) td {
background: color-mix(in srgb, var(--foreground) 1.5%, transparent);
}
code {
padding: 0.2em 0.4em;
border-radius: 5px;
background: color-mix(in srgb, var(--foreground) 8%, transparent);
font-size: 0.88em;
font-family: var(--font-mono);
}
pre {
margin: 0.75em 0;
padding: 14px 18px;
border-radius: 8px;
border: 1px solid color-mix(in srgb, var(--foreground) 6%, transparent);
overflow-x: auto;
line-height: 1.55;
background: color-mix(in srgb, var(--foreground) 6%, transparent);
font-family: var(--font-mono);
white-space: pre-wrap;
}
pre::before {
content: attr(data-language);
display: block;
min-height: 13px;
margin-bottom: 4px;
color: var(--muted-foreground);
font-size: 11px;
text-transform: uppercase;
}
pre code {
padding: 0;
border-radius: 0;
background: transparent;
font-size: 0.92em;
white-space: pre-wrap;
}
hr {
margin: 1.5em 0;
border: none;
border-top: 1px solid var(--border);
}
a {
color: var(--accent-link);
text-decoration: underline;
text-decoration-color: color-mix(in srgb, currentColor 40%, transparent);
text-underline-offset: 2px;
}
img {
display: block;
max-width: 100%;
margin: 0.75em 0;
border-radius: 8px;
}
</style>
</head>
<body>
<main id="editor" contenteditable="true" data-placeholder="Start writing..."></main>
<script>
(function () {
var editor = document.getElementById('editor');
var lastMarkdown = '';
var inputTimer = null;
var documentGeneration = 0;
var editable = true;
var suppressInput = false;
function post(message) {
window.ReactNativeWebView && window.ReactNativeWebView.postMessage(JSON.stringify(message));
}
function decodeMarkdownEntities(value) {
return String(value).replace(/&(#x[0-9a-f]+|#\\d+|amp|lt|gt|quot|apos);/gi, function (match, entity) {
var lower = String(entity).toLowerCase();
if (lower === 'amp') return '&';
if (lower === 'lt') return '<';
if (lower === 'gt') return '>';
if (lower === 'quot') return '"';
if (lower === 'apos') return "'";
if (lower.indexOf('#x') === 0) {
var hex = Number.parseInt(lower.slice(2), 16);
return Number.isFinite(hex) && hex >= 0 && hex <= 0x10ffff ? String.fromCodePoint(hex) : match;
}
if (lower.indexOf('#') === 0) {
var code = Number.parseInt(lower.slice(1), 10);
return Number.isFinite(code) && code >= 0 && code <= 0x10ffff ? String.fromCodePoint(code) : match;
}
return match;
});
}
function escapeHtml(value) {
return decodeMarkdownEntities(value).replace(/[&<>"']/g, function (char) {
return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[char];
});
}
function escapeAttr(value) {
return escapeHtml(value).replace(/\\n/g, ' ');
}
function isSafeUrl(value) {
var trimmed = String(value || '').trim();
return !/^javascript:/i.test(trimmed);
}
function splitTableRow(line) {
return line.trim().replace(/^\\|/, '').replace(/\\|$/, '').split('|').map(function (cell) {
return cell.trim();
});
}
function isTableSeparator(line) {
var cells = splitTableRow(line);
return cells.length > 0 && cells.every(function (cell) {
return /^:?-{3,}:?$/.test(cell);
});
}
function renderInline(text) {
var output = '';
var pattern = /(!\\[[^\\]]*\\]\\([^)]+\\)|\`[^\`]+\`|~~[^~]+~~|\\*\\*[^*]+\\*\\*|__[^_]+__|\\*[^*\\n]+\\*|_[^_\\n]+_|\\[[^\\]]+\\]\\([^)]+\\)|https?:\\/\\/[^\\s<]+)/g;
var lastIndex = 0;
var match;
while ((match = pattern.exec(text))) {
output += escapeHtml(text.slice(lastIndex, match.index));
var token = match[0];
var image = token.match(/^!\\[([^\\]]*)\\]\\(([^)]+)\\)$/);
var link = token.match(/^\\[([^\\]]+)\\]\\(([^)]+)\\)$/);
if (image && isSafeUrl(image[2])) {
output += '<img src="' + escapeAttr(image[2]) + '" alt="' + escapeAttr(image[1] || '') + '" />';
} else if (link && isSafeUrl(link[2])) {
output += '<a href="' + escapeAttr(link[2]) + '">' + renderInline(link[1]) + '</a>';
} else if (/^https?:\\/\\//i.test(token)) {
output += '<a href="' + escapeAttr(token) + '">' + escapeHtml(token) + '</a>';
} else if (token.indexOf('\`') === 0) {
output += '<code>' + escapeHtml(token.slice(1, -1)) + '</code>';
} else if (token.indexOf('~~') === 0) {
output += '<s>' + renderInline(token.slice(2, -2)) + '</s>';
} else if (token.indexOf('**') === 0 || token.indexOf('__') === 0) {
output += '<strong>' + renderInline(token.slice(2, -2)) + '</strong>';
} else {
output += '<em>' + renderInline(token.slice(1, -1)) + '</em>';
}
lastIndex = pattern.lastIndex;
}
output += escapeHtml(text.slice(lastIndex));
return output;
}
function isBlockStart(line) {
return /^(\`\`\`|#{1,6}\\s+|>\\s?|\\s*(?:[-*+]|\\d+[.)])\\s+|\\s*(-{3,}|\\*{3,}|_{3,})\\s*$)/.test(line);
}
function indentationWidth(value) {
return String(value || '').replace(/\\t/g, ' ').length;
}
function parseListLine(line) {
var match = String(line || '').match(/^(\\s*)((?:[-*+])|(?:\\d+[.)]))\\s+(.+)$/);
if (!match) return null;
var rawText = match[3] || '';
var task = rawText.match(/^\\[([ xX])\\]\\s+(.+)$/);
return {
indent: indentationWidth(match[1] || ''),
ordered: /^\\d/.test(match[2] || ''),
orderedNumber: /^\\d/.test(match[2] || '') ? Number.parseInt(match[2], 10) : null,
task: task ? task[1].toLowerCase() === 'x' : null,
text: task ? task[2] : rawText,
children: []
};
}
function listKind(item) {
if (item.task !== null) return 'task';
return item.ordered ? 'ol' : 'ul';
}
function parseListTree(lines, startIndex) {
var root = { indent: -1, children: [] };
var stack = [root];
var index = startIndex;
while (index < lines.length) {
var item = parseListLine(lines[index] || '');
if (!item) break;
while (stack.length > 1 && item.indent <= stack[stack.length - 1].indent) {
stack.pop();
}
stack[stack.length - 1].children.push(item);
stack.push(item);
index += 1;
}
return { items: root.children, nextIndex: index };
}
function renderListItems(items) {
var html = [];
var index = 0;
while (index < items.length) {
var kind = listKind(items[index]);
var group = [];
while (index < items.length && listKind(items[index]) === kind) {
group.push(items[index]);
index += 1;
}
var tag = kind === 'ol' ? 'ol' : 'ul';
var attrs = kind === 'task' ? ' data-type="taskList"' : kind === 'ol' && group[0].orderedNumber !== null ? ' start="' + group[0].orderedNumber + '"' : '';
html.push('<' + tag + attrs + '>' + group.map(function (item) {
var children = item.children.length ? renderListItems(item.children) : '';
if (kind === 'task') {
var checked = item.task === true;
return '<li data-checked="' + String(checked) + '"><label contenteditable="false"><input type="checkbox" ' + (checked ? 'checked ' : '') + (editable ? '' : 'disabled ') + '/></label><div><p>' + renderInline(item.text) + '</p>' + children + '</div></li>';
}
var orderedAttrs = kind === 'ol' && item.orderedNumber !== null ? ' value="' + item.orderedNumber + '" data-list-number="' + item.orderedNumber + '"' : '';
return '<li' + orderedAttrs + '><p>' + renderInline(item.text) + '</p>' + children + '</li>';
}).join('') + '</' + tag + '>');
}
return html.join('');
}
function markdownToHtml(markdown) {
var lines = String(markdown || '').replace(/\\r\\n?/g, '\\n').split('\\n');
var html = [];
var index = 0;
while (index < lines.length) {
var line = lines[index] || '';
if (!line.trim()) {
index += 1;
continue;
}
var fence = line.match(/^\\\`\`\`([^\\s\`]*)\\s*$/);
if (fence) {
index += 1;
var code = [];
while (index < lines.length && !/^\\\`\`\`\\s*$/.test(lines[index] || '')) {
code.push(lines[index] || '');
index += 1;
}
if (index < lines.length) index += 1;
html.push('<pre data-language="' + escapeAttr(fence[1] || '') + '"><code>' + escapeHtml(code.join('\\n')) + '</code></pre>');
continue;
}
if (/^\\s*(-{3,}|\\*{3,}|_{3,})\\s*$/.test(line)) {
html.push('<hr />');
index += 1;
continue;
}
if (line.indexOf('|') >= 0 && index + 1 < lines.length && isTableSeparator(lines[index + 1] || '')) {
var headers = splitTableRow(line);
index += 2;
var rows = [];
while (index < lines.length && (lines[index] || '').indexOf('|') >= 0 && (lines[index] || '').trim()) {
rows.push(splitTableRow(lines[index] || ''));
index += 1;
}
html.push('<table><thead><tr>' + headers.map(function (cell) { return '<th>' + renderInline(cell) + '</th>'; }).join('') + '</tr></thead><tbody>' + rows.map(function (row) {
return '<tr>' + headers.map(function (_, cellIndex) { return '<td>' + renderInline(row[cellIndex] || '') + '</td>'; }).join('') + '</tr>';
}).join('') + '</tbody></table>');
continue;
}
var heading = line.match(/^(#{1,6})\\s+(.+)$/);
if (heading) {
html.push('<h' + heading[1].length + '>' + renderInline(heading[2].trim()) + '</h' + heading[1].length + '>');
index += 1;
continue;
}
if (/^>\\s?/.test(line)) {
var quote = [];
while (index < lines.length && /^>\\s?/.test(lines[index] || '')) {
quote.push((lines[index] || '').replace(/^>\\s?/, ''));
index += 1;
}
html.push('<blockquote><p>' + renderInline(quote.join('\\n').trim()).replace(/\\n/g, '<br />') + '</p></blockquote>');
continue;
}
if (/^\\s*(?:[-*+]|\\d+[.)])\\s+/.test(line)) {
var list = parseListTree(lines, index);
html.push(renderListItems(list.items));
index = list.nextIndex;
continue;
}
var paragraph = [];
while (index < lines.length && (lines[index] || '').trim() && !isBlockStart(lines[index] || '') && !(index + 1 < lines.length && (lines[index] || '').indexOf('|') >= 0 && isTableSeparator(lines[index + 1] || ''))) {
paragraph.push(lines[index] || '');
index += 1;
}
html.push('<p>' + renderInline(paragraph.join('\\n')).replace(/\\n/g, '<br />') + '</p>');
}
return html.join('\\n') || '<p class="is-empty"><br /></p>';
}
function textContent(node) {
return (node.textContent || '').replace(/\\u00a0/g, ' ');
}
function inlineMarkdown(node) {
if (!node) return '';
if (node.nodeType === Node.TEXT_NODE) return textContent(node);
if (node.nodeType !== Node.ELEMENT_NODE) return '';
var el = node;
var tag = el.tagName.toLowerCase();
if (tag === 'br') return '\\n';
if (tag === 'strong' || tag === 'b') return '**' + inlineChildren(el) + '**';
if (tag === 'em' || tag === 'i') return '*' + inlineChildren(el) + '*';
if (tag === 's' || tag === 'del' || tag === 'strike') return '~~' + inlineChildren(el) + '~~';
if (tag === 'code' && el.parentElement && el.parentElement.tagName.toLowerCase() !== 'pre') return '\`' + textContent(el) + '\`';
if (tag === 'a') return '[' + inlineChildren(el) + '](' + (el.getAttribute('href') || '') + ')';
if (tag === 'img') return '![' + (el.getAttribute('alt') || '') + '](' + (el.getAttribute('src') || '') + ')';
if (tag === 'label') return '';
return inlineChildren(el);
}
function inlineChildren(el) {
return Array.prototype.map.call(el.childNodes, inlineMarkdown).join('');
}
function listItemText(li) {
var clone = li.cloneNode(true);
Array.prototype.forEach.call(clone.querySelectorAll('label'), function (label) { label.remove(); });
Array.prototype.forEach.call(clone.querySelectorAll('ul, ol'), function (list) { list.remove(); });
return inlineChildren(clone).trim();
}
function directNestedLists(li) {
return Array.prototype.filter.call(li.querySelectorAll('ul, ol'), function (list) {
return list.closest('li') === li;
});
}
function listMarkdown(el, depth) {
var tag = el.tagName.toLowerCase();
var isTask = el.getAttribute('data-type') === 'taskList';
var orderedStart = tag === 'ol' ? Number.parseInt(el.getAttribute('start') || '1', 10) : 1;
if (!Number.isFinite(orderedStart)) orderedStart = 1;
var indent = ' '.repeat(depth);
return Array.prototype.map.call(el.children, function (li, index) {
if (li.tagName.toLowerCase() !== 'li') return '';
var marker;
if (isTask) {
var input = li.querySelector('input[type="checkbox"]');
marker = '- [' + (input && input.checked ? 'x' : ' ') + '] ';
} else {
var listNumber =
li.getAttribute &&
(li.getAttribute('data-list-number') || li.getAttribute('value'));
marker = tag === 'ol' ? String(listNumber || orderedStart + index) + '. ' : '- ';
}
var line = indent + marker + listItemText(li);
var nested = directNestedLists(li).map(function (list) {
return listMarkdown(list, depth + 1);
}).filter(Boolean).join('\\n');
return nested ? line + '\\n' + nested : line;
}).filter(Boolean).join('\\n');
}
function blockMarkdown(node) {
if (node.nodeType === Node.TEXT_NODE) return textContent(node).trim();
if (node.nodeType !== Node.ELEMENT_NODE) return '';
var el = node;
var tag = el.tagName.toLowerCase();
if (tag.match(/^h[1-6]$/)) return '#'.repeat(Number(tag.slice(1))) + ' ' + inlineChildren(el).trim();
if (tag === 'p' || tag === 'div') return inlineChildren(el).trim();
if (tag === 'blockquote') {
return inlineChildren(el).trim().split('\\n').map(function (line) { return '> ' + line; }).join('\\n');
}
if (tag === 'pre') {
var lang = el.getAttribute('data-language') || '';
var code = textContent(el.querySelector('code') || el).replace(/\\n+$/g, '');
return '\`\`\`' + lang + '\\n' + code + '\\n\`\`\`';
}
if (tag === 'ul' || tag === 'ol') {
return listMarkdown(el, 0);
}
if (tag === 'table') {
var rows = Array.prototype.slice.call(el.querySelectorAll('tr'));
if (rows.length === 0) return '';
var cellsFor = function (row) {
return Array.prototype.map.call(row.children, function (cell) { return inlineChildren(cell).trim(); });
};
var headers = cellsFor(rows[0]);
var bodyRows = rows.slice(1).map(cellsFor);
return '| ' + headers.join(' | ') + ' |\\n| ' + headers.map(function () { return '---'; }).join(' | ') + ' |' + (bodyRows.length ? '\\n' + bodyRows.map(function (row) { return '| ' + row.join(' | ') + ' |'; }).join('\\n') : '');
}
if (tag === 'hr') return '---';
if (tag === 'img') return inlineMarkdown(el);
return inlineChildren(el).trim();
}
function currentMarkdown() {
return Array.prototype.map.call(editor.childNodes, blockMarkdown).filter(function (block) {
return block.trim().length > 0;
}).join('\\n\\n').trimEnd();
}
function syncTaskCheckboxesDisabled() {
Array.prototype.forEach.call(editor.querySelectorAll('input[type="checkbox"]'), function (input) {
input.disabled = !editable;
});
}
function emitChange() {
if (suppressInput || !editable) return;
window.clearTimeout(inputTimer);
var pendingGeneration = documentGeneration;
lastMarkdown = currentMarkdown();
post({ type: 'change', markdown: lastMarkdown, generation: pendingGeneration });
}
function setMarkdown(markdown, generation) {
window.clearTimeout(inputTimer);
documentGeneration = Number(generation) || 0;
suppressInput = true;
// Why: replacing innerHTML detaches the remembered caret's nodes.
savedSelectionRange = null;
selectionDroppedOnBlur = false;
lastMarkdown = String(markdown || '');
editor.innerHTML = markdownToHtml(lastMarkdown);
syncTaskCheckboxesDisabled();
suppressInput = false;
}
function setEditable(nextEditable) {
editable = Boolean(nextEditable);
editor.setAttribute('contenteditable', editable ? 'true' : 'false');
syncTaskCheckboxesDisabled();
}
${MOBILE_RICH_MARKDOWN_SELECTION_SCRIPT}
${MOBILE_RICH_MARKDOWN_KEYBOARD_DISMISS_SCRIPT}
function runCommand(command) {
if (!editable || editor.getAttribute('contenteditable') !== 'true') return;
restoreSelectionOrEnd();
if (command === 'paragraph') document.execCommand('formatBlock', false, 'p');
else if (command === 'heading1') document.execCommand('formatBlock', false, 'h1');
else if (command === 'heading2') document.execCommand('formatBlock', false, 'h2');
else if (command === 'heading3') document.execCommand('formatBlock', false, 'h3');
else if (command === 'bold') document.execCommand('bold');
else if (command === 'italic') document.execCommand('italic');
else if (command === 'strike') document.execCommand('strikeThrough');
else if (command === 'bulletList') document.execCommand('insertUnorderedList');
else if (command === 'orderedList') document.execCommand('insertOrderedList');
else if (command === 'quote') document.execCommand('formatBlock', false, 'blockquote');
else if (command === 'inlineCode') wrapSelection('code');
else if (command === 'codeBlock') document.execCommand('insertHTML', false, '<pre data-language=""><code>code</code></pre><p><br></p>');
else if (command === 'taskList') document.execCommand('insertHTML', false, '<ul data-type="taskList"><li data-checked="false"><label contenteditable="false"><input type="checkbox" /></label><div><p>Task</p></div></li></ul>');
else if (command === 'link') {
var href = window.prompt('Link URL');
if (href && isSafeUrl(href)) document.execCommand('createLink', false, href);
} else if (command === 'image') {
var src = window.prompt('Image URL');
if (src && isSafeUrl(src)) document.execCommand('insertImage', false, src);
}
syncTaskCheckboxesDisabled();
emitChange();
}
editor.addEventListener('input', function () {
selectionDroppedOnBlur = false;
if (editable) emitChange();
});
editor.addEventListener('change', function (event) {
var input = event.target && event.target.closest && event.target.closest('input[type="checkbox"]');
if (input) {
if (!editable) {
event.preventDefault();
return;
}
var li = input.closest('li');
if (li) li.setAttribute('data-checked', input.checked ? 'true' : 'false');
}
if (editable) emitChange();
});
editor.addEventListener('click', function (event) {
var link = event.target && event.target.closest && event.target.closest('a[href]');
if (link) {
event.preventDefault();
post({ type: 'openLink', url: link.getAttribute('href') || '' });
return;
}
var input = event.target && event.target.closest && event.target.closest('input[type="checkbox"]');
if (!input) {
if (!editable) return;
// Why: a task-list label forwards its click to the checkbox, so refocusing here would steal it and re-open the keyboard.
var uneditable = event.target && event.target.closest && event.target.closest('[contenteditable="false"]');
if (uneditable && uneditable !== editor) return;
selectionDroppedOnBlur = false;
if (document.activeElement === editor) return;
// Why: refocusing after a dismissal otherwise types at the stale caret, not where the user tapped.
var caret = caretRangeAtPoint(event.clientX, event.clientY);
focusEditor();
if (caret && editor.contains(caret.commonAncestorContainer)) applySelectionRange(caret);
return;
}
if (!editable) {
event.preventDefault();
return;
}
var li = input.closest('li');
if (li) li.setAttribute('data-checked', input.checked ? 'true' : 'false');
emitChange();
});
editor.addEventListener('keydown', function (event) {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'b') {
event.preventDefault();
runCommand('bold');
}
});
window.__orcaRichMarkdown = { setMarkdown: setMarkdown, setEditable: setEditable, runCommand: runCommand, currentMarkdown: currentMarkdown, dismissKeyboard: dismissKeyboard };
${MOBILE_RICH_MARKDOWN_KEYBOARD_INSET_SCRIPT}
post({ type: 'ready' });
})();
</script>
</body>
</html>`
--accent-link: ${colors.accentBlue}${MOBILE_RICH_MARKDOWN_EDITOR_BODY_PRIMARY}
${MOBILE_RICH_MARKDOWN_EDITOR_BODY_SECONDARY}${MOBILE_RICH_MARKDOWN_SELECTION_SCRIPT}
${MOBILE_RICH_MARKDOWN_KEYBOARD_DISMISS_SCRIPT}${MOBILE_RICH_MARKDOWN_EDITOR_AFTER_KEYBOARD_DISMISS}${MOBILE_RICH_MARKDOWN_KEYBOARD_INSET_SCRIPT}${MOBILE_RICH_MARKDOWN_EDITOR_DOCUMENT_END}`
}
@@ -16,6 +16,7 @@ import { normalizeWorkspaceAgent } from '../tasks/workspace-agent-selection'
import type { WorkspaceCreateSetupDecision } from '../tasks/workspace-create-params'
import type { WorkspaceSshGate } from '../tasks/workspace-ssh-gate'
import type { useMobileComposerSource } from '../tasks/use-mobile-composer-source'
import type { WorktreeCreateIdempotencySupport } from '../tasks/worktree-create-idempotency-policy'
import {
pickPreferredNewWorktreeAgent,
type NewWorktreeAgentOption,
@@ -54,7 +55,7 @@ export function useNewWorkspaceCreateSubmit(args: {
runSetup: boolean
trustedOrcaHooks: PersistedTrustedOrcaHooks
setTrustedOrcaHooks: (trust: PersistedTrustedOrcaHooks) => void
getWorktreeCreateCutoverSupport: () => Promise<boolean>
getWorktreeCreateCutoverSupport: () => Promise<WorktreeCreateIdempotencySupport | false>
transitionDrawer: (view: Exclude<NewWorktreeDrawerView, 'transition'>) => void
setError: Dispatch<SetStateAction<string>>
onCreated: (worktreeId: string, name: string) => void
@@ -163,7 +164,7 @@ export function useNewWorkspaceCreateSubmit(args: {
workspaceName: trimmedName || undefined,
note: trimmedNote,
nameIsAutoManaged: args.composer.isNameAutoManaged,
supportsIdempotentCutoverRetry: args.getWorktreeCreateCutoverSupport()
worktreeCreateIdempotency: args.getWorktreeCreateCutoverSupport()
})
: await createBlankWorkspace({
client,
@@ -173,7 +174,7 @@ export function useNewWorkspaceCreateSubmit(args: {
createdWithAgentId,
comment: trimmedNote,
setupDecision,
supportsIdempotentCutoverRetry: args.getWorktreeCreateCutoverSupport()
worktreeCreateIdempotency: args.getWorktreeCreateCutoverSupport()
})
if ('error' in result) {
args.setError(result.error)
+185
View File
@@ -0,0 +1,185 @@
import { StyleSheet } from 'react-native'
import { colors, spacing, radii, typography } from '../src/theme/mobile-theme'
export const pairScanStyles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bgBase,
padding: spacing.lg
},
backButton: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
marginBottom: spacing.sm
},
steps: {
gap: spacing.sm,
marginBottom: spacing.lg,
marginLeft: 7
},
step: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm
},
stepBadge: {
width: 22,
height: 22,
borderRadius: 11,
backgroundColor: colors.bgRaised,
alignItems: 'center',
justifyContent: 'center'
},
stepNumber: {
fontSize: 12,
fontWeight: '700',
color: colors.textSecondary
},
stepText: {
fontSize: typography.bodySize,
color: colors.textSecondary
},
cameraWrap: {
flex: 1,
borderRadius: radii.camera,
overflow: 'hidden'
},
// Why: holds the layout slot while the camera is unmounted during
// paste, so the bottom action button doesn't snap up to fill the
// empty space.
cameraPlaceholder: {
flex: 1,
backgroundColor: colors.bgPanel,
borderRadius: radii.camera
},
camera: {
...StyleSheet.absoluteFillObject
},
reticle: {
...StyleSheet.absoluteFillObject,
alignItems: 'center',
justifyContent: 'center'
},
reticleFrame: {
position: 'relative'
},
corner: {
position: 'absolute',
width: 28,
height: 28,
borderColor: 'rgba(255,255,255,0.7)'
},
cornerTL: {
top: 0,
left: 0,
borderTopWidth: 2.5,
borderLeftWidth: 2.5,
borderTopLeftRadius: 6
},
cornerTR: {
top: 0,
right: 0,
borderTopWidth: 2.5,
borderRightWidth: 2.5,
borderTopRightRadius: 6
},
cornerBL: {
bottom: 0,
left: 0,
borderBottomWidth: 2.5,
borderLeftWidth: 2.5,
borderBottomLeftRadius: 6
},
cornerBR: {
bottom: 0,
right: 0,
borderBottomWidth: 2.5,
borderRightWidth: 2.5,
borderBottomRightRadius: 6
},
centered: {
flex: 1,
alignItems: 'center',
justifyContent: 'center'
},
title: {
fontSize: typography.titleSize,
fontWeight: '600',
color: colors.textPrimary,
marginBottom: spacing.sm
},
subtitle: {
maxWidth: 310,
fontSize: typography.bodySize,
color: colors.textSecondary,
textAlign: 'center',
marginBottom: spacing.xl,
lineHeight: 20
},
connectingText: {
color: colors.textSecondary,
fontSize: typography.bodySize,
marginTop: spacing.lg
},
logSlot: {
width: '100%',
marginTop: spacing.lg,
paddingHorizontal: spacing.sm
},
errorText: {
color: colors.statusRed,
fontSize: typography.bodySize,
textAlign: 'center',
marginBottom: spacing.xl,
lineHeight: 20
},
primaryButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.xs,
backgroundColor: colors.textPrimary,
paddingHorizontal: spacing.xl,
paddingVertical: spacing.sm + 2,
borderRadius: radii.button
},
primaryButtonText: {
color: colors.bgBase,
fontSize: typography.bodySize,
fontWeight: '600'
},
pasteButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.xs,
marginTop: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radii.button
},
pasteButtonPressed: {
opacity: 0.6
},
pasteButtonText: {
color: colors.textSecondary,
fontSize: typography.bodySize,
fontWeight: '500'
},
errorActions: {
alignItems: 'center',
gap: spacing.sm
},
secondaryButton: {
paddingHorizontal: spacing.xl,
paddingVertical: spacing.sm,
borderRadius: radii.button
},
secondaryButtonText: {
color: colors.textSecondary,
fontSize: typography.bodySize,
fontWeight: '500'
}
})
@@ -1,9 +1,14 @@
import { describe, expect, it } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { createBlankWorkspace } from './blank-workspace-create'
import { WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS } from './worktree-create-idempotency-policy'
type Call = { method: string; params: unknown }
const IDEMPOTENT_CREATE_SUPPORT = {
dedupeTtlMs: WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS
}
function fakeClient(script: (method: string, call: number) => unknown, calls: Call[]): RpcClient {
return {
sendRequest: async (method: string, params?: unknown) => {
@@ -35,7 +40,7 @@ describe('createBlankWorkspace', () => {
comment: undefined,
setupDecision: 'inherit',
nameWasGenerated: false,
supportsIdempotentCutoverRetry: true
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT
})
expect(result).toEqual({ worktreeId: 'wt-1', name: 'octopus' })
@@ -70,7 +75,7 @@ describe('createBlankWorkspace', () => {
comment: undefined,
setupDecision: 'inherit',
nameWasGenerated: true,
supportsIdempotentCutoverRetry: true
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT
})
expect(calls[0]?.params).toMatchObject({ nameWasGenerated: true })
@@ -90,7 +95,7 @@ describe('createBlankWorkspace', () => {
comment: 'spike',
setupDecision: 'run',
nameWasGenerated: false,
supportsIdempotentCutoverRetry: true
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT
})
const params = calls[0]?.params as Record<string, unknown>
@@ -122,7 +127,7 @@ describe('createBlankWorkspace', () => {
comment: undefined,
setupDecision: 'inherit',
nameWasGenerated: false,
supportsIdempotentCutoverRetry: true
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT
})
expect(result).toEqual({ worktreeId: 'wt-3', name: 'octopus-2' })
@@ -148,7 +153,7 @@ describe('createBlankWorkspace', () => {
comment: undefined,
setupDecision: 'inherit',
nameWasGenerated: false,
supportsIdempotentCutoverRetry: true
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT
})
expect(result).toEqual({ worktreeId: 'wt-4', name: 'octopus-2' })
@@ -167,7 +172,7 @@ describe('createBlankWorkspace', () => {
comment: undefined,
setupDecision: 'skip',
nameWasGenerated: false,
supportsIdempotentCutoverRetry: true
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT
})
expect(result).toEqual({ error: 'SSH connection is not available' })
+3 -2
View File
@@ -1,6 +1,7 @@
import type { TuiAgent } from '../../../src/shared/tui-agent'
import type { RpcClient } from '../transport/rpc-client'
import { createWorktreeWithNameRetry, type WorktreeCreateResult } from './worktree-create-retry'
import type { WorktreeCreateIdempotencyProbe } from './worktree-create-idempotency-policy'
import {
agentLaunchCreateFields,
type WorkspaceCreateSetupDecision
@@ -19,13 +20,13 @@ export async function createBlankWorkspace(args: {
/** True when `baseName` is a generated creature name rather than one the user typed; only then
* may the host retire it. */
nameWasGenerated: boolean
supportsIdempotentCutoverRetry: boolean | Promise<boolean>
worktreeCreateIdempotency: WorktreeCreateIdempotencyProbe
}): Promise<WorktreeCreateResult> {
return createWorktreeWithNameRetry({
client: args.client,
baseName: args.baseName,
nameWasGenerated: args.nameWasGenerated,
supportsIdempotentCutoverRetry: args.supportsIdempotentCutoverRetry,
worktreeCreateIdempotency: args.worktreeCreateIdempotency,
buildParams: (name) => {
const params: Record<string, unknown> = {
repo: `id:${args.repoId}`,
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { createWorkspaceFromComposerSource } from './source-workspace-create'
import type { MobileComposerCreateSelection } from './mobile-composer-source-types'
import { WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS } from './worktree-create-idempotency-policy'
type Call = { method: string; params: Record<string, unknown> }
@@ -24,6 +25,9 @@ function fakeClient(handle: (method: string, call: number) => unknown, calls: Ca
}
const agent = { choice: 'blank' as const }
const IDEMPOTENT_CREATE_SUPPORT = {
dedupeTtlMs: WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS
}
const baseArgs = {
targetRepoId: 'repo-1',
@@ -31,7 +35,7 @@ const baseArgs = {
agent,
workspaceName: undefined,
note: undefined,
supportsIdempotentCutoverRetry: true
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT
}
describe('createWorkspaceFromComposerSource', () => {
+9 -8
View File
@@ -13,6 +13,7 @@ import {
type WorkspaceCreateTaskItem
} from './workspace-create-params'
import { createWorktreeWithNameRetry, type WorktreeCreateResult } from './worktree-create-retry'
import type { WorktreeCreateIdempotencyProbe } from './worktree-create-idempotency-policy'
// The agent bundle the modal resolved: `choice` drives launch resolution — the
// host applies the agent's launch args (permission flags) and shell quoting.
@@ -29,7 +30,7 @@ export type CreateWorkspaceFromComposerArgs = {
workspaceName: string | undefined
note: string | undefined
nameIsAutoManaged?: boolean
supportsIdempotentCutoverRetry: boolean | Promise<boolean>
worktreeCreateIdempotency: WorktreeCreateIdempotencyProbe
}
export async function createWorkspaceFromComposerSource(
@@ -92,7 +93,7 @@ async function createWorkItemWorkspace(args: {
workspaceName: string | undefined
note: string | undefined
nameIsAutoManaged?: boolean
supportsIdempotentCutoverRetry: boolean | Promise<boolean>
worktreeCreateIdempotency: WorktreeCreateIdempotencyProbe
}): Promise<WorktreeCreateResult> {
const { client, selection, targetRepoId, setupDecision, agent, workspaceName, note } = args
const item = selection.item
@@ -137,7 +138,7 @@ async function createWorkItemWorkspace(args: {
return createWorktreeWithNameRetry({
client,
baseName,
supportsIdempotentCutoverRetry: args.supportsIdempotentCutoverRetry,
worktreeCreateIdempotency: args.worktreeCreateIdempotency,
buildParams: (name) => ({ ...params, name })
})
}
@@ -150,7 +151,7 @@ async function createBranchWorkspace(args: {
agent: WorkspaceCreateAgentBundle
workspaceName: string | undefined
note: string | undefined
supportsIdempotentCutoverRetry: boolean | Promise<boolean>
worktreeCreateIdempotency: WorktreeCreateIdempotencyProbe
}): Promise<WorktreeCreateResult> {
const { client, selection, targetRepoId, setupDecision, agent, workspaceName, note } = args
const createdWithAgentId = agent.choice === 'blank' ? undefined : agent.choice
@@ -174,7 +175,7 @@ async function createBranchWorkspace(args: {
return createWorktreeWithNameRetry({
client,
baseName,
supportsIdempotentCutoverRetry: args.supportsIdempotentCutoverRetry,
worktreeCreateIdempotency: args.worktreeCreateIdempotency,
maxAttempts: 1,
buildParams: (name) =>
applyCommon({
@@ -196,7 +197,7 @@ async function createBranchWorkspace(args: {
return createWorktreeWithNameRetry({
client,
baseName,
supportsIdempotentCutoverRetry: args.supportsIdempotentCutoverRetry,
worktreeCreateIdempotency: args.worktreeCreateIdempotency,
buildParams: (candidate) => {
const params: Record<string, unknown> = {
repo: `id:${targetRepoId}`,
@@ -220,7 +221,7 @@ async function createNewBranchWorkspace(args: {
agent: WorkspaceCreateAgentBundle
workspaceName: string | undefined
note: string | undefined
supportsIdempotentCutoverRetry: boolean | Promise<boolean>
worktreeCreateIdempotency: WorktreeCreateIdempotencyProbe
}): Promise<WorktreeCreateResult> {
const { client, selection, targetRepoId, setupDecision, agent, note } = args
const createdWithAgentId = agent.choice === 'blank' ? undefined : agent.choice
@@ -232,7 +233,7 @@ async function createNewBranchWorkspace(args: {
return createWorktreeWithNameRetry({
client,
baseName: selection.branchName,
supportsIdempotentCutoverRetry: args.supportsIdempotentCutoverRetry,
worktreeCreateIdempotency: args.worktreeCreateIdempotency,
buildParams: (candidate) => {
const params: Record<string, unknown> = {
repo: `id:${targetRepoId}`,
@@ -2,8 +2,21 @@ import { describe, expect, it } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
import { readNewWorktreeRuntimeCapabilities } from './worktree-create-capability'
import {
WORKTREE_CREATE_DEDUPE_TTL_CLIENT_CEILING_MS,
WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS
} from './worktree-create-idempotency-policy'
function statusClient(outcomes: Array<'cutover' | 'error' | string[]>): RpcClient {
type StatusOutcome =
| 'cutover'
| 'error'
| string[]
| {
capabilities?: string[]
worktreeCreateIdempotency?: unknown
}
function statusClient(outcomes: StatusOutcome[]): RpcClient {
let call = 0
return {
sendRequest: async () => {
@@ -15,10 +28,13 @@ function statusClient(outcomes: Array<'cutover' | 'error' | string[]>): RpcClien
if (outcome === 'error') {
throw new Error('offline')
}
const result = Array.isArray(outcome)
? { capabilities: outcome, hostPlatform: 'darwin' }
: { ...outcome, hostPlatform: 'darwin' }
return {
id: '1',
ok: true,
result: { capabilities: outcome, hostPlatform: 'darwin' },
result,
_meta: { runtimeId: 'r' }
}
}
@@ -29,23 +45,123 @@ describe('readNewWorktreeRuntimeCapabilities', () => {
it('reads task and idempotent-create support from status.get', async () => {
await expect(
readNewWorktreeRuntimeCapabilities(
statusClient([['mobile.tasks.v1', 'worktree.create-idempotency.v1']])
statusClient([
{
capabilities: ['mobile.tasks.v1', 'worktree.create-idempotency.v1'],
worktreeCreateIdempotency: { dedupeTtlMs: 20_000 }
}
])
)
).resolves.toEqual({
tasksSupported: true,
idempotentWorktreeCreateSupported: true,
worktreeCreateIdempotency: { dedupeTtlMs: 20_000 },
hostPlatform: 'darwin'
})
})
it('retries the safe status probe after a connection cutover', async () => {
it('uses the bounded fallback for an old idempotent host without an advertisement', async () => {
await expect(
readNewWorktreeRuntimeCapabilities(statusClient([['worktree.create-idempotency.v1']]))
).resolves.toEqual({
tasksSupported: false,
worktreeCreateIdempotency: { dedupeTtlMs: WORKTREE_CREATE_DEDUPE_TTL_CLIENT_CEILING_MS },
hostPlatform: 'darwin'
})
})
it('fails closed when a valid idempotency container omits dedupeTtlMs', async () => {
await expect(
readNewWorktreeRuntimeCapabilities(
statusClient(['cutover', ['worktree.create-idempotency.v1']])
statusClient([
{
capabilities: ['worktree.create-idempotency.v1'],
worktreeCreateIdempotency: {}
}
])
)
).resolves.toEqual({
tasksSupported: false,
idempotentWorktreeCreateSupported: true,
worktreeCreateIdempotency: { dedupeTtlMs: 0 },
hostPlatform: 'darwin'
})
})
it.each([
{ label: 'null', value: null },
{ label: 'string', value: 'nonsense' },
{ label: 'number', value: 20_000 },
{ label: 'empty array', value: [] }
])(
'fails closed for a malformed idempotency container ($label)',
async ({ value: worktreeCreateIdempotency }) => {
await expect(
readNewWorktreeRuntimeCapabilities(
statusClient([
{
capabilities: ['worktree.create-idempotency.v1'],
worktreeCreateIdempotency
}
])
)
).resolves.toEqual({
tasksSupported: false,
worktreeCreateIdempotency: { dedupeTtlMs: 0 },
hostPlatform: 'darwin'
})
}
)
it('clamps an over-large host advertisement to the client fallback ceiling', async () => {
await expect(
readNewWorktreeRuntimeCapabilities(
statusClient([
{
capabilities: ['worktree.create-idempotency.v1'],
worktreeCreateIdempotency: { dedupeTtlMs: 600_000 }
}
])
)
).resolves.toEqual({
tasksSupported: false,
worktreeCreateIdempotency: { dedupeTtlMs: WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS },
hostPlatform: 'darwin'
})
})
it.each(['60000', -1, Number.NaN, 60_000.5, {}])(
'fails closed for malformed host advertisement %j',
async (advertisedDedupeTtlMs) => {
await expect(
readNewWorktreeRuntimeCapabilities(
statusClient([
{
capabilities: ['worktree.create-idempotency.v1'],
worktreeCreateIdempotency: { dedupeTtlMs: advertisedDedupeTtlMs }
}
])
)
).resolves.toEqual({
tasksSupported: false,
worktreeCreateIdempotency: { dedupeTtlMs: 0 },
hostPlatform: 'darwin'
})
}
)
it('retries the safe status probe after a connection cutover', async () => {
await expect(
readNewWorktreeRuntimeCapabilities(
statusClient([
'cutover',
{
capabilities: ['worktree.create-idempotency.v1'],
worktreeCreateIdempotency: { dedupeTtlMs: 30_000 }
}
])
)
).resolves.toEqual({
tasksSupported: false,
worktreeCreateIdempotency: { dedupeTtlMs: 30_000 },
hostPlatform: 'darwin'
})
})
@@ -53,7 +169,7 @@ describe('readNewWorktreeRuntimeCapabilities', () => {
it('fails closed when capability detection is unavailable', async () => {
await expect(readNewWorktreeRuntimeCapabilities(statusClient(['error']))).resolves.toEqual({
tasksSupported: false,
idempotentWorktreeCreateSupported: false,
worktreeCreateIdempotency: false,
hostPlatform: null
})
})
+28 -8
View File
@@ -4,6 +4,11 @@ import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-cli
import type { RpcSuccess } from '../transport/types'
import { readMobileRuntimeHostPlatform } from '../transport/mobile-runtime-host-platform'
import { MOBILE_TASKS_CAPABILITY } from './mobile-tasks-capability'
import {
WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS,
resolveWorktreeCreateIdempotencySupport,
type WorktreeCreateIdempotencySupport
} from './worktree-create-idempotency-policy'
// Why: older hosts strip worktree.create's clientMutationId, so mobile must not
// replay an ambiguous create unless the host advertises idempotency support.
@@ -14,13 +19,13 @@ const STATUS_CUTOVER_MAX_RETRIES = 5
export type NewWorktreeRuntimeCapabilities = {
tasksSupported: boolean
idempotentWorktreeCreateSupported: boolean
worktreeCreateIdempotency: WorktreeCreateIdempotencySupport | false
hostPlatform: NodeJS.Platform | null
}
const UNSUPPORTED_CAPABILITIES: NewWorktreeRuntimeCapabilities = {
tasksSupported: false,
idempotentWorktreeCreateSupported: false,
worktreeCreateIdempotency: false,
hostPlatform: null
}
@@ -35,13 +40,28 @@ export async function readNewWorktreeRuntimeCapabilities(
if (!response.ok) {
return UNSUPPORTED_CAPABILITIES
}
const result = (response as RpcSuccess).result as { capabilities?: string[] }
const result = (response as RpcSuccess).result as {
capabilities?: string[]
worktreeCreateIdempotency?: unknown
}
const capabilities = result.capabilities ?? []
const supportsIdempotency = capabilities.includes(
MOBILE_WORKTREE_CREATE_IDEMPOTENCY_CAPABILITY
)
const advertisedIdempotency = result.worktreeCreateIdempotency
return {
tasksSupported: capabilities.includes(MOBILE_TASKS_CAPABILITY),
idempotentWorktreeCreateSupported: capabilities.includes(
MOBILE_WORKTREE_CREATE_IDEMPOTENCY_CAPABILITY
),
worktreeCreateIdempotency: supportsIdempotency
? advertisedIdempotency === undefined
? { dedupeTtlMs: WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS }
: advertisedIdempotency !== null &&
typeof advertisedIdempotency === 'object' &&
!Array.isArray(advertisedIdempotency)
? resolveWorktreeCreateIdempotencySupport(
(advertisedIdempotency as { dedupeTtlMs?: unknown }).dedupeTtlMs
)
: { dedupeTtlMs: 0 }
: false,
hostPlatform: readMobileRuntimeHostPlatform(result)
}
} catch (error) {
@@ -58,7 +78,7 @@ export function useNewWorktreeRuntimeCapabilities(
): {
tasksSupported: boolean
hostPlatform: NodeJS.Platform | null
getWorktreeCreateCutoverSupport: () => Promise<boolean>
getWorktreeCreateCutoverSupport: () => Promise<WorktreeCreateIdempotencySupport | false>
} {
const [tasksSupported, setTasksSupported] = useState(false)
const [hostPlatform, setHostPlatform] = useState<NodeJS.Platform | null>(null)
@@ -97,7 +117,7 @@ export function useNewWorktreeRuntimeCapabilities(
}, [client, enabled, getCapabilities, setTasksSupported])
const getWorktreeCreateCutoverSupport = useCallback(
() => getCapabilities().then((capabilities) => capabilities.idempotentWorktreeCreateSupported),
() => getCapabilities().then((capabilities) => capabilities.worktreeCreateIdempotency),
[getCapabilities]
)
return { tasksSupported, hostPlatform, getWorktreeCreateCutoverSupport }
@@ -0,0 +1,40 @@
// Why: old idempotent hosts omit policy, so preserve today's effective replay window.
export const WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS = 60_000
// Why: a client must not trust a host advertisement beyond its tested ceiling.
export const WORKTREE_CREATE_DEDUPE_TTL_CLIENT_CEILING_MS = 60_000
// Why: the replay still has to reach the host before its dedupe record expires.
const WORKTREE_CREATE_REPLAY_FLIGHT_MARGIN_MS = 10_000
export type WorktreeCreateIdempotencySupport = {
dedupeTtlMs: number
}
export type WorktreeCreateIdempotencyProbe =
| WorktreeCreateIdempotencySupport
| false
| Promise<WorktreeCreateIdempotencySupport | false>
export function resolveWorktreeCreateIdempotencySupport(
advertisedDedupeTtlMs: unknown
): WorktreeCreateIdempotencySupport {
if (advertisedDedupeTtlMs === undefined) {
return { dedupeTtlMs: 0 }
}
if (
typeof advertisedDedupeTtlMs !== 'number' ||
!Number.isSafeInteger(advertisedDedupeTtlMs) ||
advertisedDedupeTtlMs < 0
) {
// Truthy support preserves immediate cutover retries while disabling ambiguous replay.
return { dedupeTtlMs: 0 }
}
return {
dedupeTtlMs: Math.min(advertisedDedupeTtlMs, WORKTREE_CREATE_DEDUPE_TTL_CLIENT_CEILING_MS)
}
}
export function getWorktreeCreateReplayWindowMs(support: WorktreeCreateIdempotencySupport): number {
return Math.max(0, support.dedupeTtlMs - WORKTREE_CREATE_REPLAY_FLIGHT_MARGIN_MS)
}
+102 -34
View File
@@ -3,16 +3,24 @@ import type { RpcClient } from '../transport/rpc-client'
import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
import type { ConnectionState } from '../transport/types'
import { WORKTREE_CREATE_DEDUPE_TTL_MS } from '../../../src/shared/new-workspace/worktree-create-retry-policy'
import {
getWorktreeCreateReplayWindowMs,
WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS,
type WorktreeCreateIdempotencySupport
} from './worktree-create-idempotency-policy'
import {
createWorktreeWithNameRetry,
WORKTREE_CREATE_AMBIGUOUS_RECONNECT_WAIT_MS,
WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS
WORKTREE_CREATE_AMBIGUOUS_RECONNECT_WAIT_MS
} from './worktree-create-retry'
import { WORKTREE_CREATE_TIMEOUT_MS } from './workspace-create-timeout'
type Attempt = { method: string; params: Record<string, unknown> }
const IDEMPOTENT_CREATE_SUPPORT = {
dedupeTtlMs: WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS
}
const LEGACY_HOST_REPLAY_WINDOW_MS = getWorktreeCreateReplayWindowMs(IDEMPOTENT_CREATE_SUPPORT)
// Drives the transport state a replay has to wait on. Production couples the two:
// a socket close rejects the pending frame AND drops the client off 'connected'.
function connectionController(): {
@@ -106,21 +114,21 @@ describe('createWorktreeWithNameRetry', () => {
it('waits for capability detection before sending a create', async () => {
const attempts: Attempt[] = []
const client = scriptedClient([{ id: 'wt-ready' }], attempts)
let resolveSupport!: (supported: boolean) => void
const support = new Promise<boolean>((resolve) => {
let resolveSupport!: (supported: WorktreeCreateIdempotencySupport) => void
const support = new Promise<WorktreeCreateIdempotencySupport>((resolve) => {
resolveSupport = resolve
})
const pending = createWorktreeWithNameRetry({
client,
baseName: 'puffin',
buildParams: (name) => ({ repo: 'id:r', name }),
supportsIdempotentCutoverRetry: support,
worktreeCreateIdempotency: support,
mintMutationId: () => 'key-ready'
})
await Promise.resolve()
expect(attempts).toHaveLength(0)
resolveSupport(true)
resolveSupport(IDEMPOTENT_CREATE_SUPPORT)
await expect(pending).resolves.toEqual({ worktreeId: 'wt-ready', name: 'puffin' })
expect(attempts).toHaveLength(1)
@@ -134,7 +142,7 @@ describe('createWorktreeWithNameRetry', () => {
client,
baseName: 'otter',
buildParams: (name) => ({ repo: 'id:r', name }),
supportsIdempotentCutoverRetry: true,
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
mintMutationId: () => 'key-1'
})
expect(result).toEqual({ worktreeId: 'wt-1', name: 'otter' })
@@ -152,7 +160,7 @@ describe('createWorktreeWithNameRetry', () => {
client,
baseName: 'seal',
buildParams: (name) => ({ repo: 'id:r', name }),
supportsIdempotentCutoverRetry: true,
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
mintMutationId: () => 'key-mig'
})
expect(result).toEqual({ worktreeId: 'wt-2', name: 'seal' })
@@ -172,7 +180,7 @@ describe('createWorktreeWithNameRetry', () => {
client,
baseName: 'crab',
buildParams: (name) => ({ repo: 'id:r', name }),
supportsIdempotentCutoverRetry: true,
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
mintMutationId: () => 'key-x'
})
).rejects.toBeInstanceOf(LogicalClientCutoverError)
@@ -190,7 +198,7 @@ describe('createWorktreeWithNameRetry', () => {
client,
baseName: 'eel',
buildParams: (name) => ({ repo: 'id:r', name }),
supportsIdempotentCutoverRetry: true,
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
mintMutationId: () => 'key-t'
})
).rejects.toThrow('Request timed out')
@@ -208,7 +216,7 @@ describe('createWorktreeWithNameRetry', () => {
client,
baseName: 'topic',
buildParams: (name) => ({ repo: 'id:r', name }),
supportsIdempotentCutoverRetry: true,
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
mintMutationId: () => `key-${(n += 1)}`
})
expect(result).toEqual({ worktreeId: 'wt-3', name: 'topic-2' })
@@ -231,7 +239,7 @@ describe('createWorktreeWithNameRetry', () => {
baseName: 'nautilus-2',
nameWasGenerated: true,
buildParams: (name) => ({ repo: 'id:r', name, nameWasGenerated: true }),
supportsIdempotentCutoverRetry: false
worktreeCreateIdempotency: false
})
expect(result).toEqual({ worktreeId: 'wt-generated', name: 'nautilus-3' })
@@ -246,7 +254,7 @@ describe('createWorktreeWithNameRetry', () => {
client,
baseName: 'ray',
buildParams: (name) => ({ repo: 'id:r', name }),
supportsIdempotentCutoverRetry: false,
worktreeCreateIdempotency: false,
mintMutationId: () => 'must-not-be-used'
})
).rejects.toBeInstanceOf(LogicalClientCutoverError)
@@ -273,7 +281,7 @@ describe('createWorktreeWithNameRetry', () => {
client,
baseName: 'urchin',
buildParams: (name) => ({ repo: 'id:r', name }),
supportsIdempotentCutoverRetry: true,
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
mintMutationId: () => 'key-drop'
})
@@ -301,7 +309,7 @@ describe('createWorktreeWithNameRetry', () => {
client,
baseName: 'limpet',
buildParams: (name) => ({ repo: 'id:r', name }),
supportsIdempotentCutoverRetry: false,
worktreeCreateIdempotency: false,
mintMutationId: () => 'must-not-be-used'
})
).rejects.toThrow('Connection interrupted')
@@ -309,6 +317,32 @@ describe('createWorktreeWithNameRetry', () => {
expect(attempts[0]!.params.clientMutationId).toBeUndefined()
})
it('disables ambiguous replay for a malformed advertisement', async () => {
const attempts: Attempt[] = []
const connection = connectionController()
const client = scriptedClient(
[
{
throws: markRpcDeliveryUnknown(new Error('Connection interrupted')),
dropsConnection: true
}
],
attempts,
connection
)
await expect(
createWorktreeWithNameRetry({
client,
baseName: 'limpet',
buildParams: (name) => ({ repo: 'id:r', name }),
worktreeCreateIdempotency: { dedupeTtlMs: 0 },
mintMutationId: () => 'key-malformed'
})
).rejects.toThrow('Connection interrupted')
expect(attempts).toHaveLength(1)
expect(attempts[0]!.params.clientMutationId).toBe('key-malformed')
})
it('gives up after the delivery-ambiguity replay budget, still inside the dedupe TTL', async () => {
vi.useFakeTimers()
try {
@@ -331,21 +365,21 @@ describe('createWorktreeWithNameRetry', () => {
client,
baseName: 'barnacle',
buildParams: (name) => ({ repo: 'id:r', name }),
supportsIdempotentCutoverRetry: true,
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
mintMutationId: () => 'key-budget'
}).catch((error: unknown) => {
settledAt = Date.now()
throw error
})
const settled = expect(pending).rejects.toThrow('Connection interrupted')
await vi.advanceTimersByTimeAsync(WORKTREE_CREATE_DEDUPE_TTL_MS)
await vi.advanceTimersByTimeAsync(WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS)
await settled
// Initial attempt + 2 replays.
expect(attempts).toHaveLength(3)
// The budget is a count, but what keeps a replay reconciling instead of building
// a second worktree is wall clock: every attempt has to land while the host still
// holds the record, with the watchdog's detection latency already deducted.
expect(settledAt - startedAt).toBeLessThanOrEqual(WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS)
expect(settledAt - startedAt).toBeLessThanOrEqual(LEGACY_HOST_REPLAY_WINDOW_MS)
} finally {
vi.useRealTimers()
}
@@ -373,7 +407,7 @@ describe('createWorktreeWithNameRetry', () => {
client,
baseName: 'anemone',
buildParams: (name) => ({ repo: 'id:r', name }),
supportsIdempotentCutoverRetry: true,
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
mintMutationId: () => 'key-stuck'
})
const settled = expect(pending).rejects.toThrow('Connection interrupted')
@@ -388,6 +422,41 @@ describe('createWorktreeWithNameRetry', () => {
// so vitest's default 5s real-time budget is reachable on a loaded runner.
}, 30_000)
it('stops replaying when the host-advertised dedupe record has expired', async () => {
vi.useFakeTimers()
try {
const attempts: Attempt[] = []
const connection = connectionController()
const client = scriptedClient(
[
{
throws: markRpcDeliveryUnknown(new Error('Connection interrupted')),
dropsConnection: true,
takesMs: 12_000,
reconnectsAfterMs: 1_000
},
{ id: 'wt-duplicate' }
],
attempts,
connection
)
const pending = createWorktreeWithNameRetry({
client,
baseName: 'mussel',
buildParams: (name) => ({ repo: 'id:r', name }),
worktreeCreateIdempotency: { dedupeTtlMs: 20_000 },
mintMutationId: () => 'key-short-ttl'
})
const settled = expect(pending).rejects.toThrow('Connection interrupted')
await vi.advanceTimersByTimeAsync(30_000)
await settled
expect(attempts).toHaveLength(1)
} finally {
vi.useRealTimers()
}
})
it('does not replay an ambiguity that surfaced while the transport stayed connected', async () => {
vi.useFakeTimers()
try {
@@ -415,7 +484,7 @@ describe('createWorktreeWithNameRetry', () => {
client,
baseName: 'cuttlefish',
buildParams: (name) => ({ repo: 'id:r', name }),
supportsIdempotentCutoverRetry: true,
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
mintMutationId: () => 'key-expired'
})
const settled = expect(pending).rejects.toThrow('Request timed out')
@@ -464,19 +533,19 @@ describe('createWorktreeWithNameRetry', () => {
client,
baseName: 'nautilus',
buildParams: (name) => ({ repo: 'id:r', name }),
supportsIdempotentCutoverRetry: true,
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
mintMutationId: () => 'key-clamped'
}).catch((error: unknown) => {
settledAt = Date.now()
throw error
})
const settled = expect(pending).rejects.toThrow('Connection interrupted')
await vi.advanceTimersByTimeAsync(WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS)
await vi.advanceTimersByTimeAsync(LEGACY_HOST_REPLAY_WINDOW_MS)
await settled
expect(attempts).toHaveLength(2)
// The window is anchored at the FIRST ambiguity, so the second wait gets only the
// remainder — it must not restart a full wait and carry the replay past the record.
expect(settledAt - startedAt).toBe(WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS)
expect(settledAt - startedAt).toBe(LEGACY_HOST_REPLAY_WINDOW_MS)
} finally {
vi.useRealTimers()
}
@@ -509,7 +578,7 @@ describe('createWorktreeWithNameRetry', () => {
client,
baseName: 'limpet',
buildParams: (name) => ({ repo: 'id:r', name }),
supportsIdempotentCutoverRetry: true,
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
mintMutationId: () => 'key-stale'
})
const settled = expect(pending).rejects.toThrow('Connection interrupted')
@@ -551,7 +620,7 @@ describe('createWorktreeWithNameRetry', () => {
client,
baseName: 'kelp',
buildParams: (name) => ({ repo: 'id:r', name }),
supportsIdempotentCutoverRetry: true,
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
mintMutationId: () => 'key-long'
})
await vi.advanceTimersByTimeAsync(250_000)
@@ -606,7 +675,7 @@ describe('createWorktreeWithNameRetry', () => {
client,
baseName: 'urchin',
buildParams: (name) => ({ repo: 'id:r', name }),
supportsIdempotentCutoverRetry: true,
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
mintMutationId: () => 'key-anchored'
})
const settled = expect(pending).rejects.toThrow('Connection lost')
@@ -638,7 +707,7 @@ describe('createWorktreeWithNameRetry', () => {
client,
baseName: 'urchin',
buildParams: (name) => ({ repo: 'id:r', name }),
supportsIdempotentCutoverRetry: true,
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
mintMutationId: () => 'key-unmarked'
})
).rejects.toThrow('Socket closed before send')
@@ -649,11 +718,10 @@ describe('createWorktreeWithNameRetry', () => {
// The window is measured from a lower bound on when the host could have resolved, so
// it has to leave the record room for the replay to still be in flight. Widening it
// to the whole TTL would let the last replay land on a record that just expired.
expect(WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS).toBeGreaterThan(0)
expect(WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS).toBeLessThan(WORKTREE_CREATE_DEDUPE_TTL_MS)
// A single wait must not be able to consume the window on its own.
expect(WORKTREE_CREATE_AMBIGUOUS_RECONNECT_WAIT_MS).toBeLessThan(
WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS
)
expect(LEGACY_HOST_REPLAY_WINDOW_MS).toBeGreaterThan(0)
expect(LEGACY_HOST_REPLAY_WINDOW_MS).toBeLessThan(WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS)
// A reconnect wait is clamped when a short advertisement leaves less time.
const shortReplayWindowMs = getWorktreeCreateReplayWindowMs({ dedupeTtlMs: 20_000 })
expect(Math.min(WORKTREE_CREATE_AMBIGUOUS_RECONNECT_WAIT_MS, shortReplayWindowMs)).toBe(10_000)
})
})
+19 -26
View File
@@ -7,10 +7,14 @@ import {
CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS,
getClientWorktreeCreateCandidate,
getGeneratedWorktreeCreateRetryCandidate,
isRetryableWorktreeCreateConflict,
WORKTREE_CREATE_DEDUPE_TTL_MS
isRetryableWorktreeCreateConflict
} from '../../../src/shared/new-workspace/worktree-create-retry-policy'
import { WORKTREE_CREATE_TIMEOUT_MS } from './workspace-create-timeout'
import {
getWorktreeCreateReplayWindowMs,
type WorktreeCreateIdempotencyProbe,
type WorktreeCreateIdempotencySupport
} from './worktree-create-idempotency-policy'
// Why: server-side collision checks (branch already exists locally / on a remote
// / already has PR #N) can fire even after a pre-flight basename dedupe —
@@ -36,17 +40,6 @@ const WORKTREE_CREATE_CUTOVER_MAX_RETRIES = 5
// finished the worktree. Replay on the same clientMutationId instead.
const WORKTREE_CREATE_AMBIGUOUS_MAX_RETRIES = 2
// Why: the host keeps a settled create's dedupe record for WORKTREE_CREATE_DEDUPE_TTL_MS
// after it resolves; a replay that lands later misses it and the host's suffix loop
// builds a SECOND worktree — and for folder workspaces a second one with the SAME name
// and no collision check at all. So a replay is bounded by how stale our knowledge of the
// host is, and that has to be measured in WALL CLOCK: iOS/Android suspend JS timers while
// the app is backgrounded, so any ceiling derived from timer intervals — a watchdog probe
// budget, a request timeout — can be arbitrarily wrong across a background cycle, which is
// exactly when a create sits ambiguous for minutes.
const WORKTREE_CREATE_REPLAY_FLIGHT_MARGIN_MS = 10_000
export const WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS =
WORKTREE_CREATE_DEDUPE_TTL_MS - WORKTREE_CREATE_REPLAY_FLIGHT_MARGIN_MS
// Bounded so the Create spinner doesn't sit for the whole window; the deadline still caps it.
export const WORKTREE_CREATE_AMBIGUOUS_RECONNECT_WAIT_MS = 20_000
@@ -55,7 +48,7 @@ export type CreateWorktreeWithNameRetryArgs = {
baseName: string
nameWasGenerated?: boolean
buildParams: (name: string) => Record<string, unknown>
supportsIdempotentCutoverRetry: boolean | Promise<boolean>
worktreeCreateIdempotency: WorktreeCreateIdempotencyProbe
maxAttempts?: number
// Injected in tests; production mints a fresh idempotency key per candidate.
mintMutationId?: () => string
@@ -72,7 +65,7 @@ export async function createWorktreeWithNameRetry(
const { client, baseName, buildParams } = args
// Why: creating before status.get settles would silently disable safe replay
// during the exact slow-network window this path is meant to recover from.
const supportsIdempotentCutoverRetry = await args.supportsIdempotentCutoverRetry
const worktreeCreateIdempotency = await args.worktreeCreateIdempotency
const maxAttempts = args.maxAttempts ?? CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS
const mintMutationId = args.mintMutationId ?? defaultWorktreeCreateMutationId
let lastError: string | null = null
@@ -84,14 +77,10 @@ export async function createWorktreeWithNameRetry(
// Why: older hosts strip unknown fields, so only stamp and replay when the
// host advertises idempotency. One key per candidate makes cutover retries
// safe while a name-collision bump remains a genuinely new create.
const params = supportsIdempotentCutoverRetry
const params = worktreeCreateIdempotency
? { ...candidateParams, clientMutationId: mintMutationId() }
: candidateParams
const response = await sendWorktreeCreateResilient(
client,
params,
supportsIdempotentCutoverRetry
)
const response = await sendWorktreeCreateResilient(client, params, worktreeCreateIdempotency)
if (response.ok) {
const result = (response as RpcSuccess).result as { worktree: { id: string } }
return { worktreeId: result.worktree.id, name: candidateName }
@@ -112,7 +101,7 @@ export async function createWorktreeWithNameRetry(
async function sendWorktreeCreateResilient(
client: RpcClient,
params: Record<string, unknown>,
supportsIdempotentCutoverRetry: boolean
worktreeCreateIdempotency: WorktreeCreateIdempotencySupport | false
): Promise<RpcResponse> {
let migrationRetry = 0
let ambiguousRetry = 0
@@ -124,7 +113,7 @@ async function sendWorktreeCreateResilient(
timeoutMs: WORKTREE_CREATE_TIMEOUT_MS
})
} catch (error) {
if (!supportsIdempotentCutoverRetry) {
if (!worktreeCreateIdempotency) {
throw error
}
if (isLogicalClientCutoverError(error)) {
@@ -152,7 +141,7 @@ async function sendWorktreeCreateResilient(
}
// Computed once: a later ambiguity reads a fresher lastInboundAt from the
// replacement session, which would push the deadline past the record it respects.
replayDeadlineAt ??= resolveReplayDeadline(client, firstSentAt)
replayDeadlineAt ??= resolveReplayDeadline(client, firstSentAt, worktreeCreateIdempotency)
const remainingWindowMs = replayDeadlineAt - Date.now()
if (remainingWindowMs <= 0) {
throw error
@@ -179,10 +168,14 @@ async function sendWorktreeCreateResilient(
// so it stays honest across a suspension in a way a timer budget cannot; the send is the
// fallback, and a sound floor either way, because the host cannot resolve a create it has
// not yet received.
function resolveReplayDeadline(client: RpcClient, firstSentAt: number): number {
function resolveReplayDeadline(
client: RpcClient,
firstSentAt: number,
support: WorktreeCreateIdempotencySupport
): number {
const lastInboundAt = client.getLastInboundAt?.() ?? null
const anchor = lastInboundAt !== null && lastInboundAt > firstSentAt ? lastInboundAt : firstSentAt
return anchor + WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS
return anchor + getWorktreeCreateReplayWindowMs(support)
}
function defaultWorktreeCreateMutationId(): string {
+8 -140
View File
@@ -1,3 +1,9 @@
import {
SPECIAL_KEY_LABELS,
TERMINAL_ACCESSORY_KEY_DEFINITIONS,
TERMINAL_SHORTCUT_SPECIAL_KEY_DEFINITIONS
} from './terminal-key-definitions'
export type TerminalAccessoryKey = {
id: string
label: string
@@ -75,66 +81,6 @@ const CTRL_PRINTABLE_BYTES: Record<string, string> = {
'?': '\x7f'
}
const SPECIAL_KEY_LABELS: Record<string, string> = {
escape: 'Esc',
tab: 'Tab',
enter: 'Enter',
backspace: '⌫',
delete: 'Del',
insert: 'Ins',
arrowUp: '↑',
arrowDown: '↓',
arrowLeft: '←',
arrowRight: '→',
home: 'Home',
end: 'End',
pageUp: 'PgUp',
pageDown: 'PgDn',
space: 'Space',
f1: 'F1',
f2: 'F2',
f3: 'F3',
f4: 'F4',
f5: 'F5',
f6: 'F6',
f7: 'F7',
f8: 'F8',
f9: 'F9',
f10: 'F10',
f11: 'F11',
f12: 'F12'
}
const SPECIAL_KEY_ACCESSIBILITY_LABELS: Record<string, string> = {
escape: 'Escape',
tab: 'Tab',
enter: 'Enter',
backspace: 'Backspace',
delete: 'Forward delete',
insert: 'Insert',
arrowUp: 'Arrow up',
arrowDown: 'Arrow down',
arrowLeft: 'Arrow left',
arrowRight: 'Arrow right',
home: 'Home',
end: 'End',
pageUp: 'Page up',
pageDown: 'Page down',
space: 'Space',
f1: 'F1',
f2: 'F2',
f3: 'F3',
f4: 'F4',
f5: 'F5',
f6: 'F6',
f7: 'F7',
f8: 'F8',
f9: 'F9',
f10: 'F10',
f11: 'F11',
f12: 'F12'
}
const CSI_FINAL_SPECIAL_KEYS: Record<string, string> = {
arrowUp: 'A',
arrowDown: 'B',
@@ -165,87 +111,9 @@ const CSI_TILDE_SPECIAL_KEYS: Record<string, number> = {
f12: 24
}
export const TERMINAL_SHORTCUT_SPECIAL_KEYS: TerminalShortcutSpecialKey[] = [
'escape',
'tab',
'enter',
'backspace',
'delete',
'insert',
'arrowUp',
'arrowDown',
'arrowLeft',
'arrowRight',
'home',
'end',
'pageUp',
'pageDown',
'space',
'f1',
'f2',
'f3',
'f4',
'f5',
'f6',
'f7',
'f8',
'f9',
'f10',
'f11',
'f12'
].map((id) => ({
id,
label: SPECIAL_KEY_LABELS[id]!,
accessibilityLabel: SPECIAL_KEY_ACCESSIBILITY_LABELS[id]!
}))
export const TERMINAL_SHORTCUT_SPECIAL_KEYS = TERMINAL_SHORTCUT_SPECIAL_KEY_DEFINITIONS
export const TERMINAL_ACCESSORY_KEYS: TerminalAccessoryKey[] = [
{ id: 'escape', label: 'Esc', bytes: '\x1b', accessibilityLabel: 'Escape' },
{ id: 'tab', label: 'Tab', bytes: '\t', accessibilityLabel: 'Tab' },
{ id: 'enter', label: 'Enter', bytes: '\r', accessibilityLabel: 'Enter' },
// Why: terminal apps recognize ESC [ Z as the reverse-tab sequence.
{ id: 'shiftTab', label: 'Shift+Tab', bytes: '\x1b[Z', accessibilityLabel: 'Shift Tab' },
{ id: 'space', label: 'Space', bytes: ' ', accessibilityLabel: 'Space' },
{ id: 'backspace', label: '⌫', bytes: '\x7f', accessibilityLabel: 'Backspace', repeatable: true },
{
id: 'delete',
label: 'Del',
bytes: '\x1b[3~',
accessibilityLabel: 'Forward delete',
repeatable: true
},
{ id: 'arrowUp', label: '↑', bytes: '\x1b[A', accessibilityLabel: 'Arrow Up', repeatable: true },
{
id: 'arrowDown',
label: '↓',
bytes: '\x1b[B',
accessibilityLabel: 'Arrow Down',
repeatable: true
},
{
id: 'arrowLeft',
label: '←',
bytes: '\x1b[D',
accessibilityLabel: 'Arrow Left',
repeatable: true
},
{
id: 'arrowRight',
label: '→',
bytes: '\x1b[C',
accessibilityLabel: 'Arrow Right',
repeatable: true
},
{ id: 'ctrlC', label: 'Ctrl+C', bytes: '\x03', accessibilityLabel: 'Interrupt terminal' },
{ id: 'ctrlD', label: 'Ctrl+D', bytes: '\x04', accessibilityLabel: 'Send EOF' },
{ id: 'ctrlL', label: 'Ctrl+L', bytes: '\x0c', accessibilityLabel: 'Clear screen' },
{ id: 'ctrlZ', label: 'Ctrl+Z', bytes: '\x1a', accessibilityLabel: 'Suspend process' },
{ id: 'ctrlR', label: 'Ctrl+R', bytes: '\x12', accessibilityLabel: 'Reverse search' },
{ id: 'ctrlA', label: 'Ctrl+A', bytes: '\x01', accessibilityLabel: 'Start of line' },
{ id: 'ctrlE', label: 'Ctrl+E', bytes: '\x05', accessibilityLabel: 'End of line' },
{ id: 'ctrlW', label: 'Ctrl+W', bytes: '\x17', accessibilityLabel: 'Delete word backward' },
{ id: 'ctrlU', label: 'Ctrl+U', bytes: '\x15', accessibilityLabel: 'Clear line before cursor' }
]
export const TERMINAL_ACCESSORY_KEYS = TERMINAL_ACCESSORY_KEY_DEFINITIONS
export function buildTerminalShortcutKey(
binding: TerminalShortcutBinding
@@ -0,0 +1,143 @@
import type { TerminalAccessoryKey, TerminalShortcutSpecialKey } from './terminal-accessory-keys'
export const SPECIAL_KEY_LABELS: Record<string, string> = {
escape: 'Esc',
tab: 'Tab',
enter: 'Enter',
backspace: '⌫',
delete: 'Del',
insert: 'Ins',
arrowUp: '↑',
arrowDown: '↓',
arrowLeft: '←',
arrowRight: '→',
home: 'Home',
end: 'End',
pageUp: 'PgUp',
pageDown: 'PgDn',
space: 'Space',
f1: 'F1',
f2: 'F2',
f3: 'F3',
f4: 'F4',
f5: 'F5',
f6: 'F6',
f7: 'F7',
f8: 'F8',
f9: 'F9',
f10: 'F10',
f11: 'F11',
f12: 'F12'
}
const SPECIAL_KEY_ACCESSIBILITY_LABELS: Record<string, string> = {
escape: 'Escape',
tab: 'Tab',
enter: 'Enter',
backspace: 'Backspace',
delete: 'Forward delete',
insert: 'Insert',
arrowUp: 'Arrow up',
arrowDown: 'Arrow down',
arrowLeft: 'Arrow left',
arrowRight: 'Arrow right',
home: 'Home',
end: 'End',
pageUp: 'Page up',
pageDown: 'Page down',
space: 'Space',
f1: 'F1',
f2: 'F2',
f3: 'F3',
f4: 'F4',
f5: 'F5',
f6: 'F6',
f7: 'F7',
f8: 'F8',
f9: 'F9',
f10: 'F10',
f11: 'F11',
f12: 'F12'
}
export const TERMINAL_SHORTCUT_SPECIAL_KEY_DEFINITIONS: TerminalShortcutSpecialKey[] = [
'escape',
'tab',
'enter',
'backspace',
'delete',
'insert',
'arrowUp',
'arrowDown',
'arrowLeft',
'arrowRight',
'home',
'end',
'pageUp',
'pageDown',
'space',
'f1',
'f2',
'f3',
'f4',
'f5',
'f6',
'f7',
'f8',
'f9',
'f10',
'f11',
'f12'
].map((id) => ({
id,
label: SPECIAL_KEY_LABELS[id]!,
accessibilityLabel: SPECIAL_KEY_ACCESSIBILITY_LABELS[id]!
}))
export const TERMINAL_ACCESSORY_KEY_DEFINITIONS: TerminalAccessoryKey[] = [
{ id: 'escape', label: 'Esc', bytes: '\x1b', accessibilityLabel: 'Escape' },
{ id: 'tab', label: 'Tab', bytes: '\t', accessibilityLabel: 'Tab' },
{ id: 'enter', label: 'Enter', bytes: '\r', accessibilityLabel: 'Enter' },
// Why: terminal apps recognize ESC [ Z as the reverse-tab sequence.
{ id: 'shiftTab', label: 'Shift+Tab', bytes: '\x1b[Z', accessibilityLabel: 'Shift Tab' },
{ id: 'space', label: 'Space', bytes: ' ', accessibilityLabel: 'Space' },
{ id: 'backspace', label: '⌫', bytes: '\x7f', accessibilityLabel: 'Backspace', repeatable: true },
{
id: 'delete',
label: 'Del',
bytes: '\x1b[3~',
accessibilityLabel: 'Forward delete',
repeatable: true
},
{ id: 'arrowUp', label: '↑', bytes: '\x1b[A', accessibilityLabel: 'Arrow Up', repeatable: true },
{
id: 'arrowDown',
label: '↓',
bytes: '\x1b[B',
accessibilityLabel: 'Arrow Down',
repeatable: true
},
{
id: 'arrowLeft',
label: '←',
bytes: '\x1b[D',
accessibilityLabel: 'Arrow Left',
repeatable: true
},
{
id: 'arrowRight',
label: '→',
bytes: '\x1b[C',
accessibilityLabel: 'Arrow Right',
repeatable: true
},
{ id: 'ctrlC', label: 'Ctrl+C', bytes: '\x03', accessibilityLabel: 'Interrupt terminal' },
{ id: 'ctrlD', label: 'Ctrl+D', bytes: '\x04', accessibilityLabel: 'Send EOF' },
{ id: 'ctrlL', label: 'Ctrl+L', bytes: '\x0c', accessibilityLabel: 'Clear screen' },
{ id: 'ctrlZ', label: 'Ctrl+Z', bytes: '\x1a', accessibilityLabel: 'Suspend process' },
{ id: 'ctrlR', label: 'Ctrl+R', bytes: '\x12', accessibilityLabel: 'Reverse search' },
{ id: 'ctrlA', label: 'Ctrl+A', bytes: '\x01', accessibilityLabel: 'Start of line' },
{ id: 'ctrlE', label: 'Ctrl+E', bytes: '\x05', accessibilityLabel: 'End of line' },
{ id: 'ctrlW', label: 'Ctrl+W', bytes: '\x17', accessibilityLabel: 'Delete word backward' },
{ id: 'ctrlU', label: 'Ctrl+U', bytes: '\x15', accessibilityLabel: 'Clear line before cursor' }
]
@@ -0,0 +1,308 @@
import type { AutomationPrecheck, AutomationSchedulePreset } from '../../shared/automations-types'
import {
normalizeTaskSourceContext,
type TaskSourceContext
} from '../../shared/task-source-context'
import type { TuiAgent } from '../../shared/tui-agent'
import {
DEFAULT_AUTOMATION_PRECHECK_TIMEOUT_SECONDS,
MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS
} from '../../shared/automation-precheck'
import { buildAutomationRrule } from '../../shared/automation-schedule-occurrences'
import { isValidAutomationSchedule } from '../../shared/automation-schedule-parsing'
import { isTuiAgent } from '../../shared/tui-agent-config'
import {
getOptionalPositiveIntegerFlag,
getOptionalStringFlag,
getRequiredStringFlag
} from '../flags'
import { RuntimeClientError } from '../runtime-client'
const PRESET_TRIGGERS = new Set<AutomationSchedulePreset>(['hourly', 'daily', 'weekdays', 'weekly'])
const SCHEDULE_MODIFIER_FLAGS = ['day', 'time'] as const
function getScheduleModifierFlag(flags: Map<string, string | boolean>): string | undefined {
return SCHEDULE_MODIFIER_FLAGS.find((flag) => flags.has(flag))
}
function validateScheduleModifierApplicability(
flags: Map<string, string | boolean>,
raw: string
): void {
const isPreset = PRESET_TRIGGERS.has(raw as AutomationSchedulePreset)
if (!isPreset) {
if (flags.has('time')) {
throw new RuntimeClientError(
'invalid_argument',
'--time can only be used with preset automation triggers'
)
}
if (flags.has('day')) {
throw new RuntimeClientError(
'invalid_argument',
'--day can only be used with the weekly automation preset'
)
}
return
}
if (raw === 'hourly' && flags.has('time')) {
throw new RuntimeClientError(
'invalid_argument',
'--time cannot be used with the hourly automation preset; use a cron trigger such as "30 * * * *" to choose the minute'
)
}
if (raw !== 'weekly' && flags.has('day')) {
throw new RuntimeClientError(
'invalid_argument',
'--day can only be used with the weekly automation preset'
)
}
}
function getOptionalDayFlag(flags: Map<string, string | boolean>): number | undefined {
const raw = flags.get('day')
if (raw === undefined) {
return undefined
}
const day = typeof raw === 'string' && /^\d+$/.test(raw) ? Number(raw) : Number.NaN
if (!Number.isInteger(day) || day < 0 || day > 6) {
throw new RuntimeClientError('invalid_argument', '--day must be an integer from 0 to 6')
}
return day
}
export function getProviderFlag(flags: Map<string, string | boolean>): TuiAgent {
const provider = getRequiredStringFlag(flags, 'provider')
if (!isTuiAgent(provider)) {
throw new RuntimeClientError('invalid_argument', `Unknown provider: ${provider}`)
}
return provider
}
export function getOptionalProviderFlag(
flags: Map<string, string | boolean>
): TuiAgent | undefined {
const provider = getOptionalStringFlag(flags, 'provider')
if (provider === undefined) {
return undefined
}
if (!isTuiAgent(provider)) {
throw new RuntimeClientError('invalid_argument', `Unknown provider: ${provider}`)
}
return provider
}
function getTimeFlag(flags: Map<string, string | boolean>): { hour: number; minute: number } {
const value = flags.get('time')
if (value === undefined) {
return { hour: 9, minute: 0 }
}
if (typeof value !== 'string' || value.length === 0) {
throw new RuntimeClientError('invalid_argument', '--time must use HH:MM format')
}
return parseTimeFlag(value)
}
function parseTimeFlag(value: string): { hour: number; minute: number } {
const match = /^(\d{1,2}):(\d{2})$/.exec(value)
if (!match) {
throw new RuntimeClientError('invalid_argument', '--time must use HH:MM format')
}
const hour = Number(match[1])
const minute = Number(match[2])
if (!Number.isInteger(hour) || hour < 0 || hour > 23 || minute < 0 || minute > 59) {
throw new RuntimeClientError('invalid_argument', '--time must be a valid 24-hour time')
}
return { hour, minute }
}
export function getScheduleFlag(
flags: Map<string, string | boolean>,
required: boolean
): { rrule: string; dtstart: number } | undefined {
const trigger = getOptionalStringFlag(flags, 'trigger')
const schedule = getOptionalStringFlag(flags, 'schedule')
if (trigger && schedule) {
throw new RuntimeClientError(
'invalid_argument',
'Use either --trigger or --schedule, not both.'
)
}
const raw = trigger ?? schedule
if (!raw) {
const modifier = getScheduleModifierFlag(flags)
if (modifier) {
throw new RuntimeClientError(
'invalid_argument',
`--${modifier} requires --trigger or --schedule`
)
}
if (required) {
throw new RuntimeClientError('invalid_argument', 'Missing required --trigger')
}
return undefined
}
if (raw === 'manual') {
throw new RuntimeClientError(
'invalid_argument',
'Manual-only automations are not supported yet. Create a scheduled automation with --disabled and run it with `orca automations run <id>` when needed.'
)
}
validateScheduleModifierApplicability(flags, raw)
const { hour, minute } = raw === 'hourly' ? { hour: 0, minute: 0 } : getTimeFlag(flags)
const dayOfWeek = raw === 'weekly' ? (getOptionalDayFlag(flags) ?? 1) : 1
const rrule = PRESET_TRIGGERS.has(raw as AutomationSchedulePreset)
? buildAutomationRrule({
preset: raw as Exclude<AutomationSchedulePreset, 'custom'>,
hour,
minute,
dayOfWeek
})
: raw
if (!isValidAutomationSchedule(rrule)) {
throw new RuntimeClientError('invalid_argument', `Invalid automation trigger: ${raw}`)
}
return { rrule, dtstart: Date.now() }
}
export function getEnabledFlag(flags: Map<string, string | boolean>): boolean | undefined {
const enabledFlag = flags.get('enabled')
const disabledFlag = flags.get('disabled')
if (typeof enabledFlag === 'string') {
throw new RuntimeClientError('invalid_argument', '--enabled does not take a value')
}
if (typeof disabledFlag === 'string') {
throw new RuntimeClientError('invalid_argument', '--disabled does not take a value')
}
const enabled = enabledFlag === true
const disabled = disabledFlag === true
if (enabled && disabled) {
throw new RuntimeClientError(
'invalid_argument',
'Use either --enabled or --disabled, not both.'
)
}
if (enabled) {
return true
}
if (disabled) {
return false
}
return undefined
}
export function getReuseSessionFlag(flags: Map<string, string | boolean>): boolean | undefined {
const reuseFlag = flags.get('reuse-session')
const freshFlag = flags.get('fresh-session')
if (typeof reuseFlag === 'string') {
throw new RuntimeClientError('invalid_argument', '--reuse-session does not take a value')
}
if (typeof freshFlag === 'string') {
throw new RuntimeClientError('invalid_argument', '--fresh-session does not take a value')
}
const reuse = reuseFlag === true
const fresh = freshFlag === true
if (reuse && fresh) {
throw new RuntimeClientError(
'invalid_argument',
'Use either --reuse-session or --fresh-session, not both.'
)
}
if (reuse) {
return true
}
if (fresh) {
return false
}
return undefined
}
export function getPrecheckFlag(
flags: Map<string, string | boolean>
): AutomationPrecheck | null | undefined {
const hasPrecheck = flags.has('precheck')
const timeoutSeconds = getOptionalPositiveIntegerFlag(flags, 'precheck-timeout')
if (!hasPrecheck) {
if (timeoutSeconds !== undefined) {
throw new RuntimeClientError('invalid_argument', '--precheck-timeout requires --precheck')
}
return undefined
}
const value = flags.get('precheck')
if (typeof value !== 'string') {
throw new RuntimeClientError('invalid_argument', '--precheck requires a command')
}
const command = value.trim()
if (!command) {
return null
}
if (timeoutSeconds !== undefined && timeoutSeconds > MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS) {
throw new RuntimeClientError(
'invalid_argument',
`--precheck-timeout must be at most ${MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS} seconds`
)
}
return {
command,
timeoutSeconds: timeoutSeconds ?? DEFAULT_AUTOMATION_PRECHECK_TIMEOUT_SECONDS
}
}
export function getSourceContextFlag(
flags: Map<string, string | boolean>
): TaskSourceContext | null | undefined {
if (!flags.has('source-context')) {
return undefined
}
const value = flags.get('source-context')
if (typeof value !== 'string') {
throw new RuntimeClientError(
'invalid_argument',
'--source-context requires a JSON TaskSourceContext or null'
)
}
let parsed: unknown
try {
parsed = JSON.parse(value)
} catch {
throw new RuntimeClientError('invalid_argument', '--source-context must be valid JSON')
}
if (parsed === null) {
return null
}
if (!parsed || typeof parsed !== 'object') {
throw new RuntimeClientError(
'invalid_argument',
'--source-context must be a JSON TaskSourceContext or null'
)
}
const sourceContext = normalizeTaskSourceContext(
parsed as Parameters<typeof normalizeTaskSourceContext>[0]
)
if (!sourceContext) {
throw new RuntimeClientError(
'invalid_argument',
'--source-context is not a valid TaskSourceContext'
)
}
return sourceContext
}
export function getWorkspaceModeFlag(
flags: Map<string, string | boolean>
): 'existing' | 'new_per_run' | undefined {
const value = getOptionalStringFlag(flags, 'workspace-mode')
if (value === undefined) {
return undefined
}
if (value === 'existing') {
return 'existing'
}
if (value === 'new-per-run' || value === 'new_per_run') {
return 'new_per_run'
}
throw new RuntimeClientError(
'invalid_argument',
'--workspace-mode must be existing or new-per-run'
)
}
+10 -299
View File
@@ -1,16 +1,11 @@
/* eslint-disable max-lines -- Why: automation handlers share schedule parsing, target resolution, and RPC payload shaping for one command family. */
import type {
Automation,
AutomationCreateInput,
AutomationPrecheck,
AutomationRun,
AutomationSchedulePreset,
AutomationUpdateInput
} from '../../shared/automations-types'
import {
buildWorkspaceRunContext,
normalizeTaskSourceContext,
type TaskSourceContext,
type WorkspaceRunContext
} from '../../shared/task-source-context'
import type {
@@ -18,13 +13,6 @@ import type {
AutomationOwnerPrecondition
} from '../../shared/automation-owner-precondition'
import type { ProjectHostSetup } from '../../shared/project-types'
import type { TuiAgent } from '../../shared/tui-agent'
import {
DEFAULT_AUTOMATION_PRECHECK_TIMEOUT_SECONDS,
MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS
} from '../../shared/automation-precheck'
import { buildAutomationRrule, isValidAutomationSchedule } from '../../shared/automation-schedules'
import { isTuiAgent } from '../../shared/tui-agent-config'
import type { CommandHandler } from '../dispatch'
import {
formatAutomationList,
@@ -48,6 +36,16 @@ import {
hasWorkspaceProjectTarget,
resolveProjectCreateTarget
} from '../worktree-project-target'
import {
getEnabledFlag,
getOptionalProviderFlag,
getPrecheckFlag,
getProviderFlag,
getReuseSessionFlag,
getScheduleFlag,
getSourceContextFlag,
getWorkspaceModeFlag
} from './automation-handler-flags'
type AutomationCreateParams = Omit<AutomationCreateInput, 'projectId' | 'timezone'> & {
destination?: AutomationDestination
@@ -61,293 +59,6 @@ type AutomationUpdateParams = AutomationUpdateInput & {
workspace?: string
}
const PRESET_TRIGGERS = new Set<AutomationSchedulePreset>(['hourly', 'daily', 'weekdays', 'weekly'])
const SCHEDULE_MODIFIER_FLAGS = ['day', 'time'] as const
function getScheduleModifierFlag(flags: Map<string, string | boolean>): string | undefined {
return SCHEDULE_MODIFIER_FLAGS.find((flag) => flags.has(flag))
}
function validateScheduleModifierApplicability(
flags: Map<string, string | boolean>,
raw: string
): void {
const isPreset = PRESET_TRIGGERS.has(raw as AutomationSchedulePreset)
if (!isPreset) {
if (flags.has('time')) {
throw new RuntimeClientError(
'invalid_argument',
'--time can only be used with preset automation triggers'
)
}
if (flags.has('day')) {
throw new RuntimeClientError(
'invalid_argument',
'--day can only be used with the weekly automation preset'
)
}
return
}
if (raw === 'hourly' && flags.has('time')) {
throw new RuntimeClientError(
'invalid_argument',
'--time cannot be used with the hourly automation preset; use a cron trigger such as "30 * * * *" to choose the minute'
)
}
if (raw !== 'weekly' && flags.has('day')) {
throw new RuntimeClientError(
'invalid_argument',
'--day can only be used with the weekly automation preset'
)
}
}
function getOptionalDayFlag(flags: Map<string, string | boolean>): number | undefined {
const raw = flags.get('day')
if (raw === undefined) {
return undefined
}
const day = typeof raw === 'string' && /^\d+$/.test(raw) ? Number(raw) : Number.NaN
if (!Number.isInteger(day) || day < 0 || day > 6) {
throw new RuntimeClientError('invalid_argument', '--day must be an integer from 0 to 6')
}
return day
}
function getProviderFlag(flags: Map<string, string | boolean>): TuiAgent {
const provider = getRequiredStringFlag(flags, 'provider')
if (!isTuiAgent(provider)) {
throw new RuntimeClientError('invalid_argument', `Unknown provider: ${provider}`)
}
return provider
}
function getOptionalProviderFlag(flags: Map<string, string | boolean>): TuiAgent | undefined {
const provider = getOptionalStringFlag(flags, 'provider')
if (provider === undefined) {
return undefined
}
if (!isTuiAgent(provider)) {
throw new RuntimeClientError('invalid_argument', `Unknown provider: ${provider}`)
}
return provider
}
function getTimeFlag(flags: Map<string, string | boolean>): { hour: number; minute: number } {
const value = flags.get('time')
if (value === undefined) {
return { hour: 9, minute: 0 }
}
if (typeof value !== 'string' || value.length === 0) {
throw new RuntimeClientError('invalid_argument', '--time must use HH:MM format')
}
return parseTimeFlag(value)
}
function parseTimeFlag(value: string): { hour: number; minute: number } {
const match = /^(\d{1,2}):(\d{2})$/.exec(value)
if (!match) {
throw new RuntimeClientError('invalid_argument', '--time must use HH:MM format')
}
const hour = Number(match[1])
const minute = Number(match[2])
if (!Number.isInteger(hour) || hour < 0 || hour > 23 || minute < 0 || minute > 59) {
throw new RuntimeClientError('invalid_argument', '--time must be a valid 24-hour time')
}
return { hour, minute }
}
function getScheduleFlag(
flags: Map<string, string | boolean>,
required: boolean
): { rrule: string; dtstart: number } | undefined {
const trigger = getOptionalStringFlag(flags, 'trigger')
const schedule = getOptionalStringFlag(flags, 'schedule')
if (trigger && schedule) {
throw new RuntimeClientError(
'invalid_argument',
'Use either --trigger or --schedule, not both.'
)
}
const raw = trigger ?? schedule
if (!raw) {
const modifier = getScheduleModifierFlag(flags)
if (modifier) {
throw new RuntimeClientError(
'invalid_argument',
`--${modifier} requires --trigger or --schedule`
)
}
if (required) {
throw new RuntimeClientError('invalid_argument', 'Missing required --trigger')
}
return undefined
}
if (raw === 'manual') {
throw new RuntimeClientError(
'invalid_argument',
'Manual-only automations are not supported yet. Create a scheduled automation with --disabled and run it with `orca automations run <id>` when needed.'
)
}
validateScheduleModifierApplicability(flags, raw)
const { hour, minute } = raw === 'hourly' ? { hour: 0, minute: 0 } : getTimeFlag(flags)
const dayOfWeek = raw === 'weekly' ? (getOptionalDayFlag(flags) ?? 1) : 1
const rrule = PRESET_TRIGGERS.has(raw as AutomationSchedulePreset)
? buildAutomationRrule({
preset: raw as Exclude<AutomationSchedulePreset, 'custom'>,
hour,
minute,
dayOfWeek
})
: raw
if (!isValidAutomationSchedule(rrule)) {
throw new RuntimeClientError('invalid_argument', `Invalid automation trigger: ${raw}`)
}
return { rrule, dtstart: Date.now() }
}
function getEnabledFlag(flags: Map<string, string | boolean>): boolean | undefined {
const enabledFlag = flags.get('enabled')
const disabledFlag = flags.get('disabled')
if (typeof enabledFlag === 'string') {
throw new RuntimeClientError('invalid_argument', '--enabled does not take a value')
}
if (typeof disabledFlag === 'string') {
throw new RuntimeClientError('invalid_argument', '--disabled does not take a value')
}
const enabled = enabledFlag === true
const disabled = disabledFlag === true
if (enabled && disabled) {
throw new RuntimeClientError(
'invalid_argument',
'Use either --enabled or --disabled, not both.'
)
}
if (enabled) {
return true
}
if (disabled) {
return false
}
return undefined
}
function getReuseSessionFlag(flags: Map<string, string | boolean>): boolean | undefined {
const reuseFlag = flags.get('reuse-session')
const freshFlag = flags.get('fresh-session')
if (typeof reuseFlag === 'string') {
throw new RuntimeClientError('invalid_argument', '--reuse-session does not take a value')
}
if (typeof freshFlag === 'string') {
throw new RuntimeClientError('invalid_argument', '--fresh-session does not take a value')
}
const reuse = reuseFlag === true
const fresh = freshFlag === true
if (reuse && fresh) {
throw new RuntimeClientError(
'invalid_argument',
'Use either --reuse-session or --fresh-session, not both.'
)
}
if (reuse) {
return true
}
if (fresh) {
return false
}
return undefined
}
function getPrecheckFlag(
flags: Map<string, string | boolean>
): AutomationPrecheck | null | undefined {
const hasPrecheck = flags.has('precheck')
const timeoutSeconds = getOptionalPositiveIntegerFlag(flags, 'precheck-timeout')
if (!hasPrecheck) {
if (timeoutSeconds !== undefined) {
throw new RuntimeClientError('invalid_argument', '--precheck-timeout requires --precheck')
}
return undefined
}
const value = flags.get('precheck')
if (typeof value !== 'string') {
throw new RuntimeClientError('invalid_argument', '--precheck requires a command')
}
const command = value.trim()
if (!command) {
return null
}
if (timeoutSeconds !== undefined && timeoutSeconds > MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS) {
throw new RuntimeClientError(
'invalid_argument',
`--precheck-timeout must be at most ${MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS} seconds`
)
}
return {
command,
timeoutSeconds: timeoutSeconds ?? DEFAULT_AUTOMATION_PRECHECK_TIMEOUT_SECONDS
}
}
function getSourceContextFlag(
flags: Map<string, string | boolean>
): TaskSourceContext | null | undefined {
if (!flags.has('source-context')) {
return undefined
}
const value = flags.get('source-context')
if (typeof value !== 'string') {
throw new RuntimeClientError(
'invalid_argument',
'--source-context requires a JSON TaskSourceContext or null'
)
}
let parsed: unknown
try {
parsed = JSON.parse(value)
} catch {
throw new RuntimeClientError('invalid_argument', '--source-context must be valid JSON')
}
if (parsed === null) {
return null
}
if (!parsed || typeof parsed !== 'object') {
throw new RuntimeClientError(
'invalid_argument',
'--source-context must be a JSON TaskSourceContext or null'
)
}
const sourceContext = normalizeTaskSourceContext(
parsed as Parameters<typeof normalizeTaskSourceContext>[0]
)
if (!sourceContext) {
throw new RuntimeClientError(
'invalid_argument',
'--source-context is not a valid TaskSourceContext'
)
}
return sourceContext
}
function getWorkspaceModeFlag(
flags: Map<string, string | boolean>
): 'existing' | 'new_per_run' | undefined {
const value = getOptionalStringFlag(flags, 'workspace-mode')
if (value === undefined) {
return undefined
}
if (value === 'existing') {
return 'existing'
}
if (value === 'new-per-run' || value === 'new_per_run') {
return 'new_per_run'
}
throw new RuntimeClientError(
'invalid_argument',
'--workspace-mode must be existing or new-per-run'
)
}
async function resolveDefaultTarget(
flags: Map<string, string | boolean>,
cwd: string,
+3 -364
View File
@@ -1,371 +1,10 @@
/* eslint-disable max-lines -- Why: root and generated command help text live together so CLI discovery stays greppable. */
import type { CommandSpec } from './args'
import { findCommandSpec, isCommandGroup, supportsBrowserPageFlag } from './args'
import { unknownCommandData } from './command-suggestion'
import { ROOT_HELP_TEXT_PRIMARY } from './root-help-text-primary'
import { ROOT_HELP_TEXT_SECONDARY } from './root-help-text-secondary'
const ROOT_HELP_TEXT = `orca
Usage: orca <command> [options]
Startup:
open Launch Orca and wait for the runtime to be reachable
serve Start a headless Orca runtime server
status Show app/runtime/graph readiness
Diagnostics:
diagnostics memory Collect a memory snapshot for Orca and managed terminals
Agent Discovery:
agent-context Print the machine-readable command schema for agents
Accounts:
account add Add a managed Claude or Codex account on this Orca host
account list List managed Claude and Codex accounts on this Orca host
Skills:
skills installed List installed skill selectors
skills share Publish selected skills behind one unlisted link
skills list List version-matched skill guides bundled with this Orca CLI
skills get Print a version-matched skill guide as Markdown
skills install Install bundled Orca skills globally via the community skills CLI
skills update Update already-installed Orca skills via the community skills CLI
Hosts:
host list List targetable machines and how to name each one
Environments:
environment add Save a remote Orca runtime from a pairing code
environment list List saved remote Orca runtimes
environment show Show one saved remote Orca runtime
environment rm Remove a saved remote Orca runtime
Environment Recipes:
vm recipe doctor Validate a per-workspace environment recipe
Automations:
automations list List scheduled Orca automations
automations show Show one Orca automation
automations create Create a scheduled Orca automation
automations edit Edit an Orca automation
automations remove Remove an Orca automation and its run history
automations run Run an Orca automation now
automations runs List automation run history
Projects:
project list List durable projects known to Orca
project setups List project host setups
project setup-existing-folder Make a project available on a host by importing an existing folder
project setup-clone Make a project available on a host by cloning a repository
project setup-create Create independent project host setup metadata
project setup-update Update project host setup metadata
project setup-delete Remove a project host setup
Repos:
repo list List repos registered in Orca
repo add Add a project to Orca by filesystem path
repo show Show one registered repo
repo set-base-ref Set the repo's default base ref for future worktrees
repo search-refs Search branch/tag refs within a repo
Worktrees:
worktree list List Orca-managed worktrees
worktree show Show one worktree
worktree current Show the Orca-managed worktree for the current directory
worktree create Create a new Orca-managed worktree
worktree set Update Orca metadata for a worktree
worktree rm Remove a worktree from Orca and git
worktree ps Show a compact orchestration summary across worktrees
Files:
file open Open a workspace file in the Orca editor
file diff Open a workspace file diff in the Orca editor
file open-changed Open all git-changed files for a workspace
Terminals:
terminal list List live Orca-managed terminals
terminal show Show terminal metadata and preview
terminal read Read bounded terminal output
terminal send Send input to a live terminal
terminal wait Wait for a terminal condition (exit, tui-idle)
terminal stop Stop terminals for a worktree
terminal create Create a terminal session in a worktree
terminal rename Set or clear the title of a terminal tab
terminal split Split an existing terminal pane
terminal switch Bring a terminal tab to the foreground
terminal focus Alias for terminal switch
terminal close Close a terminal pane/session, or its whole tab with --tab
Orchestration:
orchestration run-create Create and bind a lightweight orchestration Run
orchestration run-use Bind this coordinator terminal to an existing Run
orchestration run-current Show this terminal's bound Run
orchestration run-list List lightweight orchestration Runs
orchestration run-show Show one lightweight orchestration Run
orchestration send Send an inter-agent message
orchestration check Check the bound Run mailbox
orchestration ask Ask the coordinator a blocking question
orchestration reply Reply to a message
orchestration inbox Show all messages across recipients
orchestration task-create Create an orchestration task
orchestration task-list List orchestration tasks
orchestration task-update Update a task status
orchestration dispatch Dispatch a task to a terminal
orchestration dispatch-show Show dispatch context for a task
orchestration worker-start Start a supervised worker locally or on a connected Orca server
orchestration worker-show Inspect one supervised worker
orchestration worker-read Read bounded output from one supervised worker
orchestration worker-stop Fence one Dispatch; stop only its supervised worker
orchestration worker-abandon Fence an uncertain worker without claiming it stopped
orchestration worker-release Release a settled worker's terminal after archiving its output
orchestration worker-retain Keep a worker terminal live for debugging
orchestration worker-list Report worker terminal resource accounting
orchestration coordinator-start Start the legacy automatic coordinator loop
orchestration coordinator-stop Stop the legacy automatic coordinator loop
orchestration gate-create Create a decision gate blocking a task
orchestration gate-resolve Resolve a pending decision gate
orchestration gate-list List decision gates
orchestration reset Reset orchestration state
Computer Use:
computer capabilities Show computer-use provider capabilities
computer permissions Show or open computer-use permission setup
computer list-apps List running apps available to computer-use
computer list-windows List visible windows for a target app
computer get-app-state Capture a compact accessibility snapshot of an app
computer click Click an app element or window coordinate
computer perform-secondary-action Run an advertised accessibility action
computer scroll Scroll an app element
computer drag Drag between app elements or window coordinates
computer type-text Type literal text at the current app focus
computer press-key Press a single key such as Return or Escape
computer hotkey Press a shortcut combination such as CmdOrCtrl+A
computer paste-text Paste text through the native clipboard path
computer set-value Set the value of a settable app element
Linear:
linear Read Linear ticket context for agents
Mobile Emulator (iOS Simulator):
emulator list List available/running emulators (Orca-managed + raw serve-sim)
emulator attach <device> Attach/start helper and make active for the worktree
emulator tap <x> <y> Tap at normalized 0..1 coords (preferred for single taps)
emulator type <text> Type text (US ASCII only)
emulator gesture <json> Send begin/move/end touch points
emulator button <name> Hardware button (home, side_button, etc.)
emulator rotate <o> Rotate device (portrait|landscape_left|...)
emulator exec --command Raw serve-sim subcommand passthrough (no "serve-sim" prefix)
emulator kill Stop helper for device
Browser Automation:
tab create Create a new browser tab (navigates to --url)
tab list List open browser tabs
tab show Show one browser tab by page id
tab current Show the current browser tab
tab profile list List browser session profiles
tab profile create Create a browser session profile
tab profile delete Delete a browser session profile
tab profile set Switch a browser tab to a different profile
tab profile show Show the profile bound to a browser tab
tab profile use-default Switch a browser tab back to the default profile
tab profile clone Clone a browser tab into another profile
tab switch Switch the active browser tab by --index or --page
tab close Close a browser tab by --index/--page or the current tab
snapshot Accessibility snapshot with element refs (e.g. @e1, @e2)
goto Navigate the active tab to --url
click Click element by --element ref
fill Clear and fill input by --element ref with --value
type Type --input text at the current focus (no element needed)
select Select dropdown option by --element ref and --value
hover Hover element by --element ref
keypress Press a key (e.g. --key Enter, --key Tab)
scroll Scroll --direction (up/down) by --amount pixels
back Navigate back in browser history
reload Reload the active browser tab
screenshot Capture viewport screenshot (--format png|jpeg)
eval Evaluate --expression JavaScript in the page context
wait Wait for page idle or --timeout ms
check Check a checkbox by --element ref
uncheck Uncheck a checkbox by --element ref
focus Focus an element by --element ref
clear Clear an input by --element ref
drag Drag --from ref to --to ref
upload Upload --files to a file input by --element ref
dblclick Double-click element by --element ref
forward Navigate forward in browser history
scrollintoview Scroll --element into view
get Get element property (--what: text, html, value, url, title)
is Check element state (--what: visible, enabled, checked)
inserttext Insert text without key events
mouse move Move mouse to --x --y coordinates
mouse down Press mouse button
mouse up Release mouse button
mouse wheel Scroll wheel --dy [--dx]
find Find element by locator (--locator role|text|label --value <v>)
set device Emulate device (--name "iPhone 12")
set offline Toggle offline mode (--state on|off)
set headers Set HTTP headers (--headers '{"key":"val"}')
set credentials Set HTTP auth (--user <u> --pass <p>)
set media Set color scheme (--color-scheme dark|light)
clipboard read Read clipboard contents
clipboard write Write --text to clipboard
dialog accept Accept browser dialog (--text for prompt response)
dialog dismiss Dismiss browser dialog
storage local get Get localStorage value by --key
storage local set Set localStorage --key --value
storage local clear Clear localStorage
storage session get Get sessionStorage value by --key
storage session set Set sessionStorage --key --value
storage session clear Clear sessionStorage
download Download file via --selector to --path
highlight Highlight --selector on page
exec Run any agent-browser command (--command "...")
Common Commands:
orca open [--json]
orca serve [--port <port>] [--pairing-address <host>] [--mobile-pairing] [--no-pairing] [--project-root <path>] [--recipe-json] [--json]
orca status [--json]
orca diagnostics memory [--json]
orca agent-context [--json]
orca account add [--agent claude|codex] [--json]
orca account list [--json]
orca host list [--json]
orca environment add --name <name> --pairing-code <code> [--json]
orca environment list [--json]
orca environment show --environment <selector> [--json]
orca environment rm --environment <selector> [--json]
orca worktree list [--repo <selector>] [--limit <n>] [--json]
orca worktree create --name <name> [--repo <selector>|--project <id> [--host <host-id>]|--project-host-setup <id>] [--agent <id>] [--prompt <text>] [--setup run|skip|inherit] [--base-branch <ref>] [--issue <number>] [--linear-issue <identifier-or-url>] [--comment <text>] [--parent-worktree <selector>] [--no-parent] [--run-hooks] [--activate] [--json]
orca worktree show --worktree <selector> [--json]
orca worktree current [--json]
orca worktree set --worktree <selector> [--display-name <name>] [--issue <number|null>] [--linear-issue <identifier-or-url|null>] [--comment <text>] [--workspace-status <id>] [--parent-worktree <selector>|--no-parent] [--json]
orca worktree rm --worktree <selector> [--force] [--run-hooks] [--json]
orca worktree ps [--limit <n>] [--json]
orca file open <path> [--worktree <selector>] [--json]
orca file diff <path> [--staged] [--worktree <selector>] [--json]
orca file open-changed [--mode edit|diff|both] [--worktree <selector>] [--json]
orca terminal list [--worktree <selector>] [--limit <n>] [--include-visual-layouts] [--json]
orca terminal show [--terminal <handle>] [--json]
orca terminal read [--terminal <handle>] [--cursor <n>] [--limit <n>] [--json]
orca terminal send [--terminal <handle>] [--text <text>] [--enter] [--interrupt] [--json]
orca terminal wait [--terminal <handle>] --for exit|tui-idle [--timeout-ms <ms>] [--json]
orca terminal stop --worktree <selector> [--json]
orca terminal create [--worktree <selector>] [--title <name>] [--command <text>] [--focus] [--json]
orca terminal split [--terminal <handle>] [--direction horizontal|vertical] [--json]
orca terminal switch [--terminal <handle>] [--json]
orca terminal close [--terminal <handle>] [--tab] [--json]
orca project list [--json]
orca project setups [--project <id>] [--host <host-id>] [--json]
orca project setup-existing-folder --project <id> --host <host-id> --path <path> [--kind git|folder] [--display-name <name>] [--json]
orca project setup-clone --project <id> --host <host-id> --url <clone-url> --destination <path> [--display-name <name>] [--json]
orca project setup-create --project <id> --host <host-id> [--setup-id <id>] [--path <path>] [--kind git|folder] [--display-name <name>] [--worktree-base-path <path>] [--git-username <name>] [--state ready|not-set-up|setting-up|error|unsupported] [--method imported-existing-folder|cloned|provisioned] [--json]
orca project setup-update --setup <setup-id> [--display-name <name>] [--path <path>] [--worktree-base-path <path>] [--git-username <name>] [--kind git|folder] [--state ready|not-set-up|setting-up|error|unsupported] [--method legacy-repo|imported-existing-folder|cloned|provisioned] [--json]
orca project setup-delete --setup <setup-id> [--json]
orca repo list [--json]
orca repo add --path <path> [--json]
orca repo show --repo <selector> [--json]
orca repo set-base-ref --repo <selector> --ref <ref> [--json]
orca repo search-refs --repo <selector> --query <text> [--limit <n>] [--json]
Selectors:
--repo <selector> Registered repo selector such as id:<id>, name:<name>, or path:<path>
--worktree <selector> Worktree selector such as identity:<identity>, id:<repo-id>::<path>, name:<displayName>, branch:<branch>, issue:<number>, path:<path>, or active/current
--terminal <handle> Runtime-issued terminal handle returned by \`orca terminal list --json\`
--parent-worktree <selector> Parent worktree selector such as identity:<identity>, id:<repo-id>::<path>, branch:<branch>, issue:<number>, path:<path>, or active/current
--no-parent Force no parent lineage for unrelated worktree creation/update
Terminal Send Options:
--text <text> Text to send to the terminal
--enter Append Enter after sending text
--interrupt Send as an interrupt-style input when supported
Terminal List Options:
--include-visual-layouts Include tab and pane topology in JSON output
Wait Options:
--for exit Wait until the target terminal exits
--timeout-ms <ms> Maximum wait time before timing out
Output Options:
--json Emit machine-readable JSON instead of human text
--pairing-code <code> Connect to a remote Orca runtime using an orca://pair?... code
--environment <selector> Connect using a saved environment id or name
--help Show this help message
Behavior:
Most commands require a running Orca runtime. If Orca is not open yet, run \`orca open\` first.
Remote runtime access can also be supplied with ORCA_PAIRING_CODE or ORCA_ENVIRONMENT.
Use selectors for discovery and handles for repeated live terminal operations.
Agent Sessions And Worktrees:
\`worktree create --agent\` creates a new checkout with an agent.
To start a fresh agent in the current worktree, use:
orca terminal create --worktree active --command "codex"
Browser Workflow:
1. Create or navigate: orca tab create --url https://example.com
orca goto --url https://example.com
2. Inspect the page: orca snapshot
(Returns an accessibility tree with element refs like e1, e2, e3)
For concurrent workflows, prefer: orca tab list --json
then reuse tabs[].browserPageId with --page <id> on later commands.
3. Interact: orca click --element e2
orca fill --element e5 --value "search query"
orca keypress --key Enter
4. Re-inspect: orca snapshot
(Element refs change after navigation — always re-snapshot before interacting)
Browser Options:
--element <ref> Element ref from snapshot (e.g. @e3)
--url <url> URL to navigate to
--value <text> Value to fill or select
--input <text> Text to type at current focus (no element needed)
--expression <js> JavaScript expression to evaluate
--key <key> Key to press (Enter, Tab, Escape, Control+a, etc.)
--direction <dir> Scroll direction: up or down
--amount <pixels> Scroll distance in pixels (default: viewport height)
--index <n> Tab index (from \`tab list\`)
--page <id> Stable browser page id (preferred for concurrent workflows)
--profile <id> Browser profile id
--show-profile Include the tab's browser profile in text output
--no-ua-spoof Keep Electron's native user agent for a new profile
--format <png|jpeg> Screenshot image format
--from <ref> Drag source element ref
--to <ref> Drag target element ref
--files <path,...> Comma-separated file paths for upload
--timeout <ms> Wait timeout in milliseconds
--worktree <selector> Scope commands to a specific worktree's browser tabs
Examples:
$ orca open
$ orca status --json
$ orca diagnostics memory --json
$ orca repo list
$ orca worktree create --name agent-task --agent codex --prompt "hi"
$ orca worktree create --repo name:orca --name cli-test-1 --issue 273
$ orca worktree create --repo name:orca --name linear-task --linear-issue https://linear.app/stably/issue/STA-335/test-issue
$ orca worktree create --name linear-task --linear-issue STA-335
$ orca worktree show --worktree branch:Jinwoo-H/cli
$ orca worktree current
$ orca worktree set --worktree active --comment "waiting on review"
$ orca worktree set --worktree active --linear-issue null
$ orca worktree ps --limit 10
$ orca file open-changed --mode diff
$ orca file open src/App.tsx
$ orca terminal create --worktree active --command "codex"
$ orca terminal list --worktree path:/Users/me/orca/workspaces/orca/cli-test-1 --json
$ orca terminal send --terminal term_123 --text "hi" --enter
$ orca terminal wait --terminal term_123 --for exit --timeout-ms 60000 --json
$ orca tab current --json
$ orca tab show --page page_123 --json
$ orca tab create --url https://example.com --profile work
$ orca tab profile clone --page page_123 --profile work --json
$ orca snapshot
$ orca click --element e3
$ orca fill --element e5 --value "hello"
$ orca goto --url https://example.com/login
$ orca keypress --key Enter
$ orca eval --expression "document.title"
$ orca tab list --json`
const ROOT_HELP_TEXT = [ROOT_HELP_TEXT_PRIMARY, ROOT_HELP_TEXT_SECONDARY].join('\n')
export function printHelp(specs: CommandSpec[], commandPath: string[] = []): void {
const exactSpec = findCommandSpec(specs, commandPath)
+184
View File
@@ -0,0 +1,184 @@
export const ROOT_HELP_TEXT_PRIMARY = [
'orca',
'',
'Usage: orca <command> [options]',
'',
'Startup:',
' open Launch Orca and wait for the runtime to be reachable',
' serve Start a headless Orca runtime server',
' status Show app/runtime/graph readiness',
'',
'Diagnostics:',
' diagnostics memory Collect a memory snapshot for Orca and managed terminals',
'',
'Agent Discovery:',
' agent-context Print the machine-readable command schema for agents',
'',
'Accounts:',
' account add Add a managed Claude or Codex account on this Orca host',
' account list List managed Claude and Codex accounts on this Orca host',
'',
'Skills:',
' skills installed List installed skill selectors',
' skills share Publish selected skills behind one unlisted link',
' skills list List version-matched skill guides bundled with this Orca CLI',
' skills get Print a version-matched skill guide as Markdown',
' skills install Install bundled Orca skills globally via the community skills CLI',
' skills update Update already-installed Orca skills via the community skills CLI',
'',
'Hosts:',
' host list List targetable machines and how to name each one',
'',
'Environments:',
' environment add Save a remote Orca runtime from a pairing code',
' environment list List saved remote Orca runtimes',
' environment show Show one saved remote Orca runtime',
' environment rm Remove a saved remote Orca runtime',
'',
'Environment Recipes:',
' vm recipe doctor Validate a per-workspace environment recipe',
'',
'Automations:',
' automations list List scheduled Orca automations',
' automations show Show one Orca automation',
' automations create Create a scheduled Orca automation',
' automations edit Edit an Orca automation',
' automations remove Remove an Orca automation and its run history',
' automations run Run an Orca automation now',
' automations runs List automation run history',
'',
'Projects:',
' project list List durable projects known to Orca',
' project setups List project host setups',
' project setup-existing-folder Make a project available on a host by importing an existing folder',
' project setup-clone Make a project available on a host by cloning a repository',
' project setup-create Create independent project host setup metadata',
' project setup-update Update project host setup metadata',
' project setup-delete Remove a project host setup',
'',
'Repos:',
' repo list List repos registered in Orca',
' repo add Add a project to Orca by filesystem path',
' repo show Show one registered repo',
" repo set-base-ref Set the repo's default base ref for future worktrees",
' repo search-refs Search branch/tag refs within a repo',
'',
'Worktrees:',
' worktree list List Orca-managed worktrees',
' worktree show Show one worktree',
' worktree current Show the Orca-managed worktree for the current directory',
' worktree create Create a new Orca-managed worktree',
' worktree set Update Orca metadata for a worktree',
' worktree rm Remove a worktree from Orca and git',
' worktree ps Show a compact orchestration summary across worktrees',
'',
'Files:',
' file open Open a workspace file in the Orca editor',
' file diff Open a workspace file diff in the Orca editor',
' file open-changed Open all git-changed files for a workspace',
'',
'Terminals:',
' terminal list List live Orca-managed terminals',
' terminal show Show terminal metadata and preview',
' terminal read Read bounded terminal output',
' terminal send Send input to a live terminal',
' terminal wait Wait for a terminal condition (exit, tui-idle)',
' terminal stop Stop terminals for a worktree',
' terminal create Create a terminal session in a worktree',
' terminal rename Set or clear the title of a terminal tab',
' terminal split Split an existing terminal pane',
' terminal switch Bring a terminal tab to the foreground',
' terminal focus Alias for terminal switch',
' terminal close Close a terminal pane/session, or its whole tab with --tab',
'',
'Orchestration:',
' orchestration run-create Create and bind a lightweight orchestration Run',
' orchestration run-use Bind this coordinator terminal to an existing Run',
" orchestration run-current Show this terminal's bound Run",
' orchestration run-list List lightweight orchestration Runs',
' orchestration run-show Show one lightweight orchestration Run',
' orchestration send Send an inter-agent message',
' orchestration check Check the bound Run mailbox',
' orchestration ask Ask the coordinator a blocking question',
' orchestration reply Reply to a message',
' orchestration inbox Show all messages across recipients',
' orchestration task-create Create an orchestration task',
' orchestration task-list List orchestration tasks',
' orchestration task-update Update a task status',
' orchestration dispatch Dispatch a task to a terminal',
' orchestration dispatch-show Show dispatch context for a task',
' orchestration worker-start Start a supervised worker locally or on a connected Orca server',
' orchestration worker-show Inspect one supervised worker',
' orchestration worker-read Read bounded output from one supervised worker',
' orchestration worker-stop Fence one Dispatch; stop only its supervised worker',
' orchestration worker-abandon Fence an uncertain worker without claiming it stopped',
" orchestration worker-release Release a settled worker's terminal after archiving its output",
' orchestration worker-retain Keep a worker terminal live for debugging',
' orchestration worker-list Report worker terminal resource accounting',
' orchestration coordinator-start Start the legacy automatic coordinator loop',
' orchestration coordinator-stop Stop the legacy automatic coordinator loop',
' orchestration gate-create Create a decision gate blocking a task',
' orchestration gate-resolve Resolve a pending decision gate',
' orchestration gate-list List decision gates',
' orchestration reset Reset orchestration state',
'',
'Computer Use:',
' computer capabilities Show computer-use provider capabilities',
' computer permissions Show or open computer-use permission setup',
' computer list-apps List running apps available to computer-use',
' computer list-windows List visible windows for a target app',
' computer get-app-state Capture a compact accessibility snapshot of an app',
' computer click Click an app element or window coordinate',
' computer perform-secondary-action Run an advertised accessibility action',
' computer scroll Scroll an app element',
' computer drag Drag between app elements or window coordinates',
' computer type-text Type literal text at the current app focus',
' computer press-key Press a single key such as Return or Escape',
' computer hotkey Press a shortcut combination such as CmdOrCtrl+A',
' computer paste-text Paste text through the native clipboard path',
' computer set-value Set the value of a settable app element',
'',
'Linear:',
' linear Read Linear ticket context for agents',
'',
'Mobile Emulator (iOS Simulator):',
' emulator list List available/running emulators (Orca-managed + raw serve-sim)',
' emulator attach <device> Attach/start helper and make active for the worktree',
' emulator tap <x> <y> Tap at normalized 0..1 coords (preferred for single taps)',
' emulator type <text> Type text (US ASCII only)',
' emulator gesture <json> Send begin/move/end touch points',
' emulator button <name> Hardware button (home, side_button, etc.)',
' emulator rotate <o> Rotate device (portrait|landscape_left|...)',
' emulator exec --command Raw serve-sim subcommand passthrough (no "serve-sim" prefix)',
' emulator kill Stop helper for device',
'',
'Browser Automation:',
' tab create Create a new browser tab (navigates to --url)',
' tab list List open browser tabs',
' tab show Show one browser tab by page id',
' tab current Show the current browser tab',
' tab profile list List browser session profiles',
' tab profile create Create a browser session profile',
' tab profile delete Delete a browser session profile',
' tab profile set Switch a browser tab to a different profile',
' tab profile show Show the profile bound to a browser tab',
' tab profile use-default Switch a browser tab back to the default profile',
' tab profile clone Clone a browser tab into another profile',
' tab switch Switch the active browser tab by --index or --page',
' tab close Close a browser tab by --index/--page or the current tab',
' snapshot Accessibility snapshot with element refs (e.g. @e1, @e2)',
' goto Navigate the active tab to --url',
' click Click element by --element ref',
' fill Clear and fill input by --element ref with --value',
' type Type --input text at the current focus (no element needed)',
' select Select dropdown option by --element ref and --value',
' hover Hover element by --element ref',
' keypress Press a key (e.g. --key Enter, --key Tab)',
' scroll Scroll --direction (up/down) by --amount pixels',
' back Navigate back in browser history',
' reload Reload the active browser tab',
' screenshot Capture viewport screenshot (--format png|jpeg)',
' eval Evaluate --expression JavaScript in the page context',
' wait Wait for page idle or --timeout ms',
' check Check a checkbox by --element ref'
].join('\n')
+183
View File
@@ -0,0 +1,183 @@
export const ROOT_HELP_TEXT_SECONDARY = [
' uncheck Uncheck a checkbox by --element ref',
' focus Focus an element by --element ref',
' clear Clear an input by --element ref',
' drag Drag --from ref to --to ref',
' upload Upload --files to a file input by --element ref',
' dblclick Double-click element by --element ref',
' forward Navigate forward in browser history',
' scrollintoview Scroll --element into view',
' get Get element property (--what: text, html, value, url, title)',
' is Check element state (--what: visible, enabled, checked)',
' inserttext Insert text without key events',
' mouse move Move mouse to --x --y coordinates',
' mouse down Press mouse button',
' mouse up Release mouse button',
' mouse wheel Scroll wheel --dy [--dx]',
' find Find element by locator (--locator role|text|label --value <v>)',
' set device Emulate device (--name "iPhone 12")',
' set offline Toggle offline mode (--state on|off)',
' set headers Set HTTP headers (--headers \'{"key":"val"}\')',
' set credentials Set HTTP auth (--user <u> --pass <p>)',
' set media Set color scheme (--color-scheme dark|light)',
' clipboard read Read clipboard contents',
' clipboard write Write --text to clipboard',
' dialog accept Accept browser dialog (--text for prompt response)',
' dialog dismiss Dismiss browser dialog',
' storage local get Get localStorage value by --key',
' storage local set Set localStorage --key --value',
' storage local clear Clear localStorage',
' storage session get Get sessionStorage value by --key',
' storage session set Set sessionStorage --key --value',
' storage session clear Clear sessionStorage',
' download Download file via --selector to --path',
' highlight Highlight --selector on page',
' exec Run any agent-browser command (--command "...")',
'',
'Common Commands:',
' orca open [--json]',
' orca serve [--port <port>] [--pairing-address <host>] [--mobile-pairing] [--no-pairing] [--project-root <path>] [--recipe-json] [--json]',
' orca status [--json]',
' orca diagnostics memory [--json]',
' orca agent-context [--json]',
' orca account add [--agent claude|codex] [--json]',
' orca account list [--json]',
' orca host list [--json]',
' orca environment add --name <name> --pairing-code <code> [--json]',
' orca environment list [--json]',
' orca environment show --environment <selector> [--json]',
' orca environment rm --environment <selector> [--json]',
' orca worktree list [--repo <selector>] [--limit <n>] [--json]',
' orca worktree create --name <name> [--repo <selector>|--project <id> [--host <host-id>]|--project-host-setup <id>] [--agent <id>] [--prompt <text>] [--setup run|skip|inherit] [--base-branch <ref>] [--issue <number>] [--linear-issue <identifier-or-url>] [--comment <text>] [--parent-worktree <selector>] [--no-parent] [--run-hooks] [--activate] [--json]',
' orca worktree show --worktree <selector> [--json]',
' orca worktree current [--json]',
' orca worktree set --worktree <selector> [--display-name <name>] [--issue <number|null>] [--linear-issue <identifier-or-url|null>] [--comment <text>] [--workspace-status <id>] [--parent-worktree <selector>|--no-parent] [--json]',
' orca worktree rm --worktree <selector> [--force] [--run-hooks] [--json]',
' orca worktree ps [--limit <n>] [--json]',
' orca file open <path> [--worktree <selector>] [--json]',
' orca file diff <path> [--staged] [--worktree <selector>] [--json]',
' orca file open-changed [--mode edit|diff|both] [--worktree <selector>] [--json]',
' orca terminal list [--worktree <selector>] [--limit <n>] [--include-visual-layouts] [--json]',
' orca terminal show [--terminal <handle>] [--json]',
' orca terminal read [--terminal <handle>] [--cursor <n>] [--limit <n>] [--json]',
' orca terminal send [--terminal <handle>] [--text <text>] [--enter] [--interrupt] [--json]',
' orca terminal wait [--terminal <handle>] --for exit|tui-idle [--timeout-ms <ms>] [--json]',
' orca terminal stop --worktree <selector> [--json]',
' orca terminal create [--worktree <selector>] [--title <name>] [--command <text>] [--focus] [--json]',
' orca terminal split [--terminal <handle>] [--direction horizontal|vertical] [--json]',
' orca terminal switch [--terminal <handle>] [--json]',
' orca terminal close [--terminal <handle>] [--tab] [--json]',
' orca project list [--json]',
' orca project setups [--project <id>] [--host <host-id>] [--json]',
' orca project setup-existing-folder --project <id> --host <host-id> --path <path> [--kind git|folder] [--display-name <name>] [--json]',
' orca project setup-clone --project <id> --host <host-id> --url <clone-url> --destination <path> [--display-name <name>] [--json]',
' orca project setup-create --project <id> --host <host-id> [--setup-id <id>] [--path <path>] [--kind git|folder] [--display-name <name>] [--worktree-base-path <path>] [--git-username <name>] [--state ready|not-set-up|setting-up|error|unsupported] [--method imported-existing-folder|cloned|provisioned] [--json]',
' orca project setup-update --setup <setup-id> [--display-name <name>] [--path <path>] [--worktree-base-path <path>] [--git-username <name>] [--kind git|folder] [--state ready|not-set-up|setting-up|error|unsupported] [--method legacy-repo|imported-existing-folder|cloned|provisioned] [--json]',
' orca project setup-delete --setup <setup-id> [--json]',
' orca repo list [--json]',
' orca repo add --path <path> [--json]',
' orca repo show --repo <selector> [--json]',
' orca repo set-base-ref --repo <selector> --ref <ref> [--json]',
' orca repo search-refs --repo <selector> --query <text> [--limit <n>] [--json]',
'',
'Selectors:',
' --repo <selector> Registered repo selector such as id:<id>, name:<name>, or path:<path>',
' --worktree <selector> Worktree selector such as identity:<identity>, id:<repo-id>::<path>, name:<displayName>, branch:<branch>, issue:<number>, path:<path>, or active/current',
' --terminal <handle> Runtime-issued terminal handle returned by `orca terminal list --json`',
' --parent-worktree <selector> Parent worktree selector such as identity:<identity>, id:<repo-id>::<path>, branch:<branch>, issue:<number>, path:<path>, or active/current',
' --no-parent Force no parent lineage for unrelated worktree creation/update',
'',
'Terminal Send Options:',
' --text <text> Text to send to the terminal',
' --enter Append Enter after sending text',
' --interrupt Send as an interrupt-style input when supported',
'',
'Terminal List Options:',
' --include-visual-layouts Include tab and pane topology in JSON output',
'',
'Wait Options:',
' --for exit Wait until the target terminal exits',
' --timeout-ms <ms> Maximum wait time before timing out',
'',
'Output Options:',
' --json Emit machine-readable JSON instead of human text',
' --pairing-code <code> Connect to a remote Orca runtime using an orca://pair?... code',
' --environment <selector> Connect using a saved environment id or name',
' --help Show this help message',
'',
'Behavior:',
' Most commands require a running Orca runtime. If Orca is not open yet, run `orca open` first.',
' Remote runtime access can also be supplied with ORCA_PAIRING_CODE or ORCA_ENVIRONMENT.',
' Use selectors for discovery and handles for repeated live terminal operations.',
'',
'Agent Sessions And Worktrees:',
' `worktree create --agent` creates a new checkout with an agent.',
' To start a fresh agent in the current worktree, use:',
' orca terminal create --worktree active --command "codex"',
'',
'Browser Workflow:',
' 1. Create or navigate: orca tab create --url https://example.com',
' orca goto --url https://example.com',
' 2. Inspect the page: orca snapshot',
' (Returns an accessibility tree with element refs like e1, e2, e3)',
' For concurrent workflows, prefer: orca tab list --json',
' then reuse tabs[].browserPageId with --page <id> on later commands.',
' 3. Interact: orca click --element e2',
' orca fill --element e5 --value "search query"',
' orca keypress --key Enter',
' 4. Re-inspect: orca snapshot',
' (Element refs change after navigation — always re-snapshot before interacting)',
'',
'Browser Options:',
' --element <ref> Element ref from snapshot (e.g. @e3)',
' --url <url> URL to navigate to',
' --value <text> Value to fill or select',
' --input <text> Text to type at current focus (no element needed)',
' --expression <js> JavaScript expression to evaluate',
' --key <key> Key to press (Enter, Tab, Escape, Control+a, etc.)',
' --direction <dir> Scroll direction: up or down',
' --amount <pixels> Scroll distance in pixels (default: viewport height)',
' --index <n> Tab index (from `tab list`)',
' --page <id> Stable browser page id (preferred for concurrent workflows)',
' --profile <id> Browser profile id',
" --show-profile Include the tab's browser profile in text output",
" --no-ua-spoof Keep Electron's native user agent for a new profile",
' --format <png|jpeg> Screenshot image format',
' --from <ref> Drag source element ref',
' --to <ref> Drag target element ref',
' --files <path,...> Comma-separated file paths for upload',
' --timeout <ms> Wait timeout in milliseconds',
" --worktree <selector> Scope commands to a specific worktree's browser tabs",
'',
'Examples:',
' $ orca open',
' $ orca status --json',
' $ orca diagnostics memory --json',
' $ orca repo list',
' $ orca worktree create --name agent-task --agent codex --prompt "hi"',
' $ orca worktree create --repo name:orca --name cli-test-1 --issue 273',
' $ orca worktree create --repo name:orca --name linear-task --linear-issue https://linear.app/stably/issue/STA-335/test-issue',
' $ orca worktree create --name linear-task --linear-issue STA-335',
' $ orca worktree show --worktree branch:Jinwoo-H/cli',
' $ orca worktree current',
' $ orca worktree set --worktree active --comment "waiting on review"',
' $ orca worktree set --worktree active --linear-issue null',
' $ orca worktree ps --limit 10',
' $ orca file open-changed --mode diff',
' $ orca file open src/App.tsx',
' $ orca terminal create --worktree active --command "codex"',
' $ orca terminal list --worktree path:/Users/me/orca/workspaces/orca/cli-test-1 --json',
' $ orca terminal send --terminal term_123 --text "hi" --enter',
' $ orca terminal wait --terminal term_123 --for exit --timeout-ms 60000 --json',
' $ orca tab current --json',
' $ orca tab show --page page_123 --json',
' $ orca tab create --url https://example.com --profile work',
' $ orca tab profile clone --page page_123 --profile work --json',
' $ orca snapshot',
' $ orca click --element e3',
' $ orca fill --element e5 --value "hello"',
' $ orca goto --url https://example.com/login',
' $ orca keypress --key Enter',
' $ orca eval --expression "document.title"',
' $ orca tab list --json'
].join('\n')
@@ -1,210 +0,0 @@
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,32 +45,6 @@ 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,
@@ -86,7 +60,6 @@ describe('browserManager', () => {
beforeEach(() => {
resetBrowserManagerMocks(browserMocks)
resetBrowserManagerState()
revokeAllDocPreviewGrants()
})
afterEach(() => {
@@ -119,77 +92,6 @@ 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),
@@ -1,250 +0,0 @@
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,5 +882,34 @@ 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
)
})
})
})
+15 -91
View File
@@ -63,11 +63,6 @@ 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,
@@ -162,15 +157,6 @@ 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[]
@@ -685,20 +671,12 @@ export class BrowserManager {
attachGuestPolicies(
guest: Electron.WebContents,
inheritedOwnerContext: PopupOwnerContext | null = null,
policy: BrowserGuestPolicy = BROWSING_GUEST_POLICY
inheritedOwnerContext: PopupOwnerContext | null = null
): 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)
}
@@ -1038,30 +1016,6 @@ 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.
@@ -1364,9 +1318,7 @@ export class BrowserManager {
rendererWebContentsId
}: BrowserGuestRegistration): boolean {
const browserTabId = browserPageId ?? legacyBrowserTabId
// 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)) {
if (!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.
@@ -1425,12 +1377,6 @@ 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')
@@ -1498,15 +1444,10 @@ export class BrowserManager {
sessionProfileId?: string | null
userAgentMode?: BrowserSessionUserAgentMode
webContentsId: number
}): 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
}
}): void {
const guest = webContents.fromId(webContentsId)
if (!guest || guest.isDestroyed()) {
return false
return
}
// Why: offscreen pages have no renderer webview listeners, so main owns their load-failure lifecycle.
this.offscreenGuestIds.add(webContentsId)
@@ -1527,7 +1468,6 @@ export class BrowserManager {
this.worktreeIdByTabId.set(browserPageId, worktreeId)
}
this.certificateTrustController?.onGuestRegistered(webContentsId, browserPageId)
return true
}
unregisterAll(): void {
@@ -1903,13 +1843,12 @@ export class BrowserManager {
async setAnnotationViewportBridge(
browserTabId: string,
options: BrowserAnnotationViewportBridgeOptions,
resolveGuest: () => Electron.WebContents | null
options: BrowserAnnotationViewportBridgeOptions
): Promise<boolean> {
const prev = this.annotationViewportBridgeOpsByTabId.get(browserTabId) ?? Promise.resolve()
const next = prev
.catch(() => {})
.then(() => this.doSetAnnotationViewportBridgeImpl(options, resolveGuest))
.then(() => this.doSetAnnotationViewportBridgeImpl(browserTabId, options))
this.annotationViewportBridgeOpsByTabId.set(browserTabId, next)
try {
return await next
@@ -1920,22 +1859,18 @@ 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(
options: BrowserAnnotationViewportBridgeOptions,
resolveGuest: () => Electron.WebContents | null
browserTabId: string,
options: BrowserAnnotationViewportBridgeOptions
): Promise<boolean> {
// 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()
const webContentsId = this.webContentsIdByTabId.get(browserTabId)
if (!webContentsId) {
return false
}
const guest = webContents.fromId(webContentsId)
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
}
@@ -2042,21 +1977,10 @@ 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
@@ -1,185 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { BrowserSessionProfile } from '../../shared/browser-workspace-types'
const mocks = vi.hoisted(() => ({
handleGuestWillDownload: vi.fn(),
noticeDocPreviewDownloadBlocked: vi.fn()
}))
type WillDownloadListener = (
event: { preventDefault: () => void },
item: { id: string },
webContents: { id: number }
) => void
type FakeSession = {
listeners: WillDownloadListener[]
on: ReturnType<typeof vi.fn>
removeListener: ReturnType<typeof vi.fn>
getUserAgent: () => string
setUserAgent: ReturnType<typeof vi.fn>
setPermissionRequestHandler: ReturnType<typeof vi.fn>
setPermissionCheckHandler: ReturnType<typeof vi.fn>
setDisplayMediaRequestHandler: ReturnType<typeof vi.fn>
}
const sessionsByPartition = new Map<string, FakeSession>()
function fakeSession(): FakeSession {
const listeners: WillDownloadListener[] = []
return {
listeners,
on: vi.fn((event: string, listener: WillDownloadListener) => {
if (event === 'will-download') {
listeners.push(listener)
}
}),
removeListener: vi.fn((event: string, listener: WillDownloadListener) => {
if (event !== 'will-download') {
return
}
const index = listeners.indexOf(listener)
if (index !== -1) {
listeners.splice(index, 1)
}
}),
getUserAgent: () => 'Mozilla/5.0 Orca',
setUserAgent: vi.fn(),
setPermissionRequestHandler: vi.fn(),
setPermissionCheckHandler: vi.fn(),
setDisplayMediaRequestHandler: vi.fn()
}
}
vi.mock('electron', () => ({
session: {
fromPartition: (partition: string) => {
const existing = sessionsByPartition.get(partition)
if (existing) {
return existing
}
const created = fakeSession()
sessionsByPartition.set(partition, created)
return created
}
}
}))
vi.mock('./browser-manager', () => ({
browserManager: {
handleGuestWillDownload: mocks.handleGuestWillDownload,
installCertificateRequestGuard: vi.fn(),
removeCertificateRequestGuard: vi.fn(),
notifyPermissionDenied: vi.fn()
}
}))
vi.mock('./doc-preview-download-block-notice', () => ({
noticeDocPreviewDownloadBlocked: mocks.noticeDocPreviewDownloadBlocked
}))
vi.mock('./browser-media-access', () => ({
hasSystemMediaAccess: () => false,
requestSystemMediaAccess: async () => false
}))
vi.mock('./browser-session-ua', () => ({
cleanElectronUserAgent: (userAgent: string) => userAgent,
setupClientHintsOverride: vi.fn()
}))
vi.mock('./browser-session-user-agent-mode', () => ({
setBrowserSessionUserAgentMode: vi.fn()
}))
vi.mock('./browser-webauthn-access', () => ({
allowsBrowserWebAuthnPermission: () => false,
clearBrowserWebAuthnAccessHandlers: vi.fn(),
installBrowserWebAuthnAccessHandlers: vi.fn()
}))
type PartitionPolicyInstaller = (
profile: BrowserSessionProfile,
options?: { downloads?: 'route' | 'deny' }
) => void
// Why imported per test rather than at the top: the installer remembers which partitions it has
// already configured in module state, so a shared import would make the second test's install a
// no-op and leave it reading the first test's listener.
async function loadInstaller(): Promise<PartitionPolicyInstaller> {
const module = await import('./browser-session-partition-policies')
return module.installBrowserSessionPartitionPolicies
}
function profileFor(partition: string): BrowserSessionProfile {
return {
id: partition,
scope: 'isolated',
partition,
label: partition,
source: null,
userAgentMode: 'clean'
}
}
/** Fires the partition's real `will-download` listener and reports what it decided. */
function fireWillDownload(partition: string): { cancelled: boolean } {
const sess = sessionsByPartition.get(partition)
if (!sess || sess.listeners.length !== 1) {
throw new Error(`expected exactly one will-download listener on ${partition}`)
}
let cancelled = false
sess.listeners[0]({ preventDefault: () => (cancelled = true) }, { id: 'item-1' }, { id: 42 })
return { cancelled }
}
beforeEach(() => {
vi.clearAllMocks()
sessionsByPartition.clear()
vi.resetModules()
})
describe('partition download policy', () => {
// The presence half: without it, a deny assertion passes for a partition that installed no
// listener at all, and would keep passing if the whole download path were removed.
it('routes a download on a partition that did not ask for the deny', async () => {
const install = await loadInstaller()
install(profileFor('persist:browsing-1'))
expect(fireWillDownload('persist:browsing-1').cancelled).toBe(false)
expect(mocks.handleGuestWillDownload).toHaveBeenCalledWith(
expect.objectContaining({ guestWebContentsId: 42 })
)
})
it('cancels a download on a partition that asked for the deny, routing nothing', async () => {
const install = await loadInstaller()
install(profileFor('orca-doc-preview'), { downloads: 'deny' })
expect(fireWillDownload('orca-doc-preview').cancelled).toBe(true)
expect(mocks.handleGuestWillDownload).not.toHaveBeenCalled()
})
// Why in the same run as the routing test above: a refusal the reader cannot see is a pressed
// button that does nothing, and a notice on the routing partition would announce a download that
// is about to arrive normally.
it('tells the reader about the refusal, and only on the partition that refused', async () => {
const install = await loadInstaller()
install(profileFor('orca-doc-preview'), { downloads: 'deny' })
install(profileFor('persist:browsing-1'))
fireWillDownload('persist:browsing-1')
expect(mocks.noticeDocPreviewDownloadBlocked).not.toHaveBeenCalled()
fireWillDownload('orca-doc-preview')
expect(mocks.noticeDocPreviewDownloadBlocked).toHaveBeenCalledWith(
expect.objectContaining({ id: 42 })
)
})
// Why both partitions in one run: the listener is module state shared across sessions, so a deny
// installed for one partition must not follow the next partition that installs after it.
it('keeps each partition on its own decision', async () => {
const install = await loadInstaller()
install(profileFor('orca-doc-preview'), { downloads: 'deny' })
install(profileFor('persist:browsing-1'))
expect(fireWillDownload('orca-doc-preview').cancelled).toBe(true)
expect(fireWillDownload('persist:browsing-1').cancelled).toBe(false)
expect(mocks.handleGuestWillDownload).toHaveBeenCalledTimes(1)
})
})
@@ -11,7 +11,6 @@ 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>()
@@ -23,23 +22,6 @@ 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
@@ -55,13 +37,7 @@ function resolvePermissionNoticeUrl(
}
}
/** `route` hands the item to the owning page's download flow; `deny` cancels it before it starts. */
export type BrowserPartitionDownloadPolicy = 'route' | 'deny'
export function installBrowserSessionPartitionPolicies(
profile: BrowserSessionProfile,
options?: { downloads?: BrowserPartitionDownloadPolicy }
): void {
export function installBrowserSessionPartitionPolicies(profile: BrowserSessionProfile): void {
const { partition } = profile
const sess = session.fromPartition(partition)
setBrowserSessionUserAgentMode(sess, profile.userAgentMode ?? 'clean')
@@ -130,11 +106,7 @@ export function installBrowserSessionPartitionPolicies(
callback({ video: undefined, audio: undefined })
})
sess.removeListener('will-download', handleWillDownload)
sess.removeListener('will-download', handleDeniedWillDownload)
sess.on(
'will-download',
options?.downloads === 'deny' ? handleDeniedWillDownload : handleWillDownload
)
sess.on('will-download', handleWillDownload)
configuredPartitions.add(partition)
}
@@ -143,7 +115,6 @@ 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)
@@ -1,107 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
publishDocPreviewFailure: vi.fn(),
boundGrantIdByGuest: new Map<object, string>()
}))
vi.mock('./doc-preview-failure-notice', () => ({
publishDocPreviewFailure: mocks.publishDocPreviewFailure
}))
vi.mock('./doc-preview-guest-policy', () => ({
readDocPreviewGuestBoundGrantId: (guest: object) => mocks.boundGrantIdByGuest.get(guest) ?? null
}))
const GRANT_ID = 'a'.repeat(32)
const OTHER_GRANT_ID = 'b'.repeat(32)
/** Only the identity matters: the module asks the guest registry what grant this contents holds. */
function guestBoundTo(grantId: string | null): Electron.WebContents {
const guest = {} as Electron.WebContents
if (grantId !== null) {
mocks.boundGrantIdByGuest.set(guest, grantId)
}
return guest
}
async function loadNotifier(): Promise<(guest: Electron.WebContents) => void> {
const module = await import('./doc-preview-download-block-notice')
return module.noticeDocPreviewDownloadBlocked
}
beforeEach(() => {
vi.clearAllMocks()
mocks.boundGrantIdByGuest.clear()
// Why per test: the module remembers which grants it has already told the reader about.
vi.resetModules()
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-08-27T00:00:00.000Z'))
})
afterEach(() => {
vi.useRealTimers()
})
describe('noticeDocPreviewDownloadBlocked', () => {
it('tells the shell which preview had a download refused', async () => {
const notice = await loadNotifier()
notice(guestBoundTo(GRANT_ID))
expect(mocks.publishDocPreviewFailure).toHaveBeenCalledWith({
grantId: GRANT_ID,
reason: 'download-blocked'
})
})
// Why: a document can ask in a loop. The reader learns nothing from the second notice, and every
// attempt would otherwise cross the IPC boundary and re-render the strip.
it('says it once however often the document asks', async () => {
const notice = await loadNotifier()
const guest = guestBoundTo(GRANT_ID)
notice(guest)
notice(guest)
vi.advanceTimersByTime(1_500)
notice(guest)
expect(mocks.publishDocPreviewFailure).toHaveBeenCalledTimes(1)
})
it('says it again for an attempt long after the last one', async () => {
const notice = await loadNotifier()
const guest = guestBoundTo(GRANT_ID)
notice(guest)
vi.advanceTimersByTime(2_500)
notice(guest)
expect(mocks.publishDocPreviewFailure).toHaveBeenCalledTimes(2)
})
// Why not one throttle for the whole app: two previews are two readers, and silencing the second
// because the first just refused something leaves that press unexplained.
it('throttles each preview on its own', async () => {
const notice = await loadNotifier()
notice(guestBoundTo(GRANT_ID))
notice(guestBoundTo(OTHER_GRANT_ID))
expect(mocks.publishDocPreviewFailure).toHaveBeenCalledTimes(2)
expect(mocks.publishDocPreviewFailure).toHaveBeenLastCalledWith({
grantId: OTHER_GRANT_ID,
reason: 'download-blocked'
})
})
// The absence half of the first test: no shell is showing this contents, so there is no preview
// to put a notice on. Without the presence tests above, this would pass on a module that never
// published anything at all.
it('says nothing for a contents no preview is bound to', async () => {
const notice = await loadNotifier()
notice(guestBoundTo(null))
expect(mocks.publishDocPreviewFailure).not.toHaveBeenCalled()
})
})
@@ -1,34 +0,0 @@
import { publishDocPreviewFailure } from './doc-preview-failure-notice'
import { readDocPreviewGuestBoundGrantId } from './doc-preview-guest-policy'
/**
* Why the reader is told at all, when the point of the fence is that nothing happens: a press that
* produces no file and no explanation reads as Orca being broken. The notice is chrome the document
* cannot see or read back, so the refusal stays as silent to the page as it was.
*
* Why a floor between notices: a document can ask in a loop, and every attempt would otherwise
* cross the IPC boundary and re-render the strip. The reader learns nothing from the thousandth.
*/
const NOTICE_MIN_INTERVAL_MS = 2_000
const noticedAtByGrantId = new Map<string, number>()
export function noticeDocPreviewDownloadBlocked(guest: Electron.WebContents): void {
const grantId = readDocPreviewGuestBoundGrantId(guest)
// Nothing to route a notice to: no shell is showing this contents as a preview.
if (grantId === null) {
return
}
const now = Date.now()
// Doubles as the expiry check, so what survives is exactly "noticed inside the window", and a
// grant that stops downloading stops being remembered.
for (const [noticedGrantId, noticedAt] of noticedAtByGrantId) {
if (now - noticedAt >= NOTICE_MIN_INTERVAL_MS) {
noticedAtByGrantId.delete(noticedGrantId)
}
}
if (noticedAtByGrantId.has(grantId)) {
return
}
noticedAtByGrantId.set(grantId, now)
publishDocPreviewFailure({ grantId, reason: 'download-blocked' })
}
@@ -1,70 +0,0 @@
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)
})
})
@@ -1,34 +0,0 @@
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
}
}
@@ -1,333 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
callRuntimeEnvironment: vi.fn(),
readFile: vi.fn(),
realpath: vi.fn(),
requireSshFilesystemProvider: vi.fn()
}))
vi.mock('../ipc/runtime-environment-transport-routing', () => ({
callRuntimeEnvironment: mocks.callRuntimeEnvironment
}))
vi.mock('../persistence', () => ({ getCanonicalUserDataPath: () => '/user-data' }))
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
requireSshFilesystemProvider: mocks.requireSshFilesystemProvider
}))
import { FileReadCapExceededError } from '../ssh/ssh-filesystem-stream-reader'
import { docPreviewContentType, readDocPreviewFile } from './doc-preview-file-reader'
import { mintDocPreviewGrant, revokeAllDocPreviewGrants } from './doc-preview-grant-registry'
function sshGrant(): ReturnType<typeof mintDocPreviewGrant> {
return mintDocPreviewGrant({
owner: { kind: 'ssh', connectionId: 'ssh-1' },
root: '/home/alice/docs',
entryRelativePath: 'index.html',
browserPageId: 'page-1'
})
}
function runtimeGrant(root = '/srv/repo/docs'): ReturnType<typeof mintDocPreviewGrant> {
return mintDocPreviewGrant({
owner: {
kind: 'runtime',
environmentId: 'env-1',
worktreeSelector: 'id:wt-1',
worktreeRoot: '/srv/repo'
},
root,
entryRelativePath: 'index.html',
browserPageId: 'page-1'
})
}
beforeEach(() => {
vi.clearAllMocks()
revokeAllDocPreviewGrants()
// Why: an unsymlinked host canonicalizes to the path it was given.
mocks.realpath.mockImplementation((path: string) => Promise.resolve(path))
mocks.requireSshFilesystemProvider.mockReturnValue({
readFile: mocks.readFile,
realpath: mocks.realpath
})
})
describe('docPreviewContentType', () => {
it('maps document and asset extensions, defaulting to octet-stream', () => {
expect(docPreviewContentType('index.html')).toBe('text/html; charset=utf-8')
expect(docPreviewContentType('assets/app.CSS')).toBe('text/css; charset=utf-8')
expect(docPreviewContentType('assets/logo.png')).toBe('image/png')
expect(docPreviewContentType('data.bin')).toBe('application/octet-stream')
})
})
describe('readDocPreviewFile — ssh owner', () => {
it('reads text through the SSH filesystem provider', async () => {
mocks.readFile.mockResolvedValue({ content: '<h1>hi</h1>', isBinary: false })
const outcome = await readDocPreviewFile(sshGrant(), 'index.html')
expect(mocks.requireSshFilesystemProvider).toHaveBeenCalledWith('ssh-1')
expect(mocks.readFile).toHaveBeenCalledWith('/home/alice/docs/index.html')
expect(outcome).toEqual({
ok: true,
bytes: Buffer.from('<h1>hi</h1>', 'utf8'),
contentType: 'text/html; charset=utf-8'
})
})
it('decodes a base64 binary asset', async () => {
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47])
mocks.readFile.mockResolvedValue({ content: png.toString('base64'), isBinary: true })
const outcome = await readDocPreviewFile(sshGrant(), 'assets/logo.png')
expect(outcome).toEqual({ ok: true, bytes: png, contentType: 'image/png' })
})
// Why: the SSH reader rejects an over-cap file rather than clamping it, so a completed read is
// always whole and needs no truncation flag.
it('serves a whole SSH read that carries no truncation flag', async () => {
mocks.readFile.mockResolvedValue({ content: '<h1>whole</h1>', isBinary: false })
expect(await readDocPreviewFile(sshGrant(), 'index.html')).toMatchObject({ ok: true })
})
// Why: the SSH read path only serves images and PDFs as bytes, so a font is refused there by
// design — the failure must name the file type, not a stale server.
it('reports a file type the host will not send as unsupported-asset', async () => {
mocks.readFile.mockResolvedValue({ content: '', isBinary: true })
const outcome = await readDocPreviewFile(sshGrant(), 'assets/font.woff2')
expect(outcome).toMatchObject({ ok: false, status: 415, reason: 'unsupported-asset' })
})
// Why: a host that still named the type read a 0-byte file, so 0 bytes is the honest answer —
// reporting it as a refused format would be a failure the workspace never reported.
it('serves an empty file the host still typed instead of calling it unsupported', async () => {
mocks.readFile.mockResolvedValue({ content: '', isBinary: true, mimeType: 'image/png' })
const outcome = await readDocPreviewFile(sshGrant(), 'assets/logo.png')
expect(outcome).toEqual({ ok: true, bytes: Buffer.alloc(0), contentType: 'image/png' })
})
// Why: containment above is lexical, and the SSH read RPC enforces no root of its own, so a
// symlink inside the grant would otherwise read anything the account can reach.
it('404s a path that canonicalizes outside the grant root', async () => {
mocks.realpath.mockImplementation((path: string) =>
Promise.resolve(path === '/home/alice/docs/escape.html' ? '/etc/shadow' : path)
)
const outcome = await readDocPreviewFile(sshGrant(), 'escape.html')
expect(outcome).toMatchObject({ ok: false, status: 404 })
expect(mocks.readFile).not.toHaveBeenCalled()
})
it('reads the canonical path once containment holds', async () => {
mocks.realpath.mockImplementation((path: string) =>
Promise.resolve(path === '/home/alice/docs/link.html' ? '/home/alice/docs/real.html' : path)
)
mocks.readFile.mockResolvedValue({ content: '<h1>real</h1>', isBinary: false })
expect(await readDocPreviewFile(sshGrant(), 'link.html')).toMatchObject({ ok: true })
expect(mocks.readFile).toHaveBeenCalledWith('/home/alice/docs/real.html')
})
// Why: a symlinked root is legitimate; containment must be judged on what both sides resolve to.
it('keeps serving a grant whose own root is a symlink', async () => {
mocks.realpath.mockImplementation((path: string) =>
Promise.resolve(path.replace('/home/alice/docs', '/mnt/data/docs'))
)
mocks.readFile.mockResolvedValue({ content: '<h1>hi</h1>', isBinary: false })
expect(await readDocPreviewFile(sshGrant(), 'index.html')).toMatchObject({ ok: true })
expect(mocks.readFile).toHaveBeenCalledWith('/mnt/data/docs/index.html')
})
it('404s when the host cannot canonicalize the path at all', async () => {
mocks.realpath.mockRejectedValue(new Error('no such file'))
expect(await readDocPreviewFile(sshGrant(), 'index.html')).toMatchObject({
ok: false,
status: 404
})
expect(mocks.readFile).not.toHaveBeenCalled()
})
it('404s a path outside the grant root without touching the provider', async () => {
const outcome = await readDocPreviewFile(sshGrant(), '../../etc/passwd')
expect(outcome).toMatchObject({ ok: false, status: 404 })
expect(mocks.requireSshFilesystemProvider).not.toHaveBeenCalled()
})
it('reports an over-cap SSH file as too large rather than unreadable', async () => {
mocks.readFile.mockRejectedValue(new FileReadCapExceededError('exceeds client cap'))
expect(await readDocPreviewFile(sshGrant(), 'huge.html')).toMatchObject({
ok: false,
status: 413
})
})
it('404s when the provider read fails', async () => {
mocks.readFile.mockRejectedValue(new Error('no such file'))
expect(await readDocPreviewFile(sshGrant(), 'missing.html')).toMatchObject({
ok: false,
status: 404
})
})
})
describe('readDocPreviewFile — paired runtime owner', () => {
it('reads text over worktree-relative files.read', async () => {
mocks.callRuntimeEnvironment.mockResolvedValue({
ok: true,
result: { content: '<h1>remote</h1>', truncated: false, byteLength: 15 }
})
const outcome = await readDocPreviewFile(runtimeGrant(), 'index.html')
expect(mocks.callRuntimeEnvironment).toHaveBeenCalledWith(
'/user-data',
'env-1',
'files.read',
{ worktree: 'id:wt-1', relativePath: 'docs/index.html' },
15_000
)
expect(outcome).toEqual({
ok: true,
bytes: Buffer.from('<h1>remote</h1>', 'utf8'),
contentType: 'text/html; charset=utf-8'
})
})
it('falls back to the base64 preview RPC for a binary asset', async () => {
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47])
mocks.callRuntimeEnvironment
.mockResolvedValueOnce({
ok: false,
error: { code: 'runtime_error', message: 'binary_file' }
})
.mockResolvedValueOnce({
ok: true,
result: {
content: png.toString('base64'),
isBinary: true,
isImage: true,
mimeType: 'image/png'
}
})
const outcome = await readDocPreviewFile(runtimeGrant(), 'assets/logo.png')
expect(mocks.callRuntimeEnvironment).toHaveBeenNthCalledWith(
2,
'/user-data',
'env-1',
'files.readPreview',
{ worktree: 'id:wt-1', relativePath: 'docs/assets/logo.png' },
15_000
)
expect(outcome).toEqual({ ok: true, bytes: png, contentType: 'image/png' })
})
it('degrades on an old server whose empty binary preview carries no metadata', async () => {
mocks.callRuntimeEnvironment
.mockResolvedValueOnce({
ok: false,
error: { code: 'runtime_error', message: 'binary_file' }
})
.mockResolvedValueOnce({ ok: true, result: { content: '', isBinary: true } })
expect(await readDocPreviewFile(runtimeGrant(), 'assets/logo.png')).toMatchObject({
ok: false,
status: 415,
reason: 'unsupported-asset'
})
})
it('serves an empty paired asset the host typed rather than reporting a refusal', async () => {
mocks.callRuntimeEnvironment
.mockResolvedValueOnce({
ok: false,
error: { code: 'runtime_error', message: 'binary_file' }
})
.mockResolvedValueOnce({
ok: true,
result: { content: '', isBinary: true, isImage: true, mimeType: 'image/png' }
})
expect(await readDocPreviewFile(runtimeGrant(), 'assets/logo.png')).toEqual({
ok: true,
bytes: Buffer.alloc(0),
contentType: 'image/png'
})
})
// Why: files.read clamps text at the host cap and only says so in `truncated`; serving the
// clamped bytes renders a document that silently stops halfway.
it('refuses a truncated text read instead of serving the clamped bytes', async () => {
mocks.callRuntimeEnvironment.mockResolvedValue({
ok: true,
result: { content: '<h1>half of', truncated: true, byteLength: 40_000_000 }
})
const outcome = await readDocPreviewFile(runtimeGrant(), 'index.html')
expect(outcome).toMatchObject({ ok: false, status: 413 })
expect(outcome).not.toMatchObject({ ok: true })
})
it('serves a read the host reports as complete', async () => {
mocks.callRuntimeEnvironment.mockResolvedValue({
ok: true,
result: { content: '<h1>all</h1>', truncated: false, byteLength: 12 }
})
expect(await readDocPreviewFile(runtimeGrant(), 'index.html')).toMatchObject({ ok: true })
})
// Why: the binary RPC has no `truncated` field — it rejects an over-cap asset with this error.
it('reports the host rejecting an over-cap binary as too large', async () => {
mocks.callRuntimeEnvironment
.mockResolvedValueOnce({
ok: false,
error: { code: 'runtime_error', message: 'binary_file' }
})
.mockResolvedValueOnce({
ok: false,
error: { code: 'runtime_error', message: 'file_too_large' }
})
expect(await readDocPreviewFile(runtimeGrant(), 'assets/huge.png')).toMatchObject({
ok: false,
status: 413
})
})
it('does not treat an unrelated RPC failure as a binary fallback', async () => {
mocks.callRuntimeEnvironment.mockResolvedValue({
ok: false,
error: { code: 'runtime_error', message: 'permission_denied' }
})
expect(await readDocPreviewFile(runtimeGrant(), 'index.html')).toMatchObject({
ok: false,
status: 404
})
expect(mocks.callRuntimeEnvironment).toHaveBeenCalledOnce()
})
it('404s a document outside the worktree, which files.read cannot address', async () => {
const outcome = await readDocPreviewFile(runtimeGrant('/tmp/agent-docs'), 'index.html')
expect(outcome).toMatchObject({ ok: false, status: 404 })
expect(mocks.callRuntimeEnvironment).not.toHaveBeenCalled()
})
})
-198
View File
@@ -1,198 +0,0 @@
import { extname } from 'node:path'
import type {
RuntimeFilePreviewResult,
RuntimeFileReadResult
} from '../../shared/runtime-file-contracts'
import type { DocPreviewFileFailureReason } from '../../shared/doc-preview-scheme'
import { callRuntimeEnvironment } from '../ipc/runtime-environment-transport-routing'
import { FileReadCapExceededError } from '../ssh/ssh-filesystem-stream-reader'
import { getCanonicalUserDataPath } from '../persistence'
import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
import {
resolveCanonicalDocPreviewPath,
resolveDocPreviewTargetPath,
toRuntimeWorktreeRelativePath,
type DocPreviewGrant
} from './doc-preview-grant-registry'
const DOC_PREVIEW_READ_TIMEOUT_MS = 15_000
/** Why not "needs a newer server": the SSH read path only ever serves images and PDFs as bytes, so
* a font is refused there by design, not by version. Name the file type, not the host's age. */
const UNSERVABLE_ASSET_PREVIEW_MESSAGE = 'This workspace cannot send this file type to a preview.'
/** `files.read` clamps text at the host's cap and reports it; serving the clamped bytes would
* render a silently half-finished document. */
const TRUNCATED_PREVIEW_MESSAGE = 'This document is too large for the server to send in full.'
/** The paired host rejects an over-cap asset outright instead of clamping it. */
const RUNTIME_TOO_LARGE_ERROR = 'file_too_large'
/** Both owners refuse an over-cap file; only their error shapes differ. */
function isTooLargeReadError(error: unknown): boolean {
return (
error instanceof FileReadCapExceededError ||
(error instanceof Error && error.message === RUNTIME_TOO_LARGE_ERROR)
)
}
export type DocPreviewReadOutcome =
| { ok: true; bytes: Buffer; contentType: string }
| { ok: false; status: number; reason: DocPreviewFileFailureReason; message: string }
const DOC_PREVIEW_CONTENT_TYPES: Record<string, string> = {
'.html': 'text/html; charset=utf-8',
'.htm': 'text/html; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.txt': 'text/plain; charset=utf-8',
'.md': 'text/plain; charset=utf-8',
'.csv': 'text/plain; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.bmp': 'image/bmp',
'.ico': 'image/x-icon',
'.pdf': 'application/pdf',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.ttf': 'font/ttf',
'.otf': 'font/otf'
}
export function docPreviewContentType(relativePath: string): string {
return (
DOC_PREVIEW_CONTENT_TYPES[extname(relativePath).toLowerCase()] ?? 'application/octet-stream'
)
}
type PreviewFileBytes = {
content: string
isBinary: boolean
truncated?: boolean
/** Set by every owner that agreed to serve the bytes, so it also survives a 0-byte file. */
mimeType?: string
}
function toOutcome(source: PreviewFileBytes, contentType: string): DocPreviewReadOutcome {
if (source.truncated) {
return { ok: false, status: 413, reason: 'too-large', message: TRUNCATED_PREVIEW_MESSAGE }
}
if (!source.isBinary) {
return { ok: true, bytes: Buffer.from(source.content, 'utf8'), contentType }
}
if (source.content) {
return { ok: true, bytes: Buffer.from(source.content, 'base64'), contentType }
}
// Why: an empty binary body is two different answers. A host that still named the file's type
// read a 0-byte file, and 0 bytes is what it should serve; a host that named no type declined
// the format outright and has nothing to send.
return source.mimeType
? { ok: true, bytes: Buffer.alloc(0), contentType }
: {
ok: false,
status: 415,
reason: 'unsupported-asset',
message: UNSERVABLE_ASSET_PREVIEW_MESSAGE
}
}
async function readRuntimeDocPreviewFile(
environmentId: string,
worktreeSelector: string,
relativePath: string
): Promise<PreviewFileBytes> {
const userDataPath = getCanonicalUserDataPath()
const response = await callRuntimeEnvironment(
userDataPath,
environmentId,
'files.read',
{ worktree: worktreeSelector, relativePath },
DOC_PREVIEW_READ_TIMEOUT_MS
)
if (response.ok) {
const result = response.result as RuntimeFileReadResult
return { content: result.content, isBinary: false, truncated: result.truncated === true }
}
// Why: files.read rejects binaries with a typed error; the base64 preview RPC serves
// images and fonts the same way it does for markdown previews. Match the exact
// message so an unrelated failure can't spoof the fallback.
if (response.error.message !== 'binary_file') {
throw new Error(response.error.message)
}
const previewResponse = await callRuntimeEnvironment(
userDataPath,
environmentId,
'files.readPreview',
{ worktree: worktreeSelector, relativePath },
DOC_PREVIEW_READ_TIMEOUT_MS
)
if (!previewResponse.ok) {
throw new Error(previewResponse.error.message)
}
const preview = previewResponse.result as RuntimeFilePreviewResult
// Why: readPreview never clamps — it rejects an over-cap asset — so its body is whole or absent.
return {
content: preview.content,
isBinary: preview.isBinary,
...(preview.mimeType ? { mimeType: preview.mimeType } : {})
}
}
function notFoundOutcome(message = 'Not found'): DocPreviewReadOutcome {
return { ok: false, status: 404, reason: 'unreadable', message }
}
/** Reads one in-grant path over the same channel the editor uses for that owner. */
export async function readDocPreviewFile(
grant: DocPreviewGrant,
relativePath: string
): Promise<DocPreviewReadOutcome> {
const absolutePath = resolveDocPreviewTargetPath(grant, relativePath)
if (!absolutePath) {
return notFoundOutcome()
}
const contentType = docPreviewContentType(relativePath)
try {
if (grant.owner.kind === 'ssh') {
const provider = requireSshFilesystemProvider(grant.owner.connectionId)
// Why: the SSH read RPC enforces no root of its own, so containment has to survive a symlink
// before the read — the lexical check above only proves the requested path looked contained.
const canonicalPath = await resolveCanonicalDocPreviewPath(grant, absolutePath, (path) =>
provider.realpath(path)
)
if (!canonicalPath) {
return notFoundOutcome()
}
// Why: the SSH reader rejects an over-cap file outright, so its result is never partial.
return toOutcome(await provider.readFile(canonicalPath), contentType)
}
const worktreeRelativePath = toRuntimeWorktreeRelativePath(
grant.owner.worktreeRoot,
absolutePath
)
if (!worktreeRelativePath) {
// Why: files.read is worktree-scoped, so a doc outside the worktree has no client-side channel.
return notFoundOutcome()
}
// Why no realpath pass here: the host resolves this path through resolveAuthorizedPath, which
// canonicalizes and re-checks the worktree root server-side before reading.
return toOutcome(
await readRuntimeDocPreviewFile(
grant.owner.environmentId,
grant.owner.worktreeSelector,
worktreeRelativePath
),
contentType
)
} catch (error) {
return isTooLargeReadError(error)
? { ok: false, status: 413, reason: 'too-large', message: TRUNCATED_PREVIEW_MESSAGE }
: notFoundOutcome(error instanceof Error ? error.message : undefined)
}
}
@@ -1,164 +0,0 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
getDocPreviewGrant,
mintDocPreviewGrant,
resolveCanonicalDocPreviewPath,
resolveDocPreviewTargetPath,
revokeAllDocPreviewGrants,
revokeDocPreviewGrant,
toRuntimeWorktreeRelativePath,
type DocPreviewGrant
} from './doc-preview-grant-registry'
const sshOwner = { kind: 'ssh', connectionId: 'ssh-1' } as const
function mintPosixGrant(root = '/srv/repo/docs'): DocPreviewGrant {
return mintDocPreviewGrant({
owner: sshOwner,
root,
entryRelativePath: 'index.html',
browserPageId: 'page-1'
})
}
beforeEach(() => {
revokeAllDocPreviewGrants()
})
describe('doc preview grants', () => {
it('mints unguessable ids and looks them up', () => {
const first = mintPosixGrant()
const second = mintPosixGrant()
expect(first.id).toMatch(/^[0-9a-f]{32}$/)
expect(first.id).not.toBe(second.id)
expect(getDocPreviewGrant(first.id)).toBe(first)
})
it('returns nothing for an unknown or revoked grant', () => {
const grant = mintPosixGrant()
expect(getDocPreviewGrant('0'.repeat(32))).toBeNull()
expect(revokeDocPreviewGrant(grant.id)).toBe(true)
expect(getDocPreviewGrant(grant.id)).toBeNull()
expect(revokeDocPreviewGrant(grant.id)).toBe(false)
})
})
describe('resolveDocPreviewTargetPath', () => {
it('resolves paths inside the grant root', () => {
const grant = mintPosixGrant()
expect(resolveDocPreviewTargetPath(grant, 'index.html')).toBe('/srv/repo/docs/index.html')
expect(resolveDocPreviewTargetPath(grant, 'assets/logo.png')).toBe(
'/srv/repo/docs/assets/logo.png'
)
})
it('refuses parent traversal, absolute escapes and empty paths', () => {
const grant = mintPosixGrant()
expect(resolveDocPreviewTargetPath(grant, '../secret.env')).toBeNull()
expect(resolveDocPreviewTargetPath(grant, 'assets/../../secret.env')).toBeNull()
expect(resolveDocPreviewTargetPath(grant, '..')).toBeNull()
expect(resolveDocPreviewTargetPath(grant, '')).toBeNull()
expect(resolveDocPreviewTargetPath(grant, 'a//b')).toBeNull()
})
it('refuses backslash and NUL segments that could re-split on the owning host', () => {
const grant = mintPosixGrant()
expect(resolveDocPreviewTargetPath(grant, '..\\secret.env')).toBeNull()
expect(resolveDocPreviewTargetPath(grant, 'index.html\0.png')).toBeNull()
})
// Why this is a traversal test and not a containment test: every request path that names a
// sibling directory has to climb out of the root first, so the `..` segment guard answers it
// before the prefix check runs. Sibling containment is exercised where it is reachable —
// against a canonicalized path, below.
it('refuses a sibling directory by refusing the traversal that reaches it', () => {
const grant = mintPosixGrant()
expect(resolveDocPreviewTargetPath(grant, '../docs-private/secret.html')).toBeNull()
})
it('keeps a Windows drive root addressable instead of turning it drive-relative', () => {
const grant = mintDocPreviewGrant({
owner: sshOwner,
root: 'C:\\',
entryRelativePath: 'index.html',
browserPageId: 'page-1'
})
expect(resolveDocPreviewTargetPath(grant, 'index.html')).toBe('C:\\index.html')
})
it('follows the owning host path flavor rather than this process platform', () => {
const windowsGrant = mintDocPreviewGrant({
owner: sshOwner,
root: 'C:\\srv\\repo\\docs',
entryRelativePath: 'index.html',
browserPageId: 'page-1'
})
expect(resolveDocPreviewTargetPath(windowsGrant, 'assets/logo.png')).toBe(
'C:\\srv\\repo\\docs\\assets\\logo.png'
)
expect(resolveDocPreviewTargetPath(windowsGrant, '../secret.env')).toBeNull()
})
it('normalizes a trailing separator on the root', () => {
const grant = mintDocPreviewGrant({
owner: sshOwner,
root: '/srv/repo/docs/',
entryRelativePath: 'index.html',
browserPageId: 'page-1'
})
expect(resolveDocPreviewTargetPath(grant, 'index.html')).toBe('/srv/repo/docs/index.html')
expect(resolveDocPreviewTargetPath(grant, '../secret.env')).toBeNull()
})
})
describe('resolveCanonicalDocPreviewPath', () => {
// Why here and not above: a canonical path is the one input that can name a sibling directory
// without traversing — the host resolved a symlink to it — so this is where the prefix check
// is the only thing standing between the grant and `/srv/repo/docs-private`.
it('refuses a canonical path in a sibling directory that shares the root prefix', async () => {
const grant = mintPosixGrant()
await expect(
resolveCanonicalDocPreviewPath(grant, '/srv/repo/docs/report.html', async (path) =>
path === grant.root ? path : '/srv/repo/docs-private/secret.html'
)
).resolves.toBeNull()
})
it('answers the canonical path when it stays inside the canonical root', async () => {
const grant = mintPosixGrant()
await expect(
resolveCanonicalDocPreviewPath(grant, '/srv/repo/docs/report.html', async (path) => path)
).resolves.toBe('/srv/repo/docs/report.html')
})
})
describe('toRuntimeWorktreeRelativePath', () => {
it('produces a worktree-relative path for files inside the worktree', () => {
expect(toRuntimeWorktreeRelativePath('/srv/repo', '/srv/repo/docs/index.html')).toBe(
'docs/index.html'
)
})
it('rejects paths outside the worktree, which files.read cannot address', () => {
expect(toRuntimeWorktreeRelativePath('/srv/repo', '/tmp/agent/report.html')).toBeNull()
expect(toRuntimeWorktreeRelativePath('/srv/repo', '/srv/repo')).toBeNull()
})
it('uses Windows semantics for a Windows worktree root', () => {
expect(toRuntimeWorktreeRelativePath('C:\\srv\\repo', 'C:\\srv\\repo\\docs\\index.html')).toBe(
'docs/index.html'
)
expect(toRuntimeWorktreeRelativePath('C:\\srv\\repo', 'D:\\other\\index.html')).toBeNull()
})
})
@@ -1,208 +0,0 @@
import { randomBytes } from 'node:crypto'
import { posix, win32 } from 'node:path'
import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path'
/**
* A preview grant is the only authority that turns an `orca-preview://` request
* into bytes: it names the host that owns the file and the single directory
* subtree requests may resolve inside. No grant, no bytes.
*/
export type DocPreviewOwner =
| { kind: 'ssh'; connectionId: string }
| {
kind: 'runtime'
environmentId: string
/** Selector the runtime resolves `files.read` against. */
worktreeSelector: string
/** Worktree root on the runtime host; `files.read` only accepts paths inside it. */
worktreeRoot: string
}
export type DocPreviewGrant = {
id: string
owner: DocPreviewOwner
/** Containing directory of the opened document, on the owning host. */
root: string
/** Path of the opened document relative to `root`. */
entryRelativePath: string
/**
* Browser page the reader opened this document in. Main registers the guest under it once the
* guest commits to the grant, so the surface a tool names is the page the reader is looking at
* and not the grant, which a re-mint replaces underneath the same page.
*/
browserPageId: string
}
const grantsById = new Map<string, DocPreviewGrant>()
function pathFlavorFor(root: string): typeof posix | typeof win32 {
return isWindowsAbsolutePathLike(root) ? win32 : posix
}
function normalizeRootPath(root: string): string {
const flavor = pathFlavorFor(root)
const normalized =
flavor === win32 ? flavor.normalize(root.replace(/\//g, '\\')) : flavor.normalize(root)
// Why: `C:\` is the whole root, and trimming its separator would make win32.join answer the
// drive-relative `C:x`, which resolves against the host's cwd instead of inside the grant.
if (flavor === win32 && /^[a-zA-Z]:\\$/.test(normalized)) {
return normalized
}
// Why: a trailing separator would make the containment prefix check accept a sibling directory.
return normalized.length > 1 && normalized.endsWith(flavor.sep)
? normalized.slice(0, -1)
: normalized
}
export function mintDocPreviewGrant(params: {
owner: DocPreviewOwner
root: string
entryRelativePath: string
browserPageId: string
}): DocPreviewGrant {
const grant: DocPreviewGrant = {
id: randomBytes(16).toString('hex'),
owner: params.owner,
root: normalizeRootPath(params.root),
entryRelativePath: params.entryRelativePath.replace(/\\/g, '/'),
browserPageId: params.browserPageId
}
grantsById.set(grant.id, grant)
return grant
}
export function getDocPreviewGrant(grantId: string): DocPreviewGrant | null {
return grantsById.get(grantId) ?? null
}
/**
* Why anything listens at all: a grant is the only thing that names a preview's lifetime. State
* elsewhere in main keyed by a preview's tool target — grab intent, a queued grab chain — has no
* other signal telling it the surface is gone, and would otherwise accrete one entry per grant
* for the life of the process.
*/
const revocationListeners = new Set<(grant: DocPreviewGrant) => void>()
/** Why the whole grant and not its id: it is already gone from the registry when listeners run. */
export function onDocPreviewGrantRevoked(listener: (grant: DocPreviewGrant) => void): () => void {
revocationListeners.add(listener)
return () => revocationListeners.delete(listener)
}
function notifyRevoked(grant: DocPreviewGrant): void {
for (const listener of revocationListeners) {
listener(grant)
}
}
export function revokeDocPreviewGrant(grantId: string): boolean {
canonicalRootByGrantId.delete(grantId)
const grant = grantsById.get(grantId)
if (!grant) {
return false
}
grantsById.delete(grantId)
notifyRevoked(grant)
return true
}
export function revokeAllDocPreviewGrants(): void {
canonicalRootByGrantId.clear()
const revoked = [...grantsById.values()]
grantsById.clear()
for (const grant of revoked) {
notifyRevoked(grant)
}
}
function hasUnsafeSegment(segments: string[]): boolean {
return segments.some(
(segment) =>
segment.length === 0 ||
segment === '.' ||
segment === '..' ||
segment.includes('\0') ||
segment.includes('\\')
)
}
/**
* Resolves a request path to an absolute path on the owning host, or null when
* it would escape the grant's root. Path flavor follows the root (the owning
* host may be Windows while this client is not), never `process.platform`.
*/
export function resolveDocPreviewTargetPath(
grant: DocPreviewGrant,
relativePath: string
): string | null {
const segments = relativePath.split('/').filter((segment, index, all) => {
// Why: keep empty segments visible to the safety check except a single trailing one from `dir/`.
return !(segment === '' && index === all.length - 1)
})
if (segments.length === 0 || hasUnsafeSegment(segments)) {
return null
}
const flavor = pathFlavorFor(grant.root)
const resolved = flavor.normalize(flavor.join(grant.root, ...segments))
return isInsideRoot(grant.root, resolved, flavor) ? resolved : null
}
function isInsideRoot(
root: string,
candidate: string,
flavor: typeof posix | typeof win32
): boolean {
const rootPrefix = root.endsWith(flavor.sep) ? root : `${root}${flavor.sep}`
return candidate.startsWith(rootPrefix)
}
/** Why: realpath is a host round-trip, and a grant's root is fixed for its lifetime. */
const canonicalRootByGrantId = new Map<string, Promise<string>>()
/**
* Second containment pass for hosts where the lexical one is not enough: a symlink
* inside the root can point anywhere, and the SSH read RPC applies no root of its
* own. Both sides are canonicalized on the owning host before the prefix re-check;
* a host that cannot canonicalize a path answers nothing.
*/
export async function resolveCanonicalDocPreviewPath(
grant: DocPreviewGrant,
absolutePath: string,
realpath: (path: string) => Promise<string>
): Promise<string | null> {
try {
let canonicalRoot = canonicalRootByGrantId.get(grant.id)
if (!canonicalRoot) {
canonicalRoot = realpath(grant.root).then(normalizeRootPath)
canonicalRootByGrantId.set(grant.id, canonicalRoot)
}
const [root, canonicalPath] = await Promise.all([canonicalRoot, realpath(absolutePath)])
const flavor = pathFlavorFor(root)
return isInsideRoot(root, canonicalPath, flavor) ? canonicalPath : null
} catch {
// Why: a root that no longer canonicalizes must not fall back to the lexical answer.
canonicalRootByGrantId.delete(grant.id)
return null
}
}
/**
* Path a runtime `files.read` can address, i.e. relative to the worktree root.
* Returns null when the grant root sits outside the worktree — the runtime file
* RPCs are worktree-scoped, so those documents are unreadable client-side.
*/
export function toRuntimeWorktreeRelativePath(
worktreeRoot: string,
absolutePath: string
): string | null {
const flavor = pathFlavorFor(worktreeRoot)
const normalizedRoot = normalizeRootPath(worktreeRoot)
const relative = flavor.relative(normalizedRoot, absolutePath)
if (!relative || relative === '..' || relative.startsWith(`..${flavor.sep}`)) {
return null
}
if (flavor === win32 && /^[a-zA-Z]:/.test(relative)) {
return null
}
return relative.replace(/\\/g, '/')
}
@@ -1,550 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
getWorkspaceDocPageGuest,
installDocPreviewGuestPolicy,
readDocPreviewGuestBoundGrantId,
reportDocPreviewLinkClick
} from './doc-preview-guest-policy'
import { buildDocPreviewUrl } from '../../shared/doc-preview-scheme'
import {
mintDocPreviewGrant,
revokeAllDocPreviewGrants,
revokeDocPreviewGrant
} from './doc-preview-grant-registry'
type GuestHandlers = Record<string, (...args: never[]) => void>
const HOST_RENDERER_ID = 42
function installOnFakeGuest(
hostId: number = HOST_RENDERER_ID,
/** What the guest is already showing when the embedder hands it over, as a real one usually is. */
initialUrl = ''
): {
contents: object
handlers: GuestHandlers
hostId: number
isFocused: ReturnType<typeof vi.fn>
send: ReturnType<typeof vi.fn>
setWebRTCIPHandlingPolicy: ReturnType<typeof vi.fn>
windowOpenHandler: (details: { url: string }) => { action: string }
/** Why without the `destroyed` event: Chromium tears the contents down before main runs it. */
markContentsDestroyed: () => void
} {
const handlers: GuestHandlers = {}
let contentsDestroyed = false
const send = vi.fn()
const isFocused = vi.fn(() => true)
const setWebRTCIPHandlingPolicy = vi.fn()
let windowOpenHandler: (details: { url: string }) => { action: string } = () => ({
action: 'deny'
})
const register = (event: string, handler: (...args: never[]) => void): void => {
handlers[event] = handler
}
const guest = {
isFocused,
isDestroyed: () => contentsDestroyed,
getURL: () => initialUrl,
on: vi.fn(register),
once: vi.fn(register),
setWindowOpenHandler: vi.fn((handler: (details: { url: string }) => { action: string }) => {
windowOpenHandler = handler
}),
setWebRTCIPHandlingPolicy
}
installDocPreviewGuestPolicy(guest as never, { id: hostId, send })
return {
contents: guest,
handlers,
hostId,
isFocused,
send,
setWebRTCIPHandlingPolicy,
windowOpenHandler: (details) => windowOpenHandler(details),
markContentsDestroyed: () => {
contentsDestroyed = true
}
}
}
type FakeGuest = ReturnType<typeof installOnFakeGuest>
function startMainFrameNavigation(guest: FakeGuest, url: string): void {
guest.handlers['did-start-navigation']?.({ url, isMainFrame: true } as never)
}
/** A guest already showing a document, which is the only state a link can be pressed in. */
function boundGuest(): { grant: ReturnType<typeof mintGrant>; guest: FakeGuest } {
const grant = mintGrant()
const guest = installOnFakeGuest()
startMainFrameNavigation(guest, buildDocPreviewUrl(grant.id, 'index.html'))
return { grant, guest }
}
function reportClick(guest: FakeGuest, url: string): void {
reportDocPreviewLinkClick(guest.contents as never, url)
}
// Why a fresh page per grant: the registry is keyed by the page now, so two grants sharing one
// would have the second silently replace the first rather than stand beside it.
let nextDocPageOrdinal = 0
function mintGrant(): ReturnType<typeof mintDocPreviewGrant> {
nextDocPageOrdinal += 1
return mintDocPreviewGrant({
owner: { kind: 'ssh', connectionId: 'ssh-1' },
root: '/home/alice/docs',
entryRelativePath: 'index.html',
browserPageId: `doc-page-${nextDocPageOrdinal}`
})
}
beforeEach(() => {
vi.clearAllMocks()
revokeAllDocPreviewGrants()
})
describe('doc preview guest policy', () => {
it('allows relative navigation within the bound grant', () => {
const { grant, guest } = boundGuest()
const preventDefault = vi.fn()
guest.handlers['will-navigate']?.(
{ preventDefault } as never,
buildDocPreviewUrl(grant.id, 'guide.html') as never
)
expect(preventDefault).not.toHaveBeenCalled()
expect(guest.send).not.toHaveBeenCalled()
})
// Why an unlatched guest is refused rather than trusted: the renderer-set src is
// browser-initiated and never reaches will-navigate, so a navigation arriving before the latch is
// one the guest started for itself.
it('blocks a navigation the guest starts before a document has bound it', () => {
const grant = mintGrant()
const guest = installOnFakeGuest()
const preventDefault = vi.fn()
guest.handlers['will-navigate']?.(
{ preventDefault } as never,
buildDocPreviewUrl(grant.id, 'index.html') as never
)
expect(preventDefault).toHaveBeenCalledOnce()
expect(guest.send).not.toHaveBeenCalled()
})
it('blocks navigation into a different live grant once bound', () => {
const { guest } = boundGuest()
const otherGrant = mintGrant()
const preventDefault = vi.fn()
guest.handlers['will-navigate']?.(
{ preventDefault } as never,
buildDocPreviewUrl(otherGrant.id, 'index.html') as never
)
expect(preventDefault).toHaveBeenCalledOnce()
})
it('blocks a revoked grant even when it matches the bound id', () => {
const { grant, guest } = boundGuest()
revokeAllDocPreviewGrants()
const preventDefault = vi.fn()
guest.handlers['will-navigate']?.(
{ preventDefault } as never,
buildDocPreviewUrl(grant.id, 'guide.html') as never
)
expect(preventDefault).toHaveBeenCalledOnce()
})
it('blocks an external navigation without offering it to the renderer', () => {
const { guest } = boundGuest()
const preventDefault = vi.fn()
guest.handlers['will-navigate']?.({ preventDefault } as never, 'https://example.com/' as never)
expect(preventDefault).toHaveBeenCalledOnce()
expect(guest.send).not.toHaveBeenCalled()
})
it('blocks a file: navigation', () => {
const { guest } = boundGuest()
const preventDefault = vi.fn()
guest.handlers['will-navigate']?.({ preventDefault } as never, 'file:///etc/passwd' as never)
expect(preventDefault).toHaveBeenCalledOnce()
expect(guest.send).not.toHaveBeenCalled()
})
it('applies the same rule to redirects', () => {
const { guest } = boundGuest()
const preventDefault = vi.fn()
guest.handlers['will-redirect']?.({ preventDefault } as never, 'https://evil.test/' as never)
expect(preventDefault).toHaveBeenCalledOnce()
expect(guest.send).not.toHaveBeenCalled()
})
it('denies every popup and routes none of them', () => {
const { guest } = boundGuest()
expect(guest.windowOpenHandler({ url: 'https://example.com/docs' })).toEqual({
action: 'deny'
})
expect(guest.windowOpenHandler({ url: 'file:///etc/passwd' })).toEqual({ action: 'deny' })
expect(guest.send).not.toHaveBeenCalled()
})
// Why: will-navigate never fires for a subframe, so an <iframe src="https://…"> would load
// off-machine even though the top frame cannot.
it('blocks a subframe navigating outside the grant, without opening a tab for it', () => {
const { guest } = boundGuest()
const preventDefault = vi.fn()
guest.handlers['will-frame-navigate']?.({
preventDefault,
url: 'https://tracker.test/pixel',
isMainFrame: false
} as never)
expect(preventDefault).toHaveBeenCalledOnce()
expect(guest.send).not.toHaveBeenCalled()
})
it('lets a subframe load an in-grant asset', () => {
const { grant, guest } = boundGuest()
const preventDefault = vi.fn()
guest.handlers['will-frame-navigate']?.({
preventDefault,
url: buildDocPreviewUrl(grant.id, 'chart.html'),
isMainFrame: false
} as never)
expect(preventDefault).not.toHaveBeenCalled()
})
// Why this group exists: the external-link route ends in a real browser tab with full network, so
// a document that can read its grant and reach that route can exfiltrate it. Only a reader's own
// press on a link may, and only the guest's preload can tell that a press was one.
describe('the trusted-click route out of the preview', () => {
// The headline. The earlier design read a recent-input timestamp, so a script that navigated
// shortly after any genuine press was routed out as if the press had asked for it. Here the
// navigation sinks route nothing at all, so when the input happened cannot matter.
it('routes nothing for a scripted location change or window.open, whenever input happened', () => {
const { guest } = boundGuest()
const preventDefault = vi.fn()
// Genuine input the guest would report, delivered immediately before the document acts.
guest.handlers['input-event']?.({} as never, { type: 'mouseDown' } as never)
guest.handlers['before-input-event']?.({} as never, { type: 'keyDown' } as never)
guest.handlers['will-navigate']?.(
{ preventDefault } as never,
'https://attacker.test/?d=secret' as never
)
const popup = guest.windowOpenHandler({ url: 'https://attacker.test/?d=secret' })
expect(preventDefault).toHaveBeenCalledOnce()
expect(popup).toEqual({ action: 'deny' })
expect(guest.send).not.toHaveBeenCalled()
// Why assert the absence of the listeners too: with them gone there is no timing a document
// could hit, rather than a window it merely failed to hit in this test.
expect(guest.handlers['input-event']).toBeUndefined()
expect(guest.handlers['before-input-event']).toBeUndefined()
})
it('routes a reported click on an external link exactly once', () => {
const { guest } = boundGuest()
reportClick(guest, 'https://example.com/docs')
expect(guest.send).toHaveBeenCalledExactlyOnceWith('docPreview:externalLink', {
url: 'https://example.com/docs'
})
})
it('drops a click reported while the guest is not the contents the reader is looking at', () => {
const { guest } = boundGuest()
guest.isFocused.mockReturnValue(false)
reportClick(guest, 'https://example.com/docs')
expect(guest.send).not.toHaveBeenCalled()
})
it('drops a click reported by a sender that is not a preview guest', () => {
const { guest } = boundGuest()
reportDocPreviewLinkClick({ isFocused: () => true } as never, 'https://example.com/docs')
expect(guest.send).not.toHaveBeenCalled()
})
it('drops a click reported by a guest that never bound a grant', () => {
mintGrant()
const guest = installOnFakeGuest()
reportClick(guest, 'https://example.com/docs')
expect(guest.send).not.toHaveBeenCalled()
})
it("drops a click once the guest's bound grant is revoked", () => {
const { guest } = boundGuest()
revokeAllDocPreviewGrants()
reportClick(guest, 'https://example.com/docs')
expect(guest.send).not.toHaveBeenCalled()
})
it.each([
'file:///etc/passwd',
'javascript:fetch("https://attacker.test")',
'orca-preview://a/b',
'/Users/alice/secrets.txt',
''
])('drops a reported click on %s, which is not the web', (url) => {
const { guest } = boundGuest()
reportClick(guest, url)
expect(guest.send).not.toHaveBeenCalled()
})
it('stops routing for a guest that has been destroyed', () => {
const { guest } = boundGuest()
guest.handlers['destroyed']?.()
reportClick(guest, 'https://example.com/docs')
expect(guest.send).not.toHaveBeenCalled()
})
})
// Why: a peer connection is UDP straight off the network stack — the response CSP and the
// session's request filter both miss it, so this is the only place it can be refused.
it('denies the guest non-proxied WebRTC UDP at attach', () => {
const guest = installOnFakeGuest()
expect(guest.setWebRTCIPHandlingPolicy).toHaveBeenCalledWith('disable_non_proxied_udp')
})
// Why: latching from a subframe would let an in-document iframe decide which grant the guest
// belongs to, and every later main-frame check would be measured against that.
it('binds the guest from the main frame only', () => {
const grant = mintGrant()
const otherGrant = mintGrant()
const guest = installOnFakeGuest()
guest.handlers['did-start-navigation']?.({
url: buildDocPreviewUrl(otherGrant.id, 'index.html'),
isMainFrame: false
} as never)
startMainFrameNavigation(guest, buildDocPreviewUrl(grant.id, 'index.html'))
const preventDefault = vi.fn()
guest.handlers['will-navigate']?.(
{ preventDefault } as never,
buildDocPreviewUrl(otherGrant.id, 'index.html') as never
)
expect(preventDefault).toHaveBeenCalledOnce()
})
describe('tool authorization', () => {
it('answers the hosting renderer for a guest bound to a live grant', () => {
const { grant, guest } = boundGuest()
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID)).toBe(guest.contents)
})
it('refuses a renderer that does not host the preview', () => {
const { grant } = boundGuest()
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID + 1)).toBeNull()
})
// Why this is the ordinary case and not an edge one: the embedder hands the guest over after
// it has already started loading its src, so the navigation that binds most previews to their
// grant happens before any listener here exists. Missing it leaves the tools unable to name
// the guest for as long as the reader stays on the page they opened.
it('binds a guest that is already showing its document when the policy installs', () => {
const grant = mintGrant()
const guest = installOnFakeGuest(HOST_RENDERER_ID, buildDocPreviewUrl(grant.id, 'index.html'))
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID)).toBe(guest.contents)
})
// The same miss, one event later: the load started before the listener and commits after it.
it('binds a guest whose first navigation only reaches the commit', () => {
const grant = mintGrant()
const guest = installOnFakeGuest()
guest.handlers['did-navigate']?.(
{} as never,
buildDocPreviewUrl(grant.id, 'index.html') as never
)
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID)).toBe(guest.contents)
})
// Why: the src commit is what proves this guest is showing that grant. Before it, the id names
// a document nothing has been asked to render.
it('refuses a guest that has not committed a document yet', () => {
const grant = mintGrant()
installOnFakeGuest()
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID)).toBeNull()
})
// Why: revoking is how a closed tab withdraws its preview, and it happens while the guest is
// still being torn down — a tool must stop answering at revoke, not at destroy.
it('stops answering once the grant is revoked', () => {
const { grant } = boundGuest()
revokeDocPreviewGrant(grant.id)
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID)).toBeNull()
})
it('drops the guest when it is destroyed', () => {
const { grant, guest } = boundGuest()
guest.handlers['destroyed']?.()
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID)).toBeNull()
})
// Why this is separate from the destroy event: the contents die before main runs that listener,
// so in that window the registration still looks live and only this check refuses it.
it('refuses a guest whose contents died before the destroy event arrived', () => {
const { grant, guest } = boundGuest()
guest.markContentsDestroyed()
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID)).toBeNull()
})
it('answers nothing for a grant no preview ever rendered', () => {
const grant = mintGrant()
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID)).toBeNull()
})
// Why this is what makes three latch call sites safe: install, did-start-navigation and
// did-navigate all feed the same latch, so without the no-rebind guard the second and third
// would let a later navigation move a bound guest onto another grant — and a tool asking for
// that grant would be handed a guest showing someone else's document.
it('never rebinds a bound guest onto a second grant', () => {
const { grant, guest } = boundGuest()
const other = mintGrant()
startMainFrameNavigation(guest, buildDocPreviewUrl(other.id, 'index.html'))
guest.handlers['did-navigate']?.(
{} as never,
buildDocPreviewUrl(other.id, 'index.html') as never
)
expect(getWorkspaceDocPageGuest(other.browserPageId, HOST_RENDERER_ID)).toBeNull()
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID)).toBe(guest.contents)
})
// Why the replacement is registered before the old guest's teardown runs: a re-mint attaches
// the new guest first, and Chromium runs the outgoing guest's destroyed listener afterwards. A
// teardown that deleted by page alone would unregister the guest the reader is now looking at.
it('leaves a re-minted preview registered when the guest it replaced tears down', () => {
const first = mintGrant()
const outgoing = installOnFakeGuest(
HOST_RENDERER_ID,
buildDocPreviewUrl(first.id, 'index.html')
)
const remint = mintDocPreviewGrant({
owner: { kind: 'ssh', connectionId: 'ssh-1' },
root: '/home/alice/docs',
entryRelativePath: 'index.html',
browserPageId: first.browserPageId
})
const incoming = installOnFakeGuest(
HOST_RENDERER_ID,
buildDocPreviewUrl(remint.id, 'index.html')
)
expect(getWorkspaceDocPageGuest(first.browserPageId, HOST_RENDERER_ID)).toBe(
incoming.contents
)
outgoing.handlers['destroyed']?.()
expect(getWorkspaceDocPageGuest(first.browserPageId, HOST_RENDERER_ID)).toBe(
incoming.contents
)
})
// Why the grant has to be live at the latch and not only at each later request: registering the
// page a dead grant names would put a guest that can read nothing into the registry the tool
// door answers from, under a page a live grant may later want.
it('registers nothing for a guest showing a grant that is already revoked', () => {
const grant = mintGrant()
revokeDocPreviewGrant(grant.id)
const guest = installOnFakeGuest(HOST_RENDERER_ID, buildDocPreviewUrl(grant.id, 'index.html'))
expect(getWorkspaceDocPageGuest(grant.browserPageId, HOST_RENDERER_ID)).toBeNull()
// The presence half: the same install does register when the grant is still live.
const live = mintGrant()
installOnFakeGuest(HOST_RENDERER_ID, buildDocPreviewUrl(live.id, 'index.html'))
expect(getWorkspaceDocPageGuest(live.browserPageId, HOST_RENDERER_ID)).not.toBeNull()
expect(guest.contents).toBeDefined()
})
// Why both directions: two previews open at once must not be able to drive each other's guest.
it('keeps two live previews on their own guests', () => {
const first = boundGuest()
const second = boundGuest()
expect(getWorkspaceDocPageGuest(first.grant.browserPageId, HOST_RENDERER_ID)).toBe(
first.guest.contents
)
expect(getWorkspaceDocPageGuest(second.grant.browserPageId, HOST_RENDERER_ID)).toBe(
second.guest.contents
)
})
})
// Session events name the contents and nothing else, so this is how a fence firing on the whole
// partition works out which preview — if any — the reader should be told about.
describe('naming the grant a contents is showing', () => {
it('answers the bound grant for a live preview guest', () => {
const { grant, guest } = boundGuest()
expect(readDocPreviewGuestBoundGrantId(guest.contents as never)).toBe(grant.id)
})
it('answers nothing for a contents that is not a preview guest', () => {
boundGuest()
expect(readDocPreviewGuestBoundGrantId({} as never)).toBeNull()
})
it('answers nothing for a preview guest that has committed no document', () => {
const guest = installOnFakeGuest()
expect(readDocPreviewGuestBoundGrantId(guest.contents as never)).toBeNull()
})
it('answers nothing once the guest is gone', () => {
const { guest } = boundGuest()
guest.handlers['destroyed']?.()
expect(readDocPreviewGuestBoundGrantId(guest.contents as never)).toBeNull()
})
})
})
@@ -1,247 +0,0 @@
import {
DOC_PREVIEW_EXTERNAL_LINK_CHANNEL,
parseDocPreviewUrl
} from '../../shared/doc-preview-scheme'
import { normalizeExternalBrowserUrl } from '../../shared/browser-url'
import { enforceBrowserRouteWebRtcPolicy } from './browser-route-webrtc-policy'
import { getDocPreviewGrant } from './doc-preview-grant-registry'
/** The trusted renderer hosting the preview: the sink for link reports and the only sender allowed to drive tools on it. */
type PreviewHostRenderer = {
id: number
send: (channel: string, payload: { url: string }) => void
}
type PreviewGuestRegistration = {
host: PreviewHostRenderer
readBoundGrantId: () => string | null
isFocused: () => boolean
}
/**
* Why a registry: the click report arrives on an IPC channel, where the only thing main holds is
* the sender. Without this, "is this a live preview guest, and which grant is it bound to" has no
* answer, and any WebContents that learned the channel name would be routed out.
*/
const previewGuests = new WeakMap<object, PreviewGuestRegistration>()
/**
* The workspace-document half of the browser page registry, keyed by the page the reader opened
* the document in. It is deliberately a separate map from the browsing one: page management,
* agent commands, download routing and certificate attribution all read that map directly, in
* more places than a guard could be remembered in, so a document guest is simply not in it. The
* one door taught about both is `browserManager.getAuthorizedGuest`, because acting on the guest
* the reader is looking at is the single operation that legitimately spans them.
*/
const docGuestsByPageId = new Map<
string,
{ guest: Electron.WebContents; hostId: number; grantId: string }
>()
const registrationListeners = new Set<(browserPageId: string) => void>()
/** Why anything listens: a tool request can beat the guest's own attach, and the wait it parks in lives in the IPC layer. */
export function onWorkspaceDocGuestRegistered(
listener: (browserPageId: string) => void
): () => void {
registrationListeners.add(listener)
return () => registrationListeners.delete(listener)
}
/**
* Resolve the document guest a browser tool should act on. Grants the guest nothing — it answers
* only whether the trusted renderer asking is the one hosting a live, grant-bound preview on that
* page. Reached only through `browserManager.getAuthorizedGuest`.
*/
export function getWorkspaceDocPageGuest(
browserPageId: string,
senderWebContentsId: number
): Electron.WebContents | null {
const registration = docGuestsByPageId.get(browserPageId)
if (!registration || registration.hostId !== senderWebContentsId) {
return null
}
// Why: revocation is how a closed tab withdraws its preview, and it happens before the guest is torn down.
if (!getDocPreviewGrant(registration.grantId)) {
return null
}
if (registration.guest.isDestroyed()) {
docGuestsByPageId.delete(browserPageId)
return null
}
return registration.guest
}
/** True when the page renders a workspace document, so the browsing registry must never hold it. */
export function isWorkspaceDocPageId(browserPageId: string): boolean {
return docGuestsByPageId.has(browserPageId)
}
/**
* The grant a live preview guest is bound to, or null for any other WebContents — the same identity
* question `reportDocPreviewLinkClick` asks, for a session event that hands over only the contents.
*/
export function readDocPreviewGuestBoundGrantId(guest: Electron.WebContents): string | null {
return previewGuests.get(guest)?.readBoundGrantId() ?? null
}
/**
* The preview guest renders a workspace document, not the web: it may only move within its own
* grant, and nothing it does by itself leaves the preview. The one route out is
* `reportDocPreviewLinkClick`, which answers for a click the reader really made.
*
* Reached only through `browserManager.attachGuestPolicies` under the workspace-doc profile, so
* every guest in the app is policy-attached by one door whatever it renders. Returns the disposal
* that door stores: without it a retired guest keeps its listeners and stays answerable through the
* grant-keyed authority until the WebContents itself is collected.
*/
export function installDocPreviewGuestPolicy(
guest: Electron.WebContents,
host: PreviewHostRenderer
): () => void {
// Why: the first commit is the renderer-set src, already admitted by will-attach-webview;
// latching it pins every later navigation to that one grant.
let boundGrantId: string | null = null
let boundPageId: string | null = null
const latchGrantFromUrl = (rawUrl: string): void => {
if (boundGrantId !== null) {
return
}
const grantId = parseDocPreviewUrl(rawUrl)?.grantId ?? null
// Why the grant must still be live: registering under the page a dead grant names would put a
// guest nothing can read into the registry the tool door answers from.
const grant = grantId === null ? null : getDocPreviewGrant(grantId)
if (!grant) {
return
}
boundGrantId = grant.id
boundPageId = grant.browserPageId
docGuestsByPageId.set(boundPageId, { guest, hostId: host.id, grantId: grant.id })
for (const listener of registrationListeners) {
listener(boundPageId)
}
}
previewGuests.set(guest, {
host,
readBoundGrantId: () => boundGrantId,
isFocused: () => guest.isFocused()
})
const forgetGuest = (): void => {
previewGuests.delete(guest)
// Why the identity check: a re-mint registers the replacement under the same page before this
// guest's own teardown runs, and deleting by key alone would unregister the live one.
if (boundPageId !== null && docGuestsByPageId.get(boundPageId)?.guest === guest) {
docGuestsByPageId.delete(boundPageId)
}
}
guest.once('destroyed', forgetGuest)
const isAllowedPreviewNavigation = (rawUrl: string): boolean => {
const target = parseDocPreviewUrl(rawUrl)
if (!target || !getDocPreviewGrant(target.grantId)) {
return false
}
// Why the latch is required and not just consistent: the renderer-set src is browser-initiated,
// so will-navigate never fires for it. Anything reaching here before the latch is the guest
// moving itself, which no grant has admitted yet.
return boundGrantId !== null && target.grantId === boundGrantId
}
/**
* Why deny-only, with nothing routed: a navigation the guest starts cannot be attributed to the
* reader. The document may read its whole grant over `connect-src 'self'`, so routing an
* unattributable URL to a real browser tab with full network is how those bytes would get out.
*/
const navigationGuard = (event: Electron.Event, url: string): void => {
if (isAllowedPreviewNavigation(url)) {
return
}
event.preventDefault()
}
// Why here and not only on navigation events: the guest is already loading its src by the time
// the embedder hands it to us, so the only navigation most previews ever make has already
// started. Latching what it is on now is what binds the usual preview to its grant at all.
latchGrantFromUrl(guest.getURL())
const latchFromMainFrameNavigation = (details: { isMainFrame: boolean; url: string }): void => {
// Why: only the top document defines which grant this guest belongs to. Latching from a
// subframe would let an in-document iframe rebind the guest to another grant.
if (!details.isMainFrame) {
return
}
latchGrantFromUrl(details.url)
}
// Why a committed URL too: a navigation that started before this listener existed still commits
// after it, and a preview that never navigates again would otherwise stay bound to nothing.
const latchFromCommittedUrl = (_event: Electron.Event, url: string): void =>
latchGrantFromUrl(url)
// Why: will-navigate never fires for a subframe, so without this an <iframe src="https://…">
// inside a previewed document would load off-machine even though the top frame cannot.
const frameNavigationGuard = (details: {
isMainFrame: boolean
url: string
preventDefault: () => void
}): void => {
if (details.isMainFrame || isAllowedPreviewNavigation(details.url)) {
return
}
details.preventDefault()
}
guest.on('did-start-navigation', latchFromMainFrameNavigation)
guest.on('did-navigate', latchFromCommittedUrl)
guest.on('will-navigate', navigationGuard)
guest.on('will-redirect', navigationGuard)
guest.on('will-frame-navigate', frameNavigationGuard)
// Why deny with nothing routed: previews own no native child windows, and a popup the document
// asked for is the document asking, not the reader. A link the reader presses is intercepted
// before Chromium ever considers a popup.
guest.setWindowOpenHandler(() => ({ action: 'deny' }))
// Why a second fence for one API: WebRTC opens UDP straight from the network stack, so neither the
// response CSP nor the session's request filter ever sees it. This is the only layer that can.
enforceBrowserRouteWebRtcPolicy(guest, () => {})
return () => {
forgetGuest()
if (guest.isDestroyed()) {
return
}
guest.off('destroyed', forgetGuest)
guest.off('did-start-navigation', latchFromMainFrameNavigation)
guest.off('did-navigate', latchFromCommittedUrl)
guest.off('will-navigate', navigationGuard)
guest.off('will-redirect', navigationGuard)
guest.off('will-frame-navigate', frameNavigationGuard)
}
}
function isWebUrl(url: string): boolean {
return url.startsWith('http://') || url.startsWith('https://')
}
/**
* The only way a URL leaves a preview. Every condition is load-bearing: the sender must be a live
* preview guest still bound to a grant, it must be the contents the reader is looking at, and the
* target must be the web. Anything else is dropped without a trace the document could observe.
*/
export function reportDocPreviewLinkClick(sender: Electron.WebContents, rawUrl: string): void {
const registration = previewGuests.get(sender)
if (!registration) {
return
}
const boundGrantId = registration.readBoundGrantId()
if (boundGrantId === null || !getDocPreviewGrant(boundGrantId)) {
return
}
// Why focus and not just the preload's trusted-click check: that check runs inside the guest, so
// it holds only while the guest renderer does. Focus is the half main can verify for itself.
if (!registration.isFocused()) {
return
}
const externalUrl = normalizeExternalBrowserUrl(rawUrl)
if (!externalUrl || !isWebUrl(externalUrl)) {
return
}
registration.host.send(DOC_PREVIEW_EXTERNAL_LINK_CHANNEL, { url: externalUrl })
}
@@ -1,336 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
readDocPreviewFile: vi.fn(),
installBrowserSessionPartitionPolicies: vi.fn()
}))
function createFakeSession(): {
protocol: { isProtocolHandled: () => boolean; handle: ReturnType<typeof vi.fn> }
webRequest: { onBeforeRequest: ReturnType<typeof vi.fn> }
} {
return {
protocol: { isProtocolHandled: () => false, handle: vi.fn() },
webRequest: { onBeforeRequest: vi.fn() }
}
}
// Why one session per partition and a distinct default: installing the handler on the default
// session instead of the preview session is otherwise invisible — every read would come back
// from the same object.
const previewSession = createFakeSession()
const defaultSession = createFakeSession()
vi.mock('electron', () => ({
protocol: { registerSchemesAsPrivileged: vi.fn() },
session: {
get defaultSession() {
return defaultSession
},
fromPartition: (partition: string) => {
if (partition !== 'orca-doc-preview') {
throw new Error(`unexpected partition ${partition}`)
}
return previewSession
}
}
}))
vi.mock('./doc-preview-file-reader', () => ({ readDocPreviewFile: mocks.readDocPreviewFile }))
vi.mock('./browser-session-partition-policies', () => ({
installBrowserSessionPartitionPolicies: mocks.installBrowserSessionPartitionPolicies
}))
import { protocol } from 'electron'
import {
getDocPreviewSession,
handleDocPreviewRequest,
installDocPreviewProtocolHandler,
isAllowedDocPreviewRequestUrl,
isDocPreviewSession,
registerDocPreviewSchemePrivileges
} from './doc-preview-protocol'
import {
mintDocPreviewGrant,
revokeAllDocPreviewGrants,
revokeDocPreviewGrant
} from './doc-preview-grant-registry'
import {
buildDocPreviewUrl,
DOC_PREVIEW_LOAD_FAILURE_CHANNEL
} from '../../shared/doc-preview-scheme'
import { setDocPreviewFailureSink } from './doc-preview-failure-notice'
function mintGrant(): ReturnType<typeof mintDocPreviewGrant> {
return mintDocPreviewGrant({
owner: { kind: 'ssh', connectionId: 'ssh-1' },
root: '/home/alice/docs',
entryRelativePath: 'index.html',
browserPageId: 'page-1'
})
}
beforeEach(() => {
vi.clearAllMocks()
revokeAllDocPreviewGrants()
setDocPreviewFailureSink(null)
mocks.readDocPreviewFile.mockResolvedValue({
ok: true,
bytes: Buffer.from('<h1>hi</h1>', 'utf8'),
contentType: 'text/html; charset=utf-8'
})
})
describe('handleDocPreviewRequest', () => {
it('serves an in-grant document with no-store so reload re-reads the workspace', async () => {
const grant = mintGrant()
const response = await handleDocPreviewRequest(
new Request(buildDocPreviewUrl(grant.id, 'index.html'))
)
expect(response.status).toBe(200)
expect(response.headers.get('Content-Type')).toBe('text/html; charset=utf-8')
expect(response.headers.get('Cache-Control')).toBe('no-store')
expect(await response.text()).toBe('<h1>hi</h1>')
expect(mocks.readDocPreviewFile).toHaveBeenCalledWith(grant, 'index.html')
})
it('falls back to the granted entry document for a root request', async () => {
const grant = mintGrant()
await handleDocPreviewRequest(new Request(`orca-preview://${grant.id}/`))
expect(mocks.readDocPreviewFile).toHaveBeenCalledWith(grant, 'index.html')
})
it('decodes percent-encoded segments before resolving', async () => {
const grant = mintGrant()
await handleDocPreviewRequest(new Request(buildDocPreviewUrl(grant.id, 'a b/c#d.html')))
expect(mocks.readDocPreviewFile).toHaveBeenCalledWith(grant, 'a b/c#d.html')
})
it('404s an unknown grant without reading anything', async () => {
const response = await handleDocPreviewRequest(
new Request(`orca-preview://${'0'.repeat(32)}/index.html`)
)
expect(response.status).toBe(404)
expect(mocks.readDocPreviewFile).not.toHaveBeenCalled()
})
it('404s a revoked grant', async () => {
const grant = mintGrant()
revokeDocPreviewGrant(grant.id)
const response = await handleDocPreviewRequest(
new Request(buildDocPreviewUrl(grant.id, 'index.html'))
)
expect(response.status).toBe(404)
expect(mocks.readDocPreviewFile).not.toHaveBeenCalled()
})
it('404s a malformed grant id', async () => {
const response = await handleDocPreviewRequest(new Request('orca-preview://not-a-grant/x.html'))
expect(response.status).toBe(404)
expect(mocks.readDocPreviewFile).not.toHaveBeenCalled()
})
it('propagates the reader status for an unservable asset', async () => {
const grant = mintGrant()
mocks.readDocPreviewFile.mockResolvedValue({
ok: false,
status: 415,
reason: 'unsupported-asset',
message: 'cannot send this file type'
})
const response = await handleDocPreviewRequest(
new Request(buildDocPreviewUrl(grant.id, 'a.woff2'))
)
expect(response.status).toBe(415)
expect(await response.text()).toBe('cannot send this file type')
})
// Why: previewed documents are agent-authored, so an outbound request is an exfiltration channel
// for everything else the grant can read.
it('serves documents under a self-only content security policy', async () => {
const grant = mintGrant()
const response = await handleDocPreviewRequest(
new Request(buildDocPreviewUrl(grant.id, 'index.html'))
)
const policy = response.headers.get('Content-Security-Policy') ?? ''
expect(policy).toContain("default-src 'self'")
expect(policy).toContain("connect-src 'self'")
expect(policy).toContain("object-src 'none'")
expect(policy).toContain("img-src 'self' data:")
expect(policy).not.toContain('https:')
// Why assert an absence: `webrtc 'block'` is the obvious directive to reach for here and this
// Chromium does not implement it — it logs "Unrecognized Content-Security-Policy directive
// 'webrtc'" and gathers candidates regardless. Adding it back would document a fence that is
// not there; the guest's IP-handling policy is what actually refuses.
expect(policy).not.toContain('webrtc')
})
// Why: the guest paints a 4xx body as if it were the document, so the shell only learns the
// reason from this push.
it('pushes the failure reason for the requested path', async () => {
const send = vi.fn()
setDocPreviewFailureSink({ send })
const grant = mintGrant()
mocks.readDocPreviewFile.mockResolvedValue({
ok: false,
status: 413,
reason: 'too-large',
message: 'too large'
})
await handleDocPreviewRequest(new Request(buildDocPreviewUrl(grant.id, 'index.html')))
expect(send).toHaveBeenCalledWith(DOC_PREVIEW_LOAD_FAILURE_CHANNEL, {
grantId: grant.id,
relativePath: 'index.html',
reason: 'too-large'
})
})
it('pushes nothing when the document is served', async () => {
const send = vi.fn()
setDocPreviewFailureSink({ send })
const grant = mintGrant()
await handleDocPreviewRequest(new Request(buildDocPreviewUrl(grant.id, 'index.html')))
expect(send).not.toHaveBeenCalled()
})
// Why: a grant revoked underneath a live guest 404s like a missing file, and without this push
// the shell leaves the handler's "Not found" body on screen as if it were the document.
it('pushes a failure for a revoked grant so the shell can replace the 404 body', async () => {
const send = vi.fn()
const grant = mintGrant()
revokeDocPreviewGrant(grant.id)
setDocPreviewFailureSink({ send })
await handleDocPreviewRequest(new Request(buildDocPreviewUrl(grant.id, 'index.html')))
expect(send).toHaveBeenCalledWith(DOC_PREVIEW_LOAD_FAILURE_CHANNEL, {
grantId: grant.id,
relativePath: 'index.html',
reason: 'unreadable'
})
})
})
describe('installDocPreviewProtocolHandler', () => {
// Why the default session is named here: it is the session every other Electron API reaches for
// by default, and handling the scheme there would serve preview bytes to ordinary browsing.
it('handles the scheme on the preview session and nowhere else', () => {
installDocPreviewProtocolHandler()
expect(previewSession.protocol.handle).toHaveBeenCalledWith(
'orca-preview',
handleDocPreviewRequest
)
expect(previewSession.webRequest.onBeforeRequest).toHaveBeenCalled()
expect(defaultSession.protocol.handle).not.toHaveBeenCalled()
expect(defaultSession.webRequest.onBeforeRequest).not.toHaveBeenCalled()
})
it('cancels every request the preview session should never carry', () => {
installDocPreviewProtocolHandler()
const filter = previewSession.webRequest.onBeforeRequest.mock.calls.at(-1)?.[0] as (
details: { url: string },
callback: (response: { cancel: boolean }) => void
) => void
const cancelled = (url: string): boolean => {
let response: { cancel: boolean } | null = null
filter({ url }, (value) => {
response = value
})
return (response as { cancel: boolean } | null)?.cancel === true
}
expect(cancelled('https://cdn.example.com/tracker.js')).toBe(true)
expect(cancelled(`orca-preview://${'a'.repeat(32)}/index.html`)).toBe(false)
})
// Why: preview guests are webviews like any other, so they must not skip the deny-by-default
// permission and display-media policy every browser partition gets.
it('applies the shared browser partition policies to the preview session', () => {
installDocPreviewProtocolHandler()
expect(mocks.installBrowserSessionPartitionPolicies).toHaveBeenCalledWith(
expect.objectContaining({ partition: 'orca-doc-preview', userAgentMode: 'clean' }),
expect.anything()
)
})
// Why downloads and nothing else: the browser download flow attributes a file to the page that
// asked for it, and a previewed document is no page. Routed, it would write remote-authored bytes
// into this desktop's Downloads folder with no prompt and no tab to name as the source.
it('asks for downloads to be denied on the preview partition', () => {
installDocPreviewProtocolHandler()
expect(mocks.installBrowserSessionPartitionPolicies).toHaveBeenCalledWith(expect.anything(), {
downloads: 'deny'
})
})
})
describe('registerDocPreviewSchemePrivileges', () => {
// Why the whole literal and not a subset: every privilege this scheme does not claim is one the
// document cannot use to escape it. `allowServiceWorkers` would outlive the tab that was granted
// the read, and `bypassCSP` would undo the self-only policy the handler serves with.
it('claims exactly the privileges the preview document needs', () => {
registerDocPreviewSchemePrivileges()
expect(protocol.registerSchemesAsPrivileged).toHaveBeenCalledWith([
{
scheme: 'orca-preview',
privileges: {
standard: true,
secure: true,
supportFetchAPI: true,
corsEnabled: true,
stream: true
}
}
])
})
})
describe('isAllowedDocPreviewRequestUrl', () => {
// Why: the session refuses to carry the request at all, so a CSP bypass in one element type
// still reaches nothing off-machine.
it('admits in-document schemes and refuses everything that leaves the machine', () => {
expect(isAllowedDocPreviewRequestUrl(`orca-preview://${'a'.repeat(32)}/index.html`)).toBe(true)
expect(isAllowedDocPreviewRequestUrl('devtools://devtools/bundled/inspector.html')).toBe(true)
expect(isAllowedDocPreviewRequestUrl('data:image/png;base64,AAA')).toBe(true)
expect(isAllowedDocPreviewRequestUrl('blob:orca-preview://abc/123')).toBe(true)
expect(isAllowedDocPreviewRequestUrl('https://cdn.example.com/app.css')).toBe(false)
expect(isAllowedDocPreviewRequestUrl('http://127.0.0.1:9999/exfil')).toBe(false)
expect(isAllowedDocPreviewRequestUrl('ws://evil.example.com/socket')).toBe(false)
expect(isAllowedDocPreviewRequestUrl('file:///etc/passwd')).toBe(false)
})
})
describe('isDocPreviewSession', () => {
it('claims no session until the preview session has been materialized', () => {
expect(isDocPreviewSession({} as never)).toBe(false)
})
it('matches only the memoized preview session', () => {
const created = getDocPreviewSession()
expect(isDocPreviewSession(created)).toBe(true)
expect(isDocPreviewSession({} as never)).toBe(false)
expect(getDocPreviewSession()).toBe(created)
})
})
-144
View File
@@ -1,144 +0,0 @@
import { protocol, session } from 'electron'
import {
DOC_PREVIEW_PARTITION,
DOC_PREVIEW_SCHEME,
parseDocPreviewUrl
} from '../../shared/doc-preview-scheme'
import { installBrowserSessionPartitionPolicies } from './browser-session-partition-policies'
import { readDocPreviewFile } from './doc-preview-file-reader'
import { publishDocPreviewFailure } from './doc-preview-failure-notice'
import { getDocPreviewGrant } from './doc-preview-grant-registry'
/** Must run before `app.whenReady()`; Electron freezes the privileged scheme table at ready. */
export function registerDocPreviewSchemePrivileges(): void {
protocol.registerSchemesAsPrivileged([
{
scheme: DOC_PREVIEW_SCHEME,
privileges: {
standard: true,
secure: true,
supportFetchAPI: true,
corsEnabled: true,
stream: true
}
}
])
}
let docPreviewSession: Electron.Session | null = null
/** Non-persistent session, so preview bytes never land in a browsing profile's storage. */
export function getDocPreviewSession(): Electron.Session {
docPreviewSession ??= session.fromPartition(DOC_PREVIEW_PARTITION)
return docPreviewSession
}
/** Pure identity check: every guest attach consults it, and none should materialize a session. */
export function isDocPreviewSession(candidate: Electron.Session): boolean {
return docPreviewSession !== null && candidate === docPreviewSession
}
/**
* Product decision, not a hardening default: previewed documents are agent-authored, so any
* outbound request they can make is an exfiltration channel for whatever else the page can read.
* Self-contained documents — inline CSS/JS/SVG and in-grant assets — render in full; a CDN
* stylesheet, font, script, or analytics beacon deliberately does not load.
*/
const DOC_PREVIEW_CONTENT_SECURITY_POLICY = [
"default-src 'self'",
"script-src 'self' 'unsafe-inline'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data:",
"font-src 'self' data:",
"connect-src 'self'",
"frame-src 'self'",
"object-src 'none'"
// Why no `webrtc 'block'` here, though a peer connection is exactly the outbound channel this
// policy is meant to close: Chromium 43-era answers that directive with "Unrecognized
// Content-Security-Policy directive 'webrtc'" and gathers candidates anyway, so listing it would
// read as a fence while fencing nothing. The guest's IP-handling policy is the one that holds —
// see `installDocPreviewGuestPolicy`.
].join('; ')
function notFound(message: string): Response {
return new Response(message, {
status: 404,
headers: { 'Content-Type': 'text/plain; charset=utf-8' }
})
}
export async function handleDocPreviewRequest(request: Request): Promise<Response> {
const target = parseDocPreviewUrl(request.url)
if (!target) {
return notFound('Not found')
}
const grant = getDocPreviewGrant(target.grantId)
if (!grant) {
// Why: a revoked or unknown grant is indistinguishable from a missing file by design — but the
// shell still needs to know, or the guest paints this body where the document should be.
publishDocPreviewFailure({
grantId: target.grantId,
relativePath: target.relativePath,
reason: 'unreadable'
})
return notFound('Not found')
}
const relativePath = target.relativePath || grant.entryRelativePath
const outcome = await readDocPreviewFile(grant, relativePath)
if (!outcome.ok) {
publishDocPreviewFailure({ grantId: target.grantId, relativePath, reason: outcome.reason })
return new Response(outcome.message, {
status: outcome.status,
headers: { 'Content-Type': 'text/plain; charset=utf-8' }
})
}
return new Response(new Uint8Array(outcome.bytes), {
status: 200,
headers: {
'Content-Type': outcome.contentType,
'Content-Security-Policy': DOC_PREVIEW_CONTENT_SECURITY_POLICY,
// Why: reload must re-read the workspace disk, so nothing may be cached.
'Cache-Control': 'no-store'
}
})
}
/** `data:`/`blob:` never leave the document; every other scheme would reach off-machine. */
export function isAllowedDocPreviewRequestUrl(url: string): boolean {
return (
url.startsWith(`${DOC_PREVIEW_SCHEME}://`) ||
url.startsWith('devtools://') ||
url.startsWith('data:') ||
url.startsWith('blob:')
)
}
export function installDocPreviewProtocolHandler(): void {
const previewSession = getDocPreviewSession()
if (previewSession.protocol.isProtocolHandled(DOC_PREVIEW_SCHEME)) {
return
}
previewSession.protocol.handle(DOC_PREVIEW_SCHEME, handleDocPreviewRequest)
// Why: the response CSP is the document's own promise to obey; this is the session refusing to
// carry the request at all, so a CSP bypass in one element type still reaches nothing.
previewSession.webRequest.onBeforeRequest((details, callback) => {
callback({ cancel: !isAllowedDocPreviewRequestUrl(details.url) })
})
// Why: preview guests are webviews like any other, so they inherit the same deny-by-default
// permission, display-media and user-agent policy every browser partition gets.
installBrowserSessionPartitionPolicies(
{
id: DOC_PREVIEW_PARTITION,
scope: 'isolated',
partition: DOC_PREVIEW_PARTITION,
label: 'Document preview',
source: null,
userAgentMode: 'clean'
},
// Why downloads are the one policy that does not carry over: the browser download flow needs a
// page to attribute the file to, and a previewed document is not one. Routed here it would
// reserve a name in this desktop's Downloads folder and write remote-authored bytes into it
// with nothing in the UI naming the tab that asked, and no prompt in front of it.
{ downloads: 'deny' }
)
}
@@ -2,7 +2,7 @@ import { EventEmitter } from 'node:events'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
windows: [] as MockBrowserWindow[],
windows: [] as { webContents: MockWebContents }[],
BrowserWindow: vi.fn(),
finishLoads: true
}))
@@ -50,41 +50,6 @@ 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(() => {
@@ -108,7 +73,7 @@ describe('OffscreenBrowserBackend lifecycle', () => {
vi.useFakeTimers()
mocks.finishLoads = false
const browserManager = {
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
registerOffscreenGuest: vi.fn(),
unregisterGuest: vi.fn()
}
const backend = new OffscreenBrowserBackend(browserManager as never)
@@ -129,36 +94,9 @@ 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(registerOffscreenGuestLikeBrowserManager),
registerOffscreenGuest: vi.fn(),
unregisterGuest: vi.fn()
}
let releaseOwnerRetirement!: () => void
@@ -188,7 +126,7 @@ describe('OffscreenBrowserBackend lifecycle', () => {
it('preserves a replacement page when the old window finishes closing', async () => {
const browserManager = {
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
registerOffscreenGuest: vi.fn(),
unregisterGuest: vi.fn()
}
let releaseOwnerRetirement!: () => void
@@ -214,7 +152,7 @@ describe('OffscreenBrowserBackend lifecycle', () => {
it('retires the helper when an offscreen renderer is destroyed unexpectedly', async () => {
const browserManager = {
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
registerOffscreenGuest: vi.fn(),
unregisterGuest: vi.fn()
}
const onPageClosed = vi.fn(async () => {})
@@ -229,7 +167,7 @@ describe('OffscreenBrowserBackend lifecycle', () => {
it('cleans every helper owner during backend shutdown', async () => {
const browserManager = {
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
registerOffscreenGuest: vi.fn(),
unregisterGuest: vi.fn()
}
const onPageClosed = vi.fn(async () => {})
@@ -248,7 +186,7 @@ describe('OffscreenBrowserBackend lifecycle', () => {
it('rejects a concurrent create while shutdown is draining owned pages', async () => {
const browserManager = {
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
registerOffscreenGuest: vi.fn(),
unregisterGuest: vi.fn()
}
let releaseOwnerRetirement!: () => void
@@ -277,7 +215,7 @@ describe('OffscreenBrowserBackend lifecycle', () => {
it('joins owner retirement started by an unexpected renderer destroy', async () => {
const browserManager = {
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
registerOffscreenGuest: vi.fn(),
unregisterGuest: vi.fn()
}
let releaseOwnerRetirement!: () => void
@@ -306,7 +244,7 @@ describe('OffscreenBrowserBackend lifecycle', () => {
it('bounds concurrent helper retirements during shutdown', async () => {
const browserManager = {
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
registerOffscreenGuest: vi.fn(),
unregisterGuest: vi.fn()
}
let activeRetirements = 0
@@ -346,10 +284,7 @@ describe('OffscreenBrowserBackend lifecycle', () => {
})
it('closes the page even when daemon retirement throws', async () => {
const browserManager = {
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
unregisterGuest: vi.fn()
}
const browserManager = { registerOffscreenGuest: vi.fn(), unregisterGuest: vi.fn() }
const backend = new OffscreenBrowserBackend(browserManager as never, {
getAgentBrowserBridge: () => ({
onPageClosed: vi.fn(async () => {
+17 -27
View File
@@ -66,33 +66,9 @@ export class OffscreenBrowserBackend implements BrowserBackend {
this.windowsByPageId.set(browserPageId, win)
// 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.
// 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.
win.webContents.once('destroyed', () => {
// Explicit close removes the page first and performs awaited cleanup;
// only an unexpected destruction still owns the bridge retirement here.
@@ -104,6 +80,20 @@ 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(
+17 -198
View File
@@ -1,14 +1,10 @@
/* eslint-disable max-lines -- Why: WSL CLI status/install/remove share one state machine;
splitting the installer would separate conflict checks from the operations they guard. */
import type { CliInstallStatus } from '../../shared/cli-install-types'
import { getDefaultWslDistro } from '../wsl'
import { runWslProcess } from '../wsl/wsl-runner'
import { CliInstaller } from './cli-installer'
import {
buildManagedLegacyRemoveCommand,
buildRegistrationLockPrelude,
buildSafeRemoveCommand,
buildSafeReplaceGuard,
buildWslBridgeScript,
buildWslLauncher,
getBridgePathFromCommandPath,
@@ -18,10 +14,11 @@ import {
parseManagedLauncherTarget,
quoteShell
} from './wsl-cli-scripts'
import { buildWslCliInstallCommand } from './wsl-cli-registration-command'
import { buildWslCliStatus, readWslCliCommandFile, resolveReadyWslCliState } from './wsl-cli-status'
const MANAGED_MARKER = getWslLauncherMarker()
const BRIDGE_MANAGED_MARKER = getWslBridgeMarker()
const WSL_COMMAND_NAME = 'orca-ide'
const LEGACY_WSL_COMMAND_NAME = 'orca'
const WSL_COMMAND_TIMEOUT_MS = 10_000
@@ -60,7 +57,12 @@ export class WslCliInstaller {
}
async getStatus(): Promise<CliInstallStatus> {
const ready = await this.resolveReadyState()
const ready = await resolveReadyWslCliState({
platform: this.platform,
distro: this.distro,
getHostStatus: () => this.hostInstaller.getStatus(),
run: (distro, command) => this.run(distro, command)
})
if ('status' in ready) {
return ready.status
}
@@ -208,67 +210,13 @@ export class WslCliInstaller {
throw new Error(`Refusing to replace non-Orca command at ${status.commandPath}.`)
}
// Why: the launcher and PowerShell bridge are one registration; the
// command replacement stays a single atomic rename (never missing for a
// concurrent shell) while a bridge copy enables rollback of the pair.
await this.run(
this.distro as string,
[
// Why -eu not -euo pipefail: transported via runWslProcess's `sh -s`,
// and no pipe here needs pipefail -- dash on Ubuntu 20.04 lacks the option.
'set -eu',
`mkdir -p ${quoteShell(status.pathDirectory as string)}`,
`mkdir -p ${quoteShell(getPosixDirname(getBridgePathFromCommandPath(status.commandPath)))}`,
buildRegistrationLockPrelude(status.commandPath),
`command_tmp=${quoteShell(`${status.commandPath}.tmp`)}.$$`,
`bridge_path=${quoteShell(getBridgePathFromCommandPath(status.commandPath))}`,
`legacy_command_path=${quoteShell(
`${getPosixDirname(status.commandPath)}/${LEGACY_WSL_COMMAND_NAME}`
)}`,
'bridge_tmp="${bridge_path}.tmp.$$"',
'bridge_backup="${bridge_tmp}.backup"',
'bridge_had_original=0',
'bridge_touched=0',
'committed=0',
'rollback() {',
' result=$?',
' set +e',
' if [ "$committed" -ne 1 ]; then',
` if [ "$bridge_had_original" -eq 1 ]; then mv -f "$bridge_backup" ${quoteShell(getBridgePathFromCommandPath(status.commandPath))}; elif [ "$bridge_touched" -eq 1 ]; then rm -f ${quoteShell(getBridgePathFromCommandPath(status.commandPath))}; fi`,
' fi',
' rm -f "$command_tmp" "$bridge_tmp" "$bridge_backup"',
' exit "$result"',
'}',
'trap rollback EXIT',
buildSafeReplaceGuard(status.commandPath, MANAGED_MARKER),
buildSafeReplaceGuard(
getBridgePathFromCommandPath(status.commandPath),
BRIDGE_MANAGED_MARKER
),
`cat > "$command_tmp" <<'ORCA_WSL_CLI'`,
buildWslLauncher(status.launcherPath, getBridgePathFromCommandPath(status.commandPath)),
'ORCA_WSL_CLI',
`cat > "$bridge_tmp" <<'ORCA_WSL_BRIDGE'`,
buildWslBridgeScript(),
'ORCA_WSL_BRIDGE',
'chmod 755 "$command_tmp"',
'chmod 644 "$bridge_tmp"',
buildSafeReplaceGuard(status.commandPath, MANAGED_MARKER),
buildSafeReplaceGuard(
getBridgePathFromCommandPath(status.commandPath),
BRIDGE_MANAGED_MARKER
),
`if [ -f ${quoteShell(getBridgePathFromCommandPath(status.commandPath))} ]; then cp -p ${quoteShell(getBridgePathFromCommandPath(status.commandPath))} "$bridge_backup"; bridge_had_original=1; fi`,
`mv -f "$bridge_tmp" ${quoteShell(getBridgePathFromCommandPath(status.commandPath))}`,
'bridge_touched=1',
`mv -f "$command_tmp" ${quoteShell(status.commandPath)}`,
'committed=1',
'rm -f "$bridge_backup"',
// Why: the command was renamed to avoid GNOME Orca; remove only the
// old Orca-managed WSL wrapper after the replacement has committed.
buildManagedLegacyRemoveCommand('"$legacy_command_path"'),
'trap - EXIT'
].join('\n')
buildWslCliInstallCommand({
...status,
commandPath: status.commandPath,
launcherPath: status.launcherPath
})
)
return this.getStatus()
}
@@ -299,108 +247,15 @@ export class WslCliInstaller {
return this.getStatus()
}
private async resolveReadyState(): Promise<
| { status: CliInstallStatus }
| {
distro: string
commandPath: string
bridgePath: string
launcherPath: string
pathConfigured: boolean
}
> {
if (this.platform !== 'win32') {
return {
status: this.unsupported(
'platform_not_supported',
'WSL CLI registration is only available on Windows.'
)
}
}
if (!this.distro) {
return {
status: this.unsupported('platform_not_supported', 'No WSL distribution is available.')
}
}
const hostStatus = await this.hostInstaller.getStatus()
if (!hostStatus.launcherPath) {
return {
status: this.unsupported(
hostStatus.unsupportedReason ?? 'launcher_missing',
hostStatus.detail ?? 'The Windows Orca CLI launcher is missing.'
)
}
}
const home = (await this.run(this.distro, 'printf %s "$HOME"')).trim()
if (!home.startsWith('/')) {
return {
status: this.unsupported('launcher_missing', 'Unable to resolve the WSL home directory.')
}
}
const interopReady =
(
await this.run(
this.distro,
'{ command -v powershell.exe >/dev/null 2>&1 || [ -x /mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe ]; } && command -v wslpath >/dev/null 2>&1 && printf yes || printf no'
)
).trim() === 'yes'
if (!interopReady) {
return {
status: this.unsupported(
'launcher_missing',
'WSL Windows interop is unavailable; Orca cannot launch the Windows CLI from WSL.'
)
}
}
const pathDirectory = `${home}/.local/bin`
// Why: matches the Linux CLI rename to `orca-ide` (avoids GNOME Orca conflict).
const commandPath = `${pathDirectory}/${WSL_COMMAND_NAME}`
const pathConfigured =
(
await this.run(
this.distro,
`case ":$PATH:" in *:${quoteShell(pathDirectory)}:*) printf yes ;; *) printf no ;; esac`
)
).trim() === 'yes'
return {
distro: this.distro,
commandPath,
bridgePath: getBridgePathFromCommandPath(commandPath),
launcherPath: hostStatus.launcherPath,
pathConfigured
}
}
private async readCommandFile(
distro: string,
commandPath: string
): Promise<(string & {}) | 'not_file' | null> {
const output = await this.run(
return readWslCliCommandFile(
(targetDistro, command) => this.run(targetDistro, command),
distro,
[
`if [ -L ${quoteShell(commandPath)} ]; then`,
' printf __ORCA_NOT_FILE__',
`elif [ ! -e ${quoteShell(commandPath)} ]; then`,
' printf __ORCA_MISSING__',
`elif [ ! -f ${quoteShell(commandPath)} ]; then`,
' printf __ORCA_NOT_FILE__',
'else',
` cat ${quoteShell(commandPath)}`,
'fi'
].join('\n')
commandPath
)
if (output === '__ORCA_MISSING__') {
return null
}
if (output === '__ORCA_NOT_FILE__') {
return 'not_file'
}
return output
}
private buildStatus(args: {
@@ -412,43 +267,7 @@ export class WslCliInstaller {
pathConfigured: boolean
detail: string
}): CliInstallStatus {
return {
platform: 'linux',
commandName: WSL_COMMAND_NAME,
commandPath: args.commandPath,
pathDirectory: getPosixDirname(args.commandPath),
pathConfigured: args.pathConfigured,
launcherPath: args.launcherPath,
installMethod: 'wrapper',
supported: true,
state: args.state,
currentTarget: args.currentTarget,
unsupportedReason: null,
detail:
args.state === 'installed' && !args.pathConfigured
? `${args.commandPath} is registered, but ${getPosixDirname(args.commandPath)} is not on PATH in ${args.distro}.`
: args.detail
}
}
private unsupported(
unsupportedReason: NonNullable<CliInstallStatus['unsupportedReason']>,
detail: string
): CliInstallStatus {
return {
platform: 'linux',
commandName: WSL_COMMAND_NAME,
commandPath: null,
pathDirectory: null,
pathConfigured: false,
launcherPath: null,
installMethod: null,
supported: false,
state: 'unsupported',
currentTarget: null,
unsupportedReason,
detail
}
return buildWslCliStatus(args)
}
private async run(distro: string, command: string): Promise<string> {
@@ -0,0 +1,70 @@
import type { CliInstallStatus } from '../../shared/cli-install-types'
import {
buildManagedLegacyRemoveCommand,
buildRegistrationLockPrelude,
buildSafeReplaceGuard,
buildWslBridgeScript,
buildWslLauncher,
getBridgePathFromCommandPath,
getPosixDirname,
getWslBridgeMarker,
getWslLauncherMarker,
quoteShell
} from './wsl-cli-scripts'
const MANAGED_MARKER = getWslLauncherMarker()
const BRIDGE_MANAGED_MARKER = getWslBridgeMarker()
const LEGACY_WSL_COMMAND_NAME = 'orca'
export function buildWslCliInstallCommand(
status: CliInstallStatus & { commandPath: string; launcherPath: string }
): string {
const bridgePath = getBridgePathFromCommandPath(status.commandPath)
const legacyCommandPath = `${getPosixDirname(status.commandPath)}/${LEGACY_WSL_COMMAND_NAME}`
return [
// Why -eu not -euo pipefail: transported via runWslProcess's `sh -s`,
// and no pipe here needs pipefail -- dash on Ubuntu 20.04 lacks the option.
'set -eu',
`mkdir -p ${quoteShell(status.pathDirectory as string)}`,
`mkdir -p ${quoteShell(getPosixDirname(bridgePath))}`,
buildRegistrationLockPrelude(status.commandPath),
`command_tmp=${quoteShell(`${status.commandPath}.tmp`)}.$$`,
`bridge_path=${quoteShell(bridgePath)}`,
`legacy_command_path=${quoteShell(legacyCommandPath)}`,
'bridge_tmp="${bridge_path}.tmp.$$"',
'bridge_backup="${bridge_tmp}.backup"',
'bridge_had_original=0',
'bridge_touched=0',
'committed=0',
'rollback() {',
' result=$?',
' set +e',
' if [ "$committed" -ne 1 ]; then',
` if [ "$bridge_had_original" -eq 1 ]; then mv -f "$bridge_backup" ${quoteShell(bridgePath)}; elif [ "$bridge_touched" -eq 1 ]; then rm -f ${quoteShell(bridgePath)}; fi`,
' fi',
' rm -f "$command_tmp" "$bridge_tmp" "$bridge_backup"',
' exit "$result"',
'}',
'trap rollback EXIT',
buildSafeReplaceGuard(status.commandPath, MANAGED_MARKER),
buildSafeReplaceGuard(bridgePath, BRIDGE_MANAGED_MARKER),
`cat > "$command_tmp" <<'ORCA_WSL_CLI'`,
buildWslLauncher(status.launcherPath, bridgePath),
'ORCA_WSL_CLI',
`cat > "$bridge_tmp" <<'ORCA_WSL_BRIDGE'`,
buildWslBridgeScript(),
'ORCA_WSL_BRIDGE',
'chmod 755 "$command_tmp"',
'chmod 644 "$bridge_tmp"',
buildSafeReplaceGuard(status.commandPath, MANAGED_MARKER),
buildSafeReplaceGuard(bridgePath, BRIDGE_MANAGED_MARKER),
`if [ -f ${quoteShell(bridgePath)} ]; then cp -p ${quoteShell(bridgePath)} "$bridge_backup"; bridge_had_original=1; fi`,
`mv -f "$bridge_tmp" ${quoteShell(bridgePath)}`,
'bridge_touched=1',
`mv -f "$command_tmp" ${quoteShell(status.commandPath)}`,
'committed=1',
'rm -f "$bridge_backup"',
buildManagedLegacyRemoveCommand('"$legacy_command_path"'),
'trap - EXIT'
].join('\n')
}
+163
View File
@@ -0,0 +1,163 @@
import type { CliInstallStatus } from '../../shared/cli-install-types'
import { getBridgePathFromCommandPath, getPosixDirname, quoteShell } from './wsl-cli-scripts'
const WSL_COMMAND_NAME = 'orca-ide'
export type ReadyWslCliState = {
distro: string
commandPath: string
bridgePath: string
launcherPath: string
pathConfigured: boolean
}
export async function readWslCliCommandFile(
run: (distro: string, command: string) => Promise<string>,
distro: string,
commandPath: string
): Promise<(string & {}) | 'not_file' | null> {
const output = await run(
distro,
[
`if [ -L ${quoteShell(commandPath)} ]; then`,
' printf __ORCA_NOT_FILE__',
`elif [ ! -e ${quoteShell(commandPath)} ]; then`,
' printf __ORCA_MISSING__',
`elif [ ! -f ${quoteShell(commandPath)} ]; then`,
' printf __ORCA_NOT_FILE__',
'else',
` cat ${quoteShell(commandPath)}`,
'fi'
].join('\n')
)
if (output === '__ORCA_MISSING__') {
return null
}
if (output === '__ORCA_NOT_FILE__') {
return 'not_file'
}
return output
}
export function buildWslCliStatus(args: {
distro: string
commandPath: string
launcherPath: string
state: CliInstallStatus['state']
currentTarget: string | null
pathConfigured: boolean
detail: string
}): CliInstallStatus {
return {
platform: 'linux',
commandName: WSL_COMMAND_NAME,
commandPath: args.commandPath,
pathDirectory: getPosixDirname(args.commandPath),
pathConfigured: args.pathConfigured,
launcherPath: args.launcherPath,
installMethod: 'wrapper',
supported: true,
state: args.state,
currentTarget: args.currentTarget,
unsupportedReason: null,
detail:
args.state === 'installed' && !args.pathConfigured
? `${args.commandPath} is registered, but ${getPosixDirname(args.commandPath)} is not on PATH in ${args.distro}.`
: args.detail
}
}
export function unsupportedWslCliStatus(
unsupportedReason: NonNullable<CliInstallStatus['unsupportedReason']>,
detail: string
): CliInstallStatus {
return {
platform: 'linux',
commandName: WSL_COMMAND_NAME,
commandPath: null,
pathDirectory: null,
pathConfigured: false,
launcherPath: null,
installMethod: null,
supported: false,
state: 'unsupported',
currentTarget: null,
unsupportedReason,
detail
}
}
export async function resolveReadyWslCliState(args: {
platform: NodeJS.Platform
distro: string | null
getHostStatus: () => Promise<CliInstallStatus>
run: (distro: string, command: string) => Promise<string>
}): Promise<{ status: CliInstallStatus } | ReadyWslCliState> {
if (args.platform !== 'win32') {
return {
status: unsupportedWslCliStatus(
'platform_not_supported',
'WSL CLI registration is only available on Windows.'
)
}
}
if (!args.distro) {
return {
status: unsupportedWslCliStatus('platform_not_supported', 'No WSL distribution is available.')
}
}
const hostStatus = await args.getHostStatus()
if (!hostStatus.launcherPath) {
return {
status: unsupportedWslCliStatus(
hostStatus.unsupportedReason ?? 'launcher_missing',
hostStatus.detail ?? 'The Windows Orca CLI launcher is missing.'
)
}
}
const home = (await args.run(args.distro, 'printf %s "$HOME"')).trim()
if (!home.startsWith('/')) {
return {
status: unsupportedWslCliStatus(
'launcher_missing',
'Unable to resolve the WSL home directory.'
)
}
}
const interopReady =
(
await args.run(
args.distro,
'{ command -v powershell.exe >/dev/null 2>&1 || [ -x /mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe ]; } && command -v wslpath >/dev/null 2>&1 && printf yes || printf no'
)
).trim() === 'yes'
if (!interopReady) {
return {
status: unsupportedWslCliStatus(
'launcher_missing',
'WSL Windows interop is unavailable; Orca cannot launch the Windows CLI from WSL.'
)
}
}
const pathDirectory = `${home}/.local/bin`
const commandPath = `${pathDirectory}/${WSL_COMMAND_NAME}`
const pathConfigured =
(
await args.run(
args.distro,
`case ":$PATH:" in *:${quoteShell(pathDirectory)}:*) printf yes ;; *) printf no ;; esac`
)
).trim() === 'yes'
return {
distro: args.distro,
commandPath,
bridgePath: getBridgePathFromCommandPath(commandPath),
launcherPath: hostStatus.launcherPath,
pathConfigured
}
}
@@ -0,0 +1,152 @@
/**
* #15192, activation-order hypothesis: "the Orca unicode provider is not reached
* in production, so Hangul lays out at the wrong width."
*
* These pin the measurements that close it. Every precomposed Hangul syllable is
* two cells under xterm's Unicode 6 tables, its Unicode 11 tables, and Orca's
* provider alike, so no activation order — provider, v11 fallback, or the
* untouched v6 default — can change how a syllable is budgeted. Whatever moves
* Korean text off its cells is not the unicode version.
*
* The version-sensitive and oracle-sensitive code points are pinned too, as the
* complete list of Hangul-block characters where a width disagreement is even
* available. None of them occur in modern Korean prose.
*/
import { describe, expect, it } from 'vitest'
import { Terminal } from '@xterm/headless'
import { Unicode11Addon } from '@xterm/addon-unicode11'
import { activateOrcaTerminalUnicodeProvider } from '../../shared/terminal-unicode-provider'
import { isWideGlyph } from './__fixtures__/terminal-wide-cell-grid'
const ORCA_UNICODE_VERSION = 'orca-11-zwj'
const HANGUL_SYLLABLES_FIRST = 0xac00
const HANGUL_SYLLABLES_LAST = 0xd7a3
/** Jamo, Compatibility Jamo, Jamo Extended-A, Syllables + Extended-B, halfwidth jamo. */
const HANGUL_BLOCKS: [number, number][] = [
[0x1100, 0x11ff],
[0x3130, 0x318f],
[0xa960, 0xa97f],
[0xac00, 0xd7ff],
[0xffa0, 0xffdc]
]
function* hangulCodePoints(): Generator<number> {
for (const [first, last] of HANGUL_BLOCKS) {
for (let cp = first; cp <= last; cp += 1) {
yield cp
}
}
}
type UnicodeServiceInternals = {
versions: string[]
activeVersion: string
wcwidth(codepoint: number): number
charProperties(codepoint: number, preceding: number): number
}
/**
* Addon first, activation after — the order every call site uses. This mirrors that
* order rather than importing it, so it does not guard the call sites themselves;
* `pane-lifecycle.test.ts` covers that the activation runs and runs after the addon.
* What is unguarded, and what these cover, is that the real `_core.unicodeService`
* shape still lets the activation reach its non-fallback branch on a live terminal.
*/
function openWithUnicode11AddonLoaded(): {
terminal: Terminal
unicode: UnicodeServiceInternals
} {
const terminal = new Terminal({ cols: 40, rows: 10, allowProposedApi: true })
terminal.loadAddon(new Unicode11Addon())
activateOrcaTerminalUnicodeProvider(terminal as never)
const unicode = (
terminal as unknown as {
_core: { unicodeService: UnicodeServiceInternals }
}
)._core.unicodeService
return { terminal, unicode }
}
/** Width bits of xterm's packed char properties — what the buffer actually budgets. */
function propertyWidth(properties: number): number {
return (properties >> 1) & 3
}
/**
* Contiguous code points carrying the same detail collapse into one line. The detail
* is part of the run key so a changed width re-partitions the output instead of
* silently reusing a range that only ever asserted "these two disagree somehow".
*/
function summarize(entries: { codepoint: number; detail: string }[]): string[] {
const runs: { start: number; end: number; detail: string }[] = []
for (const { codepoint, detail } of entries) {
const last = runs.at(-1)
if (last && last.end === codepoint - 1 && last.detail === detail) {
last.end = codepoint
continue
}
runs.push({ start: codepoint, end: codepoint, detail })
}
const hex = (value: number): string => `U+${value.toString(16).toUpperCase().padStart(4, '0')}`
return runs.map(
({ start, end, detail }) =>
`${start === end ? hex(start) : `${hex(start)}..${hex(end)}`} ${detail}`
)
}
describe('Hangul cell width agreement (#15192)', () => {
it('reaches the Orca provider, not the v11 fallback, on a live terminal', () => {
const { terminal, unicode } = openWithUnicode11AddonLoaded()
expect(unicode.versions).toContain(ORCA_UNICODE_VERSION)
expect(unicode.activeVersion).toBe(ORCA_UNICODE_VERSION)
terminal.dispose()
})
it('budgets every precomposed syllable at two cells under v6, v11 and Orca', () => {
const { terminal, unicode } = openWithUnicode11AddonLoaded()
const disagreeing: { codepoint: number; detail: string }[] = []
for (const version of ['6', '11', ORCA_UNICODE_VERSION]) {
unicode.activeVersion = version
for (let cp = HANGUL_SYLLABLES_FIRST; cp <= HANGUL_SYLLABLES_LAST; cp += 1) {
const wcwidth = unicode.wcwidth(cp)
const packed = propertyWidth(unicode.charProperties(cp, 0))
if (wcwidth !== 2 || packed !== 2) {
disagreeing.push({
codepoint: cp,
detail: `v${version} wcwidth=${wcwidth} packed=${packed}`
})
}
}
}
expect(summarize(disagreeing)).toEqual([])
terminal.dispose()
})
it('records where the wide-cell oracle diverges from xterm on conjoining jamo', () => {
const { terminal, unicode } = openWithUnicode11AddonLoaded()
const divergent: { codepoint: number; detail: string }[] = []
for (const cp of hangulCodePoints()) {
const oracle = isWideGlyph(String.fromCodePoint(cp)) ? 2 : 1
const xterm = unicode.wcwidth(cp)
if (xterm !== oracle) {
divergent.push({ codepoint: cp, detail: `oracle=${oracle} xterm=${xterm}` })
}
}
// Why pinned rather than fixed: xterm treats medial/final jamo as zero-width
// combining marks, the oracle as one cell — and `isWideGlyph` returns a boolean,
// so it cannot express zero-width without changing shape. Only decomposed (NFD)
// Korean reaches them and no fixture writes NFD today, so nothing mis-asserts;
// this is the tripwire for the fixture that adds it. U+3130/U+318F are unassigned
// Compatibility Jamo edges. U+A960..U+A97C is assigned Jamo Extended-A (Old Korean
// initials) that xterm makes wide and the oracle narrow — the dangerous direction,
// but no fixture uses it. Both widths are asserted so changing either trips this.
expect(summarize(divergent)).toEqual([
'U+1160..U+11FF oracle=1 xterm=0',
'U+3130 oracle=2 xterm=1',
'U+318F oracle=2 xterm=1',
'U+A960..U+A97C oracle=1 xterm=2'
])
terminal.dispose()
})
})
@@ -50,6 +50,7 @@ vi.mock('../providers/local-pty-utils', async (importOriginal) => {
const actual = await importOriginal<typeof LocalPtyUtils>()
return {
...actual,
getNodePtySpawnHelperCandidates: () => [import.meta.filename],
resolveUnixShellPath: resolveUnixShellPathMock,
validateWorkingDirectory: validateWorkingDirectoryMock,
validateWorkingDirectoryAsync: validateWorkingDirectoryMock
@@ -10,6 +10,7 @@ import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type * as LocalPtyUtils from '../providers/local-pty-utils'
const { spawnMock, isPwshAvailableMock, resolveAgentForegroundProcessMock } = vi.hoisted(() => ({
spawnMock: vi.fn(),
isPwshAvailableMock: vi.fn(),
@@ -39,6 +40,14 @@ vi.mock('../providers/windows-powershell-executable', () => ({
getWindowsCmdPath: () => CMD_ABS
}))
vi.mock('../providers/local-pty-utils', async (importOriginal) => {
const actual = await importOriginal<typeof LocalPtyUtils>()
return {
...actual,
getNodePtySpawnHelperCandidates: () => [import.meta.filename]
}
})
vi.mock('../providers/agent-foreground-process', () => ({
resolveAgentForegroundProcessWithAvailability: async (...args: unknown[]) => {
const value = await resolveAgentForegroundProcessMock(...args)
@@ -50,6 +50,7 @@ vi.mock('../providers/local-pty-utils', async (importOriginal) => {
const actual = await importOriginal<typeof LocalPtyUtils>()
return {
...actual,
getNodePtySpawnHelperCandidates: () => [import.meta.filename],
resolveUnixShellPath: resolveUnixShellPathMock,
validateWorkingDirectory: validateWorkingDirectoryMock,
validateWorkingDirectoryAsync: validateWorkingDirectoryMock
-11
View File
@@ -319,11 +319,6 @@ import { browserCertificateTrustController, browserManager } from './browser/bro
import { RpcDispatcher } from './runtime/rpc/dispatcher'
import { OffscreenBrowserBackend } from './browser/offscreen-browser-backend'
import { initializeBrowserSessionsForApp } from './browser/browser-session-startup'
import {
installDocPreviewProtocolHandler,
registerDocPreviewSchemePrivileges
} from './browser/doc-preview-protocol'
import { registerDocPreviewGrantHandlers } from './ipc/doc-preview-grant-ipc'
import { initializeBrowserClientHostId } from './browser/browser-client-host-id'
import { setUnreadDockBadgeCount } from './dock/unread-badge'
import { AutomationService } from './automations/service'
@@ -967,9 +962,6 @@ if (hasSingleInstanceLock) {
if (shouldApplyPreReadyAppName(devInstanceIdentity)) {
app.setName(devInstanceIdentity.appName)
}
// Why: Electron freezes the privileged scheme table at ready, so the doc-preview
// scheme must be declared here or its webview loses fetch/secure-origin privileges.
registerDocPreviewSchemePrivileges()
// Why: must precede app.whenReady() so Crashpad is installed before the
// first renderer spawns; a CHECK before this point is still exit-code-only.
startCrashpadCapture()
@@ -2445,9 +2437,6 @@ void app.whenReady().then(async () => {
} catch {
console.warn('[proxy] Failed to apply network proxy settings')
}
// Why: the preview session is protocol-scoped, so the handler must exist before any preview webview attaches.
installDocPreviewProtocolHandler()
registerDocPreviewGrantHandlers()
// Why: browser sessions serve desktop webviews and runtime profile commands, so init at app startup rather than via a renderer IPC path.
initializeBrowserSessionsForApp({
orcaProfileId: activeOrcaProfile.profile.id,
-19
View File
@@ -1,6 +1,5 @@
import { ipcMain } from 'electron'
import { browserManager } from '../browser/browser-manager'
import { onDocPreviewGrantRevoked } from '../browser/doc-preview-grant-registry'
import { isTrustedBrowserRenderer } from './browser-renderer-trust'
import { waitForNextTabRegistration } from './browser-tab-registration-wait'
import type {
@@ -49,25 +48,7 @@ export function disposeGrabModeStateForPage(browserPageId: string): void {
grabModeOperationByPageId.delete(browserPageId)
}
/**
* Why previews need their own disposal path: a browser page announces its own death on
* `browser:unregisterGuest`, and a preview never does — it withdraws by revoking its grant, which
* is also what a re-mint does. Without this, every grant leaves an intent entry behind forever.
*/
let disposePreviewGrantSubscription: (() => void) | null = null
function subscribeToPreviewGrantRevocation(): void {
disposePreviewGrantSubscription?.()
disposePreviewGrantSubscription = onDocPreviewGrantRevoked((grant) => {
// Why cancel first: a grab still armed on that guest would otherwise leave the renderer's
// await hanging on a surface the reader has already closed.
browserManager.cancelGrabOp(grant.browserPageId, 'evicted')
disposeGrabModeStateForPage(grant.browserPageId)
})
}
export function registerBrowserGrabHandlers(): void {
subscribeToPreviewGrantRevocation()
ipcMain.removeHandler('browser:setGrabMode')
ipcMain.removeHandler('browser:awaitGrabSelection')
ipcMain.removeHandler('browser:cancelGrab')
+6 -20
View File
@@ -85,26 +85,12 @@ export function registerBrowserGuestViewHandlers(): void {
) {
return false
}
// Why resolve here: this is a tool acting on a guest the reader is looking at, so it answers
// for a workspace document too — and routing through the authority also pins the request to
// the renderer that owns the target, which page-id-only resolution never checked.
const resolveGuest = (): Electron.WebContents | null =>
browserManager.getAuthorizedGuest(args.browserPageId, event.sender.id)
if (!resolveGuest()) {
return false
}
// Why hand over the resolver rather than that guest: the op is serialized per tab, and the
// one it finally runs against must be the one on screen then, not the one this request saw.
return browserManager.setAnnotationViewportBridge(
args.browserPageId,
{
enabled: args.enabled,
emitViewport: args.emitViewport,
markers: args.markers,
token: args.token
},
resolveGuest
)
return browserManager.setAnnotationViewportBridge(args.browserPageId, {
enabled: args.enabled,
emitViewport: args.emitViewport,
markers: args.markers,
token: args.token
})
}
)
@@ -1,510 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as DocPreviewGuestPolicyModule from '../browser/doc-preview-guest-policy'
import type * as TabRegistrationWaitModule from './browser-tab-registration-wait'
const {
handleMock,
removeHandlerMock,
getAuthorizedGuestMock,
setGrabModeMock,
awaitGrabSelectionMock,
cancelGrabOpMock,
captureSelectionScreenshotMock,
extractHoverPayloadMock,
setAnnotationViewportBridgeMock,
openDevToolsMock,
setViewportOverrideMock,
previewAuthoritySpy,
registrationWaitSpy
} = vi.hoisted(() => ({
handleMock: vi.fn(),
removeHandlerMock: vi.fn(),
getAuthorizedGuestMock: vi.fn(),
setGrabModeMock: vi.fn().mockResolvedValue(true),
awaitGrabSelectionMock: vi.fn().mockResolvedValue({ opId: 'op', kind: 'cancelled' }),
cancelGrabOpMock: vi.fn(),
captureSelectionScreenshotMock: vi.fn().mockResolvedValue(null),
extractHoverPayloadMock: vi.fn().mockResolvedValue(null),
setAnnotationViewportBridgeMock: vi.fn().mockResolvedValue(true),
openDevToolsMock: vi.fn().mockResolvedValue(true),
setViewportOverrideMock: vi.fn().mockResolvedValue(true),
previewAuthoritySpy: vi.fn(),
registrationWaitSpy: vi.fn()
}))
vi.mock('electron', () => ({
BrowserWindow: { fromWebContents: vi.fn() },
ipcMain: { removeHandler: removeHandlerMock, handle: handleMock },
webContents: { fromId: vi.fn(() => ({ isDestroyed: () => false })) }
}))
vi.mock('../browser/browser-manager', () => ({
browserCertificateTrustController: { proceed: vi.fn(() => ({ ok: true })) },
browserManager: {
registerGuest: vi.fn(() => true),
attachGuestPolicies: vi.fn(),
unregisterGuest: vi.fn(),
getGuestWebContentsId: vi.fn(),
getWebContentsIdByTabId: vi.fn(() => new Map()),
getWorktreeIdForTab: vi.fn(),
getAuthorizedGuest: getAuthorizedGuestMock,
setGrabMode: setGrabModeMock,
awaitGrabSelection: awaitGrabSelectionMock,
cancelGrabOp: cancelGrabOpMock,
captureSelectionScreenshot: captureSelectionScreenshotMock,
extractHoverPayload: extractHoverPayloadMock,
setAnnotationViewportBridge: setAnnotationViewportBridgeMock,
openDevTools: openDevToolsMock,
setViewportOverride: setViewportOverrideMock,
cancelDownload: vi.fn()
}
}))
// Why the real policy module behind a spy: the point of these tests is which half of the page
// registry a channel reads, so a stub of it would prove nothing about what was consulted.
vi.mock('../browser/doc-preview-guest-policy', async (importOriginal) => {
const actual = await importOriginal<typeof DocPreviewGuestPolicyModule>()
return {
...actual,
getWorkspaceDocPageGuest: (browserPageId: string, senderWebContentsId: number) => {
previewAuthoritySpy(browserPageId, senderWebContentsId)
return actual.getWorkspaceDocPageGuest(browserPageId, senderWebContentsId)
}
}
})
// Why spy rather than stub: the wait is real machinery the browser-page path still depends on;
// only whether a preview target enters it is under test.
vi.mock('./browser-tab-registration-wait', async (importOriginal) => {
const actual = await importOriginal<typeof TabRegistrationWaitModule>()
return {
...actual,
waitForNextTabRegistration: (...args: Parameters<typeof actual.waitForNextTabRegistration>) => {
registrationWaitSpy(args[0])
return actual.waitForNextTabRegistration(...args)
}
}
})
import { registerBrowserHandlers } from './browser'
import { browserManager } from '../browser/browser-manager'
import {
getWorkspaceDocPageGuest,
installDocPreviewGuestPolicy
} from '../browser/doc-preview-guest-policy'
import {
mintDocPreviewGrant,
revokeAllDocPreviewGrants,
revokeDocPreviewGrant
} from '../browser/doc-preview-grant-registry'
import { buildDocPreviewUrl } from '../../shared/doc-preview-scheme'
const HOST_RENDERER_ID = 91
const OTHER_RENDERER_ID = 92
/** Mirrors browser-grab-ipc's own wait, so elapsing it here settles the same parked request. */
const GRAB_REGISTRATION_WAIT_MS = 1_000
/**
* Every `browser:*` invoke channel, split by whether it acts on a guest the reader is looking at
* (a tool) or manages browser-page, session and profile state. The split is asserted to be total,
* so a new channel cannot be added without deciding which side of the preview seam it belongs on.
*/
const TOOL_CHANNELS = [
'browser:setGrabMode',
'browser:awaitGrabSelection',
'browser:cancelGrab',
'browser:captureSelectionScreenshot',
'browser:extractHoverPayload',
'browser:setAnnotationViewportBridge'
]
const BROWSER_PAGE_CHANNELS = [
'browser:registerGuest',
'browser:prepareSshWorkspacePartition',
'browser:repairGuestRegistration',
'browser:isGuestRegistered',
'browser:unregisterGuest',
'browser:respondWebAuthnAccount',
'browser:proceedCertificate',
'browser:activeTabChanged',
'browser:openDevTools',
'browser:setViewportOverride',
'browser:publishClientPageMetadata',
'browser:cancelDownload',
'browser:session:listProfiles',
'browser:session:createProfile',
'browser:session:deleteProfile',
'browser:session:importCookies',
'browser:session:resolvePartition',
'browser:session:clearDefaultCookies',
'browser:session:importFromBrowserForClientHost',
'browser:session:clientRouteImportSources',
'browser:session:detectBrowsers',
'browser:session:detectBrowsersForClientHost',
'browser:session:importFromBrowser'
]
type Handler = (event: { sender: Electron.WebContents }, args: unknown) => unknown
function registeredHandlers(): Map<string, Handler> {
const handlers = new Map<string, Handler>()
for (const [channel, handler] of handleMock.mock.calls as [string, Handler][]) {
handlers.set(channel, handler)
}
return handlers
}
function trustedSender(id: number): { sender: Electron.WebContents } {
return {
sender: {
id,
isDestroyed: () => false,
getType: () => 'window',
getURL: () => 'file:///renderer/index.html'
} as unknown as Electron.WebContents
}
}
let nextDocPageOrdinal = 0
function grantForNewDocPage(): { id: string; browserPageId: string } {
nextDocPageOrdinal += 1
const browserPageId = `doc-page-${nextDocPageOrdinal}`
const grant = mintDocPreviewGrant({
owner: { kind: 'ssh', connectionId: 'ssh-1' },
root: '/home/alice/docs',
entryRelativePath: 'index.html',
browserPageId
})
return { id: grant.id, browserPageId }
}
/** A preview guest already showing its document, which is the only state a tool can act in. */
function renderPreviewForGrant(
grant: { id: string; browserPageId: string },
hostId: number = HOST_RENDERER_ID
): {
grantId: string
browserPageId: string
contents: object
markContentsDestroyed: () => void
} {
const browserPageId = grant.browserPageId
const handlers: Record<string, (...args: never[]) => void> = {}
const register = (event: string, handler: (...args: never[]) => void): void => {
handlers[event] = handler
}
// Why the guest already reports its URL: the embedder hands a preview over mid-load, so this is
// the state the policy really installs into.
const documentUrl = buildDocPreviewUrl(grant.id, 'index.html')
let contentsDestroyed = false
const guest = {
isFocused: () => true,
isDestroyed: () => contentsDestroyed,
getURL: () => documentUrl,
on: vi.fn(register),
once: vi.fn(register),
setWindowOpenHandler: vi.fn(),
setWebRTCIPHandlingPolicy: vi.fn()
}
installDocPreviewGuestPolicy(guest as never, { id: hostId, send: vi.fn() })
handlers['did-start-navigation']?.({ url: documentUrl, isMainFrame: true } as never)
return {
grantId: grant.id,
browserPageId,
contents: guest,
// Why without the `destroyed` event: Chromium tears the contents down before main runs that
// listener, so this is the window the authority has to answer for on its own.
markContentsDestroyed: () => {
contentsDestroyed = true
}
}
}
function liveRenderedPreview(
hostId: number = HOST_RENDERER_ID
): ReturnType<typeof renderPreviewForGrant> {
return renderPreviewForGrant(grantForNewDocPage(), hostId)
}
/** Minimal well-formed args per channel, so a refusal is authorization and not shape validation. */
function toolArgs(channel: string, browserPageId: string): Record<string, unknown> {
switch (channel) {
case 'browser:setGrabMode':
return { browserPageId, enabled: true }
case 'browser:awaitGrabSelection':
return { browserPageId, opId: 'op-1' }
case 'browser:captureSelectionScreenshot':
return { browserPageId, rect: { x: 0, y: 0, width: 10, height: 10 } }
case 'browser:setAnnotationViewportBridge':
return {
browserPageId,
enabled: true,
emitViewport: true,
markers: [],
token: 'annotation-bridge-token-1'
}
default:
return { browserPageId }
}
}
/** The viewport bridge is handed a resolver rather than the contents, so unwrap one call argument. */
function resolvesToGuest(argument: unknown, guest: object): boolean {
return argument === guest || (typeof argument === 'function' && argument() === guest)
}
const GUEST_RECEIVING_MOCKS = [
setGrabModeMock,
awaitGrabSelectionMock,
captureSelectionScreenshotMock,
extractHoverPayloadMock,
setAnnotationViewportBridgeMock
]
beforeEach(() => {
vi.stubEnv('ELECTRON_RENDERER_URL', '')
// Why before clearing: revoking the previous test's grants disposes their page state through the
// mocked manager, and those calls belong to that test, not this one.
revokeAllDocPreviewGrants()
vi.clearAllMocks()
// Why the mocked manager still answers for documents: this file's subject is which channels may
// reach a document guest, not how the manager splits its registry — that lives in
// browser-manager-guest-policy-profile.test.ts, against the real manager.
getAuthorizedGuestMock.mockImplementation((browserPageId: string, senderWebContentsId: number) =>
getWorkspaceDocPageGuest(browserPageId, senderWebContentsId)
)
setGrabModeMock.mockResolvedValue(true)
awaitGrabSelectionMock.mockResolvedValue({ opId: 'op-1', kind: 'cancelled' })
captureSelectionScreenshotMock.mockResolvedValue(null)
extractHoverPayloadMock.mockResolvedValue(null)
setAnnotationViewportBridgeMock.mockResolvedValue(true)
registerBrowserHandlers()
// Why fake timers for the whole file: a tool asking for a page whose guest has not attached parks
// in the registration wait, and several cases here are exactly the ones that never attach. Real
// time would spend that wait for each of them.
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
/** Settles a tool request, elapsing the registration wait it may be parked in. */
async function settle<T>(pending: Promise<T> | T | undefined): Promise<T | undefined> {
await vi.advanceTimersByTimeAsync(GRAB_REGISTRATION_WAIT_MS)
return pending
}
describe('doc preview tool authorization', () => {
it('classifies every registered browser channel as a tool or a browser-page channel', () => {
const registered = [...registeredHandlers().keys()].sort()
const classified = [...TOOL_CHANNELS, ...BROWSER_PAGE_CHANNELS].sort()
expect(registered).toEqual(classified)
})
it.each(TOOL_CHANNELS)('drives the preview guest from %s', async (channel) => {
const preview = liveRenderedPreview()
const handler = registeredHandlers().get(channel)
await handler?.(trustedSender(HOST_RENDERER_ID), toolArgs(channel, preview.browserPageId))
// Why assert on the guest and not on a return value: the whole point of the seam is which
// WebContents the tool ends up acting on.
const receivedGuest = GUEST_RECEIVING_MOCKS.some((mock) =>
mock.mock.calls.some((args) => args.some((arg) => resolvesToGuest(arg, preview.contents)))
)
expect(receivedGuest || cancelGrabOpMock.mock.calls.length > 0).toBe(true)
})
// The load-bearing containment claim: page and session management never reads the document half
// of the registry, so a document page can never be resolved as, or managed like, a browsing one.
// Nothing here is a per-channel guard — a document guest is simply not in the map these read.
it.each(BROWSER_PAGE_CHANNELS)('never reads the document registry from %s', async (channel) => {
const preview = liveRenderedPreview()
const handler = registeredHandlers().get(channel)
try {
await handler?.(trustedSender(HOST_RENDERER_ID), {
browserPageId: preview.browserPageId,
profileId: preview.browserPageId,
environmentId: preview.browserPageId
})
} catch {
// A malformed-for-this-channel payload may throw; the claim is about which registry was read.
}
expect(previewAuthoritySpy).not.toHaveBeenCalled()
})
it.each(TOOL_CHANNELS)(
'refuses %s from a renderer that does not host the preview',
async (channel) => {
const preview = liveRenderedPreview()
const handler = registeredHandlers().get(channel)
await settle(
handler?.(trustedSender(OTHER_RENDERER_ID), toolArgs(channel, preview.browserPageId))
)
for (const mock of [...GUEST_RECEIVING_MOCKS, cancelGrabOpMock]) {
expect(mock).not.toHaveBeenCalled()
}
}
)
it.each(TOOL_CHANNELS)('refuses %s for a page no preview rendered', async (channel) => {
const handler = registeredHandlers().get(channel)
await settle(
handler?.(trustedSender(HOST_RENDERER_ID), toolArgs(channel, 'doc-page-unrendered'))
)
for (const mock of [...GUEST_RECEIVING_MOCKS, cancelGrabOpMock]) {
expect(mock).not.toHaveBeenCalled()
}
})
// Why the contents check has to be its own condition: the grant is still live and the sender is
// still the host, so nothing else in the authority notices that the guest is gone.
it.each(TOOL_CHANNELS)('refuses %s once the preview contents are destroyed', async (channel) => {
const preview = liveRenderedPreview()
preview.markContentsDestroyed()
const handler = registeredHandlers().get(channel)
await settle(
handler?.(trustedSender(HOST_RENDERER_ID), toolArgs(channel, preview.browserPageId))
)
for (const mock of [...GUEST_RECEIVING_MOCKS, cancelGrabOpMock]) {
expect(mock).not.toHaveBeenCalled()
}
})
// Why this inverts what the split registries used to assert: a document page now registers under
// its own id, so the wait is reachable and useful — a tool opened while the guest is still
// attaching parks here instead of answering not-ready at the reader.
it('waits for a document page whose guest has not attached yet, and arms when it does', async () => {
const grant = grantForNewDocPage()
const handler = registeredHandlers().get('browser:setGrabMode')
const pending = handler?.(trustedSender(HOST_RENDERER_ID), {
browserPageId: grant.browserPageId,
enabled: true
})
await vi.advanceTimersByTimeAsync(0)
expect(registrationWaitSpy).toHaveBeenCalledWith(grant.browserPageId)
const preview = renderPreviewForGrant(grant)
await expect(pending).resolves.toEqual({ ok: true })
expect(setGrabModeMock).toHaveBeenCalledWith(grant.browserPageId, true, preview.contents)
})
// The absence half, with the same presence precondition: a page nothing ever renders still gives
// the reader an answer rather than hanging on the full timeout.
it('answers not-ready once the wait for an unrendered page elapses', async () => {
const handler = registeredHandlers().get('browser:setGrabMode')
await expect(
settle(
handler?.(trustedSender(HOST_RENDERER_ID), {
browserPageId: 'doc-page-never-rendered',
enabled: true
})
)
).resolves.toEqual({ ok: false, reason: 'not-ready' })
})
it('still waits for a browser page whose registration may be in flight', async () => {
const handler = registeredHandlers().get('browser:setGrabMode')
await settle(
handler?.(trustedSender(HOST_RENDERER_ID), {
browserPageId: 'browser-page-1',
enabled: true
})
)
expect(registrationWaitSpy).toHaveBeenCalledWith('browser-page-1')
})
// Why the grant and not the guest: a preview withdraws by revoking, which is also what a
// re-mint does, and nothing else tells main that this tool target will never be used again.
it('disposes the grab state a preview target accumulated when its grant is revoked', async () => {
const preview = liveRenderedPreview()
const handler = registeredHandlers().get('browser:setGrabMode')
await handler?.(trustedSender(HOST_RENDERER_ID), {
browserPageId: preview.browserPageId,
enabled: true
})
cancelGrabOpMock.mockClear()
revokeDocPreviewGrant(preview.grantId)
expect(cancelGrabOpMock).toHaveBeenCalledWith(preview.browserPageId, 'evicted')
})
// Why both halves of this door: the manager refuses a preview id on its own, but the grab
// disposal beside it takes a renderer-supplied id, and the intent it drops is compared by
// identity — dropping it makes the grab settle ok without ever arming the guest.
it('leaves an in-flight preview grab armed when unregisterGuest names its target', async () => {
const preview = liveRenderedPreview()
const handlers = registeredHandlers()
const pending = handlers.get('browser:setGrabMode')?.(trustedSender(HOST_RENDERER_ID), {
browserPageId: preview.browserPageId,
enabled: true
})
// Why synchronously here: the intent is recorded before the queued operation runs, so this is
// the exact window in which a disposal at this door would be invisible to the caller.
const unregistered = handlers.get('browser:unregisterGuest')?.(
trustedSender(HOST_RENDERER_ID),
{ browserPageId: preview.browserPageId }
)
// Why the guest and not the result: a dropped intent settles as ok either way, so only the
// guest actually being driven separates an armed grab from a silent no-op.
await expect(pending).resolves.toEqual({ ok: true })
expect(setGrabModeMock).toHaveBeenCalledWith(preview.browserPageId, true, preview.contents)
expect(unregistered).toBe(false)
expect(vi.mocked(browserManager.unregisterGuest)).not.toHaveBeenCalled()
})
// The converse, so the guard above cannot be widened into a door that stops closing tabs.
it('still disposes a browser page grab through the same door', async () => {
getAuthorizedGuestMock.mockReturnValue({ isDestroyed: () => false })
const handlers = registeredHandlers()
const pending = handlers.get('browser:setGrabMode')?.(trustedSender(HOST_RENDERER_ID), {
browserPageId: 'browser-page-1',
enabled: true
})
const unregistered = handlers.get('browser:unregisterGuest')?.(
trustedSender(HOST_RENDERER_ID),
{ browserPageId: 'browser-page-1' }
)
expect(unregistered).toBe(true)
await expect(pending).resolves.toEqual({ ok: true })
expect(setGrabModeMock).not.toHaveBeenCalled()
expect(vi.mocked(browserManager.unregisterGuest)).toHaveBeenCalledWith('browser-page-1')
})
// Why with a live preview standing beside it: the halves are looked up in one door now, so the
// thing to prove is that a browsing id is not answered by whatever document happens to be open.
it('never hands a browsing page id the guest of a document that is open', async () => {
const preview = liveRenderedPreview()
const handler = registeredHandlers().get('browser:extractHoverPayload')
await handler?.(trustedSender(HOST_RENDERER_ID), { browserPageId: 'browser-page-1' })
expect(getAuthorizedGuestMock).toHaveBeenCalledWith('browser-page-1', HOST_RENDERER_ID)
expect(previewAuthoritySpy).toHaveBeenCalledWith('browser-page-1', HOST_RENDERER_ID)
expect(extractHoverPayloadMock).not.toHaveBeenCalled()
// The presence half: the same channel does reach that guest under the page it really renders.
await handler?.(trustedSender(HOST_RENDERER_ID), { browserPageId: preview.browserPageId })
expect(extractHoverPayloadMock).toHaveBeenCalledWith(preview.browserPageId, preview.contents)
})
})
@@ -1,108 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
getWebContentsIdByTabId: vi.fn(() => new Map<string, number>()),
getWorktreeIdForTab: vi.fn(() => undefined as string | undefined),
getGuestWebContentsId: vi.fn(() => null as number | null),
webContentsFromId: vi.fn(() => null as unknown)
}))
vi.mock('electron', () => ({ webContents: { fromId: mocks.webContentsFromId } }))
vi.mock('../browser/browser-manager', () => ({
browserManager: {
getWebContentsIdByTabId: mocks.getWebContentsIdByTabId,
getWorktreeIdForTab: mocks.getWorktreeIdForTab,
getGuestWebContentsId: mocks.getGuestWebContentsId
}
}))
import {
waitForAnyTabRegistration,
waitForNextTabRegistration,
waitForWorktreeTabRegistration
} from './browser-tab-registration-wait'
import { installDocPreviewGuestPolicy } from '../browser/doc-preview-guest-policy'
import {
mintDocPreviewGrant,
revokeAllDocPreviewGrants
} from '../browser/doc-preview-grant-registry'
import { buildDocPreviewUrl } from '../../shared/doc-preview-scheme'
const WAIT_MS = 1_000
/** Registers a document guest the way the attach door does, which is what notifies the waiters. */
function attachDocumentGuest(browserPageId: string): void {
const grant = mintDocPreviewGrant({
owner: { kind: 'ssh', connectionId: 'ssh-1' },
root: '/home/alice/docs',
entryRelativePath: 'index.html',
browserPageId
})
const guest = {
isFocused: () => false,
isDestroyed: () => false,
getURL: () => buildDocPreviewUrl(grant.id, grant.entryRelativePath),
on: vi.fn(),
once: vi.fn(),
setWindowOpenHandler: vi.fn(),
setWebRTCIPHandlingPolicy: vi.fn()
}
installDocPreviewGuestPolicy(guest as never, { id: 91, send: vi.fn() })
}
/** Whether a wait has settled, without letting its rejection escape as an unhandled one. */
function track(pending: Promise<void>): () => 'pending' | 'resolved' | 'rejected' {
let state: 'pending' | 'resolved' | 'rejected' = 'pending'
pending.then(
() => {
state = 'resolved'
},
() => {
state = 'rejected'
}
)
return () => state
}
beforeEach(() => {
revokeAllDocPreviewGrants()
vi.clearAllMocks()
mocks.getWebContentsIdByTabId.mockReturnValue(new Map())
mocks.getGuestWebContentsId.mockReturnValue(null)
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
describe('a workspace document registering', () => {
it('settles a wait that already names its page', async () => {
const settled = track(waitForNextTabRegistration('doc-page-1', WAIT_MS))
attachDocumentGuest('doc-page-1')
await vi.advanceTimersByTimeAsync(0)
expect(settled()).toBe('resolved')
})
// Why these two stay pending: they are how the CLI and agents ask for a browser tab to drive, and
// a preview is neither drivable by them nor visible to them. Satisfying either would hand the
// caller a surface that answers nothing, instead of letting it keep waiting for a real tab.
it('leaves the waits that ask for any browser tab still waiting', async () => {
const worktreeWait = track(waitForWorktreeTabRegistration('wt-1', WAIT_MS))
const anyWait = track(waitForAnyTabRegistration(WAIT_MS))
attachDocumentGuest('doc-page-2')
await vi.advanceTimersByTimeAsync(0)
expect(worktreeWait()).toBe('pending')
expect(anyWait()).toBe('pending')
// The presence half: those waits do settle, so a mutant that never resolves them would pass the
// assertions above by being uniformly stuck.
await vi.advanceTimersByTimeAsync(WAIT_MS)
expect(worktreeWait()).toBe('rejected')
expect(anyWait()).toBe('rejected')
})
})
@@ -1,6 +1,5 @@
import { webContents } from 'electron'
import { browserManager } from '../browser/browser-manager'
import { onWorkspaceDocGuestRegistered } from '../browser/doc-preview-guest-policy'
// Why: CLI-driven tab creation must wait until the renderer mounts the webview
// and calls registerGuest, so the tab has a webContentsId and is operable by
@@ -110,18 +109,6 @@ export function waitForAnyTabRegistration(timeoutMs = 8_000): Promise<void> {
return waitForRegistrationSet(pendingAnyTabRegistrations, timeoutMs, () => {})
}
/**
* Why a document page resolves only its own waiters: the worktree-wide and any-tab waits are how
* the CLI and agents ask for a browser tab to drive, and a preview is neither drivable by them nor
* visible to them. Only a request already naming this page — a tool the reader opened on the
* document — is waiting for this.
*/
onWorkspaceDocGuestRegistered((browserPageId) => {
const pendingResolves = pendingTabRegistrations.get(browserPageId)
pendingTabRegistrations.delete(browserPageId)
resolvePendingRegistrations(pendingResolves)
})
export function resolveTabRegistrationWaiters(browserPageId: string, worktreeId: string): void {
const pendingResolves = pendingTabRegistrations.get(browserPageId)
pendingTabRegistrations.delete(browserPageId)
+6 -48
View File
@@ -794,8 +794,6 @@ describe('registerBrowserHandlers', () => {
it('validates annotation viewport bridge requests before syncing to the guest', async () => {
registerBrowserHandlers()
const guest = { isDestroyed: () => false } as Electron.WebContents
getAuthorizedGuestMock.mockReturnValue(guest)
const syncHandler = handleMock.mock.calls.find(
([channel]) => channel === 'browser:setAnnotationViewportBridge'
@@ -820,52 +818,12 @@ describe('registerBrowserHandlers', () => {
)
expect(result).toBe(true)
expect(setAnnotationViewportBridgeMock).toHaveBeenCalledWith(
'page-1',
{
emitViewport: false,
enabled: true,
markers: [],
token: 'annotationviewporttoken'
},
// Why a resolver and not the guest: the op is serialized per page, so it has to read the
// registry when it runs — a navigation while it waited may have swapped the contents.
// Which guest it then resolves is pinned in browser-manager-annotation-bridge.test.ts.
expect.any(Function)
)
})
// Why this is new: the channel used to hand a page id straight to the manager, so any trusted
// renderer could drive any page's guest. It now resolves through the same authority the grab
// channels use, which pins the request to the renderer that registered the page.
it('refuses an annotation viewport bridge request from a renderer that does not own the page', async () => {
registerBrowserHandlers()
getAuthorizedGuestMock.mockReturnValue(null)
const syncHandler = handleMock.mock.calls.find(
([channel]) => channel === 'browser:setAnnotationViewportBridge'
)?.[1] as (event: { sender: Electron.WebContents }, args: unknown) => Promise<boolean> | boolean
const result = await syncHandler(
{
sender: {
id: 91,
isDestroyed: () => false,
getType: () => 'window',
getURL: () => 'file:///renderer/index.html'
} as Electron.WebContents
},
{
browserPageId: 'page-1',
emitViewport: false,
enabled: true,
markers: [],
token: 'annotationviewporttoken'
}
)
expect(result).toBe(false)
expect(setAnnotationViewportBridgeMock).not.toHaveBeenCalled()
expect(setAnnotationViewportBridgeMock).toHaveBeenCalledWith('page-1', {
emitViewport: false,
enabled: true,
markers: [],
token: 'annotationviewporttoken'
})
})
it('rejects invalid annotation viewport bridge requests', async () => {
-8
View File
@@ -2,7 +2,6 @@ import { ipcMain, webContents } from 'electron'
import { browserCertificateTrustController, browserManager } from '../browser/browser-manager'
import type { AgentBrowserBridge } from '../browser/agent-browser-bridge'
import { browserSessionRegistry } from '../browser/browser-session-registry'
import { isWorkspaceDocPageId } from '../browser/doc-preview-guest-policy'
import { isTrustedBrowserRenderer } from './browser-renderer-trust'
import {
isLiveBrowserWebContentsId,
@@ -161,13 +160,6 @@ export function registerBrowserHandlers(): void {
if (!isTrustedBrowserRenderer(event.sender)) {
return false
}
// Why the whole door and not just the manager call: a document page shares this renderer, and
// the grab disposal below drops the intent an in-flight preview grab compares by identity —
// that grab would then answer ok without ever arming. A document page withdraws by revoking
// its grant, so its id arriving here is misaddressed however it got here.
if (typeof args?.browserPageId !== 'string' || isWorkspaceDocPageId(args.browserPageId)) {
return false
}
// Why: notify bridge before unregistering so it can destroy the session
// process and proxy. Must happen before unregisterGuest clears the mapping.
const wcId = browserManager.getGuestWebContentsId(args.browserPageId)
-159
View File
@@ -1,159 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
handlers: new Map<string, (event: unknown, ...args: unknown[]) => unknown>(),
listeners: new Map<string, (event: unknown, ...args: unknown[]) => unknown>(),
isTrustedBrowserRenderer: vi.fn(),
reportDocPreviewLinkClick: vi.fn(),
getGuestWebContentsId: vi.fn()
}))
vi.mock('electron', () => ({
ipcMain: {
handle: (channel: string, handler: (event: unknown, ...args: unknown[]) => unknown) => {
mocks.handlers.set(channel, handler)
},
on: (channel: string, listener: (event: unknown, ...args: unknown[]) => unknown) => {
mocks.listeners.set(channel, listener)
}
}
}))
vi.mock('./browser-renderer-trust', () => ({
isTrustedBrowserRenderer: mocks.isTrustedBrowserRenderer
}))
vi.mock('../browser/doc-preview-guest-policy', () => ({
reportDocPreviewLinkClick: mocks.reportDocPreviewLinkClick
}))
vi.mock('../browser/browser-manager', () => ({
browserManager: { getGuestWebContentsId: mocks.getGuestWebContentsId }
}))
import {
registerDocPreviewGrantHandlers,
type DocPreviewGrantRequest
} from './doc-preview-grant-ipc'
import {
getDocPreviewGrant,
revokeAllDocPreviewGrants
} from '../browser/doc-preview-grant-registry'
import {
DOC_PREVIEW_LINK_CLICK_CHANNEL,
DOC_PREVIEW_MINT_GRANT_CHANNEL,
DOC_PREVIEW_REVOKE_GRANT_CHANNEL,
parseDocPreviewUrl
} from '../../shared/doc-preview-scheme'
const REQUEST: DocPreviewGrantRequest = {
owner: { kind: 'ssh', connectionId: 'ssh-1' },
root: '/home/alice/docs',
entryRelativePath: 'index.html',
browserPageId: 'doc-page-1'
}
const sender = { id: 7 }
function mint(request: DocPreviewGrantRequest = REQUEST): { grantId: string; url: string } {
const handler = mocks.handlers.get(DOC_PREVIEW_MINT_GRANT_CHANNEL)
if (!handler) {
throw new Error('mint handler not registered')
}
return handler({ sender }, request) as { grantId: string; url: string }
}
function revoke(grantId: string): boolean {
const handler = mocks.handlers.get(DOC_PREVIEW_REVOKE_GRANT_CHANNEL)
if (!handler) {
throw new Error('revoke handler not registered')
}
return handler({ sender }, grantId) as boolean
}
function reportLinkClick(url: unknown): void {
const listener = mocks.listeners.get(DOC_PREVIEW_LINK_CLICK_CHANNEL)
if (!listener) {
throw new Error('link click listener not registered')
}
listener({ sender }, url)
}
beforeEach(() => {
mocks.handlers.clear()
mocks.listeners.clear()
vi.clearAllMocks()
revokeAllDocPreviewGrants()
mocks.isTrustedBrowserRenderer.mockReturnValue(true)
mocks.getGuestWebContentsId.mockReturnValue(null)
registerDocPreviewGrantHandlers()
})
describe('document preview grant handlers', () => {
it('mints a grant addressable by the URL it returns', () => {
const result = mint()
expect(parseDocPreviewUrl(result.url)).toEqual({
grantId: result.grantId,
relativePath: 'index.html'
})
expect(getDocPreviewGrant(result.grantId)?.root).toBe('/home/alice/docs')
expect(mocks.isTrustedBrowserRenderer).toHaveBeenCalledWith(sender)
})
it('revokes a grant it minted', () => {
const result = mint()
expect(revoke(result.grantId)).toBe(true)
expect(getDocPreviewGrant(result.grantId)).toBeNull()
})
it('rejects a request that names no root, entry document or page', () => {
expect(() => mint({ ...REQUEST, root: ' ' })).toThrow(/Invalid/)
expect(() => mint({ ...REQUEST, entryRelativePath: '' })).toThrow(/Invalid/)
expect(() => mint({ ...REQUEST, browserPageId: ' ' })).toThrow(/Invalid/)
})
// Why this is refused here and not left to registration: this is where a page becomes a document
// page, and the two halves of the page registry have to stay disjoint. A page already hosting a
// browsing guest would otherwise resolve in both, and the tool door prefers the document one.
it('refuses to make a page that already hosts a browsing guest into a document page', () => {
mocks.getGuestWebContentsId.mockReturnValue(42)
expect(() => mint()).toThrow(/browsing page/)
expect(mocks.getGuestWebContentsId).toHaveBeenCalledWith('doc-page-1')
})
// Why: this channel hands out filesystem-read authority, so an untrusted sender must leave with
// nothing rather than with a grant id the scheme handler would honor.
it('mints nothing for a sender that is not the trusted renderer', () => {
mocks.isTrustedBrowserRenderer.mockReturnValue(false)
expect(() => mint()).toThrow(/Untrusted/)
})
it('refuses to revoke on behalf of a sender that is not the trusted renderer', () => {
const result = mint()
mocks.isTrustedBrowserRenderer.mockReturnValue(false)
expect(revoke(result.grantId)).toBe(false)
expect(getDocPreviewGrant(result.grantId)).not.toBeNull()
})
// Why this channel skips the trusted-renderer check: its sender is a preview guest rendering a
// workspace document, which is the untrusted side by construction. The guest policy holds the
// gate, so all this listener owes is the sender and a string.
it('hands a reported link click to the guest policy with the sender that reported it', () => {
reportLinkClick('https://example.com/docs')
expect(mocks.reportDocPreviewLinkClick).toHaveBeenCalledExactlyOnceWith(
sender,
'https://example.com/docs'
)
expect(mocks.isTrustedBrowserRenderer).not.toHaveBeenCalled()
})
it('ignores a reported click that is not a string at all', () => {
reportLinkClick({ url: 'https://example.com/docs' })
reportLinkClick(undefined)
expect(mocks.reportDocPreviewLinkClick).not.toHaveBeenCalled()
})
})
-95
View File
@@ -1,95 +0,0 @@
import { ipcMain } from 'electron'
import {
buildDocPreviewUrl,
DOC_PREVIEW_LINK_CLICK_CHANNEL,
DOC_PREVIEW_MINT_GRANT_CHANNEL,
DOC_PREVIEW_REVOKE_GRANT_CHANNEL
} from '../../shared/doc-preview-scheme'
import { browserManager } from '../browser/browser-manager'
import { reportDocPreviewLinkClick } from '../browser/doc-preview-guest-policy'
import {
mintDocPreviewGrant,
revokeDocPreviewGrant,
type DocPreviewOwner
} from '../browser/doc-preview-grant-registry'
import { isTrustedBrowserRenderer } from './browser-renderer-trust'
export type DocPreviewGrantRequest = {
owner: DocPreviewOwner
/** Containing directory of the opened document, on the owning host. */
root: string
/** Opened document, relative to `root`. */
entryRelativePath: string
/** Browser page the reader is opening the document in; main registers the guest under it. */
browserPageId: string
}
export type DocPreviewGrantResult = { grantId: string; url: string }
function isValidGrantRequest(request: DocPreviewGrantRequest): boolean {
if (!request.root.trim() || !request.entryRelativePath.trim()) {
return false
}
if (typeof request.browserPageId !== 'string' || !request.browserPageId.trim()) {
return false
}
if (request.owner.kind === 'ssh') {
return Boolean(request.owner.connectionId.trim())
}
return Boolean(
request.owner.environmentId.trim() &&
request.owner.worktreeSelector.trim() &&
request.owner.worktreeRoot.trim()
)
}
/**
* Minting never widens what the renderer can already read: an SSH grant reads
* through the same provider as `fs:readFile`, and a runtime grant through the
* same worktree-scoped `files.read` RPC the renderer can call directly.
*/
export function registerDocPreviewGrantHandlers(): void {
ipcMain.handle(
DOC_PREVIEW_MINT_GRANT_CHANNEL,
(event, request: DocPreviewGrantRequest): DocPreviewGrantResult => {
// Why gate a channel guests cannot reach today: this one hands out filesystem-read
// authority, so it holds the same sender check its sibling browser channels do rather than
// relying on guests never gaining an ipcRenderer.
if (!isTrustedBrowserRenderer(event.sender)) {
throw new Error('Untrusted document preview grant request')
}
if (!isValidGrantRequest(request)) {
throw new Error('Invalid document preview grant request')
}
// Why the other half of the registry is consulted here: this is where a page first becomes a
// document page, and the two halves must stay disjoint. Naming a page that already hosts a
// browsing guest would make one id resolve in both.
if (browserManager.getGuestWebContentsId(request.browserPageId) !== null) {
throw new Error('Document preview grant names a browsing page')
}
const grant = mintDocPreviewGrant({
owner: request.owner,
root: request.root,
entryRelativePath: request.entryRelativePath,
browserPageId: request.browserPageId
})
return {
grantId: grant.id,
url: buildDocPreviewUrl(grant.id, grant.entryRelativePath)
}
}
)
ipcMain.handle(DOC_PREVIEW_REVOKE_GRANT_CHANNEL, (event, grantId: string): boolean =>
isTrustedBrowserRenderer(event.sender) ? revokeDocPreviewGrant(grantId) : false
)
// Why no trusted-renderer check here: the sender is a preview guest rendering a workspace
// document, which is exactly the untrusted side. `reportDocPreviewLinkClick` is the gate — a
// live bound grant, a focused guest, a web URL — and it drops everything else silently.
ipcMain.on(DOC_PREVIEW_LINK_CLICK_CHANNEL, (event, url: unknown) => {
if (typeof url === 'string') {
reportDocPreviewLinkClick(event.sender, url)
}
})
}
@@ -148,6 +148,58 @@ describe('registerPtyHandlers', () => {
false
)
})
it('restores daemon launch identity for a runtime-created reattach', async () => {
const incarnationId = 'runtime-reattach-incarnation'
const leafId = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
const daemonSpawn = setupDaemonAdapter()
daemonSpawn.mockResolvedValueOnce({
id: 'runtime-reattach',
incarnationId,
isReattach: true,
launchAgent: 'codex'
} as never)
const runtime = {
setPtyController: vi.fn(),
registerPty: vi.fn(),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as {
spawn(args: {
cols: number
rows: number
worktreeId: string
tabId: string
leafId: string
sessionId: string
}): Promise<{ id: string }>
}
await controller.spawn({
cols: 80,
rows: 24,
worktreeId: 'wt-runtime-reattach',
tabId: 'tab-runtime-reattach',
leafId,
sessionId: 'runtime-reattach'
})
expect(runtime.registerPty).toHaveBeenCalledWith(
'runtime-reattach',
'wt-runtime-reattach',
null,
{
tabId: 'tab-runtime-reattach',
leafId,
incarnationId,
providerReattachLaunchIdentity: { incarnationId, launchAgent: 'codex' }
},
false
)
})
it('uses the owning project WSL runtime for runtime-created daemon PTYs', async () => {
await withWin32Platform(async () => {
_setWslCachesForTests({ available: true, distros: ['Ubuntu'] })
+1
View File
@@ -87,6 +87,7 @@ export const fsModuleMock = () => ({
chmodSync: chmodSyncMock,
renameSync: renameSyncMock,
rmSync: rmSyncMock,
mkdtempSync: () => '/tmp/orca-watcher-canary-test',
constants: {
X_OK: 1,
R_OK: 4
@@ -416,8 +416,14 @@ describe('registerPtyHandlers', () => {
baseEnv: expect.any(Object)
})
})
it('does not echo launch config for provider reattach results', async () => {
const spawn = vi.fn(async () => ({ id: 'ssh-reattach', isReattach: true }))
it('restores daemon launch identity without minting renderer authority on reattach', async () => {
const incarnationId = 'ssh-reattach-incarnation'
const spawn = vi.fn(async () => ({
id: 'ssh-reattach',
incarnationId,
isReattach: true as const,
launchAgent: 'codex' as const
}))
registerSshPtyProvider('ssh-reattach-1', {
spawn,
write: vi.fn(),
@@ -439,17 +445,30 @@ describe('registerPtyHandlers', () => {
getProfiles: vi.fn(),
acknowledgeDataEvent: vi.fn()
} as never)
const registerPty = vi.fn()
const runtime = {
setPtyController: vi.fn(),
createPreAllocatedTerminalHandle: vi.fn(() => 'term_remote'),
registerPreAllocatedHandleForPty: vi.fn()
registerPreAllocatedHandleForPty: vi.fn(),
registerPty
}
const tabId = 'tab-reattach'
const leafId = '77777777-7777-4777-8777-777777777777'
const worktreeId = 'repo-1::/tmp/reattach'
registerPtyHandlers(mainWindow as never, runtime as never)
const result = (await handlers.get('pty:spawn')!(null, {
const result = (await handlers.get('pty:spawn')!(mainWindowIpcEvent, {
cols: 80,
rows: 24,
connectionId: 'ssh-reattach-1',
worktreeId,
tabId,
leafId,
env: {
ORCA_PANE_KEY: makePaneKey(tabId, leafId),
ORCA_TAB_ID: tabId,
ORCA_WORKTREE_ID: worktreeId
},
launchConfig: {
agentCommand: 'codex --model gpt-5',
agentArgs: '--model gpt-5',
@@ -459,6 +478,18 @@ describe('registerPtyHandlers', () => {
expect(result).toMatchObject({ id: 'ssh-reattach', isReattach: true })
expect(result.launchConfig).toBeUndefined()
expect(registerPty).toHaveBeenCalledWith(
'ssh-reattach',
worktreeId,
'ssh-reattach-1',
expect.objectContaining({
tabId,
leafId,
incarnationId,
providerReattachLaunchIdentity: { incarnationId, launchAgent: 'codex' }
}),
undefined
)
})
it('reuses the runtime background handle in local PTY spawn env', async () => {
type RuntimeSpawnController = {
+11 -2
View File
@@ -18,7 +18,10 @@ import {
import { rememberPaneKeyForPty } from '../pane/key-state'
import { resolvePaneSpawnReservation } from '../pane/spawn-reservation'
import { seedTerminalRestoreRecordsFromSpawnResult } from '../pane/agent-session-owners'
import { admitRendererAgentLaunchAuthority } from '../pane/launch-authority'
import {
admitProviderReattachLaunchIdentity,
admitRendererAgentLaunchAuthority
} from '../pane/launch-authority'
import type { PtyIpcSpawnState } from './spawn-state'
import { persistPtyIpcSpawnCommit } from './spawn-commit-persist'
@@ -82,6 +85,11 @@ export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise<PtySpawn
hasStablePaneOwner: ctx.stablePaneOwner !== null,
incarnationId: ctx.result.incarnationId
})
const providerReattachLaunchIdentity = admitProviderReattachLaunchIdentity({
isReattach: ctx.result.isReattach === true,
launchAgent: ctx.result.launchAgent,
incarnationId: ctx.result.incarnationId
})
ctx.deps.runtime?.registerPty(
ctx.result.id,
args.worktreeId,
@@ -95,7 +103,8 @@ export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise<PtySpawn
tabId: args.tabId,
leafId: ctx.metadataLeafId,
...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}),
...(agentLaunchAuthority ? { agentLaunchAuthority } : {})
...(agentLaunchAuthority ? { agentLaunchAuthority } : {}),
...(providerReattachLaunchIdentity ? { providerReattachLaunchIdentity } : {})
}
: undefined,
!args.connectionId
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest'
import { admitProviderReattachLaunchIdentity } from './launch-authority'
describe('admitProviderReattachLaunchIdentity', () => {
it('binds provider launch identity to a valid reattach incarnation', () => {
expect(
admitProviderReattachLaunchIdentity({
isReattach: true,
launchAgent: 'codex',
incarnationId: 'provider-incarnation'
})
).toEqual({ launchAgent: 'codex', incarnationId: 'provider-incarnation' })
})
it.each([
{ label: 'fresh spawn', isReattach: false, launchAgent: 'codex', incarnationId: 'incarnation' },
{
label: 'invalid agent',
isReattach: true,
launchAgent: 'unknown',
incarnationId: 'incarnation'
},
{
label: 'missing incarnation',
isReattach: true,
launchAgent: 'codex',
incarnationId: undefined
},
{
label: 'oversized incarnation',
isReattach: true,
launchAgent: 'codex',
incarnationId: 'x'.repeat(129)
}
])('rejects $label metadata', ({ isReattach, launchAgent, incarnationId }) => {
expect(
admitProviderReattachLaunchIdentity({ isReattach, launchAgent, incarnationId })
).toBeNull()
})
})
+15
View File
@@ -30,6 +30,21 @@ export function admitRendererAgentLaunchAuthority(args: {
return { launchToken: args.launchToken, launchAgent: args.launchAgent }
}
export function admitProviderReattachLaunchIdentity(args: {
isReattach?: boolean
launchAgent?: unknown
incarnationId?: unknown
}): { incarnationId: string; launchAgent: TuiAgent } | null {
if (
!args.isReattach ||
!isTuiAgent(args.launchAgent) ||
!isPtyIncarnationId(args.incarnationId)
) {
return null
}
return { incarnationId: args.incarnationId, launchAgent: args.launchAgent }
}
export function shouldRefreshNativeClaudeAgentTeamsEnv(args: {
command?: string
launchConfig?: SleepingAgentLaunchConfig
+6 -2
View File
@@ -33,10 +33,12 @@ import { toSshExecutionHostId } from '../../../../shared/execution-host'
import { createTerminalSessionStateSaveFailureMessage } from '../../../../shared/terminal-session-state-save-failure'
import { clearProviderPtyState } from '../provider/state-cleanup'
import { resolvePaneSpawnReservation } from '../pane/spawn-reservation'
import { admitProviderReattachLaunchIdentity } from '../pane/launch-authority'
import type { RuntimePtySpawnState } from './spawn-state'
export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) {
const args = ctx.args
const providerReattachLaunchIdentity = admitProviderReattachLaunchIdentity(ctx.result)
try {
ctx.stablePaneBindingPersisted = persistAdmittedStablePaneBinding({
store: ctx.hostSessionBinding?.store,
@@ -69,7 +71,8 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) {
{
tabId: owner.surface.tabId,
leafId: owner.surface.leafId,
...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {})
...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}),
...(providerReattachLaunchIdentity ? { providerReattachLaunchIdentity } : {})
}
)
if (!args.connectionId) {
@@ -204,7 +207,8 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) {
? {
tabId: args.tabId,
leafId: ctx.metadataLeafId,
...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {})
...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}),
...(providerReattachLaunchIdentity ? { providerReattachLaunchIdentity } : {})
}
: undefined,
!args.connectionId
@@ -0,0 +1,241 @@
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
import { dirname } from 'node:path'
import {
findKeybindingConflicts,
getKeybindingDefinition,
isKeybindingActionId,
normalizeKeybindingArrayForAction,
normalizeKeybindingListForAction,
type KeybindingActionId,
type KeybindingFileDiagnostic,
type KeybindingOverrides,
type KeybindingPlatform
} from '../../shared/keybindings'
export type JsonObject = Record<string, unknown>
export const FILE_VERSION = 1
const PLATFORM_KEYS: readonly KeybindingPlatform[] = ['darwin', 'linux', 'win32']
const ROOT_KEYS = new Set(['$schema', 'version', 'keybindings', 'platforms'])
export function isJsonObject(value: unknown): value is JsonObject {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
}
export function createEmptyDocument(): JsonObject {
return {
version: FILE_VERSION,
keybindings: {},
platforms: {
darwin: {},
linux: {},
win32: {}
}
}
}
export function readJsonDocument(path: string): {
exists: boolean
document: JsonObject | null
error?: string
} {
if (!existsSync(path)) {
return { exists: false, document: createEmptyDocument() }
}
try {
const parsed = JSON.parse(readFileSync(path, 'utf8')) as unknown
if (!isJsonObject(parsed)) {
return { exists: true, document: null, error: 'Keybindings file must contain a JSON object.' }
}
return { exists: true, document: parsed }
} catch (error) {
return {
exists: true,
document: null,
error: error instanceof Error ? error.message : String(error)
}
}
}
export function writeJsonDocument(path: string, document: JsonObject): void {
mkdirSync(dirname(path), { recursive: true })
const tempPath = `${path}.tmp`
try {
writeFileSync(tempPath, `${JSON.stringify(document, null, 2)}\n`, 'utf8')
renameSync(tempPath, path)
} catch (error) {
try {
if (existsSync(tempPath)) {
unlinkSync(tempPath)
}
} catch {
// Ignore cleanup failure; the original write error is more actionable.
}
throw error
}
}
function normalizeBindingValue(
actionId: KeybindingActionId,
value: unknown
): { ok: true; value: string[] } | { ok: false; error: string } {
if (value === null || value === false) {
return { ok: true, value: [] }
}
if (typeof value === 'string') {
const normalized = normalizeKeybindingListForAction(actionId, value)
return Array.isArray(normalized)
? { ok: true, value: normalized }
: normalized.ok
? { ok: true, value: [normalized.value] }
: normalized
}
if (Array.isArray(value)) {
if (!value.every((item) => typeof item === 'string')) {
return { ok: false, error: 'Use a string, string array, null, or false.' }
}
const normalized = normalizeKeybindingArrayForAction(actionId, value)
return Array.isArray(normalized)
? { ok: true, value: normalized }
: normalized.ok
? { ok: true, value: [normalized.value] }
: normalized
}
return { ok: false, error: 'Use a string, string array, null, or false.' }
}
export function normalizeWriteBindingValue(
actionId: KeybindingActionId,
value: unknown
): string[] | null {
if (value === null) {
return null
}
if (!Array.isArray(value) || !value.every((binding) => typeof binding === 'string')) {
throw new Error('Use a string array or null.')
}
const normalized = normalizeKeybindingArrayForAction(actionId, value)
if (!Array.isArray(normalized)) {
throw new Error(normalized.ok ? 'Unable to parse shortcut.' : normalized.error)
}
return normalized
}
export function parseBindingSection(
value: unknown,
section: string,
diagnostics: KeybindingFileDiagnostic[],
options: { skipRootKeys?: boolean } = {}
): KeybindingOverrides {
if (value === undefined) {
return {}
}
if (!isJsonObject(value)) {
diagnostics.push({
severity: 'error',
section,
message: `${section} must be an object.`
})
return {}
}
const overrides: KeybindingOverrides = {}
for (const [actionId, rawBinding] of Object.entries(value)) {
if (options.skipRootKeys && ROOT_KEYS.has(actionId)) {
continue
}
if (!isKeybindingActionId(actionId)) {
diagnostics.push({
severity: 'warning',
section,
actionId,
message: `Unknown keybinding action "${actionId}" was ignored.`
})
continue
}
const normalized = normalizeBindingValue(actionId, rawBinding)
if (!normalized.ok) {
diagnostics.push({
severity: 'error',
section,
actionId,
message: `Shortcut for "${actionId}" was ignored: ${normalized.error}`
})
continue
}
overrides[actionId] = normalized.value
}
return overrides
}
export function parsePlatformOverrides(
document: JsonObject,
diagnostics: KeybindingFileDiagnostic[]
): Partial<Record<KeybindingPlatform, KeybindingOverrides>> {
const rawPlatforms = document.platforms
if (rawPlatforms === undefined) {
return {}
}
if (!isJsonObject(rawPlatforms)) {
diagnostics.push({
severity: 'error',
section: 'platforms',
message: 'platforms must be an object with darwin, linux, or win32 sections.'
})
return {}
}
const result: Partial<Record<KeybindingPlatform, KeybindingOverrides>> = {}
for (const [platform, value] of Object.entries(rawPlatforms)) {
if (!PLATFORM_KEYS.includes(platform as KeybindingPlatform)) {
diagnostics.push({
severity: 'warning',
section: `platforms.${platform}`,
message: `Unknown platform "${platform}" was ignored.`
})
continue
}
result[platform as KeybindingPlatform] = parseBindingSection(
value,
`platforms.${platform}`,
diagnostics
)
}
return result
}
export function removeConflictingOverrides(
platform: KeybindingPlatform,
overrides: KeybindingOverrides,
diagnostics: KeybindingFileDiagnostic[]
): KeybindingOverrides {
let next = { ...overrides }
for (let attempt = 0; attempt < 20; attempt++) {
const conflicts = findKeybindingConflicts(platform, next)
const conflictingOverrides = new Set<KeybindingActionId>()
for (const conflict of conflicts) {
for (const actionId of conflict.actionIds) {
if (Object.hasOwn(next, actionId)) {
conflictingOverrides.add(actionId)
}
}
}
if (conflictingOverrides.size === 0) {
return next
}
for (const actionId of conflictingOverrides) {
delete next[actionId]
}
diagnostics.push({
severity: 'error',
message: `Conflicting custom shortcuts were ignored: ${Array.from(conflictingOverrides)
.map((actionId) => getKeybindingDefinition(actionId)?.title ?? actionId)
.join(', ')}.`
})
}
return next
}
+15 -232
View File
@@ -1,250 +1,33 @@
/* eslint-disable max-lines -- Why: parsing, sanitizing, migrating, and writing the keybindings file must stay together so file-format edge cases share one validation path. */
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { existsSync } from 'node:fs'
import { join } from 'node:path'
import {
findKeybindingConflicts,
formatKeybindingList,
getKeybindingDefinition,
getKeybindingPlatform,
isKeybindingActionId,
normalizeKeybindingArrayForAction,
normalizeKeybindingListForAction,
type KeybindingActionId,
type KeybindingFileDiagnostic,
type KeybindingFileSnapshot,
type KeybindingOverrides,
type KeybindingPlatform
type KeybindingOverrides
} from '../../shared/keybindings'
type JsonObject = Record<string, unknown>
const FILE_VERSION = 1
const PLATFORM_KEYS: readonly KeybindingPlatform[] = ['darwin', 'linux', 'win32']
const ROOT_KEYS = new Set(['$schema', 'version', 'keybindings', 'platforms'])
import {
createEmptyDocument,
FILE_VERSION,
isJsonObject,
normalizeWriteBindingValue,
parseBindingSection,
parsePlatformOverrides,
readJsonDocument,
removeConflictingOverrides,
writeJsonDocument,
type JsonObject
} from './keybinding-file-parser'
export function getUserKeybindingsPath(homePath: string): string {
return join(homePath, '.orca', 'keybindings.json')
}
function isJsonObject(value: unknown): value is JsonObject {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
}
function createEmptyDocument(): JsonObject {
return {
version: FILE_VERSION,
keybindings: {},
platforms: {
darwin: {},
linux: {},
win32: {}
}
}
}
function readJsonDocument(path: string): {
exists: boolean
document: JsonObject | null
error?: string
} {
if (!existsSync(path)) {
return { exists: false, document: createEmptyDocument() }
}
try {
const parsed = JSON.parse(readFileSync(path, 'utf8')) as unknown
if (!isJsonObject(parsed)) {
return { exists: true, document: null, error: 'Keybindings file must contain a JSON object.' }
}
return { exists: true, document: parsed }
} catch (error) {
return {
exists: true,
document: null,
error: error instanceof Error ? error.message : String(error)
}
}
}
function writeJsonDocument(path: string, document: JsonObject): void {
mkdirSync(dirname(path), { recursive: true })
const tempPath = `${path}.tmp`
try {
writeFileSync(tempPath, `${JSON.stringify(document, null, 2)}\n`, 'utf8')
renameSync(tempPath, path)
} catch (error) {
try {
if (existsSync(tempPath)) {
unlinkSync(tempPath)
}
} catch {
// Ignore cleanup failure; the original write error is more actionable.
}
throw error
}
}
function normalizeBindingValue(
actionId: KeybindingActionId,
value: unknown
): { ok: true; value: string[] } | { ok: false; error: string } {
if (value === null || value === false) {
return { ok: true, value: [] }
}
if (typeof value === 'string') {
const normalized = normalizeKeybindingListForAction(actionId, value)
return Array.isArray(normalized)
? { ok: true, value: normalized }
: normalized.ok
? { ok: true, value: [normalized.value] }
: normalized
}
if (Array.isArray(value)) {
if (!value.every((item) => typeof item === 'string')) {
return { ok: false, error: 'Use a string, string array, null, or false.' }
}
const normalized = normalizeKeybindingArrayForAction(actionId, value)
return Array.isArray(normalized)
? { ok: true, value: normalized }
: normalized.ok
? { ok: true, value: [normalized.value] }
: normalized
}
return { ok: false, error: 'Use a string, string array, null, or false.' }
}
function normalizeWriteBindingValue(actionId: KeybindingActionId, value: unknown): string[] | null {
if (value === null) {
return null
}
if (!Array.isArray(value) || !value.every((binding) => typeof binding === 'string')) {
throw new Error('Use a string array or null.')
}
const normalized = normalizeKeybindingArrayForAction(actionId, value)
if (!Array.isArray(normalized)) {
throw new Error(normalized.ok ? 'Unable to parse shortcut.' : normalized.error)
}
return normalized
}
function parseBindingSection(
value: unknown,
section: string,
diagnostics: KeybindingFileDiagnostic[],
options: { skipRootKeys?: boolean } = {}
): KeybindingOverrides {
if (value === undefined) {
return {}
}
if (!isJsonObject(value)) {
diagnostics.push({
severity: 'error',
section,
message: `${section} must be an object.`
})
return {}
}
const overrides: KeybindingOverrides = {}
for (const [actionId, rawBinding] of Object.entries(value)) {
if (options.skipRootKeys && ROOT_KEYS.has(actionId)) {
continue
}
if (!isKeybindingActionId(actionId)) {
diagnostics.push({
severity: 'warning',
section,
actionId,
message: `Unknown keybinding action "${actionId}" was ignored.`
})
continue
}
const normalized = normalizeBindingValue(actionId, rawBinding)
if (!normalized.ok) {
diagnostics.push({
severity: 'error',
section,
actionId,
message: `Shortcut for "${actionId}" was ignored: ${normalized.error}`
})
continue
}
overrides[actionId] = normalized.value
}
return overrides
}
function parsePlatformOverrides(
document: JsonObject,
diagnostics: KeybindingFileDiagnostic[]
): Partial<Record<KeybindingPlatform, KeybindingOverrides>> {
const rawPlatforms = document.platforms
if (rawPlatforms === undefined) {
return {}
}
if (!isJsonObject(rawPlatforms)) {
diagnostics.push({
severity: 'error',
section: 'platforms',
message: 'platforms must be an object with darwin, linux, or win32 sections.'
})
return {}
}
const result: Partial<Record<KeybindingPlatform, KeybindingOverrides>> = {}
for (const [platform, value] of Object.entries(rawPlatforms)) {
if (!PLATFORM_KEYS.includes(platform as KeybindingPlatform)) {
diagnostics.push({
severity: 'warning',
section: `platforms.${platform}`,
message: `Unknown platform "${platform}" was ignored.`
})
continue
}
result[platform as KeybindingPlatform] = parseBindingSection(
value,
`platforms.${platform}`,
diagnostics
)
}
return result
}
function removeConflictingOverrides(
platform: KeybindingPlatform,
overrides: KeybindingOverrides,
diagnostics: KeybindingFileDiagnostic[]
): KeybindingOverrides {
let next = { ...overrides }
for (let attempt = 0; attempt < 20; attempt++) {
const conflicts = findKeybindingConflicts(platform, next)
const conflictingOverrides = new Set<KeybindingActionId>()
for (const conflict of conflicts) {
for (const actionId of conflict.actionIds) {
if (Object.hasOwn(next, actionId)) {
conflictingOverrides.add(actionId)
}
}
}
if (conflictingOverrides.size === 0) {
return next
}
for (const actionId of conflictingOverrides) {
delete next[actionId]
}
diagnostics.push({
severity: 'error',
message: `Conflicting custom shortcuts were ignored: ${Array.from(conflictingOverrides)
.map((actionId) => getKeybindingDefinition(actionId)?.title ?? actionId)
.join(', ')}.`
})
}
return next
}
export function readKeybindingFile(
path: string,
platform: NodeJS.Platform = process.platform
+46 -168
View File
@@ -1,6 +1,3 @@
/* eslint-disable max-lines -- Why: the collector's host-process index,
history ring buffer, Electron bucketing, and worktree attribution live
together to keep one snapshot's orchestration in one place. */
/**
* Memory dashboard collector.
*
@@ -17,11 +14,9 @@
* renderer polls never produces overlapping child processes.
*/
import { basename } from 'node:path'
import { exec } from 'node:child_process'
import { promisify } from 'node:util'
import os from 'node:os'
import { splitWorktreeIdForFilesystem } from '../../shared/worktree/id'
import {
getProcessOutputFields,
iterateProcessOutputLines
@@ -30,7 +25,6 @@ import { getAppEnvironment, type AppEnvironment } from '../../shared/app-environ
import type {
AppMemory,
MemorySnapshot,
ProcessCommitMetric,
SessionMemory,
UsageValues,
WorktreeMemory
@@ -39,8 +33,24 @@ import type { Store } from '../persistence'
import { ORPHAN_WORKTREE_ID } from '../../shared/constants'
import { listRegisteredPtys } from './pty-registry'
import { enumerateWindowsProcessResources } from './windows-process-resource-collector'
import { collectHostMemory, fallbackHostMemory } from './host-memory'
import { collectHostMemory } from './host-memory'
import { getProcessMemoryMetric } from './process-memory-metric'
import {
createEmptyWorktreeMemoryBucket,
pushAppMemoryHistory,
pushMemoryHistorySample,
readAppMemoryHistory,
readMemoryHistory,
resolveWorktreeMemoryNames,
sweepStaleMemoryHistory,
type WorktreeMemoryBucket
} from './memory-snapshot-buckets'
import {
clampMemoryMetric,
emptyMemorySnapshot,
optionalCommitField,
snapshotCommitFields
} from './memory-snapshot-values'
export type MemorySnapshotStore = Pick<Store, 'getRepo' | 'getWorktreeMeta'>
@@ -61,7 +71,7 @@ export async function collectMemorySnapshot(store: MemorySnapshotStore): Promise
inflight = runSnapshot(store)
.catch((err) => {
console.warn('[memory] snapshot failed; returning empty', err)
return emptySnapshot()
return emptyMemorySnapshot()
})
.finally(() => {
inflight = null
@@ -99,88 +109,6 @@ type ProcIndex = {
hasPrivateMemory: boolean
}
const PROCESS_COMMIT_METRIC: ProcessCommitMetric = 'private-bytes'
/**
* The one rule for every committed-bytes key: present only when the sweep could
* measure it, because a 0 would read as "these processes commit nothing".
*/
function commitField(hasPrivateMemory: boolean, privateMemory: number): { privateMemory?: number } {
return hasPrivateMemory ? { privateMemory: clampNumber(privateMemory) } : {}
}
/** The snapshot-level pair, which names the unit alongside the total. */
function snapshotCommitFields(
hasPrivateMemory: boolean,
totalPrivateMemory: number
): Pick<MemorySnapshot, 'processCommitMetric' | 'totalPrivateMemory'> {
return hasPrivateMemory
? {
processCommitMetric: PROCESS_COMMIT_METRIC,
totalPrivateMemory: clampNumber(totalPrivateMemory)
}
: {}
}
function clampNumber(value: unknown): number {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return 0
}
return Math.max(0, value)
}
function emptySnapshot(): MemorySnapshot {
const zero = { cpu: 0, memory: 0 }
return {
app: { ...zero, main: zero, renderer: zero, other: zero, history: [] },
worktrees: [],
host: fallbackHostMemory(),
processMemoryMetric: getProcessMemoryMetric(),
totalCpu: 0,
totalMemory: 0,
collectedAt: Date.now()
}
}
// ─── History ring buffers ───────────────────────────────────────────
const APP_HISTORY_KEY = '__app__'
const HISTORY_CAPACITY = 60
const HISTORY_STALE_MS = 10 * 60 * 1000
type HistoryRing = {
samples: number[]
touchedAt: number
}
const historyByKey = new Map<string, HistoryRing>()
function pushHistorySample(key: string, memoryBytes: number, now: number): void {
let ring = historyByKey.get(key)
if (!ring) {
ring = { samples: [], touchedAt: now }
historyByKey.set(key, ring)
}
ring.samples.push(memoryBytes)
if (ring.samples.length > HISTORY_CAPACITY) {
ring.samples.shift()
}
ring.touchedAt = now
}
function readHistory(key: string): number[] {
const ring = historyByKey.get(key)
return ring ? [...ring.samples] : []
}
function sweepStaleHistory(now: number): void {
for (const [key, ring] of historyByKey) {
if (now - ring.touchedAt > HISTORY_STALE_MS) {
historyByKey.delete(key)
}
}
}
// ─── Host process enumeration ───────────────────────────────────────
async function enumerateProcesses(): Promise<ProcIndex> {
@@ -292,7 +220,7 @@ function electronMetricMemoryBytes(
// Why: on macOS, getAppEnvironment().getAppMetrics().workingSetSize can include large shared
// Chromium/Electron mappings. Prefer the host RSS sweep used elsewhere, but
// keep workingSetSize as a fallback when the process disappears mid-snapshot.
return clampNumber(proc.memory?.workingSetSize) * 1024
return clampMemoryMetric(proc.memory?.workingSetSize) * 1024
}
function bucketElectronMetrics(processIndex: ProcIndex): AppBucketsRaw {
@@ -301,11 +229,11 @@ function bucketElectronMetrics(processIndex: ProcIndex): AppBucketsRaw {
const other = { cpu: 0, memory: 0, privateMemory: 0 }
for (const proc of getAppEnvironment().getAppMetrics()) {
const cpu = clampNumber(proc.cpu?.percentCPUUsage)
const cpu = clampMemoryMetric(proc.cpu?.percentCPUUsage)
const memoryBytes = electronMetricMemoryBytes(proc, processIndex)
// Why the host row rather than Electron's own metric: getAppMetrics has no
// commit figure for helper processes, and the sweep already indexed them.
const privateBytes = clampNumber(processIndex.byPid.get(proc.pid)?.privateMemory)
const privateBytes = clampMemoryMetric(processIndex.byPid.get(proc.pid)?.privateMemory)
// Why: lowercase once so future Electron versions emitting different
// casing ('browser' vs 'Browser') still bucket correctly.
@@ -325,7 +253,7 @@ function bucketElectronMetrics(processIndex: ProcIndex): AppBucketsRaw {
const usage = (bucket: typeof main): UsageValues => ({
cpu: bucket.cpu,
memory: bucket.memory,
...commitField(processIndex.hasPrivateMemory, bucket.privateMemory)
...optionalCommitField(processIndex.hasPrivateMemory, bucket.privateMemory)
})
return {
@@ -340,61 +268,6 @@ function bucketElectronMetrics(processIndex: ProcIndex): AppBucketsRaw {
}
}
// ─── Worktree attribution ───────────────────────────────────────────
type WorktreeBucket = {
worktreeId: string
worktreeName: string
repoId: string
repoName: string
cpu: number
memory: number
privateMemory: number
sessions: SessionMemory[]
}
function resolveWorktreeNames(
worktreeId: string,
store: MemorySnapshotStore
): {
worktreeName: string
repoId: string
repoName: string
} {
// Orca worktree ids look like `${repoId}::${absolutePath}`.
const parsed = splitWorktreeIdForFilesystem(worktreeId)
const repoId = parsed?.repoId ?? worktreeId
const worktreePath = parsed?.worktreePath ?? ''
const fallbackName = worktreePath ? basename(worktreePath) : worktreeId
const meta = store.getWorktreeMeta(worktreeId)
const repo = store.getRepo(repoId)
return {
worktreeName: meta?.displayName?.trim() || fallbackName,
repoId,
repoName: repo?.displayName?.trim() || repoId || 'Unknown Repo'
}
}
function makeEmptyBucket(
worktreeId: string,
worktreeName: string,
repoId: string,
repoName: string
): WorktreeBucket {
return {
worktreeId,
worktreeName,
repoId,
repoName,
cpu: 0,
memory: 0,
privateMemory: 0,
sessions: []
}
}
// ─── Main collection path ───────────────────────────────────────────
async function runSnapshot(store: MemorySnapshotStore): Promise<MemorySnapshot> {
@@ -408,13 +281,13 @@ async function runSnapshot(store: MemorySnapshotStore): Promise<MemorySnapshot>
// attribute to the first PTY (registration order) to see each pid.
const claimed = new Set<number>()
const orphan = makeEmptyBucket(
const orphan = createEmptyWorktreeMemoryBucket(
ORPHAN_WORKTREE_ID,
'Unattributed terminals',
ORPHAN_WORKTREE_ID,
'Other'
)
const worktreeBuckets = new Map<string, WorktreeBucket>()
const worktreeBuckets = new Map<string, WorktreeMemoryBucket>()
for (const pty of ptys) {
let sessionCpu = 0
@@ -435,7 +308,7 @@ async function runSnapshot(store: MemorySnapshotStore): Promise<MemorySnapshot>
sessionMemory += row.memory
// Why the whole subtree: an agent's committed bytes live in the
// children it spawned (codex.exe, MCP servers), not in the shell.
sessionPrivateMemory += clampNumber(row.privateMemory)
sessionPrivateMemory += clampMemoryMetric(row.privateMemory)
}
}
@@ -443,19 +316,24 @@ async function runSnapshot(store: MemorySnapshotStore): Promise<MemorySnapshot>
sessionId: pty.sessionId ?? pty.ptyId,
paneKey: pty.paneKey,
pid: pty.pid ?? 0,
cpu: clampNumber(sessionCpu),
memory: clampNumber(sessionMemory),
...commitField(processIndex.hasPrivateMemory, sessionPrivateMemory)
cpu: clampMemoryMetric(sessionCpu),
memory: clampMemoryMetric(sessionMemory),
...optionalCommitField(processIndex.hasPrivateMemory, sessionPrivateMemory)
}
let bucket: WorktreeBucket
let bucket: WorktreeMemoryBucket
if (pty.worktreeId) {
const existing = worktreeBuckets.get(pty.worktreeId)
if (existing) {
bucket = existing
} else {
const names = resolveWorktreeNames(pty.worktreeId, store)
bucket = makeEmptyBucket(pty.worktreeId, names.worktreeName, names.repoId, names.repoName)
const names = resolveWorktreeMemoryNames(pty.worktreeId, store)
bucket = createEmptyWorktreeMemoryBucket(
pty.worktreeId,
names.worktreeName,
names.repoId,
names.repoName
)
worktreeBuckets.set(pty.worktreeId, bucket)
}
} else {
@@ -464,11 +342,11 @@ async function runSnapshot(store: MemorySnapshotStore): Promise<MemorySnapshot>
bucket.cpu += session.cpu
bucket.memory += session.memory
bucket.privateMemory += clampNumber(session.privateMemory)
bucket.privateMemory += clampMemoryMetric(session.privateMemory)
bucket.sessions.push(session)
}
const bucketList: WorktreeBucket[] = [...worktreeBuckets.values()]
const bucketList: WorktreeMemoryBucket[] = [...worktreeBuckets.values()]
if (orphan.sessions.length > 0) {
bucketList.push(orphan)
}
@@ -477,16 +355,16 @@ async function runSnapshot(store: MemorySnapshotStore): Promise<MemorySnapshot>
// returned arrays end with the freshly-collected value. Each write also
// acts as a keep-alive so active worktrees survive the staleness sweep.
const now = Date.now()
pushHistorySample(APP_HISTORY_KEY, appBuckets.memory, now)
pushAppMemoryHistory(appBuckets.memory, now)
for (const bucket of bucketList) {
pushHistorySample(bucket.worktreeId, bucket.memory, now)
pushMemoryHistorySample(bucket.worktreeId, bucket.memory, now)
}
sweepStaleHistory(now)
sweepStaleMemoryHistory(now)
const worktrees: WorktreeMemory[] = bucketList.map(({ privateMemory, ...b }) => ({
...b,
...commitField(processIndex.hasPrivateMemory, privateMemory),
history: readHistory(b.worktreeId)
...optionalCommitField(processIndex.hasPrivateMemory, privateMemory),
history: readMemoryHistory(b.worktreeId)
}))
let sessionCpuTotal = 0
@@ -495,17 +373,17 @@ async function runSnapshot(store: MemorySnapshotStore): Promise<MemorySnapshot>
for (const wt of worktrees) {
sessionCpuTotal += wt.cpu
sessionMemoryTotal += wt.memory
sessionPrivateTotal += clampNumber(wt.privateMemory)
sessionPrivateTotal += clampMemoryMetric(wt.privateMemory)
}
return {
app: { ...appBuckets, history: readHistory(APP_HISTORY_KEY) },
app: { ...appBuckets, history: readAppMemoryHistory() },
worktrees,
host,
processMemoryMetric: getProcessMemoryMetric(),
...snapshotCommitFields(
processIndex.hasPrivateMemory,
clampNumber(appBuckets.privateMemory) + sessionPrivateTotal
clampMemoryMetric(appBuckets.privateMemory) + sessionPrivateTotal
),
totalCpu: appBuckets.cpu + sessionCpuTotal,
totalMemory: appBuckets.memory + sessionMemoryTotal,
+101
View File
@@ -0,0 +1,101 @@
import { basename } from 'node:path'
import { splitWorktreeIdForFilesystem } from '../../shared/worktree/id'
import type { SessionMemory } from '../../shared/process-stats-types'
import type { MemorySnapshotStore } from './collector'
const APP_HISTORY_KEY = '__app__'
const HISTORY_CAPACITY = 60
const HISTORY_STALE_MS = 10 * 60 * 1000
type HistoryRing = {
samples: number[]
touchedAt: number
}
const historyByKey = new Map<string, HistoryRing>()
export function pushMemoryHistorySample(key: string, memoryBytes: number, now: number): void {
let ring = historyByKey.get(key)
if (!ring) {
ring = { samples: [], touchedAt: now }
historyByKey.set(key, ring)
}
ring.samples.push(memoryBytes)
if (ring.samples.length > HISTORY_CAPACITY) {
ring.samples.shift()
}
ring.touchedAt = now
}
export function readMemoryHistory(key: string): number[] {
const ring = historyByKey.get(key)
return ring ? [...ring.samples] : []
}
export function sweepStaleMemoryHistory(now: number): void {
for (const [key, ring] of historyByKey) {
if (now - ring.touchedAt > HISTORY_STALE_MS) {
historyByKey.delete(key)
}
}
}
export function pushAppMemoryHistory(memoryBytes: number, now: number): void {
pushMemoryHistorySample(APP_HISTORY_KEY, memoryBytes, now)
}
export function readAppMemoryHistory(): number[] {
return readMemoryHistory(APP_HISTORY_KEY)
}
export type WorktreeMemoryBucket = {
worktreeId: string
worktreeName: string
repoId: string
repoName: string
cpu: number
memory: number
privateMemory: number
sessions: SessionMemory[]
}
export function resolveWorktreeMemoryNames(
worktreeId: string,
store: MemorySnapshotStore
): {
worktreeName: string
repoId: string
repoName: string
} {
// Orca worktree ids look like `${repoId}::${absolutePath}`.
const parsed = splitWorktreeIdForFilesystem(worktreeId)
const repoId = parsed?.repoId ?? worktreeId
const worktreePath = parsed?.worktreePath ?? ''
const fallbackName = worktreePath ? basename(worktreePath) : worktreeId
const meta = store.getWorktreeMeta(worktreeId)
const repo = store.getRepo(repoId)
return {
worktreeName: meta?.displayName?.trim() || fallbackName,
repoId,
repoName: repo?.displayName?.trim() || repoId || 'Unknown Repo'
}
}
export function createEmptyWorktreeMemoryBucket(
worktreeId: string,
worktreeName: string,
repoId: string,
repoName: string
): WorktreeMemoryBucket {
return {
worktreeId,
worktreeName,
repoId,
repoName,
cpu: 0,
memory: 0,
privateMemory: 0,
sessions: []
}
}

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