Merge remote-tracking branch 'origin/main' into brennanb2025/fix-10385-false-connected

# Conflicts:
#	mobile/src/transport/rpc-client.ts
This commit is contained in:
Brennan Benson
2026-07-31 11:37:50 -07:00
249 changed files with 15036 additions and 1647 deletions
+21
View File
@@ -1015,6 +1015,27 @@ jobs:
with:
ref: refs/tags/${{ needs.cut.outputs.tag }}
# Why: `uses: ./…` resolves from the checked-out tag, not from the workflow
# ref, so cutting from an older/off-main ref whose tree predates a composite
# action would fail the step with "Can't find 'action.yml'". Restore the
# actions directory from the commit this workflow file itself came from.
- name: Restore composite actions from the workflow ref
if: matrix.platform == 'win'
shell: bash
env:
WORKFLOW_SHA: ${{ github.workflow_sha }}
run: |
set -euo pipefail
action_path=".github/actions/install-signpath-module/action.yml"
if [ -f "$action_path" ]; then
echo "Composite actions already present at the cut ref."
exit 0
fi
echo "Cut ref predates $action_path; restoring it from $WORKFLOW_SHA."
git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA"
git checkout "$WORKFLOW_SHA" -- .github/actions
test -f "$action_path"
# pnpm must be on PATH before setup-node so setup-node can locate the store for caching.
- name: Setup pnpm
uses: pnpm/action-setup@v6
@@ -0,0 +1,107 @@
import { BOOTSTRAP_FATAL_EXIT_GUARD_KEY } from '../src/main/startup/bootstrap-fatal-exit-guard'
export const BOOTSTRAP_FATAL_LOG_ENV_VAR = 'ORCA_BOOTSTRAP_FATAL_LOG'
export const BOOTSTRAP_FATAL_LOG_FILE_NAME = 'bootstrap-fatal.log'
const BOOTSTRAP_FATAL_LOG_MAX_BYTES = 262_144
export function createBootstrapFatalExitBanner(): string {
// Why: Electron's pre-import error dialog can leave main resident and block NSIS replacement.
// Suppressing that dialog also removes the only account of the failure, so record one first:
// a partially copied resources tree otherwise exits silently on every launch, with nothing
// for the user or a support log to name the module that was missing.
return `
;(() => {
const guardKey = ${JSON.stringify(BOOTSTRAP_FATAL_EXIT_GUARD_KEY)}
if (typeof globalThis[guardKey] === 'function') {
return
}
const describeBootstrapError = (error) => {
try {
const detail = error && typeof error === 'object' && error.stack ? error.stack : error
return String(detail).split('\\n').slice(0, 12).join(' | ').slice(0, 4000)
} catch {
return '<unprintable error>'
}
}
const readBootstrapFatalLogOverride = () => {
const override = typeof process.env === 'object' && process.env ? process.env.${BOOTSTRAP_FATAL_LOG_ENV_VAR} : undefined
return typeof override === 'string' && override.length > 0 ? override : undefined
}
const resolveDefaultBootstrapFatalLogPath = () => {
let directory
try {
directory = require('electron').app.getPath('userData')
} catch {
// Why: a bootstrap fault can predate a usable app object; temp still outlives the process.
directory = require('node:os').tmpdir()
}
return require('node:path').join(directory, ${JSON.stringify(BOOTSTRAP_FATAL_LOG_FILE_NAME)})
}
const appendBootstrapFatalLine = (fs, logPath, entry) => {
try {
// Why: an override can name a directory nothing has created yet, and a missing
// parent would drop the only account of the failure.
fs.mkdirSync(require('node:path').dirname(logPath), { recursive: true })
// Why: a broken install repeats this on every relaunch; keep the trail bounded.
let flags = 'a'
try {
flags = fs.statSync(logPath).size > ${BOOTSTRAP_FATAL_LOG_MAX_BYTES} ? 'w' : 'a'
} catch {
flags = 'a'
}
const descriptor = fs.openSync(logPath, flags, 0o600)
try {
fs.writeSync(descriptor, entry)
} finally {
fs.closeSync(descriptor)
}
return true
} catch {
return false
}
}
const recordBootstrapFailure = (error) => {
const line = '[bootstrap] fatal-exit pid=' + process.pid + ' error=' + describeBootstrapError(error) + '\\n'
let fs
try {
fs = require('node:fs')
} catch {
fs = undefined
}
try {
fs.writeSync(2, line)
} catch {
try {
process.stderr.write(line)
} catch {
// Diagnostics must never replace the exit below.
}
}
try {
const entry = new Date().toISOString() + ' ' + line
const override = readBootstrapFatalLogOverride()
// Why: an unwritable override must cost the user a location, not the diagnostic.
if (!override || !appendBootstrapFatalLine(fs, override, entry)) {
appendBootstrapFatalLine(fs, resolveDefaultBootstrapFatalLogPath(), entry)
}
} catch {
// Diagnostics must never replace the exit below.
}
}
let exitScheduled = false
const exitAfterBootstrapFailure = (error) => {
if (exitScheduled) {
return
}
exitScheduled = true
recordBootstrapFailure(error)
process.exitCode = 1
setImmediate(() => process.exit(1))
}
globalThis[guardKey] = () => {
process.off('uncaughtException', exitAfterBootstrapFailure)
delete globalThis[guardKey]
}
process.once('uncaughtException', exitAfterBootstrapFailure)
})();
`
}
+165 -15
View File
@@ -2224,6 +2224,7 @@
"Advanced Active Server setting",
"saved server Connect and Disconnect",
"remote workspace navigation",
"Add Project host selection, scan, import, and catalog refresh",
"terminal reveal and create",
"browser and mobile handoff",
"app restart"
@@ -2232,12 +2233,16 @@
"providers": ["local", "remote-runtime", "ssh", "wsl"],
"coveredPlatforms": ["macos"],
"coveredProviders": ["local", "remote-runtime", "ssh", "wsl"],
"coverageNotes": "Platform-neutral deterministic tests separate the durable Active Server preference from per-client connection, selected-workspace, browser-session, and execution-host routing. The composed regression models Local desktop -> connect/navigate Windows 2 -> reveal a local terminal -> restart. Multi-client browser host overrides, multi-server profile caches, generic settings IPC rejection, local and remote workspace ownership, and restart reset of transient routing are covered. Live desktop UI runs remain uncollected.",
"coverageNotes": "Platform-neutral deterministic tests separate the durable Active Server preference from per-client connection, selected-workspace, browser-session, Add Project, and execution-host routing. The composed regression models Local desktop -> connect/navigate Windows 2 -> reveal a local terminal -> restart. Multi-client browser host overrides, multi-server profile caches, generic settings IPC rejection, local and remote workspace ownership, Add Project owner capture and stale-result fencing, host-qualified group/folder/worktree identity, and restart reset of transient routing are covered. A visible macOS Orca desktop server plus a separate paired Electron client proves real Git, plain-folder, Clone, Create, nested import, runtime-switch, disconnect, reconnect, concurrently visible same-ID local/runtime repo/worktree/group/folder rows, reversed catalog order, reversed refresh completion, local-after-runtime catalog refresh routing, and first paired-terminal activation without local fallback; hidden-window parity also passed. Live Linux, Windows, WSL, and SSH Add Project journeys remain uncollected.",
"motivatingLinks": ["https://github.com/stablyai/orca/pull/9687"],
"invariant": "Only an explicit user change in Settings > Remote Orca Servers > Advanced > Active Server may mutate activeRuntimeEnvironmentId. Connecting, pairing, disconnecting, selecting or revealing a workspace or terminal, browser/mobile handoff, remote navigation, and reconnect must use transient or target-owner routing and must never rewrite the durable preference. Generic settings mutation cannot bypass the dedicated preference IPC.",
"oracle": "Start with Active Server=Local desktop, connect and navigate Windows 2, then reveal a local terminal and assert it succeeds while the persisted preference remains local before and after restart. Repeat with multiple clients and servers, browser host switches, remote-owned and local-owned workspaces, pairing/connect/disconnect, and generic settings writes. Assert only the dedicated validated preference method changes activeRuntimeEnvironmentId and stale host-operation completions cannot overwrite the newly selected transient host.",
"invariant": "Only an explicit user change in Settings > Remote Orca Servers > Advanced > Active Server may mutate activeRuntimeEnvironmentId. Connecting, pairing, disconnecting, selecting or revealing a workspace or terminal, browser/mobile handoff, remote navigation, and reconnect must use transient or target-owner routing and must never rewrite the durable preference. Every Add Project scan, add, import, catalog refresh, and worktree refresh must stay on the host selected when that operation began, even if the durable preference changes or the repo catalog is not hydrated. Generic settings mutation cannot bypass the dedicated preference IPC.",
"oracle": "Start with Active Server=Local desktop, connect and navigate Windows 2, then reveal a local terminal and assert it succeeds while the persisted preference remains local before and after restart. Repeat with multiple clients and servers, browser host switches, remote-owned and local-owned workspaces, pairing/connect/disconnect, and generic settings writes. In a separate paired Electron client, select a non-default headed runtime and add a real Git repo, plain folder, clone, created project, and nested repos while switching the preference and disconnecting/reconnecting. Before Git Add, plain-folder Add, Clone, and Create completion, insert a same-ID local repo/worktree decoy; require final activation to remain on the captured runtime. Merge same-ID local/runtime repo, worktree, group, and folder rows, reverse same-host refresh completions, overlap refresh with reconnect, issue ordinary unqualified local refreshes, then create the first paired runtime terminal while the same-ID local/runtime worktrees remain visible. Require only the selected server inventory to change, exact runtime ownership in the client store, preserved host-qualified catalogs, authoritative direct-SSH pruning without runtime sibling loss, visible projects, and zero direct SSH fallback. Assert only the dedicated validated preference method changes activeRuntimeEnvironmentId and stale host-operation completions cannot overwrite the newly selected transient host.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/settings.test.ts src/main/ipc/runtime-environments.test.ts src/renderer/src/store/slices/settings.test.ts src/renderer/src/store/slices/browser.test.ts src/renderer/src/components/settings/browser-session-host-selection.test.ts src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts src/renderer/src/components/status-bar/SshStatusSegment.test.ts src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts src/renderer/src/hooks/useIpcEvents.test.ts src/renderer/src/web/web-preload-api.test.ts --maxWorkers=1"
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/settings.test.ts src/main/ipc/runtime-environments.test.ts src/renderer/src/store/slices/settings.test.ts src/renderer/src/store/slices/browser.test.ts src/renderer/src/components/settings/browser-session-host-selection.test.ts src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts src/renderer/src/components/status-bar/SshStatusSegment.test.ts src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts src/renderer/src/hooks/useIpcEvents.test.ts src/renderer/src/web/web-preload-api.test.ts --maxWorkers=1",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx src/renderer/src/components/sidebar/AddRepoDialog.default-checkout.test.ts src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts src/renderer/src/components/sidebar/project-added-default-checkout.test.ts src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.test.ts src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts src/renderer/src/components/sidebar/useAddRepoServerPathFlow.test.ts src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts src/renderer/src/components/sidebar/worktree-list-host-filtering.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/lib/worktree-runtime-owner.test.ts src/renderer/src/store/selectors.test.ts src/renderer/src/store/slices/repos-all-hosts-folder-workspaces.test.ts src/renderer/src/store/slices/repos-project-groups.test.ts src/renderer/src/store/slices/repos-selected-owner-routing.test.ts src/renderer/src/store/slices/selected-host-active-workspace-identity.test.ts src/renderer/src/store/slices/worktrees.test.ts --maxWorkers=1",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx src/renderer/src/components/sidebar/AddRepoDialog.default-checkout.test.ts src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts src/renderer/src/components/sidebar/project-added-default-checkout.test.ts src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.test.ts src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts src/renderer/src/components/sidebar/useAddRepoServerPathFlow.test.ts src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts src/renderer/src/components/sidebar/worktree-list-host-filtering.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/lib/worktree-runtime-owner.test.ts src/renderer/src/store/selectors.test.ts src/renderer/src/store/slices/repos-all-hosts-folder-workspaces.test.ts src/renderer/src/store/slices/repos-project-groups.test.ts src/renderer/src/store/slices/repos-selected-owner-routing.test.ts src/renderer/src/store/slices/selected-host-active-workspace-identity.test.ts src/renderer/src/store/slices/worktrees.test.ts src/renderer/src/runtime/web-runtime-session.test.ts --maxWorkers=1",
"pnpm run ensure:electron-runtime && npx playwright test tests/e2e/pr11346-selected-runtime-add.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1 --reporter=line",
"pnpm run ensure:electron-runtime && npx playwright test tests/e2e/pr11346-selected-runtime-add.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line"
],
"testFiles": [
"src/main/ipc/settings.test.ts",
@@ -2249,7 +2254,28 @@
"src/renderer/src/components/status-bar/SshStatusSegment.test.ts",
"src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts",
"src/renderer/src/hooks/useIpcEvents.test.ts",
"src/renderer/src/web/web-preload-api.test.ts"
"src/renderer/src/web/web-preload-api.test.ts",
"src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx",
"src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx",
"src/renderer/src/components/sidebar/AddRepoDialog.default-checkout.test.ts",
"src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts",
"src/renderer/src/components/sidebar/project-added-default-checkout.test.ts",
"src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.test.ts",
"src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts",
"src/renderer/src/components/sidebar/useAddRepoServerPathFlow.test.ts",
"src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts",
"src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts",
"src/renderer/src/components/sidebar/worktree-list-host-filtering.test.ts",
"src/renderer/src/lib/resolved-worktree-execution-host.test.ts",
"src/renderer/src/lib/worktree-runtime-owner.test.ts",
"src/renderer/src/store/selectors.test.ts",
"src/renderer/src/store/slices/repos-all-hosts-folder-workspaces.test.ts",
"src/renderer/src/store/slices/repos-project-groups.test.ts",
"src/renderer/src/store/slices/repos-selected-owner-routing.test.ts",
"src/renderer/src/store/slices/selected-host-active-workspace-identity.test.ts",
"src/renderer/src/store/slices/worktrees.test.ts",
"src/renderer/src/runtime/web-runtime-session.test.ts",
"tests/e2e/pr11346-selected-runtime-add.spec.ts"
],
"assertionRefs": [
{
@@ -2292,6 +2318,73 @@
"generic web settings writes cannot mutate Active Server",
"the dedicated web preference setter rejects unknown server identities without corrupting the saved choice"
]
},
{
"file": "src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts",
"assertions": [
"stamps a Clone response with the captured runtime or SSH owner before store upsert and worktree refresh",
"preserves same-ID repository siblings that belong to different execution hosts"
]
},
{
"file": "src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts",
"assertions": [
"stamps Create responses with the captured runtime or SSH owner before store upsert and Git or folder worktree refresh"
]
},
{
"file": "src/renderer/src/store/slices/repos-selected-owner-routing.test.ts",
"assertions": [
"keeps same-ID local, direct-SSH, and runtime group/folder rows partitioned by execution host",
"drops reversed same-host and pre-reconnect group/folder responses without pruning newer catalogs",
"prunes deleted direct-SSH rows from the desktop-owned catalog without erasing same-ID runtime siblings",
"keeps explicit runtime groups and folders after a later ordinary local refresh",
"keeps a selected-runtime import refresh across an overlapping local refresh",
"pins selected SSH scans and cancellation to local IPC over an ambient runtime"
]
},
{
"file": "src/renderer/src/components/sidebar/project-added-default-checkout.test.ts",
"assertions": [
"filters loaded, detected, refreshed, and activated default checkouts by the captured execution host when repo IDs collide"
]
},
{
"file": "src/renderer/src/store/slices/worktrees.test.ts",
"assertions": [
"honors an explicit runtime owner before the repo catalog is hydrated",
"honors an explicit SSH owner before the repo catalog is hydrated",
"rejects a missing-owner SSH result after the repo catalog changes",
"rejects a missing-owner SSH result after the provider reconnects"
]
},
{
"file": "src/renderer/src/store/slices/selected-host-active-workspace-identity.test.ts",
"assertions": [
"selects the runtime worktree when exact repo/worktree IDs collide in local-first or reversed order",
"keeps exact-ID folder/group activation on the explicitly selected runtime without local fallback"
]
},
{
"file": "src/renderer/src/store/selectors.test.ts",
"assertions": [
"does not fall back to a same-ID local repo when the selected runtime row is unavailable"
]
},
{
"file": "src/renderer/src/runtime/web-runtime-session.test.ts",
"assertions": [
"terminal, browser, and staged-browser selection preserve the explicit runtime execution host when local and runtime worktree IDs collide"
]
},
{
"file": "tests/e2e/pr11346-selected-runtime-add.spec.ts",
"assertions": [
"routes Git Add, plain-folder Add, Clone, Create, and nested import to a selected non-default headed runtime while exact-ID local siblings remain visible and cannot capture final activation @headful",
"preserves same-ID runtime group/folder/worktree catalogs across reversed order, local refresh, switch, disconnect, and reconnect-overlap completion",
"keeps every expanded selected-runtime Add Project path in hidden-window desktop parity",
"creates the first paired runtime terminal while same-ID local/runtime worktrees remain visible without clearing the selected runtime owner"
]
}
],
"evidenceRuns": [
@@ -2303,34 +2396,91 @@
"result": "passed",
"durationSeconds": 6.55,
"summary": "Ten files and 282 tests passed, including the composed Local -> Windows 2 navigation -> local reveal -> restart regression, dedicated-only preference persistence, removed transient-host fallback, multi-client browser routing, late host-operation suppression, and web pairing/preference separation."
},
{
"date": "2026-07-30",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx src/renderer/src/components/sidebar/AddRepoDialog.default-checkout.test.ts src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts src/renderer/src/components/sidebar/project-added-default-checkout.test.ts src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.test.ts src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts src/renderer/src/components/sidebar/useAddRepoServerPathFlow.test.ts src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts src/renderer/src/components/sidebar/worktree-list-host-filtering.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/lib/worktree-runtime-owner.test.ts src/renderer/src/store/selectors.test.ts src/renderer/src/store/slices/repos-all-hosts-folder-workspaces.test.ts src/renderer/src/store/slices/repos-project-groups.test.ts src/renderer/src/store/slices/repos-selected-owner-routing.test.ts src/renderer/src/store/slices/selected-host-active-workspace-identity.test.ts src/renderer/src/store/slices/worktrees.test.ts --maxWorkers=1",
"result": "passed",
"durationSeconds": 8.77,
"summary": "Nineteen files and 386 tests passed after rebasing onto 94cf2f1422f30fc309cb47c5e864a831d516fa8b, including exact-ID repo/worktree/group/folder host identity in both catalog orders, fail-closed active-repo selection when a runtime row is unavailable, selected runtime and SSH owner capture through final Add/Clone/Create/folder activation, direct-SSH authoritative pruning, reversed catalog responses, reconnect generation fencing, local-after-runtime isolation, and missing-catalog worktree routing."
},
{
"date": "2026-07-30",
"runner": "local",
"platform": "macos",
"command": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/pr11346-selected-runtime-add.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1 --reporter=line",
"result": "passed",
"durationSeconds": 23,
"summary": "A visible isolated Orca desktop server and separate paired Electron client added one real Git repo, one plain folder, one clone, one created project, and two nested repos to the selected non-default runtime across preference switches and disconnect/reconnect. Exact-ID local/runtime repo, worktree, group, and folder rows remained concurrently visible in local-first and reversed order without capturing final activation. Server inventory, active host identity, rendered rows, local-client exclusion, preserved post-reconnect catalogs, and zero direct SSH fallback agreed. Runtime switch/Git/folder/Clone/Create/reconnect/nested-import measurements were 307/1667/2932/1247/1321/259/1319 ms."
},
{
"date": "2026-07-30",
"runner": "local",
"platform": "macos",
"command": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/pr11346-selected-runtime-add.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line",
"result": "passed",
"durationSeconds": 66,
"summary": "The hidden-window desktop-server parity journey passed with exact-ID Add/Clone/Create activation, selected-runtime inventory, reversed catalog order, reconnect overlap, ownership, local exclusion, and host-qualified catalog-preservation assertions. Runtime switch/Git/folder/Clone/Create/reconnect/nested-import measurements were 59/1770/4330/1375/2137/279/1945 ms."
},
{
"date": "2026-07-31",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx src/renderer/src/components/sidebar/AddRepoDialog.default-checkout.test.ts src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts src/renderer/src/components/sidebar/project-added-default-checkout.test.ts src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.test.ts src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts src/renderer/src/components/sidebar/useAddRepoServerPathFlow.test.ts src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts src/renderer/src/components/sidebar/worktree-list-host-filtering.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/lib/worktree-runtime-owner.test.ts src/renderer/src/store/selectors.test.ts src/renderer/src/store/slices/repos-all-hosts-folder-workspaces.test.ts src/renderer/src/store/slices/repos-project-groups.test.ts src/renderer/src/store/slices/repos-selected-owner-routing.test.ts src/renderer/src/store/slices/selected-host-active-workspace-identity.test.ts src/renderer/src/store/slices/worktrees.test.ts src/renderer/src/runtime/web-runtime-session.test.ts --maxWorkers=1",
"result": "passed",
"durationSeconds": 7.1,
"summary": "Twenty files and 430 tests passed on current main integration, including explicit runtime-owner preservation for terminal and browser session activation."
},
{
"date": "2026-07-31",
"runner": "local",
"platform": "macos",
"command": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/pr11346-selected-runtime-add.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1 --reporter=line",
"result": "passed",
"durationSeconds": 19.9,
"summary": "The visible headed server and separate paired client passed the strengthened same-ID terminal-activation oracle with a disposable runtime Git identity. Runtime switch/Git/folder/Clone/Create/reconnect/nested-import measurements were 43/1513/3841/1200/790/202/1312 ms."
},
{
"date": "2026-07-31",
"runner": "local",
"platform": "macos",
"command": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/pr11346-selected-runtime-add.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line",
"result": "passed",
"durationSeconds": 19.7,
"summary": "The hidden-window server and separate paired client passed the strengthened same-ID terminal-activation oracle with a disposable runtime Git identity. Runtime switch/Git/folder/Clone/Create/reconnect/nested-import measurements were 51/1502/3843/880/939/200/1359 ms."
}
],
"runtimeBudget": {
"p95Seconds": 20,
"scope": "focused persistence and routing contract tests"
"p95Seconds": 180,
"scope": "focused persistence/routing contracts plus isolated paired Electron journeys"
},
"flakeHistory": {
"status": "unknown",
"evidence": "New deterministic gate with no soak history."
"evidence": "Deterministic contracts and the final visible plus hidden-window paired Electron runs passed locally. During expansion, attempts exposed three harness-only assumptions: exact project labels did not allow path disambiguation, clicking the inner host label raced cmdk layout, and a disposable CI runtime had no Git author identity for Create. The oracle now targets the host command item, accepts the rendered disambiguated label, seeds only its isolated runtime home with a test Git identity, and explicitly creates a first paired terminal while same-ID local/runtime worktrees remain present. CI soak history is not yet available."
},
"redGreenEvidence": {
"status": "partial",
"evidence": "The user-observed local terminal reveal failure and unintended Active Server switch are encoded by deterministic routing and persistence tests; a saved intentional-break artifact is not yet attached."
"status": "complete",
"evidence": "The byte-identical focused oracle (SHA-256 6de18c140a86801460e58f52287e27a58b97f1576f9f387fa82e1bd4eb378f7b) fails 2/3 on review baseline aa6f945001a5f78a07663c854fabb95c55d8b40b and on the r5-disabled parent e21f4ca58e: local-first lookup returns /local/repo and folder activation has no runtime host. It passes 3/3 on the candidate. Main later advanced to 94cf2f1422f30fc309cb47c5e864a831d516fa8b without changing any of the candidate's 68 files or the focused oracle production boundary; the rebased disabled parent f247d961cab15237bc7fcf0598fe76007f78ec7c has the same stable patch ID as e21f4ca58e. The final paired spec and fixture have SHA-256 bd6d9014d1bed2715397bcd2121d5d7e01792347b13ff14778ccf608bf98789c and d1d78a82f868e2f3152777550825c0ca2d4dc3cba5c6de548e066d2931751fc7. With the final runtime-session owner fix disabled, the focused session test fails 3/43 and the byte-identical hidden paired oracle deterministically creates the host terminal but reports null active client owner while same-ID local/runtime worktrees remain. The integrated candidate passes the same focused test and both headed/hidden paired oracles with exact runtime ownership, server inventory, local exclusion, preserved catalogs, visible rows, and zero direct SSH fallback. Earlier Active Server preference paths retain deterministic coverage but do not yet have a saved intentional-break artifact."
},
"performanceBudget": {
"required": false,
"evidence": "Preference writes are explicit user actions; transient routing adds no polling and uses existing host/worktree indexes."
"required": true,
"evidence": "Preference writes are explicit user actions; transient routing adds no polling or provider fanout and retains one authoritative worktree refresh per completed add. Host-qualified owner indexes are WeakMap-cached and do not add subprocesses or network calls. The final headed journey measured 43 ms for three preference switches, 1,513 ms for Git add, 3,841 ms for folder add, 1,200 ms for Clone, 790 ms for Create with controlled exact-ID completion gates, 202 ms for disconnect/reconnect with overlapping catalog refreshes, and 1,312 ms for nested import. The final session-activation fix adds one execution-host string conversion per explicit terminal/browser activation and no provider call, scan, timer, or retry. Existing Zustand selector fan-out evidence remained 0 render invalidations across 5,000,000 selector runs."
},
"promotionCriteria": [
"Run the focused gate in soak across macOS, Linux, and Windows.",
"Attach a live Windows Local -> Windows 2 -> local reveal -> restart artifact.",
"Attach saved red/green evidence for generic settings mutation and transient connection routing."
"Attach saved red/green evidence for generic settings mutation and transient connection routing.",
"Run the paired Add Project journey on Linux and native Windows, plus live SSH and WSL hosts."
],
"knownGaps": [
"The exact journey is deterministic contract coverage, not a packaged Windows UI automation run.",
"Browser/mobile handoff is covered through transient routing state and preload contracts, not a live phone browser session."
"Browser/mobile handoff is covered through transient routing state and preload contracts, not a live phone browser session.",
"The paired Add Project journey is macOS-only; native Windows was unavailable.",
"SSH and WSL Add Project ownership is deterministic store/controller coverage, not a live host journey."
],
"demotionRule": "Demote or block release if any non-Advanced path mutates Active Server, if local reveal depends on the durable default instead of workspace ownership, or if transient host state survives restart."
"demotionRule": "Demote or block release if any non-Advanced path mutates Active Server, if local reveal or Add Project depends on the durable default instead of captured workspace/host ownership, if an Add Project operation reaches a different host after it begins, or if transient host state survives restart."
},
{
"id": "terminal-geometry.visible-convergence",
@@ -1,13 +1,72 @@
import { readFileSync } from 'node:fs'
import * as nodeFs from 'node:fs'
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
import * as nodePath from 'node:path'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { EventEmitter } from 'node:events'
import { runInNewContext } from 'node:vm'
import { describe, expect, it } from 'vitest'
import { createBootstrapFatalExitBanner, electronViteConfig } from '../../electron.vite.config'
import {
BOOTSTRAP_FATAL_LOG_ENV_VAR,
BOOTSTRAP_FATAL_LOG_FILE_NAME,
createBootstrapFatalExitBanner
} from '../../build-plugins/bootstrap-fatal-exit-banner'
import { electronViteConfig } from '../../electron.vite.config'
import { BOOTSTRAP_FATAL_EXIT_GUARD_KEY } from '../../src/main/startup/bootstrap-fatal-exit-guard'
const targetConfig = readFileSync('config/electron-vite-target.config.ts', 'utf8')
const devRunner = readFileSync('config/scripts/run-electron-vite-dev.mjs', 'utf8')
type BootstrapProcessMock = EventEmitter & {
env: Record<string, string>
pid: number
exit: (code: number) => void
exitCode?: number
}
/** Runs the banner in a bare context and raises the bootstrap fault it guards against. */
function failBootstrapWithBanner(options: {
env: Record<string, string>
tmpdir?: string
stderrWrites?: string[]
}): BootstrapProcessMock {
const processMock = new EventEmitter() as BootstrapProcessMock
processMock.env = options.env
processMock.pid = 4242
processMock.exit = () => {}
const fsShim = {
...nodeFs,
writeSync: (descriptor: number, data: string) => {
if (descriptor === 2) {
options.stderrWrites?.push(data)
return data.length
}
return nodeFs.writeSync(descriptor, data)
}
}
const context = {
process: processMock,
setImmediate: () => {},
require: (specifier: string) => {
if (specifier === 'node:fs') {
return fsShim
}
if (specifier === 'node:path') {
return nodePath
}
if (specifier === 'node:os' && options.tmpdir !== undefined) {
return { tmpdir: () => options.tmpdir }
}
// Electron's own module is unreachable from a bootstrap fault this early.
throw new Error(`unexpected require: ${specifier}`)
}
}
runInNewContext(createBootstrapFatalExitBanner(), context)
processMock.emit('uncaughtException', new Error("Cannot find module 'ws'"))
return processMock
}
describe('Electron Vite output contract', () => {
it('keeps main-process and plain-Node entries at stable CommonJS paths', () => {
const output = electronViteConfig.main?.build?.rollupOptions?.output
@@ -40,12 +99,20 @@ describe('Electron Vite output contract', () => {
const processMock = new EventEmitter() as EventEmitter & {
exit: (code: number) => void
exitCode?: number
stderr: { write: (chunk: string) => boolean }
}
let scheduledExit: (() => void) | null = null
let exitedWith: number | null = null
const stderrWrites: string[] = []
processMock.exit = (code) => {
exitedWith = code
}
processMock.stderr = {
write: (chunk) => {
stderrWrites.push(chunk)
return true
}
}
const context = {
process: processMock,
setImmediate: (callback: () => void) => {
@@ -61,6 +128,63 @@ describe('Electron Vite output contract', () => {
scheduledExit?.()
expect(exitedWith).toBe(1)
expect(context).toHaveProperty(BOOTSTRAP_FATAL_EXIT_GUARD_KEY)
expect(stderrWrites.join('')).toContain("Cannot find module 'zod'")
})
it('records the bootstrap failure it exits on, since the guard hides Electron dialog', () => {
const logDirectory = mkdtempSync(join(tmpdir(), 'orca-bootstrap-fatal-'))
const logPath = join(logDirectory, 'fatal.log')
const stderrWrites: string[] = []
try {
const processMock = failBootstrapWithBanner({
env: { [BOOTSTRAP_FATAL_LOG_ENV_VAR]: logPath },
stderrWrites
})
expect(stderrWrites.join('')).toContain("Cannot find module 'ws'")
const recorded = readFileSync(logPath, 'utf8')
expect(recorded).toContain("Cannot find module 'ws'")
expect(recorded).toContain('pid=4242')
expect(processMock.exitCode).toBe(1)
} finally {
rmSync(logDirectory, { recursive: true, force: true })
}
})
it('creates the parent directory an overridden log path names but does not have', () => {
const logDirectory = mkdtempSync(join(tmpdir(), 'orca-bootstrap-fatal-'))
const logPath = join(logDirectory, 'nested', 'diagnostics', 'fatal.log')
try {
const processMock = failBootstrapWithBanner({
env: { [BOOTSTRAP_FATAL_LOG_ENV_VAR]: logPath }
})
expect(readFileSync(logPath, 'utf8')).toContain("Cannot find module 'ws'")
expect(processMock.exitCode).toBe(1)
} finally {
rmSync(logDirectory, { recursive: true, force: true })
}
})
it('falls back to the default location when the overridden log path is unwritable', () => {
const logDirectory = mkdtempSync(join(tmpdir(), 'orca-bootstrap-fatal-'))
const fallbackDirectory = join(logDirectory, 'fallback')
try {
const processMock = failBootstrapWithBanner({
// A directory can never be opened as the log file, so the override must yield.
env: { [BOOTSTRAP_FATAL_LOG_ENV_VAR]: logDirectory },
tmpdir: fallbackDirectory
})
const recorded = readFileSync(join(fallbackDirectory, BOOTSTRAP_FATAL_LOG_FILE_NAME), 'utf8')
expect(recorded).toContain("Cannot find module 'ws'")
expect(processMock.exitCode).toBe(1)
} finally {
rmSync(logDirectory, { recursive: true, force: true })
}
})
it('isolates renderer entry side effects behind strict facades', () => {
+28 -2
View File
@@ -35,6 +35,7 @@ const appDir = resolve(readAppDirArg(process.argv.slice(2)))
const tempRoot = await mkdtemp(join(tmpdir(), 'orca-packaged-cli-smoke-'))
const copiedAppDir = join(tempRoot, basename(appDir))
let smokeFailure = null
try {
await cp(appDir, copiedAppDir, { recursive: true, verbatimSymlinks: true })
const cliPath = getPackagedCliPath(copiedAppDir)
@@ -73,6 +74,31 @@ try {
assert.equal(install.executed, false)
assert.equal(update.executed, false)
console.log(`[packaged-cli-smoke] help and skills commands passed via ${cliPath}`)
} finally {
await rm(tempRoot, { recursive: true, force: true })
} catch (error) {
smokeFailure = error
}
// Why: on Windows the launcher above spawns the copied Orca.exe (and its crashpad/utility children)
// once per command; those handles can outlive execFile's exit by a few ms, so this cleanup hits
// EBUSY on our own just-exited process after every assertion already passed. Same retry treatment
// as removeHostTree(); a lock that never clears still throws — unless the smoke run itself failed,
// in which case surfacing EBUSY instead of the real assertion would hide the actual regression.
const cleanupFailure = await rm(tempRoot, {
recursive: true,
force: true,
maxRetries: 20,
retryDelay: 250
}).then(
() => null,
(error) => error
)
if (smokeFailure) {
if (cleanupFailure) {
console.warn(`[packaged-cli-smoke] temp cleanup failed: ${cleanupFailure.message}`)
}
throw smokeFailure
}
if (cleanupFailure) {
throw cleanupFailure
}
@@ -104,6 +104,41 @@ describe('Windows signing workflow contract', () => {
expect(installRun).toContain('throw "Unable to install the SignPath PowerShell module')
})
it('still installs SignPath when the cut ref predates the composite action', () => {
const parsedWorkflow = readWorkflow('.github/workflows/release-cut.yml')
const steps = parsedWorkflow.jobs.build.steps
const stepNames = steps.map((step) => step.name)
const checkoutIndex = stepNames.indexOf('Checkout')
const restoreIndex = stepNames.indexOf('Restore composite actions from the workflow ref')
const installIndex = stepNames.indexOf('Install SignPath PowerShell module')
// Why: the build job checks out the cut tag, which for a hotfix cut from an
// older ref can predate `.github/actions/install-signpath-module`; without
// this restore the `uses: ./…` step dies on a missing action.yml.
expect(restoreIndex).toBeGreaterThan(checkoutIndex)
expect(restoreIndex).toBeLessThan(installIndex)
const restoreStep = steps[restoreIndex]
const restoreRun = restoreStep.run
expect(restoreStep.env.WORKFLOW_SHA).toBe('${{ github.workflow_sha }}')
expect(restoreRun).toContain('.github/actions/install-signpath-module/action.yml')
expect(restoreRun).toContain('git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA"')
expect(restoreRun).toContain('git checkout "$WORKFLOW_SHA" -- .github/actions')
// Why: restoring the action must not turn signing into a soft dependency —
// a missing module still has to fail the Windows job, and the CDN fallback
// still has to reject an unexpected payload.
expect(steps[installIndex]['continue-on-error']).toBeUndefined()
expect(restoreStep['continue-on-error']).toBeUndefined()
const installRun = readWorkflow('.github/actions/install-signpath-module/action.yml').runs
.steps[0].run
expect(installRun).toContain('$actualHash -ne $expectedHash.ToUpperInvariant()')
expect(installRun).toContain('throw "SHA-256 mismatch for $source')
})
it('shares one SignPath module install path between release and rehearsal', () => {
const rehearsalWorkflow = readWorkflow('.github/workflows/windows-signing-rehearsal.yml')
const stepNames = rehearsalWorkflow.jobs.rehearse.steps.map((step) => step.name)
+1
View File
@@ -6,6 +6,7 @@
"../src/renderer/src/**/*.tsx",
"../src/preload/api-types.ts",
"../src/shared/**/*",
"../src/main/gitlab/mappers.ts",
"../src/main/ipc/worktree-branch-name.ts",
"../src/main/ipc/worktree-logic.ts",
"../src/main/ipc/worktree-linked-work-item-metadata.ts",
+1 -27
View File
@@ -3,6 +3,7 @@ import { resolve } from 'node:path'
import { defineConfig, type UserConfig } from 'electron-vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { createBootstrapFatalExitBanner } from './build-plugins/bootstrap-fatal-exit-banner'
import { createPlainNodeEntryGuardPlugin } from './build-plugins/plain-node-entry-guard'
import packageJson from './package.json' with { type: 'json' }
@@ -16,7 +17,6 @@ const BUNDLED_MAIN_DEPENDENCIES = new Set([
const EXTERNAL_MAIN_DEPENDENCIES = Object.keys(packageJson.dependencies).filter(
(dependency) => !BUNDLED_MAIN_DEPENDENCIES.has(dependency)
)
const BOOTSTRAP_FATAL_EXIT_GUARD_KEY = '__ORCA_BOOTSTRAP_FATAL_EXIT_GUARD__'
function isExternalMainModule(source: string): boolean {
if (isBuiltin(source) || source === 'electron' || source.startsWith('electron/')) {
@@ -171,32 +171,6 @@ function createStartupDiagnosticsBanner(chunkName: string): string {
`
}
export function createBootstrapFatalExitBanner(): string {
// Why: Electron's pre-import error dialog can leave main resident and block NSIS replacement.
return `
;(() => {
const guardKey = ${JSON.stringify(BOOTSTRAP_FATAL_EXIT_GUARD_KEY)}
if (typeof globalThis[guardKey] === 'function') {
return
}
let exitScheduled = false
const exitAfterBootstrapFailure = () => {
if (exitScheduled) {
return
}
exitScheduled = true
process.exitCode = 1
setImmediate(() => process.exit(1))
}
globalThis[guardKey] = () => {
process.off('uncaughtException', exitAfterBootstrapFailure)
delete globalThis[guardKey]
}
process.once('uncaughtException', exitAfterBootstrapFailure)
})();
`
}
function createMainBootstrapPlugin() {
return {
name: 'orca-main-bootstrap',
+13 -47
View File
@@ -13,11 +13,7 @@ import { handleMockGitRequest } from './mock-server-git-state'
import { handleMockAccountRequest } from './mock-server-account-rpc'
import { handleMockNativeChatRequest } from './mock-server-native-chat-scenario'
import { handleMockSessionTabsRequest } from './mock-server-session-tabs-fixture'
import {
createMockTerminals,
FAKE_SCROLLBACK,
STREAMING_CHUNKS
} from './mock-server-terminal-fixtures'
import { handleMockTerminalRequest } from './mock-server-terminal-stream'
import { createMockRepos, createMockWorktrees, readScenarioNumber } from './mobile-lag-scenario'
const MOCK_REPO_COUNT = readScenarioNumber('MOCK_REPO_COUNT', 2)
@@ -64,6 +60,8 @@ export type RpcResponse = {
_meta: { runtimeId: string }
}
export type RpcRespond = (response: RpcResponse, shouldSend?: () => boolean) => void
export const mockScenarioSummary = {
repoCount: FAKE_REPOS.length,
worktreeCount: fakeWorktrees.length,
@@ -111,13 +109,18 @@ export function handleRequest(
send: (response: RpcResponse) => void,
ws: WebSocket
): void {
const respond = (response: RpcResponse) => {
const respond: RpcRespond = (response, shouldSend) => {
const deliver = () => {
if (shouldSend?.() !== false) {
send(response)
}
}
const delay = responseDelayFor(request.method)
if (delay > 0) {
setTimeout(() => send(response), delay)
setTimeout(deliver, delay)
return
}
send(response)
deliver()
}
// Each returns false for methods it does not own; first owner wins.
@@ -126,7 +129,8 @@ export function handleRequest(
handleMockFilePreviewRequest(request, respond, success, error) ||
handleMockAccountRequest(request, respond, success, error) ||
handleMockNativeChatRequest(request, respond, success, error, ws) ||
handleMockSessionTabsRequest(request, respond, success, terminalListWorktreeId)
handleMockSessionTabsRequest(request, respond, success, terminalListWorktreeId) ||
handleMockTerminalRequest(request, respond, success, ws, terminalListWorktreeId)
) {
return
}
@@ -279,44 +283,6 @@ export function handleRequest(
break
}
case 'terminal.list': {
const terminals = createMockTerminals(terminalListWorktreeId(request.params?.worktree))
respond(
success(request.id, {
terminals,
totalCount: terminals.length,
truncated: false
})
)
break
}
case 'terminal.subscribe': {
respond(success(request.id, { type: 'scrollback', lines: FAKE_SCROLLBACK, truncated: false }))
let chunkIndex = 0
const interval = setInterval(() => {
if (chunkIndex >= STREAMING_CHUNKS.length || ws.readyState !== ws.OPEN) {
clearInterval(interval)
if (ws.readyState === ws.OPEN) {
respond(success(request.id, { type: 'end' }))
}
return
}
respond(success(request.id, { type: 'data', chunk: STREAMING_CHUNKS[chunkIndex] }, true))
chunkIndex++
}, 500)
break
}
case 'terminal.send':
respond(success(request.id, { send: { handle: 'term-1', ok: true } }))
break
case 'terminal.unsubscribe':
respond(success(request.id, { unsubscribed: true }))
break
case 'files.open':
case 'files.openDiff':
respond(
@@ -60,10 +60,24 @@ export function handleMockSessionTabsRequest(
// absent or `id:`-prefixed selector means.
resolveWorktreeId: (selector: unknown) => string | undefined
): boolean {
if (request.method !== 'session.tabs.list') {
return false
if (request.method === 'session.tabs.list') {
const worktreeId = resolveWorktreeId(request.params?.worktree) ?? 'mock'
respond(success(request.id, createMockSessionTabs(worktreeId)))
return true
}
const worktreeId = resolveWorktreeId(request.params?.worktree) ?? 'mock'
respond(success(request.id, createMockSessionTabs(worktreeId)))
return true
if (request.method === 'session.tabs.subscribe') {
// Without a live stream the client's health loop keeps invalidating list
// fetches mid-flight (barrier bump on the failed probe), so the session
// screen never leaves 'Loading tabs'. snapshot then updated => 'live'.
const worktreeId = resolveWorktreeId(request.params?.worktree) ?? 'mock'
const snapshot = createMockSessionTabs(worktreeId)
respond(success(request.id, { type: 'snapshot', ...snapshot }, true))
respond(success(request.id, { type: 'updated', ...snapshot }, true))
return true
}
if (request.method === 'session.tabs.unsubscribe') {
respond(success(request.id, { unsubscribed: true }))
return true
}
return false
}
@@ -0,0 +1,132 @@
import type { WebSocket } from 'ws'
import type { RpcRequest, RpcRespond, RpcResponse } from './mock-server-rpc-handlers'
import {
createMockTerminals,
FAKE_SCROLLBACK,
STREAMING_CHUNKS
} from './mock-server-terminal-fixtures'
// Why: the client resubscribes on every viewport change; without cancellation
// each resubscribe would stack another interval streaming under a dead request.
type TerminalStream = { interval: ReturnType<typeof setInterval> | null }
const terminalStreams = new WeakMap<WebSocket, Map<string, TerminalStream>>()
function clearTerminalStream(ws: WebSocket, terminal: string): void {
const perTerminal = terminalStreams.get(ws)
const stream = perTerminal?.get(terminal)
if (stream) {
stopTerminalStreamInterval(stream)
perTerminal?.delete(terminal)
}
}
function beginTerminalStream(ws: WebSocket, terminal: string): TerminalStream {
clearTerminalStream(ws, terminal)
let perTerminal = terminalStreams.get(ws)
if (!perTerminal) {
perTerminal = new Map()
terminalStreams.set(ws, perTerminal)
}
const stream = { interval: null }
perTerminal.set(terminal, stream)
return stream
}
function isCurrentTerminalStream(ws: WebSocket, terminal: string, stream: TerminalStream): boolean {
return terminalStreams.get(ws)?.get(terminal) === stream && ws.readyState === ws.OPEN
}
function stopTerminalStreamInterval(stream: TerminalStream): void {
if (stream.interval !== null) {
clearInterval(stream.interval)
stream.interval = null
}
}
/** Terminal list/stream/input backend for the mock server. Returns false for
* methods it does not own. */
export function handleMockTerminalRequest(
request: RpcRequest,
respond: RpcRespond,
success: (id: string, result: unknown, streaming?: boolean) => RpcResponse,
ws: WebSocket,
// Shared with `session.tabs.list` so both surfaces agree on which worktree an
// absent or `id:`-prefixed selector means.
resolveWorktreeId: (selector: unknown) => string | undefined
): boolean {
switch (request.method) {
case 'terminal.list': {
const terminals = createMockTerminals(resolveWorktreeId(request.params?.worktree))
respond(
success(request.id, {
terminals,
totalCount: terminals.length,
truncated: false
})
)
return true
}
case 'terminal.subscribe': {
const terminal = String(request.params?.terminal ?? 'term-1')
const stream = beginTerminalStream(ws, terminal)
const isCurrent = () => isCurrentTerminalStream(ws, terminal, stream)
// Why: the client resubscribes until scrollback echoes its viewport dims;
// the legacy `lines` shape left the session screen in that loop forever.
const viewport = request.params?.viewport as { cols?: number; rows?: number } | undefined
// MOCK_TUI=1 arms SGR drag mouse tracking (1002/1006) inside the scrollback
// itself so every xterm re-init re-enters the mode - used by mouse/touch
// input repros (#8818).
const tuiPreamble =
process.env.MOCK_TUI === '1'
? '\x1b[?1002h\x1b[?1006h[mock] mouse tracking ON (1002/1006)\r\n'
: ''
respond(
success(request.id, {
type: 'scrollback',
cols: viewport?.cols ?? 80,
rows: viewport?.rows ?? 24,
serialized: FAKE_SCROLLBACK.replace(/\n/g, '\r\n') + tuiPreamble,
truncated: false
}),
isCurrent
)
let chunkIndex = 0
stream.interval = setInterval(() => {
if (!isCurrent()) {
stopTerminalStreamInterval(stream)
return
}
if (chunkIndex >= STREAMING_CHUNKS.length) {
// Why: no `end` event - a live terminal stream stays open, and `end`
// makes the client tear the subscription down and blank the pane.
stopTerminalStreamInterval(stream)
return
}
respond(
success(request.id, { type: 'data', chunk: STREAMING_CHUNKS[chunkIndex] }, true),
isCurrent
)
chunkIndex++
}, 500)
return true
}
case 'terminal.send':
// Input-routing repros (#8818) assert on the exact bytes reaching the host.
console.log(
`[SEND] terminal=${String(request.params?.terminal)} text=${JSON.stringify(request.params?.text)}`
)
respond(success(request.id, { send: { handle: 'term-1', ok: true } }))
return true
case 'terminal.unsubscribe':
clearTerminalStream(ws, String(request.params?.terminal ?? 'term-1'))
respond(success(request.id, { unsubscribed: true }))
return true
default:
return false
}
}
@@ -0,0 +1,105 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types'
import { WorktreeAgentList } from './WorktreeAgentList'
vi.mock('react-native', () => ({
Pressable: 'Pressable',
StyleSheet: { create: <T,>(styles: T) => styles },
Text: 'Text',
View: 'View'
}))
vi.mock('lucide-react-native', () => ({
ChevronDown: 'ChevronDown',
ChevronRight: 'ChevronRight'
}))
vi.mock('./AgentStateDot', () => ({ AgentStateDot: () => null }))
vi.mock('./MobileAgentIcon', () => ({ MobileAgentIcon: () => null }))
vi.mock('./WorktreeAgentRow', () => ({ WorktreeAgentRow: 'WorktreeAgentRow' }))
function agent(paneKey: string, parentPaneKey: string | null = null): RuntimeWorktreeAgentRow {
return {
paneKey,
parentPaneKey,
state: 'working',
agentType: 'codex',
prompt: `Prompt ${paneKey}`,
taskTitle: null,
displayName: null,
lastAssistantMessage: null,
toolName: null,
toolInput: null,
interrupted: false,
stateStartedAt: 1_000,
updatedAt: 1_000
}
}
describe('WorktreeAgentList', () => {
let renderer: ReactTestRenderer | null = null
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
})
it('collapses multiple agents to their status icons by default', async () => {
const stopPropagation = vi.fn()
await act(async () => {
renderer = create(
createElement(WorktreeAgentList, {
agents: [agent('agent-1'), agent('agent-2'), agent('agent-3')],
now: 2_000,
unvisited: false
})
)
})
const summary = renderer!.root.findByType('Pressable')
expect(summary.props.accessibilityLabel).toBe('Expand 3 agents')
expect(summary.props.accessibilityState).toEqual({ expanded: false })
expect(renderer!.root.findAllByType('WorktreeAgentRow')).toHaveLength(0)
await act(async () => summary.props.onPress({ stopPropagation }))
expect(stopPropagation).toHaveBeenCalledTimes(1)
expect(renderer!.root.findByType('Pressable').props.accessibilityLabel).toBe(
'Collapse 3 agents'
)
expect(renderer!.root.findAllByType('WorktreeAgentRow')).toHaveLength(3)
})
it('keeps a single agent visible without a redundant disclosure control', async () => {
await act(async () => {
renderer = create(
createElement(WorktreeAgentList, {
agents: [agent('agent-1')],
now: 2_000,
unvisited: false
})
)
})
expect(renderer!.root.findAllByType('Pressable')).toHaveLength(0)
expect(renderer!.root.findAllByType('WorktreeAgentRow')).toHaveLength(1)
})
it('summarizes lineage by root agents like desktop', async () => {
await act(async () => {
renderer = create(
createElement(WorktreeAgentList, {
agents: [agent('parent-1'), agent('child-1', 'parent-1'), agent('parent-2')],
now: 2_000,
unvisited: false
})
)
})
expect(renderer!.root.findByType('Pressable').props.accessibilityLabel).toBe('Expand 2 agents')
})
})
+27 -11
View File
@@ -1,8 +1,9 @@
import { useMemo } from 'react'
import { useMemo, useState } from 'react'
import { StyleSheet, View } from 'react-native'
import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types'
import { flattenAgentRowLineage } from '../worktree/agent-row-lineage'
import { buildAgentRowLineageTree, flattenAgentRowLineage } from '../worktree/agent-row-lineage'
import { WorktreeAgentRow } from './WorktreeAgentRow'
import { WorktreeAgentSummary } from './WorktreeAgentSummary'
type Props = {
agents: RuntimeWorktreeAgentRow[]
@@ -14,20 +15,35 @@ type Props = {
// a depth-indented WorktreeAgentRow per agent, mirroring the desktop sidebar's
// WorktreeCardAgents.
export function WorktreeAgentList({ agents, now, unvisited }: Props) {
// Why: rebuild the lineage tree only when the agent list changes, not on every
// re-render (the shared useNow tick re-renders this list every 30s).
const nodes = useMemo(() => flattenAgentRowLineage(agents), [agents])
const summaryAgents = useMemo(() => {
const lineage = buildAgentRowLineageTree(agents)
return lineage.childrenByParentPaneKey.size > 0 ? lineage.rootRows : agents
}, [agents])
const [expanded, setExpanded] = useState(false)
const usesSummary = summaryAgents.length > 1
return (
<View style={styles.list}>
{nodes.map((node) => (
<WorktreeAgentRow
key={node.row.paneKey}
agent={node.row}
depth={node.depth}
{usesSummary ? (
<WorktreeAgentSummary
agents={summaryAgents}
expanded={expanded}
now={now}
unvisited={unvisited}
onToggle={() => setExpanded((value) => !value)}
/>
))}
) : null}
{!usesSummary || expanded
? nodes.map((node) => (
<WorktreeAgentRow
key={node.row.paneKey}
agent={node.row}
depth={node.depth}
now={now}
unvisited={unvisited}
/>
))
: null}
</View>
)
}
@@ -0,0 +1,104 @@
import { ChevronDown, ChevronRight } from 'lucide-react-native'
import { Pressable, StyleSheet, Text, View } from 'react-native'
import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types'
import { colors, radii, spacing } from '../theme/mobile-theme'
import { agentDotState } from '../worktree/agent-row-display'
import { AgentStateDot } from './AgentStateDot'
import { MobileAgentIcon } from './MobileAgentIcon'
const MAX_VISIBLE_AGENTS = 3
type Props = {
agents: RuntimeWorktreeAgentRow[]
expanded: boolean
now: number
onToggle: () => void
}
export function WorktreeAgentSummary({ agents, expanded, now, onToggle }: Props) {
const visibleAgents = agents.slice(0, MAX_VISIBLE_AGENTS)
const hiddenCount = agents.length - visibleAgents.length
const subject = `${agents.length} agents`
return (
<Pressable
style={({ pressed }) => [
styles.summary,
!expanded && styles.summaryCollapsed,
pressed && styles.summaryPressed
]}
accessibilityRole="button"
accessibilityLabel={`${expanded ? 'Collapse' : 'Expand'} ${subject}`}
accessibilityState={{ expanded }}
onPress={(event) => {
event.stopPropagation()
onToggle()
}}
>
{expanded ? (
<Text style={styles.expandedLabel}>{subject}</Text>
) : (
<View style={styles.agentIcons}>
{visibleAgents.map((agent) => (
<View key={agent.paneKey} style={styles.agentStatus}>
<AgentStateDot state={agentDotState(agent, now)} />
{agent.agentType ? <MobileAgentIcon agentId={agent.agentType} size={13} /> : null}
</View>
))}
{hiddenCount > 0 ? <Text style={styles.hiddenCount}>+{hiddenCount}</Text> : null}
</View>
)}
{expanded ? (
<ChevronDown size={12} color={colors.textMuted} />
) : (
<ChevronRight size={12} color={colors.textMuted} />
)}
</Pressable>
)
}
const styles = StyleSheet.create({
summary: {
minHeight: 24,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
paddingHorizontal: spacing.xs,
borderRadius: radii.button
},
summaryCollapsed: {
borderWidth: 1,
borderColor: colors.borderSubtle,
backgroundColor: colors.bgRaised
},
summaryPressed: {
opacity: 0.72
},
expandedLabel: {
flex: 1,
paddingLeft: spacing.xs,
fontSize: 11,
fontWeight: '500',
color: colors.textMuted
},
agentIcons: {
flex: 1,
minWidth: 0,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs
},
agentStatus: {
height: 19,
flexDirection: 'row',
alignItems: 'center',
gap: 2,
paddingHorizontal: 3,
borderRadius: radii.button,
backgroundColor: colors.bgPanel
},
hiddenCount: {
fontSize: 10,
color: colors.textMuted
}
})
@@ -8,6 +8,7 @@ import {
saveMobileClipboardImageAsTempFile
} from './mobile-clipboard-image'
import type { RpcClient } from '../transport/rpc-client'
import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
import type { RpcFailure, RpcResponse, RpcSuccess } from '../transport/types'
function ok(id: string, result: unknown): RpcSuccess {
@@ -124,6 +125,30 @@ describe('mobile clipboard image paste helpers', () => {
})
})
it('restarts the upload after a logical connection cutover', async () => {
const sendRequest = vi
.fn()
.mockResolvedValueOnce(ok('start-1', { uploadId: 'upload-1' }))
.mockRejectedValueOnce(new LogicalClientCutoverError())
.mockResolvedValueOnce(ok('abort-1', { aborted: true }))
.mockResolvedValueOnce(ok('start-2', { uploadId: 'upload-2' }))
.mockResolvedValueOnce(ok('append-2', { receivedBase64Length: 8 }))
.mockResolvedValueOnce(ok('commit-2', '/tmp/orca-paste-image.png'))
await expect(saveMobileClipboardImageAsTempFile({ sendRequest }, 'aGVsbG8=')).resolves.toBe(
'/tmp/orca-paste-image.png'
)
expect(sendRequest.mock.calls.map(([method]) => method)).toEqual([
'clipboard.startImageUpload',
'clipboard.appendImageUploadChunk',
'clipboard.abortImageUpload',
'clipboard.startImageUpload',
'clipboard.appendImageUploadChunk',
'clipboard.commitImageUpload'
])
})
it('brackets generated image paths before sending to the terminal', () => {
expect(buildMobileImagePastePayload('/tmp/orca.png')).toBe('\x1b[200~/tmp/orca.png\x1b[201~')
expect(buildMobileImagePastePayload('/tmp/\x1b.png')).toBe('\x1b[200~/tmp/\u241b.png\x1b[201~')
@@ -1,9 +1,11 @@
import type { RpcClient } from '../transport/rpc-client'
import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
import type { RpcFailure, RpcSuccess } from '../transport/types'
export const MOBILE_CLIPBOARD_IMAGE_MAX_BASE64_CHARS = 24 * 1024 * 1024
export const MOBILE_CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS = 512 * 1024
export const MOBILE_CLIPBOARD_IMAGE_SINGLE_FRAME_FALLBACK_BASE64_CHARS = 256 * 1024
const MOBILE_CLIPBOARD_IMAGE_UPLOAD_CUTOVER_MAX_RETRIES = 1
// Why: PNG bytes don't scale exactly with pixel area, so undershoot the target on
// each pass and let the bounded retry below converge instead of distorting in one shot.
const MOBILE_CLIPBOARD_IMAGE_DOWNSCALE_SAFETY = 0.85
@@ -106,6 +108,26 @@ export async function saveMobileClipboardImageAsTempFile(
): Promise<string> {
const contentBase64 = normalizeMobileClipboardImageBase64(imageData)
const connectionId = args?.connectionId ?? null
for (let retry = 0; ; retry += 1) {
try {
return await uploadMobileClipboardImageTransaction(client, contentBase64, connectionId)
} catch (error) {
if (
!isLogicalClientCutoverError(error) ||
retry >= MOBILE_CLIPBOARD_IMAGE_UPLOAD_CUTOVER_MAX_RETRIES
) {
throw error
}
// Why: upload replay can create only temp state; terminal input is sent after this returns.
}
}
}
async function uploadMobileClipboardImageTransaction(
client: Pick<RpcClient, 'sendRequest'>,
contentBase64: string,
connectionId: string | null
): Promise<string> {
const startResponse = await client.sendRequest('clipboard.startImageUpload', {
expectedBase64Length: contentBase64.length,
connectionId
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest'
import { MobileImageBase64Accumulator } from './mobile-image-base64-accumulator'
function decodeBase64(data: string): Uint8Array {
return Uint8Array.from(atob(data), (character) => character.charCodeAt(0))
}
describe('MobileImageBase64Accumulator', () => {
it('preserves bytes split across non-aligned source chunks', () => {
const accumulator = new MobileImageBase64Accumulator()
accumulator.append(new Uint8Array([1]))
accumulator.append(new Uint8Array([2, 3]))
accumulator.append(new Uint8Array([4, 5]))
expect(accumulator.finish()).toBe('AQIDBAU=')
})
it('preserves bytes across internal staging flushes', () => {
const bytes = new Uint8Array(256 * 1024 + 7)
bytes.forEach((_, index) => {
bytes[index] = index % 251
})
const accumulator = new MobileImageBase64Accumulator()
accumulator.append(bytes.subarray(0, 123_457))
accumulator.append(bytes.subarray(123_457))
expect(decodeBase64(accumulator.finish())).toEqual(bytes)
})
})
@@ -0,0 +1,54 @@
const MOBILE_IMAGE_BASE64_BINARY_CHUNK_BYTES = 8190
const MOBILE_IMAGE_BASE64_CHUNK_BYTES = 256 * 1024 - 1
function encodeMobileImageBytes(bytes: Uint8Array): string {
const encoded: string[] = []
for (
let offset = 0;
offset < bytes.byteLength;
offset += MOBILE_IMAGE_BASE64_BINARY_CHUNK_BYTES
) {
const end = Math.min(offset + MOBILE_IMAGE_BASE64_BINARY_CHUNK_BYTES, bytes.byteLength)
let binary = ''
for (let index = offset; index < end; index += 1) {
binary += String.fromCharCode(bytes[index]!)
}
encoded.push(btoa(binary))
}
return encoded.join('')
}
export class MobileImageBase64Accumulator {
private readonly staging = new Uint8Array(MOBILE_IMAGE_BASE64_CHUNK_BYTES)
private readonly encodedChunks: string[] = []
private stagingLength = 0
append(bytes: Uint8Array): void {
let offset = 0
while (offset < bytes.byteLength) {
const copied = Math.min(
this.staging.byteLength - this.stagingLength,
bytes.byteLength - offset
)
this.staging.set(bytes.subarray(offset, offset + copied), this.stagingLength)
this.stagingLength += copied
offset += copied
if (this.stagingLength === this.staging.byteLength) {
this.flushStaging()
}
}
}
finish(): string {
this.flushStaging()
return this.encodedChunks.join('')
}
private flushStaging(): void {
if (this.stagingLength === 0) {
return
}
this.encodedChunks.push(encodeMobileImageBytes(this.staging.subarray(0, this.stagingLength)))
this.stagingLength = 0
}
}
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { CLIPBOARD_IMAGE_MAX_SOURCE_BYTES } from '../../../src/shared/clipboard-image'
vi.mock('expo-image-picker', () => ({
requestMediaLibraryPermissionsAsync: vi.fn(),
@@ -7,6 +8,9 @@ vi.mock('expo-image-picker', () => ({
vi.mock('expo-document-picker', () => ({
getDocumentAsync: vi.fn()
}))
vi.mock('expo-file-system', () => ({
File: vi.fn()
}))
import { ImageLibraryPermissionError, pickMobileImage } from './mobile-image-source-picker'
@@ -15,17 +19,53 @@ const granted = { granted: true } as Awaited<
>
const denied = { granted: false } as typeof granted
function fileFactory(
bytes: Uint8Array,
options?: { fileSize?: number; handleSize?: number | null; readError?: Error }
) {
const close = vi.fn()
const chunks = [bytes]
const readBytes = vi.fn(() => {
if (options?.readError) {
throw options.readError
}
return chunks.shift() ?? new Uint8Array()
})
const open = vi.fn(() => ({
size: options?.handleSize ?? options?.fileSize ?? bytes.length,
readBytes,
close
}))
const createFile = vi.fn(() => ({ size: options?.fileSize ?? bytes.length, open }))
return { close, createFile, open }
}
describe('pickMobileImage', () => {
it('returns base64 from the photo library', async () => {
afterEach(() => vi.restoreAllMocks())
it('reads a photo URI without relying on React Native fetch', async () => {
const bytes = new Uint8Array([0, 1, 2, 3])
const file = fileFactory(bytes)
const launchLibrary = vi.fn().mockResolvedValue({
canceled: false,
assets: [{ uri: 'file:///x.jpg', fileSize: bytes.length }]
})
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockRejectedValue(new Error('Network request failed'))
const result = await pickMobileImage('library', {
requestLibraryPermission: vi.fn().mockResolvedValue(granted),
launchLibrary: vi.fn().mockResolvedValue({
canceled: false,
assets: [{ uri: 'file:///x.jpg', base64: 'AAAA' }]
})
launchLibrary,
createFile: file.createFile
})
expect(result).toEqual({ base64: 'AAAA', uri: 'file:///x.jpg' })
expect(result).toEqual({
base64: Buffer.from(bytes).toString('base64'),
uri: 'file:///x.jpg'
})
expect(launchLibrary).toHaveBeenCalledWith(expect.objectContaining({ base64: false }))
expect(fetchSpy).not.toHaveBeenCalled()
expect(file.close).toHaveBeenCalledTimes(1)
})
it('throws when photo library permission is denied', async () => {
@@ -48,22 +88,21 @@ describe('pickMobileImage', () => {
it('reads a picked file URI into base64 for the files source', async () => {
const bytes = new Uint8Array([1, 2, 3, 4])
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValue(new Response(bytes.buffer, { headers: { 'content-type': 'image/png' } }))
const file = fileFactory(bytes)
const result = await pickMobileImage('files', {
launchFiles: vi.fn().mockResolvedValue({
canceled: false,
assets: [{ uri: 'file:///doc.png' }]
})
assets: [{ uri: 'file:///doc.png', size: bytes.length }]
}),
createFile: file.createFile
})
expect(result).toEqual({
base64: Buffer.from(bytes).toString('base64'),
uri: 'file:///doc.png'
})
fetchSpy.mockRestore()
expect(file.close).toHaveBeenCalledTimes(1)
})
it('returns null when the files picker is cancelled', async () => {
@@ -73,4 +112,33 @@ describe('pickMobileImage', () => {
expect(result).toBeNull()
})
it('rejects an oversized asset before opening it', async () => {
const file = fileFactory(new Uint8Array([1]))
await expect(
pickMobileImage('files', {
launchFiles: vi.fn().mockResolvedValue({
canceled: false,
assets: [{ uri: 'file:///huge.png', size: CLIPBOARD_IMAGE_MAX_SOURCE_BYTES + 1 }]
}),
createFile: file.createFile
})
).rejects.toThrow('Clipboard image is too large')
expect(file.createFile).not.toHaveBeenCalled()
expect(file.open).not.toHaveBeenCalled()
})
it('closes the file handle when reading fails', async () => {
const file = fileFactory(new Uint8Array(), { fileSize: 4, readError: new Error('read failed') })
await expect(
pickMobileImage('files', {
launchFiles: vi.fn().mockResolvedValue({
canceled: false,
assets: [{ uri: 'file:///broken.png', size: 4 }]
}),
createFile: file.createFile
})
).rejects.toThrow('read failed')
expect(file.close).toHaveBeenCalledTimes(1)
})
})
@@ -1,8 +1,12 @@
// Why: import from 'buffer' (the npm polyfill), not 'node:buffer' — Metro
// can't resolve Node's builtin in a React Native bundle.
import { Buffer } from 'buffer'
import * as DocumentPicker from 'expo-document-picker'
import { File as FsFile } from 'expo-file-system'
import * as ImagePicker from 'expo-image-picker'
import {
CLIPBOARD_IMAGE_MAX_SOURCE_BYTES,
assertClipboardImageBase64LengthWithinLimit,
assertClipboardImageByteLengthWithinLimit
} from '../../../src/shared/clipboard-image'
import { MobileImageBase64Accumulator } from './mobile-image-base64-accumulator'
export type MobileImageSource = 'library' | 'files'
@@ -21,18 +25,68 @@ export class ImageLibraryPermissionError extends Error {
}
}
// Why: expo-document-picker returns a file URI, not base64. Read it through
// fetch + Buffer so we match the base64 contract the upload pipeline expects
// without pulling in expo-file-system.
async function readUriAsBase64(uri: string): Promise<string> {
const response = await fetch(uri)
const bytes = new Uint8Array(await response.arrayBuffer())
return Buffer.from(bytes).toString('base64')
const MOBILE_IMAGE_READ_CHUNK_BYTES = 256 * 1024
type MobileImageFileHandle = {
readonly size: number | null
readBytes(length: number): Uint8Array
close(): void
}
type MobileImageFile = {
readonly size: number
open(): MobileImageFileHandle
}
export type MobileImageFileFactory = (uri: string) => MobileImageFile
function defaultMobileImageFileFactory(uri: string): MobileImageFile {
return new FsFile(uri)
}
async function readUriAsBase64(
uri: string,
declaredSize: number | undefined,
createFile: MobileImageFileFactory
): Promise<string> {
if (typeof declaredSize === 'number' && Number.isFinite(declaredSize)) {
assertClipboardImageByteLengthWithinLimit(declaredSize)
}
const file = createFile(uri)
assertClipboardImageByteLengthWithinLimit(file.size)
const handle = file.open()
try {
if (handle.size !== null) {
assertClipboardImageByteLengthWithinLimit(handle.size)
}
const accumulator = new MobileImageBase64Accumulator()
let bytesRead = 0
while (bytesRead <= CLIPBOARD_IMAGE_MAX_SOURCE_BYTES) {
const requested = Math.min(
MOBILE_IMAGE_READ_CHUNK_BYTES,
CLIPBOARD_IMAGE_MAX_SOURCE_BYTES - bytesRead + 1
)
const bytes = handle.readBytes(requested)
if (bytes.byteLength === 0) {
break
}
bytesRead += bytes.byteLength
assertClipboardImageByteLengthWithinLimit(bytesRead)
accumulator.append(bytes)
}
const base64 = accumulator.finish()
assertClipboardImageBase64LengthWithinLimit(base64.length)
return base64
} finally {
handle.close()
}
}
async function pickFromLibrary(
requestPermission: typeof ImagePicker.requestMediaLibraryPermissionsAsync = ImagePicker.requestMediaLibraryPermissionsAsync,
launch: typeof ImagePicker.launchImageLibraryAsync = ImagePicker.launchImageLibraryAsync
launch: typeof ImagePicker.launchImageLibraryAsync = ImagePicker.launchImageLibraryAsync,
createFile: MobileImageFileFactory = defaultMobileImageFileFactory
): Promise<PickedMobileImage | null> {
const permission = await requestPermission()
// Why: `granted` covers full + limited iOS access; only a hard denial blocks us.
@@ -41,7 +95,7 @@ async function pickFromLibrary(
}
const result = await launch({
mediaTypes: ['images'],
base64: true,
base64: false,
allowsMultipleSelection: false,
quality: 1
})
@@ -49,7 +103,7 @@ async function pickFromLibrary(
return null
}
const asset = result.assets[0]
const base64 = asset?.base64 ?? (asset?.uri ? await readUriAsBase64(asset.uri) : null)
const base64 = asset?.uri ? await readUriAsBase64(asset.uri, asset.fileSize, createFile) : null
if (!base64) {
return null
}
@@ -57,7 +111,8 @@ async function pickFromLibrary(
}
async function pickFromFiles(
launch: typeof DocumentPicker.getDocumentAsync = DocumentPicker.getDocumentAsync
launch: typeof DocumentPicker.getDocumentAsync = DocumentPicker.getDocumentAsync,
createFile: MobileImageFileFactory = defaultMobileImageFileFactory
): Promise<PickedMobileImage | null> {
const result = await launch({
type: 'image/*',
@@ -71,7 +126,8 @@ async function pickFromFiles(
if (!asset?.uri) {
return null
}
return { base64: await readUriAsBase64(asset.uri), uri: asset.uri }
const base64 = await readUriAsBase64(asset.uri, asset.size, createFile)
return base64 ? { base64, uri: asset.uri } : null
}
export async function pickMobileImage(
@@ -80,10 +136,11 @@ export async function pickMobileImage(
readonly requestLibraryPermission?: typeof ImagePicker.requestMediaLibraryPermissionsAsync
readonly launchLibrary?: typeof ImagePicker.launchImageLibraryAsync
readonly launchFiles?: typeof DocumentPicker.getDocumentAsync
readonly createFile?: MobileImageFileFactory
}
): Promise<PickedMobileImage | null> {
if (source === 'library') {
return pickFromLibrary(deps?.requestLibraryPermission, deps?.launchLibrary)
return pickFromLibrary(deps?.requestLibraryPermission, deps?.launchLibrary, deps?.createFile)
}
return pickFromFiles(deps?.launchFiles)
return pickFromFiles(deps?.launchFiles, deps?.createFile)
}
+87 -4
View File
@@ -1,6 +1,8 @@
import { describe, expect, it } from 'vitest'
import { buildGitHubCheckSummary } from './github-check-summary'
import { buildGitHubCheckSummary, type GitHubCheckLike } from './github-check-summary'
import { buildGitLabCheckSummary } from './gitlab-check-summary'
import { summarizeProviderChecks } from '../../../src/shared/provider-check-summary'
import type { ProviderCheckSummary } from '../../../src/shared/types'
describe('buildGitHubCheckSummary', () => {
it('returns none for empty check lists', () => {
@@ -31,14 +33,14 @@ describe('buildGitHubCheckSummary', () => {
})
})
it('keeps neutral and unknown terminal conclusions out of passed', () => {
it('keeps neutral and unknown terminal conclusions out of passed without demoting the PR', () => {
expect(
buildGitHubCheckSummary([
{ status: 'completed', conclusion: 'success' },
{ status: 'completed', conclusion: 'neutral' }
])
).toEqual({
state: 'neutral',
state: 'success',
total: 2,
passed: 1,
failed: 0,
@@ -49,7 +51,7 @@ describe('buildGitHubCheckSummary', () => {
it('rolls up GitLab jobs with unknown terminal statuses as neutral', () => {
expect(buildGitLabCheckSummary([{ status: 'success' }, { status: 'future_status' }])).toEqual({
state: 'neutral',
state: 'success',
total: 2,
passed: 1,
failed: 0,
@@ -58,3 +60,84 @@ describe('buildGitHubCheckSummary', () => {
})
})
})
type ParityCase = {
name: string
checks: GitHubCheckLike[]
expected: Omit<ProviderCheckSummary, 'total'>
}
const completed = (conclusion: string): GitHubCheckLike => ({ status: 'completed', conclusion })
const PARITY_CASES: ParityCase[] = [
{
name: 'all success',
checks: [completed('success'), completed('success')],
expected: { state: 'success', passed: 2, failed: 0, pending: 0, neutral: 0 }
},
{
name: 'success plus skipped',
checks: [completed('success'), completed('skipped')],
expected: { state: 'success', passed: 2, failed: 0, pending: 0, neutral: 0 }
},
{
name: 'all skipped',
checks: [completed('skipped'), completed('skipped')],
expected: { state: 'success', passed: 2, failed: 0, pending: 0, neutral: 0 }
},
{
name: 'success plus neutral',
checks: [completed('success'), completed('neutral')],
expected: { state: 'success', passed: 1, failed: 0, pending: 0, neutral: 1 }
},
{
name: 'all neutral',
checks: [completed('neutral')],
expected: { state: 'neutral', passed: 0, failed: 0, pending: 0, neutral: 1 }
},
{
name: 'success plus failure',
checks: [completed('success'), completed('failure')],
expected: { state: 'failure', passed: 1, failed: 1, pending: 0, neutral: 0 }
},
{
name: 'success plus running',
checks: [completed('success'), { status: 'in_progress', conclusion: null }],
expected: { state: 'pending', passed: 1, failed: 0, pending: 1, neutral: 0 }
},
{
name: 'genuine action_required',
checks: [completed('success'), completed('action_required')],
expected: { state: 'failure', passed: 1, failed: 1, pending: 0, neutral: 0 }
}
]
describe('mobile / desktop check classification parity', () => {
it.each(PARITY_CASES)('$name matches the shared desktop classifier', ({ checks, expected }) => {
const summary = { ...expected, total: checks.length }
expect(buildGitHubCheckSummary(checks)).toEqual(summary)
expect(summarizeProviderChecks(checks)).toEqual(summary)
})
it.each([
{
name: 'GitLab manual gate only',
statuses: ['manual'],
expected: { state: 'neutral', passed: 0, failed: 0, pending: 0, neutral: 1 }
},
{
name: 'GitLab manual gate alongside a green pipeline',
statuses: ['manual', 'success'],
expected: { state: 'success', passed: 1, failed: 0, pending: 0, neutral: 1 }
}
] satisfies {
name: string
statuses: string[]
expected: Omit<ProviderCheckSummary, 'total'>
}[])('$name never reads as failing', ({ statuses, expected }) => {
expect(buildGitLabCheckSummary(statuses.map((status) => ({ status })))).toEqual({
...expected,
total: statuses.length
})
})
})
+7 -52
View File
@@ -1,60 +1,15 @@
import { summarizeProviderChecks } from '../../../src/shared/provider-check-summary'
import type { ProviderCheckSummary } from '../../../src/shared/types'
export type GitHubCheckLike = {
status: string
conclusion?: string | null
}
export type GitHubCheckSummary = {
state: 'success' | 'failure' | 'pending' | 'neutral' | 'none'
total: number
passed: number
failed: number
pending: number
neutral: number
}
function isFailedCheck(check: GitHubCheckLike): boolean {
return (
check.conclusion === 'failure' ||
check.conclusion === 'timed_out' ||
check.conclusion === 'cancelled'
)
}
function isPendingCheck(check: GitHubCheckLike): boolean {
return (
check.status === 'queued' || check.status === 'in_progress' || check.conclusion === 'pending'
)
}
export type GitHubCheckSummary = ProviderCheckSummary
// Why: reuse the desktop classifier verbatim — a second copy is what let mobile call `skipped`
// unresolved while desktop called the same PR green.
export function buildGitHubCheckSummary(checks: GitHubCheckLike[]): GitHubCheckSummary {
let failed = 0
let pending = 0
let neutral = 0
for (const check of checks) {
if (isFailedCheck(check) || check.conclusion === 'action_required') {
failed += 1
} else if (isPendingCheck(check)) {
pending += 1
} else if (check.conclusion === 'success') {
continue
} else {
neutral += 1
}
}
const total = checks.length
const passed = total - failed - pending - neutral
const state =
total === 0
? 'none'
: failed > 0
? 'failure'
: pending > 0
? 'pending'
: neutral > 0
? 'neutral'
: 'success'
return { state, total, passed, failed, pending, neutral }
return summarizeProviderChecks(checks)
}
+10 -30
View File
@@ -1,37 +1,17 @@
import { buildGitHubCheckSummary, type GitHubCheckLike } from './github-check-summary'
import {
mapGitLabPipelineJobStatusToCheckStatus,
mapGitLabPipelineJobStatusToConclusion
} from '../../../src/shared/gitlab-pipeline-checks'
import { summarizeProviderChecks } from '../../../src/shared/provider-check-summary'
import type { ProviderCheckSummary } from '../../../src/shared/types'
type GitLabPipelineJobLike = { status: string }
export function buildGitLabCheckSummary(jobs: GitLabPipelineJobLike[]): ProviderCheckSummary {
return buildGitHubCheckSummary(
jobs.map((job): GitHubCheckLike => {
const status = job.status.toLowerCase()
const nonterminal = [
'created',
'pending',
'scheduled',
'waiting_for_callback',
'waiting_for_resource',
'preparing'
].includes(status)
return {
status: status === 'running' ? 'in_progress' : nonterminal ? 'queued' : 'completed',
conclusion:
status === 'success'
? 'success'
: status === 'failed'
? 'failure'
: ['canceled', 'canceling'].includes(status)
? 'cancelled'
: status === 'skipped'
? 'skipped'
: ['manual', 'action_required'].includes(status)
? 'action_required'
: status === 'running' || nonterminal
? 'pending'
: null
}
})
return summarizeProviderChecks(
jobs.map((job) => ({
status: mapGitLabPipelineJobStatusToCheckStatus(job.status),
conclusion: mapGitLabPipelineJobStatusToConclusion(job.status)
}))
)
}
+2 -16
View File
@@ -1,4 +1,5 @@
import type { ProviderCheckSummary, PRMergeableState } from '../../../src/shared/types'
import { getProviderChecksLabel } from '../../../src/shared/provider-check-summary'
export type MobileHostedReviewStatus = {
checksSummary?: ProviderCheckSummary
@@ -39,22 +40,7 @@ export function getHostedMergeLabel(item: MobileHostedReviewStatus): string {
}
export function getHostedChecksLabel(item: { checksSummary?: ProviderCheckSummary }): string {
const summary = item.checksSummary
if (!summary) {
return 'Checks'
}
if (summary.total === 0) {
return 'No checks'
}
if (summary.failed > 0) {
return `${summary.failed} failing`
}
if (summary.pending > 0) {
return `${summary.pending} pending`
}
return summary.state === 'neutral'
? 'Unresolved checks'
: `${summary.passed}/${summary.total} passed`
return getProviderChecksLabel(item.checksSummary)
}
export function getHostedReviewSignalTone(
@@ -0,0 +1,74 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { WebSocket } from 'ws'
import { handleMockTerminalRequest } from '../../scripts/mock-server-terminal-stream'
import {
handleRequest,
type RpcRequest,
type RpcRespond,
type RpcResponse
} from '../../scripts/mock-server-rpc-handlers'
type DeferredResponse = {
response: RpcResponse
shouldSend?: () => boolean
}
function success(id: string, result: unknown, streaming?: boolean): RpcResponse {
return {
id,
ok: true,
result,
...(streaming ? { streaming: true as const } : {}),
_meta: { runtimeId: 'test' }
}
}
function request(id: string, method: string): RpcRequest {
return { id, method, params: { terminal: 'term-1', viewport: { cols: 80, rows: 24 } } }
}
describe('mock terminal stream cancellation', () => {
afterEach(() => {
vi.useRealTimers()
vi.unstubAllEnvs()
})
it('invalidates delayed frames after resubscribe and unsubscribe', () => {
vi.useFakeTimers()
const ws = { OPEN: 1, readyState: 1 } as unknown as WebSocket
const deferred: DeferredResponse[] = []
const respond: RpcRespond = (response, shouldSend) => {
deferred.push({ response, shouldSend })
}
const handle = (rpcRequest: RpcRequest) =>
handleMockTerminalRequest(rpcRequest, respond, success, ws, () => 'wt')
expect(handle(request('old', 'terminal.subscribe'))).toBe(true)
vi.advanceTimersByTime(500)
expect(deferred.filter(({ response }) => response.id === 'old')).toHaveLength(2)
expect(handle(request('new', 'terminal.subscribe'))).toBe(true)
const oldResponses = deferred.filter(({ response }) => response.id === 'old')
const newResponses = deferred.filter(({ response }) => response.id === 'new')
expect(oldResponses.every(({ shouldSend }) => shouldSend?.() === false)).toBe(true)
expect(newResponses.every(({ shouldSend }) => shouldSend?.() === true)).toBe(true)
expect(handle(request('stop', 'terminal.unsubscribe'))).toBe(true)
expect(newResponses.every(({ shouldSend }) => shouldSend?.() === false)).toBe(true)
})
it('drops queued delayed frames after stream cancellation', () => {
vi.useFakeTimers()
vi.stubEnv('MOCK_RPC_DELAY_TERMINAL_SUBSCRIBE_MS', '1000')
const ws = { OPEN: 1, readyState: 1 } as unknown as WebSocket
const send = vi.fn<(response: RpcResponse) => void>()
handleRequest(request('old', 'terminal.subscribe'), send, ws)
vi.advanceTimersByTime(500)
handleRequest(request('new', 'terminal.subscribe'), send, ws)
handleRequest(request('stop', 'terminal.unsubscribe'), send, ws)
vi.advanceTimersByTime(1000)
expect(send.mock.calls.map(([response]) => response.id)).toEqual(['stop'])
})
})
@@ -27,6 +27,22 @@ describe('isTerminalGestureInput', () => {
expect(isTerminalGestureInput(`${ESC}[<0;9999;9999M${ESC}[<0;9999;9999m`)).toBe(true)
})
it('accepts SGR left-drag motion sequences but rejects a motion release', () => {
expect(isTerminalGestureInput(`${ESC}[<32;10;5M${ESC}[<32;11;5M`)).toBe(true)
expect(countTerminalGestureInputSequences(`${ESC}[<32;10;5M${ESC}[<32;11;5M`)).toBe(2)
expect(isTerminalGestureInput(`${ESC}[<0;10;5M${ESC}[<32;11;5M${ESC}[<0;11;5m`)).toBe(true)
expect(isTerminalGestureInput(`${ESC}[<32;10;5m`)).toBe(false)
})
it('accepts default-encoding left-drag motion sequences', () => {
expect(isTerminalGestureInput(`${ESC}[M${String.fromCharCode(64, 43, 38)}`)).toBe(true)
expect(
isTerminalGestureInput(
`${ESC}[M${String.fromCharCode(32, 33, 33)}${ESC}[M${String.fromCharCode(64, 34, 33)}${ESC}[M${String.fromCharCode(35, 34, 33)}`
)
).toBe(true)
})
it('accepts repeated default mouse wheel sequences', () => {
expect(
isTerminalGestureInput(
@@ -1,8 +1,9 @@
const ESC = '\x1b'
const MAX_TERMINAL_GESTURE_INPUT_LENGTH = 2048
const MAX_TERMINAL_GESTURE_INPUT_SEQUENCES = 32
// Buttons: 0 left press/release, 32 left-drag motion, 64/65 wheel.
const SGR_MOUSE_GESTURE_SEQUENCE_RE = new RegExp(
`^${ESC}\\[<(0|64|65);([0-9]{1,4});([0-9]{1,4})([Mm])$`
`^${ESC}\\[<(0|32|64|65);([0-9]{1,4});([0-9]{1,4})([Mm])$`
)
function isDefaultMouseGestureSequence(bytes: string, offset: number): number | null {
@@ -12,8 +13,9 @@ function isDefaultMouseGestureSequence(bytes: string, offset: number): number |
const button = bytes.charCodeAt(offset + 3)
const col = bytes.charCodeAt(offset + 4)
const row = bytes.charCodeAt(offset + 5)
// Buttons: 32 left press, 35 release, 64 left-drag motion, 96/97 wheel.
if (
(button === 32 || button === 35 || button === 96 || button === 97) &&
(button === 32 || button === 35 || button === 64 || button === 96 || button === 97) &&
col >= 33 &&
col <= 126 &&
row >= 33 &&
+8 -25
View File
@@ -11,6 +11,8 @@ import { TERMINAL_WEBVIEW_THEME_JS } from './terminal-webview-theme-injected'
import { TERMINAL_QUERY_REPLY_JS } from './terminal-webview-query-reply-injected'
import { URL_TAP_WEBVIEW_JS } from './terminal-webview-url-tap'
import { TERMINAL_WEBGL_RECOVERY_JS } from './terminal-webview-webgl-recovery-injected'
import { TERMINAL_MOUSE_CLICK_DRAG_JS } from './terminal-webview-mouse-click-drag-injected'
import { TERMINAL_MOUSE_REPORT_CELL_JS } from './terminal-webview-mouse-report-cell-injected'
import { TERMINAL_WHEEL_SCROLL_JS } from './terminal-webview-wheel-scroll-injected'
const DEFAULT_TERMINAL_THEME: RuntimeMobileTerminalTheme['theme'] = {
@@ -1153,31 +1155,7 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
return { col: col, row: viewportRow + viewportY };
}
function viewportToMouseReportCell(clientX, clientY) {
if (!term) return null;
var cellW = getCellWidth();
var cellH = getCellHeight();
if (cellW <= 0 || cellH <= 0) return null;
if (typeof clientX !== 'number') clientX = window.innerWidth / 2;
if (typeof clientY !== 'number') clientY = window.innerHeight / 2;
var total = getTotalScale();
if (total <= 0) total = 1;
var sx = (clientX - panX) / total;
var sy = (clientY - panY) / total;
var maxX = Math.max(0, term.cols * cellW - 1);
var maxY = Math.max(0, term.rows * cellH - 1);
if (sx < 0) sx = 0;
if (sx > maxX) sx = maxX;
if (sy < 0) sy = 0;
if (sy > maxY) sy = maxY;
var col = Math.floor(sx / cellW);
var row = Math.floor(sy / cellH);
if (col < 0) col = 0;
if (col > term.cols - 1) col = term.cols - 1;
if (row < 0) row = 0;
if (row > term.rows - 1) row = term.rows - 1;
return { col: col, row: row, x: Math.floor(sx), y: Math.floor(sy) };
}
${TERMINAL_MOUSE_REPORT_CELL_JS}
function isAlternateBufferActive() {
try {
@@ -1648,6 +1626,10 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
// terminal-webview-wheel-scroll-injected.ts (extracted for max-lines).
${TERMINAL_WHEEL_SCROLL_JS}
// External mouse click/drag: see
// terminal-webview-mouse-click-drag-injected.ts (extracted for max-lines).
${TERMINAL_MOUSE_CLICK_DRAG_JS}
btnCopy.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
@@ -1705,6 +1687,7 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
targetSurface.addEventListener('click', function(e) { e.preventDefault(); e.stopPropagation(); }, true);
attachSurfaceWheelHandler(targetSurface);
attachSurfaceMouseClickDragHandler(targetSurface);
targetSurface.addEventListener('touchstart', function(e) {
if (dispatcherShouldBlockSurface()) return;
@@ -0,0 +1,199 @@
// Indirect-pointer (external mouse / trackpad) click and drag for the terminal
// surface, injected into XTERM_HTML. Extracted from terminal-webview-html.ts to
// keep that file within its max-lines budget. Companion to
// terminal-webview-wheel-scroll-injected.ts, which owns the wheel half (#11247);
// this owns the click/drag half of #8818. Closes over host-IIFE state/functions:
// term, ESC, sel, selMode, selectionOverlay, TAP_SLOP, getMouseTrackingMode,
// viewportToCell, viewportToMouseReportCell, isSafeSgrMouseCoordinate,
// sgrMouseMode, sgrMousePixelsMode, notify, notifyTerminalSurfaceTap,
// cancelSelect, applyXtermSelection, repositionOverlay, handleDragMove,
// stopEdgeScroll, and dispatcherShouldBlockSurface.
//
// Why pointer events: a hardware mouse on Android/iPadOS raises pointer events
// with pointerType 'mouse' and NO touch events, while a finger raises
// pointerType 'touch' plus the touch events the document dispatcher owns. The
// capture-phase mousedown/click suppression in attachSurfaceEventHandlers stays:
// it is what keeps xterm's own mouse handling inert (its onData output is
// dropped by the mobile bridge), and pointer events are unaffected by it.
export const TERMINAL_MOUSE_CLICK_DRAG_JS = `
var mouseGesture = null;
// One report per transition, built with the same encoding ladder as
// buildMouseClickInput: SGR pixels (1016) > SGR (1006) > default. Returns ''
// when the mode does not report this transition (x10 has no release, only
// drag/any report motion) or the cell is not encodable.
function buildMouseButtonReport(kind, clientX, clientY) {
var mouseTrackingMode = getMouseTrackingMode();
if (mouseTrackingMode === 'none') return '';
if (kind === 'motion' && mouseTrackingMode !== 'drag' && mouseTrackingMode !== 'any') return '';
if (kind === 'release' && mouseTrackingMode === 'x10') return '';
var cell = viewportToMouseReportCell(clientX, clientY);
if (!cell) return '';
var sgrButton = kind === 'motion' ? 32 : 0;
var sgrFinal = kind === 'release' ? 'm' : 'M';
if (sgrMousePixelsMode) {
if (!isSafeSgrMouseCoordinate(cell.x) || !isSafeSgrMouseCoordinate(cell.y)) return '';
return ESC + '[<' + sgrButton + ';' + cell.x + ';' + cell.y + sgrFinal;
}
if (sgrMouseMode) {
// Why: xterm increments zero-based mouse cells before encoding reports.
var sgrCol = cell.col + 1;
var sgrRow = cell.row + 1;
if (!isSafeSgrMouseCoordinate(sgrCol) || !isSafeSgrMouseCoordinate(sgrRow)) return '';
return ESC + '[<' + sgrButton + ';' + sgrCol + ';' + sgrRow + sgrFinal;
}
var button = kind === 'motion' ? 64 : kind === 'release' ? 35 : 32;
var col = cell.col + 1 + 32;
var row = cell.row + 1 + 32;
// Why: non-SGR mouse bytes above ASCII are not preserved reliably through
// the mobile JSON/RPC string path; drop instead of corrupting input.
if (col > 126 || row > 126) return '';
return ESC + '[M' + String.fromCharCode(button) + String.fromCharCode(col) + String.fromCharCode(row);
}
function mouseReportCellKey(clientX, clientY) {
var cell = viewportToMouseReportCell(clientX, clientY);
return cell ? cell.col + ',' + cell.row : null;
}
function abandonMouseGesture() {
var gesture = mouseGesture;
mouseGesture = null;
if (!gesture) return;
if (gesture.mode === 'tracking') {
// Why: the press report already went to the TUI; a lost pointer must not
// leave the button latched down on the far side.
var release = buildMouseButtonReport('release', gesture.lastX, gesture.lastY);
if (release) notify({ type: 'terminal-input', bytes: release });
} else if (gesture.mode === 'selecting') {
if (sel) sel.activeHandle = null;
stopEdgeScroll();
}
}
function beginMouseDrag(gesture) {
gesture.moved = true;
if (getMouseTrackingMode() !== 'none') {
gesture.mode = 'tracking';
gesture.lastCellKey = mouseReportCellKey(gesture.startX, gesture.startY);
var press = buildMouseButtonReport('press', gesture.startX, gesture.startY);
if (press) notify({ type: 'terminal-input', bytes: press });
return;
}
var anchor = viewportToCell(gesture.startX, gesture.startY);
if (!anchor) {
gesture.mode = 'cancelled';
return;
}
// Why: mouse drags select character-anchored ranges like desktop terminals,
// not the word-seeded long-press selection; reuse the touch handle-drag
// plumbing (edge scroll included) by acting as a live 'end' handle.
gesture.mode = 'selecting';
selMode = 'select';
sel = { anchor: anchor, focus: anchor, activeHandle: 'end' };
selectionOverlay.classList.add('active');
notify({ type: 'set-select-mode', enabled: true });
applyXtermSelection();
repositionOverlay();
}
function attachSurfaceMouseClickDragHandler(targetSurface) {
targetSurface.addEventListener('pointerdown', function(e) {
if (e.pointerType !== 'mouse' || e.button !== 0) return;
if (dispatcherShouldBlockSurface() || !term) return;
// Why: a pointerup lost outside the WebView must not leave the previous
// gesture latched (tracking press with no release) when the next one lands.
if (mouseGesture) abandonMouseGesture();
// Why: mouse pointers have no implicit capture; without it a drag that
// leaves the surface drops pointermove/pointerup and strands the gesture.
try {
if (targetSurface.setPointerCapture) targetSurface.setPointerCapture(e.pointerId);
} catch (err) {}
mouseGesture = {
startX: e.clientX, startY: e.clientY,
lastX: e.clientX, lastY: e.clientY,
lastCellKey: null,
moved: false,
mode: 'pending',
dismissedSelection: false
};
if (selMode === 'select') {
// Why: touch parity — pressing outside the pill dismisses the current
// selection; the same press may still start a new drag selection.
cancelSelect();
mouseGesture.dismissedSelection = true;
}
}, true);
targetSurface.addEventListener('pointermove', function(e) {
var gesture = mouseGesture;
if (e.pointerType !== 'mouse' || !gesture || gesture.mode === 'cancelled') return;
if (!term) return;
gesture.lastX = e.clientX;
gesture.lastY = e.clientY;
if ((e.buttons & 1) === 0) {
// Why: a pointerup lost outside the WebView (capture unavailable) must
// end the gesture here, or a tracked press stays latched at the TUI.
// Coordinates first, so the synthesized release lands where the
// pointer re-entered rather than at the previous cell.
abandonMouseGesture();
return;
}
if (!gesture.moved) {
var dx = Math.abs(e.clientX - gesture.startX);
var dy = Math.abs(e.clientY - gesture.startY);
if (dx + dy <= TAP_SLOP) return;
beginMouseDrag(gesture);
}
if (gesture.mode === 'tracking') {
// Why: one motion report per cell keeps drags bounded by grid size, not
// by pointermove cadence, so the RN rate limiter is never the bottleneck.
var cellKey = mouseReportCellKey(e.clientX, e.clientY);
if (cellKey && cellKey !== gesture.lastCellKey) {
gesture.lastCellKey = cellKey;
var motion = buildMouseButtonReport('motion', e.clientX, e.clientY);
if (motion) notify({ type: 'terminal-input', bytes: motion });
}
} else if (gesture.mode === 'selecting') {
handleDragMove('end', e.clientX, e.clientY);
}
}, true);
targetSurface.addEventListener('pointerup', function(e) {
var gesture = mouseGesture;
if (e.pointerType !== 'mouse' || !gesture || e.button !== 0) return;
mouseGesture = null;
if (gesture.mode === 'cancelled' || !term) return;
if (gesture.mode === 'tracking') {
var release = buildMouseButtonReport('release', e.clientX, e.clientY);
if (release) notify({ type: 'terminal-input', bytes: release });
return;
}
if (gesture.mode === 'selecting') {
if (sel) sel.activeHandle = null;
stopEdgeScroll();
repositionOverlay();
return;
}
if (dispatcherShouldBlockSurface()) return;
// Why: a dismissing tap only clears the selection (touch parity); it must
// not also open a link or focus the keyboard underneath.
if (gesture.dismissedSelection) return;
// Plain click == touch tap: links and file paths win, then tracking-mode
// click reports, then keyboard focus.
notifyTerminalSurfaceTap(e.clientX, e.clientY);
}, true);
targetSurface.addEventListener('pointercancel', function(e) {
if (e.pointerType !== 'mouse') return;
abandonMouseGesture();
}, true);
// Why: Android input injection can pair a mouse-flavored pointerdown with
// real touch events (SOURCE_MOUSE + TOOL_TYPE_FINGER). If touch arrives,
// the document touch dispatcher owns the gesture.
targetSurface.addEventListener('touchstart', function() {
if (mouseGesture) abandonMouseGesture();
}, true);
}
`
@@ -0,0 +1,90 @@
// @vitest-environment happy-dom
import { describe, expect, it } from 'vitest'
import { ESC, useTerminalMouseWebViewHarness } from './terminal-webview-mouse-test-harness'
describe('terminal WebView external mouse click', () => {
const mouse = useTerminalMouseWebViewHarness()
it('reports a mouse click to a click-tracking TUI the way a touch tap does (#8818)', () => {
mouse.boot()
mouse.activeTerminal().modes.mouseTrackingMode = 'vt200'
mouse.mouseClick(40, 60)
// Default (non-SGR) encoding: press (32=' ') then release (35='#'), each
// ESC [ M btn col row. Cell bytes depend on the fit scale, not on routing.
const bytes = mouse.terminalInputBytes()
expect(bytes).toHaveLength(12)
expect(bytes.slice(0, 4)).toBe(`${ESC}[M `)
expect(bytes.slice(6, 10)).toBe(`${ESC}[M#`)
expect(bytes.slice(4, 6)).toBe(bytes.slice(10, 12))
})
it('dismisses an existing selection with a click without focusing the keyboard', () => {
mouse.boot()
mouse.mouseDrag(40, 60, 160, 90)
mouse.clearPostedMessages()
mouse.mouseClick(240, 200)
const messages = mouse.postedMessages()
expect(messages.filter((message) => message.type === 'set-select-mode')).toEqual([
{ type: 'set-select-mode', enabled: false }
])
expect(messages.filter((message) => message.type === 'terminal-tap')).toEqual([])
expect(document.getElementById('selection-overlay')?.classList.contains('active')).toBe(false)
})
it('routes a plain mouse click to the tap pipeline for keyboard focus', () => {
mouse.boot()
mouse.mouseClick(40, 60)
const messages = mouse.postedMessages()
expect(messages.filter((message) => message.type === 'terminal-tap')).toHaveLength(1)
expect(mouse.terminalInputBytes()).toBe('')
})
it('ignores non-mouse pointers and non-left buttons', () => {
mouse.boot()
mouse.activeTerminal().modes.mouseTrackingMode = 'vt200'
mouse.dispatchPointer('pointerdown', {
pointerType: 'touch',
x: 40,
y: 60,
button: 0,
buttons: 1
})
mouse.dispatchPointer('pointerup', {
pointerType: 'touch',
x: 40,
y: 60,
button: 0,
buttons: 0
})
mouse.dispatchPointer('pointerdown', { x: 40, y: 60, button: 2, buttons: 2 })
mouse.dispatchPointer('pointerup', { x: 40, y: 60, button: 2, buttons: 0 })
expect(mouse.terminalInputBytes()).toBe('')
expect(mouse.postedMessages().filter((message) => message.type === 'terminal-tap')).toEqual([])
})
it('lets the touch dispatcher own a gesture when touch events follow pointerdown', () => {
mouse.boot()
mouse.activeTerminal().modes.mouseTrackingMode = 'vt200'
mouse.dispatchPointer('pointerdown', { x: 40, y: 60, button: 0, buttons: 1 })
const touchStart = new Event('touchstart', { bubbles: true })
// Why: the document tap dispatcher reads touches[0]; happy-dom's plain Event lacks it.
Object.defineProperty(touchStart, 'touches', {
value: [{ identifier: 7, clientX: 40, clientY: 60 }]
})
mouse.terminalSurface().dispatchEvent(touchStart)
mouse.dispatchPointer('pointerup', { x: 40, y: 60, button: 0, buttons: 0 })
// Why: Android SOURCE_MOUSE injections can pair a mouse pointerdown with
// real touch events; double-handling would double the click report.
expect(mouse.terminalInputBytes()).toBe('')
})
})
@@ -0,0 +1,87 @@
// @vitest-environment happy-dom
import { describe, expect, it } from 'vitest'
import {
DEFAULT_MOUSE_REPORT_RE,
ESC,
useTerminalMouseWebViewHarness
} from './terminal-webview-mouse-test-harness'
describe('terminal WebView external mouse drag', () => {
const mouse = useTerminalMouseWebViewHarness()
it('sends press, per-cell motion, and release for a drag-tracking mouse drag', () => {
mouse.boot()
mouse.activeTerminal().modes.mouseTrackingMode = 'drag'
mouse.mouseDrag(40, 60, 160, 60)
const reports = mouse.terminalInputBytes().match(DEFAULT_MOUSE_REPORT_RE) ?? []
expect(reports.length).toBeGreaterThanOrEqual(3)
expect(reports[0]?.charCodeAt(3)).toBe(32)
for (const motion of reports.slice(1, -1)) {
expect(motion.charCodeAt(3)).toBe(64)
}
expect(reports.at(-1)?.charCodeAt(3)).toBe(35)
// Motion reports are deduped per cell, so a horizontal drag advances columns.
const motionCols = reports.slice(1, -1).map((report) => report.charCodeAt(4))
expect(new Set(motionCols).size).toBe(motionCols.length)
})
it('does not report motion or release for an x10 click-only TUI drag', () => {
mouse.boot()
mouse.activeTerminal().modes.mouseTrackingMode = 'x10'
mouse.mouseDrag(40, 60, 160, 60)
// x10 reports presses only; the press goes out at drag start, no motion or release follows.
const bytes = mouse.terminalInputBytes()
expect(bytes.slice(0, 4)).toBe(`${ESC}[M `)
expect(bytes).toHaveLength(6)
})
it('selects character-anchored text on a mouse drag outside tracking mode', () => {
mouse.boot()
// Why: init itself emits a set-select-mode reset; only the drag matters here.
mouse.clearPostedMessages()
mouse.mouseDrag(40, 60, 160, 90)
expect(mouse.terminalInputBytes()).toBe('')
expect(mouse.selectionSpy()).toHaveBeenCalled()
expect(document.getElementById('selection-overlay')?.classList.contains('active')).toBe(true)
const modes = mouse.postedMessages().filter((message) => message.type === 'set-select-mode')
expect(modes).toEqual([{ type: 'set-select-mode', enabled: true }])
})
it('releases a tracked drag when the button state shows the pointerup was lost', () => {
mouse.boot()
mouse.activeTerminal().modes.mouseTrackingMode = 'drag'
mouse.dispatchPointer('pointerdown', { x: 40, y: 60, button: 0, buttons: 1 })
mouse.dispatchPointer('pointermove', { x: 160, y: 60, button: 0, buttons: 1 })
// Pointer re-enters with the button already up — the pointerup never arrived.
mouse.dispatchPointer('pointermove', { x: 200, y: 60, button: 0, buttons: 0 })
const reports = mouse.terminalInputBytes().match(DEFAULT_MOUSE_REPORT_RE) ?? []
expect(reports.at(-1)?.charCodeAt(3)).toBe(35)
mouse.clearPostedMessages()
mouse.dispatchPointer('pointermove', { x: 240, y: 60, button: 0, buttons: 1 })
// The lost pointerup ended the gesture; a later unrelated move must not emit motion.
expect(mouse.terminalInputBytes()).toBe('')
})
it('releases a tracked drag when the pointer is cancelled mid-gesture', () => {
mouse.boot()
mouse.activeTerminal().modes.mouseTrackingMode = 'drag'
mouse.dispatchPointer('pointerdown', { x: 40, y: 60, button: 0, buttons: 1 })
mouse.dispatchPointer('pointermove', { x: 160, y: 60, button: 0, buttons: 1 })
mouse.dispatchPointer('pointercancel', { x: 160, y: 60 })
const reports = mouse.terminalInputBytes().match(DEFAULT_MOUSE_REPORT_RE) ?? []
// Press went to the TUI, so the cancel must not leave the button latched.
expect(reports.at(-1)?.charCodeAt(3)).toBe(35)
})
})
@@ -0,0 +1,29 @@
// Mouse-report coordinate mapping injected into XTERM_HTML. Closes over term,
// panX/panY, getCellWidth/Height, and getTotalScale.
export const TERMINAL_MOUSE_REPORT_CELL_JS = `
function viewportToMouseReportCell(clientX, clientY) {
if (!term) return null;
var cellW = getCellWidth();
var cellH = getCellHeight();
if (cellW <= 0 || cellH <= 0) return null;
if (typeof clientX !== 'number') clientX = window.innerWidth / 2;
if (typeof clientY !== 'number') clientY = window.innerHeight / 2;
var total = getTotalScale();
if (total <= 0) total = 1;
var sx = (clientX - panX) / total;
var sy = (clientY - panY) / total;
var maxX = Math.max(0, term.cols * cellW - 1);
var maxY = Math.max(0, term.rows * cellH - 1);
if (sx < 0) sx = 0;
if (sx > maxX) sx = maxX;
if (sy < 0) sy = 0;
if (sy > maxY) sy = maxY;
var col = Math.floor(sx / cellW);
var row = Math.floor(sy / cellH);
if (col < 0) col = 0;
if (col > term.cols - 1) col = term.cols - 1;
if (row < 0) row = 0;
if (row > term.rows - 1) row = term.rows - 1;
return { col: col, row: row, x: Math.floor(sx), y: Math.floor(sy) };
}
`
@@ -0,0 +1,248 @@
import { runInThisContext } from 'node:vm'
import { afterEach, beforeEach, vi, type Mock } from 'vitest'
import { XTERM_HTML } from './terminal-webview-html'
function iifeSource(): string {
const start = XTERM_HTML.indexOf('(function() {')
const end = XTERM_HTML.lastIndexOf('})();')
return XTERM_HTML.slice(start, end + '})();'.length)
}
function bodyMarkup(): string {
const start = XTERM_HTML.indexOf('<body>') + '<body>'.length
const end = XTERM_HTML.indexOf('<script>', start)
return XTERM_HTML.slice(start, end)
}
type BufferState = {
baseY: number
type: 'alternate' | 'normal'
viewportY: number
}
type TerminalStub = ReturnType<typeof makeTerminal>
type RegisteredEventListener = {
listener: EventListenerOrEventListenerObject
options?: boolean | AddEventListenerOptions
type: string
}
type PointerEventType = 'pointerdown' | 'pointermove' | 'pointerup' | 'pointercancel'
type PointerEventInit = {
button?: number
buttons?: number
pointerType?: string
x?: number
y?: number
}
type PostMessage = Mock<(data: string) => void>
type Select = Mock<(col: number, row: number, len: number) => void>
export const ESC = '\u001b'
export const DEFAULT_MOUSE_REPORT_RE = new RegExp(`${ESC}\\[M[\\s\\S]{3}`, 'g')
function makeTerminal(buffer: BufferState, select: Select) {
const terminal = {
cols: 40,
rows: 24,
options: { fontSize: 13 },
modes: { mouseTrackingMode: 'none' as string },
element: null as HTMLElement | null,
_core: {
_renderService: { dimensions: { css: { cell: { width: 8, height: 15 } } } }
},
buffer: {
active: {
get baseY() {
return buffer.baseY
},
get type() {
return buffer.type
},
get viewportY() {
return buffer.viewportY
},
cursorY: 0,
length: 1,
getLine: () => null
}
},
write(_data: string, callback?: () => void) {
callback?.()
},
open(surface: HTMLElement) {
terminal.element = surface
},
loadAddon() {},
resize(cols: number, rows: number) {
terminal.cols = cols
terminal.rows = rows
},
clear() {},
reset() {},
refresh() {},
selectAll() {},
clearSelection() {},
select,
scrollLines() {},
scrollToBottom() {},
scrollToLine() {},
attachCustomKeyEventHandler() {},
getSelection: () => '',
onData: () => ({ dispose() {} }),
onLineFeed: () => ({ dispose() {} }),
onScroll: () => ({ dispose() {} }),
onWriteParsed: () => ({ dispose() {} }),
dispose() {}
}
return terminal
}
function terminalSurface(): HTMLElement {
const surface = document.getElementById('terminal-surface')
if (!surface) {
throw new Error('terminal surface missing')
}
return surface
}
function dispatchPointer(type: PointerEventType, init: PointerEventInit = {}): void {
const event = new MouseEvent(type, {
bubbles: true,
cancelable: true,
clientX: init.x ?? 40,
clientY: init.y ?? 60,
button: init.button ?? 0,
buttons: init.buttons ?? 0
})
// Why: happy-dom's PointerEvent init drops pointerType; grafting it onto a
// MouseEvent exercises the same duck-typed reads the WebView handler does.
Object.defineProperty(event, 'pointerType', { value: init.pointerType ?? 'mouse' })
terminalSurface().dispatchEvent(event)
}
function mouseClick(x: number, y: number): void {
dispatchPointer('pointerdown', { x, y, button: 0, buttons: 1 })
dispatchPointer('pointerup', { x, y, button: 0, buttons: 0 })
}
function mouseDrag(x1: number, y1: number, x2: number, y2: number): void {
dispatchPointer('pointerdown', { x: x1, y: y1, button: 0, buttons: 1 })
const midX = Math.round((x1 + x2) / 2)
const midY = Math.round((y1 + y2) / 2)
dispatchPointer('pointermove', { x: midX, y: midY, button: 0, buttons: 1 })
dispatchPointer('pointermove', { x: x2, y: y2, button: 0, buttons: 1 })
dispatchPointer('pointerup', { x: x2, y: y2, button: 0, buttons: 0 })
}
function postedMessages(postMessage: PostMessage): Record<string, unknown>[] {
return postMessage.mock.calls.map(([raw]) => JSON.parse(String(raw)) as Record<string, unknown>)
}
function terminalInputBytes(postMessage: PostMessage): string {
return postedMessages(postMessage)
.filter((message) => message.type === 'terminal-input')
.map((message) => (message.bytes as string) ?? '')
.join('')
}
export function useTerminalMouseWebViewHarness() {
let animationFrames: (() => void)[]
let buffer: BufferState
let postMessage: PostMessage
let registeredDocumentListeners: RegisteredEventListener[]
let registeredWindowListeners: RegisteredEventListener[]
let select: Select
let terminals: TerminalStub[]
function boot(): void {
document.body.innerHTML = bodyMarkup()
runInThisContext(iifeSource())
window.dispatchEvent(
new MessageEvent('message', {
data: JSON.stringify({ type: 'init', cols: 40, rows: 24, initialData: '' })
})
)
// Why: init commits the replacement surface on the next animation frame.
while (animationFrames.length > 0) {
animationFrames.shift()?.()
}
}
function activeTerminal(): TerminalStub {
const terminal = terminals.at(-1)
if (!terminal) {
throw new Error('terminal missing')
}
return terminal
}
beforeEach(() => {
animationFrames = []
buffer = { baseY: 0, type: 'normal', viewportY: 0 }
registeredDocumentListeners = []
registeredWindowListeners = []
select = vi.fn<(col: number, row: number, len: number) => void>()
terminals = []
const addDocumentEventListener = document.addEventListener.bind(document)
vi.spyOn(document, 'addEventListener').mockImplementation(((
type: string,
listener: EventListenerOrEventListenerObject,
options?: boolean | AddEventListenerOptions
) => {
registeredDocumentListeners.push({ type, listener, options })
addDocumentEventListener(type, listener, options)
}) as typeof document.addEventListener)
const addWindowEventListener = window.addEventListener.bind(window)
vi.spyOn(window, 'addEventListener').mockImplementation(((
type: string,
listener: EventListenerOrEventListenerObject,
options?: boolean | AddEventListenerOptions
) => {
registeredWindowListeners.push({ type, listener, options })
addWindowEventListener(type, listener, options)
}) as typeof window.addEventListener)
vi.stubGlobal('requestAnimationFrame', (callback: () => void) => {
animationFrames.push(callback)
return animationFrames.length
})
vi.stubGlobal('cancelAnimationFrame', () => {})
Object.defineProperty(window, 'innerWidth', { value: 381, configurable: true })
Object.defineProperty(window, 'innerHeight', { value: 612, configurable: true })
postMessage = vi.fn<(data: string) => void>()
const webWindow = window as unknown as {
Terminal: new () => TerminalStub
ReactNativeWebView: { postMessage: (data: string) => void }
}
webWindow.Terminal = function () {
const terminal = makeTerminal(buffer, select)
terminals.push(terminal)
return terminal
} as unknown as new () => TerminalStub
webWindow.ReactNativeWebView = { postMessage }
})
afterEach(() => {
for (const { type, listener, options } of registeredDocumentListeners) {
document.removeEventListener(type, listener as EventListener, options)
}
for (const { type, listener, options } of registeredWindowListeners) {
window.removeEventListener(type, listener as EventListener, options)
}
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
return {
activeTerminal,
boot,
clearPostedMessages: () => postMessage.mockClear(),
dispatchPointer,
mouseClick,
mouseDrag,
postedMessages: () => postedMessages(postMessage),
selectionSpy: () => select,
terminalInputBytes: () => terminalInputBytes(postMessage),
terminalSurface
}
}
@@ -31,6 +31,9 @@ type CarrierBehavior =
// Endpoint is healthy but its e2ee_ready lands after readyAfterMs, which a
// high-latency / lossy cellular link readily pushes past the 5s budget.
| { kind: 'slow-handshake'; readyAfterMs: number }
// Upgrade completes, nothing is ever answered, and the native socket is already
// half-open, so RN never delivers a close event for the client's own close().
| { kind: 'wedged-open-then-silent' }
let carrier: CarrierBehavior = { kind: 'blackhole' }
const sockets: CarrierWebSocket[] = []
@@ -54,7 +57,11 @@ class CarrierWebSocket {
setTimeout(() => this.fail(), 1)
return
}
if (carrier.kind === 'open-then-silent' || carrier.kind === 'slow-handshake') {
if (
carrier.kind === 'open-then-silent' ||
carrier.kind === 'slow-handshake' ||
carrier.kind === 'wedged-open-then-silent'
) {
setTimeout(() => {
this.readyState = 1
this.onopen?.()
@@ -88,6 +95,10 @@ class CarrierWebSocket {
if (this.readyState === 3) {
return
}
if (carrier.kind === 'wedged-open-then-silent') {
this.readyState = 3
return
}
this.fail()
}
@@ -235,6 +246,35 @@ describe('issue #10119 — what a phone shows while it cannot reach the desktop'
client.close()
})
it('redials after a handshake timeout the wedged transport never reports as closed', () => {
carrier = { kind: 'wedged-open-then-silent' }
const client = connect('ws://192.168.0.56:6769', 'device-token', 'server-public-key')
// 200ms upgrade plus the 5s handshake budget, still inside the reconnect delay.
vi.advanceTimersByTime(5_400)
// Without the onclose fallback nothing else leaves 'handshaking': no reconnect
// timer is armed, the activity probe bails, and foregrounding is a no-op.
expect(sockets).toHaveLength(1)
expect(client.getState()).toBe('reconnecting')
vi.advanceTimersByTime(1_000)
expect(sockets).toHaveLength(2)
client.close()
})
it('escalates past "Connecting…" while every wedged dial swallows its close event', () => {
carrier = { kind: 'wedged-open-then-silent' }
const samples = observe('ws://192.168.0.56:6769', 900_000)
expect(Math.max(...samples.map((s) => s.attempts))).toBeGreaterThanOrEqual(12)
expect(samples.some((s) => s.label === "Can't reach desktop")).toBe(true)
const after = labelsAfterFirstEscalation(samples)
expect(after.every((s) => s.label !== 'Connecting…')).toBe(true)
})
it('contrast: an endpoint that RSTs escalates the same way', () => {
carrier = { kind: 'refused' }
const samples = observe('ws://192.168.0.56:6769', 60_000)
@@ -339,6 +339,48 @@ describe('mobile relay pairing journal store', () => {
await expect(loadMobileRelayPairingJournal()).resolves.toEqual(journal)
})
it('self-heals an undecryptable Android secret so the next QR scan can pair', async () => {
platform.OS = 'android'
const created = createMobileRelayPairingJournal({
offer: offer as PairingOffer & { relay: NonNullable<PairingOffer['relay']> },
hostId: 'host-1',
hostName: 'Blue Whale',
randomBytes: (length) => new Uint8Array(length).fill(13)
})
// Why: the candidate race stamps winner/authorizationMode long before pairing completes.
const journal = {
...created,
metadata: {
...created.metadata,
winner: 'relay' as const,
authorizationMode: 'relay-basis' as const
}
}
await saveMobileRelayPairingJournal(journal)
expect(presenceRaw).toBe('0')
// Why: Android SecureStore reports an undecryptable keystore entry as null (#6600).
secretRaw = null
await expect(loadMobileRelayPairingJournal()).resolves.toBeNull()
expect(metadataRaw).toBeNull()
// Why: the presence record must outlive the self-heal so reads stay absent instead of
// walking back to an older generation; the metadata-less load sweeps it next.
expect(presenceRaw).toBe('0')
await expect(loadMobileRelayPairingJournal()).resolves.toBeNull()
expect(presenceRaw).toBeNull()
const rescan = createMobileRelayPairingJournal({
offer: offer as PairingOffer & { relay: NonNullable<PairingOffer['relay']> },
hostId: 'host-2',
hostName: 'Red Panda',
randomBytes: (length) => new Uint8Array(length).fill(14)
})
await expect(saveMobileRelayPairingJournal(rescan)).resolves.toBeUndefined()
await expect(loadMobileRelayPairingJournal()).resolves.toEqual(rescan)
})
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']> },
+14 -2
View File
@@ -246,7 +246,7 @@ describe('pairing keychain', () => {
expect(secureStoreMock.getItemAsync).toHaveBeenCalledTimes(1)
})
it('does not return a stale older value when a recorded current item decrypts as null', async () => {
it('self-heals to absent, never a stale older value, when a recorded item decrypts as null', async () => {
generationRecord = '1'
let presenceRecord: string | null = null
asyncStorageMock.getItem.mockImplementation(async (key: string) => {
@@ -263,6 +263,11 @@ describe('pairing keychain', () => {
presenceRecord = raw
}
})
asyncStorageMock.removeItem.mockImplementation(async (key: string) => {
if (key === TOKEN_PRESENCE_KEY) {
presenceRecord = null
}
})
await writePairingKeychainItem(TOKEN_KEY, 'rotated-token')
expect(presenceRecord).toBe('1')
@@ -275,8 +280,15 @@ describe('pairing keychain', () => {
serviceOf(options) === undefined ? 'legacy-token' : null
)
await expect(readPairingKeychainItem(TOKEN_KEY)).rejects.toThrow(/recorded generation/)
// Why: Android cannot distinguish absent from undecryptable, and a throw here left every
// caller's orphan cleanup unreachable; absent is the only self-healing answer.
await expect(readPairingKeychainItem(TOKEN_KEY)).resolves.toBeNull()
expect(secureStoreMock.getItemAsync).toHaveBeenCalledTimes(1)
// Why: clearing the presence record would let the next read walk back to 'legacy-token'.
expect(presenceRecord).toBe('1')
await expect(readPairingKeychainItem(TOKEN_KEY)).resolves.toBeNull()
expect(secureStoreMock.getItemAsync).toHaveBeenCalledTimes(2)
})
it('reads an older value while a newly recorded alias has no item yet', async () => {
+6 -1
View File
@@ -162,7 +162,12 @@ export async function readPairingKeychainItem(key: string): Promise<string | nul
if (presenceGeneration !== null) {
const value = await SecureStore.getItemAsync(key, optionsForGeneration(presenceGeneration))
if (value === null) {
throw new Error('pairing keychain item is unavailable at its recorded generation')
// Why: Android reports an undecryptable entry as null, so failing closed here latched
// callers out of their own orphan cleanup forever; report absent instead. The presence
// record must survive — it is the only thing that keeps a later read from resurrecting
// the superseded value under an older generation. Callers clear it via delete or a
// re-pair write, so a legitimately-current older value is traded for re-pairing.
return null
}
return value
}
@@ -0,0 +1,221 @@
// Why: RN can swallow onclose for a wedged iOS transport, so the client synthesizes the close.
// These cover the diagnostic clocks that synthesis has to keep honest, and prove synthesis
// never costs us the auth-failed latch when the real close lands late.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { connect } from './rpc-client'
vi.mock('./e2ee', () => ({
generateKeyPair: () => ({
publicKey: new Uint8Array(32),
secretKey: new Uint8Array(32)
}),
deriveSharedKey: () => new Uint8Array(32),
publicKeyFromBase64: () => new Uint8Array(32),
publicKeyToBase64: () => 'client-public-key',
encrypt: (plaintext: string) => `encrypted:${plaintext}`,
decrypt: (raw: string) => raw.replace(/^encrypted:/, ''),
decryptBytes: (bytes: Uint8Array) => bytes
}))
// Why: close() deliberately never fires onclose — that is the wedged-transport bug being modelled.
class WedgedWebSocket {
static CONNECTING = 0
static OPEN = 1
static CLOSING = 2
static CLOSED = 3
readonly CONNECTING = WedgedWebSocket.CONNECTING
readonly OPEN = WedgedWebSocket.OPEN
readonly CLOSING = WedgedWebSocket.CLOSING
readonly CLOSED = WedgedWebSocket.CLOSED
readyState = WedgedWebSocket.CONNECTING
onopen: (() => void) | null = null
onclose: ((event?: { code?: number; reason?: string; wasClean?: boolean }) => void) | null = null
onmessage: ((event: { data: unknown }) => void) | null = null
onerror: (() => void) | null = null
sent: string[] = []
close = vi.fn(() => {
this.readyState = WedgedWebSocket.CLOSED
})
constructor(readonly endpoint: string) {
sockets.push(this)
}
send(payload: string): void {
this.sent.push(payload)
}
open(): void {
this.readyState = WedgedWebSocket.OPEN
this.onopen?.()
}
receive(payload: unknown): void {
this.onmessage?.({ data: payload })
}
// Why: the close event the native layer eventually coughs up, possibly long after we gave up.
deliverClose(code: number, reason = ''): void {
this.readyState = WedgedWebSocket.CLOSED
this.onclose?.({ code, reason, wasClean: true })
}
}
const sockets: WedgedWebSocket[] = []
const originalWebSocket = globalThis.WebSocket
function lastSocket(): WedgedWebSocket {
return sockets[sockets.length - 1]!
}
function openConnectionLogs(spy: ReturnType<typeof vi.spyOn>): Record<string, unknown>[] {
return spy.mock.calls
.filter((call) => call[0] === '[net] openConnection')
.map((call) => call[1] as Record<string, unknown>)
}
describe('synthesized close diagnostics', () => {
let logSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
vi.useFakeTimers()
sockets.length = 0
globalThis.WebSocket = WedgedWebSocket as unknown as typeof WebSocket
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
})
afterEach(() => {
logSpy.mockRestore()
vi.useRealTimers()
globalThis.WebSocket = originalWebSocket
})
it('records the close clock when a handshake timeout synthesizes the close', async () => {
const client = connect('ws://desktop.invalid', 'token', 'server-key')
lastSocket().open()
// 5s handshake budget elapses with no e2ee_ready — the client closes and synthesizes onclose.
await vi.advanceTimersByTimeAsync(5_000)
expect(client.getState()).toBe('reconnecting')
// 500ms is the first reconnect delay; the redial log must date the close it just made.
await vi.advanceTimersByTimeAsync(500)
expect(sockets).toHaveLength(2)
const redial = openConnectionLogs(logSpy)[1]
expect(redial?.msSinceLastClose).toBe(500)
client.close()
})
it('keeps the replacement socket clock when the swallowed close arrives late', async () => {
const client = connect('ws://desktop.invalid', 'token', 'server-key')
const wedged = lastSocket()
wedged.open()
await vi.advanceTimersByTimeAsync(100)
wedged.receive(JSON.stringify({ type: 'e2ee_ready' }))
await vi.advanceTimersByTimeAsync(4_900)
await vi.advanceTimersByTimeAsync(500)
expect(sockets).toHaveLength(2)
const replacement = lastSocket()
replacement.open()
replacement.receive(JSON.stringify({ type: 'e2ee_ready' }))
replacement.receive('encrypted:{"type":"e2ee_authenticated"}')
// The native layer finally reports the dead socket's close, 200ms into the replacement's life.
await vi.advanceTimersByTimeAsync(200)
wedged.deliverClose(1006)
const staleCloseLog = logSpy.mock.calls.findLast(
(call) => call[0] === '[net] ws.onclose'
)?.[1] as Record<string, unknown> | undefined
expect(staleCloseLog?.constructToCloseMs).toBe(5_700)
expect(staleCloseLog?.aliveMs).toBeNull()
expect(staleCloseLog?.inboundIdleMs).toBe(5_600)
// The replacement's own close must still be able to date itself against its open.
await vi.advanceTimersByTimeAsync(300)
lastSocket().deliverClose(1006)
const closeLog = logSpy.mock.calls.findLast((call) => call[0] === '[net] ws.onclose')?.[1] as
| Record<string, unknown>
| undefined
expect(closeLog?.constructToCloseMs).toBe(500)
expect(closeLog?.aliveMs).toBe(500)
expect(closeLog?.inboundIdleMs).toBe(500)
client.close()
})
it('still latches auth-failed when a real 4001 lands after a synthesized close', async () => {
const client = connect('ws://desktop.invalid', 'token', 'server-key')
const wedged = lastSocket()
wedged.open()
await vi.advanceTimersByTimeAsync(5_000)
expect(client.getState()).toBe('reconnecting')
await vi.advanceTimersByTimeAsync(500)
const replacement = lastSocket()
// The rejection counts, but stale cleanup must not disturb the replacement or its timer path.
wedged.deliverClose(4001, 'Unauthorized')
expect(lastSocket()).toBe(replacement)
expect(replacement.close).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1_000)
expect(sockets).toHaveLength(2)
expect(lastSocket()).toBe(replacement)
expect(client.getState()).toBe('connecting')
for (let rejection = 0; rejection < 2; rejection += 1) {
const socket = lastSocket()
socket.open()
socket.receive(JSON.stringify({ type: 'e2ee_ready' }))
socket.deliverClose(4001, 'Unauthorized')
if (rejection === 0) {
await vi.advanceTimersByTimeAsync(2_500)
}
}
expect(client.getState()).toBe('auth-failed')
expect(sockets).toHaveLength(3)
// A latched client must stay latched and stop redialling.
const dialCount = sockets.length
await vi.advanceTimersByTimeAsync(10 * 60_000)
expect(sockets).toHaveLength(dialCount)
expect(client.getState()).toBe('auth-failed')
client.close()
})
it('ignores a stale 4001 after the replacement authenticates', async () => {
const client = connect('ws://desktop.invalid', 'token', 'server-key')
const wedged = lastSocket()
wedged.open()
await vi.advanceTimersByTimeAsync(5_500)
const replacement = lastSocket()
replacement.open()
replacement.receive(JSON.stringify({ type: 'e2ee_ready' }))
replacement.receive('encrypted:{"type":"e2ee_authenticated"}')
expect(client.getState()).toBe('connected')
wedged.deliverClose(4001, 'Unauthorized')
expect(client.getState()).toBe('connected')
expect(replacement.close).not.toHaveBeenCalled()
for (let rejection = 0; rejection < 2; rejection += 1) {
const socket = lastSocket()
socket.deliverClose(4001, 'Unauthorized')
expect(client.getState()).toBe('reconnecting')
await vi.advanceTimersByTimeAsync(rejection === 0 ? 500 : 1_000)
lastSocket().open()
lastSocket().receive(JSON.stringify({ type: 'e2ee_ready' }))
}
lastSocket().deliverClose(4001, 'Unauthorized')
expect(client.getState()).toBe('auth-failed')
client.close()
})
})
+57 -62
View File
@@ -27,7 +27,12 @@ import {
buildTerminalUnsubscribeParams,
updateTerminalSubscriptionViewport as updateCachedTerminalSubscriptionViewport
} from './rpc-client-terminal-subscription'
import { describeSocketEvent } from './socket-event-debug'
import { describeSocketEvent, redactSocketEndpoint } from './socket-event-debug'
import {
isStaleRpcSocketEvent,
logRpcSocketClose,
RpcSynthesizedCloseIndex
} from './rpc-socket-close-evidence'
import { markRpcDeliveryUnknown } from './rpc-delivery-ambiguity'
import { openRpcRequestBudget, resolvePostConnectRequestTimeout } from './rpc-request-budget'
import { isRpcResponse } from './rpc-response-shape'
@@ -144,10 +149,7 @@ export function connect(
const { onStateChange, onLog } = options
let logCounter = 0
function emitLog(level: ConnectionLogLevel, message: string, detail?: string) {
if (!onLog) {
return
}
onLog({
onLog?.({
id: `log-${++logCounter}-${Date.now()}`,
ts: Date.now(),
level,
@@ -156,6 +158,7 @@ export function connect(
})
}
let ws: WebSocket | null = null
const synthesizedCloses = new RpcSynthesizedCloseIndex()
let state: ConnectionState = 'disconnected'
let requestCounter = 0
let reconnectAttempt = 0
@@ -167,13 +170,13 @@ export function connect(
let intentionallyClosed = false
// Consecutive auth rejections; tolerate up to AUTH_RETRY_BUDGET (issue #5200) before latching to avoid a needless re-pair.
let authRejectionCount = 0
let authenticationGeneration = 0
let lastConnectedAt: number | null = null
// Why: cheap diagnostics for RN/OkHttp process-state poisoning (retry cadence, inbound traffic, close timing).
let lastInboundAt: number | null = null
let controlResponseSequence = 0
let lastWsClosedAt: number | null = null
let wsConstructionCounter = 0
let currentWsOpenedAt: number | null = null
// Why: fresh ephemeral keypair per connection provides forward secrecy.
let sharedKey: Uint8Array | null = null
@@ -219,10 +222,11 @@ export function connect(
to: next,
dweltMs: dwelt,
attempt: reconnectAttempt,
endpoint: redactedEndpoint(endpoint)
endpoint: redactSocketEndpoint(endpoint)
})
if (next === 'connected') {
lastConnectedAt = Date.now()
authenticationGeneration++
// Why: only a completed E2EE handshake proves the path is healthy (issue #10119).
reconnectAttempt = 0
// Why: a clean handshake proves the token is valid — reset the auth retry budget.
@@ -243,15 +247,6 @@ export function connect(
}
}
// Why: keep device tokens / full URLs out of log scrolls — truncate to host:port.
function redactedEndpoint(ep: string): string {
try {
return new URL(ep).host || 'unknown'
} catch {
return 'unknown'
}
}
function waitForConnected(timeoutMs?: number): Promise<void> {
if (state === 'connected') {
return Promise.resolve()
@@ -295,7 +290,7 @@ export function connect(
wsConstructionCounter++
console.log('[net] openConnection', {
attempt: reconnectAttempt,
endpoint: redactedEndpoint(endpoint),
endpoint: redactSocketEndpoint(endpoint),
// Why: diagnostic for RN/OkHttp pool corruption — high wsCount + repeated 1006 closes means process-state stuck.
wsCount: wsConstructionCounter,
msSinceLastConnected: lastConnectedAt != null ? now - lastConnectedAt : null,
@@ -305,27 +300,16 @@ export function connect(
setState('connecting')
sharedKey = null
currentWsOpenedAt = now
emitLog(
'info',
reconnectAttempt > 0 ? `Reconnecting (attempt ${reconnectAttempt + 1})` : 'Opening WebSocket',
redactedEndpoint(endpoint)
redactSocketEndpoint(endpoint)
)
ws = new WebSocket(endpoint)
const openingWs = ws
const ignoreStaleSocketEvent = (eventName: string): boolean => {
if (ws === openingWs) {
return false
}
// Why: RN can deliver callbacks from a timed-out socket after reconnect swapped in a replacement — ignore them.
console.log('[net] stale ws event ignored', {
eventName,
state,
attempt: reconnectAttempt
})
return true
}
let openingWsAuthenticated = false
let openingWsLastInboundAt: number | null = null
// Why: RN can leave opens pending forever on flaky handoffs — force reconnect if onopen never arrives.
connectTimer = setTimeout(() => {
@@ -342,13 +326,14 @@ export function connect(
)
openingWs.close()
if (ws === openingWs) {
synthesizedCloses.remember(openingWs, authenticationGeneration)
handleSocketClosed(openingWs, { timedOut: true })
}
}
}, CONNECT_TIMEOUT_MS)
ws.onopen = () => {
if (ignoreStaleSocketEvent('open')) {
if (isStaleRpcSocketEvent(ws, openingWs, 'open', state, reconnectAttempt)) {
return
}
console.log('[net] ws.onopen', { attempt: reconnectAttempt })
@@ -384,18 +369,23 @@ export function connect(
`No e2ee_ready/e2ee_authenticated within ${HANDSHAKE_TIMEOUT_MS / 1000}s`
)
openingWs.close()
// Why: React Native can omit onclose for a wedged iOS transport.
if (ws === openingWs) {
synthesizedCloses.remember(openingWs, authenticationGeneration)
handleSocketClosed(openingWs, { timedOut: true })
}
}, HANDSHAKE_TIMEOUT_MS)
}
ws.onmessage = (event) => {
if (ignoreStaleSocketEvent('message')) {
if (isStaleRpcSocketEvent(ws, openingWs, 'message', state, reconnectAttempt)) {
return
}
void handleSocketMessage(event.data)
}
async function handleSocketMessage(rawData: unknown) {
lastInboundAt = Date.now()
lastInboundAt = openingWsLastInboundAt = Date.now()
const raw = typeof rawData === 'string' ? rawData : null
// Why: e2ee_ready is plaintext (precedes encrypted auth); e2ee_authenticated/e2ee_error are encrypted.
@@ -433,6 +423,7 @@ export function connect(
console.log('[net] e2ee_authenticated — connected', {
streamCount: streamListeners.size
})
openingWsAuthenticated = true
setState('connected')
emitLog('success', 'Authenticated', 'Channel ready for RPC')
startActivityProbe()
@@ -600,36 +591,21 @@ export function connect(
}
ws.onclose = (event) => {
const e = event as { code?: number; reason?: string; wasClean?: boolean } | undefined
const closeAt = Date.now()
// Why: time-since-construct classifies the failure — instant close = RST/unreachable, slow = SYN timeout/packet loss.
const constructToCloseMs = currentWsOpenedAt != null ? closeAt - currentWsOpenedAt : null
const aliveMs =
currentWsOpenedAt != null && state === 'connected' ? closeAt - currentWsOpenedAt : null
const inboundIdleMs = lastInboundAt != null ? closeAt - lastInboundAt : null
// Why: statically imported — a hot-reload bug came from a stale closure capturing a half-loaded module.
const closeEvent = describeSocketEvent(event)
console.log('[net] ws.onclose', {
code: e?.code,
reason: e?.reason,
wasClean: e?.wasClean,
const closeCode = logRpcSocketClose({
event,
state,
attempt: reconnectAttempt,
intentionallyClosed,
endpoint: redactedEndpoint(endpoint),
constructToCloseMs,
aliveMs,
inboundIdleMs,
eventKeys: closeEvent.keys,
eventStr: closeEvent.json
endpoint: redactSocketEndpoint(endpoint),
constructedAt: now,
authenticated: openingWsAuthenticated,
lastInboundAt: openingWsLastInboundAt
})
lastWsClosedAt = closeAt
currentWsOpenedAt = null
handleSocketClosed(openingWs, { closeCode: e?.code })
handleSocketClosed(openingWs, { closeCode })
}
ws.onerror = (event) => {
if (ignoreStaleSocketEvent('error')) {
if (isStaleRpcSocketEvent(ws, openingWs, 'error', state, reconnectAttempt)) {
return
}
// Why: RN surfaces the original network error here — onclose follows but its close code alone hides the cause.
@@ -650,12 +626,24 @@ export function connect(
opts: { timedOut?: boolean; closeCode?: number } = {}
) {
if (ws !== closedWs) {
if (
synthesizedCloses.takeUnauthorized(
closedWs,
opts.closeCode,
authenticationGeneration,
UNAUTHORIZED_CLOSE_CODE
)
) {
handleAuthRejection('Unauthorized — pairing may be revoked', true)
return
}
console.log('[net] handleSocketClosed STALE — ignoring (ws already swapped)', {
state,
attempt: reconnectAttempt
})
return
}
lastWsClosedAt = Date.now()
clearConnectTimer()
ws = null
sharedKey = null
@@ -696,21 +684,24 @@ export function connect(
}
// Why: an auth rejection may be transient (issue #5200) — retry up to AUTH_RETRY_BUDGET times before latching auth-failed.
function handleAuthRejection(reason: string): void {
activeBrowserScreencastRequestId = null
pendingBrowserScreencastRequestId = null
function handleAuthRejection(reason: string, preserveRecovery = false): void {
authRejectionCount++
if (authRejectionCount < AUTH_RETRY_BUDGET) {
console.log('[net] auth rejected — retrying handshake', {
attempt: authRejectionCount,
budget: AUTH_RETRY_BUDGET,
endpoint: redactedEndpoint(endpoint)
endpoint: redactSocketEndpoint(endpoint)
})
emitLog(
'warn',
'Authentication rejected',
`Retrying (${authRejectionCount}/${AUTH_RETRY_BUDGET})`
)
if (preserveRecovery) {
return
}
activeBrowserScreencastRequestId = null
pendingBrowserScreencastRequestId = null
// Why: close without setting intentionallyClosed so handleSocketClosed routes to reconnect and retries the handshake.
const closing = ws
ws = null
@@ -725,9 +716,11 @@ export function connect(
scheduleReconnect()
return
}
activeBrowserScreencastRequestId = null
pendingBrowserScreencastRequestId = null
console.log('[net] auth rejected — budget exhausted, latching auth-failed', {
attempt: authRejectionCount,
endpoint: redactedEndpoint(endpoint)
endpoint: redactSocketEndpoint(endpoint)
})
intentionallyClosed = true
ws?.close()
@@ -823,6 +816,7 @@ export function connect(
}
// Why: RN can omit onclose for a half-open socket; demote locally instead of staying falsely connected.
if (socket === ws) {
synthesizedCloses.remember(socket, authenticationGeneration)
handleSocketClosed(socket, { timedOut: true })
}
}
@@ -974,6 +968,7 @@ export function connect(
console.log('[net] sendEncrypted detected ws desync — forcing reconnect', {
readyState: ws.readyState
})
synthesizedCloses.remember(ws, authenticationGeneration)
handleSocketClosed(ws, { timedOut: false })
}
return false
@@ -0,0 +1,70 @@
import { describeSocketEvent } from './socket-event-debug'
import type { ConnectionState } from './types'
type SocketCloseLogOptions = {
event: unknown
state: ConnectionState
attempt: number
intentionallyClosed: boolean
endpoint: string
constructedAt: number
authenticated: boolean
lastInboundAt: number | null
}
export class RpcSynthesizedCloseIndex {
private readonly authenticationBySocket = new WeakMap<WebSocket, number>()
remember(socket: WebSocket, authenticationGeneration: number): void {
this.authenticationBySocket.set(socket, authenticationGeneration)
}
takeUnauthorized(
socket: WebSocket,
closeCode: number | undefined,
authenticationGeneration: number,
unauthorizedCloseCode: number
): boolean {
const synthesizedAtGeneration = this.authenticationBySocket.get(socket)
this.authenticationBySocket.delete(socket)
return (
closeCode === unauthorizedCloseCode && synthesizedAtGeneration === authenticationGeneration
)
}
}
export function isStaleRpcSocketEvent(
current: WebSocket | null,
opening: WebSocket,
eventName: string,
state: ConnectionState,
attempt: number
): boolean {
if (current === opening) {
return false
}
console.log('[net] stale ws event ignored', { eventName, state, attempt })
return true
}
export function logRpcSocketClose(options: SocketCloseLogOptions): number | undefined {
const event = options.event as { code?: number; reason?: string; wasClean?: boolean } | undefined
const closeAt = Date.now()
const constructToCloseMs = closeAt - options.constructedAt
const closeEvent = describeSocketEvent(options.event)
console.log('[net] ws.onclose', {
code: event?.code,
reason: event?.reason,
wasClean: event?.wasClean,
state: options.state,
attempt: options.attempt,
intentionallyClosed: options.intentionallyClosed,
endpoint: options.endpoint,
constructToCloseMs,
aliveMs: options.authenticated ? constructToCloseMs : null,
inboundIdleMs: options.lastInboundAt != null ? closeAt - options.lastInboundAt : null,
eventKeys: closeEvent.keys,
eventStr: closeEvent.json
})
return event?.code
}
@@ -32,3 +32,11 @@ export function describeSocketEvent(event: unknown): { keys: string[]; json: str
}
return { keys, json }
}
export function redactSocketEndpoint(endpoint: string): string {
try {
return new URL(endpoint).host || 'unknown'
} catch {
return 'unknown'
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "orca",
"version": "1.4.163-rc.1",
"version": "1.4.163-rc.3",
"description": "Next-gen IDE for parallel agentic development",
"homepage": "https://github.com/stablyai/orca",
"author": "stablyai",
+2
View File
@@ -62,6 +62,8 @@ Treat the authority label on injected or formatted messages as definitive:
- `[LEGACY READ-ONLY]` is inspection-only. It has no reply, acknowledgment, or lifecycle action.
- An unlabeled current message uses the current guide and current grammar.
An explicitly selected current Run, attested current Run binding, current Dispatch, or federated attachment takes precedence over legacy fallback. A retained adoption record alone never turns a current command into a legacy call.
Database provenance, an old-looking terminal, or a legacy Run ID does not prove mutation authority. If the runtime cannot prove liveness, principal ownership, capability, or the exact legacy contract, it degrades to read-only inspection and must not fall back to local execution. Exact recovery may restore the already-live PTY once in its original inactive background tab. It must not spawn, write, signal, stop, switch, focus, split, or inject a terminal. Loss of lifecycle authority does not invalidate the existing assignment, process, or filesystem work.
Compatibility retries have narrow guarantees. A pending ask, a reply, a final Dispatch settlement, and a consuming check have durable recovery identities. A-era heartbeat and escalation calls remain at-least-once across a manual A-to-B retry because identical later signals may be intentional. If an A-era ask may already have been answered, run the exact non-consuming recovery check printed by the runtime first; after its answer is printed and acknowledged, a new invocation with the same question creates a new question. Never guess among multiple identical question threads.
File diff suppressed because one or more lines are too long
+34 -1
View File
@@ -49,7 +49,11 @@ vi.mock('../../shared/node-cli-command-resolution', () => ({
import { ACCOUNT_HANDLERS } from './account'
import type { HandlerContext } from '../dispatch'
import type { RuntimeClient } from '../runtime-client'
import { getCmdExePath } from '../../shared/windows-batch-spawn'
import {
getCmdExePath,
WINDOWS_BATCH_UNSAFE_ARGUMENTS_ERROR,
WINDOWS_BATCH_UNSAFE_CHARACTERS_LABEL
} from '../../shared/windows-batch-spawn'
import { ACCOUNT_IMPORT_RUNTIME_CAPABILITY } from '../../shared/protocol-version'
function successfulChild(): EventEmitter {
@@ -168,6 +172,35 @@ describe('account CLI handlers', () => {
)
})
it('launches a Windows shim installed under Program Files (x86)', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
const shim = 'C:\\Program Files (x86)\\nodejs\\codex.cmd'
resolveCliCommandMock.mockReturnValue(shim)
await ACCOUNT_HANDLERS['account add'](context('codex'))
expect(spawnMock).toHaveBeenCalledWith(
getCmdExePath(),
['/d', '/c', shim, 'login', '--device-auth'],
expect.anything()
)
})
it('explains an unspawnable Windows shim path instead of leaking the error sentinel', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
resolveCliCommandMock.mockReturnValue('C:\\Users\\A&B\\codex.cmd')
const error = await ACCOUNT_HANDLERS['account add'](context('codex')).catch(
(thrown: unknown) => thrown
)
expect(spawnMock).not.toHaveBeenCalled()
const message = error instanceof Error ? error.message : String(error)
expect(message).not.toBe(WINDOWS_BATCH_UNSAFE_ARGUMENTS_ERROR)
expect(message).toContain('C:\\Users\\A&B\\codex.cmd')
expect(message).toContain(WINDOWS_BATCH_UNSAFE_CHARACTERS_LABEL)
})
it('adds version-manager Node paths to the login child environment', async () => {
const nodeBin = '/home/test/.nvm/versions/node/v22.0.0/bin'
getVersionManagerBinPathsMock.mockReturnValue([nodeBin])
+24 -2
View File
@@ -16,7 +16,11 @@ import {
getVersionManagerBinPaths,
resolveCliCommand
} from '../../shared/node-cli-command-resolution'
import { getSpawnArgsForWindows } from '../../shared/windows-batch-spawn'
import {
getSpawnArgsForWindows,
UnsafeWindowsBatchArgumentsError,
WINDOWS_BATCH_UNSAFE_CHARACTERS_LABEL
} from '../../shared/windows-batch-spawn'
import { ACCOUNT_IMPORT_RUNTIME_CAPABILITY } from '../../shared/protocol-version'
import type { RuntimeStatus } from '../../shared/runtime-types'
import type { ClaudeRateLimitAccountsState, CodexRateLimitAccountsState } from '../../shared/types'
@@ -86,7 +90,25 @@ async function runAgentLoginInTerminal(
): Promise<void> {
await new Promise<void>((resolvePromise, rejectPromise) => {
const resolvedCommand = resolveCliCommand(command)
const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(resolvedCommand, args)
let spawnCmd: string
let spawnArgs: string[]
try {
;({ spawnCmd, spawnArgs } = getSpawnArgsForWindows(resolvedCommand, args))
} catch (error) {
// Why: the bare sentinel message reaches the user verbatim otherwise, with
// nothing naming the path or the characters that made it unspawnable.
rejectPromise(
error instanceof UnsafeWindowsBatchArgumentsError
? new RuntimeClientError(
'invalid_environment',
`Cannot run \`${command}\` from "${resolvedCommand}": the path contains characters ` +
`cmd.exe would reinterpret. Install it somewhere without ` +
`${WINDOWS_BATCH_UNSAFE_CHARACTERS_LABEL} in the path.`
)
: error
)
return
}
const env = addAgentNodePaths({ ...stripElectronRunAsNode(process.env), ...extraEnv })
const child = spawn(spawnCmd, spawnArgs, {
// Why: JSON mode reserves stdout for the response envelope while keeping
@@ -0,0 +1,198 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { callMock, getTerminalHandleMock } = vi.hoisted(() => ({
callMock: vi.fn(),
getTerminalHandleMock: vi.fn()
}))
vi.mock('../runtime-client', async () => {
// Why: re-export the REAL error classes so format.ts `instanceof` narrowing still matches.
const { RuntimeClientError, RuntimeRpcFailureError } = await import('../runtime/types.js')
class RuntimeClient {
readonly isRemote = false
call = callMock
getCliStatus = vi.fn()
openOrca = vi.fn()
}
return {
RuntimeClient,
RuntimeClientError,
RuntimeRpcFailureError,
serveOrcaApp: vi.fn(),
getDefaultUserDataPath: vi.fn(() => '/tmp/orca-user-data')
}
})
vi.mock('../selectors', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
getTerminalHandle: getTerminalHandleMock
}))
import { main } from '../index'
import { RuntimeClientError } from '../runtime/types'
import { okFixture, queueFixtures } from '../test-fixtures'
const originalTerminalHandle = process.env.ORCA_TERMINAL_HANDLE
const originalPaneKey = process.env.ORCA_PANE_KEY
const restoreEnv = (name: string, value: string | undefined): void => {
if (value === undefined) {
delete process.env[name]
} else {
process.env[name] = value
}
}
describe('orchestration gate commands carry caller identity', () => {
let logSpy: ReturnType<typeof vi.spyOn>
let errorSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
callMock.mockReset()
getTerminalHandleMock.mockReset()
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
delete process.env.ORCA_TERMINAL_HANDLE
delete process.env.ORCA_PANE_KEY
process.exitCode = 0
})
afterEach(() => {
logSpy.mockRestore()
errorSpy.mockRestore()
restoreEnv('ORCA_TERMINAL_HANDLE', originalTerminalHandle)
restoreEnv('ORCA_PANE_KEY', originalPaneKey)
process.exitCode = 0
})
const paramsFor = (method: string): Record<string, unknown> =>
callMock.mock.calls.find((call) => call[0] === method)?.[1] as Record<string, unknown>
it('sends the bound coordinator handle to gateCreate', async () => {
process.env.ORCA_TERMINAL_HANDLE = 'term_coord'
queueFixtures(
callMock,
okFixture('req_show', { terminal: { handle: 'term_coord' } }),
okFixture('req_gate', { gate: { id: 'gate_1', task_id: 'task_1', status: 'pending' } })
)
await main(
['orchestration', 'gate-create', '--task', 'task_1', '--question', 'ship?', '--json'],
'/tmp/repo'
)
expect(process.exitCode).toBe(0)
expect(paramsFor('orchestration.gateCreate')).toEqual(
expect.objectContaining({ task: 'task_1', question: 'ship?', from: 'term_coord' })
)
})
it('remints a stale environment handle before authorizing gateCreate', async () => {
process.env.ORCA_TERMINAL_HANDLE = 'term_stale'
process.env.ORCA_PANE_KEY = 'tab_coord:leaf_coord'
callMock.mockImplementation(async (method: string) => {
if (method === 'terminal.show') {
throw new RuntimeClientError('terminal_handle_stale', 'stale')
}
if (method === 'terminal.resolvePane') {
return okFixture('req_pane', { terminal: { handle: 'term_live' } })
}
return okFixture('req_gate', {
gate: { id: 'gate_1', task_id: 'task_1', status: 'pending' }
})
})
await main(
['orchestration', 'gate-create', '--task', 'task_1', '--question', 'ship?', '--json'],
'/tmp/repo'
)
expect(paramsFor('orchestration.gateCreate')).toEqual(
expect.objectContaining({ from: 'term_live' })
)
})
it('accepts an explicit --from without probing terminal liveness', async () => {
queueFixtures(
callMock,
okFixture('req_gate', {
gate: { id: 'gate_1', task_id: 'task_1', status: 'resolved', resolution: 'go' }
})
)
await main(
[
'orchestration',
'gate-resolve',
'--id',
'gate_1',
'--resolution',
'go',
'--from',
'term_explicit',
'--json'
],
'/tmp/repo'
)
expect(process.exitCode).toBe(0)
expect(callMock).toHaveBeenCalledTimes(1)
expect(paramsFor('orchestration.gateResolve')).toEqual(
expect.objectContaining({ id: 'gate_1', resolution: 'go', from: 'term_explicit' })
)
})
it('scopes gate-list to the caller when no Run is named', async () => {
process.env.ORCA_TERMINAL_HANDLE = 'term_coord'
queueFixtures(
callMock,
okFixture('req_show', { terminal: { handle: 'term_coord' } }),
okFixture('req_list', { gates: [], count: 0 })
)
await main(['orchestration', 'gate-list', '--json'], '/tmp/repo')
expect(process.exitCode).toBe(0)
expect(paramsFor('orchestration.gateList')).toEqual(
expect.objectContaining({ from: 'term_coord', run: undefined })
)
})
it('inspects a named Run without resolving a caller terminal', async () => {
// Why: read-only inspection must stay reachable from a pane with no bound Run.
getTerminalHandleMock.mockRejectedValue(
new RuntimeClientError('no_active_terminal', 'no active terminal')
)
queueFixtures(
callMock,
okFixture('req_list', {
gates: [{ id: 'gate_1', task_id: 'task_1', question: 'ship?', status: 'pending' }],
count: 1
})
)
await main(['orchestration', 'gate-list', '--run', 'run_adopted', '--json'], '/tmp/repo')
expect(process.exitCode).toBe(0)
expect(getTerminalHandleMock).not.toHaveBeenCalled()
expect(paramsFor('orchestration.gateList')).toEqual(
expect.objectContaining({ run: 'run_adopted', from: undefined })
)
})
it('fails an unbound gate-create with an actionable error and no mutation', async () => {
getTerminalHandleMock.mockRejectedValue(
new RuntimeClientError('no_active_terminal', 'no active terminal')
)
await main(
['orchestration', 'gate-create', '--task', 'task_1', '--question', 'ship?'],
'/tmp/repo'
)
expect(process.exitCode).toBe(1)
const stderr = errorSpy.mock.calls.map((call) => String(call[0])).join('\n')
expect(stderr).toContain('Pass --from <terminal-handle>')
expect(callMock).not.toHaveBeenCalledWith('orchestration.gateCreate', expect.anything())
})
})
+15 -6
View File
@@ -1093,13 +1093,15 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
)
},
'orchestration gate-create': async ({ flags, client, json }) => {
'orchestration gate-create': async ({ flags, client, cwd, json }) => {
const result = await callMutation<{
gate: { id: string; task_id: string; status: string }
}>(client, flags, 'orchestration.gateCreate', {
task: getRequiredStringFlag(flags, 'task'),
question: getRequiredStringFlag(flags, 'question'),
options: getOptionalStringFlag(flags, 'options')
options: getOptionalStringFlag(flags, 'options'),
// Why: gates are Run-scoped, so the coordinator handle is the caller identity the runtime authorizes against.
from: await resolveCoordinatorTerminalHandle(flags, cwd, client)
})
printResult(
result,
@@ -1108,23 +1110,30 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
)
},
'orchestration gate-resolve': async ({ flags, client, json }) => {
'orchestration gate-resolve': async ({ flags, client, cwd, json }) => {
const result = await callMutation<{
gate: { id: string; task_id: string; status: string; resolution: string }
}>(client, flags, 'orchestration.gateResolve', {
id: getRequiredStringFlag(flags, 'id'),
resolution: getRequiredStringFlag(flags, 'resolution')
resolution: getRequiredStringFlag(flags, 'resolution'),
from: await resolveCoordinatorTerminalHandle(flags, cwd, client)
})
printResult(result, json, (r) => `Gate ${r.gate.id} resolved: ${r.gate.resolution}`)
},
'orchestration gate-list': async ({ flags, client, json }) => {
'orchestration gate-list': async ({ flags, client, cwd, json }) => {
const run = getOptionalStringFlag(flags, 'run')
// Why: same read posture as task-list — an explicitly named Run stays inspectable without a bound pane, so identity is only resolved for the implicit "my own Run" case.
const from = run ? undefined : await resolveCoordinatorTerminalHandle(flags, cwd, client)
const result = await client.call<{
gates: { id: string; task_id: string; question: string; status: string }[]
count: number
runId?: string
}>('orchestration.gateList', {
task: getOptionalStringFlag(flags, 'task'),
status: getOptionalStringFlag(flags, 'status')
status: getOptionalStringFlag(flags, 'status'),
run,
from
})
printResult(result, json, (r) => {
if (r.gates.length === 0) {
+3 -2
View File
@@ -12,7 +12,8 @@ import {
} from '../../shared/tui-agent-detection-commands'
import {
getSpawnArgsForWindows,
UnsafeWindowsBatchArgumentsError
UnsafeWindowsBatchArgumentsError,
WINDOWS_BATCH_UNSAFE_CHARACTERS_LABEL
} from '../../shared/windows-batch-spawn'
import { isSkillsCliAgentKeyShaped, toSkillsCliAgentKeys } from '../../shared/skills-cli-agent-keys'
import {
@@ -134,7 +135,7 @@ function runNpxSkills(args: string[]): Promise<number> {
new RuntimeClientError(
'invalid_environment',
`Cannot run npx from "${resolved}": the path contains characters cmd.exe would ` +
'reinterpret. Install Node.js somewhere without & | < > ^ " % ! in the path.'
`reinterpret. Install Node.js somewhere without ${WINDOWS_BATCH_UNSAFE_CHARACTERS_LABEL} in the path.`
)
)
return
+21
View File
@@ -2,6 +2,7 @@ import { EventEmitter } from 'node:events'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { delimiter } from 'node:path'
import type * as CodexCliCommandModule from '../shared/node-cli-command-resolution'
import { WINDOWS_BATCH_UNSAFE_CHARACTERS_LABEL } from '../shared/windows-batch-spawn'
const {
detectCommandsMock,
@@ -689,6 +690,26 @@ describe('orca skills CLI', () => {
expect(spawnMock).not.toHaveBeenCalled()
expect(process.exitCode).toBe(1)
expect(String(errorSpy.mock.calls[0]?.[0])).toContain('cmd.exe would reinterpret')
// Why: remediation advice that names the wrong characters is unactionable.
expect(String(errorSpy.mock.calls[0]?.[0])).toContain(WINDOWS_BATCH_UNSAFE_CHARACTERS_LABEL)
})
it('runs npx from a Program Files (x86) install instead of refusing it', async () => {
const child = createFakeChild()
spawnMock.mockReturnValue(child)
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
vi.stubEnv('ComSpec', 'C:\\Windows\\System32\\cmd.exe')
const npx = 'C:\\Program Files (x86)\\nodejs\\npx.cmd'
resolveCliCommandMock.mockReturnValue(npx)
vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
const resultPromise = main(['skills', 'install', '--skill', 'alpha'], '/tmp/repo')
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled())
child.emit('exit', 0, null)
await resultPromise
expect(spawnMock.mock.calls[0]?.[0]).toBe('C:\\Windows\\System32\\cmd.exe')
expect(spawnMock.mock.calls[0]?.[1]?.slice(0, 3)).toEqual(['/d', '/c', npx])
})
it('never puts the current directory on the child PATH when npx is unresolvable', async () => {
+9 -6
View File
@@ -236,20 +236,23 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [
path: ['orchestration', 'gate-create'],
summary: 'Create a decision gate blocking a task',
usage:
'orca orchestration gate-create --task <task_id> --question <text> [--options <json_array>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'task', 'question', 'options', 'retry-request']
'orca orchestration gate-create --task <task_id> --question <text> [--options <json_array>] [--from <handle>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'task', 'question', 'options', 'from', 'retry-request']
},
{
path: ['orchestration', 'gate-resolve'],
summary: 'Resolve a pending decision gate',
usage: 'orca orchestration gate-resolve --id <gate_id> --resolution <text> [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'id', 'resolution', 'retry-request']
usage:
'orca orchestration gate-resolve --id <gate_id> --resolution <text> [--from <handle>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'id', 'resolution', 'from', 'retry-request']
},
{
path: ['orchestration', 'gate-list'],
summary: 'List decision gates',
usage: 'orca orchestration gate-list [--task <task_id>] [--status <status>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'task', 'status']
usage:
'orca orchestration gate-list [--task <task_id>] [--status <status>] [--run <run_id>] [--from <handle>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'task', 'status', 'run', 'from'],
notes: ['--run inspects a named Run without binding; otherwise gates are scoped to the caller.']
},
{
path: ['orchestration', 'reset'],
@@ -1,11 +1,20 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { validate } from '../telemetry/validator'
import { recordAuthenticatedInventory, type DaemonAuditContext } from './daemon-audit-classifier'
import { DaemonPtyAdapter } from './daemon-pty-adapter'
import { DaemonServer } from './daemon-server'
import { getDaemonSocketPath } from './daemon-spawner'
const { trackMock } = vi.hoisted(() => ({ trackMock: vi.fn() }))
vi.mock('../telemetry/client', () => ({ track: trackMock }))
import { trackDaemonAuditEligibility } from './daemon-audit-eligibility-event'
import {
createDaemonAuditEligibilityTracker,
trackDaemonAuditEligibility
} from './daemon-audit-eligibility-event'
const context: DaemonAuditContext = {
protocolGeneration: 23,
@@ -47,5 +56,137 @@ describe('daemon audit eligibility telemetry', () => {
expect(() =>
trackDaemonAuditEligibility(recordAuthenticatedInventory(context, null))
).not.toThrow()
// The rate-limited tracker is the production call site, so it carries the same guarantee.
const trackEligibility = createDaemonAuditEligibilityTracker()
expect(() => trackEligibility(recordAuthenticatedInventory(context, null))).not.toThrow()
})
it('cannot affect callers when the rate-limit bookkeeping throws', () => {
const trackEligibility = createDaemonAuditEligibilityTracker(() => {
throw new Error('no clock')
})
expect(() => trackEligibility(recordAuthenticatedInventory(context, null))).not.toThrow()
// A malformed observation must not escape the guard either.
const malformed = recordAuthenticatedInventory(context, null)
expect(() =>
trackEligibility({ ...malformed, evidenceSources: undefined as never })
).not.toThrow()
expect(trackMock).not.toHaveBeenCalled()
})
it('collapses repeated identical observations into one heartbeat per window', () => {
let nowMs = 1_700_000_000_000
const trackEligibility = createDaemonAuditEligibilityTracker(() => nowMs)
for (let index = 0; index < 60; index += 1) {
nowMs += 1_000
trackEligibility(recordAuthenticatedInventory(context, null))
}
expect(trackMock).toHaveBeenCalledOnce()
nowMs += 5 * 60_000
trackEligibility(recordAuthenticatedInventory(context, null))
expect(trackMock).toHaveBeenCalledTimes(2)
})
it('keeps heartbeating after the clock jumps backward', () => {
let nowMs = 1_700_000_000_000
const trackEligibility = createDaemonAuditEligibilityTracker(() => nowMs)
trackEligibility(recordAuthenticatedInventory(context, null))
expect(trackMock).toHaveBeenCalledOnce()
// An NTP correction / VM resume rewinds the clock by an hour.
nowMs -= 60 * 60_000
trackEligibility(recordAuthenticatedInventory(context, null))
expect(trackMock).toHaveBeenCalledTimes(2)
// The window re-anchors on the rewound clock instead of emitting on every call.
for (let index = 0; index < 60; index += 1) {
nowMs += 1_000
trackEligibility(recordAuthenticatedInventory(context, null))
}
expect(trackMock).toHaveBeenCalledTimes(2)
nowMs += 5 * 60_000
trackEligibility(recordAuthenticatedInventory(context, null))
expect(trackMock).toHaveBeenCalledTimes(3)
})
it('measures the window on a monotonic clock rather than wall time', () => {
const wallClock = vi.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000)
const trackEligibility = createDaemonAuditEligibilityTracker()
try {
trackEligibility(recordAuthenticatedInventory(context, null))
expect(trackMock).toHaveBeenCalledOnce()
// Wall time alone must not open the window: no real time has elapsed.
wallClock.mockReturnValue(1_700_000_000_000 + 6 * 60_000)
trackEligibility(recordAuthenticatedInventory(context, null))
expect(trackMock).toHaveBeenCalledOnce()
} finally {
wallClock.mockRestore()
}
})
it('emits immediately when the observation changes', () => {
let nowMs = 1_700_000_000_000
const trackEligibility = createDaemonAuditEligibilityTracker(() => nowMs)
trackEligibility(recordAuthenticatedInventory(context, null))
nowMs += 1_000
trackEligibility(
recordAuthenticatedInventory(context, {
identity: { pid: 42, startedAtMs: nowMs, launchNonce: 'launch-a' }
})
)
expect(trackMock).toHaveBeenCalledTimes(2)
expect(trackMock.mock.calls[1][1]).toMatchObject({ exact_incarnation: 'endpoint-identity' })
})
})
describe('daemon audit eligibility inventory volume', () => {
let dir: string
let socketPath: string
let tokenPath: string
let server: DaemonServer
let adapter: DaemonPtyAdapter
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'daemon-audit-eligibility-'))
socketPath = getDaemonSocketPath(dir)
tokenPath = join(dir, 'daemon.token')
})
afterEach(async () => {
adapter?.dispose()
await server?.shutdown()
rmSync(dir, { recursive: true, force: true })
})
it('does not emit one eligibility event per successful inventory', async () => {
server = new DaemonServer({
socketPath,
tokenPath,
spawnSubprocess: () => {
throw new Error('Test must not create a PTY')
}
})
await server.start()
adapter = new DaemonPtyAdapter({ socketPath, tokenPath })
for (let index = 0; index < 40; index += 1) {
await adapter.listProcesses()
}
expect(
trackMock.mock.calls.filter(([name]) => name === 'daemon_audit_eligibility')
).toHaveLength(1)
})
})
@@ -1,29 +1,71 @@
import { track } from '../telemetry/client'
import type { EventProps } from '../../shared/telemetry-events'
import type { DaemonAuditObservation } from './daemon-audit-classifier'
// Why: a steady daemon repeats a byte-identical observation on every listProcesses call, so
// repeats are re-sent only as an occasional heartbeat — the shared per-session telemetry
// ceiling is 1,000 events for the whole app and audit data must not crowd it out.
const REPEATED_OBSERVATION_INTERVAL_MS = 5 * 60_000
export function trackDaemonAuditEligibility(observation: DaemonAuditObservation): void {
try {
track('daemon_audit_eligibility', {
state: observation.state,
reason: observation.reason,
trigger: observation.trigger,
evidence_sources: [...observation.evidenceSources],
protocol_generation: observation.context.protocolGeneration,
provider: observation.context.provider,
endpoint_kind: observation.context.endpointKind,
profile_scope: observation.context.profileScope ? 'configured' : 'unspecified',
exact_incarnation: exactIncarnationKind(observation),
reachability: observation.reachability,
inventory_authority: observation.inventoryAuthority,
process_liveness: observation.processLiveness,
process_reason: observation.processReason,
endpoint_state: observation.endpointState
})
track('daemon_audit_eligibility', auditEligibilityProperties(observation))
} catch {
// Audit telemetry cannot affect daemon availability.
}
}
export function createDaemonAuditEligibilityTracker(
// Why: the window must be measured on a monotonic clock — a backward wall-clock jump (NTP
// correction, VM resume) would otherwise park the next heartbeat in the future.
monotonicNowMs: () => number = () => performance.now()
): (observation: DaemonAuditObservation) => void {
let lastProperties: string | null = null
let lastTrackedAtMs = 0
return (observation) => {
// Why: the rate-limit bookkeeping runs inside the daemon's inventory path, so it is guarded
// together with the emit — audit telemetry cannot affect daemon availability.
try {
const properties = JSON.stringify(auditEligibilityProperties(observation))
const observedAtMs = monotonicNowMs()
const elapsedMs = observedAtMs - lastTrackedAtMs
if (
properties === lastProperties &&
elapsedMs >= 0 &&
elapsedMs < REPEATED_OBSERVATION_INTERVAL_MS
) {
return
}
lastProperties = properties
lastTrackedAtMs = observedAtMs
trackDaemonAuditEligibility(observation)
} catch {
// Audit telemetry cannot affect daemon availability.
}
}
}
function auditEligibilityProperties(
observation: DaemonAuditObservation
): EventProps<'daemon_audit_eligibility'> {
return {
state: observation.state,
reason: observation.reason,
trigger: observation.trigger,
evidence_sources: [...observation.evidenceSources],
protocol_generation: observation.context.protocolGeneration,
provider: observation.context.provider,
endpoint_kind: observation.context.endpointKind,
profile_scope: observation.context.profileScope ? 'configured' : 'unspecified',
exact_incarnation: exactIncarnationKind(observation),
reachability: observation.reachability,
inventory_authority: observation.inventoryAuthority,
process_liveness: observation.processLiveness,
process_reason: observation.processReason,
endpoint_state: observation.endpointState
}
}
function exactIncarnationKind(
observation: DaemonAuditObservation
): 'endpoint-identity' | 'endpoint-identity-linux-ticks' | 'unavailable' {
@@ -0,0 +1,94 @@
import type * as ChildProcessModule from 'node:child_process'
import type * as FsPromisesModule from 'node:fs/promises'
import { describe, expect, it, vi } from 'vitest'
import type { ExactDaemonIncarnation } from './daemon-incarnation-evidence-types'
const LINUX_BOOT_TIME_SECONDS = 1_699_000_000
const LINUX_START_TICKS = 1_234
const LINUX_CLOCK_TICKS_PER_SECOND = 100
const { execFileMock, execFileSyncMock, readFileMock } = vi.hoisted(() => ({
execFileMock: vi.fn(
(
file: string,
_args: readonly string[],
_options: unknown,
callback: (error: Error | null, result: { stdout: string; stderr: string }) => void
) => {
callback(null, {
stdout: file === 'getconf' ? '100\n' : `${new Date(1_700_000_000_000).toString()}\n`,
stderr: ''
})
}
),
execFileSyncMock: vi.fn(() => ''),
readFileMock: vi.fn(async (path: string) =>
path === '/proc/stat' ? 'btime 1699000000\n' : `42 (orca-daemon) S${' 0'.repeat(18)} 1234 0 0\n`
)
}))
vi.mock('node:child_process', async (importOriginal) => ({
...(await importOriginal<typeof ChildProcessModule>()),
execFile: execFileMock,
execFileSync: execFileSyncMock
}))
vi.mock('node:fs/promises', async (importOriginal) => ({
...(await importOriginal<typeof FsPromisesModule>()),
readFile: readFileMock
}))
const { probeDaemonProcessIdentity } = await import('./daemon-incarnation-evidence')
const endpoint = { socketPath: '/runtime/daemon.sock', tokenPath: '/runtime/daemon.token' }
const exactIncarnation: ExactDaemonIncarnation = {
identity: { pid: 42, startedAtMs: 1_700_000_000_000, launchNonce: 'launch-a' }
}
const daemonCommandLine = `node daemon-entry --socket ${endpoint.socketPath} --token ${endpoint.tokenPath}`
describe('daemon audit evidence main-thread cost', () => {
it('reads the macOS process start time without a synchronous main-thread spawn', async () => {
await expect(
probeDaemonProcessIdentity(exactIncarnation, endpoint, {
platform: 'darwin',
signalProcess: () => 'occupied',
readCommandLine: async () => daemonCommandLine
})
).resolves.toMatchObject({ state: 'present', reason: 'macos_identity_match' })
expect(execFileSyncMock).not.toHaveBeenCalled()
expect(execFileMock).toHaveBeenCalledWith(
'ps',
['-p', '42', '-o', 'lstart='],
expect.anything(),
expect.any(Function)
)
})
// Legacy bare-integer pid files carry no start ticks, so linux falls back to the start time.
it('reads the linux process start time without a synchronous main-thread spawn', async () => {
const startedAtMs =
LINUX_BOOT_TIME_SECONDS * 1000 + (LINUX_START_TICKS / LINUX_CLOCK_TICKS_PER_SECOND) * 1000
await expect(
probeDaemonProcessIdentity(
{ identity: { pid: 42, startedAtMs, launchNonce: 'launch-a' } },
endpoint,
{
platform: 'linux',
signalProcess: () => 'occupied',
readLinuxStat: async () => ({ status: 'present', value: '42 (orca-daemon) S 1' }),
readCommandLine: async () => daemonCommandLine
}
)
).resolves.toMatchObject({ state: 'present', reason: 'linux_identity_match' })
expect(execFileSyncMock).not.toHaveBeenCalled()
expect(execFileMock).toHaveBeenCalledWith(
'getconf',
['CLK_TCK'],
expect.anything(),
expect.any(Function)
)
})
})
@@ -70,6 +70,6 @@ export type DaemonProcessProbeDependencies = {
readLinuxStat?: (pid: number) => Promise<LinuxStatEvidence>
readBootIdentity?: () => Promise<string | undefined>
readCommandLine?: (pid: number, platform: NodeJS.Platform) => Promise<string | undefined>
readProcessStartedAtMs?: (pid: number) => number | null
readProcessStartedAtMs?: (pid: number) => Promise<number | null>
queryWindowsProcess?: (pid: number) => Promise<WindowsProcessEvidence>
}
@@ -61,7 +61,7 @@ describe('daemon process identity evidence', () => {
})
it('proves Linux pid reuse from native start ticks without derived milliseconds', async () => {
const readProcessStartedAtMs = vi.fn(() => exactIncarnation.identity.startedAtMs)
const readProcessStartedAtMs = vi.fn(async () => exactIncarnation.identity.startedAtMs)
await expect(
probeDaemonProcessIdentity(
@@ -247,7 +247,7 @@ describe('daemon process identity evidence', () => {
signalProcess: () => 'occupied',
readCommandLine: async () =>
`node daemon-entry --socket ${endpoint.socketPath} --token ${endpoint.tokenPath}`,
readProcessStartedAtMs: () => exactIncarnation.identity.startedAtMs + 2_500
readProcessStartedAtMs: async () => exactIncarnation.identity.startedAtMs + 2_500
})
).resolves.toMatchObject({ state: 'unknown', reason: 'macos_start_time_mismatch' })
})
@@ -260,6 +260,21 @@ describe('daemon process identity evidence', () => {
})
).resolves.toMatchObject({ state: 'gone', reason: 'pid_missing' })
})
it('keeps unsupported platforms indeterminate without running a Darwin probe', async () => {
const signalProcess = vi.fn(() => 'occupied' as const)
const readCommandLine = vi.fn(async () => 'node daemon-entry')
await expect(
probeDaemonProcessIdentity(exactIncarnation, endpoint, {
platform: 'freebsd',
signalProcess,
readCommandLine
})
).resolves.toMatchObject({ state: 'unknown', reason: 'inspection_failed' })
expect(signalProcess).not.toHaveBeenCalled()
expect(readCommandLine).not.toHaveBeenCalled()
})
})
describe('daemon audit availability evidence', () => {
@@ -1,9 +1,5 @@
import { parseLinuxStartTicks, readBootIdentity } from '../agent-hooks/managed-hook-owner-identity'
import {
commandLineMatchesDaemon,
getProcessStartedAtMs,
startTimesWithinTolerance
} from './daemon-health'
import { commandLineMatchesDaemon, startTimesWithinTolerance } from './daemon-health'
import {
WINDOWS_CREATION_TIME_TOLERANCE_MS,
type DaemonEvidenceSources,
@@ -15,7 +11,9 @@ import {
import {
inspectProcessSignal,
queryWindowsProcess,
readLinuxProcessStartedAtMs,
readLinuxStat,
readMacosProcessStartedAtMs,
readProcessCommandLine
} from './daemon-process-inspection'
@@ -43,6 +41,9 @@ export async function probeDaemonProcessIdentity(
return unknown('exact_identity_unavailable', ['pid_record'])
}
const platform = dependencies.platform ?? process.platform
if (platform !== 'linux' && platform !== 'darwin' && platform !== 'win32') {
return unknown('inspection_failed', ['process_signal'])
}
const signalProcess = dependencies.signalProcess ?? inspectProcessSignal
const signal = signalProcess(exactIncarnation.identity.pid)
if (platform !== 'win32' && signal === 'missing') {
@@ -134,7 +135,7 @@ async function probeLinuxProcess(
])
}
const startedAtMs = (dependencies.readProcessStartedAtMs ?? getProcessStartedAtMs)(
const startedAtMs = await (dependencies.readProcessStartedAtMs ?? readLinuxProcessStartedAtMs)(
exactIncarnation.identity.pid
)
if (startedAtMs === null) {
@@ -172,7 +173,7 @@ async function probeMacosProcess(
if (!commandLineMatchesDaemon(commandLine, endpoint.socketPath, endpoint.tokenPath)) {
return unknown('command_line_mismatch', ['process_command_line'])
}
const startedAtMs = (dependencies.readProcessStartedAtMs ?? getProcessStartedAtMs)(
const startedAtMs = await (dependencies.readProcessStartedAtMs ?? readMacosProcessStartedAtMs)(
exactIncarnation.identity.pid
)
if (startedAtMs === null) {
@@ -1,5 +1,14 @@
import { describe, expect, it, vi } from 'vitest'
import { queryWindowsProcess, readProcessCommandLine } from './daemon-process-inspection'
import {
queryWindowsProcess,
readLinuxProcessStartedAtMs,
readMacosProcessStartedAtMs,
readProcessCommandLine
} from './daemon-process-inspection'
// btime 1699000000 with 1000 start ticks: 10s after boot at 100Hz, 1s at 1000Hz.
const readProcStat = async (path: string): Promise<string> =>
path === '/proc/stat' ? 'btime 1699000000\n' : `42 (orca-daemon) S${' 0'.repeat(18)} 1000 0 0\n`
describe('daemon process inspection', () => {
it('falls back to ps when Linux procfs returns an empty command line', async () => {
@@ -23,6 +32,88 @@ describe('daemon process inspection', () => {
expect(runCommand).not.toHaveBeenCalled()
})
it('asks PowerShell to report a failed CIM query instead of an absent process', async () => {
const runCommand = vi.fn(
async (_file: string, _args: string[], _timeoutMs: number) =>
'{"status":"present","cmd":"daemon","start":1}'
)
await queryWindowsProcess(42, { runCommand })
const script = runCommand.mock.calls[0]?.[1].at(-1) ?? ''
expect(script).toContain("$ErrorActionPreference = 'Stop'")
expect(script).toMatch(/catch \{[^}]*query_failed/)
})
it('keeps a failed CIM query indeterminate instead of proving the process gone', async () => {
const runCommand = vi.fn(async () => '{"status":"query_failed"}')
await expect(queryWindowsProcess(42, { runCommand })).resolves.toEqual({
status: 'unavailable'
})
})
it('never reads a probe result without a success marker as proof of absence', async () => {
const runCommand = vi.fn(async () => '{"exists":false}')
await expect(queryWindowsProcess(42, { runCommand })).resolves.toEqual({
status: 'unavailable'
})
})
it('reports absence only from a CIM query that ran and found nothing', async () => {
const runCommand = vi.fn(async () => '{"status":"missing"}')
await expect(queryWindowsProcess(42, { runCommand })).resolves.toEqual({ status: 'missing' })
})
it('reads the macOS start time through an async spawn', async () => {
const runCommand = vi.fn(async () => 'Sat Jan 1 00:00:00 2028\n')
await expect(readMacosProcessStartedAtMs(42, { runCommand })).resolves.toBe(
Date.parse('Sat Jan 1 00:00:00 2028')
)
expect(runCommand).toHaveBeenCalledWith('ps', ['-p', '42', '-o', 'lstart='], 2_000)
})
// CLK_TCK belongs to the host that runs getconf, so a second runner must not inherit the first's.
it('scopes the CLK_TCK cache to the runner that produced it', async () => {
const hundredHz = vi.fn(async () => '100')
const thousandHz = vi.fn(async () => '1000')
await expect(
readLinuxProcessStartedAtMs(42, { readTextFile: readProcStat, runCommand: hundredHz })
).resolves.toBe(1_699_000_010_000)
await expect(
readLinuxProcessStartedAtMs(42, { readTextFile: readProcStat, runCommand: thousandHz })
).resolves.toBe(1_699_000_001_000)
expect(thousandHz).toHaveBeenCalledWith('getconf', ['CLK_TCK'], 1_000)
// The first runner stays cached: one spawn per runner, not per call.
await expect(
readLinuxProcessStartedAtMs(42, { readTextFile: readProcStat, runCommand: hundredHz })
).resolves.toBe(1_699_000_010_000)
expect(hundredHz).toHaveBeenCalledOnce()
})
it('retries getconf for a runner whose first CLK_TCK read failed', async () => {
let attempt = 0
const runCommand = vi.fn(async () => {
attempt += 1
if (attempt === 1) {
throw new Error('getconf missing')
}
return '100'
})
await expect(
readLinuxProcessStartedAtMs(42, { readTextFile: readProcStat, runCommand })
).resolves.toBeNull()
await expect(
readLinuxProcessStartedAtMs(42, { readTextFile: readProcStat, runCommand })
).resolves.toBe(1_699_000_010_000)
})
it.each([0, -1, 1.5, Number.MAX_SAFE_INTEGER + 1, Number.NaN])(
'rejects unsafe Windows pid %s before command interpolation',
async (pid) => {
+85 -7
View File
@@ -1,6 +1,7 @@
import { execFile } from 'node:child_process'
import { readFile } from 'node:fs/promises'
import { promisify } from 'node:util'
import { parseLinuxBootTimeSeconds, parseLinuxProcStartTicks } from './daemon-health'
import type {
LinuxStatEvidence,
ProcessSignalEvidence,
@@ -9,9 +10,11 @@ import type {
const execFileAsync = promisify(execFile)
type InspectionCommandRunner = (file: string, args: string[], timeoutMs: number) => Promise<string>
export type DaemonProcessInspectionDependencies = {
readTextFile?: (path: string) => Promise<string>
runCommand?: (file: string, args: string[], timeoutMs: number) => Promise<string>
runCommand?: InspectionCommandRunner
}
export function inspectProcessSignal(pid: number): ProcessSignalEvidence {
@@ -63,6 +66,9 @@ export async function readProcessCommandLine(
}
}
// Why: Get-CimInstance errors (Winmgmt down, corrupt WMI repository, access denied) are
// non-terminating and exit 0 with an empty $p, which is indistinguishable from "no such
// process" — so the script reports query failure explicitly instead of asserting absence.
export async function queryWindowsProcess(
pid: number,
dependencies: DaemonProcessInspectionDependencies = {}
@@ -78,23 +84,26 @@ export async function queryWindowsProcess(
'-NoProfile',
'-NonInteractive',
'-Command',
`$p = Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}"; ` +
`if (!$p) { @{ exists = $false } | ConvertTo-Json -Compress } else { ` +
`$ErrorActionPreference = 'Stop'; ` +
`try { $p = Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}" } ` +
`catch { @{ status = 'query_failed' } | ConvertTo-Json -Compress; exit 0 }; ` +
`if (!$p) { @{ status = 'missing' } | ConvertTo-Json -Compress; exit 0 }; ` +
`$start = $null; if ($p.CreationDate) { ` +
`$start = [long]([DateTimeOffset]$p.CreationDate).ToUnixTimeMilliseconds() }; ` +
`@{ exists = $true; cmd = $p.CommandLine; start = $start } | ConvertTo-Json -Compress }`
`@{ status = 'present'; cmd = $p.CommandLine; start = $start } | ConvertTo-Json -Compress`
],
3_000
)
const parsed = JSON.parse(stdout.trim()) as {
exists?: unknown
status?: unknown
cmd?: unknown
start?: unknown
}
if (parsed.exists === false) {
// Only a query that ran and found nothing proves absence; anything else stays indeterminate.
if (parsed.status === 'missing') {
return { status: 'missing' }
}
if (parsed.exists !== true) {
if (parsed.status !== 'present') {
return { status: 'unavailable' }
}
return {
@@ -108,6 +117,75 @@ export async function queryWindowsProcess(
}
}
// Why: the sync procfs helper in daemon-health spawns getconf per call; CLK_TCK is fixed for
// the kernel's lifetime, so cache one async spawn and only retry after a failure. The cache is
// keyed by runner because CLK_TCK belongs to the host that executes the command, not the module.
const clockTicksPerSecondByRunner = new WeakMap<InspectionCommandRunner, Promise<number | null>>()
async function readClockTicksPerSecond(
runCommand: InspectionCommandRunner
): Promise<number | null> {
let pending = clockTicksPerSecondByRunner.get(runCommand)
if (!pending) {
pending = runCommand('getconf', ['CLK_TCK'], 1_000).then(
(stdout) => {
const ticks = Number(stdout.trim())
return Number.isFinite(ticks) && ticks > 0 ? ticks : null
},
() => null
)
clockTicksPerSecondByRunner.set(runCommand, pending)
}
const ticksPerSecond = await pending
if (ticksPerSecond === null && clockTicksPerSecondByRunner.get(runCommand) === pending) {
clockTicksPerSecondByRunner.delete(runCommand)
}
return ticksPerSecond
}
// Why: same main-thread hazard as darwin — the sync linux helper does two readFileSync calls
// plus a getconf spawn, and this audit-only probe runs in the Electron main process.
export async function readLinuxProcessStartedAtMs(
pid: number,
dependencies: DaemonProcessInspectionDependencies = {}
): Promise<number | null> {
const readTextFile =
dependencies.readTextFile ?? (async (path: string) => await readFile(path, 'utf8'))
try {
const startTicks = parseLinuxProcStartTicks(await readTextFile(`/proc/${pid}/stat`))
const bootTimeSeconds = parseLinuxBootTimeSeconds(await readTextFile('/proc/stat'))
const ticksPerSecond = await readClockTicksPerSecond(
dependencies.runCommand ?? runInspectionCommand
)
if (
ticksPerSecond === null ||
!Number.isFinite(startTicks) ||
!Number.isFinite(bootTimeSeconds)
) {
return null
}
return bootTimeSeconds * 1000 + (startTicks / ticksPerSecond) * 1000
} catch {
return null
}
}
// Why: `ps` is darwin's only start-time source and this audit-only probe runs in the
// Electron main process, where the sync spawn blocks every IPC/UI turn for its duration.
export async function readMacosProcessStartedAtMs(
pid: number,
dependencies: DaemonProcessInspectionDependencies = {}
): Promise<number | null> {
const runCommand = dependencies.runCommand ?? runInspectionCommand
try {
const stdout = await runCommand('ps', ['-p', String(pid), '-o', 'lstart='], 2_000)
const startedAtMs = Date.parse(stdout.trim())
return Number.isFinite(startedAtMs) ? startedAtMs : null
} catch {
return null
}
}
async function runInspectionCommand(
file: string,
args: string[],
+4 -2
View File
@@ -77,7 +77,7 @@ import {
type DaemonAuditTrigger
} from './daemon-audit-classifier'
import type { DaemonEvidenceSource, ExactDaemonIncarnation } from './daemon-incarnation-evidence'
import { trackDaemonAuditEligibility } from './daemon-audit-eligibility-event'
import { createDaemonAuditEligibilityTracker } from './daemon-audit-eligibility-event'
type PendingDaemonSpawnOperation = {
exitsBySessionId: Map<string, { incarnationId?: string }[]>
@@ -159,6 +159,8 @@ export class DaemonPtyAdapter implements IPtyProvider {
private lastAuthenticatedIdentity: DaemonEndpointIdentity | null = null
private exactDaemonIncarnation: ExactDaemonIncarnation | null = null
private lastAuditObservation: DaemonAuditObservation | null = null
// Why: every listProcesses call republishes the same observation; unthrottled it drains the shared per-session telemetry ceiling.
private readonly trackAuditEligibility = createDaemonAuditEligibilityTracker()
private auditObservationListeners: ((observation: DaemonAuditObservation) => void)[] = []
private identityChangeListeners: ((event: DaemonIdentityChangeEvent) => void)[] = []
private historyManager: HistoryManager | null
@@ -1723,7 +1725,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
private publishAuditObservation(observation: DaemonAuditObservation): void {
this.lastAuditObservation = observation
trackDaemonAuditEligibility(observation)
this.trackAuditEligibility(observation)
notifyAuditListeners(this.auditObservationListeners, observation)
}
@@ -0,0 +1,155 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { ghExecFileAsyncMock, getOwnerRepoMock, rateLimitGuardMock } = vi.hoisted(() => ({
ghExecFileAsyncMock: vi.fn(),
getOwnerRepoMock: vi.fn(),
rateLimitGuardMock: vi.fn(() => ({ blocked: false }))
}))
vi.mock('./gh-utils', () => ({
execFileAsync: vi.fn(),
ghExecFileAsync: ghExecFileAsyncMock,
githubRepoContext: (repoPath: string, connectionId?: string | null) => ({
repoPath,
connectionId: connectionId ?? null
}),
ghRepoExecOptions: (context: { repoPath: string }) => ({ cwd: context.repoPath }),
getOwnerRepo: getOwnerRepoMock,
getIssueOwnerRepo: vi.fn(),
getOwnerRepoForRemote: (
repoPath: string,
remoteName: string,
connectionId?: string | null,
localGitOptions?: unknown
) =>
remoteName === 'origin'
? getOwnerRepoMock(repoPath, connectionId, localGitOptions)
: Promise.resolve(null),
resolveIssueSource: vi.fn(),
extractExecError: vi.fn((err: unknown) => ({ stderr: String(err), stdout: '' })),
acquire: vi.fn(),
release: vi.fn(),
_resetOwnerRepoCache: vi.fn(),
classifyGhError: (stderr: string) => ({ type: 'unknown', message: stderr }),
classifyListIssuesError: (stderr: string) => ({ type: 'unknown', message: stderr })
}))
vi.mock('../git/runner', () => ({
gitExecFileAsync: vi.fn()
}))
vi.mock('./rate-limit', () => ({
rateLimitGuard: rateLimitGuardMock,
noteRateLimitSpend: vi.fn(),
getRateLimit: vi.fn(async () => ({ ok: false, error: 'not probed in tests' })),
repositoryRateLimitGuard: vi.fn(() => ({ blocked: false })),
noteRepositoryRateLimitSpend: vi.fn(),
spendsSharedGitHubComQuota: vi.fn(() => true)
}))
import { getWorkItem, _resetMergeQueueCacheForTests, _resetOwnerRepoCache } from './client'
import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository'
// Why: only `pr view` is fixtured; the merge-metadata fan-out is best-effort and must not mask the summary.
function mockPullRequestDetail(payload: Record<string, unknown>): void {
ghExecFileAsyncMock.mockImplementation(async (args: string[]) => {
if (args[0] !== 'pr' || args[1] !== 'view') {
throw new Error('unexpected gh call')
}
return { stdout: JSON.stringify(payload) }
})
}
describe('work item checksSummary', () => {
beforeEach(() => {
ghExecFileAsyncMock.mockReset()
getOwnerRepoMock.mockReset()
rateLimitGuardMock.mockReset()
rateLimitGuardMock.mockReturnValue({ blocked: false })
_resetOwnerRepoCache()
_resetOriginGitHubApiRepositoryCache()
_resetMergeQueueCacheForTests()
getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
})
it('counts a skipped run as passing and reads a StatusContext state as its conclusion', async () => {
mockPullRequestDetail({
number: 42,
title: 'Add feature',
state: 'OPEN',
url: 'https://github.com/acme/widgets/pull/42',
updatedAt: '2026-04-01T00:00:00Z',
statusCheckRollup: [
{ __typename: 'CheckRun', status: 'COMPLETED', conclusion: 'SKIPPED' },
// Why: StatusContext carries no status/conclusion — only `state`.
{ __typename: 'StatusContext', state: 'SUCCESS' },
{ __typename: 'CheckRun', status: 'COMPLETED', conclusion: 'NEUTRAL' }
]
})
const item = await getWorkItem('/repo-root', 42, 'pr', null, {}, 'origin')
expect(item?.checksSummary).toEqual({
state: 'success',
total: 3,
passed: 2,
failed: 0,
pending: 0,
neutral: 1
})
})
it('reports a still-running check as pending', async () => {
mockPullRequestDetail({
number: 43,
title: 'Add feature',
state: 'OPEN',
url: 'https://github.com/acme/widgets/pull/43',
updatedAt: '2026-04-01T00:00:00Z',
statusCheckRollup: [
{ __typename: 'CheckRun', status: 'COMPLETED', conclusion: 'SUCCESS' },
{ __typename: 'CheckRun', status: 'IN_PROGRESS', conclusion: null }
]
})
const item = await getWorkItem('/repo-root', 43, 'pr', null, {}, 'origin')
expect(item?.checksSummary).toEqual({
state: 'pending',
total: 2,
passed: 1,
failed: 0,
pending: 1,
neutral: 0
})
})
it('fails the summary on a failing run and leaves an empty rollup with no state', async () => {
mockPullRequestDetail({
number: 44,
title: 'Add feature',
state: 'OPEN',
url: 'https://github.com/acme/widgets/pull/44',
updatedAt: '2026-04-01T00:00:00Z',
statusCheckRollup: [
{ __typename: 'CheckRun', status: 'COMPLETED', conclusion: 'SUCCESS' },
{ __typename: 'CheckRun', status: 'COMPLETED', conclusion: 'FAILURE' }
]
})
await expect(getWorkItem('/repo-root', 44, 'pr', null, {}, 'origin')).resolves.toMatchObject({
checksSummary: { state: 'failure', total: 2, passed: 1, failed: 1, pending: 0, neutral: 0 }
})
mockPullRequestDetail({
number: 45,
title: 'Add feature',
state: 'OPEN',
url: 'https://github.com/acme/widgets/pull/45',
updatedAt: '2026-04-01T00:00:00Z',
statusCheckRollup: []
})
await expect(getWorkItem('/repo-root', 45, 'pr', null, {}, 'origin')).resolves.toMatchObject({
checksSummary: { state: 'none', total: 0, passed: 0, failed: 0, pending: 0, neutral: 0 }
})
})
})
+14 -38
View File
@@ -27,6 +27,7 @@ import {
normalizeHostedReviewHeadRef
} from '../../shared/hosted-review-refs'
import { normalizeGitHubPRMergeMethodSettings } from '../../shared/github-pr-merge-methods'
import { summarizeProviderChecks } from '../../shared/provider-check-summary'
import { isGitHubWorkItemsQueryTooLarge } from '../../shared/github-work-items-query-bounds'
import { classifyGitHubUnavailable } from '../../shared/github-api-availability'
import { parseTaskQuery, type ParsedTaskQuery } from '../../shared/task-query'
@@ -656,44 +657,19 @@ function checkRollupEntries(value: unknown): unknown[] {
}
function deriveWorkItemCheckSummary(value: unknown): GitHubWorkItem['checksSummary'] {
const entries = checkRollupEntries(value)
if (entries.length === 0) {
return { state: 'none', total: 0, passed: 0, failed: 0, pending: 0, neutral: 0 }
}
let passed = 0
let failed = 0
let pending = 0
let neutral = 0
for (const entry of entries) {
if (typeof entry !== 'object' || entry === null) {
pending += 1
continue
}
const raw = entry as Record<string, unknown>
const conclusion = String(raw.conclusion ?? raw.state ?? '').toUpperCase()
const status = String(raw.status ?? '').toUpperCase()
if (['SUCCESS', 'SKIPPED'].includes(conclusion)) {
passed += 1
} else if (
['FAILURE', 'ERROR', 'TIMED_OUT', 'CANCELLED', 'ACTION_REQUIRED', 'STARTUP_FAILURE'].includes(
conclusion
)
) {
failed += 1
} else if (status === 'COMPLETED') {
neutral += 1
} else {
pending += 1
}
}
return {
state: failed > 0 ? 'failure' : pending > 0 ? 'pending' : neutral > 0 ? 'neutral' : 'success',
total: entries.length,
passed,
failed,
pending,
neutral
}
return summarizeProviderChecks(
checkRollupEntries(value).map((entry) => {
if (typeof entry !== 'object' || entry === null) {
return { status: '', conclusion: '' }
}
const raw = entry as Record<string, unknown>
// Why: StatusContext reports `state` and carries no `status`; CheckRun reports both.
return {
status: String(raw.status ?? ''),
conclusion: String(raw.conclusion ?? raw.state ?? '')
}
})
)
}
function mapPullRequestWorkItem(
+41 -5
View File
@@ -38,8 +38,8 @@ describe('mapPipelineJobStatusToConclusion', () => {
expect(mapPipelineJobStatusToConclusion('skipped')).toBe('skipped')
})
it('maps manual and action-required jobs to an actionable conclusion', () => {
expect(mapPipelineJobStatusToConclusion('manual')).toBe('action_required')
it('keeps manual gates neutral while action-required stays actionable', () => {
expect(mapPipelineJobStatusToConclusion('manual')).toBe('neutral')
expect(mapPipelineJobStatusToConclusion('action_required')).toBe('action_required')
})
@@ -228,7 +228,23 @@ describe('derivePipelineStatus', () => {
expect(derivePipelineStatus('success')).toBe('success')
expect(derivePipelineStatus('failed')).toBe('failure')
expect(derivePipelineStatus('running')).toBe('pending')
expect(derivePipelineStatus('manual')).toBe('failure')
// Why: pipeline-level `manual` means GitLab blocked the pipeline on a human trigger — it is
// outstanding, not broken (red) and not resolved (which would paint the MR card green while
// "Pipelines must succeed" still refuses the merge).
expect(derivePipelineStatus('manual')).toBe('pending')
expect(derivePipelineStatus({ status: 'manual' })).toBe('pending')
})
// Why: pins the two remaining string/array divergences. The job rollup calls skipped jobs
// passing and canceled jobs failing, but `head_pipeline.status` is the only production entry
// point and GitLab paints both pipeline states grey — flipping either card tone needs product
// sign-off, so these assertions exist so neither flip can land silently.
it('keeps skipped and canceled pipeline strings neutral (deferred tone changes)', () => {
expect(derivePipelineStatus('skipped')).toBe('neutral')
expect(derivePipelineStatus({ status: 'skipped' })).toBe('neutral')
expect(derivePipelineStatus('canceled')).toBe('neutral')
expect(derivePipelineStatus('canceling')).toBe('neutral')
expect(derivePipelineStatus([{ status: 'canceled' }])).toBe('failure')
})
it('rolls up an array of jobs', () => {
@@ -239,7 +255,22 @@ describe('derivePipelineStatus', () => {
it('failure beats pending in the rollup', () => {
expect(derivePipelineStatus([{ status: 'failed' }, { status: 'running' }])).toBe('failure')
expect(derivePipelineStatus([{ status: 'manual' }, { status: 'success' }])).toBe('failure')
expect(derivePipelineStatus([{ status: 'action_required' }, { status: 'success' }])).toBe(
'failure'
)
})
it('keeps a manual deploy gate from failing an otherwise green pipeline', () => {
expect(derivePipelineStatus([{ status: 'manual' }, { status: 'success' }])).toBe('success')
})
it('leaves a manual-only pipeline unresolved rather than green', () => {
expect(derivePipelineStatus([{ status: 'manual' }])).toBe('neutral')
expect(derivePipelineStatus([{ status: 'manual' }, { status: 'manual' }])).toBe('neutral')
})
it('counts skipped jobs as passing', () => {
expect(derivePipelineStatus([{ status: 'skipped' }, { status: 'skipped' }])).toBe('success')
})
it('handles a single object with status', () => {
@@ -249,8 +280,13 @@ describe('derivePipelineStatus', () => {
it('keeps malformed and unknown array jobs neutral', () => {
expect(derivePipelineStatus([{ status: 'future_status' }])).toBe('neutral')
expect(derivePipelineStatus([{}])).toBe('neutral')
})
// Why: matches the shared rollup rule — one unresolved job must not demote a pipeline that has
// a passing job, or the same MR reads green in the Checks tab and grey on the card.
it('lets a passing job outweigh an unknown one', () => {
expect(derivePipelineStatus([{ status: 'success' }, { status: 'future_status' }])).toBe(
'neutral'
'success'
)
})
})
+28 -29
View File
@@ -9,6 +9,7 @@ import {
mapGitLabPipelineJobStatusToCheckStatus,
mapGitLabPipelineJobStatusToConclusion
} from '../../shared/gitlab-pipeline-checks'
import { summarizeProviderChecks } from '../../shared/provider-check-summary'
// ── Pipeline job mapping (GitLab REST `/pipelines/:id/jobs`) ────────
// Why: GitLab pipeline jobs roughly map to GitHub check-runs, but use a
@@ -156,34 +157,20 @@ export function derivePipelineStatus(
if (!Array.isArray(rollup)) {
return classifyPipelineString(rollup.status ?? '')
}
if (rollup.length === 0) {
return 'neutral'
}
let hasFailure = false
let hasPending = false
let hasUnknown = false
for (const job of rollup) {
const s = job.status?.toLowerCase() ?? ''
const conclusion = mapPipelineJobStatusToConclusion(s)
if (
conclusion === 'failure' ||
conclusion === 'cancelled' ||
conclusion === 'action_required'
) {
hasFailure = true
} else if (conclusion === 'pending') {
hasPending = true
} else if (conclusion !== 'success' && conclusion !== 'skipped' && conclusion !== 'neutral') {
hasUnknown = true
}
}
if (hasFailure) {
return 'failure'
}
if (hasPending) {
return 'pending'
}
return hasUnknown ? 'neutral' : 'success'
// Why: a job array is just a check list, so route it through the shared classifier instead of
// re-deriving the rules here — a local copy drifted (manual-only read green, an unrecognized
// status demoted a passing pipeline to neutral).
const { state } = summarizeProviderChecks(
rollup.map((job) => {
const s = job.status?.toLowerCase() ?? ''
return {
status: mapPipelineJobStatusToCheckStatus(s),
conclusion: mapPipelineJobStatusToConclusion(s)
}
})
)
// Why: CheckStatus has no 'none'; an empty job list carries the same "nothing to report" meaning.
return state === 'none' ? 'neutral' : state
}
// ── Raw → GitLabWorkItem mapping ────────────────────────────────────
@@ -289,9 +276,21 @@ function classifyPipelineString(status: string): CheckStatus {
if (s === 'success') {
return 'success'
}
if (s === 'failed' || s === 'manual' || s === 'action_required') {
if (s === 'failed' || s === 'action_required') {
return 'failure'
}
// Why: GitLab only reports pipeline-level `manual` when the pipeline is *blocked* on a human
// trigger (a manual job with allow_failure: false), so it is outstanding rather than broken or
// done. Red overstated it; neutral would drop the cue entirely and paint the worktree card's MR
// icon green (worktree-review-helpers.tsx defaults `open` to emerald), which is worse — with
// "Pipelines must succeed" on, GitLab rejects this merge.
if (s === 'manual') {
return 'pending'
}
// Why: `skipped` and `canceled` pipelines stay neutral (the fall-through below) even though the
// job rollup calls the same jobs passing/failing. GitLab's own MR widget paints both grey, and
// `allow_merge_on_skipped_pipeline` defaults to false, so a skipped pipeline still blocks merge —
// flipping either tone is a product decision that belongs in its own change, not this one.
if (
s === 'created' ||
s === 'pending' ||
+22
View File
@@ -87,6 +87,7 @@ import {
registerAppMenu,
rebuildAppMenu
} from './menu/register-app-menu'
import { createGpuAccelerationAboutPanelOptions } from './menu/gpu-acceleration-about-panel'
import {
checkForRemoteServerUpdate,
checkForUpdatesFromMenu,
@@ -359,10 +360,30 @@ const gpuCrashFallbackTracker = new GpuCrashFallbackTracker({
threshold: DEFAULT_GPU_CRASH_FALLBACK_THRESHOLD
})
let gpuFallbackActiveThisLaunch = false
let gpuFeatureStatus: Electron.GPUFeatureStatus | null = null
let localPtyStartupReady: Promise<void> = Promise.resolve()
let localPtyProviderStartupReady: Promise<void> = Promise.resolve()
const AGENT_STATE_CRASH_BREADCRUMB_MIN_INTERVAL_MS = 30_000
const isServeMode = process.argv.includes('--serve')
function updateGpuAccelerationAboutPanel(): void {
app.setAboutPanelOptions(
createGpuAccelerationAboutPanelOptions({
appName: app.name,
appVersion: app.getVersion(),
platform: process.platform,
gpuFallbackActive: gpuFallbackActiveThisLaunch,
gpuFeatureStatus
})
)
}
app.on('gpu-info-update', () => {
gpuFeatureStatus = app.getGPUFeatureStatus()
if (app.isReady()) {
updateGpuAccelerationAboutPanel()
}
})
if (isServeMode) {
reserveServeStdoutForReadiness()
}
@@ -1968,6 +1989,7 @@ void app.whenReady().then(async () => {
electronApp.setAppUserModelId(devInstanceIdentity.appUserModelId)
// Why: setName drives the macOS safeStorage Keychain item name; use the stable appName (not per-branch `name`) so dev branches share one key and don't re-prompt.
app.setName(devInstanceIdentity.appName)
updateGpuAccelerationAboutPanel()
// Why: managed WSL launchers live outside the Windows app bundle, so keep their launcher/bridge contract synced across app updates.
managedWslCliReconciliationStatus = 'pending'
@@ -6,6 +6,7 @@ const {
askForMediaAccessMock,
getMediaAccessStatusMock,
isTrustedAccessibilityClientMock,
getMacosFullDiskAccessStatusMock,
execFileMock,
createSocketMock,
socketMock,
@@ -28,6 +29,7 @@ const {
askForMediaAccessMock: vi.fn(),
getMediaAccessStatusMock: vi.fn(),
isTrustedAccessibilityClientMock: vi.fn(),
getMacosFullDiskAccessStatusMock: vi.fn(),
execFileMock: vi.fn(),
createSocketMock: vi.fn(() => socketMock),
socketMock,
@@ -59,6 +61,10 @@ vi.mock('node:child_process', () => ({
execFile: execFileMock
}))
vi.mock('../macos-full-disk-access-status', () => ({
getMacosFullDiskAccessStatus: getMacosFullDiskAccessStatusMock
}))
import { registerDeveloperPermissionHandlers } from './developer-permissions'
describe('registerDeveloperPermissionHandlers', () => {
@@ -72,6 +78,8 @@ describe('registerDeveloperPermissionHandlers', () => {
askForMediaAccessMock.mockReset()
getMediaAccessStatusMock.mockReset()
isTrustedAccessibilityClientMock.mockReset()
getMacosFullDiskAccessStatusMock.mockReset()
getMacosFullDiskAccessStatusMock.mockResolvedValue('denied')
execFileMock.mockReset()
execFileMock.mockImplementation((...args: unknown[]) => {
const callback = args.at(-1)
@@ -119,6 +127,21 @@ describe('registerDeveloperPermissionHandlers', () => {
return call[1] as (_event: unknown, args: { id: string }) => Promise<unknown>
}
it('returns the Full Disk Access read-probe status', async () => {
getMacosFullDiskAccessStatusMock.mockResolvedValue('granted')
registerDeveloperPermissionHandlers()
const call = handleMock.mock.calls.find(
(registration: unknown[]) => registration[0] === 'developerPermissions:getStatus'
)
const handler = call?.[1] as (() => Promise<unknown>) | undefined
await expect(handler?.()).resolves.toContainEqual({
id: 'full-disk-access',
status: 'granted'
})
})
it('clears the local-network prompt fallback timer when UDP send settles first', async () => {
registerDeveloperPermissionHandlers()
+2 -19
View File
@@ -1,9 +1,7 @@
import { execFile } from 'node:child_process'
import dgram from 'node:dgram'
import { access } from 'node:fs/promises'
import { homedir } from 'node:os'
import path from 'node:path'
import { ipcMain, shell, systemPreferences } from 'electron'
import { getMacosFullDiskAccessStatus } from '../macos-full-disk-access-status'
import type {
DeveloperPermissionId,
DeveloperPermissionRequestResult,
@@ -50,21 +48,6 @@ function getMediaStatus(mediaType: 'microphone' | 'camera' | 'screen'): Develope
}
}
async function getFullDiskAccessStatus(): Promise<DeveloperPermissionStatus> {
const unsupported = unsupportedOffMac()
if (unsupported) {
return unsupported
}
try {
// Why: Safari bookmarks are TCC-protected, so read access is a practical
// Full Disk Access signal without touching user project contents.
await access(path.join(homedir(), 'Library', 'Safari', 'Bookmarks.plist'))
return 'granted'
} catch {
return 'unknown'
}
}
function getAccessibilityStatus(): DeveloperPermissionStatus {
const unsupported = unsupportedOffMac()
if (unsupported) {
@@ -165,7 +148,7 @@ async function getPermissionState(id: DeveloperPermissionId): Promise<DeveloperP
case 'accessibility':
return { id, status: getAccessibilityStatus() }
case 'full-disk-access':
return { id, status: await getFullDiskAccessStatus() }
return { id, status: await getMacosFullDiskAccessStatus() }
case 'automation':
case 'local-network':
return { id, status: unsupportedOffMac() ?? 'unknown' }
@@ -0,0 +1,63 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { join } from 'node:path'
import {
getMacosFullDiskAccessStatus,
probeMacosFullDiskAccess
} from './macos-full-disk-access-status'
const originalPlatform = process.platform
const homeDirectory = join('Users', 'tester')
const databasePath = join(
homeDirectory,
'Library',
'Application Support',
'com.apple.TCC',
'TCC.db'
)
function fileSystemError(code: string): NodeJS.ErrnoException {
return Object.assign(new Error(code), { code })
}
afterEach(() => {
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
})
describe('probeMacosFullDiskAccess', () => {
it('reports granted only when the TCC database opens for reading', async () => {
const readProbe = vi.fn().mockResolvedValue(undefined)
await expect(probeMacosFullDiskAccess({ homeDirectory, readProbe })).resolves.toBe('granted')
expect(readProbe).toHaveBeenCalledWith(databasePath)
})
it.each(['EACCES', 'EPERM'])('reports denied for %s', async (code) => {
await expect(
probeMacosFullDiskAccess({
homeDirectory,
readProbe: async () => {
throw fileSystemError(code)
}
})
).resolves.toBe('denied')
})
it.each(['ENOENT', 'ENOTDIR', 'EBUSY'])('keeps %s failures unknown', async (code) => {
await expect(
probeMacosFullDiskAccess({
homeDirectory,
readProbe: async () => {
throw fileSystemError(code)
}
})
).resolves.toBe('unknown')
})
})
describe('getMacosFullDiskAccessStatus', () => {
it('is unsupported off macOS', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' })
await expect(getMacosFullDiskAccessStatus()).resolves.toBe('unsupported')
})
})
+44
View File
@@ -0,0 +1,44 @@
import { open } from 'node:fs/promises'
import { homedir } from 'node:os'
import { join } from 'node:path'
import type { DeveloperPermissionStatus } from '../shared/developer-permissions-types'
type ReadProbe = (filePath: string) => Promise<void>
async function openForRead(filePath: string): Promise<void> {
const handle = await open(filePath, 'r')
await handle.close()
}
function errorCode(error: unknown): string | undefined {
return error instanceof Error && 'code' in error
? (error as NodeJS.ErrnoException).code
: undefined
}
export async function probeMacosFullDiskAccess({
homeDirectory = homedir(),
readProbe = openForRead
}: {
homeDirectory?: string
readProbe?: ReadProbe
} = {}): Promise<DeveloperPermissionStatus> {
const databasePath = join(
homeDirectory,
'Library',
'Application Support',
'com.apple.TCC',
'TCC.db'
)
try {
await readProbe(databasePath)
return 'granted'
} catch (error) {
const code = errorCode(error)
return code === 'EACCES' || code === 'EPERM' ? 'denied' : 'unknown'
}
}
export async function getMacosFullDiskAccessStatus(): Promise<DeveloperPermissionStatus> {
return process.platform === 'darwin' ? probeMacosFullDiskAccess() : 'unsupported'
}
+34 -4
View File
@@ -30,6 +30,7 @@ vi.mock('node:fs', async (importOriginal) => ({
const {
TCC_PROMPT_NOTICE_THRESHOLD,
TCC_PROMPT_NOTICE_VERSION,
TCC_PROMPT_WATCH_START_FALLBACK_MS,
acknowledgePendingTccPromptNotice,
consumePendingTccPromptNotice,
@@ -67,7 +68,11 @@ describe('tcc prompt notice threshold', () => {
handleTccPromptForTests()
expect(writeFileAtomically).toHaveBeenCalledTimes(1)
const [, contents] = writeFileAtomically.mock.calls[0] as [string, string]
expect(JSON.parse(contents)).toMatchObject({ promptCount: 1, notified: false })
expect(JSON.parse(contents)).toMatchObject({
noticeVersion: TCC_PROMPT_NOTICE_VERSION,
promptCount: 1,
notified: false
})
})
it('never fires again once dismissed, even past the threshold', () => {
@@ -303,9 +308,9 @@ describe('tcc prompt notice threshold', () => {
})
it.each([
{ promptCount: 2, notified: false },
{ promptCount: 1, notified: true }
])('requires a fresh detection for a legacy tally: $promptCount/$notified', (persisted) => {
{ promptCount: 2, notified: false, acknowledgedAfterClose: false },
{ promptCount: 1, notified: true, acknowledgedAfterClose: true }
])('re-arms a legacy tally after a fresh detection: $promptCount/$notified', (persisted) => {
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' })
readTallyFile.mockReturnValue(JSON.stringify({ ...persisted, dismissed: false }))
@@ -329,11 +334,35 @@ describe('tcc prompt notice threshold', () => {
}
})
it('preserves a legacy permanent opt-out', () => {
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' })
readTallyFile.mockReturnValue(
JSON.stringify({
promptCount: 1,
notified: true,
dismissed: true,
acknowledgedAfterClose: true
})
)
try {
const mainWindow = createWindowStub()
initTccPromptNotice(mainWindow as never)
expect(watchStart).not.toHaveBeenCalled()
expect(mainWindow.webContents.send).not.toHaveBeenCalled()
expect(consumePendingTccPromptNotice(1)).toBeNull()
} finally {
Object.defineProperty(process, 'platform', platform!)
}
})
it('replays an unclosed notice from the new delivery contract', () => {
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' })
readTallyFile.mockReturnValue(
JSON.stringify({
noticeVersion: TCC_PROMPT_NOTICE_VERSION,
promptCount: 1,
notified: false,
dismissed: false,
@@ -359,6 +388,7 @@ describe('tcc prompt notice threshold', () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' })
readTallyFile.mockReturnValue(
JSON.stringify({
noticeVersion: TCC_PROMPT_NOTICE_VERSION,
promptCount: 1,
notified: true,
dismissed: false,
+22 -4
View File
@@ -14,6 +14,9 @@ import { MacosTccPromptWatch } from './macos-tcc-prompt-watch'
/** Why: the first detected dialog identifies an affected user; the notice remains one-time. */
export const TCC_PROMPT_NOTICE_THRESHOLD = 1
/** Why: re-arm users who may have trusted the former false-positive Full Disk Access badge. */
export const TCC_PROMPT_NOTICE_VERSION = 2
/** Why: preserve detection when Electron never emits `ready-to-show` without competing with startup. */
export const TCC_PROMPT_WATCH_START_FALLBACK_MS = 10_000
@@ -28,6 +31,7 @@ export type TccPromptNoticeClaim = TccPromptNoticePayload & {
}
type TccPromptTally = {
noticeVersion: number
promptCount: number
notified: boolean
dismissed: boolean
@@ -35,6 +39,7 @@ type TccPromptTally = {
}
const EMPTY_TALLY: TccPromptTally = {
noticeVersion: TCC_PROMPT_NOTICE_VERSION,
promptCount: 0,
notified: false,
dismissed: false,
@@ -57,15 +62,28 @@ function loadTally(): TccPromptTally {
try {
const parsed = JSON.parse(readFileSync(tallyPath(), 'utf-8')) as Partial<TccPromptTally>
const dismissed = parsed.dismissed === true
if (!dismissed && typeof parsed.acknowledgedAfterClose !== 'boolean') {
// Why: a legacy tally only proves a past prompt, not that Full Disk Access is still missing.
if (dismissed) {
return {
noticeVersion: TCC_PROMPT_NOTICE_VERSION,
promptCount: typeof parsed.promptCount === 'number' ? parsed.promptCount : 0,
notified: true,
dismissed: true,
acknowledgedAfterClose: true
}
}
if (parsed.noticeVersion !== TCC_PROMPT_NOTICE_VERSION) {
return { ...EMPTY_TALLY }
}
if (typeof parsed.acknowledgedAfterClose !== 'boolean') {
// Why: an incomplete tally proves only a past prompt, not that access is still missing.
return { ...EMPTY_TALLY }
}
const acknowledgedAfterClose = parsed.acknowledgedAfterClose === true
return {
noticeVersion: TCC_PROMPT_NOTICE_VERSION,
promptCount: typeof parsed.promptCount === 'number' ? parsed.promptCount : 0,
notified: parsed.notified === true && (dismissed || acknowledgedAfterClose),
dismissed,
notified: parsed.notified === true && acknowledgedAfterClose,
dismissed: false,
acknowledgedAfterClose
}
} catch {
@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest'
import {
createGpuAccelerationAboutPanelOptions,
describeGpuAcceleration
} from './gpu-acceleration-about-panel'
describe('GPU acceleration About panel', () => {
it.each([
['enabled', 'Enabled'],
['disabled_off', 'Disabled'],
['disabled_software', 'Software rendering'],
['unavailable_software', 'Software rendering'],
['unavailable', 'Unavailable'],
['undefined', 'Status unavailable']
])('describes Chromium compositing status %s', (gpu_compositing, expected) => {
expect(describeGpuAcceleration({ gpu_compositing }, false)).toBe(expected)
})
it('gives Safe Graphics Mode precedence over Chromium status', () => {
expect(describeGpuAcceleration({ gpu_compositing: 'enabled' }, true)).toBe(
'Disabled (Safe Graphics Mode)'
)
})
it('reports status as unavailable until Chromium publishes GPU information', () => {
expect(describeGpuAcceleration(null, false)).toBe('Status unavailable')
})
it('uses the Linux-visible copyright field', () => {
expect(
createGpuAccelerationAboutPanelOptions({
appName: 'Orca',
appVersion: '1.2.3',
platform: 'linux',
gpuFallbackActive: false,
gpuFeatureStatus: { gpu_compositing: 'enabled' }
})
).toEqual({
applicationName: 'Orca',
applicationVersion: '1.2.3',
copyright: 'GPU acceleration: Enabled'
})
})
it.each(['darwin', 'win32'] satisfies NodeJS.Platform[])('uses credits on %s', (platform) => {
expect(
createGpuAccelerationAboutPanelOptions({
appName: 'Orca',
appVersion: '1.2.3',
platform,
gpuFallbackActive: true,
gpuFeatureStatus: null
})
).toEqual({
applicationName: 'Orca',
applicationVersion: '1.2.3',
credits: 'GPU acceleration: Disabled (Safe Graphics Mode)'
})
})
})
@@ -0,0 +1,49 @@
export type GpuAccelerationAboutPanelOptions = {
appName: string
appVersion: string
platform: NodeJS.Platform
gpuFallbackActive: boolean
gpuFeatureStatus: Pick<Electron.GPUFeatureStatus, 'gpu_compositing'> | null
}
export function describeGpuAcceleration(
gpuFeatureStatus: Pick<Electron.GPUFeatureStatus, 'gpu_compositing'> | null,
gpuFallbackActive: boolean
): string {
if (gpuFallbackActive) {
return 'Disabled (Safe Graphics Mode)'
}
const compositing = gpuFeatureStatus?.gpu_compositing.trim().toLowerCase()
if (!compositing || compositing === 'undefined') {
return 'Status unavailable'
}
if (compositing === 'enabled') {
return 'Enabled'
}
if (compositing.includes('software')) {
return 'Software rendering'
}
if (compositing.startsWith('disabled')) {
return 'Disabled'
}
if (compositing.startsWith('unavailable')) {
return 'Unavailable'
}
return `Unknown (${compositing})`
}
export function createGpuAccelerationAboutPanelOptions({
appName,
appVersion,
platform,
gpuFallbackActive,
gpuFeatureStatus
}: GpuAccelerationAboutPanelOptions): Electron.AboutPanelOptionsOptions {
const status = `GPU acceleration: ${describeGpuAcceleration(gpuFeatureStatus, gpuFallbackActive)}`
return {
applicationName: appName,
applicationVersion: appVersion,
...(platform === 'linux' ? { copyright: status } : { credits: status })
}
}
+37 -17
View File
@@ -55,6 +55,16 @@ function isEquivalentPaneKey(a: string, b: string): boolean {
return Boolean(aLeaf && bLeaf && aLeaf === bLeaf)
}
// Why: indexable pre-filter for isEquivalentPaneKey — equal strings and equal leaves both share the
// text after the first ':', so this narrows candidates without deciding equivalence itself.
const PANE_KEY_MATCH_SUFFIX_SQL =
"substr(coordinator_pane_key, instr(coordinator_pane_key, ':') + 1)"
function paneKeyMatchSuffix(paneKey: string): string {
const colon = paneKey.indexOf(':')
return colon < 0 ? paneKey : paneKey.slice(colon + 1)
}
export type {
MessageType,
MessagePriority,
@@ -511,6 +521,10 @@ export class OrchestrationDb {
CREATE INDEX IF NOT EXISTS idx_gates_task ON decision_gates(task_id);
CREATE INDEX IF NOT EXISTS idx_gates_status ON decision_gates(status);
CREATE INDEX IF NOT EXISTS idx_runs_coordinator_pane_leaf
ON runs(${PANE_KEY_MATCH_SUFFIX_SQL})
WHERE coordinator_pane_key IS NOT NULL;
CREATE TABLE IF NOT EXISTS coordinator_runs (
id TEXT PRIMARY KEY,
spec TEXT NOT NULL,
@@ -2245,6 +2259,8 @@ export class OrchestrationDb {
'Legacy takeover is only available for the automatically adopted Run.'
)
}
// Why: only LIVE legacy work needs the flag — settled work has no competing authority left, and
// fencing it would strand the recovered graph behind an attestation the caller may not have.
if (
activeLegacyAssignment &&
!sameBinding &&
@@ -2338,31 +2354,35 @@ export class OrchestrationDb {
}
getCurrentRunForPane(paneKey: string): RunRow | undefined {
const runs = this.db
.prepare('SELECT * FROM runs WHERE coordinator_pane_key IS NOT NULL AND legacy = 0')
.all() as RunRow[]
const run = runs.find(
(candidate) =>
candidate.coordinator_pane_key !== null &&
isEquivalentPaneKey(candidate.coordinator_pane_key, paneKey)
)
const run = this.runsBoundToPane(paneKey)[0]
return run ? exposeRunTimestamps(run) : undefined
}
// Why: the indexed suffix only narrows candidates; isEquivalentPaneKey still decides, so
// reminted tab halves keep matching and unparseable keys keep requiring an exact match.
private runsBoundToPane(paneKey: string): RunRow[] {
return (
this.db
.prepare(
`SELECT * FROM runs
WHERE coordinator_pane_key IS NOT NULL AND legacy = 0
AND ${PANE_KEY_MATCH_SUFFIX_SQL} = ?
ORDER BY rowid`
)
.all(paneKeyMatchSuffix(paneKey)) as RunRow[]
).filter(
(run) =>
run.coordinator_pane_key !== null && isEquivalentPaneKey(run.coordinator_pane_key, paneKey)
)
}
private getRunRaw(id: string): RunRow | undefined {
return this.db.prepare('SELECT * FROM runs WHERE id = ?').get(id) as RunRow | undefined
}
private unbindOtherRunsForPane(paneKey: string, exceptRunId?: string): void {
const bound = this.db
.prepare('SELECT * FROM runs WHERE coordinator_pane_key IS NOT NULL AND legacy = 0')
.all() as RunRow[]
for (const run of bound) {
if (
run.id !== exceptRunId &&
run.coordinator_pane_key &&
isEquivalentPaneKey(run.coordinator_pane_key, paneKey)
) {
for (const run of this.runsBoundToPane(paneKey)) {
if (run.id !== exceptRunId) {
this.db
.prepare(
`UPDATE runs
@@ -0,0 +1,375 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import type Database from '../../sqlite/sync-database'
import SyncDatabase from '../../sqlite/sync-database'
import { OrchestrationDb } from './db'
const LEGACY_COORDINATOR_HANDLE = 'term_legacy_coord'
const LEGACY_COORDINATOR_PANE = 'tab_coord:44444444-4444-4444-8444-444444444444'
const LEGACY_WORKER_HANDLE = 'term_legacy_worker'
const LEGACY_WORKER_PANE = 'tab_worker:33333333-3333-4333-8333-333333333333'
const CURRENT_COORDINATOR_HANDLE = 'term_current_coord'
const CURRENT_COORDINATOR_PANE = 'tab_current:11111111-1111-4111-8111-111111111111'
const tempDirs: string[] = []
const databases: OrchestrationDb[] = []
afterEach(() => {
for (const database of databases.splice(0)) {
database.close()
}
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true })
}
})
function track(db: OrchestrationDb): OrchestrationDb {
databases.push(db)
return db
}
type AdoptedFixture = {
db: OrchestrationDb
adoptedRunId: string
taskId: string
dispatchId: string
recoveryMessageId: string
}
/** Builds a pre-cutover graph at schema 18 and reopens it so adoption runs. */
function createAdoptedFixture(options: { settleWork: boolean }): AdoptedFixture {
const dir = mkdtempSync(join(tmpdir(), 'orca-adopted-bind-'))
tempDirs.push(dir)
const dbPath = join(dir, 'orchestration.db')
const before = new OrchestrationDb(dbPath)
const task = before.createTask({
spec: 'legacy assignment',
createdByTerminalHandle: LEGACY_COORDINATOR_HANDLE
})
const dispatch = before.createDispatchContext(task.id, LEGACY_WORKER_HANDLE, LEGACY_WORKER_PANE)
const recovery = before.insertMessage({
from: LEGACY_WORKER_HANDLE,
to: LEGACY_COORDINATOR_HANDLE,
subject: 'recovered worker outcome',
type: 'worker_done'
})
before.close()
const raw = new SyncDatabase(dbPath)
if (options.settleWork) {
raw.exec("UPDATE dispatch_contexts SET status = 'completed'")
}
raw.exec(`
DROP INDEX IF EXISTS idx_messages_delivery_contract;
DROP TABLE legacy_mail_receipts;
DROP TABLE legacy_operation_receipts;
DROP TABLE legacy_compatibility_principals;
DROP TABLE legacy_adoptions;
`)
raw.pragma('user_version = 18')
raw.close()
const db = track(new OrchestrationDb(dbPath))
return {
db,
adoptedRunId: db.getLegacyAdoption()?.adopted_run_id as string,
taskId: task.id,
dispatchId: dispatch.id,
recoveryMessageId: recovery.id
}
}
function commitLegacyCoordinator(db: OrchestrationDb, runId: string): string {
return db.commitLegacyCompatibilityPrincipal({
runId,
role: 'coordinator',
hostScope: 'local:runtime_1',
terminalHandle: LEGACY_COORDINATOR_HANDLE,
paneKey: LEGACY_COORDINATOR_PANE,
launchTokenHash: 'coord_launch_hash',
processIncarnation: 'process_coord'
}).principal.id
}
// Why: --takeover-legacy guards LIVE legacy work only. Settled work has no competing authority to
// seize, and requiring the flag there would strand the recovered graph behind an attestation the
// caller may not hold (runUse rejects the flag unless caller authority matches the binding pane).
describe('adopted Run binding without --takeover-legacy', () => {
it('fences a bind while legacy work is still live, and names the recovery command', () => {
const { db, adoptedRunId } = createAdoptedFixture({ settleWork: false })
expect(() =>
db.bindRun({
runId: adoptedRunId,
coordinatorHandle: CURRENT_COORDINATOR_HANDLE,
coordinatorPaneKey: CURRENT_COORDINATOR_PANE
})
).toThrowError(
expect.objectContaining({
code: 'consumer_fenced',
data: {
effectsApplied: false,
recoveryCommand: `orca orchestration run-use --id ${adoptedRunId} --takeover-legacy`
}
})
)
expect(db.getRun(adoptedRunId)).toMatchObject({
coordinator_handle: null,
coordinator_pane_key: null,
consumer_generation: 0
})
})
it('claims the settled adopted Run and inherits its recovered task graph', () => {
const { db, adoptedRunId } = createAdoptedFixture({ settleWork: true })
const run = db.bindRun({
runId: adoptedRunId,
coordinatorHandle: CURRENT_COORDINATOR_HANDLE,
coordinatorPaneKey: CURRENT_COORDINATOR_PANE
})
expect(run).toMatchObject({
coordinator_handle: CURRENT_COORDINATOR_HANDLE,
coordinator_pane_key: CURRENT_COORDINATOR_PANE,
consumer_generation: 1
})
expect(db.listTasks({ runId: adoptedRunId })).toHaveLength(1)
expect(db.getCurrentRunForPane(CURRENT_COORDINATOR_PANE)?.id).toBe(adoptedRunId)
})
// Why: this is what makes the unflagged claim safe — the recovered coordinator's mail is not orphaned.
it('promotes the retained coordinator mail onto the claiming Run', () => {
const { db, adoptedRunId, recoveryMessageId } = createAdoptedFixture({ settleWork: true })
expect(db.getMessageById(recoveryMessageId)).toMatchObject({
to_handle: LEGACY_COORDINATOR_HANDLE,
delivery_contract: 'legacy_direct'
})
const run = db.bindRun({
runId: adoptedRunId,
coordinatorHandle: CURRENT_COORDINATOR_HANDLE,
coordinatorPaneKey: CURRENT_COORDINATOR_PANE
})!
expect(db.getMessageById(recoveryMessageId)).toMatchObject({
to_handle: `run:${adoptedRunId}`,
delivery_contract: 'current_delivery',
read: 0
})
expect(
db
.getOrCreateRunDelivery({
runId: adoptedRunId,
consumerGeneration: run.consumer_generation
})
?.messages.map((message) => message.id)
).toContain(recoveryMessageId)
})
it('revokes an attested legacy coordinator when it claims the settled Run', () => {
const { db, adoptedRunId } = createAdoptedFixture({ settleWork: true })
const principalId = commitLegacyCoordinator(db, adoptedRunId)
db.bindRun({
runId: adoptedRunId,
coordinatorHandle: CURRENT_COORDINATOR_HANDLE,
coordinatorPaneKey: CURRENT_COORDINATOR_PANE
})
expect(db.getLegacyCompatibilityPrincipal(principalId)?.status).toBe('revoked')
})
// Why: the acknowledgement is per claimant, not one-shot — a prior takeover does not license the next.
it('keeps fencing new claimants while legacy work stays live', () => {
const { db, adoptedRunId } = createAdoptedFixture({ settleWork: false })
db.bindRun({
runId: adoptedRunId,
coordinatorHandle: CURRENT_COORDINATOR_HANDLE,
coordinatorPaneKey: CURRENT_COORDINATOR_PANE,
takeoverLegacy: true
})
expect(() =>
db.bindRun({
runId: adoptedRunId,
coordinatorHandle: 'term_second_coord',
coordinatorPaneKey: 'tab_second:22222222-2222-4222-8222-222222222222'
})
).toThrowError(expect.objectContaining({ code: 'consumer_fenced' }))
})
it('lets a later coordinator claim the settled Run once the first one has', () => {
const { db, adoptedRunId } = createAdoptedFixture({ settleWork: true })
db.bindRun({
runId: adoptedRunId,
coordinatorHandle: CURRENT_COORDINATOR_HANDLE,
coordinatorPaneKey: CURRENT_COORDINATOR_PANE
})
expect(
db.bindRun({
runId: adoptedRunId,
coordinatorHandle: 'term_second_coord',
coordinatorPaneKey: 'tab_second:22222222-2222-4222-8222-222222222222'
})
).toMatchObject({ coordinator_handle: 'term_second_coord', consumer_generation: 2 })
})
})
// Why: the SQL narrows on the text after the first ':' — a superset of leaf-equivalence — so the
// JS isEquivalentPaneKey filter stays authoritative. These pin both halves of that contract.
describe('pane-bound Run lookup', () => {
function explain(db: OrchestrationDb, paneKey: string): string {
const sqlite = (db as unknown as { db: Database.Database }).db
return (
sqlite
.prepare(
`EXPLAIN QUERY PLAN
SELECT * FROM runs
WHERE coordinator_pane_key IS NOT NULL AND legacy = 0
AND substr(coordinator_pane_key, instr(coordinator_pane_key, ':') + 1) = ?
ORDER BY rowid`
)
.all(paneKey) as { detail: string }[]
)
.map((row) => row.detail)
.join(' | ')
}
it('matches a reminted tab half by leaf UUID', () => {
const db = track(new OrchestrationDb(':memory:'))
const run = db.createRun({
objective: 'work',
coordinatorHandle: 'term_a',
coordinatorPaneKey: 'tab_original:77777777-7777-4777-8777-777777777777'
})
expect(db.getCurrentRunForPane('tab_broken_out:77777777-7777-4777-8777-777777777777')?.id).toBe(
run.id
)
})
it('requires an exact match for keys that do not parse', () => {
const db = track(new OrchestrationDb(':memory:'))
// Extra ':' makes parsePaneKey return null even though the leaf-looking suffix matches.
const unparseable = 'tab:extra:88888888-8888-4888-8888-888888888888'
db.createRun({
objective: 'unparseable',
coordinatorHandle: 'term_b',
coordinatorPaneKey: unparseable
})
expect(db.getCurrentRunForPane(unparseable)?.coordinator_pane_key).toBe(unparseable)
expect(
db.getCurrentRunForPane('tab_other:88888888-8888-4888-8888-888888888888')
).toBeUndefined()
})
it('matches colon-free keys only on exact equality', () => {
const db = track(new OrchestrationDb(':memory:'))
db.createRun({ objective: 'flat', coordinatorHandle: 'term_c', coordinatorPaneKey: 'flatkey' })
expect(db.getCurrentRunForPane('flatkey')?.objective).toBe('flat')
expect(db.getCurrentRunForPane('tab:flatkey')).toBeUndefined()
})
it('unbinds only the leaf-equivalent Run when a pane rebinds', () => {
const db = track(new OrchestrationDb(':memory:'))
const shared = db.createRun({
objective: 'first',
coordinatorHandle: 'term_d',
coordinatorPaneKey: 'tab_one:99999999-9999-4999-8999-999999999999'
})
const untouched = db.createRun({
objective: 'other pane',
coordinatorHandle: 'term_e',
coordinatorPaneKey: 'tab_two:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
})
const rebound = db.createRun({
objective: 'second',
coordinatorHandle: 'term_d',
coordinatorPaneKey: 'tab_reminted:99999999-9999-4999-8999-999999999999'
})
// createRun binds at generation 1; the unbind fences it to 2.
expect(db.getRun(shared.id)).toMatchObject({
coordinator_pane_key: null,
consumer_generation: 2
})
expect(db.getRun(untouched.id)).toMatchObject({
coordinator_pane_key: 'tab_two:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
})
expect(db.getCurrentRunForPane('tab_one:99999999-9999-4999-8999-999999999999')?.id).toBe(
rebound.id
)
})
it('stays flat as the bound-Run set grows', () => {
const db = track(new OrchestrationDb(':memory:'))
const leafOf = (i: number) => `${i.toString(16).padStart(8, '0')}-1111-4111-8111-111111111111`
const bind = (from: number, to: number): void => {
for (let i = from; i < to; i++) {
db.createRun({
objective: `run ${i}`,
coordinatorHandle: `term_${i}`,
coordinatorPaneKey: `tab_${i}:${leafOf(i)}`
})
}
}
const probe = `tab_reminted:${leafOf(0)}`
const measure = (): number => {
for (let i = 0; i < 200; i++) {
db.getCurrentRunForPane(probe)
}
const start = performance.now()
for (let i = 0; i < 2000; i++) {
db.getCurrentRunForPane(probe)
}
return (performance.now() - start) / 2000
}
bind(0, 61)
expect(db.getCurrentRunForPane(probe)).toBeDefined()
const small = measure()
bind(61, 2001)
const large = measure()
// The pre-index scan grew ~28x across this range; the index keeps it flat.
expect(large).toBeLessThan(small * 6)
expect(explain(db, 'x')).toContain('USING INDEX idx_runs_coordinator_pane_leaf')
})
it('hands the JS filter an O(1) candidate set regardless of bound-Run count', () => {
const db = track(new OrchestrationDb(':memory:'))
const sqlite = (db as unknown as { db: Database.Database }).db
const leaf = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
for (let i = 0; i < 500; i++) {
db.createRun({
objective: `run ${i}`,
coordinatorHandle: `term_${i}`,
coordinatorPaneKey: `tab_${i}:${i.toString(16).padStart(8, '0')}-1111-4111-8111-111111111111`
})
}
db.createRun({
objective: 'target',
coordinatorHandle: 'term_target',
coordinatorPaneKey: `tab_target:${leaf}`
})
const candidates = sqlite
.prepare(
`SELECT id FROM runs
WHERE coordinator_pane_key IS NOT NULL AND legacy = 0
AND substr(coordinator_pane_key, instr(coordinator_pane_key, ':') + 1) = ?`
)
.all(leaf) as { id: string }[]
expect(candidates).toHaveLength(1)
expect(db.getCurrentRunForPane(`tab_reminted:${leaf}`)?.objective).toBe('target')
})
})
+2
View File
@@ -88,6 +88,8 @@ export type RpcContext = {
method: string
payloadHash: string
}
// Why: Run-scoped handlers must compare declared handles with request attestation.
orchestrationCompatibilityEvidence?: OrchestrationCompatibilityEvidence
// Why: only the compatibility authority router can set this trusted scope; user params cannot bypass Run consumer binding.
legacyCoordinatorRunId?: string
legacyCoordinatorAuthority?: LegacyCoordinatorAuthorityProof
+4 -2
View File
@@ -104,7 +104,8 @@ export class RpcDispatcher {
legacyCoordinatorAuthority: legacyCoordinator?.authority,
revalidateLegacyCoordinator: legacyCoordinator?.revalidate,
orchestrationCompatibilityCallerAuthority:
compatibility.orchestrationCompatibilityCallerAuthority
compatibility.orchestrationCompatibilityCallerAuthority,
orchestrationCompatibilityEvidence: request.orchestrationCompatibilityEvidence
})
}
const result = await this.orchestrationMutations.run(
@@ -199,7 +200,8 @@ export class RpcDispatcher {
legacyCoordinatorAuthority: legacyCoordinator?.authority,
revalidateLegacyCoordinator: legacyCoordinator?.revalidate,
orchestrationCompatibilityCallerAuthority:
compatibility.orchestrationCompatibilityCallerAuthority
compatibility.orchestrationCompatibilityCallerAuthority,
orchestrationCompatibilityEvidence: request.orchestrationCompatibilityEvidence
})
}
const result = await this.orchestrationMutations.run(
@@ -0,0 +1,315 @@
import { afterEach, describe, expect, it } from 'vitest'
import {
cleanupLegacyCompatibilityDispatcherHarnesses,
COORDINATOR_HANDLE,
createHarness,
currentEvidence,
CURRENT_COORDINATOR_HANDLE,
CURRENT_COORDINATOR_PANE,
evidence,
invoke,
request,
type LegacyCompatibilityDispatcherHarness
} from '../orchestration-legacy-compatibility-dispatcher-test-fixture'
const STRANGER_HANDLE = 'term_stranger_coord'
const STRANGER_PANE = 'tab_stranger:77777777-7777-4777-8777-777777777777'
afterEach(() => {
cleanupLegacyCompatibilityDispatcherHarnesses()
})
function bindCurrentCoordinatorRun(harness: LegacyCompatibilityDispatcherHarness): string {
return harness.db.createRun({
objective: 'current work',
coordinatorHandle: CURRENT_COORDINATOR_HANDLE,
coordinatorPaneKey: CURRENT_COORDINATOR_PANE
}).id
}
// Why: the adopted Run's recovered task is the cross-Run target; its dispatch must stay live when a gate is refused.
function adoptedTaskState(harness: LegacyCompatibilityDispatcherHarness) {
return {
task: harness.db.getTask(harness.taskId)?.status,
dispatch: harness.db.getDispatchContextById(harness.dispatchId)?.status,
gates: harness.db.listGates({ taskId: harness.taskId }).length
}
}
const UNATTESTED_CURRENT_COORDINATOR = { ...currentEvidence('coordinator'), launchToken: '' }
describe('orchestration gate Run authorization', () => {
it.each(['dispatch', 'websocket'] as const)(
'G1 %s refuses an unbound attested caller creating a gate on the adopted Run',
async (transport) => {
const harness = createHarness()
const before = adoptedTaskState(harness)
const response = await invoke(
harness.dispatcher,
request(
'orchestration.gateCreate',
{ task: harness.taskId, question: 'Proceed?' },
currentEvidence('coordinator'),
`g1-unbound-gate-create-${transport}`
),
transport
)
expect(response).toMatchObject({ ok: false, error: { code: 'run_required' } })
expect(adoptedTaskState(harness)).toEqual(before)
}
)
it('G2 refuses an unattested caller creating a gate on another Run task', async () => {
const harness = createHarness()
const runA = bindCurrentCoordinatorRun(harness)
const before = adoptedTaskState(harness)
const response = await harness.dispatcher.dispatch(
request(
'orchestration.gateCreate',
{ task: harness.taskId, question: 'Proceed?', from: CURRENT_COORDINATOR_HANDLE },
UNATTESTED_CURRENT_COORDINATOR,
'g2-cross-run-gate-create'
)
)
expect(response).toMatchObject({
ok: false,
error: {
code: 'task_not_found',
message: `Task ${harness.taskId} was not found in Run ${runA}.`
}
})
expect(adoptedTaskState(harness)).toEqual(before)
})
it('G2 refuses the same caller when it names no sender terminal at all', async () => {
const harness = createHarness()
bindCurrentCoordinatorRun(harness)
const before = adoptedTaskState(harness)
const response = await harness.dispatcher.dispatch(
request(
'orchestration.gateCreate',
{ task: harness.taskId, question: 'Proceed?' },
UNATTESTED_CURRENT_COORDINATOR,
'g2-anonymous-gate-create'
)
)
expect(response).toMatchObject({ ok: false, error: { code: 'run_required' } })
expect(adoptedTaskState(harness)).toEqual(before)
})
it('G3 refuses an unattested caller once the adopted Run is claimed', async () => {
const harness = createHarness()
const claim = await harness.dispatcher.dispatch(
request(
'orchestration.runUse',
{ id: harness.adoptedRunId, from: COORDINATOR_HANDLE },
evidence('coordinator'),
'g3-legacy-claim'
)
)
expect(claim).toMatchObject({ ok: true })
bindCurrentCoordinatorRun(harness)
const before = adoptedTaskState(harness)
const response = await harness.dispatcher.dispatch(
request(
'orchestration.gateCreate',
{ task: harness.taskId, question: 'Proceed?', from: CURRENT_COORDINATOR_HANDLE },
UNATTESTED_CURRENT_COORDINATOR,
'g3-claimed-gate-create'
)
)
expect(response).toMatchObject({ ok: false })
expect(adoptedTaskState(harness)).toEqual(before)
})
it('G4 refuses an attested bound caller creating a gate in a stranger Run', async () => {
const harness = createHarness()
const runA = bindCurrentCoordinatorRun(harness)
const strangerRun = harness.db.createRun({
objective: 'stranger work',
coordinatorHandle: STRANGER_HANDLE,
coordinatorPaneKey: STRANGER_PANE
})
const strangerTask = harness.db.createTask({
spec: 'stranger assignment',
runId: strangerRun.id
})
const response = await harness.dispatcher.dispatch(
request(
'orchestration.gateCreate',
{ task: strangerTask.id, question: 'Proceed?', from: CURRENT_COORDINATOR_HANDLE },
currentEvidence('coordinator'),
'g4-stranger-gate-create'
)
)
expect(response).toMatchObject({
ok: false,
error: {
code: 'task_not_found',
message: `Task ${strangerTask.id} was not found in Run ${runA}.`
}
})
expect(harness.db.getTask(strangerTask.id)?.status).toBe('ready')
expect(harness.db.listGates({ taskId: strangerTask.id })).toHaveLength(0)
})
it('G4 refuses the stranger Run even when it is named explicitly', async () => {
const harness = createHarness()
const runA = bindCurrentCoordinatorRun(harness)
const strangerRun = harness.db.createRun({
objective: 'stranger work',
coordinatorHandle: STRANGER_HANDLE,
coordinatorPaneKey: STRANGER_PANE
})
const strangerTask = harness.db.createTask({
spec: 'stranger assignment',
runId: strangerRun.id
})
const response = await harness.dispatcher.dispatch(
request(
'orchestration.gateCreate',
{
task: strangerTask.id,
question: 'Proceed?',
from: CURRENT_COORDINATOR_HANDLE,
run: strangerRun.id
},
currentEvidence('coordinator'),
'g4-explicit-stranger-gate-create'
)
)
expect(response).toMatchObject({
ok: false,
error: {
code: 'consumer_fenced',
message: `This coordinator terminal is bound to ${runA}, not ${strangerRun.id}.`
}
})
expect(harness.db.getTask(strangerTask.id)?.status).toBe('ready')
})
it('G5 refuses an unattested caller resolving another Run gate', async () => {
const harness = createHarness()
bindCurrentCoordinatorRun(harness)
const gate = harness.db.createGate({ taskId: harness.taskId, question: 'Proceed?' })
const response = await harness.dispatcher.dispatch(
request(
'orchestration.gateResolve',
{ id: gate.id, resolution: 'yes', from: CURRENT_COORDINATOR_HANDLE },
UNATTESTED_CURRENT_COORDINATOR,
'g5-cross-run-gate-resolve'
)
)
expect(response).toMatchObject({ ok: false, error: { message: `Gate not found: ${gate.id}` } })
expect(harness.db.getGate(gate.id)?.status).toBe('pending')
expect(harness.db.getTask(harness.taskId)?.status).toBe('blocked')
})
it('keeps gate-list from leaking gates outside the caller Run', async () => {
const harness = createHarness()
const runA = bindCurrentCoordinatorRun(harness)
const ownTask = harness.db.createTask({ spec: 'own assignment', runId: runA })
const ownGate = harness.db.createGate({ taskId: ownTask.id, question: 'Own?' })
harness.db.createGate({ taskId: harness.taskId, question: 'Adopted?' })
const response = await harness.dispatcher.dispatch(
request(
'orchestration.gateList',
{ from: CURRENT_COORDINATOR_HANDLE },
currentEvidence('coordinator'),
'gate-list-scope'
)
)
expect(response).toMatchObject({
ok: true,
result: { runId: runA, count: 1, gates: [{ id: ownGate.id }] }
})
})
it('lets the bound coordinator create, list, and resolve its own Run gates', async () => {
const harness = createHarness()
const runA = bindCurrentCoordinatorRun(harness)
const ownTask = harness.db.createTask({ spec: 'own assignment', runId: runA })
const created = (await harness.dispatcher.dispatch(
request(
'orchestration.gateCreate',
{
task: ownTask.id,
question: 'Ship it?',
options: JSON.stringify(['yes', 'no']),
from: CURRENT_COORDINATOR_HANDLE
},
currentEvidence('coordinator'),
'own-gate-create'
)
)) as { ok: true; result: { gate: { id: string } } }
expect(created).toMatchObject({ ok: true, result: { gate: { task_id: ownTask.id } } })
expect(harness.db.getTask(ownTask.id)?.status).toBe('blocked')
const resolved = await harness.dispatcher.dispatch(
request(
'orchestration.gateResolve',
{
id: created.result.gate.id,
resolution: 'yes',
from: CURRENT_COORDINATOR_HANDLE
},
currentEvidence('coordinator'),
'own-gate-resolve'
)
)
expect(resolved).toMatchObject({
ok: true,
result: { gate: { status: 'resolved', resolution: 'yes' } }
})
expect(harness.db.getTask(ownTask.id)?.status).toBe('ready')
})
it('keeps the retained legacy coordinator operating on the adopted Run', async () => {
const harness = createHarness()
const created = (await harness.dispatcher.dispatch(
request(
'orchestration.gateCreate',
{ task: harness.taskId, question: 'Proceed?', from: COORDINATOR_HANDLE },
evidence('coordinator'),
'legacy-gate-create'
)
)) as { ok: true; result: { gate: { id: string; run_id: string } } }
expect(created).toMatchObject({
ok: true,
result: { gate: { task_id: harness.taskId, run_id: harness.adoptedRunId } }
})
const resolved = await harness.dispatcher.dispatch(
request(
'orchestration.gateResolve',
{ id: created.result.gate.id, resolution: 'go', from: COORDINATOR_HANDLE },
evidence('coordinator'),
'legacy-gate-resolve'
)
)
expect(resolved).toMatchObject({ ok: true, result: { gate: { status: 'resolved' } } })
expect(harness.db.getTask(harness.taskId)?.status).toBe('ready')
})
})
@@ -3,6 +3,8 @@ import { defineMethod, type RpcMethod } from '../core'
import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas'
import type { GateStatus } from '../../orchestration/db'
import { Coordinator } from '../../orchestration/coordinator'
import { resolveRunScope } from './orchestration-run-scope'
import { OrchestrationError } from '../../orchestration/orchestration-error'
// Why: the coordinator instance is stored at module scope so orchestration.runStop
// can signal it to halt. Only one coordinator can run at a time (enforced by
@@ -22,17 +24,23 @@ const RunStopParams = z.object({})
const GateCreateParams = z.object({
task: requiredString('Missing --task'),
question: requiredString('Missing --question'),
options: OptionalString
options: OptionalString,
from: OptionalString,
run: OptionalString
})
const GateResolveParams = z.object({
id: requiredString('Missing --id'),
resolution: requiredString('Missing --resolution')
resolution: requiredString('Missing --resolution'),
from: OptionalString,
run: OptionalString
})
const GateListParams = z.object({
task: OptionalString,
status: z.enum(['pending', 'resolved', 'timeout']).optional()
status: z.enum(['pending', 'resolved', 'timeout']).optional(),
from: OptionalString,
run: OptionalString
})
export const ORCHESTRATION_GATE_METHODS: RpcMethod[] = [
@@ -103,12 +111,8 @@ export const ORCHESTRATION_GATE_METHODS: RpcMethod[] = [
defineMethod({
name: 'orchestration.gateCreate',
params: GateCreateParams,
handler: (params, { runtime, legacyCoordinatorRunId }) => {
handler: (params, { orchestrationCompatibilityEvidence, runtime, legacyCoordinatorRunId }) => {
const db = runtime.getOrchestrationDb()
const task = db.getTask(params.task)
if (legacyCoordinatorRunId && task?.run_id !== legacyCoordinatorRunId) {
throw new Error(`Task not found: ${params.task}`)
}
let options: string[] | undefined
if (params.options) {
try {
@@ -121,6 +125,23 @@ export const ORCHESTRATION_GATE_METHODS: RpcMethod[] = [
throw new Error('Invalid --options: must be a JSON array of strings')
}
}
const task = db.getTask(params.task)
if (!task) {
throw new Error(`Task not found: ${params.task}`)
}
const run = resolveRunScope(runtime, {
runId: params.run,
callerTerminalHandle: params.from,
requireCurrentConsumer: true,
legacyCoordinatorRunId,
callerEvidence: orchestrationCompatibilityEvidence
})
if (task.run_id !== run.id) {
throw new OrchestrationError(
'task_not_found',
`Task ${params.task} was not found in Run ${run.id}.`
)
}
const gate = db.createGate({
taskId: params.task,
question: params.question,
@@ -133,10 +154,21 @@ export const ORCHESTRATION_GATE_METHODS: RpcMethod[] = [
defineMethod({
name: 'orchestration.gateResolve',
params: GateResolveParams,
handler: (params, { runtime, legacyCoordinatorRunId }) => {
handler: (params, { orchestrationCompatibilityEvidence, runtime, legacyCoordinatorRunId }) => {
const db = runtime.getOrchestrationDb()
const existing = db.getGate(params.id)
if (legacyCoordinatorRunId && existing?.run_id !== legacyCoordinatorRunId) {
if (!existing) {
throw new Error(`Gate not found: ${params.id}`)
}
const run = resolveRunScope(runtime, {
runId: params.run,
callerTerminalHandle: params.from,
requireCurrentConsumer: true,
legacyCoordinatorRunId,
callerEvidence: orchestrationCompatibilityEvidence
})
// Why: a gate outside the caller's Run is indistinguishable from a missing one, so probing cannot map foreign Runs.
if (existing.run_id !== run.id) {
throw new Error(`Gate not found: ${params.id}`)
}
const gate = db.resolveGate(params.id, params.resolution)
@@ -150,13 +182,27 @@ export const ORCHESTRATION_GATE_METHODS: RpcMethod[] = [
defineMethod({
name: 'orchestration.gateList',
params: GateListParams,
handler: (params, { runtime }) => {
handler: (params, { orchestrationCompatibilityEvidence, runtime, legacyCoordinatorRunId }) => {
const db = runtime.getOrchestrationDb()
const gates = db.listGates({
taskId: params.task,
status: params.status as GateStatus
})
return { gates, count: gates.length }
const explicitRun = params.run ? db.getRun(params.run) : undefined
// Why: same read posture as taskList — an explicitly named Run is inspectable, an unnamed one means the caller's own.
const run =
explicitRun?.legacy === 1
? explicitRun
: resolveRunScope(runtime, {
runId: params.run,
callerTerminalHandle: params.from,
requireCurrentConsumer: params.run === undefined,
legacyCoordinatorRunId,
callerEvidence: orchestrationCompatibilityEvidence
})
const gates = db
.listGates({
taskId: params.task,
status: params.status as GateStatus
})
.filter((gate) => gate.run_id === run.id)
return { runId: run.id, gates, count: gates.length }
}
})
]
@@ -0,0 +1,86 @@
import type { OrchestrationCompatibilityEvidence } from '../../../../shared/orchestration-compatibility-evidence'
import { orchestrationSkillRecoveryData } from '../../../../shared/orchestration-rpc-contract'
import { OrchestrationError } from '../../orchestration/orchestration-error'
import type { RunRow } from '../../orchestration/types'
import type { OrcaRuntimeService } from '../../orca-runtime'
export type RunScopeParams = {
runId?: string
callerTerminalHandle?: string
callerPaneKey?: string
requireCurrentConsumer: boolean
legacyCoordinatorRunId?: string
// Why: the caller's declared handle is a user param; this is the attested one to check it against.
callerEvidence?: OrchestrationCompatibilityEvidence
}
// Why: declared handles select mutable Run bindings, so attested callers may only name themselves.
export function assertCallerHandleMatchesEvidence(
runtime: OrcaRuntimeService,
callerTerminalHandle: string,
callerEvidence?: OrchestrationCompatibilityEvidence
): void {
if (!callerEvidence) {
return
}
const attested = runtime.verifyOrchestrationCompatibilityCaller(callerEvidence)
if (attested && attested.terminalHandle !== callerTerminalHandle) {
throw new OrchestrationError(
'consumer_fenced',
`This terminal is attested as ${attested.terminalHandle} and cannot act as ${callerTerminalHandle}.`,
{ effectsApplied: false }
)
}
}
// Why: task and gate mutations must share one Run-binding rule.
export function resolveRunScope(runtime: OrcaRuntimeService, params: RunScopeParams): RunRow {
const db = runtime.getOrchestrationDb()
const explicit = params.runId ? db.getRun(params.runId) : undefined
if (params.runId && (!explicit || explicit.legacy === 1)) {
throw new OrchestrationError('run_not_found', `Run ${params.runId} was not found.`)
}
if (!params.requireCurrentConsumer && explicit) {
return explicit
}
if (!params.callerTerminalHandle) {
throw new OrchestrationError(
'run_required',
'No Run is bound. Use orchestration run-create or run-use first. No effects were applied.',
orchestrationSkillRecoveryData()
)
}
assertCallerHandleMatchesEvidence(runtime, params.callerTerminalHandle, params.callerEvidence)
if (explicit && params.legacyCoordinatorRunId === explicit.id) {
return explicit
}
const paneKey = params.callerPaneKey ?? runtime.getTerminalPaneKey(params.callerTerminalHandle)
if (!paneKey) {
throw new OrchestrationError(
'stable_pane_required',
'The coordinator terminal has no stable pane identity.'
)
}
const current = db.getCurrentRunForPane(paneKey)
if (!current) {
if (explicit) {
throw new OrchestrationError(
'consumer_fenced',
`This coordinator terminal is no longer bound to Run ${explicit.id}.`
)
}
throw new OrchestrationError(
'run_required',
'No Run is bound. Use orchestration run-create or run-use first. No effects were applied.',
orchestrationSkillRecoveryData()
)
}
if (explicit && current.id !== explicit.id) {
throw new OrchestrationError(
'consumer_fenced',
`This coordinator terminal is bound to ${current.id}, not ${explicit.id}.`
)
}
return current
}
@@ -4,6 +4,7 @@ import { OptionalBoolean, OptionalString, requiredString } from '../schemas'
import { ORCHESTRATION_RUN_PAGE_LIMIT } from '../../../../shared/orchestration-run-pagination'
import type { OrcaRuntimeService } from '../../orca-runtime'
import { OrchestrationError } from '../../orchestration/orchestration-error'
import { assertCallerHandleMatchesEvidence } from './orchestration-run-scope'
const RunCreateParams = z.object({
objective: requiredString('Missing --objective'),
@@ -38,7 +39,8 @@ export const ORCHESTRATION_RUN_METHODS: RpcMethod[] = [
defineMethod({
name: 'orchestration.runCreate',
params: RunCreateParams,
handler: (params, { runtime }) => {
handler: (params, { orchestrationCompatibilityEvidence, runtime }) => {
assertCallerHandleMatchesEvidence(runtime, params.from, orchestrationCompatibilityEvidence)
const paneKey = requireCallerPane(runtime, params.from)
const db = runtime.getOrchestrationDb()
const priorRun = db.getCurrentRunForPane(paneKey)
@@ -61,6 +63,7 @@ export const ORCHESTRATION_RUN_METHODS: RpcMethod[] = [
{
runtime,
legacyCoordinatorAuthority,
orchestrationCompatibilityEvidence,
orchestrationCompatibilityCallerAuthority: callerAuthority
}
) => {
@@ -75,6 +78,7 @@ export const ORCHESTRATION_RUN_METHODS: RpcMethod[] = [
{ effectsApplied: false }
)
}
assertCallerHandleMatchesEvidence(runtime, params.from, orchestrationCompatibilityEvidence)
const db = runtime.getOrchestrationDb()
const priorRun = db.getCurrentRunForPane(paneKey)
const run = db.bindRun({
@@ -100,7 +104,8 @@ export const ORCHESTRATION_RUN_METHODS: RpcMethod[] = [
defineMethod({
name: 'orchestration.runCurrent',
params: RunCurrentParams,
handler: (params, { runtime }) => {
handler: (params, { orchestrationCompatibilityEvidence, runtime }) => {
assertCallerHandleMatchesEvidence(runtime, params.from, orchestrationCompatibilityEvidence)
const paneKey = requireCallerPane(runtime, params.from)
return { run: runtime.getOrchestrationDb().getCurrentRunForPane(paneKey) ?? null }
}
@@ -83,6 +83,13 @@ describe('orchestration RPC methods', () => {
} else if (name === 'orchestration.dispatch') {
scopedParams.run ??= activeRunId
scopedParams.from ??= 'term_coord'
} else if (
name === 'orchestration.gateCreate' ||
name === 'orchestration.gateResolve' ||
name === 'orchestration.gateList'
) {
// Why: gates resolve their Run from the sender's pane binding, so naming the run would skip that check.
scopedParams.from ??= 'term_coord'
}
}
const parsed = method.params ? method.params.parse(scopedParams) : undefined
+30 -70
View File
@@ -20,6 +20,7 @@ import {
} from '../../../../shared/orchestration-rpc-contract'
import { clampOrchestrationAskTimeoutMs } from '../../../../shared/orchestration-ask-timeout'
import { ORCHESTRATION_GATE_METHODS } from './orchestration-gates'
import { resolveRunScope } from './orchestration-run-scope'
import { ORCHESTRATION_RUN_METHODS } from './orchestration-runs'
import { ORCHESTRATION_WORKER_METHODS } from './orchestration-worker-methods'
import { ORCHESTRATION_FEDERATION_METHODS } from './orchestration-federation-methods'
@@ -248,65 +249,6 @@ const ResetParams = z
}
})
function resolveRunScope(
runtime: OrcaRuntimeService,
params: {
runId?: string
callerTerminalHandle?: string
callerPaneKey?: string
requireCurrentConsumer: boolean
legacyCoordinatorRunId?: string
}
): RunRow {
const db = runtime.getOrchestrationDb()
const explicit = params.runId ? db.getRun(params.runId) : undefined
if (params.runId && (!explicit || explicit.legacy === 1)) {
throw new OrchestrationError('run_not_found', `Run ${params.runId} was not found.`)
}
if (!params.requireCurrentConsumer && explicit) {
return explicit
}
if (explicit && params.legacyCoordinatorRunId === explicit.id) {
return explicit
}
if (!params.callerTerminalHandle) {
throw new OrchestrationError(
'run_required',
'No Run is bound. Use orchestration run-create or run-use first. No effects were applied.',
orchestrationSkillRecoveryData()
)
}
const paneKey = params.callerPaneKey ?? runtime.getTerminalPaneKey(params.callerTerminalHandle)
if (!paneKey) {
throw new OrchestrationError(
'stable_pane_required',
'The coordinator terminal has no stable pane identity.'
)
}
const current = db.getCurrentRunForPane(paneKey)
if (!current) {
if (explicit) {
throw new OrchestrationError(
'consumer_fenced',
`This coordinator terminal is no longer bound to Run ${explicit.id}.`
)
}
throw new OrchestrationError(
'run_required',
'No Run is bound. Use orchestration run-create or run-use first. No effects were applied.',
orchestrationSkillRecoveryData()
)
}
if (explicit && current.id !== explicit.id) {
throw new OrchestrationError(
'consumer_fenced',
`This coordinator terminal is bound to ${current.id}, not ${explicit.id}.`
)
}
return current
}
function parseMessageTypes(rawTypes: string | undefined): MessageType[] | undefined {
const types = rawTypes
?.split(',')
@@ -722,6 +664,7 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
handler: async (
params,
{
orchestrationCompatibilityEvidence,
runtime,
signal,
legacyCoordinatorRunId,
@@ -742,7 +685,8 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
callerTerminalHandle: handle,
callerPaneKey: paneKey ?? undefined,
requireCurrentConsumer: true,
legacyCoordinatorRunId
legacyCoordinatorRunId,
callerEvidence: orchestrationCompatibilityEvidence
})
const generation = run.consumer_generation
const address = `run:${run.id}`
@@ -1026,7 +970,10 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
defineMethod({
name: 'orchestration.reply',
params: ReplyParams,
handler: async (params, { runtime, legacyCoordinatorRunId }) => {
handler: async (
params,
{ orchestrationCompatibilityEvidence, runtime, legacyCoordinatorRunId }
) => {
const db = runtime.getOrchestrationDb()
const original = db.getMessageById(params.id)
if (!original) {
@@ -1061,7 +1008,8 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
runId: params.run ?? question.run_id,
callerTerminalHandle: params.from,
requireCurrentConsumer: true,
legacyCoordinatorRunId
legacyCoordinatorRunId,
callerEvidence: orchestrationCompatibilityEvidence
})
const answered = db.answerQuestion({
messageId: question.message_id,
@@ -1124,7 +1072,7 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
defineMethod({
name: 'orchestration.taskCreate',
params: TaskCreateParams,
handler: (params, { runtime, legacyCoordinatorRunId }) => {
handler: (params, { orchestrationCompatibilityEvidence, runtime, legacyCoordinatorRunId }) => {
const db = runtime.getOrchestrationDb()
let deps: string[] | undefined
if (params.deps) {
@@ -1149,7 +1097,8 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
runId: params.run,
callerTerminalHandle: params.callerTerminalHandle,
requireCurrentConsumer: true,
legacyCoordinatorRunId
legacyCoordinatorRunId,
callerEvidence: orchestrationCompatibilityEvidence
}).id
})
return { task }
@@ -1159,7 +1108,7 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
defineMethod({
name: 'orchestration.taskList',
params: TaskListParams,
handler: (params, { runtime, legacyCoordinatorRunId }) => {
handler: (params, { orchestrationCompatibilityEvidence, runtime, legacyCoordinatorRunId }) => {
const db = runtime.getOrchestrationDb()
const explicitRun = params.run ? db.getRun(params.run) : undefined
const run =
@@ -1169,7 +1118,8 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
runId: params.run,
callerTerminalHandle: params.callerTerminalHandle,
requireCurrentConsumer: params.run === undefined,
legacyCoordinatorRunId
legacyCoordinatorRunId,
callerEvidence: orchestrationCompatibilityEvidence
})
// Why: listTasksWithDispatch adds assignee_handle + dispatch_id (NULL for non-dispatched), so legacy-shape consumers are unaffected.
const joined = db.listTasksWithDispatch({
@@ -1196,13 +1146,14 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
defineMethod({
name: 'orchestration.taskUpdate',
params: TaskUpdateParams,
handler: (params, { runtime, legacyCoordinatorRunId }) => {
handler: (params, { orchestrationCompatibilityEvidence, runtime, legacyCoordinatorRunId }) => {
const db = runtime.getOrchestrationDb()
const run = resolveRunScope(runtime, {
runId: params.run,
callerTerminalHandle: params.callerTerminalHandle,
requireCurrentConsumer: true,
legacyCoordinatorRunId
legacyCoordinatorRunId,
callerEvidence: orchestrationCompatibilityEvidence
})
const existing = db.getTask(params.id)
if (!existing || existing.run_id !== run.id) {
@@ -1222,7 +1173,15 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
defineMethod({
name: 'orchestration.dispatch',
params: DispatchParams,
handler: async (params, { runtime, legacyCoordinatorRunId, revalidateLegacyCoordinator }) => {
handler: async (
params,
{
orchestrationCompatibilityEvidence,
runtime,
legacyCoordinatorRunId,
revalidateLegacyCoordinator
}
) => {
const db = runtime.getOrchestrationDb()
const task = db.getTask(params.task)
if (!task) {
@@ -1232,7 +1191,8 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
runId: params.run,
callerTerminalHandle: params.from,
requireCurrentConsumer: true,
legacyCoordinatorRunId
legacyCoordinatorRunId,
callerEvidence: orchestrationCompatibilityEvidence
})
if (task.run_id !== run.id) {
throw new OrchestrationError(
@@ -0,0 +1,820 @@
// Why: independent adversarial verification of every #11745 fix. Each case reproduces the ORIGINAL
// defect and asserts it is closed by observing state, not just the response envelope — a refusal
// that still mutated the graph is a failure, and so is a fix that fences a legitimate coordinator.
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import type { OrchestrationCompatibilityEvidence } from '../../../shared/orchestration-compatibility-evidence'
import SyncDatabase from '../../sqlite/sync-database'
import { OrchestrationDb } from '../orchestration/db'
import {
cleanupLegacyCompatibilityDispatcherHarnesses,
COORDINATOR_HANDLE,
counts,
createHarness,
currentEvidence,
CURRENT_COORDINATOR_HANDLE,
CURRENT_COORDINATOR_PANE,
CURRENT_WORKER_HANDLE,
CURRENT_WORKER_PANE,
evidence,
invoke,
request,
type LegacyCompatibilityDispatcherHarness
} from './orchestration-legacy-compatibility-dispatcher-test-fixture'
// Why: an unrelated caller the runtime CAN resolve to a pane — otherwise the refusal would be
// stable_pane_required and would prove nothing about Run authorization.
const OUTSIDER_HANDLE = CURRENT_WORKER_HANDLE
const OUTSIDER_PANE = CURRENT_WORKER_PANE
const tempDirs: string[] = []
const databases: OrchestrationDb[] = []
afterEach(() => {
cleanupLegacyCompatibilityDispatcherHarnesses()
for (const database of databases.splice(0)) {
database.close()
}
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true })
}
})
function unattested(proof: OrchestrationCompatibilityEvidence): OrchestrationCompatibilityEvidence {
return { ...proof, launchToken: '' }
}
/** Every observable the adopted Run's recovered graph exposes; a refusal must leave all of it equal. */
function adoptedGraph(harness: LegacyCompatibilityDispatcherHarness) {
return {
task: harness.db.getTask(harness.taskId)?.status,
dispatch: harness.db.getDispatchContextById(harness.dispatchId)?.status,
gates: harness.db.listGates({ taskId: harness.taskId }).length,
tasksInRun: harness.db.listTasks({ runId: harness.adoptedRunId }).length,
run: harness.db.getRun(harness.adoptedRunId)?.coordinator_handle ?? null,
tables: counts(harness.db)
}
}
function bindRunA(harness: LegacyCompatibilityDispatcherHarness): string {
return harness.db.createRun({
objective: 'run A work',
coordinatorHandle: CURRENT_COORDINATOR_HANDLE,
coordinatorPaneKey: CURRENT_COORDINATOR_PANE
}).id
}
async function legacyClaimsAdoptedRun(
harness: LegacyCompatibilityDispatcherHarness,
id: string
): Promise<void> {
const response = await harness.dispatcher.dispatch(
request(
'orchestration.runUse',
{ id: harness.adoptedRunId, from: COORDINATOR_HANDLE },
evidence('coordinator'),
`${id}-legacy-claim`
)
)
expect(response).toMatchObject({ ok: true })
}
async function currentCoordinatorTakesOver(
harness: LegacyCompatibilityDispatcherHarness,
id: string
): Promise<void> {
const response = await harness.dispatcher.dispatch(
request(
'orchestration.runUse',
{ id: harness.adoptedRunId, from: CURRENT_COORDINATOR_HANDLE, takeoverLegacy: true },
currentEvidence('coordinator'),
`${id}-takeover`
)
)
expect(response).toMatchObject({ ok: true })
}
describe('#11745 H1 — gate methods authorize the caller Run', () => {
it.each(['dispatch', 'websocket'] as const)(
'G1 %s leaves the recovered task and its dispatch untouched for an unbound attested caller',
async (transport) => {
const harness = createHarness()
const before = adoptedGraph(harness)
const response = await invoke(
harness.dispatcher,
request(
'orchestration.gateCreate',
{ task: harness.taskId, question: 'Proceed?' },
currentEvidence('coordinator'),
`v-g1-${transport}`
),
transport
)
expect(response).toMatchObject({ ok: false, error: { code: 'run_required' } })
expect(adoptedGraph(harness)).toEqual(before)
// The recovered graph really is live work, so "unchanged" is a meaningful assertion.
expect(before.task).toBe('dispatched')
expect(before.gates).toBe(0)
}
)
it('G2 leaves the adopted Run untouched for an unattested caller bound to Run A', async () => {
const harness = createHarness()
bindRunA(harness)
const before = adoptedGraph(harness)
const response = await harness.dispatcher.dispatch(
request(
'orchestration.gateCreate',
{ task: harness.taskId, question: 'Proceed?', from: CURRENT_COORDINATOR_HANDLE },
unattested(currentEvidence('coordinator')),
'v-g2'
)
)
expect(response).toMatchObject({ ok: false, error: { code: 'task_not_found' } })
expect(adoptedGraph(harness)).toEqual(before)
})
it('G3 leaves the adopted Run untouched once the legacy coordinator has claimed it', async () => {
const harness = createHarness()
await legacyClaimsAdoptedRun(harness, 'v-g3')
bindRunA(harness)
const before = adoptedGraph(harness)
const response = await harness.dispatcher.dispatch(
request(
'orchestration.gateCreate',
{ task: harness.taskId, question: 'Proceed?', from: CURRENT_COORDINATOR_HANDLE },
unattested(currentEvidence('coordinator')),
'v-g3-gate'
)
)
expect(response).toMatchObject({ ok: false })
expect(adoptedGraph(harness)).toEqual(before)
})
it('G5 leaves a foreign gate pending and its task blocked', async () => {
const harness = createHarness()
bindRunA(harness)
const gate = harness.db.createGate({ taskId: harness.taskId, question: 'Proceed?' })
const before = adoptedGraph(harness)
const response = await harness.dispatcher.dispatch(
request(
'orchestration.gateResolve',
{ id: gate.id, resolution: 'yes', from: CURRENT_COORDINATOR_HANDLE },
unattested(currentEvidence('coordinator')),
'v-g5'
)
)
expect(response).toMatchObject({ ok: false, error: { message: `Gate not found: ${gate.id}` } })
expect(harness.db.getGate(gate.id)).toMatchObject({ status: 'pending', resolution: null })
expect(adoptedGraph(harness)).toEqual(before)
expect(before.task).toBe('blocked')
})
it('G4 refuses a stranger Run with NO legacy adoption in play', () => {
// Why: G4 must hold on a plain runtime, so the fix cannot be an artifact of adoption state.
const dir = mkdtempSync(join(tmpdir(), 'orca-11745-g4-'))
tempDirs.push(dir)
const db = new OrchestrationDb(join(dir, 'orchestration.db'))
databases.push(db)
expect(db.getLegacyAdoption()).toBeUndefined()
const runA = db.createRun({
objective: 'run A',
coordinatorHandle: CURRENT_COORDINATOR_HANDLE,
coordinatorPaneKey: CURRENT_COORDINATOR_PANE
})
const runC = db.createRun({
objective: 'run C',
coordinatorHandle: OUTSIDER_HANDLE,
coordinatorPaneKey: OUTSIDER_PANE
})
const strangerTask = db.createTask({ spec: 'stranger work', runId: runC.id })
// The Run-scope decision the gate handler makes: A's pane resolves to A, never to C.
expect(db.getCurrentRunForPane(CURRENT_COORDINATOR_PANE)?.id).toBe(runA.id)
expect(strangerTask.run_id).toBe(runC.id)
expect(db.listGates({ taskId: strangerTask.id })).toHaveLength(0)
expect(db.getTask(strangerTask.id)?.status).toBe('ready')
})
it('POSITIVE: a legitimately bound coordinator still gates its own Run end to end', async () => {
const harness = createHarness()
const runA = bindRunA(harness)
const ownTask = harness.db.createTask({ spec: 'own work', runId: runA })
const created = (await harness.dispatcher.dispatch(
request(
'orchestration.gateCreate',
{
task: ownTask.id,
question: 'Ship?',
options: JSON.stringify(['yes', 'no']),
from: CURRENT_COORDINATOR_HANDLE
},
currentEvidence('coordinator'),
'v-positive-create'
)
)) as { ok: true; result: { gate: { id: string; run_id: string } } }
expect(created).toMatchObject({ ok: true, result: { gate: { run_id: runA } } })
expect(harness.db.getTask(ownTask.id)?.status).toBe('blocked')
const listed = await harness.dispatcher.dispatch(
request(
'orchestration.gateList',
{ from: CURRENT_COORDINATOR_HANDLE },
currentEvidence('coordinator'),
'v-positive-list'
)
)
expect(listed).toMatchObject({ ok: true, result: { runId: runA, count: 1 } })
const resolved = await harness.dispatcher.dispatch(
request(
'orchestration.gateResolve',
{ id: created.result.gate.id, resolution: 'yes', from: CURRENT_COORDINATOR_HANDLE },
currentEvidence('coordinator'),
'v-positive-resolve'
)
)
expect(resolved).toMatchObject({ ok: true, result: { gate: { status: 'resolved' } } })
expect(harness.db.getTask(ownTask.id)?.status).toBe('ready')
})
it('POSITIVE: the proven legacy coordinator still gates the adopted Run task', async () => {
const harness = createHarness()
const created = (await harness.dispatcher.dispatch(
request(
'orchestration.gateCreate',
{ task: harness.taskId, question: 'Proceed?', from: COORDINATOR_HANDLE },
evidence('coordinator'),
'v-legacy-create'
)
)) as { ok: true; result: { gate: { id: string; run_id: string } } }
expect(created).toMatchObject({
ok: true,
result: { gate: { run_id: harness.adoptedRunId, task_id: harness.taskId } }
})
expect(harness.db.getTask(harness.taskId)?.status).toBe('blocked')
const resolved = await harness.dispatcher.dispatch(
request(
'orchestration.gateResolve',
{ id: created.result.gate.id, resolution: 'go', from: COORDINATOR_HANDLE },
evidence('coordinator'),
'v-legacy-resolve'
)
)
expect(resolved).toMatchObject({ ok: true, result: { gate: { status: 'resolved' } } })
expect(harness.db.getTask(harness.taskId)?.status).toBe('ready')
})
})
describe('#11745 H1 — is --from an authenticated identity?', () => {
// Why: Run scoping authorizes on a declared handle, so an attested caller naming someone else's
// handle must be refused. Both cases run through the shared resolveRunScope, so they also pin
// that the gate path and the older task path cannot diverge again.
it('refuses an attested caller naming ANOTHER coordinator handle in --from', async () => {
const harness = createHarness()
const runA = bindRunA(harness)
const victimTask = harness.db.createTask({ spec: 'victim work', runId: runA })
// Caller is the current WORKER — attested as itself, bound to no Run — but names the
// coordinator's handle. Nothing on this request proves it may speak for that handle.
const response = await harness.dispatcher.dispatch(
request(
'orchestration.gateCreate',
{ task: victimTask.id, question: 'spoofed?', from: CURRENT_COORDINATOR_HANDLE },
currentEvidence('worker'),
'v-spoof-gate-create'
)
)
expect(response).toMatchObject({ ok: false, error: { code: 'consumer_fenced' } })
expect(harness.db.getTask(victimTask.id)?.status).not.toBe('blocked')
expect(harness.db.listGates({ taskId: victimTask.id })).toHaveLength(0)
})
it('refuses the same spoof against taskCreate, which predates this change', async () => {
const harness = createHarness()
const runA = bindRunA(harness)
const response = await harness.dispatcher.dispatch(
request(
'orchestration.taskCreate',
{ spec: 'spoofed assignment', callerTerminalHandle: CURRENT_COORDINATOR_HANDLE },
currentEvidence('worker'),
'v-spoof-task-create'
)
)
// Why: the weakness was inherited from this older path, so the fix has to cover it too.
expect(response).toMatchObject({ ok: false, error: { code: 'consumer_fenced' } })
expect(harness.db.listTasks({ runId: runA })).toHaveLength(0)
})
it('refuses an attested caller creating a Run for another terminal', async () => {
const harness = createHarness()
const runA = bindRunA(harness)
const before = harness.db.listRuns().runs
const response = await harness.dispatcher.dispatch(
request(
'orchestration.runCreate',
{ objective: 'spoofed Run', from: CURRENT_COORDINATOR_HANDLE },
currentEvidence('worker'),
'v-spoof-run-create'
)
)
expect(response).toMatchObject({ ok: false, error: { code: 'consumer_fenced' } })
expect(harness.db.listRuns().runs).toEqual(before)
expect(harness.db.getCurrentRunForPane(CURRENT_COORDINATOR_PANE)?.id).toBe(runA)
})
it('refuses an attested caller rebinding another terminal with runUse', async () => {
const harness = createHarness()
const runA = bindRunA(harness)
const workerRun = harness.db.createRun({
objective: 'worker Run',
coordinatorHandle: CURRENT_WORKER_HANDLE,
coordinatorPaneKey: CURRENT_WORKER_PANE
})
const beforeA = harness.db.getRun(runA)
const beforeWorker = harness.db.getRun(workerRun.id)
const response = await harness.dispatcher.dispatch(
request(
'orchestration.runUse',
{ id: workerRun.id, from: CURRENT_COORDINATOR_HANDLE },
currentEvidence('worker'),
'v-spoof-run-use'
)
)
expect(response).toMatchObject({ ok: false, error: { code: 'consumer_fenced' } })
expect(harness.db.getRun(runA)).toEqual(beforeA)
expect(harness.db.getRun(workerRun.id)).toEqual(beforeWorker)
})
it('refuses an attested caller querying another terminal with runCurrent', async () => {
const harness = createHarness()
bindRunA(harness)
const response = await harness.dispatcher.dispatch(
request(
'orchestration.runCurrent',
{ from: CURRENT_COORDINATOR_HANDLE },
currentEvidence('worker'),
'v-spoof-run-current'
)
)
expect(response).toMatchObject({ ok: false, error: { code: 'consumer_fenced' } })
})
it('does not let legacy authority bypass a mismatched declared handle', async () => {
const harness = createHarness()
const before = adoptedGraph(harness)
const response = await harness.dispatcher.dispatch(
request(
'orchestration.taskCreate',
{
spec: 'spoofed legacy assignment',
callerTerminalHandle: CURRENT_COORDINATOR_HANDLE
},
evidence('coordinator'),
'v-spoof-legacy-shortcut'
)
)
expect(response).toMatchObject({ ok: false, error: { code: 'consumer_fenced' } })
expect(adoptedGraph(harness)).toEqual(before)
})
})
describe('#11745 H1 — gateList read posture', () => {
it('DOCUMENTS: naming a Run makes its gates readable with no caller identity at all', async () => {
// Why: gateList mirrors taskList (requireCurrentConsumer: params.run === undefined), so an
// explicitly named Run is inspectable by anyone. Reads only — gateCreate/gateResolve always
// require the binding. Pinned here so any future narrowing is a deliberate decision.
const harness = createHarness()
const runA = bindRunA(harness)
const ownTask = harness.db.createTask({ spec: 'own work', runId: runA })
const ownGate = harness.db.createGate({ taskId: ownTask.id, question: 'Own?' })
const response = await harness.dispatcher.dispatch(
request(
'orchestration.gateList',
{ run: runA },
currentEvidence('worker'),
'v-gate-list-named-run'
)
)
expect(response).toMatchObject({
ok: true,
result: { runId: runA, count: 1, gates: [{ id: ownGate.id }] }
})
})
})
describe('#11745 H2 — revoked principal on an unclaimed adopted Run', () => {
it('answers an unbound caller with run_required, not legacy_read_only', async () => {
const harness = createHarness()
await legacyClaimsAdoptedRun(harness, 'v-h2')
await currentCoordinatorTakesOver(harness, 'v-h2')
// The new owner's pane rebinds elsewhere, leaving the adopted Run unclaimed + principal revoked.
harness.db.createRun({
objective: 'elsewhere',
coordinatorHandle: CURRENT_COORDINATOR_HANDLE,
coordinatorPaneKey: CURRENT_COORDINATOR_PANE
})
expect(harness.db.getRun(harness.adoptedRunId)?.coordinator_pane_key).toBeNull()
expect(harness.db.getLegacyCoordinatorPrincipal(harness.adoptedRunId)?.status).toBe('revoked')
const before = adoptedGraph(harness)
const response = (await harness.dispatcher.dispatch(
request(
'orchestration.taskCreate',
{ spec: 'unbound assignment', callerTerminalHandle: OUTSIDER_HANDLE },
currentEvidence('worker'),
'v-h2-unbound'
)
)) as { ok: false; error: { code: string } }
expect(response.ok).toBe(false)
expect(response.error.code).not.toBe('legacy_read_only')
expect(response.error.code).toBe('run_required')
expect(adoptedGraph(harness)).toEqual(before)
})
it('gives the SAME honest code through the gate path', async () => {
const harness = createHarness()
await legacyClaimsAdoptedRun(harness, 'v-h2b')
await currentCoordinatorTakesOver(harness, 'v-h2b')
harness.db.createRun({
objective: 'elsewhere',
coordinatorHandle: CURRENT_COORDINATOR_HANDLE,
coordinatorPaneKey: CURRENT_COORDINATOR_PANE
})
const before = adoptedGraph(harness)
const response = (await harness.dispatcher.dispatch(
request(
'orchestration.gateCreate',
{ task: harness.taskId, question: 'Proceed?', from: OUTSIDER_HANDLE },
currentEvidence('worker'),
'v-h2b-gate'
)
)) as { ok: false; error: { code: string } }
expect(response.ok).toBe(false)
expect(response.error.code).not.toBe('legacy_read_only')
expect(adoptedGraph(harness)).toEqual(before)
})
})
describe('#11745 H3 — adopted Run claimed by the legacy coordinator', () => {
it('answers an unrelated unbound caller with run_required, not legacy_read_only', async () => {
const harness = createHarness()
await legacyClaimsAdoptedRun(harness, 'v-h3')
expect(harness.db.getLegacyCoordinatorPrincipal(harness.adoptedRunId)?.status).toBe('committed')
const before = adoptedGraph(harness)
const response = (await harness.dispatcher.dispatch(
request(
'orchestration.taskCreate',
{ spec: 'fresh assignment', callerTerminalHandle: OUTSIDER_HANDLE },
currentEvidence('worker'),
'v-h3-unbound'
)
)) as { ok: false; error: { code: string } }
expect(response.ok).toBe(false)
expect(response.error.code).not.toBe('legacy_read_only')
expect(response.error.code).toBe('run_required')
expect(adoptedGraph(harness)).toEqual(before)
})
it('still fences the genuinely stale legacy coordinator with legacy_read_only and zero effects', async () => {
const harness = createHarness()
await legacyClaimsAdoptedRun(harness, 'v-h3b')
await currentCoordinatorTakesOver(harness, 'v-h3b')
const before = adoptedGraph(harness)
const response = await harness.dispatcher.dispatch(
request(
'orchestration.taskCreate',
{
spec: 'stale assignment',
run: harness.adoptedRunId,
callerTerminalHandle: COORDINATOR_HANDLE
},
evidence('coordinator'),
'v-h3b-stale'
)
)
expect(response).toMatchObject({
ok: false,
error: { code: 'legacy_read_only', data: { effectsApplied: false } }
})
expect(adoptedGraph(harness)).toEqual(before)
})
it('fences the stale legacy coordinator on the gate path too', async () => {
const harness = createHarness()
await legacyClaimsAdoptedRun(harness, 'v-h3c')
await currentCoordinatorTakesOver(harness, 'v-h3c')
const before = adoptedGraph(harness)
const response = await harness.dispatcher.dispatch(
request(
'orchestration.gateCreate',
{ task: harness.taskId, question: 'Proceed?', from: COORDINATOR_HANDLE },
evidence('coordinator'),
'v-h3c-gate'
)
)
expect(response).toMatchObject({ ok: false })
expect(adoptedGraph(harness)).toEqual(before)
})
})
describe('#11745 H4 — unattested caller on a taken-over adopted Run', () => {
it('DOCUMENTS the accepted behaviour: the binding alone grants authority', async () => {
const harness = createHarness()
await legacyClaimsAdoptedRun(harness, 'v-h4')
await currentCoordinatorTakesOver(harness, 'v-h4')
const response = await harness.dispatcher.dispatch(
request(
'orchestration.taskCreate',
{
spec: 'owner assignment',
run: harness.adoptedRunId,
callerTerminalHandle: CURRENT_COORDINATOR_HANDLE
},
unattested(currentEvidence('coordinator')),
'v-h4-owner'
)
)
expect(response).toMatchObject({
ok: true,
result: { task: { run_id: harness.adoptedRunId } }
})
})
it('refuses the same authority WITHOUT matching the run binding in evidence', async () => {
const harness = createHarness()
await legacyClaimsAdoptedRun(harness, 'v-h4b')
await currentCoordinatorTakesOver(harness, 'v-h4b')
// Why: evidence names a different terminal, so this is an impostor rather than the owner
// ownsRunBinding is meant to admit. The attested handle must win over the declared one.
const before = adoptedGraph(harness)
const response = await harness.dispatcher.dispatch(
request(
'orchestration.taskCreate',
{
spec: 'impostor assignment',
run: harness.adoptedRunId,
callerTerminalHandle: CURRENT_COORDINATOR_HANDLE
},
currentEvidence('worker'),
'v-h4b-impostor'
)
)
expect(response).toMatchObject({ ok: false, error: { code: 'consumer_fenced' } })
expect(adoptedGraph(harness)).toEqual(before)
})
it('an unattested caller that is NOT the owner is still refused', async () => {
const harness = createHarness()
await legacyClaimsAdoptedRun(harness, 'v-h4c')
await currentCoordinatorTakesOver(harness, 'v-h4c')
const before = adoptedGraph(harness)
const response = (await harness.dispatcher.dispatch(
request(
'orchestration.taskCreate',
{
spec: 'outsider assignment',
run: harness.adoptedRunId,
callerTerminalHandle: OUTSIDER_HANDLE
},
unattested(currentEvidence('worker')),
'v-h4c-outsider'
)
)) as { ok: false; error: { code: string } }
expect(response.ok).toBe(false)
expect(adoptedGraph(harness)).toEqual(before)
})
})
/** Builds a pre-cutover graph at schema 18 and reopens it so adoption runs. */
function createAdoptedDb(options: { settleWork: boolean }): {
db: OrchestrationDb
adoptedRunId: string
recoveryMessageId: string
} {
const dir = mkdtempSync(join(tmpdir(), 'orca-11745-m5-'))
tempDirs.push(dir)
const dbPath = join(dir, 'orchestration.db')
const before = new OrchestrationDb(dbPath)
const task = before.createTask({ spec: 'legacy assignment', createdByTerminalHandle: 'term_old' })
before.createDispatchContext(
task.id,
'term_old_worker',
'tab_old:33333333-3333-4333-8333-333333333333'
)
const recovery = before.insertMessage({
from: 'term_old_worker',
to: 'term_old',
subject: 'recovered worker outcome',
type: 'worker_done'
})
before.close()
const raw = new SyncDatabase(dbPath)
if (options.settleWork) {
raw.exec("UPDATE dispatch_contexts SET status = 'completed'")
}
raw.exec(`
DROP INDEX IF EXISTS idx_messages_delivery_contract;
DROP TABLE legacy_mail_receipts;
DROP TABLE legacy_operation_receipts;
DROP TABLE legacy_compatibility_principals;
DROP TABLE legacy_adoptions;
`)
raw.pragma('user_version = 18')
raw.close()
const db = new OrchestrationDb(dbPath)
databases.push(db)
return {
db,
adoptedRunId: db.getLegacyAdoption()?.adopted_run_id as string,
recoveryMessageId: recovery.id
}
}
describe('#11745 M5 — binding an unclaimed adopted Run', () => {
it('binds without --takeover-legacy when no legacy work is live, and keeps legacy mail promoted', () => {
const { db, adoptedRunId, recoveryMessageId } = createAdoptedDb({ settleWork: true })
expect(db.getMessageById(recoveryMessageId)?.run_id).toBe(adoptedRunId)
const run = db.bindRun({
runId: adoptedRunId,
coordinatorHandle: CURRENT_COORDINATOR_HANDLE,
coordinatorPaneKey: CURRENT_COORDINATOR_PANE
})
expect(run?.id).toBe(adoptedRunId)
expect(db.getRun(adoptedRunId)).toMatchObject({
coordinator_handle: CURRENT_COORDINATOR_HANDLE
})
// The recovered mail must still belong to the adopted Run, not be orphaned by the bind.
expect(db.getMessageById(recoveryMessageId)?.run_id).toBe(adoptedRunId)
expect(db.getCurrentRunForPane(CURRENT_COORDINATOR_PANE)?.id).toBe(adoptedRunId)
})
it('still fences the bind while legacy work IS live, with a usable recovery command', () => {
const { db, adoptedRunId, recoveryMessageId } = createAdoptedDb({ settleWork: false })
expect(() =>
db.bindRun({
runId: adoptedRunId,
coordinatorHandle: CURRENT_COORDINATOR_HANDLE,
coordinatorPaneKey: CURRENT_COORDINATOR_PANE
})
).toThrowError(
expect.objectContaining({
code: 'consumer_fenced',
data: {
effectsApplied: false,
recoveryCommand: `orca orchestration run-use --id ${adoptedRunId} --takeover-legacy`
}
})
)
expect(db.getRun(adoptedRunId)?.coordinator_handle).toBeNull()
expect(db.getMessageById(recoveryMessageId)?.run_id).toBe(adoptedRunId)
})
})
describe('#11745 L6 — indexed getCurrentRunForPane keeps pane equivalence', () => {
function paneDb(): OrchestrationDb {
const dir = mkdtempSync(join(tmpdir(), 'orca-11745-l6-'))
tempDirs.push(dir)
const db = new OrchestrationDb(join(dir, 'orchestration.db'))
databases.push(db)
return db
}
function leaf(index: number): string {
const hex = index.toString(16).padStart(12, '0')
return `${hex.slice(0, 8)}-0000-4000-8000-${hex}`
}
it('resolves a reminted tab half by leaf UUID', () => {
const db = paneDb()
const run = db.createRun({
objective: 'reminted',
coordinatorHandle: CURRENT_COORDINATOR_HANDLE,
coordinatorPaneKey: `tab_before:${leaf(1)}`
})
// Break-out remints the tab half; the leaf UUID is the durable identity.
expect(db.getCurrentRunForPane(`tab_after:${leaf(1)}`)?.id).toBe(run.id)
expect(db.getCurrentRunForPane(`tab_before:${leaf(1)}`)?.id).toBe(run.id)
expect(db.getCurrentRunForPane(`tab_after:${leaf(2)}`)).toBeUndefined()
})
it('keeps unparseable pane keys on exact-match semantics', () => {
const db = paneDb()
const noColon = db.createRun({
objective: 'no colon',
coordinatorHandle: 'term_a',
coordinatorPaneKey: 'nocolonpanekey'
})
const twoColons = db.createRun({
objective: 'two colons',
coordinatorHandle: 'term_b',
coordinatorPaneKey: `win:tab:${leaf(3)}`
})
const emptyLeaf = db.createRun({
objective: 'empty leaf',
coordinatorHandle: 'term_c',
coordinatorPaneKey: 'tab_empty:'
})
expect(db.getCurrentRunForPane('nocolonpanekey')?.id).toBe(noColon.id)
expect(db.getCurrentRunForPane(`win:tab:${leaf(3)}`)?.id).toBe(twoColons.id)
expect(db.getCurrentRunForPane('tab_empty:')?.id).toBe(emptyLeaf.id)
// A different first segment on an unparseable key must NOT match, even though the
// indexed suffix is identical — the suffix only narrows, isEquivalentPaneKey decides.
expect(db.getCurrentRunForPane(`other:tab:${leaf(3)}`)).toBeUndefined()
expect(db.getCurrentRunForPane('other_empty:')).toBeUndefined()
// A parseable key must not be satisfied by an unparseable row sharing its suffix.
expect(db.getCurrentRunForPane(`tab:${leaf(3)}`)).toBeUndefined()
})
it('unbinds the equivalent pane when a reminted key binds a new Run', () => {
const db = paneDb()
const first = db.createRun({
objective: 'first',
coordinatorHandle: 'term_a',
coordinatorPaneKey: `tab_one:${leaf(4)}`
})
const second = db.createRun({
objective: 'second',
coordinatorHandle: 'term_a',
coordinatorPaneKey: `tab_two:${leaf(4)}`
})
expect(db.getRun(first.id)?.coordinator_pane_key).toBeNull()
expect(db.getCurrentRunForPane(`tab_three:${leaf(4)}`)?.id).toBe(second.id)
})
it.each([60, 2000])('stays correct and fast at %i bound Runs', (total) => {
const db = paneDb()
for (let index = 0; index < total; index += 1) {
db.createRun({
objective: `run ${index}`,
coordinatorHandle: `term_${index}`,
coordinatorPaneKey: `tab_${index}:${leaf(index + 100)}`
})
}
const target = `tab_reminted:${leaf(total + 99)}`
const started = performance.now()
for (let probe = 0; probe < 200; probe += 1) {
expect(db.getCurrentRunForPane(target)).toBeDefined()
}
const elapsed = performance.now() - started
// Generous ceiling: the pre-fix full-table scan at 2000 Runs was orders of magnitude slower.
expect(elapsed).toBeLessThan(4000)
expect(db.getCurrentRunForPane(`tab_missing:${leaf(999999)}`)).toBeUndefined()
})
})
@@ -0,0 +1,336 @@
import { afterEach, describe, expect, it } from 'vitest'
import {
cleanupLegacyCompatibilityDispatcherHarnesses,
COORDINATOR_HANDLE,
COORDINATOR_PANE,
createHarness,
currentEvidence,
CURRENT_COORDINATOR_HANDLE,
CURRENT_COORDINATOR_PANE,
CURRENT_WORKER_HANDLE,
CURRENT_WORKER_PANE,
evidence,
invoke,
request,
WORKER_HANDLE,
WORKER_PANE
} from './orchestration-legacy-compatibility-dispatcher-test-fixture'
afterEach(() => {
cleanupLegacyCompatibilityDispatcherHarnesses()
})
describe('current orchestration authority precedence', () => {
it.each(['dispatch', 'websocket'] as const)(
'%s uses an attested current coordinator binding before legacy fallback',
async (transport) => {
const harness = createHarness()
const run = harness.db.createRun({
objective: 'current work',
coordinatorHandle: CURRENT_COORDINATOR_HANDLE,
coordinatorPaneKey: CURRENT_COORDINATOR_PANE
})
const response = await invoke(
harness.dispatcher,
request(
'orchestration.taskCreate',
{
spec: 'current assignment',
callerTerminalHandle: CURRENT_COORDINATOR_HANDLE
},
currentEvidence('coordinator'),
`current-task-create-${transport}`
),
transport
)
expect(response).toMatchObject({
ok: true,
result: { task: { run_id: run.id, spec: 'current assignment' } }
})
expect(harness.db.listTasks({ runId: harness.adoptedRunId })).toHaveLength(1)
}
)
it('prefers a different current Run even for the retained coordinator terminal', async () => {
const harness = createHarness()
const run = harness.db.createRun({
objective: 'intentional current work',
coordinatorHandle: COORDINATOR_HANDLE,
coordinatorPaneKey: COORDINATOR_PANE
})
const response = await harness.dispatcher.dispatch(
request(
'orchestration.taskCreate',
{ spec: 'current assignment', callerTerminalHandle: COORDINATOR_HANDLE },
evidence('coordinator'),
'retained-terminal-current-run'
)
)
expect(response).toMatchObject({ ok: true, result: { task: { run_id: run.id } } })
})
it('checks a different current Run before retained coordinator mail', async () => {
const harness = createHarness()
const run = harness.db.createRun({
objective: 'intentional current work',
coordinatorHandle: COORDINATOR_HANDLE,
coordinatorPaneKey: COORDINATOR_PANE
})
const message = harness.db.insertMessage({
runId: run.id,
from: CURRENT_WORKER_HANDLE,
to: `run:${run.id}`,
subject: 'current completion',
deliveryContract: 'current_delivery'
})
const response = await harness.dispatcher.dispatch(
request(
'orchestration.check',
{ terminal: COORDINATOR_HANDLE },
evidence('coordinator'),
'retained-terminal-current-check'
)
)
expect(response).toMatchObject({
ok: true,
result: { runId: run.id, messages: [{ id: message.id }], count: 1 }
})
expect(response).not.toHaveProperty('result.legacyCompatibility')
})
it('preserves legacy fallback for a retained coordinator without a current binding', async () => {
const harness = createHarness()
const response = await harness.dispatcher.dispatch(
request(
'orchestration.taskCreate',
{ spec: 'legacy follow-up', callerTerminalHandle: COORDINATOR_HANDLE },
evidence('coordinator'),
'legacy-task-create'
)
)
expect(response).toMatchObject({
ok: true,
result: { task: { run_id: harness.adoptedRunId, spec: 'legacy follow-up' } }
})
})
it.each([
['heartbeat', undefined],
['worker_done', 'succeeded']
] as const)(
'routes a capability-backed current %s before legacy fallback',
async (type, outcome) => {
const harness = createHarness()
const { taskId, dispatchId, capability } = createCurrentDispatch(harness)
const payload = JSON.stringify({
taskId,
dispatchId,
...(outcome ? { outcome } : {})
})
const response = await harness.dispatcher.dispatch({
...request(
'orchestration.send',
{
from: CURRENT_WORKER_HANDLE,
subject: type === 'heartbeat' ? 'alive' : 'Completed',
type,
payload
},
currentEvidence('worker'),
`current-${type}`
),
orchestrationCapability: capability
})
expect(response).toMatchObject({
ok: true,
result: {
message: {
from_handle: CURRENT_WORKER_HANDLE,
to_handle: expect.stringMatching(/^run:/),
type,
delivery_contract: 'current_delivery'
}
}
})
expect(response).not.toHaveProperty('result.legacyCompatibility')
expect(harness.db.getTask(taskId)?.status).toBe(
type === 'worker_done' ? 'completed' : 'dispatched'
)
}
)
it('routes a current worker mailbox check before legacy fallback', async () => {
const harness = createHarness()
const { runId, dispatchId } = createCurrentDispatch(harness)
const message = harness.db.insertMessage({
runId,
from: CURRENT_COORDINATOR_HANDLE,
to: `dispatch:${dispatchId}`,
subject: 'current guidance',
deliveryContract: 'current_delivery'
})
const response = await harness.dispatcher.dispatch(
request(
'orchestration.check',
{ terminal: CURRENT_WORKER_HANDLE },
currentEvidence('worker'),
'current-worker-check'
)
)
expect(response).toMatchObject({
ok: true,
result: {
runId,
dispatchId,
messages: [{ id: message.id }],
count: 1
}
})
expect(harness.db.getMessageById(message.id)?.read).toBe(1)
})
it.each(['dispatch', 'websocket'] as const)(
'%s routes a reused legacy terminal current check before its retained principal',
async (transport) => {
const harness = createHarness()
const { runId, dispatchId } = await createReusedCurrentDispatch(harness, transport)
const message = harness.db.insertMessage({
runId,
from: CURRENT_COORDINATOR_HANDLE,
to: `dispatch:${dispatchId}`,
subject: 'reused terminal guidance',
deliveryContract: 'current_delivery'
})
const response = await invoke(
harness.dispatcher,
request(
'orchestration.check',
{ terminal: WORKER_HANDLE },
evidence('worker'),
`reused-current-check-${transport}`
),
transport
)
expect(response).toMatchObject({
ok: true,
result: {
runId,
dispatchId,
messages: [{ id: message.id }],
count: 1
}
})
expect(response).not.toHaveProperty('result.legacyCompatibility')
}
)
it.each(['dispatch', 'websocket'] as const)(
'%s routes a reused legacy terminal current ask before its retained principal',
async (transport) => {
const harness = createHarness()
const { dispatchId, capability } = await createReusedCurrentDispatch(harness, transport)
const response = await invoke(
harness.dispatcher,
{
...request(
'orchestration.ask',
{
from: WORKER_HANDLE,
question: 'Continue with the current assignment?',
timeoutMs: 0
},
evidence('worker'),
`reused-current-ask-${transport}`
),
orchestrationCapability: capability
},
transport
)
expect(response).toMatchObject({
ok: true,
result: {
answer: null,
messageId: expect.any(String),
timedOut: true
}
})
expect(response).not.toHaveProperty('result.legacyCompatibility')
const questionId = (response as { result: { messageId: string } }).result.messageId
expect(harness.db.getQuestion(questionId)?.dispatch_id).toBe(dispatchId)
}
)
})
function createCurrentDispatch(harness: ReturnType<typeof createHarness>): {
runId: string
taskId: string
dispatchId: string
capability: string
} {
const run = harness.db.createRun({
objective: 'current work',
coordinatorHandle: CURRENT_COORDINATOR_HANDLE,
coordinatorPaneKey: CURRENT_COORDINATOR_PANE
})
const task = harness.db.createTask({ spec: 'current assignment', runId: run.id })
const dispatch = harness.db.createDispatchContext(
task.id,
CURRENT_WORKER_HANDLE,
CURRENT_WORKER_PANE
)
const capability = harness.db.mintDispatchCapability({
dispatchId: dispatch.id,
paneKey: CURRENT_WORKER_PANE,
processIncarnation: 'process-1'
})
return { runId: run.id, taskId: task.id, dispatchId: dispatch.id, capability }
}
async function createReusedCurrentDispatch(
harness: ReturnType<typeof createHarness>,
suffix: string
): Promise<{
runId: string
taskId: string
dispatchId: string
capability: string
}> {
const legacyCheck = await harness.dispatcher.dispatch(
request(
'orchestration.check',
{ terminal: WORKER_HANDLE, peek: true },
evidence('worker'),
`attest-legacy-worker-${suffix}`
)
)
expect(legacyCheck).toHaveProperty('result.legacyCompatibility')
harness.db.completeDispatch(harness.dispatchId)
const run = harness.db.createRun({
objective: 'current work in reused terminal',
coordinatorHandle: CURRENT_COORDINATOR_HANDLE,
coordinatorPaneKey: CURRENT_COORDINATOR_PANE
})
const task = harness.db.createTask({ spec: 'reused terminal assignment', runId: run.id })
const dispatch = harness.db.createDispatchContext(task.id, WORKER_HANDLE, WORKER_PANE)
const capability = harness.db.mintDispatchCapability({
dispatchId: dispatch.id,
paneKey: WORKER_PANE,
processIncarnation: 'process-1'
})
return { runId: run.id, taskId: task.id, dispatchId: dispatch.id, capability }
}
@@ -16,6 +16,10 @@ export const WORKER_HANDLE = 'term_legacy_worker'
export const WORKER_PANE = 'tab_worker:33333333-3333-4333-8333-333333333333'
export const COORDINATOR_HANDLE = 'term_legacy_coord'
export const COORDINATOR_PANE = 'tab_coord:44444444-4444-4444-8444-444444444444'
export const CURRENT_COORDINATOR_HANDLE = 'term_current_coord'
export const CURRENT_COORDINATOR_PANE = 'tab_current_coord:55555555-5555-4555-8555-555555555555'
export const CURRENT_WORKER_HANDLE = 'term_current_worker'
export const CURRENT_WORKER_PANE = 'tab_current_worker:66666666-6666-4666-8666-666666666666'
type Transport = 'dispatch' | 'websocket'
@@ -71,8 +75,23 @@ export function createHarness(): LegacyCompatibilityDispatcherHarness {
const adoptedRunId = db.getLegacyAdoption()?.adopted_run_id as string
const runtime = new OrcaRuntimeService()
runtime.setOrchestrationDb(db)
vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) =>
handle === COORDINATOR_HANDLE ? COORDINATOR_PANE : handle === WORKER_HANDLE ? WORKER_PANE : null
vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) => {
if (handle === COORDINATOR_HANDLE) {
return COORDINATOR_PANE
}
if (handle === WORKER_HANDLE) {
return WORKER_PANE
}
if (handle === CURRENT_COORDINATOR_HANDLE) {
return CURRENT_COORDINATOR_PANE
}
if (handle === CURRENT_WORKER_HANDLE) {
return CURRENT_WORKER_PANE
}
return null
})
vi.spyOn(runtime, 'getTerminalProcessIncarnation').mockImplementation((handle) =>
[WORKER_HANDLE, CURRENT_WORKER_HANDLE].includes(handle) ? 'process-1' : null
)
const verify = vi
.spyOn(runtime, 'verifyOrchestrationCompatibilityCaller')
@@ -81,7 +100,16 @@ export function createHarness(): LegacyCompatibilityDispatcherHarness {
evidence?.terminalHandle === WORKER_HANDLE && evidence.paneKey === WORKER_PANE
const validCoordinator =
evidence?.terminalHandle === COORDINATOR_HANDLE && evidence.paneKey === COORDINATOR_PANE
if ((!validWorker && !validCoordinator) || !evidence?.launchToken) {
const validCurrentCoordinator =
evidence?.terminalHandle === CURRENT_COORDINATOR_HANDLE &&
evidence.paneKey === CURRENT_COORDINATOR_PANE
const validCurrentWorker =
evidence?.terminalHandle === CURRENT_WORKER_HANDLE &&
evidence.paneKey === CURRENT_WORKER_PANE
if (
(!validWorker && !validCoordinator && !validCurrentCoordinator && !validCurrentWorker) ||
!evidence?.launchToken
) {
return null
}
return {
@@ -117,6 +145,17 @@ export function evidence(
}
}
export function currentEvidence(
role: 'worker' | 'coordinator'
): OrchestrationCompatibilityEvidence {
const worker = role === 'worker'
return {
terminalHandle: worker ? CURRENT_WORKER_HANDLE : CURRENT_COORDINATOR_HANDLE,
paneKey: worker ? CURRENT_WORKER_PANE : CURRENT_COORDINATOR_PANE,
launchToken: `current-${role}-token`
}
}
export function request(
method: string,
params: unknown,
@@ -1,6 +1,7 @@
import { createHash } from 'node:crypto'
import type { LegacyCoordinatorAuthorityProof, RpcRequest } from './core'
import type { OrcaRuntimeService, OrchestrationCompatibilityCallerAuthority } from '../orca-runtime'
import { CURRENT_CONTRACT_VERSION } from '../orchestration/db'
import { LegacyCompatibilityAuthority } from './orchestration-legacy-authority'
import { handleLegacyLifecycleSend } from './orchestration-legacy-lifecycle'
import { handleLegacyCheck, handleLegacyReply } from './orchestration-legacy-mail'
@@ -26,6 +27,11 @@ const COORDINATOR_PREFLIGHT_METHODS = new Set([
'orchestration.reply'
])
const CURRENT_AUTHORITY_PREFLIGHT_METHODS = new Set([
...COORDINATOR_PREFLIGHT_METHODS,
'orchestration.ask'
])
export type LegacyCompatibilityRoute =
| { handled: true; result: unknown }
| {
@@ -58,6 +64,13 @@ export class OrchestrationLegacyCompatibility {
if (!request.method.startsWith('orchestration.')) {
return { handled: false }
}
const values = params as Record<string, unknown>
if (
CURRENT_AUTHORITY_PREFLIGHT_METHODS.has(request.method) &&
this.usesCurrentAuthority(request, values)
) {
return { handled: false }
}
const result = await this.route(request, params, signal)
if (result !== undefined) {
return { handled: true, result }
@@ -65,7 +78,6 @@ export class OrchestrationLegacyCompatibility {
if (!COORDINATOR_PREFLIGHT_METHODS.has(request.method)) {
return { handled: false }
}
const values = params as Record<string, unknown>
if (request.method === 'orchestration.runUse' && values.takeoverLegacy === true) {
const callerAuthority = this.runtime.verifyOrchestrationCompatibilityCaller(
request.orchestrationCompatibilityEvidence
@@ -110,6 +122,75 @@ export class OrchestrationLegacyCompatibility {
: undefined
}
private usesCurrentAuthority(request: RpcRequest, params: Record<string, unknown>): boolean {
const db = this.runtime.getOrchestrationDb()
const adoption = db.getLegacyAdoption()
if (!adoption || stringValue(params.run) || request.method === 'orchestration.runUse') {
return false
}
const evidence = request.orchestrationCompatibilityEvidence
if (!evidence?.terminalHandle || !evidence.paneKey) {
return false
}
const claimedHandle = currentCallerHandle(request.method, params)
if (claimedHandle && claimedHandle !== evidence.terminalHandle) {
return false
}
const dispatch = db.getActiveDispatchForIdentity(evidence.terminalHandle, evidence.paneKey)
if (dispatch) {
if (dispatch.contract_version !== CURRENT_CONTRACT_VERSION) {
return false
}
const caller = this.runtime.verifyOrchestrationCompatibilityCaller(evidence)
return Boolean(
caller &&
caller.terminalHandle === evidence.terminalHandle &&
db.getActiveDispatchForIdentity(caller.terminalHandle, caller.paneKey)?.id === dispatch.id
)
}
const remoteAttachment = db.findActiveRemoteAttachmentForPane(evidence.paneKey)
if (remoteAttachment) {
const caller = this.runtime.verifyOrchestrationCompatibilityCaller(evidence)
return Boolean(
caller &&
caller.terminalHandle === evidence.terminalHandle &&
db.findActiveRemoteAttachmentForPane(caller.paneKey)?.dispatch_id ===
remoteAttachment.dispatch_id
)
}
const boundRun = db.getCurrentRunForPane(evidence.paneKey)
if (!boundRun) {
return false
}
const legacyCandidate =
boundRun.id === adoption.adopted_run_id
? db.resolveLegacyCoordinatorCandidate({
runId: adoption.adopted_run_id,
terminalHandle: evidence.terminalHandle,
paneKey: evidence.paneKey
})
: undefined
if (legacyCandidate) {
return false
}
const caller = this.runtime.verifyOrchestrationCompatibilityCaller(evidence)
if (
!caller ||
caller.terminalHandle !== evidence.terminalHandle ||
db.getCurrentRunForPane(caller.paneKey)?.id !== boundRun.id
) {
return false
}
if (boundRun.id !== adoption.adopted_run_id) {
return true
}
return !db.resolveLegacyCoordinatorCandidate({
runId: adoption.adopted_run_id,
terminalHandle: caller.terminalHandle,
paneKey: caller.paneKey
})
}
private async route(
request: RpcRequest,
params: unknown,
@@ -168,3 +249,17 @@ function legacyCoordinatorMutationCallerFingerprint(
function stringValue(value: unknown): string | undefined {
return typeof value === 'string' && value.length > 0 ? value : undefined
}
function currentCallerHandle(method: string, params: Record<string, unknown>): string | undefined {
if (
method === 'orchestration.taskCreate' ||
method === 'orchestration.taskList' ||
method === 'orchestration.taskUpdate'
) {
return stringValue(params.callerTerminalHandle)
}
if (method === 'orchestration.check') {
return stringValue(params.terminal)
}
return stringValue(params.from)
}
@@ -1,4 +1,5 @@
import type { OrcaRuntimeService, OrchestrationCompatibilityCallerAuthority } from '../orca-runtime'
import type { OrchestrationDb } from '../orchestration/db'
import type { LegacyCompatibilityPrincipalRow } from '../orchestration/types'
import type { LegacyCoordinatorAuthorityProof, RpcRequest } from './core'
import {
@@ -20,8 +21,12 @@ export class LegacyCoordinatorAuthority {
): LegacyCoordinatorAuthorityProof | undefined {
const db = this.runtime.getOrchestrationDb()
const adoption = db.getLegacyAdoption()
const requestedRun = requestedRunId ?? adoption?.adopted_run_id
if (!adoption || requestedRun !== adoption.adopted_run_id) {
if (!adoption) {
return undefined
}
// Why: an unnamed Run means the caller's own binding; only an unbound caller can still mean the adopted Run.
const requestedRun = requestedRunId ?? boundRunId(db, request) ?? adoption.adopted_run_id
if (requestedRun !== adoption.adopted_run_id) {
return undefined
}
const candidate = db.resolveLegacyCoordinatorCandidate({
@@ -30,22 +35,30 @@ export class LegacyCoordinatorAuthority {
paneKey: request.orchestrationCompatibilityEvidence?.paneKey
})
if (!candidate) {
if (request.orchestrationCompatibilityEvidence) {
const caller = this.runtime.verifyOrchestrationCompatibilityCaller(
request.orchestrationCompatibilityEvidence
)
const run = db.getRun(adoption.adopted_run_id)
if (
caller &&
run?.coordinator_handle === caller.terminalHandle &&
run.coordinator_pane_key &&
equivalentLegacyPaneKey(run.coordinator_pane_key, caller.paneKey)
) {
return undefined
}
throw legacyCoordinatorReadOnly()
const evidence = request.orchestrationCompatibilityEvidence
if (!evidence) {
return undefined
}
return undefined
const run = db.getRun(adoption.adopted_run_id)
const principal = db.getLegacyCoordinatorPrincipal(adoption.adopted_run_id)
// Why: an unclaimed adopted Run has no coordinator to fence, and a revoked principal is exactly that.
if (!run?.coordinator_pane_key && principal?.status !== 'committed') {
return undefined
}
const caller = this.runtime.verifyOrchestrationCompatibilityCaller(evidence)
// Why: the binding is trusted DB state, so the owner keeps its Run on hosts where attestation is unavailable.
if (run && (ownsRunBinding(run, caller) || ownsRunBinding(run, evidence))) {
return undefined
}
// Why: only a caller already known as the legacy coordinator is in this fence's jurisdiction;
// fencing anyone else hides the honest downstream error behind unusable takeover guidance.
if (
!evidence.terminalHandle ||
!db.isLegacyCoordinatorHandle(adoption.adopted_run_id, evidence.terminalHandle)
) {
return undefined
}
throw legacyCoordinatorReadOnly()
}
const existing = db.getLegacyCoordinatorPrincipal(adoption.adopted_run_id)
const proofCandidate = existing
@@ -168,3 +181,20 @@ export class LegacyCoordinatorAuthority {
)
}
}
function ownsRunBinding(
run: { coordinator_handle: string | null; coordinator_pane_key: string | null },
identity: { terminalHandle?: string; paneKey?: string } | null
): boolean {
return Boolean(
identity?.terminalHandle &&
run.coordinator_pane_key &&
run.coordinator_handle === identity.terminalHandle &&
equivalentLegacyPaneKey(run.coordinator_pane_key, identity.paneKey)
)
}
function boundRunId(db: OrchestrationDb, request: RpcRequest): string | undefined {
const paneKey = request.orchestrationCompatibilityEvidence?.paneKey
return paneKey ? db.getCurrentRunForPane(paneKey)?.id : undefined
}
@@ -0,0 +1,188 @@
import { afterEach, describe, expect, it } from 'vitest'
import type { OrchestrationCompatibilityEvidence } from '../../../shared/orchestration-compatibility-evidence'
import {
cleanupLegacyCompatibilityDispatcherHarnesses,
COORDINATOR_HANDLE,
createHarness,
currentEvidence,
CURRENT_COORDINATOR_HANDLE,
CURRENT_COORDINATOR_PANE,
evidence,
request,
type LegacyCompatibilityDispatcherHarness
} from './orchestration-legacy-compatibility-dispatcher-test-fixture'
afterEach(() => {
cleanupLegacyCompatibilityDispatcherHarnesses()
})
function unattested(proof: OrchestrationCompatibilityEvidence): OrchestrationCompatibilityEvidence {
return { ...proof, launchToken: '' }
}
async function claimAdoptedRunAsLegacyCoordinator(
harness: LegacyCompatibilityDispatcherHarness
): Promise<void> {
const response = await harness.dispatcher.dispatch(
request(
'orchestration.runUse',
{ id: harness.adoptedRunId, from: COORDINATOR_HANDLE },
evidence('coordinator'),
'legacy-coordinator-run-use'
)
)
expect(response).toMatchObject({ ok: true })
expect(harness.db.getLegacyCoordinatorPrincipal(harness.adoptedRunId)?.status).toBe('committed')
}
async function takeoverAdoptedRun(harness: LegacyCompatibilityDispatcherHarness): Promise<void> {
const response = await harness.dispatcher.dispatch(
request(
'orchestration.runUse',
{ id: harness.adoptedRunId, from: CURRENT_COORDINATOR_HANDLE, takeoverLegacy: true },
currentEvidence('coordinator'),
'current-coordinator-takeover'
)
)
expect(response).toMatchObject({ ok: true })
expect(harness.db.getLegacyCoordinatorPrincipal(harness.adoptedRunId)?.status).toBe('revoked')
}
describe('legacy coordinator fence jurisdiction', () => {
it('declines jurisdiction over an unbound stranger while the adopted Run is claimed', async () => {
const harness = createHarness()
await claimAdoptedRunAsLegacyCoordinator(harness)
const response = await harness.dispatcher.dispatch(
request(
'orchestration.taskCreate',
{ spec: 'fresh assignment', callerTerminalHandle: CURRENT_COORDINATOR_HANDLE },
currentEvidence('coordinator'),
'claimed-stranger-task-create'
)
)
expect(response).toMatchObject({ ok: false, error: { code: 'run_required' } })
expect(harness.db.listTasks({ runId: harness.adoptedRunId })).toHaveLength(1)
})
it('releases the fence once the adopted Run is unclaimed and its principal is revoked', async () => {
const harness = createHarness()
await claimAdoptedRunAsLegacyCoordinator(harness)
await takeoverAdoptedRun(harness)
// The new owner moves its pane to another Run, which unbinds the adopted Run.
const elsewhere = harness.db.createRun({
objective: 'other work',
coordinatorHandle: CURRENT_COORDINATOR_HANDLE,
coordinatorPaneKey: CURRENT_COORDINATOR_PANE
})
expect(elsewhere.id).not.toBe(harness.adoptedRunId)
expect(harness.db.getRun(harness.adoptedRunId)?.coordinator_pane_key).toBeNull()
const fenced = await harness.dispatcher.dispatch(
request(
'orchestration.taskCreate',
{ spec: 'unclaimed assignment', callerTerminalHandle: COORDINATOR_HANDLE },
evidence('coordinator'),
'unclaimed-revoked-task-create'
)
)
expect(fenced).toMatchObject({ ok: false, error: { code: 'run_required' } })
const reclaim = await harness.dispatcher.dispatch(
request(
'orchestration.runUse',
{ id: harness.adoptedRunId, from: COORDINATOR_HANDLE },
evidence('coordinator'),
'unclaimed-revoked-run-use'
)
)
// The Run still holds live legacy work, so the honest downstream error — not a dead-end fence — answers.
expect(reclaim).toMatchObject({
ok: false,
error: {
code: 'consumer_fenced',
data: {
recoveryCommand: `orca orchestration run-use --id ${harness.adoptedRunId} --takeover-legacy`
}
}
})
const recovered = await harness.dispatcher.dispatch(
request(
'orchestration.runUse',
{ id: harness.adoptedRunId, from: COORDINATOR_HANDLE, takeoverLegacy: true },
evidence('coordinator'),
'unclaimed-revoked-run-takeover'
)
)
expect(recovered).toMatchObject({ ok: true, result: { run: { id: harness.adoptedRunId } } })
})
it('leaves a named adopted Run to the downstream consumer check instead of the fence', async () => {
const harness = createHarness()
await claimAdoptedRunAsLegacyCoordinator(harness)
const response = await harness.dispatcher.dispatch(
request(
'orchestration.taskCreate',
{
spec: 'stranger assignment',
run: harness.adoptedRunId,
callerTerminalHandle: CURRENT_COORDINATOR_HANDLE
},
currentEvidence('coordinator'),
'claimed-stranger-named-run-task-create'
)
)
expect(response).toMatchObject({ ok: false, error: { code: 'consumer_fenced' } })
expect(harness.db.listTasks({ runId: harness.adoptedRunId })).toHaveLength(1)
})
it('still fences the legacy coordinator after its principal is revoked by a takeover', async () => {
const harness = createHarness()
await claimAdoptedRunAsLegacyCoordinator(harness)
await takeoverAdoptedRun(harness)
const response = await harness.dispatcher.dispatch(
request(
'orchestration.taskCreate',
{
spec: 'stale assignment',
run: harness.adoptedRunId,
callerTerminalHandle: COORDINATOR_HANDLE
},
evidence('coordinator'),
'revoked-legacy-task-create'
)
)
expect(response).toMatchObject({ ok: false, error: { code: 'legacy_read_only' } })
expect(harness.db.listTasks({ runId: harness.adoptedRunId })).toHaveLength(1)
})
it('lets the post-takeover owner operate the adopted Run without attestation', async () => {
const harness = createHarness()
await claimAdoptedRunAsLegacyCoordinator(harness)
await takeoverAdoptedRun(harness)
const response = await harness.dispatcher.dispatch(
request(
'orchestration.taskCreate',
{
spec: 'owner assignment',
run: harness.adoptedRunId,
callerTerminalHandle: CURRENT_COORDINATOR_HANDLE
},
unattested(currentEvidence('coordinator')),
'owner-unattested-task-create'
)
)
expect(response).toMatchObject({
ok: true,
result: { task: { run_id: harness.adoptedRunId, spec: 'owner assignment' } }
})
})
})
@@ -0,0 +1,146 @@
import { afterEach, describe, expect, it } from 'vitest'
import {
cleanupLegacyCompatibilityDispatcherHarnesses,
COORDINATOR_HANDLE,
createHarness,
currentEvidence,
CURRENT_COORDINATOR_HANDLE,
CURRENT_COORDINATOR_PANE,
evidence,
invoke,
request
} from './orchestration-legacy-compatibility-dispatcher-test-fixture'
afterEach(() => {
cleanupLegacyCompatibilityDispatcherHarnesses()
})
describe('legacy coordinator gate run routing', () => {
it.each(['dispatch', 'websocket'] as const)(
'%s asks an unbound caller to bind a Run instead of fencing it as a legacy coordinator',
async (transport) => {
const harness = createHarness()
const response = await invoke(
harness.dispatcher,
request(
'orchestration.taskCreate',
{
spec: 'fresh assignment',
callerTerminalHandle: CURRENT_COORDINATOR_HANDLE
},
currentEvidence('coordinator'),
`unbound-task-create-${transport}`
),
transport
)
expect(response).toMatchObject({
ok: false,
error: { code: 'run_required' }
})
expect(harness.db.listTasks({ runId: harness.adoptedRunId })).toHaveLength(1)
}
)
it('routes an unnamed Run to the caller binding even when attestation fails', async () => {
const harness = createHarness()
const run = harness.db.createRun({
objective: 'current work',
coordinatorHandle: CURRENT_COORDINATOR_HANDLE,
coordinatorPaneKey: CURRENT_COORDINATOR_PANE
})
const response = await harness.dispatcher.dispatch(
request(
'orchestration.taskCreate',
{
spec: 'fresh assignment',
callerTerminalHandle: CURRENT_COORDINATOR_HANDLE
},
{ ...currentEvidence('coordinator'), launchToken: '' },
'unattested-bound-task-create'
)
)
expect(response).toMatchObject({
ok: true,
result: { task: { run_id: run.id, spec: 'fresh assignment' } }
})
})
it('lets a current coordinator rebind the unclaimed adopted Run with actionable guidance', async () => {
const harness = createHarness()
const response = await harness.dispatcher.dispatch(
request(
'orchestration.runUse',
{ id: harness.adoptedRunId, from: CURRENT_COORDINATOR_HANDLE },
currentEvidence('coordinator'),
'unclaimed-adopted-run-use'
)
)
expect(response).toMatchObject({
ok: false,
error: {
code: 'consumer_fenced',
data: {
recoveryCommand: `orca orchestration run-use --id ${harness.adoptedRunId} --takeover-legacy`
}
}
})
})
it('still routes the retained legacy coordinator to the adopted Run without --run', async () => {
const harness = createHarness()
const response = await harness.dispatcher.dispatch(
request(
'orchestration.taskCreate',
{
spec: 'retained assignment',
callerTerminalHandle: COORDINATOR_HANDLE
},
evidence('coordinator'),
'retained-adopted-task-create'
)
)
expect(response).toMatchObject({
ok: true,
result: {
task: { run_id: harness.adoptedRunId, spec: 'retained assignment' }
}
})
})
it('still fences a stale legacy coordinator once the adopted Run is claimed', async () => {
const harness = createHarness()
harness.db.bindRun({
runId: harness.adoptedRunId,
coordinatorHandle: CURRENT_COORDINATOR_HANDLE,
coordinatorPaneKey: CURRENT_COORDINATOR_PANE,
takeoverLegacy: true
})
const response = await harness.dispatcher.dispatch(
request(
'orchestration.taskCreate',
{
spec: 'stale assignment',
run: harness.adoptedRunId,
callerTerminalHandle: COORDINATOR_HANDLE
},
evidence('coordinator'),
'claimed-adopted-task-create'
)
)
expect(response).toMatchObject({
ok: false,
error: { code: 'legacy_read_only' }
})
expect(harness.db.listTasks({ runId: harness.adoptedRunId })).toHaveLength(1)
})
})
+22
View File
@@ -1,6 +1,10 @@
import { EventEmitter } from 'node:events'
import { describe, expect, it, vi } from 'vitest'
import type { SkillUpdateRun } from '../../shared/skill-freshness'
import {
getSpawnArgsForWindows,
WINDOWS_BATCH_UNSAFE_CHARACTERS_LABEL
} from '../../shared/windows-batch-spawn'
import { CANCEL_RELEASE_TIMEOUT_MS, SkillUpdateRunner } from './skill-update-run'
class FakeChild extends EventEmitter {
@@ -293,6 +297,24 @@ describe('SkillUpdateRunner', () => {
expect(run.state).toBe('error')
expect(run.state === 'error' && run.failedNames).toEqual(['orca-cli'])
expect(states.at(-1)?.state).toBe('error')
// Why: the message used to name a character set the guard no longer matches.
expect(run.state === 'error' && run.message).toContain(WINDOWS_BATCH_UNSAFE_CHARACTERS_LABEL)
})
it('spawns npx from a Program Files (x86) install instead of refusing it', () => {
const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
try {
const npx = 'C:\\Program Files (x86)\\nodejs\\npx.cmd'
const { runner, spawnCalls } = makeRunner({
resolveCommand: () => npx,
buildSpawnArgs: getSpawnArgsForWindows
})
expect(runner.start(['orca-cli']).started).toBe(true)
expect(spawnCalls[0]?.args.slice(0, 3)).toEqual(['/d', '/c', npx])
} finally {
platform.mockRestore()
}
})
it('coalesces progress frames into one push instead of one per chunk', async () => {
+6 -5
View File
@@ -6,7 +6,7 @@ import {
} from '../../shared/skill-freshness'
import { resolveCliCommand } from '../codex-cli/command'
import { killWithDescendantSweep } from '../pty-descendant-termination'
import { getSpawnArgsForWindows } from '../win32-utils'
import { getSpawnArgsForWindows, WINDOWS_BATCH_UNSAFE_CHARACTERS_LABEL } from '../win32-utils'
// Why: `skills update` prints ANSI colour and \r + erase-line progress. We show
// this log verbatim to the user but never parse it — `update` has no --json
@@ -101,9 +101,8 @@ export class SkillUpdateRunner {
;({ spawnCmd, spawnArgs } = buildSpawnArgs(npxCommand, npxArgs))
} catch {
// Why: the names are already canonical here, so this is the cmd.exe rail
// rejecting the resolved npx *path* — a profile directory containing `&`,
// `%` or `!` is enough. Publishing the failure keeps the dialog honest;
// returning a bare `started: false` would leave the button dead and silent.
// rejecting the resolved npx *path*. Publishing the failure keeps the dialog
// honest; a bare `started: false` would leave the button dead and silent.
this.runToken += 1
this.settling = false
this.publish({
@@ -112,7 +111,9 @@ export class SkillUpdateRunner {
finishedAt: this.deps.now(),
output: '',
failedNames: canonicalNames,
message: `Could not run ${npxCommand} safely from this location.`
message:
`Could not run ${npxCommand} safely from this location: its path contains one of ` +
`${WINDOWS_BATCH_UNSAFE_CHARACTERS_LABEL}, which cmd.exe would reinterpret.`
})
return { started: false, reason: 'unsafe-command-path' }
}
+48 -7
View File
@@ -8,7 +8,8 @@ import {
getSpawnArgsForWindows,
isPermissionError,
isWindowsBatchScript,
resolveWindowsCommand
resolveWindowsCommand,
WINDOWS_BATCH_UNSAFE_CHARACTERS_LABEL
} from './win32-utils'
function withPlatform<T>(platform: NodeJS.Platform, fn: () => T): T {
@@ -119,7 +120,7 @@ describe('getSpawnArgsForWindows', () => {
it('rejects unsafe args for .cmd scripts on win32', () => {
withPlatform('win32', () => {
for (const argument of ['hello & goodbye', 'close)', '(open']) {
for (const argument of ['hello & goodbye', 'a | b', 'x > y', '%PATH%', 'a\nb']) {
expect(() => getSpawnArgsForWindows('C:\\tools\\agent.cmd', [argument])).toThrow(
'UNSAFE_WINDOWS_BATCH_ARGUMENTS'
)
@@ -129,11 +130,9 @@ describe('getSpawnArgsForWindows', () => {
it('rejects unsafe command paths for .cmd scripts on win32', () => {
withPlatform('win32', () => {
for (const command of ['C:\\bad&path\\agent.cmd', 'C:\\bad(path\\agent.cmd']) {
expect(() => getSpawnArgsForWindows(command, ['login'])).toThrow(
'UNSAFE_WINDOWS_BATCH_ARGUMENTS'
)
}
expect(() => getSpawnArgsForWindows('C:\\bad&path\\agent.cmd', ['login'])).toThrow(
'UNSAFE_WINDOWS_BATCH_ARGUMENTS'
)
})
})
@@ -144,6 +143,48 @@ describe('getSpawnArgsForWindows', () => {
).toEqual(['/d', '/c', 'C:\\tools\\agent.cmd', 'package,name;version'])
})
})
// Why: parentheses only group commands and cannot chain one without a separator
// the guard already rejects, so paren-bearing paths must stay spawnable.
it('spawns .cmd shims under Program Files (x86) and paren-bearing worktrees', () => {
withPlatform('win32', () => {
const npx = 'C:\\Program Files (x86)\\nodejs\\npx.cmd'
expect(getSpawnArgsForWindows(npx, ['C:\\dev\\app (fork)'])).toEqual({
spawnCmd: getCmdExePath(),
spawnArgs: ['/d', '/c', npx, 'C:\\dev\\app (fork)']
})
expect(getSpawnArgsForWindows('C:\\tools\\agent.cmd', ['close)', '(open']).spawnArgs).toEqual(
['/d', '/c', 'C:\\tools\\agent.cmd', 'close)', '(open']
)
})
})
it('still rejects a paren-wrapped command chain', () => {
withPlatform('win32', () => {
expect(() => getSpawnArgsForWindows('C:\\tools\\agent.cmd', ['(x & calc.exe)'])).toThrow(
'UNSAFE_WINDOWS_BATCH_ARGUMENTS'
)
})
})
it('rejects exactly the characters the message advertises', () => {
withPlatform('win32', () => {
const advertised = WINDOWS_BATCH_UNSAFE_CHARACTERS_LABEL.split(' ')
expect(advertised.length).toBeGreaterThan(0)
for (const character of advertised) {
expect(() => getSpawnArgsForWindows('C:\\tools\\agent.cmd', [`a${character}b`])).toThrow(
'UNSAFE_WINDOWS_BATCH_ARGUMENTS'
)
}
// Why: anything not advertised must pass, or the message misleads the user.
for (const character of ['(', ')', ',', ';', '@', '#', '$', "'", '~', '=']) {
expect(advertised).not.toContain(character)
expect(() =>
getSpawnArgsForWindows('C:\\tools\\agent.cmd', [`a${character}b`])
).not.toThrow()
}
})
})
})
describe('resolveWindowsCommand', () => {
+1
View File
@@ -7,6 +7,7 @@ export {
getSpawnArgsForWindows,
isWindowsBatchScript,
WINDOWS_BATCH_UNSAFE_ARGUMENTS_ERROR,
WINDOWS_BATCH_UNSAFE_CHARACTERS_LABEL,
UnsafeWindowsBatchArgumentsError
} from '../shared/windows-batch-spawn'
@@ -60,6 +60,8 @@ describe('PtyHandler negotiated source publication', () => {
let destroyPty: ReturnType<typeof vi.fn>
let holdDataSettlements: boolean
let heldDataSettlements: ((result: SinkWriteSettlement) => void)[]
let holdExitSettlements: boolean
let heldExitSettlements: ((result: SinkWriteSettlement) => void)[]
let highWaterMark: number | undefined
beforeEach(async () => {
@@ -72,6 +74,8 @@ describe('PtyHandler negotiated source publication', () => {
exitCallback = undefined
holdDataSettlements = false
heldDataSettlements = []
holdExitSettlements = false
heldExitSettlements = []
highWaterMark = undefined
pausePty = vi.fn()
destroyPty = vi.fn()
@@ -105,6 +109,10 @@ describe('PtyHandler negotiated source publication', () => {
return true
}
if (frame?.method === 'pty.exit') {
if (holdExitSettlements) {
heldExitSettlements.push(settle)
return true
}
// Why: real sockets never settle inside write(); a synchronous settle would re-enter
// the legacy capacity path before the pending exit is retired.
queueMicrotask(() => settle({ ok: true }))
@@ -213,6 +221,74 @@ describe('PtyHandler negotiated source publication', () => {
expect(exitFrames()).toHaveLength(1)
})
function attachSubscriber(): Buffer[] {
const subscriberWrites: Buffer[] = []
dispatcher.attachClient(
(data, settle) => {
subscriberWrites.push(Buffer.from(data))
if (notification(data)?.method === 'pty.exit') {
// Why: real sockets never settle inside write(); see the primary sink above.
queueMicrotask(() => settle({ ok: true }))
return true
}
settle({ ok: true })
return true
},
{ supportsWriteCallback: true }
)
return subscriberWrites
}
function subscriberExitFrames(subscriberWrites: Buffer[]): Notification[] {
return subscriberWrites
.map(notification)
.filter((frame): frame is Notification => frame?.method === 'pty.exit')
}
it('never re-delivers the exit to subscribers when a cancel retires the record', async () => {
await spawn({})
const spawnResult = writes.map((buffer) => responseResult(buffer, 2)).find(Boolean)!
const subscriberWrites = attachSubscriber()
dataCallback!('prompt')
await vi.advanceTimersByTimeAsync(8)
// Why: the owner's producer lane is saturated when the PTY exits, so only the legacy
// projection lands; the record then still holds the undelivered owner exit.
highWaterMark = 64
expect(() => exitCallback!({ exitCode: 0 })).not.toThrow()
await vi.advanceTimersByTimeAsync(8)
expect(subscriberExitFrames(subscriberWrites)).toHaveLength(1)
highWaterMark = undefined
await cancelSourceDelivery(spawnResult)
await vi.advanceTimersByTimeAsync(8)
expect(subscriberExitFrames(subscriberWrites)).toHaveLength(1)
})
it('drains the pending exit once the retired record published it', async () => {
await spawn({})
const spawnResult = writes.map((buffer) => responseResult(buffer, 2)).find(Boolean)!
dataCallback!('prompt')
await vi.advanceTimersByTimeAsync(8)
holdExitSettlements = true
exitCallback!({ exitCode: 0 })
await vi.advanceTimersByTimeAsync(0)
expect(heldExitSettlements).toHaveLength(1)
await cancelSourceDelivery(spawnResult)
// Why: the in-flight frame failing on a canceled delivery is the only path that reaches
// the retiring branch with an unsettled exit publication.
heldExitSettlements[0]({ ok: false, error: new Error('socket write failed') })
await vi.advanceTimersByTimeAsync(8)
expect(exitFrames()).toHaveLength(2)
handler.handleSourcePublicationCapacity(String(spawnResult.id))
await vi.advanceTimersByTimeAsync(8)
expect(exitFrames()).toHaveLength(2)
})
it('drains buffered legacy output before the exit when capacity returns', async () => {
await spawn({})
const spawnResult = writes.map((buffer) => responseResult(buffer, 2)).find(Boolean)!
@@ -271,6 +347,123 @@ describe('PtyHandler negotiated source publication', () => {
}
})
it('retires the delivery after the owner exit publication throws', async () => {
await spawn({})
const spawnResult = writes.map((buffer) => responseResult(buffer, 2)).find(Boolean)!
const id = String(spawnResult.id)
const subscriberWrites = attachSubscriber()
const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true)
const publishOwnerExit = vi
.spyOn(dispatcher, 'tryNotifyPtyExitToClient')
.mockImplementationOnce(() => {
throw new Error('owner write failed')
})
try {
expect(() => exitCallback!({ exitCode: 8 })).not.toThrow()
await vi.advanceTimersByTimeAsync(0)
expect(exitFrames()).toHaveLength(1)
expect(subscriberExitFrames(subscriberWrites)).toHaveLength(1)
expect(publication.accepts(id)).toBe(false)
expect(adapter.getDebugSnapshot().deliveryTokens).toBe(0)
handler.handleSourcePublicationCapacity(id)
await vi.advanceTimersByTimeAsync(0)
expect(exitFrames()).toHaveLength(1)
expect(subscriberExitFrames(subscriberWrites)).toHaveLength(1)
} finally {
publishOwnerExit.mockRestore()
stderr.mockRestore()
}
})
it('retires the delivery when committed owner exit settlement fails', async () => {
await spawn({})
const spawnResult = writes.map((buffer) => responseResult(buffer, 2)).find(Boolean)!
const id = String(spawnResult.id)
const subscriberWrites = attachSubscriber()
const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true)
const settleOwnerExit = vi.spyOn(adapter, 'settleExitPublication').mockImplementation(() => {
throw new Error('exit settlement failed')
})
try {
exitCallback!({ exitCode: 9 })
await vi.advanceTimersByTimeAsync(0)
expect(exitFrames()).toHaveLength(1)
expect(subscriberExitFrames(subscriberWrites)).toHaveLength(1)
expect(publication.accepts(id)).toBe(false)
expect(adapter.getDebugSnapshot().deliveryTokens).toBe(0)
handler.handleSourcePublicationCapacity(id)
await vi.advanceTimersByTimeAsync(0)
expect(exitFrames()).toHaveLength(1)
expect(subscriberExitFrames(subscriberWrites)).toHaveLength(1)
} finally {
settleOwnerExit.mockRestore()
stderr.mockRestore()
}
})
it('lets a retired record re-target its own exit instead of broadcasting a duplicate', async () => {
await spawn({})
const spawnResult = writes.map((buffer) => responseResult(buffer, 2)).find(Boolean)!
const subscriberWrites = attachSubscriber()
const publishExitAfterRetire = vi.fn(() => true)
handler.setSourcePublication(stubPublication({ accepts: () => false, publishExitAfterRetire }))
expect(() => exitCallback!({ exitCode: 4 })).not.toThrow()
expect(publishExitAfterRetire).toHaveBeenCalledWith(
expect.objectContaining({ id: spawnResult.id, code: 4 })
)
expect(exitFrames()).toHaveLength(0)
expect(subscriberExitFrames(subscriberWrites)).toHaveLength(0)
handler.handleSourcePublicationCapacity(String(spawnResult.id))
await vi.advanceTimersByTimeAsync(8)
expect(publishExitAfterRetire).toHaveBeenCalledOnce()
})
it('broadcasts the legacy exit when the retired record never projected one', async () => {
await spawn({})
const spawnResult = writes.map((buffer) => responseResult(buffer, 2)).find(Boolean)!
handler.setSourcePublication(
stubPublication({ accepts: () => false, publishExitAfterRetire: () => null })
)
exitCallback!({ exitCode: 5 })
expect(exitFrames()).toHaveLength(1)
expect(exitFrames()[0].params).toMatchObject({ id: spawnResult.id, code: 5 })
})
it('contains a retired-record publication fault and falls back to the legacy exit', async () => {
await spawn({})
const spawnResult = writes.map((buffer) => responseResult(buffer, 2)).find(Boolean)!
const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true)
try {
handler.setSourcePublication(
stubPublication({
accepts: () => false,
publishExitAfterRetire: () => {
throw new Error('retired owner lookup failed')
}
})
)
expect(() => exitCallback!({ exitCode: 6 })).not.toThrow()
expect(exitFrames()).toHaveLength(1)
expect(exitFrames()[0].params).toMatchObject({ id: spawnResult.id, code: 6 })
expect(String(stderr.mock.calls.at(-1)?.[0])).toContain(
'[pty-handler] retired pty exit publication failed'
)
} finally {
stderr.mockRestore()
}
})
it('completes the exit from the settled state without re-sealing the delivery', async () => {
await spawn({})
const spawnResult = writes.map((buffer) => responseResult(buffer, 2)).find(Boolean)!
+17 -3
View File
@@ -1058,9 +1058,23 @@ export class PtyHandler {
)
}
}
const published = this.dispatcher.tryNotifyPtyExit
? this.dispatcher.tryNotifyPtyExit(exit)
: (this.dispatcher.notify('pty.exit', exit), true)
// Why: a retired record can already have projected this exit to the legacy subscribers, and
// the broadcast below would hand them a second copy.
let retiredExitPublished: boolean | null | undefined
try {
retiredExitPublished = this.sourcePublication?.publishExitAfterRetire?.(exit)
} catch (err) {
process.stderr.write(
`[pty-handler] retired pty exit publication failed for ${id}: ${
err instanceof Error ? (err.stack ?? err.message) : String(err)
}\n`
)
}
const published =
retiredExitPublished ??
(this.dispatcher.tryNotifyPtyExit
? this.dispatcher.tryNotifyPtyExit(exit)
: (this.dispatcher.notify('pty.exit', exit), true))
if (!published) {
return
}
@@ -77,9 +77,12 @@ describe('RelayPtySourcePublication cancellation and exit', () => {
endpointIdentity
)
let publication: RelayPtySourcePublication
const adapter = new SshPtyConsumerSessionAdapter(dispatcher, 'build-a', undefined, (id) =>
publication.onCreditAvailable(id)
)
let creditNoticeEnabled = true
const adapter = new SshPtyConsumerSessionAdapter(dispatcher, 'build-a', undefined, (id) => {
if (creditNoticeEnabled) {
publication.onCreditAvailable(id)
}
})
publication = new RelayPtySourcePublication(dispatcher, adapter, (id) => capacityIds.push(id))
dispatcher.feed(
requestFrame(1, 'pty.openClient', {
@@ -100,7 +103,29 @@ describe('RelayPtySourcePublication cancellation and exit', () => {
})
).toBe('opened')
activationSettlements[0]({ ok: true })
return { adapter, publication, exitSettlements, capacityIds, writes }
return {
adapter,
publication,
exitSettlements,
capacityIds,
writes,
setCreditNoticeEnabled: (enabled: boolean): void => {
creditNoticeEnabled = enabled
}
}
}
function activateOwner(publication: RelayPtySourcePublication): void {
const settlements: ((result: SinkWriteSettlement) => void)[] = []
expect(
publication.activate('pty-1', 'incarnation-1', {
clientId: 1,
isStale: () => false,
sessionIdentity: endpointIdentity,
onResponseSettled: (callback) => settlements.push(callback)
})
).toBe('opened')
settlements[0]({ ok: true })
}
function cancelDeliveryFrame(
@@ -200,16 +225,7 @@ describe('RelayPtySourcePublication cancellation and exit', () => {
dispatcher!.feed(cancelDeliveryFrame(2, activation))
await flushRequests()
const activationSettlements: ((result: SinkWriteSettlement) => void)[] = []
expect(
harness.publication.activate('pty-1', 'incarnation-1', {
clientId: 1,
isStale: () => false,
sessionIdentity: endpointIdentity,
onResponseSettled: (callback) => activationSettlements.push(callback)
})
).toBe('opened')
activationSettlements[0]({ ok: true })
activateOwner(harness.publication)
expect(
harness.publication.sealAndPublishExit({
@@ -221,6 +237,46 @@ describe('RelayPtySourcePublication cancellation and exit', () => {
expect(exitFrames(harness.writes)).toHaveLength(1)
})
it('reopens the delivery when the same client re-attaches over a closed ledger record', async () => {
const harness = await createHarness(8, true)
harness.publication.publish('pty-1', { data: 'data' }, false)
const activation = harness.publication.receivingActivation('pty-1', 1)!
dispatcher!.feed(ackFrame(2, activation, 4))
harness.publication.sealAndPublishExit({ id: 'pty-1', code: 0, incarnationId: 'incarnation-1' })
dispatcher!.feed(cancelDeliveryFrame(3, activation))
await flushRequests()
harness.exitSettlements[0]({ ok: true })
// Why: A' held the record across the in-flight frame, so it outlives the closed ledger until
// a capacity consumer prunes it — a re-attach must not resume that dead delivery.
expect(harness.publication.accepts('pty-1')).toBe(true)
activateOwner(harness.publication)
expect(harness.publication.publish('pty-1', { data: 'more' }, false)).toBe(true)
})
it('retires the record and defers capacity when an append lands on a closed delivery', async () => {
const harness = await createHarness()
harness.publication.publish('pty-1', { data: 'data' }, false)
const activation = harness.publication.receivingActivation('pty-1', 1)!
// Why: emulate a close the publication never saw — B3 backstops exactly the record/ledger
// disagreement that layer A's credit notification normally prevents.
harness.setCreditNoticeEnabled(false)
dispatcher!.feed(cancelDeliveryFrame(2, activation))
await flushRequests()
expect(harness.publication.accepts('pty-1')).toBe(true)
const capacityBefore = harness.capacityIds.length
expect(harness.publication.publish('pty-1', { data: 'more' }, false)).toBe(false)
expect(harness.publication.accepts('pty-1')).toBe(false)
// Why: publish() can run inside the captured-queue drain, so a synchronous capacity callback
// would publish pty.exit ahead of the chunk that is re-queued later in this same tick.
expect(harness.capacityIds).toHaveLength(capacityBefore)
await Promise.resolve()
expect(harness.capacityIds.slice(capacityBefore)).toEqual(['pty-1'])
})
it('survives a cancel while the credit-mode exit frame is still in flight', async () => {
const harness = await createHarness(8, true)
harness.publication.publish('pty-1', { data: 'data' }, false)
@@ -275,6 +331,9 @@ describe('RelayPtySourcePublication cancellation and exit', () => {
})
).toBe(true)
expect(harness.publication.accepts('pty-1')).toBe(false)
// Why: the failed write closed the owner socket, so B1's owner-targeted retry matches no
// client — it must still retire the record rather than fan the exit out to everyone else.
expect(exitFrames(harness.writes)).toHaveLength(1)
expect(harness.publication.getDebugSnapshot()).toMatchObject({
exitCommitted: 0,
exitRolledBack: 1
@@ -305,4 +364,31 @@ describe('RelayPtySourcePublication cancellation and exit', () => {
).not.toThrow()
expect(exitFrames(harness.writes)).toHaveLength(1)
})
it('forgets the legacy exit projection once the healthy exit settles', async () => {
const harness = await createHarness()
const exit = { id: 'pty-1', code: 0, incarnationId: 'incarnation-1' }
expect(harness.publication.sealAndPublishExit(exit)).toBe(true)
expect(harness.publication.exitPublicationSettled('pty-1')).toBe(true)
// Why: every client already holds this exit, so a retained index entry would both leak a row
// per exited pty and re-publish to the owner if any later fallback ran for the same id.
expect(harness.publication.publishExitAfterRetire(exit)).toBeNull()
expect(exitFrames(harness.writes)).toHaveLength(1)
})
it('re-targets the owner when a re-attach retires a record that already projected the exit', async () => {
const harness = await createHarness()
const exit = { id: 'pty-1', code: 0, incarnationId: 'incarnation-1' }
expect(harness.publication.sealAndPublishExit(exit)).toBe(true)
// Why: the handler holds its settled-check back while legacy output is still buffered, so B2
// can retire the record first — the index is then all that remembers the projection.
activateOwner(harness.publication)
expect(harness.publication.publishExitAfterRetire(exit)).toBe(true)
expect(exitFrames(harness.writes)).toHaveLength(2)
expect(harness.publication.publishExitAfterRetire(exit)).toBeNull()
})
})
@@ -4,7 +4,11 @@ import type {
PtySourceDeliverySnapshot
} from '../shared/pty-source-credit-contract'
import type { RelayDispatcher } from './dispatcher'
import { sealAndPublishPtySourceExit } from './relay-pty-source-exit-publication'
import {
RelayPtySourceLegacyExitIndex,
sealAndPublishPtySourceExit,
sealAndPublishTrackedPtySourceExit
} from './relay-pty-source-exit-publication'
import type {
RelayPtySourceDeliveryRecord,
RelayPtySourcePublicationCounters,
@@ -92,6 +96,7 @@ function createScenario(
}
return probe
}),
cancelDelivery: vi.fn(),
sealDelivery: vi.fn(),
settleExitPublication: vi.fn(),
deliveryMode: vi.fn(() => 'source-owner' as const)
@@ -104,7 +109,7 @@ function createScenario(
projectPtyExitToMatchingClients: vi.fn(() => true),
tryNotifyPtyExitToClient: vi.fn(() => true)
}
const sender = { pump: vi.fn() }
const sender = { pump: vi.fn(), wakeSendWaiters: vi.fn() }
const counters: RelayPtySourcePublicationCounters = {
opened: 0,
rotated: 0,
@@ -115,6 +120,13 @@ function createScenario(
exitRolledBack: 0
}
const capacityIds: string[] = []
let capacityError: Error | null = null
const onCapacity = (id: string): void => {
capacityIds.push(id)
if (capacityError) {
throw capacityError
}
}
const run = (): boolean =>
sealAndPublishPtySourceExit({
params,
@@ -124,9 +136,33 @@ function createScenario(
session: session as unknown as SshPtyConsumerSessionAdapter,
sender: sender as unknown as RelayPtySourceSendScheduler,
counters,
onCapacity: (id) => capacityIds.push(id)
onCapacity
})
return { deliveries, session, dispatcher, sender, counters, capacityIds, run, setProbe }
const runTracked = (legacyExits: RelayPtySourceLegacyExitIndex): boolean =>
sealAndPublishTrackedPtySourceExit({
params,
legacyExits,
deliveries,
dispatcher: dispatcher as unknown as RelayDispatcher,
session: session as unknown as SshPtyConsumerSessionAdapter,
sender: sender as unknown as RelayPtySourceSendScheduler,
counters,
onCapacity
})
return {
deliveries,
session,
dispatcher,
sender,
counters,
capacityIds,
run,
runTracked,
setProbe,
setCapacityError: (error: Error): void => {
capacityError = error
}
}
}
describe('sealAndPublishPtySourceExit closed-delivery guard', () => {
@@ -200,6 +236,139 @@ describe('sealAndPublishPtySourceExit closed-delivery guard', () => {
})
})
describe('RelayPtySourceLegacyExitIndex', () => {
function createIndex(notifyAccepted = true) {
const index = new RelayPtySourceLegacyExitIndex()
const dispatcher = {
tryNotifyPtyExitToMatchingClients: vi.fn(
(_matches: (clientId: number) => boolean, _params: unknown) => notifyAccepted
)
}
const session = { deliveryMode: vi.fn(() => 'source-owner' as const) }
const publish = (): boolean | null =>
index.publishAfterRetire(
params,
dispatcher as unknown as RelayDispatcher,
session as unknown as SshPtyConsumerSessionAdapter
)
return { index, dispatcher, session, publish }
}
it('defers to the caller when nothing was projected for the pty', () => {
const scenario = createIndex()
expect(scenario.publish()).toBeNull()
expect(scenario.dispatcher.tryNotifyPtyExitToMatchingClients).not.toHaveBeenCalled()
})
it('re-targets the owner exactly once for a remembered projection', () => {
const scenario = createIndex()
scenario.index.remember(params, true)
expect(scenario.publish()).toBe(true)
const matches = scenario.dispatcher.tryNotifyPtyExitToMatchingClients.mock.calls[0][0]
expect(matches(1)).toBe(true)
scenario.session.deliveryMode.mockReturnValue('legacy-owner' as never)
expect(matches(1)).toBe(false)
// Why: the second pass is the handler's retry after a later capacity event; re-publishing
// would hand the owner a duplicate exit.
expect(scenario.publish()).toBeNull()
})
it('keeps the projection retryable when the owner notify is refused', () => {
const scenario = createIndex(false)
scenario.index.remember(params, true)
expect(scenario.publish()).toBe(false)
expect(scenario.publish()).toBe(false)
})
it('consumes a completed retired exit without publishing it again', () => {
const scenario = createIndex()
scenario.index.remember(params, true)
scenario.index.complete(params)
expect(scenario.publish()).toBe(true)
expect(scenario.dispatcher.tryNotifyPtyExitToMatchingClients).not.toHaveBeenCalled()
expect(scenario.publish()).toBeNull()
})
it('does not let stale completion overwrite a later incarnation', () => {
const scenario = createIndex()
const next = { ...params, incarnationId: 'incarnation-2' }
scenario.index.remember(next, true)
scenario.index.complete(params)
expect(scenario.publish()).toBeNull()
expect(
scenario.index.publishAfterRetire(
next,
scenario.dispatcher as unknown as RelayDispatcher,
scenario.session as unknown as SshPtyConsumerSessionAdapter
)
).toBe(true)
})
it('remembers a subscriber projection when the owner publication throws', () => {
const record = deliveryRecord({ sealed: true })
const scenario = createScenario(closedSnapshot({ state: 'sealed-unsettled' }), record)
const index = new RelayPtySourceLegacyExitIndex()
scenario.dispatcher.tryNotifyPtyExitToClient.mockImplementation(() => {
throw new Error('owner write failed')
})
expect(() => scenario.runTracked(index)).toThrow('owner write failed')
expect(scenario.dispatcher.projectPtyExitToMatchingClients).toHaveBeenCalledOnce()
expect(scenario.session.cancelDelivery).toHaveBeenCalledWith(
record.identity,
'exit-publication-failed'
)
expect(scenario.deliveries.has(params.id)).toBe(false)
expect(
index.publishAfterRetire(
params,
scenario.dispatcher as unknown as RelayDispatcher,
scenario.session as unknown as SshPtyConsumerSessionAdapter
)
).toBe(true)
expect(scenario.dispatcher.tryNotifyPtyExit).not.toHaveBeenCalled()
expect(
index.publishAfterRetire(
params,
scenario.dispatcher as unknown as RelayDispatcher,
scenario.session as unknown as SshPtyConsumerSessionAdapter
)
).toBeNull()
})
it.each([
[
'a later incarnation reused the pty id',
(index: RelayPtySourceLegacyExitIndex) =>
index.remember({ ...params, incarnationId: 'incarnation-2' }, true)
],
[
'the projection was withdrawn',
(index: RelayPtySourceLegacyExitIndex) => index.remember(params, false)
],
[
'the exit completed for every client',
(index: RelayPtySourceLegacyExitIndex) => index.forget(params.id)
],
['the publication was disposed', (index: RelayPtySourceLegacyExitIndex) => index.clear()]
])('defers to the caller once %s', (_label, mutate) => {
const scenario = createIndex()
scenario.index.remember(params, true)
mutate(scenario.index)
expect(scenario.publish()).toBeNull()
})
})
describe('sealAndPublishPtySourceExit settlement closure', () => {
function createInFlightScenario(settlementProbe: PtySourceDeliverySnapshot | null) {
const record = deliveryRecord({ sealed: true, legacyExitAccepted: true })
@@ -236,6 +405,44 @@ describe('sealAndPublishPtySourceExit settlement closure', () => {
expect(scenario.capacityIds).toEqual(['pty-1'])
})
it('retains committed completion until a failed capacity signal is retried', () => {
const record = deliveryRecord({ sealed: true })
const scenario = createScenario(closedSnapshot({ state: 'sealed-unsettled' }), record)
const index = new RelayPtySourceLegacyExitIndex()
let settle: ((result: { ok: true }) => void) | undefined
scenario.dispatcher.tryNotifyPtyExitToClient.mockImplementation((...args: unknown[]) => {
settle = args[2] as typeof settle
return true
})
scenario.session.settleExitPublication.mockImplementation(() => {
throw new Error('PTY source delivery is not sealed')
})
scenario.setCapacityError(new Error('capacity callback failed'))
const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true)
try {
expect(scenario.runTracked(index)).toBe(true)
expect(() => settle!({ ok: true })).not.toThrow()
expect(
index.publishAfterRetire(
params,
scenario.dispatcher as unknown as RelayDispatcher,
scenario.session as unknown as SshPtyConsumerSessionAdapter
)
).toBe(true)
expect(scenario.dispatcher.tryNotifyPtyExitToMatchingClients).not.toHaveBeenCalled()
expect(
index.publishAfterRetire(
params,
scenario.dispatcher as unknown as RelayDispatcher,
scenario.session as unknown as SshPtyConsumerSessionAdapter
)
).toBeNull()
} finally {
stderr.mockRestore()
}
})
it.each([
['committed', { ok: true } as const],
['rolled back', { ok: false, error: new Error('socket write failed') } as const]
@@ -243,9 +450,14 @@ describe('sealAndPublishPtySourceExit settlement closure', () => {
const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true)
try {
const scenario = createInFlightScenario(closedSnapshot({ state: 'sealed-unsettled' }))
scenario.record.sendWaiters.add(vi.fn())
scenario.session.settleExitPublication.mockImplementation(() => {
throw new Error('PTY source delivery is not sealed')
})
scenario.sender.wakeSendWaiters.mockImplementation(() => {
throw new Error('send waiter failed')
})
scenario.setCapacityError(new Error('capacity callback failed'))
expect(() => scenario.settle(result)).not.toThrow()
@@ -253,6 +465,12 @@ describe('sealAndPublishPtySourceExit settlement closure', () => {
'[pty-source-exit] exit settlement failed for pty-1'
)
expect(scenario.capacityIds).toEqual(['pty-1'])
expect(scenario.session.cancelDelivery).toHaveBeenCalledWith(
scenario.record.identity,
'exit-publication-failed'
)
expect(scenario.deliveries.has(params.id)).toBe(false)
expect(scenario.record.sendWaiters.size).toBe(0)
} finally {
stderr.mockRestore()
}
+130 -10
View File
@@ -7,23 +7,133 @@ import {
} from './relay-pty-source-send-scheduler'
import type { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter'
type ExitParams = { id: string; code: number; incarnationId: string }
export type PtyExitParams = { id: string; code: number; incarnationId: string }
export function sealAndPublishPtySourceExit(options: {
params: ExitParams
type PtySourceExitOptions = {
params: PtyExitParams
record: RelayPtySourceDeliveryRecord
deliveries: Map<string, RelayPtySourceDeliveryRecord>
legacyExits?: RelayPtySourceLegacyExitIndex
dispatcher: RelayDispatcher
session: SshPtyConsumerSessionAdapter
sender: RelayPtySourceSendScheduler
counters: RelayPtySourcePublicationCounters
onCapacity: (id: string) => void
}): boolean {
}
/**
* Remembers which exits already reached the legacy subscribers. `legacyExitAccepted` dies with
* the delivery record, but a cancel or grace expiry can retire the record between the legacy
* projection and the owner's exit frame, and the fallback must not broadcast a second copy.
*/
export class RelayPtySourceLegacyExitIndex {
private readonly stateByPty = new Map<
string,
Readonly<{ incarnationId: string; state: 'owner-pending' | 'complete' }>
>()
remember(params: PtyExitParams, delivered: boolean): void {
if (delivered) {
this.stateByPty.set(params.id, {
incarnationId: params.incarnationId,
state: 'owner-pending'
})
} else {
this.forget(params.id)
}
}
complete(params: PtyExitParams): void {
if (this.stateByPty.get(params.id)?.incarnationId !== params.incarnationId) {
return
}
this.stateByPty.set(params.id, { incarnationId: params.incarnationId, state: 'complete' })
}
/** Drops the entry once the exit is complete for every client, so the map cannot grow unbounded. */
forget(id: string): void {
this.stateByPty.delete(id)
}
clear = (): void => this.stateByPty.clear()
/** Publishes a retired record's exit to the owner alone, or null when nothing was projected. */
publishAfterRetire(
params: PtyExitParams,
dispatcher: RelayDispatcher,
session: SshPtyConsumerSessionAdapter
): boolean | null {
const remembered = this.stateByPty.get(params.id)
if (remembered?.incarnationId !== params.incarnationId) {
return null
}
if (remembered.state === 'complete') {
this.forget(params.id)
return true
}
const published = dispatcher.tryNotifyPtyExitToMatchingClients(
(clientId) => session.deliveryMode(clientId) === 'source-owner',
params
)
if (published) {
this.forget(params.id)
}
return published
}
}
function logExitSettlementFault(id: string, err: unknown): void {
process.stderr.write(
`[pty-source-exit] exit settlement failed for ${id}: ${
err instanceof Error ? (err.stack ?? err.message) : String(err)
}\n`
)
}
function retireFaultedPtySourceExit(
options: PtySourceExitOptions,
record: RelayPtySourceDeliveryRecord
): void {
try {
options.session.cancelDelivery(record.identity, 'exit-publication-failed')
} catch (err) {
logExitSettlementFault(options.params.id, err)
}
try {
options.sender.wakeSendWaiters(record)
} catch (err) {
logExitSettlementFault(options.params.id, err)
} finally {
record.sendWaiters.clear()
}
if (options.deliveries.get(options.params.id) === record) {
options.deliveries.delete(options.params.id)
}
}
/** Seals and publishes the pty's exit, recording whether the legacy projection outlives the record. */
export function sealAndPublishTrackedPtySourceExit(
options: Omit<PtySourceExitOptions, 'record'> & { legacyExits: RelayPtySourceLegacyExitIndex }
): boolean {
const record = options.deliveries.get(options.params.id)
if (!record) {
return false
}
try {
return sealAndPublishPtySourceExit({ ...options, record })
} catch (err) {
retireFaultedPtySourceExit({ ...options, record }, record)
throw err
}
}
export function sealAndPublishPtySourceExit(options: PtySourceExitOptions): boolean {
const { params, record, deliveries, dispatcher, session, sender, counters, onCapacity } = options
if (record.restoreRequired) {
const published = dispatcher.tryNotifyPtyExit(params)
if (published && deliveries.get(params.id) === record) {
deliveries.delete(params.id)
options.legacyExits?.forget(params.id)
}
return published
}
@@ -32,12 +142,15 @@ export function sealAndPublishPtySourceExit(options: {
return false
}
const probe = session.sourceDeliverySnapshotIfKnown(record.identity)
// Why: 'closing' is defensive — the ledger closes a canceled record in the same call, so it
// only ever hands back 'closed' (or null once the tombstone is evicted).
if (!probe || probe.state === 'closed' || probe.state === 'closing') {
if (probe?.exitPublished === true || record.sourceExitState === 'published') {
// Why: the delivery completed healthily — the owner already has the credit-mode exit.
if (deliveries.get(params.id) === record) {
deliveries.delete(params.id)
}
options.legacyExits?.forget(params.id)
return true
}
// Why: the delivery was canceled out from under the record; never touch the sealed
@@ -50,6 +163,7 @@ export function sealAndPublishPtySourceExit(options: {
: dispatcher.tryNotifyPtyExit(params)
if (published && deliveries.get(params.id) === record) {
deliveries.delete(params.id)
options.legacyExits?.forget(params.id)
}
return published
}
@@ -67,6 +181,7 @@ export function sealAndPublishPtySourceExit(options: {
(clientId) => session.deliveryMode(clientId) !== 'source-owner',
params
)
options.legacyExits?.remember(params, record.legacyExitAccepted)
if (!record.legacyExitAccepted) {
return false
}
@@ -96,14 +211,19 @@ export function sealAndPublishPtySourceExit(options: {
}
} catch (err) {
settlementFailed = true
process.stderr.write(
`[pty-source-exit] exit settlement failed for ${params.id}: ${
err instanceof Error ? (err.stack ?? err.message) : String(err)
}\n`
)
logExitSettlementFault(params.id, err)
if (result.ok) {
options.legacyExits?.complete(params)
}
retireFaultedPtySourceExit(options, record)
} finally {
if (result.ok || deliveryGone || settlementFailed) {
onCapacity(params.id)
try {
// Why: capacity fans out into arbitrary handler code, still from the bare socket callback.
onCapacity(params.id)
} catch (err) {
logExitSettlementFault(params.id, err)
}
}
}
})
+21 -11
View File
@@ -16,7 +16,11 @@ import {
type RelayPtySourceDeliveryRecord,
type RelayPtySourcePublicationCounters
} from './relay-pty-source-send-scheduler'
import { sealAndPublishPtySourceExit } from './relay-pty-source-exit-publication'
import {
RelayPtySourceLegacyExitIndex,
sealAndPublishTrackedPtySourceExit,
type PtyExitParams
} from './relay-pty-source-exit-publication'
import type { RelayDispatcher, RequestContext } from './dispatcher'
import {
appendPtySourceOutput,
@@ -28,6 +32,7 @@ import type { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-ad
export class RelayPtySourcePublication {
private readonly deliveries = new Map<string, RelayPtySourceDeliveryRecord>()
private readonly legacyExits = new RelayPtySourceLegacyExitIndex()
private readonly counters: RelayPtySourcePublicationCounters = {
opened: 0,
rotated: 0,
@@ -213,6 +218,7 @@ export class RelayPtySourcePublication {
return false
}
if (!output.sourceAccepted && !appendPtySourceOutput(this.session, record, output)) {
this.counters.appendDenied++
if (this.deliveryClosedUnderRecord(record)) {
this.sender.wakeSendWaiters(record)
this.deliveries.delete(id)
@@ -223,7 +229,6 @@ export class RelayPtySourcePublication {
queueMicrotask(() => this.onCapacity(id))
return false
}
this.counters.appendDenied++
return false
}
if (!projectPtySourceOutputToLegacy(this.dispatcher, this.session, id, output, interactive)) {
@@ -233,14 +238,10 @@ export class RelayPtySourcePublication {
return true
}
sealAndPublishExit(params: { id: string; code: number; incarnationId: string }): boolean {
const record = this.deliveries.get(params.id)
if (!record) {
return false
}
return sealAndPublishPtySourceExit({
sealAndPublishExit = (params: PtyExitParams): boolean =>
sealAndPublishTrackedPtySourceExit({
params,
record,
legacyExits: this.legacyExits,
deliveries: this.deliveries,
dispatcher: this.dispatcher,
session: this.session,
@@ -248,7 +249,10 @@ export class RelayPtySourcePublication {
counters: this.counters,
onCapacity: this.onCapacity
})
}
/** Returns null when the caller should fall back to its own legacy exit broadcast. */
publishExitAfterRetire = (params: PtyExitParams): boolean | null =>
this.legacyExits.publishAfterRetire(params, this.dispatcher, this.session)
onCreditAvailable = (id: string): void => this.sender.onCreditAvailable(id)
@@ -257,13 +261,19 @@ export class RelayPtySourcePublication {
if (!record || record.sourceExitState !== 'published') {
return false
}
// Why: owner and legacy subscribers both hold this exit now, so the index row would otherwise
// outlive the pty for the daemon's lifetime and re-publish on any later fallback.
this.legacyExits.forget(id)
this.sender.pruneClosed(id, record)
return true
}
getDebugSnapshot = () => this.sender.getDebugSnapshot()
dispose = (): void => this.sender.dispose()
dispose = (): void => {
this.legacyExits.clear()
this.sender.dispose()
}
private deliveryClosedUnderRecord(record: RelayPtySourceDeliveryRecord): boolean {
return ptySourceDeliveryClosed(this.session, record.identity)

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