Merge branch 'main' of https://github.com/stablyai/orca into rc139-fix-pr-evidence-package

This commit is contained in:
Jinjing
2026-07-14 17:26:47 -07:00
485 changed files with 41447 additions and 3825 deletions
+3
View File
@@ -6,3 +6,6 @@
/config/scripts/run-internal-dev-setup.mjs text eol=lf
/config/scripts/verify-cli-bin.mjs text eol=lf
/config/scripts/verify-release-required-assets.mjs text eol=lf
/skill-guides/*.md text eol=lf
/skills/*/SKILL.md text eol=lf
/src/cli/bundled-skill-guides.ts text eol=lf
@@ -9,6 +9,8 @@ on:
- 'tests/e2e/fixtures/terminal-emoji-table.md'
- 'tests/e2e/helpers/**'
- 'tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts'
- 'tests/e2e/terminal-webgl-atlas-budget.spec.ts'
- 'config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch'
- 'src/renderer/src/components/sidebar/SidebarHeader.tsx'
- 'src/renderer/src/lib/pane-manager/**'
workflow_dispatch:
+1 -1
View File
@@ -51,7 +51,7 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Typecheck
run: npx tsc --noEmit
run: pnpm typecheck
- name: Test
run: pnpm test
+5
View File
@@ -77,6 +77,11 @@ jobs:
- name: Enforce max-lines ratchet (no new bypasses)
run: pnpm run check:max-lines-ratchet
# Why: the CLI embeds guide content while the skills CLI installs generated
# projections from the repository, so stale output would split those two truths.
- name: Verify bundled skill guides
run: pnpm run verify:bundled-skill-guides
# Why: project-owned type declarations must live in .ts so tsc
# actually checks them. TypeScript's skipLibCheck: true (inherited
# from @electron-toolkit/tsconfig) silently widens unresolved names
+3
View File
@@ -62,6 +62,9 @@ module.exports = {
'!mobile{,/**/*}',
'!native{,/**/*}',
'!skills{,/**/*}',
// Why: authoritative guide markdown is compiled into out/cli; shipping the
// authoring sources too would duplicate content without a runtime consumer.
'!skill-guides{,/**/*}',
'!tests{,/**/*}',
// Why: pr-evidence/ is a local e2e screenshot output (ORCA_CAPTURE_EVIDENCE);
// it is gitignored, but exclude it defensively so a stray local capture at
File diff suppressed because one or more lines are too long
+188 -22
View File
@@ -590,7 +590,7 @@
"https://github.com/stablyai/orca/issues/8001"
],
"invariant": "Every terminal surface confirmed in the invoking renderer is force-closed exactly once after daemon management settles, later-created surfaces and non-terminal tabs survive, and exact shutdown requests are limited to deduplicated current non-runtime PTY bindings of the confirmed surfaces.",
"oracle": "Snapshot terminal entity IDs before the first await; mutate ownership, active selection, bindings, and tab presence while daemon management is pending; then assert only the immutable targets disappear from both terminal stores, active targets close last with valid editor/browser/deactivated post-state, every captured exact PTY promise settles before callbacks, and no provider inventory sweep or late-tab kill occurs.",
"oracle": "Snapshot terminal entity IDs before the first await; mutate ownership, active selection, bindings, and tab presence while daemon management is pending and between bounded close batches; then assert only the immutable targets disappear from both terminal stores, active targets close last with valid editor/browser/deactivated post-state, every captured exact PTY promise settles before callbacks, and no provider inventory sweep or late-tab kill occurs.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts src/renderer/src/components/shared/useDaemonActions.test.tsx src/renderer/src/components/terminal/terminal-tab-actions-kill-all.test.ts src/main/ipc/pty-management.test.ts"
],
@@ -609,7 +609,7 @@
"current exact PTY bindings are deduplicated, remote runtime IDs and stale/late bindings are excluded, and all per-PTY settlements finish before completion",
"the production dependency path calls daemon management exactly once and never invokes listSessions for a post-kill sweep",
"management rejection and per-close/provider failures do not stop remaining cleanup and produce bounded count/latency diagnostics",
"a real 100-tab Zustand fixture records 100 close attempts and exact kills, at least 100 writes, 49 event-loop yields, and no close batch over 50 ms"
"a real 100-tab Zustand fixture records 100 close attempts and exact kills, at least 100 writes, and 49 event-loop yields; ownership is revalidated at most once after each yield when the store changed"
]
},
{
@@ -658,11 +658,11 @@
},
"redGreenEvidence": {
"status": "partial",
"evidence": "The initial 100-tab implementation exposed a 145.82 ms close batch and failed the 50 ms budget; two-close event-loop batching reduced the focused run below the budget. Saved CI artifacts and intentional-break evidence for the confirmation-boundary assertions are still needed."
"evidence": "The initial 100-tab implementation exposed an oversized close batch; two-close event-loop batching fixed the structural issue. The gate now uses deterministic batch instrumentation because full-suite CPU saturation made wall-clock assertions flaky; saved CI artifacts and intentional-break evidence are still needed."
},
"performanceBudget": {
"required": true,
"evidence": "The coordinator performs one existing management call, zero provider inventory sweeps, one close per present unique target, and at most one exact kill per unique current non-runtime PTY binding. A real 100-tab Zustand fixture asserts 100 close attempts, 100 exact kill calls, at least 100 store writes, 49 event-loop yields, and a maximum two-close batch at or below 50 ms."
"evidence": "The coordinator performs one management sweep, builds an initial live-owner index, and revalidates at most once after each two-close yield when the Zustand state changed (at most 49 replans for the 100-tab fixture). It closes each present unique target once and sends at most one exact kill per unique current non-runtime PTY not already settled by daemon management. The fixture asserts 100 close attempts, 100 local kill calls, at least 100 store writes, and 49 yields; planner-level tests prove each individual plan build scans terminal and unified ownership stores once. Production diagnostics report measured close-batch duration, while the deterministic gate makes no machine-load-sensitive latency claim."
},
"promotionCriteria": [
"Run the focused gate for at least 100 consecutive passes or 14 days across required CI platforms.",
@@ -676,7 +676,124 @@
"Runtime-host terminal close is best-effort because closeTerminalTab still discards the existing async host result and its close-intent lifetime is shorter than the possible RPC flow.",
"Daemon adapter listing failures remain suppressed by the existing management API, so reported daemon counts are not authoritative verification of every process."
],
"demotionRule": "Keep experimental or demote to protection none if the gate flakes, permits a late-created tab or unrelated PTY to close, or exceeds the 50 ms maximum close-batch budget without a tracked product or harness bug."
"demotionRule": "Keep experimental or demote to protection none if the gate flakes, permits a late-created tab or unrelated PTY to close, duplicates provider shutdown, or performs more than one ownership replan per bounded yield."
},
{
"id": "terminal-session.explicit-close-retirement",
"title": "Explicit terminal close retires parked PTYs and agent authority exactly once",
"maturity": "experimental",
"protection": "partial",
"owner": "terminal-runtime",
"layer": "main-preload-renderer-electron-contract",
"surfaces": [
"terminal tab close",
"split pane close and detach",
"hidden terminal parking",
"agent resume authority"
],
"platforms": [
"macos",
"linux",
"windows"
],
"providers": [
"local",
"daemon",
"ssh",
"runtime"
],
"coveredPlatforms": [
"macos"
],
"coveredProviders": [
"local",
"daemon",
"ssh",
"runtime"
],
"coverageNotes": "A live macOS Electron test proves exact local PTY disappearance after a parked-tab close. Deterministic unit/store tests cover daemon and SSH routing, ordinary runtime close ownership, unified-only hydration, split ownership, pane detach transfer, restart alias hydration, and late-hook suppression; live Linux, Windows, WSL, SSH, and remote-runtime process evidence remains pending.",
"motivatingLinks": [
"https://github.com/stablyai/orca/pull/8628"
],
"invariant": "Close permanently removes the owned provider session and resume authority even when no TerminalPane is mounted; detach and park preserve both; aliases prevent a detached agent's immutable physical pane key from being retired with its former tab.",
"oracle": "Capture the exact PTY before parking, prove it remains listed while the view is absent, close through the product state boundary, and poll the provider inventory until that exact ID disappears; unit tests assert canonical owner dedupe, one teardown owner, exact pane tombstones, chained detach transfer, and restart alias restoration.",
"commands": [
"pnpm dlx node@24 ./node_modules/vitest/vitest.mjs run --config config/vitest.config.ts src/main/agent-hooks/server-pane-authority.test.ts src/main/ipc/agent-hooks.test.ts src/main/ipc/agent-pane-authority-ownership.test.ts src/main/ipc/pty-management.test.ts src/main/persistence.test.ts src/renderer/src/store/slices/agent-pane-authority.test.ts src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts src/renderer/src/store/slices/terminal-tab-retirement.test.ts src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts",
"pnpm run test:e2e -- tests/e2e/terminal-parked-close-retirement.spec.ts --workers=1"
],
"testFiles": [
"src/main/agent-hooks/server-pane-authority.test.ts",
"src/main/ipc/agent-hooks.test.ts",
"src/main/ipc/agent-pane-authority-ownership.test.ts",
"src/main/ipc/pty-management.test.ts",
"src/main/persistence.test.ts",
"src/renderer/src/store/slices/agent-pane-authority.test.ts",
"src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts",
"src/renderer/src/store/slices/terminal-tab-retirement.test.ts",
"src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts",
"src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts",
"tests/e2e/terminal-parked-close-retirement.spec.ts"
],
"assertionRefs": [
{
"file": "tests/e2e/terminal-parked-close-retirement.spec.ts",
"assertions": [
"a long-lived exact PTY remains alive after its terminal view is parked",
"closing the parked tab removes the exact PTY from the provider inventory and the visible tab model"
]
},
{
"file": "src/main/ipc/agent-pane-authority-ownership.test.ts",
"assertions": [
"pane authority transfer accepts only the PTY bound to the physical local pane or the canonical legacy/scoped runtime handle"
]
},
{
"file": "src/renderer/src/store/slices/agent-pane-authority.test.ts",
"assertions": [
"exact pane retirement removes resume and launch authority while preserving siblings",
"chained detach keeps physical hooks and resume authority routed to the current owner until that owner closes"
]
}
],
"evidenceRuns": [
{
"date": "2026-07-13",
"runner": "local",
"platform": "macos",
"command": "pnpm run test:e2e -- tests/e2e/terminal-parked-close-retirement.spec.ts --workers=1",
"result": "passed",
"durationSeconds": 40.3,
"summary": "A fresh E2E build launched an isolated Electron profile, parked a live terminal, closed it through closeTab, and observed its exact PTY disappear."
}
],
"runtimeBudget": {
"p95Seconds": 60,
"scope": "fresh E2E build plus one isolated local Electron parked-close test"
},
"flakeHistory": {
"status": "unknown",
"evidence": "The Electron gate passed three times locally, including the final review-fix head through the registered fresh-build command; CI and soak history are not yet available."
},
"redGreenEvidence": {
"status": "partial",
"evidence": "The test exercises the original parked-view failure shape and passed with the retirement boundary; an archived intentional-break run is not yet attached."
},
"performanceBudget": {
"required": false,
"evidence": "The close path is user-triggered and bounded by canonical live-owner indexing; kill-all scale remains covered by terminal-session.kill-all-surface-cleanup."
},
"promotionCriteria": [
"Accumulate 100 clean runs or 14 days on required CI platforms.",
"Add live Windows/ConPTY, Linux, WSL, SSH, and ordinary runtime process-absence evidence.",
"Add restart/no-resurrection and repeated park-close soak coverage."
],
"knownGaps": [
"The live Electron proof currently covers macOS local PTYs only.",
"Disconnected SSH relay death still requires reconnect-aware provider ownership.",
"Daemon owner leases and durable retry inventory remain follow-up hardening."
],
"demotionRule": "Keep experimental or demote to protection none if exact PTY disappearance flakes, a sibling/detached pane is retired, or late hooks can recreate closed authority."
},
{
"id": "terminal-session.startup-cwd-missing-dir-recovery",
@@ -1992,7 +2109,7 @@
"id": "terminal-provider.ssh-remote-reattach-contract",
"title": "SSH and remote terminal restore never turn unknown liveness into destructive cleanup",
"maturity": "experimental",
"protection": "none",
"protection": "partial",
"owner": "terminal-provider",
"layer": "provider-contract",
"surfaces": [
@@ -2012,28 +2129,79 @@
"remote-runtime",
"wsl"
],
"coveredPlatforms": [],
"coveredProviders": [],
"coverageNotes": "Registered gap on main. The SSH unknown-liveness hardening and deferred-reattach contracts exist only on the pending reliability stack. It registers here with its owning split PR.",
"coveredPlatforms": [
"macos"
],
"coveredProviders": [
"ssh"
],
"coverageNotes": "Deterministic renderer coverage proves startup publishes the state returned by ssh.connect, remote or unresolved hosts cannot enter local-daemon cold activation deferral, and stale cleanup cannot unregister a replacement runtime terminal. A macOS Electron journey connects to a real Linux SSH relay in Docker, persists six terminals, reloads the renderer, and verifies all six original relay PTYs remount eagerly and still execute remote input. Linux and Windows desktop clients, WSL, and remote-runtime restore remain gaps.",
"motivatingLinks": [
"https://github.com/stablyai/orca/pull/6951",
"https://github.com/stablyai/orca/pull/6955",
"https://github.com/stablyai/orca/pull/6979",
"https://github.com/stablyai/orca/pull/7009"
"https://github.com/stablyai/orca/pull/7009",
"https://github.com/stablyai/orca/pull/8597"
],
"invariant": "SSH, WSL, and remote-runtime restore paths must treat provider listing failures and unknown liveness as unknown, not dead, while still avoiding duplicate spawn and clearing expired relay leases exactly once. Snapshot-backed cold activation deferral is local-daemon-only; every restored remote terminal must mount a manager eagerly and preserve its provider PTY identity.",
"oracle": "Deterministic tests assert startup publishes ssh.connect's authoritative state before terminal reconnect, only an explicit local execution host can defer cold activation, host ownership changes clear old restrictions, and replacement runtime registrations survive stale cleanup. The live Docker SSH journey commits six terminal records to the relay, reloads Electron, requires the exact six manager ids with no parked tabs, compares all restored PTY ids to their pre-reload identities, and sends a marker through the restored terminal to a proof file on the Linux host.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/startup/ssh-startup-reconnect.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts src/renderer/src/runtime/sync-runtime-graph-scheduling.test.ts src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts",
"pnpm exec electron-vite build --mode e2e",
"SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-cold-activation-deferral.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-cold-activation-restore.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1"
],
"testFiles": [
"src/renderer/src/startup/ssh-startup-reconnect.test.ts",
"src/renderer/src/lib/resolved-worktree-execution-host.test.ts",
"src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts",
"src/renderer/src/runtime/sync-runtime-graph-scheduling.test.ts",
"src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts",
"src/renderer/src/components/terminal-pane/pty-connection.test.ts",
"tests/e2e/terminal-cold-activation-deferral.spec.ts",
"tests/e2e/ssh-cold-activation-restore.spec.ts"
],
"assertionRefs": [
{
"file": "src/renderer/src/startup/ssh-startup-reconnect.test.ts",
"assertions": [
"the state returned by ssh.connect is published before persisted terminals reconnect"
]
},
{
"file": "src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts",
"assertions": [
"only an explicit local execution host can defer cold activation",
"SSH, remote-runtime, and unresolved owners remain eager"
]
},
{
"file": "tests/e2e/ssh-cold-activation-restore.spec.ts",
"assertions": [
"six relay-backed tabs retain the same six PTY ids across renderer reload",
"all six SSH managers mount eagerly and none is parked",
"restored terminal input reaches a proof file on the Linux SSH host"
]
}
],
"evidenceRuns": [
{
"date": "2026-07-14",
"runner": "local",
"platform": "macos",
"command": "ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-cold-activation-restore.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"result": "passed",
"durationSeconds": 128,
"summary": "Six focused files and 475 tests passed; the local cold-activation journey passed; the Docker/Linux SSH journey restored six of six original relay PTYs, mounted six of six managers without parking, and executed remote input through the restored terminal."
}
],
"invariant": "SSH, WSL, and remote-runtime restore paths must treat provider listing failures and unknown liveness as unknown, not dead, while still avoiding duplicate spawn and clearing expired relay leases exactly once.",
"oracle": "The current executable slice asserts saved SSH sessions use relay attach, expired relay attach does not silently fresh-spawn inside the provider, provider listing and targeted-liveness failures are unknown rather than dead, deferred passphrase cancellation does not auto-reconnect, deferred SSH attach uses saved leaf/tab session ids once connected, transient deferred reattach failure preserves the saved restore id without fresh-spawning, deferred SSH no-result cleanup clears the pending serializer without consuming the saved restore id, disconnected SSH relay ids are retained as deferred reconnect metadata and sidebar wake hints rather than attached PTY proof, and expired deferred relay state clears stale pane/tab bindings before one fresh replacement spawn. WSL and remote-runtime mirror polling require separate follow-up contracts.",
"commands": [],
"testFiles": [],
"assertionRefs": [],
"evidenceRuns": [],
"runtimeBudget": {
"p95Seconds": 45,
"scope": "provider contract plus optional SSH soak"
},
"flakeHistory": {
"status": "unknown",
"evidence": "Focused provider/main/renderer SSH contract tests now run locally; needs soak history before promotion."
"evidence": "Focused renderer contracts and one clean Docker/Linux SSH Electron run pass locally; the live journey needs CI soak history before promotion."
},
"redGreenEvidence": {
"status": "partial",
@@ -2041,7 +2209,7 @@
},
"performanceBudget": {
"required": true,
"evidence": "Provider listing and polling must be bounded, targeted where possible, and absent from typing/focus hot paths."
"evidence": "Host eligibility is resolved from existing store metadata during activation and adds no provider listing or polling. The local journey preserves cold deferral, while SSH eagerly mounts six managers as required; no work is added to typing or terminal-output hot paths."
},
"promotionCriteria": [
"Use deterministic fake providers for failure and unknown-liveness cases.",
@@ -2049,12 +2217,10 @@
"Include reconnect/polling artifacts that identify provider, connection id, and lease state."
],
"knownGaps": [
"No executable coverage on main yet; the slice lives on the pending fix-terminal-reliability stack.",
"Current command covers store wake-hint metadata, main-process SSH provider failure semantics, provider attach/expired-attach behavior, and renderer deferred SSH reconnect/transient-failure/expired-relay fallback with mocked transports.",
"The store coverage asserts recoverability metadata, not a live attached PTY or user-visible connected state.",
"Live SSH/remote coverage remains environment-dependent.",
"The live SSH journey is environment-dependent and currently runs from a macOS Electron client against a Linux Docker host.",
"WSL and remote-runtime mirror polling contracts are not wired yet.",
"The deferred SSH coverage does not boot Electron against a real SSH relay or exercise real network reconnect timing."
"Linux and Windows desktop-client journeys are not yet collected."
],
"demotionRule": "Cannot promote if provider failure can close panes or if the oracle is screenshot-only."
},
@@ -28,6 +28,7 @@ describe('electron-builder config', () => {
'!mobile{,/**/*}',
'!native{,/**/*}',
'!skills{,/**/*}',
'!skill-guides{,/**/*}',
'!tests{,/**/*}',
'!pr-evidence{,/**/*}',
'!Casks{,/**/*}',
@@ -0,0 +1,205 @@
import { constants } from 'node:fs'
import { access, mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
import path from 'node:path'
import process from 'node:process'
import { parse } from 'yaml'
const SCRIPT_DIR = import.meta.dirname
const REPO_ROOT = path.resolve(SCRIPT_DIR, '..', '..')
const CANONICAL_GUIDE_NAMES = [
'computer-use',
'linear-tickets',
'orca-cli',
'orca-emulator',
'orca-emulator-android',
'orca-linear',
'orca-per-workspace-env',
'orchestration'
]
// Why: old discovery stubs can outlive a rename indefinitely, so aliases are
// a compatibility ledger: add entries for renames, but never remove them.
const GUIDE_ALIASES = {
'computer-use': [],
'linear-tickets': [],
'orca-cli': [],
'orca-emulator': [],
'orca-emulator-android': [],
'orca-linear': [],
'orca-per-workspace-env': [],
orchestration: []
}
function normalizeMarkdown(markdown) {
return markdown.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
}
function parseFrontmatter(markdown, sourcePath) {
const normalized = normalizeMarkdown(markdown)
const match = /^---\s*\n([\s\S]*?)\n---\s*(?:\n|$)/.exec(normalized)
if (!match) {
throw new Error(`Guide source has no YAML frontmatter: ${sourcePath}`)
}
let values
try {
values = parse(match[1])
} catch (error) {
throw new Error(
`Guide source has invalid YAML frontmatter: ${sourcePath}: ${error instanceof Error ? error.message : String(error)}`
)
}
if (
!values ||
typeof values !== 'object' ||
typeof values.name !== 'string' ||
typeof values.description !== 'string'
) {
throw new Error(`Guide source must declare name and description: ${sourcePath}`)
}
return {
name: values.name,
description: values.description.replace(/\s+/g, ' ').trim()
}
}
function constantName(name) {
return `${name.replace(/-/g, '_').toUpperCase()}_MARKDOWN`
}
function serializeEmbeddedModule(guides) {
const markdownConstants = guides
.map(
(guide) =>
`// oxfmt-ignore\nconst ${constantName(guide.name)} = ${JSON.stringify(guide.markdown)}`
)
.join('\n\n')
const guideEntries = guides
.map((guide) => {
const markdownConstant = constantName(guide.name)
return [
' {',
` name: ${JSON.stringify(guide.name)},`,
` description: ${JSON.stringify(guide.description)},`,
` markdown: ${markdownConstant},`,
` fullMarkdown: ${markdownConstant},`,
` aliases: ${JSON.stringify(guide.aliases)}`,
' }'
].join('\n')
})
.join(',\n')
return `// Generated by config/scripts/generate-bundled-skill-guides.mjs. Do not edit.\n\nexport type BundledSkillGuide = {\n readonly name: string\n readonly description: string\n readonly markdown: string\n readonly fullMarkdown: string\n readonly aliases: readonly string[]\n}\n\n${markdownConstants}\n\n// Why: no current guide has bundled reference documents, so --full is byte-identical for now.\n// oxfmt-ignore\nexport const BUNDLED_SKILL_GUIDES = [\n${guideEntries}\n] as const satisfies readonly BundledSkillGuide[]\n`
}
function assertAliasContract(guides) {
const canonicalNames = new Set(guides.map((guide) => guide.name))
const seenAliases = new Set()
for (const guide of guides) {
for (const alias of guide.aliases) {
if (canonicalNames.has(alias)) {
throw new Error(`Guide alias collides with canonical name: ${alias}`)
}
if (seenAliases.has(alias)) {
throw new Error(`Guide alias is assigned more than once: ${alias}`)
}
seenAliases.add(alias)
}
}
}
async function buildArtifacts(repoRoot = REPO_ROOT) {
const guideRoot = path.join(repoRoot, 'skill-guides')
const sourceFiles = (await readdir(guideRoot, { withFileTypes: true }))
.filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
.map((entry) => entry.name.slice(0, -3))
.sort((left, right) => left.localeCompare(right, 'en'))
const expectedNames = [...CANONICAL_GUIDE_NAMES].sort((left, right) =>
left.localeCompare(right, 'en')
)
if (JSON.stringify(sourceFiles) !== JSON.stringify(expectedNames)) {
throw new Error(
`Guide sources must match the canonical topic list.\nExpected: ${expectedNames.join(', ')}\nFound: ${sourceFiles.join(', ')}`
)
}
const guides = []
const projections = []
for (const name of expectedNames) {
const sourcePath = path.join(guideRoot, `${name}.md`)
// Why: Git may render text with native EOLs despite repository policy; the
// embedded guide and generated projection must have one platform-neutral identity.
const markdown = normalizeMarkdown(await readFile(sourcePath, 'utf8'))
const frontmatter = parseFrontmatter(markdown, path.relative(repoRoot, sourcePath))
if (frontmatter.name !== name) {
throw new Error(`Guide source ${name}.md declares mismatched name ${frontmatter.name}`)
}
const aliases = GUIDE_ALIASES[name]
guides.push({ name, description: frontmatter.description, markdown, aliases })
projections.push({
path: path.join(repoRoot, 'skills', name, 'SKILL.md'),
content: markdown
})
}
assertAliasContract(guides)
return [
{
path: path.join(repoRoot, 'src', 'cli', 'bundled-skill-guides.ts'),
content: serializeEmbeddedModule(guides)
},
...projections
]
}
async function writeArtifacts(artifacts) {
for (const artifact of artifacts) {
await mkdir(path.dirname(artifact.path), { recursive: true })
await writeFile(artifact.path, artifact.content, 'utf8')
}
}
async function verifyArtifacts(artifacts, repoRoot = REPO_ROOT) {
const stale = []
for (const artifact of artifacts) {
try {
await access(artifact.path, constants.R_OK)
if ((await readFile(artifact.path, 'utf8')) !== artifact.content) {
stale.push(artifact.path)
}
} catch {
stale.push(artifact.path)
}
}
if (stale.length > 0) {
throw new Error(
`Generated bundled skill guides are stale:\n${stale
.map((filePath) => path.relative(repoRoot, filePath))
.join('\n')}\nRun node config/scripts/generate-bundled-skill-guides.mjs --write.`
)
}
}
async function main() {
const artifacts = await buildArtifacts()
await (process.argv.includes('--write') ? writeArtifacts : verifyArtifacts)(artifacts)
}
if (process.argv[1] && path.resolve(process.argv[1]) === import.meta.filename) {
main().catch((error) => {
console.error(error instanceof Error ? error.message : error)
process.exitCode = 1
})
}
export {
CANONICAL_GUIDE_NAMES,
GUIDE_ALIASES,
assertAliasContract,
buildArtifacts,
normalizeMarkdown,
parseFrontmatter,
serializeEmbeddedModule,
verifyArtifacts,
writeArtifacts
}
@@ -0,0 +1,150 @@
import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { BUNDLED_SKILL_GUIDES } from '../../src/cli/bundled-skill-guides'
import {
CANONICAL_GUIDE_NAMES,
GUIDE_ALIASES,
assertAliasContract,
buildArtifacts,
normalizeMarkdown,
parseFrontmatter,
verifyArtifacts,
writeArtifacts
} from './generate-bundled-skill-guides.mjs'
const projectDir = path.resolve(import.meta.dirname, '..', '..')
const temporaryDirectories = []
async function createFixture() {
const root = await mkdtemp(path.join(tmpdir(), 'orca-bundled-skill-guides-'))
temporaryDirectories.push(root)
await Promise.all([
cp(path.join(projectDir, 'skill-guides'), path.join(root, 'skill-guides'), {
recursive: true
}),
cp(path.join(projectDir, 'skills'), path.join(root, 'skills'), { recursive: true }),
mkdir(path.join(root, 'src', 'cli'), { recursive: true })
])
return root
}
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true }))
)
})
describe('bundled skill guide generator', () => {
it('keeps every first-phase fat projection byte-identical to its authoritative source', async () => {
for (const name of CANONICAL_GUIDE_NAMES) {
const source = await readFile(path.join(projectDir, 'skill-guides', `${name}.md`))
const projection = await readFile(path.join(projectDir, 'skills', name, 'SKILL.md'))
expect(projection, name).toEqual(source)
}
})
it('embeds canonical names, discovery descriptions, Markdown, and append-only aliases', async () => {
expect(BUNDLED_SKILL_GUIDES.map((guide) => guide.name)).toEqual(
[...CANONICAL_GUIDE_NAMES].sort((left, right) => left.localeCompare(right, 'en'))
)
for (const guide of BUNDLED_SKILL_GUIDES) {
const source = await readFile(
path.join(projectDir, 'skill-guides', `${guide.name}.md`),
'utf8'
)
const frontmatter = parseFrontmatter(source, `${guide.name}.md`)
expect(guide.description).toBe(frontmatter.description)
expect(guide.markdown).toBe(source)
expect(guide.fullMarkdown).toBe(source)
expect(guide.aliases).toEqual(GUIDE_ALIASES[guide.name])
}
})
it('keeps CLI guide examples safe across shells and Linux command names', async () => {
for (const name of ['orca-cli', 'computer-use', 'orca-emulator', 'orca-emulator-android']) {
const source = await readFile(path.join(projectDir, 'skill-guides', `${name}.md`), 'utf8')
expect(source).toContain('ORCA_CLI_COMMAND')
expect(source).toContain('orca-dev')
expect(source).toContain('orca-ide')
expect(source).toContain('PowerShell')
expect(source).toContain('cmd.exe')
expect(source).toMatch(/^ORCA .+--json$/mu)
// Why: bare command lines can launch GNOME Orca, while shell variables make
// the same guide unusable from PowerShell and cmd.exe.
expect(source).not.toMatch(/^orca /mu)
expect(source).not.toMatch(/\$ORCA(?:_|\b)/u)
}
})
it('builds deterministic artifacts and verifies the checked-in outputs', async () => {
const first = await buildArtifacts(projectDir)
const second = await buildArtifacts(projectDir)
expect(second).toEqual(first)
await expect(verifyArtifacts(first, projectDir)).resolves.toBeUndefined()
})
it('generates platform-identical output from CRLF guide sources', async () => {
const expected = await buildArtifacts(projectDir)
const root = await createFixture()
for (const name of CANONICAL_GUIDE_NAMES) {
const sourcePath = path.join(root, 'skill-guides', `${name}.md`)
const source = await readFile(sourcePath, 'utf8')
await writeFile(sourcePath, source.replaceAll('\n', '\r\n'))
}
const actual = await buildArtifacts(root)
expect(actual.map((artifact) => artifact.content)).toEqual(
expected.map((artifact) => artifact.content)
)
})
it('pins guide sources, projections, and embedded output to LF in Git', async () => {
const attributes = await readFile(path.join(projectDir, '.gitattributes'), 'utf8')
expect(normalizeMarkdown(attributes)).toContain('/skill-guides/*.md text eol=lf\n')
expect(normalizeMarkdown(attributes)).toContain('/skills/*/SKILL.md text eol=lf\n')
expect(normalizeMarkdown(attributes)).toContain(
'/src/cli/bundled-skill-guides.ts text eol=lf\n'
)
})
it('reports stale outputs and write mode repairs all projections', async () => {
const root = await createFixture()
const artifacts = await buildArtifacts(root)
await expect(verifyArtifacts(artifacts, root)).rejects.toThrow(
'src/cli/bundled-skill-guides.ts'
)
await writeArtifacts(artifacts)
await expect(verifyArtifacts(artifacts, root)).resolves.toBeUndefined()
await writeFile(path.join(root, 'skills', 'computer-use', 'SKILL.md'), 'stale\n')
await expect(verifyArtifacts(artifacts, root)).rejects.toThrow('skills/computer-use/SKILL.md')
})
it('rejects mismatched source names and ambiguous aliases', async () => {
const root = await createFixture()
await writeFile(
path.join(root, 'skill-guides', 'computer-use.md'),
'---\nname: wrong\ndescription: present\n---\n'
)
await expect(buildArtifacts(root)).rejects.toThrow('declares mismatched name wrong')
expect(() =>
assertAliasContract([
{ name: 'first', aliases: ['legacy'] },
{ name: 'second', aliases: ['legacy'] }
])
).toThrow('assigned more than once')
expect(() =>
assertAliasContract([
{ name: 'first', aliases: ['second'] },
{ name: 'second', aliases: [] }
])
).toThrow('collides with canonical name')
})
})
@@ -40,7 +40,7 @@ describe('orca CLI skill guidance', () => {
'`task-create` is also forbidden because it records coordinator-owned tracking state'
)
expect(skill).toContain(
'orca worktree create --name <task-name> --no-parent --agent codex --prompt'
'ORCA worktree create --name <task-name> --no-parent --agent codex --prompt'
)
expect(skill).toContain('codex --model gpt-5.5 -c model_reasoning_effort="xhigh"')
expect(skill).toContain('wait only for TUI readiness if needed to avoid losing input')
@@ -7,6 +7,19 @@ const projectDir = resolve(import.meta.dirname, '../..')
const packageJson = JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf8'))
describe('Electron runtime package contract', () => {
it('keeps shared WebGL atlas invalidation reproducible from vendored source', () => {
const patch = readFileSync(
join(projectDir, 'config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch'),
'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')
})
it('keeps root postinstall as the single Electron binary install owner', () => {
expect(packageJson.scripts.postinstall).toBe('node config/scripts/rebuild-native-deps.mjs')
expect(packageJson.pnpm.onlyBuiltDependencies).not.toContain('electron')
@@ -535,6 +548,9 @@ describe('Electron runtime package contract', () => {
expect(packageScripts['test:e2e:terminal-rendering-golden']).toContain(
'terminal-raw-emoji-table-scroll-restore.spec.ts'
)
expect(packageScripts['test:e2e:terminal-rendering-golden']).toContain(
'terminal-webgl-atlas-budget.spec.ts'
)
expect(packageScripts['test:e2e:terminal-rendering-golden']).not.toContain(
'terminal-long-table-scroll-restore.spec.ts'
)
@@ -548,6 +564,8 @@ describe('Electron runtime package contract', () => {
expect(runStep?.run).toContain('pnpm run test:e2e:terminal-rendering-golden')
}
expect(pullRequestPaths).toContain('tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts')
expect(pullRequestPaths).toContain('tests/e2e/terminal-webgl-atlas-budget.spec.ts')
expect(pullRequestPaths).toContain('config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch')
expect(pullRequestPaths).toContain('tests/e2e/fixtures/terminal-emoji-table.md')
expect(pullRequestPaths).toContain('src/renderer/src/lib/pane-manager/**')
expect(releaseBuildNeeds).not.toContain('terminal-rendering-golden')
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 5.0m">
<title>downloads: 5.0m</title>
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 5.3m">
<title>downloads: 5.3m</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="93.5" y="15" fill="#010101" fill-opacity=".3">5.0m</text>
<text x="93.5" y="14">5.0m</text>
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">5.3m</text>
<text x="93.5" y="14">5.3m</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 943 B

After

Width:  |  Height:  |  Size: 943 B

+8 -2
View File
@@ -112,8 +112,14 @@ path. This mirrors POSIX ownership without starting an additional process.
- Encoded PowerShell launchers use `Test-Path -PathType Leaf`. A missing script
calls `[Console]::In.ReadToEnd()` and exits zero; an existing script preserves
`$LASTEXITCODE`.
- Codex's cmd.exe fast path remains PowerShell-free. It rejects missing paths
and directories, using the same qualified `more.com` drain for both.
- Codex's (and Antigravity's/Devin's) cmd fast path is the bare, directly
spawnable `.cmd` path. These agents launch the hook command as a program
(argv[0]), not through cmd.exe, so the launcher cannot lead with a cmd builtin
such as `if` — that argv[0] is unspawnable and fails every hook. A missing
script therefore surfaces a normal launch failure on this fast path; only
launchers that already require a real interpreter (encoded PowerShell, Git
Bash) drain a missing script. Paths cmd.exe cannot invoke verbatim fall back to
the encoded PowerShell launcher, which owns stdin for the missing case.
- Claude's Git Bash fast path uses a POSIX file guard and drain while continuing
to execute the `.cmd` directly rather than interpreting it as shell source.
- Agent-specific direct launchers, including Antigravity event wrappers and
@@ -0,0 +1,552 @@
# Terminal Session Ownership and Teardown
**Status:** Incident fix and pane-authority transfer implemented
**Date:** 2026-07-13
**Incident:** [`../../pty-exhaustion-agent-session-leak.md`](../../pty-exhaustion-agent-session-leak.md)
**Related contracts:** [`terminal-model-view-contract.md`](./terminal-model-view-contract.md),
[`terminal-hidden-view-parking.md`](./terminal-hidden-view-parking.md)
## Summary
Terminal process lifetime must be owned by explicit product intent, never by
whether a React view happens to be mounted.
Orca currently preserves a PTY when a terminal view detaches, which is correct
for view parking, tab moves, renderer reload, and warm reattach. It also relies
on `TerminalPane` unmount to destroy a PTY after a tab is closed. Hidden-view
parking invalidated that implicit invariant: a parked tab has no mounted pane,
so closing it removes renderer state and observers without terminating the PTY.
Agent resume records survive the same close and later interpret the missing pane
as failed recovery, launching the deliberately closed session again.
This design establishes one authoritative lifecycle contract:
- **Close tab or pane:** permanently retire the surface, terminate every PTY it
owns, and revoke every resume record for its panes.
- **Detach or park:** remove only the view connection; preserve the PTY and its
durable identity.
- **Sleep:** intentionally checkpoint resumable agents, terminate the PTYs, and
preserve only the checkpoint needed for wake.
- **Quit or reload:** follow the configured persistence policy; warm detach is
allowed, but it is not a tab close.
The immediate implementation makes the existing terminal-tab state close the
authoritative compatibility boundary for explicit retirement while adding an
explicit natural-exit reason for callers that are only reconciling an already
dead PTY. A follow-up separates the command and reducer APIs once all direct
store callers have migrated.
## Implementation status
The incident patch ships the retirement planner, provider-aware close routing,
unified-only retirement, parked watcher/candidate disposal, resume-authority
revocation, explicit natural-exit handling, late-binding rejection, and direct
background-launch ownership checks. It also adds exact pane retirement and a
persisted physical-to-owner pane-key alias for detach, plus a live Electron
parked-close gate. Focused tests cover local, SSH, split, parked, shared,
paired-host, ordinary runtime, pane-transfer, and late-spawn paths.
Origin-aware defensive expiry remains a follow-up. Immediate deletion on
explicit close fixes the incident without applying a wall-clock policy that
could invalidate intentional long-lived `worktree-sleep` checkpoints.
## Goals
1. Closing a terminal tab submits retirement for all exclusively owned local,
daemon, WSL, SSH, or runtime PTYs, including every split pane, whether its
views are mounted or parked.
2. Closing a terminal pane terminates only that pane's PTY; detaching a pane to
another tab preserves its PTY, hook identity, and resume authority under the
new owning pane.
3. Closing a tab permanently removes its agent resume authority. App restart,
worktree activation, mobile wake, and periodic capture must not resurrect it.
4. Parking, tab-group moves, renderer reload, and warm reattach keep their
existing process-preservation behavior.
5. Remote-runtime and web-session surfaces are terminated by their owning host,
not by sending a host-scoped ID to the local PTY provider.
6. Teardown is idempotent and safe under duplicate close requests, late PTY
exits, late agent-hook events, in-flight spawns, and provider disconnects.
7. A future bookkeeping regression cannot grow without bound: unowned defensive
resume records have an origin-aware expiry, and provider-owned detached
sessions gain a bounded orphan policy in the daemon-lease phase.
## Non-goals
- Changing terminal output, snapshot, replay, query-response, or hidden-delivery
behavior.
- Killing processes that deliberately daemonize away from the terminal process
group. This contract covers the PTY and its attached foreground process tree.
- Changing agent-provider resume commands or permission flags.
- Making a UI close wait for a remote process to exit before the tab disappears.
- Replacing worktree sleep with tab close. Sleep remains resumable by design.
- Claiming that a disconnected SSH relay process is already dead when main can
only tombstone its app-scoped ID. Durable relay-side kill-on-reconnect is a
follow-up.
## Terminology
- **Surface:** a terminal tab or a pane within a split terminal tab.
- **View:** a renderer xterm or pane-less parked watcher observing a surface.
- **Session:** the provider-owned PTY identified by a PTY ID.
- **Resume authority:** a `sleepingAgentSessionsByPaneKey` record that permits
Orca to launch an agent-provider resume command.
- **Retire:** permanently close a surface and revoke its process and resume
ownership.
- **Detach:** disconnect a view while preserving the session for reattachment.
## Open-source prior art
The lifecycle distinction is established in mature terminal implementations:
- VS Code's terminal instance disposal calls process-manager disposal, and the
process manager marks the process `KilledByUser` before shutdown. Its separate
`detachFromProcess` path deliberately clears the client reference without
shutting down the process. It also shuts down a process whose asynchronous
creation completes after the manager was disposed. See
[`terminalInstance.ts`](https://github.com/microsoft/vscode/blob/3f5c62a95ddb886424da463b41ac3ac5e45aa04f/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts#L1287-L1325)
and
[`terminalProcessManager.ts`](https://github.com/microsoft/vscode/blob/3f5c62a95ddb886424da463b41ac3ac5e45aa04f/src/vs/workbench/contrib/terminal/browser/terminalProcessManager.ts#L207-L239).
- tmux exposes the same contract as separate commands: `detach-client` removes
a client while leaving the session intact, whereas `kill-session` destroys
the session and its windows. See
[`tmux.1`](https://github.com/tmux/tmux/blob/42b3ea0d7411acb4cd0357a3c2829d986b455918/tmux.1#L1177-L1218).
Orca needs the same semantic split, extended across local, WSL, SSH, persistent
daemon, and remote-runtime ownership. The important precedent is the explicit
intent boundary, not a particular framework or process API.
## Non-negotiable invariants
1. View lifetime and process lifetime are independent. Mount and unmount may
attach or detach observers; they do not decide whether a session should live.
2. Every live PTY has an owning surface or an explicit bounded detached state.
3. A user close is terminal: once accepted, no status event, session snapshot,
or runtime replay may recreate the closed surface or resume its agent.
4. A tab's retirement candidates are the union of its live index, tab row,
persisted split layout, last-known relay ID, deferred SSH ID, and pending
reconnect ID. A candidate is killable only when no other live surface claims
it.
5. Provider routing follows the execution owner:
- local/daemon/WSL and app-scoped SSH IDs use `pty.kill`;
- `remote:` runtime mirror IDs are never passed to the local PTY provider;
- paired web tabs are closed through the host session RPC.
6. Teardown requests are idempotent. Repeated kill, exit-after-kill, and
close-after-exit must converge on the same empty state.
7. Resume-record validity is origin-aware. Intentional `worktree-sleep` records
are not expired by agent-status freshness; unowned defensive `live` and
`quit` records use a documented decision-time horizon.
8. Natural process exit and explicit user retirement are distinct intents.
Reconciliation after an exit must not silently adopt destructive user-close
semantics.
## Lifecycle matrix
| Intent | View | PTY | Resume record | Persistent tab state |
| --------------------- | ------------------------------------------ | ---------------------------------- | --------------------------------------------------- | -------------------- |
| Switch tabs/worktrees | hidden or parked | keep | keep/update | keep |
| Move pane/tab group | detach, then remount | keep | keep | move |
| Renderer reload | detach | keep when warm persistence applies | keep | keep |
| App quit | detach or terminate per persistence policy | policy-dependent | capture live agents | keep |
| Sleep worktree | unmount | terminate | capture intentional sleep checkpoint | keep identifiers |
| Close split pane | destroy | terminate that pane | delete that pane record | remove pane |
| Close terminal tab | destroy or already absent | terminate all tab PTYs | delete all tab records | remove tab |
| Remove worktree | destroy | terminate all worktree PTYs | delete all worktree records | remove worktree |
| PTY exits naturally | disconnect | already dead | clear or retain completed evidence per agent policy | reconcile surface |
## Current failure
The current close path deletes `ptyIdsByTabId[tabId]` and the tab layout, then
relies on a mounted `TerminalPane` cleanup to call `transport.destroy()`. A
parked tab has already run the detach branch and has no mounted cleanup left.
`disposeParkedTabWatchers` only unregisters observers. The provider session is
therefore alive after Orca discards its last renderer-side owner.
The same state close drops live agent status but not
`sleepingAgentSessionsByPaneKey`. Worktree activation intentionally fresh-resumes
records that no preserved pane can own. A deliberately closed pane therefore
looks like a failed restore. The existing age check compares `capturedAt` with
`updatedAt`; that detects a status that was stale at capture time but never
expires a record as wall time advances.
## Proposed architecture
### 1. Authoritative tab retirement plan
Before deleting any tab-scoped state, build a `TerminalTabRetirementPlan` from
one store snapshot:
```ts
type TerminalTabRetirementPlan = {
tabId: string
worktreeId: string | null
ptyIds: string[]
localOrSshPtyIds: string[]
runtimeTerminals: Array<{ environmentId: string; handle: string }>
sharedPtyIds: string[]
paneKeys: string[]
}
```
`ptyIds` is the deduplicated union of:
- `ptyIdsByTabId[tabId]`;
- the tab row's legacy `ptyId`;
- every value in `terminalLayoutsByTabId[tabId].ptyIdsByLeafId`;
- `lastKnownRelayPtyIdByTabId[tabId]`;
- `deferredSshSessionIdsByTabId[tabId]`;
- `pendingReconnectPtyIdByTabId[tabId]`.
This snapshot must be built before any of those maps are pruned. PTY IDs are
classified structurally with `parseRemoteRuntimePtyId`, not by assuming a host
or platform from the current active workspace. Before shutdown, the planner
subtracts IDs referenced by another tab row, live index, split layout, or relay
and reconnect map. A partially completed pane move must not let closing the
source tab kill the target tab's session.
The immediate compatibility boundary is `closeTab`, because several production
paths still call the store action directly. Its options carry an explicit
`reason: 'user' | 'pty-exit' | 'cleanup'`; `user` and `cleanup` retire, while
`pty-exit` reconciles an already-dead session without killing siblings or
revoking crash-recovery policy by accident. Making only the rendered tab-bar
action destructive would leave background launch cleanup, floating terminal,
onboarding, runtime notifications, and future direct callers vulnerable.
### 2. Teardown ordering
A local tab retirement executes in this order:
1. Snapshot the retirement plan and its execution-owner classifications.
2. Mark the tab recently closed so late agent-hook events cannot reintroduce
status or resume authority.
3. Dispose the whole tab's parked registry entry and captured candidates, and
silence teardown side-effect handlers while preserving deterministic exit
observation.
4. Submit `pty.kill` for exclusive local or SSH IDs and `terminal.close` for
exclusive ordinary runtime handles. A paired host-session close remains
owned by `session.tabs.close` and is not duplicated locally.
5. Atomically remove the tab, layout/binding maps, agent status, and resume
records from renderer state.
6. Let subsequent React unmount cleanup run idempotently. It is no longer the
process-lifetime authority.
Steps 3 and 4 are issued before the state loses its IDs. The UI state mutation
remains synchronous and does not await provider exit. Completion is observed
with `Promise.allSettled` so rejections cannot become unhandled promises. The
current Phase 1 fallback for a rejected retirement is provider `listSessions`
plus Resource Manager orphan cleanup; durable retry inventory belongs to the
main-owned retirement phase.
Remote-runtime mirrors are different: their `remote:` IDs are excluded from
local `pty.kill`. Ordinary runtime terminals retire through `terminal.close`.
Paired host-session tabs retire through `session.tabs.close`, which owns the
entire host tab graph. The local mirror may be pruned optimistically only after
the host close intent is recorded, as it is today.
### 3. Resume-authority revocation
Tab retirement removes every sleeping record whose:
- map key begins with `${tabId}:`; or
- record has `tabId === tabId`.
The explicit `record.tabId` check covers migrated or legacy keys whose key no
longer encodes the current tab identity. Sibling tabs and other worktrees retain
their records by reference when unchanged.
The existing recently-closed tab registry remains the short-lived race guard
for hook events already in flight. It must prevent those events from creating a
new `origin: 'live'` resume record after retirement. Tests must cover both event
orders: close then late status, and status queued in the same turn as close.
Periodic capture iterates only current live status entries. Once close removes
the live entry and resume record, the periodic pass has nothing to persist.
### 4. Origin-aware defensive record expiry
An unowned defensive `live` or `quit` record may expire when:
```ts
Date.now() - record.capturedAt > DEFENSIVE_AGENT_RESUME_MAX_AGE_MS
```
Intentional `worktree-sleep` records are exempt from the agent-status freshness
window. Originless legacy behavior remains unchanged until its migration policy
is explicitly chosen. The same validator must run before desktop activation and
mobile/background wake so one entry point cannot bypass it.
Expiry is defense in depth, not the primary close mechanism. A correctly closed
record is deleted immediately; it is not retained for 30 minutes.
### 5. Pane close remains pane-scoped
`PaneManager.closePane` already distinguishes `reason: 'close'` from
`reason: 'detach'`. Preserve that distinction with explicit pane authority:
- `close` must clear the exact pane's resume authority, add a pane-scoped late
event tombstone, and destroy the pane transport;
- `detach` must atomically transfer or alias resume ownership because the agent
process retains its immutable source `ORCA_PANE_KEY`.
The last-pane path routes to terminal-tab retirement so it receives the same
parked, split, SSH, and resume cleanup behavior as a tab-bar close.
### 6. Provider-side safety net
Client correctness is necessary but not sufficient for a daemon deliberately
designed to outlive renderer and app processes. Add a follow-up provider-owned
orphan policy:
- track attachments/ownership leases rather than treating `detach` as a no-op;
- distinguish warm-reattach grace from ownerless retirement;
- reap sessions that have no owner after a bounded grace period;
- never idle-reap a session merely because it produces no output;
- scope leases to native, WSL, SSH provider, relay connection, or runtime host;
- expose orphan count and oldest orphan age in Resource Manager diagnostics.
This hardening is not required to land the immediate close fix, because a lease
protocol changes daemon compatibility and needs its own migration. It is the
defense against a future client bookkeeping regression.
## Provider behavior
| Provider/session kind | Close operation | Notes |
| ----------------------- | ----------------------------------- | ---------------------------------------------------------------------------------------------- |
| Local native | `pty.kill(id)` | Main routes to the owning local/daemon provider. |
| Persistent daemon | `pty.kill(id)` | Explicit close overrides warm-reattach persistence. |
| Windows ConPTY | `pty.kill(id)` | Provider shutdown owns Windows process-tree semantics. |
| WSL | `pty.kill(id)` | Route by PTY ownership; do not infer from path separators. |
| SSH | `pty.kill(appScopedId)` | Main resolves the connection-specific SSH provider. |
| Disconnected SSH | `pty.kill(appScopedId)` | Tombstones locally and prevents fallback; relay-side death requires reconnect-aware follow-up. |
| Ordinary remote runtime | host `terminal.close` | Classify environment and handle from the `remote:` ID. |
| Paired web host tab | host `session.tabs.close` | Host owns the complete tab and pane graph. |
| Web client local mirror | optimistic prune after close intent | Host snapshot confirms final removal. |
## Failure and race handling
### Duplicate close
The first close snapshots and retires the tab. Later closes find no target and
no-op. Provider shutdown is idempotent for an already exited session.
### PTY exit races close
Observers are unregistered before shutdown. If an exit event was already
queued, the recently-closed tab guard and missing tab make it a no-op. It must
not recreate a tab, completion row, resume record, or notification.
### Spawn completes after close
The transport already kills a PTY when its spawn resolves after `destroyed` is
set, but the two direct background launchers do not use that transport. Each
direct launcher must revalidate tab ownership immediately after `pty.spawn` or
`terminal.create` resolves and before writing any binding, layout, eager buffer,
subscription, or mount request. A retired local or SSH result is killed; a
retired runtime result is closed through `terminal.close`.
### Provider shutdown fails
The tab remains closed; reappearing would violate user intent. Report the
provider class through structured diagnostics and leave the session visible in
provider inventory so existing Resource Manager orphan cleanup can retry. A
durable pending-retirement inventory is a later main-process API and is not
claimed by Phase 1. Do not restore resume authority after a failed terminal
kill.
### Remote host is unavailable
Keep the local close intent until either a host snapshot confirms removal or the
existing intent TTL expires. If it expires, the host remains authoritative and
may republish the tab; log the failed close. Never pretend a local mirror prune
terminated the host PTY.
### App exits during close
Submit shutdown IPC before discarding the only local PTY identifiers. The main
process/provider owns completion after accepting the request. A future batched
shutdown IPC may return per-ID acceptance, but UI close does not wait for it.
## Implementation plan
### Phase 1: correctness patch (implemented)
1. Add a small store-independent terminal-tab retirement planner that collects
all six ID sources, classifies execution owners, and excludes IDs shared by
another live surface.
2. Add an explicit close reason, update natural-exit callers, and make explicit
`closeTab` retirement dispose the whole parked-tab registry, submit local,
SSH, or runtime shutdown, atomically clear all reconnect maps, and remove
tab-owned resume records.
3. Add post-await owner checks to both direct background launchers so a late
local, SSH, or runtime spawn is terminated before any renderer binding.
4. Defer decision-time expiry until unowned defensive `live` or `quit` records
can be distinguished without changing intentional `worktree-sleep` or legacy
recovery behavior.
5. Add focused planner, store, launcher-race, and resume-policy tests. Keep one
local Electron parked-close proof as an RC release gate.
### Phase 2: remaining API cleanup
1. Rename the state-only reducer primitive so `closeTab` cannot ambiguously mean
either UI cleanup or lifecycle teardown.
2. Route all user and runtime close call sites through one domain command.
3. Keep explicit `detach` and `sleep` APIs; do not encode them as close options.
4. Add a development assertion when tab state is removed with provider PTY IDs
but no retirement plan.
Exact pane retirement, persisted detach authority transfer, and unified-only
terminal routing landed with Phase 1 because exact-head review found they were
required to avoid introducing adjacent close regressions.
### Phase 3: daemon ownership leases
1. Define versioned attach/detach/retire lease messages.
2. Preserve warm reattach across renderer/app restarts within a bounded grace.
3. Reap ownerless sessions and expose pressure telemetry.
4. Add compatibility tests across the supported daemon protocol versions and
native/WSL/SSH host isolation.
## Test plan
### Unit/store tests
- Closing an active single-pane tab submits exactly its PTY for shutdown and
removes all tab state.
- Closing a parked tab with no mounted transport still submits its PTY.
- Closing a split tab submits every unique PTY found across tab row, index, and
layout plus relay/deferred/reconnect maps, including IDs present in only one
source.
- Duplicate IDs are submitted once by the authoritative retirement plan.
- An ID referenced by another live tab is not killed when the source tab closes.
- `remote:` IDs are excluded from local PTY shutdown.
- App-scoped SSH IDs are included and are not mistaken for remote runtime IDs.
- All sleeping records for the tab are removed by key prefix or `record.tabId`;
sibling records retain identity.
- Periodic capture after close does not recreate the record.
- Late agent status after close cannot recreate live status or resume authority.
- Closing twice and close-after-exit are no-ops after the first retirement.
- A spawn resolving after close kills the newly returned PTY.
- A defensive live/quit record at the expiry boundary is retained; one
millisecond past it is cleared and never launched, while an older intentional
worktree-sleep record still resumes.
### Renderer integration tests
- Mounted tab close still destroys its xterm and terminates the PTY.
- Parked watcher disposal occurs before or with PTY shutdown and emits no final
completion/bell notification.
- Closing one split pane preserves the sibling; closing the last pane retires
the tab.
- Pane-to-tab detach preserves the PTY and resume record.
- Pinned-tab cancellation performs no shutdown; confirmed close does.
- Bulk close (`close others`, `close right`, kill-all) uses the same semantics.
### Provider/main tests
- Local, daemon, WSL, ConPTY, and SSH ownership route explicit close to the
correct provider.
- Disconnected SSH close tombstones the app-scoped ID and never falls through to
a local provider.
- Repeated `pty.kill` for the same ID is benign.
- Remote-runtime IDs cannot reach the local kill handler from renderer close.
### Electron end-to-end regression
1. Create a worktree terminal and start a deterministic long-lived child.
2. Switch worktrees and wait until the source worktree is cold-parked.
3. Close the parked tab.
4. Assert its exact PTY disappears from `pty:listSessions` and the child exits.
5. Restart/reload Orca and activate the worktree.
6. Assert no terminal or agent resume command is recreated for the closed pane.
7. Repeat with two split panes and with an SSH fixture where CI supports it.
### Soak and pressure gate
Run repeated create, park, close, reload, and reopen cycles. App-owned PTY count
must return to baseline after each cycle and remain bounded over the run. Record
the peak and final provider session count in CI artifacts.
## Observability
Emit a synchronous scheduled summary and an asynchronous completion summary:
- tab/worktree identifiers in hashed or existing diagnostic form;
- number of PTY IDs discovered by each source;
- provider classification counts;
- submitted count, then fulfilled/rejected counts after `Promise.allSettled`;
- number of resume records removed;
- whether the tab was parked or mounted when retired.
After the daemon-lease phase, Resource Manager should distinguish:
- attached sessions;
- warm-detached sessions eligible for reattach;
- ownerless/orphan sessions;
- pending/failed explicit retirement.
Alert locally before app-owned PTYs approach platform pressure. The warning is a
defense, not a substitute for lifecycle correctness.
## Rollout
1. Land the correctness patch without a feature flag. User close semantics are
restorative behavior, not an experiment.
2. Keep hidden-view parking enabled; disabling it masks the ownership bug and
forfeits its memory benefit.
3. Run focused unit/integration tests plus the live parked-close Electron gate.
4. Cut an RC and soak create/close/restart cycles while monitoring provider and
OS PTY counts.
5. Ship daemon leases separately behind protocol compatibility and telemetry.
## Alternatives rejected
### Disable hidden-view parking
This restores the old accidental unmount behavior at significant memory cost
and leaves every other state-only close path fragile. It does not repair resume
records or daemon ownership.
### Kill only from `TerminalPane` cleanup
There is intentionally no pane during parking and some headless/runtime flows.
View cleanup cannot be process authority.
### Clear only resume records
This prevents resurrection but still leaks the live PTY until app/daemon exit.
### Kill only the tab row's `ptyId`
Split panes and partially reconciled layouts can own multiple IDs. The full
union is required.
### Rely only on a daemon idle timeout
Interactive agents can be legitimately idle for hours. Reaping by output
idleness loses user work; ownership, not activity, is the safe signal.
### Await every provider exit before hiding the tab
Remote and disconnected providers can take seconds or fail. UI retirement must
be immediate once the user confirms; provider completion is asynchronous and
observable.
## Phase 1 acceptance criteria
- A confirmed terminal-tab close submits retirement for every exclusively owned
session Orca can currently reach and reports rejected requests without an
unhandled promise.
- A closed agent session is never resumed by restart, activation, or mobile wake.
- Parking, move/detach, sleep/wake, and warm app reattach retain their documented
behavior.
- Split, local, SSH, and remote-runtime ownership are covered by deterministic
tests; Windows/WSL process-tree behavior remains a live-platform follow-up.
- PTY counts remain bounded across the soak scenario.
- No process-lifetime decision depends on React component mount state.
## Full-contract follow-ups
- Durable main-owned retirement intent and bounded retry inventory.
- Disconnected SSH relay kill-on-reconnect.
- Serializable daemon attachment leases with protocol-version fallback.
- Positive ownership validation for late hook events beyond the bounded recent-
close registry.
+1 -1
View File
@@ -2,7 +2,7 @@
"expo": {
"name": "Orca",
"slug": "orca-mobile",
"version": "0.0.27",
"version": "0.0.30",
"orientation": "default",
"icon": "./assets/icon.png",
"userInterfaceStyle": "automatic",
+7
View File
@@ -11,6 +11,7 @@ import { RpcClientProvider } from '../src/transport/client-context'
import { getNotificationNavigationPath } from '../src/notifications/notification-routing'
import { loadHosts } from '../src/transport/host-store'
import { extractPairingCodeFromUrl } from '../src/transport/pairing'
import { recoverMobileRelayPairing } from '../src/transport/mobile-relay-pairing-recovery'
// Why: keeps the native splash screen visible until the React tree is mounted
// and ready to render. Without this the user sees a blank white/black frame
@@ -35,6 +36,12 @@ export default function RootLayout() {
const router = useRouter()
const handledNotificationIdsRef = useRef<Set<string>>(new Set())
useEffect(() => {
// Why: pairing publication is journaled across process death; startup must
// reconcile the server result before another scan can replace that journal.
void recoverMobileRelayPairing()
}, [])
// Why: route `orca://pair?...` deep links to the confirm screen so
// the same pairing flow runs whether the link arrived via QR scan,
// paste, AirDrop, Messages, or `xcrun simctl openurl`. getInitialURL
+2 -2
View File
@@ -9,11 +9,11 @@ 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 {
useHostClient,
useLastConnectedAt,
useReconnectAttempt
} from '../src/transport/client-context'
} from '../src/transport/client-context-connection-metrics'
import { buildConnectionDiagnosticsReport } from '../src/diagnostics/connection-diagnostics-report'
import type { ConnectionLogEntry, HostProfile } from '../src/transport/types'
+38 -5
View File
@@ -6,7 +6,8 @@ import {
SectionList,
Pressable,
ActivityIndicator,
Alert
Alert,
RefreshControl
} from 'react-native'
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { useFocusEffect, useLocalSearchParams, usePathname, useRouter } from 'expo-router'
@@ -33,10 +34,13 @@ import { removeHostAndCloseClient } from '../../../src/transport/host-removal-li
import {
useHostClient,
useCloseHost,
useForceReconnect,
useReconnectAttempt,
useLastConnectedAt
useForceReconnect
} from '../../../src/transport/client-context'
import { useWorktreeResync } from '../../../src/transport/use-worktree-resync'
import {
useLastConnectedAt,
useReconnectAttempt
} from '../../../src/transport/client-context-connection-metrics'
import {
classifyConnection,
type ConnectionVerdict
@@ -59,7 +63,7 @@ import { ProtocolBlockScreen } from '../../../src/components/ProtocolBlockScreen
import { AuthFailedBanner } from '../../../src/components/AuthFailedBanner'
import { MobileSearchField } from '../../../src/components/MobileSearchField'
import { WorkspaceDetailPlaceholder } from '../../../src/components/WorkspaceDetailPlaceholder'
import { getCachedWorktrees } from '../../../src/cache/worktree-cache'
import { getCachedWorktrees, setCachedWorktrees } from '../../../src/cache/worktree-cache'
import { setCachedRepos } from '../../../src/cache/repo-cache'
import { colors, radii, spacing, typography } from '../../../src/theme/mobile-theme'
import { useResponsiveLayout } from '../../../src/layout/responsive-layout'
@@ -459,6 +463,14 @@ export function HostScreen({
areWorktreeListsEqual(current, result.worktrees) ? current : result.worktrees
)
setWorktreesLoaded(true)
// Why (#8498): the host detail screen seeds its list from the
// home-written cache, so a partial home fetch could poison it until a
// focus poll corrected it. Write the confirmed snapshot back through
// the same cache so a reconnect refetch (or a remount) can't serve a
// stale worktree list.
if (hostId) {
setCachedWorktrees(hostId, result.worktrees)
}
// Drop the optimistic active override once the host confirms it (the
// activate RPC has landed and worktree.ps now reports it active), so we
// stop overriding and respect any later desktop-driven change.
@@ -613,6 +625,17 @@ export function HostScreen({
return () => clearInterval(interval)
}, [embedded, connState, fetchWorktrees, fetchRepoMetadata, syncViewSettingsFromDesktop])
// Why (#8498): reconnect refetch + manual pull-to-refresh, extracted to
// useWorktreeResync so this screen stays under its max-lines budget. The
// steady-state focus/embedded polls don't cover the transition INTO
// 'connected' after a background/sleep, which is when the cache is stalest.
const { refreshing, onRefresh } = useWorktreeResync({
client,
connState,
fetchWorktrees,
fetchRepoMetadata
})
const updateLocalPins = useCallback(
(worktreeId: string, pinned: boolean) => {
setPinnedIds((prev) => {
@@ -1229,6 +1252,16 @@ export function HostScreen({
)
}}
ItemSeparatorComponent={ListSeparator}
// Why (#8498): manual pull-to-refresh forces a fresh worktree
// snapshot after a reconnect or whenever the cache looks stale.
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor={colors.textSecondary}
colors={[colors.textSecondary]}
/>
}
renderItem={({ item }) => (
<WorktreeListRow
item={item}
+12 -10
View File
@@ -56,12 +56,11 @@ import {
saveTerminalTextScale,
type MobileTerminalLinkOpenMode
} from '../../../../src/storage/preferences'
import { useHostClient, useForceReconnect } from '../../../../src/transport/client-context'
import {
useHostClient,
useForceReconnect,
useReconnectAttempt,
useLastConnectedAt
} from '../../../../src/transport/client-context'
useLastConnectedAt,
useReconnectAttempt
} from '../../../../src/transport/client-context-connection-metrics'
import {
classifyConnection,
verdictDisplayLabel
@@ -1080,7 +1079,6 @@ export default function SessionScreen() {
// the row) without any window-dim change. Tracking the measured width lets the
// refit hook re-fit the PTY on those resizes — see terminal-viewport-refit.ts.
const [terminalFrameWidth, setTerminalFrameWidth] = useState(0)
const activeSessionTab = sessionTabs.find((tab) => tab.id === activeSessionTabId) ?? null
const {
clearPendingLiveInputCommit,
@@ -2589,7 +2587,7 @@ export default function SessionScreen() {
// Why: viewport refits for layout changes outside the subscribe path
// (tab strip toggling, fold/unfold, rotation) live in a dedicated hook —
// see terminal-viewport-refit.ts for the full rationale.
useTerminalViewportRefit({
const { notifyTerminalFrameHeight, notifyKeyboardVisibility } = useTerminalViewportRefit({
activeHandleRef,
terminalRefs,
terminalFrameHeightRef,
@@ -2608,9 +2606,11 @@ export default function SessionScreen() {
useEffect(() => {
const onShow = (e: KeyboardEvent) => {
notifyKeyboardVisibility(true)
setKeyboardHeight(e.endCoordinates?.height ?? 0)
}
const onHide = () => {
notifyKeyboardVisibility(false)
setKeyboardHeight(0)
}
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'
@@ -2621,7 +2621,7 @@ export default function SessionScreen() {
showSub.remove()
hideSub.remove()
}
}, [])
}, [notifyKeyboardVisibility])
const scrollActiveTabIntoView = useCallback((tabId: string | null, animated: boolean) => {
if (!tabId) {
@@ -4831,10 +4831,12 @@ export default function SessionScreen() {
style={styles.terminalFrame}
onLayout={(e) => {
terminalFrameHeightRef.current = e.nativeEvent.layout.height
// Trigger a refit only when the width actually changes (sidebar
// resize, fold, rotation) — avoids churn on height-only changes.
// Why: notify height imperatively so dock settling re-fits the
// PTY without rerendering SessionScreen for layout callbacks.
const nextWidth = Math.round(e.nativeEvent.layout.width)
const nextHeight = Math.round(e.nativeEvent.layout.height)
setTerminalFrameWidth((prev) => (prev === nextWidth ? prev : nextWidth))
notifyTerminalFrameHeight(nextHeight)
}}
>
{terminals.map((terminal) => (
+2 -2
View File
@@ -34,11 +34,11 @@ import {
} from 'lucide-react-native'
import type { RpcClient } from '../../../src/transport/rpc-client'
import type { RpcSuccess } from '../../../src/transport/types'
import { useHostClient } from '../../../src/transport/client-context'
import {
useHostClient,
useLastConnectedAt,
useReconnectAttempt
} from '../../../src/transport/client-context'
} from '../../../src/transport/client-context-connection-metrics'
import { classifyConnection } from '../../../src/transport/connection-health'
import { StatusDot } from '../../../src/components/StatusDot'
import { ActionSheetModal } from '../../../src/components/ActionSheetModal'
+17 -93
View File
@@ -3,7 +3,6 @@ import { View, Text, StyleSheet, Pressable, FlatList, Alert } from 'react-native
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { useRouter, useFocusEffect } from 'expo-router'
import {
Monitor,
QrCode,
Settings,
ChevronRight,
@@ -35,12 +34,12 @@ import {
useForceReconnect,
usePrimeHosts
} from '../src/transport/client-context'
import { classifyConnection, verdictDisplayLabel } from '../src/transport/connection-health'
import { classifyConnection } from '../src/transport/connection-health'
import { subscribeToDesktopNotifications } from '../src/notifications/mobile-notifications'
import type { ConnectionState, HostProfile } from '../src/transport/types'
import { triggerMediumImpact } from '../src/platform/haptics'
import { OrcaLogo } from '../src/components/OrcaLogo'
import { StatusDot } from '../src/components/StatusDot'
import { MobileHostCard } from '../src/components/MobileHostCard'
import { TaskProviderLogo } from '../src/components/TaskProviderLogo'
import { TextInputModal } from '../src/components/TextInputModal'
import { ActionSheetModal, type ActionSheetAction } from '../src/components/ActionSheetModal'
@@ -324,6 +323,10 @@ export default function HomeScreen() {
// docs/mobile-shared-client-per-host.md.
const hostIds = useMemo(() => hosts.map((h) => h.id), [hosts])
const allClients = useAllHostClients(hostIds)
const hostPaths = useMemo(
() => Object.fromEntries(allClients.map(({ hostId, path }) => [hostId, path])),
[allClients]
)
const closeHostClient = useCloseHost()
const forceReconnectHost = useForceReconnect()
const primeHosts = usePrimeHosts()
@@ -824,7 +827,6 @@ export default function HomeScreen() {
const state = hostStates[item.id] ?? 'connecting'
const attempts = hostAttempts[item.id] ?? 0
const lastConnectedAt = hostLastConnected[item.id] ?? null
const connected = state === 'connected'
const info = worktreeInfo[item.id]
const verdict = classifyConnection({
state,
@@ -832,45 +834,21 @@ export default function HomeScreen() {
lastConnectedAt,
endpoint: item.endpoint
})
const isError =
verdict.kind === 'warning' ||
verdict.kind === 'unreachable' ||
verdict.kind === 'auth-failed'
return (
<Pressable
style={({ pressed }) => [styles.hostCard, pressed && styles.hostCardPressed]}
<MobileHostCard
host={item}
state={state}
verdict={verdict}
path={hostPaths[item.id] ?? 'lan'}
worktreeCounts={
info ? { total: info.totalWorktrees, active: info.activeCount } : undefined
}
onPress={() => router.push(`/h/${item.id}`)}
onLongPress={() => {
triggerMediumImpact()
setActionTarget(item)
}}
delayLongPress={400}
>
<View style={styles.hostIcon}>
<Monitor
size={20}
color={connected ? colors.textPrimary : colors.textSecondary}
/>
</View>
<View style={styles.hostMain}>
<Text
style={[styles.hostName, !connected && { color: colors.textSecondary }]}
numberOfLines={1}
>
{item.name}
</Text>
<View style={styles.hostMeta}>
<StatusDot state={state} verdict={verdict} />
<Text style={[styles.hostMetaItem, isError && { color: colors.statusRed }]}>
{verdictDisplayLabel(verdict)}
{connected && info
? ` · ${info.totalWorktrees} worktree${info.totalWorktrees !== 1 ? 's' : ''}${info.activeCount > 0 ? ` · ${info.activeCount} active` : ''}`
: ''}
</Text>
</View>
</View>
<ChevronRight size={16} color={colors.textMuted} />
</Pressable>
/>
)
}}
ListFooterComponent={
@@ -1081,16 +1059,16 @@ export default function HomeScreen() {
items.push({
label: 'Rename',
icon: Edit3,
closeBeforePress: true,
onPress: () => {
setActionTarget(null)
setRenameTarget(host)
}
})
items.push({
label: 'Remove',
destructive: true,
closeBeforePress: true,
onPress: () => {
setActionTarget(null)
setConfirmRemove(host)
}
})
@@ -1244,63 +1222,9 @@ const styles = StyleSheet.create({
},
/* ─── Host cards ─── */
hostCard: {
flexDirection: 'row',
alignItems: 'center',
paddingLeft: spacing.md,
paddingRight: spacing.md,
paddingVertical: 12,
borderRadius: radii.card,
backgroundColor: colors.bgPanel,
borderWidth: 1,
borderColor: colors.borderSubtle
},
hostCardPressed: {
backgroundColor: colors.bgRaised
},
hostIcon: {
width: 46,
height: 46,
borderRadius: 13,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.bgRaised,
marginRight: 14,
position: 'relative'
},
hostMain: {
flex: 1,
minWidth: 0,
marginRight: spacing.sm
},
hostName: {
color: colors.textPrimary,
fontSize: 15,
fontWeight: '600',
lineHeight: 20
},
hostMeta: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
marginTop: 3
},
hostMetaItem: {
fontSize: 12,
color: colors.textSecondary
},
hostMetaDot: {
width: 3,
height: 3,
borderRadius: 1.5,
backgroundColor: colors.textMuted,
marginHorizontal: 8
},
statusDot: {
width: 7,
height: 7,
borderRadius: 3.5
},
/* ─── Resume card ─── */
resumeCard: {
+15 -61
View File
@@ -5,12 +5,10 @@ import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'
import { ChevronLeft } from 'lucide-react-native'
import { resolvePairConfirmRouteState } from '../src/transport/pair-confirm-state'
import {
startPairingConnectionAttempt,
type PairingConnectionAttempt
} from '../src/transport/pairing-connection-attempt'
import { connect } from '../src/transport/rpc-client'
import { saveHost, getNextHostName } from '../src/transport/host-store'
import type { ConnectionLogEntry, RpcResponse } from '../src/transport/types'
startPreProfilePairing,
type PreProfilePairingAttempt
} from '../src/transport/pre-profile-pairing-coordinator'
import type { ConnectionLogEntry } from '../src/transport/types'
import { colors, spacing, radii, typography } from '../src/theme/mobile-theme'
import { ConnectionLog } from '../src/components/ConnectionLog'
@@ -35,7 +33,7 @@ export default function PairConfirmScreen() {
// batch fewer setState calls when entries arrive in bursts.
const logsRef = useRef<ConnectionLogEntry[]>([])
const mountedRef = useRef(true)
const activePairingAttemptRef = useRef<PairingConnectionAttempt | null>(null)
const activePairingAttemptRef = useRef<PreProfilePairingAttempt | null>(null)
const routeState = resolvePairConfirmRouteState(params.code)
const offer = routeState.offer
@@ -79,20 +77,12 @@ export default function PairConfirmScreen() {
setStatus('connecting')
logsRef.current = []
setLogs([])
let client: ReturnType<typeof connect> | null = null
activePairingAttemptRef.current?.dispose()
// Why: split the try/catch around the network call vs the local save
// so a Keychain or AsyncStorage failure doesn't masquerade as a
// "Cannot connect" error.
let response: RpcResponse
const attempt = startPairingConnectionAttempt({
const attempt = startPreProfilePairing({
offer,
timeoutMs: PAIRING_OVERALL_TIMEOUT_MS,
closeClient: () => client?.close()
})
activePairingAttemptRef.current = attempt
try {
client = connect(offer.endpoint, offer.deviceToken, offer.publicKeyB64, {
connectOptions: {
onLog: (entry) => {
if (!mountedRef.current || activePairingAttemptRef.current !== attempt) {
return
@@ -100,8 +90,11 @@ export default function PairConfirmScreen() {
logsRef.current = [...logsRef.current, entry]
setLogs(logsRef.current)
}
})
response = await client.sendRequest('status.get')
}
})
activePairingAttemptRef.current = attempt
try {
const { hostId } = await attempt.result
const attemptIsCurrent = activePairingAttemptRef.current === attempt
attempt.dispose()
if (activePairingAttemptRef.current === attempt) {
@@ -110,6 +103,7 @@ export default function PairConfirmScreen() {
if (!mountedRef.current || !attemptIsCurrent) {
return
}
router.replace(`/h/${hostId}`)
} catch (err) {
const timedOut = attempt.timedOut
const attemptIsCurrent = activePairingAttemptRef.current === attempt
@@ -125,47 +119,7 @@ export default function PairConfirmScreen() {
setErrorMessage(
timedOut
? `Couldn't connect within ${PAIRING_OVERALL_TIMEOUT_MS / 1000}s — see log below for where it stalled`
: 'Cannot connect — check that your computer is on the same network'
)
return
}
if (!response.ok) {
if (!mountedRef.current) {
return
}
setStatus('error')
setErrorMessage(
response.error.code === 'unauthorized'
? 'Authentication failed — token may be expired'
: `Server error: ${response.error.message}`
)
return
}
try {
const hostId = `host-${Date.now()}`
const hostName = await getNextHostName()
await saveHost({
id: hostId,
name: hostName,
endpoint: offer.endpoint,
deviceToken: offer.deviceToken,
publicKeyB64: offer.publicKeyB64,
lastConnected: Date.now()
})
if (!mountedRef.current) {
return
}
router.replace(`/h/${hostId}`)
} catch (err) {
if (!mountedRef.current) {
return
}
console.warn('[pair-confirm] save failed', err)
setStatus('error')
setErrorMessage(
`Pairing succeeded but couldn't save the host: ${err instanceof Error ? err.message : String(err)}`
: `Pairing failed: ${err instanceof Error ? err.message : String(err)}`
)
}
}
+15 -66
View File
@@ -14,12 +14,10 @@ import { useRouter } from 'expo-router'
import { ChevronLeft, Clipboard as ClipboardIcon, QrCode } from 'lucide-react-native'
import { decodePairingUrl, parsePairingCode } from '../src/transport/pairing'
import {
startPairingConnectionAttempt,
type PairingConnectionAttempt
} from '../src/transport/pairing-connection-attempt'
import { connect } from '../src/transport/rpc-client'
import { saveHost, getNextHostName } from '../src/transport/host-store'
import type { ConnectionLogEntry, PairingOffer, RpcResponse } from '../src/transport/types'
startPreProfilePairing,
type PreProfilePairingAttempt
} from '../src/transport/pre-profile-pairing-coordinator'
import type { ConnectionLogEntry, PairingOffer } from '../src/transport/types'
import { colors, spacing, radii, typography } from '../src/theme/mobile-theme'
import { TextInputModal } from '../src/components/TextInputModal'
import { ConnectionLog } from '../src/components/ConnectionLog'
@@ -54,7 +52,7 @@ export default function PairScanScreen() {
const logsRef = useRef<ConnectionLogEntry[]>([])
const processingRef = useRef(false)
const mountedRef = useRef(true)
const activePairingAttemptRef = useRef<PairingConnectionAttempt | null>(null)
const activePairingAttemptRef = useRef<PreProfilePairingAttempt | null>(null)
const setPairScanRootRef = useCallback((node: View | null): void => {
if (node !== null) {
@@ -123,21 +121,12 @@ export default function PairScanScreen() {
setStatus('connecting')
logsRef.current = []
setLogs([])
let client: ReturnType<typeof connect> | null = null
activePairingAttemptRef.current?.dispose()
// Why: split the try/catch around the network call vs the local save
// so a Keychain or AsyncStorage failure doesn't masquerade as a
// "Cannot connect — same network?" error. Pairing reached the
// desktop fine; the failure is local persistence.
let response: RpcResponse
const attempt = startPairingConnectionAttempt({
const attempt = startPreProfilePairing({
offer,
timeoutMs: PAIRING_OVERALL_TIMEOUT_MS,
closeClient: () => client?.close()
})
activePairingAttemptRef.current = attempt
try {
client = connect(offer.endpoint, offer.deviceToken, offer.publicKeyB64, {
connectOptions: {
onLog: (entry) => {
if (!mountedRef.current || activePairingAttemptRef.current !== attempt) {
return
@@ -145,8 +134,11 @@ export default function PairScanScreen() {
logsRef.current = [...logsRef.current, entry]
setLogs(logsRef.current)
}
})
response = await client.sendRequest('status.get')
}
})
activePairingAttemptRef.current = attempt
try {
const { hostId } = await attempt.result
const attemptIsCurrent = activePairingAttemptRef.current === attempt
attempt.dispose()
if (activePairingAttemptRef.current === attempt) {
@@ -155,6 +147,7 @@ export default function PairScanScreen() {
if (!mountedRef.current || !attemptIsCurrent) {
return
}
router.replace(`/h/${hostId}`)
} catch (err) {
const timedOut = attempt.timedOut
const attemptIsCurrent = activePairingAttemptRef.current === attempt
@@ -170,51 +163,7 @@ export default function PairScanScreen() {
setErrorMessage(
timedOut
? `Couldn't connect within ${PAIRING_OVERALL_TIMEOUT_MS / 1000}s — see log below for where it stalled`
: 'Cannot connect — check that your computer is on the same network'
)
processingRef.current = false
return
}
if (!response.ok) {
if (!mountedRef.current) {
return
}
if (response.error.code === 'unauthorized') {
setStatus('error')
setErrorMessage('Authentication failed — token may be expired')
processingRef.current = false
return
}
setStatus('error')
setErrorMessage(`Server error: ${response.error.message}`)
processingRef.current = false
return
}
try {
const hostId = `host-${Date.now()}`
const hostName = await getNextHostName()
await saveHost({
id: hostId,
name: hostName,
endpoint: offer.endpoint,
deviceToken: offer.deviceToken,
publicKeyB64: offer.publicKeyB64,
lastConnected: Date.now()
})
if (!mountedRef.current) {
return
}
router.replace(`/h/${hostId}`)
} catch (err) {
if (!mountedRef.current) {
return
}
console.warn('[pair] save failed', err)
setStatus('error')
setErrorMessage(
`Pairing succeeded but couldn't save the host: ${err instanceof Error ? err.message : String(err)}`
: `Pairing failed: ${err instanceof Error ? err.message : String(err)}`
)
processingRef.current = false
}
+2
View File
@@ -9,6 +9,7 @@
"ios": "expo run:ios",
"postinstall": "node scripts/build-terminal-webview-engine.mjs",
"test": "vitest run",
"typecheck": "tsc --noEmit",
"lint": "oxlint",
"format": "oxfmt --write .",
"format:check": "oxfmt --check .",
@@ -17,6 +18,7 @@
"start:emulator": "node scripts/start-emulator.mjs"
},
"dependencies": {
"@noble/hashes": "1.8.0",
"@orca/expo-two-way-audio": "file:./packages/expo-two-way-audio",
"@react-native-async-storage/async-storage": "^2.2.0",
"@xterm/addon-unicode11": "0.10.0-beta.285",
+9
View File
@@ -11,6 +11,9 @@ importers:
.:
dependencies:
'@noble/hashes':
specifier: 1.8.0
version: 1.8.0
'@orca/expo-two-way-audio':
specifier: file:./packages/expo-two-way-audio
version: file:packages/expo-two-way-audio(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
@@ -1878,6 +1881,10 @@ packages:
'@nicolo-ribaudo/chokidar-2@2.1.8-no-fsevents.3':
resolution: {integrity: sha512-s88O1aVtXftvp5bCPB7WnmXc5IwOZZ7YPuwNPt+GtOOXpPvad1LfbmjYv+qII7zP6RU2QGnqve27dnLycEnyEQ==}
'@noble/hashes@1.8.0':
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
engines: {node: ^14.21.3 || >=16}
'@orca/expo-two-way-audio@file:packages/expo-two-way-audio':
resolution: {directory: packages/expo-two-way-audio, type: directory}
peerDependencies:
@@ -8835,6 +8842,8 @@ snapshots:
'@nicolo-ribaudo/chokidar-2@2.1.8-no-fsevents.3':
optional: true
'@noble/hashes@1.8.0': {}
'@orca/expo-two-way-audio@file:packages/expo-two-way-audio(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)':
dependencies:
expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@6.0.3)
+43
View File
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest'
import { setCachedWorktrees, getCachedWorktrees } from './worktree-cache'
// Why: AC #8498 guarantees a reconnect refetch writes through the
// same cache path the host detail screen seeds from, so a reconnect can't
// serve a stale snapshot. This unit pins the write-through contract.
describe('worktree-cache write-through', () => {
it('returns the most-recently written snapshot, not a stale one', () => {
const hostId = 'host-write-through'
const stale = [{ worktreeId: 'a', name: 'stale' }]
const fresh = [
{ worktreeId: 'a', name: 'fresh' },
{ worktreeId: 'b', name: 'added' }
]
setCachedWorktrees(hostId, stale)
expect(getCachedWorktrees(hostId)).toEqual(stale)
// Why: simulates the reconnect refetch write-through — the fresh
// worktree.ps snapshot must fully replace the poisoned cache entry.
setCachedWorktrees(hostId, fresh)
expect(getCachedWorktrees(hostId)).toEqual(fresh)
expect(getCachedWorktrees(hostId)).not.toEqual(stale)
})
it('exposes a fresh snapshot to a remounting screen after reconnect', () => {
// Why: the host detail screen reads getCachedWorktrees(hostId)
// on (re)mount as its initialCache. A reconnect that writes
// through must therefore surface here instead of the pre-reconnect data.
const hostId = 'host-remount'
setCachedWorktrees(hostId, [{ worktreeId: 'old', name: 'pre-reconnect' }])
// Reconnect refetch lands a fresh snapshot and writes it through.
const reconnected = [
{ worktreeId: 'old', name: 'post-reconnect' },
{ worktreeId: 'new', name: 'now-visible' }
]
setCachedWorktrees(hostId, reconnected)
// A fresh screen mount reads the cache — must see the connected set.
expect(getCachedWorktrees(hostId)).toEqual(reconnected)
})
})
+32 -3
View File
@@ -1,4 +1,4 @@
import type { ReactNode } from 'react'
import { useRef, type ReactNode } from 'react'
import { ActivityIndicator, View, Text, Pressable, StyleSheet } from 'react-native'
import { Edit3, Trash2, type LucideIcon } from 'lucide-react-native'
import { colors, spacing, typography } from '../theme/mobile-theme'
@@ -13,6 +13,7 @@ export type ActionSheetAction = {
hint?: string
loading?: boolean
skipAutoClose?: boolean
closeBeforePress?: boolean
onPress: () => void
}
@@ -107,9 +108,37 @@ export function ActionSheetContent({ title, message, actions, onClose }: Content
}
export function ActionSheetModal({ visible, title, message, actions, onClose }: Props) {
const pendingActionRef = useRef<(() => void) | null>(null)
const sequencedActions = actions.map((action) =>
action.closeBeforePress
? {
...action,
onPress: () => {
pendingActionRef.current = action.onPress
}
}
: action
)
return (
<BottomDrawer visible={visible} onClose={onClose} dragContentToDismiss>
<ActionSheetContent title={title} message={message} actions={actions} onClose={onClose} />
<BottomDrawer
visible={visible}
onClose={onClose}
onAfterClose={() => {
// Why: iOS cannot present a second native modal until the action
// sheet's native window has fully unmounted.
const pendingAction = pendingActionRef.current
pendingActionRef.current = null
pendingAction?.()
}}
dragContentToDismiss
>
<ActionSheetContent
title={title}
message={message}
actions={sequencedActions}
onClose={onClose}
/>
</BottomDrawer>
)
}
+6 -1
View File
@@ -40,6 +40,7 @@ const TOP_SCROLL_EPSILON = 1
type Props = {
visible: boolean
onClose: () => void
onAfterClose?: () => void
children: ReactNode
dragContentToDismiss?: boolean
contentScrollable?: boolean
@@ -49,6 +50,7 @@ type Props = {
export function BottomDrawer({
visible,
onClose,
onAfterClose,
children,
dragContentToDismiss = true,
contentScrollable = true,
@@ -73,7 +75,10 @@ export function BottomDrawer({
<MountedBottomDrawer
visible={visible}
onClose={onClose}
onHidden={() => setMounted(false)}
onHidden={() => {
setMounted(false)
onAfterClose?.()
}}
dragContentToDismiss={dragContentToDismiss}
contentScrollable={contentScrollable}
zIndex={zIndex}
+91
View File
@@ -0,0 +1,91 @@
import { ChevronRight, Monitor } from 'lucide-react-native'
import { Pressable, StyleSheet, Text, View } from 'react-native'
import type { ConnectionVerdict } from '../transport/connection-health'
import { verdictDisplayLabel } from '../transport/connection-health'
import { mobileConnectionPathLabel } from '../transport/mobile-connection-path-label'
import type { MobileConnectionPath } from '../transport/stable-logical-rpc-client'
import type { ConnectionState, HostProfile } from '../transport/types'
import { colors, radii, spacing } from '../theme/mobile-theme'
import { StatusDot } from './StatusDot'
export function MobileHostCard(props: {
host: HostProfile
state: ConnectionState
verdict: ConnectionVerdict
path: MobileConnectionPath
worktreeCounts?: { total: number; active: number }
onPress: () => void
onLongPress: () => void
}) {
const connected = props.state === 'connected'
const isError = ['warning', 'unreachable', 'auth-failed'].includes(props.verdict.kind)
return (
<Pressable
style={({ pressed }) => [styles.card, pressed && styles.cardPressed]}
onPress={props.onPress}
onLongPress={props.onLongPress}
delayLongPress={400}
>
<View style={styles.icon}>
<Monitor size={20} color={connected ? colors.textPrimary : colors.textSecondary} />
</View>
<View style={styles.main}>
<Text
style={[styles.name, !connected && { color: colors.textSecondary }]}
numberOfLines={1}
>
{props.host.name}
</Text>
<View style={styles.meta}>
<StatusDot state={props.state} verdict={props.verdict} />
<Text style={[styles.metaText, isError && { color: colors.statusRed }]}>
{verdictDisplayLabel(props.verdict)}
{connected ? ` · ${mobileConnectionPathLabel(props.path)}` : ''}
{connected && props.worktreeCounts
? ` · ${props.worktreeCounts.total} worktree${props.worktreeCounts.total !== 1 ? 's' : ''}${props.worktreeCounts.active > 0 ? ` · ${props.worktreeCounts.active} active` : ''}`
: ''}
</Text>
</View>
{props.verdict.kind === 'unreachable' && !props.host.relay ? (
<Text style={styles.discoveryHint} numberOfLines={2}>
Update desktop Orca and sign in to connect from anywhere
</Text>
) : null}
</View>
<ChevronRight size={16} color={colors.textMuted} />
</Pressable>
)
}
const styles = StyleSheet.create({
card: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingVertical: 12,
borderRadius: radii.card,
backgroundColor: colors.bgPanel,
borderWidth: 1,
borderColor: colors.borderSubtle
},
cardPressed: { backgroundColor: colors.bgRaised },
icon: {
width: 46,
height: 46,
borderRadius: 13,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.bgRaised,
marginRight: 14
},
main: { flex: 1, minWidth: 0, marginRight: spacing.sm },
name: { color: colors.textPrimary, fontSize: 15, fontWeight: '600', lineHeight: 20 },
meta: { flexDirection: 'row', alignItems: 'center', gap: 6, marginTop: 3 },
metaText: { fontSize: 12, color: colors.textSecondary },
discoveryHint: {
marginTop: spacing.xs,
fontSize: 11,
lineHeight: 15,
color: colors.textMuted
}
})
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcFailure, RpcResponse, RpcSuccess } from '../transport/types'
import {
@@ -67,6 +68,26 @@ describe('rpc wrappers', () => {
expect(client.calls[0]).toEqual({ method: 'speech.models.list', params: null })
})
it('retries the idempotent setup read once after logical-client cutover', async () => {
const setup: MobileSpeechSetup = { enabled: false, selectedModelId: '', models: [] }
const sendRequest = vi
.fn()
.mockRejectedValueOnce(new LogicalClientCutoverError())
.mockResolvedValueOnce(ok(setup))
await expect(fetchDictationSetup({ sendRequest })).resolves.toEqual(setup)
expect(sendRequest).toHaveBeenCalledTimes(2)
expect(sendRequest).toHaveBeenNthCalledWith(1, 'speech.models.list', null)
expect(sendRequest).toHaveBeenNthCalledWith(2, 'speech.models.list', null)
})
it('does not retry unrelated setup-read failures', async () => {
const sendRequest = vi.fn().mockRejectedValue(new Error('offline'))
await expect(fetchDictationSetup({ sendRequest })).rejects.toThrow('offline')
expect(sendRequest).toHaveBeenCalledOnce()
})
it('starts a download', async () => {
const client = clientWith([ok({ started: true })])
await downloadDictationModel(client, 'm1')
+15 -1
View File
@@ -1,5 +1,6 @@
import type { RuntimeSpeechSetupState } from '../../../src/shared/runtime-types'
import type { RpcClient } from '../transport/rpc-client'
import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
import type { RpcSuccess } from '../transport/types'
export type MobileSpeechSetup = RuntimeSpeechSetupState
@@ -31,7 +32,7 @@ export function isDictationSetupRequiredError(message: string): boolean {
export async function fetchDictationSetup(
client: Pick<RpcClient, 'sendRequest'>
): Promise<MobileSpeechSetup> {
const response = await client.sendRequest('speech.models.list', null)
const response = await fetchDictationSetupResponse(client)
if (!response.ok) {
if (isLegacyDesktopSpeechSetupError(response.error)) {
throw new Error(LEGACY_DESKTOP_SPEECH_SETUP_MESSAGE)
@@ -41,6 +42,19 @@ export async function fetchDictationSetup(
return (response as RpcSuccess).result as MobileSpeechSetup
}
async function fetchDictationSetupResponse(client: Pick<RpcClient, 'sendRequest'>) {
try {
return await client.sendRequest('speech.models.list', null)
} catch (error) {
if (!(error instanceof LogicalClientCutoverError)) {
throw error
}
// Why: this read can safely repeat on the authenticated replacement; mutation
// RPCs must still surface cutover so callers never replay unknown commits.
return client.sendRequest('speech.models.list', null)
}
}
export async function downloadDictationModel(
client: Pick<RpcClient, 'sendRequest'>,
modelId: string
@@ -4,6 +4,7 @@ import {
setScheduledNotificationsMaxForTests,
subscribeToDesktopNotifications
} from './mobile-notifications'
import AsyncStorage from '@react-native-async-storage/async-storage'
import type { RpcClient } from '../transport/rpc-client'
import { loadPushNotificationsEnabled } from '../storage/preferences'
@@ -20,6 +21,16 @@ vi.mock('react-native', () => ({
Platform: { OS: 'ios' }
}))
// Why: mobile-notifications now persists the catch-up watermark to
// AsyncStorage. The package isn't resolvable in the node test env (other
// mobile tests mock it the same way), so we provide a no-op mock.
vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
getItem: vi.fn(async () => null),
setItem: vi.fn(async () => undefined)
}
}))
vi.mock('../storage/preferences', () => ({
loadPushNotificationsEnabled: vi.fn()
}))
@@ -316,3 +327,288 @@ describe('subscribeToDesktopNotifications', () => {
}
})
})
// Why: #8129 catch-up. On a reconnect the live stream re-emits `ready`; the
// client must fetch missed notifications from its watermark and push exactly
// the ones it had not yet delivered — never re-pushing an already-delivered id.
describe('subscribeToDesktopNotifications — reconnect catch-up', () => {
const AsyncStorageMock = vi.mocked(AsyncStorage)
beforeEach(() => {
vi.clearAllMocks()
AsyncStorageMock.getItem.mockResolvedValue(null)
vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined)
})
function flushAsync(): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, 10)
})
}
function makeClient() {
let onData: ((data: unknown) => void) | null = null
const sentRequests: { method: string; params: unknown }[] = []
const client = {
subscribe: vi.fn((_method: string, _params: unknown, cb: (data: unknown) => void) => {
onData = cb
return vi.fn()
}),
getState: vi.fn(() => 'connected'),
sendRequest: vi.fn(
async (method: string, _params: unknown = {}) =>
({
ok: true,
result: method === 'notifications.getMissedSince' ? { notifications: [] } : undefined
}) as never
)
}
// Why: onData is captured live via a getter (not destructured) because the
// subscribe mock assigns it asynchronously as a side effect of
// subscribeToDesktopNotifications calling client.subscribe.
return {
client: client as unknown as RpcClient,
get onData() {
return onData
},
sentRequests
}
}
it('does not fetch missed notifications on the first (cold-open) ready', async () => {
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'granted',
canAskAgain: true
} as never)
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('scheduled-1')
const sub = makeClient()
subscribeToDesktopNotifications(sub.client, 'host-1')
// First ready = cold open.
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' })
await flushAsync()
expect(sub.client.sendRequest).not.toHaveBeenCalledWith(
'notifications.getMissedSince',
expect.anything()
)
})
it('fetches only notifications after the delivered watermark (idempotent catch-up)', async () => {
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'granted',
canAskAgain: true
} as never)
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('scheduled-1')
const sub = makeClient()
// The desktop honours the watermark: only seq 10 (agent:missed) is returned
// because seq 11 (agent:dup) was already delivered on the live stream and
// advanced lastDeliveredSeq to 11. So the replay never re-includes it.
sub.client.sendRequest = vi.fn(async (method: string) => {
if (method === 'notifications.getMissedSince') {
return {
ok: true,
result: {
notifications: [
{
type: 'notification',
title: 'missed',
body: 'b',
notificationId: 'agent:missed',
notificationSeq: 10
}
]
}
} as never
}
return { ok: true, result: undefined } as never
})
subscribeToDesktopNotifications(sub.client, 'host-1')
// First ready = cold open (no fetch).
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' })
await flushAsync()
// Live stream already delivered agent:dup (seq 11) before reap.
sub.onData?.({
type: 'notification',
title: 'dup',
body: 'b',
notificationId: 'agent:dup',
notificationSeq: 11
})
await flushAsync()
// Reconnect ready → fetchMissed sends the watermark (11).
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' })
await flushAsync()
await flushAsync()
// The watermark passed to getMissedSince is the delivered seq.
const missedCall = vi
.mocked(sub.client.sendRequest)
.mock.calls.find((c: unknown[]) => c[0] === 'notifications.getMissedSince')
expect(missedCall?.[1]).toEqual({ lastSeenSeq: 11 })
// Only agent:missed was pushed; agent:dup appears exactly once (live only).
const scheduledIds = vi
.mocked(Notifications.scheduleNotificationAsync)
.mock.calls.map(
(call) =>
(call[0] as { content: { data: { notificationId: string } } }).content.data.notificationId
)
expect(scheduledIds).toEqual(['agent:dup', 'agent:missed'])
expect(scheduledIds.filter((id) => id === 'agent:dup')).toHaveLength(1)
})
it('drops an already-seen id if a replay re-includes it (defense-in-depth)', async () => {
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'granted',
canAskAgain: true
} as never)
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('s')
const sub = makeClient()
// Simulate the bounded-buffer edge: the desktop returns seq 11 again
// (already delivered live) alongside a new seq 12.
sub.client.sendRequest = vi.fn(async (method: string) => {
if (method === 'notifications.getMissedSince') {
return {
ok: true,
result: {
notifications: [
{
type: 'notification',
title: 'dup',
body: 'b',
notificationId: 'agent:dup',
notificationSeq: 11
},
{
type: 'notification',
title: 'new',
body: 'b',
notificationId: 'agent:new',
notificationSeq: 12
}
]
}
} as never
}
return { ok: true, result: undefined } as never
})
subscribeToDesktopNotifications(sub.client, 'host-1')
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' })
await flushAsync()
// Live stream delivered agent:dup (seq 11) before reap.
sub.onData?.({
type: 'notification',
title: 'dup',
body: 'b',
notificationId: 'agent:dup',
notificationSeq: 11
})
await flushAsync()
// Reconnect replay re-includes seq 11 (must be dropped) + new seq 12.
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' })
await flushAsync()
await flushAsync()
const scheduledIds = vi
.mocked(Notifications.scheduleNotificationAsync)
.mock.calls.map(
(call) =>
(call[0] as { content: { data: { notificationId: string } } }).content.data.notificationId
)
expect(scheduledIds).toEqual(['agent:dup', 'agent:new'])
expect(scheduledIds.filter((id) => id === 'agent:dup')).toHaveLength(1)
})
it('persists the highest delivered seq so a later reconnect resumes from it', async () => {
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'granted',
canAskAgain: true
} as never)
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('s')
const sub = makeClient()
subscribeToDesktopNotifications(sub.client, 'host-1')
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' })
await flushAsync()
// Live stream delivers seq 5.
sub.onData?.({
type: 'notification',
title: 't',
body: 'b',
notificationId: 'agent:live',
notificationSeq: 5
})
await flushAsync()
expect(AsyncStorageMock.setItem).toHaveBeenCalledWith(
'orca:mobileNotificationsLastSeq:host-1',
'5'
)
})
// Why: a replay-ONLY delivery (nothing arrived live first) must still advance
// and persist the watermark. This is the exact case the seq/notificationSeq
// field mismatch broke — the desktop replay path returns `notificationSeq`
// (matching the live fan-out), so the client watermark moves and the next
// reconnect resumes from it instead of re-fetching from 0.
it('advances + persists the watermark from a replay-only delivery (#8129 field-mismatch regression)', async () => {
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'granted',
canAskAgain: true
} as never)
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('s')
const sub = makeClient()
// Desktop replay returns events keyed by notificationSeq (the fixed shape).
sub.client.sendRequest = vi.fn(async (method: string) => {
if (method === 'notifications.getMissedSince') {
return {
ok: true,
result: {
notifications: [
{
type: 'notification',
title: 'missed',
body: 'b',
notificationId: 'agent:missed',
notificationSeq: 8
}
]
}
} as never
}
return { ok: true, result: undefined } as never
})
subscribeToDesktopNotifications(sub.client, 'host-1')
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' })
await flushAsync()
// First reconnect → replay delivers seq 8 (no prior live delivery).
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' })
await flushAsync()
await flushAsync()
// Watermark advanced to the replayed seq and was persisted.
expect(AsyncStorageMock.setItem).toHaveBeenCalledWith(
'orca:mobileNotificationsLastSeq:host-1',
'8'
)
// Second reconnect resumes from the advanced watermark, not 0.
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' })
await flushAsync()
const missedCalls = vi
.mocked(sub.client.sendRequest)
.mock.calls.filter((c: unknown[]) => c[0] === 'notifications.getMissedSince')
expect(missedCalls.at(-1)?.[1]).toEqual({ lastSeenSeq: 8 })
})
})
@@ -3,6 +3,12 @@ import { Platform } from 'react-native'
import type { RpcClient } from '../transport/rpc-client'
import { loadPushNotificationsEnabled } from '../storage/preferences'
import { buildLocalNotificationData, type DesktopNotificationSource } from './notification-routing'
import {
createSeenNotificationGuard,
loadLastSeenSeq,
saveLastSeenSeq,
seenKeyForEvent
} from './notification-reconnect-catchup'
type NotificationEvent = {
type: 'notification'
@@ -11,11 +17,16 @@ type NotificationEvent = {
body: string
worktreeId?: string
notificationId?: string
// Mirrors the desktop-assigned MobileNotificationEvent.notificationSeq used
// for reconnect catch-up (#8129). Optional because older runtimes / non-
// replay events may omit it.
notificationSeq?: number
}
type DismissNotificationEvent = {
type: 'dismiss'
notificationId: string
notificationSeq?: number
}
type SubscribeResult = {
@@ -223,18 +234,105 @@ async function dismissLocalNotification(
// Why: each host connection gets its own notification subscription. When the
// connection drops, the unsubscribe function cleans up the streaming RPC.
// On reconnect the same subscribe stream is re-established by the RPC client;
// we use its `ready` event to trigger catch-up (#8129): fetch notifications
// dispatched while the socket was reaped, watermarked by the last seq we
// already delivered so the desktop never re-sends an already-pushed one.
// Returns an unsubscribe function.
export function subscribeToDesktopNotifications(client: RpcClient, hostId: string): () => void {
configureNotificationChannel()
let subscriptionId: string | null = null
let disposed = false
// Highest seq delivered on the live stream or replay for this connection.
// Persisted per-host so a cold app start still resumes from the right cut.
let lastDeliveredSeq = 0
// Why: per-connection dedup guard applied ONLY to the replay path
// (fetchMissed), never the live stream. The desktop already guarantees the
// replay cannot contain an event with seq <= lastDeliveredSeq (both live and
// replay advance the same watermark), so live + replay never overlap there.
// This set is defense-in-depth: if the desktop's bounded buffer evicted an
// old entry and a reconnect re-fetches across a boundary, an id delivered in
// the same connection isn't pushed twice. Bounded (RECENTLY_SEEN_CAP) so a
// long-lived session can't grow without limit.
const seenReplay = createSeenNotificationGuard()
function deliverLive(
type: 'notification' | 'dismiss',
event: NotificationEvent | DismissNotificationEvent
): Promise<void> {
if (event.notificationSeq != null && event.notificationSeq > lastDeliveredSeq) {
lastDeliveredSeq = event.notificationSeq
void saveLastSeenSeq(hostId, lastDeliveredSeq)
}
// Why (#8129 dedup): mark the event seen on EVERY delivery path (live AND
// replay) so a replay that re-includes an id already pushed live in this
// connection is dropped instead of double-pushed. fetchMissed also
// pre-checks seenReplay, but without this the live path never populated it.
const key = seenKeyForEvent(event)
if (key) {
seenReplay.add(key)
}
if (type === 'notification') {
return showLocalNotification(event as NotificationEvent, hostId)
}
return dismissLocalNotification(event as DismissNotificationEvent, hostId)
}
// Why: on a reconnect `ready` the desktop has already dispatched whatever we
// missed; ask for it from our persisted watermark. Because the desktop cuts
// by seq > lastSeenSeq this is idempotent — we only ever get events we have
// not delivered before. The seenReplay guard is a second layer so a replay
// that somehow re-includes an id already delivered this connection is
// dropped instead of double-pushed.
async function fetchMissed(): Promise<void> {
if (disposed) {
return
}
const missed = await client
.sendRequest('notifications.getMissedSince', { lastSeenSeq: lastDeliveredSeq })
.then((response) => {
if (!response.ok) {
return []
}
const result = response.result as { notifications?: unknown[] } | undefined
return Array.isArray(result?.notifications) ? result.notifications : []
})
.catch(() => [])
for (const raw of missed) {
const event = raw as NotificationEvent | DismissNotificationEvent
const key = seenKeyForEvent(event)
if (key && seenReplay.has(key)) {
continue
}
if (key) {
seenReplay.add(key)
}
if (event.type === 'notification') {
await deliverLive('notification', event)
} else if (event.type === 'dismiss') {
await deliverLive('dismiss', event)
}
}
}
// Why: lazily seed the watermark from durable storage on first use so we
// don't block subscribe() on an AsyncStorage read. The first `ready` (cold
// open) does NOT need catch-up — the live stream starts fresh; only
// subsequent reconnect `ready` events fetch missed notifications.
let watermarkLoaded = false
void loadLastSeenSeq(hostId).then((seq) => {
lastDeliveredSeq = Math.max(lastDeliveredSeq, seq)
watermarkLoaded = true
})
function unsubscribeServer(id: string) {
if (client.getState() === 'connected') {
client.sendRequest('notifications.unsubscribe', { subscriptionId: id }).catch(() => {})
}
}
let reconnectReadyCount = 0
const unsubscribeStream = client.subscribe('notifications.subscribe', {}, (data: unknown) => {
const event = data as
| NotificationEvent
@@ -243,9 +341,18 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin
| { type: 'end' }
if (event.type === 'ready') {
subscriptionId = (event as SubscribeResult).subscriptionId
reconnectReadyCount += 1
if (disposed) {
unsubscribeServer(subscriptionId)
unsubscribeStream()
return
}
// Why: first ready is the cold-open live stream — no catch-up needed.
// Every later ready is a reconnect; fetch what we missed from the
// watermark. Guard on watermarkLoaded so a fast reconnect doesn't
// fetch from a stale 0 watermark (which would re-push everything).
if (reconnectReadyCount > 1 && watermarkLoaded) {
void fetchMissed()
}
return
}
@@ -259,9 +366,9 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin
return
}
if (event.type === 'notification') {
void showLocalNotification(event as NotificationEvent, hostId)
void deliverLive('notification', event as NotificationEvent)
} else if (event.type === 'dismiss') {
void dismissLocalNotification(event as DismissNotificationEvent, hostId)
void deliverLive('dismiss', event as DismissNotificationEvent)
}
})
@@ -0,0 +1,97 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
// Why: the reconnect catch-up watermark + dedup helpers for #8129, extracted
// from mobile-notifications.ts so that file stays under its max-lines budget.
// The highest desktop notification seq this device has delivered is persisted
// per-host so it survives app restarts. On reconnect we send it to
// notifications.getMissedSince as the catch-up watermark — the desktop then
// returns only notifications dispatched after it, so we never re-push a
// notification we already delivered. The in-memory seen-set is a second guard
// against double-delivery for events that arrive on both the live stream and a
// replay (e.g. a brief liveness spell before a reap).
const LAST_SEQ_STORAGE_KEY_PREFIX = 'orca:mobileNotificationsLastSeq:'
function lastSeqStorageKey(hostId: string): string {
return LAST_SEQ_STORAGE_KEY_PREFIX + encodeURIComponent(hostId)
}
export async function loadLastSeenSeq(hostId: string): Promise<number> {
try {
const raw = await AsyncStorage.getItem(lastSeqStorageKey(hostId))
const parsed = raw == null ? 0 : Number(raw)
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0
} catch {
return 0
}
}
export async function saveLastSeenSeq(hostId: string, seq: number): Promise<void> {
if (!Number.isFinite(seq) || seq <= 0) {
return
}
try {
await AsyncStorage.setItem(lastSeqStorageKey(hostId), String(seq))
} catch {
// Why: persisting the watermark is best-effort. If it fails (or lags), the
// stored value stays BELOW what we delivered, so a later cold start can
// re-fetch — and, once the in-memory seen-set is gone, re-show — an already
// delivered notification. That's the accepted at-least-once trade-off;
// within a live session the in-memory watermark is authoritative, so only
// post-restart reconnects are affected.
}
}
// Why: bounded in-memory dedup window for notificationIds/dismiss ids observed
// on the current connection. The desktop already dedupes by seq on replay, but
// a socket that flickers background→foreground→background can deliver an event
// on the live stream and again in a replay; the seen-set guarantees each
// notificationId maps to at most one local push for the connection lifetime.
// Bounded so a long-lived session can't grow without limit — a 2x superset of
// the desktop's 256-entry replay buffer and the 256 scheduled-notification cap.
const RECENTLY_SEEN_CAP = 512
export function createSeenNotificationGuard(): {
has: (id: string) => boolean
add: (id: string) => void
} {
const seen = new Set<string>()
return {
has(id: string): boolean {
return seen.has(id)
},
add(id: string): void {
seen.add(id)
if (seen.size > RECENTLY_SEEN_CAP) {
// Why: insertion order; the oldest entries are first. Drop one to stay
// bounded without disturbing the more-recently-relevant keys.
const first = seen.values().next().value
if (first !== undefined) {
seen.delete(first)
}
}
}
}
}
// Why: key for the replay dedup guard. Uses notificationId when present, but
// disambiguates by seq so a legitimate live re-delivery of the same id at a
// NEW seq (content refresh, allowed by the existing behaviour) is NOT treated
// as a duplicate, while a replay re-returning the SAME id+seq already delivered
// live is suppressed. Replay events always carry a seq (the desktop assigns
// one), so the guard is effective on the reconnect path.
export function seenKeyForEvent(event: {
notificationId?: string
notificationSeq?: number
}): string | null {
const id = event.notificationId
if (id != null && event.notificationSeq != null) {
return `id:${id}#${event.notificationSeq}`
}
if (id != null) {
return `id:${id}`
}
if (event.notificationSeq != null) {
return `seq:${event.notificationSeq}`
}
return null
}
@@ -8,7 +8,8 @@ import {
getMobilePrCreateBlockMessage,
mobileRepoSelectorFromWorktreeId,
resolveMobilePrPrefill,
shouldPushBeforeMobilePrCreate
shouldPushBeforeMobilePrCreate,
type MobilePrPrefill
} from './mobile-pr-create'
function ok(result: unknown): RpcSuccess {
@@ -122,6 +123,19 @@ describe('mobile create form gating parity', () => {
expect(mobileAllowsComposer).toBe(desktopAllowsComposer)
})
it('stays safely blocked for a reason added by a newer desktop contract', () => {
expect(
getMobilePrCreateBlockMessage({
provider: 'github',
base: 'main',
title: 'Add feature',
body: '',
canCreate: false,
blockedReason: 'future_desktop_reason' as unknown as MobilePrPrefill['blockedReason']
})
).toBe('This branch is not ready for a pull request yet.')
})
})
describe('resolveMobilePrPrefill', () => {
@@ -72,5 +72,9 @@ export function getMobilePrCreateBlockMessage(prefill: MobilePrPrefill): string
case null:
case undefined:
return `This branch is not ready for a ${copy.reviewLabel} yet.`
default:
// Why: desktop can add blocked reasons before a long-lived mobile branch
// catches up; remain safely blocked while preserving merge-ref typechecks.
return `This branch is not ready for a ${copy.reviewLabel} yet.`
}
}
@@ -1,5 +1,7 @@
import type { RpcResponse } from '../transport/types'
export type TerminalUpdateViewportCapability = 'unknown' | 'supported' | 'unsupported'
export type TerminalViewportRefitTargetState = {
activeHandle: string | null
expectedHandle: string
@@ -24,6 +26,70 @@ export function isTerminalUpdateViewportApplied(response: RpcResponse): boolean
return (response.result as { applied?: unknown }).applied === true
}
export function resolveTerminalUpdateViewportCapability(
response: RpcResponse
): TerminalUpdateViewportCapability {
if (response.ok) {
return 'supported'
}
return response.error.code === 'method_not_found' ? 'unsupported' : 'unknown'
}
// Why: defer height refits while typing, then coalesce every skipped layout
// change into one correction after the keyboard closes.
export type TerminalFrameHeightRefitState = {
frameHeight: number
keyboardVisible: boolean
pending: boolean
}
export type TerminalFrameHeightRefitEvent =
| { type: 'frame-height'; height: number }
| { type: 'keyboard-visibility'; visible: boolean }
| { type: 'refit-committed' }
export function reduceTerminalFrameHeightRefit(
state: TerminalFrameHeightRefitState,
event: TerminalFrameHeightRefitEvent
): { state: TerminalFrameHeightRefitState; shouldRefit: boolean } {
if (event.type === 'refit-committed') {
// Why: the debounced height refit is firing. The keyboard can reopen during
// the debounce window, so re-check here and re-defer rather than reflow the
// PTY mid-keystroke; it runs on the next keyboard close.
if (state.keyboardVisible) {
return { state: { ...state, pending: true }, shouldRefit: false }
}
return { state: { ...state, pending: false }, shouldRefit: true }
}
if (event.type === 'keyboard-visibility') {
if (event.visible === state.keyboardVisible) {
return { state, shouldRefit: false }
}
if (event.visible) {
return { state: { ...state, keyboardVisible: true }, shouldRefit: false }
}
return {
state: { ...state, keyboardVisible: false, pending: false },
shouldRefit: state.pending
}
}
if (event.height === state.frameHeight) {
return { state, shouldRefit: false }
}
if (state.keyboardVisible) {
return {
state: { ...state, frameHeight: event.height, pending: true },
shouldRefit: false
}
}
return {
state: { ...state, frameHeight: event.height, pending: false },
shouldRefit: true
}
}
export function isTerminalViewportRefitTargetCurrent(
state: TerminalViewportRefitTargetState
): boolean {
@@ -4,7 +4,12 @@ import type { RpcResponse } from '../transport/types'
import {
isTerminalUpdateViewportApplied,
isTerminalUpdateViewportUpdated,
isTerminalViewportRefitTargetCurrent
isTerminalViewportRefitTargetCurrent,
reduceTerminalFrameHeightRefit,
resolveTerminalUpdateViewportCapability,
type TerminalFrameHeightRefitEvent,
type TerminalFrameHeightRefitState,
type TerminalUpdateViewportCapability
} from './terminal-viewport-refit-state'
const hookSource = readFileSync(new URL('./terminal-viewport-refit.ts', import.meta.url), 'utf8')
@@ -48,11 +53,110 @@ describe('terminal viewport refit', () => {
expect(textScaleEffect).toContain('[textScale, viewportMeasuredRef, scheduleViewportRefit]')
})
it('coalesces keyboard-visible frame-height churn into one refit after close', () => {
let state: TerminalFrameHeightRefitState = {
frameHeight: 600,
keyboardVisible: false,
pending: false
}
let refitCount = 0
const dispatch = (event: TerminalFrameHeightRefitEvent) => {
const transition = reduceTerminalFrameHeightRefit(state, event)
state = transition.state
refitCount += Number(transition.shouldRefit)
}
dispatch({ type: 'keyboard-visibility', visible: true })
dispatch({ type: 'frame-height', height: 540 })
dispatch({ type: 'frame-height', height: 520 })
dispatch({ type: 'frame-height', height: 520 })
expect(refitCount).toBe(0)
expect(state.pending).toBe(true)
dispatch({ type: 'keyboard-visibility', visible: false })
dispatch({ type: 'keyboard-visibility', visible: false })
dispatch({ type: 'frame-height', height: 520 })
expect(refitCount).toBe(1)
expect(state.pending).toBe(false)
dispatch({ type: 'frame-height', height: 500 })
expect(refitCount).toBe(2)
})
it('routes imperative height notifications through the keyboard-aware reducer', () => {
const start = hookSource.indexOf('const notifyFrameHeightRefitEvent = useCallback(')
expect(start).toBeGreaterThanOrEqual(0)
const notifier = hookSource.slice(start, start + 1_300)
expect(notifier).toContain('reduceTerminalFrameHeightRefit(')
expect(notifier).toContain("{ type: 'frame-height', height }")
expect(notifier).toContain("{ type: 'keyboard-visibility', visible }")
expect(notifier).toContain('viewportMeasuredRef.current = false')
expect(notifier).toContain('scheduleViewportRefit({ heightOriginated: true })')
})
it('re-defers a height refit if the keyboard reopens before the debounce fires', () => {
// Settle while the keyboard is up -> deferred (pending), no refit.
let r = reduceTerminalFrameHeightRefit(
{ frameHeight: 600, keyboardVisible: true, pending: false },
{ type: 'frame-height', height: 520 }
)
expect(r.shouldRefit).toBe(false)
expect(r.state.pending).toBe(true)
// Keyboard closes -> refit scheduled (the hook arms a 150ms timer here).
r = reduceTerminalFrameHeightRefit(r.state, { type: 'keyboard-visibility', visible: false })
expect(r.shouldRefit).toBe(true)
// Keyboard reopens inside the debounce window, then the timer fires:
// the committed refit must NOT reflow while typing, and stays owed.
r = reduceTerminalFrameHeightRefit(r.state, { type: 'keyboard-visibility', visible: true })
const committed = reduceTerminalFrameHeightRefit(r.state, { type: 'refit-committed' })
expect(committed.shouldRefit).toBe(false)
expect(committed.state.pending).toBe(true)
// Keyboard closes again -> rescheduled -> now the committed refit runs.
const rescheduled = reduceTerminalFrameHeightRefit(committed.state, {
type: 'keyboard-visibility',
visible: false
})
expect(rescheduled.shouldRefit).toBe(true)
const ran = reduceTerminalFrameHeightRefit(rescheduled.state, { type: 'refit-committed' })
expect(ran.shouldRefit).toBe(true)
expect(ran.state.pending).toBe(false)
})
it('re-checks the keyboard at fire time only for height-originated refits', () => {
const start = hookSource.indexOf('refitTimerRef.current = setTimeout(')
expect(start).toBeGreaterThanOrEqual(0)
const timerBody = hookSource.slice(start, start + 900)
// The height flag scopes the guard; forced/width refits stay unguarded.
expect(timerBody).toContain('if (heightOriginatedRefitRef.current)')
expect(timerBody).toContain("type: 'refit-committed'")
expect(timerBody).toContain('if (!decision.shouldRefit)')
})
it('is wired into the session screen', () => {
expect(sessionSource).toContain('useTerminalViewportRefit({')
expect(sessionSource).toContain('tabStripVisible: terminals.length > 1')
expect(sessionSource).toContain('textScale: terminalTextScale')
expect(sessionSource).toContain('connState,')
expect(sessionSource).toContain('notifyTerminalFrameHeight(nextHeight)')
expect(sessionSource).toContain('notifyKeyboardVisibility(true)')
expect(sessionSource).toContain('notifyKeyboardVisibility(false)')
})
it('does not rerender SessionScreen for frame-height-only layout changes', () => {
// The imperative notifier keeps a dock-settling burst off React's render path.
expect(sessionSource).not.toContain('setTerminalFrameHeight')
expect(sessionSource).not.toContain('const [terminalFrameHeight,')
})
it('defers height-only window resizes while the keyboard is visible', () => {
const start = hookSource.indexOf('const prevWindowDimsRef')
const windowEffect = hookSource.slice(start, start + 1_100)
expect(windowEffect).toContain(
'prev.width === windowWidth && frameHeightRefitStateRef.current.keyboardVisible'
)
})
it('forces a refit on iOS foreground and connection recovery', () => {
@@ -93,6 +197,42 @@ describe('terminal viewport refit', () => {
expect(resubscribeIndex).toBeGreaterThan(rpcIndex)
})
it('falls back to legacy resubscribe when an older desktop lacks updateViewport', () => {
const unsupported = {
id: 'old-host',
ok: false,
error: { code: 'method_not_found', message: 'Unknown method: terminal.updateViewport' },
_meta: { runtimeId: 'runtime' }
} satisfies RpcResponse
expect(isTerminalUpdateViewportUpdated(unsupported)).toBe(false)
expect(
resolveTerminalUpdateViewportCapability({
...unsupported,
error: { code: 'temporary_failure', message: 'retryable' }
})
).toBe('unknown')
let capability: TerminalUpdateViewportCapability = 'unknown'
let probeCount = 0
for (let refit = 0; refit < 10; refit += 1) {
if (capability === 'unsupported') {
continue
}
probeCount += 1
capability = resolveTerminalUpdateViewportCapability(unsupported)
}
expect(probeCount).toBe(1)
const responseCheckIndex = hookSource.indexOf('isTerminalUpdateViewportUpdated(response)')
const unsubscribeIndex = hookSource.indexOf('unsubscribeTerminal(handle)', responseCheckIndex)
const subscribeIndex = hookSource.indexOf('subscribeToTerminal(handle)', unsubscribeIndex)
expect(responseCheckIndex).toBeGreaterThanOrEqual(0)
expect(unsubscribeIndex).toBeGreaterThan(responseCheckIndex)
expect(subscribeIndex).toBeGreaterThan(unsubscribeIndex)
expect(hookSource).toContain("updateViewportCapabilityRef.current !== 'unsupported'")
expect(hookSource).toContain("updateViewportCapabilityRef.current = 'unknown'")
})
it('reflows the local xterm scrollback after a successful updateViewport', () => {
// Why: updateViewport may only record an informational mobile viewport in
// desktop mode. Reflow local scrollback only after the server says it
+166 -92
View File
@@ -7,7 +7,12 @@ import { shouldRecoverTerminalOnAppStateChange } from './terminal-foreground-rec
import {
isTerminalUpdateViewportApplied,
isTerminalUpdateViewportUpdated,
isTerminalViewportRefitTargetCurrent
isTerminalViewportRefitTargetCurrent,
reduceTerminalFrameHeightRefit,
resolveTerminalUpdateViewportCapability,
type TerminalFrameHeightRefitEvent,
type TerminalFrameHeightRefitState,
type TerminalUpdateViewportCapability
} from './terminal-viewport-refit-state'
export type TerminalViewportDims = { cols: number; rows: number }
@@ -36,13 +41,20 @@ type TerminalViewportRefitOptions = {
subscribeToTerminal: (handle: string) => void
}
type TerminalViewportRefitNotifications = {
notifyTerminalFrameHeight: (height: number) => void
notifyKeyboardVisibility: (visible: boolean) => void
}
// Why: re-measure the phone viewport when layout-affecting state changes
// outside the subscribe path — the tab strip toggling visibility, and the
// window itself resizing (fold/unfold on foldables, orientation rotation,
// split-screen). Without the resize trigger, a PTY fitted on the folded
// cover screen stays at cover-screen cols after unfolding and the terminal
// renders in only part of the display (#4579's "cut in half" symptom).
export function useTerminalViewportRefit(options: TerminalViewportRefitOptions): void {
export function useTerminalViewportRefit(
options: TerminalViewportRefitOptions
): TerminalViewportRefitNotifications {
const {
activeHandleRef,
terminalRefs,
@@ -64,101 +76,130 @@ export function useTerminalViewportRefit(options: TerminalViewportRefitOptions):
const refitRunSeqRef = useRef(0)
const forceNextRefitRef = useRef(false)
const disposedRef = useRef(false)
const scheduleViewportRefit = useCallback(() => {
if (refitTimerRef.current) {
clearTimeout(refitTimerRef.current)
}
refitTimerRef.current = setTimeout(() => {
refitTimerRef.current = null
const runSeq = refitRunSeqRef.current + 1
refitRunSeqRef.current = runSeq
const handle = activeHandleRef.current
if (!handle) {
return
const updateViewportCapabilityRef = useRef<TerminalUpdateViewportCapability>('unknown')
const frameHeightRefitStateRef = useRef<TerminalFrameHeightRefitState>({
frameHeight: 0,
keyboardVisible: false,
pending: false
})
// Why: marks the currently-armed timer as a height refit so its callback can
// re-check the keyboard at fire time. Non-height refits (width/rotation and
// the forced reconnect/foreground re-asserts) stay unguarded so they always run.
const heightOriginatedRefitRef = useRef(false)
const scheduleViewportRefit = useCallback(
(options?: { heightOriginated?: boolean }) => {
if (refitTimerRef.current) {
clearTimeout(refitTimerRef.current)
}
const ref = terminalRefs.current.get(handle)
if (!ref) {
return
}
const isCurrentTarget = () =>
isTerminalViewportRefitTargetCurrent({
activeHandle: activeHandleRef.current,
expectedHandle: handle,
currentRef: terminalRefs.current.get(handle),
expectedRef: ref,
disposed: disposedRef.current,
runSeq,
currentRunSeq: refitRunSeqRef.current
})
void (async () => {
const dims = await ref.measureFitDimensions(terminalFrameHeightRef.current || undefined)
if (!isCurrentTarget()) {
return
}
if (!dims) {
return
}
const forceRefit = forceNextRefitRef.current
forceNextRefitRef.current = false
const prev = viewportRef.current
if (!forceRefit && prev && prev.cols === dims.cols && prev.rows === dims.rows) {
return
}
viewportRef.current = dims
viewportMeasuredRef.current = true
// Why: prefer the in-place viewport update RPC over the legacy
// unsubscribe → subscribe cycle. This keeps the server-side
// mobile subscriber record alive (no driver=idle blip on the
// desktop banner; no false phone-fit baseline capture on the
// re-subscribe). See docs/mobile-presence-lock.md.
const rpc = clientRef.current
const deviceToken = deviceTokenRef.current
if (rpc && deviceToken) {
try {
const response = await rpc.sendRequest('terminal.updateViewport', {
terminal: handle,
client: { id: deviceToken, type: 'mobile' as const },
viewport: dims
})
if (!isCurrentTarget()) {
return
}
if (isTerminalUpdateViewportUpdated(response)) {
rpc.updateTerminalSubscriptionViewport(handle, dims)
if (isTerminalUpdateViewportApplied(response)) {
// Why: updateViewport reflows the server PTY and re-streams only
// the visible screen, so the WebView's local xterm scrollback
// stays wrapped at the old width. Reflow it locally only when
// the server actually applied phone-fit; desktop mode records
// the viewport but leaves the PTY at desktop dims.
ref.reflow(dims.cols, dims.rows)
}
return
}
} catch {
// Fall through to legacy resubscribe.
heightOriginatedRefitRef.current = options?.heightOriginated ?? false
refitTimerRef.current = setTimeout(() => {
refitTimerRef.current = null
// Why: a height refit deferred at keyboard-close can fire after the keyboard
// reopened within the 150ms debounce; re-check and re-defer so we never
// reflow the PTY mid-keystroke. Scoped via the height-originated flag.
if (heightOriginatedRefitRef.current) {
heightOriginatedRefitRef.current = false
const decision = reduceTerminalFrameHeightRefit(frameHeightRefitStateRef.current, {
type: 'refit-committed'
})
frameHeightRefitStateRef.current = decision.state
if (!decision.shouldRefit) {
return
}
}
if (!isCurrentTarget()) {
const runSeq = refitRunSeqRef.current + 1
refitRunSeqRef.current = runSeq
const handle = activeHandleRef.current
if (!handle) {
return
}
unsubscribeTerminal(handle)
initializedHandlesRef.current.delete(handle)
subscribeToTerminal(handle)
})()
}, 150)
}, [
activeHandleRef,
terminalRefs,
terminalFrameHeightRef,
viewportRef,
viewportMeasuredRef,
clientRef,
deviceTokenRef,
initializedHandlesRef,
unsubscribeTerminal,
subscribeToTerminal
])
const ref = terminalRefs.current.get(handle)
if (!ref) {
return
}
const isCurrentTarget = () =>
isTerminalViewportRefitTargetCurrent({
activeHandle: activeHandleRef.current,
expectedHandle: handle,
currentRef: terminalRefs.current.get(handle),
expectedRef: ref,
disposed: disposedRef.current,
runSeq,
currentRunSeq: refitRunSeqRef.current
})
void (async () => {
const dims = await ref.measureFitDimensions(terminalFrameHeightRef.current || undefined)
if (!isCurrentTarget()) {
return
}
if (!dims) {
return
}
const forceRefit = forceNextRefitRef.current
forceNextRefitRef.current = false
const prev = viewportRef.current
if (!forceRefit && prev && prev.cols === dims.cols && prev.rows === dims.rows) {
return
}
viewportRef.current = dims
viewportMeasuredRef.current = true
// Why: prefer the in-place viewport update RPC over the legacy
// unsubscribe → subscribe cycle. This keeps the server-side
// mobile subscriber record alive (no driver=idle blip on the
// desktop banner; no false phone-fit baseline capture on the
// re-subscribe). See docs/mobile-presence-lock.md.
const rpc = clientRef.current
const deviceToken = deviceTokenRef.current
if (rpc && deviceToken && updateViewportCapabilityRef.current !== 'unsupported') {
try {
const response = await rpc.sendRequest('terminal.updateViewport', {
terminal: handle,
client: { id: deviceToken, type: 'mobile' as const },
viewport: dims
})
if (!isCurrentTarget()) {
return
}
updateViewportCapabilityRef.current =
resolveTerminalUpdateViewportCapability(response)
if (isTerminalUpdateViewportUpdated(response)) {
rpc.updateTerminalSubscriptionViewport(handle, dims)
if (isTerminalUpdateViewportApplied(response)) {
// Why: updateViewport reflows the server PTY and re-streams only
// the visible screen, so the WebView's local xterm scrollback
// stays wrapped at the old width. Reflow it locally only when
// the server actually applied phone-fit; desktop mode records
// the viewport but leaves the PTY at desktop dims.
ref.reflow(dims.cols, dims.rows)
}
return
}
} catch {
// Fall through to legacy resubscribe.
}
}
if (!isCurrentTarget()) {
return
}
unsubscribeTerminal(handle)
initializedHandlesRef.current.delete(handle)
subscribeToTerminal(handle)
})()
}, 150)
},
[
activeHandleRef,
terminalRefs,
terminalFrameHeightRef,
viewportRef,
viewportMeasuredRef,
clientRef,
deviceTokenRef,
initializedHandlesRef,
unsubscribeTerminal,
subscribeToTerminal
]
)
const scheduleForcedViewportRefit = useCallback(() => {
forceNextRefitRef.current = true
scheduleViewportRefit()
@@ -194,6 +235,11 @@ export function useTerminalViewportRefit(options: TerminalViewportRefitOptions):
return
}
prevWindowDimsRef.current = { width: windowWidth, height: windowHeight }
// Why: adjustResize can change only window height while the IME is open;
// the frame-height notifier schedules one correction after it closes.
if (prev.width === windowWidth && frameHeightRefitStateRef.current.keyboardVisible) {
return
}
viewportMeasuredRef.current = false
scheduleViewportRefit()
}, [windowWidth, windowHeight, viewportMeasuredRef, scheduleViewportRefit])
@@ -226,6 +272,29 @@ export function useTerminalViewportRefit(options: TerminalViewportRefitOptions):
scheduleViewportRefit()
}, [terminalFrameWidth, viewportMeasuredRef, scheduleViewportRefit])
const notifyFrameHeightRefitEvent = useCallback(
(event: TerminalFrameHeightRefitEvent) => {
const transition = reduceTerminalFrameHeightRefit(frameHeightRefitStateRef.current, event)
frameHeightRefitStateRef.current = transition.state
if (!transition.shouldRefit) {
return
}
viewportMeasuredRef.current = false
scheduleViewportRefit({ heightOriginated: true })
},
[viewportMeasuredRef, scheduleViewportRefit]
)
// Why: notify imperatively so layout churn does not rerender the full session;
// a height change during typing is coalesced into one refit after keyboard close.
const notifyTerminalFrameHeight = useCallback(
(height: number) => notifyFrameHeightRefitEvent({ type: 'frame-height', height }),
[notifyFrameHeightRefitEvent]
)
const notifyKeyboardVisibility = useCallback(
(visible: boolean) => notifyFrameHeightRefitEvent({ type: 'keyboard-visibility', visible }),
[notifyFrameHeightRefitEvent]
)
useEffect(() => {
if (Platform.OS !== 'ios') {
return
@@ -256,6 +325,9 @@ export function useTerminalViewportRefit(options: TerminalViewportRefitOptions):
if (previous === 'connected' || connState !== 'connected') {
return
}
// Why: an in-place desktop upgrade may add updateViewport; reconnect is the
// narrow boundary where an old-host method_not_found cache becomes stale.
updateViewportCapabilityRef.current = 'unknown'
// Why: reconnect can restore a PTY whose host-side size changed while the
// socket was down, so equal cached dimensions still need reassertion.
viewportMeasuredRef.current = false
@@ -272,4 +344,6 @@ export function useTerminalViewportRefit(options: TerminalViewportRefitOptions):
}
}
}, [])
return { notifyTerminalFrameHeight, notifyKeyboardVisibility }
}
@@ -0,0 +1,26 @@
import { useEffect, useState } from 'react'
import { useRpcClientContext, type RpcClientContextValue } from './client-context'
export function useReconnectAttempt(hostId: string | undefined): number {
return useHostMetric(hostId, (context, id) => context.getReconnectAttempt(id), 0)
}
export function useLastConnectedAt(hostId: string | undefined): number | null {
return useHostMetric(hostId, (context, id) => context.getLastConnectedAt(id), null)
}
function useHostMetric<T>(
hostId: string | undefined,
read: (context: RpcClientContextValue, hostId: string) => T,
fallback: T
): T {
const context = useRpcClientContext()
const [, force] = useState(0)
useEffect(() => {
if (!hostId) {
return
}
return context.subscribeHostState(hostId, () => force((count) => count + 1))
}, [context, hostId])
return hostId ? read(context, hostId) : fallback
}
+4 -1
View File
@@ -10,6 +10,9 @@ const loadHostsMock = vi.fn()
vi.mock('./rpc-client', () => ({
connect: (...args: unknown[]) => connectMock(...args)
}))
vi.mock('./host-logical-client', () => ({
openHostLogicalClient: (...args: unknown[]) => connectMock(...args)
}))
vi.mock('./host-store', () => ({
loadHosts: () => loadHostsMock()
}))
@@ -131,7 +134,7 @@ describe('useHostClient', () => {
loadHostsMock.mockResolvedValue([HOST])
const harness = await renderHarness(HOST.id)
expect(harness.hook.client).toBe(fake)
expect(harness.hook.client).not.toBeNull()
expect(harness.hook.state).toBe('connected')
// Regression (STA-1511): closeHost deletes the entry; before the fix the
+36 -51
View File
@@ -19,11 +19,13 @@ import {
useState,
type ReactNode
} from 'react'
import { connect, type RpcClient } from './rpc-client'
import type { RpcClient } from './rpc-client'
import { connectionLogStore } from './connection-log-buffer'
import { subscribeConnectionRevivalTriggers } from './connection-revival-triggers'
import { HostClientOpenRegistry } from './host-client-open-registry'
import { loadHosts } from './host-store'
import { openHostLogicalClient } from './host-logical-client'
import type { MobileConnectionPath, StableLogicalRpcClient } from './stable-logical-rpc-client'
import type { ConnectionState, HostProfile } from './types'
type StoreEntry = {
@@ -33,7 +35,7 @@ type StoreEntry = {
unsubState: () => void
}
type ContextValue = {
export type RpcClientContextValue = {
acquire: (hostId: string, host?: HostProfile) => RpcClient | null
release: (hostId: string) => void
forceReconnect: (hostId: string) => Promise<void>
@@ -45,6 +47,7 @@ type ContextValue = {
// Used by the UI to escalate "Reconnecting…" into a "host appears
// unreachable, re-pair?" prompt.
getLastConnectedAt: (hostId: string) => number | null
getActivePath: (hostId: string) => MobileConnectionPath
subscribeHostState: (hostId: string, listener: (state: ConnectionState) => void) => () => void
getAllClients: () => Array<{ hostId: string; client: RpcClient }>
subscribeAllHosts: (listener: () => void) => () => void
@@ -54,7 +57,7 @@ type ContextValue = {
primeHosts: (hosts: HostProfile[]) => void
}
const Ctx = createContext<ContextValue | null>(null)
const Ctx = createContext<RpcClientContextValue | null>(null)
export function RpcClientProvider({ children }: { children: ReactNode }) {
// Why: entries live in a ref so updates don't force re-renders of the
@@ -155,12 +158,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
let client: RpcClient
try {
client = connect(host.endpoint, host.deviceToken, host.publicKeyB64, {
// Why: retain reconnect lifecycle events for the Connection Log
// screen — without this the reasons a host is stuck live only in
// console.log, which users can't see or share.
onLog: (entry) => connectionLogStore.append(hostId, entry)
})
client = openHostLogicalClient(host, (entry) => connectionLogStore.append(hostId, entry))
} catch {
// Why: connect() can throw synchronously if the public key is
// malformed or the endpoint URL is invalid. Notify so the UI
@@ -279,6 +277,10 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
return storeRef.current.get(hostId)?.client.getLastConnectedAt() ?? null
}, [])
const getActivePath = useCallback((hostId: string): MobileConnectionPath => {
return clientActivePath(storeRef.current.get(hostId)?.client)
}, [])
const subscribeHostState = useCallback(
(hostId: string, listener: (state: ConnectionState) => void) => {
let set = stateListenersRef.current.get(hostId)
@@ -348,7 +350,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
})
}, [])
const value = useMemo<ContextValue>(
const value = useMemo<RpcClientContextValue>(
() => ({
acquire,
release,
@@ -357,6 +359,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
getState,
getReconnectAttempt,
getLastConnectedAt,
getActivePath,
subscribeHostState,
getAllClients,
subscribeAllHosts,
@@ -370,6 +373,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
getState,
getReconnectAttempt,
getLastConnectedAt,
getActivePath,
subscribeHostState,
getAllClients,
subscribeAllHosts,
@@ -380,7 +384,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
return <Ctx.Provider value={value}>{children}</Ctx.Provider>
}
function useCtx(): ContextValue {
export function useRpcClientContext(): RpcClientContextValue {
const ctx = useContext(Ctx)
if (!ctx) {
throw new Error('useHostClient must be used inside <RpcClientProvider>')
@@ -395,7 +399,7 @@ export function useHostClient(hostId: string | undefined): {
client: RpcClient | null
state: ConnectionState
} {
const ctx = useCtx()
const ctx = useRpcClientContext()
const [, force] = useState(0)
const [state, setState] = useState<ConnectionState>(() =>
hostId ? ctx.getState(hostId) : 'disconnected'
@@ -453,8 +457,9 @@ export function useAllHostClients(hostIds: string[]): Array<{
hostId: string
client: RpcClient
state: ConnectionState
path: MobileConnectionPath
}> {
const ctx = useCtx()
const ctx = useRpcClientContext()
// Stable key so we don't tear down on every render of the array.
const key = useMemo(() => [...hostIds].sort().join(','), [hostIds])
const [tick, setTick] = useState(0)
@@ -483,11 +488,21 @@ export function useAllHostClients(hostIds: string[]): Array<{
}, [key])
return useMemo(() => {
const out: Array<{ hostId: string; client: RpcClient; state: ConnectionState }> = []
const out: Array<{
hostId: string
client: RpcClient
state: ConnectionState
path: MobileConnectionPath
}> = []
for (const id of hostIds) {
const all = ctx.getAllClients().find((entry) => entry.hostId === id)
if (all) {
out.push({ hostId: id, client: all.client, state: ctx.getState(id) })
out.push({
hostId: id,
client: all.client,
state: ctx.getState(id),
path: ctx.getActivePath(id)
})
}
}
return out
@@ -499,13 +514,13 @@ export function useAllHostClients(hostIds: string[]): Array<{
// host-store has no React-side handle. Expose a hook that lets callers
// close a host after removal.
export function useCloseHost(): (hostId: string) => void {
const ctx = useCtx()
const ctx = useRpcClientContext()
return ctx.closeHost
}
// Why: future-proof "Connection issues — try again" affordance.
export function useForceReconnect(): (hostId: string) => Promise<void> {
const ctx = useCtx()
const ctx = useRpcClientContext()
return ctx.forceReconnect
}
@@ -513,41 +528,11 @@ export function useForceReconnect(): (hostId: string) => Promise<void> {
// provider can skip its own loadHosts() pass when it eventually opens
// each host — collapses two serial Keychain reads on cold-start into one.
export function usePrimeHosts(): (hosts: HostProfile[]) => void {
const ctx = useCtx()
const ctx = useRpcClientContext()
return ctx.primeHosts
}
// Why: lets the home/host-detail UI escalate "Reconnecting…" to a more
// alarming "Can't connect" once the rpc-client has cycled enough times to
// indicate something's actually wrong (wrong port, server down, network
// loss). Reads through the context so it stays in sync with the live
// rpc-client instance even after forceReconnect swaps the underlying
// client.
export function useReconnectAttempt(hostId: string | undefined): number {
const ctx = useCtx()
const [, force] = useState(0)
useEffect(() => {
if (!hostId) {
return
}
return ctx.subscribeHostState(hostId, () => force((n) => n + 1))
}, [ctx, hostId])
return hostId ? ctx.getReconnectAttempt(hostId) : 0
}
// Why: timestamp of last successful connect for this host, or null if
// the client has never connected. Combined with reconnectAttempt this
// distinguishes "transient blip" (recently connected) from "host
// appears unreachable" (never connected, or hasn't connected in N
// seconds despite many retry attempts).
export function useLastConnectedAt(hostId: string | undefined): number | null {
const ctx = useCtx()
const [, force] = useState(0)
useEffect(() => {
if (!hostId) {
return
}
return ctx.subscribeHostState(hostId, () => force((n) => n + 1))
}, [ctx, hostId])
return hostId ? ctx.getLastConnectedAt(hostId) : null
function clientActivePath(client: RpcClient | undefined): MobileConnectionPath {
const logical = client as Partial<StableLogicalRpcClient> | undefined
return typeof logical?.getActivePath === 'function' ? logical.getActivePath() : 'lan'
}
@@ -0,0 +1,36 @@
import { AppState, Platform } from 'react-native'
import { connect, type RpcClient } from './rpc-client'
import { createStableLogicalRpcClient } from './stable-logical-rpc-client'
import type { ConnectionLogSink, HostProfile } from './types'
import { directPathForEndpoint } from './mobile-direct-endpoint-probe'
import { startMobileEndpointLifecycle } from './mobile-endpoint-lifecycle'
export function openHostLogicalClient(host: HostProfile, onLog: ConnectionLogSink): RpcClient {
// Why: the stable facade owns app-visible RPC/subscription state while the
// direct socket remains a replaceable first physical generation.
const logical = createStableLogicalRpcClient(
connect(host.endpoint, host.deviceToken, host.publicKeyB64, { onLog }),
directPathForEndpoint(host, host.endpoint)
)
if (Platform.OS === 'web') {
return logical
}
const endpointLifecycle = startMobileEndpointLifecycle(logical, host, onLog)
endpointLifecycle.setForeground(AppState.currentState === 'active')
const appStateSubscription = AppState.addEventListener('change', (state) => {
endpointLifecycle.setForeground(state === 'active')
})
const closeLogical = logical.close
logical.close = () => {
appStateSubscription.remove()
endpointLifecycle.stop()
closeLogical()
}
const notifyLogicalForeground = logical.notifyForeground
logical.notifyForeground = () => {
endpointLifecycle.setForeground(true)
notifyLogicalForeground()
}
return logical
}
+66 -1
View File
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { MobileRelayHostOverlay } from './mobile-relay-host-overlay'
const asyncStorageMock = vi.hoisted(() => ({
getItem: vi.fn(),
@@ -32,9 +33,19 @@ vi.mock('./host-credential-cleanup', () => ({
retryPendingHostCredentialCleanups: vi.fn()
}))
import { removeHost, renameHost, resetHostStoreForTests, updateLastConnected } from './host-store'
import {
loadHosts,
MobileRelayUpgradeHostRemovedError,
removeHost,
renameHost,
resetHostStoreForTests,
saveExistingHostRelayUpgrade,
updateLastConnected
} from './host-store'
import { resetMobileRelayHostOverlayStoreForTests } from './mobile-relay-host-overlay-store'
const HOSTS_STORAGE_KEY = 'orca:hosts'
const OVERLAY_STORAGE_KEY = 'orca:mobile-relay:host-overlays:v2'
const HOST_ONE = {
id: 'host-1',
name: 'Host 1',
@@ -52,17 +63,23 @@ const HOST_TWO = {
describe('host-store list mutations', () => {
let storedHostsRaw: string
let storedOverlayRaw: string | null
beforeEach(() => {
vi.clearAllMocks()
resetHostStoreForTests()
resetMobileRelayHostOverlayStoreForTests()
scheduleCleanupMock.mockReset()
scheduleCleanupMock.mockResolvedValue(undefined)
storedHostsRaw = JSON.stringify([HOST_ONE, HOST_TWO])
storedOverlayRaw = null
asyncStorageMock.getItem.mockImplementation(async (key: string) => {
if (key === HOSTS_STORAGE_KEY) {
return storedHostsRaw
}
if (key === OVERLAY_STORAGE_KEY) {
return storedOverlayRaw
}
return null
})
asyncStorageMock.setItem.mockImplementation(async (key: string, raw: string) => {
@@ -70,6 +87,9 @@ describe('host-store list mutations', () => {
storedHostsRaw = raw
}
})
secureStoreMock.getItemAsync.mockImplementation(async (key: string) =>
key.endsWith(HOST_ONE.id) || key.endsWith(HOST_TWO.id) ? `token-${key.at(-1)}` : null
)
})
it('commits the removal when credential cleanup scheduling rejects', async () => {
@@ -81,6 +101,51 @@ describe('host-store list mutations', () => {
expect(scheduleCleanupMock).toHaveBeenCalledWith(HOST_ONE.id, expect.any(Function))
})
it('merges v2 endpoints only onto an existing legacy base host', async () => {
const overlay: MobileRelayHostOverlay = {
v: 2,
hostId: HOST_ONE.id,
endpoints: [
{ id: 'direct-primary', kind: 'lan', url: HOST_ONE.endpoint },
{
id: 'relay-primary',
kind: 'relay',
url: 'wss://relay-c1.onorca.dev/v1/connect/AbCdEf0123_-xyZ9'
}
],
relayHostId: 'AbCdEf0123_-xyZ9',
relay: {
v: 1,
directorUrl: 'https://relay.onorca.dev',
cellUrl: 'https://relay-c1.onorca.dev',
assignmentEpoch: 7,
relayHostId: 'AbCdEf0123_-xyZ9',
e2eeFraming: 2
}
}
storedOverlayRaw = JSON.stringify([overlay, { ...overlay, hostId: 'removed-by-old-build' }])
const hosts = await loadHosts()
expect(hosts.find(({ id }) => id === HOST_ONE.id)).toMatchObject({
endpoints: overlay.endpoints,
relayHostId: overlay.relayHostId,
relay: overlay.relay
})
expect(hosts.some(({ id }) => id === 'removed-by-old-build')).toBe(false)
})
it('refuses to resurrect a removed host during relay upgrade publication', async () => {
storedHostsRaw = JSON.stringify([HOST_TWO])
await expect(
saveExistingHostRelayUpgrade({ ...HOST_ONE, deviceToken: 'token-1' })
).rejects.toBeInstanceOf(MobileRelayUpgradeHostRemovedError)
expect(JSON.parse(storedHostsRaw)).toEqual([HOST_TWO])
expect(secureStoreMock.setItemAsync).not.toHaveBeenCalled()
})
it('awaits cleanup scheduling after metadata commit', async () => {
let resolveSchedule: (() => void) | null = null
scheduleCleanupMock.mockReturnValue(
+65 -3
View File
@@ -12,6 +12,14 @@ import {
retryPendingHostCredentialCleanups,
scheduleHostCredentialCleanup
} from './host-credential-cleanup'
import {
loadMobileRelayHostOverlayState,
removeMobileRelayHostOverlay,
saveMobileRelayHostOverlay
} from './mobile-relay-host-overlay-store'
import { deleteMobileRelayCredentialBundle } from './mobile-relay-credential-bundle'
import { deleteMobileRelayDirectUpgradeJournal } from './mobile-relay-direct-upgrade-journal'
import { scheduleOrphanedMobileRelayCleanup } from './mobile-relay-orphan-cleanup'
const STORAGE_KEY = 'orca:hosts'
// Why: SecureStore keys must match [A-Za-z0-9._-]; colons are rejected.
@@ -61,6 +69,12 @@ async function deleteDeviceToken(hostId: string): Promise<void> {
await SecureStore.deleteItemAsync(tokenKey(hostId), KEYCHAIN_OPTIONS)
}
async function deleteHostCredentials(hostId: string): Promise<void> {
await deleteDeviceToken(hostId)
await deleteMobileRelayCredentialBundle(hostId)
await deleteMobileRelayDirectUpgradeJournal(hostId)
}
// Why: SecureStore reads on Android Keystore can take 50-200ms each, and
// loadHosts() is called from every screen mount + every useFocusEffect.
// Stack with N hosts and you get N*200ms blocking every navigation, which
@@ -120,6 +134,14 @@ async function doLoadHosts(): Promise<HostProfile[]> {
if (!storedHosts) {
return []
}
const overlayState = await loadMobileRelayHostOverlayState(
new Set(storedHosts.map(({ id }) => id))
)
await scheduleOrphanedMobileRelayCleanup({
hostIds: overlayState.orphanHostIds,
deleteCredential: deleteHostCredentials
})
const overlays = overlayState.overlays
const out: HostProfile[] = []
for (const stored of storedHosts) {
@@ -144,7 +166,18 @@ async function doLoadHosts(): Promise<HostProfile[]> {
token = fetched
tokenCache.set(stored.id, token)
}
out.push({ ...stored, deviceToken: token })
const overlay = overlays.get(stored.id)
out.push({
...stored,
deviceToken: token,
...(overlay
? {
endpoints: overlay.endpoints,
relayHostId: overlay.relayHostId,
relay: overlay.relay
}
: {})
})
}
return out
}
@@ -196,7 +229,17 @@ function toStored(host: HostProfile): StoredHostProfile {
}
}
export class MobileRelayUpgradeHostRemovedError extends Error {}
export async function saveHost(host: HostProfile): Promise<void> {
await persistHost(host, false)
}
export async function saveExistingHostRelayUpgrade(host: HostProfile): Promise<void> {
await persistHost(host, true)
}
async function persistHost(host: HostProfile, requireExisting: boolean): Promise<void> {
const validated = HostProfileSchema.parse(host)
const stored = toStored(validated)
await mutateStoredHosts((hosts) => {
@@ -206,6 +249,10 @@ export async function saveHost(host: HostProfile): Promise<void> {
next[index] = stored
return next
}
if (requireExisting) {
// Why: an in-flight relay upgrade must not resurrect a host the user removed.
throw new MobileRelayUpgradeHostRemovedError('mobile relay upgrade host was removed')
}
return [...hosts, stored]
})
// Why: write metadata BEFORE the keychain token so a crash between the two
@@ -215,15 +262,30 @@ export async function saveHost(host: HostProfile): Promise<void> {
// from current metadata.
await writeDeviceToken(stored.id, validated.deviceToken)
tokenCache.set(stored.id, validated.deviceToken)
if (validated.endpoints) {
await saveMobileRelayHostOverlay({
v: 2,
hostId: stored.id,
endpoints: validated.endpoints,
relayHostId: validated.relayHostId,
relay: validated.relay
})
}
}
export async function removeHost(hostId: string): Promise<void> {
await mutateStoredHosts((hosts) => hosts.filter((h) => h.id !== hostId))
tokenCache.delete(hostId)
try {
await removeMobileRelayHostOverlay(hostId)
} catch {
// The missing legacy base is authoritative, so a retained overlay cannot
// resurrect this host and can be cleaned on a later explicit retry.
}
// Why: await only the durable cleanup intent (AsyncStorage). Native keychain
// delete can reject or stall and must not freeze removeHost / the UI.
try {
await scheduleHostCredentialCleanup(hostId, deleteDeviceToken)
await scheduleHostCredentialCleanup(hostId, deleteHostCredentials)
} catch {
// Metadata is already committed; orphan-token recovery is best-effort.
}
@@ -234,7 +296,7 @@ export async function retryPendingHostCredentialCleanup(): Promise<{
remainingIds: string[]
storageUnreadable: boolean
}> {
return retryPendingHostCredentialCleanups(deleteDeviceToken)
return retryPendingHostCredentialCleanups(deleteHostCredentials)
}
export async function renameHost(hostId: string, newName: string): Promise<void> {
@@ -0,0 +1,10 @@
import { describe, expect, it } from 'vitest'
import { mobileConnectionPathLabel } from './mobile-connection-path-label'
describe('mobile connection path label', () => {
it('distinguishes LAN, Tailscale, and the relay without exposing transport errors', () => {
expect(mobileConnectionPathLabel('lan')).toBe('Direct · LAN')
expect(mobileConnectionPathLabel('tailscale')).toBe('Direct · Tailscale')
expect(mobileConnectionPathLabel('relay')).toBe('Orca Relay')
})
})
@@ -0,0 +1,8 @@
import type { MobileConnectionPath } from './stable-logical-rpc-client'
export function mobileConnectionPathLabel(path: MobileConnectionPath): string {
if (path === 'relay') {
return 'Orca Relay'
}
return path === 'tailscale' ? 'Direct · Tailscale' : 'Direct · LAN'
}
@@ -0,0 +1,71 @@
import type { RpcClient } from './rpc-client'
import type { MobileConnectionPath } from './stable-logical-rpc-client'
import type { HostProfile } from './types'
function directEndpointUrls(host: HostProfile): string[] {
const endpoints =
host.endpoints?.filter(({ kind }) => kind !== 'relay').map(({ url }) => url) ?? []
return [...new Set([host.endpoint, ...endpoints])]
}
export function directPathForEndpoint(
host: HostProfile,
endpoint: string
): Exclude<MobileConnectionPath, 'relay'> {
const configured = host.endpoints?.find((candidate) => candidate.url === endpoint)
if (configured?.kind === 'tailscale') {
return 'tailscale'
}
try {
const hostname = new URL(endpoint).hostname
if (hostname.endsWith('.ts.net') || /^100\.(?:\d{1,3}\.){2}\d{1,3}$/.test(hostname)) {
return 'tailscale'
}
} catch {}
return 'lan'
}
function waitForAuthenticatedSession(session: RpcClient, timeoutMs: number): Promise<void> {
if (session.getState() === 'connected') {
return Promise.resolve()
}
return new Promise((resolve, reject) => {
let timer: ReturnType<typeof setTimeout> | null = null
const unsubscribe = session.onStateChange((state) => {
if (state === 'connected') {
finish()
resolve()
} else if (state === 'disconnected' || state === 'auth-failed') {
finish()
reject(new Error(`probe session ${state}`))
}
})
timer = setTimeout(() => {
finish()
reject(new Error('probe session authentication timed out'))
}, timeoutMs)
function finish(): void {
if (timer) {
clearTimeout(timer)
}
unsubscribe()
}
})
}
export async function openAuthenticatedDirectEndpoint(
host: HostProfile,
openDirect: (endpoint: string) => RpcClient,
timeoutMs: number
): Promise<{ client: RpcClient; path: Exclude<MobileConnectionPath, 'relay'> } | null> {
for (const endpoint of directEndpointUrls(host)) {
const client = openDirect(endpoint)
try {
await waitForAuthenticatedSession(client, timeoutMs)
return { client, path: directPathForEndpoint(host, endpoint) }
} catch {
client.close()
}
}
return null
}
@@ -0,0 +1,30 @@
import { describe, expect, it, vi } from 'vitest'
import nacl from 'tweetnacl'
import { MOBILE_E2EE_LEGACY_FIXTURE } from '../../../src/shared/mobile-e2ee-legacy-fixtures'
vi.mock('expo-crypto', () => ({
getRandomBytes: (length: number) => new Uint8Array(length).fill(9)
}))
import { decrypt, decryptBytes, deriveSharedKey } from './e2ee'
describe('mobile legacy E2EE fixtures', () => {
it('matches the captured desktop key and text/binary frames', () => {
const fixture = MOBILE_E2EE_LEGACY_FIXTURE
const server = nacl.box.keyPair.fromSecretKey(fixture.serverSecretKey)
const client = nacl.box.keyPair.fromSecretKey(fixture.clientSecretKey)
const shared = deriveSharedKey(client.secretKey, server.publicKey)
expect(hex(shared)).toBe(fixture.sharedKeyHex)
expect(decrypt(fixture.authFrameB64, shared)).toBe(fixture.authPlaintext)
expect(decryptBytes(fromHex(fixture.binaryFrameHex), shared)).toEqual(fixture.binaryPlaintext)
})
})
function hex(bytes: Uint8Array): string {
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')
}
function fromHex(value: string): Uint8Array {
return Uint8Array.from(value.match(/../g) ?? [], (byte) => Number.parseInt(byte, 16))
}
@@ -0,0 +1,85 @@
import { describe, expect, it, vi } from 'vitest'
import nacl from 'tweetnacl'
import {
encodeMobileE2EEV2Transcript,
validateMobileE2EEV2Handshake,
type MobileE2EEV2Ready
} from '../../../src/shared/mobile-e2ee-v2-contract'
import { sealMobileE2EEV2Frame } from '../../../src/shared/mobile-e2ee-v2-framing'
vi.mock('expo-crypto', () => ({
getRandomBytes: (length: number) => new Uint8Array(length).fill(9)
}))
import { deriveSharedKey } from './e2ee'
import { MobileE2EEV2ClientSession } from './mobile-e2ee-v2-client-session'
import { deriveMobileE2EEV2KeySchedule } from './mobile-e2ee-v2-key-schedule'
const desktop = nacl.box.keyPair.fromSecretKey(new Uint8Array(32).fill(1))
const client = nacl.box.keyPair.fromSecretKey(new Uint8Array(32).fill(2))
function setup() {
const session = MobileE2EEV2ClientSession.create({
desktopPublicKeyB64: Buffer.from(desktop.publicKey).toString('base64'),
transport: 'relay',
relayHostId: 'AbCdEf0123_-xyZ9',
clientNonce: new Uint8Array(32).fill(3),
clientKeyPair: client
})
const ready: MobileE2EEV2Ready = {
type: 'e2ee_ready',
v: 2,
desktopPublicKeyB64: Buffer.from(desktop.publicKey).toString('base64'),
clientNonceB64: session.hello.clientNonceB64,
desktopNonceB64: Buffer.from(new Uint8Array(32).fill(4)).toString('base64'),
selection: { framing: 2, payloadKinds: ['text', 'binary'] },
context: session.hello.context
}
return { session, ready }
}
describe('mobile E2EE v2 client session', () => {
it('pins the desktop key and accepts the exact transcript', () => {
const { session, ready } = setup()
expect(session.acceptReady(ready)).toBe(true)
expect(session.transcriptHashB64).toHaveLength(44)
expect(
session.acceptReady({
...ready,
desktopPublicKeyB64: Buffer.from(new Uint8Array(32).fill(8)).toString('base64')
})
).toBe(false)
})
it('seals auth at counter zero and rejects replayed desktop frames', () => {
const { session, ready } = setup()
expect(session.acceptReady(ready)).toBe(true)
const auth = JSON.stringify({
type: 'e2ee_auth',
v: 2,
transcriptHashB64: session.transcriptHashB64,
deviceToken: 'token'
})
const authFrame = Buffer.from(session.sealText(auth), 'base64')
expect(authFrame.subarray(16, 24)).toEqual(Buffer.alloc(8, 0))
const handshake = validateMobileE2EEV2Handshake(session.hello, ready)!
const schedule = deriveMobileE2EEV2KeySchedule({
sharedSecret: deriveSharedKey(desktop.secretKey, client.publicKey),
transcript: encodeMobileE2EEV2Transcript(handshake),
clientNonce: handshake.clientNonce,
desktopNonce: handshake.desktopNonce
})
const response = sealMobileE2EEV2Frame({
payload: new TextEncoder().encode('authenticated'),
key: schedule.desktopToMobileKey,
sessionId: schedule.sessionId,
direction: 'desktop-to-mobile',
payloadKind: 'text',
counter: 0n
})
const encoded = Buffer.from(response).toString('base64')
expect(session.openText(encoded)).toBe('authenticated')
expect(session.openText(encoded)).toBeNull()
})
})
@@ -0,0 +1,167 @@
import * as ExpoCrypto from 'expo-crypto'
import {
encodeMobileE2EEV2Transcript,
validateMobileE2EEV2Handshake,
type MobileE2EETransport,
type MobileE2EEV2Hello
} from '../../../src/shared/mobile-e2ee-v2-contract'
import {
openMobileE2EEV2Frame,
sealMobileE2EEV2Frame
} from '../../../src/shared/mobile-e2ee-v2-framing'
import { deriveSharedKey, generateKeyPair, publicKeyFromBase64, publicKeyToBase64 } from './e2ee'
import { deriveMobileE2EEV2KeySchedule } from './mobile-e2ee-v2-key-schedule'
export class MobileE2EEV2ClientSession {
readonly hello: MobileE2EEV2Hello
private inboundCounter = 0n
private outboundCounter = 0n
private schedule: ReturnType<typeof deriveMobileE2EEV2KeySchedule> | null = null
private transcriptHashB64Value: string | null = null
private constructor(
private readonly clientSecretKey: Uint8Array,
private readonly pinnedDesktopPublicKey: Uint8Array,
hello: MobileE2EEV2Hello
) {
this.hello = hello
}
static create(args: {
desktopPublicKeyB64: string
transport: MobileE2EETransport
relayHostId?: string
clientNonce?: Uint8Array
clientKeyPair?: { publicKey: Uint8Array; secretKey: Uint8Array }
}): MobileE2EEV2ClientSession {
const keyPair = args.clientKeyPair ?? generateKeyPair()
const clientNonce = args.clientNonce ?? ExpoCrypto.getRandomBytes(32)
if (clientNonce.length !== 32) {
throw new Error(`Invalid client nonce length: ${clientNonce.length}`)
}
return new MobileE2EEV2ClientSession(
keyPair.secretKey,
publicKeyFromBase64(args.desktopPublicKeyB64),
{
type: 'e2ee_hello',
v: 2,
clientPublicKeyB64: publicKeyToBase64(keyPair.publicKey),
clientNonceB64: encodeBase64(clientNonce),
capabilities: { framing: [2], payloadKinds: ['text', 'binary'] },
context: {
protocol: 'orca-mobile-e2ee',
initiator: 'mobile',
responder: 'desktop',
transport: args.transport,
...(args.relayHostId ? { relayHostId: args.relayHostId } : {})
}
}
)
}
acceptReady(ready: unknown): boolean {
const handshake = validateMobileE2EEV2Handshake(this.hello, ready)
if (!handshake || !equalBytes(handshake.desktopPublicKey, this.pinnedDesktopPublicKey)) {
return false
}
this.schedule = deriveMobileE2EEV2KeySchedule({
sharedSecret: deriveSharedKey(this.clientSecretKey, this.pinnedDesktopPublicKey),
transcript: encodeMobileE2EEV2Transcript(handshake),
clientNonce: handshake.clientNonce,
desktopNonce: handshake.desktopNonce
})
this.transcriptHashB64Value = encodeBase64(this.schedule.transcriptHash)
return true
}
get transcriptHashB64(): string {
if (!this.transcriptHashB64Value) {
throw new Error('E2EE v2 ready has not been accepted')
}
return this.transcriptHashB64Value
}
openText(frameB64: string): string | null {
const frame = decodeCanonicalBase64(frameB64)
if (!frame) {
return null
}
const plaintext = this.open(frame, 'text')
return plaintext ? new TextDecoder().decode(plaintext) : null
}
openBinary(frame: Uint8Array): Uint8Array | null {
return this.open(frame, 'binary')
}
sealText(plaintext: string): string {
return encodeBase64(this.seal(new TextEncoder().encode(plaintext), 'text'))
}
sealBinary(plaintext: Uint8Array): Uint8Array {
return this.seal(plaintext, 'binary')
}
private open(frame: Uint8Array, payloadKind: 'text' | 'binary'): Uint8Array | null {
if (!this.schedule) {
return null
}
const plaintext = openMobileE2EEV2Frame({
frame,
key: this.schedule.desktopToMobileKey,
sessionId: this.schedule.sessionId,
direction: 'desktop-to-mobile',
payloadKind,
expectedCounter: this.inboundCounter
})
if (plaintext) {
this.inboundCounter++
}
return plaintext
}
private seal(plaintext: Uint8Array, payloadKind: 'text' | 'binary'): Uint8Array {
if (!this.schedule) {
throw new Error('E2EE v2 ready has not been accepted')
}
const frame = sealMobileE2EEV2Frame({
payload: plaintext,
key: this.schedule.mobileToDesktopKey,
sessionId: this.schedule.sessionId,
direction: 'mobile-to-desktop',
payloadKind,
counter: this.outboundCounter
})
this.outboundCounter++
return frame
}
}
function encodeBase64(bytes: Uint8Array): string {
let binary = ''
for (const byte of bytes) {
binary += String.fromCharCode(byte)
}
return btoa(binary)
}
function decodeCanonicalBase64(value: string): Uint8Array | null {
try {
const binary = atob(value)
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0))
return encodeBase64(bytes) === value ? bytes : null
} catch {
return null
}
}
function equalBytes(left: Uint8Array, right: Uint8Array): boolean {
if (left.length !== right.length) {
return false
}
let difference = 0
for (let index = 0; index < left.length; index++) {
difference |= left[index]! ^ right[index]!
}
return difference === 0
}
@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest'
import {
encodeMobileE2EEV2Transcript,
validateMobileE2EEV2Handshake
} from '../../../src/shared/mobile-e2ee-v2-contract'
import {
createMobileE2EEV2Fixture,
MOBILE_E2EE_V2_VECTOR
} from '../../../src/shared/mobile-e2ee-v2-fixtures'
import { deriveMobileE2EEV2KeySchedule } from './mobile-e2ee-v2-key-schedule'
function hex(bytes: Uint8Array): string {
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')
}
describe('mobile E2EE v2 key schedule', () => {
it('matches the desktop normative HKDF vector', () => {
const { hello, ready, sharedSecret } = createMobileE2EEV2Fixture()
const handshake = validateMobileE2EEV2Handshake(hello, ready)!
const schedule = deriveMobileE2EEV2KeySchedule({
sharedSecret,
transcript: encodeMobileE2EEV2Transcript(handshake),
clientNonce: handshake.clientNonce,
desktopNonce: handshake.desktopNonce
})
expect(hex(schedule.mobileToDesktopKey)).toBe(MOBILE_E2EE_V2_VECTOR.mobileToDesktopKeyHex)
expect(hex(schedule.desktopToMobileKey)).toBe(MOBILE_E2EE_V2_VECTOR.desktopToMobileKeyHex)
expect(hex(schedule.sessionId)).toBe(MOBILE_E2EE_V2_VECTOR.sessionIdHex)
expect(hex(schedule.transcriptHash)).toBe(MOBILE_E2EE_V2_VECTOR.transcriptHashHex)
})
it('derives unique direction keys and session IDs across fresh client nonces', () => {
const { hello, ready, sharedSecret } = createMobileE2EEV2Fixture()
const fingerprints = new Set<string>()
for (let index = 0; index < 128; index++) {
const nonce = new Uint8Array(32)
new DataView(nonce.buffer).setUint32(28, index, false)
const clientNonceB64 = Buffer.from(nonce).toString('base64')
const handshake = validateMobileE2EEV2Handshake(
{ ...hello, clientNonceB64 },
{ ...ready, clientNonceB64 }
)!
const schedule = deriveMobileE2EEV2KeySchedule({
sharedSecret,
transcript: encodeMobileE2EEV2Transcript(handshake),
clientNonce: handshake.clientNonce,
desktopNonce: handshake.desktopNonce
})
fingerprints.add(
[schedule.mobileToDesktopKey, schedule.desktopToMobileKey, schedule.sessionId]
.map(hex)
.join(':')
)
}
expect(fingerprints.size).toBe(128)
})
})
@@ -0,0 +1,48 @@
import { hkdf } from '@noble/hashes/hkdf'
import { sha256 } from '@noble/hashes/sha256'
const SALT_LABEL = new TextEncoder().encode('orca-mobile-e2ee/v2/salt\0')
const INFO_LABEL = new TextEncoder().encode('orca-mobile-e2ee/v2/session\0')
export function deriveMobileE2EEV2KeySchedule(args: {
sharedSecret: Uint8Array
transcript: Uint8Array
clientNonce: Uint8Array
desktopNonce: Uint8Array
}): {
mobileToDesktopKey: Uint8Array
desktopToMobileKey: Uint8Array
sessionId: Uint8Array
transcriptHash: Uint8Array
} {
requireLength(args.sharedSecret, 32, 'shared secret')
requireLength(args.clientNonce, 32, 'client nonce')
requireLength(args.desktopNonce, 32, 'desktop nonce')
const transcriptHash = sha256(args.transcript)
const salt = sha256(concatBytes([SALT_LABEL, args.clientNonce, args.desktopNonce]))
const info = concatBytes([INFO_LABEL, transcriptHash])
const expanded = hkdf(sha256, args.sharedSecret, salt, info, 96)
return {
mobileToDesktopKey: expanded.slice(0, 32),
desktopToMobileKey: expanded.slice(32, 64),
sessionId: expanded.slice(64, 96),
transcriptHash
}
}
function concatBytes(parts: readonly Uint8Array[]): Uint8Array {
const result = new Uint8Array(parts.reduce((total, part) => total + part.length, 0))
let offset = 0
for (const part of parts) {
result.set(part, offset)
offset += part.length
}
return result
}
function requireLength(bytes: Uint8Array, expected: number, label: string): void {
if (bytes.length !== expected) {
throw new Error(`Invalid ${label}: expected ${expected} bytes, got ${bytes.length}`)
}
}
@@ -0,0 +1,208 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import nacl from 'tweetnacl'
import {
encodeMobileE2EEV2Transcript,
validateMobileE2EEV2Handshake,
type MobileE2EEV2Ready
} from '../../../src/shared/mobile-e2ee-v2-contract'
import {
openMobileE2EEV2Frame,
sealMobileE2EEV2Frame
} from '../../../src/shared/mobile-e2ee-v2-framing'
vi.mock('expo-crypto', () => ({
getRandomBytes: (length: number) => new Uint8Array(length).fill(9)
}))
import { deriveSharedKey } from './e2ee'
import { MobileE2EEV2ClientSession } from './mobile-e2ee-v2-client-session'
import { deriveMobileE2EEV2KeySchedule } from './mobile-e2ee-v2-key-schedule'
import {
MobileE2EEAuthenticationError,
MobileE2EEV2PhysicalChannel,
type MobileE2EEV2Socket
} from './mobile-e2ee-v2-physical-channel'
const desktop = nacl.box.keyPair.fromSecretKey(new Uint8Array(32).fill(1))
const client = nacl.box.keyPair.fromSecretKey(new Uint8Array(32).fill(2))
function setup(decodeBinary: (raw: unknown) => Promise<Uint8Array | null>) {
const session = MobileE2EEV2ClientSession.create({
desktopPublicKeyB64: Buffer.from(desktop.publicKey).toString('base64'),
transport: 'relay',
relayHostId: 'AbCdEf0123_-xyZ9',
clientNonce: new Uint8Array(32).fill(3),
clientKeyPair: client
})
const sent: (string | Uint8Array)[] = []
const socket = {
OPEN: 1,
readyState: 1,
bufferedAmount: 0,
send: (frame: string | Uint8Array) => sent.push(frame)
} satisfies MobileE2EEV2Socket
const events: string[] = []
const onAuthenticated = vi.fn(() => events.push('authenticated'))
const onError = vi.fn()
const channel = new MobileE2EEV2PhysicalChannel({
session,
socket,
deviceToken: 'valid-token',
decodeBinary,
onAuthenticated,
onText: (plaintext) => events.push(`text:${plaintext}`),
onBinary: (plaintext) => events.push(`binary:${plaintext[0]}`),
onError
})
channel.start()
const ready: MobileE2EEV2Ready = {
type: 'e2ee_ready',
v: 2,
desktopPublicKeyB64: Buffer.from(desktop.publicKey).toString('base64'),
clientNonceB64: session.hello.clientNonceB64,
desktopNonceB64: Buffer.from(new Uint8Array(32).fill(4)).toString('base64'),
selection: { framing: 2, payloadKinds: ['text', 'binary'] },
context: session.hello.context
}
const handshake = validateMobileE2EEV2Handshake(session.hello, ready)!
const schedule = deriveMobileE2EEV2KeySchedule({
sharedSecret: deriveSharedKey(desktop.secretKey, client.publicKey),
transcript: encodeMobileE2EEV2Transcript(handshake),
clientNonce: handshake.clientNonce,
desktopNonce: handshake.desktopNonce
})
return { channel, session, socket, sent, events, onAuthenticated, onError, ready, schedule }
}
function serverFrame(
payload: Uint8Array,
kind: 'text' | 'binary',
counter: bigint,
schedule: ReturnType<typeof setup>['schedule']
): Uint8Array {
return sealMobileE2EEV2Frame({
payload,
key: schedule.desktopToMobileKey,
sessionId: schedule.sessionId,
direction: 'desktop-to-mobile',
payloadKind: kind,
counter
})
}
async function authenticate(ctx: ReturnType<typeof setup>): Promise<void> {
await ctx.channel.handleMessage(JSON.stringify(ctx.ready))
const response = serverFrame(
new TextEncoder().encode(
JSON.stringify({
type: 'e2ee_authenticated',
v: 2,
transcriptHashB64: ctx.session.transcriptHashB64
})
),
'text',
0n,
ctx.schedule
)
await ctx.channel.handleMessage(Buffer.from(response).toString('base64'))
}
describe('mobile E2EE v2 physical channel', () => {
beforeEach(() => vi.useFakeTimers())
afterEach(() => vi.useRealTimers())
it('sends hello/auth and confirms the exact transcript', async () => {
const ctx = setup(async () => null)
await authenticate(ctx)
expect(JSON.parse(ctx.sent[0] as string)).toEqual(ctx.session.hello)
expect(typeof ctx.sent[1]).toBe('string')
expect(ctx.onAuthenticated).toHaveBeenCalledOnce()
expect(ctx.onError).not.toHaveBeenCalled()
})
it('classifies the encrypted desktop device-token rejection as global auth failure', async () => {
const ctx = setup(async () => null)
await ctx.channel.handleMessage(JSON.stringify(ctx.ready))
const rejection = serverFrame(
new TextEncoder().encode(
JSON.stringify({ type: 'e2ee_error', error: { code: 'unauthorized' } })
),
'text',
0n,
ctx.schedule
)
await ctx.channel.handleMessage(Buffer.from(rejection).toString('base64'))
expect(ctx.onError.mock.calls[0]![0]).toBeInstanceOf(MobileE2EEAuthenticationError)
expect(ctx.onAuthenticated).not.toHaveBeenCalled()
})
it('serializes delayed binary conversion before a later text counter', async () => {
let releaseBinary!: (bytes: Uint8Array) => void
const pendingBinary = new Promise<Uint8Array>((resolve) => (releaseBinary = resolve))
const ctx = setup(async () => pendingBinary)
await authenticate(ctx)
ctx.events.length = 0
const binary = serverFrame(new Uint8Array([7]), 'binary', 1n, ctx.schedule)
const text = serverFrame(new TextEncoder().encode('later'), 'text', 2n, ctx.schedule)
const first = ctx.channel.handleMessage({ delayedBlob: true })
const second = ctx.channel.handleMessage(Buffer.from(text).toString('base64'))
await Promise.resolve()
expect(ctx.events).toEqual([])
releaseBinary(binary)
await Promise.all([first, second])
expect(ctx.events).toEqual(['binary:7', 'text:later'])
expect(ctx.onError).not.toHaveBeenCalled()
})
it('queues outbound text and binary in one counter order', async () => {
const ctx = setup(async () => null)
await authenticate(ctx)
ctx.socket.bufferedAmount = 9 * 1024 * 1024
expect(ctx.channel.sendText('one')).toBe(true)
expect(ctx.channel.sendBinary(new Uint8Array([2]))).toBe(true)
expect(ctx.sent).toHaveLength(2)
ctx.socket.bufferedAmount = 0
vi.runOnlyPendingTimers()
expect(typeof ctx.sent[2]).toBe('string')
expect(ctx.sent[3]).toBeInstanceOf(Uint8Array)
expect(
openMobileE2EEV2Frame({
frame: Buffer.from(ctx.sent[2] as string, 'base64'),
key: ctx.schedule.mobileToDesktopKey,
sessionId: ctx.schedule.sessionId,
direction: 'mobile-to-desktop',
payloadKind: 'text',
expectedCounter: 1n
})
).toEqual(new TextEncoder().encode('one'))
expect(
openMobileE2EEV2Frame({
frame: ctx.sent[3] as Uint8Array,
key: ctx.schedule.mobileToDesktopKey,
sessionId: ctx.schedule.sessionId,
direction: 'mobile-to-desktop',
payloadKind: 'binary',
expectedCounter: 2n
})
).toEqual(new Uint8Array([2]))
})
it('bounds the unified outbound queue and reports a wedged link', async () => {
const ctx = setup(async () => null)
await authenticate(ctx)
ctx.socket.bufferedAmount = 9 * 1024 * 1024
const megabyte = new Uint8Array(1024 * 1024)
for (let index = 0; index < 65; index++) {
expect(ctx.channel.sendBinary(megabyte)).toBe(true)
}
expect(ctx.onError).toHaveBeenCalledOnce()
expect(ctx.onError.mock.calls[0]![0].message).toBe('E2EE v2 outbound buffer overflow')
})
})
@@ -0,0 +1,185 @@
import {
createWsOutboundBackpressureQueue,
type WsOutboundBackpressureQueue
} from '../../../src/shared/ws-outbound-backpressure-queue'
import type { MobileE2EEV2ClientSession } from './mobile-e2ee-v2-client-session'
type ChannelState = 'awaiting-ready' | 'awaiting-authenticated' | 'ready'
type OutboundItem = { kind: 'text'; plaintext: string } | { kind: 'binary'; plaintext: Uint8Array }
export class MobileE2EEAuthenticationError extends Error {
constructor() {
super('E2EE device authentication rejected')
}
}
export type MobileE2EEV2Socket = {
readonly OPEN: number
readonly readyState: number
readonly bufferedAmount: number
send: (frame: string | Uint8Array) => void
}
export class MobileE2EEV2PhysicalChannel {
private state: ChannelState = 'awaiting-ready'
private generation = 0
private inboundChain: Promise<void> = Promise.resolve()
private readonly outboundQueue: WsOutboundBackpressureQueue<OutboundItem>
constructor(
private readonly args: {
session: MobileE2EEV2ClientSession
socket: MobileE2EEV2Socket
deviceToken: string
decodeBinary: (raw: unknown) => Promise<Uint8Array | null>
onAuthenticated: () => void
onText: (plaintext: string) => void
onBinary: (plaintext: Uint8Array) => void
onError: (error: Error) => void
}
) {
this.outboundQueue = createWsOutboundBackpressureQueue<OutboundItem>({
// Why: encryption happens only when an admitted item reaches the wire,
// so a bounded-queue rejection cannot burn an ordered v2 counter.
send: (item) => {
args.socket.send(
item.kind === 'text'
? args.session.sealText(item.plaintext)
: args.session.sealBinary(item.plaintext)
)
},
byteLengthOf: (item) =>
(item.kind === 'text'
? new TextEncoder().encode(item.plaintext).length
: item.plaintext.length) + 82,
getBufferedAmount: () => args.socket.bufferedAmount,
isWritable: () => args.socket.readyState === args.socket.OPEN,
onOverflow: () => args.onError(new Error('E2EE v2 outbound buffer overflow'))
})
}
start(): void {
this.args.socket.send(JSON.stringify(this.args.session.hello))
}
handleMessage(raw: unknown): Promise<void> {
const generation = this.generation
this.inboundChain = this.inboundChain
.then(() => this.processMessage(raw, generation))
.catch((error: unknown) => {
if (generation === this.generation) {
this.args.onError(error instanceof Error ? error : new Error(String(error)))
}
})
return this.inboundChain
}
sendText(plaintext: string): boolean {
return this.enqueueReady({ kind: 'text', plaintext })
}
sendBinary(plaintext: Uint8Array): boolean {
return this.enqueueReady({ kind: 'binary', plaintext })
}
dispose(): void {
this.generation++
this.outboundQueue.dispose()
}
private async processMessage(raw: unknown, generation: number): Promise<void> {
if (generation !== this.generation) {
return
}
if (this.state === 'awaiting-ready') {
this.acceptReady(raw)
return
}
const plaintext =
typeof raw === 'string'
? this.args.session.openText(raw)
: await this.openBinary(raw, generation)
if (generation !== this.generation || plaintext === null) {
return
}
if (this.state === 'awaiting-authenticated') {
if (typeof plaintext === 'string' && isAuthenticationRejection(plaintext)) {
throw new MobileE2EEAuthenticationError()
}
if (typeof plaintext !== 'string' || !this.isAuthenticated(plaintext)) {
throw new Error('Invalid E2EE v2 authenticated response')
}
this.state = 'ready'
this.args.onAuthenticated()
} else if (typeof plaintext === 'string') {
this.args.onText(plaintext)
} else {
this.args.onBinary(plaintext)
}
}
private acceptReady(raw: unknown): void {
if (typeof raw !== 'string') {
throw new Error('Expected plaintext E2EE v2 ready')
}
let ready: unknown
try {
ready = JSON.parse(raw)
} catch {
throw new Error('Invalid E2EE v2 ready JSON')
}
if (!this.args.session.acceptReady(ready)) {
throw new Error('Invalid E2EE v2 ready')
}
this.state = 'awaiting-authenticated'
this.outboundQueue.enqueue({
kind: 'text',
plaintext: JSON.stringify({
type: 'e2ee_auth',
v: 2,
transcriptHashB64: this.args.session.transcriptHashB64,
deviceToken: this.args.deviceToken
})
})
}
private async openBinary(raw: unknown, generation: number): Promise<Uint8Array | null> {
const bytes = await this.args.decodeBinary(raw)
if (!bytes || generation !== this.generation) {
return null
}
return this.args.session.openBinary(bytes)
}
private isAuthenticated(plaintext: string): boolean {
try {
const message = JSON.parse(plaintext) as Record<string, unknown>
return (
Object.keys(message).sort().join(',') === 'transcriptHashB64,type,v' &&
message.type === 'e2ee_authenticated' &&
message.v === 2 &&
message.transcriptHashB64 === this.args.session.transcriptHashB64
)
} catch {
return false
}
}
private enqueueReady(item: OutboundItem): boolean {
if (this.state !== 'ready') {
return false
}
this.outboundQueue.enqueue(item)
return true
}
}
function isAuthenticationRejection(plaintext: string): boolean {
try {
const message = JSON.parse(plaintext) as Record<string, unknown>
return message.type === 'e2ee_error'
} catch {
return false
}
}
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest'
import { MobileEndpointHysteresis } from './mobile-endpoint-hysteresis'
const options = {
directSuccessesRequired: 3,
directObservationMs: 30_000,
failureCooldownMs: 60_000,
minimumDwellMs: 60_000
}
describe('mobile endpoint hysteresis', () => {
it('requires three authenticated direct successes across the observation and dwell windows', () => {
const policy = new MobileEndpointHysteresis(0, options)
expect(policy.recordDirectSuccess(60_000)).toBe(false)
expect(policy.recordDirectSuccess(75_000)).toBe(false)
expect(policy.recordDirectSuccess(90_000)).toBe(true)
})
it('resets progress and observes cooldown after a failure', () => {
const policy = new MobileEndpointHysteresis(0, options)
policy.recordDirectSuccess(60_000)
policy.recordDirectFailure(61_000)
expect(policy.canProbe(120_999)).toBe(false)
expect(policy.canProbe(121_000)).toBe(true)
expect(policy.recordDirectSuccess(121_000)).toBe(false)
expect(policy.recordDirectSuccess(136_000)).toBe(false)
expect(policy.recordDirectSuccess(151_000)).toBe(true)
})
})
@@ -0,0 +1,52 @@
export type EndpointHysteresisOptions = {
directSuccessesRequired: number
directObservationMs: number
failureCooldownMs: number
minimumDwellMs: number
}
export class MobileEndpointHysteresis {
private consecutiveDirectSuccesses = 0
private directObservationStartedAt: number | null = null
private cooldownUntil = 0
private lastMigrationAt: number
constructor(
startedAt: number,
private readonly options: EndpointHysteresisOptions
) {
this.lastMigrationAt = startedAt
}
recordDirectSuccess(now: number): boolean {
if (now < this.cooldownUntil) {
return false
}
if (this.consecutiveDirectSuccesses === 0) {
this.directObservationStartedAt = now
}
this.consecutiveDirectSuccesses += 1
return (
this.consecutiveDirectSuccesses >= this.options.directSuccessesRequired &&
this.directObservationStartedAt !== null &&
now - this.directObservationStartedAt >= this.options.directObservationMs &&
now - this.lastMigrationAt >= this.options.minimumDwellMs
)
}
recordDirectFailure(now: number): void {
this.consecutiveDirectSuccesses = 0
this.directObservationStartedAt = null
this.cooldownUntil = now + this.options.failureCooldownMs
}
recordMigration(now: number): void {
this.lastMigrationAt = now
this.consecutiveDirectSuccesses = 0
this.directObservationStartedAt = null
}
canProbe(now: number): boolean {
return now >= this.cooldownUntil
}
}
@@ -0,0 +1,98 @@
import * as ExpoCrypto from 'expo-crypto'
import type { ConnectionLogSink, HostProfile } from './types'
import { connect } from './rpc-client'
import { MobileEndpointSupervisor } from './mobile-endpoint-supervisor'
import { connectMobileRelayRpcSession } from './mobile-relay-rpc-session'
import { resolveMobileRelayEndpoint } from './mobile-relay-resume-director'
import {
readMobileRelayCredentialBundle,
writeMobileRelayCredentialBundle
} from './mobile-relay-credential-bundle'
import { saveHost } from './host-store'
import { upgradeDirectMobileRelay } from './mobile-relay-direct-upgrade'
import { MobileRelayDirectUpgradeController } from './mobile-relay-direct-upgrade-controller'
import type { StableLogicalRpcClient } from './stable-logical-rpc-client'
type EndpointLifecycle = {
setForeground(foreground: boolean): void
stop(): void
}
type EndpointOwner = EndpointLifecycle & {
start(): Promise<void>
}
export function startMobileEndpointLifecycle(
logical: StableLogicalRpcClient,
initialHost: HostProfile,
onLog: ConnectionLogSink
): EndpointLifecycle {
let stopped = false
let foreground = true
let owner: EndpointOwner
const startSupervisor = async (host: HostProfile): Promise<void> => {
if (stopped) {
return
}
const supervisor = createSupervisor(logical, host, onLog)
owner.stop()
owner = supervisor
supervisor.setForeground(foreground)
await supervisor.start()
}
if (initialHost.relay) {
owner = createSupervisor(logical, initialHost, onLog)
void owner.start()
} else {
owner = new MobileRelayDirectUpgradeController(logical, initialHost, {
upgrade: (client, host) =>
upgradeDirectMobileRelay({
client,
host,
dependencies: { randomBytes: ExpoCrypto.getRandomBytes }
}),
onUpgraded: ({ host }) => startSupervisor(host)
})
void owner.start()
}
return {
setForeground(next) {
foreground = next
owner.setForeground(next)
},
stop() {
stopped = true
owner.stop()
}
}
}
function createSupervisor(
logical: StableLogicalRpcClient,
host: HostProfile,
onLog: ConnectionLogSink
): MobileEndpointSupervisor {
return new MobileEndpointSupervisor(logical, host, {
openDirect: (endpoint) => connect(endpoint, host.deviceToken, host.publicKeyB64, { onLog }),
openRelay: (relay, credential, confirmReqId) =>
connectMobileRelayRpcSession({
relay,
resumeToken: credential.token,
resumeCredentialVersion: credential.version,
resumeConfirmReqId: confirmReqId,
deviceToken: host.deviceToken,
desktopPublicKeyB64: host.publicKeyB64
}),
resolveRelay: resolveMobileRelayEndpoint,
readBundle: readMobileRelayCredentialBundle,
writeBundle: writeMobileRelayCredentialBundle,
saveHost,
now: Date.now,
randomBytes: ExpoCrypto.getRandomBytes,
setTimer: setTimeout,
clearTimer: clearTimeout
})
}
@@ -0,0 +1,44 @@
import { RelayOuterError } from './mobile-relay-e2ee-link'
import { MobileE2EEAuthenticationError } from './mobile-e2ee-v2-physical-channel'
import type { HostProfile } from './types'
import type { MobileRelayEndpoint } from '../../../src/shared/mobile-relay-credential-contract'
export function isDirectorResolutionFailure(error: Error): boolean {
return (
!(error instanceof MobileE2EEAuthenticationError) &&
(!(error instanceof RelayOuterError) || [4409, 4503, 1006].includes(error.code))
)
}
export function relayWebSocketUrl(relay: { cellUrl: string; relayHostId: string }): string {
const url = new URL(relay.cellUrl)
url.protocol = 'wss:'
url.pathname = `/v1/connect/${encodeURIComponent(relay.relayHostId)}`
return url.toString()
}
export async function persistRelayHost(
host: HostProfile,
relay: MobileRelayEndpoint,
saveHost: (host: HostProfile) => Promise<void>
): Promise<HostProfile> {
const endpoints = [
...(host.endpoints ?? [{ id: 'direct-primary', kind: 'lan' as const, url: host.endpoint }])
].filter(({ kind }) => kind !== 'relay')
endpoints.push({ id: 'relay-primary', kind: 'relay', url: relayWebSocketUrl(relay) })
const updated = { ...host, endpoints, relayHostId: relay.relayHostId, relay }
await saveHost(updated)
return updated
}
export function encodeBase64Url(value: Uint8Array): string {
let binary = ''
for (const byte of value) {
binary += String.fromCharCode(byte)
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
export function toError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error))
}
@@ -0,0 +1,246 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bundle'
import { RelayOuterError } from './mobile-relay-e2ee-link'
import type { MobileRelayRpcSession } from './mobile-relay-rpc-session'
import {
MobileEndpointSupervisor,
type MobileEndpointSupervisorDependencies
} from './mobile-endpoint-supervisor'
import type { RpcClient } from './rpc-client'
import type { MobileConnectionPath, StableLogicalRpcClient } from './stable-logical-rpc-client'
import type { ConnectionState, HostProfile, RpcResponse } from './types'
vi.mock('react-native', () => ({ Platform: { OS: 'ios' } }))
vi.mock('expo-secure-store', () => ({ WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'when-unlocked' }))
vi.mock('expo-crypto', () => ({ getRandomBytes: (length: number) => new Uint8Array(length) }))
class FakeSession implements RpcClient {
readonly sendRequest = vi.fn(
async (): Promise<RpcResponse> => ({
id: 'rpc-1',
ok: true,
result: {},
_meta: { runtimeId: 'runtime-1' }
})
)
readonly subscribe = vi.fn(() => () => {})
readonly updateTerminalSubscriptionViewport = vi.fn()
readonly notifyForeground = vi.fn()
readonly close = vi.fn()
private readonly listeners = new Set<(state: ConnectionState) => void>()
constructor(private state: ConnectionState) {}
getState = () => this.state
getReconnectAttempt = () => 0
getLastConnectedAt = () => null
onStateChange = (listener: (state: ConnectionState) => void) => {
this.listeners.add(listener)
return () => this.listeners.delete(listener)
}
publishState(state: ConnectionState): void {
this.state = state
for (const listener of this.listeners) {
listener(state)
}
}
}
class FakeRelaySession extends FakeSession implements MobileRelayRpcSession {
constructor(
state: ConnectionState,
private readonly failure: Error | null = null,
private readonly lease = Date.now() + 120_000
) {
super(state)
}
getLeaseExpiresAt = () => this.lease
getResumeConfirmation = () => ({
v: 1 as const,
reqId: 'confirm-1',
currentVersion: 2,
acceptedAs: 'current' as const,
renewed: true,
resumeExpiresAt: Date.now() + 300_000
})
getFailure = () => this.failure
}
class FakeLogicalClient extends FakeSession implements StableLogicalRpcClient {
private path: MobileConnectionPath
private generation = 1
constructor(state: ConnectionState, path: MobileConnectionPath) {
super(state)
this.path = path
}
migrateTo = vi.fn(async (session: RpcClient, path: MobileConnectionPath) => {
if (session.getState() !== 'connected') {
session.close()
throw new Error(`replacement session ${session.getState()}`)
}
this.path = path
this.generation += 1
})
suspendActiveSession = vi.fn(() => this.publishState('disconnected'))
getActivePath = () => this.path
getGeneration = () => this.generation
}
const relay = {
v: 1 as const,
directorUrl: 'https://relay.onorca.dev',
cellUrl: 'https://relay-c1.onorca.dev',
assignmentEpoch: 7,
relayHostId: 'AbCdEf0123_-xyZ9',
e2eeFraming: 2 as const
}
const host: HostProfile = {
id: 'host-1',
name: 'Blue Whale',
endpoint: 'ws://192.168.1.10:6768',
deviceToken: 'device-token',
publicKeyB64: 'A'.repeat(44),
lastConnected: 1,
endpoints: [
{ id: 'direct-primary', kind: 'lan', url: 'ws://192.168.1.10:6768' },
{ id: 'relay-primary', kind: 'relay', url: 'wss://relay-c1.onorca.dev/v1/connect/id' }
],
relayHostId: relay.relayHostId,
relay
}
const bundle: MobileRelayCredentialBundle = {
v: 1,
hostId: host.id,
deviceToken: host.deviceToken,
current: {
token: 'A'.repeat(43),
hash: 'B'.repeat(43),
version: 2,
expiresAt: Number.MAX_SAFE_INTEGER
}
}
function dependencies(
overrides: Partial<MobileEndpointSupervisorDependencies> = {}
): MobileEndpointSupervisorDependencies {
return {
openDirect: vi.fn(() => new FakeSession('connected')),
openRelay: vi.fn(() => new FakeRelaySession('connected')),
resolveRelay: vi.fn(async ({ relay }) => relay),
readBundle: vi.fn(async () => bundle),
writeBundle: vi.fn(async () => {}),
saveHost: vi.fn(async () => {}),
now: Date.now,
randomBytes: (length) => new Uint8Array(length).fill(1),
setTimer: setTimeout,
clearTimer: clearTimeout,
...overrides
}
}
describe('mobile endpoint supervisor', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-07-13T12:00:00Z'))
})
afterEach(() => {
vi.useRealTimers()
})
it('fails over to a confirmed relay session and persists its renewed expiry', async () => {
const logical = new FakeLogicalClient('disconnected', 'lan')
const deps = dependencies()
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
expect(logical.migrateTo).toHaveBeenCalledWith(expect.any(FakeRelaySession), 'relay')
expect(logical.getActivePath()).toBe('relay')
expect(deps.writeBundle).toHaveBeenCalledWith(
expect.objectContaining({ current: expect.objectContaining({ version: 2 }) })
)
supervisor.stop()
})
it('fails over when the direct retry loop publishes reconnecting', async () => {
const logical = new FakeLogicalClient('connecting', 'lan')
const deps = dependencies()
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
logical.publishState('reconnecting')
await vi.waitFor(() => expect(logical.getActivePath()).toBe('relay'))
expect(logical.migrateTo).toHaveBeenCalledWith(expect.any(FakeRelaySession), 'relay')
supervisor.stop()
})
it('fails over when direct is already reconnecting before startup completes', async () => {
const logical = new FakeLogicalClient('reconnecting', 'lan')
const deps = dependencies()
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
expect(logical.migrateTo).toHaveBeenCalledWith(expect.any(FakeRelaySession), 'relay')
expect(logical.getActivePath()).toBe('relay')
supervisor.stop()
})
it('uses POST resolve for wrong-cell recovery and persists the authoritative target', async () => {
const logical = new FakeLogicalClient('disconnected', 'lan')
const openRelay = vi
.fn()
.mockReturnValueOnce(new FakeRelaySession('disconnected', new RelayOuterError(4409)))
.mockReturnValueOnce(new FakeRelaySession('connected'))
const resolved = { ...relay, cellUrl: 'https://relay-c2.onorca.dev', assignmentEpoch: 8 }
const deps = dependencies({
openRelay,
resolveRelay: vi.fn(async () => resolved)
})
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
expect(deps.resolveRelay).toHaveBeenCalledOnce()
expect(openRelay).toHaveBeenLastCalledWith(resolved, expect.any(Object), expect.any(String))
expect(deps.saveHost).toHaveBeenCalledWith(
expect.objectContaining({ relay: resolved, endpoint: host.endpoint })
)
supervisor.stop()
})
it('promotes direct only after repeated foreground authenticated probes and dwell', async () => {
const logical = new FakeLogicalClient('connected', 'relay')
const deps = dependencies()
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
await vi.advanceTimersByTimeAsync(45_000)
expect(logical.getActivePath()).toBe('relay')
await vi.advanceTimersByTimeAsync(15_000)
expect(logical.getActivePath()).toBe('lan')
expect(deps.openDirect).toHaveBeenCalledTimes(4)
supervisor.stop()
})
it('releases a background relay session and reconnects it on foreground', async () => {
const logical = new FakeLogicalClient('connected', 'relay')
const deps = dependencies()
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
supervisor.setForeground(false)
expect(logical.suspendActiveSession).toHaveBeenCalledOnce()
expect(logical.getState()).toBe('disconnected')
supervisor.setForeground(true)
await vi.waitFor(() => expect(logical.migrateTo).toHaveBeenCalled())
expect(logical.getActivePath()).toBe('relay')
supervisor.stop()
})
})
@@ -0,0 +1,322 @@
import type { MobileRelayEndpoint } from '../../../src/shared/mobile-relay-credential-contract'
import { openAuthenticatedDirectEndpoint } from './mobile-direct-endpoint-probe'
import { MobileEndpointHysteresis } from './mobile-endpoint-hysteresis'
import {
encodeBase64Url,
isDirectorResolutionFailure,
persistRelayHost,
toError
} from './mobile-endpoint-supervisor-support'
import {
applyResumeConfirmation,
mobileRelayCredentialNeedsRotation,
rotateMobileRelayCredential
} from './mobile-relay-credential-rotation'
import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bundle'
import type { MobileRelayRpcSession } from './mobile-relay-rpc-session'
import { resolveMobileRelayEndpoint } from './mobile-relay-resume-director'
import type { RpcClient } from './rpc-client'
import type { StableLogicalRpcClient } from './stable-logical-rpc-client'
import type { HostProfile } from './types'
const DIRECT_PROBE_INTERVAL_MS = 15_000
const DIRECT_OBSERVATION_MS = 30_000
const MINIMUM_DWELL_MS = 60_000
const FAILURE_COOLDOWN_MS = 60_000
const LEASE_ROTATION_MARGIN_MS = 30_000
export type MobileEndpointSupervisorDependencies = {
openDirect: (endpoint: string) => RpcClient
openRelay: (
relay: MobileRelayEndpoint,
credential: { token: string; version: number },
confirmReqId: string
) => MobileRelayRpcSession
resolveRelay: typeof resolveMobileRelayEndpoint
readBundle: (hostId: string) => Promise<MobileRelayCredentialBundle | null>
writeBundle: (bundle: MobileRelayCredentialBundle) => Promise<void>
saveHost: (host: HostProfile) => Promise<void>
now: () => number
randomBytes: (length: number) => Uint8Array
setTimer: typeof setTimeout
clearTimer: typeof clearTimeout
}
export class MobileEndpointSupervisor {
private host: HostProfile
private bundle: MobileRelayCredentialBundle | null = null
private stopped = false
private foreground = true
private operationInFlight = false
private credentialRotationInFlight = false
private relayRotationPending = false
private probeTimer: ReturnType<typeof setTimeout> | null = null
private leaseTimer: ReturnType<typeof setTimeout> | null = null
private unsubscribeState: (() => void) | null = null
private readonly hysteresis: MobileEndpointHysteresis
constructor(
private readonly logical: StableLogicalRpcClient,
host: HostProfile,
private readonly dependencies: MobileEndpointSupervisorDependencies
) {
this.host = host
this.hysteresis = new MobileEndpointHysteresis(dependencies.now(), {
directSuccessesRequired: 3,
directObservationMs: DIRECT_OBSERVATION_MS,
failureCooldownMs: FAILURE_COOLDOWN_MS,
minimumDwellMs: MINIMUM_DWELL_MS
})
}
async start(): Promise<void> {
this.bundle = await this.dependencies.readBundle(this.host.id).catch(() => null)
if (this.stopped || !this.bundle || !this.host.relay) {
return
}
this.unsubscribeState = this.logical.onStateChange((state) => {
if (state === 'connected') {
if (this.logical.getActivePath() !== 'relay') {
void this.rotateCredentialIfNeeded()
}
this.scheduleDirectProbe()
} else if (state === 'reconnecting' || state === 'disconnected' || state === 'auth-failed') {
// Why: the direct client enters reconnecting after its first failed
// dial and may never publish disconnected while its retry loop lives.
void this.recoverRelay()
}
})
const initialState = this.logical.getState()
if (
initialState === 'reconnecting' ||
initialState === 'disconnected' ||
initialState === 'auth-failed'
) {
// Why: the first direct dial can fail while encrypted relay credentials
// are still loading, before the supervisor subscribes to state changes.
await this.recoverRelay()
} else {
this.scheduleDirectProbe()
}
}
setForeground(foreground: boolean): void {
this.foreground = foreground
if (foreground) {
void this.recoverRelay(this.relayRotationPending)
this.scheduleDirectProbe(0)
} else {
if (this.logical.getActivePath() === 'relay') {
// Why: background phones must not hold billed relay data splices; the
// stable client keeps subscriptions for authenticated foreground replay.
this.logical.suspendActiveSession()
}
if (this.probeTimer) {
this.dependencies.clearTimer(this.probeTimer)
this.probeTimer = null
}
}
}
stop(): void {
this.stopped = true
this.unsubscribeState?.()
this.unsubscribeState = null
if (this.probeTimer) {
this.dependencies.clearTimer(this.probeTimer)
this.probeTimer = null
}
this.clearLeaseTimer()
}
private async recoverRelay(forceReplacement = false): Promise<void> {
if (
this.stopped ||
!this.foreground ||
this.operationInFlight ||
!this.bundle ||
!this.host.relay ||
(!forceReplacement && this.logical.getState() === 'connected')
) {
return
}
this.operationInFlight = true
try {
const credentials = [this.bundle.current, this.bundle.grace].filter(
(credential): credential is NonNullable<typeof credential> =>
Boolean(credential && credential.expiresAt > this.dependencies.now())
)
for (const credential of credentials) {
if (await this.tryRelayCredential(credential)) {
return
}
}
} finally {
this.operationInFlight = false
if (forceReplacement && this.relayRotationPending && !this.stopped && !this.leaseTimer) {
this.leaseTimer = this.dependencies.setTimer(() => {
this.leaseTimer = null
void this.recoverRelay(true)
}, 5000)
}
}
}
private async tryRelayCredential(credential: {
token: string
version: number
}): Promise<boolean> {
const first = await this.openAndMigrateRelay(credential)
if (first.ok) {
return true
}
if (!isDirectorResolutionFailure(first.error) || !this.host.relay) {
return false
}
try {
const resolved = await this.dependencies.resolveRelay({
relay: this.host.relay,
resumeToken: credential.token
})
this.host = await persistRelayHost(this.host, resolved, this.dependencies.saveHost)
return (await this.openAndMigrateRelay(credential)).ok
} catch {
return false
}
}
private async openAndMigrateRelay(credential: {
token: string
version: number
}): Promise<{ ok: true } | { ok: false; error: Error }> {
if (!this.host.relay || !this.bundle) {
return { ok: false, error: new Error('relay state missing') }
}
const session = this.dependencies.openRelay(
this.host.relay,
credential,
`confirm-${encodeBase64Url(this.dependencies.randomBytes(16))}`
)
try {
await this.logical.migrateTo(session, 'relay')
if (!this.foreground) {
this.logical.suspendActiveSession()
}
this.relayRotationPending = false
this.hysteresis.recordMigration(this.dependencies.now())
const confirmation = session.getResumeConfirmation()
if (confirmation) {
this.bundle = applyResumeConfirmation(this.bundle, credential.version, confirmation)
await this.dependencies.writeBundle(this.bundle)
}
this.scheduleLeaseRotation(session)
this.scheduleDirectProbe()
return { ok: true }
} catch (error) {
return { ok: false, error: session.getFailure() ?? toError(error) }
}
}
private scheduleDirectProbe(delayMs = DIRECT_PROBE_INTERVAL_MS): void {
if (
this.stopped ||
!this.foreground ||
this.logical.getActivePath() !== 'relay' ||
this.probeTimer
) {
return
}
this.probeTimer = this.dependencies.setTimer(() => {
this.probeTimer = null
void this.probeDirect()
}, delayMs)
}
private async probeDirect(): Promise<void> {
if (
this.stopped ||
!this.foreground ||
this.operationInFlight ||
!this.hysteresis.canProbe(this.dependencies.now())
) {
this.scheduleDirectProbe()
return
}
this.operationInFlight = true
let successful: Awaited<ReturnType<typeof openAuthenticatedDirectEndpoint>> = null
try {
const openDirect = this.dependencies.openDirect
successful = await openAuthenticatedDirectEndpoint(this.host, openDirect, 12_000)
if (!successful) {
this.hysteresis.recordDirectFailure(this.dependencies.now())
return
}
if (!this.hysteresis.recordDirectSuccess(this.dependencies.now())) {
successful.client.close()
return
}
await this.logical.migrateTo(successful.client, successful.path)
successful = null
this.hysteresis.recordMigration(this.dependencies.now())
this.clearLeaseTimer()
this.relayRotationPending = false
await this.rotateCredentialIfNeeded()
} finally {
successful?.client.close()
this.operationInFlight = false
if (this.relayRotationPending) {
void this.recoverRelay(true)
}
this.scheduleDirectProbe()
}
}
private async rotateCredentialIfNeeded(): Promise<void> {
if (
this.stopped ||
this.credentialRotationInFlight ||
!this.bundle ||
this.logical.getActivePath() === 'relay' ||
!mobileRelayCredentialNeedsRotation(this.bundle, this.dependencies.now())
) {
return
}
this.credentialRotationInFlight = true
try {
const result = await rotateMobileRelayCredential({
client: this.logical,
bundle: this.bundle,
writeBundle: this.dependencies.writeBundle,
randomBytes: this.dependencies.randomBytes
})
this.bundle = result.bundle
this.host = await persistRelayHost(this.host, result.relay, this.dependencies.saveHost)
} catch {
// Why: pending material remains durable; the next authenticated direct
// opportunity must reconcile it before creating another install key.
} finally {
this.credentialRotationInFlight = false
}
}
private scheduleLeaseRotation(session: MobileRelayRpcSession): void {
this.clearLeaseTimer()
const deadline = session.getLeaseExpiresAt()
if (!deadline) {
return
}
const delay = Math.max(1000, deadline - this.dependencies.now() - LEASE_ROTATION_MARGIN_MS)
this.leaseTimer = this.dependencies.setTimer(() => {
this.leaseTimer = null
this.relayRotationPending = true
void this.recoverRelay(true)
}, delay)
}
private clearLeaseTimer(): void {
if (this.leaseTimer) {
this.dependencies.clearTimer(this.leaseTimer)
this.leaseTimer = null
}
}
}
@@ -0,0 +1,118 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const secureStore = vi.hoisted(() => ({
getItemAsync: vi.fn(),
setItemAsync: vi.fn(),
deleteItemAsync: vi.fn()
}))
const platform = vi.hoisted(() => ({ OS: 'ios' }))
vi.mock('expo-secure-store', () => ({
WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'WHEN_UNLOCKED_THIS_DEVICE_ONLY',
...secureStore
}))
vi.mock('react-native', () => ({ Platform: platform }))
import {
deleteMobileRelayCredentialBundle,
promotePairingJournalCredential,
readMobileRelayCredentialBundle,
writeMobileRelayCredentialBundle
} from './mobile-relay-credential-bundle'
import type { MobileRelayPairingJournal } from './mobile-relay-pairing-journal'
const journal = {
metadata: {
v: 1,
journalId: 'pair-1',
offerFingerprint: 'A'.repeat(43),
host: {
id: 'host-1',
name: 'Blue Whale',
endpoint: 'ws://192.168.1.10:6768',
publicKeyB64: 'A'.repeat(44),
lastConnected: 1
},
relay: {
v: 1,
directorUrl: 'https://relay.onorca.dev',
cellUrl: 'https://relay-c1.onorca.dev',
assignmentEpoch: 7,
relayHostId: 'AbCdEf0123_-xyZ9',
inviteExpiresAt: 10_000,
e2eeFraming: 2
},
installReqId: 'install-1',
resumeConfirmReqId: 'confirm-1',
pendingResumeTokenHash: 'B'.repeat(43),
winner: 'direct',
authorizationMode: 'authenticated-direct'
},
secrets: {
v: 1,
journalId: 'pair-1',
deviceToken: 'device-token',
inviteToken: 'C'.repeat(43),
pendingResumeToken: 'D'.repeat(43)
}
} satisfies MobileRelayPairingJournal
describe('mobile relay credential bundle', () => {
let stored: string | null
beforeEach(() => {
vi.clearAllMocks()
platform.OS = 'ios'
stored = null
secureStore.getItemAsync.mockImplementation(async () => stored)
secureStore.setItemAsync.mockImplementation(async (_key: string, value: string) => {
stored = value
})
secureStore.deleteItemAsync.mockImplementation(async () => {
stored = null
})
})
it('promotes only a matching committed install result to current', async () => {
const bundle = promotePairingJournalCredential({
journal,
installed: {
v: 1,
reqId: 'install-1',
authorizationMode: 'authenticated-direct',
currentVersion: 3,
resumeExpiresAt: 50_000
}
})
await writeMobileRelayCredentialBundle(bundle)
await expect(readMobileRelayCredentialBundle('host-1')).resolves.toEqual(bundle)
expect(stored).toContain(journal.secrets.pendingResumeToken)
expect(stored).not.toContain(journal.secrets.inviteToken)
})
it('rejects a result from another request or authorization mode', () => {
expect(() =>
promotePairingJournalCredential({
journal,
installed: {
v: 1,
reqId: 'other-request',
authorizationMode: 'relay-basis',
currentVersion: 1,
resumeExpiresAt: 50_000
}
})
).toThrow(/does not match/)
})
it('deletes the namespaced bundle and never enables it on web', async () => {
await deleteMobileRelayCredentialBundle('host-1')
expect(secureStore.deleteItemAsync).toHaveBeenCalledWith(
'orca.mobile-relay.credentials.host-1',
expect.any(Object)
)
platform.OS = 'web'
await expect(readMobileRelayCredentialBundle('host-1')).rejects.toThrow(/native secret store/)
})
})
@@ -0,0 +1,112 @@
import * as SecureStore from 'expo-secure-store'
import { Platform } from 'react-native'
import { z } from 'zod'
import type { DeviceCredentialInstalled } from '../../../src/shared/mobile-relay-credential-contract'
import type { MobileRelayPairingJournal } from './mobile-relay-pairing-journal'
const Base64Url32ByteSchema = z.string().regex(/^[A-Za-z0-9_-]{43}$/)
const ResumeCredentialSchema = z
.object({
token: Base64Url32ByteSchema,
hash: Base64Url32ByteSchema,
version: z.number().int().positive(),
expiresAt: z.number().int().nonnegative()
})
.strict()
export const MobileRelayCredentialBundleSchema = z
.object({
v: z.literal(1),
hostId: z.string().min(1),
deviceToken: z.string().min(1),
current: ResumeCredentialSchema,
grace: ResumeCredentialSchema.optional(),
pending: z
.object({
token: Base64Url32ByteSchema,
hash: Base64Url32ByteSchema,
reqId: z.string().min(1)
})
.strict()
.optional(),
invite: z
.object({ token: Base64Url32ByteSchema, expiresAt: z.number().int().positive() })
.strict()
.optional()
})
.strict()
export type MobileRelayCredentialBundle = z.infer<typeof MobileRelayCredentialBundleSchema>
const KEYCHAIN_OPTIONS: SecureStore.SecureStoreOptions = {
keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY
}
function credentialKey(hostId: string): string {
return `orca.mobile-relay.credentials.${hostId}`
}
export function promotePairingJournalCredential(args: {
journal: MobileRelayPairingJournal
installed: DeviceCredentialInstalled
}): MobileRelayCredentialBundle {
const { journal, installed } = args
if (
installed.reqId !== journal.metadata.installReqId ||
installed.authorizationMode !== journal.metadata.authorizationMode
) {
throw new Error('relay credential install result does not match pairing journal')
}
return MobileRelayCredentialBundleSchema.parse({
v: 1,
hostId: journal.metadata.host.id,
deviceToken: journal.secrets.deviceToken,
current: {
token: journal.secrets.pendingResumeToken,
hash: journal.metadata.pendingResumeTokenHash,
version: installed.currentVersion,
expiresAt: installed.resumeExpiresAt
}
})
}
export async function readMobileRelayCredentialBundle(
hostId: string
): Promise<MobileRelayCredentialBundle | null> {
requireNativeSecretStore()
const raw = await SecureStore.getItemAsync(credentialKey(hostId), KEYCHAIN_OPTIONS)
if (raw === null) {
return null
}
try {
const result = MobileRelayCredentialBundleSchema.safeParse(JSON.parse(raw))
return result.success && result.data.hostId === hostId ? result.data : null
} catch {
return null
}
}
export async function writeMobileRelayCredentialBundle(
bundle: MobileRelayCredentialBundle
): Promise<void> {
requireNativeSecretStore()
const validated = MobileRelayCredentialBundleSchema.parse(bundle)
await SecureStore.setItemAsync(
credentialKey(validated.hostId),
JSON.stringify(validated),
KEYCHAIN_OPTIONS
)
}
export async function deleteMobileRelayCredentialBundle(hostId: string): Promise<void> {
if (Platform.OS === 'web') {
return
}
await SecureStore.deleteItemAsync(credentialKey(hostId), KEYCHAIN_OPTIONS)
}
function requireNativeSecretStore(): void {
if (Platform.OS === 'web') {
throw new Error('Orca Relay credentials require a native secret store')
}
}
@@ -0,0 +1,10 @@
import { describe, expect, it } from 'vitest'
import { hashMobileRelayCredential } from './mobile-relay-credential-hash'
describe('mobile relay credential hash', () => {
it('hashes the base64url wire token text rather than its decoded bytes', () => {
expect(hashMobileRelayCredential('BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc')).toBe(
'3Ev4DHdHPRMPoN6GukAY_pi7IUAF5qWJHRK6kURvnoE'
)
})
})
@@ -0,0 +1,15 @@
import { sha256 } from '@noble/hashes/sha256'
// Why: the relay stores a digest of the serialized base64url bearer, not the
// random bytes it encodes, so every installer must hash the wire token text.
export function hashMobileRelayCredential(token: string): string {
return encodeBase64Url(sha256(new TextEncoder().encode(token)))
}
function encodeBase64Url(value: Uint8Array): string {
let binary = ''
for (const byte of value) {
binary += String.fromCharCode(byte)
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
@@ -0,0 +1,191 @@
import { describe, expect, it, vi } from 'vitest'
import type { RpcClient } from './rpc-client'
import {
applyResumeConfirmation,
mobileRelayCredentialNeedsRotation,
rotateMobileRelayCredential
} from './mobile-relay-credential-rotation'
import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bundle'
import { hashMobileRelayCredential } from './mobile-relay-credential-hash'
import type { RpcResponse } from './types'
vi.mock('react-native', () => ({ Platform: { OS: 'ios' } }))
vi.mock('expo-secure-store', () => ({ WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'when-unlocked' }))
vi.mock('expo-crypto', () => ({ getRandomBytes: (length: number) => new Uint8Array(length) }))
const relay = {
v: 1 as const,
directorUrl: 'https://relay.onorca.dev',
cellUrl: 'https://relay-c1.onorca.dev',
assignmentEpoch: 7,
relayHostId: 'AbCdEf0123_-xyZ9',
e2eeFraming: 2 as const
}
const bundle: MobileRelayCredentialBundle = {
v: 1,
hostId: 'host-1',
deviceToken: 'device-token',
current: {
token: 'A'.repeat(43),
hash: 'B'.repeat(43),
version: 2,
expiresAt: 50_000
}
}
function success(result: unknown): RpcResponse {
return { id: 'rpc-1', ok: true, result, _meta: { runtimeId: 'runtime-1' } }
}
function install(reqId: string) {
return {
v: 1 as const,
reqId,
authorizationMode: 'authenticated-direct' as const,
currentVersion: 3,
resumeExpiresAt: 100_000,
graceExpiresAt: 70_000
}
}
function fakeClient(responses: RpcResponse[]): RpcClient {
return {
sendRequest: vi.fn(async () => responses.shift()!),
subscribe: vi.fn(() => () => {}),
updateTerminalSubscriptionViewport: vi.fn(),
getState: () => 'connected',
getReconnectAttempt: () => 0,
getLastConnectedAt: () => 1,
onStateChange: () => () => {},
notifyForeground: vi.fn(),
close: vi.fn()
}
}
describe('mobile relay credential rotation', () => {
it('persists pending material before install and promotes only committed status', async () => {
const installed = install('rotate-CAgICAgICAgICAgICAgICA')
const client = fakeClient([
success({ v: 1, relay, installStatus: { v: 1, reqId: installed.reqId, state: 'not-found' } }),
success(installed),
success({
v: 1,
relay,
installStatus: { v: 1, reqId: installed.reqId, state: 'committed', result: installed }
})
])
const writes: MobileRelayCredentialBundle[] = []
const result = await rotateMobileRelayCredential({
client,
bundle,
writeBundle: async (value) => {
writes.push(value)
},
randomBytes: (length) => new Uint8Array(length).fill(length === 32 ? 7 : 8)
})
expect(writes).toHaveLength(2)
expect(writes[0]!.pending).toMatchObject({ reqId: installed.reqId })
expect(writes[0]!.pending!.hash).toBe('3Ev4DHdHPRMPoN6GukAY_pi7IUAF5qWJHRK6kURvnoE')
expect(client.sendRequest).toHaveBeenNthCalledWith(2, 'pairing.provisionRelay', {
reqId: installed.reqId,
newResumeTokenHash: writes[0]!.pending!.hash,
expectedCurrentHash: bundle.current.hash
})
expect(result.bundle).toMatchObject({
current: { version: 3, expiresAt: 100_000 },
grace: { version: 2, expiresAt: 70_000 }
})
expect(result.bundle.pending).toBeUndefined()
})
it('repairs legacy decoded-byte hashes before the normal rotation window', () => {
const now = 1_000
const valid = {
...bundle,
current: {
...bundle.current,
hash: hashMobileRelayCredential(bundle.current.token),
expiresAt: now + 30 * 24 * 60 * 60 * 1000
}
}
expect(mobileRelayCredentialNeedsRotation(valid, now)).toBe(false)
expect(mobileRelayCredentialNeedsRotation(bundle, now)).toBe(true)
expect(
mobileRelayCredentialNeedsRotation(
{ ...valid, pending: { token: 'C'.repeat(43), hash: 'D'.repeat(43), reqId: 'pending' } },
now
)
).toBe(true)
})
it('reconciles a committed lost response without issuing a second install', async () => {
const pendingBundle: MobileRelayCredentialBundle = {
...bundle,
pending: { token: 'C'.repeat(43), hash: 'D'.repeat(43), reqId: 'rotate-existing' }
}
const installed = install('rotate-existing')
const client = fakeClient([
success({
v: 1,
relay,
installStatus: { v: 1, reqId: installed.reqId, state: 'committed', result: installed }
})
])
const writeBundle = vi.fn(async () => {})
await rotateMobileRelayCredential({ client, bundle: pendingBundle, writeBundle })
expect(client.sendRequest).toHaveBeenCalledOnce()
expect(client.sendRequest).toHaveBeenCalledWith('pairing.getEndpoints', {
installReqId: 'rotate-existing'
})
expect(writeBundle).toHaveBeenCalledOnce()
})
it('applies only authoritative current or grace confirmation expiries', () => {
const withGrace: MobileRelayCredentialBundle = {
...bundle,
grace: { token: 'C'.repeat(43), hash: 'D'.repeat(43), version: 1, expiresAt: 40_000 }
}
const renewed = applyResumeConfirmation(withGrace, 2, {
v: 1,
reqId: 'confirm-current',
currentVersion: 2,
acceptedAs: 'current',
renewed: true,
resumeExpiresAt: 120_000,
graceExpiresAt: 40_000
})
const grace = applyResumeConfirmation(renewed, 1, {
v: 1,
reqId: 'confirm-grace',
currentVersion: 2,
acceptedAs: 'grace',
renewed: false,
resumeExpiresAt: 120_000,
graceExpiresAt: 45_000
})
expect(renewed.current.expiresAt).toBe(120_000)
expect(grace.grace?.expiresAt).toBe(45_000)
expect(applyResumeConfirmation(grace, 99, { ...graceConfirmation(), reqId: 'other' })).toBe(
grace
)
})
})
function graceConfirmation() {
return {
v: 1 as const,
reqId: 'confirm',
currentVersion: 2,
acceptedAs: 'grace' as const,
renewed: false,
resumeExpiresAt: 120_000,
graceExpiresAt: 45_000
}
}
@@ -0,0 +1,147 @@
import * as ExpoCrypto from 'expo-crypto'
import {
DeviceCredentialInstalledSchema,
PairingGetEndpointsResultSchema,
type DeviceResumeConfirmed,
type MobileRelayEndpoint
} from '../../../src/shared/mobile-relay-credential-contract'
import {
MobileRelayCredentialBundleSchema,
type MobileRelayCredentialBundle
} from './mobile-relay-credential-bundle'
import { hashMobileRelayCredential } from './mobile-relay-credential-hash'
import type { RpcClient } from './rpc-client'
const CREDENTIAL_ROTATION_WINDOW_MS = 7 * 24 * 60 * 60 * 1000
type RotationResult = {
bundle: MobileRelayCredentialBundle
relay: MobileRelayEndpoint
}
export async function rotateMobileRelayCredential(args: {
client: RpcClient
bundle: MobileRelayCredentialBundle
writeBundle: (bundle: MobileRelayCredentialBundle) => Promise<void>
randomBytes?: (length: number) => Uint8Array
}): Promise<RotationResult> {
let bundle = args.bundle
if (!bundle.pending) {
const randomBytes = args.randomBytes ?? ExpoCrypto.getRandomBytes
const token = encodeBase64Url(randomBytes(32))
bundle = MobileRelayCredentialBundleSchema.parse({
...bundle,
pending: {
token,
hash: hashMobileRelayCredential(token),
reqId: `rotate-${encodeBase64Url(randomBytes(16))}`
}
})
// Why: a crash or lost response must leave enough material to query the
// one global install key before any second authorization attempt.
await args.writeBundle(bundle)
}
const pending = bundle.pending
if (!pending) {
throw new Error('relay credential rotation pending state missing')
}
let endpoints = await getEndpoints(args.client, pending.reqId)
if (endpoints.installStatus?.state !== 'committed') {
const response = await args.client.sendRequest('pairing.provisionRelay', {
reqId: pending.reqId,
newResumeTokenHash: pending.hash,
expectedCurrentHash: bundle.current.hash
})
if (!response.ok) {
throw new Error(`${response.error.code}: ${response.error.message}`)
}
const installed = DeviceCredentialInstalledSchema.parse(response.result)
endpoints = await getEndpoints(args.client, pending.reqId)
if (
endpoints.installStatus?.state !== 'committed' ||
JSON.stringify(endpoints.installStatus.result) !== JSON.stringify(installed)
) {
throw new Error('relay credential rotation was not authoritatively committed')
}
}
if (!endpoints.relay || endpoints.installStatus?.state !== 'committed') {
throw new Error('relay credential rotation endpoint state missing')
}
const installed = endpoints.installStatus.result
const next = MobileRelayCredentialBundleSchema.parse({
...bundle,
current: {
token: pending.token,
hash: pending.hash,
version: installed.currentVersion,
expiresAt: installed.resumeExpiresAt
},
...(installed.graceExpiresAt
? { grace: { ...bundle.current, expiresAt: installed.graceExpiresAt } }
: { grace: undefined }),
pending: undefined
})
await args.writeBundle(next)
return { bundle: next, relay: endpoints.relay }
}
export function mobileRelayCredentialNeedsRotation(
bundle: MobileRelayCredentialBundle,
now: number
): boolean {
// Why: pre-release clients hashed decoded random bytes. Direct connectivity
// can safely replace that unusable cloud credential using its stored hash.
const malformedCurrentHash =
bundle.current.hash !== hashMobileRelayCredential(bundle.current.token)
return (
Boolean(bundle.pending) ||
malformedCurrentHash ||
bundle.current.expiresAt - now <= CREDENTIAL_ROTATION_WINDOW_MS
)
}
export function applyResumeConfirmation(
bundle: MobileRelayCredentialBundle,
usedCredentialVersion: number,
confirmation: DeviceResumeConfirmed
): MobileRelayCredentialBundle {
if (
confirmation.acceptedAs === 'current' &&
confirmation.renewed &&
bundle.current.version === usedCredentialVersion &&
confirmation.currentVersion === usedCredentialVersion
) {
return MobileRelayCredentialBundleSchema.parse({
...bundle,
current: { ...bundle.current, expiresAt: confirmation.resumeExpiresAt }
})
}
if (
confirmation.acceptedAs === 'grace' &&
bundle.grace?.version === usedCredentialVersion &&
confirmation.graceExpiresAt
) {
return MobileRelayCredentialBundleSchema.parse({
...bundle,
grace: { ...bundle.grace, expiresAt: confirmation.graceExpiresAt }
})
}
return bundle
}
async function getEndpoints(client: RpcClient, installReqId: string) {
const response = await client.sendRequest('pairing.getEndpoints', { installReqId })
if (!response.ok) {
throw new Error(`${response.error.code}: ${response.error.message}`)
}
return PairingGetEndpointsResultSchema.parse(response.result)
}
function encodeBase64Url(value: Uint8Array): string {
let binary = ''
for (const byte of value) {
binary += String.fromCharCode(byte)
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
@@ -0,0 +1,118 @@
import { describe, expect, it, vi } from 'vitest'
import { MobileRelayDirectUpgradeController } from './mobile-relay-direct-upgrade-controller'
import type { MobileRelayDirectUpgradeResult } from './mobile-relay-direct-upgrade'
import type { StableLogicalRpcClient } from './stable-logical-rpc-client'
import type { ConnectionState, HostProfile } from './types'
const directHost: HostProfile = {
id: 'host-direct',
name: 'Host 4',
endpoint: 'ws://192.168.1.2:6768',
deviceToken: 'device-token',
publicKeyB64: 'A'.repeat(44),
lastConnected: 1
}
const upgraded = {
host: {
...directHost,
relayHostId: 'AbCdEf0123_-xyZ9',
relay: {
v: 1 as const,
directorUrl: 'https://relay-staging.onorca.dev',
cellUrl: 'https://c1.relay-staging.onorca.dev',
assignmentEpoch: 4,
relayHostId: 'AbCdEf0123_-xyZ9',
e2eeFraming: 2 as const
}
},
bundle: {
v: 1 as const,
hostId: directHost.id,
deviceToken: directHost.deviceToken,
current: {
token: 'A'.repeat(43),
hash: 'B'.repeat(43),
version: 1,
expiresAt: 99_999_999
}
}
} satisfies MobileRelayDirectUpgradeResult
function logicalClient(initial: ConnectionState) {
let state = initial
const listeners = new Set<(state: ConnectionState) => void>()
return {
client: {
getState: () => state,
onStateChange: (listener: (next: ConnectionState) => void) => {
listeners.add(listener)
return () => listeners.delete(listener)
}
} as unknown as StableLogicalRpcClient,
setState(next: ConnectionState) {
state = next
for (const listener of listeners) {
listener(next)
}
}
}
}
describe('direct pairing upgrade controller', () => {
it('upgrades immediately after an authenticated direct connection', async () => {
const logical = logicalClient('connected')
const upgrade = vi.fn(async () => upgraded)
const onUpgraded = vi.fn(async () => {})
const controller = new MobileRelayDirectUpgradeController(logical.client, directHost, {
upgrade,
onUpgraded
})
await controller.start()
expect(upgrade).toHaveBeenCalledWith(logical.client, directHost)
expect(onUpgraded).toHaveBeenCalledWith(upgraded)
})
it('retries a deferred upgrade on the next foreground transition', async () => {
const logical = logicalClient('connected')
const upgrade = vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(upgraded)
const onUpgraded = vi.fn(async () => {})
const controller = new MobileRelayDirectUpgradeController(logical.client, directHost, {
upgrade,
onUpgraded
})
await controller.start()
controller.setForeground(false)
controller.setForeground(true)
await vi.waitFor(() => expect(onUpgraded).toHaveBeenCalledOnce())
expect(upgrade).toHaveBeenCalledTimes(2)
})
it('fences a completed request after the host client closes', async () => {
const logical = logicalClient('connecting')
let resolveUpgrade!: (result: MobileRelayDirectUpgradeResult) => void
const upgrade = vi.fn(
() =>
new Promise<MobileRelayDirectUpgradeResult>((resolve) => {
resolveUpgrade = resolve
})
)
const onUpgraded = vi.fn(async () => {})
const controller = new MobileRelayDirectUpgradeController(logical.client, directHost, {
upgrade,
onUpgraded
})
await controller.start()
logical.setState('connected')
controller.stop()
resolveUpgrade(upgraded)
await Promise.resolve()
expect(onUpgraded).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,69 @@
import type { MobileRelayDirectUpgradeResult } from './mobile-relay-direct-upgrade'
import type { StableLogicalRpcClient } from './stable-logical-rpc-client'
import type { HostProfile } from './types'
type Dependencies = {
upgrade: (
client: StableLogicalRpcClient,
host: HostProfile
) => Promise<MobileRelayDirectUpgradeResult | null>
onUpgraded: (result: MobileRelayDirectUpgradeResult) => Promise<void>
}
export class MobileRelayDirectUpgradeController {
private foreground = true
private stopped = false
private inFlight = false
private unsubscribe: (() => void) | null = null
constructor(
private readonly logical: StableLogicalRpcClient,
private readonly host: HostProfile,
private readonly dependencies: Dependencies
) {}
async start(): Promise<void> {
this.unsubscribe = this.logical.onStateChange((state) => {
if (state === 'connected') {
void this.tryUpgrade()
}
})
if (this.logical.getState() === 'connected') {
await this.tryUpgrade()
}
}
setForeground(foreground: boolean): void {
this.foreground = foreground
if (foreground && this.logical.getState() === 'connected') {
void this.tryUpgrade()
}
}
stop(): void {
this.stopped = true
this.unsubscribe?.()
this.unsubscribe = null
}
private async tryUpgrade(): Promise<void> {
if (this.stopped || !this.foreground || this.inFlight) {
return
}
this.inFlight = true
try {
const result = await this.dependencies.upgrade(this.logical, this.host)
if (!result || this.stopped) {
return
}
this.unsubscribe?.()
this.unsubscribe = null
await this.dependencies.onUpgraded(result)
} catch {
// Why: the journal survives transient auth/control failure; retry only on
// the next authenticated reconnect or foreground transition.
} finally {
this.inFlight = false
}
}
}
@@ -0,0 +1,89 @@
import * as SecureStore from 'expo-secure-store'
import { Platform } from 'react-native'
import { z } from 'zod'
import { hashMobileRelayCredential } from './mobile-relay-credential-hash'
const Base64Url32ByteSchema = z.string().regex(/^[A-Za-z0-9_-]{43}$/)
export const MobileRelayDirectUpgradeJournalSchema = z
.object({
v: z.literal(1),
hostId: z.string().min(1),
reqId: z.string().min(1).max(128),
pendingResumeToken: Base64Url32ByteSchema,
pendingResumeTokenHash: Base64Url32ByteSchema
})
.strict()
export type MobileRelayDirectUpgradeJournal = z.infer<typeof MobileRelayDirectUpgradeJournalSchema>
const KEYCHAIN_OPTIONS: SecureStore.SecureStoreOptions = {
keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY
}
function journalKey(hostId: string): string {
return `orca.mobile-relay.direct-upgrade.${hostId}`
}
export function createMobileRelayDirectUpgradeJournal(
hostId: string,
randomBytes: (length: number) => Uint8Array
): MobileRelayDirectUpgradeJournal {
const pendingResumeToken = encodeBase64Url(randomBytes(32))
return MobileRelayDirectUpgradeJournalSchema.parse({
v: 1,
hostId,
reqId: `upgrade-${encodeBase64Url(randomBytes(16))}`,
pendingResumeToken,
pendingResumeTokenHash: hashMobileRelayCredential(pendingResumeToken)
})
}
export async function readMobileRelayDirectUpgradeJournal(
hostId: string
): Promise<MobileRelayDirectUpgradeJournal | null> {
requireNativeSecretStore()
const raw = await SecureStore.getItemAsync(journalKey(hostId), KEYCHAIN_OPTIONS)
if (!raw) {
return null
}
try {
const parsed = MobileRelayDirectUpgradeJournalSchema.safeParse(JSON.parse(raw))
return parsed.success && parsed.data.hostId === hostId ? parsed.data : null
} catch {
return null
}
}
export async function writeMobileRelayDirectUpgradeJournal(
journal: MobileRelayDirectUpgradeJournal
): Promise<void> {
requireNativeSecretStore()
const parsed = MobileRelayDirectUpgradeJournalSchema.parse(journal)
await SecureStore.setItemAsync(
journalKey(parsed.hostId),
JSON.stringify(parsed),
KEYCHAIN_OPTIONS
)
}
export async function deleteMobileRelayDirectUpgradeJournal(hostId: string): Promise<void> {
if (Platform.OS === 'web') {
return
}
await SecureStore.deleteItemAsync(journalKey(hostId), KEYCHAIN_OPTIONS)
}
function encodeBase64Url(value: Uint8Array): string {
let binary = ''
for (const byte of value) {
binary += String.fromCharCode(byte)
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
function requireNativeSecretStore(): void {
if (Platform.OS === 'web') {
throw new Error('Orca Relay upgrade state requires a native secret store')
}
}
@@ -0,0 +1,193 @@
import { describe, expect, it, vi } from 'vitest'
import type { MobileRelayEndpoint } from '../../../src/shared/mobile-relay-credential-contract'
import { MobileRelayUpgradeHostRemovedError } from './host-store'
import {
createMobileRelayDirectUpgradeJournal,
type MobileRelayDirectUpgradeJournal
} from './mobile-relay-direct-upgrade-journal'
import { upgradeDirectMobileRelay } from './mobile-relay-direct-upgrade'
import type { RpcClient } from './rpc-client'
import type { HostProfile, RpcResponse } from './types'
vi.mock('react-native', () => ({ Platform: { OS: 'ios' } }))
vi.mock('expo-secure-store', () => ({ WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'when-unlocked' }))
vi.mock('expo-crypto', () => ({ getRandomBytes: (length: number) => new Uint8Array(length) }))
const relay: MobileRelayEndpoint = {
v: 1,
directorUrl: 'https://relay-staging.onorca.dev',
cellUrl: 'https://c1.relay-staging.onorca.dev',
assignmentEpoch: 4,
relayHostId: 'AbCdEf0123_-xyZ9',
e2eeFraming: 2
}
const host: HostProfile = {
id: 'host-direct',
name: 'Host 4',
endpoint: 'ws://192.168.1.2:6768',
deviceToken: 'device-token',
publicKeyB64: 'A'.repeat(44),
lastConnected: 1
}
function success(result: unknown): RpcResponse {
return { id: 'rpc', ok: true, result, _meta: { runtimeId: 'runtime' } }
}
function clientWith(responses: RpcResponse[]) {
return {
sendRequest: vi.fn(async () => responses.shift()!),
getState: () => 'connected'
} as unknown as RpcClient
}
function installed(journal: MobileRelayDirectUpgradeJournal) {
return {
v: 1 as const,
reqId: journal.reqId,
authorizationMode: 'authenticated-direct' as const,
currentVersion: 1,
resumeExpiresAt: 9_999_999
}
}
function dependencies(journal: MobileRelayDirectUpgradeJournal | null = null) {
let stored = journal
return {
readJournal: vi.fn(async () => stored),
writeJournal: vi.fn(async (next: MobileRelayDirectUpgradeJournal) => {
stored = next
}),
clearJournal: vi.fn(async () => {
stored = null
}),
writeBundle: vi.fn(async () => {}),
deleteBundle: vi.fn(async () => {}),
saveHost: vi.fn(async () => {}),
randomBytes: (length: number) => new Uint8Array(length).fill(7)
}
}
describe('existing direct pairing relay upgrade', () => {
it('persists pending material before install and publishes only after committed status', async () => {
const deps = dependencies()
let journal: MobileRelayDirectUpgradeJournal | null = null
deps.writeJournal.mockImplementation(async (next) => {
journal = next
})
const client = clientWith([
success({ v: 1, relay }),
success({
v: 1,
reqId: 'upgrade-BwcHBwcHBwcHBwcHBwcHBw',
authorizationMode: 'authenticated-direct',
currentVersion: 1,
resumeExpiresAt: 9_999_999
}),
success({
v: 1,
relay,
installStatus: {
v: 1,
reqId: 'upgrade-BwcHBwcHBwcHBwcHBwcHBw',
state: 'committed',
result: {
v: 1,
reqId: 'upgrade-BwcHBwcHBwcHBwcHBwcHBw',
authorizationMode: 'authenticated-direct',
currentVersion: 1,
resumeExpiresAt: 9_999_999
}
}
})
])
const result = await upgradeDirectMobileRelay({ client, host, dependencies: deps })
expect(journal).not.toBeNull()
expect(deps.writeJournal.mock.invocationCallOrder[0]).toBeLessThan(
client.sendRequest.mock.invocationCallOrder[0]!
)
expect(client.sendRequest).toHaveBeenNthCalledWith(2, 'pairing.provisionRelay', {
reqId: journal!.reqId,
newResumeTokenHash: journal!.pendingResumeTokenHash
})
expect(deps.writeBundle).toHaveBeenCalledBefore(deps.saveHost)
expect(result?.host.relay).toEqual(relay)
expect(deps.clearJournal).toHaveBeenCalledWith(host.id)
})
it('recovers an already committed install without authorizing a second one', async () => {
const journal = createMobileRelayDirectUpgradeJournal(host.id, (length) =>
new Uint8Array(length).fill(3)
)
const committed = installed(journal)
const deps = dependencies(journal)
const client = clientWith([
success({
v: 1,
relay,
installStatus: { v: 1, reqId: journal.reqId, state: 'committed', result: committed }
})
])
const result = await upgradeDirectMobileRelay({ client, host, dependencies: deps })
expect(client.sendRequest).toHaveBeenCalledOnce()
expect(result?.bundle.current.version).toBe(1)
expect(deps.writeBundle).toHaveBeenCalledOnce()
})
it('cleans pending state and leaves direct access unchanged for an old desktop', async () => {
const deps = dependencies()
const client = clientWith([
{
id: 'rpc',
ok: false,
error: { code: 'method_not_found', message: 'unsupported' },
_meta: { runtimeId: 'runtime' }
}
])
await expect(upgradeDirectMobileRelay({ client, host, dependencies: deps })).resolves.toBeNull()
expect(deps.clearJournal).toHaveBeenCalledWith(host.id)
expect(deps.writeBundle).not.toHaveBeenCalled()
expect(deps.saveHost).not.toHaveBeenCalled()
})
it('retains the durable journal when relay registration is temporarily unavailable', async () => {
const deps = dependencies()
const client = clientWith([success({ v: 1, relay: null })])
await expect(upgradeDirectMobileRelay({ client, host, dependencies: deps })).rejects.toThrow(
'relay endpoint unavailable'
)
expect(deps.writeJournal).toHaveBeenCalledOnce()
expect(deps.clearJournal).not.toHaveBeenCalled()
})
it('cleans newly installed secrets instead of resurrecting a removed host', async () => {
const journal = createMobileRelayDirectUpgradeJournal(host.id, (length) =>
new Uint8Array(length).fill(5)
)
const committed = installed(journal)
const deps = dependencies(journal)
deps.saveHost.mockRejectedValue(
new MobileRelayUpgradeHostRemovedError('mobile relay upgrade host was removed')
)
const client = clientWith([
success({
v: 1,
relay,
installStatus: { v: 1, reqId: journal.reqId, state: 'committed', result: committed }
})
])
await expect(
upgradeDirectMobileRelay({ client, host, dependencies: deps })
).rejects.toBeInstanceOf(MobileRelayUpgradeHostRemovedError)
expect(deps.deleteBundle).toHaveBeenCalledWith(host.id)
expect(deps.clearJournal).toHaveBeenCalledWith(host.id)
})
})
@@ -0,0 +1,175 @@
import * as ExpoCrypto from 'expo-crypto'
import {
DeviceCredentialInstalledSchema,
PairingGetEndpointsResultSchema,
type DeviceCredentialInstalled,
type PairingGetEndpointsResult
} from '../../../src/shared/mobile-relay-credential-contract'
import { MobileRelayUpgradeHostRemovedError, saveExistingHostRelayUpgrade } from './host-store'
import { persistRelayHost } from './mobile-endpoint-supervisor-support'
import {
MobileRelayCredentialBundleSchema,
deleteMobileRelayCredentialBundle,
writeMobileRelayCredentialBundle,
type MobileRelayCredentialBundle
} from './mobile-relay-credential-bundle'
import {
createMobileRelayDirectUpgradeJournal,
deleteMobileRelayDirectUpgradeJournal,
readMobileRelayDirectUpgradeJournal,
writeMobileRelayDirectUpgradeJournal,
type MobileRelayDirectUpgradeJournal
} from './mobile-relay-direct-upgrade-journal'
import type { RpcClient } from './rpc-client'
import type { HostProfile, RpcResponse } from './types'
export type MobileRelayDirectUpgradeResult = {
host: HostProfile
bundle: MobileRelayCredentialBundle
}
type Dependencies = {
readJournal: typeof readMobileRelayDirectUpgradeJournal
writeJournal: typeof writeMobileRelayDirectUpgradeJournal
clearJournal: typeof deleteMobileRelayDirectUpgradeJournal
writeBundle: typeof writeMobileRelayCredentialBundle
saveHost: typeof saveExistingHostRelayUpgrade
deleteBundle: typeof deleteMobileRelayCredentialBundle
randomBytes: (length: number) => Uint8Array
}
export async function upgradeDirectMobileRelay(args: {
client: RpcClient
host: HostProfile
dependencies?: Partial<Dependencies>
}): Promise<MobileRelayDirectUpgradeResult | null> {
if (args.host.relay) {
return null
}
const dependencies: Dependencies = {
readJournal: readMobileRelayDirectUpgradeJournal,
writeJournal: writeMobileRelayDirectUpgradeJournal,
clearJournal: deleteMobileRelayDirectUpgradeJournal,
writeBundle: writeMobileRelayCredentialBundle,
saveHost: saveExistingHostRelayUpgrade,
deleteBundle: deleteMobileRelayCredentialBundle,
randomBytes: ExpoCrypto.getRandomBytes,
...args.dependencies
}
let journal = await dependencies.readJournal(args.host.id)
if (!journal) {
journal = createMobileRelayDirectUpgradeJournal(args.host.id, dependencies.randomBytes)
// Why: the stable reqId and pending secret must survive a lost install response.
await dependencies.writeJournal(journal)
}
const initial = await getEndpoints(args.client, journal.reqId)
if (initial === 'method-not-found') {
await dependencies.clearJournal(args.host.id)
return null
}
if (initial.installStatus?.state === 'committed') {
return publishCommitted(args.host, journal, initial, dependencies)
}
if (!initial.relay) {
throw new Error('relay endpoint unavailable for direct pairing upgrade')
}
const provisionResponse = await args.client.sendRequest('pairing.provisionRelay', {
reqId: journal.reqId,
newResumeTokenHash: journal.pendingResumeTokenHash
})
if (isMethodNotFound(provisionResponse)) {
await dependencies.clearJournal(args.host.id)
return null
}
const installed = DeviceCredentialInstalledSchema.parse(requireSuccess(provisionResponse))
assertDirectInstall(journal, installed)
const reconciled = await getEndpoints(args.client, journal.reqId)
if (reconciled === 'method-not-found') {
throw new Error('relay endpoint reconciliation became unavailable')
}
assertCommitted(reconciled, installed)
return publishCommitted(args.host, journal, reconciled, dependencies)
}
async function publishCommitted(
host: HostProfile,
journal: MobileRelayDirectUpgradeJournal,
endpoints: PairingGetEndpointsResult,
dependencies: Dependencies
): Promise<MobileRelayDirectUpgradeResult> {
if (endpoints.installStatus?.state !== 'committed' || !endpoints.relay) {
throw new Error('direct pairing upgrade was not authoritatively committed')
}
const installed = endpoints.installStatus.result
assertDirectInstall(journal, installed)
const bundle = MobileRelayCredentialBundleSchema.parse({
v: 1,
hostId: host.id,
deviceToken: host.deviceToken,
current: {
token: journal.pendingResumeToken,
hash: journal.pendingResumeTokenHash,
version: installed.currentVersion,
expiresAt: installed.resumeExpiresAt
}
})
// Why: the overlay must never advertise relay without its matching credential.
await dependencies.writeBundle(bundle)
let updatedHost: HostProfile
try {
updatedHost = await persistRelayHost(host, endpoints.relay, dependencies.saveHost)
} catch (error) {
if (error instanceof MobileRelayUpgradeHostRemovedError) {
await dependencies.deleteBundle(host.id)
await dependencies.clearJournal(host.id)
}
throw error
}
await dependencies.clearJournal(host.id)
return { host: updatedHost, bundle }
}
async function getEndpoints(
client: RpcClient,
installReqId: string
): Promise<PairingGetEndpointsResult | 'method-not-found'> {
const response = await client.sendRequest('pairing.getEndpoints', { installReqId })
if (isMethodNotFound(response)) {
return 'method-not-found'
}
return PairingGetEndpointsResultSchema.parse(requireSuccess(response))
}
function assertDirectInstall(
journal: MobileRelayDirectUpgradeJournal,
installed: DeviceCredentialInstalled
): void {
if (installed.reqId !== journal.reqId || installed.authorizationMode !== 'authenticated-direct') {
throw new Error('relay credential install does not match direct upgrade journal')
}
}
function assertCommitted(
endpoints: PairingGetEndpointsResult,
installed: DeviceCredentialInstalled
): void {
if (
endpoints.installStatus?.state !== 'committed' ||
JSON.stringify(endpoints.installStatus.result) !== JSON.stringify(installed)
) {
throw new Error('relay credential install was not authoritatively reconciled')
}
}
function requireSuccess(response: RpcResponse): unknown {
if (!response.ok) {
throw new Error(`${response.error.code}: ${response.error.message}`)
}
return response.result
}
function isMethodNotFound(response: RpcResponse): boolean {
return !response.ok && response.error.code === 'method_not_found'
}
@@ -0,0 +1,151 @@
import {
RelayPhoneHelloSchema,
type RelayPhoneHello
} from '../../../src/shared/mobile-relay-phone-protocol'
import { MobileE2EEV2ClientSession } from './mobile-e2ee-v2-client-session'
import { MobileE2EEV2PhysicalChannel } from './mobile-e2ee-v2-physical-channel'
import { websocketPayloadToUint8 } from './websocket-payload-bytes'
export class RelayOuterError extends Error {
constructor(readonly code: number) {
super(`relay_outer_${code}`)
}
}
type MobileRelayE2eeLinkOptions = {
endpoint: { cellUrl: string; relayHostId: string }
credential: string
expectedCredentialKind: 'invite' | 'resume'
deviceToken: string
desktopPublicKeyB64: string
onAuthenticated: () => void
onText: (plaintext: string) => void
onBinary: (plaintext: Uint8Array) => void
onHello?: (hello: Extract<RelayPhoneHello, { ok: true }>) => void
onError: (error: Error) => void
createSocket?: (url: string) => WebSocket
}
export class MobileRelayE2eeLink {
private readonly options: MobileRelayE2eeLinkOptions
private readonly socket: WebSocket
private readonly channel: MobileE2EEV2PhysicalChannel
private outerReady = false
private closed = false
private inboundChain: Promise<void> = Promise.resolve()
constructor(options: MobileRelayE2eeLinkOptions) {
this.options = options
this.socket = (options.createSocket ?? ((url) => new WebSocket(url)))(
relaySocketUrl(options.endpoint)
)
const session = MobileE2EEV2ClientSession.create({
desktopPublicKeyB64: options.desktopPublicKeyB64,
transport: 'relay',
relayHostId: options.endpoint.relayHostId
})
this.channel = new MobileE2EEV2PhysicalChannel({
session,
socket: this.socket,
deviceToken: options.deviceToken,
decodeBinary: websocketPayloadToUint8,
onAuthenticated: options.onAuthenticated,
onText: options.onText,
onBinary: options.onBinary,
onError: (error) => this.fail(error)
})
this.bindSocket()
}
sendText(plaintext: string): boolean {
return !this.closed && this.channel.sendText(plaintext)
}
sendBinary(plaintext: Uint8Array): boolean {
return !this.closed && this.channel.sendBinary(plaintext)
}
close(): void {
if (this.closed) {
return
}
this.closed = true
this.channel.dispose()
this.socket.close()
}
private bindSocket(): void {
this.socket.onopen = () => {
this.socket.send(
JSON.stringify({
type: 'relay-auth',
v: 1,
mode: 'connect',
credential: this.options.credential
})
)
}
this.socket.onmessage = (event) => {
this.inboundChain = this.inboundChain
.then(async () => {
if (this.closed) {
return
}
if (!this.outerReady) {
this.acceptHello(event.data)
} else {
await this.channel.handleMessage(event.data)
}
})
.catch((error: unknown) => this.fail(asError(error)))
}
this.socket.onerror = () => this.fail(new Error('relay transport error'))
this.socket.onclose = (event) => this.fail(new RelayOuterError(event.code || 1006))
}
private acceptHello(raw: unknown): void {
if (typeof raw !== 'string') {
throw new Error('expected plaintext relay hello')
}
let value: unknown
try {
value = JSON.parse(raw)
} catch {
throw new Error('invalid relay hello JSON')
}
const parsed = RelayPhoneHelloSchema.safeParse(value)
if (!parsed.success) {
throw new Error('invalid relay hello')
}
if (!parsed.data.ok) {
throw new RelayOuterError(parsed.data.code)
}
if (parsed.data.credentialKind !== this.options.expectedCredentialKind) {
throw new Error('relay credential resolved as an unexpected credential kind')
}
this.outerReady = true
this.options.onHello?.(parsed.data)
this.channel.start()
}
private fail(error: Error): void {
if (this.closed) {
return
}
this.closed = true
this.channel.dispose()
this.options.onError(error)
this.socket.close()
}
}
function relaySocketUrl(endpoint: { cellUrl: string; relayHostId: string }): string {
const url = new URL(endpoint.cellUrl)
url.protocol = 'wss:'
url.pathname = `/v1/connect/${encodeURIComponent(endpoint.relayHostId)}`
return url.toString()
}
function asError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error))
}
@@ -0,0 +1,80 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const asyncStorage = vi.hoisted(() => ({
getItem: vi.fn(),
setItem: vi.fn()
}))
vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage }))
import {
loadMobileRelayHostOverlays,
resetMobileRelayHostOverlayStoreForTests,
saveMobileRelayHostOverlay
} from './mobile-relay-host-overlay-store'
import type { MobileRelayHostOverlay } from './mobile-relay-host-overlay'
const STORAGE_KEY = 'orca:mobile-relay:host-overlays:v2'
const OVERLAY: MobileRelayHostOverlay = {
v: 2,
hostId: 'host-1',
endpoints: [
{ id: 'direct-primary', kind: 'lan', url: 'ws://192.168.1.10:6768' },
{
id: 'relay-primary',
kind: 'relay',
url: 'wss://relay-c1.onorca.dev/v1/connect/AbCdEf0123_-xyZ9'
}
],
relayHostId: 'AbCdEf0123_-xyZ9',
relay: {
v: 1,
directorUrl: 'https://relay.onorca.dev',
cellUrl: 'https://relay-c1.onorca.dev',
assignmentEpoch: 7,
relayHostId: 'AbCdEf0123_-xyZ9',
e2eeFraming: 2
}
}
describe('mobile relay host overlay store', () => {
let stored: string | null
beforeEach(() => {
vi.clearAllMocks()
resetMobileRelayHostOverlayStoreForTests()
stored = null
asyncStorage.getItem.mockImplementation(async (key: string) =>
key === STORAGE_KEY ? stored : null
)
asyncStorage.setItem.mockImplementation(async (key: string, value: string) => {
if (key === STORAGE_KEY) {
stored = value
}
})
})
it('round-trips v2 metadata in a namespace legacy builds do not rewrite', async () => {
await saveMobileRelayHostOverlay(OVERLAY)
await expect(loadMobileRelayHostOverlays(new Set(['host-1']))).resolves.toEqual(
new Map([['host-1', OVERLAY]])
)
expect(asyncStorage.setItem).toHaveBeenCalledWith(STORAGE_KEY, expect.any(String))
})
it('never overlays or resurrects a host whose legacy base was removed', async () => {
stored = JSON.stringify([OVERLAY])
await expect(loadMobileRelayHostOverlays(new Set())).resolves.toEqual(new Map())
expect(asyncStorage.setItem).not.toHaveBeenCalled()
expect(JSON.parse(stored)).toEqual([OVERLAY])
})
it('refuses to overwrite unreadable overlay storage', async () => {
stored = '{'
await expect(saveMobileRelayHostOverlay(OVERLAY)).rejects.toThrow(/unreadable/)
expect(asyncStorage.setItem).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,94 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import {
MobileRelayHostOverlaySchema,
type MobileRelayHostOverlay
} from './mobile-relay-host-overlay'
const OVERLAY_STORAGE_KEY = 'orca:mobile-relay:host-overlays:v2'
let overlayMutation: Promise<void> = Promise.resolve()
function parseOverlays(raw: string | null): MobileRelayHostOverlay[] | null {
if (raw === null) {
return []
}
try {
const value = JSON.parse(raw) as unknown
if (!Array.isArray(value)) {
return null
}
return value.flatMap((item) => {
const result = MobileRelayHostOverlaySchema.safeParse(item)
return result.success ? [result.data] : []
})
} catch {
return null
}
}
async function readOverlaysForMutation(): Promise<MobileRelayHostOverlay[]> {
const overlays = parseOverlays(await AsyncStorage.getItem(OVERLAY_STORAGE_KEY))
if (!overlays) {
// Why: never rewrite an unreadable v2 namespace as an empty list; doing so
// would destroy relay recovery data during an unrelated host mutation.
throw new Error('mobile relay host overlay storage unreadable')
}
return overlays
}
async function mutateOverlays(
update: (overlays: MobileRelayHostOverlay[]) => MobileRelayHostOverlay[]
): Promise<void> {
const mutation = overlayMutation.then(async () => {
const current = await readOverlaysForMutation()
await AsyncStorage.setItem(OVERLAY_STORAGE_KEY, JSON.stringify(update(current)))
})
overlayMutation = mutation.catch(() => {})
return mutation
}
export async function loadMobileRelayHostOverlays(
existingHostIds: ReadonlySet<string>
): Promise<Map<string, MobileRelayHostOverlay>> {
return (await loadMobileRelayHostOverlayState(existingHostIds)).overlays
}
export async function loadMobileRelayHostOverlayState(
existingHostIds: ReadonlySet<string>
): Promise<{ overlays: Map<string, MobileRelayHostOverlay>; orphanHostIds: string[] }> {
await overlayMutation
const overlays = parseOverlays(await AsyncStorage.getItem(OVERLAY_STORAGE_KEY)) ?? []
const active = new Map<string, MobileRelayHostOverlay>()
const orphanHostIds: string[] = []
for (const overlay of overlays) {
// Why: an older app can remove the legacy base without knowing this
// namespace; never let the retained overlay resurrect that host later.
if (existingHostIds.has(overlay.hostId)) {
active.set(overlay.hostId, overlay)
} else {
orphanHostIds.push(overlay.hostId)
}
}
return { overlays: active, orphanHostIds }
}
export async function saveMobileRelayHostOverlay(overlay: MobileRelayHostOverlay): Promise<void> {
const validated = MobileRelayHostOverlaySchema.parse(overlay)
return mutateOverlays((overlays) => {
const index = overlays.findIndex(({ hostId }) => hostId === validated.hostId)
if (index < 0) {
return [...overlays, validated]
}
const next = overlays.slice()
next[index] = validated
return next
})
}
export function removeMobileRelayHostOverlay(hostId: string): Promise<void> {
return mutateOverlays((overlays) => overlays.filter((overlay) => overlay.hostId !== hostId))
}
/** Test-only: drain the module mutation chain between cases. */
export function resetMobileRelayHostOverlayStoreForTests(): void {
overlayMutation = Promise.resolve()
}
@@ -0,0 +1,47 @@
import { z } from 'zod'
import { MobileRelayEndpointSchema } from '../../../src/shared/mobile-relay-credential-contract'
export const MobileAccessEndpointSchema = z
.object({
id: z.string().min(1).max(128),
kind: z.enum(['lan', 'tailscale', 'relay']),
url: z.string().min(1).max(2048)
})
.strict()
export const MobileRelayHostOverlaySchema = z
.object({
v: z.literal(2),
hostId: z.string().min(1),
endpoints: z.array(MobileAccessEndpointSchema).min(1).max(16),
relayHostId: z
.string()
.regex(/^[A-Za-z0-9_-]{16}$/)
.optional(),
relay: MobileRelayEndpointSchema.optional()
})
.strict()
.superRefine((overlay, context) => {
if ((overlay.relayHostId === undefined) !== (overlay.relay === undefined)) {
context.addIssue({ code: 'custom', message: 'Relay identity and endpoint must coexist' })
return
}
if (overlay.relay && overlay.relay.relayHostId !== overlay.relayHostId) {
context.addIssue({
code: 'custom',
path: ['relayHostId'],
message: 'Relay host identity mismatch'
})
}
const relayEndpointCount = overlay.endpoints.filter(({ kind }) => kind === 'relay').length
if (relayEndpointCount !== (overlay.relay ? 1 : 0)) {
context.addIssue({
code: 'custom',
path: ['endpoints'],
message: 'Expected exactly one endpoint for configured relay metadata'
})
}
})
export type MobileAccessEndpoint = z.infer<typeof MobileAccessEndpointSchema>
export type MobileRelayHostOverlay = z.infer<typeof MobileRelayHostOverlaySchema>
@@ -0,0 +1,80 @@
import { describe, expect, it } from 'vitest'
import { resolvePairingInviteThroughDirector } from './mobile-relay-invite-director'
class FakeSocket {
sent: string[] = []
onopen: (() => void) | null = null
onmessage: ((event: { data: unknown }) => void) | null = null
onerror: (() => void) | null = null
onclose: ((event: { code: number }) => void) | null = null
send(value: string): void {
this.sent.push(value)
}
close(): void {}
}
const relay = {
v: 1 as const,
directorUrl: 'https://relay.onorca.dev',
cellUrl: 'https://relay-c1.onorca.dev',
assignmentEpoch: 7,
relayHostId: 'AbCdEf0123_-xyZ9',
inviteToken: 'abcdefghijklmnopqrstuvwxyzABCDEFGH012345678',
inviteExpiresAt: Date.now() + 300_000,
e2eeFraming: 2 as const
}
describe('pairing invite director resolution', () => {
it('authenticates only to the configured director and accepts a strictly newer move', async () => {
const socket = new FakeSocket()
let url = ''
const resolving = resolvePairingInviteThroughDirector({
relay,
createSocket: (value) => {
url = value
return socket as unknown as WebSocket
}
})
socket.onopen?.()
expect(url).toBe('wss://relay.onorca.dev/v1/connect/AbCdEf0123_-xyZ9')
expect(url).not.toContain('?')
expect(JSON.parse(socket.sent[0]!)).toEqual({
type: 'relay-auth',
v: 1,
mode: 'connect',
credential: relay.inviteToken
})
socket.onmessage?.({
data: JSON.stringify({
type: 'relay-moved',
v: 1,
cellUrl: 'https://relay-c2.onorca.dev',
assignmentEpoch: 8
})
})
await expect(resolving).resolves.toMatchObject({
cellUrl: 'https://relay-c2.onorca.dev',
assignmentEpoch: 8
})
})
it('rejects same/older epochs and untrusted extra fields', async () => {
const socket = new FakeSocket()
const resolving = resolvePairingInviteThroughDirector({
relay,
createSocket: () => socket as unknown as WebSocket
})
socket.onmessage?.({
data: JSON.stringify({
type: 'relay-moved',
v: 1,
cellUrl: 'https://relay-c2.onorca.dev',
assignmentEpoch: 7,
targetFromCell: true
})
})
await expect(resolving).rejects.toThrow(/not strictly newer/)
})
})
@@ -0,0 +1,78 @@
import type { PairingRelay } from '../../../src/shared/mobile-relay-pairing-offer'
import { RelayMovedSchema } from '../../../src/shared/mobile-relay-phone-protocol'
export function resolvePairingInviteThroughDirector(args: {
relay: PairingRelay
timeoutMs?: number
createSocket?: (url: string) => WebSocket
}): Promise<PairingRelay> {
const socket = (args.createSocket ?? ((url) => new WebSocket(url)))(
directorWebSocketUrl(args.relay)
)
return new Promise((resolve, reject) => {
let settled = false
const timeout = setTimeout(
() => finish(new Error('relay director resolution timed out')),
args.timeoutMs ?? 5_000
)
socket.onopen = () => {
socket.send(
JSON.stringify({
type: 'relay-auth',
v: 1,
mode: 'connect',
credential: args.relay.inviteToken
})
)
}
socket.onmessage = (event) => {
if (typeof event.data !== 'string') {
finish(new Error('invalid relay director move'))
return
}
let value: unknown
try {
value = JSON.parse(event.data)
} catch {
finish(new Error('invalid relay director move'))
return
}
const moved = RelayMovedSchema.safeParse(value)
if (!moved.success || moved.data.assignmentEpoch <= args.relay.assignmentEpoch) {
finish(new Error('relay director move was not strictly newer'))
return
}
settled = true
clearTimeout(timeout)
socket.close()
resolve({
...args.relay,
cellUrl: moved.data.cellUrl,
assignmentEpoch: moved.data.assignmentEpoch
})
}
socket.onerror = () => finish(new Error('relay director transport error'))
socket.onclose = (event) => {
if (!settled) {
finish(new Error(`relay director closed before move: ${event.code || 1006}`))
}
}
function finish(error: Error): void {
if (settled) {
return
}
settled = true
clearTimeout(timeout)
socket.close()
reject(error)
}
})
}
export function directorWebSocketUrl(relay: PairingRelay): string {
const url = new URL(relay.directorUrl)
url.protocol = 'wss:'
url.pathname = `/v1/connect/${encodeURIComponent(relay.relayHostId)}`
return url.toString()
}
@@ -0,0 +1,39 @@
import { describe, expect, it, vi } from 'vitest'
import { scheduleOrphanedMobileRelayCleanup } from './mobile-relay-orphan-cleanup'
describe('mobile relay orphan cleanup', () => {
it('durably schedules credential deletion before removing an orphan overlay pointer', async () => {
const order: string[] = []
const deleteCredential = vi.fn(async () => {})
const scheduleCleanup = vi.fn(async (hostId: string) => {
order.push(`schedule:${hostId}`)
})
const removeOverlay = vi.fn(async (hostId: string) => {
order.push(`overlay:${hostId}`)
})
await scheduleOrphanedMobileRelayCleanup({
hostIds: ['host-1', 'host-1'],
deleteCredential,
scheduleCleanup,
removeOverlay
})
expect(order).toEqual(['schedule:host-1', 'overlay:host-1'])
expect(scheduleCleanup).toHaveBeenCalledWith('host-1', deleteCredential)
})
it('retains the overlay pointer when durable cleanup scheduling fails', async () => {
const removeOverlay = vi.fn(async () => {})
await scheduleOrphanedMobileRelayCleanup({
hostIds: ['host-1'],
deleteCredential: vi.fn(async () => {}),
scheduleCleanup: vi.fn(async () => {
throw new Error('storage unavailable')
}),
removeOverlay
})
expect(removeOverlay).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,22 @@
import { scheduleHostCredentialCleanup } from './host-credential-cleanup'
import { removeMobileRelayHostOverlay } from './mobile-relay-host-overlay-store'
export async function scheduleOrphanedMobileRelayCleanup(args: {
hostIds: string[]
deleteCredential: (hostId: string) => Promise<void>
scheduleCleanup?: typeof scheduleHostCredentialCleanup
removeOverlay?: typeof removeMobileRelayHostOverlay
}): Promise<void> {
const scheduleCleanup = args.scheduleCleanup ?? scheduleHostCredentialCleanup
const removeOverlay = args.removeOverlay ?? removeMobileRelayHostOverlay
for (const hostId of new Set(args.hostIds)) {
try {
// Why: an older build may remove the legacy host while retaining the v2
// namespace; persist keychain cleanup intent before dropping that pointer.
await scheduleCleanup(hostId, args.deleteCredential)
await removeOverlay(hostId)
} catch {
// Retain the overlay pointer if durable cleanup intent could not be recorded.
}
}
}
@@ -0,0 +1,173 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const asyncStorage = vi.hoisted(() => ({
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn()
}))
const secureStore = vi.hoisted(() => ({
getItemAsync: vi.fn(),
setItemAsync: vi.fn(),
deleteItemAsync: vi.fn()
}))
const platform = vi.hoisted(() => ({ OS: 'ios' }))
vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage }))
vi.mock('expo-secure-store', () => ({
WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'WHEN_UNLOCKED_THIS_DEVICE_ONLY',
...secureStore
}))
vi.mock('expo-crypto', () => ({ getRandomBytes: vi.fn() }))
vi.mock('react-native', () => ({ Platform: platform }))
import { createMobileRelayPairingJournal } from './mobile-relay-pairing-journal'
import {
clearMobileRelayPairingJournal,
loadMobileRelayPairingJournal,
resetMobileRelayPairingJournalStoreForTests,
saveMobileRelayPairingJournal,
updateMobileRelayPairingJournal
} from './mobile-relay-pairing-journal-store'
import type { PairingOffer } from './types'
const now = Date.UTC(2026, 6, 13)
const offer = {
v: 2,
endpoint: 'ws://192.168.1.10:6768',
deviceToken: 'device-token-secret',
publicKeyB64: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
relay: {
v: 1,
directorUrl: 'https://relay.onorca.dev',
cellUrl: 'https://relay-c1.onorca.dev',
assignmentEpoch: 7,
relayHostId: 'AbCdEf0123_-xyZ9',
inviteToken: 'abcdefghijklmnopqrstuvwxyzABCDEFGH012345678',
inviteExpiresAt: now + 300_000,
e2eeFraming: 2
}
} satisfies PairingOffer
describe('mobile relay pairing journal store', () => {
let metadataRaw: string | null
let secretRaw: string | null
beforeEach(() => {
vi.clearAllMocks()
resetMobileRelayPairingJournalStoreForTests()
platform.OS = 'ios'
metadataRaw = null
secretRaw = null
asyncStorage.getItem.mockImplementation(async () => metadataRaw)
asyncStorage.setItem.mockImplementation(async (_key: string, value: string) => {
metadataRaw = value
})
asyncStorage.removeItem.mockImplementation(async () => {
metadataRaw = null
})
secureStore.getItemAsync.mockImplementation(async () => secretRaw)
secureStore.setItemAsync.mockImplementation(async (_key: string, value: string) => {
secretRaw = value
})
secureStore.deleteItemAsync.mockImplementation(async () => {
secretRaw = null
})
})
it('persists metadata before secrets and never places bearers in AsyncStorage', async () => {
const journal = createMobileRelayPairingJournal({
offer: offer as PairingOffer & { relay: NonNullable<PairingOffer['relay']> },
hostId: 'host-1',
hostName: 'Blue Whale',
now,
randomBytes: (length) => new Uint8Array(length).fill(length)
})
await saveMobileRelayPairingJournal(journal)
expect(asyncStorage.setItem.mock.invocationCallOrder[0]).toBeLessThan(
secureStore.setItemAsync.mock.invocationCallOrder[0]!
)
expect(metadataRaw).not.toContain(offer.deviceToken)
expect(metadataRaw).not.toContain(offer.relay.inviteToken)
expect(metadataRaw).not.toContain(journal.secrets.pendingResumeToken)
await expect(loadMobileRelayPairingJournal()).resolves.toEqual(journal)
})
it('records a provisional winner only for the active journal identity', async () => {
const journal = createMobileRelayPairingJournal({
offer: offer as PairingOffer & { relay: NonNullable<PairingOffer['relay']> },
hostId: 'host-1',
hostName: 'Blue Whale',
randomBytes: (length) => new Uint8Array(length).fill(7)
})
await saveMobileRelayPairingJournal(journal)
await updateMobileRelayPairingJournal(journal.metadata.journalId, (metadata) => ({
...metadata,
winner: 'direct',
authorizationMode: 'authenticated-direct'
}))
await expect(
updateMobileRelayPairingJournal('stale-journal', (metadata) => metadata)
).rejects.toThrow(/stale/)
expect(JSON.parse(metadataRaw!)).toMatchObject({
winner: 'direct',
authorizationMode: 'authenticated-direct'
})
})
it('treats metadata without its secret record as an incomplete crash', async () => {
const journal = createMobileRelayPairingJournal({
offer: offer as PairingOffer & { relay: NonNullable<PairingOffer['relay']> },
hostId: 'host-1',
hostName: 'Blue Whale',
randomBytes: (length) => new Uint8Array(length).fill(9)
})
metadataRaw = JSON.stringify(journal.metadata)
await expect(loadMobileRelayPairingJournal()).resolves.toBeNull()
expect(metadataRaw).toBeNull()
})
it('cleans mismatched secret records and refuses to replace a recoverable journal', async () => {
const journal = createMobileRelayPairingJournal({
offer: offer as PairingOffer & { relay: NonNullable<PairingOffer['relay']> },
hostId: 'host-1',
hostName: 'Blue Whale',
randomBytes: (length) => new Uint8Array(length).fill(10)
})
metadataRaw = JSON.stringify(journal.metadata)
secretRaw = JSON.stringify({ ...journal.secrets, journalId: 'different-journal' })
await expect(loadMobileRelayPairingJournal()).resolves.toBeNull()
expect(metadataRaw).toBeNull()
expect(secretRaw).toBeNull()
await saveMobileRelayPairingJournal(journal)
const replacement = createMobileRelayPairingJournal({
offer: offer as PairingOffer & { relay: NonNullable<PairingOffer['relay']> },
hostId: 'host-2',
hostName: 'Red Panda',
randomBytes: (length) => new Uint8Array(length).fill(12)
})
await expect(saveMobileRelayPairingJournal(replacement)).rejects.toThrow(/recovery pending/)
await expect(loadMobileRelayPairingJournal()).resolves.toEqual(journal)
})
it('clears metadata before deleting its secret and keeps relay unavailable on web', async () => {
const journal = createMobileRelayPairingJournal({
offer: offer as PairingOffer & { relay: NonNullable<PairingOffer['relay']> },
hostId: 'host-1',
hostName: 'Blue Whale',
randomBytes: (length) => new Uint8Array(length).fill(11)
})
await saveMobileRelayPairingJournal(journal)
await clearMobileRelayPairingJournal(journal.metadata.journalId)
expect(asyncStorage.removeItem.mock.invocationCallOrder[0]).toBeLessThan(
secureStore.deleteItemAsync.mock.invocationCallOrder[0]!
)
platform.OS = 'web'
await expect(saveMobileRelayPairingJournal(journal)).rejects.toThrow(/native secret store/)
})
})
@@ -0,0 +1,136 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import * as SecureStore from 'expo-secure-store'
import { Platform } from 'react-native'
import {
MobileRelayPairingJournalMetadataSchema,
MobileRelayPairingJournalSecretsSchema,
type MobileRelayPairingJournal,
type MobileRelayPairingJournalMetadata
} from './mobile-relay-pairing-journal'
const JOURNAL_STORAGE_KEY = 'orca:mobile-relay:pairing-journal:v1'
const JOURNAL_SECRET_KEY = 'orca.mobile-relay.pairing-journal.v1'
const KEYCHAIN_OPTIONS: SecureStore.SecureStoreOptions = {
keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY
}
let journalMutation: Promise<void> = Promise.resolve()
export async function saveMobileRelayPairingJournal(
journal: MobileRelayPairingJournal
): Promise<void> {
requireNativeSecretStore()
const metadata = MobileRelayPairingJournalMetadataSchema.parse(journal.metadata)
const secrets = MobileRelayPairingJournalSecretsSchema.parse(journal.secrets)
if (metadata.journalId !== secrets.journalId) {
throw new Error('mobile relay pairing journal identity mismatch')
}
const mutation = journalMutation.then(async () => {
const existingRaw = await AsyncStorage.getItem(JOURNAL_STORAGE_KEY)
const existing = existingRaw ? parseMetadata(existingRaw) : null
if (existing && existing.journalId !== metadata.journalId) {
throw new Error('mobile relay pairing recovery pending')
}
// Why: metadata-first makes a crash before the keychain write recover as
// an incomplete journal, never as an untracked bearer secret.
await AsyncStorage.setItem(JOURNAL_STORAGE_KEY, JSON.stringify(metadata))
await SecureStore.setItemAsync(JOURNAL_SECRET_KEY, JSON.stringify(secrets), KEYCHAIN_OPTIONS)
})
journalMutation = mutation.catch(() => {})
return mutation
}
export async function loadMobileRelayPairingJournal(): Promise<MobileRelayPairingJournal | null> {
requireNativeSecretStore()
await journalMutation
const rawMetadata = await AsyncStorage.getItem(JOURNAL_STORAGE_KEY)
if (rawMetadata === null) {
await SecureStore.deleteItemAsync(JOURNAL_SECRET_KEY, KEYCHAIN_OPTIONS).catch(() => {})
return null
}
const metadata = parseMetadata(rawMetadata)
if (!metadata) {
await removeIncompleteJournal()
return null
}
const rawSecrets = await SecureStore.getItemAsync(JOURNAL_SECRET_KEY, KEYCHAIN_OPTIONS)
if (rawSecrets === null) {
await AsyncStorage.removeItem(JOURNAL_STORAGE_KEY)
return null
}
const secrets = parseSecrets(rawSecrets)
if (!secrets || secrets.journalId !== metadata.journalId) {
await removeIncompleteJournal()
return null
}
return { metadata, secrets }
}
async function removeIncompleteJournal(): Promise<void> {
// Why: metadata is the discoverable cleanup pointer; remove it before the
// native secret so a second crash can only leave a self-cleaning orphan.
await AsyncStorage.removeItem(JOURNAL_STORAGE_KEY)
await SecureStore.deleteItemAsync(JOURNAL_SECRET_KEY, KEYCHAIN_OPTIONS).catch(() => {})
}
export async function updateMobileRelayPairingJournal(
journalId: string,
update: (metadata: MobileRelayPairingJournalMetadata) => MobileRelayPairingJournalMetadata
): Promise<void> {
const mutation = journalMutation.then(async () => {
const raw = await AsyncStorage.getItem(JOURNAL_STORAGE_KEY)
const current = raw ? parseMetadata(raw) : null
if (!current || current.journalId !== journalId) {
throw new Error('stale mobile relay pairing journal')
}
const next = MobileRelayPairingJournalMetadataSchema.parse(update(current))
if (next.journalId !== journalId) {
throw new Error('mobile relay pairing journal identity mismatch')
}
await AsyncStorage.setItem(JOURNAL_STORAGE_KEY, JSON.stringify(next))
})
journalMutation = mutation.catch(() => {})
return mutation
}
export async function clearMobileRelayPairingJournal(journalId: string): Promise<void> {
const mutation = journalMutation.then(async () => {
const raw = await AsyncStorage.getItem(JOURNAL_STORAGE_KEY)
const current = raw ? parseMetadata(raw) : null
if (current && current.journalId !== journalId) {
throw new Error('stale mobile relay pairing journal')
}
await AsyncStorage.removeItem(JOURNAL_STORAGE_KEY)
await SecureStore.deleteItemAsync(JOURNAL_SECRET_KEY, KEYCHAIN_OPTIONS)
})
journalMutation = mutation.catch(() => {})
return mutation
}
function parseMetadata(raw: string): MobileRelayPairingJournalMetadata | null {
try {
const result = MobileRelayPairingJournalMetadataSchema.safeParse(JSON.parse(raw))
return result.success ? result.data : null
} catch {
return null
}
}
function parseSecrets(raw: string) {
try {
const result = MobileRelayPairingJournalSecretsSchema.safeParse(JSON.parse(raw))
return result.success ? result.data : null
} catch {
return null
}
}
function requireNativeSecretStore(): void {
if (Platform.OS === 'web') {
throw new Error('Orca Relay pairing requires a native secret store')
}
}
/** Test-only: drain the module mutation chain between cases. */
export function resetMobileRelayPairingJournalStoreForTests(): void {
journalMutation = Promise.resolve()
}
@@ -0,0 +1,111 @@
import * as ExpoCrypto from 'expo-crypto'
import { sha256 } from '@noble/hashes/sha256'
import { z } from 'zod'
import type { PairingRelay } from '../../../src/shared/mobile-relay-pairing-offer'
import { hashMobileRelayCredential } from './mobile-relay-credential-hash'
import type { PairingOffer } from './types'
const Base64Url32ByteSchema = z.string().regex(/^[A-Za-z0-9_-]{43}$/)
export const MobileRelayPairingJournalMetadataSchema = z
.object({
v: z.literal(1),
journalId: z.string().min(1).max(128),
offerFingerprint: Base64Url32ByteSchema,
host: z
.object({
id: z.string().min(1),
name: z.string().min(1),
endpoint: z.string().min(1),
publicKeyB64: z.string().min(1),
lastConnected: z.number().int().nonnegative()
})
.strict(),
relay: z
.object({
v: z.literal(1),
directorUrl: z.string().min(1),
cellUrl: z.string().min(1),
assignmentEpoch: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER),
relayHostId: z.string().regex(/^[A-Za-z0-9_-]{16}$/),
inviteExpiresAt: z.number().int().positive(),
e2eeFraming: z.literal(2)
})
.strict(),
installReqId: z.string().min(1).max(128),
resumeConfirmReqId: z.string().min(1).max(128),
pendingResumeTokenHash: Base64Url32ByteSchema,
winner: z.enum(['direct', 'relay']).optional(),
authorizationMode: z.enum(['authenticated-direct', 'relay-basis']).optional()
})
.strict()
export const MobileRelayPairingJournalSecretsSchema = z
.object({
v: z.literal(1),
journalId: z.string().min(1).max(128),
deviceToken: z.string().min(1),
inviteToken: Base64Url32ByteSchema,
pendingResumeToken: Base64Url32ByteSchema
})
.strict()
export type MobileRelayPairingJournalMetadata = z.infer<
typeof MobileRelayPairingJournalMetadataSchema
>
export type MobileRelayPairingJournalSecrets = z.infer<
typeof MobileRelayPairingJournalSecretsSchema
>
export type MobileRelayPairingJournal = {
metadata: MobileRelayPairingJournalMetadata
secrets: MobileRelayPairingJournalSecrets
}
export function createMobileRelayPairingJournal(args: {
offer: PairingOffer & { relay: PairingRelay }
hostId: string
hostName: string
now?: number
randomBytes?: (length: number) => Uint8Array
}): MobileRelayPairingJournal {
const randomBytes = args.randomBytes ?? ExpoCrypto.getRandomBytes
const pendingResumeToken = encodeBase64Url(randomBytes(32))
const journalId = `pair-${encodeBase64Url(randomBytes(16))}`
const installReqId = `install-${encodeBase64Url(randomBytes(16))}`
const resumeConfirmReqId = `confirm-${encodeBase64Url(randomBytes(16))}`
const { inviteToken, ...relayMetadata } = args.offer.relay
return {
metadata: MobileRelayPairingJournalMetadataSchema.parse({
v: 1,
journalId,
offerFingerprint: encodeBase64Url(sha256(JSON.stringify(args.offer))),
host: {
id: args.hostId,
name: args.hostName,
endpoint: args.offer.endpoint,
publicKeyB64: args.offer.publicKeyB64,
lastConnected: args.now ?? Date.now()
},
relay: relayMetadata,
installReqId,
resumeConfirmReqId,
pendingResumeTokenHash: hashMobileRelayCredential(pendingResumeToken)
}),
secrets: {
v: 1,
journalId,
deviceToken: args.offer.deviceToken,
inviteToken,
pendingResumeToken
}
}
}
function encodeBase64Url(value: Uint8Array | string): string {
const bytes = typeof value === 'string' ? new TextEncoder().encode(value) : value
let binary = ''
for (const byte of bytes) {
binary += String.fromCharCode(byte)
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
@@ -0,0 +1,27 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
createMobileRelayPairingFixtures,
encodePairingFixturePayload
} from '../../../src/shared/mobile-relay-pairing-fixtures'
import { decodePairingUrl } from './pairing'
describe('mobile relay pairing contract', () => {
const now = Date.UTC(2026, 6, 12, 16)
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(now)
})
afterEach(() => {
vi.useRealTimers()
})
for (const fixture of createMobileRelayPairingFixtures(now)) {
it(fixture.name, () => {
expect(decodePairingUrl(encodePairingFixturePayload(fixture.payload))).toEqual(
fixture.expected
)
})
}
})
@@ -0,0 +1,223 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bundle'
import { createMobileRelayPairingJournal } from './mobile-relay-pairing-journal'
import {
recoverMobileRelayPairing,
resetMobileRelayPairingRecoveryForTests
} from './mobile-relay-pairing-recovery'
import type { PairingCandidateClient } from './mobile-relay-physical-client'
import type { PairingOffer, RpcResponse } from './types'
vi.mock('react-native', () => ({ Platform: { OS: 'ios' } }))
vi.mock('expo-crypto', () => ({ getRandomBytes: vi.fn() }))
vi.mock('expo-secure-store', () => ({ WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'WHEN_UNLOCKED' }))
vi.mock('@react-native-async-storage/async-storage', () => ({ default: {} }))
const now = Date.UTC(2026, 6, 13)
const offer = {
v: 2,
endpoint: 'ws://192.168.1.10:6768',
deviceToken: 'device-token',
publicKeyB64: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
relay: {
v: 1,
directorUrl: 'https://relay.onorca.dev',
cellUrl: 'https://relay-c1.onorca.dev',
assignmentEpoch: 7,
relayHostId: 'AbCdEf0123_-xyZ9',
inviteToken: 'abcdefghijklmnopqrstuvwxyzABCDEFGH012345678',
inviteExpiresAt: now + 300_000,
e2eeFraming: 2
}
} satisfies PairingOffer
function response(result: unknown): RpcResponse {
return { id: 'rpc-1', ok: true, result, _meta: { runtimeId: 'runtime-1' } }
}
function installed(
journal: ReturnType<typeof journal>,
mode: 'authenticated-direct' | 'relay-basis'
) {
return {
v: 1 as const,
reqId: journal.metadata.installReqId,
authorizationMode: mode,
currentVersion: 1,
resumeExpiresAt: now + 86_400_000
}
}
function endpoints(
journal: ReturnType<typeof journal>,
state: { state: 'not-found' } | { state: 'committed'; result: ReturnType<typeof installed> }
) {
return {
v: 1 as const,
relay: {
v: 1 as const,
directorUrl: offer.relay.directorUrl,
cellUrl: offer.relay.cellUrl,
assignmentEpoch: offer.relay.assignmentEpoch,
relayHostId: offer.relay.relayHostId,
e2eeFraming: 2 as const
},
installStatus: { v: 1 as const, reqId: journal.metadata.installReqId, ...state }
}
}
function journal(mode: 'authenticated-direct' | 'relay-basis' = 'authenticated-direct') {
const value = createMobileRelayPairingJournal({
offer: offer as PairingOffer & { relay: NonNullable<PairingOffer['relay']> },
hostId: 'host-1',
hostName: 'Blue Whale',
now,
randomBytes: (length) => new Uint8Array(length).fill(length)
})
return {
...value,
metadata: {
...value.metadata,
winner: mode === 'authenticated-direct' ? ('direct' as const) : ('relay' as const),
authorizationMode: mode
}
}
}
function client(handler: (method: string, params: unknown) => Promise<RpcResponse>) {
return { sendRequest: vi.fn(handler), close: vi.fn() } satisfies PairingCandidateClient
}
function dependencies(args: {
journal: ReturnType<typeof journal>
connectRelay: ReturnType<typeof vi.fn>
bundle?: MobileRelayCredentialBundle | null
}) {
return {
loadJournal: vi.fn(async () => args.journal),
updateJournal: vi.fn(async (_id, update) => {
Object.assign(args.journal.metadata, update(args.journal.metadata))
}),
clearJournal: vi.fn(async () => {}),
readCredentialBundle: vi.fn(async () => args.bundle ?? null),
writeCredentialBundle: vi.fn(async () => {}),
loadHosts: vi.fn(async () => []),
saveHost: vi.fn(async () => {}),
connectRelay: args.connectRelay,
resolveInviteDirector: vi.fn(async () => {
throw new Error('director not needed')
}),
now: () => now,
platform: 'ios'
}
}
describe('mobile relay pairing recovery', () => {
beforeEach(() => {
resetMobileRelayPairingRecoveryForTests()
})
it('recovers a lost direct-install response with the pending credential first', async () => {
const saved = journal()
const committed = installed(saved, 'authenticated-direct')
const pending = client(async (method, params) => {
expect(method).toBe('pairing.getEndpoints')
expect(params).toEqual({
installReqId: saved.metadata.installReqId,
resumeConfirmReqId: saved.metadata.resumeConfirmReqId
})
return response(endpoints(saved, { state: 'committed', result: committed }))
})
const connectRelay = vi.fn(() => pending)
const deps = dependencies({ journal: saved, connectRelay })
await expect(recoverMobileRelayPairing(deps)).resolves.toBe('recovered')
expect(connectRelay).toHaveBeenCalledWith(
expect.objectContaining({
credential: saved.secrets.pendingResumeToken,
expectedCredentialKind: 'resume'
})
)
expect(deps.writeCredentialBundle).toHaveBeenCalledOnce()
expect(deps.saveHost).toHaveBeenCalledOnce()
expect(deps.clearJournal).toHaveBeenCalledOnce()
})
it('tries pending then current before an unexpired invite and transitions after not-found', async () => {
const saved = journal()
const currentToken = 'C'.repeat(43)
const bundle: MobileRelayCredentialBundle = {
v: 1,
hostId: 'host-1',
deviceToken: offer.deviceToken,
current: {
token: currentToken,
hash: 'D'.repeat(43),
version: 1,
expiresAt: now + 60_000
}
}
const failed = () =>
client(async () => {
throw new Error('resume rejected')
})
const relayInstalled = installed(saved, 'relay-basis')
let statusCalls = 0
const invite = client(async (method) => {
if (method === 'pairing.getEndpoints') {
statusCalls += 1
return response(
statusCalls === 1
? endpoints(saved, { state: 'not-found' })
: endpoints(saved, { state: 'committed', result: relayInstalled })
)
}
expect(saved.metadata.authorizationMode).toBe('relay-basis')
return response(relayInstalled)
})
const seenCredentials: (string | undefined)[] = []
const connectRelay = vi.fn((args) => {
seenCredentials.push(args.credential)
return args.credential ? failed() : invite
})
const deps = dependencies({ journal: saved, connectRelay, bundle })
await expect(recoverMobileRelayPairing(deps)).resolves.toBe('recovered')
expect(seenCredentials).toEqual([saved.secrets.pendingResumeToken, currentToken, undefined])
expect(deps.updateJournal).toHaveBeenCalledWith(saved.metadata.journalId, expect.any(Function))
expect(invite.sendRequest).toHaveBeenCalledWith('pairing.provisionRelay', {
reqId: saved.metadata.installReqId,
newResumeTokenHash: saved.metadata.pendingResumeTokenHash
})
})
it('accepts the one late direct result after invite fallback observed not-found', async () => {
const saved = journal()
const directInstalled = installed(saved, 'authenticated-direct')
const failedPending = client(async () => {
throw new Error('pending unavailable')
})
let statusCalls = 0
const invite = client(async (method) => {
if (method === 'pairing.getEndpoints') {
statusCalls += 1
return response(
statusCalls === 1
? endpoints(saved, { state: 'not-found' })
: endpoints(saved, { state: 'committed', result: directInstalled })
)
}
return response(directInstalled)
})
const deps = dependencies({
journal: saved,
connectRelay: vi.fn((args) => (args.credential ? failedPending : invite))
})
await expect(recoverMobileRelayPairing(deps)).resolves.toBe('recovered')
const written = deps.writeCredentialBundle.mock.calls[0]![0]
expect(written.current.token).toBe(saved.secrets.pendingResumeToken)
expect(saved.metadata.authorizationMode).toBe('authenticated-direct')
expect(deps.updateJournal).toHaveBeenCalledTimes(2)
})
})
@@ -0,0 +1,296 @@
import { Platform } from 'react-native'
import {
DeviceCredentialInstalledSchema,
PairingGetEndpointsResultSchema,
type DeviceCredentialInstalled,
type MobileRelayEndpoint
} from '../../../src/shared/mobile-relay-credential-contract'
import type { PairingRelay } from '../../../src/shared/mobile-relay-pairing-offer'
import { loadHosts, saveHost } from './host-store'
import {
promotePairingJournalCredential,
readMobileRelayCredentialBundle,
writeMobileRelayCredentialBundle,
type MobileRelayCredentialBundle
} from './mobile-relay-credential-bundle'
import { resolvePairingInviteThroughDirector } from './mobile-relay-invite-director'
import type { MobileRelayPairingJournal } from './mobile-relay-pairing-journal'
import {
clearMobileRelayPairingJournal,
loadMobileRelayPairingJournal,
updateMobileRelayPairingJournal
} from './mobile-relay-pairing-journal-store'
import {
connectMobileRelayForPairing,
type PairingCandidateClient
} from './mobile-relay-physical-client'
import { createRecoveringPairingRelayCandidate } from './pairing-relay-candidate'
import type { HostProfile, RpcResponse } from './types'
export type MobileRelayPairingRecoveryResult = 'none' | 'recovered' | 'deferred'
type RecoveryDependencies = {
loadJournal: typeof loadMobileRelayPairingJournal
updateJournal: typeof updateMobileRelayPairingJournal
clearJournal: typeof clearMobileRelayPairingJournal
readCredentialBundle: typeof readMobileRelayCredentialBundle
writeCredentialBundle: typeof writeMobileRelayCredentialBundle
loadHosts: typeof loadHosts
saveHost: typeof saveHost
connectRelay: typeof connectMobileRelayForPairing
resolveInviteDirector: typeof resolvePairingInviteThroughDirector
now: () => number
platform: string
}
const defaultDependencies: RecoveryDependencies = {
loadJournal: loadMobileRelayPairingJournal,
updateJournal: updateMobileRelayPairingJournal,
clearJournal: clearMobileRelayPairingJournal,
readCredentialBundle: readMobileRelayCredentialBundle,
writeCredentialBundle: writeMobileRelayCredentialBundle,
loadHosts,
saveHost,
connectRelay: connectMobileRelayForPairing,
resolveInviteDirector: resolvePairingInviteThroughDirector,
now: Date.now,
platform: Platform.OS
}
let recoveryPromise: Promise<MobileRelayPairingRecoveryResult> | null = null
export function recoverMobileRelayPairing(
overrides: Partial<RecoveryDependencies> = {}
): Promise<MobileRelayPairingRecoveryResult> {
if (recoveryPromise) {
return recoveryPromise
}
const dependencies = { ...defaultDependencies, ...overrides }
recoveryPromise = runRecovery(dependencies).finally(() => {
recoveryPromise = null
})
return recoveryPromise
}
async function runRecovery(
dependencies: RecoveryDependencies
): Promise<MobileRelayPairingRecoveryResult> {
if (dependencies.platform === 'web') {
return 'none'
}
let journal: MobileRelayPairingJournal | null
try {
journal = await dependencies.loadJournal()
} catch {
return 'deferred'
}
if (!journal) {
return 'none'
}
const bundle = await dependencies.readCredentialBundle(journal.metadata.host.id).catch(() => null)
const hosts = await dependencies.loadHosts().catch(() => [])
const existing = hosts.find(({ id }) => id === journal!.metadata.host.id)
if (existing?.relayHostId === journal.metadata.relay.relayHostId && bundle) {
await dependencies.clearJournal(journal.metadata.journalId)
return 'recovered'
}
const credentials = recoveryCredentials(journal, bundle, dependencies.now())
for (const credential of credentials) {
let client: PairingCandidateClient | null = null
try {
client =
credential.kind === 'invite'
? createInviteClient(journal, dependencies, (next) => {
journal = next
})
: dependencies.connectRelay({
relay: pairingRelay(journal),
deviceToken: journal.secrets.deviceToken,
desktopPublicKeyB64: journal.metadata.host.publicKeyB64,
credential: credential.token,
expectedCredentialKind: 'resume'
})
const endpoints = await getRecoveryStatus(client, journal, credential.kind)
if (endpoints.installStatus?.state === 'committed') {
await publishCommitted(journal, endpoints, dependencies)
return 'recovered'
}
if (credential.kind === 'invite' && endpoints.installStatus?.state === 'not-found') {
journal = await transitionToInviteAuthorization(journal, dependencies)
const installed = DeviceCredentialInstalledSchema.parse(
requireSuccess(
await client.sendRequest('pairing.provisionRelay', {
reqId: journal.metadata.installReqId,
newResumeTokenHash: journal.metadata.pendingResumeTokenHash
})
)
)
const reconciled = await getRecoveryStatus(client, journal, 'invite')
assertCommitted(reconciled, installed)
await publishCommitted(journal, reconciled, dependencies)
return 'recovered'
}
} catch {
// Why: ambiguous pairing state advances only by credential priority and
// authoritative status; a transport failure never rewrites the journal.
} finally {
client?.close()
}
}
return 'deferred'
}
function recoveryCredentials(
journal: MobileRelayPairingJournal,
bundle: MobileRelayCredentialBundle | null,
now: number
): { kind: 'resume' | 'invite'; token: string }[] {
const credentials: { kind: 'resume' | 'invite'; token: string }[] = [
{ kind: 'resume', token: journal.secrets.pendingResumeToken }
]
if (bundle?.current.token && bundle.current.token !== journal.secrets.pendingResumeToken) {
credentials.push({ kind: 'resume', token: bundle.current.token })
}
if (journal.metadata.relay.inviteExpiresAt > now) {
credentials.push({ kind: 'invite', token: journal.secrets.inviteToken })
}
return credentials
}
function createInviteClient(
journal: MobileRelayPairingJournal,
dependencies: RecoveryDependencies,
replaceJournal: (journal: MobileRelayPairingJournal) => void
): PairingCandidateClient {
return createRecoveringPairingRelayCandidate({
journal,
connect: (relay) =>
dependencies.connectRelay({
relay,
deviceToken: journal.secrets.deviceToken,
desktopPublicKeyB64: journal.metadata.host.publicKeyB64
}),
resolveDirector: (relay) => dependencies.resolveInviteDirector({ relay }),
persistMove: async (relay) => {
const next = {
...journal,
metadata: {
...journal.metadata,
relay: {
...journal.metadata.relay,
cellUrl: relay.cellUrl,
assignmentEpoch: relay.assignmentEpoch
}
}
}
await dependencies.updateJournal(journal.metadata.journalId, () => next.metadata)
replaceJournal(next)
},
now: dependencies.now
})
}
async function getRecoveryStatus(
client: PairingCandidateClient,
journal: MobileRelayPairingJournal,
kind: 'resume' | 'invite'
) {
return PairingGetEndpointsResultSchema.parse(
requireSuccess(
await client.sendRequest('pairing.getEndpoints', {
installReqId: journal.metadata.installReqId,
...(kind === 'resume' ? { resumeConfirmReqId: journal.metadata.resumeConfirmReqId } : {})
})
)
)
}
async function transitionToInviteAuthorization(
journal: MobileRelayPairingJournal,
dependencies: RecoveryDependencies
): Promise<MobileRelayPairingJournal> {
const next: MobileRelayPairingJournal = {
...journal,
metadata: {
...journal.metadata,
winner: 'relay',
authorizationMode: 'relay-basis'
}
}
// Why: the branch change becomes durable only after authoritative not-found.
await dependencies.updateJournal(journal.metadata.journalId, () => next.metadata)
return next
}
async function publishCommitted(
journal: MobileRelayPairingJournal,
endpoints: ReturnType<typeof PairingGetEndpointsResultSchema.parse>,
dependencies: RecoveryDependencies
): Promise<void> {
if (endpoints.installStatus?.state !== 'committed' || !endpoints.relay) {
throw new Error('relay pairing recovery was not committed')
}
const installed = endpoints.installStatus.result
const reconciledJournal: MobileRelayPairingJournal = {
...journal,
metadata: {
...journal.metadata,
winner: installed.authorizationMode === 'authenticated-direct' ? 'direct' : 'relay',
authorizationMode: installed.authorizationMode
}
}
if (journal.metadata.authorizationMode !== installed.authorizationMode) {
await dependencies.updateJournal(journal.metadata.journalId, () => reconciledJournal.metadata)
}
await dependencies.writeCredentialBundle(
promotePairingJournalCredential({ journal: reconciledJournal, installed })
)
await dependencies.saveHost(relayHost(reconciledJournal, endpoints.relay))
await dependencies.clearJournal(journal.metadata.journalId)
}
function relayHost(journal: MobileRelayPairingJournal, relay: MobileRelayEndpoint): HostProfile {
const host = journal.metadata.host
const url = new URL(relay.cellUrl)
url.protocol = 'wss:'
url.pathname = `/v1/connect/${encodeURIComponent(relay.relayHostId)}`
return {
...host,
deviceToken: journal.secrets.deviceToken,
endpoints: [
{ id: 'direct-primary', kind: 'lan', url: host.endpoint },
{ id: 'relay-primary', kind: 'relay', url: url.toString() }
],
relayHostId: relay.relayHostId,
relay
}
}
function pairingRelay(journal: MobileRelayPairingJournal): PairingRelay {
return { ...journal.metadata.relay, inviteToken: journal.secrets.inviteToken }
}
function requireSuccess(response: RpcResponse): unknown {
if (!response.ok) {
throw new Error(`${response.error.code}: ${response.error.message}`)
}
return response.result
}
function assertCommitted(
endpoints: ReturnType<typeof PairingGetEndpointsResultSchema.parse>,
installed: DeviceCredentialInstalled
): void {
if (
endpoints.installStatus?.state !== 'committed' ||
JSON.stringify(endpoints.installStatus.result) !== JSON.stringify(installed)
) {
throw new Error('relay pairing recovery install was not authoritatively committed')
}
}
/** Test-only: clear the startup single-flight between cases. */
export function resetMobileRelayPairingRecoveryForTests(): void {
recoveryPromise = null
}
@@ -0,0 +1,135 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const fakes = vi.hoisted(() => ({
channelOptions: null as null | {
onAuthenticated(): void
onText(value: string): void
},
start: vi.fn(),
handleMessage: vi.fn(),
sendText: vi.fn(() => true),
dispose: vi.fn()
}))
vi.mock('./mobile-e2ee-v2-client-session', () => ({
MobileE2EEV2ClientSession: { create: vi.fn(() => ({ hello: {} })) }
}))
vi.mock('./mobile-e2ee-v2-physical-channel', () => ({
MobileE2EEV2PhysicalChannel: class {
constructor(options: NonNullable<typeof fakes.channelOptions>) {
fakes.channelOptions = options
}
start = fakes.start
handleMessage = fakes.handleMessage
sendText = fakes.sendText
dispose = fakes.dispose
}
}))
import { connectMobileRelayForPairing, RelayOuterError } from './mobile-relay-physical-client'
class FakeSocket {
readonly OPEN = 1
readyState = 1
bufferedAmount = 0
sent: unknown[] = []
onopen: (() => void) | null = null
onmessage: ((event: { data: unknown }) => void) | null = null
onerror: (() => void) | null = null
onclose: ((event: { code: number }) => void) | null = null
send(value: unknown): void {
this.sent.push(value)
}
close(): void {}
receive(data: unknown): void {
this.onmessage?.({ data })
}
}
const relay = {
v: 1 as const,
directorUrl: 'https://relay.onorca.dev',
cellUrl: 'https://relay-c1.onorca.dev',
assignmentEpoch: 7,
relayHostId: 'AbCdEf0123_-xyZ9',
inviteToken: 'abcdefghijklmnopqrstuvwxyzABCDEFGH012345678',
inviteExpiresAt: Date.now() + 300_000,
e2eeFraming: 2 as const
}
describe('mobile relay physical pairing client', () => {
beforeEach(() => {
vi.clearAllMocks()
fakes.channelOptions = null
fakes.sendText.mockReturnValue(true)
})
it('uses first-frame outer auth, waits for host attach, then carries RPC over E2EE v2', async () => {
const socket = new FakeSocket()
let openedUrl = ''
const client = connectMobileRelayForPairing({
relay,
deviceToken: 'device-token',
desktopPublicKeyB64: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
createSocket: (url) => {
openedUrl = url
return socket as unknown as WebSocket
}
})
socket.onopen?.()
expect(openedUrl).toBe('wss://relay-c1.onorca.dev/v1/connect/AbCdEf0123_-xyZ9')
expect(openedUrl).not.toContain('?')
expect(JSON.parse(socket.sent[0] as string)).toEqual({
type: 'relay-auth',
v: 1,
mode: 'connect',
credential: relay.inviteToken
})
socket.receive(
JSON.stringify({
type: 'relay-hello',
ok: true,
credentialKind: 'invite',
leaseExpiresAt: Date.now() + 60_000
})
)
await vi.waitFor(() => expect(fakes.start).toHaveBeenCalledOnce())
fakes.channelOptions!.onAuthenticated()
const responsePromise = client.sendRequest('status.get')
await vi.waitFor(() => expect(fakes.sendText).toHaveBeenCalledOnce())
const request = JSON.parse(fakes.sendText.mock.calls[0]![0] as string)
expect(request).toEqual({
id: 'relay-pair-1',
deviceToken: 'device-token',
method: 'status.get'
})
fakes.channelOptions!.onText(
JSON.stringify({
id: request.id,
ok: true,
result: { path: 'relay' },
_meta: { runtimeId: 'runtime-1' }
})
)
await expect(responsePromise).resolves.toMatchObject({ ok: true, result: { path: 'relay' } })
})
it('surfaces a typed endpoint-scoped outer rejection before E2EE', async () => {
const socket = new FakeSocket()
const client = connectMobileRelayForPairing({
relay,
deviceToken: 'device-token',
desktopPublicKeyB64: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
createSocket: () => socket as unknown as WebSocket
})
const status = client.sendRequest('status.get')
socket.receive(JSON.stringify({ type: 'relay-hello', ok: false, code: 4404 }))
await expect(status).rejects.toEqual(new RelayOuterError(4404))
expect(fakes.start).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,182 @@
import type { PairingRelay } from '../../../src/shared/mobile-relay-pairing-offer'
import { RelayPhoneHelloSchema } from '../../../src/shared/mobile-relay-phone-protocol'
import { MobileE2EEV2ClientSession } from './mobile-e2ee-v2-client-session'
import { MobileE2EEV2PhysicalChannel } from './mobile-e2ee-v2-physical-channel'
import { isRpcResponse } from './rpc-response-shape'
import type { RpcResponse } from './types'
import { websocketPayloadToUint8 } from './websocket-payload-bytes'
export { RelayOuterError } from './mobile-relay-e2ee-link'
import { RelayOuterError } from './mobile-relay-e2ee-link'
type PendingRequest = {
resolve: (response: RpcResponse) => void
reject: (error: Error) => void
timer: ReturnType<typeof setTimeout>
}
export type PairingCandidateClient = {
sendRequest(method: string, params?: unknown): Promise<RpcResponse>
close(): void
}
export function connectMobileRelayForPairing(args: {
relay: PairingRelay
deviceToken: string
desktopPublicKeyB64: string
credential?: string
expectedCredentialKind?: 'invite' | 'resume'
requestTimeoutMs?: number
createSocket?: (url: string) => WebSocket
}): PairingCandidateClient {
const requestTimeoutMs = args.requestTimeoutMs ?? 30_000
const socketUrl = relayPhoneWebSocketUrl(args.relay)
const socket = (args.createSocket ?? ((url) => new WebSocket(url)))(socketUrl)
const session = MobileE2EEV2ClientSession.create({
desktopPublicKeyB64: args.desktopPublicKeyB64,
transport: 'relay',
relayHostId: args.relay.relayHostId
})
const pending = new Map<string, PendingRequest>()
let requestCounter = 0
let closed = false
let outerReady = false
let authenticated = false
let resolveAuthenticated!: () => void
let rejectAuthenticated!: (error: Error) => void
const authenticatedPromise = new Promise<void>((resolve, reject) => {
resolveAuthenticated = resolve
rejectAuthenticated = reject
})
const channel = new MobileE2EEV2PhysicalChannel({
session,
socket,
deviceToken: args.deviceToken,
decodeBinary: websocketPayloadToUint8,
onAuthenticated: () => {
authenticated = true
resolveAuthenticated()
},
onText: (plaintext) => {
let value: unknown
try {
value = JSON.parse(plaintext)
} catch {
return
}
if (!isRpcResponse(value)) {
return
}
const request = pending.get(value.id)
if (request) {
clearTimeout(request.timer)
pending.delete(value.id)
request.resolve(value)
}
},
onBinary: () => {},
onError: fail
})
socket.onopen = () => {
socket.send(
JSON.stringify({
type: 'relay-auth',
v: 1,
mode: 'connect',
credential: args.credential ?? args.relay.inviteToken
})
)
}
let inboundChain: Promise<void> = Promise.resolve()
socket.onmessage = (event) => {
inboundChain = inboundChain
.then(async () => {
if (closed) {
return
}
if (!outerReady) {
acceptRelayHello(event.data)
return
}
await channel.handleMessage(event.data)
})
.catch((error: unknown) => fail(asError(error)))
}
socket.onerror = () => fail(new Error('relay transport error'))
socket.onclose = (event) => fail(new RelayOuterError(event.code || 1006))
function acceptRelayHello(raw: unknown): void {
if (typeof raw !== 'string') {
throw new Error('expected plaintext relay hello')
}
let value: unknown
try {
value = JSON.parse(raw)
} catch {
throw new Error('invalid relay hello JSON')
}
const parsed = RelayPhoneHelloSchema.safeParse(value)
if (!parsed.success) {
throw new Error('invalid relay hello')
}
if (!parsed.data.ok) {
throw new RelayOuterError(parsed.data.code)
}
if (parsed.data.credentialKind !== (args.expectedCredentialKind ?? 'invite')) {
throw new Error('relay credential resolved as an unexpected credential kind')
}
outerReady = true
channel.start()
}
function fail(error: Error): void {
if (closed) {
return
}
closed = true
channel.dispose()
rejectAuthenticated(error)
for (const request of pending.values()) {
clearTimeout(request.timer)
request.reject(error)
}
pending.clear()
socket.close()
}
return {
async sendRequest(method, params) {
await authenticatedPromise
if (closed || !authenticated) {
throw new Error('relay pairing client closed')
}
const id = `relay-pair-${++requestCounter}`
return new Promise<RpcResponse>((resolve, reject) => {
const timer = setTimeout(() => {
pending.delete(id)
reject(new Error(`relay pairing RPC timed out: ${method}`))
}, requestTimeoutMs)
pending.set(id, { resolve, reject, timer })
if (
!channel.sendText(JSON.stringify({ id, deviceToken: args.deviceToken, method, params }))
) {
clearTimeout(timer)
pending.delete(id)
reject(new Error('relay E2EE channel not ready'))
}
})
},
close: () => fail(new Error('relay pairing client closed'))
}
}
export function relayPhoneWebSocketUrl(relay: PairingRelay): string {
const url = new URL(relay.cellUrl)
url.protocol = 'wss:'
url.pathname = `/v1/connect/${encodeURIComponent(relay.relayHostId)}`
return url.toString()
}
function asError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error))
}
@@ -0,0 +1,66 @@
import { describe, expect, it, vi } from 'vitest'
import { resolveMobileRelayEndpoint } from './mobile-relay-resume-director'
const relay = {
v: 1 as const,
directorUrl: 'https://relay.onorca.dev',
cellUrl: 'https://relay-old.onorca.dev',
assignmentEpoch: 7,
relayHostId: 'AbCdEf0123_-xyZ9',
e2eeFraming: 2 as const
}
describe('mobile relay resume director', () => {
it('uses a bounded POST body and never puts the bearer in the URL', async () => {
const fetchImpl = vi.fn(
async () =>
new Response(
JSON.stringify({
v: 1,
cellUrl: 'https://relay-c2.onorca.dev',
assignmentEpoch: 8,
leaseExpiresAt: Date.now() + 60_000
}),
{ status: 200, headers: { 'content-type': 'application/json' } }
)
)
await expect(
resolveMobileRelayEndpoint({ relay, resumeToken: 'A'.repeat(43), fetchImpl })
).resolves.toMatchObject({ cellUrl: 'https://relay-c2.onorca.dev', assignmentEpoch: 8 })
const [url, init] = fetchImpl.mock.calls[0]!
expect(url).toBe('https://relay.onorca.dev/v1/resolve')
expect(url).not.toContain('A'.repeat(43))
expect(init).toMatchObject({ method: 'POST' })
expect(JSON.parse(init!.body as string)).toEqual({
v: 1,
relayHostId: relay.relayHostId,
resumeToken: 'A'.repeat(43)
})
})
it('rejects non-canonical targets and oversized bodies', async () => {
const badTarget = vi.fn(
async () =>
new Response(
JSON.stringify({
v: 1,
cellUrl: 'http://relay-c2.onorca.dev',
assignmentEpoch: 8,
leaseExpiresAt: 1
})
)
)
await expect(
resolveMobileRelayEndpoint({ relay, resumeToken: 'A'.repeat(43), fetchImpl: badTarget })
).rejects.toThrow()
const oversized = vi.fn(
async () =>
new Response('x'.repeat(16 * 1024 + 1), { headers: { 'content-length': '16385' } })
)
await expect(
resolveMobileRelayEndpoint({ relay, resumeToken: 'A'.repeat(43), fetchImpl: oversized })
).rejects.toThrow(/too large/)
})
})
@@ -0,0 +1,63 @@
import { z } from 'zod'
import type { MobileRelayEndpoint } from '../../../src/shared/mobile-relay-credential-contract'
const MAX_RESPONSE_BYTES = 16 * 1024
const ResolveResponseSchema = z
.object({
v: z.literal(1),
cellUrl: z.string().refine(isCanonicalHttpsOrigin),
assignmentEpoch: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER),
leaseExpiresAt: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER)
})
.strict()
export async function resolveMobileRelayEndpoint(args: {
relay: MobileRelayEndpoint
resumeToken: string
fetchImpl?: typeof fetch
timeoutMs?: number
}): Promise<MobileRelayEndpoint> {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), args.timeoutMs ?? 5000)
try {
const url = new URL('/v1/resolve', args.relay.directorUrl)
const response = await (args.fetchImpl ?? fetch)(url.toString(), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
v: 1,
relayHostId: args.relay.relayHostId,
resumeToken: args.resumeToken
}),
signal: controller.signal
})
if (!response.ok) {
throw new Error(`relay director resolve failed (${response.status})`)
}
const declaredLength = Number(response.headers.get('content-length') ?? 0)
if (declaredLength > MAX_RESPONSE_BYTES) {
throw new Error('relay director resolve response too large')
}
const raw = await response.text()
if (new TextEncoder().encode(raw).byteLength > MAX_RESPONSE_BYTES) {
throw new Error('relay director resolve response too large')
}
const resolved = ResolveResponseSchema.parse(JSON.parse(raw) as unknown)
return {
...args.relay,
cellUrl: resolved.cellUrl,
assignmentEpoch: resolved.assignmentEpoch
}
} finally {
clearTimeout(timer)
}
}
function isCanonicalHttpsOrigin(value: string): boolean {
try {
const parsed = new URL(value)
return parsed.protocol === 'https:' && parsed.origin === value
} catch {
return false
}
}
@@ -0,0 +1,207 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
BrowserScreencastOpcode,
encodeBrowserScreencastFrame
} from '../../../src/shared/browser-screencast-protocol'
import { encodeTerminalStreamFrame, TerminalStreamOpcode } from './terminal-stream-protocol'
const fakes = vi.hoisted(() => ({
linkOptions: null as null | {
endpoint: { cellUrl: string; relayHostId: string }
credential: string
expectedCredentialKind: string
onHello(value: unknown): void
onAuthenticated(): void
onText(value: string): void
onBinary(value: Uint8Array): void
onError(error: Error): void
},
sendText: vi.fn(() => true),
close: vi.fn()
}))
vi.mock('./mobile-relay-e2ee-link', () => ({
MobileRelayE2eeLink: class {
constructor(options: NonNullable<typeof fakes.linkOptions>) {
fakes.linkOptions = options
}
sendText = fakes.sendText
close = fakes.close
}
}))
import { connectMobileRelayRpcSession } from './mobile-relay-rpc-session'
const relay = {
v: 1 as const,
directorUrl: 'https://relay.onorca.dev',
cellUrl: 'https://relay-c1.onorca.dev',
assignmentEpoch: 7,
relayHostId: 'AbCdEf0123_-xyZ9',
e2eeFraming: 2 as const
}
function openSession() {
return connectMobileRelayRpcSession({
relay,
resumeToken: 'resume-secret',
resumeCredentialVersion: 3,
resumeConfirmReqId: 'confirm-1',
deviceToken: 'device-token',
desktopPublicKeyB64: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
requestTimeoutMs: 1000
})
}
async function authenticateSession() {
const session = openSession()
fakes.linkOptions!.onHello({
type: 'relay-hello',
ok: true,
credentialKind: 'resume',
leaseExpiresAt: Date.now() + 60_000,
acceptedCredentialVersion: 3,
acceptedAs: 'current',
resumeExpiresAt: Date.now() + 300_000
})
expect(session.getState()).toBe('handshaking')
fakes.linkOptions!.onAuthenticated()
await vi.waitFor(() => expect(fakes.sendText).toHaveBeenCalledOnce())
const request = JSON.parse(fakes.sendText.mock.calls[0]![0] as string) as {
id: string
method: string
params: unknown
}
fakes.linkOptions!.onText(
JSON.stringify({
id: request.id,
ok: true,
result: {
v: 1,
relay,
resumeConfirmation: {
v: 1,
reqId: 'confirm-1',
currentVersion: 3,
acceptedAs: 'current',
renewed: true,
resumeExpiresAt: Date.now() + 300_000
}
},
_meta: { runtimeId: 'runtime-1' }
})
)
await vi.waitFor(() => expect(session.getState()).toBe('connected'))
fakes.sendText.mockClear()
return { session, confirmationRequest: request }
}
describe('mobile relay RPC session', () => {
beforeEach(() => {
vi.clearAllMocks()
fakes.linkOptions = null
fakes.sendText.mockReturnValue(true)
})
it('requires exact resume observations and confirms by request ID before becoming connected', async () => {
const { session, confirmationRequest } = await authenticateSession()
expect(fakes.linkOptions).toMatchObject({
endpoint: relay,
credential: 'resume-secret',
expectedCredentialKind: 'resume'
})
expect(confirmationRequest).toMatchObject({
method: 'pairing.getEndpoints',
params: { resumeConfirmReqId: 'confirm-1' },
deviceToken: 'device-token'
})
expect(confirmationRequest.params).not.toHaveProperty('relayDeviceId')
expect(confirmationRequest.params).not.toHaveProperty('acceptedCredentialVersion')
expect(session.getLeaseExpiresAt()).toEqual(expect.any(Number))
})
it('rejects a mismatched outer credential version and closes the physical link', () => {
const session = openSession()
fakes.linkOptions!.onHello({
type: 'relay-hello',
ok: true,
credentialKind: 'resume',
leaseExpiresAt: Date.now() + 60_000,
acceptedCredentialVersion: 2,
acceptedAs: 'grace',
resumeExpiresAt: Date.now() + 300_000
})
expect(session.getState()).toBe('disconnected')
expect(fakes.close).toHaveBeenCalledOnce()
expect(fakes.sendText).not.toHaveBeenCalled()
})
it('routes terminal and browser binary streams after confirmation', async () => {
const { session } = await authenticateSession()
const terminalListener = vi.fn()
session.subscribe('terminal.subscribe', { terminal: 'term-1' }, terminalListener)
await vi.waitFor(() => expect(fakes.sendText).toHaveBeenCalledOnce())
const terminalRequest = JSON.parse(fakes.sendText.mock.calls[0]![0] as string) as {
id: string
}
fakes.linkOptions!.onText(
JSON.stringify({
id: terminalRequest.id,
ok: true,
result: { streamId: 42 },
_meta: { runtimeId: 'runtime-1' }
})
)
fakes.linkOptions!.onBinary(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Output,
streamId: 42,
seq: 1,
payload: new TextEncoder().encode('hello')
})
)
expect(terminalListener).toHaveBeenLastCalledWith({
type: 'data',
streamId: 42,
chunk: 'hello'
})
fakes.sendText.mockClear()
const onBinaryFrame = vi.fn()
session.subscribe('browser.screencast', {}, vi.fn(), { onBinaryFrame })
await vi.waitFor(() => expect(fakes.sendText).toHaveBeenCalledOnce())
const browserRequest = JSON.parse(fakes.sendText.mock.calls[0]![0] as string) as { id: string }
fakes.linkOptions!.onText(
JSON.stringify({
id: browserRequest.id,
ok: true,
result: { subscriptionId: 'browser-1' },
_meta: { runtimeId: 'runtime-1' }
})
)
fakes.linkOptions!.onBinary(
encodeBrowserScreencastFrame({
opcode: BrowserScreencastOpcode.Frame,
seq: 9,
format: 'jpeg',
metadata: { imageWidth: 800 },
image: new Uint8Array([1, 2, 3])
})
)
expect(onBinaryFrame).toHaveBeenCalledWith(
expect.objectContaining({ seq: 9, format: 'jpeg', image: new Uint8Array([1, 2, 3]) })
)
})
it('rejects pending RPC work when the physical link fails', async () => {
const { session } = await authenticateSession()
const pending = session.sendRequest('status.get')
await vi.waitFor(() => expect(fakes.sendText).toHaveBeenCalledOnce())
fakes.linkOptions!.onError(new Error('relay transport error'))
await expect(pending).rejects.toThrow('relay transport error')
expect(session.getState()).toBe('disconnected')
})
})
@@ -0,0 +1,254 @@
import {
PairingGetEndpointsResultSchema,
type DeviceResumeConfirmed,
type MobileRelayEndpoint
} from '../../../src/shared/mobile-relay-credential-contract'
import { MobileRelayE2eeLink } from './mobile-relay-e2ee-link'
import { MobileRelayRpcStreams } from './mobile-relay-rpc-streams'
import { MobileE2EEAuthenticationError } from './mobile-e2ee-v2-physical-channel'
import { isRpcResponse } from './rpc-response-shape'
import type { RpcClient } from './rpc-client'
import type { ConnectionState, RpcResponse } from './types'
type PendingRequest = {
resolve: (response: RpcResponse) => void
reject: (error: Error) => void
timer: ReturnType<typeof setTimeout>
}
export type MobileRelayRpcSession = RpcClient & {
getLeaseExpiresAt(): number | null
getResumeConfirmation(): DeviceResumeConfirmed | null
getFailure(): Error | null
}
export function connectMobileRelayRpcSession(args: {
relay: MobileRelayEndpoint
resumeToken: string
resumeCredentialVersion: number
resumeConfirmReqId: string
deviceToken: string
desktopPublicKeyB64: string
requestTimeoutMs?: number
createSocket?: (url: string) => WebSocket
}): MobileRelayRpcSession {
const requestTimeoutMs = args.requestTimeoutMs ?? 30_000
const pending = new Map<string, PendingRequest>()
const stateListeners = new Set<(state: ConnectionState) => void>()
let state: ConnectionState = 'connecting'
let requestCounter = 0
let lastConnectedAt: number | null = null
let leaseExpiresAt: number | null = null
let resumeConfirmation: DeviceResumeConfirmed | null = null
let failure: Error | null = null
let closed = false
const streams = new MobileRelayRpcStreams({
nextId,
sendFrame,
waitForConnected: () => waitForConnected()
})
const link = new MobileRelayE2eeLink({
endpoint: args.relay,
credential: args.resumeToken,
expectedCredentialKind: 'resume',
deviceToken: args.deviceToken,
desktopPublicKeyB64: args.desktopPublicKeyB64,
createSocket: args.createSocket,
onHello: (hello) => {
if (
hello.credentialKind !== 'resume' ||
hello.acceptedCredentialVersion !== args.resumeCredentialVersion
) {
fail(new Error('relay resume credential version mismatch'))
return
}
leaseExpiresAt = hello.leaseExpiresAt
publishState('handshaking')
},
onAuthenticated: () => void confirmResume(),
onText: handleText,
onBinary: handleBinary,
onError: fail
})
const client: MobileRelayRpcSession = {
async sendRequest(method, params, options) {
await waitForConnected(options?.timeoutMs)
return sendRpc(method, params, options?.timeoutMs)
},
subscribe(method, params, listener, options) {
if (closed) {
return () => {}
}
return streams.subscribe(method, params, listener, options)
},
updateTerminalSubscriptionViewport(terminal, viewport) {
streams.updateTerminalViewport(terminal, viewport)
},
getState: () => state,
getReconnectAttempt: () => 0,
getLastConnectedAt: () => lastConnectedAt,
onStateChange(listener) {
stateListeners.add(listener)
return () => stateListeners.delete(listener)
},
notifyForeground: () => {},
close() {
if (closed) {
return
}
closed = true
link.close()
rejectPending(new Error('Client closed'))
streams.clear()
publishState('disconnected')
},
getLeaseExpiresAt: () => leaseExpiresAt,
getResumeConfirmation: () => resumeConfirmation,
getFailure: () => failure
}
return client
async function confirmResume(): Promise<void> {
try {
const response = await sendRpc(
'pairing.getEndpoints',
{ resumeConfirmReqId: args.resumeConfirmReqId },
requestTimeoutMs,
true
)
if (!response.ok) {
throw new Error(response.error.code)
}
const result = PairingGetEndpointsResultSchema.parse(response.result)
if (!result.resumeConfirmation || result.relay?.relayHostId !== args.relay.relayHostId) {
throw new Error('relay resume confirmation missing')
}
resumeConfirmation = result.resumeConfirmation
lastConnectedAt = Date.now()
publishState('connected')
} catch (error) {
fail(asError(error))
}
}
function sendRpc(
method: string,
params: unknown,
timeoutMs = requestTimeoutMs,
beforeConnected = false
): Promise<RpcResponse> {
if (closed || (!beforeConnected && state !== 'connected')) {
return Promise.reject(new Error('relay session not connected'))
}
const id = nextId()
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
pending.delete(id)
reject(new Error(`relay RPC timed out: ${method}`))
}, timeoutMs)
pending.set(id, { resolve, reject, timer })
if (!sendFrame({ id, method, params })) {
clearTimeout(timer)
pending.delete(id)
reject(new Error('relay E2EE channel not ready'))
}
})
}
function sendFrame(request: { id: string; method: string; params?: unknown }): boolean {
return link.sendText(JSON.stringify({ ...request, deviceToken: args.deviceToken }))
}
function handleText(plaintext: string): void {
let value: unknown
try {
value = JSON.parse(plaintext)
} catch {
return
}
if (!isRpcResponse(value)) {
return
}
const request = pending.get(value.id)
if (request) {
clearTimeout(request.timer)
pending.delete(value.id)
request.resolve(value)
return
}
streams.handleResponse(value)
}
function handleBinary(bytes: Uint8Array): void {
streams.handleBinary(bytes)
}
function waitForConnected(timeoutMs = requestTimeoutMs): Promise<void> {
if (state === 'connected') {
return Promise.resolve()
}
return new Promise((resolve, reject) => {
let timer: ReturnType<typeof setTimeout> | null = null
const unsubscribe = client.onStateChange((next) => {
if (next === 'connected') {
finish()
resolve()
} else if (next === 'disconnected' || next === 'auth-failed') {
finish()
reject(new Error(`relay session ${next}`))
}
})
timer = setTimeout(() => {
finish()
reject(new Error('relay session connection timed out'))
}, timeoutMs)
function finish(): void {
if (timer) {
clearTimeout(timer)
}
unsubscribe()
}
})
}
function publishState(next: ConnectionState): void {
if (state === next) {
return
}
state = next
for (const listener of stateListeners) {
listener(next)
}
}
function fail(error: Error): void {
if (closed) {
return
}
closed = true
failure = error
link.close()
rejectPending(error)
publishState(error instanceof MobileE2EEAuthenticationError ? 'auth-failed' : 'disconnected')
}
function rejectPending(error: Error): void {
for (const request of pending.values()) {
clearTimeout(request.timer)
request.reject(error)
}
pending.clear()
}
function nextId(): string {
return `relay-rpc-${++requestCounter}-${Date.now()}`
}
}
function asError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error))
}
@@ -0,0 +1,165 @@
import { decodeBrowserScreencastFrame } from './browser-screencast-protocol'
import {
handleTerminalBinaryFrame,
type TerminalSnapshotState
} from './rpc-client-terminal-binary-frame'
import {
buildTerminalUnsubscribeParams,
updateTerminalSubscriptionViewport
} from './rpc-client-terminal-subscription'
import type { RpcClient } from './rpc-client'
import type { RpcResponse, RpcSuccess } from './types'
type StreamRecord = {
method: string
params: unknown
listener: (result: unknown) => void
onBinaryFrame?: Parameters<RpcClient['subscribe']>[3] extends
| { onBinaryFrame?: infer Listener }
| undefined
? Listener
: never
streamIds: Set<number>
subscriptionId?: string
cancelled: boolean
}
type StreamManagerOptions = {
nextId: () => string
sendFrame: (request: { id: string; method: string; params?: unknown }) => boolean
waitForConnected: () => Promise<void>
}
export class MobileRelayRpcStreams {
private readonly streams = new Map<string, StreamRecord>()
private readonly terminalListeners = new Map<number, (result: unknown) => void>()
private readonly terminalSnapshots = new Map<number, TerminalSnapshotState>()
private activeBrowserStream: StreamRecord | null = null
constructor(private readonly options: StreamManagerOptions) {}
subscribe(
method: string,
params: unknown,
listener: (result: unknown) => void,
subscribeOptions?: Parameters<RpcClient['subscribe']>[3]
): () => void {
const id = this.options.nextId()
const stream: StreamRecord = {
method,
params,
listener,
onBinaryFrame: subscribeOptions?.onBinaryFrame,
streamIds: new Set(),
cancelled: false
}
this.streams.set(id, stream)
void this.options
.waitForConnected()
.then(() => {
if (!stream.cancelled && !this.options.sendFrame({ id, method, params: stream.params })) {
this.remove(id)
}
})
.catch(() => this.remove(id))
return () => this.cancel(id)
}
updateTerminalViewport(terminal: string, viewport: { cols: number; rows: number }): void {
updateTerminalSubscriptionViewport(this.streams.values(), terminal, viewport)
}
handleResponse(response: RpcResponse): boolean {
const stream = this.streams.get(response.id)
if (!stream) {
return false
}
if (!response.ok) {
this.remove(response.id)
return true
}
const result = (response as RpcSuccess).result
if (result && typeof result === 'object') {
const metadata = result as { subscriptionId?: unknown; streamId?: unknown; type?: unknown }
if (typeof metadata.subscriptionId === 'string') {
stream.subscriptionId = metadata.subscriptionId
}
if (typeof metadata.streamId === 'number') {
stream.streamIds.add(metadata.streamId)
this.terminalListeners.set(metadata.streamId, stream.listener)
}
if (stream.method === 'browser.screencast') {
this.activeBrowserStream = stream
}
if (metadata.type === 'end') {
stream.listener(result)
this.remove(response.id)
return true
}
}
if (!stream.cancelled) {
stream.listener(result)
}
return true
}
handleBinary(bytes: Uint8Array): void {
const browserFrame = decodeBrowserScreencastFrame(bytes)
if (browserFrame && this.activeBrowserStream?.onBinaryFrame) {
this.activeBrowserStream.onBinaryFrame(browserFrame)
return
}
handleTerminalBinaryFrame(bytes, {
terminalSnapshots: this.terminalSnapshots,
getListener: (streamId) => this.terminalListeners.get(streamId),
recordValidatedInboundTraffic: () => {}
})
}
clear(): void {
this.streams.clear()
this.terminalListeners.clear()
this.terminalSnapshots.clear()
this.activeBrowserStream = null
}
private cancel(id: string): void {
const stream = this.streams.get(id)
if (!stream || stream.cancelled) {
return
}
stream.cancelled = true
if (stream.method === 'terminal.subscribe') {
const params = buildTerminalUnsubscribeParams(stream.params)
if (params) {
this.options.sendFrame({
id: this.options.nextId(),
method: 'terminal.unsubscribe',
params
})
}
} else if (stream.subscriptionId) {
this.options.sendFrame({
id: this.options.nextId(),
method: stream.method.replace(/\.subscribe$/, '.unsubscribe'),
params: { subscriptionId: stream.subscriptionId }
})
}
this.remove(id)
}
private remove(id: string): void {
const stream = this.streams.get(id)
if (!stream) {
return
}
for (const streamId of stream.streamIds) {
this.terminalListeners.delete(streamId)
this.terminalSnapshots.delete(streamId)
}
if (this.activeBrowserStream === stream) {
this.activeBrowserStream = null
}
this.streams.delete(id)
}
}
@@ -0,0 +1,59 @@
import type { PairingCandidateClient } from './mobile-relay-physical-client'
export type PairingCandidate = {
path: 'direct' | 'relay'
client: PairingCandidateClient
}
export function racePairingCandidates(
candidates: readonly PairingCandidate[]
): Promise<PairingCandidate> {
return new Promise((resolve, reject) => {
const successes: PairingCandidate[] = []
let failures = 0
let settled = false
let selectionQueued = false
for (const candidate of candidates) {
void candidate.client.sendRequest('status.get').then(
(response) => {
if (!response.ok) {
failures++
rejectIfFinished()
return
}
successes.push(candidate)
if (selectionQueued) {
return
}
selectionQueued = true
// Why: defer one microtask so simultaneous successes are visible and
// direct deterministically wins the exact tie regardless of callback order.
queueMicrotask(() => {
if (settled) {
return
}
settled = true
const winner = successes.find(({ path }) => path === 'direct') ?? successes[0]!
for (const loser of candidates) {
if (loser !== winner) {
loser.client.close()
}
}
resolve(winner)
})
},
() => {
failures++
rejectIfFinished()
}
)
}
function rejectIfFinished(): void {
if (!settled && failures === candidates.length && successes.length === 0) {
settled = true
reject(new Error('direct and relay pairing paths both failed'))
}
}
})
}
@@ -0,0 +1,166 @@
import { describe, expect, it, vi } from 'vitest'
import type { PairingCandidateClient } from './mobile-relay-physical-client'
import { RelayOuterError } from './mobile-relay-physical-client'
import { createRecoveringPairingRelayCandidate } from './pairing-relay-candidate'
import type { MobileRelayPairingJournal } from './mobile-relay-pairing-journal'
vi.mock('react-native', () => ({ Platform: { OS: 'ios' } }))
vi.mock('expo-crypto', () => ({
getRandomBytes: (length: number) => new Uint8Array(length).fill(length)
}))
const journal = {
metadata: {
v: 1,
journalId: 'pair-1',
offerFingerprint: 'A'.repeat(43),
host: {
id: 'host-1',
name: 'Blue Whale',
endpoint: 'ws://192.168.1.10:6768',
publicKeyB64: 'A'.repeat(44),
lastConnected: 1
},
relay: {
v: 1,
directorUrl: 'https://relay.onorca.dev',
cellUrl: 'https://relay-c1.onorca.dev',
assignmentEpoch: 7,
relayHostId: 'AbCdEf0123_-xyZ9',
inviteExpiresAt: 10_000,
e2eeFraming: 2
},
installReqId: 'install-1',
resumeConfirmReqId: 'confirm-1',
pendingResumeTokenHash: 'B'.repeat(43)
},
secrets: {
v: 1,
journalId: 'pair-1',
deviceToken: 'device-token',
inviteToken: 'C'.repeat(43),
pendingResumeToken: 'D'.repeat(43)
}
} satisfies MobileRelayPairingJournal
function client(
result: Promise<never> | Promise<ReturnType<typeof success>>
): PairingCandidateClient {
return { sendRequest: vi.fn(() => result), close: vi.fn() }
}
function success() {
return {
id: 'rpc-1',
ok: true as const,
result: { path: 'relay' },
_meta: { runtimeId: 'runtime-1' }
}
}
describe('recovering pairing relay candidate', () => {
it('persists a strictly-newer director move before retrying the target', async () => {
const events: string[] = []
const stale = client(Promise.reject(new RelayOuterError(4409)))
const target = client(Promise.resolve(success()))
let connects = 0
const candidate = createRecoveringPairingRelayCandidate({
journal,
connect: (relay) => {
events.push(`connect:${relay.assignmentEpoch}`)
return connects++ === 0 ? stale : target
},
resolveDirector: async (relay) => ({
...relay,
cellUrl: 'https://relay-c2.onorca.dev',
assignmentEpoch: 8
}),
persistMove: async (relay) => {
events.push(`persist:${relay.assignmentEpoch}`)
},
now: () => 1,
random: () => 0,
sleep: async () => {}
})
await expect(candidate.sendRequest('status.get')).resolves.toEqual(success())
expect(events).toEqual(['connect:7', 'persist:8', 'connect:8'])
expect(stale.close).toHaveBeenCalledOnce()
})
it('does not ask the director to reinterpret endpoint-scoped host-offline', async () => {
const offline = client(Promise.reject(new RelayOuterError(4404)))
const resolveDirector = vi.fn()
const candidate = createRecoveringPairingRelayCandidate({
journal,
connect: () => offline,
resolveDirector,
persistMove: vi.fn(),
now: () => 1
})
await expect(candidate.sendRequest('status.get')).rejects.toEqual(new RelayOuterError(4404))
expect(resolveDirector).not.toHaveBeenCalled()
})
it.each([
['wrong cell', new RelayOuterError(4409)],
['planned drain', new RelayOuterError(4503)],
['opaque close', new RelayOuterError(1006)],
['HTTP 502', new Error('HTTP 502')],
['HTTP 503', new Error('HTTP 503')],
['HTTP 504', new Error('HTTP 504')],
['transport failure', new Error('relay transport error')]
])('uses the configured director after %s before E2EE', async (_name, failure) => {
const stale = client(Promise.reject(failure))
const target = client(Promise.resolve(success()))
const resolveDirector = vi.fn(async (relay) => ({
...relay,
cellUrl: 'https://relay-c2.onorca.dev',
assignmentEpoch: 8
}))
let connects = 0
const candidate = createRecoveringPairingRelayCandidate({
journal,
connect: () => (connects++ === 0 ? stale : target),
resolveDirector,
persistMove: vi.fn(async () => {}),
now: () => 1,
random: () => 0,
sleep: async () => {}
})
await expect(candidate.sendRequest('status.get')).resolves.toEqual(success())
expect(resolveDirector).toHaveBeenCalledOnce()
})
it('bounds director recovery and applies full jitter to failures and target retries', async () => {
const stale = client(Promise.reject(new Error('HTTP 503')))
const target = client(Promise.resolve(success()))
const resolveDirector = vi
.fn()
.mockRejectedValueOnce(new Error('HTTP 504'))
.mockRejectedValueOnce(new RelayOuterError(1006))
.mockImplementationOnce(async (relay) => ({
...relay,
cellUrl: 'https://relay-c2.onorca.dev',
assignmentEpoch: 8
}))
const sleep = vi.fn(async () => {})
let connects = 0
const candidate = createRecoveringPairingRelayCandidate({
journal,
connect: () => (connects++ === 0 ? stale : target),
resolveDirector,
persistMove: vi.fn(async () => {}),
now: () => 1,
random: () => 0.5,
sleep,
maxRecoveryAttempts: 3
})
await expect(candidate.sendRequest('status.get')).resolves.toEqual(success())
expect(resolveDirector).toHaveBeenCalledTimes(3)
expect(sleep.mock.calls.map(([delay]) => delay)).toEqual([50, 100, 200])
})
})
@@ -0,0 +1,90 @@
import type { PairingRelay } from '../../../src/shared/mobile-relay-pairing-offer'
import type { MobileRelayPairingJournal } from './mobile-relay-pairing-journal'
import { RelayOuterError, type PairingCandidateClient } from './mobile-relay-physical-client'
export function createRecoveringPairingRelayCandidate(args: {
journal: MobileRelayPairingJournal
connect: (relay: PairingRelay) => PairingCandidateClient
resolveDirector: (relay: PairingRelay) => Promise<PairingRelay>
persistMove: (relay: PairingRelay) => Promise<void>
now: () => number
random?: () => number
sleep?: (delayMs: number) => Promise<void>
maxRecoveryAttempts?: number
}): PairingCandidateClient {
let relay = pairingRelayFromJournal(args.journal)
let client = args.connect(relay)
let closed = false
return {
async sendRequest(method, params) {
try {
return await client.sendRequest(method, params)
} catch (error) {
if (
method !== 'status.get' ||
closed ||
relay.inviteExpiresAt <= args.now() ||
!isDirectorRecoverable(error)
) {
throw error
}
return recoverThroughDirector(method, params, error)
}
},
close() {
closed = true
client.close()
}
}
async function recoverThroughDirector(method: string, params: unknown, initialError: unknown) {
const maxAttempts = args.maxRecoveryAttempts ?? 3
const random = args.random ?? Math.random
const sleep =
args.sleep ?? ((delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs)))
let lastError = initialError
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
if (closed || relay.inviteExpiresAt <= args.now()) {
throw lastError
}
try {
const moved = await args.resolveDirector(relay)
// Why: the authenticated newer assignment must be durable before a
// target dial so a crash cannot revert to the known-stale cell.
await args.persistMove(moved)
client.close()
relay = moved
const capMs = Math.min(2_000, 100 * 2 ** attempt)
await sleep(Math.floor(random() * (capMs + 1)))
if (closed) {
throw new Error('relay pairing client closed')
}
client = args.connect(relay)
return await client.sendRequest(method, params)
} catch (error) {
lastError = error
if (!isDirectorRecoverable(error) || attempt + 1 >= maxAttempts) {
throw error
}
const capMs = Math.min(2_000, 100 * 2 ** attempt)
await sleep(Math.floor(random() * (capMs + 1)))
}
}
throw lastError
}
}
function pairingRelayFromJournal(journal: MobileRelayPairingJournal): PairingRelay {
return {
...journal.metadata.relay,
inviteToken: journal.secrets.inviteToken
}
}
function isDirectorRecoverable(error: unknown): boolean {
if (!(error instanceof RelayOuterError)) {
return true
}
return error.code === 4409 || error.code === 4503 || error.code === 1006
}
@@ -0,0 +1,159 @@
import { createServer, type Server } from 'node:http'
import type { AddressInfo } from 'node:net'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { WebSocket as NodeWebSocket, WebSocketServer } from 'ws'
import type { PairingRelay } from '../../../src/shared/mobile-relay-pairing-offer'
import {
connectMobileRelayForPairing,
type PairingCandidateClient
} from './mobile-relay-physical-client'
import type { MobileRelayPairingJournal } from './mobile-relay-pairing-journal'
import { createRecoveringPairingRelayCandidate } from './pairing-relay-candidate'
vi.mock('react-native', () => ({ Platform: { OS: 'ios' } }))
vi.mock('expo-crypto', () => ({
getRandomBytes: (length: number) => new Uint8Array(length).fill(length)
}))
const servers: Server[] = []
afterEach(async () => {
await Promise.all(
servers.splice(0).map((server) => new Promise<void>((resolve) => server.close(() => resolve())))
)
})
describe('served relay pairing recovery', () => {
it('uses the configured director after an HTTP 502 WebSocket upgrade response', async () => {
const cellUrl = await serveUpgradeFailure(502)
const journal = createJournal(cellUrl)
const resolvedRelay = {
...relayFromJournal(journal),
cellUrl: 'https://c2.relay-staging.onorca.dev',
assignmentEpoch: 8
}
const resolveDirector = vi.fn(async () => resolvedRelay)
const persistMove = vi.fn(async () => {})
const target = successfulClient()
const candidate = createRecoveringPairingRelayCandidate({
journal,
connect: (relay) => (relay.assignmentEpoch === 7 ? servedPhysicalClient(relay) : target),
resolveDirector,
persistMove,
now: () => 1,
random: () => 0,
sleep: async () => {}
})
await expect(candidate.sendRequest('status.get')).resolves.toMatchObject({ ok: true })
expect(resolveDirector).toHaveBeenCalledOnce()
expect(persistMove).toHaveBeenCalledWith(resolvedRelay)
})
it('keeps a served 4404 host-offline close scoped to the failed endpoint', async () => {
const cellUrl = await serveCloseCode(4404)
const journal = createJournal(cellUrl)
const resolveDirector = vi.fn()
const candidate = createRecoveringPairingRelayCandidate({
journal,
connect: servedPhysicalClient,
resolveDirector,
persistMove: vi.fn(async () => {}),
now: () => 1
})
await expect(candidate.sendRequest('status.get')).rejects.toMatchObject({ code: 4404 })
expect(resolveDirector).not.toHaveBeenCalled()
})
})
function servedPhysicalClient(relay: PairingRelay): PairingCandidateClient {
return connectMobileRelayForPairing({
relay,
deviceToken: 'device-token',
desktopPublicKeyB64: Buffer.alloc(32, 7).toString('base64'),
createSocket: (url) => {
// Why: production cells require TLS; the black-box test only substitutes
// a loopback plaintext transport while preserving the served upgrade path.
return new NodeWebSocket(url.replace('wss:', 'ws:')) as unknown as WebSocket
}
})
}
function successfulClient(): PairingCandidateClient {
return {
sendRequest: async () => ({
id: 'rpc-1',
ok: true,
result: { path: 'relay' },
_meta: { runtimeId: 'runtime-1' }
}),
close: vi.fn()
}
}
async function serveUpgradeFailure(status: number): Promise<string> {
const server = createServer()
server.on('upgrade', (_request, socket) => {
socket.end(`HTTP/1.1 ${status} Bad Gateway\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`)
})
return listen(server)
}
async function serveCloseCode(code: number): Promise<string> {
const server = createServer()
const sockets = new WebSocketServer({ server })
sockets.on('connection', (socket) => socket.close(code, 'host offline'))
return listen(server)
}
async function listen(server: Server): Promise<string> {
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
servers.push(server)
const address = server.address() as AddressInfo
return `http://127.0.0.1:${address.port}`
}
function createJournal(cellUrl: string): MobileRelayPairingJournal {
return {
metadata: {
v: 1,
journalId: 'pair-1',
offerFingerprint: 'A'.repeat(43),
host: {
id: 'host-1',
name: 'Blue Whale',
endpoint: 'ws://192.168.1.10:6768',
publicKeyB64: Buffer.alloc(32, 7).toString('base64'),
lastConnected: 1
},
relay: {
v: 1,
directorUrl: 'https://relay-staging.onorca.dev',
cellUrl,
assignmentEpoch: 7,
relayHostId: 'AbCdEf0123_-xyZ9',
inviteExpiresAt: 10_000,
e2eeFraming: 2
},
installReqId: 'install-1',
resumeConfirmReqId: 'confirm-1',
pendingResumeTokenHash: 'B'.repeat(43)
},
secrets: {
v: 1,
journalId: 'pair-1',
deviceToken: 'device-token',
inviteToken: 'C'.repeat(43),
pendingResumeToken: 'D'.repeat(43)
}
}
}
function relayFromJournal(journal: MobileRelayPairingJournal): PairingRelay {
return {
...journal.metadata.relay,
inviteToken: journal.secrets.inviteToken
}
}
@@ -0,0 +1,330 @@
import { describe, expect, it, vi } from 'vitest'
import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bundle'
import type { MobileRelayPairingJournal } from './mobile-relay-pairing-journal'
import { racePairingCandidates } from './pairing-candidate-race'
import { startPreProfilePairing } from './pre-profile-pairing-coordinator'
import type { HostProfile, PairingOffer, RpcResponse } from './types'
import type { RpcClient } from './rpc-client'
vi.mock('react-native', () => ({ Platform: { OS: 'ios' } }))
vi.mock('expo-crypto', () => ({
getRandomBytes: (length: number) => new Uint8Array(length).fill(length)
}))
vi.mock('expo-secure-store', () => ({ WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'WHEN_UNLOCKED' }))
const now = Date.UTC(2026, 6, 13)
const directOffer: PairingOffer = {
v: 2,
endpoint: 'ws://192.168.1.10:6768',
deviceToken: 'device-token',
publicKeyB64: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA='
}
const relayOffer: PairingOffer = {
...directOffer,
relay: {
v: 1,
directorUrl: 'https://relay.onorca.dev',
cellUrl: 'https://relay-c1.onorca.dev',
assignmentEpoch: 7,
relayHostId: 'AbCdEf0123_-xyZ9',
inviteToken: 'abcdefghijklmnopqrstuvwxyzABCDEFGH012345678',
inviteExpiresAt: now + 300_000,
e2eeFraming: 2
}
}
function success(result: unknown): RpcResponse {
return { id: 'rpc-1', ok: true, result, _meta: { runtimeId: 'runtime-1' } }
}
function failure(code: string): RpcResponse {
return {
id: 'rpc-1',
ok: false,
error: { code, message: code },
_meta: { runtimeId: 'runtime-1' }
}
}
function fakeClient(responses: RpcResponse[]) {
return {
sendRequest: vi.fn().mockImplementation(async () => responses.shift()!),
close: vi.fn()
} as unknown as RpcClient
}
function dependencies(client: RpcClient, events: string[]) {
const unavailableRelay = fakeClient([])
;(unavailableRelay.sendRequest as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error('relay unavailable')
)
return {
connectDirect: vi.fn(() => (events.push('connect'), client)),
connectRelay: vi.fn(() => unavailableRelay),
resolveInviteDirector: vi.fn(async () => {
throw new Error('director unavailable')
}),
getNextHostName: vi.fn(async () => 'Blue Whale'),
saveHost: vi.fn(async (_host: HostProfile) => {
events.push('save-host')
}),
saveJournal: vi.fn(async (_journal: MobileRelayPairingJournal) => {
events.push('save-journal')
}),
updateJournal: vi.fn(async () => {
events.push('update-journal')
}),
clearJournal: vi.fn(async () => {
events.push('clear-journal')
}),
writeCredentialBundle: vi.fn(async (_bundle: MobileRelayCredentialBundle) => {
events.push('write-credential')
}),
now: () => now,
platform: 'ios'
}
}
describe('pre-profile pairing coordinator', () => {
it('chooses direct when both post-E2EE status successes settle in the same turn', async () => {
let resolveDirect!: (response: RpcResponse) => void
let resolveRelay!: (response: RpcResponse) => void
const direct = fakeClient([])
const relay = fakeClient([])
;(direct.sendRequest as ReturnType<typeof vi.fn>).mockReturnValue(
new Promise<RpcResponse>((resolve) => {
resolveDirect = resolve
})
)
;(relay.sendRequest as ReturnType<typeof vi.fn>).mockReturnValue(
new Promise<RpcResponse>((resolve) => {
resolveRelay = resolve
})
)
const racing = racePairingCandidates([
{ path: 'direct', client: direct },
{ path: 'relay', client: relay }
])
resolveRelay(success({ path: 'relay' }))
resolveDirect(success({ path: 'direct' }))
await expect(racing).resolves.toMatchObject({ path: 'direct' })
expect(relay.close).toHaveBeenCalledOnce()
expect(direct.close).not.toHaveBeenCalled()
})
it('keeps a legacy offer direct-only through the shared path', async () => {
const events: string[] = []
const client = fakeClient([success({ version: '1.0.0' })])
const deps = dependencies(client, events)
const attempt = startPreProfilePairing({
offer: directOffer,
timeoutMs: 5_000,
dependencies: deps
})
await expect(attempt.result).resolves.toEqual({ hostId: `host-${now}` })
expect(deps.saveHost).toHaveBeenCalledWith({
id: `host-${now}`,
name: 'Blue Whale',
endpoint: directOffer.endpoint,
deviceToken: directOffer.deviceToken,
publicKeyB64: directOffer.publicKeyB64,
lastConnected: now
})
expect(events).toEqual(['connect', 'save-host'])
})
it('journals before connecting and publishes only after authoritative direct install', async () => {
const events: string[] = []
let journal: MobileRelayPairingJournal | null = null
const client = {
sendRequest: vi.fn(async (method: string) => {
if (method === 'status.get') {
return success({ version: '1.0.0' })
}
if (!journal) {
throw new Error('journal was not saved before RPC')
}
const installed = {
v: 1 as const,
reqId: journal.metadata.installReqId,
authorizationMode: 'authenticated-direct' as const,
currentVersion: 1,
resumeExpiresAt: now + 86_400_000
}
if (method === 'pairing.provisionRelay') {
return success(installed)
}
return success({
v: 1,
relay: {
v: 1,
directorUrl: relayOffer.relay!.directorUrl,
cellUrl: relayOffer.relay!.cellUrl,
assignmentEpoch: 7,
relayHostId: relayOffer.relay!.relayHostId,
e2eeFraming: 2
},
installStatus: {
v: 1,
reqId: journal.metadata.installReqId,
state: 'committed',
result: installed
}
})
}),
close: vi.fn()
} as unknown as RpcClient
const deps = dependencies(client, events)
deps.saveJournal.mockImplementation(async (value) => {
journal = value
events.push('save-journal')
})
const attempt = startPreProfilePairing({
offer: relayOffer,
timeoutMs: 5_000,
dependencies: deps
})
await expect(attempt.result).resolves.toEqual({ hostId: `host-${now}` })
expect(journal).not.toBeNull()
expect(events).toEqual([
'save-journal',
'connect',
'update-journal',
'write-credential',
'save-host',
'clear-journal'
])
expect(client.sendRequest).toHaveBeenNthCalledWith(2, 'pairing.provisionRelay', {
reqId: journal!.metadata.installReqId,
newResumeTokenHash: journal!.metadata.pendingResumeTokenHash
})
expect(deps.saveHost).toHaveBeenCalledWith(
expect.objectContaining({
id: `host-${now}`,
endpoint: directOffer.endpoint,
relayHostId: relayOffer.relay!.relayHostId,
endpoints: [
{ id: 'direct-primary', kind: 'lan', url: directOffer.endpoint },
{
id: 'relay-primary',
kind: 'relay',
url: `wss://relay-c1.onorca.dev/v1/connect/${relayOffer.relay!.relayHostId}`
}
]
})
)
})
it('tolerates an old desktop method_not_found and commits a direct-only host', async () => {
const events: string[] = []
const client = fakeClient([success({ version: '1.0.0' }), failure('method_not_found')])
const deps = dependencies(client, events)
const attempt = startPreProfilePairing({
offer: relayOffer,
timeoutMs: 5_000,
dependencies: deps
})
await expect(attempt.result).resolves.toEqual({ hostId: `host-${now}` })
expect(deps.saveHost).toHaveBeenCalledWith(
expect.not.objectContaining({ endpoints: expect.anything() })
)
expect(events).toEqual([
'save-journal',
'connect',
'update-journal',
'save-host',
'clear-journal'
])
})
it('uses relay-basis provisioning when only the relay reaches post-E2EE status', async () => {
const direct = fakeClient([])
;(direct.sendRequest as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('LAN down'))
let journal: MobileRelayPairingJournal | null = null
const relay = {
sendRequest: vi.fn(async (method: string) => {
if (method === 'status.get') {
return success({ path: 'relay' })
}
const installed = {
v: 1 as const,
reqId: journal!.metadata.installReqId,
authorizationMode: 'relay-basis' as const,
currentVersion: 1,
resumeExpiresAt: now + 86_400_000
}
if (method === 'pairing.provisionRelay') {
return success(installed)
}
return success({
v: 1,
relay: {
v: 1,
directorUrl: relayOffer.relay!.directorUrl,
cellUrl: relayOffer.relay!.cellUrl,
assignmentEpoch: 7,
relayHostId: relayOffer.relay!.relayHostId,
e2eeFraming: 2
},
installStatus: {
v: 1,
reqId: journal!.metadata.installReqId,
state: 'committed',
result: installed
}
})
}),
close: vi.fn()
} as unknown as RpcClient
const deps = dependencies(direct, [])
deps.connectRelay.mockReturnValue(relay)
deps.saveJournal.mockImplementation(async (value) => {
journal = value
})
const attempt = startPreProfilePairing({
offer: relayOffer,
timeoutMs: 5_000,
dependencies: deps
})
await expect(attempt.result).resolves.toEqual({ hostId: `host-${now}` })
expect(direct.close).toHaveBeenCalled()
expect(relay.sendRequest).toHaveBeenNthCalledWith(2, 'pairing.provisionRelay', {
reqId: journal!.metadata.installReqId,
newResumeTokenHash: journal!.metadata.pendingResumeTokenHash
})
expect(deps.writeCredentialBundle).toHaveBeenCalledWith(
expect.objectContaining({ current: expect.objectContaining({ version: 1 }) })
)
})
it('cancels the disposable physical client without publishing a host', async () => {
let resolveStatus!: (response: RpcResponse) => void
const status = new Promise<RpcResponse>((resolve) => {
resolveStatus = resolve
})
const client = fakeClient([])
;(client.sendRequest as ReturnType<typeof vi.fn>).mockReturnValue(status)
const deps = dependencies(client, [])
const attempt = startPreProfilePairing({
offer: directOffer,
timeoutMs: 5_000,
dependencies: deps
})
await vi.waitFor(() => expect(client.sendRequest).toHaveBeenCalledWith('status.get'))
attempt.dispose()
resolveStatus(success({ version: '1.0.0' }))
await expect(attempt.result).rejects.toThrow(/cancelled/)
expect(client.close).toHaveBeenCalledOnce()
expect(deps.saveHost).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,301 @@
import { Platform } from 'react-native'
import {
DeviceCredentialInstalledSchema,
PairingGetEndpointsResultSchema,
type DeviceCredentialInstalled,
type MobileRelayEndpoint
} from '../../../src/shared/mobile-relay-credential-contract'
import { connect, type ConnectOptions } from './rpc-client'
import { getNextHostName, saveHost } from './host-store'
import type { HostProfile, PairingOffer, RpcResponse } from './types'
import {
createMobileRelayPairingJournal,
type MobileRelayPairingJournal
} from './mobile-relay-pairing-journal'
import {
clearMobileRelayPairingJournal,
saveMobileRelayPairingJournal,
updateMobileRelayPairingJournal
} from './mobile-relay-pairing-journal-store'
import {
promotePairingJournalCredential,
writeMobileRelayCredentialBundle
} from './mobile-relay-credential-bundle'
import {
connectMobileRelayForPairing,
type PairingCandidateClient
} from './mobile-relay-physical-client'
import { racePairingCandidates, type PairingCandidate } from './pairing-candidate-race'
import { resolvePairingInviteThroughDirector } from './mobile-relay-invite-director'
import { createRecoveringPairingRelayCandidate } from './pairing-relay-candidate'
export type PreProfilePairingAttempt = {
readonly result: Promise<{ hostId: string }>
readonly timedOut: boolean
dispose(): void
}
type Dependencies = {
connectDirect: typeof connect
connectRelay: typeof connectMobileRelayForPairing
resolveInviteDirector: typeof resolvePairingInviteThroughDirector
getNextHostName: typeof getNextHostName
saveHost: typeof saveHost
saveJournal: typeof saveMobileRelayPairingJournal
updateJournal: typeof updateMobileRelayPairingJournal
clearJournal: typeof clearMobileRelayPairingJournal
writeCredentialBundle: typeof writeMobileRelayCredentialBundle
now: () => number
platform: string
}
const defaultDependencies: Dependencies = {
connectDirect: connect,
connectRelay: connectMobileRelayForPairing,
resolveInviteDirector: resolvePairingInviteThroughDirector,
getNextHostName,
saveHost,
saveJournal: saveMobileRelayPairingJournal,
updateJournal: updateMobileRelayPairingJournal,
clearJournal: clearMobileRelayPairingJournal,
writeCredentialBundle: writeMobileRelayCredentialBundle,
now: Date.now,
platform: Platform.OS
}
export function startPreProfilePairing(args: {
offer: PairingOffer
timeoutMs: number
connectOptions?: ConnectOptions
dependencies?: Partial<Dependencies>
}): PreProfilePairingAttempt {
const dependencies = { ...defaultDependencies, ...args.dependencies }
const clients = new Set<PairingCandidateClient>()
let disposed = false
let timedOut = false
let timer: ReturnType<typeof setTimeout> | null = null
const dispose = (): void => {
if (disposed) {
return
}
disposed = true
if (timer) {
clearTimeout(timer)
timer = null
}
for (const client of clients) {
client.close()
}
clients.clear()
}
timer = setTimeout(() => {
timedOut = true
dispose()
}, args.timeoutMs)
const result = runPairing(args.offer, args.connectOptions, dependencies, clients, () => disposed)
.catch((error: unknown) => {
if (timedOut) {
throw new Error('mobile pairing timed out')
}
throw error
})
.finally(() => {
if (timer) {
clearTimeout(timer)
timer = null
}
for (const client of clients) {
client.close()
}
clients.clear()
})
return {
result,
get timedOut() {
return timedOut
},
dispose
}
}
async function runPairing(
offer: PairingOffer,
connectOptions: ConnectOptions | undefined,
dependencies: Dependencies,
clients: Set<PairingCandidateClient>,
isDisposed: () => boolean
): Promise<{ hostId: string }> {
const now = dependencies.now()
const hostId = `host-${now}`
const hostName = await dependencies.getNextHostName()
assertActive(isDisposed)
let journal: MobileRelayPairingJournal | null = null
if (offer.relay && dependencies.platform !== 'web') {
journal = createMobileRelayPairingJournal({
offer: { ...offer, relay: offer.relay },
hostId,
hostName,
now
})
await dependencies.saveJournal(journal)
assertActive(isDisposed)
}
const directClient = dependencies.connectDirect(
offer.endpoint,
offer.deviceToken,
offer.publicKeyB64,
connectOptions
)
clients.add(directClient)
const candidates: PairingCandidate[] = [{ path: 'direct', client: directClient }]
if (journal) {
const relayClient = createRecoveringPairingRelayCandidate({
journal,
connect: (relay) =>
dependencies.connectRelay({
relay,
deviceToken: offer.deviceToken,
desktopPublicKeyB64: offer.publicKeyB64
}),
resolveDirector: (relay) => dependencies.resolveInviteDirector({ relay }),
persistMove: async (relay) => {
journal = {
...journal!,
metadata: {
...journal!.metadata,
relay: {
...journal!.metadata.relay,
cellUrl: relay.cellUrl,
assignmentEpoch: relay.assignmentEpoch
}
}
}
await dependencies.updateJournal(journal.metadata.journalId, () => journal!.metadata)
},
now: dependencies.now
})
clients.add(relayClient)
candidates.push({ path: 'relay', client: relayClient })
}
const winner = await racePairingCandidates(candidates)
assertActive(isDisposed)
if (!journal) {
await dependencies.saveHost(baseHost(offer, hostId, hostName, now))
return { hostId }
}
journal = {
...journal,
metadata: {
...journal.metadata,
winner: winner.path,
authorizationMode: winner.path === 'direct' ? 'authenticated-direct' : 'relay-basis'
}
}
await dependencies.updateJournal(journal.metadata.journalId, () => journal!.metadata)
const provision = await winner.client.sendRequest('pairing.provisionRelay', {
reqId: journal.metadata.installReqId,
newResumeTokenHash: journal.metadata.pendingResumeTokenHash
})
if (isMethodNotFound(provision)) {
if (winner.path !== 'direct') {
throw new Error('relay pairing RPC unavailable after relay path authentication')
}
await dependencies.saveHost(baseHost(offer, hostId, hostName, now))
await dependencies.clearJournal(journal.metadata.journalId)
return { hostId }
}
const installed = DeviceCredentialInstalledSchema.parse(requireSuccess(provision))
const endpoints = PairingGetEndpointsResultSchema.parse(
requireSuccess(
await winner.client.sendRequest('pairing.getEndpoints', {
installReqId: journal.metadata.installReqId
})
)
)
assertCommittedInstall(endpoints.installStatus, installed)
if (!endpoints.relay) {
throw new Error('desktop returned no relay endpoint after credential install')
}
assertActive(isDisposed)
await dependencies.writeCredentialBundle(promotePairingJournalCredential({ journal, installed }))
await dependencies.saveHost(relayHost(journal, endpoints.relay))
await dependencies.clearJournal(journal.metadata.journalId)
return { hostId }
}
function baseHost(
offer: PairingOffer,
hostId: string,
name: string,
lastConnected: number
): HostProfile {
return {
id: hostId,
name,
endpoint: offer.endpoint,
deviceToken: offer.deviceToken,
publicKeyB64: offer.publicKeyB64,
lastConnected
}
}
function relayHost(journal: MobileRelayPairingJournal, relay: MobileRelayEndpoint): HostProfile {
const host = journal.metadata.host
return {
...host,
deviceToken: journal.secrets.deviceToken,
endpoints: [
{ id: 'direct-primary', kind: 'lan', url: host.endpoint },
{ id: 'relay-primary', kind: 'relay', url: relayWebSocketUrl(relay) }
],
relayHostId: relay.relayHostId,
relay
}
}
function relayWebSocketUrl(relay: MobileRelayEndpoint): string {
const url = new URL(relay.cellUrl)
url.protocol = 'wss:'
url.pathname = `/v1/connect/${encodeURIComponent(relay.relayHostId)}`
return url.toString()
}
function requireSuccess(response: RpcResponse): unknown {
if (!response.ok) {
throw new Error(`${response.error.code}: ${response.error.message}`)
}
return response.result
}
function isMethodNotFound(response: RpcResponse): boolean {
return !response.ok && response.error.code === 'method_not_found'
}
function assertCommittedInstall(
status:
| { state: 'not-found' }
| { state: 'committed'; result: DeviceCredentialInstalled }
| undefined,
installed: DeviceCredentialInstalled
): void {
if (
!status ||
status.state !== 'committed' ||
JSON.stringify(status.result) !== JSON.stringify(installed)
) {
throw new Error('relay credential install was not authoritatively reconciled')
}
}
function assertActive(isDisposed: () => boolean): void {
if (isDisposed()) {
throw new Error('mobile pairing cancelled')
}
}

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