mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 16:03:49 +00:00
Merge branch 'main' into brennanb2025/fix-run-binding-loss
This commit is contained in:
@@ -112,7 +112,6 @@ All stable kinds (`patch`, `minor`, `major`) are computed off the latest _stable
|
||||
|
||||
The scheduled 2x/day RC cron in [`release-rc.yml`](../../actions/workflows/release-rc.yml) is independent and continues to run automatically from `main`.
|
||||
|
||||
|
||||
## Release Channels
|
||||
|
||||
The public Homebrew cask tracks stable desktop releases:
|
||||
|
||||
@@ -87,7 +87,9 @@ runs:
|
||||
run: |
|
||||
pnpm install --frozen-lockfile --ignore-scripts
|
||||
# Job containers can run composite steps from a source mirror without .git.
|
||||
git -C "$GITHUB_WORKSPACE" diff --exit-code -- package.json pnpm-lock.yaml
|
||||
if [ "$(git -C "$GITHUB_WORKSPACE" rev-parse --is-inside-work-tree 2>/dev/null)" = true ]; then
|
||||
git -C "$GITHUB_WORKSPACE" diff --exit-code -- package.json pnpm-lock.yaml
|
||||
fi
|
||||
|
||||
# Why cached: `--ignore-scripts` leaves node-pty without build/Release, so
|
||||
# ensure-native-runtime node-gyp-compiles it in every job that asks for a runtime.
|
||||
|
||||
@@ -308,7 +308,6 @@ jobs:
|
||||
echo "tag=$TAG" >>"$GITHUB_OUTPUT"
|
||||
echo "notes_file=$notes_file" >>"$GITHUB_OUTPUT"
|
||||
|
||||
|
||||
- name: Publish daily macOS artifacts
|
||||
if: steps.freshness.outputs.should_build == 'true'
|
||||
uses: nick-fields/retry@v4
|
||||
|
||||
@@ -290,7 +290,6 @@ jobs:
|
||||
including back to Stable, works in-app from there."
|
||||
echo "tag=$TAG" >>"$GITHUB_OUTPUT"
|
||||
|
||||
|
||||
- name: Publish hourly macOS artifacts
|
||||
if: steps.freshness.outputs.should_build == 'true'
|
||||
uses: nick-fields/retry@v4
|
||||
|
||||
@@ -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"]'
|
||||
@@ -444,16 +444,12 @@ jobs:
|
||||
src/shared/posix-command-path-lookup.test.ts
|
||||
|
||||
# Cache-key input changes would otherwise make every shard compile the same
|
||||
# native addon concurrently. Prime each Node ABI once before the matrix fans out.
|
||||
# native addon concurrently. Prime the supported Node ABI before the matrix fans out.
|
||||
test_native_cache:
|
||||
name: prepare test native cache node ${{ matrix.node }}
|
||||
name: prepare test native cache node 24
|
||||
needs: [code_paths]
|
||||
if: needs.code_paths.outputs.native_cache_changed == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
node: ['24', '26']
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -464,58 +460,17 @@ jobs:
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
native-runtime: node
|
||||
node-version: ${{ matrix.node }}
|
||||
node-version: '24'
|
||||
|
||||
test:
|
||||
name: tests node ${{ matrix.node }} ${{ matrix.shard }}/${{ matrix.shard_total }}
|
||||
needs: [code_paths, test_native_cache]
|
||||
if: >-
|
||||
always() &&
|
||||
needs.code_paths.outputs.test == 'true' &&
|
||||
(needs.test_native_cache.result == 'success' || needs.test_native_cache.result == 'skipped')
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
node: ['24', '26']
|
||||
shard: [1, 2, 3, 4, 5, 6, 7, 8]
|
||||
shard_total: [8]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
native-runtime: node
|
||||
node-version: ${{ matrix.node }}
|
||||
|
||||
- name: Install Electron package binary for tests
|
||||
run: node config/scripts/install-electron-package-binary.mjs
|
||||
|
||||
- name: Test shard
|
||||
run: |
|
||||
pnpm exec vitest run --config config/vitest.config.ts \
|
||||
--exclude=src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts \
|
||||
--exclude=src/main/daemon/shell-ready.test.ts \
|
||||
--exclude=src/main/daemon/node-pty-fd-leak.test.ts \
|
||||
--exclude=src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts \
|
||||
--exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \
|
||||
--exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \
|
||||
--exclude=src/main/shell-startup-feature-channel.test.ts \
|
||||
--exclude=src/main/terminal-history-fish-session.node-pty.test.ts \
|
||||
--exclude=src/main/zsh-scoped-histfile.live-shell.test.ts \
|
||||
--exclude=src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts \
|
||||
--exclude=src/main/zsh-wrapper-version-mismatch.live-shell.test.ts \
|
||||
--exclude=src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \
|
||||
--exclude=src/shared/fish-query-reply-child-stdin.node-pty.test.ts \
|
||||
--exclude=src/shared/pty-reply-echo-shapes.node-pty.test.ts \
|
||||
--exclude=src/shared/startup-shell-portability.live-shell.test.ts \
|
||||
--exclude=src/shared/posix-command-path-lookup.test.ts \
|
||||
--exclude=tests/e2e/cross-version-wire/** \
|
||||
--shard=${{ matrix.shard }}/${{ matrix.shard_total }}
|
||||
uses: ./.github/workflows/unit-tests.yml
|
||||
with:
|
||||
node_versions: '["24"]'
|
||||
|
||||
# Why a separate job: the test needs a real Chrome, and the sharded `test` matrix
|
||||
# would pay for it on every shard to run one file in whichever shard it landed in.
|
||||
|
||||
@@ -34,8 +34,7 @@ jobs:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
GOOGLE_GENERATIVE_AI_API_KEY:
|
||||
${{ secrets.GOOGLE_GENERATIVE_AI_API_KEY }}
|
||||
GOOGLE_GENERATIVE_AI_API_KEY: ${{ secrets.GOOGLE_GENERATIVE_AI_API_KEY }}
|
||||
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
|
||||
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
|
||||
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
|
||||
|
||||
@@ -844,6 +844,16 @@ jobs:
|
||||
with:
|
||||
ref: refs/tags/${{ needs.cut.outputs.tag }}
|
||||
|
||||
- name: Restore golden test harness from the workflow ref
|
||||
shell: bash
|
||||
env:
|
||||
WORKFLOW_SHA: ${{ github.workflow_sha }}
|
||||
run: |
|
||||
git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA"
|
||||
git checkout "$WORKFLOW_SHA" -- \
|
||||
tests/e2e/golden-source-control-open-diff.spec.ts \
|
||||
tests/e2e/golden-terminal-file-link.spec.ts
|
||||
|
||||
- name: Install native build tools
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential python3 xvfb
|
||||
@@ -946,6 +956,16 @@ jobs:
|
||||
with:
|
||||
ref: refs/tags/${{ needs.cut.outputs.tag }}
|
||||
|
||||
- name: Restore skill-sharing test harness from the workflow ref
|
||||
shell: bash
|
||||
env:
|
||||
WORKFLOW_SHA: ${{ github.workflow_sha }}
|
||||
run: |
|
||||
git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA"
|
||||
git checkout "$WORKFLOW_SHA" -- \
|
||||
src/main/skills/skill-freshness-inventory.test.ts \
|
||||
src/main/skills/skill-provider-runtime-roots.test.ts
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
native-runtime: node
|
||||
@@ -978,13 +998,26 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Install Ubuntu 20.04 prerequisites
|
||||
run: apt-get update && apt-get install -y build-essential ca-certificates git python3
|
||||
run: apt-get update && apt-get install -y build-essential ca-certificates git python3 unzip
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: refs/tags/${{ needs.cut.outputs.tag }}
|
||||
|
||||
- name: Trust the checked-out workspace in the job container
|
||||
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
|
||||
|
||||
- name: Restore skill-sharing test harness from the workflow ref
|
||||
shell: bash
|
||||
env:
|
||||
WORKFLOW_SHA: ${{ github.workflow_sha }}
|
||||
run: |
|
||||
git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA"
|
||||
git checkout "$WORKFLOW_SHA" -- \
|
||||
src/main/skills/skill-freshness-inventory.test.ts \
|
||||
src/main/skills/skill-provider-runtime-roots.test.ts
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
native-runtime: node
|
||||
|
||||
@@ -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 }}
|
||||
@@ -7,13 +7,15 @@ All UI work — layout, color, typography, spacing, component selection, UX beha
|
||||
Use the `$electron` skill and Playwright CDP for rendered Orca UI checks. Do not use computer-use for Orca UI validation.
|
||||
|
||||
# Style
|
||||
|
||||
## Reuse Before Reimplementing
|
||||
|
||||
Before writing new logic at any scale — a function, component, IPC channel, state store, or whole subsystem/flow — check whether an existing implementation already does the job (or nearly does). Extend or generalize it instead of building a parallel version; only write from scratch when nothing fits. Keep the check proportionate: a quick search for trivial code, a real one before building anything substantial.
|
||||
|
||||
## Concise/Brief Non-obvious Comments ONLY
|
||||
* DO NOT: be verbose, explain the obvious, walk through the code ("WHY not HOW")
|
||||
* BE CONCISE. 1 LINE if possible
|
||||
|
||||
- DO NOT: be verbose, explain the obvious, walk through the code ("WHY not HOW")
|
||||
- BE CONCISE. 1 LINE if possible
|
||||
|
||||
## Lint Rules: Do Not Disable Max Lines
|
||||
|
||||
@@ -32,6 +34,7 @@ Never use vague names like `helpers`, `utils`, `common`, `misc`, or `shared-stuf
|
||||
- **Lint**: `oxlint`, or `pnpm run check:code-quality:changed` for changed files (full `pnpm lint` is slow); format with `pnpm format`
|
||||
|
||||
# Considerations
|
||||
|
||||
## Worktree Safety
|
||||
|
||||
Always use the primary working directory (the worktree) for all file reads and edits. Never follow absolute paths from subagent results that point to the main repo.
|
||||
|
||||
@@ -262,6 +262,7 @@ Want to contribute or run locally? See our [CONTRIBUTING.md](.github/CONTRIBUTIN
|
||||
</p>
|
||||
|
||||
## Signed Builds
|
||||
|
||||
Windows code signing sponored/provided by [SignPath.io](https://signpath.io), certificate by [SignPath Foundation](https://signpath.org).
|
||||
|
||||
## License
|
||||
|
||||
@@ -1,26 +1,31 @@
|
||||
diff --git a/binding.gyp b/binding.gyp
|
||||
index 855bd4b86f0a3c18c7594212c0e42b6e35bc4001..5f15551cb1520af996f216500a83ac68b57f5104 100644
|
||||
index 855bd4b86f0a3c18c7594212c0e42b6e35bc4001..33774e7ae296f0de39dd94156673c9e773638bf4 100644
|
||||
--- a/binding.gyp
|
||||
+++ b/binding.gyp
|
||||
@@ -3,7 +3,7 @@
|
||||
@@ -3,7 +3,6 @@
|
||||
{
|
||||
"target_name": "windows_process_tree",
|
||||
"dependencies": [
|
||||
- "<!(node -p \"require('node-addon-api').targets\"):node_addon_api_except",
|
||||
+ "<!(node -p \"require.resolve('node-addon-api/node_addon_api.gyp')\"):node_addon_api_except",
|
||||
],
|
||||
"conditions": [
|
||||
['OS=="win"', {
|
||||
@@ -16,9 +16,6 @@
|
||||
@@ -15,14 +14,13 @@
|
||||
"src/process_commandline.cc"
|
||||
],
|
||||
"include_dirs": [],
|
||||
- "include_dirs": [],
|
||||
+ "include_dirs": ["deps/node-addon-api"],
|
||||
+ "defines": ["NAPI_CPP_EXCEPTIONS", "_HAS_EXCEPTIONS=1"],
|
||||
"libraries": [ 'psapi.lib' ],
|
||||
- "msvs_configuration_attributes": {
|
||||
- "SpectreMitigation": "Spectre"
|
||||
- },
|
||||
"msvs_settings": {
|
||||
"VCCLCompilerTool": {
|
||||
+ "ExceptionHandling": 1,
|
||||
"AdditionalOptions": [
|
||||
"/guard:cf",
|
||||
"/sdl",
|
||||
diff --git a/src/process.cc b/src/process.cc
|
||||
index 3eea92077c4d1d433119361d5c432881859131e9..1998f4addd4d7e9aba946ea6f7f7a4a5d13291bc 100644
|
||||
--- a/src/process.cc
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -2,13 +2,21 @@ diff --git a/binding.gyp b/binding.gyp
|
||||
index 5f63978b07ab50aaf7523219a2170ec737a6b5db..bbd9e06136e8922f40b5779e35d4fc835f1479ab 100644
|
||||
--- a/binding.gyp
|
||||
+++ b/binding.gyp
|
||||
@@ -1,13 +1,10 @@
|
||||
@@ -1,13 +1,18 @@
|
||||
{
|
||||
'target_defaults': {
|
||||
'dependencies': [
|
||||
- "<!(node -p \"require('node-addon-api').targets\"):node_addon_api_except",
|
||||
+ "<!(node -p \"require.resolve('node-addon-api/node_addon_api.gyp')\"):node_addon_api_except",
|
||||
],
|
||||
+ # Orca: GCC 9 (Ubuntu 20.04 floor) accepts C++20 as gnu++2a, but rejects
|
||||
+ # the newer gnu++20 spelling emitted by Node 24's gyp flags.
|
||||
+ 'cflags_cc!': [
|
||||
+ '-std=gnu++20'
|
||||
+ ],
|
||||
+ 'cflags_cc': [
|
||||
+ '-std=gnu++2a'
|
||||
+ ],
|
||||
'conditions': [
|
||||
['OS=="win"', {
|
||||
- 'msvs_configuration_attributes': {
|
||||
|
||||
+506
-110
File diff suppressed because one or more lines are too long
@@ -132,12 +132,16 @@ function compileNodePty(dir) {
|
||||
return built
|
||||
}
|
||||
console.log('[orcad-prebuilds] compiling node-pty from patched source ...')
|
||||
const result = spawnSync(process.platform === 'win32' ? 'npx.cmd' : 'npx', ['node-gyp', 'rebuild'], {
|
||||
cwd: dir,
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
windowsHide: true
|
||||
})
|
||||
const result = spawnSync(
|
||||
process.platform === 'win32' ? 'npx.cmd' : 'npx',
|
||||
['node-gyp', 'rebuild'],
|
||||
{
|
||||
cwd: dir,
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
windowsHide: true
|
||||
}
|
||||
)
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`[orcad-prebuilds] node-gyp rebuild failed (status ${result.status})`)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,8 @@ import {
|
||||
slotName
|
||||
} from './build-orcad-prebuilds.mjs'
|
||||
|
||||
const PATCHED_BINDING_GYP = "'ldflags': ['-Wl,--no-as-needed,-l:libutil.so.1,-l:libpthread.so.0,--as-needed']"
|
||||
const PATCHED_BINDING_GYP =
|
||||
"'ldflags': ['-Wl,--no-as-needed,-l:libutil.so.1,-l:libpthread.so.0,--as-needed']"
|
||||
const PATCHED_PTY_CC = '__asm__(".symver openpty,openpty@" ORCA_GLIBC_COMPAT_VERSION);'
|
||||
|
||||
const dirs = []
|
||||
@@ -107,8 +108,9 @@ describe('mergeManifest', () => {
|
||||
|
||||
it('does not duplicate a slot rebuilt twice', () => {
|
||||
const once = mergeManifest(null, { slot: 'darwin-arm64', version: '1.1.0', nodeAbi: '127' })
|
||||
expect(mergeManifest(once, { slot: 'darwin-arm64', version: '1.1.0', nodeAbi: '127' }).slots)
|
||||
.toEqual(['darwin-arm64'])
|
||||
expect(
|
||||
mergeManifest(once, { slot: 'darwin-arm64', version: '1.1.0', nodeAbi: '127' }).slots
|
||||
).toEqual(['darwin-arm64'])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -26,9 +26,11 @@ import {
|
||||
mkdirSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
readSync
|
||||
readSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { createRequire } from 'node:module'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { RELAY_WINDOWS_PROCESS_TREE_FILENAME } from '../../src/shared/relay-artifacts.ts'
|
||||
|
||||
const ROOT = resolve(import.meta.dirname, '..', '..')
|
||||
@@ -67,13 +69,16 @@ function assertPatchApplied() {
|
||||
'config/patches/@vscode__windows-process-tree@0.8.0.patch; run pnpm install.'
|
||||
)
|
||||
}
|
||||
if (!bindingGyp.includes("require.resolve('node-addon-api/node_addon_api.gyp')")) {
|
||||
if (bindingGyp.includes('node_addon_api.gyp')) {
|
||||
throw new Error(
|
||||
'binding.gyp still uses require("node-addon-api").targets. That path is ' +
|
||||
'cwd-relative and misses node_addon_api.gyp under pnpm on Windows. ' +
|
||||
'binding.gyp still depends on node_addon_api.gyp. pnpm and node-gyp rewrite that ' +
|
||||
'project path incorrectly on Windows. ' +
|
||||
'pnpm did not apply config/patches/@vscode__windows-process-tree@0.8.0.patch; run pnpm install.'
|
||||
)
|
||||
}
|
||||
if (!bindingGyp.includes('"include_dirs": ["deps/node-addon-api"]')) {
|
||||
throw new Error('binding.gyp does not use the staged node-addon-api headers.')
|
||||
}
|
||||
const processCc = readFileSync(join(PACKAGE_DIR, 'src', 'process.cc'), 'utf8')
|
||||
if (processCc.includes('process_count < 1024')) {
|
||||
throw new Error(
|
||||
@@ -83,6 +88,58 @@ function assertPatchApplied() {
|
||||
}
|
||||
}
|
||||
|
||||
// pnpm can materialize this CRLF package without applying its patch. Repair the
|
||||
// load-bearing build settings before node-gyp so the release build stays safe.
|
||||
function applyWindowsProcessTreeBuildFixes() {
|
||||
const bindingPath = join(PACKAGE_DIR, 'binding.gyp')
|
||||
const processPath = join(PACKAGE_DIR, 'src', 'process.cc')
|
||||
const nodeAddonApiDir = dirname(
|
||||
createRequire(join(PACKAGE_DIR, 'package.json')).resolve('node-addon-api/package.json')
|
||||
)
|
||||
const stagedHeaderDir = join(PACKAGE_DIR, 'deps', 'node-addon-api')
|
||||
let bindingGyp = readFileSync(bindingPath, 'utf8')
|
||||
let processCc = readFileSync(processPath, 'utf8')
|
||||
const originalBinding = bindingGyp
|
||||
const originalProcess = processCc
|
||||
|
||||
for (const dynamicDependency of [
|
||||
String.raw`<!(node -p \"require('node-addon-api').targets\"):node_addon_api_except`,
|
||||
String.raw`<!(node -p \"require.resolve('node-addon-api/node_addon_api.gyp')\"):node_addon_api_except`,
|
||||
'../../node-addon-api/node_addon_api.gyp:node_addon_api_except'
|
||||
]) {
|
||||
bindingGyp = bindingGyp.replace(`"${dynamicDependency}",`, '')
|
||||
}
|
||||
bindingGyp = bindingGyp.replace(
|
||||
'"include_dirs": []',
|
||||
'"include_dirs": ["deps/node-addon-api"],\n "defines": ["NAPI_CPP_EXCEPTIONS", "_HAS_EXCEPTIONS=1"]'
|
||||
)
|
||||
if (!bindingGyp.includes('"ExceptionHandling": 1')) {
|
||||
bindingGyp = bindingGyp.replace(
|
||||
'"VCCLCompilerTool": {',
|
||||
'"VCCLCompilerTool": {\n "ExceptionHandling": 1,'
|
||||
)
|
||||
}
|
||||
bindingGyp = bindingGyp.replace(
|
||||
/\r?\n\s*"msvs_configuration_attributes": \{\s*"SpectreMitigation": "Spectre"\s*\},?/s,
|
||||
''
|
||||
)
|
||||
processCc = processCc.replace(/process_count < 1024 && /, '')
|
||||
|
||||
if (bindingGyp !== originalBinding) {
|
||||
writeFileSync(bindingPath, bindingGyp)
|
||||
}
|
||||
if (processCc !== originalProcess) {
|
||||
writeFileSync(processPath, processCc)
|
||||
}
|
||||
mkdirSync(stagedHeaderDir, { recursive: true })
|
||||
for (const header of ['napi.h', 'napi-inl.h', 'napi-inl.deprecated.h']) {
|
||||
copyFileSync(join(nodeAddonApiDir, header), join(stagedHeaderDir, header))
|
||||
}
|
||||
if (bindingGyp !== originalBinding || processCc !== originalProcess) {
|
||||
console.warn('[windows-process-tree] Repaired un-applied pnpm patch hunks before build.')
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the PE machine field, so an arm64 request cannot ship an x64 binary. */
|
||||
function readPeMachine(binaryPath) {
|
||||
const fd = openSync(binaryPath, 'r')
|
||||
@@ -109,6 +166,7 @@ function main() {
|
||||
if (!existsSync(PACKAGE_DIR)) {
|
||||
throw new Error(`${PACKAGE_DIR} is missing. Run pnpm install first.`)
|
||||
}
|
||||
applyWindowsProcessTreeBuildFixes()
|
||||
assertPatchApplied()
|
||||
|
||||
console.log(`[windows-process-tree] building ${arch} from ${PACKAGE_DIR}`)
|
||||
|
||||
@@ -378,11 +378,15 @@ function rebuildNodeRuntimeModules(moduleNames) {
|
||||
|
||||
function runPnpm(args, { cwd = projectDir } = {}) {
|
||||
const command = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
|
||||
const env =
|
||||
process.platform === 'linux' && args.includes('node-gyp')
|
||||
? { ...process.env, CXXFLAGS: `${process.env.CXXFLAGS ?? ''} -std=gnu++2a`.trim() }
|
||||
: process.env
|
||||
const result = spawnSync(command, args, {
|
||||
cwd,
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32',
|
||||
env: process.env
|
||||
env
|
||||
})
|
||||
|
||||
if (result.error || result.status !== 0) {
|
||||
|
||||
@@ -29,6 +29,7 @@ describe('ensure-native-runtime', () => {
|
||||
const binDir = join(projectDir, 'bin')
|
||||
copyFileSync(sourceScriptPath, scriptPath)
|
||||
writeFakeNativeModules(projectDir)
|
||||
writeNodePtyPatchFile(projectDir)
|
||||
writeFakePnpm(binDir)
|
||||
|
||||
const result = spawnSync(process.execPath, [scriptPath, '--runtime=node'], {
|
||||
@@ -44,6 +45,9 @@ describe('ensure-native-runtime', () => {
|
||||
const log = readFileSync(logPath, 'utf8')
|
||||
expect(log).toContain('pnpm exec node-gyp rebuild\n')
|
||||
expect(log).toContain(join('node_modules', 'node-pty'))
|
||||
if (process.platform === 'linux') {
|
||||
expect(log).toMatch(/^cxxflags=(?:.*\s)?-std=gnu\+\+2a$/m)
|
||||
}
|
||||
expect(log.split('\n').filter((line) => line.startsWith('node-pty child '))).toEqual([
|
||||
expect.stringMatching(/^node-pty child (?:conpty|pty) marker=false$/),
|
||||
expect.stringMatching(/^node-pty child (?:conpty|pty) marker=true$/)
|
||||
@@ -239,6 +243,14 @@ exports.loadNativeModule = function loadNativeModule(nativeName) {
|
||||
if (!markerExists) {
|
||||
throw new Error('ABI mismatch sentinel')
|
||||
}
|
||||
return {
|
||||
dir: '../build/Release/',
|
||||
module: {
|
||||
listJobProcessIds() {},
|
||||
terminateJob() {},
|
||||
assignCurrentProcessToJob() {}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
)
|
||||
@@ -337,6 +349,10 @@ appendFileSync(
|
||||
process.env.ORCA_NATIVE_TEST_LOG,
|
||||
\`npm_config_build_from_source=\${process.env.npm_config_build_from_source || ''}\\n\`
|
||||
)
|
||||
appendFileSync(
|
||||
process.env.ORCA_NATIVE_TEST_LOG,
|
||||
\`cxxflags=\${process.env.CXXFLAGS || ''}\\n\`
|
||||
)
|
||||
writeFileSync(process.env.ORCA_NATIVE_TEST_MARKER, 'rebuilt')
|
||||
`
|
||||
)
|
||||
|
||||
@@ -47,6 +47,33 @@ function executeInstallScript(fixture) {
|
||||
}
|
||||
|
||||
describe('install-node-dependencies action', () => {
|
||||
it('skips the lockfile diff when a job container has no Git metadata', () => {
|
||||
const fixture = createFixture()
|
||||
try {
|
||||
rmSync(join(fixture.workspace, '.git'), { recursive: true, force: true })
|
||||
|
||||
const result = executeInstallScript(fixture)
|
||||
expect(result.status, result.stderr || result.stdout).toBe(0)
|
||||
expect(`${result.stdout}\n${result.stderr}`).not.toMatch(/^diff --git /m)
|
||||
} finally {
|
||||
rmSync(fixture.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('skips the lockfile diff for a bare repository', () => {
|
||||
const fixture = createFixture()
|
||||
try {
|
||||
rmSync(join(fixture.workspace, '.git'), { recursive: true, force: true })
|
||||
expect(run('git', ['init', '--bare', '-q'], { cwd: fixture.workspace }).status).toBe(0)
|
||||
|
||||
const result = executeInstallScript(fixture)
|
||||
expect(result.status, result.stderr || result.stdout).toBe(0)
|
||||
expect(`${result.stdout}\n${result.stderr}`).not.toMatch(/^diff --git /m)
|
||||
} finally {
|
||||
rmSync(fixture.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
['package.json', '{"name":"changed"}\n'],
|
||||
['pnpm-lock.yaml', 'lockfileVersion: 9\nchanged: true\n']
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
'use strict'
|
||||
|
||||
const NODE_PTY_JOB_EXPORTS = [
|
||||
'listJobProcessIds',
|
||||
'terminateJob',
|
||||
'assignCurrentProcessToJob'
|
||||
]
|
||||
const NODE_PTY_JOB_EXPORTS = ['listJobProcessIds', 'terminateJob', 'assignCurrentProcessToJob']
|
||||
|
||||
function assertNodePtyJobOwnership({ nativeName, native, platform = process.platform }) {
|
||||
if (platform !== 'win32' || nativeName !== 'conpty') {
|
||||
|
||||
@@ -17,11 +17,11 @@ describe('Electron runtime package contract', () => {
|
||||
'utf8'
|
||||
)
|
||||
|
||||
expect(patch).toContain('diff --git a/src/Types.ts b/src/Types.ts')
|
||||
expect(patch).toContain('readonly clearModelGeneration: number')
|
||||
expect(patch).toContain('const generation = this._atlas.clearModelGeneration')
|
||||
expect(patch).toContain('this.clearModelGeneration++')
|
||||
expect(patch).toContain('this._atlas._clearModelGeneration||0')
|
||||
expect(patch.match(/\^\(\?:\[1-8\]\\d\{2\}\|900\)\$/g)).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('keeps root postinstall as the single Electron binary install owner', () => {
|
||||
@@ -467,10 +467,11 @@ describe('Electron runtime package contract', () => {
|
||||
expect(copyStep.run).toContain('git add "$CASK_PATH"')
|
||||
})
|
||||
|
||||
it('installs the Electron package binary in PR checks without changing native module ABI', () => {
|
||||
const prWorkflow = readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8')
|
||||
const parsedWorkflow = parse(prWorkflow)
|
||||
const installStep = parsedWorkflow.jobs.test.steps.find(
|
||||
it('installs the Electron package binary in the shared unit-test workflow', () => {
|
||||
const unitTestWorkflow = parse(
|
||||
readFileSync(join(projectDir, '.github/workflows/unit-tests.yml'), 'utf8')
|
||||
)
|
||||
const installStep = unitTestWorkflow.jobs.test.steps.find(
|
||||
(step) => step.name === 'Install Electron package binary for tests'
|
||||
)
|
||||
|
||||
|
||||
@@ -281,7 +281,11 @@ describe('PR Checks skip wiring', () => {
|
||||
expect(prWorkflow.jobs.test_native_cache.if).toBe(
|
||||
"needs.code_paths.outputs.native_cache_changed == 'true'"
|
||||
)
|
||||
expect(prWorkflow.jobs.test_native_cache.strategy.matrix.node).toEqual(['24', '26'])
|
||||
expect(prWorkflow.jobs.test_native_cache.strategy).toBeUndefined()
|
||||
const primerInstall = prWorkflow.jobs.test_native_cache.steps.find(
|
||||
(step) => step.uses === './.github/actions/install-node-dependencies'
|
||||
)
|
||||
expect(primerInstall.with['node-version']).toBe('24')
|
||||
})
|
||||
|
||||
it('skips e2e detection on docs-only PRs without dropping the draft gate', () => {
|
||||
|
||||
@@ -3,6 +3,8 @@ import { parse } from 'yaml'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8'))
|
||||
const unitTestWorkflow = parse(readFileSync('.github/workflows/unit-tests.yml', 'utf8'))
|
||||
const nodeNextWorkflow = parse(readFileSync('.github/workflows/node-next-compat.yml', 'utf8'))
|
||||
const dependencyAction = parse(
|
||||
readFileSync('.github/actions/install-node-dependencies/action.yml', 'utf8')
|
||||
)
|
||||
@@ -47,28 +49,34 @@ describe('PR workflow parallelism', () => {
|
||||
expect(workflow.permissions).toEqual({ contents: 'read' })
|
||||
})
|
||||
|
||||
it('shards the general test suite across Node 24 and Node 26', () => {
|
||||
expect(workflow.jobs.test.strategy.matrix.node).toEqual(['24', '26'])
|
||||
expect(workflow.jobs.test.strategy.matrix.shard).toEqual(
|
||||
Array.from({ length: 8 }, (_, index) => index + 1)
|
||||
it('runs Node 24 on PRs and the same eight-shard suite on Node 26 daily', () => {
|
||||
const sharedTest = unitTestWorkflow.jobs.test
|
||||
const testStep = sharedTest.steps.find((step) => step.name === 'Test shard')
|
||||
const installStep = sharedTest.steps.find(
|
||||
(step) => step.uses === './.github/actions/install-node-dependencies'
|
||||
)
|
||||
expect(workflow.jobs.test.strategy.matrix.shard_total).toEqual([8])
|
||||
const testStep = workflow.jobs.test.steps.find((step) => step.name === 'Test shard')
|
||||
const installStep = workflow.jobs.test.steps.find(
|
||||
const primerInstall = workflow.jobs.test_native_cache.steps.find(
|
||||
(step) => step.uses === './.github/actions/install-node-dependencies'
|
||||
)
|
||||
|
||||
expect(workflow.jobs.test.uses).toBe('./.github/workflows/unit-tests.yml')
|
||||
expect(JSON.parse(workflow.jobs.test.with.node_versions)).toEqual(['24'])
|
||||
expect(nodeNextWorkflow.jobs.test.uses).toBe('./.github/workflows/unit-tests.yml')
|
||||
expect(JSON.parse(nodeNextWorkflow.jobs.test.with.node_versions)).toEqual(['26'])
|
||||
expect(nodeNextWorkflow.on.schedule).toHaveLength(1)
|
||||
expect(nodeNextWorkflow.on.workflow_dispatch).toBeNull()
|
||||
expect(sharedTest.strategy.matrix.node).toBe('${{ fromJSON(inputs.node_versions) }}')
|
||||
expect(sharedTest.strategy.matrix.shard).toEqual(
|
||||
Array.from({ length: 8 }, (_, index) => index + 1)
|
||||
)
|
||||
expect(sharedTest.strategy.matrix.shard_total).toEqual([8])
|
||||
expect(installStep.with['node-version']).toBe('${{ matrix.node }}')
|
||||
expect(testStep.run).toContain('--shard=${{ matrix.shard }}/${{ matrix.shard_total }}')
|
||||
for (const testFile of nativeShellContractFiles) {
|
||||
expect(testStep.run).toContain(`--exclude=${testFile}`)
|
||||
}
|
||||
const primerInstall = workflow.jobs.test_native_cache.steps.find(
|
||||
(step) => step.uses === './.github/actions/install-node-dependencies'
|
||||
)
|
||||
expect(workflow.jobs.test_native_cache.strategy.matrix.node).toEqual(['24', '26'])
|
||||
expect(primerInstall.with['native-runtime']).toBe('node')
|
||||
expect(primerInstall.with['node-version']).toBe('${{ matrix.node }}')
|
||||
expect(primerInstall.with['node-version']).toBe('24')
|
||||
expect(workflow.jobs.test.needs).toContain('test_native_cache')
|
||||
})
|
||||
|
||||
@@ -241,13 +249,16 @@ describe('PR workflow parallelism', () => {
|
||||
workflow.jobs[jobName].steps.find(
|
||||
(step) => step.uses === './.github/actions/install-node-dependencies'
|
||||
)
|
||||
const sharedTestInstall = unitTestWorkflow.jobs.test.steps.find(
|
||||
(step) => step.uses === './.github/actions/install-node-dependencies'
|
||||
)
|
||||
|
||||
for (const jobName of ['typecheck', 'git_compatibility', 'xterm_patch_sync']) {
|
||||
expect(installFor(jobName).with, jobName).toBeUndefined()
|
||||
}
|
||||
expect(installFor('static_analysis').with['native-runtime']).toBe('node')
|
||||
expect(installFor('shell_contracts').with['native-runtime']).toBe('node')
|
||||
expect(installFor('test').with['native-runtime']).toBe('node')
|
||||
expect(sharedTestInstall.with['native-runtime']).toBe('node')
|
||||
expect(installFor('package').with['native-runtime']).toBe('electron')
|
||||
expect(installFor('package_windows').with['native-runtime']).toBe('node')
|
||||
expect(installFor('package_windows').with['persist-native-cache']).toBe('false')
|
||||
|
||||
@@ -434,6 +434,20 @@ describe('check-mode reporting', () => {
|
||||
// These run without network or a build, so ordinary `pnpm test` catches the two
|
||||
// desyncs that would otherwise only surface in the heavy xterm_patch_sync job.
|
||||
describe('committed xterm patch artifacts', () => {
|
||||
it('normalizes Chromium font-weight serialization in every WebGL runtime copy', async () => {
|
||||
const patch = await readFile(
|
||||
path.join(REPO_ROOT, 'config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch'),
|
||||
'utf8'
|
||||
)
|
||||
expect(patch).not.toMatch(/desiredWeight !== '400'|orcaProbeWeight!=="400"/)
|
||||
expect(
|
||||
patch.match(/(?:actualWeightToken === 'normal'|orcaActualWeightToken==="normal")/g)
|
||||
).toHaveLength(3)
|
||||
expect(
|
||||
patch.match(/(?:actualWeight !== desiredWeight|orcaActualWeight!==orcaProbeWeight)/g)
|
||||
).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('records the lockfile hash pnpm derives from the patch file', async () => {
|
||||
const manifest = JSON.parse(await readFile(MANIFEST_PATH, 'utf8'))
|
||||
const lockfile = await readFile(path.join(REPO_ROOT, 'pnpm-lock.yaml'), 'utf8')
|
||||
|
||||
@@ -10,6 +10,17 @@ const releaseWorkflow = parse(
|
||||
const e2eWorkflow = parse(readFileSync(join(projectDir, '.github/workflows/e2e.yml'), 'utf8'))
|
||||
|
||||
describe('release E2E dispatch contract', () => {
|
||||
it('validates immutable tags with the current golden test harness', () => {
|
||||
const restoreStep = releaseWorkflow.jobs['terminal-rendering-golden'].steps.find(
|
||||
(step) => step.name === 'Restore golden test harness from the workflow ref'
|
||||
)
|
||||
|
||||
expect(restoreStep.env.WORKFLOW_SHA).toBe('${{ github.workflow_sha }}')
|
||||
expect(restoreStep.run).toContain('git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA"')
|
||||
expect(restoreStep.run).toContain('golden-source-control-open-diff.spec.ts')
|
||||
expect(restoreStep.run).toContain('golden-terminal-file-link.spec.ts')
|
||||
})
|
||||
|
||||
it('dispatches tag-scoped E2E only after publication', () => {
|
||||
const dispatchJob = releaseWorkflow.jobs['post-release-e2e']
|
||||
const dispatchStep = dispatchJob.steps.find((step) => step.name === 'Dispatch tag-scoped E2E')
|
||||
|
||||
@@ -44,6 +44,48 @@ describe('skill-sharing release workflow', () => {
|
||||
expect(command).toContain('src/shared/skill-bundle-install-contract.test.ts')
|
||||
})
|
||||
|
||||
it('installs the archive tool required by Electron on the Linux floor', () => {
|
||||
const linux = workflow.jobs['skill-sharing-linux-floor-release-gate']
|
||||
const prerequisites = stepNamed(linux, 'Install Ubuntu 20.04 prerequisites')
|
||||
|
||||
expect(prerequisites.run).toMatch(/apt-get install[^\n]*\bunzip\b/)
|
||||
})
|
||||
|
||||
it('trusts only the checked-out workspace before container git operations', () => {
|
||||
const linux = workflow.jobs['skill-sharing-linux-floor-release-gate']
|
||||
const trustWorkspace = stepNamed(linux, 'Trust the checked-out workspace in the job container')
|
||||
const restoreHarness = stepNamed(
|
||||
linux,
|
||||
'Restore skill-sharing test harness from the workflow ref'
|
||||
)
|
||||
const safeDirectoryCommands = linux.steps
|
||||
.filter((step) => typeof step.run === 'string' && step.run.includes('safe.directory'))
|
||||
.map((step) => step.run)
|
||||
|
||||
expect(trustWorkspace.run).toBe('git config --global --add safe.directory "$GITHUB_WORKSPACE"')
|
||||
expect(linux.steps.indexOf(trustWorkspace)).toBeLessThan(linux.steps.indexOf(restoreHarness))
|
||||
expect(safeDirectoryCommands).toEqual([
|
||||
'git config --global --add safe.directory "$GITHUB_WORKSPACE"'
|
||||
])
|
||||
})
|
||||
|
||||
it('validates immutable tags with the current skill-sharing test harness', () => {
|
||||
for (const jobName of [
|
||||
'skill-sharing-release-gate',
|
||||
'skill-sharing-linux-floor-release-gate'
|
||||
]) {
|
||||
const restore = stepNamed(
|
||||
workflow.jobs[jobName],
|
||||
'Restore skill-sharing test harness from the workflow ref'
|
||||
)
|
||||
|
||||
expect(restore.env.WORKFLOW_SHA).toBe('${{ github.workflow_sha }}')
|
||||
expect(restore.run).toContain('git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA"')
|
||||
expect(restore.run).toContain('skill-freshness-inventory.test.ts')
|
||||
expect(restore.run).toContain('skill-provider-runtime-roots.test.ts')
|
||||
}
|
||||
})
|
||||
|
||||
it('archives bounded machine-readable evidence from every platform', () => {
|
||||
for (const jobName of [
|
||||
'skill-sharing-release-gate',
|
||||
|
||||
@@ -9,26 +9,30 @@ const PATCH = readFileSync(
|
||||
'utf8'
|
||||
)
|
||||
const PACKAGE_DIR = join(projectDir, 'node_modules', '@vscode', 'windows-process-tree')
|
||||
const ABSOLUTE_GYP = "require.resolve('node-addon-api/node_addon_api.gyp')"
|
||||
const RESOLVED_GYP = "require.resolve('node-addon-api/node_addon_api.gyp')"
|
||||
|
||||
describe('windows-process-tree node-addon-api gyp path', () => {
|
||||
it('keeps the gyp project path absolute so pnpm Windows source builds find it', () => {
|
||||
expect(PATCH).toContain(
|
||||
`+ "<!(node -p \\"require.resolve('node-addon-api/node_addon_api.gyp')\\"):node_addon_api_except"`
|
||||
it('stages headers without a pnpm-sensitive gyp dependency', () => {
|
||||
expect(PATCH).not.toContain('+ "../../node-addon-api')
|
||||
expect(PATCH).toContain('+ "include_dirs": ["deps/node-addon-api"],')
|
||||
expect(PATCH).toContain('+ "defines": ["NAPI_CPP_EXCEPTIONS", "_HAS_EXCEPTIONS=1"],')
|
||||
const buildScript = readFileSync(
|
||||
join(projectDir, 'config/scripts/build-windows-process-tree-relay-addon.mjs'),
|
||||
'utf8'
|
||||
)
|
||||
const bindingGyp = readFileSync(join(PACKAGE_DIR, 'binding.gyp'), 'utf8')
|
||||
expect(bindingGyp).toContain(ABSOLUTE_GYP)
|
||||
expect(bindingGyp).not.toContain("require('node-addon-api').targets")
|
||||
expect(
|
||||
readFileSync(
|
||||
join(projectDir, 'config/scripts/build-windows-process-tree-relay-addon.mjs'),
|
||||
'utf8'
|
||||
)
|
||||
).toContain(ABSOLUTE_GYP)
|
||||
expect(buildScript).toContain(
|
||||
"for (const header of ['napi.h', 'napi-inl.h', 'napi-inl.deprecated.h'])"
|
||||
)
|
||||
expect(buildScript).toContain("import { createRequire } from 'node:module'")
|
||||
expect(buildScript).toContain("import { dirname, join, resolve } from 'node:path'")
|
||||
expect(buildScript).toContain(
|
||||
"createRequire(join(PACKAGE_DIR, 'package.json')).resolve('node-addon-api/package.json')"
|
||||
)
|
||||
expect(buildScript).toContain('Repaired un-applied pnpm patch hunks before build.')
|
||||
})
|
||||
|
||||
it('resolves node_addon_api.gyp to a real file from the package directory', () => {
|
||||
const resolved = execFileSync(process.execPath, ['-p', ABSOLUTE_GYP], {
|
||||
const resolved = execFileSync(process.execPath, ['-p', RESOLVED_GYP], {
|
||||
cwd: PACKAGE_DIR,
|
||||
encoding: 'utf8'
|
||||
}).trim()
|
||||
|
||||
+14
-14
@@ -137,20 +137,20 @@ Browse `src/renderer/src/components/ui/` for the full list. Most wrap a Radix UI
|
||||
|
||||
When a control has multiple plausible primitives, use this fork:
|
||||
|
||||
| You want… | Reach for | Don't use |
|
||||
| ------------------------------------------------------------ | -------------------------------------------------------------------- | ------------------------------------- |
|
||||
| Hover-only label on an icon-only button | `Tooltip` | `HoverCard` (too heavy), title attr |
|
||||
| Hover preview of richer content (avatar + summary) | `HoverCard` | `Tooltip` (no rich content) |
|
||||
| Click-revealed menu with actions | `DropdownMenu` | `Popover` with hand-rolled list |
|
||||
| Right-click contextual actions | `ContextMenu` | `DropdownMenu` (different invocation) |
|
||||
| Click-revealed surface with arbitrary content (form, picker) | `Popover` | `Dialog` (it traps focus and dims) |
|
||||
| Modal that demands a decision before you continue | `Dialog` | `Popover`, inline overlay |
|
||||
| Drawer / panel sliding in from an edge | `Sheet` | `Dialog` centered |
|
||||
| Single choice from a known list | `Select` | Custom listbox |
|
||||
| Single choice with search / fuzzy filtering | `Command` inside `Popover` | `Select` (no search) |
|
||||
| Multi-select with search | `repo-multi-combobox` (mirror its pattern) | Roll a new one |
|
||||
| Transient confirmation ("Saved", "Copied") | `sonner` toast | `Dialog`, inline banner |
|
||||
| Persistent inline status ("3 errors") | inline text + `Badge` | toast (toasts disappear) |
|
||||
| You want… | Reach for | Don't use |
|
||||
| ------------------------------------------------------------ | ------------------------------------------ | ------------------------------------- |
|
||||
| Hover-only label on an icon-only button | `Tooltip` | `HoverCard` (too heavy), title attr |
|
||||
| Hover preview of richer content (avatar + summary) | `HoverCard` | `Tooltip` (no rich content) |
|
||||
| Click-revealed menu with actions | `DropdownMenu` | `Popover` with hand-rolled list |
|
||||
| Right-click contextual actions | `ContextMenu` | `DropdownMenu` (different invocation) |
|
||||
| Click-revealed surface with arbitrary content (form, picker) | `Popover` | `Dialog` (it traps focus and dims) |
|
||||
| Modal that demands a decision before you continue | `Dialog` | `Popover`, inline overlay |
|
||||
| Drawer / panel sliding in from an edge | `Sheet` | `Dialog` centered |
|
||||
| Single choice from a known list | `Select` | Custom listbox |
|
||||
| Single choice with search / fuzzy filtering | `Command` inside `Popover` | `Select` (no search) |
|
||||
| Multi-select with search | `repo-multi-combobox` (mirror its pattern) | Roll a new one |
|
||||
| Transient confirmation ("Saved", "Copied") | `sonner` toast | `Dialog`, inline banner |
|
||||
| Persistent inline status ("3 errors") | inline text + `Badge` | toast (toasts disappear) |
|
||||
|
||||
If you find yourself styling around a primitive (`<Popover>` to act like a `<Dialog>`, or vice versa), stop and reconsider — the focus-management semantics differ and a future contributor will be misled by the mismatch.
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 31m">
|
||||
<title>downloads: 31m</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 32m">
|
||||
<title>downloads: 32m</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
@@ -15,7 +15,7 @@
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
|
||||
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
|
||||
<text x="37" y="14">downloads</text>
|
||||
<text x="90" y="15" fill="#010101" fill-opacity=".3">31m</text>
|
||||
<text x="90" y="14">31m</text>
|
||||
<text x="90" y="15" fill="#010101" fill-opacity=".3">32m</text>
|
||||
<text x="90" y="14">32m</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 935 B After Width: | Height: | Size: 935 B |
@@ -17,23 +17,59 @@ differ on other Debian-derived releases.
|
||||
|
||||
## Ubuntu and Debian prerequisites
|
||||
|
||||
Install the AppImage runtime dependency and Xvfb:
|
||||
Install the CLI tools, Xvfb, and the shared libraries Electron links against.
|
||||
A minimal server or container image ships none of the Electron libraries, and
|
||||
`orca serve` then fails before Electron starts:
|
||||
|
||||
```bash
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y curl file jq xvfb zlib1g-dev
|
||||
sudo apt-get install -y \
|
||||
curl file jq xvfb zlib1g-dev ca-certificates git \
|
||||
libgtk-3-0t64 libnss3 libatk1.0-0t64 libatk-bridge2.0-0t64 libgbm1 libasound2t64 \
|
||||
libxtst6 libcups2t64 libdrm2 libxkbcommon0 libpango-1.0-0 libcairo2 libatspi2.0-0t64 \
|
||||
libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libxrender1 libx11-xcb1 \
|
||||
libxcb-dri3-0 libxss1
|
||||
```
|
||||
|
||||
On Ubuntu 22.04, install `libfuse2` to execute the AppImage through FUSE. On
|
||||
Ubuntu 24.04 and Debian, the equivalent package may be `libfuse2t64`. FUSE is
|
||||
That command is for Ubuntu 24.04 and newer and Debian 13 and newer. Those
|
||||
releases carried out the 64-bit `time_t` transition, which renamed six of the
|
||||
packages with a `t64` suffix. On Ubuntu 20.04, Ubuntu 22.04, and Debian 12,
|
||||
substitute the unsuffixed names:
|
||||
|
||||
- `libgtk-3-0t64` becomes `libgtk-3-0`
|
||||
- `libatk1.0-0t64` becomes `libatk1.0-0`
|
||||
- `libatk-bridge2.0-0t64` becomes `libatk-bridge2.0-0`
|
||||
- `libasound2t64` becomes `libasound2`
|
||||
- `libcups2t64` becomes `libcups2`
|
||||
- `libatspi2.0-0t64` becomes `libatspi2.0-0`
|
||||
|
||||
The other names are identical on every supported release. The substitution is not
|
||||
symmetric, so use the list that matches the release. A `t64` name on Ubuntu 20.04,
|
||||
Ubuntu 22.04, or Debian 12 fails immediately with `E: Unable to locate package
|
||||
libgtk-3-0t64`. In the other direction the old names mostly still resolve, because
|
||||
each renamed package declares `Provides:` its unsuffixed name — except `libasound2`
|
||||
on Ubuntu 24.04, where `liboss4-salsa-asound2` in `universe` claims that name too.
|
||||
apt will not choose between two providers and exits with `E: Package 'libasound2'
|
||||
has no installation candidate`, which aborts the entire install line and leaves none
|
||||
of the libraries installed.
|
||||
|
||||
On Ubuntu 20.04 and 22.04, install `libfuse2` to execute the AppImage through
|
||||
FUSE. On Ubuntu 24.04 and Debian 13 the package is `libfuse2t64`, though the plain
|
||||
`libfuse2` name also resolves there because nothing else provides it. FUSE is
|
||||
optional: without it, use the AppImage's supported extraction path:
|
||||
|
||||
```bash
|
||||
cd /opt/orca
|
||||
./orca-linux.AppImage --appimage-extract
|
||||
sudo chmod -R a+rX /opt/orca/squashfs-root
|
||||
/opt/orca/squashfs-root/AppRun serve --port 6768
|
||||
```
|
||||
|
||||
The `chmod` is required whenever the extraction runs as a different user than
|
||||
the server: `--appimage-extract` creates `squashfs-root` as `drwx------` owned by
|
||||
the extracting user, so anyone else — including a dedicated service user — cannot
|
||||
even traverse it, and the run fails before Electron starts.
|
||||
|
||||
Docker commonly has no FUSE device. Use `--appimage-extract` once or
|
||||
`--appimage-extract-and-run`; neither requires a privileged container. The
|
||||
extract-and-run wrapper can print extracted paths before Orca starts, so
|
||||
@@ -144,8 +180,15 @@ AppImage, but must not be able to replace it or the rollback artifacts.
|
||||
sudo useradd --system --create-home --shell /usr/sbin/nologin orca
|
||||
sudo chown root:root /opt/orca /opt/orca/orca-linux.AppImage
|
||||
sudo chmod 755 /opt/orca /opt/orca/orca-linux.AppImage
|
||||
# Only if you ran --appimage-extract: extraction leaves squashfs-root root-only.
|
||||
sudo chmod -R a+rX /opt/orca/squashfs-root
|
||||
```
|
||||
|
||||
The last line matters because the two halves of this guide combine badly without
|
||||
it. `--appimage-extract` writes `squashfs-root` as `drwx------ root root`, so the
|
||||
`orca` service user cannot read or traverse the extracted tree and the unit fails
|
||||
at startup. `chmod 755 /opt/orca` alone does not reach into it.
|
||||
|
||||
For most hosts, one `orca serve` service is enough because Orca starts Xvfb on
|
||||
display `:99` when no display exists:
|
||||
|
||||
@@ -834,11 +877,12 @@ refuse to run there and print the command to run on the machine you want.
|
||||
- GPU or DRI warnings on a VPS: keep `LIBGL_ALWAYS_SOFTWARE=1` in the service
|
||||
environment.
|
||||
- Chromium sandbox errors: confirm the service is running as the non-root
|
||||
`orca` user and that `/opt/orca` is readable by that user.
|
||||
`orca` user and that `/opt/orca` is readable by that user, including
|
||||
`/opt/orca/squashfs-root` if you extracted the AppImage.
|
||||
- Clients cannot connect: make sure `--pairing-address` is an address reachable
|
||||
from the client, and make sure firewalls allow the selected `--port`.
|
||||
- Journal shows `Another Orca instance is already running for this userData
|
||||
profile` and the unit exits `3`: another process already owns the profile, so
|
||||
profile` and the unit exits `3`: another process already owns the profile, so
|
||||
`RestartPreventExitStatus=3` leaves the unit `failed` on purpose. Find the
|
||||
owner with `systemctl status orca-serve` and `pgrep -af orca`. Stop it (or
|
||||
keep it and leave the unit down), then run
|
||||
@@ -857,4 +901,7 @@ refuse to run there and print the command to run on the machine you want.
|
||||
`sudo systemctl reset-failed orca-serve.service` first.
|
||||
- Diagnosing other missing libraries: extract the AppImage without launching it
|
||||
with `./orca-linux.AppImage --appimage-extract`, then run
|
||||
`ldd squashfs-root/orca` to list any shared libraries the host is missing.
|
||||
`ldd squashfs-root/orca-ide` to list any shared libraries the host is missing.
|
||||
The Electron binary is `orca-ide`, not `orca`; `ldd` on a path that does not
|
||||
exist prints nothing and exits cleanly, which reads as a clean result in
|
||||
exactly the situation where you are hunting a missing library.
|
||||
|
||||
@@ -25,11 +25,11 @@ broke launch on Ubuntu 20.04 ([#9902](https://github.com/stablyai/orca/issues/99
|
||||
The specific trap is glibc's 2.32–2.34 "libpthread/libutil merge", which moved
|
||||
several long-stable functions into libc under brand-new symbol versions:
|
||||
|
||||
| Symbol | New version | node-pty use |
|
||||
| ----------------- | ------------- | ----------------------- |
|
||||
| `pthread_sigmask` | `GLIBC_2.32` | reset child signal mask |
|
||||
| `openpty` | `GLIBC_2.34` | allocate the pty |
|
||||
| `forkpty` | `GLIBC_2.34` | fork the shell |
|
||||
| Symbol | New version | node-pty use |
|
||||
| ----------------- | ------------ | ----------------------- |
|
||||
| `pthread_sigmask` | `GLIBC_2.32` | reset child signal mask |
|
||||
| `openpty` | `GLIBC_2.34` | allocate the pty |
|
||||
| `forkpty` | `GLIBC_2.34` | fork the shell |
|
||||
|
||||
Electron itself (glibc 2.25) and the other bundled native modules
|
||||
(`sherpa-onnx`, `@parcel/watcher`, both prebuilt on old glibc) stay well under
|
||||
|
||||
@@ -10,12 +10,12 @@ Design background: `docs/design/shipping-orcad.html` §00c and §04.
|
||||
|
||||
A deployment is **orcad** plus **the terminal daemon**.
|
||||
|
||||
| | orcad | terminal daemon |
|
||||
| --- | --- | --- |
|
||||
| Started by | the supervisor | orcad, detached |
|
||||
| Owns | RPC, git, worktrees, persistence | every local PTY |
|
||||
| Lifetime | one supervised run | **outlives orcad** |
|
||||
| Endpoint | `ws://<bind>:<port>` | `<data-root>/daemon/daemon-v<N>.sock` |
|
||||
| | orcad | terminal daemon |
|
||||
| ---------- | -------------------------------- | ------------------------------------- |
|
||||
| Started by | the supervisor | orcad, detached |
|
||||
| Owns | RPC, git, worktrees, persistence | every local PTY |
|
||||
| Lifetime | one supervised run | **outlives orcad** |
|
||||
| Endpoint | `ws://<bind>:<port>` | `<data-root>/daemon/daemon-v<N>.sock` |
|
||||
|
||||
The daemon outliving orcad is the property the whole peer model is recommended for
|
||||
(`docs/reference/ssh-execution-boundary.md`): daemon-backed PTYs stay `live` across a runtime
|
||||
@@ -52,13 +52,13 @@ The data root is `$ORCA_USER_DATA`, else `$XDG_DATA_HOME/Orca`, else `~/.orca`.
|
||||
Before the profile index or the store is touched, orcad takes `<data-root>/orcad.lock`.
|
||||
It refuses to start when:
|
||||
|
||||
| Code | Meaning |
|
||||
| --- | --- |
|
||||
| `orcad_data_root_wrong_owner` | the root is owned by another uid (POSIX) |
|
||||
| `orcad_data_root_shared` | the root is group/world accessible and could not be tightened |
|
||||
| `orcad_instance_lock_held` | another live orcad owns this root |
|
||||
| `orcad_instance_lock_foreign_identity` | the lock belongs to a different identity |
|
||||
| `orcad_data_root_unusable` | the root cannot be created, stat'd or written |
|
||||
| Code | Meaning |
|
||||
| -------------------------------------- | ------------------------------------------------------------- |
|
||||
| `orcad_data_root_wrong_owner` | the root is owned by another uid (POSIX) |
|
||||
| `orcad_data_root_shared` | the root is group/world accessible and could not be tightened |
|
||||
| `orcad_instance_lock_held` | another live orcad owns this root |
|
||||
| `orcad_instance_lock_foreign_identity` | the lock belongs to a different identity |
|
||||
| `orcad_data_root_unusable` | the root cannot be created, stat'd or written |
|
||||
|
||||
A root that is merely too permissive and that we own is tightened to `0700` rather than
|
||||
refused — orcad stores credentials there unsealed (no OS keyring on this host), so the goal
|
||||
@@ -91,14 +91,15 @@ An external supervisor (systemd, launchd, a process manager). orcad conforms to
|
||||
and exits 1, so the failure stays attributable instead of arriving as an unlogged kill.
|
||||
- **Exit codes.**
|
||||
|
||||
| Code | Meaning | Supervisor should |
|
||||
| --- | --- | --- |
|
||||
| 0 | clean shutdown | restart per policy |
|
||||
| 1 | startup or shutdown failure | restart with backoff |
|
||||
| 78 | configuration fault (bind address, data root, instance lock) | **not** restart |
|
||||
| Code | Meaning | Supervisor should |
|
||||
| ---- | ------------------------------------------------------------ | -------------------- |
|
||||
| 0 | clean shutdown | restart per policy |
|
||||
| 1 | startup or shutdown failure | restart with backoff |
|
||||
| 78 | configuration fault (bind address, data root, instance lock) | **not** restart |
|
||||
|
||||
78 is `EX_CONFIG`. Put it in systemd's `RestartPreventExitStatus`: restarting on a data
|
||||
root owned by someone else is a restart-spin, not a recovery.
|
||||
|
||||
- **Logs.** orcad writes human-readable diagnostics to **stderr** and its readiness contract
|
||||
to **stdout**; the supervisor owns capture and rotation. The daemon, being detached, writes
|
||||
its own NDJSON lifecycle log to `<data-root>/logs/daemon.log` (suppressed by
|
||||
@@ -110,7 +111,7 @@ An external supervisor (systemd, launchd, a process manager). orcad conforms to
|
||||
- **Launch.** Forked detached from `daemon-entry.js` beside `orcad.js`, with its own PID
|
||||
record, token and socket under `<data-root>/daemon`.
|
||||
- **Adoption before spawn.** A daemon already answering the endpoint is adopted, not
|
||||
replaced, unless it is unhealthy, foreign, or built from a superseded bundle *and* owns no
|
||||
replaced, unless it is unhealthy, foreign, or built from a superseded bundle _and_ owns no
|
||||
live sessions. Replacing a healthy daemon kills its PTYs, so code freshness always defers
|
||||
to live work.
|
||||
- **Restart.** The adapter respawns the daemon on death, transparently to callers.
|
||||
|
||||
@@ -113,7 +113,7 @@ getting that wrong turns a skew into a false "nothing is blocked".
|
||||
unverifiable, the pane was unreadable, or the agent probe did not answer in time.
|
||||
|
||||
A new client against an old host sees the field absent, which is why absence must read as
|
||||
*unknown* and never as *not waiting*. Collapsing absent into `null` at any hop — including a
|
||||
_unknown_ and never as _not waiting_. Collapsing absent into `null` at any hop — including a
|
||||
convenience `?? null` in an RPC handler — makes an old or unreachable peer indistinguishable
|
||||
from a healthy idle worker, which is the exact failure the field exists to remove.
|
||||
|
||||
@@ -180,7 +180,7 @@ has to be there before the first snapshot is interpreted, which is earlier than
|
||||
renderer could wait on. Every other viewer — a second desktop, the web client, which installs no
|
||||
page renderer at all, the dashboard pop-out, which is deliberately left unstamped — keeps tracking
|
||||
the host, which is the only reason a mirrored viewer shows anything but its first snapshot
|
||||
forever. Improving what a *second* client sees still means fixing the publish, not the carve-out;
|
||||
forever. Improving what a _second_ client sees still means fixing the publish, not the carve-out;
|
||||
the carve-out no longer stands in the way of it.
|
||||
|
||||
The two failure fields above are deliberately left on the looser `placement?.kind !== 'client'`
|
||||
|
||||
@@ -28,11 +28,11 @@ Three corrections to the first draft:
|
||||
verified. The ssh2 proxy-spawn at `:697` is effectively unreachable. Good news for migration, and
|
||||
the first draft's motivating example was simply wrong.
|
||||
- **Agent forwarding was overstated.** `agentForward` is gated on the user's `ForwardAgent yes`
|
||||
(`ssh-connection-utils.ts:203-205`). `config.agent` is always set, but that is agent *auth*, whose
|
||||
(`ssh-connection-utils.ts:203-205`). `config.agent` is always set, but that is agent _auth_, whose
|
||||
signatures bind the session id and cannot be replayed onward. The risk applies to users who opted
|
||||
into `ForwardAgent`, not everyone.
|
||||
- **Credential theft was understated, and the relay claim was backwards.** `isAgentFallbackError`
|
||||
treats *any* auth error as agent fallback (`ssh-connection-utils.ts:59-61`), so a MITM that rejects
|
||||
treats _any_ auth error as agent fallback (`ssh-connection-utils.ts:59-61`), so a MITM that rejects
|
||||
publickey walks the user to the password prompt (`ssh-connection.ts:844`) and the private-key
|
||||
**passphrase** prompt (`:834`), and `cachedPassword` is replayed without prompting on every
|
||||
reconnect (`:709`). Meanwhile the relay upload matters less than assumed — the attacker already
|
||||
@@ -65,7 +65,7 @@ Two consequences to own rather than discover:
|
||||
|
||||
There is no `ssh`-only way out of this: `-F /dev/null` does NOT invert the exclusion, it reports
|
||||
built-in defaults, so a probe built on it looks permissive on every machine. Verified against
|
||||
OpenSSH 10.2p1. So the file is read directly, answering a deliberately weaker question — *could*
|
||||
OpenSSH 10.2p1. So the file is read directly, answering a deliberately weaker question — _could_
|
||||
the site config be restricting host keys — where anything ambiguous (unreadable, an unresolvable
|
||||
`Include`, the directive present at all) keeps the refusal. Only a site config that demonstrably
|
||||
says nothing about host keys clears it, which is what stops the rule punishing every devcontainer,
|
||||
@@ -130,16 +130,16 @@ connect, training them to dismiss the one warning that matters.
|
||||
|
||||
> **Corrected against a live client.** The premise above is wrong about OpenSSH, though the
|
||||
> conclusion survives. `check_key_in_hostkeys` is not type-scoped at all: ANY non-marker entry for
|
||||
> the host that is not byte-equal produces `HOST_CHANGED`. Verified on 127.0.0.1:2223 — known_hosts
|
||||
> the host that is not byte-equal produces `HOST_CHANGED`. Verified on 127.0.0.1:2223 — `known_hosts`
|
||||
> holding only `ssh-rsa` against an ed25519-only server prints `IDENTIFICATION HAS CHANGED` and
|
||||
> refuses. So ssh does not avoid the false alarm by scoping; it avoids the *situation* via
|
||||
> refuses. So ssh does not avoid the false alarm by scoping; it avoids the _situation_ via
|
||||
> `order_hostkeyalgs`, and hard-fails when the situation arises anyway. Our split into `mismatch`
|
||||
> and `unknown-type-known-host` therefore only chooses the wording — both refuse, which is ssh's
|
||||
> action. What the ordering below buys us is what it buys ssh: the situation mostly never arises.
|
||||
|
||||
**But scoping alone is a downgrade vector, and this is the correction that most changes the design.**
|
||||
OpenSSH is safe here only because `order_hostkeyalgs()` reorders the client's proposed host-key
|
||||
algorithms to put the types already in `known_hosts` first, and RFC 4253 gives the *client's* order
|
||||
algorithms to put the types already in `known_hosts` first, and RFC 4253 gives the _client's_ order
|
||||
priority — so a server cannot choose a type the client deprioritised. ssh2 negotiates ed25519 first
|
||||
regardless. An attacker who cannot forge the RSA key on file simply presents ed25519 and receives a
|
||||
friendly first-contact prompt instead of a hard failure.
|
||||
@@ -147,7 +147,7 @@ friendly first-contact prompt instead of a hard failure.
|
||||
Therefore: **set ssh2's `algorithms.serverHostKey` to lead with the key types already known for that
|
||||
host.** Type scoping without algorithm ordering is not a safe design.
|
||||
|
||||
And when the presented type is unknown *while other types are known for this host*, that is
|
||||
And when the presented type is unknown _while other types are known for this host_, that is
|
||||
`unknown-type-known-host` — never a plain TOFU prompt. It must say we already hold a different key
|
||||
for this host.
|
||||
|
||||
@@ -193,7 +193,7 @@ means nothing is known.
|
||||
|
||||
### D5. Recovery must not live in the failure dialog
|
||||
|
||||
A "forget this host key" button *in* the mismatch dialog is D4's rejected "trust anyway" with one
|
||||
A "forget this host key" button _in_ the mismatch dialog is D4's rejected "trust anyway" with one
|
||||
extra click. Recovery lives in target settings: a separate, deliberate surface, no auto-retry, and it
|
||||
shows the stored fingerprint so the user is choosing knowingly.
|
||||
|
||||
@@ -201,7 +201,7 @@ Offer it only when **our** store is what disagreed; when `known_hosts` disagrees
|
||||
record cannot unblock the connect. Messages, written to avoid naming internals:
|
||||
|
||||
> **Ours disagreed** — "The host key for `build-01` changed since you last connected from Orca. If you
|
||||
> rebuilt or reprovisioned this machine, this is expected." → *Forget the saved key* / *Cancel*
|
||||
> rebuilt or reprovisioned this machine, this is expected." → _Forget the saved key_ / _Cancel_
|
||||
|
||||
> **`known_hosts` disagreed** — "The host key for `build-01` does not match the entry in
|
||||
> `~/.ssh/known_hosts`. `ssh` and `git` will refuse this host too. Run `ssh-keygen -R build-01`." →
|
||||
@@ -239,7 +239,7 @@ machines, two targets can name one machine, and a re-created target must not los
|
||||
The store is a **dedicated file**, not the main persistence blob (`persistence.ts:7088`): a settings
|
||||
restore or rollback must not silently reset trust. Accept and mismatch events are logged.
|
||||
|
||||
`hostKeyFingerprint` is now security-relevant *and* wire-relevant — it is an isolation namespace sent
|
||||
`hostKeyFingerprint` is now security-relevant _and_ wire-relevant — it is an isolation namespace sent
|
||||
to the host (`ssh-relay-session.ts:1298`, `managed-hook-owner-identity.ts:187`). It is `undefined` on
|
||||
the system transport, so **no trust logic may key off it**, and its format must not change (see
|
||||
Traps).
|
||||
@@ -247,11 +247,11 @@ Traps).
|
||||
## Phasing — ship the defence before the dialog
|
||||
|
||||
Review made the case that the riskiest part of this change is not the security model but the modal.
|
||||
Startup restore fires eager connects for *all* previously-active targets in parallel (`App.tsx:1041`)
|
||||
Startup restore fires eager connects for _all_ previously-active targets in parallel (`App.tsx:1041`)
|
||||
with a 15s timeout, while a prompt would live 120s — N unknown hosts means N stacked dialogs
|
||||
outliving the timeout that already deferred them. Runtime-owned ephemeral VMs
|
||||
(`ephemeral-vm-runtime-ssh.ts:31`) dial a freshly provisioned host with a brand-new key on every
|
||||
launch. Paired-web connects run on the *host desktop* (`runtime/rpc/methods/ssh.ts:32`), so the
|
||||
launch. Paired-web connects run on the _host desktop_ (`runtime/rpc/methods/ssh.ts:32`), so the
|
||||
dialog would open on someone else's screen while the web user watches a spinner.
|
||||
|
||||
**Phase 1 — no new modal.** Consult `known_hosts` + our store. `match` connects. `unknown` persists
|
||||
@@ -301,10 +301,11 @@ Each of these makes the fix silently do nothing. All confirmed in our tree.
|
||||
## Scope
|
||||
|
||||
**In scope, corrected:** IPv6 literals and `[host]:port` bracket parsing. Review was right that this
|
||||
is a *parser* requirement, not a scope call — getting it wrong means hosts `ssh` knows come back
|
||||
is a _parser_ requirement, not a scope call — getting it wrong means hosts `ssh` knows come back
|
||||
`unknown`, which is the prompt-training harm D3 exists to avoid.
|
||||
|
||||
**Out of scope, with consequences stated:**
|
||||
|
||||
- **`CheckHostIP`** — OpenSSH defaults it off; we form candidates from the hostname only.
|
||||
- **WSL** — `src/main/ssh/` has no WSL awareness; a distro's `known_hosts` is unreachable, so WSL
|
||||
users get first-contact treatment for hosts they already verified.
|
||||
@@ -337,7 +338,7 @@ verified by running an OpenSSH 10.2p1 client against a real `sshd` on `127.0.0.1
|
||||
its verdict:
|
||||
|
||||
- **The bare-host fallback pass never reports a change.** With `StrictHostKeyChecking=accept-new`, a
|
||||
bare line holding a *different* key, dialed on a non-default port, made ssh connect and append a
|
||||
bare line holding a _different_ key, dialed on a non-default port, made ssh connect and append a
|
||||
new `[127.0.0.1]:2222` line — first contact, no `IDENTIFICATION HAS CHANGED`. Reporting `mismatch`
|
||||
on that pass would refuse hosts ssh connects to happily, and would have looked like the cautious
|
||||
choice.
|
||||
@@ -355,16 +356,16 @@ subtly wrong.**
|
||||
Fixed after review:
|
||||
|
||||
1. **Our own store was type-downgradable.** The inline lookup filtered by key type first and could
|
||||
only answer match/mismatch/unknown, so a record of a *different* type read as `unknown`. D3's
|
||||
only answer match/mismatch/unknown, so a record of a _different_ type read as `unknown`. D3's
|
||||
downgrade, applied to the records we create ourselves. Stored types now also feed the algorithm
|
||||
ordering — without that the guard is only half present.
|
||||
2. **We keyed on the Orca label.** `ssh -G` echoes its own argument back as `hostname` when no Host
|
||||
block matches, so for a manual target `resolved.hostname` *is* the label — the one name D2
|
||||
block matches, so for a manual target `resolved.hostname` _is_ the label — the one name D2
|
||||
forbids. We consulted no entries at all.
|
||||
3. **A refused key still walked the credential ladder.** ssh2 reports a denial as a generic auth
|
||||
failure, so we went on to prompt for the passphrase and hand it to the host we had just refused.
|
||||
Rejections are now a typed error recognised before any fallback.
|
||||
4. **Fail-closed nearly became fail-always.** "No readable known_hosts" counted a *missing* file the
|
||||
4. **Fail-closed nearly became fail-always.** "No readable `known_hosts`" counted a _missing_ file the
|
||||
same as an unreadable one, so a profile that had never connected — everyone's first run — would
|
||||
have been refused, and the suite passed only because dev machines have a `known_hosts`.
|
||||
5. **Ephemeral runtimes were refused for a policy they cannot satisfy.** The carve-out sat below the
|
||||
@@ -404,7 +405,7 @@ tooltip, and the terminal reconnect overlay never asked for it at all. Still ope
|
||||
`getPublicSshState`, so a paired-web client always sees exactly `SSH connection unavailable`.
|
||||
Pre-existing, but it makes the Phase 2 dialog message unreachable there without a change. Note the
|
||||
redaction is not web-only: any target owned by a paired runtime environment is redacted, so a
|
||||
*desktop* user viewing a remote-Orca-server-owned host gets the same generic string.
|
||||
_desktop_ user viewing a remote-Orca-server-owned host gets the same generic string.
|
||||
- **RPC fail-fast** becomes load-bearing the moment the dialog exists (see Phasing).
|
||||
|
||||
**Known gaps that Phase 1 accepts, listed so they are choices and not surprises:**
|
||||
|
||||
@@ -15,7 +15,10 @@ since removed.
|
||||
table. It wraps a Toolhelp32 snapshot from `@vscode/windows-process-tree`.
|
||||
|
||||
```ts
|
||||
import { readWindowsProcessTable, readWindowsProcessTableFresh } from '../windows/windows-process-table'
|
||||
import {
|
||||
readWindowsProcessTable,
|
||||
readWindowsProcessTableFresh
|
||||
} from '../windows/windows-process-table'
|
||||
```
|
||||
|
||||
- `readWindowsProcessTable()` — shared TTL cache. Use for anything periodic.
|
||||
@@ -31,11 +34,11 @@ survived its own teardown (#9045).
|
||||
|
||||
Measured on Windows 11 with 1050 processes (p50 / p95):
|
||||
|
||||
| | p50 | p95 |
|
||||
| --- | --- | --- |
|
||||
| pid + ppid + name | 15.9 ms | 17.5 ms |
|
||||
| + memory + command line | 30.6 ms | 33.7 ms |
|
||||
| `Get-CimInstance` via PowerShell | 706 ms | 723 ms |
|
||||
| | p50 | p95 |
|
||||
| -------------------------------- | ------- | ------- |
|
||||
| pid + ppid + name | 15.9 ms | 17.5 ms |
|
||||
| + memory + command line | 30.6 ms | 33.7 ms |
|
||||
| `Get-CimInstance` via PowerShell | 706 ms | 723 ms |
|
||||
|
||||
Those CIM numbers are from a 1050-process host. The scan scales with process
|
||||
count: on a 1486-process Windows SSH host it measured **1.36 s** and produced
|
||||
@@ -62,7 +65,7 @@ Two guards, and they work together:
|
||||
the module refuses every further read until that read's callback fires.
|
||||
|
||||
The wedge used to be a 30 s cooldown that let one probe through per window. That
|
||||
bounded the *rate* of new callbacks but not the total: a permanently wedged
|
||||
bounded the _rate_ of new callbacks but not the total: a permanently wedged
|
||||
reader retained one more closure every 30 s for as long as the app ran, and each
|
||||
probe also blocked its caller for the full 3 s deadline first. Gating on the
|
||||
outstanding read instead bounds retention at exactly one callback, and gives up
|
||||
@@ -90,7 +93,7 @@ checked on a real Windows SSH host with 1486 processes:
|
||||
|
||||
**Installing it normally rebuilds from source, and that build fails.** The
|
||||
tarball carries a `binding.gyp`, so npm runs `node-gyp rebuild` regardless of
|
||||
what is already compiled inside it. On a host that *already had* MSVC Build
|
||||
what is already compiled inside it. On a host that _already had_ MSVC Build
|
||||
Tools 2022 installed, that build still failed:
|
||||
|
||||
```
|
||||
@@ -103,7 +106,7 @@ would then break outright rather than degrade: `installNativeDeps` throws on
|
||||
failure, and the toolchain-skip retry is gated to Linux.
|
||||
|
||||
**Skipping the build and using the shipped binary returns a truncated table.**
|
||||
Contrary to what this file used to claim, the published 0.8.0 tarball *does*
|
||||
Contrary to what this file used to claim, the published 0.8.0 tarball _does_
|
||||
contain `build/Release/windows_process_tree.node` — an MSVC build directory that
|
||||
looks accidentally published (`.obj` and `.tlog` files ship with it). It is
|
||||
N-API, so it loads on any modern Node. But it predates our patch and still has
|
||||
@@ -151,14 +154,14 @@ it as an optional relay artifact.
|
||||
`config/scripts/build-windows-process-tree-relay-addon.mjs` builds it from the
|
||||
source pnpm has already patched, on a Windows runner, and refuses to run if
|
||||
any patch hunk is missing — the Spectre hunk fails loudly, the 1024-process
|
||||
hunk fails *silently*, and the relative gyp path dies at configure on Windows.
|
||||
hunk fails _silently_, and the relative gyp path dies at configure on Windows.
|
||||
The source is checked rather than the install trusted. It also reads the PE
|
||||
machine field of the output, because a cross-build that quietly emitted host
|
||||
arch would ship a binary the target cannot load.
|
||||
|
||||
Windows arm64 cross-compiles from the x64 runner — verified on real hardware,
|
||||
producing `IMAGE_FILE_MACHINE_ARM64` (0xaa64) against x64's 0x8664. It needs the
|
||||
optional *MSVC v143 ARM64 build tools* component; without it node-gyp fails with
|
||||
optional _MSVC v143 ARM64 build tools_ component; without it node-gyp fails with
|
||||
`MSB8020`, which is why the addon build runs before the long packaging step.
|
||||
`ORCA_REQUIRE_RELAY_NATIVE_ADDONS` is a per-arch list so a future arch can be
|
||||
added best-effort before it is promoted to required.
|
||||
@@ -258,7 +261,7 @@ The per-PTY job deliberately does **not** set
|
||||
`JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`. Measured on Windows 11: with that flag,
|
||||
releasing the handle when the shell exits also kills whatever the user left
|
||||
running, so typing `exit` in a pane reaped a `start /b` server that used to
|
||||
survive. The job exists to make an *explicit* teardown exact, not to redefine
|
||||
survive. The job exists to make an _explicit_ teardown exact, not to redefine
|
||||
what a clean exit means.
|
||||
|
||||
Reaping a dead daemon's shells (#9195, #10415) is therefore a **second, nested
|
||||
@@ -283,7 +286,7 @@ kill-on-close job on the app, which is exactly what the crash-survival
|
||||
guarantee forbids.
|
||||
|
||||
Once the shell exits, node-pty drops its handle record and closes the job, so a
|
||||
terminated tree reports `null` rather than `[]`. Null means *unverifiable* in
|
||||
terminated tree reports `null` rather than `[]`. Null means _unverifiable_ in
|
||||
the sense of [`ssh-execution-boundary.md`](./ssh-execution-boundary.md) — no job
|
||||
support, not a ConPTY, or no longer tracked. It is never evidence that
|
||||
processes died.
|
||||
|
||||
@@ -25,7 +25,7 @@ scripts:
|
||||
|
||||
## Why the script declares it, not the terminal preference
|
||||
|
||||
`terminalWindowsShell` says which shell *interactive terminals* open in. It says nothing about the
|
||||
`terminalWindowsShell` says which shell _interactive terminals_ open in. It says nothing about the
|
||||
language a project's setup script is written in. Deriving the runner from it had two consequences:
|
||||
|
||||
- Windows users with batch-syntax setup scripts silently switched to bash on upgrade, so `copy`,
|
||||
|
||||
@@ -35,7 +35,7 @@ whichever poller arrives first afterwards pays a full-fleet `git worktree list`
|
||||
fan-out. The many high-frequency callers — `listTerminals` without a selector,
|
||||
`showTerminal`, `getWorktreePs`, `listManagedWorktrees`,
|
||||
`resolveWorktreeSelector`, orchestration authority refresh — only determine
|
||||
*who* pays, not *how often*. Steady-state subprocess volume is therefore
|
||||
_who_ pays, not _how often_. Steady-state subprocess volume is therefore
|
||||
`repos / 30 s`, independent of poll rate, which is exactly the observed
|
||||
~1 sweep / 30.5 s.
|
||||
|
||||
@@ -86,16 +86,16 @@ common directory without a subprocess (read `.git`; if it is a `gitdir:` file,
|
||||
follow it and then its `commondir`; if `.git` is absent, treat `repoPath` as a
|
||||
bare gitdir), then records:
|
||||
|
||||
| Input | External change it catches |
|
||||
| --- | --- |
|
||||
| sorted entry names of `<commonDir>/worktrees` | `worktree add`, `worktree remove`, `worktree prune` |
|
||||
| existence of `repoPath` | main checkout deleted |
|
||||
| `<commonDir>/packed-refs` mtime + size | a tip moved while its loose ref is packed away |
|
||||
| `<commonDir>/reftable` mtime + size | a tip moved under the reftable backend |
|
||||
| per checkout: `HEAD` contents | branch switch, detach (the detached oid is in HEAD itself) |
|
||||
| per checkout: contents of the ref HEAD names | a plain `git commit`, `reset`, or `fetch` that moves the tip |
|
||||
| per entry: `gitdir` contents | `worktree move`, `worktree repair` |
|
||||
| per entry: `locked` presence | `worktree lock` / `unlock` |
|
||||
| Input | External change it catches |
|
||||
| -------------------------------------------------- | ------------------------------------------------------------- |
|
||||
| sorted entry names of `<commonDir>/worktrees` | `worktree add`, `worktree remove`, `worktree prune` |
|
||||
| existence of `repoPath` | main checkout deleted |
|
||||
| `<commonDir>/packed-refs` mtime + size | a tip moved while its loose ref is packed away |
|
||||
| `<commonDir>/reftable` mtime + size | a tip moved under the reftable backend |
|
||||
| per checkout: `HEAD` contents | branch switch, detach (the detached oid is in HEAD itself) |
|
||||
| per checkout: contents of the ref HEAD names | a plain `git commit`, `reset`, or `fetch` that moves the tip |
|
||||
| per entry: `gitdir` contents | `worktree move`, `worktree repair` |
|
||||
| per entry: `locked` presence | `worktree lock` / `unlock` |
|
||||
| per entry: existence of the path named by `gitdir` | a worktree directory deleted with `rm -rf` (flips `prunable`) |
|
||||
|
||||
"per checkout" covers the main worktree and each linked worktree. Reading the
|
||||
@@ -159,21 +159,21 @@ rescans. Capturing after the scan would let that mutation be masked forever.
|
||||
|
||||
Untouched. `invalidateWorktreeScanCacheForRepo` deletes the entry (fingerprint
|
||||
included) and bumps the generation, so every event-driven path still forces a
|
||||
real scan on the next read. The fingerprint only ever *extends* an entry that
|
||||
real scan on the next read. The fingerprint only ever _extends_ an entry that
|
||||
the TTL alone would have refreshed.
|
||||
|
||||
## Freshness budget
|
||||
|
||||
| Change | Before | After |
|
||||
| --- | --- | --- |
|
||||
| Orca-initiated create/remove/rename/sparse/repo edit | immediate (event) | immediate (event) |
|
||||
| SSH reconnect / provider generation bump | immediate (event) | immediate (event) |
|
||||
| External `worktree add/remove/move/prune/lock` | ≤ 30 s | ≤ 30 s |
|
||||
| External `git checkout` / `commit` / `reset` in any worktree | ≤ 30 s | ≤ 30 s |
|
||||
| External `rm -rf <worktree>` | ≤ 30 s | ≤ 30 s |
|
||||
| External sparse-checkout pattern edit | ≤ 30 s | ≤ 5 min |
|
||||
| Packed/reftable tip moved within one mtime tick at an equal file size | ≤ 30 s | ≤ 5 min |
|
||||
| SSH / WSL repos, folder workspaces | unchanged | unchanged |
|
||||
| Change | Before | After |
|
||||
| --------------------------------------------------------------------- | ----------------- | ----------------- |
|
||||
| Orca-initiated create/remove/rename/sparse/repo edit | immediate (event) | immediate (event) |
|
||||
| SSH reconnect / provider generation bump | immediate (event) | immediate (event) |
|
||||
| External `worktree add/remove/move/prune/lock` | ≤ 30 s | ≤ 30 s |
|
||||
| External `git checkout` / `commit` / `reset` in any worktree | ≤ 30 s | ≤ 30 s |
|
||||
| External `rm -rf <worktree>` | ≤ 30 s | ≤ 30 s |
|
||||
| External sparse-checkout pattern edit | ≤ 30 s | ≤ 5 min |
|
||||
| Packed/reftable tip moved within one mtime tick at an equal file size | ≤ 30 s | ≤ 5 min |
|
||||
| SSH / WSL repos, folder workspaces | unchanged | unchanged |
|
||||
|
||||
The two regressions are bounded by the reconciliation interval and are both
|
||||
changes Orca does not make itself.
|
||||
@@ -185,10 +185,10 @@ thread" in the naive sense — but they are not equally free there. Measured on
|
||||
macOS with a 1 ms interval sampling event-loop lag while each ran 30 times
|
||||
against a repo with 20 linked worktrees:
|
||||
|
||||
| | wall per call | main-thread stall per call | worst single stall |
|
||||
| --- | --- | --- | --- |
|
||||
| `git worktree list` | 18.66 ms | 2.69 ms | 3.02 ms |
|
||||
| fingerprint probe | 1.66 ms | 0.01 ms | 0.04 ms |
|
||||
| | wall per call | main-thread stall per call | worst single stall |
|
||||
| ------------------- | ------------- | -------------------------- | ------------------ |
|
||||
| `git worktree list` | 18.66 ms | 2.69 ms | 3.02 ms |
|
||||
| fingerprint probe | 1.66 ms | 0.01 ms | 0.04 ms |
|
||||
|
||||
`fs/promises` dispatches to libuv's threadpool, so ~99 % of the probe's latency
|
||||
is off-thread. Spawning Git does not: `uv_spawn`, fd and pipe setup, and stdout
|
||||
@@ -212,10 +212,10 @@ sparse-checkout probes already do.
|
||||
reported steady state — 10 idle local repos, a caller polling at 1 Hz for 30
|
||||
simulated minutes — and counts `git worktree list` invocations:
|
||||
|
||||
| | `git worktree list` per 30 min | per hour |
|
||||
| --- | --- | --- |
|
||||
| TTL only (before) | 600 | 1,200 |
|
||||
| fingerprint gate (after) | 60 | 120 |
|
||||
| | `git worktree list` per 30 min | per hour |
|
||||
| ------------------------ | ------------------------------ | -------- |
|
||||
| TTL only (before) | 600 | 1,200 |
|
||||
| fingerprint gate (after) | 60 | 120 |
|
||||
|
||||
A 90 % reduction, with the remainder being the bounded reconciliation. Repos
|
||||
with genuine external activity keep rescanning at the 30 s cadence because the
|
||||
@@ -227,7 +227,7 @@ Extrapolating to the original trace's shape (10 repos, 3 h 27 min): 4,272
|
||||
## Rejected alternatives
|
||||
|
||||
**Raise `WORKTREE_SCAN_CACHE_TTL_MS` to 5 min.** One line, same subprocess
|
||||
reduction, but it degrades *every* external-change latency to 5 min, including
|
||||
reduction, but it degrades _every_ external-change latency to 5 min, including
|
||||
the common "I ran `git worktree add` in a terminal" case. The fingerprint buys
|
||||
the same reduction without that regression.
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ exactly the case a POSIX script uses to mean a literal dollar.
|
||||
Build argv with `buildWslExecArgs()` in `src/shared/wsl-login-shell-command.ts`. A test walks the
|
||||
tree and fails if the `--` form reappears.
|
||||
|
||||
The `--` inside `sh -s -- <path>` is a *shell* argument separator and is unrelated; leave it alone.
|
||||
The `--` inside `sh -s -- <path>` is a _shell_ argument separator and is unrelated; leave it alone.
|
||||
|
||||
## 2. Machine-read output must be fenced
|
||||
|
||||
|
||||
@@ -4,24 +4,24 @@ Three layers of coverage, because each catches what the others structurally cann
|
||||
|
||||
## 1. Unit — runs everywhere, every PR
|
||||
|
||||
| Suite | Pins |
|
||||
|---|---|
|
||||
| `src/main/wsl/wsl-runner.test.ts` | Separator, lane selection, fencing, WSLENV, guest cwd, script interpreter, budget split, refusal on unresolved PATH |
|
||||
| `src/main/wsl/wsl-guest-environment.test.ts` | Burst collapse, per-distro isolation, malformed-payload rejection, transient vs permanent, retry windows, joiner budget |
|
||||
| `src/main/wsl/wsl-w1-w3-contract.test.ts` | The W1→W3 chain end to end: absolute `wsl.exe`, argv array, bounded call, no `--`, script byte-identical, WSLENV, no shell on probe, login PATH still applied |
|
||||
| `src/shared/source-scan/source-tree-scan.test.ts` | The guard helpers. A guard that under-reports is worse than none |
|
||||
| Suite | Pins |
|
||||
| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `src/main/wsl/wsl-runner.test.ts` | Separator, lane selection, fencing, WSLENV, guest cwd, script interpreter, budget split, refusal on unresolved PATH |
|
||||
| `src/main/wsl/wsl-guest-environment.test.ts` | Burst collapse, per-distro isolation, malformed-payload rejection, transient vs permanent, retry windows, joiner budget |
|
||||
| `src/main/wsl/wsl-w1-w3-contract.test.ts` | The W1→W3 chain end to end: absolute `wsl.exe`, argv array, bounded call, no `--`, script byte-identical, WSLENV, no shell on probe, login PATH still applied |
|
||||
| `src/shared/source-scan/source-tree-scan.test.ts` | The guard helpers. A guard that under-reports is worse than none |
|
||||
|
||||
## 2. Ratchets — the goalposts, enforced continuously
|
||||
|
||||
| Guard | Measures |
|
||||
|---|---|
|
||||
| `wsl-invocation-boundary.test.ts` | Files spawning `wsl.exe` outside the runner, plus bash-only payloads that fail to declare `shell: 'bash'` |
|
||||
| `windows-console-visibility.test.ts` | Direct child-process calls missing `windowsHide` |
|
||||
| `child-process-import-boundary.test.ts` | Files importing `child_process` outside the chokepoint |
|
||||
| `wsl-exec-mode-separator.test.ts` | The banned `--` separator |
|
||||
| `pty-descendant-termination-job-coverage.test.ts` | Every sweep passes `terminateOwnedTree` |
|
||||
| Guard | Measures |
|
||||
| ------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
|
||||
| `wsl-invocation-boundary.test.ts` | Files spawning `wsl.exe` outside the runner, plus bash-only payloads that fail to declare `shell: 'bash'` |
|
||||
| `windows-console-visibility.test.ts` | Direct child-process calls missing `windowsHide` |
|
||||
| `child-process-import-boundary.test.ts` | Files importing `child_process` outside the chokepoint |
|
||||
| `wsl-exec-mode-separator.test.ts` | The banned `--` separator |
|
||||
| `pty-descendant-termination-job-coverage.test.ts` | Every sweep passes `terminateOwnedTree` |
|
||||
|
||||
Each fails on a **new** offender *and* on a **stale** entry, so the count can only fall. Verify a guard by planting a violation and watching it get named — that step has found a bug in the guard itself three times.
|
||||
Each fails on a **new** offender _and_ on a **stale** entry, so the count can only fall. Verify a guard by planting a violation and watching it get named — that step has found a bug in the guard itself three times.
|
||||
|
||||
## 3. Real-binary — the assertions nothing else can make
|
||||
|
||||
@@ -48,4 +48,4 @@ Recorded rather than implied, because a guard that looks complete is worse than
|
||||
|
||||
### Verifying a guard change
|
||||
|
||||
Plant a violation and watch it fail. Every guard fix in this workstream that was verified only by reading was wrong — three consecutive attempts at an exact lexer each shipped a desync that *reduced* the offender count, which read as progress. Plant at least: a plain call, one in a template-literal-heavy file, one in a regex-heavy file, `windowsHide: false`, a ternary first argument, and a renamed import.
|
||||
Plant a violation and watch it fail. Every guard fix in this workstream that was verified only by reading was wrong — three consecutive attempts at an exact lexer each shipped a desync that _reduced_ the offender count, which read as progress. Plant at least: a plain call, one in a template-literal-heavy file, one in a regex-heavy file, `windowsHide: false`, a ternary first argument, and a renamed import.
|
||||
|
||||
@@ -115,7 +115,7 @@ the resulting patch is silently wrong — the failure mode is a `.mjs` that is
|
||||
`forbiddenBuildScripts` in the manifest encodes this and the generator refuses
|
||||
to run a build step that names one of those scripts.
|
||||
|
||||
The generator also builds the *unmodified* commit first and asserts that it
|
||||
The generator also builds the _unmodified_ commit first and asserts that it
|
||||
reproduces the published `lib/` byte for byte before it emits anything. A
|
||||
toolchain or build-order problem therefore surfaces as an explicit "did not
|
||||
reproduce the published bundles" error rather than as 7 MB of mystery diff.
|
||||
|
||||
@@ -209,6 +209,7 @@ export const electronViteConfig: UserConfig = {
|
||||
index: resolve('src/main/index.ts'),
|
||||
// Why: sandboxed webview preloads cannot load Rollup helper chunks.
|
||||
'browser-window-close-preload': resolve('src/preload/browser-window-close.ts'),
|
||||
'doc-preview-link-preload': resolve('src/preload/doc-preview-link.ts'),
|
||||
'daemon-entry': resolve('src/main/daemon/daemon-entry.ts'),
|
||||
'plugin-host-entry': resolve('src/main/plugins/plugin-host-entry.ts'),
|
||||
'computer-sidecar': resolve('src/main/computer/sidecar-entry.ts'),
|
||||
|
||||
+209
-23
@@ -1,20 +1,35 @@
|
||||
import { useCallback, useEffect, useState, useSyncExternalStore } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react'
|
||||
import { View, Text, StyleSheet, Pressable, Platform } from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router'
|
||||
import * as Clipboard from 'expo-clipboard'
|
||||
import Constants from 'expo-constants'
|
||||
import { ChevronLeft, Copy, Check } from 'lucide-react-native'
|
||||
import { ChevronLeft, Copy, Check, Send } from 'lucide-react-native'
|
||||
import { colors, spacing, typography } from '../src/theme/mobile-theme'
|
||||
import { ConnectionLog } from '../src/components/ConnectionLog'
|
||||
import { loadHosts } from '../src/transport/host-store'
|
||||
import { connectionLogStore } from '../src/transport/connection-log-buffer'
|
||||
import { useHostClient } from '../src/transport/client-context'
|
||||
import { connectionLogStore } from '../src/transport/persisted-connection-log-store'
|
||||
import { useHostClient, useRpcClientContext } from '../src/transport/client-context'
|
||||
import {
|
||||
useLastConnectedAt,
|
||||
useConnectionPathStatus,
|
||||
useReconnectAttempt
|
||||
} from '../src/transport/client-context-connection-metrics'
|
||||
import { buildConnectionDiagnosticsReport } from '../src/diagnostics/connection-diagnostics-report'
|
||||
import {
|
||||
diagnoseConnection,
|
||||
getReportableConnectionIncidentId
|
||||
} from '../src/diagnostics/connection-diagnostics-analysis'
|
||||
import { submitConnectionDiagnostics } from '../src/diagnostics/connection-diagnostics-submission'
|
||||
import {
|
||||
readHydratedConnectionLog,
|
||||
readConnectionDiagnosticsSnapshot,
|
||||
resolveDiagnosticsHostId,
|
||||
getDiagnosticsSubmissionState,
|
||||
updateDiagnosticsSubmissionState,
|
||||
type DiagnosticsSubmissionStates
|
||||
} from '../src/diagnostics/connection-diagnostics-screen-data'
|
||||
import { useHostStatusGates } from '../src/transport/host-status-gates'
|
||||
import { loadHostAppVersion } from '../src/transport/host-app-version-store'
|
||||
import type { ConnectionLogEntry, HostProfile } from '../src/transport/types'
|
||||
|
||||
// Why: getSnapshot must be referentially stable when there's no data —
|
||||
@@ -25,11 +40,19 @@ const EMPTY_ENTRIES: readonly ConnectionLogEntry[] = []
|
||||
// screen also *acquires* the host client — opening it kicks a dial and the
|
||||
// log fills live instead of showing a stale tail.
|
||||
export default function ConnectionLogScreen() {
|
||||
const clientContext = useRpcClientContext()
|
||||
const router = useRouter()
|
||||
const params = useLocalSearchParams<{ hostId?: string }>()
|
||||
const insets = useSafeAreaInsets()
|
||||
const routeKey = useMemo(() => ({}), [params.hostId])
|
||||
const [hosts, setHosts] = useState<HostProfile[]>([])
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [manualSelection, setManualSelection] = useState<{
|
||||
hostId: string
|
||||
requestedHostId: string | undefined
|
||||
routeKey: object
|
||||
} | null>(null)
|
||||
const [copiedHostId, setCopiedHostId] = useState<string | null>(null)
|
||||
const [submissionStates, setSubmissionStates] = useState<DiagnosticsSubmissionStates>({})
|
||||
|
||||
useEffect(() => {
|
||||
let stale = false
|
||||
@@ -38,17 +61,28 @@ export default function ConnectionLogScreen() {
|
||||
return
|
||||
}
|
||||
setHosts(loaded)
|
||||
setSelectedId((prev) => prev ?? loaded[0]?.id ?? null)
|
||||
})
|
||||
return () => {
|
||||
stale = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const selectedId = resolveDiagnosticsHostId(hosts, params.hostId, manualSelection, routeKey)
|
||||
const selected = hosts.find((h) => h.id === selectedId) ?? null
|
||||
const { state } = useHostClient(selected?.id)
|
||||
const { client, state } = useHostClient(selected?.id)
|
||||
const { desktopAppVersion: liveDesktopAppVersion } = useHostStatusGates({
|
||||
hostId: selected?.id,
|
||||
client,
|
||||
connState: state
|
||||
})
|
||||
const reconnectAttempts = useReconnectAttempt(selected?.id)
|
||||
const lastConnectedAt = useLastConnectedAt(selected?.id)
|
||||
const { activePath, pendingPath } = useConnectionPathStatus(selected?.id)
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedId) {
|
||||
void readHydratedConnectionLog(connectionLogStore, selectedId)
|
||||
}
|
||||
}, [selectedId])
|
||||
|
||||
const subscribe = useCallback(
|
||||
(listener: () => void) =>
|
||||
@@ -60,25 +94,93 @@ export default function ConnectionLogScreen() {
|
||||
[selectedId]
|
||||
)
|
||||
const entries = useSyncExternalStore(subscribe, getSnapshot)
|
||||
const diagnosis = selected
|
||||
? diagnoseConnection({ endpoint: selected.endpoint, state, activePath, pendingPath, entries })
|
||||
: null
|
||||
const incidentId = selected
|
||||
? getReportableConnectionIncidentId({
|
||||
endpoint: selected.endpoint,
|
||||
state,
|
||||
activePath,
|
||||
pendingPath,
|
||||
entries
|
||||
})
|
||||
: null
|
||||
const submissionKey = selected && incidentId ? `${selected.id}:${incidentId}` : null
|
||||
const submissionState = getDiagnosticsSubmissionState(submissionStates, submissionKey)
|
||||
const copied = copiedHostId === selectedId
|
||||
|
||||
const copyDiagnostics = useCallback(async () => {
|
||||
if (!selected) {
|
||||
return
|
||||
}
|
||||
const desktopAppVersion = liveDesktopAppVersion ?? (await loadHostAppVersion(selected.id))
|
||||
const snapshot = await readConnectionDiagnosticsSnapshot(
|
||||
clientContext,
|
||||
connectionLogStore,
|
||||
selected.id
|
||||
)
|
||||
const report = buildConnectionDiagnosticsReport({
|
||||
hostName: selected.name,
|
||||
endpoint: selected.endpoint,
|
||||
state,
|
||||
reconnectAttempts,
|
||||
lastConnectedAt,
|
||||
state: snapshot.state,
|
||||
reconnectAttempts: snapshot.reconnectAttempts,
|
||||
lastConnectedAt: snapshot.lastConnectedAt,
|
||||
platform: `${Platform.OS} ${Platform.Version ?? ''}`.trim(),
|
||||
appVersion: Constants.expoConfig?.version ?? 'unknown',
|
||||
entries
|
||||
desktopAppVersion,
|
||||
entries: snapshot.entries,
|
||||
activePath: snapshot.activePath,
|
||||
pendingPath: snapshot.pendingPath
|
||||
})
|
||||
await Clipboard.setStringAsync(report)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}, [selected, state, reconnectAttempts, lastConnectedAt, entries])
|
||||
setCopiedHostId(selected.id)
|
||||
setTimeout(() => setCopiedHostId((hostId) => (hostId === selected.id ? null : hostId)), 2000)
|
||||
}, [selected, liveDesktopAppVersion, clientContext])
|
||||
|
||||
const sendDiagnostics = useCallback(async () => {
|
||||
if (!selected || !submissionKey || submissionState === 'sending') {
|
||||
return
|
||||
}
|
||||
const startedKey = submissionKey
|
||||
setSubmissionStates((states) => updateDiagnosticsSubmissionState(states, startedKey, 'sending'))
|
||||
const appVersion = Constants.expoConfig?.version ?? 'unknown'
|
||||
const platform = `${Platform.OS} ${Platform.Version ?? ''}`.trim()
|
||||
const desktopAppVersion = liveDesktopAppVersion ?? (await loadHostAppVersion(selected.id))
|
||||
const snapshot = await readConnectionDiagnosticsSnapshot(
|
||||
clientContext,
|
||||
connectionLogStore,
|
||||
selected.id
|
||||
)
|
||||
const currentIncidentId = getReportableConnectionIncidentId({
|
||||
endpoint: selected.endpoint,
|
||||
state: snapshot.state,
|
||||
activePath: snapshot.activePath,
|
||||
pendingPath: snapshot.pendingPath,
|
||||
entries: snapshot.entries
|
||||
})
|
||||
if (`${selected.id}:${currentIncidentId ?? ''}` !== startedKey) {
|
||||
setSubmissionStates((states) => updateDiagnosticsSubmissionState(states, startedKey, null))
|
||||
return
|
||||
}
|
||||
const report = buildConnectionDiagnosticsReport({
|
||||
hostName: selected.name,
|
||||
endpoint: selected.endpoint,
|
||||
state: snapshot.state,
|
||||
reconnectAttempts: snapshot.reconnectAttempts,
|
||||
lastConnectedAt: snapshot.lastConnectedAt,
|
||||
platform,
|
||||
appVersion,
|
||||
desktopAppVersion,
|
||||
entries: snapshot.entries,
|
||||
activePath: snapshot.activePath,
|
||||
pendingPath: snapshot.pendingPath
|
||||
})
|
||||
const result = await submitConnectionDiagnostics({ report, appVersion, platform })
|
||||
setSubmissionStates((states) =>
|
||||
updateDiagnosticsSubmissionState(states, startedKey, result.ok ? 'sent' : 'failed')
|
||||
)
|
||||
}, [selected, submissionKey, submissionState, liveDesktopAppVersion, clientContext])
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
|
||||
@@ -86,7 +188,7 @@ export default function ConnectionLogScreen() {
|
||||
<Pressable style={styles.backButton} onPress={() => router.back()}>
|
||||
<ChevronLeft size={22} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
<Text style={styles.heading}>Connection log</Text>
|
||||
<Text style={styles.heading}>Network diagnostics</Text>
|
||||
</View>
|
||||
|
||||
{hosts.length > 1 && (
|
||||
@@ -95,7 +197,9 @@ export default function ConnectionLogScreen() {
|
||||
<Pressable
|
||||
key={host.id}
|
||||
style={[styles.hostChip, host.id === selectedId && styles.hostChipActive]}
|
||||
onPress={() => setSelectedId(host.id)}
|
||||
onPress={() =>
|
||||
setManualSelection({ hostId: host.id, requestedHostId: params.hostId, routeKey })
|
||||
}
|
||||
>
|
||||
<Text
|
||||
style={[styles.hostChipText, host.id === selectedId && styles.hostChipTextActive]}
|
||||
@@ -121,14 +225,49 @@ export default function ConnectionLogScreen() {
|
||||
) : (
|
||||
<Copy size={14} color={colors.textSecondary} />
|
||||
)}
|
||||
<Text style={styles.copyButtonText}>{copied ? 'Copied' : 'Copy diagnostics'}</Text>
|
||||
<Text style={styles.copyButtonText}>{copied ? 'Copied' : 'Copy report'}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
{diagnosis && (
|
||||
<View style={styles.diagnosisCard}>
|
||||
<Text style={styles.diagnosisHeading}>What this suggests</Text>
|
||||
<Text style={styles.diagnosisText}>{diagnosis.likelyCause}</Text>
|
||||
<Text style={styles.diagnosisNext}>{diagnosis.nextStep}</Text>
|
||||
{diagnosis.reportability === 'orca-relay' && (
|
||||
<>
|
||||
<Text style={styles.privacyHint}>
|
||||
Sends a size-limited redacted report including host name, endpoint, versions,
|
||||
connection state, and events—never terminal contents or credentials.
|
||||
</Text>
|
||||
<Pressable
|
||||
style={styles.sendButton}
|
||||
onPress={() => void sendDiagnostics()}
|
||||
disabled={submissionState === 'sending'}
|
||||
>
|
||||
{submissionState === 'sent' ? (
|
||||
<Check size={14} color={colors.statusGreen} />
|
||||
) : (
|
||||
<Send size={14} color={colors.textPrimary} />
|
||||
)}
|
||||
<Text style={styles.sendButtonText}>
|
||||
{submissionState === 'sending'
|
||||
? 'Sending…'
|
||||
: submissionState === 'sent'
|
||||
? 'Diagnostics sent'
|
||||
: submissionState === 'failed'
|
||||
? 'Retry sending'
|
||||
: 'Send diagnostics to Orca'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
{entries.length > 0 ? (
|
||||
<ConnectionLog entries={[...entries]} title={selected.name} />
|
||||
<ConnectionLog entries={[...entries]} title={selected.name} fillAvailableHeight />
|
||||
) : (
|
||||
<Text style={styles.emptyText}>
|
||||
No connection events yet this session. Events appear as the app dials this host.
|
||||
No connection events yet. Events appear as the app dials this host.
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
@@ -199,6 +338,53 @@ const styles = StyleSheet.create({
|
||||
fontSize: typography.metaSize,
|
||||
color: colors.textSecondary
|
||||
},
|
||||
diagnosisCard: {
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.borderSubtle,
|
||||
borderRadius: 10,
|
||||
padding: spacing.md,
|
||||
marginBottom: spacing.md
|
||||
},
|
||||
diagnosisHeading: {
|
||||
fontSize: typography.metaSize,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary,
|
||||
marginBottom: spacing.xs
|
||||
},
|
||||
diagnosisText: {
|
||||
fontSize: typography.metaSize,
|
||||
color: colors.textPrimary,
|
||||
lineHeight: 18
|
||||
},
|
||||
diagnosisNext: {
|
||||
fontSize: typography.metaSize,
|
||||
color: colors.textSecondary,
|
||||
lineHeight: 18,
|
||||
marginTop: spacing.xs
|
||||
},
|
||||
privacyHint: {
|
||||
marginTop: spacing.sm,
|
||||
fontSize: 11,
|
||||
lineHeight: 15,
|
||||
color: colors.textMuted
|
||||
},
|
||||
sendButton: {
|
||||
marginTop: spacing.md,
|
||||
alignSelf: 'flex-start',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderRadius: 8,
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
sendButtonText: {
|
||||
fontSize: typography.metaSize,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
copyButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -63,6 +63,7 @@ import { ConfirmModal } from '../../../src/components/ConfirmModal'
|
||||
import { BottomDrawer } from '../../../src/components/BottomDrawer'
|
||||
import { useHostProtocolGates } from '../../../src/components/HostProtocolGate'
|
||||
import { AuthFailedBanner } from '../../../src/components/AuthFailedBanner'
|
||||
import { HostDiagnosticsLink } from '../../../src/components/HostDiagnosticsLink'
|
||||
import { HostRouteNoticeBanner } from '../../../src/components/HostRouteNoticeBanner'
|
||||
import { visibleHostRouteNotice } from '../../../src/host-route-notice'
|
||||
import { MobileSearchField } from '../../../src/components/MobileSearchField'
|
||||
@@ -1091,6 +1092,17 @@ export function HostScreen({
|
||||
/>
|
||||
)}
|
||||
|
||||
{connState !== 'connected' &&
|
||||
!relayRecovery.pairingRejected &&
|
||||
reconnectAttempts >= 3 &&
|
||||
hostId ? (
|
||||
<HostDiagnosticsLink
|
||||
onPress={() =>
|
||||
router.push({ pathname: '/connection-log', params: { hostId: String(hostId) } })
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Why a bounced route landed here (e.g. the workspace was deleted on the desktop). */}
|
||||
{routeNotice && (
|
||||
<HostRouteNoticeBanner
|
||||
|
||||
@@ -222,7 +222,7 @@ export default function TroubleshootScreen() {
|
||||
onPress={() => router.push('/connection-log')}
|
||||
>
|
||||
<ScrollText size={16} color={colors.textPrimary} />
|
||||
<Text style={styles.diagnosticButtonLabel}>View connection log</Text>
|
||||
<Text style={styles.diagnosticButtonLabel}>View network diagnostics</Text>
|
||||
</Pressable>
|
||||
|
||||
{checks.length > 0 && (
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create } from 'react-test-renderer'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ConnectionLogEntry } from '../transport/types'
|
||||
import { ConnectionLog } from './ConnectionLog'
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
ScrollView: 'ScrollView',
|
||||
StyleSheet: { create: <T,>(styles: T) => styles, hairlineWidth: 1 },
|
||||
Text: 'Text',
|
||||
View: 'View'
|
||||
}))
|
||||
|
||||
const entries: ConnectionLogEntry[] = [
|
||||
{ id: 'event-1', ts: 1, level: 'info', message: 'Opening WebSocket' }
|
||||
]
|
||||
|
||||
const duplicateEntries: ConnectionLogEntry[] = [
|
||||
{ id: 'relay-1', ts: 1, level: 'info', message: 'Relay recovery started' },
|
||||
{ id: 'relay-1', ts: 2, level: 'warn', message: 'Relay retry scheduled' }
|
||||
]
|
||||
|
||||
describe('ConnectionLog', () => {
|
||||
type RenderedNode = { props: { style?: unknown } }
|
||||
type Renderer = {
|
||||
root: {
|
||||
findAllByType: (type: unknown) => RenderedNode[]
|
||||
findByType: (type: unknown) => RenderedNode
|
||||
}
|
||||
unmount: () => void
|
||||
}
|
||||
|
||||
let renderer: Renderer | null = null
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
})
|
||||
|
||||
function renderLog(fillAvailableHeight = false): Renderer {
|
||||
act(() => {
|
||||
renderer = create(
|
||||
createElement(ConnectionLog, { entries, fillAvailableHeight })
|
||||
) as unknown as Renderer
|
||||
})
|
||||
return renderer as Renderer
|
||||
}
|
||||
|
||||
it('keeps the compact height in pairing flows', () => {
|
||||
const instance = renderLog()
|
||||
const containerStyles = instance.root.findAllByType('View')[0]!.props.style
|
||||
|
||||
expect(containerStyles).toContainEqual({ maxHeight: 240 })
|
||||
})
|
||||
|
||||
it('fills the available diagnostics viewport', () => {
|
||||
const instance = renderLog(true)
|
||||
const containerStyles = instance.root.findAllByType('View')[0]!.props.style
|
||||
const scroll = instance.root.findByType('ScrollView')
|
||||
|
||||
expect(containerStyles).toContainEqual({ flex: 1 })
|
||||
expect(scroll.props.style).toEqual({ flex: 1 })
|
||||
})
|
||||
|
||||
it('does not emit duplicate-key warnings for repeated persisted event IDs', () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
act(() => {
|
||||
renderer = create(
|
||||
createElement(ConnectionLog, { entries: duplicateEntries })
|
||||
) as unknown as Renderer
|
||||
})
|
||||
|
||||
expect(
|
||||
consoleError.mock.calls.some(([message]) =>
|
||||
String(message).includes('Encountered two children with the same key')
|
||||
)
|
||||
).toBe(false)
|
||||
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
})
|
||||
@@ -8,6 +8,7 @@ type Props = {
|
||||
// Tag printed before the first entry so it's clear what's being logged
|
||||
// (e.g. 'Pairing' vs 'Reconnect').
|
||||
title?: string
|
||||
fillAvailableHeight?: boolean
|
||||
}
|
||||
|
||||
const LEVEL_COLOR: Record<ConnectionLogEntry['level'], string> = {
|
||||
@@ -37,42 +38,53 @@ function formatTime(ts: number, baseTs: number): string {
|
||||
return `+${Math.round(elapsed)}s`
|
||||
}
|
||||
|
||||
export function ConnectionLog({ entries, title }: Props) {
|
||||
export function ConnectionLog({ entries, title, fillAvailableHeight = false }: Props) {
|
||||
const scrollRef = useRef<ScrollView | null>(null)
|
||||
|
||||
if (entries.length === 0) {
|
||||
return null
|
||||
}
|
||||
const baseTs = entries[0]!.ts
|
||||
const keyOccurrences = new Map<string, number>()
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View
|
||||
style={[
|
||||
styles.container,
|
||||
fillAvailableHeight ? styles.fillContainer : styles.boundedContainer
|
||||
]}
|
||||
>
|
||||
{title && <Text style={styles.title}>{title}</Text>}
|
||||
<ScrollView
|
||||
ref={scrollRef}
|
||||
style={styles.scroll}
|
||||
style={fillAvailableHeight ? styles.fillScroll : styles.boundedScroll}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
onContentSizeChange={() => scrollRef.current?.scrollToEnd({ animated: true })}
|
||||
>
|
||||
{entries.map((entry) => (
|
||||
<View key={entry.id} style={styles.row}>
|
||||
<Text style={styles.timestamp}>{formatTime(entry.ts, baseTs)}</Text>
|
||||
<Text style={[styles.glyph, { color: LEVEL_COLOR[entry.level] }]}>
|
||||
{LEVEL_GLYPH[entry.level]}
|
||||
</Text>
|
||||
<View style={styles.rowText}>
|
||||
<Text style={[styles.message, { color: LEVEL_COLOR[entry.level] }]}>
|
||||
{entry.message}
|
||||
{entries.map((entry) => {
|
||||
const occurrence = keyOccurrences.get(entry.id) ?? 0
|
||||
keyOccurrences.set(entry.id, occurrence + 1)
|
||||
const renderKey = occurrence === 0 ? entry.id : `${entry.id}:${occurrence}`
|
||||
return (
|
||||
<View key={renderKey} style={styles.row}>
|
||||
<Text style={styles.timestamp}>{formatTime(entry.ts, baseTs)}</Text>
|
||||
<Text style={[styles.glyph, { color: LEVEL_COLOR[entry.level] }]}>
|
||||
{LEVEL_GLYPH[entry.level]}
|
||||
</Text>
|
||||
{entry.detail && (
|
||||
<Text style={styles.detail} numberOfLines={2}>
|
||||
{entry.detail}
|
||||
<View style={styles.rowText}>
|
||||
<Text style={[styles.message, { color: LEVEL_COLOR[entry.level] }]}>
|
||||
{entry.message}
|
||||
</Text>
|
||||
)}
|
||||
{entry.detail && (
|
||||
<Text style={styles.detail} numberOfLines={2}>
|
||||
{entry.detail}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
@@ -81,7 +93,6 @@ export function ConnectionLog({ entries, title }: Props) {
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
width: '100%',
|
||||
maxHeight: 240,
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderRadius: radii.card,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
@@ -89,6 +100,12 @@ const styles = StyleSheet.create({
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.md
|
||||
},
|
||||
boundedContainer: {
|
||||
maxHeight: 240
|
||||
},
|
||||
fillContainer: {
|
||||
flex: 1
|
||||
},
|
||||
title: {
|
||||
fontSize: typography.metaSize,
|
||||
fontFamily: typography.monoFamily,
|
||||
@@ -97,9 +114,12 @@ const styles = StyleSheet.create({
|
||||
letterSpacing: 1,
|
||||
marginBottom: spacing.xs
|
||||
},
|
||||
scroll: {
|
||||
boundedScroll: {
|
||||
maxHeight: 200
|
||||
},
|
||||
fillScroll: {
|
||||
flex: 1
|
||||
},
|
||||
scrollContent: {
|
||||
gap: 6
|
||||
},
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { ChevronRight } from 'lucide-react-native'
|
||||
import { Pressable, StyleSheet, Text } from 'react-native'
|
||||
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
|
||||
|
||||
export function HostDiagnosticsLink({ onPress }: { onPress: () => void }): React.JSX.Element {
|
||||
return (
|
||||
<Pressable
|
||||
style={styles.link}
|
||||
onPress={onPress}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="View network diagnostics"
|
||||
>
|
||||
<Text style={styles.text}>View network diagnostics</Text>
|
||||
<ChevronRight size={16} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
link: {
|
||||
marginHorizontal: spacing.md,
|
||||
marginBottom: spacing.sm,
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderRadius: radii.button,
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between'
|
||||
},
|
||||
text: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.metaSize,
|
||||
fontWeight: '600'
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,240 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
diagnoseConnection,
|
||||
getReportableConnectionIncidentId
|
||||
} from './connection-diagnostics-analysis'
|
||||
import type { ConnectionLogEntry } from '../transport/types'
|
||||
|
||||
function event(message: string, detail?: string): ConnectionLogEntry {
|
||||
return { id: message, ts: 1, level: 'error', message, detail }
|
||||
}
|
||||
|
||||
describe('diagnoseConnection', () => {
|
||||
it('reports the current healthy path instead of a historical failure', () => {
|
||||
expect(
|
||||
diagnoseConnection({
|
||||
endpoint: 'ws://100.88.90.25:6768',
|
||||
state: 'connected',
|
||||
activePath: 'relay',
|
||||
entries: [event('WebSocket connect timeout')]
|
||||
})
|
||||
).toEqual({
|
||||
likelyCause: 'Connection is healthy via Relay.',
|
||||
nextStep: 'No action needed.',
|
||||
reportability: 'none'
|
||||
})
|
||||
})
|
||||
|
||||
it('distinguishes an invalid Relay credential from a transient outage', () => {
|
||||
expect(
|
||||
diagnoseConnection({
|
||||
endpoint: 'ws://100.88.90.25:6768',
|
||||
state: 'reconnecting',
|
||||
pendingPath: 'relay',
|
||||
entries: [event('Relay: relay dial failed', 'relay director resolve failed (401)')]
|
||||
})
|
||||
).toEqual({
|
||||
likelyCause: 'Relay rejected the saved resume credential.',
|
||||
nextStep: 'Try a direct connection; if Relay keeps returning 401, pair this device again.',
|
||||
reportability: 'none'
|
||||
})
|
||||
})
|
||||
|
||||
it('identifies the direct Tailscale timeout while Relay recovery is pending', () => {
|
||||
expect(
|
||||
diagnoseConnection({
|
||||
endpoint: 'ws://100.88.90.25:6768',
|
||||
state: 'reconnecting',
|
||||
activePath: 'tailscale',
|
||||
pendingPath: 'relay',
|
||||
entries: [event('WebSocket connect timeout')]
|
||||
})
|
||||
).toEqual({
|
||||
likelyCause: 'The saved Tailscale endpoint did not answer before the connection timeout.',
|
||||
nextStep: 'Relay recovery is in progress; keep Orca open while it retries.',
|
||||
reportability: 'none'
|
||||
})
|
||||
})
|
||||
|
||||
it('identifies an authenticated Relay liveness failure', () => {
|
||||
expect(
|
||||
diagnoseConnection({
|
||||
endpoint: 'ws://192.168.1.2:6768',
|
||||
state: 'reconnecting',
|
||||
activePath: 'relay',
|
||||
entries: [
|
||||
{
|
||||
...event('Relay health check failed'),
|
||||
code: 'liveness-timeout',
|
||||
path: 'relay'
|
||||
}
|
||||
]
|
||||
})
|
||||
).toEqual({
|
||||
likelyCause: 'Relay stopped answering authenticated health checks.',
|
||||
nextStep: 'Orca closed the stale session and started recovery.',
|
||||
reportability: 'orca-relay'
|
||||
})
|
||||
})
|
||||
|
||||
it('separates an active Relay close from a failed Relay dial', () => {
|
||||
expect(
|
||||
diagnoseConnection({
|
||||
endpoint: 'ws://192.168.1.2:6768',
|
||||
state: 'reconnecting',
|
||||
activePath: 'relay',
|
||||
entries: [
|
||||
{
|
||||
...event('Relay: active relay session failed', 'RelayOuterError: close code 4408'),
|
||||
code: 'relay-session-failed',
|
||||
path: 'relay'
|
||||
}
|
||||
]
|
||||
})
|
||||
).toEqual({
|
||||
likelyCause: 'The active Relay session closed unexpectedly.',
|
||||
nextStep: 'Orca started Relay recovery; the event history includes the cell close reason.',
|
||||
reportability: 'orca-relay'
|
||||
})
|
||||
})
|
||||
|
||||
it('marks authenticated Relay liveness failures as safe to send', () => {
|
||||
expect(
|
||||
diagnoseConnection({
|
||||
endpoint: 'ws://192.168.1.2:6768',
|
||||
state: 'reconnecting',
|
||||
activePath: 'relay',
|
||||
entries: [
|
||||
{
|
||||
...event('Relay health check failed'),
|
||||
code: 'liveness-timeout',
|
||||
path: 'relay'
|
||||
}
|
||||
]
|
||||
}).reportability
|
||||
).toBe('orca-relay')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['direct timeout', 'connect-timeout', 'tailscale'],
|
||||
['handshake timeout', 'handshake-timeout', 'relay'],
|
||||
['invalid credential', 'relay director resolve failed (401)', 'relay'],
|
||||
['ambiguous recovery', 'retry scheduled', 'relay']
|
||||
] as const)('does not offer submission for %s', (_name, message, path) => {
|
||||
expect(
|
||||
diagnoseConnection({
|
||||
endpoint: 'ws://100.88.90.25:6768',
|
||||
state: 'reconnecting',
|
||||
pendingPath: 'relay',
|
||||
entries: [{ ...event(message), path }]
|
||||
}).reportability
|
||||
).toBe('none')
|
||||
})
|
||||
|
||||
it('does not offer submission for a bounded Relay director outage', () => {
|
||||
expect(
|
||||
diagnoseConnection({
|
||||
endpoint: 'ws://100.88.90.25:6768',
|
||||
state: 'reconnecting',
|
||||
pendingPath: 'relay',
|
||||
entries: [event('Relay: relay dial failed', 'relay director resolve failed (503)')]
|
||||
}).reportability
|
||||
).toBe('none')
|
||||
})
|
||||
|
||||
it('classifies the newest failure instead of an older persisted 401', () => {
|
||||
expect(
|
||||
diagnoseConnection({
|
||||
endpoint: 'ws://100.88.90.25:6768',
|
||||
state: 'reconnecting',
|
||||
pendingPath: 'relay',
|
||||
entries: [
|
||||
event('Relay: relay dial failed', 'relay director resolve failed (401)'),
|
||||
{ ...event('WebSocket connect timeout'), id: 'newer', ts: 2, code: 'connect-timeout' }
|
||||
]
|
||||
}).likelyCause
|
||||
).toBe('The saved Tailscale endpoint did not answer before the connection timeout.')
|
||||
})
|
||||
|
||||
it('requires structured Relay evidence before offering submission', () => {
|
||||
expect(
|
||||
diagnoseConnection({
|
||||
endpoint: 'ws://192.168.1.2:6768',
|
||||
state: 'reconnecting',
|
||||
activePath: 'relay',
|
||||
entries: [event('active relay session failed')]
|
||||
}).reportability
|
||||
).toBe('none')
|
||||
})
|
||||
|
||||
it('ignores failures from before the current app resume boundary', () => {
|
||||
expect(
|
||||
diagnoseConnection({
|
||||
endpoint: 'ws://192.168.1.2:6768',
|
||||
state: 'connecting',
|
||||
activePath: 'lan',
|
||||
entries: [
|
||||
{
|
||||
...event('Relay health check failed'),
|
||||
code: 'liveness-timeout',
|
||||
path: 'relay'
|
||||
},
|
||||
{
|
||||
...event('App returned to foreground'),
|
||||
id: 'resume',
|
||||
ts: 2,
|
||||
code: 'app-resumed'
|
||||
},
|
||||
{ ...event('WebSocket closed'), id: 'closed', ts: 3, code: 'socket-closed' }
|
||||
]
|
||||
}).reportability
|
||||
).toBe('none')
|
||||
})
|
||||
|
||||
it('uses a structured direct liveness path ahead of the current active path', () => {
|
||||
expect(
|
||||
diagnoseConnection({
|
||||
endpoint: 'ws://192.168.1.2:6768',
|
||||
state: 'reconnecting',
|
||||
activePath: 'relay',
|
||||
entries: [
|
||||
{
|
||||
...event('Connection health check failed'),
|
||||
code: 'liveness-timeout',
|
||||
path: 'lan'
|
||||
}
|
||||
]
|
||||
})
|
||||
).toEqual({
|
||||
likelyCause: 'The connected host stopped answering authenticated health checks.',
|
||||
nextStep: 'Orca closed the stale session and started recovery.',
|
||||
reportability: 'none'
|
||||
})
|
||||
})
|
||||
|
||||
it('keys reportability to the current structured incident', () => {
|
||||
const args = {
|
||||
endpoint: 'ws://192.168.1.2:6768',
|
||||
state: 'reconnecting' as const,
|
||||
activePath: 'relay' as const,
|
||||
entries: [
|
||||
{ ...event('Authenticated'), code: 'direct-connected' as const, path: 'lan' as const },
|
||||
{
|
||||
...event('Relay health check failed'),
|
||||
id: 'current-relay-failure',
|
||||
ts: 2,
|
||||
code: 'liveness-timeout' as const,
|
||||
path: 'relay' as const
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
expect(getReportableConnectionIncidentId(args)).toBe('current-relay-failure')
|
||||
expect(
|
||||
getReportableConnectionIncidentId({
|
||||
...args,
|
||||
entries: [...args.entries, { ...event('Network changed'), ts: 3, code: 'network-changed' }]
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,168 @@
|
||||
import { isTailscaleEndpoint } from '../../../src/shared/remote-runtime-tailscale-hint'
|
||||
import type {
|
||||
ConnectionLogEntry,
|
||||
ConnectionState,
|
||||
MobileConnectionDiagnosticPath
|
||||
} from '../transport/types'
|
||||
|
||||
export type ConnectionDiagnosis = {
|
||||
likelyCause: string
|
||||
nextStep: string
|
||||
reportability: 'none' | 'orca-relay'
|
||||
}
|
||||
|
||||
type DiagnoseConnectionArgs = {
|
||||
endpoint: string
|
||||
state: ConnectionState
|
||||
activePath?: MobileConnectionDiagnosticPath
|
||||
pendingPath?: MobileConnectionDiagnosticPath | null
|
||||
entries: readonly ConnectionLogEntry[]
|
||||
}
|
||||
|
||||
export function diagnoseConnection(args: DiagnoseConnectionArgs): ConnectionDiagnosis {
|
||||
if (args.state === 'connected') {
|
||||
return {
|
||||
likelyCause: `Connection is healthy${args.activePath ? ` via ${formatPath(args.activePath)}` : ''}.`,
|
||||
nextStep: 'No action needed.',
|
||||
reportability: 'none'
|
||||
}
|
||||
}
|
||||
const failure = findCurrentDiagnosticFailure(args.entries)
|
||||
const evidence = failure ? `${failure.code ?? ''} ${failure.message} ${failure.detail ?? ''}` : ''
|
||||
|
||||
if (/relay director resolve failed \(401\)/i.test(evidence)) {
|
||||
return {
|
||||
likelyCause: 'Relay rejected the saved resume credential.',
|
||||
nextStep: 'Try a direct connection; if Relay keeps returning 401, pair this device again.',
|
||||
reportability: 'none'
|
||||
}
|
||||
}
|
||||
|
||||
if (/relay director resolve failed \(503\)/i.test(evidence)) {
|
||||
const retryMs = parseRetryDelayMs(evidence)
|
||||
return {
|
||||
likelyCause: `Relay service was temporarily unavailable${retryMs == null ? '.' : ` and asked Orca to retry in ${formatDelay(retryMs)}.`}`,
|
||||
nextStep: 'Keep Orca open; recovery should retry automatically.',
|
||||
reportability: 'none'
|
||||
}
|
||||
}
|
||||
|
||||
if (/liveness-timeout|liveness timeout|connection health check failed/i.test(evidence)) {
|
||||
const relayLiveness = failure?.code === 'liveness-timeout' && failure.path === 'relay'
|
||||
const structuredDirectLiveness =
|
||||
failure?.code === 'liveness-timeout' &&
|
||||
(failure.path === 'lan' || failure.path === 'tailscale')
|
||||
const path =
|
||||
relayLiveness || (!structuredDirectLiveness && args.activePath === 'relay')
|
||||
? 'Relay'
|
||||
: 'The connected host'
|
||||
return {
|
||||
likelyCause: `${path} stopped answering authenticated health checks.`,
|
||||
nextStep: 'Orca closed the stale session and started recovery.',
|
||||
reportability: relayLiveness ? 'orca-relay' : 'none'
|
||||
}
|
||||
}
|
||||
|
||||
if (/relay-session-failed|active relay session failed/i.test(evidence)) {
|
||||
return {
|
||||
likelyCause: 'The active Relay session closed unexpectedly.',
|
||||
nextStep: 'Orca started Relay recovery; the event history includes the cell close reason.',
|
||||
reportability:
|
||||
failure?.code === 'relay-session-failed' && failure.path === 'relay' ? 'orca-relay' : 'none'
|
||||
}
|
||||
}
|
||||
|
||||
if (/authentication-rejected|unauthorized|pairing may be revoked/i.test(evidence)) {
|
||||
return {
|
||||
likelyCause: 'The desktop rejected this device during authentication.',
|
||||
nextStep: 'Confirm the device is still paired; pair it again if the rejection repeats.',
|
||||
reportability: 'none'
|
||||
}
|
||||
}
|
||||
|
||||
if (/connect-timeout|websocket connect timeout/i.test(evidence)) {
|
||||
return {
|
||||
likelyCause: isTailscaleEndpoint(args.endpoint)
|
||||
? 'The saved Tailscale endpoint did not answer before the connection timeout.'
|
||||
: 'The saved direct endpoint did not answer before the connection timeout.',
|
||||
nextStep:
|
||||
args.pendingPath === 'relay'
|
||||
? 'Relay recovery is in progress; keep Orca open while it retries.'
|
||||
: 'Check the local/VPN network and confirm the desktop is awake.',
|
||||
reportability: 'none'
|
||||
}
|
||||
}
|
||||
|
||||
if (/handshake-timeout|handshake timeout/i.test(evidence)) {
|
||||
return {
|
||||
likelyCause: 'The endpoint opened, but the encrypted Orca handshake did not finish.',
|
||||
nextStep: 'Confirm the desktop is running a compatible Orca version and retry.',
|
||||
reportability: 'none'
|
||||
}
|
||||
}
|
||||
|
||||
if (args.pendingPath === 'relay') {
|
||||
return {
|
||||
likelyCause: 'Relay recovery is selected, but no more specific failure is recorded yet.',
|
||||
nextStep: 'Keep this page open while the next recovery event is recorded.',
|
||||
reportability: 'none'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
likelyCause: 'No single failure cause can be determined from the recorded events.',
|
||||
nextStep: 'Run diagnostics and copy the report again after the next connection attempt.',
|
||||
reportability: 'none'
|
||||
}
|
||||
}
|
||||
|
||||
export function getReportableConnectionIncidentId(args: DiagnoseConnectionArgs): string | null {
|
||||
if (diagnoseConnection(args).reportability !== 'orca-relay') {
|
||||
return null
|
||||
}
|
||||
return findCurrentDiagnosticFailure(args.entries)?.id ?? null
|
||||
}
|
||||
|
||||
function findCurrentDiagnosticFailure(
|
||||
entries: readonly ConnectionLogEntry[]
|
||||
): ConnectionLogEntry | undefined {
|
||||
const boundaryIndex = entries.findLastIndex(isDiagnosticBoundary)
|
||||
return entries
|
||||
.slice(boundaryIndex + 1)
|
||||
.toReversed()
|
||||
.find(isDiagnosticFailure)
|
||||
}
|
||||
|
||||
function isDiagnosticBoundary(entry: ConnectionLogEntry): boolean {
|
||||
return (
|
||||
entry.code === 'client-session-started' ||
|
||||
entry.code === 'app-resumed' ||
|
||||
entry.code === 'network-changed' ||
|
||||
entry.code === 'relay-connected' ||
|
||||
entry.code === 'direct-connected' ||
|
||||
entry.message === 'Authenticated'
|
||||
)
|
||||
}
|
||||
|
||||
function isDiagnosticFailure(entry: ConnectionLogEntry): boolean {
|
||||
const evidence = `${entry.code ?? ''} ${entry.message} ${entry.detail ?? ''}`
|
||||
return /relay director resolve failed \((?:401|503)\)|liveness-timeout|liveness timeout|connection health check failed|relay-session-failed|active relay session failed|authentication-rejected|unauthorized|pairing may be revoked|connect-timeout|websocket connect timeout|handshake-timeout|handshake timeout/i.test(
|
||||
evidence
|
||||
)
|
||||
}
|
||||
|
||||
function parseRetryDelayMs(evidence: string): number | null {
|
||||
const match = /retry(?:-|\s)?after(?:=|\s)(\d+)ms/i.exec(evidence)
|
||||
return match ? Number(match[1]) : null
|
||||
}
|
||||
|
||||
function formatDelay(ms: number): string {
|
||||
return ms < 60_000 ? `${Math.round(ms / 1000)}s` : `${Math.round(ms / 60_000)}m`
|
||||
}
|
||||
|
||||
function formatPath(path: MobileConnectionDiagnosticPath): string {
|
||||
if (path === 'relay') {
|
||||
return 'Relay'
|
||||
}
|
||||
return path === 'tailscale' ? 'Tailscale/direct' : 'LAN/direct'
|
||||
}
|
||||
@@ -13,6 +13,7 @@ describe('buildConnectionDiagnosticsReport', () => {
|
||||
lastConnectedAt: NOW - 5 * 60_000,
|
||||
platform: 'ios 26.5.1',
|
||||
appVersion: '0.0.29',
|
||||
desktopAppVersion: '1.4.191',
|
||||
entries: [
|
||||
{
|
||||
id: 'log-1',
|
||||
@@ -26,6 +27,7 @@ describe('buildConnectionDiagnosticsReport', () => {
|
||||
})
|
||||
|
||||
expect(report).toContain('App: Orca Mobile 0.0.29 · ios 26.5.1')
|
||||
expect(report).toContain('Host Orca version: 1.4.191')
|
||||
expect(report).toContain('Endpoint: 100.65.9.106:6768 (Tailscale)')
|
||||
expect(report).toContain('State: reconnecting (reconnect attempts: 12)')
|
||||
expect(report).toContain('(5m 0s ago)')
|
||||
@@ -48,8 +50,96 @@ describe('buildConnectionDiagnosticsReport', () => {
|
||||
})
|
||||
|
||||
expect(report).toContain('Endpoint: 192.168.1.50:6768')
|
||||
expect(report).toContain('Host Orca version: unknown')
|
||||
expect(report).not.toContain('(Tailscale)')
|
||||
expect(report).toContain('Last connected: never this session')
|
||||
expect(report).toContain('No connection events recorded this session.')
|
||||
expect(report).toContain('No connection events recorded.')
|
||||
})
|
||||
|
||||
it('explains the most likely cause and redacts credentials before copying', () => {
|
||||
const report = buildConnectionDiagnosticsReport({
|
||||
hostName: 'Host 3',
|
||||
endpoint: 'ws://100.88.90.25:6768',
|
||||
state: 'reconnecting',
|
||||
reconnectAttempts: 4,
|
||||
lastConnectedAt: null,
|
||||
platform: 'android 36',
|
||||
appVersion: '0.0.46',
|
||||
activePath: 'tailscale',
|
||||
pendingPath: 'relay',
|
||||
entries: [
|
||||
{
|
||||
id: 'relay-failure',
|
||||
ts: NOW - 5_000,
|
||||
level: 'error',
|
||||
message: 'Relay: relay dial failed',
|
||||
detail:
|
||||
'RelayDirectorHttpError: relay director resolve failed (503); retry after 30000ms; resumeToken=secret-resume-token'
|
||||
}
|
||||
],
|
||||
nowMs: NOW
|
||||
})
|
||||
|
||||
expect(report).toContain(
|
||||
'Likely cause: Relay service was temporarily unavailable and asked Orca to retry in 30s.'
|
||||
)
|
||||
expect(report).toContain('Path: active=tailscale; recovery=relay')
|
||||
expect(report).toContain('Next step: Keep Orca open; recovery should retry automatically.')
|
||||
expect(report).toContain('resumeToken=[redacted]')
|
||||
expect(report).not.toContain('secret-resume-token')
|
||||
})
|
||||
|
||||
it('redacts quoted JSON credentials and never echoes an invalid endpoint', () => {
|
||||
const report = buildConnectionDiagnosticsReport({
|
||||
hostName: 'Host 4',
|
||||
endpoint: 'not-a-url?token=endpoint-secret',
|
||||
state: 'reconnecting',
|
||||
reconnectAttempts: 1,
|
||||
lastConnectedAt: null,
|
||||
platform: 'android 36',
|
||||
appVersion: '0.0.47',
|
||||
entries: [
|
||||
{
|
||||
id: 'json-secret',
|
||||
ts: NOW,
|
||||
level: 'error',
|
||||
message: 'Relay failed',
|
||||
detail: '{"resumeToken":"json-secret","authorization":"Bearer bearer-secret"}'
|
||||
}
|
||||
],
|
||||
nowMs: NOW
|
||||
})
|
||||
|
||||
expect(report).toContain('Endpoint: invalid endpoint')
|
||||
expect(report).not.toContain('endpoint-secret')
|
||||
expect(report).not.toContain('json-secret')
|
||||
expect(report).not.toContain('bearer-secret')
|
||||
})
|
||||
|
||||
it('bounds a single event line before submission while preserving its identity', () => {
|
||||
const report = buildConnectionDiagnosticsReport({
|
||||
hostName: 'Host 5',
|
||||
endpoint: 'ws://192.168.1.2:6768',
|
||||
state: 'reconnecting',
|
||||
reconnectAttempts: 1,
|
||||
lastConnectedAt: null,
|
||||
platform: 'android 36',
|
||||
appVersion: '0.0.47',
|
||||
entries: [
|
||||
{
|
||||
id: 'oversized',
|
||||
ts: NOW,
|
||||
level: 'error',
|
||||
message: `newest oversized ${'😀'.repeat(2_000)}`
|
||||
}
|
||||
],
|
||||
nowMs: NOW
|
||||
})
|
||||
|
||||
expect(report).toContain('newest oversized')
|
||||
expect(report).toContain('[truncated]')
|
||||
expect(new TextEncoder().encode(report.split('\n').at(-1)!).byteLength).toBeLessThanOrEqual(
|
||||
2048
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { isTailscaleEndpoint } from '../../../src/shared/remote-runtime-tailscale-hint'
|
||||
import type { ConnectionLogEntry, ConnectionState } from '../transport/types'
|
||||
import type {
|
||||
ConnectionLogEntry,
|
||||
ConnectionState,
|
||||
MobileConnectionDiagnosticPath
|
||||
} from '../transport/types'
|
||||
import { normalizeHostAppVersion } from '../transport/host-app-version-store'
|
||||
import { formatEndpoint } from './host-reachability'
|
||||
import { diagnoseConnection } from './connection-diagnostics-analysis'
|
||||
import { redactConnectionLogEntry, redactConnectionLogText } from './connection-log-redaction'
|
||||
|
||||
const MAX_EVENT_LINE_BYTES = 2 * 1024
|
||||
const EVENT_TRUNCATION_MARKER = ' … [truncated]'
|
||||
|
||||
// Why: one shareable text blob answering everything we historically had to
|
||||
// ask reporters one message at a time (endpoint type, state, attempt count,
|
||||
@@ -13,37 +23,83 @@ export function buildConnectionDiagnosticsReport(args: {
|
||||
lastConnectedAt: number | null
|
||||
platform: string
|
||||
appVersion: string
|
||||
desktopAppVersion?: string | null
|
||||
entries: readonly ConnectionLogEntry[]
|
||||
activePath?: MobileConnectionDiagnosticPath
|
||||
pendingPath?: MobileConnectionDiagnosticPath | null
|
||||
nowMs?: number
|
||||
}): string {
|
||||
const now = args.nowMs ?? Date.now()
|
||||
const entries = args.entries.map(redactConnectionLogEntry)
|
||||
const diagnosis = diagnoseConnection({
|
||||
endpoint: args.endpoint,
|
||||
state: args.state,
|
||||
activePath: args.activePath,
|
||||
pendingPath: args.pendingPath,
|
||||
entries
|
||||
})
|
||||
const lines: string[] = []
|
||||
lines.push('Orca Mobile connection diagnostics')
|
||||
lines.push(`Generated: ${new Date(now).toISOString()}`)
|
||||
lines.push(`App: Orca Mobile ${args.appVersion} · ${args.platform}`)
|
||||
lines.push(`Host: ${args.hostName}`)
|
||||
const desktopAppVersion = normalizeHostAppVersion(args.desktopAppVersion)
|
||||
lines.push(`Host Orca version: ${desktopAppVersion ?? 'unknown'}`)
|
||||
lines.push(`Host: ${redactConnectionLogText(args.hostName)}`)
|
||||
lines.push(
|
||||
`Endpoint: ${formatEndpoint(args.endpoint)}${isTailscaleEndpoint(args.endpoint) ? ' (Tailscale)' : ''}`
|
||||
)
|
||||
lines.push(`State: ${args.state} (reconnect attempts: ${args.reconnectAttempts})`)
|
||||
if (args.activePath) {
|
||||
lines.push(
|
||||
`Path: active=${args.activePath}${args.pendingPath ? `; recovery=${args.pendingPath}` : ''}`
|
||||
)
|
||||
}
|
||||
lines.push(
|
||||
args.lastConnectedAt == null
|
||||
? 'Last connected: never this session'
|
||||
: `Last connected: ${new Date(args.lastConnectedAt).toISOString()} (${formatAgo(now - args.lastConnectedAt)} ago)`
|
||||
)
|
||||
lines.push('')
|
||||
if (args.entries.length === 0) {
|
||||
lines.push('No connection events recorded this session.')
|
||||
lines.push(`Likely cause: ${diagnosis.likelyCause}`)
|
||||
lines.push(`Next step: ${diagnosis.nextStep}`)
|
||||
lines.push('')
|
||||
if (entries.length === 0) {
|
||||
lines.push('No connection events recorded.')
|
||||
} else {
|
||||
lines.push(`Connection log (${args.entries.length} events, oldest first):`)
|
||||
for (const entry of args.entries) {
|
||||
lines.push(`Recent connection history (${entries.length} events, oldest first):`)
|
||||
for (const entry of entries) {
|
||||
const detail = entry.detail ? ` — ${entry.detail}` : ''
|
||||
lines.push(`${new Date(entry.ts).toISOString()} [${entry.level}] ${entry.message}${detail}`)
|
||||
const evidence = [entry.code, entry.path].filter(Boolean).join(' · ')
|
||||
lines.push(
|
||||
truncateUtf8WithMarker(
|
||||
`${new Date(entry.ts).toISOString()} [${entry.level}]${evidence ? ` [${evidence}]` : ''} ${entry.message}${detail}`,
|
||||
MAX_EVENT_LINE_BYTES,
|
||||
EVENT_TRUNCATION_MARKER
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function truncateUtf8WithMarker(value: string, maxBytes: number, marker: string): string {
|
||||
if (new TextEncoder().encode(value).byteLength <= maxBytes) {
|
||||
return value
|
||||
}
|
||||
const markerBytes = new TextEncoder().encode(marker).byteLength
|
||||
const characters: string[] = []
|
||||
let bytes = 0
|
||||
for (const character of value) {
|
||||
const characterBytes = new TextEncoder().encode(character).byteLength
|
||||
if (bytes + characterBytes + markerBytes > maxBytes) {
|
||||
break
|
||||
}
|
||||
characters.push(character)
|
||||
bytes += characterBytes
|
||||
}
|
||||
return `${characters.join('')}${marker}`
|
||||
}
|
||||
|
||||
function formatAgo(ms: number): string {
|
||||
const seconds = Math.max(0, Math.round(ms / 1000))
|
||||
if (seconds < 60) {
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
getDiagnosticsSubmissionState,
|
||||
readConnectionDiagnosticsSnapshot,
|
||||
readHydratedConnectionLog,
|
||||
resolveDiagnosticsHostId,
|
||||
selectDiagnosticsHostId,
|
||||
updateDiagnosticsSubmissionState
|
||||
} from './connection-diagnostics-screen-data'
|
||||
import type { ConnectionLogEntry, HostProfile } from '../transport/types'
|
||||
|
||||
const host = (id: string): HostProfile => ({
|
||||
id,
|
||||
name: id,
|
||||
endpoint: 'ws://192.168.1.2:6768',
|
||||
deviceToken: 'token',
|
||||
publicKeyB64: 'key',
|
||||
lastConnected: 0
|
||||
})
|
||||
|
||||
describe('connection diagnostics screen data', () => {
|
||||
it('prefers a valid route host when navigation changes', () => {
|
||||
expect(selectDiagnosticsHostId([host('a'), host('b')], 'b', 'a')).toBe('b')
|
||||
expect(selectDiagnosticsHostId([host('a')], 'missing', 'missing')).toBe('a')
|
||||
})
|
||||
|
||||
it('applies route changes synchronously without discarding a chip choice for the same route', () => {
|
||||
const hosts = [host('a'), host('b')]
|
||||
expect(resolveDiagnosticsHostId(hosts, undefined, null)).toBe('a')
|
||||
expect(resolveDiagnosticsHostId(hosts, 'b', { hostId: 'a', requestedHostId: 'a' })).toBe('b')
|
||||
expect(resolveDiagnosticsHostId(hosts, 'b', { hostId: 'a', requestedHostId: 'b' })).toBe('a')
|
||||
})
|
||||
|
||||
it('does not revive a stale manual choice when routing away and back', () => {
|
||||
const hosts = [host('a'), host('b'), host('c')]
|
||||
const firstRouteA = {}
|
||||
const routeC = {}
|
||||
const secondRouteA = {}
|
||||
const manualSelection = { hostId: 'b', requestedHostId: 'a', routeKey: firstRouteA }
|
||||
|
||||
expect(resolveDiagnosticsHostId(hosts, 'c', manualSelection, routeC)).toBe('c')
|
||||
expect(resolveDiagnosticsHostId(hosts, 'a', manualSelection, secondRouteA)).toBe('a')
|
||||
})
|
||||
|
||||
it('keeps async submission completion scoped to its host incident', () => {
|
||||
let states = updateDiagnosticsSubmissionState({}, 'host-a:incident-1', 'sending')
|
||||
states = updateDiagnosticsSubmissionState(states, 'host-b:incident-2', 'sending')
|
||||
states = updateDiagnosticsSubmissionState(states, 'host-a:incident-1', 'sent')
|
||||
|
||||
expect(getDiagnosticsSubmissionState(states, 'host-a:incident-1')).toBe('sent')
|
||||
expect(getDiagnosticsSubmissionState(states, 'host-b:incident-2')).toBe('sending')
|
||||
})
|
||||
|
||||
it('waits for hydration and then reads the refreshed log', async () => {
|
||||
const entries: ConnectionLogEntry[] = []
|
||||
const store = {
|
||||
hydrate: vi.fn(async () => {
|
||||
entries.push({ id: 'stored', ts: 1, level: 'info', message: 'stored event' })
|
||||
}),
|
||||
get: vi.fn(() => entries)
|
||||
}
|
||||
|
||||
await expect(readHydratedConnectionLog(store, 'host-a')).resolves.toEqual(entries)
|
||||
expect(store.get).toHaveBeenCalledAfter(store.hydrate)
|
||||
})
|
||||
|
||||
it('retries one transient hydration failure before reading', async () => {
|
||||
const entries = [{ id: 'stored', ts: 1, level: 'info' as const, message: 'stored event' }]
|
||||
const store = {
|
||||
hydrate: vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('storage unavailable'))
|
||||
.mockResolvedValueOnce(undefined),
|
||||
get: vi.fn(() => entries)
|
||||
}
|
||||
|
||||
await expect(readHydratedConnectionLog(store, 'host-a')).resolves.toEqual(entries)
|
||||
expect(store.hydrate).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('reads connection metadata after hydration completes', async () => {
|
||||
let state: 'connecting' | 'reconnecting' = 'connecting'
|
||||
const store = {
|
||||
hydrate: vi.fn(async () => {
|
||||
state = 'reconnecting'
|
||||
}),
|
||||
get: vi.fn(() => [{ id: 'new', ts: 2, level: 'warn' as const, message: 'new event' }])
|
||||
}
|
||||
const context = {
|
||||
getState: vi.fn(() => state),
|
||||
getReconnectAttempt: vi.fn(() => 2),
|
||||
getLastConnectedAt: vi.fn(() => 1),
|
||||
getActivePath: vi.fn(() => 'relay' as const),
|
||||
getPendingPath: vi.fn(() => null)
|
||||
}
|
||||
|
||||
await expect(readConnectionDiagnosticsSnapshot(context, store, 'host-a')).resolves.toEqual({
|
||||
state: 'reconnecting',
|
||||
reconnectAttempts: 2,
|
||||
lastConnectedAt: 1,
|
||||
activePath: 'relay',
|
||||
pendingPath: null,
|
||||
entries: [{ id: 'new', ts: 2, level: 'warn', message: 'new event' }]
|
||||
})
|
||||
expect(context.getState).toHaveBeenCalledAfter(store.hydrate)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { ConnectionLogStore } from '../transport/connection-log-buffer'
|
||||
import type { ConnectionLogEntry, HostProfile } from '../transport/types'
|
||||
import type { RpcClientContextValue } from '../transport/rpc-client-context-contract'
|
||||
|
||||
export type DiagnosticsHostSelection = {
|
||||
hostId: string
|
||||
requestedHostId: string | undefined
|
||||
routeKey?: object
|
||||
}
|
||||
|
||||
export type DiagnosticsSubmissionState = 'sending' | 'sent' | 'failed'
|
||||
export type DiagnosticsSubmissionStates = Readonly<Record<string, DiagnosticsSubmissionState>>
|
||||
|
||||
export function selectDiagnosticsHostId(
|
||||
hosts: readonly HostProfile[],
|
||||
requestedHostId: string | undefined,
|
||||
previousHostId: string | null
|
||||
): string | null {
|
||||
if (requestedHostId && hosts.some((host) => host.id === requestedHostId)) {
|
||||
return requestedHostId
|
||||
}
|
||||
if (previousHostId && hosts.some((host) => host.id === previousHostId)) {
|
||||
return previousHostId
|
||||
}
|
||||
return hosts[0]?.id ?? null
|
||||
}
|
||||
|
||||
export function resolveDiagnosticsHostId(
|
||||
hosts: readonly HostProfile[],
|
||||
requestedHostId: string | undefined,
|
||||
manualSelection: DiagnosticsHostSelection | null,
|
||||
routeKey?: object
|
||||
): string | null {
|
||||
const selected = manualSelection
|
||||
if (selected && selected.requestedHostId === requestedHostId && selected.routeKey === routeKey) {
|
||||
const manualHostId = selected.hostId
|
||||
if (hosts.some((host) => host.id === manualHostId)) {
|
||||
return manualHostId
|
||||
}
|
||||
}
|
||||
return selectDiagnosticsHostId(hosts, requestedHostId, null)
|
||||
}
|
||||
|
||||
export function getDiagnosticsSubmissionState(
|
||||
states: DiagnosticsSubmissionStates,
|
||||
key: string | null
|
||||
): DiagnosticsSubmissionState | 'idle' {
|
||||
return key ? (states[key] ?? 'idle') : 'idle'
|
||||
}
|
||||
|
||||
export function updateDiagnosticsSubmissionState(
|
||||
states: DiagnosticsSubmissionStates,
|
||||
key: string,
|
||||
state: DiagnosticsSubmissionState | null
|
||||
): DiagnosticsSubmissionStates {
|
||||
const next = { ...states }
|
||||
if (state) {
|
||||
next[key] = state
|
||||
} else {
|
||||
delete next[key]
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
export async function readHydratedConnectionLog(
|
||||
store: Pick<ConnectionLogStore, 'hydrate' | 'get'>,
|
||||
hostId: string
|
||||
): Promise<readonly ConnectionLogEntry[]> {
|
||||
try {
|
||||
await store.hydrate(hostId)
|
||||
} catch {
|
||||
await store.hydrate(hostId).catch(() => {})
|
||||
}
|
||||
return store.get(hostId)
|
||||
}
|
||||
|
||||
export async function readConnectionDiagnosticsSnapshot(
|
||||
context: Pick<
|
||||
RpcClientContextValue,
|
||||
'getState' | 'getReconnectAttempt' | 'getLastConnectedAt' | 'getActivePath' | 'getPendingPath'
|
||||
>,
|
||||
store: Pick<ConnectionLogStore, 'hydrate' | 'get'>,
|
||||
hostId: string
|
||||
) {
|
||||
const entries = await readHydratedConnectionLog(store, hostId)
|
||||
return {
|
||||
state: context.getState(hostId),
|
||||
reconnectAttempts: context.getReconnectAttempt(hostId),
|
||||
lastConnectedAt: context.getLastConnectedAt(hostId),
|
||||
activePath: context.getActivePath(hostId),
|
||||
pendingPath: context.getPendingPath(hostId),
|
||||
entries
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
boundConnectionDiagnosticsReport,
|
||||
submitConnectionDiagnostics
|
||||
} from './connection-diagnostics-submission'
|
||||
|
||||
describe('submitConnectionDiagnostics', () => {
|
||||
it('sends a bounded report through the diagnostics lane', async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(new Response(null, { status: 204 }))
|
||||
const result = await submitConnectionDiagnostics(
|
||||
{ report: 'x'.repeat(100_000), appVersion: '0.0.47', platform: 'android 36' },
|
||||
fetchImpl
|
||||
)
|
||||
|
||||
expect(result).toEqual({ ok: true })
|
||||
expect(fetchImpl).toHaveBeenCalledWith(
|
||||
'https://www.onorca.dev/v1/feedback',
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
)
|
||||
const request = fetchImpl.mock.calls[0]?.[1]
|
||||
const body = JSON.parse(String(request?.body)) as { feedback: string; submissionType: string }
|
||||
expect(body.submissionType).toBe('connection_diagnostics')
|
||||
expect(new TextEncoder().encode(body.feedback).byteLength).toBeLessThanOrEqual(64 * 1024)
|
||||
})
|
||||
|
||||
it('returns a safe failure for a rejected response', async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(new Response(null, { status: 503 }))
|
||||
await expect(
|
||||
submitConnectionDiagnostics(
|
||||
{ report: 'report', appVersion: '0.0.47', platform: 'android' },
|
||||
fetchImpl
|
||||
)
|
||||
).resolves.toEqual({ ok: false, error: 'status 503' })
|
||||
})
|
||||
|
||||
it('preserves the newest complete events when bounding a UTF-8 report', () => {
|
||||
const report = [
|
||||
'Orca Mobile connection diagnostics',
|
||||
'State: reconnecting',
|
||||
'',
|
||||
'Recent connection history (3 events, oldest first):',
|
||||
`oldest ${'😀'.repeat(20)}`,
|
||||
`middle ${'😀'.repeat(20)}`,
|
||||
`newest ${'😀'.repeat(20)}`
|
||||
].join('\n')
|
||||
|
||||
const bounded = boundConnectionDiagnosticsReport(report, 180)
|
||||
|
||||
expect(new TextEncoder().encode(bounded).byteLength).toBeLessThanOrEqual(180)
|
||||
expect(bounded).toContain('newest')
|
||||
expect(bounded).not.toContain('oldest')
|
||||
expect(bounded).toMatch(/older omitted/)
|
||||
})
|
||||
|
||||
it('retains a bounded form of the newest event when that event exceeds the budget', () => {
|
||||
const report = [
|
||||
'Orca Mobile connection diagnostics',
|
||||
'State: reconnecting',
|
||||
'',
|
||||
'Recent connection history (2 events, oldest first):',
|
||||
'old event',
|
||||
`newest oversized ${'😀'.repeat(1_000)}`
|
||||
].join('\n')
|
||||
|
||||
const bounded = boundConnectionDiagnosticsReport(report, 220)
|
||||
|
||||
expect(new TextEncoder().encode(bounded).byteLength).toBeLessThanOrEqual(220)
|
||||
expect(bounded).toContain('newest oversized')
|
||||
expect(bounded).toContain('[truncated]')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,125 @@
|
||||
const CONNECTION_DIAGNOSTICS_ENDPOINT = 'https://www.onorca.dev/v1/feedback'
|
||||
const SUBMISSION_TIMEOUT_MS = 10_000
|
||||
const MAX_SUBMISSION_BYTES = 64 * 1024
|
||||
|
||||
export type ConnectionDiagnosticsSubmission = {
|
||||
report: string
|
||||
appVersion: string
|
||||
platform: string
|
||||
}
|
||||
|
||||
export type ConnectionDiagnosticsSubmissionResult = { ok: true } | { ok: false; error: string }
|
||||
|
||||
/** Sends only the already-redacted, bounded report after an explicit user tap. */
|
||||
export async function submitConnectionDiagnostics(
|
||||
submission: ConnectionDiagnosticsSubmission,
|
||||
fetchImpl: typeof fetch = fetch
|
||||
): Promise<ConnectionDiagnosticsSubmissionResult> {
|
||||
const report = boundConnectionDiagnosticsReport(submission.report)
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), SUBMISSION_TIMEOUT_MS)
|
||||
try {
|
||||
const response = await fetchImpl(CONNECTION_DIAGNOSTICS_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
feedback: report,
|
||||
submissionType: 'connection_diagnostics',
|
||||
githubLogin: null,
|
||||
githubEmail: null,
|
||||
appVersion: submission.appVersion,
|
||||
platform: `mobile-${submission.platform}`,
|
||||
osRelease: 'unknown',
|
||||
arch: 'unknown'
|
||||
}),
|
||||
signal: controller.signal
|
||||
})
|
||||
return response.ok ? { ok: true } : { ok: false, error: `status ${response.status}` }
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: controller.signal.aborted
|
||||
? 'request timed out'
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: 'request failed'
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
export function boundConnectionDiagnosticsReport(
|
||||
report: string,
|
||||
maxBytes: number = MAX_SUBMISSION_BYTES
|
||||
): string {
|
||||
if (utf8Bytes(report) <= maxBytes) {
|
||||
return report
|
||||
}
|
||||
const lines = report.split('\n')
|
||||
const historyIndex = lines.findIndex((line) => line.startsWith('Recent connection history ('))
|
||||
if (historyIndex === -1) {
|
||||
return truncateUtf8(report, maxBytes)
|
||||
}
|
||||
const header = lines.slice(0, historyIndex)
|
||||
const events = lines.slice(historyIndex + 1)
|
||||
let kept: string[] = []
|
||||
for (let index = events.length - 1; index >= 0; index--) {
|
||||
const candidateEvents = [events[index]!, ...kept]
|
||||
const candidate = formatBoundedReport(header, candidateEvents, events.length)
|
||||
if (utf8Bytes(candidate) > maxBytes) {
|
||||
break
|
||||
}
|
||||
kept = candidateEvents
|
||||
}
|
||||
if (kept.length === 0 && events.length > 0) {
|
||||
return formatReportWithTruncatedNewestEvent(header, events, maxBytes)
|
||||
}
|
||||
return truncateUtf8(formatBoundedReport(header, kept, events.length), maxBytes)
|
||||
}
|
||||
|
||||
function formatReportWithTruncatedNewestEvent(
|
||||
header: string[],
|
||||
events: string[],
|
||||
maxBytes: number
|
||||
): string {
|
||||
const history = `Recent connection history (1 newest event; ${events.length - 1} older omitted):`
|
||||
const prefix = [...header, history].join('\n') + '\n'
|
||||
const availableBytes = maxBytes - utf8Bytes(prefix)
|
||||
if (availableBytes <= 0) {
|
||||
return truncateUtf8(prefix, maxBytes)
|
||||
}
|
||||
const marker = ' … [truncated]'
|
||||
const markerBytes = utf8Bytes(marker)
|
||||
if (availableBytes <= markerBytes) {
|
||||
return truncateUtf8(prefix, maxBytes)
|
||||
}
|
||||
return `${prefix}${truncateUtf8(events.at(-1)!, availableBytes - markerBytes)}${marker}`
|
||||
}
|
||||
|
||||
function formatBoundedReport(header: string[], events: string[], totalEvents: number): string {
|
||||
const omitted = totalEvents - events.length
|
||||
return [
|
||||
...header,
|
||||
`Recent connection history (${events.length} newest events; ${omitted} older omitted):`,
|
||||
...events
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function truncateUtf8(value: string, maxBytes: number): string {
|
||||
const characters: string[] = []
|
||||
let bytes = 0
|
||||
for (const character of value) {
|
||||
const characterBytes = utf8Bytes(character)
|
||||
if (bytes + characterBytes > maxBytes) {
|
||||
break
|
||||
}
|
||||
characters.push(character)
|
||||
bytes += characterBytes
|
||||
}
|
||||
return characters.join('')
|
||||
}
|
||||
|
||||
function utf8Bytes(value: string): number {
|
||||
return new TextEncoder().encode(value).byteLength
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { ConnectionLogEntry } from '../transport/types'
|
||||
|
||||
const SECRET_JSON_ASSIGNMENT =
|
||||
/(["'])(resumeToken|deviceToken|authorization|credential|token|publicKeyB64)\1(\s*:\s*)(["'])(?:Bearer\s+)?(?:\\.|(?!\4)[^\\\r\n])*\4/gi
|
||||
const SECRET_QUOTED_VALUE_ASSIGNMENT =
|
||||
/\b(resumeToken|deviceToken|authorization|credential|token|publicKeyB64)(\s*[:=]\s*)(["'])(?:Bearer\s+)?(?:\\.|(?!\3)[^\\\r\n])*\3/gi
|
||||
const SECRET_UNTERMINATED_JSON_ASSIGNMENT =
|
||||
/(["'])(resumeToken|deviceToken|authorization|credential|token|publicKeyB64)\1(\s*:\s*)(["'])(?:Bearer\s+)?(?:\\.|(?!\4)[^\\\r\n])*(?=\r?\n|$)/gi
|
||||
const SECRET_UNTERMINATED_QUOTED_VALUE_ASSIGNMENT =
|
||||
/\b(resumeToken|deviceToken|authorization|credential|token|publicKeyB64)(\s*[:=]\s*)(["'])(?:Bearer\s+)?(?:\\.|(?!\3)[^\\\r\n])*(?=\r?\n|$)/gi
|
||||
const SECRET_ASSIGNMENT =
|
||||
/\b(resumeToken|deviceToken|authorization|credential|token|publicKeyB64)(\s*[:=]\s*)(?:Bearer\s+)?([^\s;,}"']+)/gi
|
||||
const SECRET_QUERY = /([?&](?:token|credential|resumeToken|deviceToken)=)[^&#\s]+/gi
|
||||
const URL_USERINFO = /((?:wss?|https?):\/\/)[^\s/?#]*@/gi
|
||||
|
||||
export function redactConnectionLogText(value: string): string {
|
||||
return value
|
||||
.replace(
|
||||
SECRET_JSON_ASSIGNMENT,
|
||||
(_match, keyQuote: string, label: string, separator: string, valueQuote: string) =>
|
||||
`${keyQuote}${label}${keyQuote}${separator}${valueQuote}[redacted]${valueQuote}`
|
||||
)
|
||||
.replace(
|
||||
SECRET_QUOTED_VALUE_ASSIGNMENT,
|
||||
(_match, label: string, separator: string, valueQuote: string) =>
|
||||
`${label}${separator}${valueQuote}[redacted]${valueQuote}`
|
||||
)
|
||||
.replace(
|
||||
SECRET_UNTERMINATED_JSON_ASSIGNMENT,
|
||||
(_match, keyQuote: string, label: string, separator: string, valueQuote: string) =>
|
||||
`${keyQuote}${label}${keyQuote}${separator}${valueQuote}[redacted]`
|
||||
)
|
||||
.replace(
|
||||
SECRET_UNTERMINATED_QUOTED_VALUE_ASSIGNMENT,
|
||||
(_match, label: string, separator: string, valueQuote: string) =>
|
||||
`${label}${separator}${valueQuote}[redacted]`
|
||||
)
|
||||
.replace(SECRET_ASSIGNMENT, (_match, label: string, separator: string) => {
|
||||
return `${label}${separator}[redacted]`
|
||||
})
|
||||
.replace(SECRET_QUERY, '$1[redacted]')
|
||||
.replace(URL_USERINFO, '$1[redacted]@')
|
||||
}
|
||||
|
||||
export function redactConnectionLogEntry(entry: ConnectionLogEntry): ConnectionLogEntry {
|
||||
return {
|
||||
...entry,
|
||||
message: redactConnectionLogText(entry.message),
|
||||
...(entry.detail ? { detail: redactConnectionLogText(entry.detail) } : {})
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { testHostReachability, unreachableHostDetail } from './host-reachability'
|
||||
import { formatEndpoint, testHostReachability, unreachableHostDetail } from './host-reachability'
|
||||
|
||||
describe('unreachableHostDetail', () => {
|
||||
it('points at Tailscale for tailnet CGNAT endpoints', () => {
|
||||
@@ -21,6 +21,10 @@ describe('unreachableHostDetail', () => {
|
||||
it('does not treat non-CGNAT 100.x addresses as Tailscale', () => {
|
||||
expect(unreachableHostDetail('ws://100.20.1.5:6768')).toBe('Cannot reach 100.20.1.5:6768')
|
||||
})
|
||||
|
||||
it('does not echo malformed endpoints that may contain credentials', () => {
|
||||
expect(formatEndpoint('not-a-url?token=secret')).toBe('invalid endpoint')
|
||||
})
|
||||
})
|
||||
|
||||
describe('testHostReachability', () => {
|
||||
|
||||
@@ -55,7 +55,7 @@ export function formatEndpoint(endpoint: string): string {
|
||||
const url = new URL(endpoint)
|
||||
return url.host
|
||||
} catch {
|
||||
return endpoint
|
||||
return 'invalid endpoint'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -177,6 +177,8 @@ export function MobileHomeScreen() {
|
||||
onDismiss: () => setActionTarget(null),
|
||||
onReconnect: (hostId) => void forceReconnectHost(hostId),
|
||||
onDisconnect: disconnectHostClient,
|
||||
onDiagnostics: (hostId) =>
|
||||
data.router.push({ pathname: '/connection-log', params: { hostId } }),
|
||||
onEdit: openMobileHostEdit,
|
||||
onRemove: (host) => setConfirmRemove(host)
|
||||
})}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getHostListActionSheetActions } from './host-list-action-sheet-actions'
|
||||
import type { ConnectionState, HostProfile } from './transport/types'
|
||||
|
||||
vi.mock('lucide-react-native', () => ({
|
||||
Activity: vi.fn(),
|
||||
Edit3: vi.fn(),
|
||||
PowerOff: vi.fn(),
|
||||
RefreshCw: vi.fn()
|
||||
@@ -22,6 +23,7 @@ function build(overrides: { state?: ConnectionState; hasEverConnected?: boolean
|
||||
onDismiss: vi.fn(),
|
||||
onReconnect: vi.fn(),
|
||||
onDisconnect: vi.fn(),
|
||||
onDiagnostics: vi.fn(),
|
||||
onEdit: vi.fn(),
|
||||
onRemove: vi.fn()
|
||||
}
|
||||
@@ -35,14 +37,17 @@ function build(overrides: { state?: ConnectionState; hasEverConnected?: boolean
|
||||
}
|
||||
|
||||
describe('getHostListActionSheetActions', () => {
|
||||
// Why: both open a second drawer. Presenting one while this sheet's native
|
||||
// Why: these navigate or open a second drawer. Presenting while this sheet's native
|
||||
// Modal is still up freezes the whole screen on iOS — issue #8791.
|
||||
it.each(['Edit host', 'Remove'])('defers %s until the action sheet has closed', (label) => {
|
||||
const { actions } = build()
|
||||
expect(actions.find((action) => action.label === label)).toMatchObject({
|
||||
closeBeforePress: true
|
||||
})
|
||||
})
|
||||
it.each(['Network diagnostics', 'Edit host', 'Remove'])(
|
||||
'defers %s until the action sheet has closed',
|
||||
(label) => {
|
||||
const { actions } = build()
|
||||
expect(actions.find((action) => action.label === label)).toMatchObject({
|
||||
closeBeforePress: true
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it('leaves the in-place actions undeferred so they fire on tap', () => {
|
||||
const { actions, spies } = build()
|
||||
@@ -65,15 +70,23 @@ describe('getHostListActionSheetActions', () => {
|
||||
expect(spies.onEdit).toHaveBeenCalledWith(HOST.id)
|
||||
})
|
||||
|
||||
it('routes Network diagnostics with the selected host', () => {
|
||||
const { actions, spies } = build()
|
||||
actions.find((action) => action.label === 'Network diagnostics')?.onPress()
|
||||
expect(spies.onDiagnostics).toHaveBeenCalledWith(HOST.id)
|
||||
})
|
||||
|
||||
it('offers Disconnect only while the socket is live', () => {
|
||||
expect(build({ state: 'reconnecting' }).actions.map((action) => action.label)).toEqual([
|
||||
'Reconnect',
|
||||
'Disconnect',
|
||||
'Network diagnostics',
|
||||
'Edit host',
|
||||
'Remove'
|
||||
])
|
||||
expect(build({ state: 'disconnected' }).actions.map((action) => action.label)).toEqual([
|
||||
'Connect',
|
||||
'Network diagnostics',
|
||||
'Edit host',
|
||||
'Remove'
|
||||
])
|
||||
@@ -93,6 +106,7 @@ describe('getHostListActionSheetActions', () => {
|
||||
onDismiss: vi.fn(),
|
||||
onReconnect: vi.fn(),
|
||||
onDisconnect: vi.fn(),
|
||||
onDiagnostics: vi.fn(),
|
||||
onEdit: vi.fn(),
|
||||
onRemove: vi.fn()
|
||||
})
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Edit3, PowerOff, RefreshCw } from 'lucide-react-native'
|
||||
import { Activity, Edit3, PowerOff, RefreshCw } from 'lucide-react-native'
|
||||
import type { ActionSheetAction } from './components/ActionSheetModal'
|
||||
import type { ConnectionState, HostProfile } from './transport/types'
|
||||
|
||||
/** Builds the home-screen host long-press menu. Edit and Remove open a second
|
||||
* drawer, so both must defer until this sheet's native Modal has unmounted —
|
||||
/** Builds the home-screen host long-press menu. Navigation and second drawers
|
||||
* defer until this sheet's native Modal has unmounted —
|
||||
* presenting into a live one freezes the whole screen on iOS (issue #8791). */
|
||||
export function getHostListActionSheetActions(args: {
|
||||
host: HostProfile | null
|
||||
@@ -13,6 +13,7 @@ export function getHostListActionSheetActions(args: {
|
||||
onDismiss: () => void
|
||||
onReconnect: (hostId: string) => void
|
||||
onDisconnect: (hostId: string) => void
|
||||
onDiagnostics: (hostId: string) => void
|
||||
onEdit: (hostId: string) => void
|
||||
onRemove: (host: HostProfile) => void
|
||||
}): ActionSheetAction[] {
|
||||
@@ -47,6 +48,14 @@ export function getHostListActionSheetActions(args: {
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: 'Network diagnostics',
|
||||
icon: Activity,
|
||||
closeBeforePress: true,
|
||||
onPress: () => {
|
||||
args.onDiagnostics(host.id)
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Edit host',
|
||||
icon: Edit3,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRpcClientContext, type RpcClientContextValue } from './client-context'
|
||||
import { useRpcClientContext } from './client-context'
|
||||
import type { RpcClientContextValue } from './rpc-client-context-contract'
|
||||
import type { MobileConnectionPath } from './stable-logical-rpc-client'
|
||||
|
||||
export function useReconnectAttempt(hostId: string | undefined): number {
|
||||
@@ -10,6 +11,20 @@ export function useLastConnectedAt(hostId: string | undefined): number | null {
|
||||
return useHostMetric(hostId, (context, id) => context.getLastConnectedAt(id), null)
|
||||
}
|
||||
|
||||
export function useConnectionPathStatus(hostId: string | undefined): {
|
||||
activePath: MobileConnectionPath
|
||||
pendingPath: MobileConnectionPath | null
|
||||
} {
|
||||
return useHostMetric(
|
||||
hostId,
|
||||
(context, id) => ({
|
||||
activePath: context.getActivePath(id),
|
||||
pendingPath: context.getPendingPath(id)
|
||||
}),
|
||||
{ activePath: 'lan', pendingPath: null }
|
||||
)
|
||||
}
|
||||
|
||||
// Both inputs classifyConnection needs about a relay recovery, read from one
|
||||
// subscription so a screen cannot render half of the escalation.
|
||||
export function useRelayRecoveryStatus(hostId: string | undefined): {
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import { HostOpenRetryScheduler } from './host-open-retry-scheduler'
|
||||
import { openHostClientEntry, type HostClientStoreEntry } from './host-entry-opener'
|
||||
import { shouldPreserveActiveRelay } from './relay-reconnect-preservation'
|
||||
import { recordConnectionRevival } from './persisted-connection-log-store'
|
||||
import {
|
||||
createHostClientSelectors,
|
||||
listHostClients,
|
||||
@@ -35,8 +36,6 @@ import type { ConnectionState, HostProfile } from './types'
|
||||
import type { RpcClientContextValue } from './rpc-client-context-contract'
|
||||
|
||||
type StoreEntry = HostClientStoreEntry
|
||||
export type { HostClientAcquisition } from './host-client-acquisition-registry'
|
||||
export type { RpcClientContextValue } from './rpc-client-context-contract'
|
||||
|
||||
const Ctx = createContext<RpcClientContextValue | null>(null)
|
||||
|
||||
@@ -311,7 +310,8 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
|
||||
for (const hostId of pendingAcquisitionsRef.current.keys()) {
|
||||
retrySchedulerRef.current?.expedite(hostId)
|
||||
}
|
||||
for (const entry of storeRef.current.values()) {
|
||||
for (const [hostId, entry] of storeRef.current) {
|
||||
recordConnectionRevival(hostId, reason)
|
||||
try {
|
||||
entry.client.notifyForeground(reason)
|
||||
} catch {
|
||||
|
||||
@@ -53,4 +53,154 @@ describe('connection log buffer', () => {
|
||||
store.append('host-a', entry(2))
|
||||
expect(onA).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('hydrates persisted history without dropping events recorded during app startup', async () => {
|
||||
let finishLoad: (entries: readonly ConnectionLogEntry[]) => void = () => {}
|
||||
const load = vi.fn(
|
||||
() =>
|
||||
new Promise<readonly ConnectionLogEntry[]>((resolve) => {
|
||||
finishLoad = resolve
|
||||
})
|
||||
)
|
||||
const save = vi.fn(async () => {})
|
||||
const store = createConnectionLogStore(3, { load, save })
|
||||
|
||||
store.append('host-a', entry(3))
|
||||
finishLoad([entry(1), entry(2)])
|
||||
await store.hydrate('host-a')
|
||||
|
||||
expect(store.get('host-a').map((e) => e.id)).toEqual(['log-1', 'log-2', 'log-3'])
|
||||
await vi.waitFor(() => expect(save).toHaveBeenCalled())
|
||||
expect(save).toHaveBeenLastCalledWith('host-a', [entry(1), entry(2), entry(3)])
|
||||
})
|
||||
|
||||
it('redacts credentials before retaining or persisting an event', async () => {
|
||||
const save = vi.fn(async () => {})
|
||||
const store = createConnectionLogStore(3, { load: async () => [], save })
|
||||
|
||||
store.append('host-a', {
|
||||
...entry(1),
|
||||
detail: 'resumeToken=do-not-copy deviceToken:also-secret'
|
||||
})
|
||||
await store.hydrate('host-a')
|
||||
|
||||
expect(store.get('host-a')[0]?.detail).toBe('resumeToken=[redacted] deviceToken:[redacted]')
|
||||
await vi.waitFor(() => expect(save).toHaveBeenCalled())
|
||||
expect(JSON.stringify(save.mock.calls)).not.toContain('do-not-copy')
|
||||
expect(JSON.stringify(save.mock.calls)).not.toContain('also-secret')
|
||||
})
|
||||
|
||||
it('redacts quoted credential values when the object key is unquoted', async () => {
|
||||
const store = createConnectionLogStore(3, { load: async () => [], save: async () => {} })
|
||||
|
||||
store.append('host-a', {
|
||||
...entry(1),
|
||||
detail: `resumeToken: 'secret-one' deviceToken="secret-two"`
|
||||
})
|
||||
await store.hydrate('host-a')
|
||||
|
||||
expect(store.get('host-a')[0]?.detail).toBe(
|
||||
`resumeToken: '[redacted]' deviceToken="[redacted]"`
|
||||
)
|
||||
})
|
||||
|
||||
it('redacts the full quoted credential when its value contains an escaped quote', async () => {
|
||||
const store = createConnectionLogStore(3, { load: async () => [], save: async () => {} })
|
||||
|
||||
store.append('host-a', {
|
||||
...entry(1),
|
||||
detail: String.raw`{"token":"secret\"tail"} token: 'secret\'tail'`
|
||||
})
|
||||
await store.hydrate('host-a')
|
||||
|
||||
expect(store.get('host-a')[0]?.detail).toBe(`{"token":"[redacted]"} token: '[redacted]'`)
|
||||
})
|
||||
|
||||
it('redacts unterminated quoted credentials', async () => {
|
||||
const store = createConnectionLogStore(3, { load: async () => [], save: async () => {} })
|
||||
|
||||
store.append('host-a', {
|
||||
...entry(1),
|
||||
message: 'token="secret',
|
||||
detail: '{"authorization":"Bearer also-secret'
|
||||
})
|
||||
await store.hydrate('host-a')
|
||||
|
||||
expect(store.get('host-a')[0]).toMatchObject({
|
||||
message: 'token="[redacted]',
|
||||
detail: '{"authorization":"[redacted]'
|
||||
})
|
||||
})
|
||||
|
||||
it('redacts URL userinfo through the last authority separator', async () => {
|
||||
const store = createConnectionLogStore(3, { load: async () => [], save: async () => {} })
|
||||
|
||||
store.append('host-a', {
|
||||
...entry(1),
|
||||
message: 'wss://user:p@ss@example.com/x',
|
||||
detail: 'https://user:pass@example.com/x'
|
||||
})
|
||||
await store.hydrate('host-a')
|
||||
|
||||
expect(store.get('host-a')[0]).toMatchObject({
|
||||
message: 'wss://[redacted]@example.com/x',
|
||||
detail: 'https://[redacted]@example.com/x'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not overwrite persisted history when hydration fails and retries later', async () => {
|
||||
const load = vi
|
||||
.fn<() => Promise<readonly ConnectionLogEntry[]>>()
|
||||
.mockRejectedValueOnce(new Error('storage unavailable'))
|
||||
.mockResolvedValueOnce([entry(1)])
|
||||
const save = vi.fn(async () => {})
|
||||
const store = createConnectionLogStore(3, { load, save })
|
||||
|
||||
store.append('host-a', entry(2))
|
||||
await expect(store.hydrate('host-a')).rejects.toThrow('storage unavailable')
|
||||
expect(save).not.toHaveBeenCalled()
|
||||
|
||||
await store.hydrate('host-a')
|
||||
expect(store.get('host-a').map((value) => value.id)).toEqual(['log-1', 'log-2'])
|
||||
})
|
||||
|
||||
it('preserves legacy entries that reused the same event id', async () => {
|
||||
const store = createConnectionLogStore(3, {
|
||||
load: async () => [entry(1), { ...entry(2), id: 'log-1' }],
|
||||
save: async () => {}
|
||||
})
|
||||
|
||||
await store.hydrate('host-a')
|
||||
|
||||
expect(store.get('host-a').map((value) => value.message)).toEqual(['event 1', 'event 2'])
|
||||
})
|
||||
|
||||
it('retries one transient persistence failure without requiring another append', async () => {
|
||||
const save = vi.fn().mockResolvedValue(undefined)
|
||||
const store = createConnectionLogStore(3, { load: async () => [], save })
|
||||
|
||||
await store.hydrate('host-a')
|
||||
await vi.waitFor(() => expect(save).toHaveBeenCalled())
|
||||
save.mockReset()
|
||||
save.mockRejectedValueOnce(new Error('storage unavailable')).mockResolvedValueOnce(undefined)
|
||||
store.append('host-a', entry(1))
|
||||
|
||||
await vi.waitFor(() => expect(save).toHaveBeenCalledTimes(2))
|
||||
expect(save).toHaveBeenLastCalledWith('host-a', [entry(1)])
|
||||
})
|
||||
|
||||
it('does not retry failed automatic hydration for every appended event', async () => {
|
||||
const load = vi.fn().mockRejectedValue(new Error('storage unavailable'))
|
||||
const store = createConnectionLogStore(3, { load, save: async () => {} })
|
||||
|
||||
store.append('host-a', entry(1))
|
||||
await expect(store.hydrate('host-a')).rejects.toThrow('storage unavailable')
|
||||
store.append('host-a', entry(2))
|
||||
store.append('host-a', entry(3))
|
||||
await Promise.resolve()
|
||||
|
||||
expect(load).toHaveBeenCalledTimes(1)
|
||||
await expect(store.hydrate('host-a')).rejects.toThrow('storage unavailable')
|
||||
expect(load).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ConnectionLogEntry } from './types'
|
||||
import { redactConnectionLogEntry } from '../diagnostics/connection-log-redaction'
|
||||
|
||||
// Why: the rpc-client's onLog entries were only wired during pairing; for
|
||||
// long-lived host connections everything went to console.log, invisible to
|
||||
@@ -12,20 +13,108 @@ const MAX_ENTRIES_PER_HOST = 200
|
||||
export type ConnectionLogStore = {
|
||||
append: (hostId: string, entry: ConnectionLogEntry) => void
|
||||
get: (hostId: string) => readonly ConnectionLogEntry[]
|
||||
hydrate: (hostId: string) => Promise<void>
|
||||
subscribe: (hostId: string, listener: () => void) => () => void
|
||||
}
|
||||
|
||||
export type ConnectionLogPersistence = {
|
||||
load: (hostId: string) => Promise<readonly ConnectionLogEntry[]>
|
||||
save: (hostId: string, entries: readonly ConnectionLogEntry[]) => Promise<void>
|
||||
}
|
||||
|
||||
export function createConnectionLogStore(
|
||||
maxEntriesPerHost: number = MAX_ENTRIES_PER_HOST
|
||||
maxEntriesPerHost: number = MAX_ENTRIES_PER_HOST,
|
||||
persistence?: ConnectionLogPersistence
|
||||
): ConnectionLogStore {
|
||||
const entriesByHost = new Map<string, ConnectionLogEntry[]>()
|
||||
const listenersByHost = new Map<string, Set<() => void>>()
|
||||
const hydratedHosts = new Set<string>()
|
||||
const hydrationFailedHosts = new Set<string>()
|
||||
const hydrationByHost = new Map<string, Promise<void>>()
|
||||
const saveByHost = new Map<string, Promise<void>>()
|
||||
// Why: useSyncExternalStore compares snapshots by reference — getSnapshot
|
||||
// must return the SAME array until the data actually changes, or React
|
||||
// loops re-rendering. Cache per host; invalidate on append.
|
||||
const snapshotByHost = new Map<string, readonly ConnectionLogEntry[]>()
|
||||
const EMPTY: readonly ConnectionLogEntry[] = []
|
||||
|
||||
const trim = (entries: ConnectionLogEntry[]): void => {
|
||||
if (entries.length > maxEntriesPerHost) {
|
||||
entries.splice(0, entries.length - maxEntriesPerHost)
|
||||
}
|
||||
}
|
||||
|
||||
const notify = (hostId: string): void => {
|
||||
snapshotByHost.delete(hostId)
|
||||
const listeners = listenersByHost.get(hostId)
|
||||
if (listeners) {
|
||||
for (const listener of listeners) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const persist = (hostId: string): void => {
|
||||
if (!persistence || !hydratedHosts.has(hostId)) {
|
||||
return
|
||||
}
|
||||
const snapshot = [...(entriesByHost.get(hostId) ?? [])]
|
||||
const previous = saveByHost.get(hostId) ?? Promise.resolve()
|
||||
const pending = previous
|
||||
.catch(() => {})
|
||||
.then(async () => {
|
||||
try {
|
||||
await persistence.save(hostId, snapshot)
|
||||
} catch {
|
||||
await persistence.save(hostId, snapshot)
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
saveByHost.set(hostId, pending)
|
||||
}
|
||||
|
||||
const hydrateHost = async (hostId: string, retryAfterFailure: boolean): Promise<void> => {
|
||||
if (!persistence || hydratedHosts.has(hostId)) {
|
||||
return
|
||||
}
|
||||
const existing = hydrationByHost.get(hostId)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
if (!retryAfterFailure && hydrationFailedHosts.has(hostId)) {
|
||||
return
|
||||
}
|
||||
const pending = persistence
|
||||
.load(hostId)
|
||||
.then((stored) => {
|
||||
const live = entriesByHost.get(hostId) ?? []
|
||||
const seen = new Set<string>()
|
||||
const merged: ConnectionLogEntry[] = []
|
||||
for (const entry of [...stored, ...live]) {
|
||||
const redacted = redactConnectionLogEntry(entry)
|
||||
const fingerprint = JSON.stringify(redacted)
|
||||
if (!seen.has(fingerprint)) {
|
||||
seen.add(fingerprint)
|
||||
merged.push(redacted)
|
||||
}
|
||||
}
|
||||
merged.sort((a, b) => a.ts - b.ts)
|
||||
trim(merged)
|
||||
entriesByHost.set(hostId, merged)
|
||||
hydratedHosts.add(hostId)
|
||||
hydrationFailedHosts.delete(hostId)
|
||||
notify(hostId)
|
||||
persist(hostId)
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
hydrationFailedHosts.add(hostId)
|
||||
throw error
|
||||
})
|
||||
.finally(() => hydrationByHost.delete(hostId))
|
||||
hydrationByHost.set(hostId, pending)
|
||||
return pending
|
||||
}
|
||||
|
||||
return {
|
||||
append(hostId, entry) {
|
||||
let entries = entriesByHost.get(hostId)
|
||||
@@ -33,17 +122,12 @@ export function createConnectionLogStore(
|
||||
entries = []
|
||||
entriesByHost.set(hostId, entries)
|
||||
}
|
||||
entries.push(entry)
|
||||
if (entries.length > maxEntriesPerHost) {
|
||||
entries.splice(0, entries.length - maxEntriesPerHost)
|
||||
}
|
||||
snapshotByHost.delete(hostId)
|
||||
const listeners = listenersByHost.get(hostId)
|
||||
if (listeners) {
|
||||
for (const listener of listeners) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
entries.push(redactConnectionLogEntry(entry))
|
||||
trim(entries)
|
||||
notify(hostId)
|
||||
void hydrateHost(hostId, false)
|
||||
.then(() => persist(hostId))
|
||||
.catch(() => {})
|
||||
},
|
||||
|
||||
get(hostId) {
|
||||
@@ -60,6 +144,8 @@ export function createConnectionLogStore(
|
||||
return snapshot
|
||||
},
|
||||
|
||||
hydrate: (hostId) => hydrateHost(hostId, true),
|
||||
|
||||
subscribe(hostId, listener) {
|
||||
let listeners = listenersByHost.get(hostId)
|
||||
if (!listeners) {
|
||||
@@ -80,5 +166,3 @@ export function createConnectionLogStore(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const connectionLogStore = createConnectionLogStore()
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { isTailscaleEndpoint } from '../../../src/shared/remote-runtime-tailscale-hint'
|
||||
import type { LivenessTimeoutEvidence } from './rpc-session-liveness-watchdog'
|
||||
import type {
|
||||
ConnectionLogEntry,
|
||||
ConnectionLogLevel,
|
||||
ConnectionLogSink,
|
||||
MobileConnectionDiagnosticPath
|
||||
} from './types'
|
||||
|
||||
export class DirectConnectionLog {
|
||||
private sequence = 0
|
||||
private readonly path: MobileConnectionDiagnosticPath
|
||||
|
||||
constructor(
|
||||
endpoint: string,
|
||||
private readonly sink?: ConnectionLogSink
|
||||
) {
|
||||
this.path = isTailscaleEndpoint(endpoint) ? 'tailscale' : 'lan'
|
||||
}
|
||||
|
||||
emit = (
|
||||
level: ConnectionLogLevel,
|
||||
message: string,
|
||||
detail?: string,
|
||||
evidence?: Pick<ConnectionLogEntry, 'code' | 'path'>
|
||||
): void => {
|
||||
this.sink?.({
|
||||
id: `log-${++this.sequence}-${Date.now()}`,
|
||||
ts: Date.now(),
|
||||
level,
|
||||
message,
|
||||
detail,
|
||||
...evidence,
|
||||
path: evidence?.path ?? this.path
|
||||
})
|
||||
}
|
||||
|
||||
livenessTimeout = (evidence: LivenessTimeoutEvidence): void => {
|
||||
this.emit(
|
||||
'error',
|
||||
'Connection health check failed',
|
||||
`${evidence.reason}; ${evidence.missedProbes}/${evidence.missedProbeLimit} probes missed; last authenticated activity ${evidence.lastInboundAgeMs}ms ago`,
|
||||
{ code: 'liveness-timeout' }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ConnectOptions, RpcClient, SendRequestOptions } from './rpc-client'
|
||||
import { DirectConnectionLog } from './direct-connection-log'
|
||||
import { RpcClientAuthenticationRetry } from './rpc-client-authentication-retry'
|
||||
import { RpcClientConnectionState } from './rpc-client-connection-state'
|
||||
import {
|
||||
@@ -16,12 +17,7 @@ import {
|
||||
} from './rpc-client-stream-registry'
|
||||
import { RpcSessionLivenessWatchdog } from './rpc-session-liveness-watchdog'
|
||||
import { isStaleForegroundDial } from './rpc-stale-dial'
|
||||
import type {
|
||||
ConnectionLogLevel,
|
||||
ConnectionState,
|
||||
ForegroundNudgeReason,
|
||||
RpcResponse
|
||||
} from './types'
|
||||
import type { ConnectionState, ForegroundNudgeReason, RpcResponse } from './types'
|
||||
|
||||
const LIVENESS_REQUEST_ID_PREFIX = 'mobile-liveness-'
|
||||
|
||||
@@ -36,7 +32,7 @@ export class DirectRpcClient implements RpcClient {
|
||||
private readonly authenticationRetry: RpcClientAuthenticationRetry
|
||||
private readonly socketClose: RpcClientSocketCloseController
|
||||
private requestCounter = 0
|
||||
private logCounter = 0
|
||||
private readonly connectionLog: DirectConnectionLog
|
||||
private intentionallyClosed = false
|
||||
private authenticationGeneration = 0
|
||||
private livenessSession: RpcClientSocketSession | null = null
|
||||
@@ -47,10 +43,12 @@ export class DirectRpcClient implements RpcClient {
|
||||
serverPublicKeyB64: string,
|
||||
private readonly options: ConnectOptions
|
||||
) {
|
||||
this.connectionLog = new DirectConnectionLog(endpoint, options.onLog)
|
||||
this.reconnect = new RpcClientReconnectSchedule({
|
||||
openConnection: () => this.openConnection(),
|
||||
rejectConnectWaiters: (reason) => this.connectionState.rejectWaiters(reason),
|
||||
emitLog: (message, detail) => this.emitLog('info', message, detail)
|
||||
emitLog: (message, detail) =>
|
||||
this.connectionLog.emit('info', message, detail, { code: 'retry-scheduled' })
|
||||
})
|
||||
this.connectionState = new RpcClientConnectionState({
|
||||
endpoint,
|
||||
@@ -78,7 +76,8 @@ export class DirectRpcClient implements RpcClient {
|
||||
if (identity === this.livenessSession && this.socketSession === this.livenessSession) {
|
||||
this.socketClose.forceClose(this.livenessSession)
|
||||
}
|
||||
}
|
||||
},
|
||||
onTimeout: this.connectionLog.livenessTimeout
|
||||
})
|
||||
this.socketFactory = new RpcClientSocketFactory({
|
||||
endpoint,
|
||||
@@ -89,7 +88,7 @@ export class DirectRpcClient implements RpcClient {
|
||||
getReconnectAttempt: () => this.getReconnectAttempt(),
|
||||
getLastConnectedAt: () => this.getLastConnectedAt(),
|
||||
isIntentionallyClosed: () => this.intentionallyClosed,
|
||||
emitLog: (level, message, detail) => this.emitLog(level, message, detail),
|
||||
emitLog: this.connectionLog.emit,
|
||||
onHandshakeStarted: () => this.connectionState.publish('handshaking'),
|
||||
onAuthenticated: (session) => this.handleAuthenticated(session),
|
||||
onAuthRejected: (reason) => this.authenticationRetry.reject(reason),
|
||||
@@ -102,7 +101,8 @@ export class DirectRpcClient implements RpcClient {
|
||||
this.authenticationRetry = new RpcClientAuthenticationRetry({
|
||||
endpoint,
|
||||
stopLiveness: () => this.stopLiveness(),
|
||||
emitWarning: (message, detail) => this.emitLog('warn', message, detail),
|
||||
emitWarning: (message, detail) =>
|
||||
this.connectionLog.emit('warn', message, detail, { code: 'authentication-rejected' }),
|
||||
retry: (reason) => this.retryAuthentication(reason),
|
||||
latchFailure: (reason) => this.latchAuthenticationFailure(reason)
|
||||
})
|
||||
@@ -122,7 +122,8 @@ export class DirectRpcClient implements RpcClient {
|
||||
this.stopLiveness()
|
||||
}
|
||||
},
|
||||
emitWarning: (message, detail) => this.emitLog('warn', message, detail)
|
||||
emitWarning: (message, detail, evidence) =>
|
||||
this.connectionLog.emit('warn', message, detail, evidence)
|
||||
})
|
||||
this.openConnection()
|
||||
}
|
||||
@@ -232,7 +233,9 @@ export class DirectRpcClient implements RpcClient {
|
||||
this.reconnect.authenticated()
|
||||
this.authenticationRetry.accepted()
|
||||
this.connectionState.publish('connected')
|
||||
this.emitLog('success', 'Authenticated', 'Channel ready for RPC')
|
||||
this.connectionLog.emit('success', 'Authenticated', 'Channel ready for RPC', {
|
||||
code: 'direct-connected'
|
||||
})
|
||||
this.streams.replayAfterAuthentication()
|
||||
}
|
||||
|
||||
@@ -311,14 +314,4 @@ export class DirectRpcClient implements RpcClient {
|
||||
private nextId(): string {
|
||||
return `rpc-${++this.requestCounter}-${Date.now()}`
|
||||
}
|
||||
|
||||
private emitLog(level: ConnectionLogLevel, message: string, detail?: string): void {
|
||||
this.options.onLog?.({
|
||||
id: `log-${++this.logCounter}-${Date.now()}`,
|
||||
ts: Date.now(),
|
||||
level,
|
||||
message,
|
||||
detail
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const asyncStorageMock = vi.hoisted(() => ({
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@react-native-async-storage/async-storage', () => ({
|
||||
default: asyncStorageMock
|
||||
}))
|
||||
|
||||
import {
|
||||
loadHostAppVersion,
|
||||
normalizeHostAppVersion,
|
||||
recordHostAppVersion
|
||||
} from './host-app-version-store'
|
||||
|
||||
describe('host app version store', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
asyncStorageMock.getItem.mockResolvedValue(null)
|
||||
asyncStorageMock.setItem.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('persists a bounded version once per host', async () => {
|
||||
asyncStorageMock.getItem.mockResolvedValueOnce(null).mockResolvedValueOnce('1.4.191')
|
||||
|
||||
await recordHostAppVersion('host-1', ' 1.4.191 ')
|
||||
await recordHostAppVersion('host-1', '1.4.191')
|
||||
|
||||
expect(asyncStorageMock.setItem).toHaveBeenCalledOnce()
|
||||
expect(asyncStorageMock.setItem).toHaveBeenCalledWith(
|
||||
'orca:host-app-version:v1:host-1',
|
||||
'1.4.191'
|
||||
)
|
||||
})
|
||||
|
||||
it('loads a previously observed version without requiring a live host', async () => {
|
||||
asyncStorageMock.getItem.mockResolvedValue('1.4.188')
|
||||
|
||||
await expect(loadHostAppVersion('host-1')).resolves.toBe('1.4.188')
|
||||
})
|
||||
|
||||
it('rejects multiline, empty, and oversized host-provided values', async () => {
|
||||
expect(normalizeHostAppVersion('1.4.191\nInjected: value')).toBeNull()
|
||||
expect(normalizeHostAppVersion(' ')).toBeNull()
|
||||
expect(normalizeHostAppVersion('x'.repeat(65))).toBeNull()
|
||||
|
||||
await recordHostAppVersion('host-1', '1.4.191\nInjected: value')
|
||||
expect(asyncStorageMock.setItem).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
|
||||
const STORAGE_KEY_PREFIX = 'orca:host-app-version:v1:'
|
||||
const MAX_VERSION_LENGTH = 64
|
||||
|
||||
export function normalizeHostAppVersion(value: unknown): string | null {
|
||||
if (typeof value !== 'string') {
|
||||
return null
|
||||
}
|
||||
const normalized = value.trim()
|
||||
if (
|
||||
normalized.length === 0 ||
|
||||
normalized.length > MAX_VERSION_LENGTH ||
|
||||
normalized.includes('\n') ||
|
||||
normalized.includes('\r')
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
export async function loadHostAppVersion(hostId: string): Promise<string | null> {
|
||||
try {
|
||||
return normalizeHostAppVersion(await AsyncStorage.getItem(storageKey(hostId)))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function recordHostAppVersion(hostId: string, value: unknown): Promise<void> {
|
||||
const appVersion = normalizeHostAppVersion(value)
|
||||
if (!appVersion) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const key = storageKey(hostId)
|
||||
if (normalizeHostAppVersion(await AsyncStorage.getItem(key)) !== appVersion) {
|
||||
await AsyncStorage.setItem(key, appVersion)
|
||||
}
|
||||
} catch {
|
||||
// Best-effort diagnostic metadata must never affect host connectivity.
|
||||
}
|
||||
}
|
||||
|
||||
function storageKey(hostId: string): string {
|
||||
return `${STORAGE_KEY_PREFIX}${hostId}`
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
import { connectionLogStore } from './connection-log-buffer'
|
||||
import {
|
||||
connectionLogStore,
|
||||
recordConnectionClientSessionStart
|
||||
} from './persisted-connection-log-store'
|
||||
import { loadHosts } from './host-store'
|
||||
import { openHostLogicalClient } from './host-logical-client'
|
||||
import type { HostClientOpenRegistry } from './host-client-open-registry'
|
||||
@@ -65,6 +68,7 @@ export async function openHostClientEntry(
|
||||
id: `host-open-${ticket.generation}-${Date.now()}`,
|
||||
ts: Date.now(),
|
||||
level: 'error',
|
||||
code: 'host-open-failed',
|
||||
message: 'Host client open failed',
|
||||
detail: `${category}; retry ${retry.nextDelayMs}ms (failure ${retry.failureCount})`
|
||||
})
|
||||
@@ -98,6 +102,7 @@ export async function openHostClientEntry(
|
||||
|
||||
let client: RpcClient
|
||||
try {
|
||||
recordConnectionClientSessionStart(hostId)
|
||||
client = openHostLogicalClient(host, (entry) => connectionLogStore.append(hostId, entry))
|
||||
} catch {
|
||||
failCurrentOpen('client-construction')
|
||||
|
||||
@@ -4,6 +4,13 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcClient } from './rpc-client'
|
||||
import { useHostStatusGates, type HostStatusGates } from './host-status-gates'
|
||||
|
||||
const recordHostAppVersionMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined))
|
||||
|
||||
vi.mock('./host-app-version-store', () => ({
|
||||
normalizeHostAppVersion: (value: unknown) => (typeof value === 'string' ? value : null),
|
||||
recordHostAppVersion: (...args: unknown[]) => recordHostAppVersionMock(...args)
|
||||
}))
|
||||
|
||||
describe('useHostStatusGates', () => {
|
||||
it('clears every prior-host gate and ignores its late response while the client is replaced', async () => {
|
||||
let resolveOldStatus: ((response: unknown) => void) | null = null
|
||||
@@ -76,6 +83,7 @@ describe('useHostStatusGates', () => {
|
||||
const sendRequest = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
result: {
|
||||
appVersion: '1.4.191',
|
||||
capabilities: ['browser.screencast.v1'],
|
||||
floatingWorkspaceEnabled: true
|
||||
}
|
||||
@@ -95,11 +103,13 @@ describe('useHostStatusGates', () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(gates).toMatchObject({
|
||||
desktopAppVersion: '1.4.191',
|
||||
hostCapabilities: ['browser.screencast.v1'],
|
||||
floatingWorkspaceEnabled: true
|
||||
})
|
||||
|
||||
expect(sendRequest).toHaveBeenCalledOnce()
|
||||
expect(recordHostAppVersionMock).toHaveBeenCalledWith('host-1', '1.4.191')
|
||||
} finally {
|
||||
renderer?.unmount()
|
||||
}
|
||||
|
||||
@@ -3,10 +3,12 @@ import type { RpcClient } from './rpc-client'
|
||||
import type { ConnectionState, RpcSuccess } from './types'
|
||||
import { evaluateCompat, type CompatVerdict } from './protocol-compat'
|
||||
import type { DesktopStatus } from '../worktree/host-worktree-rpc-types'
|
||||
import { normalizeHostAppVersion, recordHostAppVersion } from './host-app-version-store'
|
||||
|
||||
export type HostStatusGates = {
|
||||
hostCapabilities: string[]
|
||||
floatingWorkspaceEnabled: boolean
|
||||
desktopAppVersion: string | null
|
||||
compatVerdict: CompatVerdict
|
||||
statusPending: boolean
|
||||
}
|
||||
@@ -53,6 +55,7 @@ export function useHostStatusGates(args: {
|
||||
settle({
|
||||
hostCapabilities: [],
|
||||
floatingWorkspaceEnabled: false,
|
||||
desktopAppVersion: null,
|
||||
compatVerdict: { kind: 'ok' }
|
||||
})
|
||||
return
|
||||
@@ -64,9 +67,14 @@ export function useHostStatusGates(args: {
|
||||
desktopProtocolVersion: status.protocolVersion,
|
||||
desktopMinCompatibleMobileVersion: status.minCompatibleMobileVersion
|
||||
})
|
||||
const desktopAppVersion = normalizeHostAppVersion(status.appVersion)
|
||||
if (hostId && desktopAppVersion) {
|
||||
void recordHostAppVersion(hostId, desktopAppVersion)
|
||||
}
|
||||
settle({
|
||||
hostCapabilities: status.capabilities ?? [],
|
||||
floatingWorkspaceEnabled: status.floatingWorkspaceEnabled === true,
|
||||
desktopAppVersion,
|
||||
compatVerdict: verdict
|
||||
})
|
||||
if (verdict.kind === 'blocked') {
|
||||
@@ -84,6 +92,7 @@ export function useHostStatusGates(args: {
|
||||
settle({
|
||||
hostCapabilities: [],
|
||||
floatingWorkspaceEnabled: false,
|
||||
desktopAppVersion: null,
|
||||
compatVerdict: { kind: 'ok' }
|
||||
})
|
||||
}
|
||||
@@ -100,6 +109,7 @@ export function useHostStatusGates(args: {
|
||||
return {
|
||||
hostCapabilities: EMPTY_HOST_CAPABILITIES,
|
||||
floatingWorkspaceEnabled: false,
|
||||
desktopAppVersion: null,
|
||||
compatVerdict: { kind: 'ok' },
|
||||
statusPending: connState === 'connected' && client !== null
|
||||
}
|
||||
@@ -107,6 +117,7 @@ export function useHostStatusGates(args: {
|
||||
return {
|
||||
hostCapabilities: proven.hostCapabilities,
|
||||
floatingWorkspaceEnabled: proven.floatingWorkspaceEnabled,
|
||||
desktopAppVersion: proven.desktopAppVersion,
|
||||
compatVerdict: proven.compatVerdict,
|
||||
// Why (F10): unchanged pending timing — the reconnect refetch is still "unknown", it just no
|
||||
// longer blanks the capabilities this same host already proved.
|
||||
|
||||
@@ -93,7 +93,8 @@ function createSupervisor(
|
||||
resumeCredentialVersion: credential.version,
|
||||
resumeConfirmReqId: confirmReqId,
|
||||
deviceToken: host.deviceToken,
|
||||
desktopPublicKeyB64: host.publicKeyB64
|
||||
desktopPublicKeyB64: host.publicKeyB64,
|
||||
onLog
|
||||
}),
|
||||
resolveRelay: resolveMobileRelayEndpoint,
|
||||
readBundle: readMobileRelayCredentialBundle,
|
||||
|
||||
@@ -234,8 +234,10 @@ describe('mobile endpoint supervisor', () => {
|
||||
.fn()
|
||||
.mockReturnValueOnce(new FakeRelaySession('connected', new RelayOuterError(4429)))
|
||||
.mockImplementation(() => new FakeRelaySession('connected'))
|
||||
const onLog = vi.fn()
|
||||
const deps = dependencies({
|
||||
openRelay,
|
||||
onLog,
|
||||
randomBytes: () => new Uint8Array([128, 0])
|
||||
})
|
||||
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
|
||||
@@ -245,6 +247,13 @@ describe('mobile endpoint supervisor', () => {
|
||||
|
||||
expect(openRelay).toHaveBeenCalledOnce()
|
||||
expect(logical.getPendingPath()).toBe('relay')
|
||||
expect(onLog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
code: 'relay-session-failed',
|
||||
path: 'relay',
|
||||
message: 'Relay: active relay session failed'
|
||||
})
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(249)
|
||||
expect(openRelay).toHaveBeenCalledOnce()
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
|
||||
@@ -22,6 +22,11 @@ import * as recoveryPresentation from './mobile-relay-recovery-presentation'
|
||||
import type { StableLogicalRpcClient } from './stable-logical-rpc-client'
|
||||
import type { ForegroundNudgeReason, HostProfile } from './types'
|
||||
import { MobileRelayBackgroundGrace } from './mobile-relay-background-grace'
|
||||
import {
|
||||
logRelayConnected,
|
||||
logRelayCredentialUnavailable,
|
||||
logRelayDialFailure
|
||||
} from './mobile-relay-diagnostic-log'
|
||||
|
||||
export type { MobileEndpointSupervisorDependencies } from './mobile-endpoint-supervisor-contract'
|
||||
|
||||
@@ -98,7 +103,7 @@ export class MobileEndpointSupervisor {
|
||||
recordMigration: () => {
|
||||
this.relayRotationPending = false
|
||||
this.hysteresis.recordMigration(dependencies.now())
|
||||
this.logRelay('runtime channel migrated to relay')
|
||||
logRelayConnected(this.logRelay)
|
||||
},
|
||||
scheduleLease: (expiry) =>
|
||||
this.leaseRotation.scheduleFromLease(
|
||||
@@ -107,8 +112,7 @@ export class MobileEndpointSupervisor {
|
||||
scheduleDirectProbe: () => this.directProbe.schedule(),
|
||||
onBookkeepingError: (error) =>
|
||||
this.logRelay('relay bookkeeping failed after migration', error.message.slice(0, 80)),
|
||||
onDialFailure: (error) =>
|
||||
this.logRelay('relay dial failed', `${error.name}: ${String(error.message).slice(0, 80)}`)
|
||||
onDialFailure: (error) => logRelayDialFailure(this.logRelay, error)
|
||||
})
|
||||
this.directProbe = new DirectReturnProbe(dependencies, {
|
||||
hysteresis: this.hysteresis,
|
||||
@@ -166,7 +170,8 @@ export class MobileEndpointSupervisor {
|
||||
// Why: the direct client enters reconnecting after its first failed
|
||||
// dial and may never publish disconnected while its retry loop lives.
|
||||
recoveryPresentation.onActiveFailure(this.logical, this.relayReconnect, state, this.bundle)
|
||||
this.relayReconnect.handleStateFailure(this.logical, state)
|
||||
const relayFailure = this.relayReconnect.handleStateFailure(this.logical, state)
|
||||
logRelayDialFailure(this.logRelay, relayFailure, 'active-session')
|
||||
}
|
||||
})
|
||||
if (this.relayReconnect.needsRecovery(this.logical.getState())) {
|
||||
@@ -249,11 +254,7 @@ export class MobileEndpointSupervisor {
|
||||
this.logical.setRecoveryPath(null)
|
||||
// Why: "expired" vs "missing" separates a sleep-past-expiry phone
|
||||
// (needs re-pair or LAN) from a Keychain failure in field reports.
|
||||
this.logRelay(
|
||||
selection.bundle
|
||||
? 'relay credential expired or rejected; slow reprobe armed'
|
||||
: 'no relay credential bundle; slow reprobe armed'
|
||||
)
|
||||
logRelayCredentialUnavailable(this.logRelay, selection.bundle !== null)
|
||||
this.relayReconnect.armCredentialReprobe()
|
||||
if (ownsRecovery) {
|
||||
// Why: no dial happened — keep the session and the intent; the reprobe
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { RelayRecoveryLog } from './mobile-relay-recovery-log'
|
||||
import { RelayDirectorHttpError } from './mobile-relay-resume-director'
|
||||
|
||||
export function logRelayConnected(log: RelayRecoveryLog): void {
|
||||
log('runtime channel migrated to relay', undefined, {
|
||||
level: 'success',
|
||||
code: 'relay-connected'
|
||||
})
|
||||
}
|
||||
|
||||
export function logRelayDialFailure(
|
||||
log: RelayRecoveryLog,
|
||||
error: Error | null,
|
||||
source: 'dial' | 'active-session' = 'dial'
|
||||
): void {
|
||||
if (!error) {
|
||||
return
|
||||
}
|
||||
const base = `${error.name}: ${String(error.message).slice(0, 80)}`
|
||||
const detail =
|
||||
error instanceof RelayDirectorHttpError && error.retryAfterMs != null
|
||||
? `${base}; retry-after=${error.retryAfterMs}ms`
|
||||
: base
|
||||
log(source === 'dial' ? 'relay dial failed' : 'active relay session failed', detail, {
|
||||
level: 'error',
|
||||
code: source === 'dial' ? 'relay-dial-failed' : 'relay-session-failed'
|
||||
})
|
||||
}
|
||||
|
||||
export function logRelayCredentialUnavailable(log: RelayRecoveryLog, hasBundle: boolean): void {
|
||||
log(
|
||||
hasBundle
|
||||
? 'relay credential expired or rejected; slow reprobe armed'
|
||||
: 'no relay credential bundle; slow reprobe armed',
|
||||
undefined,
|
||||
{ level: 'warn', code: 'relay-credential-unavailable' }
|
||||
)
|
||||
}
|
||||
@@ -46,9 +46,7 @@ export class RelayReconnectController {
|
||||
this.credentials = new RelayCredentialEligibility(dependencies.now)
|
||||
}
|
||||
|
||||
getFailureCount(): number {
|
||||
return this.failureCount.current()
|
||||
}
|
||||
getFailureCount = (): number => this.failureCount.current()
|
||||
|
||||
reportRecoveryTo(logical: StableLogicalRpcClient): void {
|
||||
this.failureCount.reportTo(logical.setRecoveryAttempt)
|
||||
@@ -97,17 +95,17 @@ export class RelayReconnectController {
|
||||
return 'recover'
|
||||
}
|
||||
|
||||
handleStateFailure(logical: StableLogicalRpcClient, state: ConnectionState): void {
|
||||
handleStateFailure(logical: StableLogicalRpcClient, state: ConnectionState): Error | null {
|
||||
if (!this.needsRecovery(state)) {
|
||||
return
|
||||
return null
|
||||
}
|
||||
this.registerActiveFailure(logical)
|
||||
const failure = this.registerActiveFailure(logical)
|
||||
this.onRetry()
|
||||
return failure
|
||||
}
|
||||
|
||||
needsRecovery(state: ConnectionState): boolean {
|
||||
return state !== 'connected' && state !== 'connecting' && state !== 'handshaking'
|
||||
}
|
||||
needsRecovery = (state: ConnectionState): boolean =>
|
||||
state !== 'connected' && state !== 'connecting' && state !== 'handshaking'
|
||||
|
||||
suspendActiveRelay(logical: StableLogicalRpcClient): void {
|
||||
if (logical.getActivePath() !== 'relay') {
|
||||
@@ -207,9 +205,9 @@ export class RelayReconnectController {
|
||||
|
||||
recordRejectedCredential = (version: number): void => this.credentials.recordRejected(version)
|
||||
|
||||
registerActiveFailure(logical: StableLogicalRpcClient): void {
|
||||
registerActiveFailure(logical: StableLogicalRpcClient): Error | null {
|
||||
if (logical.getActivePath() !== 'relay') {
|
||||
return
|
||||
return null
|
||||
}
|
||||
const failure = this.activeSession?.getFailure()
|
||||
this.activeSession = null
|
||||
@@ -219,6 +217,7 @@ export class RelayReconnectController {
|
||||
} else {
|
||||
this.activeRelayConnectedAt = null
|
||||
}
|
||||
return failure ?? null
|
||||
}
|
||||
|
||||
// True when the caller is still inside the cooldown window and must not
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createRelayRecoveryLog } from './mobile-relay-recovery-log'
|
||||
import type { ConnectionLogEntry } from './types'
|
||||
|
||||
describe('createRelayRecoveryLog', () => {
|
||||
it('uses distinct ids across logger instances', () => {
|
||||
const entries: ConnectionLogEntry[] = []
|
||||
const sink = vi.fn((entry: ConnectionLogEntry) => entries.push(entry))
|
||||
createRelayRecoveryLog(() => 1, sink)('first')
|
||||
createRelayRecoveryLog(() => 1, sink)('second')
|
||||
|
||||
expect(entries[0]?.id).not.toBe(entries[1]?.id)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,12 @@
|
||||
import type { ConnectionLogSink } from './types'
|
||||
import type { ConnectionDiagnosticCode, ConnectionLogLevel, ConnectionLogSink } from './types'
|
||||
|
||||
export type RelayRecoveryLog = (message: string, detail?: string) => void
|
||||
export type RelayRecoveryLog = (
|
||||
message: string,
|
||||
detail?: string,
|
||||
evidence?: { level?: ConnectionLogLevel; code?: ConnectionDiagnosticCode }
|
||||
) => void
|
||||
|
||||
let relayLoggerInstanceSequence = 0
|
||||
|
||||
// Why: relay recovery failed silently in production for weeks; every decision
|
||||
// must reach logcat and the in-app connection log.
|
||||
@@ -9,12 +15,15 @@ export function createRelayRecoveryLog(
|
||||
onLog?: ConnectionLogSink
|
||||
): RelayRecoveryLog {
|
||||
let sequence = 0
|
||||
return (message, detail) => {
|
||||
const instanceId = `${Date.now().toString(36)}-${(++relayLoggerInstanceSequence).toString(36)}`
|
||||
return (message, detail, evidence) => {
|
||||
console.log(`[relay] ${message}`, detail ?? '')
|
||||
onLog?.({
|
||||
id: `relay-${++sequence}`,
|
||||
id: `relay-${instanceId}-${++sequence}`,
|
||||
ts: now(),
|
||||
level: 'info',
|
||||
level: evidence?.level ?? 'info',
|
||||
path: 'relay',
|
||||
...(evidence?.code ? { code: evidence.code } : {}),
|
||||
message: `Relay: ${message}`,
|
||||
...(detail ? { detail } : {})
|
||||
})
|
||||
|
||||
@@ -21,6 +21,7 @@ vi.mock('./mobile-relay-e2ee-link', () => ({
|
||||
}))
|
||||
|
||||
import { connectMobileRelayRpcSession } from './mobile-relay-rpc-session'
|
||||
import type { ConnectionLogSink } from './types'
|
||||
|
||||
const relay = {
|
||||
v: 1 as const,
|
||||
@@ -31,7 +32,7 @@ const relay = {
|
||||
e2eeFraming: 2 as const
|
||||
}
|
||||
|
||||
async function authenticateSession() {
|
||||
async function authenticateSession(onLog?: ConnectionLogSink) {
|
||||
const session = connectMobileRelayRpcSession({
|
||||
relay,
|
||||
resumeToken: 'resume-secret',
|
||||
@@ -39,7 +40,8 @@ async function authenticateSession() {
|
||||
resumeConfirmReqId: 'confirm-1',
|
||||
deviceToken: 'device-token',
|
||||
desktopPublicKeyB64: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
|
||||
requestTimeoutMs: 30_000
|
||||
requestTimeoutMs: 30_000,
|
||||
onLog
|
||||
})
|
||||
fakes.linkOptions!.onHello({
|
||||
type: 'relay-hello',
|
||||
@@ -103,7 +105,8 @@ describe('mobile relay RPC session liveness', () => {
|
||||
})
|
||||
|
||||
it('disconnects after two fair foreground misses', async () => {
|
||||
const session = await authenticateSession()
|
||||
const onLog = vi.fn<ConnectionLogSink>()
|
||||
const session = await authenticateSession(onLog)
|
||||
|
||||
session.notifyForeground('focus')
|
||||
expect(sentRequests().map(({ method }) => method)).toEqual(['status.get'])
|
||||
@@ -114,6 +117,38 @@ describe('mobile relay RPC session liveness', () => {
|
||||
|
||||
expect(session.getState()).toBe('disconnected')
|
||||
expect(fakes.close).toHaveBeenCalledOnce()
|
||||
expect(onLog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
code: 'liveness-timeout',
|
||||
path: 'relay',
|
||||
message: 'Relay health check failed',
|
||||
detail: expect.stringMatching(
|
||||
/^probe-timeout; 2\/2 probes missed; last authenticated activity \d+ms ago$/
|
||||
)
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('uses distinct liveness evidence IDs for sessions created in the same millisecond', async () => {
|
||||
vi.setSystemTime(1_000)
|
||||
const firstLog = vi.fn<ConnectionLogSink>()
|
||||
const first = await authenticateSession(firstLog)
|
||||
first.notifyForeground('focus')
|
||||
await vi.advanceTimersByTimeAsync(8_000)
|
||||
const firstId = firstLog.mock.calls[0]?.[0].id
|
||||
|
||||
vi.clearAllMocks()
|
||||
fakes.sendText.mockReturnValue(true)
|
||||
vi.setSystemTime(1_000)
|
||||
const secondLog = vi.fn<ConnectionLogSink>()
|
||||
const second = await authenticateSession(secondLog)
|
||||
second.notifyForeground('focus')
|
||||
await vi.advanceTimersByTimeAsync(8_000)
|
||||
const secondId = secondLog.mock.calls[0]?.[0].id
|
||||
|
||||
expect(firstId).toBeTruthy()
|
||||
expect(secondId).toBeTruthy()
|
||||
expect(secondId).not.toBe(firstId)
|
||||
})
|
||||
|
||||
it('rate-limits foreground sequences without suppressing a retry', async () => {
|
||||
|
||||
@@ -11,11 +11,12 @@ import { openRpcRequestBudget, resolvePostConnectRequestTimeout } from './rpc-re
|
||||
import { isRpcResponse } from './rpc-response-shape'
|
||||
import { RpcSessionLivenessWatchdog } from './rpc-session-liveness-watchdog'
|
||||
import type { RpcClient } from './rpc-client'
|
||||
import type { ConnectionState, RpcResponse } from './types'
|
||||
import type { ConnectionLogSink, ConnectionState, RpcResponse } from './types'
|
||||
|
||||
const RELAY_PROBE_TIMEOUT_MS = 4_000
|
||||
const RELAY_MISSED_PROBE_LIMIT = 2
|
||||
const RELAY_FOREGROUND_PROBE_MIN_INTERVAL_MS = 10_000
|
||||
let relayRpcSessionSequence = 0
|
||||
|
||||
type PendingRequest = {
|
||||
resolve: (response: RpcResponse) => void
|
||||
@@ -41,6 +42,7 @@ export function connectMobileRelayRpcSession(args: {
|
||||
desktopPublicKeyB64: string
|
||||
requestTimeoutMs?: number
|
||||
createSocket?: (url: string) => WebSocket
|
||||
onLog?: ConnectionLogSink
|
||||
}): MobileRelayRpcSession {
|
||||
const requestTimeoutMs = args.requestTimeoutMs ?? 30_000
|
||||
const pending = new Map<string, PendingRequest>()
|
||||
@@ -53,6 +55,8 @@ export function connectMobileRelayRpcSession(args: {
|
||||
let resumeConfirmation: DeviceResumeConfirmed | null = null
|
||||
let failure: Error | null = null
|
||||
let closed = false
|
||||
let logSequence = 0
|
||||
const logSessionId = `${Date.now().toString(36)}-${(++relayRpcSessionSequence).toString(36)}`
|
||||
const livenessIdentity = {}
|
||||
const streams = new MobileRelayRpcStreams({
|
||||
nextId,
|
||||
@@ -145,6 +149,17 @@ export function connectMobileRelayRpcSession(args: {
|
||||
voluntaryProbeMinIntervalMs: RELAY_FOREGROUND_PROBE_MIN_INTERVAL_MS,
|
||||
sendProbe: () =>
|
||||
state === 'connected' && sendFrame({ id: nextId(), method: 'status.get', params: undefined }),
|
||||
onTimeout: (evidence) => {
|
||||
args.onLog?.({
|
||||
id: `relay-liveness-${logSessionId}-${++logSequence}`,
|
||||
ts: Date.now(),
|
||||
level: 'error',
|
||||
code: 'liveness-timeout',
|
||||
path: 'relay',
|
||||
message: 'Relay health check failed',
|
||||
detail: `${evidence.reason}; ${evidence.missedProbes}/${evidence.missedProbeLimit} probes missed; last authenticated activity ${evidence.lastInboundAgeMs}ms ago`
|
||||
})
|
||||
},
|
||||
terminate: () => fail(new Error('relay session liveness timeout'))
|
||||
})
|
||||
return client
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ConnectionLogEntry } from './types'
|
||||
|
||||
vi.mock('@react-native-async-storage/async-storage', () => ({
|
||||
default: {
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
describe('persisted connection log store', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_000)
|
||||
vi.mocked(AsyncStorage.getItem).mockReset()
|
||||
vi.mocked(AsyncStorage.setItem).mockReset()
|
||||
vi.mocked(AsyncStorage.setItem).mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
it('keeps a new client-session boundary when a restart shares the prior timestamp', async () => {
|
||||
const stored: ConnectionLogEntry[] = [
|
||||
{
|
||||
id: 'client-session-1000',
|
||||
ts: 1_000,
|
||||
level: 'info',
|
||||
code: 'client-session-started',
|
||||
message: 'Mobile client session started'
|
||||
},
|
||||
{
|
||||
id: 'relay-failure',
|
||||
ts: 1_000,
|
||||
level: 'error',
|
||||
code: 'relay-session-failed',
|
||||
message: 'Relay: active relay session failed'
|
||||
}
|
||||
]
|
||||
vi.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify(stored))
|
||||
vi.resetModules()
|
||||
const { connectionLogStore, recordConnectionClientSessionStart } =
|
||||
await import('./persisted-connection-log-store')
|
||||
|
||||
recordConnectionClientSessionStart('host-a')
|
||||
await connectionLogStore.hydrate('host-a')
|
||||
|
||||
expect(connectionLogStore.get('host-a').map((entry) => entry.code)).toEqual([
|
||||
'client-session-started',
|
||||
'relay-session-failed',
|
||||
'client-session-started'
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import { createConnectionLogStore } from './connection-log-buffer'
|
||||
import type { ConnectionLogEntry } from './types'
|
||||
|
||||
const STORAGE_PREFIX = 'orca.mobile.connection-log.v1.'
|
||||
const clientSessionId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`
|
||||
const sessionStartedHosts = new Set<string>()
|
||||
|
||||
export const connectionLogStore = createConnectionLogStore(200, {
|
||||
async load(hostId) {
|
||||
const raw = await AsyncStorage.getItem(storageKey(hostId))
|
||||
if (!raw) {
|
||||
return []
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
return Array.isArray(parsed) ? parsed.filter(isConnectionLogEntry) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
},
|
||||
save(hostId, entries) {
|
||||
return AsyncStorage.setItem(storageKey(hostId), JSON.stringify(entries))
|
||||
}
|
||||
})
|
||||
|
||||
export function recordConnectionRevival(
|
||||
hostId: string,
|
||||
reason: 'app-resume' | 'network-change'
|
||||
): void {
|
||||
const now = Date.now()
|
||||
connectionLogStore.append(hostId, {
|
||||
id: `revival-${reason}-${now}`,
|
||||
ts: now,
|
||||
level: 'info',
|
||||
code: reason === 'app-resume' ? 'app-resumed' : 'network-changed',
|
||||
message: reason === 'app-resume' ? 'App returned to foreground' : 'Network changed',
|
||||
detail: 'Connection recovery notified'
|
||||
})
|
||||
}
|
||||
|
||||
export function recordConnectionClientSessionStart(hostId: string): void {
|
||||
if (sessionStartedHosts.has(hostId)) {
|
||||
return
|
||||
}
|
||||
sessionStartedHosts.add(hostId)
|
||||
const now = Date.now()
|
||||
connectionLogStore.append(hostId, {
|
||||
id: `client-session-${clientSessionId}-${now}`,
|
||||
ts: now,
|
||||
level: 'info',
|
||||
code: 'client-session-started',
|
||||
message: 'Mobile client session started'
|
||||
})
|
||||
}
|
||||
|
||||
function storageKey(hostId: string): string {
|
||||
return `${STORAGE_PREFIX}${encodeURIComponent(hostId)}`
|
||||
}
|
||||
|
||||
function isConnectionLogEntry(value: unknown): value is ConnectionLogEntry {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false
|
||||
}
|
||||
const entry = value as Partial<ConnectionLogEntry>
|
||||
return (
|
||||
typeof entry.id === 'string' &&
|
||||
typeof entry.ts === 'number' &&
|
||||
Number.isFinite(entry.ts) &&
|
||||
(entry.level === 'info' ||
|
||||
entry.level === 'success' ||
|
||||
entry.level === 'warn' ||
|
||||
entry.level === 'error') &&
|
||||
typeof entry.message === 'string' &&
|
||||
(entry.detail === undefined || typeof entry.detail === 'string')
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import type { RpcClientSocketFactory } from './rpc-client-socket-factory'
|
||||
import type { RpcClientSocketSession } from './rpc-client-socket-session'
|
||||
import type { RpcClientStreamRegistry } from './rpc-client-stream-registry'
|
||||
import { RpcSynthesizedCloseIndex } from './rpc-socket-close-evidence'
|
||||
import type { ConnectionLogEntry } from './types'
|
||||
|
||||
const UNAUTHORIZED_CLOSE_CODE = 4001
|
||||
|
||||
@@ -21,7 +22,11 @@ type SocketCloseControllerOptions = {
|
||||
getAuthenticationGeneration: () => number
|
||||
isIntentionallyClosed: () => boolean
|
||||
stopLiveness: (session: RpcClientSocketSession) => void
|
||||
emitWarning: (message: string, detail: string) => void
|
||||
emitWarning: (
|
||||
message: string,
|
||||
detail: string,
|
||||
evidence?: Pick<ConnectionLogEntry, 'code' | 'path'>
|
||||
) => void
|
||||
}
|
||||
|
||||
export class RpcClientSocketCloseController {
|
||||
@@ -80,7 +85,11 @@ export class RpcClientSocketCloseController {
|
||||
streamCount: this.options.streams.size(),
|
||||
attempt: this.options.reconnect.getAttempt()
|
||||
})
|
||||
this.options.emitWarning('WebSocket closed', 'Will attempt to reconnect')
|
||||
this.options.emitWarning(
|
||||
'WebSocket closed',
|
||||
`${closeCode == null ? 'Close code unavailable' : `Close code ${closeCode}`}; reconnect scheduled`,
|
||||
{ code: 'socket-closed' }
|
||||
)
|
||||
this.options.requests.rejectAll('Connection interrupted', { deliveryUnknown: true })
|
||||
this.options.connectionState.publish('reconnecting')
|
||||
this.options.reconnect.schedule()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { publicKeyFromBase64 } from './e2ee'
|
||||
import { RpcClientSocketSession } from './rpc-client-socket-session'
|
||||
import { redactSocketEndpoint } from './socket-event-debug'
|
||||
import type { ConnectionLogLevel, ConnectionState, RpcResponse } from './types'
|
||||
import type { ConnectionLogEmitter, ConnectionState, RpcResponse } from './types'
|
||||
|
||||
type SocketFactoryOptions = {
|
||||
endpoint: string
|
||||
@@ -12,7 +12,7 @@ type SocketFactoryOptions = {
|
||||
getReconnectAttempt: () => number
|
||||
getLastConnectedAt: () => number | null
|
||||
isIntentionallyClosed: () => boolean
|
||||
emitLog: (level: ConnectionLogLevel, message: string, detail?: string) => void
|
||||
emitLog: ConnectionLogEmitter
|
||||
onHandshakeStarted: () => void
|
||||
onAuthenticated: (session: RpcClientSocketSession) => void
|
||||
onAuthRejected: (reason: string) => void
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import { isRpcResponse } from './rpc-response-shape'
|
||||
import { isStaleRpcSocketEvent, logRpcSocketClose } from './rpc-socket-close-evidence'
|
||||
import { describeSocketEvent, redactSocketEndpoint } from './socket-event-debug'
|
||||
import type { ConnectionLogLevel, ConnectionState, RpcResponse } from './types'
|
||||
import type { ConnectionLogEmitter, ConnectionState, RpcResponse } from './types'
|
||||
import { websocketPayloadToUint8 } from './websocket-payload-bytes'
|
||||
|
||||
const CONNECT_TIMEOUT_MS = 12_000
|
||||
@@ -24,7 +24,7 @@ type SocketSessionOptions = {
|
||||
getState: () => ConnectionState
|
||||
getReconnectAttempt: () => number
|
||||
isIntentionallyClosed: () => boolean
|
||||
emitLog: (level: ConnectionLogLevel, message: string, detail?: string) => void
|
||||
emitLog: ConnectionLogEmitter
|
||||
onHandshakeStarted: () => void
|
||||
onAuthenticated: (session: RpcClientSocketSession) => void
|
||||
onAuthRejected: (reason: string) => void
|
||||
@@ -261,7 +261,8 @@ export class RpcClientSocketSession {
|
||||
this.options.emitLog(
|
||||
'error',
|
||||
'WebSocket connect timeout',
|
||||
`No TCP/WS handshake within ${CONNECT_TIMEOUT_MS / 1000}s — endpoint unreachable?`
|
||||
`No TCP/WS handshake within ${CONNECT_TIMEOUT_MS / 1000}s — endpoint unreachable?`,
|
||||
{ code: 'connect-timeout' }
|
||||
)
|
||||
this.options.onForcedClose(this)
|
||||
}
|
||||
@@ -280,7 +281,8 @@ export class RpcClientSocketSession {
|
||||
this.options.emitLog(
|
||||
'error',
|
||||
'Handshake timeout',
|
||||
`No e2ee_ready/e2ee_authenticated within ${HANDSHAKE_TIMEOUT_MS / 1000}s`
|
||||
`No e2ee_ready/e2ee_authenticated within ${HANDSHAKE_TIMEOUT_MS / 1000}s`,
|
||||
{ code: 'handshake-timeout' }
|
||||
)
|
||||
this.options.onForcedClose(this)
|
||||
}, HANDSHAKE_TIMEOUT_MS)
|
||||
|
||||
@@ -46,6 +46,37 @@ describe('RpcSessionLivenessWatchdog', () => {
|
||||
expect(terminate).toHaveBeenCalledWith(identity)
|
||||
})
|
||||
|
||||
it('reports causal evidence before terminating a stale session', async () => {
|
||||
const onTimeout = vi.fn()
|
||||
const terminate = vi.fn()
|
||||
const identity = {}
|
||||
const watchdog = new RpcSessionLivenessWatchdog({
|
||||
transport: 'relay',
|
||||
idleProbeMs: null,
|
||||
probeTimeoutMs: 4_000,
|
||||
missedProbeLimit: 2,
|
||||
sendProbe: () => true,
|
||||
terminate,
|
||||
onTimeout,
|
||||
now: Date.now
|
||||
})
|
||||
watchdog.start(identity)
|
||||
watchdog.probeNow(identity)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(8_000)
|
||||
|
||||
expect(onTimeout).toHaveBeenCalledWith({
|
||||
transport: 'relay',
|
||||
reason: 'probe-timeout',
|
||||
missedProbes: 2,
|
||||
missedProbeLimit: 2,
|
||||
lastInboundAgeMs: 8_000
|
||||
})
|
||||
expect(onTimeout.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
terminate.mock.invocationCallOrder[0]!
|
||||
)
|
||||
})
|
||||
|
||||
it('authenticated activity resets suspicion', async () => {
|
||||
const { identity, terminate, watchdog } = fixture()
|
||||
watchdog.probeNow(identity)
|
||||
|
||||
@@ -8,6 +8,7 @@ type WatchdogOptions = {
|
||||
transport: 'direct' | 'relay'
|
||||
sendProbe: (identity: RpcSessionIdentity) => boolean
|
||||
terminate: (identity: RpcSessionIdentity) => void
|
||||
onTimeout?: (evidence: LivenessTimeoutEvidence) => void
|
||||
idleProbeMs?: number | null
|
||||
probeTimeoutMs?: number
|
||||
missedProbeLimit?: number
|
||||
@@ -17,6 +18,14 @@ type WatchdogOptions = {
|
||||
clearTimer?: typeof clearTimeout
|
||||
}
|
||||
|
||||
export type LivenessTimeoutEvidence = {
|
||||
transport: 'direct' | 'relay'
|
||||
reason: 'probe-send-failed' | 'probe-timeout'
|
||||
missedProbes: number
|
||||
missedProbeLimit: number
|
||||
lastInboundAgeMs: number
|
||||
}
|
||||
|
||||
export class RpcSessionLivenessWatchdog {
|
||||
private identity: RpcSessionIdentity | null = null
|
||||
private timer: ReturnType<typeof setTimeout> | null = null
|
||||
@@ -138,7 +147,7 @@ export class RpcSessionLivenessWatchdog {
|
||||
sent = false
|
||||
}
|
||||
if (!sent) {
|
||||
this.terminateCurrent(identity)
|
||||
this.terminateCurrent(identity, 'probe-send-failed')
|
||||
return
|
||||
}
|
||||
this.timer = this.setTimer(() => this.handleProbeTimeout(identity, sentAt), this.probeTimeoutMs)
|
||||
@@ -161,7 +170,7 @@ export class RpcSessionLivenessWatchdog {
|
||||
}
|
||||
this.missedProbes += 1
|
||||
if (this.missedProbes >= this.missedProbeLimit) {
|
||||
this.terminateCurrent(identity)
|
||||
this.terminateCurrent(identity, 'probe-timeout')
|
||||
return
|
||||
}
|
||||
console.log('[net] activity-probe timeout tolerated', {
|
||||
@@ -172,7 +181,10 @@ export class RpcSessionLivenessWatchdog {
|
||||
this.startProbe(identity)
|
||||
}
|
||||
|
||||
private terminateCurrent(identity: RpcSessionIdentity): void {
|
||||
private terminateCurrent(
|
||||
identity: RpcSessionIdentity,
|
||||
reason: LivenessTimeoutEvidence['reason']
|
||||
): void {
|
||||
if (this.identity !== identity) {
|
||||
return
|
||||
}
|
||||
@@ -184,6 +196,13 @@ export class RpcSessionLivenessWatchdog {
|
||||
missedProbes: this.missedProbes,
|
||||
missedProbeLimit: this.missedProbeLimit
|
||||
})
|
||||
this.options.onTimeout?.({
|
||||
transport: this.options.transport,
|
||||
reason,
|
||||
missedProbes: this.missedProbes,
|
||||
missedProbeLimit: this.missedProbeLimit,
|
||||
lastInboundAgeMs: Math.max(0, this.now() - this.lastInboundAt)
|
||||
})
|
||||
this.options.terminate(identity)
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,25 @@ export type RpcResponse = RpcSuccess | RpcFailure
|
||||
|
||||
export type ConnectionLogLevel = 'info' | 'success' | 'warn' | 'error'
|
||||
|
||||
export type MobileConnectionDiagnosticPath = 'lan' | 'tailscale' | 'relay'
|
||||
|
||||
export type ConnectionDiagnosticCode =
|
||||
| 'client-session-started'
|
||||
| 'app-resumed'
|
||||
| 'network-changed'
|
||||
| 'connect-timeout'
|
||||
| 'handshake-timeout'
|
||||
| 'authentication-rejected'
|
||||
| 'socket-closed'
|
||||
| 'liveness-timeout'
|
||||
| 'retry-scheduled'
|
||||
| 'relay-dial-failed'
|
||||
| 'relay-session-failed'
|
||||
| 'relay-connected'
|
||||
| 'direct-connected'
|
||||
| 'relay-credential-unavailable'
|
||||
| 'host-open-failed'
|
||||
|
||||
export type ConnectionLogEntry = {
|
||||
id: string
|
||||
ts: number
|
||||
@@ -47,10 +66,19 @@ export type ConnectionLogEntry = {
|
||||
message: string
|
||||
// Optional second line for endpoint/error/elapsed detail.
|
||||
detail?: string
|
||||
code?: ConnectionDiagnosticCode
|
||||
path?: MobileConnectionDiagnosticPath
|
||||
}
|
||||
|
||||
export type ConnectionLogSink = (entry: ConnectionLogEntry) => void
|
||||
|
||||
export type ConnectionLogEmitter = (
|
||||
level: ConnectionLogLevel,
|
||||
message: string,
|
||||
detail?: string,
|
||||
evidence?: Pick<ConnectionLogEntry, 'code' | 'path'>
|
||||
) => void
|
||||
|
||||
export type ConnectionState =
|
||||
| 'connecting'
|
||||
| 'handshaking'
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { RpcClient } from './rpc-client'
|
||||
import type { MobileConnectionPath } from './stable-logical-rpc-client'
|
||||
import type { ConnectionState } from './types'
|
||||
import { useRpcClientContext } from './client-context'
|
||||
import type { HostClientAcquisition } from './client-context'
|
||||
import type { HostClientAcquisition } from './host-client-acquisition-registry'
|
||||
|
||||
type UseAllHostClientsOptions = {
|
||||
autoConnectHostIds?: readonly string[]
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ExecutionHostId } from '../../../src/shared/execution-host'
|
||||
|
||||
// Locally-typed subset of the desktop status payload read from status.get.
|
||||
export type DesktopStatus = {
|
||||
appVersion?: string
|
||||
protocolVersion?: number
|
||||
minCompatibleMobileVersion?: number
|
||||
// Why: absent on hosts that predate the mobile Floating Workspace entry;
|
||||
|
||||
Generated
+9
-9
@@ -9,7 +9,7 @@ overrides:
|
||||
|
||||
patchedDependencies:
|
||||
'@vscode/windows-process-tree@0.8.0':
|
||||
hash: 73a90530e6b95c05dac50f2c48787fb51cb292587773016ebe57eb05e41075a0
|
||||
hash: 946ffb2b1026bcb554d9920681fb00f546c28fc5de6d95a877d198b49bf73988
|
||||
path: config/patches/@vscode__windows-process-tree@0.8.0.patch
|
||||
'@xterm/addon-ligatures@0.11.0-beta.287':
|
||||
hash: 47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920
|
||||
@@ -18,7 +18,7 @@ patchedDependencies:
|
||||
hash: af3b156143ed2ee9903b753145b63dd4a2ee72cadd7ef333ab0f4d5d3ebfc32c
|
||||
path: config/patches/@xterm__addon-serialize@0.15.0-beta.287.patch
|
||||
'@xterm/addon-webgl@0.20.0-beta.286':
|
||||
hash: 6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258
|
||||
hash: 118f649459ba56a51627d46603e2d5d81d837ac9c4dc393f5cc20263d0ac1b91
|
||||
path: config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch
|
||||
'@xterm/xterm@6.1.0-beta.287':
|
||||
hash: 46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7
|
||||
@@ -27,7 +27,7 @@ patchedDependencies:
|
||||
hash: 7333b3837f80a7fbd045964db6d76ba4fc118e49134bdbabb00585b6b7b60673
|
||||
path: config/patches/lint-staged@16.4.0.patch
|
||||
node-pty@1.1.0:
|
||||
hash: 9c665d9e41571341cb8986607d531b953a7f32c0dd60e1e74e633c098d80f2c1
|
||||
hash: 9a2eedbf2448b8ff1387a8a740ee5e4e968bf8484d7d80e12a3f6a2dc26b0e17
|
||||
path: config/patches/node-pty@1.1.0.patch
|
||||
|
||||
importers:
|
||||
@@ -69,7 +69,7 @@ importers:
|
||||
version: 3.3.1
|
||||
node-pty:
|
||||
specifier: ^1.1.0
|
||||
version: 1.1.0(patch_hash=9c665d9e41571341cb8986607d531b953a7f32c0dd60e1e74e633c098d80f2c1)
|
||||
version: 1.1.0(patch_hash=9a2eedbf2448b8ff1387a8a740ee5e4e968bf8484d7d80e12a3f6a2dc26b0e17)
|
||||
posthog-node:
|
||||
specifier: ^5.33.3
|
||||
version: 5.33.3
|
||||
@@ -229,7 +229,7 @@ importers:
|
||||
version: 0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))
|
||||
'@xterm/addon-webgl':
|
||||
specifier: 0.20.0-beta.286
|
||||
version: 0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))
|
||||
version: 0.20.0-beta.286(patch_hash=118f649459ba56a51627d46603e2d5d81d837ac9c4dc393f5cc20263d0ac1b91)(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))
|
||||
'@xterm/xterm':
|
||||
specifier: 6.1.0-beta.287
|
||||
version: 6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7)
|
||||
@@ -407,7 +407,7 @@ importers:
|
||||
optionalDependencies:
|
||||
'@vscode/windows-process-tree':
|
||||
specifier: 0.8.0
|
||||
version: 0.8.0(patch_hash=73a90530e6b95c05dac50f2c48787fb51cb292587773016ebe57eb05e41075a0)
|
||||
version: 0.8.0(patch_hash=946ffb2b1026bcb554d9920681fb00f546c28fc5de6d95a877d198b49bf73988)
|
||||
sherpa-onnx-darwin-arm64:
|
||||
specifier: 1.12.37
|
||||
version: 1.12.37
|
||||
@@ -9605,7 +9605,7 @@ snapshots:
|
||||
convert-source-map: 2.0.0
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
'@vscode/windows-process-tree@0.8.0(patch_hash=73a90530e6b95c05dac50f2c48787fb51cb292587773016ebe57eb05e41075a0)':
|
||||
'@vscode/windows-process-tree@0.8.0(patch_hash=946ffb2b1026bcb554d9920681fb00f546c28fc5de6d95a877d198b49bf73988)':
|
||||
dependencies:
|
||||
node-addon-api: 7.1.0
|
||||
optional: true
|
||||
@@ -9638,7 +9638,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7)
|
||||
|
||||
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))':
|
||||
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=118f649459ba56a51627d46603e2d5d81d837ac9c4dc393f5cc20263d0ac1b91)(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7)
|
||||
|
||||
@@ -12046,7 +12046,7 @@ snapshots:
|
||||
|
||||
node-int64@0.4.0: {}
|
||||
|
||||
node-pty@1.1.0(patch_hash=9c665d9e41571341cb8986607d531b953a7f32c0dd60e1e74e633c098d80f2c1):
|
||||
node-pty@1.1.0(patch_hash=9a2eedbf2448b8ff1387a8a740ee5e4e968bf8484d7d80e12a3f6a2dc26b0e17):
|
||||
dependencies:
|
||||
node-addon-api: 7.1.1
|
||||
|
||||
|
||||
@@ -89,20 +89,20 @@ issues `adb shell input` events; AVD names resolve to running adb serials.
|
||||
Use `--json` for agent-friendly output. Coordinates are **normalized 0..1**
|
||||
(top-left origin) — never pixels; Orca converts using the live screen size.
|
||||
|
||||
| Goal | Command | Notes |
|
||||
|----------------------------|----------------------------------------------------------------|-------|
|
||||
| List devices + AVDs | `ORCA emulator devices --json` | Cross-platform; shows iOS + Android with a platform column, booted vs shutdown. |
|
||||
| Single tap | `ORCA emulator tap <x> <y> --device <serial>` | Normalized 0..1. Preferred for single taps. |
|
||||
| Swipe / gesture | `ORCA emulator gesture '<json>' --device <serial>` | adb approximates the path by its endpoints (start→end). |
|
||||
| Type text | `ORCA emulator type "user@example.com" --device <serial>` | US ASCII; spaces handled. No newlines. |
|
||||
| Hardware button | `ORCA emulator button back --device <serial>` | home, back, recents, power, volume_up, volume_down. |
|
||||
| Rotate | `ORCA emulator rotate landscape_left --device <serial>` | Sets user_rotation (disables auto-rotate). |
|
||||
| Install an APK | `ORCA emulator install ./app-debug.apk --reinstall --device <serial>` | `--reinstall` passes `-r`. |
|
||||
| Launch an app | `ORCA emulator launch com.acme.app --activity .MainActivity --device <serial>` | Omit `--activity` to launch the default LAUNCHER activity. |
|
||||
| Grant a permission | `ORCA emulator permissions grant com.acme.app android.permission.CAMERA --device <serial>` | grant / revoke / reset. |
|
||||
| Accessibility tree | `ORCA emulator ax --device <serial> --json` | `uiautomator dump` parsed to a node tree. |
|
||||
| Logcat (one-shot) | `ORCA emulator logcat --lines 200 --device <serial>` | Dumps recent lines; parsed to entries. |
|
||||
| Raw adb shell | `ORCA emulator exec --command "getprop ro.build.version.sdk" --device <serial>` | Runs `adb -s <serial> shell <command>`. |
|
||||
| Goal | Command | Notes |
|
||||
| ------------------- | ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------- |
|
||||
| List devices + AVDs | `ORCA emulator devices --json` | Cross-platform; shows iOS + Android with a platform column, booted vs shutdown. |
|
||||
| Single tap | `ORCA emulator tap <x> <y> --device <serial>` | Normalized 0..1. Preferred for single taps. |
|
||||
| Swipe / gesture | `ORCA emulator gesture '<json>' --device <serial>` | adb approximates the path by its endpoints (start→end). |
|
||||
| Type text | `ORCA emulator type "user@example.com" --device <serial>` | US ASCII; spaces handled. No newlines. |
|
||||
| Hardware button | `ORCA emulator button back --device <serial>` | home, back, recents, power, volume_up, volume_down. |
|
||||
| Rotate | `ORCA emulator rotate landscape_left --device <serial>` | Sets user_rotation (disables auto-rotate). |
|
||||
| Install an APK | `ORCA emulator install ./app-debug.apk --reinstall --device <serial>` | `--reinstall` passes `-r`. |
|
||||
| Launch an app | `ORCA emulator launch com.acme.app --activity .MainActivity --device <serial>` | Omit `--activity` to launch the default LAUNCHER activity. |
|
||||
| Grant a permission | `ORCA emulator permissions grant com.acme.app android.permission.CAMERA --device <serial>` | grant / revoke / reset. |
|
||||
| Accessibility tree | `ORCA emulator ax --device <serial> --json` | `uiautomator dump` parsed to a node tree. |
|
||||
| Logcat (one-shot) | `ORCA emulator logcat --lines 200 --device <serial>` | Dumps recent lines; parsed to entries. |
|
||||
| Raw adb shell | `ORCA emulator exec --command "getprop ro.build.version.sdk" --device <serial>` | Runs `adb -s <serial> shell <command>`. |
|
||||
|
||||
## Critical gotchas (teach agents)
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ shell-neutral for POSIX shells, PowerShell, and cmd.exe.
|
||||
- The agent should use Orca's preview pane instead of external Simulator.app or raw serve-sim URLs.
|
||||
|
||||
**When NOT to use**
|
||||
|
||||
- Android emulators → use the `orca-emulator-android` skill (same `ORCA emulator` namespace, cross-platform via adb/emulator).
|
||||
- Building or installing the app itself → use `xcodebuild`, `xcrun simctl install`, `expo run:ios`, etc. (launch the app, then use `ORCA emulator` to drive it).
|
||||
- In-app debugging (state, network, views) → use the app's own tools or the browser pane if it's a webview.
|
||||
@@ -75,6 +76,7 @@ An active emulator "session" for the worktree is required for most commands. Use
|
||||
```
|
||||
|
||||
Orca owns:
|
||||
|
||||
- Starting/stopping the serve-sim helper (via --detach or direct).
|
||||
- Per-worktree "active" emulator (like active browser tab).
|
||||
- Explicit targeting with `--worktree`, `--device`, `--emulator <id>`.
|
||||
@@ -82,26 +84,26 @@ Orca owns:
|
||||
|
||||
Agents use the Orca executable chosen above (on PATH in Orca terminals) and never have to manage PIDs, state files in /tmp, or raw WS URLs themselves.
|
||||
|
||||
**For `pnpm dev` testing:** run `pnpm build:cli` first (rebuilds the CLI + ensures the `orca-dev` shim points at *this* worktree). Then inside the dev app use `orca-dev emulator ...` (or the direct `./config/scripts/orca-dev.mjs emulator ...` from the repo root). The orchestration preambles and dev launchers automatically select the dev command name so the CLI reaches your in-memory EmulatorBridge / runtime. Plain `orca` reaches a packaged install instead.
|
||||
**For `pnpm dev` testing:** run `pnpm build:cli` first (rebuilds the CLI + ensures the `orca-dev` shim points at _this_ worktree). Then inside the dev app use `orca-dev emulator ...` (or the direct `./config/scripts/orca-dev.mjs emulator ...` from the repo root). The orchestration preambles and dev launchers automatically select the dev command name so the CLI reaches your in-memory EmulatorBridge / runtime. Plain `orca` reaches a packaged install instead.
|
||||
|
||||
## Common operations
|
||||
|
||||
Use `--json` for agent-friendly output. Commands are workspace-scoped by default (current worktree's active emulator).
|
||||
|
||||
| Goal | Command | Notes |
|
||||
|-----------------------------|----------------------------------------------|-------|
|
||||
| List available / running | `ORCA emulator list [--worktree <sel>]` | Shows Orca-managed + raw serve-sim streams. Use output for explicit --device/--emulator. |
|
||||
| Attach / make active | `ORCA emulator attach "iPhone 16 Pro" [--worktree <sel>] [--focus]` | Starts helper if needed (serve-sim --detach). Sets active for unqualified commands. --focus optional (does not auto-steal UI focus by default). |
|
||||
| Single tap | `ORCA emulator tap <x> <y> [--device <id>]` | Normalized 0..1 coords. **Preferred over gesture for simple taps.** |
|
||||
| Multi-step gesture | `ORCA emulator gesture '<json>'` | See gestures reference (begin/move/end). Use tap for singles. |
|
||||
| Type text | `ORCA emulator type "text" [--device <id>]` | US ASCII only. Supports stdin/file via exec if needed. |
|
||||
| Hardware button | `ORCA emulator button home [--device <id>]` | home, swipe_home, app_switcher, lock, siri, side_button. |
|
||||
| Rotate device | `ORCA emulator rotate landscape_left` | Remembers orientation for subsequent gestures. |
|
||||
| Camera injection | `ORCA emulator camera com.acme.App --webcam` | Or --file, placeholder. Hot-swap with switch. May (re)launch app. |
|
||||
| Permissions | `ORCA emulator permissions grant camera com.acme.App` | grant/revoke/reset/list. See full subcommand help. |
|
||||
| Accessibility tree | `ORCA emulator ax [--device <id>]` | Raw serve-sim AX node tree (labels, roles, nested children, capped at 500 nodes; frames normalized 0..1 with top-left origin — tap an element at its frame center: x+width/2, y+height/2). Needs an active session. |
|
||||
| Raw / advanced | `ORCA emulator exec --command "tap 0.5 0.7"` | Or "ca-debug blended on", "memory-warning", full serve-sim subcommands (no "serve-sim" prefix needed in the command string). Bridge injects active device context. |
|
||||
| Stop | `ORCA emulator kill [--device <id>]` | Or let pane close / Orca quit clean up. |
|
||||
| Goal | Command | Notes |
|
||||
| ------------------------ | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| List available / running | `ORCA emulator list [--worktree <sel>]` | Shows Orca-managed + raw serve-sim streams. Use output for explicit --device/--emulator. |
|
||||
| Attach / make active | `ORCA emulator attach "iPhone 16 Pro" [--worktree <sel>] [--focus]` | Starts helper if needed (serve-sim --detach). Sets active for unqualified commands. --focus optional (does not auto-steal UI focus by default). |
|
||||
| Single tap | `ORCA emulator tap <x> <y> [--device <id>]` | Normalized 0..1 coords. **Preferred over gesture for simple taps.** |
|
||||
| Multi-step gesture | `ORCA emulator gesture '<json>'` | See gestures reference (begin/move/end). Use tap for singles. |
|
||||
| Type text | `ORCA emulator type "text" [--device <id>]` | US ASCII only. Supports stdin/file via exec if needed. |
|
||||
| Hardware button | `ORCA emulator button home [--device <id>]` | home, swipe_home, app_switcher, lock, siri, side_button. |
|
||||
| Rotate device | `ORCA emulator rotate landscape_left` | Remembers orientation for subsequent gestures. |
|
||||
| Camera injection | `ORCA emulator camera com.acme.App --webcam` | Or --file, placeholder. Hot-swap with switch. May (re)launch app. |
|
||||
| Permissions | `ORCA emulator permissions grant camera com.acme.App` | grant/revoke/reset/list. See full subcommand help. |
|
||||
| Accessibility tree | `ORCA emulator ax [--device <id>]` | Raw serve-sim AX node tree (labels, roles, nested children, capped at 500 nodes; frames normalized 0..1 with top-left origin — tap an element at its frame center: x+width/2, y+height/2). Needs an active session. |
|
||||
| Raw / advanced | `ORCA emulator exec --command "tap 0.5 0.7"` | Or "ca-debug blended on", "memory-warning", full serve-sim subcommands (no "serve-sim" prefix needed in the command string). Bridge injects active device context. |
|
||||
| Stop | `ORCA emulator kill [--device <id>]` | Or let pane close / Orca quit clean up. |
|
||||
|
||||
Most support `--worktree <selector>` and explicit `--device <udid|name>` or `--emulator <id>` (from list) for targeting.
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ a long time, or need the user at the keyboard. Never create an Orca workspace or
|
||||
- **Coding-agent CLI + account:** which agent runs in the VM (`codex`, `claude`, …) and that the user
|
||||
has an account for it — it gets logged in during the Phase-3 auth snapshot (§4).
|
||||
- **Git auth:** the token source for cloning a private repo (`GH_TOKEN`/`GITHUB_TOKEN` or `gh auth
|
||||
token`; §5).
|
||||
token`; §5).
|
||||
3. **Check prerequisites (§2)** — detect the provider CLI + auth and confirm the items above are in
|
||||
place before any paid step.
|
||||
4. **Scaffold scripts + state file** from §7 (worked Vercel example: §7f; SSH host: §7g; Docker SSH:
|
||||
@@ -135,6 +135,13 @@ shape is §7a; key points:
|
||||
- Use the VM image's package manager (`apt`/`dnf`/`apk`, per the base distro — not the provider brand).
|
||||
- Clone with the git token via `GIT_ASKPASS` (§5).
|
||||
- **Trap errors and remove the half-built sandbox** so a crash doesn't leave a paid resource running.
|
||||
- **Never snapshot a machine on which the Orca runtime has already run.** The first `orca serve` creates
|
||||
the runtime's user-data dir, and everything in it gets baked into the image and shared by every VM
|
||||
booted from it: the pairing keypair and device-token registry (`orca-devices.json`,
|
||||
`orca-e2ee-keypair.json`), `agent-session-authority.key`, and the build box's logs, terminal history
|
||||
and orchestration db. Confirmed: two VMs from one such snapshot emitted **identical `deviceToken` and
|
||||
`pairedDeviceId`**. Snapshot **before** the runtime has ever run, or `rm -rf` the whole user-data dir
|
||||
(`~/.config/orca` on Linux) first — deleting a named file list will drift as Orca adds state.
|
||||
- Snapshot the stopped sandbox, parse the snapshot id, and write it + scope/project/port/repo to state.
|
||||
|
||||
---
|
||||
@@ -163,6 +170,10 @@ their own terminal, or via the Claude Code harness bang-prefix (`! <cmd>`, with
|
||||
`!`). You scaffold/boot the sandbox and run steps 3–4, but **you cannot observe the interactive login
|
||||
finishing** — so **ask the user to tell you when it's done** before you verify and re-snapshot.
|
||||
|
||||
This layer inherits §3's rule: if you started `orca serve` on the base or auth sandbox to smoke-test it,
|
||||
delete the runtime's user-data dir (`~/.config/orca` on Linux) before re-snapshotting, or every workspace
|
||||
booted from this image shares one pairing identity and one `agent-session-authority.key`.
|
||||
|
||||
If the agent's credentials are short-lived, warn that the snapshot may need periodic re-auth (§10).
|
||||
|
||||
For disposable runtimes, do **not** treat a host agent config directory (for example `~/.codex`) as the
|
||||
@@ -221,9 +232,9 @@ reserve stdout for the final JSON and log progress to stderr. Include a shared `
|
||||
|
||||
- **Local-side** (`create`/`suspend`/`resume`/`destroy` + the base-snapshot/auth scripts the user
|
||||
invokes) runs **on the user's desktop**, so it must run on their OS. macOS/Linux: `#!/usr/bin/env
|
||||
bash`, `set -euo pipefail`, quoted paths. **Windows:** a bare `.sh` won't run — scaffold `.ps1`/`.cmd`
|
||||
bash`, `set -euo pipefail`, quoted paths. **Windows:** a bare `.sh` won't run — scaffold `.ps1`/`.cmd`
|
||||
or require WSL/Git-Bash and point `orca.yaml` at the right launcher.
|
||||
- **Remote-side** (commands you `exec` *inside* the Linux VM) always runs in the VM's Linux shell, so
|
||||
- **Remote-side** (commands you `exec` _inside_ the Linux VM) always runs in the VM's Linux shell, so
|
||||
bash is fine there regardless of the user's OS.
|
||||
|
||||
### 7a. Base-snapshot (`<provider>-base-snapshot.sh`) — Phase 2
|
||||
@@ -303,7 +314,11 @@ There is **no `--host` flag**. `--project-root` must be an absolute directory on
|
||||
keeps serving:
|
||||
|
||||
```json
|
||||
{ "schemaVersion": 1, "pairingCode": "<orca pairing URL>", "projectRoot": "<the --project-root you passed>" }
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"pairingCode": "<orca pairing URL>",
|
||||
"projectRoot": "<the --project-root you passed>"
|
||||
}
|
||||
```
|
||||
|
||||
`pairingCode` is the pairing URL, already pointing at whatever you passed as `--pairing-address` — so set
|
||||
@@ -498,7 +513,7 @@ git checkout -B "$ORCA_REPO_BRANCH" "$ORCA_REPO_REF_HEAD"
|
||||
|
||||
Fail if the requested schema is not `2`; do not silently fall back to the ordinary recipe shape.
|
||||
|
||||
**Networking → which `target` fields to set** (how *your desktop* reaches the box — there is no
|
||||
**Networking → which `target` fields to set** (how _your desktop_ reaches the box — there is no
|
||||
`orca serve` URL in SSH mode):
|
||||
|
||||
- Public IP / DNS, or a Tailscale/VPN address → `host`; SSH port → `port` (usually 22).
|
||||
@@ -511,7 +526,7 @@ Fail if the requested schema is not `2`; do not silently fall back to the ordina
|
||||
reconnect grace window.
|
||||
|
||||
**Toolchain & agent auth on a persistent (no-snapshot) host — do this ONCE, by hand, before wiring the
|
||||
recipe** (there's no base image to bake; the host *is* the base). Run the §7f Phase-2 install steps and
|
||||
recipe** (there's no base image to bake; the host _is_ the base). Run the §7f Phase-2 install steps and
|
||||
the §7f Phase-3 `<agent> login --device-auth` **directly over SSH on the host** (interactive, e.g.
|
||||
`ssh -t user@host '<agent> login --device-auth'`). After that the host stays ready across workspaces.
|
||||
|
||||
@@ -615,7 +630,7 @@ $ErrorActionPreference = 'Stop'
|
||||
# progress/errors → Write-Error / the error stream, never stdout.
|
||||
```
|
||||
|
||||
The remote-side commands you run *inside* the Linux VM stay bash regardless of the desktop OS.
|
||||
The remote-side commands you run _inside_ the Linux VM stay bash regardless of the desktop OS.
|
||||
|
||||
---
|
||||
|
||||
@@ -702,10 +717,10 @@ each stage so you can self-diagnose without asking the user to relay logs:
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"checks": [ { "id": "recipe.provision", "status": "fail", "message": "…" } ],
|
||||
"checks": [{ "id": "recipe.provision", "status": "fail", "message": "…" }],
|
||||
"provisionTranscript": {
|
||||
"provision": { "exitCode": 0, "signal": null, "stdout": "…", "stderr": "…", "parseError": "…" },
|
||||
"destroy": { "exitCode": 0, "signal": null, "stdout": "…", "stderr": "…" }
|
||||
"destroy": { "exitCode": 0, "signal": null, "stdout": "…", "stderr": "…" }
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -743,7 +758,7 @@ startup-only `docker run` before the full clone/install path.
|
||||
- **Agent verified as "not logged in" despite a good login.** `codex login status` (and similar) print
|
||||
"Logged in …" to **stderr**; an stdout-only `grep` misses it. Prefer the status **exit code**; if you
|
||||
grep, fold stderr first (`status 2>&1 | grep …`) and match the exact success line — not `grep -qi
|
||||
'logged in'`, which also matches "not logged in".
|
||||
'logged in'`, which also matches "not logged in".
|
||||
- **Headless agent login hangs.** Plain OAuth `login` starts a loopback callback server on a VM/container
|
||||
port the host browser can't reach. Use the **device-auth** flow (`login --device-auth`) — it prints a
|
||||
URL + code the user opens on the host.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -2,9 +2,14 @@ function __orca_shell_ready_marker --on-event fish_prompt
|
||||
builtin printf "\033]777;orca-shell-ready\007"
|
||||
functions -e __orca_shell_ready_marker
|
||||
end
|
||||
if test -x "$ORCA_CODEX_LAUNCH_PREFLIGHT"; and test (type -t codex 2>/dev/null) = file
|
||||
# Why captured: an unquoted (type -t codex) expands to zero words when codex is
|
||||
# absent, leaving "test = file" — fish then errors instead of failing closed.
|
||||
# Quoting in place is not the fix; fish never substitutes inside double quotes.
|
||||
set -l __orca_codex_type (type -t codex 2>/dev/null)
|
||||
if test -x "$ORCA_CODEX_LAUNCH_PREFLIGHT"; and test "$__orca_codex_type" = file
|
||||
function codex
|
||||
command "$ORCA_CODEX_LAUNCH_PREFLIGHT" agent hooks prepare-codex >/dev/null 2>&1; or true
|
||||
command codex $argv
|
||||
end
|
||||
end
|
||||
end
|
||||
set -e __orca_codex_type
|
||||
@@ -2,9 +2,14 @@ function __orca_shell_ready_marker --on-event fish_prompt
|
||||
builtin printf "\033]777;orca-shell-ready\007"
|
||||
functions -e __orca_shell_ready_marker
|
||||
end
|
||||
if test -x "$ORCA_CODEX_LAUNCH_PREFLIGHT"; and test (type -t codex 2>/dev/null) = file
|
||||
# Why captured: an unquoted (type -t codex) expands to zero words when codex is
|
||||
# absent, leaving "test = file" — fish then errors instead of failing closed.
|
||||
# Quoting in place is not the fix; fish never substitutes inside double quotes.
|
||||
set -l __orca_codex_type (type -t codex 2>/dev/null)
|
||||
if test -x "$ORCA_CODEX_LAUNCH_PREFLIGHT"; and test "$__orca_codex_type" = file
|
||||
function codex
|
||||
command "$ORCA_CODEX_LAUNCH_PREFLIGHT" agent hooks prepare-codex >/dev/null 2>&1; or true
|
||||
command codex $argv
|
||||
end
|
||||
end
|
||||
end
|
||||
set -e __orca_codex_type
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user