diff --git a/.github/actions/install-node-dependencies/action.yml b/.github/actions/install-node-dependencies/action.yml index 39cb1465852..8b7801881eb 100644 --- a/.github/actions/install-node-dependencies/action.yml +++ b/.github/actions/install-node-dependencies/action.yml @@ -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 @@ -18,6 +30,7 @@ runs: - name: Setup pnpm uses: pnpm/action-setup@v6 with: + version: 10.24.0 run_install: false - name: Setup Node.js @@ -51,7 +64,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 @@ -74,18 +87,50 @@ runs: run: | pnpm install --frozen-lockfile --ignore-scripts # Job containers can run composite steps from a source mirror without .git. - git -C "$GITHUB_WORKSPACE" diff --exit-code -- package.json pnpm-lock.yaml + if [ "$(git -C "$GITHUB_WORKSPACE" rev-parse --is-inside-work-tree 2>/dev/null)" = true ]; then + git -C "$GITHUB_WORKSPACE" diff --exit-code -- package.json pnpm-lock.yaml + fi # Why cached: `--ignore-scripts` leaves node-pty without build/Release, so # ensure-native-runtime node-gyp-compiles it in every job that asks for a runtime. # 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', '.github/actions/install-node-dependencies/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', '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', '.github/actions/install-node-dependencies/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', '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' diff --git a/.github/workflows/computer-e2e.yml b/.github/workflows/computer-e2e.yml index ab1c6ce687b..b24c8a52f2c 100644 --- a/.github/workflows/computer-e2e.yml +++ b/.github/workflows/computer-e2e.yml @@ -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 diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 24c0ffa0e5b..00739f36350 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -17,6 +17,10 @@ on: description: JSON array of changed specs; empty runs the full suite required: false type: string + ssh_source_changed: + description: '"true" when the PR touches SSH execution source; gates the Docker-SSH lane' + required: false + type: string workflow_dispatch: inputs: ref: @@ -40,36 +44,10 @@ 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 + # Why: the build's plain-Node daemon smoke load resolves node-pty. + - 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. - - 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: node # Why: building here avoids parallel builds inside Playwright globalSetup; # paired-browser specs also need the standalone web bundle. @@ -130,11 +108,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 +121,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 +176,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 @@ -287,14 +225,21 @@ jobs: ssh-docker-watcher-isolation: name: ssh docker watcher isolation needs: build + # Why ssh_source_changed first: this lane used to trigger on SSH source only as a side + # effect of one route listing a startup-readiness spec — pruning that spec would have + # silently retired the whole lane. The signal is now derived from the SSH routes directly. + # The two spec clauses stay for their honest purpose: changed-e2e hands these specs to this + # lane, so editing one must still run it here. if: >- inputs.test_files == '' || + inputs.ssh_source_changed == 'true' || contains(inputs.test_files, 'tests/e2e/ssh-startup-exec-readiness.spec.ts') || contains(inputs.test_files, 'tests/e2e/paired-startup-exec-readiness.spec.ts') runs-on: ubuntu-latest - # Why 35: the parking, retention, startup-exec, and paired parity specs run - # serially on isolated Electron/SSH fixtures after watcher isolation. - timeout-minutes: 35 + # Why 60: this lane now also runs the remaining Docker-SSH specs serially. They average + # ~18s but several budget 4-10 minutes per test, so a slow run lands far above the old 35 + # — and the sharded lanes already show that a lane which times out is a lane nobody trusts. + timeout-minutes: 60 steps: - name: Checkout @@ -305,29 +250,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 @@ -346,6 +271,14 @@ jobs: if: always() run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker-terminal-parking + # Why here rather than the sharded lanes: the shards set no ORCA_E2E_SSH_DOCKER, so every + # spec below skipped itself while the shard still reported green. Running them on this one + # VM pays the fixture image build once instead of ten times, and keeps an SSH regression + # legible as an SSH-named failure. + - name: Run remaining Docker SSH E2E + if: always() + run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker + - name: Upload watcher isolation traces if: failure() uses: actions/upload-artifact@v7 diff --git a/.github/workflows/node-next-compat.yml b/.github/workflows/node-next-compat.yml new file mode 100644 index 00000000000..eb9de70dab7 --- /dev/null +++ b/.github/workflows/node-next-compat.yml @@ -0,0 +1,20 @@ +name: Node next compatibility + +on: + schedule: + # Full future-runtime coverage is useful, but not worth doubling every PR matrix. + - cron: '0 10 * * *' + workflow_dispatch: + +concurrency: + group: node-next-compat + cancel-in-progress: true + +permissions: + contents: read + +jobs: + test: + uses: ./.github/workflows/unit-tests.yml + with: + node_versions: '["26"]' diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index a256da5112c..338bcda7dbd 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -16,15 +16,29 @@ 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 }} + native_cache_changed: ${{ steps.filter.outputs.native_cache_changed }} + 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 +62,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 +82,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 +202,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 +230,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 +297,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 +327,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 @@ -429,17 +443,13 @@ jobs: src/shared/startup-shell-portability.live-shell.test.ts \ src/shared/posix-command-path-lookup.test.ts - test: - name: tests node ${{ matrix.node }} ${{ matrix.shard }}/${{ matrix.shard_total }} + # Cache-key input changes would otherwise make every shard compile the same + # native addon concurrently. Prime the supported Node ABI before the matrix fans out. + test_native_cache: + name: prepare test native cache node 24 needs: [code_paths] - if: needs.code_paths.outputs.should_run == 'true' + if: needs.code_paths.outputs.native_cache_changed == '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] steps: - name: Checkout @@ -450,39 +460,24 @@ jobs: - uses: ./.github/actions/install-node-dependencies with: native-runtime: node - node-version: ${{ matrix.node }} + node-version: '24' - - name: Install Electron package binary for tests - run: node config/scripts/install-electron-package-binary.mjs + test: + needs: [code_paths, test_native_cache] + if: >- + always() && + needs.code_paths.outputs.test == 'true' && + (needs.test_native_cache.result == 'success' || needs.test_native_cache.result == 'skipped') + uses: ./.github/workflows/unit-tests.yml + with: + node_versions: '["24"]' - - name: Test shard - run: | - pnpm exec vitest run --config config/vitest.config.ts \ - --exclude=src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts \ - --exclude=src/main/daemon/shell-ready.test.ts \ - --exclude=src/main/daemon/node-pty-fd-leak.test.ts \ - --exclude=src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts \ - --exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \ - --exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \ - --exclude=src/main/shell-startup-feature-channel.test.ts \ - --exclude=src/main/terminal-history-fish-session.node-pty.test.ts \ - --exclude=src/main/zsh-scoped-histfile.live-shell.test.ts \ - --exclude=src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts \ - --exclude=src/main/zsh-wrapper-version-mismatch.live-shell.test.ts \ - --exclude=src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \ - --exclude=src/shared/fish-query-reply-child-stdin.node-pty.test.ts \ - --exclude=src/shared/pty-reply-echo-shapes.node-pty.test.ts \ - --exclude=src/shared/startup-shell-portability.live-shell.test.ts \ - --exclude=src/shared/posix-command-path-lookup.test.ts \ - --exclude=tests/e2e/cross-version-wire/** \ - --shard=${{ matrix.shard }}/${{ matrix.shard_total }} - - # 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 +513,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 +545,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 +570,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 +642,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 +652,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 +662,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', '.github/actions/install-node-dependencies/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', '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 +712,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', '.github/actions/install-node-dependencies/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', '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 @@ -754,6 +759,7 @@ jobs: outputs: should_run: ${{ steps.filter.outputs.should_run }} test_files: ${{ steps.filter.outputs.test_files }} + ssh_source_changed: ${{ steps.filter.outputs.ssh_source_changed }} steps: - name: Checkout uses: actions/checkout@v6 @@ -776,6 +782,11 @@ jobs: # authorities, exclusions, and sentinels without evaluating workflow shell. TEST_FILES_JSON="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-e2e-source-routing.mjs)" echo "test_files=$TEST_FILES_JSON" >> "$GITHUB_OUTPUT" + # Why a separate signal: the Docker-SSH lane must trigger on SSH source, not on a + # spec name surviving in a route's list. Same routes, so the two cannot drift. + SSH_SOURCE_CHANGED="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-e2e-source-routing.mjs --ssh-source)" + echo "ssh_source_changed=$SSH_SOURCE_CHANGED" >> "$GITHUB_OUTPUT" + echo "SSH source changed: $SSH_SOURCE_CHANGED" if [ "$TEST_FILES_JSON" != '[]' ]; then echo "should_run=true" >> "$GITHUB_OUTPUT" echo "Changed E2E specs: $TEST_FILES_JSON" @@ -794,6 +805,7 @@ jobs: uses: ./.github/workflows/e2e.yml with: test_files: ${{ needs.e2e-paths.outputs.test_files }} + ssh_source_changed: ${{ needs.e2e-paths.outputs.ssh_source_changed }} verify: if: always() @@ -807,6 +819,7 @@ jobs: - shell_contracts - test - orcad_browser + - cross-version-wire - managed_hook_node18 - package - package_windows @@ -826,57 +839,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" diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml new file mode 100644 index 00000000000..9906b569324 --- /dev/null +++ b/.github/workflows/unit-tests.yml @@ -0,0 +1,59 @@ +name: Unit tests + +on: + workflow_call: + inputs: + node_versions: + description: JSON array of Node.js major versions to test. + required: true + type: string + +permissions: + contents: read + +jobs: + test: + name: tests node ${{ matrix.node }} ${{ matrix.shard }}/${{ matrix.shard_total }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node: ${{ fromJSON(inputs.node_versions) }} + shard: [1, 2, 3, 4, 5, 6, 7, 8] + shard_total: [8] + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + persist-credentials: false + + - uses: ./.github/actions/install-node-dependencies + with: + native-runtime: node + node-version: ${{ matrix.node }} + + - name: Install Electron package binary for tests + run: node config/scripts/install-electron-package-binary.mjs + + - name: Test shard + run: | + pnpm exec vitest run --config config/vitest.config.ts \ + --exclude=src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts \ + --exclude=src/main/daemon/shell-ready.test.ts \ + --exclude=src/main/daemon/node-pty-fd-leak.test.ts \ + --exclude=src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts \ + --exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \ + --exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \ + --exclude=src/main/shell-startup-feature-channel.test.ts \ + --exclude=src/main/terminal-history-fish-session.node-pty.test.ts \ + --exclude=src/main/zsh-scoped-histfile.live-shell.test.ts \ + --exclude=src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts \ + --exclude=src/main/zsh-wrapper-version-mismatch.live-shell.test.ts \ + --exclude=src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \ + --exclude=src/shared/fish-query-reply-child-stdin.node-pty.test.ts \ + --exclude=src/shared/pty-reply-echo-shapes.node-pty.test.ts \ + --exclude=src/shared/startup-shell-portability.live-shell.test.ts \ + --exclude=src/shared/posix-command-path-lookup.test.ts \ + --exclude=tests/e2e/cross-version-wire/** \ + --shard=${{ matrix.shard }}/${{ matrix.shard_total }} diff --git a/README.md b/README.md index 8d0b9e79653..8de0ff8d2b9 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere. -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.44](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) +[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.46](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) @@ -230,7 +230,7 @@ yay -S stably-orca-bin Pair with your desktop app to monitor and steer your agents from your phone. - **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) or [join TestFlight](https://testflight.apple.com/join/YjeGMQBA) -- **Android:** [Download APK 0.0.44](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) · [Install guide](https://www.onorca.dev/docs/android-apk) +- **Android:** [Download APK 0.0.46](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk) · [Install guide](https://www.onorca.dev/docs/android-apk) --- diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 664bb4c28b3..75e536a2198 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -2,17 +2,13 @@ # 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 inline src/main/browser/agent-browser-bridge.ts 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 +19,7 @@ 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 @@ -52,7 +41,6 @@ inline src/relay/git-handler.ts inline src/relay/pty-handler.ts inline src/relay/workspace-space-scan.ts inline src/renderer/src/components/GitLabItemDialog.tsx -inline src/renderer/src/components/JiraIssueWorkspace.tsx inline src/renderer/src/components/LinearItemDrawer.tsx inline src/renderer/src/components/TaskPage.tsx inline src/renderer/src/components/Terminal.tsx @@ -61,15 +49,12 @@ inline src/renderer/src/components/activity/ActivityPrototypePage.tsx inline src/renderer/src/components/automations/AutomationsPage.tsx inline src/renderer/src/components/editor/CombinedDiffViewer.tsx inline src/renderer/src/components/editor/MarkdownPreview.tsx -inline src/renderer/src/components/feature-wall/EditorAnimatedVisual.tsx inline src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx inline src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx -inline src/renderer/src/components/onboarding/use-onboarding-flow.ts 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 +67,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 diff --git a/config/patches/@vscode__windows-process-tree@0.8.0.patch b/config/patches/@vscode__windows-process-tree@0.8.0.patch index 8e6e8e86e25..65cf2be5ce4 100644 --- a/config/patches/@vscode__windows-process-tree@0.8.0.patch +++ b/config/patches/@vscode__windows-process-tree@0.8.0.patch @@ -1,26 +1,31 @@ diff --git a/binding.gyp b/binding.gyp -index 855bd4b86f0a3c18c7594212c0e42b6e35bc4001..5f15551cb1520af996f216500a83ac68b57f5104 100644 +index 855bd4b86f0a3c18c7594212c0e42b6e35bc4001..33774e7ae296f0de39dd94156673c9e773638bf4 100644 --- a/binding.gyp +++ b/binding.gyp -@@ -3,7 +3,7 @@ +@@ -3,7 +3,6 @@ { "target_name": "windows_process_tree", "dependencies": [ - "= 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) + } +} diff --git a/config/scripts/build-windows-cli-launcher.test.mjs b/config/scripts/build-windows-cli-launcher.test.mjs index 4bb219a5687..82332897715 100644 --- a/config/scripts/build-windows-cli-launcher.test.mjs +++ b/config/scripts/build-windows-cli-launcher.test.mjs @@ -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 { diff --git a/config/scripts/build-windows-process-tree-relay-addon.mjs b/config/scripts/build-windows-process-tree-relay-addon.mjs index 01045eadfbf..3e43beb359e 100644 --- a/config/scripts/build-windows-process-tree-relay-addon.mjs +++ b/config/scripts/build-windows-process-tree-relay-addon.mjs @@ -26,9 +26,11 @@ import { mkdirSync, openSync, readFileSync, - readSync + readSync, + writeFileSync } from 'node:fs' -import { join, resolve } from 'node:path' +import { createRequire } from 'node:module' +import { dirname, join, resolve } from 'node:path' import { RELAY_WINDOWS_PROCESS_TREE_FILENAME } from '../../src/shared/relay-artifacts.ts' const ROOT = resolve(import.meta.dirname, '..', '..') @@ -67,13 +69,16 @@ function assertPatchApplied() { 'config/patches/@vscode__windows-process-tree@0.8.0.patch; run pnpm install.' ) } - if (!bindingGyp.includes("require.resolve('node-addon-api/node_addon_api.gyp')")) { + if (bindingGyp.includes('node_addon_api.gyp')) { throw new Error( - 'binding.gyp still uses require("node-addon-api").targets. That path is ' + - 'cwd-relative and misses node_addon_api.gyp under pnpm on Windows. ' + + 'binding.gyp still depends on node_addon_api.gyp. pnpm and node-gyp rewrite that ' + + 'project path incorrectly on Windows. ' + 'pnpm did not apply config/patches/@vscode__windows-process-tree@0.8.0.patch; run pnpm install.' ) } + if (!bindingGyp.includes('"include_dirs": ["deps/node-addon-api"]')) { + throw new Error('binding.gyp does not use the staged node-addon-api headers.') + } const processCc = readFileSync(join(PACKAGE_DIR, 'src', 'process.cc'), 'utf8') if (processCc.includes('process_count < 1024')) { throw new Error( @@ -83,6 +88,58 @@ function assertPatchApplied() { } } +// pnpm can materialize this CRLF package without applying its patch. Repair the +// load-bearing build settings before node-gyp so the release build stays safe. +function applyWindowsProcessTreeBuildFixes() { + const bindingPath = join(PACKAGE_DIR, 'binding.gyp') + const processPath = join(PACKAGE_DIR, 'src', 'process.cc') + const nodeAddonApiDir = dirname( + createRequire(join(PACKAGE_DIR, 'package.json')).resolve('node-addon-api/package.json') + ) + const stagedHeaderDir = join(PACKAGE_DIR, 'deps', 'node-addon-api') + let bindingGyp = readFileSync(bindingPath, 'utf8') + let processCc = readFileSync(processPath, 'utf8') + const originalBinding = bindingGyp + const originalProcess = processCc + + for (const dynamicDependency of [ + String.raw` { ) 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') diff --git a/config/scripts/ensure-native-runtime.mjs b/config/scripts/ensure-native-runtime.mjs index b570ac0e7ae..424b671b11e 100644 --- a/config/scripts/ensure-native-runtime.mjs +++ b/config/scripts/ensure-native-runtime.mjs @@ -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,32 @@ 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 env = + process.platform === 'linux' && args.includes('node-gyp') + ? { ...process.env, CXXFLAGS: `${process.env.CXXFLAGS ?? ''} -std=gnu++2a`.trim() } + : process.env const result = spawnSync(command, args, { - cwd: projectDir, + cwd, stdio: 'inherit', - shell: process.platform === 'win32' + shell: process.platform === 'win32', + 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)) } diff --git a/config/scripts/ensure-native-runtime.test.mjs b/config/scripts/ensure-native-runtime.test.mjs index d1ea104b863..ea6e876e619 100644 --- a/config/scripts/ensure-native-runtime.test.mjs +++ b/config/scripts/ensure-native-runtime.test.mjs @@ -29,6 +29,7 @@ describe('ensure-native-runtime', () => { const binDir = join(projectDir, 'bin') copyFileSync(sourceScriptPath, scriptPath) writeFakeNativeModules(projectDir) + writeNodePtyPatchFile(projectDir) writeFakePnpm(binDir) const result = spawnSync(process.execPath, [scriptPath, '--runtime=node'], { @@ -42,7 +43,11 @@ 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')) + if (process.platform === 'linux') { + expect(log).toMatch(/^cxxflags=(?:.*\s)?-std=gnu\+\+2a$/m) + } expect(log.split('\n').filter((line) => line.startsWith('node-pty child '))).toEqual([ expect.stringMatching(/^node-pty child (?:conpty|pty) marker=false$/), expect.stringMatching(/^node-pty child (?:conpty|pty) marker=true$/) @@ -52,6 +57,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 +120,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 +154,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 +188,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 +218,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( @@ -197,15 +243,29 @@ exports.loadNativeModule = function loadNativeModule(nativeName) { if (!markerExists) { throw new Error('ABI mismatch sentinel') } + return { + dir: '../build/Release/', + module: { + listJobProcessIds() {}, + terminateJob() {}, + assignCurrentProcessToJob() {} + } + } } ` ) - 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 +278,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 +322,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 +344,15 @@ 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\` +) +appendFileSync( + process.env.ORCA_NATIVE_TEST_LOG, + \`cxxflags=\${process.env.CXXFLAGS || ''}\\n\` +) writeFileSync(process.env.ORCA_NATIVE_TEST_MARKER, 'rebuilt') ` ) diff --git a/config/scripts/install-node-dependencies-action.test.mjs b/config/scripts/install-node-dependencies-action.test.mjs index d989221c11a..a90747513a1 100644 --- a/config/scripts/install-node-dependencies-action.test.mjs +++ b/config/scripts/install-node-dependencies-action.test.mjs @@ -47,6 +47,33 @@ function executeInstallScript(fixture) { } describe('install-node-dependencies action', () => { + it('skips the lockfile diff when a job container has no Git metadata', () => { + const fixture = createFixture() + try { + rmSync(join(fixture.workspace, '.git'), { recursive: true, force: true }) + + const result = executeInstallScript(fixture) + expect(result.status, result.stderr || result.stdout).toBe(0) + expect(`${result.stdout}\n${result.stderr}`).not.toMatch(/^diff --git /m) + } finally { + rmSync(fixture.root, { recursive: true, force: true }) + } + }) + + it('skips the lockfile diff for a bare repository', () => { + const fixture = createFixture() + try { + rmSync(join(fixture.workspace, '.git'), { recursive: true, force: true }) + expect(run('git', ['init', '--bare', '-q'], { cwd: fixture.workspace }).status).toBe(0) + + const result = executeInstallScript(fixture) + expect(result.status, result.stderr || result.stdout).toBe(0) + expect(`${result.stdout}\n${result.stderr}`).not.toMatch(/^diff --git /m) + } finally { + rmSync(fixture.root, { recursive: true, force: true }) + } + }) + it.each([ ['package.json', '{"name":"changed"}\n'], ['pnpm-lock.yaml', 'lockfileVersion: 9\nchanged: true\n'] diff --git a/config/scripts/package-electron-runtime-contract.test.mjs b/config/scripts/package-electron-runtime-contract.test.mjs index 372218d903c..64b2f96505a 100644 --- a/config/scripts/package-electron-runtime-contract.test.mjs +++ b/config/scripts/package-electron-runtime-contract.test.mjs @@ -467,10 +467,11 @@ describe('Electron runtime package contract', () => { expect(copyStep.run).toContain('git add "$CASK_PATH"') }) - it('installs the Electron package binary in PR checks without changing native module ABI', () => { - const prWorkflow = readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8') - const parsedWorkflow = parse(prWorkflow) - const installStep = parsedWorkflow.jobs.test.steps.find( + it('installs the Electron package binary in the shared unit-test workflow', () => { + const unitTestWorkflow = parse( + readFileSync(join(projectDir, '.github/workflows/unit-tests.yml'), 'utf8') + ) + const installStep = unitTestWorkflow.jobs.test.steps.find( (step) => step.name === 'Install Electron package binary for tests' ) diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index d19eee52454..c51b1c18a5c 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -16,6 +16,196 @@ 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 NATIVE_CACHE_FILES = new Set([ + 'package.json', + 'pnpm-lock.yaml', + '.github/actions/install-node-dependencies/action.yml', + 'config/scripts/ensure-native-runtime.mjs', + 'config/scripts/rebuild-native-deps.mjs' +]) + +const NATIVE_CACHE_PREFIXES = [ + '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 +222,91 @@ 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, + native_cache_changed: shouldRun && (emptyDiff || changedFiles.some(isNativeCacheInputPath)), + ...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 isNativeCacheInputPath(file) { + return NATIVE_CACHE_FILES.has(file) || matchesPrefix(file, NATIVE_CACHE_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`) + } } diff --git a/config/scripts/pr-code-change-scope.test.mjs b/config/scripts/pr-code-change-scope.test.mjs index f71d6c42934..ea83796e6cb 100644 --- a/config/scripts/pr-code-change-scope.test.mjs +++ b/config/scripts/pr-code-change-scope.test.mjs @@ -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,170 @@ 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('primes native caches only when their immutable inputs change', () => { + expect(classifyPrJobs([]).native_cache_changed).toBe(true) + expect(classifyPrJobs(['README.md']).native_cache_changed).toBe(false) + expect(classifyPrJobs(['src/main/index.ts']).native_cache_changed).toBe(false) + for (const file of [ + 'package.json', + 'pnpm-lock.yaml', + '.github/actions/install-node-dependencies/action.yml', + 'config/scripts/ensure-native-runtime.mjs', + 'config/scripts/rebuild-native-deps.mjs', + 'config/patches/node-pty@1.1.0.patch' + ]) { + expect(classifyPrJobs([file]).native_cache_changed, file).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 +253,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', 'native_cache_changed', ...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,11 +266,26 @@ 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', () => { - for (const jobName of expensiveJobs) { + it('gates each expensive job on its classifier and cache prerequisite', () => { + for (const jobName of expensiveJobs.filter((jobName) => jobName !== 'test')) { 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'` + ) } + expect(prWorkflow.jobs.test.needs).toEqual(['code_paths', 'test_native_cache']) + expect(prWorkflow.jobs.test.if).toContain("needs.code_paths.outputs.test == 'true'") + expect(prWorkflow.jobs.test.if).toContain("needs.test_native_cache.result == 'success'") + expect(prWorkflow.jobs.test.if).toContain("needs.test_native_cache.result == 'skipped'") + expect(prWorkflow.jobs.test_native_cache.needs).toEqual(['code_paths']) + expect(prWorkflow.jobs.test_native_cache.if).toBe( + "needs.code_paths.outputs.native_cache_changed == 'true'" + ) + expect(prWorkflow.jobs.test_native_cache.strategy).toBeUndefined() + const primerInstall = prWorkflow.jobs.test_native_cache.steps.find( + (step) => step.uses === './.github/actions/install-node-dependencies' + ) + expect(primerInstall.with['node-version']).toBe('24') }) it('skips e2e detection on docs-only PRs without dropping the draft gate', () => { @@ -89,19 +295,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}"`) + } }) }) diff --git a/config/scripts/pr-e2e-gate-contract.test.mjs b/config/scripts/pr-e2e-gate-contract.test.mjs index 2c1243d04fa..8cd622b300c 100644 --- a/config/scripts/pr-e2e-gate-contract.test.mjs +++ b/config/scripts/pr-e2e-gate-contract.test.mjs @@ -1,9 +1,14 @@ -import { existsSync, readFileSync } from 'node:fs' +import { existsSync, readdirSync, readFileSync } from 'node:fs' import { join, resolve } from 'node:path' import { parse as parseJsonc } from 'jsonc-parser' import { describe, expect, it } from 'vitest' import { parse as parseYaml } from 'yaml' -import { PR_E2E_SOURCE_ROUTES, selectPrE2eSpecs } from './pr-e2e-source-routing.mjs' +import { + hasSshSourceChange, + PR_E2E_SOURCE_ROUTES, + selectPrE2eSpecs, + SSH_SOURCE_ROUTE_IDS +} from './pr-e2e-source-routing.mjs' const projectDir = resolve(import.meta.dirname, '../..') const prWorkflow = parseYaml(readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8')) @@ -100,17 +105,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 +164,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['native-runtime']).toBe('node') + 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) => @@ -195,6 +214,8 @@ describe('PR E2E gate contract', () => { 'src/main/providers/ssh-', 'src/main/ipc/pty', 'src/relay/', + 'src/shared/ssh-', + 'src/renderer/src/store/slices/direct-ssh-', 'src/renderer/src/components/terminal-pane/remote-runtime-' ] for (const authority of sshSourceAuthorities) { @@ -203,10 +224,24 @@ describe('PR E2E gate contract', () => { ) } + // Why named files rather than prefixes: these seams are single modules, and a prefix + // here would route their unrelated neighbours. + for (const file of [ + 'src/main/runtime/public-ssh-state.ts', + 'src/renderer/src/startup/ssh-startup-reconnect.ts', + 'src/renderer/src/store/slices/ssh.ts' + ]) { + expect(selectPrE2eSpecs([file]), file).toContain( + 'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts' + ) + } + const mappedSpecs = [ 'tests/e2e/pty-input-write-queue-ssh.spec.ts', 'tests/e2e/ssh-cold-activation-restore.spec.ts', 'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts', + 'tests/e2e/ssh-port-forward-lifecycle.spec.ts', + 'tests/e2e/ssh-reconnect-tab-destruction.spec.ts', 'tests/e2e/ssh-startup-exec-readiness.spec.ts', 'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts' ] @@ -235,6 +270,140 @@ describe('PR E2E gate contract', () => { expect(changedInstall.run).toContain('openssh-client') }) + it('routes direct-SSH workspace and tab restore from its unnamed source seams', () => { + // Why by name: none of these carry "ssh", so the SSH authorities above never reach them + // — a closed-tab tombstone and a dropped default-tabs marker both shipped through it. + for (const file of [ + 'src/renderer/src/hooks/remote-workspace-session-merge.ts', + 'src/main/ipc/remote-workspace-snapshot-normalization.ts', + 'src/renderer/src/lib/worktree-initial-terminal-seeding.ts', + 'src/renderer/src/lib/worktree-default-terminal-tabs.ts', + 'src/shared/remote-workspace-session-projection.ts', + 'src/renderer/src/components/terminal/initial-terminal.ts' + ]) { + const specs = selectPrE2eSpecs([file]) + expect(specs, file).toContain('tests/e2e/ssh-cold-activation-restore.spec.ts') + expect(specs, file).toContain('tests/e2e/ssh-reconnect-tab-destruction.spec.ts') + } + + expect( + selectPrE2eSpecs(['src/renderer/src/hooks/remote-workspace-session-merge.test.ts']) + ).toEqual([]) + }) + + it('triggers the Docker-SSH lane from SSH source, not from a spec name', () => { + // The behavioural half of the invariant, and the part that actually matters: an SSH source + // edit is recognised as one, through the same routes that select the specs. + for (const file of [ + 'src/main/ssh/connection.ts', + 'src/relay/pty-handler.ts', + 'src/renderer/src/store/slices/direct-ssh-pane-retry-ledger.ts', + 'src/renderer/src/hooks/remote-workspace-session-merge.ts', + 'src/main/ipc/remote-workspace-snapshot-normalization.ts' + ]) { + expect(hasSshSourceChange([file]), file).toBe(true) + } + for (const file of [ + 'src/main/git/git-status.ts', + 'src/renderer/src/components/tab-bar/BrowserTab.tsx', + 'src/main/ssh/connection.test.ts' + ]) { + expect(hasSshSourceChange([file]), file).toBe(false) + } + + // Why: the signal must stay derived from the routes. A route id that no longer exists would + // silently narrow it to nothing. + for (const id of SSH_SOURCE_ROUTE_IDS) { + expect( + PR_E2E_SOURCE_ROUTES.map((route) => route.id), + id + ).toContain(id) + } + + // Why text and not structure: a job `if:` is only ever available as a string. The strongest + // available assertion is that the source signal is its own disjunct, so the lane no longer + // depends on a spec name surviving in a route's spec list. + const sshLaneCondition = e2eWorkflow.jobs['ssh-docker-watcher-isolation'].if + expect(sshLaneCondition).toContain("inputs.ssh_source_changed == 'true' ||") + + expect(e2eWorkflow.on.workflow_call.inputs.ssh_source_changed.type).toBe('string') + expect(prWorkflow.jobs['e2e-paths'].outputs.ssh_source_changed).toBe( + '${{ steps.filter.outputs.ssh_source_changed }}' + ) + expect(prWorkflow.jobs.e2e.with.ssh_source_changed).toBe( + '${{ needs.e2e-paths.outputs.ssh_source_changed }}' + ) + expect(filterStep.run).toContain('pr-e2e-source-routing.mjs --ssh-source') + expect(filterStep.run).toContain('ssh_source_changed=$SSH_SOURCE_CHANGED') + }) + + it('gives every Docker-gated SSH spec a lane that runs it', () => { + // Why this shape: the sharded lanes set no ORCA_E2E_SSH_DOCKER, so a Docker-gated spec + // that no runner names runs nowhere and still reports green — the silent skip this file + // exists to prevent. Asserting reachability rather than a literal keeps that true when + // the lanes move. + // Why these two are exempt: each needs something CI cannot give it, recorded in + // run-ssh-docker-e2e.mjs so the gap stays legible rather than looking like coverage. + const unreachableSpecs = new Set([ + 'tests/e2e/ssh-docker-relay-perf.spec.ts', + 'tests/e2e/ssh-codex-display-artifacts-repro.spec.ts', + 'tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts' + ]) + // Why comments are stripped: this file's own runner lists the two exempt specs by name in a + // prose comment. A substring scan over raw text would count any spec merely *discussed* in a + // runner as claimed by it -- the silent skip this assertion exists to catch, re-entering + // through the documentation. + const stripComments = (text) => + text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '') + const laneRunners = [ + 'run-ssh-docker-e2e.mjs', + 'run-ssh-docker-watcher-isolation-e2e.mjs', + 'run-ssh-docker-terminal-parking-e2e.mjs' + ].map((file) => stripComments(readFileSync(join(projectDir, 'config/scripts', file), 'utf8'))) + + // Why a comparison and not the bare name: preview and demo specs cite the flag in a + // "how to run me" comment without gating on it. Why a regex rather than one literal: an + // equally-valid spelling (double quotes, or a `!==` guard) would escape a fixed-string scan + // and the spec would silently leave the contract. + const dockerGateExpression = /ORCA_E2E_SSH_DOCKER\s*[!=]==\s*['"]1['"]/ + const dockerGatedSpecs = readdirSync(join(projectDir, 'tests/e2e')) + .filter((file) => file.endsWith('.spec.ts')) + .map((file) => `tests/e2e/${file}`) + .filter((spec) => dockerGateExpression.test(readFileSync(join(projectDir, spec), 'utf8'))) + expect(dockerGatedSpecs.length).toBeGreaterThan(0) + + const unclaimed = dockerGatedSpecs.filter( + (spec) => !unreachableSpecs.has(spec) && !laneRunners.some((runner) => runner.includes(spec)) + ) + expect( + unclaimed, + `Docker-gated specs claimed by no lane runner: ${unclaimed.join(', ')}` + ).toEqual([]) + + // Why: an exemption that outlives its spec would quietly excuse a real gap. + for (const spec of unreachableSpecs) { + expect(dockerGatedSpecs, spec).toContain(spec) + // Why also assert absence from every runner: `unreachableSpecs` short-circuits the + // unclaimed check above, so a spec could be documented as exempt while a runner still + // invokes it -- an exemption that reads as coverage removal but changes nothing, and a + // lane that stays red for a reason the file says it excluded. + for (const runner of laneRunners) { + expect(runner.includes(spec), `${spec} is exempt but still invoked by a lane runner`).toBe( + false + ) + } + } + + const laneStep = e2eWorkflow.jobs['ssh-docker-watcher-isolation'].steps.find( + (step) => step.name === 'Run remaining Docker SSH E2E' + ) + expect(laneStep.run).toContain('test:e2e:ssh-docker') + // Why: the added serial tests, several budgeting 4-10 minutes each, do not fit the old 35. + expect( + e2eWorkflow.jobs['ssh-docker-watcher-isolation']['timeout-minutes'] + ).toBeGreaterThanOrEqual(60) + }) + it('scopes the VM rollback oracle to the PR range and recipe schema authorities', () => { expect(rollbackStep.run).toContain('--merge-base "$BASE_SHA" "$HEAD_SHA"') expect(rollbackStep.run).toContain('src/shared/ephemeral-vm-recipes.ts') diff --git a/config/scripts/pr-e2e-source-routing.mjs b/config/scripts/pr-e2e-source-routing.mjs index 04f570f0094..108a4d5ab10 100644 --- a/config/scripts/pr-e2e-source-routing.mjs +++ b/config/scripts/pr-e2e-source-routing.mjs @@ -18,12 +18,32 @@ export const PR_E2E_SOURCE_ROUTES = [ 'tests/e2e/pty-input-write-queue-ssh.spec.ts', 'tests/e2e/ssh-cold-activation-restore.spec.ts', 'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts', + 'tests/e2e/ssh-port-forward-lifecycle.spec.ts', + 'tests/e2e/ssh-reconnect-tab-destruction.spec.ts', 'tests/e2e/ssh-startup-exec-readiness.spec.ts', 'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts' ], + // Why the store/startup/shared additions: the SSH-named authorities stop at the main + // process and the pane component, but the reconnect ledgers and retained-payload + // admission that decide whether a pane rebinds live in the renderer store. matches: (file) => isProductSource(file) && - /^(?:src\/main\/ssh\/|src\/main\/providers\/ssh-|src\/main\/ipc\/(?:ssh-|pty)|src\/relay\/|src\/renderer\/src\/components\/terminal-pane\/(?:pty-|ssh-|remote-runtime-|terminal-parked-pty))/.test( + /^(?:src\/main\/ssh\/|src\/main\/providers\/ssh-|src\/main\/ipc\/(?:ssh-|pty)|src\/main\/runtime\/(?:public-ssh-state|ssh-file-explorer-chunk-read)\.ts|src\/relay\/|src\/shared\/(?:ssh-|skill-ssh-relay-contract)|src\/renderer\/src\/startup\/(?:ssh-startup-reconnect|startup-ssh-connection-restore)\.ts|src\/renderer\/src\/store\/slices\/(?:ssh|direct-ssh-)|src\/renderer\/src\/components\/terminal-pane\/(?:pty-|ssh-|remote-runtime-|terminal-parked-pty))/.test( + file + ) + }, + { + // Why a sibling route rather than more paths on ssh-terminal-source: these modules carry + // no "ssh" in their names, and only the two restore specs gate them. Folding them in + // would run the whole SSH terminal list for a tab-tombstone edit. + id: 'ssh-workspace-session-restore', + specs: [ + 'tests/e2e/ssh-cold-activation-restore.spec.ts', + 'tests/e2e/ssh-reconnect-tab-destruction.spec.ts' + ], + matches: (file) => + isProductSource(file) && + /^(?:src\/main\/ipc\/remote-workspace|src\/shared\/remote-workspace-|src\/renderer\/src\/hooks\/remote-workspace-|src\/renderer\/src\/lib\/worktree-(?:initial-terminal-seeding|default-terminal-tabs)\.ts|src\/renderer\/src\/components\/terminal\/initial-terminal)/.test( file ) }, @@ -131,6 +151,18 @@ export function selectPrE2eSpecs(changedPaths, reportRoute = () => undefined) { return [...specs].sort((left, right) => left.localeCompare(right)) } +/** Routes whose authorities are SSH execution source, and so require the Docker-SSH lane. */ +export const SSH_SOURCE_ROUTE_IDS = ['ssh-terminal-source', 'ssh-workspace-session-restore'] + +// Why derive this from the routes instead of a second path list: the Docker-SSH lane used to +// trigger only because one route happened to list a startup-readiness spec, so pruning that +// spec would have silently retired the lane. Two lists that must agree is how that drifted. +export function hasSshSourceChange(changedPaths) { + return PR_E2E_SOURCE_ROUTES.filter((route) => SSH_SOURCE_ROUTE_IDS.includes(route.id)).some( + (route) => changedPaths.some(route.matches) + ) +} + if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { let input = '' process.stdin.setEncoding('utf8') @@ -138,6 +170,10 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) input += chunk } const changedPaths = input.split(/\r?\n/).filter(Boolean) - const specs = selectPrE2eSpecs(changedPaths, (message) => console.error(message)) - process.stdout.write(`${JSON.stringify(specs)}\n`) + if (process.argv.includes('--ssh-source')) { + process.stdout.write(`${hasSshSourceChange(changedPaths)}\n`) + } else { + const specs = selectPrE2eSpecs(changedPaths, (message) => console.error(message)) + process.stdout.write(`${JSON.stringify(specs)}\n`) + } } diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs index 42540f6bcb4..29f532b9436 100644 --- a/config/scripts/pr-workflow-parallelism.test.mjs +++ b/config/scripts/pr-workflow-parallelism.test.mjs @@ -3,6 +3,8 @@ import { parse } from 'yaml' import { describe, expect, it } from 'vitest' const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8')) +const unitTestWorkflow = parse(readFileSync('.github/workflows/unit-tests.yml', 'utf8')) +const nodeNextWorkflow = parse(readFileSync('.github/workflows/node-next-compat.yml', 'utf8')) const dependencyAction = parse( readFileSync('.github/actions/install-node-dependencies/action.yml', 'utf8') ) @@ -47,22 +49,35 @@ describe('PR workflow parallelism', () => { expect(workflow.permissions).toEqual({ contents: 'read' }) }) - it('shards the general test suite across Node 24 and Node 26', () => { - expect(workflow.jobs.test.strategy.matrix.node).toEqual(['24', '26']) - expect(workflow.jobs.test.strategy.matrix.shard).toEqual( - Array.from({ length: 16 }, (_, index) => index + 1) + it('runs Node 24 on PRs and the same eight-shard suite on Node 26 daily', () => { + const sharedTest = unitTestWorkflow.jobs.test + const testStep = sharedTest.steps.find((step) => step.name === 'Test shard') + const installStep = sharedTest.steps.find( + (step) => step.uses === './.github/actions/install-node-dependencies' ) - expect(workflow.jobs.test.strategy.matrix.shard_total).toEqual([16]) - const testStep = workflow.jobs.test.steps.find((step) => step.name === 'Test shard') - const installStep = workflow.jobs.test.steps.find( + const primerInstall = workflow.jobs.test_native_cache.steps.find( (step) => step.uses === './.github/actions/install-node-dependencies' ) + expect(workflow.jobs.test.uses).toBe('./.github/workflows/unit-tests.yml') + expect(JSON.parse(workflow.jobs.test.with.node_versions)).toEqual(['24']) + expect(nodeNextWorkflow.jobs.test.uses).toBe('./.github/workflows/unit-tests.yml') + expect(JSON.parse(nodeNextWorkflow.jobs.test.with.node_versions)).toEqual(['26']) + expect(nodeNextWorkflow.on.schedule).toHaveLength(1) + expect(nodeNextWorkflow.on.workflow_dispatch).toBeNull() + expect(sharedTest.strategy.matrix.node).toBe('${{ fromJSON(inputs.node_versions) }}') + expect(sharedTest.strategy.matrix.shard).toEqual( + Array.from({ length: 8 }, (_, index) => index + 1) + ) + expect(sharedTest.strategy.matrix.shard_total).toEqual([8]) expect(installStep.with['node-version']).toBe('${{ matrix.node }}') expect(testStep.run).toContain('--shard=${{ matrix.shard }}/${{ matrix.shard_total }}') for (const testFile of nativeShellContractFiles) { expect(testStep.run).toContain(`--exclude=${testFile}`) } + expect(primerInstall.with['native-runtime']).toBe('node') + expect(primerInstall.with['node-version']).toBe('24') + expect(workflow.jobs.test.needs).toContain('test_native_cache') }) it('runs real-shell coverage once outside the general shards', () => { @@ -208,6 +223,8 @@ describe('PR workflow parallelism', () => { expect(pnpmIndex).toBeLessThan(nodeIndex) expect(pnpmIndex).toBeLessThan(requestedNodeIndex) + const packageManagerVersion = /^pnpm@([^+]+)/.exec(packageJson.packageManager)?.[1] + expect(steps[pnpmIndex].with.version).toBe(packageManagerVersion) expect(steps[nodeIndex].with.cache).toBe('pnpm') expect(steps[nodeIndex].if).toBe("inputs.node-version == ''") expect(steps[requestedNodeIndex].if).toBe("inputs.node-version != ''") @@ -232,22 +249,24 @@ describe('PR workflow parallelism', () => { workflow.jobs[jobName].steps.find( (step) => step.uses === './.github/actions/install-node-dependencies' ) + const sharedTestInstall = unitTestWorkflow.jobs.test.steps.find( + (step) => step.uses === './.github/actions/install-node-dependencies' + ) - for (const jobName of [ - '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(sharedTestInstall.with['native-runtime']).toBe('node') expect(installFor('package').with['native-runtime']).toBe('electron') + expect(installFor('package_windows').with['native-runtime']).toBe('node') + expect(installFor('package_windows').with['persist-native-cache']).toBe('false') + 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 +314,42 @@ 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.key).toContain('.github/actions/install-node-dependencies/action.yml') + expect(cacheStep.with.key).toContain('config/scripts/ensure-native-runtime.mjs') + expect(cacheStep.with.key).toContain('config/scripts/rebuild-native-deps.mjs') + 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 +393,7 @@ describe('PR workflow parallelism', () => { 'shell_contracts', 'test', 'orcad_browser', + 'cross-version-wire', 'managed_hook_node18', 'package', 'package_windows' @@ -359,5 +407,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"') }) }) diff --git a/config/scripts/rebuild-native-deps.mjs b/config/scripts/rebuild-native-deps.mjs index 78e25d3634c..4eca4bac33a 100644 --- a/config/scripts/rebuild-native-deps.mjs +++ b/config/scripts/rebuild-native-deps.mjs @@ -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 } diff --git a/config/scripts/rebuild-native-deps.test.mjs b/config/scripts/rebuild-native-deps.test.mjs index b158ee57f21..5ebc39dfcaa 100644 --- a/config/scripts/rebuild-native-deps.test.mjs +++ b/config/scripts/rebuild-native-deps.test.mjs @@ -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'), '') diff --git a/config/scripts/run-ssh-docker-e2e.mjs b/config/scripts/run-ssh-docker-e2e.mjs new file mode 100644 index 00000000000..a723a9a6ad0 --- /dev/null +++ b/config/scripts/run-ssh-docker-e2e.mjs @@ -0,0 +1,95 @@ +import { spawnSync } from 'node:child_process' + +const rawExtraArgs = process.argv.slice(2) +const extraArgs = rawExtraArgs[0] === '--' ? rawExtraArgs.slice(1) : rawExtraArgs +const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm' +const env = { + ...process.env, + ORCA_E2E_SSH_DOCKER: '1', + ORCA_E2E_WEB_CLIENT: '1' +} + +// Why: Node's CVE-2024-27980 hardening rejects .cmd spawns without shell on Windows. +const spawnOptions = { + stdio: 'inherit', + env, + shell: process.platform === 'win32' +} + +const runtime = spawnSync(pnpm, ['run', 'ensure:electron-runtime'], spawnOptions) + +if (runtime.status !== 0) { + process.exit(runtime.status ?? 1) +} + +// Why one explicit list: these specs self-skip without ORCA_E2E_SSH_DOCKER and no sharded lane +// sets it, so a spec in no runner runs nowhere. The gate contract proves every flag-reading +// spec is claimed here, by the watcher-isolation or parking runner, or by a listed exclusion. +// +// Deliberately absent, and therefore still covered by no CI trigger: +// ssh-docker-relay-perf.spec.ts — wall-clock latency thresholds; flaky budgets here would +// cost the lane its credibility. NOTE: a runner script test:e2e:ssh-docker-perf exists in +// package.json but NO workflow invokes it, so this spec currently runs in no CI lane at +// all. Recorded as a real gap, not as coverage living somewhere else. +// ssh-codex-display-artifacts-repro.spec.ts — installs a real remote codex binary that CI +// runners do not have (observed as `spawn codex ENOENT`). Runs in no CI lane at all. +// ssh-docker-bulk-open-freeze-repro.spec.ts — two reasons, both disqualifying: +// (a) it is a perf oracle, not a correctness one: SOFT_FREEZE_LAG_MS=2500 / +// HARD_FREEZE_LAG_MS=5000 measured by a renderer lag probe under a deliberate +// 5-pane output flood on a 420s budget. Same rule as ssh-docker-relay-perf above. +// (b) it is ROTTED: four call sites are out of date against terminal.ts's current +// helpers — execInTerminal gained a ptyId parameter and splitActiveTerminalPane +// gained a direction, so it cannot compile, let alone pass. Repairing it needs two +// semantic decisions (which ptyId to capture, which split direction) that change +// what the repro measures. Tracked in stablyai/orca#16764. +// +// Why both projects: ssh-port-forward-lifecycle is @headful, which the headless project +// grep-inverts away. +// +// Known gaps in SSH e2e coverage, recorded here because nothing else names them: +// - The job that runs this is still called `ssh-docker-watcher-isolation`, though watcher +// isolation is now one spec of many. Renaming it changes the GitHub check name and can +// break required-check config, so the name understates the job on purpose. +// - E2E does not gate merges: `verify.needs` in pr.yml omits `e2e` while the suite is red on +// main. Nothing in this lane blocks a PR yet. pr.yml's Require-successful-checks comment +// has the exact wiring to flip it, and the gate contract asserts the current state. +// - Five specs and one unit test are gated on env vars no workflow sets, so they run nowhere +// and are not Docker-gated, which puts them outside this file's contract: +// local-ssh-browser-routing (ORCA_E2E_LOCAL_SSH_BROWSER) +// ssh-client-hosted-browser-drop-reconnect (ORCA_E2E_SSH_CLIENT_HOSTED_BROWSER) +// nested-runtime-ssh-lifecycle, nested-runtime-ssh-routing (ORCA_E2E_NESTED_RUNTIME_SSH) +// ssh-localhost (ORCA_E2E_SSH_LOCALHOST) +// ssh-browser-network-execution-route.docker.unit.test.ts (ORCA_RUN_DOCKER_SSH_BROWSER_E2E) +// Runner scripts for the first four sit unused in package.json; no workflow calls them. +const result = spawnSync( + pnpm, + [ + 'exec', + 'playwright', + 'test', + 'tests/e2e/pty-input-write-queue-ssh.spec.ts', + 'tests/e2e/ssh-ai-vault-session-history.spec.ts', + 'tests/e2e/ssh-cold-activation-restore.spec.ts', + 'tests/e2e/ssh-cold-hydration-gap-tab-seeding.spec.ts', + 'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts', + 'tests/e2e/ssh-external-image-preview.spec.ts', + 'tests/e2e/ssh-lost-kill-tab-resurrection.spec.ts', + 'tests/e2e/ssh-pi-compatible-agent-title.spec.ts', + 'tests/e2e/ssh-port-forward-lifecycle.spec.ts', + 'tests/e2e/ssh-reconnect-tab-destruction.spec.ts', + 'tests/e2e/ssh-restart-tab-accumulation.spec.ts', + 'tests/e2e/ssh-skill-installation.spec.ts', + 'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts', + '--config', + 'tests/playwright.config.ts', + '--project', + 'electron-headless', + '--project', + 'electron-headful', + '--workers=1', + ...extraArgs + ], + spawnOptions +) + +process.exit(result.status ?? 1) diff --git a/config/scripts/skills-cli-package-workflow.test.mjs b/config/scripts/skills-cli-package-workflow.test.mjs index 489437dd61f..07f7beb46ac 100644 --- a/config/scripts/skills-cli-package-workflow.test.mjs +++ b/config/scripts/skills-cli-package-workflow.test.mjs @@ -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') diff --git a/config/scripts/windows-process-tree-gyp-path.test.mjs b/config/scripts/windows-process-tree-gyp-path.test.mjs index 5a3aa465737..e2e650384ec 100644 --- a/config/scripts/windows-process-tree-gyp-path.test.mjs +++ b/config/scripts/windows-process-tree-gyp-path.test.mjs @@ -9,26 +9,30 @@ const PATCH = readFileSync( 'utf8' ) const PACKAGE_DIR = join(projectDir, 'node_modules', '@vscode', 'windows-process-tree') -const ABSOLUTE_GYP = "require.resolve('node-addon-api/node_addon_api.gyp')" +const RESOLVED_GYP = "require.resolve('node-addon-api/node_addon_api.gyp')" describe('windows-process-tree node-addon-api gyp path', () => { - it('keeps the gyp project path absolute so pnpm Windows source builds find it', () => { - expect(PATCH).toContain( - `+ " { + expect(PATCH).not.toContain('+ "../../node-addon-api') + expect(PATCH).toContain('+ "include_dirs": ["deps/node-addon-api"],') + expect(PATCH).toContain('+ "defines": ["NAPI_CPP_EXCEPTIONS", "_HAS_EXCEPTIONS=1"],') + const buildScript = readFileSync( + join(projectDir, 'config/scripts/build-windows-process-tree-relay-addon.mjs'), + 'utf8' ) - const bindingGyp = readFileSync(join(PACKAGE_DIR, 'binding.gyp'), 'utf8') - expect(bindingGyp).toContain(ABSOLUTE_GYP) - expect(bindingGyp).not.toContain("require('node-addon-api').targets") - expect( - readFileSync( - join(projectDir, 'config/scripts/build-windows-process-tree-relay-addon.mjs'), - 'utf8' - ) - ).toContain(ABSOLUTE_GYP) + expect(buildScript).toContain( + "for (const header of ['napi.h', 'napi-inl.h', 'napi-inl.deprecated.h'])" + ) + expect(buildScript).toContain("import { createRequire } from 'node:module'") + expect(buildScript).toContain("import { dirname, join, resolve } from 'node:path'") + expect(buildScript).toContain( + "createRequire(join(PACKAGE_DIR, 'package.json')).resolve('node-addon-api/package.json')" + ) + expect(buildScript).toContain('Repaired un-applied pnpm patch hunks before build.') }) it('resolves node_addon_api.gyp to a real file from the package directory', () => { - const resolved = execFileSync(process.execPath, ['-p', ABSOLUTE_GYP], { + const resolved = execFileSync(process.execPath, ['-p', RESOLVED_GYP], { cwd: PACKAGE_DIR, encoding: 'utf8' }).trim() diff --git a/config/scripts/windows-pty-native-capability-workflow.test.mjs b/config/scripts/windows-pty-native-capability-workflow.test.mjs index 9a6b8b285cf..e1742a43bb0 100644 --- a/config/scripts/windows-pty-native-capability-workflow.test.mjs +++ b/config/scripts/windows-pty-native-capability-workflow.test.mjs @@ -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,22 @@ 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') + for (const cache of [nodeCacheSave, electronCache]) { + expect(cache.with.key).toContain('.github/actions/install-node-dependencies/action.yml') + expect(cache.with.key).toContain('config/scripts/ensure-native-runtime.mjs') + expect(cache.with.key).toContain('config/scripts/rebuild-native-deps.mjs') + } + 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') diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index d604b83bf97..49201fa15cc 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 30m + + downloads: 31m @@ -15,7 +15,7 @@ downloads downloads - 30m - 30m + 31m + 31m diff --git a/docs/readme/README.es.md b/docs/readme/README.es.md index 86f04ea9fdf..638220150b5 100644 --- a/docs/readme/README.es.md +++ b/docs/readme/README.es.md @@ -36,7 +36,7 @@ Supervisa y dirige a tus agentes desde el teléfono — recibe una notificación cuando un agente termine y envía instrucciones de seguimiento desde cualquier lugar. -[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) +[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) @@ -227,7 +227,7 @@ yay -S stably-orca-bin Vincúlala con tu app de escritorio para supervisar y dirigir a tus agentes desde el teléfono. - **iOS:** [Descargar desde App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) +- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk) --- diff --git a/docs/readme/README.fr.md b/docs/readme/README.fr.md index b015c26449a..c9bb161fe8e 100644 --- a/docs/readme/README.fr.md +++ b/docs/readme/README.fr.md @@ -40,7 +40,7 @@ Surveillez et pilotez vos agents depuis votre téléphone — soyez notifié quand un agent termine, et envoyez des instructions de suivi où que vous soyez. -[App Store iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.44](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) +[App Store iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.46](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) @@ -235,7 +235,7 @@ yay -S stably-orca-bin Associez-la à l'app de bureau pour surveiller et piloter vos agents depuis votre téléphone. - **iOS :** [Télécharger sur l'App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) ou [rejoindre TestFlight](https://testflight.apple.com/join/YjeGMQBA) -- **Android :** [Télécharger l'APK 0.0.44](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) +- **Android :** [Télécharger l'APK 0.0.46](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk) --- diff --git a/docs/readme/README.ja.md b/docs/readme/README.ja.md index 3b1992448fe..cc589be1cc5 100644 --- a/docs/readme/README.ja.md +++ b/docs/readme/README.ja.md @@ -36,7 +36,7 @@ スマートフォンからエージェントを監視・操作 — エージェントの完了を通知で受け取り、どこからでもフォローアップを送信できます。 -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile) +[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile) @@ -227,7 +227,7 @@ yay -S stably-orca-bin デスクトップアプリとペアリングして、スマートフォンからエージェントを監視・操作できます。 - **iOS:** [App Store からダウンロード](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) +- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk) --- diff --git a/docs/readme/README.ko.md b/docs/readme/README.ko.md index 6e53e590463..1de67052fcb 100644 --- a/docs/readme/README.ko.md +++ b/docs/readme/README.ko.md @@ -36,7 +36,7 @@ 휴대폰에서 에이전트를 모니터링하고 조종하세요 — 에이전트가 완료되면 알림을 받고 어디서든 후속 지시를 보낼 수 있습니다. -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.44](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile) +[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.46](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile) @@ -230,7 +230,7 @@ yay -S stably-orca-bin 데스크톱 앱과 페어링해 휴대폰에서 에이전트를 모니터링하고 조종하세요. - **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217) 또는 [TestFlight 참여](https://testflight.apple.com/join/YjeGMQBA) -- **Android:** [APK 0.0.44 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) · [설치 가이드](https://www.onorca.dev/docs/android-apk) +- **Android:** [APK 0.0.46 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk) · [설치 가이드](https://www.onorca.dev/docs/android-apk) --- diff --git a/docs/readme/README.pt.md b/docs/readme/README.pt.md index 9f86dc2cfa3..970fcf6c8a0 100644 --- a/docs/readme/README.pt.md +++ b/docs/readme/README.pt.md @@ -36,7 +36,7 @@ Monitore e conduza seus agentes pelo celular — receba uma notificação quando um agente terminar e envie instruções de acompanhamento de qualquer lugar. -[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.44](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) +[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.46](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) @@ -230,7 +230,7 @@ yay -S stably-orca-bin Conecte ao app desktop para monitorar e conduzir seus agentes pelo celular. - **iOS:** [Baixar na App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) ou [entrar no TestFlight](https://testflight.apple.com/join/YjeGMQBA) -- **Android:** [Baixar APK 0.0.44](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) +- **Android:** [Baixar APK 0.0.46](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk) --- diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md index 0c619ea260d..2e11413aa2a 100644 --- a/docs/readme/README.zh-CN.md +++ b/docs/readme/README.zh-CN.md @@ -36,7 +36,7 @@ 用手机监控并指挥你的智能体 — 智能体完成时收到通知,随时随地发送后续指令。 -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile) +[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile) @@ -227,7 +227,7 @@ yay -S stably-orca-bin 与桌面应用配对,用手机监控并指挥你的智能体。 - **iOS:** [从 App Store 下载](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) +- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk) --- diff --git a/docs/reference/windows-process-enumeration.md b/docs/reference/windows-process-enumeration.md index e4c91329c46..11e39b35b7e 100644 --- a/docs/reference/windows-process-enumeration.md +++ b/docs/reference/windows-process-enumeration.md @@ -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. diff --git a/electron.vite.config.ts b/electron.vite.config.ts index f81dbb93041..4faa844ee4c 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -209,6 +209,7 @@ export const electronViteConfig: UserConfig = { index: resolve('src/main/index.ts'), // Why: sandboxed webview preloads cannot load Rollup helper chunks. 'browser-window-close-preload': resolve('src/preload/browser-window-close.ts'), + 'doc-preview-link-preload': resolve('src/preload/doc-preview-link.ts'), 'daemon-entry': resolve('src/main/daemon/daemon-entry.ts'), 'plugin-host-entry': resolve('src/main/plugins/plugin-host-entry.ts'), 'computer-sidecar': resolve('src/main/computer/sidecar-entry.ts'), diff --git a/mobile/.oxlintrc.json b/mobile/.oxlintrc.json index 877a6d7e123..f3338fc3616 100644 --- a/mobile/.oxlintrc.json +++ b/mobile/.oxlintrc.json @@ -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": { diff --git a/mobile/app/connection-log.tsx b/mobile/app/connection-log.tsx index e2a9b73ff7f..1ab79c50269 100644 --- a/mobile/app/connection-log.tsx +++ b/mobile/app/connection-log.tsx @@ -1,20 +1,35 @@ -import { useCallback, useEffect, useState, useSyncExternalStore } from 'react' +import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react' import { View, Text, StyleSheet, Pressable, Platform } from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' -import { useRouter } from 'expo-router' +import { useLocalSearchParams, useRouter } from 'expo-router' import * as Clipboard from 'expo-clipboard' import Constants from 'expo-constants' -import { ChevronLeft, Copy, Check } from 'lucide-react-native' +import { ChevronLeft, Copy, Check, Send } from 'lucide-react-native' import { colors, spacing, typography } from '../src/theme/mobile-theme' import { ConnectionLog } from '../src/components/ConnectionLog' import { loadHosts } from '../src/transport/host-store' -import { connectionLogStore } from '../src/transport/connection-log-buffer' -import { useHostClient } from '../src/transport/client-context' +import { connectionLogStore } from '../src/transport/persisted-connection-log-store' +import { useHostClient, useRpcClientContext } from '../src/transport/client-context' import { - useLastConnectedAt, + useConnectionPathStatus, useReconnectAttempt } from '../src/transport/client-context-connection-metrics' import { buildConnectionDiagnosticsReport } from '../src/diagnostics/connection-diagnostics-report' +import { + diagnoseConnection, + getReportableConnectionIncidentId +} from '../src/diagnostics/connection-diagnostics-analysis' +import { submitConnectionDiagnostics } from '../src/diagnostics/connection-diagnostics-submission' +import { + readHydratedConnectionLog, + readConnectionDiagnosticsSnapshot, + resolveDiagnosticsHostId, + getDiagnosticsSubmissionState, + updateDiagnosticsSubmissionState, + type DiagnosticsSubmissionStates +} from '../src/diagnostics/connection-diagnostics-screen-data' +import { useHostStatusGates } from '../src/transport/host-status-gates' +import { loadHostAppVersion } from '../src/transport/host-app-version-store' import type { ConnectionLogEntry, HostProfile } from '../src/transport/types' // Why: getSnapshot must be referentially stable when there's no data — @@ -25,11 +40,19 @@ const EMPTY_ENTRIES: readonly ConnectionLogEntry[] = [] // screen also *acquires* the host client — opening it kicks a dial and the // log fills live instead of showing a stale tail. export default function ConnectionLogScreen() { + const clientContext = useRpcClientContext() const router = useRouter() + const params = useLocalSearchParams<{ hostId?: string }>() const insets = useSafeAreaInsets() + const routeKey = useMemo(() => ({}), [params.hostId]) const [hosts, setHosts] = useState([]) - const [selectedId, setSelectedId] = useState(null) - const [copied, setCopied] = useState(false) + const [manualSelection, setManualSelection] = useState<{ + hostId: string + requestedHostId: string | undefined + routeKey: object + } | null>(null) + const [copiedHostId, setCopiedHostId] = useState(null) + const [submissionStates, setSubmissionStates] = useState({}) useEffect(() => { let stale = false @@ -38,17 +61,28 @@ export default function ConnectionLogScreen() { return } setHosts(loaded) - setSelectedId((prev) => prev ?? loaded[0]?.id ?? null) }) return () => { stale = true } }, []) + const selectedId = resolveDiagnosticsHostId(hosts, params.hostId, manualSelection, routeKey) const selected = hosts.find((h) => h.id === selectedId) ?? null - const { state } = useHostClient(selected?.id) + const { client, state } = useHostClient(selected?.id) + const { desktopAppVersion: liveDesktopAppVersion } = useHostStatusGates({ + hostId: selected?.id, + client, + connState: state + }) const reconnectAttempts = useReconnectAttempt(selected?.id) - const lastConnectedAt = useLastConnectedAt(selected?.id) + const { activePath, pendingPath } = useConnectionPathStatus(selected?.id) + + useEffect(() => { + if (selectedId) { + void readHydratedConnectionLog(connectionLogStore, selectedId) + } + }, [selectedId]) const subscribe = useCallback( (listener: () => void) => @@ -60,25 +94,93 @@ export default function ConnectionLogScreen() { [selectedId] ) const entries = useSyncExternalStore(subscribe, getSnapshot) + const diagnosis = selected + ? diagnoseConnection({ endpoint: selected.endpoint, state, activePath, pendingPath, entries }) + : null + const incidentId = selected + ? getReportableConnectionIncidentId({ + endpoint: selected.endpoint, + state, + activePath, + pendingPath, + entries + }) + : null + const submissionKey = selected && incidentId ? `${selected.id}:${incidentId}` : null + const submissionState = getDiagnosticsSubmissionState(submissionStates, submissionKey) + const copied = copiedHostId === selectedId const copyDiagnostics = useCallback(async () => { if (!selected) { return } + const desktopAppVersion = liveDesktopAppVersion ?? (await loadHostAppVersion(selected.id)) + const snapshot = await readConnectionDiagnosticsSnapshot( + clientContext, + connectionLogStore, + selected.id + ) const report = buildConnectionDiagnosticsReport({ hostName: selected.name, endpoint: selected.endpoint, - state, - reconnectAttempts, - lastConnectedAt, + state: snapshot.state, + reconnectAttempts: snapshot.reconnectAttempts, + lastConnectedAt: snapshot.lastConnectedAt, platform: `${Platform.OS} ${Platform.Version ?? ''}`.trim(), appVersion: Constants.expoConfig?.version ?? 'unknown', - entries + desktopAppVersion, + entries: snapshot.entries, + activePath: snapshot.activePath, + pendingPath: snapshot.pendingPath }) await Clipboard.setStringAsync(report) - setCopied(true) - setTimeout(() => setCopied(false), 2000) - }, [selected, state, reconnectAttempts, lastConnectedAt, entries]) + setCopiedHostId(selected.id) + setTimeout(() => setCopiedHostId((hostId) => (hostId === selected.id ? null : hostId)), 2000) + }, [selected, liveDesktopAppVersion, clientContext]) + + const sendDiagnostics = useCallback(async () => { + if (!selected || !submissionKey || submissionState === 'sending') { + return + } + const startedKey = submissionKey + setSubmissionStates((states) => updateDiagnosticsSubmissionState(states, startedKey, 'sending')) + const appVersion = Constants.expoConfig?.version ?? 'unknown' + const platform = `${Platform.OS} ${Platform.Version ?? ''}`.trim() + const desktopAppVersion = liveDesktopAppVersion ?? (await loadHostAppVersion(selected.id)) + const snapshot = await readConnectionDiagnosticsSnapshot( + clientContext, + connectionLogStore, + selected.id + ) + const currentIncidentId = getReportableConnectionIncidentId({ + endpoint: selected.endpoint, + state: snapshot.state, + activePath: snapshot.activePath, + pendingPath: snapshot.pendingPath, + entries: snapshot.entries + }) + if (`${selected.id}:${currentIncidentId ?? ''}` !== startedKey) { + setSubmissionStates((states) => updateDiagnosticsSubmissionState(states, startedKey, null)) + return + } + const report = buildConnectionDiagnosticsReport({ + hostName: selected.name, + endpoint: selected.endpoint, + state: snapshot.state, + reconnectAttempts: snapshot.reconnectAttempts, + lastConnectedAt: snapshot.lastConnectedAt, + platform, + appVersion, + desktopAppVersion, + entries: snapshot.entries, + activePath: snapshot.activePath, + pendingPath: snapshot.pendingPath + }) + const result = await submitConnectionDiagnostics({ report, appVersion, platform }) + setSubmissionStates((states) => + updateDiagnosticsSubmissionState(states, startedKey, result.ok ? 'sent' : 'failed') + ) + }, [selected, submissionKey, submissionState, liveDesktopAppVersion, clientContext]) return ( @@ -86,7 +188,7 @@ export default function ConnectionLogScreen() { router.back()}> - Connection log + Network diagnostics {hosts.length > 1 && ( @@ -95,7 +197,9 @@ export default function ConnectionLogScreen() { setSelectedId(host.id)} + onPress={() => + setManualSelection({ hostId: host.id, requestedHostId: params.hostId, routeKey }) + } > )} - {copied ? 'Copied' : 'Copy diagnostics'} + {copied ? 'Copied' : 'Copy report'} + {diagnosis && ( + + What this suggests + {diagnosis.likelyCause} + {diagnosis.nextStep} + {diagnosis.reportability === 'orca-relay' && ( + <> + + Sends a size-limited redacted report including host name, endpoint, versions, + connection state, and events—never terminal contents or credentials. + + void sendDiagnostics()} + disabled={submissionState === 'sending'} + > + {submissionState === 'sent' ? ( + + ) : ( + + )} + + {submissionState === 'sending' + ? 'Sending…' + : submissionState === 'sent' + ? 'Diagnostics sent' + : submissionState === 'failed' + ? 'Retry sending' + : 'Send diagnostics to Orca'} + + + + )} + + )} {entries.length > 0 ? ( - + ) : ( - No connection events yet this session. Events appear as the app dials this host. + No connection events yet. Events appear as the app dials this host. )} @@ -199,6 +338,53 @@ const styles = StyleSheet.create({ fontSize: typography.metaSize, color: colors.textSecondary }, + diagnosisCard: { + backgroundColor: colors.bgPanel, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.borderSubtle, + borderRadius: 10, + padding: spacing.md, + marginBottom: spacing.md + }, + diagnosisHeading: { + fontSize: typography.metaSize, + fontWeight: '600', + color: colors.textPrimary, + marginBottom: spacing.xs + }, + diagnosisText: { + fontSize: typography.metaSize, + color: colors.textPrimary, + lineHeight: 18 + }, + diagnosisNext: { + fontSize: typography.metaSize, + color: colors.textSecondary, + lineHeight: 18, + marginTop: spacing.xs + }, + privacyHint: { + marginTop: spacing.sm, + fontSize: 11, + lineHeight: 15, + color: colors.textMuted + }, + sendButton: { + marginTop: spacing.md, + alignSelf: 'flex-start', + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + paddingVertical: spacing.sm, + paddingHorizontal: spacing.md, + borderRadius: 8, + backgroundColor: colors.bgRaised + }, + sendButtonText: { + fontSize: typography.metaSize, + fontWeight: '600', + color: colors.textPrimary + }, copyButton: { flexDirection: 'row', alignItems: 'center', diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx index 3400fd3694b..18ebe3efe3d 100644 --- a/mobile/app/h/[hostId]/index.tsx +++ b/mobile/app/h/[hostId]/index.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback, useMemo, useRef } from 'react' -import { View, Text, StyleSheet, SectionList, Pressable, Alert, RefreshControl } from 'react-native' +import { View, Text, SectionList, Pressable, Alert, RefreshControl } from 'react-native' import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context' import { useFocusEffect, useLocalSearchParams, usePathname, useRouter } from 'expo-router' import { @@ -63,14 +63,16 @@ import { ConfirmModal } from '../../../src/components/ConfirmModal' import { BottomDrawer } from '../../../src/components/BottomDrawer' import { useHostProtocolGates } from '../../../src/components/HostProtocolGate' import { AuthFailedBanner } from '../../../src/components/AuthFailedBanner' +import { HostDiagnosticsLink } from '../../../src/components/HostDiagnosticsLink' import { HostRouteNoticeBanner } from '../../../src/components/HostRouteNoticeBanner' import { visibleHostRouteNotice } from '../../../src/host-route-notice' import { MobileSearchField } from '../../../src/components/MobileSearchField' import { WorkspaceDetailPlaceholder } from '../../../src/components/WorkspaceDetailPlaceholder' import { getCachedWorktrees, setCachedWorktrees } from '../../../src/cache/worktree-cache' import { setCachedRepos } from '../../../src/cache/repo-cache' -import { colors, radii, spacing, typography } from '../../../src/theme/mobile-theme' +import { colors, spacing } from '../../../src/theme/mobile-theme' import { useResponsiveLayout } from '../../../src/layout/responsive-layout' +import { hostScreenStyles as styles } from '../../../src/host-screen/host-screen-styles' import { leaveHostRoute } from '../../../src/host-route-exit' import { loadPinnedIds, savePinnedIds } from '../../../src/storage/preferences' import { @@ -1090,6 +1092,17 @@ export function HostScreen({ /> )} + {connState !== 'connected' && + !relayRecovery.pairingRejected && + reconnectAttempts >= 3 && + hostId ? ( + + router.push({ pathname: '/connection-log', params: { hostId: String(hostId) } }) + } + /> + ) : null} + {/* Why a bounced route landed here (e.g. the workspace was deleted on the desktop). */} {routeNotice && ( } - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: colors.bgBase - }, - topChrome: { - backgroundColor: colors.bgPanel, - borderBottomWidth: 1, - borderBottomColor: colors.borderSubtle - }, - statusBar: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - minHeight: 34, - paddingTop: spacing.xs, - paddingHorizontal: spacing.lg - }, - backButton: { - width: 32, - height: 32, - alignItems: 'center', - justifyContent: 'center', - marginRight: spacing.xs - }, - sidebarCollapseButton: { - width: 24, - height: 24, - alignItems: 'center', - justifyContent: 'center', - borderRadius: radii.button, - marginLeft: spacing.xs - }, - hostIdentity: { - flex: 1, - flexDirection: 'row', - alignItems: 'center', - minWidth: 0, - marginRight: spacing.md - }, - hostNameText: { - flex: 1, - fontSize: 15, - fontWeight: '600', - color: colors.textPrimary - }, - reconnectButton: { - paddingVertical: 4, - paddingHorizontal: spacing.sm, - borderRadius: radii.button, - backgroundColor: colors.bgPanel, - borderWidth: 1, - borderColor: colors.borderSubtle - }, - reconnectButtonText: { - color: colors.textPrimary, - fontSize: typography.metaSize, - fontWeight: '600' - }, - toolbar: { - flexDirection: 'row', - alignItems: 'center', - paddingVertical: spacing.xs + 2, - paddingHorizontal: spacing.md, - gap: spacing.sm, - borderBottomWidth: 1, - borderBottomColor: colors.borderSubtle - }, - embeddedToolbar: { - paddingVertical: spacing.xs + 2, - paddingHorizontal: spacing.sm, - gap: spacing.xs, - borderBottomWidth: 1, - borderBottomColor: colors.borderSubtle - }, - embeddedToolbarRow: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.sm - }, - embeddedFilterChip: { - flex: 1, - minWidth: 0, - height: 30, - justifyContent: 'center', - paddingHorizontal: spacing.xs, - paddingVertical: 0 - }, - embeddedModeButton: { - flex: 1, - minWidth: 0, - height: 30, - justifyContent: 'center', - paddingHorizontal: spacing.xs, - paddingVertical: 0 - }, - filterChip: { - flexDirection: 'row', - alignItems: 'center', - gap: 4, - paddingHorizontal: spacing.sm + 2, - paddingVertical: spacing.xs, - borderRadius: 12, - borderWidth: 1, - borderColor: colors.borderSubtle - }, - filterChipActive: { - borderColor: colors.textSecondary, - backgroundColor: colors.bgRaised - }, - filterChipText: { - fontSize: 12, - color: colors.textSecondary - }, - filterChipTextActive: { - color: colors.textPrimary - }, - modeButton: { - flexDirection: 'row', - alignItems: 'center', - flexShrink: 1, - minWidth: 0, - gap: 4, - paddingHorizontal: spacing.sm, - paddingVertical: spacing.xs - }, - sortLabel: { - flexShrink: 1, - minWidth: 0, - fontSize: 12, - color: colors.textSecondary - }, - toolbarSpacer: { - flex: 1 - }, - floatingWorkspaceHeaderButton: { - width: 32, - height: 32, - alignItems: 'center', - justifyContent: 'center', - marginLeft: spacing.xs - }, - embeddedToolbarIconButton: { - flex: 1, - height: 28, - alignItems: 'center', - justifyContent: 'center', - borderRadius: radii.button - }, - toolbarIconDisabled: { - opacity: 0.6 - }, - searchToggle: { - padding: spacing.xs - }, - searchBar: { - paddingHorizontal: spacing.md, - paddingVertical: spacing.sm, - borderBottomWidth: StyleSheet.hairlineWidth, - borderBottomColor: colors.borderSubtle, - backgroundColor: colors.bgPanel - }, - centered: { - flex: 1, - alignItems: 'center', - justifyContent: 'center' - }, - emptyText: { - color: colors.textSecondary, - fontSize: typography.bodySize - }, - errorText: { - color: colors.statusRed, - fontSize: typography.bodySize - }, - list: { - paddingBottom: spacing.lg - }, - sectionHeader: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: spacing.lg, - paddingTop: spacing.md, - paddingBottom: spacing.xs - }, - sectionIcon: { - marginRight: spacing.xs - }, - sectionRepoIcon: { - marginRight: spacing.xs - }, - sectionTitle: { - fontSize: 11, - fontWeight: '600', - color: colors.textMuted, - textTransform: 'uppercase', - letterSpacing: 0.5 - }, - sectionCount: { - fontSize: 11, - color: colors.textMuted, - marginLeft: spacing.xs - }, - separator: { - height: 1, - backgroundColor: colors.borderSubtle, - marginLeft: spacing.lg + 24, - marginRight: spacing.lg - }, - filterModalHeader: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingHorizontal: spacing.xs, - marginBottom: spacing.md - }, - filterModalTitle: { - fontSize: 15, - fontWeight: '600', - color: colors.textPrimary - }, - clearFiltersText: { - fontSize: 13, - color: colors.textSecondary - }, - filterSectionLabel: { - fontSize: 11, - fontWeight: '600', - color: colors.textMuted, - textTransform: 'uppercase', - letterSpacing: 0.5, - marginBottom: spacing.xs, - paddingHorizontal: spacing.xs - }, - filterGroup: { - backgroundColor: colors.bgPanel, - borderRadius: 12, - overflow: 'hidden', - marginBottom: spacing.md - }, - filterRow: { - flexDirection: 'row', - alignItems: 'center', - paddingVertical: spacing.md, - paddingHorizontal: spacing.md + 2, - gap: spacing.sm - }, - filterRowText: { - flex: 1, - fontSize: typography.bodySize, - color: colors.textPrimary - }, - filterSeparator: { - height: StyleSheet.hairlineWidth, - backgroundColor: colors.borderSubtle, - marginHorizontal: spacing.md - }, - filterRepoDot: { - width: 8, - height: 8, - borderRadius: 4 - }, - confirmContent: { - paddingBottom: spacing.lg - }, - confirmTitle: { - fontSize: 16, - fontWeight: '700', - color: colors.textPrimary - }, - confirmMessage: { - fontSize: typography.bodySize, - color: colors.textSecondary, - marginTop: spacing.xs, - lineHeight: 20 - }, - confirmButtons: { - flexDirection: 'row', - gap: spacing.sm - }, - confirmBtn: { - flex: 1, - paddingVertical: spacing.sm + 2, - borderRadius: 10, - alignItems: 'center' - }, - confirmBtnCancel: { - backgroundColor: colors.bgPanel - }, - confirmBtnDestructive: { - backgroundColor: colors.statusRed - }, - confirmBtnPressed: { - opacity: 0.7 - }, - confirmBtnCancelText: { - fontSize: typography.bodySize, - fontWeight: '600', - color: colors.textSecondary - }, - confirmBtnDestructiveText: { - fontSize: typography.bodySize, - fontWeight: '600', - color: '#fff' - } -}) diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 14a94513507..0d36fa96020 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -117,6 +117,10 @@ import { } from '../../../../src/terminal/terminal-accessory-layout' import { createTerminalLiveAccessoryInput } from '../../../../src/terminal/terminal-live-accessory-input' import { sendTerminalLiveAccessoryRawBytes } from '../../../../src/terminal/terminal-live-accessory-raw-send' +import { + createTerminalAccessoryRepeatController, + createTerminalAccessoryRepeatSender +} from '../../../../src/terminal/terminal-accessory-repeat' import { clearTerminalLiveInputFocusTimer, isTerminalLiveInputWithinByteLimit, @@ -2993,16 +2997,22 @@ export default function SessionScreen() { } } - async function handleAccessoryKey(input: ReturnType) { - if (!client || !activeHandle || !canSend) { - return + async function handleAccessoryKey( + input: ReturnType, + targetHandle = activeHandleRef.current, + isDeliveryTargetCurrent: () => boolean = () => targetHandle === activeHandleRef.current + ) { + if (!client || !targetHandle || !isDeliveryTargetCurrent() || !canSend) { + return false } - const targetHandle = activeHandle const accessoryCommit = await handleLiveInputAccessoryBytes(input) - if (accessoryCommit.kind !== 'allow-raw') { - return + if (!isDeliveryTargetCurrent()) { + return false } - await sendTerminalLiveAccessoryRawBytes({ + if (accessoryCommit.kind !== 'allow-raw') { + return accessoryCommit.kind === 'handled' + } + return sendTerminalLiveAccessoryRawBytes({ client: clientRef.current, targetHandle, activeHandle: activeHandleRef.current, @@ -3399,32 +3409,37 @@ export default function SessionScreen() { } } - // Why: hold-to-repeat matches iOS cadence (400ms then 45ms); non-repeatable keys fire once (holding is destructive). - const repeatTimeoutRef = useRef | null>(null) - const repeatIntervalRef = useRef | null>(null) - // Why: ref keeps repeat firing the current callback; else a mid-hold tab switch/reconnect routes bytes to a stale terminal. + const accessoryRepeatRef = useRef( + createTerminalAccessoryRepeatController>() + ) + // Why: the current callback observes reconnect state while the sender pins the press to its original terminal. const handleAccessoryKeyRef = useRef(handleAccessoryKey) handleAccessoryKeyRef.current = handleAccessoryKey const stopAccessoryRepeat = useCallback(() => { - if (repeatTimeoutRef.current) { - clearTimeout(repeatTimeoutRef.current) - repeatTimeoutRef.current = null - } - if (repeatIntervalRef.current) { - clearInterval(repeatIntervalRef.current) - repeatIntervalRef.current = null - } + accessoryRepeatRef.current.stop() }, []) const startAccessoryRepeat = useCallback( (input: ReturnType) => { - stopAccessoryRepeat() - repeatTimeoutRef.current = setTimeout(() => { - repeatIntervalRef.current = setInterval(() => { - void handleAccessoryKeyRef.current(input) - }, 45) - }, 400) + // Why: a held repeat must not resume through a replacement client or connection. + const targetClient = clientRef.current + const targetConnectedAt = targetClient?.getLastConnectedAt() ?? null + const isDeliveryTargetCurrent = (targetHandle: string) => + activeHandleRef.current === targetHandle && + targetClient !== null && + clientRef.current === targetClient && + connStateRef.current === 'connected' && + targetClient.getLastConnectedAt() === targetConnectedAt + accessoryRepeatRef.current.start( + input, + createTerminalAccessoryRepeatSender( + activeHandleRef.current, + isDeliveryTargetCurrent, + (nextInput, targetHandle, isTargetCurrent) => + handleAccessoryKeyRef.current(nextInput, targetHandle, isTargetCurrent) + ) + ) }, - [stopAccessoryRepeat] + [] ) const setMobileSessionRootRef = useCallback( (node: View | null): void => { @@ -3440,15 +3455,14 @@ export default function SessionScreen() { clearPendingLiveInputCommit() sessionTabActionSheetRequestSeqRef.current += 1 clearSessionTabActionSheetKeyboardListener() - stopAccessoryRepeat() + accessoryRepeatRef.current.cancel() }, [ clearPendingLiveInputCommit, clearDelayedActionTimers, clearSessionTabActionSheetKeyboardListener, clearTerminalCache, - clearToastHideTimer, - stopAccessoryRepeat + clearToastHideTimer ] ) @@ -4848,7 +4862,6 @@ export default function SessionScreen() { return } const input = createTerminalLiveAccessoryInput(key) - void handleAccessoryKey(input) startAccessoryRepeat(input) }} onPressOut={() => { diff --git a/mobile/app/pair-scan.tsx b/mobile/app/pair-scan.tsx index c78d7a907df..61e99f19f2a 100644 --- a/mobile/app/pair-scan.tsx +++ b/mobile/app/pair-scan.tsx @@ -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() { ) } - -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' - } -}) diff --git a/mobile/app/troubleshoot.tsx b/mobile/app/troubleshoot.tsx index 5ae53e0084c..c4313e88fcc 100644 --- a/mobile/app/troubleshoot.tsx +++ b/mobile/app/troubleshoot.tsx @@ -222,7 +222,7 @@ export default function TroubleshootScreen() { onPress={() => router.push('/connection-log')} > - View connection log + View network diagnostics {checks.length > 0 && ( diff --git a/mobile/scripts/repro-terminal-color-output.ts b/mobile/scripts/repro-terminal-color-output.ts new file mode 100644 index 00000000000..50e4e153b54 --- /dev/null +++ b/mobile/scripts/repro-terminal-color-output.ts @@ -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 { + 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 +} diff --git a/mobile/scripts/repro-terminal-colors.ts b/mobile/scripts/repro-terminal-colors.ts index 3c21df64176..e6c302652a0 100644 --- a/mobile/scripts/repro-terminal-colors.ts +++ b/mobile/scripts/repro-terminal-colors.ts @@ -10,10 +10,13 @@ * ORCA_MOBILE_WS_URL=ws://127.0.0.1:6768 \ * pnpm exec tsx scripts/repro-terminal-colors.ts [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 [handleA] [handleB]' @@ -158,9 +151,13 @@ async function ensureSecondHandle(ws: WebSocket, handleA: string): Promise { +async function captureSnapshot( + ws: WebSocket, + label: string, + handle: string +): Promise { const id = nextId() - const snapshot = await new Promise((resolve, reject) => { + const snapshot = await new Promise((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 { - 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 { ws.send( JSON.stringify({ @@ -303,11 +258,14 @@ async function run(ws: WebSocket): Promise { 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' ) diff --git a/mobile/scripts/repro-worktree-startup-output.ts b/mobile/scripts/repro-worktree-startup-output.ts new file mode 100644 index 00000000000..83e127a3fa6 --- /dev/null +++ b/mobile/scripts/repro-worktree-startup-output.ts @@ -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 | 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 { + 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 +} diff --git a/mobile/scripts/repro-worktree-startup-stream.ts b/mobile/scripts/repro-worktree-startup-stream.ts index e4db6182f00..f3bbdc0d5d0 100644 --- a/mobile/scripts/repro-worktree-startup-stream.ts +++ b/mobile/scripts/repro-worktree-startup-stream.ts @@ -4,10 +4,15 @@ * Usage: * pnpm exec tsx mobile/scripts/repro-worktree-startup-stream.ts [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 | null - chunks: string[] -} - if (!repoSelector || !worktreeName) { console.error( 'Usage: pnpm exec tsx mobile/scripts/repro-worktree-startup-stream.ts [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 { const deadline = Date.now() + 60_000 while (Date.now() < deadline) { @@ -185,7 +162,7 @@ async function waitForTerminals(ws: WebSocket, worktreeId: string): Promise { +async function subscribe(ws: WebSocket, capture: StartupTerminalCapture): Promise { const id = nextId() streamListeners.set(id, (result) => { if (result.type === 'scrollback') { @@ -204,44 +181,6 @@ async function subscribe(ws: WebSocket, capture: Capture): Promise { }) } -function summarize(capture: Capture): Record { - 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 { ws.send( JSON.stringify({ @@ -312,8 +251,8 @@ async function run(ws: WebSocket): Promise { 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}`) } diff --git a/mobile/src/browser/MobileBrowserPane.tsx b/mobile/src/browser/MobileBrowserPane.tsx index 6397f69c549..f823f309630 100644 --- a/mobile/src/browser/MobileBrowserPane.tsx +++ b/mobile/src/browser/MobileBrowserPane.tsx @@ -1,7 +1,4 @@ /* oxlint-disable react-doctor/no-adjust-state-on-prop-change -- Why: mobile browser state mirrors a remote desktop screencast session and CDP dialogs, which are external systems that cannot be derived during render. */ -// Why: import from 'buffer' (the npm polyfill), not 'node:buffer' — Metro -// can't resolve Node's builtin in a React Native bundle. -import { Buffer } from 'buffer' import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { ActivityIndicator, @@ -10,7 +7,6 @@ import { PanResponder, PixelRatio, Pressable, - StyleSheet, Text, TextInput, View, @@ -24,7 +20,7 @@ import type { BrowserScreencastFrame, BrowserScreencastFrameMetadata } from '../transport/browser-screencast-protocol' -import { colors, radii, spacing, typography } from '../theme/mobile-theme' +import { colors } from '../theme/mobile-theme' import { MOBILE_BROWSER_FRAME_MIN_INTERVAL_MS, buildMobileBrowserScreencastRequest, @@ -47,11 +43,32 @@ import { computeBrowserTouchClickRadiusCss, mapScreenToBrowserPoint, readLocalTouchPoint, - type BrowserFrameGeometry, type BrowserPoint, type BrowserTouchLayout, type BrowserZoomState } from './browser-touch-geometry' +import { + MAX_ZOOM, + MIN_ZOOM, + assertRpcOk, + browserErrorMessage, + browserFrameMetadataEqual, + buttonColor, + cacheBrowserFrame, + clearCachedBrowserFramesForWorktree, + createBrowserFrameDataUri, + createPinchGesture, + getCachedBrowserFrame, + makeBrowserFrameCacheKey, + peekCachedBrowserFrame, + shouldSurfaceBrowserError, + updateBrowserImageSource, + updateBrowserLayerVisibility, + updatePinchZoom, + type FrameLayer, + type PinchGesture +} from './mobile-browser-frame-state' +import { mobileBrowserPaneStyles as styles } from './mobile-browser-pane-styles' import { displayBrowserUrl, normalizeBrowserUrl } from './browser-url' import { MobileBrowserAddressField } from './MobileBrowserAddressField' import { resolveMobileBrowserAddressSync } from './mobile-browser-address-sync' @@ -79,15 +96,6 @@ type MobileBrowserPaneProps = { onToast: (message: string, durationMs?: number) => void } -type FrameLayer = 0 | 1 - -type PinchGesture = { - distance: number - scale: number - anchorX: number - anchorY: number -} - type PanGesture = { x: number y: number @@ -105,17 +113,7 @@ const SCROLL_START_SLOP = 22 const LONG_PRESS_MS = 550 const WHEEL_INTERVAL_MS = 70 const TOUCH_CLICK_RADIUS_DIP = 14 -const MIN_ZOOM = 1 -const MAX_ZOOM = 3.5 const DEFAULT_ZOOM: BrowserZoomState = { scale: 1, offsetX: 0, offsetY: 0 } -const BROWSER_FRAME_CACHE_LIMIT = 4 - -type BrowserFrameCacheEntry = { - uri: string - metadata: BrowserScreencastFrameMetadata -} - -const browserFrameCache = new Map() type BrowserPageParams = { worktree: string @@ -1268,350 +1266,3 @@ export function MobileBrowserPane({ ) } - -function buttonColor(enabled: boolean): string { - return enabled ? colors.textSecondary : colors.textMuted -} - -function createBrowserFrameDataUri(frame: BrowserScreencastFrame): string { - return `data:image/${frame.format};base64,${Buffer.from(frame.image).toString('base64')}` -} - -function makeBrowserFrameCacheKey( - worktreeId: string, - browserPageId: string | null, - viewMode: MobileBrowserViewMode -): string | null { - return browserPageId ? `${worktreeId}:${browserPageId}:${viewMode}` : null -} - -function clearCachedBrowserFramesForWorktree(worktreeId: string): void { - const prefix = `${worktreeId}:` - for (const key of browserFrameCache.keys()) { - if (key.startsWith(prefix)) { - browserFrameCache.delete(key) - } - } -} - -function getCachedBrowserFrame(cacheKey: string | null): BrowserFrameCacheEntry | null { - if (!cacheKey) { - return null - } - const cached = browserFrameCache.get(cacheKey) - if (!cached) { - return null - } - browserFrameCache.delete(cacheKey) - browserFrameCache.set(cacheKey, cached) - return cached -} - -function peekCachedBrowserFrame(cacheKey: string | null): BrowserFrameCacheEntry | null { - return cacheKey ? (browserFrameCache.get(cacheKey) ?? null) : null -} - -function cacheBrowserFrame(cacheKey: string | null, entry: BrowserFrameCacheEntry): void { - if (!cacheKey) { - return - } - browserFrameCache.delete(cacheKey) - browserFrameCache.set(cacheKey, entry) - while (browserFrameCache.size > BROWSER_FRAME_CACHE_LIMIT) { - const oldestKey = browserFrameCache.keys().next().value - if (typeof oldestKey !== 'string') { - break - } - browserFrameCache.delete(oldestKey) - } -} - -function updateBrowserLayerVisibility( - layers: [View | null, View | null], - visible: FrameLayer -): void { - for (const [index, layer] of layers.entries()) { - layer?.setNativeProps({ style: { opacity: index === visible ? 1 : 0 } }) - } -} - -function updateBrowserImageSource(image: Image | null, uri: string): void { - // Why: browser frames are large strings; mutating only the native Image - // source avoids re-rendering the whole tab view for every streamed frame. - const source = [{ uri }] - image?.setNativeProps({ source, src: source }) -} - -function assertRpcOk( - response: RpcSuccess | RpcFailure, - fallbackMessage: string -): asserts response is RpcSuccess { - if (!response.ok) { - throw new Error(response.error.message || fallbackMessage) - } -} - -function browserFrameMetadataEqual( - a: BrowserScreencastFrameMetadata | null, - b: BrowserScreencastFrameMetadata -): boolean { - return ( - a?.deviceWidth === b.deviceWidth && - a?.deviceHeight === b.deviceHeight && - a?.pageScaleFactor === b.pageScaleFactor - ) -} - -function browserErrorMessage(error: unknown, fallback: string): string { - return error instanceof Error && error.message ? error.message : fallback -} - -function shouldSurfaceBrowserError(message: string): boolean { - const normalized = message.toLowerCase() - // Why: selector_not_found can be emitted by in-flight page automation while - // the browser is still usable; replacing the frame with it feels like a crash. - return !normalized.includes('selector_not_found') && !normalized.includes('selector not found') -} - -function touchPair(event: GestureResponderEvent): { a: BrowserPoint; b: BrowserPoint } | null { - const touches = event.nativeEvent.touches - if (!touches || touches.length < 2) { - return null - } - const a = readLocalTouchPoint(touches[0]) - const b = readLocalTouchPoint(touches[1]) - return a && b ? { a, b } : null -} - -function pointDistance(a: BrowserPoint, b: BrowserPoint): number { - return Math.hypot(a.x - b.x, a.y - b.y) -} - -function createPinchGesture( - event: GestureResponderEvent, - geometry: BrowserFrameGeometry | null, - zoom: BrowserZoomState -): PinchGesture | null { - if (!geometry) { - return null - } - const pair = touchPair(event) - if (!pair) { - return null - } - const distance = pointDistance(pair.a, pair.b) - if (distance < 8) { - return null - } - const centerX = (pair.a.x + pair.b.x) / 2 - const centerY = (pair.a.y + pair.b.y) / 2 - const frameCenterX = geometry.offsetX + geometry.renderedWidth / 2 + zoom.offsetX - const frameCenterY = geometry.offsetY + geometry.renderedHeight / 2 + zoom.offsetY - return { - distance, - scale: zoom.scale, - anchorX: (centerX - frameCenterX) / zoom.scale, - anchorY: (centerY - frameCenterY) / zoom.scale - } -} - -function updatePinchZoom( - event: GestureResponderEvent, - geometry: BrowserFrameGeometry | null, - pinch: PinchGesture -): BrowserZoomState | null { - if (!geometry) { - return null - } - const pair = touchPair(event) - if (!pair) { - return null - } - const nextScale = Math.min( - MAX_ZOOM, - Math.max(MIN_ZOOM, (pinch.scale * pointDistance(pair.a, pair.b)) / pinch.distance) - ) - const centerX = (pair.a.x + pair.b.x) / 2 - const centerY = (pair.a.y + pair.b.y) / 2 - const baseCenterX = geometry.offsetX + geometry.renderedWidth / 2 - const baseCenterY = geometry.offsetY + geometry.renderedHeight / 2 - return clampBrowserZoomState( - { - scale: nextScale, - offsetX: centerX - baseCenterX - pinch.anchorX * nextScale, - offsetY: centerY - baseCenterY - pinch.anchorY * nextScale - }, - geometry, - MIN_ZOOM, - MAX_ZOOM - ) -} - -const styles = StyleSheet.create({ - root: { - flex: 1, - minHeight: 0, - backgroundColor: colors.bgBase - }, - toolbar: { - minHeight: 32, - flexDirection: 'row', - alignItems: 'center', - gap: spacing.xs, - paddingHorizontal: spacing.sm, - paddingVertical: 2, - borderBottomWidth: 1, - borderBottomColor: colors.borderSubtle, - backgroundColor: colors.bgPanel - }, - viewport: { - flex: 1, - minHeight: 0, - overflow: 'hidden', - backgroundColor: colors.bgBase - }, - browserImageHost: { - ...StyleSheet.absoluteFillObject, - alignItems: 'center', - justifyContent: 'center', - overflow: 'hidden' - }, - browserImageFill: { - width: '100%', - height: '100%' - }, - browserImageLayer: { - ...StyleSheet.absoluteFillObject, - alignItems: 'center', - justifyContent: 'center' - }, - browserImageLayerHidden: { - opacity: 0 - }, - browserZoomOffset: { - alignItems: 'center', - justifyContent: 'center' - }, - browserFrameBox: { - alignItems: 'center', - justifyContent: 'center', - overflow: 'hidden' - }, - browserImage: { - backgroundColor: colors.bgBase - }, - overlay: { - ...StyleSheet.absoluteFillObject, - alignItems: 'center', - justifyContent: 'center', - padding: spacing.xl, - gap: spacing.sm, - backgroundColor: 'rgba(13, 15, 24, 0.2)' - }, - errorText: { - color: colors.textPrimary, - backgroundColor: colors.bgPanel, - borderWidth: 1, - borderColor: colors.borderSubtle, - borderRadius: radii.button, - paddingHorizontal: spacing.md, - paddingVertical: spacing.sm, - fontSize: 13, - textAlign: 'center', - overflow: 'hidden' - }, - dialogOverlay: { - ...StyleSheet.absoluteFillObject, - zIndex: 30, - alignItems: 'center', - justifyContent: 'center', - padding: spacing.xl, - backgroundColor: 'rgba(13, 15, 24, 0.5)' - }, - dialogCard: { - width: '100%', - maxWidth: 360, - borderRadius: radii.card, - borderWidth: 1, - borderColor: colors.borderSubtle, - backgroundColor: colors.bgPanel, - padding: spacing.lg - }, - dialogTitle: { - color: colors.textPrimary, - fontSize: 16, - fontWeight: '600' - }, - dialogMessage: { - color: colors.textSecondary, - fontSize: typography.bodySize, - lineHeight: 20, - marginTop: spacing.sm - }, - dialogActions: { - flexDirection: 'row', - justifyContent: 'flex-end', - gap: spacing.sm, - marginTop: spacing.lg - }, - dialogButton: { - minHeight: 34, - borderRadius: radii.button, - backgroundColor: colors.bgRaised, - paddingHorizontal: spacing.md, - alignItems: 'center', - justifyContent: 'center' - }, - dialogButtonPrimary: { - backgroundColor: colors.textPrimary - }, - dialogButtonPressed: { - opacity: 0.75 - }, - dialogButtonText: { - color: colors.textSecondary, - fontSize: typography.bodySize, - fontWeight: '600' - }, - dialogButtonPrimaryText: { - color: colors.bgBase - }, - keyboardDock: { - zIndex: 20, - borderTopWidth: 1, - borderTopColor: colors.borderSubtle, - backgroundColor: colors.bgPanel - }, - inputRow: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: spacing.md, - paddingTop: spacing.xs, - paddingBottom: spacing.xs + 2 - }, - keyboardInput: { - flex: 1, - height: 34, - backgroundColor: colors.bgRaised, - color: colors.textPrimary, - borderRadius: radii.input, - paddingHorizontal: spacing.md, - fontSize: 14, - fontFamily: typography.monoFamily, - marginRight: spacing.sm - }, - sendButton: { - width: 34, - height: 34, - borderRadius: 17, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: colors.bgRaised - }, - disabled: { - opacity: 0.35 - }, - disabledText: { - color: colors.textMuted - } -}) diff --git a/mobile/src/browser/mobile-browser-frame-state.ts b/mobile/src/browser/mobile-browser-frame-state.ts new file mode 100644 index 00000000000..9609c2ffda4 --- /dev/null +++ b/mobile/src/browser/mobile-browser-frame-state.ts @@ -0,0 +1,210 @@ +import { Buffer } from 'buffer' +import type { GestureResponderEvent, Image, View } from 'react-native' +import type { RpcFailure, RpcSuccess } from '../transport/types' +import type { + BrowserScreencastFrame, + BrowserScreencastFrameMetadata +} from '../transport/browser-screencast-protocol' +import { colors } from '../theme/mobile-theme' +import { + clampBrowserZoomState, + readLocalTouchPoint, + type BrowserFrameGeometry, + type BrowserPoint, + type BrowserZoomState +} from './browser-touch-geometry' +import type { MobileBrowserViewMode } from './browser-screencast-request' + +export type FrameLayer = 0 | 1 +export type PinchGesture = { + distance: number + scale: number + anchorX: number + anchorY: number +} +export type BrowserFrameCacheEntry = { + uri: string + metadata: BrowserScreencastFrameMetadata +} +export const MIN_ZOOM = 1 +export const MAX_ZOOM = 3.5 +const BROWSER_FRAME_CACHE_LIMIT = 4 +const browserFrameCache = new Map() + +export function buttonColor(enabled: boolean): string { + return enabled ? colors.textSecondary : colors.textMuted +} + +export function createBrowserFrameDataUri(frame: BrowserScreencastFrame): string { + return `data:image/${frame.format};base64,${Buffer.from(frame.image).toString('base64')}` +} + +export function makeBrowserFrameCacheKey( + worktreeId: string, + browserPageId: string | null, + viewMode: MobileBrowserViewMode +): string | null { + return browserPageId ? `${worktreeId}:${browserPageId}:${viewMode}` : null +} + +export function clearCachedBrowserFramesForWorktree(worktreeId: string): void { + const prefix = `${worktreeId}:` + for (const key of browserFrameCache.keys()) { + if (key.startsWith(prefix)) { + browserFrameCache.delete(key) + } + } +} + +export function getCachedBrowserFrame(cacheKey: string | null): BrowserFrameCacheEntry | null { + if (!cacheKey) { + return null + } + const cached = browserFrameCache.get(cacheKey) + if (!cached) { + return null + } + browserFrameCache.delete(cacheKey) + browserFrameCache.set(cacheKey, cached) + return cached +} + +export function peekCachedBrowserFrame(cacheKey: string | null): BrowserFrameCacheEntry | null { + return cacheKey ? (browserFrameCache.get(cacheKey) ?? null) : null +} + +export function cacheBrowserFrame(cacheKey: string | null, entry: BrowserFrameCacheEntry): void { + if (!cacheKey) { + return + } + browserFrameCache.delete(cacheKey) + browserFrameCache.set(cacheKey, entry) + while (browserFrameCache.size > BROWSER_FRAME_CACHE_LIMIT) { + const oldestKey = browserFrameCache.keys().next().value + if (typeof oldestKey !== 'string') { + break + } + browserFrameCache.delete(oldestKey) + } +} + +export function updateBrowserLayerVisibility( + layers: [View | null, View | null], + visible: FrameLayer +): void { + for (const [index, layer] of layers.entries()) { + layer?.setNativeProps({ style: { opacity: index === visible ? 1 : 0 } }) + } +} + +export function updateBrowserImageSource(image: Image | null, uri: string): void { + // Why: browser frames are large strings; mutating only the native Image + // source avoids re-rendering the whole tab view for every streamed frame. + const source = [{ uri }] + image?.setNativeProps({ source, src: source }) +} + +export function assertRpcOk( + response: RpcSuccess | RpcFailure, + fallbackMessage: string +): asserts response is RpcSuccess { + if (!response.ok) { + throw new Error(response.error.message || fallbackMessage) + } +} + +export function browserFrameMetadataEqual( + a: BrowserScreencastFrameMetadata | null, + b: BrowserScreencastFrameMetadata +): boolean { + return ( + a?.deviceWidth === b.deviceWidth && + a?.deviceHeight === b.deviceHeight && + a?.pageScaleFactor === b.pageScaleFactor + ) +} + +export function browserErrorMessage(error: unknown, fallback: string): string { + return error instanceof Error && error.message ? error.message : fallback +} + +export function shouldSurfaceBrowserError(message: string): boolean { + const normalized = message.toLowerCase() + // Why: selector_not_found can be emitted by in-flight page automation while + // the browser is still usable; replacing the frame with it feels like a crash. + return !normalized.includes('selector_not_found') && !normalized.includes('selector not found') +} + +function touchPair(event: GestureResponderEvent): { a: BrowserPoint; b: BrowserPoint } | null { + const touches = event.nativeEvent.touches + if (!touches || touches.length < 2) { + return null + } + const a = readLocalTouchPoint(touches[0]) + const b = readLocalTouchPoint(touches[1]) + return a && b ? { a, b } : null +} + +function pointDistance(a: BrowserPoint, b: BrowserPoint): number { + return Math.hypot(a.x - b.x, a.y - b.y) +} + +export function createPinchGesture( + event: GestureResponderEvent, + geometry: BrowserFrameGeometry | null, + zoom: BrowserZoomState +): PinchGesture | null { + if (!geometry) { + return null + } + const pair = touchPair(event) + if (!pair) { + return null + } + const distance = pointDistance(pair.a, pair.b) + if (distance < 8) { + return null + } + const centerX = (pair.a.x + pair.b.x) / 2 + const centerY = (pair.a.y + pair.b.y) / 2 + const frameCenterX = geometry.offsetX + geometry.renderedWidth / 2 + zoom.offsetX + const frameCenterY = geometry.offsetY + geometry.renderedHeight / 2 + zoom.offsetY + return { + distance, + scale: zoom.scale, + anchorX: (centerX - frameCenterX) / zoom.scale, + anchorY: (centerY - frameCenterY) / zoom.scale + } +} + +export function updatePinchZoom( + event: GestureResponderEvent, + geometry: BrowserFrameGeometry | null, + pinch: PinchGesture +): BrowserZoomState | null { + if (!geometry) { + return null + } + const pair = touchPair(event) + if (!pair) { + return null + } + const nextScale = Math.min( + MAX_ZOOM, + Math.max(MIN_ZOOM, (pinch.scale * pointDistance(pair.a, pair.b)) / pinch.distance) + ) + const centerX = (pair.a.x + pair.b.x) / 2 + const centerY = (pair.a.y + pair.b.y) / 2 + const baseCenterX = geometry.offsetX + geometry.renderedWidth / 2 + const baseCenterY = geometry.offsetY + geometry.renderedHeight / 2 + return clampBrowserZoomState( + { + scale: nextScale, + offsetX: centerX - baseCenterX - pinch.anchorX * nextScale, + offsetY: centerY - baseCenterY - pinch.anchorY * nextScale + }, + geometry, + MIN_ZOOM, + MAX_ZOOM + ) +} diff --git a/mobile/src/browser/mobile-browser-pane-styles.ts b/mobile/src/browser/mobile-browser-pane-styles.ts new file mode 100644 index 00000000000..b41428516ba --- /dev/null +++ b/mobile/src/browser/mobile-browser-pane-styles.ts @@ -0,0 +1,171 @@ +import { StyleSheet } from 'react-native' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' + +export const mobileBrowserPaneStyles = StyleSheet.create({ + root: { + flex: 1, + minHeight: 0, + backgroundColor: colors.bgBase + }, + toolbar: { + minHeight: 32, + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + paddingHorizontal: spacing.sm, + paddingVertical: 2, + borderBottomWidth: 1, + borderBottomColor: colors.borderSubtle, + backgroundColor: colors.bgPanel + }, + viewport: { + flex: 1, + minHeight: 0, + overflow: 'hidden', + backgroundColor: colors.bgBase + }, + browserImageHost: { + ...StyleSheet.absoluteFillObject, + alignItems: 'center', + justifyContent: 'center', + overflow: 'hidden' + }, + browserImageFill: { + width: '100%', + height: '100%' + }, + browserImageLayer: { + ...StyleSheet.absoluteFillObject, + alignItems: 'center', + justifyContent: 'center' + }, + browserImageLayerHidden: { + opacity: 0 + }, + browserZoomOffset: { + alignItems: 'center', + justifyContent: 'center' + }, + browserFrameBox: { + alignItems: 'center', + justifyContent: 'center', + overflow: 'hidden' + }, + browserImage: { + backgroundColor: colors.bgBase + }, + overlay: { + ...StyleSheet.absoluteFillObject, + alignItems: 'center', + justifyContent: 'center', + padding: spacing.xl, + gap: spacing.sm, + backgroundColor: 'rgba(13, 15, 24, 0.2)' + }, + errorText: { + color: colors.textPrimary, + backgroundColor: colors.bgPanel, + borderWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: radii.button, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + fontSize: 13, + textAlign: 'center', + overflow: 'hidden' + }, + dialogOverlay: { + ...StyleSheet.absoluteFillObject, + zIndex: 30, + alignItems: 'center', + justifyContent: 'center', + padding: spacing.xl, + backgroundColor: 'rgba(13, 15, 24, 0.5)' + }, + dialogCard: { + width: '100%', + maxWidth: 360, + borderRadius: radii.card, + borderWidth: 1, + borderColor: colors.borderSubtle, + backgroundColor: colors.bgPanel, + padding: spacing.lg + }, + dialogTitle: { + color: colors.textPrimary, + fontSize: 16, + fontWeight: '600' + }, + dialogMessage: { + color: colors.textSecondary, + fontSize: typography.bodySize, + lineHeight: 20, + marginTop: spacing.sm + }, + dialogActions: { + flexDirection: 'row', + justifyContent: 'flex-end', + gap: spacing.sm, + marginTop: spacing.lg + }, + dialogButton: { + minHeight: 34, + borderRadius: radii.button, + backgroundColor: colors.bgRaised, + paddingHorizontal: spacing.md, + alignItems: 'center', + justifyContent: 'center' + }, + dialogButtonPrimary: { + backgroundColor: colors.textPrimary + }, + dialogButtonPressed: { + opacity: 0.75 + }, + dialogButtonText: { + color: colors.textSecondary, + fontSize: typography.bodySize, + fontWeight: '600' + }, + dialogButtonPrimaryText: { + color: colors.bgBase + }, + keyboardDock: { + zIndex: 20, + borderTopWidth: 1, + borderTopColor: colors.borderSubtle, + backgroundColor: colors.bgPanel + }, + inputRow: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: spacing.md, + paddingTop: spacing.xs, + paddingBottom: spacing.xs + 2 + }, + keyboardInput: { + flex: 1, + height: 34, + backgroundColor: colors.bgRaised, + color: colors.textPrimary, + borderRadius: radii.input, + paddingHorizontal: spacing.md, + fontSize: 14, + fontFamily: typography.monoFamily, + marginRight: spacing.sm + }, + sendButton: { + width: 34, + height: 34, + borderRadius: 17, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.bgRaised + }, + disabled: { + opacity: 0.35 + }, + disabledText: { + color: colors.textMuted + } +}) diff --git a/mobile/src/components/ConnectionLog.test.tsx b/mobile/src/components/ConnectionLog.test.tsx new file mode 100644 index 00000000000..c310833f157 --- /dev/null +++ b/mobile/src/components/ConnectionLog.test.tsx @@ -0,0 +1,81 @@ +import { createElement } from 'react' +import { act, create } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { ConnectionLogEntry } from '../transport/types' +import { ConnectionLog } from './ConnectionLog' + +vi.mock('react-native', () => ({ + ScrollView: 'ScrollView', + StyleSheet: { create: (styles: T) => styles, hairlineWidth: 1 }, + Text: 'Text', + View: 'View' +})) + +const entries: ConnectionLogEntry[] = [ + { id: 'event-1', ts: 1, level: 'info', message: 'Opening WebSocket' } +] + +const duplicateEntries: ConnectionLogEntry[] = [ + { id: 'relay-1', ts: 1, level: 'info', message: 'Relay recovery started' }, + { id: 'relay-1', ts: 2, level: 'warn', message: 'Relay retry scheduled' } +] + +describe('ConnectionLog', () => { + type RenderedNode = { props: { style?: unknown } } + type Renderer = { + root: { + findAllByType: (type: unknown) => RenderedNode[] + findByType: (type: unknown) => RenderedNode + } + unmount: () => void + } + + let renderer: Renderer | null = null + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + function renderLog(fillAvailableHeight = false): Renderer { + act(() => { + renderer = create( + createElement(ConnectionLog, { entries, fillAvailableHeight }) + ) as unknown as Renderer + }) + return renderer as Renderer + } + + it('keeps the compact height in pairing flows', () => { + const instance = renderLog() + const containerStyles = instance.root.findAllByType('View')[0]!.props.style + + expect(containerStyles).toContainEqual({ maxHeight: 240 }) + }) + + it('fills the available diagnostics viewport', () => { + const instance = renderLog(true) + const containerStyles = instance.root.findAllByType('View')[0]!.props.style + const scroll = instance.root.findByType('ScrollView') + + expect(containerStyles).toContainEqual({ flex: 1 }) + expect(scroll.props.style).toEqual({ flex: 1 }) + }) + + it('does not emit duplicate-key warnings for repeated persisted event IDs', () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + act(() => { + renderer = create( + createElement(ConnectionLog, { entries: duplicateEntries }) + ) as unknown as Renderer + }) + + expect( + consoleError.mock.calls.some(([message]) => + String(message).includes('Encountered two children with the same key') + ) + ).toBe(false) + + consoleError.mockRestore() + }) +}) diff --git a/mobile/src/components/ConnectionLog.tsx b/mobile/src/components/ConnectionLog.tsx index ad49e46d870..08f467d943f 100644 --- a/mobile/src/components/ConnectionLog.tsx +++ b/mobile/src/components/ConnectionLog.tsx @@ -8,6 +8,7 @@ type Props = { // Tag printed before the first entry so it's clear what's being logged // (e.g. 'Pairing' vs 'Reconnect'). title?: string + fillAvailableHeight?: boolean } const LEVEL_COLOR: Record = { @@ -37,42 +38,53 @@ function formatTime(ts: number, baseTs: number): string { return `+${Math.round(elapsed)}s` } -export function ConnectionLog({ entries, title }: Props) { +export function ConnectionLog({ entries, title, fillAvailableHeight = false }: Props) { const scrollRef = useRef(null) if (entries.length === 0) { return null } const baseTs = entries[0]!.ts + const keyOccurrences = new Map() return ( - + {title && {title}} scrollRef.current?.scrollToEnd({ animated: true })} > - {entries.map((entry) => ( - - {formatTime(entry.ts, baseTs)} - - {LEVEL_GLYPH[entry.level]} - - - - {entry.message} + {entries.map((entry) => { + const occurrence = keyOccurrences.get(entry.id) ?? 0 + keyOccurrences.set(entry.id, occurrence + 1) + const renderKey = occurrence === 0 ? entry.id : `${entry.id}:${occurrence}` + return ( + + {formatTime(entry.ts, baseTs)} + + {LEVEL_GLYPH[entry.level]} - {entry.detail && ( - - {entry.detail} + + + {entry.message} - )} + {entry.detail && ( + + {entry.detail} + + )} + - - ))} + ) + })} ) @@ -81,7 +93,6 @@ export function ConnectionLog({ entries, title }: Props) { const styles = StyleSheet.create({ container: { width: '100%', - maxHeight: 240, backgroundColor: colors.bgPanel, borderRadius: radii.card, borderWidth: StyleSheet.hairlineWidth, @@ -89,6 +100,12 @@ const styles = StyleSheet.create({ paddingVertical: spacing.sm, paddingHorizontal: spacing.md }, + boundedContainer: { + maxHeight: 240 + }, + fillContainer: { + flex: 1 + }, title: { fontSize: typography.metaSize, fontFamily: typography.monoFamily, @@ -97,9 +114,12 @@ const styles = StyleSheet.create({ letterSpacing: 1, marginBottom: spacing.xs }, - scroll: { + boundedScroll: { maxHeight: 200 }, + fillScroll: { + flex: 1 + }, scrollContent: { gap: 6 }, diff --git a/mobile/src/components/CustomKeyModal.styles.ts b/mobile/src/components/CustomKeyModal.styles.ts new file mode 100644 index 00000000000..991e557441f --- /dev/null +++ b/mobile/src/components/CustomKeyModal.styles.ts @@ -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 + } +}) diff --git a/mobile/src/components/CustomKeyModal.tsx b/mobile/src/components/CustomKeyModal.tsx index 2777e310dc8..0cc24f4c030 100644 --- a/mobile/src/components/CustomKeyModal.tsx +++ b/mobile/src/components/CustomKeyModal.tsx @@ -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 ) } - -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 - } -}) diff --git a/mobile/src/components/HostDiagnosticsLink.tsx b/mobile/src/components/HostDiagnosticsLink.tsx new file mode 100644 index 00000000000..eaab1f96ca4 --- /dev/null +++ b/mobile/src/components/HostDiagnosticsLink.tsx @@ -0,0 +1,38 @@ +import { ChevronRight } from 'lucide-react-native' +import { Pressable, StyleSheet, Text } from 'react-native' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' + +export function HostDiagnosticsLink({ onPress }: { onPress: () => void }): React.JSX.Element { + return ( + + View network diagnostics + + + ) +} + +const styles = StyleSheet.create({ + link: { + marginHorizontal: spacing.md, + marginBottom: spacing.sm, + paddingVertical: spacing.sm, + paddingHorizontal: spacing.md, + borderRadius: radii.button, + backgroundColor: colors.bgPanel, + borderWidth: 1, + borderColor: colors.borderSubtle, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between' + }, + text: { + color: colors.textSecondary, + fontSize: typography.metaSize, + fontWeight: '600' + } +}) diff --git a/mobile/src/components/mobile-rich-markdown-editor-body-primary.ts b/mobile/src/components/mobile-rich-markdown-editor-body-primary.ts new file mode 100644 index 00000000000..c4348ad15a2 --- /dev/null +++ b/mobile/src/components/mobile-rich-markdown-editor-body-primary.ts @@ -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;', + ' }', + ' ', + '', + '', + '
', + ' ', + '', + '' +].join('\n') diff --git a/mobile/src/components/mobile-rich-markdown-editor-html.ts b/mobile/src/components/mobile-rich-markdown-editor-html.ts index b7ff1649075..493fea3dd02 100644 --- a/mobile/src/components/mobile-rich-markdown-editor-html.ts +++ b/mobile/src/components/mobile-rich-markdown-editor-html.ts @@ -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; - } - - - -
- - -` + --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}` } diff --git a/mobile/src/components/use-new-workspace-create-submit.ts b/mobile/src/components/use-new-workspace-create-submit.ts index 14d9f9bfae0..1ab3ac5d5c4 100644 --- a/mobile/src/components/use-new-workspace-create-submit.ts +++ b/mobile/src/components/use-new-workspace-create-submit.ts @@ -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 + getWorktreeCreateCutoverSupport: () => Promise transitionDrawer: (view: Exclude) => void setError: Dispatch> 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) diff --git a/mobile/src/diagnostics/connection-diagnostics-analysis.test.ts b/mobile/src/diagnostics/connection-diagnostics-analysis.test.ts new file mode 100644 index 00000000000..762d82b10d5 --- /dev/null +++ b/mobile/src/diagnostics/connection-diagnostics-analysis.test.ts @@ -0,0 +1,240 @@ +import { describe, expect, it } from 'vitest' +import { + diagnoseConnection, + getReportableConnectionIncidentId +} from './connection-diagnostics-analysis' +import type { ConnectionLogEntry } from '../transport/types' + +function event(message: string, detail?: string): ConnectionLogEntry { + return { id: message, ts: 1, level: 'error', message, detail } +} + +describe('diagnoseConnection', () => { + it('reports the current healthy path instead of a historical failure', () => { + expect( + diagnoseConnection({ + endpoint: 'ws://100.88.90.25:6768', + state: 'connected', + activePath: 'relay', + entries: [event('WebSocket connect timeout')] + }) + ).toEqual({ + likelyCause: 'Connection is healthy via Relay.', + nextStep: 'No action needed.', + reportability: 'none' + }) + }) + + it('distinguishes an invalid Relay credential from a transient outage', () => { + expect( + diagnoseConnection({ + endpoint: 'ws://100.88.90.25:6768', + state: 'reconnecting', + pendingPath: 'relay', + entries: [event('Relay: relay dial failed', 'relay director resolve failed (401)')] + }) + ).toEqual({ + likelyCause: 'Relay rejected the saved resume credential.', + nextStep: 'Try a direct connection; if Relay keeps returning 401, pair this device again.', + reportability: 'none' + }) + }) + + it('identifies the direct Tailscale timeout while Relay recovery is pending', () => { + expect( + diagnoseConnection({ + endpoint: 'ws://100.88.90.25:6768', + state: 'reconnecting', + activePath: 'tailscale', + pendingPath: 'relay', + entries: [event('WebSocket connect timeout')] + }) + ).toEqual({ + likelyCause: 'The saved Tailscale endpoint did not answer before the connection timeout.', + nextStep: 'Relay recovery is in progress; keep Orca open while it retries.', + reportability: 'none' + }) + }) + + it('identifies an authenticated Relay liveness failure', () => { + expect( + diagnoseConnection({ + endpoint: 'ws://192.168.1.2:6768', + state: 'reconnecting', + activePath: 'relay', + entries: [ + { + ...event('Relay health check failed'), + code: 'liveness-timeout', + path: 'relay' + } + ] + }) + ).toEqual({ + likelyCause: 'Relay stopped answering authenticated health checks.', + nextStep: 'Orca closed the stale session and started recovery.', + reportability: 'orca-relay' + }) + }) + + it('separates an active Relay close from a failed Relay dial', () => { + expect( + diagnoseConnection({ + endpoint: 'ws://192.168.1.2:6768', + state: 'reconnecting', + activePath: 'relay', + entries: [ + { + ...event('Relay: active relay session failed', 'RelayOuterError: close code 4408'), + code: 'relay-session-failed', + path: 'relay' + } + ] + }) + ).toEqual({ + likelyCause: 'The active Relay session closed unexpectedly.', + nextStep: 'Orca started Relay recovery; the event history includes the cell close reason.', + reportability: 'orca-relay' + }) + }) + + it('marks authenticated Relay liveness failures as safe to send', () => { + expect( + diagnoseConnection({ + endpoint: 'ws://192.168.1.2:6768', + state: 'reconnecting', + activePath: 'relay', + entries: [ + { + ...event('Relay health check failed'), + code: 'liveness-timeout', + path: 'relay' + } + ] + }).reportability + ).toBe('orca-relay') + }) + + it.each([ + ['direct timeout', 'connect-timeout', 'tailscale'], + ['handshake timeout', 'handshake-timeout', 'relay'], + ['invalid credential', 'relay director resolve failed (401)', 'relay'], + ['ambiguous recovery', 'retry scheduled', 'relay'] + ] as const)('does not offer submission for %s', (_name, message, path) => { + expect( + diagnoseConnection({ + endpoint: 'ws://100.88.90.25:6768', + state: 'reconnecting', + pendingPath: 'relay', + entries: [{ ...event(message), path }] + }).reportability + ).toBe('none') + }) + + it('does not offer submission for a bounded Relay director outage', () => { + expect( + diagnoseConnection({ + endpoint: 'ws://100.88.90.25:6768', + state: 'reconnecting', + pendingPath: 'relay', + entries: [event('Relay: relay dial failed', 'relay director resolve failed (503)')] + }).reportability + ).toBe('none') + }) + + it('classifies the newest failure instead of an older persisted 401', () => { + expect( + diagnoseConnection({ + endpoint: 'ws://100.88.90.25:6768', + state: 'reconnecting', + pendingPath: 'relay', + entries: [ + event('Relay: relay dial failed', 'relay director resolve failed (401)'), + { ...event('WebSocket connect timeout'), id: 'newer', ts: 2, code: 'connect-timeout' } + ] + }).likelyCause + ).toBe('The saved Tailscale endpoint did not answer before the connection timeout.') + }) + + it('requires structured Relay evidence before offering submission', () => { + expect( + diagnoseConnection({ + endpoint: 'ws://192.168.1.2:6768', + state: 'reconnecting', + activePath: 'relay', + entries: [event('active relay session failed')] + }).reportability + ).toBe('none') + }) + + it('ignores failures from before the current app resume boundary', () => { + expect( + diagnoseConnection({ + endpoint: 'ws://192.168.1.2:6768', + state: 'connecting', + activePath: 'lan', + entries: [ + { + ...event('Relay health check failed'), + code: 'liveness-timeout', + path: 'relay' + }, + { + ...event('App returned to foreground'), + id: 'resume', + ts: 2, + code: 'app-resumed' + }, + { ...event('WebSocket closed'), id: 'closed', ts: 3, code: 'socket-closed' } + ] + }).reportability + ).toBe('none') + }) + + it('uses a structured direct liveness path ahead of the current active path', () => { + expect( + diagnoseConnection({ + endpoint: 'ws://192.168.1.2:6768', + state: 'reconnecting', + activePath: 'relay', + entries: [ + { + ...event('Connection health check failed'), + code: 'liveness-timeout', + path: 'lan' + } + ] + }) + ).toEqual({ + likelyCause: 'The connected host stopped answering authenticated health checks.', + nextStep: 'Orca closed the stale session and started recovery.', + reportability: 'none' + }) + }) + + it('keys reportability to the current structured incident', () => { + const args = { + endpoint: 'ws://192.168.1.2:6768', + state: 'reconnecting' as const, + activePath: 'relay' as const, + entries: [ + { ...event('Authenticated'), code: 'direct-connected' as const, path: 'lan' as const }, + { + ...event('Relay health check failed'), + id: 'current-relay-failure', + ts: 2, + code: 'liveness-timeout' as const, + path: 'relay' as const + } + ] + } + + expect(getReportableConnectionIncidentId(args)).toBe('current-relay-failure') + expect( + getReportableConnectionIncidentId({ + ...args, + entries: [...args.entries, { ...event('Network changed'), ts: 3, code: 'network-changed' }] + }) + ).toBeNull() + }) +}) diff --git a/mobile/src/diagnostics/connection-diagnostics-analysis.ts b/mobile/src/diagnostics/connection-diagnostics-analysis.ts new file mode 100644 index 00000000000..117f52e236b --- /dev/null +++ b/mobile/src/diagnostics/connection-diagnostics-analysis.ts @@ -0,0 +1,168 @@ +import { isTailscaleEndpoint } from '../../../src/shared/remote-runtime-tailscale-hint' +import type { + ConnectionLogEntry, + ConnectionState, + MobileConnectionDiagnosticPath +} from '../transport/types' + +export type ConnectionDiagnosis = { + likelyCause: string + nextStep: string + reportability: 'none' | 'orca-relay' +} + +type DiagnoseConnectionArgs = { + endpoint: string + state: ConnectionState + activePath?: MobileConnectionDiagnosticPath + pendingPath?: MobileConnectionDiagnosticPath | null + entries: readonly ConnectionLogEntry[] +} + +export function diagnoseConnection(args: DiagnoseConnectionArgs): ConnectionDiagnosis { + if (args.state === 'connected') { + return { + likelyCause: `Connection is healthy${args.activePath ? ` via ${formatPath(args.activePath)}` : ''}.`, + nextStep: 'No action needed.', + reportability: 'none' + } + } + const failure = findCurrentDiagnosticFailure(args.entries) + const evidence = failure ? `${failure.code ?? ''} ${failure.message} ${failure.detail ?? ''}` : '' + + if (/relay director resolve failed \(401\)/i.test(evidence)) { + return { + likelyCause: 'Relay rejected the saved resume credential.', + nextStep: 'Try a direct connection; if Relay keeps returning 401, pair this device again.', + reportability: 'none' + } + } + + if (/relay director resolve failed \(503\)/i.test(evidence)) { + const retryMs = parseRetryDelayMs(evidence) + return { + likelyCause: `Relay service was temporarily unavailable${retryMs == null ? '.' : ` and asked Orca to retry in ${formatDelay(retryMs)}.`}`, + nextStep: 'Keep Orca open; recovery should retry automatically.', + reportability: 'none' + } + } + + if (/liveness-timeout|liveness timeout|connection health check failed/i.test(evidence)) { + const relayLiveness = failure?.code === 'liveness-timeout' && failure.path === 'relay' + const structuredDirectLiveness = + failure?.code === 'liveness-timeout' && + (failure.path === 'lan' || failure.path === 'tailscale') + const path = + relayLiveness || (!structuredDirectLiveness && args.activePath === 'relay') + ? 'Relay' + : 'The connected host' + return { + likelyCause: `${path} stopped answering authenticated health checks.`, + nextStep: 'Orca closed the stale session and started recovery.', + reportability: relayLiveness ? 'orca-relay' : 'none' + } + } + + if (/relay-session-failed|active relay session failed/i.test(evidence)) { + return { + likelyCause: 'The active Relay session closed unexpectedly.', + nextStep: 'Orca started Relay recovery; the event history includes the cell close reason.', + reportability: + failure?.code === 'relay-session-failed' && failure.path === 'relay' ? 'orca-relay' : 'none' + } + } + + if (/authentication-rejected|unauthorized|pairing may be revoked/i.test(evidence)) { + return { + likelyCause: 'The desktop rejected this device during authentication.', + nextStep: 'Confirm the device is still paired; pair it again if the rejection repeats.', + reportability: 'none' + } + } + + if (/connect-timeout|websocket connect timeout/i.test(evidence)) { + return { + likelyCause: isTailscaleEndpoint(args.endpoint) + ? 'The saved Tailscale endpoint did not answer before the connection timeout.' + : 'The saved direct endpoint did not answer before the connection timeout.', + nextStep: + args.pendingPath === 'relay' + ? 'Relay recovery is in progress; keep Orca open while it retries.' + : 'Check the local/VPN network and confirm the desktop is awake.', + reportability: 'none' + } + } + + if (/handshake-timeout|handshake timeout/i.test(evidence)) { + return { + likelyCause: 'The endpoint opened, but the encrypted Orca handshake did not finish.', + nextStep: 'Confirm the desktop is running a compatible Orca version and retry.', + reportability: 'none' + } + } + + if (args.pendingPath === 'relay') { + return { + likelyCause: 'Relay recovery is selected, but no more specific failure is recorded yet.', + nextStep: 'Keep this page open while the next recovery event is recorded.', + reportability: 'none' + } + } + + return { + likelyCause: 'No single failure cause can be determined from the recorded events.', + nextStep: 'Run diagnostics and copy the report again after the next connection attempt.', + reportability: 'none' + } +} + +export function getReportableConnectionIncidentId(args: DiagnoseConnectionArgs): string | null { + if (diagnoseConnection(args).reportability !== 'orca-relay') { + return null + } + return findCurrentDiagnosticFailure(args.entries)?.id ?? null +} + +function findCurrentDiagnosticFailure( + entries: readonly ConnectionLogEntry[] +): ConnectionLogEntry | undefined { + const boundaryIndex = entries.findLastIndex(isDiagnosticBoundary) + return entries + .slice(boundaryIndex + 1) + .toReversed() + .find(isDiagnosticFailure) +} + +function isDiagnosticBoundary(entry: ConnectionLogEntry): boolean { + return ( + entry.code === 'client-session-started' || + entry.code === 'app-resumed' || + entry.code === 'network-changed' || + entry.code === 'relay-connected' || + entry.code === 'direct-connected' || + entry.message === 'Authenticated' + ) +} + +function isDiagnosticFailure(entry: ConnectionLogEntry): boolean { + const evidence = `${entry.code ?? ''} ${entry.message} ${entry.detail ?? ''}` + return /relay director resolve failed \((?:401|503)\)|liveness-timeout|liveness timeout|connection health check failed|relay-session-failed|active relay session failed|authentication-rejected|unauthorized|pairing may be revoked|connect-timeout|websocket connect timeout|handshake-timeout|handshake timeout/i.test( + evidence + ) +} + +function parseRetryDelayMs(evidence: string): number | null { + const match = /retry(?:-|\s)?after(?:=|\s)(\d+)ms/i.exec(evidence) + return match ? Number(match[1]) : null +} + +function formatDelay(ms: number): string { + return ms < 60_000 ? `${Math.round(ms / 1000)}s` : `${Math.round(ms / 60_000)}m` +} + +function formatPath(path: MobileConnectionDiagnosticPath): string { + if (path === 'relay') { + return 'Relay' + } + return path === 'tailscale' ? 'Tailscale/direct' : 'LAN/direct' +} diff --git a/mobile/src/diagnostics/connection-diagnostics-report.test.ts b/mobile/src/diagnostics/connection-diagnostics-report.test.ts index d2326ffa022..35a1425165b 100644 --- a/mobile/src/diagnostics/connection-diagnostics-report.test.ts +++ b/mobile/src/diagnostics/connection-diagnostics-report.test.ts @@ -13,6 +13,7 @@ describe('buildConnectionDiagnosticsReport', () => { lastConnectedAt: NOW - 5 * 60_000, platform: 'ios 26.5.1', appVersion: '0.0.29', + desktopAppVersion: '1.4.191', entries: [ { id: 'log-1', @@ -26,6 +27,7 @@ describe('buildConnectionDiagnosticsReport', () => { }) expect(report).toContain('App: Orca Mobile 0.0.29 · ios 26.5.1') + expect(report).toContain('Host Orca version: 1.4.191') expect(report).toContain('Endpoint: 100.65.9.106:6768 (Tailscale)') expect(report).toContain('State: reconnecting (reconnect attempts: 12)') expect(report).toContain('(5m 0s ago)') @@ -48,8 +50,96 @@ describe('buildConnectionDiagnosticsReport', () => { }) expect(report).toContain('Endpoint: 192.168.1.50:6768') + expect(report).toContain('Host Orca version: unknown') expect(report).not.toContain('(Tailscale)') expect(report).toContain('Last connected: never this session') - expect(report).toContain('No connection events recorded this session.') + expect(report).toContain('No connection events recorded.') + }) + + it('explains the most likely cause and redacts credentials before copying', () => { + const report = buildConnectionDiagnosticsReport({ + hostName: 'Host 3', + endpoint: 'ws://100.88.90.25:6768', + state: 'reconnecting', + reconnectAttempts: 4, + lastConnectedAt: null, + platform: 'android 36', + appVersion: '0.0.46', + activePath: 'tailscale', + pendingPath: 'relay', + entries: [ + { + id: 'relay-failure', + ts: NOW - 5_000, + level: 'error', + message: 'Relay: relay dial failed', + detail: + 'RelayDirectorHttpError: relay director resolve failed (503); retry after 30000ms; resumeToken=secret-resume-token' + } + ], + nowMs: NOW + }) + + expect(report).toContain( + 'Likely cause: Relay service was temporarily unavailable and asked Orca to retry in 30s.' + ) + expect(report).toContain('Path: active=tailscale; recovery=relay') + expect(report).toContain('Next step: Keep Orca open; recovery should retry automatically.') + expect(report).toContain('resumeToken=[redacted]') + expect(report).not.toContain('secret-resume-token') + }) + + it('redacts quoted JSON credentials and never echoes an invalid endpoint', () => { + const report = buildConnectionDiagnosticsReport({ + hostName: 'Host 4', + endpoint: 'not-a-url?token=endpoint-secret', + state: 'reconnecting', + reconnectAttempts: 1, + lastConnectedAt: null, + platform: 'android 36', + appVersion: '0.0.47', + entries: [ + { + id: 'json-secret', + ts: NOW, + level: 'error', + message: 'Relay failed', + detail: '{"resumeToken":"json-secret","authorization":"Bearer bearer-secret"}' + } + ], + nowMs: NOW + }) + + expect(report).toContain('Endpoint: invalid endpoint') + expect(report).not.toContain('endpoint-secret') + expect(report).not.toContain('json-secret') + expect(report).not.toContain('bearer-secret') + }) + + it('bounds a single event line before submission while preserving its identity', () => { + const report = buildConnectionDiagnosticsReport({ + hostName: 'Host 5', + endpoint: 'ws://192.168.1.2:6768', + state: 'reconnecting', + reconnectAttempts: 1, + lastConnectedAt: null, + platform: 'android 36', + appVersion: '0.0.47', + entries: [ + { + id: 'oversized', + ts: NOW, + level: 'error', + message: `newest oversized ${'😀'.repeat(2_000)}` + } + ], + nowMs: NOW + }) + + expect(report).toContain('newest oversized') + expect(report).toContain('[truncated]') + expect(new TextEncoder().encode(report.split('\n').at(-1)!).byteLength).toBeLessThanOrEqual( + 2048 + ) }) }) diff --git a/mobile/src/diagnostics/connection-diagnostics-report.ts b/mobile/src/diagnostics/connection-diagnostics-report.ts index c762c69799e..0507b44349a 100644 --- a/mobile/src/diagnostics/connection-diagnostics-report.ts +++ b/mobile/src/diagnostics/connection-diagnostics-report.ts @@ -1,6 +1,16 @@ import { isTailscaleEndpoint } from '../../../src/shared/remote-runtime-tailscale-hint' -import type { ConnectionLogEntry, ConnectionState } from '../transport/types' +import type { + ConnectionLogEntry, + ConnectionState, + MobileConnectionDiagnosticPath +} from '../transport/types' +import { normalizeHostAppVersion } from '../transport/host-app-version-store' import { formatEndpoint } from './host-reachability' +import { diagnoseConnection } from './connection-diagnostics-analysis' +import { redactConnectionLogEntry, redactConnectionLogText } from './connection-log-redaction' + +const MAX_EVENT_LINE_BYTES = 2 * 1024 +const EVENT_TRUNCATION_MARKER = ' … [truncated]' // Why: one shareable text blob answering everything we historically had to // ask reporters one message at a time (endpoint type, state, attempt count, @@ -13,37 +23,83 @@ export function buildConnectionDiagnosticsReport(args: { lastConnectedAt: number | null platform: string appVersion: string + desktopAppVersion?: string | null entries: readonly ConnectionLogEntry[] + activePath?: MobileConnectionDiagnosticPath + pendingPath?: MobileConnectionDiagnosticPath | null nowMs?: number }): string { const now = args.nowMs ?? Date.now() + const entries = args.entries.map(redactConnectionLogEntry) + const diagnosis = diagnoseConnection({ + endpoint: args.endpoint, + state: args.state, + activePath: args.activePath, + pendingPath: args.pendingPath, + entries + }) const lines: string[] = [] lines.push('Orca Mobile connection diagnostics') lines.push(`Generated: ${new Date(now).toISOString()}`) lines.push(`App: Orca Mobile ${args.appVersion} · ${args.platform}`) - lines.push(`Host: ${args.hostName}`) + const desktopAppVersion = normalizeHostAppVersion(args.desktopAppVersion) + lines.push(`Host Orca version: ${desktopAppVersion ?? 'unknown'}`) + lines.push(`Host: ${redactConnectionLogText(args.hostName)}`) lines.push( `Endpoint: ${formatEndpoint(args.endpoint)}${isTailscaleEndpoint(args.endpoint) ? ' (Tailscale)' : ''}` ) lines.push(`State: ${args.state} (reconnect attempts: ${args.reconnectAttempts})`) + if (args.activePath) { + lines.push( + `Path: active=${args.activePath}${args.pendingPath ? `; recovery=${args.pendingPath}` : ''}` + ) + } lines.push( args.lastConnectedAt == null ? 'Last connected: never this session' : `Last connected: ${new Date(args.lastConnectedAt).toISOString()} (${formatAgo(now - args.lastConnectedAt)} ago)` ) lines.push('') - if (args.entries.length === 0) { - lines.push('No connection events recorded this session.') + lines.push(`Likely cause: ${diagnosis.likelyCause}`) + lines.push(`Next step: ${diagnosis.nextStep}`) + lines.push('') + if (entries.length === 0) { + lines.push('No connection events recorded.') } else { - lines.push(`Connection log (${args.entries.length} events, oldest first):`) - for (const entry of args.entries) { + lines.push(`Recent connection history (${entries.length} events, oldest first):`) + for (const entry of entries) { const detail = entry.detail ? ` — ${entry.detail}` : '' - lines.push(`${new Date(entry.ts).toISOString()} [${entry.level}] ${entry.message}${detail}`) + const evidence = [entry.code, entry.path].filter(Boolean).join(' · ') + lines.push( + truncateUtf8WithMarker( + `${new Date(entry.ts).toISOString()} [${entry.level}]${evidence ? ` [${evidence}]` : ''} ${entry.message}${detail}`, + MAX_EVENT_LINE_BYTES, + EVENT_TRUNCATION_MARKER + ) + ) } } return lines.join('\n') } +function truncateUtf8WithMarker(value: string, maxBytes: number, marker: string): string { + if (new TextEncoder().encode(value).byteLength <= maxBytes) { + return value + } + const markerBytes = new TextEncoder().encode(marker).byteLength + const characters: string[] = [] + let bytes = 0 + for (const character of value) { + const characterBytes = new TextEncoder().encode(character).byteLength + if (bytes + characterBytes + markerBytes > maxBytes) { + break + } + characters.push(character) + bytes += characterBytes + } + return `${characters.join('')}${marker}` +} + function formatAgo(ms: number): string { const seconds = Math.max(0, Math.round(ms / 1000)) if (seconds < 60) { diff --git a/mobile/src/diagnostics/connection-diagnostics-screen-data.test.ts b/mobile/src/diagnostics/connection-diagnostics-screen-data.test.ts new file mode 100644 index 00000000000..06ec5dabf81 --- /dev/null +++ b/mobile/src/diagnostics/connection-diagnostics-screen-data.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it, vi } from 'vitest' +import { + getDiagnosticsSubmissionState, + readConnectionDiagnosticsSnapshot, + readHydratedConnectionLog, + resolveDiagnosticsHostId, + selectDiagnosticsHostId, + updateDiagnosticsSubmissionState +} from './connection-diagnostics-screen-data' +import type { ConnectionLogEntry, HostProfile } from '../transport/types' + +const host = (id: string): HostProfile => ({ + id, + name: id, + endpoint: 'ws://192.168.1.2:6768', + deviceToken: 'token', + publicKeyB64: 'key', + lastConnected: 0 +}) + +describe('connection diagnostics screen data', () => { + it('prefers a valid route host when navigation changes', () => { + expect(selectDiagnosticsHostId([host('a'), host('b')], 'b', 'a')).toBe('b') + expect(selectDiagnosticsHostId([host('a')], 'missing', 'missing')).toBe('a') + }) + + it('applies route changes synchronously without discarding a chip choice for the same route', () => { + const hosts = [host('a'), host('b')] + expect(resolveDiagnosticsHostId(hosts, undefined, null)).toBe('a') + expect(resolveDiagnosticsHostId(hosts, 'b', { hostId: 'a', requestedHostId: 'a' })).toBe('b') + expect(resolveDiagnosticsHostId(hosts, 'b', { hostId: 'a', requestedHostId: 'b' })).toBe('a') + }) + + it('does not revive a stale manual choice when routing away and back', () => { + const hosts = [host('a'), host('b'), host('c')] + const firstRouteA = {} + const routeC = {} + const secondRouteA = {} + const manualSelection = { hostId: 'b', requestedHostId: 'a', routeKey: firstRouteA } + + expect(resolveDiagnosticsHostId(hosts, 'c', manualSelection, routeC)).toBe('c') + expect(resolveDiagnosticsHostId(hosts, 'a', manualSelection, secondRouteA)).toBe('a') + }) + + it('keeps async submission completion scoped to its host incident', () => { + let states = updateDiagnosticsSubmissionState({}, 'host-a:incident-1', 'sending') + states = updateDiagnosticsSubmissionState(states, 'host-b:incident-2', 'sending') + states = updateDiagnosticsSubmissionState(states, 'host-a:incident-1', 'sent') + + expect(getDiagnosticsSubmissionState(states, 'host-a:incident-1')).toBe('sent') + expect(getDiagnosticsSubmissionState(states, 'host-b:incident-2')).toBe('sending') + }) + + it('waits for hydration and then reads the refreshed log', async () => { + const entries: ConnectionLogEntry[] = [] + const store = { + hydrate: vi.fn(async () => { + entries.push({ id: 'stored', ts: 1, level: 'info', message: 'stored event' }) + }), + get: vi.fn(() => entries) + } + + await expect(readHydratedConnectionLog(store, 'host-a')).resolves.toEqual(entries) + expect(store.get).toHaveBeenCalledAfter(store.hydrate) + }) + + it('retries one transient hydration failure before reading', async () => { + const entries = [{ id: 'stored', ts: 1, level: 'info' as const, message: 'stored event' }] + const store = { + hydrate: vi + .fn() + .mockRejectedValueOnce(new Error('storage unavailable')) + .mockResolvedValueOnce(undefined), + get: vi.fn(() => entries) + } + + await expect(readHydratedConnectionLog(store, 'host-a')).resolves.toEqual(entries) + expect(store.hydrate).toHaveBeenCalledTimes(2) + }) + + it('reads connection metadata after hydration completes', async () => { + let state: 'connecting' | 'reconnecting' = 'connecting' + const store = { + hydrate: vi.fn(async () => { + state = 'reconnecting' + }), + get: vi.fn(() => [{ id: 'new', ts: 2, level: 'warn' as const, message: 'new event' }]) + } + const context = { + getState: vi.fn(() => state), + getReconnectAttempt: vi.fn(() => 2), + getLastConnectedAt: vi.fn(() => 1), + getActivePath: vi.fn(() => 'relay' as const), + getPendingPath: vi.fn(() => null) + } + + await expect(readConnectionDiagnosticsSnapshot(context, store, 'host-a')).resolves.toEqual({ + state: 'reconnecting', + reconnectAttempts: 2, + lastConnectedAt: 1, + activePath: 'relay', + pendingPath: null, + entries: [{ id: 'new', ts: 2, level: 'warn', message: 'new event' }] + }) + expect(context.getState).toHaveBeenCalledAfter(store.hydrate) + }) +}) diff --git a/mobile/src/diagnostics/connection-diagnostics-screen-data.ts b/mobile/src/diagnostics/connection-diagnostics-screen-data.ts new file mode 100644 index 00000000000..c8a2ed589c8 --- /dev/null +++ b/mobile/src/diagnostics/connection-diagnostics-screen-data.ts @@ -0,0 +1,94 @@ +import type { ConnectionLogStore } from '../transport/connection-log-buffer' +import type { ConnectionLogEntry, HostProfile } from '../transport/types' +import type { RpcClientContextValue } from '../transport/rpc-client-context-contract' + +export type DiagnosticsHostSelection = { + hostId: string + requestedHostId: string | undefined + routeKey?: object +} + +export type DiagnosticsSubmissionState = 'sending' | 'sent' | 'failed' +export type DiagnosticsSubmissionStates = Readonly> + +export function selectDiagnosticsHostId( + hosts: readonly HostProfile[], + requestedHostId: string | undefined, + previousHostId: string | null +): string | null { + if (requestedHostId && hosts.some((host) => host.id === requestedHostId)) { + return requestedHostId + } + if (previousHostId && hosts.some((host) => host.id === previousHostId)) { + return previousHostId + } + return hosts[0]?.id ?? null +} + +export function resolveDiagnosticsHostId( + hosts: readonly HostProfile[], + requestedHostId: string | undefined, + manualSelection: DiagnosticsHostSelection | null, + routeKey?: object +): string | null { + const selected = manualSelection + if (selected && selected.requestedHostId === requestedHostId && selected.routeKey === routeKey) { + const manualHostId = selected.hostId + if (hosts.some((host) => host.id === manualHostId)) { + return manualHostId + } + } + return selectDiagnosticsHostId(hosts, requestedHostId, null) +} + +export function getDiagnosticsSubmissionState( + states: DiagnosticsSubmissionStates, + key: string | null +): DiagnosticsSubmissionState | 'idle' { + return key ? (states[key] ?? 'idle') : 'idle' +} + +export function updateDiagnosticsSubmissionState( + states: DiagnosticsSubmissionStates, + key: string, + state: DiagnosticsSubmissionState | null +): DiagnosticsSubmissionStates { + const next = { ...states } + if (state) { + next[key] = state + } else { + delete next[key] + } + return next +} + +export async function readHydratedConnectionLog( + store: Pick, + hostId: string +): Promise { + try { + await store.hydrate(hostId) + } catch { + await store.hydrate(hostId).catch(() => {}) + } + return store.get(hostId) +} + +export async function readConnectionDiagnosticsSnapshot( + context: Pick< + RpcClientContextValue, + 'getState' | 'getReconnectAttempt' | 'getLastConnectedAt' | 'getActivePath' | 'getPendingPath' + >, + store: Pick, + hostId: string +) { + const entries = await readHydratedConnectionLog(store, hostId) + return { + state: context.getState(hostId), + reconnectAttempts: context.getReconnectAttempt(hostId), + lastConnectedAt: context.getLastConnectedAt(hostId), + activePath: context.getActivePath(hostId), + pendingPath: context.getPendingPath(hostId), + entries + } +} diff --git a/mobile/src/diagnostics/connection-diagnostics-submission.test.ts b/mobile/src/diagnostics/connection-diagnostics-submission.test.ts new file mode 100644 index 00000000000..f2977b6055c --- /dev/null +++ b/mobile/src/diagnostics/connection-diagnostics-submission.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it, vi } from 'vitest' +import { + boundConnectionDiagnosticsReport, + submitConnectionDiagnostics +} from './connection-diagnostics-submission' + +describe('submitConnectionDiagnostics', () => { + it('sends a bounded report through the diagnostics lane', async () => { + const fetchImpl = vi.fn().mockResolvedValue(new Response(null, { status: 204 })) + const result = await submitConnectionDiagnostics( + { report: 'x'.repeat(100_000), appVersion: '0.0.47', platform: 'android 36' }, + fetchImpl + ) + + expect(result).toEqual({ ok: true }) + expect(fetchImpl).toHaveBeenCalledWith( + 'https://www.onorca.dev/v1/feedback', + expect.objectContaining({ method: 'POST' }) + ) + const request = fetchImpl.mock.calls[0]?.[1] + const body = JSON.parse(String(request?.body)) as { feedback: string; submissionType: string } + expect(body.submissionType).toBe('connection_diagnostics') + expect(new TextEncoder().encode(body.feedback).byteLength).toBeLessThanOrEqual(64 * 1024) + }) + + it('returns a safe failure for a rejected response', async () => { + const fetchImpl = vi.fn().mockResolvedValue(new Response(null, { status: 503 })) + await expect( + submitConnectionDiagnostics( + { report: 'report', appVersion: '0.0.47', platform: 'android' }, + fetchImpl + ) + ).resolves.toEqual({ ok: false, error: 'status 503' }) + }) + + it('preserves the newest complete events when bounding a UTF-8 report', () => { + const report = [ + 'Orca Mobile connection diagnostics', + 'State: reconnecting', + '', + 'Recent connection history (3 events, oldest first):', + `oldest ${'😀'.repeat(20)}`, + `middle ${'😀'.repeat(20)}`, + `newest ${'😀'.repeat(20)}` + ].join('\n') + + const bounded = boundConnectionDiagnosticsReport(report, 180) + + expect(new TextEncoder().encode(bounded).byteLength).toBeLessThanOrEqual(180) + expect(bounded).toContain('newest') + expect(bounded).not.toContain('oldest') + expect(bounded).toMatch(/older omitted/) + }) + + it('retains a bounded form of the newest event when that event exceeds the budget', () => { + const report = [ + 'Orca Mobile connection diagnostics', + 'State: reconnecting', + '', + 'Recent connection history (2 events, oldest first):', + 'old event', + `newest oversized ${'😀'.repeat(1_000)}` + ].join('\n') + + const bounded = boundConnectionDiagnosticsReport(report, 220) + + expect(new TextEncoder().encode(bounded).byteLength).toBeLessThanOrEqual(220) + expect(bounded).toContain('newest oversized') + expect(bounded).toContain('[truncated]') + }) +}) diff --git a/mobile/src/diagnostics/connection-diagnostics-submission.ts b/mobile/src/diagnostics/connection-diagnostics-submission.ts new file mode 100644 index 00000000000..67e220f86c7 --- /dev/null +++ b/mobile/src/diagnostics/connection-diagnostics-submission.ts @@ -0,0 +1,125 @@ +const CONNECTION_DIAGNOSTICS_ENDPOINT = 'https://www.onorca.dev/v1/feedback' +const SUBMISSION_TIMEOUT_MS = 10_000 +const MAX_SUBMISSION_BYTES = 64 * 1024 + +export type ConnectionDiagnosticsSubmission = { + report: string + appVersion: string + platform: string +} + +export type ConnectionDiagnosticsSubmissionResult = { ok: true } | { ok: false; error: string } + +/** Sends only the already-redacted, bounded report after an explicit user tap. */ +export async function submitConnectionDiagnostics( + submission: ConnectionDiagnosticsSubmission, + fetchImpl: typeof fetch = fetch +): Promise { + const report = boundConnectionDiagnosticsReport(submission.report) + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), SUBMISSION_TIMEOUT_MS) + try { + const response = await fetchImpl(CONNECTION_DIAGNOSTICS_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + feedback: report, + submissionType: 'connection_diagnostics', + githubLogin: null, + githubEmail: null, + appVersion: submission.appVersion, + platform: `mobile-${submission.platform}`, + osRelease: 'unknown', + arch: 'unknown' + }), + signal: controller.signal + }) + return response.ok ? { ok: true } : { ok: false, error: `status ${response.status}` } + } catch (error) { + return { + ok: false, + error: controller.signal.aborted + ? 'request timed out' + : error instanceof Error + ? error.message + : 'request failed' + } + } finally { + clearTimeout(timeout) + } +} + +export function boundConnectionDiagnosticsReport( + report: string, + maxBytes: number = MAX_SUBMISSION_BYTES +): string { + if (utf8Bytes(report) <= maxBytes) { + return report + } + const lines = report.split('\n') + const historyIndex = lines.findIndex((line) => line.startsWith('Recent connection history (')) + if (historyIndex === -1) { + return truncateUtf8(report, maxBytes) + } + const header = lines.slice(0, historyIndex) + const events = lines.slice(historyIndex + 1) + let kept: string[] = [] + for (let index = events.length - 1; index >= 0; index--) { + const candidateEvents = [events[index]!, ...kept] + const candidate = formatBoundedReport(header, candidateEvents, events.length) + if (utf8Bytes(candidate) > maxBytes) { + break + } + kept = candidateEvents + } + if (kept.length === 0 && events.length > 0) { + return formatReportWithTruncatedNewestEvent(header, events, maxBytes) + } + return truncateUtf8(formatBoundedReport(header, kept, events.length), maxBytes) +} + +function formatReportWithTruncatedNewestEvent( + header: string[], + events: string[], + maxBytes: number +): string { + const history = `Recent connection history (1 newest event; ${events.length - 1} older omitted):` + const prefix = [...header, history].join('\n') + '\n' + const availableBytes = maxBytes - utf8Bytes(prefix) + if (availableBytes <= 0) { + return truncateUtf8(prefix, maxBytes) + } + const marker = ' … [truncated]' + const markerBytes = utf8Bytes(marker) + if (availableBytes <= markerBytes) { + return truncateUtf8(prefix, maxBytes) + } + return `${prefix}${truncateUtf8(events.at(-1)!, availableBytes - markerBytes)}${marker}` +} + +function formatBoundedReport(header: string[], events: string[], totalEvents: number): string { + const omitted = totalEvents - events.length + return [ + ...header, + `Recent connection history (${events.length} newest events; ${omitted} older omitted):`, + ...events + ].join('\n') +} + +function truncateUtf8(value: string, maxBytes: number): string { + const characters: string[] = [] + let bytes = 0 + for (const character of value) { + const characterBytes = utf8Bytes(character) + if (bytes + characterBytes > maxBytes) { + break + } + characters.push(character) + bytes += characterBytes + } + return characters.join('') +} + +function utf8Bytes(value: string): number { + return new TextEncoder().encode(value).byteLength +} diff --git a/mobile/src/diagnostics/connection-log-redaction.ts b/mobile/src/diagnostics/connection-log-redaction.ts new file mode 100644 index 00000000000..0461385172a --- /dev/null +++ b/mobile/src/diagnostics/connection-log-redaction.ts @@ -0,0 +1,51 @@ +import type { ConnectionLogEntry } from '../transport/types' + +const SECRET_JSON_ASSIGNMENT = + /(["'])(resumeToken|deviceToken|authorization|credential|token|publicKeyB64)\1(\s*:\s*)(["'])(?:Bearer\s+)?(?:\\.|(?!\4)[^\\\r\n])*\4/gi +const SECRET_QUOTED_VALUE_ASSIGNMENT = + /\b(resumeToken|deviceToken|authorization|credential|token|publicKeyB64)(\s*[:=]\s*)(["'])(?:Bearer\s+)?(?:\\.|(?!\3)[^\\\r\n])*\3/gi +const SECRET_UNTERMINATED_JSON_ASSIGNMENT = + /(["'])(resumeToken|deviceToken|authorization|credential|token|publicKeyB64)\1(\s*:\s*)(["'])(?:Bearer\s+)?(?:\\.|(?!\4)[^\\\r\n])*(?=\r?\n|$)/gi +const SECRET_UNTERMINATED_QUOTED_VALUE_ASSIGNMENT = + /\b(resumeToken|deviceToken|authorization|credential|token|publicKeyB64)(\s*[:=]\s*)(["'])(?:Bearer\s+)?(?:\\.|(?!\3)[^\\\r\n])*(?=\r?\n|$)/gi +const SECRET_ASSIGNMENT = + /\b(resumeToken|deviceToken|authorization|credential|token|publicKeyB64)(\s*[:=]\s*)(?:Bearer\s+)?([^\s;,}"']+)/gi +const SECRET_QUERY = /([?&](?:token|credential|resumeToken|deviceToken)=)[^&#\s]+/gi +const URL_USERINFO = /((?:wss?|https?):\/\/)[^\s/?#]*@/gi + +export function redactConnectionLogText(value: string): string { + return value + .replace( + SECRET_JSON_ASSIGNMENT, + (_match, keyQuote: string, label: string, separator: string, valueQuote: string) => + `${keyQuote}${label}${keyQuote}${separator}${valueQuote}[redacted]${valueQuote}` + ) + .replace( + SECRET_QUOTED_VALUE_ASSIGNMENT, + (_match, label: string, separator: string, valueQuote: string) => + `${label}${separator}${valueQuote}[redacted]${valueQuote}` + ) + .replace( + SECRET_UNTERMINATED_JSON_ASSIGNMENT, + (_match, keyQuote: string, label: string, separator: string, valueQuote: string) => + `${keyQuote}${label}${keyQuote}${separator}${valueQuote}[redacted]` + ) + .replace( + SECRET_UNTERMINATED_QUOTED_VALUE_ASSIGNMENT, + (_match, label: string, separator: string, valueQuote: string) => + `${label}${separator}${valueQuote}[redacted]` + ) + .replace(SECRET_ASSIGNMENT, (_match, label: string, separator: string) => { + return `${label}${separator}[redacted]` + }) + .replace(SECRET_QUERY, '$1[redacted]') + .replace(URL_USERINFO, '$1[redacted]@') +} + +export function redactConnectionLogEntry(entry: ConnectionLogEntry): ConnectionLogEntry { + return { + ...entry, + message: redactConnectionLogText(entry.message), + ...(entry.detail ? { detail: redactConnectionLogText(entry.detail) } : {}) + } +} diff --git a/mobile/src/diagnostics/host-reachability.test.ts b/mobile/src/diagnostics/host-reachability.test.ts index d20bc8c28cf..3d2bd5b80e5 100644 --- a/mobile/src/diagnostics/host-reachability.test.ts +++ b/mobile/src/diagnostics/host-reachability.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { testHostReachability, unreachableHostDetail } from './host-reachability' +import { formatEndpoint, testHostReachability, unreachableHostDetail } from './host-reachability' describe('unreachableHostDetail', () => { it('points at Tailscale for tailnet CGNAT endpoints', () => { @@ -21,6 +21,10 @@ describe('unreachableHostDetail', () => { it('does not treat non-CGNAT 100.x addresses as Tailscale', () => { expect(unreachableHostDetail('ws://100.20.1.5:6768')).toBe('Cannot reach 100.20.1.5:6768') }) + + it('does not echo malformed endpoints that may contain credentials', () => { + expect(formatEndpoint('not-a-url?token=secret')).toBe('invalid endpoint') + }) }) describe('testHostReachability', () => { diff --git a/mobile/src/diagnostics/host-reachability.ts b/mobile/src/diagnostics/host-reachability.ts index 495d65a9764..f5333bb5405 100644 --- a/mobile/src/diagnostics/host-reachability.ts +++ b/mobile/src/diagnostics/host-reachability.ts @@ -55,7 +55,7 @@ export function formatEndpoint(endpoint: string): string { const url = new URL(endpoint) return url.host } catch { - return endpoint + return 'invalid endpoint' } } diff --git a/mobile/src/home/MobileHomeScreen.tsx b/mobile/src/home/MobileHomeScreen.tsx index 44c65bf018a..83cf3de4b8a 100644 --- a/mobile/src/home/MobileHomeScreen.tsx +++ b/mobile/src/home/MobileHomeScreen.tsx @@ -177,6 +177,8 @@ export function MobileHomeScreen() { onDismiss: () => setActionTarget(null), onReconnect: (hostId) => void forceReconnectHost(hostId), onDisconnect: disconnectHostClient, + onDiagnostics: (hostId) => + data.router.push({ pathname: '/connection-log', params: { hostId } }), onEdit: openMobileHostEdit, onRemove: (host) => setConfirmRemove(host) })} diff --git a/mobile/src/host-list-action-sheet-actions.test.ts b/mobile/src/host-list-action-sheet-actions.test.ts index 2fb90334082..3672bd81f96 100644 --- a/mobile/src/host-list-action-sheet-actions.test.ts +++ b/mobile/src/host-list-action-sheet-actions.test.ts @@ -3,6 +3,7 @@ import { getHostListActionSheetActions } from './host-list-action-sheet-actions' import type { ConnectionState, HostProfile } from './transport/types' vi.mock('lucide-react-native', () => ({ + Activity: vi.fn(), Edit3: vi.fn(), PowerOff: vi.fn(), RefreshCw: vi.fn() @@ -22,6 +23,7 @@ function build(overrides: { state?: ConnectionState; hasEverConnected?: boolean onDismiss: vi.fn(), onReconnect: vi.fn(), onDisconnect: vi.fn(), + onDiagnostics: vi.fn(), onEdit: vi.fn(), onRemove: vi.fn() } @@ -35,14 +37,17 @@ function build(overrides: { state?: ConnectionState; hasEverConnected?: boolean } describe('getHostListActionSheetActions', () => { - // Why: both open a second drawer. Presenting one while this sheet's native + // Why: these navigate or open a second drawer. Presenting while this sheet's native // Modal is still up freezes the whole screen on iOS — issue #8791. - it.each(['Edit host', 'Remove'])('defers %s until the action sheet has closed', (label) => { - const { actions } = build() - expect(actions.find((action) => action.label === label)).toMatchObject({ - closeBeforePress: true - }) - }) + it.each(['Network diagnostics', 'Edit host', 'Remove'])( + 'defers %s until the action sheet has closed', + (label) => { + const { actions } = build() + expect(actions.find((action) => action.label === label)).toMatchObject({ + closeBeforePress: true + }) + } + ) it('leaves the in-place actions undeferred so they fire on tap', () => { const { actions, spies } = build() @@ -65,15 +70,23 @@ describe('getHostListActionSheetActions', () => { expect(spies.onEdit).toHaveBeenCalledWith(HOST.id) }) + it('routes Network diagnostics with the selected host', () => { + const { actions, spies } = build() + actions.find((action) => action.label === 'Network diagnostics')?.onPress() + expect(spies.onDiagnostics).toHaveBeenCalledWith(HOST.id) + }) + it('offers Disconnect only while the socket is live', () => { expect(build({ state: 'reconnecting' }).actions.map((action) => action.label)).toEqual([ 'Reconnect', 'Disconnect', + 'Network diagnostics', 'Edit host', 'Remove' ]) expect(build({ state: 'disconnected' }).actions.map((action) => action.label)).toEqual([ 'Connect', + 'Network diagnostics', 'Edit host', 'Remove' ]) @@ -93,6 +106,7 @@ describe('getHostListActionSheetActions', () => { onDismiss: vi.fn(), onReconnect: vi.fn(), onDisconnect: vi.fn(), + onDiagnostics: vi.fn(), onEdit: vi.fn(), onRemove: vi.fn() }) diff --git a/mobile/src/host-list-action-sheet-actions.ts b/mobile/src/host-list-action-sheet-actions.ts index b045db7eea4..22b1472e7c3 100644 --- a/mobile/src/host-list-action-sheet-actions.ts +++ b/mobile/src/host-list-action-sheet-actions.ts @@ -1,9 +1,9 @@ -import { Edit3, PowerOff, RefreshCw } from 'lucide-react-native' +import { Activity, Edit3, PowerOff, RefreshCw } from 'lucide-react-native' import type { ActionSheetAction } from './components/ActionSheetModal' import type { ConnectionState, HostProfile } from './transport/types' -/** Builds the home-screen host long-press menu. Edit and Remove open a second - * drawer, so both must defer until this sheet's native Modal has unmounted — +/** Builds the home-screen host long-press menu. Navigation and second drawers + * defer until this sheet's native Modal has unmounted — * presenting into a live one freezes the whole screen on iOS (issue #8791). */ export function getHostListActionSheetActions(args: { host: HostProfile | null @@ -13,6 +13,7 @@ export function getHostListActionSheetActions(args: { onDismiss: () => void onReconnect: (hostId: string) => void onDisconnect: (hostId: string) => void + onDiagnostics: (hostId: string) => void onEdit: (hostId: string) => void onRemove: (host: HostProfile) => void }): ActionSheetAction[] { @@ -47,6 +48,14 @@ export function getHostListActionSheetActions(args: { } ] : []), + { + label: 'Network diagnostics', + icon: Activity, + closeBeforePress: true, + onPress: () => { + args.onDiagnostics(host.id) + } + }, { label: 'Edit host', icon: Edit3, diff --git a/mobile/src/host-screen/host-screen-primary-styles.ts b/mobile/src/host-screen/host-screen-primary-styles.ts new file mode 100644 index 00000000000..c499ad2afe6 --- /dev/null +++ b/mobile/src/host-screen/host-screen-primary-styles.ts @@ -0,0 +1,175 @@ +import { StyleSheet } from 'react-native' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' + +export const hostScreenPrimaryStyles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.bgBase + }, + topChrome: { + backgroundColor: colors.bgPanel, + borderBottomWidth: 1, + borderBottomColor: colors.borderSubtle + }, + statusBar: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + minHeight: 34, + paddingTop: spacing.xs, + paddingHorizontal: spacing.lg + }, + backButton: { + width: 32, + height: 32, + alignItems: 'center', + justifyContent: 'center', + marginRight: spacing.xs + }, + sidebarCollapseButton: { + width: 24, + height: 24, + alignItems: 'center', + justifyContent: 'center', + borderRadius: radii.button, + marginLeft: spacing.xs + }, + hostIdentity: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + minWidth: 0, + marginRight: spacing.md + }, + hostNameText: { + flex: 1, + fontSize: 15, + fontWeight: '600', + color: colors.textPrimary + }, + reconnectButton: { + paddingVertical: 4, + paddingHorizontal: spacing.sm, + borderRadius: radii.button, + backgroundColor: colors.bgPanel, + borderWidth: 1, + borderColor: colors.borderSubtle + }, + reconnectButtonText: { + color: colors.textPrimary, + fontSize: typography.metaSize, + fontWeight: '600' + }, + toolbar: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: spacing.xs + 2, + paddingHorizontal: spacing.md, + gap: spacing.sm, + borderBottomWidth: 1, + borderBottomColor: colors.borderSubtle + }, + embeddedToolbar: { + paddingVertical: spacing.xs + 2, + paddingHorizontal: spacing.sm, + gap: spacing.xs, + borderBottomWidth: 1, + borderBottomColor: colors.borderSubtle + }, + embeddedToolbarRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + }, + embeddedFilterChip: { + flex: 1, + minWidth: 0, + height: 30, + justifyContent: 'center', + paddingHorizontal: spacing.xs, + paddingVertical: 0 + }, + embeddedModeButton: { + flex: 1, + minWidth: 0, + height: 30, + justifyContent: 'center', + paddingHorizontal: spacing.xs, + paddingVertical: 0 + }, + filterChip: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + paddingHorizontal: spacing.sm + 2, + paddingVertical: spacing.xs, + borderRadius: 12, + borderWidth: 1, + borderColor: colors.borderSubtle + }, + filterChipActive: { + borderColor: colors.textSecondary, + backgroundColor: colors.bgRaised + }, + filterChipText: { + fontSize: 12, + color: colors.textSecondary + }, + filterChipTextActive: { + color: colors.textPrimary + }, + modeButton: { + flexDirection: 'row', + alignItems: 'center', + flexShrink: 1, + minWidth: 0, + gap: 4, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs + }, + sortLabel: { + flexShrink: 1, + minWidth: 0, + fontSize: 12, + color: colors.textSecondary + }, + toolbarSpacer: { + flex: 1 + }, + floatingWorkspaceHeaderButton: { + width: 32, + height: 32, + alignItems: 'center', + justifyContent: 'center', + marginLeft: spacing.xs + }, + embeddedToolbarIconButton: { + flex: 1, + height: 28, + alignItems: 'center', + justifyContent: 'center', + borderRadius: radii.button + }, + toolbarIconDisabled: { + opacity: 0.6 + }, + searchToggle: { + padding: spacing.xs + }, + searchBar: { + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.borderSubtle, + backgroundColor: colors.bgPanel + }, + centered: { + flex: 1, + alignItems: 'center', + justifyContent: 'center' + }, + emptyText: { + color: colors.textSecondary, + fontSize: typography.bodySize + } +}) diff --git a/mobile/src/host-screen/host-screen-secondary-styles.ts b/mobile/src/host-screen/host-screen-secondary-styles.ts new file mode 100644 index 00000000000..af14aa2b97a --- /dev/null +++ b/mobile/src/host-screen/host-screen-secondary-styles.ts @@ -0,0 +1,139 @@ +import { StyleSheet } from 'react-native' +import { colors, spacing, typography } from '../theme/mobile-theme' + +export const hostScreenSecondaryStyles = StyleSheet.create({ + errorText: { + color: colors.statusRed, + fontSize: typography.bodySize + }, + list: { + paddingBottom: spacing.lg + }, + sectionHeader: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: spacing.lg, + paddingTop: spacing.md, + paddingBottom: spacing.xs + }, + sectionIcon: { + marginRight: spacing.xs + }, + sectionRepoIcon: { + marginRight: spacing.xs + }, + sectionTitle: { + fontSize: 11, + fontWeight: '600', + color: colors.textMuted, + textTransform: 'uppercase', + letterSpacing: 0.5 + }, + sectionCount: { + fontSize: 11, + color: colors.textMuted, + marginLeft: spacing.xs + }, + separator: { + height: 1, + backgroundColor: colors.borderSubtle, + marginLeft: spacing.lg + 24, + marginRight: spacing.lg + }, + filterModalHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: spacing.xs, + marginBottom: spacing.md + }, + filterModalTitle: { + fontSize: 15, + fontWeight: '600', + color: colors.textPrimary + }, + clearFiltersText: { + fontSize: 13, + color: colors.textSecondary + }, + filterSectionLabel: { + fontSize: 11, + fontWeight: '600', + color: colors.textMuted, + textTransform: 'uppercase', + letterSpacing: 0.5, + marginBottom: spacing.xs, + paddingHorizontal: spacing.xs + }, + filterGroup: { + backgroundColor: colors.bgPanel, + borderRadius: 12, + overflow: 'hidden', + marginBottom: spacing.md + }, + filterRow: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: spacing.md, + paddingHorizontal: spacing.md + 2, + gap: spacing.sm + }, + filterRowText: { + flex: 1, + fontSize: typography.bodySize, + color: colors.textPrimary + }, + filterSeparator: { + height: StyleSheet.hairlineWidth, + backgroundColor: colors.borderSubtle, + marginHorizontal: spacing.md + }, + filterRepoDot: { + width: 8, + height: 8, + borderRadius: 4 + }, + confirmContent: { + paddingBottom: spacing.lg + }, + confirmTitle: { + fontSize: 16, + fontWeight: '700', + color: colors.textPrimary + }, + confirmMessage: { + fontSize: typography.bodySize, + color: colors.textSecondary, + marginTop: spacing.xs, + lineHeight: 20 + }, + confirmButtons: { + flexDirection: 'row', + gap: spacing.sm + }, + confirmBtn: { + flex: 1, + paddingVertical: spacing.sm + 2, + borderRadius: 10, + alignItems: 'center' + }, + confirmBtnCancel: { + backgroundColor: colors.bgPanel + }, + confirmBtnDestructive: { + backgroundColor: colors.statusRed + }, + confirmBtnPressed: { + opacity: 0.7 + }, + confirmBtnCancelText: { + fontSize: typography.bodySize, + fontWeight: '600', + color: colors.textSecondary + }, + confirmBtnDestructiveText: { + fontSize: typography.bodySize, + fontWeight: '600', + color: '#fff' + } +}) diff --git a/mobile/src/host-screen/host-screen-styles.ts b/mobile/src/host-screen/host-screen-styles.ts new file mode 100644 index 00000000000..f4fdb7bed5d --- /dev/null +++ b/mobile/src/host-screen/host-screen-styles.ts @@ -0,0 +1,7 @@ +import { hostScreenPrimaryStyles } from './host-screen-primary-styles' +import { hostScreenSecondaryStyles } from './host-screen-secondary-styles' + +export const hostScreenStyles = { + ...hostScreenPrimaryStyles, + ...hostScreenSecondaryStyles +} diff --git a/mobile/src/pair-scan-styles.ts b/mobile/src/pair-scan-styles.ts new file mode 100644 index 00000000000..af1b936fb54 --- /dev/null +++ b/mobile/src/pair-scan-styles.ts @@ -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' + } +}) diff --git a/mobile/src/tasks/blank-workspace-create.test.ts b/mobile/src/tasks/blank-workspace-create.test.ts index 32e9e162ef4..1d3cf2c15eb 100644 --- a/mobile/src/tasks/blank-workspace-create.test.ts +++ b/mobile/src/tasks/blank-workspace-create.test.ts @@ -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 @@ -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' }) diff --git a/mobile/src/tasks/blank-workspace-create.ts b/mobile/src/tasks/blank-workspace-create.ts index d81d75d5a69..77d0dccc4d3 100644 --- a/mobile/src/tasks/blank-workspace-create.ts +++ b/mobile/src/tasks/blank-workspace-create.ts @@ -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 + worktreeCreateIdempotency: WorktreeCreateIdempotencyProbe }): Promise { return createWorktreeWithNameRetry({ client: args.client, baseName: args.baseName, nameWasGenerated: args.nameWasGenerated, - supportsIdempotentCutoverRetry: args.supportsIdempotentCutoverRetry, + worktreeCreateIdempotency: args.worktreeCreateIdempotency, buildParams: (name) => { const params: Record = { repo: `id:${args.repoId}`, diff --git a/mobile/src/tasks/source-workspace-create.test.ts b/mobile/src/tasks/source-workspace-create.test.ts index ba1ddc4b300..66974f63443 100644 --- a/mobile/src/tasks/source-workspace-create.test.ts +++ b/mobile/src/tasks/source-workspace-create.test.ts @@ -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 } @@ -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', () => { diff --git a/mobile/src/tasks/source-workspace-create.ts b/mobile/src/tasks/source-workspace-create.ts index 53b6a2bd03f..53e13e68ea3 100644 --- a/mobile/src/tasks/source-workspace-create.ts +++ b/mobile/src/tasks/source-workspace-create.ts @@ -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 + 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 + worktreeCreateIdempotency: WorktreeCreateIdempotencyProbe }): Promise { 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 + worktreeCreateIdempotency: WorktreeCreateIdempotencyProbe }): Promise { 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 = { repo: `id:${targetRepoId}`, @@ -220,7 +221,7 @@ async function createNewBranchWorkspace(args: { agent: WorkspaceCreateAgentBundle workspaceName: string | undefined note: string | undefined - supportsIdempotentCutoverRetry: boolean | Promise + worktreeCreateIdempotency: WorktreeCreateIdempotencyProbe }): Promise { 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 = { repo: `id:${targetRepoId}`, diff --git a/mobile/src/tasks/worktree-create-capability.test.ts b/mobile/src/tasks/worktree-create-capability.test.ts index 8b1e55efcae..cad108e29d7 100644 --- a/mobile/src/tasks/worktree-create-capability.test.ts +++ b/mobile/src/tasks/worktree-create-capability.test.ts @@ -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 }) }) diff --git a/mobile/src/tasks/worktree-create-capability.ts b/mobile/src/tasks/worktree-create-capability.ts index 6083120c7a4..c4ca8170312 100644 --- a/mobile/src/tasks/worktree-create-capability.ts +++ b/mobile/src/tasks/worktree-create-capability.ts @@ -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 + getWorktreeCreateCutoverSupport: () => Promise } { const [tasksSupported, setTasksSupported] = useState(false) const [hostPlatform, setHostPlatform] = useState(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 } diff --git a/mobile/src/tasks/worktree-create-idempotency-policy.ts b/mobile/src/tasks/worktree-create-idempotency-policy.ts new file mode 100644 index 00000000000..797b62305e0 --- /dev/null +++ b/mobile/src/tasks/worktree-create-idempotency-policy.ts @@ -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 + +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) +} diff --git a/mobile/src/tasks/worktree-create-retry.test.ts b/mobile/src/tasks/worktree-create-retry.test.ts index 52d6d7b9782..b4463207ebe 100644 --- a/mobile/src/tasks/worktree-create-retry.test.ts +++ b/mobile/src/tasks/worktree-create-retry.test.ts @@ -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 } +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((resolve) => { + let resolveSupport!: (supported: WorktreeCreateIdempotencySupport) => void + const support = new Promise((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) }) }) diff --git a/mobile/src/tasks/worktree-create-retry.ts b/mobile/src/tasks/worktree-create-retry.ts index 7bacc95aa72..971dfa759cf 100644 --- a/mobile/src/tasks/worktree-create-retry.ts +++ b/mobile/src/tasks/worktree-create-retry.ts @@ -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 - supportsIdempotentCutoverRetry: boolean | Promise + 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, - supportsIdempotentCutoverRetry: boolean + worktreeCreateIdempotency: WorktreeCreateIdempotencySupport | false ): Promise { 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 { diff --git a/mobile/src/terminal/terminal-accessory-keys.ts b/mobile/src/terminal/terminal-accessory-keys.ts index 49aaba134d5..c02f265fba4 100644 --- a/mobile/src/terminal/terminal-accessory-keys.ts +++ b/mobile/src/terminal/terminal-accessory-keys.ts @@ -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 = { '?': '\x7f' } -const SPECIAL_KEY_LABELS: Record = { - 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 = { - 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 = { arrowUp: 'A', arrowDown: 'B', @@ -165,87 +111,9 @@ const CSI_TILDE_SPECIAL_KEYS: Record = { 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 diff --git a/mobile/src/terminal/terminal-accessory-repeat.test.ts b/mobile/src/terminal/terminal-accessory-repeat.test.ts new file mode 100644 index 00000000000..eab103eac15 --- /dev/null +++ b/mobile/src/terminal/terminal-accessory-repeat.test.ts @@ -0,0 +1,192 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + createTerminalAccessoryRepeatController, + createTerminalAccessoryRepeatSender, + TERMINAL_ACCESSORY_REPEAT_DELAY_MS, + TERMINAL_ACCESSORY_REPEAT_INTERVAL_MS +} from './terminal-accessory-repeat' + +function deferred() { + let resolve: () => void = () => {} + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve } +} + +describe('terminal accessory repeat', () => { + afterEach(() => vi.useRealTimers()) + + it('keeps one send in flight and preserves repeat order under host latency', async () => { + vi.useFakeTimers() + const first = deferred() + const second = deferred() + const sent: string[] = [] + const send = vi.fn((input: string) => { + sent.push(input) + return (sent.length === 1 ? first.promise : second.promise).then(() => true) + }) + const repeat = createTerminalAccessoryRepeatController() + + repeat.start('down', send) + expect(sent).toEqual(['down']) + + await vi.advanceTimersByTimeAsync(TERMINAL_ACCESSORY_REPEAT_DELAY_MS + 500) + expect(sent).toEqual(['down']) + + first.resolve() + await vi.advanceTimersByTimeAsync(0) + expect(sent).toEqual(['down', 'down']) + + await vi.advanceTimersByTimeAsync(TERMINAL_ACCESSORY_REPEAT_INTERVAL_MS + 500) + expect(sent).toEqual(['down', 'down']) + + second.resolve() + await vi.advanceTimersByTimeAsync(TERMINAL_ACCESSORY_REPEAT_INTERVAL_MS) + expect(sent).toEqual(['down', 'down', 'down']) + }) + + it('does not schedule another repeat after release while a send is pending', async () => { + vi.useFakeTimers() + const pending = deferred() + const send = vi.fn(() => pending.promise.then(() => true)) + const repeat = createTerminalAccessoryRepeatController() + + repeat.start('down', send) + repeat.stop() + pending.resolve() + await vi.runAllTimersAsync() + + expect(send).toHaveBeenCalledTimes(1) + }) + + it('dispatches distinct taps without waiting for prior acknowledgements', async () => { + vi.useFakeTimers() + const pending = Array.from({ length: 5 }, () => deferred()) + const sent: string[] = [] + const send = vi.fn((input: string) => { + sent.push(input) + return pending[sent.length - 1]!.promise.then(() => true) + }) + const repeat = createTerminalAccessoryRepeatController() + + for (let index = 0; index < pending.length; index += 1) { + repeat.start(`tap-${index}`, send) + repeat.stop() + } + + expect(sent).toEqual(['tap-0', 'tap-1', 'tap-2', 'tap-3', 'tap-4']) + + pending.forEach(({ resolve }) => resolve()) + await vi.runAllTimersAsync() + expect(send).toHaveBeenCalledTimes(5) + }) + + it('cancels repeats without dropping a distinct tap already dispatched by the user', async () => { + vi.useFakeTimers() + const first = deferred() + const sent: string[] = [] + const send = vi.fn((input: string) => { + sent.push(input) + return input === 'down' ? first.promise.then(() => true) : Promise.resolve(true) + }) + const repeat = createTerminalAccessoryRepeatController() + + repeat.start('down', send) + repeat.stop() + repeat.start('up', send) + repeat.cancel() + + first.resolve() + await vi.runAllTimersAsync() + + expect(sent).toEqual(['down', 'up']) + expect(send).toHaveBeenCalledTimes(2) + }) + + it('stops repeating when the transport rejects a send', async () => { + vi.useFakeTimers() + const send = vi.fn(() => Promise.reject(new Error('disconnected'))) + const repeat = createTerminalAccessoryRepeatController() + + repeat.start('down', send) + await vi.runAllTimersAsync() + + expect(send).toHaveBeenCalledTimes(1) + }) + + it('stops repeating when the send is dropped before reaching the transport', async () => { + vi.useFakeTimers() + const send = vi.fn(async () => false) + const repeat = createTerminalAccessoryRepeatController() + + repeat.start('down', send) + await vi.runAllTimersAsync() + + expect(send).toHaveBeenCalledTimes(1) + }) + + it('stops sending when the terminal active at press time is no longer active', async () => { + let activeTerminal = 'terminal-a' + const sendToTerminal = vi.fn(async () => true) + const send = createTerminalAccessoryRepeatSender( + activeTerminal, + (targetHandle) => activeTerminal === targetHandle, + sendToTerminal + ) + + await expect(send('down')).resolves.toBe(true) + activeTerminal = 'terminal-b' + await expect(send('down')).resolves.toBe(false) + + expect(sendToTerminal).toHaveBeenCalledTimes(1) + expect(sendToTerminal).toHaveBeenCalledWith('down', 'terminal-a', expect.any(Function)) + }) + + it('provides a live target guard for async send preflight', async () => { + let targetCurrent = true + const preflight = deferred() + const send = createTerminalAccessoryRepeatSender( + 'terminal-a', + () => targetCurrent, + async (_input, _targetHandle, isDeliveryTargetCurrent) => { + await preflight.promise + return isDeliveryTargetCurrent() + } + ) + + const result = send('down') + targetCurrent = false + preflight.resolve() + + await expect(result).resolves.toBe(false) + }) + + it('stops held repeats when the press-time connection is no longer current', async () => { + vi.useFakeTimers() + let connectionGeneration = 1 + const first = deferred() + const sent: string[] = [] + const sendToTerminal = vi.fn((input: string) => { + sent.push(input) + return input === 'down' ? first.promise.then(() => true) : Promise.resolve(true) + }) + const createSender = () => { + const pressedConnectionGeneration = connectionGeneration + return createTerminalAccessoryRepeatSender( + 'terminal-a', + () => connectionGeneration === pressedConnectionGeneration, + sendToTerminal + ) + } + const repeat = createTerminalAccessoryRepeatController() + + repeat.start('down', createSender()) + + connectionGeneration = 2 + first.resolve() + await vi.runAllTimersAsync() + + expect(sent).toEqual(['down']) + }) +}) diff --git a/mobile/src/terminal/terminal-accessory-repeat.ts b/mobile/src/terminal/terminal-accessory-repeat.ts new file mode 100644 index 00000000000..2b354b42d59 --- /dev/null +++ b/mobile/src/terminal/terminal-accessory-repeat.ts @@ -0,0 +1,76 @@ +export const TERMINAL_ACCESSORY_REPEAT_DELAY_MS = 400 +export const TERMINAL_ACCESSORY_REPEAT_INTERVAL_MS = 45 + +type TerminalAccessoryRepeatSender = (input: TInput) => Promise +type TerminalAccessoryRepeatSendToTerminal = ( + input: TInput, + targetHandle: string, + isDeliveryTargetCurrent: () => boolean +) => Promise + +export function createTerminalAccessoryRepeatSender( + targetHandle: string | null, + isDeliveryTargetCurrent: (targetHandle: string) => boolean, + sendToTerminal: TerminalAccessoryRepeatSendToTerminal +): TerminalAccessoryRepeatSender { + return (input) => { + if (!targetHandle || !isDeliveryTargetCurrent(targetHandle)) { + return Promise.resolve(false) + } + return sendToTerminal(input, targetHandle, () => isDeliveryTargetCurrent(targetHandle)) + } +} + +export function createTerminalAccessoryRepeatController() { + let generation = 0 + let timer: ReturnType | null = null + + const stop = () => { + generation += 1 + if (timer !== null) { + clearTimeout(timer) + timer = null + } + } + + const cancel = stop + + const start = (input: TInput, send: TerminalAccessoryRepeatSender) => { + stop() + const activeGeneration = generation + const pressedAt = Date.now() + + const schedule = (delayMs: number) => { + timer = setTimeout(() => { + timer = null + if (generation !== activeGeneration) { + return + } + void send(input).then( + (sent) => { + if (sent && generation === activeGeneration) { + schedule(TERMINAL_ACCESSORY_REPEAT_INTERVAL_MS) + } + }, + () => undefined + ) + }, delayMs) + } + + void send(input).then( + (sent) => { + if (sent && generation === activeGeneration) { + schedule( + Math.min( + TERMINAL_ACCESSORY_REPEAT_DELAY_MS, + Math.max(0, TERMINAL_ACCESSORY_REPEAT_DELAY_MS - (Date.now() - pressedAt)) + ) + ) + } + }, + () => undefined + ) + } + + return { cancel, start, stop } +} diff --git a/mobile/src/terminal/terminal-key-definitions.ts b/mobile/src/terminal/terminal-key-definitions.ts new file mode 100644 index 00000000000..e6aeb63d45c --- /dev/null +++ b/mobile/src/terminal/terminal-key-definitions.ts @@ -0,0 +1,143 @@ +import type { TerminalAccessoryKey, TerminalShortcutSpecialKey } from './terminal-accessory-keys' + +export const SPECIAL_KEY_LABELS: Record = { + 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 = { + 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' } +] diff --git a/mobile/src/terminal/terminal-live-accessory-raw-send.test.ts b/mobile/src/terminal/terminal-live-accessory-raw-send.test.ts index 7a1110b407b..4ecab7dd10c 100644 --- a/mobile/src/terminal/terminal-live-accessory-raw-send.test.ts +++ b/mobile/src/terminal/terminal-live-accessory-raw-send.test.ts @@ -1,8 +1,16 @@ import { describe, expect, it, vi } from 'vitest' import { sendTerminalLiveAccessoryRawBytes } from './terminal-live-accessory-raw-send' import type { RpcClient } from '../transport/rpc-client' +import type { RpcResponse } from '../transport/types' -function captureClient(result: Promise = Promise.resolve({ ok: true })) { +function captureClient( + result: Promise = Promise.resolve({ + id: 'send', + ok: true, + result: { send: { handle: 'terminal-a', accepted: true, bytesWritten: 1 } }, + _meta: { runtimeId: 'test-runtime' } + }) +) { const sendRequest = vi.fn(() => result) return { client: { sendRequest } as unknown as Pick, sendRequest } } @@ -20,7 +28,7 @@ describe('terminal live accessory raw send', () => { it('sends raw bytes now-or-never with the device presence tag', async () => { const { client, sendRequest } = captureClient() - await sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client }) + await expect(sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client })).resolves.toBe(true) expect(sendRequest).toHaveBeenCalledWith( 'terminal.send', @@ -33,7 +41,9 @@ describe('terminal live accessory raw send', () => { it('drops the bytes instead of sending while disconnected', async () => { const { client, sendRequest } = captureClient() - await sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client, connState: 'reconnecting' }) + await expect( + sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client, connState: 'reconnecting' }) + ).resolves.toBe(false) expect(sendRequest).not.toHaveBeenCalled() }) @@ -41,16 +51,28 @@ describe('terminal live accessory raw send', () => { it('drops the bytes when the terminal selection went stale mid-flush', async () => { const { client, sendRequest } = captureClient() - await sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client, activeHandle: 'terminal-b' }) + await expect( + sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client, activeHandle: 'terminal-b' }) + ).resolves.toBe(false) expect(sendRequest).not.toHaveBeenCalled() }) - it('swallows a rejected send so accessory taps never surface transport errors', async () => { + it('reports a rejected send without surfacing transport errors', async () => { const { client } = captureClient(Promise.reject(new Error('Not connected: terminal.send'))) - await expect( - sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client }) - ).resolves.toBeUndefined() + await expect(sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client })).resolves.toBe(false) + }) + + it('reports a fulfilled RPC failure as a failed send', async () => { + const response: RpcResponse = { + id: 'send', + ok: false, + error: { code: 'terminal_error', message: 'failed' }, + _meta: { runtimeId: 'test-runtime' } + } + const { client } = captureClient(Promise.resolve(response)) + + await expect(sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client })).resolves.toBe(false) }) }) diff --git a/mobile/src/terminal/terminal-live-accessory-raw-send.ts b/mobile/src/terminal/terminal-live-accessory-raw-send.ts index 792491f6c63..3824d750792 100644 --- a/mobile/src/terminal/terminal-live-accessory-raw-send.ts +++ b/mobile/src/terminal/terminal-live-accessory-raw-send.ts @@ -1,4 +1,5 @@ import { getTerminalLiveAccessoryRawSendTarget } from './terminal-live-accessory-raw-send-target' +import { isTerminalSendRpcAccepted } from './terminal-send-rpc-response' import { buildTerminalSendParams, TERMINAL_INPUT_SEND_OPTIONS } from './terminal-send-request' import type { RpcClient } from '../transport/rpc-client' import type { ConnectionState } from '../transport/types' @@ -15,7 +16,7 @@ type TerminalLiveAccessoryRawSendArgs = { export async function sendTerminalLiveAccessoryRawBytes( args: TerminalLiveAccessoryRawSendArgs -): Promise { +): Promise { // Why: async IME flushing can outlive the original terminal selection. const rawSendTarget = getTerminalLiveAccessoryRawSendTarget({ targetHandle: args.targetHandle, @@ -23,9 +24,9 @@ export async function sendTerminalLiveAccessoryRawBytes( activeSessionTabType: args.activeSessionTabType }) if (!args.client || !rawSendTarget || args.connState !== 'connected') { - return + return false } - await args.client + return args.client .sendRequest( 'terminal.send', buildTerminalSendParams({ @@ -36,8 +37,5 @@ export async function sendTerminalLiveAccessoryRawBytes( }), TERMINAL_INPUT_SEND_OPTIONS ) - .then( - () => undefined, - () => undefined - ) + .then(isTerminalSendRpcAccepted, () => false) } diff --git a/mobile/src/terminal/use-terminal-live-accessory-input-commit.test.ts b/mobile/src/terminal/use-terminal-live-accessory-input-commit.test.ts index 2542b315a8f..ad1d1726798 100644 --- a/mobile/src/terminal/use-terminal-live-accessory-input-commit.test.ts +++ b/mobile/src/terminal/use-terminal-live-accessory-input-commit.test.ts @@ -26,6 +26,7 @@ function createDeferredBoolean(): DeferredBoolean { } type AccessoryInputCommitHarnessOptions = { + readonly composing?: boolean readonly heldText?: string readonly sentText?: string readonly pendingHandle?: string | null @@ -46,6 +47,7 @@ type AccessoryInputCommitHarness = { } function createAccessoryInputCommitHarness({ + composing, heldText = '', sentText = '', pendingHandle = null, @@ -55,6 +57,7 @@ function createAccessoryInputCommitHarness({ }: AccessoryInputCommitHarnessOptions = {}): AccessoryInputCommitHarness { const activeHandle = 'terminal-a' const heldLiveInputTextRef: RefObject = { current: heldText } + const liveInputComposingRef: RefObject = { current: composing } const sentLiveInputTextRef: RefObject = { current: sentText } const pendingLiveInputHandleRef: RefObject = { current: pendingHandle } const liveInputRef: RefObject = { current: null } @@ -66,7 +69,9 @@ function createAccessoryInputCommitHarness({ return sendResult } } - const applyLiveInputMirror = vi.fn((_handle: string, _fieldText: string) => {}) + const applyLiveInputMirror = vi.fn( + async (_handle: string, _fieldText: string, _composing?: boolean) => true + ) const clearPendingLiveInputCommit = vi.fn(() => {}) const flushPendingLiveInputText = vi.fn(async (_expectedHandle: string | null) => flushResult) const waitForPendingLiveInputFlush = vi.fn(async () => waitResult) @@ -82,6 +87,7 @@ function createAccessoryInputCommitHarness({ clearPendingLiveInputCommit, flushPendingLiveInputText, heldLiveInputTextRef, + liveInputComposingRef, liveInputRef, liveInputTerminalHandles, pendingLiveInputHandleRef, @@ -165,6 +171,20 @@ describe('terminal live accessory input commit hook', () => { expect(result).toEqual({ kind: 'handled' }) }) + it('Given held text with a failed control send When committed Then reports the accessory input as suppressed', async () => { + const harness = createAccessoryInputCommitHarness({ + heldText: '한', + pendingHandle: 'terminal-a', + sendResult: false + }) + + const result = await harness.commit({ bytes: '\x1b' }) + + expect(harness.flushPendingLiveInputText).toHaveBeenCalledWith('terminal-a') + expect(harness.sent).toEqual(['\x1b']) + expect(result).toEqual({ kind: 'suppress-raw' }) + }) + it('Given raw accessory bytes with no held text When committed Then allows the raw send without flushing', async () => { // Given const harness = createAccessoryInputCommitHarness({ pendingHandle: null }) @@ -178,10 +198,11 @@ describe('terminal live accessory input commit hook', () => { expect(harness.sent).toEqual([]) }) - it('Given accessory backspace with a held syllable When committed Then mirrors the emptied field without terminal bytes', async () => { + it('Given accessory backspace with reported composition When committed Then keeps the edited preedit held', async () => { // Given const harness = createAccessoryInputCommitHarness({ - heldText: '한', + composing: true, + heldText: 'ni hao', sentText: '', pendingHandle: 'terminal-a' }) @@ -190,14 +211,31 @@ describe('terminal live accessory input commit hook', () => { const result = await harness.commit({ bytes: '\x7f', localEdit: 'backspace' }) // Then - expect(harness.applyLiveInputMirror).toHaveBeenCalledWith('terminal-a', '') + expect(harness.applyLiveInputMirror).toHaveBeenCalledWith('terminal-a', 'ni ha', true) expect(result).toEqual({ kind: 'handled' }) expect(harness.sent).toEqual([]) }) + it('Given accessory backspace with an unreported hold When committed Then preserves the Android fallback', async () => { + // Given + const harness = createAccessoryInputCommitHarness({ + heldText: '한글', + sentText: '', + pendingHandle: 'terminal-a' + }) + + // When + const result = await harness.commit({ bytes: '\x7f', localEdit: 'backspace' }) + + // Then + expect(harness.applyLiveInputMirror).toHaveBeenCalledWith('terminal-a', '한', undefined) + expect(result).toEqual({ kind: 'handled' }) + }) + it('Given accessory backspace with mirrored sent text When committed Then mirrors the shortened field so the diff emits DEL', async () => { // Given const harness = createAccessoryInputCommitHarness({ + composing: false, heldText: '', sentText: 'ab', pendingHandle: 'terminal-a' @@ -207,7 +245,7 @@ describe('terminal live accessory input commit hook', () => { const result = await harness.commit({ bytes: '\x7f', localEdit: 'backspace' }) // Then - expect(harness.applyLiveInputMirror).toHaveBeenCalledWith('terminal-a', 'a') + expect(harness.applyLiveInputMirror).toHaveBeenCalledWith('terminal-a', 'a', false) expect(result).toEqual({ kind: 'handled' }) }) }) diff --git a/mobile/src/terminal/use-terminal-live-accessory-input-commit.ts b/mobile/src/terminal/use-terminal-live-accessory-input-commit.ts index 0893ae16098..80a8422d085 100644 --- a/mobile/src/terminal/use-terminal-live-accessory-input-commit.ts +++ b/mobile/src/terminal/use-terminal-live-accessory-input-commit.ts @@ -21,10 +21,15 @@ export async function getTerminalLiveAccessoryInactiveInputCommitResult( type TerminalLiveAccessoryInputCommitOptions = { readonly activeHandle: string | null - readonly applyLiveInputMirror: (handle: string, fieldText: string) => void + readonly applyLiveInputMirror: ( + handle: string, + fieldText: string, + composing?: boolean + ) => Promise readonly clearPendingLiveInputCommit: () => void readonly flushPendingLiveInputText: (expectedHandle: string | null) => Promise readonly heldLiveInputTextRef: RefObject + readonly liveInputComposingRef: RefObject readonly liveInputRef: RefObject readonly liveInputTerminalHandles: ReadonlySet readonly pendingLiveInputHandleRef: RefObject @@ -40,6 +45,7 @@ export function useTerminalLiveAccessoryInputCommit({ clearPendingLiveInputCommit, flushPendingLiveInputText, heldLiveInputTextRef, + liveInputComposingRef, liveInputRef, liveInputTerminalHandles, pendingLiveInputHandleRef, @@ -81,15 +87,21 @@ export function useTerminalLiveAccessoryInputCommit({ // field is edited here and the mirror diff syncs the PTY echo. setLiveInputCapture(editedText) liveInputRef.current?.setNativeProps({ text: editedText }) - applyLiveInputMirror(activeHandle, editedText) - return { kind: 'handled' } + // Preserve undefined so Android's heuristic hold still settles on its timer. + const sent = await applyLiveInputMirror( + activeHandle, + editedText, + liveInputComposingRef.current + ) + return sent ? { kind: 'handled' } : { kind: 'suppress-raw' } } - case 'commit-held-then-send': - await sendTerminalLiveControlAfterPendingFlush( + case 'commit-held-then-send': { + const sent = await sendTerminalLiveControlAfterPendingFlush( () => flushPendingLiveInputText(activeHandle), () => sendLiveTerminalInputRef.current(activeHandle, decision.bytes) ) - return { kind: 'handled' } + return sent ? { kind: 'handled' } : { kind: 'suppress-raw' } + } default: decision satisfies never return { kind: 'handled' } @@ -101,6 +113,7 @@ export function useTerminalLiveAccessoryInputCommit({ clearPendingLiveInputCommit, flushPendingLiveInputText, heldLiveInputTextRef, + liveInputComposingRef, liveInputRef, liveInputTerminalHandles, pendingLiveInputHandleRef, diff --git a/mobile/src/terminal/use-terminal-live-input-commit.test.ts b/mobile/src/terminal/use-terminal-live-input-commit.test.ts index d4a39d2d417..6077ccb4033 100644 --- a/mobile/src/terminal/use-terminal-live-input-commit.test.ts +++ b/mobile/src/terminal/use-terminal-live-input-commit.test.ts @@ -161,6 +161,49 @@ describe('terminal live input commit hook', () => { expect(sent).toEqual([]) }) + it('Given an iOS pinyin preedit When accessory Backspace edits it Then only the candidate reaches the terminal', async () => { + // Given + vi.useFakeTimers() + const { handlers, sent } = createTerminalLiveInputCommitHarness() + changeLiveInput(handlers, 'ni hao', true) + + // When + await handlers.handleLiveInputAccessoryBytes({ bytes: '\x7f', localEdit: 'backspace' }) + await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_HELD_PREEDIT_COMMIT_DELAY_MS * 2) + changeLiveInput(handlers, '你好', false) + + // Then + await vi.waitFor(() => expect(sent).toEqual(['你好'])) + }) + + it('Given an Android heuristic hold When accessory Backspace edits it Then the remaining text still settles', async () => { + // Given + vi.useFakeTimers() + const { handlers, sent } = createTerminalLiveInputCommitHarness() + changeLiveInput(handlers, '한글') + + // When + await handlers.handleLiveInputAccessoryBytes({ bytes: '\x7f', localEdit: 'backspace' }) + await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_HELD_PREEDIT_COMMIT_DELAY_MS) + + // Then + await vi.waitFor(() => expect(sent).toEqual(['한'])) + }) + + it('Given a failed mirrored Backspace When accessory input commits Then reports failure', async () => { + const { handlers, sent } = createTerminalLiveInputCommitHarness({ sendResult: false }) + changeLiveInput(handlers, 'a', false) + await vi.waitFor(() => expect(sent).toEqual(['a'])) + + const result = await handlers.handleLiveInputAccessoryBytes({ + bytes: '\x7f', + localEdit: 'backspace' + }) + + expect(sent).toEqual(['a', '\x7f']) + expect(result).toEqual({ kind: 'suppress-raw' }) + }) + it('Given a held syllable When the settle timer elapses Then commits it to the terminal', async () => { // Given vi.useFakeTimers() diff --git a/mobile/src/terminal/use-terminal-live-input-commit.ts b/mobile/src/terminal/use-terminal-live-input-commit.ts index b05241f6421..952612f0a87 100644 --- a/mobile/src/terminal/use-terminal-live-input-commit.ts +++ b/mobile/src/terminal/use-terminal-live-input-commit.ts @@ -68,6 +68,7 @@ export function useTerminalLiveInputCommit({ clearPendingLiveInputCommit, flushPendingLiveInputText, heldLiveInputTextRef, + liveInputComposingRef, pendingLiveInputHandleRef, sentLiveInputTextRef, waitForPendingLiveInputFlush @@ -132,7 +133,7 @@ export function useTerminalLiveInputCommit({ // that differs from the native field text, so the controlled capture must // echo the field verbatim; only the PTY mirror sees normalized text. setLiveInputCapture(nativeEvent.text) - applyLiveInputMirror( + void applyLiveInputMirror( activeHandle, normalizeTerminalTextInput(nativeEvent.text), nativeEvent.isComposing @@ -196,6 +197,7 @@ export function useTerminalLiveInputCommit({ clearPendingLiveInputCommit, flushPendingLiveInputText, heldLiveInputTextRef, + liveInputComposingRef, liveInputRef, liveInputTerminalHandles, pendingLiveInputHandleRef, diff --git a/mobile/src/terminal/use-terminal-live-pending-input-flush.ts b/mobile/src/terminal/use-terminal-live-pending-input-flush.ts index 75dd3e49f61..ff0c09c1c51 100644 --- a/mobile/src/terminal/use-terminal-live-pending-input-flush.ts +++ b/mobile/src/terminal/use-terminal-live-pending-input-flush.ts @@ -30,10 +30,15 @@ type RunTerminalLiveMirrorStep = ( ) => Promise type TerminalLivePendingInputFlush = { - readonly applyLiveInputMirror: (handle: string, fieldText: string, composing?: boolean) => void + readonly applyLiveInputMirror: ( + handle: string, + fieldText: string, + composing?: boolean + ) => Promise readonly clearPendingLiveInputCommit: () => void readonly flushPendingLiveInputText: (expectedHandle: string | null) => Promise readonly heldLiveInputTextRef: RefObject + readonly liveInputComposingRef: RefObject readonly pendingLiveInputHandleRef: RefObject readonly sentLiveInputTextRef: RefObject readonly waitForPendingLiveInputFlush: () => Promise @@ -50,6 +55,7 @@ export function useTerminalLivePendingInputFlush({ const heldCommitTimerRef = useRef | null>(null) const pendingLiveInputFlushRef = useRef(createTerminalLivePendingFlushState()) const heldLiveInputTextRef = useRef('') + const liveInputComposingRef = useRef(undefined) const sentLiveInputTextRef = useRef('') const pendingLiveInputHandleRef = useRef(null) const runMirrorStepRef = useRef(async () => false) @@ -65,6 +71,7 @@ export function useTerminalLivePendingInputFlush({ clearHeldCommitTimer() cancelTerminalLivePendingFlush(pendingLiveInputFlushRef.current) heldLiveInputTextRef.current = '' + liveInputComposingRef.current = undefined sentLiveInputTextRef.current = '' pendingLiveInputHandleRef.current = null }, [clearHeldCommitTimer]) @@ -106,6 +113,7 @@ export function useTerminalLivePendingInputFlush({ }) sentLiveInputTextRef.current = step.nextSentText heldLiveInputTextRef.current = step.heldText + liveInputComposingRef.current = composing pendingLiveInputHandleRef.current = step.heldText.length > 0 || step.nextSentText.length > 0 ? handle : null @@ -149,9 +157,8 @@ export function useTerminalLivePendingInputFlush({ }, [runMirrorStep]) const applyLiveInputMirror = useCallback( - (handle: string, fieldText: string, composing?: boolean): void => { - void runMirrorStep(handle, fieldText, false, composing) - }, + (handle: string, fieldText: string, composing?: boolean): Promise => + runMirrorStep(handle, fieldText, false, composing), [runMirrorStep] ) @@ -187,6 +194,7 @@ export function useTerminalLivePendingInputFlush({ heldCommitTimerRef.current = null } heldLiveInputTextRef.current = '' + liveInputComposingRef.current = undefined sentLiveInputTextRef.current = '' pendingLiveInputHandleRef.current = null cancelTerminalLivePendingFlush(pendingLiveInputFlushRef.current) @@ -198,6 +206,7 @@ export function useTerminalLivePendingInputFlush({ clearPendingLiveInputCommit, flushPendingLiveInputText, heldLiveInputTextRef, + liveInputComposingRef, pendingLiveInputHandleRef, sentLiveInputTextRef, waitForPendingLiveInputFlush diff --git a/mobile/src/transport/client-context-connection-metrics.ts b/mobile/src/transport/client-context-connection-metrics.ts index fa085d85423..1e2ff6f7919 100644 --- a/mobile/src/transport/client-context-connection-metrics.ts +++ b/mobile/src/transport/client-context-connection-metrics.ts @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react' -import { useRpcClientContext, type RpcClientContextValue } from './client-context' +import { useRpcClientContext } from './client-context' +import type { RpcClientContextValue } from './rpc-client-context-contract' import type { MobileConnectionPath } from './stable-logical-rpc-client' export function useReconnectAttempt(hostId: string | undefined): number { @@ -10,6 +11,20 @@ export function useLastConnectedAt(hostId: string | undefined): number | null { return useHostMetric(hostId, (context, id) => context.getLastConnectedAt(id), null) } +export function useConnectionPathStatus(hostId: string | undefined): { + activePath: MobileConnectionPath + pendingPath: MobileConnectionPath | null +} { + return useHostMetric( + hostId, + (context, id) => ({ + activePath: context.getActivePath(id), + pendingPath: context.getPendingPath(id) + }), + { activePath: 'lan', pendingPath: null } + ) +} + // Both inputs classifyConnection needs about a relay recovery, read from one // subscription so a screen cannot render half of the escalation. export function useRelayRecoveryStatus(hostId: string | undefined): { diff --git a/mobile/src/transport/client-context.tsx b/mobile/src/transport/client-context.tsx index d525d60d6d5..f83519940c1 100644 --- a/mobile/src/transport/client-context.tsx +++ b/mobile/src/transport/client-context.tsx @@ -21,6 +21,7 @@ import { import { HostOpenRetryScheduler } from './host-open-retry-scheduler' import { openHostClientEntry, type HostClientStoreEntry } from './host-entry-opener' import { shouldPreserveActiveRelay } from './relay-reconnect-preservation' +import { recordConnectionRevival } from './persisted-connection-log-store' import { createHostClientSelectors, listHostClients, @@ -35,8 +36,6 @@ import type { ConnectionState, HostProfile } from './types' import type { RpcClientContextValue } from './rpc-client-context-contract' type StoreEntry = HostClientStoreEntry -export type { HostClientAcquisition } from './host-client-acquisition-registry' -export type { RpcClientContextValue } from './rpc-client-context-contract' const Ctx = createContext(null) @@ -311,7 +310,8 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { for (const hostId of pendingAcquisitionsRef.current.keys()) { retrySchedulerRef.current?.expedite(hostId) } - for (const entry of storeRef.current.values()) { + for (const [hostId, entry] of storeRef.current) { + recordConnectionRevival(hostId, reason) try { entry.client.notifyForeground(reason) } catch { diff --git a/mobile/src/transport/connection-log-buffer.test.ts b/mobile/src/transport/connection-log-buffer.test.ts index e7ce662534b..51d8bbb044d 100644 --- a/mobile/src/transport/connection-log-buffer.test.ts +++ b/mobile/src/transport/connection-log-buffer.test.ts @@ -53,4 +53,154 @@ describe('connection log buffer', () => { store.append('host-a', entry(2)) expect(onA).toHaveBeenCalledTimes(1) }) + + it('hydrates persisted history without dropping events recorded during app startup', async () => { + let finishLoad: (entries: readonly ConnectionLogEntry[]) => void = () => {} + const load = vi.fn( + () => + new Promise((resolve) => { + finishLoad = resolve + }) + ) + const save = vi.fn(async () => {}) + const store = createConnectionLogStore(3, { load, save }) + + store.append('host-a', entry(3)) + finishLoad([entry(1), entry(2)]) + await store.hydrate('host-a') + + expect(store.get('host-a').map((e) => e.id)).toEqual(['log-1', 'log-2', 'log-3']) + await vi.waitFor(() => expect(save).toHaveBeenCalled()) + expect(save).toHaveBeenLastCalledWith('host-a', [entry(1), entry(2), entry(3)]) + }) + + it('redacts credentials before retaining or persisting an event', async () => { + const save = vi.fn(async () => {}) + const store = createConnectionLogStore(3, { load: async () => [], save }) + + store.append('host-a', { + ...entry(1), + detail: 'resumeToken=do-not-copy deviceToken:also-secret' + }) + await store.hydrate('host-a') + + expect(store.get('host-a')[0]?.detail).toBe('resumeToken=[redacted] deviceToken:[redacted]') + await vi.waitFor(() => expect(save).toHaveBeenCalled()) + expect(JSON.stringify(save.mock.calls)).not.toContain('do-not-copy') + expect(JSON.stringify(save.mock.calls)).not.toContain('also-secret') + }) + + it('redacts quoted credential values when the object key is unquoted', async () => { + const store = createConnectionLogStore(3, { load: async () => [], save: async () => {} }) + + store.append('host-a', { + ...entry(1), + detail: `resumeToken: 'secret-one' deviceToken="secret-two"` + }) + await store.hydrate('host-a') + + expect(store.get('host-a')[0]?.detail).toBe( + `resumeToken: '[redacted]' deviceToken="[redacted]"` + ) + }) + + it('redacts the full quoted credential when its value contains an escaped quote', async () => { + const store = createConnectionLogStore(3, { load: async () => [], save: async () => {} }) + + store.append('host-a', { + ...entry(1), + detail: String.raw`{"token":"secret\"tail"} token: 'secret\'tail'` + }) + await store.hydrate('host-a') + + expect(store.get('host-a')[0]?.detail).toBe(`{"token":"[redacted]"} token: '[redacted]'`) + }) + + it('redacts unterminated quoted credentials', async () => { + const store = createConnectionLogStore(3, { load: async () => [], save: async () => {} }) + + store.append('host-a', { + ...entry(1), + message: 'token="secret', + detail: '{"authorization":"Bearer also-secret' + }) + await store.hydrate('host-a') + + expect(store.get('host-a')[0]).toMatchObject({ + message: 'token="[redacted]', + detail: '{"authorization":"[redacted]' + }) + }) + + it('redacts URL userinfo through the last authority separator', async () => { + const store = createConnectionLogStore(3, { load: async () => [], save: async () => {} }) + + store.append('host-a', { + ...entry(1), + message: 'wss://user:p@ss@example.com/x', + detail: 'https://user:pass@example.com/x' + }) + await store.hydrate('host-a') + + expect(store.get('host-a')[0]).toMatchObject({ + message: 'wss://[redacted]@example.com/x', + detail: 'https://[redacted]@example.com/x' + }) + }) + + it('does not overwrite persisted history when hydration fails and retries later', async () => { + const load = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error('storage unavailable')) + .mockResolvedValueOnce([entry(1)]) + const save = vi.fn(async () => {}) + const store = createConnectionLogStore(3, { load, save }) + + store.append('host-a', entry(2)) + await expect(store.hydrate('host-a')).rejects.toThrow('storage unavailable') + expect(save).not.toHaveBeenCalled() + + await store.hydrate('host-a') + expect(store.get('host-a').map((value) => value.id)).toEqual(['log-1', 'log-2']) + }) + + it('preserves legacy entries that reused the same event id', async () => { + const store = createConnectionLogStore(3, { + load: async () => [entry(1), { ...entry(2), id: 'log-1' }], + save: async () => {} + }) + + await store.hydrate('host-a') + + expect(store.get('host-a').map((value) => value.message)).toEqual(['event 1', 'event 2']) + }) + + it('retries one transient persistence failure without requiring another append', async () => { + const save = vi.fn().mockResolvedValue(undefined) + const store = createConnectionLogStore(3, { load: async () => [], save }) + + await store.hydrate('host-a') + await vi.waitFor(() => expect(save).toHaveBeenCalled()) + save.mockReset() + save.mockRejectedValueOnce(new Error('storage unavailable')).mockResolvedValueOnce(undefined) + store.append('host-a', entry(1)) + + await vi.waitFor(() => expect(save).toHaveBeenCalledTimes(2)) + expect(save).toHaveBeenLastCalledWith('host-a', [entry(1)]) + }) + + it('does not retry failed automatic hydration for every appended event', async () => { + const load = vi.fn().mockRejectedValue(new Error('storage unavailable')) + const store = createConnectionLogStore(3, { load, save: async () => {} }) + + store.append('host-a', entry(1)) + await expect(store.hydrate('host-a')).rejects.toThrow('storage unavailable') + store.append('host-a', entry(2)) + store.append('host-a', entry(3)) + await Promise.resolve() + + expect(load).toHaveBeenCalledTimes(1) + await expect(store.hydrate('host-a')).rejects.toThrow('storage unavailable') + expect(load).toHaveBeenCalledTimes(2) + }) }) diff --git a/mobile/src/transport/connection-log-buffer.ts b/mobile/src/transport/connection-log-buffer.ts index 1b223ad8e1f..941f7971498 100644 --- a/mobile/src/transport/connection-log-buffer.ts +++ b/mobile/src/transport/connection-log-buffer.ts @@ -1,4 +1,5 @@ import type { ConnectionLogEntry } from './types' +import { redactConnectionLogEntry } from '../diagnostics/connection-log-redaction' // Why: the rpc-client's onLog entries were only wired during pairing; for // long-lived host connections everything went to console.log, invisible to @@ -12,20 +13,108 @@ const MAX_ENTRIES_PER_HOST = 200 export type ConnectionLogStore = { append: (hostId: string, entry: ConnectionLogEntry) => void get: (hostId: string) => readonly ConnectionLogEntry[] + hydrate: (hostId: string) => Promise subscribe: (hostId: string, listener: () => void) => () => void } +export type ConnectionLogPersistence = { + load: (hostId: string) => Promise + save: (hostId: string, entries: readonly ConnectionLogEntry[]) => Promise +} + export function createConnectionLogStore( - maxEntriesPerHost: number = MAX_ENTRIES_PER_HOST + maxEntriesPerHost: number = MAX_ENTRIES_PER_HOST, + persistence?: ConnectionLogPersistence ): ConnectionLogStore { const entriesByHost = new Map() const listenersByHost = new Map void>>() + const hydratedHosts = new Set() + const hydrationFailedHosts = new Set() + const hydrationByHost = new Map>() + const saveByHost = new Map>() // Why: useSyncExternalStore compares snapshots by reference — getSnapshot // must return the SAME array until the data actually changes, or React // loops re-rendering. Cache per host; invalidate on append. const snapshotByHost = new Map() const EMPTY: readonly ConnectionLogEntry[] = [] + const trim = (entries: ConnectionLogEntry[]): void => { + if (entries.length > maxEntriesPerHost) { + entries.splice(0, entries.length - maxEntriesPerHost) + } + } + + const notify = (hostId: string): void => { + snapshotByHost.delete(hostId) + const listeners = listenersByHost.get(hostId) + if (listeners) { + for (const listener of listeners) { + listener() + } + } + } + + const persist = (hostId: string): void => { + if (!persistence || !hydratedHosts.has(hostId)) { + return + } + const snapshot = [...(entriesByHost.get(hostId) ?? [])] + const previous = saveByHost.get(hostId) ?? Promise.resolve() + const pending = previous + .catch(() => {}) + .then(async () => { + try { + await persistence.save(hostId, snapshot) + } catch { + await persistence.save(hostId, snapshot) + } + }) + .catch(() => {}) + saveByHost.set(hostId, pending) + } + + const hydrateHost = async (hostId: string, retryAfterFailure: boolean): Promise => { + if (!persistence || hydratedHosts.has(hostId)) { + return + } + const existing = hydrationByHost.get(hostId) + if (existing) { + return existing + } + if (!retryAfterFailure && hydrationFailedHosts.has(hostId)) { + return + } + const pending = persistence + .load(hostId) + .then((stored) => { + const live = entriesByHost.get(hostId) ?? [] + const seen = new Set() + const merged: ConnectionLogEntry[] = [] + for (const entry of [...stored, ...live]) { + const redacted = redactConnectionLogEntry(entry) + const fingerprint = JSON.stringify(redacted) + if (!seen.has(fingerprint)) { + seen.add(fingerprint) + merged.push(redacted) + } + } + merged.sort((a, b) => a.ts - b.ts) + trim(merged) + entriesByHost.set(hostId, merged) + hydratedHosts.add(hostId) + hydrationFailedHosts.delete(hostId) + notify(hostId) + persist(hostId) + }) + .catch((error: unknown) => { + hydrationFailedHosts.add(hostId) + throw error + }) + .finally(() => hydrationByHost.delete(hostId)) + hydrationByHost.set(hostId, pending) + return pending + } + return { append(hostId, entry) { let entries = entriesByHost.get(hostId) @@ -33,17 +122,12 @@ export function createConnectionLogStore( entries = [] entriesByHost.set(hostId, entries) } - entries.push(entry) - if (entries.length > maxEntriesPerHost) { - entries.splice(0, entries.length - maxEntriesPerHost) - } - snapshotByHost.delete(hostId) - const listeners = listenersByHost.get(hostId) - if (listeners) { - for (const listener of listeners) { - listener() - } - } + entries.push(redactConnectionLogEntry(entry)) + trim(entries) + notify(hostId) + void hydrateHost(hostId, false) + .then(() => persist(hostId)) + .catch(() => {}) }, get(hostId) { @@ -60,6 +144,8 @@ export function createConnectionLogStore( return snapshot }, + hydrate: (hostId) => hydrateHost(hostId, true), + subscribe(hostId, listener) { let listeners = listenersByHost.get(hostId) if (!listeners) { @@ -80,5 +166,3 @@ export function createConnectionLogStore( } } } - -export const connectionLogStore = createConnectionLogStore() diff --git a/mobile/src/transport/direct-connection-log.ts b/mobile/src/transport/direct-connection-log.ts new file mode 100644 index 00000000000..ef805e643c5 --- /dev/null +++ b/mobile/src/transport/direct-connection-log.ts @@ -0,0 +1,46 @@ +import { isTailscaleEndpoint } from '../../../src/shared/remote-runtime-tailscale-hint' +import type { LivenessTimeoutEvidence } from './rpc-session-liveness-watchdog' +import type { + ConnectionLogEntry, + ConnectionLogLevel, + ConnectionLogSink, + MobileConnectionDiagnosticPath +} from './types' + +export class DirectConnectionLog { + private sequence = 0 + private readonly path: MobileConnectionDiagnosticPath + + constructor( + endpoint: string, + private readonly sink?: ConnectionLogSink + ) { + this.path = isTailscaleEndpoint(endpoint) ? 'tailscale' : 'lan' + } + + emit = ( + level: ConnectionLogLevel, + message: string, + detail?: string, + evidence?: Pick + ): void => { + this.sink?.({ + id: `log-${++this.sequence}-${Date.now()}`, + ts: Date.now(), + level, + message, + detail, + ...evidence, + path: evidence?.path ?? this.path + }) + } + + livenessTimeout = (evidence: LivenessTimeoutEvidence): void => { + this.emit( + 'error', + 'Connection health check failed', + `${evidence.reason}; ${evidence.missedProbes}/${evidence.missedProbeLimit} probes missed; last authenticated activity ${evidence.lastInboundAgeMs}ms ago`, + { code: 'liveness-timeout' } + ) + } +} diff --git a/mobile/src/transport/direct-rpc-client.ts b/mobile/src/transport/direct-rpc-client.ts index 4ac7e9fb361..36ed573aa5b 100644 --- a/mobile/src/transport/direct-rpc-client.ts +++ b/mobile/src/transport/direct-rpc-client.ts @@ -1,4 +1,5 @@ import type { ConnectOptions, RpcClient, SendRequestOptions } from './rpc-client' +import { DirectConnectionLog } from './direct-connection-log' import { RpcClientAuthenticationRetry } from './rpc-client-authentication-retry' import { RpcClientConnectionState } from './rpc-client-connection-state' import { @@ -16,12 +17,7 @@ import { } from './rpc-client-stream-registry' import { RpcSessionLivenessWatchdog } from './rpc-session-liveness-watchdog' import { isStaleForegroundDial } from './rpc-stale-dial' -import type { - ConnectionLogLevel, - ConnectionState, - ForegroundNudgeReason, - RpcResponse -} from './types' +import type { ConnectionState, ForegroundNudgeReason, RpcResponse } from './types' const LIVENESS_REQUEST_ID_PREFIX = 'mobile-liveness-' @@ -36,7 +32,7 @@ export class DirectRpcClient implements RpcClient { private readonly authenticationRetry: RpcClientAuthenticationRetry private readonly socketClose: RpcClientSocketCloseController private requestCounter = 0 - private logCounter = 0 + private readonly connectionLog: DirectConnectionLog private intentionallyClosed = false private authenticationGeneration = 0 private livenessSession: RpcClientSocketSession | null = null @@ -47,10 +43,12 @@ export class DirectRpcClient implements RpcClient { serverPublicKeyB64: string, private readonly options: ConnectOptions ) { + this.connectionLog = new DirectConnectionLog(endpoint, options.onLog) this.reconnect = new RpcClientReconnectSchedule({ openConnection: () => this.openConnection(), rejectConnectWaiters: (reason) => this.connectionState.rejectWaiters(reason), - emitLog: (message, detail) => this.emitLog('info', message, detail) + emitLog: (message, detail) => + this.connectionLog.emit('info', message, detail, { code: 'retry-scheduled' }) }) this.connectionState = new RpcClientConnectionState({ endpoint, @@ -78,7 +76,8 @@ export class DirectRpcClient implements RpcClient { if (identity === this.livenessSession && this.socketSession === this.livenessSession) { this.socketClose.forceClose(this.livenessSession) } - } + }, + onTimeout: this.connectionLog.livenessTimeout }) this.socketFactory = new RpcClientSocketFactory({ endpoint, @@ -89,7 +88,7 @@ export class DirectRpcClient implements RpcClient { getReconnectAttempt: () => this.getReconnectAttempt(), getLastConnectedAt: () => this.getLastConnectedAt(), isIntentionallyClosed: () => this.intentionallyClosed, - emitLog: (level, message, detail) => this.emitLog(level, message, detail), + emitLog: this.connectionLog.emit, onHandshakeStarted: () => this.connectionState.publish('handshaking'), onAuthenticated: (session) => this.handleAuthenticated(session), onAuthRejected: (reason) => this.authenticationRetry.reject(reason), @@ -102,7 +101,8 @@ export class DirectRpcClient implements RpcClient { this.authenticationRetry = new RpcClientAuthenticationRetry({ endpoint, stopLiveness: () => this.stopLiveness(), - emitWarning: (message, detail) => this.emitLog('warn', message, detail), + emitWarning: (message, detail) => + this.connectionLog.emit('warn', message, detail, { code: 'authentication-rejected' }), retry: (reason) => this.retryAuthentication(reason), latchFailure: (reason) => this.latchAuthenticationFailure(reason) }) @@ -122,7 +122,8 @@ export class DirectRpcClient implements RpcClient { this.stopLiveness() } }, - emitWarning: (message, detail) => this.emitLog('warn', message, detail) + emitWarning: (message, detail, evidence) => + this.connectionLog.emit('warn', message, detail, evidence) }) this.openConnection() } @@ -232,7 +233,9 @@ export class DirectRpcClient implements RpcClient { this.reconnect.authenticated() this.authenticationRetry.accepted() this.connectionState.publish('connected') - this.emitLog('success', 'Authenticated', 'Channel ready for RPC') + this.connectionLog.emit('success', 'Authenticated', 'Channel ready for RPC', { + code: 'direct-connected' + }) this.streams.replayAfterAuthentication() } @@ -311,14 +314,4 @@ export class DirectRpcClient implements RpcClient { private nextId(): string { return `rpc-${++this.requestCounter}-${Date.now()}` } - - private emitLog(level: ConnectionLogLevel, message: string, detail?: string): void { - this.options.onLog?.({ - id: `log-${++this.logCounter}-${Date.now()}`, - ts: Date.now(), - level, - message, - detail - }) - } } diff --git a/mobile/src/transport/host-app-version-store.test.ts b/mobile/src/transport/host-app-version-store.test.ts new file mode 100644 index 00000000000..a9f9f92d105 --- /dev/null +++ b/mobile/src/transport/host-app-version-store.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const asyncStorageMock = vi.hoisted(() => ({ + getItem: vi.fn(), + setItem: vi.fn() +})) + +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: asyncStorageMock +})) + +import { + loadHostAppVersion, + normalizeHostAppVersion, + recordHostAppVersion +} from './host-app-version-store' + +describe('host app version store', () => { + beforeEach(() => { + vi.clearAllMocks() + asyncStorageMock.getItem.mockResolvedValue(null) + asyncStorageMock.setItem.mockResolvedValue(undefined) + }) + + it('persists a bounded version once per host', async () => { + asyncStorageMock.getItem.mockResolvedValueOnce(null).mockResolvedValueOnce('1.4.191') + + await recordHostAppVersion('host-1', ' 1.4.191 ') + await recordHostAppVersion('host-1', '1.4.191') + + expect(asyncStorageMock.setItem).toHaveBeenCalledOnce() + expect(asyncStorageMock.setItem).toHaveBeenCalledWith( + 'orca:host-app-version:v1:host-1', + '1.4.191' + ) + }) + + it('loads a previously observed version without requiring a live host', async () => { + asyncStorageMock.getItem.mockResolvedValue('1.4.188') + + await expect(loadHostAppVersion('host-1')).resolves.toBe('1.4.188') + }) + + it('rejects multiline, empty, and oversized host-provided values', async () => { + expect(normalizeHostAppVersion('1.4.191\nInjected: value')).toBeNull() + expect(normalizeHostAppVersion(' ')).toBeNull() + expect(normalizeHostAppVersion('x'.repeat(65))).toBeNull() + + await recordHostAppVersion('host-1', '1.4.191\nInjected: value') + expect(asyncStorageMock.setItem).not.toHaveBeenCalled() + }) +}) diff --git a/mobile/src/transport/host-app-version-store.ts b/mobile/src/transport/host-app-version-store.ts new file mode 100644 index 00000000000..9c649cd2d0c --- /dev/null +++ b/mobile/src/transport/host-app-version-store.ts @@ -0,0 +1,47 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' + +const STORAGE_KEY_PREFIX = 'orca:host-app-version:v1:' +const MAX_VERSION_LENGTH = 64 + +export function normalizeHostAppVersion(value: unknown): string | null { + if (typeof value !== 'string') { + return null + } + const normalized = value.trim() + if ( + normalized.length === 0 || + normalized.length > MAX_VERSION_LENGTH || + normalized.includes('\n') || + normalized.includes('\r') + ) { + return null + } + return normalized +} + +export async function loadHostAppVersion(hostId: string): Promise { + try { + return normalizeHostAppVersion(await AsyncStorage.getItem(storageKey(hostId))) + } catch { + return null + } +} + +export async function recordHostAppVersion(hostId: string, value: unknown): Promise { + const appVersion = normalizeHostAppVersion(value) + if (!appVersion) { + return + } + try { + const key = storageKey(hostId) + if (normalizeHostAppVersion(await AsyncStorage.getItem(key)) !== appVersion) { + await AsyncStorage.setItem(key, appVersion) + } + } catch { + // Best-effort diagnostic metadata must never affect host connectivity. + } +} + +function storageKey(hostId: string): string { + return `${STORAGE_KEY_PREFIX}${hostId}` +} diff --git a/mobile/src/transport/host-entry-opener.ts b/mobile/src/transport/host-entry-opener.ts index 092d205bb75..6e29f55d985 100644 --- a/mobile/src/transport/host-entry-opener.ts +++ b/mobile/src/transport/host-entry-opener.ts @@ -1,4 +1,7 @@ -import { connectionLogStore } from './connection-log-buffer' +import { + connectionLogStore, + recordConnectionClientSessionStart +} from './persisted-connection-log-store' import { loadHosts } from './host-store' import { openHostLogicalClient } from './host-logical-client' import type { HostClientOpenRegistry } from './host-client-open-registry' @@ -65,6 +68,7 @@ export async function openHostClientEntry( id: `host-open-${ticket.generation}-${Date.now()}`, ts: Date.now(), level: 'error', + code: 'host-open-failed', message: 'Host client open failed', detail: `${category}; retry ${retry.nextDelayMs}ms (failure ${retry.failureCount})` }) @@ -98,6 +102,7 @@ export async function openHostClientEntry( let client: RpcClient try { + recordConnectionClientSessionStart(hostId) client = openHostLogicalClient(host, (entry) => connectionLogStore.append(hostId, entry)) } catch { failCurrentOpen('client-construction') diff --git a/mobile/src/transport/host-status-gates.test.ts b/mobile/src/transport/host-status-gates.test.ts index f0454243703..7820eebc0d7 100644 --- a/mobile/src/transport/host-status-gates.test.ts +++ b/mobile/src/transport/host-status-gates.test.ts @@ -4,6 +4,13 @@ import { describe, expect, it, vi } from 'vitest' import type { RpcClient } from './rpc-client' import { useHostStatusGates, type HostStatusGates } from './host-status-gates' +const recordHostAppVersionMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)) + +vi.mock('./host-app-version-store', () => ({ + normalizeHostAppVersion: (value: unknown) => (typeof value === 'string' ? value : null), + recordHostAppVersion: (...args: unknown[]) => recordHostAppVersionMock(...args) +})) + describe('useHostStatusGates', () => { it('clears every prior-host gate and ignores its late response while the client is replaced', async () => { let resolveOldStatus: ((response: unknown) => void) | null = null @@ -76,6 +83,7 @@ describe('useHostStatusGates', () => { const sendRequest = vi.fn().mockResolvedValue({ ok: true, result: { + appVersion: '1.4.191', capabilities: ['browser.screencast.v1'], floatingWorkspaceEnabled: true } @@ -95,11 +103,13 @@ describe('useHostStatusGates', () => { await Promise.resolve() }) expect(gates).toMatchObject({ + desktopAppVersion: '1.4.191', hostCapabilities: ['browser.screencast.v1'], floatingWorkspaceEnabled: true }) expect(sendRequest).toHaveBeenCalledOnce() + expect(recordHostAppVersionMock).toHaveBeenCalledWith('host-1', '1.4.191') } finally { renderer?.unmount() } diff --git a/mobile/src/transport/host-status-gates.ts b/mobile/src/transport/host-status-gates.ts index f9837a8ac17..91f0205a5c7 100644 --- a/mobile/src/transport/host-status-gates.ts +++ b/mobile/src/transport/host-status-gates.ts @@ -3,10 +3,12 @@ import type { RpcClient } from './rpc-client' import type { ConnectionState, RpcSuccess } from './types' import { evaluateCompat, type CompatVerdict } from './protocol-compat' import type { DesktopStatus } from '../worktree/host-worktree-rpc-types' +import { normalizeHostAppVersion, recordHostAppVersion } from './host-app-version-store' export type HostStatusGates = { hostCapabilities: string[] floatingWorkspaceEnabled: boolean + desktopAppVersion: string | null compatVerdict: CompatVerdict statusPending: boolean } @@ -53,6 +55,7 @@ export function useHostStatusGates(args: { settle({ hostCapabilities: [], floatingWorkspaceEnabled: false, + desktopAppVersion: null, compatVerdict: { kind: 'ok' } }) return @@ -64,9 +67,14 @@ export function useHostStatusGates(args: { desktopProtocolVersion: status.protocolVersion, desktopMinCompatibleMobileVersion: status.minCompatibleMobileVersion }) + const desktopAppVersion = normalizeHostAppVersion(status.appVersion) + if (hostId && desktopAppVersion) { + void recordHostAppVersion(hostId, desktopAppVersion) + } settle({ hostCapabilities: status.capabilities ?? [], floatingWorkspaceEnabled: status.floatingWorkspaceEnabled === true, + desktopAppVersion, compatVerdict: verdict }) if (verdict.kind === 'blocked') { @@ -84,6 +92,7 @@ export function useHostStatusGates(args: { settle({ hostCapabilities: [], floatingWorkspaceEnabled: false, + desktopAppVersion: null, compatVerdict: { kind: 'ok' } }) } @@ -100,6 +109,7 @@ export function useHostStatusGates(args: { return { hostCapabilities: EMPTY_HOST_CAPABILITIES, floatingWorkspaceEnabled: false, + desktopAppVersion: null, compatVerdict: { kind: 'ok' }, statusPending: connState === 'connected' && client !== null } @@ -107,6 +117,7 @@ export function useHostStatusGates(args: { return { hostCapabilities: proven.hostCapabilities, floatingWorkspaceEnabled: proven.floatingWorkspaceEnabled, + desktopAppVersion: proven.desktopAppVersion, compatVerdict: proven.compatVerdict, // Why (F10): unchanged pending timing — the reconnect refetch is still "unknown", it just no // longer blanks the capabilities this same host already proved. diff --git a/mobile/src/transport/mobile-endpoint-lifecycle.ts b/mobile/src/transport/mobile-endpoint-lifecycle.ts index 76ba92547e0..8ee8df6948e 100644 --- a/mobile/src/transport/mobile-endpoint-lifecycle.ts +++ b/mobile/src/transport/mobile-endpoint-lifecycle.ts @@ -93,7 +93,8 @@ function createSupervisor( resumeCredentialVersion: credential.version, resumeConfirmReqId: confirmReqId, deviceToken: host.deviceToken, - desktopPublicKeyB64: host.publicKeyB64 + desktopPublicKeyB64: host.publicKeyB64, + onLog }), resolveRelay: resolveMobileRelayEndpoint, readBundle: readMobileRelayCredentialBundle, diff --git a/mobile/src/transport/mobile-endpoint-supervisor.test.ts b/mobile/src/transport/mobile-endpoint-supervisor.test.ts index 1bce6800ea8..aeb9cddef63 100644 --- a/mobile/src/transport/mobile-endpoint-supervisor.test.ts +++ b/mobile/src/transport/mobile-endpoint-supervisor.test.ts @@ -234,8 +234,10 @@ describe('mobile endpoint supervisor', () => { .fn() .mockReturnValueOnce(new FakeRelaySession('connected', new RelayOuterError(4429))) .mockImplementation(() => new FakeRelaySession('connected')) + const onLog = vi.fn() const deps = dependencies({ openRelay, + onLog, randomBytes: () => new Uint8Array([128, 0]) }) const supervisor = new MobileEndpointSupervisor(logical, host, deps) @@ -245,6 +247,13 @@ describe('mobile endpoint supervisor', () => { expect(openRelay).toHaveBeenCalledOnce() expect(logical.getPendingPath()).toBe('relay') + expect(onLog).toHaveBeenCalledWith( + expect.objectContaining({ + code: 'relay-session-failed', + path: 'relay', + message: 'Relay: active relay session failed' + }) + ) await vi.advanceTimersByTimeAsync(249) expect(openRelay).toHaveBeenCalledOnce() await vi.advanceTimersByTimeAsync(1) diff --git a/mobile/src/transport/mobile-endpoint-supervisor.ts b/mobile/src/transport/mobile-endpoint-supervisor.ts index ac41e748954..6fca6c0cc17 100644 --- a/mobile/src/transport/mobile-endpoint-supervisor.ts +++ b/mobile/src/transport/mobile-endpoint-supervisor.ts @@ -22,6 +22,11 @@ import * as recoveryPresentation from './mobile-relay-recovery-presentation' import type { StableLogicalRpcClient } from './stable-logical-rpc-client' import type { ForegroundNudgeReason, HostProfile } from './types' import { MobileRelayBackgroundGrace } from './mobile-relay-background-grace' +import { + logRelayConnected, + logRelayCredentialUnavailable, + logRelayDialFailure +} from './mobile-relay-diagnostic-log' export type { MobileEndpointSupervisorDependencies } from './mobile-endpoint-supervisor-contract' @@ -98,7 +103,7 @@ export class MobileEndpointSupervisor { recordMigration: () => { this.relayRotationPending = false this.hysteresis.recordMigration(dependencies.now()) - this.logRelay('runtime channel migrated to relay') + logRelayConnected(this.logRelay) }, scheduleLease: (expiry) => this.leaseRotation.scheduleFromLease( @@ -107,8 +112,7 @@ export class MobileEndpointSupervisor { scheduleDirectProbe: () => this.directProbe.schedule(), onBookkeepingError: (error) => this.logRelay('relay bookkeeping failed after migration', error.message.slice(0, 80)), - onDialFailure: (error) => - this.logRelay('relay dial failed', `${error.name}: ${String(error.message).slice(0, 80)}`) + onDialFailure: (error) => logRelayDialFailure(this.logRelay, error) }) this.directProbe = new DirectReturnProbe(dependencies, { hysteresis: this.hysteresis, @@ -166,7 +170,8 @@ export class MobileEndpointSupervisor { // Why: the direct client enters reconnecting after its first failed // dial and may never publish disconnected while its retry loop lives. recoveryPresentation.onActiveFailure(this.logical, this.relayReconnect, state, this.bundle) - this.relayReconnect.handleStateFailure(this.logical, state) + const relayFailure = this.relayReconnect.handleStateFailure(this.logical, state) + logRelayDialFailure(this.logRelay, relayFailure, 'active-session') } }) if (this.relayReconnect.needsRecovery(this.logical.getState())) { @@ -249,11 +254,7 @@ export class MobileEndpointSupervisor { this.logical.setRecoveryPath(null) // Why: "expired" vs "missing" separates a sleep-past-expiry phone // (needs re-pair or LAN) from a Keychain failure in field reports. - this.logRelay( - selection.bundle - ? 'relay credential expired or rejected; slow reprobe armed' - : 'no relay credential bundle; slow reprobe armed' - ) + logRelayCredentialUnavailable(this.logRelay, selection.bundle !== null) this.relayReconnect.armCredentialReprobe() if (ownsRecovery) { // Why: no dial happened — keep the session and the intent; the reprobe diff --git a/mobile/src/transport/mobile-relay-diagnostic-log.ts b/mobile/src/transport/mobile-relay-diagnostic-log.ts new file mode 100644 index 00000000000..0014294cc24 --- /dev/null +++ b/mobile/src/transport/mobile-relay-diagnostic-log.ts @@ -0,0 +1,38 @@ +import type { RelayRecoveryLog } from './mobile-relay-recovery-log' +import { RelayDirectorHttpError } from './mobile-relay-resume-director' + +export function logRelayConnected(log: RelayRecoveryLog): void { + log('runtime channel migrated to relay', undefined, { + level: 'success', + code: 'relay-connected' + }) +} + +export function logRelayDialFailure( + log: RelayRecoveryLog, + error: Error | null, + source: 'dial' | 'active-session' = 'dial' +): void { + if (!error) { + return + } + const base = `${error.name}: ${String(error.message).slice(0, 80)}` + const detail = + error instanceof RelayDirectorHttpError && error.retryAfterMs != null + ? `${base}; retry-after=${error.retryAfterMs}ms` + : base + log(source === 'dial' ? 'relay dial failed' : 'active relay session failed', detail, { + level: 'error', + code: source === 'dial' ? 'relay-dial-failed' : 'relay-session-failed' + }) +} + +export function logRelayCredentialUnavailable(log: RelayRecoveryLog, hasBundle: boolean): void { + log( + hasBundle + ? 'relay credential expired or rejected; slow reprobe armed' + : 'no relay credential bundle; slow reprobe armed', + undefined, + { level: 'warn', code: 'relay-credential-unavailable' } + ) +} diff --git a/mobile/src/transport/mobile-relay-reconnect-controller.ts b/mobile/src/transport/mobile-relay-reconnect-controller.ts index 2d99e6b1d27..a606abbe9b4 100644 --- a/mobile/src/transport/mobile-relay-reconnect-controller.ts +++ b/mobile/src/transport/mobile-relay-reconnect-controller.ts @@ -46,9 +46,7 @@ export class RelayReconnectController { this.credentials = new RelayCredentialEligibility(dependencies.now) } - getFailureCount(): number { - return this.failureCount.current() - } + getFailureCount = (): number => this.failureCount.current() reportRecoveryTo(logical: StableLogicalRpcClient): void { this.failureCount.reportTo(logical.setRecoveryAttempt) @@ -97,17 +95,17 @@ export class RelayReconnectController { return 'recover' } - handleStateFailure(logical: StableLogicalRpcClient, state: ConnectionState): void { + handleStateFailure(logical: StableLogicalRpcClient, state: ConnectionState): Error | null { if (!this.needsRecovery(state)) { - return + return null } - this.registerActiveFailure(logical) + const failure = this.registerActiveFailure(logical) this.onRetry() + return failure } - needsRecovery(state: ConnectionState): boolean { - return state !== 'connected' && state !== 'connecting' && state !== 'handshaking' - } + needsRecovery = (state: ConnectionState): boolean => + state !== 'connected' && state !== 'connecting' && state !== 'handshaking' suspendActiveRelay(logical: StableLogicalRpcClient): void { if (logical.getActivePath() !== 'relay') { @@ -207,9 +205,9 @@ export class RelayReconnectController { recordRejectedCredential = (version: number): void => this.credentials.recordRejected(version) - registerActiveFailure(logical: StableLogicalRpcClient): void { + registerActiveFailure(logical: StableLogicalRpcClient): Error | null { if (logical.getActivePath() !== 'relay') { - return + return null } const failure = this.activeSession?.getFailure() this.activeSession = null @@ -219,6 +217,7 @@ export class RelayReconnectController { } else { this.activeRelayConnectedAt = null } + return failure ?? null } // True when the caller is still inside the cooldown window and must not diff --git a/mobile/src/transport/mobile-relay-recovery-log.test.ts b/mobile/src/transport/mobile-relay-recovery-log.test.ts new file mode 100644 index 00000000000..e24173629b3 --- /dev/null +++ b/mobile/src/transport/mobile-relay-recovery-log.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it, vi } from 'vitest' +import { createRelayRecoveryLog } from './mobile-relay-recovery-log' +import type { ConnectionLogEntry } from './types' + +describe('createRelayRecoveryLog', () => { + it('uses distinct ids across logger instances', () => { + const entries: ConnectionLogEntry[] = [] + const sink = vi.fn((entry: ConnectionLogEntry) => entries.push(entry)) + createRelayRecoveryLog(() => 1, sink)('first') + createRelayRecoveryLog(() => 1, sink)('second') + + expect(entries[0]?.id).not.toBe(entries[1]?.id) + }) +}) diff --git a/mobile/src/transport/mobile-relay-recovery-log.ts b/mobile/src/transport/mobile-relay-recovery-log.ts index 9b373cd112a..21ad9dd63ad 100644 --- a/mobile/src/transport/mobile-relay-recovery-log.ts +++ b/mobile/src/transport/mobile-relay-recovery-log.ts @@ -1,6 +1,12 @@ -import type { ConnectionLogSink } from './types' +import type { ConnectionDiagnosticCode, ConnectionLogLevel, ConnectionLogSink } from './types' -export type RelayRecoveryLog = (message: string, detail?: string) => void +export type RelayRecoveryLog = ( + message: string, + detail?: string, + evidence?: { level?: ConnectionLogLevel; code?: ConnectionDiagnosticCode } +) => void + +let relayLoggerInstanceSequence = 0 // Why: relay recovery failed silently in production for weeks; every decision // must reach logcat and the in-app connection log. @@ -9,12 +15,15 @@ export function createRelayRecoveryLog( onLog?: ConnectionLogSink ): RelayRecoveryLog { let sequence = 0 - return (message, detail) => { + const instanceId = `${Date.now().toString(36)}-${(++relayLoggerInstanceSequence).toString(36)}` + return (message, detail, evidence) => { console.log(`[relay] ${message}`, detail ?? '') onLog?.({ - id: `relay-${++sequence}`, + id: `relay-${instanceId}-${++sequence}`, ts: now(), - level: 'info', + level: evidence?.level ?? 'info', + path: 'relay', + ...(evidence?.code ? { code: evidence.code } : {}), message: `Relay: ${message}`, ...(detail ? { detail } : {}) }) diff --git a/mobile/src/transport/mobile-relay-rpc-session-liveness.test.ts b/mobile/src/transport/mobile-relay-rpc-session-liveness.test.ts index b7073075b2b..a3885a226c0 100644 --- a/mobile/src/transport/mobile-relay-rpc-session-liveness.test.ts +++ b/mobile/src/transport/mobile-relay-rpc-session-liveness.test.ts @@ -21,6 +21,7 @@ vi.mock('./mobile-relay-e2ee-link', () => ({ })) import { connectMobileRelayRpcSession } from './mobile-relay-rpc-session' +import type { ConnectionLogSink } from './types' const relay = { v: 1 as const, @@ -31,7 +32,7 @@ const relay = { e2eeFraming: 2 as const } -async function authenticateSession() { +async function authenticateSession(onLog?: ConnectionLogSink) { const session = connectMobileRelayRpcSession({ relay, resumeToken: 'resume-secret', @@ -39,7 +40,8 @@ async function authenticateSession() { resumeConfirmReqId: 'confirm-1', deviceToken: 'device-token', desktopPublicKeyB64: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', - requestTimeoutMs: 30_000 + requestTimeoutMs: 30_000, + onLog }) fakes.linkOptions!.onHello({ type: 'relay-hello', @@ -103,7 +105,8 @@ describe('mobile relay RPC session liveness', () => { }) it('disconnects after two fair foreground misses', async () => { - const session = await authenticateSession() + const onLog = vi.fn() + const session = await authenticateSession(onLog) session.notifyForeground('focus') expect(sentRequests().map(({ method }) => method)).toEqual(['status.get']) @@ -114,6 +117,38 @@ describe('mobile relay RPC session liveness', () => { expect(session.getState()).toBe('disconnected') expect(fakes.close).toHaveBeenCalledOnce() + expect(onLog).toHaveBeenCalledWith( + expect.objectContaining({ + code: 'liveness-timeout', + path: 'relay', + message: 'Relay health check failed', + detail: expect.stringMatching( + /^probe-timeout; 2\/2 probes missed; last authenticated activity \d+ms ago$/ + ) + }) + ) + }) + + it('uses distinct liveness evidence IDs for sessions created in the same millisecond', async () => { + vi.setSystemTime(1_000) + const firstLog = vi.fn() + const first = await authenticateSession(firstLog) + first.notifyForeground('focus') + await vi.advanceTimersByTimeAsync(8_000) + const firstId = firstLog.mock.calls[0]?.[0].id + + vi.clearAllMocks() + fakes.sendText.mockReturnValue(true) + vi.setSystemTime(1_000) + const secondLog = vi.fn() + const second = await authenticateSession(secondLog) + second.notifyForeground('focus') + await vi.advanceTimersByTimeAsync(8_000) + const secondId = secondLog.mock.calls[0]?.[0].id + + expect(firstId).toBeTruthy() + expect(secondId).toBeTruthy() + expect(secondId).not.toBe(firstId) }) it('rate-limits foreground sequences without suppressing a retry', async () => { diff --git a/mobile/src/transport/mobile-relay-rpc-session.ts b/mobile/src/transport/mobile-relay-rpc-session.ts index 84b1e7cdbeb..f242fce07ba 100644 --- a/mobile/src/transport/mobile-relay-rpc-session.ts +++ b/mobile/src/transport/mobile-relay-rpc-session.ts @@ -11,11 +11,12 @@ import { openRpcRequestBudget, resolvePostConnectRequestTimeout } from './rpc-re import { isRpcResponse } from './rpc-response-shape' import { RpcSessionLivenessWatchdog } from './rpc-session-liveness-watchdog' import type { RpcClient } from './rpc-client' -import type { ConnectionState, RpcResponse } from './types' +import type { ConnectionLogSink, ConnectionState, RpcResponse } from './types' const RELAY_PROBE_TIMEOUT_MS = 4_000 const RELAY_MISSED_PROBE_LIMIT = 2 const RELAY_FOREGROUND_PROBE_MIN_INTERVAL_MS = 10_000 +let relayRpcSessionSequence = 0 type PendingRequest = { resolve: (response: RpcResponse) => void @@ -41,6 +42,7 @@ export function connectMobileRelayRpcSession(args: { desktopPublicKeyB64: string requestTimeoutMs?: number createSocket?: (url: string) => WebSocket + onLog?: ConnectionLogSink }): MobileRelayRpcSession { const requestTimeoutMs = args.requestTimeoutMs ?? 30_000 const pending = new Map() @@ -53,6 +55,8 @@ export function connectMobileRelayRpcSession(args: { let resumeConfirmation: DeviceResumeConfirmed | null = null let failure: Error | null = null let closed = false + let logSequence = 0 + const logSessionId = `${Date.now().toString(36)}-${(++relayRpcSessionSequence).toString(36)}` const livenessIdentity = {} const streams = new MobileRelayRpcStreams({ nextId, @@ -145,6 +149,17 @@ export function connectMobileRelayRpcSession(args: { voluntaryProbeMinIntervalMs: RELAY_FOREGROUND_PROBE_MIN_INTERVAL_MS, sendProbe: () => state === 'connected' && sendFrame({ id: nextId(), method: 'status.get', params: undefined }), + onTimeout: (evidence) => { + args.onLog?.({ + id: `relay-liveness-${logSessionId}-${++logSequence}`, + ts: Date.now(), + level: 'error', + code: 'liveness-timeout', + path: 'relay', + message: 'Relay health check failed', + detail: `${evidence.reason}; ${evidence.missedProbes}/${evidence.missedProbeLimit} probes missed; last authenticated activity ${evidence.lastInboundAgeMs}ms ago` + }) + }, terminate: () => fail(new Error('relay session liveness timeout')) }) return client diff --git a/mobile/src/transport/persisted-connection-log-store.test.ts b/mobile/src/transport/persisted-connection-log-store.test.ts new file mode 100644 index 00000000000..6635ccec696 --- /dev/null +++ b/mobile/src/transport/persisted-connection-log-store.test.ts @@ -0,0 +1,57 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ConnectionLogEntry } from './types' + +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: { + getItem: vi.fn(), + setItem: vi.fn() + } +})) + +describe('persisted connection log store', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + vi.mocked(AsyncStorage.getItem).mockReset() + vi.mocked(AsyncStorage.setItem).mockReset() + vi.mocked(AsyncStorage.setItem).mockResolvedValue(undefined) + }) + + afterEach(() => { + vi.useRealTimers() + vi.resetModules() + }) + + it('keeps a new client-session boundary when a restart shares the prior timestamp', async () => { + const stored: ConnectionLogEntry[] = [ + { + id: 'client-session-1000', + ts: 1_000, + level: 'info', + code: 'client-session-started', + message: 'Mobile client session started' + }, + { + id: 'relay-failure', + ts: 1_000, + level: 'error', + code: 'relay-session-failed', + message: 'Relay: active relay session failed' + } + ] + vi.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify(stored)) + vi.resetModules() + const { connectionLogStore, recordConnectionClientSessionStart } = + await import('./persisted-connection-log-store') + + recordConnectionClientSessionStart('host-a') + await connectionLogStore.hydrate('host-a') + + expect(connectionLogStore.get('host-a').map((entry) => entry.code)).toEqual([ + 'client-session-started', + 'relay-session-failed', + 'client-session-started' + ]) + }) +}) diff --git a/mobile/src/transport/persisted-connection-log-store.ts b/mobile/src/transport/persisted-connection-log-store.ts new file mode 100644 index 00000000000..85f9794b284 --- /dev/null +++ b/mobile/src/transport/persisted-connection-log-store.ts @@ -0,0 +1,77 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' +import { createConnectionLogStore } from './connection-log-buffer' +import type { ConnectionLogEntry } from './types' + +const STORAGE_PREFIX = 'orca.mobile.connection-log.v1.' +const clientSessionId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}` +const sessionStartedHosts = new Set() + +export const connectionLogStore = createConnectionLogStore(200, { + async load(hostId) { + const raw = await AsyncStorage.getItem(storageKey(hostId)) + if (!raw) { + return [] + } + try { + const parsed: unknown = JSON.parse(raw) + return Array.isArray(parsed) ? parsed.filter(isConnectionLogEntry) : [] + } catch { + return [] + } + }, + save(hostId, entries) { + return AsyncStorage.setItem(storageKey(hostId), JSON.stringify(entries)) + } +}) + +export function recordConnectionRevival( + hostId: string, + reason: 'app-resume' | 'network-change' +): void { + const now = Date.now() + connectionLogStore.append(hostId, { + id: `revival-${reason}-${now}`, + ts: now, + level: 'info', + code: reason === 'app-resume' ? 'app-resumed' : 'network-changed', + message: reason === 'app-resume' ? 'App returned to foreground' : 'Network changed', + detail: 'Connection recovery notified' + }) +} + +export function recordConnectionClientSessionStart(hostId: string): void { + if (sessionStartedHosts.has(hostId)) { + return + } + sessionStartedHosts.add(hostId) + const now = Date.now() + connectionLogStore.append(hostId, { + id: `client-session-${clientSessionId}-${now}`, + ts: now, + level: 'info', + code: 'client-session-started', + message: 'Mobile client session started' + }) +} + +function storageKey(hostId: string): string { + return `${STORAGE_PREFIX}${encodeURIComponent(hostId)}` +} + +function isConnectionLogEntry(value: unknown): value is ConnectionLogEntry { + if (!value || typeof value !== 'object') { + return false + } + const entry = value as Partial + return ( + typeof entry.id === 'string' && + typeof entry.ts === 'number' && + Number.isFinite(entry.ts) && + (entry.level === 'info' || + entry.level === 'success' || + entry.level === 'warn' || + entry.level === 'error') && + typeof entry.message === 'string' && + (entry.detail === undefined || typeof entry.detail === 'string') + ) +} diff --git a/mobile/src/transport/rpc-client-socket-close-controller.ts b/mobile/src/transport/rpc-client-socket-close-controller.ts index a03096757c6..65e65951954 100644 --- a/mobile/src/transport/rpc-client-socket-close-controller.ts +++ b/mobile/src/transport/rpc-client-socket-close-controller.ts @@ -6,6 +6,7 @@ import type { RpcClientSocketFactory } from './rpc-client-socket-factory' import type { RpcClientSocketSession } from './rpc-client-socket-session' import type { RpcClientStreamRegistry } from './rpc-client-stream-registry' import { RpcSynthesizedCloseIndex } from './rpc-socket-close-evidence' +import type { ConnectionLogEntry } from './types' const UNAUTHORIZED_CLOSE_CODE = 4001 @@ -21,7 +22,11 @@ type SocketCloseControllerOptions = { getAuthenticationGeneration: () => number isIntentionallyClosed: () => boolean stopLiveness: (session: RpcClientSocketSession) => void - emitWarning: (message: string, detail: string) => void + emitWarning: ( + message: string, + detail: string, + evidence?: Pick + ) => void } export class RpcClientSocketCloseController { @@ -80,7 +85,11 @@ export class RpcClientSocketCloseController { streamCount: this.options.streams.size(), attempt: this.options.reconnect.getAttempt() }) - this.options.emitWarning('WebSocket closed', 'Will attempt to reconnect') + this.options.emitWarning( + 'WebSocket closed', + `${closeCode == null ? 'Close code unavailable' : `Close code ${closeCode}`}; reconnect scheduled`, + { code: 'socket-closed' } + ) this.options.requests.rejectAll('Connection interrupted', { deliveryUnknown: true }) this.options.connectionState.publish('reconnecting') this.options.reconnect.schedule() diff --git a/mobile/src/transport/rpc-client-socket-factory.ts b/mobile/src/transport/rpc-client-socket-factory.ts index 1c21c0dd4e0..df82e460d92 100644 --- a/mobile/src/transport/rpc-client-socket-factory.ts +++ b/mobile/src/transport/rpc-client-socket-factory.ts @@ -1,7 +1,7 @@ import { publicKeyFromBase64 } from './e2ee' import { RpcClientSocketSession } from './rpc-client-socket-session' import { redactSocketEndpoint } from './socket-event-debug' -import type { ConnectionLogLevel, ConnectionState, RpcResponse } from './types' +import type { ConnectionLogEmitter, ConnectionState, RpcResponse } from './types' type SocketFactoryOptions = { endpoint: string @@ -12,7 +12,7 @@ type SocketFactoryOptions = { getReconnectAttempt: () => number getLastConnectedAt: () => number | null isIntentionallyClosed: () => boolean - emitLog: (level: ConnectionLogLevel, message: string, detail?: string) => void + emitLog: ConnectionLogEmitter onHandshakeStarted: () => void onAuthenticated: (session: RpcClientSocketSession) => void onAuthRejected: (reason: string) => void diff --git a/mobile/src/transport/rpc-client-socket-session.ts b/mobile/src/transport/rpc-client-socket-session.ts index 6eebc6b02b9..bc8f9d13e2c 100644 --- a/mobile/src/transport/rpc-client-socket-session.ts +++ b/mobile/src/transport/rpc-client-socket-session.ts @@ -9,7 +9,7 @@ import { import { isRpcResponse } from './rpc-response-shape' import { isStaleRpcSocketEvent, logRpcSocketClose } from './rpc-socket-close-evidence' import { describeSocketEvent, redactSocketEndpoint } from './socket-event-debug' -import type { ConnectionLogLevel, ConnectionState, RpcResponse } from './types' +import type { ConnectionLogEmitter, ConnectionState, RpcResponse } from './types' import { websocketPayloadToUint8 } from './websocket-payload-bytes' const CONNECT_TIMEOUT_MS = 12_000 @@ -24,7 +24,7 @@ type SocketSessionOptions = { getState: () => ConnectionState getReconnectAttempt: () => number isIntentionallyClosed: () => boolean - emitLog: (level: ConnectionLogLevel, message: string, detail?: string) => void + emitLog: ConnectionLogEmitter onHandshakeStarted: () => void onAuthenticated: (session: RpcClientSocketSession) => void onAuthRejected: (reason: string) => void @@ -261,7 +261,8 @@ export class RpcClientSocketSession { this.options.emitLog( 'error', 'WebSocket connect timeout', - `No TCP/WS handshake within ${CONNECT_TIMEOUT_MS / 1000}s — endpoint unreachable?` + `No TCP/WS handshake within ${CONNECT_TIMEOUT_MS / 1000}s — endpoint unreachable?`, + { code: 'connect-timeout' } ) this.options.onForcedClose(this) } @@ -280,7 +281,8 @@ export class RpcClientSocketSession { this.options.emitLog( 'error', 'Handshake timeout', - `No e2ee_ready/e2ee_authenticated within ${HANDSHAKE_TIMEOUT_MS / 1000}s` + `No e2ee_ready/e2ee_authenticated within ${HANDSHAKE_TIMEOUT_MS / 1000}s`, + { code: 'handshake-timeout' } ) this.options.onForcedClose(this) }, HANDSHAKE_TIMEOUT_MS) diff --git a/mobile/src/transport/rpc-session-liveness-watchdog.test.ts b/mobile/src/transport/rpc-session-liveness-watchdog.test.ts index c3aab2a243f..aa1398e44b6 100644 --- a/mobile/src/transport/rpc-session-liveness-watchdog.test.ts +++ b/mobile/src/transport/rpc-session-liveness-watchdog.test.ts @@ -46,6 +46,37 @@ describe('RpcSessionLivenessWatchdog', () => { expect(terminate).toHaveBeenCalledWith(identity) }) + it('reports causal evidence before terminating a stale session', async () => { + const onTimeout = vi.fn() + const terminate = vi.fn() + const identity = {} + const watchdog = new RpcSessionLivenessWatchdog({ + transport: 'relay', + idleProbeMs: null, + probeTimeoutMs: 4_000, + missedProbeLimit: 2, + sendProbe: () => true, + terminate, + onTimeout, + now: Date.now + }) + watchdog.start(identity) + watchdog.probeNow(identity) + + await vi.advanceTimersByTimeAsync(8_000) + + expect(onTimeout).toHaveBeenCalledWith({ + transport: 'relay', + reason: 'probe-timeout', + missedProbes: 2, + missedProbeLimit: 2, + lastInboundAgeMs: 8_000 + }) + expect(onTimeout.mock.invocationCallOrder[0]).toBeLessThan( + terminate.mock.invocationCallOrder[0]! + ) + }) + it('authenticated activity resets suspicion', async () => { const { identity, terminate, watchdog } = fixture() watchdog.probeNow(identity) diff --git a/mobile/src/transport/rpc-session-liveness-watchdog.ts b/mobile/src/transport/rpc-session-liveness-watchdog.ts index e819dbb6edf..36525f60fb0 100644 --- a/mobile/src/transport/rpc-session-liveness-watchdog.ts +++ b/mobile/src/transport/rpc-session-liveness-watchdog.ts @@ -8,6 +8,7 @@ type WatchdogOptions = { transport: 'direct' | 'relay' sendProbe: (identity: RpcSessionIdentity) => boolean terminate: (identity: RpcSessionIdentity) => void + onTimeout?: (evidence: LivenessTimeoutEvidence) => void idleProbeMs?: number | null probeTimeoutMs?: number missedProbeLimit?: number @@ -17,6 +18,14 @@ type WatchdogOptions = { clearTimer?: typeof clearTimeout } +export type LivenessTimeoutEvidence = { + transport: 'direct' | 'relay' + reason: 'probe-send-failed' | 'probe-timeout' + missedProbes: number + missedProbeLimit: number + lastInboundAgeMs: number +} + export class RpcSessionLivenessWatchdog { private identity: RpcSessionIdentity | null = null private timer: ReturnType | null = null @@ -138,7 +147,7 @@ export class RpcSessionLivenessWatchdog { sent = false } if (!sent) { - this.terminateCurrent(identity) + this.terminateCurrent(identity, 'probe-send-failed') return } this.timer = this.setTimer(() => this.handleProbeTimeout(identity, sentAt), this.probeTimeoutMs) @@ -161,7 +170,7 @@ export class RpcSessionLivenessWatchdog { } this.missedProbes += 1 if (this.missedProbes >= this.missedProbeLimit) { - this.terminateCurrent(identity) + this.terminateCurrent(identity, 'probe-timeout') return } console.log('[net] activity-probe timeout tolerated', { @@ -172,7 +181,10 @@ export class RpcSessionLivenessWatchdog { this.startProbe(identity) } - private terminateCurrent(identity: RpcSessionIdentity): void { + private terminateCurrent( + identity: RpcSessionIdentity, + reason: LivenessTimeoutEvidence['reason'] + ): void { if (this.identity !== identity) { return } @@ -184,6 +196,13 @@ export class RpcSessionLivenessWatchdog { missedProbes: this.missedProbes, missedProbeLimit: this.missedProbeLimit }) + this.options.onTimeout?.({ + transport: this.options.transport, + reason, + missedProbes: this.missedProbes, + missedProbeLimit: this.missedProbeLimit, + lastInboundAgeMs: Math.max(0, this.now() - this.lastInboundAt) + }) this.options.terminate(identity) } diff --git a/mobile/src/transport/types.ts b/mobile/src/transport/types.ts index 9d96f0561ad..c40bd979936 100644 --- a/mobile/src/transport/types.ts +++ b/mobile/src/transport/types.ts @@ -39,6 +39,25 @@ export type RpcResponse = RpcSuccess | RpcFailure export type ConnectionLogLevel = 'info' | 'success' | 'warn' | 'error' +export type MobileConnectionDiagnosticPath = 'lan' | 'tailscale' | 'relay' + +export type ConnectionDiagnosticCode = + | 'client-session-started' + | 'app-resumed' + | 'network-changed' + | 'connect-timeout' + | 'handshake-timeout' + | 'authentication-rejected' + | 'socket-closed' + | 'liveness-timeout' + | 'retry-scheduled' + | 'relay-dial-failed' + | 'relay-session-failed' + | 'relay-connected' + | 'direct-connected' + | 'relay-credential-unavailable' + | 'host-open-failed' + export type ConnectionLogEntry = { id: string ts: number @@ -47,10 +66,19 @@ export type ConnectionLogEntry = { message: string // Optional second line for endpoint/error/elapsed detail. detail?: string + code?: ConnectionDiagnosticCode + path?: MobileConnectionDiagnosticPath } export type ConnectionLogSink = (entry: ConnectionLogEntry) => void +export type ConnectionLogEmitter = ( + level: ConnectionLogLevel, + message: string, + detail?: string, + evidence?: Pick +) => void + export type ConnectionState = | 'connecting' | 'handshaking' diff --git a/mobile/src/transport/use-all-host-clients.ts b/mobile/src/transport/use-all-host-clients.ts index ac957638db6..03ac5890015 100644 --- a/mobile/src/transport/use-all-host-clients.ts +++ b/mobile/src/transport/use-all-host-clients.ts @@ -3,7 +3,7 @@ import type { RpcClient } from './rpc-client' import type { MobileConnectionPath } from './stable-logical-rpc-client' import type { ConnectionState } from './types' import { useRpcClientContext } from './client-context' -import type { HostClientAcquisition } from './client-context' +import type { HostClientAcquisition } from './host-client-acquisition-registry' type UseAllHostClientsOptions = { autoConnectHostIds?: readonly string[] diff --git a/mobile/src/worktree/host-worktree-rpc-types.ts b/mobile/src/worktree/host-worktree-rpc-types.ts index 037e4dcfa87..2884c95e87a 100644 --- a/mobile/src/worktree/host-worktree-rpc-types.ts +++ b/mobile/src/worktree/host-worktree-rpc-types.ts @@ -3,6 +3,7 @@ import type { ExecutionHostId } from '../../../src/shared/execution-host' // Locally-typed subset of the desktop status payload read from status.get. export type DesktopStatus = { + appVersion?: string protocolVersion?: number minCompatibleMobileVersion?: number // Why: absent on hosts that predate the mobile Floating Workspace entry; diff --git a/package.json b/package.json index 7b46b369406..c8e59299c70 100644 --- a/package.json +++ b/package.json @@ -111,6 +111,7 @@ "test:e2e:terminal-perf:html-report": "node config/scripts/generate-terminal-perf-html-report.mjs", "test:e2e:ssh-docker-perf": "node config/scripts/run-ssh-docker-perf-e2e.mjs", "test:e2e:ssh-docker-watcher-isolation": "node config/scripts/run-ssh-docker-watcher-isolation-e2e.mjs", + "test:e2e:ssh-docker": "node config/scripts/run-ssh-docker-e2e.mjs", "test:e2e:ssh-client-hosted-browser": "node config/scripts/run-ssh-client-hosted-browser-drop-reconnect-e2e.mjs", "test:e2e:local-ssh-browser": "node config/scripts/run-local-ssh-browser-routing-e2e.mjs", "test:e2e:ssh-docker-terminal-parking": "node config/scripts/run-ssh-docker-terminal-parking-e2e.mjs", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1d998e1b27e..2c36d26ae93 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,7 +9,7 @@ overrides: patchedDependencies: '@vscode/windows-process-tree@0.8.0': - hash: 73a90530e6b95c05dac50f2c48787fb51cb292587773016ebe57eb05e41075a0 + hash: 946ffb2b1026bcb554d9920681fb00f546c28fc5de6d95a877d198b49bf73988 path: config/patches/@vscode__windows-process-tree@0.8.0.patch '@xterm/addon-ligatures@0.11.0-beta.287': hash: 47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920 @@ -27,7 +27,7 @@ patchedDependencies: hash: 7333b3837f80a7fbd045964db6d76ba4fc118e49134bdbabb00585b6b7b60673 path: config/patches/lint-staged@16.4.0.patch node-pty@1.1.0: - hash: 9c665d9e41571341cb8986607d531b953a7f32c0dd60e1e74e633c098d80f2c1 + hash: 9a2eedbf2448b8ff1387a8a740ee5e4e968bf8484d7d80e12a3f6a2dc26b0e17 path: config/patches/node-pty@1.1.0.patch importers: @@ -69,7 +69,7 @@ importers: version: 3.3.1 node-pty: specifier: ^1.1.0 - version: 1.1.0(patch_hash=9c665d9e41571341cb8986607d531b953a7f32c0dd60e1e74e633c098d80f2c1) + version: 1.1.0(patch_hash=9a2eedbf2448b8ff1387a8a740ee5e4e968bf8484d7d80e12a3f6a2dc26b0e17) posthog-node: specifier: ^5.33.3 version: 5.33.3 @@ -407,7 +407,7 @@ importers: optionalDependencies: '@vscode/windows-process-tree': specifier: 0.8.0 - version: 0.8.0(patch_hash=73a90530e6b95c05dac50f2c48787fb51cb292587773016ebe57eb05e41075a0) + version: 0.8.0(patch_hash=946ffb2b1026bcb554d9920681fb00f546c28fc5de6d95a877d198b49bf73988) sherpa-onnx-darwin-arm64: specifier: 1.12.37 version: 1.12.37 @@ -9605,7 +9605,7 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 - '@vscode/windows-process-tree@0.8.0(patch_hash=73a90530e6b95c05dac50f2c48787fb51cb292587773016ebe57eb05e41075a0)': + '@vscode/windows-process-tree@0.8.0(patch_hash=946ffb2b1026bcb554d9920681fb00f546c28fc5de6d95a877d198b49bf73988)': dependencies: node-addon-api: 7.1.0 optional: true @@ -12046,7 +12046,7 @@ snapshots: node-int64@0.4.0: {} - node-pty@1.1.0(patch_hash=9c665d9e41571341cb8986607d531b953a7f32c0dd60e1e74e633c098d80f2c1): + node-pty@1.1.0(patch_hash=9a2eedbf2448b8ff1387a8a740ee5e4e968bf8484d7d80e12a3f6a2dc26b0e17): dependencies: node-addon-api: 7.1.1 diff --git a/src/cli/handlers/automation-handler-flags.ts b/src/cli/handlers/automation-handler-flags.ts new file mode 100644 index 00000000000..c67f592ab7c --- /dev/null +++ b/src/cli/handlers/automation-handler-flags.ts @@ -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(['hourly', 'daily', 'weekdays', 'weekly']) +const SCHEDULE_MODIFIER_FLAGS = ['day', 'time'] as const + +function getScheduleModifierFlag(flags: Map): string | undefined { + return SCHEDULE_MODIFIER_FLAGS.find((flag) => flags.has(flag)) +} + +function validateScheduleModifierApplicability( + flags: Map, + 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): 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): TuiAgent { + const provider = getRequiredStringFlag(flags, 'provider') + if (!isTuiAgent(provider)) { + throw new RuntimeClientError('invalid_argument', `Unknown provider: ${provider}`) + } + return provider +} + +export function getOptionalProviderFlag( + flags: Map +): 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): { 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, + 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 ` 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, + 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): 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): 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 +): 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 +): 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[0] + ) + if (!sourceContext) { + throw new RuntimeClientError( + 'invalid_argument', + '--source-context is not a valid TaskSourceContext' + ) + } + return sourceContext +} + +export function getWorkspaceModeFlag( + flags: Map +): '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' + ) +} diff --git a/src/cli/handlers/automations.ts b/src/cli/handlers/automations.ts index d413b8a2a79..a9a14cc7d78 100644 --- a/src/cli/handlers/automations.ts +++ b/src/cli/handlers/automations.ts @@ -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 & { destination?: AutomationDestination @@ -61,293 +59,6 @@ type AutomationUpdateParams = AutomationUpdateInput & { workspace?: string } -const PRESET_TRIGGERS = new Set(['hourly', 'daily', 'weekdays', 'weekly']) -const SCHEDULE_MODIFIER_FLAGS = ['day', 'time'] as const - -function getScheduleModifierFlag(flags: Map): string | undefined { - return SCHEDULE_MODIFIER_FLAGS.find((flag) => flags.has(flag)) -} - -function validateScheduleModifierApplicability( - flags: Map, - 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): 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): TuiAgent { - const provider = getRequiredStringFlag(flags, 'provider') - if (!isTuiAgent(provider)) { - throw new RuntimeClientError('invalid_argument', `Unknown provider: ${provider}`) - } - return provider -} - -function getOptionalProviderFlag(flags: Map): 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): { 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, - 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 ` 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, - 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): 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): 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 -): 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 -): 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[0] - ) - if (!sourceContext) { - throw new RuntimeClientError( - 'invalid_argument', - '--source-context is not a valid TaskSourceContext' - ) - } - return sourceContext -} - -function getWorkspaceModeFlag( - flags: Map -): '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, cwd: string, diff --git a/src/cli/handlers/orchestration-gate-cli.test.ts b/src/cli/handlers/orchestration-gate-cli.test.ts index 0bcdddbc98e..b4be315c155 100644 --- a/src/cli/handlers/orchestration-gate-cli.test.ts +++ b/src/cli/handlers/orchestration-gate-cli.test.ts @@ -206,6 +206,16 @@ describe('orchestration gate commands carry caller identity', () => { 'The Orca runtime closed the connection before responding. Restart Orca and try again. Orchestration mutation request ID: mutation_1.', { orchestrationRequestId: 'mutation_1', + originalCommand: [ + 'orca', + 'orchestration', + 'gate-create', + '--task', + 'task_1', + '--question', + 'ship?', + '--json' + ], failedStage: 'dispatch_input', residualResources: [ { kind: 'worktree', id: 'repo::child' }, diff --git a/src/cli/help.ts b/src/cli/help.ts index d43c8294963..7a6b3686123 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -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 [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 Attach/start helper and make active for the worktree - emulator tap Tap at normalized 0..1 coords (preferred for single taps) - emulator type Type text (US ASCII only) - emulator gesture Send begin/move/end touch points - emulator button Hardware button (home, side_button, etc.) - emulator rotate 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 ) - 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 --pass

) - 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 ] [--pairing-address ] [--mobile-pairing] [--no-pairing] [--project-root ] [--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 --pairing-code [--json] - orca environment list [--json] - orca environment show --environment [--json] - orca environment rm --environment [--json] - orca worktree list [--repo ] [--limit ] [--json] - orca worktree create --name [--repo |--project [--host ]|--project-host-setup ] [--agent ] [--prompt ] [--setup run|skip|inherit] [--base-branch ] [--issue ] [--linear-issue ] [--comment ] [--parent-worktree ] [--no-parent] [--run-hooks] [--activate] [--json] - orca worktree show --worktree [--json] - orca worktree current [--json] - orca worktree set --worktree [--display-name ] [--issue ] [--linear-issue ] [--comment ] [--workspace-status ] [--parent-worktree |--no-parent] [--json] - orca worktree rm --worktree [--force] [--run-hooks] [--json] - orca worktree ps [--limit ] [--json] - orca file open [--worktree ] [--json] - orca file diff [--staged] [--worktree ] [--json] - orca file open-changed [--mode edit|diff|both] [--worktree ] [--json] - orca terminal list [--worktree ] [--limit ] [--include-visual-layouts] [--json] - orca terminal show [--terminal ] [--json] - orca terminal read [--terminal ] [--cursor ] [--limit ] [--json] - orca terminal send [--terminal ] [--text ] [--enter] [--interrupt] [--json] - orca terminal wait [--terminal ] --for exit|tui-idle [--timeout-ms ] [--json] - orca terminal stop --worktree [--json] - orca terminal create [--worktree ] [--title ] [--command ] [--focus] [--json] - orca terminal split [--terminal ] [--direction horizontal|vertical] [--json] - orca terminal switch [--terminal ] [--json] - orca terminal close [--terminal ] [--tab] [--json] - orca project list [--json] - orca project setups [--project ] [--host ] [--json] - orca project setup-existing-folder --project --host --path [--kind git|folder] [--display-name ] [--json] - orca project setup-clone --project --host --url --destination [--display-name ] [--json] - orca project setup-create --project --host [--setup-id ] [--path ] [--kind git|folder] [--display-name ] [--worktree-base-path ] [--git-username ] [--state ready|not-set-up|setting-up|error|unsupported] [--method imported-existing-folder|cloned|provisioned] [--json] - orca project setup-update --setup [--display-name ] [--path ] [--worktree-base-path ] [--git-username ] [--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 [--json] - orca repo list [--json] - orca repo add --path [--json] - orca repo show --repo [--json] - orca repo set-base-ref --repo --ref [--json] - orca repo search-refs --repo --query [--limit ] [--json] - -Selectors: - --repo Registered repo selector such as id:, name:, or path: - --worktree Worktree selector such as id:::, name:, branch:, issue:, path:, or active/current - --terminal Runtime-issued terminal handle returned by \`orca terminal list --json\` - --parent-worktree Parent worktree selector such as id:::, branch:, issue:, path:, or active/current - --no-parent Force no parent lineage for unrelated worktree creation/update - -Terminal Send Options: - --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 Maximum wait time before timing out - -Output Options: - --json Emit machine-readable JSON instead of human text - --pairing-code Connect to a remote Orca runtime using an orca://pair?... code - --environment 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 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 Element ref from snapshot (e.g. @e3) - --url URL to navigate to - --value Value to fill or select - --input Text to type at current focus (no element needed) - --expression JavaScript expression to evaluate - --key Key to press (Enter, Tab, Escape, Control+a, etc.) - --direction

Scroll direction: up or down - --amount Scroll distance in pixels (default: viewport height) - --index Tab index (from \`tab list\`) - --page Stable browser page id (preferred for concurrent workflows) - --profile 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 Screenshot image format - --from Drag source element ref - --to Drag target element ref - --files Comma-separated file paths for upload - --timeout Wait timeout in milliseconds - --worktree 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) @@ -509,7 +148,7 @@ function formatCommandFlagHelp(flag: string, commandPath: string[]): string { return '--parent-current Use the current linked issue as parent' } if (command === 'worktree create' && flag === 'parent-worktree') { - return '--parent-worktree Parent selector such as active/current, id:::, branch:, issue:, path:, folder:, or worktree:' + return '--parent-worktree Parent selector such as identity:, active/current, id:::, branch:, issue:, path:, folder:, or worktree:' } if (command === 'orchestration task-create' && flag === 'task-title') { return '--task-title Concise title for the orchestration task' @@ -577,7 +216,7 @@ export function formatFlagHelp(flag: string): string { 'no-screenshot': '--no-screenshot Skip screenshot capture after the operation', pages: '--pages Number of scroll pages', 'parent-worktree': - '--parent-worktree Parent worktree selector such as id:::, branch:, issue:, path:, or active/current', + '--parent-worktree Parent worktree selector such as identity:, id:::, branch:, issue:, path:, or active/current', path: '--path Path argument for the command', prompt: '--prompt Prompt text for agent-backed commands', query: '--query Search text for matching refs', @@ -601,7 +240,7 @@ export function formatFlagHelp(flag: string): string { 'to-x': '--to-x Destination window-local x coordinate', 'to-y': '--to-y Destination window-local y coordinate', worktree: - '--worktree Worktree selector such as id:::, name:, branch:, issue:, path:, or active/current', + '--worktree Worktree selector such as identity:, id:::, name:, branch:, issue:, path:, or active/current', workspace: '--workspace Existing worktree selector for automation runs', 'workspace-status': '--workspace-status Board status id (defaults: todo, in-progress, in-review, completed)', diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index 8daf4db772b..1b190d9dbde 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -41,6 +41,7 @@ vi.mock('child_process', async () => { }) import { COMMAND_SPECS, main } from './index' +import { formatFlagHelp } from './help' import { GLOBAL_FLAGS, specPaths } from './args' import { okFixture, queueFixtures } from './test-fixtures' @@ -480,6 +481,7 @@ describe('orca root help', () => { const rootHelp = String(logSpy.mock.calls[0][0]) expect(rootHelp).not.toContain('--parent-workspace') expect(rootHelp).toContain('[--parent-worktree ] [--no-parent]') + expect(rootHelp).toContain('identity:') logSpy.mockClear() await main(['worktree', 'create', '--help'], '/tmp/repo') @@ -504,6 +506,7 @@ describe('orca root help', () => { const setHelp = String(logSpy.mock.calls[0][0]) expect(setHelp).not.toContain('--parent-workspace') expect(setHelp).not.toContain('folder:') + expect(formatFlagHelp('parent-worktree')).toContain('identity:') expect(setHelp).not.toContain('worktree:') expect(callMock).not.toHaveBeenCalled() }) diff --git a/src/cli/index.ts b/src/cli/index.ts index 26cfb8eea49..2a8921a2cdb 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -18,6 +18,7 @@ import { reportCliError } from './format' import { printHelp } from './help' import type { RuntimeClient } from './runtime-client' import { COMMAND_SPECS } from './specs' +import { resolveOrchestrationCliExecutable } from './runtime/orchestration-recovery-command' export { COMMAND_SPECS } from './specs' export { buildCurrentWorktreeSelector, normalizeWorktreeSelector } from './selectors' @@ -149,7 +150,9 @@ export async function main( undefined, undefined, remotePairingCode, - remoteEnvironment + remoteEnvironment, + resolveOrchestrationCliExecutable(), + argv ) return client }, diff --git a/src/cli/orchestration-mutation-recovery.test.ts b/src/cli/orchestration-mutation-recovery.test.ts new file mode 100644 index 00000000000..7664cc7ec55 --- /dev/null +++ b/src/cli/orchestration-mutation-recovery.test.ts @@ -0,0 +1,269 @@ +import { describe, expect, it } from 'vitest' +import { runProcess } from '../shared/child-process/run-process' +import { + orchestrationMutationRecoveryError, + renderCommand +} from './orchestration-mutation-recovery' +import { RuntimeClientError } from './runtime-client' + +describe('orchestration mutation recovery', () => { + it('queries a known dispatch before issuing the keyed retry', () => { + const result = orchestrationMutationRecoveryError( + new RuntimeClientError('runtime_timeout', 'request timed out', { + orchestrationRequestId: 'request_1', + dispatchId: 'dispatch_1', + originalCommand: ['orca', 'orchestration', 'worker-start', '--task', 'task_1'] + }) + ) as RuntimeClientError + + expect(result.data).toMatchObject({ + recovery: { + orchestrationRequestId: 'request_1', + dispatchId: 'dispatch_1', + queryCommand: [ + 'orca', + 'orchestration', + 'worker-show', + '--dispatch', + 'dispatch_1', + '--json' + ], + retryCommand: [ + 'orca', + 'orchestration', + 'worker-start', + '--task', + 'task_1', + '--retry-request', + 'request_1' + ], + workerDeathInferred: false + } + }) + expect(result.message.indexOf('orca orchestration worker-show')).toBeLessThan( + result.message.indexOf('orca orchestration worker-start') + ) + expect((result.data as { nextSteps?: string[] }).nextSteps).toEqual([ + 'Run orca orchestration worker-show --dispatch dispatch_1 --json before retrying.', + 'Run orca orchestration worker-start --task task_1 --retry-request request_1.' + ]) + }) + + it('does not invent a dispatch for an old-client-shaped error', () => { + const result = orchestrationMutationRecoveryError( + new RuntimeClientError('runtime_timeout', 'request timed out', { + orchestrationRequestId: 'request_2', + originalCommand: ['orca', 'orchestration', 'worker-start', '--task', 'task_2'] + }) + ) as RuntimeClientError + + expect(result.data).toMatchObject({ + recovery: { + orchestrationRequestId: 'request_2', + retryCommand: expect.arrayContaining(['--retry-request', 'request_2']), + workerDeathInferred: false + } + }) + expect((result.data as Record).recovery).not.toHaveProperty('dispatchId') + expect(result.message).not.toContain('worker death') + }) + + it('renders the exact executable and safely quotes original arguments', () => { + const result = orchestrationMutationRecoveryError( + new RuntimeClientError('runtime_timeout', 'request timed out', { + orchestrationRequestId: 'request_3', + dispatchId: 'dispatch_3', + originalCommand: [ + 'orca-dev', + 'orchestration', + 'worker-start', + '--task', + 'task 3', + '--comment', + 'literal $(do-not-run)' + ] + }) + ) as RuntimeClientError + + expect((result.data as { nextSteps?: string[] }).nextSteps).toEqual([ + 'Run orca-dev orchestration worker-show --dispatch dispatch_3 --json before retrying.', + "Run orca-dev orchestration worker-start --task 'task 3' --comment 'literal $(do-not-run)' --retry-request request_3." + ]) + expect(result.message).toContain("'literal $(do-not-run)'") + }) + + it('parses legacy command text without losing quoted arguments', () => { + const result = orchestrationMutationRecoveryError( + new RuntimeClientError('runtime_timeout', 'request timed out', { + orchestrationRequestId: 'request_4', + originalCommand: + 'orca-ide orchestration worker-stop --dispatch dispatch_4 --comment "quoted value"' + }) + ) as RuntimeClientError + + expect( + (result.data as { recovery?: { retryCommand?: string[] } }).recovery?.retryCommand + ).toEqual([ + 'orca-ide', + 'orchestration', + 'worker-stop', + '--dispatch', + 'dispatch_4', + '--comment', + 'quoted value', + '--retry-request', + 'request_4' + ]) + }) + + it.each([ + [ + 'gate-create', + ['orca', 'orchestration', 'gate-create', '--task', 'task_1', '--question', 'ship?'] + ], + [ + 'worker-retain', + ['orca', 'orchestration', 'worker-retain', '--dispatch', 'dispatch_1', '--json'] + ] + ])('replays exact %s argv with the keyed retry', (_name, originalCommand) => { + const result = orchestrationMutationRecoveryError( + new RuntimeClientError('runtime_timeout', 'request timed out', { + orchestrationRequestId: 'request_exact', + originalCommand + }) + ) as RuntimeClientError + + expect( + (result.data as { recovery?: { retryCommand?: string[] } }).recovery?.retryCommand + ).toEqual([...originalCommand, '--retry-request', 'request_exact']) + }) + + it('reuses the request identity without duplicating an existing retry flag', () => { + const result = orchestrationMutationRecoveryError( + new RuntimeClientError('runtime_timeout', 'request timed out', { + orchestrationRequestId: 'request_reused', + originalCommand: [ + 'orca', + 'orchestration', + 'worker-retain', + '--dispatch', + 'dispatch_1', + '--retry-request=request_reused' + ] + }) + ) as RuntimeClientError + + expect( + (result.data as { recovery?: { retryCommand?: string[] } }).recovery?.retryCommand + ).toEqual([ + 'orca', + 'orchestration', + 'worker-retain', + '--dispatch', + 'dispatch_1', + '--retry-request', + 'request_reused' + ]) + }) + + it('renders Windows cmd recovery guidance without quote drift or percent expansion', () => { + expect( + renderCommand( + ['orca', 'orchestration', 'worker-start', '--comment', 'literal "quoted" %PATH% & safe'], + 'win32', + { ComSpec: 'C:\\Windows\\System32\\cmd.exe' } + ) + ).toBe( + '"orca" "orchestration" "worker-start" "--comment" "literal ""quoted"" "^%"PATH"^%" & safe"' + ) + }) + + it('keeps PowerShell and POSIX recovery guidance literal', () => { + expect( + renderCommand(['orca', 'literal "quoted" $HOME'], 'win32', { + ComSpec: 'powershell.exe' + }) + ).toBe("& 'orca' 'literal \\\"quoted\\\" $HOME'") + expect(renderCommand(['orca', 'literal $(do-not-run)'], 'darwin')).toBe( + "orca 'literal $(do-not-run)'" + ) + }) + + it.runIf(process.platform !== 'win32')( + 'round trips POSIX recovery argv through /bin/sh', + async () => { + const values = ['with spaces', "apostrophe's", 'literal $(do-not-run)', 'line one\nline two'] + const command = renderCommand( + [ + process.execPath, + '-e', + 'process.stdout.write(JSON.stringify(process.argv.slice(1)))', + ...values + ], + 'darwin' + ) + + const result = await runProcess({ program: '/bin/sh', args: ['-c', command] }) + + expect(result).toMatchObject({ code: 0, stderr: '', timedOut: false }) + expect(JSON.parse(result.stdout)).toEqual(values) + } + ) + + it.each([ + [ + 'split', + ['orca', 'orchestration', 'send', '--pairing-code', 'split-secret', '--subject', 'status'], + 'split-secret' + ], + [ + 'equals', + ['orca', 'orchestration', 'send', '--pairing-code=equals-secret', '--subject', 'status'], + 'equals-secret' + ], + [ + 'dispatch split', + [ + 'orca', + 'orchestration', + 'send', + '--dispatch-capability', + 'split-dispatch-secret', + '--subject', + 'status' + ], + 'split-dispatch-secret' + ], + [ + 'dispatch equals', + [ + 'orca', + 'orchestration', + 'send', + '--dispatch-capability=equals-dispatch-secret', + '--subject', + 'status' + ], + 'equals-dispatch-secret' + ] + ])('blocks recovery and removes %s credentials', (_name, originalCommand, secret) => { + const result = orchestrationMutationRecoveryError( + new RuntimeClientError('runtime_timeout', 'request timed out', { + orchestrationRequestId: 'request_secret', + originalCommand + }) + ) as RuntimeClientError + const output = JSON.stringify({ message: result.message, data: result.data }) + + expect(output).not.toContain(secret) + expect(result.message).toContain('Recovery is blocked') + expect(result.data).toMatchObject({ + recovery: { + orchestrationRequestId: 'request_secret', + recoveryBlocked: true + } + }) + expect(result.data).not.toHaveProperty('originalCommand') + expect((result.data as { recovery: object }).recovery).not.toHaveProperty('retryCommand') + }) +}) diff --git a/src/cli/orchestration-mutation-recovery.ts b/src/cli/orchestration-mutation-recovery.ts index 8e4a03f911d..63261561caf 100644 --- a/src/cli/orchestration-mutation-recovery.ts +++ b/src/cli/orchestration-mutation-recovery.ts @@ -1,4 +1,12 @@ -import { RuntimeClientError } from './runtime-client' +import { RuntimeClientError, RuntimeRpcFailureError } from './runtime-client' +import { + recoverableOrchestrationArgs, + resolveOrchestrationCliExecutable +} from './runtime/orchestration-recovery-command' +import { quoteWindowsCmdArgument } from '../shared/child-process/windows-command-line' +import { quotePowerShellNativeArgument } from '../shared/powershell-native-argument' +import { resolveWindowsShellStartupFamily } from '../shared/windows-terminal-shell' +import type { AgentStartupShell } from '../shared/tui-agent-startup-shell' export function orchestrationMutationRecoveryError(error: unknown): unknown { if (!(error instanceof RuntimeClientError) || !isUnknownMutationOutcomeCode(error.code)) { @@ -9,16 +17,64 @@ export function orchestrationMutationRecoveryError(error: unknown): unknown { if (typeof requestId !== 'string' || requestId.length === 0) { return error } + const dispatchId = typeof data?.dispatchId === 'string' ? data.dispatchId : undefined + const parsedOriginalCommand = commandParts(data?.originalCommand) + const originalCommand = parsedOriginalCommand + ? recoverableOrchestrationArgs(parsedOriginalCommand) + : undefined + const safeData = { ...data } + delete safeData.originalCommand + const retryCommand = originalCommand + ? [...originalCommand, '--retry-request', requestId] + : undefined + const executable = originalCommand?.[0] ?? resolveOrchestrationCliExecutable() + const queryCommand = dispatchId + ? [executable, 'orchestration', 'worker-show', '--dispatch', dispatchId, '--json'] + : undefined + const recovery = { + orchestrationRequestId: requestId, + ...(dispatchId ? { dispatchId } : {}), + ...(queryCommand ? { queryCommand } : {}), + ...(retryCommand ? { retryCommand } : {}), + recoveryBlocked: !retryCommand, + disposition: 'outcome_unknown', + workerDeathInferred: false + } + const retryStep = retryCommand + ? `Run ${renderCommand(retryCommand)}.` + : 'Recovery is blocked until the exact original command is available; no retry command was emitted.' + const nextSteps = queryCommand + ? [`Run ${renderCommand(queryCommand)} before retrying.`, retryStep] + : [retryStep] const message = [ stripUnsafeRetryAdvice(error.message, requestId), 'The orchestration mutation may already have taken effect; do not assume it failed.', - `Re-issue the same command with --retry-request ${requestId} to recover idempotently. Do not retry this mutation without --retry-request.`, + ...nextSteps, typeof data?.failedStage === 'string' ? `Failed stage: ${data.failedStage}.` : undefined, Array.isArray(data?.residualResources) ? `Residual resources: ${JSON.stringify(data.residualResources)}.` : undefined ].filter((line): line is string => line !== undefined) - return new RuntimeClientError(error.code, message.join('\n'), error.data) + const recoveredData = { + ...safeData, + orchestrationRequestId: requestId, + ...(originalCommand ? { originalCommand } : {}), + recovery, + nextSteps + } + // Preserve the RPC failure envelope so --json callers retain the request id and + // runtime metadata while receiving the structured recovery guidance. + if (error instanceof RuntimeRpcFailureError) { + return new RuntimeRpcFailureError({ + ...error.response, + error: { + ...error.response.error, + message: message.join('\n'), + data: recoveredData + } + }) + } + return new RuntimeClientError(error.code, message.join('\n'), recoveredData) } function isUnknownMutationOutcomeCode(code: string): boolean { @@ -36,6 +92,110 @@ function objectRecord(value: unknown): Record | undefined { : undefined } +function commandParts(value: unknown): string[] | undefined { + if (Array.isArray(value) && value.every((part) => typeof part === 'string')) { + return [...value] + } + if (typeof value === 'string' && value.length > 0) { + return parseCommandLine(value) + } + return undefined +} + +function parseCommandLine(value: string): string[] | undefined { + const parts: string[] = [] + let part = '' + let quote: "'" | '"' | undefined + let tokenStarted = false + for (let index = 0; index < value.length; index += 1) { + const character = value[index] + if (quote === "'") { + if (character === "'") { + quote = undefined + } else { + part += character + } + tokenStarted = true + continue + } + if (quote === '"') { + if (character === '"') { + quote = undefined + } else if (character === '\\' && ['"', '\\'].includes(value[index + 1] ?? '')) { + part += value[++index] + } else { + part += character + } + tokenStarted = true + continue + } + if (character === "'" || character === '"') { + quote = character + tokenStarted = true + } else if (/\s/.test(character)) { + if (tokenStarted) { + parts.push(part) + part = '' + tokenStarted = false + } + } else if (character === '\\' && [' ', '\\', "'", '"'].includes(value[index + 1] ?? '')) { + part += value[++index] + tokenStarted = true + } else { + part += character + tokenStarted = true + } + } + if (quote !== undefined) { + return undefined + } + if (tokenStarted) { + parts.push(part) + } + return parts.length > 0 ? parts : undefined +} + +export function renderCommand( + command: readonly string[], + platform: NodeJS.Platform = process.platform, + env: NodeJS.ProcessEnv = process.env +): string { + const shell = resolveRecoveryShell(platform, env) + const rendered = command.map((value) => quoteRecoveryArgument(value, shell)).join(' ') + return shell === 'powershell' && rendered ? `& ${rendered}` : rendered +} + +function resolveRecoveryShell( + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv +): AgentStartupShell { + if (platform !== 'win32') { + return 'posix' + } + return resolveWindowsShellStartupFamily( + env.ORCA_TERMINAL_WINDOWS_SHELL ?? env.ORCA_WINDOWS_SHELL ?? env.ComSpec ?? env.COMSPEC + ) +} + +function quoteRecoveryArgument(value: string, shell: AgentStartupShell): string { + if (shell === 'cmd') { + // cmd has no single-quote syntax and expands %VAR% inside double quotes; + // use the argv encoder that keeps quote parity and escapes percent pairs. + return quoteWindowsCmdArgument(value) + } + if (shell === 'powershell') { + return quotePowerShellNativeArgument(value) + } + return shellQuote(value) +} + +function shellQuote(value: string): string { + if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(value)) { + return value + } + return `'${value.replaceAll("'", `'"'"'`)}'` +} + function stripUnsafeRetryAdvice(message: string, requestId: string): string { return message .replace(' Restart Orca and try again.', '') diff --git a/src/cli/root-help-text-primary.ts b/src/cli/root-help-text-primary.ts new file mode 100644 index 00000000000..6f282c884fd --- /dev/null +++ b/src/cli/root-help-text-primary.ts @@ -0,0 +1,184 @@ +export const ROOT_HELP_TEXT_PRIMARY = [ + 'orca', + '', + 'Usage: orca [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 Attach/start helper and make active for the worktree', + ' emulator tap Tap at normalized 0..1 coords (preferred for single taps)', + ' emulator type Type text (US ASCII only)', + ' emulator gesture Send begin/move/end touch points', + ' emulator button Hardware button (home, side_button, etc.)', + ' emulator rotate 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') diff --git a/src/cli/root-help-text-secondary.ts b/src/cli/root-help-text-secondary.ts new file mode 100644 index 00000000000..79c2f7d21f1 --- /dev/null +++ b/src/cli/root-help-text-secondary.ts @@ -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 )', + ' 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 --pass

)', + ' 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 ] [--pairing-address ] [--mobile-pairing] [--no-pairing] [--project-root ] [--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 --pairing-code [--json]', + ' orca environment list [--json]', + ' orca environment show --environment [--json]', + ' orca environment rm --environment [--json]', + ' orca worktree list [--repo ] [--limit ] [--json]', + ' orca worktree create --name [--repo |--project [--host ]|--project-host-setup ] [--agent ] [--prompt ] [--setup run|skip|inherit] [--base-branch ] [--issue ] [--linear-issue ] [--comment ] [--parent-worktree ] [--no-parent] [--run-hooks] [--activate] [--json]', + ' orca worktree show --worktree [--json]', + ' orca worktree current [--json]', + ' orca worktree set --worktree [--display-name ] [--issue ] [--linear-issue ] [--comment ] [--workspace-status ] [--parent-worktree |--no-parent] [--json]', + ' orca worktree rm --worktree [--force] [--run-hooks] [--json]', + ' orca worktree ps [--limit ] [--json]', + ' orca file open [--worktree ] [--json]', + ' orca file diff [--staged] [--worktree ] [--json]', + ' orca file open-changed [--mode edit|diff|both] [--worktree ] [--json]', + ' orca terminal list [--worktree ] [--limit ] [--include-visual-layouts] [--json]', + ' orca terminal show [--terminal ] [--json]', + ' orca terminal read [--terminal ] [--cursor ] [--limit ] [--json]', + ' orca terminal send [--terminal ] [--text ] [--enter] [--interrupt] [--json]', + ' orca terminal wait [--terminal ] --for exit|tui-idle [--timeout-ms ] [--json]', + ' orca terminal stop --worktree [--json]', + ' orca terminal create [--worktree ] [--title ] [--command ] [--focus] [--json]', + ' orca terminal split [--terminal ] [--direction horizontal|vertical] [--json]', + ' orca terminal switch [--terminal ] [--json]', + ' orca terminal close [--terminal ] [--tab] [--json]', + ' orca project list [--json]', + ' orca project setups [--project ] [--host ] [--json]', + ' orca project setup-existing-folder --project --host --path [--kind git|folder] [--display-name ] [--json]', + ' orca project setup-clone --project --host --url --destination [--display-name ] [--json]', + ' orca project setup-create --project --host [--setup-id ] [--path ] [--kind git|folder] [--display-name ] [--worktree-base-path ] [--git-username ] [--state ready|not-set-up|setting-up|error|unsupported] [--method imported-existing-folder|cloned|provisioned] [--json]', + ' orca project setup-update --setup [--display-name ] [--path ] [--worktree-base-path ] [--git-username ] [--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 [--json]', + ' orca repo list [--json]', + ' orca repo add --path [--json]', + ' orca repo show --repo [--json]', + ' orca repo set-base-ref --repo --ref [--json]', + ' orca repo search-refs --repo --query [--limit ] [--json]', + '', + 'Selectors:', + ' --repo Registered repo selector such as id:, name:, or path:', + ' --worktree Worktree selector such as identity:, id:::, name:, branch:, issue:, path:, or active/current', + ' --terminal Runtime-issued terminal handle returned by `orca terminal list --json`', + ' --parent-worktree Parent worktree selector such as identity:, id:::, branch:, issue:, path:, or active/current', + ' --no-parent Force no parent lineage for unrelated worktree creation/update', + '', + 'Terminal Send Options:', + ' --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 Maximum wait time before timing out', + '', + 'Output Options:', + ' --json Emit machine-readable JSON instead of human text', + ' --pairing-code Connect to a remote Orca runtime using an orca://pair?... code', + ' --environment 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 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 Element ref from snapshot (e.g. @e3)', + ' --url URL to navigate to', + ' --value Value to fill or select', + ' --input Text to type at current focus (no element needed)', + ' --expression JavaScript expression to evaluate', + ' --key Key to press (Enter, Tab, Escape, Control+a, etc.)', + ' --direction

Scroll direction: up or down', + ' --amount Scroll distance in pixels (default: viewport height)', + ' --index Tab index (from `tab list`)', + ' --page Stable browser page id (preferred for concurrent workflows)', + ' --profile 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 Screenshot image format', + ' --from Drag source element ref', + ' --to Drag target element ref', + ' --files Comma-separated file paths for upload', + ' --timeout Wait timeout in milliseconds', + " --worktree 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') diff --git a/src/cli/runtime/client-error-recovery.ts b/src/cli/runtime/client-error-recovery.ts new file mode 100644 index 00000000000..89ff4fa884f --- /dev/null +++ b/src/cli/runtime/client-error-recovery.ts @@ -0,0 +1,31 @@ +import { RuntimeClientError, RuntimeRpcFailureError } from './types' + +export function attachMutationRecovery( + error: unknown, + requestId: string | undefined, + originalCommand?: string[] +): unknown { + if (!requestId || !(error instanceof RuntimeClientError)) { + return error + } + const data = { + ...(error.data && typeof error.data === 'object' ? error.data : {}), + orchestrationRequestId: requestId, + ...(originalCommand ? { originalCommand } : {}) + } + if (error instanceof RuntimeRpcFailureError) { + return new RuntimeRpcFailureError({ + ...error.response, + error: { + ...error.response.error, + message: `${error.message} Orchestration mutation request ID: ${requestId}.`, + data + } + }) + } + return new RuntimeClientError( + error.code, + `${error.message} Orchestration mutation request ID: ${requestId}.`, + data + ) +} diff --git a/src/cli/runtime/client-recovery.test.ts b/src/cli/runtime/client-recovery.test.ts new file mode 100644 index 00000000000..3da40edd5f0 --- /dev/null +++ b/src/cli/runtime/client-recovery.test.ts @@ -0,0 +1,130 @@ +import { createServer, type Server } from 'node:net' +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY } from '../../shared/protocol-version' +import { ORCHESTRATION_WORKER_START_CLIENT_GRACE_MS } from '../../shared/orchestration-timing-budgets' +import { MAX_TIMER_DELAY_MS } from '../../shared/timer-delay' +import { orchestrationMutationRecoveryError } from '../orchestration-mutation-recovery' +import { RuntimeClient, RuntimeRpcFailureError } from '../runtime-client' + +const servers = new Set() + +afterEach(async () => { + await Promise.all( + [...servers].map( + (server) => + new Promise((resolve) => { + server.close(() => resolve()) + }) + ) + ) + servers.clear() +}) + +describe('RuntimeClient orchestration recovery identity', () => { + it('rejects a worker-start timeout whose client grace would overflow timers', () => { + const client = new RuntimeClient(undefined, 60_000, null, null, 'orca') + const resolve = ( + client as unknown as { + resolveMethodTimeoutMs: (method: string, params?: unknown) => number + } + ).resolveMethodTimeoutMs.bind(client) + const maxValid = MAX_TIMER_DELAY_MS - ORCHESTRATION_WORKER_START_CLIENT_GRACE_MS + expect(resolve('orchestration.workerStart', { timeoutMs: maxValid })).toBe(MAX_TIMER_DELAY_MS) + expect(() => resolve('orchestration.workerStart', { timeoutMs: maxValid + 1 })).toThrow( + 'derived timeout must be' + ) + }) + + it('attaches the request and exact retry identity to a real RPC failure response', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-recovery-')) + const endpoint = join(userDataPath, 'runtime.sock') + const server = createServer((socket) => { + let buffer = '' + socket.setEncoding('utf8') + socket.on('data', (chunk: string) => { + buffer += chunk + const newline = buffer.indexOf('\n') + if (newline === -1) { + return + } + const request = JSON.parse(buffer.slice(0, newline)) as { id: string; method: string } + const response = + request.method === 'status.get' + ? { + id: request.id, + ok: true, + result: { capabilities: [ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY] }, + _meta: { runtimeId: 'runtime-1' } + } + : { + id: request.id, + ok: false, + error: { + code: 'runtime_timeout', + message: 'request timed out', + data: { requestId: 'request_1', dispatchId: 'dispatch_1' } + }, + _meta: { runtimeId: 'runtime-1' } + } + socket.end(`${JSON.stringify(response)}\n`) + }) + }) + servers.add(server) + await new Promise((resolve) => server.listen(endpoint, resolve)) + writeFileSync( + join(userDataPath, 'orca-runtime.json'), + JSON.stringify({ + runtimeId: 'runtime-1', + pid: 1, + transports: [{ kind: 'unix', endpoint }], + authToken: 'token', + startedAt: 1 + }) + ) + + const client = new RuntimeClient(userDataPath, 500, null, null, 'orca') + try { + await client.call('orchestration.workerStart', { task: 'task_1' }) + throw new Error('expected worker-start failure') + } catch (error) { + expect(error).toBeInstanceOf(RuntimeRpcFailureError) + const recovered = orchestrationMutationRecoveryError(error) as { + data?: Record + response?: { id?: string; _meta?: { runtimeId?: string } } + } + expect(recovered).toBeInstanceOf(RuntimeRpcFailureError) + expect(recovered.response).toMatchObject({ + id: expect.any(String), + _meta: { runtimeId: 'runtime-1' } + }) + expect(recovered.data).toMatchObject({ + orchestrationRequestId: expect.any(String), + dispatchId: 'dispatch_1', + originalCommand: ['orca', 'orchestration', 'worker-start', '--task', 'task_1'], + recovery: { + queryCommand: [ + 'orca', + 'orchestration', + 'worker-show', + '--dispatch', + 'dispatch_1', + '--json' + ], + retryCommand: [ + 'orca', + 'orchestration', + 'worker-start', + '--task', + 'task_1', + '--retry-request', + expect.any(String) + ], + recoveryBlocked: false + } + }) + } + }) +}) diff --git a/src/cli/runtime/client.ts b/src/cli/runtime/client.ts index 31c5b94e5c9..f9a855ae929 100644 --- a/src/cli/runtime/client.ts +++ b/src/cli/runtime/client.ts @@ -11,6 +11,7 @@ import { getDefaultUserDataPath, readMetadata } from './metadata' import { getCliStatus, projectRemoteAppStatus } from './status' import { sendRequest } from './transport' import { RuntimeClientError, RuntimeRpcFailureError, type RuntimeRpcSuccess } from './types' +import { attachMutationRecovery } from './client-error-recovery' import { markEnvironmentUsed, resolveEnvironmentPairingOffer } from './environments' import { ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY, @@ -19,6 +20,16 @@ import { import { RemoteRuntimeCompatGate } from './remote-runtime-compat-gate' import { createOrchestrationCompatibilityEnvelope } from './orchestration-compatibility-envelope' import { getTimeoutMsParam, isWaitingCheck } from './runtime-request-timeout' +import { + isWorkerStartTimeoutWithinTimerLimit, + resolveWorkerStartClientTimeoutMs, + resolveWorkerStartReadinessTimeoutMs +} from '../../shared/orchestration-timing-budgets' +import { MAX_TIMER_DELAY_MS } from '../../shared/timer-delay' +import { + buildOrchestrationRecoveryCommand, + resolveOrchestrationCliExecutable +} from './orchestration-recovery-command' // Why: for long-poll methods the caller's method-level // `params.timeoutMs` is the inner waiter budget; we extend the client-side @@ -40,6 +51,8 @@ export class RuntimeClient { private readonly requestTimeoutMs: number private readonly remotePairing: PairingOffer | null private readonly environmentSelector: string | null + private readonly cliExecutable: string + private readonly originalArgs: readonly string[] | undefined private readonly remoteCompat: RemoteRuntimeCompatGate private orchestrationContractCheck: Promise | null = null private readonly orchestrationCompatibility = createOrchestrationCompatibilityEnvelope( @@ -53,11 +66,15 @@ export class RuntimeClient { userDataPath = getDefaultUserDataPath(), requestTimeoutMs = 60_000, remotePairingCode = process.env.ORCA_PAIRING_CODE ?? process.env.ORCA_REMOTE_PAIRING ?? null, - environmentSelector = process.env.ORCA_ENVIRONMENT ?? null + environmentSelector = process.env.ORCA_ENVIRONMENT ?? null, + cliExecutable = resolveOrchestrationCliExecutable(), + originalArgs?: readonly string[] ) { this.userDataPath = userDataPath this.requestTimeoutMs = requestTimeoutMs this.environmentSelector = environmentSelector + this.cliExecutable = cliExecutable + this.originalArgs = originalArgs ? [...originalArgs] : undefined this.remotePairing = resolveRemotePairing(userDataPath, remotePairingCode, environmentSelector) this.remoteCompat = new RemoteRuntimeCompatGate(userDataPath, environmentSelector) } @@ -79,6 +96,9 @@ export class RuntimeClient { const orchestrationRequestId = orchestrationMutation ? (options?.orchestrationRequestId ?? randomUUID()) : undefined + const originalCommand = orchestrationMutation + ? buildOrchestrationRecoveryCommand(method, params, this.cliExecutable, this.originalArgs) + : undefined const compatibilityEnvelope = method.startsWith('orchestration.') ? { ...this.orchestrationCompatibility, @@ -107,10 +127,14 @@ export class RuntimeClient { envelope }) } catch (error) { - throw attachMutationRecovery(error, orchestrationRequestId) + throw attachMutationRecovery(error, orchestrationRequestId, originalCommand) } if (response.ok === false) { - throw new RuntimeRpcFailureError(response) + throw attachMutationRecovery( + new RuntimeRpcFailureError(response), + orchestrationRequestId, + originalCommand + ) } if (this.environmentSelector) { markEnvironmentUsed(this.userDataPath, this.environmentSelector, { @@ -124,10 +148,14 @@ export class RuntimeClient { try { response = await sendRequest(metadata, method, params, effectiveTimeoutMs, envelope) } catch (error) { - throw attachMutationRecovery(error, orchestrationRequestId) + throw attachMutationRecovery(error, orchestrationRequestId, originalCommand) } if (response.ok === false) { - throw new RuntimeRpcFailureError(response) + throw attachMutationRecovery( + new RuntimeRpcFailureError(response), + orchestrationRequestId, + originalCommand + ) } return response } @@ -138,6 +166,18 @@ export class RuntimeClient { // to resolve. Without this, a 5 min wait would still die at the 60 s default. // See design doc §3.1. private resolveMethodTimeoutMs(method: string, params?: unknown): number { + if (method === 'orchestration.workerStart') { + const requestedValue = getTimeoutMsParam(params) + const requested = typeof requestedValue === 'number' ? requestedValue : Number(requestedValue) + if (!isWorkerStartTimeoutWithinTimerLimit(requested)) { + throw new RuntimeClientError( + 'invalid_argument', + `--timeout-ms is too large for worker-start transport grace; the derived timeout must be <= ${MAX_TIMER_DELAY_MS}ms.` + ) + } + const readiness = resolveWorkerStartReadinessTimeoutMs(requested) + return Math.max(resolveWorkerStartClientTimeoutMs(readiness), this.requestTimeoutMs) + } if ( (method === 'orchestration.check' && isWaitingCheck(params)) || method === 'terminal.wait' @@ -241,20 +281,6 @@ export class RuntimeClient { } } -function attachMutationRecovery(error: unknown, requestId: string | undefined): unknown { - if (!requestId || !(error instanceof RuntimeClientError)) { - return error - } - return new RuntimeClientError( - error.code, - `${error.message} Orchestration mutation request ID: ${requestId}.`, - { - ...(error.data && typeof error.data === 'object' ? error.data : {}), - orchestrationRequestId: requestId - } - ) -} - function throwDesktopActivationBlocked(): never { throw new RuntimeClientError( 'desktop_activation_blocked', diff --git a/src/cli/runtime/orchestration-recovery-command.test.ts b/src/cli/runtime/orchestration-recovery-command.test.ts new file mode 100644 index 00000000000..68c0a72c507 --- /dev/null +++ b/src/cli/runtime/orchestration-recovery-command.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from 'vitest' +import { + buildOrchestrationRecoveryCommand, + resolveOrchestrationCliExecutable +} from './orchestration-recovery-command' + +describe('orchestration recovery command identity', () => { + it.each([ + ['configured dev', { ORCA_CLI_COMMAND: 'orca-dev' }, 'darwin', 'orca-dev'], + ['configured WSL', { ORCA_CLI_COMMAND: 'orca-ide' }, 'linux', 'orca-ide'], + ['dev checkout', { ORCA_DEV_REPO_ROOT: '/repo' }, 'darwin', 'orca-dev'], + ['packaged Linux', {}, 'linux', 'orca-ide'], + ['local macOS', {}, 'darwin', 'orca'], + ['local Windows', {}, 'win32', 'orca'] + ] as const)('resolves the %s CLI identity', (_name, env, platform, expected) => { + expect(resolveOrchestrationCliExecutable(env, platform)).toBe(expected) + }) + + it('reconstructs the keyed worker-start command from its RPC params', () => { + expect( + buildOrchestrationRecoveryCommand( + 'orchestration.workerStart', + { + task: 'task_1', + on: 'windows', + worktree: 'new-top-level', + timeoutMs: 90_000, + devMode: false + }, + 'orca' + ) + ).toEqual([ + 'orca', + 'orchestration', + 'worker-start', + '--task', + 'task_1', + '--on', + 'windows', + '--worktree', + 'new-top-level', + '--timeout-ms', + '90000' + ]) + }) + + it('preserves the selected executable and raw command arguments', () => { + expect( + buildOrchestrationRecoveryCommand( + 'orchestration.workerStart', + { task: 'task_1' }, + 'orca-dev', + [ + 'orchestration', + 'worker-start', + '--task', + 'task_1', + '--comment', + 'literal $(do-not-run) "quoted"', + '--json' + ] + ) + ).toEqual([ + 'orca-dev', + 'orchestration', + 'worker-start', + '--task', + 'task_1', + '--comment', + 'literal $(do-not-run) "quoted"', + '--json' + ]) + }) + + it.each([ + [ + 'gate-create', + 'orchestration.gateCreate', + ['orchestration', 'gate-create', '--task', 'task_1', '--question', 'ship?'] + ], + [ + 'worker-retain', + 'orchestration.workerRetain', + ['orchestration', 'worker-retain', '--dispatch', 'dispatch_1', '--json'] + ] + ] as const)('preserves exact raw argv for %s recovery', (_name, method, args) => { + expect(buildOrchestrationRecoveryCommand(method, {}, 'orca', args)).toEqual(['orca', ...args]) + }) + + it.each([ + ['split', ['orchestration', 'send', '--pairing-code', 'secret', '--subject', 'status']], + ['equals', ['orchestration', 'send', '--pairing-code=secret', '--subject', 'status']], + [ + 'dispatch split', + ['orchestration', 'send', '--dispatch-capability', 'secret', '--subject', 'status'] + ], + [ + 'dispatch equals', + ['orchestration', 'send', '--dispatch-capability=secret', '--subject', 'status'] + ] + ])('blocks %s credential argv instead of exposing it', (_name, args) => { + expect( + buildOrchestrationRecoveryCommand('orchestration.send', {}, 'orca', args) + ).toBeUndefined() + }) + + it('supports the explicit executable-first form', () => { + expect( + buildOrchestrationRecoveryCommand('orca-ide', 'orchestration.workerStop', { + dispatch: 'dispatch_1' + }) + ).toEqual(['orca-ide', 'orchestration', 'worker-stop', '--dispatch', 'dispatch_1']) + }) + + it('reconstructs worker-retain when raw argv is unavailable', () => { + expect( + buildOrchestrationRecoveryCommand('orchestration.workerRetain', { + dispatch: 'dispatch_1' + }) + ).toEqual([ + resolveOrchestrationCliExecutable(), + 'orchestration', + 'worker-retain', + '--dispatch', + 'dispatch_1' + ]) + }) + + it('does not fabricate identity for unsupported mutations or malformed params', () => { + expect(buildOrchestrationRecoveryCommand('orchestration.send', {})).toBeUndefined() + expect(buildOrchestrationRecoveryCommand('orchestration.workerStart', null)).toBeUndefined() + }) +}) diff --git a/src/cli/runtime/orchestration-recovery-command.ts b/src/cli/runtime/orchestration-recovery-command.ts new file mode 100644 index 00000000000..706b1656ac6 --- /dev/null +++ b/src/cli/runtime/orchestration-recovery-command.ts @@ -0,0 +1,102 @@ +export function resolveOrchestrationCliExecutable( + env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform +): string { + const configured = env.ORCA_CLI_COMMAND?.trim() + if (configured) { + return configured + } + if (env.ORCA_DEV_REPO_ROOT) { + return 'orca-dev' + } + return platform === 'linux' ? 'orca-ide' : 'orca' +} + +export function buildOrchestrationRecoveryCommand( + method: string, + params: unknown, + executable?: string, + originalArgs?: readonly string[] +): string[] | undefined +export function buildOrchestrationRecoveryCommand( + executable: string, + method: string, + params: unknown, + originalArgs?: readonly string[] +): string[] | undefined +export function buildOrchestrationRecoveryCommand( + methodOrExecutable: string, + paramsOrMethod: unknown, + executableOrParams?: unknown, + originalArgs?: readonly string[] +): string[] | undefined { + const legacyOrder = typeof paramsOrMethod === 'string' && executableOrParams !== undefined + const method = (legacyOrder ? paramsOrMethod : methodOrExecutable) as string + const params = legacyOrder ? executableOrParams : paramsOrMethod + const executable = legacyOrder + ? methodOrExecutable + : typeof executableOrParams === 'string' + ? executableOrParams + : resolveOrchestrationCliExecutable() + if (originalArgs && originalArgs.length > 0) { + const recoverableArgs = recoverableOrchestrationArgs(originalArgs) + return recoverableArgs ? [executable, ...recoverableArgs] : undefined + } + const command = + method === 'orchestration.workerStart' + ? 'worker-start' + : method === 'orchestration.workerStop' + ? 'worker-stop' + : method === 'orchestration.workerAbandon' + ? 'worker-abandon' + : method === 'orchestration.workerRelease' + ? 'worker-release' + : method === 'orchestration.workerRetain' + ? 'worker-retain' + : undefined + if (!command || params === null || typeof params !== 'object') { + return undefined + } + const record = params as Record + const requiredKey = command === 'worker-start' ? 'task' : 'dispatch' + if (typeof record[requiredKey] !== 'string' || record[requiredKey].length === 0) { + return undefined + } + const args = [executable, 'orchestration', command] + for (const [key, value] of Object.entries(record)) { + if (value === undefined || value === null || value === false) { + continue + } + const flag = `--${key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}` + if (value === true) { + args.push(flag) + } else if (typeof value === 'string' || typeof value === 'number') { + args.push(flag, String(value)) + } + } + return args +} + +export function recoverableOrchestrationArgs(args: readonly string[]): string[] | undefined { + const containsCredential = args.some((arg) => + ['--pairing-code', '--dispatch-capability'].some( + (flag) => arg === flag || arg.startsWith(`${flag}=`) + ) + ) + if (containsCredential) { + return undefined + } + const result: string[] = [] + for (let index = 0; index < args.length; index += 1) { + const arg = args[index] + if (arg === '--retry-request') { + index += 1 + continue + } + if (arg.startsWith('--retry-request=')) { + continue + } + result.push(arg) + } + return result +} diff --git a/src/cli/runtime/websocket-transport-error.test.ts b/src/cli/runtime/websocket-transport-error.test.ts new file mode 100644 index 00000000000..ab3df967f03 --- /dev/null +++ b/src/cli/runtime/websocket-transport-error.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it, vi } from 'vitest' +import { RemoteRuntimeClientError } from '../../shared/remote-runtime-client-error' +import { orchestrationMutationRecoveryError } from '../orchestration-mutation-recovery' + +const { sendRemoteRuntimeRequest, sendRemoteRuntimeRequestWithStatusPreflight } = vi.hoisted( + () => ({ + sendRemoteRuntimeRequest: vi.fn(), + sendRemoteRuntimeRequestWithStatusPreflight: vi.fn() + }) +) + +vi.mock('../../shared/remote-runtime-client', () => ({ + RemoteRuntimeClientError, + sendRemoteRuntimeRequest, + sendRemoteRuntimeRequestWithStatusPreflight +})) + +import { + sendWebSocketRequest, + sendWebSocketRequestWithStatusPreflight +} from './websocket-transport' + +const pairing = { + v: 2, + endpoint: 'ws://127.0.0.1:1', + deviceToken: 'token', + publicKeyB64: 'key' +} as never + +describe('CLI WebSocket transport error identity', () => { + it('preserves recovery data from one-shot transport errors', async () => { + sendRemoteRuntimeRequest.mockRejectedValueOnce( + new RemoteRuntimeClientError('runtime_timeout', 'timed out', { + data: { + orchestrationRequestId: 'request_1', + dispatchId: 'dispatch_1', + originalCommand: ['orca', 'orchestration', 'worker-start', '--task', 'task_1'] + } + }) + ) + + try { + await sendWebSocketRequest(pairing, 'orchestration.workerStart', {}, 100) + throw new Error('expected transport failure') + } catch (error) { + expect(error).toMatchObject({ + code: 'runtime_timeout', + data: { + orchestrationRequestId: 'request_1', + dispatchId: 'dispatch_1', + originalCommand: ['orca', 'orchestration', 'worker-start', '--task', 'task_1'] + } + }) + expect(orchestrationMutationRecoveryError(error)).toMatchObject({ + data: { + recovery: { + dispatchId: 'dispatch_1', + queryCommand: [ + 'orca', + 'orchestration', + 'worker-show', + '--dispatch', + 'dispatch_1', + '--json' + ], + retryCommand: [ + 'orca', + 'orchestration', + 'worker-start', + '--task', + 'task_1', + '--retry-request', + 'request_1' + ] + } + } + }) + } + }) + + it('preserves recovery data through status-preflight transport errors', async () => { + sendRemoteRuntimeRequestWithStatusPreflight.mockRejectedValueOnce( + new RemoteRuntimeClientError('runtime_timeout', 'timed out', { + data: { orchestrationRequestId: 'request_2' } + }) + ) + + await expect( + sendWebSocketRequestWithStatusPreflight( + pairing, + 'orchestration.workerStart', + {}, + 100, + () => {} + ) + ).rejects.toMatchObject({ + code: 'runtime_timeout', + data: { orchestrationRequestId: 'request_2' } + }) + }) +}) diff --git a/src/cli/runtime/websocket-transport.ts b/src/cli/runtime/websocket-transport.ts index d71c1cbd671..8c7696d9f7d 100644 --- a/src/cli/runtime/websocket-transport.ts +++ b/src/cli/runtime/websocket-transport.ts @@ -19,7 +19,7 @@ export async function sendWebSocketRequest( return await sendRemoteRuntimeRequest(pairing, method, params, timeoutMs, envelope) } catch (error) { if (error instanceof RemoteRuntimeClientError) { - throw new RuntimeClientError(error.code, error.message) + throw new RuntimeClientError(error.code, error.message, error.data) } throw error } @@ -44,7 +44,7 @@ export async function sendWebSocketRequestWithStatusPreflight( ) } catch (error) { if (error instanceof RemoteRuntimeClientError) { - throw new RuntimeClientError(error.code, error.message) + throw new RuntimeClientError(error.code, error.message, error.data) } throw error } diff --git a/src/cli/selectors.ts b/src/cli/selectors.ts index aef583ad0ac..cfc2dd68749 100644 --- a/src/cli/selectors.ts +++ b/src/cli/selectors.ts @@ -39,7 +39,7 @@ function assertLocalCwdWorktreeSelector(selector: string, client: RuntimeClient) // server, so cwd-derived worktree selectors are only valid locally. throw new RuntimeClientError( 'invalid_argument', - `${selector} is a local cwd shortcut and cannot be resolved against a remote runtime. Pass an explicit server-side worktree selector such as id:::, name:, branch:, issue:, or path:.` + `${selector} is a local cwd shortcut and cannot be resolved against a remote runtime. Pass an explicit server-side worktree selector such as identity:, id:::, name:, branch:, issue:, or path:.` ) } diff --git a/src/cli/specs/core.ts b/src/cli/specs/core.ts index d4422943f67..aaf94bb0d64 100644 --- a/src/cli/specs/core.ts +++ b/src/cli/specs/core.ts @@ -208,6 +208,7 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [ 'By default this returns accumulated terminal output with escape sequences stripped, not the rendered screen. Any program that repaints a line — shells, progress bars, TUIs — comes back as stacked fragments, so one `clear` keystroke by keystroke reads as `cclclecleaclear`, and spaces a prompt draws by moving the cursor are absent.', 'Use --screen to read what the terminal actually renders. Prefer it whenever the answer depends on how output looks rather than what was emitted over time; the default is unsuitable for verifying rendered output.', 'The result reports source: stream when it is accumulated output, screen when it is the rendered screen, and screen-unavailable when a screen was asked for but none could be rendered and the accumulated output is being returned instead. An absent source means the host predates the field.', + 'When present, draft is UI-only composer text excluded from tail; never treat it as terminal output or a submitted instruction.', '--screen and --cursor are mutually exclusive: a screen read is the current frame and has no history to page.', 'Use --cursor with the nextCursor value from a previous read to get only new output since that read.', 'Use --limit to request more retained lines for long agent responses; output reports oldestCursor when older lines were dropped.', diff --git a/src/cli/terminal-format-draft.test.ts b/src/cli/terminal-format-draft.test.ts new file mode 100644 index 00000000000..3f38e48dd21 --- /dev/null +++ b/src/cli/terminal-format-draft.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { formatTerminalRead } from './terminal-format' + +describe('formatTerminalRead draft separation', () => { + it('labels composer drafts outside terminal output', () => { + const output = formatTerminalRead({ + terminal: { + handle: 'term_1', + status: 'running', + tail: ['Build passed', '❯'], + truncated: false, + nextCursor: null, + source: 'screen', + draft: 'proceed with the release' + } + }) + + expect(output).toContain('draft: "proceed with the release"') + expect(output).toContain('\n\nBuild passed\n❯') + }) +}) diff --git a/src/cli/terminal-format.ts b/src/cli/terminal-format.ts index 806a39cb588..4d57e7693a1 100644 --- a/src/cli/terminal-format.ts +++ b/src/cli/terminal-format.ts @@ -141,6 +141,7 @@ export function formatTerminalRead(result: { terminal: RuntimeTerminalRead }): s `handle: ${terminal.handle}`, `status: ${terminal.status}`, ...(terminal.source ? [`source: ${terminal.source}`] : []), + ...(terminal.draft ? [`draft: ${JSON.stringify(terminal.draft)}`] : []), ...(terminal.nextCursor !== null ? [`cursor: ${terminal.nextCursor}`] : []), ...oldestCursor, ...latestCursor, diff --git a/src/main/ai-vault/remote-session-scanner-cline-source.ts b/src/main/ai-vault/remote-session-scanner-cline-source.ts new file mode 100644 index 00000000000..f2f79089f9a --- /dev/null +++ b/src/main/ai-vault/remote-session-scanner-cline-source.ts @@ -0,0 +1,42 @@ +import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' +import { joinRemotePath } from '../ssh/ssh-remote-platform' +import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation' +import { isMissingRemoteSessionPathError } from './remote-session-file-stat' +import type { RemoteSessionSource } from './remote-session-scanner-types' +import { + clineMessagesPathForMetadata, + isClineSessionMetadataPath, + parseClineSessionContent +} from './session-scanner-cline-parser' + +export function remoteClineSource( + remoteHome: string, + hostPlatform: RemoteHostPlatform +): RemoteSessionSource { + return { + agent: 'cline', + rootDir: joinRemotePath(hostPlatform, remoteHome, '.cline', 'data', 'sessions'), + extensions: ['.json'], + filePredicate: isClineSessionMetadataPath, + contentDependencyPath: clineMessagesPathForMetadata, + directoryPredicate: (_name, depth) => depth === 0, + parse: async (file, content, context) => { + let messagesContent: string | null = null + try { + throwIfAiVaultScanCancelled(context.signal) + const read = await context.provider.readFile(clineMessagesPathForMetadata(file.path)) + throwIfAiVaultScanCancelled(context.signal) + messagesContent = read.isBinary ? null : read.content + } catch (error) { + throwIfAiVaultScanCancelled(context.signal) + if (!isMissingRemoteSessionPathError(error)) { + throw error + } + } + return parseClineSessionContent(file, content, messagesContent, context.hostPlatform.os, { + executionHostId: context.executionHostId, + executionHostPlatform: context.hostPlatform.os + }) + } + } +} diff --git a/src/main/ai-vault/remote-session-scanner-discovery.ts b/src/main/ai-vault/remote-session-scanner-discovery.ts index 329bdb2e584..159bf049da6 100644 --- a/src/main/ai-vault/remote-session-scanner-discovery.ts +++ b/src/main/ai-vault/remote-session-scanner-discovery.ts @@ -29,18 +29,7 @@ export async function discoverRemoteSourceCandidates(args: { const files = await mapRemoteScanBatches( paths, REMOTE_DISCOVERY_CONCURRENCY, - (path) => - statRemoteSessionFile( - args.context.provider, - path, - args.source.agent, - args.context.executionHostId, - args.issues, - { - missingIsExpected: Boolean(args.source.fixedChildFileSegments), - signal: args.context.signal - } - ), + (path) => statRemoteCandidateFile(path, args.source, args.context, args.issues), args.context.signal ) return files @@ -52,6 +41,46 @@ export async function discoverRemoteSourceCandidates(args: { })) } +async function statRemoteCandidateFile( + path: string, + source: RemoteSessionSource, + context: RemoteScannerContext, + issues: AiVaultScanIssue[] +): Promise { + const file = await statRemoteSessionFile( + context.provider, + path, + source.agent, + context.executionHostId, + issues, + { + missingIsExpected: Boolean(source.fixedChildFileSegments), + signal: context.signal + } + ) + if (!file || !source.contentDependencyPath) { + return file + } + const dependency = await statRemoteSessionFile( + context.provider, + source.contentDependencyPath(path), + source.agent, + context.executionHostId, + issues, + { missingIsExpected: true, signal: context.signal } + ) + if (!dependency) { + return file + } + const mtimeMs = Math.max(file.mtimeMs, dependency.mtimeMs) + return { + ...file, + mtimeMs, + modifiedAt: new Date(mtimeMs).toISOString(), + sizeBytes: (file.sizeBytes ?? 0) + (dependency.sizeBytes ?? 0) + } +} + async function listRemoteFixedChildFiles( source: RemoteSessionSource, context: RemoteScannerContext, diff --git a/src/main/ai-vault/remote-session-scanner-sources.ts b/src/main/ai-vault/remote-session-scanner-sources.ts index 8bd9fdaf61d..1fd0ca30a61 100644 --- a/src/main/ai-vault/remote-session-scanner-sources.ts +++ b/src/main/ai-vault/remote-session-scanner-sources.ts @@ -17,6 +17,7 @@ import { partitionOmpSubagentTranscriptPaths } from './session-scanner-omp-subag import type { FileWithMtime } from './session-scanner-types' import { normalizeAgentSessionsDir } from './session-scanner-values' import { remoteCodexIndexTitles } from './remote-session-scanner-codex-index' +import { remoteClineSource } from './remote-session-scanner-cline-source' import type { RemoteParserOptions, RemoteScannerContext, @@ -77,6 +78,7 @@ export function remoteSessionSources( parseCursorSessionContent, (path) => remotePathSegments(path).includes('agent-transcripts') ), + remoteClineSource(remoteHome, hostPlatform), source( 'hermes', remoteHome, diff --git a/src/main/ai-vault/remote-session-scanner-types.ts b/src/main/ai-vault/remote-session-scanner-types.ts index 8c9774abe14..405ea7f4626 100644 --- a/src/main/ai-vault/remote-session-scanner-types.ts +++ b/src/main/ai-vault/remote-session-scanner-types.ts @@ -33,6 +33,7 @@ export type RemoteSessionSource = { codexHome?: string extensions: readonly string[] filePredicate?: (path: string) => boolean + contentDependencyPath?: (path: string) => string // Depth 0 denotes a direct child of rootDir. directoryPredicate?: (name: string, depth: number) => boolean // A canonical file directly beneath every top-level session directory. diff --git a/src/main/ai-vault/remote-session-scanner.test.ts b/src/main/ai-vault/remote-session-scanner.test.ts index 7884fa9461d..c1c99bcbf12 100644 --- a/src/main/ai-vault/remote-session-scanner.test.ts +++ b/src/main/ai-vault/remote-session-scanner.test.ts @@ -5,6 +5,98 @@ import { MemoryRemoteProvider, jsonLines } from './remote-session-scanner-test-f import { primeAgentFixture } from './session-scanner-prime-agent-fixtures' describe('scanRemoteAiVaultSessions', () => { + it('indexes Cline manifests on the SSH-owned disk without messages-file phantoms', async () => { + const provider = new MemoryRemoteProvider() + const sessionId = '1786466194549_xrzrl' + const sessionDir = `/home/ada/.cline/data/sessions/${sessionId}` + provider.addFile( + `${sessionDir}/${sessionId}.json`, + JSON.stringify({ + version: 1, + session_id: sessionId, + started_at: '2026-08-11T16:36:34.551Z', + provider: 'deepseek', + model: 'deepseek-v4-flash', + cwd: '/home/ada/repo' + }), + 10 + ) + provider.addFile( + `${sessionDir}/${sessionId}.messages.json`, + JSON.stringify({ + version: 1, + updated_at: '2026-08-11T16:38:00.000Z', + sessionId, + messages: [ + { + role: 'user', + content: [{ type: 'text', text: 'Fix remote Cline history' }] + } + ] + }), + 11 + ) + + const result = await scanRemoteAiVaultSessions({ + provider, + executionHostId: 'ssh:dev-box', + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64') + }) + + expect(result.issues).toEqual([]) + expect(result.sessions).toHaveLength(1) + expect(result.sessions[0]).toMatchObject({ + agent: 'cline', + sessionId, + title: 'Fix remote Cline history', + cwd: '/home/ada/repo', + executionHostId: 'ssh:dev-box', + executionHostPlatform: 'linux', + filePath: `${sessionDir}/${sessionId}.json` + }) + }) + + it('indexes and resumes Cline sessions from a Windows SSH host', async () => { + const provider = new MemoryRemoteProvider() + const sessionId = '1786466194549_xrzrl' + const sessionDir = `C:/Users/Ada/.cline/data/sessions/${sessionId}` + provider.addFile( + `${sessionDir}/${sessionId}.json`, + JSON.stringify({ + session_id: sessionId, + started_at: '2026-08-11T16:36:34.551Z', + cwd: 'C:/repo/app' + }), + 10 + ) + provider.addFile( + `${sessionDir}/${sessionId}.messages.json`, + JSON.stringify({ + updated_at: '2026-08-11T16:38:00.000Z', + messages: [{ role: 'user', content: [{ type: 'text', text: 'Fix Windows history' }] }] + }), + 11 + ) + + const result = await scanRemoteAiVaultSessions({ + provider, + executionHostId: 'ssh:win-box', + remoteHome: 'C:/Users/Ada', + hostPlatform: getRemoteHostPlatform('win32-x64') + }) + + expect(result.issues).toEqual([]) + expect(result.sessions).toHaveLength(1) + expect(result.sessions[0]).toMatchObject({ + agent: 'cline', + sessionId, + executionHostPlatform: 'win32', + filePath: `${sessionDir}/${sessionId}.json`, + resumeCommand: `cmd /d /s /c "cd /d ""C:/repo/app"" && cline --id ""${sessionId}"""` + }) + }) + it('parses remote default and Orca-managed Codex homes with SSH host ids', async () => { const provider = new MemoryRemoteProvider() provider.addFile( diff --git a/src/main/ai-vault/session-delete-target.test.ts b/src/main/ai-vault/session-delete-target.test.ts index 39c95f4f5f6..cf51fecdad9 100644 --- a/src/main/ai-vault/session-delete-target.test.ts +++ b/src/main/ai-vault/session-delete-target.test.ts @@ -19,6 +19,37 @@ const ROVO_ROOT = join(HOME, '.rovodev', 'sessions') const GROK_ROOT = join(HOME, '.grok', 'sessions') describe('validateAiVaultSessionDeleteTarget', () => { + it('allows a canonical Cline manifest and removes its whole session directory', () => { + const root = join('/tmp', 'cline-sessions') + const sessionId = '1786466194549_xrzrl' + const sessionDir = join(root, sessionId) + const result = validateAiVaultSessionDeleteTarget({ + agent: 'cline', + filePath: join(sessionDir, `${sessionId}.json`), + executionHostId: 'local', + rootOptions: { clineSessionsDir: root } + }) + + expect(result).toMatchObject({ + allowed: true, + agent: 'cline', + removals: [{ path: sessionDir, kind: 'directory' }] + }) + }) + + it('rejects a Cline messages companion as an undiscoverable delete target', () => { + const root = join('/tmp', 'cline-sessions') + const sessionId = '1786466194549_xrzrl' + const result = validateAiVaultSessionDeleteTarget({ + agent: 'cline', + filePath: join(root, sessionId, `${sessionId}.messages.json`), + executionHostId: 'local', + rootOptions: { clineSessionsDir: root } + }) + + expect(result).toEqual({ allowed: false, agent: 'cline', reason: 'undiscoverable-path' }) + }) + it('allows a supported agent whose file resolves inside its known root', () => { const result = validateAiVaultSessionDeleteTarget({ agent: 'gemini', diff --git a/src/main/ai-vault/session-delete-target.ts b/src/main/ai-vault/session-delete-target.ts index 11ef38039dc..13320ce39e9 100644 --- a/src/main/ai-vault/session-delete-target.ts +++ b/src/main/ai-vault/session-delete-target.ts @@ -21,7 +21,11 @@ import type { AiVaultScanOptions } from './session-scanner-types' // Agents whose session IS the directory holding the scanned file: everything // beside it belongs to the same session (rovo's session_context.json, grok's // chat_history.jsonl), so the directory is the only complete delete unit. -const AI_VAULT_DIRECTORY_SHAPED_DELETE_AGENTS = new Set(['rovo', 'grok']) +const AI_VAULT_DIRECTORY_SHAPED_DELETE_AGENTS = new Set([ + 'rovo', + 'grok', + 'cline' +]) export type ValidateAiVaultSessionDeleteTargetArgs = { agent: AiVaultAgent diff --git a/src/main/ai-vault/session-scanner-agent-parser.ts b/src/main/ai-vault/session-scanner-agent-parser.ts index 61554f663fe..83f23943241 100644 --- a/src/main/ai-vault/session-scanner-agent-parser.ts +++ b/src/main/ai-vault/session-scanner-agent-parser.ts @@ -2,6 +2,7 @@ import type { AiVaultSession } from '../../shared/ai-vault-types' import { parseDevinSessionFile } from './session-scanner-devin-parser' import { parseAntigravitySessionFile } from './session-scanner-antigravity-parser' import { parseDroidSessionFile } from './session-scanner-droid-parser' +import { parseClineSessionFile } from './session-scanner-cline-parser' import { parseGrokSessionFile } from './session-scanner-grok-parser' import { parseMessageGraphSessionFile, parseRovoSessionFile } from './session-scanner-graph-parsers' import { parseKimiSessionFile } from './session-scanner-kimi-parser' @@ -72,6 +73,8 @@ export async function parseAgentSessionFile( return parseMessageGraphSessionFile('prime-agent', candidate.file, platform) case 'droid': return parseDroidSessionFile(candidate.file, platform) + case 'cline': + return parseClineSessionFile(candidate.file, platform) case 'devin': return parseDevinSessionFile(candidate.file, platform) case 'kimi': diff --git a/src/main/ai-vault/session-scanner-agent-sources.ts b/src/main/ai-vault/session-scanner-agent-sources.ts index 803bf6a3f07..8b2e66ea5b0 100644 --- a/src/main/ai-vault/session-scanner-agent-sources.ts +++ b/src/main/ai-vault/session-scanner-agent-sources.ts @@ -4,6 +4,10 @@ import type { AiVaultAgent } from '../../shared/ai-vault-types' import type { AiVaultDeletableAgent } from '../../shared/ai-vault-session-deletion' import { resolveGrokSessionsDir } from '../../shared/grok-session-paths' import { uniqueCodexSessionsDirs } from './session-scanner-codex-paths' +import { + clineMessagesPathForMetadata, + isClineSessionMetadataPath +} from './session-scanner-cline-parser' import { resolveKimiSessionsDir } from './session-scanner-kimi-paths' import { OMP_SESSION_ARTIFACT_DIR_PATTERN } from './session-scanner-omp-subagent-transcripts' import { claudeProjectsRootDirs, OMP_SESSIONS_DIR, sessionRootDirs } from './session-scanner-roots' @@ -40,6 +44,8 @@ const DEVIN_TRANSCRIPTS_DIR = join( ) const DROID_SESSIONS_DIR = join(homedir(), '.factory', 'sessions') const DROID_PROJECTS_DIR = join(homedir(), '.factory', 'projects') +const CLINE_SESSIONS_DIR = + process.env.CLINE_SESSION_DATA_DIR?.trim() || join(homedir(), '.cline', 'data', 'sessions') /** * Where one agent's session files live and which of them count as sessions. @@ -55,6 +61,8 @@ export type AiVaultAgentSource = { rootDirs: (options: AiVaultScanOptions, wslHomeDirs: readonly string[]) => string[] extensions: readonly string[] filePredicate?: (filePath: string) => boolean + // A sibling whose stat participates in candidate freshness and recency. + contentDependencyPath?: (filePath: string) => string // Return false to skip a directory; depth 0 is a child of the root. directoryPredicate?: (name: string, depth: number) => boolean // Roots that are alternates for one install rather than distinct locations, @@ -221,6 +229,19 @@ export const AI_VAULT_AGENT_SOURCES: AiVaultAgentSourceTable = { ], extensions: ['.jsonl'] }, + cline: { + rootDirs: (options, wslHomeDirs) => + sessionRootDirs(options.clineSessionsDir ?? CLINE_SESSIONS_DIR, wslHomeDirs, [ + '.cline', + 'data', + 'sessions' + ]), + extensions: ['.json'], + filePredicate: isClineSessionMetadataPath, + contentDependencyPath: clineMessagesPathForMetadata, + // Cline stores one manifest directly beneath each session directory. + directoryPredicate: (_name, depth) => depth === 0 + }, kimi: { rootDirs: (options, wslHomeDirs) => sessionRootDirs(resolveKimiSessionsDir(options.kimiSessionsDir), wslHomeDirs, [ diff --git a/src/main/ai-vault/session-scanner-cline-parser.test.ts b/src/main/ai-vault/session-scanner-cline-parser.test.ts new file mode 100644 index 00000000000..6d96bcb114f --- /dev/null +++ b/src/main/ai-vault/session-scanner-cline-parser.test.ts @@ -0,0 +1,117 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { scanAiVaultSessions } from './session-scanner' +import { isolatedScanRoots } from './session-scanner-test-fixtures' + +let tempRoots: string[] = [] + +afterEach(async () => { + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + tempRoots = [] +}) + +describe('Cline AI Vault sessions', () => { + it('indexes one manifest-backed session without surfacing its messages file as a phantom', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-cline-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + const sessionId = '1786466194549_xrzrl' + const sessionDir = join(roots.clineSessionsDir, sessionId) + await mkdir(sessionDir, { recursive: true }) + await writeFile( + join(sessionDir, `${sessionId}.json`), + JSON.stringify({ + version: 1, + session_id: sessionId, + source: 'cli', + started_at: '2026-08-11T16:36:34.551Z', + status: 'idle', + interactive: true, + provider: 'deepseek', + model: 'deepseek-v4-flash', + cwd: '/repo/cline', + workspace_root: '/repo/cline' + }) + ) + await writeFile( + join(sessionDir, `${sessionId}.messages.json`), + JSON.stringify({ + version: 1, + updated_at: '2026-08-11T16:38:00.000Z', + agent: 'lead', + sessionId, + messages: [ + { + id: 'user-1', + role: 'user', + content: [{ type: 'text', text: 'Fix the Cline vault scanner' }] + }, + { + id: 'assistant-1', + role: 'assistant', + content: [{ type: 'text', text: 'I will inspect the scanner.' }], + ts: 1_786_466_280_000, + modelInfo: { id: 'deepseek-v4-flash', provider: 'deepseek' } + } + ] + }) + ) + + const result = await scanAiVaultSessions({ ...roots, platform: 'darwin' }) + + expect(result.issues).toEqual([]) + expect(result.sessions.filter((session) => session.agent === 'cline')).toHaveLength(1) + expect(result.sessions.find((session) => session.agent === 'cline')).toMatchObject({ + sessionId, + title: 'Fix the Cline vault scanner', + cwd: '/repo/cline', + model: 'deepseek-v4-flash', + messageCount: 2, + resumeCommand: `cd '/repo/cline' && cline --id '${sessionId}'`, + filePath: join(sessionDir, `${sessionId}.json`) + }) + + await writeFile( + join(sessionDir, `${sessionId}.messages.json`), + JSON.stringify({ + version: 1, + updated_at: '2026-08-11T16:39:00.000Z', + sessionId, + messages: [ + { role: 'user', content: [{ type: 'text', text: 'Fix the Cline vault scanner' }] }, + { role: 'assistant', content: [{ type: 'text', text: 'I inspected the scanner.' }] }, + { role: 'user', content: [{ type: 'text', text: 'Add the regression test too' }] } + ] + }) + ) + + const rescanned = await scanAiVaultSessions({ ...roots, platform: 'darwin' }) + expect(rescanned.sessions.find((session) => session.agent === 'cline')).toMatchObject({ + messageCount: 3, + updatedAt: '2026-08-11T16:39:00.000Z' + }) + }) + + it('only indexes manifests directly beneath a session directory', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-cline-nested-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + const sessionId = 'direct-session' + const nestedId = 'nested-session' + await mkdir(join(roots.clineSessionsDir, sessionId), { recursive: true }) + await mkdir(join(roots.clineSessionsDir, 'workspace', nestedId), { recursive: true }) + const metadata = JSON.stringify({ session_id: sessionId, cwd: '/repo' }) + await writeFile(join(roots.clineSessionsDir, sessionId, `${sessionId}.json`), metadata) + await writeFile( + join(roots.clineSessionsDir, 'workspace', nestedId, `${nestedId}.json`), + JSON.stringify({ session_id: nestedId, cwd: '/unexpected' }) + ) + + const result = await scanAiVaultSessions({ ...roots, platform: 'darwin' }) + + expect(result.sessions.filter((session) => session.agent === 'cline')).toHaveLength(1) + expect(result.sessions.find((session) => session.agent === 'cline')?.sessionId).toBe(sessionId) + }) +}) diff --git a/src/main/ai-vault/session-scanner-cline-parser.ts b/src/main/ai-vault/session-scanner-cline-parser.ts new file mode 100644 index 00000000000..21a21664205 --- /dev/null +++ b/src/main/ai-vault/session-scanner-cline-parser.ts @@ -0,0 +1,116 @@ +import { wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access' +import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate' +import type { AiVaultSession } from '../../shared/ai-vault-types' +import type { ExecutionHostId } from '../../shared/execution-host' +import { + addPreviewContent, + createAccumulator, + finalizeSession, + updateTimeline +} from './session-scanner-accumulator' +import type { FileWithMtime } from './session-scanner-types' +import { + arrayValue, + asRecord, + extractContentText, + extractString, + normalizeTitleText +} from './session-scanner-values' + +type ParserSessionOptions = { + executionHostId?: ExecutionHostId + executionHostPlatform?: NodeJS.Platform | null +} + +export function isClineSessionMetadataPath(filePath: string): boolean { + const segments = filePath.replace(/\\/g, '/').split('/').filter(Boolean) + const fileName = segments.pop() + const sessionId = segments.pop() + return Boolean(fileName && sessionId && fileName === `${sessionId}.json`) +} + +export function clineMessagesPathForMetadata(filePath: string): string { + return filePath.endsWith('.json') ? `${filePath.slice(0, -'.json'.length)}.messages.json` : '' +} + +export async function parseClineSessionFile( + file: FileWithMtime, + platform: NodeJS.Platform = process.platform +): Promise { + const metadataContent = await wslGatedReadFile(file.path, 'utf-8', 'scan') + let messagesContent: string | null = null + try { + messagesContent = await wslGatedReadFile( + clineMessagesPathForMetadata(file.path), + 'utf-8', + 'scan' + ) + } catch (error) { + // The manifest is written before the first turn; a missing messages file is + // a valid metadata-only session, while a WSL gate refusal must stay visible. + if (error instanceof WslTranscriptFsError || !isMissingSessionPathError(error)) { + throw error + } + } + return parseClineSessionContent(file, metadataContent, messagesContent, platform) +} + +function isMissingSessionPathError(error: unknown): boolean { + const code = + error && typeof error === 'object' && 'code' in error && typeof error.code === 'string' + ? error.code + : null + return code === 'ENOENT' || code === 'ENOTDIR' +} + +export function parseClineSessionContent( + file: FileWithMtime, + metadataContent: string, + messagesContent: string | null, + platform: NodeJS.Platform = process.platform, + options: ParserSessionOptions = {} +): AiVaultSession | null { + const metadata = parseJsonRecord(metadataContent) + if (!metadata) { + return null + } + const pathSegments = file.path.replace(/\\/g, '/').split('/').filter(Boolean) + const sessionId = extractString(metadata.session_id) ?? pathSegments.at(-2) ?? '' + const accumulator = createAccumulator({ agent: 'cline', file, sessionId }) + accumulator.cwd = extractString(metadata.cwd) ?? extractString(metadata.workspace_root) + accumulator.model = extractString(metadata.model) + updateTimeline(accumulator, metadata.started_at) + + const messages = messagesContent ? parseJsonRecord(messagesContent) : null + if (messages) { + updateTimeline(accumulator, messages.updated_at) + for (const value of arrayValue(messages.messages)) { + const message = asRecord(value) + const role = message?.role + if (!message || (role !== 'user' && role !== 'assistant')) { + continue + } + accumulator.messageCount++ + updateTimeline(accumulator, message.ts) + const content = message.content + if (role === 'user' && !accumulator.fallbackTitle) { + accumulator.fallbackTitle = normalizeTitleText(extractContentText(content) ?? '') + } + if (role === 'assistant' && !accumulator.model) { + accumulator.model = extractString(asRecord(message.modelInfo)?.id) + } + addPreviewContent(accumulator, role, content, message.ts) + } + } + accumulator.fallbackTitle ??= normalizeTitleText(extractString(metadata.prompt) ?? '') + + return finalizeSession(accumulator, platform, options) +} + +function parseJsonRecord(content: string): Record | null { + try { + return asRecord(JSON.parse(content) as unknown) + } catch { + return null + } +} diff --git a/src/main/ai-vault/session-scanner-discovery.ts b/src/main/ai-vault/session-scanner-discovery.ts index 9ec569bc547..a5cf386043e 100644 --- a/src/main/ai-vault/session-scanner-discovery.ts +++ b/src/main/ai-vault/session-scanner-discovery.ts @@ -14,6 +14,7 @@ export async function discoverFiles(args: { issues: AiVaultScanIssue[] extensions: string[] filePredicate?: (path: string) => boolean + contentDependencyPath?: (path: string) => string directoryPredicate?: (name: string, depth: number) => boolean }): Promise { let paths: string[] @@ -41,11 +42,13 @@ export async function discoverFiles(args: { for (const path of paths) { try { const fileStat = await wslGatedStat(path, 'scan') + const dependencyStat = await optionalContentDependencyStat(args.contentDependencyPath?.(path)) + const mtimeMs = Math.max(fileStat.mtimeMs, dependencyStat?.mtimeMs ?? 0) files.push({ path, - mtimeMs: fileStat.mtimeMs, - modifiedAt: fileStat.mtime.toISOString(), - sizeBytes: fileStat.size, + mtimeMs, + modifiedAt: new Date(mtimeMs).toISOString(), + sizeBytes: fileStat.size + (dependencyStat?.size ?? 0), dev: fileStat.dev, ino: fileStat.ino, nlink: fileStat.nlink @@ -65,6 +68,23 @@ export async function discoverFiles(args: { } } +async function optionalContentDependencyStat( + filePath: string | undefined +): Promise<{ mtimeMs: number; size: number } | null> { + if (!filePath) { + return null + } + try { + const fileStat = await wslGatedStat(filePath, 'scan') + return { mtimeMs: fileStat.mtimeMs, size: fileStat.size } + } catch (error) { + if (error instanceof WslTranscriptFsError) { + throw error + } + return null + } +} + export async function walkSessionFiles( dirPath: string, agent: AiVaultAgent, diff --git a/src/main/ai-vault/session-scanner-parse-cache.ts b/src/main/ai-vault/session-scanner-parse-cache.ts index 563055ffa39..aaabaf76d6e 100644 --- a/src/main/ai-vault/session-scanner-parse-cache.ts +++ b/src/main/ai-vault/session-scanner-parse-cache.ts @@ -76,6 +76,7 @@ function resumableStateFactoryFor( case 'devin': case 'grok': case 'hermes': + case 'cline': case 'kimi': case 'opencode': case 'rovo': diff --git a/src/main/ai-vault/session-scanner-service-env.test.ts b/src/main/ai-vault/session-scanner-service-env.test.ts index 5d06c0508e8..b56b849270b 100644 --- a/src/main/ai-vault/session-scanner-service-env.test.ts +++ b/src/main/ai-vault/session-scanner-service-env.test.ts @@ -33,6 +33,7 @@ describe('buildAiVaultServiceEnv', () => { const env = buildAiVaultServiceEnv( { CODEX_HOME: '/home/dev/.codex', + CLINE_SESSION_DATA_DIR: '/home/dev/cline-sessions', COPILOT_HOME: '/home/dev/.copilot', DEVIN_HOME: '/home/dev/.devin', GROK_HOME: '/home/dev/.grok', @@ -49,6 +50,7 @@ describe('buildAiVaultServiceEnv', () => { expect(env).toEqual({ CODEX_HOME: '/home/dev/.codex', + CLINE_SESSION_DATA_DIR: '/home/dev/cline-sessions', COPILOT_HOME: '/home/dev/.copilot', DEVIN_HOME: '/home/dev/.devin', GROK_HOME: '/home/dev/.grok', diff --git a/src/main/ai-vault/session-scanner-service-env.ts b/src/main/ai-vault/session-scanner-service-env.ts index e2f5309e70e..a29dd9d2108 100644 --- a/src/main/ai-vault/session-scanner-service-env.ts +++ b/src/main/ai-vault/session-scanner-service-env.ts @@ -34,6 +34,7 @@ export const RUNTIME_ENV_ALLOWLIST = [ // dropping one hides every session of a user who relocated that agent's home. const AGENT_ROOT_ENV_ALLOWLIST = [ 'CODEX_HOME', + 'CLINE_SESSION_DATA_DIR', 'COPILOT_HOME', 'DEVIN_HOME', 'GROK_HOME', diff --git a/src/main/ai-vault/session-scanner-source-discovery.ts b/src/main/ai-vault/session-scanner-source-discovery.ts index fd368f6c172..fef37ebcda0 100644 --- a/src/main/ai-vault/session-scanner-source-discovery.ts +++ b/src/main/ai-vault/session-scanner-source-discovery.ts @@ -55,6 +55,7 @@ function agentDiscoveries( issues, extensions: [...source.extensions], filePredicate: source.filePredicate, + contentDependencyPath: source.contentDependencyPath, directoryPredicate: source.directoryPredicate }) return source.mergeRootDiscoveries diff --git a/src/main/ai-vault/session-scanner-test-fixtures.ts b/src/main/ai-vault/session-scanner-test-fixtures.ts index 6da538c6a5f..e7dd54196f5 100644 --- a/src/main/ai-vault/session-scanner-test-fixtures.ts +++ b/src/main/ai-vault/session-scanner-test-fixtures.ts @@ -24,6 +24,7 @@ export function isolatedScanRoots(root: string) { primeAgentSessionsDir: join(root, 'prime-agent-sessions'), droidSessionsDir: join(root, 'droid-sessions'), droidProjectsDir: join(root, 'droid-projects'), + clineSessionsDir: join(root, 'cline-sessions'), kimiSessionsDir: join(root, 'kimi-sessions') } } diff --git a/src/main/ai-vault/session-scanner-types.ts b/src/main/ai-vault/session-scanner-types.ts index 9021b1f7c02..ab553b63568 100644 --- a/src/main/ai-vault/session-scanner-types.ts +++ b/src/main/ai-vault/session-scanner-types.ts @@ -33,6 +33,7 @@ export type AiVaultScanOptions = { primeAgentSessionsDir?: string droidSessionsDir?: string droidProjectsDir?: string + clineSessionsDir?: string kimiSessionsDir?: string limit?: number unlimited?: boolean diff --git a/src/main/ai-vault/session-scanner.test.ts b/src/main/ai-vault/session-scanner.test.ts index feff1b19f89..14f37d566b8 100644 --- a/src/main/ai-vault/session-scanner.test.ts +++ b/src/main/ai-vault/session-scanner.test.ts @@ -647,6 +647,26 @@ describe('scanAiVaultSessions', () => { ]) ) + const clineSessionId = 'cline-session' + const clineSessionDir = join(roots.clineSessionsDir, clineSessionId) + await mkdir(clineSessionDir, { recursive: true }) + await writeFile( + join(clineSessionDir, `${clineSessionId}.json`), + JSON.stringify({ + session_id: clineSessionId, + started_at: '2026-05-01T10:10:30.000Z', + model: 'cline-model', + cwd: '/tmp/cline' + }) + ) + await writeFile( + join(clineSessionDir, `${clineSessionId}.messages.json`), + JSON.stringify({ + updated_at: '2026-05-01T10:10:31.000Z', + messages: [{ role: 'user', content: [{ type: 'text', text: 'Cline vault title' }] }] + }) + ) + // Kimi: /wd_*/session_*/state.json + sibling agents/main/wire.jsonl, // with the work dir resolved from the top-level session_index.jsonl. const kimiSessionDir = join(roots.kimiSessionsDir, 'wd_app_abc', 'session_kimi-session') @@ -738,6 +758,7 @@ describe('scanAiVaultSessions', () => { expect(commandByAgent.get('prime-agent')).toBe( `cd '/tmp/prime-agent' && prime-agent --resume '${primeAgentSessionFile}'` ) + expect(commandByAgent.get('cline')).toBe("cd '/tmp/cline' && cline --id 'cline-session'") expect(commandByAgent.get('devin')).toBe("cd '/tmp/devin' && devin --resume 'devin-session'") expect(commandByAgent.get('droid')).toBe("cd '/tmp/droid' && droid --resume 'droid-session'") expect(commandByAgent.get('kimi')).toBe( diff --git a/src/main/automations/hermes-cron-output-markdown.ts b/src/main/automations/hermes-cron-output-markdown.ts new file mode 100644 index 00000000000..a698458ca8d --- /dev/null +++ b/src/main/automations/hermes-cron-output-markdown.ts @@ -0,0 +1,284 @@ +export const HERMES_OUTPUT_FILE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})_(\d{2})-(\d{2})-(\d{2})\.md$/ +const HERMES_RUN_KEY_PATTERN = /^(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})(\d{2})$/ +export const MAX_SESSION_OUTPUT_GAP_MS = 24 * 60 * 60 * 1000 +export const MAX_REFERENCED_LOG_BYTES = 5 * 1024 * 1024 +export const FULL_SESSION_LOG_HEADING = '## Full session log' +export const REFERENCED_LOG_HEADING = '## Latest log file' +const RUN_PREVIEW_LIMIT = 180 +export const LATEST_LOG_PATH_PATTERN = + /\bLatest log path:\s*(?(?:[A-Za-z]:[\\/]|\/)[^\r\n]*?)(?=\s+Run summary:|\r?\n|$)/i +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export function asString(value: unknown): string | null { + return typeof value === 'string' && value.trim() ? value : null +} + +export function runAtFromHermesOutputFile(filename: string): string | null { + const match = HERMES_OUTPUT_FILE_PATTERN.exec(filename) + if (!match) { + return null + } + const [, year, month, day, hour, minute, second] = match + return `${year}-${month}-${day}T${hour}:${minute}:${second}` +} + +export function runKeyFromHermesOutputFile(filename: string): string | null { + const match = HERMES_OUTPUT_FILE_PATTERN.exec(filename) + if (!match) { + return null + } + const [, year, month, day, hour, minute, second] = match + return `${year}${month}${day}_${hour}${minute}${second}` +} + +export function runAtFromUnixSeconds(value: unknown): string | null { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return null + } + const date = new Date(value * 1000) + return Number.isNaN(date.getTime()) ? null : date.toISOString() +} + +export function sortableTimeFromRunKey(runKey: string | null): number { + if (!runKey) { + return Number.NaN + } + const match = HERMES_RUN_KEY_PATTERN.exec(runKey) + if (!match) { + return Number.NaN + } + const [, year, month, day, hour, minute, second] = match + return Date.UTC( + Number(year), + Number(month) - 1, + Number(day), + Number(hour), + Number(minute), + Number(second) + ) +} + +export function escapeSqlLike(value: string): string { + return value.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_') +} + +type ContentRange = { + start: number + end: number +} + +function cleanRunPreview(value: string, startIndex = 0, endIndex = value.length): string | null { + const normalized = foldRunPreviewText(value, startIndex, endIndex) + if (!normalized.text) { + return null + } + return normalized.truncated + ? `${normalized.text.slice(0, RUN_PREVIEW_LIMIT - 3)}...` + : normalized.text +} + +function foldRunPreviewText( + value: string, + startIndex: number, + endIndex: number +): { text: string; truncated: boolean } { + let text = '' + let pendingSpace = false + let index = Math.max(0, startIndex) + const end = Math.min(value.length, endIndex) + while (index < end && text.length <= RUN_PREVIEW_LIMIT) { + if (startsWithAt(value, '```', index)) { + if (text.length > 0) { + pendingSpace = true + } + index = skipFencedBlock(value, index, end) + continue + } + + const code = value.charCodeAt(index) + if (isPreviewSeparator(code)) { + if (text.length > 0) { + pendingSpace = true + } + index += 1 + continue + } + + if (pendingSpace) { + text += ' ' + pendingSpace = false + if (text.length > RUN_PREVIEW_LIMIT) { + break + } + } + text += value[index] + index += 1 + } + return { text, truncated: text.length > RUN_PREVIEW_LIMIT } +} + +function isPreviewSeparator(code: number): boolean { + return ( + code === 32 || + (code >= 9 && code <= 13) || + code === 35 || + code === 40 || + code === 41 || + code === 42 || + code === 62 || + code === 91 || + code === 93 || + code === 95 || + code === 96 + ) +} + +export function parseHermesOutput(content: string): { + status: 'completed' | 'failed' | 'unknown' + outputPreview: string | null + outputContent: string + error: string | null +} { + const errorHeading = findMarkdownHeading(content, '## Error') + const responseHeading = findMarkdownHeading(content, '## Response') + const errorRange = errorHeading ? errorContentRange(content, errorHeading.bodyStart) : null + const failed = hasFailedCronHeading(content) || errorHeading !== null + const error = errorRange ? cleanRunPreview(content, errorRange.start, errorRange.end) : null + const previewRange = responseHeading + ? { start: responseHeading.bodyStart, end: content.length } + : (errorRange ?? { start: 0, end: content.length }) + return { + status: failed ? 'failed' : responseHeading ? 'completed' : 'unknown', + outputPreview: cleanRunPreview(content, previewRange.start, previewRange.end), + outputContent: content, + error + } +} + +function hasFailedCronHeading(content: string): boolean { + let lineStart = 0 + while (lineStart < content.length) { + const lineEnd = lineEndIndex(content, lineStart) + if ( + startsWithAt(content, '#', lineStart) && + lineContains(content, lineStart, lineEnd, 'Cron Job:') && + lineContains(content, lineStart, lineEnd, '(FAILED)') + ) { + return true + } + lineStart = nextLineStart(content, lineEnd) + } + return false +} + +function findMarkdownHeading( + content: string, + heading: '## Error' | '## Response' +): { bodyStart: number } | null { + let lineStart = 0 + while (lineStart < content.length) { + const lineEnd = lineEndIndex(content, lineStart) + if ( + startsWithAt(content, heading, lineStart) && + isHeadingBoundary(content.charCodeAt(lineStart + heading.length)) + ) { + return { bodyStart: nextLineStart(content, lineEnd) } + } + lineStart = nextLineStart(content, lineEnd) + } + return null +} + +function errorContentRange(content: string, bodyStart: number): ContentRange { + const start = skipPreviewWhitespace(content, bodyStart, content.length) + if (!startsWithAt(content, '```', start)) { + return { start, end: nextMarkdownHeadingStart(content, start) ?? content.length } + } + + const fencedStart = nextLineStart(content, lineEndIndex(content, start)) + const fencedEnd = findClosingFence(content, fencedStart) ?? content.length + return { start: fencedStart, end: fencedEnd } +} + +function skipFencedBlock(content: string, fenceStart: number, endIndex: number): number { + const bodyStart = nextLineStart(content, lineEndIndex(content, fenceStart)) + const closeStart = findClosingFence(content, bodyStart) + if (closeStart === null || closeStart > endIndex) { + return endIndex + } + return nextLineStart(content, lineEndIndex(content, closeStart)) +} + +function findClosingFence(content: string, fromIndex: number): number | null { + let lineStart = fromIndex + while (lineStart < content.length) { + const lineEnd = lineEndIndex(content, lineStart) + const textStart = skipPreviewWhitespace(content, lineStart, lineEnd) + if (startsWithAt(content, '```', textStart)) { + return textStart + } + lineStart = nextLineStart(content, lineEnd) + } + return null +} + +function nextMarkdownHeadingStart(content: string, fromIndex: number): number | null { + let lineStart = fromIndex + while (lineStart < content.length) { + const lineEnd = lineEndIndex(content, lineStart) + if (startsWithAt(content, '## ', lineStart)) { + return lineStart + } + lineStart = nextLineStart(content, lineEnd) + } + return null +} + +function lineEndIndex(value: string, startIndex: number): number { + const newline = value.indexOf('\n', startIndex) + return newline === -1 ? value.length : newline +} + +function nextLineStart(value: string, lineEnd: number): number { + return lineEnd < value.length ? lineEnd + 1 : value.length +} + +function lineContains( + value: string, + startIndex: number, + endIndex: number, + needle: string +): boolean { + const index = value.indexOf(needle, startIndex) + return index !== -1 && index < endIndex +} + +function skipPreviewWhitespace(value: string, startIndex: number, endIndex: number): number { + let index = startIndex + while (index < endIndex && isPreviewWhitespace(value.charCodeAt(index))) { + index += 1 + } + return index +} + +function isPreviewWhitespace(code: number): boolean { + return code === 32 || (code >= 9 && code <= 13) +} + +function isHeadingBoundary(code: number): boolean { + return Number.isNaN(code) || isPreviewWhitespace(code) +} + +function startsWithAt(value: string, search: string, startIndex: number): boolean { + if (startIndex + search.length > value.length) { + return false + } + for (let offset = 0; offset < search.length; offset += 1) { + if (value.charCodeAt(startIndex + offset) !== search.charCodeAt(offset)) { + return false + } + } + return true +} diff --git a/src/main/automations/hermes-cron-output.ts b/src/main/automations/hermes-cron-output.ts index 581b7f998d1..22272929e45 100644 --- a/src/main/automations/hermes-cron-output.ts +++ b/src/main/automations/hermes-cron-output.ts @@ -1,31 +1,38 @@ -/* eslint-disable max-lines -- Why: Hermes run history has to reconcile - * markdown output files with SQLite session transcripts from separate stores. */ import { existsSync } from 'node:fs' -import { open, readdir, readFile, realpath, stat } from 'node:fs/promises' +import { readdir, readFile } from 'node:fs/promises' import { homedir } from 'node:os' -import { isAbsolute, join, relative, resolve, sep } from 'node:path' +import { join } from 'node:path' import Database from '../sqlite/sync-database' +import { + HERMES_OUTPUT_FILE_PATTERN, + escapeSqlLike, + parseHermesOutput, + runAtFromHermesOutputFile, + runAtFromUnixSeconds, + runKeyFromHermesOutputFile +} from './hermes-cron-output-markdown' +import { + appendReferencedLogFile, + formatSessionMessages, + mergeHermesOutputAndSessionRunRefs, + mergeHermesOutputAndSessionRuns +} from './hermes-cron-run-content' +import { + clearHermesCronOutputRunCountCache as clearHermesRunCountCache, + readCachedHermesRunCount +} from './hermes-cron-run-count-cache' const HERMES_HOME = process.env.HERMES_HOME?.trim() || join(homedir(), '.hermes') const HERMES_OUTPUT_DIR = join(HERMES_HOME, 'cron', 'output') const HERMES_STATE_DB = join(HERMES_HOME, 'state.db') const EXTERNAL_JOB_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/ -const HERMES_OUTPUT_FILE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})_(\d{2})-(\d{2})-(\d{2})\.md$/ -const HERMES_RUN_KEY_PATTERN = /^(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})(\d{2})$/ -const MAX_SESSION_OUTPUT_GAP_MS = 24 * 60 * 60 * 1000 -const MAX_REFERENCED_LOG_BYTES = 5 * 1024 * 1024 -const FULL_SESSION_LOG_HEADING = '## Full session log' -const REFERENCED_LOG_HEADING = '## Latest log file' -const RUN_PREVIEW_LIMIT = 180 -const LATEST_LOG_PATH_PATTERN = - /\bLatest log path:\s*(?(?:[A-Za-z]:[\\/]|\/)[^\r\n]*?)(?=\s+Run summary:|\r?\n|$)/i export type HermesCronOutputRunsPage = { total: number runs: unknown[] } -type HermesOutputRunRef = { +export type HermesOutputRunRef = { kind: 'output' id: string job_id: string @@ -34,7 +41,7 @@ type HermesOutputRunRef = { output_path: string } -type HermesSessionRunRef = { +export type HermesSessionRunRef = { kind: 'session' id: string job_id: string @@ -42,7 +49,7 @@ type HermesSessionRunRef = { run_key: string | null } -type HermesMergedRunRef = { +export type HermesMergedRunRef = { id: string job_id: string run_at: string | null @@ -51,529 +58,6 @@ type HermesMergedRunRef = { session: HermesSessionRunRef | null } -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - -function asString(value: unknown): string | null { - return typeof value === 'string' && value.trim() ? value : null -} - -function runAtFromHermesOutputFile(filename: string): string | null { - const match = HERMES_OUTPUT_FILE_PATTERN.exec(filename) - if (!match) { - return null - } - const [, year, month, day, hour, minute, second] = match - return `${year}-${month}-${day}T${hour}:${minute}:${second}` -} - -function runKeyFromHermesOutputFile(filename: string): string | null { - const match = HERMES_OUTPUT_FILE_PATTERN.exec(filename) - if (!match) { - return null - } - const [, year, month, day, hour, minute, second] = match - return `${year}${month}${day}_${hour}${minute}${second}` -} - -function runAtFromUnixSeconds(value: unknown): string | null { - if (typeof value !== 'number' || !Number.isFinite(value)) { - return null - } - const date = new Date(value * 1000) - return Number.isNaN(date.getTime()) ? null : date.toISOString() -} - -function sortableTimeFromRunKey(runKey: string | null): number { - if (!runKey) { - return Number.NaN - } - const match = HERMES_RUN_KEY_PATTERN.exec(runKey) - if (!match) { - return Number.NaN - } - const [, year, month, day, hour, minute, second] = match - return Date.UTC( - Number(year), - Number(month) - 1, - Number(day), - Number(hour), - Number(minute), - Number(second) - ) -} - -function escapeSqlLike(value: string): string { - return value.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_') -} - -type ContentRange = { - start: number - end: number -} - -function cleanRunPreview(value: string, startIndex = 0, endIndex = value.length): string | null { - const normalized = foldRunPreviewText(value, startIndex, endIndex) - if (!normalized.text) { - return null - } - return normalized.truncated - ? `${normalized.text.slice(0, RUN_PREVIEW_LIMIT - 3)}...` - : normalized.text -} - -function foldRunPreviewText( - value: string, - startIndex: number, - endIndex: number -): { text: string; truncated: boolean } { - let text = '' - let pendingSpace = false - let index = Math.max(0, startIndex) - const end = Math.min(value.length, endIndex) - while (index < end && text.length <= RUN_PREVIEW_LIMIT) { - if (startsWithAt(value, '```', index)) { - if (text.length > 0) { - pendingSpace = true - } - index = skipFencedBlock(value, index, end) - continue - } - - const code = value.charCodeAt(index) - if (isPreviewSeparator(code)) { - if (text.length > 0) { - pendingSpace = true - } - index += 1 - continue - } - - if (pendingSpace) { - text += ' ' - pendingSpace = false - if (text.length > RUN_PREVIEW_LIMIT) { - break - } - } - text += value[index] - index += 1 - } - return { text, truncated: text.length > RUN_PREVIEW_LIMIT } -} - -function isPreviewSeparator(code: number): boolean { - return ( - code === 32 || - (code >= 9 && code <= 13) || - code === 35 || - code === 40 || - code === 41 || - code === 42 || - code === 62 || - code === 91 || - code === 93 || - code === 95 || - code === 96 - ) -} - -function parseHermesOutput(content: string): { - status: 'completed' | 'failed' | 'unknown' - outputPreview: string | null - outputContent: string - error: string | null -} { - const errorHeading = findMarkdownHeading(content, '## Error') - const responseHeading = findMarkdownHeading(content, '## Response') - const errorRange = errorHeading ? errorContentRange(content, errorHeading.bodyStart) : null - const failed = hasFailedCronHeading(content) || errorHeading !== null - const error = errorRange ? cleanRunPreview(content, errorRange.start, errorRange.end) : null - const previewRange = responseHeading - ? { start: responseHeading.bodyStart, end: content.length } - : (errorRange ?? { start: 0, end: content.length }) - return { - status: failed ? 'failed' : responseHeading ? 'completed' : 'unknown', - outputPreview: cleanRunPreview(content, previewRange.start, previewRange.end), - outputContent: content, - error - } -} - -function hasFailedCronHeading(content: string): boolean { - let lineStart = 0 - while (lineStart < content.length) { - const lineEnd = lineEndIndex(content, lineStart) - if ( - startsWithAt(content, '#', lineStart) && - lineContains(content, lineStart, lineEnd, 'Cron Job:') && - lineContains(content, lineStart, lineEnd, '(FAILED)') - ) { - return true - } - lineStart = nextLineStart(content, lineEnd) - } - return false -} - -function findMarkdownHeading( - content: string, - heading: '## Error' | '## Response' -): { bodyStart: number } | null { - let lineStart = 0 - while (lineStart < content.length) { - const lineEnd = lineEndIndex(content, lineStart) - if ( - startsWithAt(content, heading, lineStart) && - isHeadingBoundary(content.charCodeAt(lineStart + heading.length)) - ) { - return { bodyStart: nextLineStart(content, lineEnd) } - } - lineStart = nextLineStart(content, lineEnd) - } - return null -} - -function errorContentRange(content: string, bodyStart: number): ContentRange { - const start = skipPreviewWhitespace(content, bodyStart, content.length) - if (!startsWithAt(content, '```', start)) { - return { start, end: nextMarkdownHeadingStart(content, start) ?? content.length } - } - - const fencedStart = nextLineStart(content, lineEndIndex(content, start)) - const fencedEnd = findClosingFence(content, fencedStart) ?? content.length - return { start: fencedStart, end: fencedEnd } -} - -function skipFencedBlock(content: string, fenceStart: number, endIndex: number): number { - const bodyStart = nextLineStart(content, lineEndIndex(content, fenceStart)) - const closeStart = findClosingFence(content, bodyStart) - if (closeStart === null || closeStart > endIndex) { - return endIndex - } - return nextLineStart(content, lineEndIndex(content, closeStart)) -} - -function findClosingFence(content: string, fromIndex: number): number | null { - let lineStart = fromIndex - while (lineStart < content.length) { - const lineEnd = lineEndIndex(content, lineStart) - const textStart = skipPreviewWhitespace(content, lineStart, lineEnd) - if (startsWithAt(content, '```', textStart)) { - return textStart - } - lineStart = nextLineStart(content, lineEnd) - } - return null -} - -function nextMarkdownHeadingStart(content: string, fromIndex: number): number | null { - let lineStart = fromIndex - while (lineStart < content.length) { - const lineEnd = lineEndIndex(content, lineStart) - if (startsWithAt(content, '## ', lineStart)) { - return lineStart - } - lineStart = nextLineStart(content, lineEnd) - } - return null -} - -function lineEndIndex(value: string, startIndex: number): number { - const newline = value.indexOf('\n', startIndex) - return newline === -1 ? value.length : newline -} - -function nextLineStart(value: string, lineEnd: number): number { - return lineEnd < value.length ? lineEnd + 1 : value.length -} - -function lineContains( - value: string, - startIndex: number, - endIndex: number, - needle: string -): boolean { - const index = value.indexOf(needle, startIndex) - return index !== -1 && index < endIndex -} - -function skipPreviewWhitespace(value: string, startIndex: number, endIndex: number): number { - let index = startIndex - while (index < endIndex && isPreviewWhitespace(value.charCodeAt(index))) { - index += 1 - } - return index -} - -function isPreviewWhitespace(code: number): boolean { - return code === 32 || (code >= 9 && code <= 13) -} - -function isHeadingBoundary(code: number): boolean { - return Number.isNaN(code) || isPreviewWhitespace(code) -} - -function startsWithAt(value: string, search: string, startIndex: number): boolean { - if (startIndex + search.length > value.length) { - return false - } - for (let offset = 0; offset < search.length; offset += 1) { - if (value.charCodeAt(startIndex + offset) !== search.charCodeAt(offset)) { - return false - } - } - return true -} - -function extractLatestLogPath(content: string): string | null { - const rawPath = LATEST_LOG_PATH_PATTERN.exec(content)?.groups?.path?.trim() - if (!rawPath) { - return null - } - return rawPath.replace(/^`|`$/g, '').trim() -} - -async function readReferencedLogFile(content: string): Promise<{ - path: string - content: string - truncated: boolean -} | null> { - const logPath = extractLatestLogPath(content) - if (!logPath || !isAbsolute(logPath)) { - return null - } - try { - const homeRealPath = await realpath(HERMES_HOME) - const logRealPath = await realpath(logPath) - const relativeToHermesHome = relative(resolve(homeRealPath), resolve(logRealPath)) - // Why: the output body can contain agent-authored text, so only hydrate - // referenced files that resolve inside Hermes' own data directory. - if ( - relativeToHermesHome === '..' || - relativeToHermesHome.startsWith(`..${sep}`) || - isAbsolute(relativeToHermesHome) - ) { - return null - } - const logStat = await stat(logPath) - if (!logStat.isFile()) { - return null - } - if (logStat.size <= MAX_REFERENCED_LOG_BYTES) { - return { - path: logPath, - content: await readFile(logPath, 'utf-8'), - truncated: false - } - } - const file = await open(logPath, 'r') - try { - const buffer = Buffer.alloc(MAX_REFERENCED_LOG_BYTES) - await file.read(buffer, 0, MAX_REFERENCED_LOG_BYTES, logStat.size - MAX_REFERENCED_LOG_BYTES) - return { - path: logPath, - content: buffer.toString('utf-8'), - truncated: true - } - } finally { - await file.close() - } - } catch { - return null - } -} - -async function appendReferencedLogFile(content: string): Promise { - if (content.includes(REFERENCED_LOG_HEADING)) { - return content - } - const logFile = await readReferencedLogFile(content) - if (!logFile) { - return content - } - const note = logFile.truncated - ? `Showing the last ${MAX_REFERENCED_LOG_BYTES} bytes because the log file is larger.` - : null - return [ - content, - '---', - REFERENCED_LOG_HEADING, - '', - `Path: ${logFile.path}`, - note, - '```text', - logFile.content.trimEnd(), - '```' - ] - .filter((part) => part !== null) - .join('\n\n') -} - -function formatSessionMessages(messages: Record[]): string | null { - if (messages.length === 0) { - return null - } - return messages - .map((message) => { - const role = typeof message.role === 'string' ? message.role : 'message' - const content = typeof message.content === 'string' ? message.content.trim() : '' - const toolName = typeof message.tool_name === 'string' ? message.tool_name.trim() : '' - const reasoning = - typeof message.reasoning_content === 'string' - ? message.reasoning_content.trim() - : typeof message.reasoning === 'string' - ? message.reasoning.trim() - : '' - const parts = [ - `## ${role}${toolName ? ` / ${toolName}` : ''}`, - reasoning ? `### Reasoning\n\n${reasoning}` : null, - content || '(empty)' - ].filter(Boolean) - return parts.join('\n\n') - }) - .join('\n\n---\n\n') -} - -function getRunKey(run: unknown): string | null { - return isRecord(run) ? asString(run.run_key) : null -} - -function getRunOutputContent(run: unknown): string | null { - return isRecord(run) ? asString(run.output_content) : null -} - -function mergeOutputAndSessionContent( - outputContent: string | null, - sessionContent: string | null -): string | null { - if (!sessionContent) { - return outputContent - } - if (!outputContent) { - return `${FULL_SESSION_LOG_HEADING}\n\n${sessionContent}` - } - if (outputContent.includes(FULL_SESSION_LOG_HEADING)) { - return outputContent - } - return `${outputContent}\n\n---\n\n${FULL_SESSION_LOG_HEADING}\n\n${sessionContent}` -} - -function findMatchingSessionRunIndex( - outputRun: unknown, - sessionRuns: unknown[], - usedSessionRunIndexes: Set -): number | null { - const outputRunKey = getRunKey(outputRun) - const exactMatchIndex = sessionRuns.findIndex( - (sessionRun, index) => - !usedSessionRunIndexes.has(index) && getRunKey(sessionRun) === outputRunKey - ) - if (exactMatchIndex !== -1) { - return exactMatchIndex - } - - const outputTime = sortableTimeFromRunKey(outputRunKey) - if (!Number.isFinite(outputTime)) { - return null - } - - let bestIndex: number | null = null - let bestGap = Number.POSITIVE_INFINITY - for (let index = 0; index < sessionRuns.length; index += 1) { - if (usedSessionRunIndexes.has(index)) { - continue - } - const sessionTime = sortableTimeFromRunKey(getRunKey(sessionRuns[index])) - if (!Number.isFinite(sessionTime)) { - continue - } - const gap = outputTime - sessionTime - if (gap < 0 || gap > MAX_SESSION_OUTPUT_GAP_MS || gap >= bestGap) { - continue - } - bestIndex = index - bestGap = gap - } - return bestIndex -} - -function mergeHermesOutputAndSessionRuns(outputRuns: unknown[], sessionRuns: unknown[]): unknown[] { - const usedSessionRunIndexes = new Set() - const mergedOutputRuns = outputRuns.map((outputRun) => { - if (!isRecord(outputRun)) { - return outputRun - } - const sessionRunIndex = findMatchingSessionRunIndex( - outputRun, - sessionRuns, - usedSessionRunIndexes - ) - if (sessionRunIndex === null) { - return outputRun - } - const sessionRun = sessionRuns[sessionRunIndex] - if (!isRecord(sessionRun)) { - return outputRun - } - usedSessionRunIndexes.add(sessionRunIndex) - // Hermes writes the markdown output at completion, while state.db keeps - // the actual turn-by-turn transcript under the cron session start time. - return { - ...outputRun, - output_preview: asString(outputRun.output_preview) ?? asString(sessionRun.output_preview), - output_content: mergeOutputAndSessionContent( - getRunOutputContent(outputRun), - getRunOutputContent(sessionRun) - ) - } - }) - return [ - ...mergedOutputRuns, - ...sessionRuns.filter((_, index) => !usedSessionRunIndexes.has(index)) - ] -} - -function mergeHermesOutputAndSessionRunRefs( - outputRefs: HermesOutputRunRef[], - sessionRefs: HermesSessionRunRef[] -): HermesMergedRunRef[] { - const usedSessionRunIndexes = new Set() - const mergedOutputRefs = outputRefs.map((outputRef) => { - const sessionRunIndex = findMatchingSessionRunIndex( - outputRef, - sessionRefs, - usedSessionRunIndexes - ) - const sessionRef = sessionRunIndex === null ? null : sessionRefs[sessionRunIndex] - if (sessionRunIndex !== null) { - usedSessionRunIndexes.add(sessionRunIndex) - } - return { - id: outputRef.id, - job_id: outputRef.job_id, - run_at: outputRef.run_at, - run_key: outputRef.run_key, - output: outputRef, - session: sessionRef - } - }) - return [ - ...mergedOutputRefs, - ...sessionRefs - .filter((_, index) => !usedSessionRunIndexes.has(index)) - .map((sessionRef) => ({ - id: sessionRef.id, - job_id: sessionRef.job_id, - run_at: sessionRef.run_at, - run_key: sessionRef.run_key, - output: null, - session: sessionRef - })) - ] -} - export async function readHermesCronOutputRuns(jobId: string): Promise { return (await readHermesCronOutputRunsPage(jobId, { page: 1, pageSize: Number.MAX_SAFE_INTEGER })) .runs @@ -597,64 +81,15 @@ async function readHermesCronOutputRunRefs(jobId: string): Promise - expiresAt: number -} -const hermesRunCountCache = new Map() - export function clearHermesCronOutputRunCountCache(jobId?: string): void { - if (jobId) { - hermesRunCountCache.delete(jobId) - return - } - hermesRunCountCache.clear() -} - -function pruneHermesRunCountCache(now: number): void { - for (const [jobId, entry] of hermesRunCountCache) { - if (entry.expiresAt <= now) { - hermesRunCountCache.delete(jobId) - } - } - while (hermesRunCountCache.size >= HERMES_RUN_COUNT_CACHE_MAX_ENTRIES) { - const oldestJobId = hermesRunCountCache.keys().next().value - if (oldestJobId === undefined) { - return - } - hermesRunCountCache.delete(oldestJobId) - } + clearHermesRunCountCache(jobId) } async function readHermesCronOutputRunCount(jobId: string): Promise { - const now = Date.now() - const cached = hermesRunCountCache.get(jobId) - if (cached && cached.expiresAt > now) { - return cached.promise - } - if (cached) { - hermesRunCountCache.delete(jobId) - } - // Why: external Hermes jobs can be created/removed outside Orca; without a - // size bound and expired sweep, a long session can pin stale job ids forever. - pruneHermesRunCountCache(now) - const entry: HermesRunCountCacheEntry = { - promise: readHermesCronOutputRunRefs(jobId).then((refs) => refs.length), - expiresAt: Number.POSITIVE_INFINITY - } - hermesRunCountCache.set(jobId, entry) - try { - const count = await entry.promise - entry.expiresAt = Date.now() + HERMES_RUN_COUNT_CACHE_TTL_MS - return count - } catch (error) { - if (hermesRunCountCache.get(jobId) === entry) { - hermesRunCountCache.delete(jobId) - } - throw error - } + return readCachedHermesRunCount(jobId, async (targetJobId) => { + const refs = await readHermesCronOutputRunRefs(targetJobId) + return refs.length + }) } async function hydrateHermesRunRef(jobId: string, ref: HermesMergedRunRef): Promise { diff --git a/src/main/automations/hermes-cron-run-content.ts b/src/main/automations/hermes-cron-run-content.ts new file mode 100644 index 00000000000..55b01fb2545 --- /dev/null +++ b/src/main/automations/hermes-cron-run-content.ts @@ -0,0 +1,270 @@ +import { open, readFile, realpath, stat } from 'node:fs/promises' +import { homedir } from 'node:os' +import { isAbsolute, join, relative, resolve, sep } from 'node:path' +import { + FULL_SESSION_LOG_HEADING, + LATEST_LOG_PATH_PATTERN, + MAX_REFERENCED_LOG_BYTES, + MAX_SESSION_OUTPUT_GAP_MS, + REFERENCED_LOG_HEADING, + asString, + isRecord, + sortableTimeFromRunKey +} from './hermes-cron-output-markdown' +import type { + HermesMergedRunRef, + HermesOutputRunRef, + HermesSessionRunRef +} from './hermes-cron-output' + +const HERMES_HOME = process.env.HERMES_HOME?.trim() || join(homedir(), '.hermes') + +function extractLatestLogPath(content: string): string | null { + const rawPath = LATEST_LOG_PATH_PATTERN.exec(content)?.groups?.path?.trim() + if (!rawPath) { + return null + } + return rawPath.replace(/^`|`$/g, '').trim() +} + +async function readReferencedLogFile(content: string): Promise<{ + path: string + content: string + truncated: boolean +} | null> { + const logPath = extractLatestLogPath(content) + if (!logPath || !isAbsolute(logPath)) { + return null + } + try { + const homeRealPath = await realpath(HERMES_HOME) + const logRealPath = await realpath(logPath) + const relativeToHermesHome = relative(resolve(homeRealPath), resolve(logRealPath)) + // Why: the output body can contain agent-authored text, so only hydrate + // referenced files that resolve inside Hermes' own data directory. + if ( + relativeToHermesHome === '..' || + relativeToHermesHome.startsWith(`..${sep}`) || + isAbsolute(relativeToHermesHome) + ) { + return null + } + const logStat = await stat(logPath) + if (!logStat.isFile()) { + return null + } + if (logStat.size <= MAX_REFERENCED_LOG_BYTES) { + return { + path: logPath, + content: await readFile(logPath, 'utf-8'), + truncated: false + } + } + const file = await open(logPath, 'r') + try { + const buffer = Buffer.alloc(MAX_REFERENCED_LOG_BYTES) + await file.read(buffer, 0, MAX_REFERENCED_LOG_BYTES, logStat.size - MAX_REFERENCED_LOG_BYTES) + return { + path: logPath, + content: buffer.toString('utf-8'), + truncated: true + } + } finally { + await file.close() + } + } catch { + return null + } +} + +export async function appendReferencedLogFile(content: string): Promise { + if (content.includes(REFERENCED_LOG_HEADING)) { + return content + } + const logFile = await readReferencedLogFile(content) + if (!logFile) { + return content + } + const note = logFile.truncated + ? `Showing the last ${MAX_REFERENCED_LOG_BYTES} bytes because the log file is larger.` + : null + return [ + content, + '---', + REFERENCED_LOG_HEADING, + '', + `Path: ${logFile.path}`, + note, + '```text', + logFile.content.trimEnd(), + '```' + ] + .filter((part) => part !== null) + .join('\n\n') +} + +export function formatSessionMessages(messages: Record[]): string | null { + if (messages.length === 0) { + return null + } + return messages + .map((message) => { + const role = typeof message.role === 'string' ? message.role : 'message' + const content = typeof message.content === 'string' ? message.content.trim() : '' + const toolName = typeof message.tool_name === 'string' ? message.tool_name.trim() : '' + const reasoning = + typeof message.reasoning_content === 'string' + ? message.reasoning_content.trim() + : typeof message.reasoning === 'string' + ? message.reasoning.trim() + : '' + const parts = [ + `## ${role}${toolName ? ` / ${toolName}` : ''}`, + reasoning ? `### Reasoning\n\n${reasoning}` : null, + content || '(empty)' + ].filter(Boolean) + return parts.join('\n\n') + }) + .join('\n\n---\n\n') +} + +function getRunKey(run: unknown): string | null { + return isRecord(run) ? asString(run.run_key) : null +} + +function getRunOutputContent(run: unknown): string | null { + return isRecord(run) ? asString(run.output_content) : null +} + +function mergeOutputAndSessionContent( + outputContent: string | null, + sessionContent: string | null +): string | null { + if (!sessionContent) { + return outputContent + } + if (!outputContent) { + return `${FULL_SESSION_LOG_HEADING}\n\n${sessionContent}` + } + if (outputContent.includes(FULL_SESSION_LOG_HEADING)) { + return outputContent + } + return `${outputContent}\n\n---\n\n${FULL_SESSION_LOG_HEADING}\n\n${sessionContent}` +} + +function findMatchingSessionRunIndex( + outputRun: unknown, + sessionRuns: unknown[], + usedSessionRunIndexes: Set +): number | null { + const outputRunKey = getRunKey(outputRun) + const exactMatchIndex = sessionRuns.findIndex( + (sessionRun, index) => + !usedSessionRunIndexes.has(index) && getRunKey(sessionRun) === outputRunKey + ) + if (exactMatchIndex !== -1) { + return exactMatchIndex + } + + const outputTime = sortableTimeFromRunKey(outputRunKey) + if (!Number.isFinite(outputTime)) { + return null + } + + let bestIndex: number | null = null + let bestGap = Number.POSITIVE_INFINITY + for (let index = 0; index < sessionRuns.length; index += 1) { + if (usedSessionRunIndexes.has(index)) { + continue + } + const sessionTime = sortableTimeFromRunKey(getRunKey(sessionRuns[index])) + if (!Number.isFinite(sessionTime)) { + continue + } + const gap = outputTime - sessionTime + if (gap < 0 || gap > MAX_SESSION_OUTPUT_GAP_MS || gap >= bestGap) { + continue + } + bestIndex = index + bestGap = gap + } + return bestIndex +} + +export function mergeHermesOutputAndSessionRuns( + outputRuns: unknown[], + sessionRuns: unknown[] +): unknown[] { + const usedSessionRunIndexes = new Set() + const mergedOutputRuns = outputRuns.map((outputRun) => { + if (!isRecord(outputRun)) { + return outputRun + } + const sessionRunIndex = findMatchingSessionRunIndex( + outputRun, + sessionRuns, + usedSessionRunIndexes + ) + if (sessionRunIndex === null) { + return outputRun + } + const sessionRun = sessionRuns[sessionRunIndex] + if (!isRecord(sessionRun)) { + return outputRun + } + usedSessionRunIndexes.add(sessionRunIndex) + // Hermes writes the markdown output at completion, while state.db keeps + // the actual turn-by-turn transcript under the cron session start time. + return { + ...outputRun, + output_preview: asString(outputRun.output_preview) ?? asString(sessionRun.output_preview), + output_content: mergeOutputAndSessionContent( + getRunOutputContent(outputRun), + getRunOutputContent(sessionRun) + ) + } + }) + return [ + ...mergedOutputRuns, + ...sessionRuns.filter((_, index) => !usedSessionRunIndexes.has(index)) + ] +} + +export function mergeHermesOutputAndSessionRunRefs( + outputRefs: HermesOutputRunRef[], + sessionRefs: HermesSessionRunRef[] +): HermesMergedRunRef[] { + const usedSessionRunIndexes = new Set() + const mergedOutputRefs = outputRefs.map((outputRef) => { + const sessionRunIndex = findMatchingSessionRunIndex( + outputRef, + sessionRefs, + usedSessionRunIndexes + ) + const sessionRef = sessionRunIndex === null ? null : sessionRefs[sessionRunIndex] + if (sessionRunIndex !== null) { + usedSessionRunIndexes.add(sessionRunIndex) + } + return { + id: outputRef.id, + job_id: outputRef.job_id, + run_at: outputRef.run_at, + run_key: outputRef.run_key, + output: outputRef, + session: sessionRef + } + }) + return [ + ...mergedOutputRefs, + ...sessionRefs + .filter((_, index) => !usedSessionRunIndexes.has(index)) + .map((sessionRef) => ({ + id: sessionRef.id, + job_id: sessionRef.job_id, + run_at: sessionRef.run_at, + run_key: sessionRef.run_key, + output: null, + session: sessionRef + })) + ] +} diff --git a/src/main/automations/hermes-cron-run-count-cache.ts b/src/main/automations/hermes-cron-run-count-cache.ts new file mode 100644 index 00000000000..06c45d54d44 --- /dev/null +++ b/src/main/automations/hermes-cron-run-count-cache.ts @@ -0,0 +1,64 @@ +const HERMES_RUN_COUNT_CACHE_TTL_MS = 2000 +const HERMES_RUN_COUNT_CACHE_MAX_ENTRIES = 200 + +type HermesRunCountCacheEntry = { + promise: Promise + expiresAt: number +} + +const hermesRunCountCache = new Map() + +export function clearHermesCronOutputRunCountCache(jobId?: string): void { + if (jobId) { + hermesRunCountCache.delete(jobId) + return + } + hermesRunCountCache.clear() +} + +function pruneHermesRunCountCache(now: number): void { + for (const [jobId, entry] of hermesRunCountCache) { + if (entry.expiresAt <= now) { + hermesRunCountCache.delete(jobId) + } + } + while (hermesRunCountCache.size >= HERMES_RUN_COUNT_CACHE_MAX_ENTRIES) { + const oldestJobId = hermesRunCountCache.keys().next().value + if (oldestJobId === undefined) { + return + } + hermesRunCountCache.delete(oldestJobId) + } +} + +export async function readCachedHermesRunCount( + jobId: string, + loadRunCount: (jobId: string) => Promise +): Promise { + const now = Date.now() + const cached = hermesRunCountCache.get(jobId) + if (cached && cached.expiresAt > now) { + return cached.promise + } + if (cached) { + hermesRunCountCache.delete(jobId) + } + // Why: external Hermes jobs can be created/removed outside Orca; without a + // size bound and expired sweep, a long session can pin stale job ids forever. + pruneHermesRunCountCache(now) + const entry: HermesRunCountCacheEntry = { + promise: loadRunCount(jobId), + expiresAt: Number.POSITIVE_INFINITY + } + hermesRunCountCache.set(jobId, entry) + try { + const count = await entry.promise + entry.expiresAt = Date.now() + HERMES_RUN_COUNT_CACHE_TTL_MS + return count + } catch (error) { + if (hermesRunCountCache.get(jobId) === entry) { + hermesRunCountCache.delete(jobId) + } + throw error + } +} diff --git a/src/main/browser/browser-manager-annotation-bridge.test.ts b/src/main/browser/browser-manager-annotation-bridge.test.ts new file mode 100644 index 00000000000..2d9aa5e50fe --- /dev/null +++ b/src/main/browser/browser-manager-annotation-bridge.test.ts @@ -0,0 +1,210 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const browserMocks = vi.hoisted(() => ({ + appGetPathMock: vi.fn(() => '/downloads'), + shellOpenExternalMock: vi.fn(), + browserWindowFromWebContentsMock: vi.fn(), + menuBuildFromTemplateMock: vi.fn(), + guestOffMock: vi.fn(), + guestOnMock: vi.fn(), + guestSetBackgroundThrottlingMock: vi.fn(), + guestSetWindowOpenHandlerMock: vi.fn(), + guestOpenDevToolsMock: vi.fn(), + webContentsFromIdMock: vi.fn(), + screenGetCursorScreenPointMock: vi.fn(() => ({ x: 0, y: 0 })), + openPopupWithOriginBarMock: vi.fn() +})) + +vi.mock('electron', () => ({ + app: { getPath: browserMocks.appGetPathMock }, + BrowserWindow: { fromWebContents: browserMocks.browserWindowFromWebContentsMock }, + clipboard: { writeText: vi.fn() }, + shell: { openExternal: browserMocks.shellOpenExternalMock }, + Menu: { buildFromTemplate: browserMocks.menuBuildFromTemplateMock }, + screen: { getCursorScreenPoint: browserMocks.screenGetCursorScreenPointMock }, + webContents: { fromId: browserMocks.webContentsFromIdMock } +})) + +vi.mock('./popup-origin-bar-window', () => ({ + openPopupWithOriginBar: browserMocks.openPopupWithOriginBarMock +})) + +import { browserManager } from './browser-manager' +import type { BrowserAnnotationViewportBridgeOptions } from '../../shared/browser-annotation-viewport-bridge' +import { + rendererWebContentsId, + resetBrowserManagerMocks, + resetBrowserManagerState +} from './browser-manager-test-harness' +import { + createViewportGuestFactory, + flushViewportOps +} from './browser-manager-viewport-test-fixtures' + +const { webContentsFromIdMock } = browserMocks +const makeGuest = createViewportGuestFactory(browserMocks) + +const BRIDGE_OPTIONS: BrowserAnnotationViewportBridgeOptions = { + emitViewport: false, + enabled: true, + markers: [], + token: 'annotationviewporttoken' +} + +function registerPage(pageId: string, guest: Record): 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((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).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() + }) +}) diff --git a/src/main/browser/browser-manager-guest-lifecycle.test.ts b/src/main/browser/browser-manager-guest-lifecycle.test.ts index d1a6a16c085..6d71db94585 100644 --- a/src/main/browser/browser-manager-guest-lifecycle.test.ts +++ b/src/main/browser/browser-manager-guest-lifecycle.test.ts @@ -45,6 +45,32 @@ import { resetBrowserManagerMocks, resetBrowserManagerState } from './browser-manager-test-harness' +import { installDocPreviewGuestPolicy } from './doc-preview-guest-policy' +import { mintDocPreviewGrant, revokeAllDocPreviewGrants } from './doc-preview-grant-registry' +import { buildDocPreviewUrl } from '../../shared/doc-preview-scheme' + +/** + * A page the document half of the registry really holds. Built rather than named: membership is + * what both doors refuse on now, so an id that merely looks like a preview's would be admitted. + */ +function registerWorkspaceDocPage(browserPageId: string): void { + const grant = mintDocPreviewGrant({ + owner: { kind: 'ssh', connectionId: 'ssh-1' }, + root: '/home/alice/docs', + entryRelativePath: 'index.html', + browserPageId + }) + const guest = { + isFocused: () => false, + isDestroyed: () => false, + getURL: () => buildDocPreviewUrl(grant.id, grant.entryRelativePath), + on: vi.fn(), + once: vi.fn(), + setWindowOpenHandler: vi.fn(), + setWebRTCIPHandlingPolicy: vi.fn() + } + installDocPreviewGuestPolicy(guest as never, { id: rendererWebContentsId, send: vi.fn() }) +} const { guestOffMock, @@ -60,6 +86,7 @@ describe('browserManager', () => { beforeEach(() => { resetBrowserManagerMocks(browserMocks) resetBrowserManagerState() + revokeAllDocPreviewGrants() }) afterEach(() => { @@ -92,6 +119,77 @@ describe('browserManager', () => { expect(browserManager.getSessionProfileIdForTab('browser-1')).toBe('work') }) + // Why both doors: one id in both halves of the registry would make the tool door answer with a + // document guest for a page the reader is browsing in. + it.each(['registerGuest', 'registerOffscreenGuest'] as const)( + 'refuses %s for a page the document registry already holds', + (entryPoint) => { + const guest = { + id: 129, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: guestOnMock, + off: guestOffMock, + openDevTools: guestOpenDevToolsMock + } + webContentsFromIdMock.mockReturnValue(guest) + browserManager.attachGuestPolicies(guest as never) + const browserPageId = 'doc-page-1' + registerWorkspaceDocPage(browserPageId) + + if (entryPoint === 'registerGuest') { + expect( + browserManager.registerGuest({ + browserPageId, + webContentsId: guest.id, + rendererWebContentsId + }) + ).toBe(false) + } else { + expect( + browserManager.registerOffscreenGuest({ browserPageId, webContentsId: guest.id }) + ).toBe(false) + } + + expect(browserManager.getGuestWebContentsId(browserPageId)).toBeNull() + } + ) + + // Why this answer is load-bearing: the headless backend destroys its window on false, so a true + // for a guest that is already gone would leave a page id registered onto nothing. + it.each(['missing', 'destroyed'] as const)( + 'refuses registerOffscreenGuest when the named guest is %s', + (guestState) => { + webContentsFromIdMock.mockReturnValue( + guestState === 'missing' ? null : { id: 137, isDestroyed: vi.fn(() => true) } + ) + + expect( + browserManager.registerOffscreenGuest({ browserPageId: 'offscreen-1', webContentsId: 137 }) + ).toBe(false) + + expect(browserManager.getGuestWebContentsId('offscreen-1')).toBeNull() + } + ) + + // Why the exit door needs the same check: a document page withdraws by revoking its grant, so its + // id here is misaddressed — and unregistering opens by evicting whatever grab that id names. + it('refuses unregisterGuest for a page the document registry holds', () => { + registerWorkspaceDocPage('doc-page-2') + const cancelGrabOp = vi.spyOn(browserManager, 'cancelGrabOp') + + browserManager.unregisterGuest('doc-page-2') + + expect(cancelGrabOp).not.toHaveBeenCalled() + + // The presence half: the same door does evict a browsing page's grab. + browserManager.unregisterGuest('browser-page-1') + expect(cancelGrabOp).toHaveBeenCalledWith('browser-page-1', 'evicted') + cancelGrabOp.mockRestore() + }) + it('blocks non-web guest navigations after attach', () => { const guest = { isDestroyed: vi.fn(() => false), diff --git a/src/main/browser/browser-manager-guest-policy-profile.test.ts b/src/main/browser/browser-manager-guest-policy-profile.test.ts new file mode 100644 index 00000000000..47871f64ea9 --- /dev/null +++ b/src/main/browser/browser-manager-guest-policy-profile.test.ts @@ -0,0 +1,250 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const browserMocks = vi.hoisted(() => ({ + appGetPathMock: vi.fn(() => '/downloads'), + shellOpenExternalMock: vi.fn(), + browserWindowFromWebContentsMock: vi.fn(), + menuBuildFromTemplateMock: vi.fn(), + guestOffMock: vi.fn(), + guestOnMock: vi.fn(), + guestSetBackgroundThrottlingMock: vi.fn(), + guestSetWindowOpenHandlerMock: vi.fn(), + guestOpenDevToolsMock: vi.fn(), + webContentsFromIdMock: vi.fn(), + screenGetCursorScreenPointMock: vi.fn(() => ({ x: 0, y: 0 })), + openPopupWithOriginBarMock: vi.fn() +})) + +vi.mock('electron', () => ({ + app: { getPath: browserMocks.appGetPathMock }, + BrowserWindow: { fromWebContents: browserMocks.browserWindowFromWebContentsMock }, + clipboard: { writeText: vi.fn() }, + shell: { openExternal: browserMocks.shellOpenExternalMock }, + Menu: { buildFromTemplate: browserMocks.menuBuildFromTemplateMock }, + screen: { getCursorScreenPoint: browserMocks.screenGetCursorScreenPointMock }, + webContents: { fromId: browserMocks.webContentsFromIdMock } +})) +vi.mock('./popup-origin-bar-window', () => ({ + openPopupWithOriginBar: browserMocks.openPopupWithOriginBarMock +})) + +import { browserManager } from './browser-manager' +import { resetBrowserManagerMocks, resetBrowserManagerState } from './browser-manager-test-harness' +import { getWorkspaceDocPageGuest } from './doc-preview-guest-policy' +import { mintDocPreviewGrant, revokeAllDocPreviewGrants } from './doc-preview-grant-registry' +import { buildDocPreviewUrl } from '../../shared/doc-preview-scheme' + +type GuestFake = { + id: number + url: string + listeners: Map 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 + debugger: { + isAttached: () => boolean + attach: ReturnType + sendCommand: ReturnType + } + 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 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) + }) +}) diff --git a/src/main/browser/browser-manager-viewport-override.test.ts b/src/main/browser/browser-manager-viewport-override.test.ts index c72936cf6a3..b7d3bbabe0a 100644 --- a/src/main/browser/browser-manager-viewport-override.test.ts +++ b/src/main/browser/browser-manager-viewport-override.test.ts @@ -882,34 +882,5 @@ describe('browserManager', () => { expect(debuggerAttach).toHaveBeenCalledWith('1.3') expect(debuggerSendCommand).not.toHaveBeenCalled() }) - - it('installs annotation viewport bridge in an isolated world', async () => { - const { guest } = makeGuest(4646) - webContentsFromIdMock.mockReturnValue(guest) - browserManager.attachGuestPolicies(guest as never) - browserManager.registerGuest({ - browserPageId: 'tab-annotations', - webContentsId: guest.id as number, - rendererWebContentsId - }) - - const ok = await browserManager.setAnnotationViewportBridge('tab-annotations', { - emitViewport: false, - enabled: true, - markers: [], - token: 'annotationviewporttoken' - }) - - expect(ok).toBe(true) - expect(guest.executeJavaScriptInIsolatedWorld).toHaveBeenCalledWith( - expect.any(Number), - [ - expect.objectContaining({ - code: expect.stringContaining('__orcaBrowserAnnotationViewportBridge') - }) - ], - false - ) - }) }) }) diff --git a/src/main/browser/browser-manager.ts b/src/main/browser/browser-manager.ts index 7ac64b485b9..2b43e17a371 100644 --- a/src/main/browser/browser-manager.ts +++ b/src/main/browser/browser-manager.ts @@ -63,6 +63,11 @@ import { BROWSER_ANNOTATION_VIEWPORT_BRIDGE_WORLD_ID, buildBrowserAnnotationViewportBridgeScript } from '../../shared/browser-annotation-viewport-bridge' +import { + getWorkspaceDocPageGuest, + installDocPreviewGuestPolicy, + isWorkspaceDocPageId +} from './doc-preview-guest-policy' import type { KeybindingOverrides } from '../../shared/keybindings' import { BrowserCertificateTrustController, @@ -157,6 +162,15 @@ type PopupOwnerContext = { browserTabId: string rootGuestWebContentsId: number } +/** + * What a guest is allowed to be. A browsing guest is the web — popups, clicked-link routing and + * anti-detection all apply. A workspace-document guest renders one granted document and gets none + * of that; `host` is the renderer that minted its grant, and the only sink for what it reports. + */ +export type BrowserGuestPolicy = + | { profile: 'browsing' } + | { profile: 'workspace-doc'; host: Electron.WebContents } +const BROWSING_GUEST_POLICY: BrowserGuestPolicy = { profile: 'browsing' } type PendingMainFrameNavigation = { currentUrl: string supersededUrls: string[] @@ -671,12 +685,20 @@ export class BrowserManager { attachGuestPolicies( guest: Electron.WebContents, - inheritedOwnerContext: PopupOwnerContext | null = null + inheritedOwnerContext: PopupOwnerContext | null = null, + policy: BrowserGuestPolicy = BROWSING_GUEST_POLICY ): void { if (this.policyAttachedGuestIds.has(guest.id)) { return } this.policyAttachedGuestIds.add(guest.id) + // Why one door with a profile rather than a second installer beside it: whether a guest was + // policy-attached at all is what registration and teardown both key on, so a guest that took + // another path into the app is invisible to both. + if (policy.profile === 'workspace-doc') { + this.attachWorkspaceDocGuestPolicies(guest, policy.host) + return + } if (inheritedOwnerContext) { this.popupOwnerContextByGuestId.set(guest.id, inheritedOwnerContext) } @@ -1016,6 +1038,30 @@ export class BrowserManager { }) } + /** + * A workspace document is not the web: no popups, no link routing, no anti-detection, and no + * navigation bookkeeping for chrome it does not have. What it does share with a browsing guest is + * this method's teardown, so a retired preview drops its listeners on the same path. + */ + private attachWorkspaceDocGuestPolicies( + guest: Electron.WebContents, + host: Electron.WebContents + ): void { + const disposeDocPolicy = installDocPreviewGuestPolicy(guest, host) + const handleDestroyed = (): void => { + this.cleanupGuestPolicyAttachment(guest.id) + } + guest.on('destroyed', handleDestroyed) + this.policyCleanupByGuestId.set(guest.id, () => { + disposeDocPolicy() + try { + guest.off('destroyed', handleDestroyed) + } catch { + // guest may already be destroyed + } + }) + } + // Why: navigator.userAgent (read by Google's auth JS) reflects the WebContents UA, // not the request header, so the header-level Firefox switch in setupClientHintsOverride // must be matched here per navigation or the two layers disagree — itself a bot tell. @@ -1318,7 +1364,9 @@ export class BrowserManager { rendererWebContentsId }: BrowserGuestRegistration): boolean { const browserTabId = browserPageId ?? legacyBrowserTabId - if (!browserTabId) { + // Why refuse rather than overwrite: the two halves of the registry must stay disjoint, or one + // id resolves in both and the tool door silently prefers the document guest over the page. + if (!browserTabId || isWorkspaceDocPageId(browserTabId)) { return false } // Why: on guest-surface swap, cancel any grab bound to the old guest's listeners so it doesn't strand on a stale webContents. @@ -1377,6 +1425,12 @@ export class BrowserManager { } unregisterGuest(browserTabId: string): void { + // Why the check on the exit door too: a document page withdraws by revoking its grant, never + // through here, so its id arriving is misaddressed — and the cancel below would evict that + // preview's live grab on the strength of it. + if (isWorkspaceDocPageId(browserTabId)) { + return + } // Why: teardown mid-grab must cancel it so the renderer gets a signal, not a dangling Promise. this.cancelGrabOp(browserTabId, 'evicted') @@ -1444,10 +1498,15 @@ export class BrowserManager { sessionProfileId?: string | null userAgentMode?: BrowserSessionUserAgentMode webContentsId: number - }): void { + }): boolean { + // Why the same check on both registration doors: one id resolving in both halves is the exact + // confusion the split registries exist to prevent. + if (isWorkspaceDocPageId(browserPageId)) { + return false + } const guest = webContents.fromId(webContentsId) if (!guest || guest.isDestroyed()) { - return + return false } // Why: offscreen pages have no renderer webview listeners, so main owns their load-failure lifecycle. this.offscreenGuestIds.add(webContentsId) @@ -1468,6 +1527,7 @@ export class BrowserManager { this.worktreeIdByTabId.set(browserPageId, worktreeId) } this.certificateTrustController?.onGuestRegistered(webContentsId, browserPageId) + return true } unregisterAll(): void { @@ -1843,12 +1903,13 @@ export class BrowserManager { async setAnnotationViewportBridge( browserTabId: string, - options: BrowserAnnotationViewportBridgeOptions + options: BrowserAnnotationViewportBridgeOptions, + resolveGuest: () => Electron.WebContents | null ): Promise { const prev = this.annotationViewportBridgeOpsByTabId.get(browserTabId) ?? Promise.resolve() const next = prev .catch(() => {}) - .then(() => this.doSetAnnotationViewportBridgeImpl(browserTabId, options)) + .then(() => this.doSetAnnotationViewportBridgeImpl(options, resolveGuest)) this.annotationViewportBridgeOpsByTabId.set(browserTabId, next) try { return await next @@ -1859,18 +1920,22 @@ export class BrowserManager { } } + // Why the caller resolves the guest: the same bridge serves browsing pages and workspace + // documents, which live in different halves of the page registry. + // Why a resolver and not the guest itself: this op may have waited behind another one, and a + // cross-process navigation meanwhile swaps the tab's contents without destroying the old one — + // injecting into the guest the request named would bridge a page nobody is looking at. + // Why no tab id: with teardown gone this reaches only the guest the resolver hands back, and + // taking an id it cannot act on would invite the next reader to act on it. private async doSetAnnotationViewportBridgeImpl( - browserTabId: string, - options: BrowserAnnotationViewportBridgeOptions + options: BrowserAnnotationViewportBridgeOptions, + resolveGuest: () => Electron.WebContents | null ): Promise { - const webContentsId = this.webContentsIdByTabId.get(browserTabId) - if (!webContentsId) { - return false - } - const guest = webContents.fromId(webContentsId) + // Why no teardown here: the resolver already unregisters a page whose guest died, and the only + // case it uniquely leaves is an ownership mismatch on a healthy page — where tearing down would + // cancel that page's in-flight downloads and grabs over a request that was merely misaddressed. + const guest = resolveGuest() if (!guest || guest.isDestroyed()) { - // Why: a stale guest must clear every per-tab registry entry, not just the WebContents maps. - this.unregisterGuest(browserTabId) return false } @@ -1977,10 +2042,21 @@ export class BrowserManager { // --- Browser Context Grab — main-owned operations --- /** Validate that the sender owns browserTabId; returns the guest WebContents or null. */ + /** + * The guest a request from `senderWebContentsId` may act on, across both halves of the page + * registry. This is the only door taught about workspace-document guests: they are kept out of + * the browsing maps entirely, so page management, agent commands, download routing and + * certificate attribution all miss them without a guard of their own — and a reader who opens a + * tool on the document in front of them still gets an answer. + */ getAuthorizedGuest( browserTabId: string, senderWebContentsId: number ): Electron.WebContents | null { + const docGuest = getWorkspaceDocPageGuest(browserTabId, senderWebContentsId) + if (docGuest) { + return docGuest + } const registeredRenderer = this.rendererWebContentsIdByTabId.get(browserTabId) if (registeredRenderer == null || registeredRenderer !== senderWebContentsId) { return null diff --git a/src/main/browser/browser-session-partition-policies.test.ts b/src/main/browser/browser-session-partition-policies.test.ts new file mode 100644 index 00000000000..78ce34d95fd --- /dev/null +++ b/src/main/browser/browser-session-partition-policies.test.ts @@ -0,0 +1,235 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { BrowserSessionProfile } from '../../shared/browser-workspace-types' + +const mocks = vi.hoisted(() => ({ + handleGuestWillDownload: vi.fn(), + noticeDocPreviewDownloadBlocked: vi.fn(), + clearBrowserWebAuthnAccessHandlers: vi.fn(), + installBrowserWebAuthnAccessHandlers: vi.fn() +})) + +type WillDownloadListener = ( + event: { preventDefault: () => void }, + item: { id: string }, + webContents: { id: number } +) => void + +type FakeSession = { + listeners: WillDownloadListener[] + on: ReturnType + removeListener: ReturnType + getUserAgent: () => string + setUserAgent: ReturnType + setPermissionRequestHandler: ReturnType + setPermissionCheckHandler: ReturnType + setDisplayMediaRequestHandler: ReturnType +} + +const sessionsByPartition = new Map() + +function fakeSession(): FakeSession { + const listeners: WillDownloadListener[] = [] + return { + listeners, + on: vi.fn((event: string, listener: WillDownloadListener) => { + if (event === 'will-download') { + listeners.push(listener) + } + }), + removeListener: vi.fn((event: string, listener: WillDownloadListener) => { + if (event !== 'will-download') { + return + } + const index = listeners.indexOf(listener) + if (index !== -1) { + listeners.splice(index, 1) + } + }), + getUserAgent: () => 'Mozilla/5.0 Orca', + setUserAgent: vi.fn(), + setPermissionRequestHandler: vi.fn(), + setPermissionCheckHandler: vi.fn(), + setDisplayMediaRequestHandler: vi.fn() + } +} + +vi.mock('electron', () => ({ + session: { + fromPartition: (partition: string) => { + const existing = sessionsByPartition.get(partition) + if (existing) { + return existing + } + const created = fakeSession() + sessionsByPartition.set(partition, created) + return created + } + } +})) +vi.mock('./browser-manager', () => ({ + browserManager: { + handleGuestWillDownload: mocks.handleGuestWillDownload, + installCertificateRequestGuard: vi.fn(), + removeCertificateRequestGuard: vi.fn(), + notifyPermissionDenied: vi.fn() + } +})) +vi.mock('./doc-preview-download-block-notice', () => ({ + noticeDocPreviewDownloadBlocked: mocks.noticeDocPreviewDownloadBlocked +})) +vi.mock('./browser-media-access', () => ({ + hasSystemMediaAccess: () => false, + requestSystemMediaAccess: async () => false +})) +vi.mock('./browser-session-ua', () => ({ + cleanElectronUserAgent: (userAgent: string) => userAgent, + setupClientHintsOverride: vi.fn() +})) +vi.mock('./browser-session-user-agent-mode', () => ({ + setBrowserSessionUserAgentMode: vi.fn() +})) +vi.mock('./browser-webauthn-access', () => ({ + allowsBrowserWebAuthnPermission: () => false, + clearBrowserWebAuthnAccessHandlers: mocks.clearBrowserWebAuthnAccessHandlers, + installBrowserWebAuthnAccessHandlers: mocks.installBrowserWebAuthnAccessHandlers +})) + +type PartitionPolicyInstaller = ( + profile: BrowserSessionProfile, + options?: { downloads?: 'route' | 'deny'; permissions?: 'browser' | 'deny' } +) => void + +// Why imported per test rather than at the top: the installer remembers which partitions it has +// already configured in module state, so a shared import would make the second test's install a +// no-op and leave it reading the first test's listener. +async function loadInstaller(): Promise { + const module = await import('./browser-session-partition-policies') + return module.installBrowserSessionPartitionPolicies +} + +function profileFor(partition: string): BrowserSessionProfile { + return { + id: partition, + scope: 'isolated', + partition, + label: partition, + source: null, + userAgentMode: 'clean' + } +} + +/** Fires the partition's real `will-download` listener and reports what it decided. */ +function fireWillDownload(partition: string): { cancelled: boolean } { + const sess = sessionsByPartition.get(partition) + if (!sess || sess.listeners.length !== 1) { + throw new Error(`expected exactly one will-download listener on ${partition}`) + } + let cancelled = false + sess.listeners[0]({ preventDefault: () => (cancelled = true) }, { id: 'item-1' }, { id: 42 }) + return { cancelled } +} + +beforeEach(() => { + vi.clearAllMocks() + sessionsByPartition.clear() + vi.resetModules() +}) + +describe('partition download policy', () => { + // The presence half: without it, a deny assertion passes for a partition that installed no + // listener at all, and would keep passing if the whole download path were removed. + it('routes a download on a partition that did not ask for the deny', async () => { + const install = await loadInstaller() + install(profileFor('persist:browsing-1')) + + expect(fireWillDownload('persist:browsing-1').cancelled).toBe(false) + expect(mocks.handleGuestWillDownload).toHaveBeenCalledWith( + expect.objectContaining({ guestWebContentsId: 42 }) + ) + }) + + it('cancels a download on a partition that asked for the deny, routing nothing', async () => { + const install = await loadInstaller() + install(profileFor('orca-doc-preview'), { downloads: 'deny' }) + + expect(fireWillDownload('orca-doc-preview').cancelled).toBe(true) + expect(mocks.handleGuestWillDownload).not.toHaveBeenCalled() + }) + + // Why in the same run as the routing test above: a refusal the reader cannot see is a pressed + // button that does nothing, and a notice on the routing partition would announce a download that + // is about to arrive normally. + it('tells the reader about the refusal, and only on the partition that refused', async () => { + const install = await loadInstaller() + install(profileFor('orca-doc-preview'), { downloads: 'deny' }) + install(profileFor('persist:browsing-1')) + + fireWillDownload('persist:browsing-1') + expect(mocks.noticeDocPreviewDownloadBlocked).not.toHaveBeenCalled() + + fireWillDownload('orca-doc-preview') + expect(mocks.noticeDocPreviewDownloadBlocked).toHaveBeenCalledWith( + expect.objectContaining({ id: 42 }) + ) + }) + + // Why both partitions in one run: the listener is module state shared across sessions, so a deny + // installed for one partition must not follow the next partition that installs after it. + it('keeps each partition on its own decision', async () => { + const install = await loadInstaller() + install(profileFor('orca-doc-preview'), { downloads: 'deny' }) + install(profileFor('persist:browsing-1')) + + expect(fireWillDownload('orca-doc-preview').cancelled).toBe(true) + expect(fireWillDownload('persist:browsing-1').cancelled).toBe(false) + expect(mocks.handleGuestWillDownload).toHaveBeenCalledTimes(1) + }) +}) + +describe('partition permission policy', () => { + it('keeps ordinary browser partitions on the browser permission policy', async () => { + const install = await loadInstaller() + install(profileFor('persist:browsing-1')) + + expect(mocks.installBrowserWebAuthnAccessHandlers).toHaveBeenCalledWith( + sessionsByPartition.get('persist:browsing-1') + ) + expect(mocks.clearBrowserWebAuthnAccessHandlers).not.toHaveBeenCalled() + }) + + it('denies every request and check on a strict partition without WebAuthn handlers', async () => { + const install = await loadInstaller() + install(profileFor('orca-doc-preview'), { permissions: 'deny' }) + const sess = sessionsByPartition.get('orca-doc-preview') + if (!sess) { + throw new Error('Expected the preview session') + } + const requestHandler = sess.setPermissionRequestHandler.mock.calls[0]?.[0] as ( + webContents: Electron.WebContents, + permission: string, + callback: (allowed: boolean) => void + ) => void + const checkHandler = sess.setPermissionCheckHandler.mock.calls[0]?.[0] as ( + webContents: Electron.WebContents, + permission: string + ) => boolean + const displayMediaHandler = sess.setDisplayMediaRequestHandler.mock.calls[0]?.[0] as ( + request: Electron.DisplayMediaRequestHandlerHandlerRequest, + callback: (streams: { video?: Electron.WebFrameMain; audio?: 'loopback' }) => void + ) => void + + for (const permission of ['media', 'clipboard-read', 'notifications', 'fullscreen']) { + let decision: boolean | null = null + requestHandler({} as Electron.WebContents, permission, (allowed) => (decision = allowed)) + expect(decision).toBe(false) + expect(checkHandler({} as Electron.WebContents, permission)).toBe(false) + } + expect(mocks.installBrowserWebAuthnAccessHandlers).not.toHaveBeenCalled() + expect(mocks.clearBrowserWebAuthnAccessHandlers).toHaveBeenCalledWith(sess) + let displayMediaDecision: { video?: Electron.WebFrameMain; audio?: 'loopback' } | null = null + displayMediaHandler({} as Electron.DisplayMediaRequestHandlerHandlerRequest, (decision) => { + displayMediaDecision = decision + }) + expect(displayMediaDecision).toEqual({ video: undefined, audio: undefined }) + }) +}) diff --git a/src/main/browser/browser-session-partition-policies.ts b/src/main/browser/browser-session-partition-policies.ts index 2118c62ed68..11f1f741e5e 100644 --- a/src/main/browser/browser-session-partition-policies.ts +++ b/src/main/browser/browser-session-partition-policies.ts @@ -11,6 +11,7 @@ import { clearBrowserWebAuthnAccessHandlers, installBrowserWebAuthnAccessHandlers } from './browser-webauthn-access' +import { noticeDocPreviewDownloadBlocked } from './doc-preview-download-block-notice' // Why: one shared installer keeps every partition's deny-by-default permission/download policies from drifting apart. const configuredPartitions = new Set() @@ -22,6 +23,23 @@ const handleWillDownload = ( browserManager.handleGuestWillDownload({ guestWebContentsId: webContents.id, item }) } +/** + * Why a second listener instead of a branch inside the shared one: `will-download` is a session + * event that names no partition, so the only place the decision can be keyed by partition is which + * listener that partition's session got. A workspace-document guest has no page of its own to + * attribute a download to, so routing one lands it in this desktop's Downloads folder under a + * remote-authored name that nothing in the UI accounts for. + */ +const handleDeniedWillDownload = ( + event: Electron.Event, + _item: Electron.DownloadItem, + webContents: Electron.WebContents +): void => { + event.preventDefault() + // The page gets nothing back; the reader gets a sentence, or a pressed button just does nothing. + noticeDocPreviewDownloadBlocked(webContents) +} + function resolvePermissionNoticeUrl( webContents: Electron.WebContents, details: Electron.PermissionRequest | undefined @@ -37,7 +55,17 @@ function resolvePermissionNoticeUrl( } } -export function installBrowserSessionPartitionPolicies(profile: BrowserSessionProfile): void { +/** `route` hands the item to the owning page's download flow; `deny` cancels it before it starts. */ +export type BrowserPartitionDownloadPolicy = 'route' | 'deny' +export type BrowserPartitionPermissionPolicy = 'browser' | 'deny' + +export function installBrowserSessionPartitionPolicies( + profile: BrowserSessionProfile, + options?: { + downloads?: BrowserPartitionDownloadPolicy + permissions?: BrowserPartitionPermissionPolicy + } +): void { const { partition } = profile const sess = session.fromPartition(partition) setBrowserSessionUserAgentMode(sess, profile.userAgentMode ?? 'clean') @@ -51,62 +79,72 @@ export function installBrowserSessionPartitionPolicies(profile: BrowserSessionPr sess.setUserAgent(cleanUA) setupClientHintsOverride(sess, cleanUA) } - sess.setPermissionRequestHandler((webContents, permission, callback, details) => { - // Why: defer media to macOS TCC; denying at the session layer throws NotAllowedError even after the user granted Camera/Mic to the OS. - if (permission === 'media') { - // Capture before async handling; opaque frames cannot be attributed to a named site. - const rawUrl = resolvePermissionNoticeUrl(webContents, details) - void requestSystemMediaAccess( - details as Electron.MediaAccessPermissionRequest | undefined - ).then( - (granted) => { - if (!granted) { + if (options?.permissions === 'deny') { + sess.setPermissionRequestHandler((_webContents, _permission, callback) => callback(false)) + sess.setPermissionCheckHandler(() => false) + clearBrowserWebAuthnAccessHandlers(sess) + } else { + sess.setPermissionRequestHandler((webContents, permission, callback, details) => { + // Why: defer media to macOS TCC; denying at the session layer throws NotAllowedError even after the user granted Camera/Mic to the OS. + if (permission === 'media') { + // Capture before async handling; opaque frames cannot be attributed to a named site. + const rawUrl = resolvePermissionNoticeUrl(webContents, details) + void requestSystemMediaAccess( + details as Electron.MediaAccessPermissionRequest | undefined + ).then( + (granted) => { + if (!granted) { + browserManager.notifyPermissionDenied({ + guestWebContentsId: webContents.id, + permission, + rawUrl + }) + } + callback(granted) + }, + (error: unknown) => { + console.error('[permissions] Browser media access failed:', error) browserManager.notifyPermissionDenied({ guestWebContentsId: webContents.id, permission, rawUrl }) + callback(false) } - callback(granted) - }, - (error: unknown) => { - console.error('[permissions] Browser media access failed:', error) - browserManager.notifyPermissionDenied({ - guestWebContentsId: webContents.id, - permission, - rawUrl - }) - callback(false) - } - ) - return - } - const allowed = isAutoGrantedBrowserSessionPermission(permission) - if (!allowed) { - const rawUrl = resolvePermissionNoticeUrl(webContents, details) - browserManager.notifyPermissionDenied({ - guestWebContentsId: webContents.id, - permission, - rawUrl - }) - } - callback(allowed) - }) - sess.setPermissionCheckHandler((_webContents, permission, _origin, details) => { - if (permission === 'media') { - return hasSystemMediaAccess(details?.mediaType) - } - if (allowsBrowserWebAuthnPermission(permission, details)) { - return true - } - return isAutoGrantedBrowserSessionPermission(permission) - }) - installBrowserWebAuthnAccessHandlers(sess) + ) + return + } + const allowed = isAutoGrantedBrowserSessionPermission(permission) + if (!allowed) { + const rawUrl = resolvePermissionNoticeUrl(webContents, details) + browserManager.notifyPermissionDenied({ + guestWebContentsId: webContents.id, + permission, + rawUrl + }) + } + callback(allowed) + }) + sess.setPermissionCheckHandler((_webContents, permission, _origin, details) => { + if (permission === 'media') { + return hasSystemMediaAccess(details?.mediaType) + } + if (allowsBrowserWebAuthnPermission(permission, details)) { + return true + } + return isAutoGrantedBrowserSessionPermission(permission) + }) + installBrowserWebAuthnAccessHandlers(sess) + } sess.setDisplayMediaRequestHandler((_request, callback) => { callback({ video: undefined, audio: undefined }) }) sess.removeListener('will-download', handleWillDownload) - sess.on('will-download', handleWillDownload) + sess.removeListener('will-download', handleDeniedWillDownload) + sess.on( + 'will-download', + options?.downloads === 'deny' ? handleDeniedWillDownload : handleWillDownload + ) configuredPartitions.add(partition) } @@ -115,6 +153,7 @@ export function clearBrowserSessionPartitionPolicies(partition: string, sess: Se configuredPartitions.delete(partition) browserManager.removeCertificateRequestGuard(sess) sess.removeListener('will-download', handleWillDownload) + sess.removeListener('will-download', handleDeniedWillDownload) clearBrowserWebAuthnAccessHandlers(sess) sess.setPermissionRequestHandler(null) sess.setPermissionCheckHandler(null) diff --git a/src/main/browser/cdp-auxiliary-commands.ts b/src/main/browser/cdp-auxiliary-commands.ts new file mode 100644 index 00000000000..88a02d3f43c --- /dev/null +++ b/src/main/browser/cdp-auxiliary-commands.ts @@ -0,0 +1,213 @@ +import type { WebContents } from 'electron' +import type { + BrowserCaptureStartResult, + BrowserCaptureStopResult, + BrowserConsoleEntry, + BrowserConsoleResult, + BrowserCookie, + BrowserCookieDeleteResult, + BrowserCookieGetResult, + BrowserCookieSetResult, + BrowserGeolocationResult, + BrowserInterceptDisableResult, + BrowserInterceptEnableResult, + BrowserInterceptedRequest, + BrowserNetworkEntry, + BrowserNetworkLogResult, + BrowserViewportResult +} from '../../shared/runtime-types' +import type { CdpCommandSender, SnapshotResult } from './snapshot-engine' + +export type CdpTabState = { + navigationId: string | null + snapshotResult: SnapshotResult | null + debuggerAttached: boolean + debuggerDetachListener: (() => void) | null + debuggerMessageListener: ((_event: unknown, method: string, params: unknown) => void) | null + iframeSessions: Map + capturing: boolean + consoleLog: BrowserConsoleEntry[] + networkLog: BrowserNetworkEntry[] + intercepting: boolean + interceptPatterns: string[] + pausedRequests: Map + networkRequestMap: Map +} + +type ActiveCdpContext = { + guest: WebContents + sender: CdpCommandSender + state: CdpTabState +} + +export type CdpAuxiliaryCommandHost = { + run: (operation: (context: ActiveCdpContext) => Promise) => Promise + // Why: captureStop must not attach the debugger — stopping a capture on a tab whose + // debugger detached (DevTools opened) would otherwise throw before clearing state. + runOnState: ( + operation: (context: Pick) => Promise + ) => Promise + current: () => Pick +} + +export class CdpAuxiliaryCommands { + constructor(private readonly host: CdpAuxiliaryCommandHost) {} + + cookieGet(url?: string): Promise { + return this.host.run(async ({ sender }) => { + const params: Record = {} + if (url) { + params.urls = [url] + } + const { cookies } = (await sender('Network.getCookies', params)) as { + cookies: BrowserCookie[] + } + return { cookies } + }) + } + + cookieSet(cookie: { + name: string + value: string + domain?: string + path?: string + secure?: boolean + httpOnly?: boolean + sameSite?: string + expires?: number + }): Promise { + return this.host.run(async ({ sender }) => { + let domain = cookie.domain + if (!domain) { + const { result } = (await sender('Runtime.evaluate', { + expression: 'location.hostname', + returnByValue: true + })) as { result: { value: string } } + domain = result.value + } + const params: Record = { + name: cookie.name, + value: cookie.value, + domain, + path: cookie.path ?? '/', + secure: cookie.secure ?? false, + httpOnly: cookie.httpOnly ?? false, + sameSite: cookie.sameSite ?? 'Lax' + } + if (cookie.expires !== undefined) { + params.expires = cookie.expires + } + const { success } = (await sender('Network.setCookie', params)) as { success: boolean } + return { success } + }) + } + + cookieDelete(name: string, domain?: string, url?: string): Promise { + return this.host.run(async ({ sender }) => { + const params: Record = { name } + if (domain) { + params.domain = domain + } + if (url) { + params.url = url + } + if (!domain && !url) { + const { result } = (await sender('Runtime.evaluate', { + expression: 'location.href', + returnByValue: true + })) as { result: { value: string } } + params.url = result.value + } + await sender('Network.deleteCookies', params) + return { deleted: true } + }) + } + + setViewport( + width: number, + height: number, + deviceScaleFactor = 1, + mobile = false + ): Promise { + return this.host.run(async ({ sender }) => { + await sender('Emulation.setDeviceMetricsOverride', { + width, + height, + deviceScaleFactor, + mobile + }) + await Promise.resolve(sender('Emulation.setVisibleSize', { width, height })).catch(() => {}) + return { width, height, deviceScaleFactor, mobile } + }) + } + + setGeolocation( + latitude: number, + longitude: number, + accuracy = 1 + ): Promise { + return this.host.run(async ({ sender }) => { + await sender('Emulation.setGeolocationOverride', { latitude, longitude, accuracy }) + return { latitude, longitude, accuracy } + }) + } + + interceptEnable(patterns: string[] = ['*']): Promise { + return this.host.run(async ({ sender, state }) => { + await sender('Fetch.enable', { patterns: patterns.map((urlPattern) => ({ urlPattern })) }) + state.intercepting = true + state.interceptPatterns = patterns + return { enabled: true, patterns } + }) + } + + interceptDisable(): Promise { + return this.host.run(async ({ sender, state }) => { + await sender('Fetch.disable') + state.intercepting = false + state.interceptPatterns = [] + state.pausedRequests.clear() + return { disabled: true } + }) + } + + interceptList(): { requests: BrowserInterceptedRequest[] } { + const { state } = this.host.current() + return { requests: [...state.pausedRequests.values()] } + } + + captureStart(): Promise { + return this.host.run(async ({ sender, state }) => { + await sender('Runtime.enable') + state.capturing = true + state.consoleLog = [] + state.networkLog = [] + state.networkRequestMap.clear() + return { capturing: true } + }) + } + + captureStop(): Promise { + return this.host.runOnState(async ({ state }) => { + state.capturing = false + state.networkRequestMap.clear() + return { stopped: true } + }) + } + + consoleLog(limit = 100): BrowserConsoleResult { + const { state } = this.host.current() + return { + entries: state.consoleLog.slice(-limit), + truncated: state.consoleLog.length > limit + } + } + + networkLog(limit = 100): BrowserNetworkLogResult { + const { state } = this.host.current() + return { + entries: state.networkLog.slice(-limit), + truncated: state.networkLog.length > limit + } + } +} diff --git a/src/main/browser/cdp-bridge.ts b/src/main/browser/cdp-bridge.ts index fc5991924a9..73dec07f2d2 100644 --- a/src/main/browser/cdp-bridge.ts +++ b/src/main/browser/cdp-bridge.ts @@ -6,9 +6,7 @@ import type { BrowserCheckResult, BrowserClearResult, BrowserClickResult, - BrowserConsoleEntry, BrowserConsoleResult, - BrowserCookie, BrowserCookieDeleteResult, BrowserCookieGetResult, BrowserCookieSetResult, @@ -39,16 +37,12 @@ import type { BrowserViewportResult, BrowserWaitResult } from '../../shared/runtime-types' -import { - buildSnapshot, - type CdpCommandSender, - type RefEntry, - type SnapshotResult -} from './snapshot-engine' +import { buildSnapshot, type CdpCommandSender, type RefEntry } from './snapshot-engine' import { insertTextThroughCdp } from './browser-text-insertion' import type { BrowserManager } from './browser-manager' import { ANTI_DETECTION_SCRIPT } from './anti-detection' import { BrowserError } from './browser-error' +import { CdpAuxiliaryCommands, type CdpTabState } from './cdp-auxiliary-commands' const CAPTURE_LOG_LIMIT = 1000 @@ -56,25 +50,6 @@ const CAPTURE_LOG_LIMIT = 1000 // pulling Chromium in. Existing importers of this path keep working. export { BrowserError } from './browser-error' -type TabState = { - navigationId: string | null - snapshotResult: SnapshotResult | null - debuggerAttached: boolean - debuggerDetachListener: (() => void) | null - debuggerMessageListener: ((_event: unknown, method: string, params: unknown) => void) | null - iframeSessions: Map - // Why: capture state is per-tab so one tab's console/network events don't pollute another's buffer. - capturing: boolean - consoleLog: BrowserConsoleEntry[] - networkLog: BrowserNetworkEntry[] - // Why: interception state lets the agent selectively continue or block individual requests. - intercepting: boolean - interceptPatterns: string[] - pausedRequests: Map - // Why: maps CDP requestId → networkLog entry so loadingFinished attributes size to the right overlapping response. - networkRequestMap: Map -} - type QueuedCommand = { execute: () => Promise resolve: (value: unknown) => void @@ -83,13 +58,45 @@ type QueuedCommand = { export class CdpBridge { private activeWebContentsId: number | null = null - private readonly tabState = new Map() + private readonly tabState = new Map() private readonly commandQueues = new Map() private readonly processingQueues = new Set() private readonly browserManager: BrowserManager + private readonly auxiliaryCommands: CdpAuxiliaryCommands constructor(browserManager: BrowserManager) { this.browserManager = browserManager + this.auxiliaryCommands = new CdpAuxiliaryCommands({ + run: ( + operation: (context: { + guest: Electron.WebContents + sender: CdpCommandSender + state: CdpTabState + }) => Promise + ): Promise => + this.enqueueCommand(async () => { + const guest = this.getActiveGuest() + const sender = this.makeCdpSender(guest) + await this.ensureDebuggerAttached(guest) + const state = this.getOrCreateTabState(this.resolveTabId(guest.id)) + return operation({ guest, sender, state }) + }), + runOnState: ( + operation: (context: { guest: Electron.WebContents; state: CdpTabState }) => Promise + ): Promise => + this.enqueueCommand(async () => { + const guest = this.getActiveGuest() + const state = this.getOrCreateTabState(this.resolveTabId(guest.id)) + return operation({ guest, state }) + }), + current: () => { + const guest = this.getActiveGuest() + return { + guest, + state: this.getOrCreateTabState(this.resolveTabId(guest.id)) + } + } + }) } setActiveTab(webContentsId: number): void { @@ -636,25 +643,11 @@ export class CdpBridge { // ── Cookie management ── - async cookieGet(url?: string): Promise { - return this.enqueueCommand(async () => { - const guest = this.getActiveGuest() - const sender = this.makeCdpSender(guest) - await this.ensureDebuggerAttached(guest) - - const params: Record = {} - if (url) { - params.urls = [url] - } - const { cookies } = (await sender('Network.getCookies', params)) as { - cookies: BrowserCookie[] - } - - return { cookies } - }) + cookieGet(url?: string): Promise { + return this.auxiliaryCommands.cookieGet(url) } - async cookieSet(cookie: { + cookieSet(cookie: { name: string value: string domain?: string @@ -664,211 +657,66 @@ export class CdpBridge { sameSite?: string expires?: number }): Promise { - return this.enqueueCommand(async () => { - const guest = this.getActiveGuest() - const sender = this.makeCdpSender(guest) - await this.ensureDebuggerAttached(guest) - - // Why: Network.setCookie needs a domain or url to scope the cookie; infer the domain from the current page when omitted. - let domain = cookie.domain - if (!domain) { - const { result: urlResult } = (await sender('Runtime.evaluate', { - expression: 'location.hostname', - returnByValue: true - })) as { result: { value: string } } - domain = urlResult.value - } - - const params: Record = { - name: cookie.name, - value: cookie.value, - domain, - path: cookie.path ?? '/', - secure: cookie.secure ?? false, - httpOnly: cookie.httpOnly ?? false, - sameSite: cookie.sameSite ?? 'Lax' - } - if (cookie.expires !== undefined) { - params.expires = cookie.expires - } - - const { success } = (await sender('Network.setCookie', params)) as { success: boolean } - return { success } - }) + return this.auxiliaryCommands.cookieSet(cookie) } - async cookieDelete( - name: string, - domain?: string, - url?: string - ): Promise { - return this.enqueueCommand(async () => { - const guest = this.getActiveGuest() - const sender = this.makeCdpSender(guest) - await this.ensureDebuggerAttached(guest) - - const params: Record = { name } - if (domain) { - params.domain = domain - } - if (url) { - params.url = url - } - // Why: Network.deleteCookies needs a domain or url; infer from the current page if neither was given. - if (!domain && !url) { - const { result: urlResult } = (await sender('Runtime.evaluate', { - expression: 'location.href', - returnByValue: true - })) as { result: { value: string } } - params.url = urlResult.value - } - - await sender('Network.deleteCookies', params) - return { deleted: true } - }) + cookieDelete(name: string, domain?: string, url?: string): Promise { + return this.auxiliaryCommands.cookieDelete(name, domain, url) } // ── Viewport emulation ── - async setViewport( + setViewport( width: number, height: number, deviceScaleFactor = 1, mobile = false ): Promise { - return this.enqueueCommand(async () => { - const guest = this.getActiveGuest() - const sender = this.makeCdpSender(guest) - await this.ensureDebuggerAttached(guest) - - await sender('Emulation.setDeviceMetricsOverride', { - width, - height, - deviceScaleFactor, - mobile - }) - // Why: metrics-only resize can leave the compositor surface at the old size, cropping remote screencast clients. - await Promise.resolve(sender('Emulation.setVisibleSize', { width, height })).catch(() => {}) - - return { width, height, deviceScaleFactor, mobile } - }) + return this.auxiliaryCommands.setViewport(width, height, deviceScaleFactor, mobile) } // ── Geolocation ── - async setGeolocation( + setGeolocation( latitude: number, longitude: number, accuracy = 1 ): Promise { - return this.enqueueCommand(async () => { - const guest = this.getActiveGuest() - const sender = this.makeCdpSender(guest) - await this.ensureDebuggerAttached(guest) - - await sender('Emulation.setGeolocationOverride', { latitude, longitude, accuracy }) - return { latitude, longitude, accuracy } - }) + return this.auxiliaryCommands.setGeolocation(latitude, longitude, accuracy) } // ── Request interception ── - async interceptEnable(patterns: string[] = ['*']): Promise { - return this.enqueueCommand(async () => { - const guest = this.getActiveGuest() - const sender = this.makeCdpSender(guest) - await this.ensureDebuggerAttached(guest) - - const tabId = this.resolveTabId(guest.id) - const state = this.getOrCreateTabState(tabId) - - const requestPatterns = patterns.map((p) => ({ urlPattern: p })) - await sender('Fetch.enable', { patterns: requestPatterns }) - - state.intercepting = true - state.interceptPatterns = patterns - - return { enabled: true, patterns } - }) + interceptEnable(patterns: string[] = ['*']): Promise { + return this.auxiliaryCommands.interceptEnable(patterns) } - async interceptDisable(): Promise { - return this.enqueueCommand(async () => { - const guest = this.getActiveGuest() - const sender = this.makeCdpSender(guest) - await this.ensureDebuggerAttached(guest) - - const tabId = this.resolveTabId(guest.id) - const state = this.getOrCreateTabState(tabId) - - await sender('Fetch.disable') - state.intercepting = false - state.interceptPatterns = [] - state.pausedRequests.clear() - - return { disabled: true } - }) + interceptDisable(): Promise { + return this.auxiliaryCommands.interceptDisable() } interceptList(): { requests: BrowserInterceptedRequest[] } { - const guest = this.getActiveGuest() - const tabId = this.resolveTabId(guest.id) - const state = this.getOrCreateTabState(tabId) - return { requests: [...state.pausedRequests.values()] } + return this.auxiliaryCommands.interceptList() } // TODO: Add interceptContinue/interceptBlock once agent-browser supports per-request decisions (CLI is URL-pattern-only). // ── Console/network capture ── - async captureStart(): Promise { - return this.enqueueCommand(async () => { - const guest = this.getActiveGuest() - const sender = this.makeCdpSender(guest) - await this.ensureDebuggerAttached(guest) - - const tabId = this.resolveTabId(guest.id) - const state = this.getOrCreateTabState(tabId) - - await sender('Runtime.enable') - state.capturing = true - state.consoleLog = [] - state.networkLog = [] - state.networkRequestMap.clear() - - return { capturing: true } - }) + captureStart(): Promise { + return this.auxiliaryCommands.captureStart() } - async captureStop(): Promise { - return this.enqueueCommand(async () => { - const guest = this.getActiveGuest() - const tabId = this.resolveTabId(guest.id) - const state = this.getOrCreateTabState(tabId) - - state.capturing = false - state.networkRequestMap.clear() - - return { stopped: true } - }) + captureStop(): Promise { + return this.auxiliaryCommands.captureStop() } consoleLog(limit = 100): BrowserConsoleResult { - const guest = this.getActiveGuest() - const tabId = this.resolveTabId(guest.id) - const state = this.getOrCreateTabState(tabId) - - const entries = state.consoleLog.slice(-limit) - return { entries, truncated: state.consoleLog.length > limit } + return this.auxiliaryCommands.consoleLog(limit) } networkLog(limit = 100): BrowserNetworkLogResult { - const guest = this.getActiveGuest() - const tabId = this.resolveTabId(guest.id) - const state = this.getOrCreateTabState(tabId) - - const entries = state.networkLog.slice(-limit) - return { entries, truncated: state.networkLog.length > limit } + return this.auxiliaryCommands.networkLog(limit) } async back(): Promise<{ url: string; title: string }> { @@ -1077,7 +925,7 @@ export class CdpBridge { return null } - private getOrCreateTabState(tabId: string): TabState { + private getOrCreateTabState(tabId: string): CdpTabState { let state = this.tabState.get(tabId) if (!state) { state = { @@ -1100,7 +948,7 @@ export class CdpBridge { return state } - private removeDebuggerListeners(guest: Electron.WebContents, state: TabState): void { + private removeDebuggerListeners(guest: Electron.WebContents, state: CdpTabState): void { const detachListener = state.debuggerDetachListener const messageListener = state.debuggerMessageListener state.debuggerDetachListener = null diff --git a/src/main/browser/doc-preview-download-block-notice.test.ts b/src/main/browser/doc-preview-download-block-notice.test.ts new file mode 100644 index 00000000000..c57998ea4e5 --- /dev/null +++ b/src/main/browser/doc-preview-download-block-notice.test.ts @@ -0,0 +1,126 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + publishDocPreviewFailure: vi.fn(), + boundGrantIdByGuest: new Map(), + revocationListener: null as null | ((grant: { id: string }) => void) +})) + +vi.mock('./doc-preview-failure-notice', () => ({ + publishDocPreviewFailure: mocks.publishDocPreviewFailure +})) +vi.mock('./doc-preview-guest-policy', () => ({ + readDocPreviewGuestBoundGrantId: (guest: object) => mocks.boundGrantIdByGuest.get(guest) ?? null +})) +vi.mock('./doc-preview-grant-registry', () => ({ + onDocPreviewGrantRevoked: (listener: (grant: { id: string }) => void) => { + mocks.revocationListener = listener + return vi.fn() + } +})) + +const GRANT_ID = 'a'.repeat(32) +const OTHER_GRANT_ID = 'b'.repeat(32) + +/** Only the identity matters: the module asks the guest registry what grant this contents holds. */ +function guestBoundTo(grantId: string | null): Electron.WebContents { + const guest = {} as Electron.WebContents + if (grantId !== null) { + mocks.boundGrantIdByGuest.set(guest, grantId) + } + return guest +} + +async function loadNotifier(): Promise<(guest: Electron.WebContents) => void> { + const module = await import('./doc-preview-download-block-notice') + return module.noticeDocPreviewDownloadBlocked +} + +beforeEach(() => { + vi.clearAllMocks() + mocks.boundGrantIdByGuest.clear() + mocks.revocationListener = null + // Why per test: the module remembers which grants it has already told the reader about. + vi.resetModules() + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-08-27T00:00:00.000Z')) +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('noticeDocPreviewDownloadBlocked', () => { + it('tells the shell which preview had a download refused', async () => { + const notice = await loadNotifier() + + notice(guestBoundTo(GRANT_ID)) + + expect(mocks.publishDocPreviewFailure).toHaveBeenCalledWith({ + grantId: GRANT_ID, + reason: 'download-blocked' + }) + }) + + // Why: a document can ask in a loop. The reader learns nothing from the second notice, and every + // attempt would otherwise cross the IPC boundary and re-render the strip. + it('says it once however often the document asks', async () => { + const notice = await loadNotifier() + const guest = guestBoundTo(GRANT_ID) + + notice(guest) + notice(guest) + vi.advanceTimersByTime(1_500) + notice(guest) + + expect(mocks.publishDocPreviewFailure).toHaveBeenCalledTimes(1) + }) + + it('says it again for an attempt long after the last one', async () => { + const notice = await loadNotifier() + const guest = guestBoundTo(GRANT_ID) + + notice(guest) + vi.advanceTimersByTime(2_500) + notice(guest) + + expect(mocks.publishDocPreviewFailure).toHaveBeenCalledTimes(2) + }) + + // Why not one throttle for the whole app: two previews are two readers, and silencing the second + // because the first just refused something leaves that press unexplained. + it('throttles each preview on its own', async () => { + const notice = await loadNotifier() + + notice(guestBoundTo(GRANT_ID)) + notice(guestBoundTo(OTHER_GRANT_ID)) + + expect(mocks.publishDocPreviewFailure).toHaveBeenCalledTimes(2) + expect(mocks.publishDocPreviewFailure).toHaveBeenLastCalledWith({ + grantId: OTHER_GRANT_ID, + reason: 'download-blocked' + }) + }) + + it('forgets a preview as soon as its grant is revoked', async () => { + const notice = await loadNotifier() + const guest = guestBoundTo(GRANT_ID) + + notice(guest) + mocks.revocationListener?.({ id: GRANT_ID }) + notice(guest) + + expect(mocks.publishDocPreviewFailure).toHaveBeenCalledTimes(2) + }) + + // The absence half of the first test: no shell is showing this contents, so there is no preview + // to put a notice on. Without the presence tests above, this would pass on a module that never + // published anything at all. + it('says nothing for a contents no preview is bound to', async () => { + const notice = await loadNotifier() + + notice(guestBoundTo(null)) + + expect(mocks.publishDocPreviewFailure).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/browser/doc-preview-download-block-notice.ts b/src/main/browser/doc-preview-download-block-notice.ts new file mode 100644 index 00000000000..13cebef49b7 --- /dev/null +++ b/src/main/browser/doc-preview-download-block-notice.ts @@ -0,0 +1,37 @@ +import { publishDocPreviewFailure } from './doc-preview-failure-notice' +import { onDocPreviewGrantRevoked } from './doc-preview-grant-registry' +import { readDocPreviewGuestBoundGrantId } from './doc-preview-guest-policy' + +/** + * Why the reader is told at all, when the point of the fence is that nothing happens: a press that + * produces no file and no explanation reads as Orca being broken. The notice is chrome the document + * cannot see or read back, so the refusal stays as silent to the page as it was. + * + * Why a floor between notices: a document can ask in a loop, and every attempt would otherwise + * cross the IPC boundary and re-render the strip. The reader learns nothing from the thousandth. + */ +const NOTICE_MIN_INTERVAL_MS = 2_000 +const noticedAtByGrantId = new Map() + +onDocPreviewGrantRevoked((grant) => noticedAtByGrantId.delete(grant.id)) + +export function noticeDocPreviewDownloadBlocked(guest: Electron.WebContents): void { + const grantId = readDocPreviewGuestBoundGrantId(guest) + // Nothing to route a notice to: no shell is showing this contents as a preview. + if (grantId === null) { + return + } + const now = Date.now() + // Doubles as the expiry check, so what survives is exactly "noticed inside the window", and a + // grant that stops downloading stops being remembered. + for (const [noticedGrantId, noticedAt] of noticedAtByGrantId) { + if (now - noticedAt >= NOTICE_MIN_INTERVAL_MS) { + noticedAtByGrantId.delete(noticedGrantId) + } + } + if (noticedAtByGrantId.has(grantId)) { + return + } + noticedAtByGrantId.set(grantId, now) + publishDocPreviewFailure({ grantId, reason: 'download-blocked' }) +} diff --git a/src/main/browser/doc-preview-failure-notice.test.ts b/src/main/browser/doc-preview-failure-notice.test.ts new file mode 100644 index 00000000000..2272e7bf614 --- /dev/null +++ b/src/main/browser/doc-preview-failure-notice.test.ts @@ -0,0 +1,70 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { DOC_PREVIEW_LOAD_FAILURE_CHANNEL } from '../../shared/doc-preview-scheme' +import { publishDocPreviewFailure, setDocPreviewFailureSink } from './doc-preview-failure-notice' + +afterEach(() => { + setDocPreviewFailureSink(null) +}) + +describe('publishDocPreviewFailure', () => { + it('sends the grant, path, and reason on the failure channel', () => { + const send = vi.fn() + setDocPreviewFailureSink({ send }) + + publishDocPreviewFailure({ + grantId: 'a'.repeat(32), + relativePath: 'index.html', + reason: 'too-large' + }) + + expect(send).toHaveBeenCalledWith(DOC_PREVIEW_LOAD_FAILURE_CHANNEL, { + grantId: 'a'.repeat(32), + relativePath: 'index.html', + reason: 'too-large' + }) + }) + + // Why: reads can outlive the window that asked for them; a missing sink must not throw inside + // the protocol handler. + it('is a no-op with no sink registered', () => { + expect(() => + publishDocPreviewFailure({ + grantId: 'b'.repeat(32), + relativePath: 'index.html', + reason: 'unreadable' + }) + ).not.toThrow() + }) + + it('sends nothing into a destroyed window', () => { + const send = vi.fn() + setDocPreviewFailureSink({ send, isDestroyed: () => true }) + + publishDocPreviewFailure({ + grantId: 'c'.repeat(32), + relativePath: 'index.html', + reason: 'unreadable' + }) + + expect(send).not.toHaveBeenCalled() + }) + + // Why: a WebContents can be torn down between the liveness check and the send. + it('swallows a throwing sink and stops using it', () => { + const send = vi.fn(() => { + throw new Error('Object has been destroyed') + }) + setDocPreviewFailureSink({ send }) + const failure = { + grantId: 'd'.repeat(32), + relativePath: 'index.html', + reason: 'unreadable' as const + } + + expect(() => publishDocPreviewFailure(failure)).not.toThrow() + publishDocPreviewFailure(failure) + + expect(send).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/browser/doc-preview-failure-notice.ts b/src/main/browser/doc-preview-failure-notice.ts new file mode 100644 index 00000000000..1b7330d6f27 --- /dev/null +++ b/src/main/browser/doc-preview-failure-notice.ts @@ -0,0 +1,34 @@ +import { + DOC_PREVIEW_LOAD_FAILURE_CHANNEL, + type DocPreviewFailure +} from '../../shared/doc-preview-scheme' + +type DocPreviewFailureSink = { + send: (channel: string, payload: DocPreviewFailure) => void + isDestroyed?: () => boolean +} + +let failureSink: DocPreviewFailureSink | null = null + +export function setDocPreviewFailureSink(sink: DocPreviewFailureSink | null): void { + failureSink = sink +} + +/** + * The preview shell cannot read the guest's HTTP status, and a 4xx body renders as + * if it were the document. Pushing the reason lets the shell replace that with a + * localized notice for the failure the user actually hit. + */ +export function publishDocPreviewFailure(failure: DocPreviewFailure): void { + const sink = failureSink + // Why: a read can outlive the window that asked for it, and sending into torn-down + // WebContents throws — an unreadable asset must not take the protocol handler with it. + if (!sink || sink.isDestroyed?.()) { + return + } + try { + sink.send(DOC_PREVIEW_LOAD_FAILURE_CHANNEL, failure) + } catch { + failureSink = null + } +} diff --git a/src/main/browser/doc-preview-file-reader.test.ts b/src/main/browser/doc-preview-file-reader.test.ts new file mode 100644 index 00000000000..2919d06eec9 --- /dev/null +++ b/src/main/browser/doc-preview-file-reader.test.ts @@ -0,0 +1,403 @@ +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 { + authorizeDocPreviewDirectory, + mintDocPreviewGrant, + revokeAllDocPreviewGrants +} from './doc-preview-grant-registry' + +// Why the fixtures approve the document directory up front: these tests exercise the transport +// half of a read — an entry-only grant's approval flow is pinned in its own test below. +function sshGrant(): ReturnType { + const grant = mintDocPreviewGrant({ + owner: { kind: 'ssh', connectionId: 'ssh-1' }, + root: '/home/alice/docs', + entryRelativePath: 'index.html', + browserPageId: 'page-1' + }) + authorizeDocPreviewDirectory(grant.id, grant.entryRelativePath) + return grant +} + +function runtimeGrant(root = '/srv/repo/docs'): ReturnType { + const grant = mintDocPreviewGrant({ + owner: { + kind: 'runtime', + environmentId: 'env-1', + worktreeSelector: 'id:wt-1', + worktreeRoot: '/srv/repo' + }, + root, + entryRelativePath: 'index.html', + browserPageId: 'page-1' + }) + authorizeDocPreviewDirectory(grant.id, grant.entryRelativePath) + return grant +} + +beforeEach(() => { + vi.clearAllMocks() + revokeAllDocPreviewGrants() + // 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: '

hi

', 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('

hi

', '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: '

whole

', 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: '

real

', 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: '

hi

', 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('requires approval for a sibling directory before touching the SSH provider', async () => { + const grant = mintDocPreviewGrant({ + owner: { kind: 'ssh', connectionId: 'ssh-1' }, + requestBase: '/home/alice', + root: '/home/alice/docs', + entryRelativePath: 'docs/index.html', + browserPageId: 'page-1' + }) + + await expect(readDocPreviewFile(grant, 'assets/logo.png')).resolves.toMatchObject({ + ok: false, + status: 403, + reason: 'authorization-required' + }) + expect(mocks.requireSshFilesystemProvider).not.toHaveBeenCalled() + + authorizeDocPreviewDirectory(grant.id, 'assets/logo.png') + mocks.readFile.mockResolvedValue({ content: 'logo', isBinary: false }) + + await expect(readDocPreviewFile(grant, 'assets/logo.png')).resolves.toMatchObject({ ok: true }) + expect(mocks.readFile).toHaveBeenCalledWith('/home/alice/assets/logo.png') + }) + + 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: '

remote

', 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('

remote

', '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: '

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: '

all

', 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() + }) + + it('requires approval for a sibling directory before touching the runtime', async () => { + const grant = mintDocPreviewGrant({ + owner: { + kind: 'runtime', + environmentId: 'env-1', + worktreeSelector: 'id:wt-1', + worktreeRoot: '/srv/repo' + }, + requestBase: '/srv/repo', + root: '/srv/repo/docs', + entryRelativePath: 'docs/index.html', + browserPageId: 'page-1' + }) + + await expect(readDocPreviewFile(grant, 'assets/app.js')).resolves.toMatchObject({ + ok: false, + status: 403, + reason: 'authorization-required' + }) + expect(mocks.callRuntimeEnvironment).not.toHaveBeenCalled() + + authorizeDocPreviewDirectory(grant.id, 'assets/app.js') + mocks.callRuntimeEnvironment.mockResolvedValue({ + ok: true, + result: { content: 'console.log(1)', truncated: false, byteLength: 14 } + }) + + await expect(readDocPreviewFile(grant, 'assets/app.js')).resolves.toMatchObject({ ok: true }) + expect(mocks.callRuntimeEnvironment).toHaveBeenCalledWith( + '/user-data', + 'env-1', + 'files.read', + { worktree: 'id:wt-1', relativePath: 'assets/app.js' }, + 15_000 + ) + }) +}) diff --git a/src/main/browser/doc-preview-file-reader.ts b/src/main/browser/doc-preview-file-reader.ts new file mode 100644 index 00000000000..87e78c1d2e3 --- /dev/null +++ b/src/main/browser/doc-preview-file-reader.ts @@ -0,0 +1,208 @@ +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, + resolveDocPreviewCandidatePath, + 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 = { + '.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 { + 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 { + const candidatePath = resolveDocPreviewCandidatePath(grant, relativePath) + if (!candidatePath) { + return notFoundOutcome() + } + const absolutePath = resolveDocPreviewTargetPath(grant, relativePath) + if (!absolutePath) { + return { + ok: false, + status: 403, + reason: 'authorization-required', + message: 'This file needs permission before the preview can read it.' + } + } + const contentType = docPreviewContentType(relativePath) + try { + if (grant.owner.kind === 'ssh') { + const provider = requireSshFilesystemProvider(grant.owner.connectionId) + // 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) + } +} diff --git a/src/main/browser/doc-preview-grant-registry.test.ts b/src/main/browser/doc-preview-grant-registry.test.ts new file mode 100644 index 00000000000..36022f6a753 --- /dev/null +++ b/src/main/browser/doc-preview-grant-registry.test.ts @@ -0,0 +1,283 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { + authorizeDocPreviewDirectory, + 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', () => { + // A grant whose root IS its request base — a document at the workspace root, or outside any + // workspace — starts with the entry file alone: that directory is where secrets live, and a + // DNS-prefetch beacon needs no click, so nothing beside the entry reads without the reader. + it('reads only the entry document until the reader approves its directory', () => { + const grant = mintPosixGrant() + + expect(resolveDocPreviewTargetPath(grant, 'index.html')).toBe('/srv/repo/docs/index.html') + expect(resolveDocPreviewTargetPath(grant, 'assets/logo.png')).toBeNull() + expect(resolveDocPreviewTargetPath(grant, 'secrets.env')).toBeNull() + + expect(authorizeDocPreviewDirectory(grant.id, 'assets/logo.png')).toBe(true) + expect(resolveDocPreviewTargetPath(grant, 'assets/logo.png')).toBe( + '/srv/repo/docs/assets/logo.png' + ) + expect(resolveDocPreviewTargetPath(grant, 'secrets.env')).toBeNull() + }) + + it('keeps silent authority over a document directory strictly inside the workspace, and only there', () => { + const nested = mintDocPreviewGrant({ + owner: sshOwner, + requestBase: '/srv/repo', + root: '/srv/repo/docs', + entryRelativePath: 'docs/report.html', + browserPageId: 'page-1' + }) + expect(resolveDocPreviewTargetPath(nested, 'docs/styles.css')).toBe('/srv/repo/docs/styles.css') + + const atWorkspaceRoot = mintDocPreviewGrant({ + owner: sshOwner, + requestBase: '/srv/repo', + root: '/srv/repo', + entryRelativePath: 'report.html', + browserPageId: 'page-2' + }) + expect(resolveDocPreviewTargetPath(atWorkspaceRoot, 'report.html')).toBe( + '/srv/repo/report.html' + ) + expect(resolveDocPreviewTargetPath(atWorkspaceRoot, '.env')).toBeNull() + expect(resolveDocPreviewTargetPath(atWorkspaceRoot, 'docs/styles.css')).toBeNull() + }) + + it('refuses parent traversal, absolute escapes and empty paths', () => { + const grant = mintPosixGrant() + + expect(resolveDocPreviewTargetPath(grant, '../secret.env')).toBeNull() + expect(resolveDocPreviewTargetPath(grant, 'assets/../../secret.env')).toBeNull() + expect(resolveDocPreviewTargetPath(grant, '..')).toBeNull() + expect(resolveDocPreviewTargetPath(grant, '')).toBeNull() + expect(resolveDocPreviewTargetPath(grant, 'a//b')).toBeNull() + }) + + it('refuses backslash and NUL segments that could re-split on the owning host', () => { + const grant = mintPosixGrant() + + expect(resolveDocPreviewTargetPath(grant, '..\\secret.env')).toBeNull() + expect(resolveDocPreviewTargetPath(grant, 'index.html\0.png')).toBeNull() + }) + + // Why this is a traversal test and not a containment test: every request path that names a + // sibling directory has to climb out of the root first, so the `..` segment guard answers it + // before the prefix check runs. Sibling containment is exercised where it is reachable — + // against a canonicalized path, below. + it('refuses a sibling directory by refusing the traversal that reaches it', () => { + const grant = mintPosixGrant() + + expect(resolveDocPreviewTargetPath(grant, '../docs-private/secret.html')).toBeNull() + }) + + it('keeps a Windows drive root addressable instead of turning it drive-relative', () => { + const grant = mintDocPreviewGrant({ + owner: sshOwner, + root: 'C:\\', + entryRelativePath: 'index.html', + browserPageId: 'page-1' + }) + + expect(resolveDocPreviewTargetPath(grant, 'index.html')).toBe('C:\\index.html') + }) + + it('follows the owning host path flavor rather than this process platform', () => { + const windowsGrant = mintDocPreviewGrant({ + owner: sshOwner, + root: 'C:\\srv\\repo\\docs', + entryRelativePath: 'index.html', + browserPageId: 'page-1' + }) + + expect(authorizeDocPreviewDirectory(windowsGrant.id, 'assets/logo.png')).toBe(true) + expect(resolveDocPreviewTargetPath(windowsGrant, 'assets/logo.png')).toBe( + 'C:\\srv\\repo\\docs\\assets\\logo.png' + ) + expect(resolveDocPreviewTargetPath(windowsGrant, '../secret.env')).toBeNull() + }) + + it('normalizes a trailing separator on the root', () => { + const grant = mintDocPreviewGrant({ + owner: sshOwner, + root: '/srv/repo/docs/', + entryRelativePath: 'index.html', + browserPageId: 'page-1' + }) + + expect(resolveDocPreviewTargetPath(grant, 'index.html')).toBe('/srv/repo/docs/index.html') + expect(resolveDocPreviewTargetPath(grant, '../secret.env')).toBeNull() + }) + + it('authorizes only the requested sibling directory', () => { + const grant = mintDocPreviewGrant({ + owner: sshOwner, + requestBase: '/srv/repo', + root: '/srv/repo/docs', + entryRelativePath: 'docs/report.html', + browserPageId: 'page-1' + }) + + expect(resolveDocPreviewTargetPath(grant, 'docs/report.html')).toBe( + '/srv/repo/docs/report.html' + ) + expect(resolveDocPreviewTargetPath(grant, 'assets/chart.js')).toBeNull() + expect(resolveDocPreviewTargetPath(grant, '.env')).toBeNull() + + expect(authorizeDocPreviewDirectory(grant.id, 'assets/chart.js')).toBe(true) + expect(resolveDocPreviewTargetPath(grant, 'assets/chart.js')).toBe('/srv/repo/assets/chart.js') + expect(resolveDocPreviewTargetPath(grant, 'assets/theme.css')).toBe( + '/srv/repo/assets/theme.css' + ) + expect(resolveDocPreviewTargetPath(grant, '.env')).toBeNull() + }) + + it('refuses malformed authorization requests and unknown grants', () => { + const grant = mintDocPreviewGrant({ + owner: sshOwner, + requestBase: '/srv/repo', + root: '/srv/repo/docs', + entryRelativePath: 'docs/report.html', + browserPageId: 'page-1' + }) + + expect(authorizeDocPreviewDirectory(grant.id, '../outside/secret.txt')).toBe(false) + expect(authorizeDocPreviewDirectory(grant.id, 'assets\\secret.txt')).toBe(false) + expect(authorizeDocPreviewDirectory('0'.repeat(32), 'assets/chart.js')).toBe(false) + expect(grant.authorizedRoots).toEqual([]) + }) + + it('uses Windows semantics when authorizing a sibling directory', () => { + const grant = mintDocPreviewGrant({ + owner: sshOwner, + requestBase: 'C:\\srv\\repo', + root: 'C:\\srv\\repo\\docs', + entryRelativePath: 'docs/report.html', + browserPageId: 'page-1' + }) + + expect(authorizeDocPreviewDirectory(grant.id, 'assets/chart.js')).toBe(true) + expect(resolveDocPreviewTargetPath(grant, 'assets/chart.js')).toBe( + 'C:\\srv\\repo\\assets\\chart.js' + ) + expect(authorizeDocPreviewDirectory(grant.id, '../outside/secret.txt')).toBe(false) + }) +}) + +describe('resolveCanonicalDocPreviewPath', () => { + // Why here and not above: a canonical path is the one input that can name a sibling directory + // without traversing — the host resolved a symlink to it — so this is where the prefix check + // is the only thing standing between the grant and `/srv/repo/docs-private`. + it('refuses a canonical path in a sibling directory that shares the root prefix', async () => { + const grant = mintPosixGrant() + + await expect( + resolveCanonicalDocPreviewPath(grant, '/srv/repo/docs/index.html', async (path) => + path === grant.root ? path : '/srv/repo/docs-private/secret.html' + ) + ).resolves.toBeNull() + }) + + it('answers the canonical path when it stays inside the canonical root', async () => { + const grant = mintPosixGrant() + + await expect( + resolveCanonicalDocPreviewPath(grant, '/srv/repo/docs/index.html', async (path) => path) + ).resolves.toBe('/srv/repo/docs/index.html') + }) + + it('refuses a sibling of an entry-only document even when it canonicalizes cleanly', async () => { + const grant = mintPosixGrant() + + await expect( + resolveCanonicalDocPreviewPath(grant, '/srv/repo/docs/notes.txt', async (path) => path) + ).resolves.toBeNull() + }) + + it('keeps an approved SSH directory inside the canonical workspace boundary', async () => { + const grant = mintDocPreviewGrant({ + owner: sshOwner, + requestBase: '/srv/repo', + root: '/srv/repo/docs', + entryRelativePath: 'docs/report.html', + browserPageId: 'page-1' + }) + authorizeDocPreviewDirectory(grant.id, 'assets/chart.js') + + await expect( + resolveCanonicalDocPreviewPath(grant, '/srv/repo/assets/chart.js', async (path) => { + if (path === '/srv/repo/assets') { + return '/srv/repo/assets' + } + if (path === '/srv/repo/assets/chart.js') { + return '/etc/shadow' + } + return path + }) + ).resolves.toBeNull() + }) +}) + +describe('toRuntimeWorktreeRelativePath', () => { + it('produces a worktree-relative path for files inside the worktree', () => { + expect(toRuntimeWorktreeRelativePath('/srv/repo', '/srv/repo/docs/index.html')).toBe( + 'docs/index.html' + ) + }) + + it('rejects paths outside the worktree, which files.read cannot address', () => { + expect(toRuntimeWorktreeRelativePath('/srv/repo', '/tmp/agent/report.html')).toBeNull() + expect(toRuntimeWorktreeRelativePath('/srv/repo', '/srv/repo')).toBeNull() + }) + + it('uses Windows semantics for a Windows worktree root', () => { + expect(toRuntimeWorktreeRelativePath('C:\\srv\\repo', 'C:\\srv\\repo\\docs\\index.html')).toBe( + 'docs/index.html' + ) + expect(toRuntimeWorktreeRelativePath('C:\\srv\\repo', 'D:\\other\\index.html')).toBeNull() + }) +}) diff --git a/src/main/browser/doc-preview-grant-registry.ts b/src/main/browser/doc-preview-grant-registry.ts new file mode 100644 index 00000000000..191b7ddeb95 --- /dev/null +++ b/src/main/browser/doc-preview-grant-registry.ts @@ -0,0 +1,305 @@ +import { randomBytes } from 'node:crypto' +import { posix, win32 } from 'node:path' +import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' + +/** + * A preview grant is the only authority that turns an `orca-preview://` request + * into bytes: it names the host that owns the file and the single directory + * subtree requests may resolve inside. No grant, no bytes. + */ +export type DocPreviewOwner = + | { kind: 'ssh'; connectionId: string } + | { + kind: 'runtime' + environmentId: string + /** Selector the runtime resolves `files.read` against. */ + worktreeSelector: string + /** Worktree root on the runtime host; `files.read` only accepts paths inside it. */ + worktreeRoot: string + } + +export type DocPreviewGrant = { + id: string + owner: DocPreviewOwner + /** Directory relative preview URLs resolve against. */ + requestBase: string + /** + * Containing directory of the opened document, on the owning host. It carries silent read + * authority only while it sits strictly inside `requestBase`: a document at the workspace root — + * or outside any workspace, where its directory IS the request base — starts with the entry file + * alone, because that directory is where secrets live and a DNS-prefetch beacon needs no click. + */ + root: string + /** Additional directories the reader approved for this grant. */ + authorizedRoots: string[] + /** Path of the opened document relative to `requestBase`. */ + entryRelativePath: string + /** + * Browser page the reader opened this document in. Main registers the guest under it once the + * guest commits to the grant, so the surface a tool names is the page the reader is looking at + * and not the grant, which a re-mint replaces underneath the same page. + */ + browserPageId: string +} + +const grantsById = new Map() + +function pathFlavorFor(root: string): typeof posix | typeof win32 { + return isWindowsAbsolutePathLike(root) ? win32 : posix +} + +function normalizeRootPath(root: string): string { + const flavor = pathFlavorFor(root) + const normalized = + flavor === win32 ? flavor.normalize(root.replace(/\//g, '\\')) : flavor.normalize(root) + // Why: `C:\` is the whole root, and trimming its separator would make win32.join answer the + // drive-relative `C:x`, which resolves against the host's cwd instead of inside the grant. + if (flavor === win32 && /^[a-zA-Z]:\\$/.test(normalized)) { + return normalized + } + // Why: a trailing separator would make the containment prefix check accept a sibling directory. + return normalized.length > 1 && normalized.endsWith(flavor.sep) + ? normalized.slice(0, -1) + : normalized +} + +export function mintDocPreviewGrant(params: { + owner: DocPreviewOwner + requestBase?: string + root: string + entryRelativePath: string + browserPageId: string +}): DocPreviewGrant { + const requestBase = normalizeRootPath(params.requestBase ?? params.root) + const root = normalizeRootPath(params.root) + const flavor = pathFlavorFor(requestBase) + if (!isAtOrInsideRoot(requestBase, root, flavor)) { + throw new Error('Document preview root is outside its request base') + } + const grant: DocPreviewGrant = { + id: randomBytes(16).toString('hex'), + owner: params.owner, + requestBase, + root, + authorizedRoots: [], + entryRelativePath: params.entryRelativePath.replace(/\\/g, '/'), + browserPageId: params.browserPageId + } + grantsById.set(grant.id, grant) + return grant +} + +export function getDocPreviewGrant(grantId: string): DocPreviewGrant | null { + return grantsById.get(grantId) ?? null +} + +/** + * Why anything listens at all: a grant is the only thing that names a preview's lifetime. State + * elsewhere in main keyed by a preview's tool target — grab intent, a queued grab chain — has no + * other signal telling it the surface is gone, and would otherwise accrete one entry per grant + * for the life of the process. + */ +const revocationListeners = new Set<(grant: DocPreviewGrant) => void>() + +/** Why the whole grant and not its id: it is already gone from the registry when listeners run. */ +export function onDocPreviewGrantRevoked(listener: (grant: DocPreviewGrant) => void): () => void { + revocationListeners.add(listener) + return () => revocationListeners.delete(listener) +} + +function notifyRevoked(grant: DocPreviewGrant): void { + for (const listener of revocationListeners) { + listener(grant) + } +} + +export function revokeDocPreviewGrant(grantId: string): boolean { + canonicalRootByGrantId.delete(grantId) + const grant = grantsById.get(grantId) + if (!grant) { + return false + } + grantsById.delete(grantId) + notifyRevoked(grant) + return true +} + +export function revokeAllDocPreviewGrants(): void { + canonicalRootByGrantId.clear() + const revoked = [...grantsById.values()] + grantsById.clear() + for (const grant of revoked) { + notifyRevoked(grant) + } +} + +function hasUnsafeSegment(segments: string[]): boolean { + return segments.some( + (segment) => + segment.length === 0 || + segment === '.' || + segment === '..' || + segment.includes('\0') || + segment.includes('\\') + ) +} + +/** Resolves a safe request inside the workspace boundary, before grant authorization. */ +export function resolveDocPreviewCandidatePath( + grant: DocPreviewGrant, + relativePath: string +): string | null { + const segments = relativePath.split('/').filter((segment, index, all) => { + // Why: keep empty segments visible to the safety check except a single trailing one from `dir/`. + return !(segment === '' && index === all.length - 1) + }) + if (segments.length === 0 || hasUnsafeSegment(segments)) { + return null + } + const flavor = pathFlavorFor(grant.requestBase) + const resolved = flavor.normalize(flavor.join(grant.requestBase, ...segments)) + return isInsideRoot(grant.requestBase, resolved, flavor) ? resolved : null +} + +function isInsideRoot( + root: string, + candidate: string, + flavor: typeof posix | typeof win32 +): boolean { + const rootPrefix = root.endsWith(flavor.sep) ? root : `${root}${flavor.sep}` + return candidate.startsWith(rootPrefix) +} + +function isAtOrInsideRoot( + root: string, + candidate: string, + flavor: typeof posix | typeof win32 +): boolean { + return candidate === root || isInsideRoot(root, candidate, flavor) +} + +function directoryAuthorityRoots(grant: DocPreviewGrant): string[] { + return grant.root === grant.requestBase + ? [...grant.authorizedRoots] + : [grant.root, ...grant.authorizedRoots] +} + +/** The one path an entry-only grant can read before the reader approves a directory. */ +function resolveEntryAbsolutePath(grant: DocPreviewGrant): string | null { + return resolveDocPreviewCandidatePath(grant, grant.entryRelativePath) +} + +/** Resolves a request only when it is the entry document or its directory is authorized. */ +export function resolveDocPreviewTargetPath( + grant: DocPreviewGrant, + relativePath: string +): string | null { + const resolved = resolveDocPreviewCandidatePath(grant, relativePath) + if (!resolved) { + return null + } + if (resolved === resolveEntryAbsolutePath(grant)) { + return resolved + } + const flavor = pathFlavorFor(grant.requestBase) + return directoryAuthorityRoots(grant).some((root) => isInsideRoot(root, resolved, flavor)) + ? resolved + : null +} + +/** Expands a live grant to the directory containing one reader-approved request. */ +export function authorizeDocPreviewDirectory(grantId: string, relativePath: string): boolean { + const grant = grantsById.get(grantId) + if (!grant) { + return false + } + const candidate = resolveDocPreviewCandidatePath(grant, relativePath) + if (!candidate) { + return false + } + const flavor = pathFlavorFor(grant.requestBase) + const directory = normalizeRootPath(flavor.dirname(candidate)) + if (!isAtOrInsideRoot(grant.requestBase, directory, flavor)) { + return false + } + if (!directoryAuthorityRoots(grant).some((root) => isAtOrInsideRoot(root, directory, flavor))) { + grant.authorizedRoots.push(directory) + canonicalRootByGrantId.delete(grant.id) + } + return true +} + +/** Why: realpath is a host round-trip, and a grant's root is fixed for its lifetime. */ +const canonicalRootByGrantId = new Map< + string, + Promise<{ boundary: string; roots: string[]; entry: string | null }> +>() + +/** + * Second containment pass for hosts where the lexical one is not enough: a symlink + * inside the root can point anywhere, and the SSH read RPC applies no root of its + * own. Both sides are canonicalized on the owning host before the prefix re-check; + * a host that cannot canonicalize a path answers nothing. + */ +export async function resolveCanonicalDocPreviewPath( + grant: DocPreviewGrant, + absolutePath: string, + realpath: (path: string) => Promise +): Promise { + try { + let canonicalRoots = canonicalRootByGrantId.get(grant.id) + if (!canonicalRoots) { + const entryAbsolute = resolveEntryAbsolutePath(grant) + canonicalRoots = Promise.all([ + realpath(grant.requestBase), + entryAbsolute === null ? Promise.resolve(null) : realpath(entryAbsolute), + ...directoryAuthorityRoots(grant).map((root) => realpath(root)) + ]).then(([boundaryPath, entryPath, ...rootPaths]) => { + const boundary = normalizeRootPath(boundaryPath) + const flavor = pathFlavorFor(boundary) + return { + boundary, + roots: rootPaths + .map(normalizeRootPath) + .filter((root) => isAtOrInsideRoot(boundary, root, flavor)), + entry: entryPath !== null && isInsideRoot(boundary, entryPath, flavor) ? entryPath : null + } + }) + canonicalRootByGrantId.set(grant.id, canonicalRoots) + } + const [{ boundary, roots, entry }, canonicalPath] = await Promise.all([ + canonicalRoots, + realpath(absolutePath) + ]) + const flavor = pathFlavorFor(boundary) + return isInsideRoot(boundary, canonicalPath, flavor) && + (canonicalPath === entry || roots.some((root) => isInsideRoot(root, canonicalPath, flavor))) + ? canonicalPath + : null + } catch { + // Why: a root that no longer canonicalizes must not fall back to the lexical answer. + canonicalRootByGrantId.delete(grant.id) + return null + } +} + +/** + * Path a runtime `files.read` can address, i.e. relative to the worktree root. + * Returns null when the grant root sits outside the worktree — the runtime file + * RPCs are worktree-scoped, so those documents are unreadable client-side. + */ +export function toRuntimeWorktreeRelativePath( + worktreeRoot: string, + absolutePath: string +): string | null { + const flavor = pathFlavorFor(worktreeRoot) + const normalizedRoot = normalizeRootPath(worktreeRoot) + const relative = flavor.relative(normalizedRoot, absolutePath) + if (!relative || relative === '..' || relative.startsWith(`..${flavor.sep}`)) { + return null + } + if (flavor === win32 && /^[a-zA-Z]:/.test(relative)) { + return null + } + return relative.replace(/\\/g, '/') +} diff --git a/src/main/browser/doc-preview-guest-policy.test.ts b/src/main/browser/doc-preview-guest-policy.test.ts new file mode 100644 index 00000000000..5813d60e110 --- /dev/null +++ b/src/main/browser/doc-preview-guest-policy.test.ts @@ -0,0 +1,552 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + getWorkspaceDocPageGuest, + installDocPreviewGuestPolicy, + readDocPreviewGuestBoundGrantId, + reportDocPreviewLinkClick +} from './doc-preview-guest-policy' +import { buildDocPreviewUrl } from '../../shared/doc-preview-scheme' +import { + mintDocPreviewGrant, + revokeAllDocPreviewGrants, + revokeDocPreviewGrant +} from './doc-preview-grant-registry' + +type GuestHandlers = Record 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 + send: ReturnType + setWebRTCIPHandlingPolicy: ReturnType + 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 + +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; 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 { + 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