Merge branch 'main' into brennanb2025/fix-run-binding-loss

This commit is contained in:
Brennan Benson
2026-08-28 11:04:08 -07:00
committed by GitHub
475 changed files with 29813 additions and 3743 deletions
-1
View File
@@ -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.
-1
View File
@@ -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
-1
View File
@@ -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
+20
View File
@@ -0,0 +1,20 @@
name: Node next compatibility
on:
schedule:
# Full future-runtime coverage is useful, but not worth doubling every PR matrix.
- cron: '0 10 * * *'
workflow_dispatch:
concurrency:
group: node-next-compat
cancel-in-progress: true
permissions:
contents: read
jobs:
test:
uses: ./.github/workflows/unit-tests.yml
with:
node_versions: '["26"]'
+6 -51
View File
@@ -444,16 +444,12 @@ jobs:
src/shared/posix-command-path-lookup.test.ts
# Cache-key input changes would otherwise make every shard compile the same
# native addon concurrently. Prime each Node ABI once before the matrix fans out.
# native addon concurrently. Prime the supported Node ABI before the matrix fans out.
test_native_cache:
name: prepare test native cache node ${{ matrix.node }}
name: prepare test native cache node 24
needs: [code_paths]
if: needs.code_paths.outputs.native_cache_changed == 'true'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node: ['24', '26']
steps:
- name: Checkout
@@ -464,58 +460,17 @@ jobs:
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: node
node-version: ${{ matrix.node }}
node-version: '24'
test:
name: tests node ${{ matrix.node }} ${{ matrix.shard }}/${{ matrix.shard_total }}
needs: [code_paths, test_native_cache]
if: >-
always() &&
needs.code_paths.outputs.test == 'true' &&
(needs.test_native_cache.result == 'success' || needs.test_native_cache.result == 'skipped')
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node: ['24', '26']
shard: [1, 2, 3, 4, 5, 6, 7, 8]
shard_total: [8]
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: node
node-version: ${{ matrix.node }}
- name: Install Electron package binary for tests
run: node config/scripts/install-electron-package-binary.mjs
- name: Test shard
run: |
pnpm exec vitest run --config config/vitest.config.ts \
--exclude=src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts \
--exclude=src/main/daemon/shell-ready.test.ts \
--exclude=src/main/daemon/node-pty-fd-leak.test.ts \
--exclude=src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts \
--exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \
--exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \
--exclude=src/main/shell-startup-feature-channel.test.ts \
--exclude=src/main/terminal-history-fish-session.node-pty.test.ts \
--exclude=src/main/zsh-scoped-histfile.live-shell.test.ts \
--exclude=src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts \
--exclude=src/main/zsh-wrapper-version-mismatch.live-shell.test.ts \
--exclude=src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \
--exclude=src/shared/fish-query-reply-child-stdin.node-pty.test.ts \
--exclude=src/shared/pty-reply-echo-shapes.node-pty.test.ts \
--exclude=src/shared/startup-shell-portability.live-shell.test.ts \
--exclude=src/shared/posix-command-path-lookup.test.ts \
--exclude=tests/e2e/cross-version-wire/** \
--shard=${{ matrix.shard }}/${{ matrix.shard_total }}
uses: ./.github/workflows/unit-tests.yml
with:
node_versions: '["24"]'
# Why a separate job: the test needs a real Chrome, and the sharded `test` matrix
# would pay for it on every shard to run one file in whichever shard it landed in.
+1 -2
View File
@@ -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 }}
+34 -1
View File
@@ -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
+59
View File
@@ -0,0 +1,59 @@
name: Unit tests
on:
workflow_call:
inputs:
node_versions:
description: JSON array of Node.js major versions to test.
required: true
type: string
permissions:
contents: read
jobs:
test:
name: tests node ${{ matrix.node }} ${{ matrix.shard }}/${{ matrix.shard_total }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node: ${{ fromJSON(inputs.node_versions) }}
shard: [1, 2, 3, 4, 5, 6, 7, 8]
shard_total: [8]
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: node
node-version: ${{ matrix.node }}
- name: Install Electron package binary for tests
run: node config/scripts/install-electron-package-binary.mjs
- name: Test shard
run: |
pnpm exec vitest run --config config/vitest.config.ts \
--exclude=src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts \
--exclude=src/main/daemon/shell-ready.test.ts \
--exclude=src/main/daemon/node-pty-fd-leak.test.ts \
--exclude=src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts \
--exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \
--exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \
--exclude=src/main/shell-startup-feature-channel.test.ts \
--exclude=src/main/terminal-history-fish-session.node-pty.test.ts \
--exclude=src/main/zsh-scoped-histfile.live-shell.test.ts \
--exclude=src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts \
--exclude=src/main/zsh-wrapper-version-mismatch.live-shell.test.ts \
--exclude=src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \
--exclude=src/shared/fish-query-reply-child-stdin.node-pty.test.ts \
--exclude=src/shared/pty-reply-echo-shapes.node-pty.test.ts \
--exclude=src/shared/startup-shell-portability.live-shell.test.ts \
--exclude=src/shared/posix-command-path-lookup.test.ts \
--exclude=tests/e2e/cross-version-wire/** \
--shard=${{ matrix.shard }}/${{ matrix.shard_total }}
+5 -2
View File
@@ -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.
+1
View File
@@ -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
+9 -1
View File
@@ -2,13 +2,21 @@ diff --git a/binding.gyp b/binding.gyp
index 5f63978b07ab50aaf7523219a2170ec737a6b5db..bbd9e06136e8922f40b5779e35d4fc835f1479ab 100644
--- a/binding.gyp
+++ b/binding.gyp
@@ -1,13 +1,10 @@
@@ -1,13 +1,18 @@
{
'target_defaults': {
'dependencies': [
- "<!(node -p \"require('node-addon-api').targets\"):node_addon_api_except",
+ "<!(node -p \"require.resolve('node-addon-api/node_addon_api.gyp')\"):node_addon_api_except",
],
+ # Orca: GCC 9 (Ubuntu 20.04 floor) accepts C++20 as gnu++2a, but rejects
+ # the newer gnu++20 spelling emitted by Node 24's gyp flags.
+ 'cflags_cc!': [
+ '-std=gnu++20'
+ ],
+ 'cflags_cc': [
+ '-std=gnu++2a'
+ ],
'conditions': [
['OS=="win"', {
- 'msvs_configuration_attributes': {
File diff suppressed because one or more lines are too long
+10 -6
View File
@@ -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}`)
+5 -1
View File
@@ -378,11 +378,15 @@ function rebuildNodeRuntimeModules(moduleNames) {
function runPnpm(args, { cwd = projectDir } = {}) {
const command = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
const env =
process.platform === 'linux' && args.includes('node-gyp')
? { ...process.env, CXXFLAGS: `${process.env.CXXFLAGS ?? ''} -std=gnu++2a`.trim() }
: process.env
const result = spawnSync(command, args, {
cwd,
stdio: 'inherit',
shell: process.platform === 'win32',
env: process.env
env
})
if (result.error || result.status !== 0) {
@@ -29,6 +29,7 @@ describe('ensure-native-runtime', () => {
const binDir = join(projectDir, 'bin')
copyFileSync(sourceScriptPath, scriptPath)
writeFakeNativeModules(projectDir)
writeNodePtyPatchFile(projectDir)
writeFakePnpm(binDir)
const result = spawnSync(process.execPath, [scriptPath, '--runtime=node'], {
@@ -44,6 +45,9 @@ describe('ensure-native-runtime', () => {
const log = readFileSync(logPath, 'utf8')
expect(log).toContain('pnpm exec node-gyp rebuild\n')
expect(log).toContain(join('node_modules', 'node-pty'))
if (process.platform === 'linux') {
expect(log).toMatch(/^cxxflags=(?:.*\s)?-std=gnu\+\+2a$/m)
}
expect(log.split('\n').filter((line) => line.startsWith('node-pty child '))).toEqual([
expect.stringMatching(/^node-pty child (?:conpty|pty) marker=false$/),
expect.stringMatching(/^node-pty child (?:conpty|pty) marker=true$/)
@@ -239,6 +243,14 @@ exports.loadNativeModule = function loadNativeModule(nativeName) {
if (!markerExists) {
throw new Error('ABI mismatch sentinel')
}
return {
dir: '../build/Release/',
module: {
listJobProcessIds() {},
terminateJob() {},
assignCurrentProcessToJob() {}
}
}
}
`
)
@@ -337,6 +349,10 @@ appendFileSync(
process.env.ORCA_NATIVE_TEST_LOG,
\`npm_config_build_from_source=\${process.env.npm_config_build_from_source || ''}\\n\`
)
appendFileSync(
process.env.ORCA_NATIVE_TEST_LOG,
\`cxxflags=\${process.env.CXXFLAGS || ''}\\n\`
)
writeFileSync(process.env.ORCA_NATIVE_TEST_MARKER, 'rebuilt')
`
)
@@ -47,6 +47,33 @@ function executeInstallScript(fixture) {
}
describe('install-node-dependencies action', () => {
it('skips the lockfile diff when a job container has no Git metadata', () => {
const fixture = createFixture()
try {
rmSync(join(fixture.workspace, '.git'), { recursive: true, force: true })
const result = executeInstallScript(fixture)
expect(result.status, result.stderr || result.stdout).toBe(0)
expect(`${result.stdout}\n${result.stderr}`).not.toMatch(/^diff --git /m)
} finally {
rmSync(fixture.root, { recursive: true, force: true })
}
})
it('skips the lockfile diff for a bare repository', () => {
const fixture = createFixture()
try {
rmSync(join(fixture.workspace, '.git'), { recursive: true, force: true })
expect(run('git', ['init', '--bare', '-q'], { cwd: fixture.workspace }).status).toBe(0)
const result = executeInstallScript(fixture)
expect(result.status, result.stderr || result.stdout).toBe(0)
expect(`${result.stdout}\n${result.stderr}`).not.toMatch(/^diff --git /m)
} finally {
rmSync(fixture.root, { recursive: true, force: true })
}
})
it.each([
['package.json', '{"name":"changed"}\n'],
['pnpm-lock.yaml', 'lockfileVersion: 9\nchanged: true\n']
+1 -5
View File
@@ -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'
)
+5 -1
View File
@@ -281,7 +281,11 @@ describe('PR Checks skip wiring', () => {
expect(prWorkflow.jobs.test_native_cache.if).toBe(
"needs.code_paths.outputs.native_cache_changed == 'true'"
)
expect(prWorkflow.jobs.test_native_cache.strategy.matrix.node).toEqual(['24', '26'])
expect(prWorkflow.jobs.test_native_cache.strategy).toBeUndefined()
const primerInstall = prWorkflow.jobs.test_native_cache.steps.find(
(step) => step.uses === './.github/actions/install-node-dependencies'
)
expect(primerInstall.with['node-version']).toBe('24')
})
it('skips e2e detection on docs-only PRs without dropping the draft gate', () => {
+24 -13
View File
@@ -3,6 +3,8 @@ import { parse } from 'yaml'
import { describe, expect, it } from 'vitest'
const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8'))
const unitTestWorkflow = parse(readFileSync('.github/workflows/unit-tests.yml', 'utf8'))
const nodeNextWorkflow = parse(readFileSync('.github/workflows/node-next-compat.yml', 'utf8'))
const dependencyAction = parse(
readFileSync('.github/actions/install-node-dependencies/action.yml', 'utf8')
)
@@ -47,28 +49,34 @@ describe('PR workflow parallelism', () => {
expect(workflow.permissions).toEqual({ contents: 'read' })
})
it('shards the general test suite across Node 24 and Node 26', () => {
expect(workflow.jobs.test.strategy.matrix.node).toEqual(['24', '26'])
expect(workflow.jobs.test.strategy.matrix.shard).toEqual(
Array.from({ length: 8 }, (_, index) => index + 1)
it('runs Node 24 on PRs and the same eight-shard suite on Node 26 daily', () => {
const sharedTest = unitTestWorkflow.jobs.test
const testStep = sharedTest.steps.find((step) => step.name === 'Test shard')
const installStep = sharedTest.steps.find(
(step) => step.uses === './.github/actions/install-node-dependencies'
)
expect(workflow.jobs.test.strategy.matrix.shard_total).toEqual([8])
const testStep = workflow.jobs.test.steps.find((step) => step.name === 'Test shard')
const installStep = workflow.jobs.test.steps.find(
const primerInstall = workflow.jobs.test_native_cache.steps.find(
(step) => step.uses === './.github/actions/install-node-dependencies'
)
expect(workflow.jobs.test.uses).toBe('./.github/workflows/unit-tests.yml')
expect(JSON.parse(workflow.jobs.test.with.node_versions)).toEqual(['24'])
expect(nodeNextWorkflow.jobs.test.uses).toBe('./.github/workflows/unit-tests.yml')
expect(JSON.parse(nodeNextWorkflow.jobs.test.with.node_versions)).toEqual(['26'])
expect(nodeNextWorkflow.on.schedule).toHaveLength(1)
expect(nodeNextWorkflow.on.workflow_dispatch).toBeNull()
expect(sharedTest.strategy.matrix.node).toBe('${{ fromJSON(inputs.node_versions) }}')
expect(sharedTest.strategy.matrix.shard).toEqual(
Array.from({ length: 8 }, (_, index) => index + 1)
)
expect(sharedTest.strategy.matrix.shard_total).toEqual([8])
expect(installStep.with['node-version']).toBe('${{ matrix.node }}')
expect(testStep.run).toContain('--shard=${{ matrix.shard }}/${{ matrix.shard_total }}')
for (const testFile of nativeShellContractFiles) {
expect(testStep.run).toContain(`--exclude=${testFile}`)
}
const primerInstall = workflow.jobs.test_native_cache.steps.find(
(step) => step.uses === './.github/actions/install-node-dependencies'
)
expect(workflow.jobs.test_native_cache.strategy.matrix.node).toEqual(['24', '26'])
expect(primerInstall.with['native-runtime']).toBe('node')
expect(primerInstall.with['node-version']).toBe('${{ matrix.node }}')
expect(primerInstall.with['node-version']).toBe('24')
expect(workflow.jobs.test.needs).toContain('test_native_cache')
})
@@ -241,13 +249,16 @@ describe('PR workflow parallelism', () => {
workflow.jobs[jobName].steps.find(
(step) => step.uses === './.github/actions/install-node-dependencies'
)
const sharedTestInstall = unitTestWorkflow.jobs.test.steps.find(
(step) => step.uses === './.github/actions/install-node-dependencies'
)
for (const jobName of ['typecheck', 'git_compatibility', 'xterm_patch_sync']) {
expect(installFor(jobName).with, jobName).toBeUndefined()
}
expect(installFor('static_analysis').with['native-runtime']).toBe('node')
expect(installFor('shell_contracts').with['native-runtime']).toBe('node')
expect(installFor('test').with['native-runtime']).toBe('node')
expect(sharedTestInstall.with['native-runtime']).toBe('node')
expect(installFor('package').with['native-runtime']).toBe('electron')
expect(installFor('package_windows').with['native-runtime']).toBe('node')
expect(installFor('package_windows').with['persist-native-cache']).toBe('false')
@@ -434,6 +434,20 @@ describe('check-mode reporting', () => {
// These run without network or a build, so ordinary `pnpm test` catches the two
// desyncs that would otherwise only surface in the heavy xterm_patch_sync job.
describe('committed xterm patch artifacts', () => {
it('normalizes Chromium font-weight serialization in every WebGL runtime copy', async () => {
const patch = await readFile(
path.join(REPO_ROOT, 'config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch'),
'utf8'
)
expect(patch).not.toMatch(/desiredWeight !== '400'|orcaProbeWeight!=="400"/)
expect(
patch.match(/(?:actualWeightToken === 'normal'|orcaActualWeightToken==="normal")/g)
).toHaveLength(3)
expect(
patch.match(/(?:actualWeight !== desiredWeight|orcaActualWeight!==orcaProbeWeight)/g)
).toHaveLength(3)
})
it('records the lockfile hash pnpm derives from the patch file', async () => {
const manifest = JSON.parse(await readFile(MANIFEST_PATH, 'utf8'))
const lockfile = await readFile(path.join(REPO_ROOT, 'pnpm-lock.yaml'), 'utf8')
@@ -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
View File
@@ -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.
+4 -4
View File
@@ -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

+54 -7
View File
@@ -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.
+5 -5
View File
@@ -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
+20 -19
View File
@@ -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.
+2 -2
View File
@@ -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'`
+19 -18
View File
@@ -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:**
+16 -13
View File
@@ -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.
+1 -1
View File
@@ -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`,
+31 -31
View File
@@ -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.
+1 -1
View File
@@ -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
+15 -15
View File
@@ -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.
+1 -1
View File
@@ -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.
+1
View File
@@ -209,6 +209,7 @@ export const electronViteConfig: UserConfig = {
index: resolve('src/main/index.ts'),
// Why: sandboxed webview preloads cannot load Rollup helper chunks.
'browser-window-close-preload': resolve('src/preload/browser-window-close.ts'),
'doc-preview-link-preload': resolve('src/preload/doc-preview-link.ts'),
'daemon-entry': resolve('src/main/daemon/daemon-entry.ts'),
'plugin-host-entry': resolve('src/main/plugins/plugin-host-entry.ts'),
'computer-sidecar': resolve('src/main/computer/sidecar-entry.ts'),
+209 -23
View File
@@ -1,20 +1,35 @@
import { useCallback, useEffect, useState, useSyncExternalStore } from 'react'
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react'
import { View, Text, StyleSheet, Pressable, Platform } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useRouter } from 'expo-router'
import { useLocalSearchParams, useRouter } from 'expo-router'
import * as Clipboard from 'expo-clipboard'
import Constants from 'expo-constants'
import { ChevronLeft, Copy, Check } from 'lucide-react-native'
import { ChevronLeft, Copy, Check, Send } from 'lucide-react-native'
import { colors, spacing, typography } from '../src/theme/mobile-theme'
import { ConnectionLog } from '../src/components/ConnectionLog'
import { loadHosts } from '../src/transport/host-store'
import { connectionLogStore } from '../src/transport/connection-log-buffer'
import { useHostClient } from '../src/transport/client-context'
import { connectionLogStore } from '../src/transport/persisted-connection-log-store'
import { useHostClient, useRpcClientContext } from '../src/transport/client-context'
import {
useLastConnectedAt,
useConnectionPathStatus,
useReconnectAttempt
} from '../src/transport/client-context-connection-metrics'
import { buildConnectionDiagnosticsReport } from '../src/diagnostics/connection-diagnostics-report'
import {
diagnoseConnection,
getReportableConnectionIncidentId
} from '../src/diagnostics/connection-diagnostics-analysis'
import { submitConnectionDiagnostics } from '../src/diagnostics/connection-diagnostics-submission'
import {
readHydratedConnectionLog,
readConnectionDiagnosticsSnapshot,
resolveDiagnosticsHostId,
getDiagnosticsSubmissionState,
updateDiagnosticsSubmissionState,
type DiagnosticsSubmissionStates
} from '../src/diagnostics/connection-diagnostics-screen-data'
import { useHostStatusGates } from '../src/transport/host-status-gates'
import { loadHostAppVersion } from '../src/transport/host-app-version-store'
import type { ConnectionLogEntry, HostProfile } from '../src/transport/types'
// Why: getSnapshot must be referentially stable when there's no data —
@@ -25,11 +40,19 @@ const EMPTY_ENTRIES: readonly ConnectionLogEntry[] = []
// screen also *acquires* the host client — opening it kicks a dial and the
// log fills live instead of showing a stale tail.
export default function ConnectionLogScreen() {
const clientContext = useRpcClientContext()
const router = useRouter()
const params = useLocalSearchParams<{ hostId?: string }>()
const insets = useSafeAreaInsets()
const routeKey = useMemo(() => ({}), [params.hostId])
const [hosts, setHosts] = useState<HostProfile[]>([])
const [selectedId, setSelectedId] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
const [manualSelection, setManualSelection] = useState<{
hostId: string
requestedHostId: string | undefined
routeKey: object
} | null>(null)
const [copiedHostId, setCopiedHostId] = useState<string | null>(null)
const [submissionStates, setSubmissionStates] = useState<DiagnosticsSubmissionStates>({})
useEffect(() => {
let stale = false
@@ -38,17 +61,28 @@ export default function ConnectionLogScreen() {
return
}
setHosts(loaded)
setSelectedId((prev) => prev ?? loaded[0]?.id ?? null)
})
return () => {
stale = true
}
}, [])
const selectedId = resolveDiagnosticsHostId(hosts, params.hostId, manualSelection, routeKey)
const selected = hosts.find((h) => h.id === selectedId) ?? null
const { state } = useHostClient(selected?.id)
const { client, state } = useHostClient(selected?.id)
const { desktopAppVersion: liveDesktopAppVersion } = useHostStatusGates({
hostId: selected?.id,
client,
connState: state
})
const reconnectAttempts = useReconnectAttempt(selected?.id)
const lastConnectedAt = useLastConnectedAt(selected?.id)
const { activePath, pendingPath } = useConnectionPathStatus(selected?.id)
useEffect(() => {
if (selectedId) {
void readHydratedConnectionLog(connectionLogStore, selectedId)
}
}, [selectedId])
const subscribe = useCallback(
(listener: () => void) =>
@@ -60,25 +94,93 @@ export default function ConnectionLogScreen() {
[selectedId]
)
const entries = useSyncExternalStore(subscribe, getSnapshot)
const diagnosis = selected
? diagnoseConnection({ endpoint: selected.endpoint, state, activePath, pendingPath, entries })
: null
const incidentId = selected
? getReportableConnectionIncidentId({
endpoint: selected.endpoint,
state,
activePath,
pendingPath,
entries
})
: null
const submissionKey = selected && incidentId ? `${selected.id}:${incidentId}` : null
const submissionState = getDiagnosticsSubmissionState(submissionStates, submissionKey)
const copied = copiedHostId === selectedId
const copyDiagnostics = useCallback(async () => {
if (!selected) {
return
}
const desktopAppVersion = liveDesktopAppVersion ?? (await loadHostAppVersion(selected.id))
const snapshot = await readConnectionDiagnosticsSnapshot(
clientContext,
connectionLogStore,
selected.id
)
const report = buildConnectionDiagnosticsReport({
hostName: selected.name,
endpoint: selected.endpoint,
state,
reconnectAttempts,
lastConnectedAt,
state: snapshot.state,
reconnectAttempts: snapshot.reconnectAttempts,
lastConnectedAt: snapshot.lastConnectedAt,
platform: `${Platform.OS} ${Platform.Version ?? ''}`.trim(),
appVersion: Constants.expoConfig?.version ?? 'unknown',
entries
desktopAppVersion,
entries: snapshot.entries,
activePath: snapshot.activePath,
pendingPath: snapshot.pendingPath
})
await Clipboard.setStringAsync(report)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}, [selected, state, reconnectAttempts, lastConnectedAt, entries])
setCopiedHostId(selected.id)
setTimeout(() => setCopiedHostId((hostId) => (hostId === selected.id ? null : hostId)), 2000)
}, [selected, liveDesktopAppVersion, clientContext])
const sendDiagnostics = useCallback(async () => {
if (!selected || !submissionKey || submissionState === 'sending') {
return
}
const startedKey = submissionKey
setSubmissionStates((states) => updateDiagnosticsSubmissionState(states, startedKey, 'sending'))
const appVersion = Constants.expoConfig?.version ?? 'unknown'
const platform = `${Platform.OS} ${Platform.Version ?? ''}`.trim()
const desktopAppVersion = liveDesktopAppVersion ?? (await loadHostAppVersion(selected.id))
const snapshot = await readConnectionDiagnosticsSnapshot(
clientContext,
connectionLogStore,
selected.id
)
const currentIncidentId = getReportableConnectionIncidentId({
endpoint: selected.endpoint,
state: snapshot.state,
activePath: snapshot.activePath,
pendingPath: snapshot.pendingPath,
entries: snapshot.entries
})
if (`${selected.id}:${currentIncidentId ?? ''}` !== startedKey) {
setSubmissionStates((states) => updateDiagnosticsSubmissionState(states, startedKey, null))
return
}
const report = buildConnectionDiagnosticsReport({
hostName: selected.name,
endpoint: selected.endpoint,
state: snapshot.state,
reconnectAttempts: snapshot.reconnectAttempts,
lastConnectedAt: snapshot.lastConnectedAt,
platform,
appVersion,
desktopAppVersion,
entries: snapshot.entries,
activePath: snapshot.activePath,
pendingPath: snapshot.pendingPath
})
const result = await submitConnectionDiagnostics({ report, appVersion, platform })
setSubmissionStates((states) =>
updateDiagnosticsSubmissionState(states, startedKey, result.ok ? 'sent' : 'failed')
)
}, [selected, submissionKey, submissionState, liveDesktopAppVersion, clientContext])
return (
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
@@ -86,7 +188,7 @@ export default function ConnectionLogScreen() {
<Pressable style={styles.backButton} onPress={() => router.back()}>
<ChevronLeft size={22} color={colors.textSecondary} />
</Pressable>
<Text style={styles.heading}>Connection log</Text>
<Text style={styles.heading}>Network diagnostics</Text>
</View>
{hosts.length > 1 && (
@@ -95,7 +197,9 @@ export default function ConnectionLogScreen() {
<Pressable
key={host.id}
style={[styles.hostChip, host.id === selectedId && styles.hostChipActive]}
onPress={() => setSelectedId(host.id)}
onPress={() =>
setManualSelection({ hostId: host.id, requestedHostId: params.hostId, routeKey })
}
>
<Text
style={[styles.hostChipText, host.id === selectedId && styles.hostChipTextActive]}
@@ -121,14 +225,49 @@ export default function ConnectionLogScreen() {
) : (
<Copy size={14} color={colors.textSecondary} />
)}
<Text style={styles.copyButtonText}>{copied ? 'Copied' : 'Copy diagnostics'}</Text>
<Text style={styles.copyButtonText}>{copied ? 'Copied' : 'Copy report'}</Text>
</Pressable>
</View>
{diagnosis && (
<View style={styles.diagnosisCard}>
<Text style={styles.diagnosisHeading}>What this suggests</Text>
<Text style={styles.diagnosisText}>{diagnosis.likelyCause}</Text>
<Text style={styles.diagnosisNext}>{diagnosis.nextStep}</Text>
{diagnosis.reportability === 'orca-relay' && (
<>
<Text style={styles.privacyHint}>
Sends a size-limited redacted report including host name, endpoint, versions,
connection state, and events—never terminal contents or credentials.
</Text>
<Pressable
style={styles.sendButton}
onPress={() => void sendDiagnostics()}
disabled={submissionState === 'sending'}
>
{submissionState === 'sent' ? (
<Check size={14} color={colors.statusGreen} />
) : (
<Send size={14} color={colors.textPrimary} />
)}
<Text style={styles.sendButtonText}>
{submissionState === 'sending'
? 'Sending…'
: submissionState === 'sent'
? 'Diagnostics sent'
: submissionState === 'failed'
? 'Retry sending'
: 'Send diagnostics to Orca'}
</Text>
</Pressable>
</>
)}
</View>
)}
{entries.length > 0 ? (
<ConnectionLog entries={[...entries]} title={selected.name} />
<ConnectionLog entries={[...entries]} title={selected.name} fillAvailableHeight />
) : (
<Text style={styles.emptyText}>
No connection events yet this session. Events appear as the app dials this host.
No connection events yet. Events appear as the app dials this host.
</Text>
)}
</>
@@ -199,6 +338,53 @@ const styles = StyleSheet.create({
fontSize: typography.metaSize,
color: colors.textSecondary
},
diagnosisCard: {
backgroundColor: colors.bgPanel,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.borderSubtle,
borderRadius: 10,
padding: spacing.md,
marginBottom: spacing.md
},
diagnosisHeading: {
fontSize: typography.metaSize,
fontWeight: '600',
color: colors.textPrimary,
marginBottom: spacing.xs
},
diagnosisText: {
fontSize: typography.metaSize,
color: colors.textPrimary,
lineHeight: 18
},
diagnosisNext: {
fontSize: typography.metaSize,
color: colors.textSecondary,
lineHeight: 18,
marginTop: spacing.xs
},
privacyHint: {
marginTop: spacing.sm,
fontSize: 11,
lineHeight: 15,
color: colors.textMuted
},
sendButton: {
marginTop: spacing.md,
alignSelf: 'flex-start',
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
borderRadius: 8,
backgroundColor: colors.bgRaised
},
sendButtonText: {
fontSize: typography.metaSize,
fontWeight: '600',
color: colors.textPrimary
},
copyButton: {
flexDirection: 'row',
alignItems: 'center',
+12
View File
@@ -63,6 +63,7 @@ import { ConfirmModal } from '../../../src/components/ConfirmModal'
import { BottomDrawer } from '../../../src/components/BottomDrawer'
import { useHostProtocolGates } from '../../../src/components/HostProtocolGate'
import { AuthFailedBanner } from '../../../src/components/AuthFailedBanner'
import { HostDiagnosticsLink } from '../../../src/components/HostDiagnosticsLink'
import { HostRouteNoticeBanner } from '../../../src/components/HostRouteNoticeBanner'
import { visibleHostRouteNotice } from '../../../src/host-route-notice'
import { MobileSearchField } from '../../../src/components/MobileSearchField'
@@ -1091,6 +1092,17 @@ export function HostScreen({
/>
)}
{connState !== 'connected' &&
!relayRecovery.pairingRejected &&
reconnectAttempts >= 3 &&
hostId ? (
<HostDiagnosticsLink
onPress={() =>
router.push({ pathname: '/connection-log', params: { hostId: String(hostId) } })
}
/>
) : null}
{/* Why a bounced route landed here (e.g. the workspace was deleted on the desktop). */}
{routeNotice && (
<HostRouteNoticeBanner
+1 -1
View File
@@ -222,7 +222,7 @@ export default function TroubleshootScreen() {
onPress={() => router.push('/connection-log')}
>
<ScrollText size={16} color={colors.textPrimary} />
<Text style={styles.diagnosticButtonLabel}>View connection log</Text>
<Text style={styles.diagnosticButtonLabel}>View network diagnostics</Text>
</Pressable>
{checks.length > 0 && (
@@ -0,0 +1,81 @@
import { createElement } from 'react'
import { act, create } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ConnectionLogEntry } from '../transport/types'
import { ConnectionLog } from './ConnectionLog'
vi.mock('react-native', () => ({
ScrollView: 'ScrollView',
StyleSheet: { create: <T,>(styles: T) => styles, hairlineWidth: 1 },
Text: 'Text',
View: 'View'
}))
const entries: ConnectionLogEntry[] = [
{ id: 'event-1', ts: 1, level: 'info', message: 'Opening WebSocket' }
]
const duplicateEntries: ConnectionLogEntry[] = [
{ id: 'relay-1', ts: 1, level: 'info', message: 'Relay recovery started' },
{ id: 'relay-1', ts: 2, level: 'warn', message: 'Relay retry scheduled' }
]
describe('ConnectionLog', () => {
type RenderedNode = { props: { style?: unknown } }
type Renderer = {
root: {
findAllByType: (type: unknown) => RenderedNode[]
findByType: (type: unknown) => RenderedNode
}
unmount: () => void
}
let renderer: Renderer | null = null
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
})
function renderLog(fillAvailableHeight = false): Renderer {
act(() => {
renderer = create(
createElement(ConnectionLog, { entries, fillAvailableHeight })
) as unknown as Renderer
})
return renderer as Renderer
}
it('keeps the compact height in pairing flows', () => {
const instance = renderLog()
const containerStyles = instance.root.findAllByType('View')[0]!.props.style
expect(containerStyles).toContainEqual({ maxHeight: 240 })
})
it('fills the available diagnostics viewport', () => {
const instance = renderLog(true)
const containerStyles = instance.root.findAllByType('View')[0]!.props.style
const scroll = instance.root.findByType('ScrollView')
expect(containerStyles).toContainEqual({ flex: 1 })
expect(scroll.props.style).toEqual({ flex: 1 })
})
it('does not emit duplicate-key warnings for repeated persisted event IDs', () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
act(() => {
renderer = create(
createElement(ConnectionLog, { entries: duplicateEntries })
) as unknown as Renderer
})
expect(
consoleError.mock.calls.some(([message]) =>
String(message).includes('Encountered two children with the same key')
)
).toBe(false)
consoleError.mockRestore()
})
})
+40 -20
View File
@@ -8,6 +8,7 @@ type Props = {
// Tag printed before the first entry so it's clear what's being logged
// (e.g. 'Pairing' vs 'Reconnect').
title?: string
fillAvailableHeight?: boolean
}
const LEVEL_COLOR: Record<ConnectionLogEntry['level'], string> = {
@@ -37,42 +38,53 @@ function formatTime(ts: number, baseTs: number): string {
return `+${Math.round(elapsed)}s`
}
export function ConnectionLog({ entries, title }: Props) {
export function ConnectionLog({ entries, title, fillAvailableHeight = false }: Props) {
const scrollRef = useRef<ScrollView | null>(null)
if (entries.length === 0) {
return null
}
const baseTs = entries[0]!.ts
const keyOccurrences = new Map<string, number>()
return (
<View style={styles.container}>
<View
style={[
styles.container,
fillAvailableHeight ? styles.fillContainer : styles.boundedContainer
]}
>
{title && <Text style={styles.title}>{title}</Text>}
<ScrollView
ref={scrollRef}
style={styles.scroll}
style={fillAvailableHeight ? styles.fillScroll : styles.boundedScroll}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
onContentSizeChange={() => scrollRef.current?.scrollToEnd({ animated: true })}
>
{entries.map((entry) => (
<View key={entry.id} style={styles.row}>
<Text style={styles.timestamp}>{formatTime(entry.ts, baseTs)}</Text>
<Text style={[styles.glyph, { color: LEVEL_COLOR[entry.level] }]}>
{LEVEL_GLYPH[entry.level]}
</Text>
<View style={styles.rowText}>
<Text style={[styles.message, { color: LEVEL_COLOR[entry.level] }]}>
{entry.message}
{entries.map((entry) => {
const occurrence = keyOccurrences.get(entry.id) ?? 0
keyOccurrences.set(entry.id, occurrence + 1)
const renderKey = occurrence === 0 ? entry.id : `${entry.id}:${occurrence}`
return (
<View key={renderKey} style={styles.row}>
<Text style={styles.timestamp}>{formatTime(entry.ts, baseTs)}</Text>
<Text style={[styles.glyph, { color: LEVEL_COLOR[entry.level] }]}>
{LEVEL_GLYPH[entry.level]}
</Text>
{entry.detail && (
<Text style={styles.detail} numberOfLines={2}>
{entry.detail}
<View style={styles.rowText}>
<Text style={[styles.message, { color: LEVEL_COLOR[entry.level] }]}>
{entry.message}
</Text>
)}
{entry.detail && (
<Text style={styles.detail} numberOfLines={2}>
{entry.detail}
</Text>
)}
</View>
</View>
</View>
))}
)
})}
</ScrollView>
</View>
)
@@ -81,7 +93,6 @@ export function ConnectionLog({ entries, title }: Props) {
const styles = StyleSheet.create({
container: {
width: '100%',
maxHeight: 240,
backgroundColor: colors.bgPanel,
borderRadius: radii.card,
borderWidth: StyleSheet.hairlineWidth,
@@ -89,6 +100,12 @@ const styles = StyleSheet.create({
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md
},
boundedContainer: {
maxHeight: 240
},
fillContainer: {
flex: 1
},
title: {
fontSize: typography.metaSize,
fontFamily: typography.monoFamily,
@@ -97,9 +114,12 @@ const styles = StyleSheet.create({
letterSpacing: 1,
marginBottom: spacing.xs
},
scroll: {
boundedScroll: {
maxHeight: 200
},
fillScroll: {
flex: 1
},
scrollContent: {
gap: 6
},
@@ -0,0 +1,38 @@
import { ChevronRight } from 'lucide-react-native'
import { Pressable, StyleSheet, Text } from 'react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
export function HostDiagnosticsLink({ onPress }: { onPress: () => void }): React.JSX.Element {
return (
<Pressable
style={styles.link}
onPress={onPress}
accessibilityRole="button"
accessibilityLabel="View network diagnostics"
>
<Text style={styles.text}>View network diagnostics</Text>
<ChevronRight size={16} color={colors.textSecondary} />
</Pressable>
)
}
const styles = StyleSheet.create({
link: {
marginHorizontal: spacing.md,
marginBottom: spacing.sm,
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
borderRadius: radii.button,
backgroundColor: colors.bgPanel,
borderWidth: 1,
borderColor: colors.borderSubtle,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between'
},
text: {
color: colors.textSecondary,
fontSize: typography.metaSize,
fontWeight: '600'
}
})
@@ -0,0 +1,240 @@
import { describe, expect, it } from 'vitest'
import {
diagnoseConnection,
getReportableConnectionIncidentId
} from './connection-diagnostics-analysis'
import type { ConnectionLogEntry } from '../transport/types'
function event(message: string, detail?: string): ConnectionLogEntry {
return { id: message, ts: 1, level: 'error', message, detail }
}
describe('diagnoseConnection', () => {
it('reports the current healthy path instead of a historical failure', () => {
expect(
diagnoseConnection({
endpoint: 'ws://100.88.90.25:6768',
state: 'connected',
activePath: 'relay',
entries: [event('WebSocket connect timeout')]
})
).toEqual({
likelyCause: 'Connection is healthy via Relay.',
nextStep: 'No action needed.',
reportability: 'none'
})
})
it('distinguishes an invalid Relay credential from a transient outage', () => {
expect(
diagnoseConnection({
endpoint: 'ws://100.88.90.25:6768',
state: 'reconnecting',
pendingPath: 'relay',
entries: [event('Relay: relay dial failed', 'relay director resolve failed (401)')]
})
).toEqual({
likelyCause: 'Relay rejected the saved resume credential.',
nextStep: 'Try a direct connection; if Relay keeps returning 401, pair this device again.',
reportability: 'none'
})
})
it('identifies the direct Tailscale timeout while Relay recovery is pending', () => {
expect(
diagnoseConnection({
endpoint: 'ws://100.88.90.25:6768',
state: 'reconnecting',
activePath: 'tailscale',
pendingPath: 'relay',
entries: [event('WebSocket connect timeout')]
})
).toEqual({
likelyCause: 'The saved Tailscale endpoint did not answer before the connection timeout.',
nextStep: 'Relay recovery is in progress; keep Orca open while it retries.',
reportability: 'none'
})
})
it('identifies an authenticated Relay liveness failure', () => {
expect(
diagnoseConnection({
endpoint: 'ws://192.168.1.2:6768',
state: 'reconnecting',
activePath: 'relay',
entries: [
{
...event('Relay health check failed'),
code: 'liveness-timeout',
path: 'relay'
}
]
})
).toEqual({
likelyCause: 'Relay stopped answering authenticated health checks.',
nextStep: 'Orca closed the stale session and started recovery.',
reportability: 'orca-relay'
})
})
it('separates an active Relay close from a failed Relay dial', () => {
expect(
diagnoseConnection({
endpoint: 'ws://192.168.1.2:6768',
state: 'reconnecting',
activePath: 'relay',
entries: [
{
...event('Relay: active relay session failed', 'RelayOuterError: close code 4408'),
code: 'relay-session-failed',
path: 'relay'
}
]
})
).toEqual({
likelyCause: 'The active Relay session closed unexpectedly.',
nextStep: 'Orca started Relay recovery; the event history includes the cell close reason.',
reportability: 'orca-relay'
})
})
it('marks authenticated Relay liveness failures as safe to send', () => {
expect(
diagnoseConnection({
endpoint: 'ws://192.168.1.2:6768',
state: 'reconnecting',
activePath: 'relay',
entries: [
{
...event('Relay health check failed'),
code: 'liveness-timeout',
path: 'relay'
}
]
}).reportability
).toBe('orca-relay')
})
it.each([
['direct timeout', 'connect-timeout', 'tailscale'],
['handshake timeout', 'handshake-timeout', 'relay'],
['invalid credential', 'relay director resolve failed (401)', 'relay'],
['ambiguous recovery', 'retry scheduled', 'relay']
] as const)('does not offer submission for %s', (_name, message, path) => {
expect(
diagnoseConnection({
endpoint: 'ws://100.88.90.25:6768',
state: 'reconnecting',
pendingPath: 'relay',
entries: [{ ...event(message), path }]
}).reportability
).toBe('none')
})
it('does not offer submission for a bounded Relay director outage', () => {
expect(
diagnoseConnection({
endpoint: 'ws://100.88.90.25:6768',
state: 'reconnecting',
pendingPath: 'relay',
entries: [event('Relay: relay dial failed', 'relay director resolve failed (503)')]
}).reportability
).toBe('none')
})
it('classifies the newest failure instead of an older persisted 401', () => {
expect(
diagnoseConnection({
endpoint: 'ws://100.88.90.25:6768',
state: 'reconnecting',
pendingPath: 'relay',
entries: [
event('Relay: relay dial failed', 'relay director resolve failed (401)'),
{ ...event('WebSocket connect timeout'), id: 'newer', ts: 2, code: 'connect-timeout' }
]
}).likelyCause
).toBe('The saved Tailscale endpoint did not answer before the connection timeout.')
})
it('requires structured Relay evidence before offering submission', () => {
expect(
diagnoseConnection({
endpoint: 'ws://192.168.1.2:6768',
state: 'reconnecting',
activePath: 'relay',
entries: [event('active relay session failed')]
}).reportability
).toBe('none')
})
it('ignores failures from before the current app resume boundary', () => {
expect(
diagnoseConnection({
endpoint: 'ws://192.168.1.2:6768',
state: 'connecting',
activePath: 'lan',
entries: [
{
...event('Relay health check failed'),
code: 'liveness-timeout',
path: 'relay'
},
{
...event('App returned to foreground'),
id: 'resume',
ts: 2,
code: 'app-resumed'
},
{ ...event('WebSocket closed'), id: 'closed', ts: 3, code: 'socket-closed' }
]
}).reportability
).toBe('none')
})
it('uses a structured direct liveness path ahead of the current active path', () => {
expect(
diagnoseConnection({
endpoint: 'ws://192.168.1.2:6768',
state: 'reconnecting',
activePath: 'relay',
entries: [
{
...event('Connection health check failed'),
code: 'liveness-timeout',
path: 'lan'
}
]
})
).toEqual({
likelyCause: 'The connected host stopped answering authenticated health checks.',
nextStep: 'Orca closed the stale session and started recovery.',
reportability: 'none'
})
})
it('keys reportability to the current structured incident', () => {
const args = {
endpoint: 'ws://192.168.1.2:6768',
state: 'reconnecting' as const,
activePath: 'relay' as const,
entries: [
{ ...event('Authenticated'), code: 'direct-connected' as const, path: 'lan' as const },
{
...event('Relay health check failed'),
id: 'current-relay-failure',
ts: 2,
code: 'liveness-timeout' as const,
path: 'relay' as const
}
]
}
expect(getReportableConnectionIncidentId(args)).toBe('current-relay-failure')
expect(
getReportableConnectionIncidentId({
...args,
entries: [...args.entries, { ...event('Network changed'), ts: 3, code: 'network-changed' }]
})
).toBeNull()
})
})
@@ -0,0 +1,168 @@
import { isTailscaleEndpoint } from '../../../src/shared/remote-runtime-tailscale-hint'
import type {
ConnectionLogEntry,
ConnectionState,
MobileConnectionDiagnosticPath
} from '../transport/types'
export type ConnectionDiagnosis = {
likelyCause: string
nextStep: string
reportability: 'none' | 'orca-relay'
}
type DiagnoseConnectionArgs = {
endpoint: string
state: ConnectionState
activePath?: MobileConnectionDiagnosticPath
pendingPath?: MobileConnectionDiagnosticPath | null
entries: readonly ConnectionLogEntry[]
}
export function diagnoseConnection(args: DiagnoseConnectionArgs): ConnectionDiagnosis {
if (args.state === 'connected') {
return {
likelyCause: `Connection is healthy${args.activePath ? ` via ${formatPath(args.activePath)}` : ''}.`,
nextStep: 'No action needed.',
reportability: 'none'
}
}
const failure = findCurrentDiagnosticFailure(args.entries)
const evidence = failure ? `${failure.code ?? ''} ${failure.message} ${failure.detail ?? ''}` : ''
if (/relay director resolve failed \(401\)/i.test(evidence)) {
return {
likelyCause: 'Relay rejected the saved resume credential.',
nextStep: 'Try a direct connection; if Relay keeps returning 401, pair this device again.',
reportability: 'none'
}
}
if (/relay director resolve failed \(503\)/i.test(evidence)) {
const retryMs = parseRetryDelayMs(evidence)
return {
likelyCause: `Relay service was temporarily unavailable${retryMs == null ? '.' : ` and asked Orca to retry in ${formatDelay(retryMs)}.`}`,
nextStep: 'Keep Orca open; recovery should retry automatically.',
reportability: 'none'
}
}
if (/liveness-timeout|liveness timeout|connection health check failed/i.test(evidence)) {
const relayLiveness = failure?.code === 'liveness-timeout' && failure.path === 'relay'
const structuredDirectLiveness =
failure?.code === 'liveness-timeout' &&
(failure.path === 'lan' || failure.path === 'tailscale')
const path =
relayLiveness || (!structuredDirectLiveness && args.activePath === 'relay')
? 'Relay'
: 'The connected host'
return {
likelyCause: `${path} stopped answering authenticated health checks.`,
nextStep: 'Orca closed the stale session and started recovery.',
reportability: relayLiveness ? 'orca-relay' : 'none'
}
}
if (/relay-session-failed|active relay session failed/i.test(evidence)) {
return {
likelyCause: 'The active Relay session closed unexpectedly.',
nextStep: 'Orca started Relay recovery; the event history includes the cell close reason.',
reportability:
failure?.code === 'relay-session-failed' && failure.path === 'relay' ? 'orca-relay' : 'none'
}
}
if (/authentication-rejected|unauthorized|pairing may be revoked/i.test(evidence)) {
return {
likelyCause: 'The desktop rejected this device during authentication.',
nextStep: 'Confirm the device is still paired; pair it again if the rejection repeats.',
reportability: 'none'
}
}
if (/connect-timeout|websocket connect timeout/i.test(evidence)) {
return {
likelyCause: isTailscaleEndpoint(args.endpoint)
? 'The saved Tailscale endpoint did not answer before the connection timeout.'
: 'The saved direct endpoint did not answer before the connection timeout.',
nextStep:
args.pendingPath === 'relay'
? 'Relay recovery is in progress; keep Orca open while it retries.'
: 'Check the local/VPN network and confirm the desktop is awake.',
reportability: 'none'
}
}
if (/handshake-timeout|handshake timeout/i.test(evidence)) {
return {
likelyCause: 'The endpoint opened, but the encrypted Orca handshake did not finish.',
nextStep: 'Confirm the desktop is running a compatible Orca version and retry.',
reportability: 'none'
}
}
if (args.pendingPath === 'relay') {
return {
likelyCause: 'Relay recovery is selected, but no more specific failure is recorded yet.',
nextStep: 'Keep this page open while the next recovery event is recorded.',
reportability: 'none'
}
}
return {
likelyCause: 'No single failure cause can be determined from the recorded events.',
nextStep: 'Run diagnostics and copy the report again after the next connection attempt.',
reportability: 'none'
}
}
export function getReportableConnectionIncidentId(args: DiagnoseConnectionArgs): string | null {
if (diagnoseConnection(args).reportability !== 'orca-relay') {
return null
}
return findCurrentDiagnosticFailure(args.entries)?.id ?? null
}
function findCurrentDiagnosticFailure(
entries: readonly ConnectionLogEntry[]
): ConnectionLogEntry | undefined {
const boundaryIndex = entries.findLastIndex(isDiagnosticBoundary)
return entries
.slice(boundaryIndex + 1)
.toReversed()
.find(isDiagnosticFailure)
}
function isDiagnosticBoundary(entry: ConnectionLogEntry): boolean {
return (
entry.code === 'client-session-started' ||
entry.code === 'app-resumed' ||
entry.code === 'network-changed' ||
entry.code === 'relay-connected' ||
entry.code === 'direct-connected' ||
entry.message === 'Authenticated'
)
}
function isDiagnosticFailure(entry: ConnectionLogEntry): boolean {
const evidence = `${entry.code ?? ''} ${entry.message} ${entry.detail ?? ''}`
return /relay director resolve failed \((?:401|503)\)|liveness-timeout|liveness timeout|connection health check failed|relay-session-failed|active relay session failed|authentication-rejected|unauthorized|pairing may be revoked|connect-timeout|websocket connect timeout|handshake-timeout|handshake timeout/i.test(
evidence
)
}
function parseRetryDelayMs(evidence: string): number | null {
const match = /retry(?:-|\s)?after(?:=|\s)(\d+)ms/i.exec(evidence)
return match ? Number(match[1]) : null
}
function formatDelay(ms: number): string {
return ms < 60_000 ? `${Math.round(ms / 1000)}s` : `${Math.round(ms / 60_000)}m`
}
function formatPath(path: MobileConnectionDiagnosticPath): string {
if (path === 'relay') {
return 'Relay'
}
return path === 'tailscale' ? 'Tailscale/direct' : 'LAN/direct'
}
@@ -13,6 +13,7 @@ describe('buildConnectionDiagnosticsReport', () => {
lastConnectedAt: NOW - 5 * 60_000,
platform: 'ios 26.5.1',
appVersion: '0.0.29',
desktopAppVersion: '1.4.191',
entries: [
{
id: 'log-1',
@@ -26,6 +27,7 @@ describe('buildConnectionDiagnosticsReport', () => {
})
expect(report).toContain('App: Orca Mobile 0.0.29 · ios 26.5.1')
expect(report).toContain('Host Orca version: 1.4.191')
expect(report).toContain('Endpoint: 100.65.9.106:6768 (Tailscale)')
expect(report).toContain('State: reconnecting (reconnect attempts: 12)')
expect(report).toContain('(5m 0s ago)')
@@ -48,8 +50,96 @@ describe('buildConnectionDiagnosticsReport', () => {
})
expect(report).toContain('Endpoint: 192.168.1.50:6768')
expect(report).toContain('Host Orca version: unknown')
expect(report).not.toContain('(Tailscale)')
expect(report).toContain('Last connected: never this session')
expect(report).toContain('No connection events recorded this session.')
expect(report).toContain('No connection events recorded.')
})
it('explains the most likely cause and redacts credentials before copying', () => {
const report = buildConnectionDiagnosticsReport({
hostName: 'Host 3',
endpoint: 'ws://100.88.90.25:6768',
state: 'reconnecting',
reconnectAttempts: 4,
lastConnectedAt: null,
platform: 'android 36',
appVersion: '0.0.46',
activePath: 'tailscale',
pendingPath: 'relay',
entries: [
{
id: 'relay-failure',
ts: NOW - 5_000,
level: 'error',
message: 'Relay: relay dial failed',
detail:
'RelayDirectorHttpError: relay director resolve failed (503); retry after 30000ms; resumeToken=secret-resume-token'
}
],
nowMs: NOW
})
expect(report).toContain(
'Likely cause: Relay service was temporarily unavailable and asked Orca to retry in 30s.'
)
expect(report).toContain('Path: active=tailscale; recovery=relay')
expect(report).toContain('Next step: Keep Orca open; recovery should retry automatically.')
expect(report).toContain('resumeToken=[redacted]')
expect(report).not.toContain('secret-resume-token')
})
it('redacts quoted JSON credentials and never echoes an invalid endpoint', () => {
const report = buildConnectionDiagnosticsReport({
hostName: 'Host 4',
endpoint: 'not-a-url?token=endpoint-secret',
state: 'reconnecting',
reconnectAttempts: 1,
lastConnectedAt: null,
platform: 'android 36',
appVersion: '0.0.47',
entries: [
{
id: 'json-secret',
ts: NOW,
level: 'error',
message: 'Relay failed',
detail: '{"resumeToken":"json-secret","authorization":"Bearer bearer-secret"}'
}
],
nowMs: NOW
})
expect(report).toContain('Endpoint: invalid endpoint')
expect(report).not.toContain('endpoint-secret')
expect(report).not.toContain('json-secret')
expect(report).not.toContain('bearer-secret')
})
it('bounds a single event line before submission while preserving its identity', () => {
const report = buildConnectionDiagnosticsReport({
hostName: 'Host 5',
endpoint: 'ws://192.168.1.2:6768',
state: 'reconnecting',
reconnectAttempts: 1,
lastConnectedAt: null,
platform: 'android 36',
appVersion: '0.0.47',
entries: [
{
id: 'oversized',
ts: NOW,
level: 'error',
message: `newest oversized ${'😀'.repeat(2_000)}`
}
],
nowMs: NOW
})
expect(report).toContain('newest oversized')
expect(report).toContain('[truncated]')
expect(new TextEncoder().encode(report.split('\n').at(-1)!).byteLength).toBeLessThanOrEqual(
2048
)
})
})
@@ -1,6 +1,16 @@
import { isTailscaleEndpoint } from '../../../src/shared/remote-runtime-tailscale-hint'
import type { ConnectionLogEntry, ConnectionState } from '../transport/types'
import type {
ConnectionLogEntry,
ConnectionState,
MobileConnectionDiagnosticPath
} from '../transport/types'
import { normalizeHostAppVersion } from '../transport/host-app-version-store'
import { formatEndpoint } from './host-reachability'
import { diagnoseConnection } from './connection-diagnostics-analysis'
import { redactConnectionLogEntry, redactConnectionLogText } from './connection-log-redaction'
const MAX_EVENT_LINE_BYTES = 2 * 1024
const EVENT_TRUNCATION_MARKER = ' … [truncated]'
// Why: one shareable text blob answering everything we historically had to
// ask reporters one message at a time (endpoint type, state, attempt count,
@@ -13,37 +23,83 @@ export function buildConnectionDiagnosticsReport(args: {
lastConnectedAt: number | null
platform: string
appVersion: string
desktopAppVersion?: string | null
entries: readonly ConnectionLogEntry[]
activePath?: MobileConnectionDiagnosticPath
pendingPath?: MobileConnectionDiagnosticPath | null
nowMs?: number
}): string {
const now = args.nowMs ?? Date.now()
const entries = args.entries.map(redactConnectionLogEntry)
const diagnosis = diagnoseConnection({
endpoint: args.endpoint,
state: args.state,
activePath: args.activePath,
pendingPath: args.pendingPath,
entries
})
const lines: string[] = []
lines.push('Orca Mobile connection diagnostics')
lines.push(`Generated: ${new Date(now).toISOString()}`)
lines.push(`App: Orca Mobile ${args.appVersion} · ${args.platform}`)
lines.push(`Host: ${args.hostName}`)
const desktopAppVersion = normalizeHostAppVersion(args.desktopAppVersion)
lines.push(`Host Orca version: ${desktopAppVersion ?? 'unknown'}`)
lines.push(`Host: ${redactConnectionLogText(args.hostName)}`)
lines.push(
`Endpoint: ${formatEndpoint(args.endpoint)}${isTailscaleEndpoint(args.endpoint) ? ' (Tailscale)' : ''}`
)
lines.push(`State: ${args.state} (reconnect attempts: ${args.reconnectAttempts})`)
if (args.activePath) {
lines.push(
`Path: active=${args.activePath}${args.pendingPath ? `; recovery=${args.pendingPath}` : ''}`
)
}
lines.push(
args.lastConnectedAt == null
? 'Last connected: never this session'
: `Last connected: ${new Date(args.lastConnectedAt).toISOString()} (${formatAgo(now - args.lastConnectedAt)} ago)`
)
lines.push('')
if (args.entries.length === 0) {
lines.push('No connection events recorded this session.')
lines.push(`Likely cause: ${diagnosis.likelyCause}`)
lines.push(`Next step: ${diagnosis.nextStep}`)
lines.push('')
if (entries.length === 0) {
lines.push('No connection events recorded.')
} else {
lines.push(`Connection log (${args.entries.length} events, oldest first):`)
for (const entry of args.entries) {
lines.push(`Recent connection history (${entries.length} events, oldest first):`)
for (const entry of entries) {
const detail = entry.detail ? ` — ${entry.detail}` : ''
lines.push(`${new Date(entry.ts).toISOString()} [${entry.level}] ${entry.message}${detail}`)
const evidence = [entry.code, entry.path].filter(Boolean).join(' · ')
lines.push(
truncateUtf8WithMarker(
`${new Date(entry.ts).toISOString()} [${entry.level}]${evidence ? ` [${evidence}]` : ''} ${entry.message}${detail}`,
MAX_EVENT_LINE_BYTES,
EVENT_TRUNCATION_MARKER
)
)
}
}
return lines.join('\n')
}
function truncateUtf8WithMarker(value: string, maxBytes: number, marker: string): string {
if (new TextEncoder().encode(value).byteLength <= maxBytes) {
return value
}
const markerBytes = new TextEncoder().encode(marker).byteLength
const characters: string[] = []
let bytes = 0
for (const character of value) {
const characterBytes = new TextEncoder().encode(character).byteLength
if (bytes + characterBytes + markerBytes > maxBytes) {
break
}
characters.push(character)
bytes += characterBytes
}
return `${characters.join('')}${marker}`
}
function formatAgo(ms: number): string {
const seconds = Math.max(0, Math.round(ms / 1000))
if (seconds < 60) {
@@ -0,0 +1,107 @@
import { describe, expect, it, vi } from 'vitest'
import {
getDiagnosticsSubmissionState,
readConnectionDiagnosticsSnapshot,
readHydratedConnectionLog,
resolveDiagnosticsHostId,
selectDiagnosticsHostId,
updateDiagnosticsSubmissionState
} from './connection-diagnostics-screen-data'
import type { ConnectionLogEntry, HostProfile } from '../transport/types'
const host = (id: string): HostProfile => ({
id,
name: id,
endpoint: 'ws://192.168.1.2:6768',
deviceToken: 'token',
publicKeyB64: 'key',
lastConnected: 0
})
describe('connection diagnostics screen data', () => {
it('prefers a valid route host when navigation changes', () => {
expect(selectDiagnosticsHostId([host('a'), host('b')], 'b', 'a')).toBe('b')
expect(selectDiagnosticsHostId([host('a')], 'missing', 'missing')).toBe('a')
})
it('applies route changes synchronously without discarding a chip choice for the same route', () => {
const hosts = [host('a'), host('b')]
expect(resolveDiagnosticsHostId(hosts, undefined, null)).toBe('a')
expect(resolveDiagnosticsHostId(hosts, 'b', { hostId: 'a', requestedHostId: 'a' })).toBe('b')
expect(resolveDiagnosticsHostId(hosts, 'b', { hostId: 'a', requestedHostId: 'b' })).toBe('a')
})
it('does not revive a stale manual choice when routing away and back', () => {
const hosts = [host('a'), host('b'), host('c')]
const firstRouteA = {}
const routeC = {}
const secondRouteA = {}
const manualSelection = { hostId: 'b', requestedHostId: 'a', routeKey: firstRouteA }
expect(resolveDiagnosticsHostId(hosts, 'c', manualSelection, routeC)).toBe('c')
expect(resolveDiagnosticsHostId(hosts, 'a', manualSelection, secondRouteA)).toBe('a')
})
it('keeps async submission completion scoped to its host incident', () => {
let states = updateDiagnosticsSubmissionState({}, 'host-a:incident-1', 'sending')
states = updateDiagnosticsSubmissionState(states, 'host-b:incident-2', 'sending')
states = updateDiagnosticsSubmissionState(states, 'host-a:incident-1', 'sent')
expect(getDiagnosticsSubmissionState(states, 'host-a:incident-1')).toBe('sent')
expect(getDiagnosticsSubmissionState(states, 'host-b:incident-2')).toBe('sending')
})
it('waits for hydration and then reads the refreshed log', async () => {
const entries: ConnectionLogEntry[] = []
const store = {
hydrate: vi.fn(async () => {
entries.push({ id: 'stored', ts: 1, level: 'info', message: 'stored event' })
}),
get: vi.fn(() => entries)
}
await expect(readHydratedConnectionLog(store, 'host-a')).resolves.toEqual(entries)
expect(store.get).toHaveBeenCalledAfter(store.hydrate)
})
it('retries one transient hydration failure before reading', async () => {
const entries = [{ id: 'stored', ts: 1, level: 'info' as const, message: 'stored event' }]
const store = {
hydrate: vi
.fn()
.mockRejectedValueOnce(new Error('storage unavailable'))
.mockResolvedValueOnce(undefined),
get: vi.fn(() => entries)
}
await expect(readHydratedConnectionLog(store, 'host-a')).resolves.toEqual(entries)
expect(store.hydrate).toHaveBeenCalledTimes(2)
})
it('reads connection metadata after hydration completes', async () => {
let state: 'connecting' | 'reconnecting' = 'connecting'
const store = {
hydrate: vi.fn(async () => {
state = 'reconnecting'
}),
get: vi.fn(() => [{ id: 'new', ts: 2, level: 'warn' as const, message: 'new event' }])
}
const context = {
getState: vi.fn(() => state),
getReconnectAttempt: vi.fn(() => 2),
getLastConnectedAt: vi.fn(() => 1),
getActivePath: vi.fn(() => 'relay' as const),
getPendingPath: vi.fn(() => null)
}
await expect(readConnectionDiagnosticsSnapshot(context, store, 'host-a')).resolves.toEqual({
state: 'reconnecting',
reconnectAttempts: 2,
lastConnectedAt: 1,
activePath: 'relay',
pendingPath: null,
entries: [{ id: 'new', ts: 2, level: 'warn', message: 'new event' }]
})
expect(context.getState).toHaveBeenCalledAfter(store.hydrate)
})
})
@@ -0,0 +1,94 @@
import type { ConnectionLogStore } from '../transport/connection-log-buffer'
import type { ConnectionLogEntry, HostProfile } from '../transport/types'
import type { RpcClientContextValue } from '../transport/rpc-client-context-contract'
export type DiagnosticsHostSelection = {
hostId: string
requestedHostId: string | undefined
routeKey?: object
}
export type DiagnosticsSubmissionState = 'sending' | 'sent' | 'failed'
export type DiagnosticsSubmissionStates = Readonly<Record<string, DiagnosticsSubmissionState>>
export function selectDiagnosticsHostId(
hosts: readonly HostProfile[],
requestedHostId: string | undefined,
previousHostId: string | null
): string | null {
if (requestedHostId && hosts.some((host) => host.id === requestedHostId)) {
return requestedHostId
}
if (previousHostId && hosts.some((host) => host.id === previousHostId)) {
return previousHostId
}
return hosts[0]?.id ?? null
}
export function resolveDiagnosticsHostId(
hosts: readonly HostProfile[],
requestedHostId: string | undefined,
manualSelection: DiagnosticsHostSelection | null,
routeKey?: object
): string | null {
const selected = manualSelection
if (selected && selected.requestedHostId === requestedHostId && selected.routeKey === routeKey) {
const manualHostId = selected.hostId
if (hosts.some((host) => host.id === manualHostId)) {
return manualHostId
}
}
return selectDiagnosticsHostId(hosts, requestedHostId, null)
}
export function getDiagnosticsSubmissionState(
states: DiagnosticsSubmissionStates,
key: string | null
): DiagnosticsSubmissionState | 'idle' {
return key ? (states[key] ?? 'idle') : 'idle'
}
export function updateDiagnosticsSubmissionState(
states: DiagnosticsSubmissionStates,
key: string,
state: DiagnosticsSubmissionState | null
): DiagnosticsSubmissionStates {
const next = { ...states }
if (state) {
next[key] = state
} else {
delete next[key]
}
return next
}
export async function readHydratedConnectionLog(
store: Pick<ConnectionLogStore, 'hydrate' | 'get'>,
hostId: string
): Promise<readonly ConnectionLogEntry[]> {
try {
await store.hydrate(hostId)
} catch {
await store.hydrate(hostId).catch(() => {})
}
return store.get(hostId)
}
export async function readConnectionDiagnosticsSnapshot(
context: Pick<
RpcClientContextValue,
'getState' | 'getReconnectAttempt' | 'getLastConnectedAt' | 'getActivePath' | 'getPendingPath'
>,
store: Pick<ConnectionLogStore, 'hydrate' | 'get'>,
hostId: string
) {
const entries = await readHydratedConnectionLog(store, hostId)
return {
state: context.getState(hostId),
reconnectAttempts: context.getReconnectAttempt(hostId),
lastConnectedAt: context.getLastConnectedAt(hostId),
activePath: context.getActivePath(hostId),
pendingPath: context.getPendingPath(hostId),
entries
}
}
@@ -0,0 +1,71 @@
import { describe, expect, it, vi } from 'vitest'
import {
boundConnectionDiagnosticsReport,
submitConnectionDiagnostics
} from './connection-diagnostics-submission'
describe('submitConnectionDiagnostics', () => {
it('sends a bounded report through the diagnostics lane', async () => {
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(new Response(null, { status: 204 }))
const result = await submitConnectionDiagnostics(
{ report: 'x'.repeat(100_000), appVersion: '0.0.47', platform: 'android 36' },
fetchImpl
)
expect(result).toEqual({ ok: true })
expect(fetchImpl).toHaveBeenCalledWith(
'https://www.onorca.dev/v1/feedback',
expect.objectContaining({ method: 'POST' })
)
const request = fetchImpl.mock.calls[0]?.[1]
const body = JSON.parse(String(request?.body)) as { feedback: string; submissionType: string }
expect(body.submissionType).toBe('connection_diagnostics')
expect(new TextEncoder().encode(body.feedback).byteLength).toBeLessThanOrEqual(64 * 1024)
})
it('returns a safe failure for a rejected response', async () => {
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(new Response(null, { status: 503 }))
await expect(
submitConnectionDiagnostics(
{ report: 'report', appVersion: '0.0.47', platform: 'android' },
fetchImpl
)
).resolves.toEqual({ ok: false, error: 'status 503' })
})
it('preserves the newest complete events when bounding a UTF-8 report', () => {
const report = [
'Orca Mobile connection diagnostics',
'State: reconnecting',
'',
'Recent connection history (3 events, oldest first):',
`oldest ${'😀'.repeat(20)}`,
`middle ${'😀'.repeat(20)}`,
`newest ${'😀'.repeat(20)}`
].join('\n')
const bounded = boundConnectionDiagnosticsReport(report, 180)
expect(new TextEncoder().encode(bounded).byteLength).toBeLessThanOrEqual(180)
expect(bounded).toContain('newest')
expect(bounded).not.toContain('oldest')
expect(bounded).toMatch(/older omitted/)
})
it('retains a bounded form of the newest event when that event exceeds the budget', () => {
const report = [
'Orca Mobile connection diagnostics',
'State: reconnecting',
'',
'Recent connection history (2 events, oldest first):',
'old event',
`newest oversized ${'😀'.repeat(1_000)}`
].join('\n')
const bounded = boundConnectionDiagnosticsReport(report, 220)
expect(new TextEncoder().encode(bounded).byteLength).toBeLessThanOrEqual(220)
expect(bounded).toContain('newest oversized')
expect(bounded).toContain('[truncated]')
})
})
@@ -0,0 +1,125 @@
const CONNECTION_DIAGNOSTICS_ENDPOINT = 'https://www.onorca.dev/v1/feedback'
const SUBMISSION_TIMEOUT_MS = 10_000
const MAX_SUBMISSION_BYTES = 64 * 1024
export type ConnectionDiagnosticsSubmission = {
report: string
appVersion: string
platform: string
}
export type ConnectionDiagnosticsSubmissionResult = { ok: true } | { ok: false; error: string }
/** Sends only the already-redacted, bounded report after an explicit user tap. */
export async function submitConnectionDiagnostics(
submission: ConnectionDiagnosticsSubmission,
fetchImpl: typeof fetch = fetch
): Promise<ConnectionDiagnosticsSubmissionResult> {
const report = boundConnectionDiagnosticsReport(submission.report)
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), SUBMISSION_TIMEOUT_MS)
try {
const response = await fetchImpl(CONNECTION_DIAGNOSTICS_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
feedback: report,
submissionType: 'connection_diagnostics',
githubLogin: null,
githubEmail: null,
appVersion: submission.appVersion,
platform: `mobile-${submission.platform}`,
osRelease: 'unknown',
arch: 'unknown'
}),
signal: controller.signal
})
return response.ok ? { ok: true } : { ok: false, error: `status ${response.status}` }
} catch (error) {
return {
ok: false,
error: controller.signal.aborted
? 'request timed out'
: error instanceof Error
? error.message
: 'request failed'
}
} finally {
clearTimeout(timeout)
}
}
export function boundConnectionDiagnosticsReport(
report: string,
maxBytes: number = MAX_SUBMISSION_BYTES
): string {
if (utf8Bytes(report) <= maxBytes) {
return report
}
const lines = report.split('\n')
const historyIndex = lines.findIndex((line) => line.startsWith('Recent connection history ('))
if (historyIndex === -1) {
return truncateUtf8(report, maxBytes)
}
const header = lines.slice(0, historyIndex)
const events = lines.slice(historyIndex + 1)
let kept: string[] = []
for (let index = events.length - 1; index >= 0; index--) {
const candidateEvents = [events[index]!, ...kept]
const candidate = formatBoundedReport(header, candidateEvents, events.length)
if (utf8Bytes(candidate) > maxBytes) {
break
}
kept = candidateEvents
}
if (kept.length === 0 && events.length > 0) {
return formatReportWithTruncatedNewestEvent(header, events, maxBytes)
}
return truncateUtf8(formatBoundedReport(header, kept, events.length), maxBytes)
}
function formatReportWithTruncatedNewestEvent(
header: string[],
events: string[],
maxBytes: number
): string {
const history = `Recent connection history (1 newest event; ${events.length - 1} older omitted):`
const prefix = [...header, history].join('\n') + '\n'
const availableBytes = maxBytes - utf8Bytes(prefix)
if (availableBytes <= 0) {
return truncateUtf8(prefix, maxBytes)
}
const marker = ' … [truncated]'
const markerBytes = utf8Bytes(marker)
if (availableBytes <= markerBytes) {
return truncateUtf8(prefix, maxBytes)
}
return `${prefix}${truncateUtf8(events.at(-1)!, availableBytes - markerBytes)}${marker}`
}
function formatBoundedReport(header: string[], events: string[], totalEvents: number): string {
const omitted = totalEvents - events.length
return [
...header,
`Recent connection history (${events.length} newest events; ${omitted} older omitted):`,
...events
].join('\n')
}
function truncateUtf8(value: string, maxBytes: number): string {
const characters: string[] = []
let bytes = 0
for (const character of value) {
const characterBytes = utf8Bytes(character)
if (bytes + characterBytes > maxBytes) {
break
}
characters.push(character)
bytes += characterBytes
}
return characters.join('')
}
function utf8Bytes(value: string): number {
return new TextEncoder().encode(value).byteLength
}
@@ -0,0 +1,51 @@
import type { ConnectionLogEntry } from '../transport/types'
const SECRET_JSON_ASSIGNMENT =
/(["'])(resumeToken|deviceToken|authorization|credential|token|publicKeyB64)\1(\s*:\s*)(["'])(?:Bearer\s+)?(?:\\.|(?!\4)[^\\\r\n])*\4/gi
const SECRET_QUOTED_VALUE_ASSIGNMENT =
/\b(resumeToken|deviceToken|authorization|credential|token|publicKeyB64)(\s*[:=]\s*)(["'])(?:Bearer\s+)?(?:\\.|(?!\3)[^\\\r\n])*\3/gi
const SECRET_UNTERMINATED_JSON_ASSIGNMENT =
/(["'])(resumeToken|deviceToken|authorization|credential|token|publicKeyB64)\1(\s*:\s*)(["'])(?:Bearer\s+)?(?:\\.|(?!\4)[^\\\r\n])*(?=\r?\n|$)/gi
const SECRET_UNTERMINATED_QUOTED_VALUE_ASSIGNMENT =
/\b(resumeToken|deviceToken|authorization|credential|token|publicKeyB64)(\s*[:=]\s*)(["'])(?:Bearer\s+)?(?:\\.|(?!\3)[^\\\r\n])*(?=\r?\n|$)/gi
const SECRET_ASSIGNMENT =
/\b(resumeToken|deviceToken|authorization|credential|token|publicKeyB64)(\s*[:=]\s*)(?:Bearer\s+)?([^\s;,}"']+)/gi
const SECRET_QUERY = /([?&](?:token|credential|resumeToken|deviceToken)=)[^&#\s]+/gi
const URL_USERINFO = /((?:wss?|https?):\/\/)[^\s/?#]*@/gi
export function redactConnectionLogText(value: string): string {
return value
.replace(
SECRET_JSON_ASSIGNMENT,
(_match, keyQuote: string, label: string, separator: string, valueQuote: string) =>
`${keyQuote}${label}${keyQuote}${separator}${valueQuote}[redacted]${valueQuote}`
)
.replace(
SECRET_QUOTED_VALUE_ASSIGNMENT,
(_match, label: string, separator: string, valueQuote: string) =>
`${label}${separator}${valueQuote}[redacted]${valueQuote}`
)
.replace(
SECRET_UNTERMINATED_JSON_ASSIGNMENT,
(_match, keyQuote: string, label: string, separator: string, valueQuote: string) =>
`${keyQuote}${label}${keyQuote}${separator}${valueQuote}[redacted]`
)
.replace(
SECRET_UNTERMINATED_QUOTED_VALUE_ASSIGNMENT,
(_match, label: string, separator: string, valueQuote: string) =>
`${label}${separator}${valueQuote}[redacted]`
)
.replace(SECRET_ASSIGNMENT, (_match, label: string, separator: string) => {
return `${label}${separator}[redacted]`
})
.replace(SECRET_QUERY, '$1[redacted]')
.replace(URL_USERINFO, '$1[redacted]@')
}
export function redactConnectionLogEntry(entry: ConnectionLogEntry): ConnectionLogEntry {
return {
...entry,
message: redactConnectionLogText(entry.message),
...(entry.detail ? { detail: redactConnectionLogText(entry.detail) } : {})
}
}
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { testHostReachability, unreachableHostDetail } from './host-reachability'
import { formatEndpoint, testHostReachability, unreachableHostDetail } from './host-reachability'
describe('unreachableHostDetail', () => {
it('points at Tailscale for tailnet CGNAT endpoints', () => {
@@ -21,6 +21,10 @@ describe('unreachableHostDetail', () => {
it('does not treat non-CGNAT 100.x addresses as Tailscale', () => {
expect(unreachableHostDetail('ws://100.20.1.5:6768')).toBe('Cannot reach 100.20.1.5:6768')
})
it('does not echo malformed endpoints that may contain credentials', () => {
expect(formatEndpoint('not-a-url?token=secret')).toBe('invalid endpoint')
})
})
describe('testHostReachability', () => {
+1 -1
View File
@@ -55,7 +55,7 @@ export function formatEndpoint(endpoint: string): string {
const url = new URL(endpoint)
return url.host
} catch {
return endpoint
return 'invalid endpoint'
}
}
+2
View File
@@ -177,6 +177,8 @@ export function MobileHomeScreen() {
onDismiss: () => setActionTarget(null),
onReconnect: (hostId) => void forceReconnectHost(hostId),
onDisconnect: disconnectHostClient,
onDiagnostics: (hostId) =>
data.router.push({ pathname: '/connection-log', params: { hostId } }),
onEdit: openMobileHostEdit,
onRemove: (host) => setConfirmRemove(host)
})}
@@ -3,6 +3,7 @@ import { getHostListActionSheetActions } from './host-list-action-sheet-actions'
import type { ConnectionState, HostProfile } from './transport/types'
vi.mock('lucide-react-native', () => ({
Activity: vi.fn(),
Edit3: vi.fn(),
PowerOff: vi.fn(),
RefreshCw: vi.fn()
@@ -22,6 +23,7 @@ function build(overrides: { state?: ConnectionState; hasEverConnected?: boolean
onDismiss: vi.fn(),
onReconnect: vi.fn(),
onDisconnect: vi.fn(),
onDiagnostics: vi.fn(),
onEdit: vi.fn(),
onRemove: vi.fn()
}
@@ -35,14 +37,17 @@ function build(overrides: { state?: ConnectionState; hasEverConnected?: boolean
}
describe('getHostListActionSheetActions', () => {
// Why: both open a second drawer. Presenting one while this sheet's native
// Why: these navigate or open a second drawer. Presenting while this sheet's native
// Modal is still up freezes the whole screen on iOS — issue #8791.
it.each(['Edit host', 'Remove'])('defers %s until the action sheet has closed', (label) => {
const { actions } = build()
expect(actions.find((action) => action.label === label)).toMatchObject({
closeBeforePress: true
})
})
it.each(['Network diagnostics', 'Edit host', 'Remove'])(
'defers %s until the action sheet has closed',
(label) => {
const { actions } = build()
expect(actions.find((action) => action.label === label)).toMatchObject({
closeBeforePress: true
})
}
)
it('leaves the in-place actions undeferred so they fire on tap', () => {
const { actions, spies } = build()
@@ -65,15 +70,23 @@ describe('getHostListActionSheetActions', () => {
expect(spies.onEdit).toHaveBeenCalledWith(HOST.id)
})
it('routes Network diagnostics with the selected host', () => {
const { actions, spies } = build()
actions.find((action) => action.label === 'Network diagnostics')?.onPress()
expect(spies.onDiagnostics).toHaveBeenCalledWith(HOST.id)
})
it('offers Disconnect only while the socket is live', () => {
expect(build({ state: 'reconnecting' }).actions.map((action) => action.label)).toEqual([
'Reconnect',
'Disconnect',
'Network diagnostics',
'Edit host',
'Remove'
])
expect(build({ state: 'disconnected' }).actions.map((action) => action.label)).toEqual([
'Connect',
'Network diagnostics',
'Edit host',
'Remove'
])
@@ -93,6 +106,7 @@ describe('getHostListActionSheetActions', () => {
onDismiss: vi.fn(),
onReconnect: vi.fn(),
onDisconnect: vi.fn(),
onDiagnostics: vi.fn(),
onEdit: vi.fn(),
onRemove: vi.fn()
})
+12 -3
View File
@@ -1,9 +1,9 @@
import { Edit3, PowerOff, RefreshCw } from 'lucide-react-native'
import { Activity, Edit3, PowerOff, RefreshCw } from 'lucide-react-native'
import type { ActionSheetAction } from './components/ActionSheetModal'
import type { ConnectionState, HostProfile } from './transport/types'
/** Builds the home-screen host long-press menu. Edit and Remove open a second
* drawer, so both must defer until this sheet's native Modal has unmounted —
/** Builds the home-screen host long-press menu. Navigation and second drawers
* defer until this sheet's native Modal has unmounted —
* presenting into a live one freezes the whole screen on iOS (issue #8791). */
export function getHostListActionSheetActions(args: {
host: HostProfile | null
@@ -13,6 +13,7 @@ export function getHostListActionSheetActions(args: {
onDismiss: () => void
onReconnect: (hostId: string) => void
onDisconnect: (hostId: string) => void
onDiagnostics: (hostId: string) => void
onEdit: (hostId: string) => void
onRemove: (host: HostProfile) => void
}): ActionSheetAction[] {
@@ -47,6 +48,14 @@ export function getHostListActionSheetActions(args: {
}
]
: []),
{
label: 'Network diagnostics',
icon: Activity,
closeBeforePress: true,
onPress: () => {
args.onDiagnostics(host.id)
}
},
{
label: 'Edit host',
icon: Edit3,
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'
import { useRpcClientContext, type RpcClientContextValue } from './client-context'
import { useRpcClientContext } from './client-context'
import type { RpcClientContextValue } from './rpc-client-context-contract'
import type { MobileConnectionPath } from './stable-logical-rpc-client'
export function useReconnectAttempt(hostId: string | undefined): number {
@@ -10,6 +11,20 @@ export function useLastConnectedAt(hostId: string | undefined): number | null {
return useHostMetric(hostId, (context, id) => context.getLastConnectedAt(id), null)
}
export function useConnectionPathStatus(hostId: string | undefined): {
activePath: MobileConnectionPath
pendingPath: MobileConnectionPath | null
} {
return useHostMetric(
hostId,
(context, id) => ({
activePath: context.getActivePath(id),
pendingPath: context.getPendingPath(id)
}),
{ activePath: 'lan', pendingPath: null }
)
}
// Both inputs classifyConnection needs about a relay recovery, read from one
// subscription so a screen cannot render half of the escalation.
export function useRelayRecoveryStatus(hostId: string | undefined): {
+3 -3
View File
@@ -21,6 +21,7 @@ import {
import { HostOpenRetryScheduler } from './host-open-retry-scheduler'
import { openHostClientEntry, type HostClientStoreEntry } from './host-entry-opener'
import { shouldPreserveActiveRelay } from './relay-reconnect-preservation'
import { recordConnectionRevival } from './persisted-connection-log-store'
import {
createHostClientSelectors,
listHostClients,
@@ -35,8 +36,6 @@ import type { ConnectionState, HostProfile } from './types'
import type { RpcClientContextValue } from './rpc-client-context-contract'
type StoreEntry = HostClientStoreEntry
export type { HostClientAcquisition } from './host-client-acquisition-registry'
export type { RpcClientContextValue } from './rpc-client-context-contract'
const Ctx = createContext<RpcClientContextValue | null>(null)
@@ -311,7 +310,8 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
for (const hostId of pendingAcquisitionsRef.current.keys()) {
retrySchedulerRef.current?.expedite(hostId)
}
for (const entry of storeRef.current.values()) {
for (const [hostId, entry] of storeRef.current) {
recordConnectionRevival(hostId, reason)
try {
entry.client.notifyForeground(reason)
} catch {
@@ -53,4 +53,154 @@ describe('connection log buffer', () => {
store.append('host-a', entry(2))
expect(onA).toHaveBeenCalledTimes(1)
})
it('hydrates persisted history without dropping events recorded during app startup', async () => {
let finishLoad: (entries: readonly ConnectionLogEntry[]) => void = () => {}
const load = vi.fn(
() =>
new Promise<readonly ConnectionLogEntry[]>((resolve) => {
finishLoad = resolve
})
)
const save = vi.fn(async () => {})
const store = createConnectionLogStore(3, { load, save })
store.append('host-a', entry(3))
finishLoad([entry(1), entry(2)])
await store.hydrate('host-a')
expect(store.get('host-a').map((e) => e.id)).toEqual(['log-1', 'log-2', 'log-3'])
await vi.waitFor(() => expect(save).toHaveBeenCalled())
expect(save).toHaveBeenLastCalledWith('host-a', [entry(1), entry(2), entry(3)])
})
it('redacts credentials before retaining or persisting an event', async () => {
const save = vi.fn(async () => {})
const store = createConnectionLogStore(3, { load: async () => [], save })
store.append('host-a', {
...entry(1),
detail: 'resumeToken=do-not-copy deviceToken:also-secret'
})
await store.hydrate('host-a')
expect(store.get('host-a')[0]?.detail).toBe('resumeToken=[redacted] deviceToken:[redacted]')
await vi.waitFor(() => expect(save).toHaveBeenCalled())
expect(JSON.stringify(save.mock.calls)).not.toContain('do-not-copy')
expect(JSON.stringify(save.mock.calls)).not.toContain('also-secret')
})
it('redacts quoted credential values when the object key is unquoted', async () => {
const store = createConnectionLogStore(3, { load: async () => [], save: async () => {} })
store.append('host-a', {
...entry(1),
detail: `resumeToken: 'secret-one' deviceToken="secret-two"`
})
await store.hydrate('host-a')
expect(store.get('host-a')[0]?.detail).toBe(
`resumeToken: '[redacted]' deviceToken="[redacted]"`
)
})
it('redacts the full quoted credential when its value contains an escaped quote', async () => {
const store = createConnectionLogStore(3, { load: async () => [], save: async () => {} })
store.append('host-a', {
...entry(1),
detail: String.raw`{"token":"secret\"tail"} token: 'secret\'tail'`
})
await store.hydrate('host-a')
expect(store.get('host-a')[0]?.detail).toBe(`{"token":"[redacted]"} token: '[redacted]'`)
})
it('redacts unterminated quoted credentials', async () => {
const store = createConnectionLogStore(3, { load: async () => [], save: async () => {} })
store.append('host-a', {
...entry(1),
message: 'token="secret',
detail: '{"authorization":"Bearer also-secret'
})
await store.hydrate('host-a')
expect(store.get('host-a')[0]).toMatchObject({
message: 'token="[redacted]',
detail: '{"authorization":"[redacted]'
})
})
it('redacts URL userinfo through the last authority separator', async () => {
const store = createConnectionLogStore(3, { load: async () => [], save: async () => {} })
store.append('host-a', {
...entry(1),
message: 'wss://user:p@ss@example.com/x',
detail: 'https://user:pass@example.com/x'
})
await store.hydrate('host-a')
expect(store.get('host-a')[0]).toMatchObject({
message: 'wss://[redacted]@example.com/x',
detail: 'https://[redacted]@example.com/x'
})
})
it('does not overwrite persisted history when hydration fails and retries later', async () => {
const load = vi
.fn<() => Promise<readonly ConnectionLogEntry[]>>()
.mockRejectedValueOnce(new Error('storage unavailable'))
.mockResolvedValueOnce([entry(1)])
const save = vi.fn(async () => {})
const store = createConnectionLogStore(3, { load, save })
store.append('host-a', entry(2))
await expect(store.hydrate('host-a')).rejects.toThrow('storage unavailable')
expect(save).not.toHaveBeenCalled()
await store.hydrate('host-a')
expect(store.get('host-a').map((value) => value.id)).toEqual(['log-1', 'log-2'])
})
it('preserves legacy entries that reused the same event id', async () => {
const store = createConnectionLogStore(3, {
load: async () => [entry(1), { ...entry(2), id: 'log-1' }],
save: async () => {}
})
await store.hydrate('host-a')
expect(store.get('host-a').map((value) => value.message)).toEqual(['event 1', 'event 2'])
})
it('retries one transient persistence failure without requiring another append', async () => {
const save = vi.fn().mockResolvedValue(undefined)
const store = createConnectionLogStore(3, { load: async () => [], save })
await store.hydrate('host-a')
await vi.waitFor(() => expect(save).toHaveBeenCalled())
save.mockReset()
save.mockRejectedValueOnce(new Error('storage unavailable')).mockResolvedValueOnce(undefined)
store.append('host-a', entry(1))
await vi.waitFor(() => expect(save).toHaveBeenCalledTimes(2))
expect(save).toHaveBeenLastCalledWith('host-a', [entry(1)])
})
it('does not retry failed automatic hydration for every appended event', async () => {
const load = vi.fn().mockRejectedValue(new Error('storage unavailable'))
const store = createConnectionLogStore(3, { load, save: async () => {} })
store.append('host-a', entry(1))
await expect(store.hydrate('host-a')).rejects.toThrow('storage unavailable')
store.append('host-a', entry(2))
store.append('host-a', entry(3))
await Promise.resolve()
expect(load).toHaveBeenCalledTimes(1)
await expect(store.hydrate('host-a')).rejects.toThrow('storage unavailable')
expect(load).toHaveBeenCalledTimes(2)
})
})
+98 -14
View File
@@ -1,4 +1,5 @@
import type { ConnectionLogEntry } from './types'
import { redactConnectionLogEntry } from '../diagnostics/connection-log-redaction'
// Why: the rpc-client's onLog entries were only wired during pairing; for
// long-lived host connections everything went to console.log, invisible to
@@ -12,20 +13,108 @@ const MAX_ENTRIES_PER_HOST = 200
export type ConnectionLogStore = {
append: (hostId: string, entry: ConnectionLogEntry) => void
get: (hostId: string) => readonly ConnectionLogEntry[]
hydrate: (hostId: string) => Promise<void>
subscribe: (hostId: string, listener: () => void) => () => void
}
export type ConnectionLogPersistence = {
load: (hostId: string) => Promise<readonly ConnectionLogEntry[]>
save: (hostId: string, entries: readonly ConnectionLogEntry[]) => Promise<void>
}
export function createConnectionLogStore(
maxEntriesPerHost: number = MAX_ENTRIES_PER_HOST
maxEntriesPerHost: number = MAX_ENTRIES_PER_HOST,
persistence?: ConnectionLogPersistence
): ConnectionLogStore {
const entriesByHost = new Map<string, ConnectionLogEntry[]>()
const listenersByHost = new Map<string, Set<() => void>>()
const hydratedHosts = new Set<string>()
const hydrationFailedHosts = new Set<string>()
const hydrationByHost = new Map<string, Promise<void>>()
const saveByHost = new Map<string, Promise<void>>()
// Why: useSyncExternalStore compares snapshots by reference — getSnapshot
// must return the SAME array until the data actually changes, or React
// loops re-rendering. Cache per host; invalidate on append.
const snapshotByHost = new Map<string, readonly ConnectionLogEntry[]>()
const EMPTY: readonly ConnectionLogEntry[] = []
const trim = (entries: ConnectionLogEntry[]): void => {
if (entries.length > maxEntriesPerHost) {
entries.splice(0, entries.length - maxEntriesPerHost)
}
}
const notify = (hostId: string): void => {
snapshotByHost.delete(hostId)
const listeners = listenersByHost.get(hostId)
if (listeners) {
for (const listener of listeners) {
listener()
}
}
}
const persist = (hostId: string): void => {
if (!persistence || !hydratedHosts.has(hostId)) {
return
}
const snapshot = [...(entriesByHost.get(hostId) ?? [])]
const previous = saveByHost.get(hostId) ?? Promise.resolve()
const pending = previous
.catch(() => {})
.then(async () => {
try {
await persistence.save(hostId, snapshot)
} catch {
await persistence.save(hostId, snapshot)
}
})
.catch(() => {})
saveByHost.set(hostId, pending)
}
const hydrateHost = async (hostId: string, retryAfterFailure: boolean): Promise<void> => {
if (!persistence || hydratedHosts.has(hostId)) {
return
}
const existing = hydrationByHost.get(hostId)
if (existing) {
return existing
}
if (!retryAfterFailure && hydrationFailedHosts.has(hostId)) {
return
}
const pending = persistence
.load(hostId)
.then((stored) => {
const live = entriesByHost.get(hostId) ?? []
const seen = new Set<string>()
const merged: ConnectionLogEntry[] = []
for (const entry of [...stored, ...live]) {
const redacted = redactConnectionLogEntry(entry)
const fingerprint = JSON.stringify(redacted)
if (!seen.has(fingerprint)) {
seen.add(fingerprint)
merged.push(redacted)
}
}
merged.sort((a, b) => a.ts - b.ts)
trim(merged)
entriesByHost.set(hostId, merged)
hydratedHosts.add(hostId)
hydrationFailedHosts.delete(hostId)
notify(hostId)
persist(hostId)
})
.catch((error: unknown) => {
hydrationFailedHosts.add(hostId)
throw error
})
.finally(() => hydrationByHost.delete(hostId))
hydrationByHost.set(hostId, pending)
return pending
}
return {
append(hostId, entry) {
let entries = entriesByHost.get(hostId)
@@ -33,17 +122,12 @@ export function createConnectionLogStore(
entries = []
entriesByHost.set(hostId, entries)
}
entries.push(entry)
if (entries.length > maxEntriesPerHost) {
entries.splice(0, entries.length - maxEntriesPerHost)
}
snapshotByHost.delete(hostId)
const listeners = listenersByHost.get(hostId)
if (listeners) {
for (const listener of listeners) {
listener()
}
}
entries.push(redactConnectionLogEntry(entry))
trim(entries)
notify(hostId)
void hydrateHost(hostId, false)
.then(() => persist(hostId))
.catch(() => {})
},
get(hostId) {
@@ -60,6 +144,8 @@ export function createConnectionLogStore(
return snapshot
},
hydrate: (hostId) => hydrateHost(hostId, true),
subscribe(hostId, listener) {
let listeners = listenersByHost.get(hostId)
if (!listeners) {
@@ -80,5 +166,3 @@ export function createConnectionLogStore(
}
}
}
export const connectionLogStore = createConnectionLogStore()
@@ -0,0 +1,46 @@
import { isTailscaleEndpoint } from '../../../src/shared/remote-runtime-tailscale-hint'
import type { LivenessTimeoutEvidence } from './rpc-session-liveness-watchdog'
import type {
ConnectionLogEntry,
ConnectionLogLevel,
ConnectionLogSink,
MobileConnectionDiagnosticPath
} from './types'
export class DirectConnectionLog {
private sequence = 0
private readonly path: MobileConnectionDiagnosticPath
constructor(
endpoint: string,
private readonly sink?: ConnectionLogSink
) {
this.path = isTailscaleEndpoint(endpoint) ? 'tailscale' : 'lan'
}
emit = (
level: ConnectionLogLevel,
message: string,
detail?: string,
evidence?: Pick<ConnectionLogEntry, 'code' | 'path'>
): void => {
this.sink?.({
id: `log-${++this.sequence}-${Date.now()}`,
ts: Date.now(),
level,
message,
detail,
...evidence,
path: evidence?.path ?? this.path
})
}
livenessTimeout = (evidence: LivenessTimeoutEvidence): void => {
this.emit(
'error',
'Connection health check failed',
`${evidence.reason}; ${evidence.missedProbes}/${evidence.missedProbeLimit} probes missed; last authenticated activity ${evidence.lastInboundAgeMs}ms ago`,
{ code: 'liveness-timeout' }
)
}
}
+16 -23
View File
@@ -1,4 +1,5 @@
import type { ConnectOptions, RpcClient, SendRequestOptions } from './rpc-client'
import { DirectConnectionLog } from './direct-connection-log'
import { RpcClientAuthenticationRetry } from './rpc-client-authentication-retry'
import { RpcClientConnectionState } from './rpc-client-connection-state'
import {
@@ -16,12 +17,7 @@ import {
} from './rpc-client-stream-registry'
import { RpcSessionLivenessWatchdog } from './rpc-session-liveness-watchdog'
import { isStaleForegroundDial } from './rpc-stale-dial'
import type {
ConnectionLogLevel,
ConnectionState,
ForegroundNudgeReason,
RpcResponse
} from './types'
import type { ConnectionState, ForegroundNudgeReason, RpcResponse } from './types'
const LIVENESS_REQUEST_ID_PREFIX = 'mobile-liveness-'
@@ -36,7 +32,7 @@ export class DirectRpcClient implements RpcClient {
private readonly authenticationRetry: RpcClientAuthenticationRetry
private readonly socketClose: RpcClientSocketCloseController
private requestCounter = 0
private logCounter = 0
private readonly connectionLog: DirectConnectionLog
private intentionallyClosed = false
private authenticationGeneration = 0
private livenessSession: RpcClientSocketSession | null = null
@@ -47,10 +43,12 @@ export class DirectRpcClient implements RpcClient {
serverPublicKeyB64: string,
private readonly options: ConnectOptions
) {
this.connectionLog = new DirectConnectionLog(endpoint, options.onLog)
this.reconnect = new RpcClientReconnectSchedule({
openConnection: () => this.openConnection(),
rejectConnectWaiters: (reason) => this.connectionState.rejectWaiters(reason),
emitLog: (message, detail) => this.emitLog('info', message, detail)
emitLog: (message, detail) =>
this.connectionLog.emit('info', message, detail, { code: 'retry-scheduled' })
})
this.connectionState = new RpcClientConnectionState({
endpoint,
@@ -78,7 +76,8 @@ export class DirectRpcClient implements RpcClient {
if (identity === this.livenessSession && this.socketSession === this.livenessSession) {
this.socketClose.forceClose(this.livenessSession)
}
}
},
onTimeout: this.connectionLog.livenessTimeout
})
this.socketFactory = new RpcClientSocketFactory({
endpoint,
@@ -89,7 +88,7 @@ export class DirectRpcClient implements RpcClient {
getReconnectAttempt: () => this.getReconnectAttempt(),
getLastConnectedAt: () => this.getLastConnectedAt(),
isIntentionallyClosed: () => this.intentionallyClosed,
emitLog: (level, message, detail) => this.emitLog(level, message, detail),
emitLog: this.connectionLog.emit,
onHandshakeStarted: () => this.connectionState.publish('handshaking'),
onAuthenticated: (session) => this.handleAuthenticated(session),
onAuthRejected: (reason) => this.authenticationRetry.reject(reason),
@@ -102,7 +101,8 @@ export class DirectRpcClient implements RpcClient {
this.authenticationRetry = new RpcClientAuthenticationRetry({
endpoint,
stopLiveness: () => this.stopLiveness(),
emitWarning: (message, detail) => this.emitLog('warn', message, detail),
emitWarning: (message, detail) =>
this.connectionLog.emit('warn', message, detail, { code: 'authentication-rejected' }),
retry: (reason) => this.retryAuthentication(reason),
latchFailure: (reason) => this.latchAuthenticationFailure(reason)
})
@@ -122,7 +122,8 @@ export class DirectRpcClient implements RpcClient {
this.stopLiveness()
}
},
emitWarning: (message, detail) => this.emitLog('warn', message, detail)
emitWarning: (message, detail, evidence) =>
this.connectionLog.emit('warn', message, detail, evidence)
})
this.openConnection()
}
@@ -232,7 +233,9 @@ export class DirectRpcClient implements RpcClient {
this.reconnect.authenticated()
this.authenticationRetry.accepted()
this.connectionState.publish('connected')
this.emitLog('success', 'Authenticated', 'Channel ready for RPC')
this.connectionLog.emit('success', 'Authenticated', 'Channel ready for RPC', {
code: 'direct-connected'
})
this.streams.replayAfterAuthentication()
}
@@ -311,14 +314,4 @@ export class DirectRpcClient implements RpcClient {
private nextId(): string {
return `rpc-${++this.requestCounter}-${Date.now()}`
}
private emitLog(level: ConnectionLogLevel, message: string, detail?: string): void {
this.options.onLog?.({
id: `log-${++this.logCounter}-${Date.now()}`,
ts: Date.now(),
level,
message,
detail
})
}
}
@@ -0,0 +1,52 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const asyncStorageMock = vi.hoisted(() => ({
getItem: vi.fn(),
setItem: vi.fn()
}))
vi.mock('@react-native-async-storage/async-storage', () => ({
default: asyncStorageMock
}))
import {
loadHostAppVersion,
normalizeHostAppVersion,
recordHostAppVersion
} from './host-app-version-store'
describe('host app version store', () => {
beforeEach(() => {
vi.clearAllMocks()
asyncStorageMock.getItem.mockResolvedValue(null)
asyncStorageMock.setItem.mockResolvedValue(undefined)
})
it('persists a bounded version once per host', async () => {
asyncStorageMock.getItem.mockResolvedValueOnce(null).mockResolvedValueOnce('1.4.191')
await recordHostAppVersion('host-1', ' 1.4.191 ')
await recordHostAppVersion('host-1', '1.4.191')
expect(asyncStorageMock.setItem).toHaveBeenCalledOnce()
expect(asyncStorageMock.setItem).toHaveBeenCalledWith(
'orca:host-app-version:v1:host-1',
'1.4.191'
)
})
it('loads a previously observed version without requiring a live host', async () => {
asyncStorageMock.getItem.mockResolvedValue('1.4.188')
await expect(loadHostAppVersion('host-1')).resolves.toBe('1.4.188')
})
it('rejects multiline, empty, and oversized host-provided values', async () => {
expect(normalizeHostAppVersion('1.4.191\nInjected: value')).toBeNull()
expect(normalizeHostAppVersion(' ')).toBeNull()
expect(normalizeHostAppVersion('x'.repeat(65))).toBeNull()
await recordHostAppVersion('host-1', '1.4.191\nInjected: value')
expect(asyncStorageMock.setItem).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,47 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
const STORAGE_KEY_PREFIX = 'orca:host-app-version:v1:'
const MAX_VERSION_LENGTH = 64
export function normalizeHostAppVersion(value: unknown): string | null {
if (typeof value !== 'string') {
return null
}
const normalized = value.trim()
if (
normalized.length === 0 ||
normalized.length > MAX_VERSION_LENGTH ||
normalized.includes('\n') ||
normalized.includes('\r')
) {
return null
}
return normalized
}
export async function loadHostAppVersion(hostId: string): Promise<string | null> {
try {
return normalizeHostAppVersion(await AsyncStorage.getItem(storageKey(hostId)))
} catch {
return null
}
}
export async function recordHostAppVersion(hostId: string, value: unknown): Promise<void> {
const appVersion = normalizeHostAppVersion(value)
if (!appVersion) {
return
}
try {
const key = storageKey(hostId)
if (normalizeHostAppVersion(await AsyncStorage.getItem(key)) !== appVersion) {
await AsyncStorage.setItem(key, appVersion)
}
} catch {
// Best-effort diagnostic metadata must never affect host connectivity.
}
}
function storageKey(hostId: string): string {
return `${STORAGE_KEY_PREFIX}${hostId}`
}
+6 -1
View File
@@ -1,4 +1,7 @@
import { connectionLogStore } from './connection-log-buffer'
import {
connectionLogStore,
recordConnectionClientSessionStart
} from './persisted-connection-log-store'
import { loadHosts } from './host-store'
import { openHostLogicalClient } from './host-logical-client'
import type { HostClientOpenRegistry } from './host-client-open-registry'
@@ -65,6 +68,7 @@ export async function openHostClientEntry(
id: `host-open-${ticket.generation}-${Date.now()}`,
ts: Date.now(),
level: 'error',
code: 'host-open-failed',
message: 'Host client open failed',
detail: `${category}; retry ${retry.nextDelayMs}ms (failure ${retry.failureCount})`
})
@@ -98,6 +102,7 @@ export async function openHostClientEntry(
let client: RpcClient
try {
recordConnectionClientSessionStart(hostId)
client = openHostLogicalClient(host, (entry) => connectionLogStore.append(hostId, entry))
} catch {
failCurrentOpen('client-construction')
@@ -4,6 +4,13 @@ import { describe, expect, it, vi } from 'vitest'
import type { RpcClient } from './rpc-client'
import { useHostStatusGates, type HostStatusGates } from './host-status-gates'
const recordHostAppVersionMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined))
vi.mock('./host-app-version-store', () => ({
normalizeHostAppVersion: (value: unknown) => (typeof value === 'string' ? value : null),
recordHostAppVersion: (...args: unknown[]) => recordHostAppVersionMock(...args)
}))
describe('useHostStatusGates', () => {
it('clears every prior-host gate and ignores its late response while the client is replaced', async () => {
let resolveOldStatus: ((response: unknown) => void) | null = null
@@ -76,6 +83,7 @@ describe('useHostStatusGates', () => {
const sendRequest = vi.fn().mockResolvedValue({
ok: true,
result: {
appVersion: '1.4.191',
capabilities: ['browser.screencast.v1'],
floatingWorkspaceEnabled: true
}
@@ -95,11 +103,13 @@ describe('useHostStatusGates', () => {
await Promise.resolve()
})
expect(gates).toMatchObject({
desktopAppVersion: '1.4.191',
hostCapabilities: ['browser.screencast.v1'],
floatingWorkspaceEnabled: true
})
expect(sendRequest).toHaveBeenCalledOnce()
expect(recordHostAppVersionMock).toHaveBeenCalledWith('host-1', '1.4.191')
} finally {
renderer?.unmount()
}
+11
View File
@@ -3,10 +3,12 @@ import type { RpcClient } from './rpc-client'
import type { ConnectionState, RpcSuccess } from './types'
import { evaluateCompat, type CompatVerdict } from './protocol-compat'
import type { DesktopStatus } from '../worktree/host-worktree-rpc-types'
import { normalizeHostAppVersion, recordHostAppVersion } from './host-app-version-store'
export type HostStatusGates = {
hostCapabilities: string[]
floatingWorkspaceEnabled: boolean
desktopAppVersion: string | null
compatVerdict: CompatVerdict
statusPending: boolean
}
@@ -53,6 +55,7 @@ export function useHostStatusGates(args: {
settle({
hostCapabilities: [],
floatingWorkspaceEnabled: false,
desktopAppVersion: null,
compatVerdict: { kind: 'ok' }
})
return
@@ -64,9 +67,14 @@ export function useHostStatusGates(args: {
desktopProtocolVersion: status.protocolVersion,
desktopMinCompatibleMobileVersion: status.minCompatibleMobileVersion
})
const desktopAppVersion = normalizeHostAppVersion(status.appVersion)
if (hostId && desktopAppVersion) {
void recordHostAppVersion(hostId, desktopAppVersion)
}
settle({
hostCapabilities: status.capabilities ?? [],
floatingWorkspaceEnabled: status.floatingWorkspaceEnabled === true,
desktopAppVersion,
compatVerdict: verdict
})
if (verdict.kind === 'blocked') {
@@ -84,6 +92,7 @@ export function useHostStatusGates(args: {
settle({
hostCapabilities: [],
floatingWorkspaceEnabled: false,
desktopAppVersion: null,
compatVerdict: { kind: 'ok' }
})
}
@@ -100,6 +109,7 @@ export function useHostStatusGates(args: {
return {
hostCapabilities: EMPTY_HOST_CAPABILITIES,
floatingWorkspaceEnabled: false,
desktopAppVersion: null,
compatVerdict: { kind: 'ok' },
statusPending: connState === 'connected' && client !== null
}
@@ -107,6 +117,7 @@ export function useHostStatusGates(args: {
return {
hostCapabilities: proven.hostCapabilities,
floatingWorkspaceEnabled: proven.floatingWorkspaceEnabled,
desktopAppVersion: proven.desktopAppVersion,
compatVerdict: proven.compatVerdict,
// Why (F10): unchanged pending timing — the reconnect refetch is still "unknown", it just no
// longer blanks the capabilities this same host already proved.
@@ -93,7 +93,8 @@ function createSupervisor(
resumeCredentialVersion: credential.version,
resumeConfirmReqId: confirmReqId,
deviceToken: host.deviceToken,
desktopPublicKeyB64: host.publicKeyB64
desktopPublicKeyB64: host.publicKeyB64,
onLog
}),
resolveRelay: resolveMobileRelayEndpoint,
readBundle: readMobileRelayCredentialBundle,
@@ -234,8 +234,10 @@ describe('mobile endpoint supervisor', () => {
.fn()
.mockReturnValueOnce(new FakeRelaySession('connected', new RelayOuterError(4429)))
.mockImplementation(() => new FakeRelaySession('connected'))
const onLog = vi.fn()
const deps = dependencies({
openRelay,
onLog,
randomBytes: () => new Uint8Array([128, 0])
})
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
@@ -245,6 +247,13 @@ describe('mobile endpoint supervisor', () => {
expect(openRelay).toHaveBeenCalledOnce()
expect(logical.getPendingPath()).toBe('relay')
expect(onLog).toHaveBeenCalledWith(
expect.objectContaining({
code: 'relay-session-failed',
path: 'relay',
message: 'Relay: active relay session failed'
})
)
await vi.advanceTimersByTimeAsync(249)
expect(openRelay).toHaveBeenCalledOnce()
await vi.advanceTimersByTimeAsync(1)
@@ -22,6 +22,11 @@ import * as recoveryPresentation from './mobile-relay-recovery-presentation'
import type { StableLogicalRpcClient } from './stable-logical-rpc-client'
import type { ForegroundNudgeReason, HostProfile } from './types'
import { MobileRelayBackgroundGrace } from './mobile-relay-background-grace'
import {
logRelayConnected,
logRelayCredentialUnavailable,
logRelayDialFailure
} from './mobile-relay-diagnostic-log'
export type { MobileEndpointSupervisorDependencies } from './mobile-endpoint-supervisor-contract'
@@ -98,7 +103,7 @@ export class MobileEndpointSupervisor {
recordMigration: () => {
this.relayRotationPending = false
this.hysteresis.recordMigration(dependencies.now())
this.logRelay('runtime channel migrated to relay')
logRelayConnected(this.logRelay)
},
scheduleLease: (expiry) =>
this.leaseRotation.scheduleFromLease(
@@ -107,8 +112,7 @@ export class MobileEndpointSupervisor {
scheduleDirectProbe: () => this.directProbe.schedule(),
onBookkeepingError: (error) =>
this.logRelay('relay bookkeeping failed after migration', error.message.slice(0, 80)),
onDialFailure: (error) =>
this.logRelay('relay dial failed', `${error.name}: ${String(error.message).slice(0, 80)}`)
onDialFailure: (error) => logRelayDialFailure(this.logRelay, error)
})
this.directProbe = new DirectReturnProbe(dependencies, {
hysteresis: this.hysteresis,
@@ -166,7 +170,8 @@ export class MobileEndpointSupervisor {
// Why: the direct client enters reconnecting after its first failed
// dial and may never publish disconnected while its retry loop lives.
recoveryPresentation.onActiveFailure(this.logical, this.relayReconnect, state, this.bundle)
this.relayReconnect.handleStateFailure(this.logical, state)
const relayFailure = this.relayReconnect.handleStateFailure(this.logical, state)
logRelayDialFailure(this.logRelay, relayFailure, 'active-session')
}
})
if (this.relayReconnect.needsRecovery(this.logical.getState())) {
@@ -249,11 +254,7 @@ export class MobileEndpointSupervisor {
this.logical.setRecoveryPath(null)
// Why: "expired" vs "missing" separates a sleep-past-expiry phone
// (needs re-pair or LAN) from a Keychain failure in field reports.
this.logRelay(
selection.bundle
? 'relay credential expired or rejected; slow reprobe armed'
: 'no relay credential bundle; slow reprobe armed'
)
logRelayCredentialUnavailable(this.logRelay, selection.bundle !== null)
this.relayReconnect.armCredentialReprobe()
if (ownsRecovery) {
// Why: no dial happened — keep the session and the intent; the reprobe
@@ -0,0 +1,38 @@
import type { RelayRecoveryLog } from './mobile-relay-recovery-log'
import { RelayDirectorHttpError } from './mobile-relay-resume-director'
export function logRelayConnected(log: RelayRecoveryLog): void {
log('runtime channel migrated to relay', undefined, {
level: 'success',
code: 'relay-connected'
})
}
export function logRelayDialFailure(
log: RelayRecoveryLog,
error: Error | null,
source: 'dial' | 'active-session' = 'dial'
): void {
if (!error) {
return
}
const base = `${error.name}: ${String(error.message).slice(0, 80)}`
const detail =
error instanceof RelayDirectorHttpError && error.retryAfterMs != null
? `${base}; retry-after=${error.retryAfterMs}ms`
: base
log(source === 'dial' ? 'relay dial failed' : 'active relay session failed', detail, {
level: 'error',
code: source === 'dial' ? 'relay-dial-failed' : 'relay-session-failed'
})
}
export function logRelayCredentialUnavailable(log: RelayRecoveryLog, hasBundle: boolean): void {
log(
hasBundle
? 'relay credential expired or rejected; slow reprobe armed'
: 'no relay credential bundle; slow reprobe armed',
undefined,
{ level: 'warn', code: 'relay-credential-unavailable' }
)
}
@@ -46,9 +46,7 @@ export class RelayReconnectController {
this.credentials = new RelayCredentialEligibility(dependencies.now)
}
getFailureCount(): number {
return this.failureCount.current()
}
getFailureCount = (): number => this.failureCount.current()
reportRecoveryTo(logical: StableLogicalRpcClient): void {
this.failureCount.reportTo(logical.setRecoveryAttempt)
@@ -97,17 +95,17 @@ export class RelayReconnectController {
return 'recover'
}
handleStateFailure(logical: StableLogicalRpcClient, state: ConnectionState): void {
handleStateFailure(logical: StableLogicalRpcClient, state: ConnectionState): Error | null {
if (!this.needsRecovery(state)) {
return
return null
}
this.registerActiveFailure(logical)
const failure = this.registerActiveFailure(logical)
this.onRetry()
return failure
}
needsRecovery(state: ConnectionState): boolean {
return state !== 'connected' && state !== 'connecting' && state !== 'handshaking'
}
needsRecovery = (state: ConnectionState): boolean =>
state !== 'connected' && state !== 'connecting' && state !== 'handshaking'
suspendActiveRelay(logical: StableLogicalRpcClient): void {
if (logical.getActivePath() !== 'relay') {
@@ -207,9 +205,9 @@ export class RelayReconnectController {
recordRejectedCredential = (version: number): void => this.credentials.recordRejected(version)
registerActiveFailure(logical: StableLogicalRpcClient): void {
registerActiveFailure(logical: StableLogicalRpcClient): Error | null {
if (logical.getActivePath() !== 'relay') {
return
return null
}
const failure = this.activeSession?.getFailure()
this.activeSession = null
@@ -219,6 +217,7 @@ export class RelayReconnectController {
} else {
this.activeRelayConnectedAt = null
}
return failure ?? null
}
// True when the caller is still inside the cooldown window and must not
@@ -0,0 +1,14 @@
import { describe, expect, it, vi } from 'vitest'
import { createRelayRecoveryLog } from './mobile-relay-recovery-log'
import type { ConnectionLogEntry } from './types'
describe('createRelayRecoveryLog', () => {
it('uses distinct ids across logger instances', () => {
const entries: ConnectionLogEntry[] = []
const sink = vi.fn((entry: ConnectionLogEntry) => entries.push(entry))
createRelayRecoveryLog(() => 1, sink)('first')
createRelayRecoveryLog(() => 1, sink)('second')
expect(entries[0]?.id).not.toBe(entries[1]?.id)
})
})
@@ -1,6 +1,12 @@
import type { ConnectionLogSink } from './types'
import type { ConnectionDiagnosticCode, ConnectionLogLevel, ConnectionLogSink } from './types'
export type RelayRecoveryLog = (message: string, detail?: string) => void
export type RelayRecoveryLog = (
message: string,
detail?: string,
evidence?: { level?: ConnectionLogLevel; code?: ConnectionDiagnosticCode }
) => void
let relayLoggerInstanceSequence = 0
// Why: relay recovery failed silently in production for weeks; every decision
// must reach logcat and the in-app connection log.
@@ -9,12 +15,15 @@ export function createRelayRecoveryLog(
onLog?: ConnectionLogSink
): RelayRecoveryLog {
let sequence = 0
return (message, detail) => {
const instanceId = `${Date.now().toString(36)}-${(++relayLoggerInstanceSequence).toString(36)}`
return (message, detail, evidence) => {
console.log(`[relay] ${message}`, detail ?? '')
onLog?.({
id: `relay-${++sequence}`,
id: `relay-${instanceId}-${++sequence}`,
ts: now(),
level: 'info',
level: evidence?.level ?? 'info',
path: 'relay',
...(evidence?.code ? { code: evidence.code } : {}),
message: `Relay: ${message}`,
...(detail ? { detail } : {})
})
@@ -21,6 +21,7 @@ vi.mock('./mobile-relay-e2ee-link', () => ({
}))
import { connectMobileRelayRpcSession } from './mobile-relay-rpc-session'
import type { ConnectionLogSink } from './types'
const relay = {
v: 1 as const,
@@ -31,7 +32,7 @@ const relay = {
e2eeFraming: 2 as const
}
async function authenticateSession() {
async function authenticateSession(onLog?: ConnectionLogSink) {
const session = connectMobileRelayRpcSession({
relay,
resumeToken: 'resume-secret',
@@ -39,7 +40,8 @@ async function authenticateSession() {
resumeConfirmReqId: 'confirm-1',
deviceToken: 'device-token',
desktopPublicKeyB64: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
requestTimeoutMs: 30_000
requestTimeoutMs: 30_000,
onLog
})
fakes.linkOptions!.onHello({
type: 'relay-hello',
@@ -103,7 +105,8 @@ describe('mobile relay RPC session liveness', () => {
})
it('disconnects after two fair foreground misses', async () => {
const session = await authenticateSession()
const onLog = vi.fn<ConnectionLogSink>()
const session = await authenticateSession(onLog)
session.notifyForeground('focus')
expect(sentRequests().map(({ method }) => method)).toEqual(['status.get'])
@@ -114,6 +117,38 @@ describe('mobile relay RPC session liveness', () => {
expect(session.getState()).toBe('disconnected')
expect(fakes.close).toHaveBeenCalledOnce()
expect(onLog).toHaveBeenCalledWith(
expect.objectContaining({
code: 'liveness-timeout',
path: 'relay',
message: 'Relay health check failed',
detail: expect.stringMatching(
/^probe-timeout; 2\/2 probes missed; last authenticated activity \d+ms ago$/
)
})
)
})
it('uses distinct liveness evidence IDs for sessions created in the same millisecond', async () => {
vi.setSystemTime(1_000)
const firstLog = vi.fn<ConnectionLogSink>()
const first = await authenticateSession(firstLog)
first.notifyForeground('focus')
await vi.advanceTimersByTimeAsync(8_000)
const firstId = firstLog.mock.calls[0]?.[0].id
vi.clearAllMocks()
fakes.sendText.mockReturnValue(true)
vi.setSystemTime(1_000)
const secondLog = vi.fn<ConnectionLogSink>()
const second = await authenticateSession(secondLog)
second.notifyForeground('focus')
await vi.advanceTimersByTimeAsync(8_000)
const secondId = secondLog.mock.calls[0]?.[0].id
expect(firstId).toBeTruthy()
expect(secondId).toBeTruthy()
expect(secondId).not.toBe(firstId)
})
it('rate-limits foreground sequences without suppressing a retry', async () => {
@@ -11,11 +11,12 @@ import { openRpcRequestBudget, resolvePostConnectRequestTimeout } from './rpc-re
import { isRpcResponse } from './rpc-response-shape'
import { RpcSessionLivenessWatchdog } from './rpc-session-liveness-watchdog'
import type { RpcClient } from './rpc-client'
import type { ConnectionState, RpcResponse } from './types'
import type { ConnectionLogSink, ConnectionState, RpcResponse } from './types'
const RELAY_PROBE_TIMEOUT_MS = 4_000
const RELAY_MISSED_PROBE_LIMIT = 2
const RELAY_FOREGROUND_PROBE_MIN_INTERVAL_MS = 10_000
let relayRpcSessionSequence = 0
type PendingRequest = {
resolve: (response: RpcResponse) => void
@@ -41,6 +42,7 @@ export function connectMobileRelayRpcSession(args: {
desktopPublicKeyB64: string
requestTimeoutMs?: number
createSocket?: (url: string) => WebSocket
onLog?: ConnectionLogSink
}): MobileRelayRpcSession {
const requestTimeoutMs = args.requestTimeoutMs ?? 30_000
const pending = new Map<string, PendingRequest>()
@@ -53,6 +55,8 @@ export function connectMobileRelayRpcSession(args: {
let resumeConfirmation: DeviceResumeConfirmed | null = null
let failure: Error | null = null
let closed = false
let logSequence = 0
const logSessionId = `${Date.now().toString(36)}-${(++relayRpcSessionSequence).toString(36)}`
const livenessIdentity = {}
const streams = new MobileRelayRpcStreams({
nextId,
@@ -145,6 +149,17 @@ export function connectMobileRelayRpcSession(args: {
voluntaryProbeMinIntervalMs: RELAY_FOREGROUND_PROBE_MIN_INTERVAL_MS,
sendProbe: () =>
state === 'connected' && sendFrame({ id: nextId(), method: 'status.get', params: undefined }),
onTimeout: (evidence) => {
args.onLog?.({
id: `relay-liveness-${logSessionId}-${++logSequence}`,
ts: Date.now(),
level: 'error',
code: 'liveness-timeout',
path: 'relay',
message: 'Relay health check failed',
detail: `${evidence.reason}; ${evidence.missedProbes}/${evidence.missedProbeLimit} probes missed; last authenticated activity ${evidence.lastInboundAgeMs}ms ago`
})
},
terminate: () => fail(new Error('relay session liveness timeout'))
})
return client
@@ -0,0 +1,57 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ConnectionLogEntry } from './types'
vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
getItem: vi.fn(),
setItem: vi.fn()
}
}))
describe('persisted connection log store', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
vi.mocked(AsyncStorage.getItem).mockReset()
vi.mocked(AsyncStorage.setItem).mockReset()
vi.mocked(AsyncStorage.setItem).mockResolvedValue(undefined)
})
afterEach(() => {
vi.useRealTimers()
vi.resetModules()
})
it('keeps a new client-session boundary when a restart shares the prior timestamp', async () => {
const stored: ConnectionLogEntry[] = [
{
id: 'client-session-1000',
ts: 1_000,
level: 'info',
code: 'client-session-started',
message: 'Mobile client session started'
},
{
id: 'relay-failure',
ts: 1_000,
level: 'error',
code: 'relay-session-failed',
message: 'Relay: active relay session failed'
}
]
vi.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify(stored))
vi.resetModules()
const { connectionLogStore, recordConnectionClientSessionStart } =
await import('./persisted-connection-log-store')
recordConnectionClientSessionStart('host-a')
await connectionLogStore.hydrate('host-a')
expect(connectionLogStore.get('host-a').map((entry) => entry.code)).toEqual([
'client-session-started',
'relay-session-failed',
'client-session-started'
])
})
})
@@ -0,0 +1,77 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import { createConnectionLogStore } from './connection-log-buffer'
import type { ConnectionLogEntry } from './types'
const STORAGE_PREFIX = 'orca.mobile.connection-log.v1.'
const clientSessionId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`
const sessionStartedHosts = new Set<string>()
export const connectionLogStore = createConnectionLogStore(200, {
async load(hostId) {
const raw = await AsyncStorage.getItem(storageKey(hostId))
if (!raw) {
return []
}
try {
const parsed: unknown = JSON.parse(raw)
return Array.isArray(parsed) ? parsed.filter(isConnectionLogEntry) : []
} catch {
return []
}
},
save(hostId, entries) {
return AsyncStorage.setItem(storageKey(hostId), JSON.stringify(entries))
}
})
export function recordConnectionRevival(
hostId: string,
reason: 'app-resume' | 'network-change'
): void {
const now = Date.now()
connectionLogStore.append(hostId, {
id: `revival-${reason}-${now}`,
ts: now,
level: 'info',
code: reason === 'app-resume' ? 'app-resumed' : 'network-changed',
message: reason === 'app-resume' ? 'App returned to foreground' : 'Network changed',
detail: 'Connection recovery notified'
})
}
export function recordConnectionClientSessionStart(hostId: string): void {
if (sessionStartedHosts.has(hostId)) {
return
}
sessionStartedHosts.add(hostId)
const now = Date.now()
connectionLogStore.append(hostId, {
id: `client-session-${clientSessionId}-${now}`,
ts: now,
level: 'info',
code: 'client-session-started',
message: 'Mobile client session started'
})
}
function storageKey(hostId: string): string {
return `${STORAGE_PREFIX}${encodeURIComponent(hostId)}`
}
function isConnectionLogEntry(value: unknown): value is ConnectionLogEntry {
if (!value || typeof value !== 'object') {
return false
}
const entry = value as Partial<ConnectionLogEntry>
return (
typeof entry.id === 'string' &&
typeof entry.ts === 'number' &&
Number.isFinite(entry.ts) &&
(entry.level === 'info' ||
entry.level === 'success' ||
entry.level === 'warn' ||
entry.level === 'error') &&
typeof entry.message === 'string' &&
(entry.detail === undefined || typeof entry.detail === 'string')
)
}
@@ -6,6 +6,7 @@ import type { RpcClientSocketFactory } from './rpc-client-socket-factory'
import type { RpcClientSocketSession } from './rpc-client-socket-session'
import type { RpcClientStreamRegistry } from './rpc-client-stream-registry'
import { RpcSynthesizedCloseIndex } from './rpc-socket-close-evidence'
import type { ConnectionLogEntry } from './types'
const UNAUTHORIZED_CLOSE_CODE = 4001
@@ -21,7 +22,11 @@ type SocketCloseControllerOptions = {
getAuthenticationGeneration: () => number
isIntentionallyClosed: () => boolean
stopLiveness: (session: RpcClientSocketSession) => void
emitWarning: (message: string, detail: string) => void
emitWarning: (
message: string,
detail: string,
evidence?: Pick<ConnectionLogEntry, 'code' | 'path'>
) => void
}
export class RpcClientSocketCloseController {
@@ -80,7 +85,11 @@ export class RpcClientSocketCloseController {
streamCount: this.options.streams.size(),
attempt: this.options.reconnect.getAttempt()
})
this.options.emitWarning('WebSocket closed', 'Will attempt to reconnect')
this.options.emitWarning(
'WebSocket closed',
`${closeCode == null ? 'Close code unavailable' : `Close code ${closeCode}`}; reconnect scheduled`,
{ code: 'socket-closed' }
)
this.options.requests.rejectAll('Connection interrupted', { deliveryUnknown: true })
this.options.connectionState.publish('reconnecting')
this.options.reconnect.schedule()
@@ -1,7 +1,7 @@
import { publicKeyFromBase64 } from './e2ee'
import { RpcClientSocketSession } from './rpc-client-socket-session'
import { redactSocketEndpoint } from './socket-event-debug'
import type { ConnectionLogLevel, ConnectionState, RpcResponse } from './types'
import type { ConnectionLogEmitter, ConnectionState, RpcResponse } from './types'
type SocketFactoryOptions = {
endpoint: string
@@ -12,7 +12,7 @@ type SocketFactoryOptions = {
getReconnectAttempt: () => number
getLastConnectedAt: () => number | null
isIntentionallyClosed: () => boolean
emitLog: (level: ConnectionLogLevel, message: string, detail?: string) => void
emitLog: ConnectionLogEmitter
onHandshakeStarted: () => void
onAuthenticated: (session: RpcClientSocketSession) => void
onAuthRejected: (reason: string) => void
@@ -9,7 +9,7 @@ import {
import { isRpcResponse } from './rpc-response-shape'
import { isStaleRpcSocketEvent, logRpcSocketClose } from './rpc-socket-close-evidence'
import { describeSocketEvent, redactSocketEndpoint } from './socket-event-debug'
import type { ConnectionLogLevel, ConnectionState, RpcResponse } from './types'
import type { ConnectionLogEmitter, ConnectionState, RpcResponse } from './types'
import { websocketPayloadToUint8 } from './websocket-payload-bytes'
const CONNECT_TIMEOUT_MS = 12_000
@@ -24,7 +24,7 @@ type SocketSessionOptions = {
getState: () => ConnectionState
getReconnectAttempt: () => number
isIntentionallyClosed: () => boolean
emitLog: (level: ConnectionLogLevel, message: string, detail?: string) => void
emitLog: ConnectionLogEmitter
onHandshakeStarted: () => void
onAuthenticated: (session: RpcClientSocketSession) => void
onAuthRejected: (reason: string) => void
@@ -261,7 +261,8 @@ export class RpcClientSocketSession {
this.options.emitLog(
'error',
'WebSocket connect timeout',
`No TCP/WS handshake within ${CONNECT_TIMEOUT_MS / 1000}s — endpoint unreachable?`
`No TCP/WS handshake within ${CONNECT_TIMEOUT_MS / 1000}s — endpoint unreachable?`,
{ code: 'connect-timeout' }
)
this.options.onForcedClose(this)
}
@@ -280,7 +281,8 @@ export class RpcClientSocketSession {
this.options.emitLog(
'error',
'Handshake timeout',
`No e2ee_ready/e2ee_authenticated within ${HANDSHAKE_TIMEOUT_MS / 1000}s`
`No e2ee_ready/e2ee_authenticated within ${HANDSHAKE_TIMEOUT_MS / 1000}s`,
{ code: 'handshake-timeout' }
)
this.options.onForcedClose(this)
}, HANDSHAKE_TIMEOUT_MS)
@@ -46,6 +46,37 @@ describe('RpcSessionLivenessWatchdog', () => {
expect(terminate).toHaveBeenCalledWith(identity)
})
it('reports causal evidence before terminating a stale session', async () => {
const onTimeout = vi.fn()
const terminate = vi.fn()
const identity = {}
const watchdog = new RpcSessionLivenessWatchdog({
transport: 'relay',
idleProbeMs: null,
probeTimeoutMs: 4_000,
missedProbeLimit: 2,
sendProbe: () => true,
terminate,
onTimeout,
now: Date.now
})
watchdog.start(identity)
watchdog.probeNow(identity)
await vi.advanceTimersByTimeAsync(8_000)
expect(onTimeout).toHaveBeenCalledWith({
transport: 'relay',
reason: 'probe-timeout',
missedProbes: 2,
missedProbeLimit: 2,
lastInboundAgeMs: 8_000
})
expect(onTimeout.mock.invocationCallOrder[0]).toBeLessThan(
terminate.mock.invocationCallOrder[0]!
)
})
it('authenticated activity resets suspicion', async () => {
const { identity, terminate, watchdog } = fixture()
watchdog.probeNow(identity)
@@ -8,6 +8,7 @@ type WatchdogOptions = {
transport: 'direct' | 'relay'
sendProbe: (identity: RpcSessionIdentity) => boolean
terminate: (identity: RpcSessionIdentity) => void
onTimeout?: (evidence: LivenessTimeoutEvidence) => void
idleProbeMs?: number | null
probeTimeoutMs?: number
missedProbeLimit?: number
@@ -17,6 +18,14 @@ type WatchdogOptions = {
clearTimer?: typeof clearTimeout
}
export type LivenessTimeoutEvidence = {
transport: 'direct' | 'relay'
reason: 'probe-send-failed' | 'probe-timeout'
missedProbes: number
missedProbeLimit: number
lastInboundAgeMs: number
}
export class RpcSessionLivenessWatchdog {
private identity: RpcSessionIdentity | null = null
private timer: ReturnType<typeof setTimeout> | null = null
@@ -138,7 +147,7 @@ export class RpcSessionLivenessWatchdog {
sent = false
}
if (!sent) {
this.terminateCurrent(identity)
this.terminateCurrent(identity, 'probe-send-failed')
return
}
this.timer = this.setTimer(() => this.handleProbeTimeout(identity, sentAt), this.probeTimeoutMs)
@@ -161,7 +170,7 @@ export class RpcSessionLivenessWatchdog {
}
this.missedProbes += 1
if (this.missedProbes >= this.missedProbeLimit) {
this.terminateCurrent(identity)
this.terminateCurrent(identity, 'probe-timeout')
return
}
console.log('[net] activity-probe timeout tolerated', {
@@ -172,7 +181,10 @@ export class RpcSessionLivenessWatchdog {
this.startProbe(identity)
}
private terminateCurrent(identity: RpcSessionIdentity): void {
private terminateCurrent(
identity: RpcSessionIdentity,
reason: LivenessTimeoutEvidence['reason']
): void {
if (this.identity !== identity) {
return
}
@@ -184,6 +196,13 @@ export class RpcSessionLivenessWatchdog {
missedProbes: this.missedProbes,
missedProbeLimit: this.missedProbeLimit
})
this.options.onTimeout?.({
transport: this.options.transport,
reason,
missedProbes: this.missedProbes,
missedProbeLimit: this.missedProbeLimit,
lastInboundAgeMs: Math.max(0, this.now() - this.lastInboundAt)
})
this.options.terminate(identity)
}
+28
View File
@@ -39,6 +39,25 @@ export type RpcResponse = RpcSuccess | RpcFailure
export type ConnectionLogLevel = 'info' | 'success' | 'warn' | 'error'
export type MobileConnectionDiagnosticPath = 'lan' | 'tailscale' | 'relay'
export type ConnectionDiagnosticCode =
| 'client-session-started'
| 'app-resumed'
| 'network-changed'
| 'connect-timeout'
| 'handshake-timeout'
| 'authentication-rejected'
| 'socket-closed'
| 'liveness-timeout'
| 'retry-scheduled'
| 'relay-dial-failed'
| 'relay-session-failed'
| 'relay-connected'
| 'direct-connected'
| 'relay-credential-unavailable'
| 'host-open-failed'
export type ConnectionLogEntry = {
id: string
ts: number
@@ -47,10 +66,19 @@ export type ConnectionLogEntry = {
message: string
// Optional second line for endpoint/error/elapsed detail.
detail?: string
code?: ConnectionDiagnosticCode
path?: MobileConnectionDiagnosticPath
}
export type ConnectionLogSink = (entry: ConnectionLogEntry) => void
export type ConnectionLogEmitter = (
level: ConnectionLogLevel,
message: string,
detail?: string,
evidence?: Pick<ConnectionLogEntry, 'code' | 'path'>
) => void
export type ConnectionState =
| 'connecting'
| 'handshaking'
+1 -1
View File
@@ -3,7 +3,7 @@ import type { RpcClient } from './rpc-client'
import type { MobileConnectionPath } from './stable-logical-rpc-client'
import type { ConnectionState } from './types'
import { useRpcClientContext } from './client-context'
import type { HostClientAcquisition } from './client-context'
import type { HostClientAcquisition } from './host-client-acquisition-registry'
type UseAllHostClientsOptions = {
autoConnectHostIds?: readonly string[]
@@ -3,6 +3,7 @@ import type { ExecutionHostId } from '../../../src/shared/execution-host'
// Locally-typed subset of the desktop status payload read from status.get.
export type DesktopStatus = {
appVersion?: string
protocolVersion?: number
minCompatibleMobileVersion?: number
// Why: absent on hosts that predate the mobile Floating Workspace entry;
+9 -9
View File
@@ -9,7 +9,7 @@ overrides:
patchedDependencies:
'@vscode/windows-process-tree@0.8.0':
hash: 73a90530e6b95c05dac50f2c48787fb51cb292587773016ebe57eb05e41075a0
hash: 946ffb2b1026bcb554d9920681fb00f546c28fc5de6d95a877d198b49bf73988
path: config/patches/@vscode__windows-process-tree@0.8.0.patch
'@xterm/addon-ligatures@0.11.0-beta.287':
hash: 47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920
@@ -18,7 +18,7 @@ patchedDependencies:
hash: af3b156143ed2ee9903b753145b63dd4a2ee72cadd7ef333ab0f4d5d3ebfc32c
path: config/patches/@xterm__addon-serialize@0.15.0-beta.287.patch
'@xterm/addon-webgl@0.20.0-beta.286':
hash: 6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258
hash: 118f649459ba56a51627d46603e2d5d81d837ac9c4dc393f5cc20263d0ac1b91
path: config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch
'@xterm/xterm@6.1.0-beta.287':
hash: 46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7
@@ -27,7 +27,7 @@ patchedDependencies:
hash: 7333b3837f80a7fbd045964db6d76ba4fc118e49134bdbabb00585b6b7b60673
path: config/patches/lint-staged@16.4.0.patch
node-pty@1.1.0:
hash: 9c665d9e41571341cb8986607d531b953a7f32c0dd60e1e74e633c098d80f2c1
hash: 9a2eedbf2448b8ff1387a8a740ee5e4e968bf8484d7d80e12a3f6a2dc26b0e17
path: config/patches/node-pty@1.1.0.patch
importers:
@@ -69,7 +69,7 @@ importers:
version: 3.3.1
node-pty:
specifier: ^1.1.0
version: 1.1.0(patch_hash=9c665d9e41571341cb8986607d531b953a7f32c0dd60e1e74e633c098d80f2c1)
version: 1.1.0(patch_hash=9a2eedbf2448b8ff1387a8a740ee5e4e968bf8484d7d80e12a3f6a2dc26b0e17)
posthog-node:
specifier: ^5.33.3
version: 5.33.3
@@ -229,7 +229,7 @@ importers:
version: 0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))
'@xterm/addon-webgl':
specifier: 0.20.0-beta.286
version: 0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))
version: 0.20.0-beta.286(patch_hash=118f649459ba56a51627d46603e2d5d81d837ac9c4dc393f5cc20263d0ac1b91)(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))
'@xterm/xterm':
specifier: 6.1.0-beta.287
version: 6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7)
@@ -407,7 +407,7 @@ importers:
optionalDependencies:
'@vscode/windows-process-tree':
specifier: 0.8.0
version: 0.8.0(patch_hash=73a90530e6b95c05dac50f2c48787fb51cb292587773016ebe57eb05e41075a0)
version: 0.8.0(patch_hash=946ffb2b1026bcb554d9920681fb00f546c28fc5de6d95a877d198b49bf73988)
sherpa-onnx-darwin-arm64:
specifier: 1.12.37
version: 1.12.37
@@ -9605,7 +9605,7 @@ snapshots:
convert-source-map: 2.0.0
tinyrainbow: 3.1.0
'@vscode/windows-process-tree@0.8.0(patch_hash=73a90530e6b95c05dac50f2c48787fb51cb292587773016ebe57eb05e41075a0)':
'@vscode/windows-process-tree@0.8.0(patch_hash=946ffb2b1026bcb554d9920681fb00f546c28fc5de6d95a877d198b49bf73988)':
dependencies:
node-addon-api: 7.1.0
optional: true
@@ -9638,7 +9638,7 @@ snapshots:
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7)
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))':
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=118f649459ba56a51627d46603e2d5d81d837ac9c4dc393f5cc20263d0ac1b91)(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7)
@@ -12046,7 +12046,7 @@ snapshots:
node-int64@0.4.0: {}
node-pty@1.1.0(patch_hash=9c665d9e41571341cb8986607d531b953a7f32c0dd60e1e74e633c098d80f2c1):
node-pty@1.1.0(patch_hash=9a2eedbf2448b8ff1387a8a740ee5e4e968bf8484d7d80e12a3f6a2dc26b0e17):
dependencies:
node-addon-api: 7.1.1
+14 -14
View File
@@ -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)
+17 -15
View File
@@ -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.
+25 -10
View File
@@ -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