diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 5455d0b7fb8..cfcdf2d23a5 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -245,6 +245,59 @@ jobs: retention-days: 7 if-no-files-found: ignore + ssh-docker-terminal-authority: + name: ssh docker terminal authority + needs: build + if: inputs.test_files == '' || contains(inputs.test_files, 'tests/e2e/ssh-docker-terminal-authority.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-docker-terminal-authority-restart.spec.ts') + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ inputs.ref || github.ref }} + + - name: Install native build and headless UI tools + run: sudo apt-get update && sudo apt-get install -y build-essential openssh-client python3 xvfb + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version-file: package.json + + - name: Setup pnpm + uses: pnpm/action-setup@v6 + with: + run_install: false + + - name: Use external node-gyp to avoid pnpm's bundled copy (Linux only) + if: runner.os == 'Linux' + run: | + npm install -g node-gyp@11.5.0 + echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Download E2E build output + uses: actions/download-artifact@v8 + with: + name: e2e-build-out + path: out/ + + - name: Run Docker SSH terminal authority E2E + run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker-terminal-authority + + - name: Upload terminal authority traces + if: failure() + uses: actions/upload-artifact@v7 + with: + name: playwright-traces-ssh-docker-terminal-authority + path: test-results/ + retention-days: 7 + if-no-files-found: ignore + ssh-docker-watcher-isolation: name: ssh docker watcher isolation needs: build diff --git a/.gitignore b/.gitignore index fc40e495e7a..185d037eb6b 100644 --- a/.gitignore +++ b/.gitignore @@ -101,6 +101,9 @@ docs/** !docs/reference/linux-glibc-compatibility.md !docs/reference/relay-grace-time-reconfiguration.md !docs/reference/remote-wire-compatibility.md +!docs/reference/terminal-session-authority.md +!docs/reference/terminal-session-authority-delivery.md +!docs/reference/terminal-session-authority-handoff.md !docs/reference/windows-setup-shell.md # Stably CLI (only docs/ are tracked) diff --git a/config/build-plugins/preload-sandbox-builtin-guard-write-path.test.ts b/config/build-plugins/preload-sandbox-builtin-guard-write-path.test.ts new file mode 100644 index 00000000000..e37bf093fab --- /dev/null +++ b/config/build-plugins/preload-sandbox-builtin-guard-write-path.test.ts @@ -0,0 +1,60 @@ +// Why: direct-hook unit calls cannot show ordering. These drive a real Vite write path to prove +// a rejected preload leaves nothing on disk while a clean one still emits. +import { mkdtemp, readdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { build } from 'vite' +import { createPreloadSandboxBuiltinGuardPlugin } from './preload-sandbox-builtin-guard' + +const roots: string[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { force: true, recursive: true }))) +}) + +async function buildPreload(source: string): Promise<{ outDir: string }> { + const root = await mkdtemp(join(tmpdir(), 'orca-preload-guard-')) + roots.push(root) + const outDir = join(root, 'out') + await writeFile(join(root, 'preload.ts'), source, 'utf8') + await build({ + root, + configFile: false, + logLevel: 'silent', + build: { + outDir, + // Matches the preload target: builtins stay external instead of being browser-polyfilled. + ssr: true, + write: true, + emptyOutDir: true, + minify: false, + rollupOptions: { + input: { preload: join(root, 'preload.ts') }, + external: ['electron'], + output: { format: 'cjs', entryFileNames: '[name].js' }, + plugins: [createPreloadSandboxBuiltinGuardPlugin()] + } + } + }) + return { outDir } +} + +describe('preload sandbox guard on a real build write path', () => { + it('leaves no output on disk when the preload imports a Node builtin', async () => { + const attempt = buildPreload( + "import { join } from 'node:path'\nimport { contextBridge } from 'electron'\n" + + "contextBridge.exposeInMainWorld('api', { join })\n" + ) + await expect(attempt).rejects.toThrow('node:path') + await expect(readdir(join(roots.at(-1)!, 'out'))).rejects.toThrow(/ENOENT/) + }, 60_000) + + it('writes a preload whose only external is electron', async () => { + const { outDir } = await buildPreload( + "import { contextBridge } from 'electron'\n" + + "contextBridge.exposeInMainWorld('api', { encode: (v: string) => new TextEncoder().encode(v) })\n" + ) + await expect(readdir(outDir)).resolves.toContain('preload.js') + }, 60_000) +}) diff --git a/config/build-plugins/preload-sandbox-builtin-guard.test.ts b/config/build-plugins/preload-sandbox-builtin-guard.test.ts new file mode 100644 index 00000000000..baf64146e2a --- /dev/null +++ b/config/build-plugins/preload-sandbox-builtin-guard.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, it } from 'vitest' +import type { Rollup } from 'vite' +import { + assertPreloadSandboxBundleSafe, + assertPreloadSandboxChunkSafe, + createPreloadSandboxBuiltinGuardPlugin, + findPreloadSandboxViolations +} from './preload-sandbox-builtin-guard' + +type OutputChunk = Rollup.OutputChunk + +function chunk(fileName: string, code: string, imports: string[] = []): OutputChunk { + return { + type: 'chunk', + fileName, + code, + imports, + dynamicImports: [] + } as unknown as OutputChunk +} + +function runGuard( + plugin: ReturnType, + bundle: Record +): void { + const hook = plugin.generateBundle + if (typeof hook !== 'function') { + throw new Error('guard plugin must expose a generateBundle hook') + } + hook.call({ meta: { watchMode: true } } as never, {} as never, bundle as never, false) +} + +describe('preload sandbox guard detection', () => { + it('detects static CommonJS and ESM Node builtin requests', () => { + expect( + findPreloadSandboxViolations( + 'require("node:util"); import("node:path");\nimport value from "node:buffer";\nimport "node:fs"' + ) + ).toEqual([ + { kind: 'node-builtin', detail: 'node:buffer' }, + { kind: 'node-builtin', detail: 'node:fs' }, + { kind: 'node-builtin', detail: 'node:path' }, + { kind: 'node-builtin', detail: 'node:util' } + ]) + }) + + it('detects bundler-renamed require shims', () => { + expect(findPreloadSandboxViolations('var x = __require("fs")')).toEqual([ + { kind: 'node-builtin', detail: 'fs' } + ]) + }) + + it('detects non-literal require and import requests', () => { + const violations = findPreloadSandboxViolations( + 'const m = require(name);\nconst d = await import(specifier)' + ) + expect(violations.map((violation) => violation.kind)).toEqual([ + 'nonliteral-module-request', + 'nonliteral-module-request' + ]) + expect(violations.map((violation) => violation.detail)).toEqual([ + 'import(specifier)', + 'require(name);' + ]) + }) + + it('rejects a bare npm external that a sandboxed preload cannot load', () => { + expect(findPreloadSandboxViolations('require("zod")')).toEqual([ + { kind: 'unsupported-external', detail: 'zod' } + ]) + }) + + it('rejects an emitted Rollup helper chunk', () => { + expect(findPreloadSandboxViolations('require("./chunks/shared-abc123.js")')).toEqual([ + { kind: 'helper-chunk', detail: './chunks/shared-abc123.js' } + ]) + }) + + it('rejects a shared chunk reported by its bare emitted path', () => { + expect(() => + assertPreloadSandboxChunkSafe( + chunk('index.js', 'export {}', ['chunks/chunk-BTjIgr6M.js', 'ssh2']) + ) + ).toThrow( + 'unloadable emitted chunks: chunks/chunk-BTjIgr6M.js; unsupported bare externals: ssh2' + ) + }) + + it('accepts the explicit Electron preload surface and browser-safe code', () => { + expect( + findPreloadSandboxViolations( + 'require("electron"); const bytes = new TextEncoder().encode("safe")' + ) + ).toEqual([]) + }) + + it('rejects Electron subpath exports the emitted preload never uses', () => { + expect(findPreloadSandboxViolations('require("electron/renderer")')).toEqual([ + { kind: 'unsupported-external', detail: 'electron/renderer' } + ]) + }) + + it('does not flag identifiers or quoted data that merely look like module requests', () => { + expect( + findPreloadSandboxViolations( + 'const api = { importPetsFrom: () => ipc.invoke("a"), importPetBundle: () => ipc.invoke("b") };\n' + + 'const label = "import x from \'node:fs\'";\nconst got = cache.require("node:fs")' + ) + ).toEqual([]) + }) +}) + +describe('preload sandbox guard assertions', () => { + it('names every offending specifier and the supported surface', () => { + expect(() => + assertPreloadSandboxBundleSafe('index.js', 'require("node:util"); require("zod")') + ).toThrow( + /"index\.js" requests unsupported Node builtins: node:util; unsupported bare externals: zod\..*resolves only electron;/s + ) + }) + + it('rejects a resolved external that never appears in the emitted source', () => { + expect(() => + assertPreloadSandboxChunkSafe(chunk('index.js', 'export {}', ['node:crypto'])) + ).toThrow('node:crypto') + }) + + it('accepts a chunk whose only resolved import is electron', () => { + expect(() => + assertPreloadSandboxChunkSafe(chunk('index.js', 'var e = require("electron")', ['electron'])) + ).not.toThrow() + }) +}) + +describe('preload sandbox guard plugin', () => { + it('fails in generateBundle, before Rollup writes the unsafe output', () => { + const plugin = createPreloadSandboxBuiltinGuardPlugin() + expect(plugin.writeBundle).toBeUndefined() + expect(() => + runGuard(plugin, { 'index.js': chunk('index.js', 'require("node:path")') }) + ).toThrow('node:path') + }) + + it('guards only the included outputs when the build also emits Node entries', () => { + const plugin = createPreloadSandboxBuiltinGuardPlugin({ + include: ['browser-window-close-preload.js'] + }) + expect(() => + runGuard(plugin, { + 'index.js': chunk('index.js', 'require("node:fs")', ['node:fs']), + 'browser-window-close-preload.js': chunk( + 'browser-window-close-preload.js', + 'var { contextBridge } = require("electron")', + ['electron'] + ) + }) + ).not.toThrow() + }) + + it('guards the second sandboxed preload emitted by the main build', () => { + const plugin = createPreloadSandboxBuiltinGuardPlugin({ + include: ['browser-window-close-preload.js'] + }) + expect(() => + runGuard(plugin, { + 'index.js': chunk('index.js', 'require("node:fs")'), + 'browser-window-close-preload.js': chunk( + 'browser-window-close-preload.js', + 'require("node:os")' + ) + }) + ).toThrow('browser-window-close-preload.js') + }) + + it('fails when a guarded preload output is renamed out of the bundle', () => { + const plugin = createPreloadSandboxBuiltinGuardPlugin({ + include: ['browser-window-close-preload.js'] + }) + expect(() => + runGuard(plugin, { 'index.js': chunk('index.js', 'require("electron")') }) + ).toThrow('guarded preload output missing from the bundle: browser-window-close-preload.js') + }) + + it('fails watch rebuilds too, so no dev out/ tree holds an unsafe preload', () => { + const plugin = createPreloadSandboxBuiltinGuardPlugin() + expect(() => + runGuard(plugin, { 'index.js': chunk('index.js', 'require("node:path")') }) + ).toThrow('node:path') + }) +}) diff --git a/config/build-plugins/preload-sandbox-builtin-guard.ts b/config/build-plugins/preload-sandbox-builtin-guard.ts new file mode 100644 index 00000000000..fb75756af71 --- /dev/null +++ b/config/build-plugins/preload-sandbox-builtin-guard.ts @@ -0,0 +1,157 @@ +import { builtinModules } from 'node:module' +import type { Plugin, Rollup } from 'vite' + +type NormalizedOutputOptions = Rollup.NormalizedOutputOptions +type OutputBundle = Rollup.OutputBundle +type OutputChunk = Rollup.OutputChunk + +const SANDBOX_UNSUPPORTED_BUILTINS = new Set([ + ...builtinModules, + ...builtinModules.map((moduleName) => `node:${moduleName}`) +]) + +/** The only module a sandboxed preload can resolve; everything else throws at load. */ +const SANDBOX_SUPPORTED_EXTERNALS = new Set(['electron']) + +// `[\w$]*require` also catches bundler-renamed shims such as `__require("fs")`. +const LITERAL_MODULE_CALL_RE = /(? + +const VIOLATION_LABELS: Record = { + 'node-builtin': 'unsupported Node builtins', + 'unsupported-external': 'unsupported bare externals', + 'helper-chunk': 'unloadable emitted chunks', + 'nonliteral-module-request': 'non-literal module requests' +} + +function classifySpecifier(specifier: string): PreloadSandboxViolation | null { + if (SANDBOX_SUPPORTED_EXTERNALS.has(specifier)) { + return null + } + if (SANDBOX_UNSUPPORTED_BUILTINS.has(specifier)) { + return { kind: 'node-builtin', detail: specifier } + } + // Rollup names a shared chunk by its emitted path, which is bare relative to the output dir. + if (/^[./]/.test(specifier) || /\.[cm]?js$/.test(specifier)) { + return { kind: 'helper-chunk', detail: specifier } + } + return { kind: 'unsupported-external', detail: specifier } +} + +function collect( + violations: Map, + violation: PreloadSandboxViolation | null +): void { + if (violation) { + violations.set(`${violation.kind}:${violation.detail}`, violation) + } +} + +export function findPreloadSandboxViolations(code: string): PreloadSandboxViolation[] { + const violations = new Map() + for (const pattern of [LITERAL_MODULE_CALL_RE, STATIC_MODULE_SOURCE_RE]) { + pattern.lastIndex = 0 + for (const match of code.matchAll(pattern)) { + const specifier = match[2] + if (specifier !== undefined) { + collect(violations, classifySpecifier(specifier)) + } + } + } + NONLITERAL_MODULE_CALL_RE.lastIndex = 0 + for (const match of code.matchAll(NONLITERAL_MODULE_CALL_RE)) { + collect(violations, { + kind: 'nonliteral-module-request', + detail: code + .slice(match.index, match.index + match[0].length + 24) + .split('\n')[0] + .trim() + }) + } + return [...violations.values()].sort((left, right) => + `${left.kind}:${left.detail}`.localeCompare(`${right.kind}:${right.detail}`) + ) +} + +function formatViolations( + fileName: string, + violations: readonly PreloadSandboxViolation[] +): string { + const grouped = new Map() + for (const violation of violations) { + grouped.set(violation.kind, [...(grouped.get(violation.kind) ?? []), violation.detail]) + } + const parts = [...grouped].map( + ([kind, details]) => `${VIOLATION_LABELS[kind]}: ${details.join(', ')}` + ) + return ( + `[preload-sandbox-builtin-guard] "${fileName}" requests ${parts.join('; ')}. ` + + `A sandboxed preload resolves only ${[...SANDBOX_SUPPORTED_EXTERNALS].join(', ')}; ` + + 'keep it browser-safe and do not polyfill Node modules.' + ) +} + +export function assertPreloadSandboxBundleSafe(fileName: string, code: string): void { + const violations = findPreloadSandboxViolations(code) + if (violations.length > 0) { + throw new Error(formatViolations(fileName, violations)) + } +} + +/** Rollup's resolved import lists catch externals and shared chunks a source scan can miss. */ +export function assertPreloadSandboxChunkSafe(chunk: OutputChunk): void { + const violations = new Map() + for (const specifier of [...chunk.imports, ...chunk.dynamicImports]) { + collect(violations, classifySpecifier(specifier)) + } + for (const violation of findPreloadSandboxViolations(chunk.code)) { + collect(violations, violation) + } + if (violations.size > 0) { + throw new Error(formatViolations(chunk.fileName, [...violations.values()])) + } +} + +export function createPreloadSandboxBuiltinGuardPlugin( + options?: Readonly<{ + /** Emitted file names to guard. Every chunk is guarded when omitted. */ + include?: readonly string[] + }> +): Plugin { + const include = options?.include ? new Set(options.include) : null + return { + name: 'orca-preload-sandbox-builtin-guard', + // Why: generateBundle precedes the write, so no unsafe preload reaches out/ for a later + // `pnpm start` or packaging step to pick up — including on watch rebuilds. + generateBundle(_options: NormalizedOutputOptions, bundle: OutputBundle) { + for (const item of Object.values(bundle)) { + if (item.type === 'chunk' && (!include || include.has(item.fileName))) { + assertPreloadSandboxChunkSafe(item) + } + } + // Why: a renamed entry would otherwise silently leave the preload unguarded. + const missing = include ? [...include].filter((fileName) => !(fileName in bundle)) : [] + if (missing.length > 0) { + throw new Error( + `[preload-sandbox-builtin-guard] guarded preload output missing from the bundle: ${missing.join(', ')}` + ) + } + } + } +} diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 17cfd44cc18..af86a1063f9 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "updatedAt": "2026-08-04", + "updatedAt": "2026-08-06", "policy": { "maturityLevels": ["experimental", "soak", "blocking", "accepted-gap", "deprecated"], "blockingPromotion": { @@ -9672,6 +9672,113 @@ ], "demotionRule": "Keep experimental or demote if same-authority snapshots can overwrite newer pushes, reconnect silently loses persisted intent, one forward operation disturbs unrelated forwards, live HTTP diverges from renderer/main inventory, or either transport topology flakes without an identified product or harness fault." }, + { + "id": "terminal-session-authority.hot-path-and-locator-isolation", + "title": "Terminal authority hot paths remain read-only and host locators remain isolated", + "maturity": "experimental", + "protection": "partial", + "owner": "terminal-runtime", + "layer": "relay-authority-provider-contract", + "surfaces": [ + "terminal binding reachability", + "exact terminal input", + "terminal output publication", + "authority workspace namespace location" + ], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "daemon", "ssh", "paired-runtime"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["ssh"], + "coverageNotes": "A macOS deterministic provider-contract run drives a fake PTY through the real relay handler, lifecycle, registry, service, journal, and authority projection. Pure path-flavor cases cover POSIX, Windows drive, UNC, extended-device aliases, roots, delimiter isolation, floating namespaces, UTF-8 byte bounds, and deterministic namespace hashing without consulting the client filesystem. Native Linux PTY, Windows ConPTY/named-pipe, daemon, paired-runtime, and live SSH transport measurements remain uncollected.", + "motivatingLinks": ["https://github.com/stablyai/orca/pull/12600"], + "invariant": "After an exact PTY binding commits, reachability inspection, exact input routing, and ordinary physical output publication must not append, compact, or revise terminal topology. Their retained state and queues remain bounded per PTY. Authority workspace identity is derived only from the authority host's canonical path, with distinct POSIX, Windows, injected-delimiter, and floating keys; canonical paths use their own exact 32 KiB UTF-8 bound rather than the shorter authority-ID bound.", + "oracle": "Commit one real durable authority binding, snapshot its revision, journal byte length, and synced-record count, then perform 25,000 direct reachability checks, 25,000 exact input notifications, and 25,000 physical output callbacks. Require each phase below 2,000 ms, observed heap growth below 32 MiB, exact input/output counts, zero pending output or exit queues, no pause or flush timer, zero service mutations, zero added durable writes or bytes, and an unchanged revision. Separately canonicalize POSIX, drive, UNC, and \\\\?\\ aliases, prove alias-key equality and cross-flavor/floating/delimiter inequality, accept exactly 32 KiB, reject the next UTF-8 byte, and derive the namespace directory from the complete host/namespace tuple.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/relay/terminal-session-authority-hot-path.test.ts src/main/session-authority/terminal-session-authority-workspace-isolation.test.ts src/shared/terminal-session-authority-locator.test.ts src/main/session-authority/terminal-session-authority-workspace-locator.test.ts --reporter=verbose" + ], + "testFiles": [ + "src/relay/terminal-session-authority-hot-path.test.ts", + "src/main/session-authority/terminal-session-authority-workspace-isolation.test.ts", + "src/shared/terminal-session-authority-locator.test.ts", + "src/main/session-authority/terminal-session-authority-workspace-locator.test.ts" + ], + "assertionRefs": [ + { + "file": "src/relay/terminal-session-authority-hot-path.test.ts", + "assertions": [ + "expect(reachabilityMs).toBeLessThan(PHASE_BUDGET_MS)", + "expect(inputMs).toBeLessThan(PHASE_BUDGET_MS)", + "expect(outputMs).toBeLessThan(PHASE_BUDGET_MS)", + "expect(heapGrowthBytes).toBeLessThan(HEAP_GROWTH_BUDGET_BYTES)", + "expect(state.pendingOutputByPty.size).toBe(0)", + "expect(mutationSpy).not.toHaveBeenCalled()", + "expect(durableWrites).toBe(durableWritesBefore)", + "expect(statSync(logPath).size).toBe(logBytesBefore)", + "expect(service.snapshotForObserver(observer).revision).toBe(revisionBefore)" + ] + }, + { + "file": "src/main/session-authority/terminal-session-authority-workspace-isolation.test.ts", + "assertions": [ + "expect(terminalAuthorityNamespaceLocatorKey(deviceDrive)).toBe(terminalAuthorityNamespaceLocatorKey(drive))", + "expect(new Set(keys).size).toBe(keys.length)", + "expect(new TextEncoder().encode(exactBoundary)).toHaveLength(MAX_CANONICAL_PATH_BYTES)", + "expect(terminalSessionAuthorityNamespaceDirectory('authority-root', namespace)).toBe(expected)" + ] + }, + { + "file": "src/shared/terminal-session-authority-locator.test.ts", + "assertions": [ + "terminal authority namespace locator validates host-canonical path flavor" + ] + }, + { + "file": "src/main/session-authority/terminal-session-authority-workspace-locator.test.ts", + "assertions": ["terminal authority workspace locator uses authority-host realpath"] + } + ], + "evidenceRuns": [ + { + "date": "2026-08-06", + "runner": "local", + "platform": "macos", + "result": "passed", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/relay/terminal-session-authority-hot-path.test.ts src/main/session-authority/terminal-session-authority-workspace-isolation.test.ts src/shared/terminal-session-authority-locator.test.ts src/main/session-authority/terminal-session-authority-workspace-locator.test.ts --reporter=verbose", + "durationSeconds": 0.57, + "summary": "13/13 passed in two repeated local runs. Worst observed 25,000-operation phases were 3.93 ms reachability, 6.14 ms exact input, and 10.63 ms output, with 2,223,304 bytes heap growth, zero topology writes, and zero pending output queues." + } + ], + "runtimeBudget": { + "p95Seconds": 15, + "scope": "four deterministic authority hot-path and locator contract files" + }, + "flakeHistory": { + "status": "not-started", + "evidence": "Two repeated local macOS runs exist; CI and cross-platform soak history have not started." + }, + "redGreenEvidence": { + "status": "partial", + "evidence": "The 2 KiB and exact-32-KiB locator assertions are red under the prior generic 1 KiB authority-ID validation and green with locator-specific validation. The mutation, queue, memory, and latency oracles have no retained intentional-break artifact yet." + }, + "performanceBudget": { + "required": true, + "evidence": "Each 25,000-operation phase must finish below 2,000 ms; synchronous observed heap growth must remain below 32 MiB; the output/exit queues, pause set, and flush timer must be empty; and service mutation count, synced journal count, journal bytes, and topology revision must remain unchanged. Across two local runs, worst observed phase times were 3.93/6.14/10.63 ms and heap growth was 2.12 MiB, with every topology and queue count at zero." + }, + "promotionCriteria": [ + "Collect 100 consecutive focused CI passes or 14 days of soak history.", + "Run the same measured contract on native Linux and Windows runners.", + "Add native PTY/ConPTY and live SSH transport latency artifacts without weakening the deterministic zero-write oracle.", + "Retain an intentional-break artifact for topology writes, queue retention, and latency or memory budget violations." + ], + "knownGaps": [ + "Only macOS executed the measured test; Windows path behavior is exercised through node:path.win32, not a native Windows filesystem.", + "The PTY is deterministic and in-process, so native PTY, ConPTY, named-pipe, SSH-channel, and renderer latency are not measured.", + "The 2,000 ms phase ceiling is a coarse CI regression guard, not a user-visible input-latency SLO.", + "Heap growth is a synchronous process-level observation without forced GC; retained native memory and file descriptors need separate evidence.", + "Imported legacy-worker broker routing and durable source-credit/exit checkpoints are outside this gate." + ], + "demotionRule": "Keep experimental or demote if a hot-path phase mutates or compacts topology, retains an unbounded queue, exceeds its budget without an identified runner fault, or any canonical alias can collide across host path flavors or exceed the declared byte boundary." + }, { "id": "remote-wire.cross-version-terminal-journey", "title": "A released client and a current server still complete one terminal journey in both skew directions", diff --git a/config/scripts/pr-e2e-gate-contract.test.mjs b/config/scripts/pr-e2e-gate-contract.test.mjs index 860e70c892b..9607045db55 100644 --- a/config/scripts/pr-e2e-gate-contract.test.mjs +++ b/config/scripts/pr-e2e-gate-contract.test.mjs @@ -72,6 +72,17 @@ describe('PR E2E gate contract', () => { expect(changedRun.run).toContain('pnpm run test:e2e "${TEST_FILES[@]}" --workers=1') }) + it('runs terminal authority Docker coverage for full and targeted E2E', () => { + const authorityJob = e2eWorkflow.jobs['ssh-docker-terminal-authority'] + expect(authorityJob.if).toBe( + "inputs.test_files == '' || contains(inputs.test_files, 'tests/e2e/ssh-docker-terminal-authority.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-docker-terminal-authority-restart.spec.ts')" + ) + const authorityRun = authorityJob.steps.find( + (step) => step.name === 'Run Docker SSH terminal authority E2E' + ) + expect(authorityRun.run).toContain('pnpm run test:e2e:ssh-docker-terminal-authority') + }) + it('keeps dedicated E2E workflows out of pull request CI', () => { const dedicatedWorkflows = [ 'golden-e2e-experiment.yml', diff --git a/config/scripts/run-ssh-docker-terminal-authority-e2e.mjs b/config/scripts/run-ssh-docker-terminal-authority-e2e.mjs new file mode 100644 index 00000000000..76cfad06f2c --- /dev/null +++ b/config/scripts/run-ssh-docker-terminal-authority-e2e.mjs @@ -0,0 +1,39 @@ +import { spawnSync } from 'node:child_process' + +const rawExtraArgs = process.argv.slice(2) +const extraArgs = rawExtraArgs[0] === '--' ? rawExtraArgs.slice(1) : rawExtraArgs +const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm' +const env = { + ...process.env, + ORCA_E2E_SSH_DOCKER: '1' +} +const spawnOptions = { + stdio: 'inherit', + env, + shell: process.platform === 'win32' +} + +const runtime = spawnSync(pnpm, ['run', 'ensure:electron-runtime'], spawnOptions) +if (runtime.status !== 0) { + process.exit(runtime.status ?? 1) +} + +const result = spawnSync( + pnpm, + [ + 'exec', + 'playwright', + 'test', + 'tests/e2e/ssh-docker-terminal-authority.spec.ts', + 'tests/e2e/ssh-docker-terminal-authority-restart.spec.ts', + '--config', + 'tests/playwright.config.ts', + '--project', + 'electron-headless', + '--workers=1', + ...extraArgs + ], + spawnOptions +) + +process.exit(result.status ?? 1) diff --git a/config/vitest.config.ts b/config/vitest.config.ts index 735e1b2a0af..06083834fe8 100644 --- a/config/vitest.config.ts +++ b/config/vitest.config.ts @@ -22,6 +22,7 @@ export default defineConfig({ include: [ 'src/**/*.test.ts', 'src/**/*.test.tsx', + 'config/build-plugins/**/*.test.ts', 'config/scripts/**/*.test.ts', 'config/scripts/**/*.test.mjs', 'tests/tools/**/*.test.mjs', diff --git a/docs/reference/terminal-session-authority-delivery.md b/docs/reference/terminal-session-authority-delivery.md new file mode 100644 index 00000000000..fe27f3583d4 --- /dev/null +++ b/docs/reference/terminal-session-authority-delivery.md @@ -0,0 +1,405 @@ +# Terminal session authority delivery ledger + +This ledger tracks implementation of +[`terminal-session-authority.md`](./terminal-session-authority.md). A goalpost is +complete only when its production path and its named proof both exist. Focused +unit tests, typechecking, or the presence of scaffolding cannot prove a broader +goalpost. + +Status values are `not started`, `partial`, and `proven`. Update the evidence +column with exact tests or runtime journeys before changing a row to `proven`. + +## Numeric checkpoint + +The final pause snapshot must retain this release classification: + +- goalposts: **0/8 proven, 6/8 partial, 2/8 not started**; +- partial: G0, G1, G2, G3, G4, and G6; +- not started: G5 and G7; +- required journeys: **0/13 proven**. + +Host-authority and E2EE-storage acceptance is recorded separately as a +construction milestone. It cannot promote one of these rows or journeys. + +## Goalposts + +| ID | Goalpost | Completion evidence | Status | Current evidence or gap | +| --- | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| G0 | One design contract | Every production path follows the identity, host-boundary, migration, compatibility, and minimal-shape sections of the design | partial | The contract now states host-local identity, host-derived principals, exact challenge/CAS admission, and final-host cursors. Current code still contains duplicated exact-operation clients, validation, transport-specific topology/retry state, and reachable legacy/authority paths, so the production graph does not yet match the four-role design. No audit entry below is runtime proof. | +| G1 | One final-host authority | Local, daemon, WSL, direct SSH, nested SSH, paired runtime, and remote server all resolve one final-host namespace and exact binding | partial | The host pause tranche independently accepted proof-derived daemon and SSH transports, namespace-local admission, exact reconnect, cumulative ACK, and authenticated retirement with P0/P1/P2=0. That acceptance is construction evidence only: complete local/WSL/paired/remote and simultaneous multi-host production journeys, reset orchestration, and post-cutover legacy removal remain unproven. | +| G2 | Exact operations only | Input, resize, signal, close, output, and exit are fenced by the captured full binding; stale or missing evidence cannot affect a successor | partial | The accepted host tranche covers exact close rejection, failed and partial stop, disconnected replay, and replacement-while-stop-awaits; the final pause receipts include 466 PTY tests, 168 adjacent tests, and 7 exact-close cases. This does not yet prove every input, resize, signal, close, output, and exit path across local, daemon, WSL, SSH, paired, remote, mobile, renderer fallback, and mixed versions. | +| G3 | Durable ordered delivery | Snapshot-before-events, held producer pause, replay-before-resume, durable semantic outcomes, durable idempotent main-process app projection, cumulative final-host ACK, renderer snapshot/delta observation, restart resume, and gap resnapshot are production-wired | partial | The accepted host tranche covers independent host/app consumers, proof-derived transports, namespace admission, reconnect, cumulative ACK, and retirement. The app delivery cursor, per-outcome settlement/tombstone path, duplicate proof-key store, and receipt ledger are deleted. Fresh broad validation passed, but full restart, gap, paired/remote, mixed-version, and crash-before-ACK journeys remain unproven, so pause acceptance does not promote G3. | +| G4 | One-way legacy cutover | Capability, freeze, exact inventory, plan, validation, one self-contained authority commit, topology attach, and `pty.openClient` run in order without dual-write or destructive inference | partial | Planner, evidence bridge, relay worker primitives, and a pre-admission barrier exist. Self-contained namespace import records now replay without the separate catalog in focused construction tests, but broader validation, production topology attach, and removal of legacy dual-write remain incomplete. | +| G5 | Wire and platform compatibility | Both version-skew directions and native macOS, Linux, Windows, WSL, Docker SSH, daemon, paired, remote, folder, floating, and worktree journeys pass | not started | No production journey is proven. Focused current/old terminal-wire tests pass 4/4 and the accepted host tranche covers fail-closed proof/grant behavior in its owned daemon/SSH scope, but live skew across SSH, paired runtime, remote server, and mobile/E2EE surfaces is absent. Native Windows, WSL, glibc-floor Linux, two-host, folder/floating/UNC, and production-scale coverage also remain absent. | +| G6 | Simpler production code | No unreachable non-test modules, test fixtures in production trees, duplicate state machines, re-export shims, or legacy reconciliation reachable after cutover | partial | Completed deletion passes removed 3,609 production LOC, 1,980 gross legacy-catalog LOC, 1,861 app-ledger LOC, and 97 test-only seam LOC, while moving 891 fixture LOC out of production. The 2026-08-07 pre-documentation census is still net +60,903 production LOC across 522 files because replacement authority, transport, migration, and legacy paths coexist. Reachability, duplicate-state-machine, one-type-module, and post-cutover deletion audits remain mandatory; the target remains net at or below zero. | +| G7 | No regression and reviewable PR | Correctness, latency, throughput, memory, restore, scale, packaging, independent review, readiness review, and final LOC census pass | not started | The narrower pause validation is independently clean, including the repaired 829-file/8,831-test broad run, but the 13 release journeys, real-platform and production-scale proof, convergence/deletion, rebase, final release review, commit, push, and PR do not exist. | + +## Correctness gates + +- Missing transport, timeout, failed inspection, absent projection, and unknown + process state never authorize exit, cleanup, takeover, or replacement. +- Ambiguous, unavailable, or rejected attach never falls back to spawning a + shell; creation requires an explicit fresh pane generation. +- Every mutation target is captured before an await and includes authority host, + namespace, pane generation, owner incarnation, physical PTY, and PTY + incarnation. +- An authoritative path never invokes an ID-only provider method or retries an + exact rejection through the legacy path. +- The final PTY-owning host appends mutation results and semantic outcomes before + publication; a consumer ACK happens only after its handler settles. +- Authority checkpoints and first-claim boundaries carry complete bounded + app-neutral semantic state with event identities; journal compaction cannot + make a new consumer lose title, agent, command, bell, review-link, or exit state. +- Host connection state is keyed by `authorityHostId`; it is lazily discovered, + permits concurrent hosts, and retains no fabricated state for a skipped host. + `NamespaceSession`, admission, grant, failure, and retirement state are each + keyed by `authorityHostId + namespaceId`. +- One bounded stable app/device proof-of-possession identity is shared across + host connections. Its process/session nonce is fresh and ephemeral. The host + derives the consumer principal from the authenticated proof and + `authorityHostId`; a host-wide SSH endpoint credential or client-supplied + profile ID cannot identify that principal. +- The host-issued challenge is bound to `authorityHostId`, `namespaceId`, the + current CAS (`currentCas`), the candidate process/session nonce, `requestId`, and + the exact connection grant. Proof requirements are mechanism-neutral and must + provide fresh, domain-separated possession, replay, cross-host, and + cross-namespace binding. Prefer the durable E2EE keypair only if such a + domain-separated proof satisfies the requirements; otherwise use a dedicated + signing key, with no silent weakening. +- First-install provisioning creates the proof identity once. Established + missing, corrupt, noncanonical, or public/private-mismatched identity state + fails closed; explicit reset/re-enrollment revokes the old identity and moves + its establishment evidence with paired-device credentials. +- The establishment marker contains a stable random installation identity, and + the current keypair schema contains the same identity. Only the validated + pre-marker keypair schema may migrate without a marker. Migration durably + stages the exact legacy key material in the current schema before publishing + the marker, then promotes that exact stage; every stage, marker, active-file, + and replacement-backup crash cut reuses the same key. A current-schema keypair + with no marker fails closed. First install and reset publish a bounded + non-active staged keypair through one atomic active-file rename, while reset + leaves the installation marker unchanged. +- Canonical user-data reconciliation compares validated installation, key, and + reset-stage lineage rather than raw JSON or mutable registry/outbox bytes. + Equivalent lineage no-ops even after mutable lifecycle drift; key, + installation, or reset-transaction divergence fails closed. +- A stage declares first-install or reset purpose; reset stages bind the exact + transaction ID. The ordinary loader can finish only first install with no + active keypair. It never promotes a reset stage, and recovery never generates + a second key after either kind of stage is durable. The exact reset stage + remains after publication until a later durable phase in the sole reset + transaction verifies and removes it; no successor-only, activation, + publication, or receipt ledger exists. +- Identity-lifecycle writes that advance a crash boundary explicitly sync the + file before rename and the containing directory afterward where supported. + Real POSIX sync failures propagate, unsupported platform/filesystem behavior + is explicit, and ordinary secure-file writes retain their rename-only cost. +- Identity reset persists one bounded phase/target-intent record before + mutation, then requires all final-host retirements, relay revoke + acknowledgements, transport closure, and local credential removal in that + order before successor publication. It stores the old proof-key public + fingerprint, not a global consumer ID, and derives each host-local principal + from `authorityHostId`. It stores no per-target receipt; restart reissues + idempotent work against the host journal and relay outbox. Old revoke and + host-removal APIs cannot bypass this transaction or infer success from a + disconnect. +- A lost retirement response retries the exact result on its live connection. + After host restart, a fresh retirement-specific proof may confirm the derived + principal is already absent without re-claiming it. Ordinary inventory + absence, disconnect, or a false legacy cancellation result is not an + acknowledgement, and no durable transport-grant or retirement-receipt ledger + is added. +- Consumer claim, handover, ACK, and retirement resolve through that exact + authenticated grant and nonce, never a consumer label alone. Partial setup + removes every claim, handler, observer, and grant it added. A changed or + stale challenge fails closed. +- Exact retry idempotence applies to the canonical challenge/proof/request and + `requestId` for one host, namespace, and grant: an uncertain response retries + that exact request and returns its original result. A host-current snapshot is + never blindly adopted, and no app-global committed/pending predecessor pair + is used to infer ownership. +- After boundary acceptance, the final host durably commits the policy claim and + cursor, then installs the connection-bound grant and exact-retry result in the + same serialized operation before returning success. A socket-write callback + cannot authorize or roll back it. Same-connection uncertainty retries exactly; + restart uses a fresh authenticated resume without a durable grant ledger. +- The final host attests first claim versus CAS-fenced handover independently for + each namespace. The namespace is the admission transaction: a multiplexed + connection never performs a best-effort batch that partially rotates durable + ownership; failure retains that namespace's incumbent and cannot invalidate + another completed namespace grant or install a global failure fence. +- A process crash before the durable claim append preserves the incumbent, while + a crash after it leaves the claim and cursor recoverable through authenticated + resume. In-memory rollback after an earlier durable claim is not sufficient. +- For a first claim, the host holds the producer and publishes the complete + boundary before the claim. The app commits and explicitly accepts that exact + boundary; transport send/flush success cannot substitute for acceptance. An + uncertain failure leaves the incumbent unchanged and retries the exact + challenge/proof/request without a boundary ledger. +- A high-water boundary snapshot is staged while a returning consumer replays + its contiguous suffix, then reconciled before the high-water ACK. This + replay-before-reconciliation order is not optional. The snapshot is applied + immediately only for a host-attested caught-up first claim. +- The app has no delivery cursor, settlement ledger, receipt ledger, or global + failure fence. The final host owns cumulative cursors; app projection event + keys may make projection application idempotent but cannot authorize a host + ACK by themselves. +- Every correctness-bearing app handler is idempotent by authority event key or + durably commits its projection before cumulative host ACK; an arbitrary + callback, renderer delivery, or IPC send followed by ACK is not accepted as + exactly-once evidence. +- Process-memory deduplication cannot prove app-crash replay safety and never + authorizes a cumulative host ACK. +- Reconnect drains replay while the producer remains explicitly held. Send + failure, disconnect, overflow, restart, and gap all preserve recoverability or + fail closed. +- Producer holds are keyed by `authorityHostId + namespaceId`. Acquisition and + producer queue admission are linearizable; a producer cannot pass the gate + before that namespace's hold and append inside its snapshot. +- Disconnect and reconnect fence generation-owned queues before installing a + successor. A non-settling ACK cannot block the successor, reconnect retries + automatically under bounded-resource backoff, and I/O timeout never implies + ACK, exit, or liveness. +- One exact host/namespace/binding has one active source delivery lease. Its + token plus provider, client, and owner generations fence every span, credit + ACK, replay, cancellation, pause, and exit. A provisional replacement cannot + publish, ACK, or mutate; promotion fences the predecessor and its queued bytes + before the replacement becomes active, without affecting sibling namespaces. +- The bounded byte/scalar-unit credit window is flow control only. Exhaustion + bounds retained output and pauses the exact active delivery; elapsed time, + window occupancy, or retry count never decides identity, liveness, takeover, + quarantine, or replacement. +- Backlog delivery uses bounded contiguous pages so a durable projection does not + require one full-sync transaction and transport round trip per historical + outcome; page commit and cumulative ACK preserve exact order and replay safety. +- Page-aware delivery is part of the first shipped namespace-cursor capability; + origin peers that lack it never receive those publications in either + version-skew direction. +- Migration is read-only until one atomic cutover commit. Ambiguous evidence is + visible and non-destructive; no timeout or retry count resolves identity. +- Post-cutover unresolved recovery rows fence only the exact identities they name; + they neither authorize cleanup nor freeze unrelated namespace operations. +- Sandboxed preload safety is enforced against emitted bundles in + `generateBundle` before output writes. Every preload chunk and the targeted + `browser-window-close-preload` entry permits only the exact `electron` + external; bare/static or dynamic imports, helper chunks, and literal, renamed, + or non-literal `require` forms fail closed. Ordinary main chunks are outside + this sandbox boundary. + +## Required journeys + +| Journey | Required oracle | Status | Current evidence or gap | +| --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Local macOS, Linux, and Windows | Same pane/binding/process across renderer and app restart; stale exact operations rejected | not proven | Focused macOS-host tests exist; no complete live local restart journey on all three operating systems. | +| Daemon and WSL | Same PTY across client and daemon reconnect/restart boundaries; generation skew fails closed | not proven | Daemon seams and reconnect races have focused coverage; no physical WSL or full daemon-restart journey. | +| Lazy discovery and skipped-host restart | An unused host is not probed eagerly; after a restart that skipped it, lazy rediscovery restores only that host's sessions without adopting host-current state | not proven | Host/namespace isolation has deterministic construction tests; no full skipped-host restart with lazy rediscovery. | +| Concurrent multi-host connections | Two or more `HostConnection`s keyed by distinct `authorityHostId`s operate concurrently; one host's disconnect, CAS, or failure cannot affect another | not proven | Host-keyed state and sibling isolation have focused tests; no simultaneous live multi-host journey. | +| Namespace-partial admission failure | One namespace on a multiplexed connection fails challenge/CAS or grant publication while another commits; only the failed `authorityHostId + namespaceId` is fenced | not proven | Namespace admission and sibling-failure races have focused tests; no complete multiplexed production journey. | +| Docker OpenSSH with `MaxSessions=1` | Same remote PID and exact binding across disconnect and client restart; authority restart imports exactly or exposes unresolved recovery without destruction | not proven | Single-host Docker journeys cover reconnect, contention, rejection, and app relaunch; this is pause evidence, not the full release oracle. | +| Two independent Docker SSH hosts | Two Docker hosts with distinct `authorityHostId`s run simultaneously; endpoint credentials, principals, sessions, cursors, and failures never cross-contaminate | not proven | Not run. | +| Paired client and remote server | Final host remains authoritative across independent client/host updates, pairing reconnects, and remote-runtime restart | not proven | Wire and storage seams have focused tests; no live paired-runtime or remote-server restart journey. | +| Git worktree, folder, floating, drive, and UNC namespaces | Stable host-local namespace with no client repository ID or target ID used as identity | not proven | Pure locator tests cover POSIX, drive, UNC, folder, and floating shapes; real drive/UNC and complete workspace journeys are absent. | +| Stable proof and exact retry | One bounded app/device proof identity admits fresh process/session nonces; lost challenge responses retry exactly, while altered or host-current state is rejected | not proven | Proof, CAS, retry, disconnect, and retirement tests exist; no complete cross-platform app/device journey. | +| Identity reset and re-enrollment | Crash-resumable host retirement, relay acknowledgement, transport closure, local deletion, and atomic successor publication occur in order; offline/old peers remain pending | not proven | The E2EE storage lifecycle is pause-accepted; the ordered multi-host reset and re-enrollment transaction is not implemented. | +| Mixed versions in both directions | No unknown opcode or ungranted topology publication; unsupported challenge/grant or mutations remain isolated legacy or fail before mutation | not proven | In-process old-client/new-host and new-client/old-host wire tests exist; live paired/SSH skew and all exchanged surfaces remain unproved. | +| Performance and scale | No regression in input/output latency, backpressure, memory, restore, startup, or large-pane behavior | not proven | A bounded 25,000-operation hot-path gate checks writes, queues, heap, and timing; no production-scale cross-platform baseline or full interaction benchmark. | + +## Reduction ledger + +Production-line count is measured against the eventual PR base and reported +separately from tests and CI. Net-negative production code is preferred because +the authority replaces reconciliation systems; correctness is never weakened to +hit the number. The final target is net production LOC at or below zero after +rebasing; any positive remainder requires an explicit retained-module and +superseded-path audit rather than a generic “new architecture” justification. +Construction snapshots use `--no-renames` against the current branch base; the +final census is repeated after rebasing onto the PR base. + +Current reduction targets: + +- remove unreachable non-test modules and unused public seams; +- move test-only fixtures out of production compilation; +- merge one-type and type-only files into their owning domain modules; +- use one authority transition/validation implementation; +- use one exact-operation client and one mutation-admission implementation; +- replace singleton host transport/admission state with one `HostConnection` + keyed by `authorityHostId` and one `NamespaceSession` family keyed by + `authorityHostId + namespaceId`; +- remove app-global committed/pending predecessor transitions and client-supplied + consumer identity; retain only one bounded stable app/device proof identity, + host-derived principals, and ephemeral process/session/request state; +- remove host-current adoption, retry-count identity decisions, and any + process-wide failure fence; failures and retirement remain namespace-local; +- share the daemon and relay bounded-output machinery; +- delete the relay per-outcome pending map and app-side SSH outcome-receipt + staging once authenticated namespace cursors carry exit and semantic outcomes; +- delete the app-side delivery cursor, per-outcome settlement/tombstone store, + receipt ledger, and suffix reconciliation; persist only the bounded proof + identity and use the final-host cumulative cursor plus idempotent app + projection replay; +- remove client-generation and owner-grace outcome ownership from authoritative + delivery once host-derived principals and final-host cursors replace it; + retain those mechanisms only in the unchanged legacy session path; +- delete legacy lease/layout dual-writes and reconnect reconciliation once G4 is + production-wired; +- remove the elapsed-time terminal-input quarantine from authoritative renderer + recovery; unchanged legacy recovery may retain it only before cutover; +- delete the separate legacy cutover catalog, receipt-reference events, and + suffix reconciliation after the importer commits self-contained namespace + records directly to the authority journal; +- delete timing windows, quarantine verdicts, retry-count identity decisions, + and parallel renderer readiness state replaced by authority facts. + +Next evidence-backed consolidation seeds, not yet counted as completed +deletions: + +- move the test-only SSH fixtures + `ssh-pty-consumer-session-grant-fixture.ts`, + `ssh-terminal-authority-process-fixture.ts`, + `ssh-relay-native-deps-install-fixture.ts`, and + `ssh-relay-session-test-fixtures.ts` out of the production source tree; a + direct import scan finds only test importers; +- keep one E2EE key-material equality implementation instead of the identical + exported `sameKeyMaterial` definitions in + `e2ee-keypair-backup-lineage.ts` and `e2ee-keypair-successor.ts`; +- keep one authority-namespace equality implementation instead of repeating + the same host-ID/namespace-ID comparison across app outcome, projection, + legacy inventory/state/transition, record validation, and subscription + registry modules; +- extract shared daemon/SSH admission and delivery transitions only where their + current production callers and parity tests prove identical semantics; + transport connection and encoding mechanics remain in thin adapters. + +Working consolidation budget (a forcing function, never permission to weaken a +correctness gate): + +| Retained responsibility | Gross new production target | +| ---------------------------------------------------------- | --------------------------: | +| Authority identity, transition, persistence, outcomes | <= 5,000 LOC | +| Exact-operation and local/daemon/SSH/relay/paired adapters | <= 3,500 LOC | +| One bounded legacy importer and physical-worker adoption | <= 4,000 LOC | +| Durable app projection and renderer observation controller | <= 2,000 LOC | +| Capability negotiation and compatibility isolation | <= 1,000 LOC | +| **Total retained new production** | **<= 15,500 LOC** | + +The matching deletion floor is at least 15,500 production LOC from the replaced +exact-operation, direct-SSH reconnect/hydration/retry, lease/receipt, renderer +reconciliation/quarantine, and transport-specific state-machine paths. Any +budget overrun requires a file-by-file responsibility and superseded-path ledger; +"architecture" or "compatibility" alone is not a justification. + +Completed construction-tree reductions: + +- removed the in-memory pending-close registry and polling replay state machine; + persisted full-binding close intent now enters the shared SSH termination path. +- removed 29 rootless or superseded production modules (3,609 LOC), including a + renderer authority island that derived ownership from Redux and duplicate + eager-delivery machinery; +- removed 1,980 gross production LOC of separate legacy-cutover catalog state, + store, record, registry, and service code; namespace journal events now carry + complete import and acknowledgement evidence; +- removed 10 app delivery-cursor, settlement/tombstone, receipt-ledger, and + duplicate proof-key modules (1,861 LOC); the retained app projection is + event-keyed and does not own an authority cursor; +- removed 97 production LOC of registry adapters, in-memory stores, validators, + and accepted-write APIs that had only test callers; equivalent fixtures stay + in tests when they still prove live production behavior; +- moved five test-only fixtures (891 LOC) from production compilation into + explicit `__tests__` directories and removed 4,038 LOC of tests that only + exercised the deleted systems. + +Pause-documentation census against +`a7ffb244e45fee0cb75a129aaa726ce7a2f68845`, using +`git diff --numstat --no-renames` plus full line counts for untracked files: + +| Category | Files | Additions | Deletions | Net | +| ---------- | ----: | --------: | --------: | ------: | +| Production | 522 | 65,357 | 4,454 | +60,903 | +| Tests | 268 | 45,936 | 2,995 | +42,941 | +| Docs | 3 | 1,361 | 0 | +1,361 | +| CI/config | 8 | 382 | 3 | +379 | + +The expanded status has 801 paths: 255 tracked entries including 10 deletions, +plus 546 untracked files. HEAD remains the construction base. At +2026-08-07T22:04:56Z, `origin/main` was +`2396e5e3e583a9dd8d237602372e5b66a780e6ac`, with branch distance 0 ahead and +51 behind. No commit, push, rebase, stash mutation, or PR action occurred +during pause closure. + +The increased production count reflects concurrent construction before the +required app-ledger, duplicate exact-operation, legacy relay/SSH, unreachable +file, and compatibility-path deletion passes. It is not an acceptable final PR +size. + +The PR cannot open while a new production module has no production importer or +while a legacy source of truth remains reachable after authoritative admission. + +## Verification record + +Record only commands run against the converged worktree. Earlier focused runs +are useful development evidence but are not final proof. + +### Historical construction evidence + +The date column below uses the America/Los_Angeles calendar date; embedded ISO +timestamps use UTC. These rows preserve development history only. None promotes +a goalpost, journey, or pause-tranche acceptance status. + +| Date | Scope | Command or artifact | Result | +| ---------- | ---------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 2026-08-06 | Baseline | Tracked diff: 214 files, +16,907/-5,548; untracked authority source also present | Not reviewable; census and consolidation required | +| 2026-08-06 | Multi-host audit (`task_48dd7af53ff8`) | Completed audit of host discovery, transport/session keying, restart, concurrent hosts, partial namespace failure, and Docker/paired/remote topologies | Design corrections incorporated: lazy `HostConnection` by `authorityHostId` and namespace-local session state are required; no runtime journey proof is claimed | +| 2026-08-06 | Principal audit (`task_225ba542e4c4`) | Completed audit of app/device identity, host-derived principal, challenge/CAS binding, exact retry, and cross-host authentication boundaries | Design corrections incorporated: one bounded proof identity, mechanism-neutral domain-separated proof, and no blind host-state adoption; no security or runtime proof is claimed | +| 2026-08-06 | Reachability snapshot | 256 new non-test source files / 34,612 LOC; 224 runtime, 13 type-only, 19 unreachable | G6 remains partial | +| 2026-08-06 | First reduction pass | Exact import audit; node/web typecheck; 11 focused files/98 tests; format and stale-import scans | Removed 29 production files/3,609 LOC and 15 obsolete tests/4,038 LOC; one independent worktree-teardown compatibility assertion remains red | +| 2026-08-06 | Durable semantic core | 149 authority tests plus 16 daemon/relay outcome tests; focused node typecheck, lint, format, and max-lines | Journal core and explicit consumer retirement pass; production producer/controller wiring remains | +| 2026-08-06 | Host physical effects | Five focused files / 44 tests; duplicate-exit stress 5/5; touched lint, format, and diff-check | Restart replay, exact current/imported shutdown, lost-response/concurrent retry, successor fencing, and duplicate-exit idempotency pass; daemon applier remains unwired | +| 2026-08-06 | App consumer store | One focused file / 9 tests | Construction evidence only for bounded identity/incarnation persistence; app cursor, settlement, and receipt-ledger deletion remains required, so it is not proof of the target design | +| 2026-08-06 | App outcome broker construction | Four focused files / 24 tests | 11 pass, 13 fail; node typecheck fails only in this mid-refactor slice, so G3 is not production-ready | +| 2026-08-06 | Daemon authority negotiation | Five focused files / 62 tests; preceding daemon/host durability slice 14 files / 121 tests; touched lint, format, and diff-check | Caller-controlled capability, control/stream agreement, disconnect clearing, and fail-closed create admission pass. Capability remains unrequested and unadvertised until app transport and exact host-effect application are production-wired; node typecheck is red only in the concurrent broker refactor. | +| 2026-08-06 | Broker durability correction | Four focused files / 36 tests; scoped lint, format, and max-lines | Invalid boundaries no longer compact durable settlements before rejection; persistence failure leaves memory and delivery unchanged. The app-side settlement ledger remains a scheduled deletion target, so this is construction evidence only. | +| 2026-08-06 | Self-contained namespace migration | Focused migration, service, registry, SSH, relay, and authority suites / 244 tests; CLI/web typechecks, scoped standard/native/type-aware lint, format, max-lines, forbidden-symbol audit, and diff-check | Removed 1,980 gross production LOC of catalog state/store/registry/service code. Namespace journals and checkpoints replay complete import evidence, exact retry, acknowledgement, worker routing, and identity-scoped ambiguity fences without a second catalog. Production topology attach and legacy dual-write deletion remain, so G4 stays partial. | +| 2026-08-06 | Renderer subscription lifecycle fence | Seven broker/store/preload files / 39 tests; scoped typecheck, lint, format, max-lines, and diff-check | A subscribe admitted before navigation, renderer loss, detach, or failed send cannot resurrect afterward; admitted ACKs and fresh subscriptions still recover. G3 remains partial because durable app projection and final-host transport are not wired. | +| 2026-08-06 | Relay authority grace boundary | Real relay subprocess plus focused grace/lifecycle suites, 62 tests; targeted lint, format, and max-lines | Finite grace, detached/no-client time, disconnect, and reconnect do not dispose an authoritative PTY; exact retirement and explicit administrative shutdown still do. The unchanged isolated legacy path retains compatibility grace behavior. | +| 2026-08-06 | App outcome controller boundary | Twelve renderer files / 86 tests; web typecheck, scoped default/native/type-aware lint, format, max-lines, and diff-check | Listener-before-snapshot buffering, per-stream ordering, incarnation fencing, async handler settlement, cumulative ACK retry, and canonical duplicate validation pass. Legacy callbacks have no idempotent-projection admission marker and therefore cannot run or ACK; crash-safe projection and host transport remain required. | +| 2026-08-06 | App cursor and ledger deletion (`task_12f08f0ddc0a`) | 56 app tests, 24 shared transport tests, 3 materialization tests, 29 side-effect tests, node/web typechecks, scoped lint, format, max-lines, reachability, and a bounded 25,000-event benchmark | Deleted the app delivery cursor, per-outcome settlement/tombstone path, duplicate proof-key store, and receipt ledger: 10 production modules/1,861 LOC removed. The retained app projection is event-keyed rather than a second authority cursor; final-host transport installation remains required. | +| 2026-08-06 | Adversarial proof/CAS audit (`task_d5b816fb16ee`) | Read-only daemon, relay/SSH, proof, CAS, identity, and mixed-version audit; seven focused files / 52 tests | No P0. Five P1 blockers remain: unwired production proof transport, boundary/grant admission deadlock, client-selected mixed-version fallback, success publication before admission commit, and non-transactional policy/admission commit. Replay expiry, scoped challenge capacity, canonical encoding, disconnect cleanup, fail-closed identity loading, and mandatory negotiated SSH grants also require correction and proof. | +| 2026-08-06 | Construction LOC census | `git diff --numstat --no-renames HEAD` plus untracked-file line counts at 2026-08-07T05:15:52Z, categorized separately for production and tests | Production: 444 files, +58,456/-3,600, net +54,856. Tests: 231 files, +37,595/-2,299, net +35,296. This is a transient construction tree, not completion evidence; G6 and the net-nonpositive PR gate remain unproven. | +| 2026-08-06 | Test-only production seam deletion | Production-import search, five focused test files / 30 tests, web typecheck, scoped default/native/type-aware lint, format, max-lines, and diff-check | Removed 97 production LOC across six exports with only test callers. Test-local fixtures preserve the useful registry/cursor behavior; no production entrypoint or compatibility path changed. | +| 2026-08-06 | Identity marker/reset decision | Reset prerequisite audit plus keypair, marker, and canonical-user-data migration review | Settled the legacy conflict without a permissive loader: only the pre-marker keypair schema may migrate; current keypairs bind a stable installation identity to an unchanged marker, and first install/reset activate one staged keypair by atomic rename. Production implementation and crash/platform proof remain required. | + +### Final pause acceptance evidence + +This table is populated only from commands completed after the host and E2EE +implementations converge. Pause evidence remains narrower than the G0-G7 and +13-journey release gates. + +| Timestamp (UTC) | Platform/host | Scope | Exact command or artifact | Exit | Counts/duration | Result | +| ------------------------------ | ------------- | ---------------------------------- | ---------------------------------------------------------------------------------------------------- | ---: | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------ | +| 2026-08-07T19:52:41Z–20:01:59Z | macOS arm64 | Host-authority closure | `/tmp/react185-host-authority-closure-acceptance.md` | 0 | 466 PTY + 168 adjacent + 7 exact-close tests; static gates clean | Accepted in owned pause scope; P0/P1/P2=0. | +| 2026-08-07 | macOS arm64 | E2EE storage lifecycle | `/tmp/react185-e2ee-storage-acceptance.md` | 0 | 108 storage + 60 remote-userData + 9 regression + 8 injected-Windows tests | Accepted in owned pause scope; P0/P1/P2=0. | +| 2026-08-07T20:21:38Z–20:40:06Z | macOS arm64 | Initial combined validation | `/tmp/react185-final-combined-validation.md` | 1 | Prior broad: 827/8,829 with 2 failures; rerun: 828/8,830 with 1 failure | Blocked only by the 30-second SSH scale fixture setup occurring inside the timed assertion. | +| 2026-08-07T20:51:09Z–20:54:03Z | macOS arm64 | Test-only scale-fixture correction | `/tmp/react185-broad-ssh-scale-fixture-closure.md` | 0 | Five SSH and five normal-parallel host-effect + SSH passes; broad exit 0 in 57.695 s | Product command, 15,197-entry scale, oracle, and 30-second assertion timeout remained unchanged. | +| 2026-08-07T21:00:32Z–21:02:59Z | macOS arm64 | Independent combined closure | `/tmp/react185-final-combined-validation-closure.md` | 0 | Broad: 829 files/8,831 tests passed; 7/31 skipped; 57.310 s | Pause validation accepted; P0/P1/P2=0. Release goalposts and journeys remain unchanged. | +| 2026-08-07 | macOS arm64 | Documentation reconciliation | `/tmp/react185-terminal-authority-docs-audit.md`; `/tmp/react185-terminal-authority-docs-closure.md` | 0 | 3 documents/1,361 lines; four P2s corrected; final P0/P1/P2=0 | Accepted only for the pause boundary; release classifications and gates remain unchanged. | + +## PR gate + +Before committing or opening the PR: + +1. Every G0-G7 row is `proven` with current evidence. +2. The required-journey matrix is fully proven on the converged tree. +3. Production, test, documentation, and CI LOC are reported separately. + Net production LOC is at or below zero, or every positive retained module is + justified after an independent reachability and duplicate-state-machine + audit. +4. The capability remains unadvertised until cutover, durable outcomes, exact + operations, topology attach, and mixed-version proof are complete. +5. Independent repository review and the release-readiness checklist have no + unresolved correctness, security, compatibility, or performance findings. +6. The multi-host and principal audits remain design evidence only; they do not + promote G1, G3, G5, or G6 to `proven` without the named production journeys, + challenge/grant tests, and deletion/reachability evidence. +7. Accepting the host-authority and E2EE-storage pause tranches satisfies none + of G0-G7, the 13 journeys, final LOC, platform, performance, review, commit, + push, or PR gates by itself. diff --git a/docs/reference/terminal-session-authority-handoff.md b/docs/reference/terminal-session-authority-handoff.md new file mode 100644 index 00000000000..8f3fe512cf6 --- /dev/null +++ b/docs/reference/terminal-session-authority-handoff.md @@ -0,0 +1,256 @@ +# Terminal session authority continuation handoff + +This handoff is the restart point for the comprehensive terminal-session +authority replacement. Read the normative +[`design`](./terminal-session-authority.md) first, then the numeric +[`delivery ledger`](./terminal-session-authority-delivery.md). This document +does not weaken either one. + +## Intended outcome + +Replace PR #12600's containment mechanisms with one final-host authority model: + +- no sliding windows, quarantine verdicts, retry-count identity decisions, or + inference from absence; +- no duplicate app cursor, settlement ledger, receipt ledger, durable grant + ledger, retirement-receipt ledger, or app-global failure fence; +- one exact pane-generation/PTY-incarnation binding on the final host; +- one active source delivery lease per exact host/namespace/binding, with + provider, client, owner, and delivery generations fencing a provisional + replacement before it can publish, ACK, or mutate; +- separate host physical-effect and authenticated app/device consumers reading + the same ordered journal through final-host cumulative cursors; +- one bounded proof identity, host-derived principals, and namespace-local + admission and retirement; +- one ordered, crash-resumable identity reset and re-enrollment transaction; +- stage-before-marker E2EE publication and canonical migration equality based + on validated identity lineage rather than mutable-file byte equality; +- isolated mixed-version legacy behavior, with no authoritative fallback; +- sandboxed-preload safety enforced against every emitted preload artifact + before build output is written, with `electron` as the sole external; +- complete macOS, Linux, Windows, WSL, daemon, SSH, paired-runtime, remote, + folder, floating, and version-skew proof; +- net production LOC at or below zero after the replacement paths delete the + systems they supersede. + +Correctness, security, compatibility, and performance gates cannot be weakened +to reach the line-count target. Conversely, passing tests cannot justify leaving +parallel sources of truth in production. + +## Numeric progress + +The pause snapshot must preserve these release classifications even after both +implementation tranches are accepted: + +- goalposts: **0/8 proven, 6/8 partial, 2/8 not started**; +- required journeys: **0/13 proven**; +- accepted pause tranches: a construction milestone only, never promotion of a + release goalpost or journey. + +## Pause boundary + +The requested pause occurs only after all four gates are closed: + +1. Host authority has a fresh adversarial audit with no unresolved owned-scope + finding: independent host/app consumers, proof-derived daemon and SSH + transports, namespace-local admission, exact reconnect, cumulative ACK, and + authenticated retirement. +2. E2EE storage lifecycle has a fresh adversarial audit with no unresolved + owned-scope finding: strict identity loading, installation marker, + purpose-bound stages, exact successor reuse, durable replacement, canonical + lifecycle migration, and strict registry/outbox reset loaders. +3. The frozen combined tree passes a fresh independent broad authority run, + and that closure reconciles, rather than redundantly reruns, the already + fresh correctness, SSH, performance, static, build, reliability, wire, + Docker, host, and E2EE receipts. +4. The normative design, numeric delivery ledger, and continuation handoff are + reconciled to that evidence, formatted, and independently audited. + +The pause does **not** mean the comprehensive change is shippable. Identity +reset orchestration, legacy deletion/consolidation, all required journeys, +performance proof, rebase, final review, commit, push, and PR creation remain +outside this boundary. + +## Repository state + +- Worktree: `/Users/nwparker/orca/workspaces/orca/eye-React-185` +- Branch: `nwparker/react185-session-authority` +- HEAD and construction base: + `a7ffb244e45fee0cb75a129aaa726ce7a2f68845` +- `origin/main` at 2026-08-07T22:04:56Z: + `2396e5e3e583a9dd8d237602372e5b66a780e6ac` +- Branch distance at that snapshot: 0 ahead, 51 behind. +- Expanded status before documentation finalization: 801 paths—255 tracked + entries including 10 deletions, plus 546 untracked files. +- Orchestration run: `run_2bf3603a101a` +- The worktree is intentionally dirty and contains concurrent construction + changes. Preserve unrelated edits and the exact stash entry + `stash@{0}: On nwparker/react185-session-authority: react185 terminal authority architecture probe before current-main rebuild`. +- No commit, push, rebase, stash mutation, or PR action occurred during pause + closure. None is authorized until every delivery gate passes. + +Categorized working-tree census against the construction base, using +`git diff --numstat --no-renames` plus full line counts for untracked files: + +| Category | Files | Additions | Deletions | Net | +| ---------- | ----: | --------: | --------: | ------: | +| Production | 522 | 65,357 | 4,454 | +60,903 | +| Tests | 268 | 45,936 | 2,995 | +42,941 | +| Docs | 3 | 1,361 | 0 | +1,361 | +| CI/config | 8 | 382 | 3 | +379 | + +Recalculate the categorized diff census and active dispatches after the final +documentation audit; the values above are an evidence checkpoint, not a +substitute for that final read. + +## Acceptance record + +The final pause update must record each gate as one of: + +- `accepted`: implementation, coordinator inspection, focused verification, and + a fresh adversarial audit have no unresolved owned finding; +- `implemented, unaccepted`: the implementation worker finished but independent + review found or has not excluded a blocker; +- `not implemented`. + +| Gate | Pause status | Owned scope | Independent audit and exact evidence | Residual limitation | +| ----------------------------- | ------------ | --------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | +| Host-authority implementation | accepted | Host/app consumers, proof-derived daemon/SSH transports, namespace admission, reconnect, cumulative ACK, retire | `/tmp/react185-host-authority-closure-acceptance.md`: 466 PTY + 168 adjacent + 7 exact-close tests; P0/P1/P2=0. | No physical Windows, WSL, paired-runtime, remote-server, or two-independent-host proof. | +| E2EE storage lifecycle | accepted | Strict identity load, stage/marker/backup recovery, durable replacement, canonical lifecycle migration | `/tmp/react185-e2ee-storage-acceptance.md`: 108 storage + 60 remote-userData + 9 regression + 8 injected-Windows tests; P0/P1/P2=0. | Injected Windows semantics passed; real Windows and physical power-loss durability remain unproved. | +| Frozen combined validation | accepted | SSH scale fixture, authority matrix, hot path, reliability, preload/build, wire, Docker, typecheck, lint | `/tmp/react185-final-combined-validation-closure.md`: fresh broad 829 files/8,831 tests passed; prior fresh receipts were reconciled, not rerun. | Full release platform, journey, and production-scale gates remain unproved. | +| Documentation package | accepted | Normative design, numeric ledger, continuation handoff, categorized LOC, exact residual gaps | `/tmp/react185-terminal-authority-docs-audit.md`: four P2s corrected; clean final confirmation in `/tmp/react185-terminal-authority-docs-closure.md`. | Pause-only acceptance; release goalposts, journeys, LOC, platform, performance, and review gates remain. | + +`accepted` means only that the named pause gate has sufficient scoped evidence +to stop and hand off. It does not mean release-ready and does not satisfy G0-G7, +any required journey, the LOC gate, or the final repository/readiness reviews. + +## Platform and provider gaps + +| Surface | Pause evidence | Release gap | +| ------------------------- | ------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------- | +| macOS | Current development host; focused host and E2EE suites plus injected failure tests. | No complete local production restart/restore journey or power-loss test. | +| Docker Linux/OpenSSH | One-host `MaxSessions=1` authority journeys exercise reconnect, contention, rejection, relaunch. | Does not prove a native packaged Linux client, glibc 2.31 floor, two hosts, or an unreliable real network. | +| Real Windows | Injected `win32` storage/ACL/rename semantics only. | No Windows filesystem flush, packaged preload, ConPTY, named-pipe, native SSH, or app-restart run. | +| Native Linux/glibc floor | Linux OpenSSH runs inside Docker. | No packaged Ubuntu 20.04/glibc 2.31 client or bundled-native-symbol verification in this pause tranche. | +| WSL | Path/identity seams have deterministic tests. | No physical WSL daemon, Git Bash/cmd boundary, reconnect, or restart journey. | +| Paired runtime | Proof, wire, and projection components have focused tests. | No live paired-runtime reconnect or independently updated peer journey. | +| Remote server | Remote `userData` passthrough/isolation has focused tests. | No live remote-server authority, restart, or mixed-version journey. | +| Two independent SSH hosts | Host and namespace isolation have focused tests. | No simultaneous two-host Docker journey. | +| Mixed versions | In-process current/old terminal-wire test covers both directions. | No live SSH/paired/remote version-skew journey or mobile/E2EE wire proof. | +| Power-loss durability | Crash-cut and syscall-failure injection covers stage, marker, active, and backup boundaries. | No physical abrupt-power-loss run, especially on Windows filesystems. | +| Performance and scale | Bounded hot-path test measures 25,000 operations with topology-write and heap/queue assertions. | No packaged cross-platform interaction, startup, long-session, or production-scale baseline. | +| Folder/floating/drive/UNC | Pure locator and folder/workspace tests cover representative path shapes. | No complete live floating, drive-letter, or UNC workspace journey. | + +## Remaining work in dependency order + +1. **Identity reset and re-enrollment** + - Persist the bounded reset record before mutation. + - Freeze new pairing and authority admission. + - Retire the old proof-derived principal on every known final host. + - Wait for every relay revoke outbox acknowledgement. + - Close transports, then remove local credentials. + - Publish the exact staged successor, invalidate captured proof material, + verify through the strict loader, enter re-enrollment, and clear the + transaction. + - Retry the whole immutable host intent rather than persisting per-target + receipts. + +2. **Reachability and deletion audit** + - Trace every new production module from a real entrypoint. + - Start with the test-only SSH fixtures and duplicate E2EE key-material and + authority-namespace equality functions recorded in the delivery ledger; + repeat the import scan on the resumed tree before moving or deleting them. + - Delete unreachable construction modules, test fixtures in production, + duplicate exact-operation clients, and transport-specific state machines. + - Prove that app cursors, settlement/receipt ledgers, host-current adoption, + quarantine, and timing verdicts are unreachable after authoritative + admission. + +3. **Consolidation to the four-role design and LOC reduction** + - One final-host service. + - Thin exact local/daemon/SSH/WSL/relay/paired adapters. + - One app projection/controller. + - One bounded legacy importer that disappears as a writer after cutover. + - Drive the rebased production diff to net non-positive. + +4. **All runtime, platform, wire, and performance journeys** + - Prove all 13 journeys in the delivery ledger. + - Use Docker OpenSSH with `MaxSessions=1` and two independent SSH hosts. + - Cover daemon, WSL, paired runtime, remote server, folder/floating/UNC, + both mixed-version directions, reset crash recovery, and performance. + +5. **Rebase and final categorized census** + - Rebase onto current `origin/main` without losing the design invariants. + - Re-run the tracked/untracked production, test, docs, CI/config, and other + LOC census against the actual PR base. + - Reach net-nonpositive production LOC or provide the required file-by-file + retained-module and superseded-path justification. + +6. **Independent release review** + - Run independent repository and release-readiness reviews on the rebased, + fully validated tree. + - Resolve every P0-P2 or record an explicit owner-approved release decision + where the governing checklist permits one. + +7. **Commit, push, and comprehensive PR** + - Only after G0-G7, all 13 journeys, LOC, static, performance, compatibility, + and review gates pass, create the commit, push, and one detailed PR. + +## Risks that must remain explicit + +- Replacement and legacy systems coexist during construction; this is not an + acceptable final architecture. +- A green focused suite is construction evidence, not remote, platform, wire, + or power-loss proof. +- Remote clients and hosts update independently. New stream semantics require + a separately negotiated capability; unknown opcodes may otherwise disappear. +- A host-wide SSH credential authenticates transport only. It cannot identify + an app/device consumer or key a cursor. +- Missing transport, timeout, disconnect, failed inspection, and inventory + absence remain unknown. None may authorize cleanup, exit, takeover, or + replacement. +- Any identity stage, active-file replacement, migration rename, or reset phase + that returns success must survive the documented crash boundary. + +## First safe resume commands + +Run these from the worktree before editing: + +```bash +orca status --json +orca skills get orchestration +orca orchestration run-use --id run_2bf3603a101a --json +orca orchestration check --json +git status --short --branch --untracked-files=all +git stash list +git rev-list --left-right --count HEAD...origin/main +``` + +Process and acknowledge any returned delivery before dispatching more work. +Release every settled worker before acknowledging its `worker_done`. Never +reuse an implementation worker as its own independent auditor. + +## Validation record required at pause + +The full command manifest, UTC windows, durations, counts, and cleanup scan are +in `/tmp/react185-final-combined-validation-closure.md`. That closure freshly +reran the broad authority tree and reconciled, rather than redundantly reran, +the prior fresh receipts below. Each row preserves its exact command and +outcome. + +| Scope | Exact command or artifact | Outcome | +| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Host focused | `pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty.test.ts --reporter=dot` | Exit 0; 466 passed. The adjacent 168 and exact-close 7 are recorded in `/tmp/react185-host-authority-closure-acceptance.md`; P0/P1/P2=0. | +| E2EE storage | `pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/e2ee-keypair.test.ts src/main/runtime/mobile-pairing-userdata-path.test.ts src/main/runtime/device-registry.test.ts src/main/runtime/relay/relay-revoke-outbox.test.ts src/main/durable-file-write.test.ts src/main/durable-file-write-syscall-proof.test.ts src/shared/secure-file.test.ts src/shared/secure-file-coarse-ctime.test.ts --reporter=verbose` | Exit 0; 8 files/108 tests. Remote-userData, lifecycle regression, and injected-Windows commands are recorded in `/tmp/react185-e2ee-storage-acceptance.md`; P0/P1/P2=0. | +| Broad authority tree | `pnpm exec vitest run --config config/vitest.config.ts --testTimeout=120000 src/main/session-authority src/main/daemon src/main/ssh src/relay src/shared --reporter=dot` | Exit 0; 829 files/8,831 tests passed, 7/31 skipped, 57.310 s. | +| Reliability manifest | `pnpm run check:reliability-gates` | Exit 0; 70 gates. | +| Reliability contracts | `pnpm exec vitest run --config config/vitest.config.ts config/scripts/check-reliability-gates.test.mjs config/scripts/pr-e2e-gate-contract.test.mjs --reporter=dot` | Exit 0; 2 files/32 tests. | +| Wire skew | `ORCA_CROSS_VERSION_BASELINE_REF=v1.4.174 pnpm exec vitest run --config config/vitest.config.ts tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts --reporter=dot` | Exit 0; 4/4. | +| Docker SSH | `pnpm run test:e2e:ssh-docker-terminal-authority -- --repeat-each=3` | Exit 0; 12/12 across four `MaxSessions=1` journeys repeated three times. | +| Electron build | `pnpm run build:electron-vite` | Exit 0; emitted preload inspection also clean. | +| Static gates | `pnpm run typecheck:node`; standard/native/type-aware `oxlint` over the recorded 783-file manifest; `oxfmt --check` over that manifest; `pnpm run check:max-lines-ratchet`; `git diff --check` | Every command exited 0; 352 grandfathered max-lines suppressions and no new bypass. | +| Documentation | Fresh format, relative-link, tracked/untracked whitespace, placeholder, categorized-census, and independent audit checks | Exit 0; 3 documents/1,361 lines; four P2s corrected; final P0/P1/P2=0 in `/tmp/react185-terminal-authority-docs-closure.md`. | + +The release-only Windows, WSL, native-glibc, paired/live-remote, two-host, +power-loss, folder/floating/UNC, and production-scale gaps remain listed above. +No omitted command is inferred from a narrower pass. diff --git a/docs/reference/terminal-session-authority.md b/docs/reference/terminal-session-authority.md new file mode 100644 index 00000000000..5195aca6c30 --- /dev/null +++ b/docs/reference/terminal-session-authority.md @@ -0,0 +1,700 @@ +# Terminal session authority + +Orca treats a terminal pane as one durable logical generation bound to one exact +PTY incarnation. The process that owns the PTY also owns this binding and its +ordered topology journal. Renderer state, SSH leases, workspace snapshots, and +runtime maps are projections; none may infer a replacement binding or destroy a +PTY from absence alone. + +This is the implementation contract derived from the SSH authority handover for +STA-3077/#11729. It generalizes that diagnosis across local, daemon, WSL, SSH, +paired-runtime, and remote-server ownership. It replaces quarantine, elapsed-time +windows, retry-count verdicts, and increasingly clever reconciliation; those may +remain only inside an unchanged pre-cutover legacy path. + +This contract is the long-term replacement for the containment approaches in +PRs #12264, #12743, and #12600. Their incident evidence and non-destructive +"unknown is not dead" rule remain inputs; their sliding windows, permanent +quarantine verdicts, and peer-store reconciliation are not the target design. + +## Document authority + +This file is the normative design. Implementation must be changed when it +contradicts this contract; passing focused tests does not amend the design. + +- [`terminal-session-authority-delivery.md`](./terminal-session-authority-delivery.md) + is the proof ledger. It records numeric goalposts, required journeys, + reduction targets, and exact verification evidence. +- [`terminal-session-authority-handoff.md`](./terminal-session-authority-handoff.md) + is the continuation boundary. It records the worktree state, accepted + tranches, unresolved risks, dependency order, and first safe resume command. + +No worker report or construction test promotes a goalpost by itself. A delivery +row becomes `proven` only when the named production path and its full-scope +oracle both exist on the converged tree. + +Accepting the host-authority or E2EE-storage pause tranche is likewise only a +construction milestone. It does not promote any G0-G7 release goalpost or any +required end-to-end journey in the delivery ledger. + +The multi-host and principal audits (task_48dd7af53ff8 and +task_225ba542e4c4) are design inputs, not runtime proof. They require host-local +authority and authentication state to remain separate for every discovered host +and namespace; this document therefore has no app-global ownership transition +or singleton host transport to reconcile. + +## Invariants + +1. One pane generation has at most one live PTY binding. +2. One PTY incarnation is bound to at most one pane generation. +3. Input, resize, signal, close, output, and exit name the exact binding. +4. A stale operation cannot affect a newer pane or PTY incarnation. +5. Disconnect, timeout, missing transport, and missing projection mean unknown, + never dead. +6. A topology mutation is acknowledged only after its operation and result are + durable. Retrying before outcome ACK returns that same result; older retries + below the durable revision floor fail closed. +7. Output and terminal outcomes are ordered. Gaps require a snapshot and never + permit silent continuation. +8. Renderer mount, unmount, park, and reveal only attach or detach a view. +9. Routing facts never become identity. An SSH target, connection generation, + pairing revision, WSL distro, or relay hop can admit a request but cannot + identify a PTY. +10. Ordinary input and output do not write the topology journal. +11. Host connection, namespace admission, grant, failure, and retirement state + are never singleton or app-global; their keys include the final host and, + for namespace state, the host-local namespace. +12. A durable app/device identity proves possession of its private key. A + process or session nonce is an incarnation fence, not a replacement + identity and never authorizes adoption of host state. +13. Final-host cumulative cursors are the only delivery cursors. The app has no + delivery cursor, settlement ledger, receipt ledger, or global failure fence. +14. One exact host, namespace, and PTY binding has at most one active source + delivery lease. A provisional replacement cannot publish, ACK, mutate, or + consume credit. +15. Every source span, credit ACK, cancellation, replay, and exit is fenced by + its delivery token plus provider, client, and owner generations. A match on + the physical PTY ID alone never admits data. +16. Source replacement fences the predecessor before promoting the provisional + source. Bytes already queued under the predecessor are rejected after that + fence and cannot leak into the successor generation. +17. Source activation and failure are namespace-local. Replacing or losing one + namespace source cannot pause, publish, ACK, or mutate another namespace. + +## Identity + +The authority host mints `authorityHostId` and `namespaceId`. A namespace is +resolved from a host-local locator for a git worktree, folder workspace, or +floating workspace. Client-local repository IDs and SSH target IDs are not +namespace IDs. + +An exact binding contains: + +```text +authorityHostId +namespaceId +paneKey +paneGenerationId +ownerIncarnationId +physicalPtyId +ptyIncarnationId +``` + +`paneKey` is stable placement identity. `paneGenerationId` distinguishes a pane +that was closed and later recreated at the same placement. `ownerIncarnationId` +fences a restarted PTY worker. `ptyIncarnationId` fences reuse of a physical PTY +ID. + +The app/device has one bounded stable proof-of-possession identity shared across +its host connections. It consists of a stable public verification identity and +its protected private key; it does not contain a committed/pending predecessor +pair, host-current state, delivery cursor, settlement record, or receipt ledger. +Each process or session creates a fresh unpredictable nonce and discards it when +that process/session ends. A nonce fences an incarnation but cannot be promoted +to a new durable identity. + +The security contract is mechanism-neutral. The proof must establish possession +of the stable private key, bind a fresh host challenge to its exact context, and +provide replay, cross-host, cross-namespace, cross-purpose, and cross-connection +separation. The implementation should first evaluate the existing durable E2EE +keypair; it may serve this purpose only when a domain-separated proof satisfies +all of those requirements. Otherwise it must use a dedicated signing key. A +failed fit must not silently weaken the proof or fall back to an unauthenticated +client-supplied identifier. + +Identity loading is a security boundary. Explicit first-install provisioning may +create the keypair once; after establishment, a missing, malformed, oversized, +noncanonical, wrong-version, or public/private-mismatched record fails closed and +is never silently regenerated. Loading derives the public key from the private +key and compares it with the stored public identity. Recovery is an explicit +reset and re-enrollment operation that revokes or retires the old device grants +and remote consumer identity before creating a successor. User-data migration +moves the keypair, its establishment evidence, and paired-device credentials as +one lifecycle unit. Establishment evidence is not a second key store, cursor, +receipt, or authority ledger. + +The establishment marker names one random installation identity, not the active +consumer key. The current keypair record carries that same installation identity, +so rotating the consumer key does not require a second-file activation commit. +A validated legacy keypair schema that predates installation identity is the only +markerless record eligible for one-way migration. Migration validates and +durably stages the current-schema record with the exact legacy key material +before publishing its marker, then atomically promotes that same stage. Every +crash cut around stage, marker, active-file replacement, and replacement backup +reuses that exact staged key; recovery never generates a different identity. A +legacy keypair found beside a marker fails closed unless the exact validated +migration stage proves that interrupted publication. A current keypair with a +missing or mismatched marker also fails closed; neither shape can masquerade as +an eligible legacy installation. + +First install writes and validates a non-active staged current-schema keypair, +publishes its matching installation marker, then atomically renames the staged +record into the active keypair path. Startup may finish only that exact staged +publication; it never generates a different key after the marker exists. Reset +keeps the installation marker unchanged and, only after all old remote identity +retirements and credential revocations complete, atomically replaces the active +keypair with a validated staged successor carrying the same installation +identity. The staging artifact is bounded, private, and never an accepted proof +identity or second logical key store. Canonical user-data migration applies the +same rules to the registry, keypair, marker, and any in-progress staging record; +a partial target is never made active and the source remains recoverable. If +both source and canonical target are complete, migration no-ops only when their +validated installation identity, key lineage, and reset-stage binding agree. +Equivalent validated lineage, not raw JSON serialization or mutable registry and +outbox bytes, decides canonical equality. Two unrelated complete identities are +an ambiguity and fail closed. After a proven publication, later source registry +or outbox drift does not overwrite or invalidate the canonical target; key, +installation, or reset-transaction lineage divergence still fails closed. +Each stage declares `first-install` or `reset`; a reset stage is bound to the +reset transaction ID and immutable old proof-key public fingerprint. The +ordinary loader may finish only a first-install stage when no active keypair +exists. It never promotes a reset stage or replaces an +active keypair; only the matching transaction in `creating-successor` may reuse +and atomically publish that exact stage. Once either stage is durable, recovery +never generates a different key. Reset publication retains that exact stage even +after it becomes the active keypair. Only a later durable phase of the same sole +reset transaction may remove it after verifying that the active and staged key +material still match and that the transaction still names the original +predecessor. Stage, publish, and finalize retries validate both bindings even +after the active path contains the successor. A reused transaction ID with a +different predecessor fingerprint fails closed. There is no successor-only, +activation, publication, or receipt ledger beside the reset transaction; the +retained stage is the bounded crash-recovery artifact. + +## Identity reset and re-enrollment + +Reset is one resumable main-process transaction, not a collection of revoke +callbacks. Before any mutation it strictly loads the established identity, +marker, device registry, relay revoke outbox, and any existing reset record; +freezes new pairing and authority admission; resolves the bounded immutable set +of known final hosts; and requires every needed retirement capability. Corrupt, +missing, offline, unknown, or incompatible state stops before mutation. + +The sole reset record contains only its version, transaction ID, old proof-key +public fingerprint, request time, current phase, and bounded target +host/namespace intent. Each host derives its own consumer principal from that +proof identity and `authorityHostId`; there is no global consumer ID to persist. +The record has no private key, successor key, per-target completion bits, host +cursor, receipt, settlement, delivery cursor, or global failure fence. A crash +after a subset of acknowledgements reissues the same idempotent retirement to +the whole target set; the final-host journal and relay revoke outbox remain the +durable acknowledgement sources. + +The phase order is strict and monotonic: + +1. Persist reset intent before any remote or local mutation. +2. Reconnect with the old proof identity as needed and durably retire it on + every known final host. A timeout, disconnect, or lost response remains + unknown; exact retry or fresh authenticated resume must obtain the explicit + idempotent retirement result. The same live connection replays its exact + cached result. After a host restart, a fresh retirement-specific possession + challenge may confirm that the derived host-local principal is already + absent without claiming it again; unauthenticated inventory absence cannot. +3. Enqueue every old relay binding in the existing revoke outbox and wait until + every parsed relay acknowledgement removes its item. +4. Close live mobile, paired-runtime, daemon, and SSH transports only after the + remote retirement and relay phases complete. +5. Atomically remove old local device/runtime credentials. Per-device revoke + and mobile host-removal APIs may not bypass this ordering or report success + after merely scheduling cleanup. +6. Stage, validate, and atomically publish the successor keypair with the same + installation identity, while retaining the exact transaction-bound stage. + Durably advance the sole reset record past publication, invalidate cached + proof material, and recreate any service that captured the old key. +7. Verify the successor and retained stage through the strict loader, durably + enter the later finalization phase in that same reset record, then remove the + stage. Enter re-enrollment and remove the transaction record only after that + cleanup boundary. Only this phase may report completion. + +There is no rollback after a host retirement commits. Startup resumes the +recorded phase with the old active key until successor publication, and with the +new key afterward. Unsupported mixed-version peers remain explicitly pending; +disconnect, admission cancellation, legacy revoke, inventory absence, or +host-current state never substitutes for authenticated retirement. Settings +shows durable phase and pending-host status after confirmation, and closing the +dialog cannot cancel a started transaction. + +Every storage mutation whose success advances an identity-lifecycle phase uses +an explicit durable write or rename: file sync before publication and containing +directory sync afterward where the platform supports it. A real POSIX sync +failure propagates instead of being reported as durable success; unsupported +platform or filesystem behavior is handled explicitly. This durability is +opt-in at lifecycle boundaries. The ordinary secure-file writer keeps its +existing rename-only cost, so unrelated credential and settings writes do not +inherit synchronous fsync latency. + +## Host boundary + +The authority runs on the final host that owns the managed PTY. An authoritative +local or WSL PTY is owned by the stable local terminal daemon; the direct +in-process provider remains an isolated legacy path and is never a post-cutover +fallback. A direct SSH PTY is owned by the stable service on the SSH host. In +nested SSH, a shell on host A running `ssh B` remains an A-owned PTY; an Orca +runtime explicitly hosted on B uses B's terminal service and owns its PTYs on B. + +The stable host service owns: + +- namespace resolution and one writer lease epoch per namespace; +- pane membership and exact bindings; +- idempotent topology operations and their durable results; +- PTY workers, bounded replay, and exact input admission; +- ordered terminal outcomes and durable consumer cursors. + +Packaged relays and paired runtimes are transport adapters. App upgrades do not +restart PTY workers. A compatible newer adapter connects to the existing host +service; an incompatible adapter fails before mutation. The service drains or +upgrades its control plane only when workers can remain attached. + +`HostConnection` is keyed by `authorityHostId`, not by one process-wide +connection slot or by an SSH endpoint string. Discovery is lazy: a configured or +previously known host is contacted only when a surface needs it, and an +uncontacted or skipped host has no fabricated session, grant, or failure state. +Several host connections may be live concurrently. A restart that skipped a host +must rediscover that same host before resuming its sessions; it must not adopt +host-current state from memory or from another host. + +Reconnect is deterministic in the narrow protocol sense: the same authenticated +proof resolves the same final host and namespace, resumes the same durable +consumer principal and cumulative cursor, and either reattaches the exact pane +generation and PTY incarnation or returns an explicit unresolved/rejected +result. Transport loss replaces and fences only the failed connection +generation. While a replacement transport is unavailable, work parks or retries +with bounded backoff and bounded memory; it never busy-loops, fabricates a new +identity, spawns a replacement PTY, adopts host-current state, or lets elapsed +time decide liveness. This is the common contract for daemon, SSH, WSL, +paired-runtime, and remote-server reconnects; their adapters differ only in how +they reach the final host. + +Each `NamespaceSession` and its admission, grant, failure, and retirement state +is keyed by the pair `authorityHostId + namespaceId`. A multiplexed +`HostConnection` may carry many such sessions, but one namespace's failure can +only fail that namespace. There is no process-wide failure fence and no +best-effort batch whose success is inferred from a prefix. + +The host derives the consumer principal from the authenticated proof and its own +`authorityHostId`. A client-supplied profile/device label is metadata at most; +it is never the principal or a cursor key. A host-wide SSH endpoint credential +authenticates transport access but cannot identify an app/device consumer. The +derived principal is host-local, so the same stable app/device proof yields +independent consumer principals on independent hosts. + +For every namespace admission, the host issues a challenge containing exactly: + +```text +authorityHostId +namespaceId +currentCas +candidateProcessOrSessionNonce +requestId +exactConnectionGrant +``` + +The app proves possession over that canonical challenge. The host verifies the +host and namespace, compares the supplied `currentAdmissionCas`, binds the +candidate nonce and request to the exact connection grant, and performs the CAS +transition atomically. Challenge, proof, request, and result are all scoped to +the `authorityHostId + namespaceId` key. A request retry is accepted only when it +is the exact same canonical request for the same grant and request ID; it +returns the original result idempotently. A changed nonce, CAS, namespace, +grant, or request body is rejected. A lost response therefore retries the exact +request; it does not fetch and adopt whatever state the host currently reports. + +## Product decisions + +- A session surface is scoped by the final host's namespace locator for a git + worktree, folder workspace, or floating workspace. SSH target IDs, client repo + IDs, drive spellings, and connection generations are routing aliases only. +- Each namespace has one active topology writer epoch. Other devices observe or + request handoff; there is no replace-all multi-writer merge. +- Detaching a view keeps the pane generation and PTY alive. Closing is an + explicit idempotent topology mutation that terminates that exact binding. +- Ambiguous, unavailable, or rejected attach never spawns a replacement PTY. + Only an explicit create for a fresh pane generation may create a shell. +- PTYs may survive with no connected client. Retention cleanup requires an + explicit authority operation and exact binding; elapsed time alone never + proves death. +- Terminal membership, pane generation, binding, and semantic outcome order are + host-authoritative. Window placement and other non-terminal chrome remain + client-local. +- Old clients may use an unchanged, visibly isolated legacy namespace before + cutover. After cutover they are read-only when negotiated or rejected before + mutation; they cannot open a parallel writable surface. +- Writer and input permission is granted to an authenticated actor for a writer + epoch and negotiated exact capability. Revocation rotates that authority; + routing credentials never become terminal identity. + +## Operations + +Topology mutations carry the authority protocol range, actor and operation IDs, +the caller's base revision, writer lease epoch, and exact expected pane/binding. +Create, bind, close, supersede, exit, and migration import are idempotent. The +authority appends intent, state, and result before publishing the result. +Consumer access is not mutation identity: the authenticated actor and writer +epoch authorize the operation, while admission separately proves that required +consumers and transports are installed. Appending a mutation or semantic outcome +does not select, copy to, or advance any consumer. + +Durable append and consumer delivery are separate. A mutation returns after its +canonical result is durable; any physical side effect begins only after that +commit and can be re-ensured by an idempotent retry. Semantic producers likewise +append to the authority journal without waiting for a renderer. Each claimed +consumer has one serialized cursor-only delivery pump on the final host; the host +advances that consumer's cumulative cursor only after its handler settles. An +unavailable consumer therefore retains outcomes until capacity fails closed; it +never creates an app-side cursor, settlement queue, receipt ledger, or global +failure fence, and never blocks the PTY output hot path on UI availability. + +Input, resize, signal, and close use a separately negotiated exact capability. +They are not optional identity fields on a legacy operation: an older adapter +could discard those fields and execute the unfenced operation. Exact methods +fail closed when either peer did not negotiate them. + +Output uses an attach-time source identity and monotonically ordered spans. The +source identity includes a request binding nonce, so concurrent attaches cannot +admit an old marker for a new subscription. It is not repeated per frame. + +A source delivery lease is the bounded right to publish one exact PTY +incarnation for one namespace. Its identity includes a unique delivery token, +the host-service `providerGeneration`, the transport `clientGeneration`, and the +physical-worker `ownerGeneration` plus its owner lease. A candidate source stays +provisional until the authority has validated its binding and fenced any active +predecessor. Promotion first prevents new predecessor reservations and rejects +its queued spans, then makes the candidate active; the reverse order is +forbidden. ACK, replay, pause, cancellation, exit, and source-credit operations +must match the complete active identity. + +The bounded byte/scalar-unit credit window is transport flow control only. It +caps unacknowledged source output and memory; it does not wait for stability, +vote on identity, decide liveness, quarantine a source, or reproduce PR #12600's +elapsed-time sliding-window verdict. Exhausted credit pauses publication until +the exact active delivery advances or is explicitly canceled. + +Terminal outcomes such as exit and side-effect facts are appended once to one +namespace-global journal before publication. The host derives consumer principals +from authenticated app/device proofs; consumer labels name durable effect or +policy domains, never transport processes. The host physical-effect consumer is +stable for the authority host (`host-effects:`), while an +app/device consumer is derived independently on each host. Only the +process/session nonce rotates on restart. The final host stores one +incarnation-fenced cumulative cursor per retained principal, and outcomes are +not copied into app-side queues or ledgers. A genuinely new app/device consumer +starts at the snapshot boundary, while a returning proof-bearing consumer +resumes its final-host cursor after a new process/session nonce is admitted. +Duplicate ACKs are idempotent; wrong principals, grants, or nonces are rejected. +Mutation and outcome IDs encode the namespace's monotonic revision. Compaction +discards only outcomes acknowledged by every retained consumer, retains +unacknowledged outcomes as exact replay exceptions, and rejects older IDs at or +below the durable revision floor. When unsettled retention is exhausted, exact +mutations fail closed for that namespace rather than losing an outcome or +freezing unrelated hosts/namespaces. + +The authority checkpoint and boundary materialize the latest bounded, +app-neutral semantic facts for every retained pane, including their event +identities. A new-at-tail consumer therefore receives current title, agent, +command, bell, review-link, and exit state even after older outcomes compact. +Consumer-local policy such as dismissing attention remains in that consumer's +projection and is never promoted to host topology authority. +The materialization retains bounded canonical source outcomes sufficient for +each current fact domain and terminal exit, so host restore and app boundary +application reuse the same event identities and reducer rather than inventing a +parallel semantic state machine or synthetic log. + +The app persists only the bounded stable proof-of-possession identity and its +protected key material; it has no app delivery cursor, per-outcome settlement +ledger, receipt ledger, or app-global committed/pending handover pair. The +final-host cumulative cursor is the delivery source of truth. If the app crashes +after applying an idempotent projection but before the host commits its +cumulative ACK, replay reapplies the same event key without changing the +projection twice. Any process/session nonce and request ID needed for an active +attempt are bounded, ephemeral connection state, not a durable predecessor or +host-state cache. + +For handover, the app first obtains a host-issued challenge for the exact +`authorityHostId + namespaceId` and exact connection grant, including the host's +current admission CAS, its fresh process/session nonce, and a request ID. It +proves possession of the stable key over that challenge and submits that exact +request. A lost response retries the same canonical proof/request and receives +the same result; a new challenge is required only after the host explicitly +rejects the old one. No host-current snapshot, client-supplied principal, or +different request is ever silently adopted as the app's state. + +The final host claims its physical-effect consumer with the current host-service +incarnation and installs an exact effect applier before admitting mutations. It +idempotently applies authority-owned effects such as a retired binding's shutdown, +so a crash between durable close and process termination replays the same work. +The applier advances its cursor only after the exact worker accepted shutdown or +an exact exit, successor, or host process-identity proof establishes that +incarnation complete. A missing session map entry, disconnected adapter, or +failed/unavailable inspection is not that proof and leaves the cursor +unacknowledged. +Each authenticated app or paired device independently claims its host-derived +consumer principal before its authoritative requests are admitted. The final host +stores and advances that principal's cumulative cursor only after app-scoped +policy settles. The host-effect and app/device consumers read the same journal +through separate cursor-only pumps; neither copies outcomes nor advances the +other's cursor. Admission remains closed until the required consumer, its +handler, and its negotiated transport are all installed. + +A host-derived consumer principal is not a bearer credential. Claim, handover, +ACK, and retirement are bound to the exact authenticated connection grant and +process/session nonce that installed the handler; looking up an operation by a +client-supplied consumer label alone is forbidden. The host derives the stable +app/device principal from the proof-bearing identity and its own +`authorityHostId`, never from a client-supplied profile ID. A host-wide SSH +endpoint credential is transport authentication, not an app/device principal, +and cannot key or authorize a consumer cursor. Partial connection setup +atomically rolls back its claim, handler, observers, and capability grant, and an +adapter cannot advertise authoritative mutation until the exact namespace +consumer and transport handler are both live. + +Transport admission stages a handover without rotating the durable incumbent. +After boundary acceptance, the final host durably commits the policy-consumer +claim and cursor. In the same serialized operation it installs the +connection-bound grant and exact-retry result before returning success. Failure +before the durable append preserves the incumbent. A socket-write callback never +authorizes or rolls back that commit: an uncertain response on the same live +connection retries the exact challenge/proof/request and returns the original +grant. After a host or connection restart, a fresh authenticated challenge +resumes the durable claim; it never adopts unauthenticated host-current state or +persists a transport token in a second grant ledger. The client validates the +exact committed grant before it activates mutation, ACK, or delivery. The +negotiated wire grant establishes protocol support but does not by itself +authorize a mutation. + +The namespace is the admission transaction; a connection is only an authenticated +multiplexing transport. Each namespace stages, publishes, and commits its own exact +grant, so failure in one namespace retains that namespace's incumbent without +invalidating another namespace's completed grant. A transport must not expose a +best-effort multi-namespace batch as active after only a prefix committed. + +Process-memory compensation is not atomicity: a crash before the durable append +preserves the incumbent, while a crash after it leaves the consumer claim and +cursor available for authenticated resume. This transaction is not an app +cursor, receipt ledger, durable transport-grant ledger, or global admission +fence. + +For a first claim, the host holds the producer and publishes the complete +`new-at-tail` boundary before appending the consumer claim. The app durably +commits that caught-up boundary and explicitly accepts its exact identity; a +socket write, notification, or IPC-send settlement is not acceptance. The host +then verifies the host-issued proof at the same high-water mark, CAS-commits the +host-derived principal, and activates delivery. A host-current snapshot never +proves that the boundary was accepted; an uncertain failure before the claim +leaves the incumbent unchanged and retries the exact challenge/proof/request +without a boundary ledger. The app does not treat any host-current state as its +own until this per-namespace handshake completes. + +Consumer retention follows authorization, not elapsed time. Revoking a paired +device or retiring a host-derived app/device principal is an explicit durable +consumer-retirement operation fenced to the exact `authorityHostId`, +`namespaceId`, connection grant, and process/session nonce. Only that operation +may remove the final-host cursor and release outcomes it had not acknowledged. +Disconnect and inactivity alone retain the cursor and therefore eventually apply +backpressure rather than lose effects; a failure retires only its namespace +session and cannot install a global failure fence. + +An exact close requested while its transport is unavailable is persisted with the +complete captured binding. That persisted record is the only pending-close source +of truth. Reconnect inventory may confirm the same binding before mutation or +positively identify a successor; it cannot replace the captured target. Missing +inventory remains unresolved. The close completes only from its durable exit +outcome or a positively proven successor, never from polling absence. + +## App and renderer projection + +The main process owns a bounded durable projection per authenticated +`authorityHostId + namespaceId` and host-derived principal. It installs the +authority subscription before reading the snapshot, holds that namespace's +producer while it commits the snapshot and contiguous replay, and then resumes +ordered delivery. For each later outcome it transactionally commits the +projected state and authority event key before the final host cumulatively ACKs +that exact consumer cursor. A crash after projection but before ACK safely +replays the same event key. The projection may retain event keys needed to make +its rows idempotent, but it cannot retain an app delivery cursor, per-outcome +settlement ledger, receipt ledger, or tombstone log that authorizes the host ACK. + +Boundary acceptance names the exact `authorityHostId + namespaceId`, connection +grant, process/session nonce, host-derived principal, high-water mark, and +boundary identity. It is an idempotent admission handshake, not another delivery +cursor or settlement log. + +A boundary projection represents its declared outcome high-water mark. When a +returning consumer is behind that mark, the app stages the boundary, replays the +contiguous suffix against its prior durable projection before reconciling the +staged snapshot with the final replay transaction and ACKing the high-water +outcome. This returning replay-before-reconciliation order is mandatory; the +boundary must not overwrite events that the suffix has not yet applied. +Only a host-attested first claim whose acknowledged sequence already equals the +high-water mark may install that snapshot immediately. First claim versus +returning handover is attested per namespace by the final host and cannot be +inferred from app-global metadata or host-current state. + +The host may deliver a bounded contiguous page and the app commits and +cumulatively ACKs that page as one unit. Backlog catch-up must not require one +`synchronous=FULL` transaction and one transport round trip per historical +outcome; batching never changes event order or the crash-before-ACK contract. +Page-aware delivery and page-tail ACKs are part of the first shipped +namespace-cursor capability contract. Peers without that whole capability never +receive these publications and remain on the isolated legacy path; no permanent +branch preserves semantics from an unshipped construction version. + +Producer-hold acquisition is linearizable with producer admission: operations +already admitted drain before the snapshot, while no operation can pass a stale +no-hold check and enqueue after the hold begins. Release resumes those producers +without inserting another ordering queue. + +The app-scoped renderer controller subscribes to committed projection deltas +before reading its projection snapshot. React views attach to that controller. +Parking removes an expensive view while keeping the controller and projection +subscription alive. Renderer delivery, callback completion, mount state, and IPC +send success never authorize a final-host ACK; renderer loss is recovered from the +committed main-process projection. + +Each authenticated connection generation owns its delivery queue and cancellation +signal. Disconnect or reconnect fences that queue before installing a successor, +so a non-settling transport ACK cannot serialize work for the next connection. +Transport attempts have bounded settlement and reconnect retries automatically +with bounded-resource backoff until disposal; those deadlines bound unavailable +I/O only and never decide identity, liveness, or whether an outcome was ACKed. + +Title, bell, command completion, PR-link, and exit effects are keyed by authority +event sequence. The main-process projection writer must apply each +correctness-bearing fact idempotently and durably before ACK. Calling an arbitrary +callback and persisting its ACK afterward is not sufficient: a crash between +those steps would replay the callback. A sink that cannot prove durable +idempotence cannot be installed as an authoritative consumer, and admission stays +closed. A process-memory set, map, promise cache, or high-water mark is only an +intra-incarnation optimization; it cannot authorize ACK because it disappears in +the exact crash-before-ACK case the protocol must recover. + +Renderer state changes such as title, status, command completion, PR-link, +attention, and exit are deterministic projections of the durable event. Toasts, +sounds, and other presentation cues derive from that committed projection; they +never mutate authority or stand in for durable settlement. A remount or process +restart can therefore replay without duplicating correctness-bearing state, and +an unmounted pane cannot lose it. React lifecycle readiness, elapsed-time +windows, and mount pins do not decide terminal liveness or ownership. + +## Migration + +Migration is namespace-scoped and single-writer: + +1. Negotiate authority support and acquire a migration writer epoch. +2. Freeze legacy topology writes briefly and inventory live PTYs, local leases, + local layout partitions, and the remote workspace snapshot without mutation. +3. Import only exact one-to-one pane/PTY matches with incarnation proof. +4. Preserve ambiguous or missing matches in a non-destructive legacy recovery + surface. Do not attach, expire, kill, or respawn them automatically. +5. Commit the import and authority cutover marker in one durable operation. +6. Rebuild projections from the authority. Retain legacy backups and tombstones + until explicit acknowledgement; never dual-write. + +The bounded importer is a pure reader and planner. Its complete namespace-scoped +result—imported bindings, unresolved recovery rows, worker route evidence, and +the cutover marker—is one authority log record that can replay without any +legacy file. The authority journal never stores a reference into a second +catalog log, performs suffix reconciliation from that log, or depends on it to +restore a snapshot. An inventory spanning several namespaces produces one +independent transaction per namespace while the legacy writer remains frozen; +failure leaves that namespace visibly uncut-over rather than partially imported. +After a successful cutover, an unresolved row fences only the pane, PTY, or worker +identity it names; it does not globally freeze unrelated authoritative operations +or consumer admission. Explicit acknowledgement remains an incarnation-fenced +namespace operation after clients connect. + +After cutover, clients without the required authority capability cannot mutate +the namespace. A compatible read-only observer may attach; otherwise the client +must upgrade or use a visibly isolated legacy surface. Rollback cannot silently +discard operations committed after cutover. + +## Minimal implementation shape + +The production design has four roles: + +1. One final-host authority service owns namespaces, bindings, PTY workers, + mutation results, terminal outcomes, and consumer cursors. +2. Local, daemon, SSH, WSL, relay, and paired-runtime adapters expose the same + exact operations and carry negotiated capabilities. They do not own another + pane or PTY state machine. +3. One app-scoped controller consumes snapshot plus ordered events and delivers + terminal outcomes. React views are clients of that controller. +4. One bounded legacy importer reads old stores before cutover. It cannot mutate + an authoritative namespace or remain as a second writer after cutover. Its + output is committed directly by the authority; there is no durable migration + catalog beside the authority journal. + +These four roles are also the deletion oracle. A production module that only +mirrors, translates, validates, caches, retries, or reconciles state already +owned by another role must be merged into that owner or deleted. Transport +adapters may encode transport mechanics but cannot retain a parallel authority +state machine. A net-positive construction tree is therefore unfinished work, +not evidence that the replacement needs more layers. + +Compatibility is selected once at admission. An exact operation never retries +as an ID-only operation, and an authoritative namespace never falls back to a +legacy lease, layout, or provider mutation after admission. Unsupported peers +remain on the unchanged isolated legacy path or fail closed before mutation. + +Adding authority beside the old writers is not the end state. Once a path reads +its authoritative projection, its former reconciliation, quarantine, retry, +dual-write, and timing-verdict code must be removed. Prefer one shared state +transition and validation implementation over transport-specific copies. + +## Compatibility and performance + +Negotiation uses protocol major/minor ranges and explicit capabilities, not an +app build ID. Unknown required capabilities fail before mutation. The legacy +terminal wire remains unchanged for peers that did not negotiate exact authority. +Namespace-cursor delivery is likewise capability-negotiated: after negotiation, +every delivery carries its authenticated previous sequence and omission fails +closed; a peer without that capability remains on the isolated legacy path. The +existing exit-only per-outcome delivery capability cannot be reinterpreted as +namespace-cursor delivery; the new semantics require a distinct capability +version or name. + +The first boundary on that capability carries the complete bounded consumer +projection together with its acknowledged sequence and outcome high-water mark; +cursor numbers alone are not a snapshot. A decoder may accept an absent optional +field to parse an older peer, but authoritative app admission must reject that +boundary before ACK or mutation capability is granted. + +Authority admission is O(1). There is no startup-blocking SSH capability probe, +per-frame UUID, pane scan, provider listing, or journal write on the input/output +hot paths. An unavailable transport uses bounded waits or backoff and cannot +schedule an unbounded microtask, timer, promise, listener, log, or allocation +loop. Existing latency, backpressure, restore, memory, and scale ceilings may not +increase. The release gates cover local, daemon, direct and nested SSH, +paired runtime, remote server, folder/floating workspaces, macOS, Linux, Windows, +WSL, and both version-skew directions. + +Sandboxed preload compatibility is judged at the emitted artifact boundary, not +from the TypeScript import graph. The build guard runs in `generateBundle` +before output writes, covers every preload chunk plus the main-build +`browser-window-close-preload` entry, and excludes ordinary main-process chunks. +Only the exact external module `electron` is permitted. Bare/static imports, +dynamic imports, helper chunks, and literal, renamed, or non-literal `require` +forms fail closed so an unsafe preload cannot remain in `out/` for a later start +or package step. + +Implementation status, deletion targets, and required proof are tracked in +[`terminal-session-authority-delivery.md`](./terminal-session-authority-delivery.md). diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 90b908200c4..ee668ed00c3 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -5,8 +5,12 @@ import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' import { createBootstrapFatalExitBanner } from './config/build-plugins/bootstrap-fatal-exit-banner' import { createPlainNodeEntryGuardPlugin } from './config/build-plugins/plain-node-entry-guard' +import { createPreloadSandboxBuiltinGuardPlugin } from './config/build-plugins/preload-sandbox-builtin-guard' import packageJson from './package.json' with { type: 'json' } +// The sandboxed preload the main config emits; the entry name and the guarded output must agree. +const BROWSER_WINDOW_CLOSE_PRELOAD_ENTRY = 'browser-window-close-preload' + const BUNDLED_MAIN_DEPENDENCIES = new Set([ '@xterm/headless', '@xterm/addon-serialize', @@ -208,7 +212,7 @@ export const electronViteConfig: UserConfig = { input: { index: resolve('src/main/index.ts'), // Why: sandboxed webview preloads cannot load Rollup helper chunks. - 'browser-window-close-preload': resolve('src/preload/browser-window-close.ts'), + [BROWSER_WINDOW_CLOSE_PRELOAD_ENTRY]: resolve('src/preload/browser-window-close.ts'), 'daemon-entry': resolve('src/main/daemon/daemon-entry.ts'), 'plugin-host-entry': resolve('src/main/plugins/plugin-host-entry.ts'), 'computer-sidecar': resolve('src/main/computer/sidecar-entry.ts'), @@ -251,7 +255,15 @@ export const electronViteConfig: UserConfig = { entryFileNames: '[name].js', chunkFileNames: 'chunks/[name]-[hash].js' }, - plugins: [createMainBootstrapPlugin(), createPlainNodeEntryGuardPlugin()] + plugins: [ + createMainBootstrapPlugin(), + createPlainNodeEntryGuardPlugin(), + // Why: this entry is a sandboxed guest-WebContents preload built by the main + // config, so it needs the same browser-safe guarantee as out/preload. + createPreloadSandboxBuiltinGuardPlugin({ + include: [`${BROWSER_WINDOW_CLOSE_PRELOAD_ENTRY}.js`] + }) + ] } }, // Why: compile-time substitution for the telemetry gate. See the block @@ -277,6 +289,12 @@ export const electronViteConfig: UserConfig = { build: { externalizeDeps: { exclude: ['@electron-toolkit/preload'] + }, + rollupOptions: { + // Electron is the only privileged preload external; bundling the npm + // package would pull its Node-only installer (fs/path/child_process). + external: ['electron'], + plugins: [createPreloadSandboxBuiltinGuardPlugin()] } } }, diff --git a/package.json b/package.json index d800838281b..ef156a58bc1 100644 --- a/package.json +++ b/package.json @@ -97,6 +97,7 @@ "test:e2e:ssh-docker-perf": "node config/scripts/run-ssh-docker-perf-e2e.mjs", "test:e2e:ssh-docker-watcher-isolation": "node config/scripts/run-ssh-docker-watcher-isolation-e2e.mjs", "test:e2e:ssh-docker-terminal-parking": "node config/scripts/run-ssh-docker-terminal-parking-e2e.mjs", + "test:e2e:ssh-docker-terminal-authority": "node config/scripts/run-ssh-docker-terminal-authority-e2e.mjs", "test:e2e:nested-runtime-ssh": "node config/scripts/run-nested-runtime-ssh-e2e.mjs", "test:e2e:source-control-scale": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/source-control-large-file-count.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "win-update-e2e": "node tests/tools/win-update-e2e/run.mjs", diff --git a/src/main/agent-hooks/managed-hook-owner-identity.test.ts b/src/main/agent-hooks/managed-hook-owner-identity.test.ts index ae8ac15dd12..3558d1b1664 100644 --- a/src/main/agent-hooks/managed-hook-owner-identity.test.ts +++ b/src/main/agent-hooks/managed-hook-owner-identity.test.ts @@ -178,4 +178,16 @@ describe('managed hook owner identity', () => { 'linux:pid:[4026533001]:current-boot-id:4242' ) }) + + it('parses the Windows machine identity without accepting unrelated registry output', async () => { + const identity = await import('./managed-hook-owner-identity') + expect( + identity.parseWindowsMachineGuid(` +HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Cryptography + MachineGuid REG_SZ 00000000-0000-4000-8000-000000000001 +`) + ).toBe('00000000-0000-4000-8000-000000000001') + expect(identity.parseWindowsMachineGuid('MachineGuid REG_SZ')).toBeNull() + expect(identity.parseWindowsMachineGuid('OtherValue REG_SZ machine-a')).toBeNull() + }) }) diff --git a/src/main/agent-hooks/managed-hook-owner-identity.ts b/src/main/agent-hooks/managed-hook-owner-identity.ts index 85941007e12..66e9e7e9f3e 100644 --- a/src/main/agent-hooks/managed-hook-owner-identity.ts +++ b/src/main/agent-hooks/managed-hook-owner-identity.ts @@ -1,5 +1,5 @@ import { execFile } from 'node:child_process' -import { randomUUID } from 'node:crypto' +import { createHash, randomUUID } from 'node:crypto' import { link, lstat, mkdir, readFile, readlink, unlink, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { promisify } from 'node:util' @@ -28,7 +28,7 @@ export function parseLinuxStartTicks(statLine: string): string | null { return startTicks ?? null } -async function readLinuxPidNamespace(pid: number): Promise { +export async function readLinuxPidNamespace(pid: number | 'self'): Promise { try { const namespace = await readlink(`/proc/${pid}/ns/pid`) return namespace.trim() || undefined @@ -37,6 +37,29 @@ async function readLinuxPidNamespace(pid: number): Promise { } } +export function parseWindowsMachineGuid(output: string): string | null { + const machineGuid = /^\s*MachineGuid\s+REG_[A-Z0-9_]+\s+([^\r\n]+)$/im.exec(output)?.[1]?.trim() + return machineGuid && machineGuid.length <= 256 ? machineGuid : null +} + +async function readWindowsHostIdentity(): Promise { + try { + const { stdout } = await execFileAsync( + 'reg.exe', + ['QUERY', 'HKLM\\SOFTWARE\\Microsoft\\Cryptography', '/v', 'MachineGuid'], + { encoding: 'utf8', timeout: 1_000, windowsHide: true } + ) + const machineGuid = parseWindowsMachineGuid(stdout) + if (!machineGuid) { + return undefined + } + const digest = createHash('sha256').update(machineGuid).digest('base64url') + return `win32-machine:${digest}:pid-namespace:global` + } catch { + return undefined + } +} + async function readPublishedHostToken(path: string, uid: number): Promise { try { const stats = await lstat(path) @@ -112,6 +135,9 @@ async function readHostIdentity(): Promise { return runtimeHostIdentity } } + if (process.platform === 'win32') { + return (await readWindowsHostIdentity()) ?? runtimeHostIdentity + } return runtimeHostIdentity } diff --git a/src/main/daemon/client.test.ts b/src/main/daemon/client.test.ts index 47b77cfb78e..e1bc6fc8ff1 100644 --- a/src/main/daemon/client.test.ts +++ b/src/main/daemon/client.test.ts @@ -8,6 +8,7 @@ import { DaemonClient } from './client' import { encodeNdjson, NDJSON_MAX_LINE_BYTES, NdjsonLineTooLongError } from './ndjson' import type { HelloMessage, DaemonRequest, DaemonEvent } from './types' import { getDaemonSocketPath } from './daemon-spawner' +import { TERMINAL_SESSION_AUTHORITY_HELLO_CAPABILITY } from './daemon-hello-protocol' function createTestDir(): string { return mkdtempSync(join(tmpdir(), 'daemon-client-test-')) @@ -77,6 +78,8 @@ describe('DaemonClient', () => { appVersion?: string spawnerExecPath?: string } + helloCapabilities?: (role: 'control' | 'stream') => unknown + helloTail?: (role: 'control' | 'stream') => string }): Promise { return new Promise((resolve) => { server = createServer((socket) => { @@ -113,7 +116,7 @@ describe('DaemonClient', () => { return } socket.write( - encodeNdjson({ + `${encodeNdjson({ type: 'hello', ok: true, ...(!opts?.omitHelloIdentity @@ -122,8 +125,11 @@ describe('DaemonClient', () => { ? opts.helloIdentity(hello.role) : { pid: 123, startedAtMs: 456, launchNonce: 'default-launch' } } + : {}), + ...(opts?.helloCapabilities + ? { capabilities: opts.helloCapabilities(hello.role) } : {}) - }) + })}${opts?.helloTail?.(hello.role) ?? ''}` ) if (hello.role === 'stream') { opts?.onStreamHello?.(hello) @@ -155,6 +161,115 @@ describe('DaemonClient', () => { expect(client.isConnected()).toBe(true) // Both control and stream sockets should have sent hello await waitFor(() => hellos.length > 0) + expect(hellos[0]?.capabilities).toBeUndefined() + expect(client.supportsTerminalSessionAuthority()).toBe(false) + }) + + it('enables terminal authority only after both sockets echo the exact capability', async () => { + const hellos: HelloMessage[] = [] + await startMockDaemon({ + onHello: (hello) => hellos.push(hello), + helloCapabilities: () => ({ + terminalSessionAuthority: TERMINAL_SESSION_AUTHORITY_HELLO_CAPABILITY + }) + }) + client = new DaemonClient({ + socketPath, + tokenPath, + terminalSessionAuthorityConsumerProofReady: () => true + }) + + await client.ensureConnected() + + expect(client.supportsTerminalSessionAuthority()).toBe(true) + expect(hellos).toHaveLength(2) + expect( + hellos.every( + (hello) => + hello.capabilities?.terminalSessionAuthority === + TERMINAL_SESSION_AUTHORITY_HELLO_CAPABILITY + ) + ).toBe(true) + client.disconnect() + expect(client.supportsTerminalSessionAuthority()).toBe(false) + }) + + it('preserves a stream event coalesced behind the hello response', async () => { + const event: DaemonEvent = { + type: 'event', + event: 'terminalAuthorityNamespaceOutcomeBoundary', + sessionId: 'authority-outcomes:namespace-a', + payload: { + version: 1, + consumer: { + consumerId: 'app-profile:client-test', + consumerIncarnationId: 'app-process:client-test' + }, + namespace: { authorityHostId: 'host-a', namespaceId: 'namespace-a' }, + acknowledgedSequence: 0, + outcomeHighWatermark: 0, + boundaryId: 'boundary-a', + consumerStart: 'new-at-tail' + } + } + await startMockDaemon({ + helloTail: (role) => (role === 'stream' ? encodeNdjson(event) : '') + }) + const received: unknown[] = [] + client = new DaemonClient({ socketPath, tokenPath }) + client.onEvent((value) => received.push(value)) + + await client.ensureConnected() + + expect(received).toEqual([event]) + }) + + it.each([34, 35])('keeps a version-only v%s daemon on the legacy path', async (version) => { + await startMockDaemon() + client = new DaemonClient({ + socketPath, + tokenPath, + protocolVersion: version, + terminalSessionAuthorityConsumerProofReady: () => true + }) + + await client.ensureConnected() + + expect(client.supportsTerminalSessionAuthority()).toBe(false) + }) + + it('ignores unsupported terminal authority capability values', async () => { + await startMockDaemon({ + helloCapabilities: () => ({ terminalSessionAuthority: true }) + }) + client = new DaemonClient({ + socketPath, + tokenPath, + terminalSessionAuthorityConsumerProofReady: () => true + }) + + await client.ensureConnected() + + expect(client.supportsTerminalSessionAuthority()).toBe(false) + }) + + it('rejects disagreement between control and stream capabilities', async () => { + await startMockDaemon({ + helloCapabilities: (role) => + role === 'control' + ? { terminalSessionAuthority: TERMINAL_SESSION_AUTHORITY_HELLO_CAPABILITY } + : {} + }) + client = new DaemonClient({ + socketPath, + tokenPath, + terminalSessionAuthorityConsumerProofReady: () => true + }) + + await expect(client.ensureConnected()).rejects.toThrow( + 'Daemon capabilities changed during connection' + ) + expect(client.supportsTerminalSessionAuthority()).toBe(false) }) it('captures one matching endpoint identity from both authenticated sockets', async () => { diff --git a/src/main/daemon/client.ts b/src/main/daemon/client.ts index d137ee84ea5..5dda99c04de 100644 --- a/src/main/daemon/client.ts +++ b/src/main/daemon/client.ts @@ -19,6 +19,13 @@ import type { } from './types' import { addNodePtyRecoveryHint } from './node-pty-error-hints' import { decodeDaemonResponseError } from './daemon-errors' +import { + CURRENT_DAEMON_HELLO_CAPABILITIES, + parseDaemonHelloCapabilities, + sameDaemonHelloCapabilities, + TERMINAL_SESSION_AUTHORITY_HELLO_CAPABILITY, + type DaemonHelloCapabilities +} from './daemon-hello-protocol' const CONNECT_TIMEOUT_MS = 5000 const CONNECTION_ATTEMPT_WAIT_MS = CONNECT_TIMEOUT_MS * 4 @@ -28,6 +35,7 @@ export type DaemonClientOptions = { socketPath: string tokenPath: string protocolVersion?: number + terminalSessionAuthorityConsumerProofReady?: () => boolean } type PendingRequest = { @@ -36,10 +44,17 @@ type PendingRequest = { timer: ReturnType } +type AuthenticatedDaemonHello = Readonly<{ + identity: DaemonEndpointIdentity | null + capabilities: DaemonHelloCapabilities + tail: Buffer +}> + export class DaemonClient { private socketPath: string private tokenPath: string private protocolVersion: number + private terminalSessionAuthorityConsumerProofReady: () => boolean private clientId = randomUUID() private controlSocket: Socket | null = null @@ -57,6 +72,7 @@ export class DaemonClient { private connectingPromise: Promise | null = null private connectionAttemptGeneration = 0 private daemonIdentity: DaemonEndpointIdentity | null = null + private negotiatedCapabilities: DaemonHelloCapabilities = {} private observedAuthenticatedDisconnect = false private pendingRequests = new Map() @@ -69,6 +85,8 @@ export class DaemonClient { this.socketPath = opts.socketPath this.tokenPath = opts.tokenPath this.protocolVersion = opts.protocolVersion ?? PROTOCOL_VERSION + this.terminalSessionAuthorityConsumerProofReady = + opts.terminalSessionAuthorityConsumerProofReady ?? (() => false) } isConnected(): boolean { @@ -83,6 +101,24 @@ export class DaemonClient { return this.observedAuthenticatedDisconnect } + supportsTerminalSessionAuthority(): boolean { + return ( + this.connected && + this.negotiatedCapabilities.terminalSessionAuthority === + TERMINAL_SESSION_AUTHORITY_HELLO_CAPABILITY + ) + } + + terminalSessionAuthorityConsumerProofHostId(): string | null { + const capability = this.negotiatedCapabilities.terminalAuthorityConsumerProof + return capability && 'version' in capability ? capability.authorityHostId : null + } + + terminalSessionAuthorityConsumerRetirementSupported(): boolean { + const capability = this.negotiatedCapabilities.terminalAuthorityConsumerProof + return Boolean(capability && 'version' in capability && capability.retirementVersion === 1) + } + async ensureConnected(): Promise { return this.ensureConnectedWithTimeout(CONNECT_TIMEOUT_MS, false) } @@ -131,33 +167,45 @@ export class DaemonClient { } try { + const requestedCapabilities = this.requestedHelloCapabilities() // Sequential: control first, then stream const pendingControlSocket = await this.connectSocket(remainingMs()) this.assertConnectionAttemptCurrent(attemptGeneration, pendingControlSocket) this.controlSocket = pendingControlSocket - const controlIdentity = await this.sendHello( + const controlHello = await this.sendHello( this.controlSocket, token, 'control', - remainingMs() + remainingMs(), + requestedCapabilities ) this.assertConnectionAttemptCurrent(attemptGeneration, this.controlSocket) - pendingListenerCleanups.push(this.setupControlParser(this.controlSocket)) + pendingListenerCleanups.push(this.setupControlParser(this.controlSocket, controlHello.tail)) const pendingStreamSocket = await this.connectSocket(remainingMs()) this.assertConnectionAttemptCurrent(attemptGeneration, pendingStreamSocket) this.streamSocket = pendingStreamSocket - const streamIdentity = await this.sendHello(this.streamSocket, token, 'stream', remainingMs()) + const streamHello = await this.sendHello( + this.streamSocket, + token, + 'stream', + remainingMs(), + requestedCapabilities + ) this.assertConnectionAttemptCurrent(attemptGeneration, this.streamSocket) - if (!sameDaemonIdentity(controlIdentity, streamIdentity)) { + if (!sameDaemonIdentity(controlHello.identity, streamHello.identity)) { throw new DaemonProtocolError('Daemon identity changed during connection') } - pendingListenerCleanups.push(this.setupStreamParser(this.streamSocket)) + if (!sameDaemonHelloCapabilities(controlHello.capabilities, streamHello.capabilities)) { + throw new DaemonProtocolError('Daemon capabilities changed during connection') + } + pendingListenerCleanups.push(this.setupStreamParser(this.streamSocket, streamHello.tail)) this.assertConnectionAttemptCurrent(attemptGeneration) this.connected = true this.observedAuthenticatedDisconnect = false - this.daemonIdentity = controlIdentity + this.daemonIdentity = controlHello.identity + this.negotiatedCapabilities = controlHello.capabilities this.disconnectArmed = true this.connectionGeneration++ @@ -184,6 +232,7 @@ export class DaemonClient { this.streamSocket = null this.connected = false this.daemonIdentity = null + this.negotiatedCapabilities = {} this.disconnectArmed = false throw error } @@ -259,6 +308,7 @@ export class DaemonClient { this.connectionAttemptGeneration++ this.connected = false this.daemonIdentity = null + this.negotiatedCapabilities = {} this.disconnectArmed = false this.cleanupActiveSocketListeners() @@ -331,18 +381,23 @@ export class DaemonClient { socket: Socket, token: string, role: 'control' | 'stream', - timeoutMs: number - ): Promise { + timeoutMs: number, + requestedCapabilities: DaemonHelloCapabilities = this.requestedHelloCapabilities() + ): Promise { return new Promise((resolve, reject) => { const hello: HelloMessage = { type: 'hello', version: this.protocolVersion, token, clientId: this.clientId, - role + role, + ...(requestedCapabilities.terminalSessionAuthority !== undefined || + requestedCapabilities.terminalAuthorityConsumerProof !== undefined + ? { capabilities: requestedCapabilities } + : {}) } - let buffer = '' + let buffer = Buffer.alloc(0) let settled = false let timer: ReturnType | null = null const cleanup = (): void => { @@ -354,7 +409,14 @@ export class DaemonClient { socket.removeListener('error', onError) socket.removeListener('close', onClose) } - const finish = (error?: Error, identity: DaemonEndpointIdentity | null = null): void => { + const finish = ( + error?: Error, + hello: AuthenticatedDaemonHello = { + identity: null, + capabilities: {}, + tail: Buffer.alloc(0) + } + ): void => { if (settled) { return } @@ -364,19 +426,17 @@ export class DaemonClient { reject(error) return } - resolve(identity) + resolve(hello) } - // Why: daemon socket chunks can split emoji/box-drawing UTF-8 bytes. - // Decoding each Buffer independently would permanently inject U+FFFD. - const decoder = new StringDecoder('utf8') const onData = (chunk: Buffer): void => { - buffer += decoder.write(chunk) - const newlineIdx = buffer.indexOf('\n') + buffer = Buffer.concat([buffer, chunk]) + const newlineIdx = buffer.indexOf(0x0a) if (newlineIdx === -1) { return } - const line = buffer.slice(0, newlineIdx) + const line = buffer.subarray(0, newlineIdx).toString('utf8') + const tail = buffer.subarray(newlineIdx + 1) try { const response = JSON.parse(line) as HelloResponse if (response.ok) { @@ -388,7 +448,11 @@ export class DaemonClient { finish(new DaemonProtocolError('Invalid daemon identity')) return } - finish(undefined, identity) + finish(undefined, { + identity, + capabilities: parseDaemonHelloCapabilities(response.capabilities), + tail + }) } else { finish( new DaemonProtocolError(addNodePtyRecoveryHint(response.error ?? 'Hello rejected')) @@ -415,7 +479,24 @@ export class DaemonClient { }) } - private setupControlParser(socket: Socket): () => void { + private requestedHelloCapabilities(): DaemonHelloCapabilities { + try { + // Why nothing is offered without a proof keypair: authority admission is proof-only, so a + // client that cannot prove itself must negotiate the legacy non-authority path instead. + if (!this.terminalSessionAuthorityConsumerProofReady()) { + return {} + } + return { + terminalSessionAuthority: CURRENT_DAEMON_HELLO_CAPABILITIES.terminalSessionAuthority, + terminalAuthorityConsumerProof: + CURRENT_DAEMON_HELLO_CAPABILITIES.terminalAuthorityConsumerProof + } + } catch { + return {} + } + } + + private setupControlParser(socket: Socket, initialData: Buffer): () => void { // Why: control responses may contain terminal/startup data with multibyte // text; keep incomplete UTF-8 bytes until the next socket chunk. const decoder = new StringDecoder('utf8') @@ -445,10 +526,13 @@ export class DaemonClient { const onData = (chunk: Buffer) => parser.feed(decoder.write(chunk)) socket.on('data', onData) + if (initialData.length > 0) { + parser.feed(decoder.write(initialData)) + } return () => socket.off('data', onData) } - private setupStreamParser(socket: Socket): () => void { + private setupStreamParser(socket: Socket, initialData: Buffer): () => void { // Why: PTY output streams include emoji/box-drawing tables; socket chunks // can split those UTF-8 sequences across packets. const decoder = new StringDecoder('utf8') @@ -466,6 +550,9 @@ export class DaemonClient { const onData = (chunk: Buffer) => parser.feed(decoder.write(chunk)) socket.on('data', onData) + if (initialData.length > 0) { + parser.feed(decoder.write(initialData)) + } return () => socket.off('data', onData) } @@ -480,6 +567,7 @@ export class DaemonClient { } this.connected = false this.daemonIdentity = null + this.negotiatedCapabilities = {} this.cleanupActiveSocketListeners() for (const [id, pending] of this.pendingRequests) { diff --git a/src/main/daemon/daemon-background-transient-facts.test.ts b/src/main/daemon/daemon-background-transient-facts.test.ts index 0d1aa12a94c..f2000902da1 100644 --- a/src/main/daemon/daemon-background-transient-facts.test.ts +++ b/src/main/daemon/daemon-background-transient-facts.test.ts @@ -18,6 +18,18 @@ describe('BackgroundTransientFactRelay', () => { expect(emitted).toEqual([{ sessionId: 's1', fact: { kind: 'bell' } }]) }) + it('routes one scan through its captured exact emitter', () => { + const { relay, emitted } = createRelay() + const exact: DaemonTransientFact[] = [] + relay.setSessionBackground('s1', true) + + relay.onSessionData('s1', 'first\x07', (fact) => exact.push(fact)) + relay.onSessionData('s1', 'second\x07') + + expect(exact).toEqual([{ kind: 'bell' }]) + expect(emitted).toEqual([{ sessionId: 's1', fact: { kind: 'bell' } }]) + }) + it('keeps OSC escape state across chunks — a title terminator BEL is not a bell', () => { const { relay, emitted } = createRelay() relay.setSessionBackground('s1', true) diff --git a/src/main/daemon/daemon-background-transient-facts.ts b/src/main/daemon/daemon-background-transient-facts.ts index 3e1c31c1369..6795bcd2ff8 100644 --- a/src/main/daemon/daemon-background-transient-facts.ts +++ b/src/main/daemon/daemon-background-transient-facts.ts @@ -32,6 +32,7 @@ export const BACKGROUND_STREAM_DROP_ENABLED = process.env.ORCA_DAEMON_BACKGROUND export class BackgroundTransientFactRelay { private trackersBySessionId = new Map() + private factEmittersBySessionId = new Map void>() // Why: shadow foreground bytes so a provisional subscribe survives either scan-authority handoff. private mode2031ReplyScanStateBySessionId = new Map() private emitFact: (sessionId: string, fact: DaemonTransientFact) => void @@ -59,15 +60,15 @@ export class BackgroundTransientFactRelay { this.trackersBySessionId.set( sessionId, createTerminalTitleTracker({ - onBell: () => this.emitFact(sessionId, { kind: 'bell' }), + onBell: () => this.emitSessionFact(sessionId, { kind: 'bell' }), onCommandFinished: (exitCode) => - this.emitFact(sessionId, { kind: 'command-finished', exitCode }), + this.emitSessionFact(sessionId, { kind: 'command-finished', exitCode }), // Note: recreating the tracker on each background toggle resets the // PR-link dedup memory, so a link re-printed across toggles can // re-fire — consumers treat pr-link as a latest-association update. - onPrLink: (link) => this.emitFact(sessionId, { kind: 'pr-link', link }), - onMode2031Subscribe: () => this.emitFact(sessionId, { kind: '2031-subscribe' }), - onMode2031Unsubscribe: () => this.emitFact(sessionId, { kind: '2031-unsubscribe' }) + onPrLink: (link) => this.emitSessionFact(sessionId, { kind: 'pr-link', link }), + onMode2031Subscribe: () => this.emitSessionFact(sessionId, { kind: '2031-subscribe' }), + onMode2031Unsubscribe: () => this.emitSessionFact(sessionId, { kind: '2031-unsubscribe' }) }) ) } else { @@ -103,23 +104,42 @@ export class BackgroundTransientFactRelay { /** Feed one raw chunk, in byte order, BEFORE it is enqueued for delivery — * facts must be captured even when the chunk is later keep-tail dropped. */ - onSessionData(sessionId: string, data: string): void { - const previousMode2031State = this.mode2031ReplyScanStateBySessionId.get(sessionId) - if (previousMode2031State || data.includes('\x1b') || data.includes('\x9b')) { - const mode2031Result = scanMode2031ReplyDecision( - previousMode2031State ?? INITIAL_MODE_2031_REPLY_SCAN_STATE, - data - ) - if (mode2031Result.state.tail.length > 0 || mode2031Result.state.pendingSubscribe) { - this.mode2031ReplyScanStateBySessionId.set(sessionId, mode2031Result.state) - } else { - this.mode2031ReplyScanStateBySessionId.delete(sessionId) + onSessionData( + sessionId: string, + data: string, + emitFact?: (fact: DaemonTransientFact) => void + ): void { + const tracker = this.trackersBySessionId.get(sessionId) + const installsEmitter = Boolean(emitFact && tracker) + const previousEmitter = installsEmitter + ? this.factEmittersBySessionId.get(sessionId) + : undefined + if (installsEmitter && emitFact) { + this.factEmittersBySessionId.set(sessionId, emitFact) + } + try { + const previousMode2031State = this.mode2031ReplyScanStateBySessionId.get(sessionId) + if (previousMode2031State || data.includes('\x1b') || data.includes('\x9b')) { + const mode2031Result = scanMode2031ReplyDecision( + previousMode2031State ?? INITIAL_MODE_2031_REPLY_SCAN_STATE, + data + ) + if (mode2031Result.state.tail.length > 0 || mode2031Result.state.pendingSubscribe) { + this.mode2031ReplyScanStateBySessionId.set(sessionId, mode2031Result.state) + } else { + this.mode2031ReplyScanStateBySessionId.delete(sessionId) + } + } + tracker?.handleChunk(data, { titleScanData: '' }) + } finally { + if (installsEmitter) { + if (previousEmitter) { + this.factEmittersBySessionId.set(sessionId, previousEmitter) + } else { + this.factEmittersBySessionId.delete(sessionId) + } } } - // titleScanData:'' skips title extraction (titles stay main-authoritative) - // and keeps the stale-working-title timer permanently unarmed — only the - // four transient scanners consume the chunk. - this.trackersBySessionId.get(sessionId)?.handleChunk(data, { titleScanData: '' }) } getMode2031ReplyScanState(sessionId: string): Mode2031ReplyScanState { @@ -143,5 +163,15 @@ export class BackgroundTransientFactRelay { private disposeTracker(sessionId: string): void { this.trackersBySessionId.get(sessionId)?.dispose() this.trackersBySessionId.delete(sessionId) + this.factEmittersBySessionId.delete(sessionId) + } + + private emitSessionFact(sessionId: string, fact: DaemonTransientFact): void { + const emitter = this.factEmittersBySessionId.get(sessionId) + if (emitter) { + emitter(fact) + return + } + this.emitFact(sessionId, fact) } } diff --git a/src/main/daemon/daemon-create-or-attach-result.ts b/src/main/daemon/daemon-create-or-attach-result.ts index d7a2ac64ba8..f94bf25b4c9 100644 --- a/src/main/daemon/daemon-create-or-attach-result.ts +++ b/src/main/daemon/daemon-create-or-attach-result.ts @@ -2,6 +2,7 @@ import type { TuiAgent } from '../../shared/types' import type { ShellReadyState, TerminalSnapshot } from './types' import type { AgentSessionClaimedSpawnResult } from '../../shared/agent-session-host-authority' import type { PtyIncarnationId } from '../../shared/pty-incarnation' +import type { TerminalSessionAuthorityPtyAccess } from '../../shared/terminal-session-authority-pty-access' export type DaemonCreateOrAttachResult = { isNew: boolean @@ -14,6 +15,8 @@ export type DaemonCreateOrAttachResult = { wslDistro?: string | null agentSessionEnsure?: AgentSessionClaimedSpawnResult incarnationId?: PtyIncarnationId + streamBindingNonce?: string + terminalSessionAuthorityAccess?: TerminalSessionAuthorityPtyAccess } export function getDaemonSessionResultMetadata(session: { diff --git a/src/main/daemon/daemon-entry.ts b/src/main/daemon/daemon-entry.ts index 9ad6b1564dd..d8fd8de1539 100644 --- a/src/main/daemon/daemon-entry.ts +++ b/src/main/daemon/daemon-entry.ts @@ -7,6 +7,7 @@ * Shuts down cleanly on SIGTERM. */ import { readFileSync } from 'node:fs' +import path from 'node:path' import { startDaemon, type DaemonHandle } from './daemon-main' import { createPtySubprocess } from './pty-subprocess' import { warmWindowsConptyOnce } from './windows-conpty-warmup' @@ -21,6 +22,8 @@ import { MacosLoginSessionDeathWatch } from './macos-login-session-death-watch' import { readCurrentProcessMacSystemResolverHealth } from '../network/macos-system-resolver-health' import { readCurrentDaemonReadyIdentity } from './daemon-ready-identity' import { publishDaemonPidFile } from './daemon-spawner' +import { TerminalSessionAuthorityHostRuntime } from '../session-authority/terminal-session-authority-host-runtime' +import { readCurrentTerminalAuthorityOwnerProcessIdentity } from '../session-authority/terminal-session-authority-owner-process' export type ParsedDaemonArgs = { socketPath: string @@ -170,7 +173,7 @@ async function main(): Promise { // forever on SIGTERM/SIGINT (it would then survive a real quit, not just updates). const SHUTDOWN_TIMEOUT_MS = 5000 - const shutdown = async (reason: string): Promise => { + const shutdown = async (reason: string, exitCode = 0): Promise => { // SIGTERM and SIGINT can both fire; guard against a double daemon.shutdown(). if (shuttingDown) { return @@ -193,7 +196,7 @@ async function main(): Promise { daemonLog.log('shutdown-error', { message: (err as Error)?.message }) } finally { daemonLog.close() - process.exit(0) + process.exit(exitCode) } } @@ -258,6 +261,10 @@ async function main(): Promise { }) : null + const terminalSessionAuthorityRuntime = await TerminalSessionAuthorityHostRuntime.open({ + directory: path.join(path.dirname(tokenPath), 'terminal-session-authority'), + processIdentity: await readCurrentTerminalAuthorityOwnerProcessIdentity() + }) daemon = await startDaemon({ socketPath, tokenPath, @@ -281,7 +288,12 @@ async function main(): Promise { } : {}), log: daemonLog, + terminalSessionAuthorityRuntime, preparePtySpawn: runMacosLoginPreflight, + onTerminalSessionAuthorityFailure: (error) => { + daemonLog.log('terminal-session-authority-failure', { message: error.message }) + void shutdown('terminal-session-authority-failure', 1) + }, ...(deathWatch ? { onPtySessionExit: () => deathWatch.notifyPtyExit(), diff --git a/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts index 07f291a60ff..c6daac2d5ba 100644 --- a/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts +++ b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts @@ -3,7 +3,7 @@ import { PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION } from './types' describe('foreground-confirmation daemon protocol', () => { it('rejects daemons from before the fresh-confirmation RPC', () => { - expect(PROTOCOL_VERSION).toBe(32) + expect(PROTOCOL_VERSION).toBe(35) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(19) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(22) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(23) @@ -15,5 +15,7 @@ describe('foreground-confirmation daemon protocol', () => { expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(29) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(30) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(31) + expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(32) + expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(34) }) }) diff --git a/src/main/daemon/daemon-hello-protocol.ts b/src/main/daemon/daemon-hello-protocol.ts index 5d9d031cb9d..2fcba614bb4 100644 --- a/src/main/daemon/daemon-hello-protocol.ts +++ b/src/main/daemon/daemon-hello-protocol.ts @@ -1,9 +1,43 @@ +import { TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION } from '../../shared/terminal-session-authority-consumer-proof' +import { TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_VERSION } from '../../shared/terminal-session-authority-consumer-retirement' +import { assertAuthorityId } from '../../shared/terminal-session-authority-identity' + +export const TERMINAL_SESSION_AUTHORITY_HELLO_CAPABILITY = 1 as const + +export type DaemonTerminalAuthorityConsumerProofOffer = Readonly<{ + versions: readonly number[] + retirementVersions?: readonly number[] +}> + +export type DaemonTerminalAuthorityConsumerProofGrant = Readonly<{ + version: typeof TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION + authorityHostId: string + retirementVersion?: typeof TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_VERSION +}> + +export type DaemonHelloCapabilities = { + terminalSessionAuthority?: typeof TERMINAL_SESSION_AUTHORITY_HELLO_CAPABILITY + /** Proof-only: a peer that offers none negotiates no authority and stays on the legacy path. */ + terminalAuthorityConsumerProof?: + | DaemonTerminalAuthorityConsumerProofOffer + | DaemonTerminalAuthorityConsumerProofGrant +} + +export const CURRENT_DAEMON_HELLO_CAPABILITIES: Readonly = Object.freeze({ + terminalSessionAuthority: TERMINAL_SESSION_AUTHORITY_HELLO_CAPABILITY, + terminalAuthorityConsumerProof: Object.freeze({ + versions: Object.freeze([TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION]), + retirementVersions: Object.freeze([TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_VERSION]) + }) +}) + export type HelloMessage = { type: 'hello' version: number token: string clientId: string role: 'control' | 'stream' + capabilities?: DaemonHelloCapabilities } export type DaemonEndpointIdentity = { @@ -21,4 +55,122 @@ export type HelloResponse = { ok: boolean error?: string daemonIdentity?: DaemonEndpointIdentity + capabilities?: DaemonHelloCapabilities +} + +export function parseDaemonHelloCapabilities(value: unknown): DaemonHelloCapabilities { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {} + } + // Unknown members are dropped, not rejected: a newer peer may offer capabilities this build has no name for. + const raw = value as { + terminalSessionAuthority?: unknown + terminalAuthorityConsumerProof?: unknown + } + const consumerProof = parseConsumerProofCapability(raw.terminalAuthorityConsumerProof) + return { + ...(raw.terminalSessionAuthority === TERMINAL_SESSION_AUTHORITY_HELLO_CAPABILITY + ? { terminalSessionAuthority: TERMINAL_SESSION_AUTHORITY_HELLO_CAPABILITY } + : {}), + ...(consumerProof ? { terminalAuthorityConsumerProof: consumerProof } : {}) + } +} + +export function sameDaemonHelloCapabilities( + left: DaemonHelloCapabilities, + right: DaemonHelloCapabilities +): boolean { + return ( + left.terminalSessionAuthority === right.terminalSessionAuthority && + sameConsumerProofCapability( + left.terminalAuthorityConsumerProof, + right.terminalAuthorityConsumerProof + ) + ) +} + +function parseConsumerProofCapability( + value: unknown +): DaemonTerminalAuthorityConsumerProofOffer | DaemonTerminalAuthorityConsumerProofGrant | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null + } + const raw = value as { + versions?: unknown + retirementVersions?: unknown + version?: unknown + retirementVersion?: unknown + authorityHostId?: unknown + } + if ( + Array.isArray(raw.versions) && + raw.versions.length <= 8 && + raw.versions.every((version) => Number.isSafeInteger(version) && Number(version) > 0) + ) { + const offeredRetirementVersions = Array.isArray(raw.retirementVersions) + ? raw.retirementVersions + : null + const retirementVersions = offeredRetirementVersions + ? offeredRetirementVersions.filter( + (version) => Number.isSafeInteger(version) && Number(version) > 0 + ) + : [] + return Object.freeze({ + versions: Object.freeze(raw.versions.map(Number)), + ...(retirementVersions.length === offeredRetirementVersions?.length && + retirementVersions.length > 0 + ? { retirementVersions: Object.freeze(retirementVersions.map(Number)) } + : {}) + }) + } + if (raw.version !== TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION) { + return null + } + try { + assertAuthorityId(raw.authorityHostId, 'authorityHostId') + } catch { + return null + } + return Object.freeze({ + version: TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION, + authorityHostId: raw.authorityHostId, + ...(raw.retirementVersion === TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_VERSION + ? { retirementVersion: TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_VERSION } + : {}) + }) +} + +function sameConsumerProofCapability( + left: DaemonHelloCapabilities['terminalAuthorityConsumerProof'], + right: DaemonHelloCapabilities['terminalAuthorityConsumerProof'] +): boolean { + if (!left || !right) { + return left === right + } + if ('versions' in left || 'versions' in right) { + return ( + 'versions' in left && + 'versions' in right && + left.versions.length === right.versions.length && + left.versions.every((version, index) => version === right.versions[index]) && + sameOptionalVersions(left.retirementVersions, right.retirementVersions) + ) + } + return ( + left.version === right.version && + left.authorityHostId === right.authorityHostId && + left.retirementVersion === right.retirementVersion + ) +} + +function sameOptionalVersions(left?: readonly number[], right?: readonly number[]): boolean { + return ( + left === right || + Boolean( + left && + right && + left.length === right.length && + left.every((version, index) => version === right[index]) + ) + ) } diff --git a/src/main/daemon/daemon-init.test.ts b/src/main/daemon/daemon-init.test.ts index 77016298abc..fa577ffd1ff 100644 --- a/src/main/daemon/daemon-init.test.ts +++ b/src/main/daemon/daemon-init.test.ts @@ -248,6 +248,8 @@ type MockAdapter = { shutdown: ReturnType dispose: ReturnType disconnectOnly: ReturnType + terminalAuthorityAppHostTransport: ReturnType + installTerminalAuthorityAppAdmission: ReturnType onData: ReturnType onExit: ReturnType // Why: the router calls onData/onExit on each adapter; the stub returns a no-op unsubscribe so router subscription doesn't explode. @@ -295,6 +297,13 @@ vi.mock('./daemon-health', () => ({ vi.mock('./client', () => ({ DaemonClient: daemonClientMock })) +vi.mock('../session-authority/terminal-authority-consumer-proof-keypair', () => ({ + getTerminalAuthorityConsumerProofKeypair: () => ({ + publicKey: new Uint8Array(32).fill(1), + secretKey: new Uint8Array(32).fill(2) + }) +})) + vi.mock('./daemon-lifecycle-event', () => ({ trackDaemonReplaced: trackDaemonReplacedMock, trackDaemonRetired: trackDaemonRetiredMock @@ -375,6 +384,8 @@ vi.mock('./daemon-pty-adapter', () => ({ readonly shutdown: ReturnType readonly dispose: ReturnType readonly disconnectOnly: ReturnType + readonly terminalAuthorityAppHostTransport: ReturnType + readonly installTerminalAuthorityAppAdmission: ReturnType readonly onData: ReturnType readonly onExit: ReturnType readonly callOrder: string[] @@ -404,6 +415,8 @@ vi.mock('./daemon-pty-adapter', () => ({ throw disconnectOnlyError } }) + this.terminalAuthorityAppHostTransport = vi.fn(async () => null) + this.installTerminalAuthorityAppAdmission = vi.fn() this.onData = vi.fn(() => { if (routerSubscriptionError.current) { const error = routerSubscriptionError.current diff --git a/src/main/daemon/daemon-init.ts b/src/main/daemon/daemon-init.ts index 13682828838..b571803b925 100644 --- a/src/main/daemon/daemon-init.ts +++ b/src/main/daemon/daemon-init.ts @@ -59,6 +59,8 @@ import { } from '../claude-accounts/live-pty-gate' import { parseDaemonReadyIdentity, readDaemonProcessIncarnation } from './daemon-ready-identity' import type { DaemonEndpointIdentity } from './daemon-hello-protocol' +import { getTerminalAuthorityConsumerProofKeypair } from '../session-authority/terminal-authority-consumer-proof-keypair' +import { installPtyAuthorityOutcomeHostTransport } from '../ipc/pty-authority-outcome-ipc' // Why: daemon init runs concurrent with window load, so an in-process t timestamp (not harness stderr timing) measures cold-start. function logDaemonMilestone(event: string, details: Record = {}): void { @@ -907,6 +909,7 @@ export async function initDaemonPtyProvider( pidPath: getDaemonPidPath(runtimeDir), profileScope: runtimeDir, historyPath: getHistoryDir(), + terminalSessionAuthorityConsumerProofKeypair: getTerminalAuthorityConsumerProofKeypair(), // Why: on daemon death, ensureConnected() detects the dead socket and calls this to fork a replacement before retrying. respawn: async (reason: DaemonRespawnReason) => { // Why: attribute rather than emit — the launcher below is the one that completes the @@ -935,6 +938,7 @@ export async function initDaemonPtyProvider( // Why: the launcher's temporary pair closes only after this permanent pair is established, leaving no adoption gap. await newAdapter.establishLifecycleLease() releaseDaemonAdoptionLease(newSpawner.getHandle()) + await installDaemonAuthorityAppAdmission(newAdapter) legacyAdapters = await createLegacyDaemonAdapters(runtimeDir) routedAdapter = @@ -1058,6 +1062,33 @@ function getCurrentDaemonAdapter(provider: DaemonProvider): DaemonPtyAdapter { return provider } +async function installDaemonAuthorityAppAdmission(daemonAdapter: DaemonPtyAdapter): Promise { + const transport = await daemonAdapter.terminalAuthorityAppHostTransport() + if (transport) { + daemonAdapter.installTerminalAuthorityAppAdmission( + installPtyAuthorityOutcomeHostTransport(transport) + ) + } +} + +/** Rebinds the live daemon consumer to the successor proof key after identity reset publication. */ +export async function refreshDaemonAuthorityAppProofIdentity(): Promise { + if (!adapter) { + return + } + const adapters = + adapter instanceof DaemonPtyRouter || adapter instanceof DegradedDaemonPtyProvider + ? adapter.getAllAdapters() + : [adapter] + const proofKeypair = getTerminalAuthorityConsumerProofKeypair() + for (const daemonAdapter of adapters) { + if (daemonAdapter === getCurrentDaemonAdapter(adapter)) { + daemonAdapter.setTerminalAuthorityConsumerProofKeypair(proofKeypair) + await installDaemonAuthorityAppAdmission(daemonAdapter) + } + } +} + function getLegacyDaemonAdapters(provider: DaemonProvider): DaemonPtyAdapter[] { if (provider instanceof DaemonPtyRouter || provider instanceof DegradedDaemonPtyProvider) { return [...provider.getLegacyAdapters()] @@ -1142,6 +1173,7 @@ async function runRestartDaemon(): Promise { pidPath: getDaemonPidPath(runtimeDir), profileScope: runtimeDir, historyPath: getHistoryDir(), + terminalSessionAuthorityConsumerProofKeypair: getTerminalAuthorityConsumerProofKeypair(), respawn: async (reason: DaemonRespawnReason) => { // Why: attribute rather than emit — the launcher below is the one that completes the // replacement, and emitting here would fire before the outcome is known. @@ -1168,6 +1200,7 @@ async function runRestartDaemon(): Promise { // Temporary launcher lease overlaps this permanent pair so a manual restart can't strand a newly spawned daemon during adoption. await newCurrent.establishLifecycleLease() releaseDaemonAdoptionLease(currentSpawner.getHandle()) + await installDaemonAuthorityAppAdmission(newCurrent) // Re-wrap in a router only if legacy adapters exist; they're preserved by reference and still route to their pre-upgrade daemons. newProvider = diff --git a/src/main/daemon/daemon-main.ts b/src/main/daemon/daemon-main.ts index 63e763474bc..ae477c6c486 100644 --- a/src/main/daemon/daemon-main.ts +++ b/src/main/daemon/daemon-main.ts @@ -1,5 +1,6 @@ import { DaemonServer, type DaemonServerOptions } from './daemon-server' import type { DaemonFileLog } from './daemon-file-log' +import type { TerminalSessionAuthorityHostRuntime } from '../session-authority/terminal-session-authority-host-runtime' export type DaemonStartOptions = { socketPath: string @@ -20,7 +21,9 @@ export type DaemonStartOptions = { log?: DaemonFileLog onIdleShutdown?: () => void onRpcShutdown?: () => void + onTerminalSessionAuthorityFailure?: (error: Error) => void initialAdoptionTestConfig?: DaemonServerOptions['initialAdoptionTestConfig'] + terminalSessionAuthorityRuntime?: TerminalSessionAuthorityHostRuntime } export type DaemonHandle = { @@ -50,14 +53,32 @@ export async function startDaemon(opts: DaemonStartOptions): Promise + opts.terminalSessionAuthorityRuntime!.ptyOwner.hostEffectConsumerInstalled() + } + } : {}) }) await server.start() return { - shutdown: () => server.shutdown() + shutdown: async () => { + await server.shutdown() + await opts.terminalSessionAuthorityRuntime?.close() + } } } diff --git a/src/main/daemon/daemon-protocol-version.test.ts b/src/main/daemon/daemon-protocol-version.test.ts index 53ad0c06341..24e0aad9b8f 100644 --- a/src/main/daemon/daemon-protocol-version.test.ts +++ b/src/main/daemon/daemon-protocol-version.test.ts @@ -3,19 +3,31 @@ import { AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION, AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION, COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION, + EXACT_HELD_PRODUCER_PAUSE_DAEMON_PROTOCOL_VERSION, + EXACT_PTY_OPERATIONS_DAEMON_PROTOCOL_VERSION, GET_FOREGROUND_PROCESS_PROTOCOL_VERSION, HISTORY_SEED_TRANSFER_PROTOCOL_VERSION, MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION, SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION, STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION, + TERMINAL_SESSION_AUTHORITY_DAEMON_PROTOCOL_VERSION, PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION, - supportsMode2031UnsubscribeFact + PTY_STREAM_BINDING_DAEMON_PROTOCOL_VERSION, + supportsExactPtyOperations, + supportsExactHeldProducerPause, + supportsMode2031UnsubscribeFact, + supportsPtyStreamBinding, + supportsTerminalSessionAuthority } from './daemon-protocol-version' describe('daemon protocol version', () => { it('ships bounded history transfer after the 2031-unsubscribe fact', () => { - expect(PROTOCOL_VERSION).toBe(32) + expect(PROTOCOL_VERSION).toBe(35) + expect(EXACT_HELD_PRODUCER_PAUSE_DAEMON_PROTOCOL_VERSION).toBe(35) + expect(TERMINAL_SESSION_AUTHORITY_DAEMON_PROTOCOL_VERSION).toBe(34) + expect(EXACT_PTY_OPERATIONS_DAEMON_PROTOCOL_VERSION).toBe(33) + expect(PTY_STREAM_BINDING_DAEMON_PROTOCOL_VERSION).toBe(33) expect(SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION).toBe(32) expect(STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION).toBe(31) expect(HISTORY_SEED_TRANSFER_PROTOCOL_VERSION).toBe(30) @@ -25,10 +37,28 @@ describe('daemon protocol version', () => { expect(AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION).toBe(26) expect(AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION).toBe(26) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toEqual( - Array.from({ length: 31 }, (_, index) => index + 1) + Array.from({ length: 34 }, (_, index) => index + 1) ) }) + it('admits exact held pauses only at the acknowledged v35 boundary', () => { + expect(supportsExactHeldProducerPause(34)).toBe(false) + expect(supportsExactHeldProducerPause(35)).toBe(true) + }) + + it('keeps v33 incarnation fencing explicit but authority-unavailable', () => { + expect(supportsExactPtyOperations(33)).toBe(true) + expect(supportsTerminalSessionAuthority(33)).toBe(false) + expect(supportsTerminalSessionAuthority(34)).toBe(true) + }) + + it('advertises the complete exact contract at one coherent v33 boundary', () => { + expect(supportsExactPtyOperations(32)).toBe(false) + expect(supportsExactPtyOperations(33)).toBe(true) + expect(supportsPtyStreamBinding(32)).toBe(false) + expect(supportsPtyStreamBinding(33)).toBe(true) + }) + it('withholds 2031-unsubscribe support only before its v29 boundary', () => { // Why (#9993): v28 is what ships today, so a v28 daemon preserved across an app // update is the live hazard — it emits '2031-subscribe' with no way to retract it. diff --git a/src/main/daemon/daemon-protocol-version.ts b/src/main/daemon/daemon-protocol-version.ts index fee06b235f2..c842946afa9 100644 --- a/src/main/daemon/daemon-protocol-version.ts +++ b/src/main/daemon/daemon-protocol-version.ts @@ -1,6 +1,10 @@ // Why: daemons survive app updates, so wire behavior must be version-gated. -// v32 carries the corrected terminal snapshot serializer; older owners remain attachable. -export const PROTOCOL_VERSION = 32 +// v35 adds acknowledged, incarnation-fenced producer-pause leases. +export const PROTOCOL_VERSION = 35 +export const EXACT_HELD_PRODUCER_PAUSE_DAEMON_PROTOCOL_VERSION = 35 +export const TERMINAL_SESSION_AUTHORITY_DAEMON_PROTOCOL_VERSION = 34 +export const EXACT_PTY_OPERATIONS_DAEMON_PROTOCOL_VERSION = 33 +export const PTY_STREAM_BINDING_DAEMON_PROTOCOL_VERSION = 33 export const SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION = 32 export const STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION = 31 export const HISTORY_SEED_TRANSFER_PROTOCOL_VERSION = 30 @@ -26,9 +30,25 @@ export const CLEAN_DISCONNECT_PROTOCOL_VERSION = 24 export const MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION = 29 export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, - 28, 29, 30, 31 + 28, 29, 30, 31, 32, 33, 34 ] as const +export function supportsExactHeldProducerPause(protocolVersion: number): boolean { + return protocolVersion >= EXACT_HELD_PRODUCER_PAUSE_DAEMON_PROTOCOL_VERSION +} + +export function supportsTerminalSessionAuthority(protocolVersion: number): boolean { + return protocolVersion >= TERMINAL_SESSION_AUTHORITY_DAEMON_PROTOCOL_VERSION +} + +export function supportsExactPtyOperations(protocolVersion: number): boolean { + return protocolVersion >= EXACT_PTY_OPERATIONS_DAEMON_PROTOCOL_VERSION +} + +export function supportsPtyStreamBinding(protocolVersion: number): boolean { + return protocolVersion >= PTY_STREAM_BINDING_DAEMON_PROTOCOL_VERSION +} + export function supportsPtyStartupIngress(protocolVersion: number): boolean { return protocolVersion >= PTY_STARTUP_INGRESS_PROTOCOL_VERSION } diff --git a/src/main/daemon/daemon-pty-adapter-app-admission.test.ts b/src/main/daemon/daemon-pty-adapter-app-admission.test.ts new file mode 100644 index 00000000000..e480cbc712f --- /dev/null +++ b/src/main/daemon/daemon-pty-adapter-app-admission.test.ts @@ -0,0 +1,122 @@ +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { DaemonClient } from './client' +import { DaemonPtyAdapter } from './daemon-pty-adapter' +import { PROTOCOL_VERSION } from './types' +import type { TerminalAuthorityAppNamespaceAdmission } from '../session-authority/terminal-authority-app-outcome-host-contract' + +const adapters: DaemonPtyAdapter[] = [] + +afterEach(() => { + for (const adapter of adapters.splice(0)) { + adapter.dispose() + } + vi.restoreAllMocks() +}) + +describe('DaemonPtyAdapter authority app admission', () => { + it('resolves and admits the namespace before authoritative create-or-attach', async () => { + const order: string[] = [] + const namespace = Object.freeze({ + authorityHostId: 'authority-host:daemon-preflight', + namespaceId: 'namespace:daemon-preflight' + }) + const adapter = adapterWithCreateResult(order, namespace) + const admission: TerminalAuthorityAppNamespaceAdmission & Readonly<{ dispose(): void }> = { + admitNamespace: vi.fn(async () => { + order.push('admit') + }), + resolveAndAdmitNamespace: vi.fn(async () => { + order.push('resolve-and-admit') + return namespace + }), + withSourceAdmission: vi.fn(async (locator, operation) => { + expect(locator).toEqual({ worktreeId: 'repo::workspace' }) + order.push('resolve-and-admit') + const result = await operation({ namespace, assertCurrent: vi.fn() }) + order.push('release') + return result + }), + dispose: vi.fn() + } + adapter.installTerminalAuthorityAppAdmission(admission) + + await adapter.spawn({ + sessionId: 'daemon-preflight-session', + worktreeId: 'repo::workspace', + paneKey: 'pane-preflight', + paneGeneration: 4, + cols: 80, + rows: 24 + }) + + expect(order).toEqual(['resolve-and-admit', 'create-or-attach', 'release']) + expect(admission.withSourceAdmission).toHaveBeenCalledOnce() + }) + + it('fails before create-or-attach when the app consumer is unavailable', async () => { + const order: string[] = [] + const adapter = adapterWithCreateResult( + order, + Object.freeze({ + authorityHostId: 'authority-host:daemon-preflight', + namespaceId: 'namespace:daemon-preflight' + }) + ) + + await expect( + adapter.spawn({ + sessionId: 'daemon-preflight-missing', + worktreeId: 'repo::workspace', + paneKey: 'pane-preflight', + paneGeneration: 4, + cols: 80, + rows: 24 + }) + ).rejects.toThrow('app_consumer_unavailable') + expect(order).toEqual([]) + }) +}) + +function adapterWithCreateResult( + order: string[], + namespace: Readonly<{ authorityHostId: string; namespaceId: string }> +): DaemonPtyAdapter { + vi.spyOn(DaemonClient.prototype, 'ensureConnected').mockResolvedValue() + vi.spyOn(DaemonClient.prototype, 'supportsTerminalSessionAuthority').mockReturnValue(true) + vi.spyOn(DaemonClient.prototype, 'request').mockImplementation(async (type, payload) => { + if (type !== 'createOrAttach') { + return {} as never + } + order.push('create-or-attach') + const request = payload as Record + return { + isNew: true, + incarnationId: 'pty-incarnation:daemon-preflight', + pid: 42, + shellState: 'unsupported', + snapshot: null, + streamBindingNonce: request.streamBindingNonce, + terminalSessionAuthorityAccess: { + namespace, + pane: { + paneKey: 'pane-preflight', + paneGenerationId: 'renderer:4' + }, + binding: { + ownerIncarnationId: 'owner-incarnation:daemon-preflight', + physicalPtyId: request.sessionId, + ptyIncarnationId: 'pty-incarnation:daemon-preflight' + } + } + } as never + }) + const adapter = new DaemonPtyAdapter({ + socketPath: join(tmpdir(), 'orca-daemon-preflight.socket'), + tokenPath: join(tmpdir(), 'orca-daemon-preflight.token'), + protocolVersion: PROTOCOL_VERSION + }) + adapters.push(adapter) + return adapter +} diff --git a/src/main/daemon/daemon-pty-adapter-authority.test.ts b/src/main/daemon/daemon-pty-adapter-authority.test.ts new file mode 100644 index 00000000000..b4e491687c0 --- /dev/null +++ b/src/main/daemon/daemon-pty-adapter-authority.test.ts @@ -0,0 +1,357 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { DaemonClient } from './client' +import { DaemonPtyAdapter } from './daemon-pty-adapter' +import { + EXACT_PTY_OPERATIONS_DAEMON_PROTOCOL_VERSION, + TERMINAL_SESSION_AUTHORITY_DAEMON_PROTOCOL_VERSION +} from './daemon-protocol-version' +import type { SessionInfo } from './types' +import { killListedPty } from '../providers/pty-listed-session-kill' +import type { TerminalSessionAuthorityPtyAccess } from '../../shared/terminal-session-authority-pty-access' + +const adapters: DaemonPtyAdapter[] = [] + +afterEach(() => { + for (const adapter of adapters.splice(0)) { + adapter.dispose() + } + vi.restoreAllMocks() +}) + +describe('DaemonPtyAdapter authority inventory', () => { + it('fails closed against v32 inventory without exact operations', async () => { + const { adapter, request } = adapterWithInventory( + EXACT_PTY_OPERATIONS_DAEMON_PROTOCOL_VERSION - 1, + [listedSession('session-v32', 'incarnation-v32')] + ) + + const [listed] = await adapter.listProcesses() + + expect(listed).toBeDefined() + await expect(killListedPty(adapter, listed!, { immediate: true })).resolves.toBe(false) + expect(request.mock.calls.some(([type]) => type === 'killExact')).toBe(false) + expect(request.mock.calls.some(([type]) => type === 'killAuthorityExact')).toBe(false) + }) + + it('uses the listed incarnation for v33 orphan-safe shutdown', async () => { + const session = listedSession('session-v33', 'incarnation-v33') + const { adapter, request } = adapterWithInventory( + EXACT_PTY_OPERATIONS_DAEMON_PROTOCOL_VERSION, + [session] + ) + + const [listed] = await adapter.listProcesses() + + await expect(killListedPty(adapter, listed!, { immediate: true })).resolves.toBe(true) + expect(request).toHaveBeenCalledWith( + 'killExact', + { + sessionId: session.sessionId, + incarnationId: session.incarnationId, + immediate: true + }, + undefined + ) + expect(request.mock.calls.some(([type]) => type === 'kill')).toBe(false) + }) + + it('uses full authority access for v34 inventory shutdown', async () => { + const access = authorityAccess('session-v34', 'incarnation-v34') + const session = listedSession('session-v34', 'incarnation-v34', access) + const { adapter, request } = adapterWithInventory( + TERMINAL_SESSION_AUTHORITY_DAEMON_PROTOCOL_VERSION, + [session] + ) + + const [listed] = await adapter.listProcesses() + + expect(listed?.terminalSessionAuthorityAccess).toEqual(access) + await expect(killListedPty(adapter, listed!, { immediate: true })).resolves.toBe(true) + expect(request).toHaveBeenCalledWith( + 'killAuthorityExact', + { sessionId: session.sessionId, authorityAccess: access, immediate: true }, + undefined + ) + expect(request.mock.calls.some(([type]) => type === 'killExact')).toBe(false) + expect(request.mock.calls.some(([type]) => type === 'kill')).toBe(false) + }) + + it('upgrades every incarnation-only mutation to the cached authority access', async () => { + const access = authorityAccess('authority-session', 'authority-incarnation') + const { adapter, notify, request } = adapterWithInventory( + TERMINAL_SESSION_AUTHORITY_DAEMON_PROTOCOL_VERSION, + [listedSession('authority-session', 'authority-incarnation', access)] + ) + await adapter.listProcesses() + + expect(adapter.bindTerminalSessionAuthorityAccess('authority-session', access)).toBe(true) + expect( + adapter.bindTerminalSessionAuthorityAccess( + 'authority-session', + authorityAccess('authority-session', 'stale-incarnation') + ) + ).toBe(false) + expect(adapter.writeExact('authority-session', 'stale-incarnation', 'stale')).toBe(false) + expect(adapter.resizeExact('authority-session', 'stale-incarnation', 90, 30)).toBe(false) + await expect( + adapter.sendSignalExact('authority-session', 'stale-incarnation', 'SIGTERM') + ).resolves.toBe(false) + await expect(adapter.clearBufferExact('authority-session', 'stale-incarnation')).resolves.toBe( + false + ) + await expect( + adapter.killExact('authority-session', 'stale-incarnation', { immediate: true }) + ).resolves.toBe(false) + + expect(adapter.writeExact('authority-session', 'authority-incarnation', 'current')).toBe(true) + expect(adapter.resizeExact('authority-session', 'authority-incarnation', 120, 40)).toBe(true) + await expect( + adapter.sendSignalExact('authority-session', 'authority-incarnation', 'SIGTERM') + ).resolves.toBe(true) + await expect( + adapter.clearBufferExact('authority-session', 'authority-incarnation') + ).resolves.toBe(true) + await expect( + adapter.killExact('authority-session', 'authority-incarnation', { immediate: true }) + ).resolves.toBe(true) + + expect(notify).toHaveBeenCalledWith('writeAuthorityExact', { + sessionId: 'authority-session', + authorityAccess: access, + data: 'current' + }) + expect(notify).toHaveBeenCalledWith('resizeAuthorityExact', { + sessionId: 'authority-session', + authorityAccess: access, + cols: 120, + rows: 40 + }) + expect(request.mock.calls.map(([type]) => type)).toEqual( + expect.arrayContaining([ + 'signalAuthorityExact', + 'clearBufferAuthorityExact', + 'killAuthorityExact' + ]) + ) + expect( + [...notify.mock.calls, ...request.mock.calls].some( + ([type]) => + type === 'writeExact' || + type === 'resizeExact' || + type === 'signalExact' || + type === 'clearBufferExact' || + type === 'killExact' + ) + ).toBe(false) + }) + + it('returns full authority and route evidence to Manage Sessions inventory', async () => { + const access = authorityAccess('managed-session', 'managed-incarnation') + const { adapter } = adapterWithInventory(TERMINAL_SESSION_AUTHORITY_DAEMON_PROTOCOL_VERSION, [ + listedSession('managed-session', 'managed-incarnation', access) + ]) + + const [listed] = await adapter.listSessions() + + expect(listed).toMatchObject({ + sessionId: 'managed-session', + incarnationId: 'managed-incarnation', + terminalSessionAuthorityAccess: access, + mutationRouteToken: expect.any(Object) + }) + expect(adapter.getPtyMutationRouteToken('managed-session')).toBe(listed?.mutationRouteToken) + }) + + it('reconciles authority-owned startup orphans without a legacy kill', async () => { + const access = authorityAccess('missing-worktree@@pty', 'orphan-incarnation') + const { adapter, request } = adapterWithInventory( + TERMINAL_SESSION_AUTHORITY_DAEMON_PROTOCOL_VERSION, + [listedSession('missing-worktree@@pty', 'orphan-incarnation', access)] + ) + + await expect(adapter.reconcileOnStartup(new Set())).resolves.toEqual({ + alive: [], + killed: ['missing-worktree@@pty'] + }) + expect(request).toHaveBeenCalledWith('killAuthorityExact', { + sessionId: 'missing-worktree@@pty', + authorityAccess: access + }) + expect(request.mock.calls.some(([type]) => type === 'kill')).toBe(false) + }) + + it('rejects authority access that does not bind the listed row', async () => { + const mismatched = authorityAccess('different-session', 'incarnation-v34') + const { adapter } = adapterWithInventory(TERMINAL_SESSION_AUTHORITY_DAEMON_PROTOCOL_VERSION, [ + listedSession('session-v34', 'incarnation-v34', mismatched) + ]) + + await expect(adapter.listProcesses()).rejects.toThrow( + 'daemon_terminal_session_authority_access_invalid' + ) + expect(adapter.getPtyMutationRouteToken('session-v34')).toBeNull() + }) + + it('rejects an inventory downgrade for the same authority-owned incarnation', async () => { + const access = authorityAccess('session-v34', 'incarnation-v34') + let sessions = [listedSession('session-v34', 'incarnation-v34', access)] + const { adapter } = adapterWithInventory( + TERMINAL_SESSION_AUTHORITY_DAEMON_PROTOCOL_VERSION, + () => sessions + ) + const [first] = await adapter.listProcesses() + sessions = [listedSession('session-v34', 'incarnation-v34')] + + await expect(adapter.listProcesses()).rejects.toThrow( + 'daemon_terminal_session_authority_access_missing' + ) + expect(adapter.getPtyMutationRouteToken('session-v34')).toBe(first?.mutationRouteToken) + }) + + it('invalidates old authority when inventory proves a replacement incarnation', async () => { + const oldAccess = authorityAccess('reused-session', 'old-incarnation') + let sessions = [listedSession('reused-session', 'old-incarnation', oldAccess)] + const { adapter, request } = adapterWithInventory( + TERMINAL_SESSION_AUTHORITY_DAEMON_PROTOCOL_VERSION, + () => sessions + ) + const [oldListing] = await adapter.listProcesses() + sessions = [listedSession('reused-session', 'new-incarnation')] + const [replacement] = await adapter.listProcesses() + + await expect(killListedPty(adapter, oldListing!, { immediate: true })).resolves.toBe(false) + await expect(killListedPty(adapter, replacement!, { immediate: true })).resolves.toBe(true) + expect(request).toHaveBeenCalledWith( + 'killExact', + { + sessionId: 'reused-session', + incarnationId: 'new-incarnation', + immediate: true + }, + undefined + ) + }) + + it('retains authority across disconnect but invalidates listed route tokens', async () => { + const access = authorityAccess('reconnect-session', 'reconnect-incarnation') + const { adapter, request, disconnect } = adapterWithInventory( + TERMINAL_SESSION_AUTHORITY_DAEMON_PROTOCOL_VERSION, + [listedSession('reconnect-session', 'reconnect-incarnation', access)] + ) + const [listed] = await adapter.listProcesses() + + disconnect() + + expect(adapter.getPtyMutationRouteToken('reconnect-session')).toBeNull() + await expect(killListedPty(adapter, listed!, { immediate: true })).resolves.toBe(false) + await expect( + adapter.killAuthorityExact('reconnect-session', access, { immediate: true }) + ).resolves.toBe(true) + expect(request.mock.calls.some(([type]) => type === 'killAuthorityExact')).toBe(true) + }) + + it('forgets authority when daemon replacement fans out synthetic exits', async () => { + const access = authorityAccess('replaced-session', 'replaced-incarnation') + const { adapter, request } = adapterWithInventory( + TERMINAL_SESSION_AUTHORITY_DAEMON_PROTOCOL_VERSION, + [listedSession('replaced-session', 'replaced-incarnation', access)] + ) + await adapter.listProcesses() + + adapter.fanoutSyntheticExits(-1) + + expect(adapter.bindTerminalSessionAuthorityAccess('replaced-session', access)).toBe(false) + await expect( + adapter.killAuthorityExact('replaced-session', access, { immediate: true }) + ).resolves.toBe(false) + expect(request.mock.calls.some(([type]) => type === 'killAuthorityExact')).toBe(false) + }) +}) + +function adapterWithInventory( + protocolVersion: number, + sessions: SessionInfo[] | (() => SessionInfo[]) +): { + adapter: DaemonPtyAdapter + request: ReturnType + notify: ReturnType + disconnect: () => void +} { + vi.spyOn(DaemonClient.prototype, 'ensureConnected').mockResolvedValue() + vi.spyOn(DaemonClient.prototype, 'supportsTerminalSessionAuthority').mockReturnValue( + protocolVersion >= TERMINAL_SESSION_AUTHORITY_DAEMON_PROTOCOL_VERSION + ) + const request = vi + .spyOn(DaemonClient.prototype, 'request') + .mockImplementation(async (type: string) => { + if (type === 'listSessions') { + return { sessions: typeof sessions === 'function' ? sessions() : sessions } as never + } + if ( + type === 'killExact' || + type === 'killAuthorityExact' || + type === 'signalAuthorityExact' || + type === 'clearBufferAuthorityExact' + ) { + return { accepted: true } as never + } + return {} as never + }) + const notify = vi.spyOn(DaemonClient.prototype, 'notify').mockReturnValue(true) + const adapter = new DaemonPtyAdapter({ + socketPath: join(tmpdir(), `orca-daemon-authority-${protocolVersion}.socket`), + tokenPath: join(tmpdir(), `orca-daemon-authority-${protocolVersion}.token`), + protocolVersion + }) + adapters.push(adapter) + const client = adapter as unknown as { + client: { disconnectedListeners: (() => void)[] } + } + return { + adapter, + request, + notify, + disconnect: () => { + for (const listener of client.client.disconnectedListeners) { + listener() + } + } + } +} + +function listedSession( + sessionId: string, + incarnationId: string, + access?: TerminalSessionAuthorityPtyAccess +): SessionInfo { + return { + sessionId, + incarnationId, + ...(access ? { terminalSessionAuthorityAccess: access } : {}), + state: 'running', + shellState: 'unsupported', + isAlive: true, + pid: 42, + cwd: tmpdir(), + cols: 80, + rows: 24, + createdAt: 1 + } +} + +function authorityAccess( + physicalPtyId: string, + ptyIncarnationId: string +): TerminalSessionAuthorityPtyAccess { + return { + namespace: { authorityHostId: 'authority-host', namespaceId: 'namespace' }, + pane: { paneKey: 'pane', paneGenerationId: 'renderer:1' }, + binding: { + ownerIncarnationId: 'owner-incarnation', + physicalPtyId, + ptyIncarnationId + } + } +} diff --git a/src/main/daemon/daemon-pty-adapter-output-incarnation.test.ts b/src/main/daemon/daemon-pty-adapter-output-incarnation.test.ts new file mode 100644 index 00000000000..8203ec96b75 --- /dev/null +++ b/src/main/daemon/daemon-pty-adapter-output-incarnation.test.ts @@ -0,0 +1,68 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { DaemonClient } from './client' +import { DaemonPtyAdapter } from './daemon-pty-adapter' +import { PROTOCOL_VERSION } from './daemon-protocol-version' + +type AdapterInternals = { + setupEventRouting: () => void + streamBindings: { + begin: (nonce: string) => void + acceptResponse: ( + nonce: string, + sessionId: string, + incarnationId: string, + echoedNonce: string + ) => boolean + } +} + +afterEach(() => vi.restoreAllMocks()) + +describe('DaemonPtyAdapter exact output identity', () => { + it('tags data from the admitted source and keeps a same-id successor after a stale marker', () => { + const eventListeners: ((event: unknown) => void)[] = [] + vi.spyOn(DaemonClient.prototype, 'onEvent').mockImplementation((listener) => { + eventListeners.push(listener) + return () => {} + }) + const adapter = new DaemonPtyAdapter({ + socketPath: join(tmpdir(), 'orca-daemon-output-incarnation.socket'), + tokenPath: join(tmpdir(), 'orca-daemon-output-incarnation.token'), + protocolVersion: PROTOCOL_VERSION + }) + const received: { id: string; data: string; incarnationId?: string }[] = [] + adapter.onData((payload) => received.push(payload)) + const internals = adapter as unknown as AdapterInternals + internals.setupEventRouting() + + const emit = (event: string, payload: unknown): void => { + for (const listener of eventListeners) { + listener({ type: 'event', event, sessionId: 'reused-session', payload }) + } + } + const bind = (incarnationId: string, nonce: string): void => { + internals.streamBindings.begin(nonce) + emit('sessionSource', { incarnationId, streamBindingNonce: nonce }) + expect( + internals.streamBindings.acceptResponse(nonce, 'reused-session', incarnationId, nonce) + ).toBe(true) + } + + emit('data', { data: 'unbound' }) + bind('incarnation-a', 'nonce-a') + emit('data', { data: 'first' }) + bind('incarnation-b', 'nonce-b') + emit('data', { data: 'successor' }) + emit('sessionSource', { incarnationId: 'incarnation-a', streamBindingNonce: 'nonce-a' }) + emit('data', { data: 'after-stale-marker' }) + + expect(received).toEqual([ + { id: 'reused-session', data: 'first', incarnationId: 'incarnation-a' }, + { id: 'reused-session', data: 'successor', incarnationId: 'incarnation-b' }, + { id: 'reused-session', data: 'after-stale-marker', incarnationId: 'incarnation-b' } + ]) + adapter.dispose() + }) +}) diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts index b769f1e88ef..7727d6c54ce 100644 --- a/src/main/daemon/daemon-pty-adapter.test.ts +++ b/src/main/daemon/daemon-pty-adapter.test.ts @@ -8,6 +8,7 @@ import { DaemonProtocolError } from './daemon-errors' import { DaemonPtyAdapter, LIVENESS_PROBE_TIMEOUT_MS } from './daemon-pty-adapter' import { COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION, + EXACT_PTY_OPERATIONS_DAEMON_PROTOCOL_VERSION, GET_FOREGROUND_PROCESS_PROTOCOL_VERSION, GET_SIZE_PROTOCOL_VERSION, PROTOCOL_VERSION @@ -59,6 +60,7 @@ function createMockSubprocess(dataOnSubscribe?: string): SubprocessHandle & { resize: vi.fn(), pause: vi.fn<() => void>(), resume: vi.fn<() => void>(), + clear: vi.fn(), kill: vi.fn(() => setTimeout(() => onExitCb?.(0), 5)), forceKill: vi.fn(() => setTimeout(() => onExitCb?.(137), 5)), signal: vi.fn(), @@ -163,6 +165,77 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { expect(result.providerSequence).toEqual({ value: 0, generation: 'reset' }) }) + it('binds first-attach output and exact input to the returned incarnation', async () => { + const onData = vi.fn() + adapter.onData(onData) + const result = await adapter.spawn({ cols: 80, rows: 24, sessionId: 'exact-session' }) + + expect(adapter.supportsExactPtyOperations(result.id)).toBe(false) + // A daemon that negotiated no authority keeps its panes on the legacy mutation path; only a + // pane that holds authority access can be 'unavailable'. + expect(adapter.getPtyMutationMode(result.id)).toBe('legacy') + lastSubprocess._simulateData('current-output') + await waitFor(() => onData.mock.calls.length === 1) + expect(onData).toHaveBeenCalledWith({ + id: result.id, + data: 'current-output', + incarnationId: result.incarnationId + }) + expect(adapter.writeExact(result.id, 'stale-incarnation', 'stale')).toBe(false) + expect(adapter.resizeExact(result.id, 'stale-incarnation', 120, 40)).toBe(false) + await expect( + adapter.sendSignalExact(result.id, 'stale-incarnation', 'SIGTERM') + ).resolves.toBe(false) + await expect(adapter.clearBufferExact(result.id, 'stale-incarnation')).resolves.toBe(false) + expect(adapter.writeExact(result.id, result.incarnationId!, 'current')).toBe(true) + expect(adapter.resizeExact(result.id, result.incarnationId!, 120, 40)).toBe(true) + await expect( + adapter.sendSignalExact(result.id, result.incarnationId!, 'SIGTERM') + ).resolves.toBe(true) + await expect(adapter.clearBufferExact(result.id, result.incarnationId!)).resolves.toBe(true) + await waitFor(() => lastSubprocess.write.mock.calls.some(([data]) => data === 'current')) + expect(lastSubprocess.resize).toHaveBeenCalledWith(120, 40) + expect(lastSubprocess.signal).toHaveBeenCalledWith('SIGTERM') + expect(lastSubprocess.clear).toHaveBeenCalledOnce() + }) + + it('fails closed against a v32 daemon without the exact contract', async () => { + const ensureConnectedSpy = vi + .spyOn(DaemonClient.prototype, 'ensureConnected') + .mockResolvedValue() + const requestSpy = vi.spyOn(DaemonClient.prototype, 'request').mockResolvedValue({ + isNew: true, + incarnationId: 'legacy-incarnation', + pid: null, + shellState: 'unsupported', + snapshot: null + } as never) + const legacy = new DaemonPtyAdapter({ + socketPath, + tokenPath, + protocolVersion: EXACT_PTY_OPERATIONS_DAEMON_PROTOCOL_VERSION - 1 + }) + try { + const result = await legacy.spawn({ + sessionId: 'legacy-exact-session', + cols: 80, + rows: 24 + }) + const createPayload = requestSpy.mock.calls.find(([type]) => type === 'createOrAttach')?.[1] + expect(createPayload).not.toHaveProperty('streamBindingNonce') + expect(legacy.supportsExactPtyOperations(result.id)).toBe(false) + expect(legacy.writeExact(result.id, 'legacy-incarnation', 'blocked')).toBe(false) + await expect( + legacy.sendSignalExact(result.id, 'legacy-incarnation', 'SIGTERM') + ).resolves.toBe(false) + await expect(legacy.clearBufferExact(result.id, 'legacy-incarnation')).resolves.toBe(false) + } finally { + legacy.dispose() + requestSpy.mockRestore() + ensureConnectedSpy.mockRestore() + } + }) + it('carries classified startup spans from the daemon source to the adapter', async () => { const onData = vi.fn() adapter.onData(onData) @@ -184,11 +257,16 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { expect(onData).toHaveBeenCalledWith({ id, data: '', + incarnationId: expect.any(String), sequenceChars: query.length, seq: query.length, transformed: true }) - expect(onData).toHaveBeenCalledWith({ id, data: 'prompt' }) + expect(onData).toHaveBeenCalledWith({ + id, + data: 'prompt', + incarnationId: expect.any(String) + }) await expect(adapter.getBufferSnapshot(id)).resolves.toMatchObject({ data: expect.not.stringContaining(']10;rgb') }) @@ -510,6 +588,124 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { notifySpy.mockRestore() } }) + + it('acknowledges exact held pauses and releases only the last idempotent lease', async () => { + const { id, incarnationId } = await adapter.spawn({ cols: 80, rows: 24 }) + await waitFor(() => adapter.supportsExactHeldProducerPause(id, incarnationId!)) + const client = (adapter as unknown as { client: DaemonClient }).client + const requestSpy = vi.spyOn(client, 'request') + + await expect( + adapter.acquireExactHeldProducerPause(id, incarnationId!, 'lease-a') + ).resolves.toBe(true) + await expect( + adapter.acquireExactHeldProducerPause(id, incarnationId!, 'lease-a') + ).resolves.toBe(true) + await expect( + adapter.acquireExactHeldProducerPause(id, incarnationId!, 'lease-b') + ).resolves.toBe(true) + expect(requestSpy).toHaveBeenCalledWith('pausePty', { + sessionId: id, + incarnationId, + heldPauseToken: 'lease-a' + }) + expect(lastSubprocess.pause).toHaveBeenCalledTimes(1) + + await expect( + adapter.releaseExactHeldProducerPause(id, incarnationId!, 'lease-a') + ).resolves.toBe(true) + expect(lastSubprocess.resume).not.toHaveBeenCalled() + await expect( + adapter.releaseExactHeldProducerPause(id, incarnationId!, 'lease-b') + ).resolves.toBe(true) + await expect( + adapter.releaseExactHeldProducerPause(id, incarnationId!, 'lease-b') + ).resolves.toBe(true) + expect(lastSubprocess.resume).toHaveBeenCalledTimes(1) + }) + + it('fails closed for stale incarnations and pre-v35 daemon protocols', async () => { + const { id, incarnationId } = await adapter.spawn({ cols: 80, rows: 24 }) + await waitFor(() => adapter.supportsExactHeldProducerPause(id, incarnationId!)) + expect(adapter.supportsExactHeldProducerPause(id, 'stale-incarnation')).toBe(false) + await expect( + adapter.acquireExactHeldProducerPause(id, 'stale-incarnation', 'lease') + ).resolves.toBe(false) + + const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 34 }) + const requestSpy = vi.spyOn((legacy as unknown as { client: DaemonClient }).client, 'request') + try { + expect(legacy.supportsExactHeldProducerPause(id, incarnationId!)).toBe(false) + await expect( + legacy.acquireExactHeldProducerPause(id, incarnationId!, 'lease') + ).resolves.toBe(false) + expect(requestSpy).not.toHaveBeenCalled() + } finally { + legacy.dispose() + } + }) + + it('rejects partial or stale v35 lease payloads without applying a legacy pause', async () => { + const { id } = await adapter.spawn({ cols: 80, rows: 24 }) + const client = (adapter as unknown as { client: DaemonClient }).client + + await expect( + client.request('pausePty', { sessionId: id, incarnationId: 'missing-token' }) + ).resolves.toEqual({ accepted: false }) + await expect( + client.request('pausePty', { + sessionId: id, + incarnationId: 'stale-incarnation', + heldPauseToken: 'lease' + }) + ).resolves.toEqual({ accepted: false }) + expect(lastSubprocess.pause).not.toHaveBeenCalled() + }) + + it('releases exact held pauses when the owning connection closes', async () => { + const { id, incarnationId } = await adapter.spawn({ cols: 80, rows: 24 }) + await waitFor(() => adapter.supportsExactHeldProducerPause(id, incarnationId!)) + await adapter.acquireExactHeldProducerPause(id, incarnationId!, 'lease') + + ;(adapter as unknown as { client: DaemonClient }).client.disconnect() + + await waitFor(() => lastSubprocess.resume.mock.calls.length === 1) + }) + + it('disconnects to unwind a remotely applied acquire whose acknowledgement is lost', async () => { + const { id, incarnationId } = await adapter.spawn({ cols: 80, rows: 24 }) + await waitFor(() => adapter.supportsExactHeldProducerPause(id, incarnationId!)) + const client = (adapter as unknown as { client: DaemonClient }).client + const originalRequest = client.request.bind(client) + vi.spyOn(client, 'request').mockImplementationOnce(async (type, payload) => { + await originalRequest(type, payload) + throw new Error('ack lost') + }) + + await expect( + adapter.acquireExactHeldProducerPause(id, incarnationId!, 'lease') + ).resolves.toBe(false) + await waitFor(() => lastSubprocess.resume.mock.calls.length === 1) + expect(client.isConnected()).toBe(false) + }) + + it('does not pretend an exact release succeeded when its acknowledgement is lost', async () => { + const { id, incarnationId } = await adapter.spawn({ cols: 80, rows: 24 }) + await waitFor(() => adapter.supportsExactHeldProducerPause(id, incarnationId!)) + await adapter.acquireExactHeldProducerPause(id, incarnationId!, 'lease') + const client = (adapter as unknown as { client: DaemonClient }).client + const originalRequest = client.request.bind(client) + vi.spyOn(client, 'request').mockImplementationOnce(async (type, payload) => { + await originalRequest(type, payload) + throw new Error('ack lost') + }) + + await expect( + adapter.releaseExactHeldProducerPause(id, incarnationId!, 'lease') + ).resolves.toBe(false) + await waitFor(() => lastSubprocess.resume.mock.calls.length === 1) + expect(client.isConnected()).toBe(false) + }) }) describe('dead-endpoint write respawn (STA-2373)', () => { @@ -1432,24 +1628,24 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { const dataPayloads: { id: string; data: string }[] = [] adapter.onData((payload) => dataPayloads.push(payload)) - const { id } = await adapter.spawn({ cols: 80, rows: 24 }) + const { id, incarnationId } = await adapter.spawn({ cols: 80, rows: 24 }) lastSubprocess._simulateData('hello') await waitFor(() => dataPayloads.length > 0) - expect(dataPayloads[0]).toEqual({ id, data: 'hello' }) + expect(dataPayloads[0]).toEqual({ id, data: 'hello', incarnationId }) }) it('coalesces burst data events before serializing daemon stream output', async () => { const dataPayloads: { id: string; data: string }[] = [] adapter.onData((payload) => dataPayloads.push(payload)) - const { id } = await adapter.spawn({ cols: 80, rows: 24 }) + const { id, incarnationId } = await adapter.spawn({ cols: 80, rows: 24 }) lastSubprocess._simulateData('a') lastSubprocess._simulateData('b') lastSubprocess._simulateData('c') await waitFor(() => dataPayloads.length > 0) - expect(dataPayloads).toEqual([{ id, data: 'abc' }]) + expect(dataPayloads).toEqual([{ id, data: 'abc', incarnationId }]) }) }) @@ -1641,7 +1837,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { describe('attach', () => { it('reattaches to existing session and receives events', async () => { - const { id } = await adapter.spawn({ cols: 80, rows: 24 }) + const { id, incarnationId } = await adapter.spawn({ cols: 80, rows: 24 }) // Create a second adapter simulating app restart const adapter2 = new DaemonPtyAdapter({ socketPath, tokenPath }) @@ -1652,7 +1848,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { lastSubprocess._simulateData('after-reattach') await waitFor(() => dataPayloads.length > 0) - expect(dataPayloads[0]).toEqual({ id, data: 'after-reattach' }) + expect(dataPayloads[0]).toEqual({ id, data: 'after-reattach', incarnationId }) adapter2.dispose() }) @@ -1790,6 +1986,16 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { }) }) + it('binds management inventory to the route captured from that RPC result', async () => { + const spawned = await adapter.spawn({ cols: 80, rows: 24 }) + + const listed = (await adapter.listSessions()).find( + (session) => session.sessionId === spawned.id + ) + + expect(listed?.mutationRouteToken).toBe(adapter.getPtyMutationRouteToken(spawned.id)) + }) + it('loads persisted Linux birth identity for audit observations', async () => { adapter.dispose() await server.shutdown() diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index 92f8abe5895..580f265e1a4 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -1,8 +1,15 @@ /* oxlint-disable max-lines -- Why: history .catch() safety wiring spread across spawn/event-routing is tightly coupled to the adapter↔history lifecycle. */ import { basename } from 'node:path' +import { randomUUID } from 'node:crypto' import { existsSync, readFileSync } from 'node:fs' import { readFile } from 'node:fs/promises' import { DaemonClient } from './client' +import { DaemonTerminalAuthorityAppHostTransport } from './daemon-terminal-authority-app-host-transport' +import type { TerminalAuthorityConsumerProofKeypair } from '../session-authority/terminal-session-authority-consumer-proof' +import type { + TerminalAuthorityAppAdmissionLocator, + TerminalAuthorityAppNamespaceAdmission +} from '../session-authority/terminal-authority-app-outcome-host-contract' import { getMacDaemonSystemResolverHealth, parseDaemonPidFile, @@ -40,7 +47,10 @@ import { GET_SIZE_PROTOCOL_VERSION, HISTORY_SEED_TRANSFER_PROTOCOL_VERSION, SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION, - STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION + STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION, + supportsExactHeldProducerPause as protocolSupportsExactHeldProducerPause, + supportsExactPtyOperations as protocolSupportsExactPtyOperations, + supportsPtyStreamBinding } from './daemon-protocol-version' import { isAgentSessionClaimedSpawnResult, @@ -52,6 +62,7 @@ import { cloneAgentSessionOwnerBinding } from '../../shared/claimed-agent-pty-ow import type { IPtyProvider, PtyBackgroundStreamEvent, + PtyHeldProducerPauseToken, PtyProviderBufferSnapshot, PtyProcessInfo, PtySpawnOptions, @@ -63,11 +74,12 @@ import { resolveWslSessionContext } from './wsl-session-context' import { normalizeWslColdRestoreCwd } from './wsl-cold-restore-cwd' import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' import { shouldUseShellReadyStartupDelivery } from '../../shared/codex-startup-delivery' -import type { PtyIncarnationId } from '../../shared/pty-incarnation' +import { isPtyIncarnationId, type PtyIncarnationId } from '../../shared/pty-incarnation' import { resolveSafePtyDefaultCwd } from '../providers/pty-default-cwd' import { PtyWriteUnavailableError } from '../providers/pty-write-unavailable-error' import { ColdRestorePayloadCache, type ColdRestorePayload } from './cold-restore-payload-cache' import { PtyProcessListAdmission } from '../providers/pty-process-list-admission' +import { isPtyHeldProducerPauseToken } from '../providers/pty-provider-contract' import { iterateTerminalHistorySeedChunks, measureTerminalHistorySeed, @@ -84,11 +96,20 @@ import { } from './daemon-audit-classifier' import type { DaemonEvidenceSource, ExactDaemonIncarnation } from './daemon-incarnation-evidence' import { createDaemonAuditEligibilityTracker } from './daemon-audit-eligibility-event' +import { DaemonPtyStreamBindingState } from './daemon-pty-stream-binding-state' +import { + parseTerminalSessionAuthorityPtyAccess, + sameTerminalSessionAuthorityPtyAccess, + type TerminalSessionAuthorityPtyAccess +} from '../../shared/terminal-session-authority-pty-access' +import { TERMINAL_SESSION_AUTHORITY_SPAWN_VERSION } from '../../shared/terminal-session-authority-wire' type PendingDaemonSpawnOperation = { exitsBySessionId: Map ignoredExitIncarnationIds: Set ignoreNextExit: boolean + terminalSessionAuthorityOperationId?: string + terminalSessionAuthorityMode?: 'legacy' | 'authority' } type HistoryRecoveryContext = { @@ -124,6 +145,7 @@ export type DaemonPtyAdapterOptions = { pidPath?: string profileScope?: string protocolVersion?: number + terminalSessionAuthorityConsumerProofKeypair?: TerminalAuthorityConsumerProofKeypair /** Directory for disk-based terminal history; when set, raw PTY output is written to disk for cold restore on daemon crash. */ historyPath?: string /** Forks a fresh daemon after endpoint death or a confirmed resolver-health replacement. */ @@ -167,6 +189,10 @@ export class DaemonPtyAdapter implements IPtyProvider { private pidPath: string | null private pidRecord: ParsedDaemonPid | null private client: DaemonClient + private terminalSessionAuthorityConsumerProofKeypair: TerminalAuthorityConsumerProofKeypair | null + private terminalAuthorityAppAdmission: + | (TerminalAuthorityAppNamespaceAdmission & Readonly<{ dispose(): void }>) + | null = null private auditContext: DaemonAuditContext private lastAuthenticatedIdentity: DaemonEndpointIdentity | null = null private exactDaemonIncarnation: ExactDaemonIncarnation | null = null @@ -187,6 +213,7 @@ export class DaemonPtyAdapter implements IPtyProvider { private dataListeners: ((payload: { id: string data: string + incarnationId?: PtyIncarnationId sequenceChars?: number transformed?: boolean seq?: number @@ -215,6 +242,12 @@ export class DaemonPtyAdapter implements IPtyProvider { // A replacement daemon has none of the old PTYs; only createOrAttach can make their bindings writable again. private sessionsAwaitingDaemonRecovery = new Set() private sessionIncarnations = new Map() + private terminalSessionAuthorityAccessById = new Map() + private mutationRouteTokens = new Map< + string, + Readonly<{ incarnationId: string | undefined; token: object }> + >() + private readonly streamBindings = new DaemonPtyStreamBindingState() private pendingSpawnOperationsBySessionId = new Map>() private pendingClaimSpawnOperations = new Set() private historySpawnLocks = new Map>() @@ -223,7 +256,7 @@ export class DaemonPtyAdapter implements IPtyProvider { private sessionsNeedingFullCheckpoint = new Set() private checkpointTimer: ReturnType | null = null private checkpointInFlight: Promise | null = null - private keepHistoryShutdowns = new Set>() + private keepHistoryShutdowns = new Set>() private disconnectOnlyPromise: Promise | null = null // Why: checkpoint persistence needs the getSnapshot RPC (v4+); legacy daemons reject it, spamming logs every 5s. private supportsCheckpoints: boolean @@ -247,6 +280,62 @@ export class DaemonPtyAdapter implements IPtyProvider { return this.protocolVersion >= GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION } + private supportsIncarnationExactPtyOperations(id: string): boolean { + if (!protocolSupportsExactPtyOperations(this.protocolVersion)) { + return false + } + const incarnationId = this.sessionIncarnations.get(id) + if (!incarnationId || !this.activeSessionIds.has(id)) { + return false + } + return ( + !supportsPtyStreamBinding(this.protocolVersion) || + this.streamBindings.admittedIncarnationId(id) === incarnationId + ) + } + + private supportsKnownIncarnationExactPtyOperations(id: string): boolean { + return ( + protocolSupportsExactPtyOperations(this.protocolVersion) && + this.activeSessionIds.has(id) && + this.sessionIncarnations.has(id) + ) + } + + supportsExactPtyOperations(id: string): boolean { + if ( + !this.client.supportsTerminalSessionAuthority() || + !this.supportsIncarnationExactPtyOperations(id) + ) { + return false + } + const incarnationId = this.sessionIncarnations.get(id) + const authorityAccess = this.terminalSessionAuthorityAccessById.get(id) + if ( + !incarnationId || + !authorityAccess || + authorityAccess.binding.physicalPtyId !== id || + authorityAccess.binding.ptyIncarnationId !== incarnationId + ) { + return false + } + return true + } + getPtyMutationMode(id: string): 'legacy' | 'exact' | 'unavailable' { + if (!this.client.supportsTerminalSessionAuthority()) { + return this.terminalSessionAuthorityAccessById.has(id) ? 'unavailable' : 'legacy' + } + return this.supportsExactPtyOperations(id) ? 'exact' : 'unavailable' + } + + supportsExactHeldProducerPause(id: string, incarnationId: string): boolean { + return ( + protocolSupportsExactHeldProducerPause(this.protocolVersion) && + this.sessionIncarnations.get(id) === incarnationId && + this.supportsIncarnationExactPtyOperations(id) + ) + } + canProvideAuthoritativeBufferSnapshot(_id: string): boolean { return this.supportsAuthoritativeBufferSnapshots } @@ -261,6 +350,239 @@ export class DaemonPtyAdapter implements IPtyProvider { ) } + private async requestCreateOrAttach( + payload: Record + ): Promise { + if (!supportsPtyStreamBinding(this.protocolVersion)) { + return await this.client.request('createOrAttach', payload) + } + const streamBindingNonce = randomUUID() + this.streamBindings.begin(streamBindingNonce) + try { + const result = await this.client.request('createOrAttach', { + ...payload, + streamBindingNonce + }) + const requestedSessionId = payload.sessionId + const sessionId = + result.agentSessionEnsure?.owner.ptyId ?? + (typeof requestedSessionId === 'string' ? requestedSessionId : '') + if ( + !this.streamBindings.acceptResponse( + streamBindingNonce, + sessionId, + result.incarnationId, + result.streamBindingNonce + ) + ) { + throw new Error('daemon_stream_binding_response_mismatch') + } + return result + } catch (error) { + this.streamBindings.cancel(streamBindingNonce) + throw error + } + } + + private rememberCreateResultIncarnation(id: string, incarnationId: string | undefined): void { + this.rememberMutationRoute(id, incarnationId) + if (incarnationId && !supportsPtyStreamBinding(this.protocolVersion)) { + this.sessionIncarnations.set(id, incarnationId) + } + } + + private rememberCreateResultAuthority( + id: string, + result: CreateOrAttachResult, + required: boolean + ): TerminalSessionAuthorityPtyAccess | null { + const authorityAccess = + result.terminalSessionAuthorityAccess === undefined + ? null + : parseTerminalSessionAuthorityPtyAccess(result.terminalSessionAuthorityAccess) + if ( + !authorityAccess || + authorityAccess.binding.physicalPtyId !== id || + authorityAccess.binding.ptyIncarnationId !== result.incarnationId + ) { + if (required || result.terminalSessionAuthorityAccess !== undefined) { + throw new Error('daemon_terminal_session_authority_access_invalid') + } + this.terminalSessionAuthorityAccessById.delete(id) + return null + } + this.terminalSessionAuthorityAccessById.set(id, authorityAccess) + return authorityAccess + } + + private async retireCreateResult( + id: string, + result: CreateOrAttachResult, + opts: { authorityRequired: boolean; immediate?: boolean } + ): Promise { + const authorityAccess = + result.terminalSessionAuthorityAccess === undefined + ? null + : parseTerminalSessionAuthorityPtyAccess(result.terminalSessionAuthorityAccess) + if ( + result.terminalSessionAuthorityAccess !== undefined && + (!this.client.supportsTerminalSessionAuthority() || + !authorityAccess || + authorityAccess.binding.physicalPtyId !== id || + authorityAccess.binding.ptyIncarnationId !== result.incarnationId) + ) { + throw new Error('daemon_terminal_session_authority_access_invalid') + } + if (opts.authorityRequired && !authorityAccess) { + throw new Error('daemon_terminal_session_authority_access_missing') + } + let accepted = true + if (authorityAccess) { + const response = await this.client.request<{ accepted: boolean }>('killAuthorityExact', { + sessionId: id, + authorityAccess, + immediate: opts.immediate === true + }) + accepted = response.accepted === true + } else if (protocolSupportsExactPtyOperations(this.protocolVersion)) { + if (!isPtyIncarnationId(result.incarnationId)) { + throw new Error('daemon_exact_pty_identity_missing') + } + const response = await this.client.request<{ accepted: boolean }>('killExact', { + sessionId: id, + incarnationId: result.incarnationId, + immediate: opts.immediate === true + }) + accepted = response.accepted === true + } else { + await this.client.request('kill', { + sessionId: id, + immediate: opts.immediate === true + }) + } + if (!accepted) { + throw new Error('daemon_spawn_result_exact_kill_rejected') + } + } + + private rememberListedSession(session: SessionInfo): { + mutationRouteToken: object + terminalSessionAuthorityAccess: TerminalSessionAuthorityPtyAccess | null + } { + const { sessionId, incarnationId } = session + if (incarnationId !== undefined && !isPtyIncarnationId(incarnationId)) { + throw new Error('invalid_pty_process_list') + } + const authorityAccess = + session.terminalSessionAuthorityAccess === undefined + ? null + : parseTerminalSessionAuthorityPtyAccess(session.terminalSessionAuthorityAccess) + if ( + session.terminalSessionAuthorityAccess !== undefined && + (!this.client.supportsTerminalSessionAuthority() || + !authorityAccess || + authorityAccess.binding.physicalPtyId !== sessionId || + authorityAccess.binding.ptyIncarnationId !== incarnationId) + ) { + throw new Error('daemon_terminal_session_authority_access_invalid') + } + const rememberedAccess = this.terminalSessionAuthorityAccessById.get(sessionId) + if (!authorityAccess && rememberedAccess?.binding.ptyIncarnationId === incarnationId) { + throw new Error('daemon_terminal_session_authority_access_missing') + } + if (incarnationId) { + this.sessionIncarnations.set(sessionId, incarnationId) + } else { + this.sessionIncarnations.delete(sessionId) + } + if (authorityAccess) { + this.terminalSessionAuthorityAccessById.set(sessionId, authorityAccess) + } else { + this.terminalSessionAuthorityAccessById.delete(sessionId) + } + this.activeSessionIds.add(sessionId) + return { + mutationRouteToken: this.rememberMutationRoute(sessionId, incarnationId), + terminalSessionAuthorityAccess: authorityAccess + } + } + + private async retireListedSession( + session: SessionInfo, + authorityAccess: TerminalSessionAuthorityPtyAccess | null + ): Promise { + let accepted = true + if (authorityAccess) { + const result = await this.client.request<{ accepted: boolean }>('killAuthorityExact', { + sessionId: session.sessionId, + authorityAccess + }) + accepted = result.accepted === true + } else if (protocolSupportsExactPtyOperations(this.protocolVersion)) { + if (!isPtyIncarnationId(session.incarnationId)) { + throw new Error('daemon_exact_pty_identity_missing') + } + const result = await this.client.request<{ accepted: boolean }>('killExact', { + sessionId: session.sessionId, + incarnationId: session.incarnationId + }) + accepted = result.accepted === true + } else { + await this.client.request('kill', { sessionId: session.sessionId }) + } + if (!accepted) { + throw new Error('daemon_reconcile_orphan_exact_kill_rejected') + } + this.activeSessionIds.delete(session.sessionId) + this.streamBindings.forget(session.sessionId, session.incarnationId) + this.sessionIncarnations.delete(session.sessionId) + this.terminalSessionAuthorityAccessById.delete(session.sessionId) + this.mutationRouteTokens.delete(session.sessionId) + } + + private terminalSessionAuthorityAccessMatches( + id: string, + authorityAccess: TerminalSessionAuthorityPtyAccess + ): boolean { + const parsed = parseTerminalSessionAuthorityPtyAccess(authorityAccess) + return Boolean( + parsed && + this.client.supportsTerminalSessionAuthority() && + this.activeSessionIds.has(id) && + parsed.binding.physicalPtyId === id && + parsed.binding.ptyIncarnationId === this.sessionIncarnations.get(id) && + sameTerminalSessionAuthorityPtyAccess( + this.terminalSessionAuthorityAccessById.get(id) ?? null, + parsed + ) + ) + } + + bindTerminalSessionAuthorityAccess( + id: string, + authorityAccess: TerminalSessionAuthorityPtyAccess + ): boolean { + return this.terminalSessionAuthorityAccessMatches(id, authorityAccess) + } + + getTerminalSessionAuthorityAccess(id: string): TerminalSessionAuthorityPtyAccess | null { + return this.terminalSessionAuthorityAccessById.get(id) ?? null + } + + private rememberMutationRoute(id: string, incarnationId: string | undefined): object { + const existing = this.mutationRouteTokens.get(id) + if (incarnationId !== undefined && existing?.incarnationId === incarnationId) { + return existing.token + } + const token = Object.freeze({}) + this.mutationRouteTokens.set(id, Object.freeze({ incarnationId, token })) + return token + } + + getPtyMutationRouteToken(id: string): object | null { + return this.mutationRouteTokens.get(id)?.token ?? null + } + constructor(opts: DaemonPtyAdapterOptions) { this.protocolVersion = opts.protocolVersion ?? PROTOCOL_VERSION this.socketPath = opts.socketPath @@ -275,10 +597,17 @@ export class DaemonPtyAdapter implements IPtyProvider { endpointKind: process.platform === 'win32' ? 'windows-named-pipe' : 'unix-socket', profileScope: opts.profileScope ?? '' } + this.terminalSessionAuthorityConsumerProofKeypair = + opts.terminalSessionAuthorityConsumerProofKeypair ?? null this.client = new DaemonClient({ socketPath: opts.socketPath, tokenPath: opts.tokenPath, - protocolVersion: opts.protocolVersion + protocolVersion: opts.protocolVersion, + ...(this.terminalSessionAuthorityConsumerProofKeypair + ? { + terminalSessionAuthorityConsumerProofReady: () => true + } + : {}) }) this.historyManager = opts.historyPath ? new HistoryManager(opts.historyPath) : null this.historyReader = opts.historyPath ? new HistoryReader(opts.historyPath) : null @@ -290,6 +619,8 @@ export class DaemonPtyAdapter implements IPtyProvider { this.protocolVersion >= SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION this.supportsStartupIngress = supportsPtyStartupIngress(this.protocolVersion) this.client.onDisconnected(() => { + this.streamBindings.clear() + this.mutationRouteTokens.clear() if (!this.respawnAdoptionClosed) { // Why re-arm here: the latch is otherwise only cleared when every awaiting // session rebinds, and background sessions (no mounted pane, so nothing ever @@ -312,6 +643,33 @@ export class DaemonPtyAdapter implements IPtyProvider { return this.historyManager } + async terminalAuthorityAppHostTransport(): Promise { + await this.client.ensureConnected() + const authorityHostId = this.client.terminalSessionAuthorityConsumerProofHostId() + return authorityHostId && this.terminalSessionAuthorityConsumerProofKeypair + ? new DaemonTerminalAuthorityAppHostTransport({ + client: this.client, + authenticatedAuthorityHostId: authorityHostId, + keypair: this.terminalSessionAuthorityConsumerProofKeypair + }) + : null + } + + installTerminalAuthorityAppAdmission( + admission: TerminalAuthorityAppNamespaceAdmission & Readonly<{ dispose(): void }> + ): void { + this.terminalAuthorityAppAdmission?.dispose() + this.terminalAuthorityAppAdmission = admission + } + + setTerminalAuthorityConsumerProofKeypair( + keypair: TerminalAuthorityConsumerProofKeypair | null + ): void { + this.terminalAuthorityAppAdmission?.dispose() + this.terminalAuthorityAppAdmission = null + this.terminalSessionAuthorityConsumerProofKeypair = keypair + } + getLastAuthenticatedDaemonIdentity(): DaemonEndpointIdentity | null { return this.lastAuthenticatedIdentity ? { ...this.lastAuthenticatedIdentity } : null } @@ -457,6 +815,38 @@ export class DaemonPtyAdapter implements IPtyProvider { } await this.ensureConnected() + const expectedAuthorityAccess = + opts.terminalSessionAuthorityAccess ?? + this.terminalSessionAuthorityAccessById.get(requestedSessionId) + const authorityCapability = this.client.supportsTerminalSessionAuthority() + const authorityIntent = + expectedAuthorityAccess !== undefined || + opts.paneKey !== undefined || + opts.paneGeneration !== undefined + operation.terminalSessionAuthorityMode ??= + authorityCapability && authorityIntent ? 'authority' : 'legacy' + const authorityRequired = operation.terminalSessionAuthorityMode === 'authority' + if ((expectedAuthorityAccess !== undefined || authorityRequired) && !authorityCapability) { + throw new Error('daemon_terminal_session_authority_unavailable') + } + if (authorityRequired && operation.terminalSessionAuthorityOperationId === undefined) { + operation.terminalSessionAuthorityOperationId = randomUUID() + } + const authorityAdmission = authorityRequired ? this.terminalAuthorityAppAdmission : null + if (authorityRequired && !authorityAdmission) { + throw new Error('daemon_terminal_session_authority_app_consumer_unavailable') + } + const authorityAdmissionLocator: TerminalAuthorityAppAdmissionLocator | null = + !authorityRequired + ? null + : expectedAuthorityAccess + ? { namespace: expectedAuthorityAccess.namespace } + : opts.worktreeId + ? { worktreeId: opts.worktreeId } + : null + if (authorityRequired && !authorityAdmissionLocator) { + throw new Error('terminal_session_authority_workspace_required') + } // Why before createOrAttach: a preserved daemon may still think this session is backgrounded — from // a v19 that thins without a recoverable seq, or (#9993) from a pre-v29 that a previous desktop // handed 2031 scan authority to and can never retract it. Clear it before any bytes are attached. @@ -507,7 +897,7 @@ export class DaemonPtyAdapter implements IPtyProvider { if (opts.signal?.aborted) { throw new Error('client_disconnected') } - return this.client.request('createOrAttach', { + return this.requestCreateOrAttach({ sessionId, cols: effectiveCols, rows: effectiveRows, @@ -533,6 +923,18 @@ export class DaemonPtyAdapter implements IPtyProvider { : {}), ...(!attachOnly && opts.agentSessionEnsure ? { agentSessionEnsure: opts.agentSessionEnsure } + : {}), + ...(authorityRequired && expectedAuthorityAccess + ? { terminalSessionAuthorityAccess: expectedAuthorityAccess } + : {}), + ...(authorityRequired && !expectedAuthorityAccess + ? { + terminalSessionAuthorityVersion: TERMINAL_SESSION_AUTHORITY_SPAWN_VERSION, + terminalSessionAuthorityOperationId: operation.terminalSessionAuthorityOperationId, + worktreeId: opts.worktreeId, + paneKey: opts.paneKey, + paneGeneration: opts.paneGeneration + } : {}) }) } @@ -594,6 +996,16 @@ export class DaemonPtyAdapter implements IPtyProvider { ? { ...result, historySeeded: false } : result } + const createOrAttachWithAuthority = (historySeedSegments: readonly string[] | null) => + authorityAdmission && authorityAdmissionLocator + ? authorityAdmission.withSourceAdmission( + authorityAdmissionLocator, + async ({ assertCurrent }) => { + assertCurrent() + return await createOrAttach(historySeedSegments) + } + ) + : createOrAttach(historySeedSegments) let historySeedSegments = restoreInfo ? getRecoveredHistorySeedSegments(restoreInfo) : null const adoptSpawnResultSession = async (spawnResult: CreateOrAttachResult): Promise => { @@ -604,7 +1016,9 @@ export class DaemonPtyAdapter implements IPtyProvider { ) { // Why: a claim-incapable owner may already have spawned before returning // a malformed response; retire only this requested session before failing closed. - await this.client.request('kill', { sessionId: requestedSessionId }).catch(() => {}) + await this.retireCreateResult(requestedSessionId, spawnResult, { + authorityRequired + }).catch(() => {}) throw new Error('agent_session_claim_unavailable') } sessionId = spawnResult.agentSessionEnsure?.owner.ptyId ?? requestedSessionId @@ -620,10 +1034,13 @@ export class DaemonPtyAdapter implements IPtyProvider { restoreInfo = null historySeedSegments = null } - let result = await createOrAttach(historySeedSegments) + let result = await createOrAttachWithAuthority(historySeedSegments) if (emulateLegacyAttachOnly && result.isNew) { operation.ignoreNextExit = true - await this.client.request('kill', { sessionId: requestedSessionId, immediate: true }) + await this.retireCreateResult(requestedSessionId, result, { + authorityRequired, + immediate: true + }) throw new SessionNotFoundError(requestedSessionId) } await adoptSpawnResultSession(result) @@ -634,13 +1051,20 @@ export class DaemonPtyAdapter implements IPtyProvider { if (exitedResult) { return exitedResult } - if (result.incarnationId) { - this.sessionIncarnations.set(sessionId, result.incarnationId) - } + this.rememberCreateResultIncarnation(sessionId, result.incarnationId) + let terminalSessionAuthorityAccess = this.rememberCreateResultAuthority( + sessionId, + result, + authorityRequired + ) const claimResult = (): Pick | Record => result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {} const incarnationResult = (): Pick | Record => result.incarnationId ? { incarnationId: result.incarnationId } : {} + const authorityResult = (): + | Pick + | Record => + terminalSessionAuthorityAccess ? { terminalSessionAuthorityAccess } : {} let providerWslDistro = result.wslDistro === undefined ? wslDistro : result.wslDistro // Why: explicit null from a current daemon overrides the caller's WSL preference; undefined keeps compatibility with older daemons. wslDistro = providerWslDistro ?? undefined @@ -675,6 +1099,7 @@ export class DaemonPtyAdapter implements IPtyProvider { return { id: sessionId, ...incarnationResult(), + ...authorityResult(), pid, ...claimResult(), ...launchIdentity(), @@ -696,19 +1121,25 @@ export class DaemonPtyAdapter implements IPtyProvider { operation.ignoredExitIncarnationIds.add(result.incarnationId) } operation.ignoreNextExit = true - await this.client.request('kill', { sessionId, immediate: true }) + await this.retireCreateResult(sessionId, result, { + authorityRequired, + immediate: true + }) effectiveCwd = restoreInfo.cwd effectiveCols = restoreInfo.cols effectiveRows = restoreInfo.rows - result = await createOrAttach(historySeedSegments) + result = await createOrAttachWithAuthority(historySeedSegments) await adoptSpawnResultSession(result) const exitedRetryResult = this.resultForExitBeforeSpawnReply(sessionId, result, operation) if (exitedRetryResult) { return exitedRetryResult } - if (result.incarnationId) { - this.sessionIncarnations.set(sessionId, result.incarnationId) - } + this.rememberCreateResultIncarnation(sessionId, result.incarnationId) + terminalSessionAuthorityAccess = this.rememberCreateResultAuthority( + sessionId, + result, + authorityRequired + ) providerWslDistro = result.wslDistro === undefined ? wslDistro : result.wslDistro wslDistro = providerWslDistro ?? undefined if (wslDistro) { @@ -767,6 +1198,7 @@ export class DaemonPtyAdapter implements IPtyProvider { return { id: sessionId, ...incarnationResult(), + ...authorityResult(), pid, ...claimResult(), ...launchIdentity(), @@ -779,6 +1211,7 @@ export class DaemonPtyAdapter implements IPtyProvider { return { id: sessionId, ...incarnationResult(), + ...authorityResult(), pid, ...claimResult(), ...launchIdentity(), @@ -820,6 +1253,7 @@ export class DaemonPtyAdapter implements IPtyProvider { return { id: sessionId, ...incarnationResult(), + ...authorityResult(), pid, ...claimResult(), ...launchIdentity(), @@ -839,6 +1273,7 @@ export class DaemonPtyAdapter implements IPtyProvider { return { id: sessionId, ...incarnationResult(), + ...authorityResult(), pid, ...claimResult(), ...launchIdentity(), @@ -904,24 +1339,37 @@ export class DaemonPtyAdapter implements IPtyProvider { if (!size) { throw new SessionNotFoundError(id) } - const result = await this.client.request('createOrAttach', { + const result = await this.requestCreateOrAttach({ sessionId: id, cols: size.cols, rows: size.rows, - attachOnly: true + attachOnly: true, + ...(this.terminalSessionAuthorityAccessById.get(id) + ? { + terminalSessionAuthorityAccess: this.terminalSessionAuthorityAccessById.get(id) + } + : {}) }) if (result.isNew) { // Why: a pre-v31 daemon ignores attachOnly; retire its accidental spawn // instead of publishing a fresh shell as an attach. - await this.client.request('kill', { sessionId: id, immediate: true }).catch((error) => { - // Why surface, not swallow: a failed retire leaves an untracked orphan shell. + const authorityRequired = this.terminalSessionAuthorityAccessById.has(id) + await this.retireCreateResult(id, result, { + authorityRequired, + immediate: true + }).catch((error) => { console.warn('[daemon] attach-only retire of accidental legacy spawn failed', { sessionId: id, error }) + if (authorityRequired || result.terminalSessionAuthorityAccess !== undefined) { + throw error + } }) throw new SessionNotFoundError(id) } + this.rememberCreateResultIncarnation(id, result.incarnationId) + this.rememberCreateResultAuthority(id, result, this.terminalSessionAuthorityAccessById.has(id)) this.clearSessionAwaitingDaemonRecovery(id) } @@ -991,11 +1439,215 @@ export class DaemonPtyAdapter implements IPtyProvider { } } + writeExact(id: string, incarnationId: string, data: string): boolean { + const authorityAccess = this.terminalSessionAuthorityAccessById.get(id) + if (authorityAccess) { + return authorityAccess.binding.ptyIncarnationId === incarnationId + ? this.writeAuthorityExact(id, authorityAccess, data) + : false + } + if ( + !this.supportsIncarnationExactPtyOperations(id) || + this.sessionIncarnations.get(id) !== incarnationId + ) { + return false + } + const delivered = this.client.notify('writeExact', { sessionId: id, incarnationId, data }) + if (delivered) { + this.markSessionDirty(id) + } + return delivered + } + + writeAuthorityExact( + id: string, + authorityAccess: TerminalSessionAuthorityPtyAccess, + data: string + ): boolean { + if (!this.terminalSessionAuthorityAccessMatches(id, authorityAccess)) { + return false + } + const delivered = this.client.notify('writeAuthorityExact', { + sessionId: id, + authorityAccess, + data + }) + if (delivered) { + this.markSessionDirty(id) + } + return delivered + } + resize(id: string, cols: number, rows: number): void { this.markSessionDirty(id) this.client.notify('resize', { sessionId: id, cols, rows }) } + resizeExact(id: string, incarnationId: string, cols: number, rows: number): boolean { + const authorityAccess = this.terminalSessionAuthorityAccessById.get(id) + if (authorityAccess) { + return authorityAccess.binding.ptyIncarnationId === incarnationId + ? this.resizeAuthorityExact(id, authorityAccess, cols, rows) + : false + } + if ( + !this.supportsIncarnationExactPtyOperations(id) || + this.sessionIncarnations.get(id) !== incarnationId + ) { + return false + } + const delivered = this.client.notify('resizeExact', { + sessionId: id, + incarnationId, + cols, + rows + }) + if (delivered) { + this.markSessionDirty(id) + } + return delivered + } + + resizeAuthorityExact( + id: string, + authorityAccess: TerminalSessionAuthorityPtyAccess, + cols: number, + rows: number + ): boolean { + if (!this.terminalSessionAuthorityAccessMatches(id, authorityAccess)) { + return false + } + const delivered = this.client.notify('resizeAuthorityExact', { + sessionId: id, + authorityAccess, + cols, + rows + }) + if (delivered) { + this.markSessionDirty(id) + } + return delivered + } + + async killExact( + id: string, + incarnationId: string, + opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number } + ): Promise { + const authorityAccess = this.terminalSessionAuthorityAccessById.get(id) + if (authorityAccess) { + return authorityAccess.binding.ptyIncarnationId === incarnationId + ? await this.killAuthorityExact(id, authorityAccess, opts) + : false + } + if ( + !this.supportsKnownIncarnationExactPtyOperations(id) || + this.sessionIncarnations.get(id) !== incarnationId + ) { + return false + } + return await this.withHistorySpawnLock(id, () => + this.shutdownWithHistoryLock(id, opts, incarnationId) + ) + } + + async killAuthorityExact( + id: string, + authorityAccess: TerminalSessionAuthorityPtyAccess, + opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number } + ): Promise { + if (!this.terminalSessionAuthorityAccessMatches(id, authorityAccess)) { + return false + } + return await this.withHistorySpawnLock(id, () => + this.shutdownWithHistoryLock( + id, + opts, + authorityAccess.binding.ptyIncarnationId, + authorityAccess + ) + ) + } + + async sendSignalExact(id: string, incarnationId: string, signal: string): Promise { + const authorityAccess = this.terminalSessionAuthorityAccessById.get(id) + if (authorityAccess) { + return authorityAccess.binding.ptyIncarnationId === incarnationId + ? await this.sendSignalAuthorityExact(id, authorityAccess, signal) + : false + } + if ( + !this.supportsIncarnationExactPtyOperations(id) || + this.sessionIncarnations.get(id) !== incarnationId + ) { + return false + } + const result = await this.client.request<{ accepted: boolean }>('signalExact', { + sessionId: id, + incarnationId, + signal + }) + return result.accepted === true + } + + async sendSignalAuthorityExact( + id: string, + authorityAccess: TerminalSessionAuthorityPtyAccess, + signal: string + ): Promise { + if (!this.terminalSessionAuthorityAccessMatches(id, authorityAccess)) { + return false + } + const result = await this.client.request<{ accepted: boolean }>('signalAuthorityExact', { + sessionId: id, + authorityAccess, + signal + }) + return result.accepted === true + } + + async clearBufferExact(id: string, incarnationId: string): Promise { + const authorityAccess = this.terminalSessionAuthorityAccessById.get(id) + if (authorityAccess) { + return authorityAccess.binding.ptyIncarnationId === incarnationId + ? await this.clearBufferAuthorityExact(id, authorityAccess) + : false + } + if ( + !this.supportsIncarnationExactPtyOperations(id) || + this.sessionIncarnations.get(id) !== incarnationId + ) { + return false + } + const result = await this.client.request<{ accepted: boolean }>('clearBufferExact', { + sessionId: id, + incarnationId + }) + if (result.accepted === true) { + this.markSessionDirty(id) + return true + } + return false + } + + async clearBufferAuthorityExact( + id: string, + authorityAccess: TerminalSessionAuthorityPtyAccess + ): Promise { + if (!this.terminalSessionAuthorityAccessMatches(id, authorityAccess)) { + return false + } + const result = await this.client.request<{ accepted: boolean }>('clearBufferAuthorityExact', { + sessionId: id, + authorityAccess + }) + if (result.accepted === true) { + this.markSessionDirty(id) + return true + } + return false + } + pauseProducer(id: string): void { if (!this.supportsProducerFlowControl) { return @@ -1013,6 +1665,50 @@ export class DaemonPtyAdapter implements IPtyProvider { this.client.notify('resumePty', { sessionId: id }) } + async acquireExactHeldProducerPause( + id: string, + incarnationId: string, + token: PtyHeldProducerPauseToken + ): Promise { + return await this.requestExactHeldProducerPause('pausePty', id, incarnationId, token) + } + + async releaseExactHeldProducerPause( + id: string, + incarnationId: string, + token: PtyHeldProducerPauseToken + ): Promise { + return await this.requestExactHeldProducerPause('resumePty', id, incarnationId, token) + } + + private async requestExactHeldProducerPause( + type: 'pausePty' | 'resumePty', + id: string, + incarnationId: string, + token: PtyHeldProducerPauseToken + ): Promise { + if ( + !isPtyHeldProducerPauseToken(token) || + !this.supportsExactHeldProducerPause(id, incarnationId) + ) { + return false + } + try { + const response = await this.client.request<{ accepted: boolean }>(type, { + sessionId: id, + incarnationId, + heldPauseToken: token + }) + return response.accepted === true + } catch { + // Why: closing the lease-owning connection makes an unacknowledged acquire fail closed remotely. + this.client.disconnect() + this.streamBindings.clear() + this.mutationRouteTokens.clear() + return false + } + } + // Why fire-and-forget (like pausePty): just a delivery hint for the daemon's keep-tail stream thinning. setPtyBackgrounded(id: string, background: boolean): void { if (!this.supportsProducerFlowControl) { @@ -1055,13 +1751,25 @@ export class DaemonPtyAdapter implements IPtyProvider { private async shutdownWithHistoryLock( id: string, - opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number } - ): Promise { + opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number }, + exactIncarnationId?: string, + authorityAccess?: TerminalSessionAuthorityPtyAccess + ): Promise { // Why: shutdown can be the first lazy-client operation after restart; connect // before killing so a healthy daemon session is not orphaned (#7742). Connect // and kill share the caller's one absolute deadline, so a wedged handshake // cannot burn the whole teardown budget before the kill even starts. await this.ensureConnected(opts.deadlineMs) + if (authorityAccess && !this.terminalSessionAuthorityAccessMatches(id, authorityAccess)) { + return false + } + if ( + !authorityAccess && + exactIncarnationId !== undefined && + this.sessionIncarnations.get(id) !== exactIncarnationId + ) { + return false + } // Why: sleep/exact-stop kills the live PTY before the periodic checkpoint may run. // Force a final snapshot so wake can restore the pane users left. if (opts.keepHistory) { @@ -1097,12 +1805,47 @@ export class DaemonPtyAdapter implements IPtyProvider { this.historyManager?.suspendSession(id) } } - await this.client.request( - 'kill', - { sessionId: id, immediate: opts.immediate ?? false }, - remainingRequestTimeoutMs(opts.deadlineMs) - ) + let accepted = true + if (authorityAccess) { + const result = await this.client.request<{ accepted: boolean }>( + 'killAuthorityExact', + { + sessionId: id, + authorityAccess, + immediate: opts.immediate ?? false + }, + remainingRequestTimeoutMs(opts.deadlineMs) + ) + accepted = result.accepted === true + } else if (exactIncarnationId === undefined) { + await this.client.request( + 'kill', + { sessionId: id, immediate: opts.immediate ?? false }, + remainingRequestTimeoutMs(opts.deadlineMs) + ) + } else { + const result = await this.client.request<{ accepted: boolean }>( + 'killExact', + { + sessionId: id, + incarnationId: exactIncarnationId, + immediate: opts.immediate ?? false + }, + remainingRequestTimeoutMs(opts.deadlineMs) + ) + accepted = result.accepted === true + } + if (!accepted) { + if (opts.keepHistory) { + this.coldRestoreCache.delete(id) + this.sleepRestoreSessionIds.delete(id) + this.historyManager?.reopenSession(id) + } + return false + } this.activeSessionIds.delete(id) + this.terminalSessionAuthorityAccessById.delete(id) + this.mutationRouteTokens.delete(id) this.clearSessionAwaitingDaemonRecovery(id) this.dirtySessionVersions.delete(id) if (!opts.keepHistory) { @@ -1133,6 +1876,7 @@ export class DaemonPtyAdapter implements IPtyProvider { } } } + return true } ackColdRestore(sessionId: string): void { @@ -1335,14 +2079,22 @@ export class DaemonPtyAdapter implements IPtyProvider { if (!session.isAlive) { continue } + const listedIdentity = this.rememberListedSession(session) // Why: an unminted session id (worktreeId === null) can't be tied to a live worktree, so it's treated as an orphan. const { worktreeId } = parsePtySessionId(session.sessionId) if (worktreeId === null || !validWorktreeIds.has(worktreeId)) { try { - await this.client.request('kill', { sessionId: session.sessionId }) - } catch { - /* already dead */ + await this.retireListedSession(session, listedIdentity.terminalSessionAuthorityAccess) + } catch (error) { + if (!(error instanceof SessionNotFoundError)) { + throw error + } + this.activeSessionIds.delete(session.sessionId) + this.streamBindings.forget(session.sessionId, session.incarnationId) + this.sessionIncarnations.delete(session.sessionId) + this.terminalSessionAuthorityAccessById.delete(session.sessionId) + this.mutationRouteTokens.delete(session.sessionId) } killed.push(session.sessionId) } else { @@ -1425,11 +2177,18 @@ export class DaemonPtyAdapter implements IPtyProvider { continue } aliveSessionIds.add(session.sessionId) + const listedIdentity = this.rememberListedSession(session) const { worktreeId } = parsePtySessionId(session.sessionId) processes.push( admission.admit({ id: session.sessionId, + mutationRouteToken: listedIdentity.mutationRouteToken, ...(session.incarnationId ? { incarnationId: session.incarnationId } : {}), + ...(listedIdentity.terminalSessionAuthorityAccess + ? { + terminalSessionAuthorityAccess: listedIdentity.terminalSessionAuthorityAccess + } + : {}), // Why: OSC 7 may not arrive before cleanup; spawn cwd is authoritative until the daemon reports a live cwd. cwd: session.cwd ?? this.initialCwds.get(session.sessionId) ?? '', title: 'shell', @@ -1446,6 +2205,10 @@ export class DaemonPtyAdapter implements IPtyProvider { for (const id of preRequestActiveIds) { if (!aliveSessionIds.has(id)) { this.activeSessionIds.delete(id) + this.streamBindings.forget(id, this.sessionIncarnations.get(id)) + this.sessionIncarnations.delete(id) + this.terminalSessionAuthorityAccessById.delete(id) + this.mutationRouteTokens.delete(id) } } this.publishAuditObservation( @@ -1498,10 +2261,30 @@ export class DaemonPtyAdapter implements IPtyProvider { const result = await this.client.request('listSessions', undefined) return result.sessions .filter((s) => s.isAlive) - .map((session) => ({ - ...session, - ...this.validatedAgentSessionOwners(session.agentSessionOwners) - })) + .map((session) => { + const listedIdentity = this.rememberListedSession(session) + return { + sessionId: session.sessionId, + ...(session.incarnationId ? { incarnationId: session.incarnationId } : {}), + mutationRouteToken: listedIdentity.mutationRouteToken, + ...(listedIdentity.terminalSessionAuthorityAccess + ? { + terminalSessionAuthorityAccess: listedIdentity.terminalSessionAuthorityAccess + } + : {}), + state: session.state, + shellState: session.shellState, + isAlive: true, + ...(session.terminalHandle ? { terminalHandle: session.terminalHandle } : {}), + ...(session.wslDistro !== undefined ? { wslDistro: session.wslDistro } : {}), + pid: session.pid, + cwd: session.cwd, + cols: session.cols, + rows: session.rows, + createdAt: session.createdAt, + ...this.validatedAgentSessionOwners(session.agentSessionOwners) + } + }) } getActiveSessionIds(): string[] { @@ -1513,6 +2296,9 @@ export class DaemonPtyAdapter implements IPtyProvider { fanoutSyntheticExits(code: number): void { const ids = [...this.activeSessionIds] this.activeSessionIds.clear() + this.mutationRouteTokens.clear() + this.terminalSessionAuthorityAccessById.clear() + this.streamBindings.clear() this.sessionsAwaitingDaemonRecovery.clear() this.writeRecoveryAttempted = false this.dirtySessionVersions.clear() @@ -1560,6 +2346,7 @@ export class DaemonPtyAdapter implements IPtyProvider { callback: (payload: { id: string data: string + incarnationId?: PtyIncarnationId sequenceChars?: number transformed?: boolean seq?: number @@ -1618,7 +2405,10 @@ export class DaemonPtyAdapter implements IPtyProvider { } dispose(): void { + this.terminalAuthorityAppAdmission?.dispose() + this.terminalAuthorityAppAdmission = null this.respawnAdoptionClosed = true + this.streamBindings.clear() this.sessionsAwaitingDaemonRecovery.clear() this.writeRecoveryAttempted = false this.releasePendingRespawnAdoptionLease() @@ -1656,6 +2446,8 @@ export class DaemonPtyAdapter implements IPtyProvider { // but still write a final checkpoint so a daemon crash while Orca is closed has recovery data. async disconnectOnly(): Promise { if (!this.disconnectOnlyPromise) { + this.terminalAuthorityAppAdmission?.dispose() + this.terminalAuthorityAppAdmission = null this.respawnAdoptionClosed = true this.sessionsAwaitingDaemonRecovery.clear() this.writeRecoveryAttempted = false @@ -1665,7 +2457,7 @@ export class DaemonPtyAdapter implements IPtyProvider { await this.disconnectOnlyPromise } - private async finishDisconnectOnly(keepHistoryShutdowns: Promise[]): Promise { + private async finishDisconnectOnly(keepHistoryShutdowns: Promise[]): Promise { // Why: sleep shutdowns still detect recovery and kill after checkpointing; disconnecting first rejects those admitted operations. await Promise.allSettled(keepHistoryShutdowns) this.respawnAdoptionClosed = true @@ -2298,6 +3090,22 @@ export class DaemonPtyAdapter implements IPtyProvider { return } + if (event.event === 'sessionSource') { + const source = this.streamBindings.acceptMarker(event.sessionId, event.payload) + if (source) { + this.sessionIncarnations.set(event.sessionId, source.incarnationId) + this.rememberMutationRoute(event.sessionId, source.incarnationId) + } + return + } + const streamBindingRequired = supportsPtyStreamBinding(this.protocolVersion) + const streamIncarnationId = streamBindingRequired + ? this.streamBindings.admittedIncarnationId(event.sessionId) + : undefined + if (streamBindingRequired && !streamIncarnationId) { + return + } + if (event.event === 'data') { this.markSessionDirty(event.sessionId) // oxlint-disable-next-line unicorn/no-useless-spread -- copy-safe: listeners may unsubscribe during iteration @@ -2305,6 +3113,7 @@ export class DaemonPtyAdapter implements IPtyProvider { listener({ id: event.sessionId, data: event.payload.data, + ...(streamIncarnationId ? { incarnationId: streamIncarnationId } : {}), ...((event.payload.rawLength ?? event.payload.sequenceChars) === undefined ? {} : { sequenceChars: event.payload.rawLength ?? event.payload.sequenceChars }), @@ -2352,6 +3161,12 @@ export class DaemonPtyAdapter implements IPtyProvider { fact: event.payload }) } else if (event.event === 'exit') { + if ( + supportsPtyStreamBinding(this.protocolVersion) && + !this.streamBindings.admitsExit(event.sessionId, event.payload.incarnationId) + ) { + return + } const pendingOperations = new Set([ ...(this.pendingSpawnOperationsBySessionId.get(event.sessionId) ?? []), ...this.pendingClaimSpawnOperations @@ -2397,7 +3212,10 @@ export class DaemonPtyAdapter implements IPtyProvider { } this.initialCwds.delete(event.sessionId) this.wslDistrosBySessionId.delete(event.sessionId) + this.streamBindings.forget(event.sessionId, event.payload.incarnationId) this.sessionIncarnations.delete(event.sessionId) + this.terminalSessionAuthorityAccessById.delete(event.sessionId) + this.mutationRouteTokens.delete(event.sessionId) // oxlint-disable-next-line unicorn/no-useless-spread -- copy-safe: listeners may unsubscribe during iteration for (const listener of [...this.exitListeners]) { listener({ diff --git a/src/main/daemon/daemon-pty-operation-requests.ts b/src/main/daemon/daemon-pty-operation-requests.ts new file mode 100644 index 00000000000..903c3bd0968 --- /dev/null +++ b/src/main/daemon/daemon-pty-operation-requests.ts @@ -0,0 +1,25 @@ +import type { TerminalSessionAuthorityPtyAccess } from '../../shared/terminal-session-authority-pty-access' + +type DaemonPtyRequest = { + id: string + type: TType + payload: { sessionId: string } & TPayload +} + +type ExactIdentity = { incarnationId: string } +type AuthorityIdentity = { authorityAccess: TerminalSessionAuthorityPtyAccess } + +export type DaemonPtyOperationRequest = + | DaemonPtyRequest<'write', { data: string }> + | DaemonPtyRequest<'writeExact', ExactIdentity & { data: string }> + | DaemonPtyRequest<'resize', { cols: number; rows: number }> + | DaemonPtyRequest<'resizeExact', ExactIdentity & { cols: number; rows: number }> + | DaemonPtyRequest<'kill', { immediate?: boolean }> + | DaemonPtyRequest<'killExact', ExactIdentity & { immediate?: boolean }> + | DaemonPtyRequest<'signalExact', ExactIdentity & { signal: string }> + | DaemonPtyRequest<'clearBufferExact', ExactIdentity> + | DaemonPtyRequest<'writeAuthorityExact', AuthorityIdentity & { data: string }> + | DaemonPtyRequest<'resizeAuthorityExact', AuthorityIdentity & { cols: number; rows: number }> + | DaemonPtyRequest<'killAuthorityExact', AuthorityIdentity & { immediate?: boolean }> + | DaemonPtyRequest<'signalAuthorityExact', AuthorityIdentity & { signal: string }> + | DaemonPtyRequest<'clearBufferAuthorityExact', AuthorityIdentity> diff --git a/src/main/daemon/daemon-pty-router-authority.test.ts b/src/main/daemon/daemon-pty-router-authority.test.ts new file mode 100644 index 00000000000..ce56cb532c0 --- /dev/null +++ b/src/main/daemon/daemon-pty-router-authority.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, it, vi } from 'vitest' +import { DaemonPtyRouter } from './daemon-pty-router' +import type { DaemonPtyAdapter } from './daemon-pty-adapter' +import type { PtyProcessInfo } from '../providers/types' +import type { TerminalSessionAuthorityPtyAccess } from '../../shared/terminal-session-authority-pty-access' +import { sweepProviderPtysForWorktree } from '../runtime/worktree-pty-inventory-sweep' + +type ExactDataEvent = { + id: string + data: string + incarnationId?: string +} + +type AuthorityAdapterMock = DaemonPtyAdapter & { + rows: PtyProcessInfo[] + emitData: (payload: ExactDataEvent) => void + emitIdentityChange: () => void +} + +function authorityAccess( + physicalPtyId: string, + ptyIncarnationId: string +): TerminalSessionAuthorityPtyAccess { + return { + namespace: { authorityHostId: 'authority-host', namespaceId: 'namespace' }, + pane: { paneKey: physicalPtyId, paneGenerationId: 'renderer:1' }, + binding: { + ownerIncarnationId: 'owner-incarnation', + physicalPtyId, + ptyIncarnationId + } + } +} + +function listed( + id: string, + incarnationId: string, + token: object, + worktreeId?: string +): PtyProcessInfo { + return { + id, + incarnationId, + mutationRouteToken: token, + terminalSessionAuthorityAccess: authorityAccess(id, incarnationId), + cwd: '', + title: 'shell', + ...(worktreeId ? { worktreeId } : {}) + } +} + +function createAdapter(rows: PtyProcessInfo[]): AuthorityAdapterMock { + const dataListeners: ((payload: ExactDataEvent) => void)[] = [] + const identityListeners: (() => void)[] = [] + const adapter = { + rows, + protocolVersion: 34, + listProcesses: vi.fn(async () => rows), + getPtyMutationRouteToken: vi.fn( + (id: string) => rows.find((row) => row.id === id)?.mutationRouteToken ?? null + ), + supportsExactPtyOperations: vi.fn(() => true), + getPtyMutationMode: vi.fn(() => 'exact'), + writeExact: vi.fn(() => true), + resizeExact: vi.fn(() => true), + killExact: vi.fn(async () => true), + sendSignalExact: vi.fn(async () => true), + clearBufferExact: vi.fn(async () => true), + writeAuthorityExact: vi.fn(() => true), + resizeAuthorityExact: vi.fn(() => true), + killAuthorityExact: vi.fn(async () => true), + sendSignalAuthorityExact: vi.fn(async () => true), + clearBufferAuthorityExact: vi.fn(async () => true), + bindTerminalSessionAuthorityAccess: vi.fn(() => true), + hasPty: vi.fn((id: string) => rows.some((row) => row.id === id)), + onData: vi.fn((callback: (payload: ExactDataEvent) => void) => { + dataListeners.push(callback) + return () => {} + }), + onExit: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onBackgroundStreamEvent: vi.fn(() => () => {}), + onWriteUnavailable: vi.fn(() => () => {}), + onDaemonIdentityChanged: vi.fn((callback: () => void) => { + identityListeners.push(callback) + return () => {} + }), + emitData: (payload: ExactDataEvent) => { + for (const listener of dataListeners) { + listener(payload) + } + }, + emitIdentityChange: () => { + for (const listener of identityListeners) { + listener() + } + } + } + return adapter as unknown as AuthorityAdapterMock +} + +async function expectAuthorityOperations( + router: DaemonPtyRouter, + adapter: AuthorityAdapterMock, + id: string, + incarnationId: string +): Promise { + const access = authorityAccess(id, incarnationId) + expect(router.bindTerminalSessionAuthorityAccess(id, access)).toBe(true) + expect(router.writeAuthorityExact(id, access, 'input')).toBe(true) + expect(router.resizeAuthorityExact(id, access, 120, 40)).toBe(true) + await expect(router.sendSignalAuthorityExact(id, access, 'SIGTERM')).resolves.toBe(true) + await expect(router.clearBufferAuthorityExact(id, access)).resolves.toBe(true) + await expect(router.killAuthorityExact(id, access, { immediate: true })).resolves.toBe(true) + expect(adapter.writeAuthorityExact).toHaveBeenCalledWith(id, access, 'input') + expect(adapter.resizeAuthorityExact).toHaveBeenCalledWith(id, access, 120, 40) + expect(adapter.sendSignalAuthorityExact).toHaveBeenCalledWith(id, access, 'SIGTERM') + expect(adapter.clearBufferAuthorityExact).toHaveBeenCalledWith(id, access) + expect(adapter.killAuthorityExact).toHaveBeenCalledWith(id, access, { immediate: true }) +} + +describe('DaemonPtyRouter authority routing', () => { + it('forwards exact and authority operations to uniquely routed current and legacy adapters', async () => { + const current = createAdapter([listed('current-session', 'current-incarnation', {})]) + const legacy = createAdapter([listed('legacy-session', 'legacy-incarnation', {})]) + const router = new DaemonPtyRouter({ current, legacy: [legacy] }) + await router.listProcesses() + + expect(router.writeExact('current-session', 'current-incarnation', 'exact')).toBe(true) + expect(router.resizeExact('legacy-session', 'legacy-incarnation', 100, 30)).toBe(true) + await expect( + router.sendSignalExact('current-session', 'current-incarnation', 'SIGINT') + ).resolves.toBe(true) + await expect(router.clearBufferExact('legacy-session', 'legacy-incarnation')).resolves.toBe( + true + ) + await expect( + router.killExact('current-session', 'current-incarnation', { immediate: true }) + ).resolves.toBe(true) + await expectAuthorityOperations(router, current, 'current-session', 'current-incarnation') + await expectAuthorityOperations(router, legacy, 'legacy-session', 'legacy-incarnation') + }) + + it('fails closed for a same-id adapter collision', async () => { + const access = authorityAccess('collision', 'incarnation') + const current = createAdapter([listed('collision', 'incarnation', {})]) + const legacy = createAdapter([listed('collision', 'incarnation', {})]) + const router = new DaemonPtyRouter({ current, legacy: [legacy] }) + + const inventory = await router.listProcesses() + + expect(inventory.every((row) => row.mutationRouteToken === undefined)).toBe(true) + expect(router.getPtyMutationRouteToken('collision')).toBeNull() + expect(router.writeExact('collision', 'incarnation', 'blocked')).toBe(false) + expect(router.writeAuthorityExact('collision', access, 'blocked')).toBe(false) + expect(router.bindTerminalSessionAuthorityAccess('collision', access)).toBe(false) + expect(current.writeExact).not.toHaveBeenCalled() + expect(legacy.writeExact).not.toHaveBeenCalled() + expect(current.writeAuthorityExact).not.toHaveBeenCalled() + expect(legacy.writeAuthorityExact).not.toHaveBeenCalled() + }) + + it('invalidates exact routing when the owning adapter is replaced', async () => { + const token = {} + const access = authorityAccess('replaced', 'incarnation') + const current = createAdapter([listed('replaced', 'incarnation', token)]) + const router = new DaemonPtyRouter({ current, legacy: [] }) + const [row] = await router.listProcesses() + current.emitIdentityChange() + + expect(row?.mutationRouteToken).toBe(token) + expect(router.getPtyMutationRouteToken('replaced')).toBeNull() + expect(router.writeExact('replaced', 'incarnation', 'blocked')).toBe(false) + expect(router.writeAuthorityExact('replaced', access, 'blocked')).toBe(false) + expect(router.bindTerminalSessionAuthorityAccess('replaced', access)).toBe(false) + }) + + it('uses authority-exact kill during a worktree inventory sweep', async () => { + const worktreeId = 'repo-id::/worktree' + const id = `${worktreeId}@@pty` + const current = createAdapter([listed(id, 'incarnation', {}, worktreeId)]) + const router = new DaemonPtyRouter({ current, legacy: [] }) + + await expect( + sweepProviderPtysForWorktree( + worktreeId, + router, + Date.now() + 5_000, + Date.now() + 4_000, + async (_ptyId, stop) => ({ stopped: await stop(), owner: true }), + undefined, + true, + () => router.listProcesses() + ) + ).resolves.toBe(1) + expect(current.killAuthorityExact).toHaveBeenCalledOnce() + expect(current.killExact).not.toHaveBeenCalled() + }) + + it('preserves the admitted incarnation through router data fanout', () => { + const current = createAdapter([]) + const legacy = createAdapter([]) + const router = new DaemonPtyRouter({ current, legacy: [legacy] }) + const received: ExactDataEvent[] = [] + router.onData((payload) => received.push(payload)) + + legacy.emitData({ id: 'same-id', data: 'successor', incarnationId: 'incarnation-b' }) + + expect(received).toEqual([{ id: 'same-id', data: 'successor', incarnationId: 'incarnation-b' }]) + }) +}) diff --git a/src/main/daemon/daemon-pty-router-events.ts b/src/main/daemon/daemon-pty-router-events.ts index 41c1d560286..a6bd4740e00 100644 --- a/src/main/daemon/daemon-pty-router-events.ts +++ b/src/main/daemon/daemon-pty-router-events.ts @@ -3,6 +3,7 @@ import type { PtyIncarnationId } from '../../shared/pty-incarnation' export type DaemonPtyRouterDataEvent = { id: string data: string + incarnationId?: PtyIncarnationId sequenceChars?: number transformed?: boolean seq?: number diff --git a/src/main/daemon/daemon-pty-router-exact-operations.ts b/src/main/daemon/daemon-pty-router-exact-operations.ts new file mode 100644 index 00000000000..e1dda4052b0 --- /dev/null +++ b/src/main/daemon/daemon-pty-router-exact-operations.ts @@ -0,0 +1,144 @@ +import type { TerminalSessionAuthorityPtyAccess } from '../../shared/terminal-session-authority-pty-access' +import type { IPtyProvider, PtyHeldProducerPauseToken, PtyMutationMode } from '../providers/types' + +export abstract class DaemonPtyExactOperationRouter { + protected abstract exactProviderFor(id: string): IPtyProvider | null + + supportsExactPtyOperations(id: string): boolean { + return this.exactProviderFor(id)?.supportsExactPtyOperations?.(id) === true + } + + getPtyMutationMode(id: string): PtyMutationMode { + return this.exactProviderFor(id)?.getPtyMutationMode?.(id) ?? 'unavailable' + } + + supportsExactHeldProducerPause(id: string, incarnationId: string): boolean { + return this.exactProviderFor(id)?.supportsExactHeldProducerPause?.(id, incarnationId) === true + } + + async acquireExactHeldProducerPause( + id: string, + incarnationId: string, + token: PtyHeldProducerPauseToken + ): Promise { + return ( + (await this.exactProviderFor(id)?.acquireExactHeldProducerPause?.( + id, + incarnationId, + token + )) ?? false + ) + } + + async releaseExactHeldProducerPause( + id: string, + incarnationId: string, + token: PtyHeldProducerPauseToken + ): Promise { + return ( + (await this.exactProviderFor(id)?.releaseExactHeldProducerPause?.( + id, + incarnationId, + token + )) ?? false + ) + } + + writeExact(id: string, incarnationId: string, data: string): boolean { + return this.exactProviderFor(id)?.writeExact?.(id, incarnationId, data) === true + } + + writeAuthorityExact( + id: string, + authorityAccess: TerminalSessionAuthorityPtyAccess, + data: string + ): boolean { + return this.exactProviderFor(id)?.writeAuthorityExact?.(id, authorityAccess, data) === true + } + + resizeExact(id: string, incarnationId: string, cols: number, rows: number): boolean { + return this.exactProviderFor(id)?.resizeExact?.(id, incarnationId, cols, rows) === true + } + + resizeAuthorityExact( + id: string, + authorityAccess: TerminalSessionAuthorityPtyAccess, + cols: number, + rows: number + ): boolean { + return ( + this.exactProviderFor(id)?.resizeAuthorityExact?.(id, authorityAccess, cols, rows) === true + ) + } + + async killExact( + id: string, + incarnationId: string, + opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number } + ): Promise { + return (await this.exactProviderFor(id)?.killExact?.(id, incarnationId, opts)) ?? false + } + + async killAuthorityExact( + id: string, + authorityAccess: TerminalSessionAuthorityPtyAccess, + opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number } + ): Promise { + return ( + (await this.exactProviderFor(id)?.killAuthorityExact?.(id, authorityAccess, opts)) ?? false + ) + } + + async sendSignalExact(id: string, incarnationId: string, signal: string): Promise { + return (await this.exactProviderFor(id)?.sendSignalExact?.(id, incarnationId, signal)) ?? false + } + + async sendSignalAuthorityExact( + id: string, + authorityAccess: TerminalSessionAuthorityPtyAccess, + signal: string + ): Promise { + return ( + (await this.exactProviderFor(id)?.sendSignalAuthorityExact?.(id, authorityAccess, signal)) ?? + false + ) + } + + async clearBufferExact(id: string, incarnationId: string): Promise { + return (await this.exactProviderFor(id)?.clearBufferExact?.(id, incarnationId)) ?? false + } + + async clearBufferAuthorityExact( + id: string, + authorityAccess: TerminalSessionAuthorityPtyAccess + ): Promise { + return ( + (await this.exactProviderFor(id)?.clearBufferAuthorityExact?.(id, authorityAccess)) ?? false + ) + } + + bindTerminalSessionAuthorityAccess( + id: string, + authorityAccess: TerminalSessionAuthorityPtyAccess + ): boolean { + return ( + this.exactProviderFor(id)?.bindTerminalSessionAuthorityAccess?.(id, authorityAccess) === true + ) + } + + getTerminalSessionAuthorityAccess(id: string): TerminalSessionAuthorityPtyAccess | null { + return this.exactProviderFor(id)?.getTerminalSessionAuthorityAccess?.(id) ?? null + } + + getPtyMutationRouteToken(id: string): object | null { + const provider = this.exactProviderFor(id) + if (!provider?.getPtyMutationRouteToken) { + return null + } + try { + return provider.getPtyMutationRouteToken(id) + } catch { + return null + } + } +} diff --git a/src/main/daemon/daemon-pty-router-inventory.ts b/src/main/daemon/daemon-pty-router-inventory.ts new file mode 100644 index 00000000000..83ce4177e79 --- /dev/null +++ b/src/main/daemon/daemon-pty-router-inventory.ts @@ -0,0 +1,70 @@ +import type { IPtyProvider, PtyProcessInfo } from '../providers/types' + +export type DaemonPtyProviderInventory = Readonly<{ + provider: TProvider + processes: readonly PtyProcessInfo[] +}> + +type DaemonPtyInventoryRouting = Readonly<{ + existingRouteIds: Iterable + recordRoute: (id: string, provider: TProvider, process: PtyProcessInfo) => void + forgetRoute: (id: string) => void +}> + +export function reconcileDaemonPtyProviderInventory( + inventories: readonly DaemonPtyProviderInventory[], + routing: DaemonPtyInventoryRouting +): PtyProcessInfo[] { + const candidatesById = new Map() + for (const { provider, processes } of inventories) { + for (const process of processes) { + const candidates = candidatesById.get(process.id) ?? [] + candidates.push({ provider, process }) + candidatesById.set(process.id, candidates) + } + } + + for (const id of routing.existingRouteIds) { + if (!candidatesById.has(id)) { + routing.forgetRoute(id) + } + } + for (const [id, candidates] of candidatesById) { + const candidate = candidates.length === 1 ? candidates[0] : undefined + if (candidate) { + routing.recordRoute(id, candidate.provider, candidate.process) + } else { + routing.forgetRoute(id) + } + } + + return inventories.flatMap(({ provider, processes }) => + processes.map((process) => { + const listedRouteToken = process.mutationRouteToken + const unique = candidatesById.get(process.id)?.length === 1 + const routeToken = unique + ? currentListedRouteToken(provider, process.id, listedRouteToken) + : null + if (routeToken || listedRouteToken === undefined) { + return process + } + const { mutationRouteToken: _discarded, ...withoutRouteToken } = process + return withoutRouteToken + }) + ) +} + +function currentListedRouteToken( + provider: IPtyProvider, + id: string, + listed: object | undefined +): object | null { + if (!listed || !provider.getPtyMutationRouteToken) { + return null + } + try { + return provider.getPtyMutationRouteToken(id) === listed ? listed : null + } catch { + return null + } +} diff --git a/src/main/daemon/daemon-pty-router.test.ts b/src/main/daemon/daemon-pty-router.test.ts index 1930224c2d5..cc84d7c2294 100644 --- a/src/main/daemon/daemon-pty-router.test.ts +++ b/src/main/daemon/daemon-pty-router.test.ts @@ -47,6 +47,7 @@ function createAdapter( const exitListeners: ((payload: { id: string; code: number; incarnationId?: string }) => void)[] = [] const identityChangeListeners: (() => void)[] = [] + const mutationRouteToken = Object.freeze({ label }) return { protocolVersion, supportsGitCredentialGuardHost: () => @@ -67,12 +68,19 @@ function createAdapter( listProcesses: vi.fn(async () => sessions.map((id) => ({ id, + mutationRouteToken, cwd: '', title: label })) ), + getPtyMutationRouteToken: vi.fn((id: string) => + sessions.includes(id) ? mutationRouteToken : null + ), hasPty: vi.fn((id: string) => sessions.includes(id)), probePtyLiveness: vi.fn(async (id: string) => sessions.includes(id)), + supportsExactHeldProducerPause: vi.fn((id: string) => sessions.includes(id)), + acquireExactHeldProducerPause: vi.fn(async (id: string) => sessions.includes(id)), + releaseExactHeldProducerPause: vi.fn(async (id: string) => sessions.includes(id)), write: vi.fn((id: string, data: string) => { writes.push({ id, data }) }), @@ -683,6 +691,66 @@ describe('DaemonPtyRouter', () => { await expect(router.listProcesses()).rejects.toThrow('legacy unavailable') }) + it('stamps inventory with the uniquely selected adapter route token', async () => { + const current = createAdapter('current') + const legacy = createAdapter('legacy', ['legacy-session']) + const router = new DaemonPtyRouter({ current, legacy: [legacy] }) + + const [listed] = await router.listProcesses() + + expect(listed?.mutationRouteToken).toBe(legacy.getPtyMutationRouteToken('legacy-session')) + expect(router.getPtyMutationRouteToken('legacy-session')).toBe(listed?.mutationRouteToken) + }) + + it('routes exact held-pause admission and leases to the discovered owner', async () => { + const current = createAdapter('current') + const legacy = createAdapter('legacy', ['legacy-session']) + const router = new DaemonPtyRouter({ current, legacy: [legacy] }) + await router.discoverLegacySessions() + + expect(router.supportsExactHeldProducerPause('legacy-session', 'incarnation')).toBe(true) + await expect( + router.acquireExactHeldProducerPause('legacy-session', 'incarnation', 'lease') + ).resolves.toBe(true) + await expect( + router.releaseExactHeldProducerPause('legacy-session', 'incarnation', 'lease') + ).resolves.toBe(true) + expect(legacy.acquireExactHeldProducerPause).toHaveBeenCalledWith( + 'legacy-session', + 'incarnation', + 'lease' + ) + expect(current.acquireExactHeldProducerPause).not.toHaveBeenCalled() + }) + + it('invalidates a listed token when ownership moves between daemon adapters', async () => { + const currentSessions: string[] = [] + const legacySessions = ['moving-session'] + const current = createAdapter('current', currentSessions) + const legacy = createAdapter('legacy', legacySessions) + const router = new DaemonPtyRouter({ current, legacy: [legacy] }) + const [before] = await router.listProcesses() + + legacySessions.length = 0 + currentSessions.push('moving-session') + const [after] = await router.listProcesses() + + expect(before?.mutationRouteToken).not.toBe(after?.mutationRouteToken) + expect(router.getPtyMutationRouteToken('moving-session')).toBe(after?.mutationRouteToken) + }) + + it('withholds route tokens for colliding daemon inventories', async () => { + const current = createAdapter('current', ['collision']) + const legacy = createAdapter('legacy', ['collision']) + const router = new DaemonPtyRouter({ current, legacy: [legacy] }) + + const listed = await router.listProcesses() + + expect(listed).toHaveLength(2) + expect(listed.every((entry) => entry.mutationRouteToken === undefined)).toBe(true) + expect(router.getPtyMutationRouteToken('collision')).toBeNull() + }) + it('keeps a legacy adapter that exits after construction in fail-closed aggregates', async () => { const current = createAdapter('current', ['current-session']) const legacy = createAdapter('legacy', ['legacy-session']) diff --git a/src/main/daemon/daemon-pty-router.ts b/src/main/daemon/daemon-pty-router.ts index 8429495ed9f..2bf1a237825 100644 --- a/src/main/daemon/daemon-pty-router.ts +++ b/src/main/daemon/daemon-pty-router.ts @@ -12,8 +12,10 @@ import type { PtyProcessInspection } from '../providers/pty-process-inspection' import { shouldHandoffDaemonHistory } from './daemon-history-handoff' import type { DaemonPtyRouterDataEvent, DaemonPtyRouterExitEvent } from './daemon-pty-router-events' import { DaemonSessionOwnerResolver } from './daemon-session-owner-resolution' +import { reconcileDaemonPtyProviderInventory } from './daemon-pty-router-inventory' +import { DaemonPtyExactOperationRouter } from './daemon-pty-router-exact-operations' -export class DaemonPtyRouter implements IPtyProvider { +export class DaemonPtyRouter extends DaemonPtyExactOperationRouter implements IPtyProvider { private current: DaemonPtyAdapter private legacy: DaemonPtyAdapter[] private sessionAdapters = new Map() @@ -21,6 +23,7 @@ export class DaemonPtyRouter implements IPtyProvider { private readonly subscriptions: DaemonPtyAdapterSubscriptionFanout constructor(opts: { current: DaemonPtyAdapter; legacy: DaemonPtyAdapter[] }) { + super() this.current = opts.current this.legacy = opts.legacy this.ownerResolver = new DaemonSessionOwnerResolver(this.allAdapters(), this.sessionAdapters) @@ -193,9 +196,17 @@ export class DaemonPtyRouter implements IPtyProvider { // Why: runtime exact-stop/liveness flows must fail closed if any adapter // cannot provide a trustworthy process list. const results = await Promise.all( - this.allAdapters().map((adapter) => adapter.listProcesses(opts)) + this.allAdapters().map(async (adapter) => ({ + provider: adapter, + processes: await adapter.listProcesses(opts) + })) ) - return results.flat() + return reconcileDaemonPtyProviderInventory(results, { + existingRouteIds: this.sessionAdapters.keys(), + recordRoute: (id, adapter, process) => + this.ownerResolver.recordRoute(id, adapter, process.incarnationId), + forgetRoute: (id) => this.ownerResolver.forgetRoute(id) + }) } async getDefaultShell(): Promise { @@ -313,6 +324,10 @@ export class DaemonPtyRouter implements IPtyProvider { return this.sessionAdapters.get(sessionId) ?? this.current } + protected exactProviderFor(sessionId: string): DaemonPtyAdapter | null { + return this.sessionAdapters.get(sessionId) ?? null + } + private adapterForInspection(sessionId: string): DaemonPtyAdapter { const adapter = this.sessionAdapters.get(sessionId) ?? diff --git a/src/main/daemon/daemon-pty-stream-binding-state.test.ts b/src/main/daemon/daemon-pty-stream-binding-state.test.ts new file mode 100644 index 00000000000..c12f3b3c0d9 --- /dev/null +++ b/src/main/daemon/daemon-pty-stream-binding-state.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest' +import { DaemonPtyStreamBindingState } from './daemon-pty-stream-binding-state' + +function source(incarnationId: string, streamBindingNonce: string) { + return { incarnationId, streamBindingNonce } +} + +describe('DaemonPtyStreamBindingState', () => { + it('admits the first source marker before its control reply', () => { + const state = new DaemonPtyStreamBindingState() + state.begin('nonce-a') + + expect(state.acceptMarker('session-1', source('inc-a', 'nonce-a'))).toEqual( + source('inc-a', 'nonce-a') + ) + expect(state.sourceFor('session-1')).toEqual(source('inc-a', 'nonce-a')) + expect(state.acceptResponse('nonce-a', 'session-1', 'inc-a', 'nonce-a')).toBe(true) + }) + + it('lets the latest ordered concurrent marker own the stream', () => { + const state = new DaemonPtyStreamBindingState() + state.begin('nonce-a') + state.begin('nonce-b') + + state.acceptMarker('session-1', source('inc-a', 'nonce-a')) + state.acceptMarker('session-1', source('inc-b', 'nonce-b')) + expect(state.acceptResponse('nonce-a', 'session-1', 'inc-a', 'nonce-a')).toBe(true) + expect(state.sourceFor('session-1')).toEqual(source('inc-b', 'nonce-b')) + expect(state.acceptResponse('nonce-b', 'session-1', 'inc-b', 'nonce-b')).toBe(true) + }) + + it('rejects stale markers, output bindings, and exits', () => { + const state = new DaemonPtyStreamBindingState() + state.begin('nonce-current') + state.acceptMarker('session-1', source('inc-current', 'nonce-current')) + + expect(state.acceptMarker('session-1', source('inc-stale', 'nonce-stale'))).toBeNull() + expect(state.sourceFor('session-1')).toEqual(source('inc-current', 'nonce-current')) + expect(state.admitsEvent('session-1')).toBe(true) + expect(state.admitsEvent('stale-session')).toBe(false) + expect(state.admitsExit('session-1', 'inc-stale')).toBe(false) + expect(state.admitsExit('session-1', 'inc-current')).toBe(true) + }) + + it('revokes a marker when its final control reply disagrees', () => { + const state = new DaemonPtyStreamBindingState() + state.begin('nonce-a') + state.acceptMarker('session-1', source('inc-a', 'nonce-a')) + + expect(state.acceptResponse('nonce-a', 'session-1', 'inc-b', 'nonce-a')).toBe(false) + expect(state.sourceFor('session-1')).toBeUndefined() + }) + + it('revokes a nonce that emits conflicting source markers', () => { + const state = new DaemonPtyStreamBindingState() + state.begin('nonce-a') + state.acceptMarker('session-1', source('inc-a', 'nonce-a')) + + expect(state.acceptMarker('session-2', source('inc-b', 'nonce-a'))).toBeNull() + expect(state.sourceFor('session-1')).toBeUndefined() + expect(state.sourceFor('session-2')).toBeUndefined() + }) + + it('bounds response-only bindings without evicting a live transaction', () => { + const state = new DaemonPtyStreamBindingState(2) + state.begin('nonce-a') + state.begin('nonce-b') + expect(state.acceptResponse('nonce-a', 'session-1', 'inc-a', 'nonce-a')).toBe(true) + + expect(() => state.begin('nonce-c')).toThrow('daemon_stream_binding_capacity_exceeded') + expect(state.acceptMarker('session-1', source('inc-a', 'nonce-a'))).toEqual( + source('inc-a', 'nonce-a') + ) + expect(() => state.begin('nonce-c')).not.toThrow() + }) +}) diff --git a/src/main/daemon/daemon-pty-stream-binding-state.ts b/src/main/daemon/daemon-pty-stream-binding-state.ts new file mode 100644 index 00000000000..59bbcb4e3b2 --- /dev/null +++ b/src/main/daemon/daemon-pty-stream-binding-state.ts @@ -0,0 +1,143 @@ +import { isPtyIncarnationId } from '../../shared/pty-incarnation' +import { + isPtyStreamBindingNonce, + isPtyStreamSource, + type PtyStreamSource +} from '../../shared/pty-stream-binding-protocol' + +type BoundPtyStreamSource = PtyStreamSource & { sessionId: string } + +type PendingStreamBinding = { + marker?: BoundPtyStreamSource + response?: BoundPtyStreamSource +} + +export class DaemonPtyStreamBindingState { + private readonly pendingByNonce = new Map() + private readonly currentBySessionId = new Map() + + constructor(private readonly maxPendingBindings = 4_096) {} + + begin(streamBindingNonce: string): void { + if ( + !isPtyStreamBindingNonce(streamBindingNonce) || + this.pendingByNonce.has(streamBindingNonce) + ) { + throw new Error('daemon_stream_binding_nonce_invalid') + } + if (this.pendingByNonce.size >= this.maxPendingBindings) { + throw new Error('daemon_stream_binding_capacity_exceeded') + } + this.pendingByNonce.set(streamBindingNonce, {}) + } + + acceptMarker(sessionId: string, value: unknown): PtyStreamSource | null { + if (!sessionId || !isPtyStreamSource(value)) { + return null + } + const pending = this.pendingByNonce.get(value.streamBindingNonce) + if (!pending) { + return null + } + const marker = { sessionId, ...value } + if ( + (pending.marker && !sameBoundSource(pending.marker, marker)) || + (pending.response && !sameBoundSource(pending.response, marker)) + ) { + this.cancel(value.streamBindingNonce) + return null + } + pending.marker = marker + this.currentBySessionId.set(sessionId, marker) + this.settleIfComplete(value.streamBindingNonce, pending) + return value + } + + acceptResponse( + streamBindingNonce: string, + sessionId: string, + incarnationId: unknown, + echoedStreamBindingNonce: unknown + ): boolean { + const pending = this.pendingByNonce.get(streamBindingNonce) + if ( + !pending || + echoedStreamBindingNonce !== streamBindingNonce || + !isPtyIncarnationId(incarnationId) || + !sessionId + ) { + this.cancel(streamBindingNonce) + return false + } + const response = { sessionId, incarnationId, streamBindingNonce } + if (pending.marker && !sameBoundSource(pending.marker, response)) { + this.cancel(streamBindingNonce) + return false + } + pending.response = response + this.settleIfComplete(streamBindingNonce, pending) + return true + } + + cancel(streamBindingNonce: string): void { + const pending = this.pendingByNonce.get(streamBindingNonce) + this.pendingByNonce.delete(streamBindingNonce) + if (!pending?.marker) { + return + } + const current = this.currentBySessionId.get(pending.marker.sessionId) + if (current?.streamBindingNonce === streamBindingNonce) { + this.currentBySessionId.delete(pending.marker.sessionId) + } + } + + sourceFor(sessionId: string): PtyStreamSource | undefined { + const source = this.currentBySessionId.get(sessionId) + return source + ? { + incarnationId: source.incarnationId, + streamBindingNonce: source.streamBindingNonce + } + : undefined + } + + admittedIncarnationId(sessionId: string): string | undefined { + return this.currentBySessionId.get(sessionId)?.incarnationId + } + + admitsEvent(sessionId: string): boolean { + return this.currentBySessionId.has(sessionId) + } + + admitsExit(sessionId: string, incarnationId: unknown): boolean { + const source = this.currentBySessionId.get(sessionId) + return Boolean(source && source.incarnationId === incarnationId) + } + + forget(sessionId: string, incarnationId?: string): void { + const source = this.currentBySessionId.get(sessionId) + if (!source || (incarnationId && source.incarnationId !== incarnationId)) { + return + } + this.currentBySessionId.delete(sessionId) + } + + clear(): void { + this.pendingByNonce.clear() + this.currentBySessionId.clear() + } + + private settleIfComplete(streamBindingNonce: string, pending: PendingStreamBinding): void { + if (pending.marker && pending.response) { + this.pendingByNonce.delete(streamBindingNonce) + } + } +} + +function sameBoundSource(left: BoundPtyStreamSource, right: BoundPtyStreamSource): boolean { + return ( + left.sessionId === right.sessionId && + left.incarnationId === right.incarnationId && + left.streamBindingNonce === right.streamBindingNonce + ) +} diff --git a/src/main/daemon/daemon-server-retired-policy-consumer.test.ts b/src/main/daemon/daemon-server-retired-policy-consumer.test.ts new file mode 100644 index 00000000000..11aad1b56cf --- /dev/null +++ b/src/main/daemon/daemon-server-retired-policy-consumer.test.ts @@ -0,0 +1,180 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { DaemonServer } from './daemon-server' +import type { DaemonRequest } from './types' +import { DAEMON_TERMINAL_AUTHORITY_CONSUMER_GRANT_REQUEST } from './daemon-terminal-authority-consumer-requests' +import { + TERMINAL_AUTHORITY_CONSUMER_PROOF_ALGORITHM, + TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION, + type TerminalAuthorityNamespaceAdmissionChallenge +} from '../../shared/terminal-session-authority-consumer-proof' +import { + createTerminalAuthorityConsumerProof, + createTerminalAuthorityProofEphemeralKeypair +} from '../session-authority/terminal-session-authority-consumer-proof' +import type { TerminalSessionAuthorityPtyOwner } from '../session-authority/terminal-session-authority-pty-owner' + +type DaemonServerRouter = { + routeRequest(clientId: string, request: DaemonRequest): Promise +} + +type DaemonServerAuthorityState = DaemonServerRouter & { + releaseAuthenticatedPolicyTransport(client: unknown): void + clients: Map +} + +const AUTHORITY_HOST_ID = 'authority-host:daemon-rollback-test' +const APP_KEYPAIR = createTerminalAuthorityProofEphemeralKeypair() + +describe('dead terminal authority daemon requests', () => { + let server: DaemonServer + + afterEach(async () => { + await server?.shutdown() + }) + + it('rejects the retired proofless policy-consumer request before any mutation', async () => { + server = new DaemonServer({ + socketPath: 'unused-daemon-socket', + tokenPath: 'unused-daemon-token', + spawnSubprocess: () => { + throw new Error('subprocess spawn is not part of this request') + } + }) + + await expect( + (server as unknown as DaemonServerRouter).routeRequest('client-1', { + id: 'retired-policy-consumer-request', + type: 'retireTerminalAuthorityPolicyConsumer', + payload: undefined + } as unknown as DaemonRequest) + ).rejects.toThrow('terminal authority policy consumer retirement is unsupported') + }) + + it('reports joined primary and rollback failures when transport cleanup fails', async () => { + const rollbackFailure = new Error('transport rollback failed') + const rollback = vi.fn(async (): Promise => { + throw rollbackFailure + }) + const onFailure = vi.fn() + server = new DaemonServer({ + socketPath: 'unused-daemon-socket', + tokenPath: 'unused-daemon-token', + spawnSubprocess: () => { + throw new Error('subprocess spawn is not part of this request') + }, + onTerminalSessionAuthorityFailure: onFailure + }) + + const daemon = server as unknown as DaemonServerAuthorityState + daemon.releaseAuthenticatedPolicyTransport({ + authorityConsumerTransport: { token: {} }, + authorityPendingPreparations: new Set([ + { + active: true, + preparation: { rollback }, + acceptances: { close: vi.fn() } + } + ]), + authorityNamespaceSessions: new Map() + }) + + await vi.waitFor(() => expect(onFailure).toHaveBeenCalledOnce()) + expect(rollback).toHaveBeenCalledOnce() + const failure = onFailure.mock.calls[0]?.[0] + expect(failure).toBeInstanceOf(AggregateError) + expect((failure as AggregateError).errors).toEqual([ + expect.objectContaining({ + message: 'terminal authority consumer transport released during admission' + }), + rollbackFailure + ]) + }) + + it('returns joined primary and rollback failures from a grant cleanup failure', async () => { + const { challenge, proof } = admissionProof() + const primaryFailure = new Error('grant commit failed') + const rollbackFailure = new Error('grant rollback failed') + const preparation = { + grant: {}, + policyConsumer: {}, + commit: vi.fn(async () => { + throw primaryFailure + }), + rollback: vi.fn(async () => { + throw rollbackFailure + }) + } + const owner = { + prepareAuthenticatedPolicyConsumerNamespace: vi.fn(async () => preparation), + releaseAuthenticatedPolicyConsumerTransport: vi.fn() + } as unknown as TerminalSessionAuthorityPtyOwner + server = new DaemonServer({ + socketPath: 'unused-daemon-socket', + tokenPath: 'unused-daemon-token', + spawnSubprocess: () => { + throw new Error('subprocess spawn is not part of this request') + }, + terminalSessionAuthority: { ptyOwner: owner } + }) + + const daemon = server as unknown as DaemonServerAuthorityState + daemon.clients.set('client-1', { + clientId: 'client-1', + controlSocket: { destroy: vi.fn() }, + streamSocket: { destroy: vi.fn() }, + authenticatedPairEstablished: true, + capabilities: { + terminalAuthorityConsumerProof: { version: TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION } + }, + authorityConsumerTransport: { + connectionGrantId: challenge.connectionGrantId, + principal: challenge.authenticatedTransportPrincipal, + capability: challenge.authenticatedTransportCapability, + token: {} + }, + authorityPendingPreparations: new Set() + }) + + let failure: unknown + try { + await daemon.routeRequest('client-1', { + id: 'grant-request', + type: DAEMON_TERMINAL_AUTHORITY_CONSUMER_GRANT_REQUEST, + payload: proof + } as DaemonRequest) + } catch (error) { + failure = error + } + + expect(failure).toBeInstanceOf(AggregateError) + expect((failure as AggregateError).errors).toEqual([primaryFailure, rollbackFailure]) + expect(preparation.rollback).toHaveBeenCalledOnce() + }) +}) + +function admissionProof(): { + challenge: TerminalAuthorityNamespaceAdmissionChallenge + proof: ReturnType +} { + const hostKeypair = createTerminalAuthorityProofEphemeralKeypair() + const challenge: TerminalAuthorityNamespaceAdmissionChallenge = Object.freeze({ + version: TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION, + algorithm: TERMINAL_AUTHORITY_CONSUMER_PROOF_ALGORITHM, + namespace: Object.freeze({ + authorityHostId: AUTHORITY_HOST_ID, + namespaceId: 'namespace:daemon-rollback-test' + }), + appPublicKeyB64: Buffer.from(APP_KEYPAIR.publicKey).toString('base64'), + candidateProcessIncarnationId: 'app-process:daemon-rollback-test', + candidateSessionNonce: 'app-session:daemon-rollback-test', + requestId: 'app-request:daemon-rollback-test', + intent: 'first', + currentAdmissionCas: 'admission-cas:daemon-rollback-test', + connectionGrantId: 'daemon-grant:daemon-rollback-test', + authenticatedTransportPrincipal: 'daemon-principal:daemon-rollback-test', + authenticatedTransportCapability: 'daemon-capability:daemon-rollback-test', + hostEphemeralPublicKeyB64: Buffer.from(hostKeypair.publicKey).toString('base64'), + expiresAtMs: Date.now() + 30_000 + }) + return { challenge, proof: createTerminalAuthorityConsumerProof(challenge, APP_KEYPAIR) } +} diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts index b69641e6461..bdfc38f2877 100644 --- a/src/main/daemon/daemon-server.ts +++ b/src/main/daemon/daemon-server.ts @@ -1,11 +1,11 @@ /* eslint-disable max-lines -- Why: one class owns the daemon socket protocol, routing, stream fanout, and session lifecycle. */ import { createServer, type Server, type Socket } from 'node:net' -import { randomUUID } from 'node:crypto' +import { createHash, randomUUID } from 'node:crypto' import { performance } from 'node:perf_hooks' import { writeFileSync, chmodSync, unlinkSync } from 'node:fs' import { StringDecoder } from 'node:string_decoder' import { encodeNdjson, createNdjsonParser } from './ndjson' -import { TerminalHost } from './terminal-host' +import { TerminalHost, type TerminalHostOptions } from './terminal-host' import { DaemonStreamDataBatcher } from './daemon-stream-data-batcher' import { BackgroundTransientFactRelay, @@ -37,13 +37,78 @@ import { SessionNotFoundError, TerminalAttachCanceledError, type HelloMessage, - type DaemonRequest + type DaemonRequest, + type DaemonTransientFact } from './types' +import { + supportsExactHeldProducerPause, + supportsPtyStreamBinding, + supportsTerminalSessionAuthority +} from './daemon-protocol-version' +import { isPtyStreamBindingNonce } from '../../shared/pty-stream-binding-protocol' +import { isPtyIncarnationId } from '../../shared/pty-incarnation' +import { isPtyHeldProducerPauseToken } from '../providers/pty-provider-contract' import { isAgentSessionExecutionClaim, isAgentSessionSurfaceBinding } from '../../shared/agent-session-host-authority' import { TerminalHistorySeedTransferRegistry } from './terminal-history-seed-transfer-registry' +import { + parseTerminalSessionAuthorityPtyAccess, + type TerminalSessionAuthorityPtyAccess +} from '../../shared/terminal-session-authority-pty-access' +import type { TerminalSessionAuthorityPtyOwner } from '../session-authority/terminal-session-authority-pty-owner' +import type { + TerminalAuthorityPolicyConsumerConnection, + TerminalAuthorityPolicyConsumerResolver, + TerminalAuthorityPolicyConsumerSource +} from '../session-authority/terminal-session-authority-policy-consumers' +import type { + TerminalAuthorityAuthenticatedNamespacePreparation, + TerminalAuthorityAuthenticatedNamespaceSession +} from '../session-authority/terminal-session-authority-authenticated-consumers' +import type { TerminalAuthorityAuthenticatedConsumerTransport } from '../session-authority/terminal-session-authority-consumer-admission' +import { joinTerminalAuthorityRollbackFailure } from '../session-authority/terminal-session-authority-consumer-rollback-failure' +import { TerminalSessionAuthorityBoundaryAcceptances } from '../session-authority/terminal-session-authority-boundary-acceptance' +import { + parseTerminalAuthorityNamespaceBoundaryAcceptance, + parseTerminalAuthorityNamespaceOutcomeAck, + sameTerminalAuthorityPolicyConsumer, + type TerminalAuthorityNamespaceOutcomeBoundary, + type TerminalAuthorityNamespaceOutcomePublication, + type TerminalAuthorityPolicyConsumerIdentity +} from '../../shared/terminal-session-authority-consumer-transport' +import { + parseDaemonHelloCapabilities, + sameDaemonHelloCapabilities, + TERMINAL_SESSION_AUTHORITY_HELLO_CAPABILITY, + type DaemonHelloCapabilities, + type DaemonTerminalAuthorityConsumerProofGrant +} from './daemon-hello-protocol' +import { + TERMINAL_AUTHORITY_CONSUMER_PROOF_CAPABILITY, + TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION, + parseTerminalAuthorityNamespaceAdmissionCancellation, + parseTerminalAuthorityNamespaceAdmissionProof +} from '../../shared/terminal-session-authority-consumer-proof' +import { + TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_VERSION, + parseTerminalAuthorityConsumerRetirementProof, + parseTerminalAuthorityConsumerRetirementStart +} from '../../shared/terminal-session-authority-consumer-retirement' +import { + DAEMON_TERMINAL_AUTHORITY_CONSUMER_CHALLENGE_REQUEST, + DAEMON_TERMINAL_AUTHORITY_CONSUMER_CANCEL_REQUEST, + DAEMON_TERMINAL_AUTHORITY_CONSUMER_GRANT_REQUEST, + DAEMON_TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_CHALLENGE_REQUEST, + DAEMON_TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_REQUEST, + DAEMON_TERMINAL_AUTHORITY_CONSUMER_RESOLVE_NAMESPACE_REQUEST +} from './daemon-terminal-authority-consumer-requests' +import { terminalAuthorityHostAppConsumerId } from '../session-authority/terminal-session-authority-consumer-proof' + +export type TerminalSessionAuthorityCapabilityReadiness = Readonly<{ + hostEffectConsumerInstalled(): boolean +}> export type DaemonServerOptions = { socketPath: string @@ -84,6 +149,26 @@ export type DaemonServerOptions = { command?: string shellOverride?: string }) => SubprocessHandle + terminalSessionAuthority?: TerminalHostOptions['terminalSessionAuthority'] + terminalSessionAuthorityCapabilityReadiness?: TerminalSessionAuthorityCapabilityReadiness + onTerminalSessionAuthorityFailure?: TerminalHostOptions['onTerminalSessionAuthorityFailure'] +} + +function isDaemonConsumerProofGrant( + capability: DaemonHelloCapabilities['terminalAuthorityConsumerProof'] +): capability is DaemonTerminalAuthorityConsumerProofGrant { + return Boolean(capability && 'version' in capability) +} + +function authorityNamespaceKey(namespace: TerminalSessionAuthorityPtyAccess['namespace']): string { + return JSON.stringify([namespace.authorityHostId, namespace.namespaceId]) +} + +function sameAuthorityNamespace( + left: TerminalSessionAuthorityPtyAccess['namespace'], + right: TerminalSessionAuthorityPtyAccess['namespace'] +): boolean { + return left.authorityHostId === right.authorityHostId && left.namespaceId === right.namespaceId } type ConnectedClient = { @@ -91,6 +176,25 @@ type ConnectedClient = { controlSocket: Socket streamSocket: Socket | null authenticatedPairEstablished: boolean + capabilities?: DaemonHelloCapabilities + authorityConsumerTransport?: TerminalAuthorityAuthenticatedConsumerTransport + authorityNamespaceSessions?: Map + authorityPendingPreparations?: Set +} + +type AuthenticatedAuthorityNamespaceSession = Readonly<{ + session: TerminalAuthorityAuthenticatedNamespaceSession + acceptances: TerminalSessionAuthorityBoundaryAcceptances +}> + +type AuthenticatedAuthorityNamespacePreparation = { + active: boolean + preparation: TerminalAuthorityAuthenticatedNamespacePreparation | null + acceptances: TerminalSessionAuthorityBoundaryAcceptances + requestId: string + namespace: TerminalSessionAuthorityPtyAccess['namespace'] + connectionGrantId: string + consumer: TerminalAuthorityPolicyConsumerIdentity } type PendingPtySpawnPreparation = { @@ -104,6 +208,61 @@ type PendingShutdownReply = { start: () => void } +type HeldProducerPauseLease = { + sessionId: string + incarnationId: string + token: string +} + +type HeldProducerPausePayload = + | { mode: 'legacy' } + | { mode: 'invalid' } + | ({ mode: 'exact' } & HeldProducerPauseLease) + +function parseHeldProducerPausePayload(payload: { + sessionId: string + incarnationId?: string + heldPauseToken?: string +}): HeldProducerPausePayload { + if (payload.incarnationId === undefined && payload.heldPauseToken === undefined) { + return { mode: 'legacy' } + } + if ( + !isPtyIncarnationId(payload.incarnationId) || + !isPtyHeldProducerPauseToken(payload.heldPauseToken) + ) { + return { mode: 'invalid' } + } + return { + mode: 'exact', + sessionId: payload.sessionId, + incarnationId: payload.incarnationId, + token: payload.heldPauseToken + } +} + +function heldProducerPauseLeaseKey(lease: HeldProducerPauseLease): string { + return JSON.stringify([lease.sessionId, lease.incarnationId, lease.token]) +} + +function hasTerminalSessionAuthorityCreateMetadata(payload: { + terminalSessionAuthorityVersion?: unknown + terminalSessionAuthorityOperationId?: unknown + terminalSessionAuthorityAccess?: unknown + worktreeId?: unknown + paneKey?: unknown + paneGeneration?: unknown +}): boolean { + return ( + payload.terminalSessionAuthorityVersion !== undefined || + payload.terminalSessionAuthorityOperationId !== undefined || + payload.terminalSessionAuthorityAccess !== undefined || + payload.worktreeId !== undefined || + payload.paneKey !== undefined || + payload.paneGeneration !== undefined + ) +} + export class DaemonServer { // Why: survive long enough to adopt a first client pair, but don't orphan forever if the parent crashes first. private static readonly INITIAL_ADOPTION_TIMEOUT_MS = 2 * 60 * 1000 @@ -161,23 +320,26 @@ export class DaemonServer { } } ) - // Facts ride the stream queue as control entries so they hold byte order (else a fact could arrive after the reveal snapshot). private transientFactRelay = new BackgroundTransientFactRelay((sessionId, fact) => { - const clientId = this.streamClientIdBySessionId.get(sessionId) - if (clientId) { - this.streamDataBatcher.enqueueControlEvent(clientId, sessionId, { - type: 'event', - event: 'transientFact', - sessionId, - payload: fact - }) - } + this.enqueueLegacyTransientFact(sessionId, fact) }) private streamClientIdBySessionId = new Map() private lastInputAtBySessionId = new Map() private pendingPtySpawnPreparations = new Map>() + private pendingAuthoritySemanticOutcomes = new Set>() + private heldProducerPauseLeasesByClient = new Map>() private historySeedTransfers = new TerminalHistorySeedTransferRegistry() private stopStreamBacklogProbe: () => void = () => {} + private readonly terminalSessionAuthorityEnabled: boolean + private readonly terminalSessionAuthorityPtyOwner: TerminalSessionAuthorityPtyOwner | null + private readonly terminalSessionAuthorityHostId: string | null + private readonly removeTerminalSessionAuthorityHostEffectApplier: () => void + private readonly terminalSessionAuthorityCapabilityReadiness: + | TerminalSessionAuthorityCapabilityReadiness + | undefined + private readonly onTerminalSessionAuthorityFailure: + | NonNullable + | undefined // Why: bypass batching within this window so keystroke echo/redraws skip the daemon's fixed batch delay. private static readonly INTERACTIVE_OUTPUT_WINDOW_MS = 100 @@ -214,11 +376,30 @@ export class DaemonServer { now: () => Date.now() } this.token = randomUUID() + this.terminalSessionAuthorityEnabled = opts.terminalSessionAuthority !== undefined + this.terminalSessionAuthorityPtyOwner = opts.terminalSessionAuthority?.ptyOwner ?? null + this.terminalSessionAuthorityHostId = opts.terminalSessionAuthority?.authorityHostId ?? null + this.terminalSessionAuthorityCapabilityReadiness = + opts.terminalSessionAuthorityCapabilityReadiness + this.onTerminalSessionAuthorityFailure = opts.onTerminalSessionAuthorityFailure this.onAuthenticatedClientPair = opts.onAuthenticatedClientPair ?? (() => {}) this.host = new TerminalHost({ spawnSubprocess: opts.spawnSubprocess, - ...(opts.onPtySessionExit ? { onSessionReaped: opts.onPtySessionExit } : {}) + ...(opts.onPtySessionExit ? { onSessionReaped: opts.onPtySessionExit } : {}), + ...(opts.terminalSessionAuthority + ? { terminalSessionAuthority: opts.terminalSessionAuthority } + : {}), + ...(opts.onTerminalSessionAuthorityFailure + ? { onTerminalSessionAuthorityFailure: opts.onTerminalSessionAuthorityFailure } + : {}) }) + const installHostEffectApplier = this.terminalSessionAuthorityPtyOwner?.installHostEffectApplier + this.removeTerminalSessionAuthorityHostEffectApplier = installHostEffectApplier + ? installHostEffectApplier.call(this.terminalSessionAuthorityPtyOwner, { + ensureBindingRetired: (access, reason) => + this.host.ensureAuthorityBindingRetired(access, reason) + }) + : () => {} this.ptySpawnHealthCheck = opts.ptySpawnHealthCheck ?? checkPtySpawnHealth this.preparePtySpawn = opts.preparePtySpawn ?? (() => Promise.resolve()) this.stopStreamBacklogProbe = startDaemonStreamBacklogProbe(() => ({ @@ -404,6 +585,8 @@ export class DaemonServer { this.stopStreamBacklogProbe() this.transientFactRelay.dispose() this.cancelAllPendingPtySpawnPreparations() + await Promise.allSettled(this.pendingAuthoritySemanticOutcomes) + let hostDisposeError: unknown try { await this.host.dispose() } catch (err) { @@ -411,12 +594,17 @@ export class DaemonServer { this.log.log('shutdown-dispose-failed', { error: err instanceof Error ? err.message : String(err) }) + hostDisposeError = err } + this.removeTerminalSessionAuthorityHostEffectApplier() this.streamDataBatcher.clear() this.historySeedTransfers.dispose() this.pendingShutdownReplies.clear() + this.heldProducerPauseLeasesByClient.clear() + this.pendingAuthoritySemanticOutcomes.clear() for (const [, client] of this.clients) { + this.releaseAuthenticatedPolicyTransport(client) client.controlSocket.destroy() client.streamSocket?.destroy() } @@ -425,6 +613,9 @@ export class DaemonServer { socket.destroy() } this.transportSockets.clear() + if (hostDisposeError && this.terminalSessionAuthorityEnabled) { + throw hostDisposeError + } } private beginServerClose(): Promise { @@ -550,7 +741,14 @@ export class DaemonServer { // Why: keep UTF-8 sequences intact across socket chunks before NDJSON parsing. const decoder = new StringDecoder('utf8') const parser = createNdjsonParser( - (msg) => this.handleFirstMessage(socket, msg, parser), + (msg) => { + void this.handleFirstMessage(socket, msg, parser).catch((error) => { + this.log.log('client-hello-rejected', { + reason: error instanceof Error ? error.message : String(error) + }) + socket.destroy() + }) + }, () => { socket.destroy() } @@ -559,11 +757,11 @@ export class DaemonServer { socket.on('data', (chunk) => parser.feed(decoder.write(chunk))) } - private handleFirstMessage( + private async handleFirstMessage( socket: Socket, msg: unknown, _parser: ReturnType - ): void { + ): Promise { const hello = msg as HelloMessage if (hello.type !== 'hello') { this.log.log('client-hello-rejected', { reason: 'expected-hello' }) @@ -598,36 +796,53 @@ export class DaemonServer { return } + const capabilities = this.negotiateHelloCapabilities(hello.capabilities) + const existingControl = hello.role === 'stream' ? this.clients.get(hello.clientId) : undefined + const capabilitiesMatchControl = + existingControl === undefined || + sameDaemonHelloCapabilities(existingControl.capabilities ?? {}, capabilities) + this.log.log('client-hello-accepted', { role: hello.role, clientId: hello.clientId }) - socket.write( - encodeNdjson({ - type: 'hello', - ok: true, - ...(this.launchNonce && this.startedAtMs - ? { - daemonIdentity: { - pid: process.pid, - startedAtMs: this.startedAtMs, - launchNonce: this.launchNonce, - ...(this.entryPath ? { entryPath: this.entryPath } : {}), - ...(this.appVersion ? { appVersion: this.appVersion } : {}), - ...(this.spawnerExecPath ? { spawnerExecPath: this.spawnerExecPath } : {}) - } + const response = encodeNdjson({ + type: 'hello', + ok: true, + ...(capabilities.terminalSessionAuthority !== undefined || + capabilities.terminalAuthorityConsumerProof !== undefined + ? { capabilities } + : {}), + ...(this.launchNonce && this.startedAtMs + ? { + daemonIdentity: { + pid: process.pid, + startedAtMs: this.startedAtMs, + launchNonce: this.launchNonce, + ...(this.entryPath ? { entryPath: this.entryPath } : {}), + ...(this.appVersion ? { appVersion: this.appVersion } : {}), + ...(this.spawnerExecPath ? { spawnerExecPath: this.spawnerExecPath } : {}) } - : {}) - }) - ) + } + : {}) + }) + if (hello.role === 'stream' && (!existingControl || !capabilitiesMatchControl)) { + socket.end(response) + return + } if (hello.role === 'control') { + socket.write(response) const previous = this.clients.get(hello.clientId) + if (previous) { + this.releaseHeldProducerPausesForClient(hello.clientId) + } const client: ConnectedClient = { clientId: hello.clientId, controlSocket: socket, streamSocket: null, - authenticatedPairEstablished: false + authenticatedPairEstablished: false, + capabilities } this.clients.set(hello.clientId, client) this.setupControlSocket(socket, hello.clientId) @@ -636,6 +851,7 @@ export class DaemonServer { this.cancelPendingPtySpawnPreparationsForClient(hello.clientId) this.historySeedTransfers.clearOwner(hello.clientId) this.recordFullyAuthenticatedDisconnect(previous.authenticatedPairEstablished) + this.releaseAuthenticatedPolicyTransport(previous) // Why: tear down the old sockets after installing the new owner so a stale close can't delete the replacement. previous.streamSocket?.destroy() previous.controlSocket.destroy() @@ -649,6 +865,10 @@ export class DaemonServer { } this.setupStreamSocket(socket, client) client.authenticatedPairEstablished = true + if (isDaemonConsumerProofGrant(client.capabilities?.terminalAuthorityConsumerProof)) { + this.installAuthenticatedPolicyTransport(client) + } + socket.write(response) // Why: one-shot health probes authenticate only a control socket; they are not fresh app activity. this.onAuthenticatedClientPair() // A complete app connection (unlike a probe) re-owns the endpoint and cancels pending retirement. @@ -658,6 +878,225 @@ export class DaemonServer { } } + private negotiateHelloCapabilities(requested: unknown): DaemonHelloCapabilities { + const parsed = parseDaemonHelloCapabilities(requested) + if ( + parsed.terminalSessionAuthority !== TERMINAL_SESSION_AUTHORITY_HELLO_CAPABILITY || + !this.terminalSessionAuthorityAdmissionReady() + ) { + return {} + } + const proof = parsed.terminalAuthorityConsumerProof + if (proof) { + if ( + 'versions' in proof && + proof.versions.includes(TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION) && + this.terminalSessionAuthorityHostId + ) { + return { + terminalSessionAuthority: TERMINAL_SESSION_AUTHORITY_HELLO_CAPABILITY, + terminalAuthorityConsumerProof: { + version: TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION, + authorityHostId: this.terminalSessionAuthorityHostId, + ...(proof.retirementVersions?.includes(TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_VERSION) + ? { retirementVersion: TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_VERSION } + : {}) + } + } + } + return {} + } + // Why a proofless peer negotiates nothing: it would otherwise name its own consumer identity, and + // a client-chosen label must never reach a durable authority claim. It stays on the legacy path. + return {} + } + + private terminalSessionAuthorityAdmissionReady(): boolean { + if ( + !supportsTerminalSessionAuthority(this.protocolVersion) || + !this.terminalSessionAuthorityEnabled || + !this.terminalSessionAuthorityCapabilityReadiness + ) { + return false + } + try { + return this.terminalSessionAuthorityCapabilityReadiness.hostEffectConsumerInstalled() + } catch { + return false + } + } + + private assertAuthorityPolicyAdmission( + client: ConnectedClient | undefined, + value: unknown + ): TerminalAuthorityPolicyConsumerConnection { + const access = parseTerminalSessionAuthorityPtyAccess(value) + const authenticated = access + ? client?.authorityNamespaceSessions?.get(authorityNamespaceKey(access.namespace)) + : undefined + if ( + access && + authenticated && + client?.authenticatedPairEstablished && + client.streamSocket && + authenticated.session.policyConsumer.isInstalled(access.namespace) + ) { + authenticated.session.policyConsumer.assertInstalled(access.namespace) + return authenticated.session.policyConsumer + } + throw new Error('terminal session authority policy admission is unavailable') + } + + private installAuthenticatedPolicyTransport(client: ConnectedClient): void { + this.releaseAuthenticatedPolicyTransport(client) + const token = Object.freeze({}) + client.authorityConsumerTransport = Object.freeze({ + connectionGrantId: randomUUID(), + principal: `daemon-token:v1:${createHash('sha256').update(this.token).digest('base64url')}`, + capability: TERMINAL_AUTHORITY_CONSUMER_PROOF_CAPABILITY, + token + }) + client.authorityNamespaceSessions = new Map() + client.authorityPendingPreparations = new Set() + } + + private releaseAuthenticatedPolicyTransport(client: ConnectedClient): void { + const transport = client.authorityConsumerTransport + client.authorityConsumerTransport = undefined + for (const pending of client.authorityPendingPreparations ?? []) { + pending.active = false + pending.acceptances.close() + const preparation = pending.preparation + if (preparation) { + const cause = new Error('terminal authority consumer transport released during admission') + void joinTerminalAuthorityRollbackFailure(cause, () => preparation.rollback()).catch( + (failure) => { + if (failure !== cause) { + this.reportTerminalSessionAuthorityFailure(failure) + } + } + ) + } + } + client.authorityPendingPreparations?.clear() + for (const installed of client.authorityNamespaceSessions?.values() ?? []) { + installed.acceptances.close() + installed.session.disconnect() + } + client.authorityNamespaceSessions?.clear() + client.authorityPendingPreparations = undefined + client.authorityNamespaceSessions = undefined + if (transport) { + this.terminalSessionAuthorityPtyOwner?.releaseAuthenticatedPolicyConsumerTransport( + transport.token + ) + } + } + + private reportTerminalSessionAuthorityFailure(error: unknown): void { + const failure = error instanceof Error ? error : new Error(String(error)) + const causes = + failure instanceof AggregateError + ? failure.errors.map((cause) => (cause instanceof Error ? cause.message : String(cause))) + : undefined + this.log.log('terminal-session-authority-rollback-failed', { + error: failure.message, + ...(causes ? { causes } : {}) + }) + this.onTerminalSessionAuthorityFailure?.(failure) + } + + private requireAuthenticatedConsumerTransport( + client: ConnectedClient | undefined + ): TerminalAuthorityAuthenticatedConsumerTransport { + const capability = client?.capabilities?.terminalAuthorityConsumerProof + const transport = client?.authorityConsumerTransport + if ( + !client?.authenticatedPairEstablished || + !client.streamSocket || + !isDaemonConsumerProofGrant(capability) || + !transport + ) { + throw new Error('terminal authority consumer proof transport is unavailable') + } + return transport + } + + private requireAuthenticatedConsumerRetirementTransport( + client: ConnectedClient | undefined + ): TerminalAuthorityAuthenticatedConsumerTransport { + const capability = client?.capabilities?.terminalAuthorityConsumerProof + if ( + !isDaemonConsumerProofGrant(capability) || + capability.retirementVersion !== TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_VERSION + ) { + throw new Error('terminal authority consumer retirement is unsupported') + } + return this.requireAuthenticatedConsumerTransport(client) + } + + private authenticatedPolicyConsumerResolver( + client: ConnectedClient + ): TerminalAuthorityPolicyConsumerResolver { + const transport = this.requireAuthenticatedConsumerTransport(client) + return Object.freeze({ + forNamespace: (namespace: TerminalSessionAuthorityPtyAccess['namespace']) => { + if ( + this.clients.get(client.clientId) !== client || + client.authorityConsumerTransport?.token !== transport.token + ) { + throw new Error('terminal authority consumer proof transport is stale') + } + const installed = client.authorityNamespaceSessions?.get(authorityNamespaceKey(namespace)) + if (!installed) { + throw new Error('terminal authority namespace consumer is not installed') + } + installed.session.policyConsumer.assertInstalled(namespace) + return installed.session.policyConsumer + } + }) + } + + private publishAuthenticatedAuthorityEvent( + client: ConnectedClient, + namespaceId: string, + event: 'terminalAuthorityNamespaceOutcomeBoundary' | 'terminalAuthorityNamespaceOutcome', + payload: + | TerminalAuthorityNamespaceOutcomeBoundary + | TerminalAuthorityNamespaceOutcomePublication + ): Promise { + this.requireAuthenticatedConsumerTransport(client) + const sessionId = `authority-outcomes:${namespaceId}` + return event === 'terminalAuthorityNamespaceOutcomeBoundary' + ? this.streamDataBatcher.enqueueSettledControlEvent(client.clientId, sessionId, { + type: 'event', + event, + sessionId, + payload: payload as TerminalAuthorityNamespaceOutcomeBoundary + }) + : this.streamDataBatcher.enqueueSettledControlEvent(client.clientId, sessionId, { + type: 'event', + event, + sessionId, + payload: payload as TerminalAuthorityNamespaceOutcomePublication + }) + } + + private failAuthenticatedAuthorityNamespace( + client: ConnectedClient, + namespace: TerminalSessionAuthorityPtyAccess['namespace'], + _error: Error + ): void { + const key = authorityNamespaceKey(namespace) + const installed = client.authorityNamespaceSessions?.get(key) + if (!installed) { + return + } + client.authorityNamespaceSessions?.delete(key) + installed.acceptances.close() + installed.session.disconnect() + } + private setupControlSocket(socket: Socket, clientId: string): void { // Why: decode as a UTF-8 stream so emoji/Unicode split across chunks isn't corrupted. const decoder = new StringDecoder('utf8') @@ -679,6 +1118,8 @@ export class DaemonServer { // its daemon PTY, orphaning a durable, unattached session — cancel its preps (F4). this.cancelPendingPtySpawnPreparationsForClient(clientId) this.historySeedTransfers.clearOwner(clientId) + this.releaseHeldProducerPausesForClient(clientId) + this.releaseAuthenticatedPolicyTransport(client) const wasFullyAuthenticated = client.authenticatedPairEstablished this.streamDataBatcher.clear(clientId) client.streamSocket?.destroy() @@ -718,6 +1159,8 @@ export class DaemonServer { // Why: a preflight that outlives its output channel would create an unattached daemon PTY. this.cancelPendingPtySpawnPreparationsForClient(client.clientId) this.streamDataBatcher.clear(client.clientId) + this.releaseHeldProducerPausesForClient(client.clientId) + this.releaseAuthenticatedPolicyTransport(client) client.streamSocket = null } @@ -843,10 +1286,338 @@ export class DaemonServer { } } + private rememberHeldProducerPause(clientId: string, lease: HeldProducerPauseLease): void { + const leases = this.heldProducerPauseLeasesByClient.get(clientId) ?? new Map() + leases.set(heldProducerPauseLeaseKey(lease), lease) + this.heldProducerPauseLeasesByClient.set(clientId, leases) + } + + private forgetHeldProducerPause(clientId: string, lease: HeldProducerPauseLease): void { + const leases = this.heldProducerPauseLeasesByClient.get(clientId) + if (!leases) { + return + } + leases.delete(heldProducerPauseLeaseKey(lease)) + if (leases.size === 0) { + this.heldProducerPauseLeasesByClient.delete(clientId) + } + } + + private releaseHeldProducerPausesForClient(clientId: string): void { + const leases = this.heldProducerPauseLeasesByClient.get(clientId) + this.heldProducerPauseLeasesByClient.delete(clientId) + if (!leases) { + return + } + const releasedSessions = new Set() + for (const lease of leases.values()) { + const sessionKey = JSON.stringify([lease.sessionId, lease.incarnationId]) + if (releasedSessions.has(sessionKey)) { + continue + } + releasedSessions.add(sessionKey) + this.host.releaseExactHeldProducerPauses(lease.sessionId, lease.incarnationId, clientId) + } + } + + private forgetHeldProducerPausesForSession(sessionId: string, incarnationId: string): void { + for (const [clientId, leases] of this.heldProducerPauseLeasesByClient) { + for (const [key, lease] of leases) { + if (lease.sessionId === sessionId && lease.incarnationId === incarnationId) { + leases.delete(key) + } + } + if (leases.size === 0) { + this.heldProducerPauseLeasesByClient.delete(clientId) + } + } + } + + private enqueueLegacyTransientFact( + sessionId: string, + fact: DaemonTransientFact, + exactClientId?: string + ): void { + const clientId = exactClientId ?? this.streamClientIdBySessionId.get(sessionId) + if (!clientId) { + return + } + this.streamDataBatcher.enqueueControlEvent(clientId, sessionId, { + type: 'event', + event: 'transientFact', + sessionId, + payload: fact + }) + } + + private recordAuthorityTransientFact( + sessionId: string, + access: TerminalSessionAuthorityPtyAccess, + fact: DaemonTransientFact + ): void { + const recording = this.host.recordSemanticOutcomeExact(sessionId, access, fact) + this.pendingAuthoritySemanticOutcomes.add(recording) + void recording.then( + () => this.pendingAuthoritySemanticOutcomes.delete(recording), + (error) => { + this.pendingAuthoritySemanticOutcomes.delete(recording) + this.log.log('terminal-session-authority-semantic-outcome-failed', { + sessionId, + error: error instanceof Error ? error.message : String(error) + }) + } + ) + } + private async routeRequest(clientId: string, request: DaemonRequest): Promise { const client = this.clients.get(clientId) switch (request.type) { + case DAEMON_TERMINAL_AUTHORITY_CONSUMER_RESOLVE_NAMESPACE_REQUEST: { + this.requireAuthenticatedConsumerTransport(client) + const owner = this.terminalSessionAuthorityPtyOwner + const worktreeId = request.payload.worktreeId + if (!owner || typeof worktreeId !== 'string') { + throw new Error('terminal authority consumer namespace resolution is unavailable') + } + return await owner.resolvePolicyConsumerNamespace(worktreeId) + } + + case DAEMON_TERMINAL_AUTHORITY_CONSUMER_CHALLENGE_REQUEST: { + const transport = this.requireAuthenticatedConsumerTransport(client) + const owner = this.terminalSessionAuthorityPtyOwner + if (!owner) { + throw new Error('terminal authority consumer proof is unavailable') + } + return await owner.issuePolicyConsumerChallenge(request.payload, transport) + } + + case DAEMON_TERMINAL_AUTHORITY_CONSUMER_GRANT_REQUEST: { + const transport = this.requireAuthenticatedConsumerTransport(client) + const owner = this.terminalSessionAuthorityPtyOwner + const proof = parseTerminalAuthorityNamespaceAdmissionProof(request.payload) + if (!owner || !proof || !client) { + throw new Error('terminal authority consumer proof is unavailable') + } + const namespace = proof.challenge.namespace + const acceptances = new TerminalSessionAuthorityBoundaryAcceptances() + const pending: AuthenticatedAuthorityNamespacePreparation = { + active: true, + preparation: null, + acceptances, + requestId: proof.challenge.requestId, + namespace: Object.freeze({ ...namespace }), + connectionGrantId: proof.challenge.connectionGrantId, + consumer: Object.freeze({ + consumerId: terminalAuthorityHostAppConsumerId( + namespace.authorityHostId, + Uint8Array.from(Buffer.from(proof.challenge.appPublicKeyB64, 'base64')) + ), + consumerIncarnationId: proof.challenge.candidateProcessIncarnationId + }) + } + client.authorityPendingPreparations?.add(pending) + try { + pending.preparation = await owner.prepareAuthenticatedPolicyConsumerNamespace( + proof, + transport, + { + publishBoundary: async (boundary) => { + const accepted = acceptances.wait(boundary) + try { + await this.publishAuthenticatedAuthorityEvent( + client, + namespace.namespaceId, + 'terminalAuthorityNamespaceOutcomeBoundary', + boundary + ) + await accepted + } catch (error) { + acceptances.close(error instanceof Error ? error : new Error(String(error))) + throw error + } + }, + publishOutcome: (publication) => + this.publishAuthenticatedAuthorityEvent( + client, + namespace.namespaceId, + 'terminalAuthorityNamespaceOutcome', + publication + ), + onFailure: (error) => + this.failAuthenticatedAuthorityNamespace(client, namespace, error) + } + ) + } catch (error) { + pending.active = false + client.authorityPendingPreparations?.delete(pending) + acceptances.close() + throw error + } + if ( + !pending.active || + this.clients.get(clientId) !== client || + client.authorityConsumerTransport?.token !== transport.token + ) { + pending.active = false + client.authorityPendingPreparations?.delete(pending) + acceptances.close() + await pending.preparation.rollback() + throw new Error('terminal authority consumer proof transport is unavailable') + } + const preparation = pending.preparation + try { + const session = await preparation.commit() + if ( + !pending.active || + this.clients.get(clientId) !== client || + client.authorityConsumerTransport?.token !== transport.token + ) { + session.disconnect() + throw new Error('terminal authority consumer proof transport is unavailable') + } + const key = authorityNamespaceKey(namespace) + const existing = client.authorityNamespaceSessions?.get(key) + if (existing?.session === session) { + acceptances.close() + } else { + existing?.acceptances.close() + existing?.session.disconnect() + client.authorityNamespaceSessions?.set(key, Object.freeze({ session, acceptances })) + } + return preparation.grant + } catch (error) { + acceptances.close() + return await joinTerminalAuthorityRollbackFailure(error, () => preparation.rollback()) + } finally { + client.authorityPendingPreparations?.delete(pending) + pending.active = false + } + } + + case DAEMON_TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_CHALLENGE_REQUEST: { + const transport = this.requireAuthenticatedConsumerRetirementTransport(client) + const owner = this.terminalSessionAuthorityPtyOwner + const start = parseTerminalAuthorityConsumerRetirementStart(request.payload) + if (!owner || !start) { + throw new Error('terminal authority consumer retirement is unavailable') + } + return await owner.issuePolicyConsumerRetirementChallenge(start, transport) + } + + case DAEMON_TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_REQUEST: { + const transport = this.requireAuthenticatedConsumerRetirementTransport(client) + const owner = this.terminalSessionAuthorityPtyOwner + const proof = parseTerminalAuthorityConsumerRetirementProof(request.payload) + if (!owner || !proof || !client) { + throw new Error('terminal authority consumer retirement is unavailable') + } + const result = await owner.retireAuthenticatedPolicyConsumer(proof, transport) + const key = authorityNamespaceKey(result.namespace) + const installed = client.authorityNamespaceSessions?.get(key) + if (installed?.session.grant.consumer.consumerId === result.consumerId) { + client.authorityNamespaceSessions?.delete(key) + installed.acceptances.close() + installed.session.disconnect() + } + return result + } + + case DAEMON_TERMINAL_AUTHORITY_CONSUMER_CANCEL_REQUEST: { + const transport = this.requireAuthenticatedConsumerTransport(client) + const cancellation = parseTerminalAuthorityNamespaceAdmissionCancellation(request.payload) + if ( + !client || + !cancellation || + cancellation.connectionGrantId !== transport.connectionGrantId + ) { + throw new Error('terminal authority namespace cancellation is unauthorized') + } + let canceled = false + for (const pending of client.authorityPendingPreparations ?? []) { + if ( + sameAuthorityNamespace(pending.namespace, cancellation.namespace) && + pending.requestId === cancellation.requestId && + pending.connectionGrantId === cancellation.connectionGrantId && + sameTerminalAuthorityPolicyConsumer(pending.consumer, cancellation.consumer) + ) { + client.authorityPendingPreparations?.delete(pending) + pending.active = false + pending.acceptances.close() + await pending.preparation?.rollback() + canceled = true + } + } + const key = authorityNamespaceKey(cancellation.namespace) + const installed = client.authorityNamespaceSessions?.get(key) + if ( + installed && + installed.session.grant.requestId === cancellation.requestId && + sameTerminalAuthorityPolicyConsumer( + installed.session.grant.consumer, + cancellation.consumer + ) + ) { + client.authorityNamespaceSessions?.delete(key) + installed.acceptances.close() + installed.session.disconnect() + canceled = true + } + return { canceled } + } + + case 'acceptTerminalAuthorityNamespaceBoundary': { + const acceptance = parseTerminalAuthorityNamespaceBoundaryAcceptance(request.payload) + const authenticated = acceptance + ? client?.authorityNamespaceSessions?.get(authorityNamespaceKey(acceptance.namespace)) + : undefined + const pending = acceptance + ? [...(client?.authorityPendingPreparations ?? [])].find( + (entry) => + entry.active && + sameAuthorityNamespace(entry.namespace, acceptance.namespace) && + sameTerminalAuthorityPolicyConsumer(entry.consumer, acceptance.consumer) + ) + : undefined + if ( + acceptance && + (pending || + (authenticated && + sameTerminalAuthorityPolicyConsumer( + authenticated.session.policyConsumer.identity, + acceptance.consumer + ))) + ) { + ;(pending?.acceptances ?? authenticated!.acceptances).accept(acceptance) + return { acceptedBoundaryId: acceptance.boundaryId } + } + throw new Error('terminal authority namespace boundary acceptance is unauthorized') + } + + case 'ackTerminalAuthorityNamespaceOutcome': { + const ack = parseTerminalAuthorityNamespaceOutcomeAck(request.payload) + const authenticated = ack + ? client?.authorityNamespaceSessions?.get(authorityNamespaceKey(ack.namespace)) + : undefined + if ( + ack && + authenticated && + sameTerminalAuthorityPolicyConsumer( + authenticated.session.policyConsumer.identity, + ack.consumer + ) + ) { + return { + acknowledgedSequence: await authenticated.session.policyConsumer.acknowledge(ack) + } + } + throw new Error('terminal authority namespace outcome ACK is unauthorized') + } + + case 'retireTerminalAuthorityPolicyConsumer': { + throw new Error('terminal authority policy consumer retirement is unsupported') + } + case 'startHistorySeedTransfer': { if (!client?.authenticatedPairEstablished || client.streamSocket === null) { throw new Error('Daemon client connection is incomplete; reconnect') @@ -880,10 +1651,39 @@ export class DaemonServer { // Why: a control-only replacement can't own terminal admission or erase the prior client's retirement request. throw new Error('Daemon client connection is incomplete; reconnect') } - this.createOrAttachInFlight++ const p = request.payload + // Why proof is the only admission: a durable authority claim must resolve through an + // authenticated grant, never a consumer label the client picked for itself. + const authorityConsumer: TerminalAuthorityPolicyConsumerSource | null = + isDaemonConsumerProofGrant(client.capabilities?.terminalAuthorityConsumerProof) && + client.authorityConsumerTransport !== undefined && + this.terminalSessionAuthorityAdmissionReady() + ? this.authenticatedPolicyConsumerResolver(client) + : null + const authorityAdmitted = authorityConsumer !== null + if (hasTerminalSessionAuthorityCreateMetadata(p) && !authorityAdmitted) { + throw new Error('terminal_session_authority_unavailable') + } + this.createOrAttachInFlight++ const attachOnly = p.attachOnly === true + const streamBindingNonce = + supportsPtyStreamBinding(this.protocolVersion) && + isPtyStreamBindingNonce(p.streamBindingNonce) + ? p.streamBindingNonce + : null let routedSessionId = p.sessionId + let streamAuthorityAccess: TerminalSessionAuthorityPtyAccess | null | undefined + const routeTransientFact = (fact: DaemonTransientFact): void => { + const capturedAccess = streamAuthorityAccess + if (capturedAccess === undefined) { + throw new Error('terminal_session_authority_semantic_route_missing') + } + if (capturedAccess) { + this.recordAuthorityTransientFact(routedSessionId, capturedAccess, fact) + } else { + this.enqueueLegacyTransientFact(routedSessionId, fact, clientId) + } + } let result: Awaited> try { if ( @@ -927,13 +1727,51 @@ export class DaemonServer { ? { shellReadyTimeoutMs: p.shellReadyTimeoutMs } : {}), ...(p.agentSessionEnsure ? { agentSessionEnsure: p.agentSessionEnsure } : {}), + ...(p.terminalSessionAuthorityVersion !== undefined + ? { terminalSessionAuthorityVersion: p.terminalSessionAuthorityVersion } + : {}), + ...(p.terminalSessionAuthorityOperationId !== undefined + ? { terminalSessionAuthorityOperationId: p.terminalSessionAuthorityOperationId } + : {}), + ...(p.worktreeId !== undefined ? { worktreeId: p.worktreeId } : {}), + ...(p.paneKey !== undefined ? { paneKey: p.paneKey } : {}), + ...(p.paneGeneration !== undefined ? { paneGeneration: p.paneGeneration } : {}), + ...(p.terminalSessionAuthorityAccess !== undefined + ? { terminalSessionAuthorityAccess: p.terminalSessionAuthorityAccess } + : {}), + ...(authorityAdmitted ? { terminalSessionAuthorityNegotiated: true as const } : {}), + ...(authorityAdmitted + ? { + terminalSessionAuthorityPolicyConsumer: authorityConsumer! + } + : {}), onSessionResolved: (sessionId) => { routedSessionId = sessionId }, streamClient: { + onAuthorityAccess: (access) => { + streamAuthorityAccess = access + }, + onIncarnation: (incarnationId) => { + this.streamClientIdBySessionId.set(routedSessionId, clientId) + this.streamDataBatcher.refreshSessionDroppability(routedSessionId) + if (streamBindingNonce) { + this.streamDataBatcher.establishSessionSource(clientId, routedSessionId, { + incarnationId, + streamBindingNonce + }) + } + if (this.transientFactRelay.isBackgrounded(routedSessionId)) { + this.streamDataBatcher.enqueueControlEvent(clientId, routedSessionId, { + type: 'event', + event: 'sessionBackgroundMarker', + sessionId: routedSessionId, + payload: { background: true } + }) + } + }, onData: (data, rawLength = data.length, transformed = false, seq) => { - // Scan BEFORE enqueue: the batcher may drop this chunk, but its facts must be captured regardless. - this.transientFactRelay.onSessionData(routedSessionId, data) + this.transientFactRelay.onSessionData(routedSessionId, data, routeTransientFact) const lastInputAt = this.lastInputAtBySessionId.get(routedSessionId) const isInteractiveOutput = data.length <= DaemonServer.INTERACTIVE_OUTPUT_MAX_CHARS && @@ -964,6 +1802,7 @@ export class DaemonServer { sessionIdSuffix: routedSessionId.slice(-10) }) this.transientFactRelay.onSessionExit(routedSessionId) + this.forgetHeldProducerPausesForSession(routedSessionId, incarnationId) this.streamDataBatcher.refreshSessionDroppability(routedSessionId) this.streamClientIdBySessionId.delete(routedSessionId) this.lastInputAtBySessionId.delete(routedSessionId) @@ -976,17 +1815,6 @@ export class DaemonServer { this.reevaluateIdleShutdown() } routedSessionId = result.agentSessionEnsure?.owner.ptyId ?? p.sessionId - this.streamClientIdBySessionId.set(routedSessionId, clientId) - this.streamDataBatcher.refreshSessionDroppability(routedSessionId) - // Why an attach-time marker: background resync can precede this attach, so scan suppression must start at the new stream's head. - if (this.transientFactRelay.isBackgrounded(routedSessionId)) { - this.streamDataBatcher.enqueueControlEvent(clientId, routedSessionId, { - type: 'event', - event: 'sessionBackgroundMarker', - sessionId: routedSessionId, - payload: { background: true } - }) - } this.log.log(result.isNew ? 'session-created' : 'session-attached', { sessionId: routedSessionId, pid: result.pid @@ -997,10 +1825,14 @@ export class DaemonServer { pid: result.pid, shellState: result.shellState, incarnationId: result.incarnationId, + ...(streamBindingNonce ? { streamBindingNonce } : {}), ...(result.launchAgent ? { launchAgent: result.launchAgent } : {}), wslDistro: result.wslDistro, ...(result.historySeeded !== undefined ? { historySeeded: result.historySeeded } : {}), - ...(result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {}) + ...(result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {}), + ...(result.terminalSessionAuthorityAccess + ? { terminalSessionAuthorityAccess: result.terminalSessionAuthorityAccess } + : {}) } } @@ -1026,6 +1858,35 @@ export class DaemonServer { } return {} + case 'writeExact': { + const accepted = + typeof request.payload.data === 'string' && + this.host.writeExact( + request.payload.sessionId, + request.payload.incarnationId, + request.payload.data + ) + if (accepted) { + this.lastInputAtBySessionId.set(request.payload.sessionId, performance.now()) + } + return { accepted } + } + + case 'writeAuthorityExact': { + this.assertAuthorityPolicyAdmission(client, request.payload.authorityAccess) + const accepted = + typeof request.payload.data === 'string' && + this.host.writeAuthorityExact( + request.payload.sessionId, + request.payload.authorityAccess, + request.payload.data + ) + if (accepted) { + this.lastInputAtBySessionId.set(request.payload.sessionId, performance.now()) + } + return { accepted } + } + case 'resize': try { this.host.resize(request.payload.sessionId, request.payload.cols, request.payload.rows) @@ -1037,11 +1898,71 @@ export class DaemonServer { } return {} + case 'resizeExact': + return { + accepted: this.host.resizeExact( + request.payload.sessionId, + request.payload.incarnationId, + request.payload.cols, + request.payload.rows + ) + } + + case 'resizeAuthorityExact': + this.assertAuthorityPolicyAdmission(client, request.payload.authorityAccess) + return { + accepted: this.host.resizeAuthorityExact( + request.payload.sessionId, + request.payload.authorityAccess, + request.payload.cols, + request.payload.rows + ) + } + case 'pausePty': + if (supportsExactHeldProducerPause(this.protocolVersion)) { + const pause = parseHeldProducerPausePayload(request.payload) + if (pause.mode === 'invalid') { + return { accepted: false } + } + if (pause.mode === 'exact') { + if (!client?.authenticatedPairEstablished || client.streamSocket === null) { + return { accepted: false } + } + const accepted = this.host.acquireExactHeldProducerPause( + pause.sessionId, + pause.incarnationId, + clientId, + pause.token + ) + if (accepted) { + this.rememberHeldProducerPause(clientId, pause) + } + return { accepted } + } + } this.host.pauseProducer(request.payload.sessionId) return {} case 'resumePty': + if (supportsExactHeldProducerPause(this.protocolVersion)) { + const pause = parseHeldProducerPausePayload(request.payload) + if (pause.mode === 'invalid') { + return { accepted: false } + } + if (pause.mode === 'exact') { + const accepted = this.host.releaseExactHeldProducerPause( + pause.sessionId, + pause.incarnationId, + clientId, + pause.token + ) + if (accepted) { + this.forgetHeldProducerPause(clientId, pause) + } + return { accepted } + } + } this.host.resumeProducer(request.payload.sessionId) return {} @@ -1118,10 +2039,58 @@ export class DaemonServer { return {} } + case 'killExact': { + const accepted = await this.host.killExact( + request.payload.sessionId, + request.payload.incarnationId, + { immediate: request.payload.immediate } + ) + if (accepted) { + this.lastInputAtBySessionId.delete(request.payload.sessionId) + } + return { accepted } + } + + case 'killAuthorityExact': { + const policyConsumer = this.assertAuthorityPolicyAdmission( + client, + request.payload.authorityAccess + ) + const accepted = await this.host.killAuthorityExact( + request.payload.sessionId, + request.payload.authorityAccess, + policyConsumer, + { immediate: request.payload.immediate } + ) + if (accepted) { + this.lastInputAtBySessionId.delete(request.payload.sessionId) + } + return { accepted } + } + case 'signal': this.host.signal(request.payload.sessionId, request.payload.signal) return {} + case 'signalExact': + return { + accepted: this.host.signalExact( + request.payload.sessionId, + request.payload.incarnationId, + request.payload.signal + ) + } + + case 'signalAuthorityExact': + this.assertAuthorityPolicyAdmission(client, request.payload.authorityAccess) + return { + accepted: this.host.signalAuthorityExact( + request.payload.sessionId, + request.payload.authorityAccess, + request.payload.signal + ) + } + case 'detach': // Note: detach token handling simplified — full impl would track tokens per client this.log.log('session-detached', { @@ -1149,6 +2118,23 @@ export class DaemonServer { this.host.clearScrollback(request.payload.sessionId) return {} + case 'clearBufferExact': + return { + accepted: this.host.clearScrollbackExact( + request.payload.sessionId, + request.payload.incarnationId + ) + } + + case 'clearBufferAuthorityExact': + this.assertAuthorityPolicyAdmission(client, request.payload.authorityAccess) + return { + accepted: this.host.clearScrollbackAuthorityExact( + request.payload.sessionId, + request.payload.authorityAccess + ) + } + case 'listSessions': return { sessions: this.host.listSessions() } diff --git a/src/main/daemon/daemon-stream-data-batcher.test.ts b/src/main/daemon/daemon-stream-data-batcher.test.ts index 12eec59f758..0a13f3ea134 100644 --- a/src/main/daemon/daemon-stream-data-batcher.test.ts +++ b/src/main/daemon/daemon-stream-data-batcher.test.ts @@ -38,6 +38,50 @@ function nonSentinelWrites(streamSocket: { write: ReturnType }): P } describe('DaemonStreamDataBatcher', () => { + it('settles an exit only after prior session data reaches the stream sink', async () => { + const { batcher, streamSocket } = createBatcher() + const callbacks: ((error?: Error | null) => void)[] = [] + streamSocket.write.mockImplementation((_line, callback) => { + if (typeof callback === 'function') { + callbacks.push(callback) + } + return true + }) + + batcher.enqueue('client-1', 'session-1', 'final output') + const settled = batcher.enqueueSettledControlEvent('client-1', 'session-1', { + type: 'event', + event: 'exit', + sessionId: 'session-1', + payload: { code: 0, incarnationId: 'incarnation-1' } + }) + + expect(nonSentinelWrites(streamSocket).map((entry) => entry.event)).toEqual(['data', 'exit']) + let completed = false + void settled.then(() => { + completed = true + }) + await Promise.resolve() + expect(completed).toBe(false) + callbacks.at(-1)?.() + await expect(settled).resolves.toBeUndefined() + }) + + it('rejects a pending settled event when its stream queue is cleared', async () => { + const { batcher, streamSocket } = createBatcher() + streamSocket.writableLength = 128 * 1024 + const pending = batcher.enqueueSettledControlEvent('client-1', 'session-1', { + type: 'event', + event: 'exit', + sessionId: 'session-1', + payload: { code: 0 } + }) + + batcher.clear('client-1') + + await expect(pending).rejects.toThrow('stream queue was cleared') + }) + it('coalesces background output before writing daemon stream events', () => { vi.useFakeTimers() try { @@ -115,6 +159,41 @@ describe('DaemonStreamDataBatcher', () => { } }) + it('uses per-session accounting without scanning the hot-path queue', () => { + vi.useFakeTimers() + try { + const { batcher } = createBatcher() + batcher.enqueue('client-1', 'session-1', 'bulk') + const pending = ( + batcher as unknown as { + pendingByClient: Map< + string, + { queue: unknown[]; queuedCharsBySession: Map } + > + } + ).pendingByClient.get('client-1')! + pending.queue = new Proxy(pending.queue, { + get(target, property, receiver) { + if (property === Symbol.iterator) { + throw new Error('hot-path queue scan') + } + return Reflect.get(target, property, receiver) + } + }) + pending.queuedCharsBySession.set('session-1', 2048) + + expect(() => + batcher.enqueue('client-1', 'session-1', 'echo', { + flushImmediately: true, + flushMaxChars: 1024 + }) + ).not.toThrow() + batcher.clear() + } finally { + vi.useRealTimers() + } + }) + it('flushes interactive output for one session while another session has large pending output', () => { vi.useFakeTimers() try { @@ -500,6 +579,26 @@ describe('DaemonStreamDataBatcher', () => { } }) + it('writes one source marker between old and newly attached output', () => { + vi.useFakeTimers() + try { + const { batcher, streamSocket } = createBatcher() + batcher.enqueue('client-1', 'session-1', 'old') + batcher.establishSessionSource('client-1', 'session-1', { + incarnationId: 'inc-new', + streamBindingNonce: 'nonce-new' + }) + batcher.enqueue('client-1', 'session-1', 'new') + vi.advanceTimersByTime(2) + + const messages = nonSentinelWrites(streamSocket) + expect(messages.map((message) => message.event)).toEqual(['data', 'sessionSource', 'data']) + expect(messages.filter((message) => message.event === 'sessionSource')).toHaveLength(1) + } finally { + vi.useRealTimers() + } + }) + it("holds a control event behind its session's held bulk (order latch)", () => { vi.useFakeTimers() try { diff --git a/src/main/daemon/daemon-stream-data-batcher.ts b/src/main/daemon/daemon-stream-data-batcher.ts index 53e17002179..9fde37e33d6 100644 --- a/src/main/daemon/daemon-stream-data-batcher.ts +++ b/src/main/daemon/daemon-stream-data-batcher.ts @@ -1,13 +1,14 @@ import type { Socket } from 'node:net' -import { encodeNdjson, NDJSON_MAX_LINE_BYTES } from './ndjson' -import { recordDaemonStreamBacklogEvent } from './daemon-stream-backlog-probe' +import { NDJSON_MAX_LINE_BYTES } from './ndjson' import { - clampToSafeSplitIndex, - encodeStreamDataEvent, - writeStreamDataEvents -} from './daemon-stream-data-split' + flushDaemonStreamData, + writeDaemonStreamControl, + type DaemonStreamDataFlushArguments +} from './daemon-stream-data-flush' +import { flushDaemonStreamSession } from './daemon-stream-session-flush' import type { PendingStreamDataBatch } from './daemon-stream-keep-tail-drop' import type { DaemonEvent } from './types' +import type { PtyStreamSource } from '../../shared/pty-stream-binding-protocol' import { appendDaemonStreamData, type DaemonStreamEnqueueOptions } from './daemon-stream-data-entry' import { evaluateDroppableEnqueue, @@ -21,17 +22,6 @@ type StreamDataClient = { // 2ms: each chunk waits a half-window here AND again in main's PTY batch; a smaller interval still coalesces bursts while cutting the fixed latency tax (~8ms of the measured ~19ms DSR-under-load latency). const STREAM_DATA_BATCH_INTERVAL_MS = 2 -// Shallow socket: the stream is one FIFO, so a deep buffer buries a visible pane's echo behind other panes' bulk; bulk stops here and is HELD (flushSession can jump it), bounding echo latency. -// 128KB stays above the socket's ~16KB highWaterMark so a held state implies a false write() and thus a guaranteed 'drain' wake-up. -const SHALLOW_SOCKET_WRITE_GATE_BYTES = - process.env.ORCA_DAEMON_SHALLOW_SOCKET_GATE === '0' ? Number.POSITIVE_INFINITY : 128 * 1024 -// Sliced writes: a coalesced entry can grow to megabytes; writing it whole would re-deepen the socket past the gate in one call. -const BULK_WRITE_SLICE_CHARS = 64 * 1024 -// Safety valve: past this, write through — bounded daemon memory beats bounded echo latency in the extreme. Must sit FAR above the pacer's pause watermark + overshoot (~5MB) or an engaged valve buries interactive echo behind the whole backlog. -const HELD_WRITE_THROUGH_TOTAL_CHARS = 32 * 1024 * 1024 -// Small-session bypass: a few-KB session (echo, redraws, query replies) is never the flood, so it must not wait FIFO behind others' megabytes; backstops the 100ms interactive fast-path, which misses under event-loop load. -const SMALL_SESSION_HOLD_BYPASS_CHARS = 4 * 1024 - type DaemonStreamDataBatcherOptions = { maxLineBytes?: number /** Fires after each stream-socket write — the only place backlog grows, so the backlog pacer checks its watermark here. */ @@ -44,6 +34,7 @@ type DaemonStreamDataBatcherOptions = { export class DaemonStreamDataBatcher { private pendingByClient = new Map() + private pendingSettlementsByClient = new Map void>>() private getClient: (clientId: string) => StreamDataClient | undefined private maxLineBytes: number private onAfterSocketWrite: (() => void) | undefined @@ -86,7 +77,7 @@ export class DaemonStreamDataBatcher { if ( options.flushImmediately === true && - this.queuedCharsForSession(batch, sessionId) <= + (batch.queuedCharsBySession.get(sessionId) ?? 0) <= (options.flushMaxChars ?? Number.POSITIVE_INFINITY) ) { this.flushSession(clientId, sessionId) @@ -110,6 +101,56 @@ export class DaemonStreamDataBatcher { } } + enqueueSettledControlEvent( + clientId: string, + sessionId: string, + control: DaemonEvent + ): Promise { + const client = this.getClient(clientId) + if (!client?.streamSocket || client.streamSocket.destroyed) { + return Promise.reject(new Error('daemon stream consumer is unavailable')) + } + return new Promise((resolve, reject) => { + const batch = this.getOrCreateBatch(clientId) + const pending = this.pendingSettlementsByClient.get(clientId) ?? new Set() + let settled = false + const settle = (error: Error | null): void => { + if (settled) { + return + } + settled = true + pending.delete(settle) + if (pending.size === 0) { + this.pendingSettlementsByClient.delete(clientId) + } + if (error) { + reject(error) + } else { + resolve() + } + } + pending.add(settle) + this.pendingSettlementsByClient.set(clientId, pending) + batch.queue.push({ + sessionId, + data: '', + control, + settle + }) + this.flushSession(clientId, sessionId) + }) + } + + establishSessionSource(clientId: string, sessionId: string, source: PtyStreamSource): void { + this.flushSession(clientId, sessionId) + this.enqueueControlEvent(clientId, sessionId, { + type: 'event', + event: 'sessionSource', + sessionId, + payload: source + }) + } + refreshSessionDroppability(sessionId: string): void { const droppable = this.isSessionDroppable(sessionId) refreshDroppableSessionMembership(this.pendingByClient.values(), sessionId, droppable) @@ -135,128 +176,19 @@ export class DaemonStreamDataBatcher { } flush(clientId: string): void { - const batch = this.pendingByClient.get(clientId) - if (!batch) { - return - } - - if (batch.timer) { - clearTimeout(batch.timer) - batch.timer = null - } - - const client = this.getClient(clientId) - if (!client?.streamSocket || client.streamSocket.destroyed) { - // A vanished stream socket drops the batch — the model owns the bytes and reconnect restores from a snapshot. - this.pendingByClient.delete(clientId) - return - } - - const socket = client.streamSocket - // A session that held an entry must hold all its later entries this pass — writing around a held entry would reorder that session's bytes. - const heldSessions = new Set() - const retained: PendingStreamDataBatch['queue'] = [] - while (batch.queue.length > 0) { - const entry = batch.queue[0] - if (entry.control) { - // Control entries only respect the held-session order latch; at ~100B, writing them onto a deep socket is as harmless as the small-session bypass. - if (heldSessions.has(entry.sessionId)) { - retained.push(entry) - batch.queue.shift() - continue - } - batch.queue.shift() - socket.write(encodeNdjson(entry.control)) - this.onAfterSocketWrite?.() - continue - } - const socketDeep = (socket.writableLength ?? 0) >= SHALLOW_SOCKET_WRITE_GATE_BYTES - if (socketDeep && batch.queuedChars <= HELD_WRITE_THROUGH_TOTAL_CHARS) { - const sessionHeld = batch.queuedCharsBySession.get(entry.sessionId) ?? 0 - if (heldSessions.has(entry.sessionId) || sessionHeld > SMALL_SESSION_HOLD_BYPASS_CHARS) { - // Hold this flooding session's entry; small talkers keep flowing. No timer: a deep socket implies a prior false write(), so 'drain' (routed back to flush) is guaranteed to resume held bulk. - heldSessions.add(entry.sessionId) - retained.push(entry) - batch.queue.shift() - continue - } - } else if (socketDeep) { - // Valve engaged: held bulk exceeded the memory cap, so echo protection is off until it drains — rare enough to log every time. - recordDaemonStreamBacklogEvent('heldWriteThrough', { - heldChars: batch.queuedChars, - socketBufferedBytes: socket.writableLength ?? 0 - }) - } - const end = - entry.transformed || entry.data.length <= BULK_WRITE_SLICE_CHARS - ? entry.data.length - : clampToSafeSplitIndex(entry.data, 0, BULK_WRITE_SLICE_CHARS) - const slice = entry.data.slice(0, end) - const entrySequenceChars = entry.sequenceChars ?? entry.data.length - const sliceSequenceChars = entry.transformed - ? entrySequenceChars - : entrySequenceChars === 0 - ? 0 - : slice.length - if (end >= entry.data.length) { - batch.queue.shift() - } else { - entry.data = entry.data.slice(end) - const remainingSequenceChars = entrySequenceChars - sliceSequenceChars - entry.sequenceChars = - remainingSequenceChars === entry.data.length ? undefined : remainingSequenceChars - } - batch.queuedChars -= slice.length - const sessionHeldAfter = - (batch.queuedCharsBySession.get(entry.sessionId) ?? slice.length) - slice.length - if (sessionHeldAfter <= 0) { - batch.queuedCharsBySession.delete(entry.sessionId) - batch.droppableQueuedSessionIds.delete(entry.sessionId) - } else { - batch.queuedCharsBySession.set(entry.sessionId, sessionHeldAfter) - } - writeStreamDataEvents( - socket, - entry.sessionId, - slice, - this.maxLineBytes, - sliceSequenceChars, - entry.seq, - entry.transformed - ) - this.onAfterSocketWrite?.() - } - if (retained.length > 0) { - batch.queue = retained - // 'drain' only fires when the buffer fully empties (one gate-depth/turn = seconds for multi-MB backlogs); arm a no-op data event whose flush callback re-flushes while bytes are still in flight. - this.armHeldQueueRefill(socket, clientId, retained[0].sessionId) - return - } - this.pendingByClient.delete(clientId) + flushDaemonStreamData(this.flushArguments(), clientId) } private refillArmedClients = new Set() - private armHeldQueueRefill(socket: Socket, clientId: string, sessionId: string): void { - if (this.refillArmedClients.has(clientId) || socket.destroyed) { - return + private flushArguments(): DaemonStreamDataFlushArguments { + return { + pendingByClient: this.pendingByClient, + refillArmedClients: this.refillArmedClients, + getClient: this.getClient, + maxLineBytes: this.maxLineBytes, + onAfterSocketWrite: this.onAfterSocketWrite } - this.refillArmedClients.add(clientId) - // Must be a real protocol no-op line, not an empty write: an empty write's callback fires immediately, defeating the in-flight re-flush. - socket.write(encodeStreamDataEvent(sessionId, ''), () => { - this.refillArmedClients.delete(clientId) - this.flush(clientId) - }) - } - - private queuedCharsForSession(batch: PendingStreamDataBatch, sessionId: string): number { - let chars = 0 - for (const entry of batch.queue) { - if (entry.sessionId === sessionId) { - chars += entry.data.length - } - } - return chars } private flushSession(clientId: string, sessionId: string): void { @@ -265,55 +197,25 @@ export class DaemonStreamDataBatcher { return } - const flushed: PendingStreamDataBatch['queue'] = [] - const retained: PendingStreamDataBatch['queue'] = [] - let flushedChars = 0 - for (const entry of batch.queue) { - if (entry.sessionId === sessionId) { - flushed.push(entry) - flushedChars += entry.data.length - } else { - retained.push(entry) - } - } - if (flushed.length === 0) { + const client = this.getClient(clientId) + const result = flushDaemonStreamSession({ + batch, + socket: client?.streamSocket && !client.streamSocket.destroyed ? client.streamSocket : null, + sessionId, + maxLineBytes: this.maxLineBytes, + onAfterSocketWrite: this.onAfterSocketWrite, + writeControl: writeDaemonStreamControl + }) + if (!result.flushed) { return } - - batch.queue = retained - batch.queuedChars -= flushedChars - batch.queuedCharsBySession.delete(sessionId) - batch.droppableQueuedSessionIds.delete(sessionId) - if (batch.queue.length === 0) { + if (result.queueEmpty) { if (batch.timer) { clearTimeout(batch.timer) batch.timer = null } this.pendingByClient.delete(clientId) } - - const client = this.getClient(clientId) - if (!client?.streamSocket || client.streamSocket.destroyed) { - return - } - - for (const entry of flushed) { - if (entry.control) { - client.streamSocket.write(encodeNdjson(entry.control)) - this.onAfterSocketWrite?.() - } else { - writeStreamDataEvents( - client.streamSocket, - entry.sessionId, - entry.data, - this.maxLineBytes, - entry.sequenceChars ?? entry.data.length, - entry.seq, - entry.transformed - ) - this.onAfterSocketWrite?.() - } - } } clear(clientId?: string): void { @@ -326,7 +228,11 @@ export class DaemonStreamDataBatcher { if (batch?.timer) { clearTimeout(batch.timer) } + for (const settle of this.pendingSettlementsByClient.get(id) ?? []) { + settle(new Error('daemon stream queue was cleared')) + } this.pendingByClient.delete(id) + this.pendingSettlementsByClient.delete(id) } } } diff --git a/src/main/daemon/daemon-stream-data-flush.ts b/src/main/daemon/daemon-stream-data-flush.ts new file mode 100644 index 00000000000..78384386676 --- /dev/null +++ b/src/main/daemon/daemon-stream-data-flush.ts @@ -0,0 +1,150 @@ +import type { Socket } from 'node:net' +import { encodeNdjson } from './ndjson' +import { recordDaemonStreamBacklogEvent } from './daemon-stream-backlog-probe' +import { + clampToSafeSplitIndex, + encodeStreamDataEvent, + writeStreamDataEvents +} from './daemon-stream-data-split' +import type { PendingStreamDataBatch } from './daemon-stream-keep-tail-drop' + +const SHALLOW_SOCKET_WRITE_GATE_BYTES = + process.env.ORCA_DAEMON_SHALLOW_SOCKET_GATE === '0' ? Number.POSITIVE_INFINITY : 128 * 1024 +const BULK_WRITE_SLICE_CHARS = 64 * 1024 +const HELD_WRITE_THROUGH_TOTAL_CHARS = 32 * 1024 * 1024 +const SMALL_SESSION_HOLD_BYPASS_CHARS = 4 * 1024 + +export type DaemonStreamDataFlushArguments = { + pendingByClient: Map + refillArmedClients: Set + getClient: (clientId: string) => { streamSocket: Socket | null } | undefined + maxLineBytes: number + onAfterSocketWrite: (() => void) | undefined +} + +export function flushDaemonStreamData( + args: DaemonStreamDataFlushArguments, + clientId: string +): void { + const batch = args.pendingByClient.get(clientId) + if (!batch) { + return + } + + if (batch.timer) { + clearTimeout(batch.timer) + batch.timer = null + } + + const client = args.getClient(clientId) + if (!client?.streamSocket || client.streamSocket.destroyed) { + args.pendingByClient.delete(clientId) + return + } + + const socket = client.streamSocket + const heldSessions = new Set() + const retained: PendingStreamDataBatch['queue'] = [] + while (batch.queue.length > 0) { + const entry = batch.queue[0] + if (entry.control) { + if (heldSessions.has(entry.sessionId)) { + retained.push(entry) + batch.queue.shift() + continue + } + batch.queue.shift() + writeDaemonStreamControl(socket, entry) + args.onAfterSocketWrite?.() + continue + } + const socketDeep = (socket.writableLength ?? 0) >= SHALLOW_SOCKET_WRITE_GATE_BYTES + if (socketDeep && batch.queuedChars <= HELD_WRITE_THROUGH_TOTAL_CHARS) { + const sessionHeld = batch.queuedCharsBySession.get(entry.sessionId) ?? 0 + if (heldSessions.has(entry.sessionId) || sessionHeld > SMALL_SESSION_HOLD_BYPASS_CHARS) { + heldSessions.add(entry.sessionId) + retained.push(entry) + batch.queue.shift() + continue + } + } else if (socketDeep) { + recordDaemonStreamBacklogEvent('heldWriteThrough', { + heldChars: batch.queuedChars, + socketBufferedBytes: socket.writableLength ?? 0 + }) + } + const end = + entry.transformed || entry.data.length <= BULK_WRITE_SLICE_CHARS + ? entry.data.length + : clampToSafeSplitIndex(entry.data, 0, BULK_WRITE_SLICE_CHARS) + const slice = entry.data.slice(0, end) + const entrySequenceChars = entry.sequenceChars ?? entry.data.length + const sliceSequenceChars = entry.transformed + ? entrySequenceChars + : entrySequenceChars === 0 + ? 0 + : slice.length + if (end >= entry.data.length) { + batch.queue.shift() + } else { + entry.data = entry.data.slice(end) + const remainingSequenceChars = entrySequenceChars - sliceSequenceChars + entry.sequenceChars = + remainingSequenceChars === entry.data.length ? undefined : remainingSequenceChars + } + batch.queuedChars -= slice.length + const sessionHeldAfter = + (batch.queuedCharsBySession.get(entry.sessionId) ?? slice.length) - slice.length + if (sessionHeldAfter <= 0) { + batch.queuedCharsBySession.delete(entry.sessionId) + batch.droppableQueuedSessionIds.delete(entry.sessionId) + } else { + batch.queuedCharsBySession.set(entry.sessionId, sessionHeldAfter) + } + writeStreamDataEvents( + socket, + entry.sessionId, + slice, + args.maxLineBytes, + sliceSequenceChars, + entry.seq, + entry.transformed + ) + args.onAfterSocketWrite?.() + } + if (retained.length > 0) { + batch.queue = retained + armHeldDaemonStreamQueue(args, socket, clientId, retained[0].sessionId) + return + } + args.pendingByClient.delete(clientId) +} + +function armHeldDaemonStreamQueue( + args: DaemonStreamDataFlushArguments, + socket: Socket, + clientId: string, + sessionId: string +): void { + if (args.refillArmedClients.has(clientId) || socket.destroyed) { + return + } + args.refillArmedClients.add(clientId) + socket.write(encodeStreamDataEvent(sessionId, ''), () => { + args.refillArmedClients.delete(clientId) + flushDaemonStreamData(args, clientId) + }) +} + +export function writeDaemonStreamControl( + socket: Socket, + entry: PendingStreamDataBatch['queue'][number] +): void { + try { + socket.write(encodeNdjson(entry.control!), (error?: Error | null) => { + entry.settle?.(error ?? null) + }) + } catch (error) { + entry.settle?.(error instanceof Error ? error : new Error(String(error))) + } +} diff --git a/src/main/daemon/daemon-stream-droppability-lifecycle.test.ts b/src/main/daemon/daemon-stream-droppability-lifecycle.test.ts index b41876604d5..c4b00cfcc6b 100644 --- a/src/main/daemon/daemon-stream-droppability-lifecycle.test.ts +++ b/src/main/daemon/daemon-stream-droppability-lifecycle.test.ts @@ -9,6 +9,12 @@ import type { BackgroundTransientFactRelay } from './daemon-background-transient import type { PendingStreamDataBatch } from './daemon-stream-keep-tail-drop' import type { SubprocessHandle } from './session' import type { DaemonRequest } from './types' +import { + sameTerminalSessionAuthorityPtyAccess, + type TerminalSessionAuthorityPtyAccess +} from '../../shared/terminal-session-authority-pty-access' +import type { TerminalSessionAuthorityPtyOwner } from '../session-authority/terminal-session-authority-pty-owner' +import { TERMINAL_SESSION_AUTHORITY_HELLO_CAPABILITY } from './daemon-hello-protocol' type MockSubprocess = SubprocessHandle & { emitData(data: string): void @@ -23,6 +29,15 @@ type DaemonLifecyclePrivate = { controlSocket: Socket streamSocket: Socket | null authenticatedPairEstablished: boolean + capabilities?: { + terminalSessionAuthority: 1 + terminalAuthorityConsumerProof?: { version: 1; authorityHostId: string } + } + authorityConsumerTransport?: { token: object } + authorityNamespaceSessions?: Map< + string, + { session: { policyConsumer: unknown; disconnect(): void }; acceptances: { close(): void } } + > } > host: { @@ -42,6 +57,9 @@ function createMockSubprocess(): MockSubprocess { getForegroundProcess: () => null, write: vi.fn(), resize: vi.fn(), + clear: vi.fn(), + pause: vi.fn(), + resume: vi.fn(), kill: vi.fn(() => onExit?.(0)), forceKill: vi.fn(() => onExit?.(137)), signal: vi.fn(), @@ -61,7 +79,11 @@ function createMockSubprocess(): MockSubprocess { } } -function createServerHarness() { +function createServerHarness( + terminalSessionAuthority?: ConstructorParameters< + typeof DaemonServer + >[0]['terminalSessionAuthority'] +) { const subprocesses: MockSubprocess[] = [] const unique = randomUUID() const server = new DaemonServer({ @@ -71,7 +93,15 @@ function createServerHarness() { const subprocess = createMockSubprocess() subprocesses.push(subprocess) return subprocess - } + }, + ...(terminalSessionAuthority + ? { + terminalSessionAuthority, + terminalSessionAuthorityCapabilityReadiness: { + hostEffectConsumerInstalled: () => true + } + } + : {}) }) return { server, @@ -82,7 +112,8 @@ function createServerHarness() { function addClient( daemon: DaemonLifecyclePrivate, - writableLength = 0 + writableLength = 0, + authority = false ): Socket & { write: ReturnType; writableLength: number } { const controlSocket = { destroy: vi.fn() } as unknown as Socket const streamSocket = { @@ -94,11 +125,41 @@ function addClient( write: ReturnType writableLength: number } + const policyIdentity = { + consumerId: 'app-profile:droppability-test', + consumerIncarnationId: 'app-process:droppability-test' + } + // Authority admission is proof-only, so the client carries a negotiated proof grant and one + // already-admitted namespace session rather than a self-named consumer. + const policyConsumer = { + identity: policyIdentity, + assertInstalled: () => {}, + isInstalled: () => true, + disconnect: () => {} + } daemon.clients.set('client-1', { clientId: 'client-1', controlSocket, streamSocket, - authenticatedPairEstablished: true + authenticatedPairEstablished: true, + ...(authority + ? { + capabilities: { + terminalSessionAuthority: TERMINAL_SESSION_AUTHORITY_HELLO_CAPABILITY, + terminalAuthorityConsumerProof: { version: 1 as const, authorityHostId: 'host-a' } + }, + authorityConsumerTransport: { token: Object.freeze({}) }, + authorityNamespaceSessions: new Map([ + [ + JSON.stringify(['host-a', 'namespace-a']), + { + session: { policyConsumer, disconnect: () => {} }, + acceptances: { close: () => {} } + } + ] + ]) + } + : {}) }) return streamSocket } @@ -233,6 +294,86 @@ describe('daemon stream droppability lifecycle', () => { expect(lifecycle).toEqual(['refresh:client-1', 'marker:sessionBackgroundMarker']) }) + it('keeps legacy transient facts on the existing ordered stream', async () => { + const harness = createServerHarness() + server = harness.server + const { daemon, subprocesses } = harness + addClient(daemon) + daemon.transientFactRelay.setSessionBackground('legacy-session', true) + const enqueue = vi.spyOn(daemon.streamDataBatcher, 'enqueueControlEvent') + + await daemon.routeRequest('client-1', { + id: 'attach', + type: 'createOrAttach', + payload: { sessionId: 'legacy-session', cols: 80, rows: 24 } + }) + subprocesses[0]!.emitData('ding\x07') + + expect(enqueue).toHaveBeenCalledWith('client-1', 'legacy-session', { + type: 'event', + event: 'transientFact', + sessionId: 'legacy-session', + payload: { kind: 'bell' } + }) + }) + + it('persists authoritative facts against the captured binding without legacy publication', async () => { + let currentAccess: TerminalSessionAuthorityPtyAccess | null = null + const recordSemanticOutcome = vi.fn( + async (_sessionId: string, access: TerminalSessionAuthorityPtyAccess) => + sameTerminalSessionAuthorityPtyAccess(currentAccess, access) + ) + const owner = { + prepareSpawn: vi.fn(async () => ({ kind: 'spawn', prepared: {} })), + commitSpawn: vi.fn(async (_prepared: unknown, incarnationId: string) => { + currentAccess = authorityAccess(incarnationId) + return currentAccess + }), + cancelSpawn: vi.fn(async () => undefined), + accessFor: vi.fn(() => currentAccess), + admits: vi.fn((_id: string, access: TerminalSessionAuthorityPtyAccess) => + sameTerminalSessionAuthorityPtyAccess(currentAccess, access) + ), + recordSemanticOutcome, + recordExit: vi.fn(async () => { + currentAccess = null + }), + releaseAuthenticatedPolicyConsumerTransport: vi.fn(), + close: vi.fn(async () => true), + adopt: vi.fn() + } as unknown as TerminalSessionAuthorityPtyOwner + const harness = createServerHarness({ ptyOwner: owner }) + server = harness.server + const { daemon, subprocesses } = harness + addClient(daemon, 0, true) + daemon.transientFactRelay.setSessionBackground('authority-session', true) + const enqueue = vi.spyOn(daemon.streamDataBatcher, 'enqueueControlEvent') + + const result = (await daemon.routeRequest('client-1', { + id: 'attach', + type: 'createOrAttach', + payload: { + sessionId: 'authority-session', + cols: 80, + rows: 24, + terminalSessionAuthorityVersion: 1, + terminalSessionAuthorityOperationId: 'spawn-a', + worktreeId: 'repo::/srv/repo', + paneKey: 'pane-a', + paneGeneration: 1 + } + })) as { terminalSessionAuthorityAccess: TerminalSessionAuthorityPtyAccess } + subprocesses[0]!.emitData('ding\x07') + + await vi.waitFor(() => expect(recordSemanticOutcome).toHaveBeenCalledOnce()) + expect(recordSemanticOutcome).toHaveBeenCalledWith( + 'authority-session', + result.terminalSessionAuthorityAccess, + { kind: 'bell' } + ) + expect(enqueue.mock.calls.some(([, , event]) => event.event === 'transientFact')).toBe(false) + }) + it('invalidates droppable membership for final output held behind a deep socket', async () => { const harness = createServerHarness() server = harness.server @@ -264,3 +405,15 @@ describe('daemon stream droppability lifecycle', () => { expect(daemon.streamClientIdBySessionId.has('session-exit')).toBe(false) }) }) + +function authorityAccess(ptyIncarnationId: string): TerminalSessionAuthorityPtyAccess { + return { + namespace: { authorityHostId: 'host-a', namespaceId: 'namespace-a' }, + pane: { paneKey: 'pane-a', paneGenerationId: 'renderer:1' }, + binding: { + ownerIncarnationId: 'owner-a', + physicalPtyId: 'authority-session', + ptyIncarnationId + } + } +} diff --git a/src/main/daemon/daemon-stream-events.ts b/src/main/daemon/daemon-stream-events.ts index 62519016ca9..7c685d7895e 100644 --- a/src/main/daemon/daemon-stream-events.ts +++ b/src/main/daemon/daemon-stream-events.ts @@ -1,6 +1,10 @@ // ─── Events (Daemon → Client, on stream socket) ──────────────────── import type { TerminalGitHubPRLink } from '../../shared/terminal-github-pr-link-detector' import type { PtyIncarnationId } from '../../shared/pty-incarnation' +import type { + TerminalAuthorityNamespaceOutcomeBoundary, + TerminalAuthorityNamespaceOutcomePublication +} from '../../shared/terminal-session-authority-consumer-transport' export type DataEvent = { type: 'event' @@ -16,6 +20,16 @@ export type DataEvent = { } } +export type SessionSourceEvent = { + type: 'event' + event: 'sessionSource' + sessionId: string + payload: { + incarnationId: PtyIncarnationId + streamBindingNonce: string + } +} + export type ExitEvent = { type: 'event' event: 'exit' @@ -83,10 +97,27 @@ export type TransientFactEvent = { payload: DaemonTransientFact } +export type TerminalAuthorityNamespaceOutcomeBoundaryEvent = { + type: 'event' + event: 'terminalAuthorityNamespaceOutcomeBoundary' + sessionId: string + payload: TerminalAuthorityNamespaceOutcomeBoundary +} + +export type TerminalAuthorityNamespaceOutcomeEvent = { + type: 'event' + event: 'terminalAuthorityNamespaceOutcome' + sessionId: string + payload: TerminalAuthorityNamespaceOutcomePublication +} + export type DaemonEvent = | DataEvent + | SessionSourceEvent | ExitEvent | TerminalErrorEvent | SessionBackgroundMarkerEvent | DataGapEvent | TransientFactEvent + | TerminalAuthorityNamespaceOutcomeBoundaryEvent + | TerminalAuthorityNamespaceOutcomeEvent diff --git a/src/main/daemon/daemon-stream-keep-tail-drop.ts b/src/main/daemon/daemon-stream-keep-tail-drop.ts index 5d39afeaf1a..7a543bdf0df 100644 --- a/src/main/daemon/daemon-stream-keep-tail-drop.ts +++ b/src/main/daemon/daemon-stream-keep-tail-drop.ts @@ -24,6 +24,7 @@ export type StreamQueueEntry = { seq?: number transformed?: boolean control?: DaemonEvent + settle?: (error: Error | null) => void } export type PendingStreamDataBatch = { diff --git a/src/main/daemon/daemon-stream-session-flush.ts b/src/main/daemon/daemon-stream-session-flush.ts new file mode 100644 index 00000000000..95c8548d960 --- /dev/null +++ b/src/main/daemon/daemon-stream-session-flush.ts @@ -0,0 +1,51 @@ +import type { Socket } from 'node:net' +import { writeStreamDataEvents } from './daemon-stream-data-split' +import type { PendingStreamDataBatch } from './daemon-stream-keep-tail-drop' + +export function flushDaemonStreamSession(args: { + batch: PendingStreamDataBatch + socket: Socket | null + sessionId: string + maxLineBytes: number + onAfterSocketWrite?: () => void + writeControl: (socket: Socket, entry: PendingStreamDataBatch['queue'][number]) => void +}): { flushed: boolean; queueEmpty: boolean } { + const flushed: PendingStreamDataBatch['queue'] = [] + const retained: PendingStreamDataBatch['queue'] = [] + let flushedChars = 0 + for (const entry of args.batch.queue) { + if (entry.sessionId === args.sessionId) { + flushed.push(entry) + flushedChars += entry.data.length + } else { + retained.push(entry) + } + } + if (flushed.length === 0) { + return { flushed: false, queueEmpty: false } + } + + args.batch.queue = retained + args.batch.queuedChars -= flushedChars + args.batch.queuedCharsBySession.delete(args.sessionId) + args.batch.droppableQueuedSessionIds.delete(args.sessionId) + for (const entry of flushed) { + if (args.socket) { + if (entry.control) { + args.writeControl(args.socket, entry) + } else { + writeStreamDataEvents( + args.socket, + entry.sessionId, + entry.data, + args.maxLineBytes, + entry.sequenceChars ?? entry.data.length, + entry.seq, + entry.transformed + ) + } + args.onAfterSocketWrite?.() + } + } + return { flushed: true, queueEmpty: args.batch.queue.length === 0 } +} diff --git a/src/main/daemon/daemon-terminal-authority-app-admission.integration.test.ts b/src/main/daemon/daemon-terminal-authority-app-admission.integration.test.ts new file mode 100644 index 00000000000..99b76c01b0d --- /dev/null +++ b/src/main/daemon/daemon-terminal-authority-app-admission.integration.test.ts @@ -0,0 +1,482 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { TerminalAuthorityNamespaceOutcomeBoundary } from '../../shared/terminal-session-authority-consumer-transport' +import { DaemonClient } from './client' +import { DaemonServer } from './daemon-server' +import { getDaemonSocketPath } from './daemon-spawner' +import { DaemonTerminalAuthorityAppHostTransport } from './daemon-terminal-authority-app-host-transport' +import { + DAEMON_TERMINAL_AUTHORITY_CONSUMER_GRANT_REQUEST, + DAEMON_TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_REQUEST +} from './daemon-terminal-authority-consumer-requests' +import { TerminalAuthorityAppOutcomeHostManager } from '../session-authority/terminal-authority-app-outcome-host-manager' +import { + TerminalAuthorityAppOutcomeHostTransportSlot, + type TerminalAuthorityAppOutcomeHostTransportLease +} from '../session-authority/terminal-authority-app-outcome-host-transport-slot' +import type { TerminalAuthorityAppOutcomeNamespaceConnection } from '../session-authority/terminal-authority-app-outcome-host-contract' +import { TerminalAuthorityAppProjectionStore } from '../session-authority/terminal-authority-app-projection-store' +import { + createTerminalAuthorityProofEphemeralKeypair, + terminalAuthorityHostAppConsumerId +} from '../session-authority/terminal-session-authority-consumer-proof' +import { TerminalSessionAuthorityPtyOwner } from '../session-authority/terminal-session-authority-pty-owner' +import { TerminalSessionAuthorityRegistry } from '../session-authority/terminal-session-authority-registry' +import type { TerminalSessionAuthorityService } from '../session-authority/terminal-session-authority-service' +import { terminalAuthorityWorkspaceLocator } from '../session-authority/terminal-session-authority-workspace-locator' + +const resources: AuthorityHarness[] = [] + +afterEach(async () => { + for (const harness of resources.splice(0)) { + harness.manager?.dispose() + harness.transportLease?.dispose() + harness.store?.close() + harness.client?.disconnect() + harness.guardian.disconnect() + await harness.server.shutdown() + harness.owner.dispose() + await harness.registry.close() + rmSync(harness.directory, { recursive: true, force: true }) + } +}) + +describe('daemon authenticated terminal authority admission', () => { + it('accepts the first boundary before grant and resumes the retained cursor after app restart', async () => { + const harness = await startHarness() + const keypair = createTerminalAuthorityProofEphemeralKeypair() + const consumerId = terminalAuthorityHostAppConsumerId( + harness.service.namespace.authorityHostId, + keypair.publicKey + ) + await connectApp(harness, keypair, 'app-process:first') + + expect(activeIncarnation(harness.service, consumerId)).toBe('app-process:first') + expect(await consumerSnapshot(harness.service, consumerId, 'app-process:first')).toMatchObject({ + acknowledgedSequence: 0, + outcomeHighWatermark: 0 + }) + + harness.manager?.dispose() + harness.manager = null + harness.client?.disconnect() + harness.client = null + harness.store?.close() + harness.store = null + + expect(activeIncarnation(harness.service, consumerId)).toBe('app-process:first') + await connectApp(harness, keypair, 'app-process:second') + + expect(activeIncarnation(harness.service, consumerId)).toBe('app-process:second') + expect(await consumerSnapshot(harness.service, consumerId, 'app-process:second')).toMatchObject( + { acknowledgedSequence: 0, outcomeHighWatermark: 0 } + ) + }) + + it('rolls back a first claim when the app disconnects during boundary acceptance', async () => { + const harness = await startHarness() + const keypair = createTerminalAuthorityProofEphemeralKeypair() + const consumerId = terminalAuthorityHostAppConsumerId( + harness.service.namespace.authorityHostId, + keypair.publicKey + ) + const client = new DaemonClient({ + socketPath: harness.socketPath, + tokenPath: harness.tokenPath, + terminalSessionAuthorityConsumerProofReady: () => true + }) + harness.client = client + const host = await new DaemonTerminalAuthorityAppHostTransport({ + client, + authenticatedAuthorityHostId: harness.service.namespace.authorityHostId, + keypair + }).connect({ onFailure: () => {} }) + const boundaryStarted = deferred() + const releaseBoundary = deferred() + let provisional: TerminalAuthorityAppOutcomeNamespaceConnection | null = null + const opening = host.openNamespace( + { + namespace: harness.service.namespace, + candidateProcessIncarnationId: 'app-process:disconnecting', + candidateSessionNonce: 'app-session:disconnecting', + requestId: 'app-request:disconnecting', + intent: 'first' + }, + { + publishBoundary: async (boundary) => { + boundaryStarted.resolve(boundary) + await releaseBoundary.promise + await provisional?.acceptBoundary(boundaryAcceptance(boundary)) + }, + publishOutcome: async () => {} + }, + (connection) => { + provisional = connection + } + ) + await within(boundaryStarted.promise, 3_000, () => 'boundary was not published') + + client.disconnect() + releaseBoundary.resolve() + + await expect(within(opening, 3_000, () => 'opening did not settle')).rejects.toThrow() + expect(activeIncarnation(harness.service, consumerId)).toBeNull() + }) + + it('keeps the handover claim durable when the app disconnects mid-claim', async () => { + const harness = await startHarness() + const keypair = createTerminalAuthorityProofEphemeralKeypair() + await connectApp(harness, keypair, 'app-process:incumbent') + const consumerId = terminalAuthorityHostAppConsumerId( + harness.service.namespace.authorityHostId, + keypair.publicKey + ) + const candidateClient = new DaemonClient({ + socketPath: harness.socketPath, + tokenPath: harness.tokenPath, + terminalSessionAuthorityConsumerProofReady: () => true + }) + const candidateStore = await TerminalAuthorityAppProjectionStore.open({ + directory: path.join(harness.directory, 'candidate-projection') + }) + const candidateManager = new TerminalAuthorityAppOutcomeHostManager('app-process:candidate', { + store: candidateStore, + onProjection: () => {}, + onError: () => {}, + connectTimeoutMs: 1_000, + acknowledgeTimeoutMs: 1_000 + }) + const candidateSlot = new TerminalAuthorityAppOutcomeHostTransportSlot( + harness.service.namespace.authorityHostId + ) + const candidateTransportLease = candidateSlot.install( + new DaemonTerminalAuthorityAppHostTransport({ + client: candidateClient, + authenticatedAuthorityHostId: harness.service.namespace.authorityHostId, + keypair + }) + ) + const candidateRegistration = candidateManager.installHost(candidateSlot) + const commitConsumerAdmission = harness.service.commitConsumerAdmission.bind(harness.service) + const claimReturned = deferred() + const releaseClaim = deferred() + const interleaving: string[] = [] + vi.spyOn(harness.service, 'commitConsumerAdmission').mockImplementation( + async (writer, input, seal) => { + const consumer = await commitConsumerAdmission(writer, input, seal) + if (input.consumerIncarnationId === 'app-process:candidate') { + interleaving.push('claim-returned') + claimReturned.resolve() + await releaseClaim.promise + interleaving.push('claim-released') + } + return consumer + } + ) + + try { + const opening = candidateRegistration.resolveAndAdmitNamespace(harness.worktreeId) + await within(claimReturned.promise, 3_000, () => 'candidate claim was not returned') + expect(activeIncarnation(harness.service, consumerId)).toBe('app-process:candidate') + + const disconnected = deferred() + const candidateClientId = (candidateClient as unknown as { clientId: string }).clientId + const releaseAuthenticatedPolicyTransport = ( + harness.server as unknown as { + releaseAuthenticatedPolicyTransport(client: { clientId: string }): void + } + ).releaseAuthenticatedPolicyTransport.bind(harness.server) + const releaseSpy = vi + .spyOn( + harness.server as unknown as { + releaseAuthenticatedPolicyTransport(client: { clientId: string }): void + }, + 'releaseAuthenticatedPolicyTransport' + ) + .mockImplementation((client: { clientId: string }) => { + if (client.clientId === candidateClientId) { + interleaving.push('transport-released') + disconnected.resolve() + } + releaseAuthenticatedPolicyTransport(client) + }) + candidateClient.disconnect() + await within(disconnected.promise, 3_000, () => 'candidate disconnect was not observed') + expect(interleaving).toEqual(['claim-returned', 'transport-released']) + expect(activeIncarnation(harness.service, consumerId)).toBe('app-process:candidate') + + releaseClaim.resolve() + await expect( + within(opening, 3_000, () => 'candidate admission did not settle') + ).rejects.toThrow() + // The claim and its grant settled together, so the disconnect releases live state and leaves + // the durable record on the candidate for a fresh proof to resume. + expect(activeIncarnation(harness.service, consumerId)).toBe('app-process:candidate') + expect(interleaving.slice(0, 3)).toEqual([ + 'claim-returned', + 'transport-released', + 'claim-released' + ]) + releaseSpy.mockRestore() + } finally { + releaseClaim.resolve() + candidateManager.dispose() + candidateTransportLease.dispose() + candidateStore.close() + candidateClient.disconnect() + } + }) + + it('durably retires one authenticated namespace and replays its exact acknowledgement', async () => { + const harness = await startHarness() + const keypair = createTerminalAuthorityProofEphemeralKeypair() + const consumerId = terminalAuthorityHostAppConsumerId( + harness.service.namespace.authorityHostId, + keypair.publicKey + ) + const registration = await connectApp(harness, keypair, 'app-process:retiring') + const request = Object.freeze({ + namespace: harness.service.namespace, + candidateProcessIncarnationId: 'app-process:retiring', + candidateSessionNonce: 'app-session:retiring', + requestId: 'app-retirement:daemon-integration' + }) + + expect(harness.client?.terminalSessionAuthorityConsumerRetirementSupported()).toBe(true) + await expect(registration.retireNamespace(request)).resolves.toMatchObject({ + consumerId, + retired: true, + alreadyAbsent: false, + replayed: false + }) + expect(activeIncarnation(harness.service, consumerId)).toBeNull() + await expect(registration.retireNamespace(request)).resolves.toMatchObject({ + consumerId, + retired: true, + alreadyAbsent: false, + replayed: true + }) + }) + + it('retries after a lost durable response without re-claiming on daemon reconnect', async () => { + const harness = await startHarness() + const keypair = createTerminalAuthorityProofEphemeralKeypair() + const consumerId = terminalAuthorityHostAppConsumerId( + harness.service.namespace.authorityHostId, + keypair.publicKey + ) + const registration = await connectApp(harness, keypair, 'app-process:lost-retirement') + const client = harness.client! + const originalRequest = client.request.bind(client) + const requestTypes: string[] = [] + let loseResponse = true + vi.spyOn(client, 'request').mockImplementation(async (type, payload, timeoutMs) => { + const result = await originalRequest(type, payload, timeoutMs) + requestTypes.push(type) + if (type === DAEMON_TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_REQUEST && loseResponse) { + loseResponse = false + const socket = ( + client as unknown as { controlSocket: Readonly<{ destroy(): void }> | null } + ).controlSocket + socket?.destroy() + throw new Error('daemon retirement response lost after durable append') + } + return result + }) + const request = Object.freeze({ + namespace: harness.service.namespace, + candidateProcessIncarnationId: 'app-process:lost-retirement', + candidateSessionNonce: 'app-session:lost-retirement', + requestId: 'app-retirement:lost-daemon-response' + }) + + await expect(registration.retireNamespace(request)).rejects.toThrow('response lost') + expect(activeIncarnation(harness.service, consumerId)).toBeNull() + await vi.waitFor(() => expect(client.isConnected()).toBe(false)) + await expect(registration.retireNamespace(request)).resolves.toMatchObject({ + consumerId, + retired: true, + alreadyAbsent: true, + replayed: false + }) + expect(activeIncarnation(harness.service, consumerId)).toBeNull() + expect(requestTypes).not.toContain(DAEMON_TERMINAL_AUTHORITY_CONSUMER_GRANT_REQUEST) + }) +}) + +type AuthorityHarness = { + directory: string + worktreeId: string + registry: TerminalSessionAuthorityRegistry + owner: TerminalSessionAuthorityPtyOwner + service: TerminalSessionAuthorityService + server: DaemonServer + socketPath: string + tokenPath: string + guardian: DaemonClient + client: DaemonClient | null + manager: TerminalAuthorityAppOutcomeHostManager | null + store: TerminalAuthorityAppProjectionStore | null + transportLease: TerminalAuthorityAppOutcomeHostTransportLease | null +} + +async function startHarness(): Promise { + const directory = mkdtempSync(path.join(tmpdir(), 'daemon-authority-admission-')) + const authorityHostId = 'authority-host:daemon-admission-integration' + const registry = await TerminalSessionAuthorityRegistry.open({ + directory: path.join(directory, 'authority'), + authorityHostId, + ownerToken: 'owner-token:daemon-admission-integration', + ownerIncarnationId: 'owner-incarnation:daemon-admission-integration', + writerActorId: 'writer:daemon-admission-integration' + }) + const namespace = (await registry.resolveNamespace(terminalAuthorityWorkspaceLocator(directory))) + .namespace + const service = await registry.openNamespace(namespace) + const owner = new TerminalSessionAuthorityPtyOwner({ + registry, + ownerIncarnationId: 'owner-incarnation:daemon-admission-integration' + }) + const socketPath = getDaemonSocketPath(directory) + const tokenPath = path.join(directory, 'daemon.token') + const server = new DaemonServer({ + socketPath, + tokenPath, + spawnSubprocess: () => { + throw new Error('unexpected subprocess spawn') + }, + terminalSessionAuthority: { authorityHostId, ptyOwner: owner }, + terminalSessionAuthorityCapabilityReadiness: { + hostEffectConsumerInstalled: () => owner.hostEffectConsumerInstalled() + } + }) + await owner.start() + await server.start() + const guardian = new DaemonClient({ socketPath, tokenPath }) + await guardian.ensureConnected() + const harness: AuthorityHarness = { + directory, + worktreeId: `repo::${directory}`, + registry, + owner, + service, + server, + socketPath, + tokenPath, + guardian, + client: null, + manager: null, + store: null, + transportLease: null + } + resources.push(harness) + return harness +} + +async function connectApp( + harness: AuthorityHarness, + keypair: ReturnType, + processIncarnationId: string +) { + const client = new DaemonClient({ + socketPath: harness.socketPath, + tokenPath: harness.tokenPath, + terminalSessionAuthorityConsumerProofReady: () => true + }) + const store = await TerminalAuthorityAppProjectionStore.open({ + directory: path.join(harness.directory, 'projection') + }) + const errors: Error[] = [] + const manager = new TerminalAuthorityAppOutcomeHostManager(processIncarnationId, { + store, + onProjection: () => {}, + onError: (error) => errors.push(error), + connectTimeoutMs: 1_000, + acknowledgeTimeoutMs: 1_000 + }) + harness.transportLease?.dispose() + const slot = new TerminalAuthorityAppOutcomeHostTransportSlot( + harness.service.namespace.authorityHostId + ) + const transportLease = slot.install( + new DaemonTerminalAuthorityAppHostTransport({ + client, + authenticatedAuthorityHostId: harness.service.namespace.authorityHostId, + keypair + }) + ) + const registration = manager.installHost(slot) + harness.client = client + harness.store = store + harness.manager = manager + harness.transportLease = transportLease + await within( + registration.resolveAndAdmitNamespace(harness.worktreeId), + 3_000, + () => errors.map((error) => error.message).join(' | ') || 'no app outcome error' + ) + return registration +} + +function activeIncarnation( + service: TerminalSessionAuthorityService, + consumerId: string +): string | null { + return service.activeConsumerIncarnation(service.writerAccess, consumerId) +} + +function consumerSnapshot( + service: TerminalSessionAuthorityService, + consumerId: string, + consumerIncarnationId: string +) { + return service.snapshotForConsumer({ + role: 'consumer', + serviceInstanceId: service.writerAccess.serviceInstanceId, + consumerId, + consumerIncarnationId + }) +} + +function within(pending: Promise, timeoutMs: number, detail: () => string): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error(`daemon authority admission timed out: ${detail()}`)), + timeoutMs + ) + void pending.then( + (value) => { + clearTimeout(timer) + resolve(value) + }, + (error) => { + clearTimeout(timer) + reject(error) + } + ) + }) +} + +function boundaryAcceptance(boundary: TerminalAuthorityNamespaceOutcomeBoundary) { + if (!boundary.boundaryId) { + throw new Error('daemon authority boundary identity is unavailable') + } + return { + version: boundary.version, + consumer: boundary.consumer, + namespace: boundary.namespace, + boundaryId: boundary.boundaryId, + acknowledgedSequence: boundary.acknowledgedSequence, + outcomeHighWatermark: boundary.outcomeHighWatermark + } +} + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((settle) => { + resolve = settle + }) + return { promise, resolve } +} diff --git a/src/main/daemon/daemon-terminal-authority-app-host-transport.test.ts b/src/main/daemon/daemon-terminal-authority-app-host-transport.test.ts new file mode 100644 index 00000000000..7f264a93092 --- /dev/null +++ b/src/main/daemon/daemon-terminal-authority-app-host-transport.test.ts @@ -0,0 +1,436 @@ +import { describe, expect, it, vi } from 'vitest' +import { + TERMINAL_AUTHORITY_CONSUMER_PROOF_ALGORITHM, + TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION, + type TerminalAuthorityNamespaceAdmissionChallenge, + type TerminalAuthorityNamespaceAdmissionGrant, + type TerminalAuthorityNamespaceAdmissionProof, + type TerminalAuthorityNamespaceAdmissionStart +} from '../../shared/terminal-session-authority-consumer-proof' +import { + TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_ALGORITHM, + TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_VERSION, + type TerminalAuthorityConsumerRetirementProof +} from '../../shared/terminal-session-authority-consumer-retirement' +import type { TerminalAuthorityNamespace } from '../../shared/terminal-session-authority-identity' +import { TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_VERSION } from '../../shared/terminal-session-authority-consumer-transport' +import { + TerminalAuthorityAppAdmissionRejectedError, + type TerminalAuthorityAppAdmissionIntentRequiredError +} from '../session-authority/terminal-authority-app-outcome-host-contract' +import { TerminalAuthorityAppOutcomeHostTransportSlot } from '../session-authority/terminal-authority-app-outcome-host-transport-slot' +import { + createTerminalAuthorityProofEphemeralKeypair, + terminalAuthorityAdmissionCas, + terminalAuthorityHostAppConsumerId, + terminalAuthorityRetirementCas +} from '../session-authority/terminal-session-authority-consumer-proof' +import { + DaemonTerminalAuthorityAppHostTransport, + type DaemonTerminalAuthorityAppClient +} from './daemon-terminal-authority-app-host-transport' +import { + DAEMON_TERMINAL_AUTHORITY_CONSUMER_CANCEL_REQUEST, + DAEMON_TERMINAL_AUTHORITY_CONSUMER_CHALLENGE_REQUEST, + DAEMON_TERMINAL_AUTHORITY_CONSUMER_GRANT_REQUEST, + DAEMON_TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_CHALLENGE_REQUEST, + DAEMON_TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_REQUEST, + DAEMON_TERMINAL_AUTHORITY_CONSUMER_RESOLVE_NAMESPACE_REQUEST +} from './daemon-terminal-authority-consumer-requests' + +const HOST_ID = 'authority-host:daemon-test' +const APP_KEYPAIR = createTerminalAuthorityProofEphemeralKeypair() + +describe('DaemonTerminalAuthorityAppHostTransport', () => { + it('keeps proof construction internal and maps the host-required intent', async () => { + const attempt = admission() + const client = new FakeClient(async (type, payload) => { + expect(type).toBe(DAEMON_TERMINAL_AUTHORITY_CONSUMER_CHALLENGE_REQUEST) + expect(payload).toEqual(attempt.start) + throw new Error('terminal authority namespace admission requires first') + }) + const host = await connect(client) + + await expect(host.openNamespace(attempt.request, outcomeTransport())).rejects.toMatchObject({ + requiredIntent: 'first' + } satisfies Partial) + expect(client.requests).toHaveLength(1) + }) + + it('publishes the pre-grant boundary needed to unlock exact admission', async () => { + const attempt = admission() + const boundary = namespaceBoundary(attempt.grant) + const publishBoundary = vi.fn(async () => {}) + let client!: FakeClient + client = new FakeClient(async (type, payload) => { + if (type === DAEMON_TERMINAL_AUTHORITY_CONSUMER_CHALLENGE_REQUEST) { + expect(payload).toEqual(attempt.start) + return attempt.challenge + } + if (type === DAEMON_TERMINAL_AUTHORITY_CONSUMER_GRANT_REQUEST) { + assertProofUsesChallenge(payload, attempt.challenge) + client.emit('terminalAuthorityNamespaceOutcomeBoundary', boundary) + return attempt.grant + } + throw new Error(`unexpected request: ${type}`) + }) + const host = await connect(client) + + const namespace = await host.openNamespace(attempt.request, { + publishBoundary, + publishOutcome: async () => {}, + onFailure: vi.fn() + }) + + expect(namespace.grant).toEqual(attempt.grant) + expect(publishBoundary).toHaveBeenCalledWith(boundary) + await namespace.activate?.() + expect(publishBoundary).toHaveBeenCalledOnce() + }) + + it('resolves the final-host namespace only over the authenticated proof transport', async () => { + const expected = { authorityHostId: HOST_ID, namespaceId: 'namespace:resolved' } + const client = new FakeClient(async (type, payload) => { + expect(type).toBe(DAEMON_TERMINAL_AUTHORITY_CONSUMER_RESOLVE_NAMESPACE_REQUEST) + expect(payload).toEqual({ worktreeId: 'repo::/workspace' }) + return expected + }) + const host = await connect(client) + + await expect(host.resolveNamespace('repo::/workspace')).resolves.toEqual(expected) + }) + + it('distinguishes definitive admission rejection from an uncertain response', async () => { + const attempt = admission() + const rejectedClient = new FakeClient(async () => { + throw new Error('terminal authority namespace admission challenge expired') + }) + const uncertainClient = new FakeClient(async () => { + throw new Error('connection closed before response') + }) + + await expect( + (await connect(rejectedClient)).openNamespace(attempt.request, outcomeTransport()) + ).rejects.toBeInstanceOf(TerminalAuthorityAppAdmissionRejectedError) + await expect( + (await connect(uncertainClient)).openNamespace(attempt.request, outcomeTransport()) + ).rejects.not.toBeInstanceOf(TerminalAuthorityAppAdmissionRejectedError) + }) + + it('retries an uncertain grant with the exact proof and no synthetic cancellation', async () => { + const attempt = admission() + const grantPayloads: unknown[] = [] + let grants = 0 + const client = new FakeClient(async (type, payload) => { + if (type === DAEMON_TERMINAL_AUTHORITY_CONSUMER_CHALLENGE_REQUEST) { + return attempt.challenge + } + if (type === DAEMON_TERMINAL_AUTHORITY_CONSUMER_GRANT_REQUEST) { + grantPayloads.push(payload) + grants += 1 + if (grants === 1) { + throw new Error('grant response timed out') + } + return { ...attempt.grant, replayed: true } + } + throw new Error(`unexpected request: ${type}`) + }) + const host = await connect(client) + + await expect(host.openNamespace(attempt.request, outcomeTransport())).rejects.toThrow( + 'grant response timed out' + ) + const namespace = await host.openNamespace(attempt.request, outcomeTransport()) + + expect(namespace.grant.replayed).toBe(true) + expect(grantPayloads[0]).toEqual(grantPayloads[1]) + expect( + client.requests.some( + (request) => request.type === DAEMON_TERMINAL_AUTHORITY_CONSUMER_CANCEL_REQUEST + ) + ).toBe(false) + }) + + it('fences one namespace failure without advancing or failing its sibling', async () => { + const first = admission('namespace:first') + const second = admission('namespace:second') + const attempts = new Map>([ + [first.request.requestId, first], + [second.request.requestId, second] + ]) + const client = new FakeClient(async (type, payload) => { + if (type === DAEMON_TERMINAL_AUTHORITY_CONSUMER_CHALLENGE_REQUEST) { + return attempts.get((payload as TerminalAuthorityNamespaceAdmissionStart).requestId) + ?.challenge + } + if (type === DAEMON_TERMINAL_AUTHORITY_CONSUMER_GRANT_REQUEST) { + return attempts.get( + (payload as TerminalAuthorityNamespaceAdmissionProof).challenge.requestId + )?.grant + } + if (type === DAEMON_TERMINAL_AUTHORITY_CONSUMER_CANCEL_REQUEST) { + return { canceled: true } + } + throw new Error(`unexpected request: ${type}`) + }) + const hostFailure = vi.fn() + const host = await new DaemonTerminalAuthorityAppHostTransport({ + client, + authenticatedAuthorityHostId: HOST_ID, + keypair: APP_KEYPAIR + }).connect({ onFailure: hostFailure }) + const firstFailure = vi.fn() + const secondBoundary = vi.fn(async () => {}) + const firstConnection = await host.openNamespace(first.request, outcomeTransport(firstFailure)) + const secondConnection = await host.openNamespace(second.request, { + publishBoundary: secondBoundary, + publishOutcome: async () => {}, + onFailure: vi.fn() + }) + await firstConnection.activate?.() + await secondConnection.activate?.() + + client.emit( + 'terminalAuthorityNamespaceOutcomeBoundary', + namespaceBoundary({ + ...first.grant, + consumer: { ...first.grant.consumer, consumerIncarnationId: 'app-process:wrong' } + }) + ) + client.emit('terminalAuthorityNamespaceOutcomeBoundary', namespaceBoundary(second.grant)) + await vi.waitFor(() => expect(secondBoundary).toHaveBeenCalledOnce()) + + expect(firstFailure).toHaveBeenCalledOnce() + expect(hostFailure).not.toHaveBeenCalled() + expect(secondConnection.grant).toEqual(second.grant) + }) + + it('retries a lost retirement acknowledgement with the exact proof', async () => { + const attempt = retirement() + const proofs: unknown[] = [] + let completions = 0 + const client = new FakeClient(async (type, payload) => { + if (type === DAEMON_TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_CHALLENGE_REQUEST) { + expect(payload).toEqual(attempt.start) + return attempt.challenge + } + if (type === DAEMON_TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_REQUEST) { + proofs.push(payload) + completions += 1 + if (completions === 1) { + throw new Error('retirement response timed out') + } + return { ...attempt.result, replayed: true } + } + throw new Error(`unexpected request: ${type}`) + }) + const host = await connect(client) + + await expect(host.retireNamespace(attempt.request)).rejects.toThrow( + 'retirement response timed out' + ) + await expect(host.retireNamespace(attempt.request)).resolves.toEqual({ + ...attempt.result, + replayed: true + }) + expect(proofs).toHaveLength(2) + expect(proofs[0]).toEqual(proofs[1]) + expect((proofs[0] as TerminalAuthorityConsumerRetirementProof).challenge).toEqual( + attempt.challenge + ) + }) + + it('rejects retirement before mutation when the daemon did not grant support', async () => { + const attempt = retirement() + const client = new FakeClient(async () => { + throw new Error('unexpected request') + }, false) + const host = await connect(client) + + await expect(host.retireNamespace(attempt.request)).rejects.toThrow('unsupported') + expect(client.requests).toHaveLength(0) + }) +}) + +class FakeClient implements DaemonTerminalAuthorityAppClient { + readonly requests: { type: string; payload: unknown }[] = [] + private readonly eventListeners = new Set<(event: unknown) => void>() + private readonly disconnectedListeners = new Set<() => void>() + + constructor( + private readonly handleRequest: (type: string, payload: unknown) => Promise, + private readonly retirementSupported = true + ) {} + + async ensureConnected(): Promise {} + + terminalSessionAuthorityConsumerProofHostId(): string { + return HOST_ID + } + + terminalSessionAuthorityConsumerRetirementSupported(): boolean { + return this.retirementSupported + } + + async request(type: string, payload: unknown): Promise { + this.requests.push({ type, payload }) + return (await this.handleRequest(type, payload)) as T + } + + onEvent(listener: (event: unknown) => void): () => void { + this.eventListeners.add(listener) + return () => this.eventListeners.delete(listener) + } + + onDisconnected(listener: () => void): () => void { + this.disconnectedListeners.add(listener) + return () => this.disconnectedListeners.delete(listener) + } + + emit(event: string, payload: unknown): void { + for (const listener of this.eventListeners) { + listener({ event, payload }) + } + } +} + +async function connect(client: FakeClient) { + const slot = new TerminalAuthorityAppOutcomeHostTransportSlot(HOST_ID) + slot.install( + new DaemonTerminalAuthorityAppHostTransport({ + client, + authenticatedAuthorityHostId: HOST_ID, + keypair: APP_KEYPAIR + }) + ) + return await slot.connect({ onFailure: vi.fn() }) +} + +function outcomeTransport(onFailure = vi.fn()) { + return { + publishBoundary: async () => {}, + publishOutcome: async () => {}, + onFailure + } +} + +function admission(namespaceId = 'namespace:daemon-test') { + const namespace: TerminalAuthorityNamespace = Object.freeze({ + authorityHostId: HOST_ID, + namespaceId + }) + const request = Object.freeze({ + namespace, + candidateProcessIncarnationId: 'app-process:daemon-test', + candidateSessionNonce: `app-session:${namespaceId}`, + requestId: `app-request:${namespaceId}`, + intent: 'resume' as const + }) + const start: TerminalAuthorityNamespaceAdmissionStart = Object.freeze({ + version: TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION, + algorithm: TERMINAL_AUTHORITY_CONSUMER_PROOF_ALGORITHM, + ...request, + appPublicKeyB64: Buffer.from(APP_KEYPAIR.publicKey).toString('base64') + }) + const host = createTerminalAuthorityProofEphemeralKeypair() + const challenge: TerminalAuthorityNamespaceAdmissionChallenge = Object.freeze({ + ...start, + currentAdmissionCas: 'admission-cas:current', + connectionGrantId: 'daemon-grant:test', + authenticatedTransportPrincipal: 'daemon-principal:test', + authenticatedTransportCapability: 'daemon-capability:test', + hostEphemeralPublicKeyB64: Buffer.from(host.publicKey).toString('base64'), + expiresAtMs: Date.now() + 30_000 + }) + const consumer = Object.freeze({ + consumerId: terminalAuthorityHostAppConsumerId(HOST_ID, APP_KEYPAIR.publicKey), + consumerIncarnationId: start.candidateProcessIncarnationId + }) + const grant: TerminalAuthorityNamespaceAdmissionGrant = Object.freeze({ + version: TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION, + consumer, + namespace, + requestId: start.requestId, + connectionGrantId: challenge.connectionGrantId, + admissionCas: terminalAuthorityAdmissionCas( + namespace, + consumer.consumerId, + consumer.consumerIncarnationId + ), + replayed: false + }) + return { request, start, challenge, grant } +} + +function retirement(namespaceId = 'namespace:daemon-test') { + const namespace = Object.freeze({ authorityHostId: HOST_ID, namespaceId }) + const request = Object.freeze({ + namespace, + candidateProcessIncarnationId: 'app-process:daemon-test', + candidateSessionNonce: `app-retirement-session:${namespaceId}`, + requestId: `app-retirement-request:${namespaceId}` + }) + const start = Object.freeze({ + version: TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_VERSION, + algorithm: TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_ALGORITHM, + ...request, + appPublicKeyB64: Buffer.from(APP_KEYPAIR.publicKey).toString('base64') + }) + const host = createTerminalAuthorityProofEphemeralKeypair() + const consumerId = terminalAuthorityHostAppConsumerId(HOST_ID, APP_KEYPAIR.publicKey) + const currentConsumerIncarnationId = request.candidateProcessIncarnationId + const challenge = Object.freeze({ + ...start, + consumerId, + currentConsumerIncarnationId, + retirementCas: terminalAuthorityRetirementCas( + namespace, + consumerId, + currentConsumerIncarnationId + ), + connectionGrantId: 'daemon-grant:test', + liveAdmission: null, + authenticatedTransportPrincipal: 'daemon-principal:test', + authenticatedTransportCapability: 'daemon-capability:test', + hostEphemeralPublicKeyB64: Buffer.from(host.publicKey).toString('base64'), + expiresAtMs: Date.now() + 30_000 + }) + const result = Object.freeze({ + version: TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_VERSION, + namespace, + consumerId, + retiredConsumerIncarnationId: currentConsumerIncarnationId, + requestId: request.requestId, + candidateProcessIncarnationId: request.candidateProcessIncarnationId, + candidateSessionNonce: request.candidateSessionNonce, + connectionGrantId: challenge.connectionGrantId, + retirementCas: challenge.retirementCas, + retired: true as const, + alreadyAbsent: false, + replayed: false + }) + return { request, start, challenge, result } +} + +function assertProofUsesChallenge( + value: unknown, + challenge: TerminalAuthorityNamespaceAdmissionChallenge +): void { + expect(value).toMatchObject({ + version: TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION, + challenge, + proofMacB64: expect.any(String) + }) +} + +function namespaceBoundary(grant: TerminalAuthorityNamespaceAdmissionGrant) { + return Object.freeze({ + version: TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_VERSION, + consumer: grant.consumer, + namespace: grant.namespace, + acknowledgedSequence: 0, + outcomeHighWatermark: 0, + boundaryId: `boundary:${grant.namespace.namespaceId}`, + consumerStart: 'new-at-tail' as const + }) +} diff --git a/src/main/daemon/daemon-terminal-authority-app-host-transport.ts b/src/main/daemon/daemon-terminal-authority-app-host-transport.ts new file mode 100644 index 00000000000..a6beb0d6134 --- /dev/null +++ b/src/main/daemon/daemon-terminal-authority-app-host-transport.ts @@ -0,0 +1,291 @@ +import { + TERMINAL_AUTHORITY_CONSUMER_PROOF_ALGORITHM, + TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION, + parseTerminalAuthorityNamespaceAdmissionChallenge, + parseTerminalAuthorityNamespaceAdmissionStart, + type TerminalAuthorityNamespaceAdmissionChallenge, + type TerminalAuthorityNamespaceAdmissionIntent, + type TerminalAuthorityNamespaceAdmissionStart +} from '../../shared/terminal-session-authority-consumer-proof' +import { + assertAuthorityNamespace, + type TerminalAuthorityNamespace +} from '../../shared/terminal-session-authority-identity' +import { + TerminalAuthorityAppAdmissionIntentRequiredError, + TerminalAuthorityAppAdmissionRejectedError, + type TerminalAuthorityAppConsumerRetirementRequest, + type TerminalAuthorityAppNamespaceAdmissionRequest, + type TerminalAuthorityAppOutcomeHostConnection, + type TerminalAuthorityAppOutcomeHostTransport, + type TerminalAuthorityAppOutcomeNamespaceConnection +} from '../session-authority/terminal-authority-app-outcome-host-contract' +import { terminalAuthorityAppAdmissionRejection } from '../session-authority/terminal-authority-app-admission-error' +import type { TerminalAuthorityPolicyOutcomeTransport } from '../session-authority/terminal-session-authority-policy-consumers' +import { + createTerminalAuthorityConsumerProof, + type TerminalAuthorityConsumerProofKeypair +} from '../session-authority/terminal-session-authority-consumer-proof' +import { + DaemonTerminalAuthorityAppNamespaceConnection, + parseDaemonTerminalAuthorityAppNamespaceEvent +} from './daemon-terminal-authority-app-namespace-transport' +import { + DAEMON_TERMINAL_AUTHORITY_CONSUMER_CHALLENGE_REQUEST, + DAEMON_TERMINAL_AUTHORITY_CONSUMER_RESOLVE_NAMESPACE_REQUEST +} from './daemon-terminal-authority-consumer-requests' +import { retireDaemonTerminalAuthorityAppConsumer } from './daemon-terminal-authority-app-retirement' + +export type DaemonTerminalAuthorityAppClient = Readonly<{ + ensureConnected(): Promise + terminalSessionAuthorityConsumerProofHostId(): string | null + terminalSessionAuthorityConsumerRetirementSupported?(): boolean + request(type: string, payload: unknown, timeoutMs?: number): Promise + onEvent(listener: (event: unknown) => void): () => void + onDisconnected(listener: () => void): () => void +}> + +export type DaemonTerminalAuthorityAppHostTransportOptions = Readonly<{ + client: DaemonTerminalAuthorityAppClient + authenticatedAuthorityHostId: string + keypair: TerminalAuthorityConsumerProofKeypair +}> + +export class DaemonTerminalAuthorityAppHostTransport implements TerminalAuthorityAppOutcomeHostTransport { + readonly authenticatedAuthorityHostId: string + + constructor(private readonly options: DaemonTerminalAuthorityAppHostTransportOptions) { + this.authenticatedAuthorityHostId = options.authenticatedAuthorityHostId + if (options.keypair.publicKey.length !== 32 || options.keypair.secretKey.length !== 32) { + throw new Error('daemon terminal authority proof keypair is invalid') + } + } + + async connect( + transport: Readonly<{ onFailure(error: unknown): void }> + ): Promise { + await this.options.client.ensureConnected() + if ( + this.options.client.terminalSessionAuthorityConsumerProofHostId() !== + this.authenticatedAuthorityHostId + ) { + throw new Error('daemon terminal authority host identity changed') + } + return new DaemonTerminalAuthorityAppHostConnection(this.options, transport.onFailure) + } +} + +class DaemonTerminalAuthorityAppHostConnection implements TerminalAuthorityAppOutcomeHostConnection { + readonly authenticatedAuthorityHostId: string + private readonly namespaces = new Map() + private readonly removeEventListener: () => void + private readonly removeDisconnectedListener: () => void + private active = true + + constructor( + private readonly options: DaemonTerminalAuthorityAppHostTransportOptions, + private readonly onFailure: (error: unknown) => void + ) { + this.authenticatedAuthorityHostId = options.authenticatedAuthorityHostId + this.removeEventListener = options.client.onEvent((event) => this.receiveEvent(event)) + this.removeDisconnectedListener = options.client.onDisconnected(() => { + this.fail(new Error('daemon terminal authority host disconnected')) + }) + } + + async openNamespace( + request: TerminalAuthorityAppNamespaceAdmissionRequest, + transport: TerminalAuthorityPolicyOutcomeTransport, + onOpening?: (connection: TerminalAuthorityAppOutcomeNamespaceConnection) => void + ): Promise { + this.assertActive() + const start = this.admissionStart(request) + const challenge = await this.issueChallenge(start) + let proof + try { + proof = createTerminalAuthorityConsumerProof(challenge, this.options.keypair) + } catch (error) { + throw new Error( + `daemon terminal authority proof failed: ${error instanceof Error ? error.message : String(error)}` + ) + } + const key = namespaceKey(start.namespace) + if (this.namespaces.has(key)) { + throw new Error('daemon terminal authority namespace is already open') + } + const connection = new DaemonTerminalAuthorityAppNamespaceConnection({ + client: this.options.client, + proof, + transport, + retire: (requestId) => + this.retireNamespace({ + namespace: start.namespace, + candidateProcessIncarnationId: start.candidateProcessIncarnationId, + candidateSessionNonce: start.candidateSessionNonce, + requestId + }), + isCurrent: () => this.active && this.namespaces.get(key) === connection, + remove: () => { + if (this.namespaces.get(key) === connection) { + this.namespaces.delete(key) + } + } + }) + this.namespaces.set(key, connection) + try { + onOpening?.(connection) + await connection.open() + this.assertActive() + return connection + } catch (error) { + connection.abandon() + throw error + } + } + + async resolveNamespace(worktreeId: string): Promise { + this.assertActive() + const value = await this.options.client.request( + DAEMON_TERMINAL_AUTHORITY_CONSUMER_RESOLVE_NAMESPACE_REQUEST, + { worktreeId } + ) + this.assertActive() + assertAuthorityNamespace(value) + if (value.authorityHostId !== this.authenticatedAuthorityHostId) { + throw new Error('daemon terminal authority namespace host changed') + } + return Object.freeze({ ...value }) + } + + async retireNamespace(request: TerminalAuthorityAppConsumerRetirementRequest) { + const result = await retireDaemonTerminalAuthorityAppConsumer(this.options, request, () => + this.assertActive() + ) + const key = namespaceKey(request.namespace) + this.namespaces.get(key)?.retired() + this.namespaces.delete(key) + return result + } + + private admissionStart( + request: TerminalAuthorityAppNamespaceAdmissionRequest + ): TerminalAuthorityNamespaceAdmissionStart { + const start = parseTerminalAuthorityNamespaceAdmissionStart({ + version: TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION, + algorithm: TERMINAL_AUTHORITY_CONSUMER_PROOF_ALGORITHM, + ...request, + appPublicKeyB64: Buffer.from(this.options.keypair.publicKey).toString('base64') + }) + if (!start || start.namespace.authorityHostId !== this.authenticatedAuthorityHostId) { + throw new Error('daemon terminal authority admission request is invalid') + } + return start + } + + private async issueChallenge( + start: TerminalAuthorityNamespaceAdmissionStart + ): Promise { + let unsafeChallenge: unknown + try { + unsafeChallenge = await this.options.client.request( + DAEMON_TERMINAL_AUTHORITY_CONSUMER_CHALLENGE_REQUEST, + start + ) + } catch (error) { + const requiredIntent = admissionIntentRequired(error) + if (requiredIntent) { + throw new TerminalAuthorityAppAdmissionIntentRequiredError(requiredIntent) + } + const rejection = terminalAuthorityAppAdmissionRejection(error) + if (rejection) { + throw rejection + } + throw error + } + this.assertActive() + const challenge = parseTerminalAuthorityNamespaceAdmissionChallenge(unsafeChallenge) + if (!challenge || !sameAdmissionStart(challenge, start)) { + throw new TerminalAuthorityAppAdmissionRejectedError( + 'daemon terminal authority challenge is invalid' + ) + } + return challenge + } + + disconnect(): void { + if (!this.active) { + return + } + this.active = false + this.removeEventListener() + this.removeDisconnectedListener() + for (const connection of this.namespaces.values()) { + connection.disconnect() + } + this.namespaces.clear() + } + + private receiveEvent(value: unknown): void { + if (!this.active || !value || typeof value !== 'object') { + return + } + const event = value as { event?: unknown; payload?: unknown } + const parsed = parseDaemonTerminalAuthorityAppNamespaceEvent(event.event, event.payload) + if (!parsed) { + return + } + this.namespaces.get(namespaceKey(parsed.value.namespace))?.publish(parsed) + } + + private fail(error: unknown): void { + if (!this.active) { + return + } + this.disconnect() + this.onFailure(error) + } + + private assertActive(): void { + if (!this.active) { + throw new Error('daemon terminal authority host connection is stale') + } + } +} + +function sameAdmissionStart( + challenge: TerminalAuthorityNamespaceAdmissionChallenge, + start: TerminalAuthorityNamespaceAdmissionStart +): boolean { + return ( + challenge.version === start.version && + challenge.algorithm === start.algorithm && + sameNamespace(challenge.namespace, start.namespace) && + challenge.appPublicKeyB64 === start.appPublicKeyB64 && + challenge.candidateProcessIncarnationId === start.candidateProcessIncarnationId && + challenge.candidateSessionNonce === start.candidateSessionNonce && + challenge.requestId === start.requestId && + challenge.intent === start.intent + ) +} + +function admissionIntentRequired(error: unknown): TerminalAuthorityNamespaceAdmissionIntent | null { + if (!(error instanceof Error)) { + return null + } + const prefix = 'terminal authority namespace admission requires ' + const required = error.message.startsWith(prefix) ? error.message.slice(prefix.length) : '' + return required === 'first' || required === 'resume' || required === 'explicit-handover' + ? required + : null +} + +function sameNamespace( + left: TerminalAuthorityNamespace, + right: TerminalAuthorityNamespace +): boolean { + return left.authorityHostId === right.authorityHostId && left.namespaceId === right.namespaceId +} + +function namespaceKey(namespace: TerminalAuthorityNamespace): string { + return JSON.stringify([namespace.authorityHostId, namespace.namespaceId]) +} diff --git a/src/main/daemon/daemon-terminal-authority-app-namespace-transport.ts b/src/main/daemon/daemon-terminal-authority-app-namespace-transport.ts new file mode 100644 index 00000000000..163fbfb5253 --- /dev/null +++ b/src/main/daemon/daemon-terminal-authority-app-namespace-transport.ts @@ -0,0 +1,292 @@ +import { + TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION, + parseTerminalAuthorityNamespaceAdmissionGrant, + type TerminalAuthorityNamespaceAdmissionGrant, + type TerminalAuthorityNamespaceAdmissionProof +} from '../../shared/terminal-session-authority-consumer-proof' +import type { TerminalAuthorityConsumerRetirementResult } from '../../shared/terminal-session-authority-consumer-retirement' +import type { TerminalAuthorityNamespace } from '../../shared/terminal-session-authority-identity' +import { + parseTerminalAuthorityNamespaceOutcomeBoundary, + parseTerminalAuthorityNamespaceOutcomePublication, + parseTerminalAuthorityNamespaceBoundaryAcceptance, + parseTerminalAuthorityNamespaceOutcomeAck, + sameTerminalAuthorityPolicyConsumer, + type TerminalAuthorityNamespaceOutcomeBoundary, + type TerminalAuthorityNamespaceOutcomePublication +} from '../../shared/terminal-session-authority-consumer-transport' +import { + TerminalAuthorityAppAdmissionRejectedError, + type TerminalAuthorityAppOutcomeNamespaceConnection +} from '../session-authority/terminal-authority-app-outcome-host-contract' +import { terminalAuthorityAppAdmissionRejection } from '../session-authority/terminal-authority-app-admission-error' +import type { TerminalAuthorityPolicyOutcomeTransport } from '../session-authority/terminal-session-authority-policy-consumers' +import { + terminalAuthorityAdmissionCas, + terminalAuthorityHostAppConsumerId +} from '../session-authority/terminal-session-authority-consumer-proof' +import type { DaemonTerminalAuthorityAppClient } from './daemon-terminal-authority-app-host-transport' +import { + DAEMON_TERMINAL_AUTHORITY_CONSUMER_CANCEL_REQUEST, + DAEMON_TERMINAL_AUTHORITY_CONSUMER_GRANT_REQUEST +} from './daemon-terminal-authority-consumer-requests' +import { + DAEMON_TERMINAL_AUTHORITY_BOUNDARY_ACCEPT_REQUEST, + DAEMON_TERMINAL_AUTHORITY_OUTCOME_ACK_REQUEST +} from './daemon-terminal-authority-outcome-requests' + +const MAX_PREACTIVATION_EVENTS = 256 + +export type DaemonTerminalAuthorityAppNamespaceEvent = + | Readonly<{ kind: 'boundary'; value: TerminalAuthorityNamespaceOutcomeBoundary }> + | Readonly<{ kind: 'outcome'; value: TerminalAuthorityNamespaceOutcomePublication }> + +export function parseDaemonTerminalAuthorityAppNamespaceEvent( + event: unknown, + payload: unknown +): DaemonTerminalAuthorityAppNamespaceEvent | null { + if (event === 'terminalAuthorityNamespaceOutcomeBoundary') { + const value = parseTerminalAuthorityNamespaceOutcomeBoundary(payload) + return value ? Object.freeze({ kind: 'boundary', value }) : null + } + if (event === 'terminalAuthorityNamespaceOutcome') { + const value = parseTerminalAuthorityNamespaceOutcomePublication(payload) + return value ? Object.freeze({ kind: 'outcome', value }) : null + } + return null +} + +type NamespaceConnectionOptions = Readonly<{ + client: DaemonTerminalAuthorityAppClient + proof: TerminalAuthorityNamespaceAdmissionProof + transport: TerminalAuthorityPolicyOutcomeTransport + retire: (requestId: string) => Promise + isCurrent: () => boolean + remove: () => void +}> + +export class DaemonTerminalAuthorityAppNamespaceConnection implements TerminalAuthorityAppOutcomeNamespaceConnection { + readonly expectedConsumer + private readonly buffered: DaemonTerminalAuthorityAppNamespaceEvent[] = [] + private installedGrant: TerminalAuthorityNamespaceAdmissionGrant | null = null + private deliveryTail: Promise = Promise.resolve() + private active = true + private activated = false + + constructor(private readonly options: NamespaceConnectionOptions) { + this.expectedConsumer = Object.freeze({ + consumerId: terminalAuthorityHostAppConsumerId( + options.proof.challenge.namespace.authorityHostId, + Uint8Array.from(Buffer.from(options.proof.challenge.appPublicKeyB64, 'base64')) + ), + consumerIncarnationId: options.proof.challenge.candidateProcessIncarnationId + }) + } + + get grant(): TerminalAuthorityNamespaceAdmissionGrant { + if (!this.installedGrant) { + throw new Error('daemon terminal authority namespace grant is unavailable') + } + return this.installedGrant + } + + async open(): Promise { + this.assertActive() + let unsafeGrant: unknown + try { + unsafeGrant = await this.options.client.request( + DAEMON_TERMINAL_AUTHORITY_CONSUMER_GRANT_REQUEST, + this.options.proof + ) + } catch (error) { + throw terminalAuthorityAppAdmissionRejection(error) ?? error + } + this.assertActive() + const grant = parseTerminalAuthorityNamespaceAdmissionGrant(unsafeGrant) + this.assertGrant(grant) + this.installedGrant = grant + } + + async activate(): Promise { + this.assertActive() + void this.grant + if (this.activated) { + return await this.deliveryTail + } + this.activated = true + const buffered = this.buffered.splice(0) + let settled = Promise.resolve() + for (const event of buffered) { + settled = this.enqueue(event) + } + await settled + } + + async acceptBoundary(unsafeAcceptance: unknown): Promise { + this.assertActive() + const acceptance = parseTerminalAuthorityNamespaceBoundaryAcceptance(unsafeAcceptance) + if (!acceptance || !this.matchesExpected(acceptance.consumer, acceptance.namespace)) { + throw new Error('daemon terminal authority boundary acceptance is invalid') + } + await this.options.client.request(DAEMON_TERMINAL_AUTHORITY_BOUNDARY_ACCEPT_REQUEST, acceptance) + } + + async acknowledge(unsafeAck: unknown): Promise { + this.assertActive() + const ack = parseTerminalAuthorityNamespaceOutcomeAck(unsafeAck) + if (!ack || !this.matchesGrant(ack.consumer, ack.namespace)) { + throw new Error('daemon terminal authority outcome ACK is invalid') + } + const result = await this.options.client.request<{ acknowledgedSequence?: unknown }>( + DAEMON_TERMINAL_AUTHORITY_OUTCOME_ACK_REQUEST, + ack + ) + if (!Number.isSafeInteger(result.acknowledgedSequence)) { + throw new Error('daemon terminal authority outcome ACK is invalid') + } + return Number(result.acknowledgedSequence) + } + + async retire(requestId: string): Promise { + this.assertActive() + const result = await this.options.retire(requestId) + this.close(false) + return result + } + + retired(): void { + this.close(false) + } + + publish(event: DaemonTerminalAuthorityAppNamespaceEvent): void { + if (!this.active || !this.matchesExpected(event.value.consumer, event.value.namespace)) { + this.fail(new Error('daemon terminal authority namespace outcome is invalid')) + return + } + if (!this.activated && event.kind === 'outcome') { + if (this.buffered.length >= MAX_PREACTIVATION_EVENTS) { + this.fail(new Error('daemon terminal authority preactivation events exceeded capacity')) + return + } + this.buffered.push(event) + return + } + void this.enqueue(event).catch(() => undefined) + } + + disconnect(): void { + this.close(true) + } + + abandon(): void { + this.close(false) + } + + private close(cancel: boolean): void { + if (!this.active) { + return + } + this.active = false + this.buffered.length = 0 + this.options.remove() + if (!cancel) { + return + } + const challenge = this.options.proof.challenge + const grant = this.installedGrant + void this.options.client + .request( + DAEMON_TERMINAL_AUTHORITY_CONSUMER_CANCEL_REQUEST, + { + version: TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION, + consumer: grant?.consumer ?? this.expectedConsumer, + namespace: grant?.namespace ?? challenge.namespace, + requestId: grant?.requestId ?? challenge.requestId, + connectionGrantId: grant?.connectionGrantId ?? challenge.connectionGrantId + }, + 5_000 + ) + .catch(() => undefined) + } + + private enqueue(event: DaemonTerminalAuthorityAppNamespaceEvent): Promise { + const delivery = this.deliveryTail.then(async () => { + this.assertActive() + await (event.kind === 'boundary' + ? this.options.transport.publishBoundary(event.value) + : this.options.transport.publishOutcome(event.value)) + }) + this.deliveryTail = delivery.catch(() => undefined) + void delivery.catch((error) => this.fail(error)) + return delivery + } + + private assertGrant( + grant: TerminalAuthorityNamespaceAdmissionGrant | null + ): asserts grant is TerminalAuthorityNamespaceAdmissionGrant { + const challenge = this.options.proof.challenge + if ( + !grant || + !sameTerminalAuthorityPolicyConsumer(grant.consumer, this.expectedConsumer) || + !sameNamespace(grant.namespace, challenge.namespace) || + grant.requestId !== challenge.requestId || + grant.connectionGrantId !== challenge.connectionGrantId || + grant.admissionCas !== + terminalAuthorityAdmissionCas( + challenge.namespace, + this.expectedConsumer.consumerId, + this.expectedConsumer.consumerIncarnationId + ) + ) { + throw new TerminalAuthorityAppAdmissionRejectedError( + 'daemon terminal authority grant is invalid' + ) + } + } + + private matchesGrant( + consumer: TerminalAuthorityNamespaceOutcomeBoundary['consumer'], + namespace: TerminalAuthorityNamespace + ): boolean { + return ( + this.installedGrant !== null && + sameTerminalAuthorityPolicyConsumer(this.installedGrant.consumer, consumer) && + sameNamespace(this.installedGrant.namespace, namespace) + ) + } + + private matchesExpected( + consumer: TerminalAuthorityNamespaceOutcomeBoundary['consumer'], + namespace: TerminalAuthorityNamespace + ): boolean { + return ( + sameTerminalAuthorityPolicyConsumer(this.expectedConsumer, consumer) && + sameNamespace(this.options.proof.challenge.namespace, namespace) + ) + } + + private fail(error: unknown): void { + if (!this.active) { + return + } + this.disconnect() + try { + this.options.transport.onFailure?.(error instanceof Error ? error : new Error(String(error))) + } catch { + // The namespace is already fenced. + } + } + + private assertActive(): void { + if (!this.active || !this.options.isCurrent()) { + throw new Error('daemon terminal authority namespace connection is stale') + } + } +} + +function sameNamespace( + left: TerminalAuthorityNamespace, + right: TerminalAuthorityNamespace +): boolean { + return left.authorityHostId === right.authorityHostId && left.namespaceId === right.namespaceId +} diff --git a/src/main/daemon/daemon-terminal-authority-app-retirement.ts b/src/main/daemon/daemon-terminal-authority-app-retirement.ts new file mode 100644 index 00000000000..b633d89f3ba --- /dev/null +++ b/src/main/daemon/daemon-terminal-authority-app-retirement.ts @@ -0,0 +1,32 @@ +import type { TerminalAuthorityAppConsumerRetirementRequest } from '../session-authority/terminal-authority-app-outcome-host-contract' +import { retireTerminalAuthorityAppConsumer } from '../session-authority/terminal-authority-app-consumer-retirement' +import type { DaemonTerminalAuthorityAppHostTransportOptions } from './daemon-terminal-authority-app-host-transport' +import { + DAEMON_TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_CHALLENGE_REQUEST, + DAEMON_TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_REQUEST +} from './daemon-terminal-authority-consumer-requests' + +export async function retireDaemonTerminalAuthorityAppConsumer( + options: DaemonTerminalAuthorityAppHostTransportOptions, + request: TerminalAuthorityAppConsumerRetirementRequest, + assertCurrent: () => void +) { + assertCurrent() + if (!options.client.terminalSessionAuthorityConsumerRetirementSupported?.()) { + throw new Error('daemon terminal authority consumer retirement is unsupported') + } + const result = await retireTerminalAuthorityAppConsumer({ + authenticatedAuthorityHostId: options.authenticatedAuthorityHostId, + keypair: options.keypair, + request, + issueChallenge: (start) => + options.client.request( + DAEMON_TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_CHALLENGE_REQUEST, + start + ), + complete: (proof) => + options.client.request(DAEMON_TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_REQUEST, proof) + }) + assertCurrent() + return result +} diff --git a/src/main/daemon/daemon-terminal-authority-consumer-requests.ts b/src/main/daemon/daemon-terminal-authority-consumer-requests.ts new file mode 100644 index 00000000000..670177cac12 --- /dev/null +++ b/src/main/daemon/daemon-terminal-authority-consumer-requests.ts @@ -0,0 +1,54 @@ +import type { + TerminalAuthorityNamespaceAdmissionCancellation, + TerminalAuthorityNamespaceAdmissionProof, + TerminalAuthorityNamespaceAdmissionStart +} from '../../shared/terminal-session-authority-consumer-proof' +import type { + TerminalAuthorityConsumerRetirementProof, + TerminalAuthorityConsumerRetirementStart +} from '../../shared/terminal-session-authority-consumer-retirement' + +export const DAEMON_TERMINAL_AUTHORITY_CONSUMER_CHALLENGE_REQUEST = + 'beginTerminalAuthorityConsumerAdmission' as const +export const DAEMON_TERMINAL_AUTHORITY_CONSUMER_GRANT_REQUEST = + 'completeTerminalAuthorityConsumerAdmission' as const +export const DAEMON_TERMINAL_AUTHORITY_CONSUMER_CANCEL_REQUEST = + 'cancelTerminalAuthorityConsumerAdmission' as const +export const DAEMON_TERMINAL_AUTHORITY_CONSUMER_RESOLVE_NAMESPACE_REQUEST = + 'resolveTerminalAuthorityConsumerNamespace' as const +export const DAEMON_TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_CHALLENGE_REQUEST = + 'beginTerminalAuthorityConsumerRetirement' as const +export const DAEMON_TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_REQUEST = + 'completeTerminalAuthorityConsumerRetirement' as const + +export type DaemonTerminalAuthorityConsumerRequest = + | Readonly<{ + id: string + type: typeof DAEMON_TERMINAL_AUTHORITY_CONSUMER_RESOLVE_NAMESPACE_REQUEST + payload: Readonly<{ worktreeId: string }> + }> + | Readonly<{ + id: string + type: typeof DAEMON_TERMINAL_AUTHORITY_CONSUMER_CHALLENGE_REQUEST + payload: TerminalAuthorityNamespaceAdmissionStart + }> + | Readonly<{ + id: string + type: typeof DAEMON_TERMINAL_AUTHORITY_CONSUMER_GRANT_REQUEST + payload: TerminalAuthorityNamespaceAdmissionProof + }> + | Readonly<{ + id: string + type: typeof DAEMON_TERMINAL_AUTHORITY_CONSUMER_CANCEL_REQUEST + payload: TerminalAuthorityNamespaceAdmissionCancellation + }> + | Readonly<{ + id: string + type: typeof DAEMON_TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_CHALLENGE_REQUEST + payload: TerminalAuthorityConsumerRetirementStart + }> + | Readonly<{ + id: string + type: typeof DAEMON_TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_REQUEST + payload: TerminalAuthorityConsumerRetirementProof + }> diff --git a/src/main/daemon/daemon-terminal-authority-outcome-requests.ts b/src/main/daemon/daemon-terminal-authority-outcome-requests.ts new file mode 100644 index 00000000000..a9c9b9b202a --- /dev/null +++ b/src/main/daemon/daemon-terminal-authority-outcome-requests.ts @@ -0,0 +1,33 @@ +import type { + TerminalAuthorityNamespaceBoundaryAcceptance, + TerminalAuthorityNamespaceOutcomeAck, + TerminalAuthorityPolicyConsumerRetirement +} from '../../shared/terminal-session-authority-consumer-transport' + +export const DAEMON_TERMINAL_AUTHORITY_OUTCOME_ACK_REQUEST = + 'ackTerminalAuthorityNamespaceOutcome' as const +export const DAEMON_TERMINAL_AUTHORITY_BOUNDARY_ACCEPT_REQUEST = + 'acceptTerminalAuthorityNamespaceBoundary' as const + +export type AcknowledgeTerminalAuthorityNamespaceOutcomeRequest = Readonly<{ + id: string + type: typeof DAEMON_TERMINAL_AUTHORITY_OUTCOME_ACK_REQUEST + payload: TerminalAuthorityNamespaceOutcomeAck +}> + +export type AcceptTerminalAuthorityNamespaceBoundaryRequest = Readonly<{ + id: string + type: typeof DAEMON_TERMINAL_AUTHORITY_BOUNDARY_ACCEPT_REQUEST + payload: TerminalAuthorityNamespaceBoundaryAcceptance +}> + +export type RetireTerminalAuthorityPolicyConsumerRequest = Readonly<{ + id: string + type: 'retireTerminalAuthorityPolicyConsumer' + payload: TerminalAuthorityPolicyConsumerRetirement +}> + +export type DaemonTerminalAuthorityOutcomeRequest = + | AcceptTerminalAuthorityNamespaceBoundaryRequest + | AcknowledgeTerminalAuthorityNamespaceOutcomeRequest + | RetireTerminalAuthorityPolicyConsumerRequest diff --git a/src/main/daemon/daemon-terminal-session-authority-capability.test.ts b/src/main/daemon/daemon-terminal-session-authority-capability.test.ts new file mode 100644 index 00000000000..1b2c43359ea --- /dev/null +++ b/src/main/daemon/daemon-terminal-session-authority-capability.test.ts @@ -0,0 +1,287 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { connect, type Socket } from 'node:net' +import { DaemonClient } from './client' +import { DaemonServer, type DaemonServerOptions } from './daemon-server' +import { encodeNdjson } from './ndjson' +import { getDaemonSocketPath } from './daemon-spawner' +import type { DaemonHelloCapabilities, HelloResponse } from './daemon-hello-protocol' +import { PROTOCOL_VERSION } from './types' +import type { TerminalSessionAuthorityPtyOwner } from '../session-authority/terminal-session-authority-pty-owner' +import type { TerminalAuthorityPolicyConsumerClaim } from '../../shared/terminal-session-authority-consumer-transport' + +type Readiness = { host: boolean } + +const servers: DaemonServer[] = [] +const clients: DaemonClient[] = [] +const directories: string[] = [] +const POLICY_CLAIM: TerminalAuthorityPolicyConsumerClaim = Object.freeze({ + version: 1, + consumer: Object.freeze({ + consumerId: 'app-profile:daemon-capability-test', + consumerIncarnationId: 'app-process:daemon-capability-test' + }), + expectedConsumerIncarnationId: null +}) + +afterEach(async () => { + for (const client of clients.splice(0)) { + client.disconnect() + } + for (const server of servers.splice(0)) { + await server.shutdown() + } + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +describe('daemon terminal session authority capability', () => { + it.each([ + { host: true, authority: true, expected: true }, + { host: false, authority: true, expected: false }, + { host: true, authority: false, expected: false } + ])('echoes only with host=$host authority=$authority', async ({ host, authority, expected }) => { + const readiness = { host } + const harness = await startServer(readiness, { authority }) + const client = new DaemonClient({ + ...harness, + terminalSessionAuthorityConsumerProofReady: () => true + }) + clients.push(client) + + await client.ensureConnected() + + expect(client.supportsTerminalSessionAuthority()).toBe(expected) + }) + + it.each([34, 35])('does not echo to a version-only v%s client', async (protocolVersion) => { + const harness = await startServer({ host: true }, { authority: true, protocolVersion }) + const response = await exchangeVersionOnlyHello(harness, protocolVersion) + + expect(response.ok).toBe(true) + expect(response.capabilities).toBeUndefined() + }) + + it('does not downgrade an unnegotiated proof offer to a client-supplied consumer', async () => { + const harness = await startServer({ host: true }, { authority: true }) + const response = await exchangeHelloCapabilities(harness, { + terminalSessionAuthority: 1, + terminalAuthorityConsumerProof: { versions: [2] }, + terminalAuthorityNamespaceOutcomes: POLICY_CLAIM + } as DaemonHelloCapabilities) + + expect(response.ok).toBe(true) + expect(response.capabilities).toBeUndefined() + }) + + it('negotiates no authority for a proofless peer that names its own consumer', async () => { + const harness = await startServer({ host: true }, { authority: true }) + const response = await exchangeHelloCapabilities(harness, { + terminalSessionAuthority: 1, + terminalAuthorityNamespaceOutcomes: POLICY_CLAIM + } as DaemonHelloCapabilities) + + expect(response.ok).toBe(true) + expect(response.capabilities).toBeUndefined() + }) + + it('refuses authority create metadata from a proofless peer before any spawn preparation', async () => { + const preparePtySpawn = vi.fn(async () => undefined) + const spawnSubprocess = vi.fn(() => { + throw new Error('spawn must not run') + }) + const harness = await startServer( + { host: true }, + { authority: true, preparePtySpawn, spawnSubprocess } + ) + const client = new DaemonClient({ ...harness }) + clients.push(client) + await client.ensureConnected() + + expect(client.supportsTerminalSessionAuthority()).toBe(false) + await expect(client.request('createOrAttach', authorityCreatePayload())).rejects.toThrow( + 'terminal_session_authority_unavailable' + ) + expect(preparePtySpawn).not.toHaveBeenCalled() + expect(spawnSubprocess).not.toHaveBeenCalled() + }) + + it('rejects authority metadata before spawn preparation when capability was not negotiated', async () => { + const preparePtySpawn = vi.fn(async () => undefined) + const spawnSubprocess = vi.fn(() => { + throw new Error('spawn must not run') + }) + const harness = await startServer( + { host: false }, + { authority: true, preparePtySpawn, spawnSubprocess } + ) + const client = new DaemonClient({ + ...harness, + terminalSessionAuthorityConsumerProofReady: () => true + }) + clients.push(client) + await client.ensureConnected() + + await expect(client.request('createOrAttach', authorityCreatePayload())).rejects.toThrow( + 'terminal_session_authority_unavailable' + ) + expect(preparePtySpawn).not.toHaveBeenCalled() + expect(spawnSubprocess).not.toHaveBeenCalled() + }) + + it('fails before spawn preparation when host effects become unavailable', async () => { + const readiness = { host: true } + const preparePtySpawn = vi.fn(async () => undefined) + const spawnSubprocess = vi.fn(() => { + throw new Error('spawn must not run') + }) + const harness = await startServer(readiness, { + authority: true, + preparePtySpawn, + spawnSubprocess + }) + const client = new DaemonClient({ + ...harness, + terminalSessionAuthorityConsumerProofReady: () => true + }) + clients.push(client) + await client.ensureConnected() + expect(client.supportsTerminalSessionAuthority()).toBe(true) + readiness.host = false + + await expect(client.request('createOrAttach', authorityCreatePayload())).rejects.toThrow( + 'terminal_session_authority_unavailable' + ) + expect(preparePtySpawn).not.toHaveBeenCalled() + expect(spawnSubprocess).not.toHaveBeenCalled() + }) +}) + +async function startServer( + readiness: Readiness, + options: { + authority: boolean + protocolVersion?: number + preparePtySpawn?: DaemonServerOptions['preparePtySpawn'] + spawnSubprocess?: DaemonServerOptions['spawnSubprocess'] + } +): Promise<{ socketPath: string; tokenPath: string; protocolVersion?: number }> { + const directory = mkdtempSync(join(tmpdir(), 'daemon-authority-capability-')) + directories.push(directory) + const socketPath = getDaemonSocketPath(directory) + const tokenPath = join(directory, 'daemon.token') + const server = new DaemonServer({ + socketPath, + tokenPath, + ...(options.protocolVersion !== undefined ? { protocolVersion: options.protocolVersion } : {}), + spawnSubprocess: + options.spawnSubprocess ?? + (() => { + throw new Error('unexpected spawn') + }), + ...(options.preparePtySpawn ? { preparePtySpawn: options.preparePtySpawn } : {}), + ...(options.authority ? { terminalSessionAuthority: authorityComponents() } : {}), + terminalSessionAuthorityCapabilityReadiness: { + hostEffectConsumerInstalled: () => readiness.host + } + }) + servers.push(server) + await server.start() + return { + socketPath, + tokenPath, + ...(options.protocolVersion !== undefined ? { protocolVersion: options.protocolVersion } : {}) + } +} + +function authorityComponents(): NonNullable { + return { + authorityHostId: 'authority-host:daemon-capability-test', + ptyOwner: { + releaseAuthenticatedPolicyConsumerTransport: () => {} + } as unknown as TerminalSessionAuthorityPtyOwner + } +} + +async function exchangeHelloCapabilities( + harness: { socketPath: string; tokenPath: string; protocolVersion?: number }, + capabilities: DaemonHelloCapabilities +): Promise { + const socket = await connectSocket(harness.socketPath) + try { + const response = readHelloResponse(socket) + socket.write( + encodeNdjson({ + type: 'hello', + version: harness.protocolVersion ?? PROTOCOL_VERSION, + token: readFileSync(harness.tokenPath, 'utf8').trim(), + clientId: 'capability-client', + role: 'control', + capabilities + }) + ) + return await response + } finally { + socket.destroy() + } +} + +function authorityCreatePayload(): Record { + return { + sessionId: 'authority-session', + cols: 80, + rows: 24, + terminalSessionAuthorityVersion: 1, + terminalSessionAuthorityOperationId: 'operation-a', + worktreeId: 'repo::/srv/repo', + paneKey: 'pane-a', + paneGeneration: 1 + } +} + +async function exchangeVersionOnlyHello( + harness: { socketPath: string; tokenPath: string }, + protocolVersion: number +): Promise { + const socket = await connectSocket(harness.socketPath) + try { + const response = readHelloResponse(socket) + socket.write( + encodeNdjson({ + type: 'hello', + version: protocolVersion, + token: readFileSync(harness.tokenPath, 'utf8').trim(), + clientId: 'version-only-client', + role: 'control' + }) + ) + return await response + } finally { + socket.destroy() + } +} + +function connectSocket(socketPath: string): Promise { + return new Promise((resolve, reject) => { + const socket = connect(socketPath) + socket.once('connect', () => resolve(socket)) + socket.once('error', reject) + }) +} + +function readHelloResponse(socket: Socket): Promise { + return new Promise((resolve, reject) => { + let buffer = '' + socket.on('data', (chunk) => { + buffer += chunk.toString('utf8') + const newline = buffer.indexOf('\n') + if (newline !== -1) { + resolve(JSON.parse(buffer.slice(0, newline)) as HelloResponse) + } + }) + socket.once('error', reject) + }) +} diff --git a/src/main/daemon/degraded-daemon-pty-provider-authority.test.ts b/src/main/daemon/degraded-daemon-pty-provider-authority.test.ts new file mode 100644 index 00000000000..0483f23c6fb --- /dev/null +++ b/src/main/daemon/degraded-daemon-pty-provider-authority.test.ts @@ -0,0 +1,224 @@ +import { describe, expect, it, vi } from 'vitest' +import { DegradedDaemonPtyProvider } from './degraded-daemon-pty-provider' +import type { DaemonPtyAdapter } from './daemon-pty-adapter' +import type { IPtyProvider, PtyProcessInfo, PtySpawnOptions } from '../providers/types' +import type { TerminalSessionAuthorityPtyAccess } from '../../shared/terminal-session-authority-pty-access' + +type ExactDataEvent = { id: string; data: string; incarnationId?: string } + +type ProviderMock = IPtyProvider & { + rows: PtyProcessInfo[] + emitData: (payload: ExactDataEvent) => void + emitIdentityChange: () => void +} + +function authorityAccess( + physicalPtyId: string, + ptyIncarnationId: string +): TerminalSessionAuthorityPtyAccess { + return { + namespace: { authorityHostId: 'authority-host', namespaceId: 'namespace' }, + pane: { paneKey: physicalPtyId, paneGenerationId: 'renderer:1' }, + binding: { + ownerIncarnationId: 'owner-incarnation', + physicalPtyId, + ptyIncarnationId + } + } +} + +function listed(id: string, incarnationId: string, token: object): PtyProcessInfo { + return { + id, + incarnationId, + mutationRouteToken: token, + terminalSessionAuthorityAccess: authorityAccess(id, incarnationId), + cwd: '', + title: 'shell' + } +} + +function createProvider(rows: PtyProcessInfo[]): ProviderMock { + const dataListeners: ((payload: ExactDataEvent) => void)[] = [] + const identityListeners: (() => void)[] = [] + const provider = { + rows, + spawn: vi.fn(async (opts: PtySpawnOptions) => ({ id: opts.sessionId ?? 'spawned' })), + attach: vi.fn(async () => {}), + hasPty: vi.fn((id: string) => rows.some((row) => row.id === id)), + probePtyLiveness: vi.fn(async (id: string) => rows.some((row) => row.id === id)), + providesAgentSessionOwnerListings: vi.fn(() => true), + write: vi.fn(), + resize: vi.fn(), + supportsExactPtyOperations: vi.fn(() => true), + getPtyMutationMode: vi.fn(() => 'exact'), + writeExact: vi.fn(() => true), + resizeExact: vi.fn(() => true), + killExact: vi.fn(async () => true), + sendSignalExact: vi.fn(async () => true), + clearBufferExact: vi.fn(async () => true), + writeAuthorityExact: vi.fn(() => true), + resizeAuthorityExact: vi.fn(() => true), + killAuthorityExact: vi.fn(async () => true), + sendSignalAuthorityExact: vi.fn(async () => true), + clearBufferAuthorityExact: vi.fn(async () => true), + bindTerminalSessionAuthorityAccess: vi.fn(() => true), + shutdown: vi.fn(async () => {}), + sendSignal: vi.fn(async () => {}), + getCwd: vi.fn(async () => ''), + getInitialCwd: vi.fn(async () => ''), + clearBuffer: vi.fn(async () => {}), + acknowledgeDataEvent: vi.fn(), + hasChildProcesses: vi.fn(async () => false), + getForegroundProcess: vi.fn(async () => null), + serialize: vi.fn(async () => '{}'), + revive: vi.fn(async () => {}), + listProcesses: vi.fn(async () => rows), + getPtyMutationRouteToken: vi.fn( + (id: string) => rows.find((row) => row.id === id)?.mutationRouteToken ?? null + ), + getDefaultShell: vi.fn(async () => '/bin/sh'), + getProfiles: vi.fn(async () => []), + onData: vi.fn((callback: (payload: ExactDataEvent) => void) => { + dataListeners.push(callback) + return () => {} + }), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}), + onWriteUnavailable: vi.fn(() => () => {}), + onDaemonIdentityChanged: vi.fn((callback: () => void) => { + identityListeners.push(callback) + return () => {} + }), + reconcileOnStartup: vi.fn(async () => ({ alive: [], killed: [] })), + ackColdRestore: vi.fn(), + clearTombstone: vi.fn(), + disconnectOnly: vi.fn(async () => {}), + dispose: vi.fn(), + emitData: (payload: ExactDataEvent) => { + for (const listener of dataListeners) { + listener(payload) + } + }, + emitIdentityChange: () => { + for (const listener of identityListeners) { + listener() + } + } + } + return provider as unknown as ProviderMock +} + +function asDaemon(provider: ProviderMock): DaemonPtyAdapter { + return provider as unknown as DaemonPtyAdapter +} + +describe('DegradedDaemonPtyProvider authority routing', () => { + it('forwards authority operations to current and legacy daemon owners', async () => { + const current = createProvider([listed('current-session', 'current-incarnation', {})]) + const legacy = createProvider([listed('legacy-session', 'legacy-incarnation', {})]) + const fallback = createProvider([]) + const provider = new DegradedDaemonPtyProvider({ + current: asDaemon(current), + legacy: [asDaemon(legacy)], + fallback + }) + await provider.listProcesses() + + for (const [owner, id, incarnationId] of [ + [current, 'current-session', 'current-incarnation'], + [legacy, 'legacy-session', 'legacy-incarnation'] + ] as const) { + const access = authorityAccess(id, incarnationId) + expect(provider.bindTerminalSessionAuthorityAccess(id, access)).toBe(true) + expect(provider.writeAuthorityExact(id, access, 'input')).toBe(true) + expect(provider.resizeAuthorityExact(id, access, 120, 40)).toBe(true) + await expect(provider.sendSignalAuthorityExact(id, access, 'SIGTERM')).resolves.toBe(true) + await expect(provider.clearBufferAuthorityExact(id, access)).resolves.toBe(true) + await expect(provider.killAuthorityExact(id, access, { immediate: true })).resolves.toBe(true) + expect(owner.writeAuthorityExact).toHaveBeenCalledWith(id, access, 'input') + expect(owner.killAuthorityExact).toHaveBeenCalledWith(id, access, { immediate: true }) + } + }) + + it('fails closed for a fallback-versus-daemon collision', async () => { + const access = authorityAccess('collision', 'incarnation') + const current = createProvider([listed('collision', 'incarnation', {})]) + const fallback = createProvider([listed('collision', 'incarnation', {})]) + const provider = new DegradedDaemonPtyProvider({ + current: asDaemon(current), + legacy: [], + fallback + }) + + const inventory = await provider.listProcesses() + + expect(inventory.every((row) => row.mutationRouteToken === undefined)).toBe(true) + expect(provider.getPtyMutationRouteToken('collision')).toBeNull() + expect(provider.writeExact('collision', 'incarnation', 'blocked')).toBe(false) + expect(provider.writeAuthorityExact('collision', access, 'blocked')).toBe(false) + expect(provider.bindTerminalSessionAuthorityAccess('collision', access)).toBe(false) + expect(current.writeAuthorityExact).not.toHaveBeenCalled() + expect(fallback.writeAuthorityExact).not.toHaveBeenCalled() + }) + + it('moves route identity with inventory and invalidates it on daemon replacement', async () => { + const oldToken = {} + const newToken = {} + const current = createProvider([]) + const legacy = createProvider([listed('moving', 'incarnation', oldToken)]) + const fallback = createProvider([]) + const provider = new DegradedDaemonPtyProvider({ + current: asDaemon(current), + legacy: [asDaemon(legacy)], + fallback + }) + const [oldRow] = await provider.listProcesses() + legacy.rows.splice(0) + fallback.rows.push(listed('moving', 'incarnation', newToken)) + const [newRow] = await provider.listProcesses() + + expect(oldRow?.mutationRouteToken).toBe(oldToken) + expect(newRow?.mutationRouteToken).toBe(newToken) + expect(provider.getPtyMutationRouteToken('moving')).toBe(newToken) + const access = authorityAccess('moving', 'incarnation') + expect(provider.writeAuthorityExact('moving', access, 'fallback')).toBe(true) + expect(fallback.writeAuthorityExact).toHaveBeenCalledWith('moving', access, 'fallback') + + fallback.rows.splice(0) + current.rows.push(listed('moving', 'incarnation', {})) + await provider.listProcesses() + current.emitIdentityChange() + expect(provider.getPtyMutationRouteToken('moving')).toBeNull() + expect(provider.writeAuthorityExact('moving', access, 'blocked')).toBe(false) + }) + + it('rejects aggregate inventory when any provider list fails', async () => { + const current = createProvider([]) + const fallback = createProvider([]) + vi.mocked(current.listProcesses).mockRejectedValue(new Error('daemon unavailable')) + const provider = new DegradedDaemonPtyProvider({ + current: asDaemon(current), + legacy: [], + fallback + }) + + await expect(provider.listProcesses()).rejects.toThrow('daemon unavailable') + }) + + it('preserves the admitted incarnation through degraded data fanout', () => { + const current = createProvider([]) + const fallback = createProvider([]) + const provider = new DegradedDaemonPtyProvider({ + current: asDaemon(current), + legacy: [], + fallback + }) + const received: ExactDataEvent[] = [] + provider.onData((payload) => received.push(payload)) + + current.emitData({ id: 'same-id', data: 'successor', incarnationId: 'incarnation-b' }) + + expect(received).toEqual([{ id: 'same-id', data: 'successor', incarnationId: 'incarnation-b' }]) + }) +}) diff --git a/src/main/daemon/degraded-daemon-pty-provider.test.ts b/src/main/daemon/degraded-daemon-pty-provider.test.ts index af7c30d1c5d..4eb60601577 100644 --- a/src/main/daemon/degraded-daemon-pty-provider.test.ts +++ b/src/main/daemon/degraded-daemon-pty-provider.test.ts @@ -21,6 +21,7 @@ function createProvider( sessions: string[] = [], authoritativeOwnerListings = false ): ProviderMock { + const mutationRouteToken = Object.freeze({ label }) const dataListeners: ((payload: { id: string; data: string; sequenceChars?: number }) => void)[] = [] const replayListeners: ((payload: { id: string; data: string }) => void)[] = [] @@ -34,6 +35,15 @@ function createProvider( }), attach: vi.fn(async () => {}), hasPty: vi.fn((id: string) => sessions.includes(id)), + supportsExactPtyOperations: vi.fn((id: string) => sessions.includes(id)), + supportsExactHeldProducerPause: vi.fn((id: string) => sessions.includes(id)), + acquireExactHeldProducerPause: vi.fn(async (id: string) => sessions.includes(id)), + releaseExactHeldProducerPause: vi.fn(async (id: string) => sessions.includes(id)), + writeExact: vi.fn((id: string) => sessions.includes(id)), + resizeExact: vi.fn((id: string) => sessions.includes(id)), + killExact: vi.fn(async (id: string) => sessions.includes(id)), + sendSignalExact: vi.fn(async (id: string) => sessions.includes(id)), + clearBufferExact: vi.fn(async (id: string) => sessions.includes(id)), probePtyLiveness: vi.fn(async (id: string) => sessions.includes(id)), providesAgentSessionOwnerListings: vi.fn(() => authoritativeOwnerListings), write: vi.fn(), @@ -55,7 +65,12 @@ function createProvider( confirmForegroundProcess: vi.fn(async () => `${label}-confirmed`), serialize: vi.fn(async () => '{}'), revive: vi.fn(async () => {}), - listProcesses: vi.fn(async () => sessions.map((id) => ({ id, cwd: '', title: label }))), + listProcesses: vi.fn(async () => + sessions.map((id) => ({ id, mutationRouteToken, cwd: '', title: label })) + ), + getPtyMutationRouteToken: vi.fn((id: string) => + sessions.includes(id) ? mutationRouteToken : null + ), getDefaultShell: vi.fn(async () => '/bin/zsh'), getProfiles: vi.fn(async () => []), onData: vi.fn( @@ -298,6 +313,68 @@ describe('DegradedDaemonPtyProvider', () => { expect(fallback.write).toHaveBeenCalledWith(fresh.id, 'new\n') }) + it('routes every exact mutation to the provider that owns the incarnation', async () => { + const current = createDaemonAdapter('daemon', ['daemon-session']) + const fallback = createProvider('fallback') + const provider = new DegradedDaemonPtyProvider({ current, legacy: [], fallback }) + await provider.discoverDaemonSessions() + const fresh = await provider.spawn({ cols: 80, rows: 24 }) + + expect(provider.writeExact('daemon-session', 'daemon-incarnation', 'daemon input')).toBe(true) + expect(provider.resizeExact(fresh.id, 'fallback-incarnation', 100, 30)).toBe(true) + await expect( + provider.sendSignalExact('daemon-session', 'daemon-incarnation', 'SIGTERM') + ).resolves.toBe(true) + await expect(provider.clearBufferExact(fresh.id, 'fallback-incarnation')).resolves.toBe(true) + await expect( + provider.killExact('daemon-session', 'daemon-incarnation', { immediate: true }) + ).resolves.toBe(true) + + expect(current.writeExact).toHaveBeenCalledWith( + 'daemon-session', + 'daemon-incarnation', + 'daemon input' + ) + expect(fallback.resizeExact).toHaveBeenCalledWith(fresh.id, 'fallback-incarnation', 100, 30) + expect(current.sendSignalExact).toHaveBeenCalledWith( + 'daemon-session', + 'daemon-incarnation', + 'SIGTERM' + ) + expect(fallback.clearBufferExact).toHaveBeenCalledWith(fresh.id, 'fallback-incarnation') + expect(current.killExact).toHaveBeenCalledWith('daemon-session', 'daemon-incarnation', { + immediate: true + }) + }) + + it('routes exact held-pause leases across daemon and local fallback owners', async () => { + const current = createDaemonAdapter('daemon', ['daemon-session']) + const fallback = createProvider('fallback') + const provider = new DegradedDaemonPtyProvider({ current, legacy: [], fallback }) + await provider.discoverDaemonSessions() + const fresh = await provider.spawn({ cols: 80, rows: 24 }) + + await expect( + provider.acquireExactHeldProducerPause('daemon-session', 'daemon-incarnation', 'daemon-lease') + ).resolves.toBe(true) + await expect( + provider.acquireExactHeldProducerPause(fresh.id, 'local-incarnation', 'local-lease') + ).resolves.toBe(true) + await expect( + provider.releaseExactHeldProducerPause(fresh.id, 'local-incarnation', 'local-lease') + ).resolves.toBe(true) + expect(current.acquireExactHeldProducerPause).toHaveBeenCalledWith( + 'daemon-session', + 'daemon-incarnation', + 'daemon-lease' + ) + expect(fallback.acquireExactHeldProducerPause).toHaveBeenCalledWith( + fresh.id, + 'local-incarnation', + 'local-lease' + ) + }) + it('routes later fresh PTYs to the daemon after spawn health recovers', async () => { const current = createDaemonAdapter('daemon') const fallback = createProvider('fallback') diff --git a/src/main/daemon/degraded-daemon-pty-provider.ts b/src/main/daemon/degraded-daemon-pty-provider.ts index 6b1c738b316..899ccbbe9d4 100644 --- a/src/main/daemon/degraded-daemon-pty-provider.ts +++ b/src/main/daemon/degraded-daemon-pty-provider.ts @@ -19,8 +19,13 @@ import { } from './degraded-daemon-session-routing' import { DegradedDaemonFreshSpawnRouter } from './degraded-daemon-fresh-spawn-routing' import { DegradedDaemonOwnerRecovery } from './degraded-daemon-owner-recovery' +import { reconcileDaemonPtyProviderInventory } from './daemon-pty-router-inventory' +import { DaemonPtyExactOperationRouter } from './daemon-pty-router-exact-operations' -export class DegradedDaemonPtyProvider implements IPtyProvider { +export class DegradedDaemonPtyProvider + extends DaemonPtyExactOperationRouter + implements IPtyProvider +{ readonly isDegraded = true private current: DaemonPtyAdapter @@ -39,6 +44,7 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { fallback: IPtyProvider probeCurrentDaemonSpawn?: () => Promise }) { + super() this.current = opts.current this.legacy = opts.legacy this.fallback = opts.fallback @@ -94,9 +100,7 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { return mapped ? (mapped.hasPty?.(id) ?? true) : this.findProviderForExistingSession(id) !== null } - async probePtyLiveness(id: string): Promise { - return await this.ownerRecovery.probe(id) - } + probePtyLiveness = (id: string): Promise => this.ownerRecovery.probe(id) // Why: an unknown id cannot borrow listing authority from the fresh-spawn provider. providesAgentSessionOwnerListings = (ptyId: string): boolean => @@ -194,9 +198,16 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { async listProcesses(opts?: { deadlineMs?: number }): Promise { const results = await Promise.all( - this.allProviders().map((provider) => provider.listProcesses(opts)) + this.allProviders().map(async (provider) => ({ + provider, + processes: await provider.listProcesses(opts) + })) ) - return results.flat() + return reconcileDaemonPtyProviderInventory(results, { + existingRouteIds: this.sessionProviders.keys(), + recordRoute: (id, owner) => this.ownerRecovery.recordRoute(id, owner), + forgetRoute: (id) => this.ownerRecovery.forgetRoute(id) + }) } async getDefaultShell(): Promise { @@ -334,6 +345,10 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { ) } + protected exactProviderFor(sessionId: string): IPtyProvider | null { + return this.sessionProviders.get(sessionId) ?? null + } + private findProviderForExistingSession(sessionId: string): IPtyProvider | null { return adoptOwningProvider(this.sessionProviders, this.allProviders(), sessionId) } diff --git a/src/main/daemon/session-held-producer-pause.test.ts b/src/main/daemon/session-held-producer-pause.test.ts new file mode 100644 index 00000000000..ea46842640d --- /dev/null +++ b/src/main/daemon/session-held-producer-pause.test.ts @@ -0,0 +1,124 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { PRODUCER_PAUSE_FAILSAFE_MS, Session, type SubprocessHandle } from './session' + +function createSubprocess(): SubprocessHandle & { + pauseCalls: number + resumeCalls: number +} { + return { + pid: 123, + pauseCalls: 0, + resumeCalls: 0, + getForegroundProcess: () => null, + write: () => {}, + resize: () => {}, + pause() { + this.pauseCalls += 1 + }, + resume() { + this.resumeCalls += 1 + }, + kill: () => {}, + forceKill: () => {}, + signal: () => {}, + onData: () => {}, + onExit: () => {}, + dispose: () => {} + } +} + +describe('Session exact held producer pause', () => { + let session: Session + let subprocess: ReturnType + + beforeEach(() => { + vi.useFakeTimers() + subprocess = createSubprocess() + session = new Session({ + sessionId: 'session', + cols: 80, + rows: 24, + subprocess, + shellReadySupported: false + }) + }) + + afterEach(() => { + session.dispose() + vi.useRealTimers() + }) + + it('stays paused beyond the legacy failsafe until explicit release', () => { + expect(session.supportsExactHeldProducerPause()).toBe(true) + expect(session.acquireExactHeldProducerPause('client-a', 'lease-a')).toBe(true) + + vi.advanceTimersByTime(PRODUCER_PAUSE_FAILSAFE_MS * 2) + expect(subprocess.pauseCalls).toBe(1) + expect(subprocess.resumeCalls).toBe(0) + + expect(session.releaseExactHeldProducerPause('client-a', 'lease-a')).toBe(true) + expect(session.releaseExactHeldProducerPause('client-a', 'lease-a')).toBe(true) + expect(subprocess.resumeCalls).toBe(1) + }) + + it('resumes only after every owner releases its idempotent lease', () => { + expect(session.acquireExactHeldProducerPause('client-a', 'lease')).toBe(true) + expect(session.acquireExactHeldProducerPause('client-a', 'lease')).toBe(true) + expect(session.acquireExactHeldProducerPause('client-b', 'lease')).toBe(true) + expect(subprocess.pauseCalls).toBe(1) + + expect(session.releaseExactHeldProducerPause('client-a', 'lease')).toBe(true) + expect(subprocess.resumeCalls).toBe(0) + expect(session.releaseExactHeldProducerPause('client-b', 'lease')).toBe(true) + expect(subprocess.resumeCalls).toBe(1) + }) + + it('keeps an exact hold through legacy failsafe expiry', () => { + session.pauseProducer() + expect(session.acquireExactHeldProducerPause('client-a', 'lease')).toBe(true) + + vi.advanceTimersByTime(PRODUCER_PAUSE_FAILSAFE_MS) + expect(subprocess.resumeCalls).toBe(0) + expect(session.releaseExactHeldProducerPause('client-a', 'lease')).toBe(true) + expect(subprocess.resumeCalls).toBe(1) + }) + + it('cleans up only the disconnected owner leases', () => { + session.acquireExactHeldProducerPause('client-a', 'lease-a') + session.acquireExactHeldProducerPause('client-b', 'lease-b') + + session.releaseExactHeldProducerPauses('client-a') + expect(subprocess.resumeCalls).toBe(0) + session.releaseExactHeldProducerPauses('client-b') + expect(subprocess.resumeCalls).toBe(1) + }) + + it('does not let renderer reattachment implicitly release an exact lease', () => { + session.attachClient({ onData: () => {}, onExit: () => {} }) + session.acquireExactHeldProducerPause('client-a', 'lease') + + session.detachAllClients() + expect(subprocess.resumeCalls).toBe(0) + session.attachClient({ onData: () => {}, onExit: () => {} }) + expect(subprocess.resumeCalls).toBe(0) + session.releaseExactHeldProducerPause('client-a', 'lease') + expect(subprocess.resumeCalls).toBe(1) + }) + + it('does not advertise a subprocess without native flow control', () => { + session.dispose() + const { pause: _pause, resume: _resume, ...unsupported } = createSubprocess() + void _pause + void _resume + session = new Session({ + sessionId: 'unsupported', + cols: 80, + rows: 24, + subprocess: unsupported, + shellReadySupported: false + }) + + expect(session.supportsExactHeldProducerPause()).toBe(false) + expect(session.acquireExactHeldProducerPause('client-a', 'lease')).toBe(false) + }) +}) diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index d9c5553d3e8..7566dd673e2 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -126,7 +126,10 @@ export class Session { private pendingOutputSeq = 0 private outputSequence = 0 private producerPaused = false + private legacyProducerPauseActive = false private producerPauseFailsafeTimer: ReturnType | null = null + private heldProducerPauseCount = 0 + private heldProducerPauseTokensByOwner = new Map>() private readonly _historySeeded: boolean | undefined private forceKillSent = false private subprocessDisposed = false @@ -254,6 +257,7 @@ export class Session { if (this._state === 'exited' || this._disposed) { return } + this.legacyProducerPauseActive = true this.producerPaused = true this.subprocess.pause?.() if (this.producerPauseFailsafeTimer) { @@ -261,13 +265,97 @@ export class Session { } this.producerPauseFailsafeTimer = setTimeout(() => { this.producerPauseFailsafeTimer = null - this.producerPaused = false - this.subprocess.resume?.() + this.legacyProducerPauseActive = false + if (this.heldProducerPauseCount === 0) { + this.producerPaused = false + this.subprocess.resume?.() + } }, PRODUCER_PAUSE_FAILSAFE_MS) } resumeProducer(): void { - this.releaseProducerPause({ resume: true }) + if (this.producerPauseFailsafeTimer) { + clearTimeout(this.producerPauseFailsafeTimer) + this.producerPauseFailsafeTimer = null + } + this.legacyProducerPauseActive = false + if (this.heldProducerPauseCount === 0 && this.producerPaused) { + this.producerPaused = false + this.subprocess.resume?.() + } + } + + supportsExactHeldProducerPause(): boolean { + return ( + this._state !== 'exited' && + !this._disposed && + !this._isTerminating && + typeof this.subprocess.pause === 'function' && + typeof this.subprocess.resume === 'function' + ) + } + + acquireExactHeldProducerPause(ownerId: string, token: string): boolean { + if (!this.supportsExactHeldProducerPause()) { + return false + } + const ownerTokens = this.heldProducerPauseTokensByOwner.get(ownerId) + if (ownerTokens?.has(token)) { + return true + } + if (this.heldProducerPauseCount === 0) { + try { + this.subprocess.pause!() + } catch { + return false + } + this.producerPaused = true + } + const tokens = ownerTokens ?? new Set() + tokens.add(token) + this.heldProducerPauseTokensByOwner.set(ownerId, tokens) + this.heldProducerPauseCount += 1 + return true + } + + releaseExactHeldProducerPause(ownerId: string, token: string): boolean { + const ownerTokens = this.heldProducerPauseTokensByOwner.get(ownerId) + if (!ownerTokens?.has(token)) { + return this._state !== 'exited' && !this._disposed + } + const isLastHeldPause = this.heldProducerPauseCount === 1 + if (isLastHeldPause && !this.legacyProducerPauseActive) { + try { + this.subprocess.resume?.() + } catch { + return false + } + this.producerPaused = false + } + ownerTokens.delete(token) + this.heldProducerPauseCount -= 1 + if (ownerTokens.size === 0) { + this.heldProducerPauseTokensByOwner.delete(ownerId) + } + return true + } + + releaseExactHeldProducerPauses(ownerId: string): void { + const ownerTokens = this.heldProducerPauseTokensByOwner.get(ownerId) + if (!ownerTokens) { + return + } + this.heldProducerPauseTokensByOwner.delete(ownerId) + this.heldProducerPauseCount = Math.max(0, this.heldProducerPauseCount - ownerTokens.size) + if (this.heldProducerPauseCount > 0 || this.legacyProducerPauseActive) { + return + } + this.producerPaused = false + try { + this.subprocess.resume?.() + } catch { + /* Connection cleanup cannot retry a torn-down handle. */ + } } private releaseProducerPause(opts: { resume: boolean }): void { @@ -275,6 +363,9 @@ export class Session { clearTimeout(this.producerPauseFailsafeTimer) this.producerPauseFailsafeTimer = null } + this.legacyProducerPauseActive = false + this.heldProducerPauseTokensByOwner.clear() + this.heldProducerPauseCount = 0 if (!this.producerPaused) { return } @@ -393,15 +484,15 @@ export class Session { if (idx !== -1) { this.attachedClients.splice(idx, 1) } - // Why: with no attached client nobody will send resumePty, so a paused shell would wedge until the failsafe; resume eagerly. + // Why: exact leases are connection-owned; detach only retires the legacy failsafe pause. if (this.attachedClients.length === 0) { - this.releaseProducerPause({ resume: true }) + this.resumeProducer() } } detachAllClients(): void { this.attachedClients.length = 0 - this.releaseProducerPause({ resume: true }) + this.resumeProducer() } getSnapshot(opts: { scrollbackRows?: number } = {}): TerminalSnapshot | null { diff --git a/src/main/daemon/terminal-host-authority-consumer-routing.ts b/src/main/daemon/terminal-host-authority-consumer-routing.ts new file mode 100644 index 00000000000..43b2ffadb11 --- /dev/null +++ b/src/main/daemon/terminal-host-authority-consumer-routing.ts @@ -0,0 +1,22 @@ +import type { CreateOrAttachOptions } from './terminal-host-create-contract' +import type { InternalCreateOrAttachOptions } from './terminal-host-agent-session-claim' +import type { TerminalAuthorityPolicyConsumerSource } from '../session-authority/terminal-session-authority-policy-consumers' + +export function requireTerminalHostAuthorityConsumerSource( + options: CreateOrAttachOptions, + ptyOwnerAvailable: boolean +): TerminalAuthorityPolicyConsumerSource { + const policyConsumer = options.terminalSessionAuthorityPolicyConsumer + if (!ptyOwnerAvailable || !policyConsumer) { + throw new Error('terminal_session_authority_outcome_consumer_required') + } + return policyConsumer +} + +export function authorityTerminalStreamClient( + options: InternalCreateOrAttachOptions, + ptyOwnerAvailable: boolean +): InternalCreateOrAttachOptions['streamClient'] { + requireTerminalHostAuthorityConsumerSource(options, ptyOwnerAvailable) + return { ...options.streamClient, onExit: () => undefined } +} diff --git a/src/main/daemon/terminal-host-authority-output-buffer.ts b/src/main/daemon/terminal-host-authority-output-buffer.ts new file mode 100644 index 00000000000..7637dccd86d --- /dev/null +++ b/src/main/daemon/terminal-host-authority-output-buffer.ts @@ -0,0 +1,40 @@ +export const TERMINAL_HOST_AUTHORITY_OUTPUT_MAX_BYTES = 100 * 1024 +export const TERMINAL_HOST_AUTHORITY_OUTPUT_MAX_FRAMES = 1_024 + +export type TerminalHostBufferedOutput = Readonly<{ + data: string + rawLength?: number + transformed?: boolean + seq?: number +}> + +export class TerminalHostAuthorityOutputBuffer { + private readonly frames: TerminalHostBufferedOutput[] = [] + private bytes = 0 + private overflowed = false + + append(frame: TerminalHostBufferedOutput): boolean { + const bytes = Buffer.byteLength(frame.data) + if ( + this.overflowed || + this.frames.length >= TERMINAL_HOST_AUTHORITY_OUTPUT_MAX_FRAMES || + this.bytes + bytes > TERMINAL_HOST_AUTHORITY_OUTPUT_MAX_BYTES + ) { + this.overflowed = true + return false + } + this.frames.push(Object.freeze({ ...frame })) + this.bytes += bytes + return true + } + + drain(): readonly TerminalHostBufferedOutput[] { + const drained = this.frames.splice(0) + this.bytes = 0 + return drained + } + + get exceededCapacity(): boolean { + return this.overflowed + } +} diff --git a/src/main/daemon/terminal-host-authority-request-queue.ts b/src/main/daemon/terminal-host-authority-request-queue.ts new file mode 100644 index 00000000000..52a717f5bec --- /dev/null +++ b/src/main/daemon/terminal-host-authority-request-queue.ts @@ -0,0 +1,25 @@ +export class TerminalHostAuthorityRequestQueue { + private readonly queues = new Map>() + + enqueue(sessionId: string, request: () => Promise): Promise { + const previous = this.queues.get(sessionId) ?? Promise.resolve() + const result = previous.then(request) + const settled = result.then( + () => undefined, + () => undefined + ) + this.queues.set(sessionId, settled) + void settled.then(() => { + if (this.queues.get(sessionId) === settled) { + this.queues.delete(sessionId) + } + }) + return result + } + + settlement(): Promise | null { + return this.queues.size > 0 + ? Promise.allSettled(this.queues.values()).then(() => undefined) + : null + } +} diff --git a/src/main/daemon/terminal-host-authority-semantic-recorder.ts b/src/main/daemon/terminal-host-authority-semantic-recorder.ts new file mode 100644 index 00000000000..08e2c54a9a5 --- /dev/null +++ b/src/main/daemon/terminal-host-authority-semantic-recorder.ts @@ -0,0 +1,49 @@ +import type { Session } from './session' +import type { TerminalSessionAuthorityPtyOwner } from '../session-authority/terminal-session-authority-pty-owner' +import { + sameTerminalSessionAuthorityPtyAccess, + type TerminalSessionAuthorityPtyAccess +} from '../../shared/terminal-session-authority-pty-access' +import type { TerminalSessionAuthoritySemanticFact } from '../../shared/terminal-session-authority-mutation' + +export class TerminalHostAuthoritySemanticRecorder { + constructor( + private readonly sessions: ReadonlyMap, + private readonly ptyOwner: TerminalSessionAuthorityPtyOwner | null, + private readonly accessFor: (sessionId: string) => TerminalSessionAuthorityPtyAccess | null, + private readonly fail: (error: unknown) => void + ) {} + + async recordExact( + sessionId: string, + access: TerminalSessionAuthorityPtyAccess, + fact: TerminalSessionAuthoritySemanticFact + ): Promise { + const session = this.sessions.get(sessionId) + if ( + !session?.isAlive || + access.binding.physicalPtyId !== sessionId || + access.binding.ptyIncarnationId !== session.incarnationId + ) { + return false + } + const currentAccess = this.accessFor(sessionId) + if (!currentAccess) { + const error = new Error('terminal_session_authority_semantic_access_missing') + this.fail(error) + throw error + } + if ( + !sameTerminalSessionAuthorityPtyAccess(currentAccess, access) || + !this.ptyOwner?.admits(sessionId, access) + ) { + return false + } + try { + return await this.ptyOwner.recordSemanticOutcome(sessionId, access, fact) + } catch (error) { + this.fail(error) + throw error + } + } +} diff --git a/src/main/daemon/terminal-host-authority-session.test.ts b/src/main/daemon/terminal-host-authority-session.test.ts new file mode 100644 index 00000000000..980c09540df --- /dev/null +++ b/src/main/daemon/terminal-host-authority-session.test.ts @@ -0,0 +1,352 @@ +import { describe, expect, it, vi } from 'vitest' +import type { TerminalSessionAuthorityPtyAccess } from '../../shared/terminal-session-authority-pty-access' +import { sameTerminalSessionAuthorityPtyAccess } from '../../shared/terminal-session-authority-pty-access' +import type { TerminalSessionAuthorityPtyOwner } from '../session-authority/terminal-session-authority-pty-owner' +import type { SubprocessHandle } from './session' +import { TERMINAL_HOST_AUTHORITY_OUTPUT_MAX_BYTES } from './terminal-host-authority-output-buffer' +import { TerminalHost } from './terminal-host' +import type { TerminalAuthorityPolicyConsumerConnection } from '../session-authority/terminal-session-authority-policy-consumers' + +type TestSubprocess = SubprocessHandle & { + emitData(data: string): void + emitExit(code: number): void +} + +const POLICY_CONSUMER = Object.freeze({ + identity: Object.freeze({ + consumerId: 'app-profile:host-test', + consumerIncarnationId: 'app-process:host-test' + }), + activate: async () => {}, + ensureNamespace: async () => {}, + assertInstalled: () => {}, + acknowledge: async (ack) => ack.sequence, + retire: async () => 0, + isInstalled: () => true, + disconnect: () => {} +}) satisfies TerminalAuthorityPolicyConsumerConnection + +function createSubprocess(events: string[]): TestSubprocess { + let onData: ((data: string) => void) | null = null + let onExit: ((code: number) => void) | null = null + return { + pid: 99_999, + getForegroundProcess: () => null, + write: vi.fn(), + resize: vi.fn(), + clear: vi.fn(), + pause: vi.fn(() => events.push('pause')), + resume: vi.fn(() => events.push('resume')), + kill: vi.fn(() => onExit?.(0)), + forceKill: vi.fn(() => onExit?.(137)), + signal: vi.fn(), + onData: (callback) => { + onData = callback + }, + onExit: (callback) => { + onExit = callback + }, + dispose: vi.fn(), + emitData: (data) => onData?.(data), + emitExit: (code) => onExit?.(code) + } +} + +function access(ptyIncarnationId: string): TerminalSessionAuthorityPtyAccess { + return { + namespace: { authorityHostId: 'host-a', namespaceId: 'namespace-a' }, + pane: { paneKey: 'pane-a', paneGenerationId: 'renderer:3' }, + binding: { + ownerIncarnationId: 'owner-a', + physicalPtyId: 'pty-a', + ptyIncarnationId + } + } +} + +function authorityFixture(events: string[], onCommit?: (subprocess: TestSubprocess) => void) { + const subprocess = createSubprocess(events) + let currentAccess: TerminalSessionAuthorityPtyAccess | null = null + const owner = { + prepareSpawn: vi.fn(async () => { + events.push('durable-prepare') + return { kind: 'spawn', prepared: { kind: 'spawn' } } + }), + commitSpawn: vi.fn(async (_prepared, incarnationId: string) => { + events.push('durable-commit') + onCommit?.(subprocess) + currentAccess = access(incarnationId) + return currentAccess + }), + cancelSpawn: vi.fn(async () => events.push('cancel-prepare')), + accessFor: vi.fn((id: string) => (id === 'pty-a' ? currentAccess : null)), + admits: vi.fn( + (id: string, candidate: TerminalSessionAuthorityPtyAccess) => + id === 'pty-a' && sameTerminalSessionAuthorityPtyAccess(currentAccess, candidate) + ), + recordExit: vi.fn(async () => { + currentAccess = null + }), + policyConsumerTransportInstalled: vi.fn(() => true), + recordSemanticOutcome: vi.fn( + async (_id: string, candidate: TerminalSessionAuthorityPtyAccess) => + sameTerminalSessionAuthorityPtyAccess(currentAccess, candidate) + ), + close: vi.fn(async (_id: string, candidate: TerminalSessionAuthorityPtyAccess) => { + if (!sameTerminalSessionAuthorityPtyAccess(currentAccess, candidate)) { + return false + } + currentAccess = null + return true + }), + adopt: vi.fn() + } as unknown as TerminalSessionAuthorityPtyOwner + return { owner, subprocess } +} + +function createOptions(publishAuthorityExit = vi.fn(async () => undefined)) { + return { + sessionId: 'pty-a', + cols: 80, + rows: 24, + worktreeId: 'repo::/srv/repo', + paneKey: 'pane-a', + paneGeneration: 3, + terminalSessionAuthorityVersion: 1, + terminalSessionAuthorityOperationId: 'spawn-operation-a', + terminalSessionAuthorityNegotiated: true as const, + terminalSessionAuthorityPolicyConsumer: POLICY_CONSUMER, + streamClient: { + onIncarnation: vi.fn(), + onData: vi.fn(), + onExit: vi.fn(), + publishAuthorityExit + } + } +} + +describe('TerminalHost authority session transaction', () => { + it('commits and installs the outcome route before releasing bounded early output', async () => { + const events: string[] = [] + const fixture = authorityFixture(events, (subprocess) => subprocess.emitData('boot\n')) + const options = createOptions() + options.streamClient.onData.mockImplementation(() => events.push('publish-data')) + const host = new TerminalHost({ + spawnSubprocess: () => { + events.push('native-spawn') + return fixture.subprocess + }, + terminalSessionAuthority: { + ptyOwner: fixture.owner + } + }) + + const result = await host.createOrAttach(options) + + expect(events).toEqual([ + 'durable-prepare', + 'native-spawn', + 'pause', + 'durable-commit', + 'publish-data', + 'resume' + ]) + expect(result.terminalSessionAuthorityAccess).toEqual(access(result.incarnationId)) + expect(options.streamClient.onData).toHaveBeenCalledWith('boot\n') + expect(host.listSessions()[0]?.terminalSessionAuthorityAccess).toEqual( + result.terminalSessionAuthorityAccess + ) + + fixture.subprocess.emitExit(17) + await vi.waitFor(() => expect(fixture.owner.recordExit).toHaveBeenCalled()) + expect(options.streamClient.onExit).not.toHaveBeenCalled() + expect(host.listSessions()).toEqual([]) + await host.dispose() + }) + + it('rejects legacy and stale mutations once full authority is installed', async () => { + const events: string[] = [] + const fixture = authorityFixture(events) + const options = createOptions() + const host = new TerminalHost({ + spawnSubprocess: () => fixture.subprocess, + terminalSessionAuthority: { + ptyOwner: fixture.owner + } + }) + const result = await host.createOrAttach(options) + const authorityAccess = result.terminalSessionAuthorityAccess! + + expect(host.writeExact('pty-a', result.incarnationId, 'legacy')).toBe(false) + expect(() => host.write('pty-a', 'legacy')).toThrow('Session not found') + expect(host.writeAuthorityExact('pty-a', authorityAccess, 'current')).toBe(true) + expect(host.writeAuthorityExact('pty-a', access('stale-incarnation'), 'stale')).toBe(false) + expect(fixture.subprocess.write).toHaveBeenCalledWith('current') + expect(fixture.subprocess.write).not.toHaveBeenCalledWith('legacy') + expect(fixture.subprocess.write).not.toHaveBeenCalledWith('stale') + + await expect(host.killAuthorityExact('pty-a', authorityAccess, POLICY_CONSUMER)).resolves.toBe( + true + ) + await vi.waitFor(() => expect(host.listSessions()).toEqual([])) + await host.dispose() + }) + + it('records a semantic fact only against its captured full binding', async () => { + const events: string[] = [] + const fixture = authorityFixture(events) + const host = new TerminalHost({ + spawnSubprocess: () => fixture.subprocess, + terminalSessionAuthority: { + ptyOwner: fixture.owner + } + }) + const result = await host.createOrAttach(createOptions()) + const current = result.terminalSessionAuthorityAccess! + const stale = { + ...current, + pane: { ...current.pane, paneGenerationId: 'renderer:stale' } + } + + await expect(host.recordSemanticOutcomeExact('pty-a', current, { kind: 'bell' })).resolves.toBe( + true + ) + await expect(host.recordSemanticOutcomeExact('pty-a', stale, { kind: 'bell' })).resolves.toBe( + false + ) + expect(fixture.owner.recordSemanticOutcome).toHaveBeenCalledOnce() + expect(fixture.owner.recordSemanticOutcome).toHaveBeenCalledWith('pty-a', current, { + kind: 'bell' + }) + await host.dispose() + }) + + it('fails closed when an alive session has no captured authority binding', async () => { + const subprocess = createSubprocess([]) + const onFailure = vi.fn() + const host = new TerminalHost({ + spawnSubprocess: () => subprocess, + onTerminalSessionAuthorityFailure: onFailure + }) + const options = { + sessionId: 'pty-a', + cols: 80, + rows: 24, + streamClient: { + onIncarnation: vi.fn(), + onData: vi.fn(), + onExit: vi.fn() + } + } + const result = await host.createOrAttach(options) + + await expect( + host.recordSemanticOutcomeExact('pty-a', access(result.incarnationId), { kind: 'bell' }) + ).rejects.toThrow('terminal_session_authority_semantic_access_missing') + expect(onFailure).toHaveBeenCalledOnce() + await expect(host.dispose()).rejects.toThrow( + 'terminal_session_authority_semantic_access_missing' + ) + }) + + it('rejects authority metadata before physical spawn when hello admission is absent', async () => { + const events: string[] = [] + const fixture = authorityFixture(events) + const spawnSubprocess = vi.fn(() => fixture.subprocess) + const host = new TerminalHost({ + spawnSubprocess, + terminalSessionAuthority: { + ptyOwner: fixture.owner + } + }) + const { terminalSessionAuthorityNegotiated: _admission, ...unnegotiated } = createOptions() + + await expect(host.createOrAttach(unnegotiated)).rejects.toThrow( + 'terminal_session_authority_unavailable' + ) + expect(fixture.owner.prepareSpawn).not.toHaveBeenCalled() + expect(spawnSubprocess).not.toHaveBeenCalled() + await host.dispose() + }) + + it('rejects admitted metadata before physical spawn when authority infrastructure is absent', async () => { + const spawnSubprocess = vi.fn(() => createSubprocess([])) + const host = new TerminalHost({ spawnSubprocess }) + + await expect(host.createOrAttach(createOptions())).rejects.toThrow( + 'terminal_session_authority_unavailable' + ) + expect(spawnSubprocess).not.toHaveBeenCalled() + await host.dispose() + }) + + it('publishes an exit that races durable commit only after the session is addressable', async () => { + const events: string[] = [] + const fixture = authorityFixture(events, (subprocess) => { + subprocess.emitData('last output\n') + subprocess.emitExit(23) + }) + const options = createOptions() + const host = new TerminalHost({ + spawnSubprocess: () => fixture.subprocess, + terminalSessionAuthority: { + ptyOwner: fixture.owner + } + }) + + await host.createOrAttach(options) + + await vi.waitFor(() => expect(fixture.owner.recordExit).toHaveBeenCalled()) + expect(options.streamClient.onData).toHaveBeenCalledWith('last output\n') + expect(options.streamClient.onExit).not.toHaveBeenCalled() + expect(fixture.subprocess.resume).not.toHaveBeenCalled() + expect(host.listSessions()).toEqual([]) + await host.dispose() + }) + + it('cancels the durable allocation only when native spawn never dispatches', async () => { + const events: string[] = [] + const fixture = authorityFixture(events) + const spawnFailure = new Error('native spawn failed') + const onFailure = vi.fn() + const host = new TerminalHost({ + spawnSubprocess: () => { + throw spawnFailure + }, + terminalSessionAuthority: { + ptyOwner: fixture.owner + }, + onTerminalSessionAuthorityFailure: onFailure + }) + + await expect(host.createOrAttach(createOptions())).rejects.toThrow(spawnFailure) + expect(fixture.owner.cancelSpawn).toHaveBeenCalledOnce() + expect(onFailure).not.toHaveBeenCalled() + await host.dispose() + }) + + it('fences creation instead of releasing an over-capacity pre-commit stream', async () => { + const events: string[] = [] + const fixture = authorityFixture(events, (subprocess) => + subprocess.emitData('x'.repeat(TERMINAL_HOST_AUTHORITY_OUTPUT_MAX_BYTES + 1)) + ) + const onFailure = vi.fn() + const host = new TerminalHost({ + spawnSubprocess: () => fixture.subprocess, + terminalSessionAuthority: { + ptyOwner: fixture.owner + }, + onTerminalSessionAuthorityFailure: onFailure + }) + + await expect(host.createOrAttach(createOptions())).rejects.toThrow( + 'terminal_session_authority_early_output_capacity_exceeded' + ) + expect(fixture.owner.cancelSpawn).not.toHaveBeenCalled() + expect(fixture.subprocess.resume).not.toHaveBeenCalled() + expect(onFailure).toHaveBeenCalledOnce() + await expect(host.dispose()).rejects.toThrow( + 'terminal_session_authority_early_output_capacity_exceeded' + ) + }) +}) diff --git a/src/main/daemon/terminal-host-authority-sessions.ts b/src/main/daemon/terminal-host-authority-sessions.ts new file mode 100644 index 00000000000..60605aa41b8 --- /dev/null +++ b/src/main/daemon/terminal-host-authority-sessions.ts @@ -0,0 +1,295 @@ +import type { Session } from './session' +import { + createOrAttachRequestsTerminalSessionAuthority, + type CreateOrAttachResult +} from './terminal-host-create-contract' +import type { InternalCreateOrAttachOptions } from './terminal-host-agent-session-claim' +import type { TerminalHostSessionCreationHooks } from './terminal-host-session-create' +import type { TerminalHostOptions } from './terminal-host-options' +import { + parseTerminalSessionAuthorityPtyAccess, + sameTerminalSessionAuthorityPtyAccess, + type TerminalSessionAuthorityPtyAccess +} from '../../shared/terminal-session-authority-pty-access' +import type { TerminalSessionAuthoritySemanticFact } from '../../shared/terminal-session-authority-mutation' +import type { TerminalAuthorityPreparedPtySpawn } from '../session-authority/terminal-session-authority-pty-binding' +import { TerminalHostAuthoritySemanticRecorder } from './terminal-host-authority-semantic-recorder' +import { TerminalHostAuthorityRequestQueue } from './terminal-host-authority-request-queue' +import { spawnTerminalHostAuthoritySession } from './terminal-host-authority-spawn' +import { + authorityTerminalStreamClient, + requireTerminalHostAuthorityConsumerSource +} from './terminal-host-authority-consumer-routing' +import { + terminalAuthorityPolicyConsumerForNamespace, + type TerminalAuthorityPolicyConsumerConnection +} from '../session-authority/terminal-session-authority-policy-consumers' + +type AuthorityOptions = NonNullable + +export type TerminalHostAuthoritySessionDependencies = Readonly<{ + sessions: ReadonlyMap + createPhysical( + options: InternalCreateOrAttachOptions, + creationHooks?: TerminalHostSessionCreationHooks + ): Promise + releaseExited(sessionId: string, generation: string | undefined): void + fenceCreation(): void +}> + +export class TerminalHostAuthoritySessions { + private readonly ptyOwner: AuthorityOptions['ptyOwner'] | null + private readonly accessBySession = new Map() + private readonly exitFinalizations = new Set>() + private readonly pendingSessions = new Set() + private readonly requestQueue = new TerminalHostAuthorityRequestQueue() + private readonly semanticRecorder: TerminalHostAuthoritySemanticRecorder + private failure: Error | null = null + + constructor( + options: TerminalHostOptions['terminalSessionAuthority'], + private readonly onFailure: ((error: Error) => void) | undefined, + private readonly deps: TerminalHostAuthoritySessionDependencies + ) { + this.ptyOwner = options?.ptyOwner ?? null + this.semanticRecorder = new TerminalHostAuthoritySemanticRecorder( + deps.sessions, + this.ptyOwner, + (sessionId) => this.accessFor(sessionId), + (error) => this.fail(error) + ) + } + + enqueue(sessionId: string, request: () => Promise): Promise { + return this.requestQueue.enqueue(sessionId, request) + } + + async createOrAttach(options: InternalCreateOrAttachOptions): Promise { + const authorityRequested = createOrAttachRequestsTerminalSessionAuthority(options) + if (authorityRequested && options.terminalSessionAuthorityNegotiated !== true) { + throw new Error('terminal_session_authority_unavailable') + } + if (!this.ptyOwner) { + if (authorityRequested) { + throw new Error('terminal_session_authority_unavailable') + } + options.streamClient.onAuthorityAccess?.(null) + return await this.deps.createPhysical(options) + } + const suppliedAccess = this.parseSuppliedAccess(options) + if (suppliedAccess) { + return await this.attach(options, suppliedAccess) + } + if ( + options.terminalSessionAuthorityVersion !== undefined || + options.terminalSessionAuthorityOperationId !== undefined + ) { + return await this.createOrAdopt(options) + } + if (this.accessFor(options.sessionId)) { + throw new Error('terminal_session_authority_access_required') + } + options.streamClient.onAuthorityAccess?.(null) + return await this.deps.createPhysical(options) + } + + accessFor(sessionId: string): TerminalSessionAuthorityPtyAccess | null { + return this.accessBySession.get(sessionId) ?? this.ptyOwner?.accessFor(sessionId) ?? null + } + + exactSession(sessionId: string, access: TerminalSessionAuthorityPtyAccess): Session | null { + const parsed = parseTerminalSessionAuthorityPtyAccess(access) + const session = this.deps.sessions.get(sessionId) + if ( + !parsed || + !session?.isAlive || + parsed.binding.physicalPtyId !== sessionId || + parsed.binding.ptyIncarnationId !== session.incarnationId || + !sameTerminalSessionAuthorityPtyAccess(this.accessFor(sessionId), parsed) || + !this.ptyOwner?.admits(sessionId, parsed) + ) { + return null + } + return session + } + + exactRememberedSession(access: TerminalSessionAuthorityPtyAccess): Session | null { + const sessionId = access.binding.physicalPtyId + const remembered = this.accessBySession.get(sessionId) + const session = this.deps.sessions.get(sessionId) + return remembered && + session?.isAlive && + session.incarnationId === access.binding.ptyIncarnationId && + sameTerminalSessionAuthorityPtyAccess(remembered, access) + ? session + : null + } + + async closeExact( + sessionId: string, + access: TerminalSessionAuthorityPtyAccess, + policyConsumer: TerminalAuthorityPolicyConsumerConnection + ): Promise { + if (!this.exactSession(sessionId, access)) { + return false + } + try { + return await this.ptyOwner!.close(sessionId, access, policyConsumer) + } catch (error) { + this.fail(error) + throw error + } + } + + async recordSemanticOutcomeExact( + sessionId: string, + access: TerminalSessionAuthorityPtyAccess, + fact: TerminalSessionAuthoritySemanticFact + ): Promise { + return await this.semanticRecorder.recordExact(sessionId, access, fact) + } + + handleExit(sessionId: string, generation: string | undefined, code: number): void { + const access = this.accessFor(sessionId) + if (!access) { + this.deps.releaseExited(sessionId, generation) + return + } + const finalization = this.finalizeExit(sessionId, generation, access, code) + this.exitFinalizations.add(finalization) + void finalization.then( + () => this.exitFinalizations.delete(finalization), + (error) => { + this.exitFinalizations.delete(finalization) + this.fail(error) + } + ) + } + + requestSettlement(): Promise | null { + return this.requestQueue.settlement() + } + + unpublishedSessions(): readonly Session[] { + return [...this.pendingSessions].filter((session) => !this.deps.sessions.has(session.sessionId)) + } + + async finishDispose(): Promise { + await Promise.all(this.exitFinalizations) + if (this.failure) { + throw this.failure + } + this.accessBySession.clear() + this.pendingSessions.clear() + } + + private parseSuppliedAccess( + options: InternalCreateOrAttachOptions + ): TerminalSessionAuthorityPtyAccess | null { + const supplied = options.terminalSessionAuthorityAccess + const parsed = supplied === undefined ? null : parseTerminalSessionAuthorityPtyAccess(supplied) + if (supplied !== undefined && !parsed) { + throw new Error('terminal_session_authority_access_invalid') + } + return parsed + } + + private async createOrAdopt( + options: InternalCreateOrAttachOptions + ): Promise { + const existing = this.deps.sessions.get(options.sessionId) + if (existing?.isAlive && !this.accessFor(options.sessionId)) { + throw new Error('terminal_session_authority_binding_required') + } + const policyConsumer = requireTerminalHostAuthorityConsumerSource( + options, + this.ptyOwner !== null + ) + const preparation = await this.ptyOwner!.prepareSpawn( + options, + options.sessionId, + policyConsumer, + options.terminalSessionAuthorityOperationId + ) + if (preparation.kind === 'spawn') { + return await this.spawn(options, preparation.prepared) + } + const { binding } = preparation.adopted + if (binding.physicalPtyId !== options.sessionId) { + throw new Error('terminal_session_authority_adopted_pty_mismatch') + } + const access = this.ptyOwner!.adopt( + preparation.adopted, + binding.physicalPtyId, + binding.ptyIncarnationId + ) + if (!access) { + throw new Error('terminal_session_authority_live_binding_missing') + } + return await this.attach(options, access) + } + + private async attach( + options: InternalCreateOrAttachOptions, + access: TerminalSessionAuthorityPtyAccess + ): Promise { + const policyConsumer = terminalAuthorityPolicyConsumerForNamespace( + requireTerminalHostAuthorityConsumerSource(options, this.ptyOwner !== null), + access.namespace + ) + policyConsumer.assertInstalled(access.namespace) + if ( + access.binding.physicalPtyId !== options.sessionId || + !this.ptyOwner!.admits(options.sessionId, access) + ) { + throw new Error('terminal_session_authority_access_rejected') + } + options.streamClient.onAuthorityAccess?.(access) + const result = await this.deps.createPhysical({ + ...options, + attachOnly: true, + streamClient: authorityTerminalStreamClient(options, this.ptyOwner !== null) + }) + if (result.incarnationId !== access.binding.ptyIncarnationId) { + throw new Error('terminal_session_authority_incarnation_mismatch') + } + this.accessBySession.set(options.sessionId, access) + return { ...result, terminalSessionAuthorityAccess: access } + } + + private async spawn( + options: InternalCreateOrAttachOptions, + prepared: TerminalAuthorityPreparedPtySpawn + ): Promise { + return await spawnTerminalHostAuthoritySession({ + options, + prepared, + ptyOwner: this.ptyOwner!, + dependencies: this.deps, + accessBySession: this.accessBySession, + pendingSessions: this.pendingSessions, + fail: (error) => this.fail(error) + }) + } + + private async finalizeExit( + sessionId: string, + generation: string | undefined, + access: TerminalSessionAuthorityPtyAccess, + code: number + ): Promise { + await this.ptyOwner!.recordExit(sessionId, access.binding.ptyIncarnationId, code) + if (!sameTerminalSessionAuthorityPtyAccess(this.accessFor(sessionId), access)) { + return + } + this.accessBySession.delete(sessionId) + this.deps.releaseExited(sessionId, generation) + } + + private fail(error: unknown): void { + const normalized = error instanceof Error ? error : new Error(String(error)) + this.deps.fenceCreation() + this.failure ??= normalized + this.onFailure?.(normalized) + } +} diff --git a/src/main/daemon/terminal-host-authority-spawn.ts b/src/main/daemon/terminal-host-authority-spawn.ts new file mode 100644 index 00000000000..7a6757661fa --- /dev/null +++ b/src/main/daemon/terminal-host-authority-spawn.ts @@ -0,0 +1,61 @@ +import type { TerminalSessionAuthorityPtyOwner } from '../session-authority/terminal-session-authority-pty-owner' +import type { TerminalSessionAuthorityPtyAccess } from '../../shared/terminal-session-authority-pty-access' +import type { Session } from './session' +import { TerminalHostAuthoritySubprocessGate } from './terminal-host-authority-subprocess-gate' +import type { InternalCreateOrAttachOptions } from './terminal-host-agent-session-claim' +import type { TerminalHostAuthoritySessionDependencies } from './terminal-host-authority-sessions' +import type { TerminalHostSessionCreationHooks } from './terminal-host-session-create' +import type { CreateOrAttachResult } from './terminal-host-create-contract' +import type { TerminalAuthorityPreparedPtySpawn } from '../session-authority/terminal-session-authority-pty-binding' +import { authorityTerminalStreamClient } from './terminal-host-authority-consumer-routing' + +export async function spawnTerminalHostAuthoritySession(args: { + options: InternalCreateOrAttachOptions + prepared: TerminalAuthorityPreparedPtySpawn + ptyOwner: TerminalSessionAuthorityPtyOwner + dependencies: TerminalHostAuthoritySessionDependencies + accessBySession: Map + pendingSessions: Set + fail: (error: unknown) => void +}): Promise { + let gate: TerminalHostAuthoritySubprocessGate | null = null + const creationHooks: TerminalHostSessionCreationHooks = { + wrapSubprocess: (subprocess) => { + gate = new TerminalHostAuthoritySubprocessGate(subprocess) + return gate + }, + onSpawnFailure: async () => { + try { + await args.ptyOwner.cancelSpawn(args.prepared) + } catch (error) { + args.fail(error) + throw error + } + }, + beforePublish: async (session) => { + args.pendingSessions.add(session) + const access = await args.ptyOwner.commitSpawn(args.prepared, session.incarnationId) + if (!gate || gate.exceededCapacity) { + throw new Error('terminal_session_authority_early_output_capacity_exceeded') + } + args.accessBySession.set(args.options.sessionId, access) + args.options.streamClient.onAuthorityAccess?.(access) + return access + }, + afterPublish: (session) => { + args.pendingSessions.delete(session) + gate!.release() + }, + onPostDispatchFailure: (error) => { + gate?.releaseForShutdown() + args.fail(error) + } + } + return await args.dependencies.createPhysical( + { + ...args.options, + streamClient: authorityTerminalStreamClient(args.options, true) + }, + creationHooks + ) +} diff --git a/src/main/daemon/terminal-host-authority-subprocess-gate.ts b/src/main/daemon/terminal-host-authority-subprocess-gate.ts new file mode 100644 index 00000000000..f8c1224dc5f --- /dev/null +++ b/src/main/daemon/terminal-host-authority-subprocess-gate.ts @@ -0,0 +1,129 @@ +import type { SubprocessHandle } from './session' +import { TerminalHostAuthorityOutputBuffer } from './terminal-host-authority-output-buffer' + +export class TerminalHostAuthoritySubprocessGate implements SubprocessHandle { + readonly pid: number + readonly startupCommandDeliveredInShellArgs: boolean | undefined + readonly shellPath: string | undefined + readonly slavePath: string | undefined + private readonly output = new TerminalHostAuthorityOutputBuffer() + private dataConsumer: ((data: string) => void) | null = null + private exitConsumer: ((code: number) => void) | null = null + private exitCode: number | undefined + private released = false + + constructor(private readonly subprocess: SubprocessHandle) { + this.pid = subprocess.pid + this.startupCommandDeliveredInShellArgs = subprocess.startupCommandDeliveredInShellArgs + this.shellPath = subprocess.shellPath + this.slavePath = subprocess.slavePath + subprocess.pause?.() + subprocess.onData((data) => { + if (this.released) { + this.dataConsumer?.(data) + return + } + this.output.append({ data }) + }) + subprocess.onExit((code) => { + if (this.released) { + this.exitConsumer?.(code) + return + } + this.exitCode ??= code + }) + } + + get exceededCapacity(): boolean { + return this.output.exceededCapacity + } + + get exitedBeforeRelease(): boolean { + return this.exitCode !== undefined + } + + release(): void { + if (this.released) { + return + } + if (this.output.exceededCapacity) { + throw new Error('terminal_session_authority_early_output_capacity_exceeded') + } + this.released = true + for (const frame of this.output.drain()) { + this.dataConsumer?.(frame.data) + } + if (this.exitCode !== undefined) { + this.exitConsumer?.(this.exitCode) + } else { + this.subprocess.resume?.() + } + } + + releaseForShutdown(): void { + if (this.released) { + return + } + this.released = true + this.output.drain() + if (this.exitCode !== undefined) { + this.exitConsumer?.(this.exitCode) + } + } + + getForegroundProcess(): string | null { + return this.subprocess.getForegroundProcess() + } + + async confirmForegroundProcess(): Promise { + return await Promise.resolve( + this.subprocess.confirmForegroundProcess?.() ?? this.subprocess.getForegroundProcess() + ) + } + + write(data: string): void { + this.subprocess.write(data) + } + + resize(cols: number, rows: number): void { + this.subprocess.resize(cols, rows) + } + + pause(): void { + this.subprocess.pause?.() + } + + resume(): void { + if (this.released) { + this.subprocess.resume?.() + } + } + + clear(): void { + this.subprocess.clear?.() + } + + kill(): void { + this.subprocess.kill() + } + + forceKill(): void { + this.subprocess.forceKill() + } + + signal(sig: string): void { + this.subprocess.signal(sig) + } + + onData(cb: (data: string) => void): void { + this.dataConsumer = cb + } + + onExit(cb: (code: number) => void): void { + this.exitConsumer = cb + } + + dispose(): void { + this.subprocess.dispose() + } +} diff --git a/src/main/daemon/terminal-host-create-contract.ts b/src/main/daemon/terminal-host-create-contract.ts index 114aae91ad2..70e9a26dea4 100644 --- a/src/main/daemon/terminal-host-create-contract.ts +++ b/src/main/daemon/terminal-host-create-contract.ts @@ -8,6 +8,8 @@ import type { AgentSessionSurfaceBinding } from '../../shared/agent-session-host-authority' import type { PtyIncarnationId } from '../../shared/pty-incarnation' +import type { TerminalSessionAuthorityPtyAccess } from '../../shared/terminal-session-authority-pty-access' +import type { TerminalAuthorityPolicyConsumerSource } from '../session-authority/terminal-session-authority-policy-consumers' export type CreateOrAttachOptions = { sessionId: string @@ -33,9 +35,22 @@ export type CreateOrAttachOptions = { claim: AgentSessionExecutionClaim surface: AgentSessionSurfaceBinding } + terminalSessionAuthorityVersion?: number + terminalSessionAuthorityOperationId?: string + worktreeId?: string + paneKey?: string + paneGeneration?: number + terminalSessionAuthorityAccess?: TerminalSessionAuthorityPtyAccess + /** Set only from the authenticated daemon hello; never copied from an RPC payload. */ + terminalSessionAuthorityNegotiated?: true + /** Exact authenticated daemon connection; never decoded from an RPC payload. */ + terminalSessionAuthorityPolicyConsumer?: TerminalAuthorityPolicyConsumerSource streamClient: { + onAuthorityAccess?: (access: TerminalSessionAuthorityPtyAccess | null) => void + onIncarnation?: (incarnationId: PtyIncarnationId) => void onData: (data: string, rawLength?: number, transformed?: boolean, seq?: number) => void onExit: (code: number, incarnationId: PtyIncarnationId) => void + publishAuthorityExit?: (code: number | null, signal: string | null) => Promise } /** Lets the daemon route output under the adopted owner's canonical id before * attaching its stream callbacks. */ @@ -53,4 +68,18 @@ export type CreateOrAttachResult = { attachToken: symbol incarnationId: PtyIncarnationId agentSessionEnsure?: AgentSessionClaimedSpawnResult + terminalSessionAuthorityAccess?: TerminalSessionAuthorityPtyAccess +} + +export function createOrAttachRequestsTerminalSessionAuthority( + options: CreateOrAttachOptions +): boolean { + return ( + options.terminalSessionAuthorityVersion !== undefined || + options.terminalSessionAuthorityOperationId !== undefined || + options.terminalSessionAuthorityAccess !== undefined || + options.worktreeId !== undefined || + options.paneKey !== undefined || + options.paneGeneration !== undefined + ) } diff --git a/src/main/daemon/terminal-host-disposal.ts b/src/main/daemon/terminal-host-disposal.ts new file mode 100644 index 00000000000..11845c8a901 --- /dev/null +++ b/src/main/daemon/terminal-host-disposal.ts @@ -0,0 +1,26 @@ +import { shutdownTerminalHostSessions } from './terminal-host-session-shutdown' +import type { Session } from './session' +import type { TerminalHostOptions } from './terminal-host-options' +import type { TerminalHostAuthoritySessions } from './terminal-host-authority-sessions' +import type { TerminalHostTombstones } from './terminal-host-tombstones' + +export async function disposeTerminalHostSessions(args: { + sessions: Map + authoritySessions: TerminalHostAuthoritySessions + killedTombstones: TerminalHostTombstones + onFinalCheckpoint: TerminalHostOptions['onFinalCheckpoint'] +}): Promise { + const requestSettlement = args.authoritySessions.requestSettlement() + if (requestSettlement) { + await requestSettlement + } + const pendingSessions = new Map( + args.authoritySessions + .unpublishedSessions() + .map((session) => [session.sessionId, session] as const) + ) + await shutdownTerminalHostSessions(args.sessions, args.onFinalCheckpoint) + await shutdownTerminalHostSessions(pendingSessions) + await args.authoritySessions.finishDispose() + args.killedTombstones.clear() +} diff --git a/src/main/daemon/terminal-host-exact-operations.test.ts b/src/main/daemon/terminal-host-exact-operations.test.ts new file mode 100644 index 00000000000..f75dcd8129a --- /dev/null +++ b/src/main/daemon/terminal-host-exact-operations.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, vi } from 'vitest' +import type { SubprocessHandle } from './session' +import { TerminalHost } from './terminal-host' + +describe('TerminalHost exact PTY operations', () => { + it('admits only the live physical incarnation', async () => { + let onExit: ((code: number) => void) | undefined + const subprocess: SubprocessHandle = { + pid: 99999, + getForegroundProcess: () => null, + write: vi.fn(), + resize: vi.fn(), + clear: vi.fn(), + kill: vi.fn(() => onExit?.(0)), + forceKill: vi.fn(() => onExit?.(137)), + signal: vi.fn(), + onData: vi.fn(), + onExit: (callback) => { + onExit = callback + }, + dispose: vi.fn() + } + const host = new TerminalHost({ spawnSubprocess: () => subprocess }) + + try { + const result = await host.createOrAttach({ + sessionId: 'session-1', + cols: 80, + rows: 24, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + + expect(host.writeExact('session-1', 'stale-incarnation', 'stale')).toBe(false) + expect(host.resizeExact('session-1', 'stale-incarnation', 120, 40)).toBe(false) + await expect(host.killExact('session-1', 'stale-incarnation')).resolves.toBe(false) + expect(host.signalExact('session-1', 'stale-incarnation', 'SIGTERM')).toBe(false) + expect(host.clearScrollbackExact('session-1', 'stale-incarnation')).toBe(false) + expect(host.writeExact('session-1', result.incarnationId, 'current')).toBe(true) + expect(host.resizeExact('session-1', result.incarnationId, 120, 40)).toBe(true) + expect(host.signalExact('session-1', result.incarnationId, 'SIGTERM')).toBe(true) + expect(host.clearScrollbackExact('session-1', result.incarnationId)).toBe(true) + expect(subprocess.write).toHaveBeenCalledWith('current') + expect(subprocess.resize).toHaveBeenCalledWith(120, 40) + expect(subprocess.signal).toHaveBeenCalledWith('SIGTERM') + expect(subprocess.clear).toHaveBeenCalledOnce() + await expect(host.killExact('session-1', result.incarnationId)).resolves.toBe(true) + expect(subprocess.kill).toHaveBeenCalledOnce() + } finally { + await host.dispose() + } + }) +}) diff --git a/src/main/daemon/terminal-host-options.ts b/src/main/daemon/terminal-host-options.ts index b483914bba3..5d9466ccfb6 100644 --- a/src/main/daemon/terminal-host-options.ts +++ b/src/main/daemon/terminal-host-options.ts @@ -2,6 +2,7 @@ import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery import type { TuiAgent } from '../../shared/types' import type { SubprocessHandle } from './session' import type { TakePendingOutputResult, TerminalSnapshot } from './types' +import type { TerminalSessionAuthorityPtyOwner } from '../session-authority/terminal-session-authority-pty-owner' export type TerminalHostOptions = { spawnSubprocess: (opts: { @@ -28,4 +29,9 @@ export type TerminalHostOptions = { ) => void // Why: tests need deterministic tombstone eviction without thousands of sessions. maxTombstones?: number + terminalSessionAuthority?: Readonly<{ + ptyOwner: TerminalSessionAuthorityPtyOwner + authorityHostId?: string + }> + onTerminalSessionAuthorityFailure?: (error: Error) => void } diff --git a/src/main/daemon/terminal-host-pty-mutations.ts b/src/main/daemon/terminal-host-pty-mutations.ts new file mode 100644 index 00000000000..493d3958859 --- /dev/null +++ b/src/main/daemon/terminal-host-pty-mutations.ts @@ -0,0 +1,271 @@ +import { SessionNotFoundError } from './types' +import type { Session } from './session' +import type { TerminalSessionTeardown } from './terminal-session-teardown' +import type { TerminalHostTombstones } from './terminal-host-tombstones' +import type { TerminalHostAuthoritySessions } from './terminal-host-authority-sessions' +import type { TerminalSessionAuthorityPtyAccess } from '../../shared/terminal-session-authority-pty-access' +import type { TerminalAuthorityBindingRetiredEffect } from '../session-authority/terminal-session-authority-host-effect-applier' +import type { TerminalAuthorityPolicyConsumerConnection } from '../session-authority/terminal-session-authority-policy-consumers' + +type KillOptions = { immediate?: boolean } + +export class TerminalHostPtyMutations { + constructor( + private readonly sessions: ReadonlyMap, + private readonly teardown: TerminalSessionTeardown, + private readonly tombstones: TerminalHostTombstones, + private readonly authority: TerminalHostAuthoritySessions + ) {} + + alive(sessionId: string): Session { + const session = this.sessions.get(sessionId) + if (!session || !session.isAlive) { + throw new SessionNotFoundError(sessionId) + } + return session + } + + write(sessionId: string, data: string): void { + this.mutable(sessionId).write(data) + } + + writeExact(sessionId: string, incarnationId: string, data: string): boolean { + const session = this.legacyExact(sessionId, incarnationId) + if (!session) { + return false + } + session.write(data) + return true + } + + writeAuthorityExact( + sessionId: string, + access: TerminalSessionAuthorityPtyAccess, + data: string + ): boolean { + const session = this.authority.exactSession(sessionId, access) + if (!session) { + return false + } + session.write(data) + return true + } + + resize(sessionId: string, cols: number, rows: number): void { + this.mutable(sessionId).resize(cols, rows) + } + + resizeExact(sessionId: string, incarnationId: string, cols: number, rows: number): boolean { + const session = this.legacyExact(sessionId, incarnationId) + if (!session) { + return false + } + session.resize(cols, rows) + return true + } + + resizeAuthorityExact( + sessionId: string, + access: TerminalSessionAuthorityPtyAccess, + cols: number, + rows: number + ): boolean { + const session = this.authority.exactSession(sessionId, access) + if (!session) { + return false + } + session.resize(cols, rows) + return true + } + + pauseProducer(sessionId: string): void { + const session = this.sessions.get(sessionId) + if (session?.isAlive) { + session.pauseProducer() + } + } + + resumeProducer(sessionId: string): void { + this.sessions.get(sessionId)?.resumeProducer() + } + + supportsExactHeldProducerPause(sessionId: string, incarnationId: string): boolean { + const session = this.sessions.get(sessionId) + return Boolean( + session?.isAlive && + session.incarnationId === incarnationId && + session.supportsExactHeldProducerPause() + ) + } + + acquireExactHeldProducerPause( + sessionId: string, + incarnationId: string, + ownerId: string, + token: string + ): boolean { + const session = this.sessions.get(sessionId) + return Boolean( + session?.isAlive && + session.incarnationId === incarnationId && + session.acquireExactHeldProducerPause(ownerId, token) + ) + } + + releaseExactHeldProducerPause( + sessionId: string, + incarnationId: string, + ownerId: string, + token: string + ): boolean { + const session = this.sessions.get(sessionId) + return Boolean( + session?.isAlive && + session.incarnationId === incarnationId && + session.releaseExactHeldProducerPause(ownerId, token) + ) + } + + releaseExactHeldProducerPauses(sessionId: string, incarnationId: string, ownerId: string): void { + const session = this.sessions.get(sessionId) + if (session?.isAlive && session.incarnationId === incarnationId) { + session.releaseExactHeldProducerPauses(ownerId) + } + } + + kill(sessionId: string, options: KillOptions = {}): Promise { + if (this.authority.accessFor(sessionId)) { + throw new SessionNotFoundError(sessionId) + } + if (!this.teardown.get(sessionId)) { + this.alive(sessionId) + } + return this.killOwned(sessionId, options) + } + + async killExact( + sessionId: string, + incarnationId: string, + options: KillOptions = {} + ): Promise { + if (!this.legacyExact(sessionId, incarnationId)) { + return false + } + await this.killOwned(sessionId, options) + return true + } + + async killAuthorityExact( + sessionId: string, + access: TerminalSessionAuthorityPtyAccess, + policyConsumer: TerminalAuthorityPolicyConsumerConnection, + options: KillOptions = {} + ): Promise { + if (!(await this.authority.closeExact(sessionId, access, policyConsumer))) { + return false + } + await this.killOwned(sessionId, options) + return true + } + + async ensureAuthorityBindingRetired( + access: TerminalSessionAuthorityPtyAccess, + reason: TerminalAuthorityBindingRetiredEffect['reason'] + ): Promise { + if (reason === 'exit') { + return + } + const session = this.authority.exactRememberedSession(access) + if (!session) { + throw new Error('terminal_session_authority_physical_shutdown_pending') + } + await this.killOwned(session.sessionId, { immediate: true }) + } + + signal(sessionId: string, signal: string): void { + this.mutable(sessionId).signal(signal) + } + + signalExact(sessionId: string, incarnationId: string, signal: string): boolean { + const session = this.legacyExact(sessionId, incarnationId) + if (!session) { + return false + } + session.signal(signal) + return true + } + + signalAuthorityExact( + sessionId: string, + access: TerminalSessionAuthorityPtyAccess, + signal: string + ): boolean { + const session = this.authority.exactSession(sessionId, access) + if (!session) { + return false + } + session.signal(signal) + return true + } + + clearScrollback(sessionId: string): void { + this.mutable(sessionId).clearScrollback() + } + + clearScrollbackExact(sessionId: string, incarnationId: string): boolean { + const session = this.legacyExact(sessionId, incarnationId) + if (!session) { + return false + } + session.clearScrollback() + return true + } + + clearScrollbackAuthorityExact( + sessionId: string, + access: TerminalSessionAuthorityPtyAccess + ): boolean { + const session = this.authority.exactSession(sessionId, access) + if (!session) { + return false + } + session.clearScrollback() + return true + } + + private mutable(sessionId: string): Session { + const session = this.alive(sessionId) + if (this.authority.accessFor(sessionId)) { + throw new SessionNotFoundError(sessionId) + } + return session + } + + private legacyExact(sessionId: string, incarnationId: string): Session | null { + const session = this.sessions.get(sessionId) + if ( + !session?.isAlive || + session.incarnationId !== incarnationId || + this.authority.accessFor(sessionId) + ) { + return null + } + return session + } + + private killOwned(sessionId: string, options: KillOptions): Promise { + const pending = this.teardown.get(sessionId) + if (pending) { + return Promise.resolve( + options.immediate ? this.teardown.requestImmediate(sessionId) : pending + ) + } + const killed = this.teardown.killSession( + sessionId, + this.alive(sessionId), + options.immediate === true + ) + this.tombstones.record(sessionId) + return Promise.resolve(killed) + } +} diff --git a/src/main/daemon/terminal-host-session-create.ts b/src/main/daemon/terminal-host-session-create.ts index dcc88fada05..fee328255f0 100644 --- a/src/main/daemon/terminal-host-session-create.ts +++ b/src/main/daemon/terminal-host-session-create.ts @@ -9,9 +9,20 @@ import type { CreateOrAttachResult } from './terminal-host-create-contract' import type { TerminalHostOptions } from './terminal-host-options' import type { TerminalHostTombstones } from './terminal-host-tombstones' import type { TerminalSessionTeardown } from './terminal-session-teardown' +import type { TerminalSessionAuthorityPtyAccess } from '../../shared/terminal-session-authority-pty-access' import { SessionNotFoundError } from './types' import { resolveWslSessionContext } from './wsl-session-context' +export type TerminalHostSessionCreationHooks = Readonly<{ + wrapSubprocess: ( + subprocess: ReturnType + ) => ReturnType + onSpawnFailure: (error: unknown) => Promise + beforePublish: (session: Session) => Promise + afterPublish: (session: Session) => void + onPostDispatchFailure: (error: unknown) => void +}> + type TerminalHostSessionCreateDependencies = { sessions: Map sessionTeardown: TerminalSessionTeardown @@ -20,7 +31,8 @@ type TerminalHostSessionCreateDependencies = { creationFenced: boolean onDeadSessionRemoved: (sessionId: string) => void onSessionCreated: (sessionId: string, generation: string | undefined, isAlive: boolean) => void - onSessionExit: (sessionId: string, generation: string | undefined) => void + onSessionExit: (sessionId: string, generation: string | undefined, code: number) => void + creationHooks?: TerminalHostSessionCreationHooks } export async function createOrAttachTerminalSession( @@ -42,6 +54,7 @@ export async function createOrAttachTerminalSession( if (existing && existing.isAlive && !existing.isTerminating) { const snapshot = existing.getSnapshot() existing.detachAllClients() + opts.streamClient.onIncarnation?.(existing.incarnationId) const token = existing.attachClient(opts.streamClient) return { isNew: false, @@ -74,70 +87,86 @@ export async function createOrAttachTerminalSession( deps.killedTombstones.clearForCreate(opts.sessionId) const size = normalizePtySize(opts.cols, opts.rows) const wslDistro = resolveWslSessionContext(opts)?.distro - const subprocess = deps.spawnSubprocess({ - sessionId: opts.sessionId, - cols: size.cols, - rows: size.rows, - cwd: opts.cwd, - env: opts.env, - envToDelete: opts.envToDelete, - command: opts.command, - startupCommandDelivery: opts.startupCommandDelivery, - ...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}), - shellOverride: opts.shellOverride, - terminalWindowsWslDistro: opts.terminalWindowsWslDistro, - terminalWindowsPowerShellImplementation: opts.terminalWindowsPowerShellImplementation - }) - - // Why: a fallback shell does not emit the preferred shell's ready marker; - // retaining the stale capability would indefinitely queue its first command. - const shellReadySupported = - (opts.shellReadySupported ?? false) && - (subprocess.shellPath === undefined || shellPathSupportsPtyStartupBarrier(subprocess.shellPath)) - const session = new Session({ - sessionId: opts.sessionId, - cols: size.cols, - rows: size.rows, - terminalHandle: opts.env?.ORCA_TERMINAL_HANDLE, - launchAgent: opts.launchAgent, - subprocess, - ownerBackend: resolvePtyOwnerBackend({ - platform: process.platform, - shellPath: subprocess.shellPath, - wslDistro - }), - shellReadySupported, - historySeedChunks: opts.historySeedChunks, - ...(opts.startupIngress ? { startupIngress: opts.startupIngress } : {}), - wslDistro, - onExit: () => deps.onSessionExit(opts.sessionId, opts.agentSessionGeneration), - ...(opts.shellReadyTimeoutMs !== undefined - ? { shellReadyTimeoutMs: opts.shellReadyTimeoutMs } - : {}) - }) - - deps.sessions.set(opts.sessionId, session) - deps.onSessionCreated(opts.sessionId, opts.agentSessionGeneration, session.isAlive) - const token = session.attachClient(opts.streamClient) - - if (opts.command && !subprocess.startupCommandDeliveredInShellArgs) { - const submit = process.platform === 'win32' ? '\r' : '\n' - // Why: only Orca-wrapped shells advertise the paste-safe startup barrier. - session.write( - buildStartupCommandSubmission(opts.command, { - submit, - bracketedPasteSafe: shellReadySupported - }) - ) + let subprocess: ReturnType + try { + subprocess = deps.spawnSubprocess({ + sessionId: opts.sessionId, + cols: size.cols, + rows: size.rows, + cwd: opts.cwd, + env: opts.env, + envToDelete: opts.envToDelete, + command: opts.command, + startupCommandDelivery: opts.startupCommandDelivery, + ...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}), + shellOverride: opts.shellOverride, + terminalWindowsWslDistro: opts.terminalWindowsWslDistro, + terminalWindowsPowerShellImplementation: opts.terminalWindowsPowerShellImplementation + }) + } catch (error) { + await deps.creationHooks?.onSpawnFailure(error) + throw error } + try { + const sessionSubprocess = deps.creationHooks?.wrapSubprocess(subprocess) ?? subprocess + // Why: a fallback shell does not emit the preferred shell's ready marker; + // retaining the stale capability would indefinitely queue its first command. + const shellReadySupported = + (opts.shellReadySupported ?? false) && + (subprocess.shellPath === undefined || + shellPathSupportsPtyStartupBarrier(subprocess.shellPath)) + const session = new Session({ + sessionId: opts.sessionId, + cols: size.cols, + rows: size.rows, + terminalHandle: opts.env?.ORCA_TERMINAL_HANDLE, + launchAgent: opts.launchAgent, + subprocess: sessionSubprocess, + ownerBackend: resolvePtyOwnerBackend({ + platform: process.platform, + shellPath: subprocess.shellPath, + wslDistro + }), + shellReadySupported, + historySeedChunks: opts.historySeedChunks, + ...(opts.startupIngress ? { startupIngress: opts.startupIngress } : {}), + wslDistro, + onExit: (code) => deps.onSessionExit(opts.sessionId, opts.agentSessionGeneration, code), + ...(opts.shellReadyTimeoutMs !== undefined + ? { shellReadyTimeoutMs: opts.shellReadyTimeoutMs } + : {}) + }) + const terminalSessionAuthorityAccess = await deps.creationHooks?.beforePublish(session) - return { - isNew: true, - snapshot: null, - pid: subprocess.pid, - shellState: session.shellState, - incarnationId: session.incarnationId, - ...getDaemonSessionResultMetadata(session), - attachToken: token + deps.sessions.set(opts.sessionId, session) + deps.onSessionCreated(opts.sessionId, opts.agentSessionGeneration, session.isAlive) + opts.streamClient.onIncarnation?.(session.incarnationId) + const token = session.attachClient(opts.streamClient) + deps.creationHooks?.afterPublish(session) + + if (opts.command && !subprocess.startupCommandDeliveredInShellArgs) { + const submit = process.platform === 'win32' ? '\r' : '\n' + // Why: only Orca-wrapped shells advertise the paste-safe startup barrier. + session.write( + buildStartupCommandSubmission(opts.command, { + submit, + bracketedPasteSafe: shellReadySupported + }) + ) + } + + return { + isNew: true, + snapshot: null, + pid: subprocess.pid, + shellState: session.shellState, + incarnationId: session.incarnationId, + ...getDaemonSessionResultMetadata(session), + attachToken: token, + ...(terminalSessionAuthorityAccess ? { terminalSessionAuthorityAccess } : {}) + } + } catch (error) { + deps.creationHooks?.onPostDispatchFailure(error) + throw error } } diff --git a/src/main/daemon/terminal-host-session-listing.ts b/src/main/daemon/terminal-host-session-listing.ts index 3ae2d5e845e..a8865761bcb 100644 --- a/src/main/daemon/terminal-host-session-listing.ts +++ b/src/main/daemon/terminal-host-session-listing.ts @@ -1,10 +1,12 @@ import type { ClaimedAgentPtyOwnerRegistry } from '../../shared/claimed-agent-pty-owner' import type { Session } from './session' import type { SessionInfo } from './types' +import type { TerminalSessionAuthorityPtyAccess } from '../../shared/terminal-session-authority-pty-access' export function listLiveTerminalHostSessions( sessions: ReadonlyMap, - agentSessionOwners: ClaimedAgentPtyOwnerRegistry + agentSessionOwners: ClaimedAgentPtyOwnerRegistry, + authorityAccessFor?: (physicalPtyId: string) => TerminalSessionAuthorityPtyAccess | null ): SessionInfo[] { const result: SessionInfo[] = [] for (const session of sessions.values()) { @@ -12,9 +14,11 @@ export function listLiveTerminalHostSessions( continue } const size = session.getAppliedSize() + const authorityAccess = authorityAccessFor?.(session.sessionId) ?? null result.push({ sessionId: session.sessionId, incarnationId: session.incarnationId, + ...(authorityAccess ? { terminalSessionAuthorityAccess: authorityAccess } : {}), state: session.state, shellState: session.shellState, isAlive: true, diff --git a/src/main/daemon/terminal-host-session-queries.ts b/src/main/daemon/terminal-host-session-queries.ts new file mode 100644 index 00000000000..146f41eae33 --- /dev/null +++ b/src/main/daemon/terminal-host-session-queries.ts @@ -0,0 +1,81 @@ +import type { Session } from './session' +import type { ClaimedAgentPtyOwnerRegistry } from '../../shared/claimed-agent-pty-owner' +import type { TerminalHostAuthoritySessions } from './terminal-host-authority-sessions' +import type { TerminalHostTombstones } from './terminal-host-tombstones' +import type { SessionInfo, TakePendingOutputResult, TerminalSnapshot } from './types' +import { resolveTerminalHostSessionCwd } from './terminal-host-session-cwd' +import { listLiveTerminalHostSessions } from './terminal-host-session-listing' +import { isShellProcess } from '../../shared/agent-detection' + +export class TerminalHostSessionQueries { + constructor( + private readonly sessions: ReadonlyMap, + private readonly owners: ClaimedAgentPtyOwnerRegistry, + private readonly authority: TerminalHostAuthoritySessions, + private readonly tombstones: TerminalHostTombstones, + private readonly alive: (sessionId: string) => Session + ) {} + + detach(sessionId: string, token: symbol): void { + this.sessions.get(sessionId)?.detachClient(token) + } + + async getCwd(sessionId: string): Promise { + return await resolveTerminalHostSessionCwd(this.alive(sessionId)) + } + + getForegroundProcess(sessionId: string): string | null { + const session = this.sessions.get(sessionId) + return session?.isAlive ? session.getForegroundProcess() : null + } + + inspectProcess(sessionId: string): { + foregroundProcess: string | null + hasChildProcesses: boolean + } { + const foregroundProcess = this.alive(sessionId).getForegroundProcess() + return { + foregroundProcess, + hasChildProcesses: foregroundProcess !== null && !isShellProcess(foregroundProcess) + } + } + + async confirmForegroundProcess(sessionId: string): Promise { + const session = this.sessions.get(sessionId) + return session?.isAlive ? await session.confirmForegroundProcess() : null + } + + getSnapshot(sessionId: string, options: { scrollbackRows?: number }): TerminalSnapshot | null { + const session = this.sessions.get(sessionId) + return session?.isAlive ? session.getSnapshot(options) : null + } + + getPartialEscapeTailAnsi(sessionId: string): string { + const session = this.sessions.get(sessionId) + return session?.isAlive ? session.getPartialEscapeTailAnsi() : '' + } + + getAppliedSize(sessionId: string): { cols: number; rows: number } | null { + const session = this.sessions.get(sessionId) + return session?.isAlive ? session.getAppliedSize() : null + } + + takePendingOutput( + sessionId: string, + includeSnapshot: boolean, + options: { teardownSnapshot?: boolean } + ): TakePendingOutputResult | null { + const session = this.sessions.get(sessionId) + return session?.isAlive ? session.takePendingOutput(includeSnapshot, options) : null + } + + isKilled(sessionId: string): boolean { + return this.tombstones.has(sessionId) + } + + listSessions(): SessionInfo[] { + return listLiveTerminalHostSessions(this.sessions, this.owners, (sessionId) => + this.authority.accessFor(sessionId) + ) + } +} diff --git a/src/main/daemon/terminal-host.ts b/src/main/daemon/terminal-host.ts index ae348a3854e..8bfe5231993 100644 --- a/src/main/daemon/terminal-host.ts +++ b/src/main/daemon/terminal-host.ts @@ -1,22 +1,27 @@ import type { Session } from './session' -import { - SessionNotFoundError, - type SessionInfo, - type TakePendingOutputResult, - type TerminalSnapshot -} from './types' +import type { SessionInfo, TakePendingOutputResult, TerminalSnapshot } from './types' import type { CreateOrAttachOptions, CreateOrAttachResult } from './terminal-host-create-contract' import type { TerminalHostOptions } from './terminal-host-options' -import { shutdownTerminalHostSessions } from './terminal-host-session-shutdown' import { TerminalSessionTeardown } from './terminal-session-teardown' import { ClaimedAgentPtyOwnerRegistry } from '../../shared/claimed-agent-pty-owner' -import { createOrAttachClaimedAgentSession } from './terminal-host-agent-session-claim' +import { + createOrAttachClaimedAgentSession, + type InternalCreateOrAttachOptions +} from './terminal-host-agent-session-claim' import { TerminalHostAgentSessionGenerations } from './terminal-host-agent-session-generations' -import { resolveTerminalHostSessionCwd } from './terminal-host-session-cwd' import { TerminalHostTombstones } from './terminal-host-tombstones' -import { listLiveTerminalHostSessions } from './terminal-host-session-listing' -import { createOrAttachTerminalSession } from './terminal-host-session-create' -import { isShellProcess } from '../../shared/agent-detection' +import { + createOrAttachTerminalSession, + type TerminalHostSessionCreationHooks +} from './terminal-host-session-create' +import type { TerminalSessionAuthorityPtyAccess } from '../../shared/terminal-session-authority-pty-access' +import type { TerminalSessionAuthoritySemanticFact } from '../../shared/terminal-session-authority-mutation' +import type { TerminalAuthorityBindingRetiredEffect } from '../session-authority/terminal-session-authority-host-effect-applier' +import { TerminalHostAuthoritySessions } from './terminal-host-authority-sessions' +import { TerminalHostPtyMutations } from './terminal-host-pty-mutations' +import { TerminalHostSessionQueries } from './terminal-host-session-queries' +import { disposeTerminalHostSessions } from './terminal-host-disposal' +import type { TerminalAuthorityPolicyConsumerConnection } from '../session-authority/terminal-session-authority-policy-consumers' export type { CreateOrAttachOptions, CreateOrAttachResult } from './terminal-host-create-contract' export type { TerminalHostOptions } from './terminal-host-options' @@ -35,6 +40,9 @@ export class TerminalHost { private disposePromise: Promise | null = null private readonly agentSessionOwners = new ClaimedAgentPtyOwnerRegistry() private readonly agentSessionGenerations = new TerminalHostAgentSessionGenerations() + private readonly authoritySessions: TerminalHostAuthoritySessions + private readonly mutations: TerminalHostPtyMutations + private readonly queries: TerminalHostSessionQueries constructor(opts: TerminalHostOptions) { this.spawnSubprocess = opts.spawnSubprocess @@ -42,76 +50,182 @@ export class TerminalHost { this.onFinalCheckpoint = opts.onFinalCheckpoint this.maxTombstones = opts.maxTombstones ?? DEFAULT_MAX_TOMBSTONES this.killedTombstones = new TerminalHostTombstones(this.maxTombstones) + this.authoritySessions = new TerminalHostAuthoritySessions( + opts.terminalSessionAuthority, + opts.onTerminalSessionAuthorityFailure, + { + sessions: this.sessions, + createPhysical: (options, hooks) => this.createOrAttachPhysicalSession(options, hooks), + releaseExited: (sessionId, generation) => this.releaseExitedSession(sessionId, generation), + fenceCreation: () => { + this.creationFenced = true + } + } + ) + this.mutations = new TerminalHostPtyMutations( + this.sessions, + this.sessionTeardown, + this.killedTombstones, + this.authoritySessions + ) + this.queries = new TerminalHostSessionQueries( + this.sessions, + this.agentSessionOwners, + this.authoritySessions, + this.killedTombstones, + (sessionId) => this.mutations.alive(sessionId) + ) } async createOrAttach(opts: CreateOrAttachOptions): Promise { - return await createOrAttachClaimedAgentSession({ - options: opts, - owners: this.agentSessionOwners, - isLive: (owner) => - this.agentSessionGenerations.isCurrent( - owner, - Boolean(this.sessions.get(owner.ptyId)?.isAlive) - ), - createOrAttach: async (options) => { - if (options.agentSessionGeneration && this.sessions.get(options.sessionId)?.isAlive) { - throw new Error('agent_session_claim_unavailable') - } - return await createOrAttachTerminalSession(options, { - sessions: this.sessions, - sessionTeardown: this.sessionTeardown, - killedTombstones: this.killedTombstones, - spawnSubprocess: this.spawnSubprocess, - creationFenced: this.creationFenced, - onDeadSessionRemoved: (sessionId) => this.agentSessionGenerations.forget(sessionId), - onSessionCreated: (sessionId, generation, isAlive) => - this.agentSessionGenerations.remember(sessionId, generation, isAlive), - onSessionExit: (sessionId, generation) => { - this.agentSessionOwners.release(sessionId, generation) - this.agentSessionGenerations.forget(sessionId, generation) - this.reapSession(sessionId) - } - }) - } + return await this.authoritySessions.enqueue(opts.sessionId, async () => + createOrAttachClaimedAgentSession({ + options: opts, + owners: this.agentSessionOwners, + isLive: (owner) => + this.agentSessionGenerations.isCurrent( + owner, + Boolean(this.sessions.get(owner.ptyId)?.isAlive) + ), + createOrAttach: (options) => this.authoritySessions.createOrAttach(options) + }) + ) + } + + private async createOrAttachPhysicalSession( + options: InternalCreateOrAttachOptions, + creationHooks?: TerminalHostSessionCreationHooks + ): Promise { + if (options.agentSessionGeneration && this.sessions.get(options.sessionId)?.isAlive) { + throw new Error('agent_session_claim_unavailable') + } + return await createOrAttachTerminalSession(options, { + sessions: this.sessions, + sessionTeardown: this.sessionTeardown, + killedTombstones: this.killedTombstones, + spawnSubprocess: this.spawnSubprocess, + creationFenced: this.creationFenced, + onDeadSessionRemoved: (sessionId) => this.agentSessionGenerations.forget(sessionId), + onSessionCreated: (sessionId, generation, isAlive) => + this.agentSessionGenerations.remember(sessionId, generation, isAlive), + onSessionExit: (sessionId, generation, code) => + this.authoritySessions.handleExit(sessionId, generation, code), + ...(creationHooks ? { creationHooks } : {}) }) } + private releaseExitedSession(sessionId: string, generation: string | undefined): void { + this.agentSessionOwners.release(sessionId, generation) + this.agentSessionGenerations.forget(sessionId, generation) + this.reapSession(sessionId) + } + + async recordSemanticOutcomeExact( + sessionId: string, + access: TerminalSessionAuthorityPtyAccess, + fact: TerminalSessionAuthoritySemanticFact + ): Promise { + return await this.authoritySessions.recordSemanticOutcomeExact(sessionId, access, fact) + } + write(sessionId: string, data: string): void { - this.getAliveSession(sessionId).write(data) + this.mutations.write(sessionId, data) + } + + writeExact(sessionId: string, incarnationId: string, data: string): boolean { + return this.mutations.writeExact(sessionId, incarnationId, data) + } + + writeAuthorityExact( + sessionId: string, + access: TerminalSessionAuthorityPtyAccess, + data: string + ): boolean { + return this.mutations.writeAuthorityExact(sessionId, access, data) } closeStartupQueryAuthority(sessionId: string): number { - return this.getAliveSession(sessionId).closeStartupQueryAuthority() + return this.mutations.alive(sessionId).closeStartupQueryAuthority() } resize(sessionId: string, cols: number, rows: number): void { - this.getAliveSession(sessionId).resize(cols, rows) + this.mutations.resize(sessionId, cols, rows) + } + + resizeExact(sessionId: string, incarnationId: string, cols: number, rows: number): boolean { + return this.mutations.resizeExact(sessionId, incarnationId, cols, rows) + } + + resizeAuthorityExact( + sessionId: string, + access: TerminalSessionAuthorityPtyAccess, + cols: number, + rows: number + ): boolean { + return this.mutations.resizeAuthorityExact(sessionId, access, cols, rows) } // Why null-not-throw (unlike write/resize): pause/resume are best-effort hints against a session that may have exited. pauseProducer(sessionId: string): void { - const session = this.sessions.get(sessionId) - if (!session || !session.isAlive) { - return - } - session.pauseProducer() + this.mutations.pauseProducer(sessionId) } resumeProducer(sessionId: string): void { - this.sessions.get(sessionId)?.resumeProducer() + this.mutations.resumeProducer(sessionId) + } + + supportsExactHeldProducerPause(sessionId: string, incarnationId: string): boolean { + return this.mutations.supportsExactHeldProducerPause(sessionId, incarnationId) + } + + acquireExactHeldProducerPause( + sessionId: string, + incarnationId: string, + ownerId: string, + token: string + ): boolean { + return this.mutations.acquireExactHeldProducerPause(sessionId, incarnationId, ownerId, token) + } + + releaseExactHeldProducerPause( + sessionId: string, + incarnationId: string, + ownerId: string, + token: string + ): boolean { + return this.mutations.releaseExactHeldProducerPause(sessionId, incarnationId, ownerId, token) + } + + releaseExactHeldProducerPauses(sessionId: string, incarnationId: string, ownerId: string): void { + this.mutations.releaseExactHeldProducerPauses(sessionId, incarnationId, ownerId) } kill(sessionId: string, opts: { immediate?: boolean } = {}): Promise { - const pending = this.sessionTeardown.get(sessionId) - if (pending) { - return Promise.resolve( - opts.immediate ? this.sessionTeardown.requestImmediate(sessionId) : pending - ) - } - const session = this.getAliveSession(sessionId) - const killed = this.sessionTeardown.killSession(sessionId, session, opts.immediate === true) - this.killedTombstones.record(sessionId) - return Promise.resolve(killed) + return this.mutations.kill(sessionId, opts) + } + + async killExact( + sessionId: string, + incarnationId: string, + opts: { immediate?: boolean } = {} + ): Promise { + return await this.mutations.killExact(sessionId, incarnationId, opts) + } + + async killAuthorityExact( + sessionId: string, + access: TerminalSessionAuthorityPtyAccess, + policyConsumer: TerminalAuthorityPolicyConsumerConnection, + opts: { immediate?: boolean } = {} + ): Promise { + return await this.mutations.killAuthorityExact(sessionId, access, policyConsumer, opts) + } + + ensureAuthorityBindingRetired( + access: TerminalSessionAuthorityPtyAccess, + reason: TerminalAuthorityBindingRetiredEffect['reason'] + ): Promise { + return this.mutations.ensureAuthorityBindingRetired(access, reason) } // Why: dispose a dead session's emulator so exited terminals don't pin ~5000 rows of scrollback for the daemon's life. @@ -126,75 +240,73 @@ export class TerminalHost { } signal(sessionId: string, sig: string): void { - this.getAliveSession(sessionId).signal(sig) + this.mutations.signal(sessionId, sig) + } + + signalExact(sessionId: string, incarnationId: string, sig: string): boolean { + return this.mutations.signalExact(sessionId, incarnationId, sig) + } + + signalAuthorityExact( + sessionId: string, + access: TerminalSessionAuthorityPtyAccess, + sig: string + ): boolean { + return this.mutations.signalAuthorityExact(sessionId, access, sig) } detach(sessionId: string, token: symbol): void { - const session = this.sessions.get(sessionId) - session?.detachClient(token) + this.queries.detach(sessionId, token) } async getCwd(sessionId: string): Promise { - return await resolveTerminalHostSessionCwd(this.getAliveSession(sessionId)) + return await this.queries.getCwd(sessionId) } // Why: null-not-throw — fetched for the tab-bar icon, so a vanished pane should quietly yield "no agent". getForegroundProcess(sessionId: string): string | null { - const session = this.sessions.get(sessionId) - if (!session || !session.isAlive) { - return null - } - return session.getForegroundProcess() + return this.queries.getForegroundProcess(sessionId) } inspectProcess(sessionId: string): { foregroundProcess: string | null hasChildProcesses: boolean } { - const foregroundProcess = this.getAliveSession(sessionId).getForegroundProcess() - return { - foregroundProcess, - hasChildProcesses: foregroundProcess !== null && !isShellProcess(foregroundProcess) - } + return this.queries.inspectProcess(sessionId) } async confirmForegroundProcess(sessionId: string): Promise { - const session = this.sessions.get(sessionId) - if (!session || !session.isAlive) { - return null - } - return session.confirmForegroundProcess() + return await this.queries.confirmForegroundProcess(sessionId) } clearScrollback(sessionId: string): void { - this.getAliveSession(sessionId).clearScrollback() + this.mutations.clearScrollback(sessionId) + } + + clearScrollbackExact(sessionId: string, incarnationId: string): boolean { + return this.mutations.clearScrollbackExact(sessionId, incarnationId) + } + + clearScrollbackAuthorityExact( + sessionId: string, + access: TerminalSessionAuthorityPtyAccess + ): boolean { + return this.mutations.clearScrollbackAuthorityExact(sessionId, access) } // Why: null-not-throw (unlike getAliveSession) — checkpoint is best-effort against a session that may have just exited. getSnapshot(sessionId: string, opts: { scrollbackRows?: number } = {}): TerminalSnapshot | null { - const session = this.sessions.get(sessionId) - if (!session || !session.isAlive) { - return null - } - return session.getSnapshot(opts) + return this.queries.getSnapshot(sessionId, opts) } // Why: scan-authority handoff seed (null-not-throw like getSnapshot) — emulator's dangling incomplete escape at the stream position. getPartialEscapeTailAnsi(sessionId: string): string { - const session = this.sessions.get(sessionId) - if (!session || !session.isAlive) { - return '' - } - return session.getPartialEscapeTailAnsi() + return this.queries.getPartialEscapeTailAnsi(sessionId) } // Why: renderer diffs this against xterm to detect a dropped/coerced daemon-side resize; null-not-throw like getSnapshot. getAppliedSize(sessionId: string): { cols: number; rows: number } | null { - const session = this.sessions.get(sessionId) - if (!session || !session.isAlive) { - return null - } - return session.getAppliedSize() + return this.queries.getAppliedSize(sessionId) } // Why: null-not-throw like getSnapshot — incremental checkpoints are best-effort against a just-exited session. @@ -203,19 +315,15 @@ export class TerminalHost { includeSnapshot: boolean, opts: { teardownSnapshot?: boolean } = {} ): TakePendingOutputResult | null { - const session = this.sessions.get(sessionId) - if (!session || !session.isAlive) { - return null - } - return session.takePendingOutput(includeSnapshot, opts) + return this.queries.takePendingOutput(sessionId, includeSnapshot, opts) } isKilled(sessionId: string): boolean { - return this.killedTombstones.has(sessionId) + return this.queries.isKilled(sessionId) } listSessions(): SessionInfo[] { - return listLiveTerminalHostSessions(this.sessions, this.agentSessionOwners) + return this.queries.listSessions() } dispose(): Promise { @@ -235,15 +343,11 @@ export class TerminalHost { } private async disposeSessions(): Promise { - await shutdownTerminalHostSessions(this.sessions, this.onFinalCheckpoint) - this.killedTombstones.clear() - } - - private getAliveSession(sessionId: string): Session { - const session = this.sessions.get(sessionId) - if (!session || !session.isAlive) { - throw new SessionNotFoundError(sessionId) - } - return session + await disposeTerminalHostSessions({ + sessions: this.sessions, + authoritySessions: this.authoritySessions, + killedTombstones: this.killedTombstones, + onFinalCheckpoint: this.onFinalCheckpoint + }) } } diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index 865572ea0b1..3732871a996 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -20,6 +20,10 @@ import type { AgentSessionSurfaceBinding } from '../../shared/agent-session-host-authority' import type * as HistorySeedProtocol from './terminal-history-seed-transfer-protocol' +import type { DaemonPtyOperationRequest } from './daemon-pty-operation-requests' +import type { TerminalSessionAuthorityPtyAccess } from '../../shared/terminal-session-authority-pty-access' +import type { DaemonTerminalAuthorityOutcomeRequest } from './daemon-terminal-authority-outcome-requests' +import type { DaemonTerminalAuthorityConsumerRequest } from './daemon-terminal-authority-consumer-requests' export type { TerminalModes } from './terminal-modes' import type { TerminalSnapshot } from './terminal-snapshot' export type { TerminalSnapshot } from './terminal-snapshot' @@ -28,13 +32,17 @@ export { AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION, CLEAN_DISCONNECT_PROTOCOL_VERSION, COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION, + EXACT_HELD_PRODUCER_PAUSE_DAEMON_PROTOCOL_VERSION, GET_FOREGROUND_PROCESS_PROTOCOL_VERSION, GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION, PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION, PTY_STARTUP_INGRESS_PROTOCOL_VERSION, MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION, + TERMINAL_SESSION_AUTHORITY_DAEMON_PROTOCOL_VERSION, supportsMode2031UnsubscribeFact, + supportsExactHeldProducerPause, + supportsTerminalSessionAuthority, supportsPtyStartupIngress } from './daemon-protocol-version' @@ -90,6 +98,13 @@ export type CreateOrAttachRequest = { claim: AgentSessionExecutionClaim surface: AgentSessionSurfaceBinding } + streamBindingNonce?: string + terminalSessionAuthorityVersion?: number + terminalSessionAuthorityOperationId?: string + worktreeId?: string + paneKey?: string + paneGeneration?: number + terminalSessionAuthorityAccess?: TerminalSessionAuthorityPtyAccess } } @@ -105,34 +120,16 @@ export type CancelCreateOrAttachRequest = { payload: { sessionId: string } } -export type WriteRequest = { - id: string - type: 'write' - payload: { - sessionId: string - data: string - } -} - -export type ResizeRequest = { - id: string - type: 'resize' - payload: { - sessionId: string - cols: number - rows: number - } -} - // ─── Producer flow control (v19+) ─────────────────────────────────── -// Why fire-and-forget notifications (like write/resize): pause/resume ride the -// hot data path and are best-effort — the daemon-side 5s failsafe, not an RPC -// reply, is what guarantees a paused shell can never stay wedged. +// Legacy calls stay fire-and-forget with a 5s failsafe. v35 exact calls add +// optional incarnation/token fields and require an acknowledged RPC response. export type PausePtyRequest = { id: string type: 'pausePty' payload: { sessionId: string + incarnationId?: string + heldPauseToken?: string } } @@ -141,6 +138,8 @@ export type ResumePtyRequest = { type: 'resumePty' payload: { sessionId: string + incarnationId?: string + heldPauseToken?: string } } @@ -156,15 +155,6 @@ export type SetSessionBackgroundRequest = { } } -export type KillRequest = { - id: string - type: 'kill' - payload: { - sessionId: string - immediate?: boolean - } -} - export type SignalRequest = { id: string type: 'signal' @@ -296,12 +286,10 @@ export type DaemonRequest = | CreateOrAttachRequest | HistorySeedProtocol.TerminalHistorySeedTransferRequest | CancelCreateOrAttachRequest - | WriteRequest - | ResizeRequest + | DaemonPtyOperationRequest | PausePtyRequest | ResumePtyRequest | SetSessionBackgroundRequest - | KillRequest | SignalRequest | ListSessionsRequest | ShutdownIfIdleRequest @@ -319,6 +307,8 @@ export type DaemonRequest = | GetSizeRequest | TakePendingOutputRequest | CloseStartupQueryAuthorityRequest + | DaemonTerminalAuthorityOutcomeRequest + | DaemonTerminalAuthorityConsumerRequest // ─── RPC Responses (Daemon → Client, on control socket) ──────────── @@ -358,6 +348,8 @@ export type SystemResolverHealthResult = { export type SessionInfo = { sessionId: string incarnationId?: string + mutationRouteToken?: object + terminalSessionAuthorityAccess?: TerminalSessionAuthorityPtyAccess state: SessionState shellState: ShellReadyState isAlive: boolean diff --git a/src/main/durable-file-write-syscall-proof.test.ts b/src/main/durable-file-write-syscall-proof.test.ts index bf18d6c0092..227d0e1cbb7 100644 --- a/src/main/durable-file-write-syscall-proof.test.ts +++ b/src/main/durable-file-write-syscall-proof.test.ts @@ -10,13 +10,22 @@ import { expect, it, vi } from 'vitest' * fsync-only log reads identically for the correct and the broken order. The rename is the boundary * the ordering is defined against, so it has to appear in the same sequence. */ const syscalls: ('fsync:file' | 'fsync:directory' | 'rename')[] = [] +let directoryFsyncFailure: string | null = null vi.mock('node:fs', async () => { const actual = await vi.importActual('node:fs') return { ...actual, fsyncSync: (fd: number) => { - syscalls.push(actual.fstatSync(fd).isDirectory() ? 'fsync:directory' : 'fsync:file') + const isDirectory = actual.fstatSync(fd).isDirectory() + syscalls.push(isDirectory ? 'fsync:directory' : 'fsync:file') + if (isDirectory && directoryFsyncFailure) { + const error = new Error( + `directory fsync failed: ${directoryFsyncFailure}` + ) as NodeJS.ErrnoException + error.code = directoryFsyncFailure + throw error + } return actual.fsyncSync(fd) }, renameSync: (from: NodeFs.PathLike, to: NodeFs.PathLike) => { @@ -65,3 +74,56 @@ it('fsyncs the file before rename, and the directory after where supported', asy rmSync(dir, { recursive: true, force: true }) } }) + +it('routes explicit lifecycle secure writes through the durability boundary', async () => { + const { writeSecureFileDurable } = await import('../shared/secure-file') + const dir = mkdtempSync(join(tmpdir(), 'orca-secure-fsync-')) + try { + const supported = directoryFsyncSupported(dir) + syscalls.length = 0 + const target = join(dir, 'secret.json') + writeSecureFileDurable(target, '{"ok":1}') + expect(readFileSync(target, 'utf-8')).toBe('{"ok":1}') + expect(syscalls).toEqual( + supported ? ['fsync:file', 'rename', 'fsync:directory'] : ['fsync:file', 'rename'] + ) + } finally { + rmSync(dir, { recursive: true, force: true }) + } +}) + +it('keeps ordinary secure writes free of lifecycle fsync overhead', async () => { + const { writeSecureFile } = await import('../shared/secure-file') + const dir = mkdtempSync(join(tmpdir(), 'orca-secure-write-')) + try { + syscalls.length = 0 + writeSecureFile(join(dir, 'ordinary.json'), '{"ok":1}') + expect(syscalls).toEqual(['rename']) + } finally { + rmSync(dir, { recursive: true, force: true }) + } +}) + +it('propagates POSIX directory fsync failures but degrades only for unsupported errors', async () => { + if (process.platform === 'win32') { + return + } + const { writeFileDurableSync } = await import('./durable-file-write') + const dir = mkdtempSync(join(tmpdir(), 'orca-directory-fsync-')) + try { + const target = join(dir, 'state.json') + directoryFsyncFailure = 'EIO' + expect(() => writeFileDurableSync(`${target}.tmp`, target, '{"ok":1}')).toThrow( + 'directory fsync failed' + ) + + directoryFsyncFailure = 'EINVAL' + expect(() => + writeFileDurableSync(`${target}.unsupported.tmp`, target, '{"ok":2}') + ).not.toThrow() + expect(readFileSync(target, 'utf-8')).toBe('{"ok":2}') + } finally { + directoryFsyncFailure = null + rmSync(dir, { recursive: true, force: true }) + } +}) diff --git a/src/main/durable-file-write.ts b/src/main/durable-file-write.ts index 9de0ec28109..f56b845fbe4 100644 --- a/src/main/durable-file-write.ts +++ b/src/main/durable-file-write.ts @@ -3,9 +3,9 @@ // empty-file symptom as issue #1158, from a different cause. The .bak ring recovers it at up to an // hour's loss; fsync stops it from happening. -import { closeSync, fsyncSync, openSync, renameSync, writeFileSync } from 'node:fs' import { open, readdir, rename, rm, stat } from 'node:fs/promises' import { basename, dirname, join } from 'node:path' +export { writeFileDurableSync } from '../shared/durable-file-write' /** * fsync a directory so a rename within it is durable. Best-effort by design: Windows cannot open a @@ -24,24 +24,6 @@ async function syncDirectory(directory: string): Promise { } } -function syncDirectorySync(directory: string): void { - let fd: number | null = null - try { - fd = openSync(directory, 'r') - fsyncSync(fd) - } catch { - // Same platform caveats as syncDirectory. - } finally { - if (fd !== null) { - try { - closeSync(fd) - } catch { - // Nothing actionable; the fsync already happened or the open failed. - } - } - } -} - /** * Rename and then fsync the containing directory. For callers that already fsynced the temp file * themselves and need the rename made durable. @@ -135,16 +117,3 @@ export async function removeStaleDurableWriteTempFiles( // Directory missing or unreadable — nothing to sweep. } } - -/** Synchronous counterpart for quit and crash paths that cannot await. */ -export function writeFileDurableSync(tmpPath: string, finalPath: string, payload: string): void { - writeFileSync(tmpPath, payload, 'utf-8') - const fd = openSync(tmpPath, 'r+') - try { - fsyncSync(fd) - } finally { - closeSync(fd) - } - renameSync(tmpPath, finalPath) - syncDirectorySync(dirname(finalPath)) -} diff --git a/src/main/index.ts b/src/main/index.ts index 790a8a2ed69..8306e33c558 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -28,6 +28,7 @@ import { registerPaneKeyTeardownListener, getLocalPtyProvider, getSshPtyProvider, + publishTerminalSideEffectBatchToRenderer, registerHeadlessPtyRuntime, type CodexHomeLaunchContext } from './ipc/pty' @@ -36,7 +37,8 @@ import { disconnectDaemon, getDaemonProvider, listLiveDaemonPtyIds, - shutdownDaemon + shutdownDaemon, + refreshDaemonAuthorityAppProofIdentity } from './daemon/daemon-init' import { hasRecordedLegacySharedCodexPane, @@ -188,6 +190,7 @@ import { ensureAutoUpdaterConfigured } from './window/attach-main-window-services' import { createMainWindow, loadMainWindow } from './window/createMainWindow' +import { registerMainWindowClosedCleanup } from './window/main-window-lifecycle' import { getDashboardPopoutWindow, zoomDashboardPopoutIfFocused @@ -261,7 +264,7 @@ import { AgentAwakeService } from './agent-awake-service' import { registerSystemResumeBroadcast } from './system-resume-broadcast' import { settleTeardownWithinDeadline } from './quit-teardown-deadline' import { quitTeardownStartGate } from './quit-teardown-start-gate' -import { beginSshShutdown } from './ipc/ssh' +import { beginSshShutdown, disconnectAllSshTargetsForIdentityReset } from './ipc/ssh' import { PluginService } from './plugins/plugin-service' import { PluginKillListService } from './plugins/plugin-kill-list-service' import { getPluginsDataDir } from './plugins/plugin-discovery' @@ -307,7 +310,6 @@ import { import type { AgentStatusState } from '../shared/agent-status-types' import { resolveTuiAgentPermissionMode } from '../shared/tui-agent-permissions' import { isAskUserQuestionTool } from '../shared/agent-question-answered-intent' -import type { TerminalSideEffectBatch } from '../shared/terminal-side-effect-facts' import { HEADLESS_RUNTIME_WINDOW_ID, type RuntimeDesktopWindowStatus @@ -1392,7 +1394,7 @@ function openMainWindow(): BrowserWindow { rateLimits.attach(window) // Why: quota probes spawn CLIs and hit network, so don't fetch immediately and compete with first paint; show/focus listeners refresh later. rateLimits.start({ fetchImmediately: false }) - window.on('closed', () => { + registerMainWindowClosedCleanup(window, () => { if (mainWindow === window) { mainWindow = null } @@ -2367,11 +2369,7 @@ void app.whenReady().then(async () => { agentHookServer.ingestTerminalStatus(event) }, // Why: serve can be promoted in place, so wire the listener from startup; runtime enables desktop-only scanners only for a ready renderer. - onTerminalSideEffects: (batch: TerminalSideEffectBatch) => { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('pty:sideEffect', batch) - } - }, + onTerminalSideEffects: publishTerminalSideEffectBatchToRenderer, getDesktopWindowStatus: getDesktopWindowStatus, // Why: worktree.ps pulls hook-reported agent status (same source as the desktop sidebar) at query time so mobile shows the same agents. getAgentStatusSnapshot: () => @@ -2819,7 +2817,11 @@ void app.whenReady().then(async () => { preferPinnedWsPort: true } : {}), - webClientRoot: getBundledWebClientRoot() + webClientRoot: getBundledWebClientRoot(), + identityResetHooks: { + disconnectSshTargets: disconnectAllSshTargetsForIdentityReset, + refreshDaemonProofIdentity: refreshDaemonAuthorityAppProofIdentity + } }) registerMobileHandlers(runtimeRpc, { getRelayStatus: () => desktopRelayStatus, @@ -2956,6 +2958,8 @@ void app.whenReady().then(async () => { runtimeRpc.setMobileRelayPairingProvider({ createPairingRelay: (relayDeviceId) => relayService.createPairingRelay(relayDeviceId), onDeviceRevokeQueued: (item) => relayService.onDeviceRevokeQueued(item), + awaitIdentityResetRevocations: (items) => relayService.awaitIdentityResetRevocations(items), + identityResetCompleted: () => relayService.identityResetCompleted(), onDemandStateChanged: () => relayService.demandStateChanged(), getEndpoints: (context, params) => relayService.getEndpoints(context, params), provisionRelay: (context, params) => relayService.provisionRelay(context, params) diff --git a/src/main/ipc/mobile.ts b/src/main/ipc/mobile.ts index 35584f951a4..76d2d2f2abe 100644 --- a/src/main/ipc/mobile.ts +++ b/src/main/ipc/mobile.ts @@ -9,6 +9,7 @@ import type { DeviceEntry } from '../runtime/device-registry' import { NETWORK_EXPOSURE_FAILED_GUIDANCE } from '../runtime/network-exposure-guidance' import { resolveAdvertisedPairingHostname } from '../runtime/pairing-endpoint' import type { OrcaRuntimeRpcServer } from '../runtime/runtime-rpc' +import type { E2EEIdentityResetStatus } from '../runtime/e2ee-identity-reset' import type { RelayBrokerStatus } from '../runtime/relay/relay-session-broker' import { encodeMobilePairingQr, type MobilePairingQrResult } from '../runtime/mobile-pairing-qr' import { @@ -298,6 +299,16 @@ export function registerMobileHandlers( return { revoked: rpcServer.revokeRuntimeAccess(args.deviceId) } }) + ipcMain.handle( + 'mobile:getIdentityResetStatus', + (): E2EEIdentityResetStatus => rpcServer.getIdentityResetStatus() + ) + + ipcMain.handle('mobile:resetIdentity', async () => { + const result = await rpcServer.resetE2EEIdentity() + return { phase: result.phase, transactionId: result.transactionId } + }) + ipcMain.handle('mobile:isWebSocketReady', () => { return { ready: rpcServer.getWebSocketEndpoint() !== null, diff --git a/src/main/ipc/pty-authority-outcome-ipc.test.ts b/src/main/ipc/pty-authority-outcome-ipc.test.ts new file mode 100644 index 00000000000..b67c46d0d9e --- /dev/null +++ b/src/main/ipc/pty-authority-outcome-ipc.test.ts @@ -0,0 +1,120 @@ +import { EventEmitter } from 'node:events' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterAll, describe, expect, it, vi } from 'vitest' +import { + TERMINAL_AUTHORITY_APP_PROJECTION_CLEAR_BELL, + TERMINAL_AUTHORITY_APP_PROJECTION_EVENT, + TERMINAL_AUTHORITY_APP_PROJECTION_SUBSCRIBE, + TERMINAL_AUTHORITY_APP_PROJECTION_VERSION, + type TerminalAuthorityAppProjectionSnapshot +} from '../../shared/terminal-authority-app-projection' +import { + APP_CONSUMER, + boundary, + semanticPublication +} from '../session-authority/__tests__/terminal-authority-app-projection-fixture' +import { TerminalAuthorityAppProjectionStore } from '../session-authority/terminal-authority-app-projection-store' + +const { handlers, handle, removeHandler } = vi.hoisted(() => ({ + handlers: new Map unknown>(), + handle: vi.fn(), + removeHandler: vi.fn() +})) + +vi.mock('electron', () => ({ ipcMain: { handle, removeHandler } })) +vi.mock('../orca-profiles/profile-index-store', () => ({ + ensureActiveOrcaProfile: () => ({ profileDirectory: '/tmp/orca-authority-ipc-profile' }) +})) + +import { registerPtyAuthorityOutcomeIpc } from './pty-authority-outcome-ipc' + +const directories: string[] = [] + +afterAll(async () => { + await Promise.all(directories.map((directory) => rm(directory, { recursive: true }))) +}) + +describe('pty authority projection IPC', () => { + it('serves committed snapshots, fences senders, and awaits durable bell clear', async () => { + handle.mockImplementation( + (channel: string, handler: (event: { sender: object }, value: unknown) => unknown) => { + handlers.set(channel, handler) + } + ) + const directory = await mkdtemp(path.join(tmpdir(), 'orca-authority-ipc-')) + directories.push(directory) + const store = await TerminalAuthorityAppProjectionStore.open({ + directory, + databasePath: ':memory:' + }) + store.beginBoundary(boundary(0)) + store.apply(semanticPublication(1)) + const events = new EventEmitter() + const sent: { channel: string; value: unknown }[] = [] + const webContents = { + on: events.on.bind(events), + removeListener: events.removeListener.bind(events), + send: (channel: string, value: unknown) => sent.push({ channel, value }), + isDestroyed: () => false + } + registerPtyAuthorityOutcomeIpc({ isDestroyed: () => false, webContents } as never, { + processIncarnationId: APP_CONSUMER.consumerIncarnationId, + projectionStore: store + }) + const subscribe = handlers.get(TERMINAL_AUTHORITY_APP_PROJECTION_SUBSCRIBE)! + const clearBell = handlers.get(TERMINAL_AUTHORITY_APP_PROJECTION_CLEAR_BELL)! + + const snapshot = (await subscribe( + { sender: webContents }, + subscription('renderer-1') + )) as TerminalAuthorityAppProjectionSnapshot + const row = snapshot.rows[0]! + expect(row).toMatchObject({ attention: { pendingBellCount: 1 } }) + await expect(subscribe({ sender: {} }, subscription('renderer-stale'))).rejects.toThrow( + 'sender_stale' + ) + + await expect( + clearBell( + { sender: webContents }, + { + version: TERMINAL_AUTHORITY_APP_PROJECTION_VERSION, + consumerId: row.consumerId, + namespace: row.namespace, + pane: row.pane, + expectedEvent: row.facts.bell!.event + } + ) + ).resolves.toBe(true) + expect(store.snapshot(APP_CONSUMER.consumerId)[0]?.attention.pendingBellCount).toBe(0) + expect(sent.at(-1)).toMatchObject({ + channel: TERMINAL_AUTHORITY_APP_PROJECTION_EVENT, + value: { + rows: [ + expect.objectContaining({ + attention: expect.objectContaining({ pendingBellCount: 0 }) + }) + ] + } + }) + + events.emit('did-start-navigation', {}, 'app://renderer', false, true) + await expect( + subscribe({ sender: webContents }, subscription('renderer-2', 'renderer-1')) + ).resolves.toMatchObject({ subscriptionIncarnationId: 'renderer-2' }) + store.close() + }) +}) + +function subscription( + subscriptionIncarnationId: string, + expectedSubscriptionIncarnationId: string | null = null +) { + return { + version: TERMINAL_AUTHORITY_APP_PROJECTION_VERSION, + subscriptionIncarnationId, + expectedSubscriptionIncarnationId + } +} diff --git a/src/main/ipc/pty-authority-outcome-ipc.ts b/src/main/ipc/pty-authority-outcome-ipc.ts new file mode 100644 index 00000000000..41c10287c43 --- /dev/null +++ b/src/main/ipc/pty-authority-outcome-ipc.ts @@ -0,0 +1,292 @@ +import { randomUUID } from 'node:crypto' +import { ipcMain, type BrowserWindow } from 'electron' +import { + TERMINAL_AUTHORITY_APP_PROJECTION_CLEAR_BELL, + TERMINAL_AUTHORITY_APP_PROJECTION_SUBSCRIBE +} from '../../shared/terminal-authority-app-projection' +import { parseTerminalAuthorityAppBellClearRequest } from '../../shared/terminal-authority-app-projection-validation' +import { + assertAuthorityId, + type TerminalAuthorityNamespace +} from '../../shared/terminal-session-authority-identity' +import type { TerminalAuthorityConsumerRetirementResult } from '../../shared/terminal-session-authority-consumer-retirement' +import type { TerminalAuthorityAppConsumerRetirementRequest } from '../session-authority/terminal-authority-app-outcome-host-contract' +import { ensureActiveOrcaProfile } from '../orca-profiles/profile-index-store' +import { + TerminalAuthorityAppOutcomeHostManager, + type TerminalAuthorityAppOutcomeHostRegistration +} from '../session-authority/terminal-authority-app-outcome-host-manager' +import type { + TerminalAuthorityAppNamespaceAdmission, + TerminalAuthorityAppOutcomeHostTransport +} from '../session-authority/terminal-authority-app-outcome-host-contract' +import { + TerminalAuthorityAppOutcomeHostTransportSlot, + type TerminalAuthorityAppOutcomeHostTransportLease +} from '../session-authority/terminal-authority-app-outcome-host-transport-slot' +import { TerminalAuthorityAppProjectionStore } from '../session-authority/terminal-authority-app-projection-store' +import { PtyAuthorityProjectionBroker } from './pty-authority-projection-broker' +import { PtyAuthorityOutcomeRendererBinding } from './pty-authority-outcome-renderer-binding' + +type AppProjectionRuntime = Readonly<{ + store: TerminalAuthorityAppProjectionStore + manager: TerminalAuthorityAppOutcomeHostManager +}> + +type RegisterPtyAuthorityOutcomeIpcOptions = Readonly<{ + processIncarnationId?: string + projectionStore?: + | TerminalAuthorityAppProjectionStore + | Promise + hostTransport?: TerminalAuthorityAppOutcomeHostTransport +}> + +export type PtyAuthorityOutcomeHostRegistration = TerminalAuthorityAppNamespaceAdmission & + Readonly<{ + authenticatedAuthorityHostId: string + knownNamespaceIds(): readonly string[] + retireNamespace: TerminalAuthorityAppOutcomeHostRegistration['retireNamespace'] + dispose(): void + }> + +export type PtyAuthorityOutcomeResetTarget = Readonly<{ + authenticatedAuthorityHostId: string + namespaceIds: readonly string[] + retireNamespace( + namespaceId: string, + request: TerminalAuthorityAppConsumerRetirementRequest + ): Promise +}> + +let runtimeSnapshot: AppProjectionRuntime | null = null +let runtimeReady: Promise | null = null +type PendingHostInstallation = { + slot: TerminalAuthorityAppOutcomeHostTransportSlot + registration: TerminalAuthorityAppOutcomeHostRegistration | null + ready: Promise +} + +const hostInstallations = new Map() +let authorityAdmissionsFrozen = false +const broker = new PtyAuthorityProjectionBroker(() => { + if (!runtimeSnapshot) { + throw new Error('terminal authority app projection is unavailable') + } + return runtimeSnapshot.store.snapshotAll() +}) +const rendererBinding = new PtyAuthorityOutcomeRendererBinding(broker) + +export function registerPtyAuthorityOutcomeIpc( + mainWindow: BrowserWindow, + options: RegisterPtyAuthorityOutcomeIpcOptions = {} +): void { + void ensureRuntime(options) + if (options.hostTransport) { + installPtyAuthorityOutcomeHostTransport(options.hostTransport) + } + rendererBinding.bind(mainWindow) + ipcMain.removeHandler(TERMINAL_AUTHORITY_APP_PROJECTION_SUBSCRIBE) + ipcMain.handle(TERMINAL_AUTHORITY_APP_PROJECTION_SUBSCRIBE, async (event, value: unknown) => { + const admission = broker.admitRendererRequest(event.sender) + await ensureRuntime() + return broker.subscribe(event.sender, value, admission) + }) + ipcMain.removeHandler(TERMINAL_AUTHORITY_APP_PROJECTION_CLEAR_BELL) + ipcMain.handle(TERMINAL_AUTHORITY_APP_PROJECTION_CLEAR_BELL, async (event, value: unknown) => { + const admission = broker.admitRendererRequest(event.sender) + const runtime = await ensureRuntime() + if (admission.rendererToken !== event.sender) { + throw new Error('terminal_authority_projection_sender_stale') + } + const request = parseTerminalAuthorityAppBellClearRequest(value) + if (!request) { + throw new Error('terminal_authority_projection_bell_clear_invalid') + } + const changed = runtime.store.clearBell(request) + if (changed) { + broker.publish([changed], []) + } + return changed !== null + }) +} + +export function installPtyAuthorityOutcomeHostTransport( + transport: TerminalAuthorityAppOutcomeHostTransport +): PtyAuthorityOutcomeHostRegistration { + const hostId = transport.authenticatedAuthorityHostId + assertAuthorityId(hostId, 'authenticatedAuthorityHostId') + const existing = hostInstallations.get(hostId) + if (existing) { + return sourceRegistration(existing, existing.slot.install(transport)) + } + const slot = new TerminalAuthorityAppOutcomeHostTransportSlot(hostId) + const source = slot.install(transport) + let installation!: PendingHostInstallation + const ready = ensureRuntime().then((runtime) => { + if (hostInstallations.get(hostId) !== installation) { + throw new Error('terminal authority app outcome host installation is stale') + } + const registration = runtime.manager.installHost(slot) + installation.registration = registration + return registration + }) + installation = { + slot, + registration: null, + ready + } + hostInstallations.set(hostId, installation) + void installation.ready.catch((error) => { + if (hostInstallations.get(hostId) === installation) { + console.error('[terminal-authority] app outcome transport unavailable', error) + } + }) + return sourceRegistration(installation, source) +} + +/** Blocks new app namespace admissions while allowing reset retirement calls through. */ +export function setPtyAuthorityOutcomeAdmissionFrozen(frozen: boolean): void { + authorityAdmissionsFrozen = frozen +} + +export function isPtyAuthorityOutcomeAdmissionFrozen(): boolean { + return authorityAdmissionsFrozen +} + +/** Returns the app authority manager's process identity for exact reset proofs. */ +export function getPtyAuthorityOutcomeProcessIncarnationId(): string | null { + return runtimeSnapshot?.manager.getProcessIncarnationId() ?? null +} + +/** Returns the authenticated in-memory host/session inventory captured by the reset record. */ +export function listPtyAuthorityOutcomeResetTargets(): readonly PtyAuthorityOutcomeResetTarget[] { + return Object.freeze( + [...hostInstallations.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([hostId, installation]) => { + const knownNamespaceIds = installation.registration?.knownNamespaceIds() ?? [] + return Object.freeze({ + authenticatedAuthorityHostId: hostId, + namespaceIds: Object.freeze([...knownNamespaceIds].sort()), + retireNamespace: async ( + namespaceId: string, + request: TerminalAuthorityAppConsumerRetirementRequest + ) => { + const registration = await installation.ready + if (!registration.knownNamespaceIds().includes(namespaceId)) { + throw new Error( + `terminal authority app outcome namespace is unresolved: ${namespaceId}` + ) + } + return await registration.retireNamespace(request) + } + }) + }) + ) +} + +/** Drops all old authenticated host/session consumers after retirement acknowledgements commit. */ +export function disposePtyAuthorityOutcomeResetTransports(): void { + for (const installation of hostInstallations.values()) { + installation.registration?.dispose() + installation.slot.dispose() + } + hostInstallations.clear() +} + +function sourceRegistration( + installation: PendingHostInstallation, + source: TerminalAuthorityAppOutcomeHostTransportLease +): PtyAuthorityOutcomeHostRegistration { + const hostId = installation.slot.authenticatedAuthorityHostId + const withSourceAdmission: TerminalAuthorityAppNamespaceAdmission['withSourceAdmission'] = async ( + locator, + operation + ) => { + assertAuthorityOutcomeAdmissionOpen() + return await source.withCurrent(async (sourceBinding) => { + if (hostInstallations.get(hostId) !== installation || !source.isActive()) { + throw new Error('terminal authority app outcome host installation is stale') + } + const registration = await installation.ready + const resolved = + 'namespace' in locator + ? { + namespace: locator.namespace, + binding: await registration.bindNamespace(locator.namespace) + } + : await registration.resolveAndBindNamespace(locator.worktreeId) + sourceBinding.bindConnectionGeneration() + const binding = Object.freeze({ + namespace: Object.freeze({ ...resolved.namespace }), + assertCurrent: () => { + sourceBinding.assertCurrent() + resolved.binding.assertCurrent() + } + }) + binding.assertCurrent() + const result = await operation(binding) + binding.assertCurrent() + return result + }) + } + return Object.freeze({ + authenticatedAuthorityHostId: hostId, + knownNamespaceIds: () => installation.registration?.knownNamespaceIds() ?? [], + admitNamespace: (namespace: TerminalAuthorityNamespace) => { + assertAuthorityOutcomeAdmissionOpen() + return withSourceAdmission({ namespace }, async () => undefined) + }, + resolveAndAdmitNamespace: (worktreeId: string) => { + assertAuthorityOutcomeAdmissionOpen() + return withSourceAdmission({ worktreeId }, async ({ namespace }) => namespace) + }, + withSourceAdmission, + retireNamespace: (request) => + source.withCurrent(async (binding) => { + const registration = await installation.ready + binding.bindConnectionGeneration() + return await registration.retireNamespace(request) + }), + dispose: () => { + source.dispose() + } + }) +} + +function assertAuthorityOutcomeAdmissionOpen(): void { + if (authorityAdmissionsFrozen) { + throw new Error('terminal authority app outcome admission is frozen for identity reset') + } +} + +function ensureRuntime( + options: RegisterPtyAuthorityOutcomeIpcOptions = {} +): Promise { + if (!runtimeReady) { + const profileDirectory = ensureActiveOrcaProfile().profileDirectory + const processIncarnationId = options.processIncarnationId ?? `app-process:${randomUUID()}` + assertAppProcessIncarnationId(processIncarnationId) + runtimeReady = Promise.resolve( + options.projectionStore ?? + TerminalAuthorityAppProjectionStore.open({ directory: profileDirectory }) + ).then((store) => { + const manager = new TerminalAuthorityAppOutcomeHostManager(processIncarnationId, { + store, + onProjection: (change) => broker.publish(change.rows, change.deleted), + onError: (error) => console.error('[terminal-authority] app outcome pump failed', error) + }) + runtimeSnapshot = Object.freeze({ store, manager }) + return runtimeSnapshot + }) + void runtimeReady.catch(() => undefined) + } + return runtimeReady +} + +function assertAppProcessIncarnationId(value: string): void { + assertAuthorityId(value, 'app process incarnation') + if (!value.startsWith('app-process:')) { + throw new Error('terminal authority app process incarnation is invalid') + } +} diff --git a/src/main/ipc/pty-authority-outcome-renderer-binding.ts b/src/main/ipc/pty-authority-outcome-renderer-binding.ts new file mode 100644 index 00000000000..d56bca6f0ba --- /dev/null +++ b/src/main/ipc/pty-authority-outcome-renderer-binding.ts @@ -0,0 +1,74 @@ +import type { BrowserWindow, WebContents } from 'electron' +import { + TERMINAL_AUTHORITY_APP_PROJECTION_EVENT, + type TerminalAuthorityAppProjectionDelta +} from '../../shared/terminal-authority-app-projection' +import type { PtyAuthorityProjectionBroker } from './pty-authority-projection-broker' + +type NavigationHandler = ( + event: Electron.Event, + url: string, + isSameDocument: boolean, + isMainFrame: boolean +) => void + +type BoundRenderer = Readonly<{ + webContents: WebContents + didStartNavigation: NavigationHandler + renderProcessGone: () => void + destroyed: () => void +}> + +/** Owns renderer event listeners so projection IPC can survive navigation and process loss. */ +export class PtyAuthorityOutcomeRendererBinding { + private boundRenderer: BoundRenderer | null = null + + constructor(private readonly broker: PtyAuthorityProjectionBroker) {} + + bind(mainWindow: BrowserWindow): void { + this.clear() + const webContents = mainWindow.webContents + this.broker.attachRenderer(webContents, (delta: TerminalAuthorityAppProjectionDelta) => { + if ( + mainWindow.isDestroyed() || + (typeof webContents.isDestroyed === 'function' && webContents.isDestroyed()) + ) { + throw new Error('terminal_authority_renderer_unavailable') + } + webContents.send(TERMINAL_AUTHORITY_APP_PROJECTION_EVENT, delta) + }) + const didStartNavigation: NavigationHandler = (_event, _url, isSameDocument, isMainFrame) => { + if (isMainFrame && !isSameDocument) { + this.broker.resetRenderer(webContents) + } + } + const renderProcessGone = (): void => this.broker.resetRenderer(webContents) + const destroyed = (): void => this.broker.detachRenderer(webContents) + webContents.on('did-start-navigation', didStartNavigation) + webContents.on('render-process-gone', renderProcessGone) + webContents.on('destroyed', destroyed) + this.boundRenderer = Object.freeze({ + webContents, + didStartNavigation, + renderProcessGone, + destroyed + }) + } + + clear(): void { + if (!this.boundRenderer) { + return + } + this.broker.detachRenderer(this.boundRenderer.webContents) + this.boundRenderer.webContents.removeListener( + 'did-start-navigation', + this.boundRenderer.didStartNavigation + ) + this.boundRenderer.webContents.removeListener( + 'render-process-gone', + this.boundRenderer.renderProcessGone + ) + this.boundRenderer.webContents.removeListener('destroyed', this.boundRenderer.destroyed) + this.boundRenderer = null + } +} diff --git a/src/main/ipc/pty-authority-projection-broker.test.ts b/src/main/ipc/pty-authority-projection-broker.test.ts new file mode 100644 index 00000000000..430f907313e --- /dev/null +++ b/src/main/ipc/pty-authority-projection-broker.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it, vi } from 'vitest' +import { TERMINAL_AUTHORITY_APP_PROJECTION_VERSION } from '../../shared/terminal-authority-app-projection' +import { PtyAuthorityProjectionBroker } from './pty-authority-projection-broker' + +describe('PtyAuthorityProjectionBroker', () => { + it('returns a snapshot and publishes only to the admitted renderer incarnation', async () => { + const row = { consumerId: 'app-profile:test' } as never + const send = vi.fn() + const renderer = {} + const broker = new PtyAuthorityProjectionBroker(() => [row]) + broker.attachRenderer(renderer, send) + + await expect(broker.subscribe(renderer, subscription('renderer-1'))).resolves.toMatchObject({ + subscriptionIncarnationId: 'renderer-1', + rows: [row] + }) + broker.publish( + [row], + [ + { + consumerId: 'app-profile:test', + namespace: { authorityHostId: 'host-1', namespaceId: 'namespace-1' }, + pane: { paneKey: 'pane-1', paneGenerationId: 'generation-1' } + } + ] + ) + + expect(send).toHaveBeenCalledWith( + expect.objectContaining({ + subscriptionIncarnationId: 'renderer-1', + rows: [row], + deleted: [expect.objectContaining({ consumerId: 'app-profile:test' })] + }) + ) + }) + + it('fences an admitted subscription when navigation starts before it executes', async () => { + const renderer = {} + const broker = new PtyAuthorityProjectionBroker(() => []) + broker.attachRenderer(renderer, vi.fn()) + const admission = broker.admitRendererRequest(renderer) + const subscriptionPromise = broker.subscribe(renderer, subscription('renderer-1'), admission) + + broker.resetRenderer(renderer) + + await expect(subscriptionPromise).rejects.toThrow('subscription_stale') + }) + + it('accepts a fresh reload after fencing the previous renderer subscription', async () => { + const renderer = {} + const broker = new PtyAuthorityProjectionBroker(() => []) + broker.attachRenderer(renderer, vi.fn()) + await broker.subscribe(renderer, subscription('renderer-1')) + broker.resetRenderer(renderer) + + await expect( + broker.subscribe(renderer, subscription('renderer-2', 'renderer-1')) + ).resolves.toMatchObject({ subscriptionIncarnationId: 'renderer-2' }) + }) +}) + +function subscription( + subscriptionIncarnationId: string, + expectedSubscriptionIncarnationId: string | null = null +) { + return { + version: TERMINAL_AUTHORITY_APP_PROJECTION_VERSION, + subscriptionIncarnationId, + expectedSubscriptionIncarnationId + } +} diff --git a/src/main/ipc/pty-authority-projection-broker.ts b/src/main/ipc/pty-authority-projection-broker.ts new file mode 100644 index 00000000000..071d4b8ef0f --- /dev/null +++ b/src/main/ipc/pty-authority-projection-broker.ts @@ -0,0 +1,122 @@ +import { + TERMINAL_AUTHORITY_APP_PROJECTION_VERSION, + type TerminalAuthorityAppPaneProjection, + type TerminalAuthorityAppProjectionDelta, + type TerminalAuthorityAppProjectionRowIdentity, + type TerminalAuthorityAppProjectionSnapshot +} from '../../shared/terminal-authority-app-projection' +import { parseTerminalAuthorityAppProjectionSubscribe } from '../../shared/terminal-authority-app-projection-validation' + +type AttachedRenderer = Readonly<{ + token: object + send: (delta: TerminalAuthorityAppProjectionDelta) => void +}> + +export type PtyAuthorityProjectionRendererAdmission = Readonly<{ + rendererToken: object | null + lifecycleGeneration: number +}> + +export class PtyAuthorityProjectionBroker { + private renderer: AttachedRenderer | null = null + private rendererLifecycleGeneration = 0 + private subscriptionIncarnationId: string | null = null + private operationQueue: Promise = Promise.resolve() + + constructor(private readonly snapshotRows: () => readonly TerminalAuthorityAppPaneProjection[]) {} + + attachRenderer(token: object, send: (delta: TerminalAuthorityAppProjectionDelta) => void): void { + this.renderer = Object.freeze({ token, send }) + this.fenceRendererSubscription() + } + + resetRenderer(token: object): void { + if (this.renderer?.token === token) { + this.fenceRendererSubscription() + } + } + + detachRenderer(token: object): void { + if (this.renderer?.token === token) { + this.renderer = null + this.fenceRendererSubscription() + } + } + + admitRendererRequest(token: object): PtyAuthorityProjectionRendererAdmission { + return Object.freeze({ + rendererToken: this.renderer?.token === token ? token : null, + lifecycleGeneration: this.rendererLifecycleGeneration + }) + } + + subscribe( + token: object, + value: unknown, + admission = this.admitRendererRequest(token) + ): Promise { + const request = parseTerminalAuthorityAppProjectionSubscribe(value) + return this.enqueue(() => { + if (this.renderer?.token !== token || admission.rendererToken !== token) { + throw new Error('terminal_authority_projection_sender_stale') + } + if (admission.lifecycleGeneration !== this.rendererLifecycleGeneration) { + throw new Error('terminal_authority_projection_subscription_stale') + } + if (!request) { + throw new Error('terminal_authority_projection_subscription_invalid') + } + const current = this.subscriptionIncarnationId + if ( + current !== null && + current !== request.subscriptionIncarnationId && + current !== request.expectedSubscriptionIncarnationId + ) { + throw new Error('terminal_authority_projection_subscription_stale') + } + this.subscriptionIncarnationId = request.subscriptionIncarnationId + return Object.freeze({ + version: TERMINAL_AUTHORITY_APP_PROJECTION_VERSION, + subscriptionIncarnationId: request.subscriptionIncarnationId, + rows: Object.freeze([...this.snapshotRows()]) + }) + }) + } + + publish( + rows: readonly TerminalAuthorityAppPaneProjection[], + deleted: readonly TerminalAuthorityAppProjectionRowIdentity[] = [] + ): void { + const renderer = this.renderer + const subscriptionIncarnationId = this.subscriptionIncarnationId + if (!renderer || !subscriptionIncarnationId || (rows.length === 0 && deleted.length === 0)) { + return + } + try { + renderer.send( + Object.freeze({ + version: TERMINAL_AUTHORITY_APP_PROJECTION_VERSION, + subscriptionIncarnationId, + rows: Object.freeze([...rows]), + ...(deleted.length > 0 ? { deleted: Object.freeze([...deleted]) } : {}) + }) + ) + } catch { + this.fenceRendererSubscription() + } + } + + private fenceRendererSubscription(): void { + this.rendererLifecycleGeneration += 1 + this.subscriptionIncarnationId = null + } + + private enqueue(operation: () => T): Promise { + const result = this.operationQueue.then(operation) + this.operationQueue = result.then( + () => undefined, + () => undefined + ) + return result + } +} diff --git a/src/main/ipc/pty-management.test.ts b/src/main/ipc/pty-management.test.ts index d1c2689000b..de7d703a44e 100644 --- a/src/main/ipc/pty-management.test.ts +++ b/src/main/ipc/pty-management.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { DaemonSessionInfo } from '../daemon/types' +import type { TerminalSessionAuthorityPtyAccess } from '../../shared/terminal-session-authority-pty-access' const { handleMock, @@ -86,6 +87,7 @@ function makeSession( ): DaemonSessionInfo { return { sessionId, + incarnationId: `${sessionId}-incarnation`, state: 'running', shellState: 'ready', isAlive: true, @@ -103,22 +105,52 @@ type MockAdapter = { protocolVersion: number listSessions: ReturnType shutdown: ReturnType + killExact: ReturnType + killAuthorityExact: ReturnType + getPtyMutationRouteToken: ReturnType + mutationRouteToken: object | null +} + +function daemonSessionRows(sessions: DaemonSessionInfo[], mutationRouteToken: object | null) { + return sessions.map(({ protocolVersion: _pv, ...rest }) => ({ + ...rest, + ...(mutationRouteToken ? { mutationRouteToken } : {}) + })) } function makeAdapter( protocolVersion: number, sessions: DaemonSessionInfo[], - shutdownImpl?: (id: string, immediate: boolean) => Promise + shutdownImpl?: (id: string, opts: { immediate?: boolean }) => Promise ): MockAdapter { // Why: collectSessions calls adapter.listSessions() (the daemon-side RPC) // and then annotates with adapter.protocolVersion. The mock returns the // *internal* SessionInfo shape (no protocolVersion) since the adapter adds // it. Stripping it here mirrors production behavior. - return { + const mutationRouteToken = Object.freeze({}) + const shutdown = vi.fn(shutdownImpl ?? (async () => {})) + const adapter: MockAdapter = { protocolVersion, - listSessions: vi.fn(async () => sessions.map(({ protocolVersion: _pv, ...rest }) => rest)), - shutdown: vi.fn(shutdownImpl ?? (async () => {})) + listSessions: vi.fn(async () => daemonSessionRows(sessions, adapter.mutationRouteToken)), + shutdown, + killExact: vi.fn(), + killAuthorityExact: vi.fn(), + getPtyMutationRouteToken: vi.fn(() => adapter.mutationRouteToken), + mutationRouteToken } + adapter.killExact.mockImplementation( + async (id: string, _incarnationId: string, opts: { immediate?: boolean }) => { + await shutdown(id, opts) + return true + } + ) + adapter.killAuthorityExact.mockImplementation( + async (id: string, _access: unknown, opts: { immediate?: boolean }) => { + await shutdown(id, opts) + return true + } + ) + return adapter } async function importFresh() { @@ -286,15 +318,15 @@ describe('pty:management IPC handlers', () => { } } current.listSessions = vi.fn(async () => - currentSessions.map(({ protocolVersion: _pv, ...rest }) => rest) + daemonSessionRows(currentSessions, current.mutationRouteToken) ) legacy.listSessions = vi.fn(async () => - legacySessions.map(({ protocolVersion: _pv, ...rest }) => rest) + daemonSessionRows(legacySessions, legacy.mutationRouteToken) ) - current.shutdown = vi.fn(async (id: string) => { + current.shutdown.mockImplementation(async (id: string) => { removeFrom(currentSessions, id) }) - legacy.shutdown = vi.fn(async (id: string) => { + legacy.shutdown.mockImplementation(async (id: string) => { removeFrom(legacySessions, id) }) const { registerDaemonManagementHandlers } = await importFresh() @@ -321,13 +353,13 @@ describe('pty:management IPC handlers', () => { const sessions = [makeSession('stuck')] const current = makeAdapter(5, []) current.listSessions = vi.fn(async () => - sessions.map(({ protocolVersion: _pv, ...rest }) => rest) + daemonSessionRows(sessions, current.mutationRouteToken) ) // Why: shutdown resolves but the session never leaves listSessions — // simulates a process wedged in uninterruptible sleep. After the poll // window (≈6.5s, past the daemon's 5s SIGTERM→SIGKILL ladder) the // handler must return remainingCount=1 rather than loop forever. - current.shutdown = vi.fn(async () => {}) + current.shutdown.mockImplementation(async () => {}) const { registerDaemonManagementHandlers } = await importFresh() getDaemonProviderMock.mockReturnValue(await makeRouter(current)) registerDaemonManagementHandlers() @@ -354,14 +386,14 @@ describe('pty:management IPC handlers', () => { pollCalls += 1 if (pollCalls === 1) { // Initial snapshot: a and b are alive. - return liveSessions.map(({ protocolVersion: _pv, ...rest }) => rest) + return daemonSessionRows(liveSessions, current.mutationRouteToken) } // First poll onward: a and b have been reaped, but the renderer // respawned a fresh pane with id 'c'. 'c' was never in the initial // snapshot, so it must not count as remaining. - return [makeSession('c')].map(({ protocolVersion: _pv, ...rest }) => rest) + return daemonSessionRows([makeSession('c')], current.mutationRouteToken) }) - current.shutdown = vi.fn(async () => {}) + current.shutdown.mockImplementation(async () => {}) const { registerDaemonManagementHandlers } = await importFresh() getDaemonProviderMock.mockReturnValue(await makeRouter(current)) registerDaemonManagementHandlers() @@ -376,11 +408,50 @@ describe('pty:management IPC handlers', () => { }) }) + it('does not chase a same-ID successor with a new incarnation', async () => { + const sessions = [ + makeSession('reused', { protocolVersion: 33, incarnationId: 'incarnation-old' }) + ] + const current = makeAdapter(33, []) + current.listSessions = vi.fn(async () => + daemonSessionRows(sessions, current.mutationRouteToken) + ) + current.killExact = vi.fn(async (_id: string, incarnationId: string) => { + if (incarnationId !== 'incarnation-old') { + return false + } + sessions.splice( + 0, + 1, + makeSession('reused', { + protocolVersion: 33, + incarnationId: 'incarnation-new' + }) + ) + return true + }) + const { registerDaemonManagementHandlers } = await importFresh() + getDaemonProviderMock.mockReturnValue(await makeRouter(current)) + registerDaemonManagementHandlers() + + const result = await runKillAllWithPolls(buildHandlerMap()['pty:management:killAll']) + + expect(result).toEqual({ + killedCount: 1, + remainingCount: 0, + killedSessionIds: ['reused'] + }) + expect(current.killExact).toHaveBeenCalledOnce() + expect(current.killExact).toHaveBeenCalledWith('reused', 'incarnation-old', { + immediate: true + }) + }) + it('swallows per-session shutdown rejections without stopping the batch', async () => { const sessionsList = [makeSession('a'), makeSession('b')] const current = makeAdapter(5, []) current.listSessions = vi.fn(async () => - sessionsList.map(({ protocolVersion: _pv, ...rest }) => rest) + daemonSessionRows(sessionsList, current.mutationRouteToken) ) // Why: a rejecting shutdown for 'a' must not block the shutdown of 'b'. // Since shutdowns fire in parallel (Promise.allSettled), both must be @@ -391,7 +462,7 @@ describe('pty:management IPC handlers', () => { sessionsList.splice(idx, 1) } } - current.shutdown = vi.fn(async (id: string) => { + current.shutdown.mockImplementation(async (id: string) => { if (id === 'a') { throw new Error('a is stuck') } @@ -413,6 +484,45 @@ describe('pty:management IPC handlers', () => { killedSessionIds: ['b'] }) }) + + it('does not mutate or report success when an adapter inventory rejects', async () => { + const current = makeAdapter(5, [makeSession('visible')]) + const legacy = makeAdapter(3, [makeSession('unknown', { protocolVersion: 3 })]) + legacy.listSessions.mockRejectedValue(new Error('legacy daemon unavailable')) + const { registerDaemonManagementHandlers } = await importFresh() + getDaemonProviderMock.mockReturnValue(await makeRouter(current, [legacy])) + registerDaemonManagementHandlers() + + await expect(buildHandlerMap()['pty:management:killAll']({})).rejects.toThrow( + 'pty_management_inventory_unavailable:3' + ) + expect(current.killExact).not.toHaveBeenCalled() + expect(legacy.killExact).not.toHaveBeenCalled() + expect(current.shutdown).not.toHaveBeenCalled() + expect(legacy.shutdown).not.toHaveBeenCalled() + }) + + it('does not turn a poll-time adapter failure into killed sessions', async () => { + const currentSessions = [makeSession('pending')] + const current = makeAdapter(5, []) + current.listSessions + .mockImplementationOnce(async () => + daemonSessionRows(currentSessions, current.mutationRouteToken) + ) + .mockRejectedValueOnce(new Error('daemon disconnected before exit outcome')) + current.killExact.mockResolvedValue(true) + const { registerDaemonManagementHandlers } = await importFresh() + getDaemonProviderMock.mockReturnValue(await makeRouter(current)) + registerDaemonManagementHandlers() + + const result = buildHandlerMap()['pty:management:killAll']({}) as Promise + const rejected = expect(result).rejects.toThrow('pty_management_inventory_unavailable:5') + await Promise.resolve() + await Promise.resolve() + await vi.advanceTimersByTimeAsync(100) + await rejected + expect(current.killExact).toHaveBeenCalledOnce() + }) }) describe('killOne', () => { @@ -433,6 +543,134 @@ describe('pty:management IPC handlers', () => { expect(current.shutdown).not.toHaveBeenCalled() }) + it('rejects a same-ID successor that replaces the listed incarnation before kill', async () => { + const current = makeAdapter(33, []) + let currentIncarnation = 'incarnation-old' + current.listSessions = vi.fn(async () => { + const listedIncarnation = currentIncarnation + currentIncarnation = 'incarnation-new' + return daemonSessionRows( + [ + makeSession('reused', { + protocolVersion: 33, + incarnationId: listedIncarnation + }) + ], + current.mutationRouteToken + ) + }) + current.killExact = vi.fn( + async (_id: string, incarnationId: string) => incarnationId === currentIncarnation + ) + const { registerDaemonManagementHandlers } = await importFresh() + getDaemonProviderMock.mockReturnValue(await makeRouter(current)) + registerDaemonManagementHandlers() + + const result = (await buildHandlerMap()['pty:management:killOne']( + {}, + { sessionId: 'reused' } + )) as { success: boolean } + + expect(result.success).toBe(false) + expect(current.killExact).toHaveBeenCalledWith('reused', 'incarnation-old', { + immediate: true + }) + expect(current.shutdown).not.toHaveBeenCalled() + }) + + it('rejects a session after its adapter route is replaced', async () => { + const current = makeAdapter(33, []) + current.listSessions = vi.fn(async () => { + const listedRouteToken = current.mutationRouteToken + current.mutationRouteToken = Object.freeze({}) + return daemonSessionRows( + [makeSession('reused', { protocolVersion: 33, incarnationId: 'incarnation-old' })], + listedRouteToken + ) + }) + const { registerDaemonManagementHandlers } = await importFresh() + getDaemonProviderMock.mockReturnValue(await makeRouter(current)) + registerDaemonManagementHandlers() + + const result = (await buildHandlerMap()['pty:management:killOne']( + {}, + { sessionId: 'reused' } + )) as { success: boolean } + + expect(result.success).toBe(false) + expect(current.killExact).not.toHaveBeenCalled() + expect(current.shutdown).not.toHaveBeenCalled() + }) + + it('rejects killOne when another adapter inventory is unknown', async () => { + const current = makeAdapter(5, [makeSession('visible')]) + const legacy = makeAdapter(3, []) + legacy.listSessions.mockRejectedValue(new Error('legacy daemon unavailable')) + const { registerDaemonManagementHandlers } = await importFresh() + getDaemonProviderMock.mockReturnValue(await makeRouter(current, [legacy])) + registerDaemonManagementHandlers() + + const result = (await buildHandlerMap()['pty:management:killOne']( + {}, + { sessionId: 'visible' } + )) as { success: boolean } + + expect(result.success).toBe(false) + expect(current.killExact).not.toHaveBeenCalled() + expect(current.shutdown).not.toHaveBeenCalled() + }) + + it('uses full authority access and never falls back to incarnation-only kill', async () => { + const authorityAccess: TerminalSessionAuthorityPtyAccess = { + namespace: { authorityHostId: 'host-1', namespaceId: 'namespace-1' }, + pane: { paneKey: 'pane-1', paneGenerationId: 'renderer:1' }, + binding: { + ownerIncarnationId: 'owner-1', + physicalPtyId: 'authority-1', + ptyIncarnationId: 'authority-incarnation' + } + } + const session = { + ...makeSession('authority-1', { + protocolVersion: 34, + incarnationId: 'authority-incarnation' + }), + terminalSessionAuthorityAccess: authorityAccess + } + const current = makeAdapter(34, [session]) + const { registerDaemonManagementHandlers } = await importFresh() + getDaemonProviderMock.mockReturnValue(await makeRouter(current)) + registerDaemonManagementHandlers() + + const result = (await buildHandlerMap()['pty:management:killOne']( + {}, + { sessionId: 'authority-1' } + )) as { success: boolean } + + expect(result.success).toBe(true) + expect(current.killAuthorityExact).toHaveBeenCalledWith('authority-1', authorityAccess, { + immediate: true + }) + expect(current.killExact).not.toHaveBeenCalled() + }) + + it('treats identity-free inventory as non-destructive', async () => { + const current = makeAdapter(32, [makeSession('legacy-1', { incarnationId: undefined })]) + const { registerDaemonManagementHandlers } = await importFresh() + getDaemonProviderMock.mockReturnValue(await makeRouter(current)) + registerDaemonManagementHandlers() + + const result = (await buildHandlerMap()['pty:management:killOne']( + {}, + { sessionId: 'legacy-1' } + )) as { success: boolean } + + expect(result.success).toBe(false) + expect(current.killExact).not.toHaveBeenCalled() + expect(current.killAuthorityExact).not.toHaveBeenCalled() + expect(current.shutdown).not.toHaveBeenCalled() + }) + it('returns success=false for unknown sessionId', async () => { const current = makeAdapter(5, [makeSession('new-1')]) const { registerDaemonManagementHandlers } = await importFresh() diff --git a/src/main/ipc/pty-management.ts b/src/main/ipc/pty-management.ts index 16e6cfb41ac..4913507faaa 100644 --- a/src/main/ipc/pty-management.ts +++ b/src/main/ipc/pty-management.ts @@ -9,6 +9,12 @@ import { } from '../daemon/daemon-init' import type { MacDaemonTccAttributionHealth } from '../daemon/daemon-health' import type { DaemonSessionInfo } from '../daemon/types' +import { + killListedPty, + listedPtyIdentityKey, + type PtyListedKillTarget +} from '../providers/pty-listed-session-kill' +import type { TerminalSessionAuthorityPtyAccess } from '../../shared/terminal-session-authority-pty-access' // Why: poll past the daemon's 5s SIGTERM→SIGKILL ladder (KILL_TIMEOUT_MS in session.ts), else slow-exiting shells falsely look "refused". const MAX_POLL_ATTEMPTS = 65 @@ -38,7 +44,14 @@ function isDaemonDegraded(): boolean { ) } -async function collectSessions(adapters: DaemonPtyAdapter[]): Promise { +type DaemonSessionInventory = Readonly<{ + sessions: DaemonSessionInfo[] + failedProtocolVersions: number[] +}> + +async function collectSessionInventory( + adapters: DaemonPtyAdapter[] +): Promise { const results = await Promise.allSettled( adapters.map(async (adapter) => { const sessions = await adapter.listSessions() @@ -48,7 +61,56 @@ async function collectSessions(adapters: DaemonPtyAdapter[]): Promise (r.status === 'fulfilled' ? r.value : [])) + return { + sessions: results.flatMap((result) => (result.status === 'fulfilled' ? result.value : [])), + failedProtocolVersions: results.flatMap((result, index) => + result.status === 'rejected' ? [adapters[index].protocolVersion] : [] + ) + } +} + +async function collectSessions(adapters: DaemonPtyAdapter[]): Promise { + return (await collectSessionInventory(adapters)).sessions +} + +async function collectSessionsExact(adapters: DaemonPtyAdapter[]): Promise { + const inventory = await collectSessionInventory(adapters) + if (inventory.failedProtocolVersions.length > 0) { + throw new Error( + `pty_management_inventory_unavailable:${inventory.failedProtocolVersions.join(',')}` + ) + } + return inventory.sessions +} + +type ExactDaemonSessionInfo = DaemonSessionInfo & { + terminalSessionAuthorityAccess?: TerminalSessionAuthorityPtyAccess + mutationRouteToken?: object +} + +function daemonSessionTarget(session: ExactDaemonSessionInfo): PtyListedKillTarget { + return { + id: session.sessionId, + ...(session.incarnationId ? { incarnationId: session.incarnationId } : {}), + ...(session.terminalSessionAuthorityAccess + ? { terminalSessionAuthorityAccess: session.terminalSessionAuthorityAccess } + : {}), + ...(session.mutationRouteToken ? { mutationRouteToken: session.mutationRouteToken } : {}) + } +} + +function daemonSessionIdentityKey(session: ExactDaemonSessionInfo): string { + return JSON.stringify([ + session.protocolVersion, + listedPtyIdentityKey(daemonSessionTarget(session)) ?? ['unavailable', session.sessionId] + ]) +} + +async function killDaemonSession( + adapter: DaemonPtyAdapter, + session: ExactDaemonSessionInfo +): Promise { + return await killListedPty(adapter, daemonSessionTarget(session), { immediate: true }) } export function registerDaemonManagementHandlers(): void { @@ -88,9 +150,12 @@ export function registerDaemonManagementHandlers(): void { }> => { const adapters = getDaemonAdapters() // Why: snapshot session IDs up front so mid-kill respawns aren't counted as "remaining". - const initial = await collectSessions(adapters) - const initialIds = new Set(initial.map((s) => s.sessionId)) - const initialCount = initial.length + const initial = await collectSessionsExact(adapters) + const initialByIdentity = new Map( + initial.map((session) => [daemonSessionIdentityKey(session), session] as const) + ) + const initialIdentities = new Set(initialByIdentity.keys()) + const initialCount = initialByIdentity.size if (initialCount === 0) { return { killedCount: 0, remainingCount: 0, killedSessionIds: [] } @@ -98,29 +163,28 @@ export function registerDaemonManagementHandlers(): void { // Why: no retry — session.kill() is idempotent and runs its own kill ladder; allSettled so one rejection doesn't abort the rest. await Promise.allSettled( - initial.map(async (session) => { + [...initialByIdentity.values()].map(async (session) => { // Why: assumes PROTOCOL_VERSION stays distinct from PREVIOUS_DAEMON_PROTOCOL_VERSIONS (types.ts), else legacy sessions misroute here. const owner = adapters.find((a) => a.protocolVersion === session.protocolVersion) if (!owner) { return } - // Why: immediate=true only matters to legacy/future adapters; swallow rejections since remainingCount reports stuck sessions. - await owner.shutdown(session.sessionId, { immediate: true }).catch(() => {}) + await killDaemonSession(owner, session).catch(() => false) }) ) // Why: count only the initial-snapshot intersection so renderer respawns mid-kill aren't counted as remaining. let remainingOriginalCount = initialCount - let remainingOriginalIds = initialIds + let remainingOriginalIdentities = initialIdentities for (let attempt = 0; attempt < MAX_POLL_ATTEMPTS; attempt += 1) { await sleep(POLL_INTERVAL_MS) - const current = await collectSessions(adapters) - remainingOriginalIds = new Set( + const current = await collectSessionsExact(adapters) + remainingOriginalIdentities = new Set( current - .filter((session) => initialIds.has(session.sessionId)) - .map((session) => session.sessionId) + .map(daemonSessionIdentityKey) + .filter((identity) => initialIdentities.has(identity)) ) - remainingOriginalCount = remainingOriginalIds.size + remainingOriginalCount = remainingOriginalIdentities.size if (remainingOriginalCount === 0) { break } @@ -130,9 +194,9 @@ export function registerDaemonManagementHandlers(): void { return { killedCount, remainingCount: remainingOriginalCount, - killedSessionIds: [...initialIds].filter( - (sessionId) => !remainingOriginalIds.has(sessionId) - ) + killedSessionIds: [...initialByIdentity] + .filter(([identity]) => !remainingOriginalIdentities.has(identity)) + .map(([, session]) => session.sessionId) } } ) @@ -144,18 +208,23 @@ export function registerDaemonManagementHandlers(): void { return { success: false } } const adapters = getDaemonAdapters() - const sessions = await collectSessions(adapters) - const match = sessions.find((s) => s.sessionId === args.sessionId) - if (!match) { + let sessions: DaemonSessionInfo[] + try { + sessions = await collectSessionsExact(adapters) + } catch { return { success: false } } + const matches = sessions.filter((session) => session.sessionId === args.sessionId) + if (matches.length !== 1) { + return { success: false } + } + const match = matches[0] const owner = adapters.find((a) => a.protocolVersion === match.protocolVersion) if (!owner) { return { success: false } } try { - await owner.shutdown(args.sessionId, { immediate: true }) - return { success: true } + return { success: await killDaemonSession(owner, match) } } catch { return { success: false } } diff --git a/src/main/ipc/pty-renderer-binding-fence.test.ts b/src/main/ipc/pty-renderer-binding-fence.test.ts new file mode 100644 index 00000000000..dbbe15bb966 --- /dev/null +++ b/src/main/ipc/pty-renderer-binding-fence.test.ts @@ -0,0 +1,215 @@ +import { describe, expect, it } from 'vitest' +import type { PtyIncarnationId } from '../../shared/pty-incarnation' +import type { ExactPtyMutationAccess } from '../../shared/pty-renderer-binding' +import type { TerminalSideEffectBatch } from '../../shared/terminal-side-effect-facts' +import { PtyRendererBindingFenceRegistry } from './pty-renderer-binding-fence' + +const incarnationId = '11111111-1111-4111-8111-111111111111' as PtyIncarnationId + +function access( + paneGeneration: number, + sequence: number, + mutationLeaseId: string +): ExactPtyMutationAccess { + return { + mode: 'exact', + identity: { incarnationId, paneGeneration, mutationLeaseId }, + claimant: { rendererEpochId: 'renderer-1', sequence } + } +} + +function batch(seq: number, ptyIncarnationId?: string): TerminalSideEffectBatch { + return { + ptyId: 'pty-1', + ...(ptyIncarnationId ? { ptyIncarnationId: ptyIncarnationId as PtyIncarnationId } : {}), + seq, + facts: [{ kind: 'bell' }] + } +} + +describe('PtyRendererBindingFenceRegistry', () => { + it('lets a newer same-generation claimant supersede an overlapping reconnect', () => { + const registry = new PtyRendererBindingFenceRegistry() + const stale = registry.begin({ + id: 'pty-1', + paneGeneration: 8, + claimant: { rendererEpochId: 'renderer-1', sequence: 1 } + })! + const current = registry.begin({ + id: 'pty-1', + paneGeneration: 8, + claimant: { rendererEpochId: 'renderer-1', sequence: 2 } + })! + + expect(registry.owns(stale)).toBe(false) + expect(registry.owns(current)).toBe(true) + expect(registry.finalize(stale, access(8, 1, 'lease-1'))).toBe(false) + expect(registry.finalize(current, access(8, 2, 'lease-2'))).toBe(true) + expect(registry.settleExactWithSideEffects('pty-1', access(8, 1, 'lease-1'))).toBeNull() + expect( + registry.cancelClaimWithSideEffects({ + id: 'pty-1', + paneGeneration: 8, + claimant: { rendererEpochId: 'renderer-1', sequence: 2 } + }) + ).toBeNull() + const release = registry.settleExactWithSideEffects('pty-1', access(8, 2, 'lease-2'))! + expect(release).toEqual({ id: 'pty-1', ordinal: 2 }) + expect(registry.isBlocked('pty-1')).toBe(true) + expect(registry.drainSideEffectRelease(release, () => true)).toEqual({ + id: 'pty-1', + unresolvedBatchCount: 0 + }) + expect(registry.isBlocked('pty-1')).toBe(false) + expect( + registry.begin({ + id: 'pty-1', + paneGeneration: 8, + claimant: { rendererEpochId: 'renderer-1', sequence: 1 } + }) + ).toBeNull() + }) + + it('keeps a newer pane generation fenced against later stale begin and cancel messages', () => { + const registry = new PtyRendererBindingFenceRegistry() + const current = registry.begin({ + id: 'pty-1', + paneGeneration: 9, + claimant: { rendererEpochId: 'renderer-1', sequence: 3 } + })! + + expect( + registry.begin({ + id: 'pty-1', + paneGeneration: 8, + claimant: { rendererEpochId: 'renderer-1', sequence: 99 } + }) + ).toBeNull() + expect( + registry.cancelClaimWithSideEffects({ + id: 'pty-1', + paneGeneration: 8, + claimant: { rendererEpochId: 'renderer-1', sequence: 99 } + }) + ).toBeNull() + expect(registry.owns(current)).toBe(true) + expect(registry.isBlocked('pty-1')).toBe(true) + }) + + it('requires the full incarnation, pane generation, lease, and claimant to settle', () => { + const registry = new PtyRendererBindingFenceRegistry() + const token = registry.begin({ + id: 'pty-1', + paneGeneration: 4, + claimant: { rendererEpochId: 'renderer-1', sequence: 7 } + })! + const exact = access(4, 7, 'lease-current') + + expect(registry.finalize(token, exact)).toBe(true) + expect(registry.settleExactWithSideEffects('pty-1', access(4, 7, 'lease-stale'))).toBeNull() + expect( + registry.settleExactWithSideEffects('pty-1', { + ...exact, + identity: { + ...exact.identity, + incarnationId: '22222222-2222-4222-8222-222222222222' as PtyIncarnationId + } + }) + ).toBeNull() + expect(registry.isBlocked('pty-1')).toBe(true) + }) + + it('carries ordered batches into a superseding claim until exact settlement', () => { + const registry = new PtyRendererBindingFenceRegistry() + const stale = registry.begin({ + id: 'pty-1', + paneGeneration: 4, + claimant: { rendererEpochId: 'renderer-1', sequence: 1 } + })! + expect(registry.retainSideEffect(batch(1, 'incarnation-old'))).toBe('retained') + const current = registry.begin({ + id: 'pty-1', + paneGeneration: 5, + claimant: { rendererEpochId: 'renderer-1', sequence: 2 } + })! + expect(registry.retainSideEffect(batch(2, incarnationId))).toBe('retained') + + expect(registry.cancelTokenWithSideEffects(stale)).toBeNull() + expect(registry.finalize(current, access(5, 2, 'lease-current'))).toBe(true) + const release = registry.settleExactWithSideEffects('pty-1', access(5, 2, 'lease-current'))! + const drained: TerminalSideEffectBatch[] = [] + expect( + registry.drainSideEffectRelease(release, (candidate) => { + drained.push(candidate) + return true + }) + ).toEqual({ + id: 'pty-1', + unresolvedBatchCount: 0 + }) + expect(drained).toEqual([batch(1, 'incarnation-old'), batch(2, incarnationId)]) + }) + + it('bounds retention and requests backpressure before classifying overflow', () => { + const registry = new PtyRendererBindingFenceRegistry({ + maxSideEffectBatches: 2, + maxSideEffectChars: 10_000, + sideEffectBackpressureBatches: 1, + sideEffectBackpressureChars: 10_000 + }) + const token = registry.begin({ + id: 'pty-1', + paneGeneration: 4, + claimant: { rendererEpochId: 'renderer-1', sequence: 1 } + })! + + expect(registry.retainSideEffect(batch(1, incarnationId))).toBe('backpressure') + expect(registry.needsSideEffectBackpressure('pty-1')).toBe(true) + expect(registry.retainSideEffect(batch(2, incarnationId))).toBe('backpressure') + expect(registry.retainSideEffect(batch(3, incarnationId))).toBe('overflow') + const release = registry.cancelTokenWithSideEffects(token)! + const drained: TerminalSideEffectBatch[] = [] + expect( + registry.drainSideEffectRelease(release, (candidate) => { + drained.push(candidate) + return true + }) + ).toEqual({ + id: 'pty-1', + unresolvedBatchCount: 1 + }) + expect(drained).toEqual([batch(1, incarnationId), batch(2, incarnationId)]) + expect(registry.needsSideEffectBackpressure('pty-1')).toBe(false) + }) + + it('commits only a successfully delivered prefix and retains the failed suffix', () => { + const registry = new PtyRendererBindingFenceRegistry() + const token = registry.begin({ + id: 'pty-1', + paneGeneration: 4, + claimant: { rendererEpochId: 'renderer-1', sequence: 1 } + })! + registry.retainSideEffect(batch(1, incarnationId)) + registry.retainSideEffect(batch(2, incarnationId)) + const release = registry.cancelTokenWithSideEffects(token)! + + expect(() => + registry.drainSideEffectRelease(release, (candidate) => { + if (candidate.seq === 2) { + throw new Error('renderer unavailable') + } + return true + }) + ).toThrow('renderer unavailable') + expect(registry.isBlocked('pty-1')).toBe(true) + + const retried: TerminalSideEffectBatch[] = [] + expect( + registry.drainSideEffectRelease(release, (candidate) => { + retried.push(candidate) + return true + }) + ).toEqual({ id: 'pty-1', unresolvedBatchCount: 0 }) + expect(retried).toEqual([batch(2, incarnationId)]) + }) +}) diff --git a/src/main/ipc/pty-renderer-binding-fence.ts b/src/main/ipc/pty-renderer-binding-fence.ts new file mode 100644 index 00000000000..b30ce087853 --- /dev/null +++ b/src/main/ipc/pty-renderer-binding-fence.ts @@ -0,0 +1,299 @@ +import type { + ExactPtyMutationAccess, + PtyRendererBindingClaim +} from '../../shared/pty-renderer-binding' +import { + comparePtyRendererBindingClaims, + exactPtyMutationAccessesEqual, + ptyMutationClaimantsEqual +} from '../../shared/pty-renderer-binding' +import type { TerminalSideEffectBatch } from '../../shared/terminal-side-effect-facts' + +export const PTY_RENDERER_BINDING_SIDE_EFFECT_MAX_BATCHES = 4_096 +export const PTY_RENDERER_BINDING_SIDE_EFFECT_MAX_CHARS = 16 * 1024 * 1024 +export const PTY_RENDERER_BINDING_SIDE_EFFECT_BACKPRESSURE_BATCHES = 1_024 +export const PTY_RENDERER_BINDING_SIDE_EFFECT_BACKPRESSURE_CHARS = 512 * 1024 + +export type PtyRendererBindingFenceToken = Readonly< + PtyRendererBindingClaim & { + incarnationId?: string + ordinal: number + } +> + +type PtyRendererBindingFence = { + token: PtyRendererBindingFenceToken + access?: ExactPtyMutationAccess + sideEffects: TerminalSideEffectBatch[] + sideEffectChars: number + unresolvedSideEffectBatches: number + releasePrepared: boolean +} + +export type PtyRendererBindingSideEffectRelease = Readonly<{ + id: string + ordinal: number +}> + +export type PtyRendererBindingSideEffectDrainResult = Readonly<{ + id: string + unresolvedBatchCount: number +}> + +export type PtyRendererBindingSideEffectRetention = Readonly<{ + id: string + batches: readonly TerminalSideEffectBatch[] + unresolvedBatchCount: number +}> + +export type PtyRendererBindingSideEffectRetainResult = + | 'unfenced' + | 'retained' + | 'backpressure' + | 'overflow' + +type PtyRendererBindingFenceOptions = { + maxSideEffectBatches?: number + maxSideEffectChars?: number + sideEffectBackpressureBatches?: number + sideEffectBackpressureChars?: number +} + +function serializedBatchChars(batch: TerminalSideEffectBatch): number { + try { + return JSON.stringify(batch).length + } catch { + return Number.POSITIVE_INFINITY + } +} + +export class PtyRendererBindingFenceRegistry { + private readonly fences = new Map() + private readonly latestClaims = new Map() + private readonly maxSideEffectBatches: number + private readonly maxSideEffectChars: number + private readonly sideEffectBackpressureBatches: number + private readonly sideEffectBackpressureChars: number + private nextOrdinal = 0 + + constructor(options: PtyRendererBindingFenceOptions = {}) { + this.maxSideEffectBatches = + options.maxSideEffectBatches ?? PTY_RENDERER_BINDING_SIDE_EFFECT_MAX_BATCHES + this.maxSideEffectChars = + options.maxSideEffectChars ?? PTY_RENDERER_BINDING_SIDE_EFFECT_MAX_CHARS + this.sideEffectBackpressureBatches = + options.sideEffectBackpressureBatches ?? PTY_RENDERER_BINDING_SIDE_EFFECT_BACKPRESSURE_BATCHES + this.sideEffectBackpressureChars = + options.sideEffectBackpressureChars ?? PTY_RENDERER_BINDING_SIDE_EFFECT_BACKPRESSURE_CHARS + } + + begin( + claim: PtyRendererBindingClaim & { incarnationId?: string } + ): PtyRendererBindingFenceToken | null { + const current = this.fences.get(claim.id) + const latest = current?.token ?? this.latestClaims.get(claim.id) + if (latest) { + const comparison = comparePtyRendererBindingClaims(claim, latest) + if (comparison < 0) { + return null + } + if (comparison === 0 && current) { + return current.token + } + } + this.nextOrdinal += 1 + const token = Object.freeze({ ...claim, ordinal: this.nextOrdinal }) + this.fences.set(claim.id, { + token, + sideEffects: current?.sideEffects ?? [], + sideEffectChars: current?.sideEffectChars ?? 0, + unresolvedSideEffectBatches: current?.unresolvedSideEffectBatches ?? 0, + releasePrepared: false + }) + this.latestClaims.set(claim.id, token) + return token + } + + owns(token: PtyRendererBindingFenceToken): boolean { + return this.fences.get(token.id)?.token === token + } + + finalize(token: PtyRendererBindingFenceToken, access: ExactPtyMutationAccess): boolean { + const current = this.fences.get(token.id) + if ( + current?.token !== token || + access.identity.paneGeneration !== token.paneGeneration || + !ptyMutationClaimantsEqual(access.claimant, token.claimant) + ) { + return false + } + current.access = access + return true + } + + retainSideEffect(batch: TerminalSideEffectBatch): PtyRendererBindingSideEffectRetainResult { + const current = this.fences.get(batch.ptyId) + if (!current) { + return 'unfenced' + } + const batchChars = serializedBatchChars(batch) + if ( + current.sideEffects.length >= this.maxSideEffectBatches || + batchChars > this.maxSideEffectChars - current.sideEffectChars + ) { + current.unresolvedSideEffectBatches += 1 + return 'overflow' + } + current.sideEffects.push(batch) + current.sideEffectChars += batchChars + return this.needsSideEffectBackpressure(batch.ptyId) ? 'backpressure' : 'retained' + } + + needsSideEffectBackpressure(id: string): boolean { + const current = this.fences.get(id) + return Boolean( + current && + (current.releasePrepared || + current.sideEffects.length >= this.sideEffectBackpressureBatches || + current.sideEffectChars >= this.sideEffectBackpressureChars || + current.unresolvedSideEffectBatches > 0) + ) + } + + settleExactWithSideEffects( + id: string, + access: ExactPtyMutationAccess + ): PtyRendererBindingSideEffectRelease | null { + const current = this.fences.get(id) + if (!current?.access || !exactPtyMutationAccessesEqual(current.access, access)) { + return null + } + return this.prepareRelease(current) + } + + cancelExactWithSideEffects( + id: string, + access: ExactPtyMutationAccess + ): PtyRendererBindingSideEffectRelease | null { + const current = this.fences.get(id) + if (!current?.access || !exactPtyMutationAccessesEqual(current.access, access)) { + return null + } + return this.prepareRelease(current) + } + + cancelClaimWithSideEffects( + claim: PtyRendererBindingClaim + ): PtyRendererBindingSideEffectRelease | null { + const current = this.fences.get(claim.id) + if ( + current?.access || + !current || + current.token.paneGeneration !== claim.paneGeneration || + !ptyMutationClaimantsEqual(current.token.claimant, claim.claimant) + ) { + return null + } + return this.prepareRelease(current) + } + + cancelTokenWithSideEffects( + token: PtyRendererBindingFenceToken + ): PtyRendererBindingSideEffectRelease | null { + const current = this.fences.get(token.id) + if (current?.token !== token) { + return null + } + return this.prepareRelease(current) + } + + drainSideEffectRelease( + release: PtyRendererBindingSideEffectRelease, + visit: (batch: TerminalSideEffectBatch) => boolean + ): PtyRendererBindingSideEffectDrainResult | null { + const current = this.fences.get(release.id) + if ( + !current?.releasePrepared || + current.token.ordinal !== release.ordinal || + current.token.id !== release.id + ) { + return null + } + let processed = 0 + try { + while (processed < current.sideEffects.length) { + if (!visit(current.sideEffects[processed])) { + current.unresolvedSideEffectBatches += 1 + } + processed += 1 + } + } catch (error) { + this.removeProcessedSideEffects(current, processed) + throw error + } + this.fences.delete(release.id) + return { + id: release.id, + unresolvedBatchCount: current.unresolvedSideEffectBatches + } + } + + abandonSideEffectRelease( + release: PtyRendererBindingSideEffectRelease + ): PtyRendererBindingSideEffectRetention | null { + const current = this.fences.get(release.id) + if ( + !current?.releasePrepared || + current.token.ordinal !== release.ordinal || + current.token.id !== release.id + ) { + return null + } + this.fences.delete(release.id) + return this.takeSideEffects(current) + } + + isBlocked(id: string): boolean { + return this.fences.has(id) + } + + clear(id: string): PtyRendererBindingSideEffectRetention | null { + this.latestClaims.delete(id) + const current = this.fences.get(id) + if (!current) { + return null + } + this.fences.delete(id) + return this.takeSideEffects(current) + } + + clearAll(): PtyRendererBindingSideEffectRetention[] { + const retained = Array.from(this.fences.values(), (fence) => this.takeSideEffects(fence)) + this.fences.clear() + this.latestClaims.clear() + return retained + } + + private takeSideEffects(fence: PtyRendererBindingFence): PtyRendererBindingSideEffectRetention { + return { + id: fence.token.id, + batches: fence.sideEffects, + unresolvedBatchCount: fence.unresolvedSideEffectBatches + } + } + + private prepareRelease(fence: PtyRendererBindingFence): PtyRendererBindingSideEffectRelease { + fence.releasePrepared = true + return { id: fence.token.id, ordinal: fence.token.ordinal } + } + + private removeProcessedSideEffects(fence: PtyRendererBindingFence, count: number): void { + if (count === 0) { + return + } + const processed = fence.sideEffects.splice(0, count) + for (const batch of processed) { + fence.sideEffectChars = Math.max(0, fence.sideEffectChars - serializedBatchChars(batch)) + } + } +} diff --git a/src/main/ipc/pty-renderer-binding-held-pause.test.ts b/src/main/ipc/pty-renderer-binding-held-pause.test.ts new file mode 100644 index 00000000000..c198c3800e9 --- /dev/null +++ b/src/main/ipc/pty-renderer-binding-held-pause.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, vi } from 'vitest' +import type { PtyIncarnationId } from '../../shared/pty-incarnation' +import { PtyRendererBindingHeldPauseController } from './pty-renderer-binding-held-pause' + +const incarnationId = '11111111-1111-4111-8111-111111111111' as PtyIncarnationId + +function provider() { + return { + supportsExactHeldProducerPause: vi.fn(() => true), + acquireExactHeldProducerPause: vi.fn(async () => true), + releaseExactHeldProducerPause: vi.fn(async () => true) + } +} + +describe('PtyRendererBindingHeldPauseController', () => { + it('uses monotonic provider-scoped tokens and releases committed leases', async () => { + const capable = provider() + const controller = new PtyRendererBindingHeldPauseController({ onReleaseFailure: vi.fn() }) + const first = await controller.acquire(capable as never, 'pty-1', incarnationId) + const second = await controller.acquire(capable as never, 'pty-2', incarnationId) + + expect(first?.token).toBe('renderer-binding-1') + expect(second?.token).toBe('renderer-binding-2') + controller.commit(first!) + await expect(controller.release('pty-1')).resolves.toBe(true) + expect(capable.releaseExactHeldProducerPause).toHaveBeenCalledWith( + 'pty-1', + incarnationId, + 'renderer-binding-1' + ) + }) + + it('keeps a failed predecessor lease for the final retry', async () => { + const capable = provider() + capable.releaseExactHeldProducerPause.mockResolvedValueOnce(false).mockResolvedValue(true) + const onReleaseFailure = vi.fn() + const controller = new PtyRendererBindingHeldPauseController({ onReleaseFailure }) + const first = (await controller.acquire(capable as never, 'pty-1', incarnationId))! + const second = (await controller.acquire(capable as never, 'pty-1', incarnationId))! + controller.commit(first) + controller.commit(second) + await vi.waitFor(() => expect(onReleaseFailure).toHaveBeenCalledOnce()) + + await expect(controller.release('pty-1')).resolves.toBe(true) + expect(capable.releaseExactHeldProducerPause).toHaveBeenCalledTimes(3) + }) +}) diff --git a/src/main/ipc/pty-renderer-binding-held-pause.ts b/src/main/ipc/pty-renderer-binding-held-pause.ts new file mode 100644 index 00000000000..3a11f892d73 --- /dev/null +++ b/src/main/ipc/pty-renderer-binding-held-pause.ts @@ -0,0 +1,147 @@ +import type { PtyIncarnationId } from '../../shared/pty-incarnation' +import type { IPtyProvider } from '../providers/types' + +const MAX_RENDERER_BINDING_HELD_PAUSES = 4_096 + +type ExactHeldPauseProvider = IPtyProvider & { + supportsExactHeldProducerPause: (id: string, incarnationId: PtyIncarnationId) => boolean + acquireExactHeldProducerPause: ( + id: string, + incarnationId: PtyIncarnationId, + token: string + ) => Promise + releaseExactHeldProducerPause: ( + id: string, + incarnationId: PtyIncarnationId, + token: string + ) => Promise +} + +export type PtyRendererBindingHeldPauseLease = Readonly<{ + id: string + incarnationId: PtyIncarnationId + token: string + provider: ExactHeldPauseProvider +}> + +type PtyRendererBindingHeldPauseDeps = { + onReleaseFailure: (lease: PtyRendererBindingHeldPauseLease, error?: unknown) => void +} + +function exactHeldPauseProvider( + provider: IPtyProvider, + id: string, + incarnationId: PtyIncarnationId +): ExactHeldPauseProvider | null { + const candidate = provider as Partial + return typeof candidate.supportsExactHeldProducerPause === 'function' && + typeof candidate.acquireExactHeldProducerPause === 'function' && + typeof candidate.releaseExactHeldProducerPause === 'function' && + candidate.supportsExactHeldProducerPause(id, incarnationId) + ? (candidate as ExactHeldPauseProvider) + : null +} + +export class PtyRendererBindingHeldPauseController { + private readonly leasesById = new Map>() + private readonly nextSerialByProvider = new WeakMap() + private leaseCount = 0 + + constructor(private readonly deps: PtyRendererBindingHeldPauseDeps) {} + + supports(provider: IPtyProvider, id: string, incarnationId: PtyIncarnationId): boolean { + return exactHeldPauseProvider(provider, id, incarnationId) !== null + } + + has(id: string): boolean { + return this.leasesById.has(id) + } + + async acquire( + provider: IPtyProvider, + id: string, + incarnationId: PtyIncarnationId + ): Promise { + const capable = exactHeldPauseProvider(provider, id, incarnationId) + if (!capable || this.leaseCount >= MAX_RENDERER_BINDING_HELD_PAUSES) { + return null + } + const serial = (this.nextSerialByProvider.get(provider) ?? 0) + 1 + this.nextSerialByProvider.set(provider, serial) + const lease = Object.freeze({ + id, + incarnationId, + token: `renderer-binding-${serial}`, + provider: capable + }) + return (await capable.acquireExactHeldProducerPause(id, incarnationId, lease.token)) + ? lease + : null + } + + commit(lease: PtyRendererBindingHeldPauseLease): void { + let leases = this.leasesById.get(lease.id) + if (!leases) { + leases = new Set() + this.leasesById.set(lease.id, leases) + } + leases.add(lease) + this.leaseCount += 1 + for (const previous of leases) { + if (previous !== lease) { + void this.releaseLease(previous) + } + } + } + + async discard(lease: PtyRendererBindingHeldPauseLease): Promise { + return await this.releaseProviderLease(lease) + } + + async release(id: string): Promise { + const leases = this.leasesById.get(id) + if (!leases) { + return true + } + const results = await Promise.all(Array.from(leases, (lease) => this.releaseLease(lease))) + return results.every(Boolean) + } + + releaseAll(): void { + for (const id of this.leasesById.keys()) { + void this.release(id) + } + } + + private async releaseLease(lease: PtyRendererBindingHeldPauseLease): Promise { + const released = await this.releaseProviderLease(lease) + if (!released) { + return false + } + const leases = this.leasesById.get(lease.id) + if (leases?.delete(lease)) { + this.leaseCount -= 1 + if (leases.size === 0) { + this.leasesById.delete(lease.id) + } + } + return true + } + + private async releaseProviderLease(lease: PtyRendererBindingHeldPauseLease): Promise { + try { + const released = await lease.provider.releaseExactHeldProducerPause( + lease.id, + lease.incarnationId, + lease.token + ) + if (!released) { + this.deps.onReleaseFailure(lease) + } + return released + } catch (error) { + this.deps.onReleaseFailure(lease, error) + return false + } + } +} diff --git a/src/main/ipc/pty-renderer-side-effect-delivery.test.ts b/src/main/ipc/pty-renderer-side-effect-delivery.test.ts new file mode 100644 index 00000000000..4d923ecc175 --- /dev/null +++ b/src/main/ipc/pty-renderer-side-effect-delivery.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, it, vi } from 'vitest' +import type { PtyIncarnationId } from '../../shared/pty-incarnation' +import type { ExactPtyMutationAccess } from '../../shared/pty-renderer-binding' +import type { TerminalSideEffectBatch } from '../../shared/terminal-side-effect-facts' +import { PtyRendererBindingFenceRegistry } from './pty-renderer-binding-fence' +import { + PtyRendererSideEffectDelivery, + type PtyRendererSideEffectBinding +} from './pty-renderer-side-effect-delivery' + +const currentIncarnation = '11111111-1111-4111-8111-111111111111' as PtyIncarnationId + +function access(): ExactPtyMutationAccess { + return { + mode: 'exact', + identity: { + incarnationId: currentIncarnation, + paneGeneration: 7, + mutationLeaseId: 'lease-current' + }, + claimant: { rendererEpochId: 'renderer-1', sequence: 3 } + } +} + +function batch(seq: number, incarnationId?: string): TerminalSideEffectBatch { + return { + ptyId: 'pty-1', + ...(incarnationId ? { ptyIncarnationId: incarnationId as PtyIncarnationId } : {}), + seq, + facts: [{ kind: 'bell' }] + } +} + +function setup( + fences = new PtyRendererBindingFenceRegistry(), + initialBinding: PtyRendererSideEffectBinding = { + mode: 'exact', + incarnationId: currentIncarnation + } +) { + let binding = initialBinding + const published: TerminalSideEffectBatch[] = [] + const onPressureChanged = vi.fn() + const onReleased = vi.fn() + const onReplayDeferred = vi.fn() + const onUnresolved = vi.fn() + let publishOverride: ((candidate: TerminalSideEffectBatch) => void) | null = null + const delivery = new PtyRendererSideEffectDelivery({ + fences, + resolveBinding: () => binding, + publish: (candidate) => { + if (publishOverride) { + publishOverride(candidate) + return + } + published.push(candidate) + }, + onPressureChanged, + onReleased, + onReplayDeferred, + onUnresolved + }) + return { + delivery, + published, + onPressureChanged, + onReleased, + onReplayDeferred, + onUnresolved, + setPublish: (next: (candidate: TerminalSideEffectBatch) => void) => { + publishOverride = next + }, + setBinding: (next: PtyRendererSideEffectBinding) => { + binding = next + } + } +} + +describe('PtyRendererSideEffectDelivery', () => { + it('publishes only the current exact incarnation when no handoff is fenced', () => { + const context = setup() + + context.delivery.publish(batch(1, 'incarnation-old')) + context.delivery.publish(batch(2)) + context.delivery.publish(batch(3, currentIncarnation)) + + expect(context.published).toEqual([batch(3, currentIncarnation)]) + expect(context.onUnresolved).toHaveBeenNthCalledWith(1, 'pty-1', 1) + expect(context.onUnresolved).toHaveBeenNthCalledWith(2, 'pty-1', 1) + }) + + it('downgrades identity only for an explicitly legacy binding', () => { + const context = setup(new PtyRendererBindingFenceRegistry(), { mode: 'legacy' }) + + context.delivery.publish(batch(1, currentIncarnation)) + context.delivery.publish(batch(2)) + + expect(context.published).toEqual([batch(1), batch(2)]) + expect(context.onUnresolved).not.toHaveBeenCalled() + }) + + it('retains pre-commit ordering and releases only the committed exact incarnation', () => { + const fences = new PtyRendererBindingFenceRegistry() + const context = setup(fences) + const exact = access() + const token = fences.begin({ + id: 'pty-1', + paneGeneration: 7, + claimant: exact.claimant + })! + + context.delivery.publish(batch(1, 'incarnation-old')) + context.delivery.publish(batch(2, currentIncarnation)) + context.delivery.publish(batch(3)) + context.delivery.publish(batch(4, currentIncarnation)) + expect(context.published).toEqual([]) + + expect(fences.finalize(token, exact)).toBe(true) + const retention = fences.settleExactWithSideEffects('pty-1', exact)! + context.delivery.releaseExact(retention, exact) + + expect(context.published).toEqual([batch(2, currentIncarnation), batch(4, currentIncarnation)]) + expect(context.onUnresolved).toHaveBeenCalledWith('pty-1', 2) + expect(context.onReleased).toHaveBeenCalledWith('pty-1') + }) + + it('replays still-current predecessor batches after pre-finalize cancellation', () => { + const fences = new PtyRendererBindingFenceRegistry() + const context = setup(fences, { mode: 'exact', incarnationId: 'incarnation-old' }) + const token = fences.begin({ + id: 'pty-1', + paneGeneration: 8, + claimant: { rendererEpochId: 'renderer-1', sequence: 4 }, + incarnationId: 'incarnation-old' + })! + context.delivery.publish(batch(1, 'incarnation-old')) + context.delivery.publish(batch(2, currentIncarnation)) + + const retention = fences.cancelTokenWithSideEffects(token)! + context.delivery.releaseCurrent(retention) + + expect(context.published).toEqual([batch(1, 'incarnation-old')]) + expect(context.onUnresolved).toHaveBeenCalledWith('pty-1', 1) + }) + + it('classifies bounded overflow instead of silently dropping it', () => { + const fences = new PtyRendererBindingFenceRegistry({ + maxSideEffectBatches: 1, + maxSideEffectChars: 10_000, + sideEffectBackpressureBatches: 1, + sideEffectBackpressureChars: 10_000 + }) + const context = setup(fences) + const token = fences.begin({ + id: 'pty-1', + paneGeneration: 7, + claimant: { rendererEpochId: 'renderer-1', sequence: 3 } + })! + context.delivery.publish(batch(1, currentIncarnation)) + context.delivery.publish(batch(2, currentIncarnation)) + + const release = fences.cancelTokenWithSideEffects(token)! + context.delivery.abandonRelease(release) + + expect(context.published).toEqual([]) + expect(context.onPressureChanged).toHaveBeenCalledWith('pty-1') + expect(context.onUnresolved).toHaveBeenCalledWith('pty-1', 2) + }) + + it('drains reentrant batches before notifying the producer release', () => { + const fences = new PtyRendererBindingFenceRegistry() + const context = setup(fences) + const token = fences.begin({ + id: 'pty-1', + paneGeneration: 7, + claimant: { rendererEpochId: 'renderer-1', sequence: 3 } + })! + context.delivery.publish(batch(1, currentIncarnation)) + context.setPublish((candidate) => { + context.published.push(candidate) + if (candidate.seq === 1) { + context.delivery.publish(batch(2, currentIncarnation)) + } + }) + + context.delivery.releaseCurrent(fences.cancelTokenWithSideEffects(token)!) + + expect(context.published).toEqual([batch(1, currentIncarnation), batch(2, currentIncarnation)]) + expect(context.onReleased).toHaveBeenCalledOnce() + }) + + it('retries a failed send without duplicating the committed prefix', () => { + const fences = new PtyRendererBindingFenceRegistry() + const context = setup(fences) + const token = fences.begin({ + id: 'pty-1', + paneGeneration: 7, + claimant: { rendererEpochId: 'renderer-1', sequence: 3 } + })! + context.delivery.publish(batch(1, currentIncarnation)) + context.delivery.publish(batch(2, currentIncarnation)) + let failSecond = true + context.setPublish((candidate) => { + if (candidate.seq === 2 && failSecond) { + failSecond = false + throw new Error('renderer unavailable') + } + context.published.push(candidate) + }) + + expect(context.delivery.releaseCurrent(fences.cancelTokenWithSideEffects(token)!)).toBe(false) + expect(context.published).toEqual([batch(1, currentIncarnation)]) + expect(context.onReleased).not.toHaveBeenCalled() + expect(context.delivery.retry('pty-1')).toBe(true) + expect(context.published).toEqual([batch(1, currentIncarnation), batch(2, currentIncarnation)]) + expect(context.onReplayDeferred).toHaveBeenCalledOnce() + expect(context.onReleased).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/ipc/pty-renderer-side-effect-delivery.ts b/src/main/ipc/pty-renderer-side-effect-delivery.ts new file mode 100644 index 00000000000..15b1bb5e772 --- /dev/null +++ b/src/main/ipc/pty-renderer-side-effect-delivery.ts @@ -0,0 +1,174 @@ +import type { ExactPtyMutationAccess } from '../../shared/pty-renderer-binding' +import type { TerminalSideEffectBatch } from '../../shared/terminal-side-effect-facts' +import type { + PtyRendererBindingFenceRegistry, + PtyRendererBindingSideEffectRelease, + PtyRendererBindingSideEffectRetention +} from './pty-renderer-binding-fence' + +export type PtyRendererSideEffectBinding = + | { mode: 'exact'; incarnationId: string } + | { mode: 'legacy' } + | { mode: 'unavailable' } + +type PtyRendererSideEffectDeliveryDeps = { + fences: PtyRendererBindingFenceRegistry + resolveBinding: (id: string) => PtyRendererSideEffectBinding + publish: (batch: TerminalSideEffectBatch) => void + onPressureChanged: (id: string) => void + onReleased: (id: string) => void + onReplayDeferred: (id: string, error: unknown) => void + onUnresolved: (id: string, batchCount: number) => void +} + +type PendingSideEffectRelease = { + release: PtyRendererBindingSideEffectRelease + binding: PtyRendererSideEffectBinding | 'current' +} + +function asLegacyBatch(batch: TerminalSideEffectBatch): TerminalSideEffectBatch { + if (batch.ptyIncarnationId === undefined) { + return batch + } + const { ptyIncarnationId: _unused, ...legacy } = batch + void _unused + return legacy +} + +export class PtyRendererSideEffectDelivery { + private readonly pendingReleases = new Map() + private readonly drainingReleases = new Set() + + constructor(private readonly deps: PtyRendererSideEffectDeliveryDeps) {} + + publish(batch: TerminalSideEffectBatch): void { + const retained = this.deps.fences.retainSideEffect(batch) + if (retained !== 'unfenced') { + this.deps.onPressureChanged(batch.ptyId) + if (this.pendingReleases.has(batch.ptyId) && !this.drainingReleases.has(batch.ptyId)) { + this.retry(batch.ptyId) + } + return + } + try { + if (!this.publishForBinding(batch, this.deps.resolveBinding(batch.ptyId))) { + this.reportUnresolved(batch.ptyId, 1) + } + } catch (error) { + this.deps.onReplayDeferred(batch.ptyId, error) + this.reportUnresolved(batch.ptyId, 1) + } + } + + releaseExact( + release: PtyRendererBindingSideEffectRelease, + access: ExactPtyMutationAccess + ): boolean { + return this.prepareRelease(release, { + mode: 'exact', + incarnationId: access.identity.incarnationId + }) + } + + releaseCurrent(release: PtyRendererBindingSideEffectRelease): boolean { + return this.prepareRelease(release, 'current') + } + + retry(id: string): boolean { + const pending = this.pendingReleases.get(id) + if (!pending || this.drainingReleases.has(id)) { + return false + } + const binding = pending.binding === 'current' ? this.deps.resolveBinding(id) : pending.binding + if (binding.mode === 'unavailable') { + this.deps.onPressureChanged(id) + return false + } + this.drainingReleases.add(id) + try { + const drained = this.deps.fences.drainSideEffectRelease(pending.release, (batch) => + this.publishForBinding(batch, binding) + ) + if (!drained) { + this.pendingReleases.delete(id) + return false + } + this.pendingReleases.delete(id) + this.reportUnresolved(id, drained.unresolvedBatchCount) + try { + this.deps.onReleased(id) + } finally { + this.deps.onPressureChanged(id) + } + return true + } catch (error) { + this.deps.onReplayDeferred(id, error) + this.deps.onPressureChanged(id) + return false + } finally { + this.drainingReleases.delete(id) + } + } + + retryAll(): void { + for (const id of this.pendingReleases.keys()) { + this.retry(id) + } + } + + abandonRelease(release: PtyRendererBindingSideEffectRelease): void { + this.pendingReleases.delete(release.id) + const retention = this.deps.fences.abandonSideEffectRelease(release) + if (retention) { + this.classifyUnresolved(retention) + this.deps.onReleased(release.id) + } + } + + reset(retentions: readonly PtyRendererBindingSideEffectRetention[]): void { + for (const retention of retentions) { + this.pendingReleases.delete(retention.id) + this.drainingReleases.delete(retention.id) + this.deps.onPressureChanged(retention.id) + } + } + + classifyUnresolved(retention: PtyRendererBindingSideEffectRetention): void { + this.pendingReleases.delete(retention.id) + this.reportUnresolved(retention.id, retention.batches.length + retention.unresolvedBatchCount) + this.deps.onPressureChanged(retention.id) + } + + private prepareRelease( + release: PtyRendererBindingSideEffectRelease, + binding: PtyRendererSideEffectBinding | 'current' + ): boolean { + this.pendingReleases.set(release.id, { release, binding }) + return this.retry(release.id) + } + + private publishForBinding( + batch: TerminalSideEffectBatch, + binding: PtyRendererSideEffectBinding + ): boolean { + if (binding.mode === 'legacy') { + this.deps.publish(asLegacyBatch(batch)) + return true + } + if ( + binding.mode === 'exact' && + batch.ptyIncarnationId !== undefined && + batch.ptyIncarnationId === binding.incarnationId + ) { + this.deps.publish(batch) + return true + } + return false + } + + private reportUnresolved(id: string, batchCount: number): void { + if (batchCount > 0) { + this.deps.onUnresolved(id, batchCount) + } + } +} diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 877d7d8862c..1c9917b8bb1 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -18,6 +18,15 @@ import { TerminalSessionOwnerUnverifiedError } from '../daemon/daemon-errors' const isWindowsHost = process.platform === 'win32' const posixOnlyIt = isWindowsHost ? it.skip : it +const SSH_TERMINAL_AUTHORITY_ACCESS = { + namespace: { authorityHostId: 'authority-host-1', namespaceId: 'namespace-1' }, + pane: { paneKey: 'tab:tab-1/leaf:leaf-1', paneGenerationId: 'generation-1' }, + binding: { + ownerIncarnationId: 'owner-1', + physicalPtyId: 'relay-pty', + ptyIncarnationId: 'incarnation-1' + } +} as const // Why: bare shells no longer mkdir ~/.omp; OMP status lives under userData (#10196). const expectedOmpStatusExtension = posix.join( '/tmp/orca-user-data', @@ -43,6 +52,7 @@ const { mkdirSyncMock, readFileSyncMock, writeFileSyncMock, + renameSyncMock, chmodSyncMock, getPathMock, loginPreflightExecFileMock, @@ -65,7 +75,8 @@ const { clearMigrationUnsupportedPtysForPaneKeyMock, clearPaneKeyAliasesForPtyMock, recordCodexPaneAccountMock, - forgetCodexPaneAccountMock + forgetCodexPaneAccountMock, + prepareSshTerminalAuthorityExitWaitMock } = vi.hoisted(() => ({ handleMock: vi.fn(), onMock: vi.fn(), @@ -77,6 +88,7 @@ const { mkdirSyncMock: vi.fn(), readFileSyncMock: vi.fn(), writeFileSyncMock: vi.fn(), + renameSyncMock: vi.fn(), chmodSyncMock: vi.fn(), getPathMock: vi.fn(), loginPreflightExecFileMock: vi.fn(), @@ -99,7 +111,8 @@ const { clearMigrationUnsupportedPtysForPaneKeyMock: vi.fn(), clearPaneKeyAliasesForPtyMock: vi.fn(), recordCodexPaneAccountMock: vi.fn(), - forgetCodexPaneAccountMock: vi.fn() + forgetCodexPaneAccountMock: vi.fn(), + prepareSshTerminalAuthorityExitWaitMock: vi.fn() })) vi.mock('electron', () => ({ @@ -131,6 +144,7 @@ vi.mock('fs', () => ({ mkdirSync: mkdirSyncMock, readFileSync: readFileSyncMock, writeFileSync: writeFileSyncMock, + renameSync: renameSyncMock, chmodSync: chmodSyncMock, constants: { X_OK: 1 @@ -209,10 +223,15 @@ vi.mock('../codex/codex-pane-account-registry', () => ({ recordCodexPaneAccount: recordCodexPaneAccountMock, forgetCodexPaneAccount: forgetCodexPaneAccountMock })) + +vi.mock('../ssh/ssh-terminal-authority-exit-wait', () => ({ + prepareSshTerminalAuthorityExitWait: prepareSshTerminalAuthorityExitWaitMock +})) import { LocalPtyProvider, _resetLocalPtyProviderStateForTest } from '../providers/local-pty-provider' +import { SshPtyProvider } from '../providers/ssh-pty-provider' import { makePaneKey } from '../../shared/stable-pane-id' import { SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV } from '../../shared/setup-agent-sequencing' import { @@ -234,6 +253,9 @@ import { getLocalPtyProvider, isCurrentPtyExit, restorePtyIncarnation, + publishTerminalSideEffectBatchToRenderer, + _resetPtyPendingCloseStateForTests, + waitForSshPtyPendingCloseReplay, type PrepareCodexSessionResume } from './pty' import { resetMacosLoginShellPreflightForTests } from '../providers/macos-tcc-login-shell' @@ -256,8 +278,13 @@ import { __resetShellStartupEnvCache } from '../pty/shell-startup-env' import { acceptSshPtyOutputData, acceptSshPtyOutputExit, - closeSshPtyOutputGeneration + closeSshPtyOutputGeneration, + installSshPtySourceAckPublisher } from './ssh-pty-output-intake-registry' +import { + PTY_RENDERER_BINDING_SIDE_EFFECT_BACKPRESSURE_BATCHES, + PTY_RENDERER_BINDING_SIDE_EFFECT_MAX_BATCHES +} from './pty-renderer-binding-fence' // Why: Windows resolves a bare PowerShell name to an absolute exe before ConPTY, else CreateProcessW fails with error 5 (PR #6537 / #5161). const RESOLVED_WINDOWS_POWERSHELL = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe' @@ -360,6 +387,7 @@ describe('registerPtyHandlers', () => { mkdirSyncMock.mockReset() readFileSyncMock.mockReset() writeFileSyncMock.mockReset() + renameSyncMock.mockReset() chmodSyncMock.mockReset() getPathMock.mockReset() loginPreflightExecFileMock.mockReset() @@ -383,6 +411,12 @@ describe('registerPtyHandlers', () => { clearPaneKeyAliasesForPtyMock.mockReset() recordCodexPaneAccountMock.mockReset() forgetCodexPaneAccountMock.mockReset() + prepareSshTerminalAuthorityExitWaitMock.mockReset() + prepareSshTerminalAuthorityExitWaitMock.mockImplementation(() => ({ + completion: Promise.resolve(true), + cancelUnsent: vi.fn(), + dispose: vi.fn() + })) mainWindow.webContents.on.mockReset() mainWindow.webContents.send.mockReset() mainWindow.webContents.removeListener.mockReset() @@ -474,6 +508,7 @@ describe('registerPtyHandlers', () => { afterEach(() => { _resetLocalPtyProviderStateForTest() + _resetPtyPendingCloseStateForTests() _resetWslCachesForTests() vi.useRealTimers() // Why: sshProviders is module-level state; a leftover id leaks into later tests (pty:listSessions sweeps every provider). @@ -735,6 +770,20 @@ describe('registerPtyHandlers', () => { } } + function createExactRendererBindingProvider(spawn: ReturnType) { + return { + ...createAgentClaimProvider({ spawn }), + getPtyMutationMode: vi.fn(() => 'exact' as const), + writeExact: vi.fn(() => true), + resizeExact: vi.fn(() => true), + sendSignalExact: vi.fn(async () => true), + clearBufferExact: vi.fn(async () => true), + killExact: vi.fn(async () => true), + pauseProducer: vi.fn(), + resumeProducer: vi.fn() + } + } + const recoveredAgentClaim = { digestVersion: 1 as const, keyId: 'claim-key', @@ -751,16 +800,18 @@ describe('registerPtyHandlers', () => { function registerAgentClaimController(): { spawn: (args: Record) => Promise - write: (ptyId: string, data: string) => boolean - resize: (ptyId: string, cols: number, rows: number) => boolean + captureMutationTarget: (ptyId: string) => unknown + write: (ptyId: string, data: string, target?: unknown) => boolean + resize: (ptyId: string, cols: number, rows: number, target?: unknown) => boolean probePtyLiveness: (ptyId: string) => Promise attach: (ptyId: string) => Promise } { let controller: | { spawn: (args: Record) => Promise - write: (ptyId: string, data: string) => boolean - resize: (ptyId: string, cols: number, rows: number) => boolean + captureMutationTarget: (ptyId: string) => unknown + write: (ptyId: string, data: string, target?: unknown) => boolean + resize: (ptyId: string, cols: number, rows: number, target?: unknown) => boolean probePtyLiveness: (ptyId: string) => Promise attach: (ptyId: string) => Promise } @@ -789,18 +840,21 @@ describe('registerPtyHandlers', () => { registerSshPtyProvider(connectionId, sshProvider as never) setPtyOwnership(ptyId, connectionId) const controller = registerAgentClaimController() + const connectedTarget = controller.captureMutationTarget(ptyId) unregisterSshPtyProvider(connectionId) clearPtyOwnershipForConnection(connectionId) - expect(controller.write(ptyId, 'input')).toBe(false) - expect(controller.resize(ptyId, 100, 40)).toBe(false) + expect(controller.write(ptyId, 'input', connectedTarget)).toBe(false) + expect(controller.resize(ptyId, 100, 40, connectedTarget)).toBe(false) expect(localProvider.write).not.toHaveBeenCalled() expect(localProvider.resize).not.toHaveBeenCalled() registerSshPtyProvider(connectionId, sshProvider as never) - expect(controller.write(ptyId, 'reconnected')).toBe(true) - expect(controller.resize(ptyId, 120, 50)).toBe(true) + setPtyOwnership(ptyId, connectionId) + const reconnectedTarget = controller.captureMutationTarget(ptyId) + expect(controller.write(ptyId, 'reconnected', reconnectedTarget)).toBe(true) + expect(controller.resize(ptyId, 120, 50, reconnectedTarget)).toBe(true) expect(sshProvider.write).toHaveBeenCalledWith(ptyId, 'reconnected') expect(sshProvider.resize).toHaveBeenCalledWith(ptyId, 120, 50) @@ -4988,6 +5042,15 @@ describe('registerPtyHandlers', () => { }) it('does NOT inject host-local env on SSH spawns (connectionId set)', async () => { + const terminalSessionAuthorityAccess = { + namespace: { authorityHostId: 'host-1', namespaceId: 'namespace-1' }, + pane: { paneKey: 'pane-1', paneGenerationId: 'renderer:1' }, + binding: { + ownerIncarnationId: 'owner-1', + physicalPtyId: 'ssh-pty', + ptyIncarnationId: 'incarnation-1' + } + } const sshSpawn = vi.fn( async (_opts: { env: Record @@ -4995,7 +5058,8 @@ describe('registerPtyHandlers', () => { paneKey?: string tabId?: string }) => ({ - id: 'ssh-pty' + id: 'ssh-pty', + terminalSessionAuthorityAccess }) ) const store = { @@ -5082,6 +5146,7 @@ describe('registerPtyHandlers', () => { worktreeId: 'wt-1', tabId: 'tab-1', leafId, + terminalSessionAuthorityAccess, state: 'attached' }) ) @@ -5167,6 +5232,185 @@ describe('registerPtyHandlers', () => { expect(store.markSshRemotePtyLease).toHaveBeenCalledWith('ssh-1', 'remote-pty', 'expired') }) + it('threads current incarnation and pane generation into a direct SSH reattach', async () => { + const connectionId = 'ssh-1' + const scopedPtyId = `ssh:${connectionId}@@remote-pty` + const leafId = '12121212-1212-4212-8212-121212121212' + const paneKey = makePaneKey('tab-authority', leafId) + const sshSpawn = vi.fn(async () => ({ + id: scopedPtyId, + incarnationId: 'incarnation-current', + isReattach: true + })) + registerSshPtyProvider(connectionId, { + spawn: sshSpawn, + write: vi.fn(), + resize: vi.fn(), + shutdown: vi.fn(), + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + acknowledgeDataEvent: vi.fn(), + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}), + listProcesses: vi.fn(async () => []), + attach: vi.fn(), + getDefaultShell: vi.fn(), + getProfiles: vi.fn() + } as never) + restorePtyIncarnation(scopedPtyId, 'incarnation-current') + handlers.clear() + registerPtyHandlers(mainWindow as never) + + try { + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp', + env: { + ORCA_PANE_KEY: paneKey, + ORCA_TAB_ID: 'tab-authority', + ORCA_WORKTREE_ID: 'wt-authority' + }, + connectionId, + worktreeId: 'wt-authority', + tabId: 'tab-authority', + leafId, + tabGeneration: 12, + sessionId: scopedPtyId + }) + + expect(sshSpawn).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: scopedPtyId, + paneKey, + worktreeId: 'wt-authority', + paneGeneration: 12, + expectedIncarnationId: 'incarnation-current', + expectedIncarnationIsAuthoritative: true + }) + ) + } finally { + clearProviderPtyState(scopedPtyId) + unregisterSshPtyProvider(connectionId) + } + }) + + it('keeps the durable SSH authority generation across a renderer remount', async () => { + const connectionId = 'ssh-1' + const scopedPtyId = `ssh:${connectionId}@@remote-pty` + const worktreeId = 'wt-authority' + const tabId = 'tab-authority' + const leafId = '23232323-2323-4232-8232-232323232323' + const paneKey = makePaneKey(tabId, leafId) + const sshSpawn = vi.fn(async () => ({ + id: scopedPtyId, + incarnationId: 'incarnation-current', + isReattach: true + })) + registerSshPtyProvider(connectionId, { + spawn: sshSpawn, + write: vi.fn(), + resize: vi.fn(), + shutdown: vi.fn(), + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + acknowledgeDataEvent: vi.fn(), + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}), + listProcesses: vi.fn(async () => []), + attach: vi.fn(), + getDefaultShell: vi.fn(), + getProfiles: vi.fn() + } as never) + const store = { + getWorkspaceSession: vi.fn(() => ({ + tabsByWorktree: { + [worktreeId]: [{ id: tabId, worktreeId, ptyId: scopedPtyId }] + }, + terminalLayoutsByTabId: { + [tabId]: { ptyIdsByLeafId: { [leafId]: scopedPtyId } } + }, + terminalPtyIncarnationsByPaneKey: { + [paneKey]: 'incarnation-current' + } + })), + getSshRemotePtyLeases: vi.fn(() => [ + { + targetId: connectionId, + ptyId: 'remote-pty', + incarnationId: 'incarnation-current', + worktreeId, + tabId, + leafId, + paneGeneration: 4, + state: 'detached' + } + ]), + persistPtyBinding: vi.fn(() => true), + upsertSshRemotePtyLease: vi.fn() + } + restorePtyIncarnation(scopedPtyId, 'incarnation-current') + handlers.clear() + registerPtyHandlers( + mainWindow as never, + undefined, + undefined, + undefined, + undefined, + store as never + ) + + try { + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp', + env: {}, + connectionId, + worktreeId, + tabId, + leafId, + tabGeneration: 5, + sessionId: scopedPtyId + }) + + expect(sshSpawn).toHaveBeenCalledWith( + expect.objectContaining({ + attachOnly: true, + sessionId: scopedPtyId, + paneKey, + worktreeId, + paneGeneration: 4, + expectedIncarnationId: 'incarnation-current' + }) + ) + expect(store.upsertSshRemotePtyLease).toHaveBeenCalledWith( + expect.objectContaining({ + ptyId: 'remote-pty', + paneGeneration: 4, + incarnationId: 'incarnation-current' + }) + ) + } finally { + clearProviderPtyState(scopedPtyId) + unregisterSshPtyProvider(connectionId) + } + }) + it('marks a scoped SSH session expired using the raw relay lease id', async () => { const scopedPtyId = 'ssh:ssh-1@@remote-pty' const sshSpawn = vi.fn(async () => { @@ -5922,7 +6166,7 @@ describe('registerPtyHandlers', () => { expect(store.markSshRemotePtyLease).toHaveBeenCalledWith('ssh-1', 'relay-pty', 'terminated') }) - it('runtime controller kill tombstones app-scoped SSH ids when ownership and provider are absent', async () => { + it('runtime controller kill durably defers an exact SSH close while its provider is absent', async () => { const localShutdown = vi.fn() setLocalPtyProvider({ spawn: vi.fn(), @@ -5946,7 +6190,19 @@ describe('registerPtyHandlers', () => { getDefaultShell: vi.fn(), getProfiles: vi.fn() } as never) - const store = { markSshRemotePtyLease: vi.fn() } + const requestSshRemotePtyClose = vi.fn(() => 'recorded') + const store = { + getSshRemotePtyLeases: vi.fn(() => [ + { + targetId: 'ssh-1', + ptyId: 'relay-pty', + incarnationId: 'incarnation-1', + terminalSessionAuthorityAccess: SSH_TERMINAL_AUTHORITY_ACCESS, + state: 'detached' + } + ]), + requestSshRemotePtyClose + } const runtime = { setPtyController: vi.fn(), onPtyExit: vi.fn() @@ -5967,13 +6223,30 @@ describe('registerPtyHandlers', () => { expect(controller.kill('ssh:ssh-1@@relay-pty')).toBe(true) expect(localShutdown).not.toHaveBeenCalled() - expect(store.markSshRemotePtyLease).toHaveBeenCalledWith('ssh-1', 'relay-pty', 'terminated') - expect(runtime.onPtyExit).toHaveBeenCalledWith('ssh:ssh-1@@relay-pty', -1, undefined) + expect(requestSshRemotePtyClose).toHaveBeenCalledWith( + 'ssh-1', + 'relay-pty', + expect.objectContaining({ incarnationId: 'incarnation-1', keepHistory: false }) + ) + expect(runtime.onPtyExit).not.toHaveBeenCalled() }) - it('marks a detached SSH lease terminated when runtime controller kill has no provider', async () => { + it('retains a detached SSH lease while runtime controller kill awaits reconnect replay', async () => { + const requestSshRemotePtyClose = vi.fn(() => 'recorded') const store = { - markSshRemotePtyLease: vi.fn() + getSshRemotePtyLeases: vi.fn(() => [ + { + targetId: 'ssh-1', + ptyId: 'remote-pty', + incarnationId: 'incarnation-1', + terminalSessionAuthorityAccess: { + ...SSH_TERMINAL_AUTHORITY_ACCESS, + binding: { ...SSH_TERMINAL_AUTHORITY_ACCESS.binding, physicalPtyId: 'remote-pty' } + }, + state: 'detached' + } + ]), + requestSshRemotePtyClose } const runtime = { setPtyController: vi.fn(), @@ -5995,12 +6268,12 @@ describe('registerPtyHandlers', () => { expect(controller.kill('remote-pty')).toBe(true) - expect(store.markSshRemotePtyLease).toHaveBeenCalledWith( + expect(requestSshRemotePtyClose).toHaveBeenCalledWith( 'ssh-1', 'remote-pty', - 'terminated' + expect.objectContaining({ incarnationId: 'incarnation-1', keepHistory: false }) ) - expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', -1, undefined) + expect(runtime.onPtyExit).not.toHaveBeenCalled() }) it('preserves an SSH lease when runtime controller kill shutdown fails transiently', async () => { @@ -6061,7 +6334,7 @@ describe('registerPtyHandlers', () => { 'remote-pty', 'terminated' ) - expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', -1, undefined) + expect(runtime.onPtyExit).not.toHaveBeenCalled() }) it('strips ORCA_PANE_KEY/TAB_ID/WORKTREE_ID from SSH spawn env when remote agent hooks are disabled', async () => { @@ -6539,12 +6812,12 @@ describe('registerPtyHandlers', () => { kill: (ptyId: string) => boolean } + const daemon = installObservableDaemonTestProvider() expect(controller.kill('daemon-session')).toBe(true) await Promise.resolve() expect(awaitLocalPtyProviderStartup).toHaveBeenCalledTimes(1) expect(fallbackShutdown).not.toHaveBeenCalled() - const daemon = installObservableDaemonTestProvider() barrier.resolve() await vi.waitFor(() => expect(daemon.shutdown).toHaveBeenCalledTimes(1)) await vi.waitFor(() => expect(runtime.onPtyExit).toHaveBeenCalledTimes(1)) @@ -6573,12 +6846,12 @@ describe('registerPtyHandlers', () => { stopAndWait: (ptyId: string) => Promise } + const daemon = installObservableDaemonTestProvider() const pendingStop = controller.stopAndWait('daemon-session') await Promise.resolve() expect(awaitLocalPtyProviderStartup).toHaveBeenCalledTimes(1) expect(fallbackShutdown).not.toHaveBeenCalled() - const daemon = installObservableDaemonTestProvider() barrier.resolve() await expect(pendingStop).resolves.toBe(true) @@ -6638,6 +6911,9 @@ describe('registerPtyHandlers', () => { 'daemon output', expect.any(Number), 'daemon output'.length, + undefined, + undefined, + undefined, undefined ) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { @@ -6666,6 +6942,7 @@ describe('registerPtyHandlers', () => { onPtyExit: vi.fn(), onPtyData: vi.fn(() => 12), createPreAllocatedTerminalHandle: vi.fn(() => 'terminal-handle-small'), + preAllocateHandleForPty: vi.fn(() => 'terminal-handle-small'), registerPreAllocatedHandleForPty: vi.fn() } try { @@ -6846,6 +7123,141 @@ describe('registerPtyHandlers', () => { }) }) + it('admits SSH authority sessions through their relay physical PTY identity', async () => { + const terminalSessionAuthorityAccess = { + namespace: { authorityHostId: 'host-1', namespaceId: 'namespace-1' }, + pane: { paneKey: 'pane-1', paneGenerationId: 'renderer:1' }, + binding: { + ownerIncarnationId: 'owner-1', + physicalPtyId: 'pty-1', + ptyIncarnationId: 'incarnation-1' + } + } + const mux = { + request: vi.fn(async (method: string) => + method === 'pty.listProcesses' + ? [ + { + id: 'pty-1', + cwd: '/remote', + title: 'ssh-shell', + incarnationId: 'incarnation-1', + terminalSessionAuthorityAccess + } + ] + : undefined + ), + notify: vi.fn(), + onNotification: vi.fn(() => vi.fn()), + dispose: vi.fn(), + isDisposed: vi.fn(() => false) + } + const provider = new SshPtyProvider('ssh-1', mux as never) + expect( + provider.resolveTerminalSessionAuthorityPhysicalPtyId('ssh:other-connection@@pty-1') + ).toBeNull() + const resolvePhysicalPtyId = vi.spyOn(provider, 'resolveTerminalSessionAuthorityPhysicalPtyId') + registerPtyHandlers(mainWindow as never) + registerSshPtyProvider('ssh-1', provider) + + try { + const sessions = (await handlers.get('pty:listSessions')!(null, undefined)) as { + id: string + }[] + + expect(sessions).toContainEqual(expect.objectContaining({ id: 'ssh:ssh-1@@pty-1' })) + expect(resolvePhysicalPtyId).toHaveBeenCalledWith('ssh:ssh-1@@pty-1') + } finally { + unregisterSshPtyProvider('ssh-1') + provider.dispose() + } + }) + + it('drops a pending SSH inventory from a replaced provider', async () => { + let resolveOldList!: (rows: { id: string; cwd: string; title: string }[]) => void + const oldList = new Promise<{ id: string; cwd: string; title: string }[]>((resolve) => { + resolveOldList = resolve + }) + const oldProvider = { + ...createAgentClaimProvider({}), + listProcesses: vi.fn(() => oldList) + } + const replacement = createAgentClaimProvider({}) + registerPtyHandlers(mainWindow as never) + registerSshPtyProvider('ssh-1', oldProvider as never) + const inventory = handlers.get('pty:listSessions')!(null, undefined) + await vi.waitFor(() => expect(oldProvider.listProcesses).toHaveBeenCalledTimes(1)) + + registerSshPtyProvider('ssh-1', replacement as never) + resolveOldList([{ id: 'ssh:ssh-1@@pty-old', cwd: '/old', title: 'old' }]) + + await expect(inventory).resolves.not.toContainEqual( + expect.objectContaining({ id: 'ssh:ssh-1@@pty-old' }) + ) + expect(replacement.shutdown).not.toHaveBeenCalled() + }) + + it('rejects listed legacy mutation access after its SSH provider is replaced', async () => { + const id = 'ssh:ssh-1@@pty-1' + const oldProvider = createAgentClaimProvider({ + sessions: [{ id, cwd: '/old', title: 'old' }] + }) + const replacement = createAgentClaimProvider({}) + registerPtyHandlers(mainWindow as never) + registerSshPtyProvider('ssh-1', oldProvider as never) + const sessions = (await handlers.get('pty:listSessions')!(null, undefined)) as { + id: string + administrativeMutationAccess: { mode: string; providerRouteId?: string } + }[] + const access = sessions.find((session) => session.id === id)?.administrativeMutationAccess + expect(access).toEqual({ mode: 'legacy', providerRouteId: expect.any(String) }) + + registerSshPtyProvider('ssh-1', replacement as never) + + await expect( + handlers.get('pty:kill')!(null, { id, administrativeAccess: access }) + ).rejects.toThrow('pty_mutation_identity_rejected') + expect(replacement.shutdown).not.toHaveBeenCalled() + }) + + it('drops a pending local inventory after the local provider is swapped', async () => { + let resolveOldList!: (rows: { id: string; cwd: string; title: string }[]) => void + const oldList = new Promise<{ id: string; cwd: string; title: string }[]>((resolve) => { + resolveOldList = resolve + }) + const oldProvider = { + ...createAgentClaimProvider({}), + listProcesses: vi.fn(() => oldList) + } + const replacement = createAgentClaimProvider({}) + setLocalPtyProvider(oldProvider as never) + registerPtyHandlers(mainWindow as never) + const inventory = handlers.get('pty:listSessions')!(null, undefined) + expect(oldProvider.listProcesses).toHaveBeenCalledTimes(1) + + setLocalPtyProvider(replacement as never) + resolveOldList([{ id: 'pty-old', cwd: '/old', title: 'old' }]) + + await expect(inventory).resolves.not.toContainEqual(expect.objectContaining({ id: 'pty-old' })) + expect(replacement.shutdown).not.toHaveBeenCalled() + }) + + it('rejects duplicate process IDs before publishing list ownership', async () => { + setLocalPtyProvider( + createAgentClaimProvider({ + sessions: [ + { id: 'duplicate-pty', cwd: '/first', title: 'first' }, + { id: 'duplicate-pty', cwd: '/second', title: 'second' } + ] + }) as never + ) + registerPtyHandlers(mainWindow as never) + + await expect(handlers.get('pty:listSessions')!(null, undefined)).rejects.toThrow( + 'pty_process_list_duplicate' + ) + }) + it('starts local and SSH session inventories concurrently', async () => { let resolveLocal!: (sessions: { id: string; cwd: string; title: string }[]) => void const localSessions = new Promise<{ id: string; cwd: string; title: string }[]>((resolve) => { @@ -6882,7 +7294,7 @@ describe('registerPtyHandlers', () => { const pendingInventory = handlers.get('pty:listSessions')!(null, undefined) - expect(sshListProcesses).toHaveBeenCalledTimes(1) + await vi.waitFor(() => expect(sshListProcesses).toHaveBeenCalledTimes(1)) resolveLocal([]) resolveSsh([]) await pendingInventory @@ -7348,7 +7760,7 @@ describe('registerPtyHandlers', () => { expect(store.markSshRemotePtyLease).toHaveBeenCalledWith('ssh-1', 'relay-pty', 'terminated') }) - it('tombstones app-scoped SSH PTY ids instead of falling back local when ownership and provider are absent', async () => { + it('persists an exact app-scoped SSH close instead of falling back local while disconnected', async () => { const localShutdown = vi.fn() setLocalPtyProvider({ spawn: vi.fn(), @@ -7372,7 +7784,19 @@ describe('registerPtyHandlers', () => { getDefaultShell: vi.fn(), getProfiles: vi.fn() } as never) - const store = { markSshRemotePtyLease: vi.fn() } + const requestSshRemotePtyClose = vi.fn(() => 'recorded') + const store = { + getSshRemotePtyLeases: vi.fn(() => [ + { + targetId: 'ssh-1', + ptyId: 'relay-pty', + incarnationId: 'incarnation-1', + terminalSessionAuthorityAccess: SSH_TERMINAL_AUTHORITY_ACCESS, + state: 'detached' + } + ]), + requestSshRemotePtyClose + } registerPtyHandlers( mainWindow as never, undefined, @@ -7385,17 +7809,241 @@ describe('registerPtyHandlers', () => { await handlers.get('pty:kill')!(null, { id: 'ssh:ssh-1@@relay-pty' }) expect(localShutdown).not.toHaveBeenCalled() - expect(store.markSshRemotePtyLease).toHaveBeenCalledWith('ssh-1', 'relay-pty', 'terminated') + expect(requestSshRemotePtyClose).toHaveBeenCalledWith( + 'ssh-1', + 'relay-pty', + expect.objectContaining({ + incarnationId: 'incarnation-1', + terminalSessionAuthorityAccess: SSH_TERMINAL_AUTHORITY_ACCESS, + keepHistory: false + }) + ) }) - it('ignores fire-and-forget IPC for detached SSH PTYs without a provider', async () => { + it('rejects a disconnected SSH close without a persisted full authority binding', async () => { + const requestSshRemotePtyClose = vi.fn() const store = { - upsertSshRemotePtyLease: vi.fn(), - persistPtyBinding: vi.fn(), + getSshRemotePtyLeases: vi.fn(() => [ + { + targetId: 'ssh-1', + ptyId: 'relay-pty', + incarnationId: 'incarnation-1', + state: 'detached' + } + ]), + requestSshRemotePtyClose + } + registerPtyHandlers( + mainWindow as never, + undefined, + undefined, + undefined, + undefined, + store as never + ) + + await expect(handlers.get('pty:kill')!(null, { id: 'ssh:ssh-1@@relay-pty' })).rejects.toThrow( + 'ssh_pty_pending_close_authority_unavailable' + ) + expect(requestSshRemotePtyClose).not.toHaveBeenCalled() + }) + + it('replays a durable disconnected close exactly once before SSH publication', async () => { + const lease = { + targetId: 'ssh-1', + ptyId: 'relay-pty', + incarnationId: 'incarnation-1', + terminalSessionAuthorityAccess: SSH_TERMINAL_AUTHORITY_ACCESS, + state: 'detached', + pendingClose: { + incarnationId: 'incarnation-1', + terminalSessionAuthorityAccess: SSH_TERMINAL_AUTHORITY_ACCESS, + keepHistory: true, + requestedAt: 1 + } + } + const store = { + getSshRemotePtyLeases: vi.fn(() => [lease]), + markSshRemotePtyLease: vi.fn() + } + const runtime = { setPtyController: vi.fn(), onPtyExit: vi.fn() } + const mutationRouteToken = Object.freeze({}) + const killAuthorityExact = vi.fn(async () => { + lease.state = 'terminated' + delete (lease as { pendingClose?: unknown }).pendingClose + return true + }) + const listProcesses = vi.fn(async () => [ + { + id: 'ssh:ssh-1@@relay-pty', + incarnationId: 'incarnation-1', + terminalSessionAuthorityAccess: SSH_TERMINAL_AUTHORITY_ACCESS, + mutationRouteToken, + cwd: '/workspace', + title: 'shell' + } + ]) + const provider = { + listProcesses, + getPtyMutationMode: vi.fn(() => 'exact'), + getPtyMutationRouteToken: vi.fn(() => mutationRouteToken), + killAuthorityExact + } + registerPtyHandlers( + mainWindow as never, + runtime as never, + undefined, + undefined, + undefined, + store as never + ) + registerSshPtyProvider('ssh-1', provider as never) + + await waitForSshPtyPendingCloseReplay('ssh-1', provider as never) + + expect(killAuthorityExact).toHaveBeenCalledOnce() + expect(killAuthorityExact).toHaveBeenCalledWith( + 'ssh:ssh-1@@relay-pty', + SSH_TERMINAL_AUTHORITY_ACCESS, + { immediate: true, keepHistory: true } + ) + expect(runtime.onPtyExit).toHaveBeenCalledWith('ssh:ssh-1@@relay-pty', -1, 'incarnation-1') + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:exit', { + id: 'ssh:ssh-1@@relay-pty', + code: -1, + incarnationId: 'incarnation-1' + }) + }) + + it('does not kill or settle a same-id SSH successor without a durable old-binding exit', async () => { + const lease = { + targetId: 'ssh-1', + ptyId: 'relay-pty', + incarnationId: 'incarnation-1', + terminalSessionAuthorityAccess: SSH_TERMINAL_AUTHORITY_ACCESS, + state: 'detached', + pendingClose: { + incarnationId: 'incarnation-1', + terminalSessionAuthorityAccess: SSH_TERMINAL_AUTHORITY_ACCESS, + keepHistory: false, + requestedAt: 1 + } + } + const store = { + getSshRemotePtyLeases: vi.fn(() => [lease]), markSshRemotePtyLease: vi.fn() } const provider = { - spawn: vi.fn(async () => ({ id: 'remote-pty' })), + listProcesses: vi.fn(async () => [ + { + id: 'ssh:ssh-1@@relay-pty', + incarnationId: 'incarnation-2', + terminalSessionAuthorityAccess: { + ...SSH_TERMINAL_AUTHORITY_ACCESS, + pane: { + ...SSH_TERMINAL_AUTHORITY_ACCESS.pane, + paneGenerationId: 'generation-2' + }, + binding: { + ...SSH_TERMINAL_AUTHORITY_ACCESS.binding, + ptyIncarnationId: 'incarnation-2' + } + }, + cwd: '/workspace', + title: 'successor' + } + ]), + getPtyMutationMode: vi.fn(() => 'exact'), + killAuthorityExact: vi.fn() + } + registerPtyHandlers( + mainWindow as never, + undefined, + undefined, + undefined, + undefined, + store as never + ) + registerSshPtyProvider('ssh-1', provider as never) + + await expect(waitForSshPtyPendingCloseReplay('ssh-1', provider as never)).rejects.toThrow( + 'ssh_pty_pending_close_replay_rejected' + ) + + expect(provider.killAuthorityExact).not.toHaveBeenCalled() + }) + + it('blocks SSH spawn and list publication when pending-close replay is unknown', async () => { + const lease = { + targetId: 'ssh-1', + ptyId: 'relay-pty', + incarnationId: 'incarnation-1', + terminalSessionAuthorityAccess: SSH_TERMINAL_AUTHORITY_ACCESS, + state: 'detached', + pendingClose: { + incarnationId: 'incarnation-1', + terminalSessionAuthorityAccess: SSH_TERMINAL_AUTHORITY_ACCESS, + keepHistory: false, + requestedAt: 1 + } + } + const store = { getSshRemotePtyLeases: vi.fn(() => [lease]) } + const provider = { + listProcesses: vi.fn(async () => { + throw new Error('transport down') + }), + spawn: vi.fn() + } + registerPtyHandlers( + mainWindow as never, + undefined, + undefined, + undefined, + undefined, + store as never + ) + registerSshPtyProvider('ssh-1', provider as never) + + await expect( + handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + connectionId: 'ssh-1' + }) + ).rejects.toThrow('ssh_pty_pending_close_replay_unknown') + await expect(handlers.get('pty:listSessions')!(null, undefined)).rejects.toThrow( + 'ssh_pty_pending_close_replay_unknown' + ) + expect(provider.spawn).not.toHaveBeenCalled() + }) + + it('ignores fire-and-forget IPC for detached SSH PTYs without a provider', async () => { + const terminalSessionAuthorityAccess = { + ...SSH_TERMINAL_AUTHORITY_ACCESS, + binding: { ...SSH_TERMINAL_AUTHORITY_ACCESS.binding, physicalPtyId: 'remote-pty' } + } + const requestSshRemotePtyClose = vi.fn(() => 'recorded') + const store = { + upsertSshRemotePtyLease: vi.fn(), + persistPtyBinding: vi.fn(), + markSshRemotePtyLease: vi.fn(), + getSshRemotePtyLeases: vi.fn(() => [ + { + targetId: 'ssh-1', + ptyId: 'remote-pty', + incarnationId: 'incarnation-1', + terminalSessionAuthorityAccess, + state: 'detached' + } + ]), + requestSshRemotePtyClose + } + const provider = { + spawn: vi.fn(async () => ({ + id: 'remote-pty', + incarnationId: 'incarnation-1', + terminalSessionAuthorityAccess + })), write: vi.fn(), resize: vi.fn(), shutdown: vi.fn(), @@ -7452,7 +8100,842 @@ describe('registerPtyHandlers', () => { ).not.toThrow() await expect(handlers.get('pty:kill')!(null, { id: 'remote-pty' })).resolves.toBeUndefined() - expect(store.markSshRemotePtyLease).toHaveBeenCalledWith('ssh-1', 'remote-pty', 'terminated') + expect(requestSshRemotePtyClose).toHaveBeenCalledWith( + 'ssh-1', + 'remote-pty', + expect.objectContaining({ + incarnationId: 'incarnation-1', + terminalSessionAuthorityAccess, + keepHistory: false + }) + ) + expect(store.markSshRemotePtyLease).not.toHaveBeenCalled() + }) + + it('fences every renderer mutation when a pane generation or reused PTY incarnation is stale', async () => { + const writeExact = vi.fn((_id: string, _incarnationId: string, _data: string) => true) + const resizeExact = vi.fn( + (_id: string, _incarnationId: string, _cols: number, _rows: number) => true + ) + const sendSignalExact = vi.fn( + async (_id: string, _incarnationId: string, _signal: string) => true + ) + const clearBufferExact = vi.fn(async (_id: string, _incarnationId: string) => true) + const killExact = vi.fn( + async ( + _id: string, + _incarnationId: string, + _opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number } + ) => true + ) + let authorityIncarnationId = 'incarnation-1' + const authorityAccess = () => ({ + namespace: { authorityHostId: 'authority-host-1', namespaceId: 'namespace-1' }, + pane: { paneKey: 'tab:tab-1/leaf:leaf-1', paneGenerationId: 'generation-1' }, + binding: { + ownerIncarnationId: 'owner-1', + physicalPtyId: 'reused-pty', + ptyIncarnationId: authorityIncarnationId + } + }) + const writeAuthorityExact = vi.fn( + (id: string, access: ReturnType, data: string) => + access.binding.ptyIncarnationId === authorityIncarnationId && + writeExact(id, access.binding.ptyIncarnationId, data) + ) + const resizeAuthorityExact = vi.fn( + (id: string, access: ReturnType, cols: number, rows: number) => + access.binding.ptyIncarnationId === authorityIncarnationId && + resizeExact(id, access.binding.ptyIncarnationId, cols, rows) + ) + const sendSignalAuthorityExact = vi.fn( + (id: string, access: ReturnType, signal: string) => + access.binding.ptyIncarnationId === authorityIncarnationId && + sendSignalExact(id, access.binding.ptyIncarnationId, signal) + ) + const clearBufferAuthorityExact = vi.fn( + (id: string, access: ReturnType) => + access.binding.ptyIncarnationId === authorityIncarnationId && + clearBufferExact(id, access.binding.ptyIncarnationId) + ) + const killAuthorityExact = vi.fn( + async ( + id: string, + access: ReturnType, + opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number } + ) => + access.binding.ptyIncarnationId === authorityIncarnationId && + (await killExact(id, access.binding.ptyIncarnationId, opts)) + ) + const provider = { + spawn: vi.fn(async () => ({ id: 'reused-pty', incarnationId: 'incarnation-1' })), + getPtyMutationMode: vi.fn(() => 'exact'), + writeExact, + resizeExact, + sendSignalExact, + clearBufferExact, + killExact, + writeAuthorityExact, + resizeAuthorityExact, + sendSignalAuthorityExact, + clearBufferAuthorityExact, + killAuthorityExact, + getTerminalSessionAuthorityAccess: vi.fn(() => authorityAccess()), + write: vi.fn(), + resize: vi.fn(), + shutdown: vi.fn(), + sendSignal: vi.fn(async () => undefined), + clearBuffer: vi.fn(async () => undefined), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + acknowledgeDataEvent: vi.fn(), + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}), + listProcesses: vi.fn(async () => []), + attach: vi.fn(), + getDefaultShell: vi.fn(), + getProfiles: vi.fn() + } + setLocalPtyProvider(provider as never) + registerPtyHandlers(mainWindow as never) + const leafId = '11111111-1111-4111-8111-111111111111' + const firstClaimant = { rendererEpochId: 'renderer-epoch-1', sequence: 1 } + const spawned = (await handlers.get('pty:spawn')!(mainWindowIpcEvent, { + cols: 80, + rows: 24, + env: {}, + tabId: 'terminal-tab-1', + leafId, + tabGeneration: 7, + mutationClaimant: firstClaimant + })) as { + id: string + mutationAccess: { + mode: 'exact' + identity: { + incarnationId: string + paneGeneration: number + mutationLeaseId: string + } + claimant: { rendererEpochId: string; sequence: number } + } + administrativeMutationAccess: { + mode: 'exact' + evidence: { incarnationId: string; paneGeneration: number } + } + } + const listener = (channel: string): ((event: unknown, args: unknown) => void) => { + const registration = onMock.mock.calls.find((call: unknown[]) => call[0] === channel) + if (!registration) { + throw new Error(`missing ${channel} listener`) + } + return registration[1] as (event: unknown, args: unknown) => void + } + const successorClaimant = { rendererEpochId: 'renderer-epoch-1', sequence: 2 } + const successorAccess = (await handlers.get('pty:claimMutationAccess')!(mainWindowIpcEvent, { + id: spawned.id, + tabId: 'terminal-tab-2', + leafId: '22222222-2222-4222-8222-222222222222', + paneGeneration: 7, + claimant: successorClaimant + })) as typeof spawned.mutationAccess + expect(successorAccess.mode).toBe('exact') + expect(successorAccess.identity.mutationLeaseId).not.toBe( + spawned.mutationAccess.identity.mutationLeaseId + ) + listener('pty:write')(mainWindowIpcEvent, { + id: spawned.id, + data: 'detached-pane-stale', + mutationIdentity: spawned.mutationAccess.identity + }) + await expect( + handlers.get('pty:kill')!(mainWindowIpcEvent, { + id: spawned.id, + mutationIdentity: spawned.mutationAccess.identity + }) + ).rejects.toThrow('pty_mutation_identity_rejected') + expect(writeExact).not.toHaveBeenCalled() + expect(killExact).not.toHaveBeenCalled() + expect( + handlers.get('pty:claimMutationAccess')!(mainWindowIpcEvent, { + id: spawned.id, + tabId: 'terminal-tab-1', + leafId, + paneGeneration: 7, + claimant: firstClaimant + }) + ).toEqual({ mode: 'unavailable' }) + const staleGeneration = { incarnationId: 'incarnation-1', paneGeneration: 6 } + + listener('pty:write')(mainWindowIpcEvent, { + id: spawned.id, + data: 'stale-generation', + mutationIdentity: staleGeneration + }) + listener('pty:resize')(mainWindowIpcEvent, { + id: spawned.id, + cols: 120, + rows: 40, + mutationIdentity: staleGeneration + }) + await expect( + handlers.get('pty:kill')!(mainWindowIpcEvent, { + id: spawned.id, + mutationIdentity: staleGeneration + }) + ).rejects.toThrow('pty_mutation_identity_rejected') + expect(writeExact).not.toHaveBeenCalled() + expect(resizeExact).not.toHaveBeenCalled() + expect(killExact).not.toHaveBeenCalled() + + restorePtyIncarnation(spawned.id, 'incarnation-2') + authorityIncarnationId = 'incarnation-2' + const staleIncarnation = successorAccess.identity + expect(spawned.administrativeMutationAccess).toEqual({ + mode: 'exact', + evidence: { incarnationId: 'incarnation-1', paneGeneration: 7 }, + providerRouteId: expect.any(String) + }) + listener('pty:write')(mainWindowIpcEvent, { + id: spawned.id, + data: 'stale-administrative-write', + administrativeAccess: spawned.administrativeMutationAccess + }) + await expect( + handlers.get('pty:kill')!(mainWindowIpcEvent, { + id: spawned.id, + administrativeAccess: spawned.administrativeMutationAccess + }) + ).rejects.toThrow('pty_mutation_identity_rejected') + listener('pty:write')(mainWindowIpcEvent, { + id: spawned.id, + data: 'stale-incarnation', + mutationIdentity: staleIncarnation + }) + listener('pty:resize')(mainWindowIpcEvent, { + id: spawned.id, + cols: 100, + rows: 30, + mutationIdentity: staleIncarnation + }) + listener('pty:signal')(mainWindowIpcEvent, { + id: spawned.id, + signal: 'SIGWINCH', + mutationIdentity: staleIncarnation + }) + listener('pty:clearBuffer')(mainWindowIpcEvent, { + id: spawned.id, + mutationIdentity: staleIncarnation + }) + await expect( + handlers.get('pty:kill')!(mainWindowIpcEvent, { + id: spawned.id, + mutationIdentity: staleIncarnation + }) + ).rejects.toThrow('pty_mutation_identity_rejected') + await Promise.resolve() + expect(writeExact).not.toHaveBeenCalled() + expect(resizeExact).not.toHaveBeenCalled() + expect(sendSignalExact).not.toHaveBeenCalled() + expect(clearBufferExact).not.toHaveBeenCalled() + expect(killExact).not.toHaveBeenCalled() + + const refreshedAccess = handlers.get('pty:claimMutationAccess')!(mainWindowIpcEvent, { + id: spawned.id, + tabId: 'terminal-tab-2', + leafId: '22222222-2222-4222-8222-222222222222', + paneGeneration: 7, + claimant: successorClaimant + }) as typeof successorAccess + expect(refreshedAccess.identity.mutationLeaseId).not.toBe( + successorAccess.identity.mutationLeaseId + ) + const current = refreshedAccess.identity + listener('pty:write')(mainWindowIpcEvent, { + id: spawned.id, + data: 'current', + mutationIdentity: current + }) + listener('pty:resize')(mainWindowIpcEvent, { + id: spawned.id, + cols: 90, + rows: 28, + mutationIdentity: current + }) + listener('pty:signal')(mainWindowIpcEvent, { + id: spawned.id, + signal: 'SIGWINCH', + mutationIdentity: current + }) + listener('pty:clearBuffer')(mainWindowIpcEvent, { + id: spawned.id, + mutationIdentity: current + }) + listener('pty:write')(mainWindowIpcEvent, { + id: spawned.id, + data: 'administrative-current', + administrativeCurrent: true + }) + await handlers.get('pty:kill')!(mainWindowIpcEvent, { + id: spawned.id, + administrativeCurrent: true + }) + expect(writeExact).toHaveBeenCalledWith(spawned.id, 'incarnation-2', 'current') + expect(writeExact).toHaveBeenCalledWith(spawned.id, 'incarnation-2', 'administrative-current') + expect(resizeExact).toHaveBeenCalledWith(spawned.id, 'incarnation-2', 90, 28) + expect(sendSignalExact).toHaveBeenCalledWith(spawned.id, 'incarnation-2', 'SIGWINCH') + expect(clearBufferExact).toHaveBeenCalledWith(spawned.id, 'incarnation-2') + expect(killExact).toHaveBeenCalledWith( + spawned.id, + 'incarnation-2', + expect.objectContaining({ immediate: true }) + ) + }) + + it('holds recovered data and exit for the newest overlapping reconnect binding', async () => { + let resolveSpawn!: (value: { id: string; incarnationId: string; isReattach: boolean }) => void + const acknowledgeDataEvent = vi.fn() + const provider = { + spawn: vi.fn( + () => + new Promise<{ + id: string + incarnationId: string + isReattach: boolean + }>((resolve) => { + resolveSpawn = resolve + }) + ), + getPtyMutationMode: vi.fn(() => 'exact'), + writeExact: vi.fn(() => true), + resizeExact: vi.fn(() => true), + sendSignalExact: vi.fn(async () => true), + clearBufferExact: vi.fn(async () => true), + killExact: vi.fn(async () => true), + write: vi.fn(), + resize: vi.fn(), + shutdown: vi.fn(), + sendSignal: vi.fn(async () => undefined), + clearBuffer: vi.fn(async () => undefined), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + acknowledgeDataEvent, + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}), + listProcesses: vi.fn(async () => []), + attach: vi.fn(), + getDefaultShell: vi.fn(), + getProfiles: vi.fn() + } + let modelSequence = 0 + const runtime = { + setPtyController: vi.fn(), + setRemoteTerminalSourceRangeConsumerHooks: vi.fn(), + createPreAllocatedTerminalHandle: vi.fn(() => 'binding-fence-handle'), + registerPreAllocatedHandleForPty: vi.fn(), + registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + getDriver: vi.fn(() => ({ kind: 'host' })), + onPtySpawned: vi.fn(), + getPtyOutputSequence: vi.fn(() => modelSequence), + acceptPtyDataBounded: vi.fn((_id: string, _data: string, _at: number, rawLength: number) => { + modelSequence += rawLength + return { sequence: modelSequence, completion: Promise.resolve() } + }), + onPtyExit: vi.fn() + } + const publishSourceAck = vi.fn() + const sourceGeneration = 712_345 + const removeSourceAckPublisher = installSshPtySourceAckPublisher( + sourceGeneration, + (batch, onSettled) => { + publishSourceAck(batch) + onSettled({ ok: true }) + } + ) + setLocalPtyProvider(provider as never) + registerPtyHandlers(mainWindow as never, runtime as never) + mainWindow.webContents.send.mockClear() + const leafId = '33333333-3333-4333-8333-333333333333' + const spawn = handlers.get('pty:spawn')! + const baseArgs = { + cols: 80, + rows: 24, + env: {}, + worktreeId: 'worktree-binding-fence', + tabId: 'terminal-tab-binding-fence', + leafId, + tabGeneration: 12, + sessionId: 'reattach-binding-fence' + } + const staleClaimant = { rendererEpochId: 'renderer-binding-fence', sequence: 1 } + const staleSpawn = spawn(mainWindowIpcEvent, { + ...baseArgs, + mutationClaimant: staleClaimant + }) as Promise<{ mutationAccess?: unknown }> + await vi.waitFor(() => expect(provider.spawn).toHaveBeenCalledOnce()) + const currentClaimant = { rendererEpochId: 'renderer-binding-fence', sequence: 2 } + const currentSpawn = spawn(mainWindowIpcEvent, { + ...baseArgs, + mutationClaimant: currentClaimant + }) as Promise<{ + mutationAccess: { + mode: 'exact' + identity: { + incarnationId: string + paneGeneration: number + mutationLeaseId: string + } + claimant: { rendererEpochId: string; sequence: number } + } + }> + + const recoveredData = 'RECOVERED_EXACTLY_ONCE' + const currentIncarnationId = '44444444-4444-4444-8444-444444444444' + await acceptSshPtyOutputData({ + id: baseArgs.sessionId, + data: recoveredData, + providerGeneration: sourceGeneration, + ptyIncarnation: currentIncarnationId, + rawLength: recoveredData.length, + transformed: false, + source: { + relayPtyId: baseArgs.sessionId, + spanId: 'binding-fence-source:0:22', + clientGeneration: 7, + ownerGeneration: 8, + deliveryToken: 'binding-fence-source', + sourceStartSu: 0, + sourceEndSu: recoveredData.length + } + }) + expect(mainWindow.webContents.send).not.toHaveBeenCalledWith('pty:data', expect.anything()) + expect(acknowledgeDataEvent).not.toHaveBeenCalled() + expect(getPtyRendererDeliveryDebugSnapshot()).toEqual( + expect.objectContaining({ + pendingChars: recoveredData.length, + rendererInFlightChars: 0 + }) + ) + publishTerminalSideEffectBatchToRenderer({ + ptyId: baseArgs.sessionId, + ptyIncarnationId: '55555555-5555-4555-8555-555555555555', + seq: 1, + facts: [{ kind: 'bell' }] + }) + const currentSideEffect = { + ptyId: baseArgs.sessionId, + ptyIncarnationId: currentIncarnationId, + seq: 2, + facts: [{ kind: 'title' as const, normalizedTitle: 'ready', rawTitle: 'ready' }] + } + publishTerminalSideEffectBatchToRenderer(currentSideEffect) + expect(mainWindow.webContents.send).not.toHaveBeenCalledWith( + 'pty:sideEffect', + expect.anything() + ) + + resolveSpawn({ + id: baseArgs.sessionId, + incarnationId: currentIncarnationId, + isReattach: true + }) + const [staleResult, currentResult] = await Promise.all([staleSpawn, currentSpawn]) + expect(staleResult.mutationAccess).not.toEqual(currentResult.mutationAccess) + expect(currentResult.mutationAccess.claimant).toEqual(currentClaimant) + const exit = acceptSshPtyOutputExit({ + id: baseArgs.sessionId, + code: 0, + providerGeneration: sourceGeneration, + ptyIncarnation: currentIncarnationId + }) + await Promise.resolve() + expect(mainWindow.webContents.send).not.toHaveBeenCalledWith('pty:data', expect.anything()) + expect(getPtyRendererDeliveryDebugSnapshot()).toEqual( + expect.objectContaining({ + pendingChars: recoveredData.length, + rendererInFlightChars: 0 + }) + ) + expect(mainWindow.webContents.send).not.toHaveBeenCalledWith('pty:exit', expect.anything()) + + const listener = (channel: string): ((event: unknown, args: unknown) => void) => { + const registration = onMock.mock.calls.find((call: unknown[]) => call[0] === channel) + if (!registration) { + throw new Error(`missing ${channel} listener`) + } + return registration[1] as (event: unknown, args: unknown) => void + } + const staleAccess = { + ...currentResult.mutationAccess, + identity: { + ...currentResult.mutationAccess.identity, + mutationLeaseId: 'stale-renderer-binding-lease' + }, + claimant: staleClaimant + } + listener('pty:rendererBindingReady')(mainWindowIpcEvent, { + id: baseArgs.sessionId, + access: staleAccess + }) + listener('pty:cancelRendererBinding')(mainWindowIpcEvent, { + id: baseArgs.sessionId, + paneGeneration: baseArgs.tabGeneration, + claimant: staleClaimant + }) + expect(mainWindow.webContents.send).not.toHaveBeenCalledWith('pty:data', expect.anything()) + + listener('pty:rendererBindingReady')(mainWindowIpcEvent, { + id: baseArgs.sessionId, + access: currentResult.mutationAccess + }) + await Promise.resolve() + let orderedDelivery = mainWindow.webContents.send.mock.calls.filter( + ([channel]) => + channel === 'pty:sideEffect' || channel === 'pty:data' || channel === 'pty:exit' + ) + expect(orderedDelivery.map(([channel]) => channel)).toEqual(['pty:sideEffect', 'pty:data']) + expect(orderedDelivery[0]?.[1]).toEqual(currentSideEffect) + expect(orderedDelivery[1]?.[1]).toEqual( + expect.objectContaining({ id: baseArgs.sessionId, data: recoveredData }) + ) + expect(publishSourceAck).not.toHaveBeenCalled() + + getPtyAckDataListener()(null, { + id: baseArgs.sessionId, + processedChars: recoveredData.length + }) + await exit + await vi.waitFor(() => expect(publishSourceAck).toHaveBeenCalledOnce()) + orderedDelivery = mainWindow.webContents.send.mock.calls.filter( + ([channel]) => + channel === 'pty:sideEffect' || channel === 'pty:data' || channel === 'pty:exit' + ) + expect(orderedDelivery.map(([channel]) => channel)).toEqual([ + 'pty:sideEffect', + 'pty:data', + 'pty:exit' + ]) + expect(acknowledgeDataEvent).toHaveBeenCalledWith(baseArgs.sessionId, recoveredData.length) + removeSourceAckPublisher() + closeSshPtyOutputGeneration(sourceGeneration, 'test-complete') + }) + + it('replays only predecessor facts when a renderer binding is cancelled before finalize', async () => { + const id = 'renderer-binding-pre-finalize-cancel' + const predecessorIncarnation = '11111111-1111-4111-8111-111111111111' + const successorIncarnation = '22222222-2222-4222-8222-222222222222' + let resolveSuccessor!: (value: { id: string; incarnationId: string }) => void + const spawn = vi + .fn() + .mockResolvedValueOnce({ id, incarnationId: predecessorIncarnation }) + .mockImplementationOnce( + () => + new Promise<{ id: string; incarnationId: string }>((resolve) => { + resolveSuccessor = resolve + }) + ) + const provider = createExactRendererBindingProvider(spawn) + setLocalPtyProvider(provider as never) + registerPtyHandlers(mainWindow as never) + await handlers.get('pty:spawn')!(mainWindowIpcEvent, { + cols: 80, + rows: 24, + sessionId: id + }) + mainWindow.webContents.send.mockClear() + const claimant = { rendererEpochId: 'renderer-pre-finalize', sequence: 1 } + const pending = handlers.get('pty:spawn')!(mainWindowIpcEvent, { + cols: 80, + rows: 24, + sessionId: id, + tabGeneration: 2, + mutationClaimant: claimant + }) as Promise + await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2)) + + const predecessorBatch = { + ptyId: id, + ptyIncarnationId: predecessorIncarnation, + seq: 1, + facts: [{ kind: 'bell' as const }] + } + publishTerminalSideEffectBatchToRenderer(predecessorBatch) + publishTerminalSideEffectBatchToRenderer({ + ptyId: id, + ptyIncarnationId: successorIncarnation, + seq: 2, + facts: [{ kind: 'bell' }] + }) + const cancelRegistration = onMock.mock.calls.find( + (call: unknown[]) => call[0] === 'pty:cancelRendererBinding' + ) + if (!cancelRegistration) { + throw new Error('missing pty:cancelRendererBinding listener') + } + ;(cancelRegistration[1] as (event: unknown, value: unknown) => void)(mainWindowIpcEvent, { + id, + paneGeneration: 2, + claimant + }) + + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:sideEffect', predecessorBatch) + expect(mainWindow.webContents.send).not.toHaveBeenCalledWith( + 'pty:sideEffect', + expect.objectContaining({ ptyIncarnationId: successorIncarnation }) + ) + resolveSuccessor({ id, incarnationId: successorIncarnation }) + await pending + }) + + it('classifies facts unresolved when an exact finalized binding is cancelled', async () => { + const id = 'renderer-binding-exact-cancel' + const incarnationId = '33333333-3333-4333-8333-333333333333' + const retainedBatch = { + ptyId: id, + ptyIncarnationId: incarnationId, + seq: 1, + facts: [{ kind: 'bell' as const }] + } + const spawn = vi.fn(async () => { + publishTerminalSideEffectBatchToRenderer(retainedBatch) + return { id, incarnationId } + }) + setLocalPtyProvider(createExactRendererBindingProvider(spawn) as never) + registerPtyHandlers(mainWindow as never) + mainWindow.webContents.send.mockClear() + const result = (await handlers.get('pty:spawn')!(mainWindowIpcEvent, { + cols: 80, + rows: 24, + sessionId: id, + tabGeneration: 3, + mutationClaimant: { rendererEpochId: 'renderer-exact-cancel', sequence: 1 } + })) as { mutationAccess: unknown } + const cancelRegistration = onMock.mock.calls.find( + (call: unknown[]) => call[0] === 'pty:cancelRendererBinding' + ) + if (!cancelRegistration) { + throw new Error('missing pty:cancelRendererBinding listener') + } + ;(cancelRegistration[1] as (event: unknown, value: unknown) => void)(mainWindowIpcEvent, { + id, + access: result.mutationAccess + }) + + expect(mainWindow.webContents.send).not.toHaveBeenCalledWith('pty:sideEffect', retainedBatch) + expect(mainWindow.webContents.send).toHaveBeenCalledWith( + 'pty:modelRestoreNeeded', + expect.objectContaining({ id, reason: 'pending-cap' }) + ) + }) + + it('holds the exact producer through replay and releases only after delivery', async () => { + const id = 'renderer-binding-held-pause' + const incarnationId = '77777777-7777-4777-8777-777777777777' + const replayedBatch = { + ptyId: id, + ptyIncarnationId: incarnationId, + seq: 1, + facts: [{ kind: 'bell' as const }] + } + const spawn = vi.fn(async () => { + if (spawn.mock.calls.length === 2) { + publishTerminalSideEffectBatchToRenderer(replayedBatch) + } + return { id, incarnationId, isReattach: true } + }) + const provider = { + ...createExactRendererBindingProvider(spawn), + supportsExactHeldProducerPause: vi.fn( + (_id: string, expectedIncarnationId: string) => expectedIncarnationId === incarnationId + ), + acquireExactHeldProducerPause: vi.fn(async () => true), + releaseExactHeldProducerPause: vi.fn(async () => true) + } + setLocalPtyProvider(provider as never) + registerPtyHandlers(mainWindow as never) + await handlers.get('pty:spawn')!(mainWindowIpcEvent, { + cols: 80, + rows: 24, + sessionId: id + }) + mainWindow.webContents.send.mockClear() + + const result = (await handlers.get('pty:spawn')!(mainWindowIpcEvent, { + cols: 80, + rows: 24, + sessionId: id, + tabGeneration: 7, + mutationClaimant: { rendererEpochId: 'renderer-held-pause', sequence: 1 } + })) as { mutationAccess: unknown } + + expect(provider.acquireExactHeldProducerPause).toHaveBeenCalledOnce() + expect(provider.releaseExactHeldProducerPause).not.toHaveBeenCalled() + expect(mainWindow.webContents.send).not.toHaveBeenCalledWith('pty:sideEffect', replayedBatch) + + const readyRegistration = onMock.mock.calls.find( + (call: unknown[]) => call[0] === 'pty:rendererBindingReady' + ) + if (!readyRegistration) { + throw new Error('missing pty:rendererBindingReady listener') + } + ;(readyRegistration[1] as (event: unknown, value: unknown) => void)(mainWindowIpcEvent, { + id, + access: result.mutationAccess + }) + await vi.waitFor(() => expect(provider.releaseExactHeldProducerPause).toHaveBeenCalledOnce()) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:sideEffect', replayedBatch) + expect( + mainWindow.webContents.send.mock.invocationCallOrder.find( + (_, index) => mainWindow.webContents.send.mock.calls[index]?.[0] === 'pty:sideEffect' + ) + ).toBeLessThan(provider.releaseExactHeldProducerPause.mock.invocationCallOrder[0]!) + }) + + it('bounds fenced facts, pauses before overflow, and resumes with an explicit restore marker', async () => { + const id = 'renderer-binding-side-effect-overflow' + const incarnationId = '44444444-4444-4444-8444-444444444444' + let resolveSpawn!: (value: { id: string; incarnationId: string }) => void + const spawn = vi.fn( + () => + new Promise<{ id: string; incarnationId: string }>((resolve) => { + resolveSpawn = resolve + }) + ) + const provider = createExactRendererBindingProvider(spawn) + setLocalPtyProvider(provider as never) + registerPtyHandlers(mainWindow as never) + mainWindow.webContents.send.mockClear() + const pending = handlers.get('pty:spawn')!(mainWindowIpcEvent, { + cols: 80, + rows: 24, + sessionId: id, + tabGeneration: 4, + mutationClaimant: { rendererEpochId: 'renderer-overflow', sequence: 1 } + }) as Promise<{ mutationAccess: unknown }> + await vi.waitFor(() => expect(spawn).toHaveBeenCalledOnce()) + + for (let seq = 0; seq <= PTY_RENDERER_BINDING_SIDE_EFFECT_MAX_BATCHES; seq += 1) { + publishTerminalSideEffectBatchToRenderer({ + ptyId: id, + ptyIncarnationId: incarnationId, + seq, + facts: [{ kind: 'bell' }] + }) + } + expect(provider.pauseProducer).toHaveBeenCalledOnce() + expect(provider.pauseProducer).toHaveBeenCalledWith(id) + expect(PTY_RENDERER_BINDING_SIDE_EFFECT_BACKPRESSURE_BATCHES).toBeLessThan( + PTY_RENDERER_BINDING_SIDE_EFFECT_MAX_BATCHES + ) + + resolveSpawn({ id, incarnationId }) + const result = await pending + const readyRegistration = onMock.mock.calls.find( + (call: unknown[]) => call[0] === 'pty:rendererBindingReady' + ) + if (!readyRegistration) { + throw new Error('missing pty:rendererBindingReady listener') + } + ;(readyRegistration[1] as (event: unknown, value: unknown) => void)(mainWindowIpcEvent, { + id, + access: result.mutationAccess + }) + + expect( + mainWindow.webContents.send.mock.calls.filter(([channel]) => channel === 'pty:sideEffect') + ).toHaveLength(PTY_RENDERER_BINDING_SIDE_EFFECT_MAX_BATCHES) + expect(mainWindow.webContents.send).toHaveBeenCalledWith( + 'pty:modelRestoreNeeded', + expect.objectContaining({ id, reason: 'pending-cap' }) + ) + expect(provider.resumeProducer).toHaveBeenCalledOnce() + expect(provider.resumeProducer).toHaveBeenCalledWith(id) + }) + + it('abandons retained facts on renderer reset and accepts a fresh remount binding', async () => { + const id = 'renderer-binding-remount' + const incarnationId = '55555555-5555-4555-8555-555555555555' + let resolveFirst!: (value: { id: string; incarnationId: string }) => void + const remountBatch = { + ptyId: id, + ptyIncarnationId: incarnationId, + seq: 2, + facts: [{ kind: 'bell' as const }] + } + const spawn = vi + .fn() + .mockImplementationOnce( + () => + new Promise<{ id: string; incarnationId: string }>((resolve) => { + resolveFirst = resolve + }) + ) + .mockImplementationOnce(async () => { + publishTerminalSideEffectBatchToRenderer(remountBatch) + return { id, incarnationId } + }) + setLocalPtyProvider(createExactRendererBindingProvider(spawn) as never) + registerPtyHandlers(mainWindow as never) + const first = handlers.get('pty:spawn')!(mainWindowIpcEvent, { + cols: 80, + rows: 24, + sessionId: id, + tabGeneration: 5, + mutationClaimant: { rendererEpochId: 'renderer-before-reset', sequence: 1 } + }) as Promise + await vi.waitFor(() => expect(spawn).toHaveBeenCalledOnce()) + publishTerminalSideEffectBatchToRenderer({ ...remountBatch, seq: 1 }) + const navigationRegistration = mainWindow.webContents.on.mock.calls.find( + ([channel]) => channel === 'did-start-navigation' + ) + if (!navigationRegistration) { + throw new Error('missing did-start-navigation listener') + } + ;( + navigationRegistration[1] as (details: { + isMainFrame: boolean + isSameDocument: boolean + }) => void + )({ isMainFrame: true, isSameDocument: false }) + resolveFirst({ id, incarnationId }) + await first + expect(mainWindow.webContents.send).not.toHaveBeenCalledWith( + 'pty:sideEffect', + expect.objectContaining({ ptyId: id }) + ) + + registerPtyHandlers(mainWindow as never) + mainWindow.webContents.send.mockClear() + const remount = (await handlers.get('pty:spawn')!(mainWindowIpcEvent, { + cols: 80, + rows: 24, + sessionId: id, + tabGeneration: 5, + mutationClaimant: { rendererEpochId: 'renderer-after-reset', sequence: 1 } + })) as { mutationAccess: unknown } + const readyRegistration = onMock.mock.calls + .toReversed() + .find((call: unknown[]) => call[0] === 'pty:rendererBindingReady') + if (!readyRegistration) { + throw new Error('missing pty:rendererBindingReady listener') + } + ;(readyRegistration[1] as (event: unknown, value: unknown) => void)(mainWindowIpcEvent, { + id, + access: remount.mutationAccess + }) + + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:sideEffect', remountBatch) }) it('returns idle process inspection results for detached SSH PTYs without a provider', async () => { @@ -7720,6 +9203,7 @@ describe('registerPtyHandlers', () => { it('lets trusted host activity reclaim remote viewport ownership', () => { const claimRemoteDesktopHost = vi.fn().mockResolvedValue(true) + setupProviderWithAppliedSize({ applied: { cols: 80, rows: 24 } }) const runtime = { setPtyController: vi.fn(), claimRemoteDesktopHost @@ -7734,7 +9218,12 @@ describe('registerPtyHandlers', () => { claimListener?.(mainWindowIpcEvent, { id: 'pty-1', cols: 125, rows: 48 }) - expect(claimRemoteDesktopHost).toHaveBeenCalledWith('pty-1', 125, 48) + expect(claimRemoteDesktopHost).toHaveBeenCalledWith( + 'pty-1', + 125, + 48, + expect.objectContaining({ id: 'pty-1', access: { mode: 'legacy' } }) + ) }) it('does not forward host input when viewport reclaim fails', async () => { @@ -8718,7 +10207,10 @@ describe('registerPtyHandlers', () => { await vi.waitFor(() => expect(providerSpawn).toHaveBeenCalledTimes(1)) resolveSpawn({ id: 'pty-renderer' }) const [rendererResult, runtimeResult] = await Promise.all([rendererSpawn, runtimeSpawn]) - expect(rendererResult).toEqual({ id: 'pty-renderer' }) + expect(rendererResult).toEqual({ + id: 'pty-renderer', + administrativeMutationAccess: { mode: 'legacy', providerRouteId: expect.any(String) } + }) expect(runtimeResult).toEqual({ id: 'pty-renderer', stablePaneOwner: { @@ -9091,6 +10583,7 @@ describe('registerPtyHandlers', () => { worktreeId, tabId, leafId, + tabGeneration: 9, env: { ORCA_PANE_KEY: paneKey, ORCA_TAB_ID: tabId, @@ -9112,6 +10605,10 @@ describe('registerPtyHandlers', () => { sessionId: 'pty-persisted-owner', expectedIncarnationId: 'inc-stale-owner', expectedIncarnationIsAuthoritative: false, + paneKey, + worktreeId, + paneGeneration: 9, + tabId, command: undefined }) ) @@ -10396,8 +11893,23 @@ describe('registerPtyHandlers', () => { persistHostSessionBinding?: boolean }): Promise<{ id: string; isReattach?: boolean }> } + const scopedPtyId = 'ssh:ssh-reattach-ok@@relay-pty' + const terminalSessionAuthorityAccess = { + namespace: { authorityHostId: 'host-1', namespaceId: 'namespace-1' }, + pane: { paneKey: 'pane-1', paneGenerationId: 'renderer:1' }, + binding: { + ownerIncarnationId: 'owner-1', + physicalPtyId: 'relay-pty', + ptyIncarnationId: 'incarnation-runtime-current' + } + } + const remoteSpawn = vi.fn(async () => ({ + id: scopedPtyId, + isReattach: true, + terminalSessionAuthorityAccess + })) registerSshPtyProvider('ssh-reattach-ok', { - spawn: vi.fn(async () => ({ id: 'ssh:ssh-reattach-ok@@relay-pty', isReattach: true })), + spawn: remoteSpawn, write: vi.fn(), resize: vi.fn(), shutdown: vi.fn(), @@ -10438,6 +11950,7 @@ describe('registerPtyHandlers', () => { } try { + restorePtyIncarnation(scopedPtyId, 'incarnation-runtime-current') registerPtyHandlers( mainWindow as never, runtime as never, @@ -10455,10 +11968,18 @@ describe('registerPtyHandlers', () => { worktreeId: 'wt-remote', tabId: 'tab-remote', leafId, - sessionId: 'ssh:ssh-reattach-ok@@relay-pty', + sessionId: scopedPtyId, persistHostSessionBinding: true }) + expect(remoteSpawn).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: scopedPtyId, + expectedIncarnationId: 'incarnation-runtime-current', + expectedIncarnationIsAuthoritative: true + }) + ) + expect(store.persistPtyBinding).toHaveBeenCalledWith( { worktreeId: 'wt-remote', @@ -10472,11 +11993,13 @@ describe('registerPtyHandlers', () => { expect.objectContaining({ targetId: 'ssh-reattach-ok', ptyId: 'relay-pty', + terminalSessionAuthorityAccess, state: 'attached', lastAttachedAt: expect.any(Number) }) ) } finally { + clearProviderPtyState(scopedPtyId) unregisterSshPtyProvider('ssh-reattach-ok') } }) @@ -13995,6 +15518,15 @@ describe('registerPtyHandlers', () => { pendingPtyCount: 1, pendingChars: 0 }) + expect(runtime.acceptPtyDataBounded).toHaveBeenLastCalledWith( + 'source-credit-pty', + sourceChunk, + expect.any(Number), + sourceChunk.length, + false, + [expect.objectContaining({ ptyIncarnation: 'source-incarnation' })], + 'source-incarnation' + ) expect(provider.pauseProducer).not.toHaveBeenCalledWith('source-credit-pty') expect(provider.resumeProducer).not.toHaveBeenCalledWith('source-credit-pty') @@ -14130,8 +15662,7 @@ describe('registerPtyHandlers', () => { { id, code: 0, - providerGeneration: 51, - ptyIncarnation: 'incarnation-51' + incarnationId: 'incarnation-51' } ]) }) @@ -15643,6 +17174,9 @@ describe('registerPtyHandlers', () => { 'hidden output', expect.any(Number), 'hidden output'.length, + undefined, + undefined, + undefined, undefined ) expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) @@ -16278,6 +17812,9 @@ describe('registerPtyHandlers', () => { 'pre-spawn prompt\x1b[c', expect.any(Number), 'pre-spawn prompt\x1b[c'.length, + undefined, + undefined, + undefined, undefined ) expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) @@ -16532,17 +18069,29 @@ describe('registerPtyHandlers', () => { process.env.SHELL = '/opt/homebrew/bin/bash' registerPtyHandlers(mainWindow as never) - const result = await handlers.get('pty:spawn')!(null, { + const result = (await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, cwd: '/tmp', worktreeId: 'repo-1::/tmp' - }) + })) as { + id: string + pid: number + incarnationId: string + administrativeMutationAccess: { + mode: 'legacy' + providerRouteId: string + } + } expect(result).toEqual({ id: expect.any(String), pid: 12345, - incarnationId: expect.any(String) + incarnationId: expect.any(String), + administrativeMutationAccess: { + mode: 'legacy', + providerRouteId: expect.any(String) + } }) expect(spawnMock).toHaveBeenCalledTimes(1) expect(spawnMock).toHaveBeenCalledWith( diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 5d858cac7f3..1049479d07b 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -19,6 +19,7 @@ import type { GlobalSettings, TuiAgent } from '../../shared/types' import { toSshExecutionHostId } from '../../shared/execution-host' import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' import { terminalOutputBacklogCapChars } from '../../shared/terminal-scrollback-policy' +import type { TerminalSideEffectBatch } from '../../shared/terminal-side-effect-facts' import type { PtyDeliveryWriteOff, PtyRendererDeliveryHealthReply, @@ -68,6 +69,48 @@ import { import { isPwshAvailable } from '../pwsh' import { LocalPtyProvider } from '../providers/local-pty-provider' import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types' +import { + clearPtyMutation, + killPtyMutation, + resizePtyMutation, + resolveAdministrativePtyMutationDispatch, + resolvePtyMutationMode, + resolveRendererPtyMutationDispatch, + signalPtyMutation, + writePtyMutation, + type PtyMutationDispatch +} from '../providers/pty-mutation-dispatch' +import type { PtyMutationTarget } from '../providers/pty-mutation-target' +import { + parsePtyAdministrativeMutationAccess, + parsePtyMutationAccess, + parsePtyMutationClaimant, + parsePtyMutationIdentity, + ptyMutationIdentitiesEqual, + ptyMutationIdentityMatchesAdministrativeEvidence, + toPtyAdministrativeMutationEvidence, + type PtyAdministrativeMutationAccess, + type PtyAdministrativeMutationEvidence, + type PtyMutationAccess, + type PtyMutationClaimant, + type PtyMutationIdentity +} from '../../shared/pty-mutation-identity' +import type { + ExactPtyMutationAccess, + PtyRendererBindingCancel, + PtyRendererBindingReady +} from '../../shared/pty-renderer-binding' +import { + PtyRendererBindingFenceRegistry, + type PtyRendererBindingFenceToken, + type PtyRendererBindingSideEffectRetention +} from './pty-renderer-binding-fence' +import { + PtyRendererBindingHeldPauseController, + type PtyRendererBindingHeldPauseLease +} from './pty-renderer-binding-held-pause' +import { PtyRendererSideEffectDelivery } from './pty-renderer-side-effect-delivery' +import { registerPtyAuthorityOutcomeIpc } from './pty-authority-outcome-ipc' import { isPtyWriteUnavailableError } from '../providers/pty-write-unavailable-error' import { inspectPtyProviderProcess, @@ -83,6 +126,8 @@ import { isSshPtyIdentityMismatchError, isSshPtyNotFoundError } from '../providers/ssh-pty-errors' +import { prepareSshTerminalAuthorityExitWait } from '../ssh/ssh-terminal-authority-exit-wait' +import { terminateListedSshTerminalSessions } from '../ssh/ssh-terminal-session-termination' import { parseAppSshPtyId, toAppSshPtyId, toRelaySshPtyId } from '../providers/ssh-pty-id' import { createPtySpawnTiming } from './pty-spawn-timing' import { @@ -150,7 +195,10 @@ import { import { parseWslPath } from '../wsl' import { mergePersistedWindowsPath, resolvePathEnvKey } from '../pty/windows-environment-path' import { addOrcaWslInteropEnv, stampWslOrchestrationCompatibilityHost } from '../pty/wsl-orca-env' -import { PtyProducerFlowController } from './pty-producer-flow-control' +import { + PRODUCER_FLOW_HIGH_WATERMARK_CHARS, + PtyProducerFlowController +} from './pty-producer-flow-control' import { beginTerminalInstall } from './watcher-removal-gate' import { clearHiddenRendererPtyDeliveryState, @@ -223,6 +271,10 @@ import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { resolveLocalProjectRuntimeForWorktreeId } from '../local-project-runtime-resolution' import { isPtyIncarnationId } from '../../shared/pty-incarnation' import type { PtyListedSession } from '../../shared/pty-listed-session' +import { + parseTerminalSessionAuthorityPtyAccess, + sameTerminalSessionAuthorityPtyAccess +} from '../../shared/terminal-session-authority-pty-access' // ─── Provider Registry ────────────────────────────────────────────── // Routes PTY operations by connectionId (null = local provider). @@ -230,6 +282,21 @@ import type { PtyListedSession } from '../../shared/pty-listed-session' let localProvider: IPtyProvider = new LocalPtyProvider() const sshProviders = new Map() const sshProvidersByGeneration = new Map() +const ptyProviderRouteIds = new WeakMap() + +type PendingExactPtyCloseReplayHook = (args: { + connectionId: string + provider: IPtyProvider + isCurrent: () => boolean +}) => Promise + +type PendingExactPtyCloseReplay = { + provider: IPtyProvider + completion: Promise +} + +let pendingExactPtyCloseReplayHook: PendingExactPtyCloseReplayHook = async () => {} +const pendingExactPtyCloseReplays = new Map() type RegisteredPtyProvider = { provider: IPtyProvider @@ -243,17 +310,371 @@ function registeredPtyProviders(): RegisteredPtyProvider[] { ] } +function isCurrentRegisteredPtyProvider(entry: RegisteredPtyProvider): boolean { + return entry.connectionId === null + ? localProvider === entry.provider + : sshProviders.get(entry.connectionId) === entry.provider +} + +function ptyProviderRouteId(provider: IPtyProvider): string { + const current = ptyProviderRouteIds.get(provider) + if (current) { + return current + } + const routeId = randomUUID() + ptyProviderRouteIds.set(provider, routeId) + return routeId +} + const SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS = 30_000 // Why: kill switch — flip to disable producer flow control (pause/resume) without untangling the wiring. const PRODUCER_FLOW_CONTROL_ENABLED = true // Why: post-spawn write/resize/kill calls carry only the PTY ID; map it to its connectionId so ops route to the right provider. const ptyOwnership = new Map() const ptyIncarnationById = new Map() +const ptyMutationIdentityById = new Map() +const ptyMutationClaimantById = new Map() +const ptyMutationLifecycleTokenById = new Map() +const ptyRendererBindingFences = new PtyRendererBindingFenceRegistry() +let routeRendererBindingHeldPauseReleaseFailure = ( + _lease: PtyRendererBindingHeldPauseLease, + _error?: unknown +): void => {} +const ptyRendererBindingHeldPauses = new PtyRendererBindingHeldPauseController({ + onReleaseFailure: (lease, error) => routeRendererBindingHeldPauseReleaseFailure(lease, error) +}) +type PtyRendererExit = { id: string; code: number; incarnationId?: string } +const pendingRendererBindingExits = new Map() +const syntheticPtyExits = new Map() +let routeTerminalSideEffectsToRenderer = (batch: TerminalSideEffectBatch): void => { + void batch +} +let releaseRendererBindingSideEffectPressure = (id: string): void => { + void id +} + +export function publishTerminalSideEffectBatchToRenderer(batch: TerminalSideEffectBatch): void { + routeTerminalSideEffectsToRenderer(batch) +} + +function parsePaneGeneration(value: unknown): number | undefined { + return Number.isSafeInteger(value) && (value as number) >= 0 ? (value as number) : undefined +} + +function rememberPtyMutationIdentity( + id: string, + incarnationId: string | undefined, + paneGeneration?: number, + claimant?: PtyMutationClaimant +): PtyMutationIdentity | undefined { + const current = ptyMutationIdentityById.get(id) + if ( + !ptyMutationLifecycleTokenById.has(id) || + (incarnationId !== undefined && current?.incarnationId !== incarnationId) + ) { + ptyMutationLifecycleTokenById.set(id, Object.freeze({})) + } + if (!incarnationId) { + ptyMutationIdentityById.delete(id) + ptyMutationClaimantById.delete(id) + return undefined + } + const identity = { + incarnationId, + ...(paneGeneration !== undefined ? { paneGeneration } : {}), + ...(claimant ? { mutationLeaseId: randomUUID() } : {}) + } + ptyMutationIdentityById.set(id, identity) + if (claimant) { + ptyMutationClaimantById.set(id, claimant) + } else { + ptyMutationClaimantById.delete(id) + } + return identity +} + +function restorePtyMutationIncarnation(id: string, incarnationId: string): void { + const current = ptyMutationIdentityById.get(id) + if (!ptyMutationLifecycleTokenById.has(id) || current?.incarnationId !== incarnationId) { + ptyMutationLifecycleTokenById.set(id, Object.freeze({})) + } + if (current?.incarnationId === incarnationId) { + return + } + ptyMutationIdentityById.set(id, { + incarnationId, + ...(current?.paneGeneration !== undefined ? { paneGeneration: current.paneGeneration } : {}), + ...(current?.mutationLeaseId ? { mutationLeaseId: randomUUID() } : {}) + }) +} + +function ptyMutationAccess(provider: IPtyProvider, id: string): PtyMutationAccess { + const mode = resolvePtyMutationMode(provider, id) + if (mode === 'legacy') { + return { mode } + } + const identity = ptyMutationIdentityById.get(id) + const claimant = ptyMutationClaimantById.get(id) + return mode === 'exact' && identity?.mutationLeaseId && claimant + ? { mode, identity, claimant } + : { mode: 'unavailable' } +} + +function ptyAdministrativeMutationAccess( + id: string, + expectedProvider?: IPtyProvider +): PtyAdministrativeMutationAccess { + const provider = tryGetProviderForPty(id) + if (!provider || (expectedProvider && provider !== expectedProvider)) { + return { mode: 'unavailable' } + } + const providerRouteId = ptyProviderRouteId(provider) + const mode = resolvePtyMutationMode(provider, id) + if (mode === 'legacy') { + return { mode, providerRouteId } + } + const identity = ptyMutationIdentityById.get(id) + if (mode === 'exact' && identity) { + return { + mode: 'exact', + evidence: toPtyAdministrativeMutationEvidence(identity), + providerRouteId + } + } + return { mode: 'unavailable', providerRouteId } +} + +function administrativeAccessMatchesProvider( + access: PtyAdministrativeMutationAccess, + provider: IPtyProvider +): boolean { + return ( + access.providerRouteId === undefined || access.providerRouteId === ptyProviderRouteId(provider) + ) +} + +function resolveRegisteredRendererPtyMutationDispatch(args: { + provider: IPtyProvider + id: string + requestedIdentity: PtyMutationIdentity | null + administrativeCurrent?: boolean + administrativeEvidence?: PtyAdministrativeMutationEvidence | null +}) { + if (args.administrativeCurrent || args.administrativeEvidence !== undefined) { + return resolveAdministrativePtyMutationDispatch({ + provider: args.provider, + id: args.id, + currentIdentity: ptyMutationIdentityById.get(args.id), + ...(args.administrativeEvidence !== undefined + ? { requestedEvidence: args.administrativeEvidence } + : {}) + }) + } + return resolveRendererPtyMutationDispatch({ + ...args, + currentIdentity: ptyMutationIdentityById.get(args.id) + }) +} + +function providerPtyMutationRouteToken(provider: IPtyProvider, id: string): object | null { + return provider.getPtyMutationRouteToken ? provider.getPtyMutationRouteToken(id) : provider +} + +function capturedPtyMutationLifecycleToken( + provider: IPtyProvider, + id: string, + legacy: boolean +): object | null { + return ( + ptyMutationLifecycleTokenById.get(id) ?? + (legacy ? providerPtyMutationRouteToken(provider, id) : null) + ) +} + +function unavailablePtyMutationTarget(id: string, provider?: IPtyProvider): PtyMutationTarget { + return Object.freeze({ + id, + providerRouteToken: provider ? providerPtyMutationRouteToken(provider, id) : null, + ptyLifecycleToken: ptyMutationLifecycleTokenById.get(id) ?? null, + access: Object.freeze({ mode: 'unavailable' as const }) + }) +} + +function captureRuntimePtyMutationTarget(id: string): PtyMutationTarget { + const provider = tryGetProviderForPty(id) + if (!provider) { + return unavailablePtyMutationTarget(id) + } + const providerRouteToken = providerPtyMutationRouteToken(provider, id) + if (!providerRouteToken) { + return unavailablePtyMutationTarget(id, provider) + } + const mode = resolvePtyMutationMode(provider, id) + const ptyLifecycleToken = capturedPtyMutationLifecycleToken(provider, id, mode === 'legacy') + if (!ptyLifecycleToken) { + return unavailablePtyMutationTarget(id, provider) + } + if (mode === 'legacy') { + return Object.freeze({ + id, + providerRouteToken, + ptyLifecycleToken, + access: Object.freeze({ mode }) + }) + } + const identity = ptyMutationIdentityById.get(id) + if (mode !== 'exact' || !identity) { + return unavailablePtyMutationTarget(id, provider) + } + const authorityAccess = provider.getTerminalSessionAuthorityAccess?.(id) ?? null + if (!authorityAccess) { + return unavailablePtyMutationTarget(id, provider) + } + return Object.freeze({ + id, + providerRouteToken, + ptyLifecycleToken, + access: Object.freeze({ + mode: 'runtime-exact' as const, + evidence: Object.freeze(toPtyAdministrativeMutationEvidence(identity)), + authorityAccess + }) + }) +} + +function captureRendererPtyMutationTarget( + provider: IPtyProvider, + id: string, + dispatch: PtyMutationDispatch +): PtyMutationTarget { + const providerRouteToken = providerPtyMutationRouteToken(provider, id) + const ptyLifecycleToken = capturedPtyMutationLifecycleToken( + provider, + id, + dispatch.mode === 'legacy' + ) + if (!providerRouteToken || !ptyLifecycleToken || dispatch.mode === 'rejected') { + return unavailablePtyMutationTarget(id, provider) + } + return Object.freeze({ + id, + providerRouteToken, + ptyLifecycleToken, + access: Object.freeze( + dispatch.mode === 'legacy' + ? { mode: 'legacy' as const } + : { + mode: 'renderer-exact' as const, + identity: dispatch.identity, + authorityAccess: dispatch.authorityAccess + } + ) + }) +} + +function captureAdministrativePtyMutationTarget( + provider: IPtyProvider, + id: string, + dispatch: PtyMutationDispatch +): PtyMutationTarget { + const providerRouteToken = providerPtyMutationRouteToken(provider, id) + const ptyLifecycleToken = capturedPtyMutationLifecycleToken( + provider, + id, + dispatch.mode === 'legacy' + ) + if (!providerRouteToken || !ptyLifecycleToken || dispatch.mode === 'rejected') { + return unavailablePtyMutationTarget(id, provider) + } + return Object.freeze({ + id, + providerRouteToken, + ptyLifecycleToken, + access: Object.freeze( + dispatch.mode === 'legacy' + ? { mode: 'legacy' as const } + : { + mode: 'runtime-exact' as const, + evidence: Object.freeze(toPtyAdministrativeMutationEvidence(dispatch.identity)), + authorityAccess: dispatch.authorityAccess + } + ) + }) +} + +function resolveCapturedPtyMutationDispatch( + provider: IPtyProvider, + id: string, + target: PtyMutationTarget +): PtyMutationDispatch { + if ( + target.id !== id || + target.providerRouteToken === null || + target.providerRouteToken !== providerPtyMutationRouteToken(provider, id) || + target.ptyLifecycleToken === null || + target.ptyLifecycleToken !== + capturedPtyMutationLifecycleToken(provider, id, target.access.mode === 'legacy') + ) { + return { mode: 'rejected' } + } + if (target.access.mode === 'legacy') { + return resolvePtyMutationMode(provider, id) === 'legacy' + ? { mode: 'legacy' } + : { mode: 'rejected' } + } + if (target.access.mode === 'renderer-exact') { + return resolveRendererPtyMutationDispatch({ + provider, + id, + currentIdentity: ptyMutationIdentityById.get(id), + requestedIdentity: target.access.identity, + requestedAuthorityAccess: target.access.authorityAccess + }) + } + if (target.access.mode === 'runtime-exact') { + return resolveAdministrativePtyMutationDispatch({ + provider, + id, + currentIdentity: ptyMutationIdentityById.get(id), + requestedEvidence: target.access.evidence, + requestedAuthorityAccess: target.access.authorityAccess + }) + } + return { mode: 'rejected' } +} + +function isCurrentCapturedPtyMutationTarget( + provider: IPtyProvider, + id: string, + target: PtyMutationTarget +): boolean { + return resolveCapturedPtyMutationDispatch(provider, id, target).mode !== 'rejected' +} export function isCurrentPtyExit(payload: { id: string; incarnationId?: string }): boolean { + const syntheticKey = `${payload.id}\0${payload.incarnationId ?? ''}` + const syntheticCleanup = syntheticPtyExits.get(syntheticKey) + if (syntheticCleanup) { + clearTimeout(syntheticCleanup) + syntheticPtyExits.delete(syntheticKey) + return false + } const current = ptyIncarnationById.get(payload.id) return !current || payload.incarnationId === current } + +function rememberSyntheticKillExit(id: string, incarnationId?: string): void { + const key = `${id}\0${incarnationId ?? ''}` + const existing = syntheticPtyExits.get(key) + if (existing) { + clearTimeout(existing) + } + const cleanupTimer = setTimeout(() => { + syntheticPtyExits.delete(key) + }, SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS) + cleanupTimer.unref?.() + syntheticPtyExits.set(key, cleanupTimer) +} // Why: mobile clients must mirror desktop PTY geometry even before the renderer can provide an xterm snapshot (e.g. right after tab creation). const ptySizes = new Map() // Why: the "recent user input" signal is PTY-scoped and must be cleared by every teardown path, incl. SSH/daemon shutdowns that skip the local exit listener. @@ -424,8 +845,9 @@ async function reconcileAgentSessionOwnerListings(): Promise { } }) for (const session of advertisedOwnerSessions) { - ptyOwnership.set(session.id, session.connectionId) + setPtyOwnership(session.id, session.connectionId) ptyIncarnationById.set(session.id, session.incarnationId) + restorePtyMutationIncarnation(session.id, session.incarnationId) } })() agentSessionOwnerReconciliation = reconciliation @@ -470,6 +892,10 @@ function rememberPaneKeyForPty(ptyId: string, paneKey: unknown): string | null { if (!isValidPaneKey(normalizedPaneKey)) { return null } + const priorPaneKey = ptyPaneKey.get(ptyId) + if (priorPaneKey && paneKeyPtyId.get(priorPaneKey) === ptyId) { + paneKeyPtyId.delete(priorPaneKey) + } ptyPaneKey.set(ptyId, normalizedPaneKey) paneKeyPtyId.set(normalizedPaneKey, ptyId) return normalizedPaneKey @@ -603,6 +1029,8 @@ type StablePaneOwner = { hasPersistedBinding?: true persistedIncarnationId?: string runtimeIncarnationId?: string + leaseIncarnationId?: string + authorityPaneGeneration?: number } type StablePaneAdoption = { result: PtySpawnResult @@ -611,6 +1039,26 @@ type StablePaneAdoption = { } | null const stablePaneAdoptionsByOwnerKey = new Map>() +function applyExpectedSessionIncarnation( + spawnOptions: PtySpawnOptions, + effectiveSessionAppId: string | undefined, + owner: StablePaneOwner | null +): void { + if (!spawnOptions.sessionId) { + return + } + const currentIncarnationId = + owner?.runtimeIncarnationId ?? + (effectiveSessionAppId ? ptyIncarnationById.get(effectiveSessionAppId) : undefined) + const expectedIncarnationId = + currentIncarnationId ?? owner?.leaseIncarnationId ?? owner?.persistedIncarnationId + if (!expectedIncarnationId) { + return + } + spawnOptions.expectedIncarnationId = expectedIncarnationId + spawnOptions.expectedIncarnationIsAuthoritative = currentIncarnationId !== undefined +} + function resolvePersistedStablePaneOwner( store: Store | undefined, paneKey: string, @@ -643,6 +1091,33 @@ function resolvePersistedStablePaneOwner( } } +function resolveStablePaneSshLease( + store: Store | undefined, + connectionId: string | null | undefined, + ptyId: string, + worktreeId: string, + tabId: string, + leafId: string +): ReturnType[number] | null { + if (!store || !connectionId || typeof store.getSshRemotePtyLeases !== 'function') { + return null + } + const relayPtyId = getRelayPtyId(connectionId, ptyId) + return ( + store + .getSshRemotePtyLeases(connectionId) + .find( + (lease) => + lease.ptyId === relayPtyId && + lease.worktreeId === worktreeId && + lease.tabId === tabId && + lease.leafId === leafId && + lease.state !== 'terminated' && + lease.state !== 'expired' + ) ?? null + ) +} + function resolveStablePaneOwner( runtime: OrcaRuntimeService | undefined, store: Store | undefined, @@ -685,17 +1160,31 @@ function resolveStablePaneOwner( if (!parsed) { return null } + const sshLease = resolveStablePaneSshLease( + store, + connectionId, + ptyId, + worktreeId, + parsed.tabId, + parsed.leafId + ) return { ...(resolvedHandleCandidate?.ptyId === ptyId ? { handle: resolvedHandleCandidate.handle } : {}), tabId: resolved?.tabId || persisted?.tabId || parsed.tabId, leafId: resolved?.leafId || persisted?.leafId || parsed.leafId, ptyId, - ...(runtimeIncarnationId || persisted?.incarnationId - ? { incarnationId: runtimeIncarnationId ?? persisted?.incarnationId } + ...(runtimeIncarnationId || sshLease?.incarnationId || persisted?.incarnationId + ? { + incarnationId: runtimeIncarnationId ?? sshLease?.incarnationId ?? persisted?.incarnationId + } : {}), ...(persisted ? { hasPersistedBinding: true as const } : {}), ...(persisted?.incarnationId ? { persistedIncarnationId: persisted.incarnationId } : {}), - ...(runtimeIncarnationId ? { runtimeIncarnationId } : {}) + ...(runtimeIncarnationId ? { runtimeIncarnationId } : {}), + ...(sshLease?.incarnationId ? { leaseIncarnationId: sshLease.incarnationId } : {}), + ...(sshLease?.paneGeneration !== undefined + ? { authorityPaneGeneration: sshLease.paneGeneration } + : {}) } } @@ -799,8 +1288,12 @@ async function attachStablePaneOwner( ...spawnOptions, sessionId: owner.ptyId, attachOnly: true, - expectedIncarnationId: owner.runtimeIncarnationId ?? owner.persistedIncarnationId, + expectedIncarnationId: + owner.runtimeIncarnationId ?? owner.leaseIncarnationId ?? owner.persistedIncarnationId, expectedIncarnationIsAuthoritative: owner.runtimeIncarnationId !== undefined, + ...(owner.authorityPaneGeneration !== undefined + ? { paneGeneration: owner.authorityPaneGeneration } + : {}), isNewSession: undefined, command: undefined, commandDelivery: undefined, @@ -823,6 +1316,8 @@ async function attachStablePaneOwner( ownerBeforeRetire && (ownerBeforeRetire.ptyId !== owner.ptyId || ownerBeforeRetire.runtimeIncarnationId !== owner.runtimeIncarnationId || + ownerBeforeRetire.leaseIncarnationId !== owner.leaseIncarnationId || + ownerBeforeRetire.authorityPaneGeneration !== owner.authorityPaneGeneration || ownerBeforeRetire.hasPersistedBinding !== owner.hasPersistedBinding || ownerBeforeRetire.persistedIncarnationId !== owner.persistedIncarnationId) ) { @@ -998,16 +1493,25 @@ function delay(ms: number): Promise { }) } -async function isProviderPtyLive( +async function isExpectedProviderPtyLive( provider: IPtyProvider, ptyId: string, + expectedIncarnationId: string | undefined, deadlineMs?: number ): Promise { - // Why: bound the liveness list RPC by the teardown deadline so a wedged daemon - // fails fast; undefined keeps the provider default for all other callers. - return (await provider.listProcesses(deadlineMs !== undefined ? { deadlineMs } : undefined)).some( - (session) => session.id === ptyId - ) + const session = ( + await provider.listProcesses(deadlineMs !== undefined ? { deadlineMs } : undefined) + ).find((candidate) => candidate.id === ptyId) + if (!session) { + return false + } + if (!expectedIncarnationId) { + return true + } + if (!session.incarnationId) { + throw new Error('pty_shutdown_identity_unavailable') + } + return session.incarnationId === expectedIncarnationId } async function isProviderAgentSessionOwnerLive( @@ -1034,9 +1538,10 @@ async function isProviderAgentSessionOwnerLive( async function verifyPtyStopped( provider: IPtyProvider, ptyId: string, - opts: { keepHistory?: boolean; deadlineMs?: number } | undefined + opts: { keepHistory?: boolean; deadlineMs?: number } | undefined, + expectedIncarnationId?: string ): Promise { - if (await isProviderPtyLive(provider, ptyId, opts?.deadlineMs)) { + if (await isExpectedProviderPtyLive(provider, ptyId, expectedIncarnationId, opts?.deadlineMs)) { return false } if (!opts?.keepHistory) { @@ -1045,7 +1550,7 @@ async function verifyPtyStopped( const deadline = Date.now() + KEEP_HISTORY_STOP_SETTLE_MS while (Date.now() < deadline) { await delay(KEEP_HISTORY_STOP_POLL_MS) - if (await isProviderPtyLive(provider, ptyId, opts?.deadlineMs)) { + if (await isExpectedProviderPtyLive(provider, ptyId, expectedIncarnationId, opts?.deadlineMs)) { return false } } @@ -1055,9 +1560,16 @@ async function verifyPtyStopped( function finishPtyShutdown( id: string, connectionId: string | null | undefined, - store: Store | undefined + store: Store | undefined, + provider: IPtyProvider, + target: PtyMutationTarget ): string | undefined { - const incarnationId = ptyIncarnationById.get(id) + const dispatch = resolveCapturedPtyMutationDispatch(provider, id, target) + if (dispatch.mode === 'rejected') { + return undefined + } + const incarnationId = + dispatch.mode === 'exact' ? dispatch.identity.incarnationId : ptyIncarnationById.get(id) clearProviderPtyState(id) if (connectionId) { store?.markSshRemotePtyLease(connectionId, getRelayPtyId(connectionId, id), 'terminated') @@ -1852,6 +2364,52 @@ export function registerSshPtyProvider(connectionId: string, provider: IPtyProvi if (Number.isSafeInteger(generation) && generation! > 0) { sshProvidersByGeneration.set(generation!, provider) } + startSshPtyPendingCloseReplay(connectionId, provider) +} + +function startSshPtyPendingCloseReplay(connectionId: string, provider: IPtyProvider): void { + let replay: PendingExactPtyCloseReplay + const completion = Promise.resolve().then(async () => { + const isCurrent = () => + sshProviders.get(connectionId) === provider && + pendingExactPtyCloseReplays.get(connectionId) === replay + if (!isCurrent()) { + throw new Error('ssh_pty_pending_close_replay_superseded') + } + await pendingExactPtyCloseReplayHook({ connectionId, provider, isCurrent }) + if (!isCurrent()) { + throw new Error('ssh_pty_pending_close_replay_superseded') + } + }) + replay = { provider, completion } + pendingExactPtyCloseReplays.set(connectionId, replay) + void replay.completion.catch(() => {}) +} + +export async function waitForSshPtyPendingCloseReplay( + connectionId: string, + provider: IPtyProvider +): Promise { + const replay = pendingExactPtyCloseReplays.get(connectionId) + if (!replay || replay.provider !== provider || sshProviders.get(connectionId) !== provider) { + throw new Error('ssh_pty_pending_close_replay_unavailable') + } + await replay.completion + if ( + pendingExactPtyCloseReplays.get(connectionId) !== replay || + sshProviders.get(connectionId) !== provider + ) { + throw new Error('ssh_pty_pending_close_replay_superseded') + } +} + +export function _resetPtyPendingCloseStateForTests(): void { + pendingExactPtyCloseReplays.clear() + pendingExactPtyCloseReplayHook = async () => {} + for (const cleanup of syntheticPtyExits.values()) { + clearTimeout(cleanup) + } + syntheticPtyExits.clear() } /** Remove an SSH PTY provider when a connection is closed. */ @@ -1862,6 +2420,9 @@ export function unregisterSshPtyProvider(connectionId: string): void { sshProvidersByGeneration.delete(generation) } sshProviders.delete(connectionId) + if (pendingExactPtyCloseReplays.get(connectionId)?.provider === provider) { + pendingExactPtyCloseReplays.delete(connectionId) + } } /** Get the SSH PTY provider for a connection (for dispose on cleanup). */ @@ -1931,6 +2492,10 @@ export function clearProviderPtyState( markClaudePtyExited(id) ptySizes.delete(id) ptyIncarnationById.delete(id) + ptyMutationIdentityById.delete(id) + ptyMutationClaimantById.delete(id) + ptyMutationLifecycleTokenById.delete(id) + void ptyRendererBindingHeldPauses.release(id) lastInputAtByPty.delete(id) interactiveOutputCharsByPty.delete(id) const activeChanged = activeRendererPtys.delete(id) @@ -1994,6 +2559,9 @@ export function deletePtyOwnership(id: string): void { export function setPtyOwnership(id: string, connectionId: string | null): void { ptyOwnership.set(id, connectionId) + if (!ptyMutationLifecycleTokenById.has(id)) { + ptyMutationLifecycleTokenById.set(id, Object.freeze({})) + } } export function restorePtyIncarnation(id: string, incarnationId: string): void { @@ -2001,6 +2569,7 @@ export function restorePtyIncarnation(id: string, incarnationId: string): void { throw new Error('Invalid PTY incarnation') } ptyIncarnationById.set(id, incarnationId) + restorePtyMutationIncarnation(id, incarnationId) } // Why: localProvider.onData/onExit return unsubscribe functions. Without @@ -2129,6 +2698,13 @@ let resetPtyRendererDeliveryDebugSnapshot = (): void => {} let resetRendererDeliveryAccountingForLifecycleReset = (): void => {} // Bridged so a re-registration can cancel the prior closure's dispatcher-ready watchdog before wiring its own. let clearRendererDispatcherReadyWatchdog = (): void => {} +let resetRendererBindingStateForLifecycleReset = ( + retentions: readonly PtyRendererBindingSideEffectRetention[] +): void => { + for (const retention of retentions) { + releaseRendererBindingSideEffectPressure(retention.id) + } +} export function getPtyRendererDeliveryDebugSnapshot(): PtyRendererDeliveryDebugSnapshot { return readPtyRendererDeliveryDebugSnapshot() @@ -2153,6 +2729,17 @@ function markRendererPtysHiddenForRendererLifecycleReset(): void { const activePriorityChanged = activeRendererPtys.size > 0 activeRendererPtys.clear() visibleRendererPtys.clear() + const abandonedRetentions = ptyRendererBindingFences.clearAll() + resetRendererBindingStateForLifecycleReset(abandonedRetentions) + const abandonedSideEffects = abandonedRetentions.reduce( + (count, retention) => count + retention.batches.length + retention.unresolvedBatchCount, + 0 + ) + if (abandonedSideEffects > 0) { + recordCrashBreadcrumb('terminal_binding_side_effect_renderer_reset', { + batchCount: abandonedSideEffects + }) + } // Why: the dead page never ACKs its in-flight bytes, so leaked accounting would delivery-gate surviving PTYs forever after a reload/crash. resetRendererDeliveryAccountingForLifecycleReset() if (activePriorityChanged) { @@ -2250,6 +2837,23 @@ export function registerPtyHandlers( invalidatePendingPtyDrainPriority = () => {} invalidatePendingPtyDrainPolicy = () => {} registerRendererLifecycleResetHandlers(mainWindow.webContents) + registerPtyAuthorityOutcomeIpc(mainWindow) + resetRendererBindingStateForLifecycleReset = (retentions) => { + for (const retention of retentions) { + releaseRendererBindingSideEffectPressure(retention.id) + } + } + + const admitRendererMutationClaimant = ( + event: IpcMainInvokeEvent, + value: unknown + ): PtyMutationClaimant | null => { + const claimant = parsePtyMutationClaimant(value) + if (!claimant || event.sender !== mainWindow.webContents) { + return null + } + return claimant + } const getLocalPtyStartupPromise = (connectionId?: string | null): Promise | undefined => { if (connectionId) { @@ -2290,6 +2894,8 @@ export function registerPtyHandlers( ipcMain.removeHandler('pty:resetRendererDeliveryDebug') ipcMain.removeHandler('pty:reportRendererDeliveryState') ipcMain.removeHandler('pty:writeAccepted') + ipcMain.removeHandler('pty:claimMutationAccess') + ipcMain.removeHandler('pty:captureAdministrativeMutationAccess') ipcMain.removeAllListeners('pty:write') ipcMain.removeAllListeners('pty:ackColdRestore') ipcMain.removeAllListeners('pty:ackData') @@ -2373,8 +2979,17 @@ export function registerPtyHandlers( markClaudePtyExited(id) runtime?.onPtyExit(id, code, incarnationId) }, - onData: (id, data, timestamp, sequenceChars, transformed) => - runtime?.onPtyData(id, data, timestamp, sequenceChars ?? data.length, transformed) + onData: (id, data, timestamp, sequenceChars, transformed, incarnationId) => + runtime?.onPtyData( + id, + data, + timestamp, + sequenceChars ?? data.length, + transformed, + undefined, + undefined, + incarnationId + ) }) } @@ -2392,6 +3007,9 @@ export function registerPtyHandlers( const pendingData = new PtyPendingDataDrainQueue( (id) => { const runnableLane = activeRendererPtys.has(id) ? 'active' : 'background' + if (ptyRendererBindingFences.isBlocked(id)) { + return 'blocked' + } // Why first: hidden bytes are dropped from main's pending queue even when renderer credit is exhausted. if (shouldDropHiddenRendererPtyData(id, getSettings?.())) { return runnableLane @@ -2411,6 +3029,32 @@ export function registerPtyHandlers( const rendererExitingPtyIds = new Set() const rendererCreditBeforeExitByPty = new Map() const rendererDeliveryRestoreNeededPtys = new Set() + const rendererBindingExitWaiters = new Map void>>() + + const waitForRendererBinding = (id: string): Promise => { + if (!ptyRendererBindingFences.isBlocked(id)) { + return Promise.resolve() + } + return new Promise((resolve) => { + let waiters = rendererBindingExitWaiters.get(id) + if (!waiters) { + waiters = new Set() + rendererBindingExitWaiters.set(id, waiters) + } + waiters.add(resolve) + }) + } + + const releaseRendererBindingExitWaiters = (id: string): void => { + const waiters = rendererBindingExitWaiters.get(id) + if (!waiters) { + return + } + rendererBindingExitWaiters.delete(id) + for (const resolve of waiters) { + resolve() + } + } function transitionHiddenRendererPtyDeliveryState(id: string, hidden: boolean) { const settings = getSettings?.() @@ -2495,6 +3139,7 @@ export function registerPtyHandlers( pauseProducer: (id) => tryGetProviderForPty(id)?.pauseProducer?.(id), resumeProducer: (id) => tryGetProviderForPty(id)?.resumeProducer?.(id) }) + releaseRendererBindingSideEffectPressure = (id) => producerFlowControl.release(id) const sourceCreditPendingPtys = new Set() function updateProducerFlowControl(id: string): void { @@ -2507,7 +3152,13 @@ export function registerPtyHandlers( } sourceCreditPendingPtys.delete(id) } - producerFlowControl.update(id, pendingData.get(id)?.data.length ?? 0) + const sideEffectPressure = ptyRendererBindingFences.needsSideEffectBackpressure(id) + ? PRODUCER_FLOW_HIGH_WATERMARK_CHARS + 1 + : 0 + producerFlowControl.update( + id, + Math.max(pendingData.get(id)?.data.length ?? 0, sideEffectPressure) + ) } // Why: hidden ptys are exempt from pendingData flow control, so background agents can run 100MB+ ahead in the daemon stream buffer; this sync tells the provider transport which ptys to keep-tail thin. @@ -2749,6 +3400,7 @@ export function registerPtyHandlers( // Why lossless: pendingData bytes were bound for the dead page; the replacement repaints from main's authoritative sources, which superset it. lastLifecycleResetClearedChars = rendererInFlightTotalChars rendererLifecycleResetCount += 1 + rendererPtyDispatcherReady = false // Why release before clearing: pending bytes and credits belonged to the dead page; releasing producer pauses first keeps no shell wedged. producerFlowControl.releaseAll() clearDeliveryResyncProbe() @@ -2762,7 +3414,11 @@ export function registerPtyHandlers( pendingOverflowMarkedPtys.clear() rendererDeliveryRestoreNeededPtys.clear() // Why hold sends: the reloading page's pty:data listener is gone until it re-registers/handshakes, so bytes would drop into a listener-less page and re-pin the gate. - rendererPtyDispatcherReady = false + if (mainWindow.isDestroyed()) { + for (const id of rendererBindingExitWaiters.keys()) { + releaseRendererBindingExitWaiters(id) + } + } // Why: arm the self-heal watchdog so a never-arriving handshake can't hold the gate forever; the real handshake cancels it. armDispatcherReadyWatchdog() } @@ -2825,6 +3481,9 @@ export function registerPtyHandlers( } function canSendPtyDataToRenderer(id: string, options: { interactive?: boolean } = {}): boolean { + if (ptyRendererBindingFences.isBlocked(id)) { + return false + } const totalLimit = PTY_RENDERER_TOTAL_IN_FLIGHT_HIGH_WATER_CHARS + (options.interactive === true ? PTY_RENDERER_INTERACTIVE_RESERVE_CHARS : 0) @@ -3080,6 +3739,70 @@ export function registerPtyHandlers( }) } + const rendererSideEffectDelivery = new PtyRendererSideEffectDelivery({ + fences: ptyRendererBindingFences, + resolveBinding: (id) => { + const provider = tryGetProviderForPty(id) + if (!provider) { + return { mode: 'unavailable' } + } + const mode = resolvePtyMutationMode(provider, id) + if (mode === 'legacy') { + return { mode } + } + const incarnationId = ptyMutationIdentityById.get(id)?.incarnationId + return mode === 'exact' && incarnationId ? { mode, incarnationId } : { mode: 'unavailable' } + }, + publish: (batch) => { + if ( + !rendererPtyDispatcherReady || + mainWindow.isDestroyed() || + mainWindow.webContents.isDestroyed?.() === true + ) { + throw new Error('renderer_side_effect_delivery_unavailable') + } + mainWindow.webContents.send('pty:sideEffect', batch) + }, + onPressureChanged: updateProducerFlowControl, + onReleased: (id) => { + if (!ptyRendererBindingHeldPauses.has(id)) { + resumeRendererDeliveryAfterBinding(id) + return + } + void ptyRendererBindingHeldPauses.release(id).then((released) => { + if (released) { + resumeRendererDeliveryAfterBinding(id) + } + }) + }, + onReplayDeferred: (_id, error) => { + recordCrashBreadcrumb('terminal_binding_side_effect_replay_deferred', { + errorName: error instanceof Error ? error.name : 'unknown' + }) + }, + onUnresolved: (id, batchCount) => { + recordCrashBreadcrumb('terminal_binding_side_effect_unresolved', { batchCount }) + sendModelRestoreNeededMarker(id, 'pending-cap', runtime?.getPtyOutputSequence(id)) + } + }) + resetRendererBindingStateForLifecycleReset = (retentions) => { + rendererSideEffectDelivery.reset(retentions) + ptyRendererBindingHeldPauses.releaseAll() + for (const retention of retentions) { + releaseRendererBindingExitWaiters(retention.id) + } + } + routeRendererBindingHeldPauseReleaseFailure = (lease, error) => { + recordCrashBreadcrumb('terminal_binding_held_pause_release_failed', { + errorName: error instanceof Error ? error.name : 'unknown' + }) + sendModelRestoreNeededMarker(lease.id, 'pending-cap', runtime?.getPtyOutputSequence(lease.id)) + ;( + lease.provider as IPtyProvider & { closeOutputIntake?: (reason: string) => void } + ).closeOutputIntake?.('renderer-binding-held-pause-release-failed') + } + routeTerminalSideEffectsToRenderer = (batch) => rendererSideEffectDelivery.publish(batch) + const pendingDataDropWarnedPtys = new Set() // Why capped: keeps O(1) memory per PTY; salvaged query bytes are tiny, so past the cap a pathological stream can degrade to the plain sentinel. @@ -3439,33 +4162,13 @@ export function registerPtyHandlers( flushTimer = null } - const syntheticKillExitPtyIds = new Map() const reversibleStopOwnersByPtyId = new Map() - function rememberSyntheticKillExit(id: string): void { - const existing = syntheticKillExitPtyIds.get(id) - if (existing) { - clearTimeout(existing) + function preparePtyExitForRenderer(payload: PtyRendererExit): (() => void) | null { + if (ptyRendererBindingFences.isBlocked(payload.id) && !mainWindow.isDestroyed()) { + pendingRendererBindingExits.set(payload.id, payload) + return () => {} } - // Why a timed window: providers may report the real exit after kill completes; skip only that late duplicate, not a future reused id forever. - const cleanupTimer = setTimeout(() => { - syntheticKillExitPtyIds.delete(id) - }, SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS) - cleanupTimer.unref?.() - syntheticKillExitPtyIds.set(id, cleanupTimer) - } - - function consumeSyntheticKillExit(id: string): boolean { - const cleanupTimer = syntheticKillExitPtyIds.get(id) - if (!cleanupTimer) { - return false - } - clearTimeout(cleanupTimer) - syntheticKillExitPtyIds.delete(id) - return true - } - - function preparePtyExitForRenderer(payload: { id: string; code: number }): (() => void) | null { if (mainWindow.isDestroyed()) { sshOutputIntake?.transferPtyProjections(payload.id, 'renderer-destroyed') return () => {} @@ -3526,7 +4229,11 @@ export function registerPtyHandlers( } } - function finalizePtyExitForRenderer(payload: { id: string; code: number }): void { + function finalizePtyExitForRenderer(payload: PtyRendererExit): void { + if (ptyRendererBindingFences.isBlocked(payload.id) && !mainWindow.isDestroyed()) { + pendingRendererBindingExits.set(payload.id, payload) + return + } if (mainWindow.isDestroyed()) { rendererCreditBeforeExitByPty.delete(payload.id) return @@ -3559,9 +4266,18 @@ export function registerPtyHandlers( ...payload, ...(reversibleStopOwnersByPtyId.has(payload.id) ? { preserveRendererBinding: true } : {}) }) + pendingRendererBindingExits.delete(payload.id) + const abandonedSideEffects = ptyRendererBindingFences.clear(payload.id) + if (abandonedSideEffects) { + rendererSideEffectDelivery.classifyUnresolved(abandonedSideEffects) + } } - function sendPtyExitToRenderer(payload: { id: string; code: number }): void { + function sendPtyExitToRenderer(payload: PtyRendererExit): void { + if (ptyRendererBindingFences.isBlocked(payload.id) && !mainWindow.isDestroyed()) { + pendingRendererBindingExits.set(payload.id, payload) + return + } const release = preparePtyExitForRenderer(payload) if (!release) { return @@ -3574,6 +4290,149 @@ export function registerPtyHandlers( } } + function publishVerifiedSyntheticPtyExit( + id: string, + connectionId: string | null | undefined, + incarnationId: string | undefined, + provider: IPtyProvider, + target: PtyMutationTarget | null, + leaseAlreadySettled = false + ): boolean { + if (target ? !isCurrentCapturedPtyMutationTarget(provider, id, target) : !leaseAlreadySettled) { + return false + } + const currentIncarnationId = ptyIncarnationById.get(id) + if (currentIncarnationId && incarnationId && currentIncarnationId !== incarnationId) { + return false + } + clearProviderPtyState(id) + ptyOwnership.delete(id) + markClaudePtyExited(id) + if (connectionId && !leaseAlreadySettled) { + store?.markSshRemotePtyLease(connectionId, getRelayPtyId(connectionId, id), 'terminated') + } + runtime?.onPtyExit(id, -1, incarnationId) + rememberSyntheticKillExit(id, incarnationId) + sendPtyExitToRenderer({ id, code: -1, ...(incarnationId ? { incarnationId } : {}) }) + return true + } + + function requestDisconnectedSshPtyClose( + id: string, + connectionId: string, + keepHistory: boolean + ): void { + if ( + !store || + typeof store.getSshRemotePtyLeases !== 'function' || + typeof store.requestSshRemotePtyClose !== 'function' + ) { + throw new Error('ssh_pty_pending_close_store_unavailable') + } + const relayPtyId = getRelayPtyId(connectionId, id) + const lease = store + .getSshRemotePtyLeases(connectionId) + .find( + (candidate) => + candidate.ptyId === relayPtyId && + candidate.state !== 'terminated' && + candidate.state !== 'expired' + ) + if (!lease) { + throw new Error('ssh_pty_close_lease_unavailable') + } + const authorityAccess = parseTerminalSessionAuthorityPtyAccess( + lease.terminalSessionAuthorityAccess + ) + if (!authorityAccess) { + throw new Error('ssh_pty_pending_close_authority_unavailable') + } + const knownIncarnations = new Set( + [ + ptyMutationIdentityById.get(id)?.incarnationId, + ptyIncarnationById.get(id), + lease.incarnationId, + authorityAccess.binding.ptyIncarnationId + ].filter((value): value is string => Boolean(value)) + ) + if (knownIncarnations.size !== 1) { + throw new Error('ssh_pty_pending_close_identity_unavailable') + } + const incarnationId = knownIncarnations.values().next().value! + const pendingClose = { + incarnationId, + terminalSessionAuthorityAccess: authorityAccess, + keepHistory, + requestedAt: Date.now() + } + store.requestSshRemotePtyClose(connectionId, relayPtyId, pendingClose) + } + + pendingExactPtyCloseReplayHook = async ({ connectionId, provider, isCurrent }) => { + if (!store || typeof store.getSshRemotePtyLeases !== 'function') { + return + } + const leases = store + .getSshRemotePtyLeases(connectionId) + .filter( + (lease) => lease.state !== 'terminated' && lease.state !== 'expired' && lease.pendingClose + ) + if (leases.length === 0) { + return + } + if ( + leases.some( + (lease) => + !parseTerminalSessionAuthorityPtyAccess( + lease.pendingClose?.terminalSessionAuthorityAccess + ) + ) + ) { + throw new Error('ssh_pty_pending_close_replay_authority_unavailable') + } + let terminations + try { + terminations = await terminateListedSshTerminalSessions({ + targetId: connectionId, + provider, + trackedPtyIds: [], + leases, + isCurrent, + usePersistedCloseOptions: true, + prepareAuthorityExitWait: (target) => prepareSshTerminalAuthorityExitWait(store, target) + }) + } catch (error) { + throw new Error('ssh_pty_pending_close_replay_unknown', { cause: error }) + } + for (const termination of terminations) { + if (!isCurrent()) { + throw new Error('ssh_pty_pending_close_replay_superseded') + } + if (termination.status !== 'terminated') { + throw new Error(`ssh_pty_pending_close_replay_${termination.status}`, { + cause: termination.error + }) + } + const lease = store + .getSshRemotePtyLeases(connectionId) + .find((candidate) => candidate.ptyId === termination.relayPtyId) + if (!lease || lease.state !== 'terminated' || lease.pendingClose) { + throw new Error('ssh_pty_pending_close_settlement_lost') + } + publishVerifiedSyntheticPtyExit( + termination.appPtyId, + connectionId, + lease.incarnationId, + provider, + null, + true + ) + } + } + for (const [connectionId, provider] of sshProviders) { + startSshPtyPendingCloseReplay(connectionId, provider) + } + function sendPtySpawnedToRenderer(id: string): void { if (!mainWindow.isDestroyed()) { mainWindow.webContents.send('pty:spawned', { id }) @@ -3617,7 +4476,10 @@ export function registerPtyHandlers( } return } - if (shouldDropHiddenRendererPtyData(payload.id, getSettings?.())) { + if ( + !ptyRendererBindingFences.isBlocked(payload.id) && + shouldDropHiddenRendererPtyData(payload.id, getSettings?.()) + ) { if (projectionId) { sshOutputIntake?.transferProjections([projectionId], 'hidden-drop') } @@ -3672,7 +4534,9 @@ export function registerPtyHandlers( sendModelRestoreNeededMarker(payload.id, 'pending-cap', outputSeq) } updateProducerFlowControl(payload.id) - requestDeliveryResyncForGatedPty() + if (!ptyRendererBindingFences.isBlocked(payload.id)) { + requestDeliveryResyncForGatedPty() + } return } deletePendingPtyData(payload.id) @@ -3712,7 +4576,9 @@ export function registerPtyHandlers( if ( !canSendPtyDataToRenderer(payload.id, { interactive: activeRendererPtys.has(payload.id) }) ) { - requestDeliveryResyncForGatedPty() + if (!ptyRendererBindingFences.isBlocked(payload.id)) { + requestDeliveryResyncForGatedPty() + } } if (!flushTimer) { schedulePendingDataFlush(PTY_BATCH_INTERVAL_MS) @@ -3732,7 +4598,8 @@ export function registerPtyHandlers( Date.now(), event.rawLength, event.transformed, - projection.desktopSpan ? [projection.desktopSpan] : undefined + projection.desktopSpan ? [projection.desktopSpan] : undefined, + event.ptyIncarnation ) }, project: (event, projection) => @@ -3746,8 +4613,13 @@ export function registerPtyHandlers( projection.identity.sequenceEnd, projection ), - prepareExit: (event) => { - const release = preparePtyExitForRenderer(event) + prepareExit: async (event) => { + await waitForRendererBinding(event.id) + const release = preparePtyExitForRenderer({ + id: event.id, + code: event.code, + incarnationId: event.ptyIncarnation + }) if (!release) { throw new Error('pty_renderer_exit_in_progress') } @@ -3755,7 +4627,11 @@ export function registerPtyHandlers( }, finalizeExit: (event) => { runtime?.onPtyExit(event.id, event.code, event.ptyIncarnation) - finalizePtyExitForRenderer(event) + finalizePtyExitForRenderer({ + id: event.id, + code: event.code, + incarnationId: event.ptyIncarnation + }) }, pauseProvider: (generation, id) => { const provider = sshProvidersByGeneration.get(generation) as @@ -3808,7 +4684,8 @@ export function registerPtyHandlers( async function shutdownProviderAndDetectExit( provider: IPtyProvider, id: string, - opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number } + opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number }, + dispatch: PtyMutationDispatch ): Promise { let providerExitObserved = false const expectedIncarnationId = ptyIncarnationById.get(id) @@ -3821,7 +4698,9 @@ export function registerPtyHandlers( } }) try { - await provider.shutdown(id, opts) + if (!(await killPtyMutation(provider, id, dispatch, opts))) { + throw new Error('pty_mutation_identity_rejected') + } } finally { unsubscribe() } @@ -3872,7 +4751,7 @@ export function registerPtyHandlers( ) return } - runtime?.emitDaemonPtyTransientFact(payload.id, payload.fact) + runtime?.emitDaemonPtyTransientFact(payload.id, payload.fact, payload.incarnationId) }) ?? null // Why: daemon providers lack configure().onData, so feed the runtime here or their tail buffer (terminal.read, agent-detection, mobile stream) stays empty. @@ -3882,16 +4761,22 @@ export function registerPtyHandlers( const rawLength = payload.sequenceChars ?? payload.data.length const outputSeq = isLocalProvider ? runtime?.getPtyOutputSequence(payload.id) - : runtime?.onPtyData(payload.id, payload.data, Date.now(), rawLength, payload.transformed) + : runtime?.onPtyData( + payload.id, + payload.data, + Date.now(), + rawLength, + payload.transformed, + undefined, + undefined, + payload.incarnationId + ) acceptPtyDataForRenderer(payload, outputSeq) }) localExitUnsub = localProvider.onExit((payload) => { if (!isCurrentPtyExit(payload)) { return } - if (consumeSyntheticKillExit(payload.id)) { - return - } if (!isLocalProvider) { clearProviderPtyState(payload.id) ptyOwnership.delete(payload.id) @@ -4202,6 +5087,7 @@ export function registerPtyHandlers( preAllocatedHandle?: string tabId: string leafId: string + paneGeneration?: number ownsPaneSpawnReservation?: true }) => { const paneKey = makePaneKey(args.tabId, args.leafId) @@ -4252,14 +5138,22 @@ export function registerPtyHandlers( if (!owner) { return null } + const provider = getProvider(args.connectionId) + if (args.connectionId) { + await waitForSshPtyPendingCloseReplay(args.connectionId, provider) + } const adoption = attachStablePaneOwner({ runtime, store, - provider: getProvider(args.connectionId), + provider, spawnOptions: { cols: args.cols, rows: args.rows, - cwd: args.cwd + cwd: args.cwd, + worktreeId: args.worktreeId, + paneKey, + tabId: args.tabId, + ...(args.paneGeneration !== undefined ? { paneGeneration: args.paneGeneration } : {}) }, owner, worktreeId: args.worktreeId, @@ -4319,6 +5213,9 @@ export function registerPtyHandlers( } const cwd = resolvePtySpawnStartupCwd(args.worktreeId, args.cwd) const provider = getProvider(args.connectionId) + if (args.connectionId) { + await waitForSshPtyPendingCloseReplay(args.connectionId, provider) + } const freshSpawnRecovery = preAdoptedStablePane ? undefined : recoverFreshSpawnProviderRouting( @@ -4741,6 +5638,11 @@ export function registerPtyHandlers( args.worktreeId, args.connectionId ) + applyExpectedSessionIncarnation( + spawnOptions, + effectiveSessionAppId, + stablePaneOwnerCandidate + ) const expectedPtyId = stablePaneOwnerCandidate?.ptyId ?? effectiveSessionAppId ?? sessionId if (expectedPtyId) { @@ -4796,6 +5698,7 @@ export function registerPtyHandlers( // Why: local providers cannot serialize controller claims, so liveness proof // needs the exact incarnation before the registry promotes the new owner. ptyIncarnationById.set(providerResult.id, providerResult.incarnationId) + restorePtyMutationIncarnation(providerResult.id, providerResult.incarnationId) } const providerEnsure = providerResult.agentSessionEnsure return { @@ -4962,10 +5865,11 @@ export function registerPtyHandlers( } if (result.agentSessionEnsure?.disposition === 'adopted') { const owner = result.agentSessionEnsure.owner - ptyOwnership.set(result.id, args.connectionId ?? ptyOwnership.get(result.id) ?? null) + setPtyOwnership(result.id, args.connectionId ?? ptyOwnership.get(result.id) ?? null) runtime?.registerPreAllocatedHandleForPty(result.id, owner.surface.terminalHandle) if (result.incarnationId) { ptyIncarnationById.set(result.id, result.incarnationId) + restorePtyMutationIncarnation(result.id, result.incarnationId) } runtime?.registerPty(result.id, owner.surface.worktreeId, args.connectionId ?? null, { tabId: owner.surface.tabId, @@ -4978,9 +5882,10 @@ export function registerPtyHandlers( agentSessionEnsure: result.agentSessionEnsure } } - ptyOwnership.set(result.id, args.connectionId ?? null) + setPtyOwnership(result.id, args.connectionId ?? null) if (result.incarnationId) { ptyIncarnationById.set(result.id, result.incarnationId) + restorePtyMutationIncarnation(result.id, result.incarnationId) } // Why: record the native-Windows-local-PTY determination before any byte reaches the emulator, so its ConPTY DA1 override exists from byte zero. if ( @@ -5001,6 +5906,10 @@ export function registerPtyHandlers( store.upsertSshRemotePtyLease({ targetId: args.connectionId, ptyId: relayResultId, + ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}), + ...(result.terminalSessionAuthorityAccess + ? { terminalSessionAuthorityAccess: result.terminalSessionAuthorityAccess } + : {}), ...(typeof args.worktreeId === 'string' ? { worktreeId: args.worktreeId } : {}), ...(typeof args.tabId === 'string' ? { tabId: args.tabId } : {}), ...(typeof args.leafId === 'string' && isTerminalLeafId(args.leafId) @@ -5176,10 +6085,12 @@ export function registerPtyHandlers( finishTerminalInstall() } }, - write: (ptyId, data) => { + captureMutationTarget: captureRuntimePtyMutationTarget, + write: (ptyId, data, target) => { try { - getProviderForPty(ptyId).write(ptyId, data) - return true + const provider = getProviderForPty(ptyId) + const dispatch = resolveCapturedPtyMutationDispatch(provider, ptyId, target) + return writePtyMutation(provider, ptyId, dispatch, data) } catch { return false } @@ -5236,7 +6147,9 @@ export function registerPtyHandlers( return false } }, - kill: (ptyId) => { + kill: (ptyId, requestedTarget) => { + const target = + requestedTarget === undefined ? captureRuntimePtyMutationTarget(ptyId) : requestedTarget let connectionId: string | null | undefined = ptyOwnership.get(ptyId) const parsedSshId = connectionId === undefined ? parseAppSshPtyId(ptyId) : null connectionId ??= parsedSshId?.connectionId @@ -5246,41 +6159,65 @@ export function registerPtyHandlers( provider = connectionId ? getProvider(connectionId) : getProviderForPty(ptyId) } catch { if (connectionId) { - // Why: runtime/CLI close can target a detached SSH PTY after its - // provider was unregistered. Tombstone the lease so reconnect does - // not revive a terminal the user explicitly closed. - const incarnationId = finishPtyShutdown(ptyId, connectionId, store) - runtime?.onPtyExit(ptyId, -1, incarnationId) - rememberSyntheticKillExit(ptyId) - sendPtyExitToRenderer({ id: ptyId, code: -1 }) - return true + try { + requestDisconnectedSshPtyClose(ptyId, connectionId, false) + return true + } catch (error) { + console.warn( + `[pty] Failed to persist pending close for ${ptyId}: ${ + error instanceof Error ? error.message : String(error) + }` + ) + } } return false } + if (!target) { + return false + } + const dispatch = resolveCapturedPtyMutationDispatch(provider, ptyId, target) + if (dispatch.mode === 'rejected') { + return false + } + const expectedIncarnationId = + dispatch.mode === 'exact' + ? dispatch.identity.incarnationId + : ptyIncarnationById.get(ptyId) // Why: controller is synchronous, but keep ownership until async shutdown proves whether the provider emitted an exit. - void shutdownProviderAndDetectExit(provider, ptyId, { immediate: false }) - .then((providerExitObserved) => { - const incarnationId = finishPtyShutdown(ptyId, connectionId, store) + void shutdownProviderAndDetectExit(provider, ptyId, { immediate: false }, dispatch) + .then(async (providerExitObserved) => { + if ( + !providerExitObserved && + !(await verifyPtyStopped(provider, ptyId, undefined, expectedIncarnationId)) + ) { + return + } if (!providerExitObserved) { - runtime?.onPtyExit(ptyId, -1, incarnationId) - rememberSyntheticKillExit(ptyId) - sendPtyExitToRenderer({ id: ptyId, code: -1 }) + publishVerifiedSyntheticPtyExit( + ptyId, + connectionId, + expectedIncarnationId, + provider, + target + ) + } else { + finishPtyShutdown(ptyId, connectionId, store, provider, target) } }) .catch((err) => { if (isPtyAlreadyGoneError(err)) { - const incarnationId = finishPtyShutdown(ptyId, connectionId, store) - runtime?.onPtyExit(ptyId, -1, incarnationId) - rememberSyntheticKillExit(ptyId) - sendPtyExitToRenderer({ id: ptyId, code: -1 }) + publishVerifiedSyntheticPtyExit( + ptyId, + connectionId, + expectedIncarnationId, + provider, + target + ) return } console.warn( `[pty] Failed to stop PTY ${ptyId}: ${err instanceof Error ? err.message : String(err)}` ) - // Why: close runtime tails without clearing provider ownership, so - // a retry can still target a PTY that survived the failed shutdown. - runtime?.onPtyExit(ptyId, -1, ptyIncarnationById.get(ptyId)) }) return true } @@ -5291,7 +6228,6 @@ export function registerPtyHandlers( console.warn( `[pty] Failed to stop PTY ${ptyId}: ${err instanceof Error ? err.message : String(err)}` ) - runtime?.onPtyExit(ptyId, -1, ptyIncarnationById.get(ptyId)) }) return true } @@ -5318,6 +6254,10 @@ export function registerPtyHandlers( } }, stopAndWait: async (ptyId, opts) => { + const target = + opts?.mutationTarget === undefined + ? captureRuntimePtyMutationTarget(ptyId) + : opts.mutationTarget let connectionId: string | null | undefined = ptyOwnership.get(ptyId) const parsedSshId = connectionId === undefined ? parseAppSshPtyId(ptyId) : null connectionId ??= parsedSshId?.connectionId @@ -5353,23 +6293,39 @@ export function registerPtyHandlers( provider = connectionId ? getProvider(connectionId) : getProviderForPty(ptyId) } catch { if (connectionId) { - // Why: an absent SSH provider means there is no live target left to - // await, but the relay lease must still be tombstoned. - const incarnationId = finishPtyShutdown(ptyId, connectionId, store) - runtime?.onPtyExit(ptyId, -1, incarnationId) - rememberSyntheticKillExit(ptyId) - sendPtyExitToRenderer({ id: ptyId, code: -1 }) - return true + try { + requestDisconnectedSshPtyClose(ptyId, connectionId, opts?.keepHistory ?? false) + } catch (error) { + console.warn( + `[pty] Failed to persist pending close for ${ptyId}: ${ + error instanceof Error ? error.message : String(error) + }` + ) + } } return false } + if (!target) { + return false + } + const dispatch = resolveCapturedPtyMutationDispatch(provider, ptyId, target) + if (dispatch.mode === 'rejected') { + return false + } + const expectedIncarnationId = + dispatch.mode === 'exact' ? dispatch.identity.incarnationId : ptyIncarnationById.get(ptyId) let providerExitObserved = false try { - providerExitObserved = await shutdownProviderAndDetectExit(provider, ptyId, { - immediate: true, - keepHistory: opts?.keepHistory ?? false, - deadlineMs - }) + providerExitObserved = await shutdownProviderAndDetectExit( + provider, + ptyId, + { + immediate: true, + keepHistory: opts?.keepHistory ?? false, + deadlineMs + }, + dispatch + ) } catch (err) { if (!isPtyAlreadyGoneError(err)) { console.warn( @@ -5379,7 +6335,7 @@ export function registerPtyHandlers( } } try { - if (!(await verifyPtyStopped(provider, ptyId, opts))) { + if (!(await verifyPtyStopped(provider, ptyId, opts, expectedIncarnationId))) { return false } } catch (err) { @@ -5390,11 +6346,16 @@ export function registerPtyHandlers( ) return false } - const incarnationId = finishPtyShutdown(ptyId, connectionId, store) if (!providerExitObserved) { - runtime?.onPtyExit(ptyId, -1, incarnationId) - rememberSyntheticKillExit(ptyId) - sendPtyExitToRenderer({ id: ptyId, code: -1 }) + publishVerifiedSyntheticPtyExit( + ptyId, + connectionId, + expectedIncarnationId, + provider, + target + ) + } else { + finishPtyShutdown(ptyId, connectionId, store, provider, target) } return true }, @@ -5430,13 +6391,27 @@ export function registerPtyHandlers( return false } }, - clearBuffer: async (ptyId) => { - // Why: desktop xterm and daemon/SSH providers hold separate buffers; clear both so mobile resubscribe can't resurrect cleared history. - mainWindow.webContents.send('pty:clearBuffer:request', { ptyId }) + clearBuffer: async (ptyId, target) => { try { - await getProviderForPty(ptyId).clearBuffer(ptyId) + const provider = getProviderForPty(ptyId) + const dispatch = resolveCapturedPtyMutationDispatch(provider, ptyId, target) + const accepted = await clearPtyMutation(provider, ptyId, dispatch) + if (!accepted || !isCurrentCapturedPtyMutationTarget(provider, ptyId, target)) { + return false + } + // Why: desktop xterm and daemon/SSH providers hold separate buffers; clear both so mobile resubscribe can't resurrect cleared history. + mainWindow.webContents.send('pty:clearBuffer:request', { + ptyId, + ...(dispatch.mode === 'exact' + ? { + incarnationId: dispatch.identity.incarnationId, + paneGeneration: dispatch.identity.paneGeneration + } + : {}) + }) + return true } catch { - /* best effort: renderer clear still handles local PTYs */ + return false } }, hasPty: (ptyId) => { @@ -5482,9 +6457,13 @@ export function registerPtyHandlers( return rendererSerializerReadiness.wait(ptyId, afterGeneration, timeoutMs, signal) }, getSize: (ptyId) => ptySizes.get(ptyId) ?? null, - resize: (ptyId, cols, rows) => { + resize: (ptyId, cols, rows, target) => { try { - getProviderForPty(ptyId).resize(ptyId, cols, rows) + const provider = getProviderForPty(ptyId) + const dispatch = resolveCapturedPtyMutationDispatch(provider, ptyId, target) + if (!resizePtyMutation(provider, ptyId, dispatch, cols, rows)) { + return false + } ptySizes.set(ptyId, { cols, rows }) return true } catch { @@ -5582,7 +6561,7 @@ export function registerPtyHandlers( ipcMain.handle( 'pty:spawn', async ( - _event, + event, args: { cols: number rows: number @@ -5611,6 +6590,8 @@ export function registerPtyHandlers( // Why: closes the SIGKILL race (INVESTIGATION.md) by letting main sync-flush the binding before pty:spawn returns; only the Ctrl+T daemon-host path threads these. tabId?: string leafId?: string + tabGeneration?: number + mutationClaimant?: unknown // Why: renderer-threaded launch telemetry (telemetry-plan.md§Agent launch semantics); loosely typed because the main-side schema validator is the single enforcement point. telemetry?: { agent_kind?: unknown @@ -5620,9 +6601,132 @@ export function registerPtyHandlers( } ) => { const spawnTiming = createPtySpawnTiming() + const requestedRendererPaneGeneration = parsePaneGeneration(args.tabGeneration) + const requestedRendererMutationClaimant = admitRendererMutationClaimant( + event, + args.mutationClaimant + ) + const rendererBindingRequested = Boolean( + typeof args.sessionId === 'string' && + args.sessionId && + requestedRendererPaneGeneration !== undefined && + requestedRendererMutationClaimant + ) + let rendererBindingFenceToken: PtyRendererBindingFenceToken | null = + rendererBindingRequested && + requestedRendererPaneGeneration !== undefined && + requestedRendererMutationClaimant && + typeof args.sessionId === 'string' + ? ptyRendererBindingFences.begin({ + id: getAppPtyId(args.connectionId, args.sessionId), + paneGeneration: requestedRendererPaneGeneration, + claimant: requestedRendererMutationClaimant, + ...(ptyMutationIdentityById.get(getAppPtyId(args.connectionId, args.sessionId)) + ?.incarnationId + ? { + incarnationId: ptyMutationIdentityById.get( + getAppPtyId(args.connectionId, args.sessionId) + )!.incarnationId + } + : {}) + }) + : null + let rendererBindingHeldPauseLease: PtyRendererBindingHeldPauseLease | null = null + let rendererBindingHeldPauseCommitted = false + const discardUncommittedRendererBindingHeldPause = async (): Promise => { + if (rendererBindingHeldPauseLease && !rendererBindingHeldPauseCommitted) { + const lease = rendererBindingHeldPauseLease + rendererBindingHeldPauseLease = null + await ptyRendererBindingHeldPauses.discard(lease) + } + } + const releaseSpawnRendererBindingHeldPause = async (): Promise => { + if (!rendererBindingHeldPauseLease) { + return + } + if (rendererBindingHeldPauseCommitted) { + const { id } = rendererBindingHeldPauseLease + rendererBindingHeldPauseLease = null + rendererBindingHeldPauseCommitted = false + await ptyRendererBindingHeldPauses.release(id) + return + } + await discardUncommittedRendererBindingHeldPause() + } + const cancelSpawnRendererBinding = (): void => { + if (rendererBindingFenceToken) { + const retention = + ptyRendererBindingFences.cancelTokenWithSideEffects(rendererBindingFenceToken) + if (retention) { + rendererSideEffectDelivery.releaseCurrent(retention) + } + } + rendererBindingFenceToken = null + } + const applySpawnRendererBinding = ( + response: T, + provider: IPtyProvider, + includeAdministrativeAccess = false + ): T & { + mutationAccess?: PtyMutationAccess + administrativeMutationAccess?: PtyAdministrativeMutationAccess + } => { + if (rendererBindingFenceToken && rendererBindingFenceToken.id !== response.id) { + cancelSpawnRendererBinding() + } + const ownsRequestedBinding = Boolean( + rendererBindingFenceToken && ptyRendererBindingFences.owns(rendererBindingFenceToken) + ) + if (requestedRendererPaneGeneration === undefined) { + rememberPtyMutationIdentity(response.id, response.incarnationId) + } + if ( + requestedRendererPaneGeneration !== undefined && + (!rendererBindingRequested || ownsRequestedBinding) + ) { + rememberPtyMutationIdentity( + response.id, + response.incarnationId, + requestedRendererPaneGeneration, + requestedRendererMutationClaimant ?? undefined + ) + } + const mutationAccess = + requestedRendererPaneGeneration !== undefined + ? ptyMutationAccess(provider, response.id) + : undefined + if ( + rendererBindingFenceToken && + mutationAccess?.mode === 'exact' && + ptyRendererBindingFences.owns(rendererBindingFenceToken) + ) { + const finalized = ptyRendererBindingFences.finalize( + rendererBindingFenceToken, + mutationAccess + ) + if (finalized && rendererBindingHeldPauseLease && !rendererBindingHeldPauseCommitted) { + ptyRendererBindingHeldPauses.commit(rendererBindingHeldPauseLease) + rendererBindingHeldPauseCommitted = true + } + } + return { + ...response, + ...(includeAdministrativeAccess + ? { + administrativeMutationAccess: ptyAdministrativeMutationAccess(response.id, provider) + } + : {}), + ...(mutationAccess ? { mutationAccess } : {}) + } + } const startupPromise = getLocalPtyStartupPromise(args.connectionId) if (startupPromise) { - await startupPromise + try { + await startupPromise + } catch (error) { + cancelSpawnRendererBinding() + throw error + } } // Why: honor the fallback only for fresh local spawns — reattach needs exact cwd and SSH can't probe the local filesystem. const allowMissingCwdFallback = @@ -5662,13 +6766,27 @@ export function registerPtyHandlers( ? pendingRuntimePaneCreatesByOwnerKey.get(earlyReservationKey) : undefined if (pendingRuntimeCreate) { - await pendingRuntimeCreate.promise + try { + await pendingRuntimeCreate.promise + } catch (error) { + cancelSpawnRendererBinding() + throw error + } } const existingPaneSpawn = earlyReservationKey ? paneSpawnReservationsByOwnerKey.get(earlyReservationKey) : undefined if (existingPaneSpawn) { - return { ...(await existingPaneSpawn.promise), isReattach: true } + try { + const response = await existingPaneSpawn.promise + return { + ...applySpawnRendererBinding(response, getProvider(args.connectionId)), + isReattach: true + } + } catch (error) { + cancelSpawnRendererBinding() + throw error + } } const earlyStablePaneOwner = earlyPaneKey && args.worktreeId @@ -5689,10 +6807,49 @@ export function registerPtyHandlers( let preparedProvisionalExecutionContext = false let releaseWorktreeSpawn: (() => void) | undefined try { + const provider = getProvider(args.connectionId) + if (rendererBindingFenceToken) { + const bindingId = rendererBindingFenceToken.id + const authorityAccess = provider.getTerminalSessionAuthorityAccess?.(bindingId) ?? null + const incarnationId = + authorityAccess?.binding.ptyIncarnationId ?? rendererBindingFenceToken.incarnationId + const mode = resolvePtyMutationMode(provider, bindingId) + if (authorityAccess && mode !== 'exact') { + throw new Error('pty_renderer_binding_authority_mutation_unavailable') + } + if (mode === 'exact' && incarnationId) { + if (!ptyRendererBindingHeldPauses.supports(provider, bindingId, incarnationId)) { + if (authorityAccess) { + throw new Error('pty_renderer_binding_held_pause_unavailable') + } + } else { + rendererBindingHeldPauseLease = await ptyRendererBindingHeldPauses.acquire( + provider, + bindingId, + incarnationId + ) + if (!rendererBindingHeldPauseLease) { + throw new Error('pty_renderer_binding_held_pause_rejected') + } + const currentAuthorityAccess = + provider.getTerminalSessionAuthorityAccess?.(bindingId) ?? null + const authorityUnchanged = authorityAccess + ? currentAuthorityAccess !== null && + sameTerminalSessionAuthorityPtyAccess(authorityAccess, currentAuthorityAccess) + : currentAuthorityAccess === null + if (!authorityUnchanged) { + await discardUncommittedRendererBindingHeldPause() + throw new Error('pty_renderer_binding_authority_changed') + } + } + } + } if (!earlyStablePaneOwner) { await assertFolderWorkspacePtyPathUsable(args.worktreeId) } - const provider = getProvider(args.connectionId) + if (args.connectionId) { + await waitForSshPtyPendingCloseReplay(args.connectionId, provider) + } const preAdoptedStablePane = earlyStablePaneOwner && earlyWorktreeId ? await adoptStablePane({ @@ -5703,6 +6860,9 @@ export function registerPtyHandlers( worktreeId: earlyWorktreeId, tabId: earlyStablePaneOwner.tabId, leafId: earlyStablePaneOwner.leafId, + ...(requestedRendererPaneGeneration !== undefined + ? { paneGeneration: requestedRendererPaneGeneration } + : {}), ownsPaneSpawnReservation: true }) : null @@ -6070,6 +7230,10 @@ export function registerPtyHandlers( if (reservationPaneKey) { spawnOptions.paneKey = reservationPaneKey } + const authorityPaneGeneration = parsePaneGeneration(args.tabGeneration) + if (authorityPaneGeneration !== undefined) { + spawnOptions.paneGeneration = authorityPaneGeneration + } if (typeof args.tabId === 'string' && args.tabId.length > 0 && args.tabId.length <= 512) { spawnOptions.tabId = args.tabId } @@ -6127,7 +7291,11 @@ export function registerPtyHandlers( ? paneSpawnReservationsByOwnerKey.get(paneSpawnReservationKey) : undefined if (existingPaneSpawnAfterPreflight) { - return { ...(await existingPaneSpawnAfterPreflight.promise), isReattach: true } + const response = await existingPaneSpawnAfterPreflight.promise + return { + ...applySpawnRendererBinding(response, provider), + isReattach: true + } } paneSpawnReservation = paneSpawnReservationKey ? reservePaneSpawn(paneSpawnReservationKey) @@ -6156,6 +7324,11 @@ export function registerPtyHandlers( args.worktreeId, args.connectionId ) + applyExpectedSessionIncarnation( + spawnOptions, + effectiveSessionAppId, + stablePaneOwnerCandidate + ) const expectedPtyId = stablePaneOwnerCandidate?.ptyId ?? effectiveSessionAppId ?? effectiveSessionId if (expectedPtyId) { @@ -6336,9 +7509,10 @@ export function registerPtyHandlers( target: codexSelectionTarget, settings: getSettings?.() }) - ptyOwnership.set(result.id, args.connectionId ?? null) + setPtyOwnership(result.id, args.connectionId ?? null) if (result.incarnationId) { ptyIncarnationById.set(result.id, result.incarnationId) + restorePtyMutationIncarnation(result.id, result.incarnationId) } if (initiallyHidden) { // Why marked synchronously here: provider data events dispatch on later tasks, so this still lands ahead of the first byte's delivery decision (idempotent if already marked pre-spawn). @@ -6361,9 +7535,20 @@ export function registerPtyHandlers( store.upsertSshRemotePtyLease({ targetId: args.connectionId, ptyId: relayResultId, + ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}), + ...(result.terminalSessionAuthorityAccess + ? { terminalSessionAuthorityAccess: result.terminalSessionAuthorityAccess } + : {}), ...(typeof args.worktreeId === 'string' ? { worktreeId: args.worktreeId } : {}), ...(typeof args.tabId === 'string' ? { tabId: args.tabId } : {}), ...(validatedLeafId ? { leafId: validatedLeafId } : {}), + ...(stablePaneOwner?.authorityPaneGeneration !== undefined || + requestedRendererPaneGeneration !== undefined + ? { + paneGeneration: + stablePaneOwner?.authorityPaneGeneration ?? requestedRendererPaneGeneration + } + : {}), state: 'attached', lastAttachedAt: Date.now() }) @@ -6579,23 +7764,29 @@ export function registerPtyHandlers( }) } } - const response = { - ...result, - ...(!result.isReattach && effectiveLaunchConfig - ? { launchConfig: effectiveLaunchConfig } - : {}), - // Why: a daemon-retry race can surface isReattach even for a minted session id, and a reattach must never claim its cwd was remapped. - ...(startupCwdFallback && !result.isReattach ? { startupCwdFallback } : {}), - // Why: the pane asked to resume and got a fresh session instead; only the - // renderer can say so, and a reattach never ran this launch command. - ...(codexResumeLaunch.notifyResumeUnavailable && !result.isReattach - ? { agentResumeUnavailable: true as const } - : {}) - } + const response = applySpawnRendererBinding( + { + ...result, + ...(!result.isReattach && effectiveLaunchConfig + ? { launchConfig: effectiveLaunchConfig } + : {}), + // Why: a daemon-retry race can surface isReattach even for a minted session id, and a reattach must never claim its cwd was remapped. + ...(startupCwdFallback && !result.isReattach ? { startupCwdFallback } : {}), + // Why: the pane asked to resume and got a fresh session instead; only the + // renderer can say so, and a reattach never ran this launch command. + ...(codexResumeLaunch.notifyResumeUnavailable && !result.isReattach + ? { agentResumeUnavailable: true as const } + : {}) + }, + provider, + true + ) // Why: renderer tab state cannot reliably infer background and reattached PTYs in the daemon inventory. sendPtySpawnedToRenderer(result.id) return resolvePaneSpawnReservation(paneSpawnReservationKey, paneSpawnReservation, response) } catch (err) { + cancelSpawnRendererBinding() + await releaseSpawnRendererBindingHeldPause() if (pendingRegistrationPtyId) { runtime?.cancelPendingPtyRegistration?.( pendingRegistrationPtyId, @@ -6612,12 +7803,99 @@ export function registerPtyHandlers( rejectPaneSpawnReservation(paneSpawnReservationKey, paneSpawnReservation, err) throw err } finally { + await discardUncommittedRendererBindingHeldPause() releaseWorktreeSpawn?.() finishTerminalInstall() } } ) + ipcMain.handle( + 'pty:claimMutationAccess', + ( + event, + args: { + id?: unknown + tabId?: unknown + leafId?: unknown + paneGeneration?: unknown + claimant?: unknown + } + ): PtyMutationAccess => { + if ( + event.sender !== mainWindow.webContents || + typeof args?.id !== 'string' || + !args.id || + typeof args.tabId !== 'string' || + !isValidTerminalTabId(args.tabId) || + typeof args.leafId !== 'string' || + !isTerminalLeafId(args.leafId) + ) { + return { mode: 'unavailable' } + } + const provider = tryGetProviderForPty(args.id) + if (!provider) { + return { mode: 'unavailable' } + } + const providerMode = resolvePtyMutationMode(provider, args.id) + if (providerMode === 'legacy') { + return { mode: 'legacy' } + } + const paneGeneration = parsePaneGeneration(args.paneGeneration) + const claimant = admitRendererMutationClaimant(event, args.claimant) + const current = ptyMutationIdentityById.get(args.id) + if (!current || paneGeneration === undefined || !claimant) { + return { mode: 'unavailable' } + } + const currentClaimant = ptyMutationClaimantById.get(args.id) + if ( + currentClaimant?.rendererEpochId === claimant.rendererEpochId && + claimant.sequence < currentClaimant.sequence + ) { + return { mode: 'unavailable' } + } + if ( + currentClaimant?.rendererEpochId === claimant.rendererEpochId && + claimant.sequence === currentClaimant.sequence + ) { + return ptyMutationAccess(provider, args.id) + } + const paneKey = makePaneKey(args.tabId, args.leafId) + if (!currentClaimant) { + if (ptyPaneKey.get(args.id) !== paneKey && paneKeyPtyId.get(paneKey) !== args.id) { + return { mode: 'unavailable' } + } + } + rememberPaneKeyForPty(args.id, paneKey) + rememberPtyMutationIdentity(args.id, current.incarnationId, paneGeneration, claimant) + return ptyMutationAccess(provider, args.id) + } + ) + + ipcMain.handle( + 'pty:captureAdministrativeMutationAccess', + (event, args: { ids?: unknown }): { id: string; access: PtyAdministrativeMutationAccess }[] => { + if (event.sender !== mainWindow.webContents || !Array.isArray(args?.ids)) { + return [] + } + const seen = new Set() + const access: { id: string; access: PtyAdministrativeMutationAccess }[] = [] + for (const candidate of args.ids.slice(0, 512)) { + if ( + typeof candidate !== 'string' || + candidate.length === 0 || + candidate.length > 512 || + seen.has(candidate) + ) { + continue + } + seen.add(candidate) + access.push({ id: candidate, access: ptyAdministrativeMutationAccess(candidate) }) + } + return access + } + ) + const reportUnavailablePtyWrite = (id: string, error: unknown): void => { if ( !isPtyWriteUnavailableError(error) || @@ -6630,37 +7908,63 @@ export function registerPtyHandlers( mainWindow.webContents.send('pty:writeUnavailable', { id }) } + const reportRejectedPtyMutation = (id: string): void => { + if ( + mainWindow.isDestroyed() || + (typeof mainWindow.webContents.isDestroyed === 'function' && + mainWindow.webContents.isDestroyed()) + ) { + return + } + mainWindow.webContents.send('pty:writeUnavailable', { id }) + } + const writePtyProviderInputWithinLimit = ( provider: IPtyProvider, id: string, - data: string + data: string, + dispatch: PtyMutationDispatch, + reportRejected: boolean ): boolean | Promise => { + const writeChunk = (chunk: string): boolean => { + const accepted = writePtyMutation(provider, id, dispatch, chunk) + if (!accepted && reportRejected) { + reportRejectedPtyMutation(id) + } + return accepted + } const chunks = iterateTerminalInputChunks(data) const first = chunks.next() if (first.done) { - provider.write(id, data) - return true + return writeChunk(data) } const second = chunks.next() if (second.done) { - provider.write(id, first.value) - return true + return writeChunk(first.value) } - return writePtyProviderInputChunks(provider, id, chunks, first.value, second.value) + return writePtyProviderInputChunks(id, writeChunk, chunks, first.value, second.value) } const writePtyProviderInput = ( provider: IPtyProvider, id: string, - data: string + data: string, + dispatch: PtyMutationDispatch, + reportRejected: boolean ): boolean | Promise => { try { const tooLarge = isTerminalInputTooLargeWithDeferredMeasurement(data) if (typeof tooLarge === 'boolean') { - return tooLarge ? false : writePtyProviderInputWithinLimit(provider, id, data) + return tooLarge + ? false + : writePtyProviderInputWithinLimit(provider, id, data, dispatch, reportRejected) } return tooLarge - .then((result) => (result ? false : writePtyProviderInputWithinLimit(provider, id, data))) + .then((result) => + result + ? false + : writePtyProviderInputWithinLimit(provider, id, data, dispatch, reportRejected) + ) .catch((error) => { reportUnavailablePtyWrite(id, error) return false @@ -6672,8 +7976,8 @@ export function registerPtyHandlers( } const writePtyProviderInputChunks = async ( - provider: IPtyProvider, id: string, + writeChunk: (chunk: string) => boolean, chunks: Iterator, firstChunk: string, secondChunk: string @@ -6682,7 +7986,9 @@ export function registerPtyHandlers( let chunk: IteratorResult = { done: false, value: firstChunk } let nextChunk: IteratorResult = { done: false, value: secondChunk } while (!chunk.done) { - provider.write(id, chunk.value) + if (!writeChunk(chunk.value)) { + return false + } if (!nextChunk.done) { await new Promise((resolve) => setTimeout(resolve, 0)) } @@ -6696,8 +8002,20 @@ export function registerPtyHandlers( } } - type PtyWritePayload = { id: string; data: string } - type PtyViewportClaimPayload = { id: string; cols: number; rows: number } + type PtyWritePayload = { + id: string + data: string + mutationIdentity?: unknown + administrativeCurrent?: unknown + administrativeImmediateCurrent?: unknown + administrativeAccess?: unknown + } + type PtyViewportClaimPayload = { + id: string + cols: number + rows: number + mutationIdentity?: unknown + } const isPtyWritePayload = (value: unknown): value is PtyWritePayload => typeof value === 'object' && @@ -6726,15 +8044,73 @@ export function registerPtyHandlers( !mainWindow.isDestroyed() && !(typeof mainWebContents.isDestroyed === 'function' && mainWebContents.isDestroyed()) - const writePtyInput = (args: PtyWritePayload): boolean | Promise => { + const resolvePtyWriteDispatch = ( + provider: IPtyProvider, + args: PtyWritePayload + ): PtyMutationDispatch => { + if (args.administrativeAccess !== undefined) { + const access = parsePtyAdministrativeMutationAccess(args.administrativeAccess) + if ( + !access || + access.mode === 'unavailable' || + !administrativeAccessMatchesProvider(access, provider) + ) { + return { mode: 'rejected' } + } + if (access.mode === 'legacy') { + return resolvePtyMutationMode(provider, args.id) !== 'legacy' + ? { mode: 'rejected' } + : resolveAdministrativePtyMutationDispatch({ + provider, + id: args.id, + currentIdentity: ptyMutationIdentityById.get(args.id) + }) + } + return resolveRegisteredRendererPtyMutationDispatch({ + provider, + id: args.id, + requestedIdentity: null, + administrativeEvidence: access.evidence + }) + } + return resolveRegisteredRendererPtyMutationDispatch({ + provider, + id: args.id, + requestedIdentity: parsePtyMutationIdentity(args.mutationIdentity), + administrativeCurrent: + args.administrativeImmediateCurrent === true || args.administrativeCurrent === true + }) + } + + type AdmittedPtyWrite = { + provider: IPtyProvider + dispatch: PtyMutationDispatch + } + type PtyWriteAdmission = AdmittedPtyWrite | { reason: 'rejected' | 'unavailable' } + + const admitPtyWrite = ( + args: PtyWritePayload, + requireKnownOwnership: boolean + ): PtyWriteAdmission => { + const provider = + requireKnownOwnership && !ptyOwnership.has(args.id) + ? undefined + : tryGetProviderForPty(args.id) + if (!provider) { + return { reason: 'unavailable' } + } + const dispatch = resolvePtyWriteDispatch(provider, args) + return dispatch.mode === 'rejected' ? { reason: 'rejected' } : { provider, dispatch } + } + + const writePtyInput = ( + args: PtyWritePayload, + admitted: AdmittedPtyWrite + ): boolean | Promise => { // Why: mobile-presence-lock defense-in-depth — the renderer's onData guard can let one keystroke slip during the state-flip lag, so catch it server-side. See docs/mobile-presence-lock.md. if (runtime?.getDriver(args.id).kind === 'mobile') { return false } - const provider = ptyOwnership.has(args.id) ? tryGetProviderForPty(args.id) : undefined - if (!provider) { - return false - } try { const now = performance.now() lastInputAtByPty.set(args.id, now) @@ -6742,13 +8118,16 @@ export function registerPtyHandlers( if (visibleRendererPtys.has(args.id)) { clearHiddenRendererResizeOutput(args.id) } - return writePtyProviderInput(provider, args.id, args.data) + return writePtyProviderInput(admitted.provider, args.id, args.data, admitted.dispatch, true) } catch { return false } } - const writePtyInputAccepted = (args: PtyWritePayload): boolean | Promise => { + const writePtyInputAccepted = ( + args: PtyWritePayload, + admitted: AdmittedPtyWrite + ): boolean | Promise => { if (runtime?.getDriver(args.id).kind === 'mobile') { return false } @@ -6756,8 +8135,7 @@ export function registerPtyHandlers( if (ptyOwnership.get(args.id) !== null) { return false } - const provider = tryGetProviderForPty(args.id) - if (!provider?.hasPty?.(args.id)) { + if (!admitted.provider.hasPty?.(args.id)) { return false } try { @@ -6767,7 +8145,7 @@ export function registerPtyHandlers( if (visibleRendererPtys.has(args.id)) { clearHiddenRendererResizeOutput(args.id) } - return writePtyProviderInput(provider, args.id, args.data) + return writePtyProviderInput(admitted.provider, args.id, args.data, admitted.dispatch, false) } catch { return false } @@ -6779,21 +8157,32 @@ export function registerPtyHandlers( if (!isPtyWriteEventFromMainWindow(event, mainWindow.webContents) || !isPtyWritePayload(args)) { return } - const claimTail = hostViewportClaimTails.get(args.id) - if (claimTail) { - void claimTail.then((claimed) => (claimed ? writePtyInput(args) : false)) + const admitted = admitPtyWrite(args, true) + if ('reason' in admitted) { + if (admitted.reason === 'rejected') { + reportRejectedPtyMutation(args.id) + } return } - writePtyInput(args) + const claimTail = hostViewportClaimTails.get(args.id) + if (claimTail) { + void claimTail.then((claimed) => (claimed ? writePtyInput(args, admitted) : false)) + return + } + writePtyInput(args, admitted) }) ipcMain.handle('pty:writeAccepted', (event, args: unknown): boolean | Promise => { if (!isPtyWriteEventFromMainWindow(event, mainWindow.webContents) || !isPtyWritePayload(args)) { return false } + const admitted = admitPtyWrite(args, false) + if ('reason' in admitted) { + return false + } const claimTail = hostViewportClaimTails.get(args.id) return claimTail - ? claimTail.then((claimed) => (claimed ? writePtyInputAccepted(args) : false)) - : writePtyInputAccepted(args) + ? claimTail.then((claimed) => (claimed ? writePtyInputAccepted(args, admitted) : false)) + : writePtyInputAccepted(args, admitted) }) ipcMain.removeAllListeners('pty:claimViewport') @@ -6805,15 +8194,28 @@ export function registerPtyHandlers( ) { return } + const provider = tryGetProviderForPty(args.id) + if (!provider) { + return + } + const dispatch = resolveRegisteredRendererPtyMutationDispatch({ + provider, + id: args.id, + requestedIdentity: parsePtyMutationIdentity(args.mutationIdentity) + }) + const mutationTarget = captureRendererPtyMutationTarget(provider, args.id, dispatch) + if (mutationTarget.access.mode === 'unavailable') { + return + } const prior = hostViewportClaimTails.get(args.id) // Why: two panes can mirror one PTY — never let a later no-op claim replace the in-flight resize that the following host input must await. const claim = ( prior ? prior.then( - () => runtime.claimRemoteDesktopHost(args.id, args.cols, args.rows), - () => runtime.claimRemoteDesktopHost(args.id, args.cols, args.rows) + () => runtime.claimRemoteDesktopHost(args.id, args.cols, args.rows, mutationTarget), + () => runtime.claimRemoteDesktopHost(args.id, args.cols, args.rows, mutationTarget) ) - : runtime.claimRemoteDesktopHost(args.id, args.cols, args.rows) + : runtime.claimRemoteDesktopHost(args.id, args.cols, args.rows, mutationTarget) ).catch(() => false) hostViewportClaimTails.set(args.id, claim) void claim.then(() => { @@ -6825,44 +8227,58 @@ export function registerPtyHandlers( // Why: resize is fire-and-forget — ipcMain.on (not .handle) halves IPC traffic by skipping the empty acknowledgement reply. ipcMain.removeAllListeners('pty:resize') - ipcMain.on('pty:resize', (_event, args: { id: string; cols: number; rows: number }) => { - // Why: after a desktop-fit override change the renderer's safeFit cascade re-measures ALL panes (background ones at full width), so suppress every pty:resize in this window to avoid corrupting PTY dimensions. - if (runtime?.isResizeSuppressed()) { - return - } - // Why: presence-lock defense-in-depth — while a phone or remote-desktop viewer drives the width, host-side resizes must not reach the PTY or its alt-screen grid garbles; load-bearing because the renderer mirror lags one IPC hop. See docs/mobile-presence-lock.md. - const mobileOwnsResize = runtime?.getDriver(args.id).kind === 'mobile' - const remoteDesktopOwnsResize = runtime?.isRemoteDesktopResizeDriven?.(args.id) === true - if (mobileOwnsResize || remoteDesktopOwnsResize) { - if (remoteDesktopOwnsResize) { - runtime?.recordRemoteDesktopHostReclaimTarget(args.id, args.cols, args.rows) + ipcMain.on( + 'pty:resize', + (_event, args: { id: string; cols: number; rows: number; mutationIdentity?: unknown }) => { + // Why: after a desktop-fit override change the renderer's safeFit cascade re-measures ALL panes (background ones at full width), so suppress every pty:resize in this window to avoid corrupting PTY dimensions. + if (runtime?.isResizeSuppressed()) { + return } - return - } - const provider = tryGetProviderForPty(args.id) - if (!provider) { - return - } - const markedHiddenResizeOutput = rendererPtyIsKnownHidden(args.id) - if (markedHiddenResizeOutput) { - // Why: alt-screen TUIs repaint on SIGWINCH; a hidden repaint read after switch-back must not masquerade as live output and overwrite the correctly-sized screen. - pendingHiddenRendererResizeOutputPtys.add(args.id) - deliveredHiddenRendererResizeOutputPtys.delete(args.id) - } else if (visibleRendererPtys.has(args.id)) { - // Why: after the stale hidden-resize repaint is observed, the renderer's visible resize pulse owns the next repaint. - clearDeliveredHiddenRendererResizeOutput(args.id) - } - try { - provider.resize(args.id, args.cols, args.rows) - } catch { + // Why: presence-lock defense-in-depth — while a phone or remote-desktop viewer drives the width, host-side resizes must not reach the PTY or its alt-screen grid garbles; load-bearing because the renderer mirror lags one IPC hop. See docs/mobile-presence-lock.md. + const mobileOwnsResize = runtime?.getDriver(args.id).kind === 'mobile' + const remoteDesktopOwnsResize = runtime?.isRemoteDesktopResizeDriven?.(args.id) === true + if (mobileOwnsResize || remoteDesktopOwnsResize) { + if (remoteDesktopOwnsResize) { + runtime?.recordRemoteDesktopHostReclaimTarget(args.id, args.cols, args.rows) + } + return + } + const provider = tryGetProviderForPty(args.id) + if (!provider) { + return + } + const markedHiddenResizeOutput = rendererPtyIsKnownHidden(args.id) if (markedHiddenResizeOutput) { - pendingHiddenRendererResizeOutputPtys.delete(args.id) + // Why: alt-screen TUIs repaint on SIGWINCH; a hidden repaint read after switch-back must not masquerade as live output and overwrite the correctly-sized screen. + pendingHiddenRendererResizeOutputPtys.add(args.id) + deliveredHiddenRendererResizeOutputPtys.delete(args.id) + } else if (visibleRendererPtys.has(args.id)) { + // Why: after the stale hidden-resize repaint is observed, the renderer's visible resize pulse owns the next repaint. + clearDeliveredHiddenRendererResizeOutput(args.id) } - return + try { + const dispatch = resolveRegisteredRendererPtyMutationDispatch({ + provider, + id: args.id, + requestedIdentity: parsePtyMutationIdentity(args.mutationIdentity) + }) + const accepted = resizePtyMutation(provider, args.id, dispatch, args.cols, args.rows) + if (!accepted) { + if (markedHiddenResizeOutput) { + pendingHiddenRendererResizeOutputPtys.delete(args.id) + } + return + } + } catch { + if (markedHiddenResizeOutput) { + pendingHiddenRendererResizeOutputPtys.delete(args.id) + } + return + } + ptySizes.set(args.id, { cols: args.cols, rows: args.rows }) + runtime?.onExternalPtyResize(args.id, args.cols, args.rows) } - ptySizes.set(args.id, { cols: args.cols, rows: args.rows }) - runtime?.onExternalPtyResize(args.id, args.cols, args.rows) - }) + ) // Why: pty:reportGeometry is a measurement-only sibling of pty:resize — it refreshes the restore-target cache (never resizes) so mobile-fit hold learns real desktop dims even while resize is blocked. See docs/mobile-fit-hold.md. ipcMain.removeAllListeners('pty:reportGeometry') @@ -6969,6 +8385,75 @@ export function registerPtyHandlers( } ) + const resumeRendererDeliveryAfterBinding = (id: string): void => { + releaseRendererBindingExitWaiters(id) + const pendingExit = pendingRendererBindingExits.get(id) + if (pendingExit) { + pendingRendererBindingExits.delete(id) + sendPtyExitToRenderer(pendingExit) + return + } + invalidatePendingPtyDrainClassification(id, false) + pendingData.reactivateBlocked() + if (pendingData.size > 0 && !flushTimer) { + schedulePendingDataFlush(0) + } + } + + const parseExactRendererBindingAccess = (value: unknown): ExactPtyMutationAccess | null => { + const access = parsePtyMutationAccess(value) + return access?.mode === 'exact' ? access : null + } + + ipcMain.removeAllListeners('pty:rendererBindingReady') + ipcMain.on('pty:rendererBindingReady', (event, value: unknown) => { + if (!isPtyWriteEventFromMainWindow(event, mainWindow.webContents)) { + return + } + const args = value as Partial | null + const access = parseExactRendererBindingAccess(args?.access) + if (typeof args?.id !== 'string' || !args.id || !access) { + return + } + const retention = ptyRendererBindingFences.settleExactWithSideEffects(args.id, access) + if (!retention) { + return + } + rendererSideEffectDelivery.releaseExact(retention, access) + }) + + ipcMain.removeAllListeners('pty:cancelRendererBinding') + ipcMain.on('pty:cancelRendererBinding', (event, value: unknown) => { + if (!isPtyWriteEventFromMainWindow(event, mainWindow.webContents)) { + return + } + const args = value as Partial | null + if (typeof args?.id !== 'string' || !args.id) { + return + } + const access = parseExactRendererBindingAccess(args.access) + const retention = access + ? ptyRendererBindingFences.cancelExactWithSideEffects(args.id, access) + : (() => { + const paneGeneration = parsePaneGeneration(args.paneGeneration) + const claimant = parsePtyMutationClaimant(args.claimant) + return paneGeneration !== undefined && claimant + ? ptyRendererBindingFences.cancelClaimWithSideEffects({ + id: args.id, + paneGeneration, + claimant + }) + : null + })() + if (retention) { + if (access) { + rendererSideEffectDelivery.abandonRelease(retention) + } else { + rendererSideEffectDelivery.releaseCurrent(retention) + } + } + }) + // Why: renderer signals its pty:data listener is live; until then sends are held so boot-window bytes can't drop into a listener-less page and pin the gate. ipcMain.removeAllListeners('pty:rendererDispatcherReady') ipcMain.on('pty:rendererDispatcherReady', (event) => { @@ -6983,8 +8468,20 @@ export function registerPtyHandlers( // Why: real handshake landed — cancel the self-heal watchdog so it can't later force-open the gate. clearDispatcherReadyWatchdog() rendererPtyDispatcherReady = true + rendererSideEffectDelivery.retryAll() pendingData.reactivateBlocked() schedulePendingDataFlush(0) + for (const id of rendererBindingExitWaiters.keys()) { + if (!ptyRendererBindingFences.isBlocked(id)) { + resumeRendererDeliveryAfterBinding(id) + } + } + for (const [id, pendingExit] of pendingRendererBindingExits) { + if (!ptyRendererBindingFences.isBlocked(id)) { + pendingRendererBindingExits.delete(id) + sendPtyExitToRenderer(pendingExit) + } + } }) ipcMain.removeAllListeners('pty:setActiveRendererPty') @@ -7089,98 +8586,301 @@ export function registerPtyHandlers( }) ipcMain.removeAllListeners('pty:signal') - ipcMain.on('pty:signal', (_event, args: { id: string; signal: string }) => { - tryGetProviderForPty(args.id) - ?.sendSignal(args.id, args.signal) - .catch(() => {}) - }) + ipcMain.on( + 'pty:signal', + (_event, args: { id: string; signal: string; mutationIdentity?: unknown }) => { + const provider = tryGetProviderForPty(args.id) + if (!provider) { + return + } + const dispatch = resolveRegisteredRendererPtyMutationDispatch({ + provider, + id: args.id, + requestedIdentity: parsePtyMutationIdentity(args.mutationIdentity) + }) + void signalPtyMutation(provider, args.id, dispatch, args.signal).catch(() => {}) + } + ) + + const clearPtyBuffer = async ( + provider: IPtyProvider, + args: { id: string; mutationIdentity?: unknown } + ): Promise => { + const dispatch = resolveRegisteredRendererPtyMutationDispatch({ + provider, + id: args.id, + requestedIdentity: parsePtyMutationIdentity(args.mutationIdentity) + }) + const target = captureRendererPtyMutationTarget(provider, args.id, dispatch) + const accepted = await clearPtyMutation(provider, args.id, dispatch) + if (accepted) { + if (isCurrentCapturedPtyMutationTarget(provider, args.id, target)) { + await runtime?.clearHeadlessTerminalBuffer(args.id, target) + } + } + } ipcMain.removeAllListeners('pty:clearBuffer') - ipcMain.on('pty:clearBuffer', (_event, args: { id: string }) => { + ipcMain.on('pty:clearBuffer', (_event, args: { id: string; mutationIdentity?: unknown }) => { // Why: clear PTY-side state (ConPTY/daemon/SSH buffer) so the next prompt repaint doesn't land at a stale cursor row. - tryGetProviderForPty(args.id) - ?.clearBuffer(args.id) - .catch(() => {}) - runtime?.clearHeadlessTerminalBuffer(args.id).catch(() => {}) + const provider = tryGetProviderForPty(args.id) + if (provider) { + void clearPtyBuffer(provider, args).catch(() => {}) + } }) - ipcMain.handle('pty:kill', async (_event, args: { id: string; keepHistory?: boolean }) => { - if (typeof args?.id !== 'string' || !args.id || args.id.startsWith('remote:')) { - // Why: runtime terminal handles belong to terminal.close; unowned PTY routing could target the local provider. - throw new Error('Invalid PTY provider id') - } - const ownedConnectionId = ptyOwnership.get(args.id) - const parsedSshId = ownedConnectionId === undefined ? parseAppSshPtyId(args.id) : null - const connectionId = ownedConnectionId ?? parsedSshId?.connectionId - // Why: wait for daemon startup before selecting the local provider, else a fallback shutdown falsely succeeds and orphans a restored daemon PTY (#7742). - const startupPromise = getLocalPtyProviderStartupPromise(connectionId) - if (startupPromise) { - await startupPromise - } - const provider = connectionId ? sshProviders.get(connectionId) : tryGetProviderForPty(args.id) - if (!provider && connectionId) { - // Why: detached SSH PTYs intentionally keep ownership after their - // provider is unregistered; hydrated app-scoped ids can also arrive - // before ownership is rebuilt. Tombstone instead of falling back local. - const incarnationId = finishPtyShutdown(args.id, connectionId, store) - runtime?.onPtyExit(args.id, -1, incarnationId) - rememberSyntheticKillExit(args.id) - sendPtyExitToRenderer({ id: args.id, code: -1 }) - return - } - const shutdownProvider = provider ?? getProviderForPty(args.id) - let providerExitObserved = false - try { - providerExitObserved = await shutdownProviderAndDetectExit(shutdownProvider, args.id, { - immediate: true, - keepHistory: args.keepHistory ?? false - }) - } catch (err) { - if (!isPtyAlreadyGoneError(err)) { - // Why: a failed shutdown can leave the process alive (SSH relay grace window / local daemon); keep ownership/lease state so the user can retry. - throw err + ipcMain.handle( + 'pty:kill', + async ( + _event, + args: { + id: string + keepHistory?: boolean + mutationIdentity?: unknown + administrativeCurrent?: unknown + administrativeImmediateCurrent?: unknown + administrativeAccess?: unknown + } + ) => { + if (typeof args?.id !== 'string' || !args.id || args.id.startsWith('remote:')) { + // Why: runtime terminal handles belong to terminal.close; unowned PTY routing could target the local provider. + throw new Error('Invalid PTY provider id') + } + const administrativeAccess = + args.administrativeAccess === undefined + ? null + : parsePtyAdministrativeMutationAccess(args.administrativeAccess) + if ( + args.administrativeAccess !== undefined && + (!administrativeAccess || administrativeAccess.mode === 'unavailable') + ) { + throw new Error('pty_mutation_identity_rejected') + } + const administrativeImmediateCurrent = + args.administrativeImmediateCurrent === true || args.administrativeCurrent === true + let administrativeIdentityAtRequest: PtyMutationIdentity | undefined + let administrativeLegacyAtRequest = false + let administrativeLegacyProviderAtRequest: IPtyProvider | undefined + let administrativeLegacyRouteTokenAtRequest: object | null = null + let administrativeLegacyLifecycleTokenAtRequest: object | null = null + if (administrativeAccess?.mode === 'exact') { + const currentIdentity = ptyMutationIdentityById.get(args.id) + if ( + !ptyMutationIdentityMatchesAdministrativeEvidence( + currentIdentity, + administrativeAccess.evidence + ) + ) { + throw new Error('pty_mutation_identity_rejected') + } + administrativeIdentityAtRequest = currentIdentity + } else if (administrativeAccess?.mode === 'legacy') { + const currentProvider = tryGetProviderForPty(args.id) + if ( + !currentProvider || + !administrativeAccessMatchesProvider(administrativeAccess, currentProvider) || + resolvePtyMutationMode(currentProvider, args.id) !== 'legacy' + ) { + throw new Error('pty_mutation_identity_rejected') + } + administrativeLegacyAtRequest = true + administrativeLegacyProviderAtRequest = currentProvider + administrativeLegacyRouteTokenAtRequest = providerPtyMutationRouteToken( + currentProvider, + args.id + ) + administrativeLegacyLifecycleTokenAtRequest = + ptyMutationLifecycleTokenById.get(args.id) ?? null + } else if (administrativeImmediateCurrent) { + administrativeIdentityAtRequest = ptyMutationIdentityById.get(args.id) + if (!administrativeIdentityAtRequest) { + const currentProvider = tryGetProviderForPty(args.id) + administrativeLegacyAtRequest = Boolean( + currentProvider && resolvePtyMutationMode(currentProvider, args.id) === 'legacy' + ) + administrativeLegacyProviderAtRequest = currentProvider + administrativeLegacyRouteTokenAtRequest = currentProvider + ? providerPtyMutationRouteToken(currentProvider, args.id) + : null + administrativeLegacyLifecycleTokenAtRequest = + ptyMutationLifecycleTokenById.get(args.id) ?? null + } + if (!administrativeIdentityAtRequest && !administrativeLegacyAtRequest) { + throw new Error('pty_mutation_identity_rejected') + } + } + const ownedConnectionId = ptyOwnership.get(args.id) + const parsedSshId = ownedConnectionId === undefined ? parseAppSshPtyId(args.id) : null + const connectionId = ownedConnectionId ?? parsedSshId?.connectionId + // Why: wait for daemon startup before selecting the local provider, else a fallback shutdown falsely succeeds and orphans a restored daemon PTY (#7742). + const startupPromise = getLocalPtyProviderStartupPromise(connectionId) + if (startupPromise) { + await startupPromise + } + const provider = connectionId ? sshProviders.get(connectionId) : tryGetProviderForPty(args.id) + if ( + administrativeAccess && + provider && + !administrativeAccessMatchesProvider(administrativeAccess, provider) + ) { + throw new Error('pty_mutation_identity_rejected') + } + if ( + administrativeIdentityAtRequest && + !ptyMutationIdentitiesEqual( + administrativeIdentityAtRequest, + ptyMutationIdentityById.get(args.id) ?? null + ) + ) { + throw new Error('pty_mutation_identity_rejected') + } + if ( + administrativeLegacyAtRequest && + (!provider || + provider !== administrativeLegacyProviderAtRequest || + providerPtyMutationRouteToken(provider, args.id) !== + administrativeLegacyRouteTokenAtRequest || + ptyMutationLifecycleTokenById.get(args.id) !== + administrativeLegacyLifecycleTokenAtRequest || + resolvePtyMutationMode(provider, args.id) !== 'legacy') + ) { + throw new Error('pty_mutation_identity_rejected') + } + if (!provider && connectionId) { + requestDisconnectedSshPtyClose(args.id, connectionId, args.keepHistory ?? false) + return + } + const shutdownProvider = provider ?? getProviderForPty(args.id) + const dispatch = + administrativeIdentityAtRequest || administrativeLegacyAtRequest + ? resolveAdministrativePtyMutationDispatch({ + provider: shutdownProvider, + id: args.id, + currentIdentity: administrativeIdentityAtRequest + }) + : resolveRegisteredRendererPtyMutationDispatch({ + provider: shutdownProvider, + id: args.id, + requestedIdentity: parsePtyMutationIdentity(args.mutationIdentity) + }) + if (dispatch.mode === 'rejected') { + throw new Error('pty_mutation_identity_rejected') + } + const mutationTarget = + administrativeIdentityAtRequest || administrativeLegacyAtRequest + ? captureAdministrativePtyMutationTarget(shutdownProvider, args.id, dispatch) + : captureRendererPtyMutationTarget(shutdownProvider, args.id, dispatch) + if (!isCurrentCapturedPtyMutationTarget(shutdownProvider, args.id, mutationTarget)) { + throw new Error('pty_mutation_identity_rejected') + } + const expectedIncarnationId = + dispatch.mode === 'exact' ? dispatch.identity.incarnationId : undefined + let providerExitObserved = false + let alreadyGone = false + try { + providerExitObserved = await shutdownProviderAndDetectExit( + shutdownProvider, + args.id, + { + immediate: true, + keepHistory: args.keepHistory ?? false + }, + dispatch + ) + } catch (err) { + if (!isPtyAlreadyGoneError(err)) { + // Why: a failed shutdown can leave the process alive (SSH relay grace window / local daemon); keep ownership/lease state so the user can retry. + throw err + } + alreadyGone = true + } + if ( + !providerExitObserved && + !alreadyGone && + !(await verifyPtyStopped( + shutdownProvider, + args.id, + { keepHistory: args.keepHistory ?? false }, + expectedIncarnationId + )) + ) { + return + } + const incarnationId = expectedIncarnationId ?? ptyIncarnationById.get(args.id) + if (!providerExitObserved) { + publishVerifiedSyntheticPtyExit( + args.id, + connectionId, + incarnationId, + shutdownProvider, + mutationTarget + ) + } else { + finishPtyShutdown(args.id, connectionId, store, shutdownProvider, mutationTarget) } - /* session already dead — cleanup below handles the rest */ } - // Why: some shutdown paths do not emit onExit through the provider listener. - // Explicit cleanup is idempotent and covers already-dead PTYs. - const incarnationId = finishPtyShutdown(args.id, connectionId, store) - if (!providerExitObserved) { - runtime?.onPtyExit(args.id, -1, incarnationId) - rememberSyntheticKillExit(args.id) - sendPtyExitToRenderer({ id: args.id, code: -1 }) - } - }) + ) ipcMain.handle('pty:listSessions', async (): Promise => { const deduped = new Map() const admission = new PtyProcessListAdmission() + const admittedRows: { + source: RegisteredPtyProvider + session: ReturnType + }[] = [] + const admittedIds = new Set() await visitPtyProcessListingsInBatches( registeredPtyProviders(), - ({ provider, connectionId }) => - connectionId === null ? provider.listProcesses() : provider.listProcesses().catch(() => []), - ({ provider, connectionId }, sessions) => { + async ({ provider, connectionId }) => { + if (connectionId !== null) { + await waitForSshPtyPendingCloseReplay(connectionId, provider) + } + return connectionId === null + ? provider.listProcesses() + : provider.listProcesses().catch(() => []) + }, + (source, sessions) => { + if (!isCurrentRegisteredPtyProvider(source)) { + return + } + const { provider } = source for (const rawSession of sessions) { - const session = admission.admit(rawSession) - // Why: kill actions only send back the PTY id, so rebuild ownership while listing to keep reconnect-discovered remote sessions routed to their provider. - ptyOwnership.set(session.id, connectionId) - deduped.set(session.id, { - id: session.id, - cwd: session.cwd, - title: session.title, - // Why: the renderer's binding map is empty during restore, so ownership is the only - // liveness evidence it has. Absence is authoritative only from a provider that - // serializes claims — otherwise it is 'unknown', never 'absent' (#8459). - agentOwnership: - (session.agentSessionOwners?.length ?? 0) > 0 - ? 'present' - : provider.providesAgentSessionOwnerListings?.(session.id) === true - ? 'absent' - : 'unknown' - }) + const authorityPhysicalPtyId = + provider.resolveTerminalSessionAuthorityPhysicalPtyId === undefined + ? rawSession.id + : provider.resolveTerminalSessionAuthorityPhysicalPtyId(rawSession.id) + const session = admission.admit(rawSession, authorityPhysicalPtyId) + if (admittedIds.has(session.id)) { + throw new Error('pty_process_list_duplicate') + } + admittedIds.add(session.id) + admittedRows.push({ source, session }) } } ) + const publishableRows = admittedRows.filter(({ source }) => + isCurrentRegisteredPtyProvider(source) + ) + for (const { source, session } of publishableRows) { + const { provider, connectionId } = source + // Why: kill actions only send back the PTY id, so rebuild ownership while listing to keep reconnect-discovered remote sessions routed to their exact provider. + setPtyOwnership(session.id, connectionId) + deduped.set(session.id, { + id: session.id, + cwd: session.cwd, + title: session.title, + // Why: the renderer's binding map is empty during restore, so ownership is the only + // liveness evidence it has. Absence is authoritative only from a provider that + // serializes claims — otherwise it is 'unknown', never 'absent' (#8459). + agentOwnership: + (session.agentSessionOwners?.length ?? 0) > 0 + ? 'present' + : provider.providesAgentSessionOwnerListings?.(session.id) === true + ? 'absent' + : 'unknown', + administrativeMutationAccess: ptyAdministrativeMutationAccess(session.id, provider) + }) + } return Array.from(deduped.values()) }) diff --git a/src/main/ipc/remote-workspace.ts b/src/main/ipc/remote-workspace.ts index 713fe1b8a17..49ec980fe2d 100644 --- a/src/main/ipc/remote-workspace.ts +++ b/src/main/ipc/remote-workspace.ts @@ -57,6 +57,13 @@ function getCachedRemoteWorkspaceSnapshot(targetId: string): RemoteWorkspaceSnap return snapshot } +/** Latest observed remote session for a target; legacy migration reads it as evidence only. */ +export function getRemoteWorkspaceSnapshotForTarget( + targetId: string +): RemoteWorkspaceSnapshot | undefined { + return getCachedRemoteWorkspaceSnapshot(targetId) +} + export function _resetRemoteWorkspaceCachesForTests(): void { latestSnapshotByTargetId.clear() remoteWorkspacePatchTailByTargetId.clear() @@ -84,7 +91,7 @@ export function _rememberRemoteWorkspaceSnapshotForTests( export function _getRemoteWorkspaceSnapshotForTests( targetId: string ): RemoteWorkspaceSnapshot | undefined { - return getCachedRemoteWorkspaceSnapshot(targetId) + return getRemoteWorkspaceSnapshotForTarget(targetId) } function emptyRemoteSession(): RemoteWorkspaceSession { diff --git a/src/main/ipc/ssh-pty-output-exit-deadline.ts b/src/main/ipc/ssh-pty-output-exit-deadline.ts index 169175f1c44..6507280f137 100644 --- a/src/main/ipc/ssh-pty-output-exit-deadline.ts +++ b/src/main/ipc/ssh-pty-output-exit-deadline.ts @@ -9,6 +9,7 @@ import type { SshPtyOutputSourceObligations, SshPtySourceCancellationProofCommit } from './ssh-pty-output-source-obligations' +import { SshPtyClosedGenerationRanges } from './ssh-pty-closed-generation-ranges' type SshPtyOutputExitDeadlineDependencies = Readonly<{ admission: SshPtyModelAdmission @@ -21,8 +22,11 @@ type SshPtyOutputExitDeadlineDependencies = Readonly<{ export class SshPtyOutputExitDeadline { private readonly barriersByGeneration = new Map>() + private readonly closedGenerations = new SshPtyClosedGenerationRanges() private readonly preparedExits = new Set() + private readonly preparingExits = new Map>() private readonly preparedExitReleases = new Map void>() + private readonly releasedBeforePreparation = new Set() private readonly barrierMs: number private readonly cancellationProofMs: number @@ -100,6 +104,7 @@ export class SshPtyOutputExitDeadline { } closeGeneration(providerGeneration: number, error: Error): void { + this.closedGenerations.add(providerGeneration) const barriers = this.barriersByGeneration.get(providerGeneration) if (barriers) { for (const barrier of barriers) { @@ -117,15 +122,35 @@ export class SshPtyOutputExitDeadline { } } - prepareExitOnce(event: SshPtyOutputExitEvent): void { + async prepareExitOnce(event: SshPtyOutputExitEvent): Promise { const key = this.exitKey(event) if (this.preparedExits.has(key)) { return } - const release = this.dependencies.intake.prepareExit(event) - this.preparedExits.add(key) - if (release) { - this.preparedExitReleases.set(key, release) + const existing = this.preparingExits.get(key) + if (existing) { + await existing + return + } + const preparation = Promise.resolve(this.dependencies.intake.prepareExit(event)).then( + (release) => { + const releasedBeforePreparation = this.releasedBeforePreparation.delete(key) + if (this.closedGenerations.has(event.providerGeneration) || releasedBeforePreparation) { + release?.() + return + } + this.preparedExits.add(key) + if (release) { + this.preparedExitReleases.set(key, release) + } + } + ) + this.preparingExits.set(key, preparation) + try { + await preparation + } finally { + this.releasedBeforePreparation.delete(key) + this.preparingExits.delete(key) } } @@ -170,7 +195,7 @@ export class SshPtyOutputExitDeadline { } this.dependencies.projections.transferPty(event.id, 'ssh-exit-delivery-canceled') this.dependencies.sourceObligations.commitPtyCancellationProof(commit) - this.prepareExitOnce(event) + await this.prepareExitOnce(event) if (!this.isActive(event.providerGeneration, barrier)) { return } @@ -196,6 +221,9 @@ export class SshPtyOutputExitDeadline { private releasePreparedExit(event: SshPtyOutputExitEvent): void { const key = this.exitKey(event) + if (this.preparingExits.has(key) && !this.preparedExits.has(key)) { + this.releasedBeforePreparation.add(key) + } this.preparedExits.delete(key) this.releasePreparedExitKey(key) } diff --git a/src/main/ipc/ssh-pty-output-exit.ts b/src/main/ipc/ssh-pty-output-exit.ts index 99181653135..57568c5504c 100644 --- a/src/main/ipc/ssh-pty-output-exit.ts +++ b/src/main/ipc/ssh-pty-output-exit.ts @@ -11,7 +11,7 @@ export async function settleSshPtyOutputExit(args: { projections: SshPtyLegacyProjectionLedger dependencies: SshPtyOutputIntakeDependencies validateGeneration: () => void - prepareExit?: () => void + prepareExit?: () => void | Promise afterAdmissionIdle?: () => void waitForSourceTerminal?: () => Promise beforeFinalize?: () => void @@ -34,11 +34,7 @@ export async function settleSshPtyOutputExit(args: { validateGeneration() afterAdmissionIdle?.() try { - if (prepareExit) { - prepareExit() - } else { - dependencies.prepareExit(event) - } + await (prepareExit ? prepareExit() : dependencies.prepareExit(event)) } catch (error) { projections.closePty( event.id, diff --git a/src/main/ipc/ssh-pty-output-intake-contract.ts b/src/main/ipc/ssh-pty-output-intake-contract.ts index 9e2c5d7a101..c3427ddf05f 100644 --- a/src/main/ipc/ssh-pty-output-intake-contract.ts +++ b/src/main/ipc/ssh-pty-output-intake-contract.ts @@ -53,7 +53,7 @@ export type SshPtyOutputIntakeDependencies = { projection: LegacySshProjectionSemantics ) => { sequence: number; completion: Promise } project: (event: SshPtyOutputDataEvent, projection: LegacySshProjectionSemantics) => void - prepareExit: (event: SshPtyOutputExitEvent) => void | (() => void) + prepareExit: (event: SshPtyOutputExitEvent) => void | (() => void) | Promise void)> finalizeExit: (event: SshPtyOutputExitEvent) => void pauseProvider?: (providerGeneration: number, id: string) => boolean resumeProvider?: (providerGeneration: number, id: string) => void diff --git a/src/main/ipc/ssh-pty-output-intake.test.ts b/src/main/ipc/ssh-pty-output-intake.test.ts index be38872da04..65b3ae193f0 100644 --- a/src/main/ipc/ssh-pty-output-intake.test.ts +++ b/src/main/ipc/ssh-pty-output-intake.test.ts @@ -443,6 +443,36 @@ describe('SshPtyOutputIntake', () => { expect(releaseRendererExit).toHaveBeenCalledOnce() }) + it('keeps projection obligations pending while renderer binding preparation is fenced', async () => { + let releaseBinding!: () => void + const binding = new Promise((resolve) => { + releaseBinding = resolve + }) + const harness = createHarness({ + prepareExit: vi.fn(async () => { + await binding + }) + }) + const dataReceipt = harness.intake.acceptData(event()) + harness.completions[0]!.resolve() + await dataReceipt + const exit = harness.intake.acceptExit({ + id: 'pty-1', + code: 0, + providerGeneration: 1, + ptyIncarnation: 'incarnation-1' + }) + await Promise.resolve() + + expect(harness.intake.getDebugSnapshot().projection.records).toBe(1) + expect(harness.order).not.toContain('exit') + + releaseBinding() + await exit + expect(harness.order.at(-1)).toBe('exit') + expect(harness.intake.getDebugSnapshot().projection.records).toBe(0) + }) + it('releases renderer exit preparation when generation close aborts finalization', async () => { const releaseRendererExit = vi.fn() const harness = createHarness({ @@ -471,6 +501,35 @@ describe('SshPtyOutputIntake', () => { expect(harness.order).not.toContain('exit') }) + it('releases renderer exit preparation that completes after generation close', async () => { + let completePreparation!: (release: () => void) => void + const releaseRendererExit = vi.fn() + const harness = createHarness({ + prepareExit: vi.fn( + () => + new Promise<() => void>((resolve) => { + completePreparation = resolve + }) + ) + }) + const exit = harness.intake.acceptExit({ + id: 'pty-1', + code: 0, + providerGeneration: 1, + ptyIncarnation: 'incarnation-1' + }) + await Promise.resolve() + + harness.intake.closeGeneration(1, 'provider-replaced') + await expect(exit).rejects.toThrow('provider-replaced') + expect(releaseRendererExit).not.toHaveBeenCalled() + + completePreparation(releaseRendererExit) + await Promise.resolve() + expect(releaseRendererExit).toHaveBeenCalledOnce() + expect(harness.order).not.toContain('exit') + }) + it('retains exit until a required remote source consumer settles', async () => { const harness = createHarness({}, { exitBarrierMs: 1000 }) const remote = harness.intake.getRemoteSourceRangeConsumerHooks() diff --git a/src/main/ipc/ssh.test.ts b/src/main/ipc/ssh.test.ts index 538cf4bf846..423751356cf 100644 --- a/src/main/ipc/ssh.test.ts +++ b/src/main/ipc/ssh.test.ts @@ -74,7 +74,12 @@ const { onReplay: vi.fn(), attach: vi.fn(), attachForReconnect: vi.fn().mockResolvedValue({}), + listProcesses: vi.fn(), + getPtyMutationRouteToken: vi.fn(), + killExact: vi.fn(), + killAuthorityExact: vi.fn(), shutdown: vi.fn(), + mutationRouteToken: Object.freeze({}) as object, providerGeneration: 0 }, mockFsProvider: {}, @@ -175,7 +180,7 @@ vi.mock('../ssh/ssh-relay-reset', () => ({ vi.mock('../ssh/ssh-channel-multiplexer', () => ({ SshChannelMultiplexer: class MockSshChannelMultiplexer { constructor() { - return mockMux + return Object.create(mockMux) } } })) @@ -207,6 +212,7 @@ vi.mock('./pty', () => ({ deletePtyOwnership: vi.fn(), setPtyOwnership: vi.fn(), getSshPtyProvider: vi.fn(), + waitForSshPtyPendingCloseReplay: vi.fn().mockResolvedValue(undefined), getPtyIdsForConnection: vi.fn().mockReturnValue([]), isCurrentPtyExit: vi.fn().mockReturnValue(true), isRendererPtyOutputPaused: vi.fn().mockReturnValue(false) @@ -275,6 +281,7 @@ import { type SshTarget } from '../../shared/ssh-types' import { PTY_CONSUMER_SESSION_PROTOCOL_VERSION } from '../../shared/pty-consumer-session' +import { PTY_EXACT_OPERATION_PROTOCOL_VERSION } from '../../shared/pty-exact-operation-protocol' import { DEFAULT_PTY_SOURCE_WINDOW_SU } from '../../shared/pty-source-credit-contract' import type { SshPtyDataCallback } from '../providers/ssh-pty-provider-contract' import { @@ -310,7 +317,9 @@ describe('SSH IPC handlers', () => { markSshRemotePtyLeasesAsync: vi.fn(), markSshRemotePtyLeasesForShutdown: vi.fn(), markSshRemotePtyLeasesAttachedAsync: vi.fn(), - removeSshRemotePtyLeases: vi.fn() + removeSshRemotePtyLeases: vi.fn(), + requestSshRemotePtyClose: vi.fn().mockReturnValue('recorded'), + clearSshRemotePtyCloseRequest: vi.fn().mockReturnValue(true) } const mockWindow = { isDestroyed: () => false, @@ -347,6 +356,7 @@ describe('SSH IPC handlers', () => { const createRelayLaunchResult = () => ({ transport: { write: vi.fn(), onData: vi.fn(), onClose: vi.fn() }, platform: 'linux-x64', + terminalAuthorityOwnerBuildId: relayBuildId, serverBuildId: relayBuildId }) const getLatestRelayDisposeCallback = (): RelayDisposeCallback => { @@ -390,6 +400,8 @@ describe('SSH IPC handlers', () => { mockStore.markSshRemotePtyLeasesAttachedAsync.mockReset() mockStore.removeSshRemotePtyLeases.mockReset() mockStore.upsertSshPtyConsumerRecovery.mockReset() + mockStore.requestSshRemotePtyClose.mockReset().mockReturnValue('recorded') + mockStore.clearSshRemotePtyCloseRequest.mockReset().mockReturnValue(true) mockConnectionManager.connect.mockReset() mockConnectionManager.disconnect.mockReset() @@ -407,6 +419,7 @@ describe('SSH IPC handlers', () => { mockDeployAndLaunchRelay.mockReset().mockResolvedValue({ transport: { write: vi.fn(), onData: vi.fn(), onClose: vi.fn() }, platform: 'linux-x64', + terminalAuthorityOwnerBuildId: relayBuildId, serverBuildId: relayBuildId }) mockMux.dispose.mockReset() @@ -426,7 +439,11 @@ describe('SSH IPC handlers', () => { ownerLease: 'ipc-test-owner', resumed: false, capabilities: { - outputFlowControl: { version: 1, windowSu: DEFAULT_PTY_SOURCE_WINDOW_SU } + outputFlowControl: { version: 1, windowSu: DEFAULT_PTY_SOURCE_WINDOW_SU }, + exactOperations: { version: PTY_EXACT_OPERATION_PROTOCOL_VERSION }, + terminalAuthorityExactOperations: { version: 1 }, + terminalAuthorityOutcomeDelivery: { version: 1 }, + terminalAuthorityTopology: { version: 1 } } } : {} @@ -438,6 +455,37 @@ describe('SSH IPC handlers', () => { mockPtyProvider.onReplay.mockReset() mockPtyProvider.attachForReconnect.mockReset().mockResolvedValue({}) mockPtyProvider.shutdown.mockReset() + mockPtyProvider.mutationRouteToken = Object.freeze({}) + mockPtyProvider.getPtyMutationRouteToken + .mockReset() + .mockImplementation(() => mockPtyProvider.mutationRouteToken) + mockPtyProvider.listProcesses.mockReset().mockImplementation(async () => { + const ids = new Set([ + ...vi.mocked(getPtyIdsForConnection)('ssh-1'), + ...mockStore + .getSshRemotePtyLeases('ssh-1') + .filter((lease) => lease.state !== 'terminated') + .map((lease) => lease.ptyId) + ]) + return [...ids].map((id) => { + const relayPtyId = id.startsWith('ssh:') ? id.split('@@').slice(1).join('@@') : id + const lease = mockStore + .getSshRemotePtyLeases('ssh-1') + .find((candidate) => candidate.ptyId === relayPtyId) + return { + id: `ssh:ssh-1@@${relayPtyId}`, + incarnationId: lease?.incarnationId ?? `incarnation:${relayPtyId}`, + mutationRouteToken: mockPtyProvider.mutationRouteToken, + cwd: '/remote', + title: 'shell' + } + }) + }) + mockPtyProvider.killExact.mockReset().mockImplementation(async (id, _incarnationId, opts) => { + await mockPtyProvider.shutdown(id, opts) + return true + }) + mockPtyProvider.killAuthorityExact.mockReset().mockResolvedValue(true) mockPtyProvider.providerGeneration = 0 mockRegisterSshGitProvider.mockReset() mockPortForwardManager.addForward.mockReset() @@ -451,7 +499,9 @@ describe('SSH IPC handlers', () => { mockPortForwardManager.callbacksRef.current = null powerMonitorOnMock.mockReset() powerMonitorOffMock.mockReset() - vi.mocked(getSshPtyProvider).mockReset() + vi.mocked(getSshPtyProvider) + .mockReset() + .mockReturnValue(mockPtyProvider as never) vi.mocked(getPtyIdsForConnection).mockReset().mockReturnValue([]) vi.mocked(clearProviderPtyState).mockReset() vi.mocked(deletePtyOwnership).mockReset() @@ -1026,6 +1076,7 @@ describe('SSH IPC handlers', () => { } mockDeployAndLaunchRelay.mockResolvedValueOnce({ transport: { write: vi.fn(), onData: vi.fn(), onClose: vi.fn() }, + terminalAuthorityOwnerBuildId: relayBuildId, serverBuildId: relayBuildId, hostPlatform }) @@ -2661,12 +2712,30 @@ describe('SSH IPC handlers', () => { }) it('ssh:terminateSessions cannot reach expired leases without a relay', async () => { + const target: SshTarget = { + id: 'ssh-1', + label: 'Server', + host: 'example.com', + port: 22, + username: 'deploy' + } + mockSshStore.getTarget.mockReturnValue(target) + mockConnectionManager.connect.mockResolvedValue({}) + mockConnectionManager.getState.mockReturnValue({ + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + }) mockStore.getSshRemotePtyLeases.mockReturnValue([ { targetId: 'ssh-1', ptyId: 'pty-expired', state: 'expired' } ]) - vi.mocked(getSshPtyProvider).mockReturnValue(undefined) + vi.mocked(getSshPtyProvider).mockReturnValue(mockPtyProvider as never) vi.mocked(getPtyIdsForConnection).mockReturnValue([]) + await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) + vi.mocked(getSshPtyProvider).mockReturnValue(undefined) + await expect( handlers.get('ssh:terminateSessions')!(null, { targetId: 'ssh-1' }) ).resolves.toBeUndefined() @@ -2712,7 +2781,7 @@ describe('SSH IPC handlers', () => { ) }) - it('ssh:terminateSessions tombstones an expired lease the relay reports gone', async () => { + it('ssh:terminateSessions preserves an expired lease when the relay reports not found', async () => { const target: SshTarget = { id: 'ssh-1', label: 'Server', @@ -2736,13 +2805,16 @@ describe('SSH IPC handlers', () => { mockPtyProvider.shutdown.mockRejectedValue(new Error('PTY "pty-abandoned" not found')) await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - await handlers.get('ssh:terminateSessions')!(null, { targetId: 'ssh-1' }) + await expect( + handlers.get('ssh:terminateSessions')!(null, { targetId: 'ssh-1' }) + ).rejects.toThrow('PTY "pty-abandoned" not found') - expect(mockStore.markSshRemotePtyLease).toHaveBeenCalledWith( + expect(mockStore.markSshRemotePtyLease).not.toHaveBeenCalledWith( 'ssh-1', 'pty-abandoned', 'terminated' ) + expect(mockConnectionManager.disconnect).not.toHaveBeenCalledWith('ssh-1') }) it('ssh:terminateSessions leaves leases it already proved terminated alone', async () => { @@ -3260,7 +3332,7 @@ describe('SSH IPC handlers', () => { }) await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) - mockMux.notify.mockClear() + mockMux.request.mockClear() const suspendListener = powerMonitorOnMock.mock.calls.find( ([event]) => event === 'suspend' @@ -3269,9 +3341,11 @@ describe('SSH IPC handlers', () => { suspendListener() - expect(mockMux.notify).toHaveBeenCalledWith(SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD, { - graceTimeSeconds: 0 - }) + await vi.waitFor(() => + expect(mockMux.request).toHaveBeenCalledWith(SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD, { + graceTimeSeconds: 0 + }) + ) }) it('ssh:resetRelay expires active-session leases instead of marking them terminated', async () => { diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index 72fb560c10f..e35dd1e201e 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -30,8 +30,9 @@ import { isRuntimeOwnedSshTargetId } from '../../shared/execution-host' import { isAuthError } from '../ssh/ssh-connection-utils' import { createCancelledConnectAttemptError } from '../ssh/ssh-connect-attempt-cancellation' import { forceStopRelayForTarget } from '../ssh/ssh-relay-reset' -import { isSshPtyNotFoundError } from '../providers/ssh-pty-errors' -import { toAppSshPtyId, toRelaySshPtyId } from '../providers/ssh-pty-id' +import { toAppSshPtyId } from '../providers/ssh-pty-id' +import { terminateListedSshTerminalSessions } from '../ssh/ssh-terminal-session-termination' +import { prepareSshTerminalAuthorityExitWait } from '../ssh/ssh-terminal-authority-exit-wait' import { registerSshBrowseHandler } from './ssh-browse' import { getConnectionIdsForWorktree, @@ -58,6 +59,7 @@ import { resetSshProviderAuthorities, rotateSshProviderAuthority } from '../ssh/ssh-provider-authority' +import { getTerminalAuthorityConsumerProofKeypair } from '../session-authority/terminal-authority-consumer-proof-keypair' let sshStore: SshConnectionStore | null = null let connectionManager: SshConnectionManager | null = null @@ -128,6 +130,13 @@ export async function disconnectRegisteredSshTarget(targetId: string): Promise { + const targetIds = [...activeSessions.keys()] + await Promise.all(targetIds.map((targetId) => disconnectRegisteredSshTarget(targetId))) + await connectionManager?.disconnectAll() +} + export async function removeRegisteredSshTarget(targetId: string): Promise { if (!sshStore) { return @@ -619,7 +628,13 @@ function registerPowerMonitorReconnect(): void { powerMonitorUnsubscribe?.() const onSuspend = (): void => { for (const session of activeSessions.values()) { - session.prepareForHostSleep() + void session.prepareForHostSleep().catch((error: unknown) => { + console.warn( + `[ssh] Failed to extend relay grace before suspend: ${ + error instanceof Error ? error.message : String(error) + }` + ) + }) } } const onResume = (): void => { @@ -1183,7 +1198,9 @@ export function registerSshHandlers( persistedStore!, portForwardManager!, currentRuntime, - broadcastDetectedPortsFromCurrentWindow + broadcastDetectedPortsFromCurrentWindow, + undefined, + getTerminalAuthorityConsumerProofKeypair() ) configureRelaySessionCallbacks(session) activeSessions.set(targetId, session) @@ -1280,59 +1297,40 @@ export function registerSshHandlers( await runTargetLifecycle(args.targetId, async () => { const provider = getSshPtyProvider(args.targetId) const leases = persistedStore!.getSshRemotePtyLeases(args.targetId) - const ptyIdsByRelayId = new Map() + const trackedPtyIds = getPtyIdsForConnection(args.targetId) // Why: only leases the app still believes it owns may force a reconnect; 'expired' ones are // swept opportunistically because they can name a host that is gone for good (issue #2626). - const ownedRelayIds = new Set() - const trackPtyId = (ptyId: string, owned: boolean): void => { - const relayPtyId = toRelaySshPtyId(args.targetId, ptyId) - if (!ptyIdsByRelayId.has(relayPtyId)) { - ptyIdsByRelayId.set(relayPtyId, toAppSshPtyId(args.targetId, ptyId)) - } - if (owned) { - ownedRelayIds.add(relayPtyId) - } - } - for (const ptyId of getPtyIdsForConnection(args.targetId)) { - trackPtyId(ptyId, true) - } - for (const lease of leases) { - if (lease.state === 'terminated') { - continue - } - // Why: 'expired' records that reattach gave up, never that the remote shell died — those are - // precisely the orphans, so the user's terminate action has to be able to reach them. - trackPtyId(lease.ptyId, lease.state !== 'expired') - } - const ptyIds = Array.from(ptyIdsByRelayId, ([relayPtyId, appPtyId]) => ({ - relayPtyId, - appPtyId - })) - - if (ownedRelayIds.size > 0 && !provider) { + const hasOwnedPtys = + trackedPtyIds.length > 0 || + leases.some((lease) => lease.state !== 'expired' && lease.state !== 'terminated') + if (hasOwnedPtys && !provider) { throw new Error( `${SSH_TERMINATE_RECONNECT_REQUIRED}: SSH relay is not connected; reconnect before terminating remote sessions.` ) } - const shutdownResults = provider - ? await Promise.allSettled( - ptyIds.map(({ appPtyId }) => - provider.shutdown(appPtyId, { immediate: true, keepHistory: false }) - ) - ) + const terminations = provider + ? await terminateListedSshTerminalSessions({ + targetId: args.targetId, + provider, + trackedPtyIds, + leases, + prepareAuthorityExitWait: (target) => + prepareSshTerminalAuthorityExitWait(persistedStore!, target) + }) : [] const shutdownFailures: string[] = [] - for (const [index, result] of shutdownResults.entries()) { - const { appPtyId, relayPtyId } = ptyIds[index] - if (result.status !== 'fulfilled' && !isSshPtyNotFoundError(result.reason)) { - shutdownFailures.push( - `${relayPtyId}: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}` - ) + for (const termination of terminations) { + if (termination.status !== 'terminated') { + const detail = + termination.status === 'acceptedPending' + ? 'terminal exit outcome is still pending' + : (termination.error?.message ?? termination.status) + shutdownFailures.push(`${termination.relayPtyId}: ${detail}`) continue } - clearProviderPtyState(appPtyId) - deletePtyOwnership(appPtyId) - persistedStore!.markSshRemotePtyLease(args.targetId, relayPtyId, 'terminated') + clearProviderPtyState(termination.appPtyId) + deletePtyOwnership(termination.appPtyId) + persistedStore!.markSshRemotePtyLease(args.targetId, termination.relayPtyId, 'terminated') } if (shutdownFailures.length > 0) { // Why: a failed relay shutdown can leave the remote process alive in the grace window; keep the lease/session so the user can retry. diff --git a/src/main/ipc/worktree-base-directory-watcher.ts b/src/main/ipc/worktree-base-directory-watcher.ts index 382a21dfc08..190c2182a5b 100644 --- a/src/main/ipc/worktree-base-directory-watcher.ts +++ b/src/main/ipc/worktree-base-directory-watcher.ts @@ -17,6 +17,7 @@ import { supportsWorktreeHeadIdentityRefresh } from './worktree-base-directory-notifications' import type { WorktreeBaseWatchTarget } from './worktree-base-directory-event-filter' +import { registerMainWindowClosedCleanup } from '../window/main-window-lifecycle' import { buildWorktreeBaseDirectoryWatchTargets, clearWorktreeBaseDirectoryWatchTargetWarnings @@ -277,13 +278,11 @@ export function setWorktreeBaseDirectoryWatcherSyncContext( latestSyncContext = { store, mainWindow } // Why: older integration tests use lean BrowserWindow stubs; real windows still // clear this context on close so stale watcher syncs cannot target dead chrome. - if (typeof mainWindow.once === 'function') { - mainWindow.once('closed', () => { - if (latestSyncContext?.mainWindow === mainWindow) { - latestSyncContext = null - } - }) - } + registerMainWindowClosedCleanup(mainWindow, () => { + if (latestSyncContext?.mainWindow === mainWindow) { + latestSyncContext = null + } + }) } export function scheduleWorktreeBaseDirectoryWatcherSync( diff --git a/src/main/macos-tcc-prompt-notice.ts b/src/main/macos-tcc-prompt-notice.ts index c51367d8c6a..053fd7cc4f1 100644 --- a/src/main/macos-tcc-prompt-notice.ts +++ b/src/main/macos-tcc-prompt-notice.ts @@ -4,6 +4,7 @@ import type { BrowserWindow } from 'electron' import { writeFileAtomically } from './codex-accounts/fs-utils' import { getCanonicalUserDataPath } from './persistence' import { MacosTccPromptWatch } from './macos-tcc-prompt-watch' +import { registerMainWindowClosedCleanup } from './window/main-window-lifecycle' /** * Surfaces Full Disk Access guidance only to users macOS is actually prompting @@ -162,7 +163,7 @@ export function dismissTccPromptNotice(): void { function trackMainWindow(mainWindow: BrowserWindow): void { mainWindowRef = mainWindow - mainWindow.once('closed', () => { + registerMainWindowClosedCleanup(mainWindow, () => { if (mainWindowRef === mainWindow) { mainWindowRef = null } diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 0a48bbbb343..f016ac57bf2 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -59,6 +59,15 @@ const TEST_LEAF_1 = '11111111-1111-4111-8111-111111111111' const TEST_LEAF_2 = '22222222-2222-4222-8222-222222222222' const TEST_LEAF_LIVE = '33333333-3333-4333-8333-333333333333' const TEST_LEAF_EXPIRED = '44444444-4444-4444-8444-444444444444' +const TEST_SSH_TERMINAL_AUTHORITY_ACCESS = { + namespace: { authorityHostId: 'authority-host-1', namespaceId: 'namespace-1' }, + pane: { paneKey: 'tab:tab1/leaf:leaf1', paneGenerationId: 'generation-1' }, + binding: { + ownerIncarnationId: 'owner-1', + physicalPtyId: 'remote-pty', + ptyIncarnationId: 'incarnation-1' + } +} as const const REORDERED_DEFAULT_WORKSPACE_STATUSES = [ { id: 'completed', label: 'Completed', color: 'conductor-done', icon: 'conductor-done' }, { id: 'in-review', label: 'In review', color: 'conductor-review', icon: 'conductor-review' }, @@ -8104,9 +8113,11 @@ describe('Store', () => { { targetId: 'ssh-1', ptyId: 'remote-pty', + incarnationId: 'incarnation-1', worktreeId: 'wt1', tabId: 'tab1', leafId: 'pane:1', + paneGeneration: 7, state: 'detached', createdAt: 1, updatedAt: 1 @@ -8123,6 +8134,10 @@ describe('Store', () => { expect(isTerminalLeafId(leafId)).toBe(true) expect(layout.ptyIdsByLeafId).toEqual({ [leafId]: 'remote-pty' }) expect(store.getSshRemotePtyLeases('ssh-1')[0].leafId).toBe(leafId) + expect(store.getSshRemotePtyLeases('ssh-1')[0]).toMatchObject({ + incarnationId: 'incarnation-1', + paneGeneration: 7 + }) }) it('hydrates legacy numeric agent status cache through the pane identity migration', async () => { @@ -10705,6 +10720,141 @@ describe('Store', () => { expect(session.terminalLayoutsByTabId.tab1.ptyIdsByLeafId).toEqual({}) }) + it('persists an exact SSH close request before returning', async () => { + const store = await createStore() + store.upsertSshRemotePtyLease({ + targetId: 'ssh-1', + ptyId: 'remote-pty', + incarnationId: 'incarnation-1', + terminalSessionAuthorityAccess: TEST_SSH_TERMINAL_AUTHORITY_ACCESS, + state: 'detached' + }) + + expect( + store.requestSshRemotePtyClose('ssh-1', 'remote-pty', { + incarnationId: 'incarnation-1', + terminalSessionAuthorityAccess: TEST_SSH_TERMINAL_AUTHORITY_ACCESS, + keepHistory: true, + requestedAt: 123 + }) + ).toBe('recorded') + + const reloaded = await createStore() + expect(reloaded.getSshRemotePtyLeases('ssh-1')).toEqual([ + expect.objectContaining({ + ptyId: 'remote-pty', + incarnationId: 'incarnation-1', + state: 'detached', + pendingClose: { + incarnationId: 'incarnation-1', + terminalSessionAuthorityAccess: TEST_SSH_TERMINAL_AUTHORITY_ACCESS, + keepHistory: true, + requestedAt: 123 + } + }) + ]) + }) + + it('rolls back an SSH close request when its durability barrier fails', async () => { + const store = await createStore() + store.upsertSshRemotePtyLease({ + targetId: 'ssh-1', + ptyId: 'remote-pty', + incarnationId: 'incarnation-1', + terminalSessionAuthorityAccess: TEST_SSH_TERMINAL_AUTHORITY_ACCESS, + state: 'detached' + }) + const flush = vi.spyOn(store, 'flushOrThrow').mockImplementationOnce(() => { + throw new Error('disk unavailable') + }) + + expect(() => + store.requestSshRemotePtyClose('ssh-1', 'remote-pty', { + incarnationId: 'incarnation-1', + terminalSessionAuthorityAccess: TEST_SSH_TERMINAL_AUTHORITY_ACCESS, + keepHistory: false, + requestedAt: 123 + }) + ).toThrow('disk unavailable') + flush.mockRestore() + + expect(store.getSshRemotePtyLeases('ssh-1')[0]).not.toHaveProperty('pendingClose') + }) + + it('does not replace a pending SSH close with a reused incarnation', async () => { + const store = await createStore() + store.upsertSshRemotePtyLease({ + targetId: 'ssh-1', + ptyId: 'remote-pty', + incarnationId: 'incarnation-1', + terminalSessionAuthorityAccess: TEST_SSH_TERMINAL_AUTHORITY_ACCESS, + state: 'detached' + }) + store.requestSshRemotePtyClose('ssh-1', 'remote-pty', { + incarnationId: 'incarnation-1', + terminalSessionAuthorityAccess: TEST_SSH_TERMINAL_AUTHORITY_ACCESS, + keepHistory: false, + requestedAt: 123 + }) + + expect(() => + store.upsertSshRemotePtyLease({ + targetId: 'ssh-1', + ptyId: 'remote-pty', + incarnationId: 'incarnation-2', + state: 'attached' + }) + ).toThrow('ssh_pty_pending_close_incarnation_conflict') + expect(store.getSshRemotePtyLeases('ssh-1')[0]).toMatchObject({ + incarnationId: 'incarnation-1', + pendingClose: { incarnationId: 'incarnation-1' } + }) + }) + + it('settles only the exact pending SSH close and clears it on terminal states', async () => { + const store = await createStore() + store.upsertSshRemotePtyLease({ + targetId: 'ssh-1', + ptyId: 'remote-pty', + incarnationId: 'incarnation-1', + terminalSessionAuthorityAccess: TEST_SSH_TERMINAL_AUTHORITY_ACCESS, + state: 'detached' + }) + store.requestSshRemotePtyClose('ssh-1', 'remote-pty', { + incarnationId: 'incarnation-1', + terminalSessionAuthorityAccess: TEST_SSH_TERMINAL_AUTHORITY_ACCESS, + keepHistory: false, + requestedAt: 123 + }) + + expect( + store.clearSshRemotePtyCloseRequest('ssh-1', 'remote-pty', { + ...TEST_SSH_TERMINAL_AUTHORITY_ACCESS, + pane: { + ...TEST_SSH_TERMINAL_AUTHORITY_ACCESS.pane, + paneGenerationId: 'generation-2' + } + }) + ).toBe(false) + expect(store.getSshRemotePtyLeases('ssh-1')[0]).toHaveProperty('pendingClose') + + expect( + store.clearSshRemotePtyCloseRequest('ssh-1', 'remote-pty', TEST_SSH_TERMINAL_AUTHORITY_ACCESS) + ).toBe(true) + expect(store.getSshRemotePtyLeases('ssh-1')[0]).not.toHaveProperty('pendingClose') + + store.requestSshRemotePtyClose('ssh-1', 'remote-pty', { + incarnationId: 'incarnation-1', + terminalSessionAuthorityAccess: TEST_SSH_TERMINAL_AUTHORITY_ACCESS, + keepHistory: false, + requestedAt: 124 + }) + + store.markSshRemotePtyLease('ssh-1', 'remote-pty', 'terminated') + expect(store.getSshRemotePtyLeases('ssh-1')[0]).toMatchObject({ state: 'terminated' }) + expect(store.getSshRemotePtyLeases('ssh-1')[0]).not.toHaveProperty('pendingClose') + }) + it('does not revive expired leases when marking a target detached', async () => { const store = await createStore() store.upsertSshRemotePtyLease({ diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 565b6fd0032..358c3879a48 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -92,21 +92,27 @@ import { } from '../shared/workspace-linked-item' import { isWorkspaceLinkedItemSourceContextMatch } from '../shared/workspace-linked-item-source-context' import type { MigrationUnsupportedPtyEntry } from '../shared/agent-status-types' -import { MOBILE_PAIRING_USERDATA_FILES } from './runtime/mobile-pairing-files' import { normalizePersistedMobileClientTabSelections } from './runtime/client-session-tab-selection-persistence' +import { migrateMobilePairingUserdata } from './runtime/mobile-pairing-userdata-migration' import { sanitizeWorkspaceSessionTerminalRetirements } from './runtime/mobile-session-terminal-persistence-retirement' import { removeRepoFromHostWorkspaceSessions, removeRepoFromWorkspaceSession } from './orca-profiles/profile-project-session-state' -import { hardenExistingSecureFile } from '../shared/secure-file' import { LEGACY_DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS, type RemovedSshTargetTombstone, type SshPtyConsumerRecovery, type SshRemotePtyLease, + type SshRemotePtyPendingClose, type SshTarget } from '../shared/ssh-types' +import { isPtyIncarnationId } from '../shared/pty-incarnation' +import { + parseTerminalSessionAuthorityPtyAccess, + sameTerminalSessionAuthorityPtyAccess, + type TerminalSessionAuthorityPtyAccess +} from '../shared/terminal-session-authority-pty-access' import { isFolderRepo } from '../shared/repo-kind' import { getRepoExecutionHostId, @@ -530,28 +536,7 @@ export function getCanonicalUserDataPath(): string { * Copies the registry and E2EE keypair forward as a pair so an update doesn't force a re-pair or mix devices with the wrong key. */ export function migrateMobilePairingDataToCanonicalUserDataPath(sourceUserDataDir: string): void { - const targetUserDataDir = getCanonicalUserDataPath() - if (resolve(sourceUserDataDir) === resolve(targetUserDataDir)) { - return - } - - const migrations = MOBILE_PAIRING_USERDATA_FILES.map((fileName) => ({ - sourcePath: join(sourceUserDataDir, fileName), - targetPath: join(targetUserDataDir, fileName) - })) - if (migrations.some(({ sourcePath }) => !existsSync(sourcePath))) { - return - } - if (migrations.some(({ targetPath }) => existsSync(targetPath))) { - return - } - - mkdirSync(targetUserDataDir, { recursive: true }) - for (const { sourcePath, targetPath } of migrations) { - copyFileSync(sourcePath, targetPath) - // Why: copyFileSync drops Windows ACLs, so re-assert current-user-only on these credential copies (device tokens, E2EE key). - hardenExistingSecureFile(targetPath) - } + migrateMobilePairingUserdata(sourceUserDataDir, getCanonicalUserDataPath()) } // Why (issue #1158): keep 5 rolling backups at >=1h spacing so a corrupt/empty write leaves an earlier copy recoverable. @@ -1616,12 +1601,43 @@ function normalizeSshRemotePtyLease(value: unknown): SshRemotePtyLease | null { return null } const now = Date.now() + const pendingClose = normalizeSshRemotePtyPendingClose(raw.pendingClose) + const authorityAccess = + raw.terminalSessionAuthorityAccess === undefined + ? null + : parseTerminalSessionAuthorityPtyAccess(raw.terminalSessionAuthorityAccess) + if ( + raw.terminalSessionAuthorityAccess !== undefined && + (!authorityAccess || + authorityAccess.binding.physicalPtyId !== raw.ptyId || + (raw.incarnationId !== undefined && + authorityAccess.binding.ptyIncarnationId !== raw.incarnationId)) + ) { + return null + } + if ( + pendingClose?.terminalSessionAuthorityAccess && + !sameTerminalSessionAuthorityPtyAccess( + authorityAccess, + pendingClose.terminalSessionAuthorityAccess + ) + ) { + return null + } return { targetId: raw.targetId, ptyId: raw.ptyId, + ...(typeof raw.incarnationId === 'string' && raw.incarnationId.length <= 128 + ? { incarnationId: raw.incarnationId } + : {}), ...(typeof raw.worktreeId === 'string' ? { worktreeId: raw.worktreeId } : {}), ...(typeof raw.tabId === 'string' ? { tabId: raw.tabId } : {}), ...(typeof raw.leafId === 'string' && raw.leafId.length <= 256 ? { leafId: raw.leafId } : {}), + ...(Number.isSafeInteger(raw.paneGeneration) && Number(raw.paneGeneration) >= 0 + ? { paneGeneration: Number(raw.paneGeneration) } + : {}), + ...(authorityAccess ? { terminalSessionAuthorityAccess: authorityAccess } : {}), + ...(state !== 'terminated' && state !== 'expired' && pendingClose ? { pendingClose } : {}), state, createdAt: typeof raw.createdAt === 'number' ? raw.createdAt : now, updatedAt: typeof raw.updatedAt === 'number' ? raw.updatedAt : now, @@ -1630,6 +1646,28 @@ function normalizeSshRemotePtyLease(value: unknown): SshRemotePtyLease | null { } } +function normalizeSshRemotePtyPendingClose(value: unknown): SshRemotePtyPendingClose | null { + if (!value || typeof value !== 'object') { + return null + } + const raw = value as Partial + const authorityAccess = parseTerminalSessionAuthorityPtyAccess(raw.terminalSessionAuthorityAccess) + return isPtyIncarnationId(raw.incarnationId) && + authorityAccess !== null && + authorityAccess.binding.ptyIncarnationId === raw.incarnationId && + typeof raw.keepHistory === 'boolean' && + typeof raw.requestedAt === 'number' && + Number.isFinite(raw.requestedAt) && + raw.requestedAt >= 0 + ? { + incarnationId: raw.incarnationId, + terminalSessionAuthorityAccess: authorityAccess, + keepHistory: raw.keepHistory, + requestedAt: raw.requestedAt + } + : null +} + const SSH_PTY_OWNER_LEASE_MAX_LENGTH = 512 const ENCRYPTED_SSH_PTY_OWNER_LEASE_MAX_LENGTH = 4096 @@ -2823,7 +2861,6 @@ export class Store { ) => void >() private uiChangeListeners = new Set<(ui: PersistedState['ui']) => void>() - constructor(options: StoreOptions = {}) { // Why: profile switching yields multiple state paths; capture per Store so late async writes can't follow a global path. this.dataFile = options.dataFile ?? getDataFile() @@ -3106,6 +3143,11 @@ export class Store { .map((record) => ({ ...record, ownerLease: decrypt(record.ownerLease) })) .map((record) => normalizeSshPtyConsumerRecovery(record)) .filter((record): record is SshPtyConsumerRecovery => record !== null) + if (Object.hasOwn(parsed as object, 'sshTerminalAuthorityOutcomeReceipts')) { + delete (parsed as PersistedState & { sshTerminalAuthorityOutcomeReceipts?: unknown }) + .sshTerminalAuthorityOutcomeReceipts + this.loadNeedsSave = true + } // Merge with defaults in case new fields were added const homeDir = homedir() @@ -7072,7 +7114,7 @@ export class Store { } upsertSshRemotePtyLease( - lease: Omit & + lease: Omit & Partial> ): void { this.state.sshRemotePtyLeases ??= [] @@ -7091,12 +7133,32 @@ export class Store { entry.targetId === normalizedLease.targetId && entry.ptyId === normalizedLease.ptyId ) const existing = existingIndex >= 0 ? this.state.sshRemotePtyLeases[existingIndex] : undefined + if ( + existing?.pendingClose && + normalizedLease.incarnationId !== undefined && + normalizedLease.incarnationId !== existing.pendingClose.incarnationId + ) { + throw new Error('ssh_pty_pending_close_incarnation_conflict') + } + if ( + existing?.pendingClose?.terminalSessionAuthorityAccess && + normalizedLease.terminalSessionAuthorityAccess && + !sameTerminalSessionAuthorityPtyAccess( + existing.pendingClose.terminalSessionAuthorityAccess, + normalizedLease.terminalSessionAuthorityAccess + ) + ) { + throw new Error('ssh_pty_pending_close_authority_conflict') + } const next: SshRemotePtyLease = { ...existing, ...normalizedLease, createdAt: existing?.createdAt ?? normalizedLease.createdAt ?? now, updatedAt: normalizedLease.updatedAt ?? now } + if (next.state === 'terminated' || next.state === 'expired') { + delete next.pendingClose + } if (existingIndex >= 0) { this.state.sshRemotePtyLeases[existingIndex] = next } else { @@ -7105,6 +7167,112 @@ export class Store { this.flush() } + requestSshRemotePtyClose( + targetId: string, + ptyId: string, + request: SshRemotePtyPendingClose + ): 'recorded' | 'duplicate' { + const pendingClose = normalizeSshRemotePtyPendingClose(request) + if (!pendingClose) { + throw new Error('Invalid SSH remote PTY close request') + } + const relayPtyId = this.getRelayPtyIdForSshLeaseStorage(targetId, ptyId) + const leaseIndex = this.state.sshRemotePtyLeases?.findIndex( + (entry) => entry.targetId === targetId && entry.ptyId === relayPtyId + ) + const lease = + leaseIndex !== undefined && leaseIndex >= 0 + ? this.state.sshRemotePtyLeases?.[leaseIndex] + : undefined + if (!lease || lease.state === 'terminated' || lease.state === 'expired') { + throw new Error('ssh_pty_close_lease_unavailable') + } + if (lease.incarnationId && lease.incarnationId !== pendingClose.incarnationId) { + throw new Error('ssh_pty_pending_close_incarnation_conflict') + } + if (lease.pendingClose && lease.pendingClose.incarnationId !== pendingClose.incarnationId) { + throw new Error('ssh_pty_pending_close_incarnation_conflict') + } + const leaseAccess = parseTerminalSessionAuthorityPtyAccess(lease.terminalSessionAuthorityAccess) + const requestedAccess = parseTerminalSessionAuthorityPtyAccess( + pendingClose.terminalSessionAuthorityAccess + ) + const currentAccess = parseTerminalSessionAuthorityPtyAccess( + lease.pendingClose?.terminalSessionAuthorityAccess + ) + if ( + (leaseAccess && !requestedAccess) || + (requestedAccess && !sameTerminalSessionAuthorityPtyAccess(leaseAccess, requestedAccess)) || + (requestedAccess && + currentAccess && + !sameTerminalSessionAuthorityPtyAccess(currentAccess, requestedAccess)) + ) { + throw new Error('ssh_pty_pending_close_authority_conflict') + } + const nextPendingClose = lease.pendingClose + ? { + ...lease.pendingClose, + ...(requestedAccess ? { terminalSessionAuthorityAccess: requestedAccess } : {}), + keepHistory: lease.pendingClose.keepHistory || pendingClose.keepHistory + } + : pendingClose + if ( + lease.incarnationId === pendingClose.incarnationId && + lease.pendingClose?.keepHistory === nextPendingClose.keepHistory && + ((currentAccess === null && requestedAccess === null) || + (currentAccess !== null && + requestedAccess !== null && + sameTerminalSessionAuthorityPtyAccess(currentAccess, requestedAccess))) + ) { + return 'duplicate' + } + const previous = structuredClone(lease) + lease.incarnationId = pendingClose.incarnationId + lease.pendingClose = nextPendingClose + lease.updatedAt = Date.now() + try { + this.flushOrThrow() + } catch (error) { + this.state.sshRemotePtyLeases![leaseIndex!] = previous + throw error + } + return lease.pendingClose === pendingClose ? 'recorded' : 'duplicate' + } + + clearSshRemotePtyCloseRequest( + targetId: string, + ptyId: string, + authorityAccess: TerminalSessionAuthorityPtyAccess + ): boolean { + const relayPtyId = this.getRelayPtyIdForSshLeaseStorage(targetId, ptyId) + const leaseIndex = this.state.sshRemotePtyLeases?.findIndex( + (entry) => entry.targetId === targetId && entry.ptyId === relayPtyId + ) + const lease = + leaseIndex !== undefined && leaseIndex >= 0 + ? this.state.sshRemotePtyLeases?.[leaseIndex] + : undefined + const pendingAccess = parseTerminalSessionAuthorityPtyAccess( + lease?.pendingClose?.terminalSessionAuthorityAccess + ) + if ( + !lease?.pendingClose || + !sameTerminalSessionAuthorityPtyAccess(pendingAccess, authorityAccess) + ) { + return false + } + const previous = structuredClone(lease) + delete lease.pendingClose + lease.updatedAt = Date.now() + try { + this.flushOrThrow() + } catch (error) { + this.state.sshRemotePtyLeases![leaseIndex!] = previous + throw error + } + return true + } + markSshRemotePtyLeases(targetId: string, state: SshRemotePtyLease['state']): void { if (this.updateSshRemotePtyLeaseStates(targetId, state)) { this.flush() @@ -7170,6 +7338,10 @@ export class Store { changed = true } if (shouldClearBindings) { + if (lease.pendingClose) { + delete lease.pendingClose + changed = true + } leasesToClear.push(lease) } } @@ -7189,7 +7361,15 @@ export class Store { } const shouldClearBindings = state === 'terminated' || state === 'expired' if (lease.state === state) { - if (shouldClearBindings && this.clearSshRemotePtyBindingsForLeases(targetId, [lease])) { + const pendingCloseCleared = shouldClearBindings && lease.pendingClose !== undefined + if (pendingCloseCleared) { + delete lease.pendingClose + lease.updatedAt = Date.now() + } + if ( + (shouldClearBindings && this.clearSshRemotePtyBindingsForLeases(targetId, [lease])) || + pendingCloseCleared + ) { this.flush() } return @@ -7203,6 +7383,7 @@ export class Store { lease.lastDetachedAt = now } if (shouldClearBindings) { + delete lease.pendingClose this.clearSshRemotePtyBindingsForLeases(targetId, [lease]) } this.flush() diff --git a/src/main/providers/local-pty-provider.test.ts b/src/main/providers/local-pty-provider.test.ts index e1c62e9f3cb..58a130d8176 100644 --- a/src/main/providers/local-pty-provider.test.ts +++ b/src/main/providers/local-pty-provider.test.ts @@ -122,10 +122,12 @@ describe('LocalPtyProvider', () => { resize: ReturnType pause: ReturnType resume: ReturnType + clear: ReturnType kill: ReturnType process: string pid: number } + let dataCb: ((data: string) => void) | undefined let exitCb: ((info: { exitCode: number }) => void) | undefined let origShell: string | undefined let origPowerlevelWizardDisable: string | undefined @@ -167,9 +169,13 @@ describe('LocalPtyProvider', () => { readWindowsConptyProcessIdsMock.mockReset() readWindowsConptyProcessIdsMock.mockResolvedValue(null) + dataCb = undefined exitCb = undefined mockProc = { - onData: vi.fn(() => ({ dispose: vi.fn() })), + onData: vi.fn((cb: (data: string) => void) => { + dataCb = cb + return { dispose: vi.fn() } + }), onExit: vi.fn((cb: (info: { exitCode: number }) => void) => { exitCb = cb return { @@ -184,6 +190,7 @@ describe('LocalPtyProvider', () => { resize: vi.fn(), pause: vi.fn(), resume: vi.fn(), + clear: vi.fn(), kill: vi.fn(() => { exitCb?.({ exitCode: -1 }) }), @@ -252,7 +259,8 @@ describe('LocalPtyProvider', () => { expect(second).toEqual({ id: 'serve-session-1', pid: 12345, - isReattach: true + isReattach: true, + incarnationId: first.incarnationId }) expect(mockProc.resize).toHaveBeenCalledWith(120, 40) expect(spawnMock).not.toHaveBeenCalled() @@ -498,6 +506,59 @@ describe('LocalPtyProvider', () => { expect(onSpawned).toHaveBeenCalledWith(id, incarnationId) }) + it('keeps fallback admission legacy while fencing physical incarnation operations', async () => { + const { id, incarnationId } = await provider.spawn({ cols: 80, rows: 24 }) + const killSpy = vi.spyOn(process, 'kill').mockReturnValue(true) + + try { + expect(provider.supportsExactPtyOperations(id)).toBe(false) + expect(provider.getPtyMutationMode(id)).toBe('legacy') + expect(provider.writeExact(id, 'stale-incarnation', 'stale')).toBe(false) + expect(provider.resizeExact(id, 'stale-incarnation', 120, 40)).toBe(false) + await expect(provider.killExact(id, 'stale-incarnation', {})).resolves.toBe(false) + expect(provider.sendSignalExact(id, 'stale-incarnation', 'SIGTERM')).toBe(false) + expect(provider.clearBufferExact(id, 'stale-incarnation')).toBe(false) + expect(provider.writeExact(id, incarnationId!, 'current')).toBe(true) + expect(provider.resizeExact(id, incarnationId!, 120, 40)).toBe(true) + expect(provider.sendSignalExact(id, incarnationId!, 'SIGTERM')).toBe(true) + expect(provider.clearBufferExact(id, incarnationId!)).toBe(true) + expect(mockProc.write).toHaveBeenCalledWith('current') + expect(mockProc.resize).toHaveBeenCalledWith(120, 40) + expect(killSpy).toHaveBeenCalledWith(mockProc.pid, 'SIGTERM') + expect(mockProc.clear).toHaveBeenCalledOnce() + } finally { + killSpy.mockRestore() + } + }) + + it('rejects stale native callbacks after a session id is reused', async () => { + const onData = vi.fn() + const onExit = vi.fn() + provider.configure({ onData, onExit }) + await provider.spawn({ cols: 80, rows: 24, sessionId: 'reused-session' }) + const staleData = dataCb! + const staleExit = exitCb! + + _resetLocalPtyProviderStateForTest() + const replacement = { + ...mockProc, + onData: vi.fn(), + onExit: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn() + } + spawnMock.mockReturnValueOnce(replacement) + await provider.spawn({ cols: 80, rows: 24, sessionId: 'reused-session' }) + + staleData('stale-output') + staleExit({ exitCode: 0 }) + + expect(onData).not.toHaveBeenCalled() + expect(onExit).not.toHaveBeenCalled() + expect(provider.getPtyProcess('reused-session')).toBe(replacement) + }) + it('reports physical commit before post-spawn publication can fail', async () => { spawnMock.mockClear() const committed = vi.fn() @@ -630,7 +691,11 @@ describe('LocalPtyProvider', () => { const onData = vi.fn() provider.configure({ onData }) try { - await provider.spawn({ cols: 80, rows: 24, command: 'printf ready' }) + const { incarnationId } = await provider.spawn({ + cols: 80, + rows: 24, + command: 'printf ready' + }) const dataCallback = mockProc.onData.mock.calls[0]?.[0] as (data: string) => void dataCallback('\x1b]777;orca-shell-ready') @@ -642,7 +707,10 @@ describe('LocalPtyProvider', () => { expect(onData).toHaveBeenCalledWith( expect.any(String), '\x1b]777;orca-shell-ready', - expect.any(Number) + expect.any(Number), + undefined, + undefined, + incarnationId ) expect(mockProc.write).not.toHaveBeenCalled() @@ -658,7 +726,11 @@ describe('LocalPtyProvider', () => { const onData = vi.fn() provider.configure({ onData }) - await provider.spawn({ cols: 80, rows: 24, command: 'printf ready' }) + const { incarnationId } = await provider.spawn({ + cols: 80, + rows: 24, + command: 'printf ready' + }) const dataCallback = mockProc.onData.mock.calls[0]?.[0] as (data: string) => void dataCallback('\x1b]777;orca-shell-ready') @@ -669,7 +741,10 @@ describe('LocalPtyProvider', () => { expect(onData).toHaveBeenCalledWith( expect.any(String), '\x1b]777;orca-shell-ready', - expect.any(Number) + expect.any(Number), + undefined, + undefined, + incarnationId ) }) @@ -1318,9 +1393,90 @@ describe('LocalPtyProvider', () => { provider.resumeProducer(id) }).not.toThrow() }) + + it('holds an exact incarnation paused until the last idempotent lease release', async () => { + const { id, incarnationId } = await provider.spawn({ cols: 80, rows: 24 }) + expect(provider.supportsExactHeldProducerPause(id, incarnationId!)).toBe(true) + + await expect( + provider.acquireExactHeldProducerPause(id, incarnationId!, 'lease-a') + ).resolves.toBe(true) + await expect( + provider.acquireExactHeldProducerPause(id, incarnationId!, 'lease-a') + ).resolves.toBe(true) + await expect( + provider.acquireExactHeldProducerPause(id, incarnationId!, 'lease-b') + ).resolves.toBe(true) + expect(mockProc.pause).toHaveBeenCalledTimes(1) + + await expect( + provider.releaseExactHeldProducerPause(id, incarnationId!, 'lease-a') + ).resolves.toBe(true) + expect(mockProc.resume).not.toHaveBeenCalled() + await expect( + provider.releaseExactHeldProducerPause(id, incarnationId!, 'lease-b') + ).resolves.toBe(true) + await expect( + provider.releaseExactHeldProducerPause(id, incarnationId!, 'lease-b') + ).resolves.toBe(true) + expect(mockProc.resume).toHaveBeenCalledTimes(1) + }) + + it('rejects stale exact pause leases without touching the replacement process', async () => { + const { id, incarnationId } = await provider.spawn({ cols: 80, rows: 24 }) + + expect(provider.supportsExactHeldProducerPause(id, 'stale-incarnation')).toBe(false) + await expect( + provider.acquireExactHeldProducerPause(id, 'stale-incarnation', 'lease') + ).resolves.toBe(false) + await expect( + provider.releaseExactHeldProducerPause(id, 'stale-incarnation', 'lease') + ).resolves.toBe(false) + expect(provider.supportsExactHeldProducerPause(id, incarnationId!)).toBe(true) + expect(mockProc.pause).not.toHaveBeenCalled() + expect(mockProc.resume).not.toHaveBeenCalled() + }) + + it('does not let a legacy resume release an exact held pause', async () => { + const { id, incarnationId } = await provider.spawn({ cols: 80, rows: 24 }) + await provider.acquireExactHeldProducerPause(id, incarnationId!, 'lease') + + provider.pauseProducer(id) + provider.resumeProducer(id) + expect(mockProc.resume).not.toHaveBeenCalled() + + await provider.releaseExactHeldProducerPause(id, incarnationId!, 'lease') + expect(mockProc.resume).toHaveBeenCalledTimes(1) + }) + + it('fails a held-pause acquisition that node-pty did not acknowledge', async () => { + const { id, incarnationId } = await provider.spawn({ cols: 80, rows: 24 }) + mockProc.pause.mockImplementation(() => { + throw new Error('read EIO') + }) + + await expect( + provider.acquireExactHeldProducerPause(id, incarnationId!, 'lease') + ).resolves.toBe(false) + mockProc.pause.mockImplementation(() => undefined) + await expect( + provider.releaseExactHeldProducerPause(id, incarnationId!, 'lease') + ).resolves.toBe(true) + expect(mockProc.resume).not.toHaveBeenCalled() + }) }) describe('shutdown', () => { + it('releases exact held-pause bookkeeping before terminating the PTY', async () => { + const { id, incarnationId } = await provider.spawn({ cols: 80, rows: 24 }) + await provider.acquireExactHeldProducerPause(id, incarnationId!, 'lease') + + await provider.shutdown(id, { immediate: true }) + + expect(mockProc.resume).toHaveBeenCalledTimes(1) + expect(provider.supportsExactHeldProducerPause(id, incarnationId!)).toBe(false) + }) + it('kills the PTY process', async () => { // Why: capture the spy reference before shutdown triggers onExit → // POSIX kill neutralization. After neutralization, mockProc.kill is @@ -1835,13 +1991,13 @@ describe('LocalPtyProvider', () => { it('notifies data listeners when PTY produces output', async () => { const dataHandler = vi.fn() provider.onData(dataHandler) - const { id } = await provider.spawn({ cols: 80, rows: 24 }) + const { id, incarnationId } = await provider.spawn({ cols: 80, rows: 24 }) // Simulate node-pty data event const onDataCb = mockProc.onData.mock.calls[0][0] onDataCb('hello world') - expect(dataHandler).toHaveBeenCalledWith({ id, data: 'hello world' }) + expect(dataHandler).toHaveBeenCalledWith({ id, data: 'hello world', incarnationId }) }) it('classifies startup queries before runtime and public data listeners', async () => { @@ -1850,7 +2006,7 @@ describe('LocalPtyProvider', () => { const dataHandler = vi.fn() provider.configure({ onData: runtimeData }) provider.onData(dataHandler) - const { id } = await provider.spawn({ + const { id, incarnationId } = await provider.spawn({ cols: 80, rows: 24, startupIngress: { @@ -1868,20 +2024,28 @@ describe('LocalPtyProvider', () => { expect(mockProc.write).toHaveBeenCalledWith('\x1b]10;rgb:2e2e/3434/3434\x1b\\') expect(runtimeData.mock.calls.map((call) => call.slice(1))).toEqual([ - ['', expect.any(Number), query.length, true], - ['', expect.any(Number), echo.length, true], - ['prompt', expect.any(Number)] + ['', expect.any(Number), query.length, true, incarnationId], + ['', expect.any(Number), echo.length, true, incarnationId], + ['prompt', expect.any(Number), undefined, undefined, incarnationId] ]) expect(dataHandler.mock.calls.map(([payload]) => payload)).toEqual([ - { id, data: '', sequenceChars: query.length, seq: query.length, transformed: true }, { id, data: '', + incarnationId, + sequenceChars: query.length, + seq: query.length, + transformed: true + }, + { + id, + data: '', + incarnationId, sequenceChars: echo.length, seq: query.length + echo.length, transformed: true }, - { id, data: 'prompt' } + { id, data: 'prompt', incarnationId } ]) }) @@ -1889,7 +2053,7 @@ describe('LocalPtyProvider', () => { Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const dataHandler = vi.fn() provider.onData(dataHandler) - const { id } = await provider.spawn({ + const { id, incarnationId } = await provider.spawn({ cols: 80, rows: 24, shellOverride: 'powershell.exe' @@ -1902,6 +2066,7 @@ describe('LocalPtyProvider', () => { expect(dataHandler).toHaveBeenCalledWith({ id, data: '', + incarnationId, sequenceChars: query.length, seq: query.length, transformed: true diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 64dd9850930..709d27a5ea3 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -18,7 +18,14 @@ import { updateHistFileForFallback, logHistoryInjection } from '../terminal-history' -import type { IPtyProvider, PtyProcessInfo, PtySpawnOptions, PtySpawnResult } from './types' +import type { + IPtyProvider, + PtyHeldProducerPauseToken, + PtyProcessInfo, + PtySpawnOptions, + PtySpawnResult +} from './types' +import { isPtyHeldProducerPauseToken } from './pty-provider-contract' import { ensureNodePtySpawnHelperExecutable, validateWorkingDirectory, @@ -75,6 +82,11 @@ import { expandWindowsEnvironmentVariables, expandWindowsPathEnvironmentVariables } from '../../shared/windows-environment-expansion' +import { + parseTerminalSessionAuthorityPtyAccess, + sameTerminalSessionAuthorityPtyAccess, + type TerminalSessionAuthorityPtyAccess +} from '../../shared/terminal-session-authority-pty-access' const PANE_IDENTITY_ENV_KEYS = [ 'ORCA_PANE_KEY', @@ -86,6 +98,13 @@ const PANE_IDENTITY_ENV_KEYS = [ let ptyCounter = 0 const ptyProcesses = new Map() const ptyIncarnations = new Map() +const legacyProducerPausedPtys = new Set() +const heldProducerPauses = new Map< + string, + { incarnationId: string; tokens: Set } +>() +const ptyMutationRouteTokens = new Map>() +const ptyAuthorityAccessById = new Map() // Why: agent sessions always sweep descendant trees; plain terminals preserve nohup children except on immediate win32 shutdown. const ptyAgentSessionIds = new Set() // Why: descendant capture is async, so reattach/duplicate shutdown must wait for the original owner, not return a dying PTY. @@ -127,6 +146,7 @@ const ptyLoadGeneration = new Map() type DataCallback = (payload: { id: string data: string + incarnationId?: string sequenceChars?: number transformed?: boolean seq?: number @@ -251,6 +271,10 @@ function clearPtyState(id: string): void { disposePtyExitListener(id) ptyProcesses.delete(id) ptyIncarnations.delete(id) + legacyProducerPausedPtys.delete(id) + heldProducerPauses.delete(id) + ptyMutationRouteTokens.delete(id) + ptyAuthorityAccessById.delete(id) ptyAgentSessionIds.delete(id) ptyShellName.delete(id) ptyAgentForegroundContextPaths.delete(id) @@ -264,8 +288,24 @@ function clearPtyState(id: string): void { ptyPhysicalExits.delete(id) } -function createPtyPhysicalExit(id: string): void { - ptyPhysicalExits.set(id, new PhysicalExitTracker()) +function releaseLocalProducerPausesForTeardown(id: string, proc: pty.IPty): void { + const wasPaused = + legacyProducerPausedPtys.delete(id) || (heldProducerPauses.get(id)?.tokens.size ?? 0) > 0 + heldProducerPauses.delete(id) + if (!wasPaused) { + return + } + try { + proc.resume() + } catch { + /* PTY already destroyed */ + } +} + +function createPtyPhysicalExit(id: string): PhysicalExitTracker { + const physicalExit = new PhysicalExitTracker() + ptyPhysicalExits.set(id, physicalExit) + return physicalExit } function waitForPtyPhysicalExit(id: string, physicalExit?: PhysicalExitTracker): Promise { @@ -338,6 +378,20 @@ function allocatePtyId(sessionId: string | undefined): string { return id } +function mutationRouteTokenForLocalPty(id: string): object | null { + const incarnationId = ptyIncarnations.get(id) + if (!incarnationId || !ptyProcesses.has(id)) { + return null + } + const existing = ptyMutationRouteTokens.get(id) + if (existing?.incarnationId === incarnationId) { + return existing.token + } + const token = Object.freeze({}) + ptyMutationRouteTokens.set(id, Object.freeze({ incarnationId, token })) + return token +} + async function prepareLocalPtySpawn(id: string): Promise { const pendingSpawn: PendingLocalPtySpawn = { canceled: false } const pending = pendingLocalPtySpawns.get(id) ?? new Set() @@ -399,6 +453,7 @@ function reattachLocalPty(id: string, cols: number, rows: number): PtySpawnResul } return { id, + ...(ptyIncarnations.get(id) ? { incarnationId: ptyIncarnations.get(id) } : {}), pid: existing.pid, ...(ptyWslDistroById.has(id) ? { wslDistro: ptyWslDistroById.get(id) ?? null } : {}), isReattach: true @@ -513,7 +568,8 @@ export type LocalPtyProviderOptions = { data: string, timestamp: number, sequenceChars?: number, - transformed?: boolean + transformed?: boolean, + incarnationId?: string ) => void } @@ -881,7 +937,7 @@ export class LocalPtyProvider implements IPtyProvider { : process.platform === 'win32' ? null : undefined - createPtyPhysicalExit(id) + const physicalExit = createPtyPhysicalExit(id) ptyProcesses.set(id, proc) ptyInitialCwd.set(id, cwd) if (spawnedWslDistro !== undefined) { @@ -904,14 +960,18 @@ export class LocalPtyProvider implements IPtyProvider { ) ptyLoadGeneration.set(id, loadGeneration) ptyIncarnations.set(id, incarnationId) + mutationRouteTokenForLocalPty(id) this.opts.onSpawned?.(id, incarnationId) const emitIngressData = (emission: PtyIngressEmission): void => { + if (ptyProcesses.get(id) !== proc || ptyIncarnations.get(id) !== incarnationId) { + return + } const sequenceChars = emission.rawEndSeq - emission.rawStartSeq if (emission.transformed || sequenceChars !== emission.data.length) { - this.opts.onData?.(id, emission.data, Date.now(), sequenceChars, true) + this.opts.onData?.(id, emission.data, Date.now(), sequenceChars, true, incarnationId) } else { - this.opts.onData?.(id, emission.data, Date.now()) + this.opts.onData?.(id, emission.data, Date.now(), undefined, undefined, incarnationId) } for (const cb of dataListeners) { cb( @@ -919,11 +979,12 @@ export class LocalPtyProvider implements IPtyProvider { ? { id, data: emission.data, + incarnationId, sequenceChars, seq: emission.rawEndSeq, transformed: true } - : { id, data: emission.data } + : { id, data: emission.data, incarnationId } ) } } @@ -1000,6 +1061,9 @@ export class LocalPtyProvider implements IPtyProvider { const disposables: { dispose: () => void }[] = [] const onDataDisposable = proc.onData((rawData) => { + if (ptyProcesses.get(id) !== proc || ptyIncarnations.get(id) !== incarnationId) { + return + } let data = rawData if (shellReadyScanState && resolveShellReady) { const scanned = scanForShellReady(shellReadyScanState, rawData) @@ -1016,7 +1080,10 @@ export class LocalPtyProvider implements IPtyProvider { const onExitDisposable = proc.onExit(({ exitCode }) => { const wasTerminationRequested = ptyTerminationMode.has(id) - ptyPhysicalExits.get(id)?.markExited() + physicalExit.markExited() + if (ptyProcesses.get(id) !== proc || ptyIncarnations.get(id) !== incarnationId) { + return + } // Why: neutralize proc.kill before destroy — node-pty SIGHUPs on socket 'close', which can race here and signal a reaped/recycled pid. if (process.platform !== 'win32') { ;(proc as unknown as { kill: (sig?: string) => void }).kill = () => {} @@ -1076,20 +1143,179 @@ export class LocalPtyProvider implements IPtyProvider { write(id: string, data: string): void { ptyProcesses.get(id)?.write(data) } + supportsExactPtyOperations(id: string): boolean { + const access = ptyAuthorityAccessById.get(id) + return Boolean( + access && + ptyProcesses.has(id) && + access.binding.physicalPtyId === id && + access.binding.ptyIncarnationId === ptyIncarnations.get(id) + ) + } + getPtyMutationMode(id: string): 'legacy' | 'exact' | 'unavailable' { + if (!ptyProcesses.has(id) || !ptyIncarnations.has(id)) { + return 'unavailable' + } + return this.supportsExactPtyOperations(id) ? 'exact' : 'legacy' + } + writeExact(id: string, incarnationId: string, data: string): boolean { + const proc = ptyProcesses.get(id) + if (!proc || ptyIncarnations.get(id) !== incarnationId) { + return false + } + try { + proc.write(data) + return true + } catch { + return false + } + } resize(id: string, cols: number, rows: number): void { ptyProcesses.get(id)?.resize(cols, rows) } + resizeExact(id: string, incarnationId: string, cols: number, rows: number): boolean { + const proc = ptyProcesses.get(id) + if (!proc || ptyIncarnations.get(id) !== incarnationId) { + return false + } + try { + proc.resize(cols, rows) + return true + } catch { + return false + } + } + async killExact( + id: string, + incarnationId: string, + opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number } + ): Promise { + const proc = ptyProcesses.get(id) + if (!proc || ptyIncarnations.get(id) !== incarnationId) { + return false + } + await this.shutdown(id, opts) + return true + } + + sendSignalExact(id: string, incarnationId: string, signal: string): boolean { + const proc = ptyProcesses.get(id) + if (!proc || ptyIncarnations.get(id) !== incarnationId) { + return false + } + try { + process.kill(proc.pid, signal) + return true + } catch { + return false + } + } + + clearBufferExact(id: string, incarnationId: string): boolean { + const proc = ptyProcesses.get(id) + if (!proc || ptyIncarnations.get(id) !== incarnationId) { + return false + } + try { + startupIngressByPty.get(id)?.snapshotBarrier() + proc.clear() + return true + } catch { + return false + } + } + + bindTerminalSessionAuthorityAccess( + id: string, + authorityAccess: TerminalSessionAuthorityPtyAccess + ): boolean { + const access = parseTerminalSessionAuthorityPtyAccess(authorityAccess) + const incarnationId = ptyIncarnations.get(id) + if ( + !access || + !ptyProcesses.has(id) || + access.binding.physicalPtyId !== id || + access.binding.ptyIncarnationId !== incarnationId + ) { + return false + } + ptyAuthorityAccessById.set(id, access) + return true + } + + writeAuthorityExact( + id: string, + authorityAccess: TerminalSessionAuthorityPtyAccess, + data: string + ): boolean { + return this.localAuthorityAccessMatches(id, authorityAccess) + ? this.writeExact(id, authorityAccess.binding.ptyIncarnationId, data) + : false + } + + resizeAuthorityExact( + id: string, + authorityAccess: TerminalSessionAuthorityPtyAccess, + cols: number, + rows: number + ): boolean { + return this.localAuthorityAccessMatches(id, authorityAccess) + ? this.resizeExact(id, authorityAccess.binding.ptyIncarnationId, cols, rows) + : false + } + + async killAuthorityExact( + id: string, + authorityAccess: TerminalSessionAuthorityPtyAccess, + opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number } + ): Promise { + return this.localAuthorityAccessMatches(id, authorityAccess) + ? await this.killExact(id, authorityAccess.binding.ptyIncarnationId, opts) + : false + } + + sendSignalAuthorityExact( + id: string, + authorityAccess: TerminalSessionAuthorityPtyAccess, + signal: string + ): boolean { + return this.localAuthorityAccessMatches(id, authorityAccess) + ? this.sendSignalExact(id, authorityAccess.binding.ptyIncarnationId, signal) + : false + } + + clearBufferAuthorityExact( + id: string, + authorityAccess: TerminalSessionAuthorityPtyAccess + ): boolean { + return this.localAuthorityAccessMatches(id, authorityAccess) + ? this.clearBufferExact(id, authorityAccess.binding.ptyIncarnationId) + : false + } + + getTerminalSessionAuthorityAccess(id: string): TerminalSessionAuthorityPtyAccess | null { + return ptyAuthorityAccessById.get(id) ?? null + } // Why: node-pty pause() stops reading the master fd, so a flooding child blocks on write — true producer backpressure. pauseProducer(id: string): void { + const proc = ptyProcesses.get(id) + if (!proc) { + return + } try { - ptyProcesses.get(id)?.pause() + proc.pause() + legacyProducerPausedPtys.add(id) } catch { /* PTY already destroyed */ } } resumeProducer(id: string): void { + legacyProducerPausedPtys.delete(id) + if ((heldProducerPauses.get(id)?.tokens.size ?? 0) > 0) { + return + } try { ptyProcesses.get(id)?.resume() } catch { @@ -1097,6 +1323,79 @@ export class LocalPtyProvider implements IPtyProvider { } } + supportsExactHeldProducerPause(id: string, incarnationId: string): boolean { + const proc = ptyProcesses.get(id) + return Boolean( + proc && + ptyIncarnations.get(id) === incarnationId && + !ptyShutdownOperations.has(id) && + typeof proc.pause === 'function' && + typeof proc.resume === 'function' + ) + } + + async acquireExactHeldProducerPause( + id: string, + incarnationId: string, + token: PtyHeldProducerPauseToken + ): Promise { + if ( + !isPtyHeldProducerPauseToken(token) || + !this.supportsExactHeldProducerPause(id, incarnationId) + ) { + return false + } + const current = heldProducerPauses.get(id) + if (current?.incarnationId === incarnationId && current.tokens.has(token)) { + return true + } + if (current && current.incarnationId !== incarnationId) { + return false + } + try { + if (!current || current.tokens.size === 0) { + ptyProcesses.get(id)!.pause() + } + } catch { + return false + } + const pause = current ?? { incarnationId, tokens: new Set() } + pause.tokens.add(token) + heldProducerPauses.set(id, pause) + return true + } + + async releaseExactHeldProducerPause( + id: string, + incarnationId: string, + token: PtyHeldProducerPauseToken + ): Promise { + if ( + !isPtyHeldProducerPauseToken(token) || + !this.supportsExactHeldProducerPause(id, incarnationId) + ) { + return false + } + const current = heldProducerPauses.get(id) + if (!current || current.incarnationId !== incarnationId || !current.tokens.has(token)) { + return true + } + if (current.tokens.size > 1 || legacyProducerPausedPtys.has(id)) { + current.tokens.delete(token) + if (current.tokens.size === 0) { + heldProducerPauses.delete(id) + } + return true + } + try { + ptyProcesses.get(id)!.resume() + } catch { + return false + } + heldProducerPauses.delete(id) + return true + } + // Why: proc.cols/rows are node-pty's authoritative applied size (post-clamp/no-op), used by the renderer drift-check. async getAppliedSize(id: string): Promise<{ cols: number; rows: number } | null> { const proc = ptyProcesses.get(id) @@ -1123,6 +1422,7 @@ export class LocalPtyProvider implements IPtyProvider { if (!proc) { return } + releaseLocalProducerPausesForTeardown(id, proc) const entry: PtyShutdownOperation = { promise: Promise.resolve(), immediate: opts.immediate === true, @@ -1366,15 +1666,38 @@ export class LocalPtyProvider implements IPtyProvider { } async listProcesses(): Promise { - return Array.from(ptyProcesses.entries()).map(([id, proc]) => ({ - id, - ...(ptyIncarnations.get(id) ? { incarnationId: ptyIncarnations.get(id) } : {}), - cwd: ptyInitialCwd.get(id) ?? '', - title: proc.process || ptyShellName.get(id) || 'shell', - ...(ptyWorktreeId.get(id) ? { worktreeId: ptyWorktreeId.get(id) } : {}), - ...(ptyTerminalHandle.get(id) ? { terminalHandle: ptyTerminalHandle.get(id) } : {}), - ...(ptyWslDistroById.has(id) ? { wslDistro: ptyWslDistroById.get(id) ?? null } : {}) - })) + return Array.from(ptyProcesses.entries()).map(([id, proc]) => { + const mutationRouteToken = mutationRouteTokenForLocalPty(id) + return { + id, + ...(mutationRouteToken ? { mutationRouteToken } : {}), + ...(ptyIncarnations.get(id) ? { incarnationId: ptyIncarnations.get(id) } : {}), + ...(ptyAuthorityAccessById.get(id) + ? { terminalSessionAuthorityAccess: ptyAuthorityAccessById.get(id) } + : {}), + cwd: ptyInitialCwd.get(id) ?? '', + title: proc.process || ptyShellName.get(id) || 'shell', + ...(ptyWorktreeId.get(id) ? { worktreeId: ptyWorktreeId.get(id) } : {}), + ...(ptyTerminalHandle.get(id) ? { terminalHandle: ptyTerminalHandle.get(id) } : {}), + ...(ptyWslDistroById.has(id) ? { wslDistro: ptyWslDistroById.get(id) ?? null } : {}) + } + }) + } + + getPtyMutationRouteToken(id: string): object | null { + return mutationRouteTokenForLocalPty(id) + } + + private localAuthorityAccessMatches( + id: string, + authorityAccess: TerminalSessionAuthorityPtyAccess + ): boolean { + const current = ptyAuthorityAccessById.get(id) ?? null + return ( + authorityAccess.binding.physicalPtyId === id && + authorityAccess.binding.ptyIncarnationId === ptyIncarnations.get(id) && + sameTerminalSessionAuthorityPtyAccess(current, authorityAccess) + ) } async getDefaultShell(): Promise { @@ -1446,6 +1769,7 @@ export class LocalPtyProvider implements IPtyProvider { killAll(): void { cancelAllPendingLocalPtySpawns() for (const [id, proc] of ptyProcesses) { + releaseLocalProducerPausesForTeardown(id, proc) runPtyCleanup(id) disposePtyListeners(id) disposePtyExitListener(id) diff --git a/src/main/providers/pty-listed-session-kill.test.ts b/src/main/providers/pty-listed-session-kill.test.ts new file mode 100644 index 00000000000..fe10fd89861 --- /dev/null +++ b/src/main/providers/pty-listed-session-kill.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it, vi } from 'vitest' +import type { TerminalSessionAuthorityPtyAccess } from '../../shared/terminal-session-authority-pty-access' +import { + killListedPty, + listedPtyIdentityKey, + listedPtyIncarnationId +} from './pty-listed-session-kill' + +const authorityAccess: TerminalSessionAuthorityPtyAccess = { + namespace: { authorityHostId: 'host-1', namespaceId: 'namespace-1' }, + pane: { paneKey: 'pane-1', paneGenerationId: 'renderer:1' }, + binding: { + ownerIncarnationId: 'owner-1', + physicalPtyId: 'pty-1', + ptyIncarnationId: 'incarnation-1' + } +} +const mutationRouteToken = Object.freeze({}) + +describe('listed PTY kill', () => { + it('uses full authority access without falling back to incarnation-only mutation', async () => { + const killAuthorityExact = vi.fn(async () => true) + const killExact = vi.fn(async () => true) + + await expect( + killListedPty( + { + getPtyMutationRouteToken: () => mutationRouteToken, + killAuthorityExact, + killExact + }, + { + id: 'pty-1', + incarnationId: 'incarnation-1', + terminalSessionAuthorityAccess: authorityAccess, + mutationRouteToken + }, + { immediate: true } + ) + ).resolves.toBe(true) + + expect(killAuthorityExact).toHaveBeenCalledWith('pty-1', authorityAccess, { + immediate: true + }) + expect(killExact).not.toHaveBeenCalled() + }) + + it('rejects conflicting authority and incarnation evidence without mutation', async () => { + const killAuthorityExact = vi.fn(async () => true) + const killExact = vi.fn(async () => true) + const target = { + id: 'pty-1', + incarnationId: 'incarnation-2', + terminalSessionAuthorityAccess: authorityAccess, + mutationRouteToken + } + + await expect( + killListedPty( + { getPtyMutationRouteToken: () => mutationRouteToken, killAuthorityExact, killExact }, + target, + {} + ) + ).resolves.toBe(false) + expect(killAuthorityExact).not.toHaveBeenCalled() + expect(killExact).not.toHaveBeenCalled() + expect(listedPtyIdentityKey(target)).toBeNull() + expect(listedPtyIncarnationId(target)).toBeNull() + }) + + it('uses incarnation-exact mutation and rejects identity-free inventory', async () => { + const killExact = vi.fn(async () => true) + + await expect( + killListedPty( + { getPtyMutationRouteToken: () => mutationRouteToken, killExact }, + { id: 'pty-1', incarnationId: 'incarnation-1', mutationRouteToken }, + {} + ) + ).resolves.toBe(true) + await expect( + killListedPty( + { getPtyMutationRouteToken: () => mutationRouteToken, killExact }, + { id: 'pty-2', mutationRouteToken }, + {} + ) + ).resolves.toBe(false) + + expect(killExact).toHaveBeenCalledTimes(1) + expect(killExact).toHaveBeenCalledWith('pty-1', 'incarnation-1', {}) + }) + + it('rejects missing or replaced inventory routes without exact mutation', async () => { + const killExact = vi.fn(async () => true) + const currentRouteToken = Object.freeze({}) + const provider = { + getPtyMutationRouteToken: vi.fn(() => currentRouteToken), + killExact + } + + await expect( + killListedPty(provider, { id: 'pty-1', incarnationId: 'incarnation-1' }, {}) + ).resolves.toBe(false) + await expect( + killListedPty( + provider, + { + id: 'pty-1', + incarnationId: 'incarnation-1', + mutationRouteToken: Object.freeze({}) + }, + {} + ) + ).resolves.toBe(false) + + expect(killExact).not.toHaveBeenCalled() + }) + + it('includes the complete authority route in identity keys', () => { + const original = listedPtyIdentityKey({ + id: 'pty-1', + terminalSessionAuthorityAccess: authorityAccess + }) + const successor = listedPtyIdentityKey({ + id: 'pty-1', + terminalSessionAuthorityAccess: { + ...authorityAccess, + pane: { ...authorityAccess.pane, paneGenerationId: 'renderer:2' } + } + }) + + expect(original).not.toBeNull() + expect(successor).not.toBe(original) + }) +}) diff --git a/src/main/providers/pty-listed-session-kill.ts b/src/main/providers/pty-listed-session-kill.ts new file mode 100644 index 00000000000..fc46b4ff12b --- /dev/null +++ b/src/main/providers/pty-listed-session-kill.ts @@ -0,0 +1,108 @@ +import { isPtyIncarnationId, type PtyIncarnationId } from '../../shared/pty-incarnation' +import { + parseTerminalSessionAuthorityPtyAccess, + type TerminalSessionAuthorityPtyAccess +} from '../../shared/terminal-session-authority-pty-access' + +export type PtyListedKillTarget = Readonly<{ + id: string + incarnationId?: PtyIncarnationId + terminalSessionAuthorityAccess?: TerminalSessionAuthorityPtyAccess + mutationRouteToken?: object +}> + +export type PtyListedKillOptions = Readonly<{ + immediate?: boolean + keepHistory?: boolean + deadlineMs?: number +}> + +type PtyListedKillProvider = Readonly<{ + getPtyMutationRouteToken?: (id: string) => object | null + killExact?: ( + id: string, + incarnationId: PtyIncarnationId, + opts: PtyListedKillOptions + ) => boolean | Promise + killAuthorityExact?: ( + id: string, + authorityAccess: TerminalSessionAuthorityPtyAccess, + opts: PtyListedKillOptions + ) => boolean | Promise +}> + +export async function killListedPty( + provider: PtyListedKillProvider, + target: PtyListedKillTarget, + opts: PtyListedKillOptions +): Promise { + const access = listedPtyAuthorityAccess(target) + if (target.terminalSessionAuthorityAccess !== undefined) { + if (!access || !listedPtyRouteIsCurrent(provider, target)) { + return false + } + return (await provider.killAuthorityExact?.(target.id, access, opts)) === true + } + if (!isPtyIncarnationId(target.incarnationId) || !listedPtyRouteIsCurrent(provider, target)) { + return false + } + return (await provider.killExact?.(target.id, target.incarnationId, opts)) === true +} + +export function listedPtyIdentityKey(target: PtyListedKillTarget): string | null { + const access = listedPtyAuthorityAccess(target) + if (target.terminalSessionAuthorityAccess !== undefined) { + return access + ? JSON.stringify([ + 'authority', + target.id, + access.namespace.authorityHostId, + access.namespace.namespaceId, + access.pane.paneKey, + access.pane.paneGenerationId, + access.binding.ownerIncarnationId, + access.binding.physicalPtyId, + access.binding.ptyIncarnationId + ]) + : null + } + return isPtyIncarnationId(target.incarnationId) + ? JSON.stringify(['incarnation', target.id, target.incarnationId]) + : null +} + +export function listedPtyIncarnationId(target: PtyListedKillTarget): PtyIncarnationId | null { + const access = listedPtyAuthorityAccess(target) + if (target.terminalSessionAuthorityAccess !== undefined) { + return access?.binding.ptyIncarnationId ?? null + } + return isPtyIncarnationId(target.incarnationId) ? target.incarnationId : null +} + +function listedPtyAuthorityAccess( + target: PtyListedKillTarget +): TerminalSessionAuthorityPtyAccess | null { + const access = parseTerminalSessionAuthorityPtyAccess(target.terminalSessionAuthorityAccess) + return access && + (target.incarnationId === undefined || target.incarnationId === access.binding.ptyIncarnationId) + ? access + : null +} + +function listedPtyRouteIsCurrent( + provider: PtyListedKillProvider, + target: PtyListedKillTarget +): boolean { + if ( + typeof target.mutationRouteToken !== 'object' || + target.mutationRouteToken === null || + !provider.getPtyMutationRouteToken + ) { + return false + } + try { + return provider.getPtyMutationRouteToken(target.id) === target.mutationRouteToken + } catch { + return false + } +} diff --git a/src/main/providers/pty-mutation-dispatch.test.ts b/src/main/providers/pty-mutation-dispatch.test.ts new file mode 100644 index 00000000000..c54ac08f242 --- /dev/null +++ b/src/main/providers/pty-mutation-dispatch.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it, vi } from 'vitest' +import type { TerminalSessionAuthorityPtyAccess } from '../../shared/terminal-session-authority-pty-access' +import type { IPtyProvider } from './types' +import { + clearPtyMutation, + killPtyMutation, + resizePtyMutation, + resolveAdministrativePtyMutationDispatch, + resolveRendererPtyMutationDispatch, + signalPtyMutation, + writePtyMutation +} from './pty-mutation-dispatch' + +function provider(mode: 'legacy' | 'exact' | 'unavailable'): IPtyProvider { + return { getPtyMutationMode: vi.fn(() => mode) } as unknown as IPtyProvider +} + +const current = { + incarnationId: 'incarnation-current', + paneGeneration: 7, + mutationLeaseId: 'lease-current' +} + +const authorityAccess: TerminalSessionAuthorityPtyAccess = { + namespace: { authorityHostId: 'host-1', namespaceId: 'namespace-1' }, + pane: { paneKey: 'pane-1', paneGenerationId: 'renderer:7' }, + binding: { + ownerIncarnationId: 'owner-1', + physicalPtyId: 'pty-1', + ptyIncarnationId: current.incarnationId + } +} + +describe('renderer PTY mutation dispatch', () => { + it('preserves explicitly negotiated legacy providers', () => { + expect( + resolveRendererPtyMutationDispatch({ + provider: provider('legacy'), + id: 'pty-1', + currentIdentity: current, + requestedIdentity: null + }) + ).toEqual({ mode: 'legacy' }) + }) + + it('rejects exact mode without a full authority binding', () => { + expect( + resolveRendererPtyMutationDispatch({ + provider: provider('exact'), + id: 'pty-1', + currentIdentity: current, + requestedIdentity: current + }) + ).toEqual({ mode: 'rejected' }) + for (const requestedIdentity of [ + null, + { ...current, incarnationId: 'incarnation-stale' }, + { ...current, paneGeneration: 6 }, + { ...current, mutationLeaseId: 'lease-stale' } + ]) { + expect( + resolveRendererPtyMutationDispatch({ + provider: provider('exact'), + id: 'pty-1', + currentIdentity: current, + requestedIdentity + }) + ).toEqual({ mode: 'rejected' }) + } + }) + + it('fails closed when authority requires exact mutations but identity is unavailable', () => { + expect( + resolveRendererPtyMutationDispatch({ + provider: provider('unavailable'), + id: 'pty-1', + currentIdentity: current, + requestedIdentity: current + }) + ).toEqual({ mode: 'rejected' }) + }) + + it('requires provider authority for administrative exact operations', () => { + expect( + resolveAdministrativePtyMutationDispatch({ + provider: provider('exact'), + id: 'pty-1', + currentIdentity: current + }) + ).toEqual({ mode: 'rejected' }) + expect( + resolveAdministrativePtyMutationDispatch({ + provider: provider('legacy'), + id: 'pty-1', + currentIdentity: undefined + }) + ).toEqual({ mode: 'legacy' }) + expect( + resolveAdministrativePtyMutationDispatch({ + provider: provider('exact'), + id: 'pty-1', + currentIdentity: undefined + }) + ).toEqual({ mode: 'rejected' }) + }) + + it('captures full authority and rejects a binding changed after admission', () => { + const exactProvider = { + getPtyMutationMode: () => 'exact', + getTerminalSessionAuthorityAccess: () => authorityAccess + } as unknown as IPtyProvider + expect( + resolveRendererPtyMutationDispatch({ + provider: exactProvider, + id: 'pty-1', + currentIdentity: current, + requestedIdentity: current + }) + ).toEqual({ mode: 'exact', identity: current, authorityAccess }) + expect( + resolveRendererPtyMutationDispatch({ + provider: exactProvider, + id: 'pty-1', + currentIdentity: current, + requestedIdentity: current, + requestedAuthorityAccess: { + ...authorityAccess, + pane: { ...authorityAccess.pane, paneGenerationId: 'renderer:6' } + } + }) + ).toEqual({ mode: 'rejected' }) + }) + + it('routes every authoritative mutation through the full binding', async () => { + const exactProvider = { + writeAuthorityExact: vi.fn(() => true), + resizeAuthorityExact: vi.fn(() => true), + killAuthorityExact: vi.fn(async () => true), + sendSignalAuthorityExact: vi.fn(async () => true), + clearBufferAuthorityExact: vi.fn(async () => true), + writeExact: vi.fn(), + resizeExact: vi.fn(), + killExact: vi.fn(), + sendSignalExact: vi.fn(), + clearBufferExact: vi.fn() + } as unknown as IPtyProvider + const dispatch = { mode: 'exact' as const, identity: current, authorityAccess } + + expect(writePtyMutation(exactProvider, 'pty-1', dispatch, 'input')).toBe(true) + expect(resizePtyMutation(exactProvider, 'pty-1', dispatch, 120, 40)).toBe(true) + await expect(killPtyMutation(exactProvider, 'pty-1', dispatch, {})).resolves.toBe(true) + await expect(signalPtyMutation(exactProvider, 'pty-1', dispatch, 'SIGTERM')).resolves.toBe(true) + await expect(clearPtyMutation(exactProvider, 'pty-1', dispatch)).resolves.toBe(true) + + expect(exactProvider.writeAuthorityExact).toHaveBeenCalledWith( + 'pty-1', + authorityAccess, + 'input' + ) + expect(exactProvider.resizeAuthorityExact).toHaveBeenCalledWith( + 'pty-1', + authorityAccess, + 120, + 40 + ) + expect(exactProvider.killAuthorityExact).toHaveBeenCalledWith('pty-1', authorityAccess, {}) + expect(exactProvider.sendSignalAuthorityExact).toHaveBeenCalledWith( + 'pty-1', + authorityAccess, + 'SIGTERM' + ) + expect(exactProvider.clearBufferAuthorityExact).toHaveBeenCalledWith('pty-1', authorityAccess) + expect(exactProvider.writeExact).not.toHaveBeenCalled() + expect(exactProvider.resizeExact).not.toHaveBeenCalled() + expect(exactProvider.killExact).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/providers/pty-mutation-dispatch.ts b/src/main/providers/pty-mutation-dispatch.ts new file mode 100644 index 00000000000..adc59abeae8 --- /dev/null +++ b/src/main/providers/pty-mutation-dispatch.ts @@ -0,0 +1,187 @@ +import type { + PtyAdministrativeMutationEvidence, + PtyMutationIdentity +} from '../../shared/pty-mutation-identity' +import { + ptyMutationIdentitiesEqual, + ptyMutationIdentityMatchesAdministrativeEvidence +} from '../../shared/pty-mutation-identity' +import { + sameTerminalSessionAuthorityPtyAccess, + type TerminalSessionAuthorityPtyAccess +} from '../../shared/terminal-session-authority-pty-access' +import type { IPtyProvider, PtyMutationMode } from './types' + +export type PtyMutationDispatch = + | { mode: 'legacy' } + | { + mode: 'exact' + identity: PtyMutationIdentity + authorityAccess: TerminalSessionAuthorityPtyAccess + } + | { mode: 'rejected' } + +type RequestedAuthorityAccess = TerminalSessionAuthorityPtyAccess | null | undefined + +const AUTHORITY_ACCESS_REJECTED = Symbol('authority-access-rejected') + +export function resolvePtyMutationMode(provider: IPtyProvider, id: string): PtyMutationMode { + return provider.getPtyMutationMode?.(id) ?? 'legacy' +} + +export function resolveRendererPtyMutationDispatch(args: { + provider: IPtyProvider + id: string + currentIdentity: PtyMutationIdentity | undefined + requestedIdentity: PtyMutationIdentity | null + requestedAuthorityAccess?: RequestedAuthorityAccess +}): PtyMutationDispatch { + const mode = resolvePtyMutationMode(args.provider, args.id) + if (mode === 'legacy') { + return { mode } + } + if ( + mode !== 'exact' || + !ptyMutationIdentitiesEqual(args.currentIdentity, args.requestedIdentity) + ) { + return { mode: 'rejected' } + } + const authorityAccess = resolveAuthorityAccess( + args.provider, + args.id, + args.requestedIdentity!, + args.requestedAuthorityAccess + ) + return authorityAccess === AUTHORITY_ACCESS_REJECTED + ? { mode: 'rejected' } + : { mode, identity: args.requestedIdentity!, authorityAccess } +} + +export function resolveAdministrativePtyMutationDispatch(args: { + provider: IPtyProvider + id: string + currentIdentity: PtyMutationIdentity | undefined + requestedEvidence?: PtyAdministrativeMutationEvidence | null + requestedAuthorityAccess?: RequestedAuthorityAccess +}): PtyMutationDispatch { + const mode = resolvePtyMutationMode(args.provider, args.id) + if (mode === 'legacy') { + return { mode } + } + const evidenceAccepted = + args.requestedEvidence === undefined || + ptyMutationIdentityMatchesAdministrativeEvidence(args.currentIdentity, args.requestedEvidence) + if (mode !== 'exact' || !args.currentIdentity || !evidenceAccepted) { + return { mode: 'rejected' } + } + const authorityAccess = resolveAuthorityAccess( + args.provider, + args.id, + args.currentIdentity, + args.requestedAuthorityAccess + ) + return authorityAccess === AUTHORITY_ACCESS_REJECTED + ? { mode: 'rejected' } + : { mode, identity: args.currentIdentity, authorityAccess } +} + +export function writePtyMutation( + provider: IPtyProvider, + id: string, + dispatch: PtyMutationDispatch, + data: string +): boolean { + if (dispatch.mode === 'legacy') { + provider.write(id, data) + return true + } + if (dispatch.mode !== 'exact') { + return false + } + return provider.writeAuthorityExact?.(id, dispatch.authorityAccess, data) === true +} + +export function resizePtyMutation( + provider: IPtyProvider, + id: string, + dispatch: PtyMutationDispatch, + cols: number, + rows: number +): boolean { + if (dispatch.mode === 'legacy') { + provider.resize(id, cols, rows) + return true + } + if (dispatch.mode !== 'exact') { + return false + } + return provider.resizeAuthorityExact?.(id, dispatch.authorityAccess, cols, rows) === true +} + +export async function killPtyMutation( + provider: IPtyProvider, + id: string, + dispatch: PtyMutationDispatch, + opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number } +): Promise { + if (dispatch.mode === 'legacy') { + await provider.shutdown(id, opts) + return true + } + if (dispatch.mode !== 'exact') { + return false + } + return (await provider.killAuthorityExact?.(id, dispatch.authorityAccess, opts)) === true +} + +export async function signalPtyMutation( + provider: IPtyProvider, + id: string, + dispatch: PtyMutationDispatch, + signal: string +): Promise { + if (dispatch.mode === 'legacy') { + await provider.sendSignal(id, signal) + return true + } + if (dispatch.mode !== 'exact') { + return false + } + return (await provider.sendSignalAuthorityExact?.(id, dispatch.authorityAccess, signal)) === true +} + +export async function clearPtyMutation( + provider: IPtyProvider, + id: string, + dispatch: PtyMutationDispatch +): Promise { + if (dispatch.mode === 'legacy') { + await provider.clearBuffer(id) + return true + } + if (dispatch.mode !== 'exact') { + return false + } + return (await provider.clearBufferAuthorityExact?.(id, dispatch.authorityAccess)) === true +} + +function resolveAuthorityAccess( + provider: IPtyProvider, + id: string, + identity: PtyMutationIdentity, + requested: RequestedAuthorityAccess +): TerminalSessionAuthorityPtyAccess | typeof AUTHORITY_ACCESS_REJECTED { + const current = provider.getTerminalSessionAuthorityAccess?.(id) ?? null + if (!current || current.binding.ptyIncarnationId !== identity.incarnationId) { + return AUTHORITY_ACCESS_REJECTED + } + if (requested === undefined) { + return current + } + if (requested === null) { + return AUTHORITY_ACCESS_REJECTED + } + return sameTerminalSessionAuthorityPtyAccess(current, requested) + ? requested + : AUTHORITY_ACCESS_REJECTED +} diff --git a/src/main/providers/pty-mutation-target.ts b/src/main/providers/pty-mutation-target.ts new file mode 100644 index 00000000000..581da402ede --- /dev/null +++ b/src/main/providers/pty-mutation-target.ts @@ -0,0 +1,66 @@ +import type { + PtyAdministrativeMutationEvidence, + PtyMutationIdentity +} from '../../shared/pty-mutation-identity' +import { + sameTerminalSessionAuthorityPtyAccess, + type TerminalSessionAuthorityPtyAccess +} from '../../shared/terminal-session-authority-pty-access' + +export type PtyMutationTargetAccess = + | { mode: 'legacy' } + | { + mode: 'renderer-exact' + identity: PtyMutationIdentity + authorityAccess: TerminalSessionAuthorityPtyAccess + } + | { + mode: 'runtime-exact' + evidence: PtyAdministrativeMutationEvidence + authorityAccess: TerminalSessionAuthorityPtyAccess + } + | { mode: 'unavailable' } + +export type PtyMutationTarget = Readonly<{ + id: string + providerRouteToken: object | null + ptyLifecycleToken: object | null + access: PtyMutationTargetAccess +}> + +export function ptyMutationTargetsEqual( + left: PtyMutationTarget, + right: PtyMutationTarget +): boolean { + if ( + left.id !== right.id || + left.providerRouteToken !== right.providerRouteToken || + left.ptyLifecycleToken !== right.ptyLifecycleToken || + left.access.mode !== right.access.mode + ) { + return false + } + if (left.access.mode === 'renderer-exact' && right.access.mode === 'renderer-exact') { + return ( + left.access.identity.incarnationId === right.access.identity.incarnationId && + left.access.identity.paneGeneration === right.access.identity.paneGeneration && + left.access.identity.mutationLeaseId === right.access.identity.mutationLeaseId && + sameAuthorityAccess(left.access.authorityAccess, right.access.authorityAccess) + ) + } + if (left.access.mode === 'runtime-exact' && right.access.mode === 'runtime-exact') { + return ( + left.access.evidence.incarnationId === right.access.evidence.incarnationId && + left.access.evidence.paneGeneration === right.access.evidence.paneGeneration && + sameAuthorityAccess(left.access.authorityAccess, right.access.authorityAccess) + ) + } + return true +} + +function sameAuthorityAccess( + left: TerminalSessionAuthorityPtyAccess, + right: TerminalSessionAuthorityPtyAccess +): boolean { + return sameTerminalSessionAuthorityPtyAccess(left, right) +} diff --git a/src/main/providers/pty-process-info.ts b/src/main/providers/pty-process-info.ts index a34746a6267..205293e3493 100644 --- a/src/main/providers/pty-process-info.ts +++ b/src/main/providers/pty-process-info.ts @@ -1,9 +1,13 @@ import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-authority' import type { PtyIncarnationId } from '../../shared/pty-incarnation' +import type { TerminalSessionAuthorityPtyAccess } from '../../shared/terminal-session-authority-pty-access' export type PtyProcessInfo = { id: string + /** Opaque provider route captured with this inventory row for mutation-time revalidation. */ + mutationRouteToken?: object incarnationId?: PtyIncarnationId + terminalSessionAuthorityAccess?: TerminalSessionAuthorityPtyAccess cwd: string title: string /** Owning worktree when the provider can report it authoritatively. */ diff --git a/src/main/providers/pty-process-list-admission.test.ts b/src/main/providers/pty-process-list-admission.test.ts index 19daccbd2a5..ff315ce2deb 100644 --- a/src/main/providers/pty-process-list-admission.test.ts +++ b/src/main/providers/pty-process-list-admission.test.ts @@ -22,6 +22,45 @@ describe('PtyProcessListAdmission', () => { ).toEqual({ id: 'pty-1', cwd: '/repo', title: 'shell' }) }) + it('requires the provider-resolved physical authority identity', () => { + const authorityAccess = { + namespace: { authorityHostId: 'host-1', namespaceId: 'namespace-1' }, + pane: { paneKey: 'pane-1', paneGenerationId: 'renderer:1' }, + binding: { + ownerIncarnationId: 'owner-1', + physicalPtyId: 'pty-1', + ptyIncarnationId: 'incarnation-1' + } + } + const mutationRouteToken = Object.freeze({}) + const remote = { + id: 'ssh-connection@@pty-1', + cwd: '/repo', + title: 'shell', + incarnationId: 'incarnation-1', + mutationRouteToken, + terminalSessionAuthorityAccess: authorityAccess + } + + expect(new PtyProcessListAdmission().admit(remote, 'pty-1')).toEqual(remote) + expect(() => + new PtyProcessListAdmission().admit( + { + ...remote, + terminalSessionAuthorityAccess: { + ...authorityAccess, + binding: { ...authorityAccess.binding, physicalPtyId: 'pty-other' } + } + }, + 'pty-1' + ) + ).toThrow('invalid_pty_process_list') + expect(() => new PtyProcessListAdmission().admit(remote, 'pty-other')).toThrow( + 'invalid_pty_process_list' + ) + expect(() => new PtyProcessListAdmission().admit(remote)).toThrow('invalid_pty_process_list') + }) + it('rejects aggregate entry and byte amplification', () => { const entryAdmission = new PtyProcessListAdmission() for (let index = 0; index < MAX_AGGREGATED_PTY_PROCESS_LIST_ENTRIES; index += 1) { diff --git a/src/main/providers/pty-process-list-admission.ts b/src/main/providers/pty-process-list-admission.ts index bca3bf4fa18..5b066adc9f2 100644 --- a/src/main/providers/pty-process-list-admission.ts +++ b/src/main/providers/pty-process-list-admission.ts @@ -2,6 +2,7 @@ import { isAgentSessionOwnerBinding } from '../../shared/agent-session-host-auth import { MAX_CLAIMED_AGENT_PTY_OWNER_ENTRIES } from '../../shared/claimed-agent-pty-owner' import { cloneAgentSessionOwnerBinding } from '../../shared/claimed-agent-pty-owner-snapshot' import { isPtyIncarnationId } from '../../shared/pty-incarnation' +import { parseTerminalSessionAuthorityPtyAccess } from '../../shared/terminal-session-authority-pty-access' import type { PtyProcessInfo } from './types' export const MAX_AGGREGATED_PTY_PROCESS_LIST_ENTRIES = 4096 @@ -42,10 +43,12 @@ export class PtyProcessListAdmission { constructor(private readonly capacityError = 'pty_process_list_capacity') {} - admit(value: PtyProcessInfo): PtyProcessInfo { + admit(value: PtyProcessInfo, resolvedAuthorityPhysicalPtyId?: string | null): PtyProcessInfo { if (typeof value !== 'object' || value === null) { throw new Error('invalid_pty_process_list') } + const authorityPhysicalPtyId = + resolvedAuthorityPhysicalPtyId === undefined ? value.id : resolvedAuthorityPhysicalPtyId const idBytes = retainedStringBytes(value.id) const cwdBytes = retainedStringBytes(value.cwd) const titleBytes = retainedStringBytes(value.title) @@ -53,6 +56,13 @@ export class PtyProcessListAdmission { const terminalHandleBytes = retainedOptionalStringBytes(value.terminalHandle) const wslDistroBytes = value.wslDistro === null ? 0 : retainedOptionalStringBytes(value.wslDistro) + const authorityAccess = + value.terminalSessionAuthorityAccess === undefined + ? null + : parseTerminalSessionAuthorityPtyAccess(value.terminalSessionAuthorityAccess) + const authorityAccessBytes = authorityAccess + ? Buffer.byteLength(JSON.stringify(authorityAccess), 'utf8') + : 0 if ( idBytes === null || cwdBytes === null || @@ -60,7 +70,13 @@ export class PtyProcessListAdmission { worktreeIdBytes === null || terminalHandleBytes === null || wslDistroBytes === null || + (value.mutationRouteToken !== undefined && + (typeof value.mutationRouteToken !== 'object' || value.mutationRouteToken === null)) || (value.incarnationId !== undefined && !isPtyIncarnationId(value.incarnationId)) || + (value.terminalSessionAuthorityAccess !== undefined && authorityAccess === null) || + (authorityAccess !== null && + (authorityAccess.binding.physicalPtyId !== authorityPhysicalPtyId || + authorityAccess.binding.ptyIncarnationId !== value.incarnationId)) || (value.agentSessionOwners !== undefined && !Array.isArray(value.agentSessionOwners)) ) { throw new Error('invalid_pty_process_list') @@ -91,6 +107,7 @@ export class PtyProcessListAdmission { worktreeIdBytes + terminalHandleBytes + wslDistroBytes + + authorityAccessBytes + ownerBytes if ( nextEntries > MAX_AGGREGATED_PTY_PROCESS_LIST_ENTRIES || @@ -108,6 +125,10 @@ export class PtyProcessListAdmission { cwd: value.cwd, title: value.title, ...(value.incarnationId !== undefined ? { incarnationId: value.incarnationId } : {}), + ...(value.mutationRouteToken !== undefined + ? { mutationRouteToken: value.mutationRouteToken } + : {}), + ...(authorityAccess ? { terminalSessionAuthorityAccess: authorityAccess } : {}), ...(value.worktreeId !== undefined ? { worktreeId: value.worktreeId } : {}), ...(value.terminalHandle !== undefined ? { terminalHandle: value.terminalHandle } : {}), ...(value.wslDistro !== undefined ? { wslDistro: value.wslDistro } : {}), diff --git a/src/main/providers/pty-provider-contract.ts b/src/main/providers/pty-provider-contract.ts index 98ab3d9b69b..1d0f09e71a5 100644 --- a/src/main/providers/pty-provider-contract.ts +++ b/src/main/providers/pty-provider-contract.ts @@ -10,6 +10,19 @@ import type { AgentSessionSurfaceBinding } from '../../shared/agent-session-host-authority' import type { PtyProcessInfo } from './pty-process-info' +import type { TerminalSessionAuthorityPtyAccess } from '../../shared/terminal-session-authority-pty-access' + +export type PtyMutationMode = 'legacy' | 'exact' | 'unavailable' +export type PtyHeldProducerPauseToken = string +export const PTY_HELD_PRODUCER_PAUSE_TOKEN_MAX_LENGTH = 256 + +export function isPtyHeldProducerPauseToken(value: unknown): value is PtyHeldProducerPauseToken { + return ( + typeof value === 'string' && + value.length > 0 && + value.length <= PTY_HELD_PRODUCER_PAUSE_TOKEN_MAX_LENGTH + ) +} export type { PtyBackgroundStreamEvent, @@ -51,6 +64,10 @@ export type PtySpawnOptions = { /** Stable terminal pane identity. Remote providers use this as PTY metadata * even when it must not be exported into the spawned shell environment. */ paneKey?: string + /** Durable logical generation for authority-owned remote pane allocation. */ + paneGeneration?: number + /** Full durable binding required to reattach an authority-owned PTY. */ + terminalSessionAuthorityAccess?: TerminalSessionAuthorityPtyAccess /** Stable terminal tab identity used as a coarser attach guard when a pane * identity is unavailable. */ tabId?: string @@ -120,6 +137,55 @@ export type IPtyProvider = { probePtyLiveness?: (id: string) => Promise write(id: string, data: string): void resize(id: string, cols: number, rows: number): void + /** Explicit legacy is version-skew compatibility; unavailable fails closed after cutover. */ + getPtyMutationMode?: (id: string) => PtyMutationMode + /** Incarnation-fenced mutations; unsupported providers omit them and retain legacy behavior. */ + supportsExactPtyOperations?: (id: string) => boolean + writeExact?: (id: string, incarnationId: PtyIncarnationId, data: string) => boolean + resizeExact?: (id: string, incarnationId: PtyIncarnationId, cols: number, rows: number) => boolean + killExact?: ( + id: string, + incarnationId: PtyIncarnationId, + opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number } + ) => boolean | Promise + sendSignalExact?: ( + id: string, + incarnationId: PtyIncarnationId, + signal: string + ) => boolean | Promise + clearBufferExact?: (id: string, incarnationId: PtyIncarnationId) => boolean | Promise + writeAuthorityExact?: ( + id: string, + authorityAccess: TerminalSessionAuthorityPtyAccess, + data: string + ) => boolean + resizeAuthorityExact?: ( + id: string, + authorityAccess: TerminalSessionAuthorityPtyAccess, + cols: number, + rows: number + ) => boolean + killAuthorityExact?: ( + id: string, + authorityAccess: TerminalSessionAuthorityPtyAccess, + opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number } + ) => boolean | Promise + sendSignalAuthorityExact?: ( + id: string, + authorityAccess: TerminalSessionAuthorityPtyAccess, + signal: string + ) => boolean | Promise + clearBufferAuthorityExact?: ( + id: string, + authorityAccess: TerminalSessionAuthorityPtyAccess + ) => boolean | Promise + /** Current final-host binding captured by the mutation admission layer. */ + getTerminalSessionAuthorityAccess?: (id: string) => TerminalSessionAuthorityPtyAccess | null + /** Installs a durable authority binding only after the provider's physical incarnation is known. */ + bindTerminalSessionAuthorityAccess?: ( + id: string, + authorityAccess: TerminalSessionAuthorityPtyAccess + ) => boolean /** * Producer-side flow control: stop/restart reading the underlying PTY so a * flooding child blocks on write (kernel backpressure) instead of growing @@ -130,6 +196,18 @@ export type IPtyProvider = { */ pauseProducer?: (id: string) => void resumeProducer?: (id: string) => void + /** True only when this physical incarnation supports an acknowledged, non-expiring pause. */ + supportsExactHeldProducerPause?: (id: string, incarnationId: PtyIncarnationId) => boolean + acquireExactHeldProducerPause?: ( + id: string, + incarnationId: PtyIncarnationId, + token: PtyHeldProducerPauseToken + ) => Promise + releaseExactHeldProducerPause?: ( + id: string, + incarnationId: PtyIncarnationId, + token: PtyHeldProducerPauseToken + ) => Promise /** * Hidden-delivery hint: the renderer has no visible view for this PTY, so * the provider's transport may keep-tail thin this PTY's monitoring stream @@ -194,6 +272,10 @@ export type IPtyProvider = { revive(state: string): Promise // Why: deadlineMs bounds the underlying RPC exactly like shutdown's deadlineMs. listProcesses(opts?: { deadlineMs?: number }): Promise + /** Maps a provider-visible listing ID to the authority host's physical PTY identity. */ + resolveTerminalSessionAuthorityPhysicalPtyId?: (id: string) => string | null + /** Current opaque route identity; callers compare it by reference with the listed row. */ + getPtyMutationRouteToken?: (id: string) => object | null getDefaultShell(): Promise getProfiles(): Promise<{ name: string; path: string }[]> onData(callback: (payload: PtyDataEvent) => void): () => void diff --git a/src/main/providers/pty-provider-events.ts b/src/main/providers/pty-provider-events.ts index 643aecac655..0104b876cce 100644 --- a/src/main/providers/pty-provider-events.ts +++ b/src/main/providers/pty-provider-events.ts @@ -1,8 +1,10 @@ import type { TerminalGitHubPRLink } from '../../shared/terminal-github-pr-link-detector' +import type { PtyIncarnationId } from '../../shared/pty-incarnation' export type PtyDataEvent = { id: string data: string + incarnationId?: PtyIncarnationId sequenceChars?: number transformed?: boolean seq?: number @@ -20,10 +22,22 @@ export type PtyTransientFact = export type PtyBackgroundStreamEvent = | { id: string + incarnationId?: PtyIncarnationId kind: 'backgroundMarker' background: boolean scanSeedAnsi?: string mode2031PendingSubscribe?: true } - | { id: string; kind: 'dataGap'; droppedChars: number; sequenceChars?: number } - | { id: string; kind: 'transientFact'; fact: PtyTransientFact } + | { + id: string + incarnationId?: PtyIncarnationId + kind: 'dataGap' + droppedChars: number + sequenceChars?: number + } + | { + id: string + incarnationId?: PtyIncarnationId + kind: 'transientFact' + fact: PtyTransientFact + } diff --git a/src/main/providers/pty-spawn-result.ts b/src/main/providers/pty-spawn-result.ts index 235672e05e7..afee93c4f8f 100644 --- a/src/main/providers/pty-spawn-result.ts +++ b/src/main/providers/pty-spawn-result.ts @@ -3,6 +3,7 @@ import type { TuiAgent } from '../../shared/types' import type { AgentSessionClaimedSpawnResult } from '../../shared/agent-session-host-authority' import type { PtyIncarnationId } from '../../shared/pty-incarnation' import type { PtySourceReceivingActivation } from '../../shared/pty-source-receiving-activation' +import type { TerminalSessionAuthorityPtyAccess } from '../../shared/terminal-session-authority-pty-access' export type PtySpawnResult = { agentSessionEnsure?: AgentSessionClaimedSpawnResult @@ -11,6 +12,8 @@ export type PtySpawnResult = { id: string /** Opaque provider-owned identity for this process behind a reusable PTY id. */ incarnationId?: PtyIncarnationId + /** Durable final-host admission for this exact pane and PTY incarnation. */ + terminalSessionAuthorityAccess?: TerminalSessionAuthorityPtyAccess /** Relay source identity installed before adjacent source frames are decoded. */ sourceActivation?: PtySourceReceivingActivation /** The provider observed this exact spawn exit before its control reply settled. */ diff --git a/src/main/providers/ssh-agent-session-create-operation.ts b/src/main/providers/ssh-agent-session-create-operation.ts index adc0eb264d8..2964bb8bd65 100644 --- a/src/main/providers/ssh-agent-session-create-operation.ts +++ b/src/main/providers/ssh-agent-session-create-operation.ts @@ -10,6 +10,7 @@ import { type PtySourceReceivingActivation } from '../../shared/pty-source-receiving-activation' import { validateClaimedSshSpawn } from './ssh-agent-session-claim-validation' +import { parseTerminalSessionAuthorityPtyAccess } from '../../shared/terminal-session-authority-pty-access' export const SSH_AGENT_SESSION_CAPABILITY_PROBE_TIMEOUT_MS = 5_000 @@ -171,6 +172,10 @@ function parseSshPtySpawnResult(value: unknown): PtySpawnResult { ? (value as PtySpawnResult) : ({} as PtySpawnResult) const activation = parsePtySourceReceivingActivation(result.sourceActivation) + const authorityAccess = + result.terminalSessionAuthorityAccess === undefined + ? null + : parseTerminalSessionAuthorityPtyAccess(result.terminalSessionAuthorityAccess) if ( activation && (typeof result.id !== 'string' || @@ -180,5 +185,17 @@ function parseSshPtySpawnResult(value: unknown): PtySpawnResult { ) { throw new Error('Invalid SSH PTY source activation identity') } - return activation ? { ...result, sourceActivation: activation } : result + if ( + result.terminalSessionAuthorityAccess !== undefined && + (!authorityAccess || + authorityAccess.binding.physicalPtyId !== result.id || + authorityAccess.binding.ptyIncarnationId !== result.incarnationId) + ) { + throw new Error('Invalid SSH terminal authority spawn identity') + } + return { + ...result, + ...(activation ? { sourceActivation: activation } : {}), + ...(authorityAccess ? { terminalSessionAuthorityAccess: authorityAccess } : {}) + } } diff --git a/src/main/providers/ssh-pty-authority-access-store.ts b/src/main/providers/ssh-pty-authority-access-store.ts new file mode 100644 index 00000000000..8c63408734e --- /dev/null +++ b/src/main/providers/ssh-pty-authority-access-store.ts @@ -0,0 +1,99 @@ +import type { TerminalSessionAuthorityPtyAccess } from '../../shared/terminal-session-authority-pty-access' +import { + parseTerminalSessionAuthorityPtyAccess, + sameTerminalSessionAuthorityPtyAccess +} from '../../shared/terminal-session-authority-pty-access' + +export class SshPtyAuthorityAccessStore { + private readonly accessByRelayPtyId = new Map() + private readonly cutoverRelayPtyIds = new Set() + private readonly mutationRouteToken = Object.freeze({}) + + constructor(private readonly toRelayPtyId: (id: string) => string) {} + + dispose(): void { + this.accessByRelayPtyId.clear() + this.cutoverRelayPtyIds.clear() + } + + recordExit(relayPtyId: string): void { + this.accessByRelayPtyId.delete(relayPtyId) + } + + accessForRelayPtyId(relayPtyId: string): TerminalSessionAuthorityPtyAccess | undefined { + return this.accessByRelayPtyId.get(relayPtyId) + } + + remember( + relayPtyId: string, + value: TerminalSessionAuthorityPtyAccess | undefined, + ptyIncarnationId: string | undefined + ): void { + if (value === undefined) { + return + } + this.cutoverRelayPtyIds.add(relayPtyId) + const access = parseTerminalSessionAuthorityPtyAccess(value) + if ( + !access || + access.binding.physicalPtyId !== relayPtyId || + access.binding.ptyIncarnationId !== ptyIncarnationId + ) { + throw new Error('ssh_terminal_authority_access_invalid') + } + const current = this.accessByRelayPtyId.get(relayPtyId) + if (current && !sameTerminalSessionAuthorityPtyAccess(current, access)) { + throw new Error('ssh_terminal_authority_access_conflict') + } + this.accessByRelayPtyId.set(relayPtyId, access) + } + + expect(relayPtyId: string, value: TerminalSessionAuthorityPtyAccess): void { + this.cutoverRelayPtyIds.add(relayPtyId) + const access = parseTerminalSessionAuthorityPtyAccess(value) + if (!access || access.binding.physicalPtyId !== relayPtyId) { + throw new Error('ssh_terminal_authority_expected_access_invalid') + } + } + + bind( + id: string, + access: TerminalSessionAuthorityPtyAccess, + ptyIncarnationId: string | undefined + ): boolean { + const relayPtyId = this.toRelayPtyId(id) + this.cutoverRelayPtyIds.add(relayPtyId) + try { + this.remember(relayPtyId, access, ptyIncarnationId) + return true + } catch { + return false + } + } + + markListedProcess( + id: string, + access: TerminalSessionAuthorityPtyAccess | undefined, + ptyIncarnationId: string | undefined + ): object | undefined { + const relayPtyId = this.toRelayPtyId(id) + const wasCutover = this.cutoverRelayPtyIds.has(relayPtyId) + const current = this.accessByRelayPtyId.get(relayPtyId) + if (!access && current && current.binding.ptyIncarnationId === ptyIncarnationId) { + throw new Error('ssh_terminal_authority_access_downgrade') + } + if (!access && current) { + this.accessByRelayPtyId.delete(relayPtyId) + } + this.remember(relayPtyId, access, ptyIncarnationId) + return access || !wasCutover ? this.mutationRouteToken : undefined + } + + isCutover(id: string): boolean { + return this.cutoverRelayPtyIds.has(this.toRelayPtyId(id)) + } + + mutationToken(): object { + return this.mutationRouteToken + } +} diff --git a/src/main/providers/ssh-pty-authority-routing.ts b/src/main/providers/ssh-pty-authority-routing.ts new file mode 100644 index 00000000000..fe9e9534ca9 --- /dev/null +++ b/src/main/providers/ssh-pty-authority-routing.ts @@ -0,0 +1,310 @@ +import { + matchesPtyExactOperationIdentity, + PTY_EXACT_OPERATION_PROTOCOL_VERSION +} from '../../shared/pty-exact-operation-protocol' +import { + PTY_CLEAR_BUFFER_AUTHORITY_EXACT_METHOD, + PTY_DATA_AUTHORITY_EXACT_METHOD, + PTY_RESIZE_AUTHORITY_EXACT_METHOD, + PTY_SEND_SIGNAL_AUTHORITY_EXACT_METHOD, + PTY_SHUTDOWN_AUTHORITY_EXACT_METHOD, + TERMINAL_AUTHORITY_EXACT_OPERATIONS_VERSION +} from '../../shared/terminal-authority-exact-operation-protocol' +import type { TerminalSessionAuthorityPtyAccess } from '../../shared/terminal-session-authority-pty-access' +import { + parseTerminalSessionAuthorityPtyAccess, + sameTerminalSessionAuthorityPtyAccess +} from '../../shared/terminal-session-authority-pty-access' +import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' +import type { PtyMutationMode } from './pty-provider-contract' +import { SshPtyAuthorityAccessStore } from './ssh-pty-authority-access-store' +import { sshRelayDeadlineOptions } from './ssh-relay-request-deadline' +import type { SshPtyShutdownOptions } from './ssh-pty-shutdown-operation' + +type OperationCapability = Readonly<{ version: 1; isCurrentProviderGeneration: () => boolean }> +export type SshPtyExactOperationCapability = OperationCapability +export type SshPtyAuthorityExactOperationCapability = OperationCapability + +type SshPtyAuthorityRoutingOptions = Readonly<{ + mux: SshChannelMultiplexer + legacyCapability?: SshPtyExactOperationCapability + authorityCapability?: SshPtyAuthorityExactOperationCapability + livePtyIds: Set + toRelayPtyId: (id: string) => string + getPtyIncarnation: (relayPtyId: string) => string | undefined +}> + +type AuthorityRoute = Readonly<{ relayPtyId: string; access: TerminalSessionAuthorityPtyAccess }> +type AuthorityRequestMethod = + | typeof PTY_SEND_SIGNAL_AUTHORITY_EXACT_METHOD + | typeof PTY_CLEAR_BUFFER_AUTHORITY_EXACT_METHOD + +export class SshPtyAuthorityRouting { + private readonly accessStore: SshPtyAuthorityAccessStore + + constructor(private readonly options: SshPtyAuthorityRoutingOptions) { + this.accessStore = new SshPtyAuthorityAccessStore(options.toRelayPtyId) + } + + dispose = (): void => this.accessStore.dispose() + recordExit = (relayPtyId: string): void => this.accessStore.recordExit(relayPtyId) + accessForRelayPtyId = (relayPtyId: string): TerminalSessionAuthorityPtyAccess | undefined => + this.accessStore.accessForRelayPtyId(relayPtyId) + + resolvePhysicalPtyId(id: string): string | null { + try { + return this.options.toRelayPtyId(id) + } catch { + return null + } + } + + rememberAccess( + relayPtyId: string, + value: TerminalSessionAuthorityPtyAccess | undefined, + ptyIncarnationId: string | undefined + ): void { + this.accessStore.remember(relayPtyId, value, ptyIncarnationId) + } + + expectAccess(relayPtyId: string, value: TerminalSessionAuthorityPtyAccess): void { + this.accessStore.expect(relayPtyId, value) + } + + mutationMode = (id: string): PtyMutationMode => + !this.isCutover(id) ? 'legacy' : this.currentAuthorityRoute(id) ? 'exact' : 'unavailable' + + writeExact(id: string, incarnationId: string, data: string): boolean { + const relayPtyId = this.currentLegacyRoute(id, incarnationId) + return Boolean( + relayPtyId && + this.options.mux.notify('pty.dataExact', { id: relayPtyId, incarnationId, data }) + ) + } + + resizeExact(id: string, incarnationId: string, cols: number, rows: number): boolean { + const relayPtyId = this.currentLegacyRoute(id, incarnationId) + return Boolean( + relayPtyId && + this.options.mux.notify('pty.resizeExact', { id: relayPtyId, incarnationId, cols, rows }) + ) + } + + async killExact( + id: string, + incarnationId: string, + options: SshPtyShutdownOptions + ): Promise { + const relayPtyId = this.currentLegacyRoute(id, incarnationId) + if (!relayPtyId) { + return false + } + const accepted = await this.requestAccepted( + 'pty.shutdownExact', + { + id: relayPtyId, + incarnationId, + immediate: options.immediate === true, + keepHistory: options.keepHistory === true + }, + options.deadlineMs + ) + if (accepted && this.currentLegacyRoute(id, incarnationId) === relayPtyId) { + this.options.livePtyIds.delete(id) + } + return accepted + } + + sendSignalExact = (id: string, incarnationId: string, signal: string): Promise => + this.requestLegacyAccepted(id, incarnationId, 'pty.sendSignalExact', { signal }) + clearBufferExact = (id: string, incarnationId: string): Promise => + this.requestLegacyAccepted(id, incarnationId, 'pty.clearBufferExact') + + writeAuthorityExact = ( + id: string, + access: TerminalSessionAuthorityPtyAccess, + data: string + ): boolean => this.notifyAuthority(id, access, PTY_DATA_AUTHORITY_EXACT_METHOD, { data }) + + resizeAuthorityExact = ( + id: string, + access: TerminalSessionAuthorityPtyAccess, + cols: number, + rows: number + ): boolean => this.notifyAuthority(id, access, PTY_RESIZE_AUTHORITY_EXACT_METHOD, { cols, rows }) + + async killAuthorityExact( + id: string, + access: TerminalSessionAuthorityPtyAccess, + options: SshPtyShutdownOptions + ): Promise { + const route = this.currentAuthorityRoute(id, access) + if (!route) { + return false + } + return await this.requestAccepted( + PTY_SHUTDOWN_AUTHORITY_EXACT_METHOD, + this.authorityParams(route, { + immediate: options.immediate === true, + keepHistory: options.keepHistory === true + }), + options.deadlineMs + ) + } + + sendSignalAuthorityExact = ( + id: string, + access: TerminalSessionAuthorityPtyAccess, + signal: string + ): Promise => + this.requestAuthorityAccepted(id, access, PTY_SEND_SIGNAL_AUTHORITY_EXACT_METHOD, { + signal + }) + + clearBufferAuthorityExact = ( + id: string, + access: TerminalSessionAuthorityPtyAccess + ): Promise => + this.requestAuthorityAccepted(id, access, PTY_CLEAR_BUFFER_AUTHORITY_EXACT_METHOD) + + mutationToken = (id: string): object | null => { + if (this.isCutover(id)) { + const capability = this.options.authorityCapability + if ( + !this.options.livePtyIds.has(id) || + (capability !== undefined && + (capability.version !== TERMINAL_AUTHORITY_EXACT_OPERATIONS_VERSION || + !capability.isCurrentProviderGeneration())) + ) { + return null + } + const relayPtyId = this.options.toRelayPtyId(id) + return this.accessStore.accessForRelayPtyId(relayPtyId) + ? this.accessStore.mutationToken() + : null + } + const capability = this.options.legacyCapability + if ( + !this.options.livePtyIds.has(id) || + (capability !== undefined && + (capability.version !== PTY_EXACT_OPERATION_PROTOCOL_VERSION || + !capability.isCurrentProviderGeneration())) + ) { + return null + } + return this.accessStore.mutationToken() + } + + bindAccess( + id: string, + access: TerminalSessionAuthorityPtyAccess, + ptyIncarnationId: string | undefined + ): boolean { + return this.accessStore.bind(id, access, ptyIncarnationId) + } + + markListedProcess( + id: string, + access: TerminalSessionAuthorityPtyAccess | undefined, + ptyIncarnationId: string | undefined + ): object | undefined { + return this.accessStore.markListedProcess(id, access, ptyIncarnationId) + } + + private currentLegacyRoute(id: string, incarnationId: string): string | null { + const capability = this.options.legacyCapability + if ( + this.isCutover(id) || + capability?.version !== PTY_EXACT_OPERATION_PROTOCOL_VERSION || + !capability.isCurrentProviderGeneration() || + !this.options.livePtyIds.has(id) + ) { + return null + } + const relayPtyId = this.options.toRelayPtyId(id) + return matchesPtyExactOperationIdentity( + this.options.getPtyIncarnation(relayPtyId), + incarnationId + ) + ? relayPtyId + : null + } + + private currentAuthorityRoute( + id: string, + requestedAccess?: TerminalSessionAuthorityPtyAccess + ): AuthorityRoute | null { + const capability = this.options.authorityCapability + if ( + capability?.version !== TERMINAL_AUTHORITY_EXACT_OPERATIONS_VERSION || + !capability.isCurrentProviderGeneration() || + !this.options.livePtyIds.has(id) + ) { + return null + } + const relayPtyId = this.options.toRelayPtyId(id) + const access = this.accessStore.accessForRelayPtyId(relayPtyId) + const requested = requestedAccess && parseTerminalSessionAuthorityPtyAccess(requestedAccess) + if ( + !access || + (requestedAccess !== undefined && !requested) || + (requested && !sameTerminalSessionAuthorityPtyAccess(access, requested)) + ) { + return null + } + return { relayPtyId, access: requested ?? access } + } + + private isCutover = (id: string): boolean => this.accessStore.isCutover(id) + + private notifyAuthority( + id: string, + access: TerminalSessionAuthorityPtyAccess, + method: typeof PTY_DATA_AUTHORITY_EXACT_METHOD | typeof PTY_RESIZE_AUTHORITY_EXACT_METHOD, + params: Record + ): boolean { + const route = this.currentAuthorityRoute(id, access) + return Boolean(route && this.options.mux.notify(method, this.authorityParams(route, params))) + } + + private async requestAuthorityAccepted( + id: string, + access: TerminalSessionAuthorityPtyAccess, + method: AuthorityRequestMethod, + params: Record = {} + ): Promise { + const route = this.currentAuthorityRoute(id, access) + return route ? await this.requestAccepted(method, this.authorityParams(route, params)) : false + } + + private authorityParams( + route: AuthorityRoute, + params: Record + ): Record { + return { id: route.relayPtyId, terminalSessionAuthorityAccess: route.access, ...params } + } + + private async requestLegacyAccepted( + id: string, + incarnationId: string, + method: 'pty.sendSignalExact' | 'pty.clearBufferExact', + params: Record = {} + ): Promise { + const relayPtyId = this.currentLegacyRoute(id, incarnationId) + return relayPtyId + ? await this.requestAccepted(method, { id: relayPtyId, incarnationId, ...params }) + : false + } + + private async requestAccepted( + method: string, + params: Record, + deadlineMs?: number + ): Promise { + const deadline = sshRelayDeadlineOptions(deadlineMs) + const result = (await (deadline + ? this.options.mux.request(method, params, deadline) + : this.options.mux.request(method, params))) as { accepted?: unknown } | null + return result?.accepted === true + } +} diff --git a/src/main/providers/ssh-pty-held-producer-pause.test.ts b/src/main/providers/ssh-pty-held-producer-pause.test.ts new file mode 100644 index 00000000000..0da1bb0c1cd --- /dev/null +++ b/src/main/providers/ssh-pty-held-producer-pause.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it, vi } from 'vitest' +import type { PtyIncarnationId } from '../../shared/pty-incarnation' +import { SshPtyHeldProducerPause } from './ssh-pty-held-producer-pause' + +const incarnationId = '11111111-1111-4111-8111-111111111111' as PtyIncarnationId + +describe('SshPtyHeldProducerPause', () => { + it('requires current incarnation capability and awaits the request acknowledgement', async () => { + let current = true + let settle!: (value: unknown) => void + const mux = { + request: vi.fn(() => new Promise((resolve) => (settle = resolve))), + isDisposed: vi.fn(() => false) + } + const pause = new SshPtyHeldProducerPause({ + mux: mux as never, + capability: { + version: 1, + clientGeneration: 3, + ownerGeneration: 4, + isCurrentProviderGeneration: () => current + }, + toRelayPtyId: (id) => id, + getPtyIncarnation: () => incarnationId + }) + + const acquiring = pause.acquire('pty-1', incarnationId, 'held-1') + let completed = false + void acquiring.then(() => (completed = true)) + await Promise.resolve() + expect(completed).toBe(false) + settle({ applied: true }) + await expect(acquiring).resolves.toBe(true) + expect(mux.request).toHaveBeenCalledWith( + 'pty.setDeliveryPaused', + expect.objectContaining({ paused: true, heldPauseToken: 'held-1' }), + { timeoutMs: 10_000 } + ) + + current = false + expect(pause.supports('pty-1', incarnationId)).toBe(false) + }) + + it('issues a compensating release when the provider is superseded before acknowledgement', async () => { + let current = true + const mux = { + request: vi.fn(async (_method: string, params: { paused: boolean }) => { + if (params.paused) { + current = false + } + return { applied: true } + }), + isDisposed: vi.fn(() => false) + } + const pause = new SshPtyHeldProducerPause({ + mux: mux as never, + capability: { + version: 1, + clientGeneration: 3, + ownerGeneration: 4, + isCurrentProviderGeneration: () => current + }, + toRelayPtyId: (id) => id, + getPtyIncarnation: () => incarnationId + }) + + await expect(pause.acquire('pty-1', incarnationId, 'held-1')).resolves.toBe(false) + expect(mux.request.mock.calls.map(([, params]) => params.paused)).toEqual([true, false]) + }) +}) diff --git a/src/main/providers/ssh-pty-held-producer-pause.ts b/src/main/providers/ssh-pty-held-producer-pause.ts new file mode 100644 index 00000000000..eea59e4eba6 --- /dev/null +++ b/src/main/providers/ssh-pty-held-producer-pause.ts @@ -0,0 +1,75 @@ +import type { PtyIncarnationId } from '../../shared/pty-incarnation' +import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' + +const SSH_HELD_PRODUCER_PAUSE_TIMEOUT_MS = 10_000 + +export type SshPtyHeldProducerPauseCapability = Readonly<{ + version: 1 + clientGeneration: number + ownerGeneration: number + isCurrentProviderGeneration: () => boolean +}> + +type SshPtyHeldProducerPauseOptions = { + mux: SshChannelMultiplexer + capability?: SshPtyHeldProducerPauseCapability + toRelayPtyId: (id: string) => string + getPtyIncarnation: (relayPtyId: string) => string | undefined +} + +export class SshPtyHeldProducerPause { + constructor(private readonly options: SshPtyHeldProducerPauseOptions) {} + + supports(id: string, incarnationId: PtyIncarnationId): boolean { + const relayPtyId = this.options.toRelayPtyId(id) + return Boolean( + this.options.capability?.version === 1 && + this.options.capability.isCurrentProviderGeneration() && + this.options.getPtyIncarnation(relayPtyId) === incarnationId + ) + } + + async acquire(id: string, incarnationId: PtyIncarnationId, token: string): Promise { + if (!this.supports(id, incarnationId)) { + return false + } + const applied = await this.request(id, incarnationId, token, true) + if (applied && !this.options.capability?.isCurrentProviderGeneration()) { + await this.request(id, incarnationId, token, false).catch(() => false) + return false + } + return applied + } + + async release(id: string, incarnationId: PtyIncarnationId, token: string): Promise { + if (!this.options.capability || this.options.mux.isDisposed()) { + return false + } + return await this.request(id, incarnationId, token, false) + } + + private async request( + id: string, + incarnationId: PtyIncarnationId, + token: string, + paused: boolean + ): Promise { + const capability = this.options.capability + if (!capability) { + return false + } + const response = (await this.options.mux.request( + 'pty.setDeliveryPaused', + { + id: this.options.toRelayPtyId(id), + paused, + clientGeneration: capability.clientGeneration, + ownerGeneration: capability.ownerGeneration, + ptyIncarnationId: incarnationId, + heldPauseToken: token + }, + { timeoutMs: SSH_HELD_PRODUCER_PAUSE_TIMEOUT_MS } + )) as { applied?: unknown } + return response?.applied === true + } +} diff --git a/src/main/providers/ssh-pty-live-membership.ts b/src/main/providers/ssh-pty-live-membership.ts new file mode 100644 index 00000000000..8bc4b327ac9 --- /dev/null +++ b/src/main/providers/ssh-pty-live-membership.ts @@ -0,0 +1,44 @@ +export class SshPtyLiveMembership extends Set { + private revision = 0 + private readonly changedAtById = new Map() + + override add(id: string): this { + const alreadyPresent = this.has(id) + super.add(id) + if (!alreadyPresent) { + this.recordChange(id) + } + return this + } + + markLifecycleTransition(id: string): void { + super.add(id) + this.recordChange(id) + } + + override delete(id: string): boolean { + const deleted = super.delete(id) + this.recordChange(id) + return deleted + } + + override clear(): void { + for (const id of this) { + this.recordChange(id) + } + super.clear() + } + + captureRevision(): number { + return this.revision + } + + changedAfter(id: string, revision: number): boolean { + return (this.changedAtById.get(id) ?? 0) > revision + } + + private recordChange(id: string): void { + this.revision += 1 + this.changedAtById.set(id, this.revision) + } +} diff --git a/src/main/providers/ssh-pty-notification-routing.ts b/src/main/providers/ssh-pty-notification-routing.ts index b6063799f58..94a6c6509e3 100644 --- a/src/main/providers/ssh-pty-notification-routing.ts +++ b/src/main/providers/ssh-pty-notification-routing.ts @@ -1,6 +1,14 @@ import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' import { isPtyIncarnationId } from '../../shared/pty-incarnation' import type { PtySourceReceivingActivation } from '../../shared/pty-source-receiving-activation' +import { + parseTerminalAuthorityOutcomeDeliveryIdentity, + type TerminalAuthorityOutcomeDeliveryIdentity +} from '../../shared/terminal-authority-outcome-delivery' +import { + sameTerminalSessionAuthorityPtyAccess, + type TerminalSessionAuthorityPtyAccess +} from '../../shared/terminal-session-authority-pty-access' import type { SshPtyDataCallback, SshPtyExitCallback, @@ -44,6 +52,10 @@ export function subscribeSshPtyNotifications(args: { providerGeneration: number resolvePtyIncarnation: (relayPtyId: string, incarnationId?: unknown) => string peekPtyIncarnation: (relayPtyId: string) => string | undefined + authorityOutcomeDelivery?: boolean + getTerminalSessionAuthorityAccess?: ( + relayPtyId: string + ) => TerminalSessionAuthorityPtyAccess | undefined }): SshPtyNotificationSubscription { const toDataPayload = ( pending: PendingSshPtySourceData, @@ -161,6 +173,24 @@ export function subscribeSshPtyNotifications(args: { } const relayPtyId = params.id if (method === 'pty.exit') { + let authorityOutcome: TerminalAuthorityOutcomeDeliveryIdentity | undefined + if (params.authorityOutcome !== undefined) { + const parsed = parseTerminalAuthorityOutcomeDeliveryIdentity(params.authorityOutcome) + const expectedAccess = args.getTerminalSessionAuthorityAccess?.(relayPtyId) + if ( + !args.authorityOutcomeDelivery || + !parsed || + !isPtyIncarnationId(params.incarnationId) || + !Number.isSafeInteger(params.code) || + parsed.binding.physicalPtyId !== relayPtyId || + parsed.binding.ptyIncarnationId !== params.incarnationId || + (expectedAccess && !sameTerminalSessionAuthorityPtyAccess(expectedAccess, parsed)) + ) { + args.mux.dispose('connection_lost') + return + } + authorityOutcome = parsed + } const id = args.toAppPtyId(relayPtyId) const ptyIncarnation = args.resolvePtyIncarnation(relayPtyId, params.incarnationId) rejectedPublications.delete(relayPtyId) @@ -175,7 +205,8 @@ export function subscribeSshPtyNotifications(args: { ptyIncarnation, ...(isPtyIncarnationId(params.incarnationId) ? { incarnationId: params.incarnationId } - : {}) + : {}), + ...(authorityOutcome ? { authorityOutcome } : {}) }) } return diff --git a/src/main/providers/ssh-pty-process-inventory.ts b/src/main/providers/ssh-pty-process-inventory.ts new file mode 100644 index 00000000000..6fe9cb65c85 --- /dev/null +++ b/src/main/providers/ssh-pty-process-inventory.ts @@ -0,0 +1,81 @@ +import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' +import { mapSshPtyProcessList } from './ssh-agent-session-process-list' +import type { SshPtyAuthorityRouting } from './ssh-pty-authority-routing' +import type { SshPtyLiveMembership } from './ssh-pty-live-membership' +import type { SshPtyProviderOutputState } from './ssh-pty-provider-output-state' +import { sshRelayDeadlineOptions } from './ssh-relay-request-deadline' +import type { PtyProcessInfo } from './types' + +type SshPtyProcessInventoryOptions = Readonly<{ + mux: SshChannelMultiplexer + livePtyIds: SshPtyLiveMembership + outputState: SshPtyProviderOutputState + authorityRouting: SshPtyAuthorityRouting + toAppPtyId: (id: string) => string + toRelayPtyId: (id: string) => string +}> + +export class SshPtyProcessInventory { + private requestGeneration = 0 + + constructor(private readonly options: SshPtyProcessInventoryOptions) {} + + async list(deadlineMs?: number): Promise { + const requestGeneration = ++this.requestGeneration + const membershipRevision = this.options.livePtyIds.captureRevision() + const result = await this.options.mux.request( + 'pty.listProcesses', + undefined, + sshRelayDeadlineOptions(deadlineMs) + ) + if (requestGeneration !== this.requestGeneration) { + throw new Error('ssh_pty_process_inventory_unavailable') + } + const processes = mapSshPtyProcessList(result as PtyProcessInfo[], this.options.toAppPtyId) + const admittedProcesses: PtyProcessInfo[] = [] + const admittedIds = new Set() + for (const process of processes) { + if (!this.options.livePtyIds.changedAfter(process.id, membershipRevision)) { + this.admit(process) + admittedIds.add(process.id) + admittedProcesses.push(process) + } + } + this.reconcileAbsent(admittedIds, membershipRevision) + return admittedProcesses + } + + private admit(process: PtyProcessInfo): void { + const relayPtyId = this.options.toRelayPtyId(process.id) + const currentIncarnationId = this.options.outputState.getPtyIncarnation(relayPtyId) + if ( + currentIncarnationId && + process.incarnationId && + currentIncarnationId !== process.incarnationId + ) { + throw new Error('ssh_pty_process_list_incarnation_conflict') + } + const mutationRouteToken = this.options.authorityRouting.markListedProcess( + process.id, + process.terminalSessionAuthorityAccess, + process.incarnationId ?? currentIncarnationId + ) + this.options.livePtyIds.add(process.id) + this.options.outputState.rememberPtyIncarnation(relayPtyId, process.incarnationId) + if (mutationRouteToken) { + process.mutationRouteToken = mutationRouteToken + } + } + + private reconcileAbsent(admittedIds: ReadonlySet, membershipRevision: number): void { + for (const id of this.options.livePtyIds) { + if (admittedIds.has(id) || this.options.livePtyIds.changedAfter(id, membershipRevision)) { + continue + } + this.options.livePtyIds.delete(id) + const relayPtyId = this.options.toRelayPtyId(id) + this.options.outputState.forgetPtyIncarnation(relayPtyId) + this.options.authorityRouting.recordExit(relayPtyId) + } + } +} diff --git a/src/main/providers/ssh-pty-provider-contract.ts b/src/main/providers/ssh-pty-provider-contract.ts index 4ac6f0fb4b2..e723194786a 100644 --- a/src/main/providers/ssh-pty-provider-contract.ts +++ b/src/main/providers/ssh-pty-provider-contract.ts @@ -1,4 +1,6 @@ import type { PtyIncarnationId } from '../../shared/pty-incarnation' +import type { TerminalAuthorityOutcomeDeliveryIdentity } from '../../shared/terminal-authority-outcome-delivery' +import type { TerminalSessionAuthorityPtyAccess } from '../../shared/terminal-session-authority-pty-access' export type RemoteCliBridgeEnv = { binDir: string @@ -9,6 +11,15 @@ export type RemoteCliBridgeEnv = { pathDelimiter?: ':' | ';' } +export type SshPtyExpectedIdentity = Readonly<{ + paneKey?: string + tabId?: string + worktreeId?: string + paneGeneration?: number + ptyIncarnationId?: string + terminalSessionAuthorityAccess?: TerminalSessionAuthorityPtyAccess +}> + export type SshPtyDataCallback = (payload: { id: string data: string @@ -37,6 +48,7 @@ export type SshPtyExitCallback = (payload: { providerGeneration: number ptyIncarnation: string incarnationId?: PtyIncarnationId + authorityOutcome?: TerminalAuthorityOutcomeDeliveryIdentity }) => void export type SshPtyDeliveryPauseAdapter = (args: { diff --git a/src/main/providers/ssh-pty-provider-exact-operations.test.ts b/src/main/providers/ssh-pty-provider-exact-operations.test.ts new file mode 100644 index 00000000000..8eab90dd034 --- /dev/null +++ b/src/main/providers/ssh-pty-provider-exact-operations.test.ts @@ -0,0 +1,576 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { TerminalSessionAuthorityPtyAccess } from '../../shared/terminal-session-authority-pty-access' +import type { TerminalAuthorityAppNamespaceAdmission } from '../session-authority/terminal-authority-app-outcome-host-contract' +import { killListedPty } from './pty-listed-session-kill' +import { SshPtyProvider } from './ssh-pty-provider' + +type MockMux = { + request: ReturnType + notify: ReturnType + onNotification: ReturnType + dispose: ReturnType + isDisposed: ReturnType + emitNotification: (method: string, params: Record) => void +} + +function createMux( + incarnationId: string, + terminalSessionAuthorityAccess?: TerminalSessionAuthorityPtyAccess +): MockMux { + let notificationHandler: (method: string, params: Record) => void = () => {} + const mux: MockMux = { + request: vi.fn(async (method: string) => { + if (method === 'pty.spawn') { + return { + id: 'pty-1', + incarnationId, + ...(terminalSessionAuthorityAccess ? { terminalSessionAuthorityAccess } : {}) + } + } + return undefined + }), + notify: vi.fn().mockReturnValue(true), + onNotification: vi.fn((handler: (method: string, params: Record) => void) => { + notificationHandler = handler + return () => {} + }), + dispose: vi.fn(), + isDisposed: vi.fn().mockReturnValue(false), + emitNotification: (method, params) => notificationHandler(method, params) + } + return mux +} + +const authorityAccess: TerminalSessionAuthorityPtyAccess = { + namespace: { authorityHostId: 'host-1', namespaceId: 'namespace-1' }, + pane: { paneKey: 'pane-1', paneGenerationId: 'renderer:1' }, + binding: { + ownerIncarnationId: 'owner-1', + physicalPtyId: 'pty-1', + ptyIncarnationId: 'incarnation-1' + } +} + +function exactCapability(isCurrentProviderGeneration: () => boolean) { + return { version: 1 as const, isCurrentProviderGeneration } +} + +async function spawn(provider: SshPtyProvider): Promise { + return (await provider.spawn({ cols: 80, rows: 24 })).id +} + +describe('SshPtyProvider exact operations', () => { + let mux: MockMux + let current: boolean + let provider: SshPtyProvider + + beforeEach(() => { + mux = createMux('incarnation-1') + current = true + provider = new SshPtyProvider( + 'conn-1', + mux as never, + undefined, + 17, + exactCapability(() => current) + ) + }) + + it('uses distinct exact methods without mutating legacy payloads', async () => { + const id = await spawn(provider) + mux.request.mockResolvedValueOnce([ + { id: 'pty-1', cwd: '/workspace', title: 'shell', incarnationId: 'incarnation-1' } + ]) + const [listed] = await provider.listProcesses() + expect(listed.mutationRouteToken).toBe(provider.getPtyMutationRouteToken(id)) + expect(listed.mutationRouteToken).not.toBeNull() + mux.notify.mockClear() + mux.request.mockClear() + mux.request.mockResolvedValue({ accepted: true }) + + provider.write(id, 'legacy-write') + provider.resize(id, 100, 30) + await provider.sendSignal(id, 'SIGINT') + await provider.clearBuffer(id) + expect(provider.writeExact(id, 'incarnation-1', 'exact-write')).toBe(true) + expect(provider.resizeExact(id, 'incarnation-1', 120, 40)).toBe(true) + await expect(provider.sendSignalExact(id, 'incarnation-1', 'SIGTERM')).resolves.toBe(true) + await expect(provider.clearBufferExact(id, 'incarnation-1')).resolves.toBe(true) + + expect(mux.notify.mock.calls).toEqual([ + ['pty.data', { id: 'pty-1', data: 'legacy-write' }], + ['pty.resize', { id: 'pty-1', cols: 100, rows: 30 }], + ['pty.dataExact', { id: 'pty-1', incarnationId: 'incarnation-1', data: 'exact-write' }], + ['pty.resizeExact', { id: 'pty-1', incarnationId: 'incarnation-1', cols: 120, rows: 40 }] + ]) + expect(mux.request.mock.calls).toEqual([ + ['pty.sendSignal', { id: 'pty-1', signal: 'SIGINT' }], + ['pty.clearBuffer', { id: 'pty-1' }], + ['pty.sendSignalExact', { id: 'pty-1', incarnationId: 'incarnation-1', signal: 'SIGTERM' }], + ['pty.clearBufferExact', { id: 'pty-1', incarnationId: 'incarnation-1' }] + ]) + }) + + it('dispatches legacy shutdown for a live pre-cutover route', async () => { + const id = await spawn(provider) + mux.request.mockClear() + + await expect( + provider.shutdown(id, { immediate: true, keepHistory: true }) + ).resolves.toBeUndefined() + expect(mux.request).toHaveBeenCalledWith( + 'pty.shutdown', + { + id: 'pty-1', + immediate: true, + keepHistory: true + }, + undefined + ) + expect(provider.hasPty(id)).toBe(false) + }) + + it('dispatches exact listed kill for a pre-cutover route token', async () => { + const id = await spawn(provider) + mux.request.mockResolvedValueOnce([ + { id: 'pty-1', cwd: '/workspace', title: 'shell', incarnationId: 'incarnation-1' } + ]) + const [listed] = await provider.listProcesses() + mux.request.mockClear() + mux.request.mockResolvedValue({ accepted: true }) + + await expect(killListedPty(provider, listed, { immediate: true })).resolves.toBe(true) + expect(mux.request).toHaveBeenCalledWith('pty.shutdownExact', { + id: 'pty-1', + incarnationId: 'incarnation-1', + immediate: true, + keepHistory: false + }) + expect(provider.hasPty(id)).toBe(false) + }) + + it('rejects a legacy listed token after its provider generation goes stale', async () => { + const id = await spawn(provider) + mux.request.mockResolvedValueOnce([ + { id: 'pty-1', cwd: '/workspace', title: 'shell', incarnationId: 'incarnation-1' } + ]) + const [listed] = await provider.listProcesses() + mux.request.mockClear() + current = false + + await expect(killListedPty(provider, listed, { immediate: true })).resolves.toBe(false) + expect(provider.getPtyMutationRouteToken(id)).toBeNull() + expect(mux.request).not.toHaveBeenCalled() + }) + + it('fails closed before transport admission', async () => { + const id = await spawn(provider) + mux.notify.mockClear() + + expect(provider.writeExact(id, 'stale-incarnation', 'stale')).toBe(false) + expect(provider.resizeExact(id, 'stale-incarnation', 120, 40)).toBe(false) + await expect(provider.sendSignalExact(id, 'stale-incarnation', 'SIGTERM')).resolves.toBe(false) + await expect(provider.clearBufferExact(id, 'stale-incarnation')).resolves.toBe(false) + current = false + expect(provider.supportsExactPtyOperations(id)).toBe(false) + expect(provider.writeExact(id, 'incarnation-1', 'stale-generation')).toBe(false) + await expect(provider.sendSignalExact(id, 'incarnation-1', 'SIGTERM')).resolves.toBe(false) + await expect(provider.clearBufferExact(id, 'incarnation-1')).resolves.toBe(false) + expect(mux.notify).not.toHaveBeenCalled() + expect(mux.request).toHaveBeenCalledOnce() + + current = true + mux.notify.mockReturnValueOnce(false) + expect(provider.writeExact(id, 'incarnation-1', 'unadmitted')).toBe(false) + }) + + it('keeps live state unless exact shutdown is accepted', async () => { + const id = await spawn(provider) + mux.request.mockResolvedValueOnce({ accepted: false }).mockResolvedValueOnce({ accepted: true }) + + await expect(provider.killExact(id, 'stale-incarnation', {})).resolves.toBe(false) + expect(mux.request).toHaveBeenCalledTimes(1) + await expect(provider.killExact(id, 'incarnation-1', { immediate: true })).resolves.toBe(false) + expect(provider.hasPty(id)).toBe(true) + await expect(provider.killExact(id, 'incarnation-1', { immediate: true })).resolves.toBe(true) + expect(provider.hasPty(id)).toBe(false) + expect(mux.request).toHaveBeenLastCalledWith('pty.shutdownExact', { + id: 'pty-1', + incarnationId: 'incarnation-1', + immediate: true, + keepHistory: false + }) + }) + + it('preserves the caller cleanup deadline for exact shutdown', async () => { + const id = await spawn(provider) + mux.request.mockClear() + mux.request.mockResolvedValue({ accepted: true }) + const now = Date.now() + const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(now) + try { + await expect( + provider.killExact(id, 'incarnation-1', { immediate: true, deadlineMs: now + 2_500 }) + ).resolves.toBe(true) + + expect(mux.request).toHaveBeenCalledWith( + 'pty.shutdownExact', + { + id: 'pty-1', + incarnationId: 'incarnation-1', + immediate: true, + keepHistory: false + }, + { timeoutMs: 2_500 } + ) + } finally { + nowSpy.mockRestore() + } + }) + + it('does not clear a replacement incarnation when an old shutdown settles late', async () => { + const id = await spawn(provider) + let settleShutdown!: (result: { accepted: true }) => void + mux.request.mockReturnValueOnce( + new Promise((resolve) => { + settleShutdown = resolve + }) + ) + const shutdown = provider.killExact(id, 'incarnation-1', {}) + mux.emitNotification('pty.exit', { + id: 'pty-1', + code: 0, + incarnationId: 'incarnation-1' + }) + mux.request.mockResolvedValueOnce({ id: 'pty-1', incarnationId: 'incarnation-2' }) + const replacementId = await spawn(provider) + + settleShutdown({ accepted: true }) + await expect(shutdown).resolves.toBe(true) + expect(provider.hasPty(replacementId)).toBe(true) + expect(provider.writeExact(replacementId, 'incarnation-2', 'current')).toBe(true) + }) + + it('keeps old peers on legacy operations', async () => { + const legacyMux = createMux('incarnation-1') + const legacy = new SshPtyProvider('conn-1', legacyMux as never) + const id = await spawn(legacy) + legacyMux.notify.mockClear() + + expect(legacy.supportsExactPtyOperations(id)).toBe(false) + expect(legacy.writeExact(id, 'incarnation-1', 'blocked')).toBe(false) + await expect(legacy.killExact(id, 'incarnation-1', {})).resolves.toBe(false) + await expect(legacy.sendSignalExact(id, 'incarnation-1', 'SIGTERM')).resolves.toBe(false) + await expect(legacy.clearBufferExact(id, 'incarnation-1')).resolves.toBe(false) + expect(legacyMux.notify).not.toHaveBeenCalled() + expect(legacyMux.request).toHaveBeenCalledOnce() + }) + + it('fences disposed and replacement provider generations', async () => { + const oldId = await spawn(provider) + provider.dispose() + expect(provider.writeExact(oldId, 'incarnation-1', 'disposed')).toBe(false) + + const replacementMux = createMux('incarnation-2') + const replacement = new SshPtyProvider( + 'conn-1', + replacementMux as never, + undefined, + 18, + exactCapability(() => true) + ) + const replacementId = await spawn(replacement) + replacementMux.notify.mockClear() + + expect(replacement.writeExact(replacementId, 'incarnation-1', 'stale')).toBe(false) + expect(replacement.writeExact(replacementId, 'incarnation-2', 'current')).toBe(true) + expect(replacementMux.notify).toHaveBeenCalledOnce() + }) +}) + +describe('SshPtyProvider authority exact operations', () => { + function authorityProvider( + mux: MockMux, + authorityCurrent = () => true, + legacyCurrent = () => true + ): SshPtyProvider { + return new SshPtyProvider( + 'conn-1', + mux as never, + undefined, + 17, + exactCapability(legacyCurrent), + false, + exactCapability(authorityCurrent) + ) + } + + it('carries one captured full binding through all five mutations', async () => { + const mux = createMux('incarnation-1', authorityAccess) + const provider = authorityProvider(mux) + const id = await spawn(provider) + mux.notify.mockClear() + mux.request.mockClear() + mux.request.mockResolvedValue({ accepted: true }) + + expect(provider.getPtyMutationMode(id)).toBe('exact') + expect(provider.supportsExactPtyOperations(id)).toBe(true) + expect(provider.writeAuthorityExact(id, authorityAccess, 'input')).toBe(true) + expect(provider.resizeAuthorityExact(id, authorityAccess, 120, 40)).toBe(true) + await expect(provider.sendSignalAuthorityExact(id, authorityAccess, 'SIGTERM')).resolves.toBe( + true + ) + await expect(provider.clearBufferAuthorityExact(id, authorityAccess)).resolves.toBe(true) + await expect( + provider.killAuthorityExact(id, authorityAccess, { immediate: true }) + ).resolves.toBe(true) + + expect(mux.notify.mock.calls).toEqual([ + [ + 'pty.dataAuthorityExact', + { id: 'pty-1', terminalSessionAuthorityAccess: authorityAccess, data: 'input' } + ], + [ + 'pty.resizeAuthorityExact', + { + id: 'pty-1', + terminalSessionAuthorityAccess: authorityAccess, + cols: 120, + rows: 40 + } + ] + ]) + expect(mux.request.mock.calls).toEqual([ + [ + 'pty.sendSignalAuthorityExact', + { + id: 'pty-1', + terminalSessionAuthorityAccess: authorityAccess, + signal: 'SIGTERM' + } + ], + [ + 'pty.clearBufferAuthorityExact', + { id: 'pty-1', terminalSessionAuthorityAccess: authorityAccess } + ], + [ + 'pty.shutdownAuthorityExact', + { + id: 'pty-1', + terminalSessionAuthorityAccess: authorityAccess, + immediate: true, + keepHistory: false + } + ] + ]) + expect(provider.hasPty(id)).toBe(true) + mux.emitNotification('pty.exit', { + id: 'pty-1', + code: 0, + incarnationId: 'incarnation-1' + }) + expect(provider.hasPty(id)).toBe(false) + }) + + it('rejects every stale full binding before transport dispatch', async () => { + const mux = createMux('incarnation-1', authorityAccess) + const provider = authorityProvider(mux) + const id = await spawn(provider) + const stale = { + ...authorityAccess, + pane: { ...authorityAccess.pane, paneGenerationId: 'renderer:2' } + } + mux.notify.mockClear() + mux.request.mockClear() + + expect(provider.writeAuthorityExact(id, stale, 'stale')).toBe(false) + expect(provider.resizeAuthorityExact(id, stale, 120, 40)).toBe(false) + await expect(provider.sendSignalAuthorityExact(id, stale, 'SIGTERM')).resolves.toBe(false) + await expect(provider.clearBufferAuthorityExact(id, stale)).resolves.toBe(false) + await expect(provider.killAuthorityExact(id, stale, {})).resolves.toBe(false) + + expect(mux.notify).not.toHaveBeenCalled() + expect(mux.request).not.toHaveBeenCalled() + expect(provider.hasPty(id)).toBe(true) + }) + + it('fails closed without a negotiated authority capability', async () => { + const mux = createMux('incarnation-1', authorityAccess) + const provider = new SshPtyProvider( + 'conn-1', + mux as never, + undefined, + 17, + exactCapability(() => true) + ) + const id = await spawn(provider) + mux.notify.mockClear() + mux.request.mockClear() + + expect(provider.getPtyMutationMode(id)).toBe('unavailable') + expect(provider.writeAuthorityExact(id, authorityAccess, 'blocked')).toBe(false) + expect(provider.writeExact(id, 'incarnation-1', 'blocked')).toBe(false) + provider.write(id, 'blocked') + await expect(provider.sendSignal(id, 'SIGTERM')).rejects.toThrow( + 'terminal_authority_exact_operation_required' + ) + await expect(provider.killExact(id, 'incarnation-1', {})).resolves.toBe(false) + + expect(mux.notify).not.toHaveBeenCalled() + expect(mux.request).not.toHaveBeenCalled() + }) + + it('keeps a cutover route unavailable when expected authority access is missing', async () => { + const mux = createMux('incarnation-1') + const provider = authorityProvider(mux) + const id = await spawn(provider) + const unprovedAccess = { + ...authorityAccess, + binding: { ...authorityAccess.binding, ptyIncarnationId: 'stale-incarnation' } + } + + expect(provider.bindTerminalSessionAuthorityAccess(id, unprovedAccess)).toBe(false) + mux.notify.mockClear() + mux.request.mockClear() + + expect(provider.getPtyMutationMode(id)).toBe('unavailable') + expect(provider.getPtyMutationRouteToken(id)).toBeNull() + expect(provider.writeAuthorityExact(id, authorityAccess, 'blocked')).toBe(false) + expect(provider.writeExact(id, 'incarnation-1', 'blocked')).toBe(false) + provider.write(id, 'blocked') + await expect(provider.sendSignal(id, 'SIGTERM')).rejects.toThrow( + 'terminal_authority_exact_operation_required' + ) + await expect(provider.clearBuffer(id)).rejects.toThrow( + 'terminal_authority_exact_operation_required' + ) + await expect(provider.killExact(id, 'incarnation-1', {})).resolves.toBe(false) + + expect(mux.notify).not.toHaveBeenCalled() + expect(mux.request).not.toHaveBeenCalled() + }) + + it('never retries an authority rejection through incarnation methods', async () => { + const mux = createMux('incarnation-1', authorityAccess) + const provider = authorityProvider(mux) + const id = await spawn(provider) + mux.notify.mockClear() + mux.request.mockClear() + mux.request.mockResolvedValue({ accepted: false }) + + await expect(provider.killAuthorityExact(id, authorityAccess, {})).resolves.toBe(false) + expect(provider.writeExact(id, 'incarnation-1', 'blocked')).toBe(false) + await expect(provider.sendSignalExact(id, 'incarnation-1', 'SIGTERM')).resolves.toBe(false) + + expect(mux.request).toHaveBeenCalledOnce() + expect(mux.request).toHaveBeenCalledWith( + 'pty.shutdownAuthorityExact', + expect.objectContaining({ terminalSessionAuthorityAccess: authorityAccess }) + ) + expect(mux.notify).not.toHaveBeenCalled() + expect(provider.hasPty(id)).toBe(true) + }) + + it('admits the resolved app consumer before dispatching an authoritative spawn', async () => { + const order: string[] = [] + const mux = createMux('incarnation-1', authorityAccess) + mux.request.mockImplementation(async (method: string) => { + order.push(`request:${method}`) + return method === 'pty.spawn' + ? { + id: 'pty-1', + incarnationId: 'incarnation-1', + terminalSessionAuthorityAccess: authorityAccess + } + : undefined + }) + const admission: TerminalAuthorityAppNamespaceAdmission = { + admitNamespace: vi.fn(async () => {}), + resolveAndAdmitNamespace: vi.fn(async () => authorityAccess.namespace), + withSourceAdmission: vi.fn(async (locator, operation) => { + expect(locator).toEqual({ worktreeId: 'repo::/workspace' }) + order.push('admit') + const result = await operation({ + namespace: authorityAccess.namespace, + assertCurrent: vi.fn() + }) + order.push('release') + return result + }) + } + const provider = new SshPtyProvider( + 'conn-1', + mux as never, + undefined, + 17, + undefined, + false, + exactCapability(() => true), + undefined, + admission + ) + + await provider.spawn({ + cols: 80, + rows: 24, + worktreeId: 'repo::/workspace', + paneGeneration: 1 + }) + + expect(admission.withSourceAdmission).toHaveBeenCalledOnce() + expect(order).toEqual(['admit', 'request:pty.spawn', 'release']) + }) + + it('re-admits a durable authority namespace before reconnect attach', async () => { + const relayPtyId = 'pty-1' + const reconnectAccess: TerminalSessionAuthorityPtyAccess = { + ...authorityAccess, + binding: { ...authorityAccess.binding, physicalPtyId: relayPtyId } + } + const mux = createMux('incarnation-1') + const order: string[] = [] + mux.request.mockImplementation(async (method: string) => { + order.push(`request:${method}`) + return method === 'pty.attach' + ? { incarnationId: 'incarnation-1', terminalSessionAuthorityAccess: reconnectAccess } + : undefined + }) + const admission: TerminalAuthorityAppNamespaceAdmission = { + admitNamespace: vi.fn(async () => {}), + resolveAndAdmitNamespace: vi.fn(async () => reconnectAccess.namespace), + withSourceAdmission: vi.fn(async (locator, operation) => { + expect(locator).toEqual({ namespace: reconnectAccess.namespace }) + order.push('admit') + const result = await operation({ + namespace: reconnectAccess.namespace, + assertCurrent: vi.fn() + }) + order.push('release') + return result + }) + } + const provider = new SshPtyProvider( + 'conn-1', + mux as never, + undefined, + 17, + undefined, + false, + exactCapability(() => true), + undefined, + admission + ) + + await provider.attachForReconnect('ssh:conn-1@@pty-1', { + terminalSessionAuthorityAccess: reconnectAccess + }) + + expect(admission.withSourceAdmission).toHaveBeenCalledOnce() + expect(order).toEqual(['admit', 'request:pty.attach', 'release']) + expect(mux.request).toHaveBeenCalledWith( + 'pty.attach', + { id: relayPtyId, suppressReplayNotification: true }, + expect.objectContaining({ timeoutMs: 10_000 }) + ) + }) +}) diff --git a/src/main/providers/ssh-pty-provider-list-fencing.test.ts b/src/main/providers/ssh-pty-provider-list-fencing.test.ts new file mode 100644 index 00000000000..2b91c8e3c2b --- /dev/null +++ b/src/main/providers/ssh-pty-provider-list-fencing.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, it, vi } from 'vitest' +import { SshPtyProvider } from './ssh-pty-provider' + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((settle) => { + resolve = settle + }) + return { promise, resolve } +} + +function createMux() { + let notification: ((method: string, params: Record) => void) | undefined + return { + mux: { + request: vi.fn(), + notify: vi.fn(), + onNotification: vi.fn((listener) => { + notification = listener + return vi.fn() + }), + dispose: vi.fn(), + isDisposed: vi.fn(() => false) + }, + notify(method: string, params: Record) { + notification?.(method, params) + } + } +} + +const authorityAccess = { + namespace: { authorityHostId: 'host-1', namespaceId: 'namespace-1' }, + pane: { paneKey: 'pane-1', paneGenerationId: 'renderer:1' }, + binding: { + ownerIncarnationId: 'owner-1', + physicalPtyId: 'pty-1', + ptyIncarnationId: 'incarnation-1' + } +} + +describe('SshPtyProvider process-list fencing', () => { + it('does not resurrect a row whose exit arrives while its list request is pending', async () => { + const { mux, notify } = createMux() + const pendingList = deferred() + mux.request + .mockResolvedValueOnce([ + { id: 'pty-1', cwd: '/repo', title: 'shell', incarnationId: 'incarnation-1' } + ]) + .mockReturnValueOnce(pendingList.promise) + const provider = new SshPtyProvider('conn-1', mux as never) + await provider.listProcesses() + + const listing = provider.listProcesses() + notify('pty.exit', { id: 'pty-1', code: 0, incarnationId: 'incarnation-1' }) + pendingList.resolve([ + { id: 'pty-1', cwd: '/repo', title: 'shell', incarnationId: 'incarnation-1' } + ]) + + await expect(listing).resolves.toEqual([]) + expect(provider.hasPty('ssh:conn-1@@pty-1')).toBe(false) + }) + + it('does not erase a concurrent spawn with an older empty inventory', async () => { + const { mux } = createMux() + const pendingList = deferred() + mux.request.mockImplementation((method: string) => + method === 'pty.listProcesses' + ? pendingList.promise + : Promise.resolve({ id: 'pty-new', incarnationId: 'incarnation-new' }) + ) + const provider = new SshPtyProvider('conn-1', mux as never) + + const listing = provider.listProcesses() + const spawn = await provider.spawn({ cols: 80, rows: 24 }) + pendingList.resolve([]) + + await expect(listing).resolves.toEqual([]) + expect(provider.hasPty(spawn.id)).toBe(true) + }) + + it('does not erase a successful reattach with an older empty inventory', async () => { + const { mux } = createMux() + const pendingList = deferred() + let listRequests = 0 + mux.request.mockImplementation((method: string) => { + if (method !== 'pty.listProcesses') { + return Promise.resolve({ incarnationId: 'incarnation-1' }) + } + listRequests += 1 + return listRequests === 1 + ? Promise.resolve([{ id: 'pty-1', cwd: '/repo', title: 'shell' }]) + : pendingList.promise + }) + const provider = new SshPtyProvider('conn-1', mux as never) + await provider.listProcesses() + + const listing = provider.listProcesses() + await provider.attach('ssh:conn-1@@pty-1') + pendingList.resolve([]) + + await expect(listing).resolves.toEqual([]) + expect(provider.hasPty('ssh:conn-1@@pty-1')).toBe(true) + }) + + it('keeps a preexisting live row when data arrives during its inventory request', async () => { + const { mux, notify } = createMux() + const pendingList = deferred() + const process = { + id: 'pty-1', + cwd: '/repo', + title: 'shell', + incarnationId: 'incarnation-1' + } + mux.request.mockResolvedValueOnce([process]).mockReturnValueOnce(pendingList.promise) + const provider = new SshPtyProvider('conn-1', mux as never) + await provider.listProcesses() + + const listing = provider.listProcesses() + notify('pty.data', { id: 'pty-1', data: 'busy', incarnationId: 'incarnation-1' }) + pendingList.resolve([process]) + + await expect(listing).resolves.toEqual([ + expect.objectContaining({ id: 'ssh:conn-1@@pty-1', incarnationId: 'incarnation-1' }) + ]) + expect(provider.hasPty('ssh:conn-1@@pty-1')).toBe(true) + }) + + it('reconciles an authoritative empty inventory without a concurrent change', async () => { + const { mux } = createMux() + mux.request + .mockResolvedValueOnce([{ id: 'pty-1', cwd: '/repo', title: 'shell' }]) + .mockResolvedValueOnce([]) + const provider = new SshPtyProvider('conn-1', mux as never) + + await provider.listProcesses() + await provider.listProcesses() + + expect(provider.hasPty('ssh:conn-1@@pty-1')).toBe(false) + }) + + it('rejects a superseded list response after a newer inventory commits', async () => { + const { mux } = createMux() + const staleList = deferred() + mux.request + .mockReturnValueOnce(staleList.promise) + .mockResolvedValueOnce([{ id: 'pty-new', cwd: '/new', title: 'new' }]) + const provider = new SshPtyProvider('conn-1', mux as never) + + const stale = provider.listProcesses() + await provider.listProcesses() + staleList.resolve([{ id: 'pty-old', cwd: '/old', title: 'old' }]) + + await expect(stale).rejects.toThrow('ssh_pty_process_inventory_unavailable') + expect(provider.hasPty('ssh:conn-1@@pty-new')).toBe(true) + expect(provider.hasPty('ssh:conn-1@@pty-old')).toBe(false) + }) + + it('rejects same-incarnation authority metadata disappearance', async () => { + const { mux } = createMux() + mux.request + .mockResolvedValueOnce([ + { + id: 'pty-1', + cwd: '/repo', + title: 'shell', + incarnationId: 'incarnation-1', + terminalSessionAuthorityAccess: authorityAccess + } + ]) + .mockResolvedValueOnce([ + { id: 'pty-1', cwd: '/repo', title: 'shell', incarnationId: 'incarnation-1' } + ]) + const provider = new SshPtyProvider('conn-1', mux as never) + await provider.listProcesses() + + await expect(provider.listProcesses()).rejects.toThrow( + 'ssh_terminal_authority_access_downgrade' + ) + expect(provider.getPtyMutationRouteToken('ssh:conn-1@@pty-1')).not.toBeNull() + }) +}) diff --git a/src/main/providers/ssh-pty-provider-output-state.ts b/src/main/providers/ssh-pty-provider-output-state.ts index 4d98097b9c6..a7f612d81e7 100644 --- a/src/main/providers/ssh-pty-provider-output-state.ts +++ b/src/main/providers/ssh-pty-provider-output-state.ts @@ -11,6 +11,7 @@ import { type SshPtyReceivingActivationLease } from './ssh-pty-notification-routing' import type { PtySourceReceivingActivation } from '../../shared/pty-source-receiving-activation' +import type { TerminalSessionAuthorityPtyAccess } from '../../shared/terminal-session-authority-pty-access' export class SshPtyProviderOutputState { private readonly dataListeners = new Set() @@ -30,6 +31,10 @@ export class SshPtyProviderOutputState { toAppPtyId: (id: string) => string livePtyIds: Set recordExit: (relayPtyId: string, incarnationId: unknown) => void + authorityOutcomeDelivery?: boolean + getTerminalSessionAuthorityAccess?: ( + relayPtyId: string + ) => TerminalSessionAuthorityPtyAccess | undefined } ) { this.subscription = subscribeSshPtyNotifications({ @@ -128,6 +133,15 @@ export class SshPtyProviderOutputState { } } + getPtyIncarnation(relayPtyId: string): string | undefined { + return this.incarnationByRelayPtyId.get(relayPtyId) + } + + forgetPtyIncarnation(relayPtyId: string): void { + this.incarnationByRelayPtyId.delete(relayPtyId) + this.pausedRelayPtyIds.delete(relayPtyId) + } + private resolvePtyIncarnation(relayPtyId: string, incarnationId: unknown): string { this.rememberPtyIncarnation(relayPtyId, incarnationId) let resolved = this.incarnationByRelayPtyId.get(relayPtyId) diff --git a/src/main/providers/ssh-pty-provider-process-events.test.ts b/src/main/providers/ssh-pty-provider-process-events.test.ts index e152ef47fae..625988c8dcd 100644 --- a/src/main/providers/ssh-pty-provider-process-events.test.ts +++ b/src/main/providers/ssh-pty-provider-process-events.test.ts @@ -34,7 +34,13 @@ describe('SshPtyProvider process listings and events', () => { mux.request.mockResolvedValue(processes) await expect(provider.listProcesses()).resolves.toEqual([ - { id: scopedPty1, cwd: '/home', title: 'zsh', worktreeId: 'repo::/home' } + { + id: scopedPty1, + cwd: '/home', + title: 'zsh', + worktreeId: 'repo::/home', + mutationRouteToken: expect.any(Object) + } ]) expect(mux.request).toHaveBeenLastCalledWith('pty.listProcesses', undefined, undefined) diff --git a/src/main/providers/ssh-pty-provider.test.ts b/src/main/providers/ssh-pty-provider.test.ts index d299e65b716..9560cc97af7 100644 --- a/src/main/providers/ssh-pty-provider.test.ts +++ b/src/main/providers/ssh-pty-provider.test.ts @@ -283,15 +283,6 @@ describe('SshPtyProvider', () => { expect(provider.hasPty(scopedPty1)).toBe(true) }) - it('keeps a spawned PTY live across an overlapping stale process list', async () => { - mux.request.mockResolvedValueOnce({ id: 'pty-new' }).mockResolvedValueOnce([]) - - const result = await provider.spawn({ cols: 80, rows: 24 }) - await provider.listProcesses() - - expect(provider.hasPty(result.id)).toBe(true) - }) - it('gates fresh startup intent with the relay ingress capability version', async () => { mux.request.mockResolvedValue({ id: 'pty-1' }) const startupIngress = { diff --git a/src/main/providers/ssh-pty-provider.ts b/src/main/providers/ssh-pty-provider.ts index 2bc52fe491d..4b25275c186 100644 --- a/src/main/providers/ssh-pty-provider.ts +++ b/src/main/providers/ssh-pty-provider.ts @@ -6,63 +6,120 @@ import type { RemoteCliBridgeEnv, SshPtyDataCallback, SshPtyDeliveryPauseAdapter, + SshPtyExpectedIdentity, SshPtyExitCallback, SshPtyReplayCallback } from './ssh-pty-provider-contract' import { SshPtyProviderOutputState } from './ssh-pty-provider-output-state' -import { spawnFreshSshPty } from './ssh-agent-session-create-operation' -import { mapSshPtyProcessList } from './ssh-agent-session-process-list' -import { - requestSshPtyAttach, - reattachSshPtySessionWithExitFence, - type PtySourceRecoveryRequest, - type SshPtyAttachResult -} from './ssh-pty-session-reattach' -import { buildSshPtySpawnRequest } from './ssh-pty-spawn-request' +import type { PtySourceRecoveryRequest, SshPtyAttachResult } from './ssh-pty-session-reattach' import { SshPtySpawnExitRaceTracker } from './ssh-pty-spawn-exit-race' import { SshAgentSessionCapabilities } from './ssh-agent-session-capabilities' import type { PtyProcessInspection } from './pty-process-inspection' -import { SSH_SESSION_EXPIRED_ERROR } from './ssh-pty-errors' - -// Why: sequential relay teardown calls share one absolute budget; convert to the mux-relative timeout only at dispatch. -function relayTimeoutOptions(deadlineMs: number | undefined): { timeoutMs: number } | undefined { - return deadlineMs === undefined ? undefined : { timeoutMs: Math.max(1, deadlineMs - Date.now()) } -} +import { shutdownSshPty, type SshPtyShutdownOptions } from './ssh-pty-shutdown-operation' +import type { TerminalSessionAuthorityPtyAccess } from '../../shared/terminal-session-authority-pty-access' +import { + SshPtyAuthorityRouting, + type SshPtyAuthorityExactOperationCapability, + type SshPtyExactOperationCapability +} from './ssh-pty-authority-routing' +import { SshPtyLiveMembership } from './ssh-pty-live-membership' +import { SshPtyProcessInventory } from './ssh-pty-process-inventory' +import { SshPtySessionLifecycle } from './ssh-pty-session-lifecycle' +import { + SshPtyHeldProducerPause, + type SshPtyHeldProducerPauseCapability +} from './ssh-pty-held-producer-pause' +import type { TerminalAuthorityAppNamespaceAdmission } from '../session-authority/terminal-authority-app-outcome-host-contract' /** Remote PTY provider that proxies IPtyProvider operations through the relay. */ export class SshPtyProvider implements IPtyProvider { private mux: SshChannelMultiplexer private connectionId: string - private livePtyIds = new Set() + private livePtyIds = new SshPtyLiveMembership() readonly getAppliedSize: NonNullable private readonly agentSessionCapabilities: SshAgentSessionCapabilities private spawnExitRaces = new SshPtySpawnExitRaceTracker() private readonly outputState: SshPtyProviderOutputState + private readonly authorityRouting: SshPtyAuthorityRouting + private readonly processInventory: SshPtyProcessInventory + private readonly sessionLifecycle: SshPtySessionLifecycle + private readonly heldProducerPause: SshPtyHeldProducerPause constructor( connectionId: string, mux: SshChannelMultiplexer, - private readonly remoteCliBridgeEnv?: RemoteCliBridgeEnv, - readonly providerGeneration = 1 + remoteCliBridgeEnv?: RemoteCliBridgeEnv, + readonly providerGeneration = 1, + exactOperationCapability?: SshPtyExactOperationCapability, + authorityOutcomeDelivery = false, + authorityExactOperationCapability?: SshPtyAuthorityExactOperationCapability, + heldProducerPauseCapability?: SshPtyHeldProducerPauseCapability, + terminalAuthorityAppAdmission?: TerminalAuthorityAppNamespaceAdmission ) { this.connectionId = connectionId this.mux = mux this.agentSessionCapabilities = new SshAgentSessionCapabilities(mux) this.getAppliedSize = createSshPtyAppliedSizeReader(mux, connectionId) - + this.authorityRouting = new SshPtyAuthorityRouting({ + mux, + legacyCapability: exactOperationCapability, + authorityCapability: authorityExactOperationCapability, + livePtyIds: this.livePtyIds, + toRelayPtyId: this.toRelayPtyId, + getPtyIncarnation: (relayPtyId) => this.outputState.getPtyIncarnation(relayPtyId) + }) this.outputState = new SshPtyProviderOutputState(providerGeneration, { mux, toAppPtyId: (id) => this.toAppPtyId(id), livePtyIds: this.livePtyIds, + authorityOutcomeDelivery, + getTerminalSessionAuthorityAccess: (relayPtyId) => + this.authorityRouting.accessForRelayPtyId(relayPtyId), recordExit: (relayPtyId, incarnationId) => { this.spawnExitRaces.recordExit(relayPtyId, incarnationId) + this.authorityRouting.recordExit(relayPtyId) } }) + this.heldProducerPause = new SshPtyHeldProducerPause({ + mux, + capability: heldProducerPauseCapability, + toRelayPtyId: this.toRelayPtyId, + getPtyIncarnation: (relayPtyId) => this.outputState.getPtyIncarnation(relayPtyId) + }) + this.processInventory = new SshPtyProcessInventory({ + mux, + livePtyIds: this.livePtyIds, + outputState: this.outputState, + authorityRouting: this.authorityRouting, + toAppPtyId: this.toAppPtyId, + toRelayPtyId: this.toRelayPtyId + }) + this.sessionLifecycle = new SshPtySessionLifecycle({ + mux, + connectionId, + remoteCliBridgeEnv, + livePtyIds: this.livePtyIds, + outputState: this.outputState, + exitRaceTracker: this.spawnExitRaces, + capabilities: this.agentSessionCapabilities, + toRelayPtyId: this.toRelayPtyId, + toAppPtyId: this.toAppPtyId, + rememberAuthorityAccess: (relayPtyId, access) => + this.authorityRouting.rememberAccess( + relayPtyId, + access, + this.outputState.getPtyIncarnation(relayPtyId) + ), + expectAuthorityAccess: (relayPtyId, access) => + this.authorityRouting.expectAccess(relayPtyId, access), + terminalAuthorityAppAdmission + }) } dispose(): void { this.outputState.dispose() this.livePtyIds.clear() + this.authorityRouting.dispose() } getConnectionId = (): string => this.connectionId @@ -73,78 +130,17 @@ export class SshPtyProvider implements IPtyProvider { private toAppPtyId = (id: string): string => toAppSshPtyId(this.connectionId, id) - async spawn(opts: PtySpawnOptions): Promise { - if (opts.agentSessionEnsure && opts.sessionId) { - throw new Error('agent_session_claim_unavailable') - } - if (opts.agentSessionEnsure) { - const supportsClaims = await this.supportsAgentSessionClaims({ signal: opts.signal }) - if (opts.signal?.aborted) { - throw new Error('client_disconnected') - } - if (!supportsClaims) { - throw new Error('agent_session_claim_unavailable') - } - } - if (opts.sessionId) { - let result: Awaited> | undefined - try { - result = await reattachSshPtySessionWithExitFence({ - mux: this.mux, - connectionId: this.connectionId, - sessionId: opts.sessionId, - options: opts, - exitRaceTracker: this.spawnExitRaces, - installSourceActivation: (relayPtyId, activation) => - this.outputState.installReceivingActivation(relayPtyId, activation), - rememberPtyIncarnation: (relayPtyId, incarnationId) => - this.outputState.rememberPtyIncarnation(relayPtyId, incarnationId) - }) - if (result.sourceRecovery?.status === 'restoreRequired') { - throw new Error( - `${SSH_SESSION_EXPIRED_ERROR}: ${toRelaySshPtyId(this.connectionId, result.id)}` - ) - } - this.livePtyIds.add(result.id) - result.sourceActivationLease?.commit() - const { - sourceActivationLease: _lease, - sourceRecovery: _sourceRecovery, - ...spawnResult - } = result - return spawnResult - } catch (error) { - result?.sourceActivationLease?.rollback() - throw error - } - } + resolveTerminalSessionAuthorityPhysicalPtyId(id: string): string | null { + return this.authorityRouting.resolvePhysicalPtyId(id) + } - const supportsCreateOperation = opts.agentSessionCreateOperationId - ? await this.supportsAgentSessionCreateOperations({ signal: opts.signal }) - : false - if (opts.signal?.aborted) { - throw new Error('client_disconnected') - } - if (opts.agentSessionCreateOperationId && !supportsCreateOperation) { - // Why: host routing owns legacy selection; a changed relay must not downgrade after dispatch. - throw new Error('execution_owner_unavailable') - } - return await spawnFreshSshPty({ - mux: this.mux, - options: opts, - params: buildSshPtySpawnRequest({ - options: opts, - remoteCliBridgeEnv: this.remoteCliBridgeEnv, - supportsCreateOperation - }), - exitRaceTracker: this.spawnExitRaces, - installSourceActivation: (id, activation) => - this.outputState.installReceivingActivation(id, activation), - rememberPtyIncarnation: (id, incarnation) => - this.outputState.rememberPtyIncarnation(id, incarnation), - acceptLivePty: (id) => this.livePtyIds.add(id), - toAppPtyId: this.toAppPtyId - }) + getTerminalSessionAuthorityAccess(id: string): TerminalSessionAuthorityPtyAccess | null { + const relayPtyId = this.authorityRouting.resolvePhysicalPtyId(id) + return relayPtyId ? (this.authorityRouting.accessForRelayPtyId(relayPtyId) ?? null) : null + } + + async spawn(opts: PtySpawnOptions): Promise { + return await this.sessionLifecycle.spawn(opts) } async supportsAgentSessionClaims(options: { signal?: AbortSignal } = {}): Promise { @@ -162,73 +158,68 @@ export class SshPtyProvider implements IPtyProvider { } async attach(id: string): Promise { - const relayPtyId = this.toRelayPtyId(id) - await requestSshPtyAttach({ - mux: this.mux, - relayPtyId, - params: { id: relayPtyId }, - commitSourceActivation: true, - installSourceActivation: (ptyId, activation) => - this.outputState.installReceivingActivation(ptyId, activation), - rememberPtyIncarnation: (ptyId, incarnationId) => - this.outputState.rememberPtyIncarnation(ptyId, incarnationId) - }) + await this.sessionLifecycle.attach(id) } async attachForReconnect( id: string, - expected?: { paneKey?: string; tabId?: string }, + expected?: SshPtyExpectedIdentity, sourceRecovery?: PtySourceRecoveryRequest ): Promise { - // Why: reconnect owns replay delivery so stale/duplicate attach results can - // be filtered before they reach the renderer. The expected identity lets the - // relay reject a cross-generation id collision instead of reattaching this - // lease to a different pane's freshly spawned PTY. - const params = { - id: this.toRelayPtyId(id), - suppressReplayNotification: true, - ...(sourceRecovery ? { sourceRecovery } : {}), - ...(expected?.paneKey ? { expectedPaneKey: expected.paneKey } : {}), - ...(expected?.tabId ? { expectedTabId: expected.tabId } : {}) - } - const relayPtyId = this.toRelayPtyId(id) - return await requestSshPtyAttach({ - mux: this.mux, - relayPtyId, - params, - timeoutMs: 10_000, - installSourceActivation: (ptyId, activation) => - this.outputState.installReceivingActivation(ptyId, activation), - rememberPtyIncarnation: (ptyId, incarnationId) => - this.outputState.rememberPtyIncarnation(ptyId, incarnationId) - }) + return await this.sessionLifecycle.attachForReconnect(id, expected, sourceRecovery) } write(id: string, data: string): void { + if (this.authorityRouting.mutationMode(id) !== 'legacy') { + return + } this.mux.notify('pty.data', { id: this.toRelayPtyId(id), data }) } resize(id: string, cols: number, rows: number): void { + if (this.authorityRouting.mutationMode(id) !== 'legacy') { + return + } this.mux.notify('pty.resize', { id: this.toRelayPtyId(id), cols, rows }) } - async shutdown( - id: string, - opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number } - ): Promise { - await this.mux.request( - 'pty.shutdown', - { - id: this.toRelayPtyId(id), - immediate: opts.immediate ?? false, - keepHistory: opts.keepHistory ?? false - }, - relayTimeoutOptions(opts.deadlineMs) - ) + async shutdown(id: string, opts: SshPtyShutdownOptions): Promise { + if (this.authorityRouting.mutationMode(id) !== 'legacy') { + throw new Error('terminal_authority_exact_operation_required') + } + await shutdownSshPty({ mux: this.mux, relayPtyId: this.toRelayPtyId(id), options: opts }) this.livePtyIds.delete(id) } + supportsExactPtyOperations = (id: string): boolean => + this.authorityRouting.mutationMode(id) === 'exact' + getPtyMutationMode = (id: string): 'legacy' | 'exact' | 'unavailable' => + this.authorityRouting.mutationMode(id) + killExact: NonNullable = (...args) => + this.authorityRouting.killExact(...args) + writeExact: NonNullable = (...args) => + this.authorityRouting.writeExact(...args) + resizeExact: NonNullable = (...args) => + this.authorityRouting.resizeExact(...args) + sendSignalExact: NonNullable = (...args) => + this.authorityRouting.sendSignalExact(...args) + clearBufferExact: NonNullable = (...args) => + this.authorityRouting.clearBufferExact(...args) + writeAuthorityExact: NonNullable = (...args) => + this.authorityRouting.writeAuthorityExact(...args) + resizeAuthorityExact: NonNullable = (...args) => + this.authorityRouting.resizeAuthorityExact(...args) + killAuthorityExact: NonNullable = (...args) => + this.authorityRouting.killAuthorityExact(...args) + sendSignalAuthorityExact: NonNullable = (...args) => + this.authorityRouting.sendSignalAuthorityExact(...args) + clearBufferAuthorityExact: NonNullable = (...args) => + this.authorityRouting.clearBufferAuthorityExact(...args) + async sendSignal(id: string, signal: string): Promise { + if (this.authorityRouting.mutationMode(id) !== 'legacy') { + throw new Error('terminal_authority_exact_operation_required') + } await this.mux.request('pty.sendSignal', { id: this.toRelayPtyId(id), signal }) } @@ -243,6 +234,9 @@ export class SshPtyProvider implements IPtyProvider { } async clearBuffer(id: string): Promise { + if (this.authorityRouting.mutationMode(id) !== 'legacy') { + throw new Error('terminal_authority_exact_operation_required') + } await this.mux.request('pty.clearBuffer', { id: this.toRelayPtyId(id) }) } @@ -285,22 +279,22 @@ export class SshPtyProvider implements IPtyProvider { } async listProcesses(opts?: { deadlineMs?: number }): Promise { - const result = await this.mux.request( - 'pty.listProcesses', - undefined, - relayTimeoutOptions(opts?.deadlineMs) - ) - const processes = mapSshPtyProcessList(result as PtyProcessInfo[], (id) => this.toAppPtyId(id)) - for (const process of processes) { - this.livePtyIds.add(process.id) - const relayPtyId = this.toRelayPtyId(process.id) - this.outputState.rememberPtyIncarnation(relayPtyId, process.incarnationId) - } - return processes + return await this.processInventory.list(opts?.deadlineMs) } - hasPty(id: string): boolean { - return this.livePtyIds.has(id) + hasPty = (id: string): boolean => this.livePtyIds.has(id) + getPtyMutationRouteToken = (id: string): object | null => this.authorityRouting.mutationToken(id) + + bindTerminalSessionAuthorityAccess( + id: string, + authorityAccess: TerminalSessionAuthorityPtyAccess + ): boolean { + const relayPtyId = this.toRelayPtyId(id) + return this.authorityRouting.bindAccess( + id, + authorityAccess, + this.outputState.getPtyIncarnation(relayPtyId) + ) } async getDefaultShell(): Promise { @@ -327,13 +321,18 @@ export class SshPtyProvider implements IPtyProvider { return this.outputState.hasDeliveryPauseAdapter() } - pauseProducer(id: string): void { - this.outputState.pause(this.toRelayPtyId(id)) - } + pauseProducer = (id: string): void => this.outputState.pause(this.toRelayPtyId(id)) + resumeProducer = (id: string): void => this.outputState.resume(this.toRelayPtyId(id)) - resumeProducer(id: string): void { - this.outputState.resume(this.toRelayPtyId(id)) - } + supportsExactHeldProducerPause: NonNullable = ( + ...args + ) => this.heldProducerPause.supports(...args) + acquireExactHeldProducerPause: NonNullable = ( + ...args + ) => this.heldProducerPause.acquire(...args) + releaseExactHeldProducerPause: NonNullable = ( + ...args + ) => this.heldProducerPause.release(...args) closeOutputIntake(reason: string): void { this.mux.dispose('connection_lost') diff --git a/src/main/providers/ssh-pty-session-lifecycle.ts b/src/main/providers/ssh-pty-session-lifecycle.ts new file mode 100644 index 00000000000..cb2fdeed36d --- /dev/null +++ b/src/main/providers/ssh-pty-session-lifecycle.ts @@ -0,0 +1,233 @@ +import type { TerminalSessionAuthorityPtyAccess } from '../../shared/terminal-session-authority-pty-access' +import { sameTerminalSessionAuthorityPtyAccess } from '../../shared/terminal-session-authority-pty-access' +import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' +import { spawnFreshSshPty } from './ssh-agent-session-create-operation' +import type { SshAgentSessionCapabilities } from './ssh-agent-session-capabilities' +import { SSH_SESSION_EXPIRED_ERROR } from './ssh-pty-errors' +import type { SshPtyLiveMembership } from './ssh-pty-live-membership' +import type { RemoteCliBridgeEnv, SshPtyExpectedIdentity } from './ssh-pty-provider-contract' +import type { SshPtyProviderOutputState } from './ssh-pty-provider-output-state' +import { + requestSshPtyAttach, + reattachSshPtySessionWithExitFence, + type PtySourceRecoveryRequest, + type SshPtyAttachResult +} from './ssh-pty-session-reattach' +import type { SshPtySpawnExitRaceTracker } from './ssh-pty-spawn-exit-race' +import { buildSshPtySpawnRequest } from './ssh-pty-spawn-request' +import { buildSshTerminalAuthorityAttachRequest } from './ssh-terminal-authority-attach-request' +import type { PtySpawnOptions, PtySpawnResult } from './types' +import type { + TerminalAuthorityAppAdmissionLocator, + TerminalAuthorityAppNamespaceAdmission +} from '../session-authority/terminal-authority-app-outcome-host-contract' + +type SshPtySessionLifecycleOptions = Readonly<{ + mux: SshChannelMultiplexer + connectionId: string + remoteCliBridgeEnv?: RemoteCliBridgeEnv + livePtyIds: SshPtyLiveMembership + outputState: SshPtyProviderOutputState + exitRaceTracker: SshPtySpawnExitRaceTracker + capabilities: SshAgentSessionCapabilities + toRelayPtyId: (id: string) => string + toAppPtyId: (id: string) => string + rememberAuthorityAccess: ( + relayPtyId: string, + access: TerminalSessionAuthorityPtyAccess | undefined + ) => void + expectAuthorityAccess: (relayPtyId: string, access: TerminalSessionAuthorityPtyAccess) => void + terminalAuthorityAppAdmission?: TerminalAuthorityAppNamespaceAdmission +}> + +export class SshPtySessionLifecycle { + constructor(private readonly options: SshPtySessionLifecycleOptions) {} + + async spawn(spawnOptions: PtySpawnOptions): Promise { + if (spawnOptions.agentSessionEnsure && spawnOptions.sessionId) { + throw new Error('agent_session_claim_unavailable') + } + if (spawnOptions.agentSessionEnsure) { + const supportsClaims = await this.options.capabilities.supportsClaims({ + signal: spawnOptions.signal + }) + if (spawnOptions.signal?.aborted) { + throw new Error('client_disconnected') + } + if (!supportsClaims) { + throw new Error('agent_session_claim_unavailable') + } + } + return await this.withTerminalAuthorityAdmission( + this.spawnAdmissionLocator(spawnOptions), + async () => + spawnOptions.sessionId + ? await this.reattachForSpawn(spawnOptions) + : await this.spawnFresh(spawnOptions) + ) + } + + private spawnAdmissionLocator( + spawnOptions: PtySpawnOptions + ): TerminalAuthorityAppAdmissionLocator | null { + if (!this.options.terminalAuthorityAppAdmission) { + return null + } + if (spawnOptions.terminalSessionAuthorityAccess) { + return { namespace: spawnOptions.terminalSessionAuthorityAccess.namespace } + } + if (spawnOptions.paneGeneration === undefined && !spawnOptions.agentSessionEnsure) { + return null + } + if (!spawnOptions.worktreeId) { + throw new Error('terminal_session_authority_workspace_required') + } + return { worktreeId: spawnOptions.worktreeId } + } + + private async withTerminalAuthorityAdmission( + locator: TerminalAuthorityAppAdmissionLocator | null, + operation: () => Promise + ): Promise { + const admission = this.options.terminalAuthorityAppAdmission + if (!admission || !locator) { + return await operation() + } + return await admission.withSourceAdmission(locator, async ({ assertCurrent }) => { + assertCurrent() + return await operation() + }) + } + + async attach(id: string): Promise { + const relayPtyId = this.options.toRelayPtyId(id) + const result = await requestSshPtyAttach({ + mux: this.options.mux, + relayPtyId, + params: { id: relayPtyId }, + commitSourceActivation: true, + installSourceActivation: (ptyId, activation) => + this.options.outputState.installReceivingActivation(ptyId, activation), + rememberPtyIncarnation: (ptyId, incarnationId) => + this.options.outputState.rememberPtyIncarnation(ptyId, incarnationId) + }) + this.options.rememberAuthorityAccess(relayPtyId, result.terminalSessionAuthorityAccess) + this.options.livePtyIds.markLifecycleTransition(id) + } + + async attachForReconnect( + id: string, + expected?: SshPtyExpectedIdentity, + sourceRecovery?: PtySourceRecoveryRequest + ): Promise { + const relayPtyId = this.options.toRelayPtyId(id) + const expectedAccess = expected?.terminalSessionAuthorityAccess + if (expectedAccess) { + this.options.expectAuthorityAccess(relayPtyId, expectedAccess) + } + return await this.withTerminalAuthorityAdmission( + expectedAccess ? { namespace: expectedAccess.namespace } : null, + async () => { + const result = await requestSshPtyAttach({ + mux: this.options.mux, + relayPtyId, + params: { + id: relayPtyId, + suppressReplayNotification: true, + ...(sourceRecovery ? { sourceRecovery } : {}), + ...(expected?.paneKey ? { expectedPaneKey: expected.paneKey } : {}), + ...(expected?.tabId ? { expectedTabId: expected.tabId } : {}), + ...buildSshTerminalAuthorityAttachRequest(expected) + }, + timeoutMs: 10_000, + installSourceActivation: (ptyId, activation) => + this.options.outputState.installReceivingActivation(ptyId, activation), + rememberPtyIncarnation: (ptyId, incarnationId) => + this.options.outputState.rememberPtyIncarnation(ptyId, incarnationId) + }) + if ( + expectedAccess && + (!result.terminalSessionAuthorityAccess || + !sameTerminalSessionAuthorityPtyAccess( + expectedAccess, + result.terminalSessionAuthorityAccess + )) + ) { + throw new Error('ssh_terminal_authority_attach_access_mismatch') + } + this.options.rememberAuthorityAccess(relayPtyId, result.terminalSessionAuthorityAccess) + this.options.livePtyIds.markLifecycleTransition(id) + return result + } + ) + } + + private async reattachForSpawn(spawnOptions: PtySpawnOptions): Promise { + let result: Awaited> | undefined + try { + result = await reattachSshPtySessionWithExitFence({ + mux: this.options.mux, + connectionId: this.options.connectionId, + sessionId: spawnOptions.sessionId!, + options: spawnOptions, + exitRaceTracker: this.options.exitRaceTracker, + installSourceActivation: (relayPtyId, activation) => + this.options.outputState.installReceivingActivation(relayPtyId, activation), + rememberPtyIncarnation: (relayPtyId, incarnationId) => + this.options.outputState.rememberPtyIncarnation(relayPtyId, incarnationId) + }) + if (result.sourceRecovery?.status === 'restoreRequired') { + throw new Error(`${SSH_SESSION_EXPIRED_ERROR}: ${this.options.toRelayPtyId(result.id)}`) + } + this.options.livePtyIds.markLifecycleTransition(result.id) + result.sourceActivationLease?.commit() + const { + sourceActivationLease: _lease, + sourceRecovery: _sourceRecovery, + ...spawnResult + } = result + this.options.rememberAuthorityAccess( + this.options.toRelayPtyId(spawnResult.id), + spawnResult.terminalSessionAuthorityAccess + ) + return spawnResult + } catch (error) { + result?.sourceActivationLease?.rollback() + throw error + } + } + + private async spawnFresh(spawnOptions: PtySpawnOptions): Promise { + const supportsCreateOperation = spawnOptions.agentSessionCreateOperationId + ? await this.options.capabilities.supportsCreateOperations({ signal: spawnOptions.signal }) + : false + if (spawnOptions.signal?.aborted) { + throw new Error('client_disconnected') + } + if (spawnOptions.agentSessionCreateOperationId && !supportsCreateOperation) { + // Host routing owns legacy selection; a changed relay must not downgrade after dispatch. + throw new Error('execution_owner_unavailable') + } + const result = await spawnFreshSshPty({ + mux: this.options.mux, + options: spawnOptions, + params: buildSshPtySpawnRequest({ + options: spawnOptions, + remoteCliBridgeEnv: this.options.remoteCliBridgeEnv, + supportsCreateOperation + }), + exitRaceTracker: this.options.exitRaceTracker, + installSourceActivation: (id, activation) => + this.options.outputState.installReceivingActivation(id, activation), + rememberPtyIncarnation: (id, incarnation) => + this.options.outputState.rememberPtyIncarnation(id, incarnation), + acceptLivePty: (id) => this.options.livePtyIds.markLifecycleTransition(id), + toAppPtyId: this.options.toAppPtyId + }) + this.options.rememberAuthorityAccess( + this.options.toRelayPtyId(result.id), + result.terminalSessionAuthorityAccess + ) + return result + } +} diff --git a/src/main/providers/ssh-pty-session-reattach.ts b/src/main/providers/ssh-pty-session-reattach.ts index 34974940caf..f0ad579492a 100644 --- a/src/main/providers/ssh-pty-session-reattach.ts +++ b/src/main/providers/ssh-pty-session-reattach.ts @@ -18,6 +18,11 @@ import { type PtySourceReceivingActivation } from '../../shared/pty-source-receiving-activation' import type { SshPtyReceivingActivationLease } from './ssh-pty-notification-routing' +import { buildSshTerminalAuthorityAttachRequest } from './ssh-terminal-authority-attach-request' +import { + parseTerminalSessionAuthorityPtyAccess, + type TerminalSessionAuthorityPtyAccess +} from '../../shared/terminal-session-authority-pty-access' export type SshPtyAttachResult = { replay?: string @@ -25,6 +30,7 @@ export type SshPtyAttachResult = { sourceRecovery?: PtySourceRecoveryResult sourceActivation?: PtySourceReceivingActivation sourceActivationLease?: SshPtyReceivingActivationLease + terminalSessionAuthorityAccess?: TerminalSessionAuthorityPtyAccess } type SshPtyReattachResult = PtySpawnResult & { @@ -44,6 +50,7 @@ export function parseSshPtyAttachResult(value: unknown): SshPtyAttachResult { incarnationId?: unknown sourceRecovery?: unknown sourceActivation?: unknown + terminalSessionAuthorityAccess?: unknown } if (result.replay !== undefined && typeof result.replay !== 'string') { throw new Error('Invalid SSH PTY attach replay') @@ -54,6 +61,16 @@ export function parseSshPtyAttachResult(value: unknown): SshPtyAttachResult { } const sourceRecovery = parseSourceRecoveryResult(result.sourceRecovery) const sourceActivation = parsePtySourceReceivingActivation(result.sourceActivation) + const authorityAccess = + result.terminalSessionAuthorityAccess === undefined + ? null + : parseTerminalSessionAuthorityPtyAccess(result.terminalSessionAuthorityAccess) + if ( + result.terminalSessionAuthorityAccess !== undefined && + (!authorityAccess || authorityAccess.binding.ptyIncarnationId !== result.incarnationId) + ) { + throw new Error('Invalid SSH terminal authority attach identity') + } const activation = sourceActivation ?? (sourceRecovery?.status === 'pending' ? sourceRecovery : undefined) if ( @@ -68,7 +85,8 @@ export function parseSshPtyAttachResult(value: unknown): SshPtyAttachResult { ...(typeof result.replay === 'string' ? { replay: result.replay } : {}), ...(isPtyIncarnationId(result.incarnationId) ? { incarnationId: result.incarnationId } : {}), ...(sourceRecovery ? { sourceRecovery } : {}), - ...(activation ? { sourceActivation: activation } : {}) + ...(activation ? { sourceActivation: activation } : {}), + ...(authorityAccess ? { terminalSessionAuthorityAccess: authorityAccess } : {}) } } @@ -96,6 +114,12 @@ export async function requestSshPtyAttach(args: { beforeResolve: (value) => installFromResult(parseSshPtyAttachResult(value)) }) const result = parseSshPtyAttachResult(rawResult) + if ( + result.terminalSessionAuthorityAccess && + result.terminalSessionAuthorityAccess.binding.physicalPtyId !== args.relayPtyId + ) { + throw new Error('Invalid SSH terminal authority attach route') + } installFromResult(result) args.rememberPtyIncarnation?.(args.relayPtyId, result.incarnationId) if (args.commitSourceActivation) { @@ -188,6 +212,17 @@ export async function reattachSshPtySession(args: { // Why: expected pane identity prevents a reused relay id from attaching the wrong shell. const expectedPaneKey = args.options.paneKey ?? args.options.env?.ORCA_PANE_KEY const expectedTabId = args.options.tabId ?? args.options.env?.ORCA_TAB_ID + const expected = { + ...(expectedPaneKey ? { paneKey: expectedPaneKey } : {}), + ...(expectedTabId ? { tabId: expectedTabId } : {}), + ...(args.options.worktreeId ? { worktreeId: args.options.worktreeId } : {}), + ...(args.options.paneGeneration !== undefined + ? { paneGeneration: args.options.paneGeneration } + : {}), + ...(args.options.expectedIncarnationId + ? { ptyIncarnationId: args.options.expectedIncarnationId } + : {}) + } const attachResult = await requestSshPtyAttach({ mux: args.mux, relayPtyId: relaySessionId, @@ -197,7 +232,8 @@ export async function reattachSshPtySession(args: { rows: args.options.rows, suppressReplayNotification: true, ...(expectedPaneKey ? { expectedPaneKey } : {}), - ...(expectedTabId ? { expectedTabId } : {}) + ...(expectedTabId ? { expectedTabId } : {}), + ...buildSshTerminalAuthorityAttachRequest(expected) }, installSourceActivation: args.installSourceActivation, rememberPtyIncarnation: args.rememberPtyIncarnation @@ -212,6 +248,9 @@ export async function reattachSshPtySession(args: { ...(attachResult.incarnationId ? { incarnationId: attachResult.incarnationId } : {}), ...(attachResult.sourceRecovery ? { sourceRecovery: attachResult.sourceRecovery } : {}), ...(attachResult.sourceActivation ? { sourceActivation: attachResult.sourceActivation } : {}), + ...(attachResult.terminalSessionAuthorityAccess + ? { terminalSessionAuthorityAccess: attachResult.terminalSessionAuthorityAccess } + : {}), ...(attachResult.sourceActivationLease ? { sourceActivationLease: attachResult.sourceActivationLease } : {}) diff --git a/src/main/providers/ssh-pty-shutdown-operation.ts b/src/main/providers/ssh-pty-shutdown-operation.ts new file mode 100644 index 00000000000..2ace57865d3 --- /dev/null +++ b/src/main/providers/ssh-pty-shutdown-operation.ts @@ -0,0 +1,24 @@ +import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' +import { sshRelayDeadlineOptions } from './ssh-relay-request-deadline' + +export type SshPtyShutdownOptions = Readonly<{ + immediate?: boolean + keepHistory?: boolean + deadlineMs?: number +}> + +export async function shutdownSshPty(args: { + mux: SshChannelMultiplexer + relayPtyId: string + options: SshPtyShutdownOptions +}): Promise { + await args.mux.request( + 'pty.shutdown', + { + id: args.relayPtyId, + immediate: args.options.immediate ?? false, + keepHistory: args.options.keepHistory ?? false + }, + sshRelayDeadlineOptions(args.options.deadlineMs) + ) +} diff --git a/src/main/providers/ssh-pty-spawn-request.ts b/src/main/providers/ssh-pty-spawn-request.ts index 889c0813ee9..7fea7aca953 100644 --- a/src/main/providers/ssh-pty-spawn-request.ts +++ b/src/main/providers/ssh-pty-spawn-request.ts @@ -2,6 +2,7 @@ import type { PtySpawnOptions } from './types' import type { RemoteCliBridgeEnv } from './ssh-pty-provider-contract' import { buildSshPtySpawnEnv } from './ssh-pty-spawn-env' import { PTY_STARTUP_INGRESS_VERSION } from '../../shared/pty-startup-ingress' +import { TERMINAL_SESSION_AUTHORITY_SPAWN_VERSION } from '../../shared/terminal-session-authority-wire' export function buildSshPtySpawnRequest(args: { options: PtySpawnOptions @@ -32,6 +33,15 @@ export function buildSshPtySpawnRequest(args: { : {}), // Why: attach identity must survive even when hook variables are stripped from the shell env. ...(options.paneKey ? { paneKey: options.paneKey } : {}), + ...(options.worktreeId ? { worktreeId: options.worktreeId } : {}), + ...(options.paneGeneration !== undefined + ? { + terminalSessionAuthorityVersion: TERMINAL_SESSION_AUTHORITY_SPAWN_VERSION, + paneGeneration: options.paneGeneration + } + : options.agentSessionEnsure + ? { terminalSessionAuthorityVersion: TERMINAL_SESSION_AUTHORITY_SPAWN_VERSION } + : {}), ...(options.tabId ? { tabId: options.tabId } : {}), ...(options.startupIngress ? { diff --git a/src/main/providers/ssh-relay-request-deadline.ts b/src/main/providers/ssh-relay-request-deadline.ts new file mode 100644 index 00000000000..6ca46e51396 --- /dev/null +++ b/src/main/providers/ssh-relay-request-deadline.ts @@ -0,0 +1,6 @@ +// Why: sequential relay teardown calls share one absolute budget; convert only at dispatch. +export function sshRelayDeadlineOptions( + deadlineMs: number | undefined +): { timeoutMs: number } | undefined { + return deadlineMs === undefined ? undefined : { timeoutMs: Math.max(1, deadlineMs - Date.now()) } +} diff --git a/src/main/providers/ssh-terminal-authority-attach-request.test.ts b/src/main/providers/ssh-terminal-authority-attach-request.test.ts new file mode 100644 index 00000000000..e94a519ccb0 --- /dev/null +++ b/src/main/providers/ssh-terminal-authority-attach-request.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { buildSshTerminalAuthorityAttachRequest } from './ssh-terminal-authority-attach-request' + +describe('SSH terminal authority attach request', () => { + it('publishes authority metadata only with exact durable lease identity', () => { + expect( + buildSshTerminalAuthorityAttachRequest({ + paneKey: 'pane-a', + worktreeId: 'repo::/srv/repo' + }) + ).toEqual({}) + expect( + buildSshTerminalAuthorityAttachRequest({ + paneKey: 'pane-a', + worktreeId: 'repo::/srv/repo', + ptyIncarnationId: 'incarnation-a' + }) + ).toEqual({}) + }) + + it('includes a renderer generation when the lease recorded one', () => { + expect( + buildSshTerminalAuthorityAttachRequest({ + paneKey: 'pane-a', + worktreeId: 'repo::/srv/repo', + paneGeneration: 0, + ptyIncarnationId: 'incarnation-a' + }) + ).toMatchObject({ expectedPaneGeneration: 0 }) + }) +}) diff --git a/src/main/providers/ssh-terminal-authority-attach-request.ts b/src/main/providers/ssh-terminal-authority-attach-request.ts new file mode 100644 index 00000000000..d7eb2b8792f --- /dev/null +++ b/src/main/providers/ssh-terminal-authority-attach-request.ts @@ -0,0 +1,22 @@ +import { TERMINAL_SESSION_AUTHORITY_ATTACH_VERSION } from '../../shared/terminal-session-authority-wire' +import type { SshPtyExpectedIdentity } from './ssh-pty-provider-contract' + +export function buildSshTerminalAuthorityAttachRequest( + expected: SshPtyExpectedIdentity | undefined +): Record { + if ( + !expected?.paneKey || + !expected.worktreeId || + expected.paneGeneration === undefined || + !expected.ptyIncarnationId + ) { + return {} + } + return { + terminalSessionAuthorityAttachVersion: TERMINAL_SESSION_AUTHORITY_ATTACH_VERSION, + expectedWorktreeId: expected.worktreeId, + expectedPaneKey: expected.paneKey, + expectedPtyIncarnationId: expected.ptyIncarnationId, + expectedPaneGeneration: expected.paneGeneration + } +} diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index 23c36859a1f..090b7946c16 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -4,11 +4,18 @@ // ─── PTY Provider ─────────────────────────────────────────────────── +export { + isPtyHeldProducerPauseToken, + PTY_HELD_PRODUCER_PAUSE_TOKEN_MAX_LENGTH +} from './pty-provider-contract' + export type { IPtyProvider, PtyBackgroundStreamEvent, PtyDataEvent, + PtyHeldProducerPauseToken, PtyProcessInfo, + PtyMutationMode, PtyProviderBufferSnapshot, PtySpawnOptions, PtySpawnResult, diff --git a/src/main/rate-limits/service.ts b/src/main/rate-limits/service.ts index 7cdb1e7bef9..6b5861f8308 100644 --- a/src/main/rate-limits/service.ts +++ b/src/main/rate-limits/service.ts @@ -31,6 +31,7 @@ import { type CodexAccountSelectionTarget, type NormalizedCodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' +import { registerMainWindowClosedCleanup } from '../window/main-window-lifecycle' export type InactiveCodexAccountInfo = { id: string @@ -301,12 +302,12 @@ export class RateLimitService { const refreshOnResume = (): void => { void this.refreshIfWindowActive() } - // Why: attach() can replace windows; remove the previous closed listener too, not only the focus listeners. + let removeClosedCleanup = (): void => {} const detachWindowListeners = (): void => { mainWindow.removeListener('focus', refreshOnResume) mainWindow.removeListener('show', refreshOnResume) mainWindow.removeListener('restore', refreshOnResume) - mainWindow.removeListener('closed', onClosed) + removeClosedCleanup() } const onClosed = (): void => { detachWindowListeners() @@ -320,7 +321,7 @@ export class RateLimitService { mainWindow.on('focus', refreshOnResume) mainWindow.on('show', refreshOnResume) mainWindow.on('restore', refreshOnResume) - mainWindow.on('closed', onClosed) + removeClosedCleanup = registerMainWindowClosedCleanup(mainWindow, onClosed) this.detachWindowListeners = detachWindowListeners } diff --git a/src/main/runtime/__tests__/worktree-teardown-provider.ts b/src/main/runtime/__tests__/worktree-teardown-provider.ts new file mode 100644 index 00000000000..90aa405deba --- /dev/null +++ b/src/main/runtime/__tests__/worktree-teardown-provider.ts @@ -0,0 +1,65 @@ +import { vi } from 'vitest' +import type { IPtyProvider, PtyProcessInfo } from '../../providers/types' + +export type WorktreeTeardownProviderStub = IPtyProvider & { + shutdown: ReturnType + listProcesses: ReturnType + killExact: ReturnType + killAuthorityExact: ReturnType + getPtyMutationRouteToken: ReturnType + setMutationRouteToken: (token: object | null) => void +} + +export function createWorktreeTeardownProviderStub( + listProcesses: () => Promise +): WorktreeTeardownProviderStub { + let mutationRouteToken: object | null = Object.freeze({}) + const provider = { + spawn: vi.fn(), + attach: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + shutdown: vi.fn().mockResolvedValue(undefined), + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + acknowledgeDataEvent: vi.fn(), + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + listProcesses: vi.fn(async () => { + const listedToken = mutationRouteToken + return (await listProcesses()).map((session) => ({ + ...session, + incarnationId: session.incarnationId ?? `incarnation:${session.id}`, + ...(listedToken ? { mutationRouteToken: listedToken } : {}) + })) + }), + getDefaultShell: vi.fn(), + getProfiles: vi.fn(), + onData: vi.fn().mockReturnValue(() => {}), + onReplay: vi.fn().mockReturnValue(() => {}), + onExit: vi.fn().mockReturnValue(() => {}), + killExact: vi.fn(), + killAuthorityExact: vi.fn(), + getPtyMutationRouteToken: vi.fn(() => mutationRouteToken), + setMutationRouteToken: (token: object | null) => { + mutationRouteToken = token + } + } + provider.killExact.mockImplementation( + async (id: string, _incarnationId: string, opts: { immediate?: boolean }) => { + await provider.shutdown(id, opts) + return true + } + ) + provider.killAuthorityExact.mockImplementation( + async (id: string, _access: unknown, opts: { immediate?: boolean }) => { + await provider.shutdown(id, opts) + return true + } + ) + return provider as unknown as WorktreeTeardownProviderStub +} diff --git a/src/main/runtime/device-registry-record-validation.ts b/src/main/runtime/device-registry-record-validation.ts new file mode 100644 index 00000000000..4e0452c7b3d --- /dev/null +++ b/src/main/runtime/device-registry-record-validation.ts @@ -0,0 +1,136 @@ +import type { MobilePairingConnectionMode } from '../../shared/mobile-pairing-connection-mode' +import type { DeviceScope } from '../../shared/runtime-types' +import type { RuntimePairingReach } from '../../shared/runtime-pairing-reach' +import type { DeviceEntry } from './device-registry' +import type { RelayDeviceBinding } from './relay/relay-revoke-outbox' + +export function parseOrdinaryDevices(parsed: unknown): DeviceEntry[] { + if (!Array.isArray(parsed)) { + throw new Error('Device registry is invalid') + } + return parsed.map((device) => { + if (!device || typeof device !== 'object') { + throw new Error('Device registry is invalid') + } + const entry = device as DeviceEntry + return { + ...entry, + // Why: older registries only existed for phone pairing. Treat missing scope as mobile. + scope: entry.scope === 'runtime' ? 'runtime' : 'mobile', + relayBinding: validRelayBinding(entry.relayBinding, entry.deviceId), + mobilePairingConnectionMode: + entry.mobilePairingConnectionMode === 'local-only' ? 'local-only' : 'automatic', + // Why: missing reach predates off-host pairing and must retain network behavior. + pairingReach: entry.pairingReach === 'this-computer' ? 'this-computer' : 'network' + } + }) +} + +export function parseStrictDevices(parsed: unknown): DeviceEntry[] { + if (!Array.isArray(parsed)) { + throw new Error('Device registry is invalid') + } + const ids = new Set() + const tokens = new Set() + return parsed.map((value) => { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Device registry is invalid') + } + const entry = value as Partial + const scope = entry.scope === undefined ? 'mobile' : entry.scope + const mode = entry.mobilePairingConnectionMode ?? 'automatic' + const reach = entry.pairingReach ?? 'network' + if ( + !boundedText(entry.deviceId, 256) || + !boundedText(entry.name, 256) || + !boundedText(entry.token, 512) || + !isDeviceScope(scope) || + !finiteTimestamp(entry.pairedAt) || + !finiteTimestamp(entry.lastSeenAt) || + !isPairingMode(mode) || + !isPairingReach(reach) + ) { + throw new Error('Device registry is invalid') + } + if (ids.has(entry.deviceId) || tokens.has(entry.token)) { + throw new Error('Device registry is invalid') + } + ids.add(entry.deviceId) + tokens.add(entry.token) + const relayBinding = strictRelayBinding(entry.relayBinding, entry.deviceId) + return { + deviceId: entry.deviceId, + name: entry.name, + token: entry.token, + scope, + pairedAt: entry.pairedAt, + lastSeenAt: entry.lastSeenAt, + ...(relayBinding ? { relayBinding } : {}), + mobilePairingConnectionMode: mode, + pairingReach: reach + } + }) +} + +function validRelayBinding(value: unknown, deviceId: string): RelayDeviceBinding | undefined { + if (!value || typeof value !== 'object') { + return undefined + } + const binding = value as Partial + return binding.relayDeviceId === deviceId && + typeof binding.relayHostId === 'string' && + typeof binding.ownerIdentityKey === 'string' + ? { + relayHostId: binding.relayHostId, + relayDeviceId: binding.relayDeviceId, + ownerIdentityKey: binding.ownerIdentityKey, + ...(typeof binding.inviteExpiresAt === 'number' && Number.isFinite(binding.inviteExpiresAt) + ? { inviteExpiresAt: binding.inviteExpiresAt } + : {}) + } + : undefined +} + +function strictRelayBinding(value: unknown, deviceId: string): RelayDeviceBinding | undefined { + if (value === undefined) { + return undefined + } + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Device registry is invalid') + } + const binding = value as Partial + if ( + binding.relayDeviceId !== deviceId || + !boundedText(binding.relayHostId, 256) || + !boundedText(binding.ownerIdentityKey, 1024) || + (binding.inviteExpiresAt !== undefined && !finiteTimestamp(binding.inviteExpiresAt)) + ) { + throw new Error('Device registry is invalid') + } + return { + relayHostId: binding.relayHostId, + relayDeviceId: binding.relayDeviceId, + ownerIdentityKey: binding.ownerIdentityKey, + ...(binding.inviteExpiresAt !== undefined ? { inviteExpiresAt: binding.inviteExpiresAt } : {}) + } +} + +function boundedText(value: unknown, maxLength: number): value is string { + return typeof value === 'string' && value.length > 0 && value.length <= maxLength +} + +function finiteTimestamp(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 +} + +function isDeviceScope(value: unknown): value is DeviceScope { + return value === 'mobile' || value === 'runtime' +} + +function isPairingMode(value: unknown): value is MobilePairingConnectionMode { + return value === 'automatic' || value === 'local-only' +} + +function isPairingReach(value: unknown): value is RuntimePairingReach { + return value === 'network' || value === 'this-computer' +} diff --git a/src/main/runtime/device-registry.test.ts b/src/main/runtime/device-registry.test.ts new file mode 100644 index 00000000000..7fc34bd3194 --- /dev/null +++ b/src/main/runtime/device-registry.test.ts @@ -0,0 +1,67 @@ +import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { DeviceRegistry, loadDeviceRegistryForReset } from './device-registry' + +describe('DeviceRegistry reset loading', () => { + const directories: string[] = [] + + afterEach(() => { + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } + }) + + it('propagates corrupt registry state through the reset loader', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-device-registry-')) + directories.push(directory) + writeFileSync(join(directory, 'orca-devices.json'), '{invalid') + + expect(() => loadDeviceRegistryForReset(directory)).toThrow('Device registry is invalid') + expect(new DeviceRegistry(directory).listDevices()).toEqual([]) + }) + + it('accepts a fully validated registry through the reset loader', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-device-registry-')) + directories.push(directory) + writeFileSync( + join(directory, 'orca-devices.json'), + JSON.stringify([ + { + deviceId: 'device-1', + name: 'phone', + token: 'token-1', + scope: 'mobile', + pairedAt: 1, + lastSeenAt: 0 + } + ]) + ) + + expect(loadDeviceRegistryForReset(directory).getDevice('device-1')?.token).toBe('token-1') + }) + + it('rejects missing state only through the reset loader', () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-device-registry-')) + directories.push(directory) + + expect(() => loadDeviceRegistryForReset(directory)).toThrow('Device registry is missing') + expect(new DeviceRegistry(directory).listDevices()).toEqual([]) + }) + + it.each(['symlink', 'directory'] as const)('rejects a %s reset registry path', (kind) => { + const directory = mkdtempSync(join(tmpdir(), 'orca-device-registry-')) + const outside = mkdtempSync(join(tmpdir(), 'orca-device-registry-target-')) + directories.push(directory, outside) + const target = join(outside, 'registry.json') + writeFileSync(target, '[]') + if (kind === 'symlink') { + symlinkSync(target, join(directory, 'orca-devices.json')) + } else { + mkdirSync(join(directory, 'orca-devices.json')) + } + + expect(() => loadDeviceRegistryForReset(directory)).toThrow('Device registry is invalid') + }) +}) diff --git a/src/main/runtime/device-registry.ts b/src/main/runtime/device-registry.ts index 88818b3cb1d..663ac22b2ac 100644 --- a/src/main/runtime/device-registry.ts +++ b/src/main/runtime/device-registry.ts @@ -3,14 +3,19 @@ // compromising one device doesn't expose others. The registry is a simple // JSON file with hardened permissions matching the runtime metadata pattern. import { randomBytes, randomUUID } from 'node:crypto' -import { existsSync, readFileSync } from 'node:fs' +import { existsSync, lstatSync, readFileSync, statSync } from 'node:fs' import { join } from 'node:path' -import { hardenExistingSecureFile, writeSecureJsonFile } from '../../shared/secure-file' +import { + assertSecureRegularFile, + hardenExistingSecureFile, + writeSecureJsonFile +} from '../../shared/secure-file' import type { DeviceScope } from '../../shared/runtime-types' import { DEVICE_REGISTRY_FILENAME } from './mobile-pairing-files' import type { RelayDeviceBinding } from './relay/relay-revoke-outbox' import type { MobilePairingConnectionMode } from '../../shared/mobile-pairing-connection-mode' import type { RuntimePairingReach } from '../../shared/runtime-pairing-reach' +import { parseOrdinaryDevices, parseStrictDevices } from './device-registry-record-validation' export type { DeviceScope } @@ -28,37 +33,23 @@ export type DeviceEntry = { pairingReach?: RuntimePairingReach } -function validRelayBinding(value: unknown, deviceId: string): RelayDeviceBinding | undefined { - if (!value || typeof value !== 'object') { - return undefined - } - const binding = value as Partial - return binding.relayDeviceId === deviceId && - typeof binding.relayHostId === 'string' && - typeof binding.ownerIdentityKey === 'string' - ? { - relayHostId: binding.relayHostId, - relayDeviceId: binding.relayDeviceId, - ownerIdentityKey: binding.ownerIdentityKey, - ...(typeof binding.inviteExpiresAt === 'number' && Number.isFinite(binding.inviteExpiresAt) - ? { inviteExpiresAt: binding.inviteExpiresAt } - : {}) - } - : undefined -} - // Why: a lastSeen refresh is pure bookkeeping, so coalesce reconnect bursts into one write instead of // paying a secure-file rewrite (two synchronous PowerShell ACL spawns on Windows) per connection. const LAST_SEEN_FLUSH_DELAY_MS = 250 +const MAX_DEVICE_REGISTRY_FILE_BYTES = 256 * 1024 + +type DeviceRegistryLoadOptions = { + strict?: boolean +} export class DeviceRegistry { private readonly registryPath: string private devices: DeviceEntry[] = [] private pendingLastSeenFlush: NodeJS.Timeout | null = null - constructor(userDataPath: string) { + constructor(userDataPath: string, options: DeviceRegistryLoadOptions = {}) { this.registryPath = join(userDataPath, DEVICE_REGISTRY_FILENAME) - this.load() + this.load(options.strict === true) } addDevice( @@ -202,6 +193,15 @@ export class DeviceRegistry { return this.devices } + /** Removes every local pairing only after the reset transaction reaches its cleanup phase. */ + removeAllForIdentityReset(): void { + if (this.devices.length === 0) { + return + } + this.save([]) + this.devices = [] + } + validateToken(token: string): DeviceEntry | null { return this.devices.find((d) => d.token === token) ?? null } @@ -273,27 +273,37 @@ export class DeviceRegistry { } } - private load(): void { - if (!existsSync(this.registryPath)) { + private load(strict: boolean): void { + if (strict) { + try { + lstatSync(this.registryPath) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + throw new Error('Device registry is missing') + } + throw new Error('Device registry is unavailable') + } + } else if (!existsSync(this.registryPath)) { this.devices = [] return } try { + if (strict) { + assertSecureRegularFile(this.registryPath, 'Device registry') + } hardenExistingSecureFile(this.registryPath) - const parsed = JSON.parse(readFileSync(this.registryPath, 'utf-8')) as DeviceEntry[] - this.devices = parsed.map((device) => ({ - ...device, - // Why: older registries only existed for phone pairing. Treat missing - // scope as mobile so legacy device tokens do not gain new CLI powers. - scope: device.scope === 'runtime' ? 'runtime' : 'mobile', - relayBinding: validRelayBinding(device.relayBinding, device.deviceId), - mobilePairingConnectionMode: - device.mobilePairingConnectionMode === 'local-only' ? 'local-only' : 'automatic', - // Why: registries written before this field existed only ever held network-reach grants (phones and - // LAN links), so a missing value must keep binding every interface on reconnect. - pairingReach: device.pairingReach === 'this-computer' ? 'this-computer' : 'network' - })) - } catch { + if (strict && statSync(this.registryPath).size > MAX_DEVICE_REGISTRY_FILE_BYTES) { + throw new Error('Device registry is too large') + } + const parsed: unknown = JSON.parse(readFileSync(this.registryPath, 'utf-8')) + this.devices = strict ? parseStrictDevices(parsed) : parseOrdinaryDevices(parsed) + } catch (error) { + if (strict) { + if (error instanceof Error && error.message.startsWith('Device registry')) { + throw error + } + throw new Error('Device registry is invalid') + } this.devices = [] } } @@ -304,3 +314,8 @@ export class DeviceRegistry { this.cancelPendingLastSeenFlush() } } + +/** Strict reset loader kept separate from ordinary fail-open startup loading. */ +export function loadDeviceRegistryForReset(userDataPath: string): DeviceRegistry { + return new DeviceRegistry(userDataPath, { strict: true }) +} diff --git a/src/main/runtime/e2ee-identity-reset-record.ts b/src/main/runtime/e2ee-identity-reset-record.ts new file mode 100644 index 00000000000..448aad2b11c --- /dev/null +++ b/src/main/runtime/e2ee-identity-reset-record.ts @@ -0,0 +1,292 @@ +import { randomUUID } from 'node:crypto' +import { lstatSync } from 'node:fs' +import { join } from 'node:path' +import { removeFileDurableSync } from '../../shared/durable-file-write' +import { assertSecureRegularFile, writeSecureJsonFileDurable } from '../../shared/secure-file' +import { + assertAuthorityId, + type TerminalAuthorityNamespace +} from '../../shared/terminal-session-authority-identity' +import { + assertCanonicalKeyB64, + assertTransactionId, + hasExactKeys, + isRecord, + readSecureRecord +} from './e2ee-keypair-record-validation' +import { E2EE_IDENTITY_RESET_FILENAME } from './mobile-pairing-files' + +export const E2EE_IDENTITY_RESET_VERSION = 1 +export const E2EE_IDENTITY_RESET_MAX_HOSTS = 256 +export const E2EE_IDENTITY_RESET_MAX_NAMESPACES = 4_096 + +export type E2EEIdentityResetPhase = + | 'retiring-hosts' + | 'revoking-relay' + | 'closing-transports' + | 'removing-local-credentials' + | 'creating-successor' + | 'successor-published' + | 'finalizing-successor' + | 're-enrollment' + +export type E2EEIdentityResetTarget = Readonly<{ + authorityHostId: string + namespaceIds: readonly string[] +}> + +export type E2EEIdentityResetRecord = Readonly<{ + v: typeof E2EE_IDENTITY_RESET_VERSION + transactionId: string + oldPublicKeyB64: string + requestedAt: number + phase: E2EEIdentityResetPhase + targets: readonly E2EEIdentityResetTarget[] +}> + +const PHASE_ORDER: readonly E2EEIdentityResetPhase[] = [ + 'retiring-hosts', + 'revoking-relay', + 'closing-transports', + 'removing-local-credentials', + 'creating-successor', + 'successor-published', + 'finalizing-successor', + 're-enrollment' +] +const MAX_RESET_FILE_BYTES = 256 * 1024 + +export class E2EEIdentityResetRecordStore { + private readonly path: string + + constructor(userDataPath: string) { + this.path = join(userDataPath, E2EE_IDENTITY_RESET_FILENAME) + } + + read(): E2EEIdentityResetRecord | null { + try { + lstatSync(this.path) + assertSecureRegularFile(this.path, 'E2EE identity reset transaction') + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return null + } + if (error instanceof Error && error.message.endsWith('is unavailable')) { + throw error + } + throw new Error('E2EE identity reset transaction is invalid') + } + return decodeResetRecord( + readSecureRecord(this.path, 'E2EE identity reset transaction', MAX_RESET_FILE_BYTES) + ) + } + + create( + oldPublicKeyB64: string, + targets: readonly E2EEIdentityResetTarget[], + requestedAt = Date.now() + ): E2EEIdentityResetRecord { + assertCanonicalKeyB64(oldPublicKeyB64, 'E2EE reset predecessor public key') + const record = makeRecord({ + transactionId: `identity-reset:${randomUUID()}`, + oldPublicKeyB64, + requestedAt, + phase: 'retiring-hosts', + targets + }) + this.write(record) + return record + } + + advance( + current: E2EEIdentityResetRecord, + phase: E2EEIdentityResetPhase + ): E2EEIdentityResetRecord { + const loaded = this.read() + if (!loaded || loaded.transactionId !== current.transactionId) { + throw new Error('E2EE identity reset transaction changed') + } + if (loaded.phase !== current.phase) { + throw new Error('E2EE identity reset transaction phase changed') + } + if (phaseIndex(phase) < phaseIndex(current.phase)) { + throw new Error('E2EE identity reset transaction phase regressed') + } + if (phaseIndex(phase) === phaseIndex(current.phase)) { + return loaded + } + const next = Object.freeze({ ...loaded, phase }) + this.write(next) + return next + } + + remove(current: E2EEIdentityResetRecord): void { + const loaded = this.read() + if (!loaded || loaded.transactionId !== current.transactionId) { + throw new Error('E2EE identity reset transaction changed') + } + if (loaded.phase !== 're-enrollment') { + throw new Error('E2EE identity reset transaction is not ready to remove') + } + removeFileDurableSync(this.path) + } + + get filePath(): string { + return this.path + } + + private write(record: E2EEIdentityResetRecord): void { + validateResetRecord(record) + writeSecureJsonFileDurable(this.path, record) + } +} + +export function phaseIndex(phase: E2EEIdentityResetPhase): number { + const index = PHASE_ORDER.indexOf(phase) + if (index < 0) { + throw new Error('E2EE identity reset phase is invalid') + } + return index +} + +export function validateResetRecord(value: unknown): asserts value is E2EEIdentityResetRecord { + if (!isRecord(value) || !hasExactKeys(value, ['v', 'transactionId', 'oldPublicKeyB64', 'requestedAt', 'phase', 'targets'])) { + throw new Error('E2EE identity reset transaction is invalid') + } + if (value.v !== E2EE_IDENTITY_RESET_VERSION) { + throw new Error('E2EE identity reset transaction version is invalid') + } + assertTransactionId(value.transactionId as string) + assertCanonicalKeyB64(value.oldPublicKeyB64, 'E2EE reset predecessor public key') + if ( + typeof value.requestedAt !== 'number' || + !Number.isSafeInteger(value.requestedAt) || + value.requestedAt < 0 + ) { + throw new Error('E2EE identity reset request time is invalid') + } + if (typeof value.phase !== 'string' || !PHASE_ORDER.includes(value.phase as E2EEIdentityResetPhase)) { + throw new Error('E2EE identity reset phase is invalid') + } + validateTargets(value.targets) +} + +export function resetTargetsFromNamespaces( + namespaces: readonly TerminalAuthorityNamespace[] +): readonly E2EEIdentityResetTarget[] { + const byHost = new Map>() + for (const namespace of namespaces) { + assertAuthorityId(namespace.authorityHostId, 'authorityHostId') + assertAuthorityId(namespace.namespaceId, 'namespaceId') + const hostNamespaces = byHost.get(namespace.authorityHostId) ?? new Set() + hostNamespaces.add(namespace.namespaceId) + byHost.set(namespace.authorityHostId, hostNamespaces) + } + const targets = [...byHost.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([authorityHostId, namespaceIds]) => ({ + authorityHostId, + namespaceIds: [...namespaceIds].sort() + })) + validateTargets(targets) + return Object.freeze(targets.map((target) => Object.freeze({ + authorityHostId: target.authorityHostId, + namespaceIds: Object.freeze([...target.namespaceIds]) + }))) +} + +/** Canonicalizes the bounded host inventory without creating a second transaction ledger. */ +export function resetTargetsFromAuthorityTargets( + targets: readonly E2EEIdentityResetTarget[] +): readonly E2EEIdentityResetTarget[] { + const byHost = new Map>() + for (const target of targets) { + assertAuthorityId(target.authorityHostId, 'authorityHostId') + const namespaceIds = byHost.get(target.authorityHostId) ?? new Set() + for (const namespaceId of target.namespaceIds) { + assertAuthorityId(namespaceId, 'namespaceId') + namespaceIds.add(namespaceId) + } + byHost.set(target.authorityHostId, namespaceIds) + } + const canonical = [...byHost.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([authorityHostId, namespaceIds]) => ({ + authorityHostId, + namespaceIds: [...namespaceIds].sort() + })) + validateTargets(canonical) + return Object.freeze( + canonical.map((target) => + Object.freeze({ + authorityHostId: target.authorityHostId, + namespaceIds: Object.freeze([...target.namespaceIds]) + }) + ) + ) +} + +function makeRecord(input: { + transactionId: string + oldPublicKeyB64: string + requestedAt: number + phase: E2EEIdentityResetPhase + targets: readonly E2EEIdentityResetTarget[] +}): E2EEIdentityResetRecord { + const record = Object.freeze({ + v: E2EE_IDENTITY_RESET_VERSION, + transactionId: input.transactionId, + oldPublicKeyB64: input.oldPublicKeyB64, + requestedAt: input.requestedAt, + phase: input.phase, + targets: input.targets.map((target) => + Object.freeze({ + authorityHostId: target.authorityHostId, + namespaceIds: Object.freeze([...target.namespaceIds]) + }) + ) + }) + validateResetRecord(record) + return record +} + +function decodeResetRecord(raw: Record): E2EEIdentityResetRecord { + validateResetRecord(raw) + return makeRecord(raw) +} + +function validateTargets(value: unknown): asserts value is readonly E2EEIdentityResetTarget[] { + if (!Array.isArray(value) || value.length > E2EE_IDENTITY_RESET_MAX_HOSTS) { + throw new Error('E2EE identity reset targets are invalid') + } + const hosts = new Set() + let namespaceCount = 0 + for (const target of value) { + if (!isRecord(target) || !hasExactKeys(target, ['authorityHostId', 'namespaceIds'])) { + throw new Error('E2EE identity reset targets are invalid') + } + assertAuthorityId(target.authorityHostId, 'authorityHostId') + if (hosts.has(target.authorityHostId)) { + throw new Error('E2EE identity reset targets are invalid') + } + hosts.add(target.authorityHostId) + if ( + !Array.isArray(target.namespaceIds) || + target.namespaceIds.length > E2EE_IDENTITY_RESET_MAX_NAMESPACES + ) { + throw new Error('E2EE identity reset targets are invalid') + } + const namespaces = new Set() + for (const namespaceId of target.namespaceIds) { + assertAuthorityId(namespaceId, 'namespaceId') + if (namespaces.has(namespaceId)) { + throw new Error('E2EE identity reset targets are invalid') + } + namespaces.add(namespaceId) + namespaceCount += 1 + } + } + if (namespaceCount > E2EE_IDENTITY_RESET_MAX_NAMESPACES) { + throw new Error('E2EE identity reset targets are invalid') + } +} diff --git a/src/main/runtime/e2ee-identity-reset.test.ts b/src/main/runtime/e2ee-identity-reset.test.ts new file mode 100644 index 00000000000..ccbc8cbd476 --- /dev/null +++ b/src/main/runtime/e2ee-identity-reset.test.ts @@ -0,0 +1,241 @@ +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 { RelayDeviceBinding } from './relay/relay-revoke-outbox' +import { RelayRevokeOutbox } from './relay/relay-revoke-outbox' +import { loadOrCreateE2EEKeypair, type E2EEKeypair } from './e2ee-keypair' +import { + E2EEIdentityResetCoordinator, + type E2EEIdentityResetAuthorityTarget, + type E2EEIdentityResetDependencies +} from './e2ee-identity-reset' +import type { TerminalAuthorityConsumerRetirementResult } from '../../shared/terminal-session-authority-consumer-retirement' + +const temporaryDirectories: string[] = [] + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +describe('E2EEIdentityResetCoordinator', () => { + it('retires hosts, acknowledges Relay revocation, then publishes a successor', async () => { + const userDataPath = createTemporaryDirectory() + let current = loadOrCreateE2EEKeypair(userDataPath) + const outbox = new RelayRevokeOutbox(userDataPath) + const events: string[] = [] + const binding = relayBinding('relay-device') + const authority = authorityTarget('host-a', ['namespace-a'], events) + const dependencies = createDependencies({ + userDataPath, + current: () => current, + authorities: () => [authority], + bindings: () => [binding], + outbox, + events, + onSuccessorPublished: (keypair) => { + current = keypair + events.push('successor-published') + } + }) + const coordinator = new E2EEIdentityResetCoordinator(dependencies) + + const result = await coordinator.run() + + expect(result.phase).toBe('re-enrollment') + expect(result.successorPublicKeyB64).not.toBe(result.transactionId) + expect(current.publicKeyB64).toBe(result.successorPublicKeyB64) + expect(coordinator.status()).toEqual({ inProgress: false, record: null }) + expect(events).toEqual([ + 'freeze', + 'retire:host-a:namespace-a', + 'relay-revoke', + 'relay-ack', + 'close-transports', + 'remove-credentials', + 'successor-published', + 're-enrollment', + 'unfreeze' + ]) + expect(outbox.listPending()).toEqual([]) + }) + + it('keeps the predecessor and durable phase when a host is unreachable', async () => { + const userDataPath = createTemporaryDirectory() + const predecessor = loadOrCreateE2EEKeypair(userDataPath) + const requestIds: string[] = [] + let attempt = 0 + const authority = authorityTarget( + 'host-offline', + ['namespace-a'], + [], + async (_namespaceId, request) => { + requestIds.push(request.requestId) + attempt += 1 + throw new Error('host unreachable') + } + ) + const coordinator = new E2EEIdentityResetCoordinator( + createDependencies({ + userDataPath, + current: () => predecessor, + authorities: () => [authority] + }) + ) + + await expect(coordinator.run()).rejects.toThrow('host unreachable') + await expect(coordinator.run()).rejects.toThrow('host unreachable') + + expect(attempt).toBe(2) + expect(requestIds[0]).toBe(requestIds[1]) + expect(coordinator.status().record?.phase).toBe('retiring-hosts') + expect(loadOrCreateE2EEKeypair(userDataPath).publicKeyB64).toBe(predecessor.publicKeyB64) + }) + + it('does not rotate when Relay acknowledgement fails', async () => { + const userDataPath = createTemporaryDirectory() + const predecessor = loadOrCreateE2EEKeypair(userDataPath) + const outbox = new RelayRevokeOutbox(userDataPath) + const coordinator = new E2EEIdentityResetCoordinator( + createDependencies({ + userDataPath, + current: () => predecessor, + outbox, + bindings: () => [relayBinding('relay-device')], + awaitRelayRevocations: async () => { + throw new Error('relay unavailable') + } + }) + ) + + await expect(coordinator.run()).rejects.toThrow('relay unavailable') + + expect(coordinator.status().record?.phase).toBe('revoking-relay') + expect(outbox.listPending()).toHaveLength(1) + expect(loadOrCreateE2EEKeypair(userDataPath).publicKeyB64).toBe(predecessor.publicKeyB64) + }) + + it('resumes after a crash between successor publication and acknowledgement', async () => { + const userDataPath = createTemporaryDirectory() + let current = loadOrCreateE2EEKeypair(userDataPath) + let crash = true + const coordinator = new E2EEIdentityResetCoordinator( + createDependencies({ + userDataPath, + current: () => current, + onSuccessorPublished: (keypair) => { + current = keypair + if (crash) { + crash = false + throw new Error('crash cut') + } + } + }) + ) + + await expect(coordinator.run()).rejects.toThrow('crash cut') + expect(coordinator.status().record?.phase).toBe('successor-published') + const successorPublicKeyB64 = loadOrCreateE2EEKeypair(userDataPath).publicKeyB64 + + const result = await coordinator.run() + + expect(result.successorPublicKeyB64).toBe(successorPublicKeyB64) + expect(coordinator.status().record).toBeNull() + }) +}) + +function createTemporaryDirectory(): string { + const directory = mkdtempSync(join(tmpdir(), 'orca-e2ee-reset-')) + temporaryDirectories.push(directory) + return directory +} + +function relayBinding(relayDeviceId: string): RelayDeviceBinding { + return { + relayHostId: 'relay-host', + relayDeviceId, + ownerIdentityKey: 'owner-identity' + } +} + +function authorityTarget( + authorityHostId: string, + namespaceIds: readonly string[], + events: string[], + retireNamespace?: E2EEIdentityResetAuthorityTarget['retireNamespace'] +): E2EEIdentityResetAuthorityTarget { + return { + target: { authorityHostId, namespaceIds }, + retireNamespace: + retireNamespace ?? + (async (namespaceId, request) => { + events.push(`retire:${authorityHostId}:${namespaceId}`) + return retirementResult(request) + }) + } +} + +function createDependencies(options: { + userDataPath: string + current: () => E2EEKeypair | null + authorities?: () => readonly E2EEIdentityResetAuthorityTarget[] + bindings?: () => readonly RelayDeviceBinding[] + outbox?: RelayRevokeOutbox + events?: string[] + onSuccessorPublished?: (keypair: E2EEKeypair) => void | Promise + awaitRelayRevocations?: E2EEIdentityResetDependencies['awaitRelayRevocations'] +}): E2EEIdentityResetDependencies { + const events = options.events ?? [] + const outbox = options.outbox ?? new RelayRevokeOutbox(options.userDataPath) + return { + userDataPath: options.userDataPath, + currentKeypair: options.current, + listAuthorityTargets: options.authorities ?? (() => []), + freezeAuthorityAdmissions: () => events.push('freeze'), + unfreezeAuthorityAdmissions: () => events.push('unfreeze'), + closeLiveTransports: async () => { + events.push('close-transports') + }, + listRelayBindings: options.bindings ?? (() => []), + enqueueRelayRevoke: (binding) => { + events.push('relay-revoke') + return outbox.enqueue(binding) + }, + awaitRelayRevocations: + options.awaitRelayRevocations ?? + (async (items) => { + events.push('relay-ack') + for (const item of items) { + outbox.remove(item.reqId) + } + }), + removeLocalCredentials: () => { + events.push('remove-credentials') + }, + onSuccessorPublished: options.onSuccessorPublished ?? (() => {}), + onReEnrollment: () => { + events.push('re-enrollment') + } + } +} + +function retirementResult( + request: Parameters[1] +): TerminalAuthorityConsumerRetirementResult { + return { + version: 1, + namespace: request.namespace, + consumerId: 'consumer-id', + retiredConsumerIncarnationId: null, + requestId: request.requestId, + candidateProcessIncarnationId: request.candidateProcessIncarnationId, + candidateSessionNonce: request.candidateSessionNonce, + connectionGrantId: 'grant-id', + retirementCas: 'retirement-cas', + retired: true, + alreadyAbsent: true, + replayed: false + } +} diff --git a/src/main/runtime/e2ee-identity-reset.ts b/src/main/runtime/e2ee-identity-reset.ts new file mode 100644 index 00000000000..97deff7e740 --- /dev/null +++ b/src/main/runtime/e2ee-identity-reset.ts @@ -0,0 +1,289 @@ +import { randomBytes } from 'node:crypto' +import type { RelayDeviceBinding, RelayRevokeOutboxItem } from './relay/relay-revoke-outbox' +import type { E2EEKeypair, E2EEKeypairResetTransaction } from './e2ee-keypair' +import { + finalizeE2EEKeypairResetSuccessor, + loadE2EEKeypair, + isE2EEKeypairResetResidueAbsent, + publishE2EEKeypairResetSuccessor, + removeE2EEKeypairResetResidue, + stageE2EEKeypairResetSuccessor +} from './e2ee-keypair' +import { + E2EEIdentityResetRecordStore, + phaseIndex, + resetTargetsFromNamespaces, + resetTargetsFromAuthorityTargets, + type E2EEIdentityResetPhase, + type E2EEIdentityResetRecord, + type E2EEIdentityResetTarget +} from './e2ee-identity-reset-record' +import type { TerminalAuthorityConsumerRetirementResult } from '../../shared/terminal-session-authority-consumer-retirement' +import type { TerminalAuthorityAppConsumerRetirementRequest } from '../session-authority/terminal-authority-app-outcome-host-contract' + +export type E2EEIdentityResetAuthorityTarget = Readonly<{ + target: E2EEIdentityResetTarget + retireNamespace( + namespaceId: string, + request: TerminalAuthorityAppConsumerRetirementRequest + ): Promise +}> + +export type E2EEIdentityResetDependencies = Readonly<{ + userDataPath: string + currentKeypair(): E2EEKeypair | null + listAuthorityTargets(): readonly E2EEIdentityResetAuthorityTarget[] + freezeAuthorityAdmissions(): void + unfreezeAuthorityAdmissions(): void + closeLiveTransports(): Promise + listRelayBindings(): readonly RelayDeviceBinding[] + enqueueRelayRevoke(binding: RelayDeviceBinding): RelayRevokeOutboxItem + awaitRelayRevocations(items: readonly RelayRevokeOutboxItem[]): Promise + removeLocalCredentials(): void | Promise + onSuccessorPublished(keypair: E2EEKeypair): void | Promise + onReEnrollment(): void | Promise + processIncarnationId?: string + getProcessIncarnationId?: () => string | null + createSessionNonce?: () => string +}> + +export type E2EEIdentityResetStatus = Readonly<{ + inProgress: boolean + record: E2EEIdentityResetRecord | null +}> + +export type E2EEIdentityResetResult = Readonly<{ + transactionId: string + successorPublicKeyB64: string + phase: 're-enrollment' +}> + +export class E2EEIdentityResetCoordinator { + private readonly store: E2EEIdentityResetRecordStore + private readonly processIncarnationId: string + private readonly sessionNonce: string + private inFlight: Promise | null = null + private admissionsFrozen = false + + constructor(private readonly dependencies: E2EEIdentityResetDependencies) { + this.store = new E2EEIdentityResetRecordStore(dependencies.userDataPath) + this.processIncarnationId = + dependencies.processIncarnationId ?? `app-process:${randomBytes(16).toString('hex')}` + this.sessionNonce = + dependencies.createSessionNonce?.() ?? `app-session:${randomBytes(16).toString('hex')}` + } + + status(): E2EEIdentityResetStatus { + const record = this.store.read() + return Object.freeze({ inProgress: this.admissionsFrozen || record !== null, record }) + } + + hasPendingRecord(): boolean { + return this.store.read() !== null + } + + run(): Promise { + this.inFlight ??= this.runExclusive().finally(() => { + this.inFlight = null + }) + return this.inFlight + } + + private async runExclusive(): Promise { + let record = this.store.read() + this.freezeOnce() + if (!record) { + const current = this.dependencies.currentKeypair() + if (!current) { + throw new Error('E2EE identity reset requires an established identity') + } + const authorityTargets = this.dependencies.listAuthorityTargets() + const namespaces = authorityTargets.flatMap((entry) => + entry.target.namespaceIds.map((namespaceId) => ({ + authorityHostId: entry.target.authorityHostId, + namespaceId + })) + ) + record = this.store.create(current.publicKeyB64, resetTargetsFromNamespaces(namespaces)) + } + + if (record.phase === 'retiring-hosts') { + this.assertTargetShape(record) + await this.retireHosts(record) + record = this.advance(record, 'revoking-relay') + } + if (record.phase === 'revoking-relay') { + await this.revokeRelayBindings() + record = this.advance(record, 'closing-transports') + } + if (record.phase === 'closing-transports') { + await this.dependencies.closeLiveTransports() + record = this.advance(record, 'removing-local-credentials') + } + if (record.phase === 'removing-local-credentials') { + await this.dependencies.removeLocalCredentials() + record = this.advance(record, 'creating-successor') + } + + const creating: E2EEKeypairResetTransaction = { + transactionId: record.transactionId, + oldPublicKeyB64: record.oldPublicKeyB64, + phase: 'creating-successor' + } + let successor: E2EEKeypair + if (record.phase === 'creating-successor') { + stageE2EEKeypairResetSuccessor(this.dependencies.userDataPath, creating) + successor = publishE2EEKeypairResetSuccessor(this.dependencies.userDataPath, creating) + record = this.advance(record, 'successor-published') + } else { + successor = loadE2EEKeypair(this.dependencies.userDataPath) + } + if (record.phase === 'successor-published') { + await this.dependencies.onSuccessorPublished(successor) + const loadedSuccessor = loadE2EEKeypair(this.dependencies.userDataPath) + if (loadedSuccessor.publicKeyB64 !== successor.publicKeyB64) { + throw new Error('E2EE reset successor failed strict-loader verification') + } + record = this.advance(record, 'finalizing-successor') + } + const finalizing: E2EEKeypairResetTransaction = { + ...creating, + phase: 'finalizing-successor' + } + let finalized = successor + if (record.phase === 'finalizing-successor') { + try { + finalized = finalizeE2EEKeypairResetSuccessor(this.dependencies.userDataPath, finalizing) + removeE2EEKeypairResetResidue(this.dependencies.userDataPath, finalizing) + } catch (error) { + if (!isE2EEKeypairResetResidueAbsent(this.dependencies.userDataPath, finalizing)) { + throw error + } + finalized = loadE2EEKeypair(this.dependencies.userDataPath) + } + await this.dependencies.onReEnrollment() + record = this.advance(record, 're-enrollment') + } + this.store.remove(record) + this.dependencies.unfreezeAuthorityAdmissions() + this.admissionsFrozen = false + return Object.freeze({ + transactionId: record.transactionId, + successorPublicKeyB64: finalized.publicKeyB64, + phase: 're-enrollment' + }) + } + + private freezeOnce(): void { + if (this.admissionsFrozen) { + return + } + this.dependencies.freezeAuthorityAdmissions() + this.admissionsFrozen = true + } + + private async retireHosts(record: E2EEIdentityResetRecord): Promise { + if (record.phase !== 'retiring-hosts') { + return + } + const targets = this.dependencies.listAuthorityTargets() + const byHost = new Map(targets.map((entry) => [entry.target.authorityHostId, entry])) + const processIncarnationId = + this.dependencies.getProcessIncarnationId?.() ?? this.processIncarnationId + for (const target of record.targets) { + const authority = byHost.get(target.authorityHostId) + if (!authority) { + throw new Error(`E2EE identity reset host is unresolved: ${target.authorityHostId}`) + } + const namespaces = new Set(authority.target.namespaceIds) + for (const namespaceId of target.namespaceIds) { + if (!namespaces.has(namespaceId)) { + throw new Error(`E2EE identity reset namespace is unresolved: ${namespaceId}`) + } + const result = await authority.retireNamespace(namespaceId, { + namespace: Object.freeze({ + authorityHostId: target.authorityHostId, + namespaceId + }), + candidateProcessIncarnationId: processIncarnationId, + candidateSessionNonce: this.sessionNonce, + requestId: this.retirementRequestId( + record.transactionId, + target.authorityHostId, + namespaceId + ) + }) + if ( + result.retired !== true || + result.namespace.authorityHostId !== target.authorityHostId || + result.namespace.namespaceId !== namespaceId || + result.candidateProcessIncarnationId !== processIncarnationId || + result.candidateSessionNonce !== this.sessionNonce || + result.requestId !== + this.retirementRequestId(record.transactionId, target.authorityHostId, namespaceId) + ) { + throw new Error( + `E2EE identity reset retirement acknowledgement is invalid: ${namespaceId}` + ) + } + } + } + } + + private async revokeRelayBindings(): Promise { + const bindings = this.dependencies.listRelayBindings() + const unique = new Map() + for (const binding of bindings) { + unique.set( + `${binding.relayHostId}\0${binding.relayDeviceId}\0${binding.ownerIdentityKey}`, + binding + ) + } + const items = [...unique.values()].map((binding) => + this.dependencies.enqueueRelayRevoke(binding) + ) + await this.dependencies.awaitRelayRevocations(items) + } + + private advance( + record: E2EEIdentityResetRecord, + phase: E2EEIdentityResetPhase + ): E2EEIdentityResetRecord { + if (phaseIndex(record.phase) >= phaseIndex(phase)) { + return record + } + return this.store.advance(record, phase) + } + + private assertTargetShape(record: E2EEIdentityResetRecord): void { + const targets = this.dependencies.listAuthorityTargets() + const available = new Set( + resetTargetsFromAuthorityTargets(targets.map((entry) => entry.target)).flatMap((target) => [ + `${target.authorityHostId}\0`, + ...target.namespaceIds.map((namespaceId) => `${target.authorityHostId}\0${namespaceId}`) + ]) + ) + for (const target of record.targets) { + if (!available.has(`${target.authorityHostId}\0`)) { + throw new Error(`E2EE identity reset host is unresolved: ${target.authorityHostId}`) + } + for (const namespaceId of target.namespaceIds) { + if (!available.has(`${target.authorityHostId}\0${namespaceId}`)) { + throw new Error(`E2EE identity reset target is no longer known: ${namespaceId}`) + } + } + } + } + + private retirementRequestId( + transactionId: string, + authorityHostId: string, + namespaceId: string + ): string { + return `identity-reset:${transactionId}:${encodePart(authorityHostId)}:${encodePart(namespaceId)}` + } +} + +function encodePart(value: string): string { + return Buffer.from(value, 'utf8').toString('base64url') +} diff --git a/src/main/runtime/e2ee-keypair-backup-lineage.ts b/src/main/runtime/e2ee-keypair-backup-lineage.ts new file mode 100644 index 00000000000..605075b8129 --- /dev/null +++ b/src/main/runtime/e2ee-keypair-backup-lineage.ts @@ -0,0 +1,88 @@ +import type { IdentityMarker } from './e2ee-keypair-identity-marker' +import type { + E2EEIdentityStorageInspection, + ValidatedKeypair, + ValidatedStage +} from './e2ee-keypair-storage' + +export function assertReplacementBackupLineage( + backup: ValidatedKeypair, + active: ValidatedKeypair | null, + marker: IdentityMarker | null, + stage: ValidatedStage | null +): void { + if (!marker || !stage || stage.installationId !== marker.installationId) { + throw new Error('E2EE keypair backup does not match the active lifecycle') + } + + if (stage.purpose === 'first-install') { + if (!sameKeyMaterial(backup, stage)) { + throw new Error('E2EE keypair backup does not match the active lifecycle') + } + if (backup.schema === 'current' && backup.installationId !== marker.installationId) { + throw new Error('E2EE keypair backup does not match the active lifecycle') + } + if (active && !sameKeyMaterial(active, stage)) { + throw new Error('E2EE keypair backup does not match the active lifecycle') + } + return + } + + if ( + backup.schema !== 'current' || + backup.installationId !== marker.installationId || + backup.publicKeyB64 !== stage.predecessorPublicKeyB64 || + stage.publicKeyB64 === backup.publicKeyB64 + ) { + throw new Error('E2EE keypair backup does not match the reset predecessor') + } + if (active && active.schema !== 'current') { + throw new Error('E2EE keypair backup does not match the active lifecycle') + } + if (active && !sameKeyMaterial(active, stage) && active.publicKeyB64 !== backup.publicKeyB64) { + throw new Error('E2EE keypair backup does not match the active lifecycle') + } +} + +/** Check reset lineage before and after the active path contains the successor. */ +export function requireResetStageLineage( + inspection: E2EEIdentityStorageInspection, + options: { requirePublishedBackup?: boolean } = {} +): void { + const { active, stage, marker, replacementBackup } = inspection + if (!active || active.schema !== 'current' || !active.installationId) { + throw new Error('E2EE keypair is not a current-schema record') + } + if (!marker || marker.installationId !== active.installationId) { + throw new Error('E2EE identity marker does not match the keypair') + } + if ( + !stage || + stage.purpose !== 'reset' || + !stage.transactionId || + !stage.predecessorPublicKeyB64 || + stage.publicKeyB64 === stage.predecessorPublicKeyB64 || + stage.installationId !== active.installationId + ) { + throw new Error('E2EE reset stage does not preserve its original predecessor binding') + } + if (replacementBackup) { + assertReplacementBackupLineage(replacementBackup, active, marker, stage) + } + if (sameKeyMaterial(active, stage)) { + if (options.requirePublishedBackup !== false && !replacementBackup) { + throw new Error('E2EE published reset stage is missing its predecessor binding') + } + return + } + if (active.publicKeyB64 !== stage.predecessorPublicKeyB64) { + throw new Error('E2EE reset stage does not belong to the active predecessor') + } +} + +export function sameKeyMaterial(left: ValidatedKeypair, right: ValidatedKeypair): boolean { + return ( + Buffer.from(left.secretKey).equals(Buffer.from(right.secretKey)) && + Buffer.from(left.publicKey).equals(Buffer.from(right.publicKey)) + ) +} diff --git a/src/main/runtime/e2ee-keypair-identity-marker.ts b/src/main/runtime/e2ee-keypair-identity-marker.ts new file mode 100644 index 00000000000..d7d937efa06 --- /dev/null +++ b/src/main/runtime/e2ee-keypair-identity-marker.ts @@ -0,0 +1,37 @@ +import { writeSecureJsonFileDurable } from '../../shared/secure-file' +import { + IDENTITY_MARKER_VERSION, + MAX_IDENTITY_MARKER_FILE_BYTES, + assertInstallationId, + hasExactKeys, + isInstallationId, + readSecureRecord +} from './e2ee-keypair-record-validation' + +export type IdentityMarker = { + v: number + installationId: string +} + +export function readIdentityMarker(markerPath: string): IdentityMarker { + const raw = readSecureRecord(markerPath, 'E2EE identity marker', MAX_IDENTITY_MARKER_FILE_BYTES) + if (Object.hasOwn(raw, 'consumerId') && !Object.hasOwn(raw, 'installationId')) { + throw new Error('E2EE identity marker does not match the keypair') + } + if ( + raw.v !== IDENTITY_MARKER_VERSION || + !hasExactKeys(raw, ['v', 'installationId']) || + !isInstallationId(raw.installationId) + ) { + throw new Error('E2EE identity marker is invalid') + } + return { v: IDENTITY_MARKER_VERSION, installationId: raw.installationId } +} + +export function writeIdentityMarker(markerPath: string, installationId: string): void { + assertInstallationId(installationId) + writeSecureJsonFileDurable(markerPath, { + v: IDENTITY_MARKER_VERSION, + installationId + } satisfies IdentityMarker) +} diff --git a/src/main/runtime/e2ee-keypair-lifecycle-regression.test.ts b/src/main/runtime/e2ee-keypair-lifecycle-regression.test.ts new file mode 100644 index 00000000000..ddefbee5d88 --- /dev/null +++ b/src/main/runtime/e2ee-keypair-lifecycle-regression.test.ts @@ -0,0 +1,435 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + unlinkSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import nacl from 'tweetnacl' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + E2EE_IDENTITY_MARKER_FILENAME, + E2EE_KEYPAIR_BACKUP_FILENAME, + E2EE_KEYPAIR_FILENAME, + E2EE_KEYPAIR_STAGE_FILENAME, + DEVICE_REGISTRY_FILENAME, + RELAY_REVOKE_OUTBOX_FILENAME +} from './mobile-pairing-files' +import { + finalizeE2EEKeypairResetSuccessor, + loadE2EEKeypair, + loadOrCreateE2EEKeypair, + publishE2EEKeypairResetSuccessor, + stageE2EEKeypairResetSuccessor, + validateE2EEIdentityStorage +} from './e2ee-keypair' +import { migrateMobilePairingUserdata } from './mobile-pairing-userdata-migration' + +describe('E2EE lifecycle regression boundaries', () => { + let userDataPath: string + + beforeEach(() => { + userDataPath = mkdtempSync(join(tmpdir(), 'orca-e2ee-regression-')) + }) + + afterEach(() => { + rmSync(userDataPath, { recursive: true, force: true }) + }) + + it('accepts only an exact legacy migration stage beside a marker', () => { + const legacy = nacl.box.keyPair() + const installationId = 'marker-before-v2-stage-0001' + writeFileSync( + join(userDataPath, E2EE_KEYPAIR_FILENAME), + JSON.stringify({ + v: 1, + publicKeyB64: Buffer.from(legacy.publicKey).toString('base64'), + secretKeyB64: Buffer.from(legacy.secretKey).toString('base64') + }) + ) + writeFileSync( + join(userDataPath, E2EE_KEYPAIR_STAGE_FILENAME), + JSON.stringify({ + v: 2, + publicKeyB64: Buffer.from(legacy.publicKey).toString('base64'), + secretKeyB64: Buffer.from(legacy.secretKey).toString('base64'), + installationId, + purpose: 'first-install' + }) + ) + writeFileSync( + join(userDataPath, E2EE_IDENTITY_MARKER_FILENAME), + JSON.stringify({ v: 1, installationId }) + ) + + expect(() => validateE2EEIdentityStorage(userDataPath)).not.toThrow() + expect(loadE2EEKeypair(userDataPath).publicKeyB64).toBe( + Buffer.from(legacy.publicKey).toString('base64') + ) + }) + + it('accepts a markerless legacy migration stage before marker publication', () => { + const legacy = nacl.box.keyPair() + const installationId = 'markerless-v2-stage-0001' + writeFileSync( + join(userDataPath, E2EE_KEYPAIR_FILENAME), + JSON.stringify({ + v: 1, + publicKeyB64: Buffer.from(legacy.publicKey).toString('base64'), + secretKeyB64: Buffer.from(legacy.secretKey).toString('base64') + }) + ) + writeFileSync( + join(userDataPath, E2EE_KEYPAIR_STAGE_FILENAME), + JSON.stringify({ + v: 2, + publicKeyB64: Buffer.from(legacy.publicKey).toString('base64'), + secretKeyB64: Buffer.from(legacy.secretKey).toString('base64'), + installationId, + purpose: 'first-install' + }) + ) + + expect(() => validateE2EEIdentityStorage(userDataPath)).not.toThrow() + expect(loadE2EEKeypair(userDataPath).publicKeyB64).toBe( + Buffer.from(legacy.publicKey).toString('base64') + ) + }) + + it('fails closed when a missing active has no exact staged rollback lineage', () => { + const first = loadOrCreateE2EEKeypair(userDataPath) + unlinkSync(join(userDataPath, E2EE_KEYPAIR_FILENAME)) + const replacement = nacl.box.keyPair() + writeFileSync( + join(userDataPath, E2EE_KEYPAIR_BACKUP_FILENAME), + JSON.stringify({ + v: 2, + publicKeyB64: Buffer.from(replacement.publicKey).toString('base64'), + secretKeyB64: Buffer.from(replacement.secretKey).toString('base64'), + installationId: first.installationId + }) + ) + + expect(() => loadOrCreateE2EEKeypair(userDataPath)).toThrow('backup does not match') + expect(existsSync(join(userDataPath, E2EE_KEYPAIR_FILENAME))).toBe(false) + }) + + it('rejects a published same-material successor with a substituted predecessor at every boundary', () => { + const first = loadOrCreateE2EEKeypair(userDataPath) + const successor = nacl.box.keyPair() + const substituted = nacl.box.keyPair() + const transaction = { + transactionId: 'reset-transaction-published-binding-0001', + oldPublicKeyB64: Buffer.from(substituted.publicKey).toString('base64'), + phase: 'creating-successor' + } as const + writeFileSync( + join(userDataPath, E2EE_KEYPAIR_FILENAME), + JSON.stringify({ + v: 2, + publicKeyB64: Buffer.from(successor.publicKey).toString('base64'), + secretKeyB64: Buffer.from(successor.secretKey).toString('base64'), + installationId: first.installationId + }) + ) + writeFileSync( + join(userDataPath, E2EE_KEYPAIR_BACKUP_FILENAME), + JSON.stringify({ + v: 2, + publicKeyB64: first.publicKeyB64, + secretKeyB64: Buffer.from(first.secretKey).toString('base64'), + installationId: first.installationId + }) + ) + writeFileSync( + join(userDataPath, E2EE_KEYPAIR_STAGE_FILENAME), + JSON.stringify({ + v: 2, + publicKeyB64: Buffer.from(successor.publicKey).toString('base64'), + secretKeyB64: Buffer.from(successor.secretKey).toString('base64'), + installationId: first.installationId, + purpose: 'reset', + transactionId: transaction.transactionId, + predecessorPublicKeyB64: transaction.oldPublicKeyB64 + }) + ) + writeFileSync( + join(userDataPath, E2EE_IDENTITY_MARKER_FILENAME), + JSON.stringify({ v: 1, installationId: first.installationId }) + ) + + expect(() => validateE2EEIdentityStorage(userDataPath)).toThrow('reset predecessor') + expect(() => loadE2EEKeypair(userDataPath)).toThrow() + expect(() => stageE2EEKeypairResetSuccessor(userDataPath, transaction)).toThrow() + expect(() => publishE2EEKeypairResetSuccessor(userDataPath, transaction)).toThrow() + expect(() => + finalizeE2EEKeypairResetSuccessor(userDataPath, { + ...transaction, + phase: 'successor-published' + }) + ).toThrow() + }) + + it('reconciles equivalent identity JSON while preserving an established target', () => { + const source = join(userDataPath, 'source') + const target = join(userDataPath, 'target') + mkdirSync(source) + mkdirSync(target) + const active = nacl.box.keyPair() + const successor = nacl.box.keyPair() + const installationId = 'migration-equivalent-lineage-0001' + const predecessorPublicKeyB64 = Buffer.from(active.publicKey).toString('base64') + const successorPublicKeyB64 = Buffer.from(successor.publicKey).toString('base64') + const registry = JSON.stringify([ + { + deviceId: 'phone', + name: 'Phone', + token: 'token', + scope: 'mobile', + pairedAt: 1, + lastSeenAt: 2 + } + ]) + const outbox = JSON.stringify([ + { + relayHostId: 'relay-host', + relayDeviceId: 'phone', + ownerIdentityKey: 'owner', + reqId: 'revoke-equivalent-1', + createdAt: 3 + } + ]) + const stage = { + v: 2 as const, + publicKeyB64: successorPublicKeyB64, + secretKeyB64: Buffer.from(successor.secretKey).toString('base64'), + installationId, + purpose: 'reset' as const, + transactionId: 'migration-equivalent-reset-0001', + predecessorPublicKeyB64 + } + writeCurrentLifecycle(source, active, installationId, stage) + writeFileSync(join(source, DEVICE_REGISTRY_FILENAME), registry) + writeFileSync(join(source, RELAY_REVOKE_OUTBOX_FILENAME), outbox) + writeFileSync( + join(target, E2EE_KEYPAIR_FILENAME), + ` {\n "installationId": "${installationId}",\n "secretKeyB64": "${Buffer.from(active.secretKey).toString('base64')}",\n "publicKeyB64": "${Buffer.from(active.publicKey).toString('base64')}",\n "v": 2\n}\n` + ) + writeFileSync( + join(target, E2EE_IDENTITY_MARKER_FILENAME), + ` { "installationId": "${installationId}", "v": 1 } ` + ) + writeFileSync(join(target, E2EE_KEYPAIR_STAGE_FILENAME), JSON.stringify(stage, null, 2)) + writeFileSync(join(target, DEVICE_REGISTRY_FILENAME), registry) + + const targetActive = readFileSync(join(target, E2EE_KEYPAIR_FILENAME), 'utf8') + const targetMarker = readFileSync(join(target, E2EE_IDENTITY_MARKER_FILENAME), 'utf8') + const targetStage = readFileSync(join(target, E2EE_KEYPAIR_STAGE_FILENAME), 'utf8') + migrateMobilePairingUserdata(source, target) + + expect(readFileSync(join(target, E2EE_KEYPAIR_FILENAME), 'utf8')).toBe(targetActive) + expect(readFileSync(join(target, E2EE_IDENTITY_MARKER_FILENAME), 'utf8')).toBe(targetMarker) + expect(readFileSync(join(target, E2EE_KEYPAIR_STAGE_FILENAME), 'utf8')).toBe(targetStage) + expect(readFileSync(join(target, RELAY_REVOKE_OUTBOX_FILENAME), 'utf8')).toBe(outbox) + }) + + it('retains established registry and outbox when the source copy has mutable drift', () => { + const source = join(userDataPath, 'source') + const target = join(userDataPath, 'target') + mkdirSync(source) + mkdirSync(target) + const active = nacl.box.keyPair() + const successor = nacl.box.keyPair() + const installationId = 'migration-mutable-drift-0001' + const stage = { + v: 2 as const, + publicKeyB64: Buffer.from(successor.publicKey).toString('base64'), + secretKeyB64: Buffer.from(successor.secretKey).toString('base64'), + installationId, + purpose: 'reset' as const, + transactionId: 'migration-mutable-reset-0001', + predecessorPublicKeyB64: Buffer.from(active.publicKey).toString('base64') + } + const targetRegistry = JSON.stringify([ + { + deviceId: 'target-phone', + name: 'Target', + token: 'target-token', + scope: 'mobile', + pairedAt: 1, + lastSeenAt: 2 + } + ]) + const sourceRegistry = JSON.stringify([ + { + deviceId: 'source-phone', + name: 'Source', + token: 'source-token', + scope: 'mobile', + pairedAt: 3, + lastSeenAt: 4 + } + ]) + const targetOutbox = JSON.stringify([ + { + relayHostId: 'target-relay', + relayDeviceId: 'target-phone', + ownerIdentityKey: 'target-owner', + reqId: 'target-1', + createdAt: 5 + } + ]) + const sourceOutbox = JSON.stringify([ + { + relayHostId: 'source-relay', + relayDeviceId: 'source-phone', + ownerIdentityKey: 'source-owner', + reqId: 'source-1', + createdAt: 6 + } + ]) + writeCurrentLifecycle(source, active, installationId, stage) + writeFileSync(join(source, DEVICE_REGISTRY_FILENAME), sourceRegistry) + writeFileSync(join(source, RELAY_REVOKE_OUTBOX_FILENAME), sourceOutbox) + writeCurrentLifecycle(target, active, installationId) + writeFileSync(join(target, DEVICE_REGISTRY_FILENAME), targetRegistry) + writeFileSync(join(target, RELAY_REVOKE_OUTBOX_FILENAME), targetOutbox) + + migrateMobilePairingUserdata(source, target) + + expect(readFileSync(join(target, DEVICE_REGISTRY_FILENAME), 'utf8')).toBe(targetRegistry) + expect(readFileSync(join(target, RELAY_REVOKE_OUTBOX_FILENAME), 'utf8')).toBe(targetOutbox) + expect(existsSync(join(target, E2EE_KEYPAIR_STAGE_FILENAME))).toBe(true) + }) + + it('repairs partial target and staging cuts from a complete candidate', () => { + const source = join(userDataPath, 'source') + const target = join(userDataPath, 'target') + const staging = join(target, '.orca-mobile-pairing-migration') + mkdirSync(source) + mkdirSync(target) + mkdirSync(staging) + const active = nacl.box.keyPair() + const installationId = 'migration-partial-cut-0001' + const registry = JSON.stringify([ + { + deviceId: 'phone', + name: 'Phone', + token: 'token', + scope: 'mobile', + pairedAt: 1, + lastSeenAt: 2 + } + ]) + const outbox = JSON.stringify([ + { + relayHostId: 'relay', + relayDeviceId: 'phone', + ownerIdentityKey: 'owner', + reqId: 'partial-1', + createdAt: 1 + } + ]) + writeCurrentLifecycle(source, active, installationId) + writeFileSync(join(source, DEVICE_REGISTRY_FILENAME), registry) + writeFileSync(join(source, RELAY_REVOKE_OUTBOX_FILENAME), outbox) + const targetActive = JSON.stringify({ + v: 2, + publicKeyB64: Buffer.from(active.publicKey).toString('base64'), + secretKeyB64: Buffer.from(active.secretKey).toString('base64'), + installationId + }) + writeFileSync(join(target, E2EE_KEYPAIR_FILENAME), targetActive) + writeFileSync(join(target, DEVICE_REGISTRY_FILENAME), registry) + writeFileSync(join(target, RELAY_REVOKE_OUTBOX_FILENAME), '[') + writeFileSync( + join(staging, E2EE_IDENTITY_MARKER_FILENAME), + ` { "installationId": "${installationId}", "v": 1 } ` + ) + writeFileSync(join(staging, DEVICE_REGISTRY_FILENAME), '{') + + migrateMobilePairingUserdata(source, target) + + expect(readFileSync(join(target, E2EE_KEYPAIR_FILENAME), 'utf8')).toBe(targetActive) + expect(existsSync(join(target, E2EE_IDENTITY_MARKER_FILENAME))).toBe(true) + expect(readFileSync(join(target, DEVICE_REGISTRY_FILENAME), 'utf8')).toBe(registry) + expect(readFileSync(join(target, RELAY_REVOKE_OUTBOX_FILENAME), 'utf8')).toBe(outbox) + expect(existsSync(staging)).toBe(false) + }) + + it.each(['transaction', 'predecessor'] as const)( + 'fails closed when a complete target has a divergent reset %s binding', + (difference) => { + const source = join(userDataPath, `source-${difference}`) + const target = join(userDataPath, `target-${difference}`) + mkdirSync(source) + mkdirSync(target) + const active = nacl.box.keyPair() + const sourceSuccessor = nacl.box.keyPair() + const targetSuccessor = nacl.box.keyPair() + const targetPredecessor = nacl.box.keyPair() + const installationId = `migration-binding-${difference}-0001` + const sourceStage = { + v: 2 as const, + publicKeyB64: Buffer.from(sourceSuccessor.publicKey).toString('base64'), + secretKeyB64: Buffer.from(sourceSuccessor.secretKey).toString('base64'), + installationId, + purpose: 'reset' as const, + transactionId: 'migration-binding-source-0001', + predecessorPublicKeyB64: Buffer.from(active.publicKey).toString('base64') + } + const targetStage = { + ...sourceStage, + publicKeyB64: Buffer.from(targetSuccessor.publicKey).toString('base64'), + secretKeyB64: Buffer.from(targetSuccessor.secretKey).toString('base64'), + ...(difference === 'transaction' + ? { transactionId: 'migration-binding-target-0001' } + : { + predecessorPublicKeyB64: Buffer.from(targetPredecessor.publicKey).toString('base64') + }) + } + writeCurrentLifecycle(source, active, installationId, sourceStage) + writeCurrentLifecycle(target, active, installationId, targetStage) + const targetBefore = readFileSync(join(target, E2EE_KEYPAIR_STAGE_FILENAME), 'utf8') + + expect(() => migrateMobilePairingUserdata(source, target)).toThrow() + expect(readFileSync(join(target, E2EE_KEYPAIR_STAGE_FILENAME), 'utf8')).toBe(targetBefore) + } + ) +}) + +function writeCurrentLifecycle( + directory: string, + active: nacl.BoxKeyPair, + installationId: string, + stage?: { + v: 2 + publicKeyB64: string + secretKeyB64: string + installationId: string + purpose: 'reset' + transactionId: string + predecessorPublicKeyB64: string + } +): void { + writeFileSync( + join(directory, E2EE_KEYPAIR_FILENAME), + JSON.stringify({ + v: 2, + publicKeyB64: Buffer.from(active.publicKey).toString('base64'), + secretKeyB64: Buffer.from(active.secretKey).toString('base64'), + installationId + }) + ) + writeFileSync( + join(directory, E2EE_IDENTITY_MARKER_FILENAME), + JSON.stringify({ v: 1, installationId }) + ) + if (stage) { + writeFileSync(join(directory, E2EE_KEYPAIR_STAGE_FILENAME), JSON.stringify(stage)) + } +} diff --git a/src/main/runtime/e2ee-keypair-record-validation.ts b/src/main/runtime/e2ee-keypair-record-validation.ts new file mode 100644 index 00000000000..bd5d310e35d --- /dev/null +++ b/src/main/runtime/e2ee-keypair-record-validation.ts @@ -0,0 +1,115 @@ +import { timingSafeEqual } from 'node:crypto' +import { readFileSync, statSync } from 'node:fs' +import nacl from 'tweetnacl' +import { assertSecureRegularFile, hardenExistingSecureFile } from '../../shared/secure-file' +import type { ValidatedKeypair } from './e2ee-keypair-storage' + +export const KEYPAIR_VERSION = 2 +export const LEGACY_KEYPAIR_VERSION = 1 +export const MAX_KEYPAIR_FILE_BYTES = 8 * 1024 +export const IDENTITY_MARKER_VERSION = 1 +export const MAX_IDENTITY_MARKER_FILE_BYTES = 8 * 1024 +const INSTALLATION_ID_PATTERN = /^[A-Za-z0-9_-]{16,128}$/ +const TRANSACTION_ID_PATTERN = /^[A-Za-z0-9._:-]{1,256}$/ + +export function readSecureRecord( + path: string, + field: string, + maxBytes: number +): Record { + assertSecureRegularFile(path, field) + hardenExistingSecureFile(path) + assertBoundedFile(path, maxBytes, field) + try { + const raw: unknown = JSON.parse(readFileSync(path, 'utf8')) + if (isRecord(raw)) { + return raw + } + } catch { + // Normalize parse and read failures at the storage boundary. + } + throw new Error(`${field} is invalid`) +} + +export function decodeKeypair( + raw: Record, + schema: ValidatedKeypair['schema'] +): ValidatedKeypair { + const publicKey = decodeKey(raw.publicKeyB64, 'E2EE public key') + const secretKey = decodeKey(raw.secretKeyB64, 'E2EE secret key') + const derivedPublicKey = nacl.box.keyPair.fromSecretKey(secretKey).publicKey + if (!timingSafeEqual(Buffer.from(publicKey), Buffer.from(derivedPublicKey))) { + throw new Error('E2EE public key does not match the secret key') + } + return { + schema, + publicKey, + secretKey, + publicKeyB64: Buffer.from(publicKey).toString('base64') + } +} + +export function assertInstallationId(value: string): void { + if (!isInstallationId(value)) { + throw new Error('E2EE installation identity is invalid') + } +} + +export function assertTransactionId(value: string): void { + if (!isTransactionId(value)) { + throw new Error('E2EE reset transaction is invalid') + } +} + +export function assertCanonicalKeyB64(value: unknown, field: string): asserts value is string { + if (!isCanonicalKeyB64(value)) { + throw new Error(`${field} is invalid`) + } +} + +export function isCanonicalKeyB64(value: unknown): value is string { + if (typeof value !== 'string' || value.length !== 44 || !/^[A-Za-z0-9+/]{43}=$/.test(value)) { + return false + } + const decoded = Buffer.from(value, 'base64') + return decoded.length === 32 && decoded.toString('base64') === value +} + +export function isInstallationId(value: unknown): value is string { + return typeof value === 'string' && INSTALLATION_ID_PATTERN.test(value) +} + +export function isTransactionId(value: unknown): value is string { + return typeof value === 'string' && TRANSACTION_ID_PATTERN.test(value) +} + +export function hasExactKeys(value: Record, keys: readonly string[]): boolean { + const actual = Object.keys(value).sort() + const expected = [...keys].sort() + return actual.length === expected.length && actual.every((key, index) => key === expected[index]) +} + +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function decodeKey(value: unknown, field: string): Uint8Array { + if (!isCanonicalKeyB64(value)) { + throw new Error(`${field} is invalid`) + } + const decoded = Buffer.from(value, 'base64') + return Uint8Array.from(decoded) +} + +function assertBoundedFile(path: string, maxBytes: number, field: string): void { + try { + if (statSync(path).size > maxBytes) { + throw new Error(`${field} is too large`) + } + } catch (error) { + if (error instanceof Error && error.message === `${field} is too large`) { + throw error + } + throw new Error(`${field} is unavailable`) + } +} diff --git a/src/main/runtime/e2ee-keypair-replacement.ts b/src/main/runtime/e2ee-keypair-replacement.ts new file mode 100644 index 00000000000..32099137d46 --- /dev/null +++ b/src/main/runtime/e2ee-keypair-replacement.ts @@ -0,0 +1,67 @@ +import { existsSync, renameSync, rmSync } from 'node:fs' +import { basename, dirname, join } from 'node:path' +import { fsyncDirectorySync, renameFileDurableSync } from '../../shared/durable-file-write' + +type ReplacementOptions = { + platform?: NodeJS.Platform + verify?: () => void + rename?: (source: string, destination: string) => void + remove?: (path: string) => void + retainBackup?: boolean +} + +export function verifiedReplacementBackupPath(activePath: string): string { + return join(dirname(activePath), `.${basename(activePath)}.backup`) +} + +/** Publishes a validated stage while retaining a recoverable Windows rollback. */ +export function replaceVerifiedKeypairStage( + stagePath: string, + activePath: string, + options: ReplacementOptions = {} +): void { + const platform = options.platform ?? process.platform + const move = options.rename ?? renameSync + const remove = options.remove ?? ((path: string) => rmSync(path, { force: true })) + const removeDurable = (path: string): void => { + remove(path) + fsyncDirectorySync(dirname(path)) + } + const renameDurable = (source: string, destination: string): void => { + renameFileDurableSync(source, destination, { move }) + } + if (platform !== 'win32' || !existsSync(activePath)) { + renameDurable(stagePath, activePath) + options.verify?.() + return + } + + const backupPath = verifiedReplacementBackupPath(activePath) + if (existsSync(backupPath)) { + if (existsSync(activePath)) { + removeDurable(backupPath) + } else { + renameDurable(backupPath, activePath) + } + } + let oldActiveMoved = false + let newActivePublished = false + try { + renameDurable(activePath, backupPath) + oldActiveMoved = true + renameDurable(stagePath, activePath) + newActivePublished = true + options.verify?.() + if (!options.retainBackup) { + removeDurable(backupPath) + } + } catch (error) { + if (newActivePublished) { + removeDurable(activePath) + } + if (oldActiveMoved && existsSync(backupPath)) { + renameDurable(backupPath, activePath) + } + throw error + } +} diff --git a/src/main/runtime/e2ee-keypair-storage.ts b/src/main/runtime/e2ee-keypair-storage.ts new file mode 100644 index 00000000000..0c042a96b90 --- /dev/null +++ b/src/main/runtime/e2ee-keypair-storage.ts @@ -0,0 +1,299 @@ +import { lstatSync } from 'node:fs' +import { join } from 'node:path' +import type nacl from 'tweetnacl' +import { removeFileDurableSync, renameFileDurableSync } from '../../shared/durable-file-write' +import { writeSecureJsonFileDurable } from '../../shared/secure-file' +import { + E2EE_IDENTITY_MARKER_FILENAME, + E2EE_KEYPAIR_FILENAME, + E2EE_KEYPAIR_STAGE_FILENAME +} from './mobile-pairing-files' +import { + type IdentityMarker, + readIdentityMarker, + writeIdentityMarker +} from './e2ee-keypair-identity-marker' + +import { + KEYPAIR_VERSION, + LEGACY_KEYPAIR_VERSION, + MAX_KEYPAIR_FILE_BYTES, + assertInstallationId, + assertCanonicalKeyB64, + assertTransactionId, + decodeKeypair, + hasExactKeys, + isInstallationId, + isCanonicalKeyB64, + isTransactionId, + readSecureRecord +} from './e2ee-keypair-record-validation' +import { verifiedReplacementBackupPath } from './e2ee-keypair-replacement' +import { assertReplacementBackupLineage } from './e2ee-keypair-backup-lineage' + +export type E2EEKeypairStagePurpose = 'first-install' | 'reset' + +type KeypairFile = { + v: number + publicKeyB64: string + secretKeyB64: string + installationId?: string +} + +type StageFile = KeypairFile & { + purpose: E2EEKeypairStagePurpose + transactionId?: string + predecessorPublicKeyB64?: string +} + +export type ValidatedKeypair = { + schema: 'legacy' | 'current' + publicKey: Uint8Array + secretKey: Uint8Array + publicKeyB64: string + installationId?: string +} + +export type ValidatedStage = ValidatedKeypair & { + schema: 'current' + installationId: string + purpose: E2EEKeypairStagePurpose + transactionId?: string + predecessorPublicKeyB64?: string +} + +export type E2EEIdentityStorageInspection = Readonly<{ + active: ValidatedKeypair | null + stage: ValidatedStage | null + marker: IdentityMarker | null + replacementBackup: ValidatedKeypair | null +}> + +export type IdentityPaths = { + active: string + stage: string + marker: string + backup: string +} + +type IdentityInspectionOptions = { + preserveReplacementBackup?: boolean +} + +export { replaceVerifiedKeypairStage } from './e2ee-keypair-replacement' +export { readIdentityMarker, writeIdentityMarker } +export type { IdentityMarker } + +export function identityPaths(userDataPath: string): IdentityPaths { + const active = join(userDataPath, E2EE_KEYPAIR_FILENAME) + return { + active, + stage: join(userDataPath, E2EE_KEYPAIR_STAGE_FILENAME), + marker: join(userDataPath, E2EE_IDENTITY_MARKER_FILENAME), + backup: verifiedReplacementBackupPath(active) + } +} + +export function pathExists(path: string, field: string): boolean { + try { + lstatSync(path) + return true + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return false + } + throw new Error(`${field} is unavailable`) + } +} + +export function inspectE2EEIdentityStorage( + userDataPath: string, + options: IdentityInspectionOptions = {} +): E2EEIdentityStorageInspection { + const paths = identityPaths(userDataPath) + recoverInterruptedReplacement(paths, options.preserveReplacementBackup === true) + return { + active: pathExists(paths.active, 'E2EE keypair') ? readAndValidateKeypair(paths.active) : null, + stage: pathExists(paths.stage, 'E2EE keypair stage') ? readAndValidateStage(paths.stage) : null, + marker: pathExists(paths.marker, 'E2EE identity marker') + ? readIdentityMarker(paths.marker) + : null, + replacementBackup: pathExists(paths.backup, 'E2EE keypair backup') + ? readAndValidateKeypair(paths.backup) + : null + } +} + +/** Recovers or retires the deterministic backup before identity inspection. */ +function recoverInterruptedReplacement(paths: IdentityPaths, preserveBackup: boolean): void { + if (!pathExists(paths.backup, 'E2EE keypair backup')) { + return + } + const backup = readAndValidateKeypair(paths.backup) + const marker = pathExists(paths.marker, 'E2EE identity marker') + ? readIdentityMarker(paths.marker) + : null + const stage = pathExists(paths.stage, 'E2EE keypair stage') + ? readAndValidateStage(paths.stage) + : null + if (!pathExists(paths.active, 'E2EE keypair')) { + assertReplacementBackupLineage(backup, null, marker, stage) + renameFileDurableSync(paths.backup, paths.active) + return + } + + const active = readAndValidateKeypair(paths.active) + assertReplacementBackupLineage(backup, active, marker, stage) + if (!preserveBackup) { + removeFileDurableSync(paths.backup) + } +} + +export function requireCurrentWithMarker( + keypair: ValidatedKeypair | null, + marker: IdentityMarker | null +): asserts keypair is ValidatedKeypair & { schema: 'current'; installationId: string } { + requireCurrentKeypair(keypair) + if (!marker || keypair.installationId !== marker.installationId) { + throw new Error('E2EE identity marker does not match the keypair') + } +} + +export function requireCurrentKeypair( + keypair: ValidatedKeypair | null +): asserts keypair is ValidatedKeypair & { schema: 'current'; installationId: string } { + if (!keypair || keypair.schema !== 'current' || !keypair.installationId) { + throw new Error('E2EE keypair is not a current-schema record') + } +} + +export function requireFirstInstallStage( + stage: ValidatedStage | null +): asserts stage is ValidatedStage & { purpose: 'first-install'; transactionId?: never } { + if (!stage || stage.purpose !== 'first-install' || stage.transactionId !== undefined) { + throw new Error('E2EE stage is not a first-install publication') + } +} + +export function requireResetStage( + stage: ValidatedStage | null, + transactionId: string, + predecessorPublicKeyB64: string +): asserts stage is ValidatedStage & { + purpose: 'reset' + transactionId: string + predecessorPublicKeyB64: string +} { + assertTransactionId(transactionId) + assertCanonicalKeyB64(predecessorPublicKeyB64, 'E2EE reset predecessor public key') + if ( + !stage || + stage.purpose !== 'reset' || + stage.transactionId === undefined || + stage.transactionId !== transactionId || + stage.predecessorPublicKeyB64 !== predecessorPublicKeyB64 || + stage.publicKeyB64 === predecessorPublicKeyB64 + ) { + throw new Error('E2EE reset stage does not match the reset transaction') + } +} + +export function readAndValidateKeypair(filePath: string): ValidatedKeypair { + const raw = readSecureRecord(filePath, 'E2EE keypair', MAX_KEYPAIR_FILE_BYTES) + if ( + raw.v === LEGACY_KEYPAIR_VERSION && + hasExactKeys(raw, ['v', 'publicKeyB64', 'secretKeyB64']) + ) { + return decodeKeypair(raw, 'legacy') + } + if ( + raw.v === KEYPAIR_VERSION && + hasExactKeys(raw, ['v', 'publicKeyB64', 'secretKeyB64', 'installationId']) && + isInstallationId(raw.installationId) + ) { + return { ...decodeKeypair(raw, 'current'), installationId: raw.installationId } + } + throw new Error('E2EE keypair is invalid') +} + +export function readAndValidateStage(filePath: string): ValidatedStage { + const raw = readSecureRecord(filePath, 'E2EE keypair stage', MAX_KEYPAIR_FILE_BYTES) + if (raw.v !== KEYPAIR_VERSION || !isInstallationId(raw.installationId)) { + throw new Error('E2EE keypair stage is invalid') + } + const firstInstallKeys = ['v', 'publicKeyB64', 'secretKeyB64', 'installationId', 'purpose'] + if (raw.purpose === 'first-install' && hasExactKeys(raw, firstInstallKeys)) { + return { + ...decodeKeypair(raw, 'current'), + schema: 'current', + installationId: raw.installationId, + purpose: raw.purpose + } + } + const resetKeys = [...firstInstallKeys, 'transactionId', 'predecessorPublicKeyB64'] + if ( + raw.purpose === 'reset' && + hasExactKeys(raw, resetKeys) && + isTransactionId(raw.transactionId) && + isCanonicalKeyB64(raw.predecessorPublicKeyB64) + ) { + return { + ...decodeKeypair(raw, 'current'), + schema: 'current', + installationId: raw.installationId, + purpose: raw.purpose, + transactionId: raw.transactionId, + predecessorPublicKeyB64: raw.predecessorPublicKeyB64 + } + } + throw new Error('E2EE keypair stage is invalid') +} + +export function writeKeypair( + filePath: string, + keypair: Pick, + installationId: string +): void { + assertInstallationId(installationId) + writeSecureJsonFileDurable(filePath, { + v: KEYPAIR_VERSION, + publicKeyB64: Buffer.from(keypair.publicKey).toString('base64'), + secretKeyB64: Buffer.from(keypair.secretKey).toString('base64'), + installationId + } satisfies KeypairFile) +} + +export function writeStageKeypair( + filePath: string, + keypair: Pick, + installationId: string, + purpose: E2EEKeypairStagePurpose, + transactionId?: string, + predecessorPublicKeyB64?: string +): void { + assertInstallationId(installationId) + if (purpose === 'reset') { + if (transactionId === undefined) { + throw new Error('E2EE reset stage requires a reset transaction') + } + assertTransactionId(transactionId) + assertCanonicalKeyB64(predecessorPublicKeyB64, 'E2EE reset predecessor public key') + if (Buffer.from(keypair.publicKey).toString('base64') === predecessorPublicKeyB64) { + throw new Error('E2EE reset successor must differ from its predecessor') + } + } else if (transactionId !== undefined) { + throw new Error('E2EE first-install stage cannot carry a reset transaction') + } else if (predecessorPublicKeyB64 !== undefined) { + throw new Error('E2EE first-install stage cannot carry a reset predecessor') + } + const stage: StageFile = { + v: KEYPAIR_VERSION, + publicKeyB64: Buffer.from(keypair.publicKey).toString('base64'), + secretKeyB64: Buffer.from(keypair.secretKey).toString('base64'), + installationId, + purpose, + ...(transactionId === undefined ? {} : { transactionId }), + ...(predecessorPublicKeyB64 === undefined ? {} : { predecessorPublicKeyB64 }) + } + writeSecureJsonFileDurable(filePath, stage) +} diff --git a/src/main/runtime/e2ee-keypair-successor.ts b/src/main/runtime/e2ee-keypair-successor.ts new file mode 100644 index 00000000000..80a8a35fd04 --- /dev/null +++ b/src/main/runtime/e2ee-keypair-successor.ts @@ -0,0 +1,255 @@ +import { dirname } from 'node:path' +import nacl from 'tweetnacl' +import { removeFileDurableSync } from '../../shared/durable-file-write' +import type { E2EEKeypair } from './e2ee-keypair' +import { + type E2EEIdentityStorageInspection, + type IdentityPaths, + type ValidatedKeypair, + identityPaths, + inspectE2EEIdentityStorage, + replaceVerifiedKeypairStage, + requireCurrentKeypair, + requireCurrentWithMarker, + requireResetStage, + writeKeypair, + writeStageKeypair +} from './e2ee-keypair-storage' +import { requireResetStageLineage } from './e2ee-keypair-backup-lineage' +import { assertCanonicalKeyB64, assertTransactionId } from './e2ee-keypair-record-validation' + +/** The sole reset transaction binds the durable stage to its old identity. */ +export type E2EEKeypairResetTransaction = Readonly<{ + transactionId: string + oldPublicKeyB64: string + phase: 'creating-successor' | 'successor-published' | 'finalizing-successor' +}> + +/** Stage exactly once; a durable stage is always reused for this transaction. */ +export function stageE2EEKeypairResetSuccessor( + userDataPath: string, + transaction: E2EEKeypairResetTransaction +): void { + assertCreatingSuccessor(transaction) + const paths = identityPaths(userDataPath) + const inspection = inspectE2EEIdentityStorage(userDataPath, { + preserveReplacementBackup: true + }) + requireCurrentWithMarker(inspection.active, inspection.marker) + if (inspection.stage) { + requireResetStage(inspection.stage, transaction.transactionId, transaction.oldPublicKeyB64) + requireResetStageLineage(inspection) + return + } + assertExpectedOldIdentity(inspection.active, transaction) + const successor = nacl.box.keyPair() + writeStageKeypair( + paths.stage, + successor, + inspection.active.installationId, + 'reset', + transaction.transactionId, + transaction.oldPublicKeyB64 + ) + requireResetStage( + inspectE2EEIdentityStorage(userDataPath, { preserveReplacementBackup: true }).stage, + transaction.transactionId, + transaction.oldPublicKeyB64 + ) +} + +/** Publish the exact stage while retaining it for retry and later transaction finalization. */ +export function publishE2EEKeypairResetSuccessor( + userDataPath: string, + transaction: E2EEKeypairResetTransaction +): E2EEKeypair { + assertCreatingSuccessor(transaction) + const paths = identityPaths(userDataPath) + const inspection = inspectE2EEIdentityStorage(userDataPath, { + preserveReplacementBackup: true + }) + requireCurrentWithMarker(inspection.active, inspection.marker) + requireResetStage(inspection.stage, transaction.transactionId, transaction.oldPublicKeyB64) + requireResetStageLineage(inspection) + if (sameKeyMaterial(inspection.active, inspection.stage)) { + return toE2EEKeypair(inspection.active) + } + if (inspection.active.publicKeyB64 !== transaction.oldPublicKeyB64) { + throw new Error('E2EE reset transaction no longer owns the active identity') + } + replaceStageAndVerify(paths, inspection.active.installationId, { + cleanupStage: false, + retainReplacementBackup: true + }) + const published = inspectE2EEIdentityStorage(userDataPath, { + preserveReplacementBackup: true + }) + requireCurrentWithMarker(published.active, published.marker) + requireResetStage(published.stage, transaction.transactionId, transaction.oldPublicKeyB64) + requireResetStageLineage(published) + if (!sameKeyMaterial(published.active, published.stage)) { + throw new Error('E2EE reset successor publication did not preserve the staged keypair') + } + return toE2EEKeypair(published.active) +} + +/** + * Validate the later reset phase. The normative reset transaction owns stage cleanup; + * this storage tranche leaves the exact stage in place until that durable phase commits. + */ +export function finalizeE2EEKeypairResetSuccessor( + userDataPath: string, + transaction: E2EEKeypairResetTransaction +): E2EEKeypair { + assertFinalizingSuccessor(transaction) + const inspection = inspectE2EEIdentityStorage(userDataPath, { + preserveReplacementBackup: true + }) + requireCurrentWithMarker(inspection.active, inspection.marker) + requireResetStage(inspection.stage, transaction.transactionId, transaction.oldPublicKeyB64) + requireResetStageLineage(inspection) + if (!sameKeyMaterial(inspection.active, inspection.stage)) { + throw new Error('E2EE reset successor is not published') + } + return toE2EEKeypair(inspection.active) +} + +/** Removes reset residue only after the successor and its predecessor lineage verify. */ +export function removeE2EEKeypairResetResidue( + userDataPath: string, + transaction: E2EEKeypairResetTransaction +): E2EEKeypair { + assertFinalizingSuccessor(transaction) + const paths = identityPaths(userDataPath) + const inspection = inspectE2EEIdentityStorage(userDataPath, { + preserveReplacementBackup: true + }) + requireCurrentWithMarker(inspection.active, inspection.marker) + requireResetStage(inspection.stage, transaction.transactionId, transaction.oldPublicKeyB64) + requireResetStageLineage(inspection) + if (!sameKeyMaterial(inspection.active, inspection.stage)) { + throw new Error('E2EE reset successor is not published') + } + removeFileDurableSync(paths.stage) + cleanupReplacementBackup(paths) + return toE2EEKeypair(inspection.active) +} + +/** Ordinary startup may observe a published reset stage but never promote a pending one. */ +export function isPublishedResetStage(inspection: E2EEIdentityStorageInspection): boolean { + if ( + inspection.stage?.purpose === 'reset' && + inspection.active?.schema === 'current' && + inspection.stage.installationId === inspection.active.installationId && + inspection.stage.publicKeyB64 !== inspection.stage.predecessorPublicKeyB64 && + sameKeyMaterial(inspection.active, inspection.stage) + ) { + try { + requireResetStageLineage(inspection) + return true + } catch { + return false + } + } + return false +} + +export function replaceStageAndVerify( + paths: IdentityPaths, + installationId: string, + options: { cleanupStage?: boolean; retainReplacementBackup?: boolean } = {} +): void { + const userDataPath = dirname(paths.active) + const before = inspectE2EEIdentityStorage(userDataPath, { + preserveReplacementBackup: true + }) + const staged = before.stage + requireCurrentKeypair(staged) + if (options.retainReplacementBackup) { + const active = before.active + requireCurrentWithMarker(active, before.marker) + writeKeypair(paths.backup, active, active.installationId) + const retained = inspectE2EEIdentityStorage(userDataPath, { + preserveReplacementBackup: true + }).replacementBackup + requireCurrentKeypair(retained) + if (retained.publicKeyB64 !== active.publicKeyB64) { + throw new Error('E2EE replacement backup did not retain the active predecessor') + } + } + const publicationPath = `${paths.active}.publication` + removeFileDurableSync(publicationPath) + writeKeypair(publicationPath, staged, installationId) + replaceVerifiedKeypairStage(publicationPath, paths.active, { + retainBackup: options.retainReplacementBackup, + verify: () => { + const published = inspectE2EEIdentityStorage(userDataPath, { + preserveReplacementBackup: true + }) + requireCurrentWithMarker(published.active, published.marker) + if (published.active.installationId !== installationId) { + throw new Error('E2EE identity publication changed installation identity') + } + } + }) + if (options.cleanupStage !== false) { + removeFileDurableSync(paths.stage) + } +} + +export function cleanupReplacementBackup(paths: IdentityPaths): void { + removeFileDurableSync(paths.backup) +} + +export function sameKeyMaterial(left: ValidatedKeypair, right: ValidatedKeypair): boolean { + return ( + Buffer.from(left.secretKey).equals(Buffer.from(right.secretKey)) && + Buffer.from(left.publicKey).equals(Buffer.from(right.publicKey)) + ) +} + +export function toE2EEKeypair(keypair: ValidatedKeypair): E2EEKeypair { + requireCurrentKeypair(keypair) + return { + publicKey: Uint8Array.from(keypair.publicKey), + secretKey: Uint8Array.from(keypair.secretKey), + publicKeyB64: Buffer.from(keypair.publicKey).toString('base64'), + installationId: keypair.installationId + } +} + +function assertCreatingSuccessor(transaction: E2EEKeypairResetTransaction): void { + assertTransaction(transaction) + if (transaction.phase !== 'creating-successor') { + throw new Error('E2EE reset successor requires the creating-successor transaction') + } +} + +function assertFinalizingSuccessor(transaction: E2EEKeypairResetTransaction): void { + assertTransaction(transaction) + if (transaction.phase !== 'successor-published' && transaction.phase !== 'finalizing-successor') { + throw new Error('E2EE reset successor requires the later finalization phase') + } +} + +function assertTransaction(transaction: E2EEKeypairResetTransaction): void { + if ( + !transaction || + typeof transaction.transactionId !== 'string' || + !transaction.transactionId || + typeof transaction.oldPublicKeyB64 !== 'string' + ) { + throw new Error('E2EE reset successor requires the complete reset transaction') + } + assertTransactionId(transaction.transactionId) + assertCanonicalKeyB64(transaction.oldPublicKeyB64, 'E2EE reset predecessor public key') +} + +function assertExpectedOldIdentity( + active: ValidatedKeypair & { schema: 'current'; installationId: string }, + transaction: E2EEKeypairResetTransaction +): void { + if (active.publicKeyB64 !== transaction.oldPublicKeyB64) { + throw new Error('E2EE reset transaction no longer owns the active identity') + } +} diff --git a/src/main/runtime/e2ee-keypair.test.ts b/src/main/runtime/e2ee-keypair.test.ts new file mode 100644 index 00000000000..b4f6cab2e55 --- /dev/null +++ b/src/main/runtime/e2ee-keypair.test.ts @@ -0,0 +1,784 @@ +import { + chmodSync, + existsSync, + mkdtempSync, + readFileSync, + renameSync, + rmSync, + statSync, + unlinkSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import nacl from 'tweetnacl' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + DEVICE_REGISTRY_FILENAME, + E2EE_KEYPAIR_BACKUP_FILENAME, + E2EE_IDENTITY_MARKER_FILENAME, + E2EE_KEYPAIR_FILENAME, + E2EE_KEYPAIR_STAGE_FILENAME, + RELAY_REVOKE_OUTBOX_FILENAME +} from './mobile-pairing-files' +import { + loadE2EEKeypair, + loadOrCreateE2EEKeypair, + finalizeE2EEKeypairResetSuccessor, + publishE2EEKeypairResetSuccessor, + stageE2EEKeypairResetSuccessor, + validateE2EEIdentityStorage +} from './e2ee-keypair' +import { replaceVerifiedKeypairStage } from './e2ee-keypair-storage' + +const BASE64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' + +describe('durable E2EE identity lifecycle', () => { + let userDataPath: string + + beforeEach(() => { + userDataPath = mkdtempSync(join(tmpdir(), 'orca-e2ee-identity-')) + }) + + afterEach(() => { + rmSync(userDataPath, { recursive: true, force: true }) + }) + + it('creates once on a fresh install and reloads the same identity', () => { + const first = loadOrCreateE2EEKeypair(userDataPath) + const second = loadOrCreateE2EEKeypair(userDataPath) + const keypairRecord = JSON.parse( + readFileSync(join(userDataPath, E2EE_KEYPAIR_FILENAME), 'utf8') + ) as Record + const markerRecord = JSON.parse( + readFileSync(join(userDataPath, E2EE_IDENTITY_MARKER_FILENAME), 'utf8') + ) as Record + + expect(second.publicKeyB64).toBe(first.publicKeyB64) + expect(second.publicKey).toEqual(first.publicKey) + expect(second.secretKey).toEqual(first.secretKey) + expect(keypairRecord.v).toBe(2) + expect(keypairRecord.installationId).toBe(markerRecord.installationId) + expect(markerRecord).not.toHaveProperty('consumerId') + expect(existsSync(join(userDataPath, E2EE_IDENTITY_MARKER_FILENAME))).toBe(true) + }) + + it('migrates only a validated markerless legacy keypair with the same key material', () => { + const legacy = nacl.box.keyPair() + writeFileSync( + join(userDataPath, E2EE_KEYPAIR_FILENAME), + JSON.stringify({ + v: 1, + publicKeyB64: Buffer.from(legacy.publicKey).toString('base64'), + secretKeyB64: Buffer.from(legacy.secretKey).toString('base64') + }) + ) + + const migrated = loadOrCreateE2EEKeypair(userDataPath) + const current = JSON.parse( + readFileSync(join(userDataPath, E2EE_KEYPAIR_FILENAME), 'utf8') + ) as Record + + expect(migrated.publicKeyB64).toBe(Buffer.from(legacy.publicKey).toString('base64')) + expect(current.v).toBe(2) + expect(current.installationId).toBeTruthy() + expect(existsSync(join(userDataPath, E2EE_KEYPAIR_STAGE_FILENAME))).toBe(false) + }) + + it('finishes marker-before-v2 recovery with the same legacy key material', () => { + const legacy = nacl.box.keyPair() + const installationId = 'marker-before-v2-0001' + writeFileSync( + join(userDataPath, E2EE_KEYPAIR_FILENAME), + JSON.stringify({ + v: 1, + publicKeyB64: Buffer.from(legacy.publicKey).toString('base64'), + secretKeyB64: Buffer.from(legacy.secretKey).toString('base64') + }) + ) + writeFileSync( + join(userDataPath, E2EE_IDENTITY_MARKER_FILENAME), + JSON.stringify({ v: 1, installationId }) + ) + + expect(() => loadE2EEKeypair(userDataPath)).toThrow('does not match the keypair') + expect(readFileSync(join(userDataPath, E2EE_KEYPAIR_FILENAME), 'utf8')).toContain( + Buffer.from(legacy.publicKey).toString('base64') + ) + }) + + it('fails closed when a current keypair loses its marker', () => { + loadOrCreateE2EEKeypair(userDataPath) + const keypairPath = join(userDataPath, E2EE_KEYPAIR_FILENAME) + const markerPath = join(userDataPath, E2EE_IDENTITY_MARKER_FILENAME) + const activeBefore = readFileSync(keypairPath, 'utf8') + unlinkSync(markerPath) + + expect(() => loadOrCreateE2EEKeypair(userDataPath)).toThrow('marker') + expect(readFileSync(keypairPath, 'utf8')).toBe(activeBefore) + }) + + it('fails closed when marker and current keypair installation identities differ', () => { + loadOrCreateE2EEKeypair(userDataPath) + const markerPath = join(userDataPath, E2EE_IDENTITY_MARKER_FILENAME) + writeFileSync(markerPath, JSON.stringify({ v: 1, installationId: 'different-installation-id' })) + + expect(() => loadOrCreateE2EEKeypair(userDataPath)).toThrow('does not match') + }) + + it('recovers the exact staged first-install keypair before marker publication', () => { + const staged = nacl.box.keyPair() + const installationId = 'staged-installation-0001' + writeFileSync( + join(userDataPath, E2EE_KEYPAIR_STAGE_FILENAME), + JSON.stringify({ + v: 2, + publicKeyB64: Buffer.from(staged.publicKey).toString('base64'), + secretKeyB64: Buffer.from(staged.secretKey).toString('base64'), + installationId, + purpose: 'first-install' + }) + ) + + const recovered = loadOrCreateE2EEKeypair(userDataPath) + + expect(recovered.publicKeyB64).toBe(Buffer.from(staged.publicKey).toString('base64')) + expect(existsSync(join(userDataPath, E2EE_KEYPAIR_STAGE_FILENAME))).toBe(false) + expect( + JSON.parse(readFileSync(join(userDataPath, E2EE_IDENTITY_MARKER_FILENAME), 'utf8')) + ).toEqual({ v: 1, installationId }) + }) + + it('recovers the exact staged first-install keypair after marker publication', () => { + const staged = nacl.box.keyPair() + const installationId = 'published-installation-0001' + writeFileSync( + join(userDataPath, E2EE_KEYPAIR_STAGE_FILENAME), + JSON.stringify({ + v: 2, + publicKeyB64: Buffer.from(staged.publicKey).toString('base64'), + secretKeyB64: Buffer.from(staged.secretKey).toString('base64'), + installationId, + purpose: 'first-install' + }) + ) + writeFileSync( + join(userDataPath, E2EE_IDENTITY_MARKER_FILENAME), + JSON.stringify({ v: 1, installationId }) + ) + + expect(loadOrCreateE2EEKeypair(userDataPath).publicKeyB64).toBe( + Buffer.from(staged.publicKey).toString('base64') + ) + expect(existsSync(join(userDataPath, E2EE_KEYPAIR_STAGE_FILENAME))).toBe(false) + }) + + it('publishes a reset successor with the same installation marker', () => { + const first = loadOrCreateE2EEKeypair(userDataPath) + const markerPath = join(userDataPath, E2EE_IDENTITY_MARKER_FILENAME) + const markerBefore = readFileSync(markerPath, 'utf8') + const transaction = { + transactionId: 'reset-transaction-0001', + oldPublicKeyB64: first.publicKeyB64, + phase: 'creating-successor' + } as const + + stageE2EEKeypairResetSuccessor(userDataPath, transaction) + const stagedSuccessor = JSON.parse( + readFileSync(join(userDataPath, E2EE_KEYPAIR_STAGE_FILENAME), 'utf8') + ) as { publicKeyB64: string } + const successor = publishE2EEKeypairResetSuccessor(userDataPath, transaction) + + expect(successor.publicKeyB64).toBe(stagedSuccessor.publicKeyB64) + expect(successor.publicKeyB64).not.toBe(first.publicKeyB64) + expect(readFileSync(markerPath, 'utf8')).toBe(markerBefore) + expect(existsSync(join(userDataPath, E2EE_KEYPAIR_STAGE_FILENAME))).toBe(true) + expect(loadE2EEKeypair(userDataPath).publicKeyB64).toBe(successor.publicKeyB64) + expect( + finalizeE2EEKeypairResetSuccessor(userDataPath, { + transactionId: transaction.transactionId, + oldPublicKeyB64: transaction.oldPublicKeyB64, + phase: 'successor-published' + }).publicKeyB64 + ).toBe(successor.publicKeyB64) + expect(existsSync(join(userDataPath, E2EE_KEYPAIR_STAGE_FILENAME))).toBe(true) + }) + + it('does not activate a reset successor during ordinary startup', () => { + const first = loadOrCreateE2EEKeypair(userDataPath) + const marker = JSON.parse( + readFileSync(join(userDataPath, E2EE_IDENTITY_MARKER_FILENAME), 'utf8') + ) as { installationId: string } + const successor = nacl.box.keyPair() + const transactionId = 'reset-transaction-0002' + writeFileSync( + join(userDataPath, E2EE_KEYPAIR_STAGE_FILENAME), + JSON.stringify({ + v: 2, + publicKeyB64: Buffer.from(successor.publicKey).toString('base64'), + secretKeyB64: Buffer.from(successor.secretKey).toString('base64'), + installationId: marker.installationId, + purpose: 'reset', + transactionId, + predecessorPublicKeyB64: first.publicKeyB64 + }) + ) + + expect(() => loadE2EEKeypair(userDataPath)).toThrow('cannot replace an active') + expect( + ( + JSON.parse(readFileSync(join(userDataPath, E2EE_KEYPAIR_FILENAME), 'utf8')) as Record< + string, + unknown + > + ).publicKeyB64 + ).toBe(first.publicKeyB64) + }) + + it('rejects a reset stage when its active predecessor is missing', () => { + const staged = nacl.box.keyPair() + const installationId = 'reset-without-active-0001' + writeFileSync( + join(userDataPath, E2EE_IDENTITY_MARKER_FILENAME), + JSON.stringify({ v: 1, installationId }) + ) + writeFileSync( + join(userDataPath, E2EE_KEYPAIR_STAGE_FILENAME), + JSON.stringify({ + v: 2, + publicKeyB64: Buffer.from(staged.publicKey).toString('base64'), + secretKeyB64: Buffer.from(staged.secretKey).toString('base64'), + installationId, + purpose: 'reset', + transactionId: 'reset-transaction-0007', + predecessorPublicKeyB64: Buffer.from(staged.publicKey).toString('base64') + }) + ) + + expect(() => validateE2EEIdentityStorage(userDataPath)).toThrow( + 'reset stage requires an active identity' + ) + }) + + it('rejects a reset successor equal to its predecessor in every lifecycle phase', () => { + const first = loadOrCreateE2EEKeypair(userDataPath) + const transaction = { + transactionId: 'reset-transaction-equal-0001', + oldPublicKeyB64: first.publicKeyB64, + phase: 'creating-successor' + } as const + writeFileSync( + join(userDataPath, E2EE_KEYPAIR_STAGE_FILENAME), + JSON.stringify({ + v: 2, + publicKeyB64: first.publicKeyB64, + secretKeyB64: Buffer.from(first.secretKey).toString('base64'), + installationId: first.installationId, + purpose: 'reset', + transactionId: transaction.transactionId, + predecessorPublicKeyB64: first.publicKeyB64 + }) + ) + + expect(() => stageE2EEKeypairResetSuccessor(userDataPath, transaction)).toThrow() + expect(() => validateE2EEIdentityStorage(userDataPath)).toThrow() + expect(() => publishE2EEKeypairResetSuccessor(userDataPath, transaction)).toThrow() + expect(() => + finalizeE2EEKeypairResetSuccessor(userDataPath, { + ...transaction, + phase: 'successor-published' + }) + ).toThrow() + expect( + JSON.parse(readFileSync(join(userDataPath, E2EE_KEYPAIR_FILENAME), 'utf8')).publicKeyB64 + ).toBe(first.publicKeyB64) + }) + + it('rejects an active first-install residue that changes key material', () => { + const original = loadOrCreateE2EEKeypair(userDataPath) + const staged = nacl.box.keyPair() + const marker = JSON.parse( + readFileSync(join(userDataPath, E2EE_IDENTITY_MARKER_FILENAME), 'utf8') + ) as { installationId: string } + writeFileSync( + join(userDataPath, E2EE_KEYPAIR_STAGE_FILENAME), + JSON.stringify({ + v: 2, + publicKeyB64: Buffer.from(staged.publicKey).toString('base64'), + secretKeyB64: Buffer.from(staged.secretKey).toString('base64'), + installationId: marker.installationId, + purpose: 'first-install' + }) + ) + + expect(() => validateE2EEIdentityStorage(userDataPath)).toThrow( + 'first-install stage does not preserve' + ) + expect(() => loadE2EEKeypair(userDataPath)).toThrow('cannot replace an active') + expect( + ( + JSON.parse(readFileSync(join(userDataPath, E2EE_KEYPAIR_FILENAME), 'utf8')) as { + publicKeyB64: string + } + ).publicKeyB64 + ).toBe(original.publicKeyB64) + }) + + it('rejects a reset stage for the wrong transaction and reuses the matching stage', () => { + const first = loadOrCreateE2EEKeypair(userDataPath) + const transaction = { + transactionId: 'reset-transaction-0003', + oldPublicKeyB64: first.publicKeyB64, + phase: 'creating-successor' + } as const + const wrongTransaction = { + transactionId: 'reset-transaction-0004', + oldPublicKeyB64: first.publicKeyB64, + phase: 'creating-successor' + } as const + + stageE2EEKeypairResetSuccessor(userDataPath, transaction) + const stageBefore = readFileSync(join(userDataPath, E2EE_KEYPAIR_STAGE_FILENAME), 'utf8') + stageE2EEKeypairResetSuccessor(userDataPath, transaction) + expect(readFileSync(join(userDataPath, E2EE_KEYPAIR_STAGE_FILENAME), 'utf8')).toBe(stageBefore) + expect(() => publishE2EEKeypairResetSuccessor(userDataPath, wrongTransaction)).toThrow( + 'does not match the reset transaction' + ) + expect(readFileSync(join(userDataPath, E2EE_KEYPAIR_STAGE_FILENAME), 'utf8')).toBe(stageBefore) + expect(publishE2EEKeypairResetSuccessor(userDataPath, transaction).publicKeyB64).not.toBe('') + }) + + it('retains the exact stage across publish retries until later-phase finalization', () => { + const first = loadOrCreateE2EEKeypair(userDataPath) + const transaction = { + transactionId: 'reset-transaction-retry-0001', + oldPublicKeyB64: first.publicKeyB64, + phase: 'creating-successor' + } as const + + stageE2EEKeypairResetSuccessor(userDataPath, transaction) + const staged = readFileSync(join(userDataPath, E2EE_KEYPAIR_STAGE_FILENAME), 'utf8') + const published = publishE2EEKeypairResetSuccessor(userDataPath, transaction) + expect(readFileSync(join(userDataPath, E2EE_KEYPAIR_STAGE_FILENAME), 'utf8')).toBe(staged) + expect(publishE2EEKeypairResetSuccessor(userDataPath, transaction).publicKeyB64).toBe( + published.publicKeyB64 + ) + stageE2EEKeypairResetSuccessor(userDataPath, transaction) + expect(readFileSync(join(userDataPath, E2EE_KEYPAIR_STAGE_FILENAME), 'utf8')).toBe(staged) + + const finalized = finalizeE2EEKeypairResetSuccessor(userDataPath, { + transactionId: transaction.transactionId, + oldPublicKeyB64: transaction.oldPublicKeyB64, + phase: 'finalizing-successor' + }) + expect( + finalizeE2EEKeypairResetSuccessor(userDataPath, { + transactionId: transaction.transactionId, + oldPublicKeyB64: transaction.oldPublicKeyB64, + phase: 'successor-published' + }).publicKeyB64 + ).toBe(finalized.publicKeyB64) + expect(existsSync(join(userDataPath, E2EE_KEYPAIR_STAGE_FILENAME))).toBe(true) + expect(() => stageE2EEKeypairResetSuccessor(userDataPath, transaction)).not.toThrow() + expect(() => publishE2EEKeypairResetSuccessor(userDataPath, transaction)).not.toThrow() + }) + + it('rejects a substituted predecessor on every reset retry after publication', () => { + const first = loadOrCreateE2EEKeypair(userDataPath) + const transaction = { + transactionId: 'reset-transaction-predecessor-fence-0001', + oldPublicKeyB64: first.publicKeyB64, + phase: 'creating-successor' + } as const + stageE2EEKeypairResetSuccessor(userDataPath, transaction) + publishE2EEKeypairResetSuccessor(userDataPath, transaction) + const substituted = nacl.box.keyPair() + const retry = { + ...transaction, + oldPublicKeyB64: Buffer.from(substituted.publicKey).toString('base64') + } + + expect(() => stageE2EEKeypairResetSuccessor(userDataPath, retry)).toThrow( + 'does not match the reset transaction' + ) + expect(() => publishE2EEKeypairResetSuccessor(userDataPath, retry)).toThrow( + 'does not match the reset transaction' + ) + expect(() => + finalizeE2EEKeypairResetSuccessor(userDataPath, { + ...retry, + phase: 'successor-published' + }) + ).toThrow('does not match the reset transaction') + expect( + ( + JSON.parse(readFileSync(join(userDataPath, E2EE_KEYPAIR_STAGE_FILENAME), 'utf8')) as { + predecessorPublicKeyB64: string + } + ).predecessorPublicKeyB64 + ).toBe(first.publicKeyB64) + }) + + it('rejects reset stages without strict predecessor evidence or canonical base64', () => { + const first = loadOrCreateE2EEKeypair(userDataPath) + const transaction = { + transactionId: 'reset-transaction-predecessor-schema-0001', + oldPublicKeyB64: first.publicKeyB64, + phase: 'creating-successor' + } as const + stageE2EEKeypairResetSuccessor(userDataPath, transaction) + const stagePath = join(userDataPath, E2EE_KEYPAIR_STAGE_FILENAME) + const stage = JSON.parse(readFileSync(stagePath, 'utf8')) as Record + delete stage.predecessorPublicKeyB64 + writeFileSync(stagePath, JSON.stringify(stage)) + expect(() => validateE2EEIdentityStorage(userDataPath)).toThrow('stage is invalid') + + const finalIndex = first.publicKeyB64.length - 2 + const finalValue = BASE64_ALPHABET.indexOf(first.publicKeyB64[finalIndex]!) + const noncanonicalFinal = BASE64_ALPHABET[(finalValue & 0b110000) | 1]! + stage.predecessorPublicKeyB64 = `${first.publicKeyB64.slice(0, finalIndex)}${noncanonicalFinal}=` + writeFileSync(stagePath, JSON.stringify(stage)) + expect(() => validateE2EEIdentityStorage(userDataPath)).toThrow('stage is invalid') + + stage.predecessorPublicKeyB64 = Buffer.from(nacl.box.keyPair().publicKey).toString('base64') + writeFileSync(stagePath, JSON.stringify(stage)) + expect(() => validateE2EEIdentityStorage(userDataPath)).toThrow('active predecessor') + }) + + it('reuses a reset stage after a crash just before stage cleanup', () => { + const first = loadOrCreateE2EEKeypair(userDataPath) + const transaction = { + transactionId: 'reset-transaction-0005', + oldPublicKeyB64: first.publicKeyB64, + phase: 'creating-successor' + } as const + stageE2EEKeypairResetSuccessor(userDataPath, transaction) + const stage = JSON.parse( + readFileSync(join(userDataPath, E2EE_KEYPAIR_STAGE_FILENAME), 'utf8') + ) as Record + writeFileSync( + join(userDataPath, E2EE_KEYPAIR_BACKUP_FILENAME), + JSON.stringify({ + v: 2, + publicKeyB64: first.publicKeyB64, + secretKeyB64: Buffer.from(first.secretKey).toString('base64'), + installationId: first.installationId + }) + ) + writeFileSync( + join(userDataPath, E2EE_KEYPAIR_FILENAME), + JSON.stringify({ + v: 2, + publicKeyB64: stage.publicKeyB64, + secretKeyB64: stage.secretKeyB64, + installationId: first.installationId + }) + ) + + const recovered = publishE2EEKeypairResetSuccessor(userDataPath, transaction) + expect(recovered.publicKeyB64).toBe(stage.publicKeyB64) + expect(existsSync(join(userDataPath, E2EE_KEYPAIR_STAGE_FILENAME))).toBe(true) + }) + + it('keeps the stage as the crash boundary until the normative reset phase commits', () => { + const first = loadOrCreateE2EEKeypair(userDataPath) + const transaction = { + transactionId: 'reset-transaction-finalize-crash-0001', + oldPublicKeyB64: first.publicKeyB64, + phase: 'creating-successor' + } as const + stageE2EEKeypairResetSuccessor(userDataPath, transaction) + const staged = JSON.parse( + readFileSync(join(userDataPath, E2EE_KEYPAIR_STAGE_FILENAME), 'utf8') + ) as { publicKeyB64: string } + publishE2EEKeypairResetSuccessor(userDataPath, transaction) + + expect(loadE2EEKeypair(userDataPath).publicKeyB64).toBe(staged.publicKeyB64) + expect(existsSync(join(userDataPath, E2EE_KEYPAIR_STAGE_FILENAME))).toBe(true) + expect(() => stageE2EEKeypairResetSuccessor(userDataPath, transaction)).not.toThrow() + + const nextTransaction = { + transactionId: 'reset-transaction-finalize-crash-0002', + oldPublicKeyB64: staged.publicKeyB64, + phase: 'creating-successor' + } as const + expect(() => stageE2EEKeypairResetSuccessor(userDataPath, nextTransaction)).toThrow( + 'does not match the reset transaction' + ) + }) + + it('rejects replay of the creating transaction after its later phase removes the stage', () => { + const first = loadOrCreateE2EEKeypair(userDataPath) + const transaction = { + transactionId: 'reset-transaction-finalize-cleanup-0001', + oldPublicKeyB64: first.publicKeyB64, + phase: 'creating-successor' + } as const + + stageE2EEKeypairResetSuccessor(userDataPath, transaction) + publishE2EEKeypairResetSuccessor(userDataPath, transaction) + finalizeE2EEKeypairResetSuccessor(userDataPath, { + ...transaction, + phase: 'finalizing-successor' + }) + unlinkSync(join(userDataPath, E2EE_KEYPAIR_STAGE_FILENAME)) + + expect(() => stageE2EEKeypairResetSuccessor(userDataPath, transaction)).toThrow('backup') + }) + + it('never creates an identity from a reset stage during ordinary startup', () => { + const successor = nacl.box.keyPair() + writeFileSync( + join(userDataPath, E2EE_KEYPAIR_STAGE_FILENAME), + JSON.stringify({ + v: 2, + publicKeyB64: Buffer.from(successor.publicKey).toString('base64'), + secretKeyB64: Buffer.from(successor.secretKey).toString('base64'), + installationId: 'reset-stage-install-0001', + purpose: 'reset', + transactionId: 'reset-transaction-0006', + predecessorPublicKeyB64: Buffer.from(successor.publicKey).toString('base64') + }) + ) + + expect(() => loadOrCreateE2EEKeypair(userDataPath)).toThrow('first-install') + expect(existsSync(join(userDataPath, E2EE_KEYPAIR_FILENAME))).toBe(false) + expect(existsSync(join(userDataPath, E2EE_KEYPAIR_STAGE_FILENAME))).toBe(true) + }) + + it('restores the old active file when Windows replacement cannot rename the stage', () => { + const activePath = join(userDataPath, 'active.json') + const stagePath = join(userDataPath, 'stage.json') + const activeContents = 'old-active' + const stageContents = 'new-stage' + writeFileSync(activePath, activeContents) + writeFileSync(stagePath, stageContents) + + const rename = (source: string, destination: string): void => { + if (source === stagePath && destination === activePath) { + throw new Error('sharing violation') + } + renameSync(source, destination) + } + + expect(() => + replaceVerifiedKeypairStage(stagePath, activePath, { platform: 'win32', rename }) + ).toThrow('sharing violation') + expect(readFileSync(activePath, 'utf8')).toBe(activeContents) + expect(readFileSync(stagePath, 'utf8')).toBe(stageContents) + }) + + it('recovers a Windows replacement interrupted after the first rename', () => { + const first = loadOrCreateE2EEKeypair(userDataPath) + const transaction = { + transactionId: 'reset-transaction-0008', + oldPublicKeyB64: first.publicKeyB64, + phase: 'creating-successor' + } as const + stageE2EEKeypairResetSuccessor(userDataPath, transaction) + const activePath = join(userDataPath, E2EE_KEYPAIR_FILENAME) + const backupPath = join(userDataPath, E2EE_KEYPAIR_BACKUP_FILENAME) + renameSync(activePath, backupPath) + + expect(() => loadE2EEKeypair(userDataPath)).toThrow('cannot replace an active') + expect(readFileSync(activePath, 'utf8')).toContain(first.publicKeyB64) + expect(existsSync(backupPath)).toBe(false) + expect(publishE2EEKeypairResetSuccessor(userDataPath, transaction).publicKeyB64).not.toBe( + first.publicKeyB64 + ) + }) + + it('keeps a Windows replacement interrupted after the second rename recoverable', () => { + const first = loadOrCreateE2EEKeypair(userDataPath) + const transaction = { + transactionId: 'reset-transaction-0009', + oldPublicKeyB64: first.publicKeyB64, + phase: 'creating-successor' + } as const + stageE2EEKeypairResetSuccessor(userDataPath, transaction) + const activePath = join(userDataPath, E2EE_KEYPAIR_FILENAME) + const backupPath = join(userDataPath, E2EE_KEYPAIR_BACKUP_FILENAME) + const stage = JSON.parse( + readFileSync(join(userDataPath, E2EE_KEYPAIR_STAGE_FILENAME), 'utf8') + ) as { publicKeyB64: string; secretKeyB64: string; installationId: string } + renameSync(activePath, backupPath) + writeFileSync( + activePath, + JSON.stringify({ + v: 2, + publicKeyB64: stage.publicKeyB64, + secretKeyB64: stage.secretKeyB64, + installationId: stage.installationId + }) + ) + + expect(loadE2EEKeypair(userDataPath).publicKeyB64).toBe(stage.publicKeyB64) + expect(existsSync(backupPath)).toBe(true) + expect(publishE2EEKeypairResetSuccessor(userDataPath, transaction).publicKeyB64).toBe( + stage.publicKeyB64 + ) + expect(existsSync(backupPath)).toBe(true) + expect(loadE2EEKeypair(userDataPath).publicKeyB64).toBe(stage.publicKeyB64) + expect(first.publicKeyB64).not.toBe(stage.publicKeyB64) + }) + + it('fails closed on an ambiguous replacement backup', () => { + const first = loadOrCreateE2EEKeypair(userDataPath) + const backup = nacl.box.keyPair() + writeFileSync( + join(userDataPath, E2EE_KEYPAIR_BACKUP_FILENAME), + JSON.stringify({ + v: 2, + publicKeyB64: Buffer.from(backup.publicKey).toString('base64'), + secretKeyB64: Buffer.from(backup.secretKey).toString('base64'), + installationId: 'different-backup-install-0001' + }) + ) + + expect(() => loadE2EEKeypair(userDataPath)).toThrow('backup does not match') + expect(existsSync(join(userDataPath, E2EE_KEYPAIR_BACKUP_FILENAME))).toBe(true) + expect( + ( + JSON.parse(readFileSync(join(userDataPath, E2EE_KEYPAIR_FILENAME), 'utf8')) as { + publicKeyB64: string + } + ).publicKeyB64 + ).toBe(first.publicKeyB64) + }) + + it.each([ + ['malformed JSON', '{'], + ['wrong version', JSON.stringify({ v: 2, publicKeyB64: 'x', secretKeyB64: 'x' })], + ['wrong lengths', JSON.stringify({ v: 1, publicKeyB64: 'AA==', secretKeyB64: 'AA==' })] + ])('fails closed on %s without regenerating', (_name, contents) => { + loadOrCreateE2EEKeypair(userDataPath) + const keypairPath = join(userDataPath, E2EE_KEYPAIR_FILENAME) + const markerPath = join(userDataPath, E2EE_IDENTITY_MARKER_FILENAME) + const markerBefore = readFileSync(markerPath, 'utf8') + writeFileSync(keypairPath, contents) + + expect(() => loadOrCreateE2EEKeypair(userDataPath)).toThrow() + expect(readFileSync(keypairPath, 'utf8')).toBe(contents) + expect(readFileSync(markerPath, 'utf8')).toBe(markerBefore) + }) + + it('fails closed on an oversized established record', () => { + loadOrCreateE2EEKeypair(userDataPath) + const keypairPath = join(userDataPath, E2EE_KEYPAIR_FILENAME) + const oversized = 'x'.repeat(9 * 1024) + writeFileSync(keypairPath, oversized) + + expect(() => loadOrCreateE2EEKeypair(userDataPath)).toThrow('too large') + expect(statSync(keypairPath).size).toBe(Buffer.byteLength(oversized)) + }) + + it('rejects a public key that is not derived from the stored secret', () => { + const original = loadOrCreateE2EEKeypair(userDataPath) + const other = nacl.box.keyPair() + const keypairPath = join(userDataPath, E2EE_KEYPAIR_FILENAME) + writeFileSync( + keypairPath, + JSON.stringify({ + v: 1, + publicKeyB64: Buffer.from(other.publicKey).toString('base64'), + secretKeyB64: Buffer.from(original.secretKey).toString('base64') + }) + ) + + expect(() => loadOrCreateE2EEKeypair(userDataPath)).toThrow('does not match') + }) + + it('rejects establishment evidence for a different identity', () => { + loadOrCreateE2EEKeypair(userDataPath) + writeFileSync( + join(userDataPath, E2EE_IDENTITY_MARKER_FILENAME), + JSON.stringify({ v: 1, consumerId: 'app-profile:v1:other' }) + ) + + expect(() => loadOrCreateE2EEKeypair(userDataPath)).toThrow('marker does not match') + }) + + it('rejects a noncanonical base64 encoding', () => { + const original = loadOrCreateE2EEKeypair(userDataPath) + const canonical = Buffer.from(original.secretKey).toString('base64') + const finalIndex = canonical.length - 2 + const value = BASE64_ALPHABET.indexOf(canonical[finalIndex]!) + const alternate = BASE64_ALPHABET[(value & 0b110000) | 1]! + const keypairPath = join(userDataPath, E2EE_KEYPAIR_FILENAME) + writeFileSync( + keypairPath, + JSON.stringify({ + v: 1, + publicKeyB64: original.publicKeyB64, + secretKeyB64: `${canonical.slice(0, finalIndex)}${alternate}=` + }) + ) + + expect(() => loadOrCreateE2EEKeypair(userDataPath)).toThrow('secret key is invalid') + }) + + it('treats deletion after establishment as missing identity', () => { + loadOrCreateE2EEKeypair(userDataPath) + unlinkSync(join(userDataPath, E2EE_KEYPAIR_FILENAME)) + + expect(() => loadOrCreateE2EEKeypair(userDataPath)).toThrow('missing') + expect(existsSync(join(userDataPath, E2EE_IDENTITY_MARKER_FILENAME))).toBe(true) + }) + + it('does not create when pairing state exists without the keypair', () => { + writeFileSync(join(userDataPath, DEVICE_REGISTRY_FILENAME), '[]') + + expect(() => loadOrCreateE2EEKeypair(userDataPath)).toThrow('missing') + }) + + it.each([ + ['missing', null, false], + ['valid', '[]', true], + ['invalid', '{invalid', true] + ] as const)( + 'treats a %s relay revoke outbox as established lifecycle evidence', + (_name, data, established) => { + if (data !== null) { + writeFileSync(join(userDataPath, RELAY_REVOKE_OUTBOX_FILENAME), data) + } + + if (established) { + expect(() => loadOrCreateE2EEKeypair(userDataPath)).toThrow('missing') + expect(existsSync(join(userDataPath, E2EE_KEYPAIR_FILENAME))).toBe(false) + } else { + expect(loadOrCreateE2EEKeypair(userDataPath).publicKey).toHaveLength(32) + expect(existsSync(join(userDataPath, E2EE_KEYPAIR_FILENAME))).toBe(true) + } + } + ) + + it('keeps strict established loading separate from first-install creation', () => { + expect(() => loadE2EEKeypair(userDataPath)).toThrow('missing') + const created = loadOrCreateE2EEKeypair(userDataPath) + + expect(loadE2EEKeypair(userDataPath).publicKeyB64).toBe(created.publicKeyB64) + }) + + it.runIf(process.platform !== 'win32')('hardens identity files on Unix', () => { + loadOrCreateE2EEKeypair(userDataPath) + const keypairPath = join(userDataPath, E2EE_KEYPAIR_FILENAME) + const markerPath = join(userDataPath, E2EE_IDENTITY_MARKER_FILENAME) + chmodSync(userDataPath, 0o755) + chmodSync(keypairPath, 0o644) + chmodSync(markerPath, 0o644) + + loadOrCreateE2EEKeypair(userDataPath) + + expect(statSync(userDataPath).mode & 0o777).toBe(0o700) + expect(statSync(keypairPath).mode & 0o777).toBe(0o600) + expect(statSync(markerPath).mode & 0o777).toBe(0o600) + }) + + it('uses path.join-safe userdata paths', () => { + const nestedPath = join(userDataPath, 'user data', 'Orca') + const keypair = loadOrCreateE2EEKeypair(nestedPath) + + expect(keypair.publicKey).toHaveLength(32) + expect(existsSync(join(nestedPath, E2EE_KEYPAIR_FILENAME))).toBe(true) + }) +}) diff --git a/src/main/runtime/e2ee-keypair.ts b/src/main/runtime/e2ee-keypair.ts index 3e8b3e0aa0a..e7acfc53a5e 100644 --- a/src/main/runtime/e2ee-keypair.ts +++ b/src/main/runtime/e2ee-keypair.ts @@ -1,58 +1,308 @@ // Why: the E2EE keypair enables application-layer encryption between mobile // and desktop over plain ws://. The public key is embedded in the QR pairing // offer so the mobile client can derive a shared secret via ECDH. -import { existsSync, readFileSync, statSync } from 'node:fs' -import { join } from 'node:path' +import { randomUUID } from 'node:crypto' +import { dirname, join } from 'node:path' import nacl from 'tweetnacl' -import { hardenExistingSecureFile, writeSecureJsonFile } from '../../shared/secure-file' -import { E2EE_KEYPAIR_FILENAME } from './mobile-pairing-files' - -const KEYPAIR_FILENAME = E2EE_KEYPAIR_FILENAME -const KEYPAIR_VERSION = 1 -const MAX_KEYPAIR_FILE_BYTES = 8 * 1024 - -type KeypairFile = { - v: number - publicKeyB64: string - secretKeyB64: string -} +import { removeFileDurableSync } from '../../shared/durable-file-write' +import { + type E2EEIdentityStorageInspection, + type IdentityMarker, + type IdentityPaths, + type ValidatedKeypair, + identityPaths, + inspectE2EEIdentityStorage, + pathExists, + readIdentityMarker, + requireCurrentKeypair, + requireCurrentWithMarker, + requireFirstInstallStage, + requireResetStage, + writeIdentityMarker, + writeStageKeypair +} from './e2ee-keypair-storage' +import { requireResetStageLineage } from './e2ee-keypair-backup-lineage' +import { DEVICE_REGISTRY_FILENAME, RELAY_REVOKE_OUTBOX_FILENAME } from './mobile-pairing-files' +import { + cleanupReplacementBackup, + finalizeE2EEKeypairResetSuccessor, + isPublishedResetStage, + removeE2EEKeypairResetResidue, + replaceStageAndVerify, + sameKeyMaterial, + stageE2EEKeypairResetSuccessor, + publishE2EEKeypairResetSuccessor, + toE2EEKeypair, + type E2EEKeypairResetTransaction +} from './e2ee-keypair-successor' export type E2EEKeypair = { publicKey: Uint8Array secretKey: Uint8Array publicKeyB64: string + installationId: string } -export function loadOrCreateE2EEKeypair(userDataPath: string): E2EEKeypair { - const filePath = join(userDataPath, KEYPAIR_FILENAME) +export type { E2EEKeypairResetTransaction } +export { + finalizeE2EEKeypairResetSuccessor, + removeE2EEKeypairResetResidue, + publishE2EEKeypairResetSuccessor, + stageE2EEKeypairResetSuccessor +} - if (existsSync(filePath)) { - try { - hardenExistingSecureFile(filePath) - // Why: this startup path reads synchronously; valid keypair files are - // tiny, so oversized/corrupt files should be replaced without loading. - if (statSync(filePath).size > MAX_KEYPAIR_FILE_BYTES) { - throw new Error('E2EE keypair file is too large') - } - const raw: KeypairFile = JSON.parse(readFileSync(filePath, 'utf-8')) - if (raw.v === KEYPAIR_VERSION && raw.publicKeyB64 && raw.secretKeyB64) { - const publicKey = Uint8Array.from(Buffer.from(raw.publicKeyB64, 'base64')) - const secretKey = Uint8Array.from(Buffer.from(raw.secretKeyB64, 'base64')) - if (publicKey.length === 32 && secretKey.length === 32) { - return { publicKey, secretKey, publicKeyB64: raw.publicKeyB64 } - } - } - } catch { - // Malformed file — regenerate below. +/** Loads the predecessor while a reset-owned stage is still pending. */ +export function loadE2EEKeypairForIdentityReset( + userDataPath: string, + transaction: E2EEKeypairResetTransaction +): E2EEKeypair { + const inspection = inspectE2EEIdentityStorage(userDataPath, { + preserveReplacementBackup: true + }) + requireCurrentWithMarker(inspection.active, inspection.marker) + if (transaction.phase === 'creating-successor') { + if (inspection.stage) { + requireResetStage(inspection.stage, transaction.transactionId, transaction.oldPublicKeyB64) + requireResetStageLineage(inspection) + } else if (inspection.active.publicKeyB64 !== transaction.oldPublicKeyB64) { + throw new Error('E2EE reset transaction no longer owns the active identity') } + return toE2EEKeypair(inspection.active) } + requireResetStage(inspection.stage, transaction.transactionId, transaction.oldPublicKeyB64) + requireResetStageLineage(inspection) + if (!sameKeyMaterial(inspection.active, inspection.stage)) { + throw new Error('E2EE reset successor is not published') + } + return toE2EEKeypair(inspection.active) +} +/** Recognizes the durable post-cleanup boundary for a retry cut between cleanup and phase advance. */ +export function isE2EEKeypairResetResidueAbsent( + userDataPath: string, + transaction: E2EEKeypairResetTransaction +): boolean { + const inspection = inspectE2EEIdentityStorage(userDataPath, { + preserveReplacementBackup: true + }) + try { + requireCurrentWithMarker(inspection.active, inspection.marker) + } catch { + return false + } + return ( + inspection.stage === null && + inspection.replacementBackup === null && + inspection.active.publicKeyB64 !== transaction.oldPublicKeyB64 + ) +} + +export type { E2EEIdentityStorageInspection } + +/** Creates or recovers the one explicitly allowed first-install publication. */ +export function loadOrCreateE2EEKeypair(userDataPath: string): E2EEKeypair { + const paths = identityPaths(userDataPath) + if ( + pathExists(paths.active, 'E2EE keypair') || + pathExists(paths.marker, 'E2EE identity marker') || + pathExists(paths.stage, 'E2EE keypair stage') || + pathExists(paths.backup, 'E2EE keypair backup') || + pathExists(join(userDataPath, RELAY_REVOKE_OUTBOX_FILENAME), 'relay revoke outbox') + ) { + return loadE2EEKeypair(userDataPath) + } + if (pathExists(join(userDataPath, DEVICE_REGISTRY_FILENAME), 'mobile pairing registry')) { + throw new Error('Established E2EE identity is missing') + } + return createFirstInstallKeypair(paths) +} + +/** Strict ordinary startup loader; reset stages are never activated here. */ +export function loadE2EEKeypair(userDataPath: string): E2EEKeypair { + const paths = identityPaths(userDataPath) + let inspection = inspectE2EEIdentityStorage(userDataPath, { + preserveReplacementBackup: true + }) + if (inspection.active?.schema === 'legacy') { + return migrateLegacyKeypair(paths, inspection.active, inspection.marker, inspection.stage) + } + if (inspection.stage) { + if (inspection.active) { + requireCurrentWithMarker(inspection.active, inspection.marker) + if ( + inspection.stage.purpose === 'first-install' && + sameKeyMaterial(inspection.stage, inspection.active) && + inspection.stage.installationId === inspection.active.installationId + ) { + removeFileDurableSync(paths.stage) + cleanupReplacementBackup(paths) + return toE2EEKeypair(inspection.active) + } + if (inspection.stage.purpose === 'reset') { + requireResetStageLineage(inspection) + } + if (isPublishedResetStage(inspection)) { + return toE2EEKeypair(inspection.active) + } + throw new Error('E2EE keypair stage cannot replace an active identity during startup') + } + requireFirstInstallStage(inspection.stage) + if (inspection.marker && inspection.marker.installationId !== inspection.stage.installationId) { + throw new Error('E2EE identity marker does not match the staged keypair') + } + if (!inspection.marker) { + writeIdentityMarker(paths.marker, inspection.stage.installationId) + } + replaceStageAndVerify(paths, inspection.stage.installationId) + inspection = inspectE2EEIdentityStorage(userDataPath, { + preserveReplacementBackup: true + }) + } + if (!inspection.active) { + throw new Error('Established E2EE identity is missing') + } + requireCurrentWithMarker(inspection.active, inspection.marker) + cleanupReplacementBackup(paths) + return toE2EEKeypair(inspection.active) +} + +/** Validates a lifecycle unit without activating a stage or rewriting any file. */ +export function validateE2EEIdentityStorage( + userDataPath: string, + options: { preserveReplacementBackup?: boolean } = {} +): void { + const inspection = inspectE2EEIdentityStorage(userDataPath, { + ...options, + preserveReplacementBackup: true + }) + if (inspection.stage) { + requireCurrentKeypair(inspection.stage) + if (inspection.stage.purpose === 'reset' && !inspection.stage.transactionId) { + throw new Error('E2EE reset stage requires a reset transaction') + } + if (inspection.active) { + if (inspection.active.schema === 'legacy') { + if ( + inspection.stage.purpose !== 'first-install' || + (inspection.marker && + inspection.stage.installationId !== inspection.marker.installationId) || + !sameKeyMaterial(inspection.stage, inspection.active) + ) { + throw new Error( + 'E2EE identity marker does not match the keypair or exact migration stage' + ) + } + return + } + requireCurrentWithMarker(inspection.active, inspection.marker) + const marker = inspection.marker + if (!marker || inspection.stage.installationId !== marker.installationId) { + throw new Error('E2EE identity marker does not match the staged keypair') + } + if ( + inspection.stage.purpose === 'reset' && + inspection.stage.publicKeyB64 === inspection.stage.predecessorPublicKeyB64 + ) { + throw new Error('E2EE reset successor must differ from its predecessor') + } + if (inspection.stage.purpose === 'reset') { + requireResetStageLineage(inspection) + } + if ( + inspection.stage.purpose === 'first-install' && + !sameKeyMaterial(inspection.stage, inspection.active) + ) { + throw new Error('E2EE first-install stage does not preserve the active keypair') + } + } else if ( + inspection.marker && + inspection.marker.installationId !== inspection.stage.installationId + ) { + throw new Error('E2EE identity marker does not match the staged keypair') + } else if (inspection.stage.purpose === 'reset') { + throw new Error('E2EE reset stage requires an active identity') + } + return + } + if (!inspection.active) { + if (inspection.marker) { + throw new Error('E2EE identity marker exists without a keypair') + } + return + } + if (inspection.active.schema === 'legacy') { + if (inspection.marker) { + throw new Error('E2EE identity marker does not match the keypair or exact migration stage') + } + return + } + requireCurrentWithMarker(inspection.active, inspection.marker) +} + +function createFirstInstallKeypair(paths: IdentityPaths): E2EEKeypair { + const installationId = randomUUID() const keypair = nacl.box.keyPair() - const publicKeyB64 = Buffer.from(keypair.publicKey).toString('base64') - const secretKeyB64 = Buffer.from(keypair.secretKey).toString('base64') - - const data: KeypairFile = { v: KEYPAIR_VERSION, publicKeyB64, secretKeyB64 } - writeSecureJsonFile(filePath, data) + writeStageKeypair(paths.stage, keypair, installationId, 'first-install') + const staged = inspectE2EEIdentityStorage(dirname(paths.active)).stage + requireFirstInstallStage(staged) + if (staged.installationId !== installationId) { + throw new Error('E2EE first-install stage validation failed') + } + writeIdentityMarker(paths.marker, installationId) + readIdentityMarker(paths.marker) + replaceStageAndVerify(paths, installationId) + const published = inspectE2EEIdentityStorage(dirname(paths.active)) + requireCurrentWithMarker(published.active, published.marker) + return toE2EEKeypair(published.active) +} - return { publicKey: keypair.publicKey, secretKey: keypair.secretKey, publicKeyB64 } +function migrateLegacyKeypair( + paths: IdentityPaths, + legacy: ValidatedKeypair, + marker: IdentityMarker | null, + stage: E2EEIdentityStorageInspection['stage'] +): E2EEKeypair { + let installationId = marker?.installationId + if (stage) { + requireFirstInstallStage(stage) + if (stage.publicKeyB64 !== legacy.publicKeyB64 || !sameKeyMaterial(stage, legacy)) { + throw new Error('E2EE marker-before-v2 stage does not preserve the legacy keypair') + } + if (installationId && stage.installationId !== installationId) { + throw new Error('E2EE identity marker does not match the staged keypair') + } + installationId = stage.installationId + } else { + if (marker) { + throw new Error('E2EE identity marker does not match the keypair or exact migration stage') + } + installationId = randomUUID() + writeStageKeypair( + paths.stage, + { publicKey: legacy.publicKey, secretKey: legacy.secretKey }, + installationId, + 'first-install' + ) + } + if (!installationId) { + throw new Error('E2EE legacy migration installation identity is missing') + } + const staged = inspectE2EEIdentityStorage(dirname(paths.active), { + preserveReplacementBackup: true + }).stage + requireFirstInstallStage(staged) + if (staged.installationId !== installationId || !sameKeyMaterial(staged, legacy)) { + throw new Error('E2EE legacy migration stage does not preserve the legacy keypair') + } + if (!marker) { + writeIdentityMarker(paths.marker, installationId) + } + readIdentityMarker(paths.marker) + replaceStageAndVerify(paths, installationId) + const current = inspectE2EEIdentityStorage(dirname(paths.active), { + preserveReplacementBackup: true + }) + requireCurrentWithMarker(current.active, current.marker) + cleanupReplacementBackup(paths) + return toE2EEKeypair(current.active) } diff --git a/src/main/runtime/missing-worktree-terminal-reconciliation.test.ts b/src/main/runtime/missing-worktree-terminal-reconciliation.test.ts index 22cf484a13e..22d91b9ed8e 100644 --- a/src/main/runtime/missing-worktree-terminal-reconciliation.test.ts +++ b/src/main/runtime/missing-worktree-terminal-reconciliation.test.ts @@ -2,15 +2,13 @@ import { describe, expect, it, vi } from 'vitest' import type { IPtyProvider } from '../providers/types' import type { Repo } from '../../shared/types' import type { OrcaRuntimeService } from './orca-runtime' +import { createWorktreeTeardownProviderStub } from './__tests__/worktree-teardown-provider' import { stopMissingWorktreeTerminals } from './missing-worktree-terminal-reconciliation' -function createProvider(sessionIds: string[]): IPtyProvider { - return { - listProcesses: vi.fn(async () => - sessionIds.map((id) => ({ id, cwd: '/workspace', title: 'shell' })) - ), - shutdown: vi.fn(async () => {}) - } as unknown as IPtyProvider +function createProvider(sessionIds: string[]) { + return createWorktreeTeardownProviderStub(async () => + sessionIds.map((id) => ({ id, cwd: '/workspace', title: 'shell' })) + ) } function createRuntime(): OrcaRuntimeService { @@ -49,12 +47,14 @@ describe('stopMissingWorktreeTerminals', () => { ) expect(result).toEqual({ stoppedWorktreeIds: [deletedId] }) - expect(provider.shutdown).toHaveBeenCalledWith( + expect(provider.killExact).toHaveBeenCalledWith( `${deletedId}@@deleted-session`, + `incarnation:${deletedId}@@deleted-session`, expect.objectContaining({ immediate: true }) ) - expect(provider.shutdown).not.toHaveBeenCalledWith( + expect(provider.killExact).not.toHaveBeenCalledWith( `${survivingId}@@surviving-session`, + `incarnation:${survivingId}@@surviving-session`, expect.anything() ) }) @@ -81,8 +81,9 @@ describe('stopMissingWorktreeTerminals', () => { resolvedConnectionId: 'ssh-1' }) ) - expect(sshProvider.shutdown).toHaveBeenCalledWith( + expect(sshProvider.killExact).toHaveBeenCalledWith( `${deletedId}@@ssh-session`, + `incarnation:${deletedId}@@ssh-session`, expect.objectContaining({ immediate: true }) ) expect(localProvider.listProcesses).not.toHaveBeenCalled() @@ -128,25 +129,42 @@ describe('stopMissingWorktreeTerminals', () => { expect(result.stoppedWorktreeIds).toHaveLength(ids.length) expect(provider.listProcesses).toHaveBeenCalledTimes(1) - expect(provider.shutdown).toHaveBeenCalledTimes(ids.length) + expect(provider.killExact).toHaveBeenCalledTimes(ids.length) }) - // Why: real providers put listProcesses/shutdown on the prototype and use `this`; + // Why: real providers put listProcesses/exact kills on the prototype and use `this`; // batching them behind a wrapper must not break that binding. it('keeps provider method binding intact while batching', async () => { class PrototypeProvider { listCalls = 0 - shutdownCalls: string[] = [] - private readonly sessions: { id: string; cwd: string; title: string }[] + killCalls: string[] = [] + private readonly mutationRouteToken = Object.freeze({}) + private readonly sessions: { + id: string + incarnationId: string + mutationRouteToken: object + cwd: string + title: string + }[] constructor(sessionIds: string[]) { - this.sessions = sessionIds.map((id) => ({ id, cwd: '/workspace', title: 'shell' })) + this.sessions = sessionIds.map((id) => ({ + id, + incarnationId: `incarnation:${id}`, + mutationRouteToken: this.mutationRouteToken, + cwd: '/workspace', + title: 'shell' + })) } - async listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> { + async listProcesses(): Promise { this.listCalls += 1 return this.sessions } - async shutdown(sessionId: string): Promise { - this.shutdownCalls.push(sessionId) + getPtyMutationRouteToken(): object { + return this.mutationRouteToken + } + async killExact(sessionId: string, incarnationId: string): Promise { + this.killCalls.push(`${sessionId}:${incarnationId}`) + return true } } const ids = ['repo-1::/workspace/a', 'repo-1::/workspace/b', 'repo-1::/workspace/c'] @@ -165,7 +183,10 @@ describe('stopMissingWorktreeTerminals', () => { expect(result.stoppedWorktreeIds).toHaveLength(ids.length) expect(provider.listCalls).toBe(1) - expect(provider.shutdownCalls).toHaveLength(ids.length) + expect(provider.killCalls).toEqual( + expect.arrayContaining(ids.map((id) => `${id}@@session:incarnation:${id}@@session`)) + ) + expect(provider.killCalls).toHaveLength(ids.length) }) // Why: the batching must not leak past the calls it was built for. If the proxy @@ -174,20 +195,38 @@ describe('stopMissingWorktreeTerminals', () => { it('does not serve the shared snapshot to provider-internal listProcesses', async () => { class SelfListingProvider { listCalls = 0 - constructor(private readonly sessions: { id: string; cwd: string; title: string }[]) {} - async listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> { + private readonly mutationRouteToken = Object.freeze({}) + private readonly sessions: { + id: string + incarnationId: string + mutationRouteToken: object + cwd: string + title: string + }[] + constructor(sessionIds: string[]) { + this.sessions = sessionIds.map((id) => ({ + id, + incarnationId: `incarnation:${id}`, + mutationRouteToken: this.mutationRouteToken, + cwd: '/workspace', + title: 'shell' + })) + } + async listProcesses(): Promise { this.listCalls += 1 return this.sessions } - async shutdown(): Promise { - // A provider that re-reads live state as part of stopping. + getPtyMutationRouteToken(): object { + return this.mutationRouteToken + } + async killExact(): Promise { + // A provider that re-reads live state as part of exact stopping. await this.listProcesses() + return true } } const ids = ['repo-1::/workspace/a', 'repo-1::/workspace/b'] - const provider = new SelfListingProvider( - ids.map((id) => ({ id: `${id}@@session`, cwd: '/workspace', title: 'shell' })) - ) + const provider = new SelfListingProvider(ids.map((id) => `${id}@@session`)) await stopMissingWorktreeTerminals({ ...localRepo, connectionId: 'ssh-1' }, ids, [], { runtime: createRuntime(), @@ -195,7 +234,7 @@ describe('stopMissingWorktreeTerminals', () => { getSshProvider: () => provider as unknown as IPtyProvider }) - // One shared sweep scan, plus each shutdown's own live re-read. + // One shared sweep scan, plus each exact kill's own live re-read. expect(provider.listCalls).toBe(1 + ids.length) }) @@ -203,11 +242,27 @@ describe('stopMissingWorktreeTerminals', () => { // teardown for every remaining worktree in the sweep. it('does not reuse a failed process scan', async () => { const ids = ['repo-1::/workspace/a', 'repo-1::/workspace/b'] + const sessionId = `${ids[1]}@@session` + const incarnationId = `incarnation:${sessionId}` + const mutationRouteToken = Object.freeze({}) const listProcesses = vi .fn() .mockRejectedValueOnce(new Error('relay dropped')) - .mockResolvedValue([{ id: `${ids[1]}@@session`, cwd: '/workspace', title: 'shell' }]) - const provider = { listProcesses, shutdown: vi.fn(async () => {}) } as unknown as IPtyProvider + .mockResolvedValue([ + { + id: sessionId, + incarnationId, + mutationRouteToken, + cwd: '/workspace', + title: 'shell' + } + ]) + const killExact = vi.fn(async () => true) + const provider = { + listProcesses, + killExact, + getPtyMutationRouteToken: vi.fn(() => mutationRouteToken) + } as unknown as IPtyProvider await stopMissingWorktreeTerminals({ ...localRepo, connectionId: 'ssh-1' }, ids, [], { runtime: createRuntime(), @@ -216,8 +271,9 @@ describe('stopMissingWorktreeTerminals', () => { }) expect(listProcesses.mock.calls.length).toBeGreaterThan(1) - expect(provider.shutdown).toHaveBeenCalledWith( - `${ids[1]}@@session`, + expect(killExact).toHaveBeenCalledWith( + sessionId, + incarnationId, expect.objectContaining({ immediate: true }) ) }) diff --git a/src/main/runtime/mobile-pairing-files.ts b/src/main/runtime/mobile-pairing-files.ts index 40e145417c9..6877b4fe20c 100644 --- a/src/main/runtime/mobile-pairing-files.ts +++ b/src/main/runtime/mobile-pairing-files.ts @@ -1,8 +1,22 @@ export const DEVICE_REGISTRY_FILENAME = 'orca-devices.json' export const E2EE_KEYPAIR_FILENAME = 'orca-e2ee-keypair.json' +// Why: this marker distinguishes a deleted established identity from a fresh install without storing another key. +export const E2EE_IDENTITY_MARKER_FILENAME = 'terminal-authority-consumer-proof-identity.json' +// Why: one deterministic private stage lets startup finish the exact publication that was interrupted. +export const E2EE_KEYPAIR_STAGE_FILENAME = `${E2EE_KEYPAIR_FILENAME}.stage` +// Why: Windows cannot replace an open destination; this bounded rollback artifact makes the rename boundary restartable. +export const E2EE_KEYPAIR_BACKUP_FILENAME = `.${E2EE_KEYPAIR_FILENAME}.backup` +export const RELAY_REVOKE_OUTBOX_FILENAME = 'mobile-relay-revoke-outbox.json' +// Why: one bounded crash-resumable identity reset record owns the only +// transaction that may replace the established proof identity. +export const E2EE_IDENTITY_RESET_FILENAME = 'orca-e2ee-identity-reset.json' -// Migrate these together so device tokens and E2EE material never split across dirs. +// Required pairing files migrate together; marker, stage, and rollback follow when present. export const MOBILE_PAIRING_USERDATA_FILES = [ DEVICE_REGISTRY_FILENAME, - E2EE_KEYPAIR_FILENAME + E2EE_KEYPAIR_FILENAME, + E2EE_IDENTITY_MARKER_FILENAME, + E2EE_KEYPAIR_STAGE_FILENAME, + E2EE_KEYPAIR_BACKUP_FILENAME, + RELAY_REVOKE_OUTBOX_FILENAME ] as const diff --git a/src/main/runtime/mobile-pairing-lifecycle-artifact-equivalence.ts b/src/main/runtime/mobile-pairing-lifecycle-artifact-equivalence.ts new file mode 100644 index 00000000000..e6052492059 --- /dev/null +++ b/src/main/runtime/mobile-pairing-lifecycle-artifact-equivalence.ts @@ -0,0 +1,289 @@ +import { lstatSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { loadDeviceRegistryForReset } from './device-registry' +import { + DEVICE_REGISTRY_FILENAME, + E2EE_IDENTITY_MARKER_FILENAME, + E2EE_KEYPAIR_BACKUP_FILENAME, + E2EE_KEYPAIR_FILENAME, + E2EE_KEYPAIR_STAGE_FILENAME, + RELAY_REVOKE_OUTBOX_FILENAME +} from './mobile-pairing-files' +import { + readAndValidateKeypair, + readAndValidateStage, + readIdentityMarker +} from './e2ee-keypair-storage' +import type { IdentityMarker, ValidatedKeypair, ValidatedStage } from './e2ee-keypair-storage' +import { loadRelayRevokeOutboxForReset } from './relay/relay-revoke-outbox' + +export function isValidStagedArtifact( + fileName: string, + stagedPath: string, + stagingDir: string +): boolean { + try { + switch (fileName) { + case DEVICE_REGISTRY_FILENAME: + loadDeviceRegistryForReset(stagingDir) + break + case E2EE_KEYPAIR_FILENAME: + case E2EE_KEYPAIR_BACKUP_FILENAME: + readAndValidateKeypair(stagedPath) + break + case E2EE_IDENTITY_MARKER_FILENAME: + readIdentityMarker(stagedPath) + break + case E2EE_KEYPAIR_STAGE_FILENAME: { + const stage = readAndValidateStage(stagedPath) + return stage.purpose !== 'reset' || stage.publicKeyB64 !== stage.predecessorPublicKeyB64 + } + case RELAY_REVOKE_OUTBOX_FILENAME: + loadRelayRevokeOutboxForReset(stagingDir) + break + default: + return false + } + return true + } catch { + return false + } +} + +export function areEquivalentStagedArtifacts( + fileName: string, + leftPath: string, + leftDir: string, + rightPath: string, + rightDir: string +): boolean { + try { + switch (fileName) { + case E2EE_KEYPAIR_FILENAME: + case E2EE_KEYPAIR_BACKUP_FILENAME: + return sameKeypairLineage( + readAndValidateKeypair(leftPath), + readAndValidateKeypair(rightPath) + ) + case E2EE_IDENTITY_MARKER_FILENAME: + return ( + readIdentityMarker(leftPath).installationId === + readIdentityMarker(rightPath).installationId + ) + case E2EE_KEYPAIR_STAGE_FILENAME: + return sameStageLineage(readAndValidateStage(leftPath), readAndValidateStage(rightPath)) + case DEVICE_REGISTRY_FILENAME: + loadDeviceRegistryForReset(leftDir) + loadDeviceRegistryForReset(rightDir) + return sameJsonRecord(leftPath, rightPath) + case RELAY_REVOKE_OUTBOX_FILENAME: + loadRelayRevokeOutboxForReset(leftDir) + loadRelayRevokeOutboxForReset(rightDir) + return sameJsonRecord(leftPath, rightPath) + default: + return false + } + } catch { + return false + } +} + +/** Rejects valid target identity residue that cannot coexist with the complete staged candidate. */ +export function assertCompatibleIdentityResidue(targetDir: string, stagingDir: string): void { + const target = readIdentityResidue(targetDir) + const staged = readIdentityResidue(stagingDir) + assertIdentityBindings(target, 'canonical mobile pairing target') + assertIdentityBindings(staged, 'canonical mobile pairing staging') + + if (target.active && staged.active && !sameKeypairLineage(target.active, staged.active)) { + throw new Error( + 'Source and canonical mobile pairing data have different E2EE identity ownership' + ) + } + if ( + target.marker && + staged.marker && + target.marker.installationId !== staged.marker.installationId + ) { + throw new Error( + 'Source and canonical mobile pairing data have different installation ownership' + ) + } + if (target.stage && staged.stage && !sameStageLineage(target.stage, staged.stage)) { + throw new Error('Source and canonical mobile pairing data have different reset lineage') + } + assertCrossArtifactBindings(target, staged) +} + +export function isMutableArtifact(fileName: string): boolean { + return fileName === DEVICE_REGISTRY_FILENAME || fileName === RELAY_REVOKE_OUTBOX_FILENAME +} + +type IdentityResidue = { + active: ValidatedKeypair | null + marker: IdentityMarker | null + stage: ValidatedStage | null +} + +function readIdentityResidue(userDataDir: string): IdentityResidue { + return { + active: readOptionalKeypair(userDataDir, E2EE_KEYPAIR_FILENAME), + marker: readOptionalMarker(userDataDir), + stage: readOptionalStage(userDataDir) + } +} + +function readOptionalKeypair(userDataDir: string, fileName: string): ValidatedKeypair | null { + const filePath = join(userDataDir, fileName) + return pathExists(filePath) && isValidStagedArtifact(fileName, filePath, userDataDir) + ? readAndValidateKeypair(filePath) + : null +} + +function readOptionalMarker(userDataDir: string): IdentityMarker | null { + const filePath = join(userDataDir, E2EE_IDENTITY_MARKER_FILENAME) + return pathExists(filePath) && + isValidStagedArtifact(E2EE_IDENTITY_MARKER_FILENAME, filePath, userDataDir) + ? readIdentityMarker(filePath) + : null +} + +function readOptionalStage(userDataDir: string): ValidatedStage | null { + const filePath = join(userDataDir, E2EE_KEYPAIR_STAGE_FILENAME) + return pathExists(filePath) && + isValidStagedArtifact(E2EE_KEYPAIR_STAGE_FILENAME, filePath, userDataDir) + ? readAndValidateStage(filePath) + : null +} + +function assertIdentityBindings(identity: IdentityResidue, label: string): void { + if ( + identity.active?.schema === 'current' && + identity.marker && + identity.active.installationId !== identity.marker.installationId + ) { + throw new Error(`${label} has a divergent installation binding`) + } + if ( + identity.active?.schema === 'current' && + identity.stage && + identity.active.installationId !== identity.stage.installationId + ) { + throw new Error(`${label} has a divergent stage installation binding`) + } + if ( + identity.marker && + identity.stage && + identity.marker.installationId !== identity.stage.installationId + ) { + throw new Error(`${label} has a divergent marker installation binding`) + } +} + +function assertCrossArtifactBindings(target: IdentityResidue, staged: IdentityResidue): void { + if ( + target.active?.schema === 'current' && + staged.marker && + target.active.installationId !== staged.marker.installationId + ) { + throw new Error( + 'Source and canonical mobile pairing data have different installation ownership' + ) + } + if ( + target.marker && + staged.active?.schema === 'current' && + target.marker.installationId !== staged.active.installationId + ) { + throw new Error( + 'Source and canonical mobile pairing data have different installation ownership' + ) + } + if ( + target.active?.schema === 'current' && + staged.stage && + target.active.installationId !== staged.stage.installationId + ) { + throw new Error( + 'Source and canonical mobile pairing data have different installation ownership' + ) + } + if ( + target.marker && + staged.stage && + target.marker.installationId !== staged.stage.installationId + ) { + throw new Error( + 'Source and canonical mobile pairing data have different installation ownership' + ) + } + if ( + staged.active?.schema === 'current' && + target.stage && + staged.active.installationId !== target.stage.installationId + ) { + throw new Error( + 'Source and canonical mobile pairing data have different installation ownership' + ) + } + if ( + staged.marker && + target.stage && + staged.marker.installationId !== target.stage.installationId + ) { + throw new Error( + 'Source and canonical mobile pairing data have different installation ownership' + ) + } +} + +function sameKeypairLineage(left: ValidatedKeypair, right: ValidatedKeypair): boolean { + return ( + Buffer.from(left.publicKey).equals(Buffer.from(right.publicKey)) && + Buffer.from(left.secretKey).equals(Buffer.from(right.secretKey)) && + left.installationId === right.installationId + ) +} + +function sameStageLineage(left: ValidatedStage, right: ValidatedStage): boolean { + return ( + sameKeypairLineage(left, right) && + left.installationId === right.installationId && + left.purpose === right.purpose && + left.transactionId === right.transactionId && + left.predecessorPublicKeyB64 === right.predecessorPublicKeyB64 + ) +} + +function sameJsonRecord(leftPath: string, rightPath: string): boolean { + return ( + JSON.stringify(sortJson(JSON.parse(readFileSync(leftPath, 'utf8')))) === + JSON.stringify(sortJson(JSON.parse(readFileSync(rightPath, 'utf8')))) + ) +} + +function sortJson(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(sortJson) + } + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entry]) => [key, sortJson(entry)]) + ) + } + return value +} + +function pathExists(path: string): boolean { + try { + lstatSync(path) + return true + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return false + } + throw error + } +} diff --git a/src/main/runtime/mobile-pairing-userdata-migration.ts b/src/main/runtime/mobile-pairing-userdata-migration.ts new file mode 100644 index 00000000000..3d6d7be1531 --- /dev/null +++ b/src/main/runtime/mobile-pairing-userdata-migration.ts @@ -0,0 +1,305 @@ +import { lstatSync, mkdirSync, realpathSync, rmSync, statSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { + fsyncDirectorySync, + removeFileDurableSync, + renameFileDurableSync +} from '../../shared/durable-file-write' +import { hardenSecurePath } from '../../shared/secure-file' +import { loadDeviceRegistryForReset } from './device-registry' +import { + DEVICE_REGISTRY_FILENAME, + E2EE_KEYPAIR_BACKUP_FILENAME, + E2EE_KEYPAIR_FILENAME, + RELAY_REVOKE_OUTBOX_FILENAME, + MOBILE_PAIRING_USERDATA_FILES +} from './mobile-pairing-files' +import { validateE2EEIdentityStorage } from './e2ee-keypair' +import { + inspectE2EEIdentityStorage, + type E2EEIdentityStorageInspection +} from './e2ee-keypair-storage' +import { loadRelayRevokeOutboxForReset } from './relay/relay-revoke-outbox' +import { + areEquivalentStagedArtifacts, + assertCompatibleIdentityResidue, + isMutableArtifact, + isValidStagedArtifact +} from './mobile-pairing-lifecycle-artifact-equivalence' +import { prepareMobilePairingStaging } from './mobile-pairing-userdata-staging' + +const MIGRATION_STAGE_DIRECTORY = '.orca-mobile-pairing-migration' + +/** Moves the pairing lifecycle as one validated, restartable publication. */ +export function migrateMobilePairingUserdata( + sourceUserDataDir: string, + targetUserDataDir: string +): void { + const stagingDir = join(targetUserDataDir, MIGRATION_STAGE_DIRECTORY) + assertOwnedStagingDirectory(stagingDir, targetUserDataDir) + if (sameDirectory(sourceUserDataDir, targetUserDataDir)) { + return + } + + let sourceFiles = presentLifecycleFiles(sourceUserDataDir) + let targetFiles = presentLifecycleFiles(targetUserDataDir) + let stagingFiles = presentLifecycleFiles(stagingDir) + + if (sourceFiles.size > 0) { + assertLifecycleFiles(sourceUserDataDir) + } + sourceFiles = presentLifecycleFiles(sourceUserDataDir) + + // A target-prefix cut may leave a syntactically valid but incomplete record. + const targetEstablished = targetFiles.size > 0 ? tryAssertLifecycleFiles(targetUserDataDir) : null + targetFiles = presentLifecycleFiles(targetUserDataDir) + stagingFiles = presentLifecycleFiles(stagingDir) + + if (stagingFiles.size > 0 || sourceFiles.size > 0) { + const expectedFiles = new Set([...sourceFiles, ...targetFiles, ...stagingFiles]) + if (expectedFiles.size === 0) { + return + } + prepareMobilePairingStaging( + sourceUserDataDir, + targetUserDataDir, + stagingDir, + expectedFiles, + sourceFiles, + sourceFiles.size > 0, + targetEstablished !== null, + assertOwnedStagingDirectory + ) + assertOwnedStagingDirectory(stagingDir, targetUserDataDir) + assertLifecycleFiles(stagingDir) + assertActivationCompatibility( + targetUserDataDir, + stagingDir, + expectedFiles, + targetEstablished !== null + ) + activateStagedLifecycle( + targetUserDataDir, + stagingDir, + expectedFiles, + targetEstablished !== null + ) + assertOwnedStagingDirectory(stagingDir, targetUserDataDir) + assertLifecycleFiles(targetUserDataDir) + assertOwnedStagingDirectory(stagingDir, targetUserDataDir) + rmSync(stagingDir, { recursive: true, force: true }) + fsyncDirectorySync(targetUserDataDir) + return + } + + if (targetFiles.size > 0) { + assertLifecycleFiles(targetUserDataDir) + } +} + +function activateStagedLifecycle( + targetDir: string, + stagingDir: string, + expectedFiles: ReadonlySet, + preserveTargetMutable: boolean +): void { + assertOwnedStagingDirectory(stagingDir, targetDir) + mkdirSync(targetDir, { recursive: true, mode: 0o700 }) + hardenSecurePath(targetDir, { isDirectory: true, platform: process.platform, sync: true }) + const movedFiles: string[] = [] + try { + for (const fileName of expectedFiles) { + assertOwnedStagingDirectory(stagingDir, targetDir) + const stagedPath = join(stagingDir, fileName) + const targetPath = join(targetDir, fileName) + if (!pathExists(stagedPath)) { + if (pathExists(targetPath)) { + assertRegularFile(targetPath, `target ${fileName}`) + if (isValidStagedArtifact(fileName, targetPath, targetDir)) { + continue + } + // A complete candidate may intentionally omit malformed optional residue. + assertOwnedStagingDirectory(stagingDir, targetDir) + removeFileDurableSync(targetPath) + continue + } + if (fileName === E2EE_KEYPAIR_BACKUP_FILENAME) { + continue + } + throw new Error(`Canonical mobile pairing staging is missing ${fileName}`) + } + if (pathExists(targetPath)) { + assertRegularFile(targetPath, `target ${fileName}`) + const targetValid = isValidStagedArtifact(fileName, targetPath, targetDir) + if ( + targetValid && + areEquivalentStagedArtifacts(fileName, stagedPath, stagingDir, targetPath, targetDir) + ) { + assertOwnedStagingDirectory(stagingDir, targetDir) + removeFileDurableSync(stagedPath) + continue + } + if (targetValid && isMutableArtifact(fileName) && preserveTargetMutable) { + assertOwnedStagingDirectory(stagingDir, targetDir) + removeFileDurableSync(stagedPath) + continue + } + if (targetValid) { + throw new Error(`Canonical mobile pairing target conflicts for ${fileName}`) + } + assertOwnedStagingDirectory(stagingDir, targetDir) + removeFileDurableSync(targetPath) + } + assertOwnedStagingDirectory(stagingDir, targetDir) + renameFileDurableSync(stagedPath, targetPath) + movedFiles.push(fileName) + } + } catch (error) { + for (const fileName of movedFiles) { + try { + assertOwnedStagingDirectory(stagingDir, targetDir) + renameFileDurableSync(join(targetDir, fileName), join(stagingDir, fileName)) + } catch { + // Keep the exact stage recoverable when rollback itself is interrupted. + } + } + throw error + } +} + +function assertActivationCompatibility( + targetDir: string, + stagingDir: string, + expectedFiles: ReadonlySet, + preserveTargetMutable: boolean +): void { + assertCompatibleIdentityResidue(targetDir, stagingDir) + for (const fileName of expectedFiles) { + const targetPath = join(targetDir, fileName) + const stagedPath = join(stagingDir, fileName) + if (!pathExists(targetPath) || !pathExists(stagedPath)) { + continue + } + assertRegularFile(targetPath, `target ${fileName}`) + assertRegularFile(stagedPath, `staged ${fileName}`) + const targetValid = isValidStagedArtifact(fileName, targetPath, targetDir) + const stagedValid = isValidStagedArtifact(fileName, stagedPath, stagingDir) + if ( + targetValid && + stagedValid && + (!isMutableArtifact(fileName) || !preserveTargetMutable) && + !areEquivalentStagedArtifacts(fileName, targetPath, targetDir, stagedPath, stagingDir) + ) { + throw new Error( + fileName === E2EE_KEYPAIR_FILENAME + ? 'Source and canonical mobile pairing data have different E2EE identity ownership' + : `Canonical mobile pairing target conflicts for ${fileName}` + ) + } + } +} + +function assertLifecycleFiles(userDataDir: string): E2EEIdentityStorageInspection { + const files = presentLifecycleFiles(userDataDir) + if (files.has(DEVICE_REGISTRY_FILENAME)) { + loadDeviceRegistryForReset(userDataDir) + } + if (files.has(RELAY_REVOKE_OUTBOX_FILENAME)) { + loadRelayRevokeOutboxForReset(userDataDir) + } + const inspection = inspectE2EEIdentityStorage(userDataDir, { + preserveReplacementBackup: true + }) + validateE2EEIdentityStorage(userDataDir, { preserveReplacementBackup: true }) + if (files.size > 0 && !inspection.active && !inspection.stage) { + throw new Error('Canonical mobile pairing data is missing a valid E2EE identity') + } + return inspection +} + +function tryAssertLifecycleFiles(userDataDir: string): E2EEIdentityStorageInspection | null { + try { + return assertLifecycleFiles(userDataDir) + } catch { + return null + } +} + +function presentLifecycleFiles(userDataDir: string): Set { + const files = new Set() + for (const fileName of MOBILE_PAIRING_USERDATA_FILES) { + if (pathExists(join(userDataDir, fileName))) { + files.add(fileName) + } + } + return files +} + +function assertOwnedStagingDirectory(stagingDir: string, targetDir: string): void { + let stagingStats + try { + stagingStats = lstatSync(stagingDir) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return + } + throw new Error('Canonical mobile pairing staging directory is unavailable') + } + if (stagingStats.isSymbolicLink() || !stagingStats.isDirectory()) { + throw new Error('Canonical mobile pairing staging directory is invalid') + } + + let resolvedStagingDir: string + let resolvedTargetDir: string + try { + resolvedStagingDir = realpathSync(stagingDir) + resolvedTargetDir = realpathSync(targetDir) + } catch { + throw new Error('Canonical mobile pairing staging directory is unavailable') + } + if (pathKey(resolvedStagingDir) !== pathKey(join(resolvedTargetDir, MIGRATION_STAGE_DIRECTORY))) { + throw new Error('Canonical mobile pairing staging directory is invalid') + } +} + +function sameDirectory(left: string, right: string): boolean { + return directoryKey(left) === directoryKey(right) +} + +function directoryKey(path: string): string { + const resolved = resolve(path) + try { + return pathKey(realpathSync(resolved)) + } catch { + return pathKey(resolved) + } +} + +function pathKey(path: string): string { + return process.platform === 'win32' ? path.toLocaleLowerCase('en-US') : path +} + +function pathExists(path: string): boolean { + try { + lstatSync(path) + return true + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return false + } + throw error + } +} + +function assertRegularFile(path: string, field: string): void { + try { + if (lstatSync(path).isSymbolicLink() || !statSync(path).isFile()) { + throw new Error(`${field} is invalid`) + } + } catch (error) { + if (error instanceof Error && error.message === `${field} is invalid`) { + throw error + } + throw new Error(`${field} is unavailable`) + } +} diff --git a/src/main/runtime/mobile-pairing-userdata-path.test.ts b/src/main/runtime/mobile-pairing-userdata-path.test.ts index 416ad545dd3..3b8e161baef 100644 --- a/src/main/runtime/mobile-pairing-userdata-path.test.ts +++ b/src/main/runtime/mobile-pairing-userdata-path.test.ts @@ -1,10 +1,27 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync +} from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' +import nacl from 'tweetnacl' // Import from the production source of truth so a filename rename can't silently // pass these tests against stale names. -import { DEVICE_REGISTRY_FILENAME, E2EE_KEYPAIR_FILENAME } from './mobile-pairing-files' +import { + DEVICE_REGISTRY_FILENAME, + E2EE_KEYPAIR_BACKUP_FILENAME, + E2EE_IDENTITY_MARKER_FILENAME, + E2EE_KEYPAIR_FILENAME, + E2EE_KEYPAIR_STAGE_FILENAME, + RELAY_REVOKE_OUTBOX_FILENAME +} from './mobile-pairing-files' // Mutable userData the electron mock resolves. We flip it mid-test to simulate // app.setName('Orca') changing how app.getPath('userData') resolves (e.g. from @@ -69,9 +86,9 @@ describe('mobile pairing userData path stability', () => { const { loadOrCreateE2EEKeypair } = await import('./e2ee-keypair') // Mirrors OrcaRuntimeRpcServer.start(): both read from the same userDataPath. + loadOrCreateE2EEKeypair(getCanonicalUserDataPath()) const registry = new DeviceRegistry(getCanonicalUserDataPath()) registry.addDevice('iPhone') - loadOrCreateE2EEKeypair(getCanonicalUserDataPath()) // Pairing credentials land beside orca-data.json so they survive restarts/updates. expect(existsSync(join(canonicalDir, DEVICE_REGISTRY_FILENAME))).toBe(true) @@ -101,10 +118,11 @@ describe('mobile pairing userData path stability', () => { lastSeenAt: 2 } ]) + const lateKeys = nacl.box.keyPair() const lateKeypair = JSON.stringify({ v: 1, - publicKeyB64: Buffer.from(new Uint8Array(32).fill(1)).toString('base64'), - secretKeyB64: Buffer.from(new Uint8Array(32).fill(2)).toString('base64') + publicKeyB64: Buffer.from(lateKeys.publicKey).toString('base64'), + secretKeyB64: Buffer.from(lateKeys.secretKey).toString('base64') }) writeFileSync(join(lateDir, DEVICE_REGISTRY_FILENAME), lateDevices) writeFileSync(join(lateDir, E2EE_KEYPAIR_FILENAME), lateKeypair) @@ -113,6 +131,8 @@ describe('mobile pairing userData path stability', () => { expect(readFileSync(join(canonicalDir, DEVICE_REGISTRY_FILENAME), 'utf-8')).toBe(lateDevices) expect(readFileSync(join(canonicalDir, E2EE_KEYPAIR_FILENAME), 'utf-8')).toBe(lateKeypair) + expect(readFileSync(join(lateDir, DEVICE_REGISTRY_FILENAME), 'utf-8')).toBe(lateDevices) + expect(readFileSync(join(lateDir, E2EE_KEYPAIR_FILENAME), 'utf-8')).toBe(lateKeypair) const { DeviceRegistry } = await import('./device-registry') const registry = new DeviceRegistry(getCanonicalUserDataPath()) @@ -123,6 +143,353 @@ describe('mobile pairing userData path stability', () => { expect(readFileSync(join(canonicalDir, DEVICE_REGISTRY_FILENAME), 'utf-8')).toBe(lateDevices) }) + it('migrates valid unpaired identities and first-install recovery states without a registry', async () => { + const { migrateMobilePairingUserdata } = await import('./mobile-pairing-userdata-migration') + const { loadOrCreateE2EEKeypair } = await import('./e2ee-keypair') + const record = (keys: nacl.BoxKeyPair): Record => ({ + v: 2, + publicKeyB64: Buffer.from(keys.publicKey).toString('base64'), + secretKeyB64: Buffer.from(keys.secretKey).toString('base64'), + installationId: 'first-install-migration-0001' + }) + const writeStage = (directory: string, withMarker: boolean, purpose = 'first-install') => { + mkdirSync(directory) + const keys = nacl.box.keyPair() + writeFileSync( + join(directory, E2EE_KEYPAIR_STAGE_FILENAME), + JSON.stringify({ ...record(keys), purpose }) + ) + if (withMarker) { + writeFileSync( + join(directory, E2EE_IDENTITY_MARKER_FILENAME), + JSON.stringify({ v: 1, installationId: 'first-install-migration-0001' }) + ) + } + return keys + } + + const unpairedSource = join(root, 'unpaired-source') + const unpairedTarget = join(root, 'unpaired-target') + mkdirSync(unpairedSource) + mkdirSync(unpairedTarget) + const active = loadOrCreateE2EEKeypair(unpairedSource) + migrateMobilePairingUserdata(unpairedSource, unpairedTarget) + expect( + JSON.parse(readFileSync(join(unpairedTarget, E2EE_KEYPAIR_FILENAME), 'utf8')).publicKeyB64 + ).toBe(active.publicKeyB64) + expect(existsSync(join(unpairedTarget, DEVICE_REGISTRY_FILENAME))).toBe(false) + + for (const withMarker of [false, true]) { + const source = join(root, withMarker ? 'marker-stage-source' : 'stage-source') + const target = join(root, withMarker ? 'marker-stage-target' : 'stage-target') + writeStage(source, withMarker) + mkdirSync(target) + migrateMobilePairingUserdata(source, target) + expect(existsSync(join(target, E2EE_KEYPAIR_STAGE_FILENAME))).toBe(true) + expect(existsSync(join(target, E2EE_IDENTITY_MARKER_FILENAME))).toBe(withMarker) + expect(existsSync(join(target, DEVICE_REGISTRY_FILENAME))).toBe(false) + } + + const invalidStates: [string, () => void][] = [ + [ + 'marker-only', + () => { + const directory = join(root, 'marker-only-source') + mkdirSync(directory) + writeFileSync( + join(directory, E2EE_IDENTITY_MARKER_FILENAME), + JSON.stringify({ v: 1, installationId: 'marker-only-migration-0001' }) + ) + const target = join(root, 'marker-only-target') + mkdirSync(target) + expect(() => migrateMobilePairingUserdata(directory, target)).toThrow() + } + ], + [ + 'keypair-only', + () => { + const directory = join(root, 'keypair-only-source') + mkdirSync(directory) + writeFileSync( + join(directory, E2EE_KEYPAIR_FILENAME), + JSON.stringify(record(nacl.box.keyPair())) + ) + const target = join(root, 'keypair-only-target') + mkdirSync(target) + expect(() => migrateMobilePairingUserdata(directory, target)).toThrow() + } + ], + [ + 'reset-stage-without-active', + () => { + const directory = join(root, 'reset-stage-source') + const target = join(root, 'reset-stage-target') + const keys = writeStage(directory, false, 'reset') + const predecessor = nacl.box.keyPair() + const stagePath = join(directory, E2EE_KEYPAIR_STAGE_FILENAME) + const stage = JSON.parse(readFileSync(stagePath, 'utf8')) as Record + stage.predecessorPublicKeyB64 = Buffer.from(predecessor.publicKey).toString('base64') + stage.transactionId = 'reset-stage-migration-0001' + stage.publicKeyB64 = Buffer.from(keys.publicKey).toString('base64') + writeFileSync(stagePath, JSON.stringify(stage)) + mkdirSync(target) + expect(() => migrateMobilePairingUserdata(directory, target)).toThrow('active identity') + } + ] + ] + for (const [name, check] of invalidStates) { + expect(check, name).not.toThrow() + } + }) + + it('migrates a registry-independent markerless legacy identity', async () => { + const { migrateMobilePairingUserdata } = await import('./mobile-pairing-userdata-migration') + const source = join(root, 'markerless-legacy-source') + const target = join(root, 'markerless-legacy-target') + mkdirSync(source) + mkdirSync(target) + const keys = nacl.box.keyPair() + const legacy = JSON.stringify({ + v: 1, + publicKeyB64: Buffer.from(keys.publicKey).toString('base64'), + secretKeyB64: Buffer.from(keys.secretKey).toString('base64') + }) + writeFileSync(join(source, E2EE_KEYPAIR_FILENAME), legacy) + + migrateMobilePairingUserdata(source, target) + + expect(readFileSync(join(target, E2EE_KEYPAIR_FILENAME), 'utf8')).toBe(legacy) + expect(existsSync(join(target, DEVICE_REGISTRY_FILENAME))).toBe(false) + expect(readFileSync(join(source, E2EE_KEYPAIR_FILENAME), 'utf8')).toBe(legacy) + }) + + it.each(['self-link', 'redirect', 'file'] as const)( + 'rejects a %s migration staging directory before copying credentials', + async (kind) => { + appState.userData = canonicalDir + const { initDataPath, migrateMobilePairingDataToCanonicalUserDataPath } = + await import('../persistence') + initDataPath() + + const devices = JSON.stringify([ + { + deviceId: 'late-phone', + name: 'iPhone', + token: 'late-token', + scope: 'mobile', + pairedAt: 1, + lastSeenAt: 2 + } + ]) + const keys = nacl.box.keyPair() + const keypair = JSON.stringify({ + v: 1, + publicKeyB64: Buffer.from(keys.publicKey).toString('base64'), + secretKeyB64: Buffer.from(keys.secretKey).toString('base64') + }) + writeFileSync(join(lateDir, DEVICE_REGISTRY_FILENAME), devices) + writeFileSync(join(lateDir, E2EE_KEYPAIR_FILENAME), keypair) + + const redirectDir = join(root, 'migration-redirect') + if (kind === 'redirect') { + mkdirSync(redirectDir) + } + const stagingDir = join(canonicalDir, '.orca-mobile-pairing-migration') + if (kind === 'file') { + writeFileSync(stagingDir, 'not-a-directory') + } else { + symlinkSync( + kind === 'self-link' ? canonicalDir : redirectDir, + stagingDir, + process.platform === 'win32' ? 'junction' : 'dir' + ) + } + + expect(() => migrateMobilePairingDataToCanonicalUserDataPath(lateDir)).toThrow( + 'staging directory is invalid' + ) + expect(lstatSync(stagingDir).isSymbolicLink()).toBe(kind !== 'file') + expect(existsSync(join(canonicalDir, DEVICE_REGISTRY_FILENAME))).toBe(false) + expect(existsSync(join(canonicalDir, E2EE_KEYPAIR_FILENAME))).toBe(false) + expect(existsSync(join(redirectDir, DEVICE_REGISTRY_FILENAME))).toBe(false) + expect(existsSync(join(redirectDir, E2EE_KEYPAIR_FILENAME))).toBe(false) + expect(readFileSync(join(lateDir, DEVICE_REGISTRY_FILENAME), 'utf8')).toBe(devices) + expect(readFileSync(join(lateDir, E2EE_KEYPAIR_FILENAME), 'utf8')).toBe(keypair) + } + ) + + it('accepts an owned regular staging directory and remains restart-safe', async () => { + appState.userData = canonicalDir + const { initDataPath, migrateMobilePairingDataToCanonicalUserDataPath } = + await import('../persistence') + initDataPath() + + const devices = JSON.stringify([ + { + deviceId: 'late-phone', + name: 'iPhone', + token: 'late-token', + scope: 'mobile', + pairedAt: 1, + lastSeenAt: 2 + } + ]) + const keys = nacl.box.keyPair() + const keypair = JSON.stringify({ + v: 1, + publicKeyB64: Buffer.from(keys.publicKey).toString('base64'), + secretKeyB64: Buffer.from(keys.secretKey).toString('base64') + }) + writeFileSync(join(lateDir, DEVICE_REGISTRY_FILENAME), devices) + writeFileSync(join(lateDir, E2EE_KEYPAIR_FILENAME), keypair) + + const stagingDir = join(canonicalDir, '.orca-mobile-pairing-migration') + mkdirSync(stagingDir) + writeFileSync(join(stagingDir, DEVICE_REGISTRY_FILENAME), devices) + writeFileSync(join(stagingDir, E2EE_KEYPAIR_FILENAME), keypair) + + migrateMobilePairingDataToCanonicalUserDataPath(lateDir) + expect(readFileSync(join(canonicalDir, DEVICE_REGISTRY_FILENAME), 'utf8')).toBe(devices) + expect(readFileSync(join(canonicalDir, E2EE_KEYPAIR_FILENAME), 'utf8')).toBe(keypair) + expect(existsSync(stagingDir)).toBe(false) + + migrateMobilePairingDataToCanonicalUserDataPath(lateDir) + expect(readFileSync(join(canonicalDir, DEVICE_REGISTRY_FILENAME), 'utf8')).toBe(devices) + expect(readFileSync(join(canonicalDir, E2EE_KEYPAIR_FILENAME), 'utf8')).toBe(keypair) + }) + + it('repairs malformed migration residue while rejecting a valid divergent staged artifact', async () => { + const { migrateMobilePairingUserdata } = await import('./mobile-pairing-userdata-migration') + const source = join(root, 'repair-source') + const target = join(root, 'repair-target') + mkdirSync(source) + mkdirSync(target) + const sourceKeys = nacl.box.keyPair() + const sourceKeypair = JSON.stringify({ + v: 1, + publicKeyB64: Buffer.from(sourceKeys.publicKey).toString('base64'), + secretKeyB64: Buffer.from(sourceKeys.secretKey).toString('base64') + }) + const registry = '[]' + writeFileSync(join(source, DEVICE_REGISTRY_FILENAME), registry) + writeFileSync(join(source, E2EE_KEYPAIR_FILENAME), sourceKeypair) + const staging = join(target, '.orca-mobile-pairing-migration') + mkdirSync(staging) + const stagedKeypair = join(staging, E2EE_KEYPAIR_FILENAME) + writeFileSync(stagedKeypair, '{') + writeFileSync(`${stagedKeypair}.tmp`, 'partial-copy') + + migrateMobilePairingUserdata(source, target) + expect(readFileSync(join(target, E2EE_KEYPAIR_FILENAME), 'utf8')).toBe(sourceKeypair) + expect(existsSync(join(target, DEVICE_REGISTRY_FILENAME))).toBe(true) + expect(existsSync(staging)).toBe(false) + + const divergentSource = join(root, 'divergent-source') + const divergentTarget = join(root, 'divergent-target') + mkdirSync(divergentSource) + mkdirSync(divergentTarget) + const divergentKeys = nacl.box.keyPair() + const divergentKeypair = JSON.stringify({ + v: 1, + publicKeyB64: Buffer.from(divergentKeys.publicKey).toString('base64'), + secretKeyB64: Buffer.from(divergentKeys.secretKey).toString('base64') + }) + writeFileSync(join(divergentSource, DEVICE_REGISTRY_FILENAME), registry) + writeFileSync(join(divergentSource, E2EE_KEYPAIR_FILENAME), divergentKeypair) + const divergentStaging = join(divergentTarget, '.orca-mobile-pairing-migration') + mkdirSync(divergentStaging) + writeFileSync(join(divergentStaging, DEVICE_REGISTRY_FILENAME), registry) + writeFileSync(join(divergentStaging, E2EE_KEYPAIR_FILENAME), sourceKeypair) + + expect(() => migrateMobilePairingUserdata(divergentSource, divergentTarget)).toThrow( + 'source changed during migration' + ) + expect(existsSync(join(divergentTarget, E2EE_KEYPAIR_FILENAME))).toBe(false) + expect(readFileSync(join(divergentStaging, E2EE_KEYPAIR_FILENAME), 'utf8')).toBe(sourceKeypair) + }) + + it('does not promote registry-only staged residue without a valid E2EE identity', async () => { + const { migrateMobilePairingUserdata } = await import('./mobile-pairing-userdata-migration') + const staging = join(canonicalDir, '.orca-mobile-pairing-migration') + mkdirSync(staging) + writeFileSync(join(staging, DEVICE_REGISTRY_FILENAME), '[]') + + expect(() => migrateMobilePairingUserdata(lateDir, canonicalDir)).toThrow('valid E2EE identity') + expect(existsSync(join(canonicalDir, DEVICE_REGISTRY_FILENAME))).toBe(false) + expect(existsSync(join(staging, DEVICE_REGISTRY_FILENAME))).toBe(true) + }) + + it('repairs a stage-only target-prefix cut without a source directory', async () => { + const { migrateMobilePairingUserdata } = await import('./mobile-pairing-userdata-migration') + const source = join(root, 'stage-only-source') + const target = join(root, 'stage-only-target') + mkdirSync(source) + mkdirSync(target) + const keys = nacl.box.keyPair() + const installationId = 'stage-only-recovery-0001' + const active = JSON.stringify({ + v: 2, + publicKeyB64: Buffer.from(keys.publicKey).toString('base64'), + secretKeyB64: Buffer.from(keys.secretKey).toString('base64'), + installationId + }) + const marker = JSON.stringify({ v: 1, installationId }) + const staging = join(target, '.orca-mobile-pairing-migration') + mkdirSync(staging) + writeFileSync(join(target, E2EE_KEYPAIR_FILENAME), active) + writeFileSync(join(staging, E2EE_IDENTITY_MARKER_FILENAME), marker) + + migrateMobilePairingUserdata(source, target) + + expect(readFileSync(join(target, E2EE_KEYPAIR_FILENAME), 'utf8')).toBe(active) + expect(readFileSync(join(target, E2EE_IDENTITY_MARKER_FILENAME), 'utf8')).toBe(marker) + expect(existsSync(staging)).toBe(false) + }) + + it('fails closed on divergent valid stage-only target and staged identities', async () => { + const { migrateMobilePairingUserdata } = await import('./mobile-pairing-userdata-migration') + const source = join(root, 'stage-only-divergent-source') + const target = join(root, 'stage-only-divergent-target') + mkdirSync(source) + mkdirSync(target) + const targetKeys = nacl.box.keyPair() + const stagedKeys = nacl.box.keyPair() + const targetId = 'stage-only-target-0001' + const stagedId = 'stage-only-stage-0001' + writeFileSync( + join(target, E2EE_KEYPAIR_FILENAME), + JSON.stringify({ + v: 2, + publicKeyB64: Buffer.from(targetKeys.publicKey).toString('base64'), + secretKeyB64: Buffer.from(targetKeys.secretKey).toString('base64'), + installationId: targetId + }) + ) + writeFileSync( + join(target, E2EE_IDENTITY_MARKER_FILENAME), + JSON.stringify({ v: 1, installationId: targetId }) + ) + const staging = join(target, '.orca-mobile-pairing-migration') + mkdirSync(staging) + writeFileSync( + join(staging, E2EE_KEYPAIR_FILENAME), + JSON.stringify({ + v: 2, + publicKeyB64: Buffer.from(stagedKeys.publicKey).toString('base64'), + secretKeyB64: Buffer.from(stagedKeys.secretKey).toString('base64'), + installationId: stagedId + }) + ) + writeFileSync( + join(staging, E2EE_IDENTITY_MARKER_FILENAME), + JSON.stringify({ v: 1, installationId: stagedId }) + ) + + expect(() => migrateMobilePairingUserdata(source, target)).toThrow('target conflicts') + expect(existsSync(join(target, E2EE_KEYPAIR_FILENAME))).toBe(true) + expect(existsSync(staging)).toBe(true) + }) + it('skips legacy migration when only part of the canonical credential pair exists', async () => { appState.userData = canonicalDir const { initDataPath, migrateMobilePairingDataToCanonicalUserDataPath } = @@ -140,24 +507,220 @@ describe('mobile pairing userData path stability', () => { lastSeenAt: 2 } ]) + const lateKeys = nacl.box.keyPair() const lateKeypair = JSON.stringify({ v: 1, - publicKeyB64: Buffer.from(new Uint8Array(32).fill(1)).toString('base64'), - secretKeyB64: Buffer.from(new Uint8Array(32).fill(2)).toString('base64') + publicKeyB64: Buffer.from(lateKeys.publicKey).toString('base64'), + secretKeyB64: Buffer.from(lateKeys.secretKey).toString('base64') }) + const canonicalKeys = nacl.box.keyPair() const canonicalKeypair = JSON.stringify({ v: 1, - publicKeyB64: Buffer.from(new Uint8Array(32).fill(3)).toString('base64'), - secretKeyB64: Buffer.from(new Uint8Array(32).fill(4)).toString('base64') + publicKeyB64: Buffer.from(canonicalKeys.publicKey).toString('base64'), + secretKeyB64: Buffer.from(canonicalKeys.secretKey).toString('base64') }) writeFileSync(join(lateDir, DEVICE_REGISTRY_FILENAME), lateDevices) writeFileSync(join(lateDir, E2EE_KEYPAIR_FILENAME), lateKeypair) writeFileSync(join(canonicalDir, E2EE_KEYPAIR_FILENAME), canonicalKeypair) - migrateMobilePairingDataToCanonicalUserDataPath(appState.userData) + expect(() => migrateMobilePairingDataToCanonicalUserDataPath(appState.userData)).toThrow( + 'different E2EE identity ownership' + ) expect(existsSync(join(canonicalDir, DEVICE_REGISTRY_FILENAME))).toBe(false) expect(readFileSync(join(canonicalDir, E2EE_KEYPAIR_FILENAME), 'utf-8')).toBe(canonicalKeypair) + expect(readFileSync(join(lateDir, DEVICE_REGISTRY_FILENAME), 'utf-8')).toBe(lateDevices) + expect(readFileSync(join(lateDir, E2EE_KEYPAIR_FILENAME), 'utf-8')).toBe(lateKeypair) + }) + + it('migrates and strictly validates the relay revoke outbox with the lifecycle', async () => { + appState.userData = canonicalDir + const { initDataPath, migrateMobilePairingDataToCanonicalUserDataPath } = + await import('../persistence') + initDataPath() + + appState.userData = lateDir + const devices = JSON.stringify([ + { + deviceId: 'late-phone', + name: 'iPhone', + token: 'late-token', + scope: 'mobile', + pairedAt: 1, + lastSeenAt: 2 + } + ]) + const keys = nacl.box.keyPair() + const keypair = JSON.stringify({ + v: 1, + publicKeyB64: Buffer.from(keys.publicKey).toString('base64'), + secretKeyB64: Buffer.from(keys.secretKey).toString('base64') + }) + const outbox = JSON.stringify([ + { + relayHostId: 'relay-host', + relayDeviceId: 'late-phone', + ownerIdentityKey: 'owner-profile', + reqId: 'revoke-1', + createdAt: 10 + } + ]) + writeFileSync(join(lateDir, DEVICE_REGISTRY_FILENAME), devices) + writeFileSync(join(lateDir, E2EE_KEYPAIR_FILENAME), keypair) + writeFileSync(join(lateDir, RELAY_REVOKE_OUTBOX_FILENAME), outbox) + + migrateMobilePairingDataToCanonicalUserDataPath(lateDir) + + expect(readFileSync(join(canonicalDir, RELAY_REVOKE_OUTBOX_FILENAME), 'utf8')).toBe(outbox) + expect(readFileSync(join(lateDir, RELAY_REVOKE_OUTBOX_FILENAME), 'utf8')).toBe(outbox) + + const divergentOutbox = JSON.stringify([ + { + relayHostId: 'relay-host-2', + relayDeviceId: 'late-phone', + ownerIdentityKey: 'owner-profile', + reqId: 'revoke-2', + createdAt: 11 + } + ]) + writeFileSync(join(lateDir, RELAY_REVOKE_OUTBOX_FILENAME), divergentOutbox) + migrateMobilePairingDataToCanonicalUserDataPath(lateDir) + expect(readFileSync(join(canonicalDir, RELAY_REVOKE_OUTBOX_FILENAME), 'utf8')).toBe(outbox) + }) + + it('rejects complete source and canonical lifecycles with different key material', async () => { + appState.userData = canonicalDir + const { initDataPath, migrateMobilePairingDataToCanonicalUserDataPath } = + await import('../persistence') + initDataPath() + + const sourceKeys = nacl.box.keyPair() + const targetKeys = nacl.box.keyPair() + const registry = JSON.stringify([ + { + deviceId: 'phone', + name: 'iPhone', + token: 'token', + scope: 'mobile', + pairedAt: 1, + lastSeenAt: 2 + } + ]) + const record = (keys: nacl.BoxKeyPair): string => + JSON.stringify({ + v: 1, + publicKeyB64: Buffer.from(keys.publicKey).toString('base64'), + secretKeyB64: Buffer.from(keys.secretKey).toString('base64') + }) + writeFileSync(join(lateDir, DEVICE_REGISTRY_FILENAME), registry) + writeFileSync(join(lateDir, E2EE_KEYPAIR_FILENAME), record(sourceKeys)) + writeFileSync(join(canonicalDir, DEVICE_REGISTRY_FILENAME), registry) + const targetRecord = record(targetKeys) + writeFileSync(join(canonicalDir, E2EE_KEYPAIR_FILENAME), targetRecord) + + expect(() => migrateMobilePairingDataToCanonicalUserDataPath(lateDir)).toThrow( + 'different E2EE identity ownership' + ) + expect(readFileSync(join(canonicalDir, E2EE_KEYPAIR_FILENAME), 'utf8')).toBe(targetRecord) + }) + + it('rejects complete current lifecycles with different installation lineage', async () => { + appState.userData = canonicalDir + const { initDataPath, migrateMobilePairingDataToCanonicalUserDataPath } = + await import('../persistence') + initDataPath() + + const keys = nacl.box.keyPair() + const keypair = JSON.stringify({ + v: 2, + publicKeyB64: Buffer.from(keys.publicKey).toString('base64'), + secretKeyB64: Buffer.from(keys.secretKey).toString('base64') + }) + const registry = JSON.stringify([ + { + deviceId: 'phone', + name: 'iPhone', + token: 'token', + scope: 'mobile', + pairedAt: 1, + lastSeenAt: 2 + } + ]) + const writeCurrentLifecycle = (directory: string, installationId: string): string => { + const record = JSON.stringify({ ...JSON.parse(keypair), installationId }) + writeFileSync(join(directory, DEVICE_REGISTRY_FILENAME), registry) + writeFileSync(join(directory, E2EE_KEYPAIR_FILENAME), record) + writeFileSync( + join(directory, E2EE_IDENTITY_MARKER_FILENAME), + JSON.stringify({ v: 1, installationId }) + ) + return record + } + writeCurrentLifecycle(lateDir, 'source-installation-0001') + const targetRecord = writeCurrentLifecycle(canonicalDir, 'target-installation-0001') + + expect(() => migrateMobilePairingDataToCanonicalUserDataPath(lateDir)).toThrow( + 'different E2EE identity ownership' + ) + expect(readFileSync(join(canonicalDir, E2EE_KEYPAIR_FILENAME), 'utf8')).toBe(targetRecord) + }) + + it('moves marker and an in-progress successor stage without activating the stage', async () => { + appState.userData = canonicalDir + const { + initDataPath, + getCanonicalUserDataPath, + migrateMobilePairingDataToCanonicalUserDataPath + } = await import('../persistence') + initDataPath() + + appState.userData = lateDir + const { loadOrCreateE2EEKeypair, publishE2EEKeypairResetSuccessor } = + await import('./e2ee-keypair') + const { DeviceRegistry } = await import('./device-registry') + const original = loadOrCreateE2EEKeypair(lateDir) + new DeviceRegistry(lateDir).addDevice('phone') + writeFileSync( + join(lateDir, E2EE_KEYPAIR_BACKUP_FILENAME), + readFileSync(join(lateDir, E2EE_KEYPAIR_FILENAME)) + ) + const marker = JSON.parse( + readFileSync(join(lateDir, E2EE_IDENTITY_MARKER_FILENAME), 'utf8') + ) as { installationId: string } + const successor = nacl.box.keyPair() + writeFileSync( + join(lateDir, E2EE_KEYPAIR_STAGE_FILENAME), + JSON.stringify({ + v: 2, + publicKeyB64: Buffer.from(successor.publicKey).toString('base64'), + secretKeyB64: Buffer.from(successor.secretKey).toString('base64'), + installationId: marker.installationId, + purpose: 'reset', + transactionId: 'migration-reset-0001', + predecessorPublicKeyB64: original.publicKeyB64 + }) + ) + + migrateMobilePairingDataToCanonicalUserDataPath(lateDir) + + expect(existsSync(join(getCanonicalUserDataPath(), E2EE_KEYPAIR_STAGE_FILENAME))).toBe(true) + expect(existsSync(join(getCanonicalUserDataPath(), E2EE_KEYPAIR_BACKUP_FILENAME))).toBe(true) + expect( + JSON.parse(readFileSync(join(getCanonicalUserDataPath(), E2EE_KEYPAIR_FILENAME), 'utf8')) + .publicKeyB64 + ).toBe(original.publicKeyB64) + expect(existsSync(join(lateDir, E2EE_KEYPAIR_FILENAME))).toBe(true) + expect(existsSync(join(lateDir, E2EE_KEYPAIR_BACKUP_FILENAME))).toBe(true) + expect(() => loadOrCreateE2EEKeypair(getCanonicalUserDataPath())).toThrow( + 'cannot replace an active' + ) + expect( + publishE2EEKeypairResetSuccessor(getCanonicalUserDataPath(), { + transactionId: 'migration-reset-0001', + oldPublicKeyB64: original.publicKeyB64, + phase: 'creating-successor' + }).publicKeyB64 + ).toBe(Buffer.from(successor.publicKey).toString('base64')) }) it('no-ops when the source path equals the canonical path (no rename happened)', async () => { diff --git a/src/main/runtime/mobile-pairing-userdata-staging.ts b/src/main/runtime/mobile-pairing-userdata-staging.ts new file mode 100644 index 00000000000..b58b70a87e2 --- /dev/null +++ b/src/main/runtime/mobile-pairing-userdata-staging.ts @@ -0,0 +1,194 @@ +import { copyFileSync, lstatSync, mkdirSync, readdirSync, readFileSync, statSync } from 'node:fs' +import { join } from 'node:path' +import { + fsyncFileSync, + removeFileDurableSync, + renameFileDurableSync +} from '../../shared/durable-file-write' +import { hardenExistingSecureFile, hardenSecurePath } from '../../shared/secure-file' +import { MOBILE_PAIRING_USERDATA_FILES } from './mobile-pairing-files' +import { + areEquivalentStagedArtifacts, + isMutableArtifact, + isValidStagedArtifact +} from './mobile-pairing-lifecycle-artifact-equivalence' + +type StagingDirectoryGuard = (stagingDir: string, targetDir: string) => void + +export function prepareMobilePairingStaging( + sourceDir: string, + targetDir: string, + stagingDir: string, + expectedFiles: ReadonlySet, + sourceExpectedFiles: ReadonlySet, + sourceAvailable: boolean, + preserveTargetMutable: boolean, + assertOwnedStagingDirectory: StagingDirectoryGuard +): void { + assertOwnedStagingDirectory(stagingDir, targetDir) + mkdirSync(stagingDir, { recursive: true, mode: 0o700 }) + assertOwnedStagingDirectory(stagingDir, targetDir) + hardenSecurePath(stagingDir, { isDirectory: true, platform: process.platform, sync: true }) + + assertKnownStagingResidue(stagingDir, expectedFiles) + for (const fileName of expectedFiles) { + assertOwnedStagingDirectory(stagingDir, targetDir) + const sourcePath = join(sourceDir, fileName) + const stagedPath = join(stagingDir, fileName) + const targetPath = join(targetDir, fileName) + const sourceExists = sourceAvailable && pathExists(sourcePath) + const stagedExists = pathExists(stagedPath) + const targetExists = pathExists(targetPath) + const copy = (artifactPath: string, drift: string): void => + copyArtifactToStaging( + artifactPath, + stagedPath, + fileName, + stagingDir, + targetDir, + assertOwnedStagingDirectory, + drift + ) + + if (sourceAvailable && sourceExpectedFiles.has(fileName)) { + if (!sourceExists) { + throw new Error(`Canonical mobile pairing source is missing ${fileName}`) + } + assertRegularFile(sourcePath, `source ${fileName}`) + if (!stagedExists) { + copy(sourcePath, 'source changed during migration') + } else { + assertRegularFile(stagedPath, `staged ${fileName}`) + if ( + !areEquivalentStagedArtifacts(fileName, sourcePath, sourceDir, stagedPath, stagingDir) + ) { + const stagedValid = isValidStagedArtifact(fileName, stagedPath, stagingDir) + const targetValid = targetExists && isValidStagedArtifact(fileName, targetPath, targetDir) + if ( + stagedValid && + (!isMutableArtifact(fileName) || !targetValid || !preserveTargetMutable) + ) { + throw new Error(`Canonical mobile pairing source changed during migration: ${fileName}`) + } + if (stagedValid) { + continue + } + copy(sourcePath, 'source changed during migration') + } + } + continue + } + + if (stagedExists) { + assertRegularFile(stagedPath, `staged ${fileName}`) + if (targetExists) { + assertRegularFile(targetPath, `target ${fileName}`) + const stagedValid = isValidStagedArtifact(fileName, stagedPath, stagingDir) + const targetValid = isValidStagedArtifact(fileName, targetPath, targetDir) + if (stagedValid && targetValid) { + if ( + areEquivalentStagedArtifacts(fileName, stagedPath, stagingDir, targetPath, targetDir) + ) { + continue + } + if (isMutableArtifact(fileName) && preserveTargetMutable) { + continue + } + throw new Error(`Canonical mobile pairing target conflicts for ${fileName}`) + } + if (!stagedValid && targetValid && !isMutableArtifact(fileName)) { + copy(targetPath, 'target changed during migration') + } + } + continue + } + + if (targetExists) { + assertRegularFile(targetPath, `target ${fileName}`) + if (isValidStagedArtifact(fileName, targetPath, targetDir)) { + copy(targetPath, 'target changed during migration') + continue + } + // Activation removes malformed target residue after the complete stage validates. + continue + } + + throw new Error(`Canonical mobile pairing staging is missing ${fileName}`) + } +} + +function copyArtifactToStaging( + artifactPath: string, + stagedPath: string, + fileName: string, + stagingDir: string, + targetDir: string, + assertOwnedStagingDirectory: StagingDirectoryGuard, + driftDescription: string +): void { + const temporaryPath = `${stagedPath}.tmp` + if (pathExists(temporaryPath)) { + assertRegularFile(temporaryPath, `temporary staged ${fileName}`) + assertOwnedStagingDirectory(stagingDir, targetDir) + removeFileDurableSync(temporaryPath) + } + assertOwnedStagingDirectory(stagingDir, targetDir) + copyFileSync(artifactPath, temporaryPath) + hardenExistingSecureFile(temporaryPath) + fsyncFileSync(temporaryPath) + assertOwnedStagingDirectory(stagingDir, targetDir) + if (pathExists(stagedPath)) { + assertRegularFile(stagedPath, `staged ${fileName}`) + if (isValidStagedArtifact(fileName, stagedPath, stagingDir)) { + throw new Error(`Canonical mobile pairing source changed during migration: ${fileName}`) + } + if (process.platform === 'win32') { + removeFileDurableSync(stagedPath) + } + } + renameFileDurableSync(temporaryPath, stagedPath) + if (!sameCopiedBytes(artifactPath, stagedPath)) { + throw new Error(`Canonical mobile pairing ${driftDescription}: ${fileName}`) + } +} + +function assertKnownStagingResidue(stagingDir: string, expectedFiles: ReadonlySet): void { + const knownFiles = new Set(MOBILE_PAIRING_USERDATA_FILES) + for (const fileName of readdirSync(stagingDir)) { + const known = knownFiles.has(fileName) + const temporary = fileName.endsWith('.tmp') && knownFiles.has(fileName.slice(0, -4)) + if ((known && expectedFiles.has(fileName)) || temporary) { + continue + } + throw new Error(`Canonical mobile pairing staging has an unexpected ${fileName}`) + } +} + +function pathExists(path: string): boolean { + try { + lstatSync(path) + return true + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return false + } + throw error + } +} + +function assertRegularFile(path: string, field: string): void { + try { + if (lstatSync(path).isSymbolicLink() || !statSync(path).isFile()) { + throw new Error(`${field} is invalid`) + } + } catch (error) { + if (error instanceof Error && error.message === `${field} is invalid`) { + throw error + } + throw new Error(`${field} is unavailable`) + } +} + +function sameCopiedBytes(left: string, right: string): boolean { + return readFileSync(left).equals(readFileSync(right)) +} diff --git a/src/main/runtime/mobile-presence-lock.test.ts b/src/main/runtime/mobile-presence-lock.test.ts index 517c038bd70..018553f3e00 100644 --- a/src/main/runtime/mobile-presence-lock.test.ts +++ b/src/main/runtime/mobile-presence-lock.test.ts @@ -60,6 +60,18 @@ const store = { }) } +const TEST_PROVIDER_ROUTE_TOKEN = Object.freeze({}) +const TEST_PTY_LIFECYCLE_TOKEN = Object.freeze({}) + +function captureTestMutationTarget(ptyId: string) { + return Object.freeze({ + id: ptyId, + providerRouteToken: TEST_PROVIDER_ROUTE_TOKEN, + ptyLifecycleToken: TEST_PTY_LIFECYCLE_TOKEN, + access: Object.freeze({ mode: 'legacy' as const }) + }) +} + // Why (#7588): held-modal repro needs indefinite hold (null); legacy tests need the finite 5s default. Wrap per-test without mutating the shared stub. function createRuntime(mobileAutoRestoreFitMs: number | null = 5_000) { const effectiveStore = { @@ -77,6 +89,7 @@ function createRuntime(mobileAutoRestoreFitMs: number | null = 5_000) { let resizeSucceeds = true runtime.setPtyController({ + captureMutationTarget: captureTestMutationTarget, write: (_ptyId, data) => { writes.push(data) return true @@ -133,6 +146,39 @@ describe('mobile presence lock — driver state machine', () => { expect(runtime.getDriver('pty-1')).toEqual({ kind: 'idle' }) }) + it('refuses input when the controller cannot capture an immutable mutation target', async () => { + const runtime = new OrcaRuntimeService(store) + const write = vi.fn(() => true) + runtime.setPtyController({ + write, + kill: () => true, + getForegroundProcess: async () => null + }) + + await expect(runtime.writeTerminalPreviewInput('pty-1', 'input')).resolves.toBe(false) + expect(write).not.toHaveBeenCalled() + }) + + it('does not commit layout state when resize does not explicitly accept', async () => { + const runtime = new OrcaRuntimeService(store) + const resize = vi.fn() + runtime.setPtyController({ + captureMutationTarget: captureTestMutationTarget, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + resize: resize as never, + getSize: () => ({ cols: 150, rows: 40 }) + }) + + await expect(runtime.resizeForClient('pty-1', 'mobile-fit', 'phone-A', 45, 20)).rejects.toThrow( + 'resize_failed' + ) + expect(resize).toHaveBeenCalledOnce() + expect(runtime.getLayout('pty-1')).toBeNull() + expect(runtime.getTerminalFitOverride('pty-1')).toBeNull() + }) + it('stops a preview paste when mobile claims the floor between chunks', async () => { const { runtime, writes } = createRuntime() let driverChecks = 0 diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 432711bc6cc..9535e98319a 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -104,6 +104,7 @@ import { } from '../memory/pty-registry' import { inspectPtyProviderProcess } from '../providers/pty-process-inspection' import type { IPtyProvider } from '../providers/types' +import type { PtyMutationTarget } from '../providers/pty-mutation-target' import * as worktreePathComparison from '../ipc/worktree-path-comparison' import * as localWorktreeFilesystem from '../local-worktree-filesystem' import { @@ -852,6 +853,8 @@ function resetRuntimeTestMocks(): void { beforeEach(resetRuntimeTestMocks) afterEach(resetRuntimeTestMocks) +beforeEach(installTestPtyMutationTargetFixture) +afterEach(uninstallTestPtyMutationTargetFixture) function syncSinglePty( runtime: OrcaRuntimeService, @@ -1084,6 +1087,10 @@ class InMemoryOrchestrationMessages { return this.activeCoordinatorRun } + listWorkerTerminalReleaseBacklog(): never[] { + return [] + } + markAsDelivered(ids: string[]): void { const deliveredIds = new Set(ids) for (const message of this.messages) { @@ -1103,6 +1110,153 @@ function setInMemoryOrchestrationMessages( runtime.setOrchestrationDb(db as unknown as OrchestrationDb) } +type TestRuntimePtyRecord = { + incarnationId?: string | null + paneKey?: string | null + tabId?: string | null +} + +type TestWorkspaceSession = { + terminalPtyIncarnationsByPaneKey?: Record + terminalLayoutsByTabId?: Record }> +} + +const originalSetPtyController = OrcaRuntimeService.prototype.setPtyController +const originalSetOrchestrationDb = OrcaRuntimeService.prototype.setOrchestrationDb +const originalGetOrchestrationDb = OrcaRuntimeService.prototype.getOrchestrationDb + +function getTestPersistedPtyIncarnation( + runtime: OrcaRuntimeService, + ptyId: string, + record: TestRuntimePtyRecord | undefined +): string | null { + const internals = runtime as unknown as { + store?: { + getWorkspaceSession?: (hostId?: string) => TestWorkspaceSession | undefined + } + } + const getWorkspaceSession = internals.store?.getWorkspaceSession + if (!getWorkspaceSession) { + return null + } + const hostIds = new Set([undefined]) + if (record?.paneKey?.includes(':')) { + hostIds.add('local') + } + const connectionId = ptyId.match(/^ssh:([^@]+)@@/)?.[1] + if (connectionId) { + hostIds.add(connectionId) + hostIds.add(`ssh:${connectionId}`) + } + for (const hostId of hostIds) { + let session: TestWorkspaceSession | undefined + try { + session = getWorkspaceSession(hostId) + } catch { + continue + } + if (!session) { + continue + } + if (record?.paneKey) { + const persisted = session.terminalPtyIncarnationsByPaneKey?.[record.paneKey] + if (persisted) { + return persisted + } + } + for (const [tabId, layout] of Object.entries(session.terminalLayoutsByTabId ?? {})) { + for (const [leafId, candidatePtyId] of Object.entries(layout.ptyIdsByLeafId ?? {})) { + if (candidatePtyId !== ptyId) { + continue + } + const paneKey = makePaneKey(tabId, leafId) + const persisted = session.terminalPtyIncarnationsByPaneKey?.[paneKey] + if (persisted) { + return persisted + } + } + } + } + return null +} + +function installTestPtyMutationTargetFixture(): void { + OrcaRuntimeService.prototype.setPtyController = function (controller) { + if (!controller || controller.captureMutationTarget) { + return originalSetPtyController.call(this, controller) + } + const routeToken = Object.freeze({}) + const lifecycleTokens = new Map() + const targets = new Map() + const captureMutationTarget = (ptyId: string): PtyMutationTarget => { + const internals = this as unknown as { + ptysById?: Map + } + const record = internals.ptysById?.get(ptyId) + const incarnationId = + record?.incarnationId ?? + getTestPersistedPtyIncarnation(this, ptyId, record) ?? + `test-incarnation:${ptyId}` + const paneKey = record?.paneKey ?? `${ptyId}:test-pane` + const targetKey = `${ptyId}\0${incarnationId}\0${paneKey}` + const existing = targets.get(targetKey) + if (existing) { + return existing + } + const lifecycleToken = lifecycleTokens.get(ptyId) ?? Object.freeze({}) + lifecycleTokens.set(ptyId, lifecycleToken) + const target: PtyMutationTarget = { + id: ptyId, + providerRouteToken: routeToken, + ptyLifecycleToken: lifecycleToken, + access: { + mode: 'runtime-exact', + evidence: { incarnationId, paneGeneration: 1 }, + authorityAccess: { + namespace: { authorityHostId: 'test-host', namespaceId: 'test-runtime' }, + pane: { paneKey, paneGenerationId: 'test-pane-generation' }, + binding: { + ownerIncarnationId: 'test-owner', + physicalPtyId: ptyId, + ptyIncarnationId: incarnationId + } + } + } + } + targets.set(targetKey, target) + return target + } + const stopAndWait = + controller.stopAndWait ?? + (async (ptyId: string, opts?: { mutationTarget?: PtyMutationTarget | null }) => + controller.kill(ptyId, opts?.mutationTarget ?? null)) + return originalSetPtyController.call(this, { + ...controller, + captureMutationTarget, + stopAndWait + }) + } + OrcaRuntimeService.prototype.setOrchestrationDb = function (db) { + if (!db.listWorkerTerminalReleaseBacklog) { + Object.assign(db, { listWorkerTerminalReleaseBacklog: () => [] }) + } + return originalSetOrchestrationDb.call(this, db) + } + OrcaRuntimeService.prototype.getOrchestrationDb = function () { + const db = originalGetOrchestrationDb.call(this) + if (!db.listWorkerTerminalReleaseBacklog) { + Object.assign(db, { listWorkerTerminalReleaseBacklog: () => [] }) + } + return db + } +} + +function uninstallTestPtyMutationTargetFixture(): void { + OrcaRuntimeService.prototype.setPtyController = originalSetPtyController + OrcaRuntimeService.prototype.setOrchestrationDb = originalSetOrchestrationDb + OrcaRuntimeService.prototype.getOrchestrationDb = originalGetOrchestrationDb +} + function expectStablePaneKeyEnv(env: Record): string { expect(env.ORCA_TAB_ID).toMatch(UUID_RE) const leafId = env.ORCA_PANE_KEY?.slice(`${env.ORCA_TAB_ID}:`.length) @@ -2449,6 +2603,19 @@ describe('OrcaRuntimeService', () => { it('lists live terminals and issues stable handles for synced leaves', async () => { const runtime = new OrcaRuntimeService(store) const events: RuntimeClientEvent[] = [] + let processLive = true + runtime.setPtyController({ + write: () => true, + kill: () => false, + stopAndWait: async (ptyId) => { + processLive = false + runtime.onPtyExit(ptyId, -1) + return true + }, + getForegroundProcess: async () => null, + listProcesses: async () => + processLive ? [{ id: 'pty-1', cwd: '/tmp/worktree-a', title: 'Claude' }] : [] + }) runtime.onClientEvent((event) => events.push(event)) runtime.attachWindow(1) @@ -2513,18 +2680,6 @@ describe('OrcaRuntimeService', () => { throw new Error('expected mobile terminal handle') } - const processLists = [[{ id: 'pty-1', cwd: '/tmp/worktree-a', title: 'Claude' }], []] - runtime.setPtyController({ - write: () => true, - kill: () => false, - stopAndWait: async (ptyId) => { - runtime.onPtyExit(ptyId, -1) - return true - }, - getForegroundProcess: async () => null, - listProcesses: async () => processLists.shift() ?? [] - }) - await runtime.sleepTerminalsForWorktree('branch:feature/foo') expect( events.find( @@ -3291,7 +3446,8 @@ describe('OrcaRuntimeService', () => { { id: 'repo-1::/tmp/worktree-b@@other-controller-pty', cwd: '/tmp/worktree-a/src', - title: 'prefixed other' + title: 'prefixed other', + incarnationId: 'other-incarnation' }, { id: 'nested-controller-pty', cwd: '/tmp/worktree-a/nested/src', title: 'nested' } ] @@ -3309,6 +3465,7 @@ describe('OrcaRuntimeService', () => { const internals = runtime as unknown as { ptysById: Map } expect(internals.ptysById.has('target-controller-pty')).toBe(true) expect(internals.ptysById.has('other-controller-pty')).toBe(false) + // Targeted inventory may bind restored incarnations only for its selected worktree. expect(internals.ptysById.has('repo-1::/tmp/worktree-b@@other-controller-pty')).toBe(false) expect(internals.ptysById.has('nested-controller-pty')).toBe(false) }) @@ -3980,8 +4137,27 @@ describe('OrcaRuntimeService', () => { } } let deletedWorktreeId = '' + const providerRouteToken = Object.freeze({}) + let providerPtyLive = true const localProvider = { - listProcesses: vi.fn(async () => [{ id: `${deletedWorktreeId}@@pty-1` }]), + listProcesses: vi.fn(async () => + providerPtyLive + ? [ + { + id: `${deletedWorktreeId}@@pty-1`, + cwd: '/workspace/folder', + title: 'shell', + incarnationId: 'folder-pty-incarnation', + mutationRouteToken: providerRouteToken + } + ] + : [] + ), + getPtyMutationRouteToken: vi.fn(() => providerRouteToken), + killExact: vi.fn(async () => { + providerPtyLive = false + return true + }), shutdown: vi.fn(async () => undefined) } const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, { @@ -4062,8 +4238,9 @@ describe('OrcaRuntimeService', () => { ).rejects.toThrow('Cannot delete the project root workspace') deletedWorktreeId = result.worktree.id await expect(runtime.removeManagedWorktree(`id:${result.worktree.id}`)).resolves.toEqual({}) - expect(localProvider.shutdown).toHaveBeenCalledWith( + expect(localProvider.killExact).toHaveBeenCalledWith( `${result.worktree.id}@@pty-1`, + 'folder-pty-incarnation', expect.objectContaining({ immediate: true }) ) expect(metaById[result.worktree.id]).toBeUndefined() @@ -5905,15 +6082,28 @@ describe('OrcaRuntimeService', () => { removeWorktree: vi.fn().mockResolvedValue(undefined) } registerSshGitProvider('ssh-1', gitProvider as never) + const providerRouteToken = Object.freeze({}) + let providerPtyLive = true const ptyProvider = { - listProcesses: vi.fn().mockResolvedValue([ - { - id: 'pty-remote', - cwd: '/remote/feature', - title: 'shell', - worktreeId: `${TEST_REPO_ID}::/remote/feature` - } - ]), + listProcesses: vi.fn(async () => + providerPtyLive + ? [ + { + id: 'pty-remote', + cwd: '/remote/feature', + title: 'shell', + worktreeId: `${TEST_REPO_ID}::/remote/feature`, + incarnationId: 'ssh-pty-incarnation', + mutationRouteToken: providerRouteToken + } + ] + : [] + ), + getPtyMutationRouteToken: vi.fn(() => providerRouteToken), + killExact: vi.fn(async () => { + providerPtyLive = false + return true + }), shutdown: vi.fn().mockResolvedValue(undefined) } const runtime = new OrcaRuntimeService(remoteStore as never, undefined, { @@ -5927,11 +6117,12 @@ describe('OrcaRuntimeService', () => { } expect(gitProvider.removeWorktree).toHaveBeenCalledWith('/remote/feature', true) - expect(ptyProvider.shutdown).toHaveBeenCalledWith( + expect(ptyProvider.killExact).toHaveBeenCalledWith( 'pty-remote', + 'ssh-pty-incarnation', expect.objectContaining({ immediate: true }) ) - expect(ptyProvider.shutdown.mock.invocationCallOrder[0]).toBeLessThan( + expect(ptyProvider.killExact.mock.invocationCallOrder[0]).toBeLessThan( gitProvider.removeWorktree.mock.invocationCallOrder[0] ) expect(closeRemoteWatcherForWorktreePathMock).toHaveBeenCalledWith('ssh-1', '/remote/feature') @@ -8581,6 +8772,55 @@ describe('OrcaRuntimeService', () => { expect(trackerEntries.get('pty-1')?.commandCodeDetector).toBeNull() }) + it('tags facts and snapshots with the exact emitting PTY incarnation', () => { + const { runtime, batches } = createSideEffectRuntime() + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + runtime.onPtySpawned('pty-reused', 'incarnation-a', { awaitsRegistration: false }) + + runtime.onPtyData( + 'pty-reused', + '\x1b]0;first title\x07', + 100, + undefined, + undefined, + undefined, + undefined, + 'incarnation-a' + ) + + expect(batches.at(-1)).toMatchObject({ + ptyId: 'pty-reused', + ptyIncarnationId: 'incarnation-a' + }) + expect(runtime.getTerminalSideEffectSnapshot('pty-reused')).toMatchObject({ + ptyId: 'pty-reused', + ptyIncarnationId: 'incarnation-a' + }) + + runtime.onPtySpawned('pty-reused', 'incarnation-b', { awaitsRegistration: false }) + runtime.onPtyData( + 'pty-reused', + '\x1b]0;second title\x07', + 101, + undefined, + undefined, + undefined, + undefined, + 'incarnation-b' + ) + + expect(batches.at(-1)).toMatchObject({ + ptyId: 'pty-reused', + ptyIncarnationId: 'incarnation-b', + facts: [expect.objectContaining({ kind: 'title', rawTitle: 'second title' })] + }) + expect(runtime.getTerminalSideEffectSnapshot('pty-reused')).toMatchObject({ + ptyIncarnationId: 'incarnation-b', + facts: [expect.objectContaining({ kind: 'title', rawTitle: 'second title' })] + }) + }) + it('forwards facts over the shared client-event stream without a desktop renderer', () => { const runtime = new OrcaRuntimeService(store) const events: RuntimeClientEvent[] = [] @@ -16200,7 +16440,7 @@ describe('OrcaRuntimeService', () => { cleared: true }) - expect(clearBuffer).toHaveBeenCalledWith('pty-1') + expect(clearBuffer).toHaveBeenCalledWith('pty-1', expect.anything()) const snapshot = await runtime.serializeTerminalBuffer('pty-1', { scrollbackRows: 1000 }) expect(snapshot?.data).not.toContain('line-0') }) @@ -18306,10 +18546,14 @@ describe('OrcaRuntimeService', () => { runtime.deliverPendingMessagesForHandle(terminal.handle) - expect(write).toHaveBeenCalledWith('pty-1', expect.stringContaining('Subject: hello')) + expect(write).toHaveBeenCalledWith( + 'pty-1', + expect.stringContaining('Subject: hello'), + expect.anything() + ) // Why: the split Enter write lands after the 500ms delay; advance past it before asserting on delivered_at. await vi.advanceTimersByTimeAsync(500) - expect(write).toHaveBeenCalledWith('pty-1', '\r') + expect(write).toHaveBeenCalledWith('pty-1', '\r', expect.anything()) // Why: design doc §3.2 splits delivered vs. read — push-on-idle stamps delivered_at but must not flip read; only the agent's check consumes, so rows stay unread. const unread = db.getUnreadMessages(terminal.handle) @@ -18350,7 +18594,8 @@ describe('OrcaRuntimeService', () => { expect(write).toHaveBeenCalledWith( 'pty-1', - expect.stringContaining('Subject: hello coordinator') + expect.stringContaining('Subject: hello coordinator'), + expect.anything() ) await vi.advanceTimersByTimeAsync(500) const submitWrites = write.mock.calls.filter( @@ -18389,7 +18634,11 @@ describe('OrcaRuntimeService', () => { runtime.deliverPendingMessagesForHandle(terminal.handle) - expect(write).toHaveBeenCalledWith('pty-1', expect.stringContaining('Subject: hello cursor')) + expect(write).toHaveBeenCalledWith( + 'pty-1', + expect.stringContaining('Subject: hello cursor'), + expect.anything() + ) await vi.advanceTimersByTimeAsync(500) const submitWrites = write.mock.calls.filter( ([ptyId, text]) => ptyId === 'pty-1' && text === '\r' @@ -18429,8 +18678,12 @@ describe('OrcaRuntimeService', () => { runtime.deliverPendingMessagesForHandle(terminal.handle) await vi.advanceTimersByTimeAsync(500) - expect(write).toHaveBeenCalledWith('pty-1', expect.stringContaining('Subject: hello claude')) - expect(write).toHaveBeenCalledWith('pty-1', '\r') + expect(write).toHaveBeenCalledWith( + 'pty-1', + expect.stringContaining('Subject: hello claude'), + expect.anything() + ) + expect(write).toHaveBeenCalledWith('pty-1', '\r', expect.anything()) db.close() } finally { vi.useRealTimers() @@ -18645,7 +18898,7 @@ describe('OrcaRuntimeService', () => { await runtime.sendTerminal('term_agent', { text: 'input' }) await runtime.updateRemoteDesktopViewer('pty-agent', 'viewer', 'client', 132, 41) expect(writes).toEqual([['pty-agent', 'input']]) - expect(resize).toHaveBeenCalledWith('pty-agent', 132, 41) + expect(resize).toHaveBeenCalledWith('pty-agent', 132, 41, expect.anything()) await expect(runtime.readTerminal('term_agent')).resolves.toMatchObject({ tail: ['legacy output'] }) @@ -21086,7 +21339,7 @@ describe('OrcaRuntimeService', () => { tail: ['after restart'] }) expect(writes).toEqual([['persisted-pty', 'input']]) - expect(resize).toHaveBeenCalledWith('persisted-pty', 132, 41) + expect(resize).toHaveBeenCalledWith('persisted-pty', 132, 41, expect.anything()) }) it('uses topology CAS before a client can claim a still-orphaned PTY', async () => { @@ -25079,7 +25332,7 @@ describe('OrcaRuntimeService', () => { await runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, `tab-1::${rightLeafId}`) - expect(kill).toHaveBeenCalledWith('pty-right') + expect(kill).toHaveBeenCalledWith('pty-right', expect.anything()) expect(closeTerminal).not.toHaveBeenCalled() }) @@ -26538,6 +26791,7 @@ describe('OrcaRuntimeService', () => { sshSession = session }) const kill = vi.fn(() => true) + const mutationRouteToken = Object.freeze({}) const runtime = new OrcaRuntimeService({ ...store, getRepos: () => [remoteRepo], @@ -26550,7 +26804,16 @@ describe('OrcaRuntimeService', () => { write: () => true, kill, getForegroundProcess: async () => null, - listProcesses: async () => [] + listProcesses: async () => [ + { + id: sshPtyId, + cwd: '/remote/repo', + title: 'SSH host terminal', + worktreeId: TEST_WORKTREE_ID, + incarnationId: 'ssh-headless-incarnation', + mutationRouteToken + } + ] }) runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) @@ -26559,7 +26822,7 @@ describe('OrcaRuntimeService', () => { expect(sshSession.tabsByWorktree[TEST_WORKTREE_ID]).toEqual([]) expect(localSession.tabsByWorktree[TEST_WORKTREE_ID]).toHaveLength(1) expect(setWorkspaceSession).toHaveBeenCalledTimes(1) - expect(kill).toHaveBeenCalledWith(sshPtyId) + expect(kill).toHaveBeenCalledWith(sshPtyId, expect.anything()) }) it('keeps live headless mobile session terminals when a desktop renderer publishes without them', async () => { @@ -26714,8 +26977,8 @@ describe('OrcaRuntimeService', () => { await runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, tabId, { reason: 'user' }) publishRendererOmission(3) - expect(kill).toHaveBeenCalledWith(ptyId) - expect(kill).toHaveBeenCalledWith(splitPtyId) + expect(kill).toHaveBeenCalledWith(ptyId, expect.anything()) + expect(kill).toHaveBeenCalledWith(splitPtyId, expect.anything()) expect((await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).tabs).toEqual([]) }) @@ -27188,7 +27451,7 @@ describe('OrcaRuntimeService', () => { tabId: 'laptop-tab', ptyKilled: true }) - expect(kill).toHaveBeenCalledWith('laptop-created-pty') + expect(kill).toHaveBeenCalledWith('laptop-created-pty', expect.anything()) expect(closeTerminal).toHaveBeenCalledWith('laptop-tab') }) @@ -27408,7 +27671,7 @@ describe('OrcaRuntimeService', () => { acknowledged.resolve() await expect(pending).resolves.toEqual({ closed: true }) - expect(kill).toHaveBeenCalledWith('persisted-pty') + expect(kill).toHaveBeenCalledWith('persisted-pty', expect.anything()) }) it('reuses pane close for live PTYs that do not own a renderer tab', async () => { @@ -27436,7 +27699,7 @@ describe('OrcaRuntimeService', () => { tabId: terminal.tabId, ptyKilled: true }) - expect(kill).toHaveBeenCalledWith('floating-created-pty') + expect(kill).toHaveBeenCalledWith('floating-created-pty', expect.anything()) expect(closeTerminalTab).not.toHaveBeenCalled() }) @@ -27484,8 +27747,8 @@ describe('OrcaRuntimeService', () => { await runtime.closeTerminalTab(terminal.handle) - expect(kill).toHaveBeenCalledWith('headless-left') - expect(kill).toHaveBeenCalledWith('headless-right') + expect(kill).toHaveBeenCalledWith('headless-left', expect.anything()) + expect(kill).toHaveBeenCalledWith('headless-right', expect.anything()) expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toEqual([]) expect(getSession().terminalLayoutsByTabId['durable-tab']).toBeUndefined() expect(flushOrThrow).toHaveBeenCalledTimes(1) @@ -29197,7 +29460,7 @@ describe('OrcaRuntimeService', () => { await runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab') - expect(kill).toHaveBeenCalledWith(persistedPtyId) + expect(kill).toHaveBeenCalledWith(persistedPtyId, expect.anything()) expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toEqual([]) expect(getSession().terminalLayoutsByTabId['host-tab']).toBeUndefined() }) @@ -29247,7 +29510,7 @@ describe('OrcaRuntimeService', () => { await runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab') expect(closeTerminalTab).not.toHaveBeenCalled() - expect(kill).toHaveBeenCalledWith(persistedPtyId) + expect(kill).toHaveBeenCalledWith(persistedPtyId, expect.anything()) expect(closeTerminal).toHaveBeenCalledWith('host-tab') expect(flushOrThrow).toHaveBeenCalledTimes(1) expect(warn).toHaveBeenCalledWith( @@ -30226,8 +30489,8 @@ describe('OrcaRuntimeService', () => { await runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab') - expect(kill).toHaveBeenCalledWith('pty-a') - expect(kill).toHaveBeenCalledWith('pty-b') + expect(kill).toHaveBeenCalledWith('pty-a', expect.anything()) + expect(kill).toHaveBeenCalledWith('pty-b', expect.anything()) expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toEqual([]) expect(getSession().terminalLayoutsByTabId['host-tab']).toBeUndefined() }) @@ -30309,7 +30572,7 @@ describe('OrcaRuntimeService', () => { await runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab') - expect(kill).toHaveBeenCalledWith(servePtyId) + expect(kill).toHaveBeenCalledWith(servePtyId, expect.anything()) // De-persist so syncMobileSessionTabs cannot re-hydrate and resurrect it. expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toEqual([]) expect(getSession().terminalLayoutsByTabId['host-tab']).toBeUndefined() @@ -30579,7 +30842,7 @@ describe('OrcaRuntimeService', () => { ) // Exact split leaf: kill only that leaf's PTY, keep the sibling, don't tear down the parent. - expect(kill).toHaveBeenCalledWith('serve-right') + expect(kill).toHaveBeenCalledWith('serve-right', expect.anything()) expect(kill).not.toHaveBeenCalledWith('serve-left') expect(closeTerminal).not.toHaveBeenCalled() expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toHaveLength(1) @@ -30971,7 +31234,7 @@ describe('OrcaRuntimeService', () => { { reason: 'user' } ) - expect(kill).toHaveBeenCalledWith('serve-right') + expect(kill).toHaveBeenCalledWith('serve-right', expect.anything()) expect(kill).not.toHaveBeenCalledWith('serve-left') }) @@ -31159,6 +31422,336 @@ describe('OrcaRuntimeService', () => { expect(getSession().terminalLayoutsByTabId['host-tab']).toBeDefined() expect(kill).not.toHaveBeenCalled() }) + + it('preserves the durable session and mobile snapshot when exact stop returns false', async () => { + const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal() + ) + const kill = vi.fn(() => true) + const stopAndWait = vi.fn(async () => false) + const runtime = new OrcaRuntimeService(runtimeStore as never) + runtime.setPtyController({ + write: () => true, + kill, + stopAndWait, + getForegroundProcess: async () => null, + listProcesses: async () => [ + { + id: 'persisted-pty', + cwd: TEST_WORKTREE_PATH, + title: 'Shell', + incarnationId: 'close-incarnation' + } + ] + }) + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + const before = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + await expect( + runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab', { reason: 'user' }) + ).rejects.toThrow('terminal_close_stop_failed') + + expect(stopAndWait).toHaveBeenCalledWith( + 'persisted-pty', + expect.objectContaining({ + deadlineMs: expect.any(Number), + mutationTarget: expect.objectContaining({ + id: 'persisted-pty', + access: expect.objectContaining({ mode: 'runtime-exact' }) + }) + }) + ) + expect(kill).not.toHaveBeenCalled() + expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toHaveLength(1) + expect(getSession().terminalLayoutsByTabId['host-tab']).toBeDefined() + const after = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + expect(after.snapshotVersion).toBe(before.snapshotVersion) + expect(after.tabs).toEqual( + expect.arrayContaining([expect.objectContaining({ parentTabId: 'host-tab' })]) + ) + }) + + it('cannot delete a successor binding when the target is replaced while stop waits', async () => { + const ptyId = 'replacement-pty' + const processes = [ + { + id: ptyId, + cwd: TEST_WORKTREE_PATH, + title: 'Shell', + incarnationId: 'old-incarnation' + } + ] + const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal({ + tabsByWorktree: { + [TEST_WORKTREE_ID]: [ + { + id: 'host-tab', + ptyId, + worktreeId: TEST_WORKTREE_ID, + title: 'Persisted Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + 'host-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: ptyId }) + } + }) + ) + let resolveStop!: (stopped: boolean) => void + const stopPromise = new Promise((resolve) => { + resolveStop = resolve + }) + const stopAndWait = vi.fn( + async ( + _ptyId: string, + _opts?: { deadlineMs?: number; mutationTarget?: PtyMutationTarget } + ) => stopPromise + ) + const runtime = new OrcaRuntimeService(runtimeStore as never) + runtime.setPtyController({ + write: () => true, + kill: vi.fn(() => true), + stopAndWait, + getForegroundProcess: async () => null, + listProcesses: async () => processes + }) + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + const pendingClose = runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab', { + reason: 'user' + }) + await vi.waitFor(() => expect(stopAndWait).toHaveBeenCalledTimes(1)) + const capturedTarget = stopAndWait.mock.calls[0]?.[1]?.mutationTarget as + | PtyMutationTarget + | undefined + expect(capturedTarget?.access).toMatchObject({ + mode: 'runtime-exact', + evidence: { incarnationId: 'old-incarnation' } + }) + + processes[0] = { ...processes[0]!, incarnationId: 'new-incarnation' } + runtime.registerPty(ptyId, TEST_WORKTREE_ID, null, { + tabId: 'host-tab', + leafId: HEADLESS_LEAF_ID, + incarnationId: 'new-incarnation' + }) + resolveStop(true) + + await expect(pendingClose).rejects.toThrow('terminal_handle_stale') + expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toHaveLength(1) + expect(getSession().terminalLayoutsByTabId['host-tab']).toBeDefined() + expect(runtime['mobileSessionTabsByWorktree'].get(TEST_WORKTREE_ID)?.tabs).toEqual( + expect.arrayContaining([expect.objectContaining({ parentTabId: 'host-tab', ptyId })]) + ) + }) + + it('retains a recoverable durable parent after a partial multi-leaf stop', async () => { + const leftPtyId = 'partial-left' + const rightPtyId = 'partial-right' + const processes = [ + { + id: leftPtyId, + cwd: TEST_WORKTREE_PATH, + title: 'Left', + incarnationId: 'left-incarnation' + }, + { + id: rightPtyId, + cwd: TEST_WORKTREE_PATH, + title: 'Right', + incarnationId: 'right-incarnation' + } + ] + const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal({ + tabsByWorktree: { + [TEST_WORKTREE_ID]: [ + { + id: 'host-tab', + ptyId: leftPtyId, + worktreeId: TEST_WORKTREE_ID, + title: 'Split Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + 'host-tab': makeHeadlessTerminalLayout({ + [HEADLESS_LEAF_ID]: leftPtyId, + [HEADLESS_SECOND_LEAF_ID]: rightPtyId + }) + } + }) + ) + const stopOrder: string[] = [] + const stopAndWait = vi.fn(async (ptyId: string) => { + stopOrder.push(ptyId) + if (stopOrder.length === 1) { + processes.splice( + processes.findIndex((process) => process.id === ptyId), + 1 + ) + return true + } + if (stopOrder.length === 2) { + return false + } + processes.splice( + processes.findIndex((process) => process.id === ptyId), + 1 + ) + return true + }) + const runtime = new OrcaRuntimeService(runtimeStore as never) + runtime.setPtyController({ + write: () => true, + kill: vi.fn(() => true), + stopAndWait, + getForegroundProcess: async () => null, + listProcesses: async () => processes + }) + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + await expect( + runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab', { reason: 'user' }) + ).rejects.toThrow('terminal_close_stop_failed') + expect(stopOrder).toEqual([leftPtyId, rightPtyId]) + expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toHaveLength(1) + expect(getSession().terminalLayoutsByTabId['host-tab']).toBeDefined() + expect( + runtime['mobileSessionTabsByWorktree'] + .get(TEST_WORKTREE_ID) + ?.tabs.filter((tab) => tab.type === 'terminal' && tab.parentTabId === 'host-tab') + ).toHaveLength(2) + + await expect( + runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab', { reason: 'user' }) + ).resolves.toEqual({ closed: true }) + expect(stopOrder).toEqual([leftPtyId, rightPtyId, rightPtyId]) + expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toEqual([]) + expect(getSession().terminalLayoutsByTabId['host-tab']).toBeUndefined() + }) + + it('retains a disconnected SSH surface until a replayed exact stop settles', async () => { + const ptyId = 'ssh:ssh-1@@pending-close' + const paneKey = makePaneKey('host-tab', HEADLESS_LEAF_ID) + const session = makeWorkspaceSessionWithHeadlessTerminal({ + tabsByWorktree: { + [TEST_WORKTREE_ID]: [ + { + id: 'host-tab', + ptyId, + worktreeId: TEST_WORKTREE_ID, + title: 'Remote Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + 'host-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: ptyId }) + }, + terminalPtyIncarnationsByPaneKey: { [paneKey]: 'ssh-incarnation' } + }) + const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession( + session, + 'ssh:ssh-1' + ) + const remoteRepo = { ...store.getRepo(TEST_REPO_ID)!, connectionId: 'ssh-1' } + const routeToken = Object.freeze({}) + const lifecycleToken = Object.freeze({}) + const authorityAccess = { + namespace: { authorityHostId: 'ssh-1', namespaceId: 'remote-runtime' }, + pane: { paneKey, paneGenerationId: 'remote-pane-generation' }, + binding: { + ownerIncarnationId: 'remote-owner', + physicalPtyId: ptyId, + ptyIncarnationId: 'ssh-incarnation' + } + } + const unavailableTarget: PtyMutationTarget = { + id: ptyId, + providerRouteToken: routeToken, + ptyLifecycleToken: lifecycleToken, + access: { mode: 'unavailable' } + } + const exactTarget: PtyMutationTarget = { + id: ptyId, + providerRouteToken: routeToken, + ptyLifecycleToken: lifecycleToken, + access: { + mode: 'runtime-exact', + evidence: { incarnationId: 'ssh-incarnation', paneGeneration: 1 }, + authorityAccess + } + } + let reconnected = false + const stopAndWait = vi.fn(async () => reconnected) + const runtime = new OrcaRuntimeService({ + ...runtimeStore, + getRepos: () => [remoteRepo], + getRepo: (id: string) => (id === TEST_REPO_ID ? remoteRepo : undefined), + getSshRemotePtyLeases: () => [ + { + targetId: 'ssh-1', + ptyId: 'pending-close', + worktreeId: TEST_WORKTREE_ID, + tabId: 'host-tab', + leafId: HEADLESS_LEAF_ID, + paneGeneration: 1, + incarnationId: 'ssh-incarnation', + state: 'detached' as const, + createdAt: Date.now(), + updatedAt: Date.now() + } + ] + } as never) + runtime.setPtyController({ + captureMutationTarget: () => (reconnected ? exactTarget : unavailableTarget), + write: () => true, + kill: vi.fn(() => true), + stopAndWait, + getForegroundProcess: async () => null, + listProcesses: async () => [] + }) + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + + await expect( + runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab', { reason: 'user' }) + ).rejects.toThrow('terminal_close_stop_failed') + expect(stopAndWait).toHaveBeenCalledWith( + ptyId, + expect.objectContaining({ mutationTarget: unavailableTarget }) + ) + expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toHaveLength(1) + expect(getSession().terminalLayoutsByTabId['host-tab']).toBeDefined() + expect((await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).tabs).toEqual( + expect.arrayContaining([expect.objectContaining({ parentTabId: 'host-tab' })]) + ) + + reconnected = true + await expect( + runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab', { reason: 'user' }) + ).resolves.toEqual({ closed: true }) + expect(stopAndWait).toHaveBeenLastCalledWith( + ptyId, + expect.objectContaining({ mutationTarget: exactTarget }) + ) + expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toEqual([]) + expect(getSession().terminalLayoutsByTabId['host-tab']).toBeUndefined() + }) }) it('builds mobile session agent launch commands on the runtime host', async () => { @@ -32440,7 +33033,7 @@ describe('OrcaRuntimeService', () => { expect(write).toHaveBeenCalledTimes(2) expect(write.mock.calls[0][0]).toBe('pty-bare') expect(String(write.mock.calls[0][1])).toMatch(/codex/) - expect(write.mock.calls[1]).toEqual(['pty-bare', '\r']) + expect(write.mock.calls[1]).toEqual(['pty-bare', '\r', expect.anything()]) } finally { vi.useRealTimers() } @@ -33013,7 +33606,11 @@ describe('OrcaRuntimeService', () => { runtime.deliverPendingMessagesForHandle(terminal.handle) - expect(write).toHaveBeenCalledWith('pty-1', expect.stringContaining('Subject: after wait')) + expect(write).toHaveBeenCalledWith( + 'pty-1', + expect.stringContaining('Subject: after wait'), + expect.anything() + ) db.close() }) @@ -35215,7 +35812,7 @@ describe('OrcaRuntimeService', () => { resolvedWorktreeId: TEST_WORKTREE_ID }) ).resolves.toEqual({ stopped: 1 }) - expect(kill).toHaveBeenCalledWith('pty-1') + expect(kill).toHaveBeenCalledWith('pty-1', expect.anything()) }) it('does not sweep a sibling workspace sharing the checkout dir of an exact id', async () => { @@ -35281,7 +35878,7 @@ describe('OrcaRuntimeService', () => { resolvedConnectionId: 'ssh-1' }) ).resolves.toEqual({ stopped: 1 }) - expect(kill).toHaveBeenCalledWith('pty-ssh') + expect(kill).toHaveBeenCalledWith('pty-ssh', expect.anything()) expect(kill).not.toHaveBeenCalledWith('pty-local') }) @@ -35306,7 +35903,7 @@ describe('OrcaRuntimeService', () => { })) const stopping = runtime.stopTerminalsForWorktree(`id:${TEST_WORKTREE_ID}`, { stopPty }) - await vi.waitFor(() => expect(stopAndWait).toHaveBeenCalledWith('pty-1')) + await vi.waitFor(() => expect(stopAndWait).toHaveBeenCalledWith('pty-1', expect.anything())) expect(kill).not.toHaveBeenCalled() let settled = false void stopping.then(() => { @@ -36235,7 +36832,7 @@ describe('OrcaRuntimeService', () => { kill: () => false, stopAndWait: async (ptyId, opts) => { stopped.push(ptyId) - expect(opts).toEqual({ keepHistory: true }) + expect(opts).toEqual(expect.objectContaining({ keepHistory: true })) runtime.onPtyExit(ptyId, -1) return true }, @@ -36345,7 +36942,7 @@ describe('OrcaRuntimeService', () => { kill: () => false, stopAndWait: async (ptyId, opts) => { stopped.push(ptyId) - expect(opts).toEqual({ keepHistory: true }) + expect(opts).toEqual(expect.objectContaining({ keepHistory: true })) return false }, getForegroundProcess: async () => null, @@ -36457,7 +37054,7 @@ describe('OrcaRuntimeService', () => { kill: () => false, stopAndWait: async (ptyId, opts) => { stopped.push(ptyId) - expect(opts).toEqual({ keepHistory: true }) + expect(opts).toEqual(expect.objectContaining({ keepHistory: true })) runtime.onPtyExit(ptyId, -1) return true }, @@ -37615,11 +38212,26 @@ describe('OrcaRuntimeService', () => { it('revalidates missing worktrees before stopping their local provider sessions', async () => { const deletedId = `${TEST_REPO_ID}::/tmp/deleted` const survivingId = `${TEST_REPO_ID}::/tmp/surviving` + const mutationRouteToken = Object.freeze({}) const localProvider = { listProcesses: vi.fn(async () => [ - { id: `${deletedId}@@deleted-session`, cwd: '/tmp/deleted', title: 'shell' }, - { id: `${survivingId}@@surviving-session`, cwd: '/tmp/surviving', title: 'shell' } + { + id: `${deletedId}@@deleted-session`, + cwd: '/tmp/deleted', + title: 'shell', + incarnationId: 'deleted-incarnation', + mutationRouteToken + }, + { + id: `${survivingId}@@surviving-session`, + cwd: '/tmp/surviving', + title: 'shell', + incarnationId: 'surviving-incarnation', + mutationRouteToken + } ]), + getPtyMutationRouteToken: vi.fn(() => mutationRouteToken), + killExact: vi.fn(async () => true), shutdown: vi.fn(async () => {}) } const runtime = new OrcaRuntimeService(store, undefined, { @@ -37641,11 +38253,12 @@ describe('OrcaRuntimeService', () => { ]) expect(result).toEqual({ stoppedWorktreeIds: [deletedId] }) - expect(localProvider.shutdown).toHaveBeenCalledWith( + expect(localProvider.killExact).toHaveBeenCalledWith( `${deletedId}@@deleted-session`, + 'deleted-incarnation', expect.objectContaining({ immediate: true }) ) - expect(localProvider.shutdown).not.toHaveBeenCalledWith( + expect(localProvider.killExact).not.toHaveBeenCalledWith( `${survivingId}@@surviving-session`, expect.anything() ) @@ -37656,10 +38269,19 @@ describe('OrcaRuntimeService', () => { // directory would strand those PTYs permanently — nothing asks a second time. it('revalidates against a fresh scan instead of a warm worktree-scan cache', async () => { const deletedId = `${TEST_REPO_ID}::/tmp/deleted` + const mutationRouteToken = Object.freeze({}) const localProvider = { listProcesses: vi.fn(async () => [ - { id: `${deletedId}@@deleted-session`, cwd: '/tmp/deleted', title: 'shell' } + { + id: `${deletedId}@@deleted-session`, + cwd: '/tmp/deleted', + title: 'shell', + incarnationId: 'deleted-incarnation', + mutationRouteToken + } ]), + getPtyMutationRouteToken: vi.fn(() => mutationRouteToken), + killExact: vi.fn(async () => true), shutdown: vi.fn(async () => {}) } const runtime = new OrcaRuntimeService(store, undefined, { @@ -37671,7 +38293,7 @@ describe('OrcaRuntimeService', () => { { path: '/tmp/deleted', head: 'abc', branch: 'd', isBare: false, isMainWorktree: false } ]) await runtime.teardownMissingManagedWorktreeTerminals(`id:${TEST_REPO_ID}`, [deletedId]) - expect(localProvider.shutdown).not.toHaveBeenCalled() + expect(localProvider.killExact).not.toHaveBeenCalled() // The worktree is now gone; the cached scan must not mask that. vi.mocked(listWorktrees).mockResolvedValue([]) @@ -37680,8 +38302,9 @@ describe('OrcaRuntimeService', () => { ]) expect(result).toEqual({ stoppedWorktreeIds: [deletedId] }) - expect(localProvider.shutdown).toHaveBeenCalledWith( + expect(localProvider.killExact).toHaveBeenCalledWith( `${deletedId}@@deleted-session`, + 'deleted-incarnation', expect.objectContaining({ immediate: true }) ) }) @@ -37716,10 +38339,19 @@ describe('OrcaRuntimeService', () => { // `path:`/`name:` selector fail repo_not_found on this path alone. it('resolves non-id selectors when a connection identity is supplied', async () => { const deletedId = `${TEST_REPO_ID}::/tmp/deleted` + const mutationRouteToken = Object.freeze({}) const localProvider = { listProcesses: vi.fn(async () => [ - { id: `${deletedId}@@deleted-session`, cwd: '/tmp/deleted', title: 'shell' } + { + id: `${deletedId}@@deleted-session`, + cwd: '/tmp/deleted', + title: 'shell', + incarnationId: 'deleted-incarnation', + mutationRouteToken + } ]), + getPtyMutationRouteToken: vi.fn(() => mutationRouteToken), + killExact: vi.fn(async () => true), shutdown: vi.fn(async () => {}) } const localRepo = store.getRepos()[0] @@ -37746,22 +38378,34 @@ describe('OrcaRuntimeService', () => { ) expect(result).toEqual({ stoppedWorktreeIds: [deletedId] }) - expect(localProvider.shutdown).toHaveBeenCalledWith( + expect(localProvider.killExact).toHaveBeenCalledWith( `${deletedId}@@deleted-session`, + 'deleted-incarnation', expect.objectContaining({ immediate: true }) ) }) it('uses the connection-scoped repo when local and SSH repo ids collide', async () => { const deletedId = `${TEST_REPO_ID}::/tmp/deleted` + const mutationRouteToken = Object.freeze({}) const localProvider = { listProcesses: vi.fn(async () => []), + getPtyMutationRouteToken: vi.fn(() => mutationRouteToken), + killExact: vi.fn(async () => true), shutdown: vi.fn(async () => {}) } const sshProvider = { listProcesses: vi.fn(async () => [ - { id: `${deletedId}@@ssh-session`, cwd: '/tmp/deleted', title: 'shell' } + { + id: `${deletedId}@@ssh-session`, + cwd: '/tmp/deleted', + title: 'shell', + incarnationId: 'ssh-deleted-incarnation', + mutationRouteToken + } ]), + getPtyMutationRouteToken: vi.fn(() => mutationRouteToken), + killExact: vi.fn(async () => true), shutdown: vi.fn(async () => {}) } const localRepo = store.getRepos()[0] @@ -37790,8 +38434,9 @@ describe('OrcaRuntimeService', () => { await runtime.teardownMissingManagedWorktreeTerminals(TEST_REPO_ID, [deletedId], 'ssh-1') - expect(sshProvider.shutdown).toHaveBeenCalledWith( + expect(sshProvider.killExact).toHaveBeenCalledWith( `${deletedId}@@ssh-session`, + 'ssh-deleted-incarnation', expect.objectContaining({ immediate: true }) ) expect(localProvider.listProcesses).not.toHaveBeenCalled() @@ -41331,7 +41976,11 @@ describe('OrcaRuntimeService', () => { runtime.onPtyData('pty-startup-draft', '\x1b[?2004h›', Date.now()) await vi.waitFor(() => { - expect(write).toHaveBeenCalledWith('pty-startup-draft', `\x1b[200~${draftUrl}\x1b[201~`) + expect(write).toHaveBeenCalledWith( + 'pty-startup-draft', + `\x1b[200~${draftUrl}\x1b[201~`, + expect.anything() + ) }) }) @@ -41500,7 +42149,7 @@ describe('OrcaRuntimeService', () => { }) ) await vi.waitFor(() => { - expect(write).toHaveBeenCalledWith('pty-cli-aider-startup', 'fix it\r') + expect(write).toHaveBeenCalledWith('pty-cli-aider-startup', 'fix it\r', expect.anything()) }) }) @@ -41943,7 +42592,11 @@ describe('OrcaRuntimeService', () => { runtime.onPtyData('pty-explicit-draft', '\x1b[?2004h›', Date.now()) await vi.waitFor(() => { - expect(write).toHaveBeenCalledWith('pty-explicit-draft', `\x1b[200~${draftUrl}\x1b[201~`) + expect(write).toHaveBeenCalledWith( + 'pty-explicit-draft', + `\x1b[200~${draftUrl}\x1b[201~`, + expect.anything() + ) }) }) @@ -46213,12 +46866,16 @@ describe('OrcaRuntimeService', () => { serialize: ReturnType revive: ReturnType listProcesses: ReturnType + getPtyMutationRouteToken: ReturnType + killExact: ReturnType getDefaultShell: ReturnType getProfiles: ReturnType onData: ReturnType onReplay: ReturnType onExit: ReturnType } { + const mutationRouteToken = Object.freeze({}) + const killedPtyIds = new Set() return { spawn: vi.fn(), attach: vi.fn(), @@ -46234,7 +46891,20 @@ describe('OrcaRuntimeService', () => { getForegroundProcess: vi.fn(), serialize: vi.fn(), revive: vi.fn(), - listProcesses: vi.fn(listProcesses), + listProcesses: vi.fn(async () => + (await listProcesses()) + .filter(({ id }) => !killedPtyIds.has(id)) + .map((process) => ({ + ...process, + incarnationId: `test-provider-incarnation:${process.id}`, + mutationRouteToken + })) + ), + getPtyMutationRouteToken: vi.fn(() => mutationRouteToken), + killExact: vi.fn(async (id: string) => { + killedPtyIds.add(id) + return true + }), getDefaultShell: vi.fn(), getProfiles: vi.fn(), onData: vi.fn().mockReturnValue(() => {}), @@ -46272,6 +46942,7 @@ describe('OrcaRuntimeService', () => { getForegroundProcess: async () => null }) syncSinglePty(runtime, 'pty-1') + localProvider.killExact.mockResolvedValue(false) await runtime.removeManagedWorktree(TEST_WORKTREE_ID) @@ -46340,13 +47011,14 @@ describe('OrcaRuntimeService', () => { // The post-daemon provider's prefix-matching session must have been // shut down, proving the thunk resolved lazily at call time. - expect(postDaemonProvider.shutdown).toHaveBeenCalledWith( + expect(postDaemonProvider.killExact).toHaveBeenCalledWith( `${TEST_WORKTREE_ID}@@aaaaaaaa`, + `test-provider-incarnation:${TEST_WORKTREE_ID}@@aaaaaaaa`, expect.objectContaining({ immediate: true }) ) expect(onPtyStopped).toHaveBeenCalledWith(`${TEST_WORKTREE_ID}@@aaaaaaaa`) // The pre-daemon provider must not have been consulted for the kill. - expect(preDaemonProvider.shutdown).not.toHaveBeenCalled() + expect(preDaemonProvider.killExact).not.toHaveBeenCalled() }) }) describe('stale terminal handle resolution (#7718)', () => { @@ -46439,16 +47111,96 @@ describe('OrcaRuntimeService', () => { // Simulate the mobile pre-spawn flow: the handle record predates the PTY (ptyId null); its first PTY must still resolve. const internals = runtime as unknown as { - handles: Map + handles: Map< + string, + { + ptyId: string | null + mutationTarget: PtyMutationTarget | null + authority: unknown + } + > } const record = internals.handles.get(handle) if (!record) { throw new Error('expected handle record') } record.ptyId = null + record.mutationTarget = null + record.authority = null expect(runtime.resolveLiveLeafForHandle(handle)).toEqual({ ptyId: 'pty-a' }) }) + + it('uses the restored inventory incarnation as the legacy-target reconnect oracle', async () => { + const ptyId = 'restored-legacy-pty' + const terminalHandle = 'term_reconnect_oracle' + let incarnationId = 'same-incarnation' + const session = makeWorkspaceSessionWithHeadlessTerminal({ + tabsByWorktree: { + [TEST_WORKTREE_ID]: [ + { + id: 'host-tab', + ptyId, + worktreeId: TEST_WORKTREE_ID, + title: 'Restored Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + 'host-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: ptyId }) + } + }) + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(session) + const routeToken = Object.freeze({}) + const lifecycleToken = Object.freeze({}) + const legacyTarget: PtyMutationTarget = { + id: ptyId, + providerRouteToken: routeToken, + ptyLifecycleToken: lifecycleToken, + access: { mode: 'legacy' } + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + runtime.setPtyController({ + captureMutationTarget: () => legacyTarget, + write: () => true, + kill: vi.fn(() => true), + getForegroundProcess: async () => null, + listProcesses: async () => [ + { + id: ptyId, + cwd: TEST_WORKTREE_PATH, + title: 'Restored Terminal', + worktreeId: TEST_WORKTREE_ID, + terminalHandle, + incarnationId + } + ] + }) + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + + const first = await runtime.listTerminals(`id:${TEST_WORKTREE_ID}`) + expect(first.terminals).toEqual([ + expect.objectContaining({ handle: terminalHandle, incarnationId: 'same-incarnation' }) + ]) + await expect(runtime.readTerminal(terminalHandle)).resolves.toMatchObject({ + status: 'running' + }) + + const sameIncarnation = await runtime.listTerminals(`id:${TEST_WORKTREE_ID}`) + expect(sameIncarnation.terminals[0]?.handle).toBe(terminalHandle) + await expect(runtime.readTerminal(terminalHandle)).resolves.toMatchObject({ + status: 'running' + }) + + incarnationId = 'changed-incarnation' + const changed = await runtime.listTerminals(`id:${TEST_WORKTREE_ID}`) + expect(changed.terminals[0]?.handle).not.toBe(terminalHandle) + await expect(runtime.readTerminal(terminalHandle)).rejects.toThrow('terminal_handle_stale') + }) }) describe('mobile terminal create resilience (#7718)', () => { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 80a04d60554..295076ca77b 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -85,6 +85,8 @@ import { TERMINAL_INPUT_TOO_LARGE_ERROR, iterateTerminalInputChunks } from '../../shared/terminal-input' +import type { PtyMutationTarget } from '../providers/pty-mutation-target' +import { ptyMutationTargetsEqual } from '../providers/pty-mutation-target' import { AGENT_PROMPT_BRACKETED_PASTE_END, AGENT_PROMPT_SUBMIT, @@ -1460,6 +1462,8 @@ export type RuntimeTerminalAgentStatusEvent = { type RuntimePtyTitleTrackerEntry = { tracker: TerminalTitleTracker + /** Exact PTY whose byte stream seeded this tracker; absent for legacy producers. */ + incarnationId?: PtyIncarnationId // Why: onPtyData batches the mobile session-tab touch to once per chunk; // the stale-working-title timer fires between chunks and must touch // immediately. These flags route the tracker callback to the right mode. @@ -1511,6 +1515,7 @@ type RuntimeHeadlessTerminal = { // the seq actually painted into this emulator, not the latest PTY seq. outputSequence: number writeChain: Promise + activeReplyMutationTarget: PtyMutationTarget | null } export type RuntimePtyDataAdmission = Readonly<{ @@ -1638,15 +1643,20 @@ type RuntimePtyController = { stablePaneOwner?: { handle: string; tabId: string; leafId: string } agentSessionEnsure?: AgentSessionClaimedSpawnResult }> - write(ptyId: string, data: string): boolean + captureMutationTarget?(ptyId: string): PtyMutationTarget + write(ptyId: string, data: string, target: PtyMutationTarget): boolean /** Attach-only adoption of a live local daemon session so its output streams * to main without a renderer pane; never creates, resizes, or focuses. * False on doubt (absent session, SSH-scoped id, non-daemon provider). */ attach?(ptyId: string): Promise - kill(ptyId: string): boolean + kill(ptyId: string, mutationTarget: PtyMutationTarget | null): boolean stopAndWait?( ptyId: string, - opts?: { keepHistory?: boolean; deadlineMs?: number } + opts?: { + keepHistory?: boolean + deadlineMs?: number + mutationTarget?: PtyMutationTarget | null + } ): Promise markReversibleStops?(ptyIds: readonly string[]): () => void getCwd?(ptyId: string): Promise @@ -1656,8 +1666,8 @@ type RuntimePtyController = { ): Promise<{ foregroundProcess: string | null; hasChildProcesses: boolean; unavailable?: true }> confirmForegroundProcess?(ptyId: string): Promise hasChildProcesses?(ptyId: string): Promise - clearBuffer?(ptyId: string): Promise - resize?(ptyId: string, cols: number, rows: number): boolean + clearBuffer?(ptyId: string, target: PtyMutationTarget): Promise + resize?(ptyId: string, cols: number, rows: number, target: PtyMutationTarget): boolean // Why: exact-id mobile polls should not enumerate every local and SSH PTY. hasPty?(ptyId: string): boolean | null listProcesses?(connectionId?: string | null): Promise @@ -1740,6 +1750,7 @@ const BRACKETED_PASTE_END = '\x1b[201~' const BRACKETED_PASTE_QUIET_MS = 1500 const DRAFT_PASTE_READY_TIMEOUT_MS = 8000 const MOBILE_TERMINAL_SURFACE_TIMEOUT_MS = 10_000 +const MOBILE_TERMINAL_CLOSE_TIMEOUT_MS = 10_000 const MOBILE_TERMINAL_READY_FALLBACK_MS = 1000 const RECENT_PTY_PATH_CANDIDATE_LIMIT = 1024 const RECENT_PTY_PATH_CANDIDATE_MAX_BYTES = 4 * 1024 @@ -1911,6 +1922,65 @@ type TerminalHandleRecord = { leafId: string ptyId: string | null ptyGeneration: number + mutationTarget: PtyMutationTarget | null + authority: TerminalHandleAuthority | null +} + +type TerminalHandleAuthority = Readonly<{ + ptyId: string + connectionId: string | null + incarnationId: PtyIncarnationId | null + paneGeneration: number | null + processIncarnation: string +}> + +type MobileTerminalCloseBinding = Readonly<{ + leafId: string + ptyId: string | null + terminalHandle: string | null + authority: TerminalHandleAuthority | null + mutationTarget: PtyMutationTarget | null +}> + +type MobileTerminalCloseTransaction = Readonly<{ + worktreeId: string + parentTabId: string + publicationEpoch: string + snapshotVersion: number + initiatingHandle: string | null + bindings: readonly MobileTerminalCloseBinding[] +}> + +type HandleTerminalCloseIntent = Readonly<{ + handle: string + mutationTarget: PtyMutationTarget | null + processIncarnation: string | null + publicationEpoch?: string + snapshotVersion?: number + parentTabId: string + transaction?: MobileTerminalCloseTransaction +}> + +type HeadlessTerminalClosePlan = Readonly<{ + targets: ReadonlyMap +}> + +function getExactPtyMutationTargetAuthority( + target: PtyMutationTarget | null +): { incarnationId: PtyIncarnationId; paneGeneration: number | null } | null { + if (target?.access.mode === 'renderer-exact') { + return { + incarnationId: target.access.identity.incarnationId, + paneGeneration: target.access.identity.paneGeneration ?? null + } + } + if (target?.access.mode === 'runtime-exact') { + return { + incarnationId: target.access.evidence.incarnationId, + paneGeneration: target.access.evidence.paneGeneration ?? null + } + } + return null } export type OrchestrationCompatibilityTerminalAuthority = { @@ -2707,6 +2777,7 @@ type LayoutQueueEntry = { running: Promise | null pending: { target: PtyLayoutTarget + mutationTarget: PtyMutationTarget | null waiters: ((r: ApplyLayoutResult) => void)[] }[] } @@ -4897,6 +4968,142 @@ export class OrcaRuntimeService { this.ptyController = controller } + private capturePtyMutationTarget(ptyId: string): PtyMutationTarget | null { + const controller = this.ptyController + return controller?.captureMutationTarget?.(ptyId) ?? null + } + + private getPtyConnectionId(ptyId: string): string | null { + return this.ptysById.get(ptyId)?.connectionId ?? parseAppSshPtyId(ptyId)?.connectionId ?? null + } + + private captureTerminalHandleBinding( + ptyId: string, + ptyGeneration: number, + incarnationHint?: PtyIncarnationId | null + ): Pick { + const mutationTarget = this.capturePtyMutationTarget(ptyId) + const exactTarget = getExactPtyMutationTargetAuthority(mutationTarget) + const incarnationId = + exactTarget?.incarnationId ?? + incarnationHint ?? + this.ptysById.get(ptyId)?.incarnationId ?? + null + return { + mutationTarget, + authority: { + ptyId, + connectionId: this.getPtyConnectionId(ptyId), + incarnationId, + paneGeneration: exactTarget?.paneGeneration ?? null, + processIncarnation: incarnationId + ? `${ptyId}:${incarnationId}` + : `${this.runtimeId}:${ptyId}:${ptyGeneration}` + } + } + } + + private rebindTerminalHandleFromController( + record: TerminalHandleRecord, + incarnationId: PtyIncarnationId + ): void { + const mutationTarget = this.capturePtyMutationTarget(record.ptyId!) + const exactTarget = getExactPtyMutationTargetAuthority(mutationTarget) + record.authority = { + ptyId: record.ptyId!, + connectionId: this.getPtyConnectionId(record.ptyId!), + incarnationId, + paneGeneration: + exactTarget?.incarnationId === incarnationId ? exactTarget.paneGeneration : null, + processIncarnation: `${record.ptyId}:${incarnationId}` + } + if (exactTarget?.incarnationId === incarnationId) { + record.mutationTarget = mutationTarget + } + } + + private ptyMutationTargetConflicts( + recorded: PtyMutationTarget | null, + current: PtyMutationTarget | null + ): boolean { + if (!recorded) { + return current?.access.mode !== undefined && current.access.mode !== 'unavailable' + } + if (!current) { + return false + } + if ( + recorded.providerRouteToken !== null && + current.providerRouteToken !== null && + recorded.providerRouteToken !== current.providerRouteToken + ) { + return true + } + if ( + recorded.ptyLifecycleToken !== null && + current.ptyLifecycleToken !== null && + recorded.ptyLifecycleToken !== current.ptyLifecycleToken + ) { + return true + } + if (current.access.mode === 'unavailable') { + return false + } + if (recorded.access.mode === 'unavailable') { + return true + } + return !ptyMutationTargetsEqual(recorded, current) + } + + private terminalHandleMutationTargetConflicts(record: TerminalHandleRecord): boolean { + if (!record.ptyId) { + return record.mutationTarget !== null || record.authority !== null + } + const authority = record.authority + const observedIncarnation = this.ptysById.get(record.ptyId)?.incarnationId ?? null + if ( + authority && + (authority.ptyId !== record.ptyId || + authority.connectionId !== this.getPtyConnectionId(record.ptyId) || + (authority.incarnationId !== null && + observedIncarnation !== null && + authority.incarnationId !== observedIncarnation)) + ) { + return true + } + const current = this.capturePtyMutationTarget(record.ptyId) + const currentExact = getExactPtyMutationTargetAuthority(current) + if (authority?.incarnationId) { + if (currentExact) { + if ( + currentExact.incarnationId !== authority.incarnationId || + (authority.paneGeneration !== null && + currentExact.paneGeneration !== null && + authority.paneGeneration !== currentExact.paneGeneration) + ) { + return true + } + record.mutationTarget = current + return false + } + // Exact process authority survives an unavailable or downgraded route, but + // the old mutation token remains unusable until the same incarnation proves itself. + return Boolean( + current && current.access.mode !== 'unavailable' && current.access.mode !== 'legacy' + ) + } + return this.ptyMutationTargetConflicts(record.mutationTarget, current) + } + + private isCurrentPtyMutationTarget(target: PtyMutationTarget): boolean { + const current = this.capturePtyMutationTarget(target.id) + return Boolean(current && ptyMutationTargetsEqual(target, current)) + } + + private writeCapturedPtyMutation(target: PtyMutationTarget | null, data: string): boolean { + return Boolean(target && this.ptyController?.write(target.id, data, target)) + } + setNotifier(notifier: RuntimeNotifier | null): void { this.notifier = notifier // Why: run the one-shot fork-upstream backfill once a renderer is attached, @@ -7706,6 +7913,8 @@ export class OrcaRuntimeService { expectedPublicationEpoch?: string expectedTerminalHandle?: string clientNavigationId?: string + handleIntent?: HandleTerminalCloseIntent + settledPtyIds?: ReadonlySet } = {} ): Promise { const graphEpoch = options.clientNavigationId ? this.captureReadyGraphEpoch() : null @@ -7729,9 +7938,13 @@ export class OrcaRuntimeService { ...(snapshot ? { snapshotRepublished: true as const } : {}) } } + const expectedPublicationEpoch = + options.handleIntent?.publicationEpoch ?? options.expectedPublicationEpoch if ( - options.expectedPublicationEpoch !== undefined && - snapshot?.publicationEpoch !== options.expectedPublicationEpoch + (expectedPublicationEpoch !== undefined && + snapshot?.publicationEpoch !== expectedPublicationEpoch) || + (options.handleIntent?.snapshotVersion !== undefined && + snapshot?.snapshotVersion !== options.handleIntent.snapshotVersion) ) { this.republishMobileSessionTabsSnapshot(worktreeId) return { @@ -7752,15 +7965,15 @@ export class OrcaRuntimeService { if (!tab) { throw new Error('tab_not_found') } - if (options.expectedTerminalHandle !== undefined) { + const expectedTerminalHandle = options.handleIntent?.handle ?? options.expectedTerminalHandle + if (expectedTerminalHandle !== undefined) { const terminalIncarnationMatches = tab.type === 'terminal' && snapshot!.tabs.some( (candidate) => candidate.type === 'terminal' && candidate.parentTabId === tab.parentTabId && - this.getMobileSessionTerminalHandle(worktreeId, candidate) === - options.expectedTerminalHandle + this.getMobileSessionTerminalHandle(worktreeId, candidate) === expectedTerminalHandle ) if (!terminalIncarnationMatches) { this.republishMobileSessionTabsSnapshot(worktreeId) @@ -7772,6 +7985,32 @@ export class OrcaRuntimeService { } } } + const terminalCloseTransaction = + tab.type === 'terminal' + ? (options.handleIntent?.transaction ?? + this.captureMobileTerminalCloseTransaction( + worktreeId, + snapshot!, + tab, + expectedTerminalHandle ?? null, + options.handleIntent + )) + : null + if ( + terminalCloseTransaction && + tab.type === 'terminal' && + (terminalCloseTransaction.worktreeId !== worktreeId || + terminalCloseTransaction.parentTabId !== tab.parentTabId || + this.getMobileTerminalCloseTransactionStatus(terminalCloseTransaction) === 'replaced') + ) { + this.republishMobileSessionTabsSnapshot(worktreeId) + return { + closed: true, + refused: true, + refusalReason: 'stale-terminal', + snapshotRepublished: true + } + } if (tab.type === 'terminal') { const parentLeafCount = snapshot!.tabs.filter( (candidate) => candidate.type === 'terminal' && candidate.parentTabId === tab.parentTabId @@ -7839,9 +8078,17 @@ export class OrcaRuntimeService { // the relay when no renderer owns the parent: an adopted tab needs the // renderer's live pin guard and durable close transaction. if (closingWholeParent && !this.tabs.has(tab.parentTabId)) { - this.closeHeadlessMobileTerminalTab(worktreeId, snapshot!, tab, { - killPtys: options.reason === undefined || options.reason === 'user' - }) + await this.closeHeadlessMobileTerminalTab( + worktreeId, + snapshot!, + tab, + terminalCloseTransaction!, + { + killPtys: options.reason === undefined || options.reason === 'user', + observedPtyIds, + settledPtyIds: options.settledPtyIds + } + ) this.notifyRendererOfHeadlessTerminalClose(tab.parentTabId) this.store?.flushOrThrow?.() return { closed: true } @@ -7862,21 +8109,38 @@ export class OrcaRuntimeService { } finally { releasePublicationThrottle() } + const transactionStatus = this.getMobileTerminalCloseTransactionStatus( + terminalCloseTransaction! + ) + if (transactionStatus === 'replaced') { + this.republishMobileSessionTabsSnapshot(worktreeId) + return { + closed: true, + refused: true, + refusalReason: 'stale-terminal', + snapshotRepublished: true + } + } const remainingSnapshot = this.mobileSessionTabsByWorktree.get(worktreeId) const remainingTab = remainingSnapshot?.tabs.find( (candidate): candidate is RuntimeMobileSessionTerminalTab => candidate.type === 'terminal' && candidate.parentTabId === tab.parentTabId ) if ( - remainingSnapshot && - remainingTab && - this.isRuntimeOwnedHeadlessMobileTab(worktreeId, remainingTab) + this.isRuntimeOwnedHeadlessMobileTab(worktreeId, tab) || + (remainingTab && this.isRuntimeOwnedHeadlessMobileTab(worktreeId, remainingTab)) ) { - // Why: after relay recovery the renderer can acknowledge a tab it no longer mirrors; the HUB must still retire its SSH-owned surface. - this.closeHeadlessMobileTerminalTab(worktreeId, remainingSnapshot, remainingTab, { - // Why: the renderer may already have durably removed the tab before acknowledging. - allowMissingPersistedTab: true - }) + await this.closeHeadlessMobileTerminalTab( + worktreeId, + remainingSnapshot ?? snapshot!, + remainingTab ?? tab, + terminalCloseTransaction!, + { + allowMissingPersistedTab: true, + observedPtyIds, + settledPtyIds: options.settledPtyIds + } + ) this.notifyRendererOfHeadlessTerminalClose(tab.parentTabId) this.store?.flushOrThrow?.() } @@ -7886,20 +8150,53 @@ export class OrcaRuntimeService { // Why: notifier implementations without the acknowledged relay may expose // only raw pane close. Runtime-owned parents still need de-persist + kill. if (closingWholeParent && this.isRuntimeOwnedHeadlessMobileTab(worktreeId, tab)) { - this.closeHeadlessMobileTerminalTab(worktreeId, snapshot!, tab) + await this.closeHeadlessMobileTerminalTab( + worktreeId, + snapshot!, + tab, + terminalCloseTransaction!, + { observedPtyIds, settledPtyIds: options.settledPtyIds } + ) this.notifyRendererOfHeadlessTerminalClose(tab.parentTabId) this.store?.flushOrThrow?.() return { closed: true } } if (!this.notifier?.closeTerminal) { - this.closeHeadlessMobileTerminalTab(worktreeId, snapshot!, tab) + await this.closeHeadlessMobileTerminalTab( + worktreeId, + snapshot!, + tab, + terminalCloseTransaction!, + { observedPtyIds, settledPtyIds: options.settledPtyIds } + ) this.store?.flushOrThrow?.() return { closed: true } } if (tab.id === tabId) { const pty = this.findPtyForMobileTerminalTab(worktreeId, tab) if (pty) { - this.ptyController?.kill(pty.ptyId) + const binding = terminalCloseTransaction!.bindings.find( + (candidate) => candidate.ptyId === pty.ptyId + ) + const target = binding ? this.selectExactTerminalCloseTarget(binding) : null + if (!target) { + throw new Error('terminal_exact_close_unavailable') + } + if ( + !options.settledPtyIds?.has(pty.ptyId) && + !(await this.stopPtyForTerminalClose(pty.ptyId, target)) + ) { + throw new Error('terminal_close_stop_failed') + } + if ( + this.getMobileTerminalCloseTransactionStatus( + terminalCloseTransaction!, + new Set([pty.ptyId]) + ) === 'replaced' + ) { + this.republishMobileSessionTabsSnapshot(worktreeId) + throw new Error('terminal_handle_stale') + } } else { this.notifier?.closeTerminal(tab.parentTabId) } @@ -7935,6 +8232,420 @@ export class OrcaRuntimeService { this.notifyMobileSessionTabsChanged(worktreeId) } + private getPersistedTerminalIncarnation( + worktreeId: string, + parentTabId: string, + leafId: string + ): PtyIncarnationId | null { + const session = this.getWorkspaceSessionForWorktree(worktreeId) + return session?.terminalPtyIncarnationsByPaneKey?.[makePaneKey(parentTabId, leafId)] ?? null + } + + private captureMobileTerminalCloseTransaction( + worktreeId: string, + snapshot: RuntimeMobileSessionTabsSnapshot, + tab: RuntimeMobileSessionTerminalTab, + initiatingHandle: string | null, + handleIntent?: HandleTerminalCloseIntent + ): MobileTerminalCloseTransaction { + if (handleIntent && handleIntent.parentTabId !== tab.parentTabId) { + throw new Error('terminal_handle_stale') + } + const bindings = snapshot.tabs + .filter( + (candidate): candidate is RuntimeMobileSessionTerminalTab => + candidate.type === 'terminal' && candidate.parentTabId === tab.parentTabId + ) + .map((candidate): MobileTerminalCloseBinding => { + const pty = this.findPtyForMobileTerminalTab(worktreeId, candidate) + const ptyId = + pty?.ptyId ?? + candidate.ptyId ?? + candidate.parentLayout?.ptyIdsByLeafId?.[candidate.leafId] ?? + null + if (!ptyId) { + return { + leafId: candidate.leafId, + ptyId: null, + terminalHandle: null, + authority: null, + mutationTarget: null + } + } + const terminalHandle = this.handleByPtyId.get(ptyId) ?? this.findHandleForPtyRecord(ptyId) + const record = terminalHandle ? this.handles.get(terminalHandle) : null + if (record && this.terminalHandleMutationTargetConflicts(record)) { + throw new Error('terminal_handle_stale') + } + const captured = record + ? { authority: record.authority, mutationTarget: record.mutationTarget } + : this.captureTerminalHandleBinding( + ptyId, + 0, + pty?.incarnationId ?? + this.getPersistedTerminalIncarnation( + worktreeId, + candidate.parentTabId, + candidate.leafId + ) + ) + return { + leafId: candidate.leafId, + ptyId, + terminalHandle: terminalHandle ?? null, + ...captured + } + }) + if (initiatingHandle) { + const initiating = bindings.find((binding) => binding.terminalHandle === initiatingHandle) + if ( + !initiating || + (handleIntent?.processIncarnation !== undefined && + initiating.authority?.processIncarnation !== handleIntent.processIncarnation) + ) { + throw new Error('terminal_handle_stale') + } + const intendedExact = getExactPtyMutationTargetAuthority(handleIntent?.mutationTarget ?? null) + if (intendedExact && initiating.authority?.incarnationId !== intendedExact.incarnationId) { + throw new Error('terminal_handle_stale') + } + } + return { + worktreeId, + parentTabId: tab.parentTabId, + publicationEpoch: snapshot.publicationEpoch, + snapshotVersion: snapshot.snapshotVersion, + initiatingHandle, + bindings + } + } + + private getMobileTerminalCloseTransactionStatus( + transaction: MobileTerminalCloseTransaction, + settledPtyIds: ReadonlySet = new Set() + ): 'same' | 'retired' | 'replaced' { + const snapshot = this.mobileSessionTabsByWorktree.get(transaction.worktreeId) + const snapshotTabs = snapshot?.tabs.filter( + (candidate): candidate is RuntimeMobileSessionTerminalTab => + candidate.type === 'terminal' && candidate.parentTabId === transaction.parentTabId + ) + const persistedSession = this.getWorkspaceSessionForWorktree(transaction.worktreeId) + const persistedParent = persistedSession?.tabsByWorktree[transaction.worktreeId]?.find( + (candidate) => candidate.id === transaction.parentTabId + ) + const currentTabs = + snapshotTabs && snapshotTabs.length > 0 + ? snapshotTabs + : persistedParent && persistedSession + ? this.buildHeadlessMobileSessionTerminalTabs( + transaction.worktreeId, + [persistedParent], + persistedSession + ) + : [] + const unsettled = transaction.bindings.filter( + (binding) => !binding.ptyId || !settledPtyIds.has(binding.ptyId) + ) + if (currentTabs.length === 0) { + const hasObservedReplacement = unsettled.some((binding) => { + if (!binding.ptyId || !binding.authority?.incarnationId) { + return false + } + const currentPty = this.ptysById.get(binding.ptyId) + return ( + currentPty?.connected === true && + currentPty.incarnationId !== null && + currentPty.incarnationId !== binding.authority.incarnationId + ) + }) + return hasObservedReplacement ? 'replaced' : 'retired' + } + let current: MobileTerminalCloseTransaction + try { + current = this.captureMobileTerminalCloseTransaction( + transaction.worktreeId, + { + ...(snapshot ?? { + worktree: transaction.worktreeId, + publicationEpoch: 'durable-close-check', + snapshotVersion: 0, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: currentTabs + }), + tabs: currentTabs + }, + currentTabs[0]!, + null + ) + } catch { + return 'replaced' + } + if (current.bindings.length < unsettled.length) { + return 'replaced' + } + for (const expected of transaction.bindings) { + const observed = current.bindings.find((binding) => binding.leafId === expected.leafId) + if (!observed) { + if (expected.ptyId && !settledPtyIds.has(expected.ptyId)) { + return 'replaced' + } + continue + } + const identityChanged = + observed.ptyId !== expected.ptyId || + observed.terminalHandle !== expected.terminalHandle || + observed.authority?.connectionId !== expected.authority?.connectionId || + observed.authority?.incarnationId !== expected.authority?.incarnationId || + observed.authority?.paneGeneration !== expected.authority?.paneGeneration || + observed.authority?.processIncarnation !== expected.authority?.processIncarnation || + (observed.mutationTarget !== null && + expected.mutationTarget !== null && + !ptyMutationTargetsEqual(observed.mutationTarget, expected.mutationTarget)) + if (identityChanged) { + return 'replaced' + } + } + if ( + current.bindings.some( + (observed) => !transaction.bindings.some((expected) => expected.leafId === observed.leafId) + ) + ) { + return 'replaced' + } + return 'same' + } + + private hasMatchingSshPtyLease(ptyId: string, authority: TerminalHandleAuthority): boolean { + const parsed = parseAppSshPtyId(ptyId) + if (!parsed || !authority.incarnationId || !this.store?.getSshRemotePtyLeases) { + return false + } + return this.store + .getSshRemotePtyLeases(parsed.connectionId) + .some( + (lease) => + lease.ptyId === parsed.relayPtyId && + lease.incarnationId === authority.incarnationId && + lease.state !== 'terminated' && + lease.state !== 'expired' && + (authority.paneGeneration === null || + lease.paneGeneration === undefined || + lease.paneGeneration === authority.paneGeneration) + ) + } + + private selectExactTerminalCloseTarget( + binding: MobileTerminalCloseBinding + ): PtyMutationTarget | null { + if (!binding.ptyId || !binding.authority?.incarnationId) { + return null + } + const storedExact = getExactPtyMutationTargetAuthority(binding.mutationTarget) + if (storedExact) { + if ( + storedExact.incarnationId !== binding.authority.incarnationId || + (binding.authority.paneGeneration !== null && + storedExact.paneGeneration !== null && + storedExact.paneGeneration !== binding.authority.paneGeneration) + ) { + return null + } + } else if ( + binding.mutationTarget?.access.mode !== 'unavailable' || + !this.hasMatchingSshPtyLease(binding.ptyId, binding.authority) + ) { + return null + } + const observedIncarnation = this.ptysById.get(binding.ptyId)?.incarnationId ?? null + if (observedIncarnation !== null && observedIncarnation !== binding.authority.incarnationId) { + return null + } + const current = this.capturePtyMutationTarget(binding.ptyId) + const currentExact = getExactPtyMutationTargetAuthority(current) + if (currentExact) { + return currentExact.incarnationId === binding.authority.incarnationId && + (binding.authority.paneGeneration === null || + currentExact.paneGeneration === null || + currentExact.paneGeneration === binding.authority.paneGeneration) + ? current + : null + } + if (current?.access.mode === 'legacy') { + return null + } + return this.hasMatchingSshPtyLease(binding.ptyId, binding.authority) + ? binding.mutationTarget + : null + } + + private async stopPtyForTerminalClose( + ptyId: string, + mutationTarget: PtyMutationTarget + ): Promise { + const controller = this.ptyController + const stopAndWait = controller?.stopAndWait + if (!controller || !stopAndWait) { + return false + } + const deadlineMs = Date.now() + MOBILE_TERMINAL_CLOSE_TIMEOUT_MS + if (deadlineMs <= Date.now()) { + return false + } + const stopPromise = Promise.resolve() + .then(() => + stopAndWait.call(controller, ptyId, { + deadlineMs, + mutationTarget + }) + ) + .then( + (stopped) => stopped === true, + () => false + ) + let timer: ReturnType | undefined + try { + const timedOut = new Promise((resolve) => { + timer = setTimeout(() => resolve(false), Math.max(1, deadlineMs - Date.now())) + timer.unref?.() + }) + return await Promise.race([stopPromise, timedOut]) + } finally { + if (timer !== undefined) { + clearTimeout(timer) + } + } + } + + private reconcileRecoverableHeadlessMobileTerminalClose( + worktreeId: string, + parentTabId: string + ): void { + const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) + const hasParent = snapshot?.tabs.some( + (candidate) => candidate.type === 'terminal' && candidate.parentTabId === parentTabId + ) + if (hasParent) { + return + } + const session = this.getWorkspaceSessionForWorktree(worktreeId) + const hasPersistedParent = session?.tabsByWorktree[worktreeId]?.some( + (candidate) => candidate.id === parentTabId + ) + if (hasPersistedParent) { + this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, { force: true }) + } + } + + private async stopHeadlessMobileTerminalPtys( + worktreeId: string, + tab: RuntimeMobileSessionTerminalTab, + transaction: MobileTerminalCloseTransaction, + plan: HeadlessTerminalClosePlan, + options: { + killPtys?: boolean + settledPtyIds?: ReadonlySet + } + ): Promise> { + const settledPtyIds = new Set(options.settledPtyIds ?? []) + if (options.killPtys === false) { + return settledPtyIds + } + for (const [ptyId, mutationTarget] of plan.targets) { + const stopped = await this.stopPtyForTerminalClose(ptyId, mutationTarget) + if (stopped) { + settledPtyIds.add(ptyId) + } + const transactionStatus = this.getMobileTerminalCloseTransactionStatus( + transaction, + settledPtyIds + ) + if (transactionStatus === 'replaced') { + this.reconcileRecoverableHeadlessMobileTerminalClose(worktreeId, tab.parentTabId) + throw new Error('terminal_handle_stale') + } + if (!stopped) { + this.reconcileRecoverableHeadlessMobileTerminalClose(worktreeId, tab.parentTabId) + throw Object.assign(new Error('terminal_close_stop_failed'), { ptyId }) + } + } + return settledPtyIds + } + + private preflightHeadlessMobileTerminalClose( + worktreeId: string, + snapshot: RuntimeMobileSessionTabsSnapshot, + tab: RuntimeMobileSessionTerminalTab, + transaction: MobileTerminalCloseTransaction, + options: { + allowMissingPersistedTab?: boolean + killPtys?: boolean + observedPtyIds: ReadonlySet | null + settledPtyIds?: ReadonlySet + } + ): HeadlessTerminalClosePlan { + const settledPtyIds = options.settledPtyIds ?? new Set() + const transactionStatus = this.getMobileTerminalCloseTransactionStatus( + transaction, + settledPtyIds + ) + if (transactionStatus === 'replaced') { + throw new Error('terminal_handle_stale') + } + if (transactionStatus === 'retired' && options.allowMissingPersistedTab !== true) { + throw new Error('terminal_handle_stale') + } + const session = this.getWorkspaceSessionForWorktree(worktreeId) + if (!session || !this.store?.setWorkspaceSession) { + throw new Error('workspace_session_unavailable') + } + const projected = closeTerminalTabInWorkspaceSession(session, worktreeId, tab.parentTabId) + if (projected.pinned) { + throw new Error('terminal_tab_pinned') + } + if (!projected.closed && options.allowMissingPersistedTab !== true) { + throw new Error('tab_not_found') + } + const candidatePtyIds = new Set(projected.ptyIdsToKill) + for (const binding of transaction.bindings) { + if (binding.ptyId) { + candidatePtyIds.add(binding.ptyId) + } + } + const targets = new Map() + for (const ptyId of candidatePtyIds) { + const hasOtherOwner = snapshot.tabs.some( + (candidate) => + candidate.type === 'terminal' && + candidate.parentTabId !== tab.parentTabId && + (candidate.ptyId === ptyId || + Object.values(candidate.parentLayout?.ptyIdsByLeafId ?? {}).includes(ptyId)) + ) + if (hasOtherOwner) { + continue + } + const binding = transaction.bindings.find((candidate) => candidate.ptyId === ptyId) + const exactTarget = binding ? this.selectExactTerminalCloseTarget(binding) : null + const liveOrUnknown = + options.observedPtyIds === null || + options.observedPtyIds.has(ptyId) || + this.ptysById.get(ptyId)?.connected === true || + Boolean(binding?.authority && this.hasMatchingSshPtyLease(ptyId, binding.authority)) + if (!liveOrUnknown) { + continue + } + if (options.killPtys === false) { + continue + } + if (!exactTarget) { + throw new Error('terminal_exact_close_unavailable') + } + targets.set(ptyId, exactTarget) + } + return { targets } + } + private getMobileSessionTerminalHandle( worktreeId: string, tab: RuntimeMobileSessionTerminalTab @@ -8046,63 +8757,70 @@ export class OrcaRuntimeService { this.emitMobileSessionTabsSnapshot(nextSnapshot) } - private closeHeadlessMobileTerminalTab( + private async closeHeadlessMobileTerminalTab( worktreeId: string, snapshot: RuntimeMobileSessionTabsSnapshot, tab: RuntimeMobileSessionTerminalTab, - options: { allowMissingPersistedTab?: boolean; killPtys?: boolean } = {} - ): void { + transaction: MobileTerminalCloseTransaction, + options: { + allowMissingPersistedTab?: boolean + killPtys?: boolean + observedPtyIds: ReadonlySet | null + settledPtyIds?: ReadonlySet + } + ): Promise { const closedParentTabId = tab.parentTabId - this.clearRuntimeSessionOwnershipForMobileTab(worktreeId, snapshot, closedParentTabId) - const projectedPtyIds = this.removePersistedHeadlessTerminalTab(worktreeId, closedParentTabId, { - allowMissing: options.allowMissingPersistedTab + const plan = this.preflightHeadlessMobileTerminalClose( + worktreeId, + snapshot, + tab, + transaction, + options + ) + const settledPtyIds = await this.stopHeadlessMobileTerminalPtys( + worktreeId, + tab, + transaction, + plan, + options + ) + if (this.getMobileTerminalCloseTransactionStatus(transaction, settledPtyIds) === 'replaced') { + this.reconcileRecoverableHeadlessMobileTerminalClose(worktreeId, closedParentTabId) + throw new Error('terminal_handle_stale') + } + const currentSnapshot = this.mobileSessionTabsByWorktree.get(worktreeId) + const currentTab = currentSnapshot?.tabs.find( + (candidate): candidate is RuntimeMobileSessionTerminalTab => + candidate.type === 'terminal' && candidate.parentTabId === closedParentTabId + ) + const baseSnapshot = currentSnapshot ?? snapshot + this.clearRuntimeSessionOwnershipForMobileTab(worktreeId, baseSnapshot, closedParentTabId) + this.removePersistedHeadlessTerminalTab(worktreeId, closedParentTabId, { + // A successful provider stop may have already published its verified exit + // and removed the final persisted leaf before this transaction resumes. + allowMissing: true }) - // Why: local provider ids can be reused after restart, so a dormant - // persisted id is not kill authority. SSH relay ids remain durable exact - // identities even before pane metadata reconnects. - const ptyIdsToKill = new Set(projectedPtyIds.filter((ptyId) => parseAppSshPtyId(ptyId))) - for (const candidate of snapshot.tabs) { - if (candidate.type !== 'terminal' || candidate.parentTabId !== closedParentTabId) { - continue - } - const livePty = this.findPtyForMobileTerminalTab(worktreeId, candidate) - const ptyId = livePty?.ptyId ?? candidate.ptyId - const hasOtherOwner = snapshot.tabs.some( - (other) => - other.type === 'terminal' && - other.parentTabId !== closedParentTabId && - other.ptyId === ptyId - ) - if (ptyId && !hasOtherOwner && (livePty || parseAppSshPtyId(ptyId))) { - // Why: a live serve leaf can exist before its debounced binding reaches - // persistence. Include it from the authoritative snapshot so split - // close cannot leave a provider process behind. - ptyIdsToKill.add(ptyId) - } - } - if (options.killPtys !== false) { - for (const ptyId of ptyIdsToKill) { - this.ptyController?.kill(ptyId) - } - } - const nextTabs = snapshot.tabs.filter((candidate) => { + const nextTabs = baseSnapshot.tabs.filter((candidate) => { if (candidate.type !== 'terminal' || candidate.parentTabId !== closedParentTabId) { return true } return false }) const active = nextTabs.find((candidate) => candidate.isActive) ?? nextTabs[0] ?? null + if (!currentSnapshot && !currentTab) { + return + } const nextSnapshot: RuntimeMobileSessionTabsSnapshot = { - ...snapshot, + ...baseSnapshot, publicationEpoch: `headless:${Date.now().toString(36)}`, - snapshotVersion: snapshot.snapshotVersion + 1, + snapshotVersion: baseSnapshot.snapshotVersion + 1, activeTabId: active?.id ?? null, activeTabType: active?.type ?? null, tabGroups: this.buildHeadlessMobileSessionTabGroups( worktreeId, nextTabs, active, - snapshot.tabGroups + baseSnapshot.tabGroups ), tabs: nextTabs } @@ -9150,15 +9868,38 @@ export class OrcaRuntimeService { ptyId: string, handle: string | undefined, incarnationId?: string, - options: { exactRestoredSurface?: boolean } = {} + options: { exactRestoredSurface?: boolean; priorIncarnationId?: string | null } = {} ): void { const trimmed = handle?.trim() if (!trimmed || !trimmed.startsWith('term_')) { return } + const boundHandle = + this.handleByPtyId.get(ptyId) ?? + [...this.handles].find(([, record]) => record.ptyId === ptyId)?.[0] + const boundRecord = boundHandle ? this.handles.get(boundHandle) : null + if (boundRecord && boundHandle === trimmed) { + const recordedIncarnation = boundRecord.authority?.incarnationId ?? null + if (incarnationId && recordedIncarnation && incarnationId !== recordedIncarnation) { + this.invalidateAllHandlesForPty(ptyId) + const pty = this.ptysById.get(ptyId) + if (pty) { + pty.tabId = null + pty.paneKey = null + } + return + } + if (incarnationId) { + this.rebindTerminalHandleFromController(boundRecord, incarnationId) + } + this.handleByPtyId.set(ptyId, trimmed) + return + } const pty = this.ptysById.get(ptyId) + const priorIncarnationId = + options.priorIncarnationId !== undefined ? options.priorIncarnationId : pty?.incarnationId const changedIncarnation = Boolean( - incarnationId && pty?.incarnationId && incarnationId !== pty.incarnationId + incarnationId && priorIncarnationId && incarnationId !== priorIncarnationId ) if (changedIncarnation) { const priorHandle = this.handleByPtyId.get(ptyId) @@ -9281,6 +10022,9 @@ export class OrcaRuntimeService { const pty = this.getOrCreatePtyWorktreeRecord(ptyId) if (pty) { if (incarnationId) { + if (pty.incarnationId && pty.incarnationId !== incarnationId) { + this.resetTrackedTerminalStateForProviderGeneration(ptyId) + } pty.incarnationId = incarnationId } pty.connected = true @@ -9301,6 +10045,7 @@ export class OrcaRuntimeService { isWsl?: boolean ): void { this.assertPtyDidNotExitBeforeRegistration(ptyId, binding?.incarnationId) + const priorIncarnationId = this.ptysById.get(ptyId)?.incarnationId ?? null this.spawnPublishedPtys.add(ptyId) // Why: record the renderer pane identity at spawn time so a stalled graph // sync can't hide that a live PTY already backs a pending mobile create. @@ -9318,6 +10063,18 @@ export class OrcaRuntimeService { ...(binding && paneKey ? { tabId: binding.tabId, paneKey } : {}), ...(binding?.incarnationId ? { incarnationId: binding.incarnationId } : {}) }) + if ( + binding?.incarnationId && + priorIncarnationId && + priorIncarnationId !== binding.incarnationId + ) { + // Renderer registration is the explicit pane-boundary proof for a replacement process. + for (const record of this.handles.values()) { + if (record.ptyId === ptyId) { + this.rebindTerminalHandleFromController(record, binding.incarnationId) + } + } + } const pendingIncarnation = this.pendingPtyRegistrationIncarnations.get(ptyId) if ( pendingIncarnation === null || @@ -9474,7 +10231,8 @@ export class OrcaRuntimeService { at: number, sequenceChars = data.length, transformed = false, - sourceRanges?: readonly TerminalOutputSourceRange[] + sourceRanges?: readonly TerminalOutputSourceRange[], + ptyIncarnationId?: PtyIncarnationId ): RuntimePtyDataAdmission { let completion: Promise | null = null const sequence = this.onPtyData( @@ -9486,7 +10244,8 @@ export class OrcaRuntimeService { (receipt) => { completion = receipt }, - sourceRanges + sourceRanges, + ptyIncarnationId ) if (!completion) { throw new Error('PTY model admission receipt was not captured') @@ -9501,7 +10260,8 @@ export class OrcaRuntimeService { sequenceChars = data.length, transformed = false, captureModelReceipt?: (completion: Promise) => void, - sourceRanges?: readonly TerminalOutputSourceRange[] + sourceRanges?: readonly TerminalOutputSourceRange[], + ptyIncarnationId?: PtyIncarnationId ): number { const outputSequence = (this.ptyOutputSequenceById.get(ptyId) ?? 0) + sequenceChars this.ptyOutputSequenceById.set(ptyId, outputSequence) @@ -9679,7 +10439,7 @@ export class OrcaRuntimeService { // pending-message delivery instead of being masked by the chunk's last // title (issue #1083). Uses the OSC 9999-stripped cleanData like the // renderer, so pure status chunks don't perturb the stale-title probe. - const titleTrackerEntry = this.getOrCreatePtyTitleTrackerEntry(ptyId) + const titleTrackerEntry = this.getOrCreatePtyTitleTrackerEntry(ptyId, ptyIncarnationId) const previousTitleScanTail = this.oscTitleScanTailByPtyId.get(ptyId) const titleInput = previousTitleScanTail ? `${previousTitleScanTail}${agentStatusChunk.cleanData}` @@ -9715,7 +10475,7 @@ export class OrcaRuntimeService { } finally { // Why: flushed in the finally so a throwing tracker callback cannot // strand this chunk's facts to be emitted under the next chunk's seq. - this.flushPendingTerminalSideEffectFacts(ptyId, titleTrackerEntry) + this.flushPendingTerminalSideEffectFacts(ptyId, titleTrackerEntry, ptyIncarnationId) } } // Why: hook (OSC 9999) transitions often arrive without a title change, so @@ -9858,14 +10618,17 @@ export class OrcaRuntimeService { * pty:sideEffect batch, preserving byte order. */ private flushPendingTerminalSideEffectFacts( ptyId: string, - entry: RuntimePtyTitleTrackerEntry + entry: RuntimePtyTitleTrackerEntry, + ptyIncarnationId?: PtyIncarnationId ): void { if (entry.pendingFacts.length === 0) { return } const facts = entry.pendingFacts entry.pendingFacts = [] - this.emitTerminalSideEffectBatch(ptyId, facts) + this.emitTerminalSideEffectBatch(ptyId, facts, { + ptyIncarnationId: ptyIncarnationId ?? entry.incarnationId + }) } /** Feed a main-fabricated OSC title/BEL frame (agent hook spinners) through @@ -9887,7 +10650,7 @@ export class OrcaRuntimeService { } finally { entry.applyingChunk = false entry.applyingSyntheticFrame = false - this.flushPendingTerminalSideEffectFacts(ptyId, entry) + this.flushPendingTerminalSideEffectFacts(ptyId, entry, entry.incarnationId) } if (entry.chunkTouchedSessionTabs) { this.touchMobileSessionSnapshotsForPty(ptyId) @@ -9922,26 +10685,35 @@ export class OrcaRuntimeService { /** A transient fact the daemon detected while it held scan authority — * emitted through the same fact channel as byte-scanned facts. Arrives * between chunks, so recordTerminalSideEffectFact emits it immediately. */ - emitDaemonPtyTransientFact(ptyId: string, fact: PtyTransientFact): void { + emitDaemonPtyTransientFact( + ptyId: string, + fact: PtyTransientFact, + ptyIncarnationId?: PtyIncarnationId + ): void { switch (fact.kind) { case 'bell': - this.recordTerminalSideEffectFact(ptyId, { kind: 'bell' }) + this.recordTerminalSideEffectFact(ptyId, { kind: 'bell' }, ptyIncarnationId) return case 'command-finished': this.retirePtyAgentLaunchAuthority(ptyId) - this.recordTerminalSideEffectFact(ptyId, { - kind: 'command-finished', - exitCode: fact.exitCode - }) + this.recordTerminalSideEffectFact( + ptyId, + { kind: 'command-finished', exitCode: fact.exitCode }, + ptyIncarnationId + ) return case 'pr-link': - this.recordTerminalSideEffectFact(ptyId, { kind: 'pr-link', link: fact.link }) + this.recordTerminalSideEffectFact( + ptyId, + { kind: 'pr-link', link: fact.link }, + ptyIncarnationId + ) return case '2031-subscribe': - this.recordTerminalSideEffectFact(ptyId, { kind: '2031-subscribe' }) + this.recordTerminalSideEffectFact(ptyId, { kind: '2031-subscribe' }, ptyIncarnationId) return case '2031-unsubscribe': - this.recordTerminalSideEffectFact(ptyId, { kind: '2031-unsubscribe' }) + this.recordTerminalSideEffectFact(ptyId, { kind: '2031-unsubscribe' }, ptyIncarnationId) } } @@ -9973,7 +10745,11 @@ export class OrcaRuntimeService { /** Record one derived side-effect fact: batched per chunk while applying * bytes, emitted immediately for between-chunk facts (stale-title timer). */ - private recordTerminalSideEffectFact(ptyId: string, fact: TerminalSideEffectFact): void { + private recordTerminalSideEffectFact( + ptyId: string, + fact: TerminalSideEffectFact, + ptyIncarnationId?: PtyIncarnationId + ): void { if (!this.terminalSideEffectConsumerAvailable) { return } @@ -9982,19 +10758,22 @@ export class OrcaRuntimeService { entry.pendingFacts.push(fact) return } - this.emitTerminalSideEffectBatch(ptyId, [fact]) + this.emitTerminalSideEffectBatch(ptyId, [fact], { + ptyIncarnationId: ptyIncarnationId ?? entry?.incarnationId + }) } private emitTerminalSideEffectBatch( ptyId: string, facts: TerminalSideEffectFact[], - options: { replay?: boolean } = {} + options: { replay?: boolean; ptyIncarnationId?: PtyIncarnationId } = {} ): void { if (!this.terminalSideEffectConsumerAvailable || facts.length === 0) { return } const batch: TerminalSideEffectBatch = { ptyId, + ...(options.ptyIncarnationId ? { ptyIncarnationId: options.ptyIncarnationId } : {}), seq: this.ptyOutputSequenceById.get(ptyId) ?? 0, facts, ...(options.replay ? { replay: true } : {}), @@ -10044,8 +10823,11 @@ export class OrcaRuntimeService { /** Title-only replay batch for renderer (re)attach — the no-attention-replay * rule: snapshots restore title state, never historical bells/completions. */ getTerminalSideEffectSnapshot(ptyId: string): TerminalSideEffectBatch | null { - const tracker = this.ptyTitleTrackersByPtyId.get(ptyId)?.tracker - const recordTitle = this.ptysById.get(ptyId)?.lastOscTitle + const trackerEntry = this.ptyTitleTrackersByPtyId.get(ptyId) + const tracker = trackerEntry?.tracker + const pty = this.ptysById.get(ptyId) + const recordTitle = pty?.lastOscTitle + const ptyIncarnationId = pty?.incarnationId ?? trackerEntry?.incarnationId // Why: the cursor-agent literal drop applies to every title surface; a // record-fallback snapshot must not replay the bare native title the // tracker would have refused to emit live. @@ -10056,6 +10838,7 @@ export class OrcaRuntimeService { } return { ptyId, + ...(ptyIncarnationId ? { ptyIncarnationId } : {}), seq: this.ptyOutputSequenceById.get(ptyId) ?? 0, replay: true, facts: [ @@ -10105,9 +10888,15 @@ export class OrcaRuntimeService { )}` } - private getOrCreatePtyTitleTrackerEntry(ptyId: string): RuntimePtyTitleTrackerEntry { + private getOrCreatePtyTitleTrackerEntry( + ptyId: string, + ptyIncarnationId?: PtyIncarnationId + ): RuntimePtyTitleTrackerEntry { const existing = this.ptyTitleTrackersByPtyId.get(ptyId) if (existing) { + if (ptyIncarnationId) { + existing.incarnationId = ptyIncarnationId + } return existing } // Why: trackers are created lazily on the first observed chunk. After an @@ -10194,6 +10983,7 @@ export class OrcaRuntimeService { tracker.setTransientSideEffectScanningEnabled(this.terminalSideEffectConsumerAvailable) const entry: RuntimePtyTitleTrackerEntry = { tracker, + ...(ptyIncarnationId ? { incarnationId: ptyIncarnationId } : {}), applyingChunk: false, applyingSyntheticFrame: false, lastMobileTitleGateKey: null, @@ -10919,9 +11709,13 @@ export class OrcaRuntimeService { if (data.length === 0 || this.getDriver(ptyId).kind === 'mobile') { return false } + const mutationTarget = this.capturePtyMutationTarget(ptyId) + if (!mutationTarget) { + return false + } try { await assertTerminalInputWithinLimitWithYield(data) - await this.writeTerminalInputChunks(ptyId, data, { + await this.writeTerminalInputChunks(ptyId, data, mutationTarget, { // Why: a phone can claim the floor while a paste yields between chunks. beforeWrite: () => { if (this.getDriver(ptyId).kind === 'mobile') { @@ -10988,17 +11782,23 @@ export class OrcaRuntimeService { } async clearTerminalBuffer(handle: string): Promise<{ handle: string; cleared: boolean }> { - const leaf = this.resolveLeafForHandle(handle) - if (!leaf?.ptyId) { + const livePty = this.getLivePtyForHandle(handle) + const liveLeaf = livePty ? null : this.getLiveLeafForHandle(handle) + const ptyId = livePty?.pty.ptyId ?? liveLeaf?.leaf.ptyId ?? null + const mutationTarget = livePty?.record.mutationTarget ?? liveLeaf?.record.mutationTarget ?? null + if (!ptyId || !mutationTarget || mutationTarget.id !== ptyId) { throw new Error('terminal_not_found') } // Why: clear is a terminal UI action (Cmd+K on desktop), not shell input. // Route through the controller so renderer-owned xterm buffers, daemon // sessions, and SSH relay sessions all drop scrollback before the next // mobile snapshot. - await this.ptyController?.clearBuffer?.(leaf.ptyId) - await this.clearHeadlessTerminalBuffer(leaf.ptyId) - return { handle, cleared: true } + const providerCleared = await this.ptyController?.clearBuffer?.(ptyId, mutationTarget) + if (providerCleared === false) { + return { handle, cleared: false } + } + const headlessCleared = await this.clearHeadlessTerminalBuffer(ptyId, mutationTarget) + return { handle, cleared: headlessCleared } } getTerminalSize(ptyId: string): { cols: number; rows: number } | null { @@ -11250,11 +12050,19 @@ export class OrcaRuntimeService { forwardQueryReplies = false ): Promise { const state = this.getOrCreateHeadlessTerminal(ptyId) + const replyMutationTarget = forwardQueryReplies ? this.capturePtyMutationTarget(ptyId) : null const completion = state.writeChain.then(async () => { // Why: the ingestion-time ownership decision is closed over this // chain link; async scheduling cannot retroactively change it. - await state.emulator.write(data, { forwardQueryReplies }) - state.outputSequence = outputSequence + state.activeReplyMutationTarget = replyMutationTarget + try { + await state.emulator.write(data, { forwardQueryReplies }) + state.outputSequence = outputSequence + } finally { + if (state.activeReplyMutationTarget === replyMutationTarget) { + state.activeReplyMutationTarget = null + } + } }) // Legacy callers remain best-effort; bounded SSH admission observes the raw receipt. state.writeChain = completion.catch(() => {}) @@ -11293,7 +12101,7 @@ export class OrcaRuntimeService { // pending and flushes at the ready marker or the 15s // SHELL_READY_TIMEOUT_MS bound (session.ts) — a spawn-time query // reply is delayed at most that bound, not lost. - this.ptyController?.write(ptyId, reply) + this.writeCapturedPtyMutation(state.activeReplyMutationTarget, reply) } } }) @@ -11307,7 +12115,12 @@ export class OrcaRuntimeService { if (viewAttributes) { emulator.applyPushedViewAttributes(viewAttributes) } - state = { emulator, outputSequence: 0, writeChain: Promise.resolve() } + state = { + emulator, + outputSequence: 0, + writeChain: Promise.resolve(), + activeReplyMutationTarget: null + } return state } @@ -11389,16 +12202,32 @@ export class OrcaRuntimeService { // Public: desktop-initiated clears (ipc/pty.ts) must also drop this mobile // mirror or a resubscribing mobile client resurrects the cleared scrollback. - async clearHeadlessTerminalBuffer(ptyId: string): Promise { + async clearHeadlessTerminalBuffer( + ptyId: string, + mutationTarget: PtyMutationTarget + ): Promise { + if (mutationTarget.id !== ptyId || !this.isCurrentPtyMutationTarget(mutationTarget)) { + return false + } const state = this.headlessTerminals.get(ptyId) if (!state) { - return + return true } // Why: headless writes are queued to preserve xterm parser order. Clear // must join that same chain or an earlier PTY chunk can finish after the // clear request and repopulate mobile scrollback. - state.writeChain = state.writeChain.then(() => state.emulator.clearScrollback()) + let cleared = false + state.writeChain = state.writeChain.then(() => { + if ( + this.headlessTerminals.get(ptyId) === state && + this.isCurrentPtyMutationTarget(mutationTarget) + ) { + state.emulator.clearScrollback() + cleared = true + } + }) await state.writeChain + return cleared } private async serializeTerminalBufferFromAvailableState( @@ -11918,6 +12747,9 @@ export class OrcaRuntimeService { if (!record) { return null } + if (this.terminalHandleMutationTargetConflicts(record)) { + throw new Error('terminal_handle_stale') + } if (record.tabId.startsWith('pty:')) { return { ptyId: record.ptyId } } @@ -11938,6 +12770,9 @@ export class OrcaRuntimeService { if (!record) { return null } + if (this.terminalHandleMutationTargetConflicts(record)) { + throw new Error('terminal_handle_stale') + } if (record.tabId.startsWith('pty:')) { return { ptyId: record.ptyId } } @@ -13611,7 +14446,10 @@ export class OrcaRuntimeService { return revision } - async applyRemoteDesktopLayout(ptyId: string): Promise { + async applyRemoteDesktopLayout( + ptyId: string, + admittedMutationTarget?: PtyMutationTarget + ): Promise { if (this.getDriver(ptyId).kind === 'mobile') { return true } @@ -13628,7 +14466,7 @@ export class OrcaRuntimeService { : { kind: 'desktop', ...this.resolveRemoteDesktopHostReclaimTarget(ptyId) } this.freshSubscribeGuard.add(ptyId) try { - const result = await this.enqueueLayout(ptyId, layoutTarget) + const result = await this.enqueueLayout(ptyId, layoutTarget, admittedMutationTarget) // Why: only drop the recorded host size once the reclaim resize actually // landed. If it failed, the PTY is still at the remote-viewer width, so // keep the target for the next reclaim (otherwise it resolves via the @@ -13712,19 +14550,24 @@ export class OrcaRuntimeService { return this.applyRemoteDesktopLayout(ptyId) } - claimRemoteDesktopHost(ptyId: string, cols: number, rows: number): Promise { + claimRemoteDesktopHost( + ptyId: string, + cols: number, + rows: number, + mutationTarget?: PtyMutationTarget + ): Promise { if (!this.remoteDesktopOwners.has(ptyId)) { // Why: disconnect can remove the owner before its queued host resize // lands. A host input in that window must join the reclaim, not pass it. return this.remoteDesktopHostReclaimTargets.has(ptyId) - ? this.applyRemoteDesktopLayout(ptyId) + ? this.applyRemoteDesktopLayout(ptyId, mutationTarget) : Promise.resolve(true) } const viewport = clampTerminalViewport(cols, rows) this.remoteDesktopHostReclaimTargets.set(ptyId, viewport) this.remoteDesktopOwners.delete(ptyId) this.bumpRemoteDesktopViewerRevision(ptyId) - return this.applyRemoteDesktopLayout(ptyId) + return this.applyRemoteDesktopLayout(ptyId, mutationTarget) } unregisterRemoteDesktopViewer(ptyId: string, subscriptionKey: string): Promise { @@ -14320,12 +15163,17 @@ export class OrcaRuntimeService { return true } - private enqueueLayout(ptyId: string, target: PtyLayoutTarget): Promise { + private enqueueLayout( + ptyId: string, + target: PtyLayoutTarget, + admittedMutationTarget?: PtyMutationTarget + ): Promise { // Why: PTY-exit short-circuit. Fresh-subscribe gate lets the very first // transition through even though `layouts` has no entry yet. if (!this.layouts.has(ptyId) && !this.isFreshSubscribe(ptyId)) { return Promise.resolve({ ok: false, reason: 'pty-exited' }) } + const mutationTarget = admittedMutationTarget ?? this.capturePtyMutationTarget(ptyId) let entry = this.layoutQueues.get(ptyId) if (!entry) { @@ -14336,27 +15184,34 @@ export class OrcaRuntimeService { return new Promise((resolve) => { if (!queue.running) { - queue.running = this.runLayoutSlot(ptyId, target, [resolve]) + queue.running = this.runLayoutSlot(ptyId, target, mutationTarget, [resolve]) return } const tail = queue.pending.at(-1) - if (tail && this.coalescesWith(tail.target, target)) { + const sameMutationTarget = + tail && + ((tail.mutationTarget === null && mutationTarget === null) || + (tail.mutationTarget !== null && + mutationTarget !== null && + ptyMutationTargetsEqual(tail.mutationTarget, mutationTarget))) + if (tail && sameMutationTarget && this.coalescesWith(tail.target, target)) { tail.target = target tail.waiters.push(resolve) return } - queue.pending.push({ target, waiters: [resolve] }) + queue.pending.push({ target, mutationTarget, waiters: [resolve] }) }) } private async runLayoutSlot( ptyId: string, target: PtyLayoutTarget, + mutationTarget: PtyMutationTarget | null, waiters: ((r: ApplyLayoutResult) => void)[] ): Promise { let result: ApplyLayoutResult try { - result = await this.applyLayout(ptyId, target) + result = await this.applyLayout(ptyId, target, mutationTarget) } catch (err) { // Why: defensive — applyLayout itself catches resize errors, but a // throw from one of the synchronous map writes (e.g. notifier hook) @@ -14374,7 +15229,7 @@ export class OrcaRuntimeService { } const next = queue.pending.shift() if (next) { - queue.running = this.runLayoutSlot(ptyId, next.target, next.waiters) + queue.running = this.runLayoutSlot(ptyId, next.target, next.mutationTarget, next.waiters) } else { queue.running = null // Why: drop the entry once empty so the map doesn't grow without bound @@ -14384,7 +15239,11 @@ export class OrcaRuntimeService { return result } - private async applyLayout(ptyId: string, target: PtyLayoutTarget): Promise { + private async applyLayout( + ptyId: string, + target: PtyLayoutTarget, + mutationTarget: PtyMutationTarget | null + ): Promise { // Why: re-check pty-exit at the head of the slot — the queue may have // accepted this target before onPtyExit ran. if (!this.layouts.has(ptyId) && !this.isFreshSubscribe(ptyId)) { @@ -14430,8 +15289,10 @@ export class OrcaRuntimeService { if (dimsChanged) { let ok = false try { - const r = this.ptyController?.resize?.(ptyId, target.cols, target.rows) - ok = r ?? true + const r = mutationTarget + ? this.ptyController?.resize?.(ptyId, target.cols, target.rows, mutationTarget) + : undefined + ok = r === true } catch (err) { console.error('[layout] ptyController.resize threw', { ptyId, err }) ok = false @@ -15355,7 +16216,15 @@ export class OrcaRuntimeService { } seenPtyIds.add(claim.ptyId) seenPaneKeys.add(paneKey) - const live = this.getLivePtyForHandle(claim.terminal) + let live: ReturnType = null + try { + live = this.getLivePtyForHandle(claim.terminal) + } catch (error) { + if (error instanceof Error && error.message === 'terminal_handle_stale') { + throw new Error('terminal_orphan_stale') + } + throw error + } const pty = live?.pty const controllerIdentity = terminalIdentityByPtyId.get(claim.ptyId) if ( @@ -16108,15 +16977,7 @@ export class OrcaRuntimeService { getTerminalProcessIncarnation(handle: string): string | null { const live = this.getLivePtyForHandle(handle) const record = live?.record ?? this.handles.get(handle) - if (!record?.ptyId) { - return null - } - const incarnationId = live?.pty.incarnationId ?? this.ptysById.get(record.ptyId)?.incarnationId - if (incarnationId) { - return `${record.ptyId}:${incarnationId}` - } - // Why: legacy providers may omit process incarnation; retain the prior restart-degraded fence. - return `${this.runtimeId}:${record.ptyId}:${record.ptyGeneration}` + return record?.authority?.processIncarnation ?? null } getExactWorkerProviderSession( @@ -16409,8 +17270,12 @@ export class OrcaRuntimeService { if (payload === null) { throw new Error('invalid_terminal_send') } + const mutationTarget = pty.record.mutationTarget + if (!mutationTarget) { + throw new Error('terminal_not_writable') + } await assertTerminalInputWithinLimitWithYield(action.text) - await this.writeTerminalAction(pty.pty.ptyId, action, payload, options) + await this.writeTerminalAction(pty.pty.ptyId, action, payload, mutationTarget, options) return { handle, accepted: true, @@ -16418,7 +17283,7 @@ export class OrcaRuntimeService { } } - const { leaf } = this.getLiveLeafForHandle(handle) + const { record, leaf } = this.getLiveLeafForHandle(handle) if (!leaf.writable || !leaf.ptyId) { throw new Error('terminal_not_writable') } @@ -16426,6 +17291,10 @@ export class OrcaRuntimeService { if (payload === null) { throw new Error('invalid_terminal_send') } + const mutationTarget = record.mutationTarget + if (!mutationTarget) { + throw new Error('terminal_not_writable') + } await assertTerminalInputWithinLimitWithYield(action.text) // Why: leaf.writable mirrors the renderer graph, which can still answer for // a prior process's ptyId — and provider writes to unknown ids are accepted @@ -16435,7 +17304,7 @@ export class OrcaRuntimeService { throw new Error('terminal_not_writable') } - await this.writeTerminalAction(leaf.ptyId, action, payload, options) + await this.writeTerminalAction(leaf.ptyId, action, payload, mutationTarget, options) return { handle, @@ -16459,22 +17328,30 @@ export class OrcaRuntimeService { if (!pty.pty.connected) { throw new Error('terminal_not_writable') } + const mutationTarget = pty.record.mutationTarget + if (!mutationTarget) { + throw new Error('terminal_not_writable') + } await assertTerminalInputWithinLimitWithYield(payload) - await this.writeTerminalAgentPrompt(pty.pty.ptyId, payload, options) + await this.writeTerminalAgentPrompt(pty.pty.ptyId, payload, mutationTarget, options) return { handle, accepted: true, bytesWritten } } - const { leaf } = this.getLiveLeafForHandle(handle) + const { record, leaf } = this.getLiveLeafForHandle(handle) if (!leaf.writable || !leaf.ptyId) { throw new Error('terminal_not_writable') } + const mutationTarget = record.mutationTarget + if (!mutationTarget) { + throw new Error('terminal_not_writable') + } await assertTerminalInputWithinLimitWithYield(payload) // Why: same absence gate as sendTerminal — a stale graph mirror must not // accept a prompt into a void; unknown liveness still proceeds. if (await this.isLeafPtyProvenAbsent(leaf.ptyId)) { throw new Error('terminal_not_writable') } - await this.writeTerminalAgentPrompt(leaf.ptyId, payload, options) + await this.writeTerminalAgentPrompt(leaf.ptyId, payload, mutationTarget, options) return { handle, accepted: true, bytesWritten } } @@ -16922,6 +17799,7 @@ export class OrcaRuntimeService { ptyId: string, action: { text?: string; enter?: boolean; interrupt?: boolean }, payload: string, + mutationTarget: PtyMutationTarget, options: { beforeWrite?: (ptyId: string) => void | Promise reserveWrite?: (ptyId: string) => void @@ -16934,7 +17812,7 @@ export class OrcaRuntimeService { const hasText = typeof action.text === 'string' && action.text.length > 0 const hasSuffix = action.enter || action.interrupt if (hasText) { - await this.writeTerminalInputChunks(ptyId, action.text!, options) + await this.writeTerminalInputChunks(ptyId, action.text!, mutationTarget, options) } if (hasSuffix) { const suffix = (action.enter ? '\r' : '') + (action.interrupt ? '\x03' : '') @@ -16950,7 +17828,7 @@ export class OrcaRuntimeService { } throw error } - const suffixWrote = this.ptyController?.write(ptyId, suffix) ?? false + const suffixWrote = this.writeCapturedPtyMutation(mutationTarget, suffix) if (!suffixWrote) { throw new Error(options.suffixFailureError ?? 'terminal_not_writable') } @@ -16963,7 +17841,7 @@ export class OrcaRuntimeService { await options.beforeWrite?.(ptyId) options.reserveWrite?.(ptyId) - const wrote = this.ptyController?.write(ptyId, payload) ?? false + const wrote = this.writeCapturedPtyMutation(mutationTarget, payload) if (!wrote) { throw new Error('terminal_not_writable') } @@ -16973,6 +17851,7 @@ export class OrcaRuntimeService { private async writeTerminalInputChunks( ptyId: string, text: string, + mutationTarget: PtyMutationTarget, options: { beforeWrite?: (ptyId: string) => void | Promise reserveWrite?: (ptyId: string) => void @@ -16984,7 +17863,7 @@ export class OrcaRuntimeService { while (!chunk.done) { await options.beforeWrite?.(ptyId) options.reserveWrite?.(ptyId) - const wrote = this.ptyController?.write(ptyId, chunk.value) ?? false + const wrote = this.writeCapturedPtyMutation(mutationTarget, chunk.value) if (!wrote) { throw new Error('terminal_not_writable') } @@ -16999,6 +17878,7 @@ export class OrcaRuntimeService { private async writeTerminalAgentPrompt( ptyId: string, pastePayload: string, + mutationTarget: PtyMutationTarget, options: { beforeWrite?: (ptyId: string) => void | Promise suffixFailureError?: string @@ -17011,7 +17891,7 @@ export class OrcaRuntimeService { let chunk = chunks.next() while (!chunk.done) { await options.beforeWrite?.(ptyId) - const wrote = this.ptyController?.write(ptyId, chunk.value) ?? false + const wrote = this.writeCapturedPtyMutation(mutationTarget, chunk.value) if (!wrote) { throw new Error('terminal_not_writable') } @@ -17024,7 +17904,7 @@ export class OrcaRuntimeService { completedPaste = true } catch (error) { if (wrotePasteBytes && !completedPaste) { - this.ptyController?.write(ptyId, AGENT_PROMPT_BRACKETED_PASTE_END) + this.writeCapturedPtyMutation(mutationTarget, AGENT_PROMPT_BRACKETED_PASTE_END) } throw error } @@ -17038,7 +17918,7 @@ export class OrcaRuntimeService { } throw error } - const suffixWrote = this.ptyController?.write(ptyId, AGENT_PROMPT_SUBMIT) ?? false + const suffixWrote = this.writeCapturedPtyMutation(mutationTarget, AGENT_PROMPT_SUBMIT) if (!suffixWrote) { throw new Error(options.suffixFailureError ?? 'terminal_not_writable') } @@ -21134,8 +22014,8 @@ export class OrcaRuntimeService { console.warn('[worktree-create] agent did not become ready for draft paste') return } - this.ptyController?.write( - ptyId, + this.writeCapturedPtyMutation( + this.capturePtyMutationTarget(ptyId), `${BRACKETED_PASTE_BEGIN}${draft.content}${BRACKETED_PASTE_END}` ) }) @@ -21151,7 +22031,7 @@ export class OrcaRuntimeService { console.warn('[worktree-create] agent did not become ready for follow-up prompt') return } - this.ptyController?.write(ptyId, `${followup.prompt}\r`) + this.writeCapturedPtyMutation(this.capturePtyMutationTarget(ptyId), `${followup.prompt}\r`) }) .catch((error) => { console.warn('[worktree-create] failed to send startup follow-up prompt:', error) @@ -26412,9 +27292,12 @@ export class OrcaRuntimeService { if (!pty || this.terminalSpawnCommandsByPtyId.has(pty.ptyId)) { return } - if (this.ptyController?.write(pty.ptyId, command)) { + const mutationTarget = this.capturePtyMutationTarget(pty.ptyId) + if (this.writeCapturedPtyMutation(mutationTarget, command)) { // Why: Enter rides its own write so a long command cannot swallow it. - this.ptyController.write(pty.ptyId, '\r') + if (!this.writeCapturedPtyMutation(mutationTarget, '\r')) { + return + } this.noteTerminalSpawnCommand(pty.ptyId, command) } } @@ -26769,7 +27652,6 @@ export class OrcaRuntimeService { async closeTerminal(handle: string): Promise { const pty = this.getLivePtyForHandle(handle) - this.claudeAgentTeams.removeTeamForLeaderHandle(handle) if (pty) { // Why: PTY exit can immediately replace a ready SSH publication with a pending one, so capture its durable HUB surface before killing it. const surface = @@ -26781,14 +27663,73 @@ export class OrcaRuntimeService { const siblingCount = surface?.tab.parentLayout ? countTerminalLayoutLeaves(surface.tab.parentLayout.root) : this.countLeavesInTab(tabId) - const ptyKilled = this.ptyController?.kill(pty.pty.ptyId) ?? false - if (!ptyKilled || siblingCount <= 1) { + const snapshot = this.mobileSessionTabsByWorktree.get(pty.pty.worktreeId) + const transactionTab = snapshot?.tabs.find( + (candidate): candidate is RuntimeMobileSessionTerminalTab => + candidate.type === 'terminal' && + (candidate.ptyId === pty.pty.ptyId || + candidate.parentLayout?.ptyIdsByLeafId?.[candidate.leafId] === pty.pty.ptyId) + ) + const transaction = + snapshot && transactionTab + ? this.captureMobileTerminalCloseTransaction( + pty.pty.worktreeId, + snapshot, + transactionTab, + handle + ) + : null + const binding = transaction?.bindings.find((candidate) => candidate.ptyId === pty.pty.ptyId) + const mutationTarget = + (binding ? this.selectExactTerminalCloseTarget(binding) : null) ?? + (pty.record.mutationTarget + ? this.selectExactTerminalCloseTarget({ + leafId: pty.record.leafId, + ptyId: pty.pty.ptyId, + terminalHandle: handle, + authority: pty.record.authority, + mutationTarget: pty.record.mutationTarget + }) + : null) + if (!mutationTarget) { + throw new Error('terminal_exact_close_unavailable') + } + const ptyKilled = await this.stopPtyForTerminalClose(pty.pty.ptyId, mutationTarget) + if (!ptyKilled) { + throw new Error('terminal_close_stop_failed') + } + if ( + transaction && + this.getMobileTerminalCloseTransactionStatus(transaction, new Set([pty.pty.ptyId])) === + 'replaced' + ) { + this.republishMobileSessionTabsSnapshot(pty.pty.worktreeId) + throw new Error('terminal_handle_stale') + } + if (siblingCount <= 1) { if (surface) { // Why: paired viewers keep ended streams mounted until the HUB publishes removal, so explicit close uses the durable host-tab transaction instead of viewer-local exit handling. try { - await this.closeMobileSessionTab(`id:${pty.pty.worktreeId}`, tabId) + const result = await this.closeMobileSessionTab(`id:${pty.pty.worktreeId}`, tabId, { + reason: 'user', + handleIntent: { + handle, + mutationTarget, + processIncarnation: pty.record.authority?.processIncarnation ?? null, + parentTabId: tabId, + ...(transaction ? { transaction } : {}) + }, + settledPtyIds: new Set([pty.pty.ptyId]) + }) + if (result.refused) { + throw new Error('terminal_handle_stale') + } } catch (error) { - if (!(error instanceof Error) || error.message !== 'workspace_session_unavailable') { + if ( + !(error instanceof Error) || + (error.message !== 'workspace_session_unavailable' && + error.message !== 'tab_not_found') + ) { throw error } this.notifier?.closeTerminal(tabId) @@ -26797,19 +27738,34 @@ export class OrcaRuntimeService { this.notifier?.closeTerminal(tabId) } } + this.claudeAgentTeams.removeTeamForLeaderHandle(handle) return { handle, tabId, ptyKilled } } this.assertGraphReady() - const { leaf } = this.getLiveLeafForHandle(handle) + const { record, leaf } = this.getLiveLeafForHandle(handle) let ptyKilled = false if (leaf.ptyId) { - ptyKilled = this.ptyController?.kill(leaf.ptyId) ?? false + const mutationTarget = this.selectExactTerminalCloseTarget({ + leafId: leaf.leafId, + ptyId: leaf.ptyId, + terminalHandle: handle, + authority: record.authority, + mutationTarget: record.mutationTarget + }) + if (!mutationTarget) { + throw new Error('terminal_exact_close_unavailable') + } + ptyKilled = await this.stopPtyForTerminalClose(leaf.ptyId, mutationTarget) + if (!ptyKilled) { + throw new Error('terminal_close_stop_failed') + } } // Why: in a multi-pane tab, killing the PTY is enough (renderer's exit handler closes the pane); an extra IPC close would race it and close the whole tab. const siblingCount = this.countLeavesInTab(leaf.tabId) if (!ptyKilled || siblingCount <= 1) { this.notifier?.closeTerminal(leaf.tabId, leaf.paneRuntimeId) } + this.claudeAgentTeams.removeTeamForLeaderHandle(handle) return { handle, tabId: leaf.tabId, ptyKilled } } @@ -26918,6 +27874,7 @@ export class OrcaRuntimeService { leafId, persistHostSessionBinding: true }) + const spawnedMutationTarget = this.capturePtyMutationTarget(result.id) this.registerPreAllocatedHandleForPty(result.id, preAllocatedHandle) if (result.wslDistro) { this.preparePtyExecutionContext(result.id, result.wslDistro) @@ -26948,7 +27905,7 @@ export class OrcaRuntimeService { }) } catch (error) { this.setPairedRendererSessionOwnership(result.id, false) - this.ptyController.kill?.(result.id) + this.ptyController.kill(result.id, spawnedMutationTarget) throw error } if (createdPty) { @@ -27130,6 +28087,7 @@ export class OrcaRuntimeService { if (options.deadline !== undefined && Date.now() >= options.deadline) { break } + const mutationTarget = this.capturePtyMutationTarget(ptyId) const stop = (): boolean | Promise => { if (options.deadline !== undefined && Date.now() >= options.deadline) { return false @@ -27143,13 +28101,14 @@ export class OrcaRuntimeService { if (options.deadline !== undefined) { return ( this.ptyController?.stopAndWait?.(ptyId, { - deadlineMs: teardownRpcDeadline(options.deadline) + deadlineMs: teardownRpcDeadline(options.deadline), + mutationTarget }) ?? false ) } - return this.ptyController?.stopAndWait?.(ptyId) ?? false + return this.ptyController?.stopAndWait?.(ptyId, { mutationTarget }) ?? false } - return Boolean(this.ptyController?.kill(ptyId)) + return Boolean(this.ptyController?.kill(ptyId, mutationTarget)) } const stopResult = options.stopPty ? await options.stopPty(ptyId, stop) @@ -27378,6 +28337,9 @@ export class OrcaRuntimeService { const stopAndWait = ptyController.stopAndWait.bind(ptyController) const orderedLivePtyIds = [...livePtyIds].sort() + const mutationTargetsByPtyId = new Map( + orderedLivePtyIds.map((ptyId) => [ptyId, this.capturePtyMutationTarget(ptyId)]) + ) releaseReversibleRendererStops = ptyController.markReversibleStops?.(orderedLivePtyIds) ?? (() => {}) const stopResults = await Promise.allSettled( @@ -27385,7 +28347,8 @@ export class OrcaRuntimeService { ptyId, stopped: await stopAndWait(ptyId, { keepHistory: true, - deadlineMs: teardownRpcDeadline(sleepDeadline) + deadlineMs: teardownRpcDeadline(sleepDeadline), + mutationTarget: mutationTargetsByPtyId.get(ptyId) ?? null }) })) ) @@ -27530,14 +28493,17 @@ export class OrcaRuntimeService { postStopFailure?: string remainingLivePtyIds?: string[] }> { - // Why: exact stop hibernates one known pane; worktree sleep discovers its complete host-owned set separately. - const graphEpoch = this.captureReadyGraphEpoch() - const worktree = await this.resolveWorktreeSelector(worktreeSelector) - this.assertStableReadyGraph(graphEpoch) const expected = new Set(expectedPtyIds.filter((ptyId) => ptyId.length > 0)) if (expected.size !== 1) { throw new Error('terminal_exact_stop_requires_single_pty') } + const mutationTargetsByPtyId = new Map( + [...expected].map((ptyId) => [ptyId, this.capturePtyMutationTarget(ptyId)]) + ) + // Why: exact stop hibernates one known pane; worktree sleep discovers its complete host-owned set separately. + const graphEpoch = this.captureReadyGraphEpoch() + const worktree = await this.resolveWorktreeSelector(worktreeSelector) + this.assertStableReadyGraph(graphEpoch) const resolvedWorktrees = [...(await this.getResolvedWorktreeMap()).values()] const refreshedPtyLiveness = await this.refreshPtyWorktreeRecordsFromController(resolvedWorktrees) @@ -27561,6 +28527,7 @@ export class OrcaRuntimeService { const stoppedPtyIds: string[] = [] for (const ptyId of [...expected].sort()) { + const mutationTarget = mutationTargetsByPtyId.get(ptyId) ?? null if (opts.keepHistory) { this.intentionalHandlelessPtyStops.set( ptyId, @@ -27568,7 +28535,12 @@ export class OrcaRuntimeService { ) } try { - if (!(await this.ptyController.stopAndWait(ptyId, { keepHistory: opts.keepHistory }))) { + if ( + !(await this.ptyController.stopAndWait(ptyId, { + keepHistory: opts.keepHistory, + mutationTarget + })) + ) { throw Object.assign(new Error('terminal_exact_stop_failed'), { ptyId }) } } finally { @@ -29156,22 +30128,38 @@ export class OrcaRuntimeService { persistedSurface.incarnationId === session.incarnationId && Boolean(worktreeId) && runtimeWorktreeIdsEqual(persistedSurface.worktreeId, worktreeId as string) + const isTargetWorktree = + !targetWorktreeId || + (worktreeId !== null && runtimeWorktreeIdsEqual(worktreeId, targetWorktreeId)) + const priorPtyIncarnationId = this.ptysById.get(session.id)?.incarnationId ?? null + // Bind the incarnation before adopting a restored handle, but only for + // the targeted inventory scope; unrelated PTYs remain unassociated. + const restoredPty = + isTargetWorktree && worktreeId + ? this.recordPtyWorktree(session.id, worktreeId, { + connected: true, + ...(session.incarnationId ? { incarnationId: session.incarnationId } : {}), + ...(session.wslDistro !== undefined + ? { isWsl: Boolean(session.wslDistro), wslDistro: session.wslDistro } + : {}), + ...(restoresExactSurface + ? { tabId: persistedSurface.tabId, paneKey: persistedSurface.paneKey } + : {}) + }) + : null this.adoptControllerTerminalHandle( session.id, controllerIdentity?.handle ?? session.terminalHandle, controllerIdentity?.incarnationId ?? session.incarnationId, - { exactRestoredSurface: Boolean(restoresExactSurface && controllerIdentity) } + { + exactRestoredSurface: Boolean(restoresExactSurface && controllerIdentity), + priorIncarnationId: priorPtyIncarnationId + } ) - if ( - !targetWorktreeId || - (worktreeId && runtimeWorktreeIdsEqual(worktreeId, targetWorktreeId)) - ) { + if (isTargetWorktree) { selectedLivePtyIds.add(session.id) } - if ( - targetWorktreeId && - (!worktreeId || !runtimeWorktreeIdsEqual(worktreeId, targetWorktreeId)) - ) { + if (!isTargetWorktree) { const receipt = this.restoredOrchestrationAuthorityByPtyId.get(session.id) if (receipt && runtimeWorktreeIdsEqual(receipt.worktreeId, targetWorktreeId)) { this.restoredOrchestrationAuthorityByPtyId.delete(session.id) @@ -29179,27 +30167,17 @@ export class OrcaRuntimeService { continue } this.restoredOrchestrationAuthorityByPtyId.delete(session.id) - if (worktreeId) { - const pty = this.recordPtyWorktree(session.id, worktreeId, { - connected: true, - ...(session.incarnationId ? { incarnationId: session.incarnationId } : {}), - ...(session.wslDistro !== undefined - ? { isWsl: Boolean(session.wslDistro), wslDistro: session.wslDistro } - : {}), - ...(restoresExactSurface - ? { tabId: persistedSurface.tabId, paneKey: persistedSurface.paneKey } - : {}) - }) + if (restoredPty) { if (restoresExactSurface && controllerIdentity) { this.rememberRestoredOrchestrationAuthority( - pty, + restoredPty, controllerIdentity.handle, controllerIdentity.incarnationId ) } else { this.restoredOrchestrationAuthorityByPtyId.delete(session.id) } - pty.controllerTitle = session.title?.trim() || null + restoredPty.controllerTitle = session.title?.trim() || null } // Why: fire-and-forget so this listing hot path doesn't serialize a relay round-trip per session and a throw can't abort the sweep below. this.refreshPtyForegroundAgent(session.id) @@ -31211,6 +32189,9 @@ export class OrcaRuntimeService { if (record.rendererGraphEpoch !== this.rendererGraphEpoch) { throw new Error('terminal_handle_stale') } + if (this.terminalHandleMutationTargetConflicts(record)) { + throw new Error('terminal_handle_stale') + } const leaf = this.leaves.get(this.getLeafKey(record.tabId, record.leafId)) if (!leaf || leaf.ptyId !== record.ptyId || leaf.ptyGeneration !== record.ptyGeneration) { @@ -31241,6 +32222,9 @@ export class OrcaRuntimeService { if (!record.ptyId) { return null } + if (this.terminalHandleMutationTargetConflicts(record)) { + throw new Error('terminal_handle_stale') + } const pty = this.ptysById.get(record.ptyId) if (!pty || pty.ptyId !== record.ptyId) { return null @@ -31291,10 +32275,14 @@ export class OrcaRuntimeService { existingRecord && existingRecord.rendererGraphEpoch === this.rendererGraphEpoch && existingRecord.ptyId === leaf.ptyId && - existingRecord.ptyGeneration === leaf.ptyGeneration + existingRecord.ptyGeneration === leaf.ptyGeneration && + !this.terminalHandleMutationTargetConflicts(existingRecord) ) { return existingHandle } + if (existingRecord && this.terminalHandleMutationTargetConflicts(existingRecord)) { + this.invalidateLeafHandle(leafKey) + } } const preAllocatedHandle = this.adoptPreAllocatedHandle(leaf) @@ -31305,6 +32293,9 @@ export class OrcaRuntimeService { if (this.handles.has(handle)) { return handle } + const binding = leaf.ptyId + ? this.captureTerminalHandleBinding(leaf.ptyId, leaf.ptyGeneration) + : { mutationTarget: null, authority: null } this.handles.set(handle, { handle, runtimeId: this.runtimeId, @@ -31313,7 +32304,8 @@ export class OrcaRuntimeService { tabId: leaf.tabId, leafId: leaf.leafId, ptyId: leaf.ptyId, - ptyGeneration: leaf.ptyGeneration + ptyGeneration: leaf.ptyGeneration, + ...binding }) this.handleByLeafKey.set(leafKey, handle) return handle @@ -31327,7 +32319,13 @@ export class OrcaRuntimeService { if (!preAllocated) { return null } + const existingRecord = this.handles.get(preAllocated) + if (existingRecord && this.terminalHandleMutationTargetConflicts(existingRecord)) { + this.invalidateAllHandlesForPty(leaf.ptyId) + return null + } const leafKey = this.getLeafKey(leaf.tabId, leaf.leafId) + const binding = this.captureTerminalHandleBinding(leaf.ptyId, leaf.ptyGeneration) this.handles.set(preAllocated, { handle: preAllocated, runtimeId: this.runtimeId, @@ -31336,25 +32334,30 @@ export class OrcaRuntimeService { tabId: leaf.tabId, leafId: leaf.leafId, ptyId: leaf.ptyId, - ptyGeneration: leaf.ptyGeneration + ptyGeneration: leaf.ptyGeneration, + ...binding }) this.handleByLeafKey.set(leafKey, preAllocated) return preAllocated } private issuePtyHandle(pty: RuntimePtyWorktreeRecord): string { - const existingHandle = - this.handleByPtyId.get(pty.ptyId) ?? this.findHandleForPtyRecord(pty.ptyId) + let existingHandle = this.handleByPtyId.get(pty.ptyId) ?? this.findHandleForPtyRecord(pty.ptyId) if (existingHandle) { const existingRecord = this.handles.get(existingHandle) if ( existingRecord && existingRecord.runtimeId === this.runtimeId && - existingRecord.ptyId === pty.ptyId + existingRecord.ptyId === pty.ptyId && + !this.terminalHandleMutationTargetConflicts(existingRecord) ) { this.handleByPtyId.set(pty.ptyId, existingHandle) return existingHandle } + if (existingRecord && this.terminalHandleMutationTargetConflicts(existingRecord)) { + this.invalidateAllHandlesForPty(pty.ptyId) + existingHandle = null + } } const handle = existingHandle ?? `term_${randomUUID()}` @@ -31362,6 +32365,7 @@ export class OrcaRuntimeService { this.syntheticTerminalHandles.add(handle) } const syntheticId = `pty:${pty.ptyId}` + const binding = this.captureTerminalHandleBinding(pty.ptyId, 0, pty.incarnationId) this.handles.set(handle, { handle, runtimeId: this.runtimeId, @@ -31370,7 +32374,8 @@ export class OrcaRuntimeService { tabId: syntheticId, leafId: syntheticId, ptyId: pty.ptyId, - ptyGeneration: 0 + ptyGeneration: 0, + ...binding }) this.handleByPtyId.set(pty.ptyId, handle) return handle @@ -31381,7 +32386,8 @@ export class OrcaRuntimeService { if ( record.runtimeId === this.runtimeId && record.ptyId === ptyId && - record.tabId.startsWith('pty:') + record.tabId.startsWith('pty:') && + !this.terminalHandleMutationTargetConflicts(record) ) { return handle } @@ -31416,7 +32422,13 @@ export class OrcaRuntimeService { if (!handle || !record || record.ptyId !== null || ptyId === null) { return false } - this.handles.set(handle, { ...record, ptyId, ptyGeneration }) + const binding = this.captureTerminalHandleBinding(ptyId, ptyGeneration) + this.handles.set(handle, { + ...record, + ptyId, + ptyGeneration, + ...binding + }) return true } @@ -31711,7 +32723,11 @@ export class OrcaRuntimeService { } // Why: push-on-idle delivery is event-driven (no polling) because the runtime owns both the message store and terminal status detection. - private deliverPendingMessages(leaf: RuntimeLeafRecord, skipAbsenceProbe = false): void { + private deliverPendingMessages( + leaf: RuntimeLeafRecord, + skipAbsenceProbe = false, + admittedMutationTarget?: PtyMutationTarget + ): void { if (!this._orchestrationDb) { return } @@ -31735,6 +32751,11 @@ export class OrcaRuntimeService { if (!leaf.writable || !leaf.ptyId) { return } + const deliveryPtyId = leaf.ptyId + const mutationTarget = admittedMutationTarget ?? this.capturePtyMutationTarget(deliveryPtyId) + if (!mutationTarget || mutationTarget.id !== deliveryPtyId) { + return + } if ( !skipAbsenceProbe && @@ -31744,7 +32765,7 @@ export class OrcaRuntimeService { // Why: a fire-and-forget write to a prior process's ptyId reports success // and would mark these delivered while losing them. Proven absence keeps // them queued for a future surface; unknown liveness still delivers. - const probedPtyId = leaf.ptyId + const probedPtyId = deliveryPtyId // Why: triggers arriving mid-probe must not each arm a continuation — the // Claude Enter delay stamps delivered_at late, so every continuation would // re-read the same unread rows and double-deliver. The single armed @@ -31757,7 +32778,7 @@ export class OrcaRuntimeService { .then((absent) => { this.probeDeferredDeliveryPtyIds.delete(probedPtyId) if (!absent && leaf.ptyId === probedPtyId) { - this.deliverPendingMessages(leaf, true) + this.deliverPendingMessages(leaf, true, mutationTarget) } }) .catch(() => { @@ -31766,7 +32787,6 @@ export class OrcaRuntimeService { return } - const deliveryPtyId = leaf.ptyId const flight: { enterTimer: ReturnType | null } = { enterTimer: null } this.messageDeliveryFlightsByPtyId.set(deliveryPtyId, flight) // Why: every sync outcome — failed write, sync-stamped branch, or a throw — @@ -31775,7 +32795,7 @@ export class OrcaRuntimeService { let settlesInEnterCallback = false try { const payload = formatMessagesForInjection(unread) - const wrote = this.ptyController?.write(deliveryPtyId, payload) ?? false + const wrote = this.writeCapturedPtyMutation(mutationTarget, payload) if (!wrote) { return } @@ -31807,7 +32827,7 @@ export class OrcaRuntimeService { if (!currentLeaf || currentLeaf.ptyId !== deliveryPtyId || !currentLeaf.writable) { return } - const submitted = this.ptyController?.write(deliveryPtyId, '\r') ?? false + const submitted = this.writeCapturedPtyMutation(mutationTarget, '\r') if (submitted) { this._orchestrationDb?.markAsDelivered(unread.map((m) => m.id)) } diff --git a/src/main/runtime/relay/desktop-relay-identity-reset.ts b/src/main/runtime/relay/desktop-relay-identity-reset.ts new file mode 100644 index 00000000000..3f99f074a14 --- /dev/null +++ b/src/main/runtime/relay/desktop-relay-identity-reset.ts @@ -0,0 +1,57 @@ +import type { OrcaRuntimeRpcServer } from '../runtime-rpc' +import type { RelayDemandLedger } from './relay-demand-ledger' +import type { RelaySessionBroker } from './relay-session-broker' +import type { RelayRevokeOutbox, RelayRevokeOutboxItem } from './relay-revoke-outbox' +import { deriveRelayHostId } from './relay-http-client' + +type RelayIdentityResetRuntime = Partial> + +type RelayTransientDemand = (key: string, operation: () => Promise) => Promise + +export function assertDesktopRelayIdentityResetOpen(runtimeRpc: RelayIdentityResetRuntime): void { + if (runtimeRpc.isIdentityResetInProgress?.() === true) { + throw new Error('identity_reset_in_progress') + } +} + +export function completeDesktopRelayIdentityReset( + options: Readonly<{ + runtimeRpc: Pick + demandLedger: RelayDemandLedger + fenceAndCloseNow(): void + refreshDemand(): void + }> +): void { + const keypair = options.runtimeRpc.getE2EEKeypair() + if (!keypair) { + return + } + options.demandLedger.setRelayHostId(deriveRelayHostId(keypair.publicKey)) + options.fenceAndCloseNow() + options.refreshDemand() +} + +export async function awaitDesktopRelayIdentityResetRevocations( + items: readonly RelayRevokeOutboxItem[], + options: Readonly<{ + revokeOutbox: RelayRevokeOutbox + withTransientDemand: RelayTransientDemand + requireActiveBroker(): Promise + refreshDemand(): void + }> +): Promise { + if (items.length === 0) { + return + } + await options.withTransientDemand('identity-reset-revocations', async () => { + const broker = await options.requireActiveBroker() + for (const item of items) { + if (broker.hostId !== item.relayHostId || broker.ownerIdentityKey !== item.ownerIdentityKey) { + throw new Error('relay_revoke_identity_unavailable') + } + await broker.revokeDevice(item.relayDeviceId, item.reqId) + options.revokeOutbox.remove(item.reqId) + } + options.refreshDemand() + }) +} diff --git a/src/main/runtime/relay/desktop-relay-pairing-operations.ts b/src/main/runtime/relay/desktop-relay-pairing-operations.ts new file mode 100644 index 00000000000..70e60af1400 --- /dev/null +++ b/src/main/runtime/relay/desktop-relay-pairing-operations.ts @@ -0,0 +1,147 @@ +import type { MobilePairingConnectionContext, OrcaRuntimeRpcServer } from '../runtime-rpc' +import type { + DeviceCredentialInstalled, + PairingGetEndpointsParams, + PairingGetEndpointsResult, + PairingProvisionRelayParams +} from '../../../shared/mobile-relay-credential-contract' +import type { PairingRelay } from '../../../shared/mobile-relay-pairing-offer' +import type { RelaySessionBroker } from './relay-session-broker' +import type { RelayDeviceBinding } from './relay-revoke-outbox' +import { assertDesktopRelayIdentityResetOpen } from './desktop-relay-identity-reset' + +type RelayTransientDemand = (key: string, operation: () => Promise) => Promise + +export type DesktopRelayPairingOperations = Readonly<{ + runtimeRpc: Pick< + OrcaRuntimeRpcServer, + 'getDeviceRegistry' | 'setMobileRelayBinding' | 'isIdentityResetInProgress' + > + withTransientDemand: RelayTransientDemand + activeBrokerForDemand(): Promise + requireActiveBroker(): Promise + requireMobileDevice(deviceId: string): void + assertRelayHost(context: MobilePairingConnectionContext, broker: RelaySessionBroker): void + refreshDemand(): void +}> + +export async function createDesktopPairingRelay( + relayDeviceId: string, + options: DesktopRelayPairingOperations +): Promise<{ relay: PairingRelay; binding: RelayDeviceBinding }> { + assertDesktopRelayIdentityResetOpen(options.runtimeRpc) + return await options.withTransientDemand(`pairing:${relayDeviceId}`, async () => { + const broker = await options.requireActiveBroker() + const relay = await broker.createPairingRelay(relayDeviceId) + return { + relay, + binding: { + relayHostId: broker.hostId, + relayDeviceId, + ownerIdentityKey: broker.ownerIdentityKey, + inviteExpiresAt: relay.inviteExpiresAt + } + } + }) +} + +export async function getDesktopPairingEndpoints( + context: MobilePairingConnectionContext, + params: PairingGetEndpointsParams, + options: DesktopRelayPairingOperations +): Promise { + assertDesktopRelayIdentityResetOpen(options.runtimeRpc) + options.requireMobileDevice(context.deviceId) + if ( + options.runtimeRpc.getDeviceRegistry()?.getMobilePairingConnectionMode(context.deviceId) === + 'local-only' + ) { + return { v: 1, relay: null } + } + return await options.withTransientDemand(`endpoints:${context.deviceId}`, async () => { + const broker = await options.activeBrokerForDemand() + if (!broker?.endpoint) { + return { v: 1, relay: null } + } + options.assertRelayHost(context, broker) + const result: PairingGetEndpointsResult = { v: 1, relay: broker.endpoint } + if (params.installReqId) { + result.installStatus = await broker.credentialInstallStatus( + context.deviceId, + params.installReqId + ) + } + if (params.resumeConfirmReqId) { + if ( + context.transport.transport !== 'relay' || + context.transport.credentialKind !== 'resume' + ) { + throw new Error('resume_confirmation_unavailable') + } + result.resumeConfirmation = await broker.confirmResume( + context.transport.basisConnId, + params.resumeConfirmReqId + ) + } + return result + }) +} + +export async function provisionDesktopRelay( + context: MobilePairingConnectionContext, + params: PairingProvisionRelayParams, + options: DesktopRelayPairingOperations +): Promise { + assertDesktopRelayIdentityResetOpen(options.runtimeRpc) + options.requireMobileDevice(context.deviceId) + if ( + options.runtimeRpc.getDeviceRegistry()?.getMobilePairingConnectionMode(context.deviceId) === + 'local-only' + ) { + throw new Error('relay_disabled_for_device') + } + return await options.withTransientDemand(`provision:${context.deviceId}`, async () => { + const broker = await options.requireActiveBroker() + if (!broker.endpoint) { + throw new Error('relay_control_not_active') + } + options.assertRelayHost(context, broker) + const authorization = pairingAuthorizationForContext(context, broker.hostId) + if (!authorization) { + // Why: a resume splice proves renewal through confirmation; it cannot be repurposed as either of the two initial-install authorization modes. + throw new Error('relay_provision_authorization_unavailable') + } + if ( + !options.runtimeRpc.setMobileRelayBinding(context.deviceId, { + relayHostId: broker.hostId, + relayDeviceId: context.deviceId, + ownerIdentityKey: broker.ownerIdentityKey + }) + ) { + throw new Error('mobile_device_not_found') + } + options.refreshDemand() + return await broker.installCredential(context.deviceId, params, authorization) + }) +} + +function pairingAuthorizationForContext( + context: MobilePairingConnectionContext, + relayHostId: string +): + | { mode: 'authenticated-direct'; directAuthId: string } + | { + mode: 'relay-basis' + basisConnId: string + } + | null { + if (context.transport.transport === 'direct') { + return { mode: 'authenticated-direct', directAuthId: context.connectionId } + } + if (context.transport.relayHostId !== relayHostId) { + throw new Error('stale_relay_connection') + } + return context.transport.credentialKind === 'invite' + ? { mode: 'relay-basis', basisConnId: context.transport.basisConnId } + : null +} diff --git a/src/main/runtime/relay/desktop-relay-service.ts b/src/main/runtime/relay/desktop-relay-service.ts index 575cbe23bbc..f7f829c0f32 100644 --- a/src/main/runtime/relay/desktop-relay-service.ts +++ b/src/main/runtime/relay/desktop-relay-service.ts @@ -18,6 +18,16 @@ import type { import type { DeviceCredentialInstallAuthorization } from './relay-control-requests' import { deriveRelayHostId } from './relay-http-client' import { RelayDemandLedger } from './relay-demand-ledger' +import { + awaitDesktopRelayIdentityResetRevocations, + completeDesktopRelayIdentityReset +} from './desktop-relay-identity-reset' +import { + createDesktopPairingRelay, + getDesktopPairingEndpoints, + provisionDesktopRelay, + type DesktopRelayPairingOperations +} from './desktop-relay-pairing-operations' type DesktopRelayServiceOptions = { authConfig: OrcaCloudAuthConfig @@ -57,9 +67,8 @@ export class DesktopRelayService { private stopped = false constructor(options: DesktopRelayServiceOptions) { - const keypair = options.runtimeRpc.getE2EEKeypair() - const mobileSocketWiring = options.runtimeRpc.getMobileSocketWiring() - if (!keypair || !mobileSocketWiring) { + const initialKeypair = options.runtimeRpc.getE2EEKeypair() + if (!initialKeypair || !options.runtimeRpc.getMobileSocketWiring()) { throw new Error('mobile_runtime_not_ready') } this.runtimeRpc = options.runtimeRpc @@ -67,7 +76,7 @@ export class DesktopRelayService { this.demandLedger = new RelayDemandLedger({ deviceRegistry: options.runtimeRpc.getDeviceRegistry()!, revokeOutbox: this.revokeOutbox, - relayHostId: deriveRelayHostId(keypair.publicKey) + relayHostId: deriveRelayHostId(initialKeypair.publicKey) }) this.coordinator = new RelayAuthCoordinator({ readContext: () => readRelayAuthContext(options.authConfig, options.userDataPath), @@ -76,6 +85,11 @@ export class DesktopRelayService { `${identity.userId}\0${identity.profileId}\0${identity.organizationId}` ), openBroker: async ({ context, isCurrent, refreshAccessToken }) => { + const keypair = this.runtimeRpc.getE2EEKeypair() + const mobileSocketWiring = this.runtimeRpc.getMobileSocketWiring() + if (!keypair || !mobileSocketWiring) { + throw new Error('mobile_runtime_not_ready') + } const broker = await RelaySessionBroker.connect({ authConfig: options.authConfig, accessToken: context.accessToken, @@ -123,19 +137,7 @@ export class DesktopRelayService { async createPairingRelay( relayDeviceId: string ): Promise<{ relay: PairingRelay; binding: RelayDeviceBinding }> { - return await this.withTransientDemand(`pairing:${relayDeviceId}`, async () => { - const broker = await this.requireActiveBroker() - const relay = await broker.createPairingRelay(relayDeviceId) - return { - relay, - binding: { - relayHostId: broker.hostId, - relayDeviceId, - ownerIdentityKey: broker.ownerIdentityKey, - inviteExpiresAt: relay.inviteExpiresAt - } - } - }) + return await createDesktopPairingRelay(relayDeviceId, this.pairingOperations()) } onDeviceRevokeQueued(item: RelayRevokeOutboxItem): void { @@ -154,83 +156,40 @@ export class DesktopRelayService { context: MobilePairingConnectionContext, params: PairingGetEndpointsParams ): Promise { - this.requireMobileDevice(context.deviceId) - if ( - this.runtimeRpc.getDeviceRegistry()?.getMobilePairingConnectionMode(context.deviceId) === - 'local-only' - ) { - return { v: 1, relay: null } - } - return await this.withTransientDemand(`endpoints:${context.deviceId}`, async () => { - const broker = await this.activeBrokerForDemand() - if (!broker?.endpoint) { - return { v: 1, relay: null } - } - this.assertRelayHost(context, broker) - const result: PairingGetEndpointsResult = { v: 1, relay: broker.endpoint } - if (params.installReqId) { - result.installStatus = await broker.credentialInstallStatus( - context.deviceId, - params.installReqId - ) - } - if (params.resumeConfirmReqId) { - if ( - context.transport.transport !== 'relay' || - context.transport.credentialKind !== 'resume' - ) { - throw new Error('resume_confirmation_unavailable') - } - result.resumeConfirmation = await broker.confirmResume( - context.transport.basisConnId, - params.resumeConfirmReqId - ) - } - return result - }) + return await getDesktopPairingEndpoints(context, params, this.pairingOperations()) } async provisionRelay( context: MobilePairingConnectionContext, params: PairingProvisionRelayParams ): Promise { - this.requireMobileDevice(context.deviceId) - if ( - this.runtimeRpc.getDeviceRegistry()?.getMobilePairingConnectionMode(context.deviceId) === - 'local-only' - ) { - throw new Error('relay_disabled_for_device') - } - return await this.withTransientDemand(`provision:${context.deviceId}`, async () => { - const broker = await this.requireActiveBroker() - if (!broker.endpoint) { - throw new Error('relay_control_not_active') - } - this.assertRelayHost(context, broker) - const authorization = pairingAuthorizationForContext(context, broker.hostId) - if (!authorization) { - // Why: a resume splice proves renewal through confirmation; it cannot be - // repurposed as either of the two initial-install authorization modes. - throw new Error('relay_provision_authorization_unavailable') - } - if ( - !this.runtimeRpc.setMobileRelayBinding(context.deviceId, { - relayHostId: broker.hostId, - relayDeviceId: context.deviceId, - ownerIdentityKey: broker.ownerIdentityKey - }) - ) { - throw new Error('mobile_device_not_found') - } - this.refreshDemand() - return await broker.installCredential(context.deviceId, params, authorization) - }) + return await provisionDesktopRelay(context, params, this.pairingOperations()) } demandStateChanged(): void { this.refreshDemand() } + /** Reopens future brokers with the successor identity after a completed reset publication. */ + identityResetCompleted(): void { + completeDesktopRelayIdentityReset({ + runtimeRpc: this.runtimeRpc, + demandLedger: this.demandLedger, + fenceAndCloseNow: () => this.fenceAndCloseNow(), + refreshDemand: () => this.refreshDemand() + }) + } + + /** Waits for parsed relay acknowledgements; failures leave the durable item pending. */ + async awaitIdentityResetRevocations(items: readonly RelayRevokeOutboxItem[]): Promise { + await awaitDesktopRelayIdentityResetRevocations(items, { + revokeOutbox: this.revokeOutbox, + withTransientDemand: (key, operation) => this.withTransientDemand(key, operation), + requireActiveBroker: () => this.requireActiveBroker(), + refreshDemand: () => this.refreshDemand() + }) + } + stop(): void { this.stopped = true if (this.demandExpiryTimer) { @@ -256,6 +215,18 @@ export class DesktopRelayService { } } + private pairingOperations(): DesktopRelayPairingOperations { + return { + runtimeRpc: this.runtimeRpc, + withTransientDemand: (key, operation) => this.withTransientDemand(key, operation), + activeBrokerForDemand: () => this.activeBrokerForDemand(), + requireActiveBroker: () => this.requireActiveBroker(), + requireMobileDevice: (deviceId) => this.requireMobileDevice(deviceId), + assertRelayHost: (context, broker) => this.assertRelayHost(context, broker), + refreshDemand: () => this.refreshDemand() + } + } + private assertRelayHost( context: MobilePairingConnectionContext, broker: RelaySessionBroker diff --git a/src/main/runtime/relay/mobile-relay-e2ee.integration.test.ts b/src/main/runtime/relay/mobile-relay-e2ee.integration.test.ts index 1f2b50e0648..9a5ca3e63ef 100644 --- a/src/main/runtime/relay/mobile-relay-e2ee.integration.test.ts +++ b/src/main/runtime/relay/mobile-relay-e2ee.integration.test.ts @@ -132,7 +132,8 @@ describe('desktop relay E2EE integration', () => { e2eeKeypair: { publicKey: desktopKeys.publicKey, secretKey: desktopKeys.secretKey, - publicKeyB64: Buffer.from(desktopKeys.publicKey).toString('base64') + publicKeyB64: Buffer.from(desktopKeys.publicKey).toString('base64'), + installationId: 'fixture-installation-0001' }, onText: (socket, plaintext, reply, sendBinary) => { expect(socket.transport).toEqual({ diff --git a/src/main/runtime/relay/relay-control-client.test.ts b/src/main/runtime/relay/relay-control-client.test.ts index a1ad5c03482..594592b18fc 100644 --- a/src/main/runtime/relay/relay-control-client.test.ts +++ b/src/main/runtime/relay/relay-control-client.test.ts @@ -115,7 +115,8 @@ describe('RelayControlClient', () => { identity: { userId: 'user-1', profileId: 'profile-1', organizationId: 'org-1' }, keypair: { ...keypair, - publicKeyB64: Buffer.from(keypair.publicKey).toString('base64') + publicKeyB64: Buffer.from(keypair.publicKey).toString('base64'), + installationId: 'fixture-installation-0001' }, appVersion: '1.2.3', onConnectionOpen: vi.fn(), @@ -145,7 +146,8 @@ describe('RelayControlClient', () => { identity: { userId: 'user-1', profileId: 'profile-1', organizationId: 'org-1' }, keypair: { ...keypair, - publicKeyB64: Buffer.from(keypair.publicKey).toString('base64') + publicKeyB64: Buffer.from(keypair.publicKey).toString('base64'), + installationId: 'fixture-installation-0001' }, appVersion: '1.2.3', onConnectionOpen: vi.fn(), @@ -177,7 +179,8 @@ describe('RelayControlClient', () => { const keypair: E2EEKeypair = { publicKey: hostKeys.publicKey, secretKey: hostKeys.secretKey, - publicKeyB64: Buffer.from(hostKeys.publicKey).toString('base64') + publicKeyB64: Buffer.from(hostKeys.publicKey).toString('base64'), + installationId: 'fixture-installation-0001' } const relayHostId = createHash('sha256') .update(hostKeys.publicKey) @@ -415,7 +418,8 @@ function scriptedControl(options: { closeWithAck?: boolean; issuedAtOffsetMs?: n const keypair: E2EEKeypair = { publicKey: hostKeys.publicKey, secretKey: hostKeys.secretKey, - publicKeyB64: Buffer.from(hostKeys.publicKey).toString('base64') + publicKeyB64: Buffer.from(hostKeys.publicKey).toString('base64'), + installationId: 'fixture-installation-0001' } const origin = 'http://relay.test' const relayHostId = createHash('sha256') diff --git a/src/main/runtime/relay/relay-demand-ledger.ts b/src/main/runtime/relay/relay-demand-ledger.ts index 8bcc1a6a2df..9046f1edc54 100644 --- a/src/main/runtime/relay/relay-demand-ledger.ts +++ b/src/main/runtime/relay/relay-demand-ledger.ts @@ -16,6 +16,10 @@ export class RelayDemandLedger { this.options = options } + setRelayHostId(relayHostId: string): void { + this.options.relayHostId = relayHostId + } + acquireTransient(key: string): () => void { this.transientRefs.set(key, (this.transientRefs.get(key) ?? 0) + 1) let released = false diff --git a/src/main/runtime/relay/relay-http-client.test.ts b/src/main/runtime/relay/relay-http-client.test.ts index 213d27629d6..c41185a4cd7 100644 --- a/src/main/runtime/relay/relay-http-client.test.ts +++ b/src/main/runtime/relay/relay-http-client.test.ts @@ -15,7 +15,8 @@ describe('relay HTTP client', () => { accessToken: 'ordinary-access-token', keypair: { ...keypair, - publicKeyB64: Buffer.from(keypair.publicKey).toString('base64') + publicKeyB64: Buffer.from(keypair.publicKey).toString('base64'), + installationId: 'fixture-installation-0001' }, fetch }) @@ -143,7 +144,8 @@ describe('relay HTTP client', () => { accessToken: 'ordinary-access-token', keypair: { ...keypair, - publicKeyB64: Buffer.from(keypair.publicKey).toString('base64') + publicKeyB64: Buffer.from(keypair.publicKey).toString('base64'), + installationId: 'fixture-installation-0001' }, requestDeadlineMs: 5, fetch @@ -185,7 +187,8 @@ describe('relay HTTP client', () => { accessToken: 'ordinary-access-token', keypair: { ...keypair, - publicKeyB64: Buffer.from(keypair.publicKey).toString('base64') + publicKeyB64: Buffer.from(keypair.publicKey).toString('base64'), + installationId: 'fixture-installation-0001' }, fetch }) diff --git a/src/main/runtime/relay/relay-revoke-outbox.test.ts b/src/main/runtime/relay/relay-revoke-outbox.test.ts index 2ee9e515886..dfa4a0264d0 100644 --- a/src/main/runtime/relay/relay-revoke-outbox.test.ts +++ b/src/main/runtime/relay/relay-revoke-outbox.test.ts @@ -1,9 +1,13 @@ -import { mkdtempSync, rmSync } from 'node:fs' +import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import type * as SecureFileModule from '../../../shared/secure-file' -import { RelayRevokeOutbox } from './relay-revoke-outbox' +import { + loadRelayRevokeOutboxForReset, + RELAY_REVOKE_OUTBOX_FILENAME, + RelayRevokeOutbox +} from './relay-revoke-outbox' const secureFileMocks = vi.hoisted(() => ({ failWrites: false })) @@ -82,4 +86,36 @@ describe('RelayRevokeOutbox', () => { secureFileMocks.failWrites = false expect(outbox.pendingFor(binding.ownerIdentityKey, binding.relayHostId)).toEqual([item]) }) + + it('propagates corrupt outbox state through the reset loader', () => { + const path = mkdtempSync(join(tmpdir(), 'orca-relay-revoke-')) + paths.push(path) + writeFileSync(join(path, RELAY_REVOKE_OUTBOX_FILENAME), '{invalid') + + expect(() => loadRelayRevokeOutboxForReset(path)).toThrow('Relay revoke outbox is invalid') + expect(new RelayRevokeOutbox(path).pendingFor('owner', 'host')).toEqual([]) + }) + + it('rejects missing state only through the reset loader', () => { + const path = mkdtempSync(join(tmpdir(), 'orca-relay-revoke-')) + paths.push(path) + + expect(() => loadRelayRevokeOutboxForReset(path)).toThrow('Relay revoke outbox is missing') + expect(new RelayRevokeOutbox(path).pendingFor('owner', 'host')).toEqual([]) + }) + + it.each(['symlink', 'directory'] as const)('rejects a %s reset outbox path', (kind) => { + const path = mkdtempSync(join(tmpdir(), 'orca-relay-revoke-')) + const outside = mkdtempSync(join(tmpdir(), 'orca-relay-revoke-target-')) + paths.push(path, outside) + const target = join(outside, 'outbox.json') + writeFileSync(target, '[]') + if (kind === 'symlink') { + symlinkSync(target, join(path, RELAY_REVOKE_OUTBOX_FILENAME)) + } else { + mkdirSync(join(path, RELAY_REVOKE_OUTBOX_FILENAME)) + } + + expect(() => loadRelayRevokeOutboxForReset(path)).toThrow('Relay revoke outbox is invalid') + }) }) diff --git a/src/main/runtime/relay/relay-revoke-outbox.ts b/src/main/runtime/relay/relay-revoke-outbox.ts index 473f619e134..911626e63f6 100644 --- a/src/main/runtime/relay/relay-revoke-outbox.ts +++ b/src/main/runtime/relay/relay-revoke-outbox.ts @@ -1,7 +1,12 @@ import { randomUUID } from 'node:crypto' -import { existsSync, readFileSync } from 'node:fs' +import { existsSync, lstatSync, readFileSync, statSync } from 'node:fs' import { join } from 'node:path' -import { hardenExistingSecureFile, writeSecureJsonFile } from '../../../shared/secure-file' +import { + assertSecureRegularFile, + hardenExistingSecureFile, + writeSecureJsonFile +} from '../../../shared/secure-file' +import { RELAY_REVOKE_OUTBOX_FILENAME } from '../mobile-pairing-files' export type RelayDeviceBinding = { relayHostId: string @@ -15,7 +20,13 @@ export type RelayRevokeOutboxItem = RelayDeviceBinding & { createdAt: number } -const OUTBOX_FILENAME = 'mobile-relay-revoke-outbox.json' +const MAX_OUTBOX_FILE_BYTES = 256 * 1024 + +export { RELAY_REVOKE_OUTBOX_FILENAME } + +type RelayRevokeOutboxLoadOptions = { + strict?: boolean +} function isItem(value: unknown): value is RelayRevokeOutboxItem { if (!value || typeof value !== 'object') { @@ -34,13 +45,27 @@ function isItem(value: unknown): value is RelayRevokeOutboxItem { ) } +function isStrictItem(value: unknown): value is RelayRevokeOutboxItem { + if (!isItem(value)) { + return false + } + return ( + boundedText(value.reqId, 128) && + boundedText(value.relayHostId, 256) && + boundedText(value.relayDeviceId, 256) && + boundedText(value.ownerIdentityKey, 1024) && + finiteTimestamp(value.createdAt) && + (value.inviteExpiresAt === undefined || finiteTimestamp(value.inviteExpiresAt)) + ) +} + export class RelayRevokeOutbox { private readonly path: string private items: RelayRevokeOutboxItem[] - constructor(userDataPath: string) { - this.path = join(userDataPath, OUTBOX_FILENAME) - this.items = this.load() + constructor(userDataPath: string, options: RelayRevokeOutboxLoadOptions = {}) { + this.path = join(userDataPath, RELAY_REVOKE_OUTBOX_FILENAME) + this.items = this.load(options.strict === true) } enqueue(binding: RelayDeviceBinding): RelayRevokeOutboxItem { @@ -66,6 +91,16 @@ export class RelayRevokeOutbox { ) } + /** Returns the bounded durable queue for the ordered identity reset phase. */ + listPending(): readonly RelayRevokeOutboxItem[] { + return this.items + } + + /** Makes an empty queue durable before a reset transaction records its intent. */ + ensureDurable(): void { + this.save(this.items) + } + remove(reqId: string): void { const next = this.items.filter((item) => item.reqId !== reqId) if (next.length === this.items.length) { @@ -75,15 +110,54 @@ export class RelayRevokeOutbox { this.items = next } - private load(): RelayRevokeOutboxItem[] { - if (!existsSync(this.path)) { + private load(strict: boolean): RelayRevokeOutboxItem[] { + if (strict) { + try { + lstatSync(this.path) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + throw new Error('Relay revoke outbox is missing') + } + throw new Error('Relay revoke outbox is unavailable') + } + } else if (!existsSync(this.path)) { return [] } try { + if (strict) { + assertSecureRegularFile(this.path, 'Relay revoke outbox') + } hardenExistingSecureFile(this.path) + if (strict && statSync(this.path).size > MAX_OUTBOX_FILE_BYTES) { + throw new Error('Relay revoke outbox is too large') + } const parsed: unknown = JSON.parse(readFileSync(this.path, 'utf-8')) - return Array.isArray(parsed) ? parsed.filter(isItem) : [] - } catch { + if (!Array.isArray(parsed)) { + throw new Error('Relay revoke outbox is invalid') + } + if (strict) { + const reqIds = new Set() + const bindings = new Set() + for (const item of parsed) { + if (!isStrictItem(item)) { + throw new Error('Relay revoke outbox is invalid') + } + const bindingKey = `${item.relayHostId}\0${item.relayDeviceId}\0${item.ownerIdentityKey}` + if (reqIds.has(item.reqId) || bindings.has(bindingKey)) { + throw new Error('Relay revoke outbox is invalid') + } + reqIds.add(item.reqId) + bindings.add(bindingKey) + } + } + return parsed.filter(isItem) + } catch (error) { + if (strict) { + if (error instanceof Error && error.message.startsWith('Relay revoke outbox')) { + throw error + } + throw new Error('Relay revoke outbox is invalid') + } return [] } } @@ -92,3 +166,16 @@ export class RelayRevokeOutbox { writeSecureJsonFile(this.path, items) } } + +function boundedText(value: unknown, maxLength: number): value is string { + return typeof value === 'string' && value.length > 0 && value.length <= maxLength +} + +function finiteTimestamp(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 +} + +/** Strict reset loader kept separate from ordinary fail-open relay cleanup. */ +export function loadRelayRevokeOutboxForReset(userDataPath: string): RelayRevokeOutbox { + return new RelayRevokeOutbox(userDataPath, { strict: true }) +} diff --git a/src/main/runtime/relay/relay-session-broker.test.ts b/src/main/runtime/relay/relay-session-broker.test.ts index 1fd172c2537..174371b1bd6 100644 --- a/src/main/runtime/relay/relay-session-broker.test.ts +++ b/src/main/runtime/relay/relay-session-broker.test.ts @@ -127,7 +127,8 @@ describe('RelaySessionBroker lifecycle ownership', () => { identity: { userId: 'user-1', profileId: 'profile-1', organizationId: 'org-1' }, keypair: { ...keypair, - publicKeyB64: Buffer.from(keypair.publicKey).toString('base64') + publicKeyB64: Buffer.from(keypair.publicKey).toString('base64'), + installationId: 'fixture-installation-0001' }, appVersion: '1.0.0', mobileSocketWiring: { attachTransport: vi.fn(() => detachTransport) } as never, @@ -476,7 +477,8 @@ function brokerOptions( identity: { userId: 'user-1', profileId: 'profile-1', organizationId: 'org-1' }, keypair: { ...keypair, - publicKeyB64: Buffer.from(keypair.publicKey).toString('base64') + publicKeyB64: Buffer.from(keypair.publicKey).toString('base64'), + installationId: 'fixture-installation-0001' }, appVersion: '1.0.0', mobileSocketWiring: { attachTransport: vi.fn(() => () => {}) } as never, diff --git a/src/main/runtime/rpc/mobile-auth-acl-critical-path.test.ts b/src/main/runtime/rpc/mobile-auth-acl-critical-path.test.ts index 35225f72f9c..0030c1b29cb 100644 --- a/src/main/runtime/rpc/mobile-auth-acl-critical-path.test.ts +++ b/src/main/runtime/rpc/mobile-auth-acl-critical-path.test.ts @@ -116,7 +116,8 @@ describe('mobile auth critical path', () => { e2eeKeypair: { publicKey: desktop.publicKey, secretKey: desktop.secretKey, - publicKeyB64: Buffer.from(desktop.publicKey).toString('base64') + publicKeyB64: Buffer.from(desktop.publicKey).toString('base64'), + installationId: 'fixture-installation-0001' }, onText: vi.fn(), onBinary: vi.fn(), diff --git a/src/main/runtime/rpc/mobile-socket-wiring.test.ts b/src/main/runtime/rpc/mobile-socket-wiring.test.ts index 505cf63feb6..ddd26ce6957 100644 --- a/src/main/runtime/rpc/mobile-socket-wiring.test.ts +++ b/src/main/runtime/rpc/mobile-socket-wiring.test.ts @@ -83,7 +83,8 @@ describe('MobileSocketWiring', () => { e2eeKeypair: { publicKey: desktop.publicKey, secretKey: desktop.secretKey, - publicKeyB64: Buffer.from(desktop.publicKey).toString('base64') + publicKeyB64: Buffer.from(desktop.publicKey).toString('base64'), + installationId: 'fixture-installation-0001' }, onText: vi.fn(), onBinary: vi.fn(), @@ -111,7 +112,8 @@ describe('MobileSocketWiring', () => { e2eeKeypair: { publicKey: desktop.publicKey, secretKey: desktop.secretKey, - publicKeyB64: Buffer.from(desktop.publicKey).toString('base64') + publicKeyB64: Buffer.from(desktop.publicKey).toString('base64'), + installationId: 'fixture-installation-0001' }, onText: vi.fn(), onBinary: vi.fn(), @@ -147,7 +149,8 @@ describe('MobileSocketWiring', () => { e2eeKeypair: { publicKey: desktop.publicKey, secretKey: desktop.secretKey, - publicKeyB64: Buffer.from(desktop.publicKey).toString('base64') + publicKeyB64: Buffer.from(desktop.publicKey).toString('base64'), + installationId: 'fixture-installation-0001' }, onText, onBinary: vi.fn(), @@ -205,7 +208,8 @@ describe('MobileSocketWiring', () => { e2eeKeypair: { publicKey: desktop.publicKey, secretKey: desktop.secretKey, - publicKeyB64: Buffer.from(desktop.publicKey).toString('base64') + publicKeyB64: Buffer.from(desktop.publicKey).toString('base64'), + installationId: 'fixture-installation-0001' }, onText: vi.fn(), onBinary: vi.fn(), @@ -253,7 +257,8 @@ describe('MobileSocketWiring', () => { e2eeKeypair: { publicKey: currentDesktop.publicKey, secretKey: currentDesktop.secretKey, - publicKeyB64: Buffer.from(currentDesktop.publicKey).toString('base64') + publicKeyB64: Buffer.from(currentDesktop.publicKey).toString('base64'), + installationId: 'fixture-installation-0001' }, onText: vi.fn(), onBinary: vi.fn(), @@ -298,7 +303,8 @@ describe('MobileSocketWiring', () => { e2eeKeypair: { publicKey: desktop.publicKey, secretKey: desktop.secretKey, - publicKeyB64: Buffer.from(desktop.publicKey).toString('base64') + publicKeyB64: Buffer.from(desktop.publicKey).toString('base64'), + installationId: 'fixture-installation-0001' }, onText: vi.fn(), onBinary: vi.fn(), diff --git a/src/main/runtime/rpc/mobile-socket-wiring.ts b/src/main/runtime/rpc/mobile-socket-wiring.ts index 43004be4582..bd95154b4cf 100644 --- a/src/main/runtime/rpc/mobile-socket-wiring.ts +++ b/src/main/runtime/rpc/mobile-socket-wiring.ts @@ -53,6 +53,7 @@ type MobileSocketWiringOptions = { onBinary: (socket: AuthenticatedMobileSocket, bytes: Uint8Array) => void onClose: (socket: AuthenticatedMobileSocket | null, hasOtherConnections: boolean) => void onReady?: (socket: AuthenticatedMobileSocket) => void + isAdmissionAllowed?: () => boolean // Why: stale keys and missing registry entries both fail before RPC can explain the re-pair action. onUnpairedDeviceAuthFailure?: (metadata: MobileSocketTransportMetadata) => void } @@ -66,12 +67,13 @@ function toAuthenticatedDevice(device: DeviceEntry): E2EEAuthenticatedDevice { } export class MobileSocketWiring { - private readonly deviceRegistry: DeviceRegistry - private readonly e2eeKeypair: E2EEKeypair + private deviceRegistry: DeviceRegistry + private e2eeKeypair: E2EEKeypair private readonly onText: MobileSocketWiringOptions['onText'] private readonly onBinary: MobileSocketWiringOptions['onBinary'] private readonly onClose: MobileSocketWiringOptions['onClose'] private readonly onReady: MobileSocketWiringOptions['onReady'] + private readonly isAdmissionAllowed: MobileSocketWiringOptions['isAdmissionAllowed'] private readonly onUnpairedDeviceAuthFailure: MobileSocketWiringOptions['onUnpairedDeviceAuthFailure'] private readonly channels = new Map() private readonly connectionIds = new Map() @@ -86,6 +88,7 @@ export class MobileSocketWiring { this.onBinary = options.onBinary this.onClose = options.onClose this.onReady = options.onReady + this.isAdmissionAllowed = options.isAdmissionAllowed this.onUnpairedDeviceAuthFailure = options.onUnpairedDeviceAuthFailure } @@ -130,6 +133,31 @@ export class MobileSocketWiring { return terminated } + listAuthenticatedSockets(): readonly AuthenticatedMobileSocket[] { + return [...this.authenticatedSockets.values()] + } + + terminateAllConnections(): number { + const tokens = new Set( + [...this.authenticatedSockets.values()].map((socket) => socket.device.deviceToken) + ) + let terminated = 0 + for (const token of tokens) { + terminated += this.terminateDeviceConnections(token) + } + for (const ws of this.channels.keys()) { + if (!this.authenticatedSockets.has(ws)) { + ws.close() + } + } + return terminated + } + + replaceIdentity(deviceRegistry: DeviceRegistry, e2eeKeypair: E2EEKeypair): void { + this.deviceRegistry = deviceRegistry + this.e2eeKeypair = e2eeKeypair + } + private handleRawMessage( transport: MobileSocketTransport, ws: WebSocket, @@ -149,6 +177,9 @@ export class MobileSocketWiring { requireV2: metadata.transport === 'relay', outboundMemoryBudget: this.outboundMemoryBudget, resolveAuthenticatedDevice: (token) => { + if (this.isAdmissionAllowed && !this.isAdmissionAllowed()) { + return null + } const device = this.deviceRegistry.validateToken(token) if (!device) { return null diff --git a/src/main/runtime/runtime-metadata.test.ts b/src/main/runtime/runtime-metadata.test.ts index 77b9228aa9f..78c012e76cf 100644 --- a/src/main/runtime/runtime-metadata.test.ts +++ b/src/main/runtime/runtime-metadata.test.ts @@ -11,6 +11,7 @@ import { } from '../../shared/runtime-environment-store' import { DeviceRegistry } from './device-registry' import { loadOrCreateE2EEKeypair } from './e2ee-keypair' +import { E2EE_IDENTITY_MARKER_FILENAME } from './mobile-pairing-files' import { clearRuntimeMetadata, clearRuntimeMetadataIfOwned, @@ -176,8 +177,8 @@ describe('runtime metadata', () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-secure-files-')) tempDirs.push(userDataPath) - new DeviceRegistry(userDataPath).addDevice('phone') loadOrCreateE2EEKeypair(userDataPath) + new DeviceRegistry(userDataPath).addDevice('phone') addEnvironmentFromPairingCode(userDataPath, { name: 'desk', pairingCode: encodePairingOffer({ @@ -191,6 +192,7 @@ describe('runtime metadata', () => { for (const path of [ join(userDataPath, 'orca-devices.json'), join(userDataPath, 'orca-e2ee-keypair.json'), + join(userDataPath, E2EE_IDENTITY_MARKER_FILENAME), getEnvironmentStorePath(userDataPath) ]) { expect(statSync(path).mode & 0o777).toBe(0o600) @@ -216,9 +218,11 @@ describe('runtime metadata', () => { name: 'desk', pairingCode }) + const identity = loadOrCreateE2EEKeypair(userDataPath) const devicesPath = join(userDataPath, 'orca-devices.json') const keypairPath = join(userDataPath, 'orca-e2ee-keypair.json') + const markerPath = join(userDataPath, E2EE_IDENTITY_MARKER_FILENAME) const environmentsPath = getEnvironmentStorePath(userDataPath) writeFileSync( devicesPath, @@ -232,11 +236,7 @@ describe('runtime metadata', () => { } ]) ) - writeFileSync( - keypairPath, - JSON.stringify({ v: 1, publicKeyB64: keyMaterial, secretKeyB64: keyMaterial }) - ) - for (const path of [devicesPath, keypairPath, environmentsPath]) { + for (const path of [devicesPath, keypairPath, markerPath, environmentsPath]) { chmodSync(path, 0o644) } chmodSync(userDataPath, 0o755) @@ -245,25 +245,23 @@ describe('runtime metadata', () => { token: 'token', scope: 'mobile' }) - expect(loadOrCreateE2EEKeypair(userDataPath).publicKeyB64).toBe(keyMaterial) + expect(loadOrCreateE2EEKeypair(userDataPath).publicKeyB64).toBe(identity.publicKeyB64) expect(listEnvironments(userDataPath)[0]?.id).toBe(environment.id) - for (const path of [devicesPath, keypairPath, environmentsPath]) { + for (const path of [devicesPath, keypairPath, markerPath, environmentsPath]) { expect(statSync(path).mode & 0o777).toBe(0o600) } expect(statSync(userDataPath).mode & 0o777).toBe(0o700) } ) - it('replaces oversized E2EE keypair files instead of reading them as metadata', () => { + it('fails closed on oversized E2EE keypair files instead of replacing them', () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-large-keypair-')) tempDirs.push(userDataPath) const keypairPath = join(userDataPath, 'orca-e2ee-keypair.json') writeFileSync(keypairPath, 'x'.repeat(9 * 1024)) - const keypair = loadOrCreateE2EEKeypair(userDataPath) - - expect(keypair.publicKey).toHaveLength(32) - expect(statSync(keypairPath).size).toBeLessThan(1024) + expect(() => loadOrCreateE2EEKeypair(userDataPath)).toThrow('too large') + expect(statSync(keypairPath).size).toBe(9 * 1024) }) }) diff --git a/src/main/runtime/runtime-rpc.test.ts b/src/main/runtime/runtime-rpc.test.ts index 32f7849183e..da236a3ef8b 100644 --- a/src/main/runtime/runtime-rpc.test.ts +++ b/src/main/runtime/runtime-rpc.test.ts @@ -26,6 +26,7 @@ import { import { decrypt, deriveSharedKey, encrypt, generateKeyPair } from './rpc/e2ee-crypto' import { WebSocketTransport } from './rpc/ws-transport' import { DeviceRegistry } from './device-registry' +import { loadOrCreateE2EEKeypair } from './e2ee-keypair' import { DEVICE_REGISTRY_FILENAME, E2EE_KEYPAIR_FILENAME } from './mobile-pairing-files' import { ORCHESTRATION_CONTRACT_VERSION } from '../../shared/protocol-version' @@ -5639,6 +5640,7 @@ describe('OrcaRuntimeRpcServer WebSocket bind host (STA-2370)', () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) // Why: a device that has actually connected (lastSeenAt > 0) may reconnect, so the listener must be // reachable at startup without waiting for a new pairing action. + loadOrCreateE2EEKeypair(userDataPath) const registry = new DeviceRegistry(userDataPath) const device = registry.getOrCreatePendingDevice('Paired phone', 'mobile') registry.updateLastSeen(device.deviceId) @@ -5669,6 +5671,7 @@ describe('OrcaRuntimeRpcServer WebSocket bind host (STA-2370)', () => { // Why: a pending device (offer created but never connected: lastSeenAt === 0) is not a reconnect, so // the listener must stay loopback — this distinguishes the reconnect widen from a blanket any-device // widen (a revert to listDevices().length > 0 would wrongly expose the LAN here). + loadOrCreateE2EEKeypair(userDataPath) const registry = new DeviceRegistry(userDataPath) registry.getOrCreatePendingDevice('Pending phone', 'mobile') @@ -5699,6 +5702,7 @@ describe('OrcaRuntimeRpcServer WebSocket bind host (STA-2370)', () => { // Why: the local web client authenticating marks its grant lastSeenAt > 0 like any other socket, so a // blanket "any connected device" widen republished the runtime on every interface one launch later — // exactly what the user declined by picking "This computer only". + loadOrCreateE2EEKeypair(userDataPath) const registry = new DeviceRegistry(userDataPath) const device = registry.getOrCreatePendingDevice('Runtime local', 'runtime', 'this-computer') registry.updateLastSeen(device.deviceId) @@ -5722,6 +5726,7 @@ describe('OrcaRuntimeRpcServer WebSocket bind host (STA-2370)', () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) // Why: registries written by older desktops only ever held network-reach grants; a missing field must // keep the reconnect widen or an already-paired phone would be stranded by the upgrade. + loadOrCreateE2EEKeypair(userDataPath) const legacyDevice = { deviceId: 'legacy-device', name: 'Legacy phone', diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index b6cb7ecf259..91b4a5f9eaa 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -21,13 +21,33 @@ import { WebSocketTransport } from './rpc/ws-transport' import { readWsFallbackPort, writeWsFallbackPort } from './rpc/ws-fallback-port-store' import type { WebSocket } from 'ws' import { DeviceRegistry, type DeviceEntry, type DeviceScope } from './device-registry' -import { loadOrCreateE2EEKeypair, type E2EEKeypair } from './e2ee-keypair' +import { + loadE2EEKeypairForIdentityReset, + loadOrCreateE2EEKeypair, + type E2EEKeypair, + type E2EEKeypairResetTransaction +} from './e2ee-keypair' +import { + E2EEIdentityResetCoordinator, + type E2EEIdentityResetResult, + type E2EEIdentityResetStatus +} from './e2ee-identity-reset' +import { E2EEIdentityResetRecordStore } from './e2ee-identity-reset-record' +import { loadDeviceRegistryForReset } from './device-registry' +import { loadRelayRevokeOutboxForReset } from './relay/relay-revoke-outbox' import { UnpairedDeviceAuthThrottle } from './rpc/unpaired-device-auth-throttle' import { MobileSocketWiring, type AuthenticatedMobileSocket, type MobileSocketTransportMetadata } from './rpc/mobile-socket-wiring' +import { + disposePtyAuthorityOutcomeResetTransports, + getPtyAuthorityOutcomeProcessIncarnationId, + listPtyAuthorityOutcomeResetTargets, + setPtyAuthorityOutcomeAdmissionFrozen +} from '../ipc/pty-authority-outcome-ipc' +import { invalidateTerminalAuthorityConsumerProofKeypair } from '../session-authority/terminal-authority-consumer-proof-keypair' import type { PairingRelay } from '../../shared/mobile-relay-pairing-offer' import type { MobilePairingConnectionMode } from '../../shared/mobile-pairing-connection-mode' import type { RuntimePairingReach } from '../../shared/runtime-pairing-reach' @@ -78,6 +98,10 @@ type OrcaRuntimeRpcServerOptions = { longPollCap?: number // Why: test-only override for the ownership reclaim cadence. metadataOwnershipPollMs?: number + identityResetHooks?: Readonly<{ + disconnectSshTargets(): Promise + refreshDaemonProofIdentity(): Promise + }> } export type PairingOfferUnavailableReason = @@ -87,6 +111,7 @@ export type PairingOfferUnavailableReason = | 'invalid_advertised_endpoint' | 'relay_mint_failed' | 'network_exposure_failed' + | 'identity_reset_in_progress' export type PairingOfferUnavailable = { available: false @@ -138,6 +163,8 @@ type MobileRelayPairingProvider = { context: MobilePairingConnectionContext, params: PairingProvisionRelayParams ): Promise + awaitIdentityResetRevocations?(items: readonly RelayRevokeOutboxItem[]): Promise + identityResetCompleted?(): void } export type MobilePairingConnectionContext = Readonly<{ @@ -496,10 +523,12 @@ export class OrcaRuntimeRpcServer { private readonly keepaliveIntervalMs: number private readonly longPollCap: number private readonly metadataOwnershipPollMs: number + private readonly identityResetHooks: OrcaRuntimeRpcServerOptions['identityResetHooks'] private readonly askLongPollCap: number private readonly relayRevokeOutbox: RelayRevokeOutbox private deviceRegistry: DeviceRegistry | null = null private e2eeKeypair: E2EEKeypair | null = null + private identityResetCoordinator: E2EEIdentityResetCoordinator | null = null private pairingInitializationFailure: PairingOfferUnavailable | null = null private tlsFingerprint: string | null = null private activeTransports: RpcTransport[] = [] @@ -545,7 +574,8 @@ export class OrcaRuntimeRpcServer { webClientRoot, keepaliveIntervalMs = KEEPALIVE_INTERVAL_MS, longPollCap = LONG_POLL_CAP, - metadataOwnershipPollMs = RUNTIME_METADATA_OWNERSHIP_POLL_MS + metadataOwnershipPollMs = RUNTIME_METADATA_OWNERSHIP_POLL_MS, + identityResetHooks }: OrcaRuntimeRpcServerOptions) { this.runtime = runtime this.dispatcher = new RpcDispatcher({ runtime }) @@ -560,6 +590,7 @@ export class OrcaRuntimeRpcServer { this.keepaliveIntervalMs = keepaliveIntervalMs this.longPollCap = longPollCap this.metadataOwnershipPollMs = metadataOwnershipPollMs + this.identityResetHooks = identityResetHooks // Why: derived, not configurable — the reservation must hold for whatever cap a caller picks. this.askLongPollCap = Math.max(1, Math.floor(longPollCap * ASK_LONG_POLL_SHARE)) this.relayRevokeOutbox = new RelayRevokeOutbox(userDataPath) @@ -589,7 +620,28 @@ export class OrcaRuntimeRpcServer { return this.relayRevokeOutbox } + getIdentityResetStatus(): E2EEIdentityResetStatus { + return ( + this.identityResetCoordinator?.status() ?? Object.freeze({ inProgress: false, record: null }) + ) + } + + async resetE2EEIdentity(): Promise { + if (!this.identityResetCoordinator) { + throw new Error('E2EE identity reset is unavailable') + } + this.relayRevokeOutbox.ensureDurable() + return await this.identityResetCoordinator.run() + } + + isIdentityResetInProgress(): boolean { + return this.identityResetCoordinator?.status().inProgress === true + } + setMobileRelayBinding(deviceId: string, binding: RelayDeviceBinding): boolean { + if (this.isIdentityResetInProgress()) { + return false + } const current = this.deviceRegistry?.getDevice(deviceId) if ( current?.scope !== 'mobile' || @@ -621,9 +673,15 @@ export class OrcaRuntimeRpcServer { setMobileRelayPairingProvider(provider: MobileRelayPairingProvider | null): void { this.mobileRelayPairingProvider = provider + if (provider) { + queueMicrotask(() => this.resumePendingIdentityReset()) + } } async revokeMobileDevice(deviceId: string): Promise { + if (this.isIdentityResetInProgress()) { + return false + } const device = this.deviceRegistry?.getDevice(deviceId) if (device?.scope !== 'mobile') { return false @@ -643,6 +701,9 @@ export class OrcaRuntimeRpcServer { } revokeRuntimeAccess(deviceId: string): boolean { + if (this.isIdentityResetInProgress()) { + return false + } const device = this.deviceRegistry?.getDevice(deviceId) if (device?.scope !== 'runtime' || !this.deviceRegistry?.removeDevice(deviceId)) { return false @@ -674,6 +735,12 @@ export class OrcaRuntimeRpcServer { deviceId: string webClientUrl: string | null } { + if (this.isIdentityResetInProgress()) { + return pairingUnavailable( + 'identity_reset_in_progress', + 'Pairing is unavailable while access is being reset. Retry after re-enrollment is ready.' + ) + } if (this.pairingInitializationFailure) { return this.pairingInitializationFailure } @@ -732,6 +799,12 @@ export class OrcaRuntimeRpcServer { name?: string rotate?: boolean }): Promise { + if (this.isIdentityResetInProgress()) { + return pairingUnavailable( + 'identity_reset_in_progress', + 'Pairing is unavailable while access is being reset. Retry after re-enrollment is ready.' + ) + } // Why: STA-2370 — creating a mobile pairing offer is the user's explicit opt-in to LAN reach, so // widen the loopback listener before advertising its LAN endpoint in the QR. If the widen fails the // listener stays on loopback, so report unavailable rather than advertise a dead LAN endpoint. @@ -994,6 +1067,23 @@ export class OrcaRuntimeRpcServer { return true } + private queueRelayDeviceRevokeOrThrow(binding: RelayDeviceBinding): RelayRevokeOutboxItem { + let item: RelayRevokeOutboxItem + try { + item = this.relayRevokeOutbox.enqueue(binding) + } catch (error) { + throw new Error( + `E2EE identity reset could not persist Relay revocation: ${error instanceof Error ? error.message : String(error)}` + ) + } + try { + this.mobileRelayPairingProvider?.onDeviceRevokeQueued(item) + } catch { + // The durable item remains pending; acknowledgement is awaited by the reset coordinator. + } + return item + } + private registerBinaryStreamHandler( connectionId: string | undefined, streamId: number, @@ -1088,11 +1178,35 @@ export class OrcaRuntimeRpcServer { } private initializePairingIdentity(): PairingIdentityInitialization { + let pendingReset: ReturnType = null + try { + pendingReset = new E2EEIdentityResetRecordStore(this.userDataPath).read() + if (pendingReset) { + // The ordinary startup loaders intentionally fail open for legacy pairing state; a + // pending reset must validate every durable participant before it can resume. + loadRelayRevokeOutboxForReset(this.userDataPath) + } + } catch (error) { + console.error('[runtime] Failed to inspect E2EE identity reset state:', error) + setPtyAuthorityOutcomeAdmissionFrozen(true) + return { + ok: false, + failure: pairingUnavailable( + 'e2ee_key_unavailable', + 'The E2EE identity reset state is unavailable. Verify that the Orca data directory is writable.' + ) + } + } let deviceRegistry: DeviceRegistry try { - deviceRegistry = new DeviceRegistry(this.userDataPath) + deviceRegistry = pendingReset + ? loadDeviceRegistryForReset(this.userDataPath) + : new DeviceRegistry(this.userDataPath) } catch (error) { console.error('[runtime] Failed to initialize pairing registry:', error) + if (pendingReset) { + setPtyAuthorityOutcomeAdmissionFrozen(true) + } return { ok: false, failure: pairingUnavailable( @@ -1103,9 +1217,21 @@ export class OrcaRuntimeRpcServer { } let e2eeKeypair: E2EEKeypair try { - e2eeKeypair = loadOrCreateE2EEKeypair(this.userDataPath) + if (pendingReset?.phase === 'creating-successor') { + const transaction: E2EEKeypairResetTransaction = { + transactionId: pendingReset.transactionId, + oldPublicKeyB64: pendingReset.oldPublicKeyB64, + phase: 'creating-successor' + } + e2eeKeypair = loadE2EEKeypairForIdentityReset(this.userDataPath, transaction) + } else { + e2eeKeypair = loadOrCreateE2EEKeypair(this.userDataPath) + } } catch (error) { console.error('[runtime] Failed to initialize E2EE identity:', error) + if (pendingReset) { + setPtyAuthorityOutcomeAdmissionFrozen(true) + } return { ok: false, failure: pairingUnavailable('e2ee_key_unavailable', E2EE_KEY_UNAVAILABLE_GUIDANCE) @@ -1114,6 +1240,85 @@ export class OrcaRuntimeRpcServer { return { ok: true, deviceRegistry, e2eeKeypair } } + private initializeIdentityResetCoordinator(): void { + if (!this.deviceRegistry || !this.e2eeKeypair || this.identityResetCoordinator) { + return + } + this.identityResetCoordinator = new E2EEIdentityResetCoordinator({ + userDataPath: this.userDataPath, + currentKeypair: () => this.e2eeKeypair, + listAuthorityTargets: () => + listPtyAuthorityOutcomeResetTargets().map((entry) => ({ + target: { + authorityHostId: entry.authenticatedAuthorityHostId, + namespaceIds: entry.namespaceIds + }, + retireNamespace: entry.retireNamespace + })), + freezeAuthorityAdmissions: () => setPtyAuthorityOutcomeAdmissionFrozen(true), + unfreezeAuthorityAdmissions: () => setPtyAuthorityOutcomeAdmissionFrozen(false), + closeLiveTransports: async () => { + this.mobileSocketWiring?.terminateAllConnections() + await this.identityResetHooks?.disconnectSshTargets() + disposePtyAuthorityOutcomeResetTransports() + }, + listRelayBindings: () => { + const bindings = + this.deviceRegistry + ?.listDevices() + .flatMap((device) => (device.relayBinding ? [device.relayBinding] : [])) ?? [] + const known = new Map( + [...bindings, ...this.relayRevokeOutbox.listPending()].map((binding) => [ + `${binding.relayHostId}\0${binding.relayDeviceId}\0${binding.ownerIdentityKey}`, + binding + ]) + ) + return [...known.values()] + }, + enqueueRelayRevoke: (binding) => this.queueRelayDeviceRevokeOrThrow(binding), + awaitRelayRevocations: async (items) => { + if (items.length === 0) { + return + } + const awaitRevocations = this.mobileRelayPairingProvider?.awaitIdentityResetRevocations + if (!awaitRevocations) { + throw new Error('E2EE identity reset relay revocation is unavailable') + } + await awaitRevocations(items) + }, + removeLocalCredentials: () => { + this.deviceRegistry?.flushPendingLastSeen() + this.deviceRegistry?.removeAllForIdentityReset() + }, + onSuccessorPublished: async (keypair) => { + const registry = this.deviceRegistry + if (!registry) { + throw new Error('E2EE identity reset pairing registry is unavailable') + } + this.e2eeKeypair = keypair + this.mobileSocketWiring?.replaceIdentity(registry, keypair) + invalidateTerminalAuthorityConsumerProofKeypair() + await this.identityResetHooks?.refreshDaemonProofIdentity() + }, + onReEnrollment: () => { + this.mobileRelayPairingProvider?.identityResetCompleted?.() + }, + getProcessIncarnationId: () => getPtyAuthorityOutcomeProcessIncarnationId() + }) + if (this.identityResetCoordinator.hasPendingRecord()) { + setPtyAuthorityOutcomeAdmissionFrozen(true) + } + } + + private resumePendingIdentityReset(): void { + if (!this.identityResetCoordinator?.hasPendingRecord()) { + return + } + void this.identityResetCoordinator.run().catch((error) => { + console.error('[runtime] E2EE identity reset remains pending:', error) + }) + } + async start(): Promise { if (this.activeTransports.length > 0) { return @@ -1177,6 +1382,7 @@ export class OrcaRuntimeRpcServer { this.deviceRegistry = pairingIdentity.deviceRegistry this.e2eeKeypair = pairingIdentity.e2eeKeypair this.pairingInitializationFailure = null + this.initializeIdentityResetCoordinator() try { const host = this.resolveInitialWebSocketBindHost() const { transport, endpoint } = await this.startWebSocketTransport({ @@ -1231,6 +1437,7 @@ export class OrcaRuntimeRpcServer { ) } }) + this.resumePendingIdentityReset() } // Why: STA-2370 — a desktop with no previously-connected device stays on loopback until the user @@ -1326,6 +1533,7 @@ export class OrcaRuntimeRpcServer { this.runtime.activateRecentPtyPathCandidateTracking?.() this.mobileRelayPairingProvider?.onDemandStateChanged?.() }, + isAdmissionAllowed: () => !this.isIdentityResetInProgress(), onClose: (socket, hasOtherConnections) => { if (!socket) { return diff --git a/src/main/runtime/unstopped-pty-verification.ts b/src/main/runtime/unstopped-pty-verification.ts index 068334eb98d..f1fd1d42475 100644 --- a/src/main/runtime/unstopped-pty-verification.ts +++ b/src/main/runtime/unstopped-pty-verification.ts @@ -53,7 +53,12 @@ export async function verifyUnstoppedPtys( } const livePtyIds = new Set(sessions.map((session) => session.id)) const stillLive = failedPtyIds.filter((ptyId) => livePtyIds.has(ptyId)) - return stillLive.length > 0 ? { status: 'live', ptyIds: stillLive } : { status: 'exited' } + return stillLive.length > 0 + ? { status: 'live', ptyIds: stillLive } + : { + status: 'unverifiable', + reason: 'provider inventory omitted the PTY without an exact exit outcome' + } } /** Names the blocking PTYs so a wedged removal is diagnosable, not just refused. */ diff --git a/src/main/runtime/worktree-pty-inventory-sweep.ts b/src/main/runtime/worktree-pty-inventory-sweep.ts new file mode 100644 index 00000000000..3087ddab1af --- /dev/null +++ b/src/main/runtime/worktree-pty-inventory-sweep.ts @@ -0,0 +1,186 @@ +import { isPathInsideOrEqual } from '../../shared/cross-platform-path' +import { mapWithConcurrency } from '../../shared/map-with-concurrency' +import { splitWorktreeId, splitWorktreeIdForFilesystem } from '../../shared/worktree-id' +import { listRegisteredPtys } from '../memory/pty-registry' +import { killListedPty } from '../providers/pty-listed-session-kill' +import type { IPtyProvider, PtyProcessInfo } from '../providers/types' + +const WORKTREE_TEARDOWN_CONCURRENCY = 32 + +export type WorktreePtyStop = ( + ptyId: string, + stop: () => Promise +) => Promise<{ stopped: boolean; owner: boolean }> + +export async function sweepProviderPtysForWorktree( + worktreeId: string, + provider: IPtyProvider, + deadline: number, + rpcDeadline: number, + stopPty: WorktreePtyStop, + onPtyStopped: ((ptyId: string) => void) | undefined, + failClosed: boolean, + loadProviderInventory: () => Promise +): Promise { + const belongsToWorktree = createListedPtyWorktreePredicate(worktreeId) + const sessions = failClosed + ? await loadProviderInventory() + : await loadProviderInventory().catch(() => []) + const sessionsById = indexListedPtysById(sessions) + const ambiguousIds = new Set( + sessions + .filter((session) => sessionsById.get(session.id) === null && belongsToWorktree(session)) + .map((session) => session.id) + ) + if (failClosed && ambiguousIds.size > 0) { + throw new Error(`worktree_pty_inventory_ambiguous:${[...ambiguousIds].join(',')}`) + } + const candidates = [...sessionsById.values()].filter( + (session): session is PtyProcessInfo => session !== null && belongsToWorktree(session) + ) + const stopped = await mapWithConcurrency( + candidates, + WORKTREE_TEARDOWN_CONCURRENCY, + async (session) => { + if (Date.now() >= deadline) { + return 0 + } + const stopResult = await stopPty(session.id, async () => { + if (Date.now() >= deadline) { + return false + } + try { + const accepted = await killListedPty(provider, session, { + immediate: true, + deadlineMs: rpcDeadline + }) + return accepted && Date.now() < deadline + } catch { + return false + } + }) + if (stopResult.owner && Date.now() < deadline) { + clearStoppedPtyState(session.id, onPtyStopped) + return 1 + } + return 0 + } + ) + return stopped.reduce((count, value) => count + value, 0) +} + +export async function sweepRegisteredPtysForWorktree( + worktreeId: string, + provider: IPtyProvider, + deadline: number, + rpcDeadline: number, + stopPty: WorktreePtyStop, + onPtyStopped: ((ptyId: string) => void) | undefined, + failClosed: boolean, + loadProviderInventory: () => Promise +): Promise { + let providerSessions: PtyProcessInfo[] + try { + providerSessions = await loadProviderInventory() + } catch (error) { + if (failClosed) { + throw error + } + return 0 + } + const providerSessionsById = indexListedPtysById(providerSessions) + const belongsToWorktree = createListedPtyWorktreePredicate(worktreeId) + const entries = listRegisteredPtys().filter((entry) => entry.worktreeId === worktreeId) + const stopped = await mapWithConcurrency( + entries, + WORKTREE_TEARDOWN_CONCURRENCY, + async (entry) => { + if (Date.now() >= deadline) { + return 0 + } + const listed = providerSessionsById.get(entry.ptyId) + if (listed === undefined) { + if (failClosed) { + throw new Error(`worktree_pty_inventory_missing:${entry.ptyId}`) + } + return 0 + } + if (listed === null) { + if (failClosed) { + throw new Error(`worktree_pty_inventory_ambiguous:${entry.ptyId}`) + } + return 0 + } + if (!belongsToWorktree(listed)) { + if (failClosed) { + throw new Error(`worktree_pty_inventory_owner_conflict:${entry.ptyId}`) + } + return 0 + } + const stopResult = await stopPty(entry.ptyId, async () => { + if (Date.now() >= deadline) { + return false + } + try { + const accepted = await killListedPty(provider, listed, { + immediate: true, + deadlineMs: rpcDeadline + }) + return accepted && Date.now() < deadline + } catch { + return false + } + }) + if (stopResult.owner && Date.now() < deadline) { + clearStoppedPtyState(entry.ptyId, onPtyStopped) + return 1 + } + return 0 + } + ) + return stopped.reduce((count, value) => count + value, 0) +} + +function createListedPtyWorktreePredicate( + worktreeId: string +): (session: PtyProcessInfo) => boolean { + const prefix = `${worktreeId}@@` + // Why: folder-workspace instances share a checkout path, so cwd alone cannot distinguish them. + const fullWorktreePath = splitWorktreeId(worktreeId)?.worktreePath + const cwdFallbackPath = + splitWorktreeIdForFilesystem(worktreeId)?.worktreePath === fullWorktreePath + ? fullWorktreePath + : undefined + return (session) => { + // Why: older rows may omit cwd; exact ID and authoritative worktree ownership remain usable. + const cwdOwned = + cwdFallbackPath !== undefined && + session.worktreeId === undefined && + typeof session.cwd === 'string' && + session.cwd.length > 0 && + isPathInsideOrEqual(cwdFallbackPath, session.cwd) + return session.id.startsWith(prefix) || session.worktreeId === worktreeId || cwdOwned + } +} + +function indexListedPtysById( + sessions: readonly T[] +): Map { + const indexed = new Map() + for (const session of sessions) { + indexed.set(session.id, indexed.has(session.id) ? null : session) + } + return indexed +} + +export function clearStoppedPtyState(ptyId: string, onPtyStopped?: (ptyId: string) => void): void { + if (!onPtyStopped) { + return + } + try { + // Why: daemon shutdown may not fan a local exit event back through pty.ts. + onPtyStopped(ptyId) + } catch { + /* cleanup is best-effort and must not block git-level removal */ + } +} diff --git a/src/main/runtime/worktree-teardown-exact-mutation.test.ts b/src/main/runtime/worktree-teardown-exact-mutation.test.ts new file mode 100644 index 00000000000..d275742ee5d --- /dev/null +++ b/src/main/runtime/worktree-teardown-exact-mutation.test.ts @@ -0,0 +1,402 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { TerminalSessionAuthorityPtyAccess } from '../../shared/terminal-session-authority-pty-access' + +const { listRegisteredPtysMock } = vi.hoisted(() => ({ + listRegisteredPtysMock: vi.fn() +})) + +vi.mock('../memory/pty-registry', () => ({ + listRegisteredPtys: listRegisteredPtysMock +})) + +import { killAllProcessesForWorktree } from './worktree-teardown' +import { createWorktreeTeardownProviderStub } from './__tests__/worktree-teardown-provider' + +describe('worktree teardown exact mutations', () => { + beforeEach(() => listRegisteredPtysMock.mockReset()) + + it('does not kill a same-ID successor installed after provider inventory', async () => { + let currentIncarnation = 'incarnation-old' + const localProvider = createWorktreeTeardownProviderStub(async () => { + const listedIncarnation = currentIncarnation + currentIncarnation = 'incarnation-new' + return [ + { + id: 'w1@@reused', + incarnationId: listedIncarnation, + cwd: '/tmp/w1', + title: 'shell', + worktreeId: 'w1' + } + ] + }) + localProvider.killExact.mockImplementation( + async (_id: string, incarnationId: string) => incarnationId === currentIncarnation + ) + listRegisteredPtysMock.mockReturnValue([]) + + const result = await killAllProcessesForWorktree('w1', { + localProvider, + includeLocalRegistry: false + }) + + expect(result.providerStopped).toBe(0) + expect(localProvider.killExact).toHaveBeenCalledWith( + 'w1@@reused', + 'incarnation-old', + expect.objectContaining({ immediate: true }) + ) + expect(localProvider.shutdown).not.toHaveBeenCalled() + }) + + it('never mutates duplicate provider inventory identities', async () => { + const localProvider = createWorktreeTeardownProviderStub(async () => [ + { + id: 'w1@@duplicate', + incarnationId: 'incarnation-1', + cwd: '/tmp/w1', + title: 'shell', + worktreeId: 'w1' + }, + { + id: 'w1@@duplicate', + incarnationId: 'incarnation-2', + cwd: '/tmp/w1', + title: 'shell', + worktreeId: 'w1' + } + ]) + listRegisteredPtysMock.mockReturnValue([]) + + await expect( + killAllProcessesForWorktree('w1', { + localProvider, + includeLocalRegistry: false + }) + ).resolves.toEqual({ runtimeStopped: 0, providerStopped: 0, registryStopped: 0 }) + expect(localProvider.killExact).not.toHaveBeenCalled() + + await expect( + killAllProcessesForWorktree('w1', { + localProvider, + includeLocalRegistry: false, + requirePhysicalStop: true + }) + ).rejects.toThrow(/worktree_pty_inventory_ambiguous:w1@@duplicate/) + expect(localProvider.killExact).not.toHaveBeenCalled() + expect(localProvider.killAuthorityExact).not.toHaveBeenCalled() + }) + + it('fences the registry sweep with the provider-listed incarnation', async () => { + let currentIncarnation = 'incarnation-old' + const localProvider = createWorktreeTeardownProviderStub(async () => { + const listedIncarnation = currentIncarnation + currentIncarnation = 'incarnation-new' + return [ + { + id: 'registry-reused', + incarnationId: listedIncarnation, + cwd: '/tmp/w1', + title: 'shell', + worktreeId: 'w1' + } + ] + }) + localProvider.killExact.mockImplementation( + async (_id: string, incarnationId: string) => incarnationId === currentIncarnation + ) + listRegisteredPtysMock.mockReturnValue([ + { + ptyId: 'registry-reused', + worktreeId: 'w1', + sessionId: null, + paneKey: null, + pid: 10 + } + ]) + const onPtyStopped = vi.fn() + + const result = await killAllProcessesForWorktree('w1', { + localProvider, + includeProviderInventory: false, + onPtyStopped + }) + + expect(result.registryStopped).toBe(0) + expect(localProvider.killExact).toHaveBeenCalledWith( + 'registry-reused', + 'incarnation-old', + expect.objectContaining({ immediate: true }) + ) + expect(onPtyStopped).not.toHaveBeenCalled() + expect(localProvider.shutdown).not.toHaveBeenCalled() + }) + + it('rejects a queued inventory kill after its provider route is replaced', async () => { + let releaseRuntimeStop: (stopped: boolean) => void = () => undefined + const runtimeStop = new Promise((resolve) => { + releaseRuntimeStop = resolve + }) + let markRuntimeClaimed: () => void = () => undefined + const runtimeClaimed = new Promise((resolve) => { + markRuntimeClaimed = resolve + }) + const runtime = { + stopTerminalsForWorktree: async ( + _worktreeId: string, + options: { + stopPty: ( + ptyId: string, + stop: () => Promise + ) => Promise<{ stopped: boolean; owner: boolean }> + } + ) => ({ + stopped: ( + await options.stopPty('w1@@routed', () => { + markRuntimeClaimed() + return runtimeStop + }) + ).owner + ? 1 + : 0 + }) + } as unknown as Parameters[1]['runtime'] + const localProvider = createWorktreeTeardownProviderStub(async () => { + await runtimeClaimed + return [ + { + id: 'w1@@routed', + incarnationId: 'incarnation-old', + cwd: '/tmp/w1', + title: 'shell', + worktreeId: 'w1' + } + ] + }) + listRegisteredPtysMock.mockReturnValue([]) + + const teardown = killAllProcessesForWorktree('w1', { + runtime, + localProvider, + includeLocalRegistry: false + }) + await vi.waitFor(() => expect(localProvider.listProcesses).toHaveBeenCalledOnce()) + await runtimeClaimed + localProvider.setMutationRouteToken(Object.freeze({})) + releaseRuntimeStop(false) + + await expect(teardown).resolves.toEqual({ + runtimeStopped: 0, + providerStopped: 0, + registryStopped: 0 + }) + expect(localProvider.killExact).not.toHaveBeenCalled() + expect(localProvider.shutdown).not.toHaveBeenCalled() + }) + + it('does not let a stale registry row kill another worktree same-ID successor', async () => { + const localProvider = createWorktreeTeardownProviderStub(async () => [ + { + id: 'registry-reused', + incarnationId: 'incarnation-successor', + cwd: '/tmp/w2', + title: 'shell', + worktreeId: 'w2' + } + ]) + listRegisteredPtysMock.mockReturnValue([ + { + ptyId: 'registry-reused', + worktreeId: 'w1', + sessionId: null, + paneKey: null, + pid: 10 + } + ]) + const onPtyStopped = vi.fn() + + const result = await killAllProcessesForWorktree('w1', { + localProvider, + includeProviderInventory: false, + onPtyStopped + }) + + expect(result.registryStopped).toBe(0) + expect(localProvider.killExact).not.toHaveBeenCalled() + expect(localProvider.shutdown).not.toHaveBeenCalled() + expect(onPtyStopped).not.toHaveBeenCalled() + }) + + it('blocks destructive teardown on a registry owner conflict', async () => { + const localProvider = createWorktreeTeardownProviderStub(async () => [ + { + id: 'registry-reused', + incarnationId: 'incarnation-successor', + cwd: '/tmp/w2', + title: 'shell', + worktreeId: 'w2' + } + ]) + listRegisteredPtysMock.mockReturnValue([ + { + ptyId: 'registry-reused', + worktreeId: 'w1', + sessionId: null, + paneKey: null, + pid: 10 + } + ]) + + await expect( + killAllProcessesForWorktree('w1', { + localProvider, + includeProviderInventory: false, + requirePhysicalStop: true + }) + ).rejects.toThrow(/worktree_pty_inventory_owner_conflict:registry-reused/) + expect(localProvider.killExact).not.toHaveBeenCalled() + expect(localProvider.killAuthorityExact).not.toHaveBeenCalled() + }) + + it('preserves a registry row when provider inventory omits it', async () => { + const localProvider = createWorktreeTeardownProviderStub(async () => []) + listRegisteredPtysMock.mockReturnValue([ + { + ptyId: 'registry-unknown', + worktreeId: 'w1', + sessionId: null, + paneKey: null, + pid: 10 + } + ]) + const onPtyStopped = vi.fn() + + await expect( + killAllProcessesForWorktree('w1', { + localProvider, + includeProviderInventory: false, + onPtyStopped + }) + ).resolves.toEqual({ runtimeStopped: 0, providerStopped: 0, registryStopped: 0 }) + + expect(localProvider.killExact).not.toHaveBeenCalled() + expect(localProvider.killAuthorityExact).not.toHaveBeenCalled() + expect(localProvider.shutdown).not.toHaveBeenCalled() + expect(onPtyStopped).not.toHaveBeenCalled() + }) + + it('blocks destructive teardown when provider inventory omits a registered PTY', async () => { + const localProvider = createWorktreeTeardownProviderStub(async () => []) + listRegisteredPtysMock.mockReturnValue([ + { + ptyId: 'registry-unknown', + worktreeId: 'w1', + sessionId: null, + paneKey: null, + pid: 10 + } + ]) + const onPtyStopped = vi.fn() + + await expect( + killAllProcessesForWorktree('w1', { + localProvider, + includeProviderInventory: false, + onPtyStopped, + requirePhysicalStop: true + }) + ).rejects.toThrow(/registry-unknown/) + + expect(localProvider.killExact).not.toHaveBeenCalled() + expect(localProvider.killAuthorityExact).not.toHaveBeenCalled() + expect(localProvider.shutdown).not.toHaveBeenCalled() + expect(onPtyStopped).not.toHaveBeenCalled() + }) + + it('carries full authority access from provider inventory into the kill', async () => { + const authorityAccess: TerminalSessionAuthorityPtyAccess = { + namespace: { authorityHostId: 'host-1', namespaceId: 'namespace-1' }, + pane: { paneKey: 'pane-1', paneGenerationId: 'renderer:1' }, + binding: { + ownerIncarnationId: 'owner-1', + physicalPtyId: 'w1@@authority', + ptyIncarnationId: 'authority-incarnation' + } + } + const localProvider = createWorktreeTeardownProviderStub(async () => [ + { + id: 'w1@@authority', + incarnationId: 'authority-incarnation', + terminalSessionAuthorityAccess: authorityAccess, + cwd: '/tmp/w1', + title: 'shell', + worktreeId: 'w1' + } + ]) + listRegisteredPtysMock.mockReturnValue([]) + + await killAllProcessesForWorktree('w1', { + localProvider, + includeLocalRegistry: false + }) + + expect(localProvider.killAuthorityExact).toHaveBeenCalledWith( + 'w1@@authority', + authorityAccess, + expect.objectContaining({ immediate: true }) + ) + expect(localProvider.killExact).not.toHaveBeenCalled() + }) + + it('does not clear a same-ID successor with different authority access', async () => { + const oldAccess: TerminalSessionAuthorityPtyAccess = { + namespace: { authorityHostId: 'host-1', namespaceId: 'namespace-1' }, + pane: { paneKey: 'pane-1', paneGenerationId: 'renderer:1' }, + binding: { + ownerIncarnationId: 'owner-1', + physicalPtyId: 'w1@@authority', + ptyIncarnationId: 'authority-incarnation' + } + } + const successorAccess: TerminalSessionAuthorityPtyAccess = { + ...oldAccess, + pane: { ...oldAccess.pane, paneGenerationId: 'renderer:2' } + } + let currentAccess = oldAccess + const localProvider = createWorktreeTeardownProviderStub(async () => { + const listedAccess = currentAccess + currentAccess = successorAccess + return [ + { + id: 'w1@@authority', + incarnationId: 'authority-incarnation', + terminalSessionAuthorityAccess: listedAccess, + cwd: '/tmp/w1', + title: 'shell', + worktreeId: 'w1' + } + ] + }) + localProvider.killAuthorityExact.mockImplementation( + async (_id: string, access: TerminalSessionAuthorityPtyAccess) => + access.pane.paneGenerationId === currentAccess.pane.paneGenerationId + ) + listRegisteredPtysMock.mockReturnValue([]) + const onPtyStopped = vi.fn() + + const result = await killAllProcessesForWorktree('w1', { + localProvider, + includeLocalRegistry: false, + onPtyStopped + }) + + expect(result.providerStopped).toBe(0) + expect(localProvider.killAuthorityExact).toHaveBeenCalledWith( + 'w1@@authority', + oldAccess, + expect.objectContaining({ immediate: true }) + ) + expect(onPtyStopped).not.toHaveBeenCalled() + expect(localProvider.shutdown).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/worktree-teardown-unstopped-pty.test.ts b/src/main/runtime/worktree-teardown-unstopped-pty.test.ts index ca09e01b2d8..8506830d148 100644 --- a/src/main/runtime/worktree-teardown-unstopped-pty.test.ts +++ b/src/main/runtime/worktree-teardown-unstopped-pty.test.ts @@ -14,7 +14,8 @@ import { classifyWorktreeForceDeleteReason, isProvenLivePtyRemovalError } from '../../shared/worktree-removal' -import type { IPtyProvider, PtyProcessInfo } from '../providers/types' +import type { PtyProcessInfo } from '../providers/types' +import { createWorktreeTeardownProviderStub as createProviderStub } from './__tests__/worktree-teardown-provider' // Why: these tests advance fake timers *before* awaiting the teardown, so a // rejection mid-advance had no handler yet — Node reported it as an unhandled @@ -26,16 +27,6 @@ function settleTeardown(promise: Promise): Promise { return promise } -function createProviderStub(listProcesses: () => Promise): IPtyProvider { - return { - shutdown: vi.fn().mockResolvedValue(undefined), - listProcesses: vi.fn(listProcesses), - onData: vi.fn().mockReturnValue(() => {}), - onReplay: vi.fn().mockReturnValue(() => {}), - onExit: vi.fn().mockReturnValue(() => {}) - } as unknown as IPtyProvider -} - // A worktree whose PTY teardown cannot be proven must still be removable: the // gate that blocks Git work is the same one that made #11960 permanent. describe('destructive teardown when a PTY stop cannot be proven', () => { @@ -43,10 +34,7 @@ describe('destructive teardown when a PTY stop cannot be proven', () => { listRegisteredPtysMock.mockReset() }) - // Why (#11960): the sweeps routinely burn the whole budget, so re-listing on - // the same exhausted deadline returned "unverifiable" for a PTY that had in - // fact exited — wedging the workspace on every retry. - it('verifies a failed stop against a fresh budget when the sweeps spent the deadline', async () => { + it('does not turn a late empty inventory into exit proof', async () => { vi.useFakeTimers() try { const localProvider = createProviderStub( @@ -68,20 +56,13 @@ describe('destructive teardown when a PTY stop cannot be proven', () => { ) await vi.runAllTimersAsync() - await expect(teardown).resolves.toEqual({ - runtimeStopped: 0, - providerStopped: 0, - registryStopped: 0 - }) + await expect(teardown).rejects.toThrow(/worktree_pty_inventory_missing:stale-1/) } finally { vi.useRealTimers() } }) - // The reported shape: an automation workspace whose only trace is a stale - // registry row the daemon 404s on, behind an inventory slow enough to consume - // the sweep budget. Verification must still get far enough to prove absence. - it('removes the reported wedged automation workspace without --force', async () => { + it('requires force when only an empty inventory suggests a PTY exited', async () => { const worktreeId = 'repo-1::C:/Users/admin/orca/workspaces/repo/auto-review-run-28' // Slow enough that a fixed 2s grace could not absorb it, but far enough from // the budget that the list-completion and timeout timers can't land in the @@ -107,11 +88,7 @@ describe('destructive teardown when a PTY stop cannot be proven', () => { ) await vi.runAllTimersAsync() - await expect(teardown).resolves.toEqual({ - runtimeStopped: 0, - providerStopped: 0, - registryStopped: 0 - }) + await expect(teardown).rejects.toThrow(/worktree_pty_inventory_missing:term_abab11ee/) } finally { vi.useRealTimers() } @@ -131,9 +108,7 @@ describe('destructive teardown when a PTY stop cannot be proven', () => { ).rejects.toThrow(/still live: w1@@live-1[\s\S]*--force/) }) - // Why: the memory/registry rows this drops are the reason clearStoppedPtyState - // exists; commit 3 moved that loop, so pin it before it can silently vanish. - it('clears PTY state once a failed stop is proven to have exited', async () => { + it('preserves PTY state when inventory omission is the only exit evidence', async () => { const localProvider = createProviderStub(async () => []) ;(localProvider.shutdown as unknown as ReturnType).mockRejectedValue( new Error('Session not found: stale-1') @@ -149,11 +124,11 @@ describe('destructive teardown when a PTY stop cannot be proven', () => { onPtyStopped, requirePhysicalStop: true }) - ).resolves.toBeDefined() - expect(onPtyStopped).toHaveBeenCalledWith('stale-1') + ).rejects.toThrow(/worktree_pty_inventory_missing:stale-1/) + expect(onPtyStopped).not.toHaveBeenCalled() }) - it('names only the PTYs that are actually live, not every failed stop', async () => { + it('reports an inventory-missing registry PTY as unknown instead of exited', async () => { const localProvider = createProviderStub(async () => [ { id: 'w1@@live-1', cwd: '/tmp/w1', title: 'shell' } ]) @@ -171,11 +146,10 @@ describe('destructive teardown when a PTY stop cannot be proven', () => { () => new Error('expected a rejection'), (rejection: Error) => rejection ) - expect(error.message).toContain('w1@@live-1') - expect(error.message).not.toContain('w1@@gone-2') + expect(error.message).toContain('worktree_pty_inventory_missing:w1@@gone-2') }) - it('reports unverifiable separately from live when the process list fails', async () => { + it('fails destructive teardown at the inventory fence when the process list fails', async () => { const localProvider = createProviderStub(async () => { throw new Error('daemon socket closed') }) @@ -192,7 +166,8 @@ describe('destructive teardown when a PTY stop cannot be proven', () => { includeProviderInventory: false, requirePhysicalStop: true }) - ).rejects.toThrow(/could not verify[\s\S]*stale-1[\s\S]*daemon socket closed/) + ).rejects.toThrow(/terminal sweep failed: daemon socket closed[\s\S]*--force/) + expect(localProvider.shutdown).not.toHaveBeenCalled() }) // A gate that force cannot cross is the bug, wherever it sits. These two cover @@ -269,9 +244,9 @@ describe('destructive teardown when a PTY stop cannot be proven', () => { it('waits for in-flight sweeps before force returns', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) try { - const localProvider = createProviderStub(async () => { - throw new Error('ssh channel closed') - }) + const localProvider = createProviderStub(async () => [ + { id: 'reg-1', cwd: '/tmp/w1', title: 'shell', worktreeId: 'w1' } + ]) let shutdownFinished = false ;(localProvider.shutdown as unknown as ReturnType).mockImplementation( async () => { @@ -285,6 +260,7 @@ describe('destructive teardown when a PTY stop cannot be proven', () => { const result = await killAllProcessesForWorktree('w1', { localProvider, + includeProviderInventory: false, requirePhysicalStop: true, allowUnverifiedStop: true }) diff --git a/src/main/runtime/worktree-teardown.test.ts b/src/main/runtime/worktree-teardown.test.ts index b51f292c29c..db7c99e9a2e 100644 --- a/src/main/runtime/worktree-teardown.test.ts +++ b/src/main/runtime/worktree-teardown.test.ts @@ -11,31 +11,7 @@ vi.mock('../memory/pty-registry', () => ({ import { killAllProcessesForWorktree, WORKTREE_PROCESS_SWEEP_TIMEOUT_MS } from './worktree-teardown' import type { IPtyProvider, PtyProcessInfo } from '../providers/types' import { DaemonPtyAdapter } from '../daemon/daemon-pty-adapter' - -function createProviderStub(listProcesses: () => Promise): IPtyProvider { - return { - spawn: vi.fn(), - attach: vi.fn(), - write: vi.fn(), - resize: vi.fn(), - shutdown: vi.fn().mockResolvedValue(undefined), - sendSignal: vi.fn(), - getCwd: vi.fn(), - getInitialCwd: vi.fn(), - clearBuffer: vi.fn(), - acknowledgeDataEvent: vi.fn(), - hasChildProcesses: vi.fn(), - getForegroundProcess: vi.fn(), - serialize: vi.fn(), - revive: vi.fn(), - listProcesses: vi.fn(listProcesses), - getDefaultShell: vi.fn(), - getProfiles: vi.fn(), - onData: vi.fn().mockReturnValue(() => {}), - onReplay: vi.fn().mockReturnValue(() => {}), - onExit: vi.fn().mockReturnValue(() => {}) - } as unknown as IPtyProvider -} +import { createWorktreeTeardownProviderStub as createProviderStub } from './__tests__/worktree-teardown-provider' describe('killAllProcessesForWorktree', () => { beforeEach(() => { @@ -58,13 +34,14 @@ describe('killAllProcessesForWorktree', () => { expect(result.runtimeStopped).toBe(0) expect(result.providerStopped).toBe(1) - expect(result.registryStopped).toBe(1) + expect(result.registryStopped).toBe(0) expect(localProvider.shutdown).toHaveBeenCalledWith( 'w1@@abcd1234', expect.objectContaining({ immediate: true }) ) - expect(localProvider.shutdown).toHaveBeenCalledWith( + // Inventory omission is not an exit outcome, so the registry row remains retryable. + expect(localProvider.shutdown).not.toHaveBeenCalledWith( 'w1-registry-1', expect.objectContaining({ immediate: true }) ) @@ -77,7 +54,7 @@ describe('killAllProcessesForWorktree', () => { expect.objectContaining({ immediate: true }) ) expect(onPtyStopped).toHaveBeenCalledWith('w1@@abcd1234') - expect(onPtyStopped).toHaveBeenCalledWith('w1-registry-1') + expect(onPtyStopped).not.toHaveBeenCalledWith('w1-registry-1') expect(onPtyStopped).not.toHaveBeenCalledWith('w2@@efef5678') expect(onPtyStopped).not.toHaveBeenCalledWith('w2-registry-2') }) @@ -85,8 +62,8 @@ describe('killAllProcessesForWorktree', () => { it('skips the daemon prefix sweep safely when the provider uses numeric ids', async () => { // LocalPtyProvider shape: numeric ids that cannot match `${worktreeId}@@`. const localProvider = createProviderStub(async () => [ - { id: '1', cwd: '/tmp/w1', title: 'shell' }, - { id: '2', cwd: '/tmp/w2', title: 'shell' } + { id: '1', cwd: '/tmp/w1', title: 'shell', worktreeId: 'w1' }, + { id: '2', cwd: '/tmp/w2', title: 'shell', worktreeId: 'w2' } ]) listRegisteredPtysMock.mockReturnValue([ { ptyId: '1', worktreeId: 'w1', sessionId: null, paneKey: null, pid: 200 } @@ -489,6 +466,7 @@ describe('killAllProcessesForWorktree', () => { 'w1@@same', expect.objectContaining({ immediate: true }) ) + expect(localProvider.listProcesses).toHaveBeenCalledOnce() expect(result.providerStopped + result.registryStopped).toBe(1) }) @@ -509,19 +487,25 @@ describe('killAllProcessesForWorktree', () => { const runtime = { stopTerminalsForWorktree } as unknown as Parameters[1]['runtime'] - const localProvider = createProviderStub(async () => []) + const localProvider = createProviderStub(async () => [ + { id: 'w1@@same', cwd: '/tmp/w1', title: 'shell' } + ]) listRegisteredPtysMock.mockReturnValue([ { ptyId: 'w1@@same', worktreeId: 'w1', sessionId: null, paneKey: null, pid: 100 } ]) - const result = await killAllProcessesForWorktree('w1', { runtime, localProvider }) + const result = await killAllProcessesForWorktree('w1', { + runtime, + localProvider, + includeProviderInventory: false + }) expect(result.runtimeStopped).toBe(0) expect(result.registryStopped).toBe(1) expect(localProvider.shutdown).toHaveBeenCalledTimes(1) }) - it('accepts a failed Windows stop when a fresh inventory proves the PTY exited', async () => { + it('does not treat a fresh Windows inventory miss as physical exit proof', async () => { const worktreeId = 'repo-1::C:/Users/User/orca/workspaces/repo/feature' const ptyId = `${worktreeId}@@windows-pty` const stopTerminalsForWorktree = vi.fn( @@ -558,13 +542,10 @@ describe('killAllProcessesForWorktree', () => { killAllProcessesForWorktree(worktreeId, { runtime, localProvider, + includeProviderInventory: false, requirePhysicalStop: true }) - ).resolves.toEqual({ - runtimeStopped: 0, - providerStopped: 0, - registryStopped: 0 - }) + ).rejects.toThrow(/could not verify these exited/) expect(localProvider.listProcesses).toHaveBeenCalledTimes(2) }) @@ -693,10 +674,17 @@ describe('killAllProcessesForWorktree', () => { ): Promise => { if (type === 'listSessions') { return Promise.resolve({ - sessions: [{ sessionId, isAlive: true, cwd: '/tmp/w1' }] + sessions: [ + { + sessionId, + incarnationId: 'incarnation-1', + isAlive: true, + cwd: '/tmp/w1' + } + ] }) } - if (type === 'kill') { + if (type === 'killExact') { killRequests += 1 // The daemon never replies; the request only rejects when its own // timeout elapses, exactly like the real client. @@ -711,8 +699,25 @@ describe('killAllProcessesForWorktree', () => { } } - const adapter = new DaemonPtyAdapter({ socketPath: '/tmp/sock', tokenPath: '/tmp/tok' }) + const adapter = new DaemonPtyAdapter({ + socketPath: '/tmp/sock', + tokenPath: '/tmp/tok', + protocolVersion: 33 + }) + vi.spyOn(adapter, 'supportsExactPtyOperations').mockReturnValue(true) ;(adapter as unknown as { client: typeof fakeClient }).client = fakeClient + ;( + adapter as unknown as { + activeSessionIds: Set + sessionIncarnations: Map + } + ).activeSessionIds.add(sessionId) + ;( + adapter as unknown as { + activeSessionIds: Set + sessionIncarnations: Map + } + ).sessionIncarnations.set(sessionId, 'incarnation-1') listRegisteredPtysMock.mockReturnValue([]) @@ -820,12 +825,9 @@ describe('killAllProcessesForWorktree', () => { await expect(teardown).resolves.toEqual({ runtimeStopped: 0, providerStopped: 0, - registryStopped: 1 + registryStopped: 0 }) - expect(localProvider.shutdown).toHaveBeenCalledWith( - 'registry-1', - expect.objectContaining({ immediate: true }) - ) + expect(localProvider.shutdown).not.toHaveBeenCalled() expect(vi.getTimerCount()).toBe(0) } finally { vi.useRealTimers() @@ -860,7 +862,9 @@ describe('killAllProcessesForWorktree', () => { vi.useFakeTimers() try { let resolveShutdown: () => void = () => {} - const localProvider = createProviderStub(async () => []) + const localProvider = createProviderStub(async () => [ + { id: 'registry-1', cwd: '/tmp/w1', title: 'shell' } + ]) ;(localProvider.shutdown as unknown as ReturnType).mockImplementation( () => new Promise((resolve) => { @@ -874,6 +878,7 @@ describe('killAllProcessesForWorktree', () => { const teardown = killAllProcessesForWorktree('w1', { localProvider, onPtyStopped, + includeProviderInventory: false, timeoutMs: 25 }) diff --git a/src/main/runtime/worktree-teardown.ts b/src/main/runtime/worktree-teardown.ts index 993a263d348..f6049dd3d18 100644 --- a/src/main/runtime/worktree-teardown.ts +++ b/src/main/runtime/worktree-teardown.ts @@ -1,9 +1,5 @@ -import type { IPtyProvider } from '../providers/types' +import type { IPtyProvider, PtyProcessInfo } from '../providers/types' import type { OrcaRuntimeService } from './orca-runtime' -import { listRegisteredPtys } from '../memory/pty-registry' -import { isPathInsideOrEqual } from '../../shared/cross-platform-path' -import { splitWorktreeId, splitWorktreeIdForFilesystem } from '../../shared/worktree-id' -import { mapWithConcurrency } from '../../shared/map-with-concurrency' import { isUnstoppedPtyRemovalError, WORKTREE_TEARDOWN_FORCE_HINT, @@ -11,6 +7,11 @@ import { } from '../../shared/worktree-removal' import { settleBeforeDeadline } from './settle-before-deadline' import { createWorktreeSweepTracker, settleSweepsForForcedRemoval } from './forced-sweep-settlement' +import { + clearStoppedPtyState, + sweepProviderPtysForWorktree, + sweepRegisteredPtysForWorktree +} from './worktree-pty-inventory-sweep' import { describeError, describeFailedPtySweep, @@ -19,10 +20,6 @@ import { type UnstoppedPtyVerdict } from './unstopped-pty-verification' -// Why: normal inventories still coalesce into one process scan, while a stale -// or pathological inventory cannot fan out unbounded provider/RPC shutdowns. -const WORKTREE_TEARDOWN_CONCURRENCY = 32 - export type WorktreeTeardownDeps = { runtime?: OrcaRuntimeService /** Authoritative id for callers whose selector no longer resolves (orphaned workspace). */ @@ -97,6 +94,13 @@ export async function killAllProcessesForWorktree( `${WORKTREE_TEARDOWN_TIMEOUT_PREFIX} ${worktreeId}. ${WORKTREE_TEARDOWN_FORCE_HINT}` ) const sweeps = createWorktreeSweepTracker() + let providerInventory: Promise | null = null + const loadProviderInventory = (): Promise => { + providerInventory ??= deps.localProvider.listProcesses({ + deadlineMs: teardownRpcDeadline(deadline) + }) + return providerInventory + } const stopAttempts = new Map>() const stopPty = ( ptyId: string, @@ -153,13 +157,15 @@ export async function killAllProcessesForWorktree( ? Promise.resolve(0) : settleBeforeDeadline( sweeps.track(() => - sweepProviderByPrefix( + sweepProviderPtysForWorktree( worktreeId, deps.localProvider, deadline, + teardownRpcDeadline(deadline), stopPty, deps.onPtyStopped, - deps.requirePhysicalStop + deps.requirePhysicalStop === true, + loadProviderInventory ) ), 0, @@ -171,12 +177,15 @@ export async function killAllProcessesForWorktree( ? Promise.resolve(0) : settleBeforeDeadline( sweeps.track(() => - sweepRegistryForWorktree( + sweepRegisteredPtysForWorktree( worktreeId, deps.localProvider, deadline, + teardownRpcDeadline(deadline), stopPty, - deps.onPtyStopped + deps.onPtyStopped, + deps.requirePhysicalStop === true, + loadProviderInventory ) ), 0, @@ -254,123 +263,3 @@ export async function killAllProcessesForWorktree( return { runtimeStopped: runtimeResult.stopped, providerStopped, registryStopped } } - -async function sweepProviderByPrefix( - worktreeId: string, - provider: IPtyProvider, - deadline: number, - stopPty: ( - ptyId: string, - stop: () => Promise - ) => Promise<{ stopped: boolean; owner: boolean }>, - onPtyStopped?: (ptyId: string) => void, - failClosed = false -): Promise { - const prefix = `${worktreeId}@@` - // Why (#10252): the cwd fallback only proves ownership when the filesystem path - // is the *whole* worktree path. A folder-workspace instance strips its - // `::workspace:` suffix to a checkout dir shared with sibling instances, - // so leave the fallback unset whenever stripping shortened the path — else - // deleting one instance would sweep the others. - const fullWorktreePath = splitWorktreeId(worktreeId)?.worktreePath - const cwdFallbackPath = - splitWorktreeIdForFilesystem(worktreeId)?.worktreePath === fullWorktreePath - ? fullWorktreePath - : undefined - const rpcDeadline = teardownRpcDeadline(deadline) - const sessions = failClosed - ? await provider.listProcesses({ deadlineMs: rpcDeadline }) - : await provider.listProcesses({ deadlineMs: rpcDeadline }).catch(() => []) - const ownedSessions = sessions.filter((session) => { - // Why: older daemon/relay process rows may omit cwd; their established ID - // and authoritative worktree ownership must remain usable during teardown. - const cwdOwned = - cwdFallbackPath !== undefined && - session.worktreeId === undefined && - typeof session.cwd === 'string' && - session.cwd.length > 0 && - isPathInsideOrEqual(cwdFallbackPath, session.cwd) - return session.id.startsWith(prefix) || session.worktreeId === worktreeId || cwdOwned - }) - // Why: agent shutdown snapshots coalesce only when requests begin together; - // bounded concurrency avoids serial process scans without unbounded fanout. - const stopped = await mapWithConcurrency( - ownedSessions, - WORKTREE_TEARDOWN_CONCURRENCY, - async (session) => { - if (Date.now() >= deadline) { - return 0 - } - const stopResult = await stopPty(session.id, async () => { - if (Date.now() >= deadline) { - return false - } - try { - await provider.shutdown(session.id, { immediate: true, deadlineMs: rpcDeadline }) - return Date.now() < deadline - } catch { - return false - } - }) - if (stopResult.owner && Date.now() < deadline) { - clearStoppedPtyState(session.id, onPtyStopped) - return 1 - } - return 0 - } - ) - return stopped.reduce((count, value) => count + value, 0) -} - -async function sweepRegistryForWorktree( - worktreeId: string, - localProvider: IPtyProvider, - deadline: number, - stopPty: ( - ptyId: string, - stop: () => Promise - ) => Promise<{ stopped: boolean; owner: boolean }>, - onPtyStopped?: (ptyId: string) => void -): Promise { - const rpcDeadline = teardownRpcDeadline(deadline) - const entries = listRegisteredPtys().filter((r) => r.worktreeId === worktreeId) - const stopped = await mapWithConcurrency( - entries, - WORKTREE_TEARDOWN_CONCURRENCY, - async (entry) => { - if (Date.now() >= deadline) { - return 0 - } - const stopResult = await stopPty(entry.ptyId, async () => { - if (Date.now() >= deadline) { - return false - } - try { - await localProvider.shutdown(entry.ptyId, { immediate: true, deadlineMs: rpcDeadline }) - return Date.now() < deadline - } catch { - return false - } - }) - if (stopResult.owner && Date.now() < deadline) { - clearStoppedPtyState(entry.ptyId, onPtyStopped) - return 1 - } - return 0 - } - ) - return stopped.reduce((count, value) => count + value, 0) -} - -function clearStoppedPtyState(ptyId: string, onPtyStopped?: (ptyId: string) => void): void { - if (!onPtyStopped) { - return - } - try { - // Why: daemon shutdown does not always fan a local pty:exit event back - // through pty.ts, but removed worktrees must immediately drop memory rows. - onPtyStopped(ptyId) - } catch { - /* cleanup is best-effort and must not block git-level removal */ - } -} diff --git a/src/main/session-authority/__tests__/authenticated-policy-consumer-admission.ts b/src/main/session-authority/__tests__/authenticated-policy-consumer-admission.ts new file mode 100644 index 00000000000..89d7e29917b --- /dev/null +++ b/src/main/session-authority/__tests__/authenticated-policy-consumer-admission.ts @@ -0,0 +1,136 @@ +import { + TERMINAL_AUTHORITY_CONSUMER_PROOF_ALGORITHM, + TERMINAL_AUTHORITY_CONSUMER_PROOF_CAPABILITY, + TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION, + type TerminalAuthorityNamespaceAdmissionChallenge, + type TerminalAuthorityNamespaceAdmissionIntent, + type TerminalAuthorityNamespaceAdmissionProof, + type TerminalAuthorityNamespaceAdmissionStart +} from '../../../shared/terminal-session-authority-consumer-proof' +import type { TerminalAuthorityNamespace } from '../../../shared/terminal-session-authority-identity' +import type { TerminalAuthorityAuthenticatedNamespaceSession } from '../terminal-session-authority-authenticated-consumers' +import type { TerminalAuthorityAuthenticatedConsumerTransport } from '../terminal-session-authority-consumer-admission' +import { + createTerminalAuthorityConsumerProof, + createTerminalAuthorityProofEphemeralKeypair, + type TerminalAuthorityConsumerProofKeypair +} from '../terminal-session-authority-consumer-proof' +import type { TerminalAuthorityPolicyOutcomeTransport } from '../terminal-session-authority-policy-consumers' + +/** The real challenge/proof handshake — the only path that reaches a durable consumer claim. */ +export type AuthenticatedPolicyConsumerHost = Readonly<{ + issuePolicyConsumerChallenge( + start: TerminalAuthorityNamespaceAdmissionStart, + transport: TerminalAuthorityAuthenticatedConsumerTransport + ): Promise + openAuthenticatedPolicyConsumerNamespace( + proof: TerminalAuthorityNamespaceAdmissionProof, + authenticatedTransport: TerminalAuthorityAuthenticatedConsumerTransport, + outcomeTransport: TerminalAuthorityPolicyOutcomeTransport + ): Promise +}> + +export type AuthenticatedPolicyConsumerAdmission = Readonly<{ + session: TerminalAuthorityAuthenticatedNamespaceSession + transport: TerminalAuthorityAuthenticatedConsumerTransport + appKeypair: TerminalAuthorityConsumerProofKeypair +}> + +export function terminalAuthorityTestConsumerTransport( + connectionGrantId = 'test-connection-grant', + principal = 'daemon-token:test' +): TerminalAuthorityAuthenticatedConsumerTransport { + return Object.freeze({ + connectionGrantId, + principal, + capability: TERMINAL_AUTHORITY_CONSUMER_PROOF_CAPABILITY, + token: Object.freeze({}) + }) +} + +export function terminalAuthorityTestAdmissionStart(options: { + namespace: TerminalAuthorityNamespace + appKeypair: TerminalAuthorityConsumerProofKeypair + processIncarnationId: string + requestId: string + intent?: TerminalAuthorityNamespaceAdmissionIntent +}): TerminalAuthorityNamespaceAdmissionStart { + return Object.freeze({ + version: TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION, + algorithm: TERMINAL_AUTHORITY_CONSUMER_PROOF_ALGORITHM, + namespace: options.namespace, + appPublicKeyB64: Buffer.from(options.appKeypair.publicKey).toString('base64'), + candidateProcessIncarnationId: `app-process:${options.processIncarnationId}`, + candidateSessionNonce: `session-nonce:${options.processIncarnationId}`, + requestId: options.requestId, + intent: options.intent ?? 'first' + }) +} + +/** Runs the full challenge -> proof -> admit handshake and returns the installed namespace session. */ +export async function admitAuthenticatedPolicyConsumer( + host: AuthenticatedPolicyConsumerHost, + options: { + namespace: TerminalAuthorityNamespace + transport?: TerminalAuthorityAuthenticatedConsumerTransport + outcomeTransport?: TerminalAuthorityPolicyOutcomeTransport + appKeypair?: TerminalAuthorityConsumerProofKeypair + processIncarnationId?: string + requestId?: string + /** 'auto' asks the host which intent its current state requires, then presents exactly that. */ + intent?: TerminalAuthorityNamespaceAdmissionIntent | 'auto' + } +): Promise { + const transport = options.transport ?? terminalAuthorityTestConsumerTransport() + const appKeypair = options.appKeypair ?? createTerminalAuthorityProofEphemeralKeypair() + const start = terminalAuthorityTestAdmissionStart({ + namespace: options.namespace, + appKeypair, + processIncarnationId: options.processIncarnationId ?? 'test-app', + requestId: options.requestId ?? 'test-request', + intent: options.intent === 'auto' || !options.intent ? 'first' : options.intent + }) + const challenge = + options.intent === undefined || options.intent === 'auto' + ? await issueChallengeWithRequiredIntent(host, start, transport) + : await host.issuePolicyConsumerChallenge(start, transport) + const session = await host.openAuthenticatedPolicyConsumerNamespace( + createTerminalAuthorityConsumerProof(challenge, appKeypair), + transport, + options.outcomeTransport ?? { + publishBoundary: async () => {}, + publishOutcome: async () => {} + } + ) + return Object.freeze({ session, transport, appKeypair }) +} + +// The host names the only intent its state accepts; a test that does not care re-presents that. +const REQUIRED_INTENTS: readonly TerminalAuthorityNamespaceAdmissionIntent[] = [ + 'first', + 'resume', + 'explicit-handover' +] + +async function issueChallengeWithRequiredIntent( + host: AuthenticatedPolicyConsumerHost, + start: TerminalAuthorityNamespaceAdmissionStart, + transport: TerminalAuthorityAuthenticatedConsumerTransport +): Promise { + try { + return await host.issuePolicyConsumerChallenge(start, transport) + } catch (error) { + const required = REQUIRED_INTENTS.find( + (intent) => + String((error as Error)?.message) === + `terminal authority namespace admission requires ${intent}` + ) + if (!required) { + throw error + } + return await host.issuePolicyConsumerChallenge( + Object.freeze({ ...start, intent: required }), + transport + ) + } +} diff --git a/src/main/session-authority/__tests__/terminal-authority-app-projection-fixture.ts b/src/main/session-authority/__tests__/terminal-authority-app-projection-fixture.ts new file mode 100644 index 00000000000..f93218b3c4e --- /dev/null +++ b/src/main/session-authority/__tests__/terminal-authority-app-projection-fixture.ts @@ -0,0 +1,199 @@ +import { + TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_VERSION, + type TerminalAuthorityNamespaceOutcomeBoundary, + type TerminalAuthorityNamespaceOutcomePublication, + type TerminalAuthorityPolicyConsumerIdentity +} from '../../../shared/terminal-session-authority-consumer-transport' +import type { + TerminalAuthorityProjection, + TerminalSessionAuthoritySemanticFact +} from '../../../shared/terminal-session-authority-mutation' +import { terminalAuthorityOutcomeByteLength } from '../../../shared/terminal-session-authority-record-validation' +import { terminalSessionAuthorityBoundaryId } from '../../../shared/terminal-session-authority-boundary-identity' + +export const APP_CONSUMER = Object.freeze({ + consumerId: 'app-profile:v1:test-profile', + consumerIncarnationId: 'app-process:test-process' +}) + +export const NEXT_APP_CONSUMER = Object.freeze({ + consumerId: APP_CONSUMER.consumerId, + consumerIncarnationId: 'app-process:next-process' +}) + +export const NAMESPACE = Object.freeze({ + authorityHostId: 'host-1', + namespaceId: 'namespace-1' +}) + +export const PANE_KEY = 'tab-1:11111111-1111-4111-8111-111111111111' + +export function authorityProjection( + options: { + namespaceId?: string + revision?: number + paneGenerationId?: string + bound?: boolean + panes?: TerminalAuthorityProjection['panes'] + materializedOutcomes?: TerminalAuthorityProjection['materializedOutcomes'] + } = {} +): TerminalAuthorityProjection { + const namespace = Object.freeze({ + ...NAMESPACE, + ...(options.namespaceId ? { namespaceId: options.namespaceId } : {}) + }) + const binding = { + ownerIncarnationId: 'owner-1', + physicalPtyId: 'pty-1', + ptyIncarnationId: 'pty-incarnation-1' + } + return Object.freeze({ + namespace, + writerEpoch: 1, + revision: options.revision ?? 1, + panes: Object.freeze( + options.panes ?? [ + Object.freeze({ + paneKey: PANE_KEY, + paneGenerationId: options.paneGenerationId ?? 'pane-generation-1', + status: 'open' as const, + binding: options.bound === false ? null : binding, + lastBinding: options.bound === false ? null : binding, + revision: options.revision ?? 1, + ownerStatus: options.bound === false ? null : ('reachable' as const) + }) + ] + ), + allocations: Object.freeze([]), + materializedOutcomes: Object.freeze(options.materializedOutcomes ?? []) + }) +} + +export function boundary( + acknowledgedSequence: number, + options: { + consumer?: TerminalAuthorityPolicyConsumerIdentity + namespaceId?: string + outcomeHighWatermark?: number + projection?: TerminalAuthorityProjection + consumerStart?: TerminalAuthorityNamespaceOutcomeBoundary['consumerStart'] + } = {} +): TerminalAuthorityNamespaceOutcomeBoundary { + const value: Omit = { + version: TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_VERSION, + consumer: options.consumer ?? APP_CONSUMER, + namespace: Object.freeze({ + ...NAMESPACE, + ...(options.namespaceId ? { namespaceId: options.namespaceId } : {}) + }), + acknowledgedSequence, + outcomeHighWatermark: options.outcomeHighWatermark ?? acknowledgedSequence, + consumerStart: options.consumerStart ?? 'new-at-tail', + projection: + options.projection ?? + authorityProjection({ + namespaceId: options.namespaceId, + revision: Math.max(1, acknowledgedSequence) + }) + } + return Object.freeze({ ...value, boundaryId: terminalSessionAuthorityBoundaryId(value) }) +} + +export function semanticPublication( + sequence: number, + fact: TerminalSessionAuthoritySemanticFact = { kind: 'bell' }, + options: { + consumer?: TerminalAuthorityPolicyConsumerIdentity + namespaceId?: string + paneGenerationId?: string + ptyIncarnationId?: string + outcomeId?: string + } = {} +): TerminalAuthorityNamespaceOutcomePublication { + const namespace = Object.freeze({ + ...NAMESPACE, + ...(options.namespaceId ? { namespaceId: options.namespaceId } : {}) + }) + const paneGenerationId = options.paneGenerationId ?? 'pane-generation-1' + const ptyIncarnationId = options.ptyIncarnationId ?? 'pty-incarnation-1' + const base = { + kind: 'semantic' as const, + sequence, + outcomeId: options.outcomeId ?? `semantic-${namespace.namespaceId}-${sequence}`, + access: { + namespace, + pane: { paneKey: PANE_KEY, paneGenerationId }, + binding: { + ownerIncarnationId: 'owner-1', + physicalPtyId: 'pty-1', + ptyIncarnationId + } + }, + producerIncarnationId: 'producer-1', + producerSequence: sequence, + fact, + appendedAtRevision: sequence + } + const outcome = Object.freeze({ ...base, byteLength: terminalAuthorityOutcomeByteLength(base) }) + return Object.freeze({ + version: TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_VERSION, + consumer: options.consumer ?? APP_CONSUMER, + namespace, + previousSequence: sequence - 1, + outcome + }) +} + +export function exitPublication( + sequence: number, + options: { consumer?: TerminalAuthorityPolicyConsumerIdentity; paneGenerationId?: string } = {} +): TerminalAuthorityNamespaceOutcomePublication { + const paneGenerationId = options.paneGenerationId ?? 'pane-generation-1' + const binding = { + ownerIncarnationId: 'owner-1', + physicalPtyId: 'pty-1', + ptyIncarnationId: `pty-incarnation-${paneGenerationId}` + } + const pane = { paneKey: PANE_KEY, paneGenerationId } + const base = { + sequence, + outcomeId: `exit-${paneGenerationId}-${sequence}`, + request: { + actorId: 'actor-1', + operationId: `exit-operation-${sequence}`, + baseRevision: sequence, + outcomeId: `exit-${paneGenerationId}-${sequence}`, + change: { + kind: 'exit' as const, + pane, + expected: { paneGenerationId, binding }, + exit: { code: 17, signal: null } + } + }, + result: { + namespace: NAMESPACE, + actorId: 'actor-1', + operationId: `exit-operation-${sequence}`, + kind: 'exit' as const, + revision: sequence + 1, + pane: { + ...pane, + status: 'exited' as const, + binding: null, + lastBinding: binding, + revision: sequence + 1 + }, + replacementPane: null, + allocation: null, + effects: [{ kind: 'terminal-exited' as const, binding, code: 17, signal: null }] + } + } + const outcome = Object.freeze({ ...base, byteLength: terminalAuthorityOutcomeByteLength(base) }) + return Object.freeze({ + version: TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_VERSION, + consumer: options.consumer ?? APP_CONSUMER, + namespace: NAMESPACE, + previousSequence: sequence - 1, + outcome + }) +} diff --git a/src/main/session-authority/__tests__/terminal-legacy-cutover.ts b/src/main/session-authority/__tests__/terminal-legacy-cutover.ts new file mode 100644 index 00000000000..9c460df3e03 --- /dev/null +++ b/src/main/session-authority/__tests__/terminal-legacy-cutover.ts @@ -0,0 +1,310 @@ +import type { + TerminalLegacyImportCandidate, + TerminalLegacyImportMatchEvidence, + TerminalLegacyInventoryEvidence, + TerminalLegacyMigrationImportRequest, + TerminalLegacyUnresolvedCandidate, + TerminalLegacyWorkerRoute +} from '../../../shared/terminal-legacy-cutover' +import type { TerminalAuthorityNamespace } from '../../../shared/terminal-session-authority-identity' + +export const LEGACY_TEST_NAMESPACE = Object.freeze({ + authorityHostId: 'host-a', + namespaceId: 'namespace-a' +}) + +type LegacyRequestOptions = Readonly<{ + migrationNumber: number + workerNumber?: number + recoveryNumbers?: readonly number[] + namespace?: TerminalAuthorityNamespace + unresolvedPaneKey?: string +}> + +export function legacyRecoveryOnlyRequest( + options: LegacyRequestOptions +): TerminalLegacyMigrationImportRequest { + const workerNumber = options.workerNumber ?? options.migrationNumber + const recoveries = options.recoveryNumbers ?? [options.migrationNumber] + return Object.freeze({ + version: 1, + mode: 'recovery-only', + migrationId: `migration-${options.migrationNumber}`, + authorityHostId: 'host-a', + requestedAtMs: options.migrationNumber * 10, + workerEvidence: Object.freeze({ + workerId: workerId(workerNumber), + buildId: `build-${workerNumber}`, + relayDirectory: relayDirectory(workerNumber), + endpointPath: `${relayDirectory(workerNumber)}/relay.sock`, + credentialFile: `${relayDirectory(workerNumber)}/credential`, + process: processIdentity(workerNumber), + inventoryDigest: `inventory-worker-${workerNumber}`, + gcProtection: Object.freeze({ + relayDirectories: Object.freeze([relayDirectory(workerNumber)]), + evidencePaths: Object.freeze([ + `${relayDirectory(workerNumber)}/relay.sock`, + `${relayDirectory(workerNumber)}/credential` + ]) + }) + }), + imports: Object.freeze([] as const), + unresolved: Object.freeze( + recoveries.map((recoveryNumber) => + unresolvedCandidate( + workerNumber, + recoveryNumber, + options.namespace, + false, + options.unresolvedPaneKey + ) + ) + ) + }) +} + +export function legacyCutoverRequest( + options: LegacyRequestOptions & + Readonly<{ + importedRecoveryNumbers?: readonly number[] + unresolvedRecoveryNumbers?: readonly number[] + }> +): TerminalLegacyMigrationImportRequest { + const workerNumber = options.workerNumber ?? options.migrationNumber + return Object.freeze({ + version: 1, + mode: 'cutover', + migrationId: `migration-${options.migrationNumber}`, + authorityHostId: 'host-a', + requestedAtMs: options.migrationNumber * 10, + workerRoute: workerRoute(workerNumber), + cutover: cutoverProof(workerNumber, options.migrationNumber), + imports: Object.freeze( + (options.importedRecoveryNumbers ?? []).map((recoveryNumber) => + importCandidate(workerNumber, recoveryNumber, options.namespace) + ) + ), + unresolved: Object.freeze( + (options.unresolvedRecoveryNumbers ?? []).map((recoveryNumber) => + unresolvedCandidate(workerNumber, recoveryNumber, options.namespace, true) + ) + ) + }) +} + +export function legacyAcknowledgementRequest( + migrationNumber: number, + recoveryNumber: number, + expectedCatalogReceiptId: string +): TerminalLegacyMigrationImportRequest { + return Object.freeze({ + version: 1, + mode: 'acknowledge', + migrationId: `migration-${migrationNumber}`, + authorityHostId: 'host-a', + requestedAtMs: migrationNumber * 10, + recoveryId: recoveryId(recoveryNumber), + expectedCatalogReceiptId, + acknowledgementCode: `acknowledgement-${recoveryNumber}`, + imports: Object.freeze([] as const), + unresolved: Object.freeze([] as const) + }) +} + +export function legacyWorkerRoute(workerNumber: number): TerminalLegacyWorkerRoute { + return workerRoute(workerNumber) +} + +function importCandidate( + workerNumber: number, + recoveryNumber: number, + namespace: TerminalAuthorityNamespace = LEGACY_TEST_NAMESPACE +): TerminalLegacyImportCandidate { + const paneKey = `pane-${recoveryNumber}` + const tabId = `tab-${recoveryNumber}` + const incarnationId = `pty-incarnation-${recoveryNumber}` + const processId = 2_000 + recoveryNumber + const inventory = Object.freeze({ + ...inventoryEvidence(recoveryNumber), + paneKey, + tabId, + serializedPtyIncarnationId: incarnationId, + serializedProcessId: processId + }) satisfies TerminalLegacyImportMatchEvidence['remoteInventory'] + return Object.freeze({ + recoveryId: recoveryId(recoveryNumber), + namespace, + workspace: workspaceEvidence(recoveryNumber), + physicalPty: Object.freeze({ + workerId: workerId(workerNumber), + physicalPtyId: physicalPtyId(recoveryNumber), + ptyIncarnationId: incarnationId, + processId + }), + pane: Object.freeze({ paneKey, paneGenerationId: `generation-${recoveryNumber}` }), + allocationId: `allocation-${recoveryNumber}`, + spawnFingerprint: `spawn-${recoveryNumber}`, + inventoryEvidence: inventory, + matchEvidence: Object.freeze({ + localLease: Object.freeze({ + leaseId: `lease-${recoveryNumber}`, + paneKey, + paneGenerationId: `generation-${recoveryNumber}`, + rendererGeneration: recoveryNumber, + tabId, + worktreeId: `/repo/${recoveryNumber}` + }), + remoteInventory: inventory, + uniqueness: Object.freeze({ + localCandidates: 1, + remoteCandidates: 1, + endpointIdentityMatched: true, + processIdentityMatched: true + }) + }) + }) +} + +function unresolvedCandidate( + workerNumber: number, + recoveryNumber: number, + namespace: TerminalAuthorityNamespace = LEGACY_TEST_NAMESPACE, + isolated: boolean, + paneKey: string | undefined = undefined +): TerminalLegacyUnresolvedCandidate { + return Object.freeze({ + recoveryId: recoveryId(recoveryNumber), + namespace, + workspace: workspaceEvidence(recoveryNumber), + physicalPty: Object.freeze({ + workerId: workerId(workerNumber), + physicalPtyId: physicalPtyId(recoveryNumber), + ptyIncarnationId: null, + processId: null + }), + reason: 'physical-pty-incarnation-unproved', + evidenceCode: `evidence-${recoveryNumber}`, + inventoryEvidence: Object.freeze({ + ...inventoryEvidence(recoveryNumber), + paneKey: paneKey ?? null + }), + preservation: isolated + ? Object.freeze({ + kind: 'isolated-grace-disabled' as const, + endpointIdentityRetained: true as const, + graceDisabledAcknowledged: true as const + }) + : Object.freeze({ + kind: 'evidence-gc-retained' as const, + processPreservationUnproved: true as const + }) + }) +} + +function workerRoute(workerNumber: number): TerminalLegacyWorkerRoute { + const directory = relayDirectory(workerNumber) + return Object.freeze({ + routeId: `route-${workerNumber}`, + workerId: workerId(workerNumber), + ownerIncarnationId: `legacy-owner-${workerNumber}`, + buildId: `build-${workerNumber}`, + relayDirectory: directory, + socketPath: `${directory}/relay.private.sock`, + credentialFile: `${directory}/credential.private`, + process: processIdentity(workerNumber), + endpoint: endpointIdentity(workerNumber), + sourceOwner: Object.freeze({ + clientInstanceId: `legacy-client-${workerNumber}`, + ownerGeneration: 1, + ownerLease: `owner-lease-${workerNumber}`, + outputWindowSourceUnits: 4_096 + }), + gcProtection: Object.freeze({ + relayDirectories: Object.freeze([directory]), + evidencePaths: Object.freeze([ + `${directory}/relay.private.sock`, + `${directory}/credential.private` + ]) + }) + }) +} + +function cutoverProof(workerNumber: number, migrationNumber: number) { + const directory = relayDirectory(workerNumber) + return Object.freeze({ + kind: 'posix-relocated' as const, + publicCredentialFile: `${directory}/credential`, + privateCredentialFile: `${directory}/credential.private`, + publicSocketPath: `${directory}/relay.sock`, + privateSocketPath: `${directory}/relay.private.sock`, + endpointIdentity: endpointIdentity(workerNumber), + brokerClientCount: 1 as const, + acceptedConnectionCount: 1, + quiescenceSamples: 2, + connectionProof: Object.freeze({ + method: 'linux-procfs-unix' as const, + listenerIdentity: `listener-${workerNumber}`, + brokerConnectionIdentity: `broker-${migrationNumber}`, + acceptedServerConnections: 1 as const + }), + graceConfiguration: Object.freeze({ + capabilityVersion: 1 as const, + configuredGraceMs: 0 as const, + acknowledged: true as const + }), + sealedAtMs: migrationNumber * 10 + }) +} + +function inventoryEvidence(recoveryNumber: number): TerminalLegacyInventoryEvidence { + return Object.freeze({ + evidenceDigest: `inventory-${recoveryNumber}`, + observedAtMs: recoveryNumber, + paneKey: null, + tabId: null, + worktreeId: `/repo/${recoveryNumber}`, + cwd: `/repo/${recoveryNumber}`, + serializedPtyIncarnationId: null, + serializedProcessId: null + }) +} + +function workspaceEvidence(recoveryNumber: number) { + return Object.freeze({ + kind: 'folder' as const, + locator: Object.freeze({ + kind: 'workspace' as const, + canonicalPath: `/repo/${recoveryNumber}`, + pathFlavor: 'posix' as const + }) + }) +} + +function processIdentity(workerNumber: number) { + return Object.freeze({ pid: 1_000 + workerNumber, birthMarker: `birth-${workerNumber}` }) +} + +function endpointIdentity(workerNumber: number) { + return Object.freeze({ + kind: 'unix-socket' as const, + device: '1', + inode: String(10_000 + workerNumber), + changedAtNs: String(20_000 + workerNumber) + }) +} + +function relayDirectory(workerNumber: number): string { + return `/relay/${workerNumber}` +} + +function workerId(workerNumber: number): string { + return `worker-${workerNumber}` +} + +function recoveryId(recoveryNumber: number): string { + return `recovery-${recoveryNumber}` +} + +function physicalPtyId(recoveryNumber: number): string { + return `pty-${recoveryNumber}` +} diff --git a/src/main/session-authority/terminal-authority-app-admission-error.ts b/src/main/session-authority/terminal-authority-app-admission-error.ts new file mode 100644 index 00000000000..a40f883f676 --- /dev/null +++ b/src/main/session-authority/terminal-authority-app-admission-error.ts @@ -0,0 +1,29 @@ +import { TerminalAuthorityAppAdmissionRejectedError } from './terminal-authority-app-outcome-host-contract' + +const DEFINITIVE_ADMISSION_REJECTIONS = [ + 'terminal authority namespace admission start is invalid', + 'terminal authority namespace admission proof is invalid', + 'terminal authority namespace admission retry changed', + 'terminal authority namespace admission challenge is stale', + 'terminal authority namespace admission challenge expired', + 'terminal authority namespace admission proof was rejected', + 'terminal authority namespace admission CAS changed', + 'terminal authority namespace admission targets another namespace', + 'terminal authority namespace admission transport changed', + 'terminal authority namespace admission request changed', + 'terminal authority namespace admission reservation is stale', + 'terminal authority namespace admission preparation is stale', + 'terminal authority namespace admission did not commit its claim', + 'terminal authority authenticated consumer host changed' +] as const + +export function terminalAuthorityAppAdmissionRejection( + value: unknown +): TerminalAuthorityAppAdmissionRejectedError | null { + if (!(value instanceof Error)) { + return null + } + return DEFINITIVE_ADMISSION_REJECTIONS.some((message) => value.message.startsWith(message)) + ? new TerminalAuthorityAppAdmissionRejectedError(value.message) + : null +} diff --git a/src/main/session-authority/terminal-authority-app-consumer-retirement.ts b/src/main/session-authority/terminal-authority-app-consumer-retirement.ts new file mode 100644 index 00000000000..99d0b99c03c --- /dev/null +++ b/src/main/session-authority/terminal-authority-app-consumer-retirement.ts @@ -0,0 +1,115 @@ +import { + TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_ALGORITHM, + TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_VERSION, + parseTerminalAuthorityConsumerRetirementChallenge, + parseTerminalAuthorityConsumerRetirementResult, + parseTerminalAuthorityConsumerRetirementStart, + type TerminalAuthorityConsumerRetirementChallenge, + type TerminalAuthorityConsumerRetirementResult, + type TerminalAuthorityConsumerRetirementStart +} from '../../shared/terminal-session-authority-consumer-retirement' +import type { TerminalAuthorityAppConsumerRetirementRequest } from './terminal-authority-app-outcome-host-contract' +import { + createTerminalAuthorityConsumerRetirementProof, + terminalAuthorityHostAppConsumerId, + type TerminalAuthorityConsumerProofKeypair +} from './terminal-session-authority-consumer-proof' + +export async function retireTerminalAuthorityAppConsumer(options: { + authenticatedAuthorityHostId: string + keypair: TerminalAuthorityConsumerProofKeypair + request: TerminalAuthorityAppConsumerRetirementRequest + issueChallenge(start: TerminalAuthorityConsumerRetirementStart): Promise + complete( + proof: ReturnType + ): Promise +}): Promise { + const start = terminalAuthorityAppConsumerRetirementStart( + options.authenticatedAuthorityHostId, + options.keypair, + options.request + ) + const challenge = requireTerminalAuthorityAppConsumerRetirementChallenge( + await options.issueChallenge(start), + options.authenticatedAuthorityHostId, + options.keypair, + start + ) + const proof = createTerminalAuthorityConsumerRetirementProof(challenge, options.keypair) + return requireTerminalAuthorityAppConsumerRetirementResult( + await options.complete(proof), + challenge + ) +} + +export function terminalAuthorityAppConsumerRetirementStart( + authenticatedAuthorityHostId: string, + keypair: TerminalAuthorityConsumerProofKeypair, + request: TerminalAuthorityAppConsumerRetirementRequest +): TerminalAuthorityConsumerRetirementStart { + const start = parseTerminalAuthorityConsumerRetirementStart({ + version: TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_VERSION, + algorithm: TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_ALGORITHM, + ...request, + appPublicKeyB64: Buffer.from(keypair.publicKey).toString('base64') + }) + if (!start || start.namespace.authorityHostId !== authenticatedAuthorityHostId) { + throw new Error('terminal authority consumer retirement request is invalid') + } + return start +} + +export function requireTerminalAuthorityAppConsumerRetirementChallenge( + unsafeChallenge: unknown, + authenticatedAuthorityHostId: string, + keypair: TerminalAuthorityConsumerProofKeypair, + start: TerminalAuthorityConsumerRetirementStart +): TerminalAuthorityConsumerRetirementChallenge { + const challenge = parseTerminalAuthorityConsumerRetirementChallenge(unsafeChallenge) + const expectedConsumerId = terminalAuthorityHostAppConsumerId( + authenticatedAuthorityHostId, + keypair.publicKey + ) + if (!challenge || !sameStart(challenge, start) || challenge.consumerId !== expectedConsumerId) { + throw new Error('terminal authority consumer retirement challenge is invalid') + } + return challenge +} + +export function requireTerminalAuthorityAppConsumerRetirementResult( + unsafeResult: unknown, + challenge: TerminalAuthorityConsumerRetirementChallenge +): TerminalAuthorityConsumerRetirementResult { + const result = parseTerminalAuthorityConsumerRetirementResult(unsafeResult) + if ( + !result || + result.namespace.authorityHostId !== challenge.namespace.authorityHostId || + result.namespace.namespaceId !== challenge.namespace.namespaceId || + result.consumerId !== challenge.consumerId || + result.retiredConsumerIncarnationId !== challenge.currentConsumerIncarnationId || + result.requestId !== challenge.requestId || + result.candidateProcessIncarnationId !== challenge.candidateProcessIncarnationId || + result.candidateSessionNonce !== challenge.candidateSessionNonce || + result.connectionGrantId !== challenge.connectionGrantId || + result.retirementCas !== challenge.retirementCas + ) { + throw new Error('terminal authority consumer retirement result is invalid') + } + return result +} + +function sameStart( + challenge: TerminalAuthorityConsumerRetirementChallenge, + start: TerminalAuthorityConsumerRetirementStart +): boolean { + return ( + challenge.version === start.version && + challenge.algorithm === start.algorithm && + challenge.namespace.authorityHostId === start.namespace.authorityHostId && + challenge.namespace.namespaceId === start.namespace.namespaceId && + challenge.appPublicKeyB64 === start.appPublicKeyB64 && + challenge.candidateProcessIncarnationId === start.candidateProcessIncarnationId && + challenge.candidateSessionNonce === start.candidateSessionNonce && + challenge.requestId === start.requestId + ) +} diff --git a/src/main/session-authority/terminal-authority-app-outcome-boundary.ts b/src/main/session-authority/terminal-authority-app-outcome-boundary.ts new file mode 100644 index 00000000000..2146e6a33ed --- /dev/null +++ b/src/main/session-authority/terminal-authority-app-outcome-boundary.ts @@ -0,0 +1,63 @@ +import { + TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_VERSION, + parseTerminalAuthorityNamespaceOutcomeBoundary, + type TerminalAuthorityNamespaceBoundaryAcceptance, + type TerminalAuthorityNamespaceOutcomeBoundary, + type TerminalAuthorityPolicyConsumerIdentity +} from '../../shared/terminal-session-authority-consumer-transport' +import { terminalSessionAuthorityBoundaryId } from '../../shared/terminal-session-authority-boundary-identity' +import { + observeTerminalAuthorityAppProjection, + type TerminalAuthorityAppOutcomeManagerOptions +} from './terminal-authority-app-outcome-host-contract' +import { + assertTerminalAuthorityAppOutcomeConsumer, + isTerminalAuthorityAppCompleteBoundary, + type TerminalAuthorityAppCompleteBoundary, + type TerminalAuthorityAppNamespaceGeneration +} from './terminal-authority-app-outcome-namespace-state' + +export function requireTerminalAuthorityAppOutcomeBoundary( + value: TerminalAuthorityNamespaceOutcomeBoundary +): TerminalAuthorityAppCompleteBoundary { + const boundary = parseTerminalAuthorityNamespaceOutcomeBoundary(value) + if (!isTerminalAuthorityAppCompleteBoundary(boundary)) { + throw new Error('terminal authority app outcome boundary is invalid') + } + const { boundaryId, ...unsigned } = boundary + if (terminalSessionAuthorityBoundaryId(unsigned) !== boundaryId) { + throw new Error('terminal authority app outcome boundary identity is invalid') + } + return boundary +} + +export function commitTerminalAuthorityAppOutcomeBoundary( + options: Readonly<{ + state: TerminalAuthorityAppNamespaceGeneration + boundary: TerminalAuthorityAppCompleteBoundary + identity: TerminalAuthorityPolicyConsumerIdentity + pump: TerminalAuthorityAppOutcomeManagerOptions + }> +): TerminalAuthorityNamespaceBoundaryAcceptance { + const { state, boundary } = options + assertTerminalAuthorityAppOutcomeConsumer(options.identity, boundary.consumer) + const current = state.boundary + if (current && boundary.acknowledgedSequence < current.previousSequence) { + throw new Error('terminal authority app outcome boundary regressed') + } + const change = options.pump.store.beginBoundary(boundary) + observeTerminalAuthorityAppProjection(options.pump, change) + state.boundary = { + value: boundary, + previousSequence: boundary.acknowledgedSequence, + snapshotCommitted: boundary.acknowledgedSequence === boundary.outcomeHighWatermark + } + return Object.freeze({ + version: TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_VERSION, + consumer: boundary.consumer, + namespace: boundary.namespace, + boundaryId: boundary.boundaryId, + acknowledgedSequence: boundary.acknowledgedSequence, + outcomeHighWatermark: boundary.outcomeHighWatermark + }) +} diff --git a/src/main/session-authority/terminal-authority-app-outcome-connection-generation.ts b/src/main/session-authority/terminal-authority-app-outcome-connection-generation.ts new file mode 100644 index 00000000000..938dd63e74a --- /dev/null +++ b/src/main/session-authority/terminal-authority-app-outcome-connection-generation.ts @@ -0,0 +1,126 @@ +const MAX_QUEUED_NAMESPACE_OPERATIONS = 4 + +type Settlement = + | Readonly<{ kind: 'value'; value: T }> + | Readonly<{ kind: 'error'; error: unknown }> + | Readonly<{ kind: 'canceled' }> + | Readonly<{ kind: 'timeout' }> + +/** Owns every queue and pending transport wait for one exact host connection generation. */ +export class TerminalAuthorityAppOutcomeConnectionGeneration { + private readonly queues = new Map>() + private readonly queuedCounts = new Map() + private readonly canceled: Promise + private cancelGeneration!: () => void + private active = true + + constructor(readonly id: number) { + this.canceled = new Promise((resolve) => { + this.cancelGeneration = resolve + }) + } + + enqueue(key: string, operation: () => T | Promise): Promise { + this.assertActive() + const count = (this.queuedCounts.get(key) ?? 0) + 1 + if (count > MAX_QUEUED_NAMESPACE_OPERATIONS) { + throw new Error('terminal authority app outcome queue capacity exceeded') + } + this.queuedCounts.set(key, count) + const previous = this.queues.get(key) ?? Promise.resolve() + const result = previous.then(() => { + this.assertActive() + return operation() + }) + const tail = result.then( + () => undefined, + () => undefined + ) + this.queues.set(key, tail) + void tail.then(() => { + this.queuedCounts.set(key, Math.max(0, (this.queuedCounts.get(key) ?? 1) - 1)) + if (this.queues.get(key) === tail) { + this.queues.delete(key) + } + }) + return result + } + + async settle(operation: Promise, timeoutMs: number, label: string): Promise { + this.assertActive() + let timer: ReturnType | undefined + const timeout = new Promise>((resolve) => { + timer = setTimeout(() => resolve({ kind: 'timeout' }), timeoutMs) + timer.unref?.() + }) + const result = await Promise.race>([ + operation.then( + (value) => ({ kind: 'value', value }), + (error) => ({ kind: 'error', error }) + ), + this.canceled.then(() => ({ kind: 'canceled' })), + timeout + ]) + if (timer) { + clearTimeout(timer) + } + if (result.kind === 'value') { + return result.value + } + if (result.kind === 'error') { + throw result.error + } + if (result.kind === 'timeout') { + throw new Error(`terminal authority app outcome ${label} settlement timed out`) + } + throw new Error('terminal authority app outcome connection generation was canceled') + } + + async waitForPending(operation: Promise): Promise { + this.assertActive() + const result = await Promise.race>([ + operation.then( + (value) => ({ kind: 'value', value }), + (error) => ({ kind: 'error', error }) + ), + this.canceled.then(() => ({ kind: 'canceled' })) + ]) + if (result.kind === 'value') { + return result.value + } + if (result.kind === 'error') { + throw result.error + } + throw new Error('terminal authority app outcome connection generation was canceled') + } + + async waitBeforeReconnect(delayMs: number): Promise { + this.assertActive() + let timer: ReturnType | undefined + const elapsed = new Promise((resolve) => { + timer = setTimeout(() => resolve(true), delayMs) + timer.unref?.() + }) + const result = await Promise.race([elapsed, this.canceled.then(() => false)]) + if (timer) { + clearTimeout(timer) + } + return result + } + + cancel(): void { + if (!this.active) { + return + } + this.active = false + this.cancelGeneration() + this.queues.clear() + this.queuedCounts.clear() + } + + private assertActive(): void { + if (!this.active) { + throw new Error('terminal authority app outcome connection generation was canceled') + } + } +} diff --git a/src/main/session-authority/terminal-authority-app-outcome-host-contract.ts b/src/main/session-authority/terminal-authority-app-outcome-host-contract.ts new file mode 100644 index 00000000000..f13e55f4ad6 --- /dev/null +++ b/src/main/session-authority/terminal-authority-app-outcome-host-contract.ts @@ -0,0 +1,187 @@ +import type { + TerminalAuthorityAppPaneProjection, + TerminalAuthorityAppProjectionRowIdentity +} from '../../shared/terminal-authority-app-projection' +import type { + TerminalAuthorityNamespaceBoundaryAcceptance, + TerminalAuthorityNamespaceOutcomeAck, + TerminalAuthorityPolicyConsumerIdentity +} from '../../shared/terminal-session-authority-consumer-transport' +import type { + TerminalAuthorityNamespaceAdmissionGrant, + TerminalAuthorityNamespaceAdmissionIntent +} from '../../shared/terminal-session-authority-consumer-proof' +import type { TerminalAuthorityConsumerRetirementResult } from '../../shared/terminal-session-authority-consumer-retirement' +import type { TerminalAuthorityNamespace } from '../../shared/terminal-session-authority-identity' +import type { TerminalAuthorityPolicyOutcomeTransport } from './terminal-session-authority-policy-consumers' +import type { TerminalAuthorityAppProjectionStore } from './terminal-authority-app-projection-store' + +export type TerminalAuthorityAppOutcomeNamespaceConnection = Readonly<{ + expectedConsumer: TerminalAuthorityPolicyConsumerIdentity + grant: TerminalAuthorityNamespaceAdmissionGrant + activate?(): void | Promise + acceptBoundary(acceptance: TerminalAuthorityNamespaceBoundaryAcceptance): Promise + acknowledge(ack: TerminalAuthorityNamespaceOutcomeAck): Promise + retire(requestId: string): Promise + disconnect(): void +}> + +export type TerminalAuthorityAppNamespaceAdmissionRequest = Readonly<{ + namespace: TerminalAuthorityNamespace + candidateProcessIncarnationId: string + candidateSessionNonce: string + requestId: string + intent: TerminalAuthorityNamespaceAdmissionIntent +}> + +export type TerminalAuthorityAppConsumerRetirementRequest = Readonly<{ + namespace: TerminalAuthorityNamespace + candidateProcessIncarnationId: string + candidateSessionNonce: string + requestId: string +}> + +export type TerminalAuthorityAppOutcomeHostConnection = Readonly<{ + authenticatedAuthorityHostId: string + resolveNamespace(worktreeId: string): Promise + openNamespace( + request: TerminalAuthorityAppNamespaceAdmissionRequest, + transport: TerminalAuthorityPolicyOutcomeTransport, + onOpening?: (connection: TerminalAuthorityAppOutcomeNamespaceConnection) => void + ): Promise + retireNamespace( + request: TerminalAuthorityAppConsumerRetirementRequest + ): Promise + disconnect(): void +}> + +export type TerminalAuthorityAppAdmissionLocator = + | Readonly<{ namespace: TerminalAuthorityNamespace }> + | Readonly<{ worktreeId: string }> + +export type TerminalAuthorityAppAdmissionBinding = Readonly<{ + namespace: TerminalAuthorityNamespace + assertCurrent(): void +}> + +export type TerminalAuthorityAppOutcomeNamespaceBinding = Readonly<{ + assertCurrent(): void +}> + +export type TerminalAuthorityAppResolvedNamespaceBinding = Readonly<{ + namespace: TerminalAuthorityNamespace + binding: TerminalAuthorityAppOutcomeNamespaceBinding +}> + +export type TerminalAuthorityAppNamespaceAdmission = Readonly<{ + admitNamespace(namespace: TerminalAuthorityNamespace): Promise + resolveAndAdmitNamespace(worktreeId: string): Promise + withSourceAdmission( + locator: TerminalAuthorityAppAdmissionLocator, + operation: (binding: TerminalAuthorityAppAdmissionBinding) => Promise + ): Promise +}> + +export type TerminalAuthorityAppOutcomeHostTransport = Readonly<{ + authenticatedAuthorityHostId: string + connect( + transport: Readonly<{ onFailure(error: unknown): void }> + ): Promise +}> + +export type TerminalAuthorityAppProjectionObservation = Readonly<{ + rows: readonly TerminalAuthorityAppPaneProjection[] + deleted: readonly TerminalAuthorityAppProjectionRowIdentity[] +}> + +export type TerminalAuthorityAppOutcomeManagerOptions = Readonly<{ + store: TerminalAuthorityAppProjectionStore + onProjection: (change: TerminalAuthorityAppProjectionObservation) => void + onError?: (error: Error) => void + connectTimeoutMs?: number + acknowledgeTimeoutMs?: number + reconnectBaseDelayMs?: number + reconnectMaxDelayMs?: number + createAdmissionId?: () => string +}> + +export type TerminalAuthorityAppOutcomeNamespaceSessionOptions = + TerminalAuthorityAppOutcomeManagerOptions & + Readonly<{ + processIncarnationId: string + namespace: TerminalAuthorityNamespace + hostConnection: () => Promise + }> + +export type TerminalAuthorityAppOutcomeTiming = Readonly<{ + connectTimeoutMs: number + acknowledgeTimeoutMs: number + reconnectBaseDelayMs: number + reconnectMaxDelayMs: number +}> + +export class TerminalAuthorityAppAdmissionIntentRequiredError extends Error { + constructor(readonly requiredIntent: TerminalAuthorityNamespaceAdmissionIntent) { + super(`terminal authority app outcome admission requires ${requiredIntent}`) + } +} + +export class TerminalAuthorityAppAdmissionRejectedError extends Error { + constructor(message = 'terminal authority app outcome admission was rejected') { + super(message) + } +} + +export function resolveTerminalAuthorityAppOutcomeTiming( + options: TerminalAuthorityAppOutcomeManagerOptions +): TerminalAuthorityAppOutcomeTiming { + const timing = Object.freeze({ + connectTimeoutMs: positiveDuration(options.connectTimeoutMs, 10_000), + acknowledgeTimeoutMs: positiveDuration(options.acknowledgeTimeoutMs, 10_000), + reconnectBaseDelayMs: positiveDuration(options.reconnectBaseDelayMs, 100), + reconnectMaxDelayMs: positiveDuration(options.reconnectMaxDelayMs, 5_000) + }) + if (timing.reconnectBaseDelayMs > timing.reconnectMaxDelayMs) { + throw new Error('terminal authority app outcome reconnect delays are invalid') + } + return timing +} + +export function terminalAuthorityAppReconnectDelay( + timing: TerminalAuthorityAppOutcomeTiming, + attempt: number +): number { + return Math.min( + timing.reconnectMaxDelayMs, + timing.reconnectBaseDelayMs * 2 ** Math.min(attempt, 10) + ) +} + +export function observeTerminalAuthorityAppProjection( + options: TerminalAuthorityAppOutcomeManagerOptions, + change: TerminalAuthorityAppProjectionObservation +): void { + if (change.rows.length === 0 && change.deleted.length === 0) { + return + } + try { + options.onProjection(change) + } catch (error) { + options.onError?.(error instanceof Error ? error : new Error(String(error))) + } +} + +export function reportTerminalAuthorityAppOutcomeError( + options: TerminalAuthorityAppOutcomeManagerOptions, + value: unknown +): void { + options.onError?.(value instanceof Error ? value : new Error(String(value))) +} + +function positiveDuration(value: number | undefined, fallback: number): number { + const selected = value ?? fallback + if (!Number.isSafeInteger(selected) || selected < 1) { + throw new Error('terminal authority app outcome duration is invalid') + } + return selected +} diff --git a/src/main/session-authority/terminal-authority-app-outcome-host-manager.test.ts b/src/main/session-authority/terminal-authority-app-outcome-host-manager.test.ts new file mode 100644 index 00000000000..11120ce08c8 --- /dev/null +++ b/src/main/session-authority/terminal-authority-app-outcome-host-manager.test.ts @@ -0,0 +1,401 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + authorityProjection, + boundary, + semanticPublication +} from './__tests__/terminal-authority-app-projection-fixture' +import type { TerminalAuthorityNamespace } from '../../shared/terminal-session-authority-identity' +import type { + TerminalAuthorityNamespaceOutcomeBoundary, + TerminalAuthorityNamespaceOutcomePublication, + TerminalAuthorityPolicyConsumerIdentity +} from '../../shared/terminal-session-authority-consumer-transport' +import type { TerminalAuthorityPolicyOutcomeTransport } from './terminal-session-authority-policy-consumers' +import { TerminalAuthorityAppOutcomeHostManager } from './terminal-authority-app-outcome-host-manager' +import { + TerminalAuthorityAppAdmissionIntentRequiredError, + type TerminalAuthorityAppConsumerRetirementRequest, + type TerminalAuthorityAppNamespaceAdmissionRequest, + type TerminalAuthorityAppOutcomeHostTransport +} from './terminal-authority-app-outcome-host-contract' +import { TerminalAuthorityAppProjectionStore } from './terminal-authority-app-projection-store' +import { terminalSessionAuthorityBoundaryId } from '../../shared/terminal-session-authority-boundary-identity' + +const directories: string[] = [] + +afterEach(async () => { + await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true }))) +}) + +describe('TerminalAuthorityAppOutcomeHostManager', () => { + it('connects hosts lazily and fences a physical failure to its authenticated host', async () => { + const store = await memoryStore() + const first = hostHarness('host-1') + const second = hostHarness('host-2') + const manager = new TerminalAuthorityAppOutcomeHostManager('app-process:test-process', { + store, + onProjection: () => {}, + reconnectBaseDelayMs: 1, + reconnectMaxDelayMs: 1 + }) + const firstRegistration = manager.installHost(first.transport) + const secondRegistration = manager.installHost(second.transport) + expect(first.connectHost).not.toHaveBeenCalled() + expect(second.connectHost).not.toHaveBeenCalled() + + await Promise.all([ + firstRegistration.admitNamespace(namespace('host-1', 'one')), + secondRegistration.admitNamespace(namespace('host-2', 'one')) + ]) + const firstRequest = first.openNamespace.mock.calls.at(-1)?.[0] + const secondRequest = second.openNamespace.mock.calls.at(-1)?.[0] + expect(firstRequest?.candidateProcessIncarnationId).toBe('app-process:test-process') + expect(secondRequest?.candidateProcessIncarnationId).toBe('app-process:test-process') + expect(firstRequest?.candidateSessionNonce).not.toBe(secondRequest?.candidateSessionNonce) + expect(firstRequest).not.toHaveProperty('consumer') + expect(firstRequest).not.toHaveProperty('appPublicKeyB64') + first.failHost(new Error('host one disconnected')) + await vi.waitFor(() => expect(first.connectHost).toHaveBeenCalledTimes(2)) + + expect(second.connectHost).toHaveBeenCalledTimes(1) + await second.publish(publication('host-2', 'one', 1)) + expect(second.acknowledge).toHaveBeenCalledWith(expect.objectContaining({ sequence: 1 })) + manager.dispose() + store.close() + }) + + it('retries one namespace without rolling back sibling namespace grants', async () => { + const store = await memoryStore() + const host = hostHarness('host-1') + const manager = new TerminalAuthorityAppOutcomeHostManager('app-process:test-process', { + store, + onProjection: () => {}, + reconnectBaseDelayMs: 1, + reconnectMaxDelayMs: 1 + }) + const registration = manager.installHost(host.transport) + const first = namespace('host-1', 'one') + const second = namespace('host-1', 'two') + await Promise.all([registration.admitNamespace(first), registration.admitNamespace(second)]) + + host.failNamespace(first, new Error('namespace one failed')) + await vi.waitFor(() => expect(host.openCount(first)).toBe(2)) + expect(host.openCount(second)).toBe(1) + expect(host.connectHost).toHaveBeenCalledTimes(1) + + await host.publish(publication('host-1', 'two', 1)) + expect(host.acknowledge).toHaveBeenCalledWith( + expect.objectContaining({ namespace: second, sequence: 1 }) + ) + manager.dispose() + store.close() + }) + + it('waits for the replacement host generation before re-admitting an existing namespace', async () => { + const store = await memoryStore() + const host = hostHarness('host-1', { blockHostReconnect: true }) + const manager = new TerminalAuthorityAppOutcomeHostManager('app-process:test-process', { + store, + onProjection: () => {}, + reconnectBaseDelayMs: 1, + reconnectMaxDelayMs: 1 + }) + const registration = manager.installHost(host.transport) + const target = namespace('host-1', 'one') + await registration.admitNamespace(target) + + host.failHost(new Error('host transport replaced')) + await vi.waitFor(() => expect(host.connectHost).toHaveBeenCalledTimes(2)) + + let admitted = false + const waiting = registration.admitNamespace(target).then(() => { + admitted = true + }) + await Promise.resolve() + expect(admitted).toBe(false) + + host.releaseHostReconnect() + await waiting + expect(host.openCount(target)).toBe(2) + manager.dispose() + store.close() + }) + + it('rejects caller namespace aliases and duplicate authenticated host installs', async () => { + const store = await memoryStore() + const host = hostHarness('host-1') + const manager = new TerminalAuthorityAppOutcomeHostManager('app-process:test-process', { + store, + onProjection: () => {} + }) + const registration = manager.installHost(host.transport) + + expect(() => manager.installHost(host.transport)).toThrow('already installed') + await expect(registration.admitNamespace(namespace('host-2', 'one'))).rejects.toThrow( + 'another host' + ) + expect(host.connectHost).not.toHaveBeenCalled() + manager.dispose() + store.close() + }) + + it('resolves a final-host namespace before admitting its independent consumer session', async () => { + const store = await memoryStore() + const host = hostHarness('host-1') + const manager = new TerminalAuthorityAppOutcomeHostManager('app-process:test-process', { + store, + onProjection: () => {} + }) + const registration = manager.installHost(host.transport) + + await expect(registration.resolveAndAdmitNamespace('repo::/workspace')).resolves.toEqual( + namespace('host-1', 'resolved') + ) + expect(host.resolveNamespace).toHaveBeenCalledWith('repo::/workspace') + expect(host.openNamespace).toHaveBeenCalledWith( + expect.objectContaining({ namespace: namespace('host-1', 'resolved') }), + expect.any(Object), + expect.any(Function) + ) + manager.dispose() + store.close() + }) + + it('keeps a lost-response retirement pending without re-admitting that namespace', async () => { + const store = await memoryStore() + const host = hostHarness('host-1') + const manager = new TerminalAuthorityAppOutcomeHostManager('app-process:test-process', { + store, + onProjection: () => {}, + reconnectBaseDelayMs: 1, + reconnectMaxDelayMs: 1 + }) + const registration = manager.installHost(host.transport) + const retiring = namespace('host-1', 'retiring') + const sibling = namespace('host-1', 'sibling') + await Promise.all([registration.admitNamespace(retiring), registration.admitNamespace(sibling)]) + const request = retirementRequest(retiring) + host.retireNamespace.mockImplementationOnce(async () => { + host.failHost(new Error('retirement response lost after append')) + throw new Error('retirement response lost after append') + }) + + await expect(registration.retireNamespace(request)).rejects.toThrow('response lost') + await vi.waitFor(() => expect(host.openCount(sibling)).toBe(2)) + expect(host.openCount(retiring)).toBe(1) + await expect(registration.admitNamespace(retiring)).rejects.toThrow('retirement is pending') + await expect( + registration.retireNamespace({ ...request, requestId: 'retirement-request:changed' }) + ).rejects.toThrow('request changed') + + await expect(registration.retireNamespace(request)).resolves.toMatchObject({ + namespace: retiring, + retired: true + }) + expect(host.retireNamespace).toHaveBeenCalledTimes(2) + expect(host.openCount(retiring)).toBe(1) + manager.dispose() + store.close() + }) +}) + +function hostHarness(hostId: string, options: Readonly<{ blockHostReconnect?: boolean }> = {}) { + const sinks = new Map() + const principals = new Map() + const claimed = new Set() + const opens = new Map() + const reconnectGate = deferred() + let hostFailure: ((error: unknown) => void) | null = null + const acceptBoundary = vi.fn(async (acceptance) => { + claimed.add(namespaceKey(acceptance.namespace)) + }) + const acknowledge = vi.fn(async (ack) => ack.sequence) + const resolveNamespace = vi.fn(async () => namespace(hostId, 'resolved')) + const retireNamespace = vi.fn(async (request: TerminalAuthorityAppConsumerRetirementRequest) => + retirementResult(request) + ) + const openNamespace = vi.fn( + async ( + request: TerminalAuthorityAppNamespaceAdmissionRequest, + sink: TerminalAuthorityPolicyOutcomeTransport + ) => { + const requiredIntent = claimed.has(namespaceKey(request.namespace)) ? 'resume' : 'first' + if (request.intent !== requiredIntent) { + throw new TerminalAuthorityAppAdmissionIntentRequiredError(requiredIntent) + } + const target = request.namespace + const principal = Object.freeze({ + consumerId: `app-profile:v1:derived-${hostId}`, + consumerIncarnationId: request.candidateProcessIncarnationId + }) + const key = namespaceKey(target) + opens.set(key, (opens.get(key) ?? 0) + 1) + sinks.set(key, sink) + principals.set(key, principal) + return { + expectedConsumer: principal, + grant: Object.freeze({ + version: 1 as const, + consumer: principal, + namespace: target, + requestId: request.requestId, + connectionGrantId: `connection-grant:${request.requestId}`, + admissionCas: 'admission-cas-after', + replayed: false + }), + activate: async () => + sink.publishBoundary(hostBoundary(target, principal, claimed.has(key))), + acceptBoundary, + acknowledge, + retire: vi.fn(), + disconnect: () => { + if (sinks.get(key) === sink) { + sinks.delete(key) + } + } + } + } + ) + const connectHost = vi.fn(async (lifecycle: Readonly<{ onFailure(error: unknown): void }>) => { + if (options.blockHostReconnect && connectHost.mock.calls.length > 1) { + await reconnectGate.promise + } + hostFailure = lifecycle.onFailure + return { + authenticatedAuthorityHostId: hostId, + resolveNamespace, + openNamespace, + retireNamespace, + disconnect: () => {} + } + }) + const transport: TerminalAuthorityAppOutcomeHostTransport = { + authenticatedAuthorityHostId: hostId, + connect: connectHost + } + return { + transport, + connectHost, + openNamespace, + resolveNamespace, + retireNamespace, + acknowledge, + openCount: (target: TerminalAuthorityNamespace) => opens.get(namespaceKey(target)) ?? 0, + publish(value: TerminalAuthorityNamespaceOutcomePublication) { + const sink = sinks.get(namespaceKey(value.namespace)) + if (!sink) { + throw new Error('namespace sink unavailable') + } + return sink.publishOutcome({ + ...value, + consumer: principals.get(namespaceKey(value.namespace))! + }) + }, + failNamespace(target: TerminalAuthorityNamespace, error: Error) { + const sink = sinks.get(namespaceKey(target)) + if (!sink) { + throw new Error('namespace sink unavailable') + } + sink.onFailure?.(error) + }, + failHost(error: Error) { + if (!hostFailure) { + throw new Error('host sink unavailable') + } + hostFailure(error) + }, + releaseHostReconnect() { + reconnectGate.resolve() + } + } +} + +function retirementRequest(namespace: TerminalAuthorityNamespace) { + return Object.freeze({ + namespace, + candidateProcessIncarnationId: 'app-process:test-process', + candidateSessionNonce: `retirement-session:${namespace.namespaceId}`, + requestId: `retirement-request:${namespace.namespaceId}` + }) +} + +function retirementResult(request: TerminalAuthorityAppConsumerRetirementRequest) { + return Object.freeze({ + version: 1 as const, + namespace: request.namespace, + consumerId: `app-profile:v1:derived-${request.namespace.authorityHostId}`, + retiredConsumerIncarnationId: request.candidateProcessIncarnationId, + requestId: request.requestId, + candidateProcessIncarnationId: request.candidateProcessIncarnationId, + candidateSessionNonce: request.candidateSessionNonce, + connectionGrantId: 'connection-grant:retirement', + retirementCas: 'retirement-cas:result', + retired: true as const, + alreadyAbsent: false, + replayed: false + }) +} + +function hostBoundary( + target: TerminalAuthorityNamespace, + consumer: TerminalAuthorityPolicyConsumerIdentity, + resumed: boolean +): TerminalAuthorityNamespaceOutcomeBoundary { + const candidate = boundary(0, { namespaceId: target.namespaceId }) + const projection = Object.freeze({ + ...authorityProjection({ namespaceId: target.namespaceId }), + namespace: target + }) + const unsigned = { + ...candidate, + boundaryId: undefined, + consumer, + namespace: target, + projection, + consumerStart: resumed ? ('resume' as const) : ('new-at-tail' as const) + } + const { boundaryId: _boundaryId, ...value } = unsigned + return Object.freeze({ ...value, boundaryId: terminalSessionAuthorityBoundaryId(value) }) +} + +function publication( + hostId: string, + namespaceId: string, + sequence: number +): TerminalAuthorityNamespaceOutcomePublication { + const candidate = semanticPublication(sequence, { kind: 'bell' }, { namespaceId }) + if (candidate.outcome.kind !== 'semantic') { + throw new Error('semantic fixture returned a mutation outcome') + } + const target = namespace(hostId, namespaceId) + const outcome = Object.freeze({ + ...candidate.outcome, + access: Object.freeze({ ...candidate.outcome.access, namespace: target }) + }) + return Object.freeze({ ...candidate, namespace: target, outcome }) +} + +function namespace(authorityHostId: string, namespaceId: string): TerminalAuthorityNamespace { + return Object.freeze({ authorityHostId, namespaceId }) +} + +function namespaceKey(value: TerminalAuthorityNamespace): string { + return JSON.stringify([value.authorityHostId, value.namespaceId]) +} + +async function memoryStore(): Promise { + const directory = await mkdtemp(path.join(tmpdir(), 'orca-app-host-manager-')) + directories.push(directory) + return TerminalAuthorityAppProjectionStore.open({ directory, databasePath: ':memory:' }) +} + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((settle) => { + resolve = settle + }) + return { promise, resolve } +} diff --git a/src/main/session-authority/terminal-authority-app-outcome-host-manager.ts b/src/main/session-authority/terminal-authority-app-outcome-host-manager.ts new file mode 100644 index 00000000000..0d52113c85a --- /dev/null +++ b/src/main/session-authority/terminal-authority-app-outcome-host-manager.ts @@ -0,0 +1,107 @@ +import { + assertAuthorityId, + type TerminalAuthorityNamespace +} from '../../shared/terminal-session-authority-identity' +import type { TerminalAuthorityConsumerRetirementResult } from '../../shared/terminal-session-authority-consumer-retirement' +import type { + TerminalAuthorityAppConsumerRetirementRequest, + TerminalAuthorityAppOutcomeHostTransport, + TerminalAuthorityAppOutcomeManagerOptions, + TerminalAuthorityAppOutcomeNamespaceBinding, + TerminalAuthorityAppResolvedNamespaceBinding +} from './terminal-authority-app-outcome-host-contract' +import { TerminalAuthorityAppOutcomeHost } from './terminal-authority-app-outcome-host' + +const MAX_APP_AUTHORITY_HOSTS = 256 + +export type TerminalAuthorityAppOutcomeHostRegistration = Readonly<{ + authenticatedAuthorityHostId: string + knownNamespaceIds(): readonly string[] + admitNamespace(namespace: TerminalAuthorityNamespace): Promise + bindNamespace( + namespace: TerminalAuthorityNamespace + ): Promise + resolveAndAdmitNamespace(worktreeId: string): Promise + resolveAndBindNamespace(worktreeId: string): Promise + retireNamespace( + request: TerminalAuthorityAppConsumerRetirementRequest + ): Promise + dispose(): void +}> + +export class TerminalAuthorityAppOutcomeHostManager { + private readonly hosts = new Map() + + constructor( + private readonly processIncarnationId: string, + private readonly options: TerminalAuthorityAppOutcomeManagerOptions + ) { + requireAppProcessIncarnationId(processIncarnationId) + } + + getProcessIncarnationId(): string { + return this.processIncarnationId + } + + installHost( + transport: TerminalAuthorityAppOutcomeHostTransport + ): TerminalAuthorityAppOutcomeHostRegistration { + const hostId = requireAuthenticatedHostId(transport.authenticatedAuthorityHostId) + if (this.hosts.has(hostId)) { + throw new Error('terminal authority app outcome host is already installed') + } + if (this.hosts.size >= MAX_APP_AUTHORITY_HOSTS) { + throw new Error('terminal authority app outcome host capacity exceeded') + } + const host = new TerminalAuthorityAppOutcomeHost( + hostId, + transport, + this.processIncarnationId, + this.options, + () => this.hosts.get(hostId) === host + ) + this.hosts.set(hostId, host) + return Object.freeze({ + authenticatedAuthorityHostId: hostId, + knownNamespaceIds: () => host.knownNamespaceIds(), + admitNamespace: (namespace) => host.admitNamespace(namespace), + bindNamespace: (namespace) => host.bindNamespace(namespace), + resolveAndAdmitNamespace: (worktreeId) => host.resolveAndAdmitNamespace(worktreeId), + resolveAndBindNamespace: (worktreeId) => host.resolveAndBindNamespace(worktreeId), + retireNamespace: (request) => host.retireNamespace(request), + dispose: () => { + if (this.hosts.get(hostId) === host) { + this.hosts.delete(hostId) + host.dispose() + } + } + }) + } + + snapshot(): Readonly<{ hosts: number; namespaces: number }> { + return Object.freeze({ + hosts: this.hosts.size, + namespaces: [...this.hosts.values()].reduce((count, host) => count + host.namespaceCount, 0) + }) + } + + dispose(): void { + for (const host of this.hosts.values()) { + host.dispose() + } + this.hosts.clear() + } +} + +function requireAuthenticatedHostId(value: string): string { + assertAuthorityId(value, 'authenticatedAuthorityHostId') + return value +} + +function requireAppProcessIncarnationId(value: string): string { + assertAuthorityId(value, 'app process incarnation') + if (!value.startsWith('app-process:')) { + throw new Error('terminal authority app outcome process incarnation is invalid') + } + return value +} diff --git a/src/main/session-authority/terminal-authority-app-outcome-host-transport-slot.test.ts b/src/main/session-authority/terminal-authority-app-outcome-host-transport-slot.test.ts new file mode 100644 index 00000000000..55bd60fd6af --- /dev/null +++ b/src/main/session-authority/terminal-authority-app-outcome-host-transport-slot.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it, vi } from 'vitest' +import type { + TerminalAuthorityAppOutcomeHostConnection, + TerminalAuthorityAppOutcomeHostTransport +} from './terminal-authority-app-outcome-host-contract' +import { TerminalAuthorityAppOutcomeHostTransportSlot } from './terminal-authority-app-outcome-host-transport-slot' + +const HOST = 'authority-host-a' + +describe('TerminalAuthorityAppOutcomeHostTransportSlot', () => { + it('keeps an incumbent source current until the standby is explicitly selected', async () => { + const first = transport('first') + const second = transport('second') + const slot = new TerminalAuthorityAppOutcomeHostTransportSlot(HOST) + const firstLease = slot.install(first.transport) + const onFirstFailure = vi.fn() + const firstConnection = await slot.connect({ onFailure: onFirstFailure }) + + const secondLease = slot.install(second.transport) + + expect(onFirstFailure).not.toHaveBeenCalled() + expect(first.disconnect).not.toHaveBeenCalled() + expect(firstLease.isActive()).toBe(true) + expect(firstLease.isCurrent()).toBe(true) + expect(secondLease.isCurrent()).toBe(false) + + await secondLease.withCurrent(async (binding) => { + const secondConnection = await slot.connect({ onFailure: vi.fn() }) + binding.bindConnectionGeneration() + await expect(secondConnection.resolveNamespace('repo::/workspace')).resolves.toEqual({ + authorityHostId: HOST, + namespaceId: 'second' + }) + }) + + expect(onFirstFailure).toHaveBeenCalledOnce() + expect(first.disconnect).toHaveBeenCalledOnce() + expect(firstLease.isCurrent()).toBe(false) + expect(secondLease.isCurrent()).toBe(true) + + firstConnection.disconnect() + secondLease.dispose() + expect(second.disconnect).toHaveBeenCalledOnce() + }) + + it('requires explicit selection after the current source disconnects', async () => { + const first = transport('first') + const second = transport('second') + const slot = new TerminalAuthorityAppOutcomeHostTransportSlot(HOST) + const firstLease = slot.install(first.transport) + await slot.connect({ onFailure: vi.fn() }) + const secondLease = slot.install(second.transport) + firstLease.dispose() + + expect(secondLease.isActive()).toBe(true) + expect(secondLease.isCurrent()).toBe(false) + await expect(slot.connect({ onFailure: vi.fn() })).rejects.toThrow('unavailable') + await secondLease.withCurrent(async (binding) => { + const selected = await slot.connect({ onFailure: vi.fn() }) + binding.bindConnectionGeneration() + await expect(selected.resolveNamespace('repo::/workspace')).resolves.toEqual({ + authorityHostId: HOST, + namespaceId: 'second' + }) + }) + }) + + it('rejects a pending stale connection and disconnects its late result', async () => { + let resolveFirst!: (connection: TerminalAuthorityAppOutcomeHostConnection) => void + const lateConnection = connection('late') + const first: TerminalAuthorityAppOutcomeHostTransport = { + authenticatedAuthorityHostId: HOST, + connect: () => + new Promise((resolve) => { + resolveFirst = resolve + }) + } + const slot = new TerminalAuthorityAppOutcomeHostTransportSlot(HOST) + const firstLease = slot.install(first) + const pending = slot.connect({ onFailure: vi.fn() }) + + slot.install(transport('current').transport) + firstLease.dispose() + await expect(pending).rejects.toThrow('disconnected') + resolveFirst(lateConnection.value) + await vi.waitFor(() => expect(lateConnection.disconnect).toHaveBeenCalledOnce()) + }) + + it('does not switch sources until every operation on the current source settles', async () => { + const first = transport('first') + const second = transport('second') + const slot = new TerminalAuthorityAppOutcomeHostTransportSlot(HOST) + const firstLease = slot.install(first.transport) + const secondLease = slot.install(second.transport) + let releaseFirst!: () => void + const held = new Promise((resolve) => { + releaseFirst = resolve + }) + const order: string[] = [] + + const firstOperation = firstLease.withCurrent(async (binding) => { + await slot.connect({ onFailure: vi.fn() }) + binding.bindConnectionGeneration() + order.push('first-start') + await held + binding.assertCurrent() + order.push('first-end') + }) + await vi.waitFor(() => expect(order).toEqual(['first-start'])) + const secondOperation = secondLease.withCurrent(async (binding) => { + await slot.connect({ onFailure: vi.fn() }) + binding.bindConnectionGeneration() + order.push('second') + }) + await Promise.resolve() + expect(second.transport.connect).not.toHaveBeenCalled() + + releaseFirst() + await Promise.all([firstOperation, secondOperation]) + expect(order).toEqual(['first-start', 'first-end', 'second']) + }) +}) + +function transport(namespaceId: string) { + const created = connection(namespaceId) + return { + transport: { + authenticatedAuthorityHostId: HOST, + connect: vi.fn(async () => created.value) + } satisfies TerminalAuthorityAppOutcomeHostTransport, + disconnect: created.disconnect + } +} + +function connection(namespaceId: string) { + const disconnect = vi.fn() + return { + value: { + authenticatedAuthorityHostId: HOST, + resolveNamespace: async () => ({ authorityHostId: HOST, namespaceId }), + openNamespace: vi.fn(), + retireNamespace: vi.fn(), + disconnect + } as TerminalAuthorityAppOutcomeHostConnection, + disconnect + } +} diff --git a/src/main/session-authority/terminal-authority-app-outcome-host-transport-slot.ts b/src/main/session-authority/terminal-authority-app-outcome-host-transport-slot.ts new file mode 100644 index 00000000000..d0eed1d2bcb --- /dev/null +++ b/src/main/session-authority/terminal-authority-app-outcome-host-transport-slot.ts @@ -0,0 +1,293 @@ +import { assertAuthorityId } from '../../shared/terminal-session-authority-identity' +import type { + TerminalAuthorityAppOutcomeHostConnection, + TerminalAuthorityAppOutcomeHostTransport +} from './terminal-authority-app-outcome-host-contract' + +type Delegate = { + transport: TerminalAuthorityAppOutcomeHostTransport + connectionGeneration: number +} + +type Attempt = { + delegate: Delegate + connection: TerminalAuthorityAppOutcomeHostConnection | null + rejectReplacement: (error: Error) => void + notifyFailure: (error: unknown) => void + active: boolean +} + +export type TerminalAuthorityAppOutcomeHostTransportLease = Readonly<{ + isActive(): boolean + isCurrent(): boolean + withCurrent(operation: (binding: SourceBinding) => Promise): Promise + dispose(): void +}> + +export type SourceBinding = Readonly<{ + assertCurrent(): void + bindConnectionGeneration(): void +}> + +type SourceOperation = { + delegate: Delegate + run: (binding: SourceBinding) => Promise + resolve: (value: unknown) => void + reject: (error: unknown) => void +} + +export class TerminalAuthorityAppOutcomeHostTransportSlot implements TerminalAuthorityAppOutcomeHostTransport { + readonly authenticatedAuthorityHostId: string + private readonly attempts = new Set() + private readonly delegates = new Set() + private readonly operations: SourceOperation[] = [] + private delegate: Delegate | null = null + private delegateGeneration = 0 + private activeOperations = 0 + + constructor(authorityHostId: string) { + assertAuthorityId(authorityHostId, 'authenticatedAuthorityHostId') + this.authenticatedAuthorityHostId = authorityHostId + } + + install( + transport: TerminalAuthorityAppOutcomeHostTransport + ): TerminalAuthorityAppOutcomeHostTransportLease { + if (transport.authenticatedAuthorityHostId !== this.authenticatedAuthorityHostId) { + throw new Error('terminal authority app outcome host identity changed') + } + const delegate: Delegate = { transport, connectionGeneration: 0 } + this.delegates.add(delegate) + if (!this.delegate && this.activeOperations === 0) { + this.activate(delegate) + } + return Object.freeze({ + isActive: () => this.delegates.has(delegate), + isCurrent: () => this.delegate === delegate, + withCurrent: (operation: (binding: SourceBinding) => Promise) => + this.withDelegate(delegate, operation), + dispose: () => { + if (!this.delegates.delete(delegate)) { + return + } + if (this.delegate === delegate) { + this.delegate = null + this.delegateGeneration += 1 + } + this.invalidate( + delegate, + new Error('terminal authority app outcome host transport disconnected') + ) + this.drainOperations() + } + }) + } + + async connect( + transport: Readonly<{ onFailure(error: unknown): void }> + ): Promise { + const delegate = this.delegate + if (!delegate) { + throw new Error('terminal authority app outcome host transport is unavailable') + } + let rejectReplacement!: (error: Error) => void + const replacement = new Promise((_resolve, reject) => { + rejectReplacement = reject + }) + const attempt: Attempt = { + delegate, + connection: null, + rejectReplacement, + notifyFailure: transport.onFailure, + active: true + } + this.attempts.add(attempt) + const pending = delegate.transport.connect({ + onFailure: (error) => this.failAttempt(attempt, error) + }) + void pending.then( + (connection) => { + if (!attempt.active) { + connection.disconnect() + } + }, + () => undefined + ) + try { + const connection = await Promise.race([pending, replacement]) + if (!attempt.active || this.delegate !== delegate || !this.delegates.has(delegate)) { + connection.disconnect() + throw new Error('terminal authority app outcome host transport was replaced') + } + if (connection.authenticatedAuthorityHostId !== this.authenticatedAuthorityHostId) { + connection.disconnect() + throw new Error('terminal authority app outcome host identity changed') + } + attempt.connection = connection + return this.wrapConnection(attempt, connection) + } catch (error) { + this.releaseAttempt(attempt) + throw error + } + } + + /** Tears down every installed source when the app replaces its proof identity. */ + dispose(): void { + for (const delegate of [...this.delegates]) { + this.delegates.delete(delegate) + if (this.delegate === delegate) { + this.delegate = null + this.delegateGeneration += 1 + } + this.invalidate( + delegate, + new Error('terminal authority app outcome host transport was retired') + ) + } + while (this.operations.length > 0) { + this.operations.shift()!.reject(new Error('terminal authority app outcome host transport was retired')) + } + } + + private wrapConnection( + attempt: Attempt, + connection: TerminalAuthorityAppOutcomeHostConnection + ): TerminalAuthorityAppOutcomeHostConnection { + return Object.freeze({ + authenticatedAuthorityHostId: this.authenticatedAuthorityHostId, + resolveNamespace: (worktreeId) => connection.resolveNamespace(worktreeId), + openNamespace: (request, transport, onOpening) => + connection.openNamespace(request, transport, onOpening), + retireNamespace: (request) => connection.retireNamespace(request), + disconnect: () => { + this.releaseAttempt(attempt) + connection.disconnect() + } + }) + } + + private invalidate(delegate: Delegate, error: Error): void { + for (const attempt of this.attempts) { + if (attempt.delegate === delegate) { + this.failAttempt(attempt, error) + } + } + } + + private failAttempt(attempt: Attempt, error: unknown): void { + if (!attempt.active) { + return + } + attempt.active = false + this.attempts.delete(attempt) + attempt.delegate.connectionGeneration += 1 + attempt.connection?.disconnect() + const normalized = error instanceof Error ? error : new Error(String(error)) + attempt.rejectReplacement(normalized) + attempt.notifyFailure(normalized) + } + + private releaseAttempt(attempt: Attempt): void { + if (!attempt.active) { + return + } + attempt.active = false + this.attempts.delete(attempt) + } + + private withDelegate( + delegate: Delegate, + operation: (binding: SourceBinding) => Promise + ): Promise { + return new Promise((resolve, reject) => { + this.operations.push({ + delegate, + run: operation, + resolve: (value) => resolve(value as T), + reject + }) + this.drainOperations() + }) + } + + private drainOperations(): void { + while (this.operations[0] && !this.delegates.has(this.operations[0].delegate)) { + this.operations + .shift()! + .reject(new Error('terminal authority app outcome host installation is stale')) + } + const next = this.operations[0] + if (!next) { + return + } + if (this.activeOperations > 0 && this.delegate !== next.delegate) { + return + } + if (this.activeOperations === 0 && this.delegate !== next.delegate) { + this.activate(next.delegate) + } + while (this.operations[0]?.delegate === this.delegate) { + this.startOperation(this.operations.shift()!) + } + } + + private startOperation(operation: SourceOperation): void { + const delegate = operation.delegate + const delegateGeneration = this.delegateGeneration + let connectionGeneration = delegate.connectionGeneration + const binding: SourceBinding = Object.freeze({ + assertCurrent: () => { + if ( + !this.delegates.has(delegate) || + this.delegate !== delegate || + this.delegateGeneration !== delegateGeneration || + delegate.connectionGeneration !== connectionGeneration + ) { + throw new Error('terminal authority app outcome source generation is stale') + } + }, + bindConnectionGeneration: () => { + if ( + !this.delegates.has(delegate) || + this.delegate !== delegate || + this.delegateGeneration !== delegateGeneration + ) { + throw new Error('terminal authority app outcome source generation is stale') + } + connectionGeneration = delegate.connectionGeneration + } + }) + this.activeOperations += 1 + void (async () => { + try { + binding.assertCurrent() + const result = await operation.run(binding) + binding.assertCurrent() + operation.resolve(result) + } catch (error) { + operation.reject(error) + } finally { + this.activeOperations -= 1 + this.drainOperations() + } + })() + } + + private activate(delegate: Delegate): void { + if (!this.delegates.has(delegate)) { + throw new Error('terminal authority app outcome host installation is stale') + } + const previous = this.delegate + if (previous === delegate) { + return + } + this.delegate = delegate + this.delegateGeneration += 1 + if (previous) { + this.invalidate( + previous, + new Error('terminal authority app outcome host transport was replaced') + ) + } + } +} diff --git a/src/main/session-authority/terminal-authority-app-outcome-host.ts b/src/main/session-authority/terminal-authority-app-outcome-host.ts new file mode 100644 index 00000000000..4f86785acf7 --- /dev/null +++ b/src/main/session-authority/terminal-authority-app-outcome-host.ts @@ -0,0 +1,304 @@ +import { isDeepStrictEqual } from 'node:util' +import type { TerminalAuthorityNamespace } from '../../shared/terminal-session-authority-identity' +import type { TerminalAuthorityConsumerRetirementResult } from '../../shared/terminal-session-authority-consumer-retirement' +import { TerminalAuthorityAppOutcomeConnectionGeneration } from './terminal-authority-app-outcome-connection-generation' +import { + reportTerminalAuthorityAppOutcomeError, + resolveTerminalAuthorityAppOutcomeTiming, + type TerminalAuthorityAppConsumerRetirementRequest, + type TerminalAuthorityAppOutcomeHostConnection, + type TerminalAuthorityAppOutcomeHostTransport, + type TerminalAuthorityAppOutcomeManagerOptions, + type TerminalAuthorityAppOutcomeNamespaceBinding, + type TerminalAuthorityAppResolvedNamespaceBinding +} from './terminal-authority-app-outcome-host-contract' +import { TerminalAuthorityAppOutcomeNamespaceSession } from './terminal-authority-app-outcome-namespace-session' +import { terminalAuthorityAppOutcomeNamespaceKey } from './terminal-authority-app-outcome-namespace-state' + +const MAX_APP_AUTHORITY_NAMESPACES_PER_HOST = 4_096 + +type HostGeneration = { + work: TerminalAuthorityAppOutcomeConnectionGeneration + connection: TerminalAuthorityAppOutcomeHostConnection | null + connecting: Promise | null +} + +type NamespaceEntry = { + namespace: TerminalAuthorityNamespace + session: TerminalAuthorityAppOutcomeNamespaceSession + ready: Promise +} + +type RetirementEntry = { + request: TerminalAuthorityAppConsumerRetirementRequest + completion: Promise | null +} + +export class TerminalAuthorityAppOutcomeHost { + private readonly timing + private readonly namespaces = new Map() + private readonly knownNamespaces = new Set() + private readonly retirements = new Map() + private state: HostGeneration + private nextGeneration = 0 + private active = true + + constructor( + private readonly hostId: string, + private readonly transport: TerminalAuthorityAppOutcomeHostTransport, + private readonly processIncarnationId: string, + private readonly options: TerminalAuthorityAppOutcomeManagerOptions, + private readonly isInstalled: () => boolean + ) { + this.timing = resolveTerminalAuthorityAppOutcomeTiming(options) + this.state = this.createGeneration() + } + + get namespaceCount(): number { + return this.namespaces.size + } + + knownNamespaceIds(): readonly string[] { + return [...this.knownNamespaces] + } + + async admitNamespace(namespace: TerminalAuthorityNamespace): Promise { + await this.bindNamespace(namespace) + } + + async bindNamespace( + namespace: TerminalAuthorityNamespace + ): Promise { + return this.captureNamespaceBinding(this.ensureNamespace(namespace)) + } + + private async captureNamespaceBinding( + entry: NamespaceEntry + ): Promise { + await entry.ready + this.assertActive() + return await entry.session.captureBinding() + } + + async resolveAndAdmitNamespace(worktreeId: string): Promise { + return (await this.resolveAndBindNamespace(worktreeId)).namespace + } + + async resolveAndBindNamespace( + worktreeId: string + ): Promise { + this.assertActive() + if (typeof worktreeId !== 'string' || worktreeId.length === 0) { + throw new Error('terminal authority app outcome worktree identity is invalid') + } + const namespace = await (await this.connection()).resolveNamespace(worktreeId) + this.assertNamespaceHost(namespace) + const binding = await this.bindNamespace(namespace) + return Object.freeze({ namespace, binding }) + } + + async retireNamespace( + request: TerminalAuthorityAppConsumerRetirementRequest + ): Promise { + this.assertActive() + this.assertNamespaceHost(request.namespace) + const key = terminalAuthorityAppOutcomeNamespaceKey(request.namespace) + const pending = this.retirements.get(key) + if (pending) { + if (!isDeepStrictEqual(pending.request, request)) { + throw new Error('terminal authority app outcome namespace retirement request changed') + } + return await this.completeRetirement(key, pending) + } + const existing = this.namespaces.get(key) + if (existing) { + await existing.session.waitUntilReady() + this.assertActive() + existing.session.beginRetirement() + } + const entry: RetirementEntry = { + request: Object.freeze({ ...request, namespace: Object.freeze({ ...request.namespace }) }), + completion: null + } + this.retirements.set(key, entry) + return await this.completeRetirement(key, entry) + } + + dispose(): void { + if (!this.active) { + return + } + this.active = false + for (const entry of this.namespaces.values()) { + entry.session.dispose() + } + this.namespaces.clear() + this.knownNamespaces.clear() + this.retirements.clear() + this.cancelGeneration(this.state) + } + + private ensureNamespace(namespace: TerminalAuthorityNamespace): NamespaceEntry { + this.assertActive() + this.assertNamespaceHost(namespace) + const key = terminalAuthorityAppOutcomeNamespaceKey(namespace) + if (this.retirements.has(key)) { + throw new Error('terminal authority app outcome namespace retirement is pending') + } + const existing = this.namespaces.get(key) + if (existing) { + return existing + } + if (this.namespaces.size >= MAX_APP_AUTHORITY_NAMESPACES_PER_HOST) { + throw new Error('terminal authority app outcome namespace capacity exceeded') + } + const session = new TerminalAuthorityAppOutcomeNamespaceSession({ + ...this.options, + processIncarnationId: this.processIncarnationId, + namespace: Object.freeze({ ...namespace }), + hostConnection: () => this.connection() + }) + const entry: NamespaceEntry = { + namespace: Object.freeze({ ...namespace }), + session, + ready: session.start() + } + this.knownNamespaces.add(namespace.namespaceId) + this.namespaces.set(key, entry) + void entry.ready.catch(() => { + if (this.namespaces.get(key) === entry) { + this.namespaces.delete(key) + session.dispose() + } + }) + return entry + } + + private connection(): Promise { + this.assertActive() + const state = this.state + if (state.connection) { + return Promise.resolve(state.connection) + } + state.connecting ??= this.connect(state) + return state.connecting + } + + private async connect(state: HostGeneration): Promise { + try { + const pending = this.transport.connect({ + onFailure: (error) => this.failHost(state, error) + }) + void pending.then( + (connection) => { + if (!this.isCurrent(state)) { + connection.disconnect() + } + }, + () => undefined + ) + const connection = await state.work.settle( + pending, + this.timing.connectTimeoutMs, + 'host connection' + ) + this.assertCurrent(state) + if (connection.authenticatedAuthorityHostId !== this.hostId) { + connection.disconnect() + throw new Error('terminal authority app outcome host identity changed') + } + state.connection = connection + return connection + } catch (error) { + this.failHost(state, error) + throw error + } + } + + private failHost(state: HostGeneration, error: unknown): void { + if (!this.isCurrent(state)) { + return + } + reportTerminalAuthorityAppOutcomeError(this.options, error) + this.state = this.createGeneration() + this.cancelGeneration(state) + for (const [key, entry] of this.namespaces) { + if (this.retirements.has(key)) { + entry.session.dispose() + } else { + entry.ready = entry.session.reconnectHost() + void entry.ready.catch(() => undefined) + } + } + } + + private completeRetirement( + key: string, + entry: RetirementEntry + ): Promise { + if (entry.completion) { + return entry.completion + } + const completion = this.sendRetirement(key, entry) + entry.completion = completion + void completion + .finally(() => { + if (this.retirements.get(key) === entry && entry.completion === completion) { + entry.completion = null + } + }) + .catch(() => undefined) + return completion + } + + private async sendRetirement( + key: string, + entry: RetirementEntry + ): Promise { + const result = await (await this.connection()).retireNamespace(entry.request) + if (this.retirements.get(key) === entry) { + this.retirements.delete(key) + const namespace = this.namespaces.get(key) + this.namespaces.delete(key) + namespace?.session.dispose() + } + return result + } + + private createGeneration(): HostGeneration { + return { + work: new TerminalAuthorityAppOutcomeConnectionGeneration(++this.nextGeneration), + connection: null, + connecting: null + } + } + + private cancelGeneration(state: HostGeneration): void { + state.work.cancel() + state.connection?.disconnect() + state.connection = null + state.connecting = null + } + + private assertActive(): void { + if (!this.active || !this.isInstalled()) { + throw new Error('terminal authority app outcome host is stale') + } + } + + private assertCurrent(state: HostGeneration): void { + if (!this.isCurrent(state)) { + throw new Error('terminal authority app outcome host generation is stale') + } + } + + private isCurrent(state: HostGeneration): boolean { + return this.active && this.isInstalled() && this.state.work.id === state.work.id + } + + private assertNamespaceHost(namespace: TerminalAuthorityNamespace): void { + if (namespace.authorityHostId !== this.hostId) { + throw new Error('terminal authority app outcome namespace targets another host') + } + } +} diff --git a/src/main/session-authority/terminal-authority-app-outcome-namespace-connection.ts b/src/main/session-authority/terminal-authority-app-outcome-namespace-connection.ts new file mode 100644 index 00000000000..8b8e7dbb21a --- /dev/null +++ b/src/main/session-authority/terminal-authority-app-outcome-namespace-connection.ts @@ -0,0 +1,205 @@ +import { + parseTerminalAuthorityNamespaceAdmissionGrant, + type TerminalAuthorityNamespaceAdmissionIntent +} from '../../shared/terminal-session-authority-consumer-proof' +import type { TerminalAuthorityNamespace } from '../../shared/terminal-session-authority-identity' +import { sameTerminalAuthorityPolicyConsumer } from '../../shared/terminal-session-authority-consumer-transport' +import { + TerminalAuthorityAppAdmissionIntentRequiredError, + TerminalAuthorityAppAdmissionRejectedError, + type TerminalAuthorityAppNamespaceAdmissionRequest, + type TerminalAuthorityAppOutcomeHostConnection, + type TerminalAuthorityAppOutcomeNamespaceConnection +} from './terminal-authority-app-outcome-host-contract' +import type { TerminalAuthorityAppNamespaceGeneration } from './terminal-authority-app-outcome-namespace-state' +import type { TerminalAuthorityPolicyOutcomeTransport } from './terminal-session-authority-policy-consumers' + +export type TerminalAuthorityAppAdmissionAttempt = { + readonly host: TerminalAuthorityAppOutcomeHostConnection + request: TerminalAuthorityAppNamespaceAdmissionRequest + opening: Promise | null + openingTimedOut: boolean + isCurrent: () => boolean +} + +type TerminalAuthorityAppNamespaceConnectionOptions = Readonly<{ + admission: TerminalAuthorityAppAdmissionAttempt + state: TerminalAuthorityAppNamespaceGeneration + timeoutMs: number + transport: TerminalAuthorityPolicyOutcomeTransport + isCurrent: () => boolean + createId: () => string +}> + +export function createTerminalAuthorityAppAdmissionAttempt( + host: TerminalAuthorityAppOutcomeHostConnection, + processIncarnationId: string, + namespace: TerminalAuthorityNamespace, + createId: () => string, + intent: TerminalAuthorityNamespaceAdmissionIntent = 'resume' +): TerminalAuthorityAppAdmissionAttempt { + return { + host, + request: admissionRequest(processIncarnationId, namespace, createId, intent), + opening: null, + openingTimedOut: false, + isCurrent: () => false + } +} + +export async function connectTerminalAuthorityAppOutcomeNamespace( + options: TerminalAuthorityAppNamespaceConnectionOptions +): Promise { + const attemptedIntents = new Set() + while (true) { + attemptedIntents.add(options.admission.request.intent) + try { + await openTerminalAuthorityAppOutcomeNamespace(options) + return + } catch (error) { + if ( + error instanceof TerminalAuthorityAppAdmissionIntentRequiredError && + !attemptedIntents.has(error.requiredIntent) + ) { + options.admission.request = admissionRequest( + options.admission.request.candidateProcessIncarnationId, + options.admission.request.namespace, + options.createId, + error.requiredIntent + ) + options.admission.opening = null + options.admission.openingTimedOut = false + continue + } + throw error + } + } +} + +async function openTerminalAuthorityAppOutcomeNamespace( + options: TerminalAuthorityAppNamespaceConnectionOptions +): Promise { + options.admission.isCurrent = options.isCurrent + let pending = options.admission.opening + if (!pending) { + pending = options.admission.host.openNamespace( + options.admission.request, + options.transport, + (connection) => { + if (!options.isCurrent()) { + connection.disconnect() + return + } + options.state.connection = connection + } + ) + options.admission.opening = pending + options.admission.openingTimedOut = false + observePendingConnection(options.admission, pending) + } + let connection: TerminalAuthorityAppOutcomeNamespaceConnection + try { + connection = options.admission.openingTimedOut + ? await options.state.work.waitForPending(pending) + : await options.state.work.settle(pending, options.timeoutMs, 'connection') + } catch (error) { + if (options.admission.opening === pending) { + options.admission.openingTimedOut = true + } + throw error + } + if (options.admission.opening === pending) { + options.admission.opening = null + options.admission.openingTimedOut = false + } + try { + assertCurrent(options) + } catch (error) { + connection.disconnect() + throw error + } + assertConnection(connection, options.admission.request) + if (options.state.connection && options.state.connection !== connection) { + connection.disconnect() + throw new Error('terminal authority app outcome opening connection changed') + } + options.state.connection = connection + await options.state.work.settle( + Promise.resolve(connection.activate?.()), + options.timeoutMs, + 'activation' + ) + assertCurrent(options) +} + +function observePendingConnection( + admission: TerminalAuthorityAppAdmissionAttempt, + pending: Promise +): void { + void pending.then( + (connection) => { + if (!admission.isCurrent()) { + connection.disconnect() + clearPendingConnection(admission, pending) + } + }, + () => clearPendingConnection(admission, pending) + ) +} + +function clearPendingConnection( + admission: TerminalAuthorityAppAdmissionAttempt, + pending: Promise +): void { + if (admission.opening === pending) { + admission.opening = null + admission.openingTimedOut = false + } +} + +function admissionRequest( + processIncarnationId: string, + namespace: TerminalAuthorityNamespace, + createId: () => string, + intent: TerminalAuthorityNamespaceAdmissionIntent +): TerminalAuthorityAppNamespaceAdmissionRequest { + return Object.freeze({ + namespace: Object.freeze({ ...namespace }), + candidateProcessIncarnationId: processIncarnationId, + candidateSessionNonce: `app-session:${createId()}`, + requestId: `app-request:${createId()}`, + intent + }) +} + +function assertConnection( + connection: TerminalAuthorityAppOutcomeNamespaceConnection, + request: TerminalAuthorityAppNamespaceAdmissionRequest +): void { + const grant = parseTerminalAuthorityNamespaceAdmissionGrant(connection.grant) + if ( + !grant || + !sameTerminalAuthorityPolicyConsumer(grant.consumer, connection.expectedConsumer) || + grant.consumer.consumerIncarnationId !== request.candidateProcessIncarnationId || + !sameNamespace(grant.namespace, request.namespace) || + grant.requestId !== request.requestId + ) { + connection.disconnect() + throw new TerminalAuthorityAppAdmissionRejectedError( + 'terminal authority app outcome host returned the wrong admission grant' + ) + } +} + +function sameNamespace( + left: TerminalAuthorityNamespace | undefined, + right: TerminalAuthorityNamespace +): boolean { + return left?.authorityHostId === right.authorityHostId && left.namespaceId === right.namespaceId +} + +function assertCurrent(options: TerminalAuthorityAppNamespaceConnectionOptions): void { + if (!options.isCurrent()) { + throw new Error('terminal authority app outcome callback is stale') + } +} diff --git a/src/main/session-authority/terminal-authority-app-outcome-namespace-publication.ts b/src/main/session-authority/terminal-authority-app-outcome-namespace-publication.ts new file mode 100644 index 00000000000..dda5eb54eda --- /dev/null +++ b/src/main/session-authority/terminal-authority-app-outcome-namespace-publication.ts @@ -0,0 +1,110 @@ +import { + parseTerminalAuthorityNamespaceOutcomePublication, + type TerminalAuthorityNamespaceOutcomeBoundary, + type TerminalAuthorityNamespaceOutcomePublication +} from '../../shared/terminal-session-authority-consumer-transport' +import { + commitTerminalAuthorityAppOutcomeBoundary, + requireTerminalAuthorityAppOutcomeBoundary +} from './terminal-authority-app-outcome-boundary' +import type { + TerminalAuthorityAppOutcomeNamespaceSessionOptions, + TerminalAuthorityAppOutcomeTiming +} from './terminal-authority-app-outcome-host-contract' +import { + assertTerminalAuthorityAppOutcomeNamespace, + requireTerminalAuthorityAppOutcomeConnection, + type TerminalAuthorityAppNamespaceGeneration +} from './terminal-authority-app-outcome-namespace-state' +import { applyTerminalAuthorityAppOutcomePublication } from './terminal-authority-app-outcome-publication' + +type PublicationContext = Readonly<{ + state: TerminalAuthorityAppNamespaceGeneration + options: TerminalAuthorityAppOutcomeNamespaceSessionOptions + timing: TerminalAuthorityAppOutcomeTiming + assertCurrent: (state: TerminalAuthorityAppNamespaceGeneration) => void + isCurrent: (state: TerminalAuthorityAppNamespaceGeneration) => boolean + handleGenerationFailure: (state: TerminalAuthorityAppNamespaceGeneration, error: unknown) => void +}> + +export function publishTerminalAuthorityAppOutcomeBoundary( + context: PublicationContext, + unsafeBoundary: TerminalAuthorityNamespaceOutcomeBoundary +): Promise { + const { state, options, timing } = context + let boundary: ReturnType + try { + boundary = requireTerminalAuthorityAppOutcomeBoundary(unsafeBoundary) + context.assertCurrent(state) + assertTerminalAuthorityAppOutcomeNamespace(options.namespace, boundary.namespace) + } catch (error) { + if (context.isCurrent(state)) { + state.admissionFailure = error instanceof Error ? error : new Error(String(error)) + } + return Promise.reject(error) + } + try { + return state.work.enqueue('namespace', async () => { + let acceptance: ReturnType + try { + context.assertCurrent(state) + acceptance = commitTerminalAuthorityAppOutcomeBoundary({ + state, + boundary, + identity: requireTerminalAuthorityAppOutcomeConnection(state).expectedConsumer, + pump: options + }) + } catch (error) { + state.admissionFailure = error instanceof Error ? error : new Error(String(error)) + context.handleGenerationFailure(state, error) + throw error + } + try { + const connection = requireTerminalAuthorityAppOutcomeConnection(state) + await state.work.settle( + connection.acceptBoundary(acceptance), + timing.acknowledgeTimeoutMs, + 'boundary acceptance' + ) + context.assertCurrent(state) + } catch (error) { + context.handleGenerationFailure(state, error) + throw error + } + }) + } catch (error) { + context.handleGenerationFailure(state, error) + return Promise.reject(error) + } +} + +export function publishTerminalAuthorityAppOutcome( + context: PublicationContext, + unsafePublication: TerminalAuthorityNamespaceOutcomePublication +): Promise { + const { state, options, timing } = context + const publication = parseTerminalAuthorityNamespaceOutcomePublication(unsafePublication) + if (!publication || !context.isCurrent(state)) { + return Promise.reject(new Error('terminal authority app outcome publication is invalid')) + } + try { + assertTerminalAuthorityAppOutcomeNamespace(options.namespace, publication.namespace) + return state.work.enqueue('namespace', async () => { + try { + await applyTerminalAuthorityAppOutcomePublication( + state, + publication, + options, + timing.acknowledgeTimeoutMs, + () => context.assertCurrent(state) + ) + } catch (error) { + context.handleGenerationFailure(state, error) + throw error + } + }) + } catch (error) { + context.handleGenerationFailure(state, error) + return Promise.reject(error) + } +} diff --git a/src/main/session-authority/terminal-authority-app-outcome-namespace-session.test.ts b/src/main/session-authority/terminal-authority-app-outcome-namespace-session.test.ts new file mode 100644 index 00000000000..18424ac11c2 --- /dev/null +++ b/src/main/session-authority/terminal-authority-app-outcome-namespace-session.test.ts @@ -0,0 +1,851 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { + TerminalAuthorityNamespaceBoundaryAcceptance, + TerminalAuthorityNamespaceOutcomeAck, + TerminalAuthorityNamespaceOutcomeBoundary +} from '../../shared/terminal-session-authority-consumer-transport' +import type { TerminalAuthorityNamespaceAdmissionGrant } from '../../shared/terminal-session-authority-consumer-proof' +import type { TerminalAuthorityNamespace } from '../../shared/terminal-session-authority-identity' +import type { TerminalAuthorityPolicyOutcomeTransport } from './terminal-session-authority-policy-consumers' +import { + APP_CONSUMER, + NAMESPACE, + NEXT_APP_CONSUMER, + authorityProjection, + boundary, + semanticPublication +} from './__tests__/terminal-authority-app-projection-fixture' +import { + TerminalAuthorityAppOutcomeHostManager, + type TerminalAuthorityAppOutcomeHostRegistration +} from './terminal-authority-app-outcome-host-manager' +import type { + TerminalAuthorityAppNamespaceAdmissionRequest, + TerminalAuthorityAppOutcomeHostTransport, + TerminalAuthorityAppOutcomeManagerOptions +} from './terminal-authority-app-outcome-host-contract' +import { TerminalAuthorityAppProjectionStore } from './terminal-authority-app-projection-store' +import { terminalSessionAuthorityBoundaryId } from '../../shared/terminal-session-authority-boundary-identity' + +const directories: string[] = [] + +type LegacyPumpOptions = TerminalAuthorityAppOutcomeManagerOptions & + Readonly<{ host: TerminalAuthorityAppOutcomeHostTransport }> + +class TerminalAuthorityAppOutcomeNamespaceHarness { + private manager: TerminalAuthorityAppOutcomeHostManager | null = null + private registration: TerminalAuthorityAppOutcomeHostRegistration | null = null + + constructor(private readonly options: LegacyPumpOptions) {} + + async start( + _identity: typeof APP_CONSUMER, + options: Readonly<{ namespace?: TerminalAuthorityNamespace }> = {} + ): Promise { + const { host, ...managerOptions } = this.options + this.manager = new TerminalAuthorityAppOutcomeHostManager( + APP_CONSUMER.consumerIncarnationId, + managerOptions + ) + this.registration = this.manager.installHost(host) + await this.registration.admitNamespace(options.namespace ?? NAMESPACE) + } + + admitNamespace(namespace: TerminalAuthorityNamespace): Promise { + if (!this.registration) { + throw new Error('test app outcome pump is unavailable') + } + return this.registration.admitNamespace(namespace) + } + + dispose(): void { + this.manager?.dispose() + this.manager = null + this.registration = null + } +} + +afterEach(async () => { + await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true }))) +}) + +describe('TerminalAuthorityAppOutcomeNamespaceSession', () => { + it('accepts the exact boundary only after its SQLite projection commits', async () => { + const store = await memoryStore() + const order: string[] = [] + const beginBoundary = store.beginBoundary.bind(store) + vi.spyOn(store, 'beginBoundary').mockImplementation((value) => { + const change = beginBoundary(value) + order.push('commit') + return change + }) + const host = hostHarness([boundary(7)], undefined, { + acceptBoundary: async () => { + order.push('accept') + } + }) + const pump = new TerminalAuthorityAppOutcomeNamespaceHarness({ + store, + host: host.transport, + onProjection: () => order.push('observe') + }) + + await pump.start(APP_CONSUMER) + + expect(host.connect.mock.calls[0]?.[0]).not.toHaveProperty('consumer') + expect(order).toEqual(['commit', 'observe', 'accept']) + expect(host.acceptBoundary).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + consumer: APP_CONSUMER, + acknowledgedSequence: 7, + outcomeHighWatermark: 7, + boundaryId: expect.stringMatching(/^authority-boundary:/) + }) + ) + pump.dispose() + store.close() + }) + + it('replays a committed boundary after the host accepted but its response was lost', async () => { + const store = await memoryStore() + let attempts = 0 + const host = hostHarness([boundary(5)], undefined, { + acceptBoundary: async () => { + attempts += 1 + if (attempts === 1) { + throw new Error('boundary acceptance response lost') + } + } + }) + const pump = new TerminalAuthorityAppOutcomeNamespaceHarness({ + store, + host: host.transport, + onProjection: () => {}, + reconnectBaseDelayMs: 1, + reconnectMaxDelayMs: 1 + }) + + await pump.start(APP_CONSUMER) + + expect(host.connect).toHaveBeenCalledTimes(2) + expect(host.acceptBoundary).toHaveBeenCalledTimes(2) + expect(store.statistics()).toEqual({ rows: 1, writeTransactions: 1, writtenRows: 1 }) + pump.dispose() + store.close() + }) + + it('rejects a projection tampered under an authenticated boundary identity', async () => { + const store = await memoryStore() + const authenticated = boundary(0) + const host = hostHarness( + [{ ...authenticated, projection: authorityProjection({ revision: 2 }) }], + undefined, + { preserveBoundaryId: true } + ) + const pump = new TerminalAuthorityAppOutcomeNamespaceHarness({ + store, + host: host.transport, + onProjection: () => {} + }) + + await expect(pump.start(APP_CONSUMER)).rejects.toThrow('boundary identity is invalid') + + expect(host.acceptBoundary).not.toHaveBeenCalled() + expect(store.snapshot(APP_CONSUMER.consumerId)).toEqual([]) + store.close() + }) + + it('commits before cumulative host ACK and ignores an unavailable renderer', async () => { + const store = await memoryStore() + const order: string[] = [] + const apply = store.apply.bind(store) + vi.spyOn(store, 'apply').mockImplementation((publication) => { + const result = apply(publication) + order.push('commit') + return result + }) + const host = hostHarness([boundary(0)], async () => { + order.push('ack') + return 1 + }) + const pump = new TerminalAuthorityAppOutcomeNamespaceHarness({ + store, + host: host.transport, + onProjection: () => { + order.push('observe') + throw new Error('renderer unavailable') + } + }) + await pump.start(APP_CONSUMER) + + await host.publish(semanticPublication(1)) + + expect(order).toEqual(['observe', 'commit', 'observe', 'ack']) + expect(store.snapshot(APP_CONSUMER.consumerId)[0]?.facts.bell).toBeDefined() + pump.dispose() + store.close() + }) + + it('resnapshots and replays a lost ACK as a durable no-op', async () => { + const store = await memoryStore() + let cursor = 0 + let attemptedAck = false + const projections = vi.fn() + const publication = semanticPublication(1) + const host = hostHarness( + () => [ + boundary(cursor, { + outcomeHighWatermark: attemptedAck ? 1 : cursor, + projection: authorityProjection({ + materializedOutcomes: attemptedAck ? [publication.outcome] : [] + }) + }) + ], + async (ack) => { + if (!attemptedAck) { + attemptedAck = true + throw new Error('ACK lost') + } + cursor = ack.sequence + return cursor + } + ) + const pump = new TerminalAuthorityAppOutcomeNamespaceHarness({ + store, + host: host.transport, + onProjection: projections + }) + await pump.start(APP_CONSUMER) + + await expect(host.publish(publication)).rejects.toThrow('ACK lost') + await vi.waitFor(() => expect(host.acceptBoundary).toHaveBeenCalledTimes(2)) + await host.publish(publication) + + expect(cursor).toBe(1) + expect(projections).toHaveBeenCalledTimes(2) + expect(store.statistics()).toEqual({ rows: 1, writeTransactions: 2, writtenRows: 2 }) + pump.dispose() + store.close() + }) + + it('holds a gap without ACK and reconnects for an authenticated boundary', async () => { + const store = await memoryStore() + const host = hostHarness([boundary(0)]) + const pump = new TerminalAuthorityAppOutcomeNamespaceHarness({ + store, + host: host.transport, + onProjection: () => {} + }) + await pump.start(APP_CONSUMER) + + await expect(host.publish(semanticPublication(2))).rejects.toThrow('cursor gap') + await vi.waitFor(() => expect(host.connect).toHaveBeenCalledTimes(2)) + + expect(host.acknowledge).not.toHaveBeenCalled() + expect(store.snapshot(APP_CONSUMER.consumerId)[0]?.facts).toEqual({}) + pump.dispose() + store.close() + }) + + it('rejects a wrong consumer incarnation before projection or ACK', async () => { + const store = await memoryStore() + const host = hostHarness([boundary(0)]) + const pump = new TerminalAuthorityAppOutcomeNamespaceHarness({ + store, + host: host.transport, + onProjection: () => {} + }) + await pump.start(APP_CONSUMER) + + await expect( + host.publish(semanticPublication(1, { kind: 'bell' }, { consumer: NEXT_APP_CONSUMER })) + ).rejects.toThrow('consumer incarnation changed') + + expect(host.acknowledge).not.toHaveBeenCalled() + expect(store.snapshot(APP_CONSUMER.consumerId)[0]?.facts).toEqual({}) + pump.dispose() + store.close() + }) + + it.each([ + { + label: 'process incarnation', + grant: (value: TerminalAuthorityNamespaceAdmissionGrant) => + Object.freeze({ ...value, consumer: NEXT_APP_CONSUMER }) + }, + { + label: 'namespace', + grant: (value: TerminalAuthorityNamespaceAdmissionGrant) => + Object.freeze({ + ...value, + namespace: Object.freeze({ ...value.namespace, namespaceId: 'other-namespace' }) + }) + } + ])('rejects a host response with the wrong $label', async ({ grant }) => { + const store = await memoryStore() + const host = hostHarness([boundary(0)], undefined, { grant }) + const pump = new TerminalAuthorityAppOutcomeNamespaceHarness({ + store, + host: host.transport, + onProjection: () => {} + }) + + await expect(pump.start(APP_CONSUMER)).rejects.toThrow('wrong admission grant') + + expect(host.acceptBoundary).not.toHaveBeenCalled() + expect(host.acknowledge).not.toHaveBeenCalled() + expect(store.snapshotAll()).toEqual([]) + store.close() + }) + + it('serializes each namespace while independent namespaces continue', async () => { + const store = await memoryStore() + const firstAck = deferred() + const applied: string[] = [] + const host = hostHarness( + [ + boundary(0, { + namespaceId: 'namespace-a', + projection: authorityProjection({ namespaceId: 'namespace-a' }) + }), + boundary(0, { + namespaceId: 'namespace-b', + projection: authorityProjection({ namespaceId: 'namespace-b' }) + }) + ], + (ack) => + ack.namespace.namespaceId === 'namespace-a' && ack.sequence === 1 + ? firstAck.promise + : Promise.resolve(ack.sequence) + ) + const pump = new TerminalAuthorityAppOutcomeNamespaceHarness({ + store, + host: host.transport, + onProjection: (change) => { + const row = change.rows[0] + if (row?.latestEvent) { + applied.push(`${row.namespace.namespaceId}:${row.latestEvent.sequence}`) + } + } + }) + await pump.start(APP_CONSUMER, { + namespace: { ...NAMESPACE, namespaceId: 'namespace-a' } + }) + await pump.admitNamespace({ ...NAMESPACE, namespaceId: 'namespace-b' }) + + const first = host.publish( + semanticPublication(1, { kind: 'bell' }, { namespaceId: 'namespace-a' }) + ) + const second = host.publish( + semanticPublication(2, { kind: 'agent-working' }, { namespaceId: 'namespace-a' }) + ) + await host.publish(semanticPublication(1, { kind: 'bell' }, { namespaceId: 'namespace-b' })) + + expect(applied).toContain('namespace-a:1') + expect(applied).toContain('namespace-b:1') + expect(applied).not.toContain('namespace-a:2') + firstAck.resolve(1) + await Promise.all([first, second]) + expect(applied.at(-1)).toBe('namespace-a:2') + pump.dispose() + store.close() + }) + + it('commits and cumulatively ACKs a bounded page once', async () => { + const store = await memoryStore() + const host = hostHarness([boundary(0)]) + const pump = new TerminalAuthorityAppOutcomeNamespaceHarness({ + store, + host: host.transport, + onProjection: () => {} + }) + await pump.start(APP_CONSUMER) + const outcomes = Array.from( + { length: 64 }, + (_, index) => + semanticPublication(index + 1, { + kind: 'title', + normalizedTitle: `title-${index + 1}`, + rawTitle: `title-${index + 1}` + }).outcome + ) + + await host.publish({ + ...semanticPublication(1), + outcome: outcomes[0]!, + outcomes + }) + + expect(host.acknowledge).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ sequence: 64, outcomeId: 'semantic-namespace-1-64' }) + ) + expect(store.statistics()).toEqual({ rows: 1, writeTransactions: 2, writtenRows: 2 }) + pump.dispose() + store.close() + }) + + it('rejects a page whose compatibility head conflicts with its first canonical outcome', async () => { + const store = await memoryStore() + const host = hostHarness([boundary(0)]) + const pump = new TerminalAuthorityAppOutcomeNamespaceHarness({ + store, + host: host.transport, + onProjection: () => {} + }) + await pump.start(APP_CONSUMER) + const canonical = semanticPublication(1, { + kind: 'title', + normalizedTitle: 'canonical', + rawTitle: 'canonical' + }) + + await expect( + host.publish({ + ...canonical, + outcome: semanticPublication(1, { kind: 'bell' }).outcome, + outcomes: [canonical.outcome] + }) + ).rejects.toThrow('publication is invalid') + + expect(host.acknowledge).not.toHaveBeenCalled() + expect(store.snapshot(APP_CONSUMER.consumerId)[0]?.facts).toEqual({}) + pump.dispose() + store.close() + }) + + it('replays against the acknowledged projection before reconciling the high-water snapshot', async () => { + const store = await memoryStore() + store.beginBoundary(boundary(0)) + const original = authorityProjection().panes[0]! + const replacementBinding = { + ownerIncarnationId: 'owner-2', + physicalPtyId: 'pty-2', + ptyIncarnationId: 'pty-incarnation-2' + } + const finalProjection = authorityProjection({ + revision: 2, + panes: [ + { + ...original, + binding: replacementBinding, + lastBinding: replacementBinding, + revision: 2 + } + ] + }) + const host = hostHarness([ + boundary(0, { + outcomeHighWatermark: 1, + projection: finalProjection, + consumerStart: 'resume' + }) + ]) + const pump = new TerminalAuthorityAppOutcomeNamespaceHarness({ + store, + host: host.transport, + onProjection: () => {} + }) + + await pump.start(APP_CONSUMER) + await host.publish(semanticPublication(1)) + + expect(host.acknowledge).toHaveBeenCalledWith(expect.objectContaining({ sequence: 1 })) + expect(store.snapshot(APP_CONSUMER.consumerId)[0]).toMatchObject({ + binding: replacementBinding, + facts: {} + }) + pump.dispose() + store.close() + }) + + it('initializes a newly attested namespace at a nonzero host tail', async () => { + const store = await memoryStore() + const host = hostHarness([boundary(9, { consumerStart: 'new-at-tail' })]) + const pump = new TerminalAuthorityAppOutcomeNamespaceHarness({ + store, + host: host.transport, + onProjection: () => {} + }) + + await pump.start(APP_CONSUMER) + + expect(store.snapshot(APP_CONSUMER.consumerId)).toHaveLength(1) + expect(host.acknowledge).not.toHaveBeenCalled() + pump.dispose() + store.close() + }) + + it('rejects an unattested initial boundary without retrying local admission', async () => { + const store = await memoryStore() + const { consumerStart: _consumerStart, ...unattested } = boundary(0) + const host = hostHarness([unattested]) + const pump = new TerminalAuthorityAppOutcomeNamespaceHarness({ + store, + host: host.transport, + onProjection: () => {} + }) + + await expect(pump.start(APP_CONSUMER)).rejects.toThrow('boundary is invalid') + + expect(host.connect).toHaveBeenCalledTimes(1) + store.close() + }) + + it('rejects an initial boundary without the complete host semantic projection', async () => { + const store = await memoryStore() + const complete = authorityProjection() + const { materializedOutcomes: _materializedOutcomes, ...incomplete } = complete + const host = hostHarness([boundary(0, { projection: incomplete })]) + const pump = new TerminalAuthorityAppOutcomeNamespaceHarness({ + store, + host: host.transport, + onProjection: () => {} + }) + + await expect(pump.start(APP_CONSUMER)).rejects.toThrow('boundary is invalid') + + expect(host.connect).toHaveBeenCalledTimes(1) + store.close() + }) + + it('fences a never-settling old ACK before connecting and draining a successor', async () => { + const store = await memoryStore() + const blockedAck = deferred() + let cursor = 0 + const host = hostHarness( + () => [boundary(cursor)], + async (ack) => { + if (host.connect.mock.calls.length === 1) { + return blockedAck.promise + } + cursor = ack.sequence + return cursor + } + ) + const pump = new TerminalAuthorityAppOutcomeNamespaceHarness({ + store, + host: host.transport, + onProjection: () => {}, + reconnectBaseDelayMs: 1, + reconnectMaxDelayMs: 1 + }) + await pump.start(APP_CONSUMER) + + const stale = host.publish(semanticPublication(1)) + await vi.waitFor(() => expect(host.acknowledge).toHaveBeenCalledTimes(1)) + host.fail(new Error('connection lost')) + await expect(stale).rejects.toThrow('generation was canceled') + await vi.waitFor(() => expect(host.connect).toHaveBeenCalledTimes(2)) + await host.publish(semanticPublication(1)) + + expect(cursor).toBe(1) + pump.dispose() + store.close() + }) + + it('retries a failed reconnect with bounded backoff until the host recovers', async () => { + const store = await memoryStore() + const host = hostHarness([boundary(0)], undefined, { failedConnectAttempts: [2] }) + const failures = vi.fn() + const pump = new TerminalAuthorityAppOutcomeNamespaceHarness({ + store, + host: host.transport, + onProjection: () => {}, + onError: failures, + reconnectBaseDelayMs: 1, + reconnectMaxDelayMs: 1 + }) + await pump.start(APP_CONSUMER) + + host.fail(new Error('connection lost')) + await vi.waitFor(() => expect(host.connect).toHaveBeenCalledTimes(3)) + await host.publish(semanticPublication(1)) + + expect(failures).toHaveBeenCalledWith(expect.objectContaining({ message: 'reconnect failed' })) + pump.dispose() + store.close() + }) + + it('backs off when the host transport generation is unavailable', async () => { + const store = await memoryStore() + const host = hostHarness([boundary(0)], undefined, { failedHostConnectAttempts: [1, 2] }) + const pump = new TerminalAuthorityAppOutcomeNamespaceHarness({ + store, + host: host.transport, + onProjection: () => {}, + reconnectBaseDelayMs: 5, + reconnectMaxDelayMs: 5 + }) + + await pump.start(APP_CONSUMER) + + expect(host.connectHost).toHaveBeenCalledTimes(3) + pump.dispose() + store.close() + }) + + it('bounds queued work per namespace and reconnects without retaining the old queue', async () => { + const store = await memoryStore() + const blockedAck = deferred() + const host = hostHarness([boundary(0)], () => blockedAck.promise) + const pump = new TerminalAuthorityAppOutcomeNamespaceHarness({ + store, + host: host.transport, + onProjection: () => {}, + reconnectBaseDelayMs: 1, + reconnectMaxDelayMs: 1 + }) + await pump.start(APP_CONSUMER) + + const publications = Array.from({ length: 5 }, (_, index) => + host.publish(semanticPublication(index + 1)) + ) + await expect(publications[4]).rejects.toThrow('queue capacity exceeded') + await Promise.allSettled(publications.slice(0, 4)) + await vi.waitFor(() => expect(host.connect).toHaveBeenCalledTimes(2)) + + expect(store.statistics().rows).toBe(1) + pump.dispose() + store.close() + }) + + it('bounds ACK settlement and reconnects without treating timeout as ACK', async () => { + const store = await memoryStore() + const host = hostHarness([boundary(0)], () => new Promise(() => {})) + const pump = new TerminalAuthorityAppOutcomeNamespaceHarness({ + store, + host: host.transport, + onProjection: () => {}, + acknowledgeTimeoutMs: 5, + reconnectBaseDelayMs: 1, + reconnectMaxDelayMs: 1 + }) + await pump.start(APP_CONSUMER) + + await expect(host.publish(semanticPublication(1))).rejects.toThrow('settlement timed out') + await vi.waitFor(() => expect(host.connect).toHaveBeenCalledTimes(2)) + pump.dispose() + store.close() + }) + + it('retries the exact proof after lost admission responses', async () => { + const store = await memoryStore() + const host = hostHarness([boundary(0)], undefined, { failedConnectAttempts: [1, 2] }) + const failures = vi.fn() + const pump = new TerminalAuthorityAppOutcomeNamespaceHarness({ + store, + host: host.transport, + onProjection: () => {}, + onError: failures, + reconnectBaseDelayMs: 1, + reconnectMaxDelayMs: 1 + }) + + await pump.start(APP_CONSUMER) + + expect(host.connect).toHaveBeenCalledTimes(3) + expect(host.connect.mock.calls[1]?.[0]).toEqual(host.connect.mock.calls[0]?.[0]) + expect(host.connect.mock.calls[2]?.[0]).toEqual(host.connect.mock.calls[0]?.[0]) + expect(failures).toHaveBeenCalledTimes(2) + pump.dispose() + store.close() + }) + + it('retains one unresolved exact admission request across settlement timeouts', async () => { + const store = await memoryStore() + const connectNamespace = vi.fn(() => new Promise(() => {})) + const pump = new TerminalAuthorityAppOutcomeNamespaceHarness({ + store, + host: { + authenticatedAuthorityHostId: NAMESPACE.authorityHostId, + connect: async () => ({ + authenticatedAuthorityHostId: NAMESPACE.authorityHostId, + resolveNamespace: async () => NAMESPACE, + openNamespace: connectNamespace, + retireNamespace: vi.fn(), + disconnect: () => {} + }) + }, + onProjection: () => {}, + connectTimeoutMs: 5, + reconnectBaseDelayMs: 1, + reconnectMaxDelayMs: 1 + }) + + const starting = pump.start(APP_CONSUMER) + await vi.waitFor(() => expect(connectNamespace).toHaveBeenCalledOnce()) + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(connectNamespace).toHaveBeenCalledOnce() + pump.dispose() + await expect(starting).rejects.toThrow('canceled') + store.close() + }) + + it('fails closed for a returning consumer with a missing projection database', async () => { + const store = await memoryStore() + const host = hostHarness([boundary(9, { consumerStart: 'resume' })]) + const pump = new TerminalAuthorityAppOutcomeNamespaceHarness({ + store, + host: host.transport, + onProjection: () => {} + }) + + await expect(pump.start(APP_CONSUMER)).rejects.toThrow('history is unavailable') + expect(host.acknowledge).not.toHaveBeenCalled() + store.close() + }) +}) + +function hostHarness( + boundaries: + | readonly TerminalAuthorityNamespaceOutcomeBoundary[] + | (() => readonly TerminalAuthorityNamespaceOutcomeBoundary[]), + acknowledgeResult: + | ((ack: TerminalAuthorityNamespaceOutcomeAck) => Promise) + | undefined = (ack) => Promise.resolve(ack.sequence), + options: Readonly<{ + failedConnectAttempts?: readonly number[] + failedHostConnectAttempts?: readonly number[] + acceptBoundary?: (acceptance: TerminalAuthorityNamespaceBoundaryAcceptance) => Promise + preserveBoundaryId?: boolean + grant?: ( + value: TerminalAuthorityNamespaceAdmissionGrant + ) => TerminalAuthorityNamespaceAdmissionGrant + }> = {} +) { + const sinks = new Map() + let failConnectedHost: ((error: unknown) => void) | null = null + let attempt = 0 + const claimedNamespaces = new Set() + const acknowledge = vi.fn(acknowledgeResult) + const acceptBoundary = vi.fn(async (acceptance: TerminalAuthorityNamespaceBoundaryAcceptance) => { + claimedNamespaces.add(namespaceKey(acceptance.namespace)) + await options.acceptBoundary?.(acceptance) + }) + const connect = vi.fn( + async ( + request: TerminalAuthorityAppNamespaceAdmissionRequest, + transport: TerminalAuthorityPolicyOutcomeTransport + ) => { + attempt += 1 + if (options.failedConnectAttempts?.includes(attempt)) { + throw new Error('reconnect failed') + } + const namespace = request.namespace + const consumer = Object.freeze({ + consumerId: APP_CONSUMER.consumerId, + consumerIncarnationId: request.candidateProcessIncarnationId + }) + const key = namespaceKey(namespace) + sinks.set(key, transport) + const grant = Object.freeze({ + version: 1 as const, + consumer, + namespace, + requestId: request.requestId, + connectionGrantId: `connection-grant:${request.requestId}`, + admissionCas: 'admission-cas-after', + replayed: attempt > 1 + }) + return { + expectedConsumer: consumer, + grant: options.grant?.(grant) ?? grant, + activate: vi.fn(async () => { + for (const candidate of typeof boundaries === 'function' ? boundaries() : boundaries) { + if (namespaceKey(candidate.namespace) !== key) { + continue + } + const { boundaryId: _boundaryId, ...unsignedCandidate } = candidate + const unsigned = { + ...unsignedCandidate, + consumer, + consumerStart: claimedNamespaces.has(namespaceKey(candidate.namespace)) + ? ('resume' as const) + : candidate.consumerStart + } + await transport.publishBoundary({ + ...unsigned, + boundaryId: + options.preserveBoundaryId && candidate.boundaryId + ? candidate.boundaryId + : terminalSessionAuthorityBoundaryId(unsigned) + }) + } + }), + acceptBoundary, + acknowledge, + retire: vi.fn(), + disconnect: vi.fn(() => { + if (sinks.get(key) === transport) { + sinks.delete(key) + } + }) + } + } + ) + const connectHost = vi.fn(async (lifecycle: Readonly<{ onFailure(error: unknown): void }>) => { + if (options.failedHostConnectAttempts?.includes(connectHost.mock.calls.length)) { + throw new Error('host transport unavailable') + } + failConnectedHost = lifecycle.onFailure + return { + authenticatedAuthorityHostId: NAMESPACE.authorityHostId, + resolveNamespace: async () => NAMESPACE, + openNamespace: connect, + retireNamespace: vi.fn(), + disconnect: vi.fn() + } + }) + const transport: TerminalAuthorityAppOutcomeHostTransport = { + authenticatedAuthorityHostId: NAMESPACE.authorityHostId, + connect: connectHost + } + return { + transport, + connect, + connectHost, + acceptBoundary, + acknowledge, + publish(publication: Parameters[0]) { + const sink = sinks.get(namespaceKey(publication.namespace)) + if (!sink) { + throw new Error('host sink unavailable') + } + return sink.publishOutcome(publication) + }, + fail(error: Error, namespace: TerminalAuthorityNamespace = NAMESPACE) { + const sink = sinks.get(namespaceKey(namespace)) + if (!sink) { + throw new Error('host sink unavailable') + } + sink.onFailure?.(error) + }, + failHost(error: Error) { + if (!failConnectedHost) { + throw new Error('host connection unavailable') + } + failConnectedHost(error) + } + } +} + +function namespaceKey(namespace: { authorityHostId: string; namespaceId: string }): string { + return JSON.stringify([namespace.authorityHostId, namespace.namespaceId]) +} + +async function memoryStore(): Promise { + const directory = await mkdtemp(path.join(tmpdir(), 'orca-app-pump-')) + directories.push(directory) + return TerminalAuthorityAppProjectionStore.open({ directory, databasePath: ':memory:' }) +} + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((settle) => { + resolve = settle + }) + return { promise, resolve } +} diff --git a/src/main/session-authority/terminal-authority-app-outcome-namespace-session.ts b/src/main/session-authority/terminal-authority-app-outcome-namespace-session.ts new file mode 100644 index 00000000000..f416655219e --- /dev/null +++ b/src/main/session-authority/terminal-authority-app-outcome-namespace-session.ts @@ -0,0 +1,293 @@ +import { randomUUID } from 'node:crypto' +import { + connectTerminalAuthorityAppOutcomeNamespace, + createTerminalAuthorityAppAdmissionAttempt, + type TerminalAuthorityAppAdmissionAttempt +} from './terminal-authority-app-outcome-namespace-connection' +import { + TerminalAuthorityAppAdmissionRejectedError, + reportTerminalAuthorityAppOutcomeError, + resolveTerminalAuthorityAppOutcomeTiming, + terminalAuthorityAppReconnectDelay, + type TerminalAuthorityAppOutcomeNamespaceBinding, + type TerminalAuthorityAppOutcomeNamespaceSessionOptions, + type TerminalAuthorityAppOutcomeTiming +} from './terminal-authority-app-outcome-host-contract' +import { + cancelTerminalAuthorityAppNamespaceGeneration, + createTerminalAuthorityAppNamespaceGeneration, + requireTerminalAuthorityAppNamespaceGeneration, + type TerminalAuthorityAppNamespaceGeneration +} from './terminal-authority-app-outcome-namespace-state' +import { + publishTerminalAuthorityAppOutcome, + publishTerminalAuthorityAppOutcomeBoundary +} from './terminal-authority-app-outcome-namespace-publication' + +export class TerminalAuthorityAppOutcomeNamespaceSession { + private readonly timing: TerminalAuthorityAppOutcomeTiming + private state: TerminalAuthorityAppNamespaceGeneration | null = null + private nextGeneration = 0 + private reconnectTask: Promise | null = null + private admission: TerminalAuthorityAppAdmissionAttempt | null = null + private active = false + private ready = false + private retiring = false + + constructor(private readonly options: TerminalAuthorityAppOutcomeNamespaceSessionOptions) { + this.timing = resolveTerminalAuthorityAppOutcomeTiming(options) + } + + async start(): Promise { + if (this.active) { + throw new Error('terminal authority app outcome namespace is already started') + } + this.active = true + this.replaceGeneration() + try { + await this.reconnectUntilConnected(true) + const state = requireTerminalAuthorityAppNamespaceGeneration(this.state) + this.markReady(state) + this.ready = true + } catch (error) { + this.dispose() + throw error + } + } + + reconnectHost(): Promise { + if (!this.active) { + return Promise.reject(new Error('terminal authority app outcome namespace is unavailable')) + } + if (this.retiring) { + this.dispose() + return Promise.reject(new Error('terminal authority app outcome namespace is retiring')) + } + this.replaceGeneration() + if (this.ready) { + this.ensureReconnectLoop() + } + return this.waitUntilReady() + } + + async waitUntilReady(): Promise { + while (this.active) { + const state = requireTerminalAuthorityAppNamespaceGeneration(this.state) + if (state.connection) { + return + } + const settled = await state.ready + if (this.state !== state) { + continue + } + if (settled && state.connection) { + return + } + } + throw new Error('terminal authority app outcome namespace is unavailable') + } + + async captureBinding(): Promise { + await this.waitUntilReady() + const state = requireTerminalAuthorityAppNamespaceGeneration(this.state) + const connection = state.connection + if (!connection) { + throw new Error('terminal authority app outcome namespace is unavailable') + } + this.assertCurrent(state) + return Object.freeze({ + assertCurrent: () => { + this.assertCurrent(state) + if (state.connection !== connection) { + throw new Error('terminal authority app outcome namespace grant is stale') + } + } + }) + } + + dispose(): void { + if (!this.active) { + return + } + this.active = false + this.ready = false + this.admission = null + this.cancelCurrentGeneration() + } + + beginRetirement(): void { + if (!this.active || !this.ready) { + throw new Error('terminal authority app outcome namespace is not ready for retirement') + } + this.retiring = true + } + + private async connect(state: TerminalAuthorityAppNamespaceGeneration): Promise { + const host = await this.options.hostConnection() + this.assertCurrent(state) + const createId = this.options.createAdmissionId ?? randomUUID + if (!this.admission || this.admission.host !== host) { + this.admission = createTerminalAuthorityAppAdmissionAttempt( + host, + this.options.processIncarnationId, + this.options.namespace, + createId + ) + } + try { + await connectTerminalAuthorityAppOutcomeNamespace({ + admission: this.admission, + state, + timeoutMs: this.timing.connectTimeoutMs, + transport: Object.freeze({ + publishBoundary: (boundary) => this.publishBoundary(state, boundary), + publishOutcome: (publication) => this.publishOutcome(state, publication), + onFailure: (error) => this.handleGenerationFailure(state, error) + }), + isCurrent: () => this.isCurrent(state), + createId + }) + this.admission = null + } catch (error) { + if (error instanceof TerminalAuthorityAppAdmissionRejectedError) { + this.admission = null + state.admissionFailure = error + } + throw error + } + } + + private publishBoundary( + state: TerminalAuthorityAppNamespaceGeneration, + unsafeBoundary: Parameters[1] + ): Promise { + return publishTerminalAuthorityAppOutcomeBoundary( + this.publicationContext(state), + unsafeBoundary + ) + } + + private publishOutcome( + state: TerminalAuthorityAppNamespaceGeneration, + unsafePublication: Parameters[1] + ): Promise { + return publishTerminalAuthorityAppOutcome(this.publicationContext(state), unsafePublication) + } + + private publicationContext(state: TerminalAuthorityAppNamespaceGeneration) { + return { + state, + options: this.options, + timing: this.timing, + assertCurrent: (current: TerminalAuthorityAppNamespaceGeneration) => + this.assertCurrent(current), + isCurrent: (current: TerminalAuthorityAppNamespaceGeneration) => this.isCurrent(current), + handleGenerationFailure: (current: TerminalAuthorityAppNamespaceGeneration, error: unknown) => + this.handleGenerationFailure(current, error) + } + } + + private handleGenerationFailure( + state: TerminalAuthorityAppNamespaceGeneration, + error: unknown + ): void { + if (!this.isCurrent(state)) { + return + } + reportTerminalAuthorityAppOutcomeError(this.options, error) + if (this.retiring) { + this.dispose() + return + } + if (!this.ready) { + this.replaceGeneration() + return + } + this.replaceGeneration() + this.ensureReconnectLoop() + } + + private ensureReconnectLoop(): Promise { + if (this.reconnectTask) { + return this.reconnectTask + } + const task = this.reconnectUntilConnected(false) + this.reconnectTask = task + void task.finally(() => { + if (this.reconnectTask !== task) { + return + } + this.reconnectTask = null + if (this.active && this.ready && !this.retiring && !this.state?.connection) { + this.ensureReconnectLoop() + } + }) + return task + } + + private async reconnectUntilConnected(initial: boolean): Promise { + let attempt = 0 + while (this.active && !this.retiring && (initial || this.ready)) { + const state = requireTerminalAuthorityAppNamespaceGeneration(this.state) + try { + await this.connect(state) + this.markReady(state) + return + } catch (error) { + if (initial && state.admissionFailure) { + throw state.admissionFailure + } + if (!this.active || (!initial && !this.ready)) { + if (initial) { + throw error + } + return + } + if (!this.isCurrent(state)) { + const current = this.state + if (!current) { + return + } + const delay = terminalAuthorityAppReconnectDelay(this.timing, attempt) + attempt += 1 + await current.work.waitBeforeReconnect(delay) + continue + } + reportTerminalAuthorityAppOutcomeError(this.options, error) + const waiting = this.replaceGeneration() + const delay = terminalAuthorityAppReconnectDelay(this.timing, attempt) + attempt += 1 + await waiting.work.waitBeforeReconnect(delay) + } + } + } + + private replaceGeneration(): TerminalAuthorityAppNamespaceGeneration { + this.cancelCurrentGeneration() + const state = createTerminalAuthorityAppNamespaceGeneration(++this.nextGeneration) + this.state = state + return state + } + + private markReady(state: TerminalAuthorityAppNamespaceGeneration): void { + if (this.state === state) { + state.resolveReady(true) + } + } + + private cancelCurrentGeneration(): void { + const state = this.state + this.state = null + cancelTerminalAuthorityAppNamespaceGeneration(state) + } + + private assertCurrent(state: TerminalAuthorityAppNamespaceGeneration): void { + if (!this.isCurrent(state)) { + throw new Error('terminal authority app outcome callback is stale') + } + } + + private isCurrent(state: TerminalAuthorityAppNamespaceGeneration): boolean { + return this.active && this.state?.work.id === state.work.id + } +} diff --git a/src/main/session-authority/terminal-authority-app-outcome-namespace-state.ts b/src/main/session-authority/terminal-authority-app-outcome-namespace-state.ts new file mode 100644 index 00000000000..60c24d54b28 --- /dev/null +++ b/src/main/session-authority/terminal-authority-app-outcome-namespace-state.ts @@ -0,0 +1,112 @@ +import { + sameTerminalAuthorityPolicyConsumer, + type TerminalAuthorityNamespaceOutcomeBoundary, + type TerminalAuthorityPolicyConsumerIdentity +} from '../../shared/terminal-session-authority-consumer-transport' +import type { TerminalAuthorityNamespace } from '../../shared/terminal-session-authority-identity' +import { TerminalAuthorityAppOutcomeConnectionGeneration } from './terminal-authority-app-outcome-connection-generation' +import type { TerminalAuthorityAppOutcomeNamespaceConnection } from './terminal-authority-app-outcome-host-contract' + +export type TerminalAuthorityAppNamespaceBoundary = { + value: TerminalAuthorityNamespaceOutcomeBoundary + previousSequence: number + snapshotCommitted: boolean +} + +export type TerminalAuthorityAppCompleteBoundary = TerminalAuthorityNamespaceOutcomeBoundary & { + boundaryId: string + consumerStart: NonNullable + projection: NonNullable +} + +export type TerminalAuthorityAppNamespaceGeneration = { + work: TerminalAuthorityAppOutcomeConnectionGeneration + boundary: TerminalAuthorityAppNamespaceBoundary | null + connection: TerminalAuthorityAppOutcomeNamespaceConnection | null + admissionFailure: Error | null + ready: Promise + resolveReady: (ready: boolean) => void +} + +export function terminalAuthorityAppOutcomeNamespaceKey(value: { + authorityHostId: string + namespaceId: string +}): string { + return JSON.stringify([value.authorityHostId, value.namespaceId]) +} + +export function isTerminalAuthorityAppCompleteBoundary( + boundary: TerminalAuthorityNamespaceOutcomeBoundary | null +): boundary is TerminalAuthorityAppCompleteBoundary { + return Boolean( + boundary?.boundaryId && boundary.consumerStart && boundary.projection?.materializedOutcomes + ) +} + +export function createTerminalAuthorityAppNamespaceGeneration( + id: number +): TerminalAuthorityAppNamespaceGeneration { + let resolveReady!: (ready: boolean) => void + const ready = new Promise((resolve) => { + resolveReady = resolve + }) + return { + work: new TerminalAuthorityAppOutcomeConnectionGeneration(id), + boundary: null, + connection: null, + admissionFailure: null, + ready, + resolveReady + } +} + +export function cancelTerminalAuthorityAppNamespaceGeneration( + state: TerminalAuthorityAppNamespaceGeneration | null +): void { + if (!state) { + return + } + state.work.cancel() + state.resolveReady(false) + state.connection?.disconnect() + state.boundary = null +} + +export function requireTerminalAuthorityAppOutcomeConnection( + state: TerminalAuthorityAppNamespaceGeneration +): TerminalAuthorityAppOutcomeNamespaceConnection { + if (!state.connection) { + throw new Error('terminal authority app outcome connection is unavailable') + } + return state.connection +} + +export function requireTerminalAuthorityAppNamespaceGeneration( + state: TerminalAuthorityAppNamespaceGeneration | null +): TerminalAuthorityAppNamespaceGeneration { + if (!state) { + throw new Error('terminal authority app outcome pump is unavailable') + } + return state +} + +export function assertTerminalAuthorityAppOutcomeConsumer( + expected: TerminalAuthorityPolicyConsumerIdentity, + actual: TerminalAuthorityPolicyConsumerIdentity +): void { + if (!sameTerminalAuthorityPolicyConsumer(expected, actual)) { + throw new Error('terminal authority app outcome consumer incarnation changed') + } +} + +export function assertTerminalAuthorityAppOutcomeNamespace( + expected: TerminalAuthorityNamespace, + actual: TerminalAuthorityNamespace +): void { + if ( + expected.authorityHostId !== actual.authorityHostId || + expected.namespaceId !== actual.namespaceId + ) { + throw new Error('terminal authority app outcome namespace changed') + } +} diff --git a/src/main/session-authority/terminal-authority-app-outcome-publication.ts b/src/main/session-authority/terminal-authority-app-outcome-publication.ts new file mode 100644 index 00000000000..2e7c101f5f1 --- /dev/null +++ b/src/main/session-authority/terminal-authority-app-outcome-publication.ts @@ -0,0 +1,63 @@ +import { + TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_VERSION, + type TerminalAuthorityNamespaceOutcomePublication +} from '../../shared/terminal-session-authority-consumer-transport' +import { + observeTerminalAuthorityAppProjection, + type TerminalAuthorityAppOutcomeManagerOptions, + type TerminalAuthorityAppProjectionObservation +} from './terminal-authority-app-outcome-host-contract' +import { + assertTerminalAuthorityAppOutcomeConsumer, + requireTerminalAuthorityAppOutcomeConnection, + type TerminalAuthorityAppNamespaceGeneration +} from './terminal-authority-app-outcome-namespace-state' + +export async function applyTerminalAuthorityAppOutcomePublication( + state: TerminalAuthorityAppNamespaceGeneration, + publication: TerminalAuthorityNamespaceOutcomePublication, + options: TerminalAuthorityAppOutcomeManagerOptions, + acknowledgeTimeoutMs: number, + assertCurrent: () => void +): Promise { + assertCurrent() + const connection = requireTerminalAuthorityAppOutcomeConnection(state) + assertTerminalAuthorityAppOutcomeConsumer(connection.grant.consumer, publication.consumer) + const boundary = state.boundary + if (!boundary || publication.previousSequence !== boundary.previousSequence) { + throw new Error('terminal authority app outcome publication has a cursor gap') + } + const outcomes = publication.outcomes ?? [publication.outcome] + const target = outcomes.at(-1)! + if (!boundary.snapshotCommitted && target.sequence > boundary.value.outcomeHighWatermark) { + throw new Error('terminal authority app outcome publication crossed its snapshot boundary') + } + const applied = options.store.apply(publication) + let reconciled: TerminalAuthorityAppProjectionObservation | null = null + if (!boundary.snapshotCommitted && target.sequence === boundary.value.outcomeHighWatermark) { + assertCurrent() + reconciled = options.store.completeBoundary(boundary.value) + boundary.snapshotCommitted = true + } + observeTerminalAuthorityAppProjection(options, applied) + if (reconciled) { + observeTerminalAuthorityAppProjection(options, reconciled) + } + assertCurrent() + const acknowledged = await state.work.settle( + connection.acknowledge({ + version: TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_VERSION, + consumer: publication.consumer, + namespace: publication.namespace, + sequence: target.sequence, + outcomeId: target.outcomeId + }), + acknowledgeTimeoutMs, + 'ACK' + ) + assertCurrent() + if (acknowledged !== target.sequence) { + throw new Error('terminal authority app outcome ACK changed sequence') + } + boundary.previousSequence = acknowledged +} diff --git a/src/main/session-authority/terminal-authority-app-projection-database.ts b/src/main/session-authority/terminal-authority-app-projection-database.ts new file mode 100644 index 00000000000..7d238b8a77c --- /dev/null +++ b/src/main/session-authority/terminal-authority-app-projection-database.ts @@ -0,0 +1,189 @@ +import type { + TerminalAuthorityAppPaneProjection, + TerminalAuthorityAppProjectionRowIdentity +} from '../../shared/terminal-authority-app-projection' +import type { + TerminalAuthorityNamespace, + TerminalPaneGeneration +} from '../../shared/terminal-session-authority-identity' +import SyncDatabase from '../sqlite/sync-database' +import { + parseTerminalAuthorityAppProjectionDatabaseRow, + serializeTerminalAuthorityAppProjectionRow, + type TerminalAuthorityAppProjectionDatabaseRow +} from './terminal-authority-app-projection-row-storage' + +export class TerminalAuthorityAppProjectionDatabase { + private readonly database: SyncDatabase + + constructor(databasePath: string) { + this.database = new SyncDatabase(databasePath) + this.database.pragma('journal_mode = WAL') + this.database.pragma('synchronous = FULL') + this.database.pragma('busy_timeout = 5000') + this.database.exec(schemaSql()) + } + + begin(): void { + this.database.exec('BEGIN IMMEDIATE') + } + + rollback(): void { + this.database.exec('ROLLBACK') + } + + commit(beforeCommit: () => void): void { + beforeCommit() + this.database.exec('COMMIT') + } + + close(): void { + this.database.pragma('wal_checkpoint(TRUNCATE)') + this.database.close() + } + + rowsForConsumer(consumerId: string): readonly TerminalAuthorityAppPaneProjection[] { + return ( + this.database + .prepare( + `SELECT * FROM terminal_authority_app_projection WHERE consumer_id = ? + ORDER BY authority_host_id, namespace_id, pane_key, pane_generation_id` + ) + .all(consumerId) as TerminalAuthorityAppProjectionDatabaseRow[] + ).map(parseTerminalAuthorityAppProjectionDatabaseRow) + } + + namespaceRows( + consumerId: string, + namespace: TerminalAuthorityNamespace + ): readonly TerminalAuthorityAppPaneProjection[] { + return ( + this.database + .prepare( + `SELECT * FROM terminal_authority_app_projection + WHERE consumer_id = ? AND authority_host_id = ? AND namespace_id = ?` + ) + .all( + consumerId, + namespace.authorityHostId, + namespace.namespaceId + ) as TerminalAuthorityAppProjectionDatabaseRow[] + ).map(parseTerminalAuthorityAppProjectionDatabaseRow) + } + + allRows(): readonly TerminalAuthorityAppPaneProjection[] { + return ( + this.database + .prepare('SELECT * FROM terminal_authority_app_projection') + .all() as TerminalAuthorityAppProjectionDatabaseRow[] + ).map(parseTerminalAuthorityAppProjectionDatabaseRow) + } + + readRow( + consumerId: string, + namespace: TerminalAuthorityNamespace, + pane: TerminalPaneGeneration + ): TerminalAuthorityAppPaneProjection | null { + const row = this.database + .prepare( + `SELECT * FROM terminal_authority_app_projection + WHERE consumer_id = ? AND authority_host_id = ? AND namespace_id = ? + AND pane_key = ? AND pane_generation_id = ?` + ) + .get( + consumerId, + namespace.authorityHostId, + namespace.namespaceId, + pane.paneKey, + pane.paneGenerationId + ) as TerminalAuthorityAppProjectionDatabaseRow | undefined + return row ? parseTerminalAuthorityAppProjectionDatabaseRow(row) : null + } + + writeRow(row: TerminalAuthorityAppPaneProjection): void { + this.database + .prepare( + `INSERT INTO terminal_authority_app_projection ( + consumer_id, authority_host_id, namespace_id, pane_key, pane_generation_id, projection_json + ) VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT (consumer_id, authority_host_id, namespace_id, pane_key, pane_generation_id) + DO UPDATE SET projection_json = excluded.projection_json` + ) + .run( + row.consumerId, + row.namespace.authorityHostId, + row.namespace.namespaceId, + row.pane.paneKey, + row.pane.paneGenerationId, + serializeTerminalAuthorityAppProjectionRow(row) + ) + } + + deleteRow(identity: TerminalAuthorityAppProjectionRowIdentity): void { + this.database + .prepare( + `DELETE FROM terminal_authority_app_projection + WHERE consumer_id = ? AND authority_host_id = ? AND namespace_id = ? + AND pane_key = ? AND pane_generation_id = ?` + ) + .run( + identity.consumerId, + identity.namespace.authorityHostId, + identity.namespace.namespaceId, + identity.pane.paneKey, + identity.pane.paneGenerationId + ) + } + + readInitializationRevision( + consumerId: string, + namespace: TerminalAuthorityNamespace + ): number | null { + const row = this.database + .prepare( + `SELECT authority_revision FROM terminal_authority_app_projection_namespace + WHERE consumer_id = ? AND authority_host_id = ? AND namespace_id = ?` + ) + .get(consumerId, namespace.authorityHostId, namespace.namespaceId) as + | { authority_revision: number } + | undefined + return row?.authority_revision ?? null + } + + writeInitializationRevision( + consumerId: string, + namespace: TerminalAuthorityNamespace, + authorityRevision: number + ): void { + this.database + .prepare( + `INSERT INTO terminal_authority_app_projection_namespace ( + consumer_id, authority_host_id, namespace_id, authority_revision + ) VALUES (?, ?, ?, ?) + ON CONFLICT (consumer_id, authority_host_id, namespace_id) + DO UPDATE SET authority_revision = excluded.authority_revision` + ) + .run(consumerId, namespace.authorityHostId, namespace.namespaceId, authorityRevision) + } + + rowCount(): number { + const row = this.database + .prepare('SELECT COUNT(*) AS count FROM terminal_authority_app_projection') + .get() as { count: number } + return row.count + } +} + +function schemaSql(): string { + return ` + CREATE TABLE IF NOT EXISTS terminal_authority_app_projection ( + consumer_id TEXT NOT NULL, authority_host_id TEXT NOT NULL, namespace_id TEXT NOT NULL, + pane_key TEXT NOT NULL, pane_generation_id TEXT NOT NULL, projection_json TEXT NOT NULL, + PRIMARY KEY (consumer_id, authority_host_id, namespace_id, pane_key, pane_generation_id) + ) WITHOUT ROWID; + CREATE TABLE IF NOT EXISTS terminal_authority_app_projection_namespace ( + consumer_id TEXT NOT NULL, authority_host_id TEXT NOT NULL, namespace_id TEXT NOT NULL, + authority_revision INTEGER NOT NULL, + PRIMARY KEY (consumer_id, authority_host_id, namespace_id) + ) WITHOUT ROWID;` +} diff --git a/src/main/session-authority/terminal-authority-app-projection-event-state.ts b/src/main/session-authority/terminal-authority-app-projection-event-state.ts new file mode 100644 index 00000000000..5739cd29c34 --- /dev/null +++ b/src/main/session-authority/terminal-authority-app-projection-event-state.ts @@ -0,0 +1,81 @@ +import type { + TerminalAuthorityAppEventKey, + TerminalAuthorityAppPaneProjection, + TerminalAuthorityAppTopologyProjection +} from '../../shared/terminal-authority-app-projection' +import type { TerminalAuthorityDurableOutcome } from '../../shared/terminal-session-authority-mutation' + +export function terminalAuthorityAppStatusProjection( + row: TerminalAuthorityAppPaneProjection, + event: TerminalAuthorityAppEventKey, + now: number, + agent = row.agent, + topology: TerminalAuthorityAppTopologyProjection = row.topology, + attention = row.attention +): TerminalAuthorityAppPaneProjection['status'] { + return Object.freeze({ + event, + pane: topology.status, + agent: agent?.state ?? null, + attention: attention.pendingBellCount > 0, + updatedAt: now + }) +} + +export function terminalAuthorityAppEventAlreadyProjected( + row: TerminalAuthorityAppPaneProjection, + event: TerminalAuthorityAppEventKey +): boolean { + const latest = row.latestEvent + if (!latest || latest.sequence < event.sequence) { + return false + } + if (latest.sequence === event.sequence && latest.outcomeId !== event.outcomeId) { + throw new Error('terminal authority app projection event conflicts') + } + return true +} + +export function terminalAuthorityAppProjectionEventKey( + consumerId: string, + outcome: TerminalAuthorityDurableOutcome +): TerminalAuthorityAppEventKey { + return Object.freeze({ + consumerId, + namespace: Object.freeze({ + ...(outcome.kind === 'semantic' ? outcome.access.namespace : outcome.result.namespace) + }), + sequence: outcome.sequence, + outcomeId: outcome.outcomeId + }) +} + +export function sameTerminalAuthorityAppProjectionEvent( + left: TerminalAuthorityAppEventKey | undefined, + right: TerminalAuthorityAppEventKey +): boolean { + return Boolean( + left && + left.consumerId === right.consumerId && + left.namespace.authorityHostId === right.namespace.authorityHostId && + left.namespace.namespaceId === right.namespace.namespaceId && + left.sequence === right.sequence && + left.outcomeId === right.outcomeId + ) +} + +export function latestTerminalAuthorityAppProjectionEvent( + current: TerminalAuthorityAppEventKey | undefined, + authority: TerminalAuthorityAppEventKey | undefined +): TerminalAuthorityAppEventKey | undefined { + if (!current || !authority) { + return current ?? authority + } + if (current.sequence === authority.sequence) { + if (!sameTerminalAuthorityAppProjectionEvent(current, authority)) { + throw new Error('terminal authority app projection event conflicts') + } + return current + } + return current.sequence > authority.sequence ? current : authority +} diff --git a/src/main/session-authority/terminal-authority-app-projection-performance.test.ts b/src/main/session-authority/terminal-authority-app-projection-performance.test.ts new file mode 100644 index 00000000000..6694479302a --- /dev/null +++ b/src/main/session-authority/terminal-authority-app-projection-performance.test.ts @@ -0,0 +1,127 @@ +import { mkdtemp, rm, stat } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { performance } from 'node:perf_hooks' +import { afterAll, describe, expect, it } from 'vitest' +import { + APP_CONSUMER, + boundary, + semanticPublication +} from './__tests__/terminal-authority-app-projection-fixture' +import { + TERMINAL_AUTHORITY_APP_PROJECTION_DATABASE_FILE, + TerminalAuthorityAppProjectionStore +} from './terminal-authority-app-projection-store' + +const ACKNOWLEDGED_HISTORY = 25_000 +const PAGE_SIZE = 64 +const PROBE_EVENTS = 256 +const directories: string[] = [] + +afterAll(async () => { + await Promise.all(directories.map((directory) => rm(directory, { recursive: true }))) +}) + +describe('TerminalAuthorityAppProjectionStore scale', () => { + it('keeps event-loop delay, rows touched, and DB/WAL size independent of ACK history', async () => { + const shallow = await storeAfter(1) + const deep = await storeAfter(ACKNOWLEDGED_HISTORY) + + expect(deep.store.statistics()).toEqual({ + rows: 1, + writeTransactions: 1 + Math.ceil(ACKNOWLEDGED_HISTORY / PAGE_SIZE), + writtenRows: 1 + Math.ceil(ACKNOWLEDGED_HISTORY / PAGE_SIZE) + }) + expect(deep.wallTimeMs).toBeLessThan(10_000) + expect(deep.databaseBytes + deep.walBytes).toBeLessThan(2 * 1024 * 1024) + expect(deep.store.snapshot(APP_CONSUMER.consumerId)).toHaveLength(1) + + const shallowDelay = await eventLoopDelayPercentiles(shallow.store, 2) + const deepDelay = await eventLoopDelayPercentiles(deep.store, ACKNOWLEDGED_HISTORY + 1) + const detail = `deep=${JSON.stringify(deepDelay)} shallow=${JSON.stringify(shallowDelay)}` + expect(deepDelay.p50, detail).toBeLessThan(shallowDelay.p50 * 6 + 25) + expect(deepDelay.p95, detail).toBeLessThan(shallowDelay.p95 * 6 + 25) + shallow.store.close() + deep.store.close() + }, 30_000) +}) + +async function storeAfter(count: number): Promise<{ + store: TerminalAuthorityAppProjectionStore + wallTimeMs: number + databaseBytes: number + walBytes: number +}> { + const directory = await mkdtemp(path.join(tmpdir(), 'orca-app-projection-perf-')) + directories.push(directory) + const store = await TerminalAuthorityAppProjectionStore.open({ directory }) + store.beginBoundary(boundary(0)) + const startedAt = performance.now() + for (let first = 1; first <= count; first += PAGE_SIZE) { + store.apply(titlePage(first, Math.min(PAGE_SIZE, count - first + 1))) + } + const wallTimeMs = performance.now() - startedAt + const databasePath = path.join(directory, TERMINAL_AUTHORITY_APP_PROJECTION_DATABASE_FILE) + return { + store, + wallTimeMs, + databaseBytes: await fileSize(databasePath), + walBytes: await fileSize(`${databasePath}-wal`) + } +} + +async function eventLoopDelayPercentiles( + store: TerminalAuthorityAppProjectionStore, + firstSequence: number +): Promise> { + const samples: number[] = [] + for (let sample = 0; sample < 7; sample += 1) { + const startedAt = performance.now() + const timer = new Promise((resolve) => { + setTimeout(() => resolve(performance.now() - startedAt), 0) + }) + for (let offset = 0; offset < PROBE_EVENTS; offset += PAGE_SIZE) { + const first = firstSequence + sample * PROBE_EVENTS + offset + store.apply(titlePage(first, PAGE_SIZE)) + } + samples.push(await timer) + } + samples.sort((left, right) => left - right) + return Object.freeze({ + p50: percentile(samples, 0.5), + p95: percentile(samples, 0.95) + }) +} + +function percentile(sorted: readonly number[], fraction: number): number { + return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * fraction) - 1)]! +} + +function titlePage(first: number, count: number) { + const outcomes = Array.from( + { length: count }, + (_, offset) => + semanticPublication(first + offset, { + kind: 'title', + normalizedTitle: `title-${first + offset}`, + rawTitle: `title-${first + offset}` + }).outcome + ) + return Object.freeze({ + ...semanticPublication(first), + previousSequence: first - 1, + outcome: outcomes[0]!, + outcomes: Object.freeze(outcomes) + }) +} + +async function fileSize(file: string): Promise { + try { + return (await stat(file)).size + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return 0 + } + throw error + } +} diff --git a/src/main/session-authority/terminal-authority-app-projection-reducer.ts b/src/main/session-authority/terminal-authority-app-projection-reducer.ts new file mode 100644 index 00000000000..cd61bd8eb46 --- /dev/null +++ b/src/main/session-authority/terminal-authority-app-projection-reducer.ts @@ -0,0 +1,240 @@ +import { isDeepStrictEqual } from 'node:util' +import type { + TerminalAuthorityAppEventKey, + TerminalAuthorityAppPaneProjection, + TerminalAuthorityAppTopologyProjection +} from '../../shared/terminal-authority-app-projection' +import { sameTerminalBinding } from '../../shared/terminal-session-authority-identity' +import type { + TerminalAuthorityDurableOutcome, + TerminalPaneAuthorityRecord, + TerminalSessionAuthorityEffect +} from '../../shared/terminal-session-authority-mutation' +import { parsePaneKey } from '../../shared/stable-pane-id' +import { + terminalAuthorityAppEventAlreadyProjected, + terminalAuthorityAppProjectionEventKey, + terminalAuthorityAppStatusProjection +} from './terminal-authority-app-projection-event-state' +import { reduceTerminalAuthorityAppSemanticProjection } from './terminal-authority-app-semantic-projection' + +export { + TERMINAL_AUTHORITY_COMMAND_CODE_SETTLE_MS, + TERMINAL_AUTHORITY_PR_VERIFY_DELAY_MS, + settleTerminalAuthorityCommandCodeProjections, + terminalAuthorityAppPrVerificationsDue +} from './terminal-authority-app-semantic-projection' + +export type TerminalAuthorityAppProjectionRowLookup = ( + consumerId: string, + outcome: TerminalAuthorityDurableOutcome, + pane: Readonly<{ paneKey: string; paneGenerationId: string }> +) => TerminalAuthorityAppPaneProjection | null + +export function reduceTerminalAuthorityAppProjection( + consumerId: string, + outcome: TerminalAuthorityDurableOutcome, + lookup: TerminalAuthorityAppProjectionRowLookup, + now: number +): readonly TerminalAuthorityAppPaneProjection[] { + return outcome.kind === 'semantic' + ? reduceTerminalAuthorityAppSemanticProjection(consumerId, outcome, lookup, now) + : reduceMutationOutcome(consumerId, outcome, lookup, now) +} + +function reduceMutationOutcome( + consumerId: string, + outcome: Exclude, + lookup: TerminalAuthorityAppProjectionRowLookup, + now: number +): readonly TerminalAuthorityAppPaneProjection[] { + const event = terminalAuthorityAppProjectionEventKey(consumerId, outcome) + const changed: TerminalAuthorityAppPaneProjection[] = [] + const records = [outcome.result.pane, outcome.result.replacementPane].filter( + (record): record is TerminalPaneAuthorityRecord => record !== null + ) + for (const record of records) { + const current = lookup(consumerId, outcome, record) + if (current && terminalAuthorityAppEventAlreadyProjected(current, event)) { + continue + } + const topology = projectTopology(current?.topology, event, record) + const keepsBindingState = current ? sameBindingContext(current.topology, topology) : false + const base = current ?? emptyRow(consumerId, outcome, record, topology, now) + const retained = keepsBindingState ? base : clearBindingState(base, topology, now) + const exitEffect = findExitEffect(outcome.result.effects, topology) + const exit = exitEffect + ? Object.freeze({ + event, + binding: Object.freeze({ ...exitEffect.binding }), + code: exitEffect.code, + signal: exitEffect.signal + }) + : retained.exit + const agent = exitEffect + ? Object.freeze({ + event, + binding: Object.freeze({ ...exitEffect.binding }), + state: 'exited' as const, + prompt: retained.agent?.prompt ?? '', + transitionedAt: now + }) + : retained.agent + const allocation = allocationForRecord(outcome, record, retained) + const candidate = { + ...retained, + binding: topology.binding, + topology, + latestEvent: event, + ...(exit ? { exit } : {}), + ...(agent ? { agent } : {}), + status: terminalAuthorityAppStatusProjection( + retained, + event, + now, + agent, + topology, + retained.attention + ) + } + if (allocation) { + changed.push(Object.freeze({ ...candidate, allocation })) + } else { + const { allocation: _allocation, ...withoutAllocation } = candidate + changed.push(Object.freeze(withoutAllocation)) + } + } + return Object.freeze(changed) +} + +function projectTopology( + current: TerminalAuthorityAppTopologyProjection | undefined, + event: TerminalAuthorityAppEventKey, + record: TerminalPaneAuthorityRecord +): TerminalAuthorityAppTopologyProjection { + const candidate = topologyFromRecord(record, event, current?.ownerStatus ?? null) + if (!current || record.revision > current.authorityRevision) { + return candidate + } + if (record.revision < current.authorityRevision) { + return current + } + const { event: _currentEvent, ...currentRecord } = current + const { event: _candidateEvent, ...candidateRecord } = candidate + if (!isDeepStrictEqual(currentRecord, candidateRecord)) { + throw new Error('terminal authority app projection topology conflicts') + } + return current.event ? current : candidate +} + +export function topologyFromRecord( + record: TerminalPaneAuthorityRecord & { ownerStatus?: 'reachable' | 'owner-unreachable' | null }, + event?: TerminalAuthorityAppEventKey, + fallbackOwnerStatus: 'reachable' | 'owner-unreachable' | null = null +): TerminalAuthorityAppTopologyProjection { + return Object.freeze({ + ...(event ? { event } : {}), + status: record.status, + binding: record.binding ? Object.freeze({ ...record.binding }) : null, + lastBinding: record.lastBinding ? Object.freeze({ ...record.lastBinding }) : null, + authorityRevision: record.revision, + ownerStatus: record.ownerStatus ?? fallbackOwnerStatus + }) +} + +function emptyRow( + consumerId: string, + outcome: TerminalAuthorityDurableOutcome, + pane: Readonly<{ paneKey: string; paneGenerationId: string }>, + topology: TerminalAuthorityAppTopologyProjection, + now: number +): TerminalAuthorityAppPaneProjection { + const parsed = parsePaneKey(pane.paneKey) + return Object.freeze({ + version: 1, + consumerId, + namespace: Object.freeze({ + ...(outcome.kind === 'semantic' ? outcome.access.namespace : outcome.result.namespace) + }), + pane: Object.freeze({ ...pane }), + layout: parsed ? Object.freeze({ tabId: parsed.tabId, leafId: parsed.leafId }) : null, + binding: topology.binding, + topology, + attention: Object.freeze({ event: null, pendingBellCount: 0, updatedAt: now }), + status: Object.freeze({ + event: null, + pane: topology.status, + agent: null, + attention: false, + updatedAt: now + }), + facts: Object.freeze({}) + }) +} + +function clearBindingState( + row: TerminalAuthorityAppPaneProjection, + topology: TerminalAuthorityAppTopologyProjection, + now: number +): TerminalAuthorityAppPaneProjection { + const { + allocation: _allocation, + exit: _exit, + agent: _agent, + commandCode: _commandCode, + ...retained + } = row + return Object.freeze({ + ...retained, + binding: topology.binding, + topology, + attention: Object.freeze({ event: null, pendingBellCount: 0, updatedAt: now }), + status: Object.freeze({ + event: null, + pane: topology.status, + agent: null, + attention: false, + updatedAt: now + }), + facts: Object.freeze({}) + }) +} + +function sameBindingContext( + current: TerminalAuthorityAppTopologyProjection, + next: TerminalAuthorityAppTopologyProjection +): boolean { + const previousBinding = current.binding ?? current.lastBinding + const nextBinding = next.binding ?? next.lastBinding + return Boolean( + previousBinding && nextBinding && sameTerminalBinding(previousBinding, nextBinding) + ) +} + +function findExitEffect( + effects: readonly TerminalSessionAuthorityEffect[], + topology: TerminalAuthorityAppTopologyProjection +): Extract | undefined { + const binding = topology.binding ?? topology.lastBinding + return binding + ? effects.find( + (effect): effect is Extract => + effect.kind === 'terminal-exited' && sameTerminalBinding(effect.binding, binding) + ) + : undefined +} + +function allocationForRecord( + outcome: Exclude, + record: TerminalPaneAuthorityRecord, + current: TerminalAuthorityAppPaneProjection +) { + const allocation = outcome.result.allocation + if ( + allocation?.pane.paneKey === record.paneKey && + allocation.pane.paneGenerationId === record.paneGenerationId + ) { + return Object.freeze(structuredClone(allocation)) + } + return outcome.request.change.kind === 'cancel-allocation' ? undefined : current.allocation +} diff --git a/src/main/session-authority/terminal-authority-app-projection-row-storage.ts b/src/main/session-authority/terminal-authority-app-projection-row-storage.ts new file mode 100644 index 00000000000..4db3a3b1c2d --- /dev/null +++ b/src/main/session-authority/terminal-authority-app-projection-row-storage.ts @@ -0,0 +1,61 @@ +import { + terminalAuthorityAppProjectionRowKey, + type TerminalAuthorityAppPaneProjection +} from '../../shared/terminal-authority-app-projection' +import { + assertTerminalAuthorityAppPaneProjection, + parseTerminalAuthorityAppPaneProjection +} from '../../shared/terminal-authority-app-projection-validation' + +export const TERMINAL_AUTHORITY_APP_PROJECTION_MAX_ROW_BYTES = 64 * 1024 + +export type TerminalAuthorityAppProjectionDatabaseRow = { + consumer_id: string + authority_host_id: string + namespace_id: string + pane_key: string + pane_generation_id: string + projection_json: string +} + +export function parseTerminalAuthorityAppProjectionDatabaseRow( + row: TerminalAuthorityAppProjectionDatabaseRow +): TerminalAuthorityAppPaneProjection { + if ( + Buffer.byteLength(row.projection_json, 'utf8') > TERMINAL_AUTHORITY_APP_PROJECTION_MAX_ROW_BYTES + ) { + throw new Error('terminal authority app projection row capacity exceeded') + } + let value: unknown + try { + value = JSON.parse(row.projection_json) + } catch { + throw new Error('terminal authority app projection row is corrupt') + } + const projection = parseTerminalAuthorityAppPaneProjection(value) + if (!projection || terminalAuthorityAppProjectionRowKey(projection) !== databaseRowKey(row)) { + throw new Error('terminal authority app projection row identity changed') + } + return projection +} + +export function serializeTerminalAuthorityAppProjectionRow( + row: TerminalAuthorityAppPaneProjection +): string { + assertTerminalAuthorityAppPaneProjection(row) + const contents = JSON.stringify(row) + if (Buffer.byteLength(contents, 'utf8') > TERMINAL_AUTHORITY_APP_PROJECTION_MAX_ROW_BYTES) { + throw new Error('terminal authority app projection row capacity exceeded') + } + return contents +} + +function databaseRowKey(row: TerminalAuthorityAppProjectionDatabaseRow): string { + return JSON.stringify([ + row.consumer_id, + row.authority_host_id, + row.namespace_id, + row.pane_key, + row.pane_generation_id + ]) +} diff --git a/src/main/session-authority/terminal-authority-app-projection-snapshot.ts b/src/main/session-authority/terminal-authority-app-projection-snapshot.ts new file mode 100644 index 00000000000..310a5c96258 --- /dev/null +++ b/src/main/session-authority/terminal-authority-app-projection-snapshot.ts @@ -0,0 +1,313 @@ +import { isDeepStrictEqual } from 'node:util' +import { + TERMINAL_AUTHORITY_APP_PROJECTION_VERSION, + terminalAuthorityAppProjectionRowKey, + type TerminalAuthorityAppEventKey, + type TerminalAuthorityAppPaneProjection, + type TerminalAuthorityAppProjectionRowIdentity, + type TerminalAuthorityAppTopologyProjection +} from '../../shared/terminal-authority-app-projection' +import { sameTerminalBinding } from '../../shared/terminal-session-authority-identity' +import type { + TerminalAuthorityDurableOutcome, + TerminalAuthorityProjection, + TerminalPaneAuthorityProjection, + TerminalSessionPtyAllocation +} from '../../shared/terminal-session-authority-mutation' +import { terminalAuthorityMaterializedOutcomeMatchesBinding } from '../../shared/terminal-session-authority-materialized-outcomes' +import { parsePaneKey } from '../../shared/stable-pane-id' +import { + latestTerminalAuthorityAppProjectionEvent, + sameTerminalAuthorityAppProjectionEvent +} from './terminal-authority-app-projection-event-state' +import { + reduceTerminalAuthorityAppProjection, + topologyFromRecord +} from './terminal-authority-app-projection-reducer' + +export type TerminalAuthorityAppSnapshotReconciliation = Readonly<{ + rows: readonly TerminalAuthorityAppPaneProjection[] + deleted: readonly TerminalAuthorityAppProjectionRowIdentity[] +}> + +export function reconcileTerminalAuthorityAppProjectionSnapshot( + consumerId: string, + projection: TerminalAuthorityProjection, + currentRows: readonly TerminalAuthorityAppPaneProjection[], + now: number +): TerminalAuthorityAppSnapshotReconciliation { + const current = new Map( + currentRows.map((row) => [terminalAuthorityAppProjectionRowKey(row), row]) + ) + const allocations = allocationsByPane(projection.allocations) + const projected = new Map() + for (const pane of projection.panes) { + const identity = { consumerId, namespace: projection.namespace, pane } + const key = terminalAuthorityAppProjectionRowKey(identity) + projected.set( + key, + snapshotRow( + consumerId, + projection, + pane, + allocations.get(paneKey(pane)), + current.get(key), + now + ) + ) + } + applyMaterializedOutcomes(consumerId, projection, projected, now) + const rows = [...projected.entries()] + .map(([key, row]) => + mergeLocalProjection(current.get(key), row, projection.materializedOutcomes ?? [], now) + ) + .filter( + (row) => !isDeepStrictEqual(current.get(terminalAuthorityAppProjectionRowKey(row)), row) + ) + const deleted = currentRows + .filter((row) => !projected.has(terminalAuthorityAppProjectionRowKey(row))) + .map(rowIdentity) + return Object.freeze({ rows: Object.freeze(rows), deleted: Object.freeze(deleted) }) +} + +function snapshotRow( + consumerId: string, + projection: TerminalAuthorityProjection, + pane: TerminalPaneAuthorityProjection, + allocation: TerminalSessionPtyAllocation | undefined, + current: TerminalAuthorityAppPaneProjection | undefined, + now: number +): TerminalAuthorityAppPaneProjection { + const candidateTopology = topologyFromRecord(pane) + const topology = current + ? reconcileTopology(current.topology, candidateTopology) + : candidateTopology + return emptySnapshotRow(consumerId, projection, pane, topology, allocation, now) +} + +function applyMaterializedOutcomes( + consumerId: string, + projection: TerminalAuthorityProjection, + rows: Map, + now: number +): void { + for (const outcome of projection.materializedOutcomes ?? []) { + const changed = reduceTerminalAuthorityAppProjection( + consumerId, + outcome, + (_consumerId, _outcome, pane) => + rows.get( + terminalAuthorityAppProjectionRowKey({ + consumerId, + namespace: projection.namespace, + pane + }) + ) ?? null, + now + ) + for (const row of changed) { + const key = terminalAuthorityAppProjectionRowKey(row) + if (!rows.has(key)) { + throw new Error('terminal authority app projection materialized an absent pane') + } + rows.set(key, row) + } + } +} + +function mergeLocalProjection( + current: TerminalAuthorityAppPaneProjection | undefined, + authority: TerminalAuthorityAppPaneProjection, + materializedOutcomes: readonly TerminalAuthorityDurableOutcome[], + now: number +): TerminalAuthorityAppPaneProjection { + if (!current || !sameBindingContext(current.topology, authority.topology)) { + return authority + } + const facts = Object.fromEntries( + Object.entries(authority.facts).map(([kind, field]) => [ + kind, + field + ? preserveEventProjection(current.facts[kind as keyof typeof current.facts], field) + : field + ]) + ) as TerminalAuthorityAppPaneProjection['facts'] + const exit = preserveEventProjection(current.exit, authority.exit) + const agent = preserveEventProjection(current.agent, authority.agent) + const commandCode = preserveEventProjection(current.commandCode, authority.commandCode) + const attention = mergeLocalAttention(current, authority, materializedOutcomes) + const latestEvent = latestTerminalAuthorityAppProjectionEvent( + current.latestEvent, + authority.latestEvent + ) + const statusValues = { + pane: authority.topology.status, + agent: agent?.state ?? null, + attention: attention.pendingBellCount > 0 + } + const status = + sameNullableEvent(current.status.event, authority.status.event) && + current.status.pane === statusValues.pane && + current.status.agent === statusValues.agent && + current.status.attention === statusValues.attention + ? current.status + : Object.freeze({ ...authority.status, ...statusValues, updatedAt: now }) + const { + exit: _exit, + agent: _agent, + commandCode: _commandCode, + latestEvent: _latestEvent, + ...base + } = authority + return Object.freeze({ + ...base, + ...(latestEvent ? { latestEvent } : {}), + facts: Object.freeze(facts), + attention, + status, + ...(exit ? { exit } : {}), + ...(agent ? { agent } : {}), + ...(commandCode ? { commandCode } : {}) + }) +} + +function mergeLocalAttention( + current: TerminalAuthorityAppPaneProjection, + authority: TerminalAuthorityAppPaneProjection, + materializedOutcomes: readonly TerminalAuthorityDurableOutcome[] +): TerminalAuthorityAppPaneProjection['attention'] { + if (sameNullableEvent(current.attention.event, authority.attention.event)) { + return current.attention + } + const clearedThrough = current.attention.event + const binding = authority.binding ?? authority.topology.lastBinding + if (current.attention.pendingBellCount !== 0 || !clearedThrough || !binding) { + return authority.attention + } + const pendingBellCount = materializedOutcomes.filter( + (outcome) => + outcome.kind === 'semantic' && + outcome.fact.kind === 'bell' && + outcome.sequence > clearedThrough.sequence && + terminalAuthorityMaterializedOutcomeMatchesBinding(outcome, authority.pane, binding) + ).length + return Object.freeze({ + event: authority.attention.event, + pendingBellCount, + updatedAt: authority.attention.updatedAt + }) +} + +function preserveEventProjection( + current: T | undefined, + authority: T | undefined +): T | undefined { + return current && + authority && + sameTerminalAuthorityAppProjectionEvent(current.event, authority.event) + ? current + : authority +} + +function sameNullableEvent( + left: TerminalAuthorityAppEventKey | null | undefined, + right: TerminalAuthorityAppEventKey | null | undefined +): boolean { + return left === null || left === undefined || right === null || right === undefined + ? left === right + : sameTerminalAuthorityAppProjectionEvent(left, right) +} + +function reconcileTopology( + current: TerminalAuthorityAppTopologyProjection, + candidate: TerminalAuthorityAppTopologyProjection +): TerminalAuthorityAppTopologyProjection { + if (candidate.authorityRevision < current.authorityRevision) { + throw new Error('terminal authority app projection snapshot regressed') + } + const { event: _currentEvent, ownerStatus: currentOwnerStatus, ...currentDurable } = current + const { + event: _candidateEvent, + ownerStatus: candidateOwnerStatus, + ...candidateDurable + } = candidate + if ( + candidate.authorityRevision === current.authorityRevision && + !isDeepStrictEqual(currentDurable, candidateDurable) + ) { + throw new Error('terminal authority app projection snapshot conflicts') + } + if (isDeepStrictEqual(currentDurable, candidateDurable)) { + return currentOwnerStatus === candidateOwnerStatus + ? current + : Object.freeze({ ...current, ownerStatus: candidateOwnerStatus }) + } + return candidate +} + +function emptySnapshotRow( + consumerId: string, + projection: TerminalAuthorityProjection, + pane: TerminalPaneAuthorityProjection, + topology: TerminalAuthorityAppTopologyProjection, + allocation: TerminalSessionPtyAllocation | undefined, + now: number +): TerminalAuthorityAppPaneProjection { + return Object.freeze({ + version: TERMINAL_AUTHORITY_APP_PROJECTION_VERSION, + consumerId, + namespace: Object.freeze({ ...projection.namespace }), + pane: Object.freeze({ paneKey: pane.paneKey, paneGenerationId: pane.paneGenerationId }), + layout: layoutFor(pane.paneKey), + binding: topology.binding, + topology, + ...(allocation ? { allocation: Object.freeze(structuredClone(allocation)) } : {}), + attention: Object.freeze({ event: null, pendingBellCount: 0, updatedAt: now }), + status: Object.freeze({ + event: null, + pane: topology.status, + agent: null, + attention: false, + updatedAt: now + }), + facts: Object.freeze({}) + }) +} + +function allocationsByPane( + allocations: readonly TerminalSessionPtyAllocation[] +): ReadonlyMap { + const byPane = new Map() + for (const allocation of allocations) { + const key = paneKey(allocation.pane) + if (byPane.has(key)) { + throw new Error('terminal authority app projection snapshot duplicates an allocation') + } + byPane.set(key, allocation) + } + return byPane +} + +function layoutFor(paneKeyValue: string): TerminalAuthorityAppPaneProjection['layout'] { + const parsed = parsePaneKey(paneKeyValue) + return parsed ? Object.freeze({ tabId: parsed.tabId, leafId: parsed.leafId }) : null +} + +function sameBindingContext( + current: TerminalAuthorityAppTopologyProjection, + next: TerminalAuthorityAppTopologyProjection +): boolean { + const before = current.binding ?? current.lastBinding + const after = next.binding ?? next.lastBinding + return Boolean(before && after && sameTerminalBinding(before, after)) +} + +function rowIdentity( + row: TerminalAuthorityAppPaneProjection +): TerminalAuthorityAppProjectionRowIdentity { + return Object.freeze({ consumerId: row.consumerId, namespace: row.namespace, pane: row.pane }) +} + +function paneKey(pane: { paneKey: string; paneGenerationId: string }): string { + return JSON.stringify([pane.paneKey, pane.paneGenerationId]) +} diff --git a/src/main/session-authority/terminal-authority-app-projection-store.test.ts b/src/main/session-authority/terminal-authority-app-projection-store.test.ts new file mode 100644 index 00000000000..9127b79d90c --- /dev/null +++ b/src/main/session-authority/terminal-authority-app-projection-store.test.ts @@ -0,0 +1,485 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + TERMINAL_AUTHORITY_APP_PROJECTION_VERSION, + type TerminalAuthorityAppBellClearRequest +} from '../../shared/terminal-authority-app-projection' +import type { TerminalSessionAuthoritySemanticFact } from '../../shared/terminal-session-authority-mutation' +import { + APP_CONSUMER, + authorityProjection, + boundary, + exitPublication, + semanticPublication +} from './__tests__/terminal-authority-app-projection-fixture' +import { + TERMINAL_AUTHORITY_COMMAND_CODE_SETTLE_MS, + TERMINAL_AUTHORITY_PR_VERIFY_DELAY_MS +} from './terminal-authority-app-projection-reducer' +import { + TERMINAL_AUTHORITY_APP_PROJECTION_DATABASE_FILE, + TerminalAuthorityAppProjectionStore +} from './terminal-authority-app-projection-store' +import SyncDatabase from '../sqlite/sync-database' + +const directories: string[] = [] + +afterEach(async () => { + await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true }))) +}) + +describe('TerminalAuthorityAppProjectionStore', () => { + it('materializes all twelve fact domains with event keys and restartable state', async () => { + let now = 1_000 + const store = await memoryStore(undefined, () => now) + initialize(store) + const facts: readonly TerminalSessionAuthoritySemanticFact[] = [ + { kind: 'agent-status', payload: { state: 'working', prompt: 'ship it' } }, + { kind: 'title', normalizedTitle: 'Build', rawTitle: 'Build' }, + { kind: 'bell' }, + { kind: 'agent-working' }, + { kind: 'agent-idle', title: 'Done' }, + { kind: 'agent-exited' }, + { kind: 'command-finished', exitCode: 0 }, + { + kind: 'pr-link', + link: { + url: 'https://github.com/orca/test/pull/1', + slug: { owner: 'orca', repo: 'test', host: 'github.com' }, + number: 1 + } + }, + { kind: 'command-code-working', prompt: 'work' }, + { kind: 'command-code-done', prompt: 'done' }, + { kind: '2031-subscribe' }, + { kind: '2031-unsubscribe' } + ] + + facts.forEach((fact, index) => { + now += 1 + store.apply(semanticPublication(index + 1, fact)) + }) + + const row = store.snapshot(APP_CONSUMER.consumerId)[0]! + expect(Object.keys(row.facts).sort()).toEqual(facts.map((fact) => fact.kind).sort()) + for (const [index, fact] of facts.entries()) { + expect(row.facts[fact.kind]).toMatchObject({ + event: { sequence: index + 1, outcomeId: `semantic-namespace-1-${index + 1}` }, + fact + }) + } + expect(row).toMatchObject({ + layout: { tabId: 'tab-1', leafId: '11111111-1111-4111-8111-111111111111' }, + attention: { pendingBellCount: 1 }, + commandCode: { + state: 'settling', + settleAt: 1_000 + 10 + TERMINAL_AUTHORITY_COMMAND_CODE_SETTLE_MS + } + }) + expect(store.statistics()).toEqual({ rows: 1, writeTransactions: 13, writtenRows: 13 }) + store.close() + }) + + it('replays commit-before-host-ACK pages as a durable no-op after process restart', async () => { + const directory = await tempDirectory() + const first = await TerminalAuthorityAppProjectionStore.open({ directory }) + initialize(first) + const page = publicationPage(1, 64) + expect(first.apply(page).rows).toHaveLength(1) + expect(first.statistics()).toMatchObject({ writeTransactions: 2, writtenRows: 2 }) + first.close() + + const restarted = await TerminalAuthorityAppProjectionStore.open({ directory }) + expect(restarted.apply(structuredClone(page))).toEqual({ rows: [], deleted: [] }) + expect(restarted.statistics()).toEqual({ rows: 1, writeTransactions: 0, writtenRows: 0 }) + expect(restarted.snapshot(APP_CONSUMER.consumerId)[0]?.facts.title?.event.sequence).toBe(64) + restarted.close() + }) + + it('rejects a conflicting outcome at an already projected namespace sequence', async () => { + const store = await memoryStore() + initialize(store) + store.apply(semanticPublication(1)) + + expect(() => + store.apply(semanticPublication(1, { kind: 'bell' }, { outcomeId: 'conflicting-outcome' })) + ).toThrow('event conflicts') + expect(store.snapshot(APP_CONSUMER.consumerId)[0]?.latestEvent?.outcomeId).toBe( + 'semantic-namespace-1-1' + ) + store.close() + }) + + it('rolls back a crash before commit and applies the event on reopen', async () => { + const directory = await tempDirectory() + const initialized = await TerminalAuthorityAppProjectionStore.open({ directory }) + initialize(initialized) + initialized.close() + const crashing = await TerminalAuthorityAppProjectionStore.open({ + directory, + beforeCommit: () => { + throw new Error('simulated process loss') + } + }) + + expect(() => crashing.apply(semanticPublication(1))).toThrow('simulated process loss') + expect(crashing.statistics()).toEqual({ rows: 1, writeTransactions: 0, writtenRows: 0 }) + crashing.close() + + const restarted = await TerminalAuthorityAppProjectionStore.open({ directory }) + expect(restarted.apply(semanticPublication(1)).rows).toHaveLength(1) + expect(restarted.snapshot(APP_CONSUMER.consumerId)[0]?.facts.bell).toBeDefined() + restarted.close() + }) + + it('fails closed when a nested durable row is corrupt after reopen', async () => { + const directory = await tempDirectory() + const first = await TerminalAuthorityAppProjectionStore.open({ directory }) + initialize(first) + const row = first.snapshot(APP_CONSUMER.consumerId)[0]! + first.close() + + const database = new SyncDatabase( + path.join(directory, TERMINAL_AUTHORITY_APP_PROJECTION_DATABASE_FILE) + ) + database.prepare('UPDATE terminal_authority_app_projection SET projection_json = ?').run( + JSON.stringify({ + ...row, + attention: { ...row.attention, pendingBellCount: -1 } + }) + ) + database.close() + + const reopened = await TerminalAuthorityAppProjectionStore.open({ directory }) + expect(() => reopened.snapshot(APP_CONSUMER.consumerId)).toThrow( + 'terminal authority app projection row identity changed' + ) + reopened.close() + }) + + it('persists an event-fenced bell clear across restart and duplicate replay', async () => { + const directory = await tempDirectory() + const publication = semanticPublication(1) + const first = await TerminalAuthorityAppProjectionStore.open({ directory }) + initialize(first) + first.apply(publication) + const row = first.snapshot(APP_CONSUMER.consumerId)[0]! + const request: TerminalAuthorityAppBellClearRequest = { + version: TERMINAL_AUTHORITY_APP_PROJECTION_VERSION, + consumerId: APP_CONSUMER.consumerId, + namespace: row.namespace, + pane: row.pane, + expectedEvent: row.facts.bell!.event + } + expect(first.clearBell(request)?.attention.pendingBellCount).toBe(0) + first.close() + + const restarted = await TerminalAuthorityAppProjectionStore.open({ directory }) + expect(restarted.apply(publication).rows).toEqual([]) + expect(restarted.snapshot(APP_CONSUMER.consumerId)[0]?.attention.pendingBellCount).toBe(0) + expect( + restarted.clearBell({ + ...request, + expectedEvent: { ...request.expectedEvent, outcomeId: 'wrong-outcome' } + }) + ).toBeNull() + restarted.close() + }) + + it('retains PR and Command Code deadlines across restart', async () => { + const directory = await tempDirectory() + let now = 1_000 + const first = await TerminalAuthorityAppProjectionStore.open({ directory, now: () => now }) + initialize(first) + first.apply( + semanticPublication(1, { + kind: 'pr-link', + link: { + url: 'https://github.com/orca/test/pull/2', + slug: { owner: 'orca', repo: 'test', host: 'github.com' }, + number: 2 + } + }) + ) + first.apply(semanticPublication(2, { kind: 'command-code-done', prompt: 'done' })) + first.close() + + now += TERMINAL_AUTHORITY_COMMAND_CODE_SETTLE_MS + const restarted = await TerminalAuthorityAppProjectionStore.open({ directory, now: () => now }) + expect(restarted.settleDueCommandCode()).toHaveLength(1) + expect(restarted.snapshot(APP_CONSUMER.consumerId)[0]?.commandCode?.state).toBe('done') + now = 1_000 + TERMINAL_AUTHORITY_PR_VERIFY_DELAY_MS + expect(restarted.duePrVerifications()).toHaveLength(1) + restarted.close() + }) + + it('atomically installs complete host semantics and preserves an event-fenced bell clear', async () => { + const directory = await tempDirectory() + const ptyIncarnationId = 'pty-incarnation-pane-generation-1' + const title = semanticPublication( + 1, + { kind: 'title', normalizedTitle: 'Recovered', rawTitle: 'Recovered' }, + { ptyIncarnationId } + ).outcome + const bell = semanticPublication(2, { kind: 'bell' }, { ptyIncarnationId }).outcome + const agent = semanticPublication(3, { kind: 'agent-working' }, { ptyIncarnationId }).outcome + const exit = exitPublication(4).outcome + if (exit.kind === 'semantic') { + throw new Error('exit fixture returned a semantic outcome') + } + const projection = authorityProjection({ + revision: 5, + panes: [{ ...exit.result.pane, ownerStatus: null }], + materializedOutcomes: [title, bell, agent, exit] + }) + const complete = boundary(4, { projection, consumerStart: 'new-at-tail' }) + const store = await TerminalAuthorityAppProjectionStore.open({ directory }) + + const installed = store.beginBoundary(complete).rows[0]! + + expect(installed).toMatchObject({ + facts: { + title: { event: { sequence: 1 } }, + bell: { event: { sequence: 2 } }, + 'agent-working': { event: { sequence: 3 } } + }, + agent: { event: { sequence: 4 }, state: 'exited' }, + exit: { event: { sequence: 4 }, code: 17 }, + attention: { pendingBellCount: 1 } + }) + const cleared = store.clearBell({ + version: TERMINAL_AUTHORITY_APP_PROJECTION_VERSION, + consumerId: installed.consumerId, + namespace: installed.namespace, + pane: installed.pane, + expectedEvent: installed.facts.bell!.event + }) + expect(cleared?.attention.pendingBellCount).toBe(0) + expect(store.beginBoundary({ ...complete, consumerStart: 'resume' }).rows).toEqual([]) + expect(store.snapshot(APP_CONSUMER.consumerId)[0]?.attention.pendingBellCount).toBe(0) + store.close() + }) + + it('preserves a later replayed event when the boundary materializes only older facts', async () => { + const store = await memoryStore() + const exited = exitPublication(2) + if (exited.outcome.kind === 'semantic') { + throw new Error('exit fixture returned a semantic outcome') + } + const change = exited.outcome.request.change + if (change.kind !== 'exit') { + throw new Error('exit fixture returned a non-exit mutation') + } + const binding = change.expected.binding + if (!binding) { + throw new Error('exit fixture returned no expected binding') + } + const pane = authorityProjection().panes[0]! + store.beginBoundary( + boundary(0, { + projection: authorityProjection({ + panes: [{ ...pane, binding, lastBinding: binding }] + }) + }) + ) + const bell = semanticPublication( + 1, + { kind: 'bell' }, + { + ptyIncarnationId: binding.ptyIncarnationId + } + ) + store.apply({ ...bell, outcomes: [bell.outcome, exited.outcome] }) + const resumed = boundary(0, { + outcomeHighWatermark: 2, + consumerStart: 'resume', + projection: authorityProjection({ + revision: 3, + panes: [{ ...exited.outcome.result.pane, ownerStatus: 'reachable' }], + materializedOutcomes: [bell.outcome] + }) + }) + + store.beginBoundary(resumed) + store.completeBoundary(resumed) + const beforeReplay = store.statistics() + + expect(store.snapshot(APP_CONSUMER.consumerId)[0]?.latestEvent).toMatchObject({ + sequence: 2, + outcomeId: exited.outcome.outcomeId + }) + expect(store.apply(exited)).toEqual({ rows: [], deleted: [] }) + expect(store.statistics()).toEqual(beforeReplay) + store.close() + }) + + it('rejects an equal-sequence boundary event with a different outcome identity', async () => { + const store = await memoryStore() + initialize(store) + store.apply(semanticPublication(1)) + const conflict = semanticPublication(1, { kind: 'bell' }, { outcomeId: 'conflicting-id' }) + + expect(() => + store.beginBoundary( + boundary(1, { + consumerStart: 'resume', + projection: authorityProjection({ + revision: 2, + materializedOutcomes: [conflict.outcome] + }) + }) + ) + ).toThrow('event conflicts') + + expect(store.snapshot(APP_CONSUMER.consumerId)[0]?.latestEvent?.outcomeId).toBe( + 'semantic-namespace-1-1' + ) + store.close() + }) + + it('rolls back the complete semantic boundary when its durable commit crashes', async () => { + const directory = await tempDirectory() + const store = await TerminalAuthorityAppProjectionStore.open({ + directory, + beforeCommit: () => { + throw new Error('crash before boundary commit') + } + }) + const outcome = semanticPublication(1).outcome + const complete = boundary(1, { + projection: authorityProjection({ revision: 2, materializedOutcomes: [outcome] }) + }) + + expect(() => store.beginBoundary(complete)).toThrow('crash before boundary commit') + + expect(store.snapshot(APP_CONSUMER.consumerId)).toEqual([]) + expect(store.statistics()).toEqual({ rows: 0, writeTransactions: 0, writtenRows: 0 }) + store.close() + const reopened = await TerminalAuthorityAppProjectionStore.open({ directory }) + expect(reopened.snapshot(APP_CONSUMER.consumerId)).toEqual([]) + expect(reopened.beginBoundary(complete).rows).toHaveLength(1) + reopened.close() + }) + + it('reconciles unbound panes and deletes rows absent from the next authority snapshot', async () => { + const store = await memoryStore() + const initial = boundary(0, { + projection: authorityProjection({ bound: false, paneGenerationId: 'unbound' }) + }) + expect(store.beginBoundary(initial).rows[0]).toMatchObject({ + pane: { paneGenerationId: 'unbound' }, + binding: null, + topology: { binding: null, lastBinding: null } + }) + + const empty = boundary(0, { projection: authorityProjection({ revision: 2, panes: [] }) }) + const change = store.beginBoundary(empty) + expect(change.deleted).toHaveLength(1) + expect(store.snapshot(APP_CONSUMER.consumerId)).toEqual([]) + store.close() + }) + + it('refreshes transient owner reachability at an unchanged authority revision', async () => { + const store = await memoryStore() + initialize(store) + const pane = authorityProjection().panes[0]! + + const change = store.beginBoundary( + boundary(0, { + projection: authorityProjection({ + panes: [{ ...pane, ownerStatus: 'owner-unreachable' }] + }) + }) + ) + + expect(change.rows[0]?.topology).toMatchObject({ + authorityRevision: pane.revision, + ownerStatus: 'owner-unreachable' + }) + expect(store.snapshot(APP_CONSUMER.consumerId)[0]?.topology.ownerStatus).toBe( + 'owner-unreachable' + ) + store.close() + }) + + it('fails closed when returning identity lost initialized projection history', async () => { + const store = await memoryStore() + expect(() => store.beginBoundary(boundary(25, { consumerStart: 'resume' }))).toThrow( + 'history is unavailable' + ) + expect(store.statistics()).toEqual({ rows: 0, writeTransactions: 0, writtenRows: 0 }) + expect(store.beginBoundary(boundary(25, { consumerStart: 'new-at-tail' })).rows).toHaveLength(1) + store.close() + }) + + it('isolates consumers and uses O(1) row capacity accounting', async () => { + const store = await memoryStore(2) + const other = { consumerId: 'app-profile:other', consumerIncarnationId: 'app-process:other' } + initialize(store) + store.beginBoundary({ ...boundary(0), consumer: other }) + expect(store.snapshot(APP_CONSUMER.consumerId)).toHaveLength(1) + expect(store.snapshot(other.consumerId)).toHaveLength(1) + + const originalPane = authorityProjection().panes[0]! + const thirdPane = Object.freeze({ + ...originalPane, + paneGenerationId: 'third-generation', + revision: 2 + }) + + expect(() => + store.beginBoundary( + boundary(0, { + projection: authorityProjection({ revision: 2, panes: [originalPane, thirdPane] }) + }) + ) + ).toThrow('capacity exceeded') + expect(store.statistics().rows).toBe(2) + store.close() + }) +}) + +function initialize(store: TerminalAuthorityAppProjectionStore): void { + store.beginBoundary(boundary(0)) +} + +function publicationPage(first: number, count: number) { + const outcomes = Array.from( + { length: count }, + (_, offset) => + semanticPublication(first + offset, { + kind: 'title', + normalizedTitle: `title-${first + offset}`, + rawTitle: `title-${first + offset}` + }).outcome + ) + return Object.freeze({ + ...semanticPublication( + first, + outcomes[0]!.kind === 'semantic' ? outcomes[0].fact : { kind: 'bell' } + ), + previousSequence: first - 1, + outcome: outcomes[0]!, + outcomes: Object.freeze(outcomes) + }) +} + +async function memoryStore( + maxRows?: number, + now?: () => number +): Promise { + return TerminalAuthorityAppProjectionStore.open({ + directory: await tempDirectory(), + databasePath: ':memory:', + ...(maxRows ? { maxRows } : {}), + ...(now ? { now } : {}) + }) +} + +async function tempDirectory(): Promise { + const directory = await mkdtemp(path.join(tmpdir(), 'orca-app-projection-')) + directories.push(directory) + return directory +} diff --git a/src/main/session-authority/terminal-authority-app-projection-store.ts b/src/main/session-authority/terminal-authority-app-projection-store.ts new file mode 100644 index 00000000000..2a11b05c405 --- /dev/null +++ b/src/main/session-authority/terminal-authority-app-projection-store.ts @@ -0,0 +1,314 @@ +import { mkdir } from 'node:fs/promises' +import path from 'node:path' +import { + TERMINAL_AUTHORITY_APP_PROJECTION_MAX_ROWS, + terminalAuthorityAppProjectionRowKey, + type TerminalAuthorityAppBellClearRequest, + type TerminalAuthorityAppProjectionChange, + type TerminalAuthorityAppPaneProjection +} from '../../shared/terminal-authority-app-projection' +import { + assertAuthorityId, + assertAuthorityStoragePath, + type TerminalPaneGeneration +} from '../../shared/terminal-session-authority-identity' +import type { + TerminalAuthorityNamespaceOutcomeBoundary, + TerminalAuthorityNamespaceOutcomePublication +} from '../../shared/terminal-session-authority-consumer-transport' +import { + reduceTerminalAuthorityAppProjection, + settleTerminalAuthorityCommandCodeProjections, + terminalAuthorityAppPrVerificationsDue +} from './terminal-authority-app-projection-reducer' +import { TerminalAuthorityAppProjectionDatabase } from './terminal-authority-app-projection-database' +import { + assertTerminalAuthorityAppProjectionPublicationPage, + emptyTerminalAuthorityAppProjectionChange, + requireTerminalAuthorityAppBoundaryProjection, + sameTerminalAuthorityAppEvent, + terminalAuthorityAppProjectionLimit +} from './terminal-authority-app-projection-transaction-validation' +import { reconcileTerminalAuthorityAppProjectionSnapshot } from './terminal-authority-app-projection-snapshot' + +export const TERMINAL_AUTHORITY_APP_PROJECTION_DATABASE_FILE = + 'terminal-authority-app-projection.sqlite' + +export type TerminalAuthorityAppProjectionStoreOptions = Readonly<{ + directory: string + maxRows?: number + now?: () => number + databasePath?: ':memory:' + beforeCommit?: () => void +}> + +export class TerminalAuthorityAppProjectionStore { + private rowCountValue: number + private writeTransactions = 0 + private writtenRows = 0 + + private constructor( + private readonly storage: TerminalAuthorityAppProjectionDatabase, + private readonly maxRows: number, + private readonly now: () => number, + private readonly beforeCommit: () => void + ) { + this.rowCountValue = storage.rowCount() + if (this.rowCountValue > maxRows) { + throw new Error('terminal authority app projection capacity exceeded') + } + } + + static async open( + options: TerminalAuthorityAppProjectionStoreOptions + ): Promise { + assertAuthorityStoragePath(options.directory, 'app projection directory') + const directory = path.resolve(options.directory) + await mkdir(directory, { recursive: true }) + return new TerminalAuthorityAppProjectionStore( + new TerminalAuthorityAppProjectionDatabase( + options.databasePath ?? + path.join(directory, TERMINAL_AUTHORITY_APP_PROJECTION_DATABASE_FILE) + ), + terminalAuthorityAppProjectionLimit( + options.maxRows, + TERMINAL_AUTHORITY_APP_PROJECTION_MAX_ROWS + ), + options.now ?? Date.now, + options.beforeCommit ?? (() => {}) + ) + } + + apply( + publication: TerminalAuthorityNamespaceOutcomePublication + ): TerminalAuthorityAppProjectionChange { + assertTerminalAuthorityAppProjectionPublicationPage(publication) + const cached = new Map() + const original = new Map() + const changed = new Map() + const lookup = ( + consumerId: string, + _outcome: TerminalAuthorityNamespaceOutcomePublication['outcome'], + pane: TerminalPaneGeneration + ): TerminalAuthorityAppPaneProjection | null => { + const key = terminalAuthorityAppProjectionRowKey({ + consumerId, + namespace: publication.namespace, + pane + }) + if (!cached.has(key)) { + const row = this.storage.readRow(consumerId, publication.namespace, pane) + cached.set(key, row) + original.set(key, row) + } + return cached.get(key) ?? null + } + this.storage.begin() + try { + for (const outcome of publication.outcomes ?? [publication.outcome]) { + for (const row of reduceTerminalAuthorityAppProjection( + publication.consumer.consumerId, + outcome, + lookup, + this.now() + )) { + const key = terminalAuthorityAppProjectionRowKey(row) + cached.set(key, row) + changed.set(key, row) + } + } + if (changed.size === 0) { + this.storage.rollback() + return emptyTerminalAuthorityAppProjectionChange() + } + const inserted = [...changed.keys()].filter((key) => original.get(key) === null).length + this.assertCapacity(this.rowCountValue + inserted) + for (const row of changed.values()) { + this.storage.writeRow(row) + } + this.commit() + this.rowCountValue += inserted + this.writtenRows += changed.size + return Object.freeze({ + rows: Object.freeze([...changed.values()]), + deleted: Object.freeze([]) + }) + } catch (error) { + this.storage.rollback() + throw error + } + } + + beginBoundary( + boundary: TerminalAuthorityNamespaceOutcomeBoundary + ): TerminalAuthorityAppProjectionChange { + const projection = requireTerminalAuthorityAppBoundaryProjection(boundary) + if ( + boundary.consumerStart === 'new-at-tail' && + boundary.acknowledgedSequence !== boundary.outcomeHighWatermark + ) { + throw new Error('terminal authority app projection new consumer did not start at tail') + } + if (boundary.acknowledgedSequence < boundary.outcomeHighWatermark) { + const metadataRevision = this.storage.readInitializationRevision( + boundary.consumer.consumerId, + boundary.namespace + ) + if (metadataRevision === null) { + throw new Error('terminal authority app projection history is unavailable') + } + if (projection.revision < metadataRevision) { + throw new Error('terminal authority app projection boundary regressed') + } + return emptyTerminalAuthorityAppProjectionChange() + } + return this.reconcileBoundarySnapshot(boundary, boundary.consumerStart === 'new-at-tail') + } + + completeBoundary( + boundary: TerminalAuthorityNamespaceOutcomeBoundary + ): TerminalAuthorityAppProjectionChange { + requireTerminalAuthorityAppBoundaryProjection(boundary) + return this.reconcileBoundarySnapshot(boundary, false) + } + + private reconcileBoundarySnapshot( + boundary: TerminalAuthorityNamespaceOutcomeBoundary, + allowInitialize: boolean + ): TerminalAuthorityAppProjectionChange { + const projection = requireTerminalAuthorityAppBoundaryProjection(boundary) + this.storage.begin() + try { + const metadataRevision = this.storage.readInitializationRevision( + boundary.consumer.consumerId, + boundary.namespace + ) + if (metadataRevision === null && boundary.acknowledgedSequence > 0 && !allowInitialize) { + throw new Error('terminal authority app projection history is unavailable') + } + if (metadataRevision !== null && projection.revision < metadataRevision) { + throw new Error('terminal authority app projection boundary regressed') + } + const current = this.storage.namespaceRows(boundary.consumer.consumerId, boundary.namespace) + const change = reconcileTerminalAuthorityAppProjectionSnapshot( + boundary.consumer.consumerId, + projection, + current, + this.now() + ) + const currentKeys = new Set(current.map(terminalAuthorityAppProjectionRowKey)) + const inserted = change.rows.filter( + (row) => !currentKeys.has(terminalAuthorityAppProjectionRowKey(row)) + ).length + this.assertCapacity(this.rowCountValue - change.deleted.length + inserted) + const metadataChanged = metadataRevision === null || projection.revision > metadataRevision + if (change.rows.length === 0 && change.deleted.length === 0 && !metadataChanged) { + this.storage.rollback() + return emptyTerminalAuthorityAppProjectionChange() + } + for (const identity of change.deleted) { + this.storage.deleteRow(identity) + } + for (const row of change.rows) { + this.storage.writeRow(row) + } + this.storage.writeInitializationRevision( + boundary.consumer.consumerId, + boundary.namespace, + projection.revision + ) + this.commit() + this.rowCountValue += inserted - change.deleted.length + this.writtenRows += change.rows.length + change.deleted.length + return change + } catch (error) { + this.storage.rollback() + throw error + } + } + + snapshot(consumerId: string): readonly TerminalAuthorityAppPaneProjection[] { + assertAuthorityId(consumerId, 'app projection consumerId') + return Object.freeze(this.storage.rowsForConsumer(consumerId)) + } + + snapshotAll(): readonly TerminalAuthorityAppPaneProjection[] { + return Object.freeze(this.storage.allRows()) + } + + clearBell( + request: TerminalAuthorityAppBellClearRequest + ): TerminalAuthorityAppPaneProjection | null { + this.storage.begin() + try { + const row = this.storage.readRow(request.consumerId, request.namespace, request.pane) + const bell = row?.facts.bell + if (!row || !bell || !sameTerminalAuthorityAppEvent(bell.event, request.expectedEvent)) { + this.storage.rollback() + return null + } + if (row.attention.pendingBellCount === 0) { + this.storage.rollback() + return row + } + const changed = Object.freeze({ + ...row, + attention: Object.freeze({ ...row.attention, pendingBellCount: 0, updatedAt: this.now() }), + status: Object.freeze({ ...row.status, attention: false, updatedAt: this.now() }) + }) + this.storage.writeRow(changed) + this.commit() + this.writtenRows += 1 + return changed + } catch (error) { + this.storage.rollback() + throw error + } + } + + settleDueCommandCode(now = this.now()): readonly TerminalAuthorityAppPaneProjection[] { + const changed = settleTerminalAuthorityCommandCodeProjections(this.storage.allRows(), now) + if (changed.length === 0) { + return Object.freeze([]) + } + this.storage.begin() + try { + for (const row of changed) { + this.storage.writeRow(row) + } + this.commit() + this.writtenRows += changed.length + return Object.freeze(changed) + } catch (error) { + this.storage.rollback() + throw error + } + } + + duePrVerifications(now = this.now()): readonly TerminalAuthorityAppPaneProjection[] { + return Object.freeze(terminalAuthorityAppPrVerificationsDue(this.storage.allRows(), now)) + } + + statistics(): Readonly<{ rows: number; writeTransactions: number; writtenRows: number }> { + return Object.freeze({ + rows: this.rowCountValue, + writeTransactions: this.writeTransactions, + writtenRows: this.writtenRows + }) + } + + close(): void { + this.storage.close() + } + + private assertCapacity(rows: number): void { + if (rows > this.maxRows) { + throw new Error('terminal authority app projection capacity exceeded') + } + } + + private commit(): void { + this.storage.commit(this.beforeCommit) + this.writeTransactions += 1 + } +} diff --git a/src/main/session-authority/terminal-authority-app-projection-transaction-validation.ts b/src/main/session-authority/terminal-authority-app-projection-transaction-validation.ts new file mode 100644 index 00000000000..e2c3f64e611 --- /dev/null +++ b/src/main/session-authority/terminal-authority-app-projection-transaction-validation.ts @@ -0,0 +1,78 @@ +import type { + TerminalAuthorityAppEventKey, + TerminalAuthorityAppProjectionChange +} from '../../shared/terminal-authority-app-projection' +import type { TerminalAuthorityNamespace } from '../../shared/terminal-session-authority-identity' +import type { + TerminalAuthorityNamespaceOutcomeBoundary, + TerminalAuthorityNamespaceOutcomePublication +} from '../../shared/terminal-session-authority-consumer-transport' +import type { TerminalAuthorityProjection } from '../../shared/terminal-session-authority-mutation' +import { terminalAuthorityDurableOutcomeNamespace } from '../../shared/terminal-authority-durable-outcome-validation' +import { assertAuthorityId } from '../../shared/terminal-session-authority-identity' + +export function assertTerminalAuthorityAppProjectionPublicationPage( + publication: TerminalAuthorityNamespaceOutcomePublication +): void { + assertAuthorityId(publication.consumer.consumerId, 'app projection consumerId') + const outcomes = publication.outcomes ?? [publication.outcome] + outcomes.forEach((outcome, index) => { + if ( + outcome.sequence !== publication.previousSequence + index + 1 || + !sameNamespace(terminalAuthorityDurableOutcomeNamespace(outcome), publication.namespace) + ) { + throw new Error('terminal authority app projection publication has a cursor gap') + } + }) +} + +export function requireTerminalAuthorityAppBoundaryProjection( + boundary: TerminalAuthorityNamespaceOutcomeBoundary +): TerminalAuthorityProjection { + const projection = boundary.projection + if ( + !projection || + projection.materializedOutcomes === undefined || + projection.materializedOutcomes.some( + (outcome) => outcome.sequence > boundary.outcomeHighWatermark + ) || + !sameNamespace(projection.namespace, boundary.namespace) + ) { + throw new Error('terminal authority app projection boundary is missing its authority snapshot') + } + return projection +} + +export function sameTerminalAuthorityAppEvent( + left: TerminalAuthorityAppEventKey, + right: TerminalAuthorityAppEventKey +): boolean { + return ( + left.consumerId === right.consumerId && + sameNamespace(left.namespace, right.namespace) && + left.sequence === right.sequence && + left.outcomeId === right.outcomeId + ) +} + +export function terminalAuthorityAppProjectionLimit( + value: number | undefined, + fallback: number +): number { + const selected = value ?? fallback + if (!Number.isSafeInteger(selected) || selected < 1) { + throw new Error('terminal authority app projection limit is invalid') + } + return selected +} + +export function emptyTerminalAuthorityAppProjectionChange(): TerminalAuthorityAppProjectionChange { + return Object.freeze({ rows: Object.freeze([]), deleted: Object.freeze([]) }) +} + +function sameNamespace( + left: TerminalAuthorityNamespace, + right: TerminalAuthorityNamespace +): boolean { + return left.authorityHostId === right.authorityHostId && left.namespaceId === right.namespaceId +} diff --git a/src/main/session-authority/terminal-authority-app-semantic-projection.ts b/src/main/session-authority/terminal-authority-app-semantic-projection.ts new file mode 100644 index 00000000000..bffd649780d --- /dev/null +++ b/src/main/session-authority/terminal-authority-app-semantic-projection.ts @@ -0,0 +1,248 @@ +import type { + TerminalAuthorityAppAgentProjection, + TerminalAuthorityAppCommandCodeProjection, + TerminalAuthorityAppEventKey, + TerminalAuthorityAppFactProjection, + TerminalAuthorityAppPaneProjection +} from '../../shared/terminal-authority-app-projection' +import { + sameTerminalBinding, + type TerminalSessionBinding +} from '../../shared/terminal-session-authority-identity' +import type { + TerminalAuthorityDurableOutcome, + TerminalSessionAuthoritySemanticFact +} from '../../shared/terminal-session-authority-mutation' +import { + sameTerminalAuthorityAppProjectionEvent, + terminalAuthorityAppEventAlreadyProjected, + terminalAuthorityAppProjectionEventKey, + terminalAuthorityAppStatusProjection +} from './terminal-authority-app-projection-event-state' +import type { TerminalAuthorityAppProjectionRowLookup } from './terminal-authority-app-projection-reducer' + +export const TERMINAL_AUTHORITY_PR_VERIFY_DELAY_MS = 30_000 +export const TERMINAL_AUTHORITY_COMMAND_CODE_SETTLE_MS = 1_500 +const MAX_PENDING_BELLS = 99 + +export function reduceTerminalAuthorityAppSemanticProjection( + consumerId: string, + outcome: Extract, + lookup: TerminalAuthorityAppProjectionRowLookup, + now: number +): readonly TerminalAuthorityAppPaneProjection[] { + const current = lookup(consumerId, outcome, outcome.access.pane) + if (!current) { + throw new Error('terminal authority app projection requires an authoritative pane snapshot') + } + const event = terminalAuthorityAppProjectionEventKey(consumerId, outcome) + if (terminalAuthorityAppEventAlreadyProjected(current, event)) { + return Object.freeze([]) + } + const binding = current.binding ?? current.topology.lastBinding + if (!binding || !sameTerminalBinding(binding, outcome.access.binding)) { + throw new Error('terminal authority app projection fact binding is stale') + } + const fact = projectFact(event, outcome.access.binding, outcome.fact, now) + const facts = Object.freeze({ ...current.facts, [outcome.fact.kind]: fact }) + const attention = projectAttention(current, event, outcome.fact, now) + const agent = projectAgent(current.agent, event, outcome.access.binding, outcome.fact, now) + const commandCode = projectCommandCode( + current.commandCode, + event, + outcome.access.binding, + outcome.fact, + now + ) + return Object.freeze([ + Object.freeze({ + ...current, + latestEvent: event, + facts, + attention, + ...(agent ? { agent } : {}), + ...(commandCode ? { commandCode } : {}), + status: terminalAuthorityAppStatusProjection( + current, + event, + now, + agent, + current.topology, + attention + ) + }) + ]) +} + +export function settleTerminalAuthorityCommandCodeProjection( + row: TerminalAuthorityAppPaneProjection, + now: number +): TerminalAuthorityAppPaneProjection | null { + const current = row.commandCode + if ( + !current || + current.state !== 'settling' || + current.settleAt === null || + current.settleAt > now + ) { + return null + } + const commandCode = Object.freeze({ + ...current, + state: 'done' as const, + transitionedAt: current.settleAt, + settleAt: null + }) + const agent = sameTerminalAuthorityAppProjectionEvent(row.agent?.event, current.event) + ? Object.freeze({ ...row.agent!, state: 'done' as const, transitionedAt: current.settleAt }) + : row.agent + return Object.freeze({ + ...row, + commandCode, + ...(agent ? { agent } : {}), + status: terminalAuthorityAppStatusProjection( + row, + current.event, + now, + agent, + row.topology, + row.attention + ) + }) +} + +export function settleTerminalAuthorityCommandCodeProjections( + rows: readonly TerminalAuthorityAppPaneProjection[], + now: number +): readonly TerminalAuthorityAppPaneProjection[] { + return rows + .map((row) => settleTerminalAuthorityCommandCodeProjection(row, now)) + .filter((row): row is TerminalAuthorityAppPaneProjection => row !== null) +} + +export function terminalAuthorityAppPrVerificationsDue( + rows: readonly TerminalAuthorityAppPaneProjection[], + now: number +): readonly TerminalAuthorityAppPaneProjection[] { + return rows.filter((row) => { + const field = row.facts['pr-link'] + return field?.verification === 'pending' && (field.verifyAfter ?? Infinity) <= now + }) +} + +function projectFact( + event: TerminalAuthorityAppEventKey, + binding: TerminalSessionBinding, + fact: TerminalSessionAuthoritySemanticFact, + now: number +): TerminalAuthorityAppFactProjection { + return Object.freeze({ + event, + binding: Object.freeze({ ...binding }), + fact: structuredClone(fact), + appliedAt: now, + ...(fact.kind === 'pr-link' + ? { + verifyAfter: now + TERMINAL_AUTHORITY_PR_VERIFY_DELAY_MS, + verification: 'pending' as const + } + : {}) + }) +} + +function projectAttention( + row: TerminalAuthorityAppPaneProjection, + event: TerminalAuthorityAppEventKey, + fact: TerminalSessionAuthoritySemanticFact, + now: number +): TerminalAuthorityAppPaneProjection['attention'] { + if (fact.kind !== 'bell') { + return row.attention + } + return Object.freeze({ + event, + pendingBellCount: Math.min(row.attention.pendingBellCount + 1, MAX_PENDING_BELLS), + updatedAt: now + }) +} + +function projectAgent( + current: TerminalAuthorityAppAgentProjection | undefined, + event: TerminalAuthorityAppEventKey, + binding: TerminalSessionBinding, + fact: TerminalSessionAuthoritySemanticFact, + now: number +): TerminalAuthorityAppAgentProjection | undefined { + const state = agentState(fact) + if (!state) { + return current + } + const prompt = + fact.kind === 'agent-status' + ? fact.payload.prompt + : fact.kind === 'command-code-working' || fact.kind === 'command-code-done' + ? fact.prompt.trim() + : (current?.prompt ?? '') + return Object.freeze({ + event, + binding: Object.freeze({ ...binding }), + state, + prompt, + transitionedAt: now + }) +} + +function projectCommandCode( + current: TerminalAuthorityAppCommandCodeProjection | undefined, + event: TerminalAuthorityAppEventKey, + binding: TerminalSessionBinding, + fact: TerminalSessionAuthoritySemanticFact, + now: number +): TerminalAuthorityAppCommandCodeProjection | undefined { + if (fact.kind === 'command-code-working') { + return Object.freeze({ + event, + binding: Object.freeze({ ...binding }), + state: 'working', + prompt: fact.prompt.trim(), + transitionedAt: now, + settleAt: null + }) + } + if (fact.kind !== 'command-code-done') { + return current + } + return Object.freeze({ + event, + binding: Object.freeze({ ...binding }), + state: 'settling', + prompt: fact.prompt.trim(), + transitionedAt: now, + settleAt: now + TERMINAL_AUTHORITY_COMMAND_CODE_SETTLE_MS + }) +} + +function agentState( + fact: TerminalSessionAuthoritySemanticFact +): TerminalAuthorityAppAgentProjection['state'] | null { + switch (fact.kind) { + case 'agent-status': + return fact.payload.state + case 'agent-working': + case 'command-code-working': + return 'working' + case 'agent-idle': + return 'idle' + case 'agent-exited': + return 'exited' + case 'command-code-done': + return 'working' + case 'title': + case 'bell': + case 'command-finished': + case 'pr-link': + case '2031-subscribe': + case '2031-unsubscribe': + return null + } +} diff --git a/src/main/session-authority/terminal-authority-consumer-proof-keypair.test.ts b/src/main/session-authority/terminal-authority-consumer-proof-keypair.test.ts new file mode 100644 index 00000000000..039d85ccfeb --- /dev/null +++ b/src/main/session-authority/terminal-authority-consumer-proof-keypair.test.ts @@ -0,0 +1,74 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { E2EE_KEYPAIR_FILENAME } from '../runtime/mobile-pairing-files' +import { loadTerminalAuthorityConsumerProofKeypair } from './terminal-authority-consumer-proof-keypair' +import { + createTerminalAuthorityProofEphemeralKeypair, + terminalAuthorityHostAppConsumerId +} from './terminal-session-authority-consumer-proof' + +const directories: string[] = [] + +afterEach(() => { + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +describe('terminal authority consumer proof identity keypair', () => { + it('persists one identity and fails closed after its key is lost', () => { + const directory = freshDirectory() + const first = loadTerminalAuthorityConsumerProofKeypair(directory) + const consumerId = terminalAuthorityHostAppConsumerId( + 'authority-host:keypair-test', + first.publicKey + ) + + expect( + terminalAuthorityHostAppConsumerId( + 'authority-host:keypair-test', + loadTerminalAuthorityConsumerProofKeypair(directory).publicKey + ) + ).toBe(consumerId) + rmSync(join(directory, E2EE_KEYPAIR_FILENAME)) + + expect(() => loadTerminalAuthorityConsumerProofKeypair(directory)).toThrow( + 'Established E2EE identity is missing' + ) + }) + + it('rejects corruption and a valid replacement instead of rotating identity', () => { + const corruptDirectory = freshDirectory() + writeFileSync(join(corruptDirectory, E2EE_KEYPAIR_FILENAME), '{invalid', 'utf8') + + expect(() => loadTerminalAuthorityConsumerProofKeypair(corruptDirectory)).toThrow( + 'E2EE keypair is invalid' + ) + expect(readFileSync(join(corruptDirectory, E2EE_KEYPAIR_FILENAME), 'utf8')).toBe('{invalid') + + const replacedDirectory = freshDirectory() + loadTerminalAuthorityConsumerProofKeypair(replacedDirectory) + const replacement = createTerminalAuthorityProofEphemeralKeypair() + writeFileSync( + join(replacedDirectory, E2EE_KEYPAIR_FILENAME), + JSON.stringify({ + v: 1, + publicKeyB64: Buffer.from(replacement.publicKey).toString('base64'), + secretKeyB64: Buffer.from(replacement.secretKey).toString('base64') + }), + 'utf8' + ) + + expect(() => loadTerminalAuthorityConsumerProofKeypair(replacedDirectory)).toThrow( + 'does not match the keypair' + ) + }) +}) + +function freshDirectory(): string { + const directory = mkdtempSync(join(tmpdir(), 'orca-authority-proof-keypair-')) + directories.push(directory) + return directory +} diff --git a/src/main/session-authority/terminal-authority-consumer-proof-keypair.ts b/src/main/session-authority/terminal-authority-consumer-proof-keypair.ts new file mode 100644 index 00000000000..09a25172676 --- /dev/null +++ b/src/main/session-authority/terminal-authority-consumer-proof-keypair.ts @@ -0,0 +1,21 @@ +import { getCanonicalUserDataPath } from '../persistence' +import { loadOrCreateE2EEKeypair } from '../runtime/e2ee-keypair' +import type { TerminalAuthorityConsumerProofKeypair } from './terminal-session-authority-consumer-proof' + +let keypair: TerminalAuthorityConsumerProofKeypair | null = null + +export function getTerminalAuthorityConsumerProofKeypair(): TerminalAuthorityConsumerProofKeypair { + keypair ??= loadTerminalAuthorityConsumerProofKeypair(getCanonicalUserDataPath()) + return keypair +} + +/** Drops the process cache after the reset transaction publishes its successor. */ +export function invalidateTerminalAuthorityConsumerProofKeypair(): void { + keypair = null +} + +export function loadTerminalAuthorityConsumerProofKeypair( + userDataPath: string +): TerminalAuthorityConsumerProofKeypair { + return loadOrCreateE2EEKeypair(userDataPath) +} diff --git a/src/main/session-authority/terminal-authority-writer-recovery.ts b/src/main/session-authority/terminal-authority-writer-recovery.ts new file mode 100644 index 00000000000..8aa79da9a58 --- /dev/null +++ b/src/main/session-authority/terminal-authority-writer-recovery.ts @@ -0,0 +1,68 @@ +import { TerminalSessionAuthorityError } from '../../shared/terminal-session-authority-mutation' +import { TerminalAuthorityWriterLock } from './terminal-session-authority-writer-lock' + +const MAX_WRITER_RECOVERY_STEPS = 3 + +export type TerminalAuthorityWriterClaimVerifier = (ownerToken: string) => Promise + +type TerminalAuthorityWriterRecoveryOptions = Readonly<{ + directory: string + open: (takeoverOwnerToken?: string) => Promise + claimIsGone: TerminalAuthorityWriterClaimVerifier +}> + +export async function openTerminalAuthorityWriterWithRecovery( + options: TerminalAuthorityWriterRecoveryOptions +): Promise { + let takeoverOwnerToken: string | undefined + let lastFencedError: TerminalSessionAuthorityError | null = null + for (let step = 0; step < MAX_WRITER_RECOVERY_STEPS; step++) { + try { + return await options.open(takeoverOwnerToken) + } catch (error) { + if (!(error instanceof TerminalSessionAuthorityError) || error.code !== 'writer-fenced') { + throw error + } + lastFencedError = error + takeoverOwnerToken = await recoverWriterClaim(options, error) + } + } + throw lastFencedError ?? new Error('authority writer recovery exhausted') +} + +async function recoverWriterClaim( + options: TerminalAuthorityWriterRecoveryOptions, + fencedError: TerminalSessionAuthorityError +): Promise { + const claim = await TerminalAuthorityWriterLock.readCurrentOwnerClaim(options.directory) + const { guardOwnerToken, markerOwnerToken } = claim + if (guardOwnerToken && guardOwnerToken !== markerOwnerToken) { + await requireGoneClaim(guardOwnerToken, options.claimIsGone, fencedError) + if (!(await TerminalAuthorityWriterLock.clearProvenGuard(options.directory, guardOwnerToken))) { + throw fencedError + } + return undefined + } + const predecessorToken = markerOwnerToken ?? guardOwnerToken + if (!predecessorToken) { + throw fencedError + } + await requireGoneClaim(predecessorToken, options.claimIsGone, fencedError) + if (markerOwnerToken === predecessorToken) { + return predecessorToken + } + if (!(await TerminalAuthorityWriterLock.clearProvenGuard(options.directory, predecessorToken))) { + throw fencedError + } + return undefined +} + +async function requireGoneClaim( + ownerToken: string, + claimIsGone: TerminalAuthorityWriterClaimVerifier, + fencedError: TerminalSessionAuthorityError +): Promise { + if (!(await claimIsGone(ownerToken))) { + throw fencedError + } +} diff --git a/src/main/session-authority/terminal-authority-writer-storage.ts b/src/main/session-authority/terminal-authority-writer-storage.ts new file mode 100644 index 00000000000..570330520ef --- /dev/null +++ b/src/main/session-authority/terminal-authority-writer-storage.ts @@ -0,0 +1,266 @@ +import { createHash, randomUUID } from 'node:crypto' +import { + mkdir, + open, + readFile, + readdir, + rename, + rm, + rmdir, + stat, + unlink, + type FileHandle +} from 'node:fs/promises' +import path from 'node:path' +import { assertAuthorityId, isRecord } from '../../shared/terminal-session-authority-identity' +import { failTerminalSessionAuthority } from '../../shared/terminal-session-authority-mutation' +import { removeAuthorityCrashTemporaryFiles } from './terminal-session-authority-temporary-files' + +const MARKER_FILE = 'authority-writer.json' +const GUARD_FILE = 'authority-writer.guard' +const GUARD_OWNER_PREFIX = 'owner-' +const MAX_MARKER_BYTES = 4 * 1024 +const MAX_GUARD_BYTES = 4 * 1024 +const GUARD_ATTEMPTS = 200 + +export type TerminalAuthorityWriterMarker = Readonly<{ + version: 1 + ownerToken: string + epoch: number + active: boolean +}> + +export type TerminalAuthorityWriterGuard = Readonly<{ ownerToken: string }> + +export async function acquireTerminalAuthorityWriterGuard( + directory: string, + ownerToken: string +): Promise { + const guardPath = path.join(directory, GUARD_FILE) + for (let attempt = 0; attempt < GUARD_ATTEMPTS; attempt++) { + const temporary = `${guardPath}.${process.pid}.${randomUUID()}.tmp` + try { + await mkdir(temporary, { mode: 0o700 }) + await writeSyncedFile(path.join(temporary, guardOwnerFileName(ownerToken)), `${ownerToken}\n`) + await syncDirectory(temporary) + if (await publishDirectoryExclusive(temporary, guardPath)) { + await syncDirectory(directory) + return Object.freeze({ ownerToken }) + } + await removeEmptyGuardDirectory(guardPath) + await new Promise((resolve) => setTimeout(resolve, 5)) + } finally { + await rm(temporary, { recursive: true, force: true }) + } + } + failTerminalSessionAuthority('writer-fenced', 'authority writer guard is busy') +} + +export async function releaseTerminalAuthorityWriterGuard( + directory: string, + guard: TerminalAuthorityWriterGuard +): Promise { + if (!(await removeTerminalAuthorityWriterGuard(directory, guard.ownerToken))) { + failTerminalSessionAuthority('writer-fenced', 'authority writer guard was replaced') + } +} + +export async function clearTerminalAuthorityWriterGuard( + directory: string, + ownerToken: string +): Promise { + return removeTerminalAuthorityWriterGuard(directory, ownerToken) +} + +export async function readTerminalAuthorityWriterGuardOwner( + directory: string +): Promise { + const guardPath = path.join(directory, GUARD_FILE) + let metadata + try { + metadata = await stat(guardPath) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return null + } + throw error + } + if (metadata.isFile()) { + return readBoundedOwnerFile(guardPath, metadata.size) + } + if (!metadata.isDirectory()) { + failTerminalSessionAuthority('record-corrupt', 'authority writer guard is invalid') + } + const entries = await readdir(guardPath) + if (entries.length === 0) { + return null + } + if (entries.length !== 1 || !entries[0]!.startsWith(GUARD_OWNER_PREFIX)) { + failTerminalSessionAuthority('record-corrupt', 'authority writer guard is invalid') + } + const ownerFile = path.join(guardPath, entries[0]!) + const ownerMetadata = await stat(ownerFile) + const ownerToken = await readBoundedOwnerFile(ownerFile, ownerMetadata.size) + if (entries[0] !== guardOwnerFileName(ownerToken)) { + failTerminalSessionAuthority('record-corrupt', 'authority writer guard identity changed') + } + return ownerToken +} + +export async function readTerminalAuthorityWriterMarker( + directory: string +): Promise { + const markerPath = path.join(directory, MARKER_FILE) + let metadata + try { + metadata = await stat(markerPath) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return null + } + throw error + } + if (!metadata.isFile() || metadata.size > MAX_MARKER_BYTES) { + failTerminalSessionAuthority('record-corrupt', 'authority writer marker is invalid') + } + let value: unknown + try { + value = JSON.parse(await readFile(markerPath, 'utf8')) as unknown + } catch { + failTerminalSessionAuthority('record-corrupt', 'authority writer marker is corrupt') + } + if ( + !isRecord(value) || + value.version !== 1 || + typeof value.active !== 'boolean' || + !Number.isSafeInteger(value.epoch) || + (value.epoch as number) < 1 + ) { + failTerminalSessionAuthority('record-corrupt', 'authority writer marker is corrupt') + } + assertAuthorityId(value.ownerToken, 'writer ownerToken') + return Object.freeze({ + version: 1, + ownerToken: value.ownerToken, + epoch: value.epoch as number, + active: value.active + }) +} + +export async function writeTerminalAuthorityWriterMarker( + directory: string, + marker: TerminalAuthorityWriterMarker +): Promise { + const markerPath = path.join(directory, MARKER_FILE) + const temporary = `${markerPath}.${process.pid}.${randomUUID()}.tmp` + await writeSyncedFile(temporary, `${JSON.stringify(marker)}\n`) + try { + await rename(temporary, markerPath) + await syncDirectory(directory) + } finally { + await rm(temporary, { force: true }) + } +} + +export async function removeTerminalAuthorityWriterCrashFiles(directory: string): Promise { + await removeAuthorityCrashTemporaryFiles(directory, [MARKER_FILE]) +} + +async function removeTerminalAuthorityWriterGuard( + directory: string, + ownerToken: string +): Promise { + const guardPath = path.join(directory, GUARD_FILE) + const ownerPath = path.join(guardPath, guardOwnerFileName(ownerToken)) + let ownerMetadata + try { + ownerMetadata = await stat(ownerPath) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return false + } + throw error + } + if ((await readBoundedOwnerFile(ownerPath, ownerMetadata.size)) !== ownerToken) { + return false + } + try { + await unlink(ownerPath) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return false + } + throw error + } + await removeEmptyGuardDirectory(guardPath) + await syncDirectory(directory) + return true +} + +async function readBoundedOwnerFile(file: string, size: number): Promise { + if (size < 1 || size > MAX_GUARD_BYTES) { + failTerminalSessionAuthority('record-corrupt', 'authority writer guard is invalid') + } + const ownerToken = (await readFile(file, 'utf8')).trim() + assertAuthorityId(ownerToken, 'writer guard ownerToken') + return ownerToken +} + +async function publishDirectoryExclusive(source: string, target: string): Promise { + try { + await rename(source, target) + return true + } catch (error) { + try { + await stat(target) + return false + } catch (targetError) { + if ((targetError as NodeJS.ErrnoException).code !== 'ENOENT') { + throw targetError + } + throw error + } + } +} + +async function removeEmptyGuardDirectory(guardPath: string): Promise { + try { + await rmdir(guardPath) + } catch (error) { + if (!['ENOENT', 'ENOTEMPTY', 'EEXIST', 'ENOTDIR'].includes(errorCode(error))) { + throw error + } + } +} + +async function writeSyncedFile(file: string, contents: string): Promise { + const handle = await open(file, 'wx', 0o600) + try { + await handle.writeFile(contents, 'utf8') + await handle.sync() + } finally { + await handle.close() + } +} + +function guardOwnerFileName(ownerToken: string): string { + return `${GUARD_OWNER_PREFIX}${createHash('sha256').update(ownerToken).digest('hex')}` +} + +async function syncDirectory(directory: string): Promise { + let handle: FileHandle | null = null + try { + handle = await open(directory, 'r') + await handle.sync() + } catch (error) { + if (!['EACCES', 'EBADF', 'EINVAL', 'EISDIR', 'ENOTSUP', 'EPERM'].includes(errorCode(error))) { + throw error + } + } finally { + await handle?.close() + } +} + +function errorCode(error: unknown): string { + return typeof error === 'object' && error !== null && 'code' in error ? String(error.code) : '' +} diff --git a/src/main/session-authority/terminal-legacy-worker-live-registration.ts b/src/main/session-authority/terminal-legacy-worker-live-registration.ts new file mode 100644 index 00000000000..42c9c9753cc --- /dev/null +++ b/src/main/session-authority/terminal-legacy-worker-live-registration.ts @@ -0,0 +1,57 @@ +import { isDeepStrictEqual } from 'node:util' +import type { + TerminalLegacyEndpointIdentity, + TerminalLegacyProcessIdentity, + TerminalLegacyWorkerRoute +} from '../../shared/terminal-legacy-cutover' +import { assertAuthorityId, isRecord } from '../../shared/terminal-session-authority-identity' +import { failTerminalSessionAuthority } from '../../shared/terminal-session-authority-mutation' + +export type TerminalLegacyWorkerLiveRegistration = Readonly<{ + registrationId: string + routeId: string + workerId: string + ownerIncarnationId: string + buildId: string + brokerConnectionIdentity: string + process: TerminalLegacyProcessIdentity + endpoint: TerminalLegacyEndpointIdentity +}> + +export function assertTerminalLegacyWorkerLiveRegistration( + value: unknown +): asserts value is TerminalLegacyWorkerLiveRegistration { + if (!isRecord(value) || !isRecord(value.process) || !isRecord(value.endpoint)) { + failTerminalSessionAuthority('expectation-mismatch', 'legacy live-worker proof is invalid') + } + for (const [field, selected] of [ + ['registrationId', value.registrationId], + ['routeId', value.routeId], + ['workerId', value.workerId], + ['ownerIncarnationId', value.ownerIncarnationId], + ['buildId', value.buildId], + ['brokerConnectionIdentity', value.brokerConnectionIdentity] + ] as const) { + assertAuthorityId(selected, `legacy live-worker ${field}`) + } +} + +export function assertTerminalLegacyWorkerMatchesRoute( + registration: TerminalLegacyWorkerLiveRegistration, + route: TerminalLegacyWorkerRoute | null +): asserts route is TerminalLegacyWorkerRoute { + if ( + !route || + registration.routeId !== route.routeId || + registration.workerId !== route.workerId || + registration.ownerIncarnationId !== route.ownerIncarnationId || + registration.buildId !== route.buildId || + !isDeepStrictEqual(registration.process, route.process) || + !isDeepStrictEqual(registration.endpoint, route.endpoint) + ) { + failTerminalSessionAuthority( + 'expectation-mismatch', + 'legacy live-worker proof does not match the authority route' + ) + } +} diff --git a/src/main/session-authority/terminal-session-authority-access.ts b/src/main/session-authority/terminal-session-authority-access.ts new file mode 100644 index 00000000000..c953d1b7083 --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-access.ts @@ -0,0 +1,115 @@ +import { assertAuthorityId } from '../../shared/terminal-session-authority-identity' +import { failTerminalSessionAuthority } from '../../shared/terminal-session-authority-mutation' + +export type TerminalAuthorityWriterAccess = Readonly<{ + role: 'writer' + serviceInstanceId: string + actorId: string + ownerToken: string + writerEpoch: number +}> + +export type TerminalAuthorityConsumerAccess = Readonly<{ + role: 'consumer' + serviceInstanceId: string + consumerId: string + consumerIncarnationId: string +}> + +export type TerminalAuthorityObserverAccess = Readonly<{ + role: 'observer' + serviceInstanceId: string + accessId: string + actorId: string +}> + +export class TerminalAuthorityRuntimeAccess { + private readonly observers = new Map() + + constructor( + private readonly serviceInstanceId: string, + readonly writer: TerminalAuthorityWriterAccess, + private readonly createId: () => string, + private readonly maxObservers: number + ) {} + + assertWriter(access: TerminalAuthorityWriterAccess): void { + if ( + access.role !== 'writer' || + access.serviceInstanceId !== this.serviceInstanceId || + access.actorId !== this.writer.actorId || + access.ownerToken !== this.writer.ownerToken || + access.writerEpoch !== this.writer.writerEpoch + ) { + failTerminalSessionAuthority('writer-fenced', 'authority writer access is stale') + } + } + + assertBindingAuthorityReader( + access: TerminalAuthorityWriterAccess | TerminalAuthorityObserverAccess + ): void { + if (access.role === 'writer') { + this.assertWriter(access) + return + } + this.assertObserver(access) + } + + consumer(consumerId: string, consumerIncarnationId: string): TerminalAuthorityConsumerAccess { + return Object.freeze({ + role: 'consumer', + serviceInstanceId: this.serviceInstanceId, + consumerId, + consumerIncarnationId + }) + } + + assertConsumerService(access: TerminalAuthorityConsumerAccess): void { + if (access.role !== 'consumer' || access.serviceInstanceId !== this.serviceInstanceId) { + failTerminalSessionAuthority( + 'consumer-conflict', + 'consumer access belongs to another service' + ) + } + } + + observe(actorId: string): TerminalAuthorityObserverAccess { + assertAuthorityId(actorId, 'observer actorId') + if (this.observers.size >= this.maxObservers) { + failTerminalSessionAuthority('capacity', 'authority observers are full') + } + const accessId = this.createId() + assertAuthorityId(accessId, 'authority access ID') + if (this.observers.has(accessId)) { + failTerminalSessionAuthority('operation-conflict', 'authority access ID was reused') + } + const access = Object.freeze({ + role: 'observer' as const, + serviceInstanceId: this.serviceInstanceId, + accessId, + actorId + }) + this.observers.set(accessId, access) + return access + } + + revokeObserver(access: TerminalAuthorityObserverAccess): void { + this.assertObserver(access) + this.observers.delete(access.accessId) + } + + assertObserver(access: TerminalAuthorityObserverAccess): void { + const registered = this.observers.get(access.accessId) + if ( + !registered || + registered.serviceInstanceId !== access.serviceInstanceId || + registered.actorId !== access.actorId + ) { + failTerminalSessionAuthority('writer-fenced', 'observer access is revoked') + } + } + + clear(): void { + this.observers.clear() + } +} diff --git a/src/main/session-authority/terminal-session-authority-authenticated-consumers.test.ts b/src/main/session-authority/terminal-session-authority-authenticated-consumers.test.ts new file mode 100644 index 00000000000..2f1f008e94e --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-authenticated-consumers.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it, vi } from 'vitest' +import type { TerminalAuthorityAuthenticatedNamespacePreparation } from './terminal-session-authority-authenticated-namespace-preparation' +import { TerminalSessionAuthorityAuthenticatedConsumers } from './terminal-session-authority-authenticated-consumers' +import type { TerminalSessionAuthorityPolicyConsumers } from './terminal-session-authority-policy-consumers' +import type { TerminalSessionAuthorityRegistry } from './terminal-session-authority-registry' + +describe('authenticated terminal authority consumer rollback', () => { + it('reports the primary transport-release cause with a failed pending rollback', async () => { + const report = vi.fn() + const consumers = new TerminalSessionAuthorityAuthenticatedConsumers( + {} as TerminalSessionAuthorityRegistry, + {} as TerminalSessionAuthorityPolicyConsumers, + report + ) + const rollbackFailure = new Error('pending rollback failed') + const preparation = { + rollback: async () => { + throw rollbackFailure + } + } as unknown as TerminalAuthorityAuthenticatedNamespacePreparation + const transportToken = {} + const state = consumers as unknown as { + pendingPreparations: Map> + } + state.pendingPreparations.set(transportToken, new Set([preparation])) + + consumers.releaseTransport(transportToken) + + await vi.waitFor(() => expect(report).toHaveBeenCalledOnce()) + const joined = report.mock.calls[0]?.[0] + expect(joined).toBeInstanceOf(AggregateError) + expect((joined as AggregateError).errors[1]).toBe(rollbackFailure) + }) +}) diff --git a/src/main/session-authority/terminal-session-authority-authenticated-consumers.ts b/src/main/session-authority/terminal-session-authority-authenticated-consumers.ts new file mode 100644 index 00000000000..ee837ce4d83 --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-authenticated-consumers.ts @@ -0,0 +1,269 @@ +import { + parseTerminalAuthorityNamespaceAdmissionProof, + parseTerminalAuthorityNamespaceAdmissionStart, + type TerminalAuthorityNamespaceAdmissionChallenge +} from '../../shared/terminal-session-authority-consumer-proof' +import { + parseTerminalAuthorityConsumerRetirementProof, + parseTerminalAuthorityConsumerRetirementStart, + type TerminalAuthorityConsumerRetirementChallenge, + type TerminalAuthorityConsumerRetirementResult +} from '../../shared/terminal-session-authority-consumer-retirement' +import { + TerminalSessionAuthorityConsumerAdmissions as ConsumerAdmissions, + type TerminalAuthorityAuthenticatedConsumerTransport, + type TerminalSessionAuthorityConsumerAdmissions +} from './terminal-session-authority-consumer-admission' +import type { TerminalSessionAuthorityRegistry } from './terminal-session-authority-registry' +import type { + TerminalAuthorityPolicyConsumerConnection, + TerminalAuthorityPolicyOutcomeTransport, + TerminalSessionAuthorityPolicyConsumers +} from './terminal-session-authority-policy-consumers' +import { terminalAuthorityHostAppConsumerId } from './terminal-session-authority-consumer-proof' +import { + terminalAuthorityAuthenticatedNamespacePreparation, + type TerminalAuthorityAuthenticatedNamespacePreparation +} from './terminal-session-authority-authenticated-namespace-preparation' +import { TerminalSessionAuthorityConsumerRetirements } from './terminal-session-authority-consumer-retirement' +import { joinTerminalAuthorityRollbackFailure } from './terminal-session-authority-consumer-rollback-failure' +import { + TerminalSessionAuthorityAuthenticatedSessions, + type TerminalAuthorityAuthenticatedNamespaceSession +} from './terminal-session-authority-authenticated-sessions' + +export type { TerminalAuthorityAuthenticatedNamespaceSession } from './terminal-session-authority-authenticated-sessions' + +export type { TerminalAuthorityAuthenticatedNamespacePreparation } from './terminal-session-authority-authenticated-namespace-preparation' + +export class TerminalSessionAuthorityAuthenticatedConsumers { + private admissions: TerminalSessionAuthorityConsumerAdmissions | null = null + private retirements: TerminalSessionAuthorityConsumerRetirements | null = null + private authorityHostId: string | null = null + private readonly sessions = new TerminalSessionAuthorityAuthenticatedSessions() + private readonly pendingPreparations = new Map< + object, + Set + >() + + constructor( + private readonly registry: TerminalSessionAuthorityRegistry, + private readonly policyConsumers: TerminalSessionAuthorityPolicyConsumers, + private readonly onRollbackFailure: (error: unknown) => void = reportRollbackFailure + ) {} + + async issueChallenge( + unsafeStart: unknown, + transport: TerminalAuthorityAuthenticatedConsumerTransport + ): Promise { + const start = parseTerminalAuthorityNamespaceAdmissionStart(unsafeStart) + if (!start) { + throw new Error('terminal authority namespace admission start is invalid') + } + const service = await this.registry.openNamespace(start.namespace) + return this.admissionsFor(start.namespace.authorityHostId).issueChallenge( + service, + start, + transport + ) + } + + async openNamespace( + unsafeProof: unknown, + authenticatedTransport: TerminalAuthorityAuthenticatedConsumerTransport, + outcomeTransport: TerminalAuthorityPolicyOutcomeTransport + ): Promise { + const preparation = await this.prepareNamespace( + unsafeProof, + authenticatedTransport, + outcomeTransport + ) + return await preparation.commit() + } + + async issueRetirementChallenge( + unsafeStart: unknown, + transport: TerminalAuthorityAuthenticatedConsumerTransport + ): Promise { + const start = parseTerminalAuthorityConsumerRetirementStart(unsafeStart) + if (!start) { + throw new Error('terminal authority consumer retirement start is invalid') + } + const service = await this.registry.openNamespace(start.namespace) + const admissions = this.admissionsFor(start.namespace.authorityHostId) + const consumerId = terminalAuthorityHostAppConsumerId( + start.namespace.authorityHostId, + Uint8Array.from(Buffer.from(start.appPublicKeyB64, 'base64')) + ) + return this.retirementsFor(start.namespace.authorityHostId).issueChallenge( + service, + start, + transport, + admissions.liveGrant(transport.token, consumerId, start.namespace) + ) + } + + async retireConsumer( + unsafeProof: unknown, + transport: TerminalAuthorityAuthenticatedConsumerTransport + ): Promise { + const proof = parseTerminalAuthorityConsumerRetirementProof(unsafeProof) + if (!proof) { + throw new Error('terminal authority consumer retirement proof is invalid') + } + const service = await this.registry.openNamespace(proof.challenge.namespace) + const admissions = this.admissionsFor(proof.challenge.namespace.authorityHostId) + const liveGrant = admissions.liveGrant( + transport.token, + proof.challenge.consumerId, + proof.challenge.namespace + ) + const result = await this.retirementsFor(proof.challenge.namespace.authorityHostId).complete( + service, + proof, + transport, + liveGrant + ) + this.sessions.findByConsumer(transport.token, result.consumerId, result.namespace)?.disconnect() + return result + } + + async prepareNamespace( + unsafeProof: unknown, + authenticatedTransport: TerminalAuthorityAuthenticatedConsumerTransport, + outcomeTransport: TerminalAuthorityPolicyOutcomeTransport + ): Promise { + const proof = parseTerminalAuthorityNamespaceAdmissionProof(unsafeProof) + if (!proof) { + throw new Error('terminal authority namespace admission proof is invalid') + } + const service = await this.registry.openNamespace(proof.challenge.namespace) + const admissions = this.admissionsFor(proof.challenge.namespace.authorityHostId) + const prepared = admissions.prepare(service, proof, authenticatedTransport) + const replayed = prepared.grant.replayed + ? this.sessions.find(authenticatedTransport.token, prepared.grant) + : null + if (replayed) { + return Object.freeze({ + grant: prepared.grant, + policyConsumer: replayed.policyConsumer, + commit: async () => replayed, + rollback: async () => {} + }) + } + let policyConsumer: TerminalAuthorityPolicyConsumerConnection | null = null + try { + policyConsumer = await this.policyConsumers.connect(prepared.claim, outcomeTransport) + await policyConsumer.activate() + if (!policyConsumer.prepareNamespace) { + throw new Error('terminal authority policy consumer staging is unavailable') + } + const policyPreparation = await policyConsumer.prepareNamespace( + service, + prepared.admissionSeal + ) + const preparation = terminalAuthorityAuthenticatedNamespacePreparation({ + prepared, + policyPreparation, + policyConsumer, + transport: authenticatedTransport, + liveGrant: () => + admissions.liveGrant( + authenticatedTransport.token, + prepared.grant.consumer.consumerId, + prepared.grant.namespace + ), + releaseNamespace: () => + admissions.releaseNamespace( + authenticatedTransport.token, + prepared.grant.consumer.consumerId, + prepared.grant.namespace + ), + remember: (release) => + this.sessions.remember(authenticatedTransport, prepared.grant, policyConsumer!, release), + settled: () => this.untrackPending(authenticatedTransport.token, preparation) + }) + this.trackPending(authenticatedTransport.token, preparation) + return preparation + } catch (error) { + return await joinTerminalAuthorityRollbackFailure(error, async () => { + try { + policyConsumer?.disconnect() + } finally { + prepared.rollback() + } + }) + } + } + + releaseTransport(transportToken: object): void { + const pending = this.pendingPreparations.get(transportToken) + this.pendingPreparations.delete(transportToken) + for (const preparation of pending ?? []) { + const cause = new Error( + 'terminal authority authenticated consumer transport released during admission' + ) + void joinTerminalAuthorityRollbackFailure(cause, () => preparation.rollback()).catch( + (failure) => { + if (failure !== cause) { + this.onRollbackFailure(failure) + } + } + ) + } + this.sessions.releaseTransport(transportToken) + this.admissions?.releaseTransport(transportToken) + this.retirements?.releaseTransport(transportToken) + } + + dispose(): void { + const tokens = new Set([...this.sessions.transportTokens(), ...this.pendingPreparations.keys()]) + for (const token of tokens) { + this.releaseTransport(token) + } + } + + private trackPending( + transportToken: object, + preparation: TerminalAuthorityAuthenticatedNamespacePreparation + ): void { + const pending = this.pendingPreparations.get(transportToken) ?? new Set() + pending.add(preparation) + this.pendingPreparations.set(transportToken, pending) + } + + private untrackPending( + transportToken: object, + preparation: TerminalAuthorityAuthenticatedNamespacePreparation + ): void { + const pending = this.pendingPreparations.get(transportToken) + if (!pending) { + return + } + pending.delete(preparation) + if (pending.size === 0) { + this.pendingPreparations.delete(transportToken) + } + } + + private admissionsFor(authorityHostId: string): TerminalSessionAuthorityConsumerAdmissions { + if (this.authorityHostId !== null && this.authorityHostId !== authorityHostId) { + throw new Error('terminal authority authenticated consumer host changed') + } + if (!this.admissions) { + this.authorityHostId = authorityHostId + this.admissions = new ConsumerAdmissions(authorityHostId) + } + return this.admissions + } + + private retirementsFor(authorityHostId: string): TerminalSessionAuthorityConsumerRetirements { + this.admissionsFor(authorityHostId) + this.retirements ??= new TerminalSessionAuthorityConsumerRetirements(authorityHostId) + return this.retirements + } +} + +function reportRollbackFailure(error: unknown): void { + console.error('terminal authority authenticated consumer rollback failed', error) +} diff --git a/src/main/session-authority/terminal-session-authority-authenticated-namespace-preparation.ts b/src/main/session-authority/terminal-session-authority-authenticated-namespace-preparation.ts new file mode 100644 index 00000000000..6f971477cd5 --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-authenticated-namespace-preparation.ts @@ -0,0 +1,127 @@ +import type { TerminalAuthorityNamespaceAdmissionGrant } from '../../shared/terminal-session-authority-consumer-proof' +import type { TerminalAuthorityAuthenticatedNamespaceSession } from './terminal-session-authority-authenticated-sessions' +import type { + TerminalAuthorityAdmissionLiveGrant, + TerminalAuthorityNamespaceAdmissionPreparation +} from './terminal-session-authority-consumer-admission-state' +import type { TerminalAuthorityAuthenticatedConsumerTransport } from './terminal-session-authority-consumer-admission-request' +import { joinTerminalAuthorityRollbackFailure } from './terminal-session-authority-consumer-rollback-failure' +import type { + TerminalAuthorityPolicyConsumerConnection, + TerminalAuthorityPolicyNamespacePreparation +} from './terminal-session-authority-policy-consumers' + +export type TerminalAuthorityAuthenticatedNamespacePreparation = Readonly<{ + grant: TerminalAuthorityNamespaceAdmissionGrant + policyConsumer: TerminalAuthorityPolicyConsumerConnection + commit(): Promise + rollback(): Promise +}> + +type PreparationInput = Readonly<{ + prepared: TerminalAuthorityNamespaceAdmissionPreparation + policyPreparation: TerminalAuthorityPolicyNamespacePreparation + policyConsumer: TerminalAuthorityPolicyConsumerConnection + transport: TerminalAuthorityAuthenticatedConsumerTransport + liveGrant(): TerminalAuthorityAdmissionLiveGrant | null + releaseNamespace(): void + remember(release: () => void): TerminalAuthorityAuthenticatedNamespaceSession + settled(): void +}> + +/** + * The authenticated admission's commit gate. `policyPreparation.commit()` settles the grant, the + * exact-retry publication, and the durable claim in one serialized namespace operation, so nothing + * here re-commits admission state. + */ +export function terminalAuthorityAuthenticatedNamespacePreparation( + input: PreparationInput +): TerminalAuthorityAuthenticatedNamespacePreparation { + const { prepared, policyPreparation, policyConsumer } = input + let state: 'prepared' | 'committing' | 'committed' | 'closed' = 'prepared' + let committedSession: TerminalAuthorityAuthenticatedNamespaceSession | null = null + let commitPromise: Promise | null = null + const assertAdmissionLive = (): void => { + const live = input.liveGrant() + if ( + !live || + live.connectionGrantId !== prepared.grant.connectionGrantId || + live.requestId !== prepared.grant.requestId || + live.processIncarnationId !== prepared.grant.consumer.consumerIncarnationId + ) { + throw new Error('terminal authority namespace admission was canceled') + } + } + return Object.freeze({ + grant: prepared.grant, + policyConsumer, + commit: () => { + if (state === 'committed') { + return Promise.resolve(committedSession!) + } + if (state === 'committing') { + return commitPromise! + } + if (state !== 'prepared') { + return Promise.reject( + new Error('terminal authority namespace admission preparation is stale') + ) + } + state = 'committing' + commitPromise = (async () => { + try { + await policyPreparation.commit() + assertAdmissionLive() + committedSession = input.remember(() => input.releaseNamespace()) + state = 'committed' + input.settled() + return committedSession + } catch (error) { + state = 'closed' + input.settled() + // Releasing the namespace is not a rewind: only the in-memory grant goes, and the durable + // claim it settled with stands for a fresh proof to resume. + return await joinTerminalAuthorityRollbackFailure(error, async () => { + await policyPreparation.rollback() + if (prepared.published) { + input.releaseNamespace() + } + prepared.rollback() + policyConsumer.disconnect() + }) + } + })() + return commitPromise + }, + rollback: async () => { + if (state === 'closed') { + return + } + if (state === 'committing') { + try { + const session = await commitPromise! + session.disconnect() + } catch { + // Commit cleanup already closed the preparation. + } + state = 'closed' + input.settled() + return + } + if (state === 'committed') { + committedSession?.disconnect() + state = 'closed' + input.settled() + return + } + state = 'closed' + input.settled() + try { + await policyPreparation.rollback() + } finally { + policyConsumer.disconnect() + prepared.rollback() + } + } + }) +} diff --git a/src/main/session-authority/terminal-session-authority-authenticated-sessions.ts b/src/main/session-authority/terminal-session-authority-authenticated-sessions.ts new file mode 100644 index 00000000000..ac52f8e6df6 --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-authenticated-sessions.ts @@ -0,0 +1,97 @@ +import type { TerminalAuthorityNamespaceAdmissionGrant } from '../../shared/terminal-session-authority-consumer-proof' +import type { TerminalAuthorityConsumerRetirementResult } from '../../shared/terminal-session-authority-consumer-retirement' +import type { TerminalAuthorityAuthenticatedConsumerTransport } from './terminal-session-authority-consumer-admission' +import type { TerminalAuthorityPolicyConsumerConnection } from './terminal-session-authority-policy-consumers' + +export type TerminalAuthorityAuthenticatedNamespaceSession = Readonly<{ + grant: TerminalAuthorityNamespaceAdmissionGrant + policyConsumer: TerminalAuthorityPolicyConsumerConnection + disconnect(): void +}> + +export class TerminalSessionAuthorityAuthenticatedSessions { + private readonly byTransport = new Map< + object, + Set + >() + + remember( + transport: TerminalAuthorityAuthenticatedConsumerTransport, + grant: TerminalAuthorityNamespaceAdmissionGrant, + policyConsumer: TerminalAuthorityPolicyConsumerConnection, + releaseNamespace: () => void + ): TerminalAuthorityAuthenticatedNamespaceSession { + let active = true + let session!: TerminalAuthorityAuthenticatedNamespaceSession + session = Object.freeze({ + grant, + policyConsumer, + disconnect: () => { + if (!active) { + return + } + active = false + policyConsumer.disconnect() + releaseNamespace() + const sessions = this.byTransport.get(transport.token) + sessions?.delete(session) + if (sessions?.size === 0) { + this.byTransport.delete(transport.token) + } + } + }) + const sessions = this.byTransport.get(transport.token) ?? new Set() + sessions.add(session) + this.byTransport.set(transport.token, sessions) + return session + } + + find( + transportToken: object, + grant: TerminalAuthorityNamespaceAdmissionGrant + ): TerminalAuthorityAuthenticatedNamespaceSession | null { + for (const session of this.byTransport.get(transportToken) ?? []) { + if ( + session.grant.connectionGrantId === grant.connectionGrantId && + session.grant.consumer.consumerId === grant.consumer.consumerId && + session.grant.consumer.consumerIncarnationId === grant.consumer.consumerIncarnationId && + session.grant.namespace.authorityHostId === grant.namespace.authorityHostId && + session.grant.namespace.namespaceId === grant.namespace.namespaceId + ) { + return session + } + } + return null + } + + findByConsumer( + transportToken: object, + consumerId: string, + namespace: TerminalAuthorityConsumerRetirementResult['namespace'] + ): TerminalAuthorityAuthenticatedNamespaceSession | null { + for (const session of this.byTransport.get(transportToken) ?? []) { + if ( + session.grant.consumer.consumerId === consumerId && + session.grant.namespace.authorityHostId === namespace.authorityHostId && + session.grant.namespace.namespaceId === namespace.namespaceId + ) { + return session + } + } + return null + } + + releaseTransport(transportToken: object): void { + const sessions = this.byTransport.get(transportToken) + if (sessions) { + for (const session of sessions) { + session.disconnect() + } + } + this.byTransport.delete(transportToken) + } + + transportTokens(): readonly object[] { + return [...this.byTransport.keys()] + } +} diff --git a/src/main/session-authority/terminal-session-authority-boundary-acceptance.ts b/src/main/session-authority/terminal-session-authority-boundary-acceptance.ts new file mode 100644 index 00000000000..5ec31c0626e --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-boundary-acceptance.ts @@ -0,0 +1,119 @@ +import { + sameTerminalAuthorityPolicyConsumer, + type TerminalAuthorityNamespaceBoundaryAcceptance, + type TerminalAuthorityNamespaceOutcomeBoundary +} from '../../shared/terminal-session-authority-consumer-transport' +import { terminalSessionAuthorityBoundaryId } from '../../shared/terminal-session-authority-boundary-identity' + +const DEFAULT_ACCEPTANCE_TIMEOUT_MS = 30_000 +const MAX_ACCEPTED_BOUNDARIES = 4_096 + +type PendingAcceptance = Readonly<{ + boundary: TerminalAuthorityNamespaceOutcomeBoundary + promise: Promise + resolve(): void + reject(error: Error): void + timer: ReturnType +}> + +export class TerminalSessionAuthorityBoundaryAcceptances { + private readonly pending = new Map() + private readonly accepted = new Map() + private closed = false + + constructor(private readonly timeoutMs = DEFAULT_ACCEPTANCE_TIMEOUT_MS) {} + + wait(boundary: TerminalAuthorityNamespaceOutcomeBoundary): Promise { + this.assertBoundaryIdentity(boundary) + if (this.closed) { + return Promise.reject(new Error('terminal authority boundary transport is unavailable')) + } + const boundaryId = boundary.boundaryId! + if (this.accepted.has(boundaryId)) { + return Promise.resolve() + } + const existing = this.pending.get(boundaryId) + if (existing) { + return existing.promise + } + let resolve!: () => void + let reject!: (error: Error) => void + const promise = new Promise((settle, fail) => { + resolve = settle + reject = fail + }) + const timer = setTimeout(() => { + this.pending.delete(boundaryId) + reject(new Error('terminal authority boundary acceptance timed out')) + }, this.timeoutMs) + timer.unref?.() + this.pending.set(boundaryId, { boundary, promise, resolve, reject, timer }) + return promise + } + + accept(acceptance: TerminalAuthorityNamespaceBoundaryAcceptance): void { + const key = acceptanceKey(acceptance) + const accepted = this.accepted.get(acceptance.boundaryId) + if (accepted) { + if (accepted !== key) { + throw new Error('terminal authority boundary acceptance conflicts') + } + return + } + const pending = this.pending.get(acceptance.boundaryId) + if (!pending || !matchesBoundary(pending.boundary, acceptance)) { + throw new Error('terminal authority boundary acceptance is unauthorized') + } + clearTimeout(pending.timer) + this.pending.delete(acceptance.boundaryId) + if (this.accepted.size >= MAX_ACCEPTED_BOUNDARIES) { + pending.reject(new Error('terminal authority accepted boundary capacity is full')) + throw new Error('terminal authority accepted boundary capacity is full') + } + this.accepted.set(acceptance.boundaryId, key) + pending.resolve() + } + + close(error = new Error('terminal authority boundary transport disconnected')): void { + if (this.closed) { + return + } + this.closed = true + for (const pending of this.pending.values()) { + clearTimeout(pending.timer) + pending.reject(error) + } + this.pending.clear() + this.accepted.clear() + } + + private assertBoundaryIdentity(boundary: TerminalAuthorityNamespaceOutcomeBoundary): void { + const { boundaryId, ...unsigned } = boundary + if (!boundaryId || terminalSessionAuthorityBoundaryId(unsigned) !== boundaryId) { + throw new Error('terminal authority boundary identity is invalid') + } + } +} + +function matchesBoundary( + boundary: TerminalAuthorityNamespaceOutcomeBoundary, + acceptance: TerminalAuthorityNamespaceBoundaryAcceptance +): boolean { + return ( + boundary.boundaryId === acceptance.boundaryId && + sameTerminalAuthorityPolicyConsumer(boundary.consumer, acceptance.consumer) && + boundary.namespace.authorityHostId === acceptance.namespace.authorityHostId && + boundary.namespace.namespaceId === acceptance.namespace.namespaceId && + boundary.acknowledgedSequence === acceptance.acknowledgedSequence && + boundary.outcomeHighWatermark === acceptance.outcomeHighWatermark + ) +} + +function acceptanceKey(acceptance: TerminalAuthorityNamespaceBoundaryAcceptance): string { + return JSON.stringify([ + acceptance.consumer, + acceptance.namespace, + acceptance.acknowledgedSequence, + acceptance.outcomeHighWatermark + ]) +} diff --git a/src/main/session-authority/terminal-session-authority-consumer-admission-preparation.ts b/src/main/session-authority/terminal-session-authority-consumer-admission-preparation.ts new file mode 100644 index 00000000000..819cc9e25c5 --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-consumer-admission-preparation.ts @@ -0,0 +1,128 @@ +import { TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION } from '../../shared/terminal-session-authority-consumer-proof' +import type { TerminalAuthorityNamespaceAdmissionProof } from '../../shared/terminal-session-authority-consumer-proof' +import { terminalAuthorityAdmissionCas } from './terminal-session-authority-consumer-proof' +import type { TerminalAuthorityAuthenticatedConsumerTransport } from './terminal-session-authority-consumer-admission-request' +import type { + TerminalAuthorityAdmissionChallengeEntry, + TerminalAuthorityAdmissionReplayEntry, + TerminalAuthorityAdmissionReservation, + TerminalAuthorityNamespaceAdmissionPreparation, + TerminalSessionAuthorityConsumerAdmissionState +} from './terminal-session-authority-consumer-admission-state' +import type { TerminalSessionAuthorityService } from './terminal-session-authority-service' + +type PreparationInput = Readonly<{ + state: TerminalSessionAuthorityConsumerAdmissionState + service: TerminalSessionAuthorityService + entry: TerminalAuthorityAdmissionChallengeEntry + proof: TerminalAuthorityNamespaceAdmissionProof + transport: TerminalAuthorityAuthenticatedConsumerTransport + requestKey: string + proofDigest: string + namespaceKey: string + reservation: TerminalAuthorityAdmissionReservation +}> + +/** + * The verified admission's seal: it holds the namespace reservation, and only its `commit` publishes + * the live grant and the exact-retry result, so nothing is observable until the durable claim lands. + */ +export function terminalAuthorityAdmissionPreparation( + input: PreparationInput +): TerminalAuthorityNamespaceAdmissionPreparation { + const { state, service, entry, proof, transport, requestKey, namespaceKey, reservation } = input + const claim = Object.freeze({ + version: TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION, + consumer: Object.freeze({ + consumerId: entry.consumerId, + consumerIncarnationId: proof.challenge.candidateProcessIncarnationId + }), + expectedConsumerIncarnationId: entry.expectedIncarnationId + }) + const grant = Object.freeze({ + version: TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION, + consumer: claim.consumer, + namespace: Object.freeze({ ...proof.challenge.namespace }), + requestId: proof.challenge.requestId, + connectionGrantId: proof.challenge.connectionGrantId, + admissionCas: terminalAuthorityAdmissionCas( + proof.challenge.namespace, + entry.consumerId, + proof.challenge.candidateProcessIncarnationId + ), + replayed: false + }) + const liveGrant = Object.freeze({ + connectionGrantId: transport.connectionGrantId, + requestId: proof.challenge.requestId, + processIncarnationId: claim.consumer.consumerIncarnationId, + sessionNonce: proof.challenge.candidateSessionNonce, + transportToken: transport.token, + requestKey + }) + const replayEntry = Object.freeze({ + challenge: entry.challenge, + startDigest: entry.startDigest, + proofDigest: input.proofDigest, + claim, + grant, + transportToken: transport.token + }) + let phase: 'prepared' | 'sealed' | 'committed' | 'released' = 'prepared' + const release = (): void => { + if (phase === 'committed' || phase === 'released') { + return + } + phase = 'released' + state.releaseReservation(namespaceKey, reservation) + } + return Object.freeze({ + claim, + grant, + get published() { + return phase === 'committed' + }, + admissionSeal: Object.freeze({ + // Publishes nothing, so an exact retry racing the pending append still sees no grant. + seal: () => { + if (phase !== 'prepared') { + throw new Error('terminal authority namespace admission preparation is stale') + } + state.assertReservation(namespaceKey, reservation) + if ( + service.activeConsumerIncarnation(service.writerAccess, entry.consumerId) !== + entry.expectedIncarnationId + ) { + throw new Error('terminal authority namespace admission CAS changed') + } + phase = 'sealed' + }, + commit: () => { + if (phase === 'committed') { + return + } + phase = 'committed' + state.releaseReservation(namespaceKey, reservation) + state.installLiveGrant(namespaceKey, liveGrant) + state.rememberReplay(requestKey, replayEntry) + state.deleteChallenge(requestKey) + }, + // The challenge survives: the claim provably never landed, so an exact retry re-presents it. + abort: release + }), + rollback: release + }) +} + +// Inert: the original commit already published the grant and retry result. +export function replayTerminalAuthorityAdmissionPreparation( + entry: TerminalAuthorityAdmissionReplayEntry +): TerminalAuthorityNamespaceAdmissionPreparation { + return Object.freeze({ + claim: entry.claim, + grant: Object.freeze({ ...entry.grant, replayed: true }), + published: true, + admissionSeal: Object.freeze({ seal: () => {}, commit: () => {}, abort: () => {} }), + rollback: () => {} + }) +} diff --git a/src/main/session-authority/terminal-session-authority-consumer-admission-request.ts b/src/main/session-authority/terminal-session-authority-consumer-admission-request.ts new file mode 100644 index 00000000000..b395aacea98 --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-consumer-admission-request.ts @@ -0,0 +1,78 @@ +import { createHash } from 'node:crypto' +import { + assertAuthorityId, + type TerminalAuthorityNamespace +} from '../../shared/terminal-session-authority-identity' +import type { TerminalSessionAuthorityService } from './terminal-session-authority-service' + +export type TerminalAuthorityAuthenticatedConsumerTransport = Readonly<{ + connectionGrantId: string + principal: string + capability: string + token: object +}> + +export function assertTerminalAuthorityHostId(authorityHostId: string): void { + assertAuthorityId(authorityHostId, 'authorityHostId') +} + +export function assertTerminalAuthorityConsumerTransport( + transport: TerminalAuthorityAuthenticatedConsumerTransport +): void { + assertAuthorityId(transport.connectionGrantId, 'connectionGrantId') + assertAuthorityId(transport.principal, 'authenticated transport principal') + assertAuthorityId(transport.capability, 'authenticated transport capability') +} + +export function assertTerminalAuthorityAdmissionServiceNamespace( + service: TerminalSessionAuthorityService, + namespace: TerminalAuthorityNamespace +): void { + if ( + service.namespace.authorityHostId !== namespace.authorityHostId || + service.namespace.namespaceId !== namespace.namespaceId + ) { + throw new Error('terminal authority namespace admission targets another namespace') + } +} + +export function assertTerminalAuthorityAdmissionTransportToken( + expected: object, + transport: TerminalAuthorityAuthenticatedConsumerTransport +): void { + if (expected !== transport.token) { + throw new Error('terminal authority namespace admission transport changed') + } +} + +export function assertExactTerminalAuthorityAdmissionRequest( + expected: string, + actual: string +): void { + if (expected !== actual) { + throw new Error('terminal authority namespace admission request changed') + } +} + +export function terminalAuthorityAdmissionRequestKey( + namespace: TerminalAuthorityNamespace, + requestId: string, + transport: TerminalAuthorityAuthenticatedConsumerTransport +): string { + return JSON.stringify([ + transport.connectionGrantId, + namespace.authorityHostId, + namespace.namespaceId, + requestId + ]) +} + +export function terminalAuthorityAdmissionChallengeScopeKey( + transport: TerminalAuthorityAuthenticatedConsumerTransport +): string { + return JSON.stringify([transport.principal, transport.capability]) +} + +export function terminalAuthorityAdmissionDigest(value: unknown): string { + return createHash('sha256').update(JSON.stringify(value)).digest('base64url') +} diff --git a/src/main/session-authority/terminal-session-authority-consumer-admission-seal.test.ts b/src/main/session-authority/terminal-session-authority-consumer-admission-seal.test.ts new file mode 100644 index 00000000000..80c2c7b57fb --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-consumer-admission-seal.test.ts @@ -0,0 +1,198 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { + TerminalSessionAuthorityLogEvent, + TerminalSessionAuthoritySnapshot +} from '../../shared/terminal-session-authority-mutation' +import type { TerminalSessionAuthorityState } from '../../shared/terminal-session-authority-state' +import type { TerminalAuthorityFileStore } from './terminal-session-authority-file-store' +import { TerminalAuthorityMutationPersistence } from './terminal-session-authority-mutation-persistence' +import { TerminalSessionAuthorityRegistry } from './terminal-session-authority-registry' +import type { TerminalSessionAuthorityService } from './terminal-session-authority-service' +import type { TerminalSessionAuthorityOutcomeConsumers } from './terminal-session-authority-outcome-consumers' +import { terminalAuthorityWorkspaceLocator } from './terminal-session-authority-workspace-locator' +import type { TerminalAuthorityConsumerAdmissionSeal } from './terminal-session-authority-consumer-admission-seal' + +const directories: string[] = [] +const registries: TerminalSessionAuthorityRegistry[] = [] + +afterEach(async () => { + await Promise.allSettled(registries.splice(0).map((registry) => registry.close())) + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } + vi.restoreAllMocks() +}) + +describe('terminal authority consumer admission seal', () => { + it('publishes nothing while the durable append is still pending', async () => { + const service = await openService() + const phases: string[] = [] + const seal = recordingSeal(phases) + let releaseAppend!: () => void + const appendGate = new Promise((resolve) => { + releaseAppend = resolve + }) + const consumers = outcomeConsumers(service) + const appendClaim = consumers.appendClaim.bind(consumers) + vi.spyOn(consumers, 'appendClaim').mockImplementation(async (plan) => { + phases.push('append:start') + await appendGate + await appendClaim(plan) + phases.push('append:settled') + }) + + const admission = service.commitConsumerAdmission(service.writerAccess, claim(), seal) + try { + await vi.waitFor(() => expect(phases).toContain('append:start')) + // The seal is taken but nothing is published: a racing exact retry can observe no grant yet. + expect(phases).toEqual(['seal', 'append:start']) + expect(service.activeConsumerIncarnation(service.writerAccess, claim().consumerId)).toBeNull() + } finally { + releaseAppend() + } + await admission + expect(phases).toEqual(['seal', 'append:start', 'append:settled', 'commit']) + }) + + it('aborts the seal and appends nothing when the claim is definitely not durable', async () => { + const service = await openService() + const phases: string[] = [] + const consumers = outcomeConsumers(service) + vi.spyOn(consumers, 'appendClaim').mockRejectedValue(new Error('disk is gone')) + + await expect( + service.commitConsumerAdmission(service.writerAccess, claim(), recordingSeal(phases)) + ).rejects.toThrow('disk is gone') + + expect(phases).toEqual(['seal', 'abort']) + expect(service.activeConsumerIncarnation(service.writerAccess, claim().consumerId)).toBeNull() + }) + + it('admits exactly one of two admissions racing the same CAS', async () => { + const service = await openService() + const results = await Promise.allSettled([ + service.commitConsumerAdmission(service.writerAccess, claim('incarnation-a')), + service.commitConsumerAdmission(service.writerAccess, claim('incarnation-b')) + ]) + + expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1) + expect(service.activeConsumerIncarnation(service.writerAccess, claim().consumerId)).toBe( + 'incarnation-a' + ) + }) + + it('keeps an exact repeat of the live claim durable-write free', async () => { + const service = await openService() + await service.commitConsumerAdmission(service.writerAccess, claim()) + const consumers = outcomeConsumers(service) + const planClaim = vi.spyOn(consumers, 'planClaim') + + await service.commitConsumerAdmission(service.writerAccess, { + consumerId: claim().consumerId, + expectedIncarnationId: 'incarnation-a', + consumerIncarnationId: 'incarnation-a' + }) + + expect((await planClaim.mock.results[0]!.value).event).toBeNull() + }) +}) + +describe('terminal authority durable append ambiguity', () => { + it('fences the service instead of unwinding when a write lands but its apply fails', async () => { + const crashes: number[] = [] + const applied: TerminalSessionAuthorityLogEvent[] = [] + const store = { + append: async (event: TerminalSessionAuthorityLogEvent) => { + applied.push(event) + return event + }, + shouldCompact: false, + compact: async () => {}, + assertWriterCurrent: async () => {}, + close: async () => {} + } as unknown as TerminalAuthorityFileStore + const state = { + applyEvent: () => { + throw new Error('projection apply failed') + }, + snapshot: () => ({}) as TerminalSessionAuthoritySnapshot + } as unknown as TerminalSessionAuthorityState + const persistence = new TerminalAuthorityMutationPersistence(state, store, () => + crashes.push(1) + ) + + await expect( + persistence.append({ kind: 'consumer-claim' } as unknown as TerminalSessionAuthorityLogEvent) + ).rejects.toThrow('projection apply failed') + + // The record is on disk, so recovery reopens from the log rather than rewinding a landed write. + expect(applied).toHaveLength(1) + expect(crashes).toHaveLength(1) + }) + + it('fences the service when compaction fails after the record is durable', async () => { + const crashes: number[] = [] + const store = { + append: async (event: TerminalSessionAuthorityLogEvent) => event, + shouldCompact: true, + compact: async () => { + throw new Error('compaction failed') + }, + assertWriterCurrent: async () => {}, + close: async () => {} + } as unknown as TerminalAuthorityFileStore + const state = { + applyEvent: () => {}, + snapshot: () => ({}) as TerminalSessionAuthoritySnapshot + } as unknown as TerminalSessionAuthorityState + const persistence = new TerminalAuthorityMutationPersistence(state, store, () => + crashes.push(1) + ) + + await expect( + persistence.append({ kind: 'consumer-claim' } as unknown as TerminalSessionAuthorityLogEvent) + ).rejects.toThrow('compaction failed') + expect(crashes).toHaveLength(1) + }) +}) + +function recordingSeal(phases: string[]): TerminalAuthorityConsumerAdmissionSeal { + return Object.freeze({ + seal: () => void phases.push('seal'), + commit: () => void phases.push('commit'), + abort: () => void phases.push('abort') + }) +} + +function claim(consumerIncarnationId = 'incarnation-a') { + return { + consumerId: 'app-profile:seal-test', + expectedIncarnationId: null, + consumerIncarnationId + } +} + +function outcomeConsumers( + service: TerminalSessionAuthorityService +): TerminalSessionAuthorityOutcomeConsumers { + return (service as unknown as { outcomeConsumers: TerminalSessionAuthorityOutcomeConsumers }) + .outcomeConsumers +} + +async function openService(): Promise { + const directory = mkdtempSync(path.join(tmpdir(), 'orca-admission-seal-')) + directories.push(directory) + const registry = await TerminalSessionAuthorityRegistry.open({ + directory: path.join(directory, 'authority'), + authorityHostId: 'host-a', + ownerToken: 'owner-token-a', + ownerIncarnationId: 'owner-a', + writerActorId: 'owner-a' + }) + registries.push(registry) + const resolved = await registry.resolveNamespace(terminalAuthorityWorkspaceLocator(directory)) + return await registry.openNamespace(resolved.namespace) +} diff --git a/src/main/session-authority/terminal-session-authority-consumer-admission-seal.ts b/src/main/session-authority/terminal-session-authority-consumer-admission-seal.ts new file mode 100644 index 00000000000..81083db68e7 --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-consumer-admission-seal.ts @@ -0,0 +1,53 @@ +import type { TerminalAuthorityConsumerAccess } from './terminal-session-authority-access' + +/** + * The proof-bound live state a durable `consumer-claim` publishes with: the connection-bound grant, + * the exact-retry publication, the pump's consumer, and the connection's namespace ownership. The + * service runs all of it inside one namespace-queue operation. + * + * `seal` reserves and may throw; nothing is visible yet, so a concurrent exact retry cannot observe + * authority while the append is pending. `commit` runs only after the append and must not throw, so + * nothing fallible stands between a durable claim and the grant that authorizes it. `abort` releases + * a seal that never committed. + */ +export type TerminalAuthorityConsumerAdmissionSeal = Readonly<{ + seal(): void + commit(claimed: TerminalAuthorityConsumerAccess): void + abort(): void +}> + +export function composeTerminalAuthorityConsumerAdmissionSeals( + seals: readonly (TerminalAuthorityConsumerAdmissionSeal | null | undefined)[] +): TerminalAuthorityConsumerAdmissionSeal { + const present = seals.filter((seal): seal is TerminalAuthorityConsumerAdmissionSeal => + Boolean(seal) + ) + return Object.freeze({ + seal: () => { + const sealed: TerminalAuthorityConsumerAdmissionSeal[] = [] + try { + for (const entry of present) { + entry.seal() + sealed.push(entry) + } + } catch (error) { + abortAll(sealed) + throw error + } + }, + commit: (claimed) => { + for (const entry of present) { + entry.commit(claimed) + } + }, + abort: () => abortAll(present) + }) +} + +// Reverse order, so a component releases before whatever it was sealed on top of. Abort runs while a +// pre-append error is already unwinding, and every release is a map delete. +function abortAll(seals: readonly TerminalAuthorityConsumerAdmissionSeal[]): void { + for (let index = seals.length - 1; index >= 0; index--) { + seals[index]!.abort() + } +} diff --git a/src/main/session-authority/terminal-session-authority-consumer-admission-state.ts b/src/main/session-authority/terminal-session-authority-consumer-admission-state.ts new file mode 100644 index 00000000000..b1d9341dfd8 --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-consumer-admission-state.ts @@ -0,0 +1,185 @@ +import type { + TerminalAuthorityNamespaceAdmissionChallenge, + TerminalAuthorityNamespaceAdmissionGrant +} from '../../shared/terminal-session-authority-consumer-proof' +import type { TerminalAuthorityNamespace } from '../../shared/terminal-session-authority-identity' +import type { TerminalAuthorityPolicyConsumerClaim } from '../../shared/terminal-session-authority-consumer-transport' +import type { TerminalAuthorityConsumerAdmissionSeal } from './terminal-session-authority-consumer-admission-seal' + +const MAX_PENDING_CHALLENGES = 1_024 +const MAX_PENDING_CHALLENGES_PER_SCOPE = 64 +const MAX_REPLAY_RESULTS = 1_024 + +export type TerminalAuthorityAdmissionChallengeEntry = Readonly<{ + challenge: TerminalAuthorityNamespaceAdmissionChallenge + startDigest: string + secretKey: Uint8Array + transportToken: object + consumerId: string + expectedIncarnationId: string | null + scopeKey: string +}> + +export type TerminalAuthorityAdmissionReplayEntry = Readonly<{ + challenge: TerminalAuthorityNamespaceAdmissionChallenge + startDigest: string + proofDigest: string + claim: TerminalAuthorityPolicyConsumerClaim + grant: TerminalAuthorityNamespaceAdmissionGrant + transportToken: object +}> + +export type TerminalAuthorityAdmissionReservation = Readonly<{ transportToken: object }> + +export type TerminalAuthorityAdmissionLiveGrant = Readonly<{ + connectionGrantId: string + requestId: string + processIncarnationId: string + sessionNonce: string + transportToken: object + requestKey: string +}> + +export type TerminalAuthorityNamespaceAdmissionPreparation = Readonly<{ + claim: TerminalAuthorityPolicyConsumerClaim + grant: TerminalAuthorityNamespaceAdmissionGrant + /** Seal, publish, and release, run inside the service's serialized durable-claim operation. */ + admissionSeal: TerminalAuthorityConsumerAdmissionSeal + /** True once the live grant and exact-retry publication are externally visible. */ + readonly published: boolean + rollback(): void +}> + +export class TerminalSessionAuthorityConsumerAdmissionState { + private readonly challenges = new Map() + private readonly replayResults = new Map() + private readonly reservations = new Map() + private readonly liveGrants = new Map() + + pruneExpired(now: number): void { + for (const [key, entry] of this.challenges) { + if (entry.challenge.expiresAtMs <= now) { + this.challenges.delete(key) + } + } + } + + challenge(key: string): TerminalAuthorityAdmissionChallengeEntry | undefined { + return this.challenges.get(key) + } + + rememberChallenge(key: string, entry: TerminalAuthorityAdmissionChallengeEntry): void { + if (this.challenges.size >= MAX_PENDING_CHALLENGES) { + throw new Error('terminal authority namespace admission challenge capacity exceeded') + } + if (this.pendingChallengesForScope(entry.scopeKey) >= MAX_PENDING_CHALLENGES_PER_SCOPE) { + throw new Error('terminal authority namespace admission challenge scope capacity exceeded') + } + this.challenges.set(key, entry) + } + + deleteChallenge(key: string): void { + this.challenges.delete(key) + } + + replay(key: string): TerminalAuthorityAdmissionReplayEntry | undefined { + return this.replayResults.get(key) + } + + rememberReplay(key: string, entry: TerminalAuthorityAdmissionReplayEntry): void { + this.replayResults.set(key, entry) + } + + reserve(key: string, transportToken: object): TerminalAuthorityAdmissionReservation { + if (this.reservations.has(key)) { + throw new Error('terminal authority namespace admission is already pending') + } + if ( + !this.liveGrants.has(key) && + this.replayResults.size + this.reservations.size >= MAX_REPLAY_RESULTS + ) { + throw new Error('terminal authority namespace admission retry capacity exceeded') + } + const reservation = Object.freeze({ transportToken }) + this.reservations.set(key, reservation) + return reservation + } + + assertReservation(key: string, reservation: TerminalAuthorityAdmissionReservation): void { + if (this.reservations.get(key) !== reservation) { + throw new Error('terminal authority namespace admission reservation is stale') + } + } + + releaseReservation(key: string, reservation: TerminalAuthorityAdmissionReservation): boolean { + if (this.reservations.get(key) === reservation) { + this.reservations.delete(key) + return true + } + return false + } + + liveGrant(key: string): TerminalAuthorityAdmissionLiveGrant | undefined { + return this.liveGrants.get(key) + } + + installLiveGrant(key: string, grant: TerminalAuthorityAdmissionLiveGrant): void { + const previous = this.liveGrants.get(key) + if (previous) { + this.replayResults.delete(previous.requestKey) + } + this.liveGrants.set(key, grant) + } + + releaseTransport(transportToken: object): void { + removeByToken(this.liveGrants, transportToken) + removeByToken(this.challenges, transportToken) + removeByToken(this.replayResults, transportToken) + removeByToken(this.reservations, transportToken) + } + + releaseNamespace( + transportToken: object, + consumerId: string, + namespace: TerminalAuthorityNamespace + ): void { + const key = consumerNamespaceKey(consumerId, namespace) + const live = this.liveGrants.get(key) + if (live?.transportToken === transportToken) { + this.liveGrants.delete(key) + this.replayResults.delete(live.requestKey) + } + } + + private pendingChallengesForScope(scopeKey: string): number { + let count = 0 + for (const challenge of this.challenges.values()) { + if (challenge.scopeKey === scopeKey) { + count += 1 + } + } + return count + } +} + +export function terminalAuthorityConsumerNamespaceAdmissionKey( + consumerId: string, + namespace: TerminalAuthorityNamespace +): string { + return consumerNamespaceKey(consumerId, namespace) +} + +function consumerNamespaceKey(consumerId: string, namespace: TerminalAuthorityNamespace): string { + return JSON.stringify([consumerId, namespace.authorityHostId, namespace.namespaceId]) +} + +function removeByToken>( + values: Map, + token: object +): void { + for (const [key, value] of values) { + if (value.transportToken === token) { + values.delete(key) + } + } +} diff --git a/src/main/session-authority/terminal-session-authority-consumer-admission.test.ts b/src/main/session-authority/terminal-session-authority-consumer-admission.test.ts new file mode 100644 index 00000000000..245e6a327e7 --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-consumer-admission.test.ts @@ -0,0 +1,459 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + TERMINAL_AUTHORITY_CONSUMER_PROOF_ALGORITHM, + TERMINAL_AUTHORITY_CONSUMER_PROOF_CAPABILITY, + TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION, + type TerminalAuthorityNamespaceAdmissionIntent, + type TerminalAuthorityNamespaceAdmissionStart +} from '../../shared/terminal-session-authority-consumer-proof' +import type { TerminalAuthorityNamespace } from '../../shared/terminal-session-authority-identity' +import { + createTerminalAuthorityConsumerProof, + createTerminalAuthorityProofEphemeralKeypair, + type TerminalAuthorityConsumerProofKeypair +} from './terminal-session-authority-consumer-proof' +import { + TerminalSessionAuthorityConsumerAdmissions, + type TerminalAuthorityAuthenticatedConsumerTransport, + type TerminalAuthorityNamespaceAdmissionPreparation +} from './terminal-session-authority-consumer-admission' +import { TerminalSessionAuthorityRegistry } from './terminal-session-authority-registry' +import type { TerminalSessionAuthorityService } from './terminal-session-authority-service' +import { + terminalAuthorityFloatingLocator, + terminalAuthorityWorkspaceLocator +} from './terminal-session-authority-workspace-locator' + +const directories: string[] = [] +const registries: TerminalSessionAuthorityRegistry[] = [] + +afterEach(async () => { + await Promise.allSettled(registries.splice(0).map((registry) => registry.close())) + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +describe('TerminalSessionAuthorityConsumerAdmissions', () => { + it('returns the exact committed grant for an exact request retry', async () => { + const harness = await oneNamespace() + const app = createTerminalAuthorityProofEphemeralKeypair() + const transport = authenticatedTransport('grant-a') + const start = admissionStart(harness.service.namespace, app, 'process-a', 'request-a') + const challenge = harness.admissions.issueChallenge(harness.service, start, transport) + + expect(harness.admissions.issueChallenge(harness.service, start, transport)).toBe(challenge) + const proof = createTerminalAuthorityConsumerProof(challenge, app) + const prepared = harness.admissions.prepare(harness.service, proof, transport) + await claimAndCommit(harness.service, prepared) + + expect(harness.admissions.issueChallenge(harness.service, start, transport)).toBe(challenge) + const replay = harness.admissions.prepare(harness.service, proof, transport) + expect(replay.grant).toEqual({ ...prepared.grant, replayed: true }) + expect(replay.published).toBe(true) + expect(() => + harness.admissions.issueChallenge( + harness.service, + { ...start, candidateSessionNonce: 'changed-session-nonce' }, + transport + ) + ).toThrow('request changed') + }) + + it('retains no challenge or grant for low-order keys and rejected proofs', async () => { + const harness = await oneNamespace() + const transport = authenticatedTransport('grant-a') + const app = createTerminalAuthorityProofEphemeralKeypair() + const invalid = { + ...admissionStart(harness.service.namespace, app, 'process-a', 'request-a'), + appPublicKeyB64: Buffer.alloc(32).toString('base64') + } + + expect(() => harness.admissions.issueChallenge(harness.service, invalid, transport)).toThrow( + 'low order' + ) + const start = admissionStart(harness.service.namespace, app, 'process-a', 'request-a') + const challenge = harness.admissions.issueChallenge(harness.service, start, transport) + const proof = createTerminalAuthorityConsumerProof(challenge, app) + expect(() => + harness.admissions.prepare( + harness.service, + { ...proof, proofMacB64: Buffer.alloc(32, 7).toString('base64') }, + transport + ) + ).toThrow('proof was rejected') + + const prepared = harness.admissions.prepare(harness.service, proof, transport) + await claimAndCommit(harness.service, prepared) + expect( + harness.service.activeConsumerIncarnation( + harness.service.writerAccess, + prepared.claim.consumer.consumerId + ) + ).toBe('app-process:process-a') + }) + + it('retains a durable claim but installs no dead-transport retry after cancellation', async () => { + const harness = await oneNamespace() + const app = createTerminalAuthorityProofEphemeralKeypair() + const transport = authenticatedTransport('grant-a') + const start = admissionStart(harness.service.namespace, app, 'process-a', 'request-a') + const challenge = harness.admissions.issueChallenge(harness.service, start, transport) + const prepared = harness.admissions.prepare( + harness.service, + createTerminalAuthorityConsumerProof(challenge, app), + transport + ) + prepared.admissionSeal.seal() + harness.admissions.releaseTransport(transport.token) + + await harness.service.claimConsumer(harness.service.writerAccess, { + consumerId: prepared.claim.consumer.consumerId, + consumerIncarnationId: prepared.claim.consumer.consumerIncarnationId, + expectedIncarnationId: prepared.claim.expectedConsumerIncarnationId + }) + + // The transport went away mid-flight, so the seal releases without ever publishing a grant. + prepared.admissionSeal.abort() + expect(prepared.published).toBe(false) + expect( + harness.service.activeConsumerIncarnation( + harness.service.writerAccess, + prepared.claim.consumer.consumerId + ) + ).toBe(prepared.claim.consumer.consumerIncarnationId) + expect(() => harness.admissions.issueChallenge(harness.service, start, transport)).toThrow( + 'requires resume' + ) + }) + + it('rejects expired challenges and changed authenticated transports', async () => { + let now = 1_000 + const harness = await oneNamespace(() => now) + const app = createTerminalAuthorityProofEphemeralKeypair() + const transport = authenticatedTransport('grant-a') + const start = admissionStart(harness.service.namespace, app, 'process-a', 'request-a') + const challenge = harness.admissions.issueChallenge(harness.service, start, transport) + const proof = createTerminalAuthorityConsumerProof(challenge, app) + + expect(() => + harness.admissions.prepare(harness.service, proof, { + ...transport, + token: Object.freeze({}) + }) + ).toThrow('transport changed') + now = challenge.expiresAtMs + expect(() => harness.admissions.prepare(harness.service, proof, transport)).toThrow('stale') + }) + + it('retains exact live retries and releases per-principal challenge capacity', async () => { + let now = 1_000 + const harness = await oneNamespace(() => now) + const app = createTerminalAuthorityProofEphemeralKeypair() + const transport = authenticatedTransport('grant-a') + const start = admissionStart(harness.service.namespace, app, 'process-a', 'request-a') + const challenge = harness.admissions.issueChallenge(harness.service, start, transport) + const prepared = harness.admissions.prepare( + harness.service, + createTerminalAuthorityConsumerProof(challenge, app), + transport + ) + await claimAndCommit(harness.service, prepared) + + now = challenge.expiresAtMs + 1 + expect(harness.admissions.issueChallenge(harness.service, start, transport)).toBe(challenge) + now = 60 * 60_000 + expect(harness.admissions.issueChallenge(harness.service, start, transport)).toBe(challenge) + + const scoped = authenticatedTransport('grant-b') + for (let index = 0; index < 64; index += 1) { + harness.admissions.issueChallenge( + harness.service, + admissionStart( + harness.service.namespace, + app, + 'process-b', + `scoped-request:${index}`, + 'explicit-handover' + ), + scoped + ) + } + expect(() => + harness.admissions.issueChallenge( + harness.service, + admissionStart( + harness.service.namespace, + app, + 'process-b', + 'scoped-request:overflow', + 'explicit-handover' + ), + scoped + ) + ).toThrow('scope capacity') + harness.admissions.releaseTransport(scoped.token) + expect(() => + harness.admissions.issueChallenge( + harness.service, + admissionStart( + harness.service.namespace, + app, + 'process-c', + 'scoped-request:released', + 'explicit-handover' + ), + authenticatedTransport('grant-c') + ) + ).not.toThrow() + harness.admissions.releaseTransport(transport.token) + expect(() => harness.admissions.issueChallenge(harness.service, start, transport)).toThrow( + 'requires resume' + ) + }) + + it('requires explicit live handover and resume after disconnect', async () => { + const harness = await oneNamespace() + const app = createTerminalAuthorityProofEphemeralKeypair() + const firstTransport = authenticatedTransport('grant-a') + const first = await prepareAdmission( + harness, + app, + firstTransport, + 'process-a', + 'request-a', + 'first' + ) + await claimAndCommit(harness.service, first) + + const replacementTransport = authenticatedTransport('grant-b') + expect(() => + harness.admissions.issueChallenge( + harness.service, + admissionStart(harness.service.namespace, app, 'process-b', 'request-b', 'resume'), + replacementTransport + ) + ).toThrow('requires explicit-handover') + const replacement = await prepareAdmission( + harness, + app, + replacementTransport, + 'process-b', + 'request-b', + 'explicit-handover' + ) + await claimAndCommit(harness.service, replacement) + + harness.admissions.releaseTransport(replacementTransport.token) + const resumed = await prepareAdmission( + harness, + app, + authenticatedTransport('grant-c'), + 'process-c', + 'request-c', + 'resume' + ) + await claimAndCommit(harness.service, resumed) + expect(resumed.claim.expectedConsumerIncarnationId).toBe('app-process:process-b') + }) + + it('rejects a late proof after another process wins the CAS', async () => { + const harness = await oneNamespace() + const app = createTerminalAuthorityProofEphemeralKeypair() + const first = await prepareAdmission( + harness, + app, + authenticatedTransport('grant-a'), + 'process-a', + 'request-a', + 'first' + ) + await claimAndCommit(harness.service, first) + const staleTransport = authenticatedTransport('grant-b') + const staleStart = admissionStart( + harness.service.namespace, + app, + 'process-b', + 'request-b', + 'explicit-handover' + ) + const staleChallenge = harness.admissions.issueChallenge( + harness.service, + staleStart, + staleTransport + ) + const winner = await prepareAdmission( + harness, + app, + authenticatedTransport('grant-c'), + 'process-c', + 'request-c', + 'explicit-handover' + ) + await claimAndCommit(harness.service, winner) + + expect(() => + harness.admissions.prepare( + harness.service, + createTerminalAuthorityConsumerProof(staleChallenge, app), + staleTransport + ) + ).toThrow('CAS changed') + }) + + it('lets one namespace commit while another namespace proof fails', async () => { + const harness = await twoNamespaces() + const app = createTerminalAuthorityProofEphemeralKeypair() + const transport = authenticatedTransport('grant-a') + const firstChallenge = harness.admissions.issueChallenge( + harness.first, + admissionStart(harness.first.namespace, app, 'process-a', 'request-a'), + transport + ) + const second = await prepareAdmission( + { admissions: harness.admissions, service: harness.second }, + app, + transport, + 'process-a', + 'request-b', + 'first' + ) + + expect(() => + harness.admissions.prepare( + harness.first, + { + ...createTerminalAuthorityConsumerProof(firstChallenge, app), + proofMacB64: Buffer.alloc(32, 9).toString('base64') + }, + transport + ) + ).toThrow('proof was rejected') + await claimAndCommit(harness.second, second) + expect( + harness.second.activeConsumerIncarnation( + harness.second.writerAccess, + second.claim.consumer.consumerId + ) + ).toBe('app-process:process-a') + expect( + harness.first.activeConsumerIncarnation( + harness.first.writerAccess, + second.claim.consumer.consumerId + ) + ).toBeNull() + }) +}) + +async function oneNamespace(now: () => number = Date.now) { + const directory = freshDirectory() + const registry = await openRegistry(directory) + const namespace = (await registry.resolveNamespace(terminalAuthorityWorkspaceLocator(directory))) + .namespace + return { + admissions: new TerminalSessionAuthorityConsumerAdmissions(namespace.authorityHostId, now), + service: await registry.openNamespace(namespace) + } +} + +async function twoNamespaces() { + const directory = freshDirectory() + const registry = await openRegistry(directory) + const firstNamespace = ( + await registry.resolveNamespace(terminalAuthorityWorkspaceLocator(directory)) + ).namespace + const secondNamespace = (await registry.resolveNamespace(terminalAuthorityFloatingLocator())) + .namespace + return { + admissions: new TerminalSessionAuthorityConsumerAdmissions(firstNamespace.authorityHostId), + first: await registry.openNamespace(firstNamespace), + second: await registry.openNamespace(secondNamespace) + } +} + +async function openRegistry(directory: string): Promise { + const registry = await TerminalSessionAuthorityRegistry.open({ + directory: path.join(directory, 'authority'), + authorityHostId: 'host-admission-test', + ownerToken: 'owner-token-admission-test', + ownerIncarnationId: 'owner-incarnation-admission-test', + writerActorId: 'writer-admission-test' + }) + registries.push(registry) + return registry +} + +async function prepareAdmission( + harness: Readonly<{ + admissions: TerminalSessionAuthorityConsumerAdmissions + service: TerminalSessionAuthorityService + }>, + app: TerminalAuthorityConsumerProofKeypair, + transport: TerminalAuthorityAuthenticatedConsumerTransport, + process: string, + requestId: string, + intent: TerminalAuthorityNamespaceAdmissionIntent +): Promise { + const start = admissionStart(harness.service.namespace, app, process, requestId, intent) + const challenge = harness.admissions.issueChallenge(harness.service, start, transport) + return harness.admissions.prepare( + harness.service, + createTerminalAuthorityConsumerProof(challenge, app), + transport + ) +} + +// Exercises the real seal boundary: the service seals, appends, then publishes the grant. +async function claimAndCommit( + service: TerminalSessionAuthorityService, + prepared: TerminalAuthorityNamespaceAdmissionPreparation +): Promise { + await service.commitConsumerAdmission( + service.writerAccess, + { + consumerId: prepared.claim.consumer.consumerId, + consumerIncarnationId: prepared.claim.consumer.consumerIncarnationId, + expectedIncarnationId: prepared.claim.expectedConsumerIncarnationId + }, + prepared.admissionSeal + ) +} + +function admissionStart( + namespace: TerminalAuthorityNamespace, + app: TerminalAuthorityConsumerProofKeypair, + process: string, + requestId: string, + intent: TerminalAuthorityNamespaceAdmissionIntent = 'first' +): TerminalAuthorityNamespaceAdmissionStart { + return Object.freeze({ + version: TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION, + algorithm: TERMINAL_AUTHORITY_CONSUMER_PROOF_ALGORITHM, + namespace, + appPublicKeyB64: Buffer.from(app.publicKey).toString('base64'), + candidateProcessIncarnationId: `app-process:${process}`, + candidateSessionNonce: `session-nonce:${process}`, + requestId, + intent + }) +} + +function authenticatedTransport( + connectionGrantId: string +): TerminalAuthorityAuthenticatedConsumerTransport { + return Object.freeze({ + connectionGrantId, + principal: 'daemon-token:test', + capability: TERMINAL_AUTHORITY_CONSUMER_PROOF_CAPABILITY, + token: Object.freeze({}) + }) +} + +function freshDirectory(): string { + const directory = mkdtempSync(path.join(tmpdir(), 'orca-consumer-admission-')) + directories.push(directory) + return directory +} diff --git a/src/main/session-authority/terminal-session-authority-consumer-admission.ts b/src/main/session-authority/terminal-session-authority-consumer-admission.ts new file mode 100644 index 00000000000..2fc27a1f6c2 --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-consumer-admission.ts @@ -0,0 +1,235 @@ +import { isDeepStrictEqual } from 'node:util' +import { + parseTerminalAuthorityNamespaceAdmissionProof, + parseTerminalAuthorityNamespaceAdmissionStart, + type TerminalAuthorityNamespaceAdmissionChallenge +} from '../../shared/terminal-session-authority-consumer-proof' +import type { TerminalAuthorityNamespace } from '../../shared/terminal-session-authority-identity' +import type { TerminalSessionAuthorityService } from './terminal-session-authority-service' +import { + assertTerminalAuthorityConsumerProofPeerKey, + createTerminalAuthorityProofEphemeralKeypair, + terminalAuthorityAdmissionCas, + terminalAuthorityHostAppConsumerId, + verifyTerminalAuthorityConsumerProof +} from './terminal-session-authority-consumer-proof' +export { terminalAuthorityAdmissionPreparation } from './terminal-session-authority-consumer-admission-preparation' +import { + TerminalSessionAuthorityConsumerAdmissionState, + terminalAuthorityConsumerNamespaceAdmissionKey, + type TerminalAuthorityAdmissionLiveGrant, + type TerminalAuthorityNamespaceAdmissionPreparation +} from './terminal-session-authority-consumer-admission-state' +import { + replayTerminalAuthorityAdmissionPreparation as replayPreparation, + terminalAuthorityAdmissionPreparation +} from './terminal-session-authority-consumer-admission-preparation' +import { + assertExactTerminalAuthorityAdmissionRequest, + assertTerminalAuthorityAdmissionServiceNamespace, + assertTerminalAuthorityAdmissionTransportToken, + assertTerminalAuthorityConsumerTransport, + assertTerminalAuthorityHostId, + terminalAuthorityAdmissionChallengeScopeKey, + terminalAuthorityAdmissionDigest, + terminalAuthorityAdmissionRequestKey, + type TerminalAuthorityAuthenticatedConsumerTransport +} from './terminal-session-authority-consumer-admission-request' + +export type { TerminalAuthorityAuthenticatedConsumerTransport } from './terminal-session-authority-consumer-admission-request' +export type { TerminalAuthorityNamespaceAdmissionPreparation } from './terminal-session-authority-consumer-admission-state' + +const CHALLENGE_LIFETIME_MS = 30_000 + +export class TerminalSessionAuthorityConsumerAdmissions { + private readonly state = new TerminalSessionAuthorityConsumerAdmissionState() + + constructor( + private readonly authorityHostId: string, + private readonly now: () => number = Date.now + ) { + assertTerminalAuthorityHostId(authorityHostId) + } + + issueChallenge( + service: TerminalSessionAuthorityService, + unsafeStart: unknown, + transport: TerminalAuthorityAuthenticatedConsumerTransport + ): TerminalAuthorityNamespaceAdmissionChallenge { + assertTerminalAuthorityConsumerTransport(transport) + this.state.pruneExpired(this.now()) + const start = parseTerminalAuthorityNamespaceAdmissionStart(unsafeStart) + if (!start || start.namespace.authorityHostId !== this.authorityHostId) { + throw new Error('terminal authority namespace admission start is invalid') + } + assertTerminalAuthorityAdmissionServiceNamespace(service, start.namespace) + const requestKey = terminalAuthorityAdmissionRequestKey( + start.namespace, + start.requestId, + transport + ) + const startDigest = terminalAuthorityAdmissionDigest(start) + const replay = this.state.replay(requestKey) + if (replay) { + assertExactTerminalAuthorityAdmissionRequest(replay.startDigest, startDigest) + assertTerminalAuthorityAdmissionTransportToken(replay.transportToken, transport) + return replay.challenge + } + const existing = this.state.challenge(requestKey) + if (existing) { + assertExactTerminalAuthorityAdmissionRequest(existing.startDigest, startDigest) + assertTerminalAuthorityAdmissionTransportToken(existing.transportToken, transport) + return existing.challenge + } + const scopeKey = terminalAuthorityAdmissionChallengeScopeKey(transport) + const appPublicKey = Uint8Array.from(Buffer.from(start.appPublicKeyB64, 'base64')) + const consumerId = terminalAuthorityHostAppConsumerId(this.authorityHostId, appPublicKey) + const expectedIncarnationId = service.activeConsumerIncarnation( + service.writerAccess, + consumerId + ) + this.assertIntent(start, expectedIncarnationId, transport) + const ephemeral = createTerminalAuthorityProofEphemeralKeypair() + assertTerminalAuthorityConsumerProofPeerKey(appPublicKey, ephemeral.secretKey) + const challenge = Object.freeze({ + ...start, + currentAdmissionCas: terminalAuthorityAdmissionCas( + start.namespace, + consumerId, + expectedIncarnationId + ), + connectionGrantId: transport.connectionGrantId, + authenticatedTransportPrincipal: transport.principal, + authenticatedTransportCapability: transport.capability, + hostEphemeralPublicKeyB64: Buffer.from(ephemeral.publicKey).toString('base64'), + expiresAtMs: this.now() + CHALLENGE_LIFETIME_MS + }) + this.state.rememberChallenge( + requestKey, + Object.freeze({ + challenge, + startDigest, + secretKey: ephemeral.secretKey, + transportToken: transport.token, + consumerId, + expectedIncarnationId, + scopeKey + }) + ) + return challenge + } + + prepare( + service: TerminalSessionAuthorityService, + unsafeProof: unknown, + transport: TerminalAuthorityAuthenticatedConsumerTransport + ): TerminalAuthorityNamespaceAdmissionPreparation { + assertTerminalAuthorityConsumerTransport(transport) + this.state.pruneExpired(this.now()) + const proof = parseTerminalAuthorityNamespaceAdmissionProof(unsafeProof) + if (!proof || proof.challenge.namespace.authorityHostId !== this.authorityHostId) { + throw new Error('terminal authority namespace admission proof is invalid') + } + assertTerminalAuthorityAdmissionServiceNamespace(service, proof.challenge.namespace) + const requestKey = terminalAuthorityAdmissionRequestKey( + proof.challenge.namespace, + proof.challenge.requestId, + transport + ) + const proofDigest = terminalAuthorityAdmissionDigest(proof) + const replay = this.state.replay(requestKey) + if (replay) { + if (replay.proofDigest !== proofDigest) { + throw new Error('terminal authority namespace admission retry changed') + } + assertTerminalAuthorityAdmissionTransportToken(replay.transportToken, transport) + return replayPreparation(replay) + } + const entry = this.state.challenge(requestKey) + if (!entry || !isDeepStrictEqual(entry.challenge, proof.challenge)) { + throw new Error('terminal authority namespace admission challenge is stale') + } + assertTerminalAuthorityAdmissionTransportToken(entry.transportToken, transport) + if (proof.challenge.expiresAtMs <= this.now()) { + this.state.deleteChallenge(requestKey) + throw new Error('terminal authority namespace admission challenge expired') + } + if (!verifyTerminalAuthorityConsumerProof(proof, entry.secretKey)) { + throw new Error('terminal authority namespace admission proof was rejected') + } + const currentIncarnationId = service.activeConsumerIncarnation( + service.writerAccess, + entry.consumerId + ) + if ( + currentIncarnationId !== entry.expectedIncarnationId || + terminalAuthorityAdmissionCas( + proof.challenge.namespace, + entry.consumerId, + currentIncarnationId + ) !== proof.challenge.currentAdmissionCas + ) { + throw new Error('terminal authority namespace admission CAS changed') + } + const namespaceKey = terminalAuthorityConsumerNamespaceAdmissionKey( + entry.consumerId, + proof.challenge.namespace + ) + const reservation = this.state.reserve(namespaceKey, transport.token) + return terminalAuthorityAdmissionPreparation({ + state: this.state, + service, + entry, + proof, + transport, + requestKey, + proofDigest, + namespaceKey, + reservation + }) + } + + releaseTransport(transportToken: object): void { + this.state.releaseTransport(transportToken) + } + + releaseNamespace( + transportToken: object, + consumerId: string, + namespace: TerminalAuthorityNamespace + ): void { + this.state.releaseNamespace(transportToken, consumerId, namespace) + } + + liveGrant( + transportToken: object, + consumerId: string, + namespace: TerminalAuthorityNamespace + ): TerminalAuthorityAdmissionLiveGrant | null { + const live = this.state.liveGrant( + terminalAuthorityConsumerNamespaceAdmissionKey(consumerId, namespace) + ) + return live?.transportToken === transportToken ? live : null + } + + private assertIntent( + start: NonNullable>, + currentIncarnationId: string | null, + transport: TerminalAuthorityAuthenticatedConsumerTransport + ): void { + const consumerId = terminalAuthorityHostAppConsumerId( + this.authorityHostId, + Uint8Array.from(Buffer.from(start.appPublicKeyB64, 'base64')) + ) + const live = this.state.liveGrant( + terminalAuthorityConsumerNamespaceAdmissionKey(consumerId, start.namespace) + ) + const required = currentIncarnationId === null ? 'first' : live ? 'explicit-handover' : 'resume' + if (start.intent !== required) { + throw new Error(`terminal authority namespace admission requires ${required}`) + } + if (live?.transportToken === transport.token) { + throw new Error('terminal authority namespace is already admitted on this connection') + } + } +} diff --git a/src/main/session-authority/terminal-session-authority-consumer-proof.test.ts b/src/main/session-authority/terminal-session-authority-consumer-proof.test.ts new file mode 100644 index 00000000000..c92caa63b69 --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-consumer-proof.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from 'vitest' +import { + TERMINAL_AUTHORITY_CONSUMER_PROOF_ALGORITHM, + TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION, + parseTerminalAuthorityNamespaceAdmissionProof, + parseTerminalAuthorityNamespaceAdmissionStart, + type TerminalAuthorityNamespaceAdmissionChallenge +} from '../../shared/terminal-session-authority-consumer-proof' +import { + createTerminalAuthorityConsumerProof, + createTerminalAuthorityProofEphemeralKeypair, + terminalAuthorityHostAppConsumerId, + terminalAuthorityHostPairedDeviceConsumerId, + verifyTerminalAuthorityConsumerProof +} from './terminal-session-authority-consumer-proof' + +const LOW_ORDER_KEYS = [ + ['all-zero', new Uint8Array(32)], + ['order-two', Uint8Array.from([1, ...Array.from({ length: 31 }, () => 0)])] +] as const + +describe('terminal authority consumer proof', () => { + it.each(LOW_ORDER_KEYS)('rejects a %s host peer key before proof derivation', (_name, key) => { + const app = createTerminalAuthorityProofEphemeralKeypair() + const challenge = admissionChallenge(app.publicKey, key) + + expect(() => createTerminalAuthorityConsumerProof(challenge, app)).toThrow('low order') + }) + + it.each(LOW_ORDER_KEYS)('rejects a %s app peer key before proof verification', (_name, key) => { + const host = createTerminalAuthorityProofEphemeralKeypair() + const challenge = admissionChallenge(key, host.publicKey) + + expect(() => + verifyTerminalAuthorityConsumerProof( + { version: TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION, challenge, proofMacB64: mac() }, + host.secretKey + ) + ).toThrow('low order') + }) + + it('rejects a proof verified with the wrong host secret', () => { + const app = createTerminalAuthorityProofEphemeralKeypair() + const host = createTerminalAuthorityProofEphemeralKeypair() + const otherHost = createTerminalAuthorityProofEphemeralKeypair() + const proof = createTerminalAuthorityConsumerProof( + admissionChallenge(app.publicKey, host.publicKey), + app + ) + + expect(verifyTerminalAuthorityConsumerProof(proof, host.secretKey)).toBe(true) + expect(verifyTerminalAuthorityConsumerProof(proof, otherHost.secretKey)).toBe(false) + }) + + it('rejects a wrong app secret and transcript tampering', () => { + const app = createTerminalAuthorityProofEphemeralKeypair() + const otherApp = createTerminalAuthorityProofEphemeralKeypair() + const host = createTerminalAuthorityProofEphemeralKeypair() + const challenge = admissionChallenge(app.publicKey, host.publicKey) + + expect(() => createTerminalAuthorityConsumerProof(challenge, otherApp)).toThrow( + 'does not match challenge' + ) + const proof = createTerminalAuthorityConsumerProof(challenge, app) + expect( + verifyTerminalAuthorityConsumerProof( + { + ...proof, + challenge: { ...proof.challenge, authenticatedTransportPrincipal: 'other-principal' } + }, + host.secretKey + ) + ).toBe(false) + }) + + it('derives stable versioned host-local consumers without profile labels or routes', () => { + const first = createTerminalAuthorityProofEphemeralKeypair() + const second = createTerminalAuthorityProofEphemeralKeypair() + + expect(terminalAuthorityHostAppConsumerId('host-a', first.publicKey)).toMatch( + /^app-profile:v1:/ + ) + expect(terminalAuthorityHostAppConsumerId('host-a', first.publicKey)).toBe( + terminalAuthorityHostAppConsumerId('host-a', first.publicKey) + ) + expect(terminalAuthorityHostAppConsumerId('host-a', first.publicKey)).not.toBe( + terminalAuthorityHostAppConsumerId('host-a', second.publicKey) + ) + expect(terminalAuthorityHostAppConsumerId('host-a', first.publicKey)).not.toBe( + terminalAuthorityHostAppConsumerId('host-b', first.publicKey) + ) + expect(terminalAuthorityHostPairedDeviceConsumerId('host-a', 'device-a')).toMatch( + /^paired-device:v1:/ + ) + expect(terminalAuthorityHostPairedDeviceConsumerId('host-a', 'device-a')).not.toBe( + terminalAuthorityHostPairedDeviceConsumerId('host-a', 'device-b') + ) + expect(terminalAuthorityHostPairedDeviceConsumerId('host-a', 'device-a')).not.toBe( + terminalAuthorityHostPairedDeviceConsumerId('host-b', 'device-a') + ) + }) + + it('rejects non-canonical base64 keys and MACs', () => { + const host = createTerminalAuthorityProofEphemeralKeypair() + const challenge = admissionChallenge(new Uint8Array(32), host.publicKey) + const nonCanonical = `${challenge.appPublicKeyB64.slice(0, 42)}B=` + + expect(Buffer.from(nonCanonical, 'base64')).toEqual(Buffer.alloc(32)) + expect( + parseTerminalAuthorityNamespaceAdmissionStart({ + ...challenge, + appPublicKeyB64: nonCanonical + }) + ).toBeNull() + expect( + parseTerminalAuthorityNamespaceAdmissionProof({ + version: TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION, + challenge, + proofMacB64: nonCanonical + }) + ).toBeNull() + }) +}) + +function admissionChallenge( + appPublicKey: Uint8Array, + hostPublicKey: Uint8Array +): TerminalAuthorityNamespaceAdmissionChallenge { + return Object.freeze({ + version: TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION, + algorithm: TERMINAL_AUTHORITY_CONSUMER_PROOF_ALGORITHM, + namespace: Object.freeze({ authorityHostId: 'host-a', namespaceId: 'namespace-a' }), + appPublicKeyB64: Buffer.from(appPublicKey).toString('base64'), + candidateProcessIncarnationId: 'app-process:one', + candidateSessionNonce: 'session-nonce-one', + requestId: 'request-one', + intent: 'first', + currentAdmissionCas: 'admission-cas-one', + connectionGrantId: 'connection-grant-one', + authenticatedTransportPrincipal: 'daemon-token:one', + authenticatedTransportCapability: 'daemon-authority-consumer-proof:v1', + hostEphemeralPublicKeyB64: Buffer.from(hostPublicKey).toString('base64'), + expiresAtMs: 10_000 + }) +} + +function mac(): string { + return Buffer.alloc(32).toString('base64') +} diff --git a/src/main/session-authority/terminal-session-authority-consumer-proof.ts b/src/main/session-authority/terminal-session-authority-consumer-proof.ts new file mode 100644 index 00000000000..914c0eb5cc7 --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-consumer-proof.ts @@ -0,0 +1,247 @@ +import { createHash, createHmac, hkdfSync, timingSafeEqual } from 'node:crypto' +import nacl from 'tweetnacl' +import { + TERMINAL_AUTHORITY_CONSUMER_PROOF_CAPABILITY, + TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION, + encodeTerminalAuthorityConsumerProofTranscript, + type TerminalAuthorityNamespaceAdmissionChallenge, + type TerminalAuthorityNamespaceAdmissionProof +} from '../../shared/terminal-session-authority-consumer-proof' +import { + TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_CAPABILITY, + TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_VERSION, + encodeTerminalAuthorityConsumerRetirementTranscript, + type TerminalAuthorityConsumerRetirementChallenge, + type TerminalAuthorityConsumerRetirementProof +} from '../../shared/terminal-session-authority-consumer-retirement' +import { + assertAuthorityId, + type TerminalAuthorityNamespace +} from '../../shared/terminal-session-authority-identity' + +const CONSUMER_ID_DOMAIN = 'orca:terminal-authority:app-consumer-id:v1\0' +const PAIRED_DEVICE_ID_DOMAIN = 'orca:terminal-authority:paired-device-id:v1\0' +const ADMISSION_CAS_DOMAIN = 'orca:terminal-authority:admission-cas:v1\0' +const RETIREMENT_CAS_DOMAIN = 'orca:terminal-authority:retirement-cas:v1\0' +const PROOF_KEY_DOMAIN = 'orca:terminal-authority:consumer-proof-key:v1\0' +const RETIREMENT_PROOF_KEY_DOMAIN = 'orca:terminal-authority:consumer-retirement-proof-key:v1\0' + +export type TerminalAuthorityConsumerProofKeypair = Readonly<{ + publicKey: Uint8Array + secretKey: Uint8Array +}> + +export function terminalAuthorityHostAppConsumerId( + authorityHostId: string, + publicKey: Uint8Array +): string { + assertAuthorityId(authorityHostId, 'authorityHostId') + assertKey(publicKey, 'app public key') + const fingerprint = createHash('sha256') + .update(CONSUMER_ID_DOMAIN) + .update(Buffer.from([TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION])) + .update(lengthPrefixed(authorityHostId)) + .update(publicKey) + .digest('base64url') + return `app-profile:v1:${fingerprint}` +} + +export function terminalAuthorityHostPairedDeviceConsumerId( + authorityHostId: string, + deviceId: string +): string { + assertAuthorityId(authorityHostId, 'authorityHostId') + const fingerprint = createHash('sha256') + .update(PAIRED_DEVICE_ID_DOMAIN) + .update(lengthPrefixed(authorityHostId)) + .update(deviceId, 'utf8') + .digest('base64url') + return `paired-device:v1:${fingerprint}` +} + +export function terminalAuthorityAdmissionCas( + namespace: TerminalAuthorityNamespace, + consumerId: string, + currentIncarnationId: string | null +): string { + return createHash('sha256') + .update(ADMISSION_CAS_DOMAIN) + .update(lengthPrefixed(namespace.authorityHostId)) + .update(lengthPrefixed(namespace.namespaceId)) + .update(lengthPrefixed(consumerId)) + .update(lengthPrefixed(currentIncarnationId ?? '')) + .digest('base64url') +} + +export function terminalAuthorityRetirementCas( + namespace: TerminalAuthorityNamespace, + consumerId: string, + currentIncarnationId: string | null +): string { + return createHash('sha256') + .update(RETIREMENT_CAS_DOMAIN) + .update(lengthPrefixed(namespace.authorityHostId)) + .update(lengthPrefixed(namespace.namespaceId)) + .update(lengthPrefixed(consumerId)) + .update(lengthPrefixed(currentIncarnationId ?? '')) + .digest('base64url') +} + +export function createTerminalAuthorityConsumerProof( + challenge: TerminalAuthorityNamespaceAdmissionChallenge, + appKeypair: TerminalAuthorityConsumerProofKeypair +): TerminalAuthorityNamespaceAdmissionProof { + assertKey(appKeypair.publicKey, 'app public key') + assertKey(appKeypair.secretKey, 'app secret key') + const claimedPublicKey = decodeKey(challenge.appPublicKeyB64, 'app public key') + const derivedPublicKey = nacl.box.keyPair.fromSecretKey(appKeypair.secretKey).publicKey + if ( + !timingSafeEqual(Buffer.from(appKeypair.publicKey), Buffer.from(claimedPublicKey)) || + !timingSafeEqual(Buffer.from(derivedPublicKey), Buffer.from(claimedPublicKey)) + ) { + throw new Error('terminal authority consumer proof keypair does not match challenge') + } + const hostPublicKey = decodeKey(challenge.hostEphemeralPublicKeyB64, 'host ephemeral public key') + const sharedSecret = terminalAuthorityConsumerProofSharedSecret( + hostPublicKey, + appKeypair.secretKey + ) + return Object.freeze({ + version: TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION, + challenge, + proofMacB64: proofMac(challenge, sharedSecret).toString('base64') + }) +} + +export function createTerminalAuthorityConsumerRetirementProof( + challenge: TerminalAuthorityConsumerRetirementChallenge, + appKeypair: TerminalAuthorityConsumerProofKeypair +): TerminalAuthorityConsumerRetirementProof { + assertKey(appKeypair.publicKey, 'app public key') + assertKey(appKeypair.secretKey, 'app secret key') + const claimedPublicKey = decodeKey(challenge.appPublicKeyB64, 'app public key') + const derivedPublicKey = nacl.box.keyPair.fromSecretKey(appKeypair.secretKey).publicKey + if ( + !timingSafeEqual(Buffer.from(appKeypair.publicKey), Buffer.from(claimedPublicKey)) || + !timingSafeEqual(Buffer.from(derivedPublicKey), Buffer.from(claimedPublicKey)) + ) { + throw new Error('terminal authority consumer retirement keypair does not match challenge') + } + const hostPublicKey = decodeKey(challenge.hostEphemeralPublicKeyB64, 'host ephemeral public key') + const sharedSecret = terminalAuthorityConsumerProofSharedSecret( + hostPublicKey, + appKeypair.secretKey + ) + return Object.freeze({ + version: TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_VERSION, + challenge, + proofMacB64: retirementProofMac(challenge, sharedSecret).toString('base64') + }) +} + +export function verifyTerminalAuthorityConsumerProof( + proof: TerminalAuthorityNamespaceAdmissionProof, + hostEphemeralSecretKey: Uint8Array +): boolean { + assertKey(hostEphemeralSecretKey, 'host ephemeral secret key') + const appPublicKey = decodeKey(proof.challenge.appPublicKeyB64, 'app public key') + const supplied = decodeMac(proof.proofMacB64) + const sharedSecret = terminalAuthorityConsumerProofSharedSecret( + appPublicKey, + hostEphemeralSecretKey + ) + const expected = proofMac(proof.challenge, sharedSecret) + return timingSafeEqual(supplied, expected) +} + +export function verifyTerminalAuthorityConsumerRetirementProof( + proof: TerminalAuthorityConsumerRetirementProof, + hostEphemeralSecretKey: Uint8Array +): boolean { + assertKey(hostEphemeralSecretKey, 'host ephemeral secret key') + const appPublicKey = decodeKey(proof.challenge.appPublicKeyB64, 'app public key') + const supplied = decodeMac(proof.proofMacB64) + const sharedSecret = terminalAuthorityConsumerProofSharedSecret( + appPublicKey, + hostEphemeralSecretKey + ) + const expected = retirementProofMac(proof.challenge, sharedSecret) + return timingSafeEqual(supplied, expected) +} + +export function createTerminalAuthorityProofEphemeralKeypair(): TerminalAuthorityConsumerProofKeypair { + const keypair = nacl.box.keyPair() + return Object.freeze({ publicKey: keypair.publicKey, secretKey: keypair.secretKey }) +} + +export function assertTerminalAuthorityConsumerProofPeerKey( + peerPublicKey: Uint8Array, + ourSecretKey: Uint8Array +): void { + assertKey(peerPublicKey, 'peer public key') + assertKey(ourSecretKey, 'local secret key') + const rawSharedPoint = nacl.scalarMult(ourSecretKey, peerPublicKey) + const allZero = Buffer.alloc(rawSharedPoint.length) + if (timingSafeEqual(Buffer.from(rawSharedPoint), allZero)) { + throw new Error('terminal authority consumer proof peer key is low order') + } +} + +function terminalAuthorityConsumerProofSharedSecret( + peerPublicKey: Uint8Array, + ourSecretKey: Uint8Array +): Uint8Array { + assertTerminalAuthorityConsumerProofPeerKey(peerPublicKey, ourSecretKey) + return nacl.box.before(peerPublicKey, ourSecretKey) +} + +function proofMac( + challenge: TerminalAuthorityNamespaceAdmissionChallenge, + sharedSecret: Uint8Array +): Buffer { + const salt = createHash('sha256').update(PROOF_KEY_DOMAIN).digest() + const info = Buffer.from(`${TERMINAL_AUTHORITY_CONSUMER_PROOF_CAPABILITY}:hmac`, 'utf8') + const key = Buffer.from(hkdfSync('sha256', sharedSecret, salt, info, 32)) + return createHmac('sha256', key) + .update(encodeTerminalAuthorityConsumerProofTranscript(challenge)) + .digest() +} + +function retirementProofMac( + challenge: TerminalAuthorityConsumerRetirementChallenge, + sharedSecret: Uint8Array +): Buffer { + const salt = createHash('sha256').update(RETIREMENT_PROOF_KEY_DOMAIN).digest() + const info = Buffer.from(`${TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_CAPABILITY}:hmac`, 'utf8') + const key = Buffer.from(hkdfSync('sha256', sharedSecret, salt, info, 32)) + return createHmac('sha256', key) + .update(encodeTerminalAuthorityConsumerRetirementTranscript(challenge)) + .digest() +} + +function decodeKey(value: string, field: string): Uint8Array { + const decoded = Uint8Array.from(Buffer.from(value, 'base64')) + assertKey(decoded, field) + return decoded +} + +function decodeMac(value: string): Buffer { + const decoded = Buffer.from(value, 'base64') + if (decoded.length !== 32) { + throw new Error('terminal authority consumer proof MAC is invalid') + } + return decoded +} + +function assertKey(value: Uint8Array, field: string): void { + if (value.length !== 32) { + throw new Error(`${field} is invalid`) + } +} + +function lengthPrefixed(value: string): Buffer { + const encoded = Buffer.from(value, 'utf8') + const length = Buffer.allocUnsafe(4) + length.writeUInt32BE(encoded.length) + return Buffer.concat([length, encoded]) +} diff --git a/src/main/session-authority/terminal-session-authority-consumer-retirement-state.ts b/src/main/session-authority/terminal-session-authority-consumer-retirement-state.ts new file mode 100644 index 00000000000..909debbb04e --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-consumer-retirement-state.ts @@ -0,0 +1,171 @@ +import type { + TerminalAuthorityConsumerRetirementChallenge, + TerminalAuthorityConsumerRetirementResult +} from '../../shared/terminal-session-authority-consumer-retirement' + +const MAX_CHALLENGES = 1_024 +const MAX_CHALLENGES_PER_SCOPE = 64 +const MAX_REPLAY_RESULTS = 1_024 + +export type TerminalAuthorityRetirementChallengeEntry = Readonly<{ + challenge: TerminalAuthorityConsumerRetirementChallenge + startDigest: string + secretKey: Uint8Array + transportToken: object + scopeKey: string +}> + +export type TerminalAuthorityRetirementReplayEntry = Readonly<{ + challenge: TerminalAuthorityConsumerRetirementChallenge + startDigest: string + proofDigest: string + result: TerminalAuthorityConsumerRetirementResult + transportToken: object +}> + +export type TerminalAuthorityRetirementReplayReservation = { + transportToken: object + released: boolean +} + +export class TerminalSessionAuthorityConsumerRetirementState { + private readonly challenges = new Map() + private readonly replayResults = new Map() + private readonly completions = new Map< + string, + Promise + >() + private readonly replayReservations = new Map< + string, + TerminalAuthorityRetirementReplayReservation + >() + + pruneExpired(now: number): void { + for (const [key, entry] of this.challenges) { + if (entry.challenge.expiresAtMs <= now) { + this.challenges.delete(key) + } + } + } + + challenge(key: string): TerminalAuthorityRetirementChallengeEntry | undefined { + return this.challenges.get(key) + } + + rememberChallenge(key: string, entry: TerminalAuthorityRetirementChallengeEntry): void { + if (this.occupiedReplaySlots() >= MAX_REPLAY_RESULTS) { + throw new Error('terminal authority consumer retirement retry capacity exceeded') + } + if (this.challenges.size >= MAX_CHALLENGES) { + throw new Error('terminal authority consumer retirement challenge capacity exceeded') + } + let scoped = 0 + for (const challenge of this.challenges.values()) { + if (challenge.scopeKey === entry.scopeKey) { + scoped += 1 + } + } + if (scoped >= MAX_CHALLENGES_PER_SCOPE) { + throw new Error('terminal authority consumer retirement challenge scope capacity exceeded') + } + this.challenges.set(key, entry) + } + + deleteChallenge(key: string): void { + this.challenges.delete(key) + } + + replay(key: string): TerminalAuthorityRetirementReplayEntry | undefined { + return this.replayResults.get(key) + } + + rememberReplay(key: string, entry: TerminalAuthorityRetirementReplayEntry): void { + this.replayResults.set(key, entry) + this.challenges.delete(key) + } + + reserveReplay(key: string, transportToken: object): TerminalAuthorityRetirementReplayReservation { + if (this.replayReservations.has(key)) { + throw new Error('terminal authority consumer retirement replay is already pending') + } + if (!this.challenges.has(key) && this.occupiedReplaySlots() >= MAX_REPLAY_RESULTS) { + throw new Error('terminal authority consumer retirement retry capacity exceeded') + } + const reservation = { transportToken, released: false } + this.replayReservations.set(key, reservation) + return reservation + } + + commitReplay( + key: string, + reservation: TerminalAuthorityRetirementReplayReservation, + entry: TerminalAuthorityRetirementReplayEntry + ): boolean { + if (this.replayReservations.get(key) !== reservation) { + throw new Error('terminal authority consumer retirement replay reservation is stale') + } + if (reservation.released) { + return false + } + this.rememberReplay(key, entry) + return true + } + + releaseReplayReservation( + key: string, + reservation: TerminalAuthorityRetirementReplayReservation + ): void { + if (this.replayReservations.get(key) === reservation) { + this.replayReservations.delete(key) + } + } + + completion(key: string): Promise | undefined { + return this.completions.get(key) + } + + startCompletion( + key: string, + completion: Promise + ): void { + this.completions.set(key, completion) + } + + finishCompletion( + key: string, + completion: Promise + ): void { + if (this.completions.get(key) === completion) { + this.completions.delete(key) + } + } + + releaseTransport(transportToken: object): void { + removeByToken(this.challenges, transportToken) + removeByToken(this.replayResults, transportToken) + for (const reservation of this.replayReservations.values()) { + if (reservation.transportToken === transportToken) { + reservation.released = true + } + } + } + + private occupiedReplaySlots(): number { + return new Set([ + ...this.challenges.keys(), + ...this.replayResults.keys(), + ...this.replayReservations.keys() + ]).size + } +} + +function removeByToken>( + values: Map, + token: object +): void { + for (const [key, value] of values) { + if (value.transportToken === token) { + values.delete(key) + } + } +} diff --git a/src/main/session-authority/terminal-session-authority-consumer-retirement.test.ts b/src/main/session-authority/terminal-session-authority-consumer-retirement.test.ts new file mode 100644 index 00000000000..4fb3435b17e --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-consumer-retirement.test.ts @@ -0,0 +1,454 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { TERMINAL_AUTHORITY_CONSUMER_PROOF_CAPABILITY } from '../../shared/terminal-session-authority-consumer-proof' +import { + TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_ALGORITHM, + TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_VERSION, + type TerminalAuthorityConsumerRetirementStart +} from '../../shared/terminal-session-authority-consumer-retirement' +import type { TerminalAuthorityNamespace } from '../../shared/terminal-session-authority-identity' +import type { TerminalAuthorityAuthenticatedConsumerTransport } from './terminal-session-authority-consumer-admission' +import type { TerminalAuthorityAdmissionLiveGrant } from './terminal-session-authority-consumer-admission-state' +import { + createTerminalAuthorityConsumerRetirementProof, + createTerminalAuthorityProofEphemeralKeypair, + terminalAuthorityHostAppConsumerId, + type TerminalAuthorityConsumerProofKeypair +} from './terminal-session-authority-consumer-proof' +import { TerminalSessionAuthorityConsumerRetirements } from './terminal-session-authority-consumer-retirement' +import { TerminalSessionAuthorityConsumerRetirementState } from './terminal-session-authority-consumer-retirement-state' +import { TerminalSessionAuthorityRegistry } from './terminal-session-authority-registry' +import type { TerminalSessionAuthorityService } from './terminal-session-authority-service' +import { + terminalAuthorityFloatingLocator, + terminalAuthorityWorkspaceLocator +} from './terminal-session-authority-workspace-locator' + +const HOST_ID = 'authority-host:consumer-retirement-test' +const directories: string[] = [] +const registries: TerminalSessionAuthorityRegistry[] = [] + +afterEach(async () => { + await Promise.allSettled(registries.splice(0).map((registry) => registry.close())) + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +describe('TerminalSessionAuthorityConsumerRetirements', () => { + it('durably retires a live grant and replays the exact result after a lost response', async () => { + const harness = await oneNamespace() + const app = createTerminalAuthorityProofEphemeralKeypair() + const transport = authenticatedTransport('connection-grant:a') + const consumerId = await claim(harness.service, app, 'app-process:first') + const liveGrant = admissionGrant(transport, 'app-process:first') + const start = retirementStart(harness.service.namespace, app, 'retire-request:a') + const challenge = harness.retirements.issueChallenge( + harness.service, + start, + transport, + liveGrant + ) + expect(challenge.liveAdmission).toEqual({ + requestId: liveGrant.requestId, + processIncarnationId: liveGrant.processIncarnationId, + sessionNonce: liveGrant.sessionNonce + }) + const proof = createTerminalAuthorityConsumerRetirementProof(challenge, app) + const first = await harness.retirements.complete(harness.service, proof, transport, liveGrant) + + expect(first).toMatchObject({ retired: true, alreadyAbsent: false, replayed: false }) + expect( + harness.service.activeConsumerIncarnation(harness.service.writerAccess, consumerId) + ).toBeNull() + expect(harness.retirements.issueChallenge(harness.service, start, transport, null)).toBe( + challenge + ) + await expect( + harness.retirements.complete(harness.service, proof, transport, null) + ).resolves.toEqual({ ...first, replayed: true }) + }) + + it('keeps the consumer and exact challenge when durability fails before append', async () => { + const harness = await oneNamespace() + const app = createTerminalAuthorityProofEphemeralKeypair() + const transport = authenticatedTransport('connection-grant:a') + const consumerId = await claim(harness.service, app, 'app-process:first') + const start = retirementStart(harness.service.namespace, app, 'retire-request:a') + const challenge = harness.retirements.issueChallenge(harness.service, start, transport, null) + const proof = createTerminalAuthorityConsumerRetirementProof(challenge, app) + const retire = vi.spyOn(harness.service, 'retireConsumerIdentity') + retire.mockRejectedValueOnce(new Error('append failed')) + + await expect( + harness.retirements.complete(harness.service, proof, transport, null) + ).rejects.toThrow('append failed') + expect( + harness.service.activeConsumerIncarnation(harness.service.writerAccess, consumerId) + ).toBe('app-process:first') + expect(harness.retirements.issueChallenge(harness.service, start, transport, null)).toBe( + challenge + ) + retire.mockRestore() + await expect( + harness.retirements.complete(harness.service, proof, transport, null) + ).resolves.toMatchObject({ retired: true, alreadyAbsent: false }) + }) + + it('rejects a challenge at its exact expiry boundary', async () => { + let now = 1_000 + const harness = await oneNamespace(() => now) + const app = createTerminalAuthorityProofEphemeralKeypair() + const transport = authenticatedTransport('connection-grant:a') + const consumerId = await claim(harness.service, app, 'app-process:first') + const start = retirementStart(harness.service.namespace, app, 'retire-request:expiry') + const challenge = harness.retirements.issueChallenge(harness.service, start, transport, null) + + now = challenge.expiresAtMs + await expect( + harness.retirements.complete( + harness.service, + createTerminalAuthorityConsumerRetirementProof(challenge, app), + transport, + null + ) + ).rejects.toThrow('stale') + expect( + harness.service.activeConsumerIncarnation(harness.service.writerAccess, consumerId) + ).toBe('app-process:first') + }) + + it('serializes concurrent duplicates into one durable retirement and one replay', async () => { + const harness = await oneNamespace() + const app = createTerminalAuthorityProofEphemeralKeypair() + const transport = authenticatedTransport('connection-grant:a') + await claim(harness.service, app, 'app-process:first') + const start = retirementStart(harness.service.namespace, app, 'retire-request:duplicate') + const challenge = harness.retirements.issueChallenge(harness.service, start, transport, null) + const proof = createTerminalAuthorityConsumerRetirementProof(challenge, app) + const entered = deferred() + const release = deferred() + const original = harness.service.retireConsumerIdentity.bind(harness.service) + const retire = vi + .spyOn(harness.service, 'retireConsumerIdentity') + .mockImplementation(async (...args) => { + entered.resolve() + await release.promise + return await original(...args) + }) + + const first = harness.retirements.complete(harness.service, proof, transport, null) + await entered.promise + const duplicate = harness.retirements.complete(harness.service, proof, transport, null) + release.resolve() + + await expect(first).resolves.toMatchObject({ replayed: false }) + await expect(duplicate).resolves.toMatchObject({ replayed: true }) + expect(retire).toHaveBeenCalledOnce() + }) + + it('does not retain a replay when its transport is released during durability', async () => { + const harness = await oneNamespace() + const app = createTerminalAuthorityProofEphemeralKeypair() + const transport = authenticatedTransport('connection-grant:a') + const consumerId = await claim(harness.service, app, 'app-process:first') + const start = retirementStart(harness.service.namespace, app, 'retire-request:release') + const challenge = harness.retirements.issueChallenge(harness.service, start, transport, null) + const entered = deferred() + const release = deferred() + const original = harness.service.retireConsumerIdentity.bind(harness.service) + vi.spyOn(harness.service, 'retireConsumerIdentity').mockImplementation(async (...args) => { + entered.resolve() + await release.promise + return await original(...args) + }) + + const completion = harness.retirements.complete( + harness.service, + createTerminalAuthorityConsumerRetirementProof(challenge, app), + transport, + null + ) + await entered.promise + harness.retirements.releaseTransport(transport.token) + release.resolve() + + await expect(completion).resolves.toMatchObject({ retired: true, replayed: false }) + expect( + harness.service.activeConsumerIncarnation(harness.service.writerAccess, consumerId) + ).toBeNull() + expect(harness.retirements.issueChallenge(harness.service, start, transport, null)).not.toBe( + challenge + ) + }) + + it('reserves retry capacity until an in-flight released transport settles', async () => { + const harness = await oneNamespace() + const app = createTerminalAuthorityProofEphemeralKeypair() + const transport = authenticatedTransport('connection-grant:reserved') + const start = retirementStart(harness.service.namespace, app, 'retire-request:reserved') + const challenge = harness.retirements.issueChallenge(harness.service, start, transport, null) + const state = new TerminalSessionAuthorityConsumerRetirementState() + state.rememberChallenge('reserved', { + challenge, + startDigest: 'digest:reserved', + secretKey: new Uint8Array(32), + transportToken: transport.token, + scopeKey: 'scope:reserved' + }) + const reservation = state.reserveReplay('reserved', transport.token) + state.releaseTransport(transport.token) + for (let index = 0; index < 1_023; index += 1) { + state.rememberChallenge(`fill:${index}`, { + challenge, + startDigest: `digest:${index}`, + secretKey: new Uint8Array(32), + transportToken: Object.freeze({}), + scopeKey: `scope:${index}` + }) + } + + expect(() => + state.rememberChallenge('overflow', { + challenge, + startDigest: 'digest:overflow', + secretKey: new Uint8Array(32), + transportToken: Object.freeze({}), + scopeKey: 'scope:overflow' + }) + ).toThrow('retry capacity') + state.releaseReplayReservation('reserved', reservation) + expect(() => + state.rememberChallenge('released-slot', { + challenge, + startDigest: 'digest:released-slot', + secretKey: new Uint8Array(32), + transportToken: Object.freeze({}), + scopeKey: 'scope:released-slot' + }) + ).not.toThrow() + }) + + it('rejects changed proof, transport, and live admission without retiring', async () => { + const harness = await oneNamespace() + const app = createTerminalAuthorityProofEphemeralKeypair() + const transport = authenticatedTransport('connection-grant:a') + const consumerId = await claim(harness.service, app, 'app-process:first') + const liveGrant = admissionGrant(transport, 'app-process:first') + const start = retirementStart(harness.service.namespace, app, 'retire-request:a') + const challenge = harness.retirements.issueChallenge( + harness.service, + start, + transport, + liveGrant + ) + const proof = createTerminalAuthorityConsumerRetirementProof(challenge, app) + + await expect( + harness.retirements.complete(harness.service, proof, { ...transport, token: {} }, liveGrant) + ).rejects.toThrow('transport changed') + await expect( + harness.retirements.complete(harness.service, proof, transport, null) + ).rejects.toThrow('live admission changed') + await expect( + harness.retirements.complete( + harness.service, + { ...proof, proofMacB64: Buffer.alloc(32, 7).toString('base64') }, + transport, + liveGrant + ) + ).rejects.toThrow('proof was rejected') + expect( + harness.service.activeConsumerIncarnation(harness.service.writerAccess, consumerId) + ).toBe('app-process:first') + }) + + it('retires one namespace without advancing or removing another namespace', async () => { + const harness = await twoNamespaces() + const app = createTerminalAuthorityProofEphemeralKeypair() + const transport = authenticatedTransport('connection-grant:a') + const consumerId = await claim(harness.first, app, 'app-process:first') + await claim(harness.second, app, 'app-process:first') + const start = retirementStart(harness.first.namespace, app, 'retire-request:first') + const challenge = harness.retirements.issueChallenge(harness.first, start, transport, null) + + await harness.retirements.complete( + harness.first, + createTerminalAuthorityConsumerRetirementProof(challenge, app), + transport, + null + ) + + expect( + harness.first.activeConsumerIncarnation(harness.first.writerAccess, consumerId) + ).toBeNull() + expect(harness.second.activeConsumerIncarnation(harness.second.writerAccess, consumerId)).toBe( + 'app-process:first' + ) + expect(() => + harness.retirements.issueChallenge( + harness.second, + { + ...retirementStart(harness.second.namespace, app, 'retire-request:second'), + namespace: harness.first.namespace + }, + transport, + null + ) + ).toThrow('targets another namespace') + }) + + it('authenticates durable absence after restart without re-claiming the consumer', async () => { + const directory = freshDirectory() + let registry = await openRegistry(directory) + const namespace = ( + await registry.resolveNamespace(terminalAuthorityWorkspaceLocator(directory)) + ).namespace + let service = await registry.openNamespace(namespace) + const app = createTerminalAuthorityProofEphemeralKeypair() + const transport = authenticatedTransport('connection-grant:first') + const consumerId = await claim(service, app, 'app-process:first') + const retirements = new TerminalSessionAuthorityConsumerRetirements(HOST_ID) + const firstStart = retirementStart(namespace, app, 'retire-request:first') + const firstChallenge = retirements.issueChallenge(service, firstStart, transport, null) + await retirements.complete( + service, + createTerminalAuthorityConsumerRetirementProof(firstChallenge, app), + transport, + null + ) + await registry.close() + registries.splice(registries.indexOf(registry), 1) + + registry = await openRegistry(directory) + service = await registry.openNamespace(namespace) + const resumedTransport = authenticatedTransport('connection-grant:resumed') + const resumedRetirements = new TerminalSessionAuthorityConsumerRetirements(HOST_ID) + const resumedStart = retirementStart(namespace, app, 'retire-request:resumed') + const resumedChallenge = resumedRetirements.issueChallenge( + service, + resumedStart, + resumedTransport, + null + ) + expect(resumedChallenge.currentConsumerIncarnationId).toBeNull() + await expect( + resumedRetirements.complete( + service, + createTerminalAuthorityConsumerRetirementProof(resumedChallenge, app), + resumedTransport, + null + ) + ).resolves.toMatchObject({ retired: true, alreadyAbsent: true, replayed: false }) + expect(service.activeConsumerIncarnation(service.writerAccess, consumerId)).toBeNull() + }) +}) + +async function oneNamespace(now: () => number = Date.now) { + const directory = freshDirectory() + const registry = await openRegistry(directory) + const namespace = (await registry.resolveNamespace(terminalAuthorityWorkspaceLocator(directory))) + .namespace + return { + retirements: new TerminalSessionAuthorityConsumerRetirements(HOST_ID, now), + service: await registry.openNamespace(namespace) + } +} + +async function twoNamespaces() { + const directory = freshDirectory() + const registry = await openRegistry(directory) + const firstNamespace = ( + await registry.resolveNamespace(terminalAuthorityWorkspaceLocator(directory)) + ).namespace + const secondNamespace = (await registry.resolveNamespace(terminalAuthorityFloatingLocator())) + .namespace + return { + retirements: new TerminalSessionAuthorityConsumerRetirements(HOST_ID), + first: await registry.openNamespace(firstNamespace), + second: await registry.openNamespace(secondNamespace) + } +} + +async function claim( + service: TerminalSessionAuthorityService, + app: TerminalAuthorityConsumerProofKeypair, + incarnationId: string +): Promise { + const consumerId = terminalAuthorityHostAppConsumerId(HOST_ID, app.publicKey) + await service.claimConsumer(service.writerAccess, { + consumerId, + expectedIncarnationId: null, + consumerIncarnationId: incarnationId + }) + return consumerId +} + +function retirementStart( + namespace: TerminalAuthorityNamespace, + app: TerminalAuthorityConsumerProofKeypair, + requestId: string +): TerminalAuthorityConsumerRetirementStart { + return Object.freeze({ + version: TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_VERSION, + algorithm: TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_ALGORITHM, + namespace, + appPublicKeyB64: Buffer.from(app.publicKey).toString('base64'), + candidateProcessIncarnationId: 'app-process:retirement', + candidateSessionNonce: 'app-session:retirement', + requestId + }) +} + +function admissionGrant( + transport: TerminalAuthorityAuthenticatedConsumerTransport, + processIncarnationId: string +): TerminalAuthorityAdmissionLiveGrant { + return Object.freeze({ + connectionGrantId: transport.connectionGrantId, + requestId: 'admission-request:first', + processIncarnationId, + sessionNonce: 'admission-session:first', + transportToken: transport.token, + requestKey: 'admission-request-key:first' + }) +} + +function authenticatedTransport( + connectionGrantId: string +): TerminalAuthorityAuthenticatedConsumerTransport { + return Object.freeze({ + connectionGrantId, + principal: 'daemon-token:test', + capability: TERMINAL_AUTHORITY_CONSUMER_PROOF_CAPABILITY, + token: Object.freeze({}) + }) +} + +async function openRegistry(directory: string): Promise { + const registry = await TerminalSessionAuthorityRegistry.open({ + directory: path.join(directory, 'authority'), + authorityHostId: HOST_ID, + ownerToken: 'owner-token-consumer-retirement-test', + ownerIncarnationId: 'owner-incarnation-consumer-retirement-test', + writerActorId: 'writer-consumer-retirement-test' + }) + registries.push(registry) + return registry +} + +function freshDirectory(): string { + const directory = mkdtempSync(path.join(tmpdir(), 'orca-consumer-retirement-')) + directories.push(directory) + return directory +} + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((settle) => { + resolve = settle + }) + return { promise, resolve } +} diff --git a/src/main/session-authority/terminal-session-authority-consumer-retirement.ts b/src/main/session-authority/terminal-session-authority-consumer-retirement.ts new file mode 100644 index 00000000000..89b403df0cc --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-consumer-retirement.ts @@ -0,0 +1,277 @@ +import { isDeepStrictEqual } from 'node:util' +import { + TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_VERSION, + parseTerminalAuthorityConsumerRetirementProof, + parseTerminalAuthorityConsumerRetirementStart, + type TerminalAuthorityConsumerRetirementChallenge, + type TerminalAuthorityConsumerRetirementResult +} from '../../shared/terminal-session-authority-consumer-retirement' +import type { TerminalSessionAuthorityService } from './terminal-session-authority-service' +import { + assertExactTerminalAuthorityAdmissionRequest, + assertTerminalAuthorityAdmissionServiceNamespace, + assertTerminalAuthorityAdmissionTransportToken, + assertTerminalAuthorityConsumerTransport, + assertTerminalAuthorityHostId, + terminalAuthorityAdmissionChallengeScopeKey, + terminalAuthorityAdmissionDigest, + terminalAuthorityAdmissionRequestKey, + type TerminalAuthorityAuthenticatedConsumerTransport +} from './terminal-session-authority-consumer-admission-request' +import type { TerminalAuthorityAdmissionLiveGrant } from './terminal-session-authority-consumer-admission-state' +import { + assertTerminalAuthorityConsumerProofPeerKey, + createTerminalAuthorityProofEphemeralKeypair, + terminalAuthorityHostAppConsumerId, + terminalAuthorityRetirementCas, + verifyTerminalAuthorityConsumerRetirementProof +} from './terminal-session-authority-consumer-proof' +import { + TerminalSessionAuthorityConsumerRetirementState, + type TerminalAuthorityRetirementReplayEntry +} from './terminal-session-authority-consumer-retirement-state' + +const CHALLENGE_LIFETIME_MS = 30_000 + +export class TerminalSessionAuthorityConsumerRetirements { + private readonly state = new TerminalSessionAuthorityConsumerRetirementState() + + constructor( + private readonly authorityHostId: string, + private readonly now: () => number = Date.now + ) { + assertTerminalAuthorityHostId(authorityHostId) + } + + issueChallenge( + service: TerminalSessionAuthorityService, + unsafeStart: unknown, + transport: TerminalAuthorityAuthenticatedConsumerTransport, + liveGrant: TerminalAuthorityAdmissionLiveGrant | null + ): TerminalAuthorityConsumerRetirementChallenge { + assertTerminalAuthorityConsumerTransport(transport) + this.state.pruneExpired(this.now()) + const start = parseTerminalAuthorityConsumerRetirementStart(unsafeStart) + if (!start || start.namespace.authorityHostId !== this.authorityHostId) { + throw new Error('terminal authority consumer retirement start is invalid') + } + assertTerminalAuthorityAdmissionServiceNamespace(service, start.namespace) + const requestKey = terminalAuthorityAdmissionRequestKey( + start.namespace, + start.requestId, + transport + ) + const startDigest = terminalAuthorityAdmissionDigest(start) + const replay = this.state.replay(requestKey) + if (replay) { + assertExactTerminalAuthorityAdmissionRequest(replay.startDigest, startDigest) + assertTerminalAuthorityAdmissionTransportToken(replay.transportToken, transport) + return replay.challenge + } + const existing = this.state.challenge(requestKey) + if (existing) { + assertExactTerminalAuthorityAdmissionRequest(existing.startDigest, startDigest) + assertTerminalAuthorityAdmissionTransportToken(existing.transportToken, transport) + return existing.challenge + } + const appPublicKey = Uint8Array.from(Buffer.from(start.appPublicKeyB64, 'base64')) + const consumerId = terminalAuthorityHostAppConsumerId(this.authorityHostId, appPublicKey) + const currentConsumerIncarnationId = service.activeConsumerIncarnation( + service.writerAccess, + consumerId + ) + if (liveGrant && liveGrant.processIncarnationId !== currentConsumerIncarnationId) { + throw new Error('terminal authority consumer retirement live admission is stale') + } + const ephemeral = createTerminalAuthorityProofEphemeralKeypair() + assertTerminalAuthorityConsumerProofPeerKey(appPublicKey, ephemeral.secretKey) + const challenge = Object.freeze({ + ...start, + consumerId, + currentConsumerIncarnationId, + retirementCas: terminalAuthorityRetirementCas( + start.namespace, + consumerId, + currentConsumerIncarnationId + ), + connectionGrantId: transport.connectionGrantId, + liveAdmission: liveGrant + ? Object.freeze({ + requestId: liveGrant.requestId, + processIncarnationId: liveGrant.processIncarnationId, + sessionNonce: liveGrant.sessionNonce + }) + : null, + authenticatedTransportPrincipal: transport.principal, + authenticatedTransportCapability: transport.capability, + hostEphemeralPublicKeyB64: Buffer.from(ephemeral.publicKey).toString('base64'), + expiresAtMs: this.now() + CHALLENGE_LIFETIME_MS + }) + this.state.rememberChallenge( + requestKey, + Object.freeze({ + challenge, + startDigest, + secretKey: ephemeral.secretKey, + transportToken: transport.token, + scopeKey: terminalAuthorityAdmissionChallengeScopeKey(transport) + }) + ) + return challenge + } + + async complete( + service: TerminalSessionAuthorityService, + unsafeProof: unknown, + transport: TerminalAuthorityAuthenticatedConsumerTransport, + liveGrant: TerminalAuthorityAdmissionLiveGrant | null + ): Promise { + assertTerminalAuthorityConsumerTransport(transport) + this.state.pruneExpired(this.now()) + const proof = parseTerminalAuthorityConsumerRetirementProof(unsafeProof) + if (!proof || proof.challenge.namespace.authorityHostId !== this.authorityHostId) { + throw new Error('terminal authority consumer retirement proof is invalid') + } + assertTerminalAuthorityAdmissionServiceNamespace(service, proof.challenge.namespace) + const requestKey = terminalAuthorityAdmissionRequestKey( + proof.challenge.namespace, + proof.challenge.requestId, + transport + ) + const proofDigest = terminalAuthorityAdmissionDigest(proof) + const replay = this.state.replay(requestKey) + if (replay) { + this.assertReplay(replay, proofDigest, transport) + return Object.freeze({ ...replay.result, replayed: true }) + } + const inFlight = this.state.completion(requestKey) + if (inFlight) { + const result = await inFlight + const completed = this.state.replay(requestKey) + if (!completed) { + throw new Error('terminal authority consumer retirement retry is unavailable') + } + this.assertReplay(completed, proofDigest, transport) + return Object.freeze({ ...result, replayed: true }) + } + const completion = this.completeExact( + service, + proof, + proofDigest, + transport, + liveGrant, + requestKey + ) + this.state.startCompletion(requestKey, completion) + try { + return await completion + } finally { + this.state.finishCompletion(requestKey, completion) + } + } + + releaseTransport(transportToken: object): void { + this.state.releaseTransport(transportToken) + } + + private async completeExact( + service: TerminalSessionAuthorityService, + proof: NonNullable>, + proofDigest: string, + transport: TerminalAuthorityAuthenticatedConsumerTransport, + liveGrant: TerminalAuthorityAdmissionLiveGrant | null, + requestKey: string + ): Promise { + const entry = this.state.challenge(requestKey) + if (!entry || !isDeepStrictEqual(entry.challenge, proof.challenge)) { + throw new Error('terminal authority consumer retirement challenge is stale') + } + assertTerminalAuthorityAdmissionTransportToken(entry.transportToken, transport) + if (proof.challenge.expiresAtMs <= this.now()) { + this.state.deleteChallenge(requestKey) + throw new Error('terminal authority consumer retirement challenge expired') + } + if (!verifyTerminalAuthorityConsumerRetirementProof(proof, entry.secretKey)) { + throw new Error('terminal authority consumer retirement proof was rejected') + } + this.assertLiveAdmission(proof.challenge, liveGrant) + const currentConsumerIncarnationId = service.activeConsumerIncarnation( + service.writerAccess, + proof.challenge.consumerId + ) + if ( + currentConsumerIncarnationId !== proof.challenge.currentConsumerIncarnationId || + terminalAuthorityRetirementCas( + proof.challenge.namespace, + proof.challenge.consumerId, + currentConsumerIncarnationId + ) !== proof.challenge.retirementCas + ) { + throw new Error('terminal authority consumer retirement CAS changed') + } + const reservation = this.state.reserveReplay(requestKey, transport.token) + try { + await service.retireConsumerIdentity( + service.writerAccess, + proof.challenge.consumerId, + currentConsumerIncarnationId + ) + const result = Object.freeze({ + version: TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_VERSION, + namespace: Object.freeze({ ...proof.challenge.namespace }), + consumerId: proof.challenge.consumerId, + retiredConsumerIncarnationId: currentConsumerIncarnationId, + requestId: proof.challenge.requestId, + candidateProcessIncarnationId: proof.challenge.candidateProcessIncarnationId, + candidateSessionNonce: proof.challenge.candidateSessionNonce, + connectionGrantId: proof.challenge.connectionGrantId, + retirementCas: proof.challenge.retirementCas, + retired: true as const, + alreadyAbsent: currentConsumerIncarnationId === null, + replayed: false + }) + this.state.commitReplay( + requestKey, + reservation, + Object.freeze({ + challenge: entry.challenge, + startDigest: entry.startDigest, + proofDigest, + result, + transportToken: transport.token + }) + ) + return result + } finally { + this.state.releaseReplayReservation(requestKey, reservation) + } + } + + private assertLiveAdmission( + challenge: TerminalAuthorityConsumerRetirementChallenge, + liveGrant: TerminalAuthorityAdmissionLiveGrant | null + ): void { + const expected = challenge.liveAdmission + if ( + (!expected && liveGrant) || + (expected && + (!liveGrant || + expected.requestId !== liveGrant.requestId || + expected.processIncarnationId !== liveGrant.processIncarnationId || + expected.sessionNonce !== liveGrant.sessionNonce)) + ) { + throw new Error('terminal authority consumer retirement live admission changed') + } + } + + private assertReplay( + replay: TerminalAuthorityRetirementReplayEntry, + proofDigest: string, + transport: TerminalAuthorityAuthenticatedConsumerTransport + ): void { + if (replay.proofDigest !== proofDigest) { + throw new Error('terminal authority consumer retirement retry changed') + } + assertTerminalAuthorityAdmissionTransportToken(replay.transportToken, transport) + } +} diff --git a/src/main/session-authority/terminal-session-authority-consumer-rollback-failure.ts b/src/main/session-authority/terminal-session-authority-consumer-rollback-failure.ts new file mode 100644 index 00000000000..34cf33de452 --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-consumer-rollback-failure.ts @@ -0,0 +1,19 @@ +/** + * Runs a rollback that is already unwinding `cause` and always rethrows. A failed rollback is joined + * to the cause rather than discarded: it leaves live admission or namespace state behind, and a caller + * that saw only the original error would treat a partial teardown as a clean one. + */ +export async function joinTerminalAuthorityRollbackFailure( + cause: unknown, + rollback: () => Promise +): Promise { + try { + await rollback() + } catch (failure) { + throw new AggregateError( + [cause, failure], + 'terminal authority consumer rollback failed while unwinding' + ) + } + throw cause +} diff --git a/src/main/session-authority/terminal-session-authority-file-store.ts b/src/main/session-authority/terminal-session-authority-file-store.ts new file mode 100644 index 00000000000..9db5fab20de --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-file-store.ts @@ -0,0 +1,292 @@ +import { Buffer } from 'node:buffer' +import { randomUUID } from 'node:crypto' +import { mkdir, open, rename, rm, stat, truncate, type FileHandle } from 'node:fs/promises' +import path from 'node:path' +import { + assertAuthorityNamespace, + type TerminalAuthorityNamespace +} from '../../shared/terminal-session-authority-identity' +import { + failTerminalSessionAuthority, + TerminalSessionAuthorityError, + type TerminalSessionAuthorityLogEvent, + type TerminalSessionAuthorityLogRecord, + type TerminalSessionAuthoritySnapshot +} from '../../shared/terminal-session-authority-mutation' +import { assertSafeInteger } from '../../shared/terminal-session-authority-record-validation' +import { + readTerminalAuthorityRecordFiles, + TERMINAL_AUTHORITY_CHECKPOINT_FILE, + TERMINAL_AUTHORITY_LOG_FILE, + type TerminalAuthorityCheckpoint +} from './terminal-session-authority-record-files' +import { removeAuthorityCrashTemporaryFiles } from './terminal-session-authority-temporary-files' +import type { TerminalAuthorityWriterLock } from './terminal-session-authority-writer-lock' + +export const TERMINAL_AUTHORITY_DEFAULT_MAX_CHECKPOINT_BYTES = 32 * 1024 * 1024 +export const TERMINAL_AUTHORITY_DEFAULT_MAX_LOG_BYTES = 64 * 1024 * 1024 + +export type TerminalAuthorityFileStoreOptions = Readonly<{ + directory: string + namespace: TerminalAuthorityNamespace + lock: TerminalAuthorityWriterLock + maxCheckpointBytes?: number + maxLogBytes?: number + onCrashBoundary?: ( + boundary: 'record-synced' | 'checkpoint-synced' | 'checkpoint-renamed' | 'log-reset-renamed', + detail: Readonly<{ + namespace: TerminalAuthorityNamespace + recordId?: number + eventKind?: TerminalSessionAuthorityLogEvent['kind'] + }> + ) => void +}> + +export type OpenTerminalAuthorityFileStore = Readonly<{ + store: TerminalAuthorityFileStore + checkpoint: TerminalSessionAuthoritySnapshot | null + records: readonly TerminalSessionAuthorityLogRecord[] +}> + +export class TerminalAuthorityFileStore { + private queue: Promise = Promise.resolve() + private poisoned = false + private accepting = true + private closed = false + private closePromise: Promise | null = null + + private constructor( + private readonly options: TerminalAuthorityFileStoreOptions, + private recordId: number, + private readonly maxLogBytes: number, + private readonly maxCheckpointBytes: number, + private logBytes: number, + private recordsSinceCheckpoint: number + ) {} + + static async open( + options: TerminalAuthorityFileStoreOptions + ): Promise { + assertAuthorityNamespace(options.namespace) + const directory = path.resolve(options.directory) + await mkdir(directory, { recursive: true }) + const maxCheckpointBytes = positiveLimit( + options.maxCheckpointBytes, + TERMINAL_AUTHORITY_DEFAULT_MAX_CHECKPOINT_BYTES + ) + const maxLogBytes = positiveLimit(options.maxLogBytes, TERMINAL_AUTHORITY_DEFAULT_MAX_LOG_BYTES) + await options.lock.runExclusive(() => + removeAuthorityCrashTemporaryFiles(directory, [ + TERMINAL_AUTHORITY_CHECKPOINT_FILE, + TERMINAL_AUTHORITY_LOG_FILE + ]) + ) + const { checkpoint, log: loadedLog } = await readTerminalAuthorityRecordFiles( + directory, + options.namespace, + maxCheckpointBytes, + maxLogBytes + ) + const throughRecordId = checkpoint?.throughRecordId ?? 0 + await options.lock.runExclusive(async () => { + if (loadedLog.truncateToBytes !== null) { + await truncate(path.join(directory, TERMINAL_AUTHORITY_LOG_FILE), loadedLog.truncateToBytes) + } + await ensureLogFile(directory) + }) + const records = loadedLog.records + const recordId = records.at(-1)?.recordId ?? throughRecordId + const store = new TerminalAuthorityFileStore( + { ...options, directory }, + recordId, + maxLogBytes, + maxCheckpointBytes, + loadedLog.completeBytes, + records.length + ) + return Object.freeze({ + store, + checkpoint: checkpoint?.snapshot ?? null, + records: Object.freeze(records) + }) + } + + append(event: TerminalSessionAuthorityLogEvent): Promise { + return this.enqueue(async () => { + const record = Object.freeze({ + version: 1 as const, + recordId: this.recordId + 1, + writerEpoch: this.options.lock.identity.epoch, + event + }) + await this.options.lock.runExclusive(async () => { + const bytes = Buffer.from(`${JSON.stringify(record)}\n`, 'utf8') + const logPath = path.join(this.options.directory, TERMINAL_AUTHORITY_LOG_FILE) + const metadata = await stat(logPath) + if (metadata.size + bytes.byteLength > this.maxLogBytes) { + failTerminalSessionAuthority('capacity', 'authority log reached its durable size limit') + } + const handle = await open(logPath, 'a', 0o600) + try { + await writeFully(handle, bytes) + await handle.sync() + } finally { + await handle.close() + } + this.options.onCrashBoundary?.('record-synced', { + namespace: this.options.namespace, + recordId: record.recordId, + eventKind: event.kind + }) + }) + this.recordId = record.recordId + this.logBytes += Buffer.byteLength(`${JSON.stringify(record)}\n`, 'utf8') + this.recordsSinceCheckpoint += 1 + return record + }) + } + + get shouldCompact(): boolean { + return ( + this.recordsSinceCheckpoint >= 1_024 || + this.logBytes >= Math.max(1, Math.floor(this.maxLogBytes / 2)) + ) + } + + compact(snapshot: TerminalSessionAuthoritySnapshot): Promise { + return this.enqueue(async () => { + if (snapshot.writerEpoch !== this.options.lock.identity.epoch) { + failTerminalSessionAuthority('writer-fenced', 'checkpoint writer epoch is stale') + } + const checkpoint: TerminalAuthorityCheckpoint = Object.freeze({ + version: 1, + namespace: this.options.namespace, + throughRecordId: this.recordId, + snapshot + }) + await this.options.lock.runExclusive(() => this.writeCompaction(checkpoint)) + this.logBytes = 0 + this.recordsSinceCheckpoint = 0 + }) + } + + assertWriterCurrent(): Promise { + return this.options.lock.runExclusive(async () => {}) + } + + async close(): Promise { + if (this.closePromise) { + return this.closePromise + } + this.accepting = false + this.closePromise = (async () => { + await this.queue + this.closed = true + await this.options.lock.release() + })() + return this.closePromise + } + + private enqueue(operation: () => Promise): Promise { + if (!this.accepting || this.closed || this.poisoned) { + failTerminalSessionAuthority('writer-fenced', 'authority store is unavailable') + } + const result = this.queue.then(operation) + this.queue = result.then( + () => undefined, + (error) => { + if (!(error instanceof TerminalSessionAuthorityError && error.code === 'capacity')) { + this.poisoned = true + } + } + ) + return result + } + + private async writeCompaction(checkpoint: TerminalAuthorityCheckpoint): Promise { + const checkpointPath = path.join(this.options.directory, TERMINAL_AUTHORITY_CHECKPOINT_FILE) + const logPath = path.join(this.options.directory, TERMINAL_AUTHORITY_LOG_FILE) + const checkpointTemp = temporarySibling(checkpointPath) + const logTemp = temporarySibling(logPath) + try { + const checkpointContents = `${JSON.stringify(checkpoint)}\n` + if (Buffer.byteLength(checkpointContents, 'utf8') > this.maxCheckpointBytes) { + failTerminalSessionAuthority('capacity', 'authority checkpoint exceeds its size limit') + } + await writeSyncedFile(checkpointTemp, checkpointContents) + this.options.onCrashBoundary?.('checkpoint-synced', { + namespace: this.options.namespace + }) + await rename(checkpointTemp, checkpointPath) + await syncDirectory(this.options.directory) + this.options.onCrashBoundary?.('checkpoint-renamed', { + namespace: this.options.namespace + }) + await writeSyncedFile(logTemp, '') + await rename(logTemp, logPath) + await syncDirectory(this.options.directory) + this.options.onCrashBoundary?.('log-reset-renamed', { + namespace: this.options.namespace + }) + } finally { + await rm(checkpointTemp, { force: true }) + await rm(logTemp, { force: true }) + } + } +} + +async function ensureLogFile(directory: string): Promise { + const handle = await open(path.join(directory, TERMINAL_AUTHORITY_LOG_FILE), 'a', 0o600) + try { + await handle.sync() + } finally { + await handle.close() + } + await syncDirectory(directory) +} + +async function writeSyncedFile(file: string, contents: string): Promise { + const handle = await open(file, 'wx', 0o600) + try { + await handle.writeFile(contents, 'utf8') + await handle.sync() + } finally { + await handle.close() + } +} + +async function writeFully(handle: FileHandle, bytes: Buffer): Promise { + let offset = 0 + while (offset < bytes.byteLength) { + const { bytesWritten } = await handle.write(bytes, offset, bytes.byteLength - offset) + if (bytesWritten < 1) { + throw new Error('authority log write made no progress') + } + offset += bytesWritten + } +} + +function temporarySibling(file: string): string { + return `${file}.${process.pid}.${randomUUID()}.tmp` +} + +async function syncDirectory(directory: string): Promise { + let handle: FileHandle | null = null + try { + handle = await open(directory, 'r') + await handle.sync() + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + if (!['EACCES', 'EBADF', 'EINVAL', 'EISDIR', 'ENOTSUP', 'EPERM'].includes(code ?? '')) { + throw error + } + } finally { + await handle?.close() + } +} + +function positiveLimit(value: number | undefined, fallback: number): number { + const selected = value ?? fallback + assertSafeInteger(selected, 'persistence size limit', 1) + return selected +} diff --git a/src/main/session-authority/terminal-session-authority-host-effect-applier.ts b/src/main/session-authority/terminal-session-authority-host-effect-applier.ts new file mode 100644 index 00000000000..dcd799c877e --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-host-effect-applier.ts @@ -0,0 +1,44 @@ +import type { TerminalSessionAuthorityEffect } from '../../shared/terminal-session-authority-mutation' +import type { TerminalSessionAuthorityPtyAccess } from '../../shared/terminal-session-authority-pty-access' + +export type TerminalAuthorityBindingRetiredEffect = Extract< + TerminalSessionAuthorityEffect, + { kind: 'binding-retired' } +> + +export type TerminalSessionAuthorityHostEffectApplier = Readonly<{ + ensureBindingRetired( + access: TerminalSessionAuthorityPtyAccess, + reason: TerminalAuthorityBindingRetiredEffect['reason'] + ): Promise +}> + +export class TerminalSessionAuthorityHostEffectApplierSlot implements TerminalSessionAuthorityHostEffectApplier { + private applier: TerminalSessionAuthorityHostEffectApplier | null = null + + install(applier: TerminalSessionAuthorityHostEffectApplier): () => void { + if (this.applier) { + throw new Error('terminal session authority host effect applier is already installed') + } + this.applier = applier + return () => { + if (this.applier === applier) { + this.applier = null + } + } + } + + isInstalled(): boolean { + return this.applier !== null + } + + async ensureBindingRetired( + access: TerminalSessionAuthorityPtyAccess, + reason: TerminalAuthorityBindingRetiredEffect['reason'] + ): Promise { + if (!this.applier) { + throw new Error('terminal session authority host effect applier is unavailable') + } + await this.applier.ensureBindingRetired(access, reason) + } +} diff --git a/src/main/session-authority/terminal-session-authority-host-effect-consumer.test.ts b/src/main/session-authority/terminal-session-authority-host-effect-consumer.test.ts new file mode 100644 index 00000000000..8afd000bc5d --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-host-effect-consumer.test.ts @@ -0,0 +1,250 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { TerminalSessionAuthorityPtyAccess } from '../../shared/terminal-session-authority-pty-access' +import { TERMINAL_SESSION_AUTHORITY_SPAWN_VERSION } from '../../shared/terminal-session-authority-wire' +import { terminalAuthorityOperationIdentity } from '../../shared/terminal-session-authority-operation-identity' +import { + TerminalSessionAuthorityHostEffectConsumer, + terminalAuthorityHostEffectConsumerId +} from './terminal-session-authority-host-effect-consumer' +import { TerminalSessionAuthorityHostEffectApplierSlot } from './terminal-session-authority-host-effect-applier' +import { TerminalSessionAuthorityPtyLifecycle } from './terminal-session-authority-pty-lifecycle' +import { TerminalSessionAuthorityRegistry } from './terminal-session-authority-registry' +import { terminalAuthorityWorkspaceLocator } from './terminal-session-authority-workspace-locator' +import type { TerminalAuthorityPolicyConsumerConnection } from './terminal-session-authority-policy-consumers' +import { createTerminalAuthorityProofEphemeralKeypair } from './terminal-session-authority-consumer-proof' +import { admitAuthenticatedPolicyConsumer } from './__tests__/authenticated-policy-consumer-admission' + +const directories: string[] = [] +const registries: TerminalSessionAuthorityRegistry[] = [] +const policyConnections = new WeakMap< + TerminalSessionAuthorityPtyLifecycle, + TerminalAuthorityPolicyConsumerConnection +>() +const registryRoots = new WeakMap() + +afterEach(async () => { + await Promise.allSettled(registries.splice(0).map((registry) => registry.close())) + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +describe('TerminalSessionAuthorityHostEffectConsumer', () => { + it('replays a durable binding retirement after authority restart', async () => { + const root = freshDirectory() + const firstRegistry = await openRegistry(root, 'owner-token-a', 'owner-a') + const first = await lifecycle(firstRegistry, 'owner-a') + const failedAttempts: TerminalSessionAuthorityPtyAccess[] = [] + first.installHostEffectApplier({ + ensureBindingRetired: async (access) => { + failedAttempts.push(access) + throw new Error('lost physical shutdown response') + } + }) + const managed = await spawnManaged(first, root) + await first.closePty(managed, policyFor(first)) + await vi.waitFor(() => expect(failedAttempts).toHaveLength(1)) + await firstRegistry.close() + + const secondRegistry = await openRegistry(root, 'owner-token-b', 'owner-b') + const second = await lifecycle(secondRegistry, 'owner-b', 'owner-a') + const replayed: Readonly<{ access: TerminalSessionAuthorityPtyAccess; reason: string }>[] = [] + second.installHostEffectApplier({ + ensureBindingRetired: async (access, reason) => void replayed.push({ access, reason }) + }) + await second.start() + await vi.waitFor(() => expect(replayed).toHaveLength(1)) + + expect(replayed[0]).toEqual({ + access: failedAttempts[0], + reason: 'close' + }) + await expectHostEffectsCaughtUp(secondRegistry, root, 'owner-b') + }) + + it('serializes concurrent retries and ACKs only after an accepted exact shutdown', async () => { + const root = freshDirectory() + const registry = await openRegistry(root, 'owner-token-a', 'owner-a') + const authority = await lifecycle(registry, 'owner-a') + const attempts: TerminalSessionAuthorityPtyAccess[] = [] + let active = 0 + let maximumActive = 0 + let releaseFirst!: () => void + const firstAttempt = new Promise((resolve) => { + releaseFirst = resolve + }) + authority.installHostEffectApplier({ + ensureBindingRetired: async (access) => { + attempts.push(access) + active += 1 + maximumActive = Math.max(maximumActive, active) + if (attempts.length === 1) { + await firstAttempt + active -= 1 + throw new Error('shutdown accepted but response was lost') + } + active -= 1 + } + }) + const managed = await spawnManaged(authority, root) + await authority.closePty(managed, policyFor(authority)) + await vi.waitFor(() => expect(attempts).toHaveLength(1)) + + authority.requestHostEffectDelivery() + authority.requestHostEffectDelivery() + authority.requestHostEffectDelivery() + releaseFirst() + await vi.waitFor(() => expect(attempts).toHaveLength(2)) + + expect(maximumActive).toBe(1) + expect(attempts[1]).toEqual(attempts[0]) + await expectHostEffectsCaughtUp(registry, root, 'owner-a') + }) + + it('cumulatively ACKs one replay page only after every effect settles', async () => { + const root = freshDirectory() + const registry = await openRegistry(root, 'owner-token-a', 'owner-a') + const namespace = (await registry.resolveNamespace(terminalAuthorityWorkspaceLocator(root))) + .namespace + const service = await registry.openNamespace(namespace) + const consumerId = terminalAuthorityHostEffectConsumerId('host-a') + await service.claimConsumer(service.writerAccess, { + consumerId, + expectedIncarnationId: null, + consumerIncarnationId: 'owner-a' + }) + for (let index = 0; index < 12; index += 1) { + const revision = service.snapshotForWriter(service.writerAccess).revision + await service.mutate(service.writerAccess, { + actorId: service.writerAccess.actorId, + ...terminalAuthorityOperationIdentity(revision, `page-outcome-${index}`), + baseRevision: revision, + change: { + kind: 'create', + pane: { + paneKey: `pane-${index}`, + paneGenerationId: `generation-${index}` + } + } + }) + } + const acknowledgements = vi.spyOn(service, 'acknowledgeOutcomes') + const applier = new TerminalSessionAuthorityHostEffectApplierSlot() + applier.install({ ensureBindingRetired: async () => {} }) + const effects = new TerminalSessionAuthorityHostEffectConsumer(registry, 'owner-a', applier) + await effects.start() + await vi.waitFor(async () => { + const consumer = await service.claimConsumer(service.writerAccess, { + consumerId, + expectedIncarnationId: 'owner-a', + consumerIncarnationId: 'owner-a' + }) + expect((await service.snapshotForConsumer(consumer)).acknowledgedSequence).toBe(12) + }) + expect(acknowledgements).toHaveBeenCalledTimes(1) + expect(acknowledgements.mock.calls[0]?.[1]).toBe(12) + effects.dispose() + }) +}) + +// The app keypair is the consumer identity, so restarts reuse it to resume the same durable consumer. +const appKeypair = createTerminalAuthorityProofEphemeralKeypair() + +async function lifecycle( + registry: TerminalSessionAuthorityRegistry, + ownerIncarnationId: string, + expectedOwnerIncarnationId: string | null = null +): Promise { + const lifecycle = new TerminalSessionAuthorityPtyLifecycle(registry, ownerIncarnationId) + const root = registryRoots.get(registry) + if (!root) { + throw new Error('test registry root is unavailable') + } + const admitted = await admitAuthenticatedPolicyConsumer(lifecycle, { + namespace: await lifecycle.resolvePolicyConsumerNamespace(`repo::${root}`), + appKeypair, + processIncarnationId: ownerIncarnationId, + requestId: `request-${ownerIncarnationId}`, + intent: expectedOwnerIncarnationId === null ? 'first' : 'resume' + }) + policyConnections.set(lifecycle, admitted.session.policyConsumer) + return lifecycle +} + +async function spawnManaged(lifecycle: TerminalSessionAuthorityPtyLifecycle, root: string) { + const prepared = await lifecycle.prepareSpawn(spawnParams(root), 'pty-1', policyFor(lifecycle)) + if (prepared.kind !== 'spawn') { + throw new Error('expected a fresh allocation') + } + return await lifecycle.commitSpawn(prepared, 'incarnation-1') +} + +function policyFor( + lifecycle: TerminalSessionAuthorityPtyLifecycle +): TerminalAuthorityPolicyConsumerConnection { + const connection = policyConnections.get(lifecycle) + if (!connection) { + throw new Error('test policy consumer is unavailable') + } + return connection +} + +async function expectHostEffectsCaughtUp( + registry: TerminalSessionAuthorityRegistry, + root: string, + consumerIncarnationId: string +): Promise { + const namespace = registry.namespaceForLocator(terminalAuthorityWorkspaceLocator(root)) + if (!namespace) { + throw new Error('expected authority namespace') + } + const service = await registry.openNamespace(namespace) + const consumer = await service.claimConsumer(service.writerAccess, { + consumerId: terminalAuthorityHostEffectConsumerId('host-a'), + expectedIncarnationId: consumerIncarnationId, + consumerIncarnationId + }) + await vi.waitFor(async () => { + const snapshot = await service.snapshotForConsumer(consumer) + expect(snapshot.acknowledgedSequence).toBe(snapshot.outcomeHighWatermark) + }) +} + +async function openRegistry( + root: string, + ownerToken: string, + ownerIncarnationId: string +): Promise { + const registry = await TerminalSessionAuthorityRegistry.open({ + directory: path.join(root, 'authority'), + authorityHostId: 'host-a', + ownerToken, + ownerIncarnationId, + writerActorId: ownerIncarnationId + }) + registries.push(registry) + registryRoots.set(registry, root) + return registry +} + +function spawnParams(root: string): Record { + return { + terminalSessionAuthorityVersion: TERMINAL_SESSION_AUTHORITY_SPAWN_VERSION, + paneKey: 'pane-a', + paneGeneration: 1, + worktreeId: `repo::${root}`, + cols: 80, + rows: 24, + cwd: root, + env: { TERM: 'xterm-256color' } + } +} + +function freshDirectory(): string { + const directory = mkdtempSync(path.join(tmpdir(), 'orca-host-effect-consumer-')) + directories.push(directory) + return directory +} diff --git a/src/main/session-authority/terminal-session-authority-host-effect-consumer.ts b/src/main/session-authority/terminal-session-authority-host-effect-consumer.ts new file mode 100644 index 00000000000..67d556bfa1b --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-host-effect-consumer.ts @@ -0,0 +1,234 @@ +import type { TerminalAuthorityNamespace } from '../../shared/terminal-session-authority-identity' +import type { + TerminalAuthorityDurableOutcome, + TerminalAuthorityOutcome +} from '../../shared/terminal-session-authority-mutation' +import type { TerminalAuthorityConsumerAccess } from './terminal-session-authority-access' +import type { TerminalSessionAuthorityHostEffectApplierSlot } from './terminal-session-authority-host-effect-applier' +import type { TerminalSessionAuthorityRegistry } from './terminal-session-authority-registry' +import type { TerminalSessionAuthorityService } from './terminal-session-authority-service' + +const OUTCOME_READ_PAGE_SIZE = 64 +const HOST_EFFECT_RETRY_MS = 100 + +export function terminalAuthorityHostEffectConsumerId(authorityHostId: string): string { + return `host-effects:${authorityHostId}` +} + +type HostEffectRuntime = { + service: TerminalSessionAuthorityService + consumer: TerminalAuthorityConsumerAccess + observer: ReturnType + requested: boolean + pump: Promise | null + retryTimer: ReturnType | null +} + +export class TerminalSessionAuthorityHostEffectConsumer { + private readonly runtimes = new Map>() + private startPromise: Promise | null = null + private disposed = false + + constructor( + private readonly registry: TerminalSessionAuthorityRegistry, + private readonly consumerIncarnationId: string, + private readonly applier: TerminalSessionAuthorityHostEffectApplierSlot + ) {} + + start(): Promise { + this.startPromise ??= Promise.all( + this.registry.registeredNamespaces().map(async ({ namespace }) => { + const service = await this.registry.openNamespace(namespace) + await this.ensure(service) + }) + ).then(() => undefined) + return this.startPromise + } + + async ensure(service: TerminalSessionAuthorityService): Promise { + const runtime = await this.open(service) + this.requestRuntime(runtime) + } + + assertApplierInstalled(): void { + if (!this.applier.isInstalled()) { + throw new Error('terminal session authority host effect applier is unavailable') + } + } + + requestAll(): void { + for (const opening of this.runtimes.values()) { + void opening.then( + (runtime) => this.requestRuntime(runtime), + () => undefined + ) + } + } + + dispose(): void { + if (this.disposed) { + return + } + this.disposed = true + for (const opening of this.runtimes.values()) { + void opening.then( + (runtime) => { + if (runtime.retryTimer) { + clearTimeout(runtime.retryTimer) + runtime.retryTimer = null + } + try { + runtime.service.revokeObserver(runtime.observer) + } catch { + // The authority service may already be closing. + } + }, + () => undefined + ) + } + } + + private open(service: TerminalSessionAuthorityService): Promise { + const key = namespaceKey(service.namespace) + const existing = this.runtimes.get(key) + if (existing) { + return existing + } + const opening = this.openRuntime(service) + this.runtimes.set(key, opening) + void opening.catch(() => this.runtimes.delete(key)) + return opening + } + + private async openRuntime(service: TerminalSessionAuthorityService): Promise { + const consumerId = terminalAuthorityHostEffectConsumerId(service.namespace.authorityHostId) + const expectedIncarnationId = service.activeConsumerIncarnation( + service.writerAccess, + consumerId + ) + const consumer = await service.claimConsumer(service.writerAccess, { + consumerId, + expectedIncarnationId, + consumerIncarnationId: this.consumerIncarnationId + }) + let runtime!: HostEffectRuntime + const observer = service.subscribeProjection(consumerId, () => this.requestRuntime(runtime)) + runtime = { + service, + consumer, + observer, + requested: false, + pump: null, + retryTimer: null + } + return runtime + } + + private requestRuntime(runtime: HostEffectRuntime): void { + if (this.disposed) { + return + } + if (runtime.retryTimer) { + clearTimeout(runtime.retryTimer) + runtime.retryTimer = null + } + runtime.requested = true + this.startPump(runtime) + } + + private startPump(runtime: HostEffectRuntime): void { + if (runtime.pump) { + return + } + const pump = this.runPump(runtime) + runtime.pump = pump + void pump.then(() => { + if (runtime.pump !== pump) { + return + } + runtime.pump = null + if (runtime.requested) { + this.startPump(runtime) + } + }) + } + + private async runPump(runtime: HostEffectRuntime): Promise { + while (runtime.requested) { + runtime.requested = false + try { + await this.drain(runtime) + } catch { + this.scheduleRetry(runtime) + return + } + } + } + + private scheduleRetry(runtime: HostEffectRuntime): void { + if (this.disposed || runtime.retryTimer) { + return + } + runtime.retryTimer = setTimeout(() => { + runtime.retryTimer = null + this.requestRuntime(runtime) + }, HOST_EFFECT_RETRY_MS) + runtime.retryTimer.unref?.() + } + + private async drain(runtime: HostEffectRuntime): Promise { + let cursor = (await runtime.service.snapshotForConsumer(runtime.consumer)).acknowledgedSequence + while (true) { + const snapshot = await runtime.service.snapshotForConsumer(runtime.consumer) + if (cursor >= snapshot.outcomeHighWatermark) { + return + } + const read = await runtime.service.readOutcomes( + runtime.consumer, + cursor, + OUTCOME_READ_PAGE_SIZE + ) + if (read.kind !== 'entries') { + throw new Error(`terminal authority host effect replay requires ${read.reason}`) + } + if (read.entries.length === 0) { + throw new Error('terminal authority host effect replay made no progress') + } + for (const outcome of read.entries) { + await this.apply(outcome) + cursor = outcome.sequence + } + await runtime.service.acknowledgeOutcomes(runtime.consumer, cursor) + } + } + + private async apply(outcome: TerminalAuthorityDurableOutcome): Promise { + if (outcome.kind === 'semantic') { + return + } + await this.applyMutation(outcome) + } + + private async applyMutation(outcome: TerminalAuthorityOutcome): Promise { + for (const effect of outcome.result.effects) { + if (effect.kind !== 'binding-retired') { + continue + } + await this.applier.ensureBindingRetired( + Object.freeze({ + namespace: Object.freeze({ ...outcome.result.namespace }), + pane: Object.freeze({ + paneKey: outcome.result.pane.paneKey, + paneGenerationId: outcome.result.pane.paneGenerationId + }), + binding: Object.freeze({ ...effect.binding }) + }), + effect.reason + ) + } + } +} + +function namespaceKey(namespace: TerminalAuthorityNamespace): string { + return JSON.stringify([namespace.authorityHostId, namespace.namespaceId]) +} diff --git a/src/main/session-authority/terminal-session-authority-host-identity.test.ts b/src/main/session-authority/terminal-session-authority-host-identity.test.ts new file mode 100644 index 00000000000..1aedeb419f7 --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-host-identity.test.ts @@ -0,0 +1,164 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + inspectTerminalAuthorityExecutionScope, + readOrCreateTerminalAuthorityHostId +} from './terminal-session-authority-host-identity' + +const directories: string[] = [] + +afterEach(() => { + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +describe('terminal authority host identity', () => { + it('publishes one complete identity across concurrent first writers', async () => { + const directory = freshDirectory() + let nextId = 0 + const ids = await Promise.all( + Array.from({ length: 20 }, () => + readOrCreateTerminalAuthorityHostId(directory, () => `host-${++nextId}`) + ) + ) + + expect(new Set(ids)).toEqual(new Set([ids[0]])) + await expect(readOrCreateTerminalAuthorityHostId(directory, () => 'late-host')).resolves.toBe( + ids[0] + ) + }) + + it('ignores an unpublished partial directory from a crashed contender', async () => { + const directory = freshDirectory() + const partial = path.join( + directory, + `authority-host.json.${process.pid}.00000000-0000-4000-8000-000000000000.tmp` + ) + mkdirSync(partial) + writeFileSync(path.join(partial, 'identity.json'), '{"version":1') + + await expect(readOrCreateTerminalAuthorityHostId(directory, () => 'host-a')).resolves.toBe( + 'host-a' + ) + }) + + it.each(['', '{"version":1'])( + 'normalizes an incomplete published record to corruption', + async (contents) => { + const directory = freshDirectory() + writeFileSync(path.join(directory, 'authority-host.json'), contents) + + await expect(readOrCreateTerminalAuthorityHostId(directory)).rejects.toMatchObject({ + code: 'record-corrupt' + }) + } + ) + + it('binds Linux execution scope to the physical host and PID namespace', async () => { + const dependencies = { + readHostIdentity: vi.fn(async () => 'host-a'), + readBootIdentity: vi.fn(async () => 'boot-a'), + readLinuxPidNamespace: vi.fn(async () => 'pid:[101]') + } + const first = await inspectTerminalAuthorityExecutionScope('linux', dependencies) + const same = await inspectTerminalAuthorityExecutionScope('linux', dependencies) + const otherBoot = await inspectTerminalAuthorityExecutionScope('linux', { + ...dependencies, + readBootIdentity: async () => 'boot-b' + }) + const otherNamespace = await inspectTerminalAuthorityExecutionScope('linux', { + ...dependencies, + readLinuxPidNamespace: async () => 'pid:[202]' + }) + const otherHost = await inspectTerminalAuthorityExecutionScope('linux', { + ...dependencies, + readHostIdentity: async () => 'host-b' + }) + + expect(first).toEqual({ + executionScope: expect.stringMatching(/^terminal-authority-execution-v1:linux:/), + bootId: 'boot-a', + linuxPidNamespace: 'pid:[101]' + }) + expect(same).toEqual(first) + expect(otherBoot.executionScope).toBe(first.executionScope) + expect(otherBoot.bootId).toBe('boot-b') + expect(otherNamespace.executionScope).not.toBe(first.executionScope) + expect(otherHost.executionScope).not.toBe(first.executionScope) + expect(dependencies.readLinuxPidNamespace).toHaveBeenCalledWith('self') + }) + + it('uses the proven macOS host identity as its global process namespace', async () => { + const first = await inspectTerminalAuthorityExecutionScope('darwin', { + readHostIdentity: async () => 'host-a' + }) + const otherHost = await inspectTerminalAuthorityExecutionScope('darwin', { + readHostIdentity: async () => 'host-b' + }) + + expect(first.executionScope).toMatch(/^terminal-authority-execution-v1:darwin:/) + expect(otherHost.executionScope).not.toBe(first.executionScope) + }) + + it('uses a proven Windows host identity for its global process namespace', async () => { + const first = await inspectTerminalAuthorityExecutionScope('win32', { + readHostIdentity: async () => 'win32-machine:machine-a' + }) + const otherHost = await inspectTerminalAuthorityExecutionScope('win32', { + readHostIdentity: async () => 'win32-machine:machine-b' + }) + const runtimeFallback = await inspectTerminalAuthorityExecutionScope('win32', { + readHostIdentity: async () => 'runtime:unproven' + }) + + expect(first.executionScope).toMatch(/^terminal-authority-execution-v1:win32:/) + expect(otherHost.executionScope).not.toBe(first.executionScope) + expect(runtimeFallback).toEqual({}) + }) + + it('never constructs a Linux scope without host or namespace evidence', async () => { + const complete = { + readHostIdentity: async (): Promise => 'host-a', + readBootIdentity: async (): Promise => 'boot-a', + readLinuxPidNamespace: async (): Promise => 'pid:[101]' + } + const scopes = await Promise.all([ + inspectTerminalAuthorityExecutionScope('linux', { + ...complete, + readHostIdentity: async () => undefined + }), + inspectTerminalAuthorityExecutionScope('linux', { + ...complete, + readLinuxPidNamespace: async () => undefined + }) + ]) + + expect(scopes.every((scope) => scope.executionScope === undefined)).toBe(true) + }) + + it.each(['linux', 'darwin', 'win32'] as const)( + 'keeps an unreadable %s execution scope unknown', + async (platform) => { + const unreadable = async (): Promise => { + throw new Error('scope unavailable') + } + + await expect( + inspectTerminalAuthorityExecutionScope(platform, { + readBootIdentity: unreadable, + readHostIdentity: unreadable, + readLinuxPidNamespace: unreadable + }) + ).resolves.toEqual({}) + } + ) +}) + +function freshDirectory(): string { + const directory = mkdtempSync(path.join(tmpdir(), 'orca-authority-host-identity-')) + directories.push(directory) + return directory +} diff --git a/src/main/session-authority/terminal-session-authority-host-identity.ts b/src/main/session-authority/terminal-session-authority-host-identity.ts new file mode 100644 index 00000000000..c1c4fd953f4 --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-host-identity.ts @@ -0,0 +1,261 @@ +import { createHash, randomUUID } from 'node:crypto' +import { + mkdir, + open, + readFile, + readlink, + rename, + rm, + stat, + type FileHandle +} from 'node:fs/promises' +import path from 'node:path' +import { + readBootIdentity, + readManagedHookHostIdentity +} from '../agent-hooks/managed-hook-owner-identity' +import { + assertAuthorityId, + assertAuthorityStoragePath, + isRecord +} from '../../shared/terminal-session-authority-identity' +import { failTerminalSessionAuthority } from '../../shared/terminal-session-authority-mutation' + +const HOST_IDENTITY_PATH = 'authority-host.json' +const HOST_IDENTITY_RECORD = 'identity.json' +const MAX_HOST_IDENTITY_BYTES = 4 * 1024 +const MAX_EXECUTION_SCOPE_COMPONENT_BYTES = 1_024 +const UNSAFE_EXECUTION_SCOPE_TEXT = /[\p{Cc}\u2028\u2029]/u + +export type TerminalAuthorityExecutionScope = Readonly<{ + executionScope?: string + bootId?: string + linuxPidNamespace?: string +}> + +type TerminalAuthorityExecutionScopeDependencies = Readonly<{ + readBootIdentity?: () => Promise + readHostIdentity?: () => Promise + readLinuxPidNamespace?: (pid: number | 'self') => Promise +}> + +let currentExecutionScopePromise: Promise | undefined + +export async function readCurrentTerminalAuthorityExecutionScope(): Promise { + currentExecutionScopePromise ??= inspectTerminalAuthorityExecutionScope(process.platform) + return await currentExecutionScopePromise +} + +export async function inspectTerminalAuthorityExecutionScope( + platform: NodeJS.Platform, + dependencies: TerminalAuthorityExecutionScopeDependencies = {} +): Promise { + try { + if (platform === 'linux') { + const [hostIdentity, bootId, linuxPidNamespace] = await Promise.all([ + (dependencies.readHostIdentity ?? readProvenManagedHookHostIdentity)(), + (dependencies.readBootIdentity ?? readBootIdentity)(), + (dependencies.readLinuxPidNamespace ?? readTerminalAuthorityLinuxPidNamespace)('self') + ]) + return createExecutionScope(platform, [provenHostIdentity(hostIdentity), linuxPidNamespace], { + bootId, + linuxPidNamespace + }) + } + if (platform === 'darwin') { + const hostIdentity = await ( + dependencies.readHostIdentity ?? readProvenManagedHookHostIdentity + )() + return createExecutionScope(platform, [provenHostIdentity(hostIdentity), 'global']) + } + if (platform === 'win32') { + const hostIdentity = await ( + dependencies.readHostIdentity ?? readProvenManagedHookHostIdentity + )() + return createExecutionScope(platform, [provenHostIdentity(hostIdentity), 'global']) + } + } catch { + // An unreadable scope cannot authorize owner death. + } + return Object.freeze({}) +} + +export async function readTerminalAuthorityLinuxPidNamespace( + pid: number | 'self' +): Promise { + try { + return normalizeExecutionScopeComponent(await readlink(`/proc/${pid}/ns/pid`)) + } catch { + return undefined + } +} + +export async function readOrCreateTerminalAuthorityHostId( + directory: string, + createId: () => string = randomUUID +): Promise { + assertAuthorityStoragePath(directory, 'authority host directory') + const resolvedDirectory = path.resolve(directory) + await mkdir(resolvedDirectory, { recursive: true }) + const existing = await readHostId(resolvedDirectory) + if (existing) { + return existing + } + + const authorityHostId = createId() + assertAuthorityId(authorityHostId, 'authorityHostId') + const target = path.join(resolvedDirectory, HOST_IDENTITY_PATH) + const temporary = `${target}.${process.pid}.${randomUUID()}.tmp` + try { + await mkdir(temporary, { mode: 0o700 }) + await writeSyncedFile( + path.join(temporary, HOST_IDENTITY_RECORD), + `${JSON.stringify({ version: 1, authorityHostId })}\n` + ) + await syncDirectory(temporary) + if (await publishDirectoryExclusive(temporary, target)) { + await syncDirectory(resolvedDirectory) + } + } finally { + await rm(temporary, { recursive: true, force: true }) + } + const published = await readHostId(resolvedDirectory) + if (!published) { + failTerminalSessionAuthority('record-corrupt', 'authority host identity was not published') + } + return published +} + +async function readHostId(directory: string): Promise { + const target = path.join(directory, HOST_IDENTITY_PATH) + let metadata + try { + metadata = await stat(target) + } catch (error) { + if (errorCode(error) === 'ENOENT') { + return null + } + throw error + } + if (metadata.isFile()) { + return readIdentityRecord(target, metadata.size) + } + if (!metadata.isDirectory()) { + failTerminalSessionAuthority('record-corrupt', 'authority host identity is invalid') + } + const record = path.join(target, HOST_IDENTITY_RECORD) + let recordMetadata + try { + recordMetadata = await stat(record) + } catch { + failTerminalSessionAuthority('record-corrupt', 'authority host identity is corrupt') + } + if (!recordMetadata.isFile()) { + failTerminalSessionAuthority('record-corrupt', 'authority host identity is invalid') + } + return readIdentityRecord(record, recordMetadata.size) +} + +async function readIdentityRecord(file: string, size: number): Promise { + if (size < 1 || size > MAX_HOST_IDENTITY_BYTES) { + failTerminalSessionAuthority('record-corrupt', 'authority host identity is invalid') + } + let value: unknown + try { + value = JSON.parse(await readFile(file, 'utf8')) as unknown + } catch { + failTerminalSessionAuthority('record-corrupt', 'authority host identity is corrupt') + } + if (!isRecord(value) || value.version !== 1) { + failTerminalSessionAuthority('record-corrupt', 'authority host identity is corrupt') + } + assertAuthorityId(value.authorityHostId, 'authorityHostId') + return value.authorityHostId +} + +async function publishDirectoryExclusive(source: string, target: string): Promise { + try { + await rename(source, target) + return true + } catch (error) { + try { + await stat(target) + return false + } catch (targetError) { + if (errorCode(targetError) !== 'ENOENT') { + throw targetError + } + throw error + } + } +} + +async function writeSyncedFile(file: string, contents: string): Promise { + const handle = await open(file, 'wx', 0o600) + try { + await handle.writeFile(contents, 'utf8') + await handle.sync() + } finally { + await handle.close() + } +} + +async function syncDirectory(directory: string): Promise { + let handle: FileHandle | null = null + try { + handle = await open(directory, 'r') + await handle.sync() + } catch (error) { + if (!['EACCES', 'EBADF', 'EINVAL', 'EISDIR', 'ENOTSUP', 'EPERM'].includes(errorCode(error))) { + throw error + } + } finally { + await handle?.close() + } +} + +function createExecutionScope( + platform: NodeJS.Platform, + components: readonly (string | undefined)[], + evidence: Omit = {} +): TerminalAuthorityExecutionScope { + const normalizedComponents = components.map(normalizeExecutionScopeComponent) + const bootId = normalizeExecutionScopeComponent(evidence.bootId) + const linuxPidNamespace = normalizeExecutionScopeComponent(evidence.linuxPidNamespace) + const normalizedEvidence = { + ...(bootId ? { bootId } : {}), + ...(linuxPidNamespace ? { linuxPidNamespace } : {}) + } + if (normalizedComponents.some((component) => component === undefined)) { + return Object.freeze(normalizedEvidence) + } + const digest = createHash('sha256') + .update(JSON.stringify([platform, ...normalizedComponents])) + .digest('base64url') + return Object.freeze({ + executionScope: `terminal-authority-execution-v1:${platform}:${digest}`, + ...normalizedEvidence + }) +} + +async function readProvenManagedHookHostIdentity(): Promise { + return provenHostIdentity(await readManagedHookHostIdentity()) +} + +function provenHostIdentity(identity: string | undefined): string | undefined { + const normalized = normalizeExecutionScopeComponent(identity) + return normalized?.startsWith('runtime:') ? undefined : normalized +} + +function normalizeExecutionScopeComponent(value: string | undefined): string | undefined { + const normalized = value?.trim() + return normalized && + !UNSAFE_EXECUTION_SCOPE_TEXT.test(normalized) && + new TextEncoder().encode(normalized).byteLength <= MAX_EXECUTION_SCOPE_COMPONENT_BYTES + ? normalized + : undefined +} + +function errorCode(error: unknown): string { + return typeof error === 'object' && error !== null && 'code' in error ? String(error.code) : '' +} diff --git a/src/main/session-authority/terminal-session-authority-host-runtime.test.ts b/src/main/session-authority/terminal-session-authority-host-runtime.test.ts new file mode 100644 index 00000000000..56844c4d598 --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-host-runtime.test.ts @@ -0,0 +1,343 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + acquireTerminalAuthorityWriterGuard, + writeTerminalAuthorityWriterMarker +} from './terminal-authority-writer-storage' +import { TerminalSessionAuthorityHostRuntime } from './terminal-session-authority-host-runtime' +import { encodeTerminalAuthorityOwnerToken } from './terminal-session-authority-owner-token' +import { + terminalAuthorityOwnerProcessObservationProvesGone, + type TerminalAuthorityOwnerProcessIdentity +} from './terminal-session-authority-owner-process' +import { TerminalAuthorityWriterLock } from './terminal-session-authority-writer-lock' + +const directories: string[] = [] +afterEach(() => { + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +describe('TerminalSessionAuthorityHostRuntime', () => { + it('keeps one durable host identity and mints a fresh owner after clean restart', async () => { + const directory = freshDirectory() + const first = await openRuntime(directory, ['host-a', 'writer-a', 'owner-a']) + await first.close() + const second = await openRuntime(directory, ['writer-b', 'owner-b']) + + expect(second.authorityHostId).toBe(first.authorityHostId) + expect(second.ownerIncarnationId).not.toBe(first.ownerIncarnationId) + await second.close() + }) + + it('takes over an active writer only after positive predecessor-death proof', async () => { + const directory = freshDirectory() + const first = await openRuntime(directory, ['host-a', 'writer-a', 'owner-a']) + const predecessorIsGone = vi.fn(async () => true) + const second = await openRuntime(directory, ['writer-b', 'owner-b'], predecessorIsGone) + + expect(predecessorIsGone).toHaveBeenCalledOnce() + expect(second.authorityHostId).toBe(first.authorityHostId) + await expect(first.close()).rejects.toMatchObject({ code: 'writer-fenced' }) + await second.close() + }) + + it('keeps an indeterminate predecessor fenced', async () => { + const directory = freshDirectory() + const first = await openRuntime(directory, ['host-a', 'writer-a', 'owner-a']) + const predecessorIsGone = vi.fn(async () => false) + + await expect( + openRuntime(directory, ['writer-b', 'owner-b'], predecessorIsGone) + ).rejects.toMatchObject({ code: 'writer-fenced' }) + expect(predecessorIsGone).toHaveBeenCalledOnce() + await first.close() + }) + + it('recovers a missing predecessor only inside the same execution scope', async () => { + const directory = freshDirectory() + const processIdentity = linuxProcessIdentity('host-a', 'pid:[101]') + const first = await openRuntime( + directory, + ['host-a', 'writer-a', 'owner-a'], + undefined, + processIdentity + ) + const second = await openRuntime( + directory, + ['writer-b', 'owner-b'], + async (proof) => + terminalAuthorityOwnerProcessObservationProvesGone(proof.process, { + status: 'missing', + platform: 'linux', + bootId: 'shared-boot', + linuxPidNamespace: 'pid:[101]', + executionScope: 'host-a' + }), + processIdentity + ) + + await expect(first.close()).rejects.toMatchObject({ code: 'writer-fenced' }) + await second.close() + }) + + it.each([ + ['pid:[101]', 'pid:[202]'], + ['pid:[202]', 'pid:[101]'] + ])( + 'keeps shared storage fenced across Linux PID namespaces (%s to %s)', + async (owner, observer) => { + const directory = freshDirectory() + const first = await openRuntime( + directory, + ['host-a', 'writer-a', 'owner-a'], + undefined, + linuxProcessIdentity('host-a', owner) + ) + + await expect( + openRuntime( + directory, + ['writer-b', 'owner-b'], + async (proof) => + terminalAuthorityOwnerProcessObservationProvesGone(proof.process, { + status: 'missing', + platform: 'linux', + bootId: 'shared-boot', + linuxPidNamespace: observer, + executionScope: 'host-a' + }), + linuxProcessIdentity('host-a', observer) + ) + ).rejects.toMatchObject({ code: 'writer-fenced' }) + await first.close() + } + ) + + it('keeps shared storage fenced across Windows hosts with colliding PIDs', async () => { + const directory = freshDirectory() + const first = await openRuntime( + directory, + ['host-a', 'writer-a', 'owner-a'], + undefined, + windowsProcessIdentity('machine-a') + ) + + await expect( + openRuntime( + directory, + ['writer-b', 'owner-b'], + async (proof) => + terminalAuthorityOwnerProcessObservationProvesGone(proof.process, { + status: 'missing', + platform: 'win32', + executionScope: 'machine-b' + }), + windowsProcessIdentity('machine-b') + ) + ).rejects.toMatchObject({ code: 'writer-fenced' }) + await first.close() + }) + + it.each(['guard-published', 'marker-published'] as const)( + 'recovers a first owner that crashed at %s from its lease-carried process proof', + async (boundary) => { + const directory = freshDirectory() + const proof = { + ownerIncarnationId: 'crashed-owner', + process: { pid: 88_888, platform: 'legacy' as const, startedAtMs: 1_600_000_000_000 } + } + const ownerToken = encodeTerminalAuthorityOwnerToken('crashed-nonce', proof) + const registryDirectory = path.join(directory, 'registry') + mkdirSync(registryDirectory, { recursive: true }) + await acquireTerminalAuthorityWriterGuard(registryDirectory, ownerToken) + if (boundary === 'marker-published') { + await writeTerminalAuthorityWriterMarker(registryDirectory, { + version: 1, + ownerToken, + epoch: 1, + active: true + }) + } + + const replacement = await openRuntime( + directory, + ['host-a', 'writer-b', 'owner-b'], + async (candidate) => candidate.ownerIncarnationId === proof.ownerIncarnationId + ) + expect(replacement.ownerIncarnationId).toBe('owner-b') + await replacement.close() + } + ) + + it('recovers a replacement that crashed after publishing its takeover marker', async () => { + const directory = freshDirectory() + const first = await openRuntime(directory, ['host-a', 'writer-a', 'owner-a']) + const registryDirectory = path.join(directory, 'registry') + const firstToken = await TerminalAuthorityWriterLock.readCurrentOwnerToken(registryDirectory) + expect(firstToken).not.toBeNull() + const replacementToken = encodeTerminalAuthorityOwnerToken('writer-b', { + ownerIncarnationId: 'owner-b', + process: { + pid: 77_777, + platform: 'legacy', + startedAtMs: 1_650_000_000_000 + } + }) + await TerminalAuthorityWriterLock.acquire({ + directory: registryDirectory, + ownerToken: replacementToken, + takeoverOwnerToken: firstToken! + }) + + const finalRuntime = await openRuntime( + directory, + ['writer-c', 'owner-c'], + async (candidate) => candidate.ownerIncarnationId === 'owner-b' + ) + expect(finalRuntime.ownerIncarnationId).toBe('owner-c') + await expect(first.close()).rejects.toMatchObject({ code: 'writer-fenced' }) + await finalRuntime.close() + }) + + it.each([ + { name: 'only marker owner', gone: ['owner-a'], succeeds: false }, + { name: 'only guard owner', gone: ['owner-b'], succeeds: false }, + { name: 'both owners', gone: ['owner-a', 'owner-b'], succeeds: true } + ])( + 'requires positive death proof for $name across a marker-A and guard-B crash', + async ({ gone, succeeds }) => { + const directory = freshDirectory() + const first = await openRuntime(directory, ['host-a', 'writer-a', 'owner-a']) + const registryDirectory = path.join(directory, 'registry') + const guardToken = encodeTerminalAuthorityOwnerToken('writer-b', { + ownerIncarnationId: 'owner-b', + process: { + pid: 77_777, + platform: 'legacy', + startedAtMs: 1_650_000_000_000 + } + }) + await acquireTerminalAuthorityWriterGuard(registryDirectory, guardToken) + const checkedOwners: string[] = [] + const replacement = openRuntime(directory, ['writer-c', 'owner-c'], async (proof) => { + checkedOwners.push(proof.ownerIncarnationId) + return gone.includes(proof.ownerIncarnationId) + }) + + if (!succeeds) { + await expect(replacement).rejects.toMatchObject({ code: 'writer-fenced' }) + expect(checkedOwners).toEqual( + gone.includes('owner-b') ? ['owner-b', 'owner-a'] : ['owner-b'] + ) + if (gone.includes('owner-b')) { + await first.close() + } + return + } + const finalRuntime = await replacement + expect(checkedOwners).toEqual(['owner-b', 'owner-a']) + await expect(first.close()).rejects.toMatchObject({ code: 'writer-fenced' }) + await finalRuntime.close() + } + ) + + it('fails closed for corrupt and oversized self-describing owner tokens', async () => { + const corruptDirectory = freshDirectory() + const corrupt = await TerminalAuthorityWriterLock.acquire({ + directory: path.join(corruptDirectory, 'registry'), + ownerToken: 'terminal-authority-owner-v1.not-json' + }) + const predecessorIsGone = vi.fn(async () => true) + await expect( + openRuntime(corruptDirectory, ['host-a', 'writer-b', 'owner-b'], predecessorIsGone) + ).rejects.toMatchObject({ code: 'writer-fenced' }) + expect(predecessorIsGone).not.toHaveBeenCalled() + await corrupt.release() + + const oversizedDirectory = freshDirectory() + const registryDirectory = path.join(oversizedDirectory, 'registry') + mkdirSync(registryDirectory, { recursive: true }) + writeFileSync( + path.join(registryDirectory, 'authority-writer.json'), + JSON.stringify({ + version: 1, + ownerToken: `terminal-authority-owner-v1.${'a'.repeat(1_024)}`, + epoch: 1, + active: true + }) + ) + await expect( + openRuntime(oversizedDirectory, ['host-a', 'writer-c', 'owner-c'], predecessorIsGone) + ).rejects.toThrow('writer ownerToken is invalid') + expect(predecessorIsGone).not.toHaveBeenCalled() + }) + + it('admits only one concurrent runtime owner', async () => { + const directory = freshDirectory() + const [left, right] = await Promise.allSettled([ + openRuntime(directory, ['host-left', 'writer-left', 'owner-left'], async () => false), + openRuntime(directory, ['host-right', 'writer-right', 'owner-right'], async () => false) + ]) + const results = [left, right] + const winners = results.filter( + (result): result is PromiseFulfilledResult => + result.status === 'fulfilled' + ) + const losers = results.filter( + (result): result is PromiseRejectedResult => result.status === 'rejected' + ) + expect(winners).toHaveLength(1) + expect(losers).toHaveLength(1) + expect(losers[0]?.reason).toMatchObject({ code: 'writer-fenced' }) + await winners[0]!.value.close() + }) +}) + +function freshDirectory(): string { + const directory = mkdtempSync(path.join(tmpdir(), 'orca-authority-host-')) + directories.push(directory) + return directory +} + +async function openRuntime( + directory: string, + ids: string[], + predecessorIsGone?: NonNullable< + Parameters[0]['predecessorIsGone'] + >, + processIdentity: TerminalAuthorityOwnerProcessIdentity = { + pid: 99_999, + platform: 'legacy', + startedAtMs: 1_700_000_000_000 + } +): Promise { + let index = 0 + return await TerminalSessionAuthorityHostRuntime.open({ + directory, + processIdentity, + createId: () => ids[index++] ?? `unexpected-${index}`, + ...(predecessorIsGone ? { predecessorIsGone } : {}) + }) +} + +function linuxProcessIdentity( + executionScope: string, + linuxPidNamespace: string +): TerminalAuthorityOwnerProcessIdentity { + return { + pid: 1, + platform: 'linux', + linuxStartTicks: '100', + linuxPidNamespace, + bootId: 'shared-boot', + executionScope + } +} + +function windowsProcessIdentity(executionScope: string): TerminalAuthorityOwnerProcessIdentity { + return { pid: 42, platform: 'win32', startedAtMs: 1_000, executionScope } +} diff --git a/src/main/session-authority/terminal-session-authority-host-runtime.ts b/src/main/session-authority/terminal-session-authority-host-runtime.ts new file mode 100644 index 00000000000..55b2c96d8d3 --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-host-runtime.ts @@ -0,0 +1,110 @@ +import { randomUUID } from 'node:crypto' +import { mkdir } from 'node:fs/promises' +import path from 'node:path' +import { + assertAuthorityId, + assertAuthorityStoragePath +} from '../../shared/terminal-session-authority-identity' +import { readOrCreateTerminalAuthorityHostId } from './terminal-session-authority-host-identity' +import { + terminalAuthorityOwnerProcessIsGone, + type TerminalAuthorityOwnerProcessIdentity +} from './terminal-session-authority-owner-process' +import { + assertTerminalAuthorityOwnerProcessIdentity, + encodeTerminalAuthorityOwnerToken, + parseTerminalAuthorityOwnerToken, + type TerminalAuthorityOwnerProof +} from './terminal-session-authority-owner-token' +import { TerminalSessionAuthorityPtyOwner } from './terminal-session-authority-pty-owner' +import { TerminalSessionAuthorityRegistry } from './terminal-session-authority-registry' +import { openTerminalAuthorityWriterWithRecovery } from './terminal-authority-writer-recovery' +import type { TerminalSessionAuthorityHostEffectApplier } from './terminal-session-authority-host-effect-applier' + +export type TerminalSessionAuthorityHostRuntimeOptions = Readonly<{ + directory: string + processIdentity: TerminalAuthorityOwnerProcessIdentity + hostEffectApplier?: TerminalSessionAuthorityHostEffectApplier + createId?: () => string + predecessorIsGone?: (proof: TerminalAuthorityOwnerProof) => Promise +}> + +export class TerminalSessionAuthorityHostRuntime { + readonly authorityHostId: string + readonly ownerIncarnationId: string + readonly ptyOwner: TerminalSessionAuthorityPtyOwner + private closed = false + + private constructor( + private readonly registry: TerminalSessionAuthorityRegistry, + authorityHostId: string, + ownerIncarnationId: string + ) { + this.authorityHostId = authorityHostId + this.ownerIncarnationId = ownerIncarnationId + this.ptyOwner = new TerminalSessionAuthorityPtyOwner({ + registry, + ownerIncarnationId + }) + } + + static async open( + options: TerminalSessionAuthorityHostRuntimeOptions + ): Promise { + assertAuthorityStoragePath(options.directory, 'authority host runtime directory') + assertTerminalAuthorityOwnerProcessIdentity(options.processIdentity) + const directory = path.resolve(options.directory) + await mkdir(directory, { recursive: true }) + const createId = options.createId ?? randomUUID + const authorityHostId = await readOrCreateTerminalAuthorityHostId(directory, createId) + const ownerNonce = createId() + const ownerIncarnationId = createId() + assertAuthorityId(ownerNonce, 'authority owner nonce') + assertAuthorityId(ownerIncarnationId, 'ownerIncarnationId') + const ownerToken = encodeTerminalAuthorityOwnerToken(ownerNonce, { + ownerIncarnationId, + process: Object.freeze({ ...options.processIdentity }) + }) + const predecessorIsGone = + options.predecessorIsGone ?? ((proof) => terminalAuthorityOwnerProcessIsGone(proof.process)) + const claimIsGone = async (candidate: string): Promise => { + const proof = parseTerminalAuthorityOwnerToken(candidate) + return proof ? await predecessorIsGone(proof) : false + } + const registryDirectory = path.join(directory, 'registry') + const openRegistry = (takeoverOwnerToken?: string) => + TerminalSessionAuthorityRegistry.open({ + directory: registryDirectory, + authorityHostId, + ownerToken, + ownerIncarnationId, + writerActorId: `pty-owner:${authorityHostId}`, + writerClaimIsGone: claimIsGone, + ...(takeoverOwnerToken ? { takeoverOwnerToken } : {}) + }) + const registry = await openTerminalAuthorityWriterWithRecovery({ + directory: registryDirectory, + open: openRegistry, + claimIsGone + }) + const runtime = new TerminalSessionAuthorityHostRuntime( + registry, + authorityHostId, + ownerIncarnationId + ) + if (options.hostEffectApplier) { + runtime.ptyOwner.installHostEffectApplier(options.hostEffectApplier) + } + await runtime.ptyOwner.start() + return runtime + } + + async close(): Promise { + if (this.closed) { + return + } + this.closed = true + this.ptyOwner.dispose() + await this.registry.close() + } +} diff --git a/src/main/session-authority/terminal-session-authority-legacy-inventory.ts b/src/main/session-authority/terminal-session-authority-legacy-inventory.ts new file mode 100644 index 00000000000..4bea56015b9 --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-legacy-inventory.ts @@ -0,0 +1,213 @@ +import { isDeepStrictEqual } from 'node:util' +import { assertTerminalLegacyMigrationImportRequest } from '../../shared/terminal-legacy-cutover-request-validation' +import type { + TerminalLegacyMigrationImportRequest, + TerminalLegacyMigrationReceipt, + TerminalLegacyRecoveryProjection, + TerminalLegacyWorkerRoute +} from '../../shared/terminal-legacy-cutover' +import type { TerminalAuthorityNamespace } from '../../shared/terminal-session-authority-identity' +import { terminalAuthorityNamespaceLocatorKey } from '../../shared/terminal-session-authority-locator' +import { failTerminalSessionAuthority } from '../../shared/terminal-session-authority-mutation' +import { legacyPhysicalPtyKey } from '../../shared/terminal-session-authority-legacy-transition' +import type { TerminalSessionAuthorityService } from './terminal-session-authority-service' +import type { TerminalAuthorityLegacyProjectionSource } from './terminal-session-authority-legacy-projection' + +export type TerminalAuthorityNamespaceMigration = Readonly<{ + namespace: TerminalAuthorityNamespace + request: TerminalLegacyMigrationImportRequest + service: TerminalSessionAuthorityService +}> + +type TerminalAuthorityLegacyInventoryOptions = Readonly<{ + serviceForNamespace: ( + namespace: TerminalAuthorityNamespace + ) => Promise + services: () => Iterable + namespaceMatchesLocator: (namespace: TerminalAuthorityNamespace, locatorKey: string) => boolean +}> + +export async function prepareTerminalAuthorityNamespaceMigrations( + request: TerminalLegacyMigrationImportRequest, + options: TerminalAuthorityLegacyInventoryOptions +): Promise { + assertNamespaceEvidence(request, options) + if (request.mode === 'acknowledge') { + const matches = [...options.services()].filter((service) => + service.legacy + .snapshot() + .recoveries.some((recovery) => recovery.recoveryId === request.recoveryId) + ) + if (matches.length !== 1) { + failTerminalSessionAuthority('expectation-mismatch', 'legacy recovery namespace is ambiguous') + } + return [Object.freeze({ namespace: matches[0]!.namespace, request, service: matches[0]! })] + } + const byNamespace = new Map() + for (const candidate of [...request.imports, ...request.unresolved]) { + byNamespace.set(candidate.namespace.namespaceId, candidate.namespace) + } + const entries: TerminalAuthorityNamespaceMigration[] = [] + for (const namespace of [...byNamespace.values()].sort((left, right) => + left.namespaceId.localeCompare(right.namespaceId) + )) { + const scoped = Object.freeze({ + ...structuredClone(request), + imports: Object.freeze( + request.imports.filter((candidate) => sameNamespace(candidate.namespace, namespace)) + ), + unresolved: Object.freeze( + request.unresolved.filter((candidate) => sameNamespace(candidate.namespace, namespace)) + ) + }) as TerminalLegacyMigrationImportRequest + assertTerminalLegacyMigrationImportRequest(scoped) + entries.push( + Object.freeze({ + namespace, + request: scoped, + service: await options.serviceForNamespace(namespace) + }) + ) + } + return entries +} + +export function assertTerminalAuthorityLegacyGlobalConsistency( + request: TerminalLegacyMigrationImportRequest, + requestDigest: string, + services: Iterable +): void { + const serviceList = [...services] + const source = terminalAuthorityLegacyProjectionSource(serviceList) + const existingOperation = source.migrations.filter( + (migration) => migration.receipt.request.migrationId === request.migrationId + ) + if (existingOperation.some((migration) => migration.requestDigest !== requestDigest)) { + failTerminalSessionAuthority('operation-conflict', 'legacy migration ID was reused') + } + if (request.mode === 'acknowledge') { + return + } + const recoveries = new Map(source.recoveries.map((row) => [row.recoveryId, row])) + const physicalPtys = new Map( + source.recoveries.map((row) => [legacyPhysicalPtyKey(row), row.recoveryId]) + ) + const duplicateNamespaces = new Set( + existingOperation.map((migration) => migration.namespace.namespaceId) + ) + const requestRecoveryIds = new Set() + const requestPhysicalPtys = new Set() + for (const candidate of [...request.imports, ...request.unresolved]) { + if (duplicateNamespaces.has(candidate.namespace.namespaceId)) { + continue + } + const existing = recoveries.get(candidate.recoveryId) + const physicalKey = legacyPhysicalPtyKey(candidate) + const physicalOwner = physicalPtys.get(physicalKey) + const updatesExisting = + existing?.status === 'unresolved' && + sameNamespace(existing.namespace, candidate.namespace) && + physicalOwner === candidate.recoveryId + if ( + (existing && !updatesExisting) || + (physicalOwner !== undefined && physicalOwner !== candidate.recoveryId) || + requestRecoveryIds.has(candidate.recoveryId) || + requestPhysicalPtys.has(physicalKey) + ) { + failTerminalSessionAuthority('operation-conflict', 'legacy inventory identity is not unique') + } + requestRecoveryIds.add(candidate.recoveryId) + requestPhysicalPtys.add(physicalKey) + } + if (request.mode === 'cutover') { + assertGlobalRoute(request.workerRoute, source.workerRoutes) + } +} + +export function terminalAuthorityLegacyProjectionSource( + services: Iterable +): TerminalAuthorityLegacyProjectionSource { + const snapshots = [...services].map((service) => service.legacy.snapshot()) + const workerRoutes = new Map() + const recoveries = new Map() + for (const snapshot of snapshots) { + for (const route of snapshot.workerRoutes) { + const existing = workerRoutes.get(route.routeId) + if (existing && !isDeepStrictEqual(existing, route)) { + failTerminalSessionAuthority('record-corrupt', 'legacy worker route is inconsistent') + } + workerRoutes.set(route.routeId, route) + } + for (const recovery of snapshot.recoveries) { + if (recoveries.has(recovery.recoveryId)) { + failTerminalSessionAuthority('record-corrupt', 'legacy recovery spans namespaces') + } + recoveries.set(recovery.recoveryId, recovery) + } + } + return Object.freeze({ + revision: snapshots.reduce((total, snapshot) => total + snapshot.revision, 0), + migrations: Object.freeze(snapshots.flatMap((snapshot) => [...snapshot.migrations])), + recoveries: Object.freeze([...recoveries.values()]), + workerRoutes: Object.freeze([...workerRoutes.values()]) + }) +} + +export function aggregateTerminalAuthorityLegacyReceipt( + request: TerminalLegacyMigrationImportRequest, + committed: readonly Awaited>[] +): TerminalLegacyMigrationReceipt { + return Object.freeze({ + version: 1, + receiptId: request.migrationId, + sequence: Math.max(...committed.map((entry) => entry.migration.receipt.sequence)), + committedAtMs: Math.max(...committed.map((entry) => entry.migration.receipt.committedAtMs)), + request: Object.freeze(structuredClone(request)), + recoveries: Object.freeze( + committed + .flatMap((entry) => [...entry.migration.receipt.recoveries]) + .sort((left, right) => left.recoveryId.localeCompare(right.recoveryId)) + ) + }) +} + +function assertNamespaceEvidence( + request: TerminalLegacyMigrationImportRequest, + options: TerminalAuthorityLegacyInventoryOptions +): void { + for (const candidate of [...request.imports, ...request.unresolved]) { + if ( + !options.namespaceMatchesLocator( + candidate.namespace, + terminalAuthorityNamespaceLocatorKey(candidate.workspace.locator) + ) + ) { + failTerminalSessionAuthority( + 'expectation-mismatch', + 'legacy migration namespace does not match its workspace evidence' + ) + } + } +} + +function assertGlobalRoute( + route: TerminalLegacyWorkerRoute, + existing: readonly TerminalLegacyWorkerRoute[] +): void { + for (const current of existing) { + if ( + (current.routeId === route.routeId && !isDeepStrictEqual(current, route)) || + (current.workerId === route.workerId && current.routeId !== route.routeId) || + (current.ownerIncarnationId === route.ownerIncarnationId && current.routeId !== route.routeId) + ) { + failTerminalSessionAuthority('operation-conflict', 'legacy worker route is not unique') + } + } +} + +function sameNamespace( + left: TerminalAuthorityNamespace, + right: TerminalAuthorityNamespace +): boolean { + return left.authorityHostId === right.authorityHostId && left.namespaceId === right.namespaceId +} diff --git a/src/main/session-authority/terminal-session-authority-legacy-migration.test.ts b/src/main/session-authority/terminal-session-authority-legacy-migration.test.ts new file mode 100644 index 00000000000..947a0821199 --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-legacy-migration.test.ts @@ -0,0 +1,428 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import type { + TerminalLegacyMigrationImportRequest, + TerminalLegacyUnresolvedCandidate +} from '../../shared/terminal-legacy-cutover' +import type { TerminalAuthorityNamespace } from '../../shared/terminal-session-authority-identity' +import type { TerminalSessionAuthorityLogRecord } from '../../shared/terminal-session-authority-mutation' +import { terminalAuthorityOperationIdentity } from '../../shared/terminal-session-authority-operation-identity' +import { + legacyAcknowledgementRequest, + legacyCutoverRequest, + legacyRecoveryOnlyRequest +} from './__tests__/terminal-legacy-cutover' +import { terminalSessionAuthorityNamespaceDirectory } from './terminal-session-authority-namespace-directory' +import { + TERMINAL_AUTHORITY_CHECKPOINT_FILE, + TERMINAL_AUTHORITY_LOG_FILE +} from './terminal-session-authority-record-files' +import { TerminalSessionAuthorityRegistry } from './terminal-session-authority-registry' + +const directories: string[] = [] +const registries: TerminalSessionAuthorityRegistry[] = [] + +afterEach(async () => { + await Promise.allSettled(registries.splice(0).map((registry) => registry.close())) + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +describe('namespace authority legacy migrations', () => { + it('replays a synced self-contained event and rejects changed operation reuse', async () => { + const directory = freshDirectory() + let armed = false + const registry = await openRegistry(directory, { + onAuthorityCrashBoundary: (boundary, detail) => { + if (armed && boundary === 'record-synced' && detail.eventKind === 'legacy-migration') { + throw new Error('simulated authority response loss') + } + } + }) + const namespace = await resolveNamespace(registry, 1) + const request = legacyRecoveryOnlyRequest({ migrationNumber: 1, namespace }) + armed = true + + await expect(registry.legacy.importMigration(request)).rejects.toThrow( + 'simulated authority response loss' + ) + const record = readAuthorityLog(directory, namespace)[0]! + expect(record.event).toMatchObject({ + kind: 'legacy-migration', + migration: { + requestDigest: expect.any(String), + receipt: { + request: { mode: 'recovery-only', workerEvidence: { workerId: 'worker-1' } }, + recoveries: [{ recoveryId: 'recovery-1', status: 'unresolved' }] + } + } + }) + expect(JSON.stringify(record)).not.toContain('catalog-import-ref') + expect(existsSync(path.join(directory, 'legacy-cutover'))).toBe(false) + await registry.close() + + const restarted = await openRegistry(directory, { ownerToken: 'registry-owner-b' }) + expect(restarted.legacy.recoveryNotices()).toMatchObject({ + revision: 1, + notices: [{ recoveryKey: 'recovery-1', status: 'unresolved' }] + }) + await expect(restarted.legacy.importMigration(request)).resolves.toMatchObject({ + duplicate: true, + receipt: { sequence: 1 } + }) + await expect( + restarted.legacy.importMigration({ ...request, requestedAtMs: request.requestedAtMs + 1 }) + ).rejects.toMatchObject({ code: 'operation-conflict' }) + expect(readAuthorityLog(directory, namespace)).toHaveLength(1) + }) + + it('restores imported bindings, recovery projection, and worker routing from one checkpoint', async () => { + const directory = freshDirectory() + const registry = await openRegistry(directory) + const namespace = await resolveNamespace(registry, 1) + await registry.legacy.importMigration(mixedCutoverRequest(namespace)) + const service = await registry.openNamespace(namespace) + await service.compact(service.writerAccess) + const checkpoint = readCheckpoint(directory, namespace) + expect(checkpoint.snapshot).toMatchObject({ + revision: 1, + legacyMigrations: [ + { + receipt: { + request: { mode: 'cutover', workerRoute: { routeId: 'route-1' } }, + recoveries: [ + { status: 'imported', binding: { physicalPtyId: 'pty-1' } }, + { status: 'unresolved', recoveryId: 'recovery-2' } + ] + } + } + ] + }) + expect(checkpoint.snapshot.catalogReceiptIds).toBeUndefined() + await registry.close() + + const restarted = await openRegistry(directory, { ownerToken: 'registry-owner-b' }) + expect(restarted.legacy.projection()).toMatchObject({ + revision: 1, + workers: [{ routeId: 'route-1' }], + recoveries: [ + { recoveryId: 'recovery-1', status: 'imported' }, + { recoveryId: 'recovery-2', status: 'unresolved' } + ] + }) + expect(restarted.legacy.physicalWorkerEntries()).toMatchObject([ + { route: { routeId: 'route-1' }, cutover: { kind: 'posix-relocated' } } + ]) + expect(restarted.legacy.gcProtection().relayDirectories).toEqual(['/relay/1']) + const restored = await restarted.openNamespace(namespace) + const observer = restored.observe('legacy-checkpoint-observer') + expect(restored.snapshotForObserver(observer)).toMatchObject({ + revision: 1, + panes: [{ paneKey: 'pane-1', ownerStatus: 'owner-unreachable' }] + }) + const recoveryPane = { + paneKey: 'recovery-target', + paneGenerationId: 'recovery-target-generation' + } + await restored.mutate(restored.writerAccess, { + actorId: restored.writerAccess.actorId, + ...terminalAuthorityOperationIdentity(1, 'recovery-target-create'), + baseRevision: 1, + change: { kind: 'create', pane: recoveryPane } + }) + await expect( + restored.mutate(restored.writerAccess, { + actorId: restored.writerAccess.actorId, + ...terminalAuthorityOperationIdentity(2, 'recovery-target-prepare'), + baseRevision: 2, + change: { + kind: 'prepare-allocation', + allocation: { + allocationId: 'recovery-target-allocation', + pane: recoveryPane, + ownerIncarnationId: 'legacy-owner-1', + physicalPtyId: 'pty-2', + spawnFingerprint: 'recovery-target-spawn' + }, + expected: { paneGenerationId: recoveryPane.paneGenerationId, binding: null } + } + }) + ).rejects.toMatchObject({ code: 'allocation-conflict' }) + }) + + it('keeps ambiguous rows visible until an acknowledgement authority operation', async () => { + const directory = freshDirectory() + const registry = await openRegistry(directory) + const namespace = await resolveNamespace(registry, 1) + const unresolved = await registry.legacy.importMigration( + legacyRecoveryOnlyRequest({ + migrationNumber: 1, + namespace, + unresolvedPaneKey: 'legacy-ambiguous-pane' + }) + ) + const service = await registry.openNamespace(namespace) + const observer = service.observe('unresolved-observer') + expect(service.snapshotForObserver(observer).panes).toEqual([]) + await expect( + service.mutate(service.writerAccess, { + actorId: service.writerAccess.actorId, + ...terminalAuthorityOperationIdentity(1, 'unrelated-create'), + baseRevision: 1, + change: { + kind: 'create', + pane: { paneKey: 'unrelated-pane', paneGenerationId: 'unrelated-generation' } + } + }) + ).resolves.toMatchObject({ result: { revision: 2 } }) + await expect( + service.mutate(service.writerAccess, { + actorId: service.writerAccess.actorId, + ...terminalAuthorityOperationIdentity(2, 'ambiguous-create'), + baseRevision: 2, + change: { + kind: 'create', + pane: { paneKey: 'legacy-ambiguous-pane', paneGenerationId: 'new-generation' } + } + }) + ).rejects.toMatchObject({ code: 'expectation-mismatch' }) + await expect( + registry.legacy.importMigration(legacyAcknowledgementRequest(2, 1, 'wrong-operation')) + ).rejects.toMatchObject({ code: 'expectation-mismatch' }) + + await expect( + registry.legacy.importMigration( + legacyAcknowledgementRequest(2, 1, unresolved.receipt.receiptId) + ) + ).resolves.toMatchObject({ + duplicate: false, + receipt: { + sequence: 2, + recoveries: [{ status: 'acknowledged', previousCatalogReceiptId: 'migration-1' }] + } + }) + await expect( + service.mutate(service.writerAccess, { + actorId: service.writerAccess.actorId, + ...terminalAuthorityOperationIdentity(3, 'acknowledged-create'), + baseRevision: 3, + change: { + kind: 'create', + pane: { paneKey: 'legacy-ambiguous-pane', paneGenerationId: 'acknowledged-generation' } + } + }) + ).resolves.toMatchObject({ result: { revision: 4 } }) + // An observer reads the ledger; it never becomes a durable consumer, so no claim is appended. + expect(readAuthorityLog(directory, namespace).map((record) => record.event.kind)).toEqual([ + 'legacy-migration', + 'mutation', + 'legacy-migration', + 'mutation' + ]) + await service.compact(service.writerAccess) + await registry.close() + + const restarted = await openRegistry(directory, { ownerToken: 'registry-owner-b' }) + expect(restarted.legacy.recoveryNotices()).toMatchObject({ + revision: 2, + notices: [{ recoveryKey: 'recovery-1', status: 'acknowledged' }] + }) + expect(restarted.legacy.gcProtection()).toEqual({ relayDirectories: [], evidencePaths: [] }) + }) + + it('commits a multi-namespace inventory independently and retries the exact suffix', async () => { + const directory = freshDirectory() + let nextNamespace = 0 + const registry = await openRegistry(directory, { + createNamespaceId: () => `namespace-${++nextNamespace}`, + maxLogBytes: 6_000 + }) + const namespaceA = await resolveNamespace(registry, 1) + const namespaceB = await resolveNamespace(registry, 2) + const request = multiNamespaceRequest(namespaceA, namespaceB) + + await expect(registry.legacy.importMigration(request)).rejects.toMatchObject({ + code: 'capacity' + }) + expect(registry.legacy.recoveryNoticesForNamespace(namespaceA).notices).toMatchObject([ + { recoveryKey: 'recovery-1', status: 'unresolved' } + ]) + expect(registry.legacy.recoveryNoticesForNamespace(namespaceB).notices).toEqual([]) + expect(readAuthorityLog(directory, namespaceA)).toHaveLength(1) + expect(readAuthorityLog(directory, namespaceB)).toHaveLength(0) + await registry.close() + + const restarted = await openRegistry(directory, { ownerToken: 'registry-owner-b' }) + const completed = await restarted.legacy.importMigration(request) + expect(completed).toMatchObject({ + duplicate: false, + receipt: { sequence: 1 } + }) + const notices = restarted.legacy.recoveryNotices() + expect(notices.revision).toBe(2) + expect(notices.notices.map((notice) => notice.recoveryKey)).toEqual( + expect.arrayContaining(['recovery-1', 'recovery-10']) + ) + expect(notices.notices).toHaveLength(10) + const duplicate = await restarted.legacy.importMigration(request) + expect(duplicate).toMatchObject({ + duplicate: true, + receipt: { sequence: 1 } + }) + expect(duplicate.receipt).toEqual(completed.receipt) + + const namespaceC = await resolveNamespace(restarted, 11) + await expect( + restarted.legacy.importMigration( + legacyRecoveryOnlyRequest({ + migrationNumber: 1, + workerNumber: 2, + recoveryNumbers: [11], + namespace: namespaceC + }) + ) + ).rejects.toMatchObject({ code: 'operation-conflict' }) + }) +}) + +async function openRegistry( + directory: string, + overrides: Partial[0]> = {} +): Promise { + const registry = await TerminalSessionAuthorityRegistry.open({ + directory, + authorityHostId: 'host-a', + ownerToken: 'registry-owner-a', + ownerIncarnationId: 'authority-owner-a', + writerActorId: 'authority-writer-a', + ...overrides + }) + registries.push(registry) + return registry +} + +async function resolveNamespace( + registry: TerminalSessionAuthorityRegistry, + number: number +): Promise { + return ( + await registry.resolveNamespace({ + kind: 'workspace', + canonicalPath: `/repo/${number}`, + pathFlavor: 'posix' + }) + ).namespace +} + +function multiNamespaceRequest( + namespaceA: TerminalAuthorityNamespace, + namespaceB: TerminalAuthorityNamespace +): TerminalLegacyMigrationImportRequest { + const first = legacyRecoveryOnlyRequest({ + migrationNumber: 1, + workerNumber: 1, + recoveryNumbers: [1], + namespace: namespaceA + }) + const suffix = legacyRecoveryOnlyRequest({ + migrationNumber: 1, + workerNumber: 1, + recoveryNumbers: [2, 3, 4, 5, 6, 7, 8, 9, 10], + namespace: namespaceB + }) + if (first.mode !== 'recovery-only' || suffix.mode !== 'recovery-only') { + throw new Error('invalid legacy test fixture') + } + return Object.freeze({ + ...first, + unresolved: Object.freeze([ + ...first.unresolved, + ...suffix.unresolved.map((candidate): TerminalLegacyUnresolvedCandidate => { + if (candidate.workspace.kind !== 'folder') { + throw new Error('invalid legacy workspace test fixture') + } + return Object.freeze({ + ...candidate, + workspace: Object.freeze({ + kind: 'folder' as const, + locator: Object.freeze({ + kind: 'workspace' as const, + canonicalPath: '/repo/2', + pathFlavor: 'posix' as const + }) + }) + }) + }) + ]) + }) +} + +function mixedCutoverRequest( + namespace: TerminalAuthorityNamespace +): TerminalLegacyMigrationImportRequest { + const request = legacyCutoverRequest({ + migrationNumber: 1, + workerNumber: 1, + importedRecoveryNumbers: [1], + unresolvedRecoveryNumbers: [2], + namespace + }) + if (request.mode !== 'cutover') { + throw new Error('invalid legacy cutover test fixture') + } + const unresolved = request.unresolved[0]! + if (unresolved.workspace.kind !== 'folder') { + throw new Error('invalid legacy workspace test fixture') + } + return Object.freeze({ + ...request, + unresolved: Object.freeze([ + Object.freeze({ + ...unresolved, + workspace: Object.freeze({ + kind: 'folder' as const, + locator: Object.freeze({ + kind: 'workspace' as const, + canonicalPath: '/repo/1', + pathFlavor: 'posix' as const + }) + }) + }) + ]) + }) +} + +function readAuthorityLog( + directory: string, + namespace: TerminalAuthorityNamespace +): TerminalSessionAuthorityLogRecord[] { + const file = path.join( + terminalSessionAuthorityNamespaceDirectory(directory, namespace), + TERMINAL_AUTHORITY_LOG_FILE + ) + const contents = readFileSync(file, 'utf8').trim() + return contents + ? contents.split('\n').map((line) => JSON.parse(line) as TerminalSessionAuthorityLogRecord) + : [] +} + +function readCheckpoint(directory: string, namespace: TerminalAuthorityNamespace) { + return JSON.parse( + readFileSync( + path.join( + terminalSessionAuthorityNamespaceDirectory(directory, namespace), + TERMINAL_AUTHORITY_CHECKPOINT_FILE + ), + 'utf8' + ) + ) +} + +function freshDirectory(): string { + const directory = mkdtempSync(path.join(tmpdir(), 'orca-legacy-authority-')) + directories.push(directory) + return directory +} diff --git a/src/main/session-authority/terminal-session-authority-legacy-migration.ts b/src/main/session-authority/terminal-session-authority-legacy-migration.ts new file mode 100644 index 00000000000..1418d33e396 --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-legacy-migration.ts @@ -0,0 +1,144 @@ +import { randomUUID } from 'node:crypto' +import type { TerminalLegacyMigrationImportRequest } from '../../shared/terminal-legacy-cutover' +import { + failTerminalSessionAuthority, + type TerminalSessionAuthorityLegacyMigration as TerminalAuthorityLegacyMigrationRecord +} from '../../shared/terminal-session-authority-mutation' +import type { TerminalSessionAuthorityState } from '../../shared/terminal-session-authority-state' +import type { TerminalAuthorityMutationPersistence } from './terminal-session-authority-mutation-persistence' +import type { TerminalAuthorityLegacyWorkerAccess } from './terminal-session-authority-service-contract' + +export type TerminalAuthorityLegacyMigrationAccess = Readonly<{ + role: 'legacy-migration' + serviceInstanceId: string + reservationId: string + migrationId: string + requestDigest: string + committedAtMs: number +}> + +export class TerminalSessionAuthorityLegacyMigration { + private consumerClaimed = false + private reservation: TerminalAuthorityLegacyMigrationAccess | null = null + + constructor( + private readonly state: TerminalSessionAuthorityState, + private readonly persistence: TerminalAuthorityMutationPersistence, + private readonly serviceInstanceId: string, + private readonly workerAccess: TerminalAuthorityLegacyWorkerAccess | undefined + ) {} + + assertCanAdmitConsumer(): void { + if (this.reservation) { + failTerminalSessionAuthority('consumer-conflict', 'legacy migration is still committing') + } + } + + assertCanMutate(): void { + if (this.reservation) { + failTerminalSessionAuthority('writer-fenced', 'legacy migration is still committing') + } + } + + markConsumerClaimed(): void { + this.consumerClaimed = true + } + + reserve( + request: TerminalLegacyMigrationImportRequest, + requestDigest: string, + committedAtMs: number + ): TerminalAuthorityLegacyMigrationAccess { + this.plan(request, requestDigest, committedAtMs) + if ( + request.mode !== 'acknowledge' && + (this.consumerClaimed || this.state.hasConsumers) && + !this.state.legacy.migration(request.migrationId) + ) { + failTerminalSessionAuthority( + 'consumer-conflict', + 'legacy migration must finish before consumer admission' + ) + } + if (this.reservation) { + failTerminalSessionAuthority('consumer-conflict', 'legacy migration is already reserved') + } + this.reservation = Object.freeze({ + role: 'legacy-migration', + serviceInstanceId: this.serviceInstanceId, + reservationId: randomUUID(), + migrationId: request.migrationId, + requestDigest, + committedAtMs + }) + return this.reservation + } + + async apply( + request: TerminalLegacyMigrationImportRequest, + access: TerminalAuthorityLegacyMigrationAccess + ): Promise< + Readonly<{ + migration: TerminalAuthorityLegacyMigrationRecord + duplicate: boolean + }> + > { + this.assertReservation(access, request.migrationId) + const planned = this.plan(request, access.requestDigest, access.committedAtMs) + if (!planned.duplicate) { + await this.persistence.append( + Object.freeze({ kind: 'legacy-migration', migration: planned.migration }) + ) + } + return Object.freeze({ + migration: structuredClone(planned.migration), + duplicate: planned.duplicate + }) + } + + finish(access: TerminalAuthorityLegacyMigrationAccess): void { + this.assertReservation(access, access.migrationId) + this.reservation = null + } + + setOwnerReachable( + access: TerminalAuthorityLegacyWorkerAccess, + ownerIncarnationId: string, + reachable: boolean + ): boolean { + if (access !== this.workerAccess) { + failTerminalSessionAuthority('writer-fenced', 'legacy worker access is stale') + } + return this.state.setLegacyOwnerReachable(ownerIncarnationId, reachable) + } + + private assertReservation( + access: TerminalAuthorityLegacyMigrationAccess, + migrationId: string + ): void { + if ( + access !== this.reservation || + access.serviceInstanceId !== this.serviceInstanceId || + access.migrationId !== migrationId + ) { + failTerminalSessionAuthority('writer-fenced', 'legacy migration reservation is stale') + } + } + + private plan( + request: TerminalLegacyMigrationImportRequest, + requestDigest: string, + committedAtMs: number + ): ReturnType { + const planned = this.state.legacy.plan( + request, + requestDigest, + committedAtMs, + this.state.revision + 1 + ) + if (!planned.duplicate) { + this.state.assertLegacyMigrationTopologyAllowed(planned.migration) + } + return planned + } +} diff --git a/src/main/session-authority/terminal-session-authority-legacy-operations.ts b/src/main/session-authority/terminal-session-authority-legacy-operations.ts new file mode 100644 index 00000000000..c5de9846695 --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-legacy-operations.ts @@ -0,0 +1,91 @@ +import type { TerminalLegacyMigrationImportRequest } from '../../shared/terminal-legacy-cutover' +import type { TerminalSessionAuthorityLegacyMigration as TerminalAuthorityLegacyMigrationRecord } from '../../shared/terminal-session-authority-mutation' +import type { TerminalSessionAuthorityState } from '../../shared/terminal-session-authority-state' +import type { TerminalAuthorityWriterAccess } from './terminal-session-authority-access' +import type { + TerminalAuthorityLegacyMigrationAccess, + TerminalSessionAuthorityLegacyMigration +} from './terminal-session-authority-legacy-migration' +import type { TerminalAuthorityLegacyProjectionSource } from './terminal-session-authority-legacy-projection' +import type { TerminalAuthorityLegacyWorkerAccess } from './terminal-session-authority-service-contract' + +export type TerminalAuthorityAppliedLegacyMigration = Readonly<{ + migration: TerminalAuthorityLegacyMigrationRecord + duplicate: boolean +}> + +type TerminalAuthorityLegacyOperationsOptions = Readonly<{ + state: TerminalSessionAuthorityState + migration: TerminalSessionAuthorityLegacyMigration + enqueue: (operation: () => Promise) => Promise + assertWriter: (writer: TerminalAuthorityWriterAccess) => void + assertAccepting: () => void + publish: (reason: 'legacy-import' | 'owner-reachability') => void +}> + +export class TerminalSessionAuthorityLegacyOperations { + constructor(private readonly options: TerminalAuthorityLegacyOperationsOptions) {} + + apply( + writer: TerminalAuthorityWriterAccess, + unsafeRequest: TerminalLegacyMigrationImportRequest, + reservation: TerminalAuthorityLegacyMigrationAccess + ): Promise { + const request = structuredClone(unsafeRequest) + return this.options.enqueue(async () => { + this.options.assertWriter(writer) + const applied = await this.options.migration.apply(request, reservation) + if (!applied.duplicate) { + this.options.publish('legacy-import') + } + return applied + }) + } + + reserve( + writer: TerminalAuthorityWriterAccess, + unsafeRequest: TerminalLegacyMigrationImportRequest, + requestDigest: string, + committedAtMs: number + ): Promise { + const request = structuredClone(unsafeRequest) + return this.options.enqueue(async () => { + this.options.assertWriter(writer) + return this.options.migration.reserve(request, requestDigest, committedAtMs) + }) + } + + finish( + writer: TerminalAuthorityWriterAccess, + access: TerminalAuthorityLegacyMigrationAccess + ): Promise { + return this.options.enqueue(async () => { + this.options.assertWriter(writer) + this.options.migration.finish(access) + }) + } + + setOwnerReachable( + writer: TerminalAuthorityWriterAccess, + access: TerminalAuthorityLegacyWorkerAccess, + ownerIncarnationId: string, + reachable: boolean + ): Promise { + return this.options.enqueue(async () => { + this.options.assertWriter(writer) + if (this.options.migration.setOwnerReachable(access, ownerIncarnationId, reachable)) { + this.options.publish('owner-reachability') + } + }) + } + + snapshot(): TerminalAuthorityLegacyProjectionSource { + this.options.assertAccepting() + return Object.freeze({ + revision: this.options.state.legacy.revision, + migrations: this.options.state.legacy.migrationSnapshot(), + recoveries: this.options.state.legacy.recoverySnapshot(), + workerRoutes: this.options.state.legacy.workerRouteSnapshot() + }) + } +} diff --git a/src/main/session-authority/terminal-session-authority-legacy-projection.ts b/src/main/session-authority/terminal-session-authority-legacy-projection.ts new file mode 100644 index 00000000000..9b0213c7216 --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-legacy-projection.ts @@ -0,0 +1,138 @@ +import type { + TerminalLegacyCutoverProjection, + TerminalLegacyCutoverProof, + TerminalLegacyGcProtection, + TerminalLegacyRecoveryNotice, + TerminalLegacyRecoveryNoticeProjection, + TerminalLegacyRecoveryProjection, + TerminalLegacyRecoveryView, + TerminalLegacyWorkerRoute +} from '../../shared/terminal-legacy-cutover' +import type { TerminalSessionAuthorityLegacyMigration } from '../../shared/terminal-session-authority-mutation' +import { projectLegacyWorkerRoute } from '../../shared/terminal-session-authority-legacy-transition' + +export type TerminalLegacyPhysicalWorkerAuthorityEntry = Readonly<{ + route: TerminalLegacyWorkerRoute + cutover: TerminalLegacyCutoverProof +}> + +export type TerminalAuthorityLegacyProjectionSource = Readonly<{ + revision: number + migrations: readonly TerminalSessionAuthorityLegacyMigration[] + recoveries: readonly TerminalLegacyRecoveryProjection[] + workerRoutes: readonly TerminalLegacyWorkerRoute[] +}> + +export function projectTerminalAuthorityLegacyCutover( + source: TerminalAuthorityLegacyProjectionSource +): TerminalLegacyCutoverProjection { + const routes = new Map( + source.workerRoutes.map((route) => [route.routeId, projectLegacyWorkerRoute(route)]) + ) + const recoveries: TerminalLegacyRecoveryView[] = source.recoveries.map((row) => + Object.freeze( + structuredClone({ + ...row, + workerRoute: row.status === 'imported' ? (routes.get(row.routeId) ?? null) : null + }) + ) + ) + return Object.freeze({ + version: 1, + revision: source.revision, + workers: Object.freeze( + [...routes.values()].sort((left, right) => left.routeId.localeCompare(right.routeId)) + ), + recoveries: Object.freeze( + recoveries.sort((left, right) => left.recoveryId.localeCompare(right.recoveryId)) + ) + }) +} + +export function projectTerminalAuthorityLegacyNotices( + revision: number, + rows: readonly TerminalLegacyRecoveryProjection[] +): TerminalLegacyRecoveryNoticeProjection { + const notices = rows + .map(projectRecoveryNotice) + .sort((left, right) => left.recoveryKey.localeCompare(right.recoveryKey)) + return Object.freeze({ version: 1, revision, notices: Object.freeze(notices) }) +} + +export function terminalAuthorityLegacyGcProtection( + source: TerminalAuthorityLegacyProjectionSource +): TerminalLegacyGcProtection { + const protectedWorkers = new Set( + source.recoveries + .filter((row) => row.status !== 'acknowledged') + .map((row) => row.physicalPty.workerId) + ) + const relayDirectories = new Set() + const evidencePaths = new Set() + for (const migration of source.migrations) { + const request = migration.receipt.request + if (request.mode === 'acknowledge') { + continue + } + const workerId = + request.mode === 'cutover' ? request.workerRoute.workerId : request.workerEvidence.workerId + if (!protectedWorkers.has(workerId)) { + continue + } + const protection = + request.mode === 'cutover' + ? request.workerRoute.gcProtection + : request.workerEvidence.gcProtection + protection.relayDirectories.forEach((directory) => relayDirectories.add(directory)) + protection.evidencePaths.forEach((evidencePath) => evidencePaths.add(evidencePath)) + } + return Object.freeze({ + relayDirectories: Object.freeze([...relayDirectories].sort()), + evidencePaths: Object.freeze([...evidencePaths].sort()) + }) +} + +export function terminalAuthorityLegacyPhysicalWorkers( + migrations: readonly TerminalSessionAuthorityLegacyMigration[] +): readonly TerminalLegacyPhysicalWorkerAuthorityEntry[] { + const entries = new Map() + for (const migration of migrations) { + const request = migration.receipt.request + if (request.mode !== 'cutover') { + continue + } + entries.set( + request.workerRoute.routeId, + Object.freeze({ + route: structuredClone(request.workerRoute), + cutover: structuredClone(request.cutover) + }) + ) + } + return Object.freeze( + [...entries.values()].sort((left, right) => + left.route.routeId.localeCompare(right.route.routeId) + ) + ) +} + +function projectRecoveryNotice( + row: TerminalLegacyRecoveryProjection +): TerminalLegacyRecoveryNotice { + const base = { + recoveryKey: row.recoveryId, + workspaceKind: row.workspace.kind, + evidenceDigest: row.inventoryEvidence.evidenceDigest, + observedAtMs: row.inventoryEvidence.observedAtMs, + discoveredAtMs: row.discoveredAtMs, + updatedAtMs: row.updatedAtMs + } + return row.status === 'imported' + ? Object.freeze({ ...base, status: row.status }) + : Object.freeze({ + ...base, + status: row.status, + reason: row.reason, + preservationKind: row.preservation.kind + }) +} diff --git a/src/main/session-authority/terminal-session-authority-legacy-registry.ts b/src/main/session-authority/terminal-session-authority-legacy-registry.ts new file mode 100644 index 00000000000..8a679a2c58a --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-legacy-registry.ts @@ -0,0 +1,246 @@ +import { randomUUID } from 'node:crypto' +import { isDeepStrictEqual } from 'node:util' +import { assertTerminalLegacyMigrationImportRequest } from '../../shared/terminal-legacy-cutover-request-validation' +import type { + TerminalLegacyCutoverProjection, + TerminalLegacyGcProtection, + TerminalLegacyMigrationImportRequest, + TerminalLegacyMigrationReceipt, + TerminalLegacyRecoveryNoticeProjection, + TerminalLegacyWorkerRoute +} from '../../shared/terminal-legacy-cutover' +import type { TerminalAuthorityNamespace } from '../../shared/terminal-session-authority-identity' +import { failTerminalSessionAuthority } from '../../shared/terminal-session-authority-mutation' +import { terminalLegacyMigrationRequestDigest } from '../../shared/terminal-session-authority-legacy-transition' +import type { TerminalLegacyWorkerLiveRegistration } from './terminal-legacy-worker-live-registration' +import { + assertTerminalLegacyWorkerLiveRegistration, + assertTerminalLegacyWorkerMatchesRoute +} from './terminal-legacy-worker-live-registration' +import type { TerminalSessionAuthorityService } from './terminal-session-authority-service' +import type { TerminalAuthorityLegacyMigrationAccess } from './terminal-session-authority-legacy-migration' +import type { TerminalAuthorityLegacyWorkerAccess } from './terminal-session-authority-service-contract' +import { + aggregateTerminalAuthorityLegacyReceipt, + assertTerminalAuthorityLegacyGlobalConsistency, + prepareTerminalAuthorityNamespaceMigrations, + terminalAuthorityLegacyProjectionSource, + type TerminalAuthorityNamespaceMigration +} from './terminal-session-authority-legacy-inventory' +import { + projectTerminalAuthorityLegacyCutover, + projectTerminalAuthorityLegacyNotices, + terminalAuthorityLegacyGcProtection, + terminalAuthorityLegacyPhysicalWorkers, + type TerminalLegacyPhysicalWorkerAuthorityEntry +} from './terminal-session-authority-legacy-projection' + +type ReservedNamespaceMigration = TerminalAuthorityNamespaceMigration & + Readonly<{ access: TerminalAuthorityLegacyMigrationAccess }> + +export type TerminalAuthorityLegacyRegistryOptions = Readonly<{ + serviceForNamespace: ( + namespace: TerminalAuthorityNamespace + ) => Promise + services: () => Iterable + namespaceMatchesLocator: (namespace: TerminalAuthorityNamespace, locatorKey: string) => boolean + assertNamespace: (namespace: TerminalAuthorityNamespace) => void + assertAccepting: () => void + now?: () => number +}> + +export class TerminalSessionAuthorityLegacyRegistry { + readonly workerAccess: TerminalAuthorityLegacyWorkerAccess = Object.freeze({ + role: 'legacy-worker-owner', + accessId: randomUUID() + }) + private readonly liveWorkers = new Map() + private mutationQueue: Promise = Promise.resolve() + + constructor(private readonly options: TerminalAuthorityLegacyRegistryOptions) {} + + async importMigration( + request: TerminalLegacyMigrationImportRequest + ): Promise> { + const migration = structuredClone(request) + return this.enqueue(() => this.commitMigration(migration)) + } + + projection(): TerminalLegacyCutoverProjection { + this.options.assertAccepting() + return projectTerminalAuthorityLegacyCutover( + terminalAuthorityLegacyProjectionSource(this.options.services()) + ) + } + + recoveryNotices(): TerminalLegacyRecoveryNoticeProjection { + this.options.assertAccepting() + const source = terminalAuthorityLegacyProjectionSource(this.options.services()) + return projectTerminalAuthorityLegacyNotices(source.revision, source.recoveries) + } + + recoveryNoticesForNamespace( + namespace: TerminalAuthorityNamespace + ): TerminalLegacyRecoveryNoticeProjection { + this.options.assertAccepting() + this.options.assertNamespace(namespace) + const service = [...this.options.services()].find( + (candidate) => candidate.namespace.namespaceId === namespace.namespaceId + ) + const snapshot = service?.legacy.snapshot() + return projectTerminalAuthorityLegacyNotices( + snapshot?.revision ?? 0, + snapshot?.recoveries ?? [] + ) + } + + gcProtection(): TerminalLegacyGcProtection { + this.options.assertAccepting() + return terminalAuthorityLegacyGcProtection( + terminalAuthorityLegacyProjectionSource(this.options.services()) + ) + } + + physicalWorkerEntries(): readonly TerminalLegacyPhysicalWorkerAuthorityEntry[] { + this.options.assertAccepting() + return terminalAuthorityLegacyPhysicalWorkers( + terminalAuthorityLegacyProjectionSource(this.options.services()).migrations + ) + } + + activateWorker(registration: TerminalLegacyWorkerLiveRegistration): Promise { + const candidate = structuredClone(registration) + return this.enqueue(() => this.activateWorkerNow(candidate)) + } + + deactivateWorker(routeId: string, registrationId: string): Promise { + return this.enqueue(() => this.deactivateWorkerNow(routeId, registrationId)) + } + + idle(): Promise { + return this.mutationQueue + } + + async reconcileLiveOwners(service: TerminalSessionAuthorityService): Promise { + for (const registration of this.liveWorkers.values()) { + await service.legacy.setOwnerReachable( + service.writerAccess, + this.workerAccess, + registration.ownerIncarnationId, + true + ) + } + } + + private async commitMigration( + migration: TerminalLegacyMigrationImportRequest + ): Promise> { + assertTerminalLegacyMigrationImportRequest(migration) + const requestDigest = terminalLegacyMigrationRequestDigest(migration) + const namespaces = await prepareTerminalAuthorityNamespaceMigrations(migration, this.options) + assertTerminalAuthorityLegacyGlobalConsistency( + migration, + requestDigest, + this.options.services() + ) + const committedAtMs = (this.options.now ?? Date.now)() + const reserved: ReservedNamespaceMigration[] = [] + try { + for (const entry of namespaces) { + const access = await entry.service.legacy.reserve( + entry.service.writerAccess, + entry.request, + requestDigest, + committedAtMs + ) + reserved.push(Object.freeze({ ...entry, access })) + } + const committed: Awaited>[] = + [] + for (const entry of reserved) { + committed.push( + await entry.service.legacy.apply(entry.service.writerAccess, entry.request, entry.access) + ) + } + return Object.freeze({ + receipt: aggregateTerminalAuthorityLegacyReceipt(migration, committed), + duplicate: committed.every((entry) => entry.duplicate) + }) + } finally { + await Promise.allSettled( + reserved.map( + async (entry) => + await entry.service.legacy.finish(entry.service.writerAccess, entry.access) + ) + ) + } + } + + private workerRoute(routeId: string): TerminalLegacyWorkerRoute | null { + const matches = [...this.options.services()] + .flatMap((service) => + service.legacy.snapshot().workerRoutes.filter((route) => route.routeId === routeId) + ) + .filter((route): route is TerminalLegacyWorkerRoute => route !== null) + if (matches.some((route) => !isDeepStrictEqual(route, matches[0]))) { + failTerminalSessionAuthority('record-corrupt', 'legacy route differs across namespaces') + } + return matches[0] ? structuredClone(matches[0]) : null + } + + private async activateWorkerNow( + registration: TerminalLegacyWorkerLiveRegistration + ): Promise { + assertTerminalLegacyWorkerLiveRegistration(registration) + assertTerminalLegacyWorkerMatchesRoute(registration, this.workerRoute(registration.routeId)) + const current = this.liveWorkers.get(registration.routeId) + if (current?.registrationId === registration.registrationId) { + if (!isDeepStrictEqual(current, registration)) { + failTerminalSessionAuthority( + 'operation-conflict', + 'legacy live-worker registration ID was reused' + ) + } + return false + } + this.liveWorkers.set(registration.routeId, structuredClone(registration)) + await this.setServicesOwnerReachable(registration.ownerIncarnationId, true) + return true + } + + private async deactivateWorkerNow(routeId: string, registrationId: string): Promise { + const current = this.liveWorkers.get(routeId) + if (!current || current.registrationId !== registrationId) { + return false + } + this.liveWorkers.delete(routeId) + await this.setServicesOwnerReachable(current.ownerIncarnationId, false) + return true + } + + private async setServicesOwnerReachable( + ownerIncarnationId: string, + reachable: boolean + ): Promise { + await Promise.all( + [...this.options.services()].map((service) => + service.legacy.setOwnerReachable( + service.writerAccess, + this.workerAccess, + ownerIncarnationId, + reachable + ) + ) + ) + } + + private enqueue(operation: () => Promise): Promise { + this.options.assertAccepting() + const result = this.mutationQueue.then(operation) + this.mutationQueue = result.then( + () => undefined, + () => undefined + ) + return result + } +} diff --git a/src/main/session-authority/terminal-session-authority-lifecycle-operation.ts b/src/main/session-authority/terminal-session-authority-lifecycle-operation.ts new file mode 100644 index 00000000000..f5c536976c6 --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-lifecycle-operation.ts @@ -0,0 +1,27 @@ +import { createHash } from 'node:crypto' +import { assertAuthorityId } from '../../shared/terminal-session-authority-identity' + +export function terminalAuthorityLifecycleOperationId( + callerOperationId: string, + phase: string +): string { + assertAuthorityId(callerOperationId, 'terminal lifecycle operationId') + assertAuthorityId(phase, 'terminal lifecycle phase') + return hashedOperationId('caller', [callerOperationId, phase]) +} + +export function terminalAuthorityLifecycleIdentityOperationId( + phase: string, + identities: readonly string[] +): string { + assertAuthorityId(phase, 'terminal lifecycle phase') + for (const identity of identities) { + assertAuthorityId(identity, 'terminal lifecycle identity') + } + return hashedOperationId(phase, identities) +} + +function hashedOperationId(scope: string, components: readonly string[]): string { + const digest = createHash('sha256').update(JSON.stringify(components)).digest('hex') + return `pty-lifecycle:${scope}:${digest}` +} diff --git a/src/main/session-authority/terminal-session-authority-materialized-outcomes.test.ts b/src/main/session-authority/terminal-session-authority-materialized-outcomes.test.ts new file mode 100644 index 00000000000..5914abaeb82 --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-materialized-outcomes.test.ts @@ -0,0 +1,239 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import type { + TerminalAuthorityDurableOutcome, + TerminalSessionAuthorityChange, + TerminalSessionAuthoritySemanticFact +} from '../../shared/terminal-session-authority-mutation' +import { terminalAuthorityOperationIdentity } from '../../shared/terminal-session-authority-operation-identity' +import type { TerminalSessionAuthorityPtyAccess } from '../../shared/terminal-session-authority-pty-access' +import type { TerminalAuthorityConsumerAccess } from './terminal-session-authority-access' +import { TerminalSessionAuthorityService } from './terminal-session-authority-service' +import type { TerminalSessionAuthorityServiceOptions } from './terminal-session-authority-service-contract' + +const NAMESPACE = { authorityHostId: 'host-materialized', namespaceId: 'namespace-materialized' } +const PRODUCER = 'producer-materialized' +const FACTS: readonly TerminalSessionAuthoritySemanticFact[] = [ + { kind: 'agent-status', payload: { state: 'working', prompt: 'ship it' } }, + { kind: 'title', normalizedTitle: 'Build', rawTitle: ' Build ' }, + { kind: 'bell' }, + { kind: 'agent-working' }, + { kind: 'agent-idle', title: 'Done' }, + { kind: 'agent-exited' }, + { kind: 'command-finished', exitCode: 0 }, + { + kind: 'pr-link', + link: { + url: 'https://github.com/orca/orca/pull/1', + slug: { owner: 'orca', repo: 'orca', host: 'github.com' }, + number: 1 + } + }, + { kind: 'command-code-working', prompt: 'fix tests' }, + { kind: 'command-code-done', prompt: 'fix tests' }, + { kind: '2031-subscribe' }, + { kind: '2031-unsubscribe' } +] + +const directories: string[] = [] +const services: TerminalSessionAuthorityService[] = [] + +afterEach(async () => { + await Promise.allSettled(services.splice(0).map((service) => service.close())) + directories.splice(0).forEach((directory) => rmSync(directory, { recursive: true, force: true })) +}) + +describe('terminal authority materialized outcomes', () => { + it('preserves every current semantic domain, bounded attention, and exit across restart', async () => { + const directory = freshDirectory() + const service = await openService(directory) + const consumer = await claim(service) + const access = await bindPty(service, consumer) + let producerSequence = 0 + + for (const fact of FACTS) { + const outcome = await record(service, access, ++producerSequence, fact) + expect( + materialized(service).some((candidate) => candidate.outcomeId === outcome.outcomeId) + ).toBe(true) + } + for (let index = 1; index < 100; index += 1) { + await record(service, access, ++producerSequence, { kind: 'bell' }) + } + const exit = await mutate(service, 3, 'exit', { + kind: 'exit', + pane: access.pane, + exit: { code: 0, signal: null }, + expected: { paneGenerationId: access.pane.paneGenerationId, binding: access.binding } + }) + const beforeRestart = materialized(service) + + expect(semanticKinds(beforeRestart).filter((kind) => kind === 'bell')).toHaveLength(99) + expect(new Set(semanticKinds(beforeRestart))).toEqual( + new Set(FACTS.map((fact) => fact.kind).filter((kind) => kind !== '2031-subscribe')) + ) + expect(beforeRestart.at(-1)).toMatchObject({ + sequence: exit.outcomeSequence, + result: { effects: [{ kind: 'binding-retired' }, { kind: 'terminal-exited' }] } + }) + + await service.acknowledgeOutcomes(consumer, exit.outcomeSequence) + await service.compact(service.writerAccess) + await service.close() + const restarted = await openService(directory, { + ownerToken: 'owner-token-restarted', + ownerIncarnationId: 'owner-incarnation-restarted' + }) + expect(materialized(restarted)).toEqual(beforeRestart) + }) + + it('replaces a semantic domain and removes a superseded pane generation', async () => { + const service = await openService(freshDirectory()) + const consumer = await claim(service) + const access = await bindPty(service, consumer) + const first = await record(service, access, 1, { + kind: 'title', + normalizedTitle: 'First', + rawTitle: 'First' + }) + const second = await record(service, access, 2, { + kind: 'title', + normalizedTitle: 'Second', + rawTitle: 'Second' + }) + + expect(materialized(service).map((outcome) => outcome.outcomeId)).not.toContain(first.outcomeId) + expect(materialized(service).map((outcome) => outcome.outcomeId)).toContain(second.outcomeId) + await mutate(service, 3, 'supersede', { + kind: 'supersede', + pane: access.pane, + replacementPaneGenerationId: 'generation-b', + expected: { paneGenerationId: access.pane.paneGenerationId, binding: access.binding } + }) + expect(materialized(service)).toEqual([]) + }) + + it('fails closed when compacted materialized domains reach their bound', async () => { + const service = await openService(freshDirectory(), { maxRetainedOperationEntries: 3 }) + const consumer = await claim(service) + const access = await bindPty(service, consumer) + await service.acknowledgeOutcomes(consumer, 3) + const facts: readonly TerminalSessionAuthoritySemanticFact[] = [ + { kind: 'title', normalizedTitle: 'Build', rawTitle: 'Build' }, + { kind: 'bell' }, + { kind: 'agent-working' } + ] + for (const [index, fact] of facts.entries()) { + const outcome = await record(service, access, index + 1, fact) + await service.acknowledgeOutcomes(consumer, outcome.sequence) + } + + await expect( + record(service, access, 4, { + kind: 'pr-link', + link: { + url: 'https://github.com/orca/orca/pull/2', + slug: { owner: 'orca', repo: 'orca', host: 'github.com' }, + number: 2 + } + }) + ).rejects.toMatchObject({ code: 'capacity' }) + }) +}) + +function materialized( + service: TerminalSessionAuthorityService +): readonly TerminalAuthorityDurableOutcome[] { + return service.snapshotForWriter(service.writerAccess).materializedOutcomes ?? [] +} + +function semanticKinds(outcomes: readonly TerminalAuthorityDurableOutcome[]): string[] { + return outcomes.flatMap((outcome) => (outcome.kind === 'semantic' ? [outcome.fact.kind] : [])) +} + +async function record( + service: TerminalSessionAuthorityService, + access: TerminalSessionAuthorityPtyAccess, + producerSequence: number, + fact: TerminalSessionAuthoritySemanticFact +) { + return service.recordSemanticOutcome(service.writerAccess, { + access, + producerIncarnationId: PRODUCER, + producerSequence, + fact + }) +} + +async function bindPty( + service: TerminalSessionAuthorityService, + _consumer: TerminalAuthorityConsumerAccess +): Promise { + const pane = { paneKey: 'pane-a', paneGenerationId: 'generation-a' } + const allocation = { + allocationId: 'allocation-a', + pane, + ownerIncarnationId: 'owner-incarnation-a', + physicalPtyId: 'pty-a', + spawnFingerprint: 'spawn-a' + } + await mutate(service, 0, 'create', { kind: 'create', pane }) + await mutate(service, 1, 'prepare', { + kind: 'prepare-allocation', + allocation, + expected: { paneGenerationId: pane.paneGenerationId, binding: null } + }) + const committed = await mutate(service, 2, 'commit', { + kind: 'commit-allocation', + allocation, + ptyIncarnationId: 'pty-incarnation-a', + expected: { paneGenerationId: pane.paneGenerationId, binding: null } + }) + return { namespace: NAMESPACE, pane, binding: committed.result.pane.binding! } +} + +function mutate( + service: TerminalSessionAuthorityService, + baseRevision: number, + operationId: string, + change: TerminalSessionAuthorityChange +) { + return service.mutate(service.writerAccess, { + actorId: service.writerAccess.actorId, + ...terminalAuthorityOperationIdentity(baseRevision, operationId), + baseRevision, + change + }) +} + +function claim(service: TerminalSessionAuthorityService): Promise { + return service.claimConsumer(service.writerAccess, { + consumerId: 'app-profile:materialized', + expectedIncarnationId: null, + consumerIncarnationId: 'app-process:materialized' + }) +} + +async function openService( + directory: string, + overrides: Partial = {} +): Promise { + const service = await TerminalSessionAuthorityService.open({ + directory: path.join(directory, 'namespace'), + namespace: NAMESPACE, + ownerToken: 'owner-token-a', + ownerIncarnationId: 'owner-incarnation-a', + writerActorId: 'writer-a', + ...overrides + }) + services.push(service) + return service +} + +function freshDirectory(): string { + const directory = mkdtempSync(path.join(tmpdir(), 'orca-materialized-outcomes-')) + directories.push(directory) + return directory +} diff --git a/src/main/session-authority/terminal-session-authority-mutation-persistence.ts b/src/main/session-authority/terminal-session-authority-mutation-persistence.ts new file mode 100644 index 00000000000..0e4e9ff102a --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-mutation-persistence.ts @@ -0,0 +1,55 @@ +import { + TerminalSessionAuthorityError, + type TerminalSessionAuthorityLogEvent, + type TerminalSessionAuthoritySnapshot +} from '../../shared/terminal-session-authority-mutation' +import type { TerminalSessionAuthorityState } from '../../shared/terminal-session-authority-state' +import type { TerminalAuthorityFileStore } from './terminal-session-authority-file-store' + +export class TerminalAuthorityMutationPersistence { + constructor( + private readonly state: TerminalSessionAuthorityState, + private readonly store: TerminalAuthorityFileStore, + private readonly onCrash: () => void + ) {} + + async append(event: TerminalSessionAuthorityLogEvent): Promise { + try { + await this.store.append(event) + } catch (error) { + // Definitely not durable: the record never reached the log, so callers may unwind in memory. + if (!(error instanceof TerminalSessionAuthorityError && error.code === 'capacity')) { + this.onCrash() + } + throw error + } + try { + this.state.applyEvent(event) + if (this.store.shouldCompact) { + await this.store.compact(this.state.snapshot()) + } + } catch (error) { + // The record is already durable, so this is ambiguous rather than failed: fence the service and + // let recovery reopen from the log instead of rewinding a write that landed. + this.onCrash() + throw error + } + } + + async compact(snapshot: TerminalSessionAuthoritySnapshot): Promise { + try { + await this.store.compact(snapshot) + } catch (error) { + this.onCrash() + throw error + } + } + + assertWriterCurrent(): Promise { + return this.store.assertWriterCurrent() + } + + close(): Promise { + return this.store.close() + } +} diff --git a/src/main/session-authority/terminal-session-authority-namespace-directory.ts b/src/main/session-authority/terminal-session-authority-namespace-directory.ts new file mode 100644 index 00000000000..da346ce5e28 --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-namespace-directory.ts @@ -0,0 +1,19 @@ +import { createHash } from 'node:crypto' +import path from 'node:path' +import { + assertAuthorityNamespace, + type TerminalAuthorityNamespace +} from '../../shared/terminal-session-authority-identity' + +export function terminalSessionAuthorityNamespaceDirectory( + registryDirectory: string, + namespace: TerminalAuthorityNamespace +): string { + assertAuthorityNamespace(namespace) + const digest = createHash('sha256') + .update(namespace.authorityHostId, 'utf8') + .update('\0') + .update(namespace.namespaceId, 'utf8') + .digest('hex') + return path.join(registryDirectory, 'namespaces', digest) +} diff --git a/src/main/session-authority/terminal-session-authority-namespace-index.ts b/src/main/session-authority/terminal-session-authority-namespace-index.ts new file mode 100644 index 00000000000..57a57b850df --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-namespace-index.ts @@ -0,0 +1,124 @@ +import { Buffer } from 'node:buffer' +import { randomUUID } from 'node:crypto' +import { mkdir, open, readFile, rename, rm, stat, type FileHandle } from 'node:fs/promises' +import path from 'node:path' +import { assertAuthorityId, isRecord } from '../../shared/terminal-session-authority-identity' +import { + assertTerminalAuthorityNamespaceLocator, + terminalAuthorityNamespaceLocatorKey, + type TerminalAuthorityNamespaceLocator +} from '../../shared/terminal-session-authority-locator' +import { failTerminalSessionAuthority } from '../../shared/terminal-session-authority-mutation' + +export const TERMINAL_AUTHORITY_NAMESPACE_INDEX_FILE = 'terminal-authority-namespaces.json' +const MAX_INDEX_BYTES = 16 * 1024 * 1024 +const DEFAULT_MAX_NAMESPACES = 16_384 + +export type TerminalAuthorityNamespaceEntry = Readonly<{ + locator: TerminalAuthorityNamespaceLocator + namespaceId: string +}> + +type NamespaceIndex = Readonly<{ + version: 1 + authorityHostId: string + entries: readonly TerminalAuthorityNamespaceEntry[] +}> + +export function resolveTerminalAuthorityNamespaceLimit(value: number | undefined): number { + const selected = value ?? DEFAULT_MAX_NAMESPACES + if (!Number.isSafeInteger(selected) || selected < 1) { + failTerminalSessionAuthority('capacity', 'namespace capacity is invalid') + } + return selected +} + +export async function readTerminalAuthorityNamespaceIndex( + directory: string, + authorityHostId: string, + maxNamespaces: number +): Promise { + const file = path.join(directory, TERMINAL_AUTHORITY_NAMESPACE_INDEX_FILE) + let metadata + try { + metadata = await stat(file) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return [] + } + throw error + } + if (!metadata.isFile() || metadata.size > MAX_INDEX_BYTES) { + failTerminalSessionAuthority('record-corrupt', 'namespace index is not a bounded file') + } + const value = JSON.parse(await readFile(file, 'utf8')) as unknown + if ( + !isRecord(value) || + value.version !== 1 || + value.authorityHostId !== authorityHostId || + !Array.isArray(value.entries) || + value.entries.length > maxNamespaces + ) { + failTerminalSessionAuthority('record-corrupt', 'namespace index is invalid') + } + const locatorKeys = new Set() + const namespaceIds = new Set() + return Object.freeze( + value.entries.map((raw) => { + if (!isRecord(raw)) { + failTerminalSessionAuthority('record-corrupt', 'namespace entry is invalid') + } + assertAuthorityId(raw.namespaceId, 'namespaceId') + assertTerminalAuthorityNamespaceLocator(raw.locator as TerminalAuthorityNamespaceLocator) + const locator = raw.locator as TerminalAuthorityNamespaceLocator + const locatorKey = terminalAuthorityNamespaceLocatorKey(locator) + if (locatorKeys.has(locatorKey) || namespaceIds.has(raw.namespaceId)) { + failTerminalSessionAuthority('record-corrupt', 'namespace index contains a duplicate') + } + locatorKeys.add(locatorKey) + namespaceIds.add(raw.namespaceId) + return Object.freeze({ locator, namespaceId: raw.namespaceId }) + }) + ) +} + +export async function writeTerminalAuthorityNamespaceIndex( + directory: string, + index: NamespaceIndex +): Promise { + await mkdir(directory, { recursive: true }) + const target = path.join(directory, TERMINAL_AUTHORITY_NAMESPACE_INDEX_FILE) + const temporary = `${target}.${process.pid}.${randomUUID()}.tmp` + const contents = `${JSON.stringify(index)}\n` + if (Buffer.byteLength(contents, 'utf8') > MAX_INDEX_BYTES) { + failTerminalSessionAuthority('capacity', 'namespace index exceeds its size limit') + } + const handle = await open(temporary, 'wx', 0o600) + try { + await handle.writeFile(contents, 'utf8') + await handle.sync() + } finally { + await handle.close() + } + try { + await rename(temporary, target) + await syncDirectory(directory) + } finally { + await rm(temporary, { force: true }) + } +} + +async function syncDirectory(directory: string): Promise { + let handle: FileHandle | null = null + try { + handle = await open(directory, 'r') + await handle.sync() + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + if (!['EACCES', 'EBADF', 'EINVAL', 'EISDIR', 'ENOTSUP', 'EPERM'].includes(code ?? '')) { + throw error + } + } finally { + await handle?.close() + } +} diff --git a/src/main/session-authority/terminal-session-authority-operation-queue.ts b/src/main/session-authority/terminal-session-authority-operation-queue.ts new file mode 100644 index 00000000000..f15f0e09d3c --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-operation-queue.ts @@ -0,0 +1,94 @@ +import { failTerminalSessionAuthority } from '../../shared/terminal-session-authority-mutation' + +export class TerminalSessionAuthorityOperationQueue { + private tail: Promise = Promise.resolve() + private accepting = true + private closed = false + private crashed = false + private closePromise: Promise | null = null + private readonly producerHolds = new Set() + private producerHoldGate: Promise | null = null + private releaseProducerHoldGate: (() => void) | null = null + + crash(): void { + this.crashed = true + } + + enqueue(operation: () => Promise): Promise { + this.assertAccepting() + const result = this.tail.then(operation) + this.tail = result.then( + () => undefined, + () => undefined + ) + return result + } + + enqueueProducer(operation: () => Promise): Promise { + this.assertAccepting() + const gate = this.producerHoldGate + if (gate) { + return gate.then(() => this.enqueueProducer(operation)) + } + return this.enqueue(operation) + } + + acquireProducerHold(): Readonly<{ release(): void }> { + this.assertAccepting() + const token = Object.freeze({}) + if (this.producerHolds.size === 0) { + this.producerHoldGate = new Promise((resolve) => { + this.releaseProducerHoldGate = resolve + }) + } + this.producerHolds.add(token) + let released = false + return Object.freeze({ + release: () => { + if (released) { + return + } + released = true + this.producerHolds.delete(token) + if (this.producerHolds.size === 0) { + this.releaseProducerHolds() + } + } + }) + } + + close(cleanup: () => Promise): Promise { + if (this.closePromise) { + return this.closePromise + } + this.accepting = false + this.producerHolds.clear() + this.releaseProducerHolds() + this.closePromise = (async () => { + await this.tail + this.closed = true + await cleanup() + })() + return this.closePromise + } + + assertAccepting(): void { + if (!this.accepting) { + failTerminalSessionAuthority('writer-fenced', 'authority service is unavailable') + } + this.assertProcessing() + } + + assertProcessing(): void { + if (this.closed || this.crashed) { + failTerminalSessionAuthority('writer-fenced', 'authority service is unavailable') + } + } + + private releaseProducerHolds(): void { + const release = this.releaseProducerHoldGate + this.producerHoldGate = null + this.releaseProducerHoldGate = null + release?.() + } +} diff --git a/src/main/session-authority/terminal-session-authority-outcome-consumers.ts b/src/main/session-authority/terminal-session-authority-outcome-consumers.ts new file mode 100644 index 00000000000..e0538d4d9a0 --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-outcome-consumers.ts @@ -0,0 +1,161 @@ +import type { + TerminalAuthorityConsumerProjection, + TerminalAuthorityOutcomeRead, + TerminalAuthoritySemanticOutcome, + TerminalSessionAuthoritySemanticFact +} from '../../shared/terminal-session-authority-mutation' +import type { TerminalSessionAuthorityPtyAccess } from '../../shared/terminal-session-authority-pty-access' +import type { TerminalSessionAuthorityState } from '../../shared/terminal-session-authority-state' +import type { + TerminalAuthorityConsumerAccess, + TerminalAuthorityRuntimeAccess +} from './terminal-session-authority-access' +import type { TerminalAuthorityMutationPersistence } from './terminal-session-authority-mutation-persistence' +import type { TerminalSessionAuthorityLegacyMigration } from './terminal-session-authority-legacy-migration' +import { failTerminalSessionAuthority } from '../../shared/terminal-session-authority-mutation' + +export type TerminalAuthorityConsumerClaim = Readonly<{ + consumerId: string + expectedIncarnationId: string | null + consumerIncarnationId: string +}> + +export type TerminalAuthoritySemanticOutcomeInput = Readonly<{ + access: TerminalSessionAuthorityPtyAccess + producerIncarnationId: string + producerSequence: number + fact: TerminalSessionAuthoritySemanticFact +}> + +export type TerminalAuthorityConsumerClaimPlan = Readonly<{ + input: TerminalAuthorityConsumerClaim + /** Absent when the claim is an exact duplicate of the active incarnation. */ + event: ReturnType +}> + +export type TerminalAuthorityPendingConsumerSnapshot = Readonly<{ + authority: TerminalAuthorityConsumerProjection['authority'] + acknowledgedSequence: number + outcomeHighWatermark: number +}> + +export class TerminalSessionAuthorityOutcomeConsumers { + constructor( + private readonly state: TerminalSessionAuthorityState, + private readonly persistence: TerminalAuthorityMutationPersistence, + private readonly accesses: TerminalAuthorityRuntimeAccess, + private readonly legacyMigration: TerminalSessionAuthorityLegacyMigration + ) {} + + /** Every fallible check, run before anything is sealed, so `settleClaim` after the append cannot fail. */ + async planClaim( + input: TerminalAuthorityConsumerClaim + ): Promise { + await this.persistence.assertWriterCurrent() + this.legacyMigration.assertCanAdmitConsumer() + return Object.freeze({ + input, + event: this.state.planConsumerClaim( + input.consumerId, + input.expectedIncarnationId, + input.consumerIncarnationId + ) + }) + } + + appendClaim(plan: TerminalAuthorityConsumerClaimPlan): Promise { + return plan.event ? this.persistence.append(plan.event) : Promise.resolve() + } + + /** Non-fallible finalizer; runs only once the claim is durable and applied. */ + settleClaim(plan: TerminalAuthorityConsumerClaimPlan): TerminalAuthorityConsumerAccess { + this.legacyMigration.markConsumerClaimed() + return this.accesses.consumer(plan.input.consumerId, plan.input.consumerIncarnationId) + } + + async preview( + input: TerminalAuthorityConsumerClaim + ): Promise { + await this.persistence.assertWriterCurrent() + this.legacyMigration.assertCanAdmitConsumer() + return this.state.snapshotForConsumerClaim( + input.consumerId, + input.expectedIncarnationId, + input.consumerIncarnationId + ) + } + + /** The durable append happens here; publication only ever reads the ledger afterwards. */ + async record( + input: TerminalAuthoritySemanticOutcomeInput + ): Promise { + await this.persistence.assertWriterCurrent() + const planned = this.state.planSemanticOutcome(input) + if (!planned.duplicate) { + await this.persistence.append( + Object.freeze({ kind: 'semantic-outcome', outcome: planned.outcome }) + ) + } + return planned.outcome + } + + async acknowledge(access: TerminalAuthorityConsumerAccess, sequence: number): Promise { + await this.persistence.assertWriterCurrent() + const event = this.state.planOutcomeAck( + access.consumerId, + access.consumerIncarnationId, + sequence + ) + if (event) { + await this.persistence.append(event) + } + return sequence + } + + async retire(access: TerminalAuthorityConsumerAccess): Promise { + await this.persistence.assertWriterCurrent() + const event = this.state.planConsumerRetirement(access.consumerId, access.consumerIncarnationId) + if (event) { + await this.persistence.append(event) + } + return event !== null + } + + async retireIdentity(consumerId: string, expectedIncarnationId: string | null): Promise { + await this.persistence.assertWriterCurrent() + const currentIncarnationId = this.state.activeConsumerIncarnation(consumerId) + if (currentIncarnationId !== expectedIncarnationId) { + failTerminalSessionAuthority('consumer-conflict', 'consumer incarnation changed') + } + if (currentIncarnationId === null) { + return false + } + const event = this.state.planConsumerRetirement(consumerId, currentIncarnationId) + if (!event) { + failTerminalSessionAuthority('consumer-conflict', 'consumer retirement changed') + } + await this.persistence.append(event) + return true + } + + async read( + access: TerminalAuthorityConsumerAccess, + afterSequence: number, + limit?: number + ): Promise { + await this.persistence.assertWriterCurrent() + return this.state.readOutcomes( + access.consumerId, + access.consumerIncarnationId, + afterSequence, + limit + ) + } + + async snapshot( + access: TerminalAuthorityConsumerAccess + ): Promise { + await this.persistence.assertWriterCurrent() + return this.state.snapshotForConsumer(access.consumerId, access.consumerIncarnationId) + } +} diff --git a/src/main/session-authority/terminal-session-authority-owner-process.test.ts b/src/main/session-authority/terminal-session-authority-owner-process.test.ts new file mode 100644 index 00000000000..4e44be494ce --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-owner-process.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, it, vi } from 'vitest' +import { + readCurrentTerminalAuthorityOwnerProcessIdentity, + terminalAuthorityOwnerProcessObservationProvesGone +} from './terminal-session-authority-owner-process' + +describe('terminal authority owner process identity', () => { + it('does not change when the wall clock steps', async () => { + const before = await readCurrentTerminalAuthorityOwnerProcessIdentity() + const clock = vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 60_000) + try { + const after = await readCurrentTerminalAuthorityOwnerProcessIdentity() + expect(after).toEqual(before) + } finally { + clock.mockRestore() + } + }) + + it('uses exact native process creation identity for PID reuse', () => { + const identity = { + pid: 42, + platform: 'darwin' as const, + startedAtMs: 1_000, + executionScope: 'host-a' + } + expect( + terminalAuthorityOwnerProcessObservationProvesGone(identity, { + status: 'present', + platform: 'darwin', + startedAtMs: 1_000, + executionScope: 'host-a' + }) + ).toBe(false) + expect( + terminalAuthorityOwnerProcessObservationProvesGone(identity, { + status: 'present', + platform: 'darwin', + startedAtMs: 1_001, + executionScope: 'host-a' + }) + ).toBe(true) + }) + + it('requires exact Linux boot and start-tick evidence', () => { + const identity = { + pid: 42, + platform: 'linux' as const, + bootId: 'boot-a', + linuxStartTicks: '100', + linuxPidNamespace: 'pid:[101]', + executionScope: 'host-a' + } + expect( + terminalAuthorityOwnerProcessObservationProvesGone(identity, { + status: 'present', + platform: 'linux', + bootId: 'boot-a', + linuxStartTicks: '100', + linuxPidNamespace: 'pid:[101]', + executionScope: 'host-a' + }) + ).toBe(false) + expect( + terminalAuthorityOwnerProcessObservationProvesGone(identity, { + status: 'present', + platform: 'linux', + bootId: 'boot-a', + linuxStartTicks: '101', + linuxPidNamespace: 'pid:[101]', + executionScope: 'host-a' + }) + ).toBe(true) + expect( + terminalAuthorityOwnerProcessObservationProvesGone(identity, { + status: 'present', + platform: 'linux', + bootId: 'boot-b', + linuxStartTicks: '101', + linuxPidNamespace: 'pid:[101]', + executionScope: 'host-a' + }) + ).toBe(true) + expect( + terminalAuthorityOwnerProcessObservationProvesGone(identity, { + status: 'missing', + platform: 'linux', + bootId: 'boot-a', + linuxPidNamespace: 'pid:[101]', + executionScope: 'host-a' + }) + ).toBe(true) + }) + + it('treats missing as death and every inspection gap as unknown', () => { + const identity = { + pid: 42, + platform: 'win32' as const, + startedAtMs: 1_000, + executionScope: 'host-a' + } + expect( + terminalAuthorityOwnerProcessObservationProvesGone(identity, { + status: 'missing', + platform: 'win32', + executionScope: 'host-a' + }) + ).toBe(true) + expect( + terminalAuthorityOwnerProcessObservationProvesGone(identity, { + status: 'unknown', + platform: 'win32', + executionScope: 'host-a' + }) + ).toBe(false) + expect( + terminalAuthorityOwnerProcessObservationProvesGone(identity, { + status: 'present', + platform: 'win32', + executionScope: 'host-a' + }) + ).toBe(false) + }) + + it('never treats another execution scope or Linux PID namespace as death proof', () => { + const linux = { + pid: 1, + platform: 'linux' as const, + bootId: 'shared-boot', + linuxStartTicks: '1', + linuxPidNamespace: 'pid:[101]', + executionScope: 'host-a' + } + expect( + terminalAuthorityOwnerProcessObservationProvesGone(linux, { + status: 'missing', + platform: 'linux', + bootId: 'shared-boot', + linuxPidNamespace: 'pid:[101]', + executionScope: 'host-b' + }) + ).toBe(false) + expect( + terminalAuthorityOwnerProcessObservationProvesGone(linux, { + status: 'present', + platform: 'linux', + bootId: 'shared-boot', + linuxStartTicks: '2', + linuxPidNamespace: 'pid:[202]', + executionScope: 'host-a' + }) + ).toBe(false) + }) + + it('treats missing or unreadable execution scope as unknown', () => { + const identity = { + pid: 42, + platform: 'darwin' as const, + startedAtMs: 1_000, + executionScope: 'host-a' + } + expect( + terminalAuthorityOwnerProcessObservationProvesGone(identity, { + status: 'missing', + platform: 'darwin' + }) + ).toBe(false) + expect( + terminalAuthorityOwnerProcessObservationProvesGone( + { pid: 42, platform: 'darwin', startedAtMs: 1_000 }, + { + status: 'missing', + platform: 'darwin', + executionScope: 'host-a' + } + ) + ).toBe(false) + }) + + it('never proves death from an older Linux identity without a PID namespace', () => { + expect( + terminalAuthorityOwnerProcessObservationProvesGone( + { + pid: 42, + platform: 'linux', + bootId: 'boot-a', + linuxStartTicks: '100', + executionScope: 'host-a' + }, + { + status: 'missing', + platform: 'linux', + linuxPidNamespace: 'pid:[101]', + executionScope: 'host-a' + } + ) + ).toBe(false) + }) + + it('never upgrades a legacy wall-clock timestamp into death proof', () => { + expect( + terminalAuthorityOwnerProcessObservationProvesGone( + { + pid: 42, + platform: 'legacy', + startedAtMs: 1_000, + executionScope: 'host-a' + }, + { + status: 'present', + platform: 'darwin', + startedAtMs: 5_000, + executionScope: 'host-a' + } + ) + ).toBe(false) + }) +}) diff --git a/src/main/session-authority/terminal-session-authority-owner-process.ts b/src/main/session-authority/terminal-session-authority-owner-process.ts new file mode 100644 index 00000000000..f068b4ed8d4 --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-owner-process.ts @@ -0,0 +1,239 @@ +import { readFile } from 'node:fs/promises' +import { parseLinuxStartTicks } from '../agent-hooks/managed-hook-owner-identity' +import { + inspectProcessSignal, + queryWindowsProcess, + readLinuxStat, + readMacosProcessStartedAtMs +} from '../daemon/daemon-process-inspection' +import { + readCurrentTerminalAuthorityExecutionScope, + readTerminalAuthorityLinuxPidNamespace +} from './terminal-session-authority-host-identity' + +export type TerminalAuthorityOwnerProcessIdentity = Readonly<{ + pid: number + platform: NodeJS.Platform | 'legacy' + startedAtMs?: number + linuxStartTicks?: string + linuxPidNamespace?: string + bootId?: string + executionScope?: string +}> + +export type TerminalAuthorityOwnerProcessObservation = + | Readonly<{ + status: 'missing' | 'unknown' + platform?: NodeJS.Platform + bootId?: string + linuxPidNamespace?: string + executionScope?: string + }> + | Readonly<{ + status: 'present' + platform: NodeJS.Platform + startedAtMs?: number + linuxStartTicks?: string + linuxPidNamespace?: string + bootId?: string + executionScope?: string + }> + +export async function readCurrentTerminalAuthorityOwnerProcessIdentity(): Promise { + if (process.platform === 'linux') { + return readCurrentLinuxIdentity() + } + if (process.platform === 'darwin') { + const [startedAtMs, scope] = await Promise.all([ + readMacosProcessStartedAtMs(process.pid), + readCurrentTerminalAuthorityExecutionScope() + ]) + return Object.freeze({ + pid: process.pid, + platform: process.platform, + ...scope, + ...(startedAtMs === null ? {} : { startedAtMs }) + }) + } + if (process.platform === 'win32') { + const [processIdentity, scope] = await Promise.all([ + queryWindowsProcess(process.pid), + readCurrentTerminalAuthorityExecutionScope() + ]) + return Object.freeze({ + pid: process.pid, + platform: process.platform, + ...scope, + ...(processIdentity.status === 'present' && processIdentity.startedAtMs !== null + ? { startedAtMs: processIdentity.startedAtMs } + : {}) + }) + } + const scope = await readCurrentTerminalAuthorityExecutionScope() + return Object.freeze({ + pid: process.pid, + platform: process.platform, + ...scope + }) +} + +export async function terminalAuthorityOwnerProcessIsGone( + identity: TerminalAuthorityOwnerProcessIdentity +): Promise { + return terminalAuthorityOwnerProcessObservationProvesGone( + identity, + await inspectTerminalAuthorityOwnerProcess(identity.pid) + ) +} + +export function terminalAuthorityOwnerProcessObservationProvesGone( + identity: TerminalAuthorityOwnerProcessIdentity, + observation: TerminalAuthorityOwnerProcessObservation +): boolean { + if ( + !identity.executionScope || + !observation.executionScope || + identity.executionScope !== observation.executionScope + ) { + return false + } + if (identity.platform === 'legacy' || identity.platform !== observation.platform) { + return false + } + if (identity.platform === 'linux') { + if ( + !identity.linuxStartTicks || + !identity.linuxPidNamespace || + !identity.bootId || + observation.linuxPidNamespace !== identity.linuxPidNamespace + ) { + return false + } + if (observation.status === 'missing') { + return true + } + if (observation.status !== 'present') { + return false + } + return ( + observation.bootId !== undefined && + observation.linuxStartTicks !== undefined && + (observation.bootId !== identity.bootId || + observation.linuxStartTicks !== identity.linuxStartTicks) + ) + } + if (observation.status === 'missing') { + return true + } + if (observation.status !== 'present') { + return false + } + if (identity.startedAtMs === undefined || observation.startedAtMs === undefined) { + return false + } + return observation.startedAtMs !== identity.startedAtMs +} + +async function readCurrentLinuxIdentity(): Promise { + try { + const [statLine, scope] = await Promise.all([ + readFile('/proc/self/stat', 'utf8'), + readCurrentTerminalAuthorityExecutionScope() + ]) + const linuxStartTicks = parseLinuxStartTicks(statLine) + return Object.freeze({ + pid: process.pid, + platform: 'linux', + ...scope, + ...(linuxStartTicks && scope.bootId && scope.linuxPidNamespace + ? { + linuxStartTicks, + linuxPidNamespace: scope.linuxPidNamespace, + bootId: scope.bootId + } + : {}) + }) + } catch { + const scope = await readCurrentTerminalAuthorityExecutionScope() + return Object.freeze({ + pid: process.pid, + platform: 'linux', + ...scope + }) + } +} + +async function inspectTerminalAuthorityOwnerProcess( + pid: number +): Promise { + const scope = await readCurrentTerminalAuthorityExecutionScope() + const signal = inspectProcessSignal(pid) + if (signal === 'missing') { + return Object.freeze({ + status: 'missing', + platform: process.platform, + ...scope + }) + } + if (signal !== 'occupied') { + return Object.freeze({ + status: 'unknown', + platform: process.platform, + ...scope + }) + } + if (process.platform === 'linux') { + const [stat, linuxPidNamespace] = await Promise.all([ + readLinuxStat(pid), + readTerminalAuthorityLinuxPidNamespace(pid) + ]) + if (stat.status === 'missing') { + return Object.freeze({ + status: 'missing', + platform: 'linux', + ...scope + }) + } + if (stat.status !== 'present' || !scope.bootId || !linuxPidNamespace) { + return Object.freeze({ + status: 'unknown', + platform: 'linux', + ...scope, + ...(linuxPidNamespace ? { linuxPidNamespace } : {}) + }) + } + const linuxStartTicks = parseLinuxStartTicks(stat.value) + return Object.freeze({ + status: 'present', + platform: 'linux', + ...scope, + linuxPidNamespace, + ...(linuxStartTicks ? { linuxStartTicks } : {}) + }) + } + if (process.platform === 'darwin') { + const startedAtMs = await readMacosProcessStartedAtMs(pid) + return Object.freeze({ + status: 'present', + platform: 'darwin', + ...scope, + ...(startedAtMs === null ? {} : { startedAtMs }) + }) + } + if (process.platform === 'win32') { + const processIdentity = await queryWindowsProcess(pid) + if (processIdentity.status === 'missing') { + return Object.freeze({ status: 'missing', platform: 'win32', ...scope }) + } + if (processIdentity.status !== 'present') { + return Object.freeze({ status: 'unknown', platform: 'win32', ...scope }) + } + return Object.freeze({ + status: 'present', + platform: 'win32', + ...scope, + ...(processIdentity.startedAtMs === null ? {} : { startedAtMs: processIdentity.startedAtMs }) + }) + } + return Object.freeze({ status: 'present', platform: process.platform, ...scope }) +} diff --git a/src/main/session-authority/terminal-session-authority-owner-token.test.ts b/src/main/session-authority/terminal-session-authority-owner-token.test.ts new file mode 100644 index 00000000000..a9d2ccb8cfb --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-owner-token.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest' +import { + encodeTerminalAuthorityOwnerToken, + parseTerminalAuthorityOwnerToken +} from './terminal-session-authority-owner-token' + +const OWNER_NONCE = '00000000-0000-4000-8000-000000000001' +const OWNER_INCARNATION = '00000000-0000-4000-8000-000000000002' + +describe('terminal authority owner token', () => { + it('round-trips a complete Linux execution identity', () => { + const encoded = encodeTerminalAuthorityOwnerToken(OWNER_NONCE, { + ownerIncarnationId: OWNER_INCARNATION, + process: { + pid: 1, + platform: 'linux', + bootId: 'boot-a', + linuxStartTicks: '100', + linuxPidNamespace: 'pid:[101]', + executionScope: 'host-a' + } + }) + + expect(parseTerminalAuthorityOwnerToken(encoded)?.process).toEqual({ + pid: 1, + platform: 'linux', + bootId: 'boot-a', + linuxStartTicks: '100', + linuxPidNamespace: 'pid:[101]', + executionScope: 'host-a' + }) + }) + + it('keeps older Linux tokens parseable without granting namespace proof', () => { + const encoded = encodeTerminalAuthorityOwnerToken(OWNER_NONCE, { + ownerIncarnationId: OWNER_INCARNATION, + process: { + pid: 1, + platform: 'linux', + bootId: 'boot-a', + linuxStartTicks: '100' + } + }) + + expect(parseTerminalAuthorityOwnerToken(encoded)?.process).toEqual({ + pid: 1, + platform: 'linux', + bootId: 'boot-a', + linuxStartTicks: '100' + }) + }) + + it('rejects a PID namespace without the matching Linux boot/start identity', () => { + expect(() => + encodeTerminalAuthorityOwnerToken(OWNER_NONCE, { + ownerIncarnationId: OWNER_INCARNATION, + process: { pid: 1, platform: 'linux', linuxPidNamespace: 'pid:[101]' } + }) + ).toThrow('namespace identity is incomplete') + }) +}) diff --git a/src/main/session-authority/terminal-session-authority-owner-token.ts b/src/main/session-authority/terminal-session-authority-owner-token.ts new file mode 100644 index 00000000000..5d625c36561 --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-owner-token.ts @@ -0,0 +1,142 @@ +import { Buffer } from 'node:buffer' +import { assertAuthorityId, isRecord } from '../../shared/terminal-session-authority-identity' +import type { TerminalAuthorityOwnerProcessIdentity } from './terminal-session-authority-owner-process' + +const OWNER_TOKEN_PREFIX = 'terminal-authority-owner-v1.' + +export type TerminalAuthorityOwnerProof = Readonly<{ + ownerIncarnationId: string + process: TerminalAuthorityOwnerProcessIdentity +}> + +export function encodeTerminalAuthorityOwnerToken( + ownerNonce: string, + proof: TerminalAuthorityOwnerProof +): string { + assertAuthorityId(ownerNonce, 'authority owner nonce') + assertAuthorityId(proof.ownerIncarnationId, 'ownerIncarnationId') + assertTerminalAuthorityOwnerProcessIdentity(proof.process) + const payload = Buffer.from( + JSON.stringify({ + version: 1, + ownerNonce, + ownerIncarnationId: proof.ownerIncarnationId, + process: proof.process + }), + 'utf8' + ).toString('base64url') + const ownerToken = `${OWNER_TOKEN_PREFIX}${payload}` + assertAuthorityId(ownerToken, 'authority owner token') + return ownerToken +} + +export function parseTerminalAuthorityOwnerToken( + ownerToken: string | null +): TerminalAuthorityOwnerProof | null { + if (!ownerToken?.startsWith(OWNER_TOKEN_PREFIX)) { + return null + } + let value: unknown + try { + assertAuthorityId(ownerToken, 'authority owner token') + value = JSON.parse( + Buffer.from(ownerToken.slice(OWNER_TOKEN_PREFIX.length), 'base64url').toString('utf8') + ) as unknown + } catch { + return null + } + if (!isRecord(value) || value.version !== 1 || !isRecord(value.process)) { + return null + } + try { + assertAuthorityId(value.ownerNonce, 'authority owner nonce') + assertAuthorityId(value.ownerIncarnationId, 'ownerIncarnationId') + } catch { + return null + } + const processIdentity = normalizeTerminalAuthorityOwnerProcessIdentity(value.process) + if (!processIdentity) { + return null + } + return Object.freeze({ + ownerIncarnationId: value.ownerIncarnationId, + process: processIdentity + }) +} + +export function assertTerminalAuthorityOwnerProcessIdentity( + value: unknown +): asserts value is TerminalAuthorityOwnerProcessIdentity { + if ( + !isRecord(value) || + !Number.isSafeInteger(value.pid) || + Number(value.pid) <= 0 || + !isOwnerProcessPlatform(value.platform) + ) { + throw new Error('authority owner process identity is invalid') + } + if ( + value.startedAtMs !== undefined && + (typeof value.startedAtMs !== 'number' || + !Number.isFinite(value.startedAtMs) || + value.startedAtMs <= 0) + ) { + throw new Error('authority owner process start identity is invalid') + } + const hasLinuxStartTicks = value.linuxStartTicks !== undefined + const hasBootId = value.bootId !== undefined + const hasLinuxPidNamespace = value.linuxPidNamespace !== undefined + if (hasLinuxStartTicks !== hasBootId) { + throw new Error('authority owner process identity is incomplete') + } + if (hasLinuxPidNamespace && !hasLinuxStartTicks) { + throw new Error('authority owner process namespace identity is incomplete') + } + if (hasLinuxStartTicks) { + assertAuthorityId(value.linuxStartTicks, 'linuxStartTicks') + assertAuthorityId(value.bootId, 'bootId') + } + if (hasLinuxPidNamespace) { + assertAuthorityId(value.linuxPidNamespace, 'linuxPidNamespace') + } + if (value.executionScope !== undefined) { + assertAuthorityId(value.executionScope, 'executionScope') + } +} + +function normalizeTerminalAuthorityOwnerProcessIdentity( + value: Record +): TerminalAuthorityOwnerProcessIdentity | null { + const normalized = Object.freeze({ + ...value, + platform: value.platform ?? 'legacy' + }) + try { + assertTerminalAuthorityOwnerProcessIdentity(normalized) + return normalized + } catch { + return null + } +} + +function isOwnerProcessPlatform( + value: unknown +): value is TerminalAuthorityOwnerProcessIdentity['platform'] { + return ( + typeof value === 'string' && + [ + 'aix', + 'android', + 'darwin', + 'freebsd', + 'haiku', + 'legacy', + 'linux', + 'openbsd', + 'sunos', + 'win32', + 'cygwin', + 'netbsd' + ].includes(value) + ) +} diff --git a/src/main/session-authority/terminal-session-authority-policy-consumer-claim.ts b/src/main/session-authority/terminal-session-authority-policy-consumer-claim.ts new file mode 100644 index 00000000000..f5a29200f58 --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-policy-consumer-claim.ts @@ -0,0 +1,67 @@ +import { terminalSessionAuthorityBoundaryId } from '../../shared/terminal-session-authority-boundary-identity' +import { + TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_VERSION, + type TerminalAuthorityNamespaceOutcomeBoundary, + type TerminalAuthorityPolicyConsumerIdentity +} from '../../shared/terminal-session-authority-consumer-transport' +import { failTerminalSessionAuthority } from '../../shared/terminal-session-authority-mutation' +import type { TerminalSessionAuthorityService } from './terminal-session-authority-service' + +export type TerminalAuthorityPolicyConsumerClaimPlan = Readonly<{ + expectedIncarnationId: string | null + consumerStart: 'new-at-tail' | 'resume' +}> + +export function prepareTerminalAuthorityPolicyConsumerClaim( + service: TerminalSessionAuthorityService, + identity: TerminalAuthorityPolicyConsumerIdentity, + claimedExpectedIncarnationId: string | null +): TerminalAuthorityPolicyConsumerClaimPlan { + const currentIncarnationId = service.activeConsumerIncarnation( + service.writerAccess, + identity.consumerId + ) + if ( + currentIncarnationId !== null && + currentIncarnationId !== identity.consumerIncarnationId && + currentIncarnationId !== claimedExpectedIncarnationId + ) { + failTerminalSessionAuthority('consumer-conflict', 'consumer incarnation changed') + } + return Object.freeze({ + expectedIncarnationId: currentIncarnationId, + consumerStart: currentIncarnationId === null ? 'new-at-tail' : 'resume' + }) +} + +export function terminalAuthorityPolicyConsumerClaim( + identity: TerminalAuthorityPolicyConsumerIdentity, + expectedIncarnationId: string | null +): Readonly<{ + consumerId: string + expectedIncarnationId: string | null + consumerIncarnationId: string +}> { + return Object.freeze({ + consumerId: identity.consumerId, + expectedIncarnationId, + consumerIncarnationId: identity.consumerIncarnationId + }) +} + +export function terminalAuthorityPolicyConsumerBoundary( + consumer: TerminalAuthorityPolicyConsumerIdentity, + consumerStart: 'new-at-tail' | 'resume', + snapshot: Awaited> +): TerminalAuthorityNamespaceOutcomeBoundary { + const boundary = { + version: TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_VERSION, + consumer, + namespace: Object.freeze({ ...snapshot.authority.namespace }), + acknowledgedSequence: snapshot.acknowledgedSequence, + outcomeHighWatermark: snapshot.outcomeHighWatermark, + consumerStart, + projection: structuredClone(snapshot.authority) + } + return Object.freeze({ ...boundary, boundaryId: terminalSessionAuthorityBoundaryId(boundary) }) +} diff --git a/src/main/session-authority/terminal-session-authority-policy-consumer-lifecycle.ts b/src/main/session-authority/terminal-session-authority-policy-consumer-lifecycle.ts new file mode 100644 index 00000000000..afa27ab2e22 --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-policy-consumer-lifecycle.ts @@ -0,0 +1,43 @@ +import type { TerminalSessionAuthorityService } from './terminal-session-authority-service' + +export class TerminalSessionAuthorityPolicyConsumerLifecycle { + private observer: ReturnType | null = null + private producerHold: Readonly<{ release(): void }> | null = null + + constructor(private readonly service: TerminalSessionAuthorityService) { + this.retainProducerHold() + } + + subscribe(consumerId: string, onProjection: () => void): void { + this.observer = this.service.subscribeProjection(consumerId, onProjection) + } + + revokeObserver(): void { + const observer = this.observer + this.observer = null + if (!observer) { + return + } + try { + this.service.revokeObserver(observer) + } catch { + // The authority service may already be closing. + } + } + + retainProducerHold(): void { + this.producerHold ??= this.service.acquireProducerHold(this.service.writerAccess) + } + + releaseCaughtUpHold(acknowledgedSequence: number, outcomeHighWatermark: number): void { + if (acknowledgedSequence >= outcomeHighWatermark) { + this.releaseProducerHold() + } + } + + releaseProducerHold(): void { + const hold = this.producerHold + this.producerHold = null + hold?.release() + } +} diff --git a/src/main/session-authority/terminal-session-authority-policy-consumer-session.ts b/src/main/session-authority/terminal-session-authority-policy-consumer-session.ts new file mode 100644 index 00000000000..2e90fbd6e73 --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-policy-consumer-session.ts @@ -0,0 +1,264 @@ +import type { TerminalAuthorityNamespace } from '../../shared/terminal-session-authority-identity' +import type { + TerminalAuthorityNamespaceOutcomeAck, + TerminalAuthorityPolicyConsumerIdentity +} from '../../shared/terminal-session-authority-consumer-transport' +import type { TerminalAuthorityConsumerAdmissionSeal } from './terminal-session-authority-consumer-admission-seal' +import { TerminalSessionAuthorityPolicyNamespacePump } from './terminal-session-authority-policy-namespace-pump' +import type { TerminalAuthorityPolicyOutcomeTransport } from './terminal-session-authority-policy-outcome-transport' +import type { TerminalSessionAuthorityService } from './terminal-session-authority-service' +import { + notifyTerminalAuthorityPolicyConsumerFailure, + terminalAuthorityPolicyNamespaceIsInstalled, + terminalAuthorityPolicyNamespaceKey as namespaceKey, + type TerminalAuthorityPolicyNamespaceOpening +} from './terminal-session-authority-policy-session-state' + +export class TerminalSessionAuthorityPolicyConsumerSession { + readonly identity: TerminalAuthorityPolicyConsumerIdentity + readonly token = Object.freeze({}) + private readonly runtimes = new Map() + private readonly installedNamespaces = new Set() + private active = true + private activated = false + + constructor( + readonly generation: number, + identity: TerminalAuthorityPolicyConsumerIdentity, + private readonly expectedConsumerIncarnationId: string | null, + private readonly transport: TerminalAuthorityPolicyOutcomeTransport, + private readonly assertSessionCurrent: () => void, + private readonly assertNamespaceCurrent: (namespace: TerminalAuthorityNamespace) => void, + private readonly releaseNamespace: (namespace: TerminalAuthorityNamespace) => void + ) { + this.identity = Object.freeze({ ...identity }) + } + + get isActive(): boolean { + return this.active + } + + async stageNamespace(service: TerminalSessionAuthorityService): Promise { + this.assertCurrent() + if (!this.activated) { + throw new Error('terminal authority policy consumer transport is not active') + } + const key = namespaceKey(service.namespace) + if (this.installedNamespaces.has(key)) { + return + } + const runtime = await this.openRuntime(service) + await runtime.stage() + this.assertCurrent() + } + + async commitStagedNamespace( + namespace: TerminalAuthorityNamespace, + seal?: TerminalAuthorityConsumerAdmissionSeal + ): Promise { + this.assertCurrent() + const key = namespaceKey(namespace) + const opening = this.runtimes.get(key) + if (!opening) { + throw new Error('terminal authority policy consumer namespace is not staged') + } + const runtime = await opening.ready + await runtime.commit(seal) + this.installedNamespaces.add(key) + } + + activate(): void { + this.assertCurrent() + if (this.activated) { + return + } + this.activated = true + } + + async acknowledge(ack: TerminalAuthorityNamespaceOutcomeAck): Promise { + this.assertCurrent() + const runtime = await this.runtimeFor(ack.namespace) + try { + return await runtime.acknowledge(ack) + } catch (error) { + this.failRuntime(namespaceKey(ack.namespace), runtime, error) + throw error + } + } + + async retire(): Promise { + this.assertCurrent() + let retired = 0 + for (const [key, opening] of this.runtimes) { + if (!this.installedNamespaces.has(key)) { + continue + } + const runtime = await opening.ready + if (await runtime.retire()) { + retired += 1 + } + } + this.disconnect() + return retired + } + + isInstalled(namespace: TerminalAuthorityNamespace): boolean { + return terminalAuthorityPolicyNamespaceIsInstalled( + this.active, + this.installedNamespaces, + namespace, + this.assertNamespaceCurrent + ) + } + + assertInstalled(namespace: TerminalAuthorityNamespace): void { + this.assertCurrent() + if (!this.activated || !this.installedNamespaces.has(namespaceKey(namespace))) { + throw new Error('terminal authority policy consumer namespace is not installed') + } + this.assertNamespaceCurrent(namespace) + } + + activateNamespace(namespace: TerminalAuthorityNamespace): void { + this.assertInstalled(namespace) + const opening = this.runtimes.get(namespaceKey(namespace)) + if (!opening) { + throw new Error('terminal authority policy consumer namespace is not installed') + } + opening.pump.startDelivery() + } + + displaceNamespace(namespace: TerminalAuthorityNamespace): void { + const key = namespaceKey(namespace) + const opening = this.runtimes.get(key) + this.runtimes.delete(key) + this.installedNamespaces.delete(key) + opening?.pump.disconnect() + this.releaseNamespace(namespace) + } + + async rollbackNamespace(namespace: TerminalAuthorityNamespace): Promise { + const key = namespaceKey(namespace) + const opening = this.runtimes.get(key) + if (!opening) { + return + } + const runtime = await opening.ready + const failure = await this.rollbackRuntime(key, runtime) + if (failure) { + throw failure + } + } + + disconnect(): void { + if (!this.active) { + return + } + this.active = false + this.installedNamespaces.clear() + for (const opening of this.runtimes.values()) { + opening.pump.disconnect() + } + } + + private async runtimeFor( + namespace: TerminalAuthorityNamespace + ): Promise { + this.assertCurrent() + this.assertNamespaceCurrent(namespace) + const key = namespaceKey(namespace) + const opening = this.runtimes.get(key) + if (!opening || !this.installedNamespaces.has(key)) { + throw new Error('terminal authority policy consumer namespace is not installed') + } + return await opening.ready + } + + private openRuntime( + service: TerminalSessionAuthorityService + ): Promise { + const key = namespaceKey(service.namespace) + const existing = this.runtimes.get(key) + if (existing) { + return existing.ready + } + this.assertCurrent() + let pump!: TerminalSessionAuthorityPolicyNamespacePump + pump = new TerminalSessionAuthorityPolicyNamespacePump( + service, + this.identity, + this.expectedConsumerIncarnationId, + this.transport, + () => this.assertRuntimeCurrent(service.namespace), + (error) => this.failRuntime(key, pump, error) + ) + let ready: Promise + try { + pump.prepare() + this.assertCurrent() + ready = Promise.resolve(pump) + } catch (error) { + pump.disconnect() + throw error + } + const opening = Object.freeze({ pump, ready }) + this.runtimes.set(key, opening) + void ready.catch(() => { + if (this.runtimes.get(key) === opening) { + this.runtimes.delete(key) + this.installedNamespaces.delete(key) + } + }) + return ready + } + + private async rollbackRuntime( + key: string, + runtime: TerminalSessionAuthorityPolicyNamespacePump + ): Promise { + let failure: unknown | null = null + try { + await runtime.rollback() + } catch (error) { + failure = error + } finally { + const opening = this.runtimes.get(key) + if (opening?.pump === runtime) { + this.runtimes.delete(key) + } + this.installedNamespaces.delete(key) + this.releaseNamespace(runtime.service.namespace) + } + return failure + } + + private failRuntime( + key: string, + runtime: TerminalSessionAuthorityPolicyNamespacePump, + error: unknown + ): void { + const opening = this.runtimes.get(key) + if (opening?.pump !== runtime) { + return + } + runtime.disconnect() + this.runtimes.delete(key) + this.installedNamespaces.delete(key) + this.releaseNamespace(runtime.service.namespace) + notifyTerminalAuthorityPolicyConsumerFailure(this.transport, error) + } + + private assertCurrent(): void { + if (!this.active) { + throw new Error('terminal authority policy consumer transport is stale') + } + this.assertSessionCurrent() + } + + private assertRuntimeCurrent(namespace: TerminalAuthorityNamespace): void { + this.assertCurrent() + if (this.installedNamespaces.has(namespaceKey(namespace))) { + this.assertNamespaceCurrent(namespace) + } + } +} diff --git a/src/main/session-authority/terminal-session-authority-policy-consumers.test.ts b/src/main/session-authority/terminal-session-authority-policy-consumers.test.ts new file mode 100644 index 00000000000..886b5af5d1c --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-policy-consumers.test.ts @@ -0,0 +1,631 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { + TerminalAuthorityNamespaceOutcomeAck, + TerminalAuthorityNamespaceOutcomeBoundary, + TerminalAuthorityNamespaceOutcomePublication, + TerminalAuthorityPolicyConsumerIdentity +} from '../../shared/terminal-session-authority-consumer-transport' +import { terminalAuthorityOperationIdentity } from '../../shared/terminal-session-authority-operation-identity' +import { + TerminalSessionAuthorityPolicyConsumers, + type TerminalAuthorityPolicyConsumerConnection +} from './terminal-session-authority-policy-consumers' +import { TerminalSessionAuthorityRegistry } from './terminal-session-authority-registry' +import type { TerminalSessionAuthorityService } from './terminal-session-authority-service' +import { + terminalAuthorityFloatingLocator, + terminalAuthorityWorkspaceLocator +} from './terminal-session-authority-workspace-locator' + +const directories: string[] = [] +const registries: TerminalSessionAuthorityRegistry[] = [] +const managers: TerminalSessionAuthorityPolicyConsumers[] = [] + +afterEach(async () => { + for (const manager of managers.splice(0)) { + manager.dispose() + } + await Promise.allSettled(registries.splice(0).map((registry) => registry.close())) + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +describe('TerminalSessionAuthorityPolicyConsumers', () => { + it('releases a disconnected snapshot hold and replays the retained cursor on reconnect', async () => { + const { manager, service } = await oneNamespace() + const boundaryStarted = deferred() + const releaseBoundary = deferred() + let firstStart: TerminalAuthorityNamespaceOutcomeBoundary['consumerStart'] + const first = await manager.connect(claim(identity('profile-a', 'process-a')), { + publishBoundary: async (boundary) => { + firstStart = boundary.consumerStart + boundaryStarted.resolve() + await releaseBoundary.promise + }, + publishOutcome: async () => {} + }) + await first.activate() + const firstInstallation = installNamespace(first, service) + await boundaryStarted.promise + expect(firstStart).toBe('new-at-tail') + + const firstMutation = createPane(service, 'pane-a', 'create-a') + await expectPending(firstMutation) + first.disconnect() + await expect(firstMutation).resolves.toBe(1) + releaseBoundary.resolve() + await expect(firstInstallation).rejects.toThrow('stale') + + const replayed: TerminalAuthorityNamespaceOutcomePublication[] = [] + const resumed: TerminalAuthorityNamespaceOutcomeBoundary[] = [] + const second = await manager.connect(claim(identity('profile-a', 'process-b')), { + publishBoundary: async (boundary) => { + resumed.push(boundary) + }, + publishOutcome: async (publication) => { + replayed.push(publication) + } + }) + await second.activate() + await installNamespace(second, service) + expect(resumed[0]).toMatchObject({ + consumerStart: 'new-at-tail', + acknowledgedSequence: 1, + outcomeHighWatermark: 1 + }) + expect(replayed).toHaveLength(0) + await createPane(service, 'pane-b', 'create-b') + await waitForLength(replayed, 1) + second.disconnect() + + const finalReplay: number[] = [] + const finalAcked = deferred() + let finalConnection!: Awaited> + finalConnection = await manager.connect( + claim(identity('profile-a', 'process-c'), 'app-process:process-b'), + { + publishBoundary: async () => {}, + publishOutcome: async (publication) => { + finalReplay.push( + ...(publication.outcomes ?? [publication.outcome]).map((outcome) => outcome.sequence) + ) + await finalConnection.acknowledge(ackFor(publication)) + finalAcked.resolve() + } + } + ) + await finalConnection.activate() + await installNamespace(finalConnection, service) + await vi.waitFor(() => expect(finalReplay).toEqual([2]), { timeout: 5_000 }) + await finalAcked.promise + await expect(second.acknowledge(ackFor(replayed[0]!))).rejects.toThrow('stale') + await expect(cursor(service, finalConnection.identity)).resolves.toMatchObject({ + acknowledgedSequence: 2, + outcomeHighWatermark: 2 + }) + }) + + it('fences a same-incarnation transport replacement without deleting the replacement', async () => { + const { manager, service } = await oneNamespace() + const consumer = identity('profile-a', 'process-a') + const first = await manager.connect(claim(consumer), quietTransport()) + await first.activate() + await installNamespace(first, service) + const outcomes: TerminalAuthorityNamespaceOutcomePublication[] = [] + const replacement = await manager.connect(claim(consumer), { + publishBoundary: async () => {}, + publishOutcome: async (publication) => { + outcomes.push(publication) + } + }) + await replacement.activate() + await installNamespace(replacement, service) + + expect(first.isInstalled(service.namespace)).toBe(false) + expect(() => first.assertInstalled(service.namespace)).toThrow('not installed') + first.disconnect() + await createPane(service, 'pane-a', 'replacement-survives') + await waitForLength(outcomes, 1) + expect(replacement.isInstalled(service.namespace)).toBe(true) + }) + + it('keeps the incumbent claimed when a prepared handover never receives its grant', async () => { + const { manager, service } = await oneNamespace() + const incumbentIdentity = identity('profile-a', 'process-a') + const incumbent = await manager.connect(claim(incumbentIdentity), quietTransport()) + await incumbent.activate() + await installNamespace(incumbent, service) + const stale = await manager.connect( + claim(identity('profile-a', 'stale-process'), 'app-process:wrong-predecessor'), + quietTransport() + ) + await stale.activate() + await expect(installNamespace(stale, service)).rejects.toMatchObject({ + code: 'consumer-conflict' + }) + stale.disconnect() + const replacement = await manager.connect( + claim(identity('profile-a', 'process-b'), incumbentIdentity.consumerIncarnationId), + quietTransport() + ) + await replacement.activate() + const preparation = await replacement.prepareNamespace?.(service) + expect(preparation).toBeDefined() + expect( + service.activeConsumerIncarnation(service.writerAccess, incumbentIdentity.consumerId) + ).toBe(incumbentIdentity.consumerIncarnationId) + await preparation!.rollback() + replacement.disconnect() + expect( + service.activeConsumerIncarnation(service.writerAccess, incumbentIdentity.consumerId) + ).toBe(incumbentIdentity.consumerIncarnationId) + expect(incumbent.isInstalled(service.namespace)).toBe(true) + await expect(createPane(service, 'pane-a', 'incumbent-remains')).resolves.toBe(1) + }) + + it('keeps a handover claim durable when the transport drops mid-commit', async () => { + const { manager, service } = await oneNamespace() + const incumbentIdentity = identity('profile-a', 'process-a') + const incumbent = await manager.connect(claim(incumbentIdentity), quietTransport()) + await incumbent.activate() + await installNamespace(incumbent, service) + + const candidateIdentity = identity('profile-a', 'process-b') + const candidate = await manager.connect( + claim(candidateIdentity, incumbentIdentity.consumerIncarnationId), + quietTransport() + ) + await candidate.activate() + const preparation = await candidate.prepareNamespace?.(service) + expect(preparation).toBeDefined() + + const commitConsumerAdmission = service.commitConsumerAdmission.bind(service) + const claimReturned = deferred() + const releaseClaim = deferred() + const interleaving: string[] = [] + vi.spyOn(service, 'commitConsumerAdmission').mockImplementation(async (writer, input, seal) => { + const consumer = await commitConsumerAdmission(writer, input, seal) + if (input.consumerIncarnationId === candidateIdentity.consumerIncarnationId) { + interleaving.push('claim-returned') + claimReturned.resolve() + await releaseClaim.promise + interleaving.push('claim-released') + } + return consumer + }) + + const committing = preparation!.commit() + await claimReturned.promise + candidate.disconnect() + interleaving.push('transport-disconnected') + const rollingBack = preparation!.rollback() + releaseClaim.resolve() + + await expect(committing).rejects.toThrow('stale') + await rollingBack + expect(interleaving).toEqual(['claim-returned', 'transport-disconnected', 'claim-released']) + // The claim settled with its grant inside one serialized operation, so the disconnect tears the + // live state down and leaves the durable record standing rather than rewinding it. + expect( + service.activeConsumerIncarnation(service.writerAccess, candidateIdentity.consumerId) + ).toBe(candidateIdentity.consumerIncarnationId) + expect(candidate.isInstalled(service.namespace)).toBe(false) + expect(incumbent.isInstalled(service.namespace)).toBe(false) + + // Recovery is a fresh admission against the claimed incarnation, not a compensating write. + const resumed = await manager.connect( + claim(candidateIdentity, candidateIdentity.consumerIncarnationId), + quietTransport() + ) + await resumed.activate() + await installNamespace(resumed, service) + expect(resumed.isInstalled(service.namespace)).toBe(true) + }) + + it('installs a new floating namespace without disturbing an existing folder namespace', async () => { + const { directory, registry, manager, service: folder } = await oneNamespace() + const boundaries: TerminalAuthorityNamespaceOutcomeBoundary[] = [] + const outcomes: TerminalAuthorityNamespaceOutcomePublication[] = [] + const connection = await manager.connect(claim(identity('profile-a', 'process-a')), { + publishBoundary: async (boundary) => { + boundaries.push(boundary) + }, + publishOutcome: async (publication) => { + outcomes.push(publication) + } + }) + await connection.activate() + await installNamespace(connection, folder) + await waitForLength(boundaries, 1) + + const floatingNamespace = (await registry.resolveNamespace(terminalAuthorityFloatingLocator())) + .namespace + const floating = await registry.openNamespace(floatingNamespace) + await installNamespace(connection, floating) + await waitForLength(boundaries, 2) + expect(connection.isInstalled(folder.namespace)).toBe(true) + expect(connection.isInstalled(floating.namespace)).toBe(true) + expect(directory).toContain(path.sep) + + await Promise.all([ + createPane(folder, 'folder-pane', 'folder-mutation'), + createPane(floating, 'floating-pane', 'floating-mutation') + ]) + await waitForLength(outcomes, 2) + expect(new Set(outcomes.map((item) => item.namespace.namespaceId)).size).toBe(2) + }) + + it('keeps namespace handover independent and accepts an exact namespace retry', async () => { + const harness = await twoNamespaces() + const incumbentIdentity = identity('profile-a', 'process-a') + const incumbent = await harness.manager.connect(claim(incumbentIdentity), quietTransport()) + await incumbent.activate() + await Promise.all([ + installNamespace(incumbent, harness.first), + installNamespace(incumbent, harness.second) + ]) + + const candidate = identity('profile-a', 'process-b') + const secondClaim = vi + .spyOn(harness.second, 'commitConsumerAdmission') + .mockImplementationOnce(async () => { + expect( + harness.first.activeConsumerIncarnation(harness.first.writerAccess, candidate.consumerId) + ).toBe(candidate.consumerIncarnationId) + throw new Error('second namespace claim failed') + }) + const partial = await harness.manager.connect( + claim(candidate, incumbentIdentity.consumerIncarnationId), + quietTransport() + ) + await partial.activate() + await installNamespace(partial, harness.first) + await expect(installNamespace(partial, harness.second)).rejects.toThrow( + 'second namespace claim failed' + ) + secondClaim.mockRestore() + + expect( + harness.first.activeConsumerIncarnation(harness.first.writerAccess, candidate.consumerId) + ).toBe(candidate.consumerIncarnationId) + expect( + harness.second.activeConsumerIncarnation(harness.second.writerAccess, candidate.consumerId) + ).toBe(incumbentIdentity.consumerIncarnationId) + expect(partial.isInstalled(harness.first.namespace)).toBe(true) + expect(partial.isInstalled(harness.second.namespace)).toBe(false) + expect(incumbent.isInstalled(harness.first.namespace)).toBe(false) + expect(incumbent.isInstalled(harness.second.namespace)).toBe(true) + await expect( + Promise.all([ + createPane(harness.first, 'pane-after-handover-a', 'partial-a'), + createPane(harness.second, 'pane-after-failure-b', 'partial-b') + ]) + ).resolves.toEqual([1, 1]) + + await installNamespace(partial, harness.second) + expect(partial.isInstalled(harness.first.namespace)).toBe(true) + expect(partial.isInstalled(harness.second.namespace)).toBe(true) + expect(incumbent.isInstalled(harness.second.namespace)).toBe(false) + expect( + harness.second.activeConsumerIncarnation(harness.second.writerAccess, candidate.consumerId) + ).toBe(candidate.consumerIncarnationId) + }) + + it('does not advance another device or namespace cursor', async () => { + const { manager, first, second } = await twoNamespaces() + const publications = new Map() + const connect = async (consumer: TerminalAuthorityPolicyConsumerIdentity) => { + const connection = await manager.connect(claim(consumer), { + publishBoundary: async () => {}, + publishOutcome: async (publication) => { + publications.set(publicationKey(publication), publication) + } + }) + await connection.activate() + await Promise.all([installNamespace(connection, first), installNamespace(connection, second)]) + return connection + } + const app = await connect(identity('profile-a', 'process-a')) + const device = await connect(identity('device-a', 'device-process-a', 'paired-device')) + await Promise.all([ + createPane(first, 'pane-a', 'isolation-a'), + createPane(second, 'pane-b', 'isolation-b') + ]) + await vi.waitFor(() => expect(publications.size).toBe(4), { timeout: 5_000 }) + + const appFirst = publications.get(keyFor(app.identity, first))! + await app.acknowledge(ackFor(appFirst)) + await expect(cursor(first, app.identity)).resolves.toMatchObject({ acknowledgedSequence: 1 }) + await expect(cursor(second, app.identity)).resolves.toMatchObject({ acknowledgedSequence: 0 }) + await expect(cursor(first, device.identity)).resolves.toMatchObject({ + acknowledgedSequence: 0 + }) + await expect(cursor(second, device.identity)).resolves.toMatchObject({ + acknowledgedSequence: 0 + }) + }) + + it('releases the producer after boundary, read, publish, and ACK failures', async () => { + await proveBoundaryFailureReleasesHold() + await proveReadFailureReleasesHold() + await provePublishFailureReleasesHold() + await proveAckFailureReleasesHold() + }) + + it('releases the producer when snapshot preparation fails', async () => { + const { manager, service } = await oneNamespace() + const snapshot = vi + .spyOn(service, 'snapshotForConsumerClaim') + .mockRejectedValueOnce(new Error('snapshot failed')) + const connection = await manager.connect( + claim(identity('profile-a', 'process-a')), + quietTransport() + ) + await connection.activate() + await expect(installNamespace(connection, service)).rejects.toThrow('snapshot failed') + snapshot.mockRestore() + await expect(createPane(service, 'pane-a', 'after-snapshot-failure')).resolves.toBe(1) + }) +}) + +async function proveBoundaryFailureReleasesHold(): Promise { + const { manager, service } = await oneNamespace() + const started = deferred() + const reject = deferred() + const connection = await manager.connect(claim(identity('boundary', 'process-a')), { + publishBoundary: async () => { + started.resolve() + await reject.promise + throw new Error('boundary failed') + }, + publishOutcome: async () => {} + }) + await connection.activate() + const installation = installNamespace(connection, service) + await started.promise + const mutation = createPane(service, 'boundary-pane', 'boundary-failure') + await expectPending(mutation) + reject.resolve() + await expect(installation).rejects.toThrow('boundary failed') + await expect(mutation).resolves.toBe(1) +} + +async function proveReadFailureReleasesHold(): Promise { + const { manager, service } = await returningConsumer('read') + const started = deferred() + const reject = deferred() + vi.spyOn(service, 'readOutcomes').mockImplementationOnce(async () => { + started.resolve() + await reject.promise + throw new Error('read failed') + }) + const failure = deferred() + const connection = await manager.connect( + claim(identity('read', 'process-b'), 'app-process:process-a'), + { + publishBoundary: async () => {}, + publishOutcome: async () => {}, + onFailure: () => failure.resolve() + } + ) + await connection.activate() + await installNamespace(connection, service) + await started.promise + const mutation = createPane(service, 'read-pane-b', 'read-failure') + await expectPending(mutation) + reject.resolve() + await failure.promise + await expect(mutation).resolves.toBe(2) +} + +async function provePublishFailureReleasesHold(): Promise { + const { manager, service } = await returningConsumer('publish') + const started = deferred() + const reject = deferred() + const failure = deferred() + const connection = await manager.connect( + claim(identity('publish', 'process-b'), 'app-process:process-a'), + { + publishBoundary: async () => {}, + publishOutcome: async () => { + started.resolve() + await reject.promise + throw new Error('publish failed') + }, + onFailure: () => failure.resolve() + } + ) + await connection.activate() + await installNamespace(connection, service) + await started.promise + const mutation = createPane(service, 'publish-pane-b', 'publish-failure') + await expectPending(mutation) + reject.resolve() + await failure.promise + await expect(mutation).resolves.toBe(2) +} + +async function proveAckFailureReleasesHold(): Promise { + const { manager, service } = await returningConsumer('ack') + const published = deferred() + const connection = await manager.connect( + claim(identity('ack', 'process-b'), 'app-process:process-a'), + { + publishBoundary: async () => {}, + publishOutcome: async (publication) => published.resolve(publication) + } + ) + await connection.activate() + await installNamespace(connection, service) + const publication = await published.promise + const mutation = createPane(service, 'ack-pane-b', 'ack-failure') + await expectPending(mutation) + vi.spyOn(service, 'acknowledgeOutcomes').mockRejectedValueOnce(new Error('ACK failed')) + await expect(connection.acknowledge(ackFor(publication))).rejects.toThrow('ACK failed') + await expect(mutation).resolves.toBe(2) +} + +async function returningConsumer(profile: string) { + const harness = await oneNamespace() + const consumer = identity(profile, 'process-a') + await harness.service.claimConsumer(harness.service.writerAccess, { + consumerId: consumer.consumerId, + expectedIncarnationId: null, + consumerIncarnationId: consumer.consumerIncarnationId + }) + await createPane(harness.service, `${profile}-pane-a`, `${profile}-seed`) + return harness +} + +async function oneNamespace() { + const directory = freshDirectory() + const registry = await openRegistry(directory) + const namespace = (await registry.resolveNamespace(terminalAuthorityWorkspaceLocator(directory))) + .namespace + const service = await registry.openNamespace(namespace) + const manager = new TerminalSessionAuthorityPolicyConsumers() + managers.push(manager) + return { directory, registry, manager, service } +} + +async function twoNamespaces() { + const harness = await oneNamespace() + const namespace = (await harness.registry.resolveNamespace(terminalAuthorityFloatingLocator())) + .namespace + const second = await harness.registry.openNamespace(namespace) + return { ...harness, first: harness.service, second } +} + +async function openRegistry(directory: string): Promise { + const registry = await TerminalSessionAuthorityRegistry.open({ + directory: path.join(directory, 'authority'), + authorityHostId: 'host-policy-test', + ownerToken: 'owner-token-policy-test', + ownerIncarnationId: 'owner-incarnation-policy-test', + writerActorId: 'writer-policy-test' + }) + registries.push(registry) + return registry +} + +async function createPane( + service: TerminalSessionAuthorityService, + paneKey: string, + correlationId: string +): Promise { + const revision = service.snapshotForWriter(service.writerAccess).revision + const receipt = await service.mutate(service.writerAccess, { + actorId: service.writerAccess.actorId, + ...terminalAuthorityOperationIdentity(revision, correlationId), + baseRevision: revision, + change: { + kind: 'create', + pane: { paneKey, paneGenerationId: `${paneKey}-generation` } + } + }) + return receipt.outcomeSequence +} + +function cursor( + service: TerminalSessionAuthorityService, + consumer: TerminalAuthorityPolicyConsumerIdentity +) { + return service.snapshotForConsumer({ + role: 'consumer', + serviceInstanceId: service.writerAccess.serviceInstanceId, + consumerId: consumer.consumerId, + consumerIncarnationId: consumer.consumerIncarnationId + }) +} + +function identity( + profile: string, + process: string, + kind: 'app-profile' | 'paired-device' = 'app-profile' +): TerminalAuthorityPolicyConsumerIdentity { + return Object.freeze({ + consumerId: `${kind}:${profile}`, + consumerIncarnationId: `${kind === 'app-profile' ? 'app-process' : 'device-process'}:${process}` + }) +} + +function claim( + consumer: TerminalAuthorityPolicyConsumerIdentity, + expectedConsumerIncarnationId: string | null = null +) { + return Object.freeze({ version: 1 as const, consumer, expectedConsumerIncarnationId }) +} + +function quietTransport() { + return Object.freeze({ publishBoundary: async () => {}, publishOutcome: async () => {} }) +} + +function ackFor( + publication: TerminalAuthorityNamespaceOutcomePublication +): TerminalAuthorityNamespaceOutcomeAck { + const outcome = (publication.outcomes ?? [publication.outcome]).at(-1)! + return Object.freeze({ + version: 1, + consumer: publication.consumer, + namespace: publication.namespace, + sequence: outcome.sequence, + outcomeId: outcome.outcomeId + }) +} + +function publicationKey(publication: TerminalAuthorityNamespaceOutcomePublication): string { + return JSON.stringify([publication.consumer.consumerId, publication.namespace.namespaceId]) +} + +function keyFor( + consumer: TerminalAuthorityPolicyConsumerIdentity, + service: TerminalSessionAuthorityService +): string { + return JSON.stringify([consumer.consumerId, service.namespace.namespaceId]) +} + +async function waitForLength(values: readonly unknown[], length: number): Promise { + await vi.waitFor(() => expect(values).toHaveLength(length), { timeout: 5_000 }) +} + +async function expectPending(promise: Promise): Promise { + let settled = false + void promise.then( + () => { + settled = true + }, + () => { + settled = true + } + ) + await new Promise((resolve) => setImmediate(resolve)) + expect(settled).toBe(false) +} + +function deferred() { + let resolve!: (value: T | PromiseLike) => void + const promise = new Promise((settle) => { + resolve = settle + }) + return { promise, resolve } +} + +function freshDirectory(): string { + const directory = mkdtempSync(path.join(tmpdir(), 'orca-policy-consumers-')) + directories.push(directory) + return directory +} + +// Installation now runs the same staged prepare/commit an authenticated admission drives; the +// connection's own ensureNamespace only asserts, so it can never claim on its own. +async function installNamespace( + connection: TerminalAuthorityPolicyConsumerConnection, + service: TerminalSessionAuthorityService +): Promise { + const preparation = await connection.prepareNamespace!(service) + await preparation.commit() +} diff --git a/src/main/session-authority/terminal-session-authority-policy-consumers.ts b/src/main/session-authority/terminal-session-authority-policy-consumers.ts new file mode 100644 index 00000000000..a3dfb13d29f --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-policy-consumers.ts @@ -0,0 +1,278 @@ +import type { TerminalAuthorityNamespace } from '../../shared/terminal-session-authority-identity' +import { + sameTerminalAuthorityPolicyConsumer, + type TerminalAuthorityNamespaceOutcomeAck, + type TerminalAuthorityPolicyConsumerClaim, + type TerminalAuthorityPolicyConsumerIdentity +} from '../../shared/terminal-session-authority-consumer-transport' +import type { TerminalAuthorityConsumerAdmissionSeal } from './terminal-session-authority-consumer-admission-seal' +import { + terminalAuthorityPolicyNamespacePreparation, + type TerminalAuthorityPolicyNamespacePreparation +} from './terminal-session-authority-policy-namespace-preparation' +import { TerminalSessionAuthorityPolicyConsumerSession } from './terminal-session-authority-policy-consumer-session' +import type { TerminalAuthorityPolicyOutcomeTransport } from './terminal-session-authority-policy-outcome-transport' +import type { TerminalSessionAuthorityService } from './terminal-session-authority-service' + +export type { TerminalAuthorityPolicyOutcomeTransport } from './terminal-session-authority-policy-outcome-transport' + +export type TerminalAuthorityPolicyConsumerConnection = Readonly<{ + identity: TerminalAuthorityPolicyConsumerIdentity + activate(): Promise + prepareNamespace?( + service: TerminalSessionAuthorityService, + seal?: TerminalAuthorityConsumerAdmissionSeal + ): Promise + /** Asserts only. Every durable claim goes through authenticated admission, so this never claims. */ + ensureNamespace(service: TerminalSessionAuthorityService): Promise + assertInstalled(namespace: TerminalAuthorityNamespace): void + acknowledge(ack: TerminalAuthorityNamespaceOutcomeAck): Promise + retire(): Promise + isInstalled(namespace: TerminalAuthorityNamespace): boolean + disconnect(): void +}> + +export type { TerminalAuthorityPolicyNamespacePreparation } from './terminal-session-authority-policy-namespace-preparation' + +export type TerminalAuthorityPolicyConsumerResolver = Readonly<{ + forNamespace(namespace: TerminalAuthorityNamespace): TerminalAuthorityPolicyConsumerConnection +}> + +export type TerminalAuthorityPolicyConsumerSource = + | TerminalAuthorityPolicyConsumerConnection + | TerminalAuthorityPolicyConsumerResolver + +export function terminalAuthorityPolicyConsumerForNamespace( + source: TerminalAuthorityPolicyConsumerSource, + namespace: TerminalAuthorityNamespace +): TerminalAuthorityPolicyConsumerConnection { + return 'forNamespace' in source ? source.forNamespace(namespace) : source +} + +export class TerminalSessionAuthorityPolicyConsumers { + private readonly connections = new Map() + private readonly pendingConnections = new Map< + object, + TerminalSessionAuthorityPolicyConsumerSession + >() + private readonly namespaceOwners = new Map< + string, + TerminalSessionAuthorityPolicyConsumerSession + >() + private nextGeneration = 0 + + hasInstalledTransport(): boolean { + return this.namespaceOwners.size > 0 + } + + dispose(): void { + for (const connection of this.connections.values()) { + connection.disconnect() + } + for (const connection of this.pendingConnections.values()) { + connection.disconnect() + } + this.connections.clear() + this.pendingConnections.clear() + this.namespaceOwners.clear() + } + + async connect( + claim: TerminalAuthorityPolicyConsumerClaim, + transport: TerminalAuthorityPolicyOutcomeTransport + ): Promise { + let connection!: TerminalSessionAuthorityPolicyConsumerSession + connection = new TerminalSessionAuthorityPolicyConsumerSession( + ++this.nextGeneration, + claim.consumer, + claim.expectedConsumerIncarnationId, + transport, + () => this.assertOwned(connection), + (namespace) => this.assertNamespaceOwner(connection, namespace), + (namespace) => this.releaseNamespace(connection, namespace) + ) + this.pendingConnections.set(connection.token, connection) + return this.connectionAccess(connection) + } + + private connectionAccess( + connection: TerminalSessionAuthorityPolicyConsumerSession + ): TerminalAuthorityPolicyConsumerConnection { + return Object.freeze({ + identity: connection.identity, + activate: () => this.activate(connection), + prepareNamespace: (service, seal) => this.prepareNamespace(connection, service, seal), + ensureNamespace: async (service) => this.assertInstalled(connection, service.namespace), + assertInstalled: (namespace) => this.assertInstalled(connection, namespace), + acknowledge: (ack) => this.acknowledge(connection, structuredClone(ack)), + retire: () => this.retire(connection), + isInstalled: (namespace) => connection.isInstalled(namespace), + disconnect: () => this.disconnect(connection) + }) + } + + private async prepareNamespace( + connection: TerminalSessionAuthorityPolicyConsumerSession, + service: TerminalSessionAuthorityService, + seal?: TerminalAuthorityConsumerAdmissionSeal + ): Promise { + this.assertCurrent(connection) + try { + await connection.stageNamespace(service) + this.assertCurrent(connection) + } catch (error) { + // Staging holds the producer while it publishes the boundary; a failed stage must release it. + connection.displaceNamespace(service.namespace) + throw error + } + return terminalAuthorityPolicyNamespacePreparation({ + connection, + service, + seal, + assertCurrent: () => this.assertCurrent(connection), + assertClaimable: () => this.assertNamespaceClaimable(connection, service.namespace), + installOwner: () => this.installNamespaceOwner(connection, service.namespace) + }) + } + + private assertInstalled( + connection: TerminalSessionAuthorityPolicyConsumerSession, + namespace: TerminalAuthorityNamespace + ): void { + this.assertCurrent(connection) + connection.assertInstalled(namespace) + } + + async activate(connection: TerminalSessionAuthorityPolicyConsumerSession): Promise { + this.assertOwned(connection) + if (this.connections.get(connection.token) === connection) { + connection.activate() + return + } + if (this.pendingConnections.get(connection.token) !== connection) { + throw new Error('terminal authority policy consumer transport is stale') + } + connection.activate() + this.pendingConnections.delete(connection.token) + this.connections.set(connection.token, connection) + } + + private acknowledge( + connection: TerminalSessionAuthorityPolicyConsumerSession, + ack: TerminalAuthorityNamespaceOutcomeAck + ): Promise { + return this.acknowledgeCurrent(connection, ack) + } + + private async acknowledgeCurrent( + connection: TerminalSessionAuthorityPolicyConsumerSession, + ack: TerminalAuthorityNamespaceOutcomeAck + ): Promise { + this.assertConnectionIdentity(connection, ack.consumer) + const sequence = await connection.acknowledge(ack) + this.assertConnectionIdentity(connection, ack.consumer) + return sequence + } + + private async retire(connection: TerminalSessionAuthorityPolicyConsumerSession): Promise { + this.assertCurrent(connection) + const retired = await connection.retire() + this.removeOwned(connection) + return retired + } + + private disconnect(connection: TerminalSessionAuthorityPolicyConsumerSession): void { + this.removeOwned(connection) + connection.disconnect() + } + + private assertConnectionIdentity( + connection: TerminalSessionAuthorityPolicyConsumerSession, + identity: TerminalAuthorityPolicyConsumerIdentity + ): void { + this.assertCurrent(connection) + if (!sameTerminalAuthorityPolicyConsumer(connection.identity, identity)) { + throw new Error('terminal authority policy consumer transport is stale') + } + } + + private assertCurrent(connection: TerminalSessionAuthorityPolicyConsumerSession): void { + if (!connection.isActive || this.connections.get(connection.token) !== connection) { + throw new Error('terminal authority policy consumer transport is stale') + } + } + + private assertOwned(connection: TerminalSessionAuthorityPolicyConsumerSession): void { + if (!connection.isActive) { + throw new Error('terminal authority policy consumer transport is stale') + } + if ( + this.connections.get(connection.token) !== connection && + this.pendingConnections.get(connection.token) !== connection + ) { + throw new Error('terminal authority policy consumer transport is stale') + } + } + + private assertNamespaceOwner( + connection: TerminalSessionAuthorityPolicyConsumerSession, + namespace: TerminalAuthorityNamespace + ): void { + this.assertCurrent(connection) + if ( + this.namespaceOwners.get(namespaceKey(connection.identity.consumerId, namespace)) !== + connection + ) { + throw new Error('terminal authority policy consumer namespace is stale') + } + } + + private removeOwned(connection: TerminalSessionAuthorityPolicyConsumerSession): void { + this.connections.delete(connection.token) + this.pendingConnections.delete(connection.token) + for (const [key, owner] of this.namespaceOwners) { + if (owner === connection) { + this.namespaceOwners.delete(key) + } + } + } + + // Non-fallible by contract: `assertNamespaceClaimable` already ran inside the same seal. + private installNamespaceOwner( + connection: TerminalSessionAuthorityPolicyConsumerSession, + namespace: TerminalAuthorityNamespace + ): void { + const key = namespaceKey(connection.identity.consumerId, namespace) + const previous = this.namespaceOwners.get(key) + this.namespaceOwners.set(key, connection) + if (previous && previous !== connection) { + previous.displaceNamespace(namespace) + } + } + + private assertNamespaceClaimable( + connection: TerminalSessionAuthorityPolicyConsumerSession, + namespace: TerminalAuthorityNamespace + ): void { + const current = this.namespaceOwners.get( + namespaceKey(connection.identity.consumerId, namespace) + ) + if (current && current !== connection && current.generation > connection.generation) { + throw new Error('terminal authority policy consumer namespace is stale') + } + } + + private releaseNamespace( + connection: TerminalSessionAuthorityPolicyConsumerSession, + namespace: TerminalAuthorityNamespace + ): void { + const key = namespaceKey(connection.identity.consumerId, namespace) + if (this.namespaceOwners.get(key) === connection) { + this.namespaceOwners.delete(key) + } + } +} + +function namespaceKey(consumerId: string, namespace: TerminalAuthorityNamespace): string { + return JSON.stringify([consumerId, namespace.authorityHostId, namespace.namespaceId]) +} diff --git a/src/main/session-authority/terminal-session-authority-policy-namespace-preparation.ts b/src/main/session-authority/terminal-session-authority-policy-namespace-preparation.ts new file mode 100644 index 00000000000..0429e1d86a1 --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-policy-namespace-preparation.ts @@ -0,0 +1,93 @@ +import { + composeTerminalAuthorityConsumerAdmissionSeals, + type TerminalAuthorityConsumerAdmissionSeal +} from './terminal-session-authority-consumer-admission-seal' +import { joinTerminalAuthorityRollbackFailure } from './terminal-session-authority-consumer-rollback-failure' +import type { TerminalSessionAuthorityPolicyConsumerSession } from './terminal-session-authority-policy-consumer-session' +import type { TerminalSessionAuthorityService } from './terminal-session-authority-service' + +export type TerminalAuthorityPolicyNamespacePreparation = Readonly<{ + commit(): Promise + rollback(): Promise +}> + +type PreparationInput = Readonly<{ + connection: TerminalSessionAuthorityPolicyConsumerSession + service: TerminalSessionAuthorityService + seal?: TerminalAuthorityConsumerAdmissionSeal + assertCurrent(): void + assertClaimable(): void + installOwner(): void +}> + +/** + * The staged namespace's commit gate. Its fences run inside seal() so the serialized namespace queue + * cannot invalidate them before the append, and namespace ownership moves in commit() so nothing + * after the durable claim can fail. + */ +export function terminalAuthorityPolicyNamespacePreparation( + input: PreparationInput +): TerminalAuthorityPolicyNamespacePreparation { + const { connection, service } = input + let state: 'prepared' | 'committing' | 'committed' | 'closed' = 'prepared' + let commitPromise: Promise | null = null + const admissionSeal = composeTerminalAuthorityConsumerAdmissionSeals([ + { + seal: () => { + if (state !== 'committing') { + throw new Error('terminal authority policy consumer namespace preparation is stale') + } + input.assertCurrent() + input.assertClaimable() + }, + commit: () => input.installOwner(), + abort: () => {} + }, + input.seal + ]) + return Object.freeze({ + commit: async () => { + if (state === 'committed') { + return + } + if (state === 'committing') { + await commitPromise + return + } + if (state === 'closed') { + throw new Error('terminal authority policy consumer namespace preparation is stale') + } + input.assertCurrent() + input.assertClaimable() + state = 'committing' + const operation = (async () => { + try { + await connection.commitStagedNamespace(service.namespace, admissionSeal) + connection.activateNamespace(service.namespace) + state = 'committed' + } catch (error) { + state = 'closed' + // Joined, not dropped: a half-released namespace must not read as cleanly released. + await joinTerminalAuthorityRollbackFailure(error, () => + connection.rollbackNamespace(service.namespace) + ) + } + })() + commitPromise = operation + await operation + }, + rollback: async () => { + if (state === 'closed') { + return + } + if (state === 'committing') { + state = 'closed' + await commitPromise?.catch(() => undefined) + await connection.rollbackNamespace(service.namespace) + return + } + state = 'closed' + await connection.rollbackNamespace(service.namespace) + } + }) +} diff --git a/src/main/session-authority/terminal-session-authority-policy-namespace-pump.ts b/src/main/session-authority/terminal-session-authority-policy-namespace-pump.ts new file mode 100644 index 00000000000..f29524d9a1b --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-policy-namespace-pump.ts @@ -0,0 +1,324 @@ +import { + TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_VERSION, + type TerminalAuthorityNamespaceOutcomeAck, + type TerminalAuthorityNamespaceOutcomeBoundary, + type TerminalAuthorityPolicyConsumerIdentity +} from '../../shared/terminal-session-authority-consumer-transport' +import type { TerminalAuthorityConsumerAccess } from './terminal-session-authority-access' +import { + composeTerminalAuthorityConsumerAdmissionSeals, + type TerminalAuthorityConsumerAdmissionSeal +} from './terminal-session-authority-consumer-admission-seal' +import type { TerminalSessionAuthorityService } from './terminal-session-authority-service' +import { + prepareTerminalAuthorityPolicyConsumerClaim, + terminalAuthorityPolicyConsumerBoundary, + terminalAuthorityPolicyConsumerClaim +} from './terminal-session-authority-policy-consumer-claim' +import { TerminalSessionAuthorityPolicyConsumerLifecycle } from './terminal-session-authority-policy-consumer-lifecycle' +import type { TerminalAuthorityPolicyOutcomeTransport } from './terminal-session-authority-policy-outcome-transport' + +const OUTCOME_READ_PAGE_SIZE = 64 + +export class TerminalSessionAuthorityPolicyNamespacePump { + private consumer: TerminalAuthorityConsumerAccess | null = null + private boundary: TerminalAuthorityNamespaceOutcomeBoundary | null = null + private boundaryPublished = false + private requested = false + private pump: Promise | null = null + private published: Readonly<{ sequence: number; outcomeId: string }> | null = null + private readonly lifecycle: TerminalSessionAuthorityPolicyConsumerLifecycle + private stagedClaim: ReturnType | null = null + private commitOperation: Promise | null = null + private expectedIncarnationId: string | null = null + private consumerStart: 'new-at-tail' | 'resume' | null = null + private active = true + private activated = false + + constructor( + readonly service: TerminalSessionAuthorityService, + private readonly identity: TerminalAuthorityPolicyConsumerIdentity, + private readonly claimedExpectedIncarnationId: string | null, + private readonly transport: TerminalAuthorityPolicyOutcomeTransport, + private readonly assertConnectionCurrent: () => void, + private readonly onFailure: (error: unknown) => void + ) { + this.lifecycle = new TerminalSessionAuthorityPolicyConsumerLifecycle(service) + } + + get isInstalled(): boolean { + return this.active && this.consumer !== null && this.boundary !== null + } + + prepare(): void { + this.assertActive() + const plan = prepareTerminalAuthorityPolicyConsumerClaim( + this.service, + this.identity, + this.claimedExpectedIncarnationId + ) + this.expectedIncarnationId = plan.expectedIncarnationId + this.consumerStart = plan.consumerStart + } + + async stage(): Promise { + this.assertActive() + if (this.stagedClaim) { + return + } + const claim = terminalAuthorityPolicyConsumerClaim(this.identity, this.expectedIncarnationId) + const snapshot = await this.service.snapshotForConsumerClaim(this.service.writerAccess, claim) + this.assertActive() + this.boundary = terminalAuthorityPolicyConsumerBoundary( + this.identity, + this.requireConsumerStart(), + snapshot + ) + await this.transport.publishBoundary(this.boundary) + this.assertActive() + this.lifecycle.subscribe(this.identity.consumerId, () => { + this.request() + }) + this.stagedClaim = claim + } + + async commit(seal?: TerminalAuthorityConsumerAdmissionSeal): Promise { + if (this.commitOperation) { + return await this.commitOperation + } + const operation = this.commitClaim(seal) + this.commitOperation = operation + try { + await operation + } finally { + if (this.commitOperation === operation) { + this.commitOperation = null + } + } + } + + // Nothing durable is rewound: recovery is a fresh authenticated resume against the claimed incarnation. + async rollback(): Promise { + this.disconnect() + this.lifecycle.revokeObserver() + await this.commitOperation?.catch(() => undefined) + this.consumer = null + this.stagedClaim = null + this.boundary = null + } + + startDelivery(): void { + this.assertPrepared() + if (this.activated) { + return + } + this.activated = true + this.request() + } + + async acknowledge(ack: TerminalAuthorityNamespaceOutcomeAck): Promise { + this.assertActivated() + const consumer = this.requireConsumer() + const snapshot = await this.service.snapshotForConsumer(consumer) + this.assertActive() + if ( + ack.sequence > snapshot.acknowledgedSequence && + (this.published?.sequence !== ack.sequence || this.published.outcomeId !== ack.outcomeId) + ) { + throw new Error('terminal authority policy consumer ACK was not published') + } + const sequence = await this.service.acknowledgeOutcomes(consumer, ack.sequence) + this.assertActive() + if (this.published?.sequence === ack.sequence) { + this.published = null + } + this.releaseCaughtUpHold(sequence) + this.request() + return sequence + } + + async retire(): Promise { + this.assertActivated() + const retired = await this.service.retireConsumer( + this.service.writerAccess, + this.requireConsumer() + ) + this.assertActive() + this.lifecycle.releaseProducerHold() + return retired + } + + disconnect(): void { + if (!this.active) { + return + } + this.active = false + this.requested = false + this.lifecycle.releaseProducerHold() + this.lifecycle.revokeObserver() + } + + private request(): void { + if (!this.active || !this.activated) { + return + } + this.requested = true + if (!this.pump) { + this.startPump() + } + } + + private startPump(): void { + const pump = this.runPump() + this.pump = pump + void pump.finally(() => { + if (this.pump !== pump) { + return + } + this.pump = null + if (this.active && this.requested && !this.published) { + this.startPump() + } + }) + } + + private async runPump(): Promise { + try { + while (this.active && this.activated && this.requested) { + this.requested = false + if (!this.boundaryPublished) { + await this.transport.publishBoundary(this.requireBoundary()) + this.assertActive() + this.boundaryPublished = true + this.releaseCaughtUpHold(this.requireBoundary().acknowledgedSequence) + } + if (this.published) { + return + } + const consumer = this.requireConsumer() + const snapshot = await this.service.snapshotForConsumer(consumer) + this.assertActive() + if (snapshot.acknowledgedSequence >= snapshot.outcomeHighWatermark) { + this.releaseCaughtUpHold(snapshot.acknowledgedSequence) + return + } + const read = await this.service.readOutcomes( + consumer, + snapshot.acknowledgedSequence, + OUTCOME_READ_PAGE_SIZE + ) + this.assertActive() + if (read.kind !== 'entries' || read.entries.length === 0) { + await this.resnapshot() + continue + } + const outcome = read.entries[0]! + const tail = read.entries.at(-1)! + this.published = Object.freeze({ + sequence: tail.sequence, + outcomeId: tail.outcomeId + }) + await this.transport.publishOutcome( + Object.freeze({ + version: TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_VERSION, + consumer: this.identity, + namespace: Object.freeze({ ...this.service.namespace }), + previousSequence: snapshot.acknowledgedSequence, + outcome, + outcomes: read.entries + }) + ) + this.assertActive() + } + } catch (error) { + this.published = null + if (this.active) { + this.onFailure(error) + } + } + } + + private async resnapshot(): Promise { + this.lifecycle.retainProducerHold() + const snapshot = await this.service.snapshotForConsumer(this.requireConsumer()) + this.assertActive() + this.boundary = terminalAuthorityPolicyConsumerBoundary(this.identity, 'resume', snapshot) + this.boundaryPublished = false + this.published = null + this.requested = true + } + + // The transport fence runs in seal(): a post-claim check could only be honoured by a durable rewind. + private async commitClaim(seal?: TerminalAuthorityConsumerAdmissionSeal): Promise { + this.assertActive() + const claim = this.requireStagedClaim() + const admissionSeal = composeTerminalAuthorityConsumerAdmissionSeals([ + { + seal: () => this.assertActive(), + commit: (claimed) => { + this.consumer = claimed + this.boundaryPublished = true + }, + abort: () => {} + }, + seal + ]) + await this.service.commitConsumerAdmission(this.service.writerAccess, claim, admissionSeal) + this.releaseCaughtUpHold(this.requireBoundary().acknowledgedSequence) + } + + private releaseCaughtUpHold(acknowledgedSequence: number): void { + this.lifecycle.releaseCaughtUpHold( + acknowledgedSequence, + this.requireBoundary().outcomeHighWatermark + ) + } + + private requireConsumer(): TerminalAuthorityConsumerAccess { + if (!this.consumer) { + throw new Error('terminal authority policy consumer is not prepared') + } + return this.consumer + } + + private requireBoundary(): TerminalAuthorityNamespaceOutcomeBoundary { + if (!this.boundary) { + throw new Error('terminal authority policy consumer boundary is not prepared') + } + return this.boundary + } + + private requireConsumerStart(): 'new-at-tail' | 'resume' { + if (!this.consumerStart) { + throw new Error('terminal authority policy consumer start is not prepared') + } + return this.consumerStart + } + + private requireStagedClaim(): ReturnType { + if (!this.stagedClaim) { + throw new Error('terminal authority policy consumer claim is not staged') + } + return this.stagedClaim + } + + private assertPrepared(): void { + this.assertActive() + this.requireConsumer() + this.requireBoundary() + } + + private assertActivated(): void { + this.assertPrepared() + if (!this.activated) { + throw new Error('terminal authority policy consumer transport is not active') + } + } + + private assertActive(): void { + if (!this.active) { + throw new Error('terminal authority policy consumer transport is stale') + } + this.assertConnectionCurrent() + } +} diff --git a/src/main/session-authority/terminal-session-authority-policy-outcome-transport.ts b/src/main/session-authority/terminal-session-authority-policy-outcome-transport.ts new file mode 100644 index 00000000000..98003161132 --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-policy-outcome-transport.ts @@ -0,0 +1,10 @@ +import type { + TerminalAuthorityNamespaceOutcomeBoundary, + TerminalAuthorityNamespaceOutcomePublication +} from '../../shared/terminal-session-authority-consumer-transport' + +export type TerminalAuthorityPolicyOutcomeTransport = Readonly<{ + publishBoundary(boundary: TerminalAuthorityNamespaceOutcomeBoundary): Promise + publishOutcome(publication: TerminalAuthorityNamespaceOutcomePublication): Promise + onFailure?(error: Error): void +}> diff --git a/src/main/session-authority/terminal-session-authority-policy-session-state.ts b/src/main/session-authority/terminal-session-authority-policy-session-state.ts new file mode 100644 index 00000000000..0131a9db991 --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-policy-session-state.ts @@ -0,0 +1,41 @@ +import type { TerminalAuthorityNamespace } from '../../shared/terminal-session-authority-identity' +import type { TerminalSessionAuthorityPolicyNamespacePump } from './terminal-session-authority-policy-namespace-pump' +import type { TerminalAuthorityPolicyOutcomeTransport } from './terminal-session-authority-policy-outcome-transport' + +export type TerminalAuthorityPolicyNamespaceOpening = Readonly<{ + pump: TerminalSessionAuthorityPolicyNamespacePump + ready: Promise +}> + +export function terminalAuthorityPolicyNamespaceKey(namespace: TerminalAuthorityNamespace): string { + return JSON.stringify([namespace.authorityHostId, namespace.namespaceId]) +} + +export function terminalAuthorityPolicyNamespaceIsInstalled( + active: boolean, + installedNamespaces: ReadonlySet, + namespace: TerminalAuthorityNamespace, + assertNamespaceCurrent: (namespace: TerminalAuthorityNamespace) => void +): boolean { + if (!active || !installedNamespaces.has(terminalAuthorityPolicyNamespaceKey(namespace))) { + return false + } + try { + assertNamespaceCurrent(namespace) + return true + } catch { + return false + } +} + +export function notifyTerminalAuthorityPolicyConsumerFailure( + transport: TerminalAuthorityPolicyOutcomeTransport, + value: unknown +): void { + const error = value instanceof Error ? value : new Error(String(value)) + try { + transport.onFailure?.(error) + } catch { + // The exact connection is already fenced. + } +} diff --git a/src/main/session-authority/terminal-session-authority-projection-subscriptions.ts b/src/main/session-authority/terminal-session-authority-projection-subscriptions.ts new file mode 100644 index 00000000000..c51fcb2e5c3 --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-projection-subscriptions.ts @@ -0,0 +1,62 @@ +import { failTerminalSessionAuthority } from '../../shared/terminal-session-authority-mutation' +import type { TerminalSessionAuthorityState } from '../../shared/terminal-session-authority-state' +import type { + TerminalAuthorityObserverAccess, + TerminalAuthorityRuntimeAccess +} from './terminal-session-authority-access' +import type { + TerminalAuthorityProjectionChange, + TerminalAuthorityProjectionListener +} from './terminal-session-authority-service-contract' + +export class TerminalAuthorityProjectionSubscriptions { + private readonly listeners = new Map() + + constructor( + private readonly state: TerminalSessionAuthorityState, + private readonly accesses: TerminalAuthorityRuntimeAccess + ) {} + + observe(actorId: string): TerminalAuthorityObserverAccess { + return this.accesses.observe(actorId) + } + + subscribe( + actorId: string, + listener: TerminalAuthorityProjectionListener + ): TerminalAuthorityObserverAccess { + if (typeof listener !== 'function') { + failTerminalSessionAuthority('expectation-mismatch', 'projection listener is invalid') + } + const access = this.accesses.observe(actorId) + this.listeners.set(access.accessId, listener) + return access + } + + revoke(access: TerminalAuthorityObserverAccess): void { + this.accesses.revokeObserver(access) + this.listeners.delete(access.accessId) + } + + publish(reason: TerminalAuthorityProjectionChange['reason']): void { + if (this.listeners.size === 0) { + return + } + const change = Object.freeze({ reason, projection: this.state.projection() }) + for (const accessId of Array.from(this.listeners.keys())) { + const listener = this.listeners.get(accessId) + if (!listener) { + continue + } + try { + listener(change) + } catch { + // Subscriber failure cannot roll back durable authority state. + } + } + } + + clear(): void { + this.listeners.clear() + } +} diff --git a/src/main/session-authority/terminal-session-authority-pty-binding.ts b/src/main/session-authority/terminal-session-authority-pty-binding.ts new file mode 100644 index 00000000000..02776d2d919 --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-pty-binding.ts @@ -0,0 +1,30 @@ +import type { + TerminalPaneGeneration, + TerminalSessionBinding +} from '../../shared/terminal-session-authority-identity' +import type { TerminalSessionPtyAllocationIdentity } from '../../shared/terminal-session-authority-mutation' +import type { TerminalAuthorityNamespaceRuntime } from './terminal-session-authority-runtime-cache' +import type { TerminalAuthorityPolicyConsumerConnection } from './terminal-session-authority-policy-consumers' + +export type TerminalAuthorityPreparedPtySpawn = Readonly<{ + kind: 'spawn' + operationId: string + runtime: TerminalAuthorityNamespaceRuntime + policyConsumer: TerminalAuthorityPolicyConsumerConnection + pane: TerminalPaneGeneration + allocation: TerminalSessionPtyAllocationIdentity +}> + +export type TerminalAuthorityAdoptedPtySpawn = Readonly<{ + kind: 'adopt' + runtime: TerminalAuthorityNamespaceRuntime + policyConsumer: TerminalAuthorityPolicyConsumerConnection + pane: TerminalPaneGeneration + binding: TerminalSessionBinding +}> + +export type TerminalAuthorityManagedPty = Readonly<{ + runtime: TerminalAuthorityNamespaceRuntime + pane: TerminalPaneGeneration + binding: TerminalSessionBinding +}> diff --git a/src/main/session-authority/terminal-session-authority-pty-consumer-boundary.ts b/src/main/session-authority/terminal-session-authority-pty-consumer-boundary.ts new file mode 100644 index 00000000000..54afff99c7e --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-pty-consumer-boundary.ts @@ -0,0 +1,134 @@ +import type { + TerminalAuthorityNamespaceAdmissionChallenge, + TerminalAuthorityNamespaceAdmissionProof, + TerminalAuthorityNamespaceAdmissionStart +} from '../../shared/terminal-session-authority-consumer-proof' +import type { + TerminalAuthorityConsumerRetirementChallenge, + TerminalAuthorityConsumerRetirementProof, + TerminalAuthorityConsumerRetirementResult, + TerminalAuthorityConsumerRetirementStart +} from '../../shared/terminal-session-authority-consumer-retirement' +import type { TerminalAuthorityNamespace } from '../../shared/terminal-session-authority-identity' +import type { TerminalAuthorityAuthenticatedConsumerTransport } from './terminal-session-authority-consumer-admission' +import { + TerminalSessionAuthorityAuthenticatedConsumers, + type TerminalAuthorityAuthenticatedNamespacePreparation, + type TerminalAuthorityAuthenticatedNamespaceSession +} from './terminal-session-authority-authenticated-consumers' +import { + TerminalSessionAuthorityHostEffectApplierSlot, + type TerminalSessionAuthorityHostEffectApplier +} from './terminal-session-authority-host-effect-applier' +import { TerminalSessionAuthorityHostEffectConsumer } from './terminal-session-authority-host-effect-consumer' +import { + TerminalSessionAuthorityPolicyConsumers, + type TerminalAuthorityPolicyOutcomeTransport +} from './terminal-session-authority-policy-consumers' +import type { TerminalSessionAuthorityRegistry } from './terminal-session-authority-registry' +import { terminalAuthorityLocatorForWorktreeId } from './terminal-session-authority-spawn-state' + +export class TerminalSessionAuthorityPtyConsumerBoundary { + private readonly hostEffectApplier = new TerminalSessionAuthorityHostEffectApplierSlot() + protected readonly hostEffectConsumer: TerminalSessionAuthorityHostEffectConsumer + private readonly policyConsumers = new TerminalSessionAuthorityPolicyConsumers() + private readonly authenticatedConsumers: TerminalSessionAuthorityAuthenticatedConsumers + + constructor( + private readonly authorityRegistry: TerminalSessionAuthorityRegistry, + ownerIncarnationId: string + ) { + this.hostEffectConsumer = new TerminalSessionAuthorityHostEffectConsumer( + authorityRegistry, + ownerIncarnationId, + this.hostEffectApplier + ) + this.authenticatedConsumers = new TerminalSessionAuthorityAuthenticatedConsumers( + authorityRegistry, + this.policyConsumers + ) + } + + start(): Promise { + return this.hostEffectConsumer.start() + } + + installHostEffectApplier(applier: TerminalSessionAuthorityHostEffectApplier): () => void { + const remove = this.hostEffectApplier.install(applier) + this.hostEffectConsumer.requestAll() + return remove + } + + issuePolicyConsumerChallenge( + start: TerminalAuthorityNamespaceAdmissionStart, + transport: TerminalAuthorityAuthenticatedConsumerTransport + ): Promise { + return this.authenticatedConsumers.issueChallenge(start, transport) + } + + issuePolicyConsumerRetirementChallenge( + start: TerminalAuthorityConsumerRetirementStart, + transport: TerminalAuthorityAuthenticatedConsumerTransport + ): Promise { + return this.authenticatedConsumers.issueRetirementChallenge(start, transport) + } + + retireAuthenticatedPolicyConsumer( + proof: TerminalAuthorityConsumerRetirementProof, + transport: TerminalAuthorityAuthenticatedConsumerTransport + ): Promise { + return this.authenticatedConsumers.retireConsumer(proof, transport) + } + + async resolvePolicyConsumerNamespace(worktreeId: string): Promise { + const locator = terminalAuthorityLocatorForWorktreeId(worktreeId) + const existing = this.authorityRegistry.namespaceForLocator(locator) + return existing ?? (await this.authorityRegistry.resolveNamespace(locator)).namespace + } + + openAuthenticatedPolicyConsumerNamespace( + proof: TerminalAuthorityNamespaceAdmissionProof, + authenticatedTransport: TerminalAuthorityAuthenticatedConsumerTransport, + outcomeTransport: TerminalAuthorityPolicyOutcomeTransport + ): Promise { + return this.authenticatedConsumers.openNamespace( + proof, + authenticatedTransport, + outcomeTransport + ) + } + + prepareAuthenticatedPolicyConsumerNamespace( + proof: TerminalAuthorityNamespaceAdmissionProof, + authenticatedTransport: TerminalAuthorityAuthenticatedConsumerTransport, + outcomeTransport: TerminalAuthorityPolicyOutcomeTransport + ): Promise { + return this.authenticatedConsumers.prepareNamespace( + proof, + authenticatedTransport, + outcomeTransport + ) + } + + releaseAuthenticatedPolicyConsumerTransport(transportToken: object): void { + this.authenticatedConsumers.releaseTransport(transportToken) + } + + policyConsumerTransportInstalled(): boolean { + return this.policyConsumers.hasInstalledTransport() + } + + hostEffectConsumerInstalled(): boolean { + return this.hostEffectApplier.isInstalled() + } + + requestHostEffectDelivery(): void { + this.hostEffectConsumer.requestAll() + } + + dispose(): void { + this.authenticatedConsumers.dispose() + this.policyConsumers.dispose() + this.hostEffectConsumer.dispose() + } +} diff --git a/src/main/session-authority/terminal-session-authority-pty-lifecycle.ts b/src/main/session-authority/terminal-session-authority-pty-lifecycle.ts new file mode 100644 index 00000000000..705acf17003 --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-pty-lifecycle.ts @@ -0,0 +1,276 @@ +import { + failTerminalSessionAuthority, + type TerminalSessionAuthoritySemanticFact +} from '../../shared/terminal-session-authority-mutation' +import type { TerminalSessionAuthorityPtyAccess } from '../../shared/terminal-session-authority-pty-access' +import type { TerminalSessionAuthorityRegistry } from './terminal-session-authority-registry' +import { + findTerminalAuthorityPane, + sameTerminalAuthorityBinding, + terminalAuthorityLocatorForWorktreeId +} from './terminal-session-authority-spawn-state' +import { terminalAuthorityNamespaceLocatorKey } from '../../shared/terminal-session-authority-locator' +import type { + TerminalPaneGeneration, + TerminalSessionBinding +} from '../../shared/terminal-session-authority-identity' +import { + classifyTerminalAuthorityMissingPty, + parseTerminalAuthorityReattachIdentity, + type TerminalAuthorityMissingPtyState +} from './terminal-session-authority-reattach-state' +import type { + TerminalAuthorityAdoptedPtySpawn, + TerminalAuthorityManagedPty, + TerminalAuthorityPreparedPtySpawn +} from './terminal-session-authority-pty-binding' +import { + terminalAuthorityLifecycleIdentityOperationId, + terminalAuthorityLifecycleOperationId +} from './terminal-session-authority-lifecycle-operation' +import { TerminalSessionAuthorityPtyMutationCoordinator } from './terminal-session-authority-pty-mutation-coordinator' +import { TerminalSessionAuthorityPtySpawnLifecycle } from './terminal-session-authority-pty-spawn-lifecycle' +import type { + TerminalAuthorityPolicyConsumerConnection, + TerminalAuthorityPolicyConsumerSource +} from './terminal-session-authority-policy-consumers' +import { TerminalSessionAuthorityPtyConsumerBoundary } from './terminal-session-authority-pty-consumer-boundary' + +export type { + TerminalAuthorityAdoptedPtySpawn, + TerminalAuthorityManagedPty, + TerminalAuthorityPreparedPtySpawn +} from './terminal-session-authority-pty-binding' + +export class TerminalSessionAuthorityPtyLifecycle extends TerminalSessionAuthorityPtyConsumerBoundary { + private readonly mutations: TerminalSessionAuthorityPtyMutationCoordinator + private readonly spawns: TerminalSessionAuthorityPtySpawnLifecycle + + constructor( + private readonly registry: TerminalSessionAuthorityRegistry, + private readonly ownerIncarnationId: string + ) { + super(registry, ownerIncarnationId) + this.mutations = new TerminalSessionAuthorityPtyMutationCoordinator( + registry, + this.hostEffectConsumer + ) + this.spawns = new TerminalSessionAuthorityPtySpawnLifecycle(this.mutations, ownerIncarnationId) + } + + prepareSpawn( + params: Record, + physicalPtyId: string, + policyConsumer: TerminalAuthorityPolicyConsumerSource, + operationId?: string + ): Promise { + return this.spawns.prepare(params, physicalPtyId, policyConsumer, operationId) + } + + commitSpawn( + prepared: TerminalAuthorityPreparedPtySpawn, + ptyIncarnationId: string + ): Promise { + return this.spawns.commit(prepared, ptyIncarnationId) + } + + cancelSpawn(prepared: TerminalAuthorityPreparedPtySpawn): Promise { + return this.spawns.cancel(prepared) + } + + async closePty( + managed: TerminalAuthorityManagedPty, + policyConsumer: TerminalAuthorityPolicyConsumerConnection, + operationId?: string + ): Promise { + const rootOperationId = + operationId ?? + terminalAuthorityLifecycleIdentityOperationId('close', [ + managed.pane.paneKey, + managed.pane.paneGenerationId, + managed.binding.ownerIncarnationId, + managed.binding.physicalPtyId, + managed.binding.ptyIncarnationId + ]) + await this.mutations.enqueue(managed.runtime, async () => { + const pane = findTerminalAuthorityPane( + await this.mutations.projection(managed.runtime), + managed.pane + ) + if (!pane || pane.status === 'closed' || pane.status === 'exited') { + return + } + if (!sameTerminalAuthorityBinding(pane.binding, managed.binding)) { + failTerminalSessionAuthority('expectation-mismatch', 'pane binding changed before close') + } + await this.mutations.mutateForPolicy( + managed.runtime, + policyConsumer, + { + kind: 'close', + pane: managed.pane, + expected: { paneGenerationId: managed.pane.paneGenerationId, binding: managed.binding } + }, + terminalAuthorityLifecycleOperationId(rootOperationId, 'close') + ) + }) + } + + async closeExactPtyAccess( + access: TerminalSessionAuthorityPtyAccess, + policyConsumer: TerminalAuthorityPolicyConsumerConnection, + operationId?: string + ): Promise { + const locator = this.registry.locatorForNamespace(access.namespace) + const existing = locator + ? this.mutations.find(terminalAuthorityNamespaceLocatorKey(locator), locator) + : null + if (!existing) { + failTerminalSessionAuthority('expectation-mismatch', 'exact PTY namespace is unavailable') + } + await this.closePty( + Object.freeze({ runtime: await existing, pane: access.pane, binding: access.binding }), + policyConsumer, + operationId + ) + } + + async recordExit(managed: TerminalAuthorityManagedPty, code: number | null): Promise { + await this.mutations.enqueue(managed.runtime, async () => { + const pane = findTerminalAuthorityPane( + await this.mutations.projection(managed.runtime), + managed.pane + ) + if (!pane || pane.status === 'superseded' || pane.status === 'exited') { + return + } + const retiredByClose = pane.status === 'closed' + const binding = retiredByClose ? pane.lastBinding : pane.binding + if (!sameTerminalAuthorityBinding(binding, managed.binding)) { + failTerminalSessionAuthority('expectation-mismatch', 'pane binding changed before exit') + } + await this.mutations.mutateHost( + managed.runtime, + { + kind: 'exit', + pane: managed.pane, + expected: { paneGenerationId: managed.pane.paneGenerationId, binding: managed.binding }, + exit: { code, signal: null, ...(retiredByClose ? { retiredByClose: true as const } : {}) } + }, + exitOperationId(managed.binding, code) + ) + }) + } + + /** The append settles at durability; the independent delivery pump publishes later. */ + async recordSemanticFact( + managed: TerminalAuthorityManagedPty, + access: TerminalSessionAuthorityPtyAccess, + producerSequence: number, + fact: TerminalSessionAuthoritySemanticFact + ): Promise { + await this.mutations.enqueue(managed.runtime, async () => { + await this.mutations.recordSemanticOutcome( + managed.runtime, + access, + this.ownerIncarnationId, + producerSequence, + fact + ) + }) + } + + async recordImportedExit( + input: Readonly<{ + worktreeId: string + pane: TerminalPaneGeneration + binding: TerminalSessionBinding + }>, + code: number + ): Promise { + if (input.binding.ownerIncarnationId === this.ownerIncarnationId) { + failTerminalSessionAuthority('writer-fenced', 'current-owner exit used imported routing') + } + const locator = terminalAuthorityLocatorForWorktreeId(input.worktreeId) + const existing = this.mutations.find(terminalAuthorityNamespaceLocatorKey(locator), locator) + if (!existing) { + failTerminalSessionAuthority('record-corrupt', 'imported PTY namespace is unavailable') + } + const runtime = await existing + await this.mutations.enqueue(runtime, async () => { + const pane = findTerminalAuthorityPane(await this.mutations.projection(runtime), input.pane) + if (!pane || pane.status === 'superseded' || pane.status === 'exited') { + return + } + const retiredByClose = pane.status === 'closed' + const binding = retiredByClose ? pane.lastBinding : pane.binding + if (!sameTerminalAuthorityBinding(binding, input.binding)) { + failTerminalSessionAuthority('expectation-mismatch', 'imported PTY binding changed') + } + await this.mutations.mutateHost( + runtime, + { + kind: 'exit', + pane: input.pane, + expected: { paneGenerationId: input.pane.paneGenerationId, binding: input.binding }, + exit: { code, signal: null, ...(retiredByClose ? { retiredByClose: true as const } : {}) } + }, + exitOperationId(input.binding, code) + ) + }) + } + + async missingPtyState( + params: Record, + physicalPtyId: string + ): Promise { + let identity + try { + identity = parseTerminalAuthorityReattachIdentity(params, physicalPtyId) + } catch { + return Object.freeze({ kind: 'unknown' }) + } + if (!identity) { + return Object.freeze({ kind: 'unknown' }) + } + const existing = this.mutations.find(identity.locatorKey, identity.locator) + if (!existing) { + return Object.freeze({ kind: 'unknown' }) + } + const runtime = await existing + return this.mutations.enqueue(runtime, async () => + classifyTerminalAuthorityMissingPty( + await this.mutations.projection(runtime), + identity, + this.ownerIncarnationId + ) + ) + } + + bindingIsReachable(managed: TerminalAuthorityManagedPty): boolean { + return ( + managed.runtime.service.bindingAuthority( + managed.runtime.service.writerAccess, + managed.pane, + managed.binding + ) === 'reachable' + ) + } + + managedFromAdoption(adopted: TerminalAuthorityAdoptedPtySpawn): TerminalAuthorityManagedPty { + return Object.freeze({ + runtime: adopted.runtime, + pane: adopted.pane, + binding: adopted.binding + }) + } +} + +function exitOperationId(binding: TerminalSessionBinding, code: number | null): string { + return terminalAuthorityLifecycleIdentityOperationId('exit', [ + binding.ownerIncarnationId, + binding.physicalPtyId, + binding.ptyIncarnationId, + code === null ? 'null' : String(code) + ]) +} diff --git a/src/main/session-authority/terminal-session-authority-pty-mutation-coordinator.ts b/src/main/session-authority/terminal-session-authority-pty-mutation-coordinator.ts new file mode 100644 index 00000000000..3d65cbf17ed --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-pty-mutation-coordinator.ts @@ -0,0 +1,96 @@ +import type { + TerminalAuthorityProjection, + TerminalSessionAuthorityChange, + TerminalSessionAuthoritySemanticFact +} from '../../shared/terminal-session-authority-mutation' +import type { TerminalSessionAuthorityPtyAccess } from '../../shared/terminal-session-authority-pty-access' +import type { TerminalAuthorityNamespaceLocator } from '../../shared/terminal-session-authority-locator' +import type { TerminalSessionAuthorityRegistry } from './terminal-session-authority-registry' +import { + TerminalSessionAuthorityRuntimeCache, + type TerminalAuthorityNamespaceRuntime +} from './terminal-session-authority-runtime-cache' +import { terminalAuthorityOperationIdentity } from '../../shared/terminal-session-authority-operation-identity' +import type { TerminalSessionAuthorityHostEffectConsumer } from './terminal-session-authority-host-effect-consumer' +import type { TerminalAuthorityPolicyConsumerConnection } from './terminal-session-authority-policy-consumers' + +export class TerminalSessionAuthorityPtyMutationCoordinator { + private readonly runtimeCache: TerminalSessionAuthorityRuntimeCache + + constructor( + registry: TerminalSessionAuthorityRegistry, + hostEffects: TerminalSessionAuthorityHostEffectConsumer + ) { + this.runtimeCache = new TerminalSessionAuthorityRuntimeCache(registry, hostEffects) + } + + resolve( + locatorKey: string, + locator: TerminalAuthorityNamespaceLocator + ): Promise { + return this.runtimeCache.resolve(locatorKey, locator) + } + + find( + locatorKey: string, + locator: TerminalAuthorityNamespaceLocator + ): Promise | null { + return this.runtimeCache.find(locatorKey, locator) + } + + enqueue(runtime: TerminalAuthorityNamespaceRuntime, operation: () => Promise): Promise { + return this.runtimeCache.enqueue(runtime, operation) + } + + projection(runtime: TerminalAuthorityNamespaceRuntime): Promise { + return this.runtimeCache.projection(runtime) + } + + admitPolicyConsumer( + runtime: TerminalAuthorityNamespaceRuntime, + connection: TerminalAuthorityPolicyConsumerConnection + ): Promise { + return this.runtimeCache.admitPolicyConsumer(runtime, connection) + } + + recordSemanticOutcome( + runtime: TerminalAuthorityNamespaceRuntime, + access: TerminalSessionAuthorityPtyAccess, + producerIncarnationId: string, + producerSequence: number, + fact: TerminalSessionAuthoritySemanticFact + ) { + return runtime.service.recordSemanticOutcome(runtime.service.writerAccess, { + access, + producerIncarnationId, + producerSequence, + fact + }) + } + + async mutateForPolicy( + runtime: TerminalAuthorityNamespaceRuntime, + connection: TerminalAuthorityPolicyConsumerConnection, + change: TerminalSessionAuthorityChange, + correlationId: string + ) { + this.runtimeCache.assertPolicyConsumer(runtime, connection) + return await this.mutateHost(runtime, change, correlationId) + } + + async mutateHost( + runtime: TerminalAuthorityNamespaceRuntime, + change: TerminalSessionAuthorityChange, + correlationId: string + ) { + const projection = await this.projection(runtime) + const operationIdentity = terminalAuthorityOperationIdentity(projection.revision, correlationId) + const receipt = await runtime.service.mutate(runtime.service.writerAccess, { + actorId: runtime.service.writerAccess.actorId, + ...operationIdentity, + baseRevision: projection.revision, + change + }) + return receipt.result + } +} diff --git a/src/main/session-authority/terminal-session-authority-pty-owner-semantic-delivery.test.ts b/src/main/session-authority/terminal-session-authority-pty-owner-semantic-delivery.test.ts new file mode 100644 index 00000000000..92f526de49c --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-pty-owner-semantic-delivery.test.ts @@ -0,0 +1,183 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants' +import type { TerminalAuthorityDurableOutcome } from '../../shared/terminal-session-authority-mutation' +import { TerminalSessionAuthorityHostRuntime } from './terminal-session-authority-host-runtime' +import type { TerminalAuthorityPolicyConsumerConnection } from './terminal-session-authority-policy-consumers' +import type { TerminalSessionAuthorityService } from './terminal-session-authority-service' +import type { TerminalAuthorityAuthenticatedNamespaceSession } from './terminal-session-authority-authenticated-consumers' +import { admitAuthenticatedPolicyConsumer } from './__tests__/authenticated-policy-consumer-admission' + +const directories: string[] = [] +const admissions = new WeakMap< + TerminalSessionAuthorityHostRuntime, + TerminalAuthorityAuthenticatedNamespaceSession +>() + +afterEach(() => { + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +describe('TerminalSessionAuthorityPtyOwner semantic delivery', () => { + it('serializes durable facts without waiting for consumer settlement', async () => { + const directory = mkdtempSync(path.join(tmpdir(), 'orca-authority-semantic-owner-')) + directories.push(directory) + let settleSemantic: () => void = () => undefined + const semanticSettlement = new Promise((resolve) => { + settleSemantic = resolve + }) + const published: TerminalAuthorityDurableOutcome[] = [] + const runtime = await openRuntime(directory, async (outcome) => { + published.push(outcome) + if (outcome.kind === 'semantic') { + await semanticSettlement + } + }) + const preparation = await runtime.ptyOwner.prepareSpawn( + { + terminalSessionAuthorityVersion: 1, + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + paneKey: 'pane-a', + paneGeneration: 1 + }, + 'pty-a', + policyFor(runtime), + 'spawn-a' + ) + expect(preparation.kind).toBe('spawn') + if (preparation.kind !== 'spawn') { + throw new Error('expected a fresh authority allocation') + } + const access = await runtime.ptyOwner.commitSpawn(preparation.prepared, 'pty-incarnation-a') + await vi.waitFor(async () => { + const snapshot = await preparation.prepared.runtime.service.snapshotForConsumer( + await testConsumer(runtime, preparation.prepared.runtime.service) + ) + expect(snapshot.acknowledgedSequence).toBe(snapshot.outcomeHighWatermark) + }, 5_000) + + await expect( + Promise.all([ + runtime.ptyOwner.recordSemanticOutcome('pty-a', access, { kind: 'bell' }), + runtime.ptyOwner.recordSemanticOutcome('pty-a', access, { + kind: 'command-finished', + exitCode: 17 + }) + ]) + ).resolves.toEqual([true, true]) + + const pending = await preparation.prepared.runtime.service.snapshotForConsumer( + await testConsumer(runtime, preparation.prepared.runtime.service) + ) + expect(pending.outcomeHighWatermark - pending.acknowledgedSequence).toBe(2) + const read = await preparation.prepared.runtime.service.readOutcomes( + await testConsumer(runtime, preparation.prepared.runtime.service), + pending.acknowledgedSequence + ) + expect(read).toMatchObject({ + kind: 'entries', + entries: [ + { kind: 'semantic', producerSequence: 1, fact: { kind: 'bell' } }, + { + kind: 'semantic', + producerSequence: 2, + fact: { kind: 'command-finished', exitCode: 17 } + } + ] + }) + + settleSemantic() + await vi.waitFor(async () => { + const snapshot = await preparation.prepared.runtime.service.snapshotForConsumer( + await testConsumer(runtime, preparation.prepared.runtime.service) + ) + expect(snapshot.acknowledgedSequence).toBe(snapshot.outcomeHighWatermark) + }, 5_000) + expect( + published.filter((outcome) => outcome.kind === 'semantic').map((outcome) => outcome.fact) + ).toEqual([{ kind: 'bell' }, { kind: 'command-finished', exitCode: 17 }]) + await runtime.close() + }) +}) + +async function openRuntime( + directory: string, + publish: (outcome: TerminalAuthorityDurableOutcome) => Promise +): Promise { + const ids = ['host-a', 'owner-nonce-a', 'owner-a'] + let index = 0 + const runtime = await TerminalSessionAuthorityHostRuntime.open({ + directory, + processIdentity: { + pid: 99_999, + platform: 'legacy', + startedAtMs: 1_700_000_000_000 + }, + createId: () => ids[index++] ?? `id-${index}` + }) + runtime.ptyOwner.installHostEffectApplier({ ensureBindingRetired: async () => {} }) + let session!: TerminalAuthorityAuthenticatedNamespaceSession + const admitted = await admitAuthenticatedPolicyConsumer(runtime.ptyOwner, { + namespace: await runtime.ptyOwner.resolvePolicyConsumerNamespace(FLOATING_TERMINAL_WORKTREE_ID), + processIncarnationId: 'semantic-test', + requestId: 'semantic-request', + outcomeTransport: { + publishBoundary: async () => {}, + publishOutcome: async (publication) => { + const outcomes = publication.outcomes ?? [publication.outcome] + for (const outcome of outcomes) { + await publish(outcome) + } + const tail = outcomes.at(-1)! + await session.policyConsumer.acknowledge({ + version: 1, + consumer: publication.consumer, + namespace: publication.namespace, + sequence: tail.sequence, + outcomeId: tail.outcomeId + }) + } + } + }) + session = admitted.session + admissions.set(runtime, session) + return runtime +} + +function policyFor( + runtime: TerminalSessionAuthorityHostRuntime +): TerminalAuthorityPolicyConsumerConnection { + const session = admissions.get(runtime) + if (!session) { + throw new Error('test policy consumer is unavailable') + } + return session.policyConsumer +} + +function admittedConsumer( + runtime: TerminalSessionAuthorityHostRuntime +): Readonly<{ consumerId: string; consumerIncarnationId: string }> { + const session = admissions.get(runtime) + if (!session) { + throw new Error('test policy consumer is unavailable') + } + return session.grant.consumer +} + +// Why the claim repeats the active incarnation: it is the read-only handle the assertion needs, and an +// exact repeat of the live claim appends nothing. +async function testConsumer( + runtime: TerminalSessionAuthorityHostRuntime, + service: TerminalSessionAuthorityService +): ReturnType { + const consumer = admittedConsumer(runtime) + return service.claimConsumer(service.writerAccess, { + consumerId: consumer.consumerId, + expectedIncarnationId: consumer.consumerIncarnationId, + consumerIncarnationId: consumer.consumerIncarnationId + }) +} diff --git a/src/main/session-authority/terminal-session-authority-pty-owner.ts b/src/main/session-authority/terminal-session-authority-pty-owner.ts new file mode 100644 index 00000000000..621bc9ab868 --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-pty-owner.ts @@ -0,0 +1,308 @@ +import type { TerminalSessionAuthorityRegistry } from './terminal-session-authority-registry' +import { + TerminalSessionAuthorityPtyLifecycle, + type TerminalAuthorityAdoptedPtySpawn, + type TerminalAuthorityManagedPty, + type TerminalAuthorityPreparedPtySpawn +} from './terminal-session-authority-pty-lifecycle' +import { + sameTerminalSessionAuthorityPtyAccess, + type TerminalSessionAuthorityPtyAccess +} from '../../shared/terminal-session-authority-pty-access' +import type { TerminalSessionAuthoritySemanticFact } from '../../shared/terminal-session-authority-mutation' +import type { TerminalSessionAuthorityHostEffectApplier } from './terminal-session-authority-host-effect-applier' +import type { + TerminalAuthorityPolicyConsumerConnection, + TerminalAuthorityPolicyConsumerSource, + TerminalAuthorityPolicyOutcomeTransport +} from './terminal-session-authority-policy-consumers' +import type { + TerminalAuthorityNamespaceAdmissionChallenge, + TerminalAuthorityNamespaceAdmissionProof, + TerminalAuthorityNamespaceAdmissionStart +} from '../../shared/terminal-session-authority-consumer-proof' +import type { + TerminalAuthorityConsumerRetirementChallenge, + TerminalAuthorityConsumerRetirementProof, + TerminalAuthorityConsumerRetirementResult, + TerminalAuthorityConsumerRetirementStart +} from '../../shared/terminal-session-authority-consumer-retirement' +import type { TerminalAuthorityAuthenticatedConsumerTransport } from './terminal-session-authority-consumer-admission' +import type { + TerminalAuthorityAuthenticatedNamespacePreparation, + TerminalAuthorityAuthenticatedNamespaceSession +} from './terminal-session-authority-authenticated-consumers' +import type { TerminalAuthorityNamespace } from '../../shared/terminal-session-authority-identity' + +export type TerminalSessionAuthorityPtyPreparation = + | Readonly<{ kind: 'spawn'; prepared: TerminalAuthorityPreparedPtySpawn }> + | Readonly<{ kind: 'adopt'; adopted: TerminalAuthorityAdoptedPtySpawn }> + +export class TerminalSessionAuthorityPtyOwner { + private readonly lifecycle: TerminalSessionAuthorityPtyLifecycle + private readonly managedByPhysicalPtyId = new Map() + private readonly producerSequences = new Map() + private readonly producerQueues = new Map>() + + constructor(options: { registry: TerminalSessionAuthorityRegistry; ownerIncarnationId: string }) { + this.lifecycle = new TerminalSessionAuthorityPtyLifecycle( + options.registry, + options.ownerIncarnationId + ) + } + + start(): Promise { + return this.lifecycle.start() + } + + installHostEffectApplier(applier: TerminalSessionAuthorityHostEffectApplier): () => void { + return this.lifecycle.installHostEffectApplier(applier) + } + + issuePolicyConsumerChallenge( + start: TerminalAuthorityNamespaceAdmissionStart, + transport: TerminalAuthorityAuthenticatedConsumerTransport + ): Promise { + return this.lifecycle.issuePolicyConsumerChallenge(start, transport) + } + + issuePolicyConsumerRetirementChallenge( + start: TerminalAuthorityConsumerRetirementStart, + transport: TerminalAuthorityAuthenticatedConsumerTransport + ): Promise { + return this.lifecycle.issuePolicyConsumerRetirementChallenge(start, transport) + } + + retireAuthenticatedPolicyConsumer( + proof: TerminalAuthorityConsumerRetirementProof, + transport: TerminalAuthorityAuthenticatedConsumerTransport + ): Promise { + return this.lifecycle.retireAuthenticatedPolicyConsumer(proof, transport) + } + + resolvePolicyConsumerNamespace(worktreeId: string): Promise { + return this.lifecycle.resolvePolicyConsumerNamespace(worktreeId) + } + + openAuthenticatedPolicyConsumerNamespace( + proof: TerminalAuthorityNamespaceAdmissionProof, + authenticatedTransport: TerminalAuthorityAuthenticatedConsumerTransport, + outcomeTransport: TerminalAuthorityPolicyOutcomeTransport + ): Promise { + return this.lifecycle.openAuthenticatedPolicyConsumerNamespace( + proof, + authenticatedTransport, + outcomeTransport + ) + } + + prepareAuthenticatedPolicyConsumerNamespace( + proof: TerminalAuthorityNamespaceAdmissionProof, + authenticatedTransport: TerminalAuthorityAuthenticatedConsumerTransport, + outcomeTransport: TerminalAuthorityPolicyOutcomeTransport + ): Promise { + return this.lifecycle.prepareAuthenticatedPolicyConsumerNamespace( + proof, + authenticatedTransport, + outcomeTransport + ) + } + + releaseAuthenticatedPolicyConsumerTransport(transportToken: object): void { + this.lifecycle.releaseAuthenticatedPolicyConsumerTransport(transportToken) + } + + policyConsumerTransportInstalled(): boolean { + return this.lifecycle.policyConsumerTransportInstalled() + } + + hostEffectConsumerInstalled(): boolean { + return this.lifecycle.hostEffectConsumerInstalled() + } + + requestHostEffectDelivery(): void { + this.lifecycle.requestHostEffectDelivery() + } + + dispose(): void { + this.lifecycle.dispose() + } + + async prepareSpawn( + params: Record, + physicalPtyId: string, + policyConsumer: TerminalAuthorityPolicyConsumerSource, + operationId?: string + ): Promise { + const prepared = await this.lifecycle.prepareSpawn( + params, + physicalPtyId, + policyConsumer, + operationId + ) + return prepared.kind === 'spawn' + ? Object.freeze({ kind: 'spawn', prepared }) + : Object.freeze({ kind: 'adopt', adopted: prepared }) + } + + async commitSpawn( + prepared: TerminalAuthorityPreparedPtySpawn, + ptyIncarnationId: string + ): Promise { + const managed = await this.lifecycle.commitSpawn(prepared, ptyIncarnationId) + this.remember(managed) + return accessForManagedPty(managed) + } + + adopt( + adopted: TerminalAuthorityAdoptedPtySpawn, + physicalPtyId: string, + ptyIncarnationId: string + ): TerminalSessionAuthorityPtyAccess | null { + if ( + adopted.binding.physicalPtyId !== physicalPtyId || + adopted.binding.ptyIncarnationId !== ptyIncarnationId || + !this.lifecycle.bindingIsReachable(this.lifecycle.managedFromAdoption(adopted)) + ) { + return null + } + const managed = this.lifecycle.managedFromAdoption(adopted) + this.remember(managed) + return accessForManagedPty(managed) + } + + accessFor(physicalPtyId: string): TerminalSessionAuthorityPtyAccess | null { + const managed = this.managedByPhysicalPtyId.get(physicalPtyId) + if (!managed || !this.lifecycle.bindingIsReachable(managed)) { + return null + } + return accessForManagedPty(managed) + } + + admits(physicalPtyId: string, access: TerminalSessionAuthorityPtyAccess): boolean { + return sameTerminalSessionAuthorityPtyAccess(this.accessFor(physicalPtyId), access) + } + + /** + * Returns false when the PTY no longer holds this exact binding; the caller + * must not retarget the fact at a successor. + */ + async recordSemanticOutcome( + physicalPtyId: string, + access: TerminalSessionAuthorityPtyAccess, + fact: TerminalSessionAuthoritySemanticFact + ): Promise { + const managed = this.managedByPhysicalPtyId.get(physicalPtyId) + if (!managed || !sameTerminalSessionAuthorityPtyAccess(accessForManagedPty(managed), access)) { + return false + } + const producerKey = semanticProducerKey(access) + return this.enqueueProducer(producerKey, async () => { + if ( + this.managedByPhysicalPtyId.get(physicalPtyId) !== managed || + !sameTerminalSessionAuthorityPtyAccess(accessForManagedPty(managed), access) + ) { + return false + } + const producerSequence = (this.producerSequences.get(producerKey) ?? 0) + 1 + await this.lifecycle.recordSemanticFact(managed, access, producerSequence, fact) + this.producerSequences.set(producerKey, producerSequence) + return true + }) + } + + async recordExit( + physicalPtyId: string, + ptyIncarnationId: string, + code: number | null + ): Promise { + const managed = this.managedByPhysicalPtyId.get(physicalPtyId) + if (!managed || managed.binding.ptyIncarnationId !== ptyIncarnationId) { + return + } + const producerKey = semanticProducerKey(accessForManagedPty(managed)) + await this.enqueueProducer(producerKey, async () => { + if (this.managedByPhysicalPtyId.get(physicalPtyId) !== managed) { + return + } + await this.lifecycle.recordExit(managed, code) + this.producerSequences.delete(producerKey) + if (this.managedByPhysicalPtyId.get(physicalPtyId) === managed) { + this.managedByPhysicalPtyId.delete(physicalPtyId) + } + }) + } + + async close( + physicalPtyId: string, + access: TerminalSessionAuthorityPtyAccess, + policyConsumer: TerminalAuthorityPolicyConsumerConnection, + operationId?: string + ): Promise { + const managed = this.managedByPhysicalPtyId.get(physicalPtyId) + if (!managed || !sameTerminalSessionAuthorityPtyAccess(accessForManagedPty(managed), access)) { + return false + } + const producerKey = semanticProducerKey(access) + return this.enqueueProducer(producerKey, async () => { + if ( + this.managedByPhysicalPtyId.get(physicalPtyId) !== managed || + !sameTerminalSessionAuthorityPtyAccess(accessForManagedPty(managed), access) + ) { + return false + } + await this.lifecycle.closePty(managed, policyConsumer, operationId) + this.producerSequences.delete(producerKey) + if (this.managedByPhysicalPtyId.get(physicalPtyId) === managed) { + this.managedByPhysicalPtyId.delete(physicalPtyId) + } + return true + }) + } + + async cancelSpawn(prepared: TerminalAuthorityPreparedPtySpawn): Promise { + await this.lifecycle.cancelSpawn(prepared) + } + + private remember(managed: TerminalAuthorityManagedPty): void { + this.managedByPhysicalPtyId.set(managed.binding.physicalPtyId, managed) + } + + private enqueueProducer(producerKey: string, operation: () => Promise): Promise { + const previous = this.producerQueues.get(producerKey) ?? Promise.resolve() + const result = previous.then(operation) + const settled = result.then( + () => undefined, + () => undefined + ) + this.producerQueues.set(producerKey, settled) + void settled.then(() => { + if (this.producerQueues.get(producerKey) === settled) { + this.producerQueues.delete(producerKey) + } + }) + return result + } +} + +function semanticProducerKey(access: TerminalSessionAuthorityPtyAccess): string { + return JSON.stringify([ + access.namespace.authorityHostId, + access.namespace.namespaceId, + access.pane.paneKey, + access.pane.paneGenerationId, + access.binding.ownerIncarnationId, + access.binding.physicalPtyId, + access.binding.ptyIncarnationId + ]) +} + +function accessForManagedPty( + managed: TerminalAuthorityManagedPty +): TerminalSessionAuthorityPtyAccess { + return Object.freeze({ + namespace: Object.freeze({ ...managed.runtime.service.namespace }), + pane: Object.freeze({ ...managed.pane }), + binding: Object.freeze({ ...managed.binding }) + }) +} diff --git a/src/main/session-authority/terminal-session-authority-pty-spawn-lifecycle.ts b/src/main/session-authority/terminal-session-authority-pty-spawn-lifecycle.ts new file mode 100644 index 00000000000..981d56f06cd --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-pty-spawn-lifecycle.ts @@ -0,0 +1,257 @@ +import { + failTerminalSessionAuthority, + type TerminalAuthorityProjection, + type TerminalSessionPtyAllocation, + type TerminalSessionPtyAllocationIdentity +} from '../../shared/terminal-session-authority-mutation' +import type { TerminalPaneGeneration } from '../../shared/terminal-session-authority-identity' +import { + exactTerminalAuthorityAllocation, + findTerminalAuthorityPane, + latestTerminalAuthorityPane, + parseTerminalAuthoritySpawnIdentity +} from './terminal-session-authority-spawn-state' +import { + terminalAuthorityLifecycleIdentityOperationId, + terminalAuthorityLifecycleOperationId +} from './terminal-session-authority-lifecycle-operation' +import type { + TerminalAuthorityAdoptedPtySpawn, + TerminalAuthorityManagedPty, + TerminalAuthorityPreparedPtySpawn +} from './terminal-session-authority-pty-binding' +import type { TerminalSessionAuthorityPtyMutationCoordinator } from './terminal-session-authority-pty-mutation-coordinator' +import type { TerminalAuthorityNamespaceRuntime } from './terminal-session-authority-runtime-cache' +import { + terminalAuthorityPolicyConsumerForNamespace, + type TerminalAuthorityPolicyConsumerConnection, + type TerminalAuthorityPolicyConsumerSource +} from './terminal-session-authority-policy-consumers' + +export class TerminalSessionAuthorityPtySpawnLifecycle { + constructor( + private readonly mutations: TerminalSessionAuthorityPtyMutationCoordinator, + private readonly ownerIncarnationId: string + ) {} + + async prepare( + params: Record, + physicalPtyId: string, + policyConsumerSource: TerminalAuthorityPolicyConsumerSource, + operationId?: string + ): Promise { + const identity = parseTerminalAuthoritySpawnIdentity(params, this.ownerIncarnationId) + const rootOperationId = + operationId ?? + terminalAuthorityLifecycleIdentityOperationId('spawn', [ + physicalPtyId, + identity.pane.paneKey, + identity.pane.paneGenerationId, + identity.spawnFingerprint + ]) + const allocation = Object.freeze({ + allocationId: terminalAuthorityLifecycleOperationId(rootOperationId, 'allocation'), + pane: identity.pane, + ownerIncarnationId: this.ownerIncarnationId, + physicalPtyId, + spawnFingerprint: identity.spawnFingerprint + }) + const runtime = await this.mutations.resolve(identity.locatorKey, identity.locator) + const policyConsumer = terminalAuthorityPolicyConsumerForNamespace( + policyConsumerSource, + runtime.service.namespace + ) + await this.mutations.admitPolicyConsumer(runtime, policyConsumer) + return this.mutations.enqueue(runtime, async () => { + const projection = await this.mutations.projection(runtime) + const exact = findTerminalAuthorityPane(projection, identity.pane) + if (exact?.binding) { + const committed = exactTerminalAuthorityAllocation(projection, exact.binding) + if (committed.spawnFingerprint !== identity.spawnFingerprint) { + failTerminalSessionAuthority('operation-conflict', 'pane spawn parameters changed') + } + if (exact.ownerStatus !== 'reachable') { + failTerminalSessionAuthority('writer-fenced', 'pane owner is unreachable') + } + return Object.freeze({ + kind: 'adopt' as const, + runtime, + policyConsumer, + pane: identity.pane, + binding: exact.binding + }) + } + if (exact && exact.status !== 'open') { + failTerminalSessionAuthority('expectation-mismatch', 'pane generation is inactive') + } + const exactPending = projection.allocations.find( + (candidate) => + candidate.pane.paneKey === identity.pane.paneKey && + candidate.pane.paneGenerationId === identity.pane.paneGenerationId + ) + if (exactPending) { + if (samePreparedAllocation(exactPending, allocation)) { + return Object.freeze({ + kind: 'spawn' as const, + operationId: rootOperationId, + runtime, + policyConsumer, + pane: identity.pane, + allocation + }) + } + this.rejectPendingConflict(exactPending.ownerIncarnationId, 'pane') + } + if (!exact) { + await this.preparePaneGeneration( + runtime, + policyConsumer, + projection, + identity.pane, + rootOperationId + ) + } + await this.mutations.mutateForPolicy( + runtime, + policyConsumer, + { + kind: 'prepare-allocation', + allocation, + expected: { paneGenerationId: identity.pane.paneGenerationId, binding: null } + }, + terminalAuthorityLifecycleOperationId(rootOperationId, 'prepare') + ) + return Object.freeze({ + kind: 'spawn' as const, + operationId: rootOperationId, + runtime, + policyConsumer, + pane: identity.pane, + allocation + }) + }) + } + + async commit( + prepared: TerminalAuthorityPreparedPtySpawn, + ptyIncarnationId: string + ): Promise { + return this.mutations.enqueue(prepared.runtime, async () => { + const result = await this.mutations.mutateForPolicy( + prepared.runtime, + prepared.policyConsumer, + { + kind: 'commit-allocation', + allocation: prepared.allocation, + ptyIncarnationId, + expected: { paneGenerationId: prepared.pane.paneGenerationId, binding: null } + }, + terminalAuthorityLifecycleOperationId(prepared.operationId, 'commit') + ) + if (!result.pane.binding) { + failTerminalSessionAuthority('record-corrupt', 'committed pane has no exact binding') + } + return Object.freeze({ + runtime: prepared.runtime, + pane: prepared.pane, + binding: result.pane.binding + }) + }) + } + + async cancel(prepared: TerminalAuthorityPreparedPtySpawn): Promise { + await this.mutations.enqueue(prepared.runtime, async () => { + const projection = await this.mutations.projection(prepared.runtime) + const allocation = projection.allocations.find( + (candidate) => candidate.allocationId === prepared.allocation.allocationId + ) + if (!allocation) { + return + } + if (allocation.status !== 'pending') { + failTerminalSessionAuthority('allocation-conflict', 'spawn allocation already committed') + } + await this.mutations.mutateHost( + prepared.runtime, + { + kind: 'cancel-allocation', + allocation: prepared.allocation, + expected: { paneGenerationId: prepared.pane.paneGenerationId, binding: null } + }, + terminalAuthorityLifecycleOperationId(prepared.operationId, 'cancel') + ) + }) + } + + private async preparePaneGeneration( + runtime: TerminalAuthorityNamespaceRuntime, + policyConsumer: TerminalAuthorityPolicyConsumerConnection, + projection: TerminalAuthorityProjection, + pane: TerminalPaneGeneration, + operationId: string + ): Promise { + const latest = latestTerminalAuthorityPane(projection, pane.paneKey) + if (!latest) { + await this.mutations.mutateForPolicy( + runtime, + policyConsumer, + { kind: 'create', pane }, + terminalAuthorityLifecycleOperationId(operationId, 'create') + ) + return + } + const pending = projection.allocations.find( + (allocation) => + allocation.status === 'pending' && + allocation.pane.paneKey === latest.paneKey && + allocation.pane.paneGenerationId === latest.paneGenerationId + ) + if (pending) { + this.rejectPendingConflict(pending.ownerIncarnationId, 'predecessor') + } + if (latest.binding && latest.ownerStatus === 'owner-unreachable') { + failTerminalSessionAuthority('writer-fenced', 'predecessor pane owner is unreachable') + } + if (latest.status === 'closed' || latest.binding) { + failTerminalSessionAuthority( + 'expectation-mismatch', + 'a different pane generation remains authoritative' + ) + } + await this.mutations.mutateForPolicy( + runtime, + policyConsumer, + { + kind: 'supersede', + pane: { paneKey: latest.paneKey, paneGenerationId: latest.paneGenerationId }, + replacementPaneGenerationId: pane.paneGenerationId, + expected: { paneGenerationId: latest.paneGenerationId, binding: latest.binding } + }, + terminalAuthorityLifecycleOperationId(operationId, 'supersede') + ) + } + + private rejectPendingConflict(ownerIncarnationId: string, subject: string): never { + failTerminalSessionAuthority( + ownerIncarnationId === this.ownerIncarnationId ? 'operation-conflict' : 'writer-fenced', + ownerIncarnationId === this.ownerIncarnationId + ? `${subject} spawn is already in progress` + : `${subject} spawn ownership is unresolved` + ) + } +} + +function samePreparedAllocation( + left: TerminalSessionPtyAllocation, + right: TerminalSessionPtyAllocationIdentity +): boolean { + return ( + left.status === 'pending' && + left.allocationId === right.allocationId && + left.pane.paneKey === right.pane.paneKey && + left.pane.paneGenerationId === right.pane.paneGenerationId && + left.ownerIncarnationId === right.ownerIncarnationId && + left.physicalPtyId === right.physicalPtyId && + left.spawnFingerprint === right.spawnFingerprint + ) +} diff --git a/src/main/session-authority/terminal-session-authority-reattach-state.ts b/src/main/session-authority/terminal-session-authority-reattach-state.ts new file mode 100644 index 00000000000..97a6d119961 --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-reattach-state.ts @@ -0,0 +1,120 @@ +import { + assertAuthorityId, + type TerminalPaneGeneration, + type TerminalSessionBinding +} from '../../shared/terminal-session-authority-identity' +import type { + TerminalAuthorityProjection, + TerminalPaneAuthorityProjection +} from '../../shared/terminal-session-authority-mutation' +import { terminalAuthorityNamespaceLocatorKey } from '../../shared/terminal-session-authority-locator' +import { parseTerminalSessionAuthorityAttachIdentity } from '../../shared/terminal-session-authority-wire' +import { terminalAuthorityLocatorForWorktreeId } from './terminal-session-authority-spawn-state' + +export type TerminalAuthorityReattachIdentity = Readonly<{ + locator: ReturnType + locatorKey: string + paneKey: string + pane: TerminalPaneGeneration + physicalPtyId: string + ptyIncarnationId: string +}> + +export type TerminalAuthorityMissingPtyState = + | Readonly<{ kind: 'unknown' | 'retired' }> + | Readonly<{ + kind: 'reachable-record' + ownerKind: 'current-owner' | 'imported-owner' + pane: TerminalPaneGeneration + binding: TerminalSessionBinding + }> + | Readonly<{ + kind: 'unreachable-predecessor' + pane: TerminalPaneGeneration + binding: TerminalSessionBinding + }> + +export function parseTerminalAuthorityReattachIdentity( + params: Record, + physicalPtyId: string +): TerminalAuthorityReattachIdentity | null { + const attach = parseTerminalSessionAuthorityAttachIdentity(params) + if (!attach) { + return null + } + assertAuthorityId(physicalPtyId, 'physicalPtyId') + assertAuthorityId(attach.paneKey, 'paneKey') + const locator = terminalAuthorityLocatorForWorktreeId(attach.worktreeId) + return Object.freeze({ + locator, + locatorKey: terminalAuthorityNamespaceLocatorKey(locator), + paneKey: attach.paneKey, + pane: Object.freeze({ + paneKey: attach.paneKey, + paneGenerationId: `renderer:${attach.paneGeneration}` + }), + physicalPtyId, + ptyIncarnationId: attach.ptyIncarnationId + }) +} + +export function classifyTerminalAuthorityMissingPty( + projection: TerminalAuthorityProjection, + identity: TerminalAuthorityReattachIdentity, + currentOwnerIncarnationId?: string +): TerminalAuthorityMissingPtyState { + const exact = projection.panes.find( + (pane) => + pane.paneKey === identity.paneKey && + samePane(pane, identity.pane) && + matchesExpectedBinding(pane.binding, identity) + ) + if (exact?.binding) { + return exact.ownerStatus === 'owner-unreachable' + ? Object.freeze({ + kind: 'unreachable-predecessor', + pane: Object.freeze({ + paneKey: exact.paneKey, + paneGenerationId: exact.paneGenerationId + }), + binding: exact.binding + }) + : Object.freeze({ + kind: 'reachable-record', + ownerKind: + exact.binding.ownerIncarnationId === currentOwnerIncarnationId + ? ('current-owner' as const) + : ('imported-owner' as const), + pane: Object.freeze({ + paneKey: exact.paneKey, + paneGenerationId: exact.paneGenerationId + }), + binding: exact.binding + }) + } + const retired = projection.panes.some( + (pane) => + pane.paneKey === identity.paneKey && + matchesExpectedBinding(pane.lastBinding, identity) && + !matchesExpectedBinding(pane.binding, identity) + ) + return Object.freeze({ kind: retired ? 'retired' : 'unknown' }) +} + +function samePane( + pane: TerminalPaneAuthorityProjection, + expected: TerminalPaneGeneration +): boolean { + return pane.paneKey === expected.paneKey && pane.paneGenerationId === expected.paneGenerationId +} + +function matchesExpectedBinding( + binding: TerminalSessionBinding | null, + identity: TerminalAuthorityReattachIdentity +): boolean { + return Boolean( + binding && + binding.physicalPtyId === identity.physicalPtyId && + binding.ptyIncarnationId === identity.ptyIncarnationId + ) +} diff --git a/src/main/session-authority/terminal-session-authority-record-files.ts b/src/main/session-authority/terminal-session-authority-record-files.ts new file mode 100644 index 00000000000..584c64590af --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-record-files.ts @@ -0,0 +1,173 @@ +import { Buffer } from 'node:buffer' +import { readFile, stat } from 'node:fs/promises' +import path from 'node:path' +import { + assertAuthorityNamespace, + isRecord, + type TerminalAuthorityNamespace +} from '../../shared/terminal-session-authority-identity' +import { + failTerminalSessionAuthority, + type TerminalSessionAuthorityLogRecord, + type TerminalSessionAuthoritySnapshot +} from '../../shared/terminal-session-authority-mutation' +import { assertSafeInteger } from '../../shared/terminal-session-authority-record-validation' +import { assertTerminalSessionAuthoritySnapshotEnvelope } from '../../shared/terminal-session-authority-snapshot' + +export const TERMINAL_AUTHORITY_CHECKPOINT_FILE = 'authority.checkpoint.json' +export const TERMINAL_AUTHORITY_LOG_FILE = 'authority.log' + +export type TerminalAuthorityCheckpoint = Readonly<{ + version: 1 + namespace: TerminalAuthorityNamespace + throughRecordId: number + snapshot: TerminalSessionAuthoritySnapshot +}> + +export type TerminalAuthorityLoadedLog = Readonly<{ + records: TerminalSessionAuthorityLogRecord[] + completeBytes: number + truncateToBytes: number | null +}> + +export async function readTerminalAuthorityRecordFiles( + directory: string, + namespace: TerminalAuthorityNamespace, + maxCheckpointBytes: number, + maxLogBytes: number +): Promise< + Readonly<{ + checkpoint: TerminalAuthorityCheckpoint | null + log: TerminalAuthorityLoadedLog + }> +> { + const checkpoint = await readCheckpoint(directory, namespace, maxCheckpointBytes) + const log = await readLog( + directory, + checkpoint?.throughRecordId ?? 0, + checkpoint?.snapshot.writerEpoch ?? 0, + maxLogBytes + ) + return Object.freeze({ checkpoint, log }) +} + +export async function terminalAuthorityRecordFilesContainLegacyMigrations( + directory: string, + namespace: TerminalAuthorityNamespace, + maxCheckpointBytes: number, + maxLogBytes: number +): Promise { + const loaded = await readTerminalAuthorityRecordFiles( + directory, + namespace, + maxCheckpointBytes, + maxLogBytes + ) + return ( + (loaded.checkpoint?.snapshot.legacyMigrations.length ?? 0) > 0 || + loaded.log.records.some((record) => record.event.kind === 'legacy-migration') + ) +} + +async function readCheckpoint( + directory: string, + namespace: TerminalAuthorityNamespace, + maxBytes: number +): Promise { + const checkpointPath = path.join(directory, TERMINAL_AUTHORITY_CHECKPOINT_FILE) + const contents = await readBoundedFile(checkpointPath, maxBytes) + if (contents === null) { + return null + } + const value = JSON.parse(contents) as unknown + if (!isRecord(value) || value.version !== 1) { + failTerminalSessionAuthority('record-corrupt', 'authority checkpoint version is invalid') + } + assertAuthorityNamespace(value.namespace) + assertSafeInteger(value.throughRecordId, 'checkpoint record ID') + assertTerminalSessionAuthoritySnapshotEnvelope(value.snapshot) + const snapshot = value.snapshot + assertAuthorityNamespace(snapshot.namespace) + assertSafeInteger(snapshot.writerEpoch, 'snapshot writer epoch', 1) + if ( + value.namespace.authorityHostId !== namespace.authorityHostId || + value.namespace.namespaceId !== namespace.namespaceId || + snapshot.namespace.authorityHostId !== namespace.authorityHostId || + snapshot.namespace.namespaceId !== namespace.namespaceId + ) { + failTerminalSessionAuthority('record-corrupt', 'authority checkpoint namespace changed') + } + return Object.freeze({ + version: 1, + namespace: value.namespace, + throughRecordId: value.throughRecordId, + snapshot + }) +} + +async function readLog( + directory: string, + throughRecordId: number, + checkpointWriterEpoch: number, + maxBytes: number +): Promise { + const contents = await readBoundedFile( + path.join(directory, TERMINAL_AUTHORITY_LOG_FILE), + maxBytes + ) + if (contents === null || contents.length === 0) { + return Object.freeze({ records: [], completeBytes: 0, truncateToBytes: null }) + } + const complete = contents.endsWith('\n') + ? contents + : contents.slice(0, contents.lastIndexOf('\n') + 1) + const completeBytes = Buffer.byteLength(complete, 'utf8') + const records: TerminalSessionAuthorityLogRecord[] = [] + let expectedRecordId = throughRecordId + 1 + let previousEpoch = checkpointWriterEpoch + for (const line of complete.split('\n')) { + if (!line) { + continue + } + const value = JSON.parse(line) as unknown + if (!isRecord(value) || value.version !== 1) { + failTerminalSessionAuthority('record-corrupt', 'authority log record is invalid') + } + assertSafeInteger(value.recordId, 'log record ID', 1) + assertSafeInteger(value.writerEpoch, 'log writer epoch', 1) + if (value.recordId <= throughRecordId) { + continue + } + if ( + value.recordId !== expectedRecordId || + value.writerEpoch < checkpointWriterEpoch || + value.writerEpoch < previousEpoch + ) { + failTerminalSessionAuthority('record-corrupt', 'authority log is not contiguous') + } + records.push(value as TerminalSessionAuthorityLogRecord) + expectedRecordId += 1 + previousEpoch = value.writerEpoch + } + return Object.freeze({ + records, + completeBytes, + truncateToBytes: complete.length === contents.length ? null : completeBytes + }) +} + +async function readBoundedFile(file: string, maxBytes: number): Promise { + let metadata + try { + metadata = await stat(file) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return null + } + throw error + } + if (!metadata.isFile() || metadata.size > maxBytes) { + failTerminalSessionAuthority('record-corrupt', 'authority persistence is not a bounded file') + } + return readFile(file, 'utf8') +} diff --git a/src/main/session-authority/terminal-session-authority-registry-options.ts b/src/main/session-authority/terminal-session-authority-registry-options.ts new file mode 100644 index 00000000000..7149cff2a2c --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-registry-options.ts @@ -0,0 +1,18 @@ +import type { TerminalAuthorityNamespace } from '../../shared/terminal-session-authority-identity' +import type { TerminalSessionAuthorityServiceOptions } from './terminal-session-authority-service-contract' +import type { TerminalAuthorityWriterClaimVerifier } from './terminal-authority-writer-recovery' + +export type TerminalAuthorityRegistryOptions = Omit< + TerminalSessionAuthorityServiceOptions, + 'directory' | 'namespace' | 'takeoverOwnerToken' | 'legacyWorkerAccess' +> & + Readonly<{ + directory: string + authorityHostId: string + takeoverOwnerToken?: string + maxNamespaces?: number + createNamespaceId?: () => string + onNamespaceServiceOpen?: (namespace: TerminalAuthorityNamespace) => void + writerClaimIsGone?: TerminalAuthorityWriterClaimVerifier + legacyMigration?: Readonly<{ now?: () => number }> + }> diff --git a/src/main/session-authority/terminal-session-authority-registry-service-open.ts b/src/main/session-authority/terminal-session-authority-registry-service-open.ts new file mode 100644 index 00000000000..1d34d3f1430 --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-registry-service-open.ts @@ -0,0 +1,34 @@ +import type { TerminalAuthorityNamespace } from '../../shared/terminal-session-authority-identity' +import type { TerminalAuthorityLegacyWorkerAccess } from './terminal-session-authority-service-contract' +import { terminalSessionAuthorityNamespaceDirectory } from './terminal-session-authority-namespace-directory' +import type { TerminalAuthorityRegistryOptions } from './terminal-session-authority-registry-options' +import { TerminalSessionAuthorityService } from './terminal-session-authority-service' +import { openTerminalAuthorityWriterWithRecovery } from './terminal-authority-writer-recovery' +import type { TerminalAuthorityWriterLock } from './terminal-session-authority-writer-lock' + +export async function openTerminalAuthorityRegistryService(options: { + registry: TerminalAuthorityRegistryOptions + rootLock: TerminalAuthorityWriterLock + namespace: TerminalAuthorityNamespace + legacyWorkerAccess: TerminalAuthorityLegacyWorkerAccess +}): Promise { + const directory = terminalSessionAuthorityNamespaceDirectory( + options.registry.directory, + options.namespace + ) + return await openTerminalAuthorityWriterWithRecovery({ + directory, + claimIsGone: async (ownerToken) => + ownerToken === options.rootLock.replacedOwnerToken || + (await options.registry.writerClaimIsGone?.(ownerToken)) === true, + open: (takeoverOwnerToken) => + TerminalSessionAuthorityService.open({ + ...options.registry, + directory, + namespace: options.namespace, + ...(takeoverOwnerToken ? { takeoverOwnerToken } : {}), + allowUninitializedTakeover: takeoverOwnerToken !== undefined, + legacyWorkerAccess: options.legacyWorkerAccess + }) + }) +} diff --git a/src/main/session-authority/terminal-session-authority-registry.test.ts b/src/main/session-authority/terminal-session-authority-registry.test.ts new file mode 100644 index 00000000000..af8c16b6404 --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-registry.test.ts @@ -0,0 +1,179 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import type { TerminalAuthorityNamespaceLocator } from '../../shared/terminal-session-authority-locator' +import { acquireTerminalAuthorityWriterGuard } from './terminal-authority-writer-storage' +import { openTerminalAuthorityWriterWithRecovery } from './terminal-authority-writer-recovery' +import { terminalSessionAuthorityNamespaceDirectory } from './terminal-session-authority-namespace-directory' +import { TerminalSessionAuthorityRegistry } from './terminal-session-authority-registry' + +const directories: string[] = [] +const registries: TerminalSessionAuthorityRegistry[] = [] + +afterEach(async () => { + await Promise.allSettled(registries.splice(0).map((registry) => registry.close())) + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +describe('TerminalSessionAuthorityRegistry', () => { + it('durably resolves host-canonical namespaces without eagerly opening services', async () => { + const directory = freshDirectory() + let namespaceId = 0 + let serviceOpens = 0 + const first = await openRegistry(directory, { + createNamespaceId: () => `namespace-${++namespaceId}`, + onNamespaceServiceOpen: () => serviceOpens++ + }) + const workspace = locator('/srv/repo') + const resolved = await first.resolveNamespace(workspace) + const floating = await first.resolveNamespace({ kind: 'floating' }) + expect(resolved).toMatchObject({ created: true, namespace: { namespaceId: 'namespace-1' } }) + expect(floating).toMatchObject({ created: true, namespace: { namespaceId: 'namespace-2' } }) + expect(serviceOpens).toBe(0) + await first.openNamespace(resolved.namespace) + expect(serviceOpens).toBe(1) + await first.close() + + const reopened = await openRegistry(directory, { + ownerToken: 'registry-owner-b', + onNamespaceServiceOpen: () => serviceOpens++ + }) + expect(reopened.registeredNamespaces()).toHaveLength(2) + expect(serviceOpens).toBe(1) + const stable = await reopened.resolveNamespace(workspace) + expect(stable).toEqual({ created: false, namespace: resolved.namespace }) + expect(serviceOpens).toBe(1) + const service = await reopened.openNamespace(stable.namespace) + expect(service.writerAccess.writerEpoch).toBe(2) + expect(serviceOpens).toBe(2) + }) + + it('rejects unknown, cross-host, and corrupt duplicate namespace identities', async () => { + const directory = freshDirectory() + const registry = await openRegistry(directory, { createNamespaceId: () => 'namespace-a' }) + const resolved = await registry.resolveNamespace(locator('/srv/repo')) + await expect( + registry.openNamespace({ + authorityHostId: 'other-host', + namespaceId: resolved.namespace.namespaceId + }) + ).rejects.toMatchObject({ code: 'expectation-mismatch' }) + await expect( + registry.openNamespace({ authorityHostId: 'host-a', namespaceId: 'unknown' }) + ).rejects.toMatchObject({ code: 'expectation-mismatch' }) + await registry.close() + + const indexPath = path.join(directory, 'terminal-authority-namespaces.json') + const index = JSON.parse(readFileSync(indexPath, 'utf8')) + index.entries.push({ ...index.entries[0] }) + writeFileSync(indexPath, `${JSON.stringify(index)}\n`) + await expect(openRegistry(directory, { ownerToken: 'registry-owner-b' })).rejects.toMatchObject( + { code: 'record-corrupt' } + ) + }) + + it('fences a concurrent registry writer before it can resolve a namespace', async () => { + const directory = freshDirectory() + await openRegistry(directory) + await expect(openRegistry(directory, { ownerToken: 'registry-owner-b' })).rejects.toMatchObject( + { code: 'writer-fenced' } + ) + }) + + it('recovers a lazy namespace still owned by an older root predecessor', async () => { + const directory = freshDirectory() + const first = await openRegistry(directory) + const resolved = await first.resolveNamespace(locator('/srv/repo')) + await first.openNamespace(resolved.namespace) + await openRegistry(directory, { + ownerToken: 'registry-owner-b', + ownerIncarnationId: 'owner-incarnation-b', + takeoverOwnerToken: 'registry-owner-a' + }) + const deadOwners = new Set(['registry-owner-a', 'registry-owner-b']) + const finalRegistry = await openRegistry(directory, { + ownerToken: 'registry-owner-c', + ownerIncarnationId: 'owner-incarnation-c', + takeoverOwnerToken: 'registry-owner-b', + writerClaimIsGone: async (ownerToken) => deadOwners.has(ownerToken) + }) + + const service = await finalRegistry.openNamespace(resolved.namespace) + expect(service.writerAccess.writerEpoch).toBe(2) + }) + + it.each([ + { gone: ['registry-owner-a'], succeeds: false }, + { gone: ['registry-owner-b'], succeeds: false }, + { gone: ['registry-owner-a', 'registry-owner-b'], succeeds: true } + ])( + 'requires proof for every lazy namespace marker/guard claimant: $gone', + async ({ gone, succeeds }) => { + const directory = freshDirectory() + const first = await openRegistry(directory) + const resolved = await first.resolveNamespace(locator('/srv/repo')) + await first.openNamespace(resolved.namespace) + await openRegistry(directory, { + ownerToken: 'registry-owner-b', + ownerIncarnationId: 'owner-incarnation-b', + takeoverOwnerToken: 'registry-owner-a' + }) + const namespaceDirectory = terminalSessionAuthorityNamespaceDirectory( + directory, + resolved.namespace + ) + await acquireTerminalAuthorityWriterGuard(namespaceDirectory, 'registry-owner-b') + const claimIsGone = async (ownerToken: string): Promise => gone.includes(ownerToken) + const finalRegistryOpening = openTerminalAuthorityWriterWithRecovery({ + directory, + claimIsGone, + open: (takeoverOwnerToken) => + openRegistry(directory, { + ownerToken: 'registry-owner-c', + ownerIncarnationId: 'owner-incarnation-c', + writerClaimIsGone: claimIsGone, + ...(takeoverOwnerToken ? { takeoverOwnerToken } : {}) + }) + }) + if (!gone.includes('registry-owner-b')) { + await expect(finalRegistryOpening).rejects.toMatchObject({ code: 'writer-fenced' }) + return + } + const finalRegistry = await finalRegistryOpening + const opening = finalRegistry.openNamespace(resolved.namespace) + const expectation = succeeds ? expect(opening).resolves : expect(opening).rejects + await expectation.toMatchObject( + succeeds ? { writerAccess: { writerEpoch: 2 } } : { code: 'writer-fenced' } + ) + } + ) +}) + +async function openRegistry( + directory: string, + overrides: Partial[0]> = {} +): Promise { + const registry = await TerminalSessionAuthorityRegistry.open({ + directory, + authorityHostId: 'host-a', + ownerToken: 'registry-owner-a', + ownerIncarnationId: 'owner-incarnation-a', + writerActorId: 'writer-a', + ...overrides + }) + registries.push(registry) + return registry +} + +function locator(canonicalPath: string): TerminalAuthorityNamespaceLocator { + return { kind: 'workspace', canonicalPath, pathFlavor: 'posix' } +} + +function freshDirectory(): string { + const directory = mkdtempSync(path.join(tmpdir(), 'orca-authority-registry-')) + directories.push(directory) + return directory +} diff --git a/src/main/session-authority/terminal-session-authority-registry.ts b/src/main/session-authority/terminal-session-authority-registry.ts new file mode 100644 index 00000000000..470ba148614 --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-registry.ts @@ -0,0 +1,311 @@ +import { randomUUID } from 'node:crypto' +import path from 'node:path' +import { + assertAuthorityId, + assertAuthorityNamespace, + assertAuthorityStoragePath, + type TerminalAuthorityNamespace +} from '../../shared/terminal-session-authority-identity' +import { + assertTerminalAuthorityNamespaceLocator, + terminalAuthorityNamespaceLocatorKey, + type TerminalAuthorityNamespaceLocator +} from '../../shared/terminal-session-authority-locator' +import { failTerminalSessionAuthority } from '../../shared/terminal-session-authority-mutation' +import { + readTerminalAuthorityNamespaceIndex, + resolveTerminalAuthorityNamespaceLimit, + TERMINAL_AUTHORITY_NAMESPACE_INDEX_FILE, + writeTerminalAuthorityNamespaceIndex, + type TerminalAuthorityNamespaceEntry +} from './terminal-session-authority-namespace-index' +import type { TerminalSessionAuthorityService } from './terminal-session-authority-service' +import { + TERMINAL_AUTHORITY_DEFAULT_MAX_CHECKPOINT_BYTES, + TERMINAL_AUTHORITY_DEFAULT_MAX_LOG_BYTES +} from './terminal-session-authority-file-store' +import { terminalSessionAuthorityNamespaceDirectory } from './terminal-session-authority-namespace-directory' +import { terminalAuthorityRecordFilesContainLegacyMigrations } from './terminal-session-authority-record-files' +import { TerminalAuthorityWriterLock } from './terminal-session-authority-writer-lock' +import { removeAuthorityCrashTemporaryFiles } from './terminal-session-authority-temporary-files' +import { TerminalSessionAuthorityLegacyRegistry } from './terminal-session-authority-legacy-registry' +import type { TerminalAuthorityRegistryOptions } from './terminal-session-authority-registry-options' +import { openTerminalAuthorityRegistryService } from './terminal-session-authority-registry-service-open' + +export type { TerminalAuthorityRegistryOptions } from './terminal-session-authority-registry-options' + +export class TerminalSessionAuthorityRegistry { + private readonly entriesByLocator = new Map() + private readonly entriesByNamespace = new Map() + private readonly services = new Map>() + private readonly openedServices = new Map() + private mutationQueue: Promise = Promise.resolve() + private accepting = true + private closed = false + private closePromise: Promise | null = null + readonly legacy: TerminalSessionAuthorityLegacyRegistry + + private constructor( + private readonly options: TerminalAuthorityRegistryOptions, + private readonly rootLock: TerminalAuthorityWriterLock, + private readonly maxNamespaces: number, + entries: readonly TerminalAuthorityNamespaceEntry[] + ) { + for (const entry of entries) { + this.entriesByLocator.set(terminalAuthorityNamespaceLocatorKey(entry.locator), entry) + this.entriesByNamespace.set(entry.namespaceId, entry) + } + this.legacy = new TerminalSessionAuthorityLegacyRegistry({ + serviceForNamespace: (namespace) => this.openNamespaceService(namespace), + services: () => this.openedServices.values(), + namespaceMatchesLocator: (namespace, locatorKey) => + this.namespaceMatchesLocator(namespace, locatorKey), + assertNamespace: (namespace) => this.assertRegisteredNamespace(namespace), + assertAccepting: () => this.assertAccepting(), + now: options.legacyMigration?.now + }) + } + + static async open( + options: TerminalAuthorityRegistryOptions + ): Promise { + assertAuthorityStoragePath(options.directory, 'authority registry directory') + assertAuthorityId(options.authorityHostId, 'authorityHostId') + const directory = path.resolve(options.directory) + const normalizedOptions = { + ...options, + directory, + ...(options.legacyMigration ? { legacyMigration: { ...options.legacyMigration } } : {}) + } + const maxNamespaces = resolveTerminalAuthorityNamespaceLimit(normalizedOptions.maxNamespaces) + const rootLock = await TerminalAuthorityWriterLock.acquire({ + directory, + ownerToken: normalizedOptions.ownerToken, + takeoverOwnerToken: normalizedOptions.takeoverOwnerToken, + allowUninitializedTakeover: normalizedOptions.takeoverOwnerToken !== undefined + }) + let registry: TerminalSessionAuthorityRegistry | null = null + try { + await rootLock.runExclusive(() => + removeAuthorityCrashTemporaryFiles(directory, [TERMINAL_AUTHORITY_NAMESPACE_INDEX_FILE]) + ) + const entries = await readTerminalAuthorityNamespaceIndex( + directory, + normalizedOptions.authorityHostId, + maxNamespaces + ) + registry = new TerminalSessionAuthorityRegistry( + normalizedOptions, + rootLock, + maxNamespaces, + entries + ) + await registry.openPersistedLegacyNamespaces(entries) + return registry + } catch (error) { + if (registry) { + const opened = await Promise.allSettled(registry.services.values()) + await Promise.allSettled( + opened.flatMap((result) => (result.status === 'fulfilled' ? [result.value.close()] : [])) + ) + } + await rootLock.release().catch(() => undefined) + throw error + } + } + + async resolveNamespace( + locator: TerminalAuthorityNamespaceLocator + ): Promise> { + const candidate = structuredClone(locator) + assertTerminalAuthorityNamespaceLocator(candidate) + return this.enqueueMutation(async () => { + const locatorKey = terminalAuthorityNamespaceLocatorKey(candidate) + const existing = this.entriesByLocator.get(locatorKey) + if (existing) { + return Object.freeze({ namespace: this.namespace(existing.namespaceId), created: false }) + } + if (this.entriesByLocator.size >= this.maxNamespaces) { + failTerminalSessionAuthority('capacity', 'terminal authority namespaces are full') + } + const entry = Object.freeze({ locator: candidate, namespaceId: this.mintNamespaceId() }) + const entries = [...this.entriesByLocator.values(), entry] + await this.rootLock.runExclusive(() => + writeTerminalAuthorityNamespaceIndex(this.options.directory, { + version: 1, + authorityHostId: this.options.authorityHostId, + entries + }) + ) + this.entriesByLocator.set(locatorKey, entry) + this.entriesByNamespace.set(entry.namespaceId, entry) + return Object.freeze({ namespace: this.namespace(entry.namespaceId), created: true }) + }) + } + + async openNamespace( + namespace: TerminalAuthorityNamespace + ): Promise { + const candidate = structuredClone(namespace) + assertAuthorityNamespace(candidate) + return this.enqueueMutation(() => this.openNamespaceService(candidate)) + } + + registeredNamespaces(): readonly Readonly<{ + locator: TerminalAuthorityNamespaceLocator + namespace: TerminalAuthorityNamespace + }>[] { + this.assertAccepting() + return Object.freeze( + [...this.entriesByLocator.values()].map((entry) => + Object.freeze({ + locator: structuredClone(entry.locator), + namespace: this.namespace(entry.namespaceId) + }) + ) + ) + } + + namespaceForLocator( + locator: TerminalAuthorityNamespaceLocator + ): TerminalAuthorityNamespace | null { + this.assertAccepting() + assertTerminalAuthorityNamespaceLocator(locator) + const entry = this.entriesByLocator.get(terminalAuthorityNamespaceLocatorKey(locator)) + return entry ? this.namespace(entry.namespaceId) : null + } + + locatorForNamespace( + namespace: TerminalAuthorityNamespace + ): TerminalAuthorityNamespaceLocator | null { + this.assertAccepting() + assertAuthorityNamespace(namespace) + if (namespace.authorityHostId !== this.options.authorityHostId) { + return null + } + const entry = this.entriesByNamespace.get(namespace.namespaceId) + return entry ? structuredClone(entry.locator) : null + } + + async close(): Promise { + if (this.closePromise) { + return this.closePromise + } + this.accepting = false + this.closePromise = (async () => { + await Promise.all([this.mutationQueue, this.legacy.idle()]) + this.closed = true + const services = await Promise.allSettled(this.services.values()) + await Promise.all( + services.flatMap((result) => (result.status === 'fulfilled' ? [result.value.close()] : [])) + ) + this.services.clear() + this.openedServices.clear() + await this.rootLock.release() + })() + return this.closePromise + } + + private async openService( + namespace: TerminalAuthorityNamespace + ): Promise { + this.options.onNamespaceServiceOpen?.(namespace) + const service = await openTerminalAuthorityRegistryService({ + registry: this.options, + rootLock: this.rootLock, + namespace, + legacyWorkerAccess: this.legacy.workerAccess + }) + this.openedServices.set(namespace.namespaceId, service) + await this.legacy.reconcileLiveOwners(service) + return service + } + + private async openPersistedLegacyNamespaces( + entries: readonly TerminalAuthorityNamespaceEntry[] + ): Promise { + for (const entry of entries) { + const namespace = this.namespace(entry.namespaceId) + const directory = terminalSessionAuthorityNamespaceDirectory( + this.options.directory, + namespace + ) + const containsLegacy = await terminalAuthorityRecordFilesContainLegacyMigrations( + directory, + namespace, + this.options.maxCheckpointBytes ?? TERMINAL_AUTHORITY_DEFAULT_MAX_CHECKPOINT_BYTES, + this.options.maxLogBytes ?? TERMINAL_AUTHORITY_DEFAULT_MAX_LOG_BYTES + ) + if (containsLegacy) { + await this.openNamespaceService(namespace) + } + } + } + + private async openNamespaceService( + namespace: TerminalAuthorityNamespace + ): Promise { + this.assertRegisteredNamespace(namespace) + const existing = this.services.get(namespace.namespaceId) + if (existing) { + return existing + } + const opened = this.openService(namespace) + this.services.set(namespace.namespaceId, opened) + opened.catch(() => this.services.delete(namespace.namespaceId)) + return opened + } + + private mintNamespaceId(): string { + const createId = this.options.createNamespaceId ?? randomUUID + for (let attempt = 0; attempt < 8; attempt++) { + const namespaceId = createId() + assertAuthorityId(namespaceId, 'namespaceId') + if (!this.entriesByNamespace.has(namespaceId)) { + return namespaceId + } + } + failTerminalSessionAuthority('operation-conflict', 'could not mint a unique namespace ID') + } + + private namespace(namespaceId: string): TerminalAuthorityNamespace { + return Object.freeze({ authorityHostId: this.options.authorityHostId, namespaceId }) + } + + private namespaceMatchesLocator( + namespace: TerminalAuthorityNamespace, + locatorKey: string + ): boolean { + if (namespace.authorityHostId !== this.options.authorityHostId) { + return false + } + const entry = this.entriesByNamespace.get(namespace.namespaceId) + return Boolean(entry && terminalAuthorityNamespaceLocatorKey(entry.locator) === locatorKey) + } + + private assertRegisteredNamespace(namespace: TerminalAuthorityNamespace): void { + assertAuthorityNamespace(namespace) + if ( + namespace.authorityHostId !== this.options.authorityHostId || + !this.entriesByNamespace.has(namespace.namespaceId) + ) { + failTerminalSessionAuthority('expectation-mismatch', 'authority namespace is not registered') + } + } + + private enqueueMutation(operation: () => Promise): Promise { + this.assertAccepting() + const result = this.mutationQueue.then(operation) + this.mutationQueue = result.then( + () => undefined, + () => undefined + ) + return result + } + + private assertAccepting(): void { + if (!this.accepting || this.closed) { + failTerminalSessionAuthority('writer-fenced', 'authority registry is closed') + } + } +} diff --git a/src/main/session-authority/terminal-session-authority-runtime-cache.ts b/src/main/session-authority/terminal-session-authority-runtime-cache.ts new file mode 100644 index 00000000000..f889ddc996b --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-runtime-cache.ts @@ -0,0 +1,100 @@ +import type { TerminalAuthorityNamespace } from '../../shared/terminal-session-authority-identity' +import type { TerminalAuthorityProjection } from '../../shared/terminal-session-authority-mutation' +import type { TerminalAuthorityNamespaceLocator } from '../../shared/terminal-session-authority-locator' +import type { TerminalSessionAuthorityRegistry } from './terminal-session-authority-registry' +import type { TerminalSessionAuthorityService } from './terminal-session-authority-service' +import type { TerminalSessionAuthorityHostEffectConsumer } from './terminal-session-authority-host-effect-consumer' +import type { TerminalAuthorityPolicyConsumerConnection } from './terminal-session-authority-policy-consumers' + +export type TerminalAuthorityNamespaceRuntime = { + service: TerminalSessionAuthorityService + queue: Promise +} + +export class TerminalSessionAuthorityRuntimeCache { + private readonly runtimes = new Map>() + + constructor( + private readonly registry: TerminalSessionAuthorityRegistry, + private readonly hostEffects: TerminalSessionAuthorityHostEffectConsumer + ) {} + + async resolve( + _locatorKey: string, + locator: TerminalAuthorityNamespaceLocator + ): Promise { + const registered = this.registry.namespaceForLocator(locator) + if (registered) { + return this.open(registered) + } + const resolved = await this.registry.resolveNamespace(locator) + return this.open(resolved.namespace) + } + + find( + _locatorKey: string, + locator: TerminalAuthorityNamespaceLocator + ): Promise | null { + const namespace = this.registry.namespaceForLocator(locator) + return namespace ? this.open(namespace) : null + } + + enqueue(runtime: TerminalAuthorityNamespaceRuntime, operation: () => Promise): Promise { + const result = runtime.queue.then(operation) + runtime.queue = result.then( + () => undefined, + () => undefined + ) + return result + } + + async projection( + runtime: TerminalAuthorityNamespaceRuntime + ): Promise { + return runtime.service.snapshotForWriter(runtime.service.writerAccess) + } + + async admitPolicyConsumer( + runtime: TerminalAuthorityNamespaceRuntime, + connection: TerminalAuthorityPolicyConsumerConnection + ): Promise { + await connection.ensureNamespace(runtime.service) + connection.assertInstalled(runtime.service.namespace) + } + + assertPolicyConsumer( + runtime: TerminalAuthorityNamespaceRuntime, + connection: TerminalAuthorityPolicyConsumerConnection + ): void { + connection.assertInstalled(runtime.service.namespace) + } + + private open(namespace: TerminalAuthorityNamespace): Promise { + const key = namespaceKey(namespace) + const existing = this.runtimes.get(key) + if (existing) { + return existing + } + const opening = this.openRuntime(namespace) + this.runtimes.set(key, opening) + void opening.catch(() => this.runtimes.delete(key)) + return opening + } + + private async openRuntime( + namespace: TerminalAuthorityNamespace + ): Promise { + const service = await this.registry.openNamespace(namespace) + await this.hostEffects.ensure(service) + this.hostEffects.assertApplierInstalled() + const runtime: TerminalAuthorityNamespaceRuntime = { + service, + queue: Promise.resolve() + } + return runtime + } +} + +function namespaceKey(namespace: TerminalAuthorityNamespace): string { + return JSON.stringify([namespace.authorityHostId, namespace.namespaceId]) +} diff --git a/src/main/session-authority/terminal-session-authority-semantic-outcomes.test.ts b/src/main/session-authority/terminal-session-authority-semantic-outcomes.test.ts new file mode 100644 index 00000000000..f5720b92700 --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-semantic-outcomes.test.ts @@ -0,0 +1,339 @@ +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import type { + TerminalAuthoritySemanticOutcome, + TerminalSessionAuthorityChange, + TerminalSessionAuthoritySemanticFact +} from '../../shared/terminal-session-authority-mutation' +import type { TerminalSessionAuthorityPtyAccess } from '../../shared/terminal-session-authority-pty-access' +import { terminalAuthorityOperationIdentity } from '../../shared/terminal-session-authority-operation-identity' +import type { TerminalAuthorityConsumerAccess } from './terminal-session-authority-access' +import { TerminalSessionAuthorityService } from './terminal-session-authority-service' +import type { TerminalSessionAuthorityServiceOptions } from './terminal-session-authority-service-contract' + +const NAMESPACE = { authorityHostId: 'host-a', namespaceId: 'namespace-a' } +const PRODUCER = 'pty-worker-incarnation-a' +const PR_LINK = { + url: 'https://github.com/orca/orca/pull/1', + slug: { owner: 'orca', repo: 'orca', host: 'github.com' }, + number: 1 +} + +const ALL_FACTS: readonly TerminalSessionAuthoritySemanticFact[] = [ + { kind: 'agent-status', payload: { state: 'working', prompt: 'ship it' } }, + { kind: 'title', normalizedTitle: 'Build', rawTitle: ' Build ' }, + { kind: 'bell' }, + { kind: 'agent-working' }, + { kind: 'agent-idle', title: 'Done' }, + { kind: 'agent-exited' }, + { kind: 'command-finished', exitCode: 0 }, + { kind: 'pr-link', link: PR_LINK }, + { kind: 'command-code-working', prompt: 'fix tests' }, + { kind: 'command-code-done', prompt: 'fix tests' }, + { kind: '2031-subscribe' }, + { kind: '2031-unsubscribe' } +] + +const directories: string[] = [] +const services: TerminalSessionAuthorityService[] = [] + +afterEach(async () => { + await Promise.allSettled(services.splice(0).map((service) => service.close())) + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +describe('namespace terminal outcome journal', () => { + it('replays one ordered journal independently to two consumers', async () => { + const service = await openService(freshDirectory()) + const first = await claim(service, 'consumer-a', 'incarnation-a') + const second = await claim(service, 'consumer-b', 'incarnation-b') + const access = await bindPty(service, first) + const semantic = await record(service, first, access, 1, { kind: 'bell' }) + + const firstRead = await service.readOutcomes(first, 0) + const secondRead = await service.readOutcomes(second, 0) + expect(firstRead).toEqual(secondRead) + expect(firstRead).toMatchObject({ + kind: 'entries', + entries: [ + { sequence: 1 }, + { sequence: 2 }, + { sequence: 3 }, + { sequence: semantic.sequence, fact: { kind: 'bell' } } + ] + }) + + await service.acknowledgeOutcomes(first, semantic.sequence) + expect(await service.readOutcomes(second, 0)).toEqual(secondRead) + await service.acknowledgeOutcomes(second, semantic.sequence) + expect(await service.snapshotForConsumer(first)).toMatchObject({ + acknowledgedSequence: semantic.sequence, + outcomeHighWatermark: semantic.sequence + }) + expect(await service.snapshotForConsumer(second)).toMatchObject({ + acknowledgedSequence: semantic.sequence, + outcomeHighWatermark: semantic.sequence + }) + }) + + it('starts a new stable consumer at the journal tail and preserves a rotated cursor', async () => { + const service = await openService(freshDirectory()) + const first = await claim(service, 'consumer-a', 'incarnation-a') + await mutate(service, first, 0, 'create', { + kind: 'create', + pane: { paneKey: 'pane-a', paneGenerationId: 'generation-a' } + }) + const late = await claim(service, 'consumer-b', 'incarnation-b') + + expect(await service.snapshotForConsumer(late)).toMatchObject({ + acknowledgedSequence: 1, + outcomeHighWatermark: 1 + }) + expect(await service.readOutcomes(late, 1)).toEqual({ kind: 'entries', entries: [] }) + + const rotated = await claim(service, 'consumer-a', 'incarnation-a-next', 'incarnation-a') + expect(await service.snapshotForConsumer(rotated)).toMatchObject({ acknowledgedSequence: 0 }) + await expect(service.readOutcomes(first, 0)).rejects.toMatchObject({ + code: 'consumer-conflict' + }) + }) + + it('rejects a wrong incarnation for read and ACK while duplicate ACKs are idempotent', async () => { + const directory = freshDirectory() + const service = await openService(directory) + const consumer = await claim(service, 'consumer-a', 'incarnation-a') + await mutate(service, consumer, 0, 'create', { + kind: 'create', + pane: { paneKey: 'pane-a', paneGenerationId: 'generation-a' } + }) + + await service.acknowledgeOutcomes(consumer, 1) + const afterFirstAck = readLog(directory) + await service.acknowledgeOutcomes(consumer, 1) + expect(readLog(directory)).toBe(afterFirstAck) + + const stale = { ...consumer, consumerIncarnationId: 'incarnation-stale' } + await expect(service.readOutcomes(stale, 1)).rejects.toMatchObject({ + code: 'consumer-conflict' + }) + await expect(service.acknowledgeOutcomes(stale, 1)).rejects.toMatchObject({ + code: 'consumer-conflict' + }) + }) + + it('resumes each durable consumer cursor after authority restart', async () => { + const directory = freshDirectory() + const firstService = await openService(directory) + const first = await claim(firstService, 'consumer-a', 'incarnation-a') + const second = await claim(firstService, 'consumer-b', 'incarnation-b') + const access = await bindPty(firstService, first) + const pending = await record(firstService, first, access, 1, { kind: 'bell' }) + await firstService.acknowledgeOutcomes(first, pending.sequence) + await secondServiceAckMutations(firstService, second) + await firstService.compact(firstService.writerAccess) + await firstService.close() + + const restarted = await openService(directory, { ownerIncarnationId: 'owner-incarnation-b' }) + const resumedFirst = await claim(restarted, 'consumer-a', 'incarnation-a-next', 'incarnation-a') + const resumedSecond = await claim( + restarted, + 'consumer-b', + 'incarnation-b-next', + 'incarnation-b' + ) + expect(await restarted.snapshotForConsumer(resumedFirst)).toMatchObject({ + acknowledgedSequence: pending.sequence, + outcomeHighWatermark: pending.sequence + }) + expect(await restarted.snapshotForConsumer(resumedSecond)).toMatchObject({ + acknowledgedSequence: 3, + outcomeHighWatermark: pending.sequence + }) + expect(await restarted.readOutcomes(resumedSecond, 3)).toMatchObject({ + kind: 'entries', + entries: [{ sequence: pending.sequence, fact: { kind: 'bell' } }] + }) + }) + + it('preserves every canonical side-effect payload without a duplicate terminal-exit fact', async () => { + const service = await openService(freshDirectory()) + const consumer = await claim(service, 'consumer-a', 'incarnation-a') + const access = await bindPty(service, consumer) + + const appended: TerminalAuthoritySemanticOutcome[] = [] + for (const [index, fact] of ALL_FACTS.entries()) { + appended.push(await record(service, consumer, access, index + 1, fact)) + } + + expect(appended.map((outcome) => outcome.fact)).toEqual(ALL_FACTS) + expect(appended.some((outcome) => (outcome.fact as { kind: string }).kind === 'exit')).toBe( + false + ) + expect(appended.map((outcome) => outcome.sequence)).toEqual( + ALL_FACTS.map((_, index) => index + 4) + ) + }) + + it('returns the durable semantic result for a retry and rejects changed payloads', async () => { + const service = await openService(freshDirectory()) + const consumer = await claim(service, 'consumer-a', 'incarnation-a') + const access = await bindPty(service, consumer) + const first = await record(service, consumer, access, 1, { kind: 'bell' }) + + await expect(record(service, consumer, access, 1, { kind: 'bell' })).resolves.toEqual(first) + await expect( + record(service, consumer, access, 1, { kind: 'command-finished', exitCode: 1 }) + ).rejects.toMatchObject({ code: 'record-corrupt' }) + }) + + it('fails closed for producer gaps, compacted retries, and invalid consumer cursors', async () => { + const service = await openService(freshDirectory()) + const consumer = await claim(service, 'consumer-a', 'incarnation-a') + const access = await bindPty(service, consumer) + + await expect(record(service, consumer, access, 2, { kind: 'bell' })).rejects.toMatchObject({ + code: 'expectation-mismatch' + }) + const outcome = await record(service, consumer, access, 1, { kind: 'bell' }) + await service.acknowledgeOutcomes(consumer, outcome.sequence) + + await expect(record(service, consumer, access, 1, { kind: 'bell' })).rejects.toMatchObject({ + code: 'operation-conflict' + }) + expect(await service.readOutcomes(consumer, outcome.sequence - 1)).toMatchObject({ + kind: 'resnapshot-required', + reason: 'cursor-compacted', + acknowledgedSequence: outcome.sequence + }) + expect(await service.readOutcomes(consumer, outcome.sequence + 1)).toMatchObject({ + kind: 'resnapshot-required', + reason: 'cursor-ahead' + }) + }) + + it('fails closed when the shared unsettled journal reaches its bound', async () => { + const service = await openService(freshDirectory(), { maxRetainedOperationEntries: 3 }) + const first = await claim(service, 'consumer-a', 'incarnation-a') + const second = await claim(service, 'consumer-b', 'incarnation-b') + const access = await bindPty(service, first) + + await expect(record(service, first, access, 1, { kind: 'bell' })).rejects.toMatchObject({ + code: 'capacity' + }) + await service.acknowledgeOutcomes(first, 3) + await expect(record(service, first, access, 1, { kind: 'bell' })).rejects.toMatchObject({ + code: 'capacity' + }) + await service.acknowledgeOutcomes(second, 3) + await expect(record(service, first, access, 1, { kind: 'bell' })).resolves.toMatchObject({ + sequence: 4 + }) + }) +}) + +async function secondServiceAckMutations( + service: TerminalSessionAuthorityService, + consumer: TerminalAuthorityConsumerAccess +): Promise { + await service.acknowledgeOutcomes(consumer, 3) +} + +async function record( + service: TerminalSessionAuthorityService, + _consumer: TerminalAuthorityConsumerAccess, + access: TerminalSessionAuthorityPtyAccess, + producerSequence: number, + fact: TerminalSessionAuthoritySemanticFact +) { + return service.recordSemanticOutcome(service.writerAccess, { + access, + producerIncarnationId: PRODUCER, + producerSequence, + fact + }) +} + +async function bindPty( + service: TerminalSessionAuthorityService, + consumer: TerminalAuthorityConsumerAccess +): Promise { + const pane = { paneKey: 'pane-a', paneGenerationId: 'generation-a' } + const allocation = { + allocationId: 'allocation-a', + pane, + ownerIncarnationId: 'owner-incarnation-a', + physicalPtyId: 'pty-a', + spawnFingerprint: 'spawn-a' + } + await mutate(service, consumer, 0, 'create', { kind: 'create', pane }) + await mutate(service, consumer, 1, 'prepare', { + kind: 'prepare-allocation', + allocation, + expected: { paneGenerationId: pane.paneGenerationId, binding: null } + }) + const receipt = await mutate(service, consumer, 2, 'commit', { + kind: 'commit-allocation', + allocation, + ptyIncarnationId: 'pty-incarnation-a', + expected: { paneGenerationId: pane.paneGenerationId, binding: null } + }) + return { namespace: NAMESPACE, pane, binding: receipt.result.pane.binding! } +} + +async function mutate( + service: TerminalSessionAuthorityService, + _consumer: TerminalAuthorityConsumerAccess, + baseRevision: number, + correlationId: string, + change: TerminalSessionAuthorityChange +) { + return service.mutate(service.writerAccess, { + actorId: service.writerAccess.actorId, + ...terminalAuthorityOperationIdentity(baseRevision, correlationId), + baseRevision, + change + }) +} + +async function claim( + service: TerminalSessionAuthorityService, + consumerId: string, + consumerIncarnationId: string, + expectedIncarnationId: string | null = null +): Promise { + return service.claimConsumer(service.writerAccess, { + consumerId, + expectedIncarnationId, + consumerIncarnationId + }) +} + +async function openService( + directory: string, + overrides: Partial = {} +): Promise { + const service = await TerminalSessionAuthorityService.open({ + directory: path.join(directory, 'namespace'), + namespace: NAMESPACE, + ownerToken: 'owner-token-a', + ownerIncarnationId: 'owner-incarnation-a', + writerActorId: 'writer-a', + ...overrides + }) + services.push(service) + return service +} + +function readLog(directory: string): string { + return readFileSync(path.join(directory, 'namespace', 'authority.log'), 'utf8') +} + +function freshDirectory(): string { + const directory = mkdtempSync(path.join(tmpdir(), 'orca-authority-outcomes-')) + directories.push(directory) + return directory +} diff --git a/src/main/session-authority/terminal-session-authority-service-bootstrap.ts b/src/main/session-authority/terminal-session-authority-service-bootstrap.ts new file mode 100644 index 00000000000..3c215a41f72 --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-service-bootstrap.ts @@ -0,0 +1,108 @@ +import { randomUUID } from 'node:crypto' +import { assertAuthorityId } from '../../shared/terminal-session-authority-identity' +import { failTerminalSessionAuthority } from '../../shared/terminal-session-authority-mutation' +import { TerminalSessionAuthorityState } from '../../shared/terminal-session-authority-state' +import { + TerminalAuthorityRuntimeAccess, + type TerminalAuthorityWriterAccess +} from './terminal-session-authority-access' +import { TerminalAuthorityFileStore } from './terminal-session-authority-file-store' +import type { TerminalSessionAuthorityServiceOptions } from './terminal-session-authority-service-contract' +import { + resolveTerminalAuthorityObserverLimit, + validateTerminalSessionAuthorityServiceOptions +} from './terminal-session-authority-service-options' +import { TerminalAuthorityWriterLock } from './terminal-session-authority-writer-lock' + +export type OpenTerminalSessionAuthorityServiceState = Readonly<{ + state: TerminalSessionAuthorityState + store: TerminalAuthorityFileStore + accesses: TerminalAuthorityRuntimeAccess + writerAccess: TerminalAuthorityWriterAccess +}> + +export async function openTerminalSessionAuthorityServiceState( + options: TerminalSessionAuthorityServiceOptions +): Promise { + validateTerminalSessionAuthorityServiceOptions(options) + const createRuntimeId = options.createRuntimeId ?? randomUUID + const serviceInstanceId = createRuntimeId() + assertAuthorityId(serviceInstanceId, 'serviceInstanceId') + const lock = await TerminalAuthorityWriterLock.acquire({ + directory: options.directory, + ownerToken: options.ownerToken, + takeoverOwnerToken: options.takeoverOwnerToken, + allowUninitializedTakeover: options.allowUninitializedTakeover + }) + try { + const opened = await TerminalAuthorityFileStore.open({ + directory: options.directory, + namespace: options.namespace, + lock, + maxCheckpointBytes: options.maxCheckpointBytes, + maxLogBytes: options.maxLogBytes, + onCrashBoundary: options.onAuthorityCrashBoundary + }) + const state = restoreAuthorityState(options, opened.checkpoint, lock.identity.epoch) + replayAuthorityRecords(opened.records, state, lock.identity.epoch) + state.setWriterEpoch(lock.identity.epoch) + const writerAccess = Object.freeze({ + role: 'writer' as const, + serviceInstanceId, + actorId: options.writerActorId, + ownerToken: lock.identity.ownerToken, + writerEpoch: lock.identity.epoch + }) + const accesses = new TerminalAuthorityRuntimeAccess( + serviceInstanceId, + writerAccess, + createRuntimeId, + resolveTerminalAuthorityObserverLimit(options.maxObservers) + ) + return Object.freeze({ + state, + store: opened.store, + accesses, + writerAccess + }) + } catch (error) { + await lock.release().catch(() => undefined) + throw error + } +} + +function restoreAuthorityState( + options: TerminalSessionAuthorityServiceOptions, + checkpoint: ReturnType | null, + writerEpoch: number +): TerminalSessionAuthorityState { + if (checkpoint && checkpoint.writerEpoch > writerEpoch) { + failTerminalSessionAuthority('record-corrupt', 'authority snapshot has a future writer epoch') + } + return checkpoint + ? TerminalSessionAuthorityState.restore( + checkpoint, + writerEpoch, + options.ownerIncarnationId, + options + ) + : new TerminalSessionAuthorityState( + options.namespace, + writerEpoch, + options.ownerIncarnationId, + options + ) +} + +function replayAuthorityRecords( + records: Awaited>['records'], + state: TerminalSessionAuthorityState, + writerEpoch: number +): void { + for (const record of records) { + if (record.writerEpoch > writerEpoch) { + failTerminalSessionAuthority('record-corrupt', 'authority log has a future writer epoch') + } + state.applyEvent(record.event) + } +} diff --git a/src/main/session-authority/terminal-session-authority-service-contract.ts b/src/main/session-authority/terminal-session-authority-service-contract.ts new file mode 100644 index 00000000000..89bf9f4ed5a --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-service-contract.ts @@ -0,0 +1,43 @@ +import type { TerminalAuthorityNamespace } from '../../shared/terminal-session-authority-identity' +import type { + TerminalAuthorityProjection, + TerminalSessionAuthorityMutationResult +} from '../../shared/terminal-session-authority-mutation' +import type { TerminalSessionAuthorityStateOptions } from '../../shared/terminal-session-authority-state' +import type { TerminalAuthorityFileStoreOptions } from './terminal-session-authority-file-store' + +export type TerminalSessionAuthorityServiceOptions = TerminalSessionAuthorityStateOptions & + Readonly<{ + directory: string + namespace: TerminalAuthorityNamespace + ownerToken: string + ownerIncarnationId: string + writerActorId: string + takeoverOwnerToken?: string + allowUninitializedTakeover?: boolean + maxObservers?: number + createRuntimeId?: () => string + maxCheckpointBytes?: number + maxLogBytes?: number + onAuthorityCrashBoundary?: TerminalAuthorityFileStoreOptions['onCrashBoundary'] + legacyWorkerAccess?: TerminalAuthorityLegacyWorkerAccess + }> + +export type TerminalAuthorityLegacyWorkerAccess = Readonly<{ + role: 'legacy-worker-owner' + accessId: string +}> + +export type TerminalAuthorityMutationReceipt = Readonly<{ + result: TerminalSessionAuthorityMutationResult + outcomeSequence: number +}> + +export type TerminalAuthorityProjectionChange = Readonly<{ + reason: 'mutation' | 'outcome' | 'legacy-import' | 'owner-reachability' + projection: TerminalAuthorityProjection +}> + +export type TerminalAuthorityProjectionListener = ( + change: TerminalAuthorityProjectionChange +) => void diff --git a/src/main/session-authority/terminal-session-authority-service-options.ts b/src/main/session-authority/terminal-session-authority-service-options.ts new file mode 100644 index 00000000000..56541918000 --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-service-options.ts @@ -0,0 +1,37 @@ +import { + assertAuthorityId, + assertAuthorityNamespace, + assertAuthorityStoragePath, + isRecord +} from '../../shared/terminal-session-authority-identity' +import { failTerminalSessionAuthority } from '../../shared/terminal-session-authority-mutation' +import type { TerminalSessionAuthorityServiceOptions } from './terminal-session-authority-service-contract' + +const DEFAULT_MAX_OBSERVERS = 128 + +export function validateTerminalSessionAuthorityServiceOptions( + options: TerminalSessionAuthorityServiceOptions +): void { + assertAuthorityStoragePath(options.directory, 'authority directory') + assertAuthorityNamespace(options.namespace) + assertAuthorityId(options.ownerToken, 'ownerToken') + assertAuthorityId(options.ownerIncarnationId, 'ownerIncarnationId') + assertAuthorityId(options.writerActorId, 'writerActorId') + if (options.legacyWorkerAccess !== undefined) { + if ( + !isRecord(options.legacyWorkerAccess) || + options.legacyWorkerAccess.role !== 'legacy-worker-owner' + ) { + failTerminalSessionAuthority('writer-fenced', 'legacy worker access is invalid') + } + assertAuthorityId(options.legacyWorkerAccess.accessId, 'legacy worker accessId') + } +} + +export function resolveTerminalAuthorityObserverLimit(value: number | undefined): number { + const selected = value ?? DEFAULT_MAX_OBSERVERS + if (!Number.isSafeInteger(selected) || selected < 1) { + failTerminalSessionAuthority('capacity', 'observer capacity is invalid') + } + return selected +} diff --git a/src/main/session-authority/terminal-session-authority-service.test.ts b/src/main/session-authority/terminal-session-authority-service.test.ts new file mode 100644 index 00000000000..baddabd93fc --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-service.test.ts @@ -0,0 +1,827 @@ +import { + closeSync, + fsyncSync, + mkdtempSync, + openSync, + readFileSync, + rmSync, + statSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import type { TerminalSessionAuthorityChange } from '../../shared/terminal-session-authority-mutation' +import { terminalAuthorityOperationIdentity } from '../../shared/terminal-session-authority-operation-identity' +import type { TerminalAuthorityConsumerAccess } from './terminal-session-authority-access' +import { TerminalSessionAuthorityService } from './terminal-session-authority-service' +import type { + TerminalAuthorityProjectionChange, + TerminalSessionAuthorityServiceOptions +} from './terminal-session-authority-service-contract' + +const directories: string[] = [] +const services: TerminalSessionAuthorityService[] = [] + +afterEach(async () => { + await Promise.allSettled(services.splice(0).map((service) => service.close())) + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +describe('TerminalSessionAuthorityService', () => { + it('linearizes a new producer hold with queue admission after a prior gate resolves', async () => { + const service = await openService(freshDirectory()) + const consumer = await claim(service, 'consumer-a', null, 'consumer-incarnation-a') + const firstHold = service.acquireProducerHold(service.writerAccess) + const pane = { paneKey: 'pane-a', paneGenerationId: 'generation-a' } + const mutation = mutate(service, consumer, 0, 'create', { kind: 'create', pane }) + let secondHold: Readonly<{ release(): void }> | undefined + let snapshot: ReturnType | undefined + + firstHold.release() + queueMicrotask(() => { + secondHold = service.acquireProducerHold(service.writerAccess) + snapshot = service.snapshotForConsumer(consumer) + }) + await new Promise((resolve) => setImmediate(resolve)) + + await expect(snapshot).resolves.toMatchObject({ outcomeHighWatermark: 1 }) + secondHold?.release() + await expect(mutation).resolves.toMatchObject({ outcomeSequence: 1 }) + }) + + it('snapshots a consumer claim before queueing it', async () => { + const service = await openService(freshDirectory()) + const input = { + consumerId: 'consumer-a', + expectedIncarnationId: null, + consumerIncarnationId: 'consumer-incarnation-a' + } + const claiming = service.claimConsumer(service.writerAccess, input) + Object.assign(input, { + consumerId: 'changed-consumer', + consumerIncarnationId: 'changed-incarnation' + }) + await expect(claiming).resolves.toMatchObject({ + consumerId: 'consumer-a', + consumerIncarnationId: 'consumer-incarnation-a' + }) + }) + + it('durably retires only the current consumer incarnation', async () => { + const directory = freshDirectory() + const service = await openService(directory) + const host = await claim(service, 'host-effects', null, 'host-incarnation') + const device = await claim(service, 'device-a', null, 'device-incarnation') + const pane = { paneKey: 'pane-a', paneGenerationId: 'generation-a' } + const created = await mutate(service, host, 0, 'create-before-retire', { + kind: 'create', + pane + }) + await service.acknowledgeOutcomes(host, created.outcomeSequence) + + await expect( + service.retireConsumer(service.writerAccess, { + ...device, + consumerIncarnationId: 'stale-incarnation' + }) + ).rejects.toMatchObject({ code: 'consumer-conflict' }) + await expect(service.retireConsumer(service.writerAccess, device)).resolves.toBe(true) + await expect(service.retireConsumer(service.writerAccess, device)).resolves.toBe(false) + + await service.close() + services.splice(services.indexOf(service), 1) + const restored = await openService(directory) + const restoredHost = await claim( + restored, + 'host-effects', + 'host-incarnation', + 'host-incarnation' + ) + await expect(restored.snapshotForConsumer(restoredHost)).resolves.toMatchObject({ + acknowledgedSequence: created.outcomeSequence, + outcomeHighWatermark: created.outcomeSequence + }) + }) + + it('publishes durable projection changes without admitting duplicate or consumer events', async () => { + const service = await openService(freshDirectory(), { maxObservers: 1 }) + const changes: TerminalAuthorityProjectionChange[] = [] + const subscription = service.subscribeProjection('projection-subscriber', (change) => { + changes.push(change) + }) + expect(() => service.observe('over-capacity-observer')).toThrowError( + expect.objectContaining({ code: 'capacity' }) + ) + const consumer = await claim(service, 'consumer-a', null, 'consumer-incarnation-a') + expect(changes).toHaveLength(0) + + const pane = { paneKey: 'pane-a', paneGenerationId: 'generation-a' } + await mutate(service, consumer, 0, 'create', { kind: 'create', pane }) + await mutate(service, consumer, 0, 'create', { kind: 'create', pane }) + expect(changes).toMatchObject([ + { reason: 'mutation', projection: { revision: 1, panes: [{ paneKey: 'pane-a' }] } } + ]) + + service.revokeObserver(subscription) + const failing = service.subscribeProjection('failing-subscriber', () => { + throw new Error('subscriber failure') + }) + await expect( + mutate(service, consumer, 1, 'close', { + kind: 'close', + pane, + expected: { paneGenerationId: pane.paneGenerationId, binding: null } + }) + ).resolves.toMatchObject({ result: { revision: 2 } }) + expect(changes).toHaveLength(1) + service.revokeObserver(failing) + }) + + it('durably prepares and commits an exact PTY allocation with no I/O hot-path writes', async () => { + const service = await openService(freshDirectory()) + const consumer = await claim(service, 'consumer-a', null, 'consumer-incarnation-a') + const pane = { paneKey: 'pane-a', paneGenerationId: 'generation-a' } + await mutate(service, consumer, 0, 'create', { kind: 'create', pane }) + const allocation = { + allocationId: 'allocation-a', + pane, + ownerIncarnationId: 'owner-incarnation-a', + physicalPtyId: 'pty-a', + spawnFingerprint: 'spawn-a' + } + const prepared = await mutate(service, consumer, 1, 'prepare', { + kind: 'prepare-allocation', + allocation, + expected: { paneGenerationId: pane.paneGenerationId, binding: null } + }) + expect(prepared.result.allocation).toMatchObject({ + status: 'pending', + allocationId: 'allocation-a' + }) + const committed = await mutate(service, consumer, 2, 'commit', { + kind: 'commit-allocation', + allocation, + ptyIncarnationId: 'pty-incarnation-a', + expected: { paneGenerationId: pane.paneGenerationId, binding: null } + }) + const binding = committed.result.pane.binding! + expect(committed.result.allocation).toMatchObject({ status: 'committed', binding }) + expect(service.bindingAuthority(service.writerAccess, pane, binding)).toBe('reachable') + + const log = path.join(serviceDirectory(service), 'authority.log') + const before = statSync(log).size + for (let index = 0; index < 1_000; index++) { + expect(service.bindingAuthority(service.writerAccess, pane, binding)).toBe('reachable') + } + expect(statSync(log).size).toBe(before) + expect(readFileSync(log, 'utf8').trim().split('\n')).toHaveLength(4) + expect('write' in service || 'resize' in service || 'terminalOutput' in service).toBe(false) + }) + + it('rotates consumer incarnation while retaining ordered outcomes and ACK state', async () => { + const directory = freshDirectory() + const first = await openService(directory) + const original = await claim(first, 'consumer-a', null, 'consumer-incarnation-a') + const pane = { paneKey: 'pane-a', paneGenerationId: 'generation-a' } + await mutate(first, original, 0, 'create', { kind: 'create', pane }) + const rotated = await claim( + first, + 'consumer-a', + 'consumer-incarnation-a', + 'consumer-incarnation-b' + ) + await expect(first.readOutcomes(original, 0)).rejects.toMatchObject({ + code: 'consumer-conflict' + }) + const replay = await first.readOutcomes(rotated, 0) + expect(replay).toMatchObject({ kind: 'entries', entries: [{ sequence: 1 }] }) + expect(await first.snapshotForConsumer(rotated)).toMatchObject({ + acknowledgedSequence: 0, + outcomeHighWatermark: 1, + authority: { revision: 1 } + }) + await first.acknowledgeOutcomes(rotated, 1) + const log = path.join(serviceDirectory(first), 'authority.log') + const afterAck = statSync(log).size + await first.acknowledgeOutcomes(rotated, 1) + expect(statSync(log).size).toBe(afterAck) + expect(await first.readOutcomes(rotated, 0)).toMatchObject({ + kind: 'resnapshot-required', + reason: 'cursor-compacted', + acknowledgedSequence: 1 + }) + }) + + it('fences old consumer reads and ACKs after writer takeover', async () => { + const directory = freshDirectory() + const first = await openService(directory, { ownerToken: 'owner-token-a' }) + const original = await claim(first, 'consumer-a', null, 'consumer-incarnation-a') + const pane = { paneKey: 'pane-a', paneGenerationId: 'generation-a' } + await mutate(first, original, 0, 'create', { kind: 'create', pane }) + + const replacement = await openService(directory, { + ownerToken: 'owner-token-b', + takeoverOwnerToken: 'owner-token-a', + ownerIncarnationId: 'owner-incarnation-b' + }) + const rotated = await replacement.claimConsumer(replacement.writerAccess, { + consumerId: 'consumer-a', + expectedIncarnationId: replacement.activeConsumerIncarnation( + replacement.writerAccess, + 'consumer-a' + ), + consumerIncarnationId: 'consumer-incarnation-b' + }) + + await expect(first.readOutcomes(original, 0)).rejects.toMatchObject({ + code: 'writer-fenced' + }) + await expect(first.acknowledgeOutcomes(original, 1)).rejects.toMatchObject({ + code: 'writer-fenced' + }) + await expect(replacement.readOutcomes(rotated, 0)).resolves.toMatchObject({ + kind: 'entries', + entries: [{ sequence: 1 }] + }) + }) + + it('recovers one synced mutation after its response is lost', async () => { + const directory = freshDirectory() + const first = await openService(directory, { + ownerToken: 'owner-token-a' + }) + const original = await claim(first, 'consumer-a', null, 'consumer-incarnation-a') + const pane = { paneKey: 'pane-a', paneGenerationId: 'generation-a' } + await mutate(first, original, 0, 'create-once', { kind: 'create', pane }) + + const replacement = await openService(directory, { + ownerToken: 'owner-token-b', + takeoverOwnerToken: 'owner-token-a', + ownerIncarnationId: 'owner-incarnation-b' + }) + const consumer = await claim( + replacement, + 'consumer-a', + 'consumer-incarnation-a', + 'consumer-incarnation-a' + ) + const replay = await replacement.readOutcomes(consumer, 0) + expect(replay).toMatchObject({ + kind: 'entries', + entries: [ + { + outcomeId: terminalAuthorityOperationIdentity(0, 'create-once').outcomeId, + sequence: 1 + } + ] + }) + const replayed = replay.kind === 'entries' ? replay.entries[0] : null + if (replayed?.kind === undefined) { + Object.assign(replayed!.result.pane, { paneKey: 'changed-pane' }) + } + expect(await replacement.readOutcomes(consumer, 0)).toMatchObject({ + kind: 'entries', + entries: [{ result: { pane: { paneKey: 'pane-a' } } }] + }) + const duplicate = await mutate(replacement, consumer, 0, 'create-once', { + kind: 'create', + pane + }) + Object.assign(duplicate.result.pane, { paneKey: 'changed-duplicate-pane' }) + expect((await replacement.snapshotForConsumer(consumer)).authority.panes).toHaveLength(1) + expect((await replacement.snapshotForConsumer(consumer)).authority.panes[0]?.paneKey).toBe( + 'pane-a' + ) + expect(replacement.writerAccess.writerEpoch).toBe(2) + }) + + it.each([false, true])( + 'rejects a compacted ACKed operation after restart (checkpoint=%s)', + async (checkpoint) => { + const directory = freshDirectory() + const first = await openService(directory, { ownerToken: 'owner-token-a' }) + const consumer = await claim(first, 'consumer-a', null, 'consumer-incarnation-a') + const pane = { paneKey: 'pane-a', paneGenerationId: 'generation-a' } + const request = mutationRequest(first, consumer, 0, 'create-before-response', { + kind: 'create', + pane + }) + const original = await first.mutate(first.writerAccess, request) + await first.acknowledgeOutcomes(consumer, original.outcomeSequence) + if (checkpoint) { + await first.compact(first.writerAccess) + } + + const replacement = await openService(directory, { + ownerToken: 'owner-token-b', + takeoverOwnerToken: 'owner-token-a', + ownerIncarnationId: 'owner-incarnation-b' + }) + const resumed = await claim( + replacement, + 'consumer-a', + 'consumer-incarnation-a', + 'consumer-incarnation-a' + ) + await expect(replacement.mutate(replacement.writerAccess, request)).rejects.toMatchObject({ + code: 'operation-conflict' + }) + expect(await replacement.snapshotForConsumer(resumed)).toMatchObject({ + acknowledgedSequence: 1, + outcomeHighWatermark: 1, + authority: { revision: 1, panes: [{ paneKey: 'pane-a' }] } + }) + } + ) + + it('reclaims ACKed operation capacity without admitting old IDs', async () => { + const service = await openService(freshDirectory(), { maxRetainedOperationEntries: 1 }) + const consumer = await claim(service, 'consumer-a', null, 'consumer-incarnation-a') + const pane = { paneKey: 'pane-a', paneGenerationId: 'generation-a' } + const originalRequest = mutationRequest(service, consumer, 0, 'retained-create', { + kind: 'create', + pane + }) + const original = await service.mutate(service.writerAccess, originalRequest) + await service.acknowledgeOutcomes(consumer, original.outcomeSequence) + + await expect( + mutate(service, consumer, 1, 'new-close', { + kind: 'close', + pane, + expected: { paneGenerationId: pane.paneGenerationId, binding: null } + }) + ).resolves.toMatchObject({ result: { revision: 2, kind: 'close' } }) + await expect(service.mutate(service.writerAccess, originalRequest)).rejects.toMatchObject({ + code: 'operation-conflict' + }) + }) + + it.each(['checkpoint-synced', 'checkpoint-renamed', 'log-reset-renamed'] as const)( + 'recovers one state across the %s compaction crash boundary', + async (crashBoundary) => { + const directory = freshDirectory() + const first = await openService(directory, { + ownerToken: 'owner-token-a' + }) + const consumer = await claim(first, 'consumer-a', null, 'consumer-incarnation-a') + const pane = { paneKey: 'pane-a', paneGenerationId: 'generation-a' } + await mutate(first, consumer, 0, 'create', { kind: 'create', pane }) + await stageCompactionCrashState(directory, first, crashBoundary) + + const replacement = await openService(directory, { + ownerToken: 'owner-token-b', + takeoverOwnerToken: 'owner-token-a', + ownerIncarnationId: 'owner-incarnation-b' + }) + const resumed = await claim( + replacement, + 'consumer-a', + 'consumer-incarnation-a', + 'consumer-incarnation-a' + ) + expect((await replacement.snapshotForConsumer(resumed)).authority.panes).toMatchObject([ + { paneKey: 'pane-a', paneGenerationId: 'generation-a' } + ]) + expect(await replacement.readOutcomes(resumed, 0)).toMatchObject({ + kind: 'entries', + entries: [{ outcomeId: terminalAuthorityOperationIdentity(0, 'create').outcomeId }] + }) + } + ) + + it('marks predecessor bindings and pending spawn intents owner-unreachable after restart', async () => { + const directory = freshDirectory() + const first = await openService(directory) + const consumer = await claim(first, 'consumer-a', null, 'consumer-incarnation-a') + const pane = { paneKey: 'pane-a', paneGenerationId: 'generation-a' } + await mutate(first, consumer, 0, 'create', { kind: 'create', pane }) + const allocation = { + allocationId: 'allocation-a', + pane, + ownerIncarnationId: 'owner-incarnation-a', + physicalPtyId: 'pty-a', + spawnFingerprint: 'spawn-a' + } + await mutate(first, consumer, 1, 'prepare', { + kind: 'prepare-allocation', + allocation, + expected: { paneGenerationId: pane.paneGenerationId, binding: null } + }) + await first.compact(first.writerAccess) + await first.close() + + const restarted = await openService(directory, { + ownerToken: 'owner-token-b', + ownerIncarnationId: 'owner-incarnation-b' + }) + const resumed = await claim( + restarted, + 'consumer-a', + 'consumer-incarnation-a', + 'consumer-incarnation-a' + ) + await expect( + mutate(restarted, resumed, 2, 'commit-old-owner', { + kind: 'commit-allocation', + allocation, + ptyIncarnationId: 'pty-incarnation-a', + expected: { paneGenerationId: pane.paneGenerationId, binding: null } + }) + ).rejects.toMatchObject({ code: 'allocation-conflict' }) + await expect( + mutate(restarted, resumed, 2, 'cancel-old-owner', { + kind: 'cancel-allocation', + allocation, + expected: { paneGenerationId: pane.paneGenerationId, binding: null } + }) + ).rejects.toMatchObject({ code: 'allocation-conflict' }) + expect((await restarted.snapshotForConsumer(resumed)).authority.allocations).toMatchObject([ + { allocationId: 'allocation-a', status: 'pending' } + ]) + }) + + it('does not expose restored binding or allocation state through observer projections', async () => { + const directory = freshDirectory() + const first = await openService(directory) + const consumer = await claim(first, 'consumer-a', null, 'consumer-incarnation-a') + const { pane, binding } = await createBoundPane(first, consumer) + await first.compact(first.writerAccess) + await first.close() + + const legacyWorkerAccess = Object.freeze({ + role: 'legacy-worker-owner' as const, + accessId: 'restored-binding-legacy-worker-owner' + }) + const restarted = await openService(directory, { + ownerToken: 'owner-token-b', + ownerIncarnationId: 'owner-incarnation-b', + legacyWorkerAccess + }) + const changes: string[] = [] + restarted.subscribeProjection('restored-binding-subscription', (change) => { + changes.push(change.reason) + }) + const observer = restarted.observe('projection-observer') + const projection = restarted.snapshotForObserver(observer) + expect(() => + Object.assign(projection.panes[0]!.binding!, { physicalPtyId: 'changed-pty' }) + ).toThrow() + expect(() => + Object.assign(projection.allocations[0]!.binding!, { + ptyIncarnationId: 'changed-incarnation' + }) + ).toThrow() + expect(restarted.bindingAuthority(observer, pane, binding)).toBe('owner-unreachable') + await restarted.legacy.setOwnerReachable( + restarted.writerAccess, + legacyWorkerAccess, + binding.ownerIncarnationId, + true + ) + expect(changes).toEqual(['owner-reachability']) + }) + + it.each(['close', 'supersede'] as const)( + 'drops the retired owner-binding index while replaying %s', + async (kind) => { + const directory = freshDirectory() + const first = await openService(directory) + const consumer = await claim(first, 'consumer-a', null, 'consumer-incarnation-a') + const { pane, binding } = await createBoundPane(first, consumer) + const change: TerminalSessionAuthorityChange = + kind === 'close' + ? { + kind, + pane, + expected: { paneGenerationId: pane.paneGenerationId, binding } + } + : { + kind, + pane, + replacementPaneGenerationId: 'generation-b', + expected: { paneGenerationId: pane.paneGenerationId, binding } + } + await mutate(first, consumer, 3, `retire-${kind}`, change) + await first.close() + + const legacyWorkerAccess = Object.freeze({ + role: 'legacy-worker-owner' as const, + accessId: `retired-${kind}-legacy-worker-owner` + }) + const restarted = await openService(directory, { + ownerToken: 'owner-token-b', + ownerIncarnationId: 'owner-incarnation-b', + legacyWorkerAccess + }) + const changes: string[] = [] + restarted.subscribeProjection(`retired-${kind}-subscription`, (change) => { + changes.push(change.reason) + }) + await restarted.legacy.setOwnerReachable( + restarted.writerAccess, + legacyWorkerAccess, + binding.ownerIncarnationId, + true + ) + expect(changes).toEqual([]) + } + ) + + it.each([ + ['close', false], + ['supersede', false], + ['exit', true] + ] as const)( + 'rejects %s when a predecessor still owns the live binding', + async (kind, leaveUnreachable) => { + const directory = freshDirectory() + const first = await openService(directory) + const original = await claim(first, 'consumer-a', null, 'consumer-incarnation-a') + const { pane, binding } = await createBoundPane(first, original) + await first.close() + + const legacyWorkerAccess = Object.freeze({ + role: 'legacy-worker-owner' as const, + accessId: `legacy-worker-owner-${kind}` + }) + const restarted = await openService(directory, { + ownerToken: 'owner-token-b', + ownerIncarnationId: 'owner-incarnation-b', + legacyWorkerAccess + }) + const resumed = await claim( + restarted, + 'consumer-a', + 'consumer-incarnation-a', + 'consumer-incarnation-a' + ) + if (!leaveUnreachable) { + await restarted.legacy.setOwnerReachable( + restarted.writerAccess, + legacyWorkerAccess, + binding.ownerIncarnationId, + true + ) + } + const change: TerminalSessionAuthorityChange = + kind === 'close' + ? { + kind, + pane, + expected: { paneGenerationId: pane.paneGenerationId, binding } + } + : kind === 'supersede' + ? { + kind, + pane, + replacementPaneGenerationId: 'generation-b', + expected: { paneGenerationId: pane.paneGenerationId, binding } + } + : { + kind, + pane, + exit: { code: null, signal: null }, + expected: { paneGenerationId: pane.paneGenerationId, binding } + } + await expect( + mutate(restarted, resumed, 3, `${kind}-old-owner`, change) + ).rejects.toMatchObject({ + code: 'expectation-mismatch' + }) + } + ) + + it('accepts a causal exit after the live-worker path marks its owner reachable', async () => { + const directory = freshDirectory() + const first = await openService(directory) + const original = await claim(first, 'consumer-a', null, 'consumer-incarnation-a') + const { pane, binding } = await createBoundPane(first, original) + await first.close() + + const legacyWorkerAccess = Object.freeze({ + role: 'legacy-worker-owner' as const, + accessId: 'legacy-worker-owner-exit' + }) + const restarted = await openService(directory, { + ownerToken: 'owner-token-b', + ownerIncarnationId: 'owner-incarnation-b', + legacyWorkerAccess + }) + const resumed = await claim( + restarted, + 'consumer-a', + 'consumer-incarnation-a', + 'consumer-incarnation-a' + ) + const changes: string[] = [] + restarted.subscribeProjection('causal-exit-subscription', (change) => { + changes.push(change.reason) + }) + await restarted.legacy.setOwnerReachable( + restarted.writerAccess, + legacyWorkerAccess, + binding.ownerIncarnationId, + true + ) + const exited = await mutate(restarted, resumed, 3, 'causal-predecessor-exit', { + kind: 'exit', + pane, + exit: { code: 0, signal: null }, + expected: { paneGenerationId: pane.paneGenerationId, binding } + }) + expect(exited.result).toMatchObject({ + pane: { status: 'exited', binding: null }, + effects: [{ kind: 'binding-retired' }, { kind: 'terminal-exited', code: 0 }] + }) + await restarted.legacy.setOwnerReachable( + restarted.writerAccess, + legacyWorkerAccess, + binding.ownerIncarnationId, + false + ) + expect(changes).toEqual(['owner-reachability', 'mutation']) + }) + + it('fails closed when a checkpoint outcome contains a forged retirement effect', async () => { + const directory = freshDirectory() + const first = await openService(directory) + const consumer = await claim(first, 'consumer-a', null, 'consumer-incarnation-a') + const { pane, allocation, binding } = await createBoundPane(first, consumer) + await mutate(first, consumer, 3, 'close', { + kind: 'close', + pane, + expected: { paneGenerationId: pane.paneGenerationId, binding } + }) + await first.compact(first.writerAccess) + await first.close() + + const checkpointPath = path.join(serviceDirectory(first), 'authority.checkpoint.json') + const checkpoint = JSON.parse(readFileSync(checkpointPath, 'utf8')) + const closeOutcome = checkpoint.snapshot.outcomes.at(-1) + closeOutcome.result.effects[0].binding = { + ...allocation, + ownerIncarnationId: 'forged-owner', + physicalPtyId: 'forged-pty', + ptyIncarnationId: 'forged-incarnation' + } + closeOutcome.byteLength = outcomeByteLength(closeOutcome) + writeFileSync(checkpointPath, `${JSON.stringify(checkpoint)}\n`) + await expect( + openService(directory, { + ownerToken: 'owner-token-b', + ownerIncarnationId: 'owner-incarnation-b' + }) + ).rejects.toMatchObject({ code: 'record-corrupt' }) + }) + + it('bounds and revokes observer access', async () => { + const service = await openService(freshDirectory(), { maxObservers: 1 }) + const observer = service.observe('observer-a') + expect(service.snapshotForObserver(observer).revision).toBe(0) + expect(() => service.observe('observer-b')).toThrowError( + expect.objectContaining({ code: 'capacity' }) + ) + service.revokeObserver(observer) + expect(() => service.snapshotForObserver(observer)).toThrowError( + expect.objectContaining({ code: 'writer-fenced' }) + ) + }) +}) + +async function createBoundPane( + service: TerminalSessionAuthorityService, + consumer: TerminalAuthorityConsumerAccess +) { + const pane = { paneKey: 'pane-a', paneGenerationId: 'generation-a' } + await mutate(service, consumer, 0, 'create', { kind: 'create', pane }) + const allocation = { + allocationId: 'allocation-a', + pane, + ownerIncarnationId: 'owner-incarnation-a', + physicalPtyId: 'pty-a', + spawnFingerprint: 'spawn-a' + } + await mutate(service, consumer, 1, 'prepare', { + kind: 'prepare-allocation', + allocation, + expected: { paneGenerationId: pane.paneGenerationId, binding: null } + }) + const receipt = await mutate(service, consumer, 2, 'commit', { + kind: 'commit-allocation', + allocation, + ptyIncarnationId: 'pty-incarnation-a', + expected: { paneGenerationId: pane.paneGenerationId, binding: null } + }) + return { pane, allocation, binding: receipt.result.pane.binding! } +} + +async function mutate( + service: TerminalSessionAuthorityService, + consumer: TerminalAuthorityConsumerAccess, + baseRevision: number, + operationId: string, + change: TerminalSessionAuthorityChange +) { + return service.mutate( + service.writerAccess, + mutationRequest(service, consumer, baseRevision, operationId, change) + ) +} + +function mutationRequest( + service: TerminalSessionAuthorityService, + consumer: TerminalAuthorityConsumerAccess, + baseRevision: number, + correlationId: string, + change: TerminalSessionAuthorityChange +) { + return { + actorId: service.writerAccess.actorId, + ...terminalAuthorityOperationIdentity(baseRevision, correlationId), + baseRevision, + consumerId: consumer.consumerId, + change + } +} + +async function claim( + service: TerminalSessionAuthorityService, + consumerId: string, + expectedIncarnationId: string | null, + consumerIncarnationId: string +): Promise { + return service.claimConsumer(service.writerAccess, { + consumerId, + expectedIncarnationId, + consumerIncarnationId + }) +} + +async function openService( + directory: string, + overrides: Partial = {} +): Promise { + const service = await TerminalSessionAuthorityService.open({ + directory: path.join(directory, 'namespace'), + namespace: { authorityHostId: 'host-a', namespaceId: 'namespace-a' }, + ownerToken: 'owner-token-a', + ownerIncarnationId: 'owner-incarnation-a', + writerActorId: 'writer-a', + ...overrides + }) + services.push(service) + return service +} + +function freshDirectory(): string { + const directory = mkdtempSync(path.join(tmpdir(), 'orca-authority-service-')) + directories.push(directory) + return directory +} + +function serviceDirectory(_service: TerminalSessionAuthorityService): string { + return path.join(directories.at(-1)!, 'namespace') +} + +function outcomeByteLength(outcome: Record): number { + const { byteLength: _byteLength, ...base } = outcome + return Buffer.byteLength(JSON.stringify(base), 'utf8') +} + +async function stageCompactionCrashState( + directory: string, + service: TerminalSessionAuthorityService, + boundary: 'checkpoint-synced' | 'checkpoint-renamed' | 'log-reset-renamed' +): Promise { + const namespaceDirectory = path.join(directory, 'namespace') + const logPath = path.join(namespaceDirectory, 'authority.log') + const previousLog = readFileSync(logPath, 'utf8') + if (boundary === 'checkpoint-synced') { + writeSyncedFixture( + path.join( + namespaceDirectory, + `authority.checkpoint.json.${process.pid}.00000000-0000-4000-8000-000000000000.tmp` + ), + 'durable unpublished checkpoint\n' + ) + return + } + await service.compact(service.writerAccess) + if (boundary === 'checkpoint-renamed') { + writeSyncedFixture(logPath, previousLog) + } +} + +function writeSyncedFixture(file: string, contents: string): void { + const descriptor = openSync(file, 'w', 0o600) + try { + writeFileSync(descriptor, contents, 'utf8') + fsyncSync(descriptor) + } finally { + closeSync(descriptor) + } +} diff --git a/src/main/session-authority/terminal-session-authority-service.ts b/src/main/session-authority/terminal-session-authority-service.ts new file mode 100644 index 00000000000..406574c6e51 --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-service.ts @@ -0,0 +1,315 @@ +import type { + TerminalAuthorityNamespace, + TerminalPaneGeneration, + TerminalSessionBinding +} from '../../shared/terminal-session-authority-identity' +import { + failTerminalSessionAuthority, + type TerminalAuthorityProjection, + type TerminalBindingAuthority, + type TerminalSessionAuthorityMutationRequest +} from '../../shared/terminal-session-authority-mutation' +import type { TerminalSessionAuthorityState } from '../../shared/terminal-session-authority-state' +import type { TerminalAuthorityFileStore } from './terminal-session-authority-file-store' +import type { + TerminalAuthorityRuntimeAccess, + TerminalAuthorityConsumerAccess, + TerminalAuthorityObserverAccess, + TerminalAuthorityWriterAccess +} from './terminal-session-authority-access' +import type { + TerminalAuthorityLegacyWorkerAccess, + TerminalAuthorityMutationReceipt, + TerminalAuthorityProjectionListener, + TerminalSessionAuthorityServiceOptions +} from './terminal-session-authority-service-contract' +import type { TerminalAuthorityConsumerAdmissionSeal } from './terminal-session-authority-consumer-admission-seal' +import { openTerminalSessionAuthorityServiceState } from './terminal-session-authority-service-bootstrap' +import { TerminalAuthorityMutationPersistence } from './terminal-session-authority-mutation-persistence' +import { TerminalSessionAuthorityOperationQueue } from './terminal-session-authority-operation-queue' +import { TerminalSessionAuthorityLegacyMigration } from './terminal-session-authority-legacy-migration' +import { TerminalSessionAuthorityLegacyOperations } from './terminal-session-authority-legacy-operations' +import { TerminalAuthorityProjectionSubscriptions } from './terminal-session-authority-projection-subscriptions' +import { + TerminalSessionAuthorityOutcomeConsumers, + type TerminalAuthorityConsumerClaim, + type TerminalAuthorityPendingConsumerSnapshot, + type TerminalAuthoritySemanticOutcomeInput +} from './terminal-session-authority-outcome-consumers' + +export class TerminalSessionAuthorityService { + readonly writerAccess: TerminalAuthorityWriterAccess + readonly namespace: TerminalAuthorityNamespace + private readonly operations = new TerminalSessionAuthorityOperationQueue() + private readonly legacyMigration: TerminalSessionAuthorityLegacyMigration + private readonly persistence: TerminalAuthorityMutationPersistence + private readonly projectionSubscriptions: TerminalAuthorityProjectionSubscriptions + private readonly outcomeConsumers: TerminalSessionAuthorityOutcomeConsumers + readonly legacy: TerminalSessionAuthorityLegacyOperations + + private constructor( + private readonly state: TerminalSessionAuthorityState, + store: TerminalAuthorityFileStore, + private readonly accesses: TerminalAuthorityRuntimeAccess, + legacyWorkerAccess: TerminalAuthorityLegacyWorkerAccess | undefined + ) { + this.namespace = state.namespace + this.writerAccess = accesses.writer + this.persistence = new TerminalAuthorityMutationPersistence(state, store, () => { + this.operations.crash() + }) + this.projectionSubscriptions = new TerminalAuthorityProjectionSubscriptions(state, accesses) + this.legacyMigration = new TerminalSessionAuthorityLegacyMigration( + state, + this.persistence, + accesses.writer.serviceInstanceId, + legacyWorkerAccess + ) + this.legacy = new TerminalSessionAuthorityLegacyOperations({ + state, + migration: this.legacyMigration, + enqueue: (operation) => this.operations.enqueue(operation), + assertWriter: (writer) => this.assertWriter(writer), + assertAccepting: () => this.operations.assertAccepting(), + publish: (reason) => this.projectionSubscriptions.publish(reason) + }) + this.outcomeConsumers = new TerminalSessionAuthorityOutcomeConsumers( + state, + this.persistence, + accesses, + this.legacyMigration + ) + } + + static async open( + options: TerminalSessionAuthorityServiceOptions + ): Promise { + const normalizedOptions = { + ...options, + namespace: Object.freeze({ ...options.namespace }) + } + const opened = await openTerminalSessionAuthorityServiceState(normalizedOptions) + return new TerminalSessionAuthorityService( + opened.state, + opened.store, + opened.accesses, + normalizedOptions.legacyWorkerAccess + ) + } + + claimConsumer( + writer: TerminalAuthorityWriterAccess, + input: TerminalAuthorityConsumerClaim + ): Promise { + return this.commitConsumerAdmission(writer, input) + } + + /** + * The only path that publishes a durable `consumer-claim`. One namespace-queue operation: every + * fallible check, then seal, then append, then a finalizer that cannot fail. Nothing durable is + * rewound — a pre-append or append failure aborts the seal, and a failure after the write fences the + * service, so recovery is a fresh authenticated proof rather than a compensating write. + */ + commitConsumerAdmission( + writer: TerminalAuthorityWriterAccess, + input: TerminalAuthorityConsumerClaim, + seal?: TerminalAuthorityConsumerAdmissionSeal + ): Promise { + const claim = structuredClone(input) + return this.operations.enqueue(async () => { + this.assertWriter(writer) + const plan = await this.outcomeConsumers.planClaim(claim) + seal?.seal() + try { + await this.outcomeConsumers.appendClaim(plan) + } catch (error) { + seal?.abort() + throw error + } + const access = this.outcomeConsumers.settleClaim(plan) + seal?.commit(access) + return access + }) + } + + snapshotForConsumerClaim( + writer: TerminalAuthorityWriterAccess, + input: TerminalAuthorityConsumerClaim + ): Promise { + const claim = structuredClone(input) + return this.operations.enqueue(async () => { + this.assertWriter(writer) + return this.outcomeConsumers.preview(claim) + }) + } + + mutate( + writer: TerminalAuthorityWriterAccess, + unsafeRequest: TerminalSessionAuthorityMutationRequest + ): Promise { + const request = structuredClone(unsafeRequest) + return this.operations.enqueueProducer(async () => { + this.assertWriter(writer) + await this.persistence.assertWriterCurrent() + if (request.actorId !== writer.actorId) { + failTerminalSessionAuthority('writer-fenced', 'mutation authority identity changed') + } + const planned = this.state.planMutation(request) + if (!planned.duplicate) { + this.legacyMigration.assertCanMutate() + await this.persistence.append(Object.freeze({ kind: 'mutation', outcome: planned.outcome })) + this.projectionSubscriptions.publish('mutation') + } + return Object.freeze({ + result: structuredClone(planned.outcome.result), + outcomeSequence: planned.outcome.sequence + }) + }) + } + + recordSemanticOutcome( + writer: TerminalAuthorityWriterAccess, + unsafeInput: TerminalAuthoritySemanticOutcomeInput + ): ReturnType { + const input = structuredClone(unsafeInput) + return this.operations.enqueueProducer(async () => { + this.assertWriter(writer) + const outcome = await this.outcomeConsumers.record(input) + this.projectionSubscriptions.publish('outcome') + return outcome + }) + } + + acknowledgeOutcomes( + consumer: TerminalAuthorityConsumerAccess, + sequence: number + ): Promise { + return this.operations.enqueue(async () => { + this.assertConsumerAccess(consumer) + return this.outcomeConsumers.acknowledge(consumer, sequence) + }) + } + + retireConsumer( + writer: TerminalAuthorityWriterAccess, + consumer: TerminalAuthorityConsumerAccess + ): Promise { + return this.operations.enqueue(async () => { + this.assertWriter(writer) + this.accesses.assertConsumerService(consumer) + return this.outcomeConsumers.retire(consumer) + }) + } + + retireConsumerIdentity( + writer: TerminalAuthorityWriterAccess, + consumerId: string, + expectedIncarnationId: string | null + ): Promise { + return this.operations.enqueue(async () => { + this.assertWriter(writer) + return this.outcomeConsumers.retireIdentity(consumerId, expectedIncarnationId) + }) + } + + readOutcomes( + consumer: TerminalAuthorityConsumerAccess, + afterSequence: number, + limit?: number + ): ReturnType { + return this.operations.enqueue(async () => { + this.assertConsumerAccess(consumer) + return this.outcomeConsumers.read(consumer, afterSequence, limit) + }) + } + + snapshotForConsumer( + consumer: TerminalAuthorityConsumerAccess + ): ReturnType { + return this.operations.enqueue(async () => { + this.assertConsumerAccess(consumer) + return this.outcomeConsumers.snapshot(consumer) + }) + } + + observe(actorId: string): TerminalAuthorityObserverAccess { + this.operations.assertAccepting() + return this.projectionSubscriptions.observe(actorId) + } + + subscribeProjection( + actorId: string, + listener: TerminalAuthorityProjectionListener + ): TerminalAuthorityObserverAccess { + this.operations.assertAccepting() + return this.projectionSubscriptions.subscribe(actorId, listener) + } + + revokeObserver(access: TerminalAuthorityObserverAccess): void { + this.operations.assertAccepting() + this.projectionSubscriptions.revoke(access) + } + + snapshotForObserver(access: TerminalAuthorityObserverAccess): TerminalAuthorityProjection { + this.operations.assertAccepting() + this.accesses.assertObserver(access) + return this.state.projection() + } + + snapshotForWriter(access: TerminalAuthorityWriterAccess): TerminalAuthorityProjection { + this.operations.assertAccepting() + this.accesses.assertWriter(access) + return this.state.projection() + } + + activeConsumerIncarnation( + access: TerminalAuthorityWriterAccess, + consumerId: string + ): string | null { + this.operations.assertAccepting() + this.accesses.assertWriter(access) + return this.state.activeConsumerIncarnation(consumerId) + } + + acquireProducerHold(access: TerminalAuthorityWriterAccess): Readonly<{ release(): void }> { + this.operations.assertAccepting() + this.accesses.assertWriter(access) + return this.operations.acquireProducerHold() + } + + bindingAuthority( + access: TerminalAuthorityWriterAccess | TerminalAuthorityObserverAccess, + pane: TerminalPaneGeneration, + binding: TerminalSessionBinding + ): TerminalBindingAuthority { + this.operations.assertAccepting() + this.accesses.assertBindingAuthorityReader(access) + return this.state.bindingAuthority(pane, binding) + } + + compact(writer: TerminalAuthorityWriterAccess): Promise { + return this.operations.enqueue(async () => { + this.assertWriter(writer) + await this.persistence.compact(this.state.snapshot()) + }) + } + + close(): Promise { + return this.operations.close(async () => { + this.accesses.clear() + this.projectionSubscriptions.clear() + await this.persistence.close() + }) + } + + private assertWriter(access: TerminalAuthorityWriterAccess): void { + this.operations.assertProcessing() + this.accesses.assertWriter(access) + } + + private assertConsumerAccess(access: TerminalAuthorityConsumerAccess): void { + this.operations.assertProcessing() + this.accesses.assertConsumerService(access) + this.state.readOutcomes(access.consumerId, access.consumerIncarnationId, 0, 1) + } +} diff --git a/src/main/session-authority/terminal-session-authority-spawn-state.ts b/src/main/session-authority/terminal-session-authority-spawn-state.ts new file mode 100644 index 00000000000..752259aea3e --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-spawn-state.ts @@ -0,0 +1,155 @@ +import { createHash } from 'node:crypto' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants' +import { isAgentSessionSurfaceBinding } from '../../shared/agent-session-host-authority' +import { makePaneKey } from '../../shared/stable-pane-id' +import type { + TerminalPaneGeneration, + TerminalSessionBinding +} from '../../shared/terminal-session-authority-identity' +import { + failTerminalSessionAuthority, + type TerminalAuthorityProjection, + type TerminalPaneAuthorityProjection +} from '../../shared/terminal-session-authority-mutation' +import { + TERMINAL_SESSION_AUTHORITY_SPAWN_VERSION, + isTerminalSessionAuthorityPaneGeneration +} from '../../shared/terminal-session-authority-wire' +import { splitWorktreeIdForFilesystem } from '../../shared/worktree-id' +import { + terminalAuthorityFloatingLocator, + terminalAuthorityWorkspaceLocator +} from './terminal-session-authority-workspace-locator' + +export function parseTerminalAuthoritySpawnIdentity( + params: Record, + ownerIncarnationId: string +) { + if (params.terminalSessionAuthorityVersion !== TERMINAL_SESSION_AUTHORITY_SPAWN_VERSION) { + throw new Error('terminal_session_authority_metadata_required') + } + const ensure = + typeof params.agentSessionEnsure === 'object' && params.agentSessionEnsure !== null + ? (params.agentSessionEnsure as { surface?: unknown }) + : null + const surface = isAgentSessionSurfaceBinding(ensure?.surface) ? ensure.surface : null + const paneKey = + typeof params.paneKey === 'string' + ? params.paneKey + : surface + ? makePaneKey(surface.tabId, surface.leafId) + : null + if (!paneKey) { + throw new Error('terminal_session_authority_pane_required') + } + const paneGenerationId = isTerminalSessionAuthorityPaneGeneration(params.paneGeneration) + ? `renderer:${params.paneGeneration}` + : surface + ? `agent:${surface.terminalHandle}:${ownerIncarnationId}` + : null + if (!paneGenerationId) { + throw new Error('terminal_session_authority_generation_required') + } + const worktreeId = typeof params.worktreeId === 'string' ? params.worktreeId : surface?.worktreeId + const locator = terminalAuthorityLocatorForWorktreeId(worktreeId) + return Object.freeze({ + pane: Object.freeze({ paneKey, paneGenerationId }), + locator, + locatorKey: JSON.stringify(locator), + spawnFingerprint: createHash('sha256') + .update(JSON.stringify(canonicalJson(spawnFingerprintParams(params)))) + .digest('base64url') + }) +} + +export function findTerminalAuthorityPane( + projection: TerminalAuthorityProjection, + pane: TerminalPaneGeneration +): TerminalPaneAuthorityProjection | null { + return ( + projection.panes.find( + (candidate) => + candidate.paneKey === pane.paneKey && candidate.paneGenerationId === pane.paneGenerationId + ) ?? null + ) +} + +export function latestTerminalAuthorityPane( + projection: TerminalAuthorityProjection, + paneKey: string +): TerminalPaneAuthorityProjection | null { + return ( + projection.panes + .filter((pane) => pane.paneKey === paneKey) + .sort((left, right) => right.revision - left.revision)[0] ?? null + ) +} + +export function exactTerminalAuthorityAllocation( + projection: TerminalAuthorityProjection, + binding: TerminalSessionBinding +) { + const allocation = projection.allocations.find( + (candidate) => + candidate.status === 'committed' && sameTerminalAuthorityBinding(candidate.binding, binding) + ) + if (!allocation || allocation.status !== 'committed') { + failTerminalSessionAuthority('record-corrupt', 'pane binding has no committed allocation') + } + return allocation +} + +export function sameTerminalAuthorityBinding( + left: TerminalSessionBinding | null, + right: TerminalSessionBinding +): boolean { + return Boolean( + left && + left.ownerIncarnationId === right.ownerIncarnationId && + left.physicalPtyId === right.physicalPtyId && + left.ptyIncarnationId === right.ptyIncarnationId + ) +} + +function spawnFingerprintParams(params: Record): Record { + const fingerprint = { ...params } + for (const field of [ + 'agentSessionCreateOperationId', + 'cols', + 'rows', + 'paneKey', + 'paneGeneration', + 'tabId', + 'terminalSessionAuthorityVersion', + 'worktreeId' + ]) { + delete fingerprint[field] + } + return fingerprint +} + +export function terminalAuthorityLocatorForWorktreeId(worktreeId: string | undefined) { + if (worktreeId === FLOATING_TERMINAL_WORKTREE_ID) { + return terminalAuthorityFloatingLocator() + } + const workspacePath = worktreeId ? splitWorktreeIdForFilesystem(worktreeId)?.worktreePath : null + if (!workspacePath) { + throw new Error('terminal_session_authority_workspace_required') + } + return terminalAuthorityWorkspaceLocator(workspacePath) +} + +function canonicalJson(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(canonicalJson) + } + if (typeof value === 'object' && value !== null) { + return Object.fromEntries( + Object.entries(value) + .filter(([, entry]) => entry !== undefined) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entry]) => [key, canonicalJson(entry)]) + ) + } + return value +} diff --git a/src/main/session-authority/terminal-session-authority-temporary-files.ts b/src/main/session-authority/terminal-session-authority-temporary-files.ts new file mode 100644 index 00000000000..790c7c36414 --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-temporary-files.ts @@ -0,0 +1,32 @@ +import { opendir, rm } from 'node:fs/promises' +import path from 'node:path' + +const TEMPORARY_SUFFIX = /\.\d+\.[0-9a-f-]{36}\.tmp$/iu + +export async function removeAuthorityCrashTemporaryFiles( + directory: string, + targetNames: readonly string[] +): Promise { + const prefixes = targetNames.map((name) => `${name}.`) + let entries + try { + entries = await opendir(directory) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return + } + throw error + } + for await (const entry of entries) { + if ( + (entry.isFile() || entry.isDirectory()) && + prefixes.some((prefix) => entry.name.startsWith(prefix)) && + TEMPORARY_SUFFIX.test(entry.name) + ) { + await rm(path.join(directory, entry.name), { + force: true, + recursive: entry.isDirectory() + }) + } + } +} diff --git a/src/main/session-authority/terminal-session-authority-workspace-isolation.test.ts b/src/main/session-authority/terminal-session-authority-workspace-isolation.test.ts new file mode 100644 index 00000000000..152276ae5b4 --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-workspace-isolation.test.ts @@ -0,0 +1,137 @@ +import { createHash } from 'node:crypto' +import path from 'node:path' +import { describe, expect, it } from 'vitest' +import { + assertTerminalAuthorityNamespaceLocator, + terminalAuthorityNamespaceLocatorKey +} from '../../shared/terminal-session-authority-locator' +import { terminalSessionAuthorityNamespaceDirectory } from './terminal-session-authority-namespace-directory' +import { + terminalAuthorityFloatingLocator, + terminalAuthorityWorkspaceLocator +} from './terminal-session-authority-workspace-locator' + +const MAX_CANONICAL_PATH_BYTES = 32 * 1024 + +describe('terminal authority workspace isolation', () => { + it('canonicalizes POSIX, Windows drive, UNC, and device-prefix aliases on the host', () => { + const posix = terminalAuthorityWorkspaceLocator('/client/repo-link/', { + pathFlavor: 'posix', + realpath: () => '/srv/account/repo/', + isDirectory: () => true + }) + const drive = terminalAuthorityWorkspaceLocator('C:\\Users\\Alice\\repo\\', { + pathFlavor: 'windows', + realpath: () => '\\\\?\\C:\\Users\\Alice\\repo\\', + isDirectory: () => true + }) + const deviceDrive = terminalAuthorityWorkspaceLocator('\\\\?\\C:\\Users\\Alice\\repo\\', { + pathFlavor: 'windows', + realpath: () => 'C:\\Users\\Alice\\repo', + isDirectory: () => true + }) + const unc = terminalAuthorityWorkspaceLocator('\\\\server\\share\\repo\\', { + pathFlavor: 'windows', + realpath: () => '\\\\?\\UNC\\Server\\Share\\repo\\', + isDirectory: () => true + }) + + expect(posix).toMatchObject({ canonicalPath: '/srv/account/repo' }) + expect(drive).toMatchObject({ canonicalPath: 'C:/Users/Alice/repo' }) + expect(terminalAuthorityNamespaceLocatorKey(deviceDrive)).toBe( + terminalAuthorityNamespaceLocatorKey(drive) + ) + expect(unc).toMatchObject({ canonicalPath: '//Server/Share/repo' }) + }) + + it('keeps POSIX, Windows, injected delimiters, and floating namespaces distinct', () => { + const keys = [ + terminalAuthorityNamespaceLocatorKey({ + kind: 'workspace', + canonicalPath: '/C:/repo', + pathFlavor: 'posix' + }), + terminalAuthorityNamespaceLocatorKey({ + kind: 'workspace', + canonicalPath: 'C:/repo', + pathFlavor: 'windows' + }), + terminalAuthorityNamespaceLocatorKey({ + kind: 'workspace', + canonicalPath: '/C:/repo","windows","C:/repo', + pathFlavor: 'posix' + }), + terminalAuthorityNamespaceLocatorKey(terminalAuthorityFloatingLocator()) + ] + + expect(new Set(keys).size).toBe(keys.length) + expect(keys).toEqual(keys.map((key) => JSON.stringify(JSON.parse(key)))) + }) + + it('accepts the locator-specific 32 KiB boundary and rejects the next byte', () => { + const overGenericIdLimit = `/${'a'.repeat(2 * 1024)}` + const exactBoundary = `/${'b'.repeat(MAX_CANONICAL_PATH_BYTES - 1)}` + const overBoundary = `${exactBoundary}c` + + expect(() => + assertTerminalAuthorityNamespaceLocator({ + kind: 'workspace', + canonicalPath: overGenericIdLimit, + pathFlavor: 'posix' + }) + ).not.toThrow() + expect(new TextEncoder().encode(exactBoundary)).toHaveLength(MAX_CANONICAL_PATH_BYTES) + expect(() => + assertTerminalAuthorityNamespaceLocator({ + kind: 'workspace', + canonicalPath: exactBoundary, + pathFlavor: 'posix' + }) + ).not.toThrow() + expect(() => + assertTerminalAuthorityNamespaceLocator({ + kind: 'workspace', + canonicalPath: overBoundary, + pathFlavor: 'posix' + }) + ).toThrow('invalid') + }) + + it('rejects cross-flavor roots, incomplete UNC paths, NUL, and multibyte overflow', () => { + const invalid = [ + { canonicalPath: 'C:/repo', pathFlavor: 'posix' as const }, + { canonicalPath: '/srv/repo', pathFlavor: 'windows' as const }, + { canonicalPath: '//server', pathFlavor: 'windows' as const }, + { canonicalPath: '/srv/repo\0other', pathFlavor: 'posix' as const }, + { + canonicalPath: `/${'é'.repeat(MAX_CANONICAL_PATH_BYTES / 2)}`, + pathFlavor: 'posix' as const + } + ] + + for (const locator of invalid) { + expect(() => + assertTerminalAuthorityNamespaceLocator({ kind: 'workspace', ...locator }) + ).toThrow() + } + }) + + it('derives a stable namespace directory from the complete host and namespace tuple', () => { + const namespace = { authorityHostId: 'host-a', namespaceId: 'namespace-a' } + const expectedDigest = createHash('sha256') + .update(namespace.authorityHostId, 'utf8') + .update('\0') + .update(namespace.namespaceId, 'utf8') + .digest('hex') + const expected = path.join('authority-root', 'namespaces', expectedDigest) + + expect(terminalSessionAuthorityNamespaceDirectory('authority-root', namespace)).toBe(expected) + expect(terminalSessionAuthorityNamespaceDirectory('authority-root', namespace)).toBe(expected) + expect( + terminalSessionAuthorityNamespaceDirectory('authority-root', { + authorityHostId: namespace.namespaceId, + namespaceId: namespace.authorityHostId + }) + ).not.toBe(expected) + }) +}) diff --git a/src/main/session-authority/terminal-session-authority-workspace-locator.test.ts b/src/main/session-authority/terminal-session-authority-workspace-locator.test.ts new file mode 100644 index 00000000000..38792009945 --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-workspace-locator.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import { + terminalAuthorityFloatingLocator, + terminalAuthorityWorkspaceLocator +} from './terminal-session-authority-workspace-locator' + +describe('terminal authority workspace locator', () => { + it('uses the authority host realpath for worktrees and folders', () => { + const locator = terminalAuthorityWorkspaceLocator('/ssh-alias/repo-link', { + pathFlavor: 'posix', + realpath: () => '/srv/account/repo', + isDirectory: () => true + }) + expect(locator).toEqual({ + kind: 'workspace', + canonicalPath: '/srv/account/repo', + pathFlavor: 'posix' + }) + }) + + it('normalizes remote Windows device and UNC paths without case folding', () => { + expect( + terminalAuthorityWorkspaceLocator('C:\\Users\\Alice\\repo', { + pathFlavor: 'windows', + realpath: () => '\\\\?\\C:\\Users\\Alice\\repo\\', + isDirectory: () => true + }) + ).toEqual({ + kind: 'workspace', + canonicalPath: 'C:/Users/Alice/repo', + pathFlavor: 'windows' + }) + expect( + terminalAuthorityWorkspaceLocator('\\\\server\\share\\repo', { + pathFlavor: 'windows', + realpath: () => '\\\\?\\UNC\\Server\\Share\\repo', + isDirectory: () => true + }) + ).toEqual({ + kind: 'workspace', + canonicalPath: '//Server/Share/repo', + pathFlavor: 'windows' + }) + }) + + it('fails closed for relative paths and non-directories', () => { + expect(() => + terminalAuthorityWorkspaceLocator('relative/repo', { + pathFlavor: 'posix', + realpath: (value) => value, + isDirectory: () => true + }) + ).toThrow('absolute') + expect(() => + terminalAuthorityWorkspaceLocator('/srv/file', { + pathFlavor: 'posix', + realpath: (value) => value, + isDirectory: () => false + }) + ).toThrow('not a directory') + }) + + it('uses one explicit host-scoped floating locator', () => { + expect(terminalAuthorityFloatingLocator()).toEqual({ kind: 'floating' }) + }) +}) diff --git a/src/main/session-authority/terminal-session-authority-workspace-locator.ts b/src/main/session-authority/terminal-session-authority-workspace-locator.ts new file mode 100644 index 00000000000..1416c0b2e8f --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-workspace-locator.ts @@ -0,0 +1,67 @@ +import { realpathSync, statSync } from 'node:fs' +import { posix, win32 } from 'node:path' +import { + assertTerminalAuthorityNamespaceLocator, + type TerminalAuthorityNamespaceLocator, + type TerminalAuthorityPathFlavor +} from '../../shared/terminal-session-authority-locator' + +type TerminalAuthorityWorkspaceLocatorOptions = Readonly<{ + pathFlavor?: TerminalAuthorityPathFlavor + realpath?: (path: string) => string + isDirectory?: (path: string) => boolean +}> + +export function terminalAuthorityWorkspaceLocator( + workspacePath: string, + options: TerminalAuthorityWorkspaceLocatorOptions = {} +): TerminalAuthorityNamespaceLocator { + const pathFlavor = options.pathFlavor ?? (process.platform === 'win32' ? 'windows' : 'posix') + assertAbsoluteWorkspacePath(workspacePath, pathFlavor) + const realpath = options.realpath ?? realpathSync.native + const canonicalPath = normalizeCanonicalWorkspacePath(realpath(workspacePath), pathFlavor) + const isDirectory = options.isDirectory ?? ((path: string) => statSync(path).isDirectory()) + if (!isDirectory(canonicalPath)) { + throw new Error('terminal authority workspace path is not a directory') + } + const locator = Object.freeze({ kind: 'workspace', canonicalPath, pathFlavor } as const) + assertTerminalAuthorityNamespaceLocator(locator) + return locator +} + +export function terminalAuthorityFloatingLocator(): TerminalAuthorityNamespaceLocator { + return Object.freeze({ kind: 'floating' }) +} + +function assertAbsoluteWorkspacePath( + workspacePath: string, + pathFlavor: TerminalAuthorityPathFlavor +): void { + const absolute = + pathFlavor === 'windows' + ? win32.isAbsolute(workspacePath) && !/^[\\/][^\\/]/.test(workspacePath) + : posix.isAbsolute(workspacePath) + if (!absolute) { + throw new Error('terminal authority workspace path must be absolute') + } +} + +function normalizeCanonicalWorkspacePath( + canonicalPath: string, + pathFlavor: TerminalAuthorityPathFlavor +): string { + if (pathFlavor === 'posix') { + return trimTrailingSeparators(posix.normalize(canonicalPath), '/') + } + const withoutDevicePrefix = canonicalPath + .replace(/^\\\\\?\\UNC\\/i, '\\\\') + .replace(/^\\\\\?\\/, '') + return trimTrailingSeparators(win32.normalize(withoutDevicePrefix).replace(/\\/g, '/'), '/') +} + +function trimTrailingSeparators(value: string, separator: string): string { + if (value === '/' || /^[A-Za-z]:\/$/.test(value) || /^\/\/[^/]+\/[^/]+\/$/.test(value)) { + return value + } + return value.replace(new RegExp(`${separator}+$`), '') +} diff --git a/src/main/session-authority/terminal-session-authority-writer-lock.test.ts b/src/main/session-authority/terminal-session-authority-writer-lock.test.ts new file mode 100644 index 00000000000..ce7c46994ae --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-writer-lock.test.ts @@ -0,0 +1,223 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + acquireTerminalAuthorityWriterGuard, + readTerminalAuthorityWriterGuardOwner, + releaseTerminalAuthorityWriterGuard, + writeTerminalAuthorityWriterMarker +} from './terminal-authority-writer-storage' +import { TerminalAuthorityWriterLock } from './terminal-session-authority-writer-lock' + +const directories: string[] = [] + +afterEach(() => { + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +describe('TerminalAuthorityWriterLock', () => { + it('admits exactly one process writer and advances the durable epoch after clean release', async () => { + const directory = freshDirectory() + const first = await TerminalAuthorityWriterLock.acquire({ + directory, + ownerToken: 'owner-token-a' + }) + await expect( + TerminalAuthorityWriterLock.acquire({ directory, ownerToken: 'owner-token-b' }) + ).rejects.toMatchObject({ code: 'writer-fenced' }) + await first.release() + const second = await TerminalAuthorityWriterLock.acquire({ + directory, + ownerToken: 'owner-token-b' + }) + expect(second.identity.epoch).toBe(2) + await second.release() + }) + + it('requires the exact predecessor token for takeover and fences the old writer', async () => { + const directory = freshDirectory() + const first = await TerminalAuthorityWriterLock.acquire({ + directory, + ownerToken: 'owner-token-a' + }) + await expect( + TerminalAuthorityWriterLock.acquire({ + directory, + ownerToken: 'owner-token-b', + takeoverOwnerToken: 'wrong-owner-token' + }) + ).rejects.toMatchObject({ code: 'writer-fenced' }) + const second = await TerminalAuthorityWriterLock.acquire({ + directory, + ownerToken: 'owner-token-b', + takeoverOwnerToken: 'owner-token-a' + }) + expect(second.identity.epoch).toBe(2) + await expect(first.runExclusive(async () => undefined)).rejects.toMatchObject({ + code: 'writer-fenced' + }) + await second.release() + }) + + it('serializes concurrent critical sections across independent lock handles', async () => { + const directory = freshDirectory() + const first = await TerminalAuthorityWriterLock.acquire({ + directory, + ownerToken: 'owner-token-a' + }) + const order: string[] = [] + await first.runExclusive(async () => { + order.push('first-start') + const fenced = TerminalAuthorityWriterLock.acquire({ + directory, + ownerToken: 'owner-token-b' + }) + await new Promise((resolve) => setTimeout(resolve, 10)) + order.push('first-end') + await expect(fenced).rejects.toMatchObject({ code: 'writer-fenced' }) + }) + expect(order).toEqual(['first-start', 'first-end']) + await first.release() + }) + + it.each(['guard-published', 'marker-published'] as const)( + 'recovers an exact owner after a %s crash without an age heuristic', + async (boundary) => { + const directory = freshDirectory() + await acquireTerminalAuthorityWriterGuard(directory, 'owner-token-a') + if (boundary === 'marker-published') { + await writeTerminalAuthorityWriterMarker(directory, { + version: 1, + ownerToken: 'owner-token-a', + epoch: 1, + active: true + }) + } + expect(await TerminalAuthorityWriterLock.readCurrentOwnerToken(directory)).toBe( + 'owner-token-a' + ) + + const replacement = await TerminalAuthorityWriterLock.acquire({ + directory, + ownerToken: 'owner-token-b', + takeoverOwnerToken: 'owner-token-a', + allowUninitializedTakeover: true + }) + expect(replacement.identity.epoch).toBe(boundary === 'guard-published' ? 1 : 2) + await replacement.release() + } + ) + + it('admits only one concurrent first-owner contender', async () => { + const directory = freshDirectory() + const contenders = await Promise.allSettled([ + TerminalAuthorityWriterLock.acquire({ directory, ownerToken: 'owner-token-a' }), + TerminalAuthorityWriterLock.acquire({ directory, ownerToken: 'owner-token-b' }) + ]) + const winners = contenders.filter( + (result): result is PromiseFulfilledResult => + result.status === 'fulfilled' + ) + const losers = contenders.filter( + (result): result is PromiseRejectedResult => result.status === 'rejected' + ) + expect(winners).toHaveLength(1) + expect(losers).toHaveLength(1) + expect(losers[0]?.reason).toMatchObject({ code: 'writer-fenced' }) + await winners[0]!.value.release() + }) + + it('never lets a stale release remove its successor guard', async () => { + const directory = freshDirectory() + const stale = await acquireTerminalAuthorityWriterGuard(directory, 'owner-token-a') + expect(await TerminalAuthorityWriterLock.clearProvenGuard(directory, 'owner-token-a')).toBe( + true + ) + const current = await acquireTerminalAuthorityWriterGuard(directory, 'owner-token-b') + + await expect(releaseTerminalAuthorityWriterGuard(directory, stale)).rejects.toMatchObject({ + code: 'writer-fenced' + }) + expect(await readTerminalAuthorityWriterGuardOwner(directory)).toBe('owner-token-b') + await releaseTerminalAuthorityWriterGuard(directory, current) + }) + + it('fences a stale critical-section release after exact guard replacement', async () => { + const directory = freshDirectory() + const writer = await TerminalAuthorityWriterLock.acquire({ + directory, + ownerToken: 'owner-token-a' + }) + let finishOperation!: () => void + let operationEntered!: () => void + const entered = new Promise((resolve) => { + operationEntered = resolve + }) + const finish = new Promise((resolve) => { + finishOperation = resolve + }) + const running = writer.runExclusive(async () => { + operationEntered() + await finish + }) + await entered + expect(await TerminalAuthorityWriterLock.clearProvenGuard(directory, 'owner-token-a')).toBe( + true + ) + const successor = await acquireTerminalAuthorityWriterGuard(directory, 'owner-token-b') + finishOperation() + + await expect(running).rejects.toMatchObject({ code: 'writer-fenced' }) + expect(await readTerminalAuthorityWriterGuardOwner(directory)).toBe('owner-token-b') + await releaseTerminalAuthorityWriterGuard(directory, successor) + }) + + it('admits only one concurrent recoverer without deleting the winner', async () => { + const directory = freshDirectory() + await acquireTerminalAuthorityWriterGuard(directory, 'owner-token-a') + await writeTerminalAuthorityWriterMarker(directory, { + version: 1, + ownerToken: 'owner-token-a', + epoch: 1, + active: true + }) + const contenders = await Promise.allSettled([ + TerminalAuthorityWriterLock.acquire({ + directory, + ownerToken: 'owner-token-b', + takeoverOwnerToken: 'owner-token-a' + }), + TerminalAuthorityWriterLock.acquire({ + directory, + ownerToken: 'owner-token-c', + takeoverOwnerToken: 'owner-token-a' + }) + ]) + const winners = contenders.filter( + (result): result is PromiseFulfilledResult => + result.status === 'fulfilled' + ) + expect(winners).toHaveLength(1) + expect(await TerminalAuthorityWriterLock.readCurrentOwnerToken(directory)).toBe( + winners[0]!.value.identity.ownerToken + ) + await winners[0]!.value.release() + }) + + it('normalizes empty writer markers to record corruption', async () => { + const directory = freshDirectory() + writeFileSync(path.join(directory, 'authority-writer.json'), '') + await expect( + TerminalAuthorityWriterLock.readCurrentOwnerToken(directory) + ).rejects.toMatchObject({ code: 'record-corrupt' }) + }) +}) + +function freshDirectory(): string { + const directory = mkdtempSync(path.join(tmpdir(), 'orca-authority-lock-')) + directories.push(directory) + return directory +} diff --git a/src/main/session-authority/terminal-session-authority-writer-lock.ts b/src/main/session-authority/terminal-session-authority-writer-lock.ts new file mode 100644 index 00000000000..dc20572c9b7 --- /dev/null +++ b/src/main/session-authority/terminal-session-authority-writer-lock.ts @@ -0,0 +1,178 @@ +import { mkdir } from 'node:fs/promises' +import path from 'node:path' +import { + assertAuthorityId, + assertAuthorityStoragePath, + type TerminalAuthorityWriterIdentity +} from '../../shared/terminal-session-authority-identity' +import { failTerminalSessionAuthority } from '../../shared/terminal-session-authority-mutation' +import { + acquireTerminalAuthorityWriterGuard, + clearTerminalAuthorityWriterGuard, + readTerminalAuthorityWriterGuardOwner, + readTerminalAuthorityWriterMarker, + releaseTerminalAuthorityWriterGuard, + removeTerminalAuthorityWriterCrashFiles, + writeTerminalAuthorityWriterMarker, + type TerminalAuthorityWriterMarker +} from './terminal-authority-writer-storage' + +export type TerminalAuthorityWriterLockOptions = Readonly<{ + directory: string + ownerToken: string + /** The caller must prove the predecessor process is dead before supplying this token. */ + takeoverOwnerToken?: string + /** Allows takeover of a crash guard written before its first marker. */ + allowUninitializedTakeover?: boolean +}> + +export type TerminalAuthorityWriterClaim = Readonly<{ + markerOwnerToken: string | null + guardOwnerToken: string | null +}> + +export class TerminalAuthorityWriterLock { + readonly identity: TerminalAuthorityWriterIdentity + readonly replacedOwnerToken: string | null + private released = false + + private constructor( + private readonly directory: string, + marker: TerminalAuthorityWriterMarker, + replacedOwnerToken: string | null + ) { + this.identity = Object.freeze({ ownerToken: marker.ownerToken, epoch: marker.epoch }) + this.replacedOwnerToken = replacedOwnerToken + } + + static async acquire( + options: TerminalAuthorityWriterLockOptions + ): Promise { + assertAuthorityStoragePath(options.directory, 'writer lock directory') + assertAuthorityId(options.ownerToken, 'writer ownerToken') + if (options.takeoverOwnerToken !== undefined) { + assertAuthorityId(options.takeoverOwnerToken, 'takeover ownerToken') + } + const directory = path.resolve(options.directory) + await mkdir(directory, { recursive: true }) + if (options.takeoverOwnerToken) { + await clearProvenStaleGuard( + directory, + options.takeoverOwnerToken, + options.allowUninitializedTakeover === true + ) + } + const guard = await acquireTerminalAuthorityWriterGuard(directory, options.ownerToken) + let marker: TerminalAuthorityWriterMarker + let previous: TerminalAuthorityWriterMarker | null + try { + await removeTerminalAuthorityWriterCrashFiles(directory) + previous = await readTerminalAuthorityWriterMarker(directory) + marker = nextWriterMarker(previous, options) + await writeTerminalAuthorityWriterMarker(directory, marker) + } catch (error) { + await releaseTerminalAuthorityWriterGuard(directory, guard) + throw error + } + await releaseTerminalAuthorityWriterGuard(directory, guard) + return new TerminalAuthorityWriterLock( + directory, + marker, + previous?.active ? previous.ownerToken : null + ) + } + + static async readCurrentOwnerToken(directory: string): Promise { + const claim = await this.readCurrentOwnerClaim(directory) + return claim.guardOwnerToken ?? claim.markerOwnerToken + } + + static async readCurrentOwnerClaim(directory: string): Promise { + assertAuthorityStoragePath(directory, 'writer lock directory') + const resolved = path.resolve(directory) + const marker = await readTerminalAuthorityWriterMarker(resolved) + return Object.freeze({ + markerOwnerToken: marker?.active ? marker.ownerToken : null, + guardOwnerToken: await readTerminalAuthorityWriterGuardOwner(resolved) + }) + } + + static async clearProvenGuard(directory: string, ownerToken: string): Promise { + assertAuthorityStoragePath(directory, 'writer lock directory') + assertAuthorityId(ownerToken, 'writer guard ownerToken') + return clearTerminalAuthorityWriterGuard(path.resolve(directory), ownerToken) + } + + async runExclusive(operation: () => Promise): Promise { + if (this.released) { + failTerminalSessionAuthority('writer-fenced', 'authority writer lock is released') + } + const guard = await acquireTerminalAuthorityWriterGuard( + this.directory, + this.identity.ownerToken + ) + try { + const current = await readTerminalAuthorityWriterMarker(this.directory) + if ( + !current?.active || + current.ownerToken !== this.identity.ownerToken || + current.epoch !== this.identity.epoch + ) { + failTerminalSessionAuthority('writer-fenced', 'authority writer was replaced') + } + return await operation() + } finally { + await releaseTerminalAuthorityWriterGuard(this.directory, guard) + } + } + + async release(): Promise { + if (this.released) { + return + } + await this.runExclusive(async () => { + await writeTerminalAuthorityWriterMarker(this.directory, { + version: 1, + ownerToken: this.identity.ownerToken, + epoch: this.identity.epoch, + active: false + }) + }) + this.released = true + } +} + +function nextWriterMarker( + previous: TerminalAuthorityWriterMarker | null, + options: TerminalAuthorityWriterLockOptions +): TerminalAuthorityWriterMarker { + if (!previous) { + // A registry takeover opens namespace stores lazily; an untouched namespace has no marker. + return Object.freeze({ version: 1, ownerToken: options.ownerToken, epoch: 1, active: true }) + } + if (previous.active && previous.ownerToken !== options.takeoverOwnerToken) { + failTerminalSessionAuthority('writer-fenced', 'another authority writer is active') + } + return Object.freeze({ + version: 1, + ownerToken: options.ownerToken, + epoch: previous.epoch + 1, + active: true + }) +} + +async function clearProvenStaleGuard( + directory: string, + takeoverOwnerToken: string, + allowUninitializedTakeover: boolean +): Promise { + const marker = await readTerminalAuthorityWriterMarker(directory) + const guardOwnerToken = await readTerminalAuthorityWriterGuardOwner(directory) + if ( + guardOwnerToken === takeoverOwnerToken && + ((marker?.active && marker.ownerToken === takeoverOwnerToken) || + (!marker && allowUninitializedTakeover)) + ) { + await clearTerminalAuthorityWriterGuard(directory, takeoverOwnerToken) + } +} diff --git a/src/main/ssh/__tests__/ssh-legacy-migration-evidence.ts b/src/main/ssh/__tests__/ssh-legacy-migration-evidence.ts new file mode 100644 index 00000000000..c2bd3cd954e --- /dev/null +++ b/src/main/ssh/__tests__/ssh-legacy-migration-evidence.ts @@ -0,0 +1,120 @@ +import { TERMINAL_LEGACY_CUTOVER_CAPABILITY } from '../../../shared/terminal-legacy-cutover' +import type { + LegacyPhysicalWorkerDescriptor, + SshLegacyMigrationEvidenceProvider, + SshLegacyPhysicalWorkerInspection +} from '../ssh-legacy-migration-coordinator-types' +import { sshLegacyEvidenceDigest } from '../ssh-legacy-migration-evidence-identity' +import type { SshLegacyMigrationInventoryInput } from '../ssh-legacy-migration-inventory-types' + +export const SSH_LEGACY_TEST_CAPABILITIES = Object.freeze([TERMINAL_LEGACY_CUTOVER_CAPABILITY]) + +export function descriptorForInventory( + inventory: SshLegacyMigrationInventoryInput, + workerIndex = 0 +): LegacyPhysicalWorkerDescriptor { + const relay = inventory.liveRelays[workerIndex] + const endpoint = relay.identityProof.expectedEndpoint + const process = relay.identityProof.expectedProcess + if (!endpoint || !process) { + throw new Error('test inventory requires exact worker identity') + } + const base = { + version: 1 as const, + workerId: relay.workerId, + routeId: `route-${relay.workerId}`, + ownerIncarnationId: `owner-${relay.workerId}`, + buildId: relay.buildId, + clientInstanceId: `client-${relay.workerId}`, + relayDirectory: `/relay/${relay.workerId}`, + process, + expectedEndpoint: endpoint, + requestedSourceWindowSu: 1024, + publicCredentialFile: `/relay/${relay.workerId}/credential`, + privateCredentialFile: `/authority/${relay.workerId}/credential`, + privateStateDirectory: `/authority/${relay.workerId}` + } + return endpoint.kind === 'windows-named-pipe' + ? Object.freeze({ + ...base, + platform: 'win32' as const, + pipeName: endpoint.pipeName, + activePipeMarkerPath: `C:/relay/${relay.workerId}/active-pipe`, + privateActivePipeMarkerPath: `C:/authority/${relay.workerId}/active-pipe` + }) + : Object.freeze({ + ...base, + platform: 'linux' as const, + publicSocketPath: `/relay/${relay.workerId}/relay.sock`, + privateSocketPath: `/authority/${relay.workerId}/relay.sock` + }) +} + +export function inspectionForWorker( + inventory: SshLegacyMigrationInventoryInput, + descriptor: LegacyPhysicalWorkerDescriptor +): SshLegacyPhysicalWorkerInspection { + const relay = inventory.liveRelays.find((entry) => entry.workerId === descriptor.workerId) + if (!relay) { + throw new Error('test worker is missing from inventory') + } + const ptys = relay.rows.map((row) => + Object.freeze({ + id: row.physicalPtyId, + incarnationId: row.ptyIncarnationId ?? '', + processId: row.processId, + cwd: row.serialized.cwd ?? '', + title: 'shell', + ...(row.serialized.worktreeId ? { worktreeId: row.serialized.worktreeId } : {}), + serialized: row.serialized + }) + ) + const evidence = { + protocolVersion: 1 as const, + workerId: descriptor.workerId, + routeId: descriptor.routeId, + buildId: descriptor.buildId, + identityProof: relay.identityProof, + ptys: Object.freeze(ptys) + } + return Object.freeze({ + ...evidence, + preparation: Object.freeze({ + mode: 'observational' as const, + token: `token-${descriptor.workerId}`, + evidenceDigest: sshLegacyEvidenceDigest(evidence), + catalogValidation: 'before-isolation' as const, + replay: 'durable-operation-id' as const + }) + }) +} + +export function evidenceProviderForInventory( + inventory: SshLegacyMigrationInventoryInput, + workers: readonly LegacyPhysicalWorkerDescriptor[] +): SshLegacyMigrationEvidenceProvider { + return Object.freeze({ + discoverWorkers: async () => Object.freeze({ kind: 'ready' as const, workers }), + buildInventory: async () => Object.freeze({ kind: 'ready' as const, inventory }) + }) +} + +export function combineSshLegacyInventories( + first: SshLegacyMigrationInventoryInput, + second: SshLegacyMigrationInventoryInput +): SshLegacyMigrationInventoryInput { + return Object.freeze({ + ...first, + persistedConsumerRecoveries: Object.freeze([ + ...first.persistedConsumerRecoveries, + ...second.persistedConsumerRecoveries + ]), + persistedPtyLeases: Object.freeze([...first.persistedPtyLeases, ...second.persistedPtyLeases]), + localLayoutPanes: Object.freeze([...first.localLayoutPanes, ...second.localLayoutPanes]), + remoteSnapshotPanes: Object.freeze([ + ...first.remoteSnapshotPanes, + ...second.remoteSnapshotPanes + ]), + liveRelays: Object.freeze([...first.liveRelays, ...second.liveRelays]) + }) +} diff --git a/src/main/ssh/__tests__/ssh-legacy-migration-future-rpc.ts b/src/main/ssh/__tests__/ssh-legacy-migration-future-rpc.ts new file mode 100644 index 00000000000..0ef0bc506a8 --- /dev/null +++ b/src/main/ssh/__tests__/ssh-legacy-migration-future-rpc.ts @@ -0,0 +1,235 @@ +import type { + TerminalLegacyEndpointIdentity, + TerminalLegacyMigrationImportRequest, + TerminalLegacyMigrationReceipt, + TerminalLegacyUnresolvedCandidate, + TerminalLegacyWorkerRoute +} from '../../../shared/terminal-legacy-cutover' +import type { SshMultiplexerRequestOptions } from '../ssh-channel-multiplexer' +import type { + LegacyPhysicalWorkerDescriptor, + SshLegacyInspectedWorker, + SshLegacyPhysicalWorkerInspection +} from '../ssh-legacy-migration-coordinator-types' +import type { SshLegacyWorkerCatalog } from '../ssh-legacy-migration-rpc' + +type FutureRpcOptions = Readonly<{ + committedInitially?: boolean + loseMigrationResponseOnce?: boolean + barrierError?: Error + gcError?: Error +}> + +export class FutureSshLegacyMigrationRpc { + readonly calls: { + method: string + params: Record + signal: AbortSignal | undefined + }[] = [] + private readonly inspections = new Map() + private readonly commits = new Map() + private revision = 0 + private responseLost = false + + constructor( + prepared: readonly SshLegacyInspectedWorker[], + private readonly options: FutureRpcOptions = {} + ) { + prepared.forEach(({ descriptor, inspection }) => + this.inspections.set(descriptor.routeId, inspection) + ) + } + + async request( + method: string, + params: Record = {}, + options?: SshMultiplexerRequestOptions + ): Promise { + options?.signal?.throwIfAborted() + this.calls.push({ method, params, signal: options?.signal }) + if (method.endsWith('.inspect')) { + return this.inspection(params) + } + if (method.endsWith('.migrate')) { + return this.migrate(params) + } + if (method.endsWith('.gcProtection')) { + return { catalogRevision: this.revision, protection: emptyProtection() } + } + if (method.endsWith('.migrationBarrier')) { + if (this.options.barrierError) { + throw this.options.barrierError + } + return { + version: 1, + barrierId: params.barrierId, + catalogRevision: params.expectedCatalogRevision, + committedAtMs: 9 + } + } + if (method.endsWith('.gc')) { + if (this.options.gcError) { + throw this.options.gcError + } + return { removed: ['/old/relay'], protected: emptyProtection() } + } + throw new Error(`unexpected test RPC: ${method}`) + } + + private inspection(params: Record): SshLegacyPhysicalWorkerInspection { + const routeId = (params.worker as LegacyPhysicalWorkerDescriptor).routeId + const inspection = this.inspections.get(routeId) + if (!inspection) { + throw new Error('test inspection is missing') + } + return inspection + } + + private migrate(params: Record): unknown { + if (this.options.committedInitially && !this.commits.has(String(params.operationId))) { + return this.commit(params, true) + } + const result = this.commit(params, false) + if (this.options.loseMigrationResponseOnce && !this.responseLost) { + this.responseLost = true + throw new Error('connection lost after durable commit') + } + return result + } + + private commit(params: Record, duplicate: boolean): unknown { + const operationId = String(params.operationId) + const existing = this.commits.get(operationId) + if (existing) { + return { ...(existing as Record), duplicate: true } + } + const worker = params.worker as LegacyPhysicalWorkerDescriptor + const catalog = params.catalog as SshLegacyWorkerCatalog + const receipt = migrationReceipt(worker, catalog, ++this.revision) + const result = { + operationId, + inspectionToken: params.inspectionToken, + duplicate, + receipt + } + this.commits.set(operationId, result) + return result + } +} + +function migrationReceipt( + worker: LegacyPhysicalWorkerDescriptor, + catalog: SshLegacyWorkerCatalog, + sequence: number +): TerminalLegacyMigrationReceipt { + const route = workerRoute(worker) + const request: TerminalLegacyMigrationImportRequest = Object.freeze({ + version: 1, + mode: 'cutover', + migrationId: catalog.migrationId, + authorityHostId: catalog.authorityHostId, + requestedAtMs: catalog.requestedAtMs, + workerRoute: route, + cutover: cutoverProof(worker, route.endpoint), + imports: catalog.imports, + unresolved: Object.freeze(catalog.unresolved.map((candidate) => isolatedCandidate(candidate))) + }) + return Object.freeze({ + version: 1, + receiptId: catalog.migrationId, + sequence, + committedAtMs: 8, + request, + recoveries: Object.freeze([]) + }) +} + +function workerRoute(worker: LegacyPhysicalWorkerDescriptor): TerminalLegacyWorkerRoute { + const socketPath = worker.platform === 'win32' ? worker.pipeName : worker.privateSocketPath + return Object.freeze({ + routeId: worker.routeId, + workerId: worker.workerId, + ownerIncarnationId: worker.ownerIncarnationId, + buildId: worker.buildId, + relayDirectory: worker.relayDirectory, + socketPath, + credentialFile: worker.privateCredentialFile, + process: worker.process, + endpoint: worker.expectedEndpoint, + sourceOwner: Object.freeze({ + clientInstanceId: worker.clientInstanceId, + ownerGeneration: 1, + ownerLease: `lease-${worker.workerId}`, + outputWindowSourceUnits: worker.requestedSourceWindowSu + }), + gcProtection: Object.freeze({ + relayDirectories: Object.freeze([worker.relayDirectory]), + evidencePaths: Object.freeze([socketPath, worker.privateCredentialFile]) + }) + }) +} + +function cutoverProof( + worker: LegacyPhysicalWorkerDescriptor, + endpoint: TerminalLegacyEndpointIdentity +): Extract['cutover'] { + const base = { + publicCredentialFile: worker.publicCredentialFile, + privateCredentialFile: worker.privateCredentialFile, + brokerClientCount: 1 as const, + acceptedConnectionCount: 1, + quiescenceSamples: 2, + connectionProof: Object.freeze({ + method: + worker.platform === 'win32' + ? ('windows-pipe-process' as const) + : ('linux-procfs-unix' as const), + listenerIdentity: `listener-${worker.workerId}`, + brokerConnectionIdentity: `broker-${worker.workerId}`, + acceptedServerConnections: 1 as const + }), + graceConfiguration: Object.freeze({ + capabilityVersion: 1 as const, + configuredGraceMs: 0 as const, + acknowledged: true as const + }), + sealedAtMs: 7 + } + if (worker.platform === 'win32' && endpoint.kind === 'windows-named-pipe') { + return Object.freeze({ + ...base, + kind: 'windows-sealed' as const, + originalPipeName: worker.pipeName, + activePipeMarkerIgnored: true as const, + endpointIdentity: endpoint + }) + } + const posixWorker = worker as Extract< + LegacyPhysicalWorkerDescriptor, + { platform: 'linux' | 'darwin' } + > + return Object.freeze({ + ...base, + kind: 'posix-relocated' as const, + publicSocketPath: posixWorker.publicSocketPath, + privateSocketPath: posixWorker.privateSocketPath, + endpointIdentity: endpoint as Extract + }) +} + +function isolatedCandidate( + candidate: TerminalLegacyUnresolvedCandidate +): TerminalLegacyUnresolvedCandidate { + return Object.freeze({ + ...candidate, + preservation: Object.freeze({ + kind: 'isolated-grace-disabled', + endpointIdentityRetained: true, + graceDisabledAcknowledged: true + }) + }) +} + +function emptyProtection() { + return { relayDirectories: [], evidencePaths: [] } +} diff --git a/src/main/ssh/__tests__/ssh-legacy-migration-inventory.ts b/src/main/ssh/__tests__/ssh-legacy-migration-inventory.ts new file mode 100644 index 00000000000..b495406caf4 --- /dev/null +++ b/src/main/ssh/__tests__/ssh-legacy-migration-inventory.ts @@ -0,0 +1,161 @@ +import type { TerminalLegacyWorkspaceEvidence } from '../../../shared/terminal-legacy-cutover' +import type { TerminalAuthorityPathFlavor } from '../../../shared/terminal-session-authority-locator' +import { toAppSshPtyId } from '../../../shared/ssh-pty-id' +import type { SshLegacyMigrationInventoryInput } from '../ssh-legacy-migration-inventory-types' + +export const SSH_LEGACY_SCENARIO_TARGET = 'target-a' + +export type SshLegacyInventoryScenarioOptions = Readonly<{ + targetId?: string + authorityHostId?: string + workerId?: string + buildId?: string + physicalPtyId?: string + ptyIncarnationId?: string + processId?: number + tabId?: string + leafId?: string + rendererGeneration?: number + namespaceId?: string + localPartitionId?: string + snapshotPartitionId?: string + hostPathFlavor?: TerminalAuthorityPathFlavor + workspace?: TerminalLegacyWorkspaceEvidence +}> + +export function makeSshLegacyInventoryScenario( + options: SshLegacyInventoryScenarioOptions = {} +): SshLegacyMigrationInventoryInput { + const targetId = options.targetId ?? SSH_LEGACY_SCENARIO_TARGET + const authorityHostId = options.authorityHostId ?? 'authority-host-a' + const workerId = options.workerId ?? 'worker-a' + const buildId = options.buildId ?? 'build-a' + const physicalPtyId = options.physicalPtyId ?? 'pty-1' + const ptyIncarnationId = options.ptyIncarnationId ?? 'incarnation-1' + const processId = options.processId ?? 4_201 + const tabId = options.tabId ?? 'tab-a' + const leafId = options.leafId ?? 'leaf-a' + const rendererGeneration = options.rendererGeneration ?? 0 + const hostPathFlavor = options.hostPathFlavor ?? 'posix' + const workspace = options.workspace ?? gitWorkspace('/srv/private/repo-a', 'repo-a::worktree-a') + const namespace = Object.freeze({ + authorityHostId, + namespaceId: options.namespaceId ?? 'namespace-a' + }) + const appPtyId = toAppSshPtyId(targetId, physicalPtyId) + const paneKey = `${tabId}:${leafId}` + const worktreeId = workspace.kind === 'git-worktree' ? workspace.worktreeId : undefined + const serializedWorktreeId = worktreeId ?? null + const cwd = workspace.locator.kind === 'workspace' ? workspace.locator.canonicalPath : '/tmp' + const pane = Object.freeze({ + targetId, + partitionId: options.localPartitionId ?? 'local-partition-a', + ptyId: appPtyId, + paneKey, + tabId, + leafId, + rendererGeneration, + namespace, + workspace + }) + const endpoint = Object.freeze({ + kind: 'unix-socket' as const, + device: '11', + inode: '22', + changedAtNs: '33' + }) + const relayProcess = Object.freeze({ pid: 9_001, birthMarker: 'relay-birth-a' }) + + return Object.freeze({ + targetId, + authorityHostId, + hostPathFlavor, + persistedConsumerRecoveries: Object.freeze([ + Object.freeze({ + targetId, + workerId, + clientInstanceId: 'consumer-recovery-a', + serverBuildId: buildId + }) + ]), + persistedPtyLeases: Object.freeze([ + Object.freeze({ + targetId, + ptyId: appPtyId, + incarnationId: ptyIncarnationId, + ...(worktreeId ? { worktreeId } : {}), + tabId, + leafId, + paneGeneration: rendererGeneration, + state: 'expired' as const, + createdAt: 1, + updatedAt: 2 + }) + ]), + localLayoutPanes: Object.freeze([pane]), + remoteSnapshotPanes: Object.freeze([ + Object.freeze({ + ...pane, + partitionId: options.snapshotPartitionId ?? 'remote-partition-a' + }) + ]), + liveRelays: Object.freeze([ + Object.freeze({ + workerId, + buildId, + observedAtMs: 123, + identityProof: Object.freeze({ + expectedEndpoint: endpoint, + observedEndpoint: endpoint, + expectedProcess: relayProcess, + observedProcess: relayProcess + }), + rows: Object.freeze([ + Object.freeze({ + workerId, + buildId, + physicalPtyId, + ptyIncarnationId, + processId, + namespace, + workspace, + serialized: Object.freeze({ + paneKey, + tabId, + worktreeId: serializedWorktreeId, + cwd, + ptyIncarnationId, + processId + }) + }) + ]) + }) + ]) + }) +} + +export function gitWorkspace( + canonicalPath: string, + worktreeId: string, + pathFlavor: TerminalAuthorityPathFlavor = 'posix' +): TerminalLegacyWorkspaceEvidence { + return Object.freeze({ + kind: 'git-worktree', + locator: Object.freeze({ kind: 'workspace', canonicalPath, pathFlavor }), + worktreeId + }) +} + +export function folderWorkspace( + canonicalPath: string, + pathFlavor: TerminalAuthorityPathFlavor = 'posix' +): TerminalLegacyWorkspaceEvidence { + return Object.freeze({ + kind: 'folder', + locator: Object.freeze({ kind: 'workspace', canonicalPath, pathFlavor }) + }) +} + +export function floatingWorkspace(): TerminalLegacyWorkspaceEvidence { + return Object.freeze({ kind: 'floating', locator: Object.freeze({ kind: 'floating' }) }) +} diff --git a/src/main/ssh/__tests__/ssh-terminal-authority-app-transport.ts b/src/main/ssh/__tests__/ssh-terminal-authority-app-transport.ts new file mode 100644 index 00000000000..63d19f5ee2b --- /dev/null +++ b/src/main/ssh/__tests__/ssh-terminal-authority-app-transport.ts @@ -0,0 +1,220 @@ +import { vi } from 'vitest' +import { + TERMINAL_AUTHORITY_CONSUMER_PROOF_ALGORITHM, + TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION, + type TerminalAuthorityNamespaceAdmissionChallenge, + type TerminalAuthorityNamespaceAdmissionGrant, + type TerminalAuthorityNamespaceAdmissionStart +} from '../../../shared/terminal-session-authority-consumer-proof' +import { + TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_ALGORITHM, + TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_VERSION +} from '../../../shared/terminal-session-authority-consumer-retirement' +import type { TerminalAuthorityNamespace } from '../../../shared/terminal-session-authority-identity' +import { TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_VERSION } from '../../../shared/terminal-session-authority-consumer-transport' +import { + createTerminalAuthorityProofEphemeralKeypair, + terminalAuthorityAdmissionCas, + terminalAuthorityHostAppConsumerId, + terminalAuthorityRetirementCas +} from '../../session-authority/terminal-session-authority-consumer-proof' +import type { TerminalAuthorityPolicyOutcomeTransport } from '../../session-authority/terminal-session-authority-policy-consumers' +import type { SshChannelMultiplexer } from '../ssh-channel-multiplexer' + +export const SSH_AUTHORITY_HOST_ID = 'authority-host:ssh-app-transport-test' +export const SSH_AUTHORITY_APP_KEYPAIR = createTerminalAuthorityProofEphemeralKeypair() + +export type FakeSshAuthorityRequest = Readonly<{ + method: string + params: Record | undefined +}> + +export class FakeSshAuthorityMux { + readonly requests: FakeSshAuthorityRequest[] = [] + private readonly notificationHandlers = new Map< + string, + Set<(params: Record) => void> + >() + private readonly disposeHandlers = new Set<() => void>() + private disposed = false + + constructor( + private readonly handleRequest: ( + method: string, + params: Record | undefined + ) => Promise + ) {} + + async request(method: string, params?: Record): Promise { + if (this.disposed) { + throw new Error('mux disposed') + } + this.requests.push(Object.freeze({ method, params })) + return await this.handleRequest(method, params) + } + + onNotificationByMethod( + method: string, + handler: (params: Record) => void + ): () => void { + const handlers = this.notificationHandlers.get(method) ?? new Set() + handlers.add(handler) + this.notificationHandlers.set(method, handlers) + return () => handlers.delete(handler) + } + + onDispose(handler: () => void): () => void { + this.disposeHandlers.add(handler) + return () => this.disposeHandlers.delete(handler) + } + + isDisposed(): boolean { + return this.disposed + } + + emit(method: string, params: Record): void { + for (const handler of this.notificationHandlers.get(method) ?? []) { + handler(params) + } + } + + dispose(): void { + if (this.disposed) { + return + } + this.disposed = true + for (const handler of this.disposeHandlers) { + handler() + } + } + + asMux(): SshChannelMultiplexer { + return this as unknown as SshChannelMultiplexer + } +} + +export function sshAuthorityAdmission(namespaceId = 'namespace:ssh-app-transport-test') { + const namespace: TerminalAuthorityNamespace = Object.freeze({ + authorityHostId: SSH_AUTHORITY_HOST_ID, + namespaceId + }) + const request = Object.freeze({ + namespace, + candidateProcessIncarnationId: 'app-process:ssh-app-transport-test', + candidateSessionNonce: `app-session:${namespaceId}`, + requestId: `app-request:${namespaceId}`, + intent: 'resume' as const + }) + const start: TerminalAuthorityNamespaceAdmissionStart = Object.freeze({ + version: TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION, + algorithm: TERMINAL_AUTHORITY_CONSUMER_PROOF_ALGORITHM, + ...request, + appPublicKeyB64: Buffer.from(SSH_AUTHORITY_APP_KEYPAIR.publicKey).toString('base64') + }) + const host = createTerminalAuthorityProofEphemeralKeypair() + const challenge: TerminalAuthorityNamespaceAdmissionChallenge = Object.freeze({ + ...start, + currentAdmissionCas: 'admission-cas:ssh-app-transport-test', + connectionGrantId: 'connection-grant:ssh-app-transport-test', + authenticatedTransportPrincipal: 'ssh-principal:test', + authenticatedTransportCapability: 'ssh-capability:test', + hostEphemeralPublicKeyB64: Buffer.from(host.publicKey).toString('base64'), + expiresAtMs: Date.now() + 30_000 + }) + const consumer = Object.freeze({ + consumerId: terminalAuthorityHostAppConsumerId( + SSH_AUTHORITY_HOST_ID, + SSH_AUTHORITY_APP_KEYPAIR.publicKey + ), + consumerIncarnationId: start.candidateProcessIncarnationId + }) + const grant: TerminalAuthorityNamespaceAdmissionGrant = Object.freeze({ + version: TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION, + consumer, + namespace, + requestId: start.requestId, + connectionGrantId: challenge.connectionGrantId, + admissionCas: terminalAuthorityAdmissionCas( + namespace, + consumer.consumerId, + consumer.consumerIncarnationId + ), + replayed: false + }) + return { request, start, challenge, grant } +} + +export function sshAuthorityBoundary(grant: TerminalAuthorityNamespaceAdmissionGrant) { + return Object.freeze({ + version: TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_VERSION, + consumer: grant.consumer, + namespace: grant.namespace, + acknowledgedSequence: 0, + outcomeHighWatermark: 0, + boundaryId: `boundary:${grant.namespace.namespaceId}`, + consumerStart: 'new-at-tail' as const + }) +} + +export function sshAuthorityRetirement(namespaceId = 'namespace:ssh-app-transport-test') { + const namespace = Object.freeze({ authorityHostId: SSH_AUTHORITY_HOST_ID, namespaceId }) + const request = Object.freeze({ + namespace, + candidateProcessIncarnationId: 'app-process:ssh-app-transport-test', + candidateSessionNonce: `app-retirement-session:${namespaceId}`, + requestId: `app-retirement-request:${namespaceId}` + }) + const start = Object.freeze({ + version: TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_VERSION, + algorithm: TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_ALGORITHM, + ...request, + appPublicKeyB64: Buffer.from(SSH_AUTHORITY_APP_KEYPAIR.publicKey).toString('base64') + }) + const host = createTerminalAuthorityProofEphemeralKeypair() + const consumerId = terminalAuthorityHostAppConsumerId( + SSH_AUTHORITY_HOST_ID, + SSH_AUTHORITY_APP_KEYPAIR.publicKey + ) + const currentConsumerIncarnationId = request.candidateProcessIncarnationId + const challenge = Object.freeze({ + ...start, + consumerId, + currentConsumerIncarnationId, + retirementCas: terminalAuthorityRetirementCas( + namespace, + consumerId, + currentConsumerIncarnationId + ), + connectionGrantId: 'connection-grant:ssh-app-transport-test', + liveAdmission: null, + authenticatedTransportPrincipal: 'ssh-principal:test', + authenticatedTransportCapability: 'ssh-capability:test', + hostEphemeralPublicKeyB64: Buffer.from(host.publicKey).toString('base64'), + expiresAtMs: Date.now() + 30_000 + }) + const result = Object.freeze({ + version: TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_VERSION, + namespace, + consumerId, + retiredConsumerIncarnationId: currentConsumerIncarnationId, + requestId: request.requestId, + candidateProcessIncarnationId: request.candidateProcessIncarnationId, + candidateSessionNonce: request.candidateSessionNonce, + connectionGrantId: challenge.connectionGrantId, + retirementCas: challenge.retirementCas, + retired: true as const, + alreadyAbsent: false, + replayed: false + }) + return { request, start, challenge, result } +} + +export function sshAuthorityOutcomeTransport( + onFailure: (error: Error) => void = vi.fn() +): TerminalAuthorityPolicyOutcomeTransport { + return { + publishBoundary: vi.fn(async () => {}), + publishOutcome: vi.fn(async () => {}), + onFailure + } +} diff --git a/src/main/ssh/ssh-channel-multiplexer.test.ts b/src/main/ssh/ssh-channel-multiplexer.test.ts index eeca0ce5108..836b3bca3a7 100644 --- a/src/main/ssh/ssh-channel-multiplexer.test.ts +++ b/src/main/ssh/ssh-channel-multiplexer.test.ts @@ -211,7 +211,7 @@ describe('SshChannelMultiplexer', () => { describe('notifications', () => { it('sends notifications without expecting a response', () => { - mux.notify('pty.data', { id: 'pty-1', data: 'hello' }) + expect(mux.notify('pty.data', { id: 'pty-1', data: 'hello' })).toBe(true) expect(transport.written.length).toBe(1) const payload = JSON.parse( @@ -223,6 +223,13 @@ describe('SshChannelMultiplexer', () => { expect(payload.id).toBeUndefined() }) + it('reports when a disposed channel cannot admit a notification', () => { + mux.dispose() + + expect(mux.notify('pty.data', { id: 'pty-1', data: 'blocked' })).toBe(false) + expect(transport.written).toHaveLength(0) + }) + it('dispatches incoming notifications to handler', () => { const handler = vi.fn() mux.onNotification(handler) diff --git a/src/main/ssh/ssh-channel-multiplexer.ts b/src/main/ssh/ssh-channel-multiplexer.ts index 0000f2f353b..f7b60323b3f 100644 --- a/src/main/ssh/ssh-channel-multiplexer.ts +++ b/src/main/ssh/ssh-channel-multiplexer.ts @@ -291,9 +291,9 @@ export class SshChannelMultiplexer { /** * Send a JSON-RPC notification (no response expected). */ - notify(method: string, params?: Record): void { + notify(method: string, params?: Record): boolean { if (this.disposed) { - return + return false } const msg: JsonRpcNotification = { @@ -302,7 +302,7 @@ export class SshChannelMultiplexer { ...(params !== undefined ? { params } : {}) } - this.sendMessage(msg) + return this.sendMessage(msg) } notifyWithSettlement( @@ -409,11 +409,11 @@ export class SshChannelMultiplexer { private sendMessage( msg: JsonRpcMessage, onSettled?: (result: MultiplexerWriteSettlement) => void - ): void { + ): boolean { const seq = this.nextOutgoingSeq++ const frame = encodeJsonRpcFrame(msg, seq, this.highestReceivedSeq) this.trackOutgoingTimestamp(seq, false) - this.writer.enqueue(frame, messageLane(msg), onSettled) + return this.writer.enqueue(frame, messageLane(msg), onSettled) } private sendKeepAlive(): void { diff --git a/src/main/ssh/ssh-legacy-migration-attempt.ts b/src/main/ssh/ssh-legacy-migration-attempt.ts new file mode 100644 index 00000000000..03c274ea385 --- /dev/null +++ b/src/main/ssh/ssh-legacy-migration-attempt.ts @@ -0,0 +1,72 @@ +import type { + SshLegacyMigrationCoordinatorInput, + SshLegacyMigrationOutcome, + SshLegacyMigrationUnresolvedPhase +} from './ssh-legacy-migration-coordinator-types' +import { + sshLegacyMigrationErrorMessage, + unresolvedSshLegacyMigrationOutcome +} from './ssh-legacy-migration-outcome' + +export class SshLegacyMigrationAttemptSupersededError extends Error { + readonly name = 'SshLegacyMigrationAttemptSupersededError' +} + +export function assertSshLegacyMigrationAttempt( + input: Pick +): void { + input.signal.throwIfAborted() + if (!input.isAttemptCurrent()) { + throw new SshLegacyMigrationAttemptSupersededError( + 'SSH legacy migration attempt was superseded' + ) + } +} + +export function rethrowSshLegacyMigrationAttemptStop( + input: Pick, + error: unknown +): void { + input.signal.throwIfAborted() + if (!input.isAttemptCurrent() || error instanceof SshLegacyMigrationAttemptSupersededError) { + throw new SshLegacyMigrationAttemptSupersededError( + 'SSH legacy migration attempt was superseded' + ) + } +} + +export function sshLegacyEvidenceContext(input: SshLegacyMigrationCoordinatorInput) { + return Object.freeze({ + targetId: input.targetId, + authorityHostId: input.authorityHostId, + hostPathFlavor: input.hostPathFlavor, + attemptId: input.attemptId, + signal: input.signal + }) +} + +export async function awaitSshLegacyEvidencePhase( + input: SshLegacyMigrationCoordinatorInput, + phase: Extract, + operation: () => Promise +): Promise< + | Readonly<{ kind: 'value'; value: T }> + | Readonly<{ kind: 'outcome'; outcome: SshLegacyMigrationOutcome }> +> { + try { + assertSshLegacyMigrationAttempt(input) + const value = await operation() + assertSshLegacyMigrationAttempt(input) + return Object.freeze({ kind: 'value', value }) + } catch (error) { + rethrowSshLegacyMigrationAttemptStop(input, error) + return Object.freeze({ + kind: 'outcome', + outcome: unresolvedSshLegacyMigrationOutcome( + phase, + sshLegacyMigrationErrorMessage(error), + 'none' + ) + }) + } +} diff --git a/src/main/ssh/ssh-legacy-migration-authority-resolution.ts b/src/main/ssh/ssh-legacy-migration-authority-resolution.ts new file mode 100644 index 00000000000..b5caef59d88 --- /dev/null +++ b/src/main/ssh/ssh-legacy-migration-authority-resolution.ts @@ -0,0 +1,203 @@ +import { + assertAuthorityNamespace, + assertAuthorityStoragePath +} from '../../shared/terminal-session-authority-identity' +import { assertTerminalAuthorityNamespaceLocator } from '../../shared/terminal-session-authority-locator' +import { + projectSshLegacyInventoryRowEvidence, + sortSshLegacyEvidence +} from './ssh-legacy-migration-evidence-projection' +import { + SshLegacyMigrationEvidenceError, + failSshLegacyMigrationEvidence +} from './ssh-legacy-migration-evidence-capacity' +import type { + SshLegacyDiscoveredRelayEvidence, + SshLegacyDiscoveredRelayInventoryRow, + SshLegacyMigrationEvidenceBridgeInput, + SshLegacyUnresolvedPaneEvidence, + SshLegacyWorkspaceReference, + SshLegacyWorkspaceResolution, + SshLegacyWorkspaceResolutionRequest +} from './ssh-legacy-migration-evidence-bridge-types' +import type { SshLegacyResolvedPane } from './ssh-legacy-migration-lease-normalization' +import type { + SshLegacyLiveRelayInventory, + SshLegacyRelayInventoryRow, + SshLegacySerializedPtyEvidence +} from './ssh-legacy-migration-inventory-types' +import { assertSshLegacyDiscoveredRelayIdentity } from './ssh-legacy-migration-consumer-association' + +export async function resolveSshLegacyPanes( + input: SshLegacyMigrationEvidenceBridgeInput, + panes: readonly SshLegacyUnresolvedPaneEvidence[], + source: 'local-layout' | 'remote-snapshot' +): Promise { + const resolved: SshLegacyResolvedPane[] = [] + for (const raw of panes) { + const resolution = await resolveWorkspace(input, { + targetId: input.targetId, + authorityHostId: input.authorityHostId, + hostPathFlavor: input.hostPathFlavor, + source, + partitionId: raw.partitionId, + endpointId: null, + reference: raw.workspaceReference + }) + resolved.push( + Object.freeze({ + raw, + pane: Object.freeze({ + targetId: input.targetId, + partitionId: raw.partitionId, + ptyId: raw.ptyId, + paneKey: raw.paneKey, + tabId: raw.tabId, + leafId: raw.leafId, + rendererGeneration: raw.rendererGeneration, + namespace: resolution.namespace, + workspace: resolution.workspace + }) + }) + ) + } + return Object.freeze(resolved) +} + +export async function resolveSshLegacyRelays( + input: SshLegacyMigrationEvidenceBridgeInput, + relays: readonly SshLegacyDiscoveredRelayEvidence[] +): Promise { + const resolved: SshLegacyLiveRelayInventory[] = [] + for (const relay of relays) { + assertSshLegacyDiscoveredRelayIdentity(relay) + const rows: SshLegacyRelayInventoryRow[] = [] + for (const row of relay.rows) { + const resolution = await resolveWorkspace(input, { + targetId: input.targetId, + authorityHostId: input.authorityHostId, + hostPathFlavor: input.hostPathFlavor, + source: 'relay-inventory', + partitionId: null, + endpointId: relay.endpointId, + reference: row.workspaceReference + }) + rows.push(resolveRelayRow(relay, row, resolution)) + } + resolved.push( + Object.freeze({ + workerId: relay.workerId, + buildId: relay.buildId, + observedAtMs: relay.observedAtMs, + identityProof: relay.identityProof, + rows: Object.freeze(sortSshLegacyEvidence(rows, projectSshLegacyInventoryRowEvidence)) + }) + ) + } + return Object.freeze(resolved) +} + +function resolveRelayRow( + relay: SshLegacyDiscoveredRelayEvidence, + row: SshLegacyDiscoveredRelayInventoryRow, + resolution: SshLegacyWorkspaceResolution +): SshLegacyRelayInventoryRow { + return Object.freeze({ + workerId: relay.workerId, + buildId: relay.buildId, + physicalPtyId: row.physicalPtyId, + ptyIncarnationId: row.ptyIncarnationId, + processId: row.processId, + namespace: resolution.namespace, + workspace: resolution.workspace, + serialized: Object.freeze({ + ...row.serialized, + worktreeId: normalizedSerializedWorktreeId(row.serialized, row.workspaceReference, resolution) + }) + }) +} + +async function resolveWorkspace( + input: SshLegacyMigrationEvidenceBridgeInput, + request: SshLegacyWorkspaceResolutionRequest +): Promise { + let resolution: SshLegacyWorkspaceResolution + try { + resolution = await input.resolveWorkspace(Object.freeze(request)) + assertAuthorityNamespace(resolution.namespace) + assertResolvedWorkspace(resolution) + } catch (error) { + if (error instanceof SshLegacyMigrationEvidenceError) { + throw error + } + failSshLegacyMigrationEvidence('resolution', 'workspace callback') + } + if ( + resolution.namespace.authorityHostId !== input.authorityHostId || + !resolutionMatchesReference(request.reference, resolution) + ) { + failSshLegacyMigrationEvidence('resolution', 'workspace callback identity') + } + if ( + resolution.workspace.locator.kind === 'workspace' && + resolution.workspace.locator.pathFlavor !== input.hostPathFlavor + ) { + failSshLegacyMigrationEvidence('resolution', 'workspace callback path flavor') + } + return Object.freeze({ + namespace: Object.freeze({ ...resolution.namespace }), + workspace: Object.freeze(resolution.workspace) + }) +} + +function normalizedSerializedWorktreeId( + serialized: SshLegacySerializedPtyEvidence, + reference: SshLegacyWorkspaceReference, + resolution: SshLegacyWorkspaceResolution +): string | null { + const expected = + resolution.workspace.kind === 'git-worktree' ? resolution.workspace.worktreeId : null + if ( + serialized.worktreeId === expected || + ('clientWorkspaceId' in reference && serialized.worktreeId === reference.clientWorkspaceId) || + (expected === null && serialized.worktreeId === null) + ) { + return expected + } + return serialized.worktreeId +} + +function resolutionMatchesReference( + reference: SshLegacyWorkspaceReference, + resolution: SshLegacyWorkspaceResolution +): boolean { + return ( + (reference.kind === 'workspace-path' && resolution.workspace.kind !== 'floating') || + (reference.kind === 'git-worktree' && resolution.workspace.kind === 'git-worktree') || + (reference.kind === 'folder-workspace' && resolution.workspace.kind === 'folder') || + (reference.kind === 'floating' && resolution.workspace.kind === 'floating') + ) +} + +function assertResolvedWorkspace(resolution: SshLegacyWorkspaceResolution): void { + assertTerminalAuthorityNamespaceLocator(resolution.workspace.locator) + if (resolution.workspace.kind === 'git-worktree') { + if (resolution.workspace.locator.kind !== 'workspace') { + throw new Error('resolved git workspace locator is invalid') + } + assertAuthorityStoragePath(resolution.workspace.worktreeId, 'SSH legacy resolved worktreeId') + return + } + if (resolution.workspace.kind === 'folder') { + if (resolution.workspace.locator.kind !== 'workspace') { + throw new Error('resolved folder workspace locator is invalid') + } + return + } + if ( + resolution.workspace.kind !== 'floating' || + resolution.workspace.locator.kind !== 'floating' + ) { + throw new Error('resolved floating workspace locator is invalid') + } +} diff --git a/src/main/ssh/ssh-legacy-migration-candidate-classifier.ts b/src/main/ssh/ssh-legacy-migration-candidate-classifier.ts new file mode 100644 index 00000000000..9532a0b90a9 --- /dev/null +++ b/src/main/ssh/ssh-legacy-migration-candidate-classifier.ts @@ -0,0 +1,300 @@ +import type { + TerminalLegacyImportCandidate, + TerminalLegacyInventoryEvidence, + TerminalLegacyRecoveryReason, + TerminalLegacyUnresolvedCandidate +} from '../../shared/terminal-legacy-cutover' +import type { SshRemotePtyLease } from '../../shared/ssh-types' +import { + canonicalEvidenceEqual, + sshLegacyEvidenceDigest, + sshLegacyEvidenceId, + sshLegacyWorkspaceAndNamespaceEqual, + sshLegacyWorkspaceWorktreeId +} from './ssh-legacy-migration-evidence-identity' +import { + projectSshLegacyEndpointIdentity, + projectSshLegacyProcessIdentity, + projectSshLegacyRelayIdentityProof, + projectSshLegacySourceEvidence +} from './ssh-legacy-migration-evidence-projection' +import type { + SshLegacyInventoryGroup, + SshLegacyInventoryIndexes, + SshLegacyInventorySource +} from './ssh-legacy-migration-inventory-index' +import type { + SshLegacyLayoutPaneEvidence, + SshLegacyMigrationInventoryInput +} from './ssh-legacy-migration-inventory-types' + +export type SshLegacyClassifiedCandidate = + | Readonly<{ kind: 'import'; candidate: TerminalLegacyImportCandidate }> + | Readonly<{ kind: 'unresolved'; candidate: TerminalLegacyUnresolvedCandidate }> + +type ExactMatch = Readonly<{ + lease: SshRemotePtyLease + localPane: SshLegacyLayoutPaneEvidence & { + paneKey: string + tabId: string + leafId: string + rendererGeneration: number + } + paneGenerationId: string +}> + +export function classifySshLegacyInventoryGroup( + input: SshLegacyMigrationInventoryInput, + indexes: SshLegacyInventoryIndexes, + group: SshLegacyInventoryGroup +): SshLegacyClassifiedCandidate { + const source = group.sources[0] + const groupDigest = sshLegacyEvidenceDigest(group.sources.map(projectSshLegacySourceEvidence)) + const inventoryEvidence = makeInventoryEvidence(source, groupDigest) + const recoveryId = sshLegacyEvidenceId('ssh-legacy-recovery', [ + input.authorityHostId, + group.workerId, + group.physicalPtyId + ]) + const physicalPty = Object.freeze({ + workerId: group.workerId, + physicalPtyId: group.physicalPtyId, + ptyIncarnationId: source.row.ptyIncarnationId, + processId: source.row.processId + }) + const reason = unresolvedReason(input, indexes, group, source) + if (reason !== null) { + return Object.freeze({ + kind: 'unresolved', + candidate: Object.freeze({ + recoveryId, + namespace: source.row.namespace, + workspace: source.row.workspace, + physicalPty, + reason, + evidenceCode: sshLegacyEvidenceId(`legacy-${reason}`, groupDigest), + inventoryEvidence, + preservation: Object.freeze({ + kind: 'evidence-gc-retained' as const, + processPreservationUnproved: true as const + }) + }) + }) + } + + const exact = exactMatch(indexes, group) + const ptyIncarnationId = source.row.ptyIncarnationId as string + const processId = source.row.processId as number + const allocationId = sshLegacyEvidenceId('legacy-allocation', [ + recoveryId, + exact.paneGenerationId + ]) + const spawnFingerprint = sshLegacyEvidenceId('legacy-spawn', [ + source.relay.workerId, + source.relay.buildId, + source.row.physicalPtyId, + ptyIncarnationId, + processId, + projectSshLegacyRelayIdentityProof(source.relay.identityProof) + ]) + const remoteInventory = Object.freeze({ + ...inventoryEvidence, + paneKey: source.row.serialized.paneKey as string, + tabId: source.row.serialized.tabId as string, + serializedPtyIncarnationId: source.row.serialized.ptyIncarnationId as string, + serializedProcessId: source.row.serialized.processId as number + }) + return Object.freeze({ + kind: 'import', + candidate: Object.freeze({ + recoveryId, + namespace: source.row.namespace, + workspace: source.row.workspace, + physicalPty: Object.freeze({ ...physicalPty, ptyIncarnationId }), + pane: Object.freeze({ + paneKey: exact.localPane.paneKey, + paneGenerationId: exact.paneGenerationId + }), + allocationId, + spawnFingerprint, + inventoryEvidence, + matchEvidence: Object.freeze({ + localLease: Object.freeze({ + leaseId: sshLegacyEvidenceId( + 'legacy-lease', + leaseIdentity(input.authorityHostId, group.physicalPtyId, exact.lease) + ), + paneKey: exact.localPane.paneKey, + paneGenerationId: exact.paneGenerationId, + rendererGeneration: exact.localPane.rendererGeneration, + tabId: exact.localPane.tabId, + worktreeId: sshLegacyWorkspaceWorktreeId(exact.localPane.workspace) + }), + remoteInventory, + uniqueness: Object.freeze({ + localCandidates: 1 as const, + remoteCandidates: 1 as const, + endpointIdentityMatched: true as const, + processIdentityMatched: true as const + }) + }) + }) + }) +} + +function unresolvedReason( + input: SshLegacyMigrationInventoryInput, + indexes: SshLegacyInventoryIndexes, + group: SshLegacyInventoryGroup, + source: SshLegacyInventorySource +): TerminalLegacyRecoveryReason | null { + if (group.sources.length !== 1) { + return 'physical-pty-incarnation-unproved' + } + if ( + indexes.consumersFor(source.relay.workerId, source.relay.buildId).length !== 1 || + source.row.workerId !== source.relay.workerId || + source.row.buildId !== source.relay.buildId || + source.row.namespace.authorityHostId !== input.authorityHostId + ) { + return 'endpoint-identity-unproved' + } + if ( + source.row.ptyIncarnationId === null || + source.row.processId === null || + source.row.serialized.ptyIncarnationId !== source.row.ptyIncarnationId || + source.row.serialized.processId !== source.row.processId || + indexes.exactRemoteSourcesFor(source.row.physicalPtyId, source.row.ptyIncarnationId).length !== + 1 + ) { + return 'physical-pty-incarnation-unproved' + } + const leases = indexes.leasesFor(group.physicalPtyId) + if (leases.length !== 1 || leases[0].incarnationId !== source.row.ptyIncarnationId) { + return 'physical-pty-incarnation-unproved' + } + const localPanes = indexes.localPanesFor(group.physicalPtyId) + if ( + localPanes.length !== 1 || + !paneHasGeneration(localPanes[0]) || + leases[0].paneGeneration === undefined || + leases[0].paneGeneration !== localPanes[0].rendererGeneration + ) { + return 'ambiguous-pane-generation' + } + const snapshots = indexes.snapshotPanesFor(group.physicalPtyId) + if ( + snapshots.length !== 1 || + !paneHasGeneration(snapshots[0]) || + !paneEvidenceMatches(localPanes[0], snapshots[0]) || + !paneMatchesLease(localPanes[0], leases[0]) || + !inventoryMatchesPane(source, localPanes[0]) + ) { + return 'workspace-mismatch' + } + const proof = source.relay.identityProof + if ( + proof.expectedEndpoint === null || + proof.observedEndpoint === null || + proof.expectedProcess === null || + proof.observedProcess === null || + !canonicalEvidenceEqual( + projectSshLegacyEndpointIdentity(proof.expectedEndpoint), + projectSshLegacyEndpointIdentity(proof.observedEndpoint) + ) || + !canonicalEvidenceEqual( + projectSshLegacyProcessIdentity(proof.expectedProcess), + projectSshLegacyProcessIdentity(proof.observedProcess) + ) + ) { + return 'endpoint-identity-unproved' + } + return null +} + +function exactMatch( + indexes: SshLegacyInventoryIndexes, + group: SshLegacyInventoryGroup +): ExactMatch { + const lease = indexes.leasesFor(group.physicalPtyId)[0] + const localPane = indexes.localPanesFor(group.physicalPtyId)[0] as ExactMatch['localPane'] + return Object.freeze({ + lease, + localPane, + paneGenerationId: `renderer:${localPane.rendererGeneration}` + }) +} + +function paneHasGeneration(pane: SshLegacyLayoutPaneEvidence): pane is ExactMatch['localPane'] { + return ( + pane.paneKey !== null && + pane.tabId !== null && + pane.leafId !== null && + pane.rendererGeneration !== null + ) +} + +function paneEvidenceMatches( + local: ExactMatch['localPane'], + snapshot: ExactMatch['localPane'] +): boolean { + return ( + local.paneKey === snapshot.paneKey && + local.tabId === snapshot.tabId && + local.leafId === snapshot.leafId && + local.rendererGeneration === snapshot.rendererGeneration && + sshLegacyWorkspaceAndNamespaceEqual(local, snapshot) + ) +} + +function paneMatchesLease(pane: ExactMatch['localPane'], lease: SshRemotePtyLease): boolean { + return ( + lease.tabId === pane.tabId && + lease.leafId === pane.leafId && + (lease.worktreeId ?? null) === sshLegacyWorkspaceWorktreeId(pane.workspace) + ) +} + +function inventoryMatchesPane( + source: SshLegacyInventorySource, + pane: ExactMatch['localPane'] +): boolean { + return ( + source.row.serialized.paneKey === pane.paneKey && + source.row.serialized.tabId === pane.tabId && + source.row.serialized.worktreeId === sshLegacyWorkspaceWorktreeId(pane.workspace) && + sshLegacyWorkspaceAndNamespaceEqual(source.row, pane) + ) +} + +function makeInventoryEvidence( + source: SshLegacyInventorySource, + evidenceDigest: string +): TerminalLegacyInventoryEvidence { + return Object.freeze({ + evidenceDigest, + observedAtMs: source.relay.observedAtMs, + paneKey: source.row.serialized.paneKey, + tabId: source.row.serialized.tabId, + worktreeId: source.row.serialized.worktreeId, + cwd: source.row.serialized.cwd, + serializedPtyIncarnationId: source.row.serialized.ptyIncarnationId, + serializedProcessId: source.row.serialized.processId + }) +} + +function leaseIdentity( + authorityHostId: string, + physicalPtyId: string, + lease: SshRemotePtyLease +): readonly unknown[] { + return [ + authorityHostId, + physicalPtyId, + lease.incarnationId ?? null, + lease.worktreeId ?? null, + lease.tabId ?? null, + lease.leafId ?? null, + lease.paneGeneration ?? null + ] +} diff --git a/src/main/ssh/ssh-legacy-migration-consumer-association.ts b/src/main/ssh/ssh-legacy-migration-consumer-association.ts new file mode 100644 index 00000000000..de2eff09f87 --- /dev/null +++ b/src/main/ssh/ssh-legacy-migration-consumer-association.ts @@ -0,0 +1,91 @@ +import { assertAuthorityId } from '../../shared/terminal-session-authority-identity' +import { failSshLegacyMigrationEvidence } from './ssh-legacy-migration-evidence-capacity' +import type { + SshLegacyDiscoveredRelayEvidence, + SshLegacyWorkerRecoveryAssociation +} from './ssh-legacy-migration-evidence-bridge-types' +import type { SshLegacyPersistedConsumerEvidence } from './ssh-legacy-migration-inventory-types' + +export function indexSshLegacyDiscoveredRelays( + relays: readonly SshLegacyDiscoveredRelayEvidence[] +): ReadonlyMap { + const index = new Map() + for (const relay of relays) { + assertSshLegacyDiscoveredRelayIdentity(relay) + const key = sshLegacyWorkerAssociationKey(relay) + if (index.has(key)) { + failSshLegacyMigrationEvidence('ambiguity', 'discovered relay association') + } + index.set(key, relay) + } + return index +} + +export function collectSshLegacyWorkerRecoveries( + targetId: string, + associations: readonly SshLegacyWorkerRecoveryAssociation[], + relayByAssociation: ReadonlyMap +): readonly SshLegacyPersistedConsumerEvidence[] { + const recoveryOwners = new Map() + const associatedRelays = new Set() + const consumers: SshLegacyPersistedConsumerEvidence[] = [] + for (const association of associations) { + assertRecoveryAssociation(association) + const relayKey = sshLegacyWorkerAssociationKey(association) + const relay = relayByAssociation.get(relayKey) + if (!relay || associatedRelays.has(relayKey)) { + failSshLegacyMigrationEvidence('ambiguity', 'worker recovery association') + } + const recoveryKey = JSON.stringify([ + targetId, + association.recovery.clientInstanceId, + association.recovery.serverBuildId + ]) + const existingOwner = recoveryOwners.get(recoveryKey) + if (existingOwner !== undefined && existingOwner !== relayKey) { + failSshLegacyMigrationEvidence('ambiguity', 'worker recovery owner') + } + recoveryOwners.set(recoveryKey, relayKey) + associatedRelays.add(relayKey) + consumers.push( + Object.freeze({ + targetId, + workerId: relay.workerId, + clientInstanceId: association.recovery.clientInstanceId, + serverBuildId: relay.buildId + }) + ) + } + return Object.freeze(consumers) +} + +export function assertSshLegacyDiscoveredRelayIdentity( + relay: SshLegacyDiscoveredRelayEvidence +): void { + assertAuthorityId(relay.targetId, 'SSH legacy discovered relay targetId') + assertAuthorityId(relay.endpointId, 'SSH legacy discovered relay endpointId') + assertAuthorityId(relay.workerId, 'SSH legacy discovered relay workerId') + assertAuthorityId(relay.buildId, 'SSH legacy discovered relay buildId') +} + +function assertRecoveryAssociation(association: SshLegacyWorkerRecoveryAssociation): void { + assertAuthorityId(association.targetId, 'SSH legacy recovery targetId') + assertAuthorityId(association.endpointId, 'SSH legacy recovery endpointId') + assertAuthorityId(association.workerId, 'SSH legacy recovery workerId') + assertAuthorityId(association.buildId, 'SSH legacy recovery buildId') + if ( + association.recovery.targetId !== association.targetId || + association.recovery.serverBuildId !== association.buildId + ) { + failSshLegacyMigrationEvidence('malformed', 'worker recovery provenance') + } +} + +function sshLegacyWorkerAssociationKey(value: { + targetId: string + endpointId: string + workerId: string + buildId: string +}): string { + return JSON.stringify([value.targetId, value.endpointId, value.workerId, value.buildId]) +} diff --git a/src/main/ssh/ssh-legacy-migration-coordinator-commit.test.ts b/src/main/ssh/ssh-legacy-migration-coordinator-commit.test.ts new file mode 100644 index 00000000000..e1cec612aa5 --- /dev/null +++ b/src/main/ssh/ssh-legacy-migration-coordinator-commit.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, it } from 'vitest' +import { coordinateSshLegacyMigration } from './ssh-legacy-migration-coordinator' +import type { + LegacyPhysicalWorkerDescriptor, + SshLegacyInspectedWorker, + SshLegacyMigrationCoordinatorInput +} from './ssh-legacy-migration-coordinator-types' +import { + combineSshLegacyInventories, + descriptorForInventory, + evidenceProviderForInventory, + inspectionForWorker, + SSH_LEGACY_TEST_CAPABILITIES +} from './__tests__/ssh-legacy-migration-evidence' +import { FutureSshLegacyMigrationRpc } from './__tests__/ssh-legacy-migration-future-rpc' +import { makeSshLegacyInventoryScenario } from './__tests__/ssh-legacy-migration-inventory' +import type { SshLegacyMigrationInventoryInput } from './ssh-legacy-migration-inventory-types' + +describe('SSH legacy migration coordinator commit', () => { + it('commits exact evidence, a durable barrier, then protected GC in order', async () => { + const harness = migrationHarness(makeSshLegacyInventoryScenario()) + + const outcome = await coordinateSshLegacyMigration(harness.input) + + expect(outcome).toMatchObject({ + kind: 'committed', + receipts: [{ workerId: 'worker-a', sequence: 1, duplicate: false }], + catalogRevision: 1, + gc: { kind: 'completed', removed: ['/old/relay'] } + }) + expect(methods(harness.rpc)).toEqual([ + 'inspect', + 'migrate', + 'gcProtection', + 'migrationBarrier', + 'gc' + ]) + expect(new Set(harness.rpc.calls.map((call) => call.signal))).toEqual( + new Set([harness.input.signal]) + ) + const barrierIndex = methods(harness.rpc).indexOf('migrationBarrier') + expect(methods(harness.rpc).indexOf('gc')).toBeGreaterThan(barrierIndex) + }) + + it('retries a lost migration response through the exact authority operation', async () => { + const harness = migrationHarness(makeSshLegacyInventoryScenario(), { + loseMigrationResponseOnce: true + }) + + const uncertain = await coordinateSshLegacyMigration(harness.input) + const outcome = await coordinateSshLegacyMigration(harness.input) + + expect(uncertain).toMatchObject({ kind: 'unresolved', mutationState: 'commit-uncertain' }) + expect(outcome).toMatchObject({ + kind: 'committed', + receipts: [{ duplicate: true }] + }) + expect(methods(harness.rpc).filter((method) => method === 'migrate')).toHaveLength(2) + expect(methods(harness.rpc)).not.toContain('migrationStatus') + }) + + it('replays an already durable worker commit through the same migrate request', async () => { + const harness = migrationHarness(makeSshLegacyInventoryScenario(), { + committedInitially: true + }) + + const outcome = await coordinateSshLegacyMigration(harness.input) + + expect(outcome).toMatchObject({ + kind: 'committed', + receipts: [{ workerId: 'worker-a', duplicate: true }] + }) + expect(methods(harness.rpc).filter((method) => method === 'migrate')).toHaveLength(1) + }) + + it('partitions multiple workers deterministically and never mixes candidate catalogs', async () => { + const first = makeSshLegacyInventoryScenario({ physicalPtyId: 'pty-a' }) + const second = makeSshLegacyInventoryScenario({ + workerId: 'worker-b', + buildId: 'build-b', + physicalPtyId: 'pty-b', + ptyIncarnationId: 'incarnation-b', + processId: 4_202, + tabId: 'tab-b', + leafId: 'leaf-b', + namespaceId: 'namespace-b' + }) + const inventory = combineSshLegacyInventories(first, second) + const workerA = descriptorForInventory(inventory, 0) + const workerB = descriptorForInventory(inventory, 1) + const harness = migrationHarness(inventory, {}, [workerB, workerA]) + + const outcome = await coordinateSshLegacyMigration(harness.input) + + expect(outcome).toMatchObject({ + kind: 'committed', + receipts: [ + { workerId: 'worker-a', sequence: 1 }, + { workerId: 'worker-b', sequence: 2 } + ] + }) + const migrations = harness.rpc.calls.filter((call) => call.method.endsWith('.migrate')) + expect(migrations).toHaveLength(2) + for (const call of migrations) { + const worker = call.params.worker as LegacyPhysicalWorkerDescriptor + const catalog = call.params.catalog as { + imports: { physicalPty: { workerId: string } }[] + unresolved: { physicalPty: { workerId: string } }[] + } + expect( + [...catalog.imports, ...catalog.unresolved].every( + (candidate) => candidate.physicalPty.workerId === worker.workerId + ) + ).toBe(true) + } + expect( + harness.rpc.calls + .filter((call) => call.method.endsWith('.inspect')) + .map((call) => (call.params.worker as LegacyPhysicalWorkerDescriptor).workerId) + ).toEqual(['worker-a', 'worker-b']) + }) + + it('commits ambiguous evidence only after the host records isolated preservation', async () => { + const base = makeSshLegacyInventoryScenario() + const inventory: SshLegacyMigrationInventoryInput = { + ...base, + remoteSnapshotPanes: [ + { + ...base.remoteSnapshotPanes[0], + paneKey: 'tab-stale:leaf-stale', + tabId: 'tab-stale', + leafId: 'leaf-stale' + } + ] + } + const harness = migrationHarness(inventory) + + const outcome = await coordinateSshLegacyMigration(harness.input) + + expect(outcome).toMatchObject({ + kind: 'committed', + summary: { importCount: 0, unresolvedCount: 1 } + }) + const migration = harness.rpc.calls.find((call) => call.method.endsWith('.migrate')) + expect( + ( + migration?.params.catalog as + | { unresolved: { preservation: { kind: string } }[] } + | undefined + )?.unresolved[0]?.preservation.kind + ).toBe('evidence-gc-retained') + }) + + it('never runs GC when the durable barrier is unconfirmed', async () => { + const harness = migrationHarness(makeSshLegacyInventoryScenario(), { + barrierError: new Error('barrier disk unavailable') + }) + + const outcome = await coordinateSshLegacyMigration(harness.input) + + expect(outcome).toMatchObject({ + kind: 'unresolved', + phase: 'barrier', + mutationState: 'catalog-committed' + }) + expect(methods(harness.rpc)).not.toContain('gc') + }) + + it('keeps a durable migration committed when post-barrier GC must retry', async () => { + const harness = migrationHarness(makeSshLegacyInventoryScenario(), { + gcError: new Error('GC retry required') + }) + + const outcome = await coordinateSshLegacyMigration(harness.input) + + expect(outcome).toMatchObject({ + kind: 'committed', + gc: { kind: 'pending', reason: 'GC retry required' } + }) + }) +}) + +function migrationHarness( + inventory: SshLegacyMigrationInventoryInput, + rpcOptions: ConstructorParameters[1] = {}, + workers?: readonly LegacyPhysicalWorkerDescriptor[] +) { + const selectedWorkers = + workers ?? inventory.liveRelays.map((_, index) => descriptorForInventory(inventory, index)) + const inspected: SshLegacyInspectedWorker[] = selectedWorkers.map((descriptor) => ({ + descriptor, + inspection: inspectionForWorker(inventory, descriptor) + })) + const rpc = new FutureSshLegacyMigrationRpc(inspected, rpcOptions) + const input: SshLegacyMigrationCoordinatorInput = { + targetId: inventory.targetId, + authorityHostId: inventory.authorityHostId, + hostPathFlavor: inventory.hostPathFlavor, + authorityCapabilities: SSH_LEGACY_TEST_CAPABILITIES, + attemptId: 'attempt-1', + signal: new AbortController().signal, + isAttemptCurrent: () => true, + rpc, + evidenceProvider: evidenceProviderForInventory(inventory, selectedWorkers) + } + return { input, rpc } +} + +function methods(rpc: FutureSshLegacyMigrationRpc): string[] { + return rpc.calls.map((call) => call.method.split('.').at(-1) ?? call.method) +} diff --git a/src/main/ssh/ssh-legacy-migration-coordinator-plan.ts b/src/main/ssh/ssh-legacy-migration-coordinator-plan.ts new file mode 100644 index 00000000000..a4b9354aa20 --- /dev/null +++ b/src/main/ssh/ssh-legacy-migration-coordinator-plan.ts @@ -0,0 +1,238 @@ +import { isDeepStrictEqual } from 'node:util' +import { + assertAuthorityId, + assertAuthorityStoragePath +} from '../../shared/terminal-session-authority-identity' +import type { + SshLegacyMigrationInventoryInput, + SshLegacyMigrationInventoryPlan +} from './ssh-legacy-migration-inventory-types' +import type { + SshLegacyInspectedWorker, + SshLegacyPhysicalWorkerInspection, + LegacyPhysicalWorkerDescriptor +} from './ssh-legacy-migration-coordinator-types' +import { + compareSshLegacyText, + sshLegacyEvidenceDigest, + sshLegacyEvidenceId +} from './ssh-legacy-migration-evidence-identity' +import { SSH_LEGACY_MIGRATION_INVENTORY_CAPACITY } from './ssh-legacy-migration-inventory-capacity' +import type { + SshLegacyWorkerCatalog, + SshLegacyWorkerMigrationOperation +} from './ssh-legacy-migration-rpc' + +export function validatedWorkers( + workers: readonly LegacyPhysicalWorkerDescriptor[] +): readonly LegacyPhysicalWorkerDescriptor[] { + if ( + !Array.isArray(workers) || + workers.length > SSH_LEGACY_MIGRATION_INVENTORY_CAPACITY.liveRelays + ) { + throw new Error('legacy physical worker discovery exceeds capacity') + } + const workerIds = new Set() + const routeIds = new Set() + const ownerIds = new Set() + for (const worker of workers) { + assertWorkerDescriptor(worker) + if ( + workerIds.has(worker.workerId) || + routeIds.has(worker.routeId) || + ownerIds.has(worker.ownerIncarnationId) + ) { + throw new Error('legacy physical worker discovery is ambiguous') + } + workerIds.add(worker.workerId) + routeIds.add(worker.routeId) + ownerIds.add(worker.ownerIncarnationId) + } + return Object.freeze( + [...workers].sort( + (left, right) => + compareSshLegacyText(left.routeId, right.routeId) || + compareSshLegacyText(left.workerId, right.workerId) + ) + ) +} + +export function assertInspectionDigest( + worker: LegacyPhysicalWorkerDescriptor, + inspection: SshLegacyPhysicalWorkerInspection +): void { + const expected = sshLegacyEvidenceDigest({ + protocolVersion: inspection.protocolVersion, + workerId: inspection.workerId, + routeId: inspection.routeId, + buildId: inspection.buildId, + identityProof: inspection.identityProof, + ptys: inspection.ptys + }) + if (inspection.preparation.evidenceDigest !== expected) { + throw new Error('legacy physical worker inspection digest is invalid') + } + if ( + !isDeepStrictEqual(inspection.identityProof.expectedEndpoint, worker.expectedEndpoint) || + !isDeepStrictEqual(inspection.identityProof.expectedProcess, worker.process) + ) { + throw new Error('legacy physical worker inspection changed expected identity') + } +} + +export function assertInventoryIdentity( + input: Pick, + inventory: SshLegacyMigrationInventoryInput +): void { + if ( + inventory.targetId !== input.targetId || + inventory.authorityHostId !== input.authorityHostId || + inventory.hostPathFlavor !== input.hostPathFlavor + ) { + throw new Error('legacy migration inventory changed its authority identity') + } +} + +export function assertInventoryMatchesInspections( + relays: SshLegacyMigrationInventoryInput['liveRelays'], + inspected: readonly SshLegacyInspectedWorker[] +): void { + if (relays.length !== inspected.length) { + throw new Error('legacy migration inventory omitted an inspected worker') + } + const relaysByWorker = new Map(relays.map((relay) => [relay.workerId, relay])) + if (relaysByWorker.size !== relays.length) { + throw new Error('legacy migration inventory duplicated a worker') + } + for (const prepared of inspected) { + const relay = relaysByWorker.get(prepared.descriptor.workerId) + if (!relay || relay.buildId !== prepared.descriptor.buildId) { + throw new Error('legacy migration inventory changed a worker identity') + } + assertExactIdentityProof(relay.identityProof) + if (!isDeepStrictEqual(relay.identityProof, prepared.inspection.identityProof)) { + throw new Error('legacy migration inventory changed endpoint or process proof') + } + const rows = new Map(relay.rows.map((row) => [row.physicalPtyId, row])) + if (rows.size !== relay.rows.length || rows.size !== prepared.inspection.ptys.length) { + throw new Error('legacy migration inventory changed PTY membership') + } + for (const pty of prepared.inspection.ptys) { + const row = rows.get(pty.id) + if ( + !row || + row.workerId !== prepared.descriptor.workerId || + row.buildId !== prepared.descriptor.buildId || + row.ptyIncarnationId !== pty.incarnationId || + row.processId !== pty.processId || + !isDeepStrictEqual(row.serialized, pty.serialized) + ) { + throw new Error('legacy migration inventory changed exact PTY evidence') + } + } + } +} + +export function assertPlanCoverage( + plan: SshLegacyMigrationInventoryPlan, + inspected: readonly SshLegacyInspectedWorker[] +): void { + const workers = new Map(inspected.map((entry) => [entry.descriptor.workerId, entry])) + const candidateKeys = new Set() + for (const candidate of [...plan.imports, ...plan.unresolved]) { + const workerId = candidate.physicalPty.workerId + if (!workers.has(workerId)) { + throw new Error('legacy migration plan references an unprepared worker') + } + const key = JSON.stringify([workerId, candidate.physicalPty.physicalPtyId]) + if (candidateKeys.has(key)) { + throw new Error('legacy migration plan is not one-to-one') + } + candidateKeys.add(key) + } + for (const prepared of inspected) { + for (const pty of prepared.inspection.ptys) { + if (!candidateKeys.has(JSON.stringify([prepared.descriptor.workerId, pty.id]))) { + throw new Error('legacy migration plan omitted a prepared PTY') + } + } + } + for (const candidate of plan.unresolved) { + if (candidate.preservation.kind !== 'evidence-gc-retained') { + throw new Error('legacy migration unresolved preservation is not pre-cutover evidence') + } + } +} + +export function workerCatalog( + plan: SshLegacyMigrationInventoryPlan, + workerId: string +): SshLegacyWorkerCatalog { + const imports = plan.imports.filter((candidate) => candidate.physicalPty.workerId === workerId) + const unresolved = plan.unresolved.filter( + (candidate) => candidate.physicalPty.workerId === workerId + ) + const first = imports[0] ?? unresolved[0] + if (!first) { + throw new Error('legacy worker catalog has no candidates') + } + const requestedAtMs = Math.max( + ...imports.map((candidate) => candidate.inventoryEvidence.observedAtMs), + ...unresolved.map((candidate) => candidate.inventoryEvidence.observedAtMs) + ) + return Object.freeze({ + migrationId: sshLegacyEvidenceId('ssh-legacy-worker-migration', [plan.migrationId, workerId]), + authorityHostId: first.namespace.authorityHostId, + requestedAtMs, + imports: Object.freeze(imports), + unresolved: Object.freeze(unresolved) + }) +} + +export function workerOperation( + plan: SshLegacyMigrationInventoryPlan, + prepared: SshLegacyInspectedWorker, + catalog: SshLegacyWorkerCatalog +): SshLegacyWorkerMigrationOperation { + return Object.freeze({ + operationId: sshLegacyEvidenceId('ssh-legacy-worker-operation', [ + plan.migrationId, + prepared.descriptor.routeId, + prepared.inspection.preparation.evidenceDigest + ]), + inspectionToken: prepared.inspection.preparation.token, + evidenceDigest: prepared.inspection.preparation.evidenceDigest, + catalog + }) +} + +function assertWorkerDescriptor(worker: LegacyPhysicalWorkerDescriptor): void { + if (worker.version !== 1 || !Number.isSafeInteger(worker.requestedSourceWindowSu)) { + throw new Error('legacy physical worker descriptor is invalid') + } + for (const [field, value] of [ + ['workerId', worker.workerId], + ['routeId', worker.routeId], + ['ownerIncarnationId', worker.ownerIncarnationId], + ['buildId', worker.buildId], + ['clientInstanceId', worker.clientInstanceId] + ] as const) { + assertAuthorityId(value, `legacy physical worker ${field}`) + } + assertAuthorityStoragePath(worker.relayDirectory, 'legacy worker relay directory') +} + +function assertExactIdentityProof( + proof: SshLegacyMigrationInventoryInput['liveRelays'][number]['identityProof'] +): void { + if ( + proof.expectedEndpoint === null || + proof.observedEndpoint === null || + proof.expectedProcess === null || + proof.observedProcess === null || + !isDeepStrictEqual(proof.expectedEndpoint, proof.observedEndpoint) || + !isDeepStrictEqual(proof.expectedProcess, proof.observedProcess) + ) { + throw new Error('legacy migration requires exact endpoint and process proof') + } +} diff --git a/src/main/ssh/ssh-legacy-migration-coordinator-safety.test.ts b/src/main/ssh/ssh-legacy-migration-coordinator-safety.test.ts new file mode 100644 index 00000000000..eb59d7dea5e --- /dev/null +++ b/src/main/ssh/ssh-legacy-migration-coordinator-safety.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, it, vi } from 'vitest' +import { + coordinateSshLegacyMigration, + SshLegacyMigrationAttemptSupersededError +} from './ssh-legacy-migration-coordinator' +import type { + SshLegacyMigrationCoordinatorInput, + SshLegacyMigrationEvidenceProvider, + SshLegacyMigrationRpc +} from './ssh-legacy-migration-coordinator-types' +import { + descriptorForInventory, + evidenceProviderForInventory, + inspectionForWorker, + SSH_LEGACY_TEST_CAPABILITIES +} from './__tests__/ssh-legacy-migration-evidence' +import { makeSshLegacyInventoryScenario } from './__tests__/ssh-legacy-migration-inventory' + +describe('SSH legacy migration coordinator safety', () => { + it('keeps both version-skew directions read-only without the explicit capability', async () => { + const inventory = makeSshLegacyInventoryScenario() + const descriptor = descriptorForInventory(inventory) + const discoverWorkers = vi.fn(async () => ({ kind: 'ready' as const, workers: [descriptor] })) + const provider: SshLegacyMigrationEvidenceProvider = { + discoverWorkers, + buildInventory: vi.fn(async () => ({ kind: 'ready' as const, inventory })) + } + const rpc = requestSpy(() => Promise.reject(new Error('must not call RPC'))) + + for (const capabilities of [undefined, ['terminal-session.authority.v1']]) { + await expect( + coordinateSshLegacyMigration( + input({ rpc, evidenceProvider: provider, authorityCapabilities: capabilities }) + ) + ).resolves.toEqual({ kind: 'read-only', reason: 'capability-not-negotiated' }) + } + + expect(discoverWorkers).not.toHaveBeenCalled() + expect(rpc.request).not.toHaveBeenCalled() + }) + + it('reports missing production evidence without probing or mutating the worker', async () => { + const rpc = requestSpy(() => Promise.reject(new Error('must not call RPC'))) + + await expect( + coordinateSshLegacyMigration( + input({ rpc, authorityCapabilities: SSH_LEGACY_TEST_CAPABILITIES }) + ) + ).resolves.toEqual({ + kind: 'unresolved', + phase: 'worker-discovery', + reason: 'migration evidence provider is unavailable', + mutationState: 'none' + }) + expect(rpc.request).not.toHaveBeenCalled() + }) + + it('rejects the current side-effecting inspection shape before catalog mutation', async () => { + const inventory = makeSshLegacyInventoryScenario() + const descriptor = descriptorForInventory(inventory) + const buildInventory = vi.fn() + const provider: SshLegacyMigrationEvidenceProvider = { + discoverWorkers: async () => ({ kind: 'ready', workers: [descriptor] }), + buildInventory + } + const rpc = requestSpy(async () => ({ + workerId: descriptor.workerId, + routeId: descriptor.routeId, + buildId: descriptor.buildId, + ptys: [] + })) + const controller = new AbortController() + + const outcome = await coordinateSshLegacyMigration( + input({ rpc, evidenceProvider: provider, signal: controller.signal }) + ) + + expect(outcome).toMatchObject({ + kind: 'unresolved', + phase: 'inspection', + mutationState: 'none', + workerId: descriptor.workerId + }) + expect(buildInventory).not.toHaveBeenCalled() + expect(rpc.request).toHaveBeenCalledWith( + 'terminalAuthority.legacyPhysicalWorker.inspect', + expect.objectContaining({ + requirements: { + inspectionMode: 'observational', + catalogValidation: 'before-isolation', + replay: 'durable-operation-id' + } + }), + { signal: controller.signal } + ) + }) + + it('rejects inventory that weakens exact endpoint or process proof', async () => { + const inventory = makeSshLegacyInventoryScenario() + const descriptor = descriptorForInventory(inventory) + const inspection = inspectionForWorker(inventory, descriptor) + const weakened = { + ...inventory, + liveRelays: [ + { + ...inventory.liveRelays[0], + identityProof: { ...inventory.liveRelays[0].identityProof, observedProcess: null } + } + ] + } + const provider = evidenceProviderForInventory(weakened, [descriptor]) + const rpc = requestSpy(async (method) => { + if (method.endsWith('.inspect')) { + return inspection + } + throw new Error('mutation must not run') + }) + + const outcome = await coordinateSshLegacyMigration(input({ rpc, evidenceProvider: provider })) + + expect(outcome).toMatchObject({ kind: 'unresolved', phase: 'planning', mutationState: 'none' }) + expect(rpc.request).toHaveBeenCalledTimes(1) + }) + + it('propagates one abort signal through evidence collection and stops the attempt', async () => { + const deferred = + Promise.withResolvers< + Awaited> + >() + const provider: SshLegacyMigrationEvidenceProvider = { + discoverWorkers: () => deferred.promise, + buildInventory: vi.fn() + } + const rpc = requestSpy(() => Promise.reject(new Error('must not call RPC'))) + const controller = new AbortController() + const coordinating = coordinateSshLegacyMigration( + input({ rpc, evidenceProvider: provider, signal: controller.signal }) + ) + + controller.abort() + deferred.resolve({ kind: 'unresolved', reason: 'late evidence' }) + + await expect(coordinating).rejects.toMatchObject({ name: 'AbortError' }) + expect(rpc.request).not.toHaveBeenCalled() + }) + + it('fences a superseded attempt after an awaited provider boundary', async () => { + const inventory = makeSshLegacyInventoryScenario() + const descriptor = descriptorForInventory(inventory) + let current = true + const provider: SshLegacyMigrationEvidenceProvider = { + discoverWorkers: async () => { + current = false + return { kind: 'ready', workers: [descriptor] } + }, + buildInventory: vi.fn() + } + + await expect( + coordinateSshLegacyMigration( + input({ + rpc: requestSpy(() => Promise.reject(new Error('must not call RPC'))), + evidenceProvider: provider, + isAttemptCurrent: () => current + }) + ) + ).rejects.toBeInstanceOf(SshLegacyMigrationAttemptSupersededError) + }) +}) + +function input( + overrides: Partial & + Pick +): SshLegacyMigrationCoordinatorInput { + return { + targetId: 'target-a', + authorityHostId: 'authority-host-a', + hostPathFlavor: 'posix', + authorityCapabilities: SSH_LEGACY_TEST_CAPABILITIES, + attemptId: 'attempt-1', + signal: new AbortController().signal, + isAttemptCurrent: () => true, + ...overrides + } +} + +function requestSpy( + implementation: ( + method: string, + params?: Record, + options?: { signal?: AbortSignal } + ) => Promise +): SshLegacyMigrationRpc & { request: ReturnType } { + return { request: vi.fn(implementation) } +} diff --git a/src/main/ssh/ssh-legacy-migration-coordinator-types.ts b/src/main/ssh/ssh-legacy-migration-coordinator-types.ts new file mode 100644 index 00000000000..a24fc97e601 --- /dev/null +++ b/src/main/ssh/ssh-legacy-migration-coordinator-types.ts @@ -0,0 +1,128 @@ +import type { TerminalLegacyMigrationReceipt } from '../../shared/terminal-legacy-cutover' +import type { TerminalAuthorityPathFlavor } from '../../shared/terminal-session-authority-locator' +import type { LegacyPhysicalWorkerDescriptor } from '../../relay/legacy-physical-worker-control-protocol' +import type { LegacyPhysicalWorkerPty } from '../../relay/legacy-physical-worker-inventory' +import type { SshChannelMultiplexer } from './ssh-channel-multiplexer' +import type { + SshLegacyMigrationInventoryInput, + SshLegacyMigrationInventorySummary, + SshLegacyRelayIdentityProof, + SshLegacySerializedPtyEvidence +} from './ssh-legacy-migration-inventory-types' + +export type SshLegacyPreparedPhysicalPty = LegacyPhysicalWorkerPty & + Readonly<{ serialized: SshLegacySerializedPtyEvidence }> + +export type SshLegacyPhysicalWorkerInspection = Readonly<{ + protocolVersion: 1 + workerId: string + routeId: string + buildId: string + preparation: Readonly<{ + mode: 'observational' + token: string + evidenceDigest: string + catalogValidation: 'before-isolation' + replay: 'durable-operation-id' + }> + identityProof: SshLegacyRelayIdentityProof + ptys: readonly SshLegacyPreparedPhysicalPty[] +}> + +export type SshLegacyInspectedWorker = Readonly<{ + descriptor: LegacyPhysicalWorkerDescriptor + inspection: SshLegacyPhysicalWorkerInspection +}> + +export type SshLegacyEvidenceUnavailable = Readonly<{ + kind: 'unresolved' + reason: string +}> + +export type SshLegacyWorkerDiscovery = + | Readonly<{ + kind: 'ready' + workers: readonly LegacyPhysicalWorkerDescriptor[] + }> + | SshLegacyEvidenceUnavailable + +export type SshLegacyInventoryEvidence = + | Readonly<{ + kind: 'ready' + inventory: SshLegacyMigrationInventoryInput + }> + | SshLegacyEvidenceUnavailable + +export type SshLegacyMigrationEvidenceProvider = Readonly<{ + discoverWorkers: (input: { + targetId: string + authorityHostId: string + hostPathFlavor: TerminalAuthorityPathFlavor + attemptId: string + signal: AbortSignal + }) => Promise + buildInventory: (input: { + targetId: string + authorityHostId: string + hostPathFlavor: TerminalAuthorityPathFlavor + attemptId: string + signal: AbortSignal + workers: readonly SshLegacyInspectedWorker[] + }) => Promise +}> + +export type SshLegacyMigrationRpc = Pick + +export type SshLegacyMigrationCoordinatorInput = Readonly<{ + targetId: string + authorityHostId: string + hostPathFlavor: TerminalAuthorityPathFlavor + authorityCapabilities: readonly string[] | undefined + attemptId: string + signal: AbortSignal + isAttemptCurrent: () => boolean + rpc: SshLegacyMigrationRpc + evidenceProvider?: SshLegacyMigrationEvidenceProvider +}> + +export type SshLegacyMigrationUnresolvedPhase = + | 'worker-discovery' + | 'inspection' + | 'evidence' + | 'planning' + | 'catalog-commit' + | 'barrier' + +export type SshLegacyMigrationOutcome = + | Readonly<{ + kind: 'read-only' + reason: 'capability-not-negotiated' + }> + | Readonly<{ + kind: 'unresolved' + phase: SshLegacyMigrationUnresolvedPhase + reason: string + mutationState: + | 'none' + | 'catalog-partially-committed' + | 'catalog-committed' + | 'commit-uncertain' + workerId?: string + }> + | Readonly<{ + kind: 'committed' + summary: SshLegacyMigrationInventorySummary + receipts: readonly Readonly< + Pick & { + workerId: string + duplicate: boolean + } + >[] + barrierId: string + catalogRevision: number + gc: + | Readonly<{ kind: 'completed'; removed: readonly string[] }> + | Readonly<{ kind: 'pending'; reason: string }> + }> + +export type { LegacyPhysicalWorkerDescriptor } diff --git a/src/main/ssh/ssh-legacy-migration-coordinator.ts b/src/main/ssh/ssh-legacy-migration-coordinator.ts new file mode 100644 index 00000000000..98fe27bd643 --- /dev/null +++ b/src/main/ssh/ssh-legacy-migration-coordinator.ts @@ -0,0 +1,254 @@ +import { relayDaemonGrantHasTerminalLegacyCutover } from '../../shared/terminal-legacy-cutover' +import { sshLegacyEvidenceId } from './ssh-legacy-migration-evidence-identity' +import { planSshLegacyMigrationInventory } from './ssh-legacy-migration-inventory-planner' +import type { SshLegacyMigrationInventoryPlan } from './ssh-legacy-migration-inventory-types' +import { + collectSshLegacyGc, + commitSshLegacyMigrationBarrier, + inspectSshLegacyWorker, + migrateSshLegacyWorker, + readSshLegacyGcProtection, + type SshLegacyWorkerMigrationCommit, + type SshLegacyWorkerMigrationOperation +} from './ssh-legacy-migration-rpc' +import { + assertSshLegacyMigrationAttempt, + awaitSshLegacyEvidencePhase, + rethrowSshLegacyMigrationAttemptStop, + sshLegacyEvidenceContext +} from './ssh-legacy-migration-attempt' +import { + assertInspectionDigest, + assertInventoryIdentity, + assertInventoryMatchesInspections, + assertPlanCoverage, + validatedWorkers, + workerCatalog, + workerOperation +} from './ssh-legacy-migration-coordinator-plan' +import { + committedSshLegacyMigrationOutcome, + sshLegacyMigrationErrorMessage, + unresolvedSshLegacyMigrationOutcome +} from './ssh-legacy-migration-outcome' +import type { + LegacyPhysicalWorkerDescriptor, + SshLegacyInspectedWorker, + SshLegacyMigrationCoordinatorInput, + SshLegacyMigrationOutcome +} from './ssh-legacy-migration-coordinator-types' + +export { SshLegacyMigrationAttemptSupersededError } from './ssh-legacy-migration-attempt' + +export async function coordinateSshLegacyMigration( + input: SshLegacyMigrationCoordinatorInput +): Promise { + if (!relayDaemonGrantHasTerminalLegacyCutover(input.authorityCapabilities)) { + return Object.freeze({ kind: 'read-only', reason: 'capability-not-negotiated' }) + } + if (!input.evidenceProvider) { + return unresolvedSshLegacyMigrationOutcome( + 'worker-discovery', + 'migration evidence provider is unavailable', + 'none' + ) + } + assertSshLegacyMigrationAttempt(input) + const discovery = await awaitSshLegacyEvidencePhase(input, 'worker-discovery', () => + input.evidenceProvider!.discoverWorkers(sshLegacyEvidenceContext(input)) + ) + if (discovery.kind === 'outcome') { + return discovery.outcome + } + if (discovery.value.kind === 'unresolved') { + return unresolvedSshLegacyMigrationOutcome('worker-discovery', discovery.value.reason, 'none') + } + let workers: readonly LegacyPhysicalWorkerDescriptor[] + try { + workers = validatedWorkers(discovery.value.workers) + } catch (error) { + return unresolvedSshLegacyMigrationOutcome( + 'worker-discovery', + sshLegacyMigrationErrorMessage(error), + 'none' + ) + } + + const inspected: SshLegacyInspectedWorker[] = [] + for (const worker of workers) { + try { + assertSshLegacyMigrationAttempt(input) + const inspection = await inspectSshLegacyWorker({ + rpc: input.rpc, + worker, + signal: input.signal + }) + assertSshLegacyMigrationAttempt(input) + assertInspectionDigest(worker, inspection) + inspected.push(Object.freeze({ descriptor: worker, inspection })) + } catch (error) { + rethrowSshLegacyMigrationAttemptStop(input, error) + return unresolvedSshLegacyMigrationOutcome( + 'inspection', + sshLegacyMigrationErrorMessage(error), + 'none', + worker.workerId + ) + } + } + + const evidence = await awaitSshLegacyEvidencePhase(input, 'evidence', () => + input.evidenceProvider!.buildInventory({ + ...sshLegacyEvidenceContext(input), + workers: Object.freeze(inspected) + }) + ) + if (evidence.kind === 'outcome') { + return evidence.outcome + } + if (evidence.value.kind === 'unresolved') { + return unresolvedSshLegacyMigrationOutcome('evidence', evidence.value.reason, 'none') + } + let plan: SshLegacyMigrationInventoryPlan + try { + assertInventoryIdentity(input, evidence.value.inventory) + assertInventoryMatchesInspections(evidence.value.inventory.liveRelays, inspected) + plan = planSshLegacyMigrationInventory(evidence.value.inventory) + assertPlanCoverage(plan, inspected) + } catch (error) { + return unresolvedSshLegacyMigrationOutcome( + 'planning', + sshLegacyMigrationErrorMessage(error), + 'none' + ) + } + + const receipts: Readonly<{ + workerId: string + receiptId: string + sequence: number + duplicate: boolean + }>[] = [] + for (const prepared of inspected) { + const workerId = prepared.descriptor.workerId + const hasCandidates = [...plan.imports, ...plan.unresolved].some( + (candidate) => candidate.physicalPty.workerId === workerId + ) + if (!hasCandidates) { + if (prepared.inspection.ptys.length > 0) { + return unresolvedSshLegacyMigrationOutcome( + 'planning', + 'prepared worker inventory was not cataloged', + 'none' + ) + } + continue + } + const catalog = workerCatalog(plan, workerId) + const operation = workerOperation(plan, prepared, catalog) + const committed = await commitWorker(input, prepared.descriptor, operation, receipts.length) + if (committed.kind === 'outcome') { + return committed.outcome + } + receipts.push( + Object.freeze({ + workerId: prepared.descriptor.workerId, + receiptId: committed.value.receipt.receiptId, + sequence: committed.value.receipt.sequence, + duplicate: committed.value.duplicate + }) + ) + } + + let catalogRevision: number + const barrierIdSeed = Object.freeze(receipts.map(({ receiptId }) => receiptId).sort()) + try { + assertSshLegacyMigrationAttempt(input) + catalogRevision = await readSshLegacyGcProtection({ rpc: input.rpc, signal: input.signal }) + assertSshLegacyMigrationAttempt(input) + if (receipts.some((receipt) => receipt.sequence > catalogRevision)) { + throw new Error('legacy catalog revision precedes a committed receipt') + } + } catch (error) { + rethrowSshLegacyMigrationAttemptStop(input, error) + return unresolvedSshLegacyMigrationOutcome( + 'barrier', + sshLegacyMigrationErrorMessage(error), + 'catalog-committed' + ) + } + const barrierId = sshLegacyEvidenceId('ssh-legacy-barrier', [ + plan.migrationId, + barrierIdSeed, + catalogRevision + ]) + try { + assertSshLegacyMigrationAttempt(input) + await commitSshLegacyMigrationBarrier({ + rpc: input.rpc, + barrierId, + catalogRevision, + signal: input.signal + }) + assertSshLegacyMigrationAttempt(input) + } catch (error) { + rethrowSshLegacyMigrationAttemptStop(input, error) + return unresolvedSshLegacyMigrationOutcome( + 'barrier', + sshLegacyMigrationErrorMessage(error), + 'catalog-committed' + ) + } + + try { + const removed = await collectSshLegacyGc({ + rpc: input.rpc, + barrierId, + signal: input.signal + }) + assertSshLegacyMigrationAttempt(input) + return committedSshLegacyMigrationOutcome(plan, receipts, barrierId, catalogRevision, { + kind: 'completed', + removed + }) + } catch (error) { + rethrowSshLegacyMigrationAttemptStop(input, error) + return committedSshLegacyMigrationOutcome(plan, receipts, barrierId, catalogRevision, { + kind: 'pending', + reason: sshLegacyMigrationErrorMessage(error) + }) + } +} + +async function commitWorker( + input: SshLegacyMigrationCoordinatorInput, + worker: LegacyPhysicalWorkerDescriptor, + operation: SshLegacyWorkerMigrationOperation, + priorCommitCount: number +): Promise< + | Readonly<{ kind: 'commit'; value: SshLegacyWorkerMigrationCommit }> + | Readonly<{ kind: 'outcome'; outcome: SshLegacyMigrationOutcome }> +> { + try { + assertSshLegacyMigrationAttempt(input) + const committed = await migrateSshLegacyWorker({ + rpc: input.rpc, + worker, + operation, + signal: input.signal + }) + assertSshLegacyMigrationAttempt(input) + return { kind: 'commit', value: committed } + } catch (error) { + rethrowSshLegacyMigrationAttemptStop(input, error) + return { + kind: 'outcome', + outcome: unresolvedSshLegacyMigrationOutcome( + 'catalog-commit', + sshLegacyMigrationErrorMessage(error), + priorCommitCount > 0 ? 'catalog-partially-committed' : 'commit-uncertain', + worker.workerId + ) + } + } +} diff --git a/src/main/ssh/ssh-legacy-migration-evidence-bridge-types.ts b/src/main/ssh/ssh-legacy-migration-evidence-bridge-types.ts new file mode 100644 index 00000000000..a9ec30048e8 --- /dev/null +++ b/src/main/ssh/ssh-legacy-migration-evidence-bridge-types.ts @@ -0,0 +1,115 @@ +import type { TerminalLegacyWorkspaceEvidence } from '../../shared/terminal-legacy-cutover' +import type { TerminalAuthorityNamespace } from '../../shared/terminal-session-authority-identity' +import type { TerminalAuthorityPathFlavor } from '../../shared/terminal-session-authority-locator' +import type { FolderWorkspace, WorkspaceSessionState } from '../../shared/types' +import type { SshPtyConsumerRecovery, SshRemotePtyLease } from '../../shared/ssh-types' +import type { + SshLegacyMigrationInventoryInput, + SshLegacyRelayIdentityProof, + SshLegacySerializedPtyEvidence +} from './ssh-legacy-migration-inventory-types' + +export type SshLegacyWorkspaceReference = + | Readonly<{ + kind: 'git-worktree' + clientWorkspaceId: string + path: string + }> + | Readonly<{ + kind: 'folder-workspace' + clientWorkspaceId: string + path: string + }> + | Readonly<{ + kind: 'floating' + clientWorkspaceId: string + path: string | null + }> + | Readonly<{ + kind: 'workspace-path' + path: string + }> + +export type SshLegacyUnresolvedPaneEvidence = Readonly<{ + targetId: string + partitionId: string + ptyId: string + paneKey: string + tabId: string + leafId: string + rendererGeneration: number | null + workspaceReference: SshLegacyWorkspaceReference +}> + +export type SshLegacyPersistedWorkspacePartition = Readonly<{ + targetId: string + partitionId: string + session: Readonly> + folderWorkspaces: readonly Readonly>[] + floatingWorkspacePath?: string | null +}> + +export type SshLegacyWorkerRecoveryAssociation = Readonly<{ + targetId: string + endpointId: string + workerId: string + buildId: string + recovery: Readonly +}> + +export type SshLegacyLocalMigrationEvidence = Readonly<{ + panes: readonly SshLegacyUnresolvedPaneEvidence[] + leases: readonly Readonly[] + workerRecoveries: readonly SshLegacyWorkerRecoveryAssociation[] +}> + +export type SshLegacyDiscoveredRelayInventoryRow = Readonly<{ + physicalPtyId: string + ptyIncarnationId: string | null + processId: number | null + workspaceReference: SshLegacyWorkspaceReference + serialized: SshLegacySerializedPtyEvidence +}> + +export type SshLegacyDiscoveredRelayEvidence = Readonly<{ + targetId: string + endpointId: string + workerId: string + buildId: string + observedAtMs: number + identityProof: SshLegacyRelayIdentityProof + rows: readonly SshLegacyDiscoveredRelayInventoryRow[] +}> + +export type SshLegacyWorkspaceResolutionRequest = Readonly<{ + targetId: string + authorityHostId: string + hostPathFlavor: TerminalAuthorityPathFlavor + source: 'local-layout' | 'remote-snapshot' | 'relay-inventory' + partitionId: string | null + endpointId: string | null + reference: SshLegacyWorkspaceReference +}> + +export type SshLegacyWorkspaceResolution = Readonly<{ + namespace: TerminalAuthorityNamespace + workspace: TerminalLegacyWorkspaceEvidence +}> + +export type SshLegacyWorkspaceResolver = ( + request: SshLegacyWorkspaceResolutionRequest +) => SshLegacyWorkspaceResolution | Promise + +export type SshLegacyMigrationEvidenceBridgeInput = Readonly<{ + targetId: string + authorityHostId: string + hostPathFlavor: TerminalAuthorityPathFlavor + persistedWorkspacePartitions: readonly SshLegacyPersistedWorkspacePartition[] + persistedPtyLeases: readonly Readonly[] + workerRecoveries: readonly SshLegacyWorkerRecoveryAssociation[] + remoteSnapshotPanes: readonly SshLegacyUnresolvedPaneEvidence[] + discoveredRelays: readonly SshLegacyDiscoveredRelayEvidence[] + resolveWorkspace: SshLegacyWorkspaceResolver +}> + +export type SshLegacyMigrationEvidenceBridgeResult = SshLegacyMigrationInventoryInput diff --git a/src/main/ssh/ssh-legacy-migration-evidence-bridge.test.ts b/src/main/ssh/ssh-legacy-migration-evidence-bridge.test.ts new file mode 100644 index 00000000000..b251e31151f --- /dev/null +++ b/src/main/ssh/ssh-legacy-migration-evidence-bridge.test.ts @@ -0,0 +1,639 @@ +import { describe, expect, it } from 'vitest' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants' +import type { TerminalLegacyWorkspaceEvidence } from '../../shared/terminal-legacy-cutover' +import type { TerminalAuthorityPathFlavor } from '../../shared/terminal-session-authority-locator' +import { makePaneKey } from '../../shared/stable-pane-id' +import { toAppSshPtyId } from '../../shared/ssh-pty-id' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../shared/types' +import { + SSH_LEGACY_MIGRATION_EVIDENCE_CAPACITY, + SshLegacyMigrationEvidenceError, + buildSshLegacyMigrationInventoryInput, + parseSshLegacyRemoteWorkspaceSnapshotEvidence, + type SshLegacyDiscoveredRelayEvidence, + type SshLegacyMigrationEvidenceBridgeInput, + type SshLegacyPersistedWorkspacePartition, + type SshLegacyWorkerRecoveryAssociation, + type SshLegacyWorkspaceResolution, + type SshLegacyWorkspaceResolutionRequest +} from './ssh-legacy-migration-evidence-bridge' +import { + floatingWorkspace, + folderWorkspace, + gitWorkspace +} from './__tests__/ssh-legacy-migration-inventory' +import { planSshLegacyMigrationInventory } from './ssh-legacy-migration-inventory-planner' + +const DEFAULT_LEAF_ID = '00000000-0000-4000-8000-000000000001' + +describe('SSH legacy migration evidence bridge', () => { + it('constructs an exact generation-zero import without liveness or client namespace guesses', async () => { + const scenario = makeScenario({ + clientWorkspaceId: 'client-repo-id::/client/private/repo', + canonicalWorkspace: gitWorkspace( + '/authority/canonical/repo', + 'authority-repo-id::/authority/canonical/repo' + ) + }) + + const inventory = await buildSshLegacyMigrationInventoryInput(scenario.input) + const plan = planSshLegacyMigrationInventory(inventory) + + expect(plan.imports).toHaveLength(1) + expect(plan.unresolved).toEqual([]) + expect(plan.imports[0]).toMatchObject({ + namespace: { authorityHostId: 'authority-host', namespaceId: 'authority-namespace' }, + pane: { paneGenerationId: 'renderer:0' }, + matchEvidence: { localLease: { rendererGeneration: 0 } } + }) + expect(inventory.persistedPtyLeases[0]).toMatchObject({ + state: 'expired', + worktreeId: 'authority-repo-id::/authority/canonical/repo' + }) + expect(inventory.liveRelays[0].rows[0].serialized.worktreeId).toBe( + 'authority-repo-id::/authority/canonical/repo' + ) + expect(new Set(scenario.requests.map((request) => request.source))).toEqual( + new Set(['local-layout', 'remote-snapshot', 'relay-inventory']) + ) + expect(scenario.requests.map((request) => request.reference)).toContainEqual({ + kind: 'git-worktree', + clientWorkspaceId: 'client-repo-id::/client/private/repo', + path: '/client/private/repo' + }) + expect(JSON.stringify(plan.summary)).not.toContain('/client/private') + expect(JSON.stringify(plan.summary)).not.toContain('/authority/canonical') + }) + + it('preserves Windows folder paths and floating paths until authority resolution', async () => { + const windowsFolder = makeScenario({ + hostPathFlavor: 'windows', + workspaceKind: 'folder', + clientWorkspaceId: 'folder:client-folder-id', + localPath: 'C:\\Users\\Alice\\client-folder', + remotePath: 'C:\\Users\\Alice\\snapshot-folder', + relayPath: 'C:\\Users\\Alice\\relay-folder', + canonicalWorkspace: folderWorkspace('C:/Authority/Canonical Folder', 'windows') + }) + const floating = makeScenario({ + workspaceKind: 'floating', + clientWorkspaceId: FLOATING_TERMINAL_WORKTREE_ID, + localPath: '/client/floating-cwd', + remotePath: '/snapshot/floating-cwd', + relayPath: '/relay/floating-cwd', + canonicalWorkspace: floatingWorkspace() + }) + + const folderInventory = await buildSshLegacyMigrationInventoryInput(windowsFolder.input) + const floatingInventory = await buildSshLegacyMigrationInventoryInput(floating.input) + + expect(planSshLegacyMigrationInventory(folderInventory).imports[0].workspace).toEqual( + folderWorkspace('C:/Authority/Canonical Folder', 'windows') + ) + expect(folderInventory.persistedPtyLeases[0].worktreeId).toBeUndefined() + expect(folderInventory.liveRelays[0].rows[0].serialized.worktreeId).toBeNull() + expect(windowsFolder.requests.map(referencePath)).toEqual([ + 'C:\\Users\\Alice\\client-folder', + 'C:\\Users\\Alice\\snapshot-folder', + 'C:\\Users\\Alice\\relay-folder' + ]) + expect(planSshLegacyMigrationInventory(floatingInventory).imports[0].workspace).toEqual( + floatingWorkspace() + ) + expect(floatingInventory.persistedPtyLeases[0].worktreeId).toBeUndefined() + expect(floatingInventory.liveRelays[0].rows[0].serialized.worktreeId).toBeNull() + expect(floating.requests.map(referencePath)).toEqual([ + '/client/floating-cwd', + '/snapshot/floating-cwd', + '/relay/floating-cwd' + ]) + }) + + it('excludes other targets but leaves same-target cross-partition evidence ambiguous', async () => { + const scenario = makeScenario() + const wrongTarget = 'other-target' + const wrongPartition = { + ...scenario.partition, + targetId: wrongTarget, + partitionId: 'wrong-target-partition' + } + const wrongLease = { ...scenario.input.persistedPtyLeases[0], targetId: wrongTarget } + const wrongRecovery = { + ...scenario.association, + targetId: wrongTarget, + recovery: { ...scenario.association.recovery, targetId: wrongTarget } + } + const wrongRelay = { + ...scenario.relay, + targetId: wrongTarget, + endpointId: 'wrong-target-endpoint' + } + const scoped = await buildSshLegacyMigrationInventoryInput({ + ...scenario.input, + persistedWorkspacePartitions: [wrongPartition, scenario.partition], + persistedPtyLeases: [wrongLease, ...scenario.input.persistedPtyLeases], + workerRecoveries: [wrongRecovery, scenario.association], + remoteSnapshotPanes: [ + { ...scenario.input.remoteSnapshotPanes[0], targetId: wrongTarget }, + ...scenario.input.remoteSnapshotPanes + ], + discoveredRelays: [wrongRelay, scenario.relay] + }) + + expect(planSshLegacyMigrationInventory(scoped).imports).toHaveLength(1) + expect(scoped.localLayoutPanes.map((pane) => pane.partitionId)).toEqual(['local-partition']) + + const ambiguous = await buildSshLegacyMigrationInventoryInput({ + ...scenario.input, + persistedWorkspacePartitions: [ + scenario.partition, + { ...scenario.partition, partitionId: 'second-local-partition' } + ] + }) + const ambiguousPlan = planSshLegacyMigrationInventory(ambiguous) + expect(ambiguousPlan.imports).toEqual([]) + expect(ambiguousPlan.unresolved[0].reason).toBe('ambiguous-pane-generation') + }) + + it('requires one explicit recovery association per target endpoint, worker, and build', async () => { + const first = makeScenario({ + physicalPtyId: 'pty-first', + workerId: 'worker-first', + endpointId: 'endpoint-first', + buildId: 'shared-build', + clientWorkspaceId: 'client-first::/client/first', + canonicalWorkspace: gitWorkspace('/authority/first', 'authority::/authority/first') + }) + const second = makeScenario({ + physicalPtyId: 'pty-second', + workerId: 'worker-second', + endpointId: 'endpoint-second', + buildId: 'shared-build', + tabId: 'tab-second', + leafId: '00000000-0000-4000-8000-000000000002', + partitionId: 'partition-second', + clientWorkspaceId: 'client-second::/client/second', + remotePath: '/snapshot/second', + canonicalWorkspace: gitWorkspace('/authority/second', 'authority::/authority/second') + }) + const combined = combineScenarios(first, second) + + const inventory = await buildSshLegacyMigrationInventoryInput(combined) + expect(inventory.persistedConsumerRecoveries).toEqual([ + { + targetId: 'target-a', + workerId: 'worker-first', + clientInstanceId: 'recovery-worker-first', + serverBuildId: 'shared-build' + }, + { + targetId: 'target-a', + workerId: 'worker-second', + clientInstanceId: 'recovery-worker-second', + serverBuildId: 'shared-build' + } + ]) + expect(planSshLegacyMigrationInventory(inventory).imports).toHaveLength(2) + + const reusedRecovery = { + ...second.association, + recovery: first.association.recovery + } + await expect( + buildSshLegacyMigrationInventoryInput({ + ...combined, + workerRecoveries: [first.association, reusedRecovery] + }) + ).rejects.toMatchObject({ code: 'ambiguity' }) + }) + + it('keeps stale or incomplete evidence unresolved and is deterministic after reordering', async () => { + const first = makeScenario() + const second = makeScenario({ + physicalPtyId: 'pty-two', + workerId: 'worker-two', + endpointId: 'endpoint-two', + buildId: 'build-two', + tabId: 'tab-two', + leafId: '00000000-0000-4000-8000-000000000002', + partitionId: 'partition-two', + clientWorkspaceId: 'client-two::/client/two', + remotePath: '/snapshot/two', + canonicalWorkspace: gitWorkspace('/authority/two', 'authority::/authority/two') + }) + const combined = combineScenarios(first, second) + const reordered = { + ...combined, + persistedWorkspacePartitions: combined.persistedWorkspacePartitions.toReversed(), + persistedPtyLeases: combined.persistedPtyLeases.toReversed(), + workerRecoveries: combined.workerRecoveries.toReversed(), + remoteSnapshotPanes: combined.remoteSnapshotPanes.toReversed(), + discoveredRelays: combined.discoveredRelays + .toReversed() + .map((relay) => ({ ...relay, rows: relay.rows.toReversed() })) + } + const originalPlan = planSshLegacyMigrationInventory( + await buildSshLegacyMigrationInventoryInput(combined) + ) + const reorderedPlan = planSshLegacyMigrationInventory( + await buildSshLegacyMigrationInventoryInput(reordered) + ) + + expect(reorderedPlan).toEqual(originalPlan) + + const staleInventory = await buildSshLegacyMigrationInventoryInput({ + ...first.input, + remoteSnapshotPanes: [{ ...first.input.remoteSnapshotPanes[0], rendererGeneration: 1 }] + }) + expect(planSshLegacyMigrationInventory(staleInventory).unresolved[0].reason).toBe( + 'workspace-mismatch' + ) + + const relay = first.relay + const incompleteInventory = await buildSshLegacyMigrationInventoryInput({ + ...first.input, + discoveredRelays: [ + { + ...relay, + rows: relay.rows.map((row) => ({ + ...row, + processId: null, + serialized: { ...row.serialized, processId: null } + })) + } + ] + }) + expect(planSshLegacyMigrationInventory(incompleteInventory).unresolved[0].reason).toBe( + 'physical-pty-incarnation-unproved' + ) + }) + + it('rejects oversized top-level evidence before scanning malformed entries', async () => { + const scenario = makeScenario() + const malformedOversized = Array.from( + { length: SSH_LEGACY_MIGRATION_EVIDENCE_CAPACITY.workspacePartitions + 1 }, + () => null as never + ) + + await expect( + buildSshLegacyMigrationInventoryInput({ + ...scenario.input, + persistedWorkspacePartitions: malformedOversized, + discoveredRelays: [null as never] + }) + ).rejects.toBeInstanceOf(SshLegacyMigrationEvidenceError) + await expect( + buildSshLegacyMigrationInventoryInput({ + ...scenario.input, + persistedWorkspacePartitions: malformedOversized, + discoveredRelays: [null as never] + }) + ).rejects.toMatchObject({ code: 'capacity' }) + }) +}) + +type ScenarioOptions = Readonly<{ + targetId?: string + authorityHostId?: string + hostPathFlavor?: TerminalAuthorityPathFlavor + endpointId?: string + workerId?: string + buildId?: string + physicalPtyId?: string + ptyIncarnationId?: string + processId?: number + tabId?: string + leafId?: string + partitionId?: string + generation?: number + workspaceKind?: 'git' | 'folder' | 'floating' + clientWorkspaceId?: string + localPath?: string + remotePath?: string + relayPath?: string + canonicalWorkspace?: TerminalLegacyWorkspaceEvidence +}> + +type Scenario = Readonly<{ + input: SshLegacyMigrationEvidenceBridgeInput + partition: SshLegacyPersistedWorkspacePartition + association: SshLegacyWorkerRecoveryAssociation + relay: SshLegacyDiscoveredRelayEvidence + resolution: SshLegacyWorkspaceResolution + requests: SshLegacyWorkspaceResolutionRequest[] + ownsRequest: (request: SshLegacyWorkspaceResolutionRequest) => boolean +}> + +function makeScenario(options: ScenarioOptions = {}): Scenario { + const targetId = options.targetId ?? 'target-a' + const authorityHostId = options.authorityHostId ?? 'authority-host' + const hostPathFlavor = options.hostPathFlavor ?? 'posix' + const endpointId = options.endpointId ?? 'endpoint-a' + const workerId = options.workerId ?? 'worker-a' + const buildId = options.buildId ?? 'build-a' + const physicalPtyId = options.physicalPtyId ?? 'pty-a' + const ptyIncarnationId = options.ptyIncarnationId ?? 'incarnation-a' + const processId = options.processId ?? 4_201 + const tabId = options.tabId ?? 'tab-a' + const leafId = options.leafId ?? DEFAULT_LEAF_ID + const partitionId = options.partitionId ?? 'local-partition' + const generation = options.generation ?? 0 + const workspaceKind = options.workspaceKind ?? 'git' + const clientWorkspaceId = + options.clientWorkspaceId ?? + (workspaceKind === 'folder' + ? 'folder:folder-a' + : workspaceKind === 'floating' + ? FLOATING_TERMINAL_WORKTREE_ID + : 'client-repo::/client/repo') + const localPath = options.localPath ?? '/client/repo' + const remotePath = options.remotePath ?? '/snapshot/repo' + const relayPath = options.relayPath ?? '/relay/repo' + const canonicalWorkspace = + options.canonicalWorkspace ?? gitWorkspace('/authority/repo', 'authority::/authority/repo') + const appPtyId = toAppSshPtyId(targetId, physicalPtyId) + const paneKey = makePaneKey(tabId, leafId) + const localTab = makeTab({ tabId, ptyId: appPtyId, worktreeId: clientWorkspaceId, generation }) + const partition = makePartition({ + targetId, + partitionId, + workspaceKind, + clientWorkspaceId, + localPath, + tab: localTab, + leafId, + appPtyId + }) + const remoteSnapshotPanes = parseSshLegacyRemoteWorkspaceSnapshotEvidence({ + targetId, + partitionId: `remote-${partitionId}`, + snapshot: makeRemoteSnapshot({ + workspaceKind, + workspacePath: remotePath, + tab: localTab, + leafId, + appPtyId + }) + }) + const resolution = Object.freeze({ + namespace: Object.freeze({ authorityHostId, namespaceId: 'authority-namespace' }), + workspace: canonicalWorkspace + }) + const association = makeRecoveryAssociation({ targetId, endpointId, workerId, buildId }) + const relay = makeRelay({ + targetId, + endpointId, + workerId, + buildId, + physicalPtyId, + ptyIncarnationId, + processId, + paneKey, + tabId, + clientWorkspaceId, + workspaceKind, + relayPath, + hostPathFlavor + }) + const requests: SshLegacyWorkspaceResolutionRequest[] = [] + const ownsRequest = (request: SshLegacyWorkspaceResolutionRequest): boolean => + request.reference.kind === 'workspace-path' + ? request.reference.path === remotePath + : 'clientWorkspaceId' in request.reference && + request.reference.clientWorkspaceId === clientWorkspaceId + const input: SshLegacyMigrationEvidenceBridgeInput = { + targetId, + authorityHostId, + hostPathFlavor, + persistedWorkspacePartitions: [partition], + persistedPtyLeases: [ + { + targetId, + ptyId: physicalPtyId, + incarnationId: ptyIncarnationId, + worktreeId: clientWorkspaceId, + tabId, + leafId, + paneGeneration: generation, + state: 'expired', + createdAt: 99_999, + updatedAt: 1 + } + ], + workerRecoveries: [association], + remoteSnapshotPanes, + discoveredRelays: [relay], + resolveWorkspace: (request) => { + requests.push(request) + return resolution + } + } + return { input, partition, association, relay, resolution, requests, ownsRequest } +} + +function makePartition(args: { + targetId: string + partitionId: string + workspaceKind: 'git' | 'folder' | 'floating' + clientWorkspaceId: string + localPath: string + tab: TerminalTab + leafId: string + appPtyId: string +}): SshLegacyPersistedWorkspacePartition { + const folderId = args.clientWorkspaceId.startsWith('folder:') + ? args.clientWorkspaceId.slice('folder:'.length) + : 'unused-folder' + return { + targetId: args.targetId, + partitionId: args.partitionId, + session: { + tabsByWorktree: { [args.clientWorkspaceId]: [args.tab] }, + terminalLayoutsByTabId: { [args.tab.id]: makeLayout(args.leafId, args.appPtyId) } + }, + folderWorkspaces: + args.workspaceKind === 'folder' ? [{ id: folderId, folderPath: args.localPath }] : [], + ...(args.workspaceKind === 'floating' ? { floatingWorkspacePath: args.localPath } : {}) + } +} + +function makeRemoteSnapshot(args: { + workspaceKind: 'git' | 'folder' | 'floating' + workspacePath: string + tab: TerminalTab + leafId: string + appPtyId: string +}): unknown { + const { worktreeId: _worktreeId, ...tab } = args.tab + void _worktreeId + const workspacePath = + args.workspaceKind === 'floating' ? FLOATING_TERMINAL_WORKTREE_ID : args.workspacePath + return { + namespace: 'client-routing-namespace', + revision: 1, + updatedAt: 123, + schemaVersion: 1, + session: { + tabsByWorktreePath: { + [workspacePath]: [ + { + ...tab, + worktreePath: workspacePath, + ...(args.workspaceKind === 'floating' ? { startupCwd: args.workspacePath } : {}) + } + ] + }, + terminalLayoutsByTabId: { [args.tab.id]: makeLayout(args.leafId, args.appPtyId) } + } + } +} + +function makeTab(args: { + tabId: string + ptyId: string + worktreeId: string + generation: number +}): TerminalTab { + return { + id: args.tabId, + ptyId: args.ptyId, + worktreeId: args.worktreeId, + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + generation: args.generation, + ...(args.worktreeId === FLOATING_TERMINAL_WORKTREE_ID + ? { startupCwd: '/client/floating-cwd' } + : {}) + } +} + +function makeLayout(leafId: string, ptyId: string): TerminalLayoutSnapshot { + return { + root: { type: 'leaf', leafId }, + activeLeafId: leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [leafId]: ptyId } + } +} + +function makeRecoveryAssociation(args: { + targetId: string + endpointId: string + workerId: string + buildId: string +}): SshLegacyWorkerRecoveryAssociation { + return { + ...args, + recovery: { + targetId: args.targetId, + clientInstanceId: `recovery-${args.workerId}`, + serverBuildId: args.buildId, + clientGeneration: 1, + ownerGeneration: 1, + ownerLease: `lease-${args.workerId}` + } + } +} + +function makeRelay(args: { + targetId: string + endpointId: string + workerId: string + buildId: string + physicalPtyId: string + ptyIncarnationId: string + processId: number + paneKey: string + tabId: string + clientWorkspaceId: string + workspaceKind: 'git' | 'folder' | 'floating' + relayPath: string + hostPathFlavor: TerminalAuthorityPathFlavor +}): SshLegacyDiscoveredRelayEvidence { + const endpoint = + args.hostPathFlavor === 'windows' + ? ({ + kind: 'windows-named-pipe' as const, + pipeName: '\\\\.\\pipe\\orca-legacy', + processCreationMarker: 'process-created-a' + } as const) + : ({ kind: 'unix-socket' as const, device: '1', inode: '2', changedAtNs: '3' } as const) + const process = Object.freeze({ pid: 9_001, birthMarker: `birth-${args.workerId}` }) + const workspaceReference = + args.workspaceKind === 'folder' + ? ({ + kind: 'folder-workspace' as const, + clientWorkspaceId: args.clientWorkspaceId, + path: args.relayPath + } as const) + : args.workspaceKind === 'floating' + ? ({ + kind: 'floating' as const, + clientWorkspaceId: args.clientWorkspaceId, + path: args.relayPath + } as const) + : ({ + kind: 'git-worktree' as const, + clientWorkspaceId: args.clientWorkspaceId, + path: args.relayPath + } as const) + return { + targetId: args.targetId, + endpointId: args.endpointId, + workerId: args.workerId, + buildId: args.buildId, + observedAtMs: 777, + identityProof: { + expectedEndpoint: endpoint, + observedEndpoint: endpoint, + expectedProcess: process, + observedProcess: process + }, + rows: [ + { + physicalPtyId: args.physicalPtyId, + ptyIncarnationId: args.ptyIncarnationId, + processId: args.processId, + workspaceReference, + serialized: { + paneKey: args.paneKey, + tabId: args.tabId, + worktreeId: args.clientWorkspaceId, + cwd: args.relayPath, + ptyIncarnationId: args.ptyIncarnationId, + processId: args.processId + } + } + ] + } +} + +function combineScenarios( + ...scenarios: readonly Scenario[] +): SshLegacyMigrationEvidenceBridgeInput { + const first = scenarios[0] + return { + ...first.input, + persistedWorkspacePartitions: scenarios.flatMap( + (scenario) => scenario.input.persistedWorkspacePartitions + ), + persistedPtyLeases: scenarios.flatMap((scenario) => scenario.input.persistedPtyLeases), + workerRecoveries: scenarios.flatMap((scenario) => scenario.input.workerRecoveries), + remoteSnapshotPanes: scenarios.flatMap((scenario) => scenario.input.remoteSnapshotPanes), + discoveredRelays: scenarios.flatMap((scenario) => scenario.input.discoveredRelays), + resolveWorkspace: (request) => { + const owner = scenarios.find((scenario) => scenario.ownsRequest(request)) + if (!owner) { + throw new Error('unexpected workspace resolution request') + } + return owner.resolution + } + } +} + +function referencePath(request: SshLegacyWorkspaceResolutionRequest): string | null { + return request.reference.path +} diff --git a/src/main/ssh/ssh-legacy-migration-evidence-bridge.ts b/src/main/ssh/ssh-legacy-migration-evidence-bridge.ts new file mode 100644 index 00000000000..179e6345313 --- /dev/null +++ b/src/main/ssh/ssh-legacy-migration-evidence-bridge.ts @@ -0,0 +1,152 @@ +import { assertAuthorityId } from '../../shared/terminal-session-authority-identity' +import { + resolveSshLegacyPanes, + resolveSshLegacyRelays +} from './ssh-legacy-migration-authority-resolution' +import { + collectSshLegacyWorkerRecoveries, + indexSshLegacyDiscoveredRelays +} from './ssh-legacy-migration-consumer-association' +import { + SSH_LEGACY_MIGRATION_EVIDENCE_CAPACITY, + assertSshLegacyArrayCapacity, + failSshLegacyMigrationEvidence +} from './ssh-legacy-migration-evidence-capacity' +import type { + SshLegacyMigrationEvidenceBridgeInput, + SshLegacyMigrationEvidenceBridgeResult +} from './ssh-legacy-migration-evidence-bridge-types' +import { + projectSshLegacyConsumerEvidence, + projectSshLegacyLeaseEvidence, + projectSshLegacyPaneEvidence, + projectSshLegacyRelayEvidence, + sortSshLegacyEvidence +} from './ssh-legacy-migration-evidence-projection' +import { assertSshLegacyMigrationInventoryCapacity } from './ssh-legacy-migration-inventory-capacity' +import { assertSshLegacyMigrationInventoryInput } from './ssh-legacy-migration-inventory-validation' +import { normalizeSshLegacyPtyLeases } from './ssh-legacy-migration-lease-normalization' +import { collectSshLegacyLocalMigrationEvidence } from './ssh-legacy-migration-local-evidence' + +export async function buildSshLegacyMigrationInventoryInput( + input: SshLegacyMigrationEvidenceBridgeInput +): Promise { + assertBridgeIdentity(input) + preflightBridgeCapacity(input) + const local = collectSshLegacyLocalMigrationEvidence(input) + const relays = input.discoveredRelays.filter((relay) => relay.targetId === input.targetId) + const relayByAssociation = indexSshLegacyDiscoveredRelays(relays) + const persistedConsumerRecoveries = collectSshLegacyWorkerRecoveries( + input.targetId, + local.workerRecoveries, + relayByAssociation + ) + const localPanes = await resolveSshLegacyPanes(input, local.panes, 'local-layout') + const remotePanes = await resolveSshLegacyPanes( + input, + input.remoteSnapshotPanes.filter((pane) => pane.targetId === input.targetId), + 'remote-snapshot' + ) + const liveRelays = await resolveSshLegacyRelays(input, relays) + const result = Object.freeze({ + targetId: input.targetId, + authorityHostId: input.authorityHostId, + hostPathFlavor: input.hostPathFlavor, + persistedConsumerRecoveries: Object.freeze( + sortSshLegacyEvidence(persistedConsumerRecoveries, projectSshLegacyConsumerEvidence) + ), + persistedPtyLeases: Object.freeze( + sortSshLegacyEvidence( + normalizeSshLegacyPtyLeases(input.targetId, local.leases, localPanes), + projectSshLegacyLeaseEvidence + ) + ), + localLayoutPanes: Object.freeze( + sortSshLegacyEvidence( + localPanes.map((entry) => entry.pane), + projectSshLegacyPaneEvidence + ) + ), + remoteSnapshotPanes: Object.freeze( + sortSshLegacyEvidence( + remotePanes.map((entry) => entry.pane), + projectSshLegacyPaneEvidence + ) + ), + liveRelays: Object.freeze( + sortSshLegacyEvidence(liveRelays, (relay) => projectSshLegacyRelayEvidence(relay, true)) + ) + }) + assertSshLegacyMigrationInventoryCapacity(result) + assertSshLegacyMigrationInventoryInput(result) + return result +} + +function preflightBridgeCapacity(input: SshLegacyMigrationEvidenceBridgeInput): void { + assertSshLegacyArrayCapacity( + input.persistedWorkspacePartitions, + SSH_LEGACY_MIGRATION_EVIDENCE_CAPACITY.workspacePartitions, + 'workspace partitions' + ) + assertSshLegacyArrayCapacity( + input.persistedPtyLeases, + SSH_LEGACY_MIGRATION_EVIDENCE_CAPACITY.persistedPtyLeases, + 'persisted PTY leases' + ) + assertSshLegacyArrayCapacity( + input.workerRecoveries, + SSH_LEGACY_MIGRATION_EVIDENCE_CAPACITY.workerRecoveries, + 'worker recovery associations' + ) + assertSshLegacyArrayCapacity( + input.remoteSnapshotPanes, + SSH_LEGACY_MIGRATION_EVIDENCE_CAPACITY.totalRemotePanes, + 'remote snapshot panes' + ) + assertSshLegacyArrayCapacity( + input.discoveredRelays, + SSH_LEGACY_MIGRATION_EVIDENCE_CAPACITY.discoveredRelays, + 'discovered relays' + ) + let totalRows = 0 + for (const relay of input.discoveredRelays) { + assertSshLegacyArrayCapacity( + relay.rows, + SSH_LEGACY_MIGRATION_EVIDENCE_CAPACITY.rowsPerRelay, + 'discovered relay rows' + ) + totalRows += relay.rows.length + if (totalRows > SSH_LEGACY_MIGRATION_EVIDENCE_CAPACITY.totalRelayRows) { + failSshLegacyMigrationEvidence('capacity', 'total discovered relay rows') + } + } +} + +function assertBridgeIdentity(input: SshLegacyMigrationEvidenceBridgeInput): void { + assertAuthorityId(input.targetId, 'SSH legacy bridge targetId') + assertAuthorityId(input.authorityHostId, 'SSH legacy bridge authorityHostId') + if (input.hostPathFlavor !== 'posix' && input.hostPathFlavor !== 'windows') { + failSshLegacyMigrationEvidence('malformed', 'authority host path flavor') + } +} + +export type { + SshLegacyDiscoveredRelayEvidence, + SshLegacyDiscoveredRelayInventoryRow, + SshLegacyLocalMigrationEvidence, + SshLegacyMigrationEvidenceBridgeInput, + SshLegacyMigrationEvidenceBridgeResult, + SshLegacyPersistedWorkspacePartition, + SshLegacyUnresolvedPaneEvidence, + SshLegacyWorkerRecoveryAssociation, + SshLegacyWorkspaceReference, + SshLegacyWorkspaceResolution, + SshLegacyWorkspaceResolutionRequest, + SshLegacyWorkspaceResolver +} from './ssh-legacy-migration-evidence-bridge-types' +export { collectSshLegacyLocalMigrationEvidence } from './ssh-legacy-migration-local-evidence' +export { parseSshLegacyRemoteWorkspaceSnapshotEvidence } from './ssh-legacy-migration-remote-workspace-snapshot' +export { + SSH_LEGACY_MIGRATION_EVIDENCE_CAPACITY, + SshLegacyMigrationEvidenceError +} from './ssh-legacy-migration-evidence-capacity' diff --git a/src/main/ssh/ssh-legacy-migration-evidence-capacity.ts b/src/main/ssh/ssh-legacy-migration-evidence-capacity.ts new file mode 100644 index 00000000000..8de0a37fa67 --- /dev/null +++ b/src/main/ssh/ssh-legacy-migration-evidence-capacity.ts @@ -0,0 +1,81 @@ +import { isRecord } from '../../shared/terminal-session-authority-identity' + +export const SSH_LEGACY_MIGRATION_EVIDENCE_CAPACITY = Object.freeze({ + workspacePartitions: 64, + folderWorkspacesPerPartition: 4_096, + workspaceOwnersPerPartition: 8_192, + tabsPerWorkspace: 256, + layoutsPerPartition: 8_192, + bindingsPerLayout: 64, + layoutNodes: 127, + totalLocalPanes: 8_192, + remoteWorkspacePaths: 8_192, + totalRemoteTabs: 8_192, + totalRemotePanes: 8_192, + workerRecoveries: 256, + discoveredRelays: 256, + rowsPerRelay: 4_096, + totalRelayRows: 4_096, + persistedPtyLeases: 4_096 +}) + +export type SshLegacyMigrationEvidenceErrorCode = + | 'ambiguity' + | 'capacity' + | 'malformed' + | 'resolution' + +export class SshLegacyMigrationEvidenceError extends Error { + constructor( + readonly code: SshLegacyMigrationEvidenceErrorCode, + field: string + ) { + super(`SSH legacy migration evidence ${field} is ${errorDescription(code)}`) + this.name = 'SshLegacyMigrationEvidenceError' + } +} + +export function assertSshLegacyArrayCapacity( + value: unknown, + maximum: number, + field: string +): asserts value is readonly unknown[] { + if (!Array.isArray(value) || value.length > maximum) { + throw new SshLegacyMigrationEvidenceError('capacity', field) + } +} + +export function boundedSshLegacyRecordEntries( + value: unknown, + maximum: number, + field: string +): readonly (readonly [string, unknown])[] { + if (!isRecord(value)) { + throw new SshLegacyMigrationEvidenceError('malformed', field) + } + const entries = Object.entries(value) + if (entries.length > maximum) { + throw new SshLegacyMigrationEvidenceError('capacity', field) + } + return entries +} + +export function failSshLegacyMigrationEvidence( + code: SshLegacyMigrationEvidenceErrorCode, + field: string +): never { + throw new SshLegacyMigrationEvidenceError(code, field) +} + +function errorDescription(code: SshLegacyMigrationEvidenceErrorCode): string { + switch (code) { + case 'ambiguity': + return 'ambiguous' + case 'capacity': + return 'over capacity' + case 'malformed': + return 'malformed' + case 'resolution': + return 'unresolved on the authority host' + } +} diff --git a/src/main/ssh/ssh-legacy-migration-evidence-identity.ts b/src/main/ssh/ssh-legacy-migration-evidence-identity.ts new file mode 100644 index 00000000000..8693a9a8b99 --- /dev/null +++ b/src/main/ssh/ssh-legacy-migration-evidence-identity.ts @@ -0,0 +1,107 @@ +import { createHash } from 'node:crypto' +import type { TerminalLegacyWorkspaceEvidence } from '../../shared/terminal-legacy-cutover' +import type { TerminalAuthorityNamespace } from '../../shared/terminal-session-authority-identity' +import { terminalAuthorityNamespaceLocatorKey } from '../../shared/terminal-session-authority-locator' +import { parseAppSshPtyId } from '../../shared/ssh-pty-id' + +type CanonicalValue = + | boolean + | number + | string + | null + | readonly CanonicalValue[] + | Readonly<{ [key: string]: CanonicalValue }> + +export function sshLegacyEvidenceDigest(value: unknown): string { + return `sha256:${createHash('sha256').update(canonicalJson(value), 'utf8').digest('base64url')}` +} + +export function sshLegacyEvidenceId(prefix: string, value: unknown): string { + const digest = createHash('sha256').update(canonicalJson(value), 'utf8').digest('base64url') + return `${prefix}-${digest}` +} + +export function canonicalEvidenceSort(values: readonly T[]): T[] { + return [...values].sort(compareSshLegacyEvidence) +} + +export function canonicalEvidenceEqual(left: unknown, right: unknown): boolean { + return canonicalJson(left) === canonicalJson(right) +} + +export function compareSshLegacyEvidence(left: unknown, right: unknown): number { + return compareSshLegacyText(canonicalJson(left), canonicalJson(right)) +} + +export function compareSshLegacyText(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0 +} + +export function sshLegacyWorkspaceKey(workspace: TerminalLegacyWorkspaceEvidence): string { + return canonicalJson([ + workspace.kind, + terminalAuthorityNamespaceLocatorKey(workspace.locator), + workspace.kind === 'git-worktree' ? workspace.worktreeId : null + ]) +} + +export function sshLegacyWorkspaceAndNamespaceEqual( + left: Readonly<{ + namespace: TerminalAuthorityNamespace + workspace: TerminalLegacyWorkspaceEvidence + }>, + right: Readonly<{ + namespace: TerminalAuthorityNamespace + workspace: TerminalLegacyWorkspaceEvidence + }> +): boolean { + return ( + left.namespace.authorityHostId === right.namespace.authorityHostId && + left.namespace.namespaceId === right.namespace.namespaceId && + sshLegacyWorkspaceKey(left.workspace) === sshLegacyWorkspaceKey(right.workspace) + ) +} + +export function sshLegacyWorkspaceWorktreeId( + workspace: TerminalLegacyWorkspaceEvidence +): string | null { + return workspace.kind === 'git-worktree' ? workspace.worktreeId : null +} + +export function sshLegacyPhysicalPtyId(targetId: string, ptyId: string): string | null { + const parsed = parseAppSshPtyId(ptyId) + if (!parsed) { + return ptyId + } + return parsed.connectionId === targetId ? parsed.relayPtyId : null +} + +function canonicalJson(value: unknown): string { + return JSON.stringify(toCanonicalValue(value)) +} + +function toCanonicalValue(value: unknown): CanonicalValue { + if (value === null || typeof value === 'string' || typeof value === 'boolean') { + return value + } + if (typeof value === 'number') { + if (!Number.isFinite(value)) { + throw new Error('SSH legacy migration evidence contains a non-finite number') + } + return value + } + if (Array.isArray(value)) { + return value.map((entry) => toCanonicalValue(entry)) + } + if (typeof value !== 'object') { + throw new Error('SSH legacy migration evidence contains an unsupported value') + } + const canonical: Record = {} + for (const key of Object.keys(value).sort()) { + const selected = (value as Record)[key] + if (selected !== undefined) { + canonical[key] = toCanonicalValue(selected) + } + } + return canonical +} diff --git a/src/main/ssh/ssh-legacy-migration-evidence-projection.ts b/src/main/ssh/ssh-legacy-migration-evidence-projection.ts new file mode 100644 index 00000000000..c9e1b8cd3ad --- /dev/null +++ b/src/main/ssh/ssh-legacy-migration-evidence-projection.ts @@ -0,0 +1,174 @@ +import type { TerminalLegacyWorkspaceEvidence } from '../../shared/terminal-legacy-cutover' +import type { TerminalAuthorityNamespace } from '../../shared/terminal-session-authority-identity' +import type { SshRemotePtyLease } from '../../shared/ssh-types' +import { compareSshLegacyEvidence } from './ssh-legacy-migration-evidence-identity' +import type { + SshLegacyLayoutPaneEvidence, + SshLegacyLiveRelayInventory, + SshLegacyPersistedConsumerEvidence, + SshLegacyRelayIdentityProof, + SshLegacyRelayInventoryRow, + SshLegacySerializedPtyEvidence +} from './ssh-legacy-migration-inventory-types' + +export function projectSshLegacyConsumerEvidence( + value: SshLegacyPersistedConsumerEvidence +): Readonly> { + return { + targetId: value.targetId, + workerId: value.workerId, + clientInstanceId: value.clientInstanceId, + serverBuildId: value.serverBuildId + } +} + +export function projectSshLegacyLeaseEvidence( + value: SshRemotePtyLease +): Readonly> { + return { + targetId: value.targetId, + ptyId: value.ptyId, + incarnationId: value.incarnationId ?? null, + worktreeId: value.worktreeId ?? null, + tabId: value.tabId ?? null, + leafId: value.leafId ?? null, + paneGeneration: value.paneGeneration ?? null + } +} + +export function projectSshLegacyPaneEvidence( + value: SshLegacyLayoutPaneEvidence +): Readonly> { + return { + targetId: value.targetId, + partitionId: value.partitionId, + ptyId: value.ptyId, + paneKey: value.paneKey, + tabId: value.tabId, + leafId: value.leafId, + rendererGeneration: value.rendererGeneration, + namespace: projectNamespace(value.namespace), + workspace: projectWorkspace(value.workspace) + } +} + +export function projectSshLegacyRelayEvidence( + value: SshLegacyLiveRelayInventory, + includeRows: boolean +): Readonly> { + return { + workerId: value.workerId, + buildId: value.buildId, + observedAtMs: value.observedAtMs, + identityProof: projectSshLegacyRelayIdentityProof(value.identityProof), + ...(includeRows + ? { rows: sortSshLegacyEvidence(value.rows, projectSshLegacyInventoryRowEvidence) } + : {}) + } +} + +export function projectSshLegacyInventoryRowEvidence( + value: SshLegacyRelayInventoryRow +): Readonly> { + return { + workerId: value.workerId, + buildId: value.buildId, + physicalPtyId: value.physicalPtyId, + ptyIncarnationId: value.ptyIncarnationId, + processId: value.processId, + namespace: projectNamespace(value.namespace), + workspace: projectWorkspace(value.workspace), + serialized: projectSerializedEvidence(value.serialized) + } +} + +export function projectSshLegacySourceEvidence( + value: Readonly<{ + relay: SshLegacyLiveRelayInventory + row: SshLegacyRelayInventoryRow + }> +): Readonly> { + return { + relay: projectSshLegacyRelayEvidence(value.relay, false), + row: projectSshLegacyInventoryRowEvidence(value.row) + } +} + +export function sortSshLegacyEvidence( + values: readonly T[], + project: (value: T) => unknown +): T[] { + return [...values].sort((left, right) => compareSshLegacyEvidence(project(left), project(right))) +} + +function projectNamespace(value: TerminalAuthorityNamespace): Readonly> { + return { authorityHostId: value.authorityHostId, namespaceId: value.namespaceId } +} + +function projectWorkspace( + value: TerminalLegacyWorkspaceEvidence +): Readonly> { + const locator = + value.locator.kind === 'floating' + ? { kind: 'floating' } + : { + kind: 'workspace', + canonicalPath: value.locator.canonicalPath, + pathFlavor: value.locator.pathFlavor + } + return { + kind: value.kind, + locator, + ...(value.kind === 'git-worktree' ? { worktreeId: value.worktreeId } : {}) + } +} + +export function projectSshLegacyRelayIdentityProof( + value: SshLegacyRelayIdentityProof +): Readonly> { + return { + expectedEndpoint: projectSshLegacyEndpointIdentity(value.expectedEndpoint), + observedEndpoint: projectSshLegacyEndpointIdentity(value.observedEndpoint), + expectedProcess: projectSshLegacyProcessIdentity(value.expectedProcess), + observedProcess: projectSshLegacyProcessIdentity(value.observedProcess) + } +} + +export function projectSshLegacyEndpointIdentity( + value: SshLegacyRelayIdentityProof['expectedEndpoint'] +): unknown { + if (value === null) { + return null + } + return value.kind === 'unix-socket' + ? { + kind: value.kind, + device: value.device, + inode: value.inode, + changedAtNs: value.changedAtNs + } + : { + kind: value.kind, + pipeName: value.pipeName, + processCreationMarker: value.processCreationMarker + } +} + +export function projectSshLegacyProcessIdentity( + value: SshLegacyRelayIdentityProof['expectedProcess'] +): unknown { + return value === null ? null : { pid: value.pid, birthMarker: value.birthMarker } +} + +function projectSerializedEvidence( + value: SshLegacySerializedPtyEvidence +): Readonly> { + return { + paneKey: value.paneKey, + tabId: value.tabId, + worktreeId: value.worktreeId, + cwd: value.cwd, + ptyIncarnationId: value.ptyIncarnationId, + processId: value.processId + } +} diff --git a/src/main/ssh/ssh-legacy-migration-evidence-provider.test.ts b/src/main/ssh/ssh-legacy-migration-evidence-provider.test.ts new file mode 100644 index 00000000000..ebc31b60a1c --- /dev/null +++ b/src/main/ssh/ssh-legacy-migration-evidence-provider.test.ts @@ -0,0 +1,397 @@ +import { randomUUID } from 'node:crypto' +import { describe, expect, it, vi } from 'vitest' +import { TERMINAL_LEGACY_CUTOVER_CAPABILITY } from '../../shared/terminal-legacy-cutover' +import { toAppSshPtyId } from '../../shared/ssh-pty-id' +import type { SshConnection } from './ssh-connection' +import { coordinateSshLegacyMigration } from './ssh-legacy-migration-coordinator' +import type { + LegacyPhysicalWorkerDescriptor, + SshLegacyInspectedWorker, + SshLegacyPhysicalWorkerInspection +} from './ssh-legacy-migration-coordinator-types' +import { createSshLegacyMigrationEvidenceProvider } from './ssh-legacy-migration-evidence-provider' +import { sshLegacyEvidenceDigest } from './ssh-legacy-migration-evidence-identity' +import { FutureSshLegacyMigrationRpc } from './__tests__/ssh-legacy-migration-future-rpc' +import type { SshLegacyMigrationEvidenceStore } from './ssh-legacy-migration-store-evidence' +import { getRemoteHostPlatform } from './ssh-remote-platform' + +const execCommand = vi.hoisted(() => vi.fn()) +vi.mock('./ssh-relay-exec-command', () => ({ execCommand })) + +const TARGET_ID = 'target-a' +const HOST_ID = 'authority-host-a' +const PARTITION_ID = 'ssh:target-a' +const WORKTREE_PATH = '/srv/repos/repo-a' +const WORKTREE_ID = `repo-a::${WORKTREE_PATH}` +const TAB_ID = 'tab-a' +const LEAF_ID = randomUUID() +const PANE_KEY = `${TAB_ID}:${LEAF_ID}` +const PHYSICAL_PTY_ID = 'pty-1' +const APP_PTY_ID = toAppSshPtyId(TARGET_ID, PHYSICAL_PTY_ID) +const PTY_INCARNATION_ID = 'incarnation-1' +const PROCESS_ID = 4_201 +const BUILD_ID = '0.1.0+abc' +const CLIENT_INSTANCE_ID = 'client-a' +const ENDPOINT = Object.freeze({ + kind: 'unix-socket' as const, + device: '2049', + inode: '77', + changedAtNs: '1700000000000000000' +}) + +execCommand.mockResolvedValue( + `ORCA_LEGACY_PRIOR_RELAY ${JSON.stringify({ + endpoint: { device: '2049', inode: '77', changedAtNs: '1700000000000000000' }, + liveness: 'alive' + })}` +) + +const MARKER = Object.freeze({ + markerVersion: 1, + authorityHostId: HOST_ID, + ownerInstanceId: 'owner-prior', + ownerPid: 9_001, + ownerProcessToken: 'prior-process-token', + ownerBuildId: BUILD_ID, + ownerRelayDir: '/home/u/.orca-relay/relay-0.1.0+abc', + socketPath: '/home/u/.orca-relay/terminal-authority/authority.sock', + credentialFile: '/home/u/.orca-relay/terminal-authority/endpoint.credential', + compatibility: { major: 1, minMinor: 0, maxMinor: 0, capabilities: [], requiredCapabilities: [] }, + revision: 6 +}) + +function layout(): Record { + return { + root: { type: 'leaf', leafId: LEAF_ID }, + ptyIdsByLeafId: { [LEAF_ID]: APP_PTY_ID } + } +} + +function storeDouble( + overrides: Partial = {} +): SshLegacyMigrationEvidenceStore { + return { + getSshRemotePtyLeases: () => [ + { + targetId: TARGET_ID, + ptyId: APP_PTY_ID, + incarnationId: PTY_INCARNATION_ID, + worktreeId: WORKTREE_ID, + tabId: TAB_ID, + leafId: LEAF_ID, + paneGeneration: 3, + state: 'attached', + createdAt: 1, + updatedAt: 1 + } + ], + getSshPtyConsumerRecovery: () => ({ + targetId: TARGET_ID, + clientInstanceId: CLIENT_INSTANCE_ID, + serverBuildId: BUILD_ID, + clientGeneration: 1, + ownerGeneration: 1, + ownerLease: 'lease-a' + }), + getWorkspaceSessionHostIds: () => [PARTITION_ID], + getWorkspaceSession: () => ({ + tabsByWorktree: { [WORKTREE_ID]: [{ id: TAB_ID, ptyId: null, generation: 3 }] }, + terminalLayoutsByTabId: { [TAB_ID]: layout() } + }), + getFolderWorkspaces: () => [], + ...overrides + } +} + +function remoteSnapshot(): unknown { + return { + session: { + tabsByWorktreePath: { + [WORKTREE_PATH]: [{ id: TAB_ID, ptyId: null, worktreePath: WORKTREE_PATH, generation: 3 }] + }, + terminalLayoutsByTabId: { [TAB_ID]: layout() } + } + } +} + +function providerInput(overrides: Record = {}) { + return { + targetId: TARGET_ID, + partitionId: PARTITION_ID, + clientInstanceId: CLIENT_INSTANCE_ID, + hostPlatform: getRemoteHostPlatform('linux-x64'), + nodePath: '/usr/bin/node', + priorRelayStatus: { kind: 'superseded' as const, marker: MARKER }, + store: storeDouble(), + connection: () => ({}) as SshConnection, + remoteWorkspaceSnapshot: remoteSnapshot, + isAttemptCurrent: () => true, + now: () => 1_700, + ...overrides + } as Parameters[0] +} + +function inspection( + descriptor: LegacyPhysicalWorkerDescriptor, + identityProof?: SshLegacyPhysicalWorkerInspection['identityProof'] +): SshLegacyPhysicalWorkerInspection { + const evidence = { + protocolVersion: 1 as const, + workerId: descriptor.workerId, + routeId: descriptor.routeId, + buildId: descriptor.buildId, + identityProof: identityProof ?? { + expectedEndpoint: descriptor.expectedEndpoint, + observedEndpoint: descriptor.expectedEndpoint, + expectedProcess: descriptor.process, + observedProcess: descriptor.process + }, + ptys: [ + { + id: PHYSICAL_PTY_ID, + incarnationId: PTY_INCARNATION_ID, + processId: PROCESS_ID, + cwd: WORKTREE_PATH, + title: 'shell', + worktreeId: WORKTREE_ID, + serialized: { + paneKey: PANE_KEY, + tabId: TAB_ID, + worktreeId: WORKTREE_ID, + cwd: WORKTREE_PATH, + ptyIncarnationId: PTY_INCARNATION_ID, + processId: PROCESS_ID + } + } + ] + } + return Object.freeze({ + ...evidence, + preparation: { + mode: 'observational' as const, + token: 'token-a', + evidenceDigest: sshLegacyEvidenceDigest(evidence), + catalogValidation: 'before-isolation' as const, + replay: 'durable-operation-id' as const + } + }) as SshLegacyPhysicalWorkerInspection +} + +async function discovered( + input = providerInput() +): Promise }>> { + return { provider: createSshLegacyMigrationEvidenceProvider(input) } +} + +const CONTEXT = Object.freeze({ + targetId: TARGET_ID, + authorityHostId: HOST_ID, + hostPathFlavor: 'posix' as const, + attemptId: 'attempt-a' +}) + +async function runMigration( + options: Readonly<{ + input?: Parameters[0] + rpcOptions?: ConstructorParameters[1] + signal?: AbortSignal + isAttemptCurrent?: () => boolean + identityProof?: SshLegacyPhysicalWorkerInspection['identityProof'] + rpc?: FutureSshLegacyMigrationRpc + }> = {} +) { + const input = options.input ?? providerInput() + const provider = createSshLegacyMigrationEvidenceProvider(input) + const signal = options.signal ?? new AbortController().signal + const discovery = await provider.discoverWorkers({ ...CONTEXT, signal }) + const workers: SshLegacyInspectedWorker[] = + discovery.kind === 'ready' + ? discovery.workers.map((descriptor) => ({ + descriptor, + inspection: inspection(descriptor, options.identityProof) + })) + : [] + const rpc = options.rpc ?? new FutureSshLegacyMigrationRpc(workers, options.rpcOptions) + const outcome = await coordinateSshLegacyMigration({ + ...CONTEXT, + authorityCapabilities: [TERMINAL_LEGACY_CUTOVER_CAPABILITY], + attemptId: 'attempt-a', + signal, + isAttemptCurrent: options.isAttemptCurrent ?? (() => true), + rpc, + evidenceProvider: provider + }) + return { outcome, rpc, workers } +} + +describe('production legacy migration evidence provider', () => { + it('discovers exactly the recorded prior relay as one bounded worker', async () => { + const { provider } = await discovered() + const discovery = await provider.discoverWorkers({ + ...CONTEXT, + signal: new AbortController().signal + }) + expect(discovery.kind).toBe('ready') + expect(discovery.kind === 'ready' && discovery.workers).toHaveLength(1) + expect(discovery.kind === 'ready' && discovery.workers[0].workerId).toBe('owner-prior') + }) + + it('runs capability, snapshot, inventory, plan, commit, and receipt in order on establish', async () => { + const { outcome, rpc } = await runMigration() + expect(outcome.kind).toBe('committed') + expect(outcome.kind === 'committed' && outcome.summary.importCount).toBe(1) + expect(outcome.kind === 'committed' && outcome.summary.unresolvedCount).toBe(0) + expect(rpc.calls.map((call) => call.method.split('.').pop())).toEqual([ + 'inspect', + 'migrate', + 'gcProtection', + 'migrationBarrier', + 'gc' + ]) + }) + + it('replays a durable authority operation without committing twice on reconnect', async () => { + const { outcome } = await runMigration({ + rpcOptions: { committedInitially: true } + }) + expect(outcome.kind === 'committed' && outcome.receipts[0].duplicate).toBe(true) + }) + + it('reports commit-uncertain when the authority response is lost', async () => { + const { outcome } = await runMigration({ rpcOptions: { loseMigrationResponseOnce: true } }) + expect(outcome).toMatchObject({ kind: 'unresolved', mutationState: 'commit-uncertain' }) + }) + + it('keeps a failed barrier non-destructive after the catalog committed', async () => { + const { outcome } = await runMigration({ + rpcOptions: { barrierError: new Error('barrier unavailable') } + }) + expect(outcome).toMatchObject({ + kind: 'unresolved', + phase: 'barrier', + mutationState: 'catalog-committed' + }) + }) + + it('reports a pending collection rather than losing the committed cutover', async () => { + const { outcome } = await runMigration({ rpcOptions: { gcError: new Error('gc deferred') } }) + expect(outcome.kind === 'committed' && outcome.gc.kind).toBe('pending') + }) + + it('stops before mutation when the attempt is superseded', async () => { + const controller = new AbortController() + controller.abort() + const rpc = new FutureSshLegacyMigrationRpc([]) + await expect(runMigration({ signal: controller.signal, rpc })).rejects.toThrow() + expect(rpc.calls).toHaveLength(0) + }) + + it('stops before mutation when a newer attempt took over mid-discovery', async () => { + const { outcome, rpc } = await runMigration({ + input: providerInput({ isAttemptCurrent: () => false }) + }) + expect(outcome).toMatchObject({ + kind: 'unresolved', + phase: 'worker-discovery', + mutationState: 'none' + }) + expect(rpc.calls).toHaveLength(0) + }) + + it('never cuts over when the recorded prior relay status is unknown', async () => { + const { outcome, rpc } = await runMigration({ + input: providerInput({ + priorRelayStatus: { kind: 'unknown', reason: 'recorded prior relay status is invalid' } + }) + }) + expect(outcome).toMatchObject({ + kind: 'unresolved', + phase: 'worker-discovery', + mutationState: 'none' + }) + expect(rpc.calls).toHaveLength(0) + }) + + it('never cuts over when leases are retained but no prior relay was recorded', async () => { + const { outcome } = await runMigration({ + input: providerInput({ priorRelayStatus: { kind: 'none' } }) + }) + expect(outcome).toMatchObject({ kind: 'unresolved', phase: 'worker-discovery' }) + }) + + it('completes an empty cutover on a host with neither a prior relay nor leases', async () => { + const { outcome } = await runMigration({ + input: providerInput({ + priorRelayStatus: { kind: 'adopted' }, + store: storeDouble({ getSshRemotePtyLeases: () => [] }) + }) + }) + expect(outcome.kind).toBe('committed') + expect(outcome.kind === 'committed' && outcome.summary.importCount).toBe(0) + }) + + it('leaves an ambiguous pane generation in recovery instead of importing it', async () => { + const { outcome } = await runMigration({ + input: providerInput({ + store: storeDouble({ + getWorkspaceSession: () => ({ + tabsByWorktree: { [WORKTREE_ID]: [{ id: TAB_ID, ptyId: null, generation: 4 }] }, + terminalLayoutsByTabId: { [TAB_ID]: layout() } + }) + }) + }) + }) + expect(outcome.kind === 'committed' && outcome.summary.importCount).toBe(0) + expect(outcome.kind === 'committed' && outcome.summary.unresolvedReasons).toEqual([ + { reason: 'ambiguous-pane-generation', count: 1 } + ]) + }) + + it('fails closed before mutation when the host cannot prove the endpoint identity', async () => { + const { outcome } = await runMigration({ + identityProof: { + expectedEndpoint: ENDPOINT, + observedEndpoint: null, + expectedProcess: { pid: 9_001, birthMarker: 'prior-process-token' }, + observedProcess: { pid: 9_001, birthMarker: 'prior-process-token' } + } + }) + expect(outcome).toMatchObject({ + kind: 'unresolved', + phase: 'planning', + mutationState: 'none' + }) + }) + + it('fails closed against a host that cannot answer the exact inspection contract', async () => { + const provider = createSshLegacyMigrationEvidenceProvider(providerInput()) + const rpc = new FutureSshLegacyMigrationRpc([]) + const outcome = await coordinateSshLegacyMigration({ + ...CONTEXT, + authorityCapabilities: [TERMINAL_LEGACY_CUTOVER_CAPABILITY], + attemptId: 'attempt-a', + signal: new AbortController().signal, + isAttemptCurrent: () => true, + rpc, + evidenceProvider: provider + }) + expect(outcome).toMatchObject({ + kind: 'unresolved', + phase: 'inspection', + mutationState: 'none' + }) + }) + + it('stays read-only against a host that never negotiated the cutover capability', async () => { + const outcome = await coordinateSshLegacyMigration({ + ...CONTEXT, + authorityCapabilities: [], + attemptId: 'attempt-a', + signal: new AbortController().signal, + isAttemptCurrent: () => true, + rpc: new FutureSshLegacyMigrationRpc([]), + evidenceProvider: createSshLegacyMigrationEvidenceProvider(providerInput()) + }) + expect(outcome).toEqual({ kind: 'read-only', reason: 'capability-not-negotiated' }) + }) +}) diff --git a/src/main/ssh/ssh-legacy-migration-evidence-provider.ts b/src/main/ssh/ssh-legacy-migration-evidence-provider.ts new file mode 100644 index 00000000000..e1be0f6191d --- /dev/null +++ b/src/main/ssh/ssh-legacy-migration-evidence-provider.ts @@ -0,0 +1,260 @@ +import type { TerminalAuthorityPathFlavor } from '../../shared/terminal-session-authority-locator' +import type { SshConnection } from './ssh-connection' +import { buildSshLegacyMigrationInventoryInput } from './ssh-legacy-migration-evidence-bridge' +import { SshLegacyMigrationEvidenceError } from './ssh-legacy-migration-evidence-capacity' +import type { + SshLegacyInspectedWorker, + SshLegacyInventoryEvidence, + SshLegacyMigrationEvidenceProvider, + SshLegacyWorkerDiscovery, + LegacyPhysicalWorkerDescriptor +} from './ssh-legacy-migration-coordinator-types' +import type { SshLegacyWorkspaceReference } from './ssh-legacy-migration-evidence-bridge-types' +import { createSshLegacyHostLocatorResolver } from './ssh-legacy-migration-host-locator' +import { collectSshLegacyLocalMigrationEvidence } from './ssh-legacy-migration-local-evidence' +import { readSshLegacyPriorRelayEndpoint } from './ssh-legacy-migration-prior-relay-endpoint' +import { + sshLegacyPriorRelayWorkerDescriptor, + type SshLegacyPriorRelayStatus +} from './ssh-legacy-migration-prior-relay-status' +import { parseSshLegacyRemoteWorkspaceSnapshotEvidence } from './ssh-legacy-migration-remote-workspace-snapshot' +import { + sshLegacyDiscoveredRelayEvidence, + sshLegacyFolderPathById, + sshLegacyPersistedWorkspacePartitions, + sshLegacyReferenceByPaneKey, + sshLegacyWorkerRecoveryAssociations, + type SshLegacyMigrationEvidenceStore +} from './ssh-legacy-migration-store-evidence' +import type { RemoteHostPlatform } from './ssh-remote-platform' + +export type SshLegacyMigrationEvidenceProviderInput = Readonly<{ + targetId: string + partitionId: string + clientInstanceId: string + hostPlatform: RemoteHostPlatform | null + nodePath: string | null + priorRelayStatus: SshLegacyPriorRelayStatus + store: SshLegacyMigrationEvidenceStore + connection: () => SshConnection | null + remoteWorkspaceSnapshot: () => unknown + isAttemptCurrent: () => boolean + now?: () => number +}> + +/** + * The production legacy-migration evidence provider. Every branch that cannot prove what it read + * resolves to `unresolved`, which stops the cutover before any mutation instead of guessing. + */ +export function createSshLegacyMigrationEvidenceProvider( + input: SshLegacyMigrationEvidenceProviderInput +): SshLegacyMigrationEvidenceProvider { + let descriptor: LegacyPhysicalWorkerDescriptor | null = null + return Object.freeze({ + discoverWorkers: async (context) => { + const discovery = await discoverPriorRelayWorker(input, context.signal) + descriptor = discovery.kind === 'ready' ? (discovery.workers[0] ?? null) : null + return discovery + }, + buildInventory: async (context) => + buildPriorRelayInventory(input, { + authorityHostId: context.authorityHostId, + hostPathFlavor: context.hostPathFlavor, + signal: context.signal, + workers: context.workers, + descriptor + }) + }) +} + +async function discoverPriorRelayWorker( + input: SshLegacyMigrationEvidenceProviderInput, + signal: AbortSignal +): Promise { + const status = input.priorRelayStatus + if (status.kind === 'unknown') { + return unresolved(status.reason) + } + if (status.kind !== 'superseded') { + // Why leases matter here: an absent prior owner beside retained leases is missing evidence, + // and a "nothing to import" cutover would silently strand those PTYs. + return input.store.getSshRemotePtyLeases(input.targetId).length > 0 + ? unresolved('recorded prior relay status is missing for retained PTY leases') + : Object.freeze({ kind: 'ready', workers: Object.freeze([]) }) + } + const connection = input.connection() + const hostPlatform = input.hostPlatform + const nodePath = input.nodePath + if (!connection || !hostPlatform || !nodePath) { + return unresolved('the host environment for the prior relay observation is unavailable') + } + const endpoint = await readSshLegacyPriorRelayEndpoint({ + connection, + hostPlatform, + nodePath, + marker: status.marker, + signal + }) + if (!input.isAttemptCurrent() || signal.aborted) { + return unresolved('the migration attempt was superseded during prior relay observation') + } + if (endpoint.kind === 'unknown') { + return unresolved(endpoint.reason) + } + try { + return Object.freeze({ + kind: 'ready', + workers: Object.freeze([ + sshLegacyPriorRelayWorkerDescriptor({ + marker: status.marker, + hostPlatform, + clientInstanceId: input.clientInstanceId, + expectedEndpoint: endpoint.endpoint + }) + ]) + }) + } catch (error) { + return unresolved(evidenceReason(error)) + } +} + +async function buildPriorRelayInventory( + input: SshLegacyMigrationEvidenceProviderInput, + context: Readonly<{ + authorityHostId: string + hostPathFlavor: TerminalAuthorityPathFlavor + signal: AbortSignal + workers: readonly SshLegacyInspectedWorker[] + descriptor: LegacyPhysicalWorkerDescriptor | null + }> +): Promise { + if (context.workers.length === 0) { + return await emptyInventory(input, context) + } + if ( + !context.descriptor || + context.workers.some((worker) => !matches(context.descriptor, worker)) + ) { + return unresolved('inspected workers do not match the recorded prior relay') + } + try { + const folderPathById = sshLegacyFolderPathById(input.store) + const persistedWorkspacePartitions = sshLegacyPersistedWorkspacePartitions( + input.store, + input.targetId + ) + const persistedPtyLeases = input.store.getSshRemotePtyLeases(input.targetId) + const workerRecoveries = sshLegacyWorkerRecoveryAssociations({ + store: input.store, + targetId: input.targetId, + endpointId: context.descriptor.publicCredentialFile, + workerId: context.descriptor.workerId, + buildId: context.descriptor.buildId + }) + const remoteSnapshotPanes = parseSshLegacyRemoteWorkspaceSnapshotEvidence({ + targetId: input.targetId, + partitionId: input.partitionId, + snapshot: input.remoteWorkspaceSnapshot() + }) + const local = collectSshLegacyLocalMigrationEvidence({ + targetId: input.targetId, + persistedWorkspacePartitions, + persistedPtyLeases, + workerRecoveries + }) + const discoveredRelays = sshLegacyDiscoveredRelayEvidence({ + targetId: input.targetId, + endpointId: context.descriptor.publicCredentialFile, + observedAtMs: (input.now ?? Date.now)(), + folderPathById, + referenceByPaneKey: sshLegacyReferenceByPaneKey(local.panes), + workers: context.workers + }) + if (!input.isAttemptCurrent() || context.signal.aborted) { + return unresolved('the migration attempt was superseded while reading evidence') + } + const inventory = await buildSshLegacyMigrationInventoryInput({ + targetId: input.targetId, + authorityHostId: context.authorityHostId, + hostPathFlavor: context.hostPathFlavor, + persistedWorkspacePartitions, + persistedPtyLeases, + workerRecoveries, + remoteSnapshotPanes, + discoveredRelays, + resolveWorkspace: createSshLegacyHostLocatorResolver({ + authorityHostId: context.authorityHostId, + hostPathFlavor: context.hostPathFlavor, + references: workspaceReferences(local.panes, remoteSnapshotPanes, discoveredRelays) + }) + }) + return Object.freeze({ kind: 'ready', inventory }) + } catch (error) { + return unresolved(evidenceReason(error)) + } +} + +async function emptyInventory( + input: SshLegacyMigrationEvidenceProviderInput, + context: Readonly<{ authorityHostId: string; hostPathFlavor: TerminalAuthorityPathFlavor }> +): Promise { + try { + return Object.freeze({ + kind: 'ready', + inventory: await buildSshLegacyMigrationInventoryInput({ + targetId: input.targetId, + authorityHostId: context.authorityHostId, + hostPathFlavor: context.hostPathFlavor, + persistedWorkspacePartitions: [], + persistedPtyLeases: [], + workerRecoveries: [], + remoteSnapshotPanes: [], + discoveredRelays: [], + resolveWorkspace: createSshLegacyHostLocatorResolver({ + authorityHostId: context.authorityHostId, + hostPathFlavor: context.hostPathFlavor, + references: [] + }) + }) + }) + } catch (error) { + return unresolved(evidenceReason(error)) + } +} + +function matches( + descriptor: LegacyPhysicalWorkerDescriptor | null, + worker: SshLegacyInspectedWorker +): boolean { + return ( + descriptor !== null && + worker.descriptor.workerId === descriptor.workerId && + worker.descriptor.routeId === descriptor.routeId && + worker.descriptor.buildId === descriptor.buildId + ) +} + +type ReferencedPane = Readonly<{ workspaceReference: SshLegacyWorkspaceReference }> + +function workspaceReferences( + panes: readonly ReferencedPane[], + snapshots: readonly ReferencedPane[], + relays: readonly Readonly<{ rows: readonly ReferencedPane[] }>[] +): readonly SshLegacyWorkspaceReference[] { + return Object.freeze([ + ...panes.map((pane) => pane.workspaceReference), + ...snapshots.map((pane) => pane.workspaceReference), + ...relays.flatMap((relay) => relay.rows.map((row) => row.workspaceReference)) + ]) +} + +function evidenceReason(error: unknown): string { + if (error instanceof SshLegacyMigrationEvidenceError) { + return error.message + } + return error instanceof Error ? error.message : String(error) +} + +function unresolved(reason: string): Readonly<{ kind: 'unresolved'; reason: string }> { + return Object.freeze({ kind: 'unresolved', reason }) +} diff --git a/src/main/ssh/ssh-legacy-migration-host-locator.test.ts b/src/main/ssh/ssh-legacy-migration-host-locator.test.ts new file mode 100644 index 00000000000..d23085c560e --- /dev/null +++ b/src/main/ssh/ssh-legacy-migration-host-locator.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from 'vitest' +import type { TerminalAuthorityPathFlavor } from '../../shared/terminal-session-authority-locator' +import type { + SshLegacyWorkspaceReference, + SshLegacyWorkspaceResolution +} from './ssh-legacy-migration-evidence-bridge-types' +import { createSshLegacyHostLocatorResolver } from './ssh-legacy-migration-host-locator' + +const HOST = 'authority-host-a' + +function resolve( + reference: SshLegacyWorkspaceReference, + options: Readonly<{ + flavor?: TerminalAuthorityPathFlavor + references?: readonly SshLegacyWorkspaceReference[] + }> = {} +): SshLegacyWorkspaceResolution { + const resolver = createSshLegacyHostLocatorResolver({ + authorityHostId: HOST, + hostPathFlavor: options.flavor ?? 'posix', + references: options.references ?? [] + }) + return resolver({ + targetId: 'target-a', + authorityHostId: HOST, + hostPathFlavor: options.flavor ?? 'posix', + source: 'local-layout', + partitionId: null, + endpointId: null, + reference + }) as SshLegacyWorkspaceResolution +} + +const GIT: SshLegacyWorkspaceReference = Object.freeze({ + kind: 'git-worktree', + clientWorkspaceId: 'repo-a::worktree-a', + path: '/srv/repos/repo-a' +}) + +describe('final host locator', () => { + it('names a git worktree namespace from the host path and keeps the legacy worktree id', () => { + const resolved = resolve(GIT) + expect(resolved.workspace).toEqual({ + kind: 'git-worktree', + locator: { kind: 'workspace', canonicalPath: '/srv/repos/repo-a', pathFlavor: 'posix' }, + worktreeId: 'repo-a::worktree-a' + }) + expect(resolved.namespace.authorityHostId).toBe(HOST) + }) + + it('names a folder workspace and a floating workspace by their own locator kinds', () => { + expect( + resolve({ + kind: 'folder-workspace', + clientWorkspaceId: 'folder:folder-a', + path: '/srv/folders/a' + }).workspace.kind + ).toBe('folder') + expect( + resolve({ kind: 'floating', clientWorkspaceId: 'floating', path: '/tmp' }).workspace + ).toEqual({ kind: 'floating', locator: { kind: 'floating' } }) + }) + + it('gives every floating pane on a host one namespace regardless of its path', () => { + const first = resolve({ kind: 'floating', clientWorkspaceId: 'floating', path: '/tmp/a' }) + const second = resolve({ kind: 'floating', clientWorkspaceId: 'floating', path: null }) + expect(first.namespace.namespaceId).toBe(second.namespace.namespaceId) + }) + + it('separates namespaces by host path and not by the client workspace id', () => { + const sameHostPath = resolve({ ...GIT, clientWorkspaceId: 'repo-b::worktree-b' }) + const otherHostPath = resolve({ ...GIT, path: '/srv/repos/repo-b' }) + expect(sameHostPath.namespace.namespaceId).toBe(resolve(GIT).namespace.namespaceId) + expect(otherHostPath.namespace.namespaceId).not.toBe(resolve(GIT).namespace.namespaceId) + }) + + it('canonicalises equivalent POSIX spellings onto one namespace', () => { + expect(resolve({ ...GIT, path: '/srv/repos/./repo-a/' }).namespace.namespaceId).toBe( + resolve(GIT).namespace.namespaceId + ) + }) + + it.each([ + ['C:\\Users\\u\\repo', 'C:/Users/u/repo'], + ['c:/Users/u/repo/', 'C:/Users/u/repo'], + ['//server/share/repo', '//server/share/repo'] + ])('canonicalises the Windows path %s', (raw, expected) => { + const resolved = resolve({ ...GIT, path: raw }, { flavor: 'windows' }) + expect(resolved.workspace).toMatchObject({ + locator: { canonicalPath: expected, pathFlavor: 'windows' } + }) + }) + + it('resolves a remote snapshot host path back to the workspace that owns it', () => { + const resolved = resolve( + { kind: 'workspace-path', path: '/srv/repos/repo-a' }, + { + references: [GIT] + } + ) + expect(resolved.workspace).toMatchObject({ + kind: 'git-worktree', + worktreeId: 'repo-a::worktree-a' + }) + }) + + it('treats an unknown remote snapshot path as its own host workspace', () => { + const resolved = resolve( + { kind: 'workspace-path', path: '/srv/repos/other' }, + { + references: [GIT] + } + ) + expect(resolved.workspace.kind).toBe('folder') + }) + + it('refuses to name a host path claimed by two client workspaces', () => { + expect(() => + resolve(GIT, { references: [GIT, { ...GIT, clientWorkspaceId: 'repo-b::worktree-b' }] }) + ).toThrow(/ambiguous/u) + }) + + it('rejects a resolution requested for a different authority host', () => { + const resolver = createSshLegacyHostLocatorResolver({ + authorityHostId: HOST, + hostPathFlavor: 'posix', + references: [] + }) + expect(() => + resolver({ + targetId: 'target-a', + authorityHostId: 'authority-host-b', + hostPathFlavor: 'posix', + source: 'relay-inventory', + partitionId: null, + endpointId: 'endpoint-a', + reference: GIT + }) + ).toThrow() + }) + + it.each(['relative/path', ''])('rejects the unusable host path %s', (path) => { + expect(() => resolve({ ...GIT, path })).toThrow() + }) +}) diff --git a/src/main/ssh/ssh-legacy-migration-host-locator.ts b/src/main/ssh/ssh-legacy-migration-host-locator.ts new file mode 100644 index 00000000000..39d3a1f9746 --- /dev/null +++ b/src/main/ssh/ssh-legacy-migration-host-locator.ts @@ -0,0 +1,144 @@ +import { posix, win32 } from 'node:path' +import type { TerminalLegacyWorkspaceEvidence } from '../../shared/terminal-legacy-cutover' +import { + assertTerminalAuthorityNamespaceLocator, + terminalAuthorityNamespaceLocatorKey, + type TerminalAuthorityNamespaceLocator, + type TerminalAuthorityPathFlavor +} from '../../shared/terminal-session-authority-locator' +import { failSshLegacyMigrationEvidence } from './ssh-legacy-migration-evidence-capacity' +import type { + SshLegacyWorkspaceReference, + SshLegacyWorkspaceResolution, + SshLegacyWorkspaceResolutionRequest, + SshLegacyWorkspaceResolver +} from './ssh-legacy-migration-evidence-bridge-types' +import { sshLegacyEvidenceId } from './ssh-legacy-migration-evidence-identity' + +type IndexedReference = Extract< + SshLegacyWorkspaceReference, + { kind: 'git-worktree' | 'folder-workspace' } +> + +/** + * Resolves a legacy workspace reference to the final host's own locator. The namespace is derived + * from the host-local canonical path, never from the client's repository or SSH target id. + */ +export function createSshLegacyHostLocatorResolver( + input: Readonly<{ + authorityHostId: string + hostPathFlavor: TerminalAuthorityPathFlavor + references: readonly SshLegacyWorkspaceReference[] + }> +): SshLegacyWorkspaceResolver { + const byCanonicalPath = indexReferences(input.hostPathFlavor, input.references) + return (request: SshLegacyWorkspaceResolutionRequest): SshLegacyWorkspaceResolution => { + if (request.authorityHostId !== input.authorityHostId) { + failSshLegacyMigrationEvidence('resolution', 'workspace resolution authority host') + } + const reference = + request.reference.kind === 'workspace-path' + ? (byCanonicalPath.get(canonicalHostPath(input.hostPathFlavor, request.reference.path)) ?? + request.reference) + : request.reference + return resolution(input.authorityHostId, input.hostPathFlavor, reference) + } +} + +function indexReferences( + flavor: TerminalAuthorityPathFlavor, + references: readonly SshLegacyWorkspaceReference[] +): ReadonlyMap { + const index = new Map() + for (const reference of references) { + if (reference.kind !== 'git-worktree' && reference.kind !== 'folder-workspace') { + continue + } + const key = canonicalHostPath(flavor, reference.path) + const existing = index.get(key) + if (existing && !sameIndexedReference(existing, reference)) { + // Two client workspaces claim one host path: the host cannot name a single namespace owner. + failSshLegacyMigrationEvidence('ambiguity', 'host workspace locator') + } + index.set(key, reference) + } + return index +} + +function sameIndexedReference(left: IndexedReference, right: IndexedReference): boolean { + return left.kind === right.kind && left.clientWorkspaceId === right.clientWorkspaceId +} + +function resolution( + authorityHostId: string, + flavor: TerminalAuthorityPathFlavor, + reference: SshLegacyWorkspaceReference +): SshLegacyWorkspaceResolution { + const locator = referenceLocator(flavor, reference) + assertTerminalAuthorityNamespaceLocator(locator) + return Object.freeze({ + namespace: Object.freeze({ + authorityHostId, + namespaceId: sshLegacyEvidenceId('ssh-legacy-namespace', [ + authorityHostId, + terminalAuthorityNamespaceLocatorKey(locator) + ]) + }), + workspace: workspaceEvidence(reference, locator) + }) +} + +function referenceLocator( + flavor: TerminalAuthorityPathFlavor, + reference: SshLegacyWorkspaceReference +): TerminalAuthorityNamespaceLocator { + if (reference.kind === 'floating') { + return Object.freeze({ kind: 'floating' }) + } + return Object.freeze({ + kind: 'workspace', + canonicalPath: canonicalHostPath(flavor, reference.path), + pathFlavor: flavor + }) +} + +function workspaceEvidence( + reference: SshLegacyWorkspaceReference, + locator: TerminalAuthorityNamespaceLocator +): TerminalLegacyWorkspaceEvidence { + if (locator.kind === 'floating') { + return Object.freeze({ kind: 'floating', locator }) + } + // Why the client id and not the host path: worktreeId stays legacy match evidence that must still + // equal the recorded lease, while the namespace above is host-derived. + return reference.kind === 'git-worktree' + ? Object.freeze({ kind: 'git-worktree', locator, worktreeId: reference.clientWorkspaceId }) + : Object.freeze({ kind: 'folder', locator }) +} + +export function canonicalHostPath(flavor: TerminalAuthorityPathFlavor, value: string): string { + if (typeof value !== 'string' || value.length === 0) { + failSshLegacyMigrationEvidence('malformed', 'host workspace path') + } + const canonical = flavor === 'windows' ? canonicalWindowsPath(value) : canonicalPosixPath(value) + if (canonical === null) { + failSshLegacyMigrationEvidence('malformed', 'host workspace path') + } + return canonical +} + +function canonicalPosixPath(value: string): string | null { + if (!value.startsWith('/')) { + return null + } + const normalized = posix.normalize(value) + return normalized.length > 1 ? normalized.replace(/\/+$/u, '') : '/' +} + +function canonicalWindowsPath(value: string): string | null { + const normalized = win32.normalize(value).replace(/\\/gu, '/') + if (/^[A-Za-z]:\//u.test(normalized)) { + return `${normalized[0].toUpperCase()}:/${normalized.slice(3).replace(/\/+$/u, '')}` + } + return /^\/\/[^/]+\/[^/]+/u.test(normalized) ? normalized.replace(/(.)\/+$/u, '$1') : null +} diff --git a/src/main/ssh/ssh-legacy-migration-inventory-capacity.ts b/src/main/ssh/ssh-legacy-migration-inventory-capacity.ts new file mode 100644 index 00000000000..e4db64954d6 --- /dev/null +++ b/src/main/ssh/ssh-legacy-migration-inventory-capacity.ts @@ -0,0 +1,73 @@ +import type { SshLegacyMigrationInventoryInput } from './ssh-legacy-migration-inventory-types' + +export const SSH_LEGACY_MIGRATION_INVENTORY_CAPACITY = Object.freeze({ + persistedConsumerRecoveries: 256, + persistedPtyLeases: 4_096, + localLayoutPanes: 8_192, + remoteSnapshotPanes: 8_192, + liveRelays: 256, + rowsPerRelay: 4_096, + totalInventoryRows: 4_096 +}) + +export class SshLegacyMigrationInventoryCapacityError extends Error { + readonly code = 'ssh_legacy_migration_inventory_capacity' + + constructor(field: string) { + super(`SSH legacy migration inventory ${field} exceeds bounded capacity`) + this.name = 'SshLegacyMigrationInventoryCapacityError' + } +} + +export function assertSshLegacyMigrationInventoryCapacity( + input: SshLegacyMigrationInventoryInput +): void { + assertArrayCapacity( + input.persistedConsumerRecoveries, + SSH_LEGACY_MIGRATION_INVENTORY_CAPACITY.persistedConsumerRecoveries, + 'persisted consumer recoveries' + ) + assertArrayCapacity( + input.persistedPtyLeases, + SSH_LEGACY_MIGRATION_INVENTORY_CAPACITY.persistedPtyLeases, + 'persisted PTY leases' + ) + assertArrayCapacity( + input.localLayoutPanes, + SSH_LEGACY_MIGRATION_INVENTORY_CAPACITY.localLayoutPanes, + 'local layout panes' + ) + assertArrayCapacity( + input.remoteSnapshotPanes, + SSH_LEGACY_MIGRATION_INVENTORY_CAPACITY.remoteSnapshotPanes, + 'remote snapshot panes' + ) + assertArrayCapacity( + input.liveRelays, + SSH_LEGACY_MIGRATION_INVENTORY_CAPACITY.liveRelays, + 'live relays' + ) + + let totalRows = 0 + for (const relay of input.liveRelays) { + assertArrayCapacity( + relay.rows, + SSH_LEGACY_MIGRATION_INVENTORY_CAPACITY.rowsPerRelay, + 'rows per relay' + ) + totalRows += relay.rows.length + if (totalRows > SSH_LEGACY_MIGRATION_INVENTORY_CAPACITY.totalInventoryRows) { + throw new SshLegacyMigrationInventoryCapacityError('total inventory rows') + } + } +} + +function assertArrayCapacity( + value: unknown, + maximum: number, + field: string +): asserts value is unknown[] { + if (!Array.isArray(value) || value.length > maximum) { + throw new SshLegacyMigrationInventoryCapacityError(field) + } +} diff --git a/src/main/ssh/ssh-legacy-migration-inventory-index.ts b/src/main/ssh/ssh-legacy-migration-inventory-index.ts new file mode 100644 index 00000000000..14860766abf --- /dev/null +++ b/src/main/ssh/ssh-legacy-migration-inventory-index.ts @@ -0,0 +1,263 @@ +import type { SshRemotePtyLease } from '../../shared/ssh-types' +import { + compareSshLegacyText, + sshLegacyPhysicalPtyId +} from './ssh-legacy-migration-evidence-identity' +import { + projectSshLegacyConsumerEvidence, + projectSshLegacyLeaseEvidence, + projectSshLegacyPaneEvidence, + projectSshLegacyRelayEvidence, + projectSshLegacySourceEvidence, + sortSshLegacyEvidence +} from './ssh-legacy-migration-evidence-projection' +import type { + SshLegacyLayoutPaneEvidence, + SshLegacyLiveRelayInventory, + SshLegacyMigrationInventoryInput, + SshLegacyPersistedConsumerEvidence, + SshLegacyRelayInventoryRow, + SshLegacyRemoteSnapshotPaneEvidence +} from './ssh-legacy-migration-inventory-types' + +export type SshLegacyInventorySource = Readonly<{ + relay: SshLegacyLiveRelayInventory + row: SshLegacyRelayInventoryRow +}> + +export type SshLegacyInventoryGroup = Readonly<{ + workerId: string + physicalPtyId: string + sources: readonly SshLegacyInventorySource[] +}> + +export type SshLegacyLeaseWithoutInventory = Readonly<{ + physicalPtyId: string + leases: readonly SshRemotePtyLease[] +}> + +export type SshLegacyInventoryIndexes = Readonly<{ + consumers: readonly SshLegacyPersistedConsumerEvidence[] + groups: readonly SshLegacyInventoryGroup[] + leasesWithoutInventory: readonly SshLegacyLeaseWithoutInventory[] + relayCount: number + inventoryRowCount: number + consumersFor: (workerId: string, buildId: string) => readonly SshLegacyPersistedConsumerEvidence[] + leasesFor: (physicalPtyId: string) => readonly SshRemotePtyLease[] + localPanesFor: (physicalPtyId: string) => readonly SshLegacyLayoutPaneEvidence[] + snapshotPanesFor: (physicalPtyId: string) => readonly SshLegacyRemoteSnapshotPaneEvidence[] + exactRemoteSourcesFor: ( + physicalPtyId: string, + ptyIncarnationId: string | null + ) => readonly SshLegacyInventorySource[] + relaysForBuild: (buildId: string) => readonly SshLegacyLiveRelayInventory[] +}> + +export function indexSshLegacyMigrationInventory( + input: SshLegacyMigrationInventoryInput +): SshLegacyInventoryIndexes { + const consumers = sortSshLegacyEvidence( + input.persistedConsumerRecoveries.filter((row) => row.targetId === input.targetId), + projectSshLegacyConsumerEvidence + ) + const leases = input.persistedPtyLeases.filter((row) => row.targetId === input.targetId) + const localPanes = input.localLayoutPanes.filter((row) => row.targetId === input.targetId) + const snapshotPanes = input.remoteSnapshotPanes.filter((row) => row.targetId === input.targetId) + const relays = input.liveRelays + const leasesByPty = indexByPhysicalPty(input.targetId, leases, projectSshLegacyLeaseEvidence) + const localPanesByPty = indexByPhysicalPty( + input.targetId, + localPanes, + projectSshLegacyPaneEvidence + ) + const snapshotPanesByPty = indexByPhysicalPty( + input.targetId, + snapshotPanes, + projectSshLegacyPaneEvidence + ) + const sources = relays.flatMap((relay) => relay.rows.map((row) => ({ relay, row }))) + const sourcesByExactRemote = indexSourcesByExactRemote(sources) + const groups = groupInventorySources(sources) + const inventoriedPtyIds = new Set(sources.map((source) => source.row.physicalPtyId)) + const leasesWithoutInventory = [...leasesByPty.entries()] + .filter(([physicalPtyId]) => !inventoriedPtyIds.has(physicalPtyId)) + .sort(([left], [right]) => compareSshLegacyText(left, right)) + .map(([physicalPtyId, groupedLeases]) => + Object.freeze({ physicalPtyId, leases: Object.freeze([...groupedLeases]) }) + ) + const relaysByBuild = indexRelaysByBuild(relays) + const consumersByWorkerBuild = indexConsumersByWorkerBuild(consumers) + + return Object.freeze({ + consumers: Object.freeze(consumers), + groups: Object.freeze(groups), + leasesWithoutInventory: Object.freeze(leasesWithoutInventory), + relayCount: relays.length, + inventoryRowCount: sources.length, + consumersFor: (workerId, buildId) => + consumersByWorkerBuild.get(workerBuildKey(workerId, buildId)) ?? [], + leasesFor: (physicalPtyId) => leasesByPty.get(physicalPtyId) ?? [], + localPanesFor: (physicalPtyId) => localPanesByPty.get(physicalPtyId) ?? [], + snapshotPanesFor: (physicalPtyId) => snapshotPanesByPty.get(physicalPtyId) ?? [], + exactRemoteSourcesFor: (physicalPtyId, ptyIncarnationId) => + sourcesByExactRemote.get(exactRemoteKey(physicalPtyId, ptyIncarnationId)) ?? [], + relaysForBuild: (buildId) => relaysByBuild.get(buildId) ?? [] + }) +} + +function indexConsumersByWorkerBuild( + consumers: readonly SshLegacyPersistedConsumerEvidence[] +): Map { + const index = new Map() + for (const consumer of consumers) { + const key = workerBuildKey(consumer.workerId, consumer.serverBuildId) + const existing = index.get(key) ?? [] + existing.push(consumer) + index.set(key, existing) + } + return index +} + +export function sshLegacyScopedEvidence( + input: SshLegacyMigrationInventoryInput +): Readonly> { + const consumers = input.persistedConsumerRecoveries + .filter((value) => value.targetId === input.targetId) + .map(projectScopedConsumer) + const leases = input.persistedPtyLeases + .filter((value) => value.targetId === input.targetId) + .map((value) => projectScopedLease(input.targetId, value)) + const localPanes = input.localLayoutPanes + .filter((value) => value.targetId === input.targetId) + .map((value) => projectScopedPane(input.targetId, value)) + const snapshotPanes = input.remoteSnapshotPanes + .filter((value) => value.targetId === input.targetId) + .map((value) => projectScopedPane(input.targetId, value)) + return Object.freeze({ + version: 1, + authorityHostId: input.authorityHostId, + hostPathFlavor: input.hostPathFlavor, + persistedConsumerRecoveries: sortSshLegacyEvidence(consumers, (value) => value), + persistedPtyLeases: sortSshLegacyEvidence(leases, (value) => value), + localLayoutPanes: sortSshLegacyEvidence(localPanes, (value) => value), + remoteSnapshotPanes: sortSshLegacyEvidence(snapshotPanes, (value) => value), + liveRelays: sortSshLegacyEvidence(input.liveRelays, (relay) => + projectSshLegacyRelayEvidence(relay, true) + ).map((relay) => projectSshLegacyRelayEvidence(relay, true)) + }) +} + +function projectScopedConsumer( + value: SshLegacyPersistedConsumerEvidence +): Readonly> { + const { targetId: _targetId, ...evidence } = projectSshLegacyConsumerEvidence(value) + return evidence +} + +function projectScopedLease( + targetId: string, + value: SshRemotePtyLease +): Readonly> { + const { targetId: _targetId, ptyId: _ptyId, ...evidence } = projectSshLegacyLeaseEvidence(value) + return { ...evidence, physicalPtyId: sshLegacyPhysicalPtyId(targetId, value.ptyId) } +} + +function projectScopedPane( + targetId: string, + value: SshLegacyLayoutPaneEvidence +): Readonly> { + const { targetId: _targetId, ptyId: _ptyId, ...evidence } = projectSshLegacyPaneEvidence(value) + return { ...evidence, physicalPtyId: sshLegacyPhysicalPtyId(targetId, value.ptyId) } +} + +function indexByPhysicalPty( + targetId: string, + rows: readonly T[], + project: (value: T) => unknown +): Map { + const index = new Map() + for (const row of rows) { + const physicalPtyId = sshLegacyPhysicalPtyId(targetId, row.ptyId) + if (physicalPtyId === null) { + continue + } + const existing = index.get(physicalPtyId) ?? [] + existing.push(row) + index.set(physicalPtyId, existing) + } + for (const [key, values] of index) { + index.set(key, sortSshLegacyEvidence(values, project)) + } + return index +} + +function indexSourcesByExactRemote( + sources: readonly SshLegacyInventorySource[] +): Map { + const index = new Map() + for (const source of sources) { + const key = exactRemoteKey(source.row.physicalPtyId, source.row.ptyIncarnationId) + const existing = index.get(key) ?? [] + existing.push(source) + index.set(key, existing) + } + for (const [key, values] of index) { + index.set(key, sortInventorySources(values)) + } + return index +} + +function indexRelaysByBuild( + relays: readonly SshLegacyLiveRelayInventory[] +): Map { + const index = new Map() + for (const relay of relays) { + const existing = index.get(relay.buildId) ?? [] + existing.push(relay) + index.set(relay.buildId, existing) + } + for (const [key, values] of index) { + index.set( + key, + sortSshLegacyEvidence(values, (relay) => projectSshLegacyRelayEvidence(relay, true)) + ) + } + return index +} + +function groupInventorySources( + sources: readonly SshLegacyInventorySource[] +): SshLegacyInventoryGroup[] { + const grouped = new Map() + for (const source of sources) { + const key = JSON.stringify([source.relay.workerId, source.row.physicalPtyId]) + const existing = grouped.get(key) ?? [] + existing.push(source) + grouped.set(key, existing) + } + return [...grouped.entries()] + .sort(([left], [right]) => compareSshLegacyText(left, right)) + .map(([, values]) => { + const sourcesForPty = sortInventorySources(values) + const first = sourcesForPty[0] + return Object.freeze({ + workerId: first.relay.workerId, + physicalPtyId: first.row.physicalPtyId, + sources: Object.freeze(sourcesForPty) + }) + }) +} + +function sortInventorySources( + sources: readonly SshLegacyInventorySource[] +): SshLegacyInventorySource[] { + return sortSshLegacyEvidence(sources, projectSshLegacySourceEvidence) +} + +function exactRemoteKey(physicalPtyId: string, ptyIncarnationId: string | null): string { + return JSON.stringify([physicalPtyId, ptyIncarnationId]) +} + +function workerBuildKey(workerId: string, buildId: string): string { + return JSON.stringify([workerId, buildId]) +} diff --git a/src/main/ssh/ssh-legacy-migration-inventory-planner.test.ts b/src/main/ssh/ssh-legacy-migration-inventory-planner.test.ts new file mode 100644 index 00000000000..82489232cfb --- /dev/null +++ b/src/main/ssh/ssh-legacy-migration-inventory-planner.test.ts @@ -0,0 +1,215 @@ +import { describe, expect, it } from 'vitest' +import { assertTerminalLegacyMigrationImportRequest } from '../../shared/terminal-legacy-cutover-request-validation' +import { + floatingWorkspace, + folderWorkspace, + gitWorkspace, + makeSshLegacyInventoryScenario +} from './__tests__/ssh-legacy-migration-inventory' +import { + planSshLegacyMigrationInventory, + type SshLegacyMigrationInventoryInput +} from './ssh-legacy-migration-inventory-planner' + +describe('SSH legacy migration inventory planner', () => { + it('imports generation zero without treating lease state or timestamps as liveness', () => { + const input = makeSshLegacyInventoryScenario({ rendererGeneration: 0 }) + + const plan = planSshLegacyMigrationInventory(input) + + expect(plan.imports).toHaveLength(1) + expect(plan.unresolved).toEqual([]) + expect(plan.imports[0]).toMatchObject({ + pane: { paneGenerationId: 'renderer:0' }, + physicalPty: { + workerId: 'worker-a', + physicalPtyId: 'pty-1', + ptyIncarnationId: 'incarnation-1', + processId: 4_201 + }, + matchEvidence: { + localLease: { rendererGeneration: 0 }, + uniqueness: { + localCandidates: 1, + remoteCandidates: 1, + endpointIdentityMatched: true, + processIdentityMatched: true + } + } + }) + expect(input.persistedPtyLeases[0].state).toBe('expired') + expect(plan.summary).toEqual({ + evidenceDigest: plan.evidenceDigest, + migrationId: plan.migrationId, + relayCount: 1, + inventoryRowCount: 1, + importCount: 1, + unresolvedCount: 0, + unresolvedReasons: [] + }) + expect(JSON.stringify(plan.summary)).not.toContain('/srv/private') + expect(() => assertExactCandidateRequest(plan, input)).not.toThrow() + }) + + it('plans git, folder, and floating panes from separate persisted partitions', () => { + const git = makeSshLegacyInventoryScenario({ + physicalPtyId: 'pty-git', + ptyIncarnationId: 'inc-git', + tabId: 'tab-git', + leafId: 'leaf-git', + namespaceId: 'namespace-git', + localPartitionId: 'partition-local-git', + snapshotPartitionId: 'partition-remote-git', + workspace: gitWorkspace('/srv/private/git', 'repo::git') + }) + const folder = makeSshLegacyInventoryScenario({ + physicalPtyId: 'pty-folder', + ptyIncarnationId: 'inc-folder', + processId: 4_202, + tabId: 'tab-folder', + leafId: 'leaf-folder', + namespaceId: 'namespace-folder', + localPartitionId: 'partition-local-folder', + snapshotPartitionId: 'partition-remote-folder', + workspace: folderWorkspace('/srv/private/folder') + }) + const floating = makeSshLegacyInventoryScenario({ + physicalPtyId: 'pty-floating', + ptyIncarnationId: 'inc-floating', + processId: 4_203, + tabId: 'tab-floating', + leafId: 'leaf-floating', + namespaceId: 'namespace-floating', + localPartitionId: 'partition-local-floating', + snapshotPartitionId: 'partition-remote-floating', + workspace: floatingWorkspace() + }) + + const plan = planSshLegacyMigrationInventory(combineScenarios(git, folder, floating)) + + expect(plan.unresolved).toEqual([]) + expect(plan.imports).toHaveLength(3) + expect(new Set(plan.imports.map((candidate) => candidate.workspace.kind))).toEqual( + new Set(['git-worktree', 'folder', 'floating']) + ) + expect(new Set(plan.imports.map((candidate) => candidate.namespace.namespaceId))).toEqual( + new Set(['namespace-git', 'namespace-folder', 'namespace-floating']) + ) + expect(JSON.stringify(plan.summary)).not.toContain('/srv/private') + }) + + it('uses the remote host path flavor for Windows folder locators', () => { + const input = makeSshLegacyInventoryScenario({ + hostPathFlavor: 'windows', + workspace: folderWorkspace('C:/Users/Alice/private-project', 'windows') + }) + + const plan = planSshLegacyMigrationInventory(input) + + expect(plan.imports).toHaveLength(1) + expect(plan.imports[0].workspace).toMatchObject({ + kind: 'folder', + locator: { kind: 'workspace', pathFlavor: 'windows' } + }) + expect(JSON.stringify(plan.summary)).not.toContain('Alice') + }) + + it('keeps authority and recovery identity stable across target reconfiguration', () => { + const beforeRename = makeSshLegacyInventoryScenario({ + targetId: 'routing-target-before', + authorityHostId: 'stable-authority-host' + }) + const afterRename = makeSshLegacyInventoryScenario({ + targetId: 'routing-target-after', + authorityHostId: 'stable-authority-host' + }) + + const beforePlan = planSshLegacyMigrationInventory(beforeRename) + const afterPlan = planSshLegacyMigrationInventory(afterRename) + + expect(beforePlan.imports[0].namespace.authorityHostId).toBe('stable-authority-host') + expect(afterPlan).toEqual(beforePlan) + }) +}) + +function assertExactCandidateRequest( + plan: ReturnType, + input: SshLegacyMigrationInventoryInput +): void { + const relay = input.liveRelays[0] + const endpoint = relay.identityProof.expectedEndpoint + const process = relay.identityProof.expectedProcess + if (endpoint?.kind !== 'unix-socket' || process === null) { + throw new Error('test requires exact POSIX relay identity') + } + assertTerminalLegacyMigrationImportRequest({ + version: 1, + migrationId: plan.migrationId, + authorityHostId: plan.imports[0].namespace.authorityHostId, + requestedAtMs: 0, + mode: 'cutover', + workerRoute: { + routeId: 'route-a', + workerId: relay.workerId, + ownerIncarnationId: 'owner-incarnation-a', + buildId: relay.buildId, + relayDirectory: '/relay/build-a', + socketPath: '/relay/private.sock', + credentialFile: '/relay/private.credential', + process, + endpoint, + sourceOwner: { + clientInstanceId: 'client-a', + ownerGeneration: 1, + ownerLease: 'owner-lease-a', + outputWindowSourceUnits: 1 + }, + gcProtection: { + relayDirectories: ['/relay/build-a'], + evidencePaths: ['/relay/private.sock', '/relay/private.credential'] + } + }, + cutover: { + kind: 'posix-relocated', + publicCredentialFile: '/relay/public.credential', + privateCredentialFile: '/relay/private.credential', + brokerClientCount: 1, + acceptedConnectionCount: 1, + quiescenceSamples: 2, + connectionProof: { + method: 'linux-procfs-unix', + listenerIdentity: 'listener-a', + brokerConnectionIdentity: 'broker-a', + acceptedServerConnections: 1 + }, + graceConfiguration: { + capabilityVersion: 1, + configuredGraceMs: 0, + acknowledged: true + }, + sealedAtMs: 0, + publicSocketPath: '/relay/public.sock', + privateSocketPath: '/relay/private.sock', + endpointIdentity: endpoint + }, + imports: plan.imports, + unresolved: [] + }) +} + +function combineScenarios( + first: SshLegacyMigrationInventoryInput, + ...rest: readonly SshLegacyMigrationInventoryInput[] +): SshLegacyMigrationInventoryInput { + const scenarios = [first, ...rest] + return { + targetId: first.targetId, + authorityHostId: first.authorityHostId, + hostPathFlavor: first.hostPathFlavor, + persistedConsumerRecoveries: first.persistedConsumerRecoveries, + persistedPtyLeases: scenarios.flatMap((scenario) => scenario.persistedPtyLeases), + localLayoutPanes: scenarios.flatMap((scenario) => scenario.localLayoutPanes), + remoteSnapshotPanes: scenarios.flatMap((scenario) => scenario.remoteSnapshotPanes), + liveRelays: scenarios.flatMap((scenario) => scenario.liveRelays) + } +} diff --git a/src/main/ssh/ssh-legacy-migration-inventory-planner.ts b/src/main/ssh/ssh-legacy-migration-inventory-planner.ts new file mode 100644 index 00000000000..2db78b824f3 --- /dev/null +++ b/src/main/ssh/ssh-legacy-migration-inventory-planner.ts @@ -0,0 +1,94 @@ +import type { TerminalLegacyRecoveryReason } from '../../shared/terminal-legacy-cutover' +import { classifySshLegacyInventoryGroup } from './ssh-legacy-migration-candidate-classifier' +import { + canonicalEvidenceSort, + compareSshLegacyText, + sshLegacyEvidenceDigest, + sshLegacyEvidenceId +} from './ssh-legacy-migration-evidence-identity' +import { assertSshLegacyMigrationInventoryCapacity } from './ssh-legacy-migration-inventory-capacity' +import { + indexSshLegacyMigrationInventory, + sshLegacyScopedEvidence +} from './ssh-legacy-migration-inventory-index' +import { classifySshLegacyLeaseWithoutInventory } from './ssh-legacy-migration-missing-inventory-classifier' +import type { + SshLegacyMigrationInventoryInput, + SshLegacyMigrationInventoryPlan +} from './ssh-legacy-migration-inventory-types' +import { assertSshLegacyMigrationInventoryInput } from './ssh-legacy-migration-inventory-validation' + +export function planSshLegacyMigrationInventory( + input: SshLegacyMigrationInventoryInput +): SshLegacyMigrationInventoryPlan { + assertSshLegacyMigrationInventoryCapacity(input) + assertSshLegacyMigrationInventoryInput(input) + + const indexes = indexSshLegacyMigrationInventory(input) + const evidenceDigest = sshLegacyEvidenceDigest(sshLegacyScopedEvidence(input)) + const migrationId = sshLegacyEvidenceId('ssh-legacy-migration', [ + input.authorityHostId, + evidenceDigest + ]) + const classified = indexes.groups.map((group) => + classifySshLegacyInventoryGroup(input, indexes, group) + ) + const leasesWithoutInventory = indexes.leasesWithoutInventory + .map((group) => classifySshLegacyLeaseWithoutInventory(input, indexes, group)) + .filter((candidate) => candidate !== null) + const imports = canonicalEvidenceSort( + classified.filter((row) => row.kind === 'import').map((row) => row.candidate) + ) + const unresolved = canonicalEvidenceSort([ + ...classified.filter((row) => row.kind === 'unresolved').map((row) => row.candidate), + ...leasesWithoutInventory + ]) + imports.sort((left, right) => compareSshLegacyText(left.recoveryId, right.recoveryId)) + unresolved.sort((left, right) => compareSshLegacyText(left.recoveryId, right.recoveryId)) + const unresolvedReasons = countUnresolvedReasons(unresolved.map((row) => row.reason)) + const summary = Object.freeze({ + evidenceDigest, + migrationId, + relayCount: indexes.relayCount, + inventoryRowCount: indexes.inventoryRowCount, + importCount: imports.length, + unresolvedCount: unresolved.length, + unresolvedReasons + }) + + return Object.freeze({ + evidenceDigest, + migrationId, + imports: Object.freeze(imports), + unresolved: Object.freeze(unresolved), + summary + }) +} + +function countUnresolvedReasons( + reasons: readonly TerminalLegacyRecoveryReason[] +): readonly Readonly<{ reason: TerminalLegacyRecoveryReason; count: number }>[] { + const counts = new Map() + for (const reason of reasons) { + counts.set(reason, (counts.get(reason) ?? 0) + 1) + } + return Object.freeze( + [...counts.entries()] + .sort(([left], [right]) => compareSshLegacyText(left, right)) + .map(([reason, count]) => Object.freeze({ reason, count })) + ) +} + +export type { + SshLegacyLayoutPaneEvidence, + SshLegacyLiveRelayInventory, + SshLegacyMigrationInventoryInput, + SshLegacyMigrationInventoryPlan, + SshLegacyPersistedConsumerEvidence, + SshLegacyRelayInventoryRow, + SshLegacyRemoteSnapshotPaneEvidence +} from './ssh-legacy-migration-inventory-types' +export { + SSH_LEGACY_MIGRATION_INVENTORY_CAPACITY, + SshLegacyMigrationInventoryCapacityError +} from './ssh-legacy-migration-inventory-capacity' diff --git a/src/main/ssh/ssh-legacy-migration-inventory-safety.test.ts b/src/main/ssh/ssh-legacy-migration-inventory-safety.test.ts new file mode 100644 index 00000000000..95caf8def0f --- /dev/null +++ b/src/main/ssh/ssh-legacy-migration-inventory-safety.test.ts @@ -0,0 +1,319 @@ +import { describe, expect, it } from 'vitest' +import { + SSH_LEGACY_MIGRATION_INVENTORY_CAPACITY, + SshLegacyMigrationInventoryCapacityError, + planSshLegacyMigrationInventory, + type SshLegacyMigrationInventoryInput, + type SshLegacyRelayInventoryRow +} from './ssh-legacy-migration-inventory-planner' +import { makeSshLegacyInventoryScenario } from './__tests__/ssh-legacy-migration-inventory' + +describe('SSH legacy migration inventory safety', () => { + it('separates duplicate physical PTY ids by the persisted relay build', () => { + const input = makeSshLegacyInventoryScenario() + const relayA = input.liveRelays[0] + const rowA = relayA.rows[0] + const relayB = { + ...relayA, + workerId: 'worker-b', + buildId: 'build-b', + rows: [{ ...rowA, workerId: 'worker-b', buildId: 'build-b' }] + } + + const plan = planSshLegacyMigrationInventory({ ...input, liveRelays: [relayB, relayA] }) + + expect(plan.imports).toEqual([]) + expect(plan.unresolved).toHaveLength(2) + expect(new Set(plan.unresolved.map((candidate) => candidate.physicalPty.workerId))).toEqual( + new Set(['worker-a', 'worker-b']) + ) + }) + + it('matches independent consumer recovery evidence per worker and build', () => { + const first = makeSshLegacyInventoryScenario({ physicalPtyId: 'pty-a' }) + const second = makeSshLegacyInventoryScenario({ + workerId: 'worker-b', + buildId: 'build-b', + physicalPtyId: 'pty-b', + ptyIncarnationId: 'incarnation-b', + processId: 4_202, + tabId: 'tab-b', + leafId: 'leaf-b', + namespaceId: 'namespace-b' + }) + const input = combine(first, second) + + const plan = planSshLegacyMigrationInventory({ + ...input, + persistedConsumerRecoveries: [ + ...first.persistedConsumerRecoveries, + ...second.persistedConsumerRecoveries + ] + }) + + expect(plan.unresolved).toEqual([]) + expect(new Set(plan.imports.map((candidate) => candidate.physicalPty.workerId))).toEqual( + new Set(['worker-a', 'worker-b']) + ) + }) + + it('contains duplicate consumer evidence to only its worker partition', () => { + const first = makeSshLegacyInventoryScenario({ physicalPtyId: 'pty-a' }) + const second = makeSshLegacyInventoryScenario({ + workerId: 'worker-b', + buildId: 'build-b', + physicalPtyId: 'pty-b', + ptyIncarnationId: 'incarnation-b', + processId: 4_202, + tabId: 'tab-b', + leafId: 'leaf-b', + namespaceId: 'namespace-b' + }) + const duplicateFirst = { + ...first.persistedConsumerRecoveries[0], + clientInstanceId: 'consumer-recovery-a-duplicate' + } + const input = combine(first, second) + + const plan = planSshLegacyMigrationInventory({ + ...input, + persistedConsumerRecoveries: [ + ...first.persistedConsumerRecoveries, + duplicateFirst, + ...second.persistedConsumerRecoveries + ] + }) + + expect(plan.imports).toHaveLength(1) + expect(plan.imports[0].physicalPty.workerId).toBe('worker-b') + expect(plan.unresolved).toHaveLength(1) + expect(plan.unresolved[0]).toMatchObject({ + reason: 'endpoint-identity-unproved', + physicalPty: { workerId: 'worker-a' } + }) + }) + + it('keeps stale remote snapshot disagreement unresolved', () => { + const input = makeSshLegacyInventoryScenario() + const staleSnapshot = { + ...input.remoteSnapshotPanes[0], + paneKey: 'tab-stale:leaf-stale', + tabId: 'tab-stale', + leafId: 'leaf-stale' + } + + const plan = planSshLegacyMigrationInventory({ + ...input, + remoteSnapshotPanes: [staleSnapshot] + }) + + expect(plan.imports).toEqual([]) + expect(plan.unresolved).toHaveLength(1) + expect(plan.unresolved[0].reason).toBe('workspace-mismatch') + expect(JSON.stringify(plan)).not.toContain('dead') + expect(JSON.stringify(plan)).not.toContain('kill') + }) + + it('turns a persisted lease with a missing remote row into recovery evidence', () => { + const input = makeSshLegacyInventoryScenario() + const relay = input.liveRelays[0] + + const plan = planSshLegacyMigrationInventory({ + ...input, + liveRelays: [{ ...relay, rows: [] }] + }) + + expect(plan.imports).toEqual([]) + expect(plan.unresolved).toHaveLength(1) + expect(plan.unresolved[0]).toMatchObject({ + reason: 'physical-pty-incarnation-unproved', + physicalPty: { + workerId: 'worker-a', + physicalPtyId: 'pty-1', + ptyIncarnationId: 'incarnation-1', + processId: null + }, + inventoryEvidence: { + serializedPtyIncarnationId: null, + serializedProcessId: null + } + }) + expect(plan.summary.inventoryRowCount).toBe(0) + }) + + it('rejects ambiguous local pane generations without choosing the newest', () => { + const input = makeSshLegacyInventoryScenario({ rendererGeneration: 0 }) + const secondGeneration = { ...input.localLayoutPanes[0], rendererGeneration: 1 } + + const plan = planSshLegacyMigrationInventory({ + ...input, + localLayoutPanes: [secondGeneration, input.localLayoutPanes[0]] + }) + + expect(plan.imports).toEqual([]) + expect(plan.unresolved[0].reason).toBe('ambiguous-pane-generation') + }) + + it.each([ + [ + 'incarnation', + (row: SshLegacyRelayInventoryRow): SshLegacyRelayInventoryRow => ({ + ...row, + ptyIncarnationId: null, + serialized: { ...row.serialized, ptyIncarnationId: null } + }) + ], + [ + 'serialized process', + (row: SshLegacyRelayInventoryRow): SshLegacyRelayInventoryRow => ({ + ...row, + serialized: { ...row.serialized, processId: null } + }) + ] + ])('keeps missing %s evidence unresolved', (_label, changeRow) => { + const input = makeSshLegacyInventoryScenario() + const plan = planSshLegacyMigrationInventory(replaceOnlyRow(input, changeRow)) + + expect(plan.imports).toEqual([]) + expect(plan.unresolved).toHaveLength(1) + expect(plan.unresolved[0].reason).toBe('physical-pty-incarnation-unproved') + }) + + it('requires independently matching endpoint and process identity proof', () => { + const input = makeSshLegacyInventoryScenario() + const relay = input.liveRelays[0] + const expectedEndpoint = relay.identityProof.expectedEndpoint + const expectedProcess = relay.identityProof.expectedProcess + if (expectedEndpoint?.kind !== 'unix-socket') { + throw new Error('test requires a Unix endpoint') + } + if (expectedProcess === null) { + throw new Error('test requires a relay process') + } + const changedEndpoint = { ...expectedEndpoint, inode: '999' } + + const endpointPlan = planSshLegacyMigrationInventory({ + ...input, + liveRelays: [ + { + ...relay, + identityProof: { ...relay.identityProof, observedEndpoint: changedEndpoint } + } + ] + }) + const processPlan = planSshLegacyMigrationInventory({ + ...input, + liveRelays: [ + { + ...relay, + identityProof: { + ...relay.identityProof, + observedProcess: { ...expectedProcess, birthMarker: 'changed-birth-marker' } + } + } + ] + }) + + expect(endpointPlan.imports).toEqual([]) + expect(endpointPlan.unresolved[0].reason).toBe('endpoint-identity-unproved') + expect(processPlan.imports).toEqual([]) + expect(processPlan.unresolved[0].reason).toBe('endpoint-identity-unproved') + }) + + it('rejects namespaces that disagree with the explicit authority marker', () => { + const input = makeSshLegacyInventoryScenario({ authorityHostId: 'authority-from-marker' }) + const relay = input.liveRelays[0] + const row = relay.rows[0] + + expect(() => + planSshLegacyMigrationInventory({ + ...input, + liveRelays: [ + { + ...relay, + rows: [ + { + ...row, + namespace: { ...row.namespace, authorityHostId: 'routing-target-derived-host' } + } + ] + } + ] + }) + ).toThrow('namespace authority host does not match its marker') + }) + + it('produces identical digests and ids after every evidence array is reordered', () => { + const first = makeSshLegacyInventoryScenario() + const second = makeSshLegacyInventoryScenario({ + physicalPtyId: 'pty-2', + ptyIncarnationId: 'incarnation-2', + processId: 4_202, + tabId: 'tab-b', + leafId: 'leaf-b', + namespaceId: 'namespace-b', + localPartitionId: 'local-partition-b', + snapshotPartitionId: 'remote-partition-b' + }) + const separateRelays = combine(first, second) + const combined: SshLegacyMigrationInventoryInput = { + ...separateRelays, + liveRelays: [ + { + ...separateRelays.liveRelays[0], + rows: separateRelays.liveRelays.flatMap((relay) => relay.rows) + } + ] + } + const reordered: SshLegacyMigrationInventoryInput = { + ...combined, + persistedConsumerRecoveries: combined.persistedConsumerRecoveries.toReversed(), + persistedPtyLeases: combined.persistedPtyLeases.toReversed(), + localLayoutPanes: combined.localLayoutPanes.toReversed(), + remoteSnapshotPanes: combined.remoteSnapshotPanes.toReversed(), + liveRelays: combined.liveRelays + .toReversed() + .map((relay) => ({ ...relay, rows: relay.rows.toReversed() })) + } + + expect(planSshLegacyMigrationInventory(reordered)).toEqual( + planSshLegacyMigrationInventory(combined) + ) + }) + + it('rejects capacity before scanning oversized evidence values', () => { + const input = makeSshLegacyInventoryScenario() + const oversized = Array.from( + { length: SSH_LEGACY_MIGRATION_INVENTORY_CAPACITY.persistedConsumerRecoveries + 1 }, + () => null as never + ) + + expect(() => + planSshLegacyMigrationInventory({ + ...input, + persistedConsumerRecoveries: oversized + }) + ).toThrow(SshLegacyMigrationInventoryCapacityError) + }) +}) + +function replaceOnlyRow( + input: SshLegacyMigrationInventoryInput, + change: (row: SshLegacyRelayInventoryRow) => SshLegacyRelayInventoryRow +): SshLegacyMigrationInventoryInput { + const relay = input.liveRelays[0] + return { ...input, liveRelays: [{ ...relay, rows: [change(relay.rows[0])] }] } +} + +function combine( + first: SshLegacyMigrationInventoryInput, + second: SshLegacyMigrationInventoryInput +): SshLegacyMigrationInventoryInput { + return { + ...first, + persistedPtyLeases: [...first.persistedPtyLeases, ...second.persistedPtyLeases], + localLayoutPanes: [...first.localLayoutPanes, ...second.localLayoutPanes], + remoteSnapshotPanes: [...first.remoteSnapshotPanes, ...second.remoteSnapshotPanes], + liveRelays: [...first.liveRelays, ...second.liveRelays] + } +} diff --git a/src/main/ssh/ssh-legacy-migration-inventory-types.ts b/src/main/ssh/ssh-legacy-migration-inventory-types.ts new file mode 100644 index 00000000000..e8d8f61b0a7 --- /dev/null +++ b/src/main/ssh/ssh-legacy-migration-inventory-types.ts @@ -0,0 +1,98 @@ +import type { + TerminalLegacyEndpointIdentity, + TerminalLegacyImportCandidate, + TerminalLegacyProcessIdentity, + TerminalLegacyRecoveryReason, + TerminalLegacyUnresolvedCandidate, + TerminalLegacyWorkspaceEvidence +} from '../../shared/terminal-legacy-cutover' +import type { TerminalAuthorityNamespace } from '../../shared/terminal-session-authority-identity' +import type { TerminalAuthorityPathFlavor } from '../../shared/terminal-session-authority-locator' +import type { SshPtyConsumerRecovery, SshRemotePtyLease } from '../../shared/ssh-types' + +export type SshLegacyPersistedConsumerEvidence = Readonly< + Pick & { + workerId: string + } +> + +export type SshLegacyLayoutPaneEvidence = Readonly<{ + targetId: string + partitionId: string + ptyId: string + paneKey: string | null + tabId: string | null + leafId: string | null + rendererGeneration: number | null + namespace: TerminalAuthorityNamespace + workspace: TerminalLegacyWorkspaceEvidence +}> + +export type SshLegacyRemoteSnapshotPaneEvidence = SshLegacyLayoutPaneEvidence + +export type SshLegacyRelayIdentityProof = Readonly<{ + expectedEndpoint: TerminalLegacyEndpointIdentity | null + observedEndpoint: TerminalLegacyEndpointIdentity | null + expectedProcess: TerminalLegacyProcessIdentity | null + observedProcess: TerminalLegacyProcessIdentity | null +}> + +export type SshLegacySerializedPtyEvidence = Readonly<{ + paneKey: string | null + tabId: string | null + worktreeId: string | null + cwd: string | null + ptyIncarnationId: string | null + processId: number | null +}> + +export type SshLegacyRelayInventoryRow = Readonly<{ + workerId: string + buildId: string + physicalPtyId: string + ptyIncarnationId: string | null + processId: number | null + namespace: TerminalAuthorityNamespace + workspace: TerminalLegacyWorkspaceEvidence + serialized: SshLegacySerializedPtyEvidence +}> + +export type SshLegacyLiveRelayInventory = Readonly<{ + workerId: string + buildId: string + observedAtMs: number + identityProof: SshLegacyRelayIdentityProof + rows: readonly SshLegacyRelayInventoryRow[] +}> + +export type SshLegacyMigrationInventoryInput = Readonly<{ + targetId: string + authorityHostId: string + hostPathFlavor: TerminalAuthorityPathFlavor + persistedConsumerRecoveries: readonly SshLegacyPersistedConsumerEvidence[] + persistedPtyLeases: readonly SshRemotePtyLease[] + localLayoutPanes: readonly SshLegacyLayoutPaneEvidence[] + remoteSnapshotPanes: readonly SshLegacyRemoteSnapshotPaneEvidence[] + liveRelays: readonly SshLegacyLiveRelayInventory[] +}> + +export type SshLegacyMigrationInventorySummary = Readonly<{ + evidenceDigest: string + migrationId: string + relayCount: number + inventoryRowCount: number + importCount: number + unresolvedCount: number + unresolvedReasons: readonly Readonly<{ + reason: TerminalLegacyRecoveryReason + count: number + }>[] +}> + +export type SshLegacyMigrationInventoryPlan = Readonly<{ + evidenceDigest: string + migrationId: string + imports: readonly TerminalLegacyImportCandidate[] + unresolved: readonly TerminalLegacyUnresolvedCandidate[] + summary: SshLegacyMigrationInventorySummary +}> diff --git a/src/main/ssh/ssh-legacy-migration-inventory-validation.ts b/src/main/ssh/ssh-legacy-migration-inventory-validation.ts new file mode 100644 index 00000000000..50173c300bd --- /dev/null +++ b/src/main/ssh/ssh-legacy-migration-inventory-validation.ts @@ -0,0 +1,253 @@ +import type { + TerminalLegacyEndpointIdentity, + TerminalLegacyProcessIdentity, + TerminalLegacyWorkspaceEvidence +} from '../../shared/terminal-legacy-cutover' +import { + assertAuthorityId, + assertAuthorityNamespace, + assertAuthorityStoragePath, + isRecord +} from '../../shared/terminal-session-authority-identity' +import { + assertTerminalAuthorityNamespaceLocator, + type TerminalAuthorityPathFlavor +} from '../../shared/terminal-session-authority-locator' +import type { SshRemotePtyLease } from '../../shared/ssh-types' +import type { + SshLegacyLayoutPaneEvidence, + SshLegacyMigrationInventoryInput, + SshLegacyRelayIdentityProof, + SshLegacyRelayInventoryRow, + SshLegacySerializedPtyEvidence +} from './ssh-legacy-migration-inventory-types' + +const LEASE_STATES = new Set(['attached', 'detached', 'terminated', 'expired']) + +export function assertSshLegacyMigrationInventoryInput( + input: SshLegacyMigrationInventoryInput +): void { + assertAuthorityId(input.targetId, 'SSH legacy targetId') + assertAuthorityId(input.authorityHostId, 'SSH legacy authorityHostId') + if (input.hostPathFlavor !== 'posix' && input.hostPathFlavor !== 'windows') { + throw new Error('SSH legacy host path flavor is invalid') + } + for (const recovery of input.persistedConsumerRecoveries) { + assertAuthorityId(recovery.targetId, 'SSH legacy consumer targetId') + assertAuthorityId(recovery.workerId, 'SSH legacy consumer workerId') + assertAuthorityId(recovery.clientInstanceId, 'SSH legacy consumer recovery ID') + assertAuthorityId(recovery.serverBuildId, 'SSH legacy consumer build ID') + } + for (const lease of input.persistedPtyLeases) { + assertLease(lease) + } + for (const pane of input.localLayoutPanes) { + assertPane( + pane, + pane.targetId === input.targetId ? input.hostPathFlavor : undefined, + input.authorityHostId + ) + } + for (const pane of input.remoteSnapshotPanes) { + assertPane( + pane, + pane.targetId === input.targetId ? input.hostPathFlavor : undefined, + input.authorityHostId + ) + } + for (const relay of input.liveRelays) { + assertAuthorityId(relay.workerId, 'SSH legacy relay workerId') + assertAuthorityId(relay.buildId, 'SSH legacy relay buildId') + assertNonNegativeInteger(relay.observedAtMs, 'SSH legacy relay observedAtMs') + assertIdentityProof(relay.identityProof) + for (const row of relay.rows) { + assertInventoryRow(row, input.hostPathFlavor, input.authorityHostId) + } + } +} + +function assertLease(lease: SshRemotePtyLease): void { + assertAuthorityId(lease.targetId, 'SSH legacy lease targetId') + assertAuthorityId(lease.ptyId, 'SSH legacy lease ptyId') + assertOptionalId(lease.incarnationId, 'SSH legacy lease incarnationId') + assertOptionalPath(lease.worktreeId, 'SSH legacy lease worktreeId') + assertOptionalId(lease.tabId, 'SSH legacy lease tabId') + assertOptionalId(lease.leafId, 'SSH legacy lease leafId') + if (lease.paneGeneration !== undefined) { + assertNonNegativeInteger(lease.paneGeneration, 'SSH legacy lease paneGeneration') + } + if (!LEASE_STATES.has(lease.state)) { + throw new Error('SSH legacy lease state is invalid') + } + assertNonNegativeInteger(lease.createdAt, 'SSH legacy lease createdAt') + assertNonNegativeInteger(lease.updatedAt, 'SSH legacy lease updatedAt') + assertOptionalNonNegativeInteger(lease.lastAttachedAt, 'SSH legacy lease lastAttachedAt') + assertOptionalNonNegativeInteger(lease.lastDetachedAt, 'SSH legacy lease lastDetachedAt') +} + +function assertPane( + pane: SshLegacyLayoutPaneEvidence, + flavor: TerminalAuthorityPathFlavor | undefined, + authorityHostId: string +): void { + assertAuthorityId(pane.targetId, 'SSH legacy pane targetId') + assertAuthorityId(pane.partitionId, 'SSH legacy pane partitionId') + assertAuthorityId(pane.ptyId, 'SSH legacy pane ptyId') + assertNullableId(pane.paneKey, 'SSH legacy pane paneKey') + assertNullableId(pane.tabId, 'SSH legacy pane tabId') + assertNullableId(pane.leafId, 'SSH legacy pane leafId') + if (pane.rendererGeneration !== null) { + assertNonNegativeInteger(pane.rendererGeneration, 'SSH legacy pane rendererGeneration') + } + assertAuthorityNamespace(pane.namespace) + assertNamespaceHost(pane.namespace.authorityHostId, authorityHostId) + assertWorkspace(pane.workspace, flavor) +} + +function assertInventoryRow( + row: SshLegacyRelayInventoryRow, + flavor: TerminalAuthorityPathFlavor | undefined, + authorityHostId: string +): void { + assertAuthorityId(row.workerId, 'SSH legacy inventory workerId') + assertAuthorityId(row.buildId, 'SSH legacy inventory buildId') + assertAuthorityId(row.physicalPtyId, 'SSH legacy inventory physicalPtyId') + assertNullableId(row.ptyIncarnationId, 'SSH legacy inventory ptyIncarnationId') + assertNullablePositiveInteger(row.processId, 'SSH legacy inventory processId') + assertAuthorityNamespace(row.namespace) + assertNamespaceHost(row.namespace.authorityHostId, authorityHostId) + assertWorkspace(row.workspace, flavor) + assertSerializedEvidence(row.serialized) +} + +function assertNamespaceHost(actual: string, expected: string): void { + if (actual !== expected) { + throw new Error('SSH legacy namespace authority host does not match its marker') + } +} + +function assertSerializedEvidence(value: SshLegacySerializedPtyEvidence): void { + assertNullableId(value.paneKey, 'SSH legacy serialized paneKey') + assertNullableId(value.tabId, 'SSH legacy serialized tabId') + assertNullablePath(value.worktreeId, 'SSH legacy serialized worktreeId') + assertNullablePath(value.cwd, 'SSH legacy serialized cwd') + assertNullableId(value.ptyIncarnationId, 'SSH legacy serialized ptyIncarnationId') + assertNullablePositiveInteger(value.processId, 'SSH legacy serialized processId') +} + +function assertWorkspace( + workspace: TerminalLegacyWorkspaceEvidence, + flavor: TerminalAuthorityPathFlavor | undefined +): void { + if (!isRecord(workspace) || !isRecord(workspace.locator)) { + throw new Error('SSH legacy workspace evidence is invalid') + } + assertTerminalAuthorityNamespaceLocator(workspace.locator) + if ( + flavor !== undefined && + workspace.locator.kind === 'workspace' && + workspace.locator.pathFlavor !== flavor + ) { + throw new Error('SSH legacy workspace path flavor does not match its host') + } + if (workspace.kind === 'git-worktree') { + if (workspace.locator.kind !== 'workspace') { + throw new Error('SSH legacy git workspace locator is invalid') + } + assertAuthorityStoragePath(workspace.worktreeId, 'SSH legacy worktreeId') + return + } + if (workspace.kind === 'folder') { + if (workspace.locator.kind !== 'workspace') { + throw new Error('SSH legacy folder locator is invalid') + } + return + } + if (workspace.kind !== 'floating' || workspace.locator.kind !== 'floating') { + throw new Error('SSH legacy floating locator is invalid') + } +} + +function assertIdentityProof(proof: SshLegacyRelayIdentityProof): void { + assertNullableEndpoint(proof.expectedEndpoint) + assertNullableEndpoint(proof.observedEndpoint) + assertNullableProcess(proof.expectedProcess) + assertNullableProcess(proof.observedProcess) +} + +function assertNullableEndpoint(value: TerminalLegacyEndpointIdentity | null): void { + if (value === null) { + return + } + if (value.kind === 'unix-socket') { + if ( + ![value.device, value.inode, value.changedAtNs].every( + (part) => typeof part === 'string' && /^[0-9]+$/.test(part) + ) + ) { + throw new Error('SSH legacy Unix endpoint identity is invalid') + } + return + } + if (value.kind !== 'windows-named-pipe') { + throw new Error('SSH legacy endpoint identity kind is invalid') + } + assertAuthorityStoragePath(value.pipeName, 'SSH legacy pipe name') + assertAuthorityId(value.processCreationMarker, 'SSH legacy pipe process marker') +} + +function assertNullableProcess(value: TerminalLegacyProcessIdentity | null): void { + if (value === null) { + return + } + assertPositiveInteger(value.pid, 'SSH legacy worker pid') + assertAuthorityId(value.birthMarker, 'SSH legacy worker birth marker') +} + +function assertNullableId(value: string | null, field: string): void { + if (value !== null) { + assertAuthorityId(value, field) + } +} + +function assertOptionalId(value: string | undefined, field: string): void { + if (value !== undefined) { + assertAuthorityId(value, field) + } +} + +function assertNullablePath(value: string | null, field: string): void { + if (value !== null) { + assertAuthorityStoragePath(value, field) + } +} + +function assertOptionalPath(value: string | undefined, field: string): void { + if (value !== undefined) { + assertAuthorityStoragePath(value, field) + } +} + +function assertPositiveInteger(value: number, field: string): void { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error(`${field} is invalid`) + } +} + +function assertNonNegativeInteger(value: number, field: string): void { + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error(`${field} is invalid`) + } +} + +function assertNullablePositiveInteger(value: number | null, field: string): void { + if (value !== null) { + assertPositiveInteger(value, field) + } +} + +function assertOptionalNonNegativeInteger(value: number | undefined, field: string): void { + if (value !== undefined) { + assertNonNegativeInteger(value, field) + } +} diff --git a/src/main/ssh/ssh-legacy-migration-layout-evidence.ts b/src/main/ssh/ssh-legacy-migration-layout-evidence.ts new file mode 100644 index 00000000000..252d869af73 --- /dev/null +++ b/src/main/ssh/ssh-legacy-migration-layout-evidence.ts @@ -0,0 +1,95 @@ +import { isRecord } from '../../shared/terminal-session-authority-identity' +import { isTerminalLeafId, makePaneKey } from '../../shared/stable-pane-id' +import { + SSH_LEGACY_MIGRATION_EVIDENCE_CAPACITY, + boundedSshLegacyRecordEntries, + failSshLegacyMigrationEvidence +} from './ssh-legacy-migration-evidence-capacity' + +export type SshLegacyLayoutBinding = Readonly<{ + leafId: string + paneKey: string + ptyId: string +}> + +export function collectSshLegacyLayoutBindings(args: { + tabId: string + fallbackPtyId: string | null + layout: unknown +}): readonly SshLegacyLayoutBinding[] { + if (args.layout === undefined) { + return [] + } + if (!isRecord(args.layout)) { + failSshLegacyMigrationEvidence('malformed', 'terminal layout') + } + const leafIds = collectLayoutLeafIds(args.layout.root) + const bindingEntries = + args.layout.ptyIdsByLeafId === undefined + ? [] + : boundedSshLegacyRecordEntries( + args.layout.ptyIdsByLeafId, + SSH_LEGACY_MIGRATION_EVIDENCE_CAPACITY.bindingsPerLayout, + 'terminal layout bindings' + ) + if (bindingEntries.length === 0) { + const onlyLeafId = leafIds.size === 1 ? [...leafIds][0] : null + return args.fallbackPtyId && onlyLeafId + ? [makeBinding(args.tabId, onlyLeafId, args.fallbackPtyId)] + : [] + } + return bindingEntries.map(([leafId, ptyId]) => { + if (typeof ptyId !== 'string' || !leafIds.has(leafId)) { + failSshLegacyMigrationEvidence('malformed', 'terminal pane binding') + } + return makeBinding(args.tabId, leafId, ptyId) + }) +} + +function collectLayoutLeafIds(root: unknown): ReadonlySet { + if (root === null) { + return new Set() + } + if (!isRecord(root)) { + failSshLegacyMigrationEvidence('malformed', 'terminal layout root') + } + const leafIds = new Set() + const pending: unknown[] = [root] + let scanned = 0 + while (pending.length > 0) { + scanned += 1 + if (scanned > SSH_LEGACY_MIGRATION_EVIDENCE_CAPACITY.layoutNodes) { + failSshLegacyMigrationEvidence('capacity', 'terminal layout nodes') + } + const node = pending.pop() + if (!isRecord(node)) { + failSshLegacyMigrationEvidence('malformed', 'terminal layout node') + } + if (node.type === 'leaf') { + if (typeof node.leafId !== 'string' || !isTerminalLeafId(node.leafId)) { + failSshLegacyMigrationEvidence('malformed', 'terminal leaf identity') + } + if (leafIds.has(node.leafId)) { + failSshLegacyMigrationEvidence('ambiguity', 'terminal leaf identity') + } + leafIds.add(node.leafId) + continue + } + if (node.type !== 'split') { + failSshLegacyMigrationEvidence('malformed', 'terminal layout node kind') + } + pending.push(node.first, node.second) + } + return leafIds +} + +function makeBinding(tabId: string, leafId: string, ptyId: string): SshLegacyLayoutBinding { + if (!tabId || !ptyId || !isTerminalLeafId(leafId)) { + failSshLegacyMigrationEvidence('malformed', 'terminal pane identity') + } + try { + return Object.freeze({ leafId, paneKey: makePaneKey(tabId, leafId), ptyId }) + } catch { + failSshLegacyMigrationEvidence('malformed', 'terminal pane identity') + } +} diff --git a/src/main/ssh/ssh-legacy-migration-lease-normalization.ts b/src/main/ssh/ssh-legacy-migration-lease-normalization.ts new file mode 100644 index 00000000000..c16fcc0e50b --- /dev/null +++ b/src/main/ssh/ssh-legacy-migration-lease-normalization.ts @@ -0,0 +1,57 @@ +import type { SshRemotePtyLease } from '../../shared/ssh-types' +import { sshLegacyPhysicalPtyId } from './ssh-legacy-migration-evidence-identity' +import type { + SshLegacyUnresolvedPaneEvidence, + SshLegacyWorkspaceReference +} from './ssh-legacy-migration-evidence-bridge-types' +import type { SshLegacyLayoutPaneEvidence } from './ssh-legacy-migration-inventory-types' + +export type SshLegacyResolvedPane = Readonly<{ + raw: SshLegacyUnresolvedPaneEvidence + pane: SshLegacyLayoutPaneEvidence +}> + +export function normalizeSshLegacyPtyLeases( + targetId: string, + leases: readonly Readonly[], + localPanes: readonly SshLegacyResolvedPane[] +): readonly SshRemotePtyLease[] { + return leases.map((lease) => { + const physicalPtyId = sshLegacyPhysicalPtyId(targetId, lease.ptyId) + const candidates = localPanes.filter( + ({ raw, pane }) => + physicalPtyId !== null && + sshLegacyPhysicalPtyId(targetId, raw.ptyId) === physicalPtyId && + lease.tabId === raw.tabId && + lease.leafId === raw.leafId && + leaseWorkspaceMatches(lease.worktreeId, raw.workspaceReference, pane.workspace) + ) + if (candidates.length !== 1) { + return Object.freeze({ ...lease }) + } + const { worktreeId: _worktreeId, ...evidence } = lease + void _worktreeId + const workspace = candidates[0].pane.workspace + return Object.freeze({ + ...evidence, + ...(workspace.kind === 'git-worktree' ? { worktreeId: workspace.worktreeId } : {}) + }) + }) +} + +function leaseWorkspaceMatches( + leaseWorktreeId: string | undefined, + reference: SshLegacyWorkspaceReference, + workspace: SshLegacyLayoutPaneEvidence['workspace'] +): boolean { + if (workspace.kind === 'git-worktree') { + return ( + leaseWorktreeId === workspace.worktreeId || + ('clientWorkspaceId' in reference && leaseWorktreeId === reference.clientWorkspaceId) + ) + } + return ( + leaseWorktreeId === undefined || + ('clientWorkspaceId' in reference && leaseWorktreeId === reference.clientWorkspaceId) + ) +} diff --git a/src/main/ssh/ssh-legacy-migration-local-evidence.ts b/src/main/ssh/ssh-legacy-migration-local-evidence.ts new file mode 100644 index 00000000000..b340741463f --- /dev/null +++ b/src/main/ssh/ssh-legacy-migration-local-evidence.ts @@ -0,0 +1,289 @@ +import { + assertAuthorityId, + assertAuthorityStoragePath, + isRecord +} from '../../shared/terminal-session-authority-identity' +import type { SshRemotePtyLease } from '../../shared/ssh-types' +import { sshLegacyClientWorkspaceReference } from './ssh-legacy-migration-workspace-reference' +import { + compareSshLegacyText, + sshLegacyPhysicalPtyId +} from './ssh-legacy-migration-evidence-identity' +import { projectSshLegacyLeaseEvidence } from './ssh-legacy-migration-evidence-projection' +import { + SSH_LEGACY_MIGRATION_EVIDENCE_CAPACITY, + assertSshLegacyArrayCapacity, + boundedSshLegacyRecordEntries, + failSshLegacyMigrationEvidence +} from './ssh-legacy-migration-evidence-capacity' +import type { + SshLegacyLocalMigrationEvidence, + SshLegacyPersistedWorkspacePartition, + SshLegacyUnresolvedPaneEvidence, + SshLegacyWorkerRecoveryAssociation, + SshLegacyWorkspaceReference +} from './ssh-legacy-migration-evidence-bridge-types' +import { collectSshLegacyLayoutBindings } from './ssh-legacy-migration-layout-evidence' + +export function collectSshLegacyLocalMigrationEvidence(input: { + targetId: string + persistedWorkspacePartitions: readonly SshLegacyPersistedWorkspacePartition[] + persistedPtyLeases: readonly Readonly[] + workerRecoveries: readonly SshLegacyWorkerRecoveryAssociation[] +}): SshLegacyLocalMigrationEvidence { + assertAuthorityId(input.targetId, 'SSH legacy evidence targetId') + preflightLocalEvidenceCapacity(input) + const leases = input.persistedPtyLeases.filter((lease) => lease.targetId === input.targetId) + const physicalPtyIds = new Set( + leases + .map((lease) => sshLegacyPhysicalPtyId(input.targetId, lease.ptyId)) + .filter((ptyId): ptyId is string => ptyId !== null) + ) + const panes: SshLegacyUnresolvedPaneEvidence[] = [] + for (const partition of input.persistedWorkspacePartitions) { + validatePartitionIdentity(partition) + if (partition.targetId !== input.targetId) { + continue + } + collectPartitionPanes(input.targetId, partition, physicalPtyIds, panes) + } + const workerRecoveries = input.workerRecoveries + .filter((association) => association.targetId === input.targetId) + .sort(compareWorkerRecovery) + return Object.freeze({ + panes: Object.freeze(panes.sort(comparePaneEvidence)), + leases: Object.freeze([...leases].sort(compareLeaseEvidence)), + workerRecoveries: Object.freeze(workerRecoveries) + }) +} + +function preflightLocalEvidenceCapacity(input: { + persistedWorkspacePartitions: readonly SshLegacyPersistedWorkspacePartition[] + persistedPtyLeases: readonly Readonly[] + workerRecoveries: readonly SshLegacyWorkerRecoveryAssociation[] +}): void { + assertSshLegacyArrayCapacity( + input.persistedWorkspacePartitions, + SSH_LEGACY_MIGRATION_EVIDENCE_CAPACITY.workspacePartitions, + 'workspace partitions' + ) + assertSshLegacyArrayCapacity( + input.persistedPtyLeases, + SSH_LEGACY_MIGRATION_EVIDENCE_CAPACITY.persistedPtyLeases, + 'persisted PTY leases' + ) + assertSshLegacyArrayCapacity( + input.workerRecoveries, + SSH_LEGACY_MIGRATION_EVIDENCE_CAPACITY.workerRecoveries, + 'worker recovery associations' + ) + for (const partition of input.persistedWorkspacePartitions) { + assertSshLegacyArrayCapacity( + partition.folderWorkspaces, + SSH_LEGACY_MIGRATION_EVIDENCE_CAPACITY.folderWorkspacesPerPartition, + 'folder workspaces per partition' + ) + const workspaceEntries = boundedSshLegacyRecordEntries( + partition.session.tabsByWorktree, + SSH_LEGACY_MIGRATION_EVIDENCE_CAPACITY.workspaceOwnersPerPartition, + 'workspace owners per partition' + ) + const layoutEntries = boundedSshLegacyRecordEntries( + partition.session.terminalLayoutsByTabId, + SSH_LEGACY_MIGRATION_EVIDENCE_CAPACITY.layoutsPerPartition, + 'terminal layouts per partition' + ) + for (const [, tabs] of workspaceEntries) { + assertSshLegacyArrayCapacity( + tabs, + SSH_LEGACY_MIGRATION_EVIDENCE_CAPACITY.tabsPerWorkspace, + 'terminal tabs per workspace' + ) + } + for (const [, layout] of layoutEntries) { + if (isRecord(layout) && layout.ptyIdsByLeafId !== undefined) { + boundedSshLegacyRecordEntries( + layout.ptyIdsByLeafId, + SSH_LEGACY_MIGRATION_EVIDENCE_CAPACITY.bindingsPerLayout, + 'terminal layout bindings' + ) + } + } + } +} + +function collectPartitionPanes( + targetId: string, + partition: SshLegacyPersistedWorkspacePartition, + physicalPtyIds: ReadonlySet, + panes: SshLegacyUnresolvedPaneEvidence[] +): void { + const folders = indexFolderWorkspaces(partition) + const workspaceEntries = boundedSshLegacyRecordEntries( + partition.session.tabsByWorktree, + SSH_LEGACY_MIGRATION_EVIDENCE_CAPACITY.workspaceOwnersPerPartition, + 'workspace owners per partition' + ) + for (const [workspaceOwnerId, rawTabs] of workspaceEntries) { + const reference = workspaceReference(workspaceOwnerId, partition, folders) + for (const rawTab of rawTabs as readonly unknown[]) { + if (!isRecord(rawTab)) { + failSshLegacyMigrationEvidence('malformed', 'persisted terminal tab') + } + const tabId = requiredId(rawTab.id, 'persisted terminal tab id') + const bindings = collectSshLegacyLayoutBindings({ + tabId, + fallbackPtyId: nullableId(rawTab.ptyId, 'persisted terminal PTY id'), + layout: partition.session.terminalLayoutsByTabId[tabId] + }) + for (const binding of bindings) { + const physicalPtyId = sshLegacyPhysicalPtyId(targetId, binding.ptyId) + if (physicalPtyId === null || !physicalPtyIds.has(physicalPtyId)) { + continue + } + if (reference === null) { + failSshLegacyMigrationEvidence('malformed', 'persisted workspace owner') + } + panes.push( + Object.freeze({ + targetId, + partitionId: partition.partitionId, + ptyId: binding.ptyId, + paneKey: binding.paneKey, + tabId, + leafId: binding.leafId, + rendererGeneration: rendererGeneration(rawTab.generation), + workspaceReference: floatingReference(reference, rawTab, partition) + }) + ) + if (panes.length > SSH_LEGACY_MIGRATION_EVIDENCE_CAPACITY.totalLocalPanes) { + failSshLegacyMigrationEvidence('capacity', 'total local panes') + } + } + } + } +} + +function indexFolderWorkspaces( + partition: SshLegacyPersistedWorkspacePartition +): ReadonlyMap { + const folders = new Map() + for (const folder of partition.folderWorkspaces) { + assertAuthorityId(folder.id, 'SSH legacy folder workspace id') + assertAuthorityStoragePath(folder.folderPath, 'SSH legacy folder workspace path') + if (folders.has(folder.id)) { + failSshLegacyMigrationEvidence('ambiguity', 'folder workspace identity') + } + folders.set(folder.id, folder.folderPath) + } + return folders +} + +function workspaceReference( + workspaceOwnerId: string, + partition: SshLegacyPersistedWorkspacePartition, + folders: ReadonlyMap +): SshLegacyWorkspaceReference | null { + return sshLegacyClientWorkspaceReference({ + clientWorkspaceId: workspaceOwnerId, + folderPathById: folders, + floatingWorkspacePath: partition.floatingWorkspacePath ?? null + }) +} + +function floatingReference( + reference: SshLegacyWorkspaceReference, + tab: Readonly>, + partition: SshLegacyPersistedWorkspacePartition +): SshLegacyWorkspaceReference { + if (reference.kind !== 'floating') { + return reference + } + const path = + tab.startupCwd === undefined + ? (partition.floatingWorkspacePath ?? null) + : requiredPath(tab.startupCwd, 'SSH legacy floating workspace path') + return Object.freeze({ ...reference, path }) +} + +function validatePartitionIdentity(partition: SshLegacyPersistedWorkspacePartition): void { + assertAuthorityId(partition.targetId, 'SSH legacy partition targetId') + assertAuthorityId(partition.partitionId, 'SSH legacy partitionId') + if (partition.floatingWorkspacePath !== undefined && partition.floatingWorkspacePath !== null) { + assertAuthorityStoragePath( + partition.floatingWorkspacePath, + 'SSH legacy floating workspace path' + ) + } +} + +function rendererGeneration(value: unknown): number | null { + if (value === undefined) { + return null + } + if (!Number.isSafeInteger(value) || Number(value) < 0) { + failSshLegacyMigrationEvidence('malformed', 'persisted renderer generation') + } + return Number(value) +} + +function requiredId(value: unknown, field: string): string { + try { + assertAuthorityId(value, field) + return value + } catch { + failSshLegacyMigrationEvidence('malformed', field) + } +} + +function nullableId(value: unknown, field: string): string | null { + return value === null ? null : requiredId(value, field) +} + +function requiredPath(value: unknown, field: string): string { + try { + assertAuthorityStoragePath(value, field) + return value + } catch { + failSshLegacyMigrationEvidence('malformed', field) + } +} + +function comparePaneEvidence( + left: SshLegacyUnresolvedPaneEvidence, + right: SshLegacyUnresolvedPaneEvidence +): number { + return compareSshLegacyText(paneSortKey(left), paneSortKey(right)) +} + +function paneSortKey(value: SshLegacyUnresolvedPaneEvidence): string { + return JSON.stringify([ + value.partitionId, + value.ptyId, + value.paneKey, + value.rendererGeneration, + value.workspaceReference + ]) +} + +function compareLeaseEvidence(left: SshRemotePtyLease, right: SshRemotePtyLease): number { + return compareSshLegacyText( + JSON.stringify(projectSshLegacyLeaseEvidence(left)), + JSON.stringify(projectSshLegacyLeaseEvidence(right)) + ) +} + +function compareWorkerRecovery( + left: SshLegacyWorkerRecoveryAssociation, + right: SshLegacyWorkerRecoveryAssociation +): number { + return compareSshLegacyText( + JSON.stringify([left.endpointId, left.workerId, left.buildId, left.recovery.clientInstanceId]), + JSON.stringify([ + right.endpointId, + right.workerId, + right.buildId, + right.recovery.clientInstanceId + ]) + ) +} diff --git a/src/main/ssh/ssh-legacy-migration-missing-inventory-classifier.ts b/src/main/ssh/ssh-legacy-migration-missing-inventory-classifier.ts new file mode 100644 index 00000000000..e193dde02ba --- /dev/null +++ b/src/main/ssh/ssh-legacy-migration-missing-inventory-classifier.ts @@ -0,0 +1,84 @@ +import type { + TerminalLegacyInventoryEvidence, + TerminalLegacyUnresolvedCandidate +} from '../../shared/terminal-legacy-cutover' +import { + sshLegacyEvidenceDigest, + sshLegacyEvidenceId, + sshLegacyWorkspaceWorktreeId +} from './ssh-legacy-migration-evidence-identity' +import { + sshLegacyScopedEvidence, + type SshLegacyInventoryIndexes, + type SshLegacyLeaseWithoutInventory +} from './ssh-legacy-migration-inventory-index' +import type { SshLegacyMigrationInventoryInput } from './ssh-legacy-migration-inventory-types' + +export function classifySshLegacyLeaseWithoutInventory( + input: SshLegacyMigrationInventoryInput, + indexes: SshLegacyInventoryIndexes, + group: SshLegacyLeaseWithoutInventory +): TerminalLegacyUnresolvedCandidate | null { + const relays = indexes.consumers.flatMap((consumer) => { + if (indexes.consumersFor(consumer.workerId, consumer.serverBuildId).length !== 1) { + return [] + } + return indexes + .relaysForBuild(consumer.serverBuildId) + .filter((relay) => relay.workerId === consumer.workerId) + }) + if (relays.length !== 1) { + return null + } + const localPanes = indexes.localPanesFor(group.physicalPtyId) + const snapshotPanes = indexes.snapshotPanesFor(group.physicalPtyId) + const pane = localPanes[0] ?? snapshotPanes[0] + if (!pane) { + return null + } + + const relay = relays[0] + const lease = group.leases[0] + const evidenceDigest = sshLegacyEvidenceDigest({ + kind: 'missing-remote-inventory-row', + authorityHostId: input.authorityHostId, + workerId: relay.workerId, + buildId: relay.buildId, + physicalPtyId: group.physicalPtyId, + scopedEvidence: sshLegacyScopedEvidence(input) + }) + const recoveryId = sshLegacyEvidenceId('ssh-legacy-recovery', [ + input.authorityHostId, + relay.workerId, + group.physicalPtyId + ]) + const inventoryEvidence: TerminalLegacyInventoryEvidence = Object.freeze({ + evidenceDigest, + observedAtMs: relay.observedAtMs, + paneKey: pane.paneKey, + tabId: pane.tabId, + worktreeId: sshLegacyWorkspaceWorktreeId(pane.workspace), + cwd: null, + serializedPtyIncarnationId: null, + serializedProcessId: null + }) + + return Object.freeze({ + recoveryId, + namespace: pane.namespace, + workspace: pane.workspace, + physicalPty: Object.freeze({ + workerId: relay.workerId, + physicalPtyId: group.physicalPtyId, + ptyIncarnationId: lease.incarnationId ?? null, + processId: null + }), + reason: 'physical-pty-incarnation-unproved', + evidenceCode: sshLegacyEvidenceId('legacy-physical-pty-incarnation-unproved', evidenceDigest), + inventoryEvidence, + preservation: Object.freeze({ + kind: 'evidence-gc-retained' as const, + processPreservationUnproved: true as const + }) + }) +} diff --git a/src/main/ssh/ssh-legacy-migration-outcome.ts b/src/main/ssh/ssh-legacy-migration-outcome.ts new file mode 100644 index 00000000000..f7f83a54bef --- /dev/null +++ b/src/main/ssh/ssh-legacy-migration-outcome.ts @@ -0,0 +1,54 @@ +import type { + SshLegacyMigrationOutcome, + SshLegacyMigrationUnresolvedPhase +} from './ssh-legacy-migration-coordinator-types' +import type { SshLegacyMigrationInventoryPlan } from './ssh-legacy-migration-inventory-types' + +type UnresolvedOutcome = Extract +type MutationState = UnresolvedOutcome['mutationState'] +type CommittedOutcome = Extract + +export function unresolvedSshLegacyMigrationOutcome( + phase: SshLegacyMigrationUnresolvedPhase, + reason: string, + mutationState: MutationState, + workerId?: string +): UnresolvedOutcome { + return Object.freeze({ + kind: 'unresolved', + phase, + reason: boundedReason(reason), + mutationState, + ...(workerId ? { workerId } : {}) + }) +} + +export function committedSshLegacyMigrationOutcome( + plan: SshLegacyMigrationInventoryPlan, + receipts: CommittedOutcome['receipts'], + barrierId: string, + catalogRevision: number, + gc: CommittedOutcome['gc'] +): CommittedOutcome { + return Object.freeze({ + kind: 'committed', + summary: plan.summary, + receipts: Object.freeze([...receipts]), + barrierId, + catalogRevision, + gc + }) +} + +export function sshLegacyMigrationErrorMessage(error: unknown): string { + return boundedReason(error instanceof Error ? error.message : 'unknown migration failure') +} + +function boundedReason(reason: string): string { + const normalized = reason + .replace(/[\r\n]/g, ' ') + .split('\0') + .join(' ') + .trim() + return normalized.slice(0, 512) || 'unknown migration failure' +} diff --git a/src/main/ssh/ssh-legacy-migration-prior-relay-endpoint.test.ts b/src/main/ssh/ssh-legacy-migration-prior-relay-endpoint.test.ts new file mode 100644 index 00000000000..af4c2ea8c24 --- /dev/null +++ b/src/main/ssh/ssh-legacy-migration-prior-relay-endpoint.test.ts @@ -0,0 +1,146 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { SshTerminalAuthorityMarker } from '../../shared/ssh-terminal-authority-marker' +import type { SshConnection } from './ssh-connection' +import { readSshLegacyPriorRelayEndpoint } from './ssh-legacy-migration-prior-relay-endpoint' +import { getRemoteHostPlatform } from './ssh-remote-platform' + +const execCommand = vi.hoisted(() => vi.fn()) +vi.mock('./ssh-relay-exec-command', () => ({ execCommand })) + +const MARKER = Object.freeze({ + markerVersion: 1, + authorityHostId: 'authority-host-a', + ownerInstanceId: 'owner-prior', + ownerPid: 4_321, + ownerProcessToken: 'prior-process-token', + ownerBuildId: '0.1.0+abc', + ownerRelayDir: '/home/u/.orca-relay/relay-0.1.0+abc', + socketPath: '/home/u/.orca-relay/terminal-authority/authority.sock', + credentialFile: '/home/u/.orca-relay/terminal-authority/endpoint.credential', + compatibility: { major: 1, minMinor: 0, maxMinor: 0, capabilities: [], requiredCapabilities: [] }, + revision: 6 +}) + +function input(overrides: Partial[0]> = {}) { + return { + connection: {} as SshConnection, + hostPlatform: getRemoteHostPlatform('linux-x64'), + nodePath: '/usr/bin/node', + marker: MARKER, + signal: new AbortController().signal, + ...overrides + } +} + +function response(value: unknown): string { + return `ORCA_LEGACY_PRIOR_RELAY ${JSON.stringify(value)}` +} + +describe('prior relay reachability', () => { + beforeEach(() => { + execCommand.mockReset() + }) + + it('observes the endpoint identity of a reachable POSIX relay', async () => { + execCommand.mockResolvedValue( + response({ + endpoint: { device: '2049', inode: '77', changedAtNs: '1700000000000000000' }, + liveness: 'alive' + }) + ) + await expect(readSshLegacyPriorRelayEndpoint(input())).resolves.toEqual({ + kind: 'observed', + endpoint: { + kind: 'unix-socket', + device: '2049', + inode: '77', + changedAtNs: '1700000000000000000' + } + }) + }) + + it('sends one bounded observation that names the recorded socket and pid', async () => { + execCommand.mockResolvedValue(response({ endpoint: null, liveness: 'gone' })) + await readSshLegacyPriorRelayEndpoint(input()) + expect(execCommand).toHaveBeenCalledTimes(1) + const [, command, options] = execCommand.mock.calls[0] + expect(command).toContain(MARKER.socketPath) + expect(command).toContain('4321') + expect(options.timeoutMs).toBeLessThanOrEqual(10_000) + }) + + it('reports unknown when the recorded process is not observably alive', async () => { + execCommand.mockResolvedValue( + response({ endpoint: { device: '1', inode: '2', changedAtNs: '3' }, liveness: 'gone' }) + ) + const result = await readSshLegacyPriorRelayEndpoint(input()) + expect(result.kind).toBe('unknown') + }) + + it('reports unknown when the endpoint is absent rather than assuming it died', async () => { + execCommand.mockResolvedValue(response({ endpoint: null, liveness: 'alive' })) + const result = await readSshLegacyPriorRelayEndpoint(input()) + expect(result).toEqual({ + kind: 'unknown', + reason: 'prior relay endpoint identity is not observable' + }) + }) + + it.each([ + ['no sentinel', 'unrelated output'], + ['malformed payload', 'ORCA_LEGACY_PRIOR_RELAY not-json'] + ])('reports unknown for %s', async (_label, output) => { + execCommand.mockResolvedValue(output) + expect((await readSshLegacyPriorRelayEndpoint(input())).kind).toBe('unknown') + }) + + it('reports unknown when the observation itself fails', async () => { + execCommand.mockRejectedValue(new Error('channel closed')) + const result = await readSshLegacyPriorRelayEndpoint(input()) + expect(result).toEqual({ + kind: 'unknown', + reason: 'prior relay observation failed: channel closed' + }) + }) + + it('never observes once the attempt is already aborted', async () => { + const controller = new AbortController() + controller.abort() + const result = await readSshLegacyPriorRelayEndpoint(input({ signal: controller.signal })) + expect(result.kind).toBe('unknown') + expect(execCommand).not.toHaveBeenCalled() + }) + + it('discards an observation that lands after the attempt aborted', async () => { + const controller = new AbortController() + execCommand.mockImplementation(async () => { + controller.abort() + return response({ + endpoint: { device: '1', inode: '2', changedAtNs: '3' }, + liveness: 'alive' + }) + }) + const result = await readSshLegacyPriorRelayEndpoint(input({ signal: controller.signal })) + expect(result.kind).toBe('unknown') + }) + + it('derives the Windows endpoint from the record once the relay is reachable', async () => { + execCommand.mockResolvedValue(response({ endpoint: null, liveness: 'alive' })) + const windowsMarker = { + ...MARKER, + socketPath: '\\\\.\\pipe\\orca-relay-0123456789abcdef0123' + } as SshTerminalAuthorityMarker + await expect( + readSshLegacyPriorRelayEndpoint( + input({ hostPlatform: getRemoteHostPlatform('win32-x64'), marker: windowsMarker }) + ) + ).resolves.toEqual({ + kind: 'observed', + endpoint: { + kind: 'windows-named-pipe', + pipeName: '\\\\.\\pipe\\orca-relay-0123456789abcdef0123', + processCreationMarker: 'prior-process-token' + } + }) + }) +}) diff --git a/src/main/ssh/ssh-legacy-migration-prior-relay-endpoint.ts b/src/main/ssh/ssh-legacy-migration-prior-relay-endpoint.ts new file mode 100644 index 00000000000..28fc9182041 --- /dev/null +++ b/src/main/ssh/ssh-legacy-migration-prior-relay-endpoint.ts @@ -0,0 +1,144 @@ +import type { SshTerminalAuthorityMarker } from '../../shared/ssh-terminal-authority-marker' +import type { TerminalLegacyEndpointIdentity } from '../../shared/terminal-legacy-cutover' +import { isRecord } from '../../shared/terminal-session-authority-identity' +import type { SshConnection } from './ssh-connection' +import { shellEscape } from './ssh-connection-utils' +import { sshLegacyPriorRelayWindowsEndpoint } from './ssh-legacy-migration-prior-relay-status' +import { execCommand } from './ssh-relay-exec-command' +import { isWindowsRemoteHost, type RemoteHostPlatform } from './ssh-remote-platform' +import { powerShellCommand, powerShellNativeArg } from './ssh-remote-powershell' + +const SENTINEL = 'ORCA_LEGACY_PRIOR_RELAY' +const READ_TIMEOUT_MS = 10_000 +const MAX_RESPONSE_CHARS = 4_096 + +/** + * One bounded read-only observation of the recorded prior relay. It never mutates the legacy + * endpoint and its failure modes are all `unknown`, so no branch here can authorize a cutover. + */ +export type SshLegacyPriorRelayEndpoint = + | Readonly<{ kind: 'observed'; endpoint: TerminalLegacyEndpointIdentity }> + | Readonly<{ kind: 'unknown'; reason: string }> + +export type SshLegacyPriorRelayEndpointInput = Readonly<{ + connection: SshConnection + hostPlatform: RemoteHostPlatform + nodePath: string + marker: SshTerminalAuthorityMarker + signal: AbortSignal +}> + +export async function readSshLegacyPriorRelayEndpoint( + input: SshLegacyPriorRelayEndpointInput +): Promise { + if (input.signal.aborted) { + return unknownEndpoint('prior relay observation was aborted') + } + let output: string + try { + output = await execCommand(input.connection, observationCommand(input), { + wrapCommand: !isWindowsRemoteHost(input.hostPlatform), + timeoutMs: READ_TIMEOUT_MS, + signal: input.signal + }) + } catch (error) { + return unknownEndpoint( + `prior relay observation failed: ${error instanceof Error ? error.message : String(error)}` + ) + } + if (input.signal.aborted) { + return unknownEndpoint('prior relay observation was aborted') + } + return parseObservation(output, input) +} + +function observationCommand(input: SshLegacyPriorRelayEndpointInput): string { + const encoded = Buffer.from(observationScript(), 'utf8').toString('base64') + const evaluate = `eval(Buffer.from('${encoded}','base64').toString('utf8'))` + const pid = String(input.marker.ownerPid) + if (!isWindowsRemoteHost(input.hostPlatform)) { + return `${shellEscape(input.nodePath)} -e ${shellEscape(evaluate)} ${shellEscape(input.marker.socketPath)} ${shellEscape(pid)}` + } + return powerShellCommand( + [ + `& ${powerShellNativeArg(input.nodePath)}`, + '-e', + powerShellNativeArg(evaluate), + powerShellNativeArg(input.marker.socketPath), + powerShellNativeArg(pid) + ].join(' ') + ) +} + +// Why remote node and not `stat`: the owning host compares against `fs.lstat` device/inode/ctimeNs, +// and no portable shell reports ctime nanoseconds identically on Linux and macOS. +function observationScript(): string { + return [ + 'const fs=require("fs");', + 'const a=process.argv.slice(1);', + 'let r={endpoint:null,liveness:"unknown"};', + 'try{const s=fs.lstatSync(a[0],{bigint:true});', + 'if(s.isSocket()){r.endpoint={device:s.dev.toString(),inode:s.ino.toString(),changedAtNs:s.ctimeNs.toString()}}', + '}catch(e){r.endpoint=null}', + 'try{process.kill(Number(a[1]),0);r.liveness="alive"}', + 'catch(e){r.liveness=e&&e.code==="EPERM"?"alive":"gone"}', + `process.stdout.write("${SENTINEL} "+JSON.stringify(r))` + ].join('') +} + +function parseObservation( + output: string, + input: SshLegacyPriorRelayEndpointInput +): SshLegacyPriorRelayEndpoint { + const marker = output.lastIndexOf(SENTINEL) + if (marker === -1) { + return unknownEndpoint('prior relay observation produced no response') + } + const payload = output.slice(marker + SENTINEL.length).trim() + if (payload.length === 0 || payload.length > MAX_RESPONSE_CHARS) { + return unknownEndpoint('prior relay observation response is not bounded') + } + let parsed: unknown + try { + parsed = JSON.parse(payload) + } catch { + return unknownEndpoint('prior relay observation response is malformed') + } + if (!isRecord(parsed) || parsed.liveness !== 'alive') { + return unknownEndpoint('prior relay process is not observably alive') + } + return isWindowsRemoteHost(input.hostPlatform) + ? Object.freeze({ + kind: 'observed', + endpoint: sshLegacyPriorRelayWindowsEndpoint(input.marker) + }) + : posixEndpoint(parsed.endpoint) +} + +function posixEndpoint(value: unknown): SshLegacyPriorRelayEndpoint { + if ( + !isRecord(value) || + !boundedDigits(value.device) || + !boundedDigits(value.inode) || + !boundedDigits(value.changedAtNs) + ) { + return unknownEndpoint('prior relay endpoint identity is not observable') + } + return Object.freeze({ + kind: 'observed', + endpoint: Object.freeze({ + kind: 'unix-socket', + device: value.device, + inode: value.inode, + changedAtNs: value.changedAtNs + }) + }) +} + +function boundedDigits(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 && value.length <= 40 && /^\d+$/.test(value) +} + +function unknownEndpoint(reason: string): SshLegacyPriorRelayEndpoint { + return Object.freeze({ kind: 'unknown', reason }) +} diff --git a/src/main/ssh/ssh-legacy-migration-prior-relay-status.test.ts b/src/main/ssh/ssh-legacy-migration-prior-relay-status.test.ts new file mode 100644 index 00000000000..f45fbb365d1 --- /dev/null +++ b/src/main/ssh/ssh-legacy-migration-prior-relay-status.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from 'vitest' +import type { SshTerminalAuthorityMarker } from '../../shared/ssh-terminal-authority-marker' +import { getRemoteHostPlatform } from './ssh-remote-platform' +import { + resolveSshLegacyPriorRelayStatus, + sshLegacyPriorRelayWindowsEndpoint, + sshLegacyPriorRelayWorkerDescriptor +} from './ssh-legacy-migration-prior-relay-status' + +const OWNER = Object.freeze({ + authorityHostId: 'authority-host-a', + ownerInstanceId: 'owner-new', + revision: 7 +}) + +function marker(overrides: Partial = {}): SshTerminalAuthorityMarker { + return { + markerVersion: 1, + authorityHostId: 'authority-host-a', + ownerInstanceId: 'owner-prior', + ownerPid: 4_321, + ownerProcessToken: 'prior-process-token', + ownerBuildId: '0.1.0+abc', + ownerRelayDir: '/home/u/.orca-relay/relay-0.1.0+abc', + socketPath: '/home/u/.orca-relay/terminal-authority/authority.sock', + credentialFile: '/home/u/.orca-relay/terminal-authority/endpoint.credential', + compatibility: { + major: 1, + minMinor: 0, + maxMinor: 0, + capabilities: [], + requiredCapabilities: [] + }, + revision: 6, + ...overrides + } +} + +const POSIX_ENDPOINT = Object.freeze({ + kind: 'unix-socket' as const, + device: '2049', + inode: '77', + changedAtNs: '1700000000000000000' +}) + +describe('recorded prior relay status', () => { + it('reports no prior owner when nothing was recorded', () => { + expect( + resolveSshLegacyPriorRelayStatus({ discovery: { status: 'absent' }, owner: OWNER }) + ).toEqual({ kind: 'none' }) + }) + + it.each(['invalid', 'inconclusive'] as const)('treats %s evidence as unknown', (status) => { + const resolved = resolveSshLegacyPriorRelayStatus({ discovery: { status }, owner: OWNER }) + expect(resolved.kind).toBe('unknown') + }) + + it('reports adoption when the recorded owner is the attached owner', () => { + const discovery = { + status: 'available' as const, + marker: marker({ ownerInstanceId: OWNER.ownerInstanceId, revision: OWNER.revision }) + } + expect(resolveSshLegacyPriorRelayStatus({ discovery, owner: OWNER })).toEqual({ + kind: 'adopted' + }) + }) + + it('refuses to classify a same-owner record at a different revision', () => { + const discovery = { + status: 'available' as const, + marker: marker({ ownerInstanceId: OWNER.ownerInstanceId, revision: OWNER.revision - 1 }) + } + expect(resolveSshLegacyPriorRelayStatus({ discovery, owner: OWNER }).kind).toBe('unknown') + }) + + it('refuses a record minted by a different authority host', () => { + const discovery = { + status: 'available' as const, + marker: marker({ authorityHostId: 'authority-host-b' }) + } + expect(resolveSshLegacyPriorRelayStatus({ discovery, owner: OWNER }).kind).toBe('unknown') + }) + + it('reports a superseded prior owner', () => { + const discovery = { status: 'available' as const, marker: marker() } + expect(resolveSshLegacyPriorRelayStatus({ discovery, owner: OWNER })).toEqual({ + kind: 'superseded', + marker: discovery.marker + }) + }) +}) + +describe('prior relay worker descriptor', () => { + it('derives a POSIX descriptor whose private state is isolated inside the legacy relay dir', () => { + const descriptor = sshLegacyPriorRelayWorkerDescriptor({ + marker: marker(), + hostPlatform: getRemoteHostPlatform('linux-x64'), + clientInstanceId: 'client-a', + expectedEndpoint: POSIX_ENDPOINT + }) + expect(descriptor.platform).toBe('linux') + expect(descriptor.workerId).toBe('owner-prior') + expect(descriptor.ownerIncarnationId).toBe('prior-process-token') + expect(descriptor.process).toEqual({ pid: 4_321, birthMarker: 'prior-process-token' }) + expect(descriptor.expectedEndpoint).toBe(POSIX_ENDPOINT) + expect(descriptor.privateStateDirectory).toBe( + '/home/u/.orca-relay/relay-0.1.0+abc/.orca-legacy-cutover' + ) + expect(descriptor).toMatchObject({ + publicSocketPath: '/home/u/.orca-relay/terminal-authority/authority.sock', + privateSocketPath: '/home/u/.orca-relay/relay-0.1.0+abc/.orca-legacy-cutover/authority.sock', + privateCredentialFile: + '/home/u/.orca-relay/relay-0.1.0+abc/.orca-legacy-cutover/endpoint.credential' + }) + }) + + it('keeps the route id stable for one recorded owner and distinct across revisions', () => { + const host = getRemoteHostPlatform('linux-x64') + const args = { + hostPlatform: host, + clientInstanceId: 'client-a', + expectedEndpoint: POSIX_ENDPOINT + } + const first = sshLegacyPriorRelayWorkerDescriptor({ marker: marker(), ...args }) + const same = sshLegacyPriorRelayWorkerDescriptor({ marker: marker(), ...args }) + const later = sshLegacyPriorRelayWorkerDescriptor({ + marker: marker({ revision: 9 }), + ...args + }) + expect(first.routeId).toBe(same.routeId) + expect(first.routeId).not.toBe(later.routeId) + }) + + it('derives a Windows descriptor from the recorded pipe and process token', () => { + const windowsMarker = marker({ + ownerRelayDir: 'C:/Users/u/.orca-relay/relay-0.1.0+abc', + socketPath: '\\\\.\\pipe\\orca-relay-0123456789abcdef0123', + credentialFile: 'C:/Users/u/.orca-relay/terminal-authority/endpoint.credential' + }) + const descriptor = sshLegacyPriorRelayWorkerDescriptor({ + marker: windowsMarker, + hostPlatform: getRemoteHostPlatform('win32-x64'), + clientInstanceId: 'client-a', + expectedEndpoint: sshLegacyPriorRelayWindowsEndpoint(windowsMarker) + }) + expect(descriptor.platform).toBe('win32') + expect(descriptor).toMatchObject({ + pipeName: '\\\\.\\pipe\\orca-relay-0123456789abcdef0123', + expectedEndpoint: { + kind: 'windows-named-pipe', + pipeName: '\\\\.\\pipe\\orca-relay-0123456789abcdef0123', + processCreationMarker: 'prior-process-token' + } + }) + expect(descriptor.privateStateDirectory.endsWith('.orca-legacy-cutover')).toBe(true) + }) + + it('rejects a record whose identity fields are unusable', () => { + expect(() => + sshLegacyPriorRelayWorkerDescriptor({ + marker: marker({ ownerRelayDir: 'relay\ndir' }), + hostPlatform: getRemoteHostPlatform('linux-x64'), + clientInstanceId: 'client-a', + expectedEndpoint: POSIX_ENDPOINT + }) + ).toThrow() + }) +}) diff --git a/src/main/ssh/ssh-legacy-migration-prior-relay-status.ts b/src/main/ssh/ssh-legacy-migration-prior-relay-status.ts new file mode 100644 index 00000000000..7e4ce23c792 --- /dev/null +++ b/src/main/ssh/ssh-legacy-migration-prior-relay-status.ts @@ -0,0 +1,169 @@ +import { DEFAULT_PTY_SOURCE_WINDOW_SU } from '../../shared/pty-source-credit-contract' +import type { SshTerminalAuthorityMarker } from '../../shared/ssh-terminal-authority-marker' +import type { TerminalLegacyEndpointIdentity } from '../../shared/terminal-legacy-cutover' +import { + assertAuthorityId, + assertAuthorityStoragePath +} from '../../shared/terminal-session-authority-identity' +import type { LegacyPhysicalWorkerDescriptor } from './ssh-legacy-migration-coordinator-types' +import { sshLegacyEvidenceId } from './ssh-legacy-migration-evidence-identity' +import { windowsActivePipeMarkerPath } from './ssh-relay-endpoints' +import { + isWindowsRemoteHost, + joinRemotePath, + remoteBasename, + remoteDirname, + type RemoteHostPlatform +} from './ssh-remote-platform' +import type { SshTerminalAuthorityDiscovery } from './ssh-terminal-authority-discovery' +import { TERMINAL_AUTHORITY_SOCKET_NAME } from './ssh-terminal-authority-endpoint' + +/** Isolation target for the legacy endpoint; siblings of the legacy relay's own state. */ +export const SSH_LEGACY_CUTOVER_PRIVATE_DIRECTORY = '.orca-legacy-cutover' + +export type SshLegacyPriorRelayOwner = Readonly<{ + authorityHostId: string + ownerInstanceId: string + revision: number +}> + +export type SshLegacyPriorRelayStatus = + /** No prior owner was recorded before this attempt launched its own authority. */ + | Readonly<{ kind: 'none' }> + /** The recorded prior owner is the owner this attempt attached to; nothing is legacy. */ + | Readonly<{ kind: 'adopted' }> + /** Evidence exists but cannot identify a prior owner; never treated as absence. */ + | Readonly<{ kind: 'unknown'; reason: string }> + /** A distinct prior owner was recorded and this attempt superseded it. */ + | Readonly<{ kind: 'superseded'; marker: SshTerminalAuthorityMarker }> + +export function resolveSshLegacyPriorRelayStatus( + input: Readonly<{ + discovery: SshTerminalAuthorityDiscovery + owner: SshLegacyPriorRelayOwner + }> +): SshLegacyPriorRelayStatus { + if (input.discovery.status === 'absent') { + return Object.freeze({ kind: 'none' }) + } + if (input.discovery.status !== 'available') { + return Object.freeze({ + kind: 'unknown', + reason: `recorded prior relay status is ${input.discovery.status}` + }) + } + const marker = input.discovery.marker + if (marker.authorityHostId !== input.owner.authorityHostId) { + return Object.freeze({ + kind: 'unknown', + reason: 'recorded prior relay status names a different authority host' + }) + } + if (marker.ownerInstanceId === input.owner.ownerInstanceId) { + return marker.revision === input.owner.revision + ? Object.freeze({ kind: 'adopted' }) + : Object.freeze({ + kind: 'unknown', + reason: 'recorded prior relay status revision does not match the attached owner' + }) + } + return Object.freeze({ kind: 'superseded', marker }) +} + +export type SshLegacyPriorRelayDescriptorInput = Readonly<{ + marker: SshTerminalAuthorityMarker + hostPlatform: RemoteHostPlatform + clientInstanceId: string + requestedSourceWindowSu?: number + expectedEndpoint: TerminalLegacyEndpointIdentity +}> + +/** + * Why derived and not probed: every field below is already recorded evidence. A wrong derivation + * cannot silently import — the owning host compares it against what it observes and the mismatch + * lands in the non-destructive recovery surface. + */ +export function sshLegacyPriorRelayWorkerDescriptor( + input: SshLegacyPriorRelayDescriptorInput +): LegacyPhysicalWorkerDescriptor { + const marker = input.marker + assertAuthorityId(marker.ownerInstanceId, 'prior relay ownerInstanceId') + assertAuthorityId(marker.ownerProcessToken, 'prior relay ownerProcessToken') + assertAuthorityId(marker.ownerBuildId, 'prior relay ownerBuildId') + assertAuthorityStoragePath(marker.ownerRelayDir, 'prior relay ownerRelayDir') + assertAuthorityStoragePath(marker.socketPath, 'prior relay socketPath') + assertAuthorityStoragePath(marker.credentialFile, 'prior relay credentialFile') + assertAuthorityId(input.clientInstanceId, 'prior relay clientInstanceId') + const host = input.hostPlatform + const privateStateDirectory = joinRemotePath( + host, + marker.ownerRelayDir, + SSH_LEGACY_CUTOVER_PRIVATE_DIRECTORY + ) + const base = { + version: 1 as const, + workerId: marker.ownerInstanceId, + routeId: sshLegacyEvidenceId('ssh-legacy-route', [ + marker.authorityHostId, + marker.ownerInstanceId, + marker.ownerRelayDir, + marker.revision + ]), + ownerIncarnationId: marker.ownerProcessToken, + buildId: marker.ownerBuildId, + clientInstanceId: input.clientInstanceId, + relayDirectory: marker.ownerRelayDir, + process: Object.freeze({ pid: marker.ownerPid, birthMarker: marker.ownerProcessToken }), + expectedEndpoint: input.expectedEndpoint, + requestedSourceWindowSu: input.requestedSourceWindowSu ?? DEFAULT_PTY_SOURCE_WINDOW_SU, + publicCredentialFile: marker.credentialFile, + privateCredentialFile: joinRemotePath( + host, + privateStateDirectory, + remoteBasename(marker.credentialFile, host) + ), + privateStateDirectory + } + if (!isWindowsRemoteHost(host)) { + return Object.freeze({ + ...base, + platform: host.os === 'darwin' ? ('darwin' as const) : ('linux' as const), + publicSocketPath: marker.socketPath, + privateSocketPath: joinRemotePath( + host, + privateStateDirectory, + remoteBasename(marker.socketPath, host) + ) + }) + } + const activePipeMarkerPath = windowsActivePipeMarkerPath( + host, + remoteDirname(marker.credentialFile, host), + TERMINAL_AUTHORITY_SOCKET_NAME + ) + return Object.freeze({ + ...base, + platform: 'win32' as const, + pipeName: marker.socketPath, + activePipeMarkerPath, + privateActivePipeMarkerPath: joinRemotePath( + host, + privateStateDirectory, + remoteBasename(activePipeMarkerPath, host) + ) + }) +} + +/** + * The Windows endpoint is a named pipe, so its identity is fully recorded: the pipe path in the + * marker plus the owner's process token the marker was published with. + */ +export function sshLegacyPriorRelayWindowsEndpoint( + marker: SshTerminalAuthorityMarker +): TerminalLegacyEndpointIdentity { + return Object.freeze({ + kind: 'windows-named-pipe', + pipeName: marker.socketPath, + processCreationMarker: marker.ownerProcessToken + }) +} diff --git a/src/main/ssh/ssh-legacy-migration-remote-workspace-snapshot.test.ts b/src/main/ssh/ssh-legacy-migration-remote-workspace-snapshot.test.ts new file mode 100644 index 00000000000..896b4eb4530 --- /dev/null +++ b/src/main/ssh/ssh-legacy-migration-remote-workspace-snapshot.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from 'vitest' +import { makePaneKey } from '../../shared/stable-pane-id' +import { toAppSshPtyId } from '../../shared/ssh-pty-id' +import { + SSH_LEGACY_MIGRATION_EVIDENCE_CAPACITY, + SshLegacyMigrationEvidenceError, + parseSshLegacyRemoteWorkspaceSnapshotEvidence +} from './ssh-legacy-migration-evidence-bridge' + +const TARGET_ID = 'target-a' +const TAB_ID = 'tab-a' +const LEAF_ID = '00000000-0000-4000-8000-000000000001' +const APP_PTY_ID = toAppSshPtyId(TARGET_ID, 'pty-a') + +describe('SSH legacy remote workspace snapshot evidence', () => { + it('parses generation zero as independent pane evidence for each partition', () => { + const snapshot = makeSnapshot() + + const first = parseSshLegacyRemoteWorkspaceSnapshotEvidence({ + targetId: TARGET_ID, + partitionId: 'partition-first', + snapshot + }) + const second = parseSshLegacyRemoteWorkspaceSnapshotEvidence({ + targetId: TARGET_ID, + partitionId: 'partition-second', + snapshot + }) + + expect(first).toEqual([ + { + targetId: TARGET_ID, + partitionId: 'partition-first', + ptyId: APP_PTY_ID, + paneKey: makePaneKey(TAB_ID, LEAF_ID), + tabId: TAB_ID, + leafId: LEAF_ID, + rendererGeneration: 0, + workspaceReference: { kind: 'workspace-path', path: '/remote/private/repo' } + } + ]) + expect(second[0]).toEqual({ ...first[0], partitionId: 'partition-second' }) + }) + + it('rejects duplicate tab identities and malformed path or binding evidence', () => { + const duplicateTab = makeSnapshot({ + tabsByWorktreePath: { + '/remote/one': [makeRemoteTab('/remote/one')], + '/remote/two': [makeRemoteTab('/remote/two')] + } + }) + const malformedPath = makeSnapshot({ + tabsByWorktreePath: { '': [makeRemoteTab('')] } + }) + const malformedBinding = makeSnapshot({ + terminalLayoutsByTabId: { + [TAB_ID]: { + ...makeLayout(), + ptyIdsByLeafId: { + '00000000-0000-4000-8000-000000000099': APP_PTY_ID + } + } + } + }) + + expectEvidenceError(duplicateTab, 'ambiguity') + expectEvidenceError(malformedPath, 'malformed') + expectEvidenceError(malformedBinding, 'malformed') + }) + + it('rejects malformed snapshot shape without inventing missing evidence', () => { + expectEvidenceError( + { + namespace: 'routing-namespace', + session: { tabsByWorktreePath: {} } + }, + 'malformed' + ) + + const withoutGeneration = makeSnapshot({ + tabsByWorktreePath: { + '/remote/private/repo': [makeRemoteTab('/remote/private/repo', false)] + } + }) + expect( + parseSshLegacyRemoteWorkspaceSnapshotEvidence({ + targetId: TARGET_ID, + partitionId: 'partition-a', + snapshot: withoutGeneration + })[0].rendererGeneration + ).toBeNull() + }) + + it('rejects over-capacity tabs before scanning malformed tab entries', () => { + const tabs = Array.from( + { length: SSH_LEGACY_MIGRATION_EVIDENCE_CAPACITY.tabsPerWorkspace + 1 }, + () => null + ) + const snapshot = makeSnapshot({ tabsByWorktreePath: { '/remote/private/repo': tabs } }) + + expectEvidenceError(snapshot, 'capacity') + }) +}) + +function expectEvidenceError( + snapshot: unknown, + code: InstanceType['code'] +): void { + try { + parseSshLegacyRemoteWorkspaceSnapshotEvidence({ + targetId: TARGET_ID, + partitionId: 'partition-a', + snapshot + }) + throw new Error('expected snapshot evidence parsing to fail') + } catch (error) { + expect(error).toBeInstanceOf(SshLegacyMigrationEvidenceError) + expect(error).toMatchObject({ code }) + } +} + +function makeSnapshot( + overrides: { + tabsByWorktreePath?: Readonly> + terminalLayoutsByTabId?: Readonly> + } = {} +): unknown { + return { + namespace: 'client-routing-namespace', + revision: 1, + updatedAt: 1, + schemaVersion: 1, + session: { + tabsByWorktreePath: overrides.tabsByWorktreePath ?? { + '/remote/private/repo': [makeRemoteTab('/remote/private/repo')] + }, + terminalLayoutsByTabId: overrides.terminalLayoutsByTabId ?? { + [TAB_ID]: makeLayout() + } + } + } +} + +function makeRemoteTab(worktreePath: string, includeGeneration = true): unknown { + return { + id: TAB_ID, + ptyId: APP_PTY_ID, + worktreePath, + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + ...(includeGeneration ? { generation: 0 } : {}) + } +} + +function makeLayout(): Readonly> { + return { + root: { type: 'leaf', leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: APP_PTY_ID } + } +} diff --git a/src/main/ssh/ssh-legacy-migration-remote-workspace-snapshot.ts b/src/main/ssh/ssh-legacy-migration-remote-workspace-snapshot.ts new file mode 100644 index 00000000000..59851d0e486 --- /dev/null +++ b/src/main/ssh/ssh-legacy-migration-remote-workspace-snapshot.ts @@ -0,0 +1,219 @@ +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants' +import { + assertAuthorityId, + assertAuthorityStoragePath, + isRecord +} from '../../shared/terminal-session-authority-identity' +import { + compareSshLegacyText, + sshLegacyPhysicalPtyId +} from './ssh-legacy-migration-evidence-identity' +import { + SSH_LEGACY_MIGRATION_EVIDENCE_CAPACITY, + assertSshLegacyArrayCapacity, + boundedSshLegacyRecordEntries, + failSshLegacyMigrationEvidence +} from './ssh-legacy-migration-evidence-capacity' +import type { + SshLegacyUnresolvedPaneEvidence, + SshLegacyWorkspaceReference +} from './ssh-legacy-migration-evidence-bridge-types' +import { collectSshLegacyLayoutBindings } from './ssh-legacy-migration-layout-evidence' + +export function parseSshLegacyRemoteWorkspaceSnapshotEvidence(input: { + targetId: string + partitionId: string + snapshot: unknown +}): readonly SshLegacyUnresolvedPaneEvidence[] { + assertIdentity(input.targetId, 'remote snapshot targetId') + assertIdentity(input.partitionId, 'remote snapshot partitionId') + const session = preflightRemoteSnapshot(input.snapshot) + const tabIds = new Set() + const panes: SshLegacyUnresolvedPaneEvidence[] = [] + const workspaceEntries = boundedSshLegacyRecordEntries( + session.tabsByWorktreePath, + SSH_LEGACY_MIGRATION_EVIDENCE_CAPACITY.remoteWorkspacePaths, + 'remote workspace paths' + ) + for (const [workspacePath, rawTabs] of workspaceEntries) { + assertPath(workspacePath, 'remote workspace path') + for (const rawTab of rawTabs as readonly unknown[]) { + if (!isRecord(rawTab)) { + failSshLegacyMigrationEvidence('malformed', 'remote workspace tab') + } + const tabId = requiredId(rawTab.id, 'remote workspace tab id') + if (tabIds.has(tabId)) { + failSshLegacyMigrationEvidence('ambiguity', 'remote workspace tab identity') + } + tabIds.add(tabId) + if (rawTab.worktreePath !== workspacePath) { + failSshLegacyMigrationEvidence('malformed', 'remote workspace tab path') + } + const bindings = collectSshLegacyLayoutBindings({ + tabId, + fallbackPtyId: nullableId(rawTab.ptyId, 'remote workspace PTY id'), + layout: session.terminalLayoutsByTabId[tabId] + }) + const reference = remoteWorkspaceReference(workspacePath, rawTab) + for (const binding of bindings) { + if (sshLegacyPhysicalPtyId(input.targetId, binding.ptyId) === null) { + continue + } + panes.push( + Object.freeze({ + targetId: input.targetId, + partitionId: input.partitionId, + ptyId: binding.ptyId, + paneKey: binding.paneKey, + tabId, + leafId: binding.leafId, + rendererGeneration: rendererGeneration(rawTab.generation), + workspaceReference: reference + }) + ) + if (panes.length > SSH_LEGACY_MIGRATION_EVIDENCE_CAPACITY.totalRemotePanes) { + failSshLegacyMigrationEvidence('capacity', 'total remote snapshot panes') + } + } + } + } + return Object.freeze(panes.sort(comparePaneEvidence)) +} + +type RemoteSnapshotSessionEvidence = Readonly<{ + tabsByWorktreePath: Readonly> + terminalLayoutsByTabId: Readonly> +}> + +function preflightRemoteSnapshot(snapshot: unknown): RemoteSnapshotSessionEvidence { + if (!isRecord(snapshot) || !isRecord(snapshot.session)) { + failSshLegacyMigrationEvidence('malformed', 'remote workspace snapshot') + } + if (snapshot.namespace !== undefined) { + requiredId(snapshot.namespace, 'remote snapshot routing namespace') + } + const session = snapshot.session + const workspaceEntries = boundedSshLegacyRecordEntries( + session.tabsByWorktreePath, + SSH_LEGACY_MIGRATION_EVIDENCE_CAPACITY.remoteWorkspacePaths, + 'remote workspace paths' + ) + const layoutEntries = boundedSshLegacyRecordEntries( + session.terminalLayoutsByTabId, + SSH_LEGACY_MIGRATION_EVIDENCE_CAPACITY.layoutsPerPartition, + 'remote terminal layouts' + ) + let totalTabs = 0 + for (const [, tabs] of workspaceEntries) { + assertSshLegacyArrayCapacity( + tabs, + SSH_LEGACY_MIGRATION_EVIDENCE_CAPACITY.tabsPerWorkspace, + 'remote tabs per workspace' + ) + totalTabs += tabs.length + if (totalTabs > SSH_LEGACY_MIGRATION_EVIDENCE_CAPACITY.totalRemoteTabs) { + failSshLegacyMigrationEvidence('capacity', 'total remote tabs') + } + } + for (const [, layout] of layoutEntries) { + if (isRecord(layout) && layout.ptyIdsByLeafId !== undefined) { + boundedSshLegacyRecordEntries( + layout.ptyIdsByLeafId, + SSH_LEGACY_MIGRATION_EVIDENCE_CAPACITY.bindingsPerLayout, + 'remote terminal layout bindings' + ) + } + } + return Object.freeze({ + tabsByWorktreePath: session.tabsByWorktreePath as Readonly>, + terminalLayoutsByTabId: session.terminalLayoutsByTabId as Readonly> + }) +} + +function remoteWorkspaceReference( + workspacePath: string, + tab: Readonly> +): SshLegacyWorkspaceReference { + if (workspacePath !== FLOATING_TERMINAL_WORKTREE_ID) { + return Object.freeze({ kind: 'workspace-path', path: workspacePath }) + } + const path = + tab.startupCwd === undefined + ? null + : requiredPath(tab.startupCwd, 'remote floating workspace path') + return Object.freeze({ + kind: 'floating', + clientWorkspaceId: FLOATING_TERMINAL_WORKTREE_ID, + path + }) +} + +function rendererGeneration(value: unknown): number | null { + if (value === undefined) { + return null + } + if (!Number.isSafeInteger(value) || Number(value) < 0) { + failSshLegacyMigrationEvidence('malformed', 'remote renderer generation') + } + return Number(value) +} + +function assertIdentity(value: unknown, field: string): void { + try { + assertAuthorityId(value, field) + } catch { + failSshLegacyMigrationEvidence('malformed', field) + } +} + +function requiredId(value: unknown, field: string): string { + try { + assertAuthorityId(value, field) + return value + } catch { + failSshLegacyMigrationEvidence('malformed', field) + } +} + +function nullableId(value: unknown, field: string): string | null { + return value === null ? null : requiredId(value, field) +} + +function assertPath(value: unknown, field: string): void { + try { + assertAuthorityStoragePath(value, field) + } catch { + failSshLegacyMigrationEvidence('malformed', field) + } +} + +function requiredPath(value: unknown, field: string): string { + try { + assertAuthorityStoragePath(value, field) + return value + } catch { + failSshLegacyMigrationEvidence('malformed', field) + } +} + +function comparePaneEvidence( + left: SshLegacyUnresolvedPaneEvidence, + right: SshLegacyUnresolvedPaneEvidence +): number { + return compareSshLegacyText( + JSON.stringify([ + left.partitionId, + left.ptyId, + left.paneKey, + left.rendererGeneration, + left.workspaceReference + ]), + JSON.stringify([ + right.partitionId, + right.ptyId, + right.paneKey, + right.rendererGeneration, + right.workspaceReference + ]) + ) +} diff --git a/src/main/ssh/ssh-legacy-migration-rpc.ts b/src/main/ssh/ssh-legacy-migration-rpc.ts new file mode 100644 index 00000000000..57d70128803 --- /dev/null +++ b/src/main/ssh/ssh-legacy-migration-rpc.ts @@ -0,0 +1,270 @@ +import { isDeepStrictEqual } from 'node:util' +import { assertTerminalLegacyMigrationReceipt } from '../../shared/terminal-legacy-cutover-request-validation' +import type { + TerminalLegacyImportCandidate, + TerminalLegacyMigrationReceipt, + TerminalLegacyUnresolvedCandidate +} from '../../shared/terminal-legacy-cutover' +import { + assertAuthorityId, + assertAuthorityStoragePath, + isRecord +} from '../../shared/terminal-session-authority-identity' +import type { + LegacyPhysicalWorkerDescriptor, + SshLegacyMigrationRpc, + SshLegacyPhysicalWorkerInspection +} from './ssh-legacy-migration-coordinator-types' +import { SSH_LEGACY_MIGRATION_INVENTORY_CAPACITY } from './ssh-legacy-migration-inventory-capacity' + +const INSPECT_METHOD = 'terminalAuthority.legacyPhysicalWorker.inspect' +const MIGRATE_METHOD = 'terminalAuthority.legacyPhysicalWorker.migrate' +const GC_PROTECTION_METHOD = 'terminalAuthority.legacyPhysicalWorker.gcProtection' +const MIGRATION_BARRIER_METHOD = 'terminalAuthority.legacyPhysicalWorker.migrationBarrier' +const GC_METHOD = 'terminalAuthority.legacyPhysicalWorker.gc' + +export type SshLegacyWorkerCatalog = Readonly<{ + migrationId: string + authorityHostId: string + requestedAtMs: number + imports: readonly TerminalLegacyImportCandidate[] + unresolved: readonly TerminalLegacyUnresolvedCandidate[] +}> + +export type SshLegacyWorkerMigrationOperation = Readonly<{ + operationId: string + inspectionToken: string + evidenceDigest: string + catalog: SshLegacyWorkerCatalog +}> + +export type SshLegacyWorkerMigrationCommit = Readonly<{ + receipt: TerminalLegacyMigrationReceipt + duplicate: boolean +}> + +export async function inspectSshLegacyWorker(input: { + rpc: SshLegacyMigrationRpc + worker: LegacyPhysicalWorkerDescriptor + signal: AbortSignal +}): Promise { + const value = await input.rpc.request( + INSPECT_METHOD, + { + version: 1, + worker: input.worker, + requirements: { + inspectionMode: 'observational', + catalogValidation: 'before-isolation', + replay: 'durable-operation-id' + } + }, + { signal: input.signal } + ) + return parseInspection(value, input.worker) +} + +export async function migrateSshLegacyWorker(input: { + rpc: SshLegacyMigrationRpc + worker: LegacyPhysicalWorkerDescriptor + operation: SshLegacyWorkerMigrationOperation + signal: AbortSignal +}): Promise { + const value = await input.rpc.request( + MIGRATE_METHOD, + operationParams(input.worker, input.operation), + { signal: input.signal } + ) + return parseCommit(value, input.worker, input.operation) +} + +export async function readSshLegacyGcProtection(input: { + rpc: SshLegacyMigrationRpc + signal: AbortSignal +}): Promise { + const value = await input.rpc.request( + GC_PROTECTION_METHOD, + { version: 1 }, + { signal: input.signal } + ) + if (!isRecord(value) || !nonNegativeInteger(value.catalogRevision)) { + throw new Error('legacy GC protection response is invalid') + } + assertProtection(value.protection) + return Number(value.catalogRevision) +} + +export async function commitSshLegacyMigrationBarrier(input: { + rpc: SshLegacyMigrationRpc + barrierId: string + catalogRevision: number + signal: AbortSignal +}): Promise { + const value = await input.rpc.request( + MIGRATION_BARRIER_METHOD, + { + version: 1, + barrierId: input.barrierId, + expectedCatalogRevision: input.catalogRevision + }, + { signal: input.signal } + ) + if ( + !isRecord(value) || + value.version !== 1 || + value.barrierId !== input.barrierId || + value.catalogRevision !== input.catalogRevision || + !nonNegativeInteger(value.committedAtMs) + ) { + throw new Error('legacy migration barrier response is invalid') + } +} + +export async function collectSshLegacyGc(input: { + rpc: SshLegacyMigrationRpc + barrierId: string + signal: AbortSignal +}): Promise { + const value = await input.rpc.request( + GC_METHOD, + { version: 1, barrierId: input.barrierId }, + { signal: input.signal } + ) + if (!isRecord(value) || !Array.isArray(value.removed) || value.removed.length > 256) { + throw new Error('legacy GC response is invalid') + } + value.removed.forEach((path) => assertAuthorityStoragePath(path, 'legacy GC removed path')) + assertProtection(value.protected) + return Object.freeze([...new Set(value.removed as string[])].sort()) +} + +function operationParams( + worker: LegacyPhysicalWorkerDescriptor, + operation: SshLegacyWorkerMigrationOperation +): Record { + return { + version: 1, + worker, + operationId: operation.operationId, + inspectionToken: operation.inspectionToken, + evidenceDigest: operation.evidenceDigest, + catalog: operation.catalog + } +} + +function parseInspection( + value: unknown, + worker: LegacyPhysicalWorkerDescriptor +): SshLegacyPhysicalWorkerInspection { + if ( + !isRecord(value) || + value.protocolVersion !== 1 || + value.workerId !== worker.workerId || + value.routeId !== worker.routeId || + value.buildId !== worker.buildId || + !isRecord(value.preparation) || + value.preparation.mode !== 'observational' || + value.preparation.catalogValidation !== 'before-isolation' || + value.preparation.replay !== 'durable-operation-id' || + !isRecord(value.identityProof) || + !Array.isArray(value.ptys) || + value.ptys.length > SSH_LEGACY_MIGRATION_INVENTORY_CAPACITY.rowsPerRelay + ) { + throw new Error('legacy physical worker inspection is not migration-safe') + } + assertAuthorityId(value.preparation.token, 'legacy inspection token') + assertAuthorityId(value.preparation.evidenceDigest, 'legacy inspection evidence digest') + for (const pty of value.ptys) { + assertPreparedPty(pty) + } + return Object.freeze(structuredClone(value)) as SshLegacyPhysicalWorkerInspection +} + +function assertPreparedPty(value: unknown): void { + if (!isRecord(value) || !isRecord(value.serialized)) { + throw new Error('legacy prepared PTY evidence is invalid') + } + for (const field of ['id', 'incarnationId', 'cwd', 'title'] as const) { + if (typeof value[field] !== 'string') { + throw new Error('legacy prepared PTY identity is invalid') + } + } + if (value.processId !== null && !positiveInteger(value.processId)) { + throw new Error('legacy prepared PTY process is invalid') + } + const serialized = value.serialized + for (const field of ['paneKey', 'tabId', 'worktreeId', 'cwd', 'ptyIncarnationId'] as const) { + if (serialized[field] !== null && typeof serialized[field] !== 'string') { + throw new Error('legacy prepared PTY serialized evidence is invalid') + } + } + if (serialized.processId !== null && !positiveInteger(serialized.processId)) { + throw new Error('legacy prepared PTY serialized process is invalid') + } +} + +function parseCommit( + value: unknown, + worker: LegacyPhysicalWorkerDescriptor, + operation: SshLegacyWorkerMigrationOperation +): SshLegacyWorkerMigrationCommit { + if ( + !isRecord(value) || + value.operationId !== operation.operationId || + value.inspectionToken !== operation.inspectionToken || + typeof value.duplicate !== 'boolean' || + !isRecord(value.receipt) + ) { + throw new Error('legacy migration commit response is invalid') + } + const receipt = value.receipt as TerminalLegacyMigrationReceipt + assertTerminalLegacyMigrationReceipt(receipt) + const request = receipt.request + if ( + request.mode !== 'cutover' || + request.migrationId !== operation.catalog.migrationId || + request.authorityHostId !== operation.catalog.authorityHostId || + request.requestedAtMs !== operation.catalog.requestedAtMs || + request.workerRoute.workerId !== worker.workerId || + request.workerRoute.routeId !== worker.routeId || + request.workerRoute.buildId !== worker.buildId || + !isDeepStrictEqual(request.imports, operation.catalog.imports) || + !sameRecoveryIds(request.unresolved, operation.catalog.unresolved) + ) { + throw new Error('legacy migration receipt does not match the prepared operation') + } + return Object.freeze({ receipt: structuredClone(receipt), duplicate: value.duplicate }) +} + +function sameRecoveryIds( + committed: readonly TerminalLegacyUnresolvedCandidate[], + planned: readonly TerminalLegacyUnresolvedCandidate[] +): boolean { + const ids = (values: readonly TerminalLegacyUnresolvedCandidate[]) => + values.map((candidate) => candidate.recoveryId).sort() + return isDeepStrictEqual(ids(committed), ids(planned)) +} + +function assertProtection(value: unknown): void { + if ( + !isRecord(value) || + !Array.isArray(value.relayDirectories) || + !Array.isArray(value.evidencePaths) + ) { + throw new Error('legacy GC protection is invalid') + } + if (value.relayDirectories.length > 256 || value.evidencePaths.length > 1_024) { + throw new Error('legacy GC protection exceeds capacity') + } + ;[...value.relayDirectories, ...value.evidencePaths].forEach((path) => + assertAuthorityStoragePath(path, 'legacy GC protection path') + ) +} + +function positiveInteger(value: unknown): value is number { + return Number.isSafeInteger(value) && Number(value) > 0 +} + +function nonNegativeInteger(value: unknown): boolean { + return Number.isSafeInteger(value) && Number(value) >= 0 +} diff --git a/src/main/ssh/ssh-legacy-migration-store-evidence.ts b/src/main/ssh/ssh-legacy-migration-store-evidence.ts new file mode 100644 index 00000000000..1deaca3d899 --- /dev/null +++ b/src/main/ssh/ssh-legacy-migration-store-evidence.ts @@ -0,0 +1,198 @@ +import type { FolderWorkspace } from '../../shared/types' +import type { SshPtyConsumerRecovery, SshRemotePtyLease } from '../../shared/ssh-types' +import { + SSH_LEGACY_MIGRATION_EVIDENCE_CAPACITY, + assertSshLegacyArrayCapacity, + failSshLegacyMigrationEvidence +} from './ssh-legacy-migration-evidence-capacity' +import type { + SshLegacyDiscoveredRelayEvidence, + SshLegacyDiscoveredRelayInventoryRow, + SshLegacyPersistedWorkspacePartition, + SshLegacyWorkerRecoveryAssociation, + SshLegacyWorkspaceReference +} from './ssh-legacy-migration-evidence-bridge-types' +import type { + SshLegacyInspectedWorker, + SshLegacyPreparedPhysicalPty +} from './ssh-legacy-migration-coordinator-types' +import { sshLegacyClientWorkspaceReference } from './ssh-legacy-migration-workspace-reference' + +/** The narrow persisted surface legacy migration reads; it never writes through this seam. */ +export type SshLegacyMigrationEvidenceStore = Readonly<{ + getSshRemotePtyLeases: (targetId?: string) => SshRemotePtyLease[] + getSshPtyConsumerRecovery: (targetId: string) => SshPtyConsumerRecovery | null + getWorkspaceSessionHostIds: () => readonly string[] + getWorkspaceSession: (hostId?: string | null) => Readonly<{ + tabsByWorktree?: unknown + terminalLayoutsByTabId?: unknown + }> + getFolderWorkspaces: () => readonly FolderWorkspace[] +}> + +export function sshLegacyFolderPathById( + store: SshLegacyMigrationEvidenceStore +): ReadonlyMap { + const folders = new Map() + const workspaces = store.getFolderWorkspaces() + assertSshLegacyArrayCapacity( + workspaces, + SSH_LEGACY_MIGRATION_EVIDENCE_CAPACITY.folderWorkspacesPerPartition, + 'folder workspaces per partition' + ) + for (const workspace of workspaces) { + if (typeof workspace.id === 'string' && typeof workspace.folderPath === 'string') { + folders.set(workspace.id, workspace.folderPath) + } + } + return folders +} + +export function sshLegacyPersistedWorkspacePartitions( + store: SshLegacyMigrationEvidenceStore, + targetId: string +): readonly SshLegacyPersistedWorkspacePartition[] { + const hostIds = store.getWorkspaceSessionHostIds() + assertSshLegacyArrayCapacity( + hostIds, + SSH_LEGACY_MIGRATION_EVIDENCE_CAPACITY.workspacePartitions, + 'workspace partitions' + ) + const folderWorkspaces = Object.freeze( + store + .getFolderWorkspaces() + .map((workspace) => Object.freeze({ id: workspace.id, folderPath: workspace.folderPath })) + ) + return Object.freeze( + hostIds.map((hostId) => { + const session = store.getWorkspaceSession(hostId) + return Object.freeze({ + targetId, + partitionId: hostId, + session: Object.freeze({ + tabsByWorktree: (session.tabsByWorktree ?? {}) as Record, + terminalLayoutsByTabId: (session.terminalLayoutsByTabId ?? {}) as Record + }), + folderWorkspaces + }) as SshLegacyPersistedWorkspacePartition + }) + ) +} + +export function sshLegacyWorkerRecoveryAssociations( + input: Readonly<{ + store: SshLegacyMigrationEvidenceStore + targetId: string + endpointId: string + workerId: string + buildId: string + }> +): readonly SshLegacyWorkerRecoveryAssociation[] { + const recovery = input.store.getSshPtyConsumerRecovery(input.targetId) + // Why filtered here: a recovery minted against another build is not evidence about this worker, + // and forwarding it would make the association ambiguous instead of absent. + if ( + !recovery || + recovery.targetId !== input.targetId || + recovery.serverBuildId !== input.buildId + ) { + return Object.freeze([]) + } + return Object.freeze([ + Object.freeze({ + targetId: input.targetId, + endpointId: input.endpointId, + workerId: input.workerId, + buildId: input.buildId, + recovery: Object.freeze({ ...recovery }) + }) + ]) +} + +export function sshLegacyDiscoveredRelayEvidence( + input: Readonly<{ + targetId: string + endpointId: string + observedAtMs: number + folderPathById: ReadonlyMap + referenceByPaneKey: ReadonlyMap + workers: readonly SshLegacyInspectedWorker[] + }> +): readonly SshLegacyDiscoveredRelayEvidence[] { + return Object.freeze( + input.workers.map((worker) => + Object.freeze({ + targetId: input.targetId, + endpointId: input.endpointId, + workerId: worker.inspection.workerId, + buildId: worker.inspection.buildId, + observedAtMs: input.observedAtMs, + identityProof: worker.inspection.identityProof, + rows: Object.freeze(worker.inspection.ptys.map((pty) => inventoryRow(pty, input))) + }) + ) + ) +} + +function inventoryRow( + pty: SshLegacyPreparedPhysicalPty, + input: Readonly<{ + folderPathById: ReadonlyMap + referenceByPaneKey: ReadonlyMap + }> +): SshLegacyDiscoveredRelayInventoryRow { + return Object.freeze({ + physicalPtyId: pty.id, + ptyIncarnationId: pty.incarnationId, + processId: pty.processId, + workspaceReference: inventoryWorkspaceReference(pty, input), + serialized: pty.serialized + }) +} + +/** + * The pane the row serialized is the only evidence that separates a folder workspace from a + * floating one, so it is preferred. A row that names no known pane still resolves to a plain host + * workspace: it matches nothing exactly and stays in recovery instead of failing the inventory. + */ +function inventoryWorkspaceReference( + pty: SshLegacyPreparedPhysicalPty, + input: Readonly<{ + folderPathById: ReadonlyMap + referenceByPaneKey: ReadonlyMap + }> +): SshLegacyWorkspaceReference { + const paneReference = pty.serialized.paneKey + ? input.referenceByPaneKey.get(pty.serialized.paneKey) + : undefined + if (paneReference) { + return paneReference + } + const clientWorkspaceId = pty.serialized.worktreeId ?? pty.worktreeId ?? null + const reference = + clientWorkspaceId === null + ? null + : sshLegacyClientWorkspaceReference({ + clientWorkspaceId, + folderPathById: input.folderPathById, + floatingWorkspacePath: pty.serialized.cwd ?? pty.cwd + }) + return reference ?? Object.freeze({ kind: 'workspace-path', path: pty.serialized.cwd ?? pty.cwd }) +} + +export function sshLegacyReferenceByPaneKey( + panes: readonly Readonly<{ + paneKey: string + workspaceReference: SshLegacyWorkspaceReference + }>[] +): ReadonlyMap { + const index = new Map() + for (const pane of panes) { + const existing = index.get(pane.paneKey) + if (existing && JSON.stringify(existing) !== JSON.stringify(pane.workspaceReference)) { + failSshLegacyMigrationEvidence('ambiguity', 'pane workspace reference') + } + index.set(pane.paneKey, pane.workspaceReference) + } + return index +} diff --git a/src/main/ssh/ssh-legacy-migration-workspace-cutover.test.ts b/src/main/ssh/ssh-legacy-migration-workspace-cutover.test.ts new file mode 100644 index 00000000000..730a2367be2 --- /dev/null +++ b/src/main/ssh/ssh-legacy-migration-workspace-cutover.test.ts @@ -0,0 +1,289 @@ +import { randomUUID } from 'node:crypto' +import { describe, expect, it, vi } from 'vitest' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants' +import { TERMINAL_LEGACY_CUTOVER_CAPABILITY } from '../../shared/terminal-legacy-cutover' +import { toAppSshPtyId } from '../../shared/ssh-pty-id' +import type { SshTerminalAuthorityMarker } from '../../shared/ssh-terminal-authority-marker' +import type { TerminalAuthorityPathFlavor } from '../../shared/terminal-session-authority-locator' +import type { SshConnection } from './ssh-connection' +import { coordinateSshLegacyMigration } from './ssh-legacy-migration-coordinator' +import type { + LegacyPhysicalWorkerDescriptor, + SshLegacyInspectedWorker, + SshLegacyPhysicalWorkerInspection +} from './ssh-legacy-migration-coordinator-types' +import { createSshLegacyMigrationEvidenceProvider } from './ssh-legacy-migration-evidence-provider' +import { sshLegacyEvidenceDigest } from './ssh-legacy-migration-evidence-identity' +import { FutureSshLegacyMigrationRpc } from './__tests__/ssh-legacy-migration-future-rpc' +import type { SshLegacyMigrationEvidenceStore } from './ssh-legacy-migration-store-evidence' +import type { RelayPlatform } from './relay-protocol' +import { getRemoteHostPlatform } from './ssh-remote-platform' + +const execCommand = vi.hoisted(() => vi.fn()) +vi.mock('./ssh-relay-exec-command', () => ({ execCommand })) + +execCommand.mockResolvedValue( + `ORCA_LEGACY_PRIOR_RELAY ${JSON.stringify({ + endpoint: { device: '2049', inode: '77', changedAtNs: '1700000000000000000' }, + liveness: 'alive' + })}` +) + +const TARGET_ID = 'target-a' +const HOST_ID = 'authority-host-a' +const PARTITION_ID = 'ssh:target-a' +const TAB_ID = 'tab-a' +const PHYSICAL_PTY_ID = 'pty-1' +const APP_PTY_ID = toAppSshPtyId(TARGET_ID, PHYSICAL_PTY_ID) +const PTY_INCARNATION_ID = 'incarnation-1' +const PROCESS_ID = 4_201 +const BUILD_ID = '0.1.0+abc' +const GENERATION = 3 + +type Scenario = Readonly<{ + label: string + flavor: TerminalAuthorityPathFlavor + platform: RelayPlatform + ownerKey: string + hostPath: string + worktreeId: string | null + folderWorkspaces: readonly Readonly<{ id: string; folderPath: string }>[] + remoteKey: string + startupCwd?: string +}> + +const SCENARIOS: readonly Scenario[] = [ + { + label: 'a POSIX git worktree', + flavor: 'posix', + platform: 'linux-x64', + ownerKey: 'repo-a::/srv/repos/repo-a', + hostPath: '/srv/repos/repo-a', + worktreeId: 'repo-a::/srv/repos/repo-a', + folderWorkspaces: [], + remoteKey: '/srv/repos/repo-a' + }, + { + label: 'a POSIX folder workspace', + flavor: 'posix', + platform: 'linux-x64', + ownerKey: 'folder:folder-a', + hostPath: '/srv/folders/a', + worktreeId: null, + folderWorkspaces: [{ id: 'folder-a', folderPath: '/srv/folders/a' }], + remoteKey: '/srv/folders/a' + }, + { + label: 'a floating workspace', + flavor: 'posix', + platform: 'linux-x64', + ownerKey: FLOATING_TERMINAL_WORKTREE_ID, + hostPath: '/srv/float', + worktreeId: null, + folderWorkspaces: [], + remoteKey: FLOATING_TERMINAL_WORKTREE_ID, + startupCwd: '/srv/float' + }, + { + label: 'a Windows git worktree', + flavor: 'windows', + platform: 'win32-x64', + ownerKey: 'repo-a::C:/repos/repo-a', + hostPath: 'C:/repos/repo-a', + worktreeId: 'repo-a::C:/repos/repo-a', + folderWorkspaces: [], + remoteKey: 'C:/repos/repo-a' + } +] + +function marker(scenario: Scenario): SshTerminalAuthorityMarker { + const windows = scenario.flavor === 'windows' + return { + markerVersion: 1, + authorityHostId: HOST_ID, + ownerInstanceId: 'owner-prior', + ownerPid: 9_001, + ownerProcessToken: 'prior-process-token', + ownerBuildId: BUILD_ID, + ownerRelayDir: windows + ? 'C:/Users/u/.orca-relay/relay-0.1.0+abc' + : '/home/u/.orca-relay/relay-0.1.0+abc', + socketPath: windows + ? '\\\\.\\pipe\\orca-relay-0123456789abcdef0123' + : '/home/u/.orca-relay/terminal-authority/authority.sock', + credentialFile: windows + ? 'C:/Users/u/.orca-relay/terminal-authority/endpoint.credential' + : '/home/u/.orca-relay/terminal-authority/endpoint.credential', + compatibility: { + major: 1, + minMinor: 0, + maxMinor: 0, + capabilities: [], + requiredCapabilities: [] + }, + revision: 6 + } +} + +function inspection( + scenario: Scenario, + descriptor: LegacyPhysicalWorkerDescriptor, + paneKey: string +): SshLegacyPhysicalWorkerInspection { + const evidence = { + protocolVersion: 1 as const, + workerId: descriptor.workerId, + routeId: descriptor.routeId, + buildId: descriptor.buildId, + identityProof: { + expectedEndpoint: descriptor.expectedEndpoint, + observedEndpoint: descriptor.expectedEndpoint, + expectedProcess: descriptor.process, + observedProcess: descriptor.process + }, + ptys: [ + { + id: PHYSICAL_PTY_ID, + incarnationId: PTY_INCARNATION_ID, + processId: PROCESS_ID, + cwd: scenario.hostPath, + title: 'shell', + serialized: { + paneKey, + tabId: TAB_ID, + worktreeId: scenario.worktreeId, + cwd: scenario.hostPath, + ptyIncarnationId: PTY_INCARNATION_ID, + processId: PROCESS_ID + } + } + ] + } + return Object.freeze({ + ...evidence, + preparation: { + mode: 'observational' as const, + token: 'token-a', + evidenceDigest: sshLegacyEvidenceDigest(evidence), + catalogValidation: 'before-isolation' as const, + replay: 'durable-operation-id' as const + } + }) as SshLegacyPhysicalWorkerInspection +} + +function store(scenario: Scenario, leafId: string): SshLegacyMigrationEvidenceStore { + const layout = { + root: { type: 'leaf', leafId }, + ptyIdsByLeafId: { [leafId]: APP_PTY_ID } + } + return { + getSshRemotePtyLeases: () => [ + { + targetId: TARGET_ID, + ptyId: APP_PTY_ID, + incarnationId: PTY_INCARNATION_ID, + ...(scenario.worktreeId ? { worktreeId: scenario.worktreeId } : {}), + tabId: TAB_ID, + leafId, + paneGeneration: GENERATION, + state: 'attached', + createdAt: 1, + updatedAt: 1 + } + ], + getSshPtyConsumerRecovery: () => ({ + targetId: TARGET_ID, + clientInstanceId: 'client-a', + serverBuildId: BUILD_ID, + clientGeneration: 1, + ownerGeneration: 1, + ownerLease: 'lease-a' + }), + getWorkspaceSessionHostIds: () => [PARTITION_ID], + getWorkspaceSession: () => ({ + tabsByWorktree: { + [scenario.ownerKey]: [ + { + id: TAB_ID, + ptyId: null, + generation: GENERATION, + ...(scenario.startupCwd ? { startupCwd: scenario.startupCwd } : {}) + } + ] + }, + terminalLayoutsByTabId: { [TAB_ID]: layout } + }), + getFolderWorkspaces: () => scenario.folderWorkspaces as never + } +} + +function remoteSnapshot(scenario: Scenario, leafId: string): unknown { + return { + session: { + tabsByWorktreePath: { + [scenario.remoteKey]: [ + { + id: TAB_ID, + ptyId: null, + worktreePath: scenario.remoteKey, + generation: GENERATION, + ...(scenario.startupCwd ? { startupCwd: scenario.startupCwd } : {}) + } + ] + }, + terminalLayoutsByTabId: { + [TAB_ID]: { root: { type: 'leaf', leafId }, ptyIdsByLeafId: { [leafId]: APP_PTY_ID } } + } + } + } +} + +describe('legacy cutover across workspace kinds and host path flavors', () => { + it.each(SCENARIOS)('imports one exact binding for $label', async (scenario) => { + const leafId = randomUUID() + const paneKey = `${TAB_ID}:${leafId}` + const provider = createSshLegacyMigrationEvidenceProvider({ + targetId: TARGET_ID, + partitionId: PARTITION_ID, + clientInstanceId: 'client-a', + hostPlatform: getRemoteHostPlatform(scenario.platform), + nodePath: '/usr/bin/node', + priorRelayStatus: { kind: 'superseded', marker: marker(scenario) }, + store: store(scenario, leafId), + connection: () => ({}) as SshConnection, + remoteWorkspaceSnapshot: () => remoteSnapshot(scenario, leafId), + isAttemptCurrent: () => true, + now: () => 1_700 + }) + const context = { + targetId: TARGET_ID, + authorityHostId: HOST_ID, + hostPathFlavor: scenario.flavor, + attemptId: 'attempt-a' + } + const signal = new AbortController().signal + const discovery = await provider.discoverWorkers({ ...context, signal }) + expect(discovery.kind).toBe('ready') + const workers: SshLegacyInspectedWorker[] = + discovery.kind === 'ready' + ? discovery.workers.map((descriptor) => ({ + descriptor, + inspection: inspection(scenario, descriptor, paneKey) + })) + : [] + const outcome = await coordinateSshLegacyMigration({ + ...context, + authorityCapabilities: [TERMINAL_LEGACY_CUTOVER_CAPABILITY], + signal, + isAttemptCurrent: () => true, + rpc: new FutureSshLegacyMigrationRpc(workers), + evidenceProvider: provider + }) + expect(outcome.kind).toBe('committed') + expect(outcome.kind === 'committed' && outcome.summary).toMatchObject({ + importCount: 1, + unresolvedCount: 0, + relayCount: 1 + }) + }) +}) diff --git a/src/main/ssh/ssh-legacy-migration-workspace-reference.ts b/src/main/ssh/ssh-legacy-migration-workspace-reference.ts new file mode 100644 index 00000000000..5cfe1e925e6 --- /dev/null +++ b/src/main/ssh/ssh-legacy-migration-workspace-reference.ts @@ -0,0 +1,54 @@ +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants' +import { assertAuthorityStoragePath } from '../../shared/terminal-session-authority-identity' +import { parseWorkspaceKey } from '../../shared/workspace-scope' +import { splitWorktreeIdForFilesystem } from '../../shared/worktree-id' +import { failSshLegacyMigrationEvidence } from './ssh-legacy-migration-evidence-capacity' +import type { SshLegacyWorkspaceReference } from './ssh-legacy-migration-evidence-bridge-types' + +/** + * One reading of a legacy client workspace id, shared by persisted layout evidence and live relay + * inventory rows so both name the same workspace for the same id. + */ +export function sshLegacyClientWorkspaceReference( + input: Readonly<{ + clientWorkspaceId: string + folderPathById: ReadonlyMap + floatingWorkspacePath?: string | null + }> +): SshLegacyWorkspaceReference | null { + if (input.clientWorkspaceId === FLOATING_TERMINAL_WORKTREE_ID) { + return Object.freeze({ + kind: 'floating', + clientWorkspaceId: input.clientWorkspaceId, + path: input.floatingWorkspacePath ?? null + }) + } + const scope = parseWorkspaceKey(input.clientWorkspaceId) + if (scope?.type === 'folder') { + const path = input.folderPathById.get(scope.folderWorkspaceId) + return path + ? Object.freeze({ + kind: 'folder-workspace', + clientWorkspaceId: input.clientWorkspaceId, + path + }) + : null + } + const clientWorkspaceId = scope?.type === 'worktree' ? scope.worktreeId : input.clientWorkspaceId + const parsed = splitWorktreeIdForFilesystem(clientWorkspaceId) + if (!parsed?.worktreePath) { + return null + } + assertAuthorityStoragePath(parsed.worktreePath, 'SSH legacy git worktree path') + return Object.freeze({ kind: 'git-worktree', clientWorkspaceId, path: parsed.worktreePath }) +} + +export function sshLegacyRequiredWorkspaceReference( + input: Parameters[0] +): SshLegacyWorkspaceReference { + const reference = sshLegacyClientWorkspaceReference(input) + if (reference === null) { + failSshLegacyMigrationEvidence('malformed', 'persisted workspace owner') + } + return reference +} diff --git a/src/main/ssh/ssh-pty-consumer-session-grant-fixture.ts b/src/main/ssh/ssh-pty-consumer-session-grant-fixture.ts new file mode 100644 index 00000000000..3f602c09de1 --- /dev/null +++ b/src/main/ssh/ssh-pty-consumer-session-grant-fixture.ts @@ -0,0 +1,102 @@ +// Why: specs that drive the real consumer session over a fake relay must answer pty.openClient the +// way a current relay does — echoing exactly the offered capabilities, no more and no less. +import { PTY_EXACT_OPERATION_PROTOCOL_VERSION } from '../../shared/pty-exact-operation-protocol' +import { TERMINAL_AUTHORITY_EXACT_OPERATIONS_VERSION } from '../../shared/terminal-authority-exact-operation-protocol' +import { TERMINAL_AUTHORITY_OUTCOME_DELIVERY_VERSION } from '../../shared/terminal-authority-outcome-delivery' +import { TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_VERSION } from '../../shared/terminal-session-authority-consumer-transport' +import { TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION } from '../../shared/terminal-session-authority-consumer-proof' +import { TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_VERSION } from '../../shared/terminal-session-authority-consumer-retirement' +import { + TERMINAL_AUTHORITY_TOPOLOGY_PTY_CAPABILITY, + TERMINAL_AUTHORITY_TOPOLOGY_STREAM_VERSION +} from '../../shared/terminal-authority-topology-stream-contract' + +type Offer = Record | undefined + +function offered(capabilities: Offer, name: string): Record | undefined { + const value = capabilities?.[name] + return typeof value === 'object' && value !== null + ? (value as Record) + : undefined +} + +/** + * Build the grant a current relay returns for `params`. `authorityHostId` must match the deploy + * result the session admitted, and `withheld` models an owner that never negotiated a capability. + */ +export function sshPtyOpenClientGrant( + params: Record, + options: Readonly<{ + serverBuildId: string + authorityHostId: string + withheld?: readonly string[] + }> +): Record { + const capabilities = offered(params, 'capabilities') + const resume = offered(params, 'resume') + const withheld = new Set(options.withheld ?? []) + const grantFor = (name: string): Record | undefined => + withheld.has(name) ? undefined : offered(capabilities, name) + + const outputFlowControl = grantFor('outputFlowControl') + const namespaceOutcomes = grantFor('terminalAuthorityNamespaceOutcomes') + const consumerProof = grantFor('terminalAuthorityConsumerProof') + const granted = { + ...(outputFlowControl + ? { + outputFlowControl: { version: 1, windowSu: Number(outputFlowControl.requestedWindowSu) } + } + : {}), + ...(grantFor('exactOperations') + ? { exactOperations: { version: PTY_EXACT_OPERATION_PROTOCOL_VERSION } } + : {}), + ...(grantFor('heldProducerPause') ? { heldProducerPause: { version: 1 } } : {}), + ...(grantFor('terminalAuthorityExactOperations') + ? { + terminalAuthorityExactOperations: { version: TERMINAL_AUTHORITY_EXACT_OPERATIONS_VERSION } + } + : {}), + ...(grantFor('terminalAuthorityOutcomeDelivery') + ? { + terminalAuthorityOutcomeDelivery: { version: TERMINAL_AUTHORITY_OUTCOME_DELIVERY_VERSION } + } + : {}), + ...(namespaceOutcomes + ? { + terminalAuthorityNamespaceOutcomes: { + version: TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_VERSION, + consumer: namespaceOutcomes.consumer + } + } + : {}), + ...(consumerProof + ? { + terminalAuthorityConsumerProof: { + version: TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION, + authorityHostId: options.authorityHostId, + ...(consumerProof.retirementVersions + ? { retirementVersion: TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_VERSION } + : {}) + } + } + : {}), + ...(grantFor(TERMINAL_AUTHORITY_TOPOLOGY_PTY_CAPABILITY) + ? { + [TERMINAL_AUTHORITY_TOPOLOGY_PTY_CAPABILITY]: { + version: TERMINAL_AUTHORITY_TOPOLOGY_STREAM_VERSION + } + } + : {}) + } + + return { + protocolVersion: Number(params.protocolVersion), + serverBuildId: options.serverBuildId, + clientGeneration: 1, + role: 'session-owner', + ownerGeneration: resume ? Number(resume.ownerGeneration) + 1 : 1, + ownerLease: 'test-owner-lease', + resumed: resume !== undefined, + ...(Object.keys(granted).length > 0 ? { capabilities: granted } : {}) + } +} diff --git a/src/main/ssh/ssh-pty-consumer-session-grant-validation.ts b/src/main/ssh/ssh-pty-consumer-session-grant-validation.ts new file mode 100644 index 00000000000..2ef33755b0a --- /dev/null +++ b/src/main/ssh/ssh-pty-consumer-session-grant-validation.ts @@ -0,0 +1,148 @@ +import { + PTY_CONSUMER_SESSION_PROTOCOL_VERSION, + type PtyConsumerSessionGrant +} from '../../shared/pty-consumer-session' +import { PTY_EXACT_OPERATION_PROTOCOL_VERSION } from '../../shared/pty-exact-operation-protocol' +import { TERMINAL_AUTHORITY_EXACT_OPERATIONS_VERSION } from '../../shared/terminal-authority-exact-operation-protocol' +import { TERMINAL_AUTHORITY_OUTCOME_DELIVERY_VERSION } from '../../shared/terminal-authority-outcome-delivery' +import { + TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_VERSION, + sameTerminalAuthorityPolicyConsumer +} from '../../shared/terminal-session-authority-consumer-transport' +import { TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION } from '../../shared/terminal-session-authority-consumer-proof' +import { TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_VERSION } from '../../shared/terminal-session-authority-consumer-retirement' +import { assertAuthorityId } from '../../shared/terminal-session-authority-identity' +import { terminalAuthorityTopologyGrantFromPtyCapabilities } from '../../shared/terminal-authority-topology-stream-contract' +import type { OpenSshPtyConsumerSessionOptions } from './ssh-pty-consumer-session-types' + +export function validateSshPtyConsumerSessionGrant( + value: unknown, + options: OpenSshPtyConsumerSessionOptions +): PtyConsumerSessionGrant { + if (typeof value !== 'object' || value === null) { + throw new Error('Remote relay returned an invalid pty.openClient grant') + } + if (!options.expectedServerBuildId) { + throw new Error('Local relay build identity is unavailable') + } + const grant = value as Partial + if ( + grant.protocolVersion !== PTY_CONSUMER_SESSION_PROTOCOL_VERSION || + grant.serverBuildId !== options.expectedServerBuildId + ) { + throw new Error( + `Remote relay session contract mismatch — expected build ${options.expectedServerBuildId}, got ${grant.serverBuildId ?? 'unknown'}` + ) + } + if ( + !Number.isSafeInteger(grant.clientGeneration) || + grant.clientGeneration! <= 0 || + grant.role !== 'session-owner' || + !Number.isSafeInteger(grant.ownerGeneration) || + grant.ownerGeneration! <= 0 || + typeof grant.ownerLease !== 'string' || + grant.ownerLease.length === 0 || + grant.ownerLease.length > 512 + ) { + throw new Error('Remote relay did not grant an authenticated PTY session owner') + } + // Client and relay share a build; a missing `resumed` is corruption, not an older peer. + if (typeof grant.resumed !== 'boolean') { + throw new Error('Remote relay owner grant did not state whether the claim was resumed') + } + const requestedFlow = options.outputFlowControl + const grantedFlow = grant.capabilities?.outputFlowControl + if (requestedFlow) { + if ( + grantedFlow?.version !== 1 || + !Number.isSafeInteger(grantedFlow.windowSu) || + grantedFlow.windowSu <= 0 || + grantedFlow.windowSu > requestedFlow.requestedWindowSu + ) { + throw new Error('Remote relay did not grant the offered PTY output-flow-control capability') + } + } else if (grantedFlow) { + throw new Error('Remote relay granted an unoffered PTY output-flow-control capability') + } + const grantedExact = grant.capabilities?.exactOperations + if (options.exactOperations) { + if (grantedExact?.version !== PTY_EXACT_OPERATION_PROTOCOL_VERSION) { + throw new Error('Remote relay did not grant the offered PTY exact-operation capability') + } + } else if (grantedExact) { + throw new Error('Remote relay granted an invalid or unoffered PTY exact-operation capability') + } + const grantedHeldPause = grant.capabilities?.heldProducerPause + if (grantedHeldPause && (!options.heldProducerPause || grantedHeldPause.version !== 1)) { + throw new Error('Remote relay granted an invalid or unoffered held-producer-pause capability') + } + const grantedAuthorityExact = grant.capabilities?.terminalAuthorityExactOperations + if (options.terminalAuthorityExactOperations) { + if (grantedAuthorityExact?.version !== TERMINAL_AUTHORITY_EXACT_OPERATIONS_VERSION) { + throw new Error('Remote relay did not grant terminal authority exact operations') + } + } else if (grantedAuthorityExact) { + throw new Error('Remote relay granted unoffered terminal authority exact operations') + } + const grantedAuthorityOutcome = grant.capabilities?.terminalAuthorityOutcomeDelivery + if (options.terminalAuthorityOutcomeDelivery) { + if (grantedAuthorityOutcome?.version !== TERMINAL_AUTHORITY_OUTCOME_DELIVERY_VERSION) { + throw new Error('Remote relay did not grant terminal authority outcome delivery') + } + } else if (grantedAuthorityOutcome) { + throw new Error('Remote relay granted unoffered terminal authority outcome delivery') + } + const namespaceOutcomeClaim = options.terminalAuthorityNamespaceOutcomeClaim + const grantedNamespaceOutcomes = grant.capabilities?.terminalAuthorityNamespaceOutcomes + if (namespaceOutcomeClaim) { + if ( + grantedNamespaceOutcomes?.version !== TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_VERSION || + !sameTerminalAuthorityPolicyConsumer( + grantedNamespaceOutcomes.consumer, + namespaceOutcomeClaim.consumer + ) + ) { + throw new Error('Remote relay did not grant terminal authority namespace outcomes') + } + } else if (grantedNamespaceOutcomes) { + throw new Error('Remote relay granted unoffered terminal authority namespace outcomes') + } + const grantedConsumerProof = grant.capabilities?.terminalAuthorityConsumerProof + if (grantedConsumerProof) { + if (!options.terminalAuthorityConsumerProof) { + throw new Error('Remote relay granted unoffered terminal authority consumer proof') + } + try { + assertAuthorityId(grantedConsumerProof.authorityHostId, 'remote authorityHostId') + } catch { + throw new Error('Remote relay granted invalid terminal authority consumer proof') + } + if (grantedConsumerProof.version !== TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION) { + throw new Error('Remote relay granted invalid terminal authority consumer proof') + } + if ( + options.requiredTerminalAuthorityConsumerProofHostId && + grantedConsumerProof.authorityHostId !== options.requiredTerminalAuthorityConsumerProofHostId + ) { + throw new Error('Remote relay granted terminal authority proof for another host') + } + } else if (options.requiredTerminalAuthorityConsumerProofHostId) { + throw new Error('Remote relay did not grant mandatory terminal authority consumer proof') + } + if (options.terminalAuthorityConsumerRetirement) { + if ( + !options.terminalAuthorityConsumerProof || + grantedConsumerProof?.retirementVersion !== TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_VERSION + ) { + throw new Error('Remote relay did not grant terminal authority consumer retirement') + } + } else if (grantedConsumerProof?.retirementVersion !== undefined) { + throw new Error('Remote relay granted unoffered terminal authority consumer retirement') + } + const rawTopology = grant.capabilities?.terminalAuthorityTopology + const grantedTopology = terminalAuthorityTopologyGrantFromPtyCapabilities(grant.capabilities) + if (rawTopology !== undefined && (!options.terminalAuthorityTopology || !grantedTopology)) { + throw new Error('Remote relay granted an invalid or unoffered terminal topology capability') + } + return grant as PtyConsumerSessionGrant +} diff --git a/src/main/ssh/ssh-pty-consumer-session-types.ts b/src/main/ssh/ssh-pty-consumer-session-types.ts new file mode 100644 index 00000000000..0146776786e --- /dev/null +++ b/src/main/ssh/ssh-pty-consumer-session-types.ts @@ -0,0 +1,72 @@ +import type { TerminalAuthorityPolicyConsumerClaim } from '../../shared/terminal-session-authority-consumer-transport' +import type { TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION } from '../../shared/terminal-session-authority-consumer-proof' + +export type SshPtyConsumerOwnerState = { + mode: 'negotiated' + clientInstanceId: string + clientGeneration: number + ownerGeneration: number + ownerLease: string + outputFlowControl?: { + version: 1 + windowSu: number + } + exactOperations?: { + version: 1 + } + heldProducerPause?: { + version: 1 + } + terminalAuthorityExactOperations?: { + version: 1 + } + terminalAuthorityOutcomeDelivery?: { + version: 1 + } + terminalAuthorityNamespaceOutcomes?: { + version: 1 + consumer: TerminalAuthorityPolicyConsumerClaim['consumer'] + } + terminalAuthorityConsumerProof?: { + version: typeof TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION + authorityHostId: string + retirementVersion?: 1 + } + terminalAuthorityTopology?: { + version: 1 + } +} + +export type SshPtyLegacyFallbackState = { + mode: 'legacy-fallback' + clientInstanceId: string + serverBuildId: string +} + +export type SshPtyConsumerSessionState = SshPtyConsumerOwnerState | SshPtyLegacyFallbackState + +export type SshPtyConsumerAdmission = { + state: SshPtyConsumerSessionState + // Why not on the owner state itself: this describes one admission's outcome, not the persisted + // claim, and it must never round-trip through the recovery record. + resumed: boolean +} + +export type OpenSshPtyConsumerSessionOptions = { + clientInstanceId: string + expectedServerBuildId: string | undefined + resume?: Pick + outputFlowControl?: { + requestedWindowSu: number + } + exactOperations?: true + heldProducerPause?: true + terminalAuthorityExactOperations?: true + terminalAuthorityOutcomeDelivery?: true + terminalAuthorityNamespaceOutcomeClaim?: TerminalAuthorityPolicyConsumerClaim + terminalAuthorityConsumerProof?: true + terminalAuthorityConsumerRetirement?: true + requiredTerminalAuthorityConsumerProofHostId?: string + terminalAuthorityTopology?: true + allowSameBuildLegacyFallback?: boolean +} diff --git a/src/main/ssh/ssh-pty-consumer-session.test.ts b/src/main/ssh/ssh-pty-consumer-session.test.ts index a1fc612f1dd..e22cee5cb85 100644 --- a/src/main/ssh/ssh-pty-consumer-session.test.ts +++ b/src/main/ssh/ssh-pty-consumer-session.test.ts @@ -139,6 +139,184 @@ describe('openSshPtyConsumerSession', () => { ).rejects.toThrow('unoffered') }) + it('negotiates exact operations inside the existing openClient round trip', async () => { + const { mux, request } = muxReturning( + legacyOwnerGrant({ capabilities: { exactOperations: { version: 1 } } }) + ) + + await expect( + openSshPtyConsumerSession(mux, { + clientInstanceId: 'client-a', + expectedServerBuildId: 'build-a', + exactOperations: true + }) + ).resolves.toMatchObject({ + state: { mode: 'negotiated', exactOperations: { version: 1 } } + }) + expect(request).toHaveBeenCalledOnce() + expect(request.mock.calls[0][1]).toMatchObject({ + capabilities: { exactOperations: { versions: [1] } } + }) + }) + + it('offers optional terminal topology without requiring an old gateway to grant it', async () => { + const { mux, request } = muxReturning(legacyOwnerGrant()) + + await expect( + openSshPtyConsumerSession(mux, { + clientInstanceId: 'client-a', + expectedServerBuildId: 'build-a', + terminalAuthorityTopology: true + }) + ).resolves.not.toHaveProperty('state.terminalAuthorityTopology') + expect(request.mock.calls[0][1]).toMatchObject({ + capabilities: { terminalAuthorityTopology: { versions: [1] } } + }) + }) + + it('offers optional authority proof without sending proof RPCs to an older relay', async () => { + const { mux, request } = muxReturning(legacyOwnerGrant()) + + await expect( + openSshPtyConsumerSession(mux, { + clientInstanceId: 'client-a', + expectedServerBuildId: 'build-a', + terminalAuthorityConsumerProof: true + }) + ).resolves.not.toHaveProperty('state.terminalAuthorityConsumerProof') + expect(request.mock.calls[0][1]).toMatchObject({ + capabilities: { terminalAuthorityConsumerProof: { versions: [1] } } + }) + expect(request).toHaveBeenCalledOnce() + }) + + it('accepts only an offered authority proof grant with an authenticated host id', async () => { + const proofGrant = legacyOwnerGrant({ + capabilities: { + terminalAuthorityConsumerProof: { + version: 1, + authorityHostId: 'authority-host:ssh-test' + } + } + }) + const { mux } = muxReturning(proofGrant) + + await expect( + openSshPtyConsumerSession(mux, { + clientInstanceId: 'client-a', + expectedServerBuildId: 'build-a', + terminalAuthorityConsumerProof: true + }) + ).resolves.toMatchObject({ + state: { + terminalAuthorityConsumerProof: { + version: 1, + authorityHostId: 'authority-host:ssh-test' + } + } + }) + + const { mux: unoffered } = muxReturning(proofGrant) + await expect( + openSshPtyConsumerSession(unoffered, { + clientInstanceId: 'client-a', + expectedServerBuildId: 'build-a' + }) + ).rejects.toThrow('unoffered terminal authority consumer proof') + }) + + it('requires the exact proof grant for an authenticated authority endpoint', async () => { + const { mux: omitted } = muxReturning(legacyOwnerGrant()) + await expect( + openSshPtyConsumerSession(omitted, { + clientInstanceId: 'client-a', + expectedServerBuildId: 'build-a', + terminalAuthorityConsumerProof: true, + requiredTerminalAuthorityConsumerProofHostId: 'authority-host:ssh-test' + }) + ).rejects.toThrow('mandatory terminal authority consumer proof') + + const { mux: wrongHost } = muxReturning( + legacyOwnerGrant({ + capabilities: { + terminalAuthorityConsumerProof: { + version: 1, + authorityHostId: 'authority-host:other' + } + } + }) + ) + await expect( + openSshPtyConsumerSession(wrongHost, { + clientInstanceId: 'client-a', + expectedServerBuildId: 'build-a', + terminalAuthorityConsumerProof: true, + requiredTerminalAuthorityConsumerProofHostId: 'authority-host:ssh-test' + }) + ).rejects.toThrow('another host') + }) + + it('accepts only an offered terminal topology grant', async () => { + const topologyGrant = legacyOwnerGrant({ + capabilities: { terminalAuthorityTopology: { version: 1 } } + }) + const { mux } = muxReturning(topologyGrant) + + await expect( + openSshPtyConsumerSession(mux, { + clientInstanceId: 'client-a', + expectedServerBuildId: 'build-a', + terminalAuthorityTopology: true + }) + ).resolves.toMatchObject({ + state: { terminalAuthorityTopology: { version: 1 } } + }) + + const { mux: unoffered } = muxReturning(topologyGrant) + await expect( + openSshPtyConsumerSession(unoffered, { + clientInstanceId: 'client-a', + expectedServerBuildId: 'build-a' + }) + ).rejects.toThrow('invalid or unoffered terminal topology') + }) + + it('does not silently downgrade when a current relay omits the requested exact grant', async () => { + const { mux } = muxReturning(legacyOwnerGrant()) + + await expect( + openSshPtyConsumerSession(mux, { + clientInstanceId: 'client-a', + expectedServerBuildId: 'build-a', + exactOperations: true + }) + ).rejects.toThrow('did not grant the offered PTY exact-operation capability') + }) + + it('rejects an invalid or unoffered exact-operation grant', async () => { + const { mux } = muxReturning( + legacyOwnerGrant({ capabilities: { exactOperations: { version: 2 } } }) + ) + + await expect( + openSshPtyConsumerSession(mux, { + clientInstanceId: 'client-a', + expectedServerBuildId: 'build-a', + exactOperations: true + }) + ).rejects.toThrow('did not grant the offered') + + const { mux: unofferedMux } = muxReturning( + legacyOwnerGrant({ capabilities: { exactOperations: { version: 1 } } }) + ) + await expect( + openSshPtyConsumerSession(unofferedMux, { + clientInstanceId: 'client-a', + expectedServerBuildId: 'build-a' + }) + ).rejects.toThrow('invalid or unoffered') + }) + it('uses explicit token-free fallback only for same-build method-not-found', async () => { const error = Object.assign(new Error('Method not found: pty.openClient'), { code: -32601 }) const request = vi.fn().mockRejectedValue(error) @@ -149,7 +327,8 @@ describe('openSshPtyConsumerSession', () => { clientInstanceId: 'client-a', expectedServerBuildId: 'build-a', allowSameBuildLegacyFallback: true, - outputFlowControl: { requestedWindowSu: 64 } + outputFlowControl: { requestedWindowSu: 64 }, + exactOperations: true }) ).resolves.toEqual({ state: { @@ -159,6 +338,16 @@ describe('openSshPtyConsumerSession', () => { }, resumed: false }) + + await expect( + openSshPtyConsumerSession(mux, { + clientInstanceId: 'client-a', + expectedServerBuildId: 'build-a', + allowSameBuildLegacyFallback: true, + terminalAuthorityConsumerProof: true, + requiredTerminalAuthorityConsumerProofHostId: 'authority-host:ssh-test' + }) + ).rejects.toBe(error) }) it.each([ diff --git a/src/main/ssh/ssh-pty-consumer-session.ts b/src/main/ssh/ssh-pty-consumer-session.ts index aa58534909b..4a1c36c29b6 100644 --- a/src/main/ssh/ssh-pty-consumer-session.ts +++ b/src/main/ssh/ssh-pty-consumer-session.ts @@ -1,102 +1,32 @@ +import { PTY_CONSUMER_SESSION_PROTOCOL_VERSION } from '../../shared/pty-consumer-session' +import { PTY_EXACT_OPERATION_PROTOCOL_VERSION } from '../../shared/pty-exact-operation-protocol' +import { TERMINAL_AUTHORITY_EXACT_OPERATIONS_VERSION } from '../../shared/terminal-authority-exact-operation-protocol' +import { TERMINAL_AUTHORITY_OUTCOME_DELIVERY_VERSION } from '../../shared/terminal-authority-outcome-delivery' +import { TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_VERSION } from '../../shared/terminal-session-authority-consumer-transport' +import { TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION } from '../../shared/terminal-session-authority-consumer-proof' +import { TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_VERSION } from '../../shared/terminal-session-authority-consumer-retirement' import { - PTY_CONSUMER_SESSION_PROTOCOL_VERSION, - type PtyConsumerSessionGrant -} from '../../shared/pty-consumer-session' + TERMINAL_AUTHORITY_TOPOLOGY_STREAM_VERSION, + terminalAuthorityTopologyGrantFromPtyCapabilities +} from '../../shared/terminal-authority-topology-stream-contract' import type { SshChannelMultiplexer } from './ssh-channel-multiplexer' +import { validateSshPtyConsumerSessionGrant } from './ssh-pty-consumer-session-grant-validation' +import type { + OpenSshPtyConsumerSessionOptions, + SshPtyConsumerAdmission +} from './ssh-pty-consumer-session-types' + +export type { + OpenSshPtyConsumerSessionOptions, + SshPtyConsumerAdmission, + SshPtyConsumerOwnerState, + SshPtyConsumerSessionState, + SshPtyLegacyFallbackState +} from './ssh-pty-consumer-session-types' export const SSH_PTY_OPEN_CLIENT_METHOD = 'pty.openClient' export const SSH_PTY_OPEN_CLIENT_TIMEOUT_MS = 10_000 -export type SshPtyConsumerOwnerState = { - mode: 'negotiated' - clientInstanceId: string - clientGeneration: number - ownerGeneration: number - ownerLease: string - outputFlowControl?: { - version: 1 - windowSu: number - } -} - -export type SshPtyLegacyFallbackState = { - mode: 'legacy-fallback' - clientInstanceId: string - serverBuildId: string -} - -export type SshPtyConsumerSessionState = SshPtyConsumerOwnerState | SshPtyLegacyFallbackState - -export type SshPtyConsumerAdmission = { - state: SshPtyConsumerSessionState - // Why not on the owner state itself: this describes one admission's outcome, not the persisted - // claim, and it must never round-trip through the recovery record. - resumed: boolean -} - -export type OpenSshPtyConsumerSessionOptions = { - clientInstanceId: string - expectedServerBuildId: string | undefined - resume?: Pick - outputFlowControl?: { - requestedWindowSu: number - } - allowSameBuildLegacyFallback?: boolean -} - -function validateGrant( - value: unknown, - options: OpenSshPtyConsumerSessionOptions -): PtyConsumerSessionGrant { - if (typeof value !== 'object' || value === null) { - throw new Error('Remote relay returned an invalid pty.openClient grant') - } - if (!options.expectedServerBuildId) { - throw new Error('Local relay build identity is unavailable') - } - const grant = value as Partial - if ( - grant.protocolVersion !== PTY_CONSUMER_SESSION_PROTOCOL_VERSION || - grant.serverBuildId !== options.expectedServerBuildId - ) { - throw new Error( - `Remote relay session contract mismatch — expected build ${options.expectedServerBuildId}, got ${grant.serverBuildId ?? 'unknown'}` - ) - } - if ( - !Number.isSafeInteger(grant.clientGeneration) || - grant.clientGeneration! <= 0 || - grant.role !== 'session-owner' || - !Number.isSafeInteger(grant.ownerGeneration) || - grant.ownerGeneration! <= 0 || - typeof grant.ownerLease !== 'string' || - grant.ownerLease.length === 0 || - grant.ownerLease.length > 512 - ) { - throw new Error('Remote relay did not grant an authenticated PTY session owner') - } - // Why not treated as a legacy relay: client and relay ship in one build, and the build id was already - // matched above — a missing `resumed` here is corruption, not an older peer. - if (typeof grant.resumed !== 'boolean') { - throw new Error('Remote relay owner grant did not state whether the claim was resumed') - } - const requestedFlow = options.outputFlowControl - const grantedFlow = grant.capabilities?.outputFlowControl - if (requestedFlow) { - if ( - grantedFlow?.version !== 1 || - !Number.isSafeInteger(grantedFlow.windowSu) || - grantedFlow.windowSu <= 0 || - grantedFlow.windowSu > requestedFlow.requestedWindowSu - ) { - throw new Error('Remote relay did not grant the offered PTY output-flow-control capability') - } - } else if (grantedFlow) { - throw new Error('Remote relay granted an unoffered PTY output-flow-control capability') - } - return grant as PtyConsumerSessionGrant -} - export async function openSshPtyConsumerSession( mux: SshChannelMultiplexer, options: OpenSshPtyConsumerSessionOptions @@ -110,13 +40,72 @@ export async function openSshPtyConsumerSession( clientInstanceId: options.clientInstanceId, requestedRole: 'session-owner', ...(options.resume ? { resume: options.resume } : {}), - ...(options.outputFlowControl + ...(options.outputFlowControl || + options.exactOperations || + options.heldProducerPause || + options.terminalAuthorityExactOperations || + options.terminalAuthorityOutcomeDelivery || + options.terminalAuthorityNamespaceOutcomeClaim || + options.terminalAuthorityConsumerProof || + options.terminalAuthorityTopology ? { capabilities: { - outputFlowControl: { - versions: [1], - requestedWindowSu: options.outputFlowControl.requestedWindowSu - } + ...(options.outputFlowControl + ? { + outputFlowControl: { + versions: [1], + requestedWindowSu: options.outputFlowControl.requestedWindowSu + } + } + : {}), + ...(options.exactOperations + ? { exactOperations: { versions: [PTY_EXACT_OPERATION_PROTOCOL_VERSION] } } + : {}), + ...(options.heldProducerPause ? { heldProducerPause: { versions: [1] } } : {}), + ...(options.terminalAuthorityExactOperations + ? { + terminalAuthorityExactOperations: { + versions: [TERMINAL_AUTHORITY_EXACT_OPERATIONS_VERSION] + } + } + : {}), + ...(options.terminalAuthorityOutcomeDelivery + ? { + terminalAuthorityOutcomeDelivery: { + versions: [TERMINAL_AUTHORITY_OUTCOME_DELIVERY_VERSION] + } + } + : {}), + ...(options.terminalAuthorityNamespaceOutcomeClaim + ? { + terminalAuthorityNamespaceOutcomes: { + versions: [TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_VERSION], + consumer: options.terminalAuthorityNamespaceOutcomeClaim.consumer, + expectedConsumerIncarnationId: + options.terminalAuthorityNamespaceOutcomeClaim + .expectedConsumerIncarnationId + } + } + : {}), + ...(options.terminalAuthorityConsumerProof + ? { + terminalAuthorityConsumerProof: { + versions: [TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION], + ...(options.terminalAuthorityConsumerRetirement + ? { + retirementVersions: [TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_VERSION] + } + : {}) + } + } + : {}), + ...(options.terminalAuthorityTopology + ? { + terminalAuthorityTopology: { + versions: [TERMINAL_AUTHORITY_TOPOLOGY_STREAM_VERSION] + } + } + : {}) } } : {}) @@ -128,6 +117,7 @@ export async function openSshPtyConsumerSession( if ( code === -32601 && options.allowSameBuildLegacyFallback === true && + options.requiredTerminalAuthorityConsumerProofHostId === undefined && typeof options.expectedServerBuildId === 'string' && options.expectedServerBuildId.length > 0 ) { @@ -142,7 +132,7 @@ export async function openSshPtyConsumerSession( } throw error } - const grant = validateGrant(result, options) + const grant = validateSshPtyConsumerSessionGrant(result, options) return { state: { mode: 'negotiated', @@ -152,6 +142,36 @@ export async function openSshPtyConsumerSession( ownerLease: grant.ownerLease!, ...(grant.capabilities?.outputFlowControl ? { outputFlowControl: grant.capabilities.outputFlowControl } + : {}), + ...(grant.capabilities?.exactOperations + ? { exactOperations: grant.capabilities.exactOperations } + : {}), + ...(grant.capabilities?.heldProducerPause + ? { heldProducerPause: grant.capabilities.heldProducerPause } + : {}), + ...(grant.capabilities?.terminalAuthorityExactOperations + ? { + terminalAuthorityExactOperations: grant.capabilities.terminalAuthorityExactOperations + } + : {}), + ...(grant.capabilities?.terminalAuthorityOutcomeDelivery + ? { + terminalAuthorityOutcomeDelivery: grant.capabilities.terminalAuthorityOutcomeDelivery + } + : {}), + ...(grant.capabilities?.terminalAuthorityNamespaceOutcomes + ? { + terminalAuthorityNamespaceOutcomes: + grant.capabilities.terminalAuthorityNamespaceOutcomes + } + : {}), + ...(grant.capabilities?.terminalAuthorityConsumerProof + ? { + terminalAuthorityConsumerProof: grant.capabilities.terminalAuthorityConsumerProof + } + : {}), + ...(terminalAuthorityTopologyGrantFromPtyCapabilities(grant.capabilities) + ? { terminalAuthorityTopology: { version: 1 as const } } : {}) }, resumed: grant.resumed! diff --git a/src/main/ssh/ssh-relay-cross-version-isolation.test.ts b/src/main/ssh/ssh-relay-cross-version-isolation.test.ts index 4ed6505d90c..f64b04309b9 100644 --- a/src/main/ssh/ssh-relay-cross-version-isolation.test.ts +++ b/src/main/ssh/ssh-relay-cross-version-isolation.test.ts @@ -57,17 +57,30 @@ vi.mock('./ssh-connection-utils', () => ({ import { deployAndLaunchRelay } from './ssh-relay-deploy' import { execCommand } from './ssh-relay-deploy-helpers' import type { SshConnection } from './ssh-connection' +import { + sshTerminalAuthorityBootstrapRead, + sshTerminalAuthorityMarkerRead +} from './ssh-terminal-authority-process-fixture' + +const V2_VERSION = '0.1.0+222222222222' function makeMockConnection(): SshConnection { return { canRunConcurrentExecCommands: vi.fn().mockReturnValue(false), - exec: vi.fn().mockResolvedValue({ - on: vi.fn(), - stderr: { on: vi.fn() }, - stdin: {}, - stdout: { on: vi.fn() }, - close: vi.fn() + exec: vi.fn().mockImplementation(() => { + const channel = new EventEmitter() + setTimeout(() => channel.emit('close'), 0) + return Promise.resolve( + Object.assign(channel, { + stderr: new EventEmitter(), + stdin: {}, + stdout: { on: vi.fn() }, + close: vi.fn() + }) + ) }), + uploadDirectory: vi.fn().mockResolvedValue(undefined), + writeFile: vi.fn().mockResolvedValue(undefined), // Why: production attaches and removes real SFTP/write-stream listeners, so the fake must be an emitter. sftp: vi.fn().mockImplementation(() => { const sftp = new EventEmitter() @@ -108,6 +121,7 @@ describe('cross-version isolation', () => { // v2 dir = ~/.orca-remote/relay-0.1.0+222222222222/ (does not yet exist) // The v2 client has fullVersion='0.1.0+222222222222' (from the fs mock above). // + let v2Installed = false mockExec.mockImplementation((_conn, command) => { if (command.includes('__ORCA_UPLOAD_STAGE_SLOT__')) { return Promise.resolve( @@ -115,6 +129,7 @@ describe('cross-version isolation', () => { ) } if (command.includes('__ORCA_UPLOAD_STAGE_PROMOTION__')) { + v2Installed = true return Promise.resolve( '__ORCA_UPLOAD_STAGE_PROMOTION__.sftp-namespace-00000000000000000000000000000000:PROMOTED' ) @@ -122,49 +137,42 @@ describe('cross-version isolation', () => { if (command.includes('__ORCA_REMOTE_PLATFORM__')) { return Promise.resolve('__ORCA_REMOTE_PLATFORM__ Linux x86_64') } - if (command === 'echo $HOME') { - return Promise.resolve('/home/u') + if (command.includes('ORCA_TERMINAL_AUTHORITY_HOME')) { + return Promise.resolve(sshTerminalAuthorityBootstrapRead('/home/u')) + } + if (command.includes('ORCA_TERMINAL_AUTHORITY_MARKER_ABSENT')) { + return Promise.resolve( + sshTerminalAuthorityMarkerRead({ remoteHome: '/home/u', relayVersion: V2_VERSION }) + ) } if (command.includes("-name 'relay-0.1.0+222222222222.upload-*'")) { return Promise.resolve('') } if (command.includes('relay-watcher.js') && command.includes('.install-complete')) { - return Promise.resolve('MISSING') + return Promise.resolve(v2Installed ? 'OK' : 'MISSING') } if (command.includes('.gc-claim') && command.includes('echo LOCKED || echo OPEN')) { return Promise.resolve('OPEN') } + if (command.includes('.install-lock') && command.includes('if mkdir')) { + return Promise.resolve('OK') + } if (command.includes('.install-lock') && command.includes('&& echo OK || echo BUSY')) { return Promise.resolve('OK') } if (command.includes('ORCA-NPTY-PROBE-OK')) { return Promise.resolve('ORCA-NPTY-PROBE-OK\n') } - if (command.includes('process.stdout.write("READY")')) { + if (command.includes('process.stdout.write') && command.includes('READY')) { return Promise.resolve('READY') } if (command.includes('test -S') && command.includes('echo ALIVE || echo DEAD')) { return Promise.resolve('DEAD') } - if (command.includes('__ORCA_RELAY_GC_FIND_STATUS__')) { - return Promise.resolve('relay-0.1.0+111111111111\nrelay-0.1.0+222222222222\n') - } - if (command.includes('relay-0.1.0+111111111111/.install-lock')) { - return Promise.resolve('OPEN') - } - if (command.includes('relay-0.1.0+111111111111/.install-complete')) { - return Promise.resolve('COMPLETE') - } - if (command.includes('relay-0.1.0+111111111111') && command.includes('relay-*.sock')) { - return Promise.resolve('ALIVE') - } return Promise.resolve('') }) await deployAndLaunchRelay(conn) - await vi.waitFor(() => - expect(mockExec.mock.calls.some(([, command]) => command.includes('relay-*.sock'))).toBe(true) - ) const allCmds = [ ...mockExec.mock.calls.map(([, c]) => c), @@ -185,27 +193,8 @@ describe('cross-version isolation', () => { expect(cmd).not.toContain('relay-0.1.0+111111111111') } - // (c) GC observes v1 has a live socket and never issues an rm -rf for it - const v1RemoveCmds = allCmds.filter( - (c) => c.includes('rm -rf') && c.includes('relay-0.1.0+111111111111') - ) - expect(v1RemoveCmds).toHaveLength(0) - - // (d) blanket isolation: every command that mentions v1hash MUST be a - // GC liveness probe (`ls`, `test -d`, `test -f`, or `for f in .../*.sock`) - // — never a write, mkdir, chmod, touch, rm, node launch, or socket poll. - // This prevents a future refactor that accidentally writes to the v1 dir - // (e.g. shared install-complete, upload over symlink) from passing. + // Blanket deploy isolation: v1 must not appear in any deploy command. const v1Refs = allCmds.filter((c) => c.includes('relay-0.1.0+111111111111')) - for (const cmd of v1Refs) { - const isReadOnlyProbe = - /^\s*ls\b/.test(cmd) || - /\btest -d\b/.test(cmd) || - /\btest -e\b/.test(cmd) || - /\btest -f\b/.test(cmd) || - /\btest -S\b/.test(cmd) || - /\bfor f in .*\.sock\b/.test(cmd) - expect(isReadOnlyProbe, `unexpected v1 reference: ${cmd}`).toBe(true) - } + expect(v1Refs).toHaveLength(0) }) }) diff --git a/src/main/ssh/ssh-relay-deploy-staged-upload.test.ts b/src/main/ssh/ssh-relay-deploy-staged-upload.test.ts index 7fad908a59a..26463548f49 100644 --- a/src/main/ssh/ssh-relay-deploy-staged-upload.test.ts +++ b/src/main/ssh/ssh-relay-deploy-staged-upload.test.ts @@ -1,4 +1,6 @@ +import { EventEmitter } from 'node:events' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { makeSshTerminalAuthorityProcess } from './ssh-terminal-authority-process-fixture' vi.mock('electron', () => ({ app: { getAppPath: () => '/mock/app' } @@ -66,6 +68,24 @@ vi.mock('./ssh-relay-repair-lock', () => ({ tryAcquireRelayRepairLock: vi.fn().mockResolvedValue('acquired') })) +vi.mock('./ssh-terminal-authority-discovery', () => ({ + sshTerminalAuthorityBootstrapReadCommand: vi.fn().mockReturnValue('echo $HOME'), + parseSshTerminalAuthorityBootstrapRead: vi.fn((output: string) => ({ + rawRemoteHome: output, + discovery: { status: 'absent' } + })) +})) + +vi.mock('./ssh-terminal-authority-process', () => ({ + establishSshTerminalAuthority: vi.fn(async (options: { relayDir: string }) => + makeSshTerminalAuthorityProcess({ + remoteHome: '/home/user', + ownerBuildId: '0.1.0+abcdef012345', + ownerRelayDir: options.relayDir + }) + ) +})) + vi.mock('./ssh-connection-utils', () => ({ shellEscape: (s: string) => `'${s}'`, createSshOperationAbortError: () => @@ -86,15 +106,20 @@ import { import type { SshConnection } from './ssh-connection' function makeMockConnection(): SshConnection { - return { - canRunConcurrentExecCommands: vi.fn().mockReturnValue(true), - exec: vi.fn().mockResolvedValue({ - on: vi.fn(), - stderr: { on: vi.fn() }, + const exec = vi.fn().mockImplementation(async () => { + const channel = new EventEmitter() + const result = Object.assign(channel, { + stderr: new EventEmitter(), stdin: {}, stdout: { on: vi.fn() }, close: vi.fn() - }), + }) + setTimeout(() => channel.emit('close'), 0) + return result + }) + return { + canRunConcurrentExecCommands: vi.fn().mockReturnValue(true), + exec, sftp: vi.fn().mockResolvedValue({ mkdir: vi.fn((_p: string, cb: (err: Error | null) => void) => cb(null)), createWriteStream: vi.fn().mockReturnValue({ diff --git a/src/main/ssh/ssh-relay-deploy-windows-pipe.test.ts b/src/main/ssh/ssh-relay-deploy-windows-pipe.test.ts new file mode 100644 index 00000000000..86e6f789b58 --- /dev/null +++ b/src/main/ssh/ssh-relay-deploy-windows-pipe.test.ts @@ -0,0 +1,358 @@ +// Why: split out of ssh-relay-deploy.test.ts to keep both specs under the max-lines cap; +// these cover the Windows named-pipe endpoint, its fallback, and per-target marker scoping. +import { EventEmitter } from 'node:events' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { makeSshTerminalAuthorityProcess } from './ssh-terminal-authority-process-fixture' + +vi.mock('electron', () => ({ + app: { getAppPath: () => '/mock/app' } +})) + +// Why: deployAndLaunchRelay now reads `${localRelayDir}/.version` upfront +// (per docs/ssh-relay-versioned-install-dirs.md). The fs mock must report +// the local relay package as existing AND return a content-hashed version +// string so readLocalFullVersion succeeds. +vi.mock('fs', () => ({ + existsSync: vi.fn().mockReturnValue(true), + readFileSync: vi.fn().mockReturnValue('0.1.0+abcdef012345') +})) + +vi.mock('./relay-protocol', () => ({ + RELAY_VERSION: '0.1.0', + RELAY_REMOTE_DIR: '.orca-remote', + parseUnameToRelayPlatform: vi.fn((os: string, arch: string) => { + const normalizedOs = os.toLowerCase() + const normalizedArch = arch.toLowerCase() + const relayArch = normalizedArch === 'arm64' || normalizedArch === 'aarch64' ? 'arm64' : 'x64' + if (normalizedOs === 'windows' || normalizedOs === 'win32') { + return `win32-${relayArch}` + } + if (normalizedOs === 'darwin') { + return `darwin-${relayArch}` + } + if (normalizedOs === 'linux') { + return `linux-${relayArch}` + } + return null + }), + RELAY_SENTINEL: 'ORCA-RELAY v0.1.0 READY\n', + RELAY_SENTINEL_TIMEOUT_MS: 10_000 +})) + +vi.mock('./ssh-relay-deploy-helpers', () => ({ + uploadDirectory: vi.fn().mockResolvedValue(undefined), + waitForSentinel: vi.fn().mockResolvedValue({ + write: vi.fn(), + onData: vi.fn(), + onClose: vi.fn() + }), + isUnconfirmedSshCommandTermination: (error: unknown) => + error instanceof Error && + (error as Error & { sshChannelCloseConfirmed?: boolean }).sshChannelCloseConfirmed === false, + execCommand: vi.fn().mockResolvedValue('__ORCA_REMOTE_PLATFORM__ Linux x86_64') +})) + +vi.mock('./ssh-remote-node-resolution', () => ({ + resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node') +})) + +vi.mock('./ssh-relay-endpoint-credential', () => ({ + writeRelayEndpointCredential: vi.fn().mockResolvedValue(undefined) +})) + +vi.mock('./ssh-terminal-authority-discovery', () => ({ + sshTerminalAuthorityBootstrapReadCommand: vi.fn().mockReturnValue('READ_HOME_AND_AUTHORITY'), + parseSshTerminalAuthorityBootstrapRead: vi.fn((output: string) => ({ + rawRemoteHome: output, + discovery: { status: 'absent' } + })) +})) + +vi.mock('./ssh-terminal-authority-process', () => ({ + establishSshTerminalAuthority: vi.fn(async (options: { relayDir: string }) => + makeSshTerminalAuthorityProcess({ + remoteHome: '/home/user', + ownerBuildId: '0.1.0+abcdef012345', + ownerRelayDir: options.relayDir + }) + ) +})) + +// Why: the versioned-install modules shell out for install state, locking, +// and GC. Stub them so deploy tests need no real SSH connection. +vi.mock('./ssh-relay-versioned-install', () => ({ + readLocalFullVersion: vi.fn().mockReturnValue('0.1.0+abcdef012345'), + computeRemoteRelayDir: (home: string, v: string) => `${home}/.orca-remote/relay-${v}`, + isRelayAlreadyInstalled: vi.fn().mockResolvedValue(true), + finalizeInstall: vi.fn().mockResolvedValue(undefined), + abandonInstall: vi.fn().mockResolvedValue(undefined), + gcOldRelayVersions: vi.fn().mockResolvedValue(undefined) +})) + +vi.mock('./ssh-relay-install-lock', () => ({ + acquireInstallLock: vi.fn().mockResolvedValue(undefined), + RELAY_INSTALL_LOCK_NAME: '.install-lock' +})) + +vi.mock('./ssh-relay-repair-lock', () => ({ + tryAcquireRelayRepairLock: vi.fn().mockResolvedValue('acquired') +})) + +vi.mock('./ssh-connection-utils', () => ({ + shellEscape: (s: string) => `'${s}'`, + createSshOperationAbortError: () => + Object.assign(new Error('SSH operation was cancelled'), { + name: 'AbortError' + }) +})) + +import { deployAndLaunchRelay } from './ssh-relay-deploy' +import { execCommand, waitForSentinel } from './ssh-relay-deploy-helpers' +import { resolveRemoteNodePath } from './ssh-remote-node-resolution' +import { isRelayAlreadyInstalled } from './ssh-relay-versioned-install' +import { acquireInstallLock } from './ssh-relay-install-lock' +import type { SshConnection } from './ssh-connection' + +function decodePowerShellCommand(command: string): string | null { + const match = command.match(/-EncodedCommand\s+([A-Za-z0-9+/=]+)/) + return match ? Buffer.from(match[1], 'base64').toString('utf16le') : null +} + +const extractWindowsSockPath = (script: string): string => + /--sock-path\s+'([^']+)'/.exec(script)?.[1] ?? '' + +const extractWindowsMarkerPath = (script: string): string => + /-LiteralPath\s+'([^']*\.windows-active-pipe[^']*)'/.exec(script)?.[1] ?? '' + +function makeMockConnection(): SshConnection { + const exec = vi.fn().mockImplementation(async () => { + const channel = new EventEmitter() + const result = Object.assign(channel, { + stderr: new EventEmitter(), + stdin: {}, + stdout: { on: vi.fn() }, + close: vi.fn() + }) + setTimeout(() => channel.emit('close'), 0) + return result + }) + return { + canRunConcurrentExecCommands: vi.fn().mockReturnValue(true), + exec, + writeFile: vi.fn().mockResolvedValue(undefined), + sftp: vi.fn().mockResolvedValue({ + mkdir: vi.fn((_p: string, cb: (err: Error | null) => void) => cb(null)), + createWriteStream: vi.fn().mockReturnValue({ + on: vi.fn((_event: string, cb: () => void) => { + if (_event === 'close') { + setTimeout(cb, 0) + } + }), + end: vi.fn() + }), + end: vi.fn() + }) + } as unknown as SshConnection +} + +describe('deployAndLaunchRelay on Windows remotes', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(execCommand).mockReset().mockResolvedValue('__ORCA_REMOTE_PLATFORM__ Linux x86_64') + vi.mocked(waitForSentinel).mockReset().mockResolvedValue({ + write: vi.fn(), + onData: vi.fn(), + onClose: vi.fn() + }) + vi.mocked(resolveRemoteNodePath).mockReset().mockResolvedValue('/usr/bin/node') + vi.mocked(isRelayAlreadyInstalled).mockReset().mockResolvedValue(true) + vi.mocked(acquireInstallLock).mockReset().mockResolvedValue(undefined) + }) + + it('launches Windows remotes via a named pipe endpoint', async () => { + const conn = makeMockConnection() + const mockExecCommand = vi.mocked(execCommand) + vi.mocked(resolveRemoteNodePath).mockResolvedValue('C:/Program Files/nodejs/node.exe') + mockExecCommand + .mockRejectedValueOnce(new Error('uname not found')) // tagged POSIX platform probe + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Windows X64') // tagged PowerShell platform probe + .mockResolvedValueOnce('C:\\Users\\me user') // remote home + .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe + .mockResolvedValueOnce('') // no persisted active pipe + .mockResolvedValueOnce('WAITING') // named pipe probe + .mockResolvedValueOnce('') // WMI relay launch + .mockResolvedValueOnce('READY') // named pipe poll + .mockResolvedValueOnce('') // persist active pipe marker + + const result = await deployAndLaunchRelay(conn, undefined, 300, 'target-a') + + expect(result.platform).toBe('win32-x64') + expect(result.remoteHome).toBe('C:/Users/me user') + expect(result.sockPath).toMatch(/^\\\\\.\\pipe\\orca-relay-[0-9a-f]{20}$/) + const execCommands = vi.mocked(conn.exec).mock.calls.map(([cmd]) => cmd as string) + expect(execCommands).toHaveLength(1) + expect(execCommands[0]).toContain('powershell.exe') + const decodedScripts = mockExecCommand.mock.calls + .map(([, command]) => decodePowerShellCommand(command)) + .filter((script): script is string => script !== null) + const launchScript = decodedScripts.find((script) => script.includes('Invoke-CimMethod')) ?? '' + expect(launchScript).toContain( + '"C:/Users/me user/.orca-remote/relay-0.1.0+abcdef012345/relay.js"' + ) + expect(launchScript).toContain( + '"C:/Users/me user/.orca-remote/relay-0.1.0+abcdef012345/agent-hooks/orca-relay-' + ) + expect(launchScript).toContain('--endpoint-dir') + expect(launchScript).toContain('"--authority-gateway-host-id" "authority-host"') + expect(launchScript).toContain('"--authority-gateway-owner-instance" "authority-owner"') + expect(launchScript).toContain('"--release-launch-install-lock"') + expect(launchScript).toContain('"--release-launch-install-lock-owner"') + expect(launchScript).not.toContain('--pty-source-credit-v1') + expect(launchScript).not.toContain('.pty-source-credit-policy') + expect(launchScript).not.toContain('\\\\.\\pipe\\agent-hooks') + const waitScript = decodedScripts.find((script) => script.includes('deadline=Date.now()')) ?? '' + expect(waitScript).toContain('setTimeout(attempt,intervalMs)') + const windowsLaunchCalls = mockExecCommand.mock.calls.filter(([, command]) => { + const script = decodePowerShellCommand(command) + return ( + script?.includes('.windows-active-pipe') || + script?.includes('Invoke-CimMethod') || + script?.includes('deadline=Date.now()') + ) + }) + expect(windowsLaunchCalls.length).toBeGreaterThan(0) + expect( + windowsLaunchCalls.every(([, , options]) => options?.signal instanceof AbortSignal) + ).toBe(true) + expect(vi.mocked(conn.exec).mock.calls[0]?.[1]?.signal).toBeInstanceOf(AbortSignal) + expect(vi.mocked(waitForSentinel).mock.calls[0]?.[1]).toBeInstanceOf(AbortSignal) + const markerCallIndex = mockExecCommand.mock.calls.findIndex(([, command]) => + decodePowerShellCommand(command)?.includes('Set-Content') + ) + expect(markerCallIndex).toBeGreaterThanOrEqual(0) + expect(mockExecCommand.mock.invocationCallOrder[markerCallIndex]).toBeLessThan( + vi.mocked(waitForSentinel).mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY + ) + }) + + it('relaunches Windows remotes on a fallback pipe when reconnecting the occupied pipe fails', async () => { + const conn = makeMockConnection() + const mockExecCommand = vi.mocked(execCommand) + vi.mocked(resolveRemoteNodePath).mockResolvedValue('C:/Program Files/nodejs/node.exe') + vi.mocked(waitForSentinel) + .mockRejectedValueOnce(new Error('stale daemon handshake failed')) + .mockResolvedValueOnce({ + write: vi.fn(), + onData: vi.fn(), + onClose: vi.fn() + }) + mockExecCommand + .mockRejectedValueOnce(new Error('uname not found')) // tagged POSIX platform probe + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Windows X64') // tagged PowerShell platform probe + .mockResolvedValueOnce('C:\\Users\\me user') // remote home + .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe + .mockResolvedValueOnce('') // no persisted active pipe yet + .mockResolvedValueOnce('READY') // existing named pipe probe + .mockResolvedValueOnce('') // persist the primary active pipe before reconnect + .mockResolvedValueOnce('WAITING') // deterministic fallback pipe is not already running + .mockResolvedValueOnce('') // WMI relay launch on fallback pipe + .mockResolvedValueOnce('READY') // fallback pipe poll + .mockResolvedValueOnce('') // persist fallback active pipe marker + + const result = await deployAndLaunchRelay(conn, undefined, 300, 'target-a') + + const execCommands = vi.mocked(conn.exec).mock.calls.map(([cmd]) => cmd as string) + expect(execCommands).toHaveLength(2) + const firstConnectScript = decodePowerShellCommand(execCommands[0]) ?? '' + const secondConnectScript = decodePowerShellCommand(execCommands[1]) ?? '' + const primaryPipe = extractWindowsSockPath(firstConnectScript) + const fallbackPipe = extractWindowsSockPath(secondConnectScript) + expect(primaryPipe).toMatch(/^\\\\\.\\pipe\\orca-relay-[0-9a-f]{20}$/) + expect(fallbackPipe).toMatch(/^\\\\\.\\pipe\\orca-relay-[0-9a-f]{20}$/) + expect(fallbackPipe).not.toBe(primaryPipe) + expect(result.sockPath).toBe(fallbackPipe) + + const launchScript = + mockExecCommand.mock.calls + .map(([, command]) => decodePowerShellCommand(command)) + .find((script) => script?.includes('Invoke-CimMethod')) ?? '' + expect(launchScript).toContain(fallbackPipe) + expect(launchScript).not.toContain(primaryPipe) + + const markerWriteScript = + mockExecCommand.mock.calls + .map(([, command]) => decodePowerShellCommand(command)) + .findLast( + (script) => script?.includes('Set-Content') && script.includes('.windows-active-pipe') + ) ?? '' + expect(markerWriteScript).toContain(fallbackPipe) + expect(markerWriteScript).not.toContain(primaryPipe) + }) + + it('prefers a persisted Windows fallback pipe on later reconnects', async () => { + const conn = makeMockConnection() + const mockExecCommand = vi.mocked(execCommand) + const persistedPipe = '\\\\.\\pipe\\orca-relay-1234567890abcdef1234' + vi.mocked(resolveRemoteNodePath).mockResolvedValue('C:/Program Files/nodejs/node.exe') + mockExecCommand + .mockRejectedValueOnce(new Error('uname not found')) // tagged POSIX platform probe + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Windows X64') // tagged PowerShell platform probe + .mockResolvedValueOnce('C:\\Users\\me user') // remote home + .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe + .mockResolvedValueOnce(`${persistedPipe}\n`) // persisted active pipe marker + .mockResolvedValueOnce('READY') // persisted named pipe probe + .mockResolvedValueOnce('') // refresh active pipe marker + + const result = await deployAndLaunchRelay(conn, undefined, 300, 'target-a') + + const execCommands = vi.mocked(conn.exec).mock.calls.map(([cmd]) => cmd as string) + expect(execCommands).toHaveLength(1) + const connectScript = decodePowerShellCommand(execCommands[0]) ?? '' + expect(extractWindowsSockPath(connectScript)).toBe(persistedPipe) + expect(result.sockPath).toBe(persistedPipe) + + const decodedExecScripts = mockExecCommand.mock.calls + .map(([, command]) => decodePowerShellCommand(command)) + .filter((script): script is string => script !== null) + expect(decodedExecScripts.some((script) => script.includes('Invoke-CimMethod'))).toBe(false) + }) + + it('scopes persisted Windows active pipe markers by relay target', async () => { + const connA = makeMockConnection() + const connB = makeMockConnection() + const mockExecCommand = vi.mocked(execCommand) + vi.mocked(resolveRemoteNodePath).mockResolvedValue('C:/Program Files/nodejs/node.exe') + mockExecCommand + .mockRejectedValueOnce(new Error('uname not found')) // tagged POSIX platform probe A + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Windows X64') + .mockResolvedValueOnce('C:\\Users\\me user') + .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') + .mockResolvedValueOnce('') // no persisted active pipe A + .mockResolvedValueOnce('WAITING') + .mockResolvedValueOnce('') + .mockResolvedValueOnce('READY') + .mockResolvedValueOnce('') // persist active pipe A + .mockRejectedValueOnce(new Error('uname not found')) // tagged POSIX platform probe B + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Windows X64') + .mockResolvedValueOnce('C:\\Users\\me user') + .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') + .mockResolvedValueOnce('') // no persisted active pipe B + .mockResolvedValueOnce('WAITING') + .mockResolvedValueOnce('') + .mockResolvedValueOnce('READY') + .mockResolvedValueOnce('') // persist active pipe B + + await deployAndLaunchRelay(connA, undefined, 300, 'target-a') + await deployAndLaunchRelay(connB, undefined, 300, 'target-b') + + const markerPaths = mockExecCommand.mock.calls + .map(([, command]) => decodePowerShellCommand(command)) + .filter((script): script is string => Boolean(script?.includes('Get-Content'))) + .map(extractWindowsMarkerPath) + + expect(markerPaths).toHaveLength(2) + expect(markerPaths[0]).toContain('.windows-active-pipe-relay-') + expect(markerPaths[1]).toContain('.windows-active-pipe-relay-') + expect(markerPaths[0]).not.toBe(markerPaths[1]) + }) +}) diff --git a/src/main/ssh/ssh-relay-deploy.test.ts b/src/main/ssh/ssh-relay-deploy.test.ts index 3134461fda1..e741c060e28 100644 --- a/src/main/ssh/ssh-relay-deploy.test.ts +++ b/src/main/ssh/ssh-relay-deploy.test.ts @@ -1,4 +1,10 @@ +import { EventEmitter } from 'node:events' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + currentSshTerminalAuthorityGrant, + makeSshTerminalAuthorityProcess, + sshTerminalAuthorityGrantWithout +} from './ssh-terminal-authority-process-fixture' vi.mock('electron', () => ({ app: { getAppPath: () => '/mock/app' } @@ -56,6 +62,24 @@ vi.mock('./ssh-relay-endpoint-credential', () => ({ writeRelayEndpointCredential: vi.fn().mockResolvedValue(undefined) })) +vi.mock('./ssh-terminal-authority-discovery', () => ({ + sshTerminalAuthorityBootstrapReadCommand: vi.fn().mockReturnValue('READ_HOME_AND_AUTHORITY'), + parseSshTerminalAuthorityBootstrapRead: vi.fn((output: string) => ({ + rawRemoteHome: output, + discovery: { status: 'absent' } + })) +})) + +vi.mock('./ssh-terminal-authority-process', () => ({ + establishSshTerminalAuthority: vi.fn(async (options: { relayDir: string }) => + makeSshTerminalAuthorityProcess({ + remoteHome: '/home/user', + ownerBuildId: '0.1.0+abcdef012345', + ownerRelayDir: options.relayDir + }) + ) +})) + // Why: the versioned-install modules shell out for install state, locking, // and GC. Stub them so deploy tests need no real SSH connection. vi.mock('./ssh-relay-versioned-install', () => ({ @@ -92,32 +116,29 @@ import { acquireInstallLock } from './ssh-relay-install-lock' import * as DeployTiming from './ssh-relay-deploy-timing' import type { SshConnection } from './ssh-connection' import type * as SshRemoteNodeResolution from './ssh-remote-node-resolution' +import { establishSshTerminalAuthority } from './ssh-terminal-authority-process' +import { relayDaemonGrantHasTerminalLegacyCutover } from '../../shared/terminal-legacy-cutover' +import { TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_CAPABILITY } from '../../shared/terminal-session-authority-consumer-transport' import { DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS, MAX_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../../shared/ssh-types' -function decodePowerShellCommand(command: string): string | null { - const match = command.match(/-EncodedCommand\s+([A-Za-z0-9+/=]+)/) - return match ? Buffer.from(match[1], 'base64').toString('utf16le') : null -} - -const extractWindowsSockPath = (script: string): string => - /--sock-path\s+'([^']+)'/.exec(script)?.[1] ?? '' - -const extractWindowsMarkerPath = (script: string): string => - /-LiteralPath\s+'([^']*\.windows-active-pipe[^']*)'/.exec(script)?.[1] ?? '' - function makeMockConnection(): SshConnection { - return { - canRunConcurrentExecCommands: vi.fn().mockReturnValue(true), - exec: vi.fn().mockResolvedValue({ - on: vi.fn(), - stderr: { on: vi.fn() }, + const exec = vi.fn().mockImplementation(async () => { + const channel = new EventEmitter() + const result = Object.assign(channel, { + stderr: new EventEmitter(), stdin: {}, stdout: { on: vi.fn() }, close: vi.fn() - }), + }) + setTimeout(() => channel.emit('close'), 0) + return result + }) + return { + canRunConcurrentExecCommands: vi.fn().mockReturnValue(true), + exec, writeFile: vi.fn().mockResolvedValue(undefined), sftp: vi.fn().mockResolvedValue({ mkdir: vi.fn((_p: string, cb: (err: Error | null) => void) => cb(null)), @@ -186,6 +207,93 @@ describe('deployAndLaunchRelay', () => { expect(progress).toContain('Starting relay...') }) + it('binds the single control transport to the admitted authority before launch', async () => { + const conn = makeMockConnection() + const mockExecCommand = vi.mocked(execCommand) + mockExecCommand.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') + mockExecCommand.mockResolvedValueOnce('/home/user') + mockExecCommand.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') + queueLaunchNamespaceAndDeadSocketProbe() + mockExecCommand.mockResolvedValueOnce('READY') + + const result = await deployAndLaunchRelay(conn, undefined, 300, 'target-a') + const launchCommand = vi + .mocked(conn.exec) + .mock.calls.map(([command]) => command) + .find((command) => command.includes('--detached')) + + expect(launchCommand).toContain( + "'--authority-gateway-marker-path' '/home/user/.orca-remote/terminal-authority/active.json'" + ) + expect(launchCommand).toContain("'--authority-gateway-host-id' 'authority-host'") + expect(launchCommand).toContain("'--authority-gateway-owner-instance' 'authority-owner'") + expect(launchCommand).toContain("'--authority-gateway-revision' '1'") + expect(result).not.toHaveProperty('terminalAuthorityTransport') + expect(vi.mocked(establishSshTerminalAuthority).mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(conn.exec).mock.invocationCallOrder[0] ?? Number.MAX_SAFE_INTEGER + ) + }) + + describe('terminal authority capability skew', () => { + function queueHealthyReconnect(): ReturnType { + const conn = makeMockConnection() + vi.mocked(execCommand) + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') + .mockResolvedValueOnce('/home/user') + .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') + queueLaunchNamespaceAndDeadSocketProbe() + vi.mocked(execCommand).mockResolvedValueOnce('READY') + return conn + } + + function admitOwner(owner: Record): void { + vi.mocked(establishSshTerminalAuthority).mockResolvedValueOnce( + owner as Awaited> + ) + } + + it('publishes exactly the capability set the current owner negotiated', async () => { + const conn = queueHealthyReconnect() + admitOwner(makeSshTerminalAuthorityProcess()) + + const result = await deployAndLaunchRelay(conn, undefined, 300, 'target-current') + + expect(result.terminalAuthorityCapabilities).toEqual( + currentSshTerminalAuthorityGrant().capabilities + ) + }) + + it('drops a capability the older owner withheld instead of assuming this client offer', async () => { + const withheld = TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_CAPABILITY + const conn = queueHealthyReconnect() + admitOwner( + makeSshTerminalAuthorityProcess({ + compatibility: sshTerminalAuthorityGrantWithout(withheld) + }) + ) + + const result = await deployAndLaunchRelay(conn, undefined, 300, 'target-older') + + expect(result.terminalAuthorityCapabilities).not.toContain(withheld) + expect(result.terminalAuthorityCapabilities).toContain('terminal-session.authority.v1') + }) + + it('omits capabilities entirely for an owner that predates capability negotiation', async () => { + const conn = queueHealthyReconnect() + const { compatibility: _ungranted, ...legacyOwner } = makeSshTerminalAuthorityProcess() + admitOwner(legacyOwner) + + const result = await deployAndLaunchRelay(conn, undefined, 300, 'target-legacy') + + expect(result).not.toHaveProperty('terminalAuthorityCapabilities') + // The legacy-migration gate must read this as "not negotiated", not crash the deploy. + expect(relayDaemonGrantHasTerminalLegacyCutover(result.terminalAuthorityCapabilities)).toBe( + false + ) + expect(result.authorityHostId).toBe('authority-host') + }) + }) + it('does not launch fresh after unconfirmed stale-socket cleanup', async () => { const conn = makeMockConnection() const unconfirmedCleanup = Object.assign(new Error('socket cleanup still running'), { @@ -608,7 +716,7 @@ describe('deployAndLaunchRelay', () => { if (command.includes('uname')) { return Promise.resolve('__ORCA_REMOTE_PLATFORM__ Linux x86_64') } - if (command === 'echo $HOME') { + if (command === 'READ_HOME_AND_AUTHORITY') { return Promise.resolve('/home/user') } const marker = command.match(/\.sftp-namespace-[0-9a-f]{32}/u)?.[0] @@ -644,13 +752,12 @@ describe('deployAndLaunchRelay', () => { it('aborts a launch started near the deploy deadline and closes its channel once', async () => { vi.useFakeTimers() try { - const launchChannel = { - on: vi.fn(), - stderr: { on: vi.fn() }, + const launchChannel = Object.assign(new EventEmitter(), { + stderr: new EventEmitter(), stdin: {}, stdout: { on: vi.fn() }, close: vi.fn() - } + }) const conn = makeMockConnection() vi.mocked(isRelayAlreadyInstalled).mockReset().mockResolvedValue(true) vi.mocked(conn.exec).mockResolvedValue(launchChannel as never) @@ -691,9 +798,9 @@ describe('deployAndLaunchRelay', () => { expect(result).toBeInstanceOf(Error) expect((result as Error).message).toBe('Relay deployment timed out after 900s') expect(launchChannel.close).toHaveBeenCalledTimes(1) - expect(mockExecCommand).toHaveBeenCalledTimes(6) + expect(mockExecCommand).toHaveBeenCalledTimes(5) await vi.advanceTimersByTimeAsync(10_000) - expect(mockExecCommand).toHaveBeenCalledTimes(6) + expect(mockExecCommand).toHaveBeenCalledTimes(5) } finally { vi.useRealTimers() } @@ -707,7 +814,7 @@ describe('deployAndLaunchRelay', () => { if (command.includes('__ORCA_REMOTE_PLATFORM__')) { return Promise.resolve('__ORCA_REMOTE_PLATFORM__ Linux x86_64') } - if (command === 'echo $HOME') { + if (command === 'READ_HOME_AND_AUTHORITY') { return Promise.resolve('/home/user') } if (command.includes('ORCA-NATIVE')) { @@ -744,179 +851,4 @@ describe('deployAndLaunchRelay', () => { expect(launchB).toContain('--sock-path') expect(launchA).not.toEqual(launchB) }) - - it('launches Windows remotes via a named pipe endpoint', async () => { - const conn = makeMockConnection() - const mockExecCommand = vi.mocked(execCommand) - vi.mocked(resolveRemoteNodePath).mockResolvedValue('C:/Program Files/nodejs/node.exe') - mockExecCommand - .mockRejectedValueOnce(new Error('uname not found')) // tagged POSIX platform probe - .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Windows X64') // tagged PowerShell platform probe - .mockResolvedValueOnce('C:\\Users\\me user') // remote home - .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe - .mockResolvedValueOnce('') // no persisted active pipe - .mockResolvedValueOnce('WAITING') // named pipe probe - .mockResolvedValueOnce('') // WMI relay launch - .mockResolvedValueOnce('READY') // named pipe poll - .mockResolvedValueOnce('') // persist active pipe marker - - const result = await deployAndLaunchRelay(conn, undefined, 300, 'target-a') - - expect(result.platform).toBe('win32-x64') - expect(result.remoteHome).toBe('C:/Users/me user') - expect(result.sockPath).toMatch(/^\\\\\.\\pipe\\orca-relay-[0-9a-f]{20}$/) - const execCommands = vi.mocked(conn.exec).mock.calls.map(([cmd]) => cmd as string) - expect(execCommands).toHaveLength(1) - expect(execCommands[0]).toContain('powershell.exe') - const decodedScripts = mockExecCommand.mock.calls - .map(([, command]) => decodePowerShellCommand(command)) - .filter((script): script is string => script !== null) - const launchScript = decodedScripts.find((script) => script.includes('Invoke-CimMethod')) ?? '' - expect(launchScript).toContain( - '"C:/Users/me user/.orca-remote/relay-0.1.0+abcdef012345/relay.js"' - ) - expect(launchScript).toContain( - '"C:/Users/me user/.orca-remote/relay-0.1.0+abcdef012345/agent-hooks/orca-relay-' - ) - expect(launchScript).toContain('--endpoint-dir') - expect(launchScript).not.toContain('--pty-source-credit-v1') - expect(launchScript).not.toContain('.pty-source-credit-policy') - expect(launchScript).not.toContain('\\\\.\\pipe\\agent-hooks') - const waitScript = decodedScripts.find((script) => script.includes('deadline=Date.now()')) ?? '' - expect(waitScript).toContain('setTimeout(attempt,intervalMs)') - const windowsLaunchCalls = mockExecCommand.mock.calls.filter(([, command]) => { - const script = decodePowerShellCommand(command) - return ( - script?.includes('.windows-active-pipe') || - script?.includes('Invoke-CimMethod') || - script?.includes('deadline=Date.now()') - ) - }) - expect(windowsLaunchCalls.length).toBeGreaterThan(0) - expect( - windowsLaunchCalls.every(([, , options]) => options?.signal instanceof AbortSignal) - ).toBe(true) - expect(vi.mocked(conn.exec).mock.calls[0]?.[1]?.signal).toBeInstanceOf(AbortSignal) - expect(vi.mocked(waitForSentinel).mock.calls[0]?.[1]).toBeInstanceOf(AbortSignal) - }) - - it('relaunches Windows remotes on a fallback pipe when reconnecting the occupied pipe fails', async () => { - const conn = makeMockConnection() - const mockExecCommand = vi.mocked(execCommand) - vi.mocked(resolveRemoteNodePath).mockResolvedValue('C:/Program Files/nodejs/node.exe') - vi.mocked(waitForSentinel) - .mockRejectedValueOnce(new Error('stale daemon handshake failed')) - .mockResolvedValueOnce({ - write: vi.fn(), - onData: vi.fn(), - onClose: vi.fn() - }) - mockExecCommand - .mockRejectedValueOnce(new Error('uname not found')) // tagged POSIX platform probe - .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Windows X64') // tagged PowerShell platform probe - .mockResolvedValueOnce('C:\\Users\\me user') // remote home - .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe - .mockResolvedValueOnce('') // no persisted active pipe yet - .mockResolvedValueOnce('READY') // existing named pipe probe - .mockResolvedValueOnce('WAITING') // deterministic fallback pipe is not already running - .mockResolvedValueOnce('') // WMI relay launch on fallback pipe - .mockResolvedValueOnce('READY') // fallback pipe poll - .mockResolvedValueOnce('') // persist fallback active pipe marker - - const result = await deployAndLaunchRelay(conn, undefined, 300, 'target-a') - - const execCommands = vi.mocked(conn.exec).mock.calls.map(([cmd]) => cmd as string) - expect(execCommands).toHaveLength(2) - const firstConnectScript = decodePowerShellCommand(execCommands[0]) ?? '' - const secondConnectScript = decodePowerShellCommand(execCommands[1]) ?? '' - const primaryPipe = extractWindowsSockPath(firstConnectScript) - const fallbackPipe = extractWindowsSockPath(secondConnectScript) - expect(primaryPipe).toMatch(/^\\\\\.\\pipe\\orca-relay-[0-9a-f]{20}$/) - expect(fallbackPipe).toMatch(/^\\\\\.\\pipe\\orca-relay-[0-9a-f]{20}$/) - expect(fallbackPipe).not.toBe(primaryPipe) - expect(result.sockPath).toBe(fallbackPipe) - - const launchScript = - mockExecCommand.mock.calls - .map(([, command]) => decodePowerShellCommand(command)) - .find((script) => script?.includes('Invoke-CimMethod')) ?? '' - expect(launchScript).toContain(fallbackPipe) - expect(launchScript).not.toContain(primaryPipe) - - const markerWriteScript = - mockExecCommand.mock.calls - .map(([, command]) => decodePowerShellCommand(command)) - .find( - (script) => script?.includes('Set-Content') && script.includes('.windows-active-pipe') - ) ?? '' - expect(markerWriteScript).toContain(fallbackPipe) - expect(markerWriteScript).not.toContain(primaryPipe) - }) - - it('prefers a persisted Windows fallback pipe on later reconnects', async () => { - const conn = makeMockConnection() - const mockExecCommand = vi.mocked(execCommand) - const persistedPipe = '\\\\.\\pipe\\orca-relay-1234567890abcdef1234' - vi.mocked(resolveRemoteNodePath).mockResolvedValue('C:/Program Files/nodejs/node.exe') - mockExecCommand - .mockRejectedValueOnce(new Error('uname not found')) // tagged POSIX platform probe - .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Windows X64') // tagged PowerShell platform probe - .mockResolvedValueOnce('C:\\Users\\me user') // remote home - .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe - .mockResolvedValueOnce(`${persistedPipe}\n`) // persisted active pipe marker - .mockResolvedValueOnce('READY') // persisted named pipe probe - .mockResolvedValueOnce('') // refresh active pipe marker - - const result = await deployAndLaunchRelay(conn, undefined, 300, 'target-a') - - const execCommands = vi.mocked(conn.exec).mock.calls.map(([cmd]) => cmd as string) - expect(execCommands).toHaveLength(1) - const connectScript = decodePowerShellCommand(execCommands[0]) ?? '' - expect(extractWindowsSockPath(connectScript)).toBe(persistedPipe) - expect(result.sockPath).toBe(persistedPipe) - - const decodedExecScripts = mockExecCommand.mock.calls - .map(([, command]) => decodePowerShellCommand(command)) - .filter((script): script is string => script !== null) - expect(decodedExecScripts.some((script) => script.includes('Invoke-CimMethod'))).toBe(false) - }) - - it('scopes persisted Windows active pipe markers by relay target', async () => { - const connA = makeMockConnection() - const connB = makeMockConnection() - const mockExecCommand = vi.mocked(execCommand) - vi.mocked(resolveRemoteNodePath).mockResolvedValue('C:/Program Files/nodejs/node.exe') - mockExecCommand - .mockRejectedValueOnce(new Error('uname not found')) // tagged POSIX platform probe A - .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Windows X64') - .mockResolvedValueOnce('C:\\Users\\me user') - .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') - .mockResolvedValueOnce('') // no persisted active pipe A - .mockResolvedValueOnce('WAITING') - .mockResolvedValueOnce('') - .mockResolvedValueOnce('READY') - .mockResolvedValueOnce('') // persist active pipe A - .mockRejectedValueOnce(new Error('uname not found')) // tagged POSIX platform probe B - .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Windows X64') - .mockResolvedValueOnce('C:\\Users\\me user') - .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') - .mockResolvedValueOnce('') // no persisted active pipe B - .mockResolvedValueOnce('WAITING') - .mockResolvedValueOnce('') - .mockResolvedValueOnce('READY') - .mockResolvedValueOnce('') // persist active pipe B - - await deployAndLaunchRelay(connA, undefined, 300, 'target-a') - await deployAndLaunchRelay(connB, undefined, 300, 'target-b') - - const markerPaths = mockExecCommand.mock.calls - .map(([, command]) => decodePowerShellCommand(command)) - .filter((script): script is string => Boolean(script?.includes('Get-Content'))) - .map(extractWindowsMarkerPath) - - expect(markerPaths).toHaveLength(2) - expect(markerPaths[0]).toContain('.windows-active-pipe-relay-') - expect(markerPaths[1]).toContain('.windows-active-pipe-relay-') - expect(markerPaths[0]).not.toBe(markerPaths[1]) - }) }) diff --git a/src/main/ssh/ssh-relay-deploy.ts b/src/main/ssh/ssh-relay-deploy.ts index 7590e9817f2..0263c8565d3 100644 --- a/src/main/ssh/ssh-relay-deploy.ts +++ b/src/main/ssh/ssh-relay-deploy.ts @@ -1,6 +1,7 @@ import { join } from 'node:path' /* eslint-disable max-lines -- Why: one cohesive contract (version detect, install-locked deploy, native-deps probe, launch, GC); splitting risks install/GC drift. */ import { existsSync } from 'node:fs' +import type { ClientChannel } from 'ssh2' import { app } from 'electron' import type { SshConnection } from './ssh-connection' import { RELAY_REMOTE_DIR, type RelayPlatform } from './relay-protocol' @@ -10,6 +11,10 @@ import { execCommand, isUnconfirmedSshCommandTermination } from './ssh-relay-deploy-helpers' +import { + resolveSshLegacyPriorRelayStatus, + type SshLegacyPriorRelayStatus +} from './ssh-legacy-migration-prior-relay-status' import { uploadRelayDirectory, writeRelayFile } from './ssh-relay-install-transfers' import { writeRelayEndpointCredential } from './ssh-relay-endpoint-credential' import { @@ -30,10 +35,10 @@ import { computeRemoteRelayDir, isRelayAlreadyInstalled, finalizeInstall, - abandonInstall, - gcOldRelayVersions + abandonInstall } from './ssh-relay-versioned-install' import { acquireInstallLock } from './ssh-relay-install-lock' +import { createRelayInstallLockOwnerToken } from '../../shared/relay-launch-fence-owner' import { tryAcquireRelayRepairLock } from './ssh-relay-repair-lock' import { releaseRelayGcClaimWithRetry, @@ -55,7 +60,6 @@ import { import { commandWithNodePath, makeRemoteExecutableCommand, - readRemoteHomeCommand, removeRemoteFileCommand } from './ssh-remote-commands' import { @@ -90,9 +94,24 @@ import { MAX_SSH_RELAY_GRACE_PERIOD_SECONDS, MIN_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../../shared/ssh-types' +import type { SshTerminalAuthorityDiscovery } from './ssh-terminal-authority-discovery' +import { + parseSshTerminalAuthorityBootstrapRead, + sshTerminalAuthorityBootstrapReadCommand +} from './ssh-terminal-authority-discovery' +import { sshTerminalAuthorityEndpoint } from './ssh-terminal-authority-endpoint' +import { + establishSshTerminalAuthority, + type SshTerminalAuthorityProcess +} from './ssh-terminal-authority-process' +import type { RelayDaemonCompatibilityGrant } from '../../shared/relay-daemon-compatibility' export type RelayDeployResult = { transport: MultiplexerTransport + authorityHostId: string + terminalAuthorityOwnerBuildId: string + terminalAuthorityOwnerRelayDir: string + terminalAuthorityCapabilities?: readonly string[] serverBuildId?: string platform: RelayPlatform hostPlatform?: RemoteHostPlatform @@ -101,6 +120,8 @@ export type RelayDeployResult = { nodePath?: string sockPath?: string credentialFile?: string + /** The prior authority owner recorded before this attempt launched its own; migration evidence. */ + priorRelayStatus: SshLegacyPriorRelayStatus } class RelayDirectoryGcConflictError extends Error { @@ -216,14 +237,25 @@ async function resolveRemoteInstallState( hostPlatform: RemoteHostPlatform, fullVersion: string, options?: { rethrowSessionLimitErrors?: boolean; signal?: AbortSignal } -): Promise<{ remoteHome: string; remoteRelayDir: string; alreadyInstalled: boolean }> { +): Promise<{ + remoteHome: string + remoteRelayDir: string + alreadyInstalled: boolean + terminalAuthorityDiscovery: SshTerminalAuthorityDiscovery +}> { // Why: SFTP doesn't expand `~`, so resolve the remote home explicitly via the host's native shell and normalize it. - const remoteHome = normalizeRemoteHome( - await execHostCommand(conn, hostPlatform, readRemoteHomeCommand(hostPlatform), { - signal: options?.signal - }), - hostPlatform + const bootstrap = parseSshTerminalAuthorityBootstrapRead( + await execHostCommand( + conn, + hostPlatform, + sshTerminalAuthorityBootstrapReadCommand(hostPlatform), + { signal: options?.signal } + ) ) + if (!bootstrap) { + throw new Error('Remote terminal authority bootstrap response is invalid') + } + const remoteHome = normalizeRemoteHome(bootstrap.rawRemoteHome, hostPlatform) // Why: $HOME is only used inside single-quoted shell strings, so validation only rejects control chars — spaces and non-ASCII stay valid. if (!validateRemoteHome(remoteHome, hostPlatform)) { throw new Error(`Remote home is not a valid path: ${remoteHome.slice(0, 100)}`) @@ -242,7 +274,12 @@ async function resolveRemoteInstallState( hostPlatform, probeOptions ) - return { remoteHome, remoteRelayDir, alreadyInstalled } + return { + remoteHome, + remoteRelayDir, + alreadyInstalled, + terminalAuthorityDiscovery: bootstrap.discovery + } } type RelayBootstrapState = { @@ -250,6 +287,7 @@ type RelayBootstrapState = { remoteRelayDir: string alreadyInstalled: boolean nodePath: string + terminalAuthorityDiscovery: SshTerminalAuthorityDiscovery } async function resolveRelayBootstrapStateSequentially( @@ -319,6 +357,18 @@ function isAbortError(err: unknown): boolean { return err instanceof Error && err.name === 'AbortError' } +/** + * Why: an owner older than capability negotiation publishes no grant. Omit the field so + * consumers degrade to their not-negotiated branch instead of the deploy throwing. + */ +function terminalAuthorityCapabilityGrant( + compatibility: RelayDaemonCompatibilityGrant | undefined +): { terminalAuthorityCapabilities?: readonly string[] } { + return compatibility + ? { terminalAuthorityCapabilities: Object.freeze([...compatibility.capabilities]) } + : {} +} + /** * Detect platform, resolve install state + node path, install if absent, launch, and return the transport. * Inner implementation wrapped by `deployAndLaunchRelay` with an overall timeout. @@ -380,7 +430,7 @@ async function deployAndLaunchRelayAttempt( onProgress?.('Checking existing relay...') // Why: install-check and node resolution are independent; run concurrently to save a round trip, with sequential fallback for restrictive SSH servers. - const { remoteHome, remoteRelayDir, alreadyInstalled, nodePath } = + const { remoteHome, remoteRelayDir, alreadyInstalled, nodePath, terminalAuthorityDiscovery } = await resolveRelayBootstrapState(conn, hostPlatform, fullVersion, deploySignal) console.log(`[ssh-relay] Remote dir: ${remoteRelayDir}`) console.log(`[ssh-relay] Already installed at ${fullVersion}: ${alreadyInstalled}`) @@ -395,7 +445,7 @@ async function deployAndLaunchRelayAttempt( ) const homeRelativeUploadStagePoolDir = `${RELAY_REMOTE_DIR}/${RELAY_UPLOAD_STAGE_POOL_NAME}` - let ownsInstallLock = false + let installLockOwnerToken: string | undefined let launchGcClaimToken: string | undefined let launchNamespace: RelayInstallNamespace | undefined if (alreadyInstalled) { @@ -408,7 +458,7 @@ async function deployAndLaunchRelayAttempt( homeRelativeRelayDir, deploySignal ) - ownsInstallLock = launchFence.ownsInstallLock + installLockOwnerToken = launchFence.installLockOwnerToken launchGcClaimToken = launchFence.gcClaimToken launchNamespace = launchFence.sftpNamespace deploySignal?.throwIfAborted() @@ -463,12 +513,16 @@ async function deployAndLaunchRelayAttempt( throw err } + const candidateOwnerToken = createRelayInstallLockOwnerToken() try { - await acquireInstallLock(conn, remoteRelayDir, hostPlatform, { signal: deploySignal }) - ownsInstallLock = true + await acquireInstallLock(conn, remoteRelayDir, hostPlatform, { + signal: deploySignal, + ownerToken: candidateOwnerToken + }) + installLockOwnerToken = candidateOwnerToken } catch (err) { if (isUnconfirmedSshCommandTermination(err)) { - ownsInstallLock = true + installLockOwnerToken = candidateOwnerToken } throw err } @@ -532,7 +586,7 @@ async function deployAndLaunchRelayAttempt( } catch (err) { if (!isUnconfirmedSshCommandTermination(err)) { await abandonInstall(conn, remoteRelayDir, hostPlatform) - ownsInstallLock = false + installLockOwnerToken = undefined } throw err } @@ -547,50 +601,51 @@ async function deployAndLaunchRelayAttempt( } } - let launched: Awaited> - let launchLivenessObserved = false - try { - deploySignal?.throwIfAborted() - onProgress?.('Starting relay...') - console.log('[ssh-relay] Launching relay...') - launched = await launchRelay( - conn, - remoteRelayDir, - hostPlatform, - nodePath, - graceTimeSeconds, - relayInstanceId, - deploySignal - ) - launchLivenessObserved = true - } finally { - // Why: older clients understand only the install lock; if launch never goes live, keep it so their GC can't race a caller waiting behind this owner. - if (ownsInstallLock && launchLivenessObserved) { - await abandonInstall(conn, remoteRelayDir, hostPlatform) - } - // The detached start may outlive a timed-out SSH command; keep the fence on failed launch until stale recovery proves the handoff ended. - if (launchGcClaimToken && launchLivenessObserved) { - await releaseRelayGcClaimWithRetry(conn, remoteRelayDir, launchGcClaimToken, hostPlatform) - } - } + const terminalAuthority = await establishSshTerminalAuthority({ + conn, + host: hostPlatform, + remoteHome, + relayDir: remoteRelayDir, + nodePath, + endpoint: sshTerminalAuthorityEndpoint(hostPlatform, remoteHome), + discovery: terminalAuthorityDiscovery, + graceTimeSeconds: normalizeRelayGraceTimeSeconds(graceTimeSeconds), + signal: deploySignal + }) + + deploySignal?.throwIfAborted() + onProgress?.('Starting relay...') + console.log('[ssh-relay] Launching relay...') + const launched = await launchRelay( + conn, + remoteRelayDir, + hostPlatform, + nodePath, + graceTimeSeconds, + relayInstanceId, + terminalAuthority, + { + ...(installLockOwnerToken ? { installLockOwnerToken } : {}), + ...(launchGcClaimToken ? { gcClaimOwnerToken: launchGcClaimToken } : {}) + }, + deploySignal + ) console.log('[ssh-relay] Relay started successfully') + // Why: GC is authority-migration protected and left to migrationBarrier, but bounded + // stale-stage recovery touches only this pool and must keep running every deploy. void execHostCommand( conn, hostPlatform, recoverOneStaleRelayUploadStageCommand(hostPlatform, uploadStagePoolDir) - ) - .catch(() => {}) - .then(() => - gcOldRelayVersions(conn, remoteHome, remoteRelayDir, hostPlatform, { - windowsNodePath: launched.nodePath, - windowsSockNames: [relaySocketNameForInstanceId(relayInstanceId)] - }) - ) - .catch(() => {}) + ).catch(() => {}) return { transport: launched.transport, + authorityHostId: terminalAuthority.authorityHostId, + terminalAuthorityOwnerBuildId: terminalAuthority.ownerBuildId, + terminalAuthorityOwnerRelayDir: terminalAuthority.ownerRelayDir, + ...terminalAuthorityCapabilityGrant(terminalAuthority.compatibility), serverBuildId: fullVersion, platform, hostPlatform, @@ -598,7 +653,11 @@ async function deployAndLaunchRelayAttempt( remoteRelayDir, nodePath: launched.nodePath, sockPath: launched.sockPath, - credentialFile: launched.credentialFile + credentialFile: launched.credentialFile, + priorRelayStatus: resolveSshLegacyPriorRelayStatus({ + discovery: terminalAuthorityDiscovery, + owner: terminalAuthority + }) } } @@ -771,7 +830,7 @@ async function repairInstalledNativeDeps( homeRelativeRelayDir: string, signal?: AbortSignal ): Promise<{ - ownsInstallLock: boolean + installLockOwnerToken?: string gcClaimToken?: string sftpNamespace?: RelayInstallNamespace }> { @@ -782,7 +841,11 @@ async function repairInstalledNativeDeps( nodePath, signal ) - const lockResult = await tryAcquireRelayRepairLock(conn, remoteDir, hostPlatform, { signal }) + const candidateOwnerToken = createRelayInstallLockOwnerToken() + const lockResult = await tryAcquireRelayRepairLock(conn, remoteDir, hostPlatform, { + signal, + ownerToken: candidateOwnerToken + }) if (lockResult === 'gc') { throw new RelayDirectoryGcConflictError(remoteDir, hostPlatform) } @@ -810,25 +873,28 @@ async function repairInstalledNativeDeps( if (initialProbe.available) { // Why: even a healthy reconnect stays fenced until launch liveness is observable, or cross-version GC can rename after this probe. if (lockResult !== 'acquired') { - return { ownsInstallLock: false, gcClaimToken } + return { gcClaimToken } } try { + const sftpNamespace = await createRelayLaunchNamespace( + conn, + hostPlatform, + remoteDir, + homeRelativeRelayDir, + signal + ) return { - ownsInstallLock: true, - sftpNamespace: await createRelayLaunchNamespace( - conn, - hostPlatform, - remoteDir, - homeRelativeRelayDir, - signal - ) + installLockOwnerToken: candidateOwnerToken, + sftpNamespace } } catch (err) { signal?.throwIfAborted() console.warn( `[ssh-relay] Launch namespace marker is unconfirmed at ${remoteDir}; deferring lock ownership to stale recovery` ) - return { ownsInstallLock: !isUnconfirmedSshCommandTermination(err) } + return isUnconfirmedSshCommandTermination(err) + ? {} + : { installLockOwnerToken: candidateOwnerToken } } } @@ -838,7 +904,7 @@ async function repairInstalledNativeDeps( console.warn( `[ssh-relay] Native-deps repair lock is ${lockResult} at ${remoteDir}; launching degraded` ) - return { ownsInstallLock: false, gcClaimToken } + return { gcClaimToken } } try { // Why: older complete relay dirs predate @parcel/watcher; re-probe under the lock so only one reconnect mutates the dir. @@ -865,7 +931,7 @@ async function repairInstalledNativeDeps( ) await finalizeInstall(conn, remoteDir, hostPlatform, { signal, releaseLock: false }) } - return { ownsInstallLock: true, sftpNamespace: repairNamespace } + return { installLockOwnerToken: candidateOwnerToken, sftpNamespace: repairNamespace } } catch (err) { const terminationUnconfirmed = isUnconfirmedSshCommandTermination(err) // Why: hold a confirmed-failure lock through degraded launch so GC can't move the relay before liveness is visible. @@ -875,7 +941,7 @@ async function repairInstalledNativeDeps( err instanceof Error ? err.message : String(err) }` ) - return { ownsInstallLock: !terminationUnconfirmed } + return terminationUnconfirmed ? {} : { installLockOwnerToken: candidateOwnerToken } } } @@ -1352,6 +1418,24 @@ export function getLocalRelayCandidates(platform: RelayPlatform): string[] { return [...new Set(candidates)] } +type RelayLaunchFenceRelease = Readonly<{ + installLockOwnerToken?: string + gcClaimOwnerToken?: string +}> + +function relayLaunchFenceArguments(fence: RelayLaunchFenceRelease): string[] { + return [ + ...(fence.installLockOwnerToken + ? [ + '--release-launch-install-lock', + '--release-launch-install-lock-owner', + fence.installLockOwnerToken + ] + : []), + ...(fence.gcClaimOwnerToken ? ['--release-launch-gc-claim-owner', fence.gcClaimOwnerToken] : []) + ] +} + async function launchRelay( conn: SshConnection, remoteDir: string, @@ -1359,6 +1443,8 @@ async function launchRelay( nodePath: string, graceTimeSeconds?: number, relayInstanceId?: string, + authorityGateway?: SshTerminalAuthorityProcess, + launchFence?: RelayLaunchFenceRelease, signal?: AbortSignal ): Promise<{ transport: MultiplexerTransport @@ -1367,14 +1453,7 @@ async function launchRelay( credentialFile: string }> { // Why: graceTimeSeconds comes from user-editable SshTarget config; floor+clamp to an integer prevents shell injection if the type ever loosened. - const requestedGraceTime = Math.floor(graceTimeSeconds ?? DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS) - const graceTime = - requestedGraceTime === 0 - ? 0 - : Math.max( - MIN_SSH_RELAY_GRACE_PERIOD_SECONDS, - Math.min(MAX_SSH_RELAY_GRACE_PERIOD_SECONDS, requestedGraceTime) - ) + const graceTime = normalizeRelayGraceTimeSeconds(graceTimeSeconds) const escapedDir = shellEscape(remoteDir) const escapedNode = shellEscape(nodePath) // Why: remoteRelayDir is shared across Orca targets for one account; hashing the target ID into the socket name stops cross-target attach. @@ -1382,6 +1461,12 @@ async function launchRelay( const sockFile = relayEndpointForHost(hostPlatform, remoteDir, sockName) const endpointDir = relayHookEndpointDirForHost(hostPlatform, remoteDir, sockFile) const credentialFile = joinRemotePath(hostPlatform, remoteDir, `${sockName}.credential`) + if (!authorityGateway) { + throw new Error('Control relay launch requires a terminal authority gateway binding') + } + if (!launchFence) { + throw new Error('Control relay launch requires exact launch-fence ownership') + } if (isWindowsRemoteHost(hostPlatform)) { const activePipeMarkerPath = windowsActivePipeMarkerPath(hostPlatform, remoteDir, sockName) @@ -1408,7 +1493,9 @@ async function launchRelay( graceTime, activePipeMarkerPath, reconnectFallback: fallbackEndpoint, - credentialFile + credentialFile, + authorityGateway, + launchFence }, signal ) @@ -1426,8 +1513,9 @@ async function launchRelay( if (probeOutput.trim() === 'ALIVE') { console.log('[ssh-relay] Existing relay socket found, attempting reconnect...') try { + const launchFenceArgs = relayLaunchFenceArguments(launchFence).map(shellEscape).join(' ') const channel = await conn.exec( - `cd ${escapedDir} && ${escapedNode} relay.js --connect --sock-path ${shellEscape(sockFile)} --credential-file ${shellEscape(credentialFile)}`, + `cd ${escapedDir} && ${escapedNode} relay.js --connect --sock-path ${shellEscape(sockFile)} --credential-file ${shellEscape(credentialFile)} ${launchFenceArgs}`, { signal } ) const transport = await waitForSentinel(channel, signal) @@ -1465,14 +1553,17 @@ async function launchRelay( signal }) // Why: --log-file lets the relay rotate relay.log in-process; the shell redirect stays to capture pre-JS boot/crash output. - const launchCmd = `cd ${escapedDir} && chmod 600 ${shellEscape(credentialFile)} && nohup ${escapedNode} relay.js --detached --grace-time ${graceTime} --sock-path ${shellEscape(sockFile)} --credential-file ${shellEscape(credentialFile)} --log-file ${shellEscape(logFile)} > ${shellEscape(logFile)} 2>&1 ${shellEscape(logFile)} 2>&1 {}) launchChannel.on('error', () => {}) launchChannel.stderr.on('data', () => {}) launchChannel.stderr.on('error', () => {}) - // Why: the SSH channel stays open until all child fds close; close it after the poll or channels accumulate and hit the server's MaxSessions limit. + // Why: confirm this short-lived launch session is gone before opening the readiness probe; restrictive sshd hosts may allow only one session. launchChannel.on('close', () => {}) + await waitForRelayLaunchChannelClose(launchChannel, signal) // Why: poll rather than fixed sleep — remote host speed varies widely (CI vs. Raspberry Pi). // Why: test -S only proves the inode exists, not that the relay is listening; a connect-and-close confirms it accepts connections. @@ -1480,27 +1571,23 @@ async function launchRelay( const POLL_TIMEOUT_MS = 10_000 const pollStart = Date.now() let socketReady = false - try { - while (Date.now() - pollStart < POLL_TIMEOUT_MS) { - try { - // Why: probe via node (guaranteed present) not python3/socat/perl; pass the socket path as argv[1] to dodge -e quoting issues. - const result = await execCommand( - conn, - `${escapedNode} -e 'var s=require("net").connect(process.argv[1]);s.on("connect",function(){s.destroy();process.stdout.write("READY")});s.on("error",function(){process.stdout.write("WAITING")})' ${shellEscape(sockFile)} 2>/dev/null || (test -S ${shellEscape(sockFile)} && echo READY || echo WAITING)`, - { signal } - ) - if (result.trim() === 'READY') { - socketReady = true - break - } - } catch { - signal?.throwIfAborted() - /* exec failed, retry */ + while (Date.now() - pollStart < POLL_TIMEOUT_MS) { + try { + // Why: probe via node (guaranteed present) not python3/socat/perl; pass the socket path as argv[1] to dodge -e quoting issues. + const result = await execCommand( + conn, + `${escapedNode} -e 'var s=require("net").connect(process.argv[1]);s.on("connect",function(){s.destroy();process.stdout.write("READY")});s.on("error",function(){process.stdout.write("WAITING")})' ${shellEscape(sockFile)} 2>/dev/null || (test -S ${shellEscape(sockFile)} && echo READY || echo WAITING)`, + { signal } + ) + if (result.trim() === 'READY') { + socketReady = true + break } - await waitForRelayPoll(POLL_INTERVAL_MS, signal) + } catch { + signal?.throwIfAborted() + /* exec failed, retry */ } - } finally { - launchChannel.close() + await waitForRelayPoll(POLL_INTERVAL_MS, signal) } if (!socketReady) { @@ -1526,6 +1613,29 @@ async function launchRelay( } } +function normalizeRelayGraceTimeSeconds(graceTimeSeconds?: number): number { + const requested = Math.floor(graceTimeSeconds ?? DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS) + return requested === 0 + ? 0 + : Math.max( + MIN_SSH_RELAY_GRACE_PERIOD_SECONDS, + Math.min(MAX_SSH_RELAY_GRACE_PERIOD_SECONDS, requested) + ) +} + +function terminalAuthorityGatewayArguments(authority: SshTerminalAuthorityProcess): string[] { + return [ + '--authority-gateway-marker-path', + authority.markerPath, + '--authority-gateway-host-id', + authority.authorityHostId, + '--authority-gateway-owner-instance', + authority.ownerInstanceId, + '--authority-gateway-revision', + String(authority.revision) + ] +} + function waitForRelayPoll(delayMs: number, signal?: AbortSignal): Promise { return new Promise((resolve, reject) => { const onAbort = (): void => { @@ -1544,6 +1654,47 @@ function waitForRelayPoll(delayMs: number, signal?: AbortSignal): Promise }) } +function waitForRelayLaunchChannelClose( + channel: ClientChannel, + signal?: AbortSignal +): Promise { + return new Promise((resolve, reject) => { + let settled = false + const finish = (error?: Error): void => { + if (settled) { + return + } + settled = true + clearTimeout(timer) + signal?.removeEventListener('abort', onAbort) + channel.removeListener('close', onClose) + channel.removeListener('error', onError) + if (error) { + reject(error) + } else { + resolve() + } + } + const onClose = (): void => finish() + const onError = (error: Error): void => finish(error) + const onAbort = (): void => { + channel.close() + finish(createSshOperationAbortError()) + } + const timer = setTimeout(() => { + channel.close() + finish(new Error('Control relay launch channel close was not confirmed')) + }, RELAY_DEPLOY_TEARDOWN_TIMEOUT_MS) + timer.unref?.() + channel.once('close', onClose) + channel.once('error', onError) + signal?.addEventListener('abort', onAbort, { once: true }) + if (signal?.aborted) { + onAbort() + } + }) +} + function buildWindowsRelayFallbackEndpoint( hostPlatform: RemoteHostPlatform, remoteDir: string, @@ -1619,6 +1770,8 @@ type WindowsRelayLaunchOptions = { graceTime: number activePipeMarkerPath: string credentialFile: string + authorityGateway: SshTerminalAuthorityProcess + launchFence: RelayLaunchFenceRelease } & WindowsRelayEndpoint & { reconnectFallback?: WindowsRelayEndpoint } @@ -1632,7 +1785,6 @@ async function launchWindowsRelay( let launchOpts = opts if ((await probeWindowsRelayPipe(conn, hostPlatform, opts, signal)) === 'READY') { try { - const transport = await connectWindowsRelay(conn, hostPlatform, opts, signal) await rememberWindowsActiveRelayEndpoint( conn, hostPlatform, @@ -1640,6 +1792,7 @@ async function launchWindowsRelay( opts.sockPath, signal ) + const transport = await connectWindowsRelay(conn, hostPlatform, opts, signal) return { transport, nodePath: opts.nodePath, @@ -1664,7 +1817,6 @@ async function launchWindowsRelay( (await probeWindowsRelayPipe(conn, hostPlatform, launchOpts, signal)) === 'READY' ) { try { - const transport = await connectWindowsRelay(conn, hostPlatform, launchOpts, signal) await rememberWindowsActiveRelayEndpoint( conn, hostPlatform, @@ -1672,6 +1824,7 @@ async function launchWindowsRelay( launchOpts.sockPath, signal ) + const transport = await connectWindowsRelay(conn, hostPlatform, launchOpts, signal) return { transport, nodePath: launchOpts.nodePath, @@ -1707,7 +1860,9 @@ async function launchWindowsRelay( launchOpts.graceTime, logFile, errFile, - launchOpts.credentialFile + launchOpts.credentialFile, + launchOpts.authorityGateway, + launchOpts.launchFence ), { signal } ) @@ -1724,7 +1879,6 @@ async function launchWindowsRelay( signal ) ) { - const transport = await connectWindowsRelay(conn, hostPlatform, launchOpts, signal) await rememberWindowsActiveRelayEndpoint( conn, hostPlatform, @@ -1732,6 +1886,12 @@ async function launchWindowsRelay( launchOpts.sockPath, signal ) + const transport = await connectWindowsRelay( + conn, + hostPlatform, + { ...launchOpts, launchFence: undefined }, + signal + ) return { transport, nodePath: launchOpts.nodePath, @@ -1759,6 +1919,7 @@ async function connectWindowsRelay( nodePath: string sockPath: string credentialFile: string + launchFence?: RelayLaunchFenceRelease }, signal?: AbortSignal ): Promise { @@ -1768,7 +1929,8 @@ async function connectWindowsRelay( opts.nodePath, opts.remoteDir, opts.sockPath, - opts.credentialFile + opts.credentialFile, + opts.launchFence ), { wrapCommand: false, signal } ) @@ -1780,13 +1942,17 @@ function windowsRelayConnectCommand( nodePath: string, remoteDir: string, sockPath: string, - credentialFile: string + credentialFile: string, + launchFence?: RelayLaunchFenceRelease ): string { + const launchFenceArgs = launchFence + ? relayLaunchFenceArguments(launchFence).map(powerShellLiteral).join(' ') + : '' return commandWithNodePath( hostPlatform, nodePath, remoteDir, - `& ${powerShellLiteral(nodePath)} relay.js --connect --sock-path ${powerShellLiteral(sockPath)} --credential-file ${powerShellLiteral(credentialFile)}` + `& ${powerShellLiteral(nodePath)} relay.js --connect --sock-path ${powerShellLiteral(sockPath)} --credential-file ${powerShellLiteral(credentialFile)} ${launchFenceArgs}` ) } @@ -1799,7 +1965,9 @@ function windowsRelayLaunchCommand( graceTime: number, logFile: string, errFile: string, - credentialFile: string + credentialFile: string, + authorityGateway: SshTerminalAuthorityProcess, + launchFence: RelayLaunchFenceRelease ): string { const relayScript = joinRemotePath(hostPlatform, remoteDir, 'relay.js') // Why: Windows sshd kills the exec channel's process tree on close; WMI re-parents the detached relay to survive. @@ -1808,6 +1976,7 @@ function windowsRelayLaunchCommand( quoted(nodePath), quoted(relayScript), '--detached', + '--control-adapter', '--grace-time', String(graceTime), '--sock-path', @@ -1819,6 +1988,8 @@ function windowsRelayLaunchCommand( // Why: --log-file owns rotation; shell redirects still capture pre-JS boot/crash output. '--log-file', quoted(logFile), + ...terminalAuthorityGatewayArguments(authorityGateway).map(quoted), + ...relayLaunchFenceArguments(launchFence).map(quoted), `1>${quoted(logFile)}`, `2>${quoted(errFile)}` ].join(' ') diff --git a/src/main/ssh/ssh-relay-endpoint-credential.test.ts b/src/main/ssh/ssh-relay-endpoint-credential.test.ts index 3520b94c8e8..fe3888f60bb 100644 --- a/src/main/ssh/ssh-relay-endpoint-credential.test.ts +++ b/src/main/ssh/ssh-relay-endpoint-credential.test.ts @@ -1,6 +1,9 @@ import { Buffer } from 'node:buffer' import { describe, expect, it } from 'vitest' -import { relayEndpointCredentialWriteCommand } from './ssh-relay-endpoint-credential' +import { + relayEndpointCredentialEnsureCommand, + relayEndpointCredentialWriteCommand +} from './ssh-relay-endpoint-credential' import { getRemoteHostPlatform } from './ssh-remote-platform' function decodePowerShellCommand(command: string): string { @@ -47,4 +50,30 @@ describe('relay endpoint credential writes', () => { expect(script).not.toContain('Set-Acl') expect(script).not.toContain('icacls') }) + + it('creates a stable POSIX credential once without replacing an owner credential', () => { + const command = relayEndpointCredentialEnsureCommand( + getRemoteHostPlatform('linux-x64'), + '/usr/bin/node', + '/home/me/.orca-remote/terminal-authority/endpoint.credential' + ) + expect(command).toContain('fs.linkSync(t,p)') + expect(command).toContain('fs.fsyncSync(d)') + expect(command).toContain('e.code!=="EEXIST"') + expect(command).not.toContain('fs.renameSync(t,p)') + }) + + it('keeps a valid stable Windows credential when concurrent launchers race', () => { + const script = decodePowerShellCommand( + relayEndpointCredentialEnsureCommand( + getRemoteHostPlatform('win32-x64'), + 'C:/Program Files/nodejs/node.exe', + 'C:/Users/me/.orca-remote/terminal-authority/endpoint.credential' + ) + ) + expect(script).toContain('if (Test-Path -LiteralPath $path -PathType Leaf)') + expect(script).toContain('catch [System.IO.IOException] { }') + expect(script).toContain('.SetAccessControl($security)') + expect(script).not.toContain('[System.IO.File]::Delete($path)') + }) }) diff --git a/src/main/ssh/ssh-relay-endpoint-credential.ts b/src/main/ssh/ssh-relay-endpoint-credential.ts index 896aec31211..64772f3029c 100644 --- a/src/main/ssh/ssh-relay-endpoint-credential.ts +++ b/src/main/ssh/ssh-relay-endpoint-credential.ts @@ -12,6 +12,19 @@ const POSIX_CREDENTIAL_SCRIPT = 'fs.renameSync(t,p)' + '}finally{try{fs.unlinkSync(t)}catch(e){if(e.code!=="ENOENT")throw e}}' +const POSIX_ENSURE_CREDENTIAL_SCRIPT = + 'const fs=require("fs"),path=require("path"),crypto=require("crypto"),p=process.argv[1],' + + 't=p+"."+process.pid+"."+crypto.randomBytes(8).toString("hex")+".tmp",' + + 'valid=v=>/^[A-Za-z0-9_-]{32,256}$/.test(v);' + + 'try{' + + 'const fd=fs.openSync(t,"wx",0o600);' + + 'try{fs.writeSync(fd,crypto.randomBytes(32).toString("base64url"));fs.fsyncSync(fd)}finally{fs.closeSync(fd)}' + + 'let published=false;try{fs.linkSync(t,p);published=true}catch(e){if(e.code!=="EEXIST")throw e}' + + 'if(published){let d;try{d=fs.openSync(path.dirname(p),"r");fs.fsyncSync(d)}finally{if(d!==undefined)fs.closeSync(d)}}' + + 'const value=fs.readFileSync(p,"utf8").trim();if(!valid(value))throw new Error("Invalid existing relay credential");' + + 'fs.chmodSync(p,0o600)' + + '}finally{try{fs.unlinkSync(t)}catch(e){if(e.code!=="ENOENT")throw e}}' + export function relayEndpointCredentialWriteCommand( hostPlatform: RemoteHostPlatform, nodePath: string, @@ -43,6 +56,48 @@ export function relayEndpointCredentialWriteCommand( ) } +export function relayEndpointCredentialEnsureCommand( + hostPlatform: RemoteHostPlatform, + nodePath: string, + credentialFile: string +): string { + if (!isWindowsRemoteHost(hostPlatform)) { + return `${shellEscape(nodePath)} -e ${shellEscape(POSIX_ENSURE_CREDENTIAL_SCRIPT)} ${shellEscape(credentialFile)}` + } + return powerShellCommand( + [ + '$ErrorActionPreference = "Stop"', + `$path = ${powerShellLiteral(credentialFile)}`, + '$valid = { param($value) $value -cmatch "^[A-Za-z0-9_-]{32,256}$" }', + '$identity = [System.Security.Principal.WindowsIdentity]::GetCurrent().User', + '$security = [System.Security.AccessControl.FileSecurity]::new()', + '$security.SetOwner($identity)', + '$security.SetAccessRuleProtection($true,$false)', + '$rule = [System.Security.AccessControl.FileSystemAccessRule]::new($identity,[System.Security.AccessControl.FileSystemRights]::FullControl,[System.Security.AccessControl.AccessControlType]::Allow)', + '$security.AddAccessRule($rule)', + 'if (Test-Path -LiteralPath $path -PathType Leaf) {', + '$existing = (Get-Content -LiteralPath $path -Raw -ErrorAction Stop).Trim()', + 'if (-not (& $valid $existing)) { throw "Invalid existing relay credential" }', + '} else {', + '$tempPath = $path + "." + [Guid]::NewGuid().ToString("N") + ".tmp"', + '$random = [byte[]]::new(32)', + '$rng = [System.Security.Cryptography.RandomNumberGenerator]::Create()', + 'try { $rng.GetBytes($random) } finally { $rng.Dispose() }', + '$credential = [Convert]::ToBase64String($random).TrimEnd("=").Replace("+","-").Replace("/","_")', + '$data = [System.Text.UTF8Encoding]::new($false).GetBytes($credential)', + '$stream = [System.IO.FileStream]::new($tempPath,[System.IO.FileMode]::CreateNew,[System.Security.AccessControl.FileSystemRights]::Write,[System.IO.FileShare]::None,4096,[System.IO.FileOptions]::WriteThrough,$security)', + 'try { $stream.Write($data,0,$data.Length) } finally { $stream.Dispose() }', + 'try {', + 'try { [System.IO.File]::Move($tempPath,$path) } catch [System.IO.IOException] { }', + '$existing = (Get-Content -LiteralPath $path -Raw -ErrorAction Stop).Trim()', + 'if (-not (& $valid $existing)) { throw "Invalid existing relay credential" }', + '} finally { [System.IO.File]::Delete($tempPath) }', + '}', + '(Get-Item -LiteralPath $path -ErrorAction Stop).SetAccessControl($security)' + ].join('; ') + ) +} + export async function writeRelayEndpointCredential( conn: SshConnection, hostPlatform: RemoteHostPlatform, @@ -59,3 +114,20 @@ export async function writeRelayEndpointCredential( } ) } + +export async function ensureRelayEndpointCredential( + conn: SshConnection, + hostPlatform: RemoteHostPlatform, + nodePath: string, + credentialFile: string, + options?: { signal?: AbortSignal } +): Promise { + await execCommand( + conn, + relayEndpointCredentialEnsureCommand(hostPlatform, nodePath, credentialFile), + { + wrapCommand: !isWindowsRemoteHost(hostPlatform), + signal: options?.signal + } + ) +} diff --git a/src/main/ssh/ssh-relay-gc-claim.ts b/src/main/ssh/ssh-relay-gc-claim.ts index d0c8cc1221f..606f517470b 100644 --- a/src/main/ssh/ssh-relay-gc-claim.ts +++ b/src/main/ssh/ssh-relay-gc-claim.ts @@ -15,6 +15,7 @@ import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' +import { relayGcClaimOwnerFileName } from '../../shared/relay-launch-fence-owner' const DEFAULT_REMOTE_HOST = getRemoteHostPlatform('linux-x64') const RELAY_GC_CLAIM_SUFFIX = '.gc-claim' @@ -71,26 +72,34 @@ export async function tryAcquireRelayGcClaim( signal?: AbortSignal ): Promise { const claimPath = relayGcClaimPath(remoteRelayDir) + const token = createRelayGcClaimOwnerToken() + const ownerFileName = relayGcClaimOwnerFileName(token) try { const created = await execHostCommand( conn, host, - tryCreateInstallLockCommand(host, claimPath), + tryCreateInstallLockCommand(host, claimPath, token, ownerFileName), signal ) if (created.trim().endsWith('OK')) { - return writeRelayGcClaimOwner(conn, remoteRelayDir, host, signal) + return writeRelayGcClaimOwner(conn, remoteRelayDir, token, host, signal) } const stolen = await execHostCommand( conn, host, - tryStealInstallLockCommand(host, claimPath, RELAY_GC_CLAIM_STALE_SECONDS), + tryStealInstallLockCommand( + host, + claimPath, + RELAY_GC_CLAIM_STALE_SECONDS, + token, + ownerFileName + ), signal ) if (!stolen.trim().endsWith('OK')) { return null } - return writeRelayGcClaimOwner(conn, remoteRelayDir, host, signal) + return writeRelayGcClaimOwner(conn, remoteRelayDir, token, host, signal) } catch { signal?.throwIfAborted() return null @@ -100,10 +109,10 @@ export async function tryAcquireRelayGcClaim( async function writeRelayGcClaimOwner( conn: SshConnection, remoteRelayDir: string, + token: string, host: RemoteHostPlatform, signal?: AbortSignal ): Promise { - const token = `${process.pid}-${Date.now()}-${randomUUID()}` const claimPath = relayGcClaimPath(remoteRelayDir) const ownerPath = joinRemotePath(host, claimPath, RELAY_GC_OWNER_NAME) const command = isWindowsRemoteHost(host) @@ -128,7 +137,11 @@ export async function isRelayGcClaimOwned( token: string, host: RemoteHostPlatform = DEFAULT_REMOTE_HOST ): Promise { - const ownerPath = joinRemotePath(host, relayGcClaimPath(remoteRelayDir), RELAY_GC_OWNER_NAME) + const ownerPath = joinRemotePath( + host, + relayGcClaimPath(remoteRelayDir), + relayGcClaimOwnerFileName(token) + ) const command = isWindowsRemoteHost(host) ? powerShellCommand( `if ((Get-Content -LiteralPath ${powerShellLiteral(ownerPath)} -Raw -ErrorAction SilentlyContinue) -ceq ${powerShellLiteral(token)}) { 'OWNED' } else { 'LOST' }` @@ -147,19 +160,19 @@ export async function releaseRelayGcClaim( host: RemoteHostPlatform = DEFAULT_REMOTE_HOST ): Promise { const claimPath = relayGcClaimPath(remoteRelayDir) - const ownerPath = joinRemotePath(host, claimPath, RELAY_GC_OWNER_NAME) + const ownerPath = joinRemotePath(host, claimPath, relayGcClaimOwnerFileName(token)) const command = isWindowsRemoteHost(host) ? powerShellCommand( `$claim = ${powerShellLiteral(claimPath)}; ` + `if (-not (Test-Path -LiteralPath $claim)) { 'RELEASED' } ` + `elseif ((Get-Content -LiteralPath ${powerShellLiteral(ownerPath)} -Raw -ErrorAction SilentlyContinue) -cne ${powerShellLiteral(token)}) { 'LOST' } ` + - 'else { try { Remove-Item -LiteralPath $claim -Recurse -Force -ErrorAction Stop } catch {}; ' + + `else { try { Remove-Item -LiteralPath ${powerShellLiteral(ownerPath)} -Force -ErrorAction Stop; Remove-Item -LiteralPath $claim -Recurse -Force -ErrorAction Stop } catch {}; ` + "if (Test-Path -LiteralPath $claim) { 'UNKNOWN' } else { 'RELEASED' } }" ) : [ `if ! test -e ${shellEscape(claimPath)}; then echo RELEASED;`, `elif test "$(cat ${shellEscape(ownerPath)} 2>/dev/null)" != ${shellEscape(token)}; then echo LOST;`, - `else ${removeRemoteTreeCommand(host, claimPath)} 2>/dev/null;`, + `else rm ${shellEscape(ownerPath)} 2>/dev/null && ${removeRemoteTreeCommand(host, claimPath)} 2>/dev/null;`, `if test -e ${shellEscape(claimPath)}; then echo UNKNOWN; else echo RELEASED; fi; fi` ].join(' ') const output = await execHostCommand(conn, host, command).catch(() => 'UNKNOWN') @@ -199,17 +212,24 @@ export async function waitForRelayGcClaimRelease( return } const claimPath = relayGcClaimPath(remoteRelayDir) + const token = createRelayGcClaimOwnerToken() const recovered = await execHostCommand( conn, host, - tryStealInstallLockCommand(host, claimPath, RELAY_GC_CLAIM_STALE_SECONDS), + tryStealInstallLockCommand( + host, + claimPath, + RELAY_GC_CLAIM_STALE_SECONDS, + token, + relayGcClaimOwnerFileName(token) + ), signal ).catch(() => 'BUSY') signal?.throwIfAborted() if (recovered.trim().endsWith('OK')) { - const token = await writeRelayGcClaimOwner(conn, remoteRelayDir, host, signal) - if (token) { - const release = await releaseRelayGcClaim(conn, remoteRelayDir, token, host) + const writtenToken = await writeRelayGcClaimOwner(conn, remoteRelayDir, token, host, signal) + if (writtenToken) { + const release = await releaseRelayGcClaim(conn, remoteRelayDir, writtenToken, host) if (release === 'released') { return } @@ -234,3 +254,7 @@ export async function waitForRelayGcClaimRelease( }) } } + +function createRelayGcClaimOwnerToken(): string { + return `gc-${process.pid}-${Date.now()}-${randomUUID()}` +} diff --git a/src/main/ssh/ssh-relay-gc-protection.ts b/src/main/ssh/ssh-relay-gc-protection.ts new file mode 100644 index 00000000000..73b46973804 --- /dev/null +++ b/src/main/ssh/ssh-relay-gc-protection.ts @@ -0,0 +1,16 @@ +import { remoteBasename, type RemoteHostPlatform } from './ssh-remote-platform' + +export function protectedRelayDirectoryNames( + options: { + protectedRelayDir?: string + protectedRelayDirs?: readonly string[] + }, + host: RemoteHostPlatform +): ReadonlySet { + return new Set( + [options.protectedRelayDir, ...(options.protectedRelayDirs ?? [])] + .filter((value): value is string => Boolean(value)) + .map((value) => remoteBasename(value, host)) + .map((value) => (host.pathFlavor === 'windows' ? value.toLowerCase() : value)) + ) +} diff --git a/src/main/ssh/ssh-relay-gc-retry.test.ts b/src/main/ssh/ssh-relay-gc-retry.test.ts index ae70ff68fa6..cd5ea195fa7 100644 --- a/src/main/ssh/ssh-relay-gc-retry.test.ts +++ b/src/main/ssh/ssh-relay-gc-retry.test.ts @@ -1,4 +1,6 @@ +import { EventEmitter } from 'node:events' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { makeSshTerminalAuthorityProcess } from './ssh-terminal-authority-process-fixture' vi.mock('electron', () => ({ app: { getAppPath: () => '/mock/app' } })) vi.mock('fs', () => ({ @@ -25,6 +27,22 @@ vi.mock('./ssh-relay-deploy-helpers', () => ({ vi.mock('./ssh-remote-node-resolution', () => ({ resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node') })) +vi.mock('./ssh-terminal-authority-discovery', () => ({ + sshTerminalAuthorityBootstrapReadCommand: vi.fn().mockReturnValue('READ_HOME_AND_AUTHORITY'), + parseSshTerminalAuthorityBootstrapRead: vi.fn(() => ({ + rawRemoteHome: '/home/user', + discovery: { status: 'absent' } + })) +})) +vi.mock('./ssh-terminal-authority-process', () => ({ + establishSshTerminalAuthority: vi.fn(async (options: { relayDir: string }) => + makeSshTerminalAuthorityProcess({ + remoteHome: '/home/user', + ownerBuildId: '0.1.0+gc-retry', + ownerRelayDir: options.relayDir + }) + ) +})) vi.mock('./ssh-relay-versioned-install', () => ({ readLocalFullVersion: vi.fn().mockReturnValue('0.1.0+gc-retry'), computeRemoteRelayDir: (home: string, version: string) => `${home}/.orca-remote/relay-${version}`, @@ -62,14 +80,16 @@ import { abandonInstall, isRelayAlreadyInstalled } from './ssh-relay-versioned-i import type { SshConnection } from './ssh-connection' function makeConnection(): SshConnection { - const channel = { - on: vi.fn(), - stderr: { on: vi.fn() }, + const channel = Object.assign(new EventEmitter(), { + stderr: new EventEmitter(), close: vi.fn() - } + }) return { canRunConcurrentExecCommands: vi.fn().mockReturnValue(true), - exec: vi.fn().mockResolvedValue(channel), + exec: vi.fn().mockImplementation(async () => { + setTimeout(() => channel.emit('close'), 0) + return channel + }), writeFile: vi.fn().mockResolvedValue(undefined) } as unknown as SshConnection } @@ -135,7 +155,7 @@ describe('relay GC deploy retry', () => { await deployAndLaunchRelay(conn) - expect(abandonInstall).toHaveBeenCalledTimes(2) + expect(abandonInstall).toHaveBeenCalledTimes(1) expect(waitForRelayGcClaimRelease).toHaveBeenCalledTimes(1) expect(isRelayAlreadyInstalled).toHaveBeenCalledTimes(4) expect(tryAcquireRelayRepairLock).toHaveBeenCalledTimes(2) @@ -183,8 +203,11 @@ describe('relay GC deploy retry', () => { expect(waitForRelayGcClaimRelease).not.toHaveBeenCalled() expect(tryAcquireRelayRepairLock).toHaveBeenCalledTimes(1) expect(tryAcquireRelayGcClaim).toHaveBeenCalledTimes(1) - expect(releaseRelayGcClaimWithRetry).toHaveBeenCalledTimes(1) + expect(releaseRelayGcClaimWithRetry).not.toHaveBeenCalled() expect(conn.exec).toHaveBeenCalledTimes(2) + expect(vi.mocked(conn.exec).mock.calls[0]?.[0]).toContain( + "'--release-launch-gc-claim-owner' 'launch-token'" + ) }) it('retains an owned install lock when launch never becomes live', async () => { @@ -274,6 +297,6 @@ describe('relay GC deploy retry', () => { expect(waitForRelayGcClaimRelease).toHaveBeenCalledTimes(2) expect(tryAcquireRelayGcClaim).toHaveBeenCalledTimes(3) - expect(releaseRelayGcClaimWithRetry).toHaveBeenCalledTimes(1) + expect(releaseRelayGcClaimWithRetry).not.toHaveBeenCalled() }) }) diff --git a/src/main/ssh/ssh-relay-install-lock-commands.ts b/src/main/ssh/ssh-relay-install-lock-commands.ts index d6eab561a2d..bae6619d205 100644 --- a/src/main/ssh/ssh-relay-install-lock-commands.ts +++ b/src/main/ssh/ssh-relay-install-lock-commands.ts @@ -1,6 +1,7 @@ import { shellEscape } from './ssh-connection-utils' import { powerShellCommand, powerShellLiteral } from './ssh-remote-powershell' import { isWindowsRemoteHost, type RemoteHostPlatform } from './ssh-remote-platform' +import { relayInstallLockOwnerFileName } from '../../shared/relay-launch-fence-owner' export function acquireInstallLockParentCommand( host: RemoteHostPlatform, @@ -14,9 +15,19 @@ export function acquireInstallLockParentCommand( ) } -export function tryCreateInstallLockCommand(host: RemoteHostPlatform, lockDir: string): string { +export function tryCreateInstallLockCommand( + host: RemoteHostPlatform, + lockDir: string, + ownerToken?: string, + ownerFileName = ownerToken ? relayInstallLockOwnerFileName(ownerToken) : undefined +): string { if (!isWindowsRemoteHost(host)) { - return `mkdir ${shellEscape(lockDir)} 2>&1 && echo OK || echo BUSY` + return ownerToken && ownerFileName + ? posixCreateOwnedLockCommand(lockDir, ownerToken, ownerFileName) + : `mkdir ${shellEscape(lockDir)} 2>&1 && echo OK || echo BUSY` + } + if (ownerToken && ownerFileName) { + return windowsCreateOwnedLockCommand(lockDir, ownerToken, ownerFileName) } // Why: old Orca clients recognize only a directory at `.install-lock`, while // concurrent New-Item calls can both report success in PowerShell 5.1. Keep @@ -65,15 +76,22 @@ export function lockAgeSecondsCommand(host: RemoteHostPlatform, lockDir: string) export function tryStealInstallLockCommand( host: RemoteHostPlatform, lockDir: string, - staleAfterSeconds: number + staleAfterSeconds: number, + ownerToken?: string, + ownerFileName = ownerToken ? relayInstallLockOwnerFileName(ownerToken) : undefined ): string { if (!isWindowsRemoteHost(host)) { - return posixStealInstallLockCommand(lockDir, staleAfterSeconds) + return posixStealInstallLockCommand(lockDir, staleAfterSeconds, ownerToken, ownerFileName) } - return windowsStealInstallLockCommand(lockDir, staleAfterSeconds) + return windowsStealInstallLockCommand(lockDir, staleAfterSeconds, ownerToken, ownerFileName) } -function posixStealInstallLockCommand(lockDir: string, staleAfterSeconds: number): string { +function posixStealInstallLockCommand( + lockDir: string, + staleAfterSeconds: number, + ownerToken?: string, + ownerFileName?: string +): string { const escapedLockDir = shellEscape(lockDir) const escapedStealLockPrefix = shellEscape(`${lockDir}.steal`) return [ @@ -96,13 +114,18 @@ function posixStealInstallLockCommand(lockDir: string, staleAfterSeconds: number `${posixLockIdentityAssignment(lockDir, 'current_key')} && current_mtime=\${current_key%%:*} && current_now=$(date +%s) && current_age=$((current_now - current_mtime)) || current_age=0;`, `if [ "$current_key" = "$lock_key" ] && [ "\${current_age:-0}" -gt ${staleAfterSeconds} ] 2>/dev/null; then`, `lock_tombstone=${escapedLockDir}.tombstone.$$.$(date +%s);`, - `if [ ! -e "$lock_tombstone" ] && mv ${escapedLockDir} "$lock_tombstone" 2>/dev/null; then mkdir ${escapedLockDir} 2>&1 && echo OK || echo BUSY; else echo BUSY; fi;`, + `if [ ! -e "$lock_tombstone" ] && mv ${escapedLockDir} "$lock_tombstone" 2>/dev/null; then ${ownerToken && ownerFileName ? posixCreateOwnedLockCommand(lockDir, ownerToken, ownerFileName) : `mkdir ${escapedLockDir} 2>&1 && echo OK || echo BUSY`}; else echo BUSY; fi;`, 'else echo BUSY; fi;', 'else echo BUSY; fi; fi' ].join(' ') } -function windowsStealInstallLockCommand(lockDir: string, staleAfterSeconds: number): string { +function windowsStealInstallLockCommand( + lockDir: string, + staleAfterSeconds: number, + ownerToken?: string, + ownerFileName?: string +): string { return powerShellCommand( [ `$lock = ${powerShellLiteral(lockDir)}`, @@ -144,7 +167,7 @@ function windowsStealInstallLockCommand(lockDir: string, staleAfterSeconds: numb '$lockTombstone = "$lock.tombstone.$PID.$([DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds())"', 'Move-Item -LiteralPath $lock -Destination $lockTombstone -ErrorAction Stop', '$successorStream = $null', - "try { $null = New-Item -ItemType Directory -Path $lock -ErrorAction Stop; $successorOwner = Join-Path $lock '.owner'; $successorStream = [System.IO.File]::Open($successorOwner, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None); 'OK' } catch { 'BUSY' } finally { if ($null -ne $successorStream) { $successorStream.Dispose() } }", + windowsSuccessorLockScript(ownerToken, ownerFileName), "} else { 'BUSY' }", '}', "} catch { 'BUSY' } finally {", @@ -161,6 +184,53 @@ function windowsStealInstallLockCommand(lockDir: string, staleAfterSeconds: numb ) } +function posixCreateOwnedLockCommand( + lockDir: string, + ownerToken: string, + ownerFileName: string +): string { + const escapedLockDir = shellEscape(lockDir) + const ownerPath = `${lockDir}/${ownerFileName}` + return [ + `if mkdir ${escapedLockDir} 2>/dev/null; then`, + `if (umask 077 && printf %s ${shellEscape(ownerToken)} > ${shellEscape(ownerPath)}); then echo OK;`, + `else rmdir ${escapedLockDir} 2>/dev/null || true; echo BUSY; fi;`, + 'else echo BUSY; fi' + ].join(' ') +} + +function windowsCreateOwnedLockCommand( + lockDir: string, + ownerToken: string, + ownerFileName: string +): string { + return powerShellCommand( + [ + `$lock = ${powerShellLiteral(lockDir)}`, + '$stream = $null', + '$ownsLock = $false', + 'try {', + "if (Test-Path -LiteralPath $lock) { 'BUSY' } else {", + '$null = New-Item -ItemType Directory -Path $lock -ErrorAction Stop', + "$owner = Join-Path $lock '.owner'", + '$stream = [System.IO.File]::Open($owner, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None)', + '$ownsLock = $true', + `Set-Content -LiteralPath (Join-Path $lock ${powerShellLiteral(ownerFileName)}) -Value ${powerShellLiteral(ownerToken)} -NoNewline -ErrorAction Stop`, + "'OK'", + '}', + "} catch { if ($ownsLock) { Remove-Item -LiteralPath $lock -Recurse -Force -ErrorAction SilentlyContinue }; 'BUSY' } finally { if ($null -ne $stream) { $stream.Dispose() } }" + ].join('; ') + ) +} + +function windowsSuccessorLockScript(ownerToken?: string, ownerFileName?: string): string { + const ownerWrite = + ownerToken && ownerFileName + ? `Set-Content -LiteralPath (Join-Path $lock ${powerShellLiteral(ownerFileName)}) -Value ${powerShellLiteral(ownerToken)} -NoNewline -ErrorAction Stop; ` + : '' + return `try { $null = New-Item -ItemType Directory -Path $lock -ErrorAction Stop; $successorOwner = Join-Path $lock '.owner'; $successorStream = [System.IO.File]::Open($successorOwner, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None); ${ownerWrite}'OK' } catch { 'BUSY' } finally { if ($null -ne $successorStream) { $successorStream.Dispose() } }` +} + function posixLockAgeSecondsAssignment(lockDir: string): string { return `${posixLockMtimeSecondsAssignment(lockDir, 'mtime')} && now=$(date +%s) && age=$((now - mtime))` } diff --git a/src/main/ssh/ssh-relay-install-lock.ts b/src/main/ssh/ssh-relay-install-lock.ts index 0245875e141..b373d1385cc 100644 --- a/src/main/ssh/ssh-relay-install-lock.ts +++ b/src/main/ssh/ssh-relay-install-lock.ts @@ -1,3 +1,4 @@ +import { createRelayInstallLockOwnerToken } from '../../shared/relay-launch-fence-owner' import type { SshConnection } from './ssh-connection' import { execCommand, isUnconfirmedSshCommandTermination } from './ssh-relay-deploy-helpers' import { RELAY_DEPLOY_TIMEOUT_MS } from './ssh-relay-deploy-timing' @@ -68,9 +69,10 @@ export async function acquireInstallLock( conn: SshConnection, remoteRelayDir: string, host: RemoteHostPlatform = DEFAULT_REMOTE_HOST, - options?: { signal?: AbortSignal } + options?: { signal?: AbortSignal; ownerToken?: string } ): Promise { const lockDir = joinRemotePath(host, remoteRelayDir, RELAY_INSTALL_LOCK_NAME) + const ownerToken = options?.ownerToken ?? createRelayInstallLockOwnerToken() const start = Date.now() let lastStaleCheckAt = Number.NEGATIVE_INFINITY @@ -83,9 +85,12 @@ export async function acquireInstallLock( signal: options?.signal }) try { - const result = await execHostCommand(conn, host, tryCreateInstallLockCommand(host, lockDir), { - signal: options?.signal - }) + const result = await execHostCommand( + conn, + host, + tryCreateInstallLockCommand(host, lockDir, ownerToken), + { signal: options?.signal } + ) if (result.trim().endsWith('OK')) { // Why: GC may claim the sibling path between our first probe and lock // creation. Recheck while holding the in-tree lock; one side backs off. @@ -116,7 +121,7 @@ export async function acquireInstallLock( const steal = await execHostCommand( conn, host, - tryStealInstallLockCommand(host, lockDir, INSTALL_LOCK_STALE_SECONDS), + tryStealInstallLockCommand(host, lockDir, INSTALL_LOCK_STALE_SECONDS, ownerToken), { signal: options?.signal } ).catch(() => 'BUSY') options?.signal?.throwIfAborted() diff --git a/src/main/ssh/ssh-relay-live-socket.ts b/src/main/ssh/ssh-relay-live-socket.ts new file mode 100644 index 00000000000..6e7c86b9007 --- /dev/null +++ b/src/main/ssh/ssh-relay-live-socket.ts @@ -0,0 +1,41 @@ +import type { SshConnection } from './ssh-connection' +import { execCommand } from './ssh-relay-deploy-helpers' +import { windowsRelayPipePathsForSocketName } from './ssh-relay-endpoints' +import { relayLivenessProbeCommand } from './ssh-remote-commands' +import { + getRemoteHostPlatform, + isWindowsRemoteHost, + type RemoteHostPlatform +} from './ssh-remote-platform' + +const DEFAULT_REMOTE_HOST = getRemoteHostPlatform('linux-x64') + +export async function hasLiveRelaySocket( + conn: SshConnection, + dir: string, + host: RemoteHostPlatform = DEFAULT_REMOTE_HOST, + options?: { + windowsNodePath?: string + windowsSockNames?: string[] + } +): Promise { + try { + // Why: `test -S` only — a connect-and-close probe would race with a daemon about to idle. + const windowsOptions = + isWindowsRemoteHost(host) && options?.windowsNodePath + ? { + nodePath: options.windowsNodePath, + pipePaths: (options.windowsSockNames ?? []).flatMap((sockName) => + windowsRelayPipePathsForSocketName(host, dir, sockName) + ) + } + : undefined + const out = await execCommand(conn, relayLivenessProbeCommand(host, dir, windowsOptions), { + wrapCommand: host.commandDialect !== 'powershell' + }) + return !['DEAD', 'WAITING'].includes(out.trim()) + } catch { + // Why: an inconclusive liveness probe must never authorize deletion. + return true + } +} diff --git a/src/main/ssh/ssh-relay-native-deps-install-fixture.ts b/src/main/ssh/ssh-relay-native-deps-install-fixture.ts index e8fc2274583..41ca478049f 100644 --- a/src/main/ssh/ssh-relay-native-deps-install-fixture.ts +++ b/src/main/ssh/ssh-relay-native-deps-install-fixture.ts @@ -39,15 +39,20 @@ export function makeMockConnection(capture: SftpWriteCapture): SshConnection { end: vi.fn(() => setTimeout(() => sftp.emit('close'), 0)) }) } - return { - canRunConcurrentExecCommands: vi.fn().mockReturnValue(false), - exec: vi.fn().mockResolvedValue({ - on: vi.fn(), - stderr: { on: vi.fn() }, + const exec = vi.fn().mockImplementation(async () => { + const channel = new EventEmitter() + const result = Object.assign(channel, { + stderr: new EventEmitter(), stdin: {}, stdout: { on: vi.fn() }, close: vi.fn() - }), + }) + setTimeout(() => channel.emit('close'), 0) + return result + }) + return { + canRunConcurrentExecCommands: vi.fn().mockReturnValue(false), + exec, sftp: vi.fn().mockImplementation(() => Promise.resolve(sftpCreate())) } as unknown as SshConnection } diff --git a/src/main/ssh/ssh-relay-native-deps-install-staged-upload.test.ts b/src/main/ssh/ssh-relay-native-deps-install-staged-upload.test.ts index 4d90a7c8289..21cc7feeafb 100644 --- a/src/main/ssh/ssh-relay-native-deps-install-staged-upload.test.ts +++ b/src/main/ssh/ssh-relay-native-deps-install-staged-upload.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { makeSshTerminalAuthorityProcess } from './ssh-terminal-authority-process-fixture' import type * as RelayInstallMarkerModule from './ssh-relay-install-marker' vi.mock('electron', () => ({ @@ -64,6 +65,24 @@ vi.mock('./ssh-relay-gc-claim', () => ({ waitForRelayGcClaimRelease: vi.fn().mockResolvedValue(undefined) })) +vi.mock('./ssh-terminal-authority-discovery', () => ({ + sshTerminalAuthorityBootstrapReadCommand: vi.fn().mockReturnValue('echo $HOME'), + parseSshTerminalAuthorityBootstrapRead: vi.fn((output: string) => ({ + rawRemoteHome: output, + discovery: { status: 'absent' } + })) +})) + +vi.mock('./ssh-terminal-authority-process', () => ({ + establishSshTerminalAuthority: vi.fn(async (options: { relayDir: string }) => + makeSshTerminalAuthorityProcess({ + remoteHome: '/home/user', + ownerBuildId: '0.1.0+testhash', + ownerRelayDir: options.relayDir + }) + ) +})) + vi.mock('./ssh-connection-utils', () => ({ shellEscape: (s: string) => `'${s}'` })) diff --git a/src/main/ssh/ssh-relay-native-deps-install.test.ts b/src/main/ssh/ssh-relay-native-deps-install.test.ts index 5297da7c85a..fbfb9cbf6dc 100644 --- a/src/main/ssh/ssh-relay-native-deps-install.test.ts +++ b/src/main/ssh/ssh-relay-native-deps-install.test.ts @@ -1,6 +1,7 @@ // Why: regression coverage for the install-probe contract — the "node-pty is not available" bug shipped because every guard layer was silent. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { makeSshTerminalAuthorityProcess } from './ssh-terminal-authority-process-fixture' import type * as RelayInstallMarkerModule from './ssh-relay-install-marker' vi.mock('electron', () => ({ @@ -66,6 +67,24 @@ vi.mock('./ssh-relay-gc-claim', () => ({ waitForRelayGcClaimRelease: vi.fn().mockResolvedValue(undefined) })) +vi.mock('./ssh-terminal-authority-discovery', () => ({ + sshTerminalAuthorityBootstrapReadCommand: vi.fn().mockReturnValue('echo $HOME'), + parseSshTerminalAuthorityBootstrapRead: vi.fn((output: string) => ({ + rawRemoteHome: output, + discovery: { status: 'absent' } + })) +})) + +vi.mock('./ssh-terminal-authority-process', () => ({ + establishSshTerminalAuthority: vi.fn(async (options: { relayDir: string }) => + makeSshTerminalAuthorityProcess({ + remoteHome: '/home/u', + ownerBuildId: '0.1.0+testhash', + ownerRelayDir: options.relayDir + }) + ) +})) + vi.mock('./ssh-connection-utils', () => ({ shellEscape: (s: string) => `'${s}'` })) @@ -311,7 +330,7 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { expect(warnMessages.some((m) => m.includes('[ssh-relay][NPTY-MISSING]'))).toBe(true) expect(vi.mocked(finalizeInstall)).toHaveBeenCalledTimes(1) - expect(vi.mocked(abandonInstall)).toHaveBeenCalledTimes(1) + expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled() }) it('rebuilds unloadable native deps and recovers before first relay launch', async () => { @@ -476,9 +495,9 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { expect(chmodPrebuildsIdx).toBeGreaterThan(npmIdx) expect(probeIdx).toBeGreaterThan(chmodPrebuildsIdx) - // Hold the install lock through launch, then release it exactly once. + // Launch-fence ownership releases the install lock after daemon liveness is visible. expect(vi.mocked(finalizeInstall)).toHaveBeenCalledTimes(1) - expect(vi.mocked(abandonInstall)).toHaveBeenCalledTimes(1) + expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled() }) it('matches the sentinel even with bashrc/MOTD noise prefixed to probe stdout', async () => { @@ -515,7 +534,7 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) expect(warnMessages.some((m) => m.includes('[ssh-relay][NPTY-MISSING]'))).toBe(true) expect(vi.mocked(finalizeInstall)).toHaveBeenCalledTimes(1) - expect(vi.mocked(abandonInstall)).toHaveBeenCalledTimes(1) + expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled() }) it('keeps Windows node-pty probe failures non-fatal by checking LASTEXITCODE', async () => { @@ -574,7 +593,7 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) expect(warnMessages.some((m) => m.includes('[ssh-relay][NPTY-MISSING]'))).toBe(true) expect(vi.mocked(finalizeInstall)).toHaveBeenCalledTimes(1) - expect(vi.mocked(abandonInstall)).toHaveBeenCalledTimes(1) + expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled() }) it('includes the platform tuple in NPTY-MISSING and native install failure logs', async () => { @@ -690,7 +709,7 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { await deployAndLaunchRelay(conn) expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() - expect(vi.mocked(abandonInstall)).toHaveBeenCalledTimes(1) + expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled() const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) expect(warnMessages.some((m) => m.includes('launching degraded'))).toBe(true) }) @@ -902,7 +921,7 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { expect(vi.mocked(acquireInstallLock)).not.toHaveBeenCalled() expect(vi.mocked(tryAcquireRelayRepairLock)).toHaveBeenCalledTimes(1) expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() - expect(vi.mocked(abandonInstall)).toHaveBeenCalledTimes(1) + expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled() const execCalls = vi.mocked(execCommand).mock.calls.map(([, c]) => c) expect(execCalls.some((c) => c.includes('npm install'))).toBe(false) }) diff --git a/src/main/ssh/ssh-relay-orphan-abandon-paths.test.ts b/src/main/ssh/ssh-relay-orphan-abandon-paths.test.ts index 9d08ac7da0f..963495f93f1 100644 --- a/src/main/ssh/ssh-relay-orphan-abandon-paths.test.ts +++ b/src/main/ssh/ssh-relay-orphan-abandon-paths.test.ts @@ -4,12 +4,18 @@ import { createMockDeps, mockDeploySuccess } from './ssh-relay-session-test-fixt const { muxRequestMock, openConsumerSessionMock } = vi.hoisted(() => ({ muxRequestMock: vi.fn(), - openConsumerSessionMock: vi.fn(async (_mux: unknown, options: { clientInstanceId: string }) => ({ - clientInstanceId: options.clientInstanceId, - clientGeneration: 1, - ownerGeneration: 1, - ownerLease: 'test-owner-lease' - })) + openConsumerSessionMock: vi.fn( + async (_mux: unknown, options: { clientInstanceId: string; resume?: unknown }) => ({ + state: { + mode: 'negotiated' as const, + clientInstanceId: options.clientInstanceId, + clientGeneration: 1, + ownerGeneration: 1, + ownerLease: 'test-owner-lease' + }, + resumed: options.resume !== undefined + }) + ) })) vi.mock('./ssh-relay-deploy', () => ({ deployAndLaunchRelay: vi.fn() })) @@ -69,6 +75,7 @@ vi.mock('../providers/ssh-git-provider', () => ({ SshGitProvider: class MockSshGitProvider {} })) vi.mock('../ipc/pty', () => ({ + waitForSshPtyPendingCloseReplay: vi.fn().mockResolvedValue(undefined), registerSshPtyProvider: vi.fn(), unregisterSshPtyProvider: vi.fn(), getSshPtyProvider: vi.fn(), diff --git a/src/main/ssh/ssh-relay-repair-lock.ts b/src/main/ssh/ssh-relay-repair-lock.ts index 01d58e390e1..2807049daf3 100644 --- a/src/main/ssh/ssh-relay-repair-lock.ts +++ b/src/main/ssh/ssh-relay-repair-lock.ts @@ -14,6 +14,7 @@ import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' +import { createRelayInstallLockOwnerToken } from '../../shared/relay-launch-fence-owner' import { INSTALL_LOCK_STALE_SECONDS, RELAY_INSTALL_LOCK_NAME } from './ssh-relay-install-lock' import { removeRemoteTreeCommand } from './ssh-remote-commands' @@ -40,9 +41,10 @@ export async function tryAcquireRelayRepairLock( conn: SshConnection, remoteRelayDir: string, host: RemoteHostPlatform = DEFAULT_REMOTE_HOST, - options?: { signal?: AbortSignal } + options?: { signal?: AbortSignal; ownerToken?: string } ): Promise { const lockDir = joinRemotePath(host, remoteRelayDir, RELAY_INSTALL_LOCK_NAME) + const ownerToken = options?.ownerToken ?? createRelayInstallLockOwnerToken() try { const gcClaimedBeforeAcquire = await isRelayGcClaimed( conn, @@ -66,7 +68,7 @@ export async function tryAcquireRelayRepairLock( const firstAttempt = await execHostCommand( conn, host, - tryCreateInstallLockCommand(host, lockDir), + tryCreateInstallLockCommand(host, lockDir, ownerToken), options?.signal ) if (firstAttempt.trim().endsWith('OK')) { @@ -75,7 +77,7 @@ export async function tryAcquireRelayRepairLock( const steal = await execHostCommand( conn, host, - tryStealInstallLockCommand(host, lockDir, INSTALL_LOCK_STALE_SECONDS), + tryStealInstallLockCommand(host, lockDir, INSTALL_LOCK_STALE_SECONDS, ownerToken), options?.signal ) if (steal.trim().endsWith('OK')) { diff --git a/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts b/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts index f54afef3f89..d028755bef2 100644 --- a/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts +++ b/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts @@ -15,7 +15,7 @@ import { import { agentHookServer, _internals as agentHookInternals } from '../agent-hooks/server' import { getSshPtyProvider } from '../ipc/pty' import { toAppSshPtyId } from '../providers/ssh-pty-id' -import { DEFAULT_PTY_SOURCE_WINDOW_SU } from '../../shared/pty-source-credit-contract' +import { sshPtyOpenClientGrant } from './ssh-pty-consumer-session-grant-fixture' const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({ getCohortAtEmitMock: vi.fn(), @@ -37,6 +37,7 @@ vi.mock('./ssh-relay-deploy', () => ({ const { deployAndLaunchRelay } = await import('./ssh-relay-deploy') const { SshRelaySession } = await import('./ssh-relay-session') +const AUTHORITY_HOST_ID = 'test-authority-host' const SSH_LEAF_ID = '11111111-1111-4111-8111-111111111111' const REPLAY_LEAF_ID = '22222222-2222-4222-8222-222222222222' const BAD_LEAF_ID = '33333333-3333-4333-8333-333333333333' @@ -100,25 +101,16 @@ function createFakeRelay(): FakeRelay { }) relayFeed = (data) => dispatcher.feed(data) - dispatcher.onRequest('pty.openClient', async (params) => ({ - protocolVersion: 1, - serverBuildId: 'test-relay-build', - clientGeneration: 1, - role: 'session-owner', - ownerGeneration: - typeof (params.resume as { ownerGeneration?: unknown } | undefined)?.ownerGeneration === - 'number' - ? (params.resume as { ownerGeneration: number }).ownerGeneration + 1 - : 1, - ownerLease: 'test-owner-lease', - resumed: params.resume !== undefined, - capabilities: { - outputFlowControl: { version: 1, windowSu: DEFAULT_PTY_SOURCE_WINDOW_SU } - } - })) + dispatcher.onRequest('pty.openClient', async (params) => + sshPtyOpenClientGrant(params, { + serverBuildId: 'test-relay-build', + authorityHostId: AUTHORITY_HOST_ID + }) + ) dispatcher.onRequest('session.resolveHome', async (params) => ({ resolvedPath: params.path === '~' ? '/home/orca' : params.path })) + dispatcher.onRequest('relay.configureGraceTime', async () => ({})) dispatcher.onRequest('git.listWorktrees', async () => []) dispatcher.onRequest('ports.detect', async () => ({ ports: [], platform: 'linux' })) dispatcher.onRequest('pty.spawn', async (params) => { @@ -149,6 +141,18 @@ function createFakeRelay(): FakeRelay { } } +function fakeDeployResult(transport: MultiplexerTransport) { + return { + transport, + authorityHostId: AUTHORITY_HOST_ID, + terminalAuthorityOwnerBuildId: 'test-relay-build', + priorRelayStatus: { kind: 'none' as const }, + terminalAuthorityOwnerRelayDir: '/home/orca/.orca-relay/test-relay-build', + serverBuildId: 'test-relay-build', + platform: 'linux-x64' as const + } +} + function createSession(targetId: string): InstanceType { const store = { getRepos: vi.fn().mockReturnValue([]), @@ -246,11 +250,7 @@ describe('SshRelaySession agent hooks over a fake relay transport', () => { it('establishes through a fake relay, spawns a remote PTY, and forwards agent status', async () => { relay = createFakeRelay() - vi.mocked(deployAndLaunchRelay).mockResolvedValue({ - transport: relay.transport, - serverBuildId: 'test-relay-build', - platform: 'linux-x64' - }) + vi.mocked(deployAndLaunchRelay).mockResolvedValue(fakeDeployResult(relay.transport)) const events: CapturedStatus[] = [] captureAgentStatuses(events) @@ -300,11 +300,7 @@ describe('SshRelaySession agent hooks over a fake relay transport', () => { it('stamps SSH ownership and settles only the exact manual compact identity', async () => { relay = createFakeRelay() - vi.mocked(deployAndLaunchRelay).mockResolvedValue({ - transport: relay.transport, - serverBuildId: 'test-relay-build', - platform: 'linux-x64' - }) + vi.mocked(deployAndLaunchRelay).mockResolvedValue(fakeDeployResult(relay.transport)) const events: CapturedStatus[] = [] captureAgentStatuses(events) session = createSession('conn-compact') @@ -364,16 +360,8 @@ describe('SshRelaySession agent hooks over a fake relay transport', () => { const initialRelay = createFakeRelay() relay = createFakeRelay() vi.mocked(deployAndLaunchRelay) - .mockResolvedValueOnce({ - transport: initialRelay.transport, - serverBuildId: 'test-relay-build', - platform: 'linux-x64' - }) - .mockResolvedValueOnce({ - transport: relay.transport, - serverBuildId: 'test-relay-build', - platform: 'linux-x64' - }) + .mockResolvedValueOnce(fakeDeployResult(initialRelay.transport)) + .mockResolvedValueOnce(fakeDeployResult(relay.transport)) const clearListener = vi.fn() agentHookServer.setPaneStatusClearListener(clearListener) session = createSession('conn-clear') @@ -411,11 +399,7 @@ describe('SshRelaySession agent hooks over a fake relay transport', () => { } }) ) - vi.mocked(deployAndLaunchRelay).mockResolvedValue({ - transport: relay.transport, - serverBuildId: 'test-relay-build', - platform: 'linux-x64' - }) + vi.mocked(deployAndLaunchRelay).mockResolvedValue(fakeDeployResult(relay.transport)) const events: CapturedStatus[] = [] captureAgentStatuses(events) @@ -439,11 +423,7 @@ describe('SshRelaySession agent hooks over a fake relay transport', () => { it('drops malformed remote hook notifications at Orca main before caching', async () => { relay = createFakeRelay() - vi.mocked(deployAndLaunchRelay).mockResolvedValue({ - transport: relay.transport, - serverBuildId: 'test-relay-build', - platform: 'linux-x64' - }) + vi.mocked(deployAndLaunchRelay).mockResolvedValue(fakeDeployResult(relay.transport)) const events: CapturedStatus[] = [] captureAgentStatuses(events) @@ -470,11 +450,7 @@ describe('SshRelaySession agent hooks over a fake relay transport', () => { it('preserves explicit-prompt metadata from remote hook notifications', async () => { relay = createFakeRelay() - vi.mocked(deployAndLaunchRelay).mockResolvedValue({ - transport: relay.transport, - serverBuildId: 'test-relay-build', - platform: 'linux-x64' - }) + vi.mocked(deployAndLaunchRelay).mockResolvedValue(fakeDeployResult(relay.transport)) session = createSession('conn-explicit-prompt') await session.establish({} as SshConnection) @@ -530,11 +506,7 @@ describe('SshRelaySession agent hooks over a fake relay transport', () => { it('forwards remote hook transition metadata into main ingest', async () => { relay = createFakeRelay() - vi.mocked(deployAndLaunchRelay).mockResolvedValue({ - transport: relay.transport, - serverBuildId: 'test-relay-build', - platform: 'linux-x64' - }) + vi.mocked(deployAndLaunchRelay).mockResolvedValue(fakeDeployResult(relay.transport)) const ingestSpy = vi.spyOn(agentHookServer, 'ingestRemote') session = createSession('conn-hook-metadata') @@ -587,11 +559,7 @@ describe('SshRelaySession agent hooks over a fake relay transport', () => { it('tracks prompt sent from live SSH agent hooks but not replayed hooks', async () => { relay = createFakeRelay() - vi.mocked(deployAndLaunchRelay).mockResolvedValue({ - transport: relay.transport, - serverBuildId: 'test-relay-build', - platform: 'linux-x64' - }) + vi.mocked(deployAndLaunchRelay).mockResolvedValue(fakeDeployResult(relay.transport)) session = createSession('conn-live-telemetry') await session.establish({} as SshConnection) @@ -635,11 +603,7 @@ describe('SshRelaySession agent hooks over a fake relay transport', () => { it('preserves replay metadata from remote hook notifications', async () => { relay = createFakeRelay() - vi.mocked(deployAndLaunchRelay).mockResolvedValue({ - transport: relay.transport, - serverBuildId: 'test-relay-build', - platform: 'linux-x64' - }) + vi.mocked(deployAndLaunchRelay).mockResolvedValue(fakeDeployResult(relay.transport)) session = createSession('conn-replay-marker') await session.establish({} as SshConnection) diff --git a/src/main/ssh/ssh-relay-session-data-delivery.test.ts b/src/main/ssh/ssh-relay-session-data-delivery.test.ts index 2eca6802f28..42075bc8fa0 100644 --- a/src/main/ssh/ssh-relay-session-data-delivery.test.ts +++ b/src/main/ssh/ssh-relay-session-data-delivery.test.ts @@ -1,6 +1,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { SshRelaySession } from './ssh-relay-session' -import { createMockDeps, mockDeploySuccess } from './ssh-relay-session-test-fixtures' +import { + createMockDeps, + legacyFallbackSession, + mockDeploySuccess +} from './ssh-relay-session-test-fixtures' const { acceptOutputDataMock, @@ -92,6 +96,7 @@ vi.mock('../providers/ssh-git-provider', () => ({ })) vi.mock('../ipc/pty', () => ({ + waitForSshPtyPendingCloseReplay: vi.fn().mockResolvedValue(undefined), registerSshPtyProvider: vi.fn(), unregisterSshPtyProvider: vi.fn(), getSshPtyProvider: vi.fn().mockReturnValue({ dispose: vi.fn() }), @@ -281,9 +286,9 @@ describe('SshRelaySession data delivery', () => { it('transfers negotiated owner recovery exactly once across an explicit detach', async () => { const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() vi.mocked(deployAndLaunchRelay).mockResolvedValue({ - transport: { write: vi.fn(), onData: vi.fn(), onClose: vi.fn() }, - platform: 'linux-x64', - serverBuildId: 'test-relay-build' + ...mockDeploySuccess(), + serverBuildId: 'control-relay-build', + terminalAuthorityOwnerBuildId: 'test-relay-build' }) const first = new SshRelaySession('recovery-target', getMainWindow, mockStore, mockPortForward) @@ -321,9 +326,9 @@ describe('SshRelaySession data delivery', () => { outputFlowControl: { version: 1, windowSu: 256 * 1024 } }) vi.mocked(deployAndLaunchRelay).mockResolvedValue({ - transport: { write: vi.fn(), onData: vi.fn(), onClose: vi.fn() }, - platform: 'linux-x64', - serverBuildId: 'test-relay-build' + ...mockDeploySuccess(), + serverBuildId: 'control-relay-build', + terminalAuthorityOwnerBuildId: 'test-relay-build' }) const session = new SshRelaySession(targetId, getMainWindow, mockStore, mockPortForward) @@ -351,9 +356,9 @@ describe('SshRelaySession data delivery', () => { const targetId = 'fresh-relay-retry' const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() vi.mocked(deployAndLaunchRelay).mockResolvedValue({ - transport: { write: vi.fn(), onData: vi.fn(), onClose: vi.fn() }, - platform: 'linux-x64', - serverBuildId: 'test-relay-build' + ...mockDeploySuccess(), + serverBuildId: 'control-relay-build', + terminalAuthorityOwnerBuildId: 'test-relay-build' }) vi.mocked(getSshPtyAcceptedSourceCheckpoints).mockReturnValue([ { @@ -541,11 +546,9 @@ describe('SshRelaySession data delivery', () => { }) it('keeps unoffered source metadata out of legacy intake', async () => { - openConsumerSessionMock.mockImplementationOnce(async (_mux, options) => ({ - mode: 'legacy-fallback', - clientInstanceId: options.clientInstanceId, - serverBuildId: 'test-relay-build' - })) + openConsumerSessionMock.mockImplementationOnce(async (_mux, options) => + legacyFallbackSession(options.clientInstanceId) + ) const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) await session.establish(mockConn) @@ -576,11 +579,9 @@ describe('SshRelaySession data delivery', () => { }) it('keeps same-build method-not-found fallback token-free', async () => { - openConsumerSessionMock.mockImplementationOnce(async (_mux, options) => ({ - mode: 'legacy-fallback', - clientInstanceId: options.clientInstanceId, - serverBuildId: 'test-relay-build' - })) + openConsumerSessionMock.mockImplementationOnce(async (_mux, options) => + legacyFallbackSession(options.clientInstanceId) + ) const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) diff --git a/src/main/ssh/ssh-relay-session-incarnation.test.ts b/src/main/ssh/ssh-relay-session-incarnation.test.ts index 129a98b3ebc..f6da77d97c6 100644 --- a/src/main/ssh/ssh-relay-session-incarnation.test.ts +++ b/src/main/ssh/ssh-relay-session-incarnation.test.ts @@ -10,10 +10,14 @@ const { acceptOutputExitMock, muxRequestMock } = vi.hoisted(() => ({ vi.mock('./ssh-relay-deploy', () => ({ deployAndLaunchRelay: vi.fn() })) vi.mock('./ssh-pty-consumer-session', () => ({ openSshPtyConsumerSession: vi.fn(async (_mux, options) => ({ - clientInstanceId: options.clientInstanceId, - clientGeneration: 1, - ownerGeneration: 1, - ownerLease: 'test-owner-lease' + state: { + mode: 'negotiated' as const, + clientInstanceId: options.clientInstanceId, + clientGeneration: 1, + ownerGeneration: 1, + ownerLease: 'test-owner-lease' + }, + resumed: options.resume !== undefined })) })) vi.mock('../ipc/ssh-pty-output-intake-registry', () => ({ @@ -67,6 +71,7 @@ vi.mock('../providers/ssh-git-provider', () => ({ SshGitProvider: class MockSshGitProvider {} })) vi.mock('../ipc/pty', () => ({ + waitForSshPtyPendingCloseReplay: vi.fn().mockResolvedValue(undefined), registerSshPtyProvider: vi.fn(), unregisterSshPtyProvider: vi.fn(), getSshPtyProvider: vi.fn().mockReturnValue({ dispose: vi.fn() }), diff --git a/src/main/ssh/ssh-relay-session-managed-hooks.test.ts b/src/main/ssh/ssh-relay-session-managed-hooks.test.ts index b0acfeb29e2..687b67d265e 100644 --- a/src/main/ssh/ssh-relay-session-managed-hooks.test.ts +++ b/src/main/ssh/ssh-relay-session-managed-hooks.test.ts @@ -49,9 +49,10 @@ vi.mock('../providers/ssh-git-provider', () => ({ SshGitProvider: class MockSshGitProvider {} })) vi.mock('../ipc/pty', () => ({ + waitForSshPtyPendingCloseReplay: vi.fn().mockResolvedValue(undefined), registerSshPtyProvider: vi.fn(), unregisterSshPtyProvider: vi.fn(), - getSshPtyProvider: vi.fn(), + getSshPtyProvider: vi.fn().mockReturnValue({}), getPtyIdsForConnection: vi.fn().mockReturnValue([]), clearPtyOwnershipForConnection: vi.fn(), clearProviderPtyState: vi.fn(), @@ -77,9 +78,12 @@ describe('SshRelaySession managed hooks', () => { vi.clearAllMocks() process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS = '1' openConsumerSessionMock.mockImplementation(async (_mux, options) => ({ - mode: 'legacy-fallback', - clientInstanceId: options.clientInstanceId, - serverBuildId: 'test-relay-build' + state: { + mode: 'legacy-fallback' as const, + clientInstanceId: options.clientInstanceId, + serverBuildId: 'test-relay-build' + }, + resumed: false })) mockDeploySuccess() }) diff --git a/src/main/ssh/ssh-relay-session-model-migration.test.ts b/src/main/ssh/ssh-relay-session-model-migration.test.ts index ae5db55e0cc..d316ab8713d 100644 --- a/src/main/ssh/ssh-relay-session-model-migration.test.ts +++ b/src/main/ssh/ssh-relay-session-model-migration.test.ts @@ -78,6 +78,7 @@ vi.mock('../providers/ssh-git-provider', () => ({ SshGitProvider: class MockSshGitProvider {} })) vi.mock('../ipc/pty', () => ({ + waitForSshPtyPendingCloseReplay: vi.fn().mockResolvedValue(undefined), registerSshPtyProvider: vi.fn(), unregisterSshPtyProvider: vi.fn(), getSshPtyProvider: vi.fn().mockReturnValue({ dispose: vi.fn() }), diff --git a/src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts b/src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts index c34c4298dd8..bd922cbf9e7 100644 --- a/src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts +++ b/src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts @@ -3,9 +3,11 @@ import { randomUUID } from 'node:crypto' import type * as NodeCrypto from 'node:crypto' import { SshRelaySession } from './ssh-relay-session' import { createMockDeps, mockDeploySuccess } from './ssh-relay-session-test-fixtures' +import type { SshPtyConsumerAdmission } from './ssh-pty-consumer-session' type MockMuxInstance = { requestHandlers: Map) => Promise> + notify: ReturnType } const { acceptOutputExitMock, muxRequestMock, openConsumerSessionMock, muxInstancesRaw } = @@ -15,12 +17,22 @@ const { acceptOutputExitMock, muxRequestMock, openConsumerSessionMock, muxInstan openConsumerSessionMock: vi.fn( async ( _mux: unknown, - options: { clientInstanceId: string; outputFlowControl?: unknown } - ) => ({ - clientInstanceId: options.clientInstanceId, - clientGeneration: 1, - ownerGeneration: 1, - ownerLease: 'test-owner-lease' + options: { + clientInstanceId: string + outputFlowControl?: unknown + exactOperations?: unknown + terminalAuthorityTopology?: unknown + } + ): Promise => ({ + state: { + mode: 'negotiated', + clientInstanceId: options.clientInstanceId, + clientGeneration: 1, + ownerGeneration: 1, + ownerLease: 'test-owner-lease', + terminalAuthorityTopology: { version: 1 } + }, + resumed: false }) ), muxInstancesRaw: [] as unknown[] @@ -100,6 +112,7 @@ vi.mock('../providers/ssh-git-provider', () => ({ SshGitProvider: class MockSshGitProvider {} })) vi.mock('../ipc/pty', () => ({ + waitForSshPtyPendingCloseReplay: vi.fn().mockResolvedValue(undefined), registerSshPtyProvider: vi.fn(), unregisterSshPtyProvider: vi.fn(), getSshPtyProvider: vi.fn(), @@ -124,6 +137,7 @@ vi.mock('../providers/ssh-git-dispatch', () => ({ const { registerSshPtyProvider, + unregisterSshPtyProvider, getSshPtyProvider, getPtyIdsForConnection, setPtyOwnership, @@ -133,15 +147,18 @@ const { deployAndLaunchRelay } = await import('./ssh-relay-deploy') const APP_PTY_ID = 'ssh:target-1@@pty-live' const INCARNATION_LEAF_ID = '11111111-1111-4111-8111-111111111111' +let registeredPtyProvider: ReturnType function detachedLease() { return { targetId: 'target-1', ptyId: 'pty-live', + incarnationId: 'incarnation-from-lease', state: 'detached' as const, worktreeId: 'worktree-1', tabId: 'tab-1', - leafId: INCARNATION_LEAF_ID + leafId: INCARNATION_LEAF_ID, + paneGeneration: 4 } } @@ -178,6 +195,14 @@ describe('SshRelaySession reconnect incarnation ordering', () => { vi.mocked(randomUUID).mockReturnValue('00000000-0000-4000-8000-000000000001') mockDeploySuccess() vi.mocked(getPtyIdsForConnection).mockReturnValue([]) + registeredPtyProvider = undefined + vi.mocked(registerSshPtyProvider).mockImplementation((_targetId, provider) => { + registeredPtyProvider = provider + }) + vi.mocked(unregisterSshPtyProvider).mockImplementation(() => { + registeredPtyProvider = undefined + }) + vi.mocked(getSshPtyProvider).mockImplementation(() => registeredPtyProvider) }) it('bounds fifty reattaches to eight workers without slow or failed sibling head-of-line delay', async () => { @@ -271,9 +296,127 @@ describe('SshRelaySession reconnect incarnation ordering', () => { expect(openConsumerSessionMock).toHaveBeenCalledTimes(2) for (const [, options] of openConsumerSessionMock.mock.calls) { expect(options.outputFlowControl).toBeDefined() + expect(options.exactOperations).toBe(true) + expect(options.terminalAuthorityTopology).toBe(true) } }) + it('starts topology only for an exact namespace attached by authority resolution', async () => { + const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() + const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) + const states: { kind: string }[] = [] + const namespace = { authorityHostId: 'test-authority-host', namespaceId: 'namespace-a' } + const detach = session.attachResolvedNamespace(namespace, (state) => states.push(state)) + muxRequestMock.mockImplementation( + async (method: string, params?: Record): Promise => { + if (method !== 'terminalAuthority.topologySnapshot') { + return [] + } + return { + protocolVersion: 1, + subscriptionId: params?.subscriptionId, + streamIncarnationId: 'stream-a', + namespace, + writerEpoch: 1, + authorityRevision: 0, + appliedChangeSequence: 0, + panes: [], + namespaceRecoveryNotices: { version: 1, revision: 0, notices: [] } + } + } + ) + + await session.establish(mockConn) + + expect(states.at(-1)?.kind).toBe('authoritative') + expect(muxRequestMock).toHaveBeenCalledWith( + 'terminalAuthority.topologySnapshot', + expect.objectContaining({ namespace }), + expect.objectContaining({ signal: expect.any(AbortSignal) }) + ) + detach() + expect(muxInstances[0]?.notify).toHaveBeenCalledWith( + 'terminalAuthority.topologyUnsubscribe', + expect.objectContaining({ namespace }) + ) + }) + + it('keeps attached namespaces on legacy behavior when topology is not granted', async () => { + const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() + const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) + const states: { kind: string }[] = [] + session.attachResolvedNamespace( + { authorityHostId: 'test-authority-host', namespaceId: 'namespace-a' }, + (state) => states.push(state) + ) + openConsumerSessionMock.mockResolvedValueOnce({ + state: { + mode: 'negotiated', + clientInstanceId: 'client-a', + clientGeneration: 1, + ownerGeneration: 1, + ownerLease: 'owner-a' + }, + resumed: false + }) + + await session.establish(mockConn) + + expect(states.at(-1)?.kind).toBe('legacy-fallback') + expect(muxRequestMock).not.toHaveBeenCalledWith( + 'terminalAuthority.topologySnapshot', + expect.anything(), + expect.anything() + ) + }) + + it('keeps a committed namespace read-only when reconnecting to a peer without topology', async () => { + const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() + const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) + const states: { kind: string; reason?: string }[] = [] + const namespace = { authorityHostId: 'test-authority-host', namespaceId: 'namespace-a' } + session.attachResolvedNamespace(namespace, (state) => states.push(state)) + muxRequestMock.mockImplementation( + async (method: string, params?: Record): Promise => + method === 'terminalAuthority.topologySnapshot' + ? { + protocolVersion: 1, + subscriptionId: params?.subscriptionId, + streamIncarnationId: 'stream-a', + namespace, + writerEpoch: 1, + authorityRevision: 0, + appliedChangeSequence: 0, + panes: [], + namespaceRecoveryNotices: { version: 1, revision: 0, notices: [] } + } + : [] + ) + await session.establish(mockConn) + mockDeploySuccess() + openConsumerSessionMock.mockResolvedValueOnce({ + state: { + mode: 'negotiated', + clientInstanceId: 'client-a', + clientGeneration: 2, + ownerGeneration: 1, + ownerLease: 'owner-a' + }, + resumed: false + }) + + await session.reconnect(mockConn) + + const committedIndex = states.findIndex((state) => state.kind === 'authoritative') + expect(states.slice(committedIndex + 1)).toEqual([ + { kind: 'authority-unavailable', reason: 'disconnected' }, + { kind: 'authority-unavailable', reason: 'capability-not-granted' } + ]) + expect(states.slice(committedIndex + 1).some((state) => state.kind === 'legacy-fallback')).toBe( + false + ) + }) + it('rolls back a timed-out activation that resolves after its replacement commits', async () => { const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) @@ -327,7 +470,10 @@ describe('SshRelaySession reconnect incarnation ordering', () => { it('keeps the winning reconnect incarnation when a stale health check resolves last', async () => { const consumerInstanceId = '00000000-0000-4000-8000-000000000000' + const initialAttemptId = '00000000-0000-4000-8000-000000000010' const initialIncarnation = '00000000-0000-4000-8000-000000000001' + const staleAttemptId = '00000000-0000-4000-8000-000000000011' + const winningAttemptId = '00000000-0000-4000-8000-000000000012' const winningIncarnation = '00000000-0000-4000-8000-000000000002' const staleIncarnation = '00000000-0000-4000-8000-000000000003' let resolveStaleHealthCheck!: (value: unknown) => void @@ -344,7 +490,10 @@ describe('SshRelaySession reconnect incarnation ordering', () => { }) vi.mocked(randomUUID) .mockReturnValueOnce(consumerInstanceId) + .mockReturnValueOnce(initialAttemptId) .mockReturnValueOnce(initialIncarnation) + .mockReturnValueOnce(staleAttemptId) + .mockReturnValueOnce(winningAttemptId) .mockReturnValueOnce(winningIncarnation) .mockReturnValue(staleIncarnation) const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() @@ -370,11 +519,13 @@ describe('SshRelaySession reconnect incarnation ordering', () => { await vi.waitFor(() => expect(resolveHomeCalls).toBe(2)) await session.reconnect(mockConn) expect(session.getState()).toBe('ready') + const winningMux = session.getMux() as unknown as MockMuxInstance resolveStaleHealthCheck('/') await staleReconnect - const winningCliHandler = muxInstances[2]?.requestHandlers.get('orca.cli') + expect(session.getMux()).toBe(winningMux) + const winningCliHandler = winningMux.requestHandlers.get('orca.cli') expect(winningCliHandler).toBeDefined() await winningCliHandler?.({ argv: ['status'], cwd: '/', env: {} }) @@ -382,14 +533,15 @@ describe('SshRelaySession reconnect incarnation ordering', () => { 'target-1', winningIncarnation ) - expect(randomUUID).toHaveBeenCalledTimes(3) + expect(randomUUID).toHaveBeenCalledTimes(6) }) it('restores and persists exact incarnation proof from reconnect attach', async () => { const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() const incarnationId = 'incarnation-reconnect' + const attachForReconnect = vi.fn().mockResolvedValue({ incarnationId }) vi.mocked(getSshPtyProvider).mockReturnValue({ - attachForReconnect: vi.fn().mockResolvedValue({ incarnationId }), + attachForReconnect, dispose: vi.fn() } as unknown as ReturnType) vi.mocked(mockStore.getSshRemotePtyLeases).mockReturnValue([detachedLease()] as ReturnType< @@ -406,6 +558,13 @@ describe('SshRelaySession reconnect incarnation ordering', () => { await session.establish(mockConn) + expect(attachForReconnect).toHaveBeenCalledWith('pty-live', { + paneKey: `tab-1:${INCARNATION_LEAF_ID}`, + tabId: 'tab-1', + worktreeId: 'worktree-1', + paneGeneration: 4, + ptyIncarnationId: 'incarnation-from-lease' + }) expect(restorePtyIncarnation).toHaveBeenCalledWith(APP_PTY_ID, incarnationId) expect(runtime.registerPty).toHaveBeenCalledWith(APP_PTY_ID, 'worktree-1', 'target-1', { tabId: 'tab-1', diff --git a/src/main/ssh/ssh-relay-session-recovery-durability.test.ts b/src/main/ssh/ssh-relay-session-recovery-durability.test.ts index ed0136f9110..aecca439034 100644 --- a/src/main/ssh/ssh-relay-session-recovery-durability.test.ts +++ b/src/main/ssh/ssh-relay-session-recovery-durability.test.ts @@ -6,7 +6,7 @@ import { PTY_CONSUMER_OWNER_RECOVERY_SUPERSEDED_ERROR } from '../../shared/pty-consumer-session' import { SshRelaySession } from './ssh-relay-session' -import { createMockDeps } from './ssh-relay-session-test-fixtures' +import { createMockDeps, mockDeploySuccess } from './ssh-relay-session-test-fixtures' import { getSshPtyConsumerRecovery } from './ssh-pty-consumer-recovery' const { muxRequestMock, openConsumerSessionMock } = vi.hoisted(() => ({ @@ -77,6 +77,7 @@ vi.mock('../providers/ssh-git-provider', () => ({ })) vi.mock('../ipc/pty', () => ({ + waitForSshPtyPendingCloseReplay: vi.fn().mockResolvedValue(undefined), registerSshPtyProvider: vi.fn(), unregisterSshPtyProvider: vi.fn(), getSshPtyProvider: vi.fn().mockReturnValue({ dispose: vi.fn() }), @@ -117,9 +118,9 @@ describe('SshRelaySession consumer recovery durability', () => { resumed: false })) vi.mocked(deployAndLaunchRelay).mockResolvedValue({ - transport: { write: vi.fn(), onData: vi.fn(), onClose: vi.fn() }, - platform: 'linux-x64', - serverBuildId: 'test-relay-build' + ...mockDeploySuccess(), + serverBuildId: 'control-relay-build', + terminalAuthorityOwnerBuildId: 'test-relay-build' }) }) diff --git a/src/main/ssh/ssh-relay-session-recovery-races.test.ts b/src/main/ssh/ssh-relay-session-recovery-races.test.ts index 26dd798ac5a..7d9c45d6bbb 100644 --- a/src/main/ssh/ssh-relay-session-recovery-races.test.ts +++ b/src/main/ssh/ssh-relay-session-recovery-races.test.ts @@ -89,6 +89,7 @@ vi.mock('../providers/ssh-git-provider', () => ({ SshGitProvider: class MockSshGitProvider {} })) vi.mock('../ipc/pty', () => ({ + waitForSshPtyPendingCloseReplay: vi.fn().mockResolvedValue(undefined), registerSshPtyProvider: vi.fn(), unregisterSshPtyProvider: vi.fn(), getSshPtyProvider: vi.fn().mockReturnValue({ dispose: vi.fn() }), diff --git a/src/main/ssh/ssh-relay-session-relay-loss.test.ts b/src/main/ssh/ssh-relay-session-relay-loss.test.ts index 9e70466ab31..72d6a03cf29 100644 --- a/src/main/ssh/ssh-relay-session-relay-loss.test.ts +++ b/src/main/ssh/ssh-relay-session-relay-loss.test.ts @@ -4,11 +4,8 @@ import type { SshConnection } from './ssh-connection' import { SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD } from '../../shared/ssh-types' import { createMockDeps, mockDeploySuccess } from './ssh-relay-session-test-fixtures' -// #11953: the grace-time notify is the last thing establish()/reconnect() do -// before latching 'ready', and it can dispose the mux synchronously (writer -// admission cap / throwing transport). Latching 'ready' there left the status -// bar on "connected" with fs/pty/git providers bound to a dead mux and no -// relay-loss watcher, so nothing ever scheduled a reconnect. +// #11953: grace registration can dispose either mux synchronously (writer +// admission cap / throwing transport). Never latch ready around a dead pair. const { muxRequestMock, openConsumerSessionMock, registeredPtyProvider } = vi.hoisted(() => ({ muxRequestMock: vi.fn(), @@ -40,21 +37,23 @@ vi.mock('./ssh-relay-deploy-helpers', () => ({ })) // Mirrors the real multiplexer contract: dispose() latches, and a failed write -// during notify disposes the mux synchronously via handleProtocolError. +// during request disposes the mux synchronously via handleProtocolError. vi.mock('./ssh-channel-multiplexer', () => ({ SshChannelMultiplexer: class MockSshChannelMultiplexer { private disposed = false private disposeReason: string | null = null private disposeHandlers: ((reason: string) => void)[] = [] - /** Set by the test to kill the channel from inside notify(). */ - failNotifyMethod: string | null = null - notify = vi.fn((method: string) => { - if (method === this.failNotifyMethod) { + /** Set by the test to kill the channel from inside request(). */ + failRequestMethod: string | null = null + notify = vi.fn() + request = vi.fn(async (method: string, ...args: unknown[]) => { + if (method === this.failRequestMethod) { this.dispose('connection_lost') + throw new Error('SSH connection lost, reconnecting...') } + return await muxRequestMock(method, ...args) }) notifyWithSettlement = vi.fn() - request = muxRequestMock onNotification = vi.fn().mockReturnValue(() => {}) onNotificationByMethod = vi.fn().mockReturnValue(() => {}) onRequest = vi.fn().mockReturnValue(() => {}) @@ -107,6 +106,7 @@ vi.mock('../providers/ssh-git-provider', () => ({ SshGitProvider: class MockSshGitProvider {} })) vi.mock('../ipc/pty', () => ({ + waitForSshPtyPendingCloseReplay: vi.fn().mockResolvedValue(undefined), registerSshPtyProvider: vi.fn(), unregisterSshPtyProvider: vi.fn(), getSshPtyProvider: vi.fn().mockReturnValue(registeredPtyProvider), @@ -133,7 +133,7 @@ const { registerSshFilesystemProvider, unregisterSshFilesystemProvider } = const { getPtyIdsForConnection } = await import('../ipc/pty') describe('SshRelaySession relay loss during setup', () => { - /** Armed by a test so the *next* mux dies inside its grace-time notify. */ + /** Armed by a test so the control mux dies inside its grace-time request. */ let armGraceTimeFailure = false let armProviderRegistrationFailure = false @@ -145,9 +145,12 @@ describe('SshRelaySession relay loss during setup', () => { vi.mocked(getPtyIdsForConnection).mockReturnValue([]) registeredPtyProvider.attachForReconnect.mockReset().mockResolvedValue({}) openConsumerSessionMock.mockImplementation(async (_mux, options) => ({ - mode: 'legacy-fallback', - clientInstanceId: options.clientInstanceId, - serverBuildId: 'test-relay-build' + state: { + mode: 'legacy-fallback' as const, + clientInstanceId: options.clientInstanceId, + serverBuildId: 'test-relay-build' + }, + resumed: false })) mockDeploySuccess() }) @@ -164,14 +167,14 @@ describe('SshRelaySession relay loss during setup', () => { const onReady = vi.fn() session.setOnRelayLost(onRelayLost) session.setOnReady(onReady) - // Arm once the mux exists; every attempt issues requests before the notify. + // Arm once the pair exists; every attempt resolves home before registering grace. muxRequestMock.mockImplementation(async (method: string) => { const mux = session.getMux() as unknown as { - failNotifyMethod: string | null + failRequestMethod: string | null dispose: (reason: string) => void } | null if (mux && armGraceTimeFailure) { - mux.failNotifyMethod = SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD + mux.failRequestMethod = SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD } if (mux && armProviderRegistrationFailure && method === 'git.listWorktrees') { mux.dispose('connection_lost') @@ -187,10 +190,10 @@ describe('SshRelaySession relay loss during setup', () => { armGraceTimeFailure = true await expect(session.establish({} as SshConnection)).rejects.toThrow( - 'Relay connection lost during establish' + 'SSH connection lost, reconnecting...' ) - expect(registerSshFilesystemProvider).toHaveBeenCalledWith('target-1', expect.anything()) + expect(registerSshFilesystemProvider).not.toHaveBeenCalled() expect(session.getState()).not.toBe('ready') expect(onReady).not.toHaveBeenCalled() expect(unregisterSshFilesystemProvider).toHaveBeenCalledWith('target-1') @@ -232,6 +235,22 @@ describe('SshRelaySession relay loss during setup', () => { expect(unregisterSshFilesystemProvider).toHaveBeenCalledWith('target-1') }) + it('fences the provider stack and signals reconnect once when the gateway drops', async () => { + const { session, onRelayLost } = createSession() + await session.establish({} as SshConnection) + const mux = session.getMux() as unknown as { + dispose: (reason?: string) => void + isDisposed: () => boolean + } + + mux.dispose('connection_lost') + + expect(mux.isDisposed()).toBe(true) + expect(mux.dispose).toHaveBeenCalledTimes(1) + expect(onRelayLost).toHaveBeenCalledTimes(1) + expect(session.getMux()).toBeNull() + }) + // #11953: reattachKnownPtys swallows every per-PTY failure, so a mux killed by the // reattach burst itself never reaches the catch — the post-reattach gate has to notice. it('routes a mux that dies during PTY reattach into relay-loss recovery', async () => { diff --git a/src/main/ssh/ssh-relay-session-terminal-error.test.ts b/src/main/ssh/ssh-relay-session-terminal-error.test.ts index a0f855543ef..f8f48d9811a 100644 --- a/src/main/ssh/ssh-relay-session-terminal-error.test.ts +++ b/src/main/ssh/ssh-relay-session-terminal-error.test.ts @@ -17,10 +17,14 @@ vi.mock('./ssh-relay-deploy', () => ({ vi.mock('./ssh-pty-consumer-session', () => ({ openSshPtyConsumerSession: vi.fn(async (_mux, options) => ({ - clientInstanceId: options.clientInstanceId, - clientGeneration: 1, - ownerGeneration: 1, - ownerLease: 'test-owner-lease' + state: { + mode: 'negotiated' as const, + clientInstanceId: options.clientInstanceId, + clientGeneration: 1, + ownerGeneration: 1, + ownerLease: 'test-owner-lease' + }, + resumed: options.resume !== undefined })) })) @@ -65,6 +69,7 @@ vi.mock('../providers/ssh-git-provider', () => ({ })) vi.mock('../ipc/pty', () => ({ + waitForSshPtyPendingCloseReplay: vi.fn().mockResolvedValue(undefined), registerSshPtyProvider: vi.fn(), unregisterSshPtyProvider: vi.fn(), getSshPtyProvider: vi.fn().mockReturnValue({ @@ -128,6 +133,10 @@ function mockDeploySuccess(): void { } vi.mocked(deployAndLaunchRelay).mockResolvedValue({ transport: mockTransport, + authorityHostId: 'test-authority-host', + terminalAuthorityOwnerBuildId: 'test-authority-build', + priorRelayStatus: { kind: 'none' as const }, + terminalAuthorityOwnerRelayDir: '/test/authority-relay', platform: 'linux-x64' }) } diff --git a/src/main/ssh/ssh-relay-session-test-fixtures.ts b/src/main/ssh/ssh-relay-session-test-fixtures.ts index 48461af68ca..a43077bd944 100644 --- a/src/main/ssh/ssh-relay-session-test-fixtures.ts +++ b/src/main/ssh/ssh-relay-session-test-fixtures.ts @@ -4,7 +4,7 @@ import { PTY_CONSUMER_STALE_OWNER_RECOVERY_ERROR } from '../../shared/pty-consum import type { SshConnection } from './ssh-connection' import type { Store } from '../persistence' import type { SshPortForwardManager } from './ssh-port-forward' -import { deployAndLaunchRelay } from './ssh-relay-deploy' +import { deployAndLaunchRelay, type RelayDeployResult } from './ssh-relay-deploy' type SshRelaySessionTestDeps = { mockConn: SshConnection @@ -44,15 +44,21 @@ export function createMockDeps(): SshRelaySessionTestDeps { return { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } } -export function mockDeploySuccess(): void { - vi.mocked(deployAndLaunchRelay).mockResolvedValue({ +export function mockDeploySuccess(): RelayDeployResult { + const result: RelayDeployResult = { transport: { write: vi.fn(), onData: vi.fn(), onClose: vi.fn() }, + authorityHostId: 'test-authority-host', + terminalAuthorityOwnerBuildId: 'test-authority-build', + priorRelayStatus: { kind: 'none' as const }, + terminalAuthorityOwnerRelayDir: '/test/authority-relay', platform: 'linux-x64' - }) + } + vi.mocked(deployAndLaunchRelay).mockResolvedValue(result) + return result } export function createMismatchedOwnerRecoveryError(): unknown { @@ -60,3 +66,14 @@ export function createMismatchedOwnerRecoveryError(): unknown { code: PTY_CONSUMER_STALE_OWNER_RECOVERY_ERROR }) } + +export function legacyFallbackSession(clientInstanceId: string) { + return { + state: { + mode: 'legacy-fallback' as const, + clientInstanceId, + serverBuildId: 'test-relay-build' + }, + resumed: false + } +} diff --git a/src/main/ssh/ssh-relay-session.test.ts b/src/main/ssh/ssh-relay-session.test.ts index 570fca21fd4..14b8c398ab2 100644 --- a/src/main/ssh/ssh-relay-session.test.ts +++ b/src/main/ssh/ssh-relay-session.test.ts @@ -8,13 +8,21 @@ import { import { SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD } from '../../shared/ssh-types' import { createMockDeps, mockDeploySuccess } from './ssh-relay-session-test-fixtures' -const { acceptOutputDataMock, muxRequestMock, openConsumerSessionMock, pauseAdapterMock } = - vi.hoisted(() => ({ - acceptOutputDataMock: vi.fn().mockResolvedValue(undefined), - muxRequestMock: vi.fn(), - openConsumerSessionMock: vi.fn(), - pauseAdapterMock: vi.fn() - })) +const { + acceptOutputDataMock, + muxRequestMock, + openConsumerSessionMock, + pendingCloseReplayMock, + pauseAdapterMock, + ptyProviderConstructorMock +} = vi.hoisted(() => ({ + acceptOutputDataMock: vi.fn().mockResolvedValue(undefined), + muxRequestMock: vi.fn(), + openConsumerSessionMock: vi.fn(), + pendingCloseReplayMock: vi.fn().mockResolvedValue(undefined), + pauseAdapterMock: vi.fn(), + ptyProviderConstructorMock: vi.fn() +})) vi.mock('./ssh-relay-deploy', () => ({ deployAndLaunchRelay: vi.fn() @@ -40,10 +48,6 @@ vi.mock('../ipc/ssh-pty-output-intake-registry', () => ({ installSshPtySourceCancellationPublisher: vi.fn(() => () => {}) })) -vi.mock('./ssh-relay-deploy-helpers', () => ({ - execCommand: vi.fn().mockResolvedValue('') -})) - vi.mock('./ssh-channel-multiplexer', () => { return { SshChannelMultiplexer: class MockSshChannelMultiplexer { @@ -73,6 +77,10 @@ vi.mock('../providers/ssh-pty-provider', () => ({ attachForReconnect = vi.fn().mockResolvedValue({}) setPtyDeliveryPauseAdapter = pauseAdapterMock dispose = vi.fn() + + constructor(...args: unknown[]) { + ptyProviderConstructorMock(...args) + } } })) @@ -87,6 +95,7 @@ vi.mock('../providers/ssh-git-provider', () => ({ })) vi.mock('../ipc/pty', () => ({ + waitForSshPtyPendingCloseReplay: pendingCloseReplayMock, registerSshPtyProvider: vi.fn(), unregisterSshPtyProvider: vi.fn(), getSshPtyProvider: vi.fn().mockReturnValue({ @@ -115,7 +124,6 @@ vi.mock('../providers/ssh-git-dispatch', () => ({ })) const { deployAndLaunchRelay } = await import('./ssh-relay-deploy') -const { execCommand } = await import('./ssh-relay-deploy-helpers') const { getRemoteHostPlatform } = await import('./ssh-remote-platform') const { registerSshPtyProvider, @@ -134,13 +142,17 @@ describe('SshRelaySession', () => { beforeEach(() => { vi.clearAllMocks() openConsumerSessionMock.mockImplementation(async (_mux, options) => ({ - mode: 'legacy-fallback', - clientInstanceId: options.clientInstanceId, - serverBuildId: 'test-relay-build' + state: { + mode: 'legacy-fallback', + clientInstanceId: options.clientInstanceId, + serverBuildId: 'test-relay-build' + }, + resumed: false })) delete process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS muxRequestMock.mockReset() muxRequestMock.mockResolvedValue([]) + pendingCloseReplayMock.mockReset().mockResolvedValue(undefined) mockDeploySuccess() vi.mocked(getPtyIdsForConnection).mockReturnValue([]) }) @@ -206,6 +218,53 @@ describe('SshRelaySession', () => { expect(registerSshGitProvider).toHaveBeenCalledWith('target-1', expect.anything()) }) + it('does not reattach or publish a ready session when pending-close replay fails', async () => { + const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() + vi.mocked(mockStore.getSshRemotePtyLeases).mockReturnValue([ + { + targetId: 'target-1', + ptyId: 'pty-1', + state: 'detached', + createdAt: 1, + updatedAt: 1 + } + ]) + pendingCloseReplayMock.mockRejectedValueOnce(new Error('pending close unresolved')) + const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) + + await expect(session.establish(mockConn)).rejects.toThrow('pending close unresolved') + + expect(mockStore.markSshRemotePtyLeasesAttachedAsync).not.toHaveBeenCalled() + expect(session.getState()).toBe('idle') + }) + + it('gates the negotiated exact capability on the active provider generation', async () => { + openConsumerSessionMock.mockResolvedValueOnce({ + state: { + mode: 'negotiated', + clientInstanceId: 'client-1', + clientGeneration: 1, + ownerGeneration: 1, + ownerLease: 'lease-1', + exactOperations: { version: 1 } + }, + resumed: false + }) + const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() + const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) + + await session.establish(mockConn) + const capability = ptyProviderConstructorMock.mock.calls[0][4] as { + version: number + isCurrentProviderGeneration: () => boolean + } + expect(capability.version).toBe(1) + expect(capability.isCurrentProviderGeneration()).toBe(true) + + await session.disposeAndPersist() + expect(capability.isCurrentProviderGeneration()).toBe(false) + }) + it('continues provider registration when the relay managed-hook request fails', async () => { process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS = '1' muxRequestMock.mockImplementation(async (method: string) => { @@ -272,11 +331,7 @@ describe('SshRelaySession', () => { writeFile: vi.fn().mockResolvedValue(undefined) } as unknown as SshConnection vi.mocked(deployAndLaunchRelay).mockResolvedValueOnce({ - transport: { - write: vi.fn(), - onData: vi.fn(), - onClose: vi.fn() - }, + ...mockDeploySuccess(), platform: 'win32-x64', hostPlatform: getRemoteHostPlatform('win32-x64'), remoteHome: 'C:/Users/me', @@ -367,48 +422,6 @@ describe('SshRelaySession', () => { expect(registerSshPtyProvider).toHaveBeenCalledWith('target-1', expect.anything()) }) - it('compiles a native Windows Orca CLI bridge without a cmd.exe shim', async () => { - const { mockStore, mockPortForward, getMainWindow } = createMockDeps() - const mockConn = { - writeFile: vi.fn().mockResolvedValue(undefined) - } as unknown as SshConnection - vi.mocked(deployAndLaunchRelay).mockResolvedValueOnce({ - transport: { - write: vi.fn(), - onData: vi.fn(), - onClose: vi.fn() - }, - platform: 'win32-x64', - hostPlatform: getRemoteHostPlatform('win32-x64'), - remoteHome: 'C:/Users/me', - remoteRelayDir: 'C:/Users/me/.orca-remote/relay-v1', - nodePath: 'C:/Program Files/nodejs/node.exe', - sockPath: '\\\\.\\pipe\\orca-relay-123' - }) - - const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) - - await session.establish(mockConn) - - expect(execCommand).toHaveBeenCalledTimes(2) - expect(vi.mocked(execCommand).mock.calls[0]?.[1]).toContain('powershell.exe') - expect(vi.mocked(execCommand).mock.calls[0]?.[2]).toEqual({ wrapCommand: false }) - expect(mockConn.writeFile).toHaveBeenCalledWith( - 'C:/Users/me/.orca-relay/bin/orca-launcher.cs', - expect.stringContaining('ProcessStartInfo'), - { hostPlatform: getRemoteHostPlatform('win32-x64') } - ) - const launcherSource = vi.mocked(mockConn.writeFile).mock.calls[0]?.[1] as string - expect(launcherSource).toContain('ORCA_RELAY_SOCKET_PATH') - expect(launcherSource).not.toContain('cmd.exe') - expect(launcherSource).not.toContain('%*') - expect(vi.mocked(execCommand).mock.calls[1]?.[1]).toContain('powershell.exe') - expect(vi.mocked(execCommand).mock.calls[1]?.[2]).toEqual({ wrapCommand: false }) - expect(vi.mocked(execCommand).mock.calls.some(([, command]) => command.includes('chmod'))).toBe( - false - ) - }) - it('reconnect re-attaches live PTYs', async () => { const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() vi.mocked(getPtyIdsForConnection).mockReturnValue(['pty-1', 'pty-2']) @@ -780,11 +793,7 @@ describe('SshRelaySession', () => { let resolveFirst!: () => void vi.mocked(deployAndLaunchRelay).mockReturnValueOnce( new Promise((resolve) => { - resolveFirst = () => - resolve({ - transport: { write: vi.fn(), onData: vi.fn(), onClose: vi.fn() }, - platform: 'linux-x64' as const - }) + resolveFirst = () => resolve(mockDeploySuccess()) }) ) mockDeploySuccess() @@ -813,20 +822,21 @@ describe('SshRelaySession', () => { await session.establish(mockConn, 600) - expect(session.getMux()?.notify).toHaveBeenCalledWith(SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD, { - graceTimeSeconds: 600 - }) + const graceCalls = muxRequestMock.mock.calls.filter( + ([method]) => method === SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD + ) + expect(graceCalls).toEqual([[SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD, { graceTimeSeconds: 600 }]]) }) it('sets relay grace to unlimited before host sleep', async () => { const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) await session.establish(mockConn) - vi.mocked(session.getMux()!.notify).mockClear() + muxRequestMock.mockClear() - session.prepareForHostSleep() + await session.prepareForHostSleep() - expect(session.getMux()?.notify).toHaveBeenCalledWith(SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD, { + expect(muxRequestMock).toHaveBeenCalledWith(SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD, { graceTimeSeconds: 0 }) }) diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index f44d2a77dbd..2fcb07e9b38 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -4,7 +4,6 @@ import { randomUUID } from 'node:crypto' import type { BrowserWindow } from 'electron' import { deployAndLaunchRelay } from './ssh-relay-deploy' -import { execCommand } from './ssh-relay-deploy-helpers' import { isRelayVersionMismatchError } from './ssh-relay-version-mismatch-error' import { SshChannelMultiplexer } from './ssh-channel-multiplexer' import { SshPtyProvider } from '../providers/ssh-pty-provider' @@ -41,7 +40,8 @@ import { deletePtyOwnership, setPtyOwnership, restorePtyIncarnation, - isCurrentPtyExit + isCurrentPtyExit, + waitForSshPtyPendingCloseReplay } from '../ipc/pty' import { acceptSshPtyOutputData, @@ -67,8 +67,6 @@ import { isMainWindowVisible, onMainWindowBecameVisible } from '../window/main-w import type { SshPortForwardManager } from './ssh-port-forward' import type { SshConnection } from './ssh-connection' import { joinRemotePath, isWindowsRemoteHost, type RemoteHostPlatform } from './ssh-remote-platform' -import { makeRemoteDirectoryCommand } from './ssh-remote-commands' -import { createRemoteCliInstallPlan } from './ssh-remote-cli-launcher' import { DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS, type DetectedPort, @@ -123,7 +121,30 @@ import { rememberSshPtyConsumerRecovery } from './ssh-pty-consumer-recovery' import { classifySshPtyFrameRejection, SshPtyFrameRejectionLog } from './ssh-pty-frame-rejection' +import { installSshRemoteCliLauncher } from './ssh-remote-cli-launcher-client' import { SshPtyTargetedReattachQueue } from './ssh-pty-targeted-reattach-queue' +import type { TerminalAuthorityNamespace } from '../../shared/terminal-session-authority-identity' +import { + SshTerminalAuthorityTopologyCoordinator, + type SshTerminalAuthorityNamespaceTopologyAttachOptions, + type SshTerminalAuthorityNamespaceTopologySink +} from './ssh-terminal-authority-topology-coordinator' +import type { TerminalSessionAuthorityPtyAccess } from '../../shared/terminal-session-authority-pty-access' +import { coordinateSshLegacyMigration } from './ssh-legacy-migration-coordinator' +import type { + SshLegacyMigrationEvidenceProvider, + SshLegacyMigrationOutcome +} from './ssh-legacy-migration-coordinator-types' +import { createSshLegacyMigrationEvidenceProvider } from './ssh-legacy-migration-evidence-provider' +import type { SshLegacyPriorRelayStatus } from './ssh-legacy-migration-prior-relay-status' +import { getRemoteWorkspaceSnapshotForTarget } from '../ipc/remote-workspace' +import type { TerminalAuthorityConsumerProofKeypair } from '../session-authority/terminal-session-authority-consumer-proof' +import { SshTerminalAuthorityAppHostTransport } from './ssh-terminal-authority-app-host-transport' +import { publishSshTerminalPhysicalExit } from './ssh-terminal-authority-exit-wait' +import { + installPtyAuthorityOutcomeHostTransport, + type PtyAuthorityOutcomeHostRegistration +} from '../ipc/pty-authority-outcome-ipc' export type RelaySessionState = 'idle' | 'deploying' | 'ready' | 'reconnecting' | 'disposed' @@ -187,12 +208,23 @@ type RemoteCliBridgeEnv = { pathDelimiter?: ':' | ';' } -type ExpectedPtyIdentity = { paneKey?: string; tabId?: string } +type ExpectedPtyIdentity = { + paneKey?: string + tabId?: string + worktreeId?: string + paneGeneration?: number + ptyIncarnationId?: string + terminalSessionAuthorityAccess?: TerminalSessionAuthorityPtyAccess +} type TargetedDeliveryRecovery = 'confirm-existing' | 'fresh-activation' function expectedIdentityForLease(lease: { tabId?: string leafId?: string + worktreeId?: string + paneGeneration?: number + incarnationId?: string + terminalSessionAuthorityAccess?: TerminalSessionAuthorityPtyAccess }): ExpectedPtyIdentity | null { if (typeof lease.tabId !== 'string' || lease.tabId.length === 0) { return null @@ -205,7 +237,13 @@ function expectedIdentityForLease(lease: { : undefined return { ...(paneKey ? { paneKey } : {}), - tabId: lease.tabId + tabId: lease.tabId, + ...(lease.worktreeId ? { worktreeId: lease.worktreeId } : {}), + ...(lease.paneGeneration !== undefined ? { paneGeneration: lease.paneGeneration } : {}), + ...(lease.incarnationId ? { ptyIncarnationId: lease.incarnationId } : {}), + ...(lease.terminalSessionAuthorityAccess + ? { terminalSessionAuthorityAccess: lease.terminalSessionAuthorityAccess } + : {}) } } @@ -298,6 +336,7 @@ export class SshRelaySession { private mux: SshChannelMultiplexer | null = null private abortController: AbortController | null = null private muxDisposeCleanup: (() => void) | null = null + private readonly reportedRelayLosses = new WeakSet() // Why: hold the notification-handler disposer so teardownProviders can release it on reconnect/shutdown (symmetric with muxDisposeCleanup). private muxNotificationCleanup: (() => void) | null = null // Why: onStateChange never fires when the relay channel closes but SSH stays up; this callback lets ssh.ts drive relay-level reconnect. @@ -350,6 +389,9 @@ export class SshRelaySession { private readonly ptyFrameRejectionLog = new SshPtyFrameRejectionLog() private readonly ptyConsumerClientInstanceId: string private ptyConsumerSessionState: SshPtyConsumerSessionState | null = null + private readonly terminalAuthorityTopology = new SshTerminalAuthorityTopologyCoordinator() + private terminalAuthorityAppAdmission: PtyAuthorityOutcomeHostRegistration | null = null + private legacyMigrationOutcome: SshLegacyMigrationOutcome | null = null private activeCompatibilityAttachmentIds = new Set() constructor( @@ -362,7 +404,9 @@ export class SshRelaySession { targetId: string, ports: DetectedPort[], platform: string - ) => void + ) => void, + private readonly legacyMigrationEvidenceProvider?: SshLegacyMigrationEvidenceProvider, + private readonly terminalAuthorityConsumerProofKeypair?: TerminalAuthorityConsumerProofKeypair ) { this.ptyConsumerClientInstanceId = claimSshPtyConsumerRecovery(targetId, store).clientInstanceId } @@ -413,10 +457,57 @@ export class SshRelaySession { return this.mux } + terminalAuthorityAppHostTransport(): SshTerminalAuthorityAppHostTransport | null { + const mux = this.mux + const proof = this.activePtyConsumerOwner()?.terminalAuthorityConsumerProof + return mux && !mux.isDisposed() && proof && this.terminalAuthorityConsumerProofKeypair + ? new SshTerminalAuthorityAppHostTransport({ + mux, + authenticatedAuthorityHostId: proof.authorityHostId, + keypair: this.terminalAuthorityConsumerProofKeypair, + consumerRetirementSupported: proof.retirementVersion === 1 + }) + : null + } + + attachResolvedNamespace( + namespace: TerminalAuthorityNamespace, + sink: SshTerminalAuthorityNamespaceTopologySink, + options?: SshTerminalAuthorityNamespaceTopologyAttachOptions + ): () => void { + return this.terminalAuthorityTopology.attachResolvedNamespace(namespace, sink, options) + } + getHostPlatform(): RemoteHostPlatform | null { return this.remoteCliBridgeEnv?.hostPlatform ?? this.hostPlatform } + getLegacyMigrationOutcome(): SshLegacyMigrationOutcome | null { + return this.legacyMigrationOutcome + } + + // Why per attempt and not per session: the provider is fenced by this attempt's identity, so a + // superseded attempt's evidence can never be read into a newer attempt's cutover. + private createLegacyMigrationEvidenceProvider(args: { + priorRelayStatus: SshLegacyPriorRelayStatus + nodePath: string | undefined + hostPlatform: RemoteHostPlatform | undefined + isAttemptCurrent: () => boolean + }): SshLegacyMigrationEvidenceProvider { + return createSshLegacyMigrationEvidenceProvider({ + targetId: this.targetId, + partitionId: toSshExecutionHostId(this.targetId), + clientInstanceId: this.ptyConsumerClientInstanceId, + hostPlatform: args.hostPlatform ?? null, + nodePath: args.nodePath ?? null, + priorRelayStatus: args.priorRelayStatus, + store: this.store, + connection: () => this.currentConnection ?? null, + remoteWorkspaceSnapshot: () => getRemoteWorkspaceSnapshotForTarget(this.targetId), + isAttemptCurrent: args.isAttemptCurrent + }) + } + getAiVaultHostInfo(): SshRelayAiVaultHostInfo | null { const env = this.remoteCliBridgeEnv if (!env) { @@ -461,12 +552,12 @@ export class SshRelaySession { return this.portScanner } - prepareForHostSleep(): void { + async prepareForHostSleep(): Promise { const mux = this.mux if (!mux || mux.isDisposed() || this.isDisposed()) { return } - mux.notify(SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD, { graceTimeSeconds: 0 }) + await this.configureRelayGraceTime(mux, 0) } // Why: single entry point for relay setup (initial connect + app-restart reconnect) so no path forgets a registration step. @@ -475,19 +566,25 @@ export class SshRelaySession { throw new Error(`Cannot establish relay session in state: ${this._state}`) } this._state = 'deploying' + this.abortController?.abort() + const abortController = new AbortController() + this.abortController = abortController this.aiVaultListMethodSupported = null this.currentConnection = conn try { const { transport, - serverBuildId, + authorityHostId, + terminalAuthorityOwnerBuildId, + terminalAuthorityCapabilities, remoteHome, remoteRelayDir, nodePath, sockPath, credentialFile, - hostPlatform + hostPlatform, + priorRelayStatus } = await deployAndLaunchRelay(conn, undefined, graceTimeSeconds, this.targetId) this.hostPlatform = hostPlatform ?? null this.remoteCliBridgeEnv = @@ -513,12 +610,37 @@ export class SshRelaySession { const mux = new SshChannelMultiplexer(transport) this.mux = mux - const isAttemptCurrent = (): boolean => this.mux === mux && !this.isDisposed() + const isAttemptCurrent = (): boolean => + this.mux === mux && + this.abortController === abortController && + !abortController.signal.aborted && + !this.isDisposed() const shouldContinue = (): boolean => isAttemptCurrent() && !mux.isDisposed() + this.watchMuxForRelayLoss(mux) + + this.legacyMigrationOutcome = await coordinateSshLegacyMigration({ + targetId: this.targetId, + authorityHostId, + hostPathFlavor: hostPlatform?.pathFlavor ?? 'posix', + authorityCapabilities: terminalAuthorityCapabilities, + attemptId: randomUUID(), + signal: abortController.signal, + isAttemptCurrent, + rpc: mux, + evidenceProvider: + this.legacyMigrationEvidenceProvider ?? + this.createLegacyMigrationEvidenceProvider({ + priorRelayStatus, + nodePath, + hostPlatform, + isAttemptCurrent + }) + }) const ptyConsumerSessionState = await this.openPtyConsumerSession( mux, - serverBuildId, + terminalAuthorityOwnerBuildId, + authorityHostId, shouldContinue ) if (!verifyRelayAttempt(mux, isAttemptCurrent, 'consumer session setup')) { @@ -528,7 +650,14 @@ export class SshRelaySession { throw new Error('Session disposed during establish') } this.ptyConsumerSessionState = ptyConsumerSessionState - await this.rememberPtyConsumerRecovery(serverBuildId) + this.replaceTerminalAuthorityAppAdmission() + await this.terminalAuthorityTopology.onTopologyCapability( + mux, + ptyConsumerSessionState.mode === 'negotiated' + ? (ptyConsumerSessionState.terminalAuthorityTopology ?? null) + : null + ) + await this.rememberPtyConsumerRecovery(terminalAuthorityOwnerBuildId) if (!verifyRelayAttempt(mux, isAttemptCurrent, 'consumer recovery persistence')) { if (!mux.isDisposed()) { mux.dispose() @@ -536,7 +665,10 @@ export class SshRelaySession { throw new Error('Session disposed during establish') } - await mux.request('session.resolveHome', { path: '~' }) + await Promise.all([ + mux.request('session.resolveHome', { path: '~' }), + this.configureRelayGraceTime(mux, graceTimeSeconds) + ]) if (!verifyRelayAttempt(mux, isAttemptCurrent, 'home resolution')) { if (!mux.isDisposed()) { mux.dispose() @@ -563,6 +695,10 @@ export class SshRelaySession { throw new Error('Session disposed during establish') } + if (!(await this.awaitPendingPtyCloseReplay(shouldContinue))) { + throw new Error('Session disposed during pending PTY close replay') + } + // Why: explicit disconnect keeps PTY ownership, so a later manual connect must reattach those remote PTYs. await this.reattachKnownPtys(mux, shouldContinue) @@ -570,9 +706,6 @@ export class SshRelaySession { throw new Error('Session disposed during establish') } - this.configureRelayGraceTime(mux, graceTimeSeconds) - verifyRelayAttempt(mux, isAttemptCurrent, 'establish') - this.watchMuxForRelayLoss(mux) verifyRelayAttempt(mux, isAttemptCurrent, 'establish') this._state = 'ready' this.startPortScanning() @@ -622,16 +755,20 @@ export class SshRelaySession { this.broadcastEmptyLists() this.teardownProviders('connection_lost') + let reconnectMux: SshChannelMultiplexer | null = null try { const { transport, - serverBuildId, + authorityHostId, + terminalAuthorityOwnerBuildId, + terminalAuthorityCapabilities, remoteHome, remoteRelayDir, nodePath, sockPath, credentialFile, - hostPlatform + hostPlatform, + priorRelayStatus } = await deployAndLaunchRelay(conn, undefined, graceTimeSeconds, this.targetId) this.hostPlatform = hostPlatform ?? null this.remoteCliBridgeEnv = @@ -649,13 +786,13 @@ export class SshRelaySession { : null if (abortController.signal.aborted || this.isDisposed()) { - // Why: relay is already running remotely — a throwaway mux we immediately dispose sends a clean shutdown so it doesn't linger until grace expires. const orphanMux = new SshChannelMultiplexer(transport) orphanMux.dispose() return } const mux = new SshChannelMultiplexer(transport) + reconnectMux = mux this.mux = mux const isAttemptCurrent = (): boolean => @@ -664,10 +801,31 @@ export class SshRelaySession { !abortController.signal.aborted && !this.isDisposed() const shouldContinue = (): boolean => isAttemptCurrent() && !mux.isDisposed() + this.watchMuxForRelayLoss(mux) + + this.legacyMigrationOutcome = await coordinateSshLegacyMigration({ + targetId: this.targetId, + authorityHostId, + hostPathFlavor: hostPlatform?.pathFlavor ?? 'posix', + authorityCapabilities: terminalAuthorityCapabilities, + attemptId: randomUUID(), + signal: abortController.signal, + isAttemptCurrent, + rpc: mux, + evidenceProvider: + this.legacyMigrationEvidenceProvider ?? + this.createLegacyMigrationEvidenceProvider({ + priorRelayStatus, + nodePath, + hostPlatform, + isAttemptCurrent + }) + }) const ptyConsumerSessionState = await this.openPtyConsumerSession( mux, - serverBuildId, + terminalAuthorityOwnerBuildId, + authorityHostId, shouldContinue ) if (!verifyRelayAttempt(mux, isAttemptCurrent, 'consumer session setup')) { @@ -677,7 +835,14 @@ export class SshRelaySession { return } this.ptyConsumerSessionState = ptyConsumerSessionState - await this.rememberPtyConsumerRecovery(serverBuildId) + this.replaceTerminalAuthorityAppAdmission() + await this.terminalAuthorityTopology.onTopologyCapability( + mux, + ptyConsumerSessionState.mode === 'negotiated' + ? (ptyConsumerSessionState.terminalAuthorityTopology ?? null) + : null + ) + await this.rememberPtyConsumerRecovery(terminalAuthorityOwnerBuildId) if (!verifyRelayAttempt(mux, isAttemptCurrent, 'consumer recovery persistence')) { if (!mux.isDisposed()) { mux.dispose() @@ -685,7 +850,10 @@ export class SshRelaySession { return } - await mux.request('session.resolveHome', { path: '~' }) + await Promise.all([ + mux.request('session.resolveHome', { path: '~' }), + this.configureRelayGraceTime(mux, graceTimeSeconds) + ]) if (!verifyRelayAttempt(mux, isAttemptCurrent, 'home resolution')) { if (!mux.isDisposed()) { mux.dispose() @@ -717,15 +885,16 @@ export class SshRelaySession { return } + if (!(await this.awaitPendingPtyCloseReplay(shouldContinue))) { + return + } + await this.reattachKnownPtys(mux, shouldContinue) if (!verifyRelayAttempt(mux, isAttemptCurrent, 'PTY reattach')) { return } - this.configureRelayGraceTime(mux, graceTimeSeconds) - verifyRelayAttempt(mux, isAttemptCurrent, 'reconnect') - this.watchMuxForRelayLoss(mux) verifyRelayAttempt(mux, isAttemptCurrent, 'reconnect') this._state = 'ready' this.startPortScanning() @@ -758,7 +927,11 @@ export class SshRelaySession { ) if (this.abortController === abortController && !this.isDisposed()) { // Why: treat non-not-found attach failures as relay loss so ssh.ts's bounded backoff retries instead of stranding the session in 'reconnecting'. - this._onRelayLost?.(this.targetId) + if (reconnectMux) { + this.notifyRelayLostOnce(reconnectMux) + } else { + this._onRelayLost?.(this.targetId) + } } } finally { if (this.abortController === abortController) { @@ -804,6 +977,7 @@ export class SshRelaySession { this.abortController?.abort() this.stopPortScanning() this.broadcastEmptyLists() + this.terminalAuthorityTopology.dispose() this.teardownProviders('shutdown') this.currentConnection = null this._state = 'disposed' @@ -877,6 +1051,7 @@ export class SshRelaySession { this.abortController?.abort() this.stopPortScanning() this.broadcastEmptyLists() + this.terminalAuthorityTopology.dispose() this.teardownProviders('connection_lost') this.currentConnection = null this._state = 'disposed' @@ -896,6 +1071,7 @@ export class SshRelaySession { this.abortController?.abort() this.stopPortScanning() this.broadcastEmptyLists() + this.terminalAuthorityTopology.dispose() // Why: disconnect keeps PTY ownership so a later manual connect can reattach. this.teardownProviders('connection_lost') this.currentConnection = null @@ -913,17 +1089,42 @@ export class SshRelaySession { // ── Private ─────────────────────────────────────────────────────── - // Why: onStateChange only fires on SSH-level reconnects, so watch for relay-channel loss while SSH stays up and fire onRelayLost. private watchMuxForRelayLoss(mux: SshChannelMultiplexer): void { this.muxDisposeCleanup?.() - this.muxDisposeCleanup = mux.onDispose((reason) => { - if (reason === 'connection_lost' && this.mux === mux && !this.isDisposed()) { - console.warn( - `[ssh-relay-session] Relay channel lost for ${this.targetId}, triggering reconnect` - ) - this._onRelayLost?.(this.targetId) + const cleanup = mux.onDispose((reason) => { + if (reason !== 'connection_lost' || this.mux !== mux || this.isDisposed()) { + return + } + const shouldReconnect = this._state === 'ready' || this._state === 'reconnecting' + console.warn( + `[ssh-relay-session] Relay channel lost for ${this.targetId}, triggering reconnect` + ) + this.teardownProviders('connection_lost') + if (shouldReconnect) { + this.notifyRelayLostOnce(mux) } }) + if (this.mux === mux) { + this.muxDisposeCleanup = cleanup + } else { + cleanup() + } + } + + private replaceTerminalAuthorityAppAdmission(): void { + const transport = this.terminalAuthorityAppHostTransport() + const replacement = transport ? installPtyAuthorityOutcomeHostTransport(transport) : null + const previous = this.terminalAuthorityAppAdmission + this.terminalAuthorityAppAdmission = replacement + previous?.dispose() + } + + private notifyRelayLostOnce(controlMux: SshChannelMultiplexer): void { + if (this.reportedRelayLosses.has(controlMux)) { + return + } + this.reportedRelayLosses.add(controlMux) + this._onRelayLost?.(this.targetId) } // Why: shared by establish() and reconnect() so both use the exact same registration sequence. @@ -942,10 +1143,13 @@ export class SshRelaySession { return false } + let remoteCliLauncherReady = false try { - await this.installRemoteOrcaCliLauncher() + if (this.remoteCliBridgeEnv) { + await installSshRemoteCliLauncher(mux, this.remoteCliBridgeEnv) + } + remoteCliLauncherReady = true } catch (error) { - // Why: on MaxSessions=1 remotes the relay holds the only slot, so this raw-connection install can fail — don't fail the whole connection. console.warn( `[ssh-relay-session] remote orca CLI launcher install failed for ${this.targetId}: ${ error instanceof Error ? error.message : String(error) @@ -959,13 +1163,44 @@ export class SshRelaySession { this.wireUpRemoteOrcaCli(mux, connectionIncarnation) const providerGeneration = allocateSshPtyProviderGeneration() + const consumerOwnerState = this.activePtyConsumerOwner() const ptyProvider = new SshPtyProvider( this.targetId, mux, - this.remoteCliBridgeEnv ?? undefined, - providerGeneration + remoteCliLauncherReady ? (this.remoteCliBridgeEnv ?? undefined) : undefined, + providerGeneration, + consumerOwnerState?.exactOperations + ? { + version: consumerOwnerState.exactOperations.version, + isCurrentProviderGeneration: () => + this.activePtyProviderGeneration === providerGeneration && + this.mux === mux && + !mux.isDisposed() + } + : undefined, + false, + consumerOwnerState?.terminalAuthorityExactOperations + ? { + version: consumerOwnerState.terminalAuthorityExactOperations.version, + isCurrentProviderGeneration: () => + this.activePtyProviderGeneration === providerGeneration && + this.mux === mux && + !mux.isDisposed() + } + : undefined, + consumerOwnerState?.heldProducerPause + ? { + version: consumerOwnerState.heldProducerPause.version, + clientGeneration: consumerOwnerState.clientGeneration, + ownerGeneration: consumerOwnerState.ownerGeneration, + isCurrentProviderGeneration: () => + this.activePtyProviderGeneration === providerGeneration && + this.mux === mux && + !mux.isDisposed() + } + : undefined, + this.terminalAuthorityAppAdmission ?? undefined ) - const consumerOwnerState = this.activePtyConsumerOwner() if (consumerOwnerState) { ptyProvider.setPtyDeliveryPauseAdapter?.(({ id, providerGeneration: generation, paused }) => { if ( @@ -1077,6 +1312,25 @@ export class SshRelaySession { return state && state.mode !== 'legacy-fallback' ? state : null } + private async awaitPendingPtyCloseReplay(shouldContinue: () => boolean): Promise { + if (!shouldContinue()) { + return false + } + const provider = getSshPtyProvider(this.targetId) + if (!provider) { + throw new Error('SSH PTY provider unavailable before pending close replay') + } + try { + await waitForSshPtyPendingCloseReplay(this.targetId, provider) + } catch (error) { + if (!shouldContinue()) { + return false + } + throw error + } + return shouldContinue() + } + private recoverablePtyConsumerOwner( serverBuildId: string | undefined ): SshPtyConsumerOwnerState | null { @@ -1093,6 +1347,7 @@ export class SshRelaySession { private async openPtyConsumerSession( mux: SshChannelMultiplexer, serverBuildId: string | undefined, + authorityHostId: string, ownsAttempt: () => boolean ): Promise { const previousOwner = this.recoverablePtyConsumerOwner(serverBuildId) @@ -1100,7 +1355,14 @@ export class SshRelaySession { clientInstanceId: this.ptyConsumerClientInstanceId, expectedServerBuildId: serverBuildId, allowSameBuildLegacyFallback: true, - outputFlowControl: { requestedWindowSu: DEFAULT_PTY_SOURCE_WINDOW_SU } + outputFlowControl: { requestedWindowSu: DEFAULT_PTY_SOURCE_WINDOW_SU }, + exactOperations: true, + heldProducerPause: true, + terminalAuthorityExactOperations: true, + terminalAuthorityConsumerProof: true, + terminalAuthorityConsumerRetirement: true, + requiredTerminalAuthorityConsumerProofHostId: authorityHostId, + terminalAuthorityTopology: true } let admission: SshPtyConsumerAdmission try { @@ -1208,13 +1470,14 @@ export class SshRelaySession { }) } - private configureRelayGraceTime( + private async configureRelayGraceTime( mux: SshChannelMultiplexer, graceTimeSeconds: number | undefined - ): void { - mux.notify(SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD, { + ): Promise { + const params = { graceTimeSeconds: normalizeRelayGracePeriodSeconds(graceTimeSeconds) - }) + } + await mux.request(SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD, params) } private async installManagedHooksOnRemote( @@ -1278,41 +1541,6 @@ export class SshRelaySession { } } - private async installRemoteOrcaCliLauncher(): Promise { - if (!this.remoteCliBridgeEnv) { - return - } - const { binDir, hostPlatform } = this.remoteCliBridgeEnv - const plan = createRemoteCliInstallPlan(this.remoteCliBridgeEnv) - const conn = this.requireReadyConnection() - await execCommand(conn, makeRemoteDirectoryCommand(hostPlatform, binDir), { - wrapCommand: !isWindowsRemoteHost(hostPlatform) - }) - if (typeof conn.writeFile === 'function') { - for (const file of plan.files) { - await conn.writeFile(file.path, file.contents, { hostPlatform }) - } - } else { - const sftp = await conn.sftp() - try { - for (const file of plan.files) { - await new Promise((resolve, reject) => { - const ws = sftp.createWriteStream(file.path) - sftp.once('error', reject) - ws.once('close', resolve) - ws.once('error', reject) - ws.end(file.contents) - }) - } - } finally { - sftp.end() - } - } - for (const command of plan.postWriteCommands) { - await execCommand(conn, command, { wrapCommand: !isWindowsRemoteHost(hostPlatform) }) - } - } - private wireUpRemoteOrcaCli(mux: SshChannelMultiplexer, connectionIncarnation: string): void { mux.onRequest('orca.cli', async (params) => { if (!this.runtime) { @@ -1523,6 +1751,8 @@ export class SshRelaySession { reason: 'shutdown' | 'connection_lost', outputGenerationReason: string = reason ): void { + this.terminalAuthorityAppAdmission?.dispose() + this.terminalAuthorityAppAdmission = null this.muxDisposeCleanup?.() this.muxDisposeCleanup = null this.muxNotificationCleanup?.() @@ -1544,6 +1774,9 @@ export class SshRelaySession { this.sourceAckPublisherCleanup = null this.sourceCancellationPublisherCleanup?.() this.sourceCancellationPublisherCleanup = null + if (this.mux) { + this.terminalAuthorityTopology.detachTransport(this.mux) + } if (this.mux && !this.mux.isDisposed()) { this.mux.dispose(reason) } @@ -1718,20 +1951,31 @@ export class SshRelaySession { ) { return } - const pendingReattach = this.pendingPtyReattaches.get(payload.id) - if (pendingReattach && !pendingReattach.activated) { - // Why: attach response and exit can share one transport batch, before incarnation restoration runs. - pendingReattach.exits.push(payload) - this.wakeRecovery(pendingReattach) - return - } - if (!isCurrentPtyExit(payload)) { - return - } - void this.acceptPtyExit(payload).catch(() => {}) + void this.handleProviderPtyExit(payload, mux, providerGeneration).catch(() => {}) }) } + private handleProviderPtyExit( + payload: SshPtyExitPayload, + mux: SshChannelMultiplexer, + providerGeneration: number + ): Promise { + if ( + this.mux !== mux || + this.activePtyProviderGeneration !== providerGeneration || + payload.providerGeneration !== providerGeneration + ) { + return Promise.resolve() + } + const pendingReattach = this.pendingPtyReattaches.get(payload.id) + if (pendingReattach && !pendingReattach.activated) { + pendingReattach.exits.push(payload) + this.wakeRecovery(pendingReattach) + return Promise.resolve() + } + return isCurrentPtyExit(payload) ? this.acceptPtyExit(payload) : Promise.resolve() + } + private acceptPtyData(payload: SshPtyDataPayload): Promise { const consumerOwner = this.activePtyConsumerOwner() const offeredSource = payload.source @@ -2153,6 +2397,12 @@ export class SshRelaySession { providerGeneration: payload.providerGeneration, ptyIncarnation: payload.ptyIncarnation }) + publishSshTerminalPhysicalExit({ + targetId: this.targetId, + relayPtyId: toRelaySshPtyId(this.targetId, payload.id), + ptyIncarnationId: payload.ptyIncarnation, + code: payload.code + }) if (isCurrentPtyExit(payload)) { this.retireExitedPty(payload, true) } diff --git a/src/main/ssh/ssh-relay-sftp-namespace-install.test.ts b/src/main/ssh/ssh-relay-sftp-namespace-install.test.ts index 3809af2c3d9..5603408a215 100644 --- a/src/main/ssh/ssh-relay-sftp-namespace-install.test.ts +++ b/src/main/ssh/ssh-relay-sftp-namespace-install.test.ts @@ -93,11 +93,17 @@ import { import { tryAcquireRelayRepairLock } from './ssh-relay-repair-lock' import type { SshConnection } from './ssh-connection' import type { SftpNamespacePathMapping } from './sftp-namespace-resolution' +import { + sshTerminalAuthorityBootstrapRead, + sshTerminalAuthorityLaunchExecResponses +} from './ssh-terminal-authority-process-fixture' // The transfer-option slice these tests inspect; SshConnection keeps its own options type internal. type TransferOptions = { sftpNamespace?: SftpNamespacePathMapping } const SHELL_HOME = '/home/u' +const BOOTSTRAP_ABSENT = sshTerminalAuthorityBootstrapRead(SHELL_HOME) +const AUTHORITY_LAUNCH = sshTerminalAuthorityLaunchExecResponses({ remoteHome: SHELL_HOME }) const SFTP_HOME = '/homes/u' const RELAY_SUFFIX = '.orca-remote/relay-0.1.0+testhash' const SHELL_RELAY_DIR = `${SHELL_HOME}/${RELAY_SUFFIX}` @@ -162,6 +168,13 @@ function execCommands(): string[] { return vi.mocked(execCommand).mock.calls.map(([, command]) => decodeCommand(command)) } +// A deploy mints two credentials; the authority endpoint one is not the relay socket one. +function relayCredentialCommand(): string | undefined { + return execCommands().find( + (command) => command.includes('randomBytes') && !command.includes('terminal-authority') + ) +} + function makeConnection(capture: Capture, options: ConnectionOptions = {}): SshConnection { const startPath = options.sftpStartPath ?? SFTP_HOME // Default: the marker is visible only through the SFTP namespace, and only under this install's token. @@ -204,12 +217,17 @@ function makeConnection(capture: Capture, options: ConnectionOptions = {}): SshC const conn: Record = { canRunConcurrentExecCommands: vi.fn().mockReturnValue(false), - exec: vi.fn().mockResolvedValue({ - on: vi.fn(), - stderr: { on: vi.fn() }, - stdin: {}, - stdout: { on: vi.fn() }, - close: vi.fn() + exec: vi.fn().mockImplementation(() => { + const channel = new EventEmitter() + setTimeout(() => channel.emit('close'), 0) + return Promise.resolve( + Object.assign(channel, { + stderr: new EventEmitter(), + stdin: {}, + stdout: { on: vi.fn() }, + close: vi.fn() + }) + ) }), sftp: vi.fn().mockImplementation(() => Promise.resolve(makeSftp())) } @@ -244,7 +262,7 @@ function feed(responses: string[]): void { // POSIX first install, healthy npm install and node-pty probe. const POSIX_FIRST_INSTALL = [ '__ORCA_REMOTE_PLATFORM__ Linux x86_64', - SHELL_HOME, + BOOTSTRAP_ABSENT, '', // bounded stale-stage recovery STAGE_RESERVED, '', // chmod staged node @@ -255,6 +273,7 @@ const POSIX_FIRST_INSTALL = [ 'ORCA-NPTY-PROBE-OK\n', '', // rm probe stderr '', // clean stage root + ...AUTHORITY_LAUNCH, 'DEAD', '', // publish the per-launch credential 'READY' @@ -262,7 +281,7 @@ const POSIX_FIRST_INSTALL = [ const POSIX_SYSTEM_SSH_FIRST_INSTALL = [ '__ORCA_REMOTE_PLATFORM__ Linux x86_64', - SHELL_HOME, + BOOTSTRAP_ABSENT, '', // bounded stale-stage recovery STAGE_RESERVED, '', // chmod staged node @@ -272,6 +291,7 @@ const POSIX_SYSTEM_SSH_FIRST_INSTALL = [ 'ORCA-NPTY-PROBE-OK\n', '', // rm probe stderr '', // clean stage root + ...AUTHORITY_LAUNCH, 'DEAD', '', // publish the per-launch credential 'READY' @@ -280,7 +300,7 @@ const POSIX_SYSTEM_SSH_FIRST_INSTALL = [ // POSIX repair of an installed dir whose native deps are missing. const POSIX_REPAIR = [ '__ORCA_REMOTE_PLATFORM__ Linux x86_64', - SHELL_HOME, + BOOTSTRAP_ABSENT, 'MISSING', // probe before the repair lock 'MISSING', // re-probe under the lock '', // install-owner marker @@ -288,6 +308,7 @@ const POSIX_REPAIR = [ '', // chmod prebuilds 'ORCA-NPTY-PROBE-OK\n', '', // rm probe stderr + ...AUTHORITY_LAUNCH, 'DEAD', '', // publish the per-launch credential 'READY' @@ -295,9 +316,10 @@ const POSIX_REPAIR = [ const POSIX_HEALTHY_RECONNECT = [ '__ORCA_REMOTE_PLATFORM__ Linux x86_64', - SHELL_HOME, + BOOTSTRAP_ABSENT, 'ORCA-NATIVE-DEPS-OK', '', // per-launch namespace marker + ...AUTHORITY_LAUNCH, 'DEAD', '', // publish the per-launch credential 'READY' @@ -488,7 +510,7 @@ describe('relay install writes on a split SFTP namespace', () => { return Promise.resolve('__ORCA_REMOTE_PLATFORM__ Windows AMD64') } if (decoded.includes('[Environment]::GetFolderPath')) { - return Promise.resolve('C:\\Users\\u') + return Promise.resolve(sshTerminalAuthorityBootstrapRead('C:\\Users\\u')) } if (decoded.includes('__ORCA_UPLOAD_STAGE_SLOT__')) { return Promise.resolve(STAGE_RESERVED) @@ -593,9 +615,7 @@ describe('relay repair writes on a split SFTP namespace', () => { await deployAndLaunchRelay(conn) expect(capture.writePaths).toEqual([]) - expect(execCommands().find((command) => command.includes('randomBytes'))).toContain( - `${SHELL_RELAY_DIR}/relay.sock.credential` - ) + expect(relayCredentialCommand()).toContain(`${SHELL_RELAY_DIR}/relay.sock.credential`) }) it('does not redirect a healthy reconnect credential through fallback SFTP', async () => { @@ -605,9 +625,7 @@ describe('relay repair writes on a split SFTP namespace', () => { await deployAndLaunchRelay(conn) expect(capture.writePaths).toEqual([]) - expect(execCommands().find((command) => command.includes('randomBytes'))).toContain( - `${SHELL_RELAY_DIR}/relay.sock.credential` - ) + expect(relayCredentialCommand()).toContain(`${SHELL_RELAY_DIR}/relay.sock.credential`) }) it.each(['busy', 'error'] as const)( @@ -617,8 +635,9 @@ describe('relay repair writes on a split SFTP namespace', () => { vi.mocked(tryAcquireRelayRepairLock).mockResolvedValue(lockResult) feed([ '__ORCA_REMOTE_PLATFORM__ Linux x86_64', - SHELL_HOME, + BOOTSTRAP_ABSENT, 'ORCA-NATIVE-DEPS-OK', + ...AUTHORITY_LAUNCH, 'DEAD', '', // remote credential generation 'READY' @@ -628,7 +647,7 @@ describe('relay repair writes on a split SFTP namespace', () => { expect(capture.writePaths).toEqual([]) expect(execCommands().some((command) => MARKER_PATTERN.test(command))).toBe(false) - const credentialCommand = execCommands().find((command) => command.includes('randomBytes')) + const credentialCommand = relayCredentialCommand() expect(credentialCommand).toContain(`${SHELL_RELAY_DIR}/relay.sock.credential`) expect(credentialCommand).not.toContain(SFTP_RELAY_DIR) } @@ -638,8 +657,9 @@ describe('relay repair writes on a split SFTP namespace', () => { const conn = makeConnection(capture, { systemSsh: true, transferMethods: true }) feed([ '__ORCA_REMOTE_PLATFORM__ Linux x86_64', - SHELL_HOME, + BOOTSTRAP_ABSENT, 'ORCA-NATIVE-DEPS-OK', + ...AUTHORITY_LAUNCH, 'DEAD', '', // remote credential generation 'READY' @@ -648,24 +668,20 @@ describe('relay repair writes on a split SFTP namespace', () => { await deployAndLaunchRelay(conn) expect(conn.sftp).not.toHaveBeenCalled() - expect(execCommands().find((command) => command.includes('randomBytes'))).toContain( - `${SHELL_RELAY_DIR}/relay.sock.credential` - ) + expect(relayCredentialCommand()).toContain(`${SHELL_RELAY_DIR}/relay.sock.credential`) expect(capture.writePaths).toEqual([]) }) it('falls back to remote credential generation when a healthy marker is unavailable', async () => { const conn = makeConnection(capture) - feed(['__ORCA_REMOTE_PLATFORM__ Linux x86_64', SHELL_HOME, 'ORCA-NATIVE-DEPS-OK']) + feed(['__ORCA_REMOTE_PLATFORM__ Linux x86_64', BOOTSTRAP_ABSENT, 'ORCA-NATIVE-DEPS-OK']) vi.mocked(execCommand).mockRejectedValueOnce(new Error('read-only marker')) - feed(['DEAD', '', 'READY']) + feed([...AUTHORITY_LAUNCH, 'DEAD', '', 'READY']) await deployAndLaunchRelay(conn) expect(capture.writePaths).toEqual([]) - expect(execCommands().find((command) => command.includes('randomBytes'))).toContain( - `${SHELL_RELAY_DIR}/relay.sock.credential` - ) + expect(relayCredentialCommand()).toContain(`${SHELL_RELAY_DIR}/relay.sock.credential`) }) it('stamps the marker only after the locked recheck, then redirects package.json', async () => { @@ -691,13 +707,14 @@ describe('relay repair writes on a split SFTP namespace', () => { const conn = makeConnection(capture, { systemSsh: true, transferMethods: true }) feed([ '__ORCA_REMOTE_PLATFORM__ Linux x86_64', - SHELL_HOME, + BOOTSTRAP_ABSENT, 'MISSING', 'MISSING', '', // npm install native deps '', // chmod prebuilds 'ORCA-NPTY-PROBE-OK\n', '', // rm probe stderr + ...AUTHORITY_LAUNCH, 'DEAD', '', // remote credential generation 'READY' @@ -709,20 +726,19 @@ describe('relay repair writes on a split SFTP namespace', () => { expect(conn.sftp).not.toHaveBeenCalled() expect(capture.writePaths).toEqual([`${SHELL_RELAY_DIR}/package.json`]) expect(capture.writeOptions).toEqual([expect.objectContaining({ sftpNamespace: undefined })]) - expect(execCommands().find((command) => command.includes('randomBytes'))).toContain( - `${SHELL_RELAY_DIR}/relay.sock.credential` - ) + expect(relayCredentialCommand()).toContain(`${SHELL_RELAY_DIR}/relay.sock.credential`) }) it('degrades to shell paths when marker creation fails outright', async () => { const conn = makeConnection(capture) - feed(['__ORCA_REMOTE_PLATFORM__ Linux x86_64', SHELL_HOME, 'MISSING', 'MISSING']) + feed(['__ORCA_REMOTE_PLATFORM__ Linux x86_64', BOOTSTRAP_ABSENT, 'MISSING', 'MISSING']) vi.mocked(execCommand).mockRejectedValueOnce(new Error('read-only file system')) feed([ '', // npm install native deps '', // chmod prebuilds 'ORCA-NPTY-PROBE-OK\n', '', // rm probe stderr + ...AUTHORITY_LAUNCH, 'DEAD', '', // remote credential generation 'READY' @@ -731,9 +747,7 @@ describe('relay repair writes on a split SFTP namespace', () => { await deployAndLaunchRelay(conn) expect(capture.writePaths).toEqual([`${SHELL_RELAY_DIR}/package.json`]) - expect(execCommands().find((command) => command.includes('randomBytes'))).toContain( - `${SHELL_RELAY_DIR}/relay.sock.credential` - ) + expect(relayCredentialCommand()).toContain(`${SHELL_RELAY_DIR}/relay.sock.credential`) expect(capture.realpathCalls).toEqual([]) expect(warnSpy.mock.calls.map((args) => String(args[0]))).toContainEqual( expect.stringContaining('SFTP namespace marker unavailable') @@ -742,18 +756,16 @@ describe('relay repair writes on a split SFTP namespace', () => { it('keeps the repair lock when marker creation has unconfirmed termination', async () => { const conn = makeConnection(capture) - feed(['__ORCA_REMOTE_PLATFORM__ Linux x86_64', SHELL_HOME, 'MISSING', 'MISSING']) + feed(['__ORCA_REMOTE_PLATFORM__ Linux x86_64', BOOTSTRAP_ABSENT, 'MISSING', 'MISSING']) vi.mocked(execCommand).mockRejectedValueOnce( Object.assign(new Error('marker teardown unconfirmed'), { sshChannelCloseConfirmed: false }) ) - feed(['DEAD', '', 'READY']) + feed([...AUTHORITY_LAUNCH, 'DEAD', '', 'READY']) await deployAndLaunchRelay(conn) expect(capture.writePaths).toEqual([]) - expect(execCommands().find((command) => command.includes('randomBytes'))).toContain( - `${SHELL_RELAY_DIR}/relay.sock.credential` - ) + expect(relayCredentialCommand()).toContain(`${SHELL_RELAY_DIR}/relay.sock.credential`) expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled() expect(warnSpy.mock.calls.map((args) => String(args[0]))).toContainEqual( diff --git a/src/main/ssh/ssh-relay-versioned-install-authority.test.ts b/src/main/ssh/ssh-relay-versioned-install-authority.test.ts new file mode 100644 index 00000000000..c7217afedd8 --- /dev/null +++ b/src/main/ssh/ssh-relay-versioned-install-authority.test.ts @@ -0,0 +1,65 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('fs', () => ({ + existsSync: vi.fn(), + readFileSync: vi.fn() +})) + +vi.mock('./ssh-relay-deploy-helpers', () => ({ + execCommand: vi.fn() +})) + +vi.mock('./ssh-connection-utils', () => ({ + shellEscape: (value: string) => `'${value}'` +})) + +import { execCommand } from './ssh-relay-deploy-helpers' +import type { SshConnection } from './ssh-connection' +import { gcOldRelayVersions } from './ssh-relay-versioned-install' + +const conn = {} as SshConnection +const mockExec = vi.mocked(execCommand) + +describe('terminal authority relay GC protection', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('never collects the relay directory named by the terminal authority', async () => { + mockExec.mockResolvedValueOnce('relay-0.1.0+aaa\nrelay-0.1.0+ccc\n') + mockExec + .mockResolvedValueOnce('OPEN') + .mockResolvedValueOnce('COMPLETE') + .mockResolvedValueOnce('ALIVE') + + await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb', undefined, { + protectedRelayDir: '/home/u/.orca-remote/relay-0.1.0+aaa' + }) + + const commands = mockExec.mock.calls.map(([, command]) => command) + expect(commands.some((command) => command.includes('relay-0.1.0+aaa/.install'))).toBe(false) + expect(commands.some((command) => command.includes('relay-0.1.0+ccc/.install'))).toBe(true) + }) + + it('never collects imported or unresolved legacy relay directories', async () => { + mockExec.mockResolvedValueOnce( + 'relay-0.1.0+aaa\nrelay-0.1.0+bbb\nrelay-0.1.0+ccc\nrelay-0.1.0+ddd\n' + ) + mockExec + .mockResolvedValueOnce('OPEN') + .mockResolvedValueOnce('COMPLETE') + .mockResolvedValueOnce('ALIVE') + + await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+ddd', undefined, { + protectedRelayDirs: [ + '/home/u/.orca-remote/relay-0.1.0+aaa', + '/home/u/.orca-remote/relay-0.1.0+bbb' + ] + }) + + const commands = mockExec.mock.calls.map(([, command]) => command) + expect(commands.some((command) => command.includes('relay-0.1.0+aaa/.install'))).toBe(false) + expect(commands.some((command) => command.includes('relay-0.1.0+bbb/.install'))).toBe(false) + expect(commands.some((command) => command.includes('relay-0.1.0+ccc/.install'))).toBe(true) + }) +}) diff --git a/src/main/ssh/ssh-relay-versioned-install.test.ts b/src/main/ssh/ssh-relay-versioned-install.test.ts index 23d57757b5b..557cff3e352 100644 --- a/src/main/ssh/ssh-relay-versioned-install.test.ts +++ b/src/main/ssh/ssh-relay-versioned-install.test.ts @@ -466,6 +466,7 @@ describe('relay GC claim', () => { expect(relayGcClaimPath('/relay/version')).toBe('/relay/version.gc-claim') expect(mockExec.mock.calls[0]?.[1]).toContain("mkdir '/relay/version.gc-claim'") + expect(mockExec.mock.calls[0]?.[1]).toContain('.gc-owner-gc-') }) it('recovers and removes a stale sibling claim before retrying deploy', async () => { @@ -506,12 +507,15 @@ describe('relay GC claim', () => { ) const ownerScript = decodePowerShellCommand(mockExec.mock.calls[1]?.[1] ?? '') + const acquireScript = decodePowerShellCommand(mockExec.mock.calls[0]?.[1] ?? '') const releaseScript = decodePowerShellCommand(mockExec.mock.calls[2]?.[1] ?? '') + expect(acquireScript).toContain('.gc-owner-gc-') expect(ownerScript).toContain('Set-Content -LiteralPath') expect(ownerScript).toContain('.gc-claim/.gc-owner') expect(releaseScript).toContain('Get-Content -LiteralPath') expect(releaseScript).toContain('-cne') expect(releaseScript).toContain('Remove-Item -LiteralPath') + expect(releaseScript).toContain('.gc-owner-gc-') expect(releaseScript).toContain("'RELEASED'") expect(releaseScript).toContain("'LOST'") expect(releaseScript).toContain("'UNKNOWN'") diff --git a/src/main/ssh/ssh-relay-versioned-install.ts b/src/main/ssh/ssh-relay-versioned-install.ts index e4380c319c3..9f3d9c65004 100644 --- a/src/main/ssh/ssh-relay-versioned-install.ts +++ b/src/main/ssh/ssh-relay-versioned-install.ts @@ -24,21 +24,20 @@ import { moveRemoteTreeCommand, probeFileExistsCommand, probeRelayInstalledCommand, - relayLivenessProbeCommand, removeRemoteTreeCommand, writeRemoteEmptyFileCommand } from './ssh-remote-commands' import { getRemoteHostPlatform, - isWindowsRemoteHost, joinRemotePath, remoteBasename, type RemoteHostPlatform, type RemotePathFlavor } from './ssh-remote-platform' -import { windowsRelayPipePathsForSocketName } from './ssh-relay-endpoints' import { isUnconfirmedSshCommandTermination } from './ssh-relay-exec-command' import { isSshSessionLimitError } from './ssh-session-limit-error' +import { protectedRelayDirectoryNames } from './ssh-relay-gc-protection' +import { hasLiveRelaySocket } from './ssh-relay-live-socket' // Single source of truth for GC and the version-dir parser; matches both the new and legacy relay-dir layouts. const RELAY_VERSION_DIR_REGEX = /^relay-(v?\d+\.\d+\.\d+(\+[0-9a-f]+)?)$/ @@ -183,10 +182,13 @@ export async function gcOldRelayVersions( options?: { windowsNodePath?: string windowsSockNames?: string[] + protectedRelayDir?: string + protectedRelayDirs?: readonly string[] } ): Promise { const baseDir = joinRemotePath(host, remoteHome, RELAY_REMOTE_DIR) const currentDirName = remoteBasename(currentDirAbsPath, host) + const protectedDirNames = protectedRelayDirectoryNames(options ?? {}, host) let listing: string try { listing = await execHostCommand(conn, host, listRelayBaseDirsCommand(host, baseDir)) @@ -204,6 +206,9 @@ export async function gcOldRelayVersions( const candidates = entries .filter((name) => RELAY_VERSION_DIR_REGEX.test(name)) .filter((name) => name !== currentDirName) + .filter( + (name) => !protectedDirNames.has(host.pathFlavor === 'windows' ? name.toLowerCase() : name) + ) if (candidates.length === 0) { return @@ -315,48 +320,14 @@ async function isCandidateSafeToRemove( host, probeFileExistsCommand(host, completePath) ).catch(() => 'PARTIAL') + // Crashed-install partial; leave for the next deploy to recover. if (completeProbe.trim() !== 'COMPLETE') { - // Crashed-install partial; leave for the next deploy to recover. return false } } - const sockAlive = await hasLiveRelaySocket(conn, dir, host, options) - if (sockAlive) { + if (await hasLiveRelaySocket(conn, dir, host, options)) { return false } return true } - -async function hasLiveRelaySocket( - conn: SshConnection, - dir: string, - host: RemoteHostPlatform = DEFAULT_REMOTE_HOST, - options?: { - windowsNodePath?: string - windowsSockNames?: string[] - } -): Promise { - try { - // Why: `test -S` only — a connect-and-close probe would race with a daemon about to idle. - const windowsOptions = - isWindowsRemoteHost(host) && options?.windowsNodePath - ? { - nodePath: options.windowsNodePath, - pipePaths: (options.windowsSockNames ?? []).flatMap((sockName) => - windowsRelayPipePathsForSocketName(host, dir, sockName) - ) - } - : undefined - const out = await execHostCommand( - conn, - host, - relayLivenessProbeCommand(host, dir, windowsOptions) - ) - const state = out.trim() - return state !== 'DEAD' && state !== 'WAITING' - } catch { - // Why: an inconclusive liveness probe must never authorize deletion. - return true - } -} diff --git a/src/main/ssh/ssh-remote-cli-launcher-client.test.ts b/src/main/ssh/ssh-remote-cli-launcher-client.test.ts new file mode 100644 index 00000000000..ecab535a524 --- /dev/null +++ b/src/main/ssh/ssh-remote-cli-launcher-client.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, vi } from 'vitest' +import { + SSH_REMOTE_CLI_LAUNCHER_INSTALL_METHOD, + SSH_REMOTE_CLI_LAUNCHER_INSTALL_VERSION +} from '../../shared/ssh-remote-cli-launcher-install' +import { installSshRemoteCliLauncher } from './ssh-remote-cli-launcher-client' +import { getRemoteHostPlatform } from './ssh-remote-platform' + +describe('SSH remote CLI launcher client', () => { + it('installs over the existing relay channel without requiring a raw SSH connection', async () => { + const request = vi.fn().mockResolvedValue({ + version: SSH_REMOTE_CLI_LAUNCHER_INSTALL_VERSION, + launcherPath: 'C:\\Users\\me\\.orca-relay\\bin\\orca.exe', + changed: true + }) + + await installSshRemoteCliLauncher( + { request }, + { + binDir: 'C:/Users/me/.orca-relay/bin', + relayDir: 'C:/Users/me/.orca-remote/relay-v1', + nodePath: 'C:/Program Files/nodejs/node.exe', + sockPath: '\\\\.\\pipe\\orca-relay-123', + credentialFile: 'C:/Users/me/.orca-remote/relay-v1/relay.credential', + hostPlatform: getRemoteHostPlatform('win32-x64') + } + ) + + expect(request).toHaveBeenCalledOnce() + expect(request).toHaveBeenCalledWith( + SSH_REMOTE_CLI_LAUNCHER_INSTALL_METHOD, + { + version: SSH_REMOTE_CLI_LAUNCHER_INSTALL_VERSION, + binDir: 'C:/Users/me/.orca-relay/bin', + relayDir: 'C:/Users/me/.orca-remote/relay-v1', + nodePath: 'C:/Program Files/nodejs/node.exe', + sockPath: '\\\\.\\pipe\\orca-relay-123', + credentialFile: 'C:/Users/me/.orca-remote/relay-v1/relay.credential' + }, + { timeoutMs: 60_000 } + ) + }) + + it('fails closed when the relay does not acknowledge the exact launcher path', async () => { + const request = vi.fn().mockResolvedValue({ + version: SSH_REMOTE_CLI_LAUNCHER_INSTALL_VERSION, + launcherPath: '/tmp/unrelated/orca', + changed: true + }) + + await expect( + installSshRemoteCliLauncher( + { request }, + { + binDir: '/home/me/.orca-relay/bin', + relayDir: '/home/me/.orca-remote/relay-v1', + nodePath: '/usr/bin/node', + sockPath: '/home/me/.orca-remote/relay-v1/relay.sock', + hostPlatform: getRemoteHostPlatform('linux-x64') + } + ) + ).rejects.toThrow('remote_cli_launcher_invalid_result_path') + }) +}) diff --git a/src/main/ssh/ssh-remote-cli-launcher-client.ts b/src/main/ssh/ssh-remote-cli-launcher-client.ts new file mode 100644 index 00000000000..d25d16aa74c --- /dev/null +++ b/src/main/ssh/ssh-remote-cli-launcher-client.ts @@ -0,0 +1,53 @@ +import { + SSH_REMOTE_CLI_LAUNCHER_INSTALL_METHOD, + SSH_REMOTE_CLI_LAUNCHER_INSTALL_VERSION, + parseSshRemoteCliLauncherInstallResult +} from '../../shared/ssh-remote-cli-launcher-install' +import type { SshChannelMultiplexer } from './ssh-channel-multiplexer' +import { + isWindowsRemoteHost, + joinRemotePath, + normalizeWindowsRemotePath, + type RemoteHostPlatform +} from './ssh-remote-platform' + +export type SshRemoteCliLauncherEnvironment = { + binDir: string + relayDir: string + nodePath: string + sockPath: string + credentialFile?: string + hostPlatform: RemoteHostPlatform +} + +export async function installSshRemoteCliLauncher( + mux: Pick, + env: SshRemoteCliLauncherEnvironment +): Promise { + const { binDir, relayDir, nodePath, sockPath, credentialFile, hostPlatform } = env + const result = parseSshRemoteCliLauncherInstallResult( + await mux.request( + SSH_REMOTE_CLI_LAUNCHER_INSTALL_METHOD, + { + version: SSH_REMOTE_CLI_LAUNCHER_INSTALL_VERSION, + binDir, + relayDir, + nodePath, + sockPath, + ...(credentialFile ? { credentialFile } : {}) + }, + { timeoutMs: 60_000 } + ) + ) + const expectedPath = joinRemotePath( + hostPlatform, + binDir, + isWindowsRemoteHost(hostPlatform) ? 'orca.exe' : 'orca' + ) + const normalize = isWindowsRemoteHost(hostPlatform) + ? normalizeWindowsRemotePath + : (value: string): string => value + if (normalize(result.launcherPath) !== normalize(expectedPath)) { + throw new Error('remote_cli_launcher_invalid_result_path') + } +} diff --git a/src/main/ssh/ssh-remote-cli-launcher.test.ts b/src/main/ssh/ssh-remote-cli-launcher.test.ts deleted file mode 100644 index 3abdd0fcb73..00000000000 --- a/src/main/ssh/ssh-remote-cli-launcher.test.ts +++ /dev/null @@ -1,241 +0,0 @@ -import { Buffer } from 'node:buffer' -import { spawnSync } from 'node:child_process' -import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' -import { describe, expect, it } from 'vitest' -import { createRemoteCliInstallPlan } from './ssh-remote-cli-launcher' -import { getRemoteHostPlatform } from './ssh-remote-platform' - -// Why: cold csc.exe startup exceeds Vitest's 5s unit budget on hosted Windows; -// keep the larger allowance scoped to the real compiler integration test. -function itWindows(name: string, test: () => void): void { - const runner = process.platform === 'win32' ? it : it.skip - runner(name, { timeout: 15_000 }, test) -} - -function decodePowerShellCommand(command: string): string { - const encoded = command.match(/-EncodedCommand\s+([A-Za-z0-9+/=]+)/)?.[1] - if (!encoded) { - throw new Error(`Expected an encoded PowerShell command: ${command}`) - } - return Buffer.from(encoded, 'base64').toString('utf16le') -} - -describe('SSH remote Orca CLI launcher', () => { - function windowsInstallPlan(): ReturnType { - return createRemoteCliInstallPlan({ - binDir: 'C:/Users/me user/.orca-relay/bin', - relayDir: 'C:/Users/me user/.orca-remote/relay-v1', - nodePath: 'C:/Program Files/nodejs/node.exe', - sockPath: '\\\\.\\pipe\\orca-relay-123', - credentialFile: 'C:/Users/me user/.orca-remote/relay-v1/relay.sock.credential', - hostPlatform: getRemoteHostPlatform('win32-x64') - }) - } - - it('compiles a native Windows launcher without a cmd.exe argument bridge', () => { - const plan = windowsInstallPlan() - - expect(plan.launcherPath).toBe('C:/Users/me user/.orca-relay/bin/orca.exe') - expect(plan.files).toHaveLength(1) - expect(plan.files[0]?.path).toBe('C:/Users/me user/.orca-relay/bin/orca-launcher.cs') - expect(plan.files[0]?.contents).toContain('ProcessStartInfo') - expect(plan.files[0]?.contents).toContain('"--orca-cli"') - expect(plan.files[0]?.contents).toContain('socketPath + ".credential"') - expect(plan.files[0]?.contents).toContain("value[index] == '\"'") - expect(plan.files[0]?.contents).toContain("character == '\\\\'") - expect(plan.files[0]?.contents).not.toContain('cmd.exe') - expect(plan.files[0]?.contents).not.toContain('%*') - - expect(plan.postWriteCommands).toHaveLength(1) - const compileScript = decodePowerShellCommand(plan.postWriteCommands[0] ?? '') - expect(compileScript).toContain('v4.0.30319\\csc.exe') - // Why: legacy csc.exe is invoked from the bin directory with bare, space-free - // file names so PowerShell 5.1 never mangles a space-bearing absolute path. - expect(compileScript).toContain( - "Set-Location -ErrorAction Stop -LiteralPath 'C:/Users/me user/.orca-relay/bin'" - ) - expect(compileScript).toContain('/out:orca.exe') - expect(compileScript).toContain('C:/Users/me user/.orca-relay/bin/orca-launcher.cs') - expect(compileScript).toContain('C:/Users/me user/.orca-relay/bin/orca.cmd') - }) - - it('removes the legacy orca.cmd only after every compile guard has passed', () => { - const script = decodePowerShellCommand(windowsInstallPlan().postWriteCommands[0] ?? '') - const legacyShimRemoval = - "Remove-Item -LiteralPath 'C:/Users/me user/.orca-relay/bin/orca.cmd' -Force -ErrorAction SilentlyContinue" - // Why: a host missing csc.exe or failing the compile must keep its existing - // CLI, so every fail-closed guard precedes the legacy %* shim removal. - const guards = [ - "if (-not $compiler) { Write-Error 'Unable to find the .NET Framework C# compiler required for the Orca SSH CLI launcher.'; exit 1 }", - 'if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }', - "if (-not (Test-Path -LiteralPath 'C:/Users/me user/.orca-relay/bin/orca.exe' -PathType Leaf))" - ] - expect(script).toContain(legacyShimRemoval) - for (const guard of guards) { - expect(script).toContain(guard) - expect(script.indexOf(guard)).toBeLessThan(script.indexOf(legacyShimRemoval)) - } - }) - - itWindows('preserves a multiline argument through the compiled remote launcher', () => { - const root = mkdtempSync(join(tmpdir(), 'orca remote cli ')) - try { - const binDir = join(root, 'bin').replaceAll('\\', '/') - const relayDir = join(root, 'relay').replaceAll('\\', '/') - const sockPath = '\\\\.\\pipe\\orca-relay-test' - const credentialFile = `${relayDir}/relay.sock.credential` - const plan = createRemoteCliInstallPlan({ - binDir, - relayDir, - nodePath: process.execPath, - sockPath, - credentialFile, - hostPlatform: getRemoteHostPlatform('win32-x64') - }) - for (const file of plan.files) { - mkdirSync(dirname(file.path), { recursive: true }) - writeFileSync(file.path, file.contents, 'utf8') - } - - const encoded = plan.postWriteCommands[0]?.match(/-EncodedCommand\s+(\S+)/)?.[1] - expect(encoded).toBeTruthy() - const compile = spawnSync( - 'powershell.exe', - [ - '-NoProfile', - '-NonInteractive', - '-ExecutionPolicy', - 'Bypass', - '-EncodedCommand', - encoded! - ], - { encoding: 'utf8' } - ) - expect(compile.status, `${compile.stdout}\n${compile.stderr}`).toBe(0) - - mkdirSync(relayDir, { recursive: true }) - writeFileSync( - join(relayDir, 'relay.js'), - 'process.stdout.write(JSON.stringify(process.argv.slice(2)))\n', - 'utf8' - ) - const body = 'line one\nline two & whoami\n"quoted" C:\\tail\\' - const launched = spawnSync( - plan.launcherPath, - ['orchestration', 'send', '--body', body, '--json'], - { - encoding: 'utf8', - env: { - ...process.env, - ORCA_RELAY_NODE_PATH: process.execPath, - ORCA_RELAY_DIR: relayDir, - ORCA_RELAY_SOCKET_PATH: sockPath, - ORCA_RELAY_CREDENTIAL_FILE: credentialFile - } - } - ) - - expect(launched.status, launched.stderr).toBe(0) - expect(JSON.parse(launched.stdout)).toEqual([ - '--sock-path', - sockPath, - '--credential-file', - credentialFile, - '--orca-cli', - 'orchestration', - 'send', - '--body', - body, - '--json' - ]) - - const defaulted = spawnSync(plan.launcherPath, ['status'], { - encoding: 'utf8', - env: { - ...process.env, - ORCA_RELAY_NODE_PATH: process.execPath, - ORCA_RELAY_DIR: relayDir, - ORCA_RELAY_SOCKET_PATH: sockPath, - ORCA_RELAY_CREDENTIAL_FILE: '' - } - }) - expect(defaulted.status, defaulted.stderr).toBe(0) - expect(JSON.parse(defaulted.stdout)).toEqual([ - '--sock-path', - sockPath, - '--credential-file', - `${sockPath}.credential`, - '--orca-cli', - 'status' - ]) - } finally { - rmSync(root, { recursive: true, force: true }) - } - }) - - itWindows('preserves the existing orca.cmd when the compiler is missing', () => { - const root = mkdtempSync(join(tmpdir(), 'orca remote cli ')) - try { - const binDir = join(root, 'bin').replaceAll('\\', '/') - mkdirSync(binDir, { recursive: true }) - const legacyShimPath = join(binDir, 'orca.cmd') - writeFileSync(legacyShimPath, '@echo legacy orca cli\r\n', 'utf8') - - const plan = createRemoteCliInstallPlan({ - binDir, - relayDir: join(root, 'relay').replaceAll('\\', '/'), - nodePath: process.execPath, - sockPath: '\\\\.\\pipe\\orca-relay-test', - credentialFile: join(root, 'relay', 'relay.sock.credential').replaceAll('\\', '/'), - hostPlatform: getRemoteHostPlatform('win32-x64') - }) - for (const file of plan.files) { - mkdirSync(dirname(file.path), { recursive: true }) - writeFileSync(file.path, file.contents, 'utf8') - } - - const encoded = plan.postWriteCommands[0]?.match(/-EncodedCommand\s+(\S+)/)?.[1] - expect(encoded).toBeTruthy() - // Point WINDIR at a directory with no csc.exe so compiler discovery fails. - const compile = spawnSync( - 'powershell.exe', - [ - '-NoProfile', - '-NonInteractive', - '-ExecutionPolicy', - 'Bypass', - '-EncodedCommand', - encoded! - ], - { encoding: 'utf8', env: { ...process.env, WINDIR: root, SystemRoot: root } } - ) - - expect(compile.status).not.toBe(0) - expect(existsSync(legacyShimPath), 'existing orca.cmd must survive a failed install').toBe( - true - ) - } finally { - rmSync(root, { recursive: true, force: true }) - } - }) - - it('keeps the POSIX launcher as an argv-preserving shell exec', () => { - const plan = createRemoteCliInstallPlan({ - binDir: '/home/me/.orca-relay/bin', - relayDir: '/home/me/.orca-remote/relay-v1', - nodePath: '/usr/bin/node', - sockPath: '/home/me/.orca-remote/relay-v1/relay.sock', - hostPlatform: getRemoteHostPlatform('linux-x64') - }) - - expect(plan.launcherPath).toBe('/home/me/.orca-relay/bin/orca') - expect(plan.files).toEqual([ - expect.objectContaining({ - path: '/home/me/.orca-relay/bin/orca', - contents: expect.stringContaining('--orca-cli "$@"') - }) - ]) - }) -}) diff --git a/src/main/ssh/ssh-remote-cli-launcher.ts b/src/main/ssh/ssh-remote-cli-launcher.ts deleted file mode 100644 index aaa3d09b6ce..00000000000 --- a/src/main/ssh/ssh-remote-cli-launcher.ts +++ /dev/null @@ -1,247 +0,0 @@ -import type { RemoteHostPlatform } from './ssh-remote-platform' -import { isWindowsRemoteHost, joinRemotePath } from './ssh-remote-platform' -import { powerShellCommand, powerShellLiteral, powerShellNativeArg } from './ssh-remote-powershell' - -type RemoteCliInstallEnv = { - binDir: string - relayDir: string - nodePath: string - sockPath: string - credentialFile?: string - hostPlatform: RemoteHostPlatform -} - -type RemoteCliInstallFile = { - path: string - contents: string -} - -export type RemoteCliInstallPlan = { - launcherPath: string - files: RemoteCliInstallFile[] - postWriteCommands: string[] -} - -const WINDOWS_REMOTE_CLI_LAUNCHER_SOURCE = String.raw`using System; -using System.Diagnostics; -using System.IO; -using System.Text; - -internal static class OrcaRemoteCliLauncher -{ - private static int Main(string[] args) - { - try - { - string nodePath = RequireEnvironmentVariable("ORCA_RELAY_NODE_PATH"); - string relayDirectory = RequireEnvironmentVariable("ORCA_RELAY_DIR"); - string socketPath = RequireEnvironmentVariable("ORCA_RELAY_SOCKET_PATH"); - string credentialFile = Environment.GetEnvironmentVariable("ORCA_RELAY_CREDENTIAL_FILE"); - if (String.IsNullOrEmpty(credentialFile)) - { - credentialFile = socketPath + ".credential"; - } - string relayPath = Path.Combine(relayDirectory, "relay.js"); - - if (!File.Exists(nodePath)) - { - Console.Error.WriteLine("Orca SSH CLI bridge cannot find Node.js at \"{0}\"", nodePath); - return 1; - } - if (!File.Exists(relayPath)) - { - Console.Error.WriteLine("Orca SSH CLI bridge cannot find the relay at \"{0}\"", relayPath); - return 1; - } - - ProcessStartInfo startInfo = new ProcessStartInfo - { - FileName = nodePath, - Arguments = BuildArguments(relayPath, socketPath, credentialFile, args), - UseShellExecute = false - }; - - using (Process child = Process.Start(startInfo)) - { - child.WaitForExit(); - return child.ExitCode; - } - } - catch (Exception error) - { - Console.Error.WriteLine("Unable to start the Orca SSH CLI bridge: {0}", error.Message); - return 1; - } - } - - private static string RequireEnvironmentVariable(string name) - { - string value = Environment.GetEnvironmentVariable(name); - if (String.IsNullOrEmpty(value)) - { - throw new InvalidOperationException(name + " is not set."); - } - return value; - } - - private static string BuildArguments(string relayPath, string socketPath, string credentialFile, string[] args) - { - StringBuilder commandLine = new StringBuilder(); - AppendArgument(commandLine, relayPath); - AppendArgument(commandLine, "--sock-path"); - AppendArgument(commandLine, socketPath); - AppendArgument(commandLine, "--credential-file"); - AppendArgument(commandLine, credentialFile); - AppendArgument(commandLine, "--orca-cli"); - foreach (string arg in args) - { - AppendArgument(commandLine, arg); - } - return commandLine.ToString(); - } - - private static void AppendArgument(StringBuilder commandLine, string value) - { - if (commandLine.Length > 0) - { - commandLine.Append(' '); - } - commandLine.Append(QuoteArgument(value)); - } - - private static string QuoteArgument(string value) - { - bool requiresQuotes = value.Length == 0; - for (int index = 0; index < value.Length && !requiresQuotes; index += 1) - { - requiresQuotes = value[index] == '"' || Char.IsWhiteSpace(value[index]); - } - if (!requiresQuotes) - { - return value; - } - - StringBuilder quoted = new StringBuilder("\""); - int backslashCount = 0; - foreach (char character in value) - { - if (character == '\\') - { - backslashCount += 1; - continue; - } - if (character == '"') - { - quoted.Append('\\', backslashCount * 2 + 1); - quoted.Append('"'); - } - else - { - quoted.Append('\\', backslashCount); - quoted.Append(character); - } - backslashCount = 0; - } - - quoted.Append('\\', backslashCount * 2); - quoted.Append('"'); - return quoted.ToString(); - } -} -` - -function quoteSh(value: string): string { - return `'${value.replaceAll("'", `'\\''`)}'` -} - -function createWindowsLauncherCompileCommand( - binDir: string, - sourceFileName: string, - launcherFileName: string, - launcherPath: string, - sourcePath: string, - legacyShimPath: string -): string { - // Why: legacy csc.exe mis-parses space-bearing absolute paths handed to it by - // Windows PowerShell 5.1's native-argument quoting, so compile from the bin - // directory and pass only the bare, space-free launcher file names. - const compilerArgs = [ - '/nologo', - '/target:exe', - '/optimize+', - '/warnaserror+', - `/out:${launcherFileName}`, - sourceFileName - ] - .map(powerShellNativeArg) - .join(' ') - return powerShellCommand( - [ - `Set-Location -ErrorAction Stop -LiteralPath ${powerShellLiteral(binDir)}`, - '$windowsDirectory = if ($env:WINDIR) { $env:WINDIR } else { $env:SystemRoot }', - `$compilerCandidates = @((Join-Path $windowsDirectory 'Microsoft.NET\\Framework64\\v4.0.30319\\csc.exe'), (Join-Path $windowsDirectory 'Microsoft.NET\\Framework\\v4.0.30319\\csc.exe'))`, - '$compiler = $compilerCandidates | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1', - "if (-not $compiler) { Write-Error 'Unable to find the .NET Framework C# compiler required for the Orca SSH CLI launcher.'; exit 1 }", - `& $compiler ${compilerArgs}`, - 'if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }', - `if (-not (Test-Path -LiteralPath ${powerShellLiteral(launcherPath)} -PathType Leaf)) { Write-Error 'The Orca SSH CLI launcher compiler produced no executable.'; exit 1 }`, - // Why: remove the legacy %* bridge only after a successful compile, so a - // host missing csc.exe keeps its existing CLI (orca.exe shadows orca.cmd). - `Remove-Item -LiteralPath ${powerShellLiteral(legacyShimPath)} -Force -ErrorAction SilentlyContinue`, - `Remove-Item -LiteralPath ${powerShellLiteral(sourcePath)} -Force` - ].join('; ') - ) -} - -export function createRemoteCliInstallPlan(env: RemoteCliInstallEnv): RemoteCliInstallPlan { - if (isWindowsRemoteHost(env.hostPlatform)) { - const launcherFileName = 'orca.exe' - const sourceFileName = 'orca-launcher.cs' - const launcherPath = joinRemotePath(env.hostPlatform, env.binDir, launcherFileName) - const sourcePath = joinRemotePath(env.hostPlatform, env.binDir, sourceFileName) - const legacyShimPath = joinRemotePath(env.hostPlatform, env.binDir, 'orca.cmd') - const binDir = joinRemotePath(env.hostPlatform, env.binDir) - return { - launcherPath, - files: [{ path: sourcePath, contents: WINDOWS_REMOTE_CLI_LAUNCHER_SOURCE }], - // Why: compiling on the Windows target avoids shipping an unsigned - // cross-host binary while ensuring argv never crosses cmd.exe's parser. - postWriteCommands: [ - createWindowsLauncherCompileCommand( - binDir, - sourceFileName, - launcherFileName, - launcherPath, - sourcePath, - legacyShimPath - ) - ] - } - } - - const launcherPath = joinRemotePath(env.hostPlatform, env.binDir, 'orca') - return { - launcherPath, - files: [ - { - path: launcherPath, - contents: [ - '#!/usr/bin/env sh', - 'set -eu', - `ORCA_RELAY_NODE_PATH=\${ORCA_RELAY_NODE_PATH:-${quoteSh(env.nodePath)}}`, - `ORCA_RELAY_DIR=\${ORCA_RELAY_DIR:-${quoteSh(env.relayDir)}}`, - `ORCA_RELAY_SOCKET_PATH=\${ORCA_RELAY_SOCKET_PATH:-${quoteSh(env.sockPath)}}`, - `ORCA_RELAY_CREDENTIAL_FILE=\${ORCA_RELAY_CREDENTIAL_FILE:-${quoteSh(env.credentialFile ?? `${env.sockPath}.credential`)}}`, - 'if [ ! -S "$ORCA_RELAY_SOCKET_PATH" ]; then', - ' echo "Orca SSH CLI bridge cannot find the relay socket: $ORCA_RELAY_SOCKET_PATH" >&2', - ' exit 1', - 'fi', - 'exec "$ORCA_RELAY_NODE_PATH" "$ORCA_RELAY_DIR/relay.js" --sock-path "$ORCA_RELAY_SOCKET_PATH" --credential-file "$ORCA_RELAY_CREDENTIAL_FILE" --orca-cli "$@"', - '' - ].join('\n') - } - ], - // Surface chmod failures: a non-executable launcher must fail install loudly, not silently. - postWriteCommands: [`chmod +x ${quoteSh(launcherPath)}`] - } -} diff --git a/src/main/ssh/ssh-remote-commands.test.ts b/src/main/ssh/ssh-remote-commands.test.ts index 13a1e2e246a..8713d619c3e 100644 --- a/src/main/ssh/ssh-remote-commands.test.ts +++ b/src/main/ssh/ssh-remote-commands.test.ts @@ -3,6 +3,7 @@ import { existsSync, mkdirSync, mkdtempSync, + readFileSync, readdirSync, rmSync, statSync, @@ -10,7 +11,7 @@ import { } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { describe, expect, it } from 'vitest' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { lockAgeSecondsCommand, tryCreateInstallLockCommand, @@ -206,25 +207,40 @@ describe('ssh remote command builders', () => { ) }) - it('bounds real POSIX GC output with more than the exec-cap stage population', async () => { - const root = mkdtempSync(join(tmpdir(), 'orca-relay-gc-scale-')) - try { - for (let index = 0; index < 15_197; index += 1) { - mkdirSync(join(root, `relay-0.1.0+abc.upload-${String(index).padStart(12, '0')}`)) - } - mkdirSync(join(root, 'relay-0.1.0+aaa')) - mkdirSync(join(root, 'relay-0.1.0+bbb')) + describe('POSIX GC scale boundary', () => { + let posixGcScaleRoot: string | undefined - const output = await runShellCommand(listRelayBaseDirsCommand(posix, root)) + beforeAll(() => { + posixGcScaleRoot = mkdtempSync(join(tmpdir(), 'orca-relay-gc-scale-')) + for (let index = 0; index < 15_197; index += 1) { + mkdirSync( + join(posixGcScaleRoot, `relay-0.1.0+abc.upload-${String(index).padStart(12, '0')}`) + ) + } + mkdirSync(join(posixGcScaleRoot, 'relay-0.1.0+aaa')) + mkdirSync(join(posixGcScaleRoot, 'relay-0.1.0+bbb')) + }, 60_000) + + afterAll(() => { + if (posixGcScaleRoot) { + rmSync(posixGcScaleRoot, { recursive: true, force: true }) + posixGcScaleRoot = undefined + } + }, 60_000) + + it('bounds real POSIX GC output with more than the exec-cap stage population', async () => { + if (!posixGcScaleRoot) { + throw new Error('POSIX GC scale fixture was not initialized') + } + + const output = await runShellCommand(listRelayBaseDirsCommand(posix, posixGcScaleRoot)) const entries = output.trim().split('\n') expect(entries).toEqual(['relay-0.1.0+aaa', 'relay-0.1.0+bbb']) expect(Buffer.byteLength(output)).toBeLessThan(1_024) expect(entries.length).toBeLessThanOrEqual(MAX_RELAY_GC_LISTING_ENTRIES) - } finally { - rmSync(root, { recursive: true, force: true }) - } - }, 30_000) + }, 30_000) + }) it('fails closed when real POSIX GC enumeration fails', async () => { const root = mkdtempSync(join(tmpdir(), 'orca-relay-gc-failure-')) @@ -271,6 +287,23 @@ describe('ssh remote command builders', () => { expect(script).not.toContain('}; catch') }) + it('publishes token-specific install owners on POSIX and Windows', () => { + const token = 'install-owner-token-0001' + const posixCommand = tryCreateInstallLockCommand( + posix, + '/home/me/.orca-remote/relay/.install-lock', + token + ) + const windowsScript = decodePowerShellCommand( + tryCreateInstallLockCommand(windows, 'C:/Users/me/.orca-remote/relay/.install-lock', token) + ) + + expect(posixCommand).toContain(`.install-owner-${token}`) + expect(posixCommand).toContain(`printf %s '${token}'`) + expect(windowsScript).toContain(`.install-owner-${token}`) + expect(windowsScript).toContain("Join-Path $lock '.owner'") + }) + it('computes install-lock age on the remote host clock', () => { const posixCommand = lockAgeSecondsCommand(posix, '/home/me/.orca-remote/relay/.install-lock') const windowsScript = decodePowerShellCommand( @@ -434,6 +467,39 @@ describe('ssh remote command builders', () => { 15_000 ) + it.runIf(process.platform !== 'win32')( + 'publishes token-specific ownership during POSIX create and stale recovery', + () => { + const root = mkdtempSync(join(tmpdir(), 'orca-install-lock-owned-')) + const token = 'install-owner-token-0001' + try { + const lockDir = join(root, '.install-lock') + expect( + execFileSync('/bin/sh', ['-c', tryCreateInstallLockCommand(posix, lockDir, token)], { + encoding: 'utf8' + }).trim() + ).toBe('OK') + expect(readFileSync(join(lockDir, `.install-owner-${token}`), 'utf8')).toBe(token) + + const staleDate = new Date(Date.now() - 60 * 60_000) + utimesSync(lockDir, staleDate, staleDate) + const replacementToken = 'install-owner-token-0002' + expect( + execFileSync( + '/bin/sh', + ['-c', tryStealInstallLockCommand(posix, lockDir, 20 * 60, replacementToken)], + { encoding: 'utf8' } + ).trim() + ).toBe('OK') + expect(readFileSync(join(lockDir, `.install-owner-${replacementToken}`), 'utf8')).toBe( + replacementToken + ) + } finally { + rmSync(root, { recursive: true, force: true }) + } + } + ) + it.runIf(process.platform !== 'win32')( 'recovers and cleans more than eight orphaned numbered steal claims', () => { diff --git a/src/main/ssh/ssh-system-transport.integration.test.ts b/src/main/ssh/ssh-system-transport.integration.test.ts index be5ed622927..b9a493f5838 100644 --- a/src/main/ssh/ssh-system-transport.integration.test.ts +++ b/src/main/ssh/ssh-system-transport.integration.test.ts @@ -10,9 +10,10 @@ import { SshConnection } from './ssh-connection' import { deployAndLaunchRelay } from './ssh-relay-deploy' import { SshChannelMultiplexer } from './ssh-channel-multiplexer' import { uploadDirectoryViaSystemSsh } from './ssh-system-fallback' +import { CURRENT_RELAY_DAEMON_COMPATIBILITY } from '../../shared/relay-daemon-compatibility' import type { SshTarget } from '../../shared/ssh-types' -const RELAY_VERSION = '0.1.0+systemtransport' +const RELAY_VERSION = '0.1.0+abcdef012345' function makeTarget(): SshTarget { return { @@ -63,6 +64,11 @@ const fs = require('fs'); const net = require('net'); const sentinel = 'ORCA-RELAY v0.1.0 READY\\n'; const sockPath = process.argv[process.argv.indexOf('--sock-path') + 1]; +const authorityMarkerPath = process.argv[process.argv.indexOf('--authority-marker-path') + 1]; +const credentialFile = process.argv[process.argv.indexOf('--credential-file') + 1]; +const authorityProcessToken = process.argv[process.argv.indexOf('--authority-process-token') + 1]; +const relayVersion = '${RELAY_VERSION}'; +const compatibility = ${JSON.stringify(CURRENT_RELAY_DAEMON_COMPATIBILITY)}; function encode(msg) { const payload = Buffer.from(JSON.stringify(msg), 'utf8'); @@ -91,14 +97,68 @@ function serve(socket, onResolved) { if (message.method === 'session.resolveHome') { socket.write( encode({ jsonrpc: '2.0', id: message.id, result: process.env.HOME }), - onResolved + () => { + onResolved(); + socket.end(); + } ); } } }); } -if (process.argv.includes('--detached')) { +if (process.argv.includes('--terminal-authority')) { + const child = require('child_process').spawn( + process.execPath, + [ + __filename, + '--authority-server', + '--sock-path', + sockPath, + '--authority-marker-path', + authorityMarkerPath, + '--credential-file', + credentialFile, + '--authority-process-token', + authorityProcessToken + ], + { detached: true, stdio: 'ignore' } + ); + child.unref(); + process.exit(0); +} else if (process.argv.includes('--authority-server')) { + fs.mkdirSync(require('path').dirname(authorityMarkerPath), { recursive: true }); + fs.writeFileSync(authorityMarkerPath, JSON.stringify({ + markerVersion: 1, + authorityHostId: 'authority-host', + ownerInstanceId: 'authority-owner', + ownerPid: process.pid, + ownerProcessToken: authorityProcessToken, + ownerBuildId: relayVersion, + ownerRelayDir: require('path').join(process.env.HOME, '.orca-remote', 'relay-' + relayVersion), + socketPath: sockPath, + credentialFile, + compatibility, + revision: 1 + }) + '\\n'); + try { fs.unlinkSync(sockPath); } catch {} + const server = net.createServer((socket) => serve(socket, () => server.close())); + process.once('SIGTERM', () => { + try { server.close(); } catch {} + try { fs.unlinkSync(authorityMarkerPath); } catch {} + try { fs.unlinkSync(sockPath); } catch {} + process.exit(0); + }); + server.listen(sockPath); +} else if (process.argv.includes('--detached')) { + const child = require('child_process').spawn( + process.execPath, + [__filename, '--relay-server', '--sock-path', sockPath], + { detached: true, stdio: 'ignore' } + ); + child.unref(); + process.exit(0); +} else if (process.argv.includes('--relay-server')) { try { fs.unlinkSync(sockPath); } catch {} const server = net.createServer((socket) => { serve(socket, () => server.close()); @@ -146,6 +206,44 @@ function createRelayTree(root: string, remoteHome: string): void { writeFakeRelay(remoteDir) } +async function stopFakeAuthority(tempDir: string): Promise { + const markerPath = join( + tempDir, + 'remote-home', + '.orca-remote', + 'terminal-authority', + 'active-endpoint' + ) + let ownerPid: unknown + try { + ownerPid = JSON.parse(readFileSync(markerPath, 'utf8')).ownerPid + } catch { + return + } + if (typeof ownerPid !== 'number' || !Number.isInteger(ownerPid) || ownerPid <= 0) { + return + } + try { + process.kill(ownerPid, 'SIGTERM') + } catch { + return + } + const deadline = Date.now() + 5_000 + while (Date.now() < deadline) { + try { + process.kill(ownerPid, 0) + } catch { + return + } + await new Promise((resolve) => setTimeout(resolve, 25)) + } + try { + process.kill(ownerPid, 'SIGKILL') + } catch { + // The authority exited between the final probe and fallback kill. + } +} + describe('system SSH transport integration', () => { let tempDir: string let oldHome: string | undefined @@ -172,10 +270,11 @@ describe('system SSH transport integration', () => { process.env.ORCA_SSH_FORCE_SYSTEM_TRANSPORT = '1' }) - afterEach(() => { + afterEach(async () => { if (process.platform === 'win32') { return } + await stopFakeAuthority(tempDir) if (oldHome === undefined) { delete process.env.HOME } else { diff --git a/src/main/ssh/ssh-terminal-authority-admission.test.ts b/src/main/ssh/ssh-terminal-authority-admission.test.ts new file mode 100644 index 00000000000..c301f1a077c --- /dev/null +++ b/src/main/ssh/ssh-terminal-authority-admission.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from 'vitest' +import { CURRENT_RELAY_DAEMON_COMPATIBILITY } from '../../shared/relay-daemon-compatibility' +import { SSH_TERMINAL_AUTHORITY_MARKER_VERSION } from '../../shared/ssh-terminal-authority-marker' +import { getRemoteHostPlatform } from './ssh-remote-platform' +import { + admitSshTerminalAuthority, + admitSshTerminalAuthorityTakeover, + SshTerminalAuthorityAdmissionError +} from './ssh-terminal-authority-admission' +import { sshTerminalAuthorityEndpoint } from './ssh-terminal-authority-endpoint' + +const host = getRemoteHostPlatform('linux-x64') +const remoteHome = '/home/orca' +const endpoint = sshTerminalAuthorityEndpoint(host, remoteHome) +const marker = { + markerVersion: SSH_TERMINAL_AUTHORITY_MARKER_VERSION, + authorityHostId: 'authority-host', + ownerInstanceId: 'owner-instance', + ownerPid: 42, + ownerProcessToken: 'owner-process-token', + ownerBuildId: '1.2.3+abcdef', + ownerRelayDir: '/home/orca/.orca-remote/relay-1.2.3+abcdef', + socketPath: endpoint.socketPath, + credentialFile: endpoint.credentialFile, + compatibility: CURRENT_RELAY_DAEMON_COMPATIBILITY, + revision: 7 +} as const + +function expectCode(run: () => unknown, code: string): void { + try { + run() + } catch (error) { + expect(error).toBeInstanceOf(SshTerminalAuthorityAdmissionError) + expect((error as SshTerminalAuthorityAdmissionError).code).toBe(code) + return + } + throw new Error(`Expected ${code}`) +} + +describe('SSH terminal authority admission', () => { + it('launches only after a positive absent marker', () => { + expect(admitSshTerminalAuthority({ status: 'absent' }, host, remoteHome, endpoint)).toEqual({ + kind: 'launch-first-owner' + }) + expectCode( + () => admitSshTerminalAuthority({ status: 'inconclusive' }, host, remoteHome, endpoint), + 'discovery-inconclusive' + ) + }) + + it('admits an exact compatible live owner', () => { + expect( + admitSshTerminalAuthority({ status: 'available', marker }, host, remoteHome, endpoint) + ).toMatchObject({ + kind: 'connect-owner', + marker, + compatibility: { major: 1, minor: 0 } + }) + }) + + it('rejects path substitution and incompatible owners before connection', () => { + expectCode( + () => + admitSshTerminalAuthority( + { status: 'available', marker: { ...marker, socketPath: '/tmp/authority.sock' } }, + host, + remoteHome, + endpoint + ), + 'marker-path-mismatch' + ) + expectCode( + () => + admitSshTerminalAuthority( + { + status: 'available', + marker: { + ...marker, + compatibility: { ...CURRENT_RELAY_DAEMON_COMPATIBILITY, major: 99 } + } + }, + host, + remoteHome, + endpoint + ), + 'owner-incompatible' + ) + }) + + it('authorizes takeover only after unchanged-marker exact owner death', () => { + expect( + admitSshTerminalAuthorityTakeover({ + expectedMarker: marker, + rediscovery: { status: 'available', marker }, + ownerProof: 'owner-gone', + host, + remoteHome, + endpoint + }) + ).toEqual({ ownerProcessToken: marker.ownerProcessToken, revision: marker.revision }) + + expectCode( + () => + admitSshTerminalAuthorityTakeover({ + expectedMarker: marker, + rediscovery: { status: 'available', marker: { ...marker, revision: 8 } }, + ownerProof: 'owner-gone', + host, + remoteHome, + endpoint + }), + 'owner-state-changed' + ) + expectCode( + () => + admitSshTerminalAuthorityTakeover({ + expectedMarker: marker, + rediscovery: { status: 'available', marker }, + ownerProof: 'owner-alive', + host, + remoteHome, + endpoint + }), + 'owner-still-alive' + ) + expectCode( + () => + admitSshTerminalAuthorityTakeover({ + expectedMarker: marker, + rediscovery: { status: 'available', marker }, + ownerProof: 'inspection-failed', + host, + remoteHome, + endpoint + }), + 'owner-proof-inconclusive' + ) + }) +}) diff --git a/src/main/ssh/ssh-terminal-authority-admission.ts b/src/main/ssh/ssh-terminal-authority-admission.ts new file mode 100644 index 00000000000..2206c3ca440 --- /dev/null +++ b/src/main/ssh/ssh-terminal-authority-admission.ts @@ -0,0 +1,139 @@ +import { + CURRENT_RELAY_DAEMON_COMPATIBILITY, + negotiateRelayDaemonCompatibility, + type RelayDaemonCompatibilityGrant +} from '../../shared/relay-daemon-compatibility' +import type { SshTerminalAuthorityMarker } from '../../shared/ssh-terminal-authority-marker' +import type { RemoteHostPlatform } from './ssh-remote-platform' +import type { + SshTerminalAuthorityDiscovery, + SshTerminalAuthorityOwnerProof +} from './ssh-terminal-authority-discovery' +import { sshTerminalAuthorityMarkerHasExpectedPaths } from './ssh-terminal-authority-discovery' +import type { SshTerminalAuthorityEndpoint } from './ssh-terminal-authority-endpoint' + +export type SshTerminalAuthorityAdmission = + | Readonly<{ kind: 'launch-first-owner' }> + | Readonly<{ + kind: 'connect-owner' + marker: SshTerminalAuthorityMarker + compatibility: RelayDaemonCompatibilityGrant + }> + +export type SshTerminalAuthorityTakeover = Readonly<{ + ownerProcessToken: string + revision: number +}> + +export type SshTerminalAuthorityAdmissionErrorCode = + | 'discovery-inconclusive' + | 'marker-invalid' + | 'marker-path-mismatch' + | 'owner-incompatible' + | 'owner-still-alive' + | 'owner-proof-inconclusive' + | 'owner-state-changed' + +export class SshTerminalAuthorityAdmissionError extends Error { + readonly name = 'SshTerminalAuthorityAdmissionError' + + constructor( + readonly code: SshTerminalAuthorityAdmissionErrorCode, + message: string + ) { + super(message) + } +} + +function reject(code: SshTerminalAuthorityAdmissionErrorCode, message: string): never { + throw new SshTerminalAuthorityAdmissionError(code, message) +} + +function admitMarker( + marker: SshTerminalAuthorityMarker, + host: RemoteHostPlatform, + remoteHome: string, + endpoint: SshTerminalAuthorityEndpoint +): Extract { + if (!sshTerminalAuthorityMarkerHasExpectedPaths(marker, host, remoteHome, endpoint)) { + reject('marker-path-mismatch', 'Terminal authority marker names an unexpected remote path') + } + const compatibility = negotiateRelayDaemonCompatibility( + marker.compatibility, + CURRENT_RELAY_DAEMON_COMPATIBILITY + ) + if (!compatibility) { + reject('owner-incompatible', 'Terminal authority owner requires an incompatible protocol') + } + return { kind: 'connect-owner', marker, compatibility } +} + +export function admitSshTerminalAuthority( + discovery: SshTerminalAuthorityDiscovery, + host: RemoteHostPlatform, + remoteHome: string, + endpoint: SshTerminalAuthorityEndpoint +): SshTerminalAuthorityAdmission { + if (discovery.status === 'absent') { + return { kind: 'launch-first-owner' } + } + if (discovery.status !== 'available') { + if (discovery.status === 'invalid') { + reject('marker-invalid', 'Terminal authority marker is invalid') + } + reject('discovery-inconclusive', 'Terminal authority owner could not be read safely') + } + return admitMarker(discovery.marker, host, remoteHome, endpoint) +} + +function markersEqual( + expected: SshTerminalAuthorityMarker, + current: SshTerminalAuthorityMarker +): boolean { + return JSON.stringify(expected) === JSON.stringify(current) +} + +export function admitSshTerminalAuthorityTakeover(args: { + expectedMarker: SshTerminalAuthorityMarker + rediscovery: SshTerminalAuthorityDiscovery + ownerProof: SshTerminalAuthorityOwnerProof + host: RemoteHostPlatform + remoteHome: string + endpoint: SshTerminalAuthorityEndpoint +}): SshTerminalAuthorityTakeover { + assertSshTerminalAuthorityOwnerUnchanged({ + expectedMarker: args.expectedMarker, + rediscovery: args.rediscovery, + host: args.host, + remoteHome: args.remoteHome, + endpoint: args.endpoint + }) + if (args.ownerProof === 'owner-alive') { + reject('owner-still-alive', 'Terminal authority owner is alive but its endpoint refused') + } + if (args.ownerProof !== 'owner-gone') { + reject('owner-proof-inconclusive', 'Terminal authority owner death could not be proven') + } + return { + ownerProcessToken: args.expectedMarker.ownerProcessToken, + revision: args.expectedMarker.revision + } +} + +export function assertSshTerminalAuthorityOwnerUnchanged(args: { + expectedMarker: SshTerminalAuthorityMarker + rediscovery: SshTerminalAuthorityDiscovery + host: RemoteHostPlatform + remoteHome: string + endpoint: SshTerminalAuthorityEndpoint +}): void { + const current = admitSshTerminalAuthority( + args.rediscovery, + args.host, + args.remoteHome, + args.endpoint + ) + if (current.kind !== 'connect-owner' || !markersEqual(args.expectedMarker, current.marker)) { + reject('owner-state-changed', 'Terminal authority owner changed during reconnect') + } +} diff --git a/src/main/ssh/ssh-terminal-authority-app-host-transport.test.ts b/src/main/ssh/ssh-terminal-authority-app-host-transport.test.ts new file mode 100644 index 00000000000..ec27de5d88d --- /dev/null +++ b/src/main/ssh/ssh-terminal-authority-app-host-transport.test.ts @@ -0,0 +1,490 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { + TERMINAL_AUTHORITY_CONSUMER_PROOF_ALGORITHM, + TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION, + type TerminalAuthorityNamespaceAdmissionGrant, + type TerminalAuthorityNamespaceAdmissionProof, + type TerminalAuthorityNamespaceAdmissionStart +} from '../../shared/terminal-session-authority-consumer-proof' +import { + TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_ALGORITHM, + TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_VERSION, + type TerminalAuthorityConsumerRetirementChallenge, + type TerminalAuthorityConsumerRetirementProof, + type TerminalAuthorityConsumerRetirementStart +} from '../../shared/terminal-session-authority-consumer-retirement' +import { + TERMINAL_AUTHORITY_NAMESPACE_BOUNDARY_ACCEPT_METHOD, + TERMINAL_AUTHORITY_NAMESPACE_BOUNDARY_NOTIFICATION, + TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_ACK_METHOD +} from '../../shared/terminal-session-authority-consumer-transport' +import { terminalSessionAuthorityBoundaryId } from '../../shared/terminal-session-authority-boundary-identity' +import { authorityProjection } from '../session-authority/__tests__/terminal-authority-app-projection-fixture' +import { + TerminalAuthorityAppAdmissionRejectedError, + type TerminalAuthorityAppAdmissionIntentRequiredError +} from '../session-authority/terminal-authority-app-outcome-host-contract' +import { TerminalAuthorityAppOutcomeHostManager } from '../session-authority/terminal-authority-app-outcome-host-manager' +import { TerminalAuthorityAppOutcomeHostTransportSlot } from '../session-authority/terminal-authority-app-outcome-host-transport-slot' +import { TerminalAuthorityAppProjectionStore } from '../session-authority/terminal-authority-app-projection-store' +import { + createTerminalAuthorityProofEphemeralKeypair, + terminalAuthorityAdmissionCas, + terminalAuthorityHostAppConsumerId, + terminalAuthorityRetirementCas +} from '../session-authority/terminal-session-authority-consumer-proof' +import { + FakeSshAuthorityMux, + SSH_AUTHORITY_APP_KEYPAIR, + SSH_AUTHORITY_HOST_ID, + sshAuthorityAdmission, + sshAuthorityBoundary, + sshAuthorityOutcomeTransport, + sshAuthorityRetirement +} from './__tests__/ssh-terminal-authority-app-transport' +import { SshTerminalAuthorityAppHostTransport } from './ssh-terminal-authority-app-host-transport' +import { + SSH_TERMINAL_AUTHORITY_CONSUMER_CHALLENGE_METHOD, + SSH_TERMINAL_AUTHORITY_CONSUMER_CANCEL_METHOD, + SSH_TERMINAL_AUTHORITY_CONSUMER_GRANT_METHOD, + SSH_TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_CHALLENGE_METHOD, + SSH_TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_METHOD, + SSH_TERMINAL_AUTHORITY_CONSUMER_RESOLVE_NAMESPACE_METHOD +} from './ssh-terminal-authority-consumer-methods' + +describe('SshTerminalAuthorityAppHostTransport', () => { + it('constructs proof internally and maps the host-required intent', async () => { + const attempt = sshAuthorityAdmission() + const mux = new FakeSshAuthorityMux(async (method, params) => { + expect(method).toBe(SSH_TERMINAL_AUTHORITY_CONSUMER_CHALLENGE_METHOD) + expect(params).toEqual({ start: attempt.start }) + throw new Error('terminal authority namespace admission requires first') + }) + const host = await connect(mux) + + await expect( + host.openNamespace(attempt.request, sshAuthorityOutcomeTransport()) + ).rejects.toMatchObject({ + requiredIntent: 'first' + } satisfies Partial) + expect(mux.requests).toHaveLength(1) + }) + + it('publishes the pre-grant boundary needed to unlock exact admission', async () => { + const attempt = sshAuthorityAdmission() + const boundary = sshAuthorityBoundary(attempt.grant) + let mux!: FakeSshAuthorityMux + mux = new FakeSshAuthorityMux(async (method, params) => { + if (method === SSH_TERMINAL_AUTHORITY_CONSUMER_CHALLENGE_METHOD) { + return attempt.challenge + } + if (method === SSH_TERMINAL_AUTHORITY_CONSUMER_GRANT_METHOD) { + expect(params).toMatchObject({ proof: { challenge: attempt.challenge } }) + mux.emit(TERMINAL_AUTHORITY_NAMESPACE_BOUNDARY_NOTIFICATION, { boundary }) + return attempt.grant + } + throw new Error(`unexpected request: ${method}`) + }) + const transport = sshAuthorityOutcomeTransport() + const host = await connect(mux) + + const namespace = await host.openNamespace(attempt.request, transport) + + expect(transport.publishBoundary).toHaveBeenCalledWith(boundary) + await namespace.activate?.() + expect(transport.publishBoundary).toHaveBeenCalledOnce() + }) + + it('resolves the namespace through the authenticated relay connection', async () => { + const expected = { + authorityHostId: SSH_AUTHORITY_HOST_ID, + namespaceId: 'namespace:resolved' + } + const mux = new FakeSshAuthorityMux(async (method, params) => { + expect(method).toBe(SSH_TERMINAL_AUTHORITY_CONSUMER_RESOLVE_NAMESPACE_METHOD) + expect(params).toEqual({ worktreeId: 'repo::/workspace' }) + return expected + }) + + await expect((await connect(mux)).resolveNamespace('repo::/workspace')).resolves.toEqual( + expected + ) + }) + + it('maps semantic host rejection without reclassifying transport uncertainty', async () => { + const attempt = sshAuthorityAdmission() + const rejected = new FakeSshAuthorityMux(async () => { + throw new Error('terminal authority namespace admission proof was rejected') + }) + const uncertain = new FakeSshAuthorityMux(async () => { + throw new Error('relay response lost') + }) + + await expect( + (await connect(rejected)).openNamespace(attempt.request, sshAuthorityOutcomeTransport()) + ).rejects.toBeInstanceOf(TerminalAuthorityAppAdmissionRejectedError) + await expect( + (await connect(uncertain)).openNamespace(attempt.request, sshAuthorityOutcomeTransport()) + ).rejects.not.toBeInstanceOf(TerminalAuthorityAppAdmissionRejectedError) + }) + + it('retries an uncertain grant with the exact proof and no cancellation', async () => { + const attempt = sshAuthorityAdmission() + const proofs: TerminalAuthorityNamespaceAdmissionProof[] = [] + let grants = 0 + const mux = new FakeSshAuthorityMux(async (method, params) => { + if (method === SSH_TERMINAL_AUTHORITY_CONSUMER_CHALLENGE_METHOD) { + return attempt.challenge + } + if (method === SSH_TERMINAL_AUTHORITY_CONSUMER_GRANT_METHOD) { + proofs.push(params?.proof as TerminalAuthorityNamespaceAdmissionProof) + grants += 1 + if (grants === 1) { + throw new Error('grant response timed out') + } + return { ...attempt.grant, replayed: true } + } + throw new Error(`unexpected request: ${method}`) + }) + const host = await connect(mux) + + await expect( + host.openNamespace(attempt.request, sshAuthorityOutcomeTransport()) + ).rejects.toThrow('grant response timed out') + const namespace = await host.openNamespace(attempt.request, sshAuthorityOutcomeTransport()) + + expect(namespace.grant.replayed).toBe(true) + expect(proofs[0]).toEqual(proofs[1]) + expect(mux.requests.every((request) => !request.method.includes('cancel'))).toBe(true) + }) + + it('retries a lost cumulative ACK without changing another namespace', async () => { + const first = sshAuthorityAdmission('namespace:first') + const second = sshAuthorityAdmission('namespace:second') + const attempts = new Map>([ + [first.request.requestId, first], + [second.request.requestId, second] + ]) + const ackPayloads: unknown[] = [] + let acks = 0 + const mux = new FakeSshAuthorityMux(async (method, params) => { + if (method === SSH_TERMINAL_AUTHORITY_CONSUMER_CHALLENGE_METHOD) { + const requestId = (params?.start as { requestId?: unknown } | undefined)?.requestId + return typeof requestId === 'string' ? attempts.get(requestId)?.challenge : undefined + } + if (method === SSH_TERMINAL_AUTHORITY_CONSUMER_GRANT_METHOD) { + const proof = params?.proof as TerminalAuthorityNamespaceAdmissionProof + return attempts.get(proof.challenge.requestId)?.grant + } + if (method === TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_ACK_METHOD) { + ackPayloads.push(params) + acks += 1 + if (acks === 1) { + throw new Error('ACK response lost') + } + return { acknowledgedSequence: 1 } + } + throw new Error(`unexpected request: ${method}`) + }) + const host = await connect(mux) + const firstConnection = await host.openNamespace(first.request, sshAuthorityOutcomeTransport()) + const secondConnection = await host.openNamespace( + second.request, + sshAuthorityOutcomeTransport() + ) + await firstConnection.activate?.() + await secondConnection.activate?.() + const ack = { + version: 1 as const, + consumer: first.grant.consumer, + namespace: first.grant.namespace, + sequence: 1, + outcomeId: 'outcome:first:1' + } + + await expect(firstConnection.acknowledge(ack)).rejects.toThrow('ACK response lost') + await expect(firstConnection.acknowledge(ack)).resolves.toBe(1) + + expect(ackPayloads[0]).toEqual(ackPayloads[1]) + expect(secondConnection.grant).toEqual(second.grant) + }) + + it('retries a lost retirement acknowledgement with the exact proof', async () => { + const attempt = sshAuthorityRetirement() + const proofs: TerminalAuthorityConsumerRetirementProof[] = [] + let completions = 0 + const mux = new FakeSshAuthorityMux(async (method, params) => { + if (method === SSH_TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_CHALLENGE_METHOD) { + expect(params).toEqual({ start: attempt.start }) + return attempt.challenge + } + if (method === SSH_TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_METHOD) { + proofs.push(params?.proof as TerminalAuthorityConsumerRetirementProof) + completions += 1 + if (completions === 1) { + throw new Error('retirement response timed out') + } + return { ...attempt.result, replayed: true } + } + throw new Error(`unexpected request: ${method}`) + }) + const host = await connect(mux) + + await expect(host.retireNamespace(attempt.request)).rejects.toThrow( + 'retirement response timed out' + ) + await expect(host.retireNamespace(attempt.request)).resolves.toEqual({ + ...attempt.result, + replayed: true + }) + expect(proofs[0]).toEqual(proofs[1]) + expect(proofs[0]?.challenge).toEqual(attempt.challenge) + }) + + it('keeps retirement pending across SSH transport replacement without re-admission', async () => { + const directory = mkdtempSync(path.join(tmpdir(), 'ssh-authority-retirement-')) + const store = await TerminalAuthorityAppProjectionStore.open({ + directory, + databasePath: ':memory:' + }) + const slot = new TerminalAuthorityAppOutcomeHostTransportSlot(SSH_AUTHORITY_HOST_ID) + let currentIncarnationId: string | null = 'app-process:ssh-manager-test' + let admissionGrants = 0 + const secondMux = new FakeSshAuthorityMux(async (method, params) => { + if (method === SSH_TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_CHALLENGE_METHOD) { + return retirementChallenge( + params?.start as TerminalAuthorityConsumerRetirementStart, + currentIncarnationId, + 'connection-grant:ssh-reconnected' + ) + } + if (method === SSH_TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_METHOD) { + const proof = params?.proof as TerminalAuthorityConsumerRetirementProof | undefined + if (!proof) { + throw new Error('replacement retirement proof is unavailable') + } + return retirementResult(proof.challenge, true) + } + if (method === SSH_TERMINAL_AUTHORITY_CONSUMER_CHALLENGE_METHOD) { + throw new Error('retiring namespace was re-admitted') + } + throw new Error(`unexpected replacement request: ${method}`) + }) + let replacementLease!: ReturnType + let firstMux!: FakeSshAuthorityMux + firstMux = new FakeSshAuthorityMux(async (method, params) => { + if (method === SSH_TERMINAL_AUTHORITY_CONSUMER_CHALLENGE_METHOD) { + return admissionChallenge( + params?.start as TerminalAuthorityNamespaceAdmissionStart, + 'connection-grant:ssh-first' + ) + } + if (method === SSH_TERMINAL_AUTHORITY_CONSUMER_GRANT_METHOD) { + admissionGrants += 1 + const grant = admissionGrant(params?.proof as TerminalAuthorityNamespaceAdmissionProof) + firstMux.emit(TERMINAL_AUTHORITY_NAMESPACE_BOUNDARY_NOTIFICATION, { + boundary: completeBoundary(grant) + }) + return grant + } + if (method === TERMINAL_AUTHORITY_NAMESPACE_BOUNDARY_ACCEPT_METHOD) { + return {} + } + if (method === SSH_TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_CHALLENGE_METHOD) { + return retirementChallenge( + params?.start as TerminalAuthorityConsumerRetirementStart, + currentIncarnationId, + 'connection-grant:ssh-first' + ) + } + if (method === SSH_TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_METHOD) { + currentIncarnationId = null + replacementLease = slot.install(sshTransport(secondMux)) + throw new Error('SSH retirement response lost after durable append') + } + if (method === SSH_TERMINAL_AUTHORITY_CONSUMER_CANCEL_METHOD) { + return { canceled: true } + } + throw new Error(`unexpected initial request: ${method}`) + }) + slot.install(sshTransport(firstMux)) + const manager = new TerminalAuthorityAppOutcomeHostManager('app-process:ssh-manager-test', { + store, + onProjection: () => {}, + reconnectBaseDelayMs: 1, + reconnectMaxDelayMs: 1 + }) + const registration = manager.installHost(slot) + const namespace = { + authorityHostId: SSH_AUTHORITY_HOST_ID, + namespaceId: 'namespace:ssh-manager-retirement' + } + const request = Object.freeze({ + namespace, + candidateProcessIncarnationId: 'app-process:ssh-manager-test', + candidateSessionNonce: 'app-session:ssh-manager-retirement', + requestId: 'app-retirement-request:ssh-manager' + }) + try { + await registration.admitNamespace(namespace) + await expect(registration.retireNamespace(request)).rejects.toThrow('response lost') + await expect( + replacementLease.withCurrent(async (binding) => { + const result = await registration.retireNamespace(request) + binding.bindConnectionGeneration() + return result + }) + ).resolves.toMatchObject({ retired: true, alreadyAbsent: true, replayed: false }) + expect(admissionGrants).toBe(1) + expect(currentIncarnationId).toBeNull() + } finally { + manager.dispose() + store.close() + rmSync(directory, { recursive: true, force: true }) + } + }) + + it('rejects retirement before mutation when the relay did not grant support', async () => { + const attempt = sshAuthorityRetirement() + const mux = new FakeSshAuthorityMux(async () => { + throw new Error('unexpected request') + }) + const host = await connect(mux, false) + + await expect(host.retireNamespace(attempt.request)).rejects.toThrow('unsupported') + expect(mux.requests).toHaveLength(0) + }) +}) + +async function connect(mux: FakeSshAuthorityMux, retirementSupported = true) { + const slot = new TerminalAuthorityAppOutcomeHostTransportSlot(SSH_AUTHORITY_HOST_ID) + slot.install( + new SshTerminalAuthorityAppHostTransport({ + mux: mux.asMux(), + authenticatedAuthorityHostId: SSH_AUTHORITY_HOST_ID, + keypair: SSH_AUTHORITY_APP_KEYPAIR, + consumerRetirementSupported: retirementSupported + }) + ) + return await slot.connect({ onFailure: vi.fn() }) +} + +function sshTransport(mux: FakeSshAuthorityMux) { + return new SshTerminalAuthorityAppHostTransport({ + mux: mux.asMux(), + authenticatedAuthorityHostId: SSH_AUTHORITY_HOST_ID, + keypair: SSH_AUTHORITY_APP_KEYPAIR, + consumerRetirementSupported: true + }) +} + +function admissionChallenge( + start: TerminalAuthorityNamespaceAdmissionStart, + connectionGrantId: string +) { + const host = createTerminalAuthorityProofEphemeralKeypair() + return Object.freeze({ + ...start, + version: TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION, + algorithm: TERMINAL_AUTHORITY_CONSUMER_PROOF_ALGORITHM, + currentAdmissionCas: 'admission-cas:ssh-manager-current', + connectionGrantId, + authenticatedTransportPrincipal: 'ssh-principal:manager-test', + authenticatedTransportCapability: 'ssh-capability:manager-test', + hostEphemeralPublicKeyB64: Buffer.from(host.publicKey).toString('base64'), + expiresAtMs: Date.now() + 30_000 + }) +} + +function admissionGrant( + proof: TerminalAuthorityNamespaceAdmissionProof +): TerminalAuthorityNamespaceAdmissionGrant { + const challenge = proof.challenge + const consumer = Object.freeze({ + consumerId: terminalAuthorityHostAppConsumerId( + challenge.namespace.authorityHostId, + Uint8Array.from(Buffer.from(challenge.appPublicKeyB64, 'base64')) + ), + consumerIncarnationId: challenge.candidateProcessIncarnationId + }) + return Object.freeze({ + version: TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION, + consumer, + namespace: challenge.namespace, + requestId: challenge.requestId, + connectionGrantId: challenge.connectionGrantId, + admissionCas: terminalAuthorityAdmissionCas( + challenge.namespace, + consumer.consumerId, + consumer.consumerIncarnationId + ), + replayed: false + }) +} + +function completeBoundary(grant: TerminalAuthorityNamespaceAdmissionGrant) { + const baseProjection = authorityProjection({ namespaceId: grant.namespace.namespaceId }) + const value = Object.freeze({ + version: 1 as const, + consumer: grant.consumer, + namespace: grant.namespace, + acknowledgedSequence: 0, + outcomeHighWatermark: 0, + consumerStart: 'new-at-tail' as const, + projection: Object.freeze({ ...baseProjection, namespace: grant.namespace }) + }) + return Object.freeze({ ...value, boundaryId: terminalSessionAuthorityBoundaryId(value) }) +} + +function retirementChallenge( + start: TerminalAuthorityConsumerRetirementStart, + currentConsumerIncarnationId: string | null, + connectionGrantId: string +): TerminalAuthorityConsumerRetirementChallenge { + const host = createTerminalAuthorityProofEphemeralKeypair() + const consumerId = terminalAuthorityHostAppConsumerId( + start.namespace.authorityHostId, + Uint8Array.from(Buffer.from(start.appPublicKeyB64, 'base64')) + ) + return Object.freeze({ + ...start, + version: TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_VERSION, + algorithm: TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_ALGORITHM, + consumerId, + currentConsumerIncarnationId, + retirementCas: terminalAuthorityRetirementCas( + start.namespace, + consumerId, + currentConsumerIncarnationId + ), + connectionGrantId, + liveAdmission: null, + authenticatedTransportPrincipal: 'ssh-principal:manager-test', + authenticatedTransportCapability: 'ssh-capability:manager-test', + hostEphemeralPublicKeyB64: Buffer.from(host.publicKey).toString('base64'), + expiresAtMs: Date.now() + 30_000 + }) +} + +function retirementResult( + challenge: TerminalAuthorityConsumerRetirementChallenge, + alreadyAbsent: boolean +) { + return Object.freeze({ + version: TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_VERSION, + namespace: challenge.namespace, + consumerId: challenge.consumerId, + retiredConsumerIncarnationId: challenge.currentConsumerIncarnationId, + requestId: challenge.requestId, + candidateProcessIncarnationId: challenge.candidateProcessIncarnationId, + candidateSessionNonce: challenge.candidateSessionNonce, + connectionGrantId: challenge.connectionGrantId, + retirementCas: challenge.retirementCas, + retired: true as const, + alreadyAbsent, + replayed: false + }) +} diff --git a/src/main/ssh/ssh-terminal-authority-app-host-transport.ts b/src/main/ssh/ssh-terminal-authority-app-host-transport.ts new file mode 100644 index 00000000000..d9cff9642ea --- /dev/null +++ b/src/main/ssh/ssh-terminal-authority-app-host-transport.ts @@ -0,0 +1,297 @@ +import { + TERMINAL_AUTHORITY_CONSUMER_PROOF_ALGORITHM, + TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION, + parseTerminalAuthorityNamespaceAdmissionChallenge, + parseTerminalAuthorityNamespaceAdmissionStart, + type TerminalAuthorityNamespaceAdmissionChallenge, + type TerminalAuthorityNamespaceAdmissionIntent, + type TerminalAuthorityNamespaceAdmissionStart +} from '../../shared/terminal-session-authority-consumer-proof' +import { + assertAuthorityNamespace, + type TerminalAuthorityNamespace +} from '../../shared/terminal-session-authority-identity' +import { + TERMINAL_AUTHORITY_NAMESPACE_BOUNDARY_NOTIFICATION, + TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_NOTIFICATION, + parseTerminalAuthorityNamespaceOutcomeBoundary, + parseTerminalAuthorityNamespaceOutcomePublication +} from '../../shared/terminal-session-authority-consumer-transport' +import { + TerminalAuthorityAppAdmissionIntentRequiredError, + TerminalAuthorityAppAdmissionRejectedError, + type TerminalAuthorityAppConsumerRetirementRequest, + type TerminalAuthorityAppNamespaceAdmissionRequest, + type TerminalAuthorityAppOutcomeHostConnection, + type TerminalAuthorityAppOutcomeHostTransport, + type TerminalAuthorityAppOutcomeNamespaceConnection +} from '../session-authority/terminal-authority-app-outcome-host-contract' +import { terminalAuthorityAppAdmissionRejection } from '../session-authority/terminal-authority-app-admission-error' +import type { TerminalAuthorityPolicyOutcomeTransport } from '../session-authority/terminal-session-authority-policy-consumers' +import { + createTerminalAuthorityConsumerProof, + type TerminalAuthorityConsumerProofKeypair +} from '../session-authority/terminal-session-authority-consumer-proof' +import type { SshChannelMultiplexer } from './ssh-channel-multiplexer' +import { + SshTerminalAuthorityAppNamespaceConnection, + type SshTerminalAuthorityAppNamespaceEvent +} from './ssh-terminal-authority-app-namespace-transport' +import { + SSH_TERMINAL_AUTHORITY_CONSUMER_CHALLENGE_METHOD, + SSH_TERMINAL_AUTHORITY_CONSUMER_RESOLVE_NAMESPACE_METHOD +} from './ssh-terminal-authority-consumer-methods' +import { retireSshTerminalAuthorityAppConsumer } from './ssh-terminal-authority-app-retirement' + +export type SshTerminalAuthorityAppHostTransportOptions = Readonly<{ + mux: SshChannelMultiplexer + authenticatedAuthorityHostId: string + keypair: TerminalAuthorityConsumerProofKeypair + consumerRetirementSupported?: boolean +}> + +export class SshTerminalAuthorityAppHostTransport implements TerminalAuthorityAppOutcomeHostTransport { + readonly authenticatedAuthorityHostId: string + + constructor(private readonly options: SshTerminalAuthorityAppHostTransportOptions) { + this.authenticatedAuthorityHostId = options.authenticatedAuthorityHostId + if (options.keypair.publicKey.length !== 32 || options.keypair.secretKey.length !== 32) { + throw new Error('SSH terminal authority proof keypair is invalid') + } + } + + async connect( + transport: Readonly<{ onFailure(error: unknown): void }> + ): Promise { + if (this.options.mux.isDisposed()) { + throw new Error('SSH terminal authority host transport is unavailable') + } + return new SshTerminalAuthorityAppHostConnection(this.options, transport.onFailure) + } +} + +class SshTerminalAuthorityAppHostConnection implements TerminalAuthorityAppOutcomeHostConnection { + readonly authenticatedAuthorityHostId: string + private readonly namespaces = new Map() + private readonly removeBoundaryListener: () => void + private readonly removeOutcomeListener: () => void + private readonly removeDisposeListener: () => void + private active = true + + constructor( + private readonly options: SshTerminalAuthorityAppHostTransportOptions, + private readonly onFailure: (error: unknown) => void + ) { + this.authenticatedAuthorityHostId = options.authenticatedAuthorityHostId + this.removeBoundaryListener = options.mux.onNotificationByMethod( + TERMINAL_AUTHORITY_NAMESPACE_BOUNDARY_NOTIFICATION, + (params) => this.receiveBoundary(params) + ) + this.removeOutcomeListener = options.mux.onNotificationByMethod( + TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_NOTIFICATION, + (params) => this.receiveOutcome(params) + ) + this.removeDisposeListener = options.mux.onDispose(() => { + this.fail(new Error('SSH terminal authority host disconnected')) + }) + } + + async openNamespace( + request: TerminalAuthorityAppNamespaceAdmissionRequest, + transport: TerminalAuthorityPolicyOutcomeTransport, + onOpening?: (connection: TerminalAuthorityAppOutcomeNamespaceConnection) => void + ): Promise { + this.assertActive() + const start = this.admissionStart(request) + const challenge = await this.issueChallenge(start) + const proof = createTerminalAuthorityConsumerProof(challenge, this.options.keypair) + const key = namespaceKey(start.namespace) + if (this.namespaces.has(key)) { + throw new Error('SSH terminal authority namespace is already open') + } + const connection = new SshTerminalAuthorityAppNamespaceConnection({ + mux: this.options.mux, + proof, + transport, + retire: (requestId) => + this.retireNamespace({ + namespace: start.namespace, + candidateProcessIncarnationId: start.candidateProcessIncarnationId, + candidateSessionNonce: start.candidateSessionNonce, + requestId + }), + isCurrent: () => this.active && this.namespaces.get(key) === connection, + remove: () => { + if (this.namespaces.get(key) === connection) { + this.namespaces.delete(key) + } + } + }) + this.namespaces.set(key, connection) + try { + onOpening?.(connection) + await connection.open() + this.assertActive() + return connection + } catch (error) { + connection.abandon() + throw error + } + } + + async resolveNamespace(worktreeId: string): Promise { + this.assertActive() + const value = await this.options.mux.request( + SSH_TERMINAL_AUTHORITY_CONSUMER_RESOLVE_NAMESPACE_METHOD, + { worktreeId } + ) + this.assertActive() + assertAuthorityNamespace(value) + if (value.authorityHostId !== this.authenticatedAuthorityHostId) { + throw new Error('SSH terminal authority namespace host changed') + } + return Object.freeze({ ...value }) + } + + async retireNamespace(request: TerminalAuthorityAppConsumerRetirementRequest) { + const result = await retireSshTerminalAuthorityAppConsumer(this.options, request, () => + this.assertActive() + ) + const key = namespaceKey(request.namespace) + this.namespaces.get(key)?.retired() + this.namespaces.delete(key) + return result + } + + disconnect(): void { + if (!this.active) { + return + } + this.active = false + this.removeBoundaryListener() + this.removeOutcomeListener() + this.removeDisposeListener() + for (const connection of this.namespaces.values()) { + connection.disconnect() + } + this.namespaces.clear() + } + + private admissionStart( + request: TerminalAuthorityAppNamespaceAdmissionRequest + ): TerminalAuthorityNamespaceAdmissionStart { + const start = parseTerminalAuthorityNamespaceAdmissionStart({ + version: TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION, + algorithm: TERMINAL_AUTHORITY_CONSUMER_PROOF_ALGORITHM, + ...request, + appPublicKeyB64: Buffer.from(this.options.keypair.publicKey).toString('base64') + }) + if (!start || start.namespace.authorityHostId !== this.authenticatedAuthorityHostId) { + throw new Error('SSH terminal authority admission request is invalid') + } + return start + } + + private async issueChallenge( + start: TerminalAuthorityNamespaceAdmissionStart + ): Promise { + let unsafeChallenge: unknown + try { + unsafeChallenge = await this.options.mux.request( + SSH_TERMINAL_AUTHORITY_CONSUMER_CHALLENGE_METHOD, + { start } + ) + } catch (error) { + const requiredIntent = admissionIntentRequired(error) + if (requiredIntent) { + throw new TerminalAuthorityAppAdmissionIntentRequiredError(requiredIntent) + } + const rejection = terminalAuthorityAppAdmissionRejection(error) + if (rejection) { + throw rejection + } + throw error + } + this.assertActive() + const challenge = parseTerminalAuthorityNamespaceAdmissionChallenge(unsafeChallenge) + if (!challenge || !sameAdmissionStart(challenge, start)) { + throw new TerminalAuthorityAppAdmissionRejectedError( + 'SSH terminal authority challenge is invalid' + ) + } + return challenge + } + + private receiveBoundary(params: Record): void { + const value = parseTerminalAuthorityNamespaceOutcomeBoundary(params.boundary ?? params) + if (value) { + this.publish(Object.freeze({ kind: 'boundary', value })) + } + } + + private receiveOutcome(params: Record): void { + const value = parseTerminalAuthorityNamespaceOutcomePublication(params.publication ?? params) + if (value) { + this.publish(Object.freeze({ kind: 'outcome', value })) + } + } + + private publish(event: SshTerminalAuthorityAppNamespaceEvent): void { + if (!this.active) { + return + } + this.namespaces.get(namespaceKey(event.value.namespace))?.publish(event) + } + + private fail(error: unknown): void { + if (!this.active) { + return + } + this.disconnect() + this.onFailure(error) + } + + private assertActive(): void { + if (!this.active || this.options.mux.isDisposed()) { + throw new Error('SSH terminal authority host connection is stale') + } + } +} + +function sameAdmissionStart( + challenge: TerminalAuthorityNamespaceAdmissionChallenge, + start: TerminalAuthorityNamespaceAdmissionStart +): boolean { + return ( + challenge.version === start.version && + challenge.algorithm === start.algorithm && + sameNamespace(challenge.namespace, start.namespace) && + challenge.appPublicKeyB64 === start.appPublicKeyB64 && + challenge.candidateProcessIncarnationId === start.candidateProcessIncarnationId && + challenge.candidateSessionNonce === start.candidateSessionNonce && + challenge.requestId === start.requestId && + challenge.intent === start.intent + ) +} + +function admissionIntentRequired(error: unknown): TerminalAuthorityNamespaceAdmissionIntent | null { + if (!(error instanceof Error)) { + return null + } + const prefix = 'terminal authority namespace admission requires ' + const required = error.message.startsWith(prefix) ? error.message.slice(prefix.length) : '' + return required === 'first' || required === 'resume' || required === 'explicit-handover' + ? required + : null +} + +function sameNamespace( + left: TerminalAuthorityNamespace, + right: TerminalAuthorityNamespace +): boolean { + return left.authorityHostId === right.authorityHostId && left.namespaceId === right.namespaceId +} + +function namespaceKey(namespace: TerminalAuthorityNamespace): string { + return JSON.stringify([namespace.authorityHostId, namespace.namespaceId]) +} diff --git a/src/main/ssh/ssh-terminal-authority-app-namespace-transport.ts b/src/main/ssh/ssh-terminal-authority-app-namespace-transport.ts new file mode 100644 index 00000000000..09caa295c90 --- /dev/null +++ b/src/main/ssh/ssh-terminal-authority-app-namespace-transport.ts @@ -0,0 +1,278 @@ +import { + TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION, + parseTerminalAuthorityNamespaceAdmissionGrant, + type TerminalAuthorityNamespaceAdmissionGrant, + type TerminalAuthorityNamespaceAdmissionProof +} from '../../shared/terminal-session-authority-consumer-proof' +import type { TerminalAuthorityConsumerRetirementResult } from '../../shared/terminal-session-authority-consumer-retirement' +import type { TerminalAuthorityNamespace } from '../../shared/terminal-session-authority-identity' +import { + TERMINAL_AUTHORITY_NAMESPACE_BOUNDARY_ACCEPT_METHOD, + TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_ACK_METHOD, + parseTerminalAuthorityNamespaceBoundaryAcceptance, + parseTerminalAuthorityNamespaceOutcomeAck, + sameTerminalAuthorityPolicyConsumer, + type TerminalAuthorityNamespaceOutcomeBoundary, + type TerminalAuthorityNamespaceOutcomePublication +} from '../../shared/terminal-session-authority-consumer-transport' +import { + TerminalAuthorityAppAdmissionRejectedError, + type TerminalAuthorityAppOutcomeNamespaceConnection +} from '../session-authority/terminal-authority-app-outcome-host-contract' +import { terminalAuthorityAppAdmissionRejection } from '../session-authority/terminal-authority-app-admission-error' +import type { TerminalAuthorityPolicyOutcomeTransport } from '../session-authority/terminal-session-authority-policy-consumers' +import { + terminalAuthorityAdmissionCas, + terminalAuthorityHostAppConsumerId +} from '../session-authority/terminal-session-authority-consumer-proof' +import type { SshChannelMultiplexer } from './ssh-channel-multiplexer' +import { + SSH_TERMINAL_AUTHORITY_CONSUMER_CANCEL_METHOD, + SSH_TERMINAL_AUTHORITY_CONSUMER_GRANT_METHOD +} from './ssh-terminal-authority-consumer-methods' + +const MAX_PREACTIVATION_EVENTS = 256 + +export type SshTerminalAuthorityAppNamespaceEvent = + | Readonly<{ kind: 'boundary'; value: TerminalAuthorityNamespaceOutcomeBoundary }> + | Readonly<{ kind: 'outcome'; value: TerminalAuthorityNamespaceOutcomePublication }> + +type NamespaceConnectionOptions = Readonly<{ + mux: SshChannelMultiplexer + proof: TerminalAuthorityNamespaceAdmissionProof + transport: TerminalAuthorityPolicyOutcomeTransport + retire: (requestId: string) => Promise + isCurrent: () => boolean + remove: () => void +}> + +export class SshTerminalAuthorityAppNamespaceConnection implements TerminalAuthorityAppOutcomeNamespaceConnection { + readonly expectedConsumer + private readonly buffered: SshTerminalAuthorityAppNamespaceEvent[] = [] + private installedGrant: TerminalAuthorityNamespaceAdmissionGrant | null = null + private deliveryTail: Promise = Promise.resolve() + private active = true + private activated = false + + constructor(private readonly options: NamespaceConnectionOptions) { + this.expectedConsumer = Object.freeze({ + consumerId: terminalAuthorityHostAppConsumerId( + options.proof.challenge.namespace.authorityHostId, + Uint8Array.from(Buffer.from(options.proof.challenge.appPublicKeyB64, 'base64')) + ), + consumerIncarnationId: options.proof.challenge.candidateProcessIncarnationId + }) + } + + get grant(): TerminalAuthorityNamespaceAdmissionGrant { + if (!this.installedGrant) { + throw new Error('SSH terminal authority namespace grant is unavailable') + } + return this.installedGrant + } + + async open(): Promise { + this.assertActive() + let unsafeGrant: unknown + try { + unsafeGrant = await this.options.mux.request(SSH_TERMINAL_AUTHORITY_CONSUMER_GRANT_METHOD, { + proof: this.options.proof + }) + } catch (error) { + throw terminalAuthorityAppAdmissionRejection(error) ?? error + } + this.assertActive() + const grant = parseTerminalAuthorityNamespaceAdmissionGrant(unsafeGrant) + this.assertGrant(grant) + this.installedGrant = grant + } + + async activate(): Promise { + this.assertActive() + void this.grant + if (this.activated) { + return await this.deliveryTail + } + this.activated = true + const buffered = this.buffered.splice(0) + let settled = Promise.resolve() + for (const event of buffered) { + settled = this.enqueue(event) + } + await settled + } + + async acceptBoundary(unsafeAcceptance: unknown): Promise { + this.assertActive() + const acceptance = parseTerminalAuthorityNamespaceBoundaryAcceptance(unsafeAcceptance) + if (!acceptance || !this.matchesExpected(acceptance.consumer, acceptance.namespace)) { + throw new Error('SSH terminal authority boundary acceptance is invalid') + } + await this.options.mux.request(TERMINAL_AUTHORITY_NAMESPACE_BOUNDARY_ACCEPT_METHOD, { + acceptance + }) + } + + async acknowledge(unsafeAck: unknown): Promise { + this.assertActive() + const ack = parseTerminalAuthorityNamespaceOutcomeAck(unsafeAck) + if (!ack || !this.matchesGrant(ack.consumer, ack.namespace)) { + throw new Error('SSH terminal authority outcome ACK is invalid') + } + const result = (await this.options.mux.request( + TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_ACK_METHOD, + { + ack + } + )) as { acknowledgedSequence?: unknown } + if (!Number.isSafeInteger(result?.acknowledgedSequence)) { + throw new Error('SSH terminal authority outcome ACK is invalid') + } + return Number(result.acknowledgedSequence) + } + + async retire(requestId: string): Promise { + this.assertActive() + const result = await this.options.retire(requestId) + this.close(false) + return result + } + + retired(): void { + this.close(false) + } + + publish(event: SshTerminalAuthorityAppNamespaceEvent): void { + if (!this.active || !this.matchesExpected(event.value.consumer, event.value.namespace)) { + this.fail(new Error('SSH terminal authority namespace outcome is invalid')) + return + } + if (!this.activated && event.kind === 'outcome') { + if (this.buffered.length >= MAX_PREACTIVATION_EVENTS) { + this.fail(new Error('SSH terminal authority preactivation events exceeded capacity')) + return + } + this.buffered.push(event) + return + } + void this.enqueue(event).catch(() => undefined) + } + + disconnect(): void { + this.close(true) + } + + abandon(): void { + this.close(false) + } + + private close(cancel: boolean): void { + if (!this.active) { + return + } + this.active = false + this.buffered.length = 0 + this.options.remove() + if (!cancel) { + return + } + const challenge = this.options.proof.challenge + const grant = this.installedGrant + void this.options.mux + .request( + SSH_TERMINAL_AUTHORITY_CONSUMER_CANCEL_METHOD, + { + cancellation: { + version: TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION, + consumer: grant?.consumer ?? this.expectedConsumer, + namespace: grant?.namespace ?? challenge.namespace, + requestId: grant?.requestId ?? challenge.requestId, + connectionGrantId: grant?.connectionGrantId ?? challenge.connectionGrantId + } + }, + { timeoutMs: 5_000 } + ) + .catch(() => undefined) + } + + private enqueue(event: SshTerminalAuthorityAppNamespaceEvent): Promise { + const delivery = this.deliveryTail.then(async () => { + this.assertActive() + await (event.kind === 'boundary' + ? this.options.transport.publishBoundary(event.value) + : this.options.transport.publishOutcome(event.value)) + }) + this.deliveryTail = delivery.catch(() => undefined) + void delivery.catch((error) => this.fail(error)) + return delivery + } + + private assertGrant( + grant: TerminalAuthorityNamespaceAdmissionGrant | null + ): asserts grant is TerminalAuthorityNamespaceAdmissionGrant { + const challenge = this.options.proof.challenge + if ( + !grant || + !sameTerminalAuthorityPolicyConsumer(grant.consumer, this.expectedConsumer) || + !sameNamespace(grant.namespace, challenge.namespace) || + grant.requestId !== challenge.requestId || + grant.connectionGrantId !== challenge.connectionGrantId || + grant.admissionCas !== + terminalAuthorityAdmissionCas( + challenge.namespace, + this.expectedConsumer.consumerId, + this.expectedConsumer.consumerIncarnationId + ) + ) { + throw new TerminalAuthorityAppAdmissionRejectedError( + 'SSH terminal authority grant is invalid' + ) + } + } + + private matchesGrant( + consumer: TerminalAuthorityNamespaceOutcomeBoundary['consumer'], + namespace: TerminalAuthorityNamespace + ): boolean { + return ( + this.installedGrant !== null && + sameTerminalAuthorityPolicyConsumer(this.installedGrant.consumer, consumer) && + sameNamespace(this.installedGrant.namespace, namespace) + ) + } + + private matchesExpected( + consumer: TerminalAuthorityNamespaceOutcomeBoundary['consumer'], + namespace: TerminalAuthorityNamespace + ): boolean { + return ( + sameTerminalAuthorityPolicyConsumer(this.expectedConsumer, consumer) && + sameNamespace(this.options.proof.challenge.namespace, namespace) + ) + } + + private fail(error: unknown): void { + if (!this.active) { + return + } + this.disconnect() + try { + this.options.transport.onFailure?.(error instanceof Error ? error : new Error(String(error))) + } catch { + // The namespace is already fenced. + } + } + + private assertActive(): void { + if (!this.active || !this.options.isCurrent() || this.options.mux.isDisposed()) { + throw new Error('SSH terminal authority namespace connection is stale') + } + } +} + +function sameNamespace( + left: TerminalAuthorityNamespace, + right: TerminalAuthorityNamespace +): boolean { + return left.authorityHostId === right.authorityHostId && left.namespaceId === right.namespaceId +} diff --git a/src/main/ssh/ssh-terminal-authority-app-retirement.ts b/src/main/ssh/ssh-terminal-authority-app-retirement.ts new file mode 100644 index 00000000000..3556e3023df --- /dev/null +++ b/src/main/ssh/ssh-terminal-authority-app-retirement.ts @@ -0,0 +1,29 @@ +import type { TerminalAuthorityAppConsumerRetirementRequest } from '../session-authority/terminal-authority-app-outcome-host-contract' +import { retireTerminalAuthorityAppConsumer } from '../session-authority/terminal-authority-app-consumer-retirement' +import type { SshTerminalAuthorityAppHostTransportOptions } from './ssh-terminal-authority-app-host-transport' +import { + SSH_TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_CHALLENGE_METHOD, + SSH_TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_METHOD +} from './ssh-terminal-authority-consumer-methods' + +export async function retireSshTerminalAuthorityAppConsumer( + options: SshTerminalAuthorityAppHostTransportOptions, + request: TerminalAuthorityAppConsumerRetirementRequest, + assertCurrent: () => void +) { + assertCurrent() + if (!options.consumerRetirementSupported) { + throw new Error('SSH terminal authority consumer retirement is unsupported') + } + const result = await retireTerminalAuthorityAppConsumer({ + authenticatedAuthorityHostId: options.authenticatedAuthorityHostId, + keypair: options.keypair, + request, + issueChallenge: (start) => + options.mux.request(SSH_TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_CHALLENGE_METHOD, { start }), + complete: (proof) => + options.mux.request(SSH_TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_METHOD, { proof }) + }) + assertCurrent() + return result +} diff --git a/src/main/ssh/ssh-terminal-authority-consumer-methods.ts b/src/main/ssh/ssh-terminal-authority-consumer-methods.ts new file mode 100644 index 00000000000..dd143de8241 --- /dev/null +++ b/src/main/ssh/ssh-terminal-authority-consumer-methods.ts @@ -0,0 +1,8 @@ +export { + SSH_TERMINAL_AUTHORITY_CONSUMER_CHALLENGE_METHOD, + SSH_TERMINAL_AUTHORITY_CONSUMER_GRANT_METHOD, + SSH_TERMINAL_AUTHORITY_CONSUMER_CANCEL_METHOD, + SSH_TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_CHALLENGE_METHOD, + SSH_TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_METHOD, + SSH_TERMINAL_AUTHORITY_CONSUMER_RESOLVE_NAMESPACE_METHOD +} from '../../shared/terminal-authority-consumer-methods' diff --git a/src/main/ssh/ssh-terminal-authority-discovery.test.ts b/src/main/ssh/ssh-terminal-authority-discovery.test.ts new file mode 100644 index 00000000000..c845de2f50a --- /dev/null +++ b/src/main/ssh/ssh-terminal-authority-discovery.test.ts @@ -0,0 +1,151 @@ +import { Buffer } from 'node:buffer' +import { describe, expect, it } from 'vitest' +import { SSH_TERMINAL_AUTHORITY_MARKER_VERSION } from '../../shared/ssh-terminal-authority-marker' +import { CURRENT_RELAY_DAEMON_COMPATIBILITY } from '../../shared/relay-daemon-compatibility' +import { getRemoteHostPlatform } from './ssh-remote-platform' +import { sshTerminalAuthorityEndpoint } from './ssh-terminal-authority-endpoint' +import { + parseSshTerminalAuthorityDiscovery, + parseSshTerminalAuthorityBootstrapRead, + parseSshTerminalAuthorityOwnerProof, + SSH_TERMINAL_AUTHORITY_MARKER_MAX_BYTES, + sshTerminalAuthorityMarkerHasExpectedPaths, + sshTerminalAuthorityBootstrapReadCommand, + sshTerminalAuthorityMarkerReadCommand, + sshTerminalAuthorityOwnerProofCommand +} from './ssh-terminal-authority-discovery' + +function decodePowerShellCommand(command: string): string { + const encoded = command.split(' -EncodedCommand ')[1] ?? '' + return Buffer.from(encoded, 'base64').toString('utf16le') +} + +const linux = getRemoteHostPlatform('linux-x64') +const darwin = getRemoteHostPlatform('darwin-arm64') +const windows = getRemoteHostPlatform('win32-x64') +const endpoint = sshTerminalAuthorityEndpoint(linux, '/home/orca') +const marker = { + markerVersion: SSH_TERMINAL_AUTHORITY_MARKER_VERSION, + authorityHostId: 'authority-host', + ownerInstanceId: 'owner-instance', + ownerPid: 42, + ownerProcessToken: 'owner-process-token', + ownerBuildId: '1.2.3+abcdef', + ownerRelayDir: '/home/orca/.orca-remote/relay-1.2.3+abcdef', + socketPath: endpoint.socketPath, + credentialFile: endpoint.credentialFile, + compatibility: CURRENT_RELAY_DAEMON_COMPATIBILITY, + revision: 1 +} as const + +describe('SSH terminal authority discovery', () => { + it('reads a bounded POSIX marker without following directory-shaped entries', () => { + const command = sshTerminalAuthorityMarkerReadCommand(linux, endpoint.activeEndpointMarker) + expect(command).toContain(`-gt ${SSH_TERMINAL_AUTHORITY_MARKER_MAX_BYTES}`) + expect(command).toContain('[ ! -f "$p" ]') + expect(command).toContain('ORCA_TERMINAL_AUTHORITY_MARKER_PRESENT') + }) + + it('reads a bounded Windows marker as base64', () => { + const script = decodePowerShellCommand( + sshTerminalAuthorityMarkerReadCommand( + windows, + 'C:/Users/orca/.orca-remote/terminal-authority/active-endpoint' + ) + ) + expect(script).toContain(`[IO.File]::ReadAllBytes($path)`) + expect(script).toContain(`${SSH_TERMINAL_AUTHORITY_MARKER_MAX_BYTES}`) + expect(script).toContain('ORCA_TERMINAL_AUTHORITY_MARKER_PRESENT_BASE64') + }) + + it('combines remote-home and marker discovery in the existing bootstrap round trip', () => { + expect(sshTerminalAuthorityBootstrapReadCommand(linux)).toContain( + 'ORCA_TERMINAL_AUTHORITY_HOME' + ) + expect(sshTerminalAuthorityBootstrapReadCommand(linux)).toContain( + '.orca-remote/terminal-authority/active-endpoint' + ) + expect( + parseSshTerminalAuthorityBootstrapRead( + `ORCA_TERMINAL_AUTHORITY_HOME\n/home/orca user\nORCA_TERMINAL_AUTHORITY_MARKER_ABSENT\n` + ) + ).toEqual({ rawRemoteHome: '/home/orca user', discovery: { status: 'absent' } }) + }) + + it('parses raw and Windows-encoded markers', () => { + const json = JSON.stringify(marker) + expect( + parseSshTerminalAuthorityDiscovery(`ORCA_TERMINAL_AUTHORITY_MARKER_PRESENT\n${json}`) + ).toEqual({ status: 'available', marker }) + expect( + parseSshTerminalAuthorityDiscovery( + `ORCA_TERMINAL_AUTHORITY_MARKER_PRESENT_BASE64\r\n${Buffer.from(json).toString('base64')}\r\n` + ) + ).toEqual({ status: 'available', marker }) + }) + + it('distinguishes absence from invalid or inconclusive reads', () => { + expect(parseSshTerminalAuthorityDiscovery('ORCA_TERMINAL_AUTHORITY_MARKER_ABSENT\n')).toEqual({ + status: 'absent' + }) + expect(parseSshTerminalAuthorityDiscovery('ORCA_TERMINAL_AUTHORITY_MARKER_INVALID\n')).toEqual({ + status: 'invalid' + }) + expect(parseSshTerminalAuthorityDiscovery('unexpected output')).toEqual({ + status: 'inconclusive' + }) + }) + + it('admits only the stable endpoint and one versioned owner directory', () => { + expect(sshTerminalAuthorityMarkerHasExpectedPaths(marker, linux, '/home/orca', endpoint)).toBe( + true + ) + expect( + sshTerminalAuthorityMarkerHasExpectedPaths( + { ...marker, ownerRelayDir: '/home/orca/relay-1.2.3+abcdef' }, + linux, + '/home/orca', + endpoint + ) + ).toBe(false) + expect( + sshTerminalAuthorityMarkerHasExpectedPaths( + { ...marker, socketPath: '/home/orca/.orca-remote/relay-1.2.3+abcdef/relay.sock' }, + linux, + '/home/orca', + endpoint + ) + ).toBe(false) + }) + + it('uses PID-reuse-safe process-token proof on every owner host', () => { + const linuxCommand = sshTerminalAuthorityOwnerProofCommand(linux, marker) + expect(linuxCommand).toContain('/proc/$pid/cmdline') + expect(linuxCommand).toContain('--authority-process-token') + + const darwinCommand = sshTerminalAuthorityOwnerProofCommand(darwin, marker) + expect(darwinCommand).toContain('table=$(command ps -ww -ax -o pid= -o command=') + expect(darwinCommand).toContain("printf 'OWNER_UNKNOWN\\n'") + expect(darwinCommand).toContain('valid ? "OWNER_GONE" : "OWNER_UNKNOWN"') + expect(darwinCommand).not.toContain('command=$(ps -ww -p') + + const windowsScript = decodePowerShellCommand( + sshTerminalAuthorityOwnerProofCommand(windows, marker) + ) + expect(windowsScript).toContain('Get-CimInstance Win32_Process') + expect(windowsScript).toContain('-ErrorAction Stop') + expect(windowsScript).toContain("catch { 'OWNER_UNKNOWN' }") + expect(windowsScript).toContain("$owners.Count -eq 0) { 'OWNER_GONE'") + expect(windowsScript).not.toContain('-ErrorAction SilentlyContinue') + expect(windowsScript).toContain('--authority-process-token') + expect(windowsScript).toContain('owner-process-token') + }) + + it('parses only exact positive proof and rejects unusable output', () => { + expect(parseSshTerminalAuthorityOwnerProof('OWNER_ALIVE\n')).toBe('owner-alive') + expect(parseSshTerminalAuthorityOwnerProof('OWNER_GONE\n')).toBe('owner-gone') + expect(parseSshTerminalAuthorityOwnerProof('OWNER_UNKNOWN\n')).toBe('inspection-failed') + expect(parseSshTerminalAuthorityOwnerProof('')).toBe('inspection-failed') + expect(parseSshTerminalAuthorityOwnerProof('OWNER_GONE\nunexpected')).toBe('inspection-failed') + }) +}) diff --git a/src/main/ssh/ssh-terminal-authority-discovery.ts b/src/main/ssh/ssh-terminal-authority-discovery.ts new file mode 100644 index 00000000000..bcc6023f98c --- /dev/null +++ b/src/main/ssh/ssh-terminal-authority-discovery.ts @@ -0,0 +1,284 @@ +import type { SshTerminalAuthorityMarker } from '../../shared/ssh-terminal-authority-marker' +import { parseSshTerminalAuthorityMarker } from '../../shared/ssh-terminal-authority-marker' +import type { SshConnection } from './ssh-connection' +import { shellEscape } from './ssh-connection-utils' +import { execCommand } from './ssh-relay-deploy-helpers' +import { + TERMINAL_AUTHORITY_ACTIVE_ENDPOINT_MARKER_NAME, + TERMINAL_AUTHORITY_DIRECTORY_NAME, + type SshTerminalAuthorityEndpoint +} from './ssh-terminal-authority-endpoint' +import { + isWindowsRemoteHost, + joinRemotePath, + normalizeWindowsRemotePath, + remoteBasename, + remoteDirname, + type RemoteHostPlatform +} from './ssh-remote-platform' +import { powerShellCommand, powerShellLiteral } from './ssh-remote-powershell' +import { RELAY_REMOTE_DIR } from './relay-protocol' + +export const SSH_TERMINAL_AUTHORITY_MARKER_MAX_BYTES = 16 * 1024 + +const MARKER_ABSENT = 'ORCA_TERMINAL_AUTHORITY_MARKER_ABSENT' +const MARKER_PRESENT = 'ORCA_TERMINAL_AUTHORITY_MARKER_PRESENT' +const MARKER_PRESENT_BASE64 = 'ORCA_TERMINAL_AUTHORITY_MARKER_PRESENT_BASE64' +const MARKER_INVALID = 'ORCA_TERMINAL_AUTHORITY_MARKER_INVALID' +const MARKER_INCONCLUSIVE = 'ORCA_TERMINAL_AUTHORITY_MARKER_INCONCLUSIVE' +const OWNER_ALIVE = 'OWNER_ALIVE' +const OWNER_GONE = 'OWNER_GONE' +const OWNER_UNKNOWN = 'OWNER_UNKNOWN' +const BOOTSTRAP_HOME = 'ORCA_TERMINAL_AUTHORITY_HOME' +const RELAY_VERSION_DIR_PATTERN = /^relay-v?\d+\.\d+\.\d+(?:\+[0-9a-f]+)?$/ + +export type SshTerminalAuthorityDiscovery = + | { status: 'absent' } + | { status: 'available'; marker: SshTerminalAuthorityMarker } + | { status: 'invalid' | 'inconclusive' } + +export type SshTerminalAuthorityOwnerProof = 'owner-alive' | 'owner-gone' | 'inspection-failed' + +function posixMarkerReadScript(pathAssignment: string): string { + return ( + `${pathAssignment}; ` + + `if [ ! -e "$p" ]; then printf '${MARKER_ABSENT}\\n'; ` + + `elif [ ! -f "$p" ]; then printf '${MARKER_INVALID}\\n'; ` + + `else size=$(wc -c < "$p" 2>/dev/null) || { printf '${MARKER_INCONCLUSIVE}\\n'; exit 0; }; ` + + `if [ "$size" -gt ${SSH_TERMINAL_AUTHORITY_MARKER_MAX_BYTES} ]; then printf '${MARKER_INVALID}\\n'; ` + + `else printf '${MARKER_PRESENT}\\n'; cat "$p" 2>/dev/null || printf '\\n${MARKER_INCONCLUSIVE}\\n'; ` + + 'fi; fi' + ) +} + +function powerShellMarkerReadScript(pathAssignment: string): string { + return [ + pathAssignment, + 'try {', + `if (-not (Test-Path -LiteralPath $path)) { '${MARKER_ABSENT}' }`, + `elseif (-not (Test-Path -LiteralPath $path -PathType Leaf)) { '${MARKER_INVALID}' }`, + 'else {', + '$item = Get-Item -LiteralPath $path -ErrorAction Stop', + `if ($item.Length -gt ${SSH_TERMINAL_AUTHORITY_MARKER_MAX_BYTES}) { '${MARKER_INVALID}' }`, + `else { '${MARKER_PRESENT_BASE64}'; [Convert]::ToBase64String([IO.File]::ReadAllBytes($path)) }`, + '}', + `} catch { '${MARKER_INCONCLUSIVE}' }` + ].join('; ') +} + +export function sshTerminalAuthorityMarkerReadCommand( + host: RemoteHostPlatform, + markerPath: string +): string { + if (!isWindowsRemoteHost(host)) { + return posixMarkerReadScript(`p=${shellEscape(markerPath)}`) + } + return powerShellCommand(powerShellMarkerReadScript(`$path = ${powerShellLiteral(markerPath)}`)) +} + +export function sshTerminalAuthorityBootstrapReadCommand(host: RemoteHostPlatform): string { + if (!isWindowsRemoteHost(host)) { + const suffix = `${RELAY_REMOTE_DIR}/${TERMINAL_AUTHORITY_DIRECTORY_NAME}/${TERMINAL_AUTHORITY_ACTIVE_ENDPOINT_MARKER_NAME}` + return `home="\${HOME-}"; printf '${BOOTSTRAP_HOME}\\n%s\\n' "$home"; ${posixMarkerReadScript(`p="$home/${suffix}"`)}` + } + return powerShellCommand( + [ + `$home = [Environment]::GetFolderPath('UserProfile')`, + `'${BOOTSTRAP_HOME}'`, + '$home', + powerShellMarkerReadScript( + `$path = [IO.Path]::Combine($home, ${powerShellLiteral(RELAY_REMOTE_DIR)}, ${powerShellLiteral(TERMINAL_AUTHORITY_DIRECTORY_NAME)}, ${powerShellLiteral(TERMINAL_AUTHORITY_ACTIVE_ENDPOINT_MARKER_NAME)})` + ) + ].join('; ') + ) +} + +export function parseSshTerminalAuthorityBootstrapRead( + output: string +): { rawRemoteHome: string; discovery: SshTerminalAuthorityDiscovery } | null { + const normalized = output.replace(/\r\n/g, '\n') + const firstBreak = normalized.indexOf('\n') + const secondBreak = normalized.indexOf('\n', firstBreak + 1) + if ( + firstBreak === -1 || + secondBreak === -1 || + normalized.slice(0, firstBreak).trim() !== BOOTSTRAP_HOME + ) { + return null + } + return { + rawRemoteHome: normalized.slice(firstBreak + 1, secondBreak), + discovery: parseSshTerminalAuthorityDiscovery(normalized.slice(secondBreak + 1)) + } +} + +export function parseSshTerminalAuthorityDiscovery(output: string): SshTerminalAuthorityDiscovery { + const normalized = output.replace(/\r\n/g, '\n') + const separator = normalized.indexOf('\n') + const status = (separator === -1 ? normalized : normalized.slice(0, separator)).trim() + const payload = separator === -1 ? '' : normalized.slice(separator + 1).trim() + if (status === MARKER_ABSENT) { + return { status: 'absent' } + } + if (status === MARKER_INVALID) { + return { status: 'invalid' } + } + if (status === MARKER_INCONCLUSIVE) { + return { status: 'inconclusive' } + } + let markerJson = payload + if (status === MARKER_PRESENT_BASE64) { + try { + markerJson = Buffer.from(payload, 'base64').toString('utf8') + } catch { + return { status: 'invalid' } + } + } else if (status !== MARKER_PRESENT) { + return { status: 'inconclusive' } + } + try { + const marker = parseSshTerminalAuthorityMarker(JSON.parse(markerJson)) + return marker ? { status: 'available', marker } : { status: 'invalid' } + } catch { + return { status: 'invalid' } + } +} + +export async function discoverSshTerminalAuthority( + conn: SshConnection, + host: RemoteHostPlatform, + markerPath: string, + options?: { signal?: AbortSignal } +): Promise { + try { + const output = await execCommand( + conn, + sshTerminalAuthorityMarkerReadCommand(host, markerPath), + { + wrapCommand: !isWindowsRemoteHost(host), + signal: options?.signal + } + ) + return parseSshTerminalAuthorityDiscovery(output) + } catch { + options?.signal?.throwIfAborted() + return { status: 'inconclusive' } + } +} + +function normalizedRemotePath(host: RemoteHostPlatform, value: string): string { + const normalized = + host.pathFlavor === 'windows' ? normalizeWindowsRemotePath(value).toLowerCase() : value + return normalized.replace(/\/+$/, '') +} + +export function sshTerminalAuthorityMarkerHasExpectedPaths( + marker: SshTerminalAuthorityMarker, + host: RemoteHostPlatform, + remoteHome: string, + endpoint: SshTerminalAuthorityEndpoint +): boolean { + const expectedRelayBase = normalizedRemotePath( + host, + joinRemotePath(host, remoteHome, RELAY_REMOTE_DIR) + ) + const ownerRelayDir = normalizedRemotePath(host, marker.ownerRelayDir) + return ( + normalizedRemotePath(host, remoteDirname(ownerRelayDir, host)) === expectedRelayBase && + RELAY_VERSION_DIR_PATTERN.test(remoteBasename(ownerRelayDir, host)) && + normalizedRemotePath(host, marker.socketPath) === + normalizedRemotePath(host, endpoint.socketPath) && + normalizedRemotePath(host, marker.credentialFile) === + normalizedRemotePath(host, endpoint.credentialFile) + ) +} + +export function sshTerminalAuthorityOwnerProofCommand( + host: RemoteHostPlatform, + marker: Pick +): string { + if (isWindowsRemoteHost(host)) { + const token = powerShellLiteral(marker.ownerProcessToken) + const filter = powerShellLiteral(`ProcessId = ${marker.ownerPid}`) + const inspectOwner = [ + '$owner = $owners[0]', + `if ([string]::IsNullOrWhiteSpace([string]$owner.CommandLine)) { '${OWNER_UNKNOWN}' }`, + `elseif ($owner.CommandLine -match ('(?:^|\\s)--authority-process-token\\s+["'']?' + [Regex]::Escape(${token}) + '["'']?(?:\\s|$)')) { '${OWNER_ALIVE}' }`, + `else { '${OWNER_GONE}' }` + ].join('; ') + return powerShellCommand( + `try { $owners = @(Get-CimInstance Win32_Process -Filter ${filter} -ErrorAction Stop); ` + + `if ($owners.Count -eq 0) { '${OWNER_GONE}' } ` + + `elseif ($owners.Count -ne 1) { '${OWNER_UNKNOWN}' } ` + + `else { ${inspectOwner} } } catch { '${OWNER_UNKNOWN}' }` + ) + } + const pid = marker.ownerPid + const token = shellEscape(marker.ownerProcessToken) + if (host.os === 'linux') { + return [ + `pid=${pid}`, + `token=${token}`, + `if [ ! -d "/proc/$pid" ]; then printf '${OWNER_GONE}\\n'`, + `elif [ ! -r "/proc/$pid/cmdline" ]; then printf '${OWNER_UNKNOWN}\\n'`, + `elif tr '\\000' '\\n' < "/proc/$pid/cmdline" | awk -v token="$token" 'previous=="--authority-process-token" && $0==token { found=1 } { previous=$0 } END { exit found ? 0 : 1 }'`, + `then printf '${OWNER_ALIVE}\\n'; else printf '${OWNER_GONE}\\n'; fi` + ].join('; ') + } + + const classifyProcessTable = [ + 'function emit(value) { print value; emitted=1; exit }', + '/^[[:space:]]*[0-9]+([[:space:]]|$)/ {', + 'row=$0; sub(/^[[:space:]]*/, "", row); candidate=row;', + 'sub(/[[:space:]].*$/, "", candidate);', + 'if (candidate !~ /^[0-9]+$/) next;', + 'valid=1; if (candidate != target) next;', + 'sub(/^[0-9]+[[:space:]]*/, "", row);', + `if (length(row) == 0) emit("${OWNER_UNKNOWN}");`, + 'padded=" " row " "; needle=" --authority-process-token " token " ";', + `emit(index(padded, needle) ? "${OWNER_ALIVE}" : "${OWNER_GONE}")`, + '}', + `END { if (!emitted) print valid ? "${OWNER_GONE}" : "${OWNER_UNKNOWN}" }` + ].join(' ') + return [ + `pid=${pid}`, + `token=${token}`, + `table=$(command ps -ww -ax -o pid= -o command= 2>/dev/null) || { printf '${OWNER_UNKNOWN}\\n'; exit 0; }`, + `if [ -z "$table" ]; then printf '${OWNER_UNKNOWN}\\n'`, + `else proof=$(printf '%s\\n' "$table" | awk -v target="$pid" -v token="$token" ${shellEscape(classifyProcessTable)}) || { printf '${OWNER_UNKNOWN}\\n'; exit 0; }`, + `case "$proof" in ${OWNER_ALIVE}|${OWNER_GONE}) printf '%s\\n' "$proof" ;; *) printf '${OWNER_UNKNOWN}\\n' ;; esac; fi` + ].join('; ') +} + +export function parseSshTerminalAuthorityOwnerProof( + output: string +): SshTerminalAuthorityOwnerProof { + switch (output.trim()) { + case OWNER_ALIVE: + return 'owner-alive' + case OWNER_GONE: + return 'owner-gone' + case OWNER_UNKNOWN: + return 'inspection-failed' + default: + return 'inspection-failed' + } +} + +export async function proveSshTerminalAuthorityOwner( + conn: SshConnection, + host: RemoteHostPlatform, + marker: Pick, + options?: { signal?: AbortSignal } +): Promise { + try { + const output = await execCommand(conn, sshTerminalAuthorityOwnerProofCommand(host, marker), { + wrapCommand: !isWindowsRemoteHost(host), + signal: options?.signal + }) + return parseSshTerminalAuthorityOwnerProof(output) + } catch { + options?.signal?.throwIfAborted() + return 'inspection-failed' + } +} diff --git a/src/main/ssh/ssh-terminal-authority-endpoint.test.ts b/src/main/ssh/ssh-terminal-authority-endpoint.test.ts new file mode 100644 index 00000000000..b94c59d407e --- /dev/null +++ b/src/main/ssh/ssh-terminal-authority-endpoint.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest' +import { getRemoteHostPlatform } from './ssh-remote-platform' +import { + TERMINAL_AUTHORITY_SOCKET_NAME, + sshTerminalAuthorityEndpoint, + sshTerminalAuthorityStateDirectoryCommand +} from './ssh-terminal-authority-endpoint' + +function decodePowerShellCommand(command: string): string { + const encoded = command.match(/-EncodedCommand\s+([A-Za-z0-9+/=]+)/)?.[1] ?? '' + return Buffer.from(encoded, 'base64').toString('utf16le') +} + +describe('SSH terminal authority endpoint', () => { + it('is stable across relay build directories and SSH target aliases on POSIX', () => { + const host = getRemoteHostPlatform('linux-x64') + const first = sshTerminalAuthorityEndpoint(host, '/home/orca user') + const second = sshTerminalAuthorityEndpoint(host, '/home/orca user') + + expect(first).toEqual(second) + expect(first.socketPath).toBe( + `/home/orca user/.orca-remote/terminal-authority/${TERMINAL_AUTHORITY_SOCKET_NAME}` + ) + expect(first.socketPath).not.toContain('relay-v') + }) + + it('uses one deterministic named pipe and host-local state directory on Windows', () => { + const host = getRemoteHostPlatform('win32-x64') + const endpoint = sshTerminalAuthorityEndpoint(host, 'C:\\Users\\Orca User') + + expect(endpoint.socketPath).toMatch(/^\\\\\.\\pipe\\orca-relay-[0-9a-f]{20}$/) + expect(endpoint.stateDir).toBe('C:/Users/Orca User/.orca-remote/terminal-authority') + expect(endpoint.credentialFile).toBe( + 'C:/Users/Orca User/.orca-remote/terminal-authority/endpoint.credential' + ) + }) + + it('keeps different remote accounts isolated', () => { + const host = getRemoteHostPlatform('win32-x64') + const alice = sshTerminalAuthorityEndpoint(host, 'C:\\Users\\alice') + const bob = sshTerminalAuthorityEndpoint(host, 'C:\\Users\\bob') + + expect(alice.socketPath).not.toBe(bob.socketPath) + expect(alice.stateDir).not.toBe(bob.stateDir) + }) + + it('creates the stable state directory with owner-only access', () => { + expect(sshTerminalAuthorityStateDirectoryCommand(hostFor('linux'), '/home/orca dir')).toContain( + "chmod 700 '/home/orca dir'" + ) + const windowsCommand = sshTerminalAuthorityStateDirectoryCommand( + hostFor('windows'), + 'C:/Users/orca/.orca-remote/terminal-authority' + ) + const windowsScript = decodePowerShellCommand(windowsCommand) + expect(windowsScript).toContain('/inheritance:r') + expect(windowsScript).toContain('(OI)(CI)F') + }) +}) + +function hostFor(platform: 'linux' | 'windows') { + return getRemoteHostPlatform(platform === 'linux' ? 'linux-x64' : 'win32-x64') +} diff --git a/src/main/ssh/ssh-terminal-authority-endpoint.ts b/src/main/ssh/ssh-terminal-authority-endpoint.ts new file mode 100644 index 00000000000..4083ed1684d --- /dev/null +++ b/src/main/ssh/ssh-terminal-authority-endpoint.ts @@ -0,0 +1,59 @@ +import { RELAY_REMOTE_DIR } from './relay-protocol' +import { relayEndpointForHost } from './ssh-relay-endpoints' +import { shellEscape } from './ssh-connection-utils' +import { isWindowsRemoteHost, joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' +import { powerShellCommand, powerShellLiteral } from './ssh-remote-powershell' + +export const TERMINAL_AUTHORITY_DIRECTORY_NAME = 'terminal-authority' +export const TERMINAL_AUTHORITY_SOCKET_NAME = 'authority.sock' +export const TERMINAL_AUTHORITY_ACTIVE_ENDPOINT_MARKER_NAME = 'active-endpoint' + +export type SshTerminalAuthorityEndpoint = { + stateDir: string + socketPath: string + credentialFile: string + activeEndpointMarker: string + logFile: string + errorLogFile: string +} + +export function sshTerminalAuthorityEndpoint( + host: RemoteHostPlatform, + remoteHome: string +): SshTerminalAuthorityEndpoint { + const stateDir = joinRemotePath( + host, + remoteHome, + RELAY_REMOTE_DIR, + TERMINAL_AUTHORITY_DIRECTORY_NAME + ) + return { + stateDir, + socketPath: relayEndpointForHost(host, stateDir, TERMINAL_AUTHORITY_SOCKET_NAME), + credentialFile: joinRemotePath(host, stateDir, 'endpoint.credential'), + activeEndpointMarker: joinRemotePath( + host, + stateDir, + TERMINAL_AUTHORITY_ACTIVE_ENDPOINT_MARKER_NAME + ), + logFile: joinRemotePath(host, stateDir, 'authority.log'), + errorLogFile: joinRemotePath(host, stateDir, 'authority.err.log') + } +} + +export function sshTerminalAuthorityStateDirectoryCommand( + host: RemoteHostPlatform, + stateDir: string +): string { + if (!isWindowsRemoteHost(host)) { + return `umask 077; mkdir -p ${shellEscape(stateDir)}; chmod 700 ${shellEscape(stateDir)}` + } + return powerShellCommand( + [ + `$path = ${powerShellLiteral(stateDir)}`, + '$null = New-Item -ItemType Directory -Force -Path $path', + '$identity = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name', + '& icacls.exe $path /inheritance:r /grant:r "${identity}:(OI)(CI)F" | Out-Null' + ].join('; ') + ) +} diff --git a/src/main/ssh/ssh-terminal-authority-exit-wait.test.ts b/src/main/ssh/ssh-terminal-authority-exit-wait.test.ts new file mode 100644 index 00000000000..19506f35565 --- /dev/null +++ b/src/main/ssh/ssh-terminal-authority-exit-wait.test.ts @@ -0,0 +1,161 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { Store } from '../persistence' +import type { TerminalSessionAuthorityPtyAccess } from '../../shared/terminal-session-authority-pty-access' +import { + prepareSshTerminalAuthorityExitWait, + publishSshTerminalPhysicalExit +} from './ssh-terminal-authority-exit-wait' + +const authorityAccess: TerminalSessionAuthorityPtyAccess = { + namespace: { authorityHostId: 'host-1', namespaceId: 'namespace-1' }, + pane: { paneKey: 'pane-1', paneGenerationId: 'renderer:1' }, + binding: { + ownerIncarnationId: 'owner-1', + physicalPtyId: 'pty-1', + ptyIncarnationId: 'incarnation-1' + } +} + +const target = { + targetId: 'ssh-1', + relayPtyId: 'pty-1', + appPtyId: 'ssh:ssh-1@@pty-1', + authorityAccess +} as const + +function storeMock(): Pick { + return { + requestSshRemotePtyClose: vi.fn(() => 'recorded' as const), + clearSshRemotePtyCloseRequest: vi.fn(() => true) + } +} + +describe('SSH terminal authority exit wait', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('registers the exact physical-exit observer before persisting close intent', async () => { + const store = storeMock() + vi.mocked(store.requestSshRemotePtyClose).mockImplementation(() => { + publishExactExit() + return 'recorded' + }) + + const wait = prepareSshTerminalAuthorityExitWait(store as Store, target) + + await expect(wait.completion).resolves.toBe(true) + expect(store.requestSshRemotePtyClose).toHaveBeenCalledWith('ssh-1', 'pty-1', { + incarnationId: 'incarnation-1', + terminalSessionAuthorityAccess: authorityAccess, + keepHistory: false, + requestedAt: expect.any(Number) + }) + wait.dispose() + }) + + it('resolves true only after the exact physical exit settles', async () => { + const wait = prepareSshTerminalAuthorityExitWait(storeMock() as Store, target) + + let settled = false + void wait.completion.then(() => { + settled = true + }) + await Promise.resolve() + expect(settled).toBe(false) + + publishExactExit() + await expect(wait.completion).resolves.toBe(true) + wait.dispose() + }) + + it('does not let another target, PTY, or incarnation complete the wait', async () => { + const wait = prepareSshTerminalAuthorityExitWait(storeMock() as Store, target) + let settled = false + void wait.completion.then(() => { + settled = true + }) + + publishSshTerminalPhysicalExit({ + targetId: 'ssh-2', + relayPtyId: 'pty-1', + ptyIncarnationId: 'incarnation-1', + code: 0 + }) + publishSshTerminalPhysicalExit({ + targetId: 'ssh-1', + relayPtyId: 'pty-2', + ptyIncarnationId: 'incarnation-1', + code: 0 + }) + publishSshTerminalPhysicalExit({ + targetId: 'ssh-1', + relayPtyId: 'pty-1', + ptyIncarnationId: 'incarnation-2', + code: 0 + }) + await Promise.resolve() + expect(settled).toBe(false) + + publishExactExit() + await expect(wait.completion).resolves.toBe(true) + wait.dispose() + }) + + it('resolves false on timeout without clearing durable close intent', async () => { + vi.useFakeTimers() + const store = storeMock() + const wait = prepareSshTerminalAuthorityExitWait(store as Store, target, 25) + + await vi.advanceTimersByTimeAsync(25) + await expect(wait.completion).resolves.toBe(false) + wait.dispose() + + expect(store.clearSshRemotePtyCloseRequest).not.toHaveBeenCalled() + }) + + it('disposes the observer when close-intent persistence fails', () => { + const store = storeMock() + vi.mocked(store.requestSshRemotePtyClose).mockImplementation(() => { + throw new Error('disk unavailable') + }) + + expect(() => prepareSshTerminalAuthorityExitWait(store as Store, target)).toThrow( + 'disk unavailable' + ) + }) + + it('cancels only the unsent full binding and keeps cancellation disposal separate', () => { + const store = storeMock() + const wait = prepareSshTerminalAuthorityExitWait(store as Store, target) + + wait.cancelUnsent() + + expect(store.clearSshRemotePtyCloseRequest).toHaveBeenCalledWith( + 'ssh-1', + 'pty-1', + authorityAccess + ) + wait.dispose() + }) + + it('does not cancel a duplicate durable intent owned by an earlier attempt', () => { + const store = storeMock() + vi.mocked(store.requestSshRemotePtyClose).mockReturnValue('duplicate') + const wait = prepareSshTerminalAuthorityExitWait(store as Store, target) + + wait.cancelUnsent() + + expect(store.clearSshRemotePtyCloseRequest).not.toHaveBeenCalled() + wait.dispose() + }) +}) + +function publishExactExit(): void { + publishSshTerminalPhysicalExit({ + targetId: target.targetId, + relayPtyId: target.relayPtyId, + ptyIncarnationId: target.authorityAccess.binding.ptyIncarnationId, + code: 0 + }) +} diff --git a/src/main/ssh/ssh-terminal-authority-exit-wait.ts b/src/main/ssh/ssh-terminal-authority-exit-wait.ts new file mode 100644 index 00000000000..3f04923e820 --- /dev/null +++ b/src/main/ssh/ssh-terminal-authority-exit-wait.ts @@ -0,0 +1,169 @@ +import type { Store } from '../persistence' +import type { + SshTerminalAuthorityExitTarget, + SshTerminalAuthorityExitWait +} from './ssh-terminal-session-termination' + +export const SSH_TERMINAL_AUTHORITY_EXIT_WAIT_MS = 12_000 +const MAX_SSH_TERMINAL_PHYSICAL_EXIT_OBSERVERS = 4_096 + +export type SshTerminalPhysicalExit = Readonly<{ + targetId: string + relayPtyId: string + ptyIncarnationId: string + code: number +}> + +type PhysicalExitObserver = Readonly<{ + resolve: (exit: SshTerminalPhysicalExit) => void +}> + +type PhysicalExitObservation = Readonly<{ + completion: Promise + dispose(): void +}> + +const physicalExitObservers = new Map>() +let physicalExitObserverCount = 0 + +export function prepareSshTerminalAuthorityExitWait( + store: Store, + target: SshTerminalAuthorityExitTarget, + timeoutMs = SSH_TERMINAL_AUTHORITY_EXIT_WAIT_MS +): SshTerminalAuthorityExitWait { + const observation = observeSshTerminalPhysicalExit( + target.targetId, + target.relayPtyId, + target.authorityAccess.binding.ptyIncarnationId + ) + let closeRequest: 'recorded' | 'duplicate' + try { + closeRequest = store.requestSshRemotePtyClose(target.targetId, target.relayPtyId, { + incarnationId: target.authorityAccess.binding.ptyIncarnationId, + terminalSessionAuthorityAccess: target.authorityAccess, + keepHistory: false, + requestedAt: Date.now() + }) + } catch (error) { + observation.dispose() + throw error + } + return createExitWait(store, target, observation, closeRequest === 'recorded', timeoutMs) +} + +function createExitWait( + store: Store, + target: SshTerminalAuthorityExitTarget, + observation: PhysicalExitObservation, + ownsCloseRequest: boolean, + timeoutMs: number +): SshTerminalAuthorityExitWait { + let timer: ReturnType | null = null + const timeout = new Promise((resolve) => { + timer = setTimeout(() => resolve(false), Math.max(1, timeoutMs)) + timer.unref?.() + }) + return Object.freeze({ + completion: Promise.race([observation.completion.then(() => true), timeout]), + cancelUnsent: () => { + if (!ownsCloseRequest) { + return + } + store.clearSshRemotePtyCloseRequest( + target.targetId, + target.relayPtyId, + target.authorityAccess + ) + }, + dispose: () => { + if (timer) { + clearTimeout(timer) + timer = null + } + observation.dispose() + } + }) +} + +export function publishSshTerminalPhysicalExit(exit: SshTerminalPhysicalExit): void { + if (!validPhysicalExit(exit)) { + return + } + const key = physicalExitKey(exit.targetId, exit.relayPtyId, exit.ptyIncarnationId) + const observers = physicalExitObservers.get(key) + if (!observers) { + return + } + physicalExitObservers.delete(key) + physicalExitObserverCount -= observers.size + const published = Object.freeze({ ...exit }) + for (const observer of observers) { + observer.resolve(published) + } +} + +function observeSshTerminalPhysicalExit( + targetId: string, + relayPtyId: string, + ptyIncarnationId: string +): PhysicalExitObservation { + if (!validPhysicalExitIdentity(targetId, relayPtyId, ptyIncarnationId)) { + throw new Error('ssh_terminal_physical_exit_observer_invalid') + } + if (physicalExitObserverCount >= MAX_SSH_TERMINAL_PHYSICAL_EXIT_OBSERVERS) { + throw new Error('ssh_terminal_physical_exit_observer_capacity') + } + const key = physicalExitKey(targetId, relayPtyId, ptyIncarnationId) + let observer!: PhysicalExitObserver + let active = true + const completion = new Promise((resolve) => { + observer = Object.freeze({ resolve }) + }) + const observers = physicalExitObservers.get(key) ?? new Set() + observers.add(observer) + physicalExitObservers.set(key, observers) + physicalExitObserverCount += 1 + return Object.freeze({ + completion, + dispose: () => { + if (!active) { + return + } + active = false + const current = physicalExitObservers.get(key) + if (!current?.delete(observer)) { + return + } + physicalExitObserverCount -= 1 + if (current.size === 0) { + physicalExitObservers.delete(key) + } + } + }) +} + +function validPhysicalExit(exit: SshTerminalPhysicalExit): boolean { + return ( + validPhysicalExitIdentity(exit.targetId, exit.relayPtyId, exit.ptyIncarnationId) && + Number.isSafeInteger(exit.code) + ) +} + +function validPhysicalExitIdentity( + targetId: string, + relayPtyId: string, + ptyIncarnationId: string +): boolean { + return ( + targetId.length > 0 && + targetId.length <= 512 && + relayPtyId.length > 0 && + relayPtyId.length <= 512 && + ptyIncarnationId.length > 0 && + ptyIncarnationId.length <= 512 + ) +} + +function physicalExitKey(targetId: string, relayPtyId: string, ptyIncarnationId: string): string { + return JSON.stringify([targetId, relayPtyId, ptyIncarnationId]) +} diff --git a/src/main/ssh/ssh-terminal-authority-owner-proof.test.ts b/src/main/ssh/ssh-terminal-authority-owner-proof.test.ts new file mode 100644 index 00000000000..54b92577a72 --- /dev/null +++ b/src/main/ssh/ssh-terminal-authority-owner-proof.test.ts @@ -0,0 +1,45 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { SshConnection } from './ssh-connection' +import { execCommand } from './ssh-relay-deploy-helpers' +import { getRemoteHostPlatform } from './ssh-remote-platform' +import { proveSshTerminalAuthorityOwner } from './ssh-terminal-authority-discovery' + +vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand: vi.fn() })) + +const conn = {} as SshConnection +const darwin = getRemoteHostPlatform('darwin-arm64') +const marker = { ownerPid: 42, ownerProcessToken: 'owner-process-token' } as const + +describe('SSH terminal authority owner proof', () => { + beforeEach(() => { + vi.mocked(execCommand).mockReset() + }) + + it('maps command failure to explicit inspection failure', async () => { + vi.mocked(execCommand).mockRejectedValueOnce(new Error('process inspection unavailable')) + + await expect(proveSshTerminalAuthorityOwner(conn, darwin, marker)).resolves.toBe( + 'inspection-failed' + ) + }) + + it.each(['', 'malformed output', 'OWNER_UNKNOWN\n'])( + 'rejects unusable proof output %j', + async (output) => { + vi.mocked(execCommand).mockResolvedValueOnce(output) + + await expect(proveSshTerminalAuthorityOwner(conn, darwin, marker)).resolves.toBe( + 'inspection-failed' + ) + } + ) + + it.each([ + ['OWNER_ALIVE\n', 'owner-alive'], + ['OWNER_GONE\n', 'owner-gone'] + ] as const)('accepts exact proof output %j', async (output, expected) => { + vi.mocked(execCommand).mockResolvedValueOnce(output) + + await expect(proveSshTerminalAuthorityOwner(conn, darwin, marker)).resolves.toBe(expected) + }) +}) diff --git a/src/main/ssh/ssh-terminal-authority-process-commands.test.ts b/src/main/ssh/ssh-terminal-authority-process-commands.test.ts new file mode 100644 index 00000000000..0b5befbf003 --- /dev/null +++ b/src/main/ssh/ssh-terminal-authority-process-commands.test.ts @@ -0,0 +1,154 @@ +import { Buffer } from 'node:buffer' +import { describe, expect, it } from 'vitest' +import { getRemoteHostPlatform } from './ssh-remote-platform' +import { sshTerminalAuthorityEndpoint } from './ssh-terminal-authority-endpoint' +import { + AUTHORITY_READY_INTERVAL_MS, + AUTHORITY_READY_TIMEOUT_MS, + sshTerminalAuthorityConnectCommand, + sshTerminalAuthorityLaunchCommand, + sshTerminalAuthorityReadyCommand +} from './ssh-terminal-authority-process-commands' + +function decodePowerShellCommand(command: string): string { + const encoded = command.match(/-EncodedCommand\s+([A-Za-z0-9+/=]+)/)?.[1] ?? '' + return Buffer.from(encoded, 'base64').toString('utf16le') +} + +describe('SSH terminal authority process commands', () => { + it('connects through the exact owner build and stable POSIX endpoint', () => { + const host = getRemoteHostPlatform('linux-x64') + const endpoint = sshTerminalAuthorityEndpoint(host, '/home/orca user') + const command = sshTerminalAuthorityConnectCommand({ + host, + nodePath: '/opt/node bin/node', + ownerRelayDir: '/home/orca user/.orca-remote/relay-1.2.3+abcdef', + endpoint, + expectedOwner: { + authorityHostId: 'authority host', + ownerInstanceId: 'owner instance', + revision: 7 + } + }) + + expect(command).toContain("cd '/home/orca user/.orca-remote/relay-1.2.3+abcdef'") + expect(command).toContain("'/opt/node bin/node'") + expect(command).toContain( + "--sock-path '/home/orca user/.orca-remote/terminal-authority/authority.sock'" + ) + expect(command).toContain('--credential-file') + expect(command).toContain("--authority-expect-host-id 'authority host'") + expect(command).toContain("--authority-expect-owner-instance 'owner instance'") + expect(command).toContain('--authority-expect-revision 7') + }) + + it('connects to the exact authority owner on Windows', () => { + const host = getRemoteHostPlatform('win32-x64') + const endpoint = sshTerminalAuthorityEndpoint(host, 'C:/Users/Orca User') + const command = sshTerminalAuthorityConnectCommand({ + host, + nodePath: 'C:/Program Files/nodejs/node.exe', + ownerRelayDir: 'C:/Users/Orca User/.orca-remote/relay-1.2.3+abcdef', + endpoint, + expectedOwner: { + authorityHostId: 'windows-host', + ownerInstanceId: 'windows-owner', + revision: 11 + } + }) + const script = decodePowerShellCommand(command) + + expect(script).toContain("--authority-expect-host-id 'windows-host'") + expect(script).toContain("--authority-expect-owner-instance 'windows-owner'") + expect(script).toContain('--authority-expect-revision 11') + }) + + it('launches a first POSIX owner with stable state and no takeover claim', () => { + const host = getRemoteHostPlatform('linux-x64') + const endpoint = sshTerminalAuthorityEndpoint(host, '/home/orca') + const command = sshTerminalAuthorityLaunchCommand({ + host, + nodePath: '/usr/bin/node', + relayDir: '/home/orca/.orca-remote/relay-1.2.3+abcdef', + endpoint, + processToken: 'new-owner-process-token', + graceTimeSeconds: 300 + }) + + expect(command).toContain("'--terminal-authority'") + expect(command).toContain("'--authority-process-token' 'new-owner-process-token'") + expect(command).toContain("'--authority-marker-path'") + expect(command).not.toContain('--authority-takeover-token') + }) + + it('carries exact prior owner identity on takeover', () => { + const host = getRemoteHostPlatform('linux-x64') + const endpoint = sshTerminalAuthorityEndpoint(host, '/home/orca') + const command = sshTerminalAuthorityLaunchCommand({ + host, + nodePath: '/usr/bin/node', + relayDir: '/home/orca/.orca-remote/relay-2.0.0+fedcba', + endpoint, + processToken: 'replacement-process-token', + graceTimeSeconds: 300, + takeover: { ownerProcessToken: 'prior-process-token', revision: 9 } + }) + + expect(command).toContain("'--authority-takeover-token' 'prior-process-token'") + expect(command).toContain("'--authority-takeover-revision' '9'") + }) + + it('uses an owner-scoped WMI launch and stable named pipe on Windows', () => { + const host = getRemoteHostPlatform('win32-x64') + const endpoint = sshTerminalAuthorityEndpoint(host, 'C:/Users/Orca User') + const command = sshTerminalAuthorityLaunchCommand({ + host, + nodePath: 'C:/Program Files/nodejs/node.exe', + relayDir: 'C:/Users/Orca User/.orca-remote/relay-2.0.0+fedcba', + endpoint, + processToken: 'windows-owner-process-token', + graceTimeSeconds: 600 + }) + const script = decodePowerShellCommand(command) + + expect(script).toContain('Invoke-CimMethod -ClassName Win32_Process') + expect(script).toContain('--terminal-authority') + expect(script).toContain(endpoint.socketPath) + expect(script).toContain(endpoint.activeEndpointMarker) + }) + + it('probes POSIX readiness on the stable socket with the shared ready budget', () => { + const host = getRemoteHostPlatform('linux-x64') + const endpoint = sshTerminalAuthorityEndpoint(host, '/home/orca user') + const command = sshTerminalAuthorityReadyCommand({ + host, + nodePath: '/opt/node bin/node', + relayDir: '/home/orca user/.orca-remote/relay-1.2.3+abcdef', + socketPath: endpoint.socketPath + }) + + expect(command).toContain("'/opt/node bin/node' -e ") + expect(command).toContain(`'${endpoint.socketPath}'`) + expect(command.endsWith(` ${AUTHORITY_READY_TIMEOUT_MS} ${AUTHORITY_READY_INTERVAL_MS}`)).toBe( + true + ) + }) + + it('probes Windows readiness on the stable named pipe', () => { + const host = getRemoteHostPlatform('win32-x64') + const endpoint = sshTerminalAuthorityEndpoint(host, 'C:\\Users\\orca user') + const decoded = decodePowerShellCommand( + sshTerminalAuthorityReadyCommand({ + host, + nodePath: 'C:\\Program Files\\node\\node.exe', + relayDir: 'C:\\Users\\orca user\\.orca-remote\\relay-1.2.3+abcdef', + socketPath: endpoint.socketPath + }) + ) + + expect(endpoint.socketPath.startsWith('\\\\.\\pipe\\')).toBe(true) + expect(decoded).toContain(endpoint.socketPath) + expect(decoded).toContain(String(AUTHORITY_READY_TIMEOUT_MS)) + expect(decoded).toContain(String(AUTHORITY_READY_INTERVAL_MS)) + }) +}) diff --git a/src/main/ssh/ssh-terminal-authority-process-commands.ts b/src/main/ssh/ssh-terminal-authority-process-commands.ts new file mode 100644 index 00000000000..0dedabfc952 --- /dev/null +++ b/src/main/ssh/ssh-terminal-authority-process-commands.ts @@ -0,0 +1,179 @@ +import { shellEscape } from './ssh-connection-utils' +import { relayHookEndpointDirForHost } from './ssh-relay-endpoints' +import { commandWithNodePath } from './ssh-remote-commands' +import { isWindowsRemoteHost, joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' +import { powerShellLiteral, powerShellNativeArg } from './ssh-remote-powershell' +import type { SshTerminalAuthorityEndpointIdentity } from '../../shared/ssh-terminal-authority-marker' +import type { SshTerminalAuthorityTakeover } from './ssh-terminal-authority-admission' +import type { SshTerminalAuthorityEndpoint } from './ssh-terminal-authority-endpoint' + +export const AUTHORITY_READY_TIMEOUT_MS = 10_000 +export const AUTHORITY_READY_INTERVAL_MS = 100 + +function relayScript(host: RemoteHostPlatform, relayDir: string): string { + return joinRemotePath(host, relayDir, 'relay.js') +} + +export function sshTerminalAuthorityConnectCommand(args: { + host: RemoteHostPlatform + nodePath: string + ownerRelayDir: string + endpoint: SshTerminalAuthorityEndpoint + expectedOwner: SshTerminalAuthorityEndpointIdentity +}): string { + const script = relayScript(args.host, args.ownerRelayDir) + if (!isWindowsRemoteHost(args.host)) { + return ( + `cd ${shellEscape(args.ownerRelayDir)} && ` + + `${shellEscape(args.nodePath)} ${shellEscape(script)} --connect ` + + `--sock-path ${shellEscape(args.endpoint.socketPath)} ` + + `--credential-file ${shellEscape(args.endpoint.credentialFile)} ` + + `--authority-expect-host-id ${shellEscape(args.expectedOwner.authorityHostId)} ` + + `--authority-expect-owner-instance ${shellEscape(args.expectedOwner.ownerInstanceId)} ` + + `--authority-expect-revision ${String(args.expectedOwner.revision)}` + ) + } + return commandWithNodePath( + args.host, + args.nodePath, + args.ownerRelayDir, + [ + `& ${powerShellLiteral(args.nodePath)}`, + powerShellNativeArg(script), + '--connect', + '--sock-path', + powerShellNativeArg(args.endpoint.socketPath), + '--credential-file', + powerShellNativeArg(args.endpoint.credentialFile), + '--authority-expect-host-id', + powerShellNativeArg(args.expectedOwner.authorityHostId), + '--authority-expect-owner-instance', + powerShellNativeArg(args.expectedOwner.ownerInstanceId), + '--authority-expect-revision', + String(args.expectedOwner.revision) + ].join(' ') + ) +} + +function authorityArguments(args: { + host: RemoteHostPlatform + endpoint: SshTerminalAuthorityEndpoint + processToken: string + graceTimeSeconds: number + takeover?: SshTerminalAuthorityTakeover +}): string[] { + const endpointDir = relayHookEndpointDirForHost( + args.host, + args.endpoint.stateDir, + args.endpoint.socketPath + ) + return [ + '--detached', + '--terminal-authority', + '--grace-time', + String(args.graceTimeSeconds), + '--sock-path', + args.endpoint.socketPath, + '--credential-file', + args.endpoint.credentialFile, + '--endpoint-dir', + endpointDir, + '--log-file', + args.endpoint.logFile, + '--authority-state-dir', + args.endpoint.stateDir, + '--authority-marker-path', + args.endpoint.activeEndpointMarker, + '--authority-process-token', + args.processToken, + ...(args.takeover + ? [ + '--authority-takeover-token', + args.takeover.ownerProcessToken, + '--authority-takeover-revision', + String(args.takeover.revision) + ] + : []) + ] +} + +export function sshTerminalAuthorityLaunchCommand(args: { + host: RemoteHostPlatform + nodePath: string + relayDir: string + endpoint: SshTerminalAuthorityEndpoint + processToken: string + graceTimeSeconds: number + takeover?: SshTerminalAuthorityTakeover +}): string { + const script = relayScript(args.host, args.relayDir) + const launchArgs = authorityArguments(args) + if (!isWindowsRemoteHost(args.host)) { + const command = [shellEscape(args.nodePath), shellEscape(script)] + .concat(launchArgs.map(shellEscape)) + .join(' ') + return ( + `cd ${shellEscape(args.relayDir)} && chmod 600 ${shellEscape(args.endpoint.credentialFile)} && ` + + `nohup ${command} > ${shellEscape(args.endpoint.logFile)} 2>&1 `"${value.replace(/"/g, '\\"')}"` + const commandLine = [quoted(args.nodePath), quoted(script)] + .concat(launchArgs.map(quoted)) + .concat([`1>${quoted(args.endpoint.logFile)}`, `2>${quoted(args.endpoint.errorLogFile)}`]) + .join(' ') + const wmiCommandLine = `cmd.exe /d /s /c "${commandLine}"` + return commandWithNodePath( + args.host, + args.nodePath, + args.relayDir, + [ + `$result = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{ CommandLine = ${powerShellLiteral(wmiCommandLine)}; CurrentDirectory = ${powerShellLiteral(args.relayDir)} }`, + 'if ($result.ReturnValue -ne 0) { throw "Win32_Process.Create failed with $($result.ReturnValue)" }' + ].join('; ') + ) +} + +export function sshTerminalAuthorityReadyCommand(args: { + host: RemoteHostPlatform + nodePath: string + relayDir: string + socketPath: string +}): string { + const js = [ + 'const net=require("net");', + 'const endpoint=process.argv[1];', + 'const deadline=Date.now()+Number(process.argv[2]);', + 'const interval=Number(process.argv[3]);', + 'function finish(value){process.stdout.write(value);process.exit(0)}', + 'function attempt(){const socket=net.connect(endpoint);let settled=false;', + 'function retry(){if(settled)return;settled=true;socket.destroy();', + 'if(Date.now()>=deadline)finish("WAITING");else setTimeout(attempt,interval)}', + 'socket.setTimeout(Math.min(interval,500));', + 'socket.on("connect",()=>{if(settled)return;settled=true;socket.destroy();finish("READY")});', + 'socket.on("timeout",retry);socket.on("error",retry)}attempt();' + ].join('') + if (!isWindowsRemoteHost(args.host)) { + return [ + shellEscape(args.nodePath), + '-e', + shellEscape(js), + shellEscape(args.socketPath), + String(AUTHORITY_READY_TIMEOUT_MS), + String(AUTHORITY_READY_INTERVAL_MS) + ].join(' ') + } + return commandWithNodePath( + args.host, + args.nodePath, + args.relayDir, + [ + `& ${powerShellLiteral(args.nodePath)}`, + '-e', + powerShellNativeArg(js), + powerShellNativeArg(args.socketPath), + powerShellNativeArg(String(AUTHORITY_READY_TIMEOUT_MS)), + powerShellNativeArg(String(AUTHORITY_READY_INTERVAL_MS)) + ].join(' ') + ) +} diff --git a/src/main/ssh/ssh-terminal-authority-process-fixture.ts b/src/main/ssh/ssh-terminal-authority-process-fixture.ts new file mode 100644 index 00000000000..fa9735c3ecd --- /dev/null +++ b/src/main/ssh/ssh-terminal-authority-process-fixture.ts @@ -0,0 +1,96 @@ +// Why: the deploy specs stub establishSshTerminalAuthority. The stub must carry the same +// negotiated compatibility grant a live owner returns, or capability-gated deploy behavior +// silently stops being exercised. +import { + CURRENT_RELAY_DAEMON_COMPATIBILITY, + RELAY_DAEMON_PROTOCOL_MAJOR, + RELAY_DAEMON_PROTOCOL_MINOR, + type RelayDaemonCompatibilityGrant +} from '../../shared/relay-daemon-compatibility' +import { SSH_TERMINAL_AUTHORITY_MARKER_VERSION } from '../../shared/ssh-terminal-authority-marker' +import type { SshTerminalAuthorityProcess } from './ssh-terminal-authority-process' + +/** What a current-version remote owner grants after negotiating against this client's offer. */ +export function currentSshTerminalAuthorityGrant(): RelayDaemonCompatibilityGrant { + return { + major: RELAY_DAEMON_PROTOCOL_MAJOR, + minor: RELAY_DAEMON_PROTOCOL_MINOR, + capabilities: [...CURRENT_RELAY_DAEMON_COMPATIBILITY.capabilities] + } +} + +/** An owner one negotiation older than this client: same protocol, narrower capability set. */ +export function sshTerminalAuthorityGrantWithout( + ...withheld: readonly string[] +): RelayDaemonCompatibilityGrant { + const grant = currentSshTerminalAuthorityGrant() + return { + ...grant, + capabilities: grant.capabilities.filter((capability) => !withheld.includes(capability)) + } +} + +/** + * Why: specs that exercise the real establishSshTerminalAuthority must answer its marker read with + * a marker the admission parser accepts, including a negotiable compatibility offer. + */ +export function sshTerminalAuthorityMarkerRead( + options: Readonly<{ remoteHome: string; relayVersion?: string }> +): string { + // The admission parser only accepts a content-hashed relay version directory. + const relayVersion = options.relayVersion ?? '0.1.0+abcdef012345' + const authorityDir = `${options.remoteHome}/.orca-remote/terminal-authority` + const marker = { + markerVersion: SSH_TERMINAL_AUTHORITY_MARKER_VERSION, + authorityHostId: 'authority-host', + ownerInstanceId: 'authority-owner', + ownerPid: 4242, + ownerProcessToken: 'owner-process-token', + ownerBuildId: relayVersion, + ownerRelayDir: `${options.remoteHome}/.orca-remote/relay-${relayVersion}`, + socketPath: `${authorityDir}/authority.sock`, + credentialFile: `${authorityDir}/endpoint.credential`, + compatibility: CURRENT_RELAY_DAEMON_COMPATIBILITY, + revision: 1 + } + return `ORCA_TERMINAL_AUTHORITY_MARKER_PRESENT\n${JSON.stringify(marker)}\n` +} + +/** The bootstrap read's combined home + authority-marker response. */ +export function sshTerminalAuthorityBootstrapRead( + remoteHome: string, + marker = 'ORCA_TERMINAL_AUTHORITY_MARKER_ABSENT' +): string { + return `ORCA_TERMINAL_AUTHORITY_HOME\n${remoteHome}\n${marker}\n` +} + +/** execCommand responses establishSshTerminalAuthority consumes when it launches a first owner. */ +export function sshTerminalAuthorityLaunchExecResponses( + options: Readonly<{ remoteHome: string; relayVersion?: string }> +): string[] { + return [ + '', // authority state directory + '', // publish the per-launch endpoint credential + 'READY', // authority readiness probe + sshTerminalAuthorityMarkerRead(options) // owner marker discovery after launch + ] +} + +export function makeSshTerminalAuthorityProcess( + overrides: Partial & { remoteHome?: string } = {} +): SshTerminalAuthorityProcess { + const { remoteHome = '/home/user', ...rest } = overrides + const authorityDir = `${remoteHome}/.orca-remote/terminal-authority` + return { + markerPath: `${authorityDir}/active.json`, + authorityHostId: 'authority-host', + ownerInstanceId: 'authority-owner', + revision: 1, + ownerBuildId: '0.1.0+abcdef012345', + ownerRelayDir: `${remoteHome}/.orca-remote/relay-0.1.0+abcdef012345`, + socketPath: `${authorityDir}/authority.sock`, + credentialFile: `${authorityDir}/endpoint.credential`, + compatibility: currentSshTerminalAuthorityGrant(), + ...rest + } +} diff --git a/src/main/ssh/ssh-terminal-authority-process-lifecycle.test.ts b/src/main/ssh/ssh-terminal-authority-process-lifecycle.test.ts new file mode 100644 index 00000000000..a8d6a8cc186 --- /dev/null +++ b/src/main/ssh/ssh-terminal-authority-process-lifecycle.test.ts @@ -0,0 +1,237 @@ +import { EventEmitter } from 'node:events' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { CURRENT_RELAY_DAEMON_COMPATIBILITY } from '../../shared/relay-daemon-compatibility' +import { SSH_TERMINAL_AUTHORITY_MARKER_VERSION } from '../../shared/ssh-terminal-authority-marker' +import type { MultiplexerTransport } from './ssh-channel-multiplexer' +import type { SshConnection } from './ssh-connection' +import { getRemoteHostPlatform } from './ssh-remote-platform' +import { execCommand, waitForSentinel } from './ssh-relay-deploy-helpers' +import { ensureRelayEndpointCredential } from './ssh-relay-endpoint-credential' +import type * as SshTerminalAuthorityDiscoveryModule from './ssh-terminal-authority-discovery' +import { + discoverSshTerminalAuthority, + proveSshTerminalAuthorityOwner, + type SshTerminalAuthorityDiscovery +} from './ssh-terminal-authority-discovery' +import { sshTerminalAuthorityEndpoint } from './ssh-terminal-authority-endpoint' +import { establishSshTerminalAuthority } from './ssh-terminal-authority-process' + +vi.mock('./ssh-relay-deploy-helpers', () => ({ + execCommand: vi.fn(), + waitForSentinel: vi.fn() +})) + +vi.mock('./ssh-relay-endpoint-credential', () => ({ + ensureRelayEndpointCredential: vi.fn() +})) + +vi.mock('./ssh-terminal-authority-discovery', async (importOriginal) => { + const original = await importOriginal() + return { + ...original, + discoverSshTerminalAuthority: vi.fn(), + proveSshTerminalAuthorityOwner: vi.fn() + } +}) + +const host = getRemoteHostPlatform('linux-x64') +const remoteHome = '/home/orca' +const relayDir = '/home/orca/.orca-remote/relay-2.0.0+abcdef' +const endpoint = sshTerminalAuthorityEndpoint(host, remoteHome) +const marker = { + markerVersion: SSH_TERMINAL_AUTHORITY_MARKER_VERSION, + authorityHostId: 'authority-host', + ownerInstanceId: 'owner-instance', + ownerPid: 42, + ownerProcessToken: 'owner-process-token', + ownerBuildId: '1.0.0+abcdef', + ownerRelayDir: '/home/orca/.orca-remote/relay-1.0.0+abcdef', + socketPath: endpoint.socketPath, + credentialFile: endpoint.credentialFile, + compatibility: CURRENT_RELAY_DAEMON_COMPATIBILITY, + revision: 4 +} as const + +function channel(options?: { closesWithoutClient?: boolean }) { + const stream = new EventEmitter() + const result = Object.assign(stream, { + stderr: new EventEmitter(), + close: vi.fn(() => queueMicrotask(() => stream.emit('close'))) + }) + if (options?.closesWithoutClient) { + setTimeout(() => stream.emit('close'), 0) + } + return result +} + +function transport(): MultiplexerTransport { + return { + write: vi.fn(), + onData: vi.fn(), + onClose: vi.fn(), + close: vi.fn() + } +} + +function connection(): SshConnection { + return { exec: vi.fn() } as unknown as SshConnection +} + +function options( + conn: SshConnection, + discovery: SshTerminalAuthorityDiscovery = { status: 'available', marker } +) { + return { + conn, + host, + remoteHome, + relayDir, + nodePath: '/usr/bin/node', + endpoint, + discovery, + graceTimeSeconds: 300 + } +} + +describe('SSH terminal authority process lifecycle', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(execCommand).mockResolvedValue('READY') + vi.mocked(ensureRelayEndpointCredential).mockResolvedValue() + }) + + it('connects a compatible owner without topology writes or launch probes', async () => { + const conn = connection() + const ownerChannel = channel() + const ownerTransport = transport() + vi.mocked(conn.exec).mockResolvedValue(ownerChannel as never) + vi.mocked(waitForSentinel).mockResolvedValue(ownerTransport) + + const result = await establishSshTerminalAuthority(options(conn)) + + expect(result.authorityHostId).toBe(marker.authorityHostId) + expect(result.ownerInstanceId).toBe(marker.ownerInstanceId) + expect(result.revision).toBe(marker.revision) + expect(result.ownerBuildId).toBe(marker.ownerBuildId) + expect(conn.exec).toHaveBeenCalledTimes(1) + expect(ownerChannel.close).toHaveBeenCalledOnce() + expect(execCommand).not.toHaveBeenCalled() + expect(ensureRelayEndpointCredential).not.toHaveBeenCalled() + }) + + it('confirms the owner launch channel closed before opening the readiness probe', async () => { + const conn = connection() + const launchChannel = channel() + const probeChannel = channel() + vi.mocked(conn.exec) + .mockResolvedValueOnce(launchChannel as never) + .mockResolvedValueOnce(probeChannel as never) + vi.mocked(discoverSshTerminalAuthority).mockResolvedValue({ status: 'available', marker }) + vi.mocked(waitForSentinel).mockResolvedValue(transport()) + + const establishing = establishSshTerminalAuthority(options(conn, { status: 'absent' })) + await vi.waitFor(() => expect(conn.exec).toHaveBeenCalledOnce()) + + expect(execCommand).toHaveBeenCalledOnce() + launchChannel.emit('close') + await establishing + + expect(execCommand).toHaveBeenCalledTimes(2) + expect(conn.exec).toHaveBeenCalledTimes(2) + }) + + it('does not take over a live owner whose endpoint temporarily refuses', async () => { + const conn = connection() + vi.mocked(conn.exec).mockResolvedValue(channel() as never) + vi.mocked(waitForSentinel).mockRejectedValue(new Error('endpoint refused')) + vi.mocked(discoverSshTerminalAuthority).mockResolvedValue({ + status: 'available', + marker + }) + vi.mocked(proveSshTerminalAuthorityOwner).mockResolvedValue('owner-alive') + + await expect(establishSshTerminalAuthority(options(conn))).rejects.toMatchObject({ + code: 'owner-still-alive' + }) + expect(conn.exec).toHaveBeenCalledTimes(1) + expect(ensureRelayEndpointCredential).not.toHaveBeenCalled() + }) + + it('does not take over when owner inspection fails', async () => { + const conn = connection() + vi.mocked(conn.exec).mockResolvedValue(channel() as never) + vi.mocked(waitForSentinel).mockRejectedValue(new Error('endpoint refused')) + vi.mocked(discoverSshTerminalAuthority).mockResolvedValue({ + status: 'available', + marker + }) + vi.mocked(proveSshTerminalAuthorityOwner).mockResolvedValue('inspection-failed') + + await expect(establishSshTerminalAuthority(options(conn))).rejects.toMatchObject({ + code: 'owner-proof-inconclusive' + }) + expect(conn.exec).toHaveBeenCalledTimes(1) + expect(ensureRelayEndpointCredential).not.toHaveBeenCalled() + }) + + it('checks the unchanged marker before probing process death', async () => { + const conn = connection() + vi.mocked(conn.exec).mockResolvedValue(channel() as never) + vi.mocked(waitForSentinel).mockRejectedValue(new Error('old endpoint refused')) + vi.mocked(discoverSshTerminalAuthority).mockResolvedValue({ + status: 'available', + marker: { ...marker, revision: marker.revision + 1 } + }) + + await expect(establishSshTerminalAuthority(options(conn))).rejects.toMatchObject({ + code: 'owner-state-changed' + }) + expect(proveSshTerminalAuthorityOwner).not.toHaveBeenCalled() + }) + + it('launches a replacement only after exact owner death and reconnects through its marker', async () => { + const conn = connection() + const refusedChannel = channel() + const launchChannel = channel({ closesWithoutClient: true }) + const replacementChannel = channel() + const replacementTransport = transport() + const replacementMarker = { + ...marker, + ownerInstanceId: 'replacement-instance', + ownerPid: 99, + ownerProcessToken: 'replacement-token', + ownerBuildId: '2.0.0+abcdef', + ownerRelayDir: relayDir, + revision: marker.revision + 1 + } + vi.mocked(conn.exec) + .mockResolvedValueOnce(refusedChannel as never) + .mockResolvedValueOnce(launchChannel as never) + .mockResolvedValueOnce(replacementChannel as never) + vi.mocked(waitForSentinel) + .mockRejectedValueOnce(new Error('owner exited')) + .mockResolvedValueOnce(replacementTransport) + vi.mocked(discoverSshTerminalAuthority) + .mockResolvedValueOnce({ status: 'available', marker }) + .mockResolvedValueOnce({ status: 'available', marker: replacementMarker }) + vi.mocked(proveSshTerminalAuthorityOwner).mockResolvedValue('owner-gone') + + const result = await establishSshTerminalAuthority(options(conn)) + + expect(result.ownerInstanceId).toBe(replacementMarker.ownerInstanceId) + expect(result.revision).toBe(replacementMarker.revision) + expect(ensureRelayEndpointCredential).toHaveBeenCalledOnce() + const launchCommand = vi.mocked(conn.exec).mock.calls[1]?.[0] ?? '' + expect(launchCommand).toContain("'--authority-takeover-token' 'owner-process-token'") + expect(launchCommand).toContain("'--authority-takeover-revision' '4'") + expect(launchChannel.close).not.toHaveBeenCalled() + }) + + it('fails closed instead of launching when bootstrap discovery is inconclusive', async () => { + const conn = connection() + await expect( + establishSshTerminalAuthority(options(conn, { status: 'inconclusive' })) + ).rejects.toMatchObject({ code: 'discovery-inconclusive' }) + expect(conn.exec).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ssh/ssh-terminal-authority-process.ts b/src/main/ssh/ssh-terminal-authority-process.ts new file mode 100644 index 00000000000..76e4805c2c7 --- /dev/null +++ b/src/main/ssh/ssh-terminal-authority-process.ts @@ -0,0 +1,292 @@ +import { randomBytes } from 'node:crypto' +import type { ClientChannel } from 'ssh2' +import type { SshConnection } from './ssh-connection' +import { createSshOperationAbortError } from './ssh-connection-utils' +import { waitForSentinel, execCommand } from './ssh-relay-deploy-helpers' +import { ensureRelayEndpointCredential } from './ssh-relay-endpoint-credential' +import { isWindowsRemoteHost, type RemoteHostPlatform } from './ssh-remote-platform' +import type { RelayDaemonCompatibilityGrant } from '../../shared/relay-daemon-compatibility' +import { + admitSshTerminalAuthority, + admitSshTerminalAuthorityTakeover, + assertSshTerminalAuthorityOwnerUnchanged, + type SshTerminalAuthorityTakeover +} from './ssh-terminal-authority-admission' +import { + discoverSshTerminalAuthority, + proveSshTerminalAuthorityOwner, + type SshTerminalAuthorityDiscovery +} from './ssh-terminal-authority-discovery' +import { + sshTerminalAuthorityStateDirectoryCommand, + type SshTerminalAuthorityEndpoint +} from './ssh-terminal-authority-endpoint' +import { + sshTerminalAuthorityConnectCommand, + sshTerminalAuthorityLaunchCommand, + sshTerminalAuthorityReadyCommand +} from './ssh-terminal-authority-process-commands' + +export { + sshTerminalAuthorityConnectCommand, + sshTerminalAuthorityLaunchCommand +} from './ssh-terminal-authority-process-commands' + +const AUTHORITY_PROBE_CLOSE_TIMEOUT_MS = 5_000 +const AUTHORITY_LAUNCH_CLOSE_TIMEOUT_MS = 5_000 + +export type SshTerminalAuthorityProcessOptions = Readonly<{ + conn: SshConnection + host: RemoteHostPlatform + remoteHome: string + relayDir: string + nodePath: string + endpoint: SshTerminalAuthorityEndpoint + discovery: SshTerminalAuthorityDiscovery + graceTimeSeconds: number + signal?: AbortSignal +}> + +export type SshTerminalAuthorityProcess = Readonly<{ + markerPath: string + authorityHostId: string + ownerInstanceId: string + revision: number + ownerBuildId: string + ownerRelayDir: string + socketPath: string + credentialFile: string + compatibility: RelayDaemonCompatibilityGrant +}> + +function closeAuthorityProbe(channel: ClientChannel, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + let settled = false + const finish = (error?: Error): void => { + if (settled) { + return + } + settled = true + clearTimeout(timer) + signal?.removeEventListener('abort', onAbort) + channel.removeListener('close', onClose) + if (error) { + reject(error) + } else { + resolve() + } + } + const onClose = (): void => finish() + const onAbort = (): void => { + channel.close() + finish(createSshOperationAbortError()) + } + const timer = setTimeout(() => { + channel.close() + finish(new Error('Terminal authority probe channel close was not confirmed')) + }, AUTHORITY_PROBE_CLOSE_TIMEOUT_MS) + timer.unref?.() + channel.once('close', onClose) + signal?.addEventListener('abort', onAbort, { once: true }) + if (signal?.aborted) { + onAbort() + return + } + channel.close() + }) +} + +function waitForAuthorityLaunchClose(channel: ClientChannel, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + let settled = false + const finish = (error?: Error): void => { + if (settled) { + return + } + settled = true + clearTimeout(timer) + signal?.removeEventListener('abort', onAbort) + channel.removeListener('close', onClose) + channel.removeListener('error', onError) + if (error) { + reject(error) + } else { + resolve() + } + } + const onClose = (): void => finish() + const onError = (error: Error): void => finish(error) + const onAbort = (): void => { + channel.close() + finish(createSshOperationAbortError()) + } + const timer = setTimeout(() => { + channel.close() + finish(new Error('Terminal authority launch channel close was not confirmed')) + }, AUTHORITY_LAUNCH_CLOSE_TIMEOUT_MS) + timer.unref?.() + channel.once('close', onClose) + channel.once('error', onError) + signal?.addEventListener('abort', onAbort, { once: true }) + if (signal?.aborted) { + onAbort() + } + }) +} + +async function probeOwner( + options: SshTerminalAuthorityProcessOptions, + admitted: Extract, { kind: 'connect-owner' }> +): Promise { + const { marker } = admitted + const channel = await options.conn.exec( + sshTerminalAuthorityConnectCommand({ + host: options.host, + nodePath: options.nodePath, + ownerRelayDir: marker.ownerRelayDir, + endpoint: options.endpoint, + expectedOwner: marker + }), + { wrapCommand: !isWindowsRemoteHost(options.host), signal: options.signal } + ) + try { + await waitForSentinel(channel, options.signal) + } catch (error) { + channel.close() + throw error + } + await closeAuthorityProbe(channel, options.signal) + return { + markerPath: options.endpoint.activeEndpointMarker, + authorityHostId: marker.authorityHostId, + ownerInstanceId: marker.ownerInstanceId, + revision: marker.revision, + ownerBuildId: marker.ownerBuildId, + ownerRelayDir: marker.ownerRelayDir, + socketPath: marker.socketPath, + credentialFile: marker.credentialFile, + compatibility: { + ...admitted.compatibility, + capabilities: [...admitted.compatibility.capabilities] + } + } +} + +async function launchOwner( + options: SshTerminalAuthorityProcessOptions, + takeover?: SshTerminalAuthorityTakeover +): Promise { + await execCommand( + options.conn, + sshTerminalAuthorityStateDirectoryCommand(options.host, options.endpoint.stateDir), + { wrapCommand: !isWindowsRemoteHost(options.host), signal: options.signal } + ) + await ensureRelayEndpointCredential( + options.conn, + options.host, + options.nodePath, + options.endpoint.credentialFile, + { signal: options.signal } + ) + const processToken = randomBytes(24).toString('base64url') + const launchChannel = await options.conn.exec( + sshTerminalAuthorityLaunchCommand({ + host: options.host, + nodePath: options.nodePath, + relayDir: options.relayDir, + endpoint: options.endpoint, + processToken, + graceTimeSeconds: options.graceTimeSeconds, + ...(takeover ? { takeover } : {}) + }), + { wrapCommand: !isWindowsRemoteHost(options.host), signal: options.signal } + ) + launchChannel.on('data', () => {}) + launchChannel.on('error', () => {}) + launchChannel.stderr.on('data', () => {}) + launchChannel.stderr.on('error', () => {}) + await waitForAuthorityLaunchClose(launchChannel, options.signal) + const readiness = await execCommand( + options.conn, + sshTerminalAuthorityReadyCommand({ + host: options.host, + nodePath: options.nodePath, + relayDir: options.relayDir, + socketPath: options.endpoint.socketPath + }), + { wrapCommand: !isWindowsRemoteHost(options.host), signal: options.signal } + ) + if (readiness.trim() !== 'READY') { + throw new Error('Terminal authority failed to publish a live endpoint') + } + const discovery = await discoverSshTerminalAuthority( + options.conn, + options.host, + options.endpoint.activeEndpointMarker, + { signal: options.signal } + ) + const admitted = admitSshTerminalAuthority( + discovery, + options.host, + options.remoteHome, + options.endpoint + ) + if (admitted.kind !== 'connect-owner') { + throw new Error('Terminal authority endpoint became live without an owner marker') + } + return probeOwner(options, admitted) +} + +export async function establishSshTerminalAuthority( + options: SshTerminalAuthorityProcessOptions +): Promise { + const admitted = admitSshTerminalAuthority( + options.discovery, + options.host, + options.remoteHome, + options.endpoint + ) + if (admitted.kind === 'launch-first-owner') { + return launchOwner(options) + } + try { + return await probeOwner(options, admitted) + } catch (connectError) { + options.signal?.throwIfAborted() + const rediscovery = await discoverSshTerminalAuthority( + options.conn, + options.host, + options.endpoint.activeEndpointMarker, + { signal: options.signal } + ) + assertSshTerminalAuthorityOwnerUnchanged({ + expectedMarker: admitted.marker, + rediscovery, + host: options.host, + remoteHome: options.remoteHome, + endpoint: options.endpoint + }) + const ownerProof = await proveSshTerminalAuthorityOwner( + options.conn, + options.host, + admitted.marker, + { signal: options.signal } + ) + const takeover = admitSshTerminalAuthorityTakeover({ + expectedMarker: admitted.marker, + rediscovery, + ownerProof, + host: options.host, + remoteHome: options.remoteHome, + endpoint: options.endpoint + }) + try { + return await launchOwner(options, takeover) + } catch (takeoverError) { + throw new AggregateError( + [connectError, takeoverError], + 'Terminal authority reconnect and exact takeover both failed' + ) + } + } +} diff --git a/src/main/ssh/ssh-terminal-authority-topology-client-contract.ts b/src/main/ssh/ssh-terminal-authority-topology-client-contract.ts new file mode 100644 index 00000000000..1b7bad071b9 --- /dev/null +++ b/src/main/ssh/ssh-terminal-authority-topology-client-contract.ts @@ -0,0 +1,44 @@ +import type { TerminalAuthorityNamespace } from '../../shared/terminal-session-authority-identity' +import type { + TerminalAuthorityTopologyCapabilityGrant, + TerminalAuthorityTopologySnapshot +} from '../../shared/terminal-authority-topology-stream-contract' +import type { SshTerminalAuthorityTopologyResnapshotReason } from './ssh-terminal-authority-topology-reducer' + +export type SshTerminalAuthorityTopologyTransport = Readonly<{ + onNotificationByMethod: ( + method: string, + handler: (params: Record) => void + ) => () => void + request: ( + method: string, + params: Record, + options: Readonly<{ signal: AbortSignal }> + ) => Promise + notify: (method: string, params: Record) => boolean +}> + +export type SshTerminalAuthorityTopologyClientStatus = + | Readonly<{ kind: 'idle' }> + | Readonly<{ + kind: 'synchronizing' + reason: 'initial' | 'manual' | SshTerminalAuthorityTopologyResnapshotReason + }> + | Readonly<{ + kind: 'synchronized' + streamIncarnationId: string + authorityRevision: number + appliedChangeSequence: number + }> + | Readonly<{ kind: 'stale'; error: Error }> + | Readonly<{ kind: 'disposed' }> + +export type SshTerminalAuthorityTopologyClientOptions = Readonly<{ + transport: SshTerminalAuthorityTopologyTransport + capabilityGrant: TerminalAuthorityTopologyCapabilityGrant + subscriptionId: string + namespace: TerminalAuthorityNamespace + onStatusChange?: (status: SshTerminalAuthorityTopologyClientStatus) => void + onAuthoritativeState?: (state: TerminalAuthorityTopologySnapshot) => void + onSynchronizationError?: (error: Error) => void +}> diff --git a/src/main/ssh/ssh-terminal-authority-topology-client.test.ts b/src/main/ssh/ssh-terminal-authority-topology-client.test.ts new file mode 100644 index 00000000000..19ef96f8f14 --- /dev/null +++ b/src/main/ssh/ssh-terminal-authority-topology-client.test.ts @@ -0,0 +1,489 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { TerminalAuthorityTopologySnapshot } from '../../shared/terminal-authority-topology-stream-contract' +import { + SSH_TERMINAL_AUTHORITY_TOPOLOGY_MAX_REQUESTS_PER_SYNCHRONIZATION, + SshTerminalAuthorityTopologyClient +} from './ssh-terminal-authority-topology-client' +import type { SshTerminalAuthorityTopologyTransport } from './ssh-terminal-authority-topology-client-contract' + +const namespace = Object.freeze({ authorityHostId: 'host-a', namespaceId: 'namespace-a' }) + +type PendingRequest = Readonly<{ + method: string + params: Record + resolve: (value: unknown) => void + reject: (error: Error) => void + cleanup: () => void +}> + +class FakeTopologyTransport implements SshTerminalAuthorityTopologyTransport { + readonly events: string[] = [] + readonly pending: PendingRequest[] = [] + readonly notifications: { method: string; params: Record }[] = [] + private notificationHandler: ((params: Record) => void) | null = null + + onNotificationByMethod( + method: string, + handler: (params: Record) => void + ): () => void { + this.events.push(`listen:${method}`) + this.notificationHandler = handler + return () => { + if (this.notificationHandler === handler) { + this.notificationHandler = null + } + } + } + + request( + method: string, + params: Record, + options: Readonly<{ signal: AbortSignal }> + ): Promise { + this.events.push(`request:${method}`) + return new Promise((resolve, reject) => { + const onAbort = (): void => { + const index = this.pending.indexOf(pending) + if (index >= 0) { + this.pending.splice(index, 1) + } + reject(Object.assign(new Error('cancelled'), { name: 'AbortError' })) + } + const pending: PendingRequest = { + method, + params, + resolve, + reject, + cleanup: () => options.signal.removeEventListener('abort', onAbort) + } + options.signal.addEventListener('abort', onAbort, { once: true }) + this.pending.push(pending) + }) + } + + notify(method: string, params: Record): boolean { + this.events.push(`notify:${method}`) + this.notifications.push({ method, params }) + return true + } + + emit(params: Record): void { + this.notificationHandler?.(params) + } + + resolveNext(value: unknown, beforeResolution?: () => void): void { + const request = this.pending.shift() + if (!request) { + throw new Error('No pending topology request') + } + request.cleanup() + beforeResolution?.() + request.resolve(value) + } + + rejectNext(error: Error): void { + const request = this.pending.shift() + if (!request) { + throw new Error('No pending topology request') + } + request.cleanup() + request.reject(error) + } + + hasNotificationHandler(): boolean { + return this.notificationHandler !== null + } +} + +function snapshot(overrides: Record = {}): Record { + return { + protocolVersion: 1, + subscriptionId: 'subscription-a', + streamIncarnationId: 'stream-a', + namespace, + writerEpoch: 1, + authorityRevision: 0, + appliedChangeSequence: 0, + panes: [], + namespaceRecoveryNotices: { version: 1, revision: 0, notices: [] }, + ...overrides + } +} + +function change( + sequence: number, + overrides: Record = {} +): Record { + return { + protocolVersion: 1, + subscriptionId: 'subscription-a', + streamIncarnationId: 'stream-a', + namespace, + writerEpoch: 1, + baseAuthorityRevision: 0, + authorityRevision: 0, + changeSequence: sequence, + paneChanges: [], + namespaceRecoveryNotices: { version: 1, revision: sequence, notices: [] }, + ...overrides + } +} + +function createClient( + transport: FakeTopologyTransport, + overrides: Partial[0]> = {} +): SshTerminalAuthorityTopologyClient { + return new SshTerminalAuthorityTopologyClient({ + transport, + capabilityGrant: { version: 1 }, + subscriptionId: 'subscription-a', + namespace, + ...overrides + }) +} + +async function settle(): Promise { + await new Promise((resolve) => setImmediate(resolve)) +} + +afterEach(() => { + vi.useRealTimers() +}) + +describe('SSH terminal authority topology client startup', () => { + it('installs its notification handler before issuing the initial snapshot request', async () => { + const transport = new FakeTopologyTransport() + const client = createClient(transport) + const started = client.start() + + expect(transport.events).toEqual([ + 'listen:terminalAuthority.topologyChanged', + 'request:terminalAuthority.topologySnapshot' + ]) + expect(transport.pending[0]?.params).toEqual({ + protocolVersion: 1, + subscriptionId: 'subscription-a', + namespace + }) + transport.resolveNext(snapshot()) + + await expect(started).resolves.toMatchObject({ appliedChangeSequence: 0 }) + expect(client.status.kind).toBe('synchronized') + }) + + it('buffers and applies a contiguous change published before the snapshot response', async () => { + const transport = new FakeTopologyTransport() + const client = createClient(transport) + const started = client.start() + transport.emit(change(1)) + transport.resolveNext(snapshot()) + + await expect(started).resolves.toMatchObject({ + appliedChangeSequence: 1, + namespaceRecoveryNotices: { revision: 1 } + }) + expect(transport.pending).toHaveLength(0) + }) + + it('requires an explicit v1 grant before installing any wire behavior', () => { + const transport = new FakeTopologyTransport() + expect( + () => + new SshTerminalAuthorityTopologyClient({ + transport, + capabilityGrant: { version: 2 } as never, + subscriptionId: 'subscription-a', + namespace + }) + ).toThrow('capability_not_granted') + expect(transport.events).toEqual([]) + }) + + it('adds no deadline, polling, or liveness timer while awaiting authority', async () => { + vi.useFakeTimers() + const transport = new FakeTopologyTransport() + const client = createClient(transport) + const started = client.start() + const cancelled = expect(started).rejects.toThrow('cancelled') + + expect(vi.getTimerCount()).toBe(0) + client.dispose() + await cancelled + }) +}) + +describe('SSH terminal authority topology client resnapshot', () => { + it('makes a gap non-authoritative until an exact replacement snapshot arrives', async () => { + const transport = new FakeTopologyTransport() + const states: TerminalAuthorityTopologySnapshot[] = [] + const client = createClient(transport, { onAuthoritativeState: (state) => states.push(state) }) + const started = client.start() + transport.resolveNext(snapshot()) + await started + + transport.emit(change(2)) + + expect(client.status).toMatchObject({ kind: 'synchronizing', reason: 'sequence-gap' }) + expect(client.authoritativeState()).toBeNull() + expect(client.lastKnownState()?.appliedChangeSequence).toBe(0) + expect(transport.pending).toHaveLength(1) + transport.resolveNext( + snapshot({ + authorityRevision: 2, + appliedChangeSequence: 2, + namespaceRecoveryNotices: { version: 1, revision: 2, notices: [] } + }) + ) + await settle() + + expect(client.authoritativeState()).toMatchObject({ + authorityRevision: 2, + appliedChangeSequence: 2 + }) + expect(states).toHaveLength(2) + }) + + it('resnapshots a changed stream incarnation instead of classifying terminals dead', async () => { + const transport = new FakeTopologyTransport() + const client = createClient(transport) + const started = client.start() + transport.resolveNext(snapshot()) + await started + + transport.emit(change(1, { streamIncarnationId: 'stream-b' })) + + expect(client.authoritativeState()).toBeNull() + expect(client.lastKnownState()?.streamIncarnationId).toBe('stream-a') + expect(transport.pending).toHaveLength(1) + transport.resolveNext(snapshot({ streamIncarnationId: 'stream-b' })) + await settle() + expect(client.authoritativeState()?.streamIncarnationId).toBe('stream-b') + }) + + it('does not authorize a regressed snapshot from the current stream', async () => { + const transport = new FakeTopologyTransport() + const client = createClient(transport) + const started = client.start() + transport.resolveNext( + snapshot({ + authorityRevision: 2, + appliedChangeSequence: 2, + namespaceRecoveryNotices: { version: 1, revision: 2, notices: [] } + }) + ) + await started + const resnapshot = client.resnapshot() + transport.resolveNext( + snapshot({ + authorityRevision: 1, + appliedChangeSequence: 1, + namespaceRecoveryNotices: { version: 1, revision: 1, notices: [] } + }) + ) + await settle() + + expect(client.authoritativeState()).toBeNull() + expect(client.lastKnownState()).toMatchObject({ + authorityRevision: 2, + appliedChangeSequence: 2 + }) + expect(transport.pending).toHaveLength(1) + transport.resolveNext( + snapshot({ + authorityRevision: 2, + appliedChangeSequence: 2, + namespaceRecoveryNotices: { version: 1, revision: 2, notices: [] } + }) + ) + await expect(resnapshot).resolves.toMatchObject({ authorityRevision: 2 }) + }) + + it('resnapshots an ambiguously ordered different-stream notification', async () => { + const transport = new FakeTopologyTransport() + const client = createClient(transport) + const started = client.start() + transport.resolveNext(snapshot()) + await started + const resnapshot = client.resnapshot() + + transport.emit(change(1, { streamIncarnationId: 'stream-a' })) + transport.resolveNext(snapshot({ streamIncarnationId: 'stream-b' })) + await settle() + + expect(transport.pending).toHaveLength(1) + transport.resolveNext(snapshot({ streamIncarnationId: 'stream-b' })) + await expect(resnapshot).resolves.toMatchObject({ streamIncarnationId: 'stream-b' }) + }) + + it('discards a different stream only when a higher writer epoch fences it', async () => { + const transport = new FakeTopologyTransport() + const client = createClient(transport) + const started = client.start() + transport.resolveNext(snapshot({ writerEpoch: 2 })) + await started + const resnapshot = client.resnapshot() + + transport.emit(change(1, { streamIncarnationId: 'old-stream', writerEpoch: 1 })) + transport.resolveNext(snapshot({ streamIncarnationId: 'stream-b', writerEpoch: 2 })) + + await expect(resnapshot).resolves.toMatchObject({ streamIncarnationId: 'stream-b' }) + expect(transport.pending).toHaveLength(0) + }) + + it('discards a live different-stream notification fenced by an older writer epoch', async () => { + const transport = new FakeTopologyTransport() + const client = createClient(transport) + const started = client.start() + transport.resolveNext(snapshot({ writerEpoch: 2 })) + await started + + transport.emit(change(1, { streamIncarnationId: 'old-stream', writerEpoch: 1 })) + + expect(client.status.kind).toBe('synchronized') + expect(client.authoritativeState()).toMatchObject({ + streamIncarnationId: 'stream-a', + writerEpoch: 2, + appliedChangeSequence: 0 + }) + expect(transport.pending).toHaveLength(0) + }) + + it('resnapshots a changed stream received while snapshot settlement is pending', async () => { + const transport = new FakeTopologyTransport() + const client = createClient(transport) + const started = client.start() + transport.resolveNext(snapshot()) + await started + const resnapshot = client.resnapshot() + + transport.resolveNext(snapshot({ streamIncarnationId: 'stream-b' }), () => { + transport.emit(change(1, { streamIncarnationId: 'stream-c' })) + }) + await settle() + expect(transport.pending).toHaveLength(1) + transport.resolveNext(snapshot({ streamIncarnationId: 'stream-c' })) + + await expect(resnapshot).resolves.toMatchObject({ streamIncarnationId: 'stream-c' }) + }) + + it('resnapshots malformed relevant notifications without applying them', async () => { + const transport = new FakeTopologyTransport() + const client = createClient(transport) + const started = client.start() + transport.resolveNext(snapshot()) + await started + + transport.emit({ subscriptionId: 'subscription-a', protocolVersion: 99 }) + + expect(client.authoritativeState()).toBeNull() + expect(transport.pending).toHaveLength(1) + transport.resolveNext(snapshot()) + await settle() + expect(client.authoritativeState()?.appliedChangeSequence).toBe(0) + }) + + it('bounds snapshot-settlement buffering and resolves it only through another snapshot', async () => { + const transport = new FakeTopologyTransport() + const client = createClient(transport) + const started = client.start() + transport.resolveNext(snapshot(), () => { + for (let sequence = 1; sequence <= 257; sequence += 1) { + transport.emit(change(sequence)) + } + }) + await settle() + + expect(transport.pending).toHaveLength(1) + transport.resolveNext( + snapshot({ + appliedChangeSequence: 257, + namespaceRecoveryNotices: { version: 1, revision: 257, notices: [] } + }) + ) + await expect(started).resolves.toMatchObject({ appliedChangeSequence: 257 }) + }) + + it('stops requesting after bounded continuous snapshot-settlement churn', async () => { + const transport = new FakeTopologyTransport() + const client = createClient(transport) + const started = client.start() + const capacityFailure = expect(started).rejects.toThrow('resnapshot_attempt_capacity') + + for ( + let request = 0; + request < SSH_TERMINAL_AUTHORITY_TOPOLOGY_MAX_REQUESTS_PER_SYNCHRONIZATION; + request += 1 + ) { + transport.resolveNext(snapshot({ streamIncarnationId: `snapshot-stream-${request}` }), () => { + transport.emit(change(1, { streamIncarnationId: `later-stream-${request}` })) + }) + await settle() + } + + await capacityFailure + expect(client.status).toMatchObject({ kind: 'stale' }) + expect( + transport.events.filter((event) => event === 'request:terminalAuthority.topologySnapshot') + ).toHaveLength(SSH_TERMINAL_AUTHORITY_TOPOLOGY_MAX_REQUESTS_PER_SYNCHRONIZATION) + expect(transport.pending).toHaveLength(0) + + const explicitRetry = client.resnapshot() + transport.resolveNext(snapshot({ streamIncarnationId: 'stable-stream' })) + await expect(explicitRetry).resolves.toMatchObject({ streamIncarnationId: 'stable-stream' }) + }) + + it('retains but does not authorize the last snapshot after resnapshot failure', async () => { + const transport = new FakeTopologyTransport() + const errors: Error[] = [] + const client = createClient(transport, { + onSynchronizationError: (error) => errors.push(error) + }) + const started = client.start() + transport.resolveNext(snapshot()) + await started + + transport.emit(change(2)) + transport.rejectNext(new Error('connection unavailable')) + await settle() + + expect(client.status).toMatchObject({ kind: 'stale' }) + expect(client.authoritativeState()).toBeNull() + expect(client.lastKnownState()?.appliedChangeSequence).toBe(0) + expect(errors).toHaveLength(1) + expect(transport.pending).toHaveLength(0) + }) + + it('unsubscribes the stable server subscription and local handler on detach', async () => { + const transport = new FakeTopologyTransport() + const client = createClient(transport) + const started = client.start() + transport.resolveNext(snapshot()) + await started + + client.dispose() + transport.emit(change(2)) + + expect(transport.notifications).toEqual([ + { + method: 'terminalAuthority.topologyUnsubscribe', + params: { protocolVersion: 1, subscriptionId: 'subscription-a', namespace } + } + ]) + expect(transport.hasNotificationHandler()).toBe(false) + expect(transport.pending).toHaveLength(0) + }) + + it('cancels an in-flight snapshot and still unsubscribes on detach', async () => { + const transport = new FakeTopologyTransport() + const client = createClient(transport) + const started = client.start() + const cancelled = expect(started).rejects.toThrow('cancelled') + + client.dispose() + + await cancelled + expect(transport.pending).toHaveLength(0) + expect(transport.hasNotificationHandler()).toBe(false) + expect(transport.notifications[0]?.method).toBe('terminalAuthority.topologyUnsubscribe') + }) +}) diff --git a/src/main/ssh/ssh-terminal-authority-topology-client.ts b/src/main/ssh/ssh-terminal-authority-topology-client.ts new file mode 100644 index 00000000000..b8f5218cba7 --- /dev/null +++ b/src/main/ssh/ssh-terminal-authority-topology-client.ts @@ -0,0 +1,306 @@ +import { + TERMINAL_AUTHORITY_TOPOLOGY_CHANGED_NOTIFICATION, + TERMINAL_AUTHORITY_TOPOLOGY_SNAPSHOT_METHOD, + TERMINAL_AUTHORITY_TOPOLOGY_UNSUBSCRIBE_NOTIFICATION, + assertTerminalAuthorityTopologyCapabilityGrant, + type TerminalAuthorityTopologySnapshot, + type TerminalAuthorityTopologySnapshotRequest +} from '../../shared/terminal-authority-topology-stream-contract' +import { + parseTerminalAuthorityTopologyChangeWithByteLength, + parseTerminalAuthorityTopologySnapshot, + parseTerminalAuthorityTopologySnapshotRequest, + sameTerminalAuthorityTopologyNamespace +} from '../../shared/terminal-authority-topology-stream-validation' +import { + SshTerminalAuthorityTopologyReducer, + type SshTerminalAuthorityTopologyResnapshotReason +} from './ssh-terminal-authority-topology-reducer' +import type { + SshTerminalAuthorityTopologyClientOptions, + SshTerminalAuthorityTopologyClientStatus +} from './ssh-terminal-authority-topology-client-contract' +import { + SshTerminalAuthorityTopologyNotificationBuffer, + replaySshTerminalAuthorityTopologyNotifications, + type SshTerminalAuthorityTopologyBufferedNotification +} from './ssh-terminal-authority-topology-notification-buffer' + +export const SSH_TERMINAL_AUTHORITY_TOPOLOGY_MAX_REQUESTS_PER_SYNCHRONIZATION = 3 + +export type { + SshTerminalAuthorityTopologyClientOptions, + SshTerminalAuthorityTopologyClientStatus, + SshTerminalAuthorityTopologyTransport +} from './ssh-terminal-authority-topology-client-contract' + +function toError(value: unknown): Error { + return value instanceof Error ? value : new Error(String(value)) +} + +export class SshTerminalAuthorityTopologyClient { + private readonly reducer = new SshTerminalAuthorityTopologyReducer() + private readonly requestValue: TerminalAuthorityTopologySnapshotRequest + private statusValue: SshTerminalAuthorityTopologyClientStatus = Object.freeze({ kind: 'idle' }) + private unsubscribeNotification: (() => void) | null = null + private synchronization: Promise | null = null + private requestAbortController: AbortController | null = null + private readonly notificationBuffer = new SshTerminalAuthorityTopologyNotificationBuffer() + private notificationOrdinal = 0 + private awaitingSnapshot = false + private started = false + private disposed = false + + constructor(private readonly options: SshTerminalAuthorityTopologyClientOptions) { + assertTerminalAuthorityTopologyCapabilityGrant(options.capabilityGrant) + this.requestValue = parseTerminalAuthorityTopologySnapshotRequest({ + protocolVersion: 1, + subscriptionId: options.subscriptionId, + namespace: options.namespace + }) + } + + get status(): SshTerminalAuthorityTopologyClientStatus { + return this.statusValue + } + + authoritativeState(): TerminalAuthorityTopologySnapshot | null { + return this.statusValue.kind === 'synchronized' ? this.reducer.state() : null + } + + lastKnownState(): TerminalAuthorityTopologySnapshot | null { + return this.reducer.state() + } + + async start(): Promise { + if (this.started) { + throw new Error('terminal_authority_topology_client_already_started') + } + if (this.disposed) { + throw new Error('terminal_authority_topology_client_disposed') + } + this.started = true + this.unsubscribeNotification = this.options.transport.onNotificationByMethod( + TERMINAL_AUTHORITY_TOPOLOGY_CHANGED_NOTIFICATION, + (params) => this.handleNotification(params) + ) + await this.synchronize('initial') + const state = this.authoritativeState() + if (!state) { + throw new Error('terminal_authority_topology_snapshot_unavailable') + } + return state + } + + async resnapshot(): Promise { + if (!this.started || this.disposed) { + throw new Error('terminal_authority_topology_client_not_active') + } + await this.synchronize('manual') + const state = this.authoritativeState() + if (!state) { + throw new Error('terminal_authority_topology_snapshot_unavailable') + } + return state + } + + dispose(): void { + if (this.disposed) { + return + } + this.disposed = true + this.awaitingSnapshot = false + this.requestAbortController?.abort() + this.notificationBuffer.clear() + if (this.started) { + this.options.transport.notify(TERMINAL_AUTHORITY_TOPOLOGY_UNSUBSCRIBE_NOTIFICATION, { + protocolVersion: this.requestValue.protocolVersion, + subscriptionId: this.requestValue.subscriptionId, + namespace: this.requestValue.namespace + }) + } + this.unsubscribeNotification?.() + this.unsubscribeNotification = null + this.publishStatus(Object.freeze({ kind: 'disposed' })) + } + + private synchronize( + reason: 'initial' | 'manual' | SshTerminalAuthorityTopologyResnapshotReason + ): Promise { + if (this.synchronization) { + return this.synchronization + } + this.publishStatus(Object.freeze({ kind: 'synchronizing', reason })) + let running: Promise + running = this.synchronizeUntilExact().finally(() => { + if (this.synchronization === running) { + this.synchronization = null + } + }) + this.synchronization = running + return running + } + + private async synchronizeUntilExact(): Promise { + try { + for ( + let request = 0; + request < SSH_TERMINAL_AUTHORITY_TOPOLOGY_MAX_REQUESTS_PER_SYNCHRONIZATION; + request += 1 + ) { + const retryReason = await this.requestExactSnapshot() + if (!retryReason) { + if (this.disposed) { + throw new Error('terminal_authority_topology_client_disposed') + } + return + } + if (!this.disposed) { + this.publishStatus(Object.freeze({ kind: 'synchronizing', reason: retryReason })) + } + } + throw new Error('terminal_authority_topology_resnapshot_attempt_capacity') + } catch (error) { + const normalized = toError(error) + if (!this.disposed) { + this.publishStatus(Object.freeze({ kind: 'stale', error: normalized })) + } + throw normalized + } + } + + private async requestExactSnapshot(): Promise { + this.notificationBuffer.clear() + this.awaitingSnapshot = true + const abortController = new AbortController() + this.requestAbortController = abortController + let response: unknown + try { + response = await this.options.transport.request( + TERMINAL_AUTHORITY_TOPOLOGY_SNAPSHOT_METHOD, + { + protocolVersion: this.requestValue.protocolVersion, + subscriptionId: this.requestValue.subscriptionId, + namespace: this.requestValue.namespace + }, + { signal: abortController.signal } + ) + } finally { + if (this.requestAbortController === abortController) { + this.requestAbortController = null + } + } + if (this.disposed) { + throw new Error('terminal_authority_topology_client_disposed') + } + const snapshot = parseTerminalAuthorityTopologySnapshot(response) + this.assertExpectedSnapshot(snapshot) + const snapshotConflict = this.reducer.snapshotConflict(snapshot) + if (snapshotConflict) { + return snapshotConflict + } + const buffered = this.notificationBuffer.take() + this.reducer.replace(snapshot) + const retryReason = replaySshTerminalAuthorityTopologyNotifications({ + reducer: this.reducer, + snapshot, + notifications: buffered.notifications, + overflowOrdinal: buffered.overflowOrdinal + }) + if (retryReason) { + return retryReason + } + this.awaitingSnapshot = false + const state = this.reducer.state()! + this.publishStatus( + Object.freeze({ + kind: 'synchronized', + streamIncarnationId: state.streamIncarnationId, + authorityRevision: state.authorityRevision, + appliedChangeSequence: state.appliedChangeSequence + }) + ) + this.options.onAuthoritativeState?.(state) + return null + } + + private handleNotification(params: Record): void { + if (this.disposed || params.subscriptionId !== this.requestValue.subscriptionId) { + return + } + const ordinal = ++this.notificationOrdinal + let notification: SshTerminalAuthorityTopologyBufferedNotification + try { + const parsed = parseTerminalAuthorityTopologyChangeWithByteLength(params) + notification = sameTerminalAuthorityTopologyNamespace( + parsed.value.namespace, + this.requestValue.namespace + ) + ? Object.freeze({ + kind: 'change', + ordinal, + byteLength: parsed.byteLength, + change: parsed.value + }) + : Object.freeze({ kind: 'invalid', ordinal, byteLength: 0 }) + } catch { + notification = Object.freeze({ kind: 'invalid', ordinal, byteLength: 0 }) + } + if (this.awaitingSnapshot) { + this.notificationBuffer.push(notification) + return + } + if (this.statusValue.kind !== 'synchronized' || notification.kind === 'invalid') { + if (this.statusValue.kind === 'synchronized') { + this.requestResnapshot('notification-invalid') + } + return + } + const current = this.reducer.state() + if ( + current && + notification.change.streamIncarnationId !== current.streamIncarnationId && + notification.change.writerEpoch < current.writerEpoch + ) { + return + } + const result = this.reducer.apply(notification.change) + if (result.kind === 'applied') { + this.publishStatus( + Object.freeze({ + kind: 'synchronized', + streamIncarnationId: result.state.streamIncarnationId, + authorityRevision: result.state.authorityRevision, + appliedChangeSequence: result.state.appliedChangeSequence + }) + ) + this.options.onAuthoritativeState?.(result.state) + } else if (result.kind === 'resnapshot-required') { + this.requestResnapshot(result.reason) + } + } + + private requestResnapshot(reason: SshTerminalAuthorityTopologyResnapshotReason): void { + void this.synchronize(reason).catch((error) => { + try { + this.options.onSynchronizationError?.(toError(error)) + } catch { + // A reporting callback cannot restore authority synchronization. + } + }) + } + + private assertExpectedSnapshot(snapshot: TerminalAuthorityTopologySnapshot): void { + if ( + snapshot.subscriptionId !== this.requestValue.subscriptionId || + !sameTerminalAuthorityTopologyNamespace(snapshot.namespace, this.requestValue.namespace) + ) { + throw new Error('terminal_authority_topology_snapshot_identity_mismatch') + } + } + + private publishStatus(status: SshTerminalAuthorityTopologyClientStatus): void { + this.statusValue = status + this.options.onStatusChange?.(status) + } +} diff --git a/src/main/ssh/ssh-terminal-authority-topology-coordinator.test.ts b/src/main/ssh/ssh-terminal-authority-topology-coordinator.test.ts new file mode 100644 index 00000000000..589f2adc425 --- /dev/null +++ b/src/main/ssh/ssh-terminal-authority-topology-coordinator.test.ts @@ -0,0 +1,479 @@ +import { describe, expect, it, vi } from 'vitest' +import type { TerminalAuthorityNamespace } from '../../shared/terminal-session-authority-identity' +import type { SshChannelMultiplexer } from './ssh-channel-multiplexer' +import { + SshTerminalAuthorityTopologyCoordinator, + type SshTerminalAuthorityNamespaceTopologyState +} from './ssh-terminal-authority-topology-coordinator' + +type PendingRequest = { + params: Record + resolve: (value: unknown) => void + reject: (error: Error) => void + cleanup: () => void +} + +class FakeTopologyTransport { + readonly events: string[] = [] + readonly pending: PendingRequest[] = [] + readonly notifications: { method: string; params: Record }[] = [] + private readonly handlers = new Set<(params: Record) => void>() + + onNotificationByMethod( + method: string, + handler: (params: Record) => void + ): () => void { + this.events.push(`listen:${method}`) + this.handlers.add(handler) + return () => this.handlers.delete(handler) + } + + request( + method: string, + params: Record, + options: Readonly<{ signal: AbortSignal }> + ): Promise { + this.events.push(`request:${method}`) + return new Promise((resolve, reject) => { + const onAbort = (): void => { + const index = this.pending.indexOf(pending) + if (index >= 0) { + this.pending.splice(index, 1) + } + reject(Object.assign(new Error('cancelled'), { name: 'AbortError' })) + } + const pending: PendingRequest = { + params, + resolve, + reject, + cleanup: () => options.signal.removeEventListener('abort', onAbort) + } + options.signal.addEventListener('abort', onAbort, { once: true }) + this.pending.push(pending) + }) + } + + notify(method: string, params: Record): boolean { + this.notifications.push({ method, params }) + return true + } + + emit(params: Record): void { + for (const handler of Array.from(this.handlers)) { + handler(params) + } + } + + resolveAt(index: number, overrides: Record = {}): void { + const pending = this.pending.splice(index, 1)[0] + if (!pending) { + throw new Error('No pending topology request') + } + pending.cleanup() + const namespace = pending.params.namespace as TerminalAuthorityNamespace + pending.resolve({ + protocolVersion: 1, + subscriptionId: pending.params.subscriptionId, + streamIncarnationId: `stream-${namespace.namespaceId}`, + namespace, + writerEpoch: 1, + authorityRevision: 0, + appliedChangeSequence: 0, + panes: [], + namespaceRecoveryNotices: { version: 1, revision: 0, notices: [] }, + ...overrides + }) + } + + rejectAt(index: number, error: Error): void { + const pending = this.pending.splice(index, 1)[0] + if (!pending) { + throw new Error('No pending topology request') + } + pending.cleanup() + pending.reject(error) + } + + handlerCount(): number { + return this.handlers.size + } +} + +const grant = Object.freeze({ version: 1 as const }) +const namespaceA = Object.freeze({ authorityHostId: 'host-a', namespaceId: 'namespace-a' }) +const namespaceB = Object.freeze({ authorityHostId: 'host-a', namespaceId: 'namespace-b' }) + +function asMux(transport: FakeTopologyTransport): SshChannelMultiplexer { + return transport as unknown as SshChannelMultiplexer +} + +function change( + request: Pick, + sequence: number, + overrides: Record = {} +): Record { + const namespace = request.params.namespace as TerminalAuthorityNamespace + return { + protocolVersion: 1, + subscriptionId: request.params.subscriptionId, + streamIncarnationId: `stream-${namespace.namespaceId}`, + namespace, + writerEpoch: 1, + baseAuthorityRevision: 0, + authorityRevision: 0, + changeSequence: sequence, + paneChanges: [], + namespaceRecoveryNotices: { version: 1, revision: sequence, notices: [] }, + ...overrides + } +} + +async function settle(): Promise { + await new Promise((resolve) => setImmediate(resolve)) +} + +describe('SshTerminalAuthorityTopologyCoordinator', () => { + it('keeps old-host behavior when the optional capability is not granted', async () => { + const coordinator = new SshTerminalAuthorityTopologyCoordinator() + const transport = new FakeTopologyTransport() + const states: SshTerminalAuthorityNamespaceTopologyState[] = [] + coordinator.attachResolvedNamespace(namespaceA, (state) => states.push(state)) + + await coordinator.onTopologyCapability(asMux(transport), null) + + expect(states.map((state) => state.kind)).toEqual(['disconnected', 'legacy-fallback']) + expect(transport.events).toEqual([]) + coordinator.dispose() + }) + + it('starts a namespace attached after capability admission', async () => { + const coordinator = new SshTerminalAuthorityTopologyCoordinator() + const transport = new FakeTopologyTransport() + const states: SshTerminalAuthorityNamespaceTopologyState[] = [] + await coordinator.onTopologyCapability(asMux(transport), grant) + + coordinator.attachResolvedNamespace(namespaceA, (state) => states.push(state)) + + expect(transport.events).toEqual([ + 'listen:terminalAuthority.topologyChanged', + 'request:terminalAuthority.topologySnapshot' + ]) + expect(states.at(-1)?.kind).toBe('synchronizing') + transport.resolveAt(0) + await settle() + expect(states.at(-1)?.kind).toBe('authoritative') + coordinator.dispose() + }) + + it('deduplicates a namespace and keeps its subscription until the final observer detaches', async () => { + const coordinator = new SshTerminalAuthorityTopologyCoordinator() + const transport = new FakeTopologyTransport() + const first: SshTerminalAuthorityNamespaceTopologyState[] = [] + const second: SshTerminalAuthorityNamespaceTopologyState[] = [] + const detachFirst = coordinator.attachResolvedNamespace(namespaceA, (state) => + first.push(state) + ) + const detachSecond = coordinator.attachResolvedNamespace(namespaceA, (state) => + second.push(state) + ) + const synchronized = coordinator.onTopologyCapability(asMux(transport), grant) + + expect(transport.events).toEqual([ + 'listen:terminalAuthority.topologyChanged', + 'request:terminalAuthority.topologySnapshot' + ]) + transport.resolveAt(0) + await synchronized + expect(first.at(-1)?.kind).toBe('authoritative') + expect(second.at(-1)?.kind).toBe('authoritative') + + detachFirst() + expect(transport.notifications).toHaveLength(0) + const secondCount = second.length + const request = { + params: { + subscriptionId: ( + second.at(-1) as Extract< + SshTerminalAuthorityNamespaceTopologyState, + { kind: 'authoritative' } + > + ).snapshot.subscriptionId, + namespace: namespaceA + } + } + transport.emit(change(request, 1)) + expect(first.at(-1)?.kind).toBe('authoritative') + expect(second).toHaveLength(secondCount + 1) + + detachSecond() + expect(transport.notifications).toHaveLength(1) + expect(transport.notifications[0]?.method).toBe('terminalAuthority.topologyUnsubscribe') + expect(transport.handlerCount()).toBe(0) + }) + + it('subscribes once per exact namespace and isolates their projections', async () => { + const coordinator = new SshTerminalAuthorityTopologyCoordinator() + const transport = new FakeTopologyTransport() + const statesA: SshTerminalAuthorityNamespaceTopologyState[] = [] + const statesB: SshTerminalAuthorityNamespaceTopologyState[] = [] + coordinator.attachResolvedNamespace(namespaceA, (state) => statesA.push(state)) + coordinator.attachResolvedNamespace(namespaceB, (state) => statesB.push(state)) + const synchronized = coordinator.onTopologyCapability(asMux(transport), grant) + + expect(transport.pending.map((pending) => pending.params.namespace)).toEqual([ + namespaceA, + namespaceB + ]) + transport.resolveAt(1) + transport.resolveAt(0) + await synchronized + + expect( + ( + statesA.at(-1) as Extract< + SshTerminalAuthorityNamespaceTopologyState, + { kind: 'authoritative' } + > + ).snapshot.namespace + ).toEqual(namespaceA) + expect( + ( + statesB.at(-1) as Extract< + SshTerminalAuthorityNamespaceTopologyState, + { kind: 'authoritative' } + > + ).snapshot.namespace + ).toEqual(namespaceB) + coordinator.dispose() + }) + + it('makes a sequence gap non-authoritative until the exact resnapshot settles', async () => { + const coordinator = new SshTerminalAuthorityTopologyCoordinator() + const transport = new FakeTopologyTransport() + const states: SshTerminalAuthorityNamespaceTopologyState[] = [] + coordinator.attachResolvedNamespace(namespaceA, (state) => states.push(state)) + const synchronized = coordinator.onTopologyCapability(asMux(transport), grant) + const firstRequest = transport.pending[0] + transport.resolveAt(0) + await synchronized + + transport.emit(change(firstRequest, 2)) + expect(states.at(-1)).toMatchObject({ + kind: 'authority-unavailable', + reason: 'synchronizing', + synchronizationReason: 'sequence-gap' + }) + expect(transport.pending).toHaveLength(1) + transport.resolveAt(0, { + authorityRevision: 2, + appliedChangeSequence: 2, + namespaceRecoveryNotices: { version: 1, revision: 2, notices: [] } + }) + await settle() + + expect(states.at(-1)).toMatchObject({ + kind: 'authoritative', + snapshot: { authorityRevision: 2, appliedChangeSequence: 2 } + }) + coordinator.dispose() + }) + + it('fences late snapshots from a replaced reconnect generation', async () => { + const coordinator = new SshTerminalAuthorityTopologyCoordinator() + const oldTransport = new FakeTopologyTransport() + const newTransport = new FakeTopologyTransport() + const states: SshTerminalAuthorityNamespaceTopologyState[] = [] + coordinator.attachResolvedNamespace(namespaceA, (state) => states.push(state)) + const oldSynchronization = coordinator.onTopologyCapability(asMux(oldTransport), grant) + + const newSynchronization = coordinator.onTopologyCapability(asMux(newTransport), grant) + await oldSynchronization + expect(oldTransport.pending).toHaveLength(0) + expect(oldTransport.handlerCount()).toBe(0) + newTransport.resolveAt(0, { streamIncarnationId: 'winning-stream', writerEpoch: 2 }) + await newSynchronization + + expect(states.at(-1)).toMatchObject({ + kind: 'authoritative', + snapshot: { streamIncarnationId: 'winning-stream', writerEpoch: 2 } + }) + expect(oldTransport.notifications[0]?.method).toBe('terminalAuthority.topologyUnsubscribe') + coordinator.dispose() + }) + + it('resnapshots retained namespaces after transport reconnect with stable subscription IDs', async () => { + const coordinator = new SshTerminalAuthorityTopologyCoordinator() + const firstTransport = new FakeTopologyTransport() + const secondTransport = new FakeTopologyTransport() + const states: SshTerminalAuthorityNamespaceTopologyState[] = [] + coordinator.attachResolvedNamespace(namespaceA, (state) => states.push(state)) + const firstSynchronization = coordinator.onTopologyCapability(asMux(firstTransport), grant) + const firstSubscriptionId = firstTransport.pending[0]?.params.subscriptionId + firstTransport.resolveAt(0) + await firstSynchronization + + coordinator.detachTransport(asMux(firstTransport)) + expect(states.at(-1)).toMatchObject({ + kind: 'authority-unavailable', + reason: 'disconnected' + }) + const secondSynchronization = coordinator.onTopologyCapability(asMux(secondTransport), grant) + expect(secondTransport.pending[0]?.params.subscriptionId).toBe(firstSubscriptionId) + secondTransport.resolveAt(0, { writerEpoch: 2, streamIncarnationId: 'new-stream' }) + await secondSynchronization + + expect(states.at(-1)).toMatchObject({ + kind: 'authoritative', + snapshot: { writerEpoch: 2, streamIncarnationId: 'new-stream' } + }) + coordinator.dispose() + }) + + it('never resumes legacy fallback after reconnecting a committed namespace to an old host', async () => { + const coordinator = new SshTerminalAuthorityTopologyCoordinator() + const currentTransport = new FakeTopologyTransport() + const oldHostTransport = new FakeTopologyTransport() + const states: SshTerminalAuthorityNamespaceTopologyState[] = [] + coordinator.attachResolvedNamespace(namespaceA, (state) => states.push(state)) + const synchronized = coordinator.onTopologyCapability(asMux(currentTransport), grant) + currentTransport.resolveAt(0) + await synchronized + + coordinator.detachTransport(asMux(currentTransport)) + await coordinator.onTopologyCapability(asMux(oldHostTransport), null) + + const committedIndex = states.findIndex((state) => state.kind === 'authoritative') + expect(states.slice(committedIndex + 1)).toEqual([ + { kind: 'authority-unavailable', reason: 'disconnected' }, + { kind: 'authority-unavailable', reason: 'capability-not-granted' } + ]) + expect(oldHostTransport.events).toEqual([]) + coordinator.dispose() + }) + + it('retains committed cutover when the final sink detaches and a new sink mounts', async () => { + const coordinator = new SshTerminalAuthorityTopologyCoordinator() + const transport = new FakeTopologyTransport() + const firstStates: SshTerminalAuthorityNamespaceTopologyState[] = [] + const detach = coordinator.attachResolvedNamespace(namespaceA, (state) => + firstStates.push(state) + ) + const synchronized = coordinator.onTopologyCapability(asMux(transport), grant) + const subscriptionId = transport.pending[0]?.params.subscriptionId + transport.resolveAt(0) + await synchronized + detach() + + const remountedStates: SshTerminalAuthorityNamespaceTopologyState[] = [] + coordinator.attachResolvedNamespace(namespaceA, (state) => remountedStates.push(state)) + + expect(remountedStates[0]).toMatchObject({ + kind: 'authority-unavailable', + reason: 'synchronizing' + }) + expect(transport.pending[0]?.params.subscriptionId).toBe(subscriptionId) + transport.resolveAt(0, { writerEpoch: 2, streamIncarnationId: 'remounted-stream' }) + await settle() + expect(remountedStates.at(-1)?.kind).toBe('authoritative') + expect(remountedStates.some((state) => state.kind === 'legacy-fallback')).toBe(false) + coordinator.dispose() + }) + + it('starts a durably committed namespace read-only when no topology grant exists', async () => { + const coordinator = new SshTerminalAuthorityTopologyCoordinator() + const transport = new FakeTopologyTransport() + const states: SshTerminalAuthorityNamespaceTopologyState[] = [] + coordinator.attachResolvedNamespace(namespaceA, (state) => states.push(state), { + durableCutoverCommitted: true + }) + + await coordinator.onTopologyCapability(asMux(transport), null) + + expect(states).toEqual([ + { kind: 'authority-unavailable', reason: 'disconnected' }, + { kind: 'authority-unavailable', reason: 'capability-not-granted' } + ]) + expect(transport.events).toEqual([]) + coordinator.dispose() + }) + + it('keeps a committed namespace read-only after synchronization becomes stale', async () => { + const coordinator = new SshTerminalAuthorityTopologyCoordinator() + const transport = new FakeTopologyTransport() + const states: SshTerminalAuthorityNamespaceTopologyState[] = [] + coordinator.attachResolvedNamespace(namespaceA, (state) => states.push(state)) + const synchronized = coordinator.onTopologyCapability(asMux(transport), grant) + const request = transport.pending[0] + transport.resolveAt(0) + await synchronized + + transport.emit(change(request, 2)) + transport.rejectAt(0, new Error('snapshot failed')) + await settle() + + expect(states.at(-1)).toMatchObject({ + kind: 'authority-unavailable', + reason: 'stale', + error: { message: 'snapshot failed' } + }) + coordinator.dispose() + }) + + it('removes stale observers and isolates observer exceptions', async () => { + const coordinator = new SshTerminalAuthorityTopologyCoordinator() + const transport = new FakeTopologyTransport() + const staleObserver = vi.fn() + const liveObserver = vi.fn() + const detach = coordinator.attachResolvedNamespace(namespaceA, staleObserver) + coordinator.attachResolvedNamespace(namespaceA, () => { + liveObserver() + throw new Error('observer failed') + }) + detach() + const synchronized = coordinator.onTopologyCapability(asMux(transport), grant) + transport.resolveAt(0) + await synchronized + + expect(staleObserver).toHaveBeenCalledTimes(1) + expect(liveObserver).toHaveBeenCalledTimes(3) + coordinator.dispose() + }) + + it('bounds unique namespace subscriptions per connection', () => { + const coordinator = new SshTerminalAuthorityTopologyCoordinator() + for (let index = 0; index < 256; index += 1) { + coordinator.attachResolvedNamespace( + { authorityHostId: 'host-a', namespaceId: `namespace-${index}` }, + () => {} + ) + } + + expect(() => + coordinator.attachResolvedNamespace( + { authorityHostId: 'host-a', namespaceId: 'namespace-overflow' }, + () => {} + ) + ).toThrow('subscription_capacity') + coordinator.dispose() + }) + + it('bounds committed tombstones without evicting their fail-closed state', () => { + const coordinator = new SshTerminalAuthorityTopologyCoordinator() + for (let index = 0; index < 256; index += 1) { + const detach = coordinator.attachResolvedNamespace( + { authorityHostId: 'host-a', namespaceId: `committed-${index}` }, + () => {}, + { durableCutoverCommitted: true } + ) + detach() + } + + expect(() => + coordinator.attachResolvedNamespace( + { authorityHostId: 'host-a', namespaceId: 'committed-overflow' }, + () => {} + ) + ).toThrow('subscription_capacity') + coordinator.dispose() + }) +}) diff --git a/src/main/ssh/ssh-terminal-authority-topology-coordinator.ts b/src/main/ssh/ssh-terminal-authority-topology-coordinator.ts new file mode 100644 index 00000000000..1f98cfb3861 --- /dev/null +++ b/src/main/ssh/ssh-terminal-authority-topology-coordinator.ts @@ -0,0 +1,260 @@ +import { randomUUID } from 'node:crypto' +import { + TERMINAL_AUTHORITY_TOPOLOGY_MAX_SUBSCRIPTIONS_PER_CONNECTION, + type TerminalAuthorityTopologyCapabilityGrant +} from '../../shared/terminal-authority-topology-stream-contract' +import { + assertAuthorityNamespace, + type TerminalAuthorityNamespace +} from '../../shared/terminal-session-authority-identity' +import type { SshChannelMultiplexer } from './ssh-channel-multiplexer' +import { SshTerminalAuthorityTopologyClient } from './ssh-terminal-authority-topology-client' +import { + commitSshTerminalAuthorityTopologyState, + initialSshTerminalAuthorityTopologyState, + sshTerminalAuthorityTopologyCapabilityState, + sshTerminalAuthorityTopologyDisconnectedState, + sshTerminalAuthorityTopologyStaleState, + sshTerminalAuthorityTopologySynchronizingState, + type SshTerminalAuthorityNamespaceTopologyAttachOptions, + type SshTerminalAuthorityNamespaceTopologySink, + type SshTerminalAuthorityNamespaceTopologyState +} from './ssh-terminal-authority-topology-state' + +export type { + SshTerminalAuthorityNamespaceTopologyAttachOptions, + SshTerminalAuthorityNamespaceTopologySink, + SshTerminalAuthorityNamespaceTopologyState +} from './ssh-terminal-authority-topology-state' + +type NamespaceEntry = { + namespace: TerminalAuthorityNamespace + subscriptionId: string + sinks: Map + client: SshTerminalAuthorityTopologyClient | null + state: SshTerminalAuthorityNamespaceTopologyState + authorityCommitted: boolean +} + +function namespaceKey(namespace: TerminalAuthorityNamespace): string { + return JSON.stringify([namespace.authorityHostId, namespace.namespaceId]) +} + +export class SshTerminalAuthorityTopologyCoordinator { + private readonly entries = new Map() + private transport: SshChannelMultiplexer | null = null + private capabilityGrant: TerminalAuthorityTopologyCapabilityGrant | null = null + private connectionGeneration = 0 + private nextSinkId = 1 + private disposed = false + + attachResolvedNamespace( + namespace: TerminalAuthorityNamespace, + sink: SshTerminalAuthorityNamespaceTopologySink, + options: SshTerminalAuthorityNamespaceTopologyAttachOptions = {} + ): () => void { + if (this.disposed) { + throw new Error('terminal_authority_topology_coordinator_disposed') + } + assertAuthorityNamespace(namespace) + const key = namespaceKey(namespace) + let entry = this.entries.get(key) + if (!entry) { + if (this.entries.size >= TERMINAL_AUTHORITY_TOPOLOGY_MAX_SUBSCRIPTIONS_PER_CONNECTION) { + throw new Error('terminal_authority_topology_subscription_capacity') + } + entry = { + namespace: Object.freeze({ ...namespace }), + subscriptionId: `topology-${randomUUID()}`, + sinks: new Map(), + client: null, + state: this.initialState(options.durableCutoverCommitted === true), + authorityCommitted: options.durableCutoverCommitted === true + } + this.entries.set(key, entry) + } else if (options.durableCutoverCommitted === true) { + this.commitAuthority(entry) + } + if (entry.sinks.size === 0) { + entry.state = this.initialState(entry.authorityCommitted) + } + const sinkId = this.nextSinkId++ + entry.sinks.set(sinkId, sink) + this.notifySink(sink, entry.state) + if (!entry.client && this.transport && this.capabilityGrant) { + void this.startEntry(entry, this.connectionGeneration).catch(() => {}) + } + let attached = true + return () => { + if (!attached) { + return + } + attached = false + const current = this.entries.get(key) + if (!current) { + return + } + current.sinks.delete(sinkId) + if (current.sinks.size === 0) { + current.client?.dispose() + current.client = null + if (current.authorityCommitted) { + current.state = this.initialState(true) + } else { + this.entries.delete(key) + } + } + } + } + + async onTopologyCapability( + transport: SshChannelMultiplexer, + capabilityGrant: TerminalAuthorityTopologyCapabilityGrant | null + ): Promise { + if (this.disposed) { + throw new Error('terminal_authority_topology_coordinator_disposed') + } + const generation = ++this.connectionGeneration + this.transport = transport + this.capabilityGrant = capabilityGrant + for (const entry of this.entries.values()) { + entry.client?.dispose() + entry.client = null + } + if (!capabilityGrant) { + for (const entry of this.entries.values()) { + this.publish(entry, sshTerminalAuthorityTopologyCapabilityState(entry.authorityCommitted)) + } + return + } + await Promise.all( + [...this.entries.values()] + .filter((entry) => entry.sinks.size > 0) + .map((entry) => this.startEntry(entry, generation)) + ) + } + + detachTransport(transport: SshChannelMultiplexer): void { + if (this.transport !== transport) { + return + } + ++this.connectionGeneration + this.transport = null + this.capabilityGrant = null + for (const entry of this.entries.values()) { + entry.client?.dispose() + entry.client = null + this.publish(entry, sshTerminalAuthorityTopologyDisconnectedState(entry.authorityCommitted)) + } + } + + dispose(): void { + if (this.disposed) { + return + } + this.disposed = true + ++this.connectionGeneration + this.transport = null + this.capabilityGrant = null + for (const entry of this.entries.values()) { + entry.client?.dispose() + entry.client = null + this.publish(entry, Object.freeze({ kind: 'disposed' })) + } + this.entries.clear() + } + + private initialState(authorityCommitted: boolean): SshTerminalAuthorityNamespaceTopologyState { + return initialSshTerminalAuthorityTopologyState( + authorityCommitted, + this.transport !== null, + this.capabilityGrant !== null + ) + } + + private async startEntry(entry: NamespaceEntry, generation: number): Promise { + const transport = this.transport + const capabilityGrant = this.capabilityGrant + if (!transport || !capabilityGrant || generation !== this.connectionGeneration) { + return + } + const client = new SshTerminalAuthorityTopologyClient({ + transport, + capabilityGrant, + subscriptionId: entry.subscriptionId, + namespace: entry.namespace, + onStatusChange: (status) => { + if (!this.isCurrent(entry, client, generation)) { + return + } + if (status.kind === 'synchronizing') { + this.publish( + entry, + sshTerminalAuthorityTopologySynchronizingState(entry.authorityCommitted, status.reason) + ) + } else if (status.kind === 'stale') { + this.publish( + entry, + sshTerminalAuthorityTopologyStaleState(entry.authorityCommitted, status.error) + ) + } + }, + onAuthoritativeState: (snapshot) => { + if (this.isCurrent(entry, client, generation)) { + entry.authorityCommitted = true + this.publish(entry, Object.freeze({ kind: 'authoritative', snapshot })) + } + } + }) + entry.client = client + try { + await client.start() + } catch (error) { + if (this.isCurrent(entry, client, generation)) { + throw error + } + } + } + + private isCurrent( + entry: NamespaceEntry, + client: SshTerminalAuthorityTopologyClient, + generation: number + ): boolean { + return ( + !this.disposed && + generation === this.connectionGeneration && + this.entries.get(namespaceKey(entry.namespace)) === entry && + entry.client === client + ) + } + + private publish(entry: NamespaceEntry, state: SshTerminalAuthorityNamespaceTopologyState): void { + entry.state = state + for (const sink of entry.sinks.values()) { + this.notifySink(sink, state) + } + } + + private commitAuthority(entry: NamespaceEntry): void { + if (entry.authorityCommitted) { + return + } + entry.authorityCommitted = true + const committedState = commitSshTerminalAuthorityTopologyState(entry.state) + if (committedState !== entry.state) { + this.publish(entry, committedState) + } + } + + private notifySink( + sink: SshTerminalAuthorityNamespaceTopologySink, + state: SshTerminalAuthorityNamespaceTopologyState + ): void { + try { + sink(state) + } catch { + // A projection observer cannot change authority synchronization. + } + } +} diff --git a/src/main/ssh/ssh-terminal-authority-topology-notification-buffer.ts b/src/main/ssh/ssh-terminal-authority-topology-notification-buffer.ts new file mode 100644 index 00000000000..a95ec5f0e7a --- /dev/null +++ b/src/main/ssh/ssh-terminal-authority-topology-notification-buffer.ts @@ -0,0 +1,90 @@ +import { + TERMINAL_AUTHORITY_TOPOLOGY_MAX_BUFFERED_BYTES, + TERMINAL_AUTHORITY_TOPOLOGY_MAX_BUFFERED_CHANGES, + type TerminalAuthorityTopologyChange, + type TerminalAuthorityTopologySnapshot +} from '../../shared/terminal-authority-topology-stream-contract' +import type { + SshTerminalAuthorityTopologyReducer, + SshTerminalAuthorityTopologyResnapshotReason +} from './ssh-terminal-authority-topology-reducer' + +export type SshTerminalAuthorityTopologyBufferedNotification = + | Readonly<{ + kind: 'change' + ordinal: number + byteLength: number + change: TerminalAuthorityTopologyChange + }> + | Readonly<{ kind: 'invalid'; ordinal: number; byteLength: 0 }> + +export type SshTerminalAuthorityTopologyBufferedBatch = Readonly<{ + notifications: readonly SshTerminalAuthorityTopologyBufferedNotification[] + overflowOrdinal: number +}> + +export class SshTerminalAuthorityTopologyNotificationBuffer { + private notifications: SshTerminalAuthorityTopologyBufferedNotification[] = [] + private retainedBytes = 0 + private overflowOrdinal = 0 + + push(notification: SshTerminalAuthorityTopologyBufferedNotification): void { + if (this.overflowOrdinal > 0) { + this.overflowOrdinal = notification.ordinal + return + } + if ( + this.notifications.length >= TERMINAL_AUTHORITY_TOPOLOGY_MAX_BUFFERED_CHANGES || + this.retainedBytes + notification.byteLength > TERMINAL_AUTHORITY_TOPOLOGY_MAX_BUFFERED_BYTES + ) { + this.notifications = [] + this.retainedBytes = 0 + this.overflowOrdinal = notification.ordinal + return + } + this.notifications.push(notification) + this.retainedBytes += notification.byteLength + } + + take(): SshTerminalAuthorityTopologyBufferedBatch { + const batch = Object.freeze({ + notifications: Object.freeze(this.notifications), + overflowOrdinal: this.overflowOrdinal + }) + this.clear() + return batch + } + + clear(): void { + this.notifications = [] + this.retainedBytes = 0 + this.overflowOrdinal = 0 + } +} + +export function replaySshTerminalAuthorityTopologyNotifications(args: { + reducer: SshTerminalAuthorityTopologyReducer + snapshot: TerminalAuthorityTopologySnapshot + notifications: readonly SshTerminalAuthorityTopologyBufferedNotification[] + overflowOrdinal: number +}): SshTerminalAuthorityTopologyResnapshotReason | null { + if (args.overflowOrdinal > 0) { + return 'buffer-capacity' + } + for (const entry of args.notifications) { + if (entry.kind === 'invalid') { + return 'notification-invalid' + } + if (entry.change.streamIncarnationId !== args.snapshot.streamIncarnationId) { + if (entry.change.writerEpoch < args.snapshot.writerEpoch) { + continue + } + return 'stream-incarnation-changed' + } + const result = args.reducer.apply(entry.change) + if (result.kind === 'resnapshot-required') { + return result.reason + } + } + return null +} diff --git a/src/main/ssh/ssh-terminal-authority-topology-reducer.test.ts b/src/main/ssh/ssh-terminal-authority-topology-reducer.test.ts new file mode 100644 index 00000000000..80688f82fea --- /dev/null +++ b/src/main/ssh/ssh-terminal-authority-topology-reducer.test.ts @@ -0,0 +1,294 @@ +import { describe, expect, it } from 'vitest' +import { + parseTerminalAuthorityTopologyChange, + parseTerminalAuthorityTopologySnapshot +} from '../../shared/terminal-authority-topology-stream-validation' +import { SshTerminalAuthorityTopologyReducer } from './ssh-terminal-authority-topology-reducer' + +const namespace = { authorityHostId: 'host-a', namespaceId: 'namespace-a' } +const binding = { + ownerIncarnationId: 'owner-a', + physicalPtyId: 'pty-a', + ptyIncarnationId: 'incarnation-a' +} + +function pane(overrides: Record = {}): Record { + return { + paneKey: 'pane-a', + paneGenerationId: 'generation-a', + status: 'open', + binding, + lastBinding: binding, + revision: 2, + ownerStatus: 'reachable', + ...overrides + } +} + +function notices(revision = 1): Record { + return { version: 1, revision, notices: [] } +} + +function snapshot(overrides: Record = {}) { + return parseTerminalAuthorityTopologySnapshot({ + protocolVersion: 1, + subscriptionId: 'subscription-a', + streamIncarnationId: 'stream-a', + namespace, + writerEpoch: 3, + authorityRevision: 2, + appliedChangeSequence: 7, + panes: [pane()], + namespaceRecoveryNotices: notices(), + ...overrides + }) +} + +function change(overrides: Record = {}) { + return parseTerminalAuthorityTopologyChange({ + protocolVersion: 1, + subscriptionId: 'subscription-a', + streamIncarnationId: 'stream-a', + namespace, + writerEpoch: 3, + baseAuthorityRevision: 2, + authorityRevision: 3, + changeSequence: 8, + paneChanges: [{ kind: 'upsert', pane: pane({ revision: 3 }) }], + ...overrides + }) +} + +describe('SSH terminal authority topology reducer', () => { + it('applies one contiguous exact namespace change', () => { + const reducer = new SshTerminalAuthorityTopologyReducer() + reducer.replace(snapshot()) + + const result = reducer.apply(change()) + + expect(result.kind).toBe('applied') + expect(reducer.state()).toMatchObject({ + streamIncarnationId: 'stream-a', + authorityRevision: 3, + appliedChangeSequence: 8 + }) + }) + + it('atomically retires a binding while preserving its last binding', () => { + const reducer = new SshTerminalAuthorityTopologyReducer() + reducer.replace(snapshot()) + const result = reducer.apply( + change({ + paneChanges: [ + { + kind: 'upsert', + pane: pane({ status: 'closed', binding: null, ownerStatus: null, revision: 3 }) + } + ] + }) + ) + + expect(result.kind).toBe('applied') + expect(reducer.state()?.panes[0]).toMatchObject({ + status: 'closed', + binding: null, + lastBinding: binding + }) + }) + + it('retains an open unbound pane without inferring an allocation', () => { + const reducer = new SshTerminalAuthorityTopologyReducer() + reducer.replace( + snapshot({ + panes: [pane({ binding: null, lastBinding: null, ownerStatus: null })] + }) + ) + + expect(reducer.state()?.panes[0]).toMatchObject({ + status: 'open', + binding: null, + ownerStatus: null + }) + expect(reducer.state()).not.toHaveProperty('allocations') + }) + + it('keeps the last exact state non-mutated across a sequence gap', () => { + const reducer = new SshTerminalAuthorityTopologyReducer() + const initial = reducer.replace(snapshot()) + + expect(reducer.apply(change({ changeSequence: 9 }))).toEqual({ + kind: 'resnapshot-required', + reason: 'sequence-gap' + }) + expect(reducer.state()).toBe(initial) + }) + + it('rejects a regressed snapshot from the same authority lineage', () => { + const reducer = new SshTerminalAuthorityTopologyReducer() + reducer.replace( + snapshot({ + authorityRevision: 5, + appliedChangeSequence: 9, + panes: [], + namespaceRecoveryNotices: notices(5) + }) + ) + + expect( + reducer.snapshotConflict( + snapshot({ + authorityRevision: 4, + appliedChangeSequence: 8, + panes: [], + namespaceRecoveryNotices: notices(4) + }) + ) + ).toBe('snapshot-regressed') + expect(reducer.state()).toMatchObject({ authorityRevision: 5, appliedChangeSequence: 9 }) + }) + + it.each([ + ['stream-incarnation-changed', { streamIncarnationId: 'stream-b' }], + ['writer-epoch-changed', { writerEpoch: 4 }], + ['subscription-changed', { subscriptionId: 'subscription-b' }], + ['namespace-changed', { namespace: { ...namespace, namespaceId: 'namespace-b' } }] + ] as const)('requires a resnapshot when %s', (reason, override) => { + const reducer = new SshTerminalAuthorityTopologyReducer() + reducer.replace(snapshot()) + + expect(reducer.apply(change(override))).toEqual({ kind: 'resnapshot-required', reason }) + }) + + it('distinguishes exact duplicate delivery from a conflicting duplicate', () => { + const reducer = new SshTerminalAuthorityTopologyReducer() + reducer.replace(snapshot()) + const applied = change() + expect(reducer.apply(applied).kind).toBe('applied') + + expect(reducer.apply(applied)).toEqual({ kind: 'duplicate', reason: 'exact-replay' }) + expect( + reducer.apply( + change({ + paneChanges: [ + { + kind: 'upsert', + pane: pane({ revision: 3, ownerStatus: 'owner-unreachable' }) + } + ] + }) + ) + ).toEqual({ kind: 'resnapshot-required', reason: 'sequence-conflict' }) + }) + + it('treats notifications at the snapshot high-watermark as already covered', () => { + const reducer = new SshTerminalAuthorityTopologyReducer() + reducer.replace(snapshot()) + + expect( + reducer.apply( + change({ + baseAuthorityRevision: 1, + authorityRevision: 2, + changeSequence: 7, + paneChanges: [ + { kind: 'upsert', pane: pane({ revision: 2, ownerStatus: 'owner-unreachable' }) } + ] + }) + ) + ).toEqual({ kind: 'duplicate', reason: 'covered-by-snapshot' }) + }) + + it('rejects authority revision and namespace recovery revision regressions', () => { + const reducer = new SshTerminalAuthorityTopologyReducer() + reducer.replace(snapshot({ namespaceRecoveryNotices: notices(5) })) + + expect(reducer.apply(change({ baseAuthorityRevision: 1 }))).toEqual({ + kind: 'resnapshot-required', + reason: 'revision-conflict' + }) + expect( + reducer.apply( + change({ + paneChanges: [], + authorityRevision: 2, + namespaceRecoveryNotices: notices(4) + }) + ) + ).toEqual({ kind: 'resnapshot-required', reason: 'recovery-revision-conflict' }) + }) + + it('rejects a delta that would leave contradictory topology', () => { + const reducer = new SshTerminalAuthorityTopologyReducer() + reducer.replace(snapshot()) + + expect( + reducer.apply( + change({ + paneChanges: [ + { + kind: 'upsert', + pane: pane({ + paneKey: 'pane-b', + paneGenerationId: 'generation-b', + revision: 3 + }) + } + ] + }) + ) + ).toEqual({ kind: 'resnapshot-required', reason: 'topology-conflict' }) + expect(reducer.state()?.panes).toHaveLength(1) + expect(reducer.state()?.panes[0]).toMatchObject({ paneKey: 'pane-a', revision: 2 }) + }) + + it('rejects a per-pane revision regression even when the authority revision advances', () => { + const reducer = new SshTerminalAuthorityTopologyReducer() + reducer.replace(snapshot()) + + expect( + reducer.apply(change({ paneChanges: [{ kind: 'upsert', pane: pane({ revision: 1 }) }] })) + ).toEqual({ kind: 'resnapshot-required', reason: 'topology-conflict' }) + expect(reducer.state()?.panes[0]).toMatchObject({ revision: 2 }) + }) + + it('allows only derived owner status to change at the same pane revision', () => { + const reducer = new SshTerminalAuthorityTopologyReducer() + reducer.replace(snapshot()) + + expect( + reducer.apply( + change({ + authorityRevision: 2, + paneChanges: [ + { kind: 'upsert', pane: pane({ revision: 2, ownerStatus: 'owner-unreachable' }) } + ] + }) + ).kind + ).toBe('applied') + + const conflicting = new SshTerminalAuthorityTopologyReducer() + conflicting.replace(snapshot()) + expect( + conflicting.apply( + change({ + authorityRevision: 2, + paneChanges: [ + { + kind: 'upsert', + pane: pane({ status: 'closed', binding: null, ownerStatus: null, revision: 2 }) + } + ] + }) + ) + ).toEqual({ kind: 'resnapshot-required', reason: 'topology-conflict' }) + }) + + it('fails closed on a conflicting snapshot at the same lineage high-watermark', () => { + const reducer = new SshTerminalAuthorityTopologyReducer() + reducer.replace(snapshot()) + + expect( + reducer.snapshotConflict(snapshot({ panes: [pane({ ownerStatus: 'owner-unreachable' })] })) + ).toBe('snapshot-conflict') + }) +}) diff --git a/src/main/ssh/ssh-terminal-authority-topology-reducer.ts b/src/main/ssh/ssh-terminal-authority-topology-reducer.ts new file mode 100644 index 00000000000..68d1126ddff --- /dev/null +++ b/src/main/ssh/ssh-terminal-authority-topology-reducer.ts @@ -0,0 +1,242 @@ +import { + sameTerminalBinding, + terminalPaneGenerationKey +} from '../../shared/terminal-session-authority-identity' +import type { + TerminalAuthorityTopologyChange, + TerminalAuthorityTopologySnapshot +} from '../../shared/terminal-authority-topology-stream-contract' +import { TerminalAuthorityTopologyStreamValidationError } from '../../shared/terminal-authority-topology-stream-errors' +import { assertTerminalAuthorityTopologyPanes } from '../../shared/terminal-authority-topology-record-validation' +import { sameTerminalAuthorityTopologyNamespace } from '../../shared/terminal-authority-topology-stream-validation' + +export type SshTerminalAuthorityTopologyResnapshotReason = + | 'not-initialized' + | 'subscription-changed' + | 'namespace-changed' + | 'stream-incarnation-changed' + | 'writer-epoch-changed' + | 'sequence-gap' + | 'stale-sequence' + | 'sequence-conflict' + | 'revision-conflict' + | 'topology-conflict' + | 'recovery-revision-conflict' + | 'snapshot-regressed' + | 'snapshot-conflict' + | 'notification-invalid' + | 'buffer-capacity' + +export type SshTerminalAuthorityTopologyApplyResult = + | Readonly<{ kind: 'applied'; state: TerminalAuthorityTopologySnapshot }> + | Readonly<{ kind: 'duplicate'; reason: 'covered-by-snapshot' | 'exact-replay' }> + | Readonly<{ + kind: 'resnapshot-required' + reason: SshTerminalAuthorityTopologyResnapshotReason + }> + +const MAX_RETAINED_CHANGE_SIGNATURES = 256 + +function resnapshot( + reason: SshTerminalAuthorityTopologyResnapshotReason +): SshTerminalAuthorityTopologyApplyResult { + return Object.freeze({ kind: 'resnapshot-required', reason }) +} + +function comparePaneKeys( + left: TerminalAuthorityTopologySnapshot['panes'][number], + right: TerminalAuthorityTopologySnapshot['panes'][number] +): number { + return terminalPaneGenerationKey(left).localeCompare(terminalPaneGenerationKey(right)) +} + +function recoveryProjectionsEqual( + left: TerminalAuthorityTopologySnapshot['namespaceRecoveryNotices'], + right: TerminalAuthorityTopologySnapshot['namespaceRecoveryNotices'] +): boolean { + return JSON.stringify(left) === JSON.stringify(right) +} + +function paneCoreEqual( + left: TerminalAuthorityTopologySnapshot['panes'][number], + right: TerminalAuthorityTopologySnapshot['panes'][number] +): boolean { + return ( + left.paneKey === right.paneKey && + left.paneGenerationId === right.paneGenerationId && + left.status === right.status && + sameTerminalBinding(left.binding, right.binding) && + sameTerminalBinding(left.lastBinding, right.lastBinding) + ) +} + +function snapshotStateSignature(snapshot: TerminalAuthorityTopologySnapshot): string { + return JSON.stringify({ + authorityRevision: snapshot.authorityRevision, + panes: [...snapshot.panes].sort(comparePaneKeys), + namespaceRecoveryNotices: snapshot.namespaceRecoveryNotices + }) +} + +export class SshTerminalAuthorityTopologyReducer { + private current: TerminalAuthorityTopologySnapshot | null = null + private snapshotSequence = 0 + private paneRecords = new Map() + private readonly changeSignatures = new Map() + + replace(snapshot: TerminalAuthorityTopologySnapshot): TerminalAuthorityTopologySnapshot { + this.paneRecords = new Map( + snapshot.panes.map((record) => [terminalPaneGenerationKey(record), record]) + ) + this.current = snapshot + this.snapshotSequence = snapshot.appliedChangeSequence + this.changeSignatures.clear() + return snapshot + } + + state(): TerminalAuthorityTopologySnapshot | null { + return this.current + } + + snapshotConflict( + snapshot: TerminalAuthorityTopologySnapshot + ): 'snapshot-regressed' | 'snapshot-conflict' | null { + const current = this.current + if (!current) { + return null + } + if ( + snapshot.writerEpoch < current.writerEpoch || + snapshot.authorityRevision < current.authorityRevision || + snapshot.namespaceRecoveryNotices.revision < current.namespaceRecoveryNotices.revision || + (snapshot.streamIncarnationId === current.streamIncarnationId && + (snapshot.writerEpoch !== current.writerEpoch || + snapshot.appliedChangeSequence < current.appliedChangeSequence)) + ) { + return 'snapshot-regressed' + } + if ( + snapshot.streamIncarnationId === current.streamIncarnationId && + snapshot.writerEpoch === current.writerEpoch && + snapshot.appliedChangeSequence === current.appliedChangeSequence && + snapshotStateSignature(snapshot) !== snapshotStateSignature(current) + ) { + return 'snapshot-conflict' + } + return null + } + + apply(change: TerminalAuthorityTopologyChange): SshTerminalAuthorityTopologyApplyResult { + const current = this.current + if (!current) { + return resnapshot('not-initialized') + } + const headerConflict = this.headerConflict(current, change) + if (headerConflict) { + return resnapshot(headerConflict) + } + if (change.changeSequence <= current.appliedChangeSequence) { + return this.replayResult(change) + } + if (change.changeSequence !== current.appliedChangeSequence + 1) { + return resnapshot('sequence-gap') + } + if (change.baseAuthorityRevision !== current.authorityRevision) { + return resnapshot('revision-conflict') + } + const nextPanes = new Map(this.paneRecords) + for (const operation of change.paneChanges) { + const key = terminalPaneGenerationKey(operation.pane) + if (operation.kind === 'remove') { + if (!nextPanes.delete(key)) { + return resnapshot('topology-conflict') + } + } else { + const currentPane = nextPanes.get(key) + if ( + currentPane && + (operation.pane.revision < currentPane.revision || + (operation.pane.revision === currentPane.revision && + !paneCoreEqual(currentPane, operation.pane))) + ) { + return resnapshot('topology-conflict') + } + nextPanes.set(key, operation.pane) + } + } + const recoveryNotices = change.namespaceRecoveryNotices ?? current.namespaceRecoveryNotices + if ( + recoveryNotices.revision < current.namespaceRecoveryNotices.revision || + (recoveryNotices.revision === current.namespaceRecoveryNotices.revision && + !recoveryProjectionsEqual(recoveryNotices, current.namespaceRecoveryNotices)) + ) { + return resnapshot('recovery-revision-conflict') + } + const panes = [...nextPanes.values()].sort(comparePaneKeys) + try { + assertTerminalAuthorityTopologyPanes(panes, change.authorityRevision) + } catch (error) { + if (error instanceof TerminalAuthorityTopologyStreamValidationError) { + return resnapshot('topology-conflict') + } + throw error + } + const next = Object.freeze({ + protocolVersion: 1 as const, + subscriptionId: current.subscriptionId, + streamIncarnationId: current.streamIncarnationId, + namespace: current.namespace, + writerEpoch: current.writerEpoch, + authorityRevision: change.authorityRevision, + appliedChangeSequence: change.changeSequence, + panes: Object.freeze(panes), + namespaceRecoveryNotices: recoveryNotices + }) + this.paneRecords = nextPanes + this.current = next + this.rememberChange(change) + return Object.freeze({ kind: 'applied', state: next }) + } + + private headerConflict( + current: TerminalAuthorityTopologySnapshot, + change: TerminalAuthorityTopologyChange + ): SshTerminalAuthorityTopologyResnapshotReason | null { + if (change.subscriptionId !== current.subscriptionId) { + return 'subscription-changed' + } + if (!sameTerminalAuthorityTopologyNamespace(change.namespace, current.namespace)) { + return 'namespace-changed' + } + if (change.streamIncarnationId !== current.streamIncarnationId) { + return 'stream-incarnation-changed' + } + return change.writerEpoch === current.writerEpoch ? null : 'writer-epoch-changed' + } + + private replayResult( + change: TerminalAuthorityTopologyChange + ): SshTerminalAuthorityTopologyApplyResult { + if (change.changeSequence <= this.snapshotSequence) { + return Object.freeze({ kind: 'duplicate', reason: 'covered-by-snapshot' }) + } + const expected = this.changeSignatures.get(change.changeSequence) + if (expected === undefined) { + return resnapshot('stale-sequence') + } + return expected === JSON.stringify(change) + ? Object.freeze({ kind: 'duplicate', reason: 'exact-replay' }) + : resnapshot('sequence-conflict') + } + + private rememberChange(change: TerminalAuthorityTopologyChange): void { + this.changeSignatures.set(change.changeSequence, JSON.stringify(change)) + while (this.changeSignatures.size > MAX_RETAINED_CHANGE_SIGNATURES) { + const oldest = this.changeSignatures.keys().next().value as number | undefined + if (oldest === undefined) { + break + } + this.changeSignatures.delete(oldest) + } + } +} diff --git a/src/main/ssh/ssh-terminal-authority-topology-state.ts b/src/main/ssh/ssh-terminal-authority-topology-state.ts new file mode 100644 index 00000000000..c7e156bae6a --- /dev/null +++ b/src/main/ssh/ssh-terminal-authority-topology-state.ts @@ -0,0 +1,119 @@ +import type { TerminalAuthorityTopologySnapshot } from '../../shared/terminal-authority-topology-stream-contract' +import type { SshTerminalAuthorityTopologyClientStatus } from './ssh-terminal-authority-topology-client' + +type SynchronizationReason = Extract< + SshTerminalAuthorityTopologyClientStatus, + { kind: 'synchronizing' } +>['reason'] + +export type SshTerminalAuthorityNamespaceTopologyState = + | Readonly<{ kind: 'disconnected' }> + | Readonly<{ kind: 'legacy-fallback' }> + | Readonly<{ kind: 'synchronizing'; reason: SynchronizationReason }> + | Readonly<{ + kind: 'authority-unavailable' + reason: 'disconnected' | 'capability-not-granted' + }> + | Readonly<{ + kind: 'authority-unavailable' + reason: 'synchronizing' + synchronizationReason: SynchronizationReason + }> + | Readonly<{ kind: 'authority-unavailable'; reason: 'stale'; error: Error }> + // Once emitted, every non-authoritative successor is fail-closed. + | Readonly<{ kind: 'authoritative'; snapshot: TerminalAuthorityTopologySnapshot }> + | Readonly<{ kind: 'stale'; error: Error }> + | Readonly<{ kind: 'disposed' }> + +export type SshTerminalAuthorityNamespaceTopologySink = ( + state: SshTerminalAuthorityNamespaceTopologyState +) => void + +export type SshTerminalAuthorityNamespaceTopologyAttachOptions = Readonly<{ + durableCutoverCommitted?: boolean +}> + +export function initialSshTerminalAuthorityTopologyState( + authorityCommitted: boolean, + hasTransport: boolean, + hasCapabilityGrant: boolean +): SshTerminalAuthorityNamespaceTopologyState { + if (!authorityCommitted) { + if (!hasTransport) { + return Object.freeze({ kind: 'disconnected' }) + } + return hasCapabilityGrant + ? Object.freeze({ kind: 'synchronizing', reason: 'initial' }) + : Object.freeze({ kind: 'legacy-fallback' }) + } + if (!hasTransport) { + return Object.freeze({ kind: 'authority-unavailable', reason: 'disconnected' }) + } + return hasCapabilityGrant + ? Object.freeze({ + kind: 'authority-unavailable', + reason: 'synchronizing', + synchronizationReason: 'initial' + }) + : Object.freeze({ kind: 'authority-unavailable', reason: 'capability-not-granted' }) +} + +export function sshTerminalAuthorityTopologyCapabilityState( + authorityCommitted: boolean +): SshTerminalAuthorityNamespaceTopologyState { + return authorityCommitted + ? Object.freeze({ kind: 'authority-unavailable', reason: 'capability-not-granted' }) + : Object.freeze({ kind: 'legacy-fallback' }) +} + +export function sshTerminalAuthorityTopologyDisconnectedState( + authorityCommitted: boolean +): SshTerminalAuthorityNamespaceTopologyState { + return authorityCommitted + ? Object.freeze({ kind: 'authority-unavailable', reason: 'disconnected' }) + : Object.freeze({ kind: 'disconnected' }) +} + +export function sshTerminalAuthorityTopologySynchronizingState( + authorityCommitted: boolean, + reason: SynchronizationReason +): SshTerminalAuthorityNamespaceTopologyState { + return authorityCommitted + ? Object.freeze({ + kind: 'authority-unavailable', + reason: 'synchronizing', + synchronizationReason: reason + }) + : Object.freeze({ kind: 'synchronizing', reason }) +} + +export function sshTerminalAuthorityTopologyStaleState( + authorityCommitted: boolean, + error: Error +): SshTerminalAuthorityNamespaceTopologyState { + return authorityCommitted + ? Object.freeze({ kind: 'authority-unavailable', reason: 'stale', error }) + : Object.freeze({ kind: 'stale', error }) +} + +export function commitSshTerminalAuthorityTopologyState( + state: SshTerminalAuthorityNamespaceTopologyState +): SshTerminalAuthorityNamespaceTopologyState { + if (state.kind === 'disconnected') { + return Object.freeze({ kind: 'authority-unavailable', reason: 'disconnected' }) + } + if (state.kind === 'legacy-fallback') { + return Object.freeze({ kind: 'authority-unavailable', reason: 'capability-not-granted' }) + } + if (state.kind === 'synchronizing') { + return Object.freeze({ + kind: 'authority-unavailable', + reason: 'synchronizing', + synchronizationReason: state.reason + }) + } + if (state.kind === 'stale') { + return Object.freeze({ kind: 'authority-unavailable', reason: 'stale', error: state.error }) + } + return state +} diff --git a/src/main/ssh/ssh-terminal-session-termination.test.ts b/src/main/ssh/ssh-terminal-session-termination.test.ts new file mode 100644 index 00000000000..5377888b597 --- /dev/null +++ b/src/main/ssh/ssh-terminal-session-termination.test.ts @@ -0,0 +1,485 @@ +import { describe, expect, it, vi } from 'vitest' +import type { IPtyProvider, PtyProcessInfo } from '../providers/types' +import type { SshRemotePtyLease } from '../../shared/ssh-types' +import type { TerminalSessionAuthorityPtyAccess } from '../../shared/terminal-session-authority-pty-access' +import { terminateListedSshTerminalSessions } from './ssh-terminal-session-termination' + +const authorityAccess: TerminalSessionAuthorityPtyAccess = { + namespace: { authorityHostId: 'host-1', namespaceId: 'namespace-1' }, + pane: { paneKey: 'pane-1', paneGenerationId: 'renderer:1' }, + binding: { + ownerIncarnationId: 'owner-1', + physicalPtyId: 'pty-1', + ptyIncarnationId: 'incarnation-1' + } +} + +function lease(overrides: Partial = {}): SshRemotePtyLease { + return { + targetId: 'ssh-1', + ptyId: 'pty-1', + incarnationId: 'incarnation-1', + state: 'detached', + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +function provider(input: { + list: () => Promise + killExact?: (id: string, incarnationId: string) => boolean | Promise + killAuthorityExact?: (id: string, access: TerminalSessionAuthorityPtyAccess) => boolean + listedRouteToken?: object + currentRouteToken?: () => object | null +}): IPtyProvider { + const listedRouteToken = input.listedRouteToken ?? Object.freeze({}) + return { + listProcesses: vi.fn(async () => + (await input.list()).map((session) => ({ + ...session, + mutationRouteToken: listedRouteToken + })) + ), + getPtyMutationRouteToken: vi.fn(input.currentRouteToken ?? (() => listedRouteToken)), + killExact: vi.fn(input.killExact ?? (async () => true)), + killAuthorityExact: vi.fn(input.killAuthorityExact ?? (() => true)) + } as unknown as IPtyProvider +} + +describe('SSH terminal session termination', () => { + it('kills the exact inventory incarnation and deduplicates tracked and leased IDs', async () => { + const ptyProvider = provider({ + list: async () => [ + { + id: 'ssh:ssh-1@@pty-1', + incarnationId: 'incarnation-1', + cwd: '/remote', + title: 'shell' + } + ] + }) + + await expect( + terminateListedSshTerminalSessions({ + targetId: 'ssh-1', + provider: ptyProvider, + trackedPtyIds: ['ssh:ssh-1@@pty-1'], + leases: [lease()] + }) + ).resolves.toEqual([ + { + relayPtyId: 'pty-1', + appPtyId: 'ssh:ssh-1@@pty-1', + status: 'terminated' + } + ]) + + expect(ptyProvider.killExact).toHaveBeenCalledOnce() + expect(ptyProvider.killExact).toHaveBeenCalledWith('ssh:ssh-1@@pty-1', 'incarnation-1', { + immediate: true, + keepHistory: false + }) + }) + + it('does not kill a successor whose reused ID conflicts with persisted lease evidence', async () => { + const ptyProvider = provider({ + list: async () => [ + { id: 'ssh:ssh-1@@pty-1', incarnationId: 'incarnation-2', cwd: '/new', title: 'shell' } + ] + }) + + const result = await terminateListedSshTerminalSessions({ + targetId: 'ssh-1', + provider: ptyProvider, + trackedPtyIds: [], + leases: [lease()] + }) + + expect(result).toMatchObject([{ relayPtyId: 'pty-1', status: 'rejected' }]) + expect(ptyProvider.killExact).not.toHaveBeenCalled() + }) + + it('does not kill a same-incarnation successor with different authority access', async () => { + const expectedAccess: TerminalSessionAuthorityPtyAccess = { + namespace: { authorityHostId: 'host-1', namespaceId: 'namespace-1' }, + pane: { paneKey: 'pane-1', paneGenerationId: 'renderer:1' }, + binding: { + ownerIncarnationId: 'owner-1', + physicalPtyId: 'pty-1', + ptyIncarnationId: 'incarnation-1' + } + } + const successorAccess: TerminalSessionAuthorityPtyAccess = { + ...expectedAccess, + pane: { ...expectedAccess.pane, paneGenerationId: 'renderer:2' } + } + const ptyProvider = provider({ + list: async () => [ + { + id: 'ssh:ssh-1@@pty-1', + incarnationId: 'incarnation-1', + terminalSessionAuthorityAccess: successorAccess, + cwd: '/new', + title: 'shell' + } + ] + }) + + const result = await terminateListedSshTerminalSessions({ + targetId: 'ssh-1', + provider: ptyProvider, + trackedPtyIds: [], + leases: [lease({ terminalSessionAuthorityAccess: expectedAccess })] + }) + + expect(result).toMatchObject([{ relayPtyId: 'pty-1', status: 'rejected' }]) + expect(ptyProvider.killAuthorityExact).not.toHaveBeenCalled() + expect(ptyProvider.killExact).not.toHaveBeenCalled() + }) + + it('fails closed when the listed incarnation is replaced before mutation', async () => { + let currentIncarnation = 'incarnation-1' + const ptyProvider = provider({ + list: async () => { + const listed = [ + { id: 'ssh:ssh-1@@pty-1', incarnationId: currentIncarnation, cwd: '/old', title: 'shell' } + ] + currentIncarnation = 'incarnation-2' + return listed + }, + killExact: async (_id, incarnationId) => incarnationId === currentIncarnation + }) + + const result = await terminateListedSshTerminalSessions({ + targetId: 'ssh-1', + provider: ptyProvider, + trackedPtyIds: ['pty-1'], + leases: [] + }) + + expect(result).toMatchObject([{ relayPtyId: 'pty-1', status: 'rejected' }]) + expect(ptyProvider.killExact).toHaveBeenCalledWith( + 'ssh:ssh-1@@pty-1', + 'incarnation-1', + expect.any(Object) + ) + }) + + it('rejects a list result after its provider route is replaced', async () => { + const listedRouteToken = Object.freeze({}) + const ptyProvider = provider({ + listedRouteToken, + currentRouteToken: () => Object.freeze({}), + list: async () => [ + { + id: 'ssh:ssh-1@@pty-1', + incarnationId: 'incarnation-1', + cwd: '/remote', + title: 'shell' + } + ] + }) + + const result = await terminateListedSshTerminalSessions({ + targetId: 'ssh-1', + provider: ptyProvider, + trackedPtyIds: ['pty-1'], + leases: [] + }) + + expect(result).toMatchObject([{ relayPtyId: 'pty-1', status: 'rejected' }]) + expect(ptyProvider.killExact).not.toHaveBeenCalled() + }) + + it('rejects a replay whose provider is superseded after inventory', async () => { + let current = true + const ptyProvider = provider({ + list: async () => { + current = false + return [ + { + id: 'ssh:ssh-1@@pty-1', + incarnationId: 'incarnation-1', + terminalSessionAuthorityAccess: authorityAccess, + cwd: '/remote', + title: 'shell' + } + ] + } + }) + + await expect( + terminateListedSshTerminalSessions({ + targetId: 'ssh-1', + provider: ptyProvider, + trackedPtyIds: [], + leases: [lease({ terminalSessionAuthorityAccess: authorityAccess })], + isCurrent: () => current, + prepareAuthorityExitWait: vi.fn() + }) + ).rejects.toThrow('ssh_pty_termination_provider_superseded') + expect(ptyProvider.killAuthorityExact).not.toHaveBeenCalled() + expect(ptyProvider.killExact).not.toHaveBeenCalled() + }) + + it('rejects an authority kill before mutation when its outcome wait is unavailable', async () => { + const ptyProvider = provider({ + list: async () => [ + { + id: 'ssh:ssh-1@@pty-1', + incarnationId: 'incarnation-1', + terminalSessionAuthorityAccess: authorityAccess, + cwd: '/remote', + title: 'shell' + } + ] + }) + + const result = await terminateListedSshTerminalSessions({ + targetId: 'ssh-1', + provider: ptyProvider, + trackedPtyIds: ['pty-1'], + leases: [] + }) + + expect(result).toMatchObject([{ relayPtyId: 'pty-1', status: 'rejected' }]) + + expect( + (ptyProvider as unknown as { killAuthorityExact: ReturnType }) + .killAuthorityExact + ).not.toHaveBeenCalled() + expect(ptyProvider.killExact).not.toHaveBeenCalled() + }) + + it('marks an authority kill terminated only after its exact exit outcome', async () => { + const ptyProvider = provider({ + list: async () => [ + { + id: 'ssh:ssh-1@@pty-1', + incarnationId: 'incarnation-1', + terminalSessionAuthorityAccess: authorityAccess, + cwd: '/remote', + title: 'shell' + } + ] + }) + const cancelUnsent = vi.fn() + const dispose = vi.fn() + const prepareAuthorityExitWait = vi.fn(() => ({ + completion: Promise.resolve(true), + cancelUnsent, + dispose + })) + + await expect( + terminateListedSshTerminalSessions({ + targetId: 'ssh-1', + provider: ptyProvider, + trackedPtyIds: ['pty-1'], + leases: [], + prepareAuthorityExitWait + }) + ).resolves.toMatchObject([{ relayPtyId: 'pty-1', status: 'terminated' }]) + expect(prepareAuthorityExitWait).toHaveBeenCalledWith({ + targetId: 'ssh-1', + relayPtyId: 'pty-1', + appPtyId: 'ssh:ssh-1@@pty-1', + authorityAccess + }) + expect(ptyProvider.killAuthorityExact).toHaveBeenCalledWith( + 'ssh:ssh-1@@pty-1', + authorityAccess, + { immediate: true, keepHistory: false } + ) + expect(ptyProvider.killExact).not.toHaveBeenCalled() + expect(cancelUnsent).not.toHaveBeenCalled() + expect(dispose).toHaveBeenCalledOnce() + }) + + it('cancels exact close intent when an authority mutation is definitively rejected', async () => { + const ptyProvider = provider({ + list: async () => [ + { + id: 'ssh:ssh-1@@pty-1', + incarnationId: 'incarnation-1', + terminalSessionAuthorityAccess: authorityAccess, + cwd: '/remote', + title: 'shell' + } + ], + killAuthorityExact: () => false + }) + const cancelUnsent = vi.fn() + const dispose = vi.fn() + + await expect( + terminateListedSshTerminalSessions({ + targetId: 'ssh-1', + provider: ptyProvider, + trackedPtyIds: ['pty-1'], + leases: [], + prepareAuthorityExitWait: () => ({ + completion: Promise.resolve(true), + cancelUnsent, + dispose + }) + }) + ).resolves.toMatchObject([{ relayPtyId: 'pty-1', status: 'rejected' }]) + + expect(cancelUnsent).toHaveBeenCalledOnce() + expect(dispose).toHaveBeenCalledOnce() + }) + + it('retains exact close intent when authority mutation delivery is ambiguous', async () => { + const ptyProvider = provider({ + list: async () => [ + { + id: 'ssh:ssh-1@@pty-1', + incarnationId: 'incarnation-1', + terminalSessionAuthorityAccess: authorityAccess, + cwd: '/remote', + title: 'shell' + } + ], + killAuthorityExact: () => { + throw new Error('transport lost during mutation') + } + }) + const cancelUnsent = vi.fn() + const dispose = vi.fn() + + await expect( + terminateListedSshTerminalSessions({ + targetId: 'ssh-1', + provider: ptyProvider, + trackedPtyIds: ['pty-1'], + leases: [], + prepareAuthorityExitWait: () => ({ + completion: Promise.resolve(true), + cancelUnsent, + dispose + }) + }) + ).resolves.toMatchObject([ + { + relayPtyId: 'pty-1', + status: 'rejected', + error: expect.objectContaining({ message: 'transport lost during mutation' }) + } + ]) + + expect(cancelUnsent).not.toHaveBeenCalled() + expect(dispose).toHaveBeenCalledOnce() + }) + + it('keeps an accepted authority kill pending when its durable wait times out', async () => { + const ptyProvider = provider({ + list: async () => [ + { + id: 'ssh:ssh-1@@pty-1', + incarnationId: 'incarnation-1', + terminalSessionAuthorityAccess: authorityAccess, + cwd: '/remote', + title: 'shell' + } + ] + }) + const cancelUnsent = vi.fn() + const dispose = vi.fn() + + await expect( + terminateListedSshTerminalSessions({ + targetId: 'ssh-1', + provider: ptyProvider, + trackedPtyIds: ['pty-1'], + leases: [], + prepareAuthorityExitWait: () => ({ + completion: Promise.resolve(false), + cancelUnsent, + dispose + }) + }) + ).resolves.toMatchObject([{ relayPtyId: 'pty-1', status: 'acceptedPending' }]) + + expect(cancelUnsent).not.toHaveBeenCalled() + expect(dispose).toHaveBeenCalledOnce() + }) + + it('keeps an accepted authority kill pending when outcome delivery fails', async () => { + const ptyProvider = provider({ + list: async () => [ + { + id: 'ssh:ssh-1@@pty-1', + incarnationId: 'incarnation-1', + terminalSessionAuthorityAccess: authorityAccess, + cwd: '/remote', + title: 'shell' + } + ] + }) + const cancelUnsent = vi.fn() + const dispose = vi.fn() + + await expect( + terminateListedSshTerminalSessions({ + targetId: 'ssh-1', + provider: ptyProvider, + trackedPtyIds: ['pty-1'], + leases: [], + prepareAuthorityExitWait: () => ({ + completion: Promise.reject(new Error('transport lost before durable outcome')), + cancelUnsent, + dispose + }) + }) + ).resolves.toMatchObject([ + { + relayPtyId: 'pty-1', + status: 'acceptedPending', + error: expect.objectContaining({ message: 'transport lost before durable outcome' }) + } + ]) + expect(cancelUnsent).not.toHaveBeenCalled() + expect(dispose).toHaveBeenCalledOnce() + }) + + it('treats inventory absence as unknown without issuing a mutation', async () => { + const ptyProvider = provider({ list: async () => [] }) + + await expect( + terminateListedSshTerminalSessions({ + targetId: 'ssh-1', + provider: ptyProvider, + trackedPtyIds: [], + leases: [lease({ state: 'expired' })] + }) + ).resolves.toMatchObject([{ relayPtyId: 'pty-1', status: 'unknown' }]) + expect(ptyProvider.killExact).not.toHaveBeenCalled() + }) + + it('treats a not-found mutation response as unknown', async () => { + const ptyProvider = provider({ + list: async () => [ + { + id: 'ssh:ssh-1@@pty-1', + incarnationId: 'incarnation-1', + cwd: '/remote', + title: 'shell' + } + ], + killExact: async () => { + throw new Error('PTY "pty-1" not found') + } + }) + + await expect( + terminateListedSshTerminalSessions({ + targetId: 'ssh-1', + provider: ptyProvider, + trackedPtyIds: [], + leases: [lease()] + }) + ).resolves.toMatchObject([{ relayPtyId: 'pty-1', status: 'unknown' }]) + }) +}) diff --git a/src/main/ssh/ssh-terminal-session-termination.ts b/src/main/ssh/ssh-terminal-session-termination.ts new file mode 100644 index 00000000000..3d01bc536b4 --- /dev/null +++ b/src/main/ssh/ssh-terminal-session-termination.ts @@ -0,0 +1,263 @@ +import type { IPtyProvider, PtyProcessInfo } from '../providers/types' +import { killListedPty, listedPtyIncarnationId } from '../providers/pty-listed-session-kill' +import { isSshPtyNotFoundError } from '../providers/ssh-pty-errors' +import type { SshRemotePtyLease } from '../../shared/ssh-types' +import { toAppSshPtyId, toRelaySshPtyId } from '../../shared/ssh-pty-id' +import { + parseTerminalSessionAuthorityPtyAccess, + sameTerminalSessionAuthorityPtyAccess, + type TerminalSessionAuthorityPtyAccess +} from '../../shared/terminal-session-authority-pty-access' + +type SshTerminationCandidate = { + relayPtyId: string + appPtyId: string + expectedIncarnations: Set + expectedAuthorityAccesses: TerminalSessionAuthorityPtyAccess[] + keepHistory: boolean +} + +export type SshTerminalSessionTermination = Readonly<{ + relayPtyId: string + appPtyId: string + status: 'terminated' | 'acceptedPending' | 'unknown' | 'rejected' + error?: Error +}> + +export type SshTerminalAuthorityExitTarget = Readonly<{ + targetId: string + relayPtyId: string + appPtyId: string + authorityAccess: TerminalSessionAuthorityPtyAccess +}> + +export type SshTerminalAuthorityExitWait = Readonly<{ + completion: Promise + cancelUnsent: () => void + dispose: () => void +}> + +export async function terminateListedSshTerminalSessions(input: { + targetId: string + provider: IPtyProvider + trackedPtyIds: readonly string[] + leases: readonly SshRemotePtyLease[] + prepareAuthorityExitWait?: ( + target: SshTerminalAuthorityExitTarget + ) => SshTerminalAuthorityExitWait + isCurrent?: () => boolean + usePersistedCloseOptions?: boolean +}): Promise { + if (input.isCurrent?.() === false) { + throw new Error('ssh_pty_termination_provider_superseded') + } + const candidates = collectCandidates( + input.targetId, + input.trackedPtyIds, + input.leases, + input.usePersistedCloseOptions === true + ) + const inventory = indexInventory(input.targetId, await input.provider.listProcesses()) + if (input.isCurrent?.() === false) { + throw new Error('ssh_pty_termination_provider_superseded') + } + return await Promise.all( + [...candidates.values()].map(async (candidate) => { + const listed = inventory.get(candidate.relayPtyId) + if (listed === undefined) { + return unknown(candidate, 'ssh_pty_termination_inventory_missing') + } + if (listed === null || !candidateMatchesInventory(candidate, listed)) { + return rejected(candidate, 'ssh_pty_termination_identity_ambiguous') + } + const authorityAccess = + candidate.expectedAuthorityAccesses.length === 1 + ? candidate.expectedAuthorityAccesses[0] + : parseTerminalSessionAuthorityPtyAccess(listed.terminalSessionAuthorityAccess) + let exitWait: SshTerminalAuthorityExitWait | null = null + if (authorityAccess) { + if (!input.prepareAuthorityExitWait) { + return rejected(candidate, 'ssh_pty_termination_outcome_wait_unavailable') + } + try { + exitWait = input.prepareAuthorityExitWait({ + targetId: input.targetId, + ...candidateIds(candidate), + authorityAccess + }) + } catch (error) { + return rejected(candidate, error) + } + } + try { + if (input.isCurrent?.() === false) { + exitWait?.cancelUnsent() + return unknown(candidate, 'ssh_pty_termination_provider_superseded') + } + const accepted = await killListedPty( + input.provider, + authorityAccess + ? { + ...listed, + incarnationId: authorityAccess.binding.ptyIncarnationId, + terminalSessionAuthorityAccess: authorityAccess + } + : listed, + { + immediate: true, + keepHistory: candidate.keepHistory + } + ) + if (!accepted) { + exitWait?.cancelUnsent() + return rejected(candidate, 'ssh_pty_termination_identity_rejected') + } + if (!authorityAccess) { + return { ...candidateIds(candidate), status: 'terminated' as const } + } + let exited: boolean + try { + exited = (await exitWait?.completion) === true + } catch (error) { + return { + ...candidateIds(candidate), + status: 'acceptedPending' as const, + error: error instanceof Error ? error : new Error(String(error)) + } + } + return { + ...candidateIds(candidate), + status: exited ? ('terminated' as const) : ('acceptedPending' as const) + } + } catch (error) { + return isSshPtyNotFoundError(error) ? unknown(candidate, error) : rejected(candidate, error) + } finally { + exitWait?.dispose() + } + }) + ) +} + +function unknown( + candidate: SshTerminationCandidate, + error: unknown +): SshTerminalSessionTermination { + return { + ...candidateIds(candidate), + status: 'unknown', + error: error instanceof Error ? error : new Error(String(error)) + } +} + +function collectCandidates( + targetId: string, + trackedPtyIds: readonly string[], + leases: readonly SshRemotePtyLease[], + usePersistedCloseOptions: boolean +): Map { + const candidates = new Map() + const add = ( + ptyId: string, + incarnationId?: string, + authorityAccess?: TerminalSessionAuthorityPtyAccess, + keepHistory = false + ): void => { + const relayPtyId = toRelaySshPtyId(targetId, ptyId) + let candidate = candidates.get(relayPtyId) + if (!candidate) { + candidate = { + relayPtyId, + appPtyId: toAppSshPtyId(targetId, relayPtyId), + expectedIncarnations: new Set(), + expectedAuthorityAccesses: [], + keepHistory: false + } + candidates.set(relayPtyId, candidate) + } + if (incarnationId) { + candidate.expectedIncarnations.add(incarnationId) + } + candidate.keepHistory ||= keepHistory + const parsedAccess = parseTerminalSessionAuthorityPtyAccess(authorityAccess) + if ( + parsedAccess && + !candidate.expectedAuthorityAccesses.some((current) => + sameTerminalSessionAuthorityPtyAccess(current, parsedAccess) + ) + ) { + candidate.expectedAuthorityAccesses.push(parsedAccess) + } + } + for (const ptyId of trackedPtyIds) { + add(ptyId) + } + for (const lease of leases) { + if (lease.state !== 'terminated') { + add( + lease.ptyId, + lease.incarnationId, + lease.pendingClose?.terminalSessionAuthorityAccess ?? lease.terminalSessionAuthorityAccess, + usePersistedCloseOptions && lease.pendingClose?.keepHistory === true + ) + } + } + return candidates +} + +function indexInventory( + targetId: string, + sessions: readonly PtyProcessInfo[] +): Map { + const indexed = new Map() + for (const session of sessions) { + const relayPtyId = toRelaySshPtyId(targetId, session.id) + indexed.set(relayPtyId, indexed.has(relayPtyId) ? null : session) + } + return indexed +} + +function candidateMatchesInventory( + candidate: SshTerminationCandidate, + listed: PtyProcessInfo +): boolean { + if (candidate.expectedAuthorityAccesses.length > 0) { + const expectedAccess = candidate.expectedAuthorityAccesses[0] + const listedAccess = parseTerminalSessionAuthorityPtyAccess( + listed.terminalSessionAuthorityAccess + ) + if ( + candidate.expectedAuthorityAccesses.length !== 1 || + !expectedAccess || + !sameTerminalSessionAuthorityPtyAccess(listedAccess, expectedAccess) + ) { + return false + } + } + if (candidate.expectedIncarnations.size === 0) { + return true + } + const listedIncarnation = listedPtyIncarnationId(listed) + return ( + candidate.expectedIncarnations.size === 1 && + listedIncarnation !== null && + candidate.expectedIncarnations.has(listedIncarnation) + ) +} + +function rejected( + candidate: SshTerminationCandidate, + error: unknown +): SshTerminalSessionTermination { + return { + ...candidateIds(candidate), + status: 'rejected', + error: error instanceof Error ? error : new Error(String(error)) + } +} + +function candidateIds(candidate: SshTerminationCandidate): { + relayPtyId: string + appPtyId: string +} { + return { relayPtyId: candidate.relayPtyId, appPtyId: candidate.appPtyId } +} diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index aeaca19da4a..77237e18364 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -66,6 +66,7 @@ import { setWorktreeBaseDirectoryWatcherSyncContext } from '../ipc/worktree-base-directory-watcher' import { logStartupMilestone } from '../startup/startup-diagnostics' +import { registerMainWindowClosedCleanup } from './main-window-lifecycle' const UPDATER_SETUP_FALLBACK_MS = 15_000 @@ -216,7 +217,7 @@ export function attachMainWindowServices( } ) - mainWindow.on('closed', () => { + registerMainWindowClosedCleanup(mainWindow, () => { // Why: clear main-owned guest registrations on close so stale tab→webContents ids don't leak across relaunch/hot-reload. browserManager.unregisterAll() }) @@ -266,7 +267,7 @@ function registerTccPromptNoticeHandlers(mainWindow: BrowserWindow): void { } }) // Why: macOS can stay windowless; drop stale closures without letting an old close clear newer handlers. - mainWindow.on('closed', () => { + registerMainWindowClosedCleanup(mainWindow, () => { if (activeTccPromptHandlerToken !== handlerToken) { return } @@ -299,7 +300,7 @@ function registerAppReloadHandler( onBeforeRendererReload?.({ webContentsId: mainWebContents.id, ignoreCache: false }) mainWebContents.reload() }) - mainWindow.on('closed', () => { + registerMainWindowClosedCleanup(mainWindow, () => { if (activeAppReloadHandlerToken !== handlerToken) { return } @@ -491,7 +492,7 @@ function registerRuntimeWindowLifecycle( mainWindow.webContents.on('did-start-loading', () => { runtime.markRendererReloading(mainWindow.id) }) - mainWindow.on('closed', () => { + registerMainWindowClosedCleanup(mainWindow, () => { runtime.markGraphUnavailable(mainWindow.id) if (activeRuntimeNotifierToken === notifierToken) { // Why: the notifier closes over the window; clear it in the no-window gap so the runtime can't retain destroyed graphs. @@ -521,7 +522,7 @@ function registerFileDropRelay(mainWindow: BrowserWindow): void { mainWindow.webContents.send('terminal:file-drop', args) } ipcMain.on(channel, relayFileDrop) - mainWindow.on('closed', () => { + registerMainWindowClosedCleanup(mainWindow, () => { // Why: macOS keeps the process alive after window close; drop the closure so the destroyed window isn't retained. ipcMain.removeListener(channel, relayFileDrop) }) diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index 327772164f4..11045594f7b 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -58,6 +58,7 @@ import { closeDashboardPopout } from './dashboard-popout-window' import { installPrivilegedWindowNavigationPolicy } from './privileged-window-navigation' import { isMacosTahoeOrNewer } from './macos-tahoe-release' import { registerPluginPanelNavigationGuard } from '../plugins/plugin-panel-navigation-guard' +import { registerMainWindowClosedCleanup } from './main-window-lifecycle' // Why: show/restore/resume can overlap before the size nudge resets; never capture the temporary width as the next baseline. const activeRepaintJiggles = new WeakSet() @@ -150,7 +151,7 @@ function installMacosVisibilityRepaint(window: BrowserWindow): void { window.webContents.invalidate() } }) - window.on('closed', () => { + registerMainWindowClosedCleanup(window, () => { clearDelayedRepaint() ipcMain.removeListener('ui:window-revealed', onRendererRevealed) }) @@ -1118,7 +1119,7 @@ export function createMainWindow( ipcMain.on(confirmCloseChannel, onConfirmClose) ipcMain.on(closeRequestReceivedChannel, onCloseRequestReceived) - mainWindow.on('closed', () => { + registerMainWindowClosedCleanup(mainWindow, () => { // Why: the dashboard pop-out is a companion of the main window — close it // alongside so it never orphans as a lone window after the app window is // gone (e.g. on macOS where the app stays alive after the window closes). diff --git a/src/main/window/main-window-lifecycle.test.ts b/src/main/window/main-window-lifecycle.test.ts new file mode 100644 index 00000000000..3fb8d5cdd5b --- /dev/null +++ b/src/main/window/main-window-lifecycle.test.ts @@ -0,0 +1,129 @@ +import { EventEmitter } from 'node:events' +import { describe, expect, it, vi } from 'vitest' +import type { BrowserWindow } from 'electron' +import { registerMainWindowClosedCleanup } from './main-window-lifecycle' + +function createWindow(): EventEmitter & BrowserWindow { + return new EventEmitter() as EventEmitter & BrowserWindow +} + +describe('main-window lifecycle ownership', () => { + it('keeps one closed listener for all registered owners and cleans it up', () => { + const mainWindow = createWindow() + const cleanups = Array.from({ length: 11 }, () => vi.fn()) + + for (const cleanup of cleanups) { + registerMainWindowClosedCleanup(mainWindow, cleanup) + } + + expect(mainWindow.listenerCount('closed')).toBe(1) + mainWindow.emit('closed') + + expect(mainWindow.listenerCount('closed')).toBe(0) + for (const cleanup of cleanups) { + expect(cleanup).toHaveBeenCalledOnce() + } + }) + + it('removes an owner without affecting remaining cleanup callbacks', () => { + const mainWindow = createWindow() + const removed = vi.fn() + const retained = vi.fn() + + const unregister = registerMainWindowClosedCleanup(mainWindow, removed) + registerMainWindowClosedCleanup(mainWindow, retained) + unregister() + mainWindow.emit('closed') + + expect(removed).not.toHaveBeenCalled() + expect(retained).toHaveBeenCalledOnce() + expect(mainWindow.listenerCount('closed')).toBe(0) + }) + + it('reattaches one listener when a new owner arrives after the last owner leaves', () => { + const mainWindow = createWindow() + const first = registerMainWindowClosedCleanup(mainWindow, vi.fn()) + + first() + expect(mainWindow.listenerCount('closed')).toBe(0) + + registerMainWindowClosedCleanup(mainWindow, vi.fn()) + expect(mainWindow.listenerCount('closed')).toBe(1) + }) + + it('runs late and already-destroyed registrations immediately without a dead listener', () => { + const closedWindow = createWindow() + registerMainWindowClosedCleanup(closedWindow, vi.fn()) + closedWindow.emit('closed') + const lateCleanup = vi.fn() + + registerMainWindowClosedCleanup(closedWindow, lateCleanup) + + expect(lateCleanup).toHaveBeenCalledOnce() + expect(closedWindow.listenerCount('closed')).toBe(0) + + const destroyedWindow = createWindow() as EventEmitter & BrowserWindow + destroyedWindow.isDestroyed = vi.fn(() => true) + const destroyedCleanup = vi.fn() + + registerMainWindowClosedCleanup(destroyedWindow, destroyedCleanup) + + expect(destroyedCleanup).toHaveBeenCalledOnce() + expect(destroyedWindow.listenerCount('closed')).toBe(0) + }) + + it('removes an existing listener when destruction is observed during late registration', () => { + const mainWindow = createWindow() as EventEmitter & BrowserWindow + const isDestroyed = vi.fn(() => false) + mainWindow.isDestroyed = isDestroyed + const existingCleanup = vi.fn() + const lateCleanup = vi.fn() + registerMainWindowClosedCleanup(mainWindow, existingCleanup) + + isDestroyed.mockReturnValue(true) + registerMainWindowClosedCleanup(mainWindow, lateCleanup) + + expect(existingCleanup).toHaveBeenCalledOnce() + expect(lateCleanup).toHaveBeenCalledOnce() + expect(mainWindow.listenerCount('closed')).toBe(0) + }) + + it('runs reentrant registrations immediately and keeps the closed listener detached', () => { + const mainWindow = createWindow() + const reentrant = vi.fn() + const first = vi.fn(() => registerMainWindowClosedCleanup(mainWindow, reentrant)) + registerMainWindowClosedCleanup(mainWindow, first) + + mainWindow.emit('closed') + + expect(first).toHaveBeenCalledOnce() + expect(reentrant).toHaveBeenCalledOnce() + expect(mainWindow.listenerCount('closed')).toBe(0) + }) + + it('runs every cleanup and surfaces all cleanup failures', () => { + const mainWindow = createWindow() + const firstFailure = new Error('first cleanup failed') + const secondFailure = new Error('second cleanup failed') + const retained = vi.fn() + registerMainWindowClosedCleanup(mainWindow, () => { + throw firstFailure + }) + registerMainWindowClosedCleanup(mainWindow, retained) + registerMainWindowClosedCleanup(mainWindow, () => { + throw secondFailure + }) + + let thrown: unknown + try { + mainWindow.emit('closed') + } catch (error) { + thrown = error + } + + expect(retained).toHaveBeenCalledOnce() + expect(thrown).toBeInstanceOf(AggregateError) + expect((thrown as AggregateError).errors).toEqual([firstFailure, secondFailure]) + expect(mainWindow.listenerCount('closed')).toBe(0) + }) +}) diff --git a/src/main/window/main-window-lifecycle.ts b/src/main/window/main-window-lifecycle.ts new file mode 100644 index 00000000000..9a23ec00cc4 --- /dev/null +++ b/src/main/window/main-window-lifecycle.ts @@ -0,0 +1,94 @@ +import type { BrowserWindow } from 'electron' + +type MainWindowLifecycle = { + closed: boolean + listening: boolean + cleanups: Set<() => void> + onClosed: () => void +} + +const lifecycleByWindow = new WeakMap() + +function removeClosedListener(mainWindow: BrowserWindow, onClosed: () => void): void { + mainWindow.removeListener?.('closed', onClosed) +} + +function isDestroyed(mainWindow: BrowserWindow): boolean { + return typeof mainWindow.isDestroyed === 'function' && mainWindow.isDestroyed() +} + +function surfaceCleanupFailures(failures: unknown[]): void { + if (failures.length === 0) { + return + } + if (failures.length === 1) { + throw failures[0] + } + throw new AggregateError(failures, 'main window closed cleanup failed') +} + +function closeMainWindowLifecycle( + mainWindow: BrowserWindow, + lifecycle: MainWindowLifecycle +): unknown[] { + if (lifecycle.closed) { + return [] + } + lifecycle.closed = true + lifecycle.listening = false + removeClosedListener(mainWindow, lifecycle.onClosed) + const cleanups = [...lifecycle.cleanups] + lifecycle.cleanups.clear() + const failures: unknown[] = [] + for (const registeredCleanup of cleanups) { + try { + registeredCleanup() + } catch (error) { + failures.push(error) + } + } + return failures +} + +/** Registers main-window teardown under the single BrowserWindow `closed` owner. */ +export function registerMainWindowClosedCleanup( + mainWindow: BrowserWindow, + cleanup: () => void +): () => void { + let lifecycle = lifecycleByWindow.get(mainWindow) + if (!lifecycle) { + const onClosed = (): void => { + surfaceCleanupFailures(closeMainWindowLifecycle(mainWindow, lifecycle!)) + } + lifecycle = { closed: false, listening: false, cleanups: new Set(), onClosed } + lifecycleByWindow.set(mainWindow, lifecycle) + } + + if (lifecycle.closed || isDestroyed(mainWindow)) { + const failures = closeMainWindowLifecycle(mainWindow, lifecycle) + try { + cleanup() + } catch (error) { + failures.push(error) + } + surfaceCleanupFailures(failures) + return () => {} + } + + lifecycle.cleanups.add(cleanup) + if (!lifecycle.listening) { + lifecycle.listening = true + mainWindow.on('closed', lifecycle.onClosed) + } + return () => { + const current = lifecycleByWindow.get(mainWindow) + if (!current || current.closed) { + return + } + current.cleanups.delete(cleanup) + if (current.cleanups.size === 0) { + removeClosedListener(mainWindow, current.onClosed) + current.listening = false + } + } +} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 34d102bcec9..455b94d4ef7 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -288,6 +288,22 @@ import type { } from '../shared/types' import type { PtyModelRestoreNeededEvent } from '../shared/pty-model-restore-marker' import type { PtyListedSession } from '../shared/pty-listed-session' +import type { + TerminalAuthorityAppBellClearRequest, + TerminalAuthorityAppProjectionDelta, + TerminalAuthorityAppProjectionSnapshot, + TerminalAuthorityAppProjectionSubscribe +} from '../shared/terminal-authority-app-projection' +import type { + PtyAdministrativeMutationAccess, + PtyMutationAccess, + PtyMutationClaimant, + PtyMutationIdentity +} from '../shared/pty-mutation-identity' +import type { + PtyRendererBindingCancel, + PtyRendererBindingReady +} from '../shared/pty-renderer-binding' import type { PtyRendererDeliveryHealthReply, PtyRendererDeliveryStateReport @@ -1537,6 +1553,8 @@ export type PreloadApi = { // Why: main sync-flushes the (worktreeId,tabId,leafId→ptyId) binding before pty:spawn returns to close a SIGKILL race (INVESTIGATION.md). tabId?: string leafId?: string + tabGeneration?: number + mutationClaimant?: PtyMutationClaimant // Why: main fires `agent_started` only on spawn success, so launch metadata rides this field (telemetry-plan.md §Agent launch semantics). telemetry?: { agent_kind: AgentKind; launch_source: LaunchSource; request_kind: RequestKind } }) => Promise<{ @@ -1553,16 +1571,62 @@ export type PreloadApi = { coldRestore?: { scrollback: string; cwd: string; cols?: number; rows?: number } startupCwdFallback?: { kind: 'worktree'; cwd: string } agentResumeUnavailable?: true + mutationAccess?: PtyMutationAccess + administrativeMutationAccess?: PtyAdministrativeMutationAccess }> - write: (id: string, data: string) => void - writeAccepted: (id: string, data: string) => Promise + claimMutationAccess: (args: { + id: string + tabId: string + leafId: string + paneGeneration: number + claimant: PtyMutationClaimant + }) => Promise + rendererBindingReady: (args: PtyRendererBindingReady) => void + cancelRendererBinding: (args: PtyRendererBindingCancel) => void + captureAdministrativeMutationAccess?: ( + ids: string[] + ) => Promise<{ id: string; access: PtyAdministrativeMutationAccess }[]> + write: (id: string, data: string, mutationIdentity?: PtyMutationIdentity) => void + writeAccepted: ( + id: string, + data: string, + mutationIdentity?: PtyMutationIdentity + ) => Promise + administrativeWriteCurrent?: (id: string, data: string) => void + administrativeWriteCurrentAccepted?: (id: string, data: string) => Promise + administrativeWriteImmediateCurrent?: (id: string, data: string) => void + administrativeWriteImmediateCurrentAccepted?: (id: string, data: string) => Promise + administrativeWrite?: ( + id: string, + data: string, + access: PtyAdministrativeMutationAccess + ) => void + administrativeWriteAccepted?: ( + id: string, + data: string, + access: PtyAdministrativeMutationAccess + ) => Promise onWriteUnavailable?: (callback: (payload: { id: string }) => void) => () => void - resize: (id: string, cols: number, rows: number) => void - claimViewport: (id: string, cols: number, rows: number) => void + resize: (id: string, cols: number, rows: number, mutationIdentity?: PtyMutationIdentity) => void + claimViewport: ( + id: string, + cols: number, + rows: number, + mutationIdentity?: PtyMutationIdentity + ) => void reportGeometry: (id: string, cols: number, rows: number) => void - signal: (id: string, signal: string) => void - clearBuffer: (id: string) => void - kill: (id: string, opts?: { keepHistory?: boolean }) => Promise + signal: (id: string, signal: string, mutationIdentity?: PtyMutationIdentity) => void + clearBuffer: (id: string, mutationIdentity?: PtyMutationIdentity) => void + kill: ( + id: string, + opts?: { keepHistory?: boolean; mutationIdentity?: PtyMutationIdentity } + ) => Promise + administrativeKillCurrent?: (id: string, opts?: { keepHistory?: boolean }) => Promise + administrativeKill?: ( + id: string, + access: PtyAdministrativeMutationAccess, + opts?: { keepHistory?: boolean } + ) => Promise ackColdRestore: (id: string) => void ackData: (id: string, charCount: number, processedChars?: number) => void onDeliveryResyncRequest: (callback: (payload: { requestId: number }) => void) => () => void @@ -1675,10 +1739,25 @@ export type PreloadApi = { /** Batched derived side-effect facts for PTYs whose bytes transit local * main. */ onSideEffect: (callback: (batch: TerminalSideEffectBatch) => void) => () => void + /** Install before subscribe so deltas racing the snapshot response are retained. */ + onAuthorityProjection: ( + callback: (delta: TerminalAuthorityAppProjectionDelta) => void + ) => () => void + subscribeAuthorityProjection: ( + request: TerminalAuthorityAppProjectionSubscribe + ) => Promise + clearAuthorityProjectionBell: ( + request: TerminalAuthorityAppBellClearRequest + ) => Promise /** Title-only replay snapshot for (re)attach; attention facts never replay. */ getSideEffectSnapshot: (id: string) => Promise onExit: ( - callback: (data: { id: string; code: number; preserveRendererBinding?: boolean }) => void + callback: (data: { + id: string + code: number + incarnationId?: string + preserveRendererBinding?: boolean + }) => void ) => () => void onSpawned: (callback: (data: { id: string }) => void) => () => void onSerializeBufferRequest: ( @@ -1688,7 +1767,9 @@ export type PreloadApi = { opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } }) => void ) => () => void - onClearBufferRequest: (callback: (data: { ptyId: string }) => void) => () => void + onClearBufferRequest: ( + callback: (data: { ptyId: string; incarnationId?: string; paneGeneration?: number }) => void + ) => () => void sendSerializedBuffer: ( requestId: string, snapshot: { @@ -3709,6 +3790,11 @@ export type PreloadApi = { revokeDevice: (args: { deviceId: string }) => Promise<{ revoked: boolean }> listRuntimeAccessGrants: () => Promise<{ grants: RuntimeAccessGrant[] }> revokeRuntimeAccess: (args: { deviceId: string }) => Promise<{ revoked: boolean }> + getIdentityResetStatus: () => Promise<{ + inProgress: boolean + record: { transactionId: string; phase: string } | null + }> + resetIdentity: () => Promise<{ phase: 're-enrollment'; transactionId: string }> isWebSocketReady: () => Promise<{ ready: boolean; endpoint: string | null }> getRelayStatus: () => Promise<{ status: MobileRelayStatus }> onRelayStatusChanged: (callback: (status: MobileRelayStatus) => void) => () => void diff --git a/src/preload/index.ts b/src/preload/index.ts index ea32e22e182..b7c278810d1 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -109,6 +109,29 @@ import type { } from '../shared/types' import type { PtyModelRestoreNeededEvent } from '../shared/pty-model-restore-marker' import type { PtyListedSession } from '../shared/pty-listed-session' +import { + TERMINAL_AUTHORITY_APP_PROJECTION_CLEAR_BELL, + TERMINAL_AUTHORITY_APP_PROJECTION_EVENT, + TERMINAL_AUTHORITY_APP_PROJECTION_SUBSCRIBE, + type TerminalAuthorityAppBellClearRequest, + type TerminalAuthorityAppProjectionDelta, + type TerminalAuthorityAppProjectionSnapshot, + type TerminalAuthorityAppProjectionSubscribe +} from '../shared/terminal-authority-app-projection' +import { + parseTerminalAuthorityAppProjectionDelta, + parseTerminalAuthorityAppProjectionSnapshot +} from '../shared/terminal-authority-app-projection-validation' +import type { + PtyAdministrativeMutationAccess, + PtyMutationAccess, + PtyMutationClaimant, + PtyMutationIdentity +} from '../shared/pty-mutation-identity' +import type { + PtyRendererBindingCancel, + PtyRendererBindingReady +} from '../shared/pty-renderer-binding' import type { PtyRendererDeliveryHealthReply, PtyRendererDeliveryStateReport @@ -945,6 +968,8 @@ const api = { // Why: closes the SIGKILL race (INVESTIGATION.md) — main sync-flushes the (worktreeId, tabId, leafId → ptyId) binding before pty:spawn returns. tabId?: string leafId?: string + tabGeneration?: number + mutationClaimant?: PtyMutationClaimant // Why: loose typing on purpose — renderer owns launch metadata, main owns whether the launch happened and validates (telemetry-plan.md §Agent launch semantics). telemetry?: { agent_kind: AgentKind; launch_source: LaunchSource; request_kind: RequestKind } }): Promise<{ @@ -960,13 +985,59 @@ const api = { coldRestore?: { scrollback: string; cwd: string; cols?: number; rows?: number } startupCwdFallback?: { kind: 'worktree'; cwd: string } agentResumeUnavailable?: true + mutationAccess?: PtyMutationAccess + administrativeMutationAccess?: PtyAdministrativeMutationAccess }> => ipcRenderer.invoke('pty:spawn', opts), - write: (id: string, data: string): void => { - ipcRenderer.send('pty:write', { id, data }) + claimMutationAccess: (args: { + id: string + tabId: string + leafId: string + paneGeneration: number + claimant: PtyMutationClaimant + }): Promise => ipcRenderer.invoke('pty:claimMutationAccess', args), + rendererBindingReady: (args: PtyRendererBindingReady): void => { + ipcRenderer.send('pty:rendererBindingReady', args) }, - writeAccepted: (id: string, data: string): Promise => - ipcRenderer.invoke('pty:writeAccepted', { id, data }), + cancelRendererBinding: (args: PtyRendererBindingCancel): void => { + ipcRenderer.send('pty:cancelRendererBinding', args) + }, + captureAdministrativeMutationAccess: ( + ids: string[] + ): Promise<{ id: string; access: PtyAdministrativeMutationAccess }[]> => + ipcRenderer.invoke('pty:captureAdministrativeMutationAccess', { ids }), + + write: (id: string, data: string, mutationIdentity?: PtyMutationIdentity): void => { + ipcRenderer.send('pty:write', { id, data, mutationIdentity }) + }, + writeAccepted: ( + id: string, + data: string, + mutationIdentity?: PtyMutationIdentity + ): Promise => ipcRenderer.invoke('pty:writeAccepted', { id, data, mutationIdentity }), + administrativeWriteCurrent: (id: string, data: string): void => { + ipcRenderer.send('pty:write', { id, data, administrativeCurrent: true }) + }, + administrativeWriteCurrentAccepted: (id: string, data: string): Promise => + ipcRenderer.invoke('pty:writeAccepted', { id, data, administrativeCurrent: true }), + administrativeWriteImmediateCurrent: (id: string, data: string): void => { + ipcRenderer.send('pty:write', { id, data, administrativeImmediateCurrent: true }) + }, + administrativeWriteImmediateCurrentAccepted: (id: string, data: string): Promise => + ipcRenderer.invoke('pty:writeAccepted', { id, data, administrativeImmediateCurrent: true }), + administrativeWrite: ( + id: string, + data: string, + access: PtyAdministrativeMutationAccess + ): void => { + ipcRenderer.send('pty:write', { id, data, administrativeAccess: access }) + }, + administrativeWriteAccepted: ( + id: string, + data: string, + access: PtyAdministrativeMutationAccess + ): Promise => + ipcRenderer.invoke('pty:writeAccepted', { id, data, administrativeAccess: access }), onWriteUnavailable: (callback: (payload: { id: string }) => void): (() => void) => { const handler = (_event: Electron.IpcRendererEvent, payload: { id: string }): void => callback(payload) @@ -974,11 +1045,21 @@ const api = { return () => ipcRenderer.removeListener('pty:writeUnavailable', handler) }, - resize: (id: string, cols: number, rows: number): void => { - ipcRenderer.send('pty:resize', { id, cols, rows }) + resize: ( + id: string, + cols: number, + rows: number, + mutationIdentity?: PtyMutationIdentity + ): void => { + ipcRenderer.send('pty:resize', { id, cols, rows, mutationIdentity }) }, - claimViewport: (id: string, cols: number, rows: number): void => { - ipcRenderer.send('pty:claimViewport', { id, cols, rows }) + claimViewport: ( + id: string, + cols: number, + rows: number, + mutationIdentity?: PtyMutationIdentity + ): void => { + ipcRenderer.send('pty:claimViewport', { id, cols, rows, mutationIdentity }) }, /** Why: measurement-only sibling of resize — keeps the runtime's restore-target baseline fresh while a mobile-fit override blocks pty:resize. Never resizes the PTY. See docs/mobile-fit-hold.md. */ @@ -986,13 +1067,13 @@ const api = { ipcRenderer.send('pty:reportGeometry', { id, cols, rows }) }, - signal: (id: string, signal: string): void => { - ipcRenderer.send('pty:signal', { id, signal }) + signal: (id: string, signal: string, mutationIdentity?: PtyMutationIdentity): void => { + ipcRenderer.send('pty:signal', { id, signal, mutationIdentity }) }, /** Why: Cmd/Ctrl+K clears the renderer xterm, but the PTY host keeps its own screen state and would repaint the next prompt at the stale cursor row. */ - clearBuffer: (id: string): void => { - ipcRenderer.send('pty:clearBuffer', { id }) + clearBuffer: (id: string, mutationIdentity?: PtyMutationIdentity): void => { + ipcRenderer.send('pty:clearBuffer', { id, mutationIdentity }) }, ackColdRestore: (id: string): void => { @@ -1048,8 +1129,31 @@ const api = { ipcRenderer.send('pty:terminalViewAttributes', attributes) }, - kill: (id: string, opts?: { keepHistory?: boolean }): Promise => - ipcRenderer.invoke('pty:kill', { id, keepHistory: opts?.keepHistory ?? false }), + kill: ( + id: string, + opts?: { keepHistory?: boolean; mutationIdentity?: PtyMutationIdentity } + ): Promise => + ipcRenderer.invoke('pty:kill', { + id, + keepHistory: opts?.keepHistory ?? false, + mutationIdentity: opts?.mutationIdentity + }), + administrativeKillCurrent: (id: string, opts?: { keepHistory?: boolean }): Promise => + ipcRenderer.invoke('pty:kill', { + id, + keepHistory: opts?.keepHistory ?? false, + administrativeCurrent: true + }), + administrativeKill: ( + id: string, + access: PtyAdministrativeMutationAccess, + opts?: { keepHistory?: boolean } + ): Promise => + ipcRenderer.invoke('pty:kill', { + id, + keepHistory: opts?.keepHistory ?? false, + administrativeAccess: access + }), listSessions: (): Promise => ipcRenderer.invoke('pty:listSessions'), getAuthoritativeBufferSnapshotCapabilities: ( @@ -1181,15 +1285,56 @@ const api = { return () => ipcRenderer.removeListener('pty:sideEffect', listener) }, + onAuthorityProjection: ( + callback: (delta: TerminalAuthorityAppProjectionDelta) => void + ): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, value: unknown): void => { + const delta = parseTerminalAuthorityAppProjectionDelta(value) + if (delta) { + callback(delta) + } + } + ipcRenderer.on(TERMINAL_AUTHORITY_APP_PROJECTION_EVENT, listener) + return () => ipcRenderer.removeListener(TERMINAL_AUTHORITY_APP_PROJECTION_EVENT, listener) + }, + + subscribeAuthorityProjection: async ( + request: TerminalAuthorityAppProjectionSubscribe + ): Promise => { + const value = await ipcRenderer.invoke(TERMINAL_AUTHORITY_APP_PROJECTION_SUBSCRIBE, request) + const snapshot = parseTerminalAuthorityAppProjectionSnapshot(value) + if (!snapshot) { + throw new Error('terminal_authority_projection_snapshot_invalid') + } + return snapshot + }, + + clearAuthorityProjectionBell: ( + request: TerminalAuthorityAppBellClearRequest + ): Promise => + ipcRenderer.invoke(TERMINAL_AUTHORITY_APP_PROJECTION_CLEAR_BELL, request), + /** Title-only replay snapshot on (re)attach — attention facts (bells/completions) never replay. */ getSideEffectSnapshot: (id: string): Promise => ipcRenderer.invoke('pty:sideEffectSnapshot', { id }), onExit: ( - callback: (data: { id: string; code: number; preserveRendererBinding?: boolean }) => void + callback: (data: { + id: string + code: number + incarnationId?: string + preserveRendererBinding?: boolean + }) => void ): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: { id: string; code: number }) => - callback(data) + const listener = ( + _event: Electron.IpcRendererEvent, + data: { + id: string + code: number + incarnationId?: string + preserveRendererBinding?: boolean + } + ) => callback(data) ipcRenderer.on('pty:exit', listener) return () => ipcRenderer.removeListener('pty:exit', listener) }, @@ -1219,9 +1364,13 @@ const api = { return () => ipcRenderer.removeListener('pty:serializeBuffer:request', listener) }, - onClearBufferRequest: (callback: (data: { ptyId: string }) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: { ptyId: string }) => - callback(data) + onClearBufferRequest: ( + callback: (data: { ptyId: string; incarnationId?: string; paneGeneration?: number }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { ptyId: string; incarnationId?: string; paneGeneration?: number } + ) => callback(data) ipcRenderer.on('pty:clearBuffer:request', listener) return () => ipcRenderer.removeListener('pty:clearBuffer:request', listener) }, @@ -4727,6 +4876,11 @@ const api = { revokeRuntimeAccess: (args: { deviceId: string }): Promise<{ revoked: boolean }> => ipcRenderer.invoke('mobile:revokeRuntimeAccess', args), + getIdentityResetStatus: () => ipcRenderer.invoke('mobile:getIdentityResetStatus'), + + resetIdentity: (): Promise<{ phase: 're-enrollment'; transactionId: string }> => + ipcRenderer.invoke('mobile:resetIdentity'), + isWebSocketReady: (): Promise<{ ready: boolean; endpoint: string | null }> => ipcRenderer.invoke('mobile:isWebSocketReady'), diff --git a/src/preload/pty-authority-outcome-wiring.test.ts b/src/preload/pty-authority-outcome-wiring.test.ts new file mode 100644 index 00000000000..8ca48613227 --- /dev/null +++ b/src/preload/pty-authority-outcome-wiring.test.ts @@ -0,0 +1,139 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { PreloadApi } from './api-types' +import { + TERMINAL_AUTHORITY_APP_PROJECTION_CLEAR_BELL, + TERMINAL_AUTHORITY_APP_PROJECTION_EVENT, + TERMINAL_AUTHORITY_APP_PROJECTION_SUBSCRIBE, + TERMINAL_AUTHORITY_APP_PROJECTION_VERSION, + type TerminalAuthorityAppPaneProjection +} from '../shared/terminal-authority-app-projection' + +const { exposeInMainWorld, invoke, on, removeListener, send, sendSync } = vi.hoisted(() => ({ + exposeInMainWorld: vi.fn(), + invoke: vi.fn(), + on: vi.fn(), + removeListener: vi.fn(), + send: vi.fn(), + sendSync: vi.fn() +})) + +vi.mock('electron', () => ({ + contextBridge: { exposeInMainWorld }, + ipcRenderer: { invoke, on, removeListener, send, sendSync }, + webFrame: { + getZoomFactor: vi.fn(() => 1), + setZoomFactor: vi.fn(), + setVisualZoomLevelLimits: vi.fn() + }, + webUtils: { getPathForFile: vi.fn(() => '') } +})) + +vi.mock('@electron-toolkit/preload', () => ({ electronAPI: {} })) + +describe('terminal authority projection preload wiring', () => { + const originalContextIsolated = Object.getOwnPropertyDescriptor(process, 'contextIsolated') + + beforeEach(() => { + vi.resetModules() + for (const mock of [exposeInMainWorld, invoke, on, removeListener, send, sendSync]) { + mock.mockReset() + } + Object.defineProperty(process, 'contextIsolated', { configurable: true, value: true }) + vi.stubGlobal('window', { + addEventListener: vi.fn(), + dispatchEvent: vi.fn(), + removeEventListener: vi.fn() + }) + vi.stubGlobal('document', { addEventListener: vi.fn() }) + }) + + afterEach(() => { + vi.unstubAllGlobals() + if (originalContextIsolated) { + Object.defineProperty(process, 'contextIsolated', originalContextIsolated) + } else { + Reflect.deleteProperty(process, 'contextIsolated') + } + }) + + it('validates projection deltas and snapshots and awaits bell-clear persistence', async () => { + const row = projectionRow() + const snapshot = { + version: TERMINAL_AUTHORITY_APP_PROJECTION_VERSION, + subscriptionIncarnationId: 'renderer-1', + rows: [row] + } + invoke.mockImplementation((channel: string) => { + if (channel === TERMINAL_AUTHORITY_APP_PROJECTION_SUBSCRIBE) { + return Promise.resolve(snapshot) + } + if (channel === TERMINAL_AUTHORITY_APP_PROJECTION_CLEAR_BELL) { + return Promise.resolve(true) + } + return Promise.resolve() + }) + await import('./index') + const api = exposeInMainWorld.mock.calls.find(([name]) => name === 'api')?.[1] as PreloadApi + + const callback = vi.fn() + const unsubscribe = api.pty.onAuthorityProjection(callback) + const listener = on.mock.calls.find( + ([channel]) => channel === TERMINAL_AUTHORITY_APP_PROJECTION_EVENT + )?.[1] as (event: unknown, value: unknown) => void + listener({}, { ...snapshot, rows: [{ ...row, status: { ...row.status, pane: 'closed' } }] }) + listener({}, snapshot) + expect(callback).toHaveBeenCalledExactlyOnceWith(snapshot) + unsubscribe() + expect(removeListener).toHaveBeenCalledWith(TERMINAL_AUTHORITY_APP_PROJECTION_EVENT, listener) + + const request = { + version: TERMINAL_AUTHORITY_APP_PROJECTION_VERSION, + subscriptionIncarnationId: 'renderer-1', + expectedSubscriptionIncarnationId: null + } + await expect(api.pty.subscribeAuthorityProjection(request)).resolves.toEqual(snapshot) + const clear = { + version: TERMINAL_AUTHORITY_APP_PROJECTION_VERSION, + consumerId: row.consumerId, + namespace: row.namespace, + pane: row.pane, + expectedEvent: { + consumerId: row.consumerId, + namespace: row.namespace, + sequence: 1, + outcomeId: 'bell-1' + } + } + await expect(api.pty.clearAuthorityProjectionBell(clear)).resolves.toBe(true) + expect(invoke).toHaveBeenLastCalledWith(TERMINAL_AUTHORITY_APP_PROJECTION_CLEAR_BELL, clear) + }) +}) + +function projectionRow(): TerminalAuthorityAppPaneProjection { + const binding = { + ownerIncarnationId: 'owner-1', + physicalPtyId: 'pty-1', + ptyIncarnationId: 'pty-incarnation-1' + } + return { + version: TERMINAL_AUTHORITY_APP_PROJECTION_VERSION, + consumerId: 'app-profile:test', + namespace: { authorityHostId: 'host-1', namespaceId: 'namespace-1' }, + pane: { + paneKey: 'tab-1:11111111-1111-4111-8111-111111111111', + paneGenerationId: 'pane-generation-1' + }, + layout: { tabId: 'tab-1', leafId: '11111111-1111-4111-8111-111111111111' }, + binding, + topology: { + status: 'open', + binding, + lastBinding: binding, + authorityRevision: 1, + ownerStatus: 'reachable' + }, + attention: { event: null, pendingBellCount: 0, updatedAt: 1 }, + status: { event: null, pane: 'open', agent: null, attention: false, updatedAt: 1 }, + facts: {} + } +} diff --git a/src/relay/__tests__/pty-handler-terminal-session-authority-fixture.ts b/src/relay/__tests__/pty-handler-terminal-session-authority-fixture.ts new file mode 100644 index 00000000000..28704196091 --- /dev/null +++ b/src/relay/__tests__/pty-handler-terminal-session-authority-fixture.ts @@ -0,0 +1,263 @@ +import type { IPty } from 'node-pty' +import { vi, type Mock } from 'vitest' +import type { TerminalAuthorityPolicyConsumerConnection } from '../../main/session-authority/terminal-session-authority-policy-consumers' +import type { TerminalSessionAuthorityPtyAccess } from '../../shared/terminal-session-authority-pty-access' + +export const authorityNamespace = { authorityHostId: 'host-a', namespaceId: 'namespace-a' } + +export function authorityPolicyConsumer(): TerminalAuthorityPolicyConsumerConnection { + return Object.freeze({ + identity: Object.freeze({ + consumerId: 'app-profile:fixture', + consumerIncarnationId: 'app-process:fixture' + }), + activate: async () => {}, + ensureNamespace: async () => {}, + assertInstalled: () => {}, + acknowledge: async (ack) => ack.sequence, + retire: async () => 0, + isInstalled: () => true, + disconnect: () => {} + }) +} + +type RequestContext = { + clientId?: number + isStale: () => boolean + signal?: AbortSignal +} + +type RequestHandler = ( + params: Record, + context?: RequestContext +) => Promise + +type NotificationHandler = (params: Record, context?: RequestContext) => void + +type AuthorityDispatcherFixture = { + onRequest: (method: string, handler: RequestHandler) => void + onNotification: (method: string, handler: NotificationHandler) => void + notify: Mock<(method: string, params?: Record) => void> + activeClientIds: () => number[] + callRequest: ( + method: string, + params: Record, + context?: RequestContext + ) => Promise + callNotification: ( + method: string, + params: Record, + context?: RequestContext + ) => void +} + +type AuthorityTermFixture = Pick & { + onData: Mock + onExit: Mock + write: Mock + resize: Mock + kill: Mock + clear: Mock + pause: Mock<() => number> + resume: Mock<() => number> +} + +type AuthorityRuntimeFixture = { service: { namespace: typeof authorityNamespace } } +type AuthorityPaneFixture = { paneKey: string; paneGenerationId: string } +type AuthorityBindingFixture = { + ownerIncarnationId: string + physicalPtyId: string + ptyIncarnationId: string +} +type AuthorityPreparedSpawnFixture = { + kind: 'spawn' + runtime: AuthorityRuntimeFixture + pane: Record + allocation: Record +} +type AuthorityAdoptedSpawnFixture = { + kind: 'adopt' + runtime: AuthorityRuntimeFixture + pane: AuthorityPaneFixture + binding: AuthorityBindingFixture +} +type AuthorityManagedPtyFixture = Omit + +type AuthorityLifecycleFixture = { + prepareSpawn: Mock< + ( + params: Record, + physicalPtyId: string, + operationId?: string + ) => Promise + > + commitSpawn: Mock< + ( + prepared: AuthorityPreparedSpawnFixture, + incarnationId: string + ) => Promise + > + cancelSpawn: Mock<(prepared: AuthorityPreparedSpawnFixture) => void> + closePty: Mock<(managed: AuthorityManagedPtyFixture, operationId?: string) => Promise> + closeExactPtyAccess: Mock< + (access: TerminalSessionAuthorityPtyAccess, operationId?: string) => Promise + > + recordExit: Mock<(managed: AuthorityManagedPtyFixture, code: number | null) => Promise> + missingPtyState: Mock< + (params: Record, physicalPtyId: string) => Promise<{ kind: 'unknown' }> + > + bindingIsReachable: Mock<(managed: AuthorityManagedPtyFixture) => boolean> + managedFromAdoption: Mock<(adopted: AuthorityAdoptedSpawnFixture) => AuthorityManagedPtyFixture> +} + +export function createAuthorityDispatcher(): AuthorityDispatcherFixture { + const requests = new Map() + const notifications = new Map() + return { + onRequest: (method: string, handler: RequestHandler) => requests.set(method, handler), + onNotification: (method: string, handler: NotificationHandler) => + notifications.set(method, handler), + notify: vi.fn(), + activeClientIds: () => [1], + callRequest: (method: string, params: Record, context?: RequestContext) => + requests.get(method)!(params, context), + callNotification: (method: string, params: Record, context?: RequestContext) => + notifications.get(method)!(params, context) + } +} + +export function authorityRequestContext(clientId: number) { + return { + clientId, + isStale: () => false, + signal: new AbortController().signal + } +} + +export function authoritySpawnParams(paneGeneration = 3): Record { + return { + terminalSessionAuthorityVersion: 1, + paneKey: 'pane-a', + paneGeneration, + worktreeId: 'repo::/srv/repo', + cwd: '/srv/repo', + env: { ORCA_PANE_KEY: 'pane-a', ORCA_WORKTREE_ID: 'repo::/srv/repo' } + } +} + +export function authorityAttachParams( + id: string, + incarnationId: string, + overrides: Record = {} +): Record { + return { + id, + terminalSessionAuthorityAttachVersion: 1, + expectedWorktreeId: 'repo::/srv/repo', + expectedPaneKey: 'pane-a', + expectedPaneGeneration: 3, + expectedPtyIncarnationId: incarnationId, + ...overrides + } +} + +export function createAuthorityTerm(events: string[]): AuthorityTermFixture { + return { + pid: process.pid, + process: 'bash', + cols: 80, + rows: 24, + onData: vi.fn((_callback: (data: string) => void) => ({ dispose: vi.fn() })), + onExit: vi.fn((_callback: (event: { exitCode: number }) => void) => { + events.push('exit-listener') + return { dispose: vi.fn() } + }), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + clear: vi.fn(), + pause: vi.fn(() => events.push('pause')), + resume: vi.fn(() => events.push('resume')) + } +} + +export function authorityLifecycleMock(paneGeneration = 3): AuthorityLifecycleFixture { + const binding = { + ownerIncarnationId: 'owner-a', + physicalPtyId: 'pty-1', + ptyIncarnationId: 'incarnation-1' + } + return { + prepareSpawn: vi.fn(async () => ({ + kind: 'spawn' as const, + runtime: { service: { namespace: authorityNamespace } }, + pane: {}, + allocation: {} + })), + commitSpawn: vi.fn(async (_prepared, incarnationId: string) => ({ + runtime: { service: { namespace: authorityNamespace } }, + pane: { paneKey: 'pane-a', paneGenerationId: `renderer:${paneGeneration}` }, + binding: { ...binding, ptyIncarnationId: incarnationId } + })), + cancelSpawn: vi.fn(), + closePty: vi.fn(async () => {}), + closeExactPtyAccess: vi.fn(async () => {}), + recordExit: vi.fn(async () => {}), + missingPtyState: vi.fn(async () => ({ kind: 'unknown' as const })), + bindingIsReachable: vi.fn(() => true), + managedFromAdoption: vi.fn((adopted) => ({ + runtime: adopted.runtime, + pane: adopted.pane, + binding: adopted.binding + })) + } +} + +export function terminalExitOutcome(incarnationId: string) { + const binding = { + ownerIncarnationId: 'owner-a', + physicalPtyId: 'pty-1', + ptyIncarnationId: incarnationId + } + const request = { + actorId: 'actor-a', + operationId: 'operation-a', + baseRevision: 1, + consumerId: 'consumer-a', + outcomeId: 'outcome-a', + change: { + kind: 'exit' as const, + pane: { paneKey: 'pane-a', paneGenerationId: 'renderer:3' }, + expected: { paneGenerationId: 'renderer:3', binding }, + exit: { code: 7, signal: null } + } + } + return { + consumerId: request.consumerId, + sequence: 1, + outcomeId: request.outcomeId, + request, + result: { + namespace: authorityNamespace, + actorId: request.actorId, + operationId: request.operationId, + kind: 'exit' as const, + revision: 2, + pane: { + paneKey: 'pane-a', + paneGenerationId: 'renderer:3', + status: 'exited' as const, + binding: null, + lastBinding: binding, + revision: 2 + }, + replacementPane: null, + allocation: null, + effects: [ + { kind: 'binding-retired' as const, reason: 'exit' as const, binding }, + { kind: 'terminal-exited' as const, binding, code: 7, signal: null } + ] + }, + byteLength: 1 + } +} diff --git a/src/relay/dispatcher-capacity-degradation.test.ts b/src/relay/dispatcher-capacity-degradation.test.ts index a546989d5df..e09202fb7ab 100644 --- a/src/relay/dispatcher-capacity-degradation.test.ts +++ b/src/relay/dispatcher-capacity-degradation.test.ts @@ -281,6 +281,23 @@ describe('RelayDispatcher bounded-capacity degradation', () => { } }) + it('does not apply the dormant control-adapter admission to authority data', () => { + const primary = makeBoundedClient(65536) + const secondary = makeBoundedClient(65536) + const bounded = new RelayDispatcher(primary.write, primary.options) + try { + const clientId = bounded.attachClient(secondary.write, secondary.options) + bounded.registerPtyDataPublicationAdmission(() => false) + + expect(bounded.publishProducerNotification(clientId, 'pty.data', { id: 'pty-1' })).toBe(false) + expect(bounded.publishTerminalAuthorityData(clientId, { id: 'pty-1', data: 'x' })).toBe(true) + expect(secondary.frames).toHaveLength(1) + expect(primary.frames).toHaveLength(0) + } finally { + bounded.dispose() + } + }) + it('still closes on protocol-critical control queue overflow', () => { const primary = makeSaturatingClient(65536) const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) diff --git a/src/relay/dispatcher-legacy-pty-sizing.test.ts b/src/relay/dispatcher-legacy-pty-sizing.test.ts new file mode 100644 index 00000000000..a67535bd541 --- /dev/null +++ b/src/relay/dispatcher-legacy-pty-sizing.test.ts @@ -0,0 +1,202 @@ +import { describe, expect, it, vi } from 'vitest' +import { RelayDispatcher, type SinkWriteSettlement } from './dispatcher' +import { relayWriterControlReserve } from './dispatcher-writer-admission' +import { encodeJsonRpcFrame, type JsonRpcNotification } from './protocol' + +function decodeFirstFrame(buf: Buffer): { type: number; id: number; ack: number; payload: Buffer } { + const type = buf[0] + const id = buf.readUInt32BE(1) + const ack = buf.readUInt32BE(5) + const len = buf.readUInt32BE(9) + return { type, id, ack, payload: buf.subarray(13, 13 + len) } +} + +type DispatcherInternals = { + primaryClient: object + estimateFrameBytes: (msg: JsonRpcNotification) => number + enqueueFrame: ( + client: object, + msg: JsonRpcNotification, + lane: string, + onSettled?: (result: SinkWriteSettlement) => void, + estimatedBytes?: number + ) => boolean +} + +function referenceMaxChars( + capacities: number[], + params: Record, + data: string, + limit: number +): number { + if (capacities.length === 0) { + return Math.min(data.length, limit) + } + let low = 0 + let high = Math.min(data.length, limit) + while (low < high) { + const mid = Math.ceil((low + high) / 2) + const msg: JsonRpcNotification = { + jsonrpc: '2.0', + method: 'pty.data', + params: { ...params, data: data.slice(0, mid) } + } + const bytes = encodeJsonRpcFrame(msg, 0, 0).length + if (capacities.every((capacity) => bytes <= capacity)) { + low = mid + } else { + high = mid - 1 + } + } + return low +} + +function makeDispatcher(highWaterMarks: number[]): { + sized: RelayDispatcher + capacities: number[] +} { + const [primaryHwm, ...rest] = highWaterMarks + const sized = new RelayDispatcher(() => true, { + writableHighWaterMark: () => primaryHwm, + writableLength: () => 0 + }) + for (const hwm of rest) { + sized.attachClient(() => true, { + writableHighWaterMark: () => hwm, + writableLength: () => 0 + }) + } + return { + sized, + capacities: highWaterMarks.map((hwm) => Math.max(0, hwm - relayWriterControlReserve(hwm))) + } +} + +function mulberry32(seed: number): () => number { + let value = seed + return () => { + value = (value + 0x6d2b79f5) | 0 + let mixed = Math.imul(value ^ (value >>> 15), 1 | value) + mixed = (mixed + Math.imul(mixed ^ (mixed >>> 7), 61 | mixed)) ^ mixed + return ((mixed ^ (mixed >>> 14)) >>> 0) / 4294967296 + } +} + +describe('RelayDispatcher legacy PTY chunk sizing', () => { + const alphabet = 'aZ9 "\\\n\r\t\u001bé߀中𝄞😀𐀀�' + + it('matches the pre-optimization sizing loop across randomized inputs', () => { + const random = mulberry32(0xc0ffee) + const hwmChoices = [1030, 1100, 1250, 1400, 2048, 1024 * 1024] + for (let trial = 0; trial < 300; trial++) { + const length = Math.floor(random() * 240) + let data = '' + for (let i = 0; i < length; i++) { + data += alphabet[Math.floor(random() * alphabet.length)] + } + const clientCount = 1 + Math.floor(random() * 2) + const hwms = Array.from( + { length: clientCount }, + () => hwmChoices[Math.floor(random() * hwmChoices.length)] + ) + const params = { id: `pty-${trial}`, seq: trial } + const { sized, capacities } = makeDispatcher(hwms) + try { + for (const limit of [0, 1, Math.floor(length / 2), length, length + 17]) { + expect(sized.maxLegacyPtyDataChars(params, data, limit)).toBe( + referenceMaxChars(capacities, params, data, limit) + ) + } + expect(sized.maxLegacyPtyDataChars(params, data)).toBe( + referenceMaxChars(capacities, params, data, data.length) + ) + } finally { + sized.dispose() + } + } + }) + + it('sizes a chunk that fits every client with a single frame encode', () => { + const { sized } = makeDispatcher([1024 * 1024]) + try { + const spy = vi.spyOn(sized as unknown as DispatcherInternals, 'estimateFrameBytes') + const data = 'x'.repeat(16 * 1024) + expect(sized.maxLegacyPtyDataChars({ id: 'pty-1' }, data)).toBe(data.length) + expect(spy).toHaveBeenCalledTimes(1) + } finally { + sized.dispose() + } + }) + + it('publishes PTY data with a single frame estimate', () => { + const frames: Buffer[] = [] + const publisher = new RelayDispatcher((data) => { + frames.push(Buffer.from(data)) + return true + }) + try { + const spy = vi.spyOn(publisher as unknown as DispatcherInternals, 'estimateFrameBytes') + expect(publisher.tryNotifyPtyData({ id: 'pty-1', data: 'hello' })).toBe(true) + expect(frames).toHaveLength(1) + expect(spy).toHaveBeenCalledTimes(1) + } finally { + publisher.dispose() + } + }) + + it('enqueueFrame with a caller-supplied estimate matches the computed default', () => { + const frames: Buffer[] = [] + const publisher = new RelayDispatcher((data) => { + frames.push(Buffer.from(data)) + return true + }) + try { + const internals = publisher as unknown as DispatcherInternals + const msg: JsonRpcNotification = { + jsonrpc: '2.0', + method: 'pty.data', + params: { id: 'pty-1', data: 'héllo "𝄞"\\\n\uD800' } + } + expect(internals.enqueueFrame(internals.primaryClient, msg, 'ordinary')).toBe(true) + expect( + internals.enqueueFrame( + internals.primaryClient, + msg, + 'ordinary', + undefined, + internals.estimateFrameBytes(msg) + ) + ).toBe(true) + expect(frames).toHaveLength(2) + expect(decodeFirstFrame(frames[1]).payload.equals(decodeFirstFrame(frames[0]).payload)).toBe( + true + ) + } finally { + publisher.dispose() + } + }) + + it('enqueueFrame rejects identically with and without a caller-supplied estimate', () => { + const { sized } = makeDispatcher([1030]) + try { + const internals = sized as unknown as DispatcherInternals + const msg: JsonRpcNotification = { + jsonrpc: '2.0', + method: 'pty.data', + params: { id: 'pty-1', data: 'x'.repeat(512) } + } + expect(internals.enqueueFrame(internals.primaryClient, msg, 'ordinary')).toBe(false) + expect( + internals.enqueueFrame( + internals.primaryClient, + msg, + 'ordinary', + undefined, + internals.estimateFrameBytes(msg) + ) + ).toBe(false) + } finally { + sized.dispose() + } + }) +}) diff --git a/src/relay/dispatcher.test.ts b/src/relay/dispatcher.test.ts index f38d3833dca..726eade41a3 100644 --- a/src/relay/dispatcher.test.ts +++ b/src/relay/dispatcher.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' -import { RelayDispatcher, type SinkWriteSettlement } from './dispatcher' -import { relayWriterControlReserve } from './dispatcher-writer-admission' +import { RelayDispatcher, type ResponseSettlement, type SinkWriteSettlement } from './dispatcher' import { encodeJsonRpcFrame, encodeKeepAliveFrame, @@ -119,6 +118,24 @@ describe('RelayDispatcher', () => { expect(resp.id).toBe(5) }) + it('marks a delivered application error separately from a transport failure', async () => { + let settlement: ResponseSettlement | undefined + dispatcher.onRequest('fail.with-settlement', async (_params, context) => { + context.onResponseSettled?.((result) => { + settlement = result + }) + throw new Error('application rejection') + }) + + dispatcher.feed( + encodeJsonRpcFrame({ jsonrpc: '2.0', id: 6, method: 'fail.with-settlement' }, 1, 0) + ) + await vi.advanceTimersByTimeAsync(0) + + expect(settlement).toBeDefined() + expect(settlement).toMatchObject({ ok: false, responseDelivered: true }) + }) + it('sends method-not-found for unknown methods', async () => { const req: JsonRpcRequest = { jsonrpc: '2.0', @@ -720,196 +737,4 @@ describe('RelayDispatcher', () => { } }) }) - - describe('legacy PTY chunk sizing', () => { - type DispatcherInternals = { - primaryClient: object - estimateFrameBytes: (msg: JsonRpcNotification) => number - enqueueFrame: ( - client: object, - msg: JsonRpcNotification, - lane: string, - onSettled?: (result: SinkWriteSettlement) => void, - estimatedBytes?: number - ) => boolean - } - - // Pre-optimization sizing loop, kept verbatim for differential verification. - function referenceMaxChars( - capacities: number[], - params: Record, - data: string, - limit: number - ): number { - if (capacities.length === 0) { - return Math.min(data.length, limit) - } - let low = 0 - let high = Math.min(data.length, limit) - while (low < high) { - const mid = Math.ceil((low + high) / 2) - const msg: JsonRpcNotification = { - jsonrpc: '2.0', - method: 'pty.data', - params: { ...params, data: data.slice(0, mid) } - } - const bytes = encodeJsonRpcFrame(msg, 0, 0).length - if (capacities.every((capacity) => bytes <= capacity)) { - low = mid - } else { - high = mid - 1 - } - } - return low - } - - function makeDispatcher(highWaterMarks: number[]): { - sized: RelayDispatcher - capacities: number[] - } { - const [primaryHwm, ...rest] = highWaterMarks - const sized = new RelayDispatcher(() => true, { - writableHighWaterMark: () => primaryHwm, - writableLength: () => 0 - }) - for (const hwm of rest) { - sized.attachClient(() => true, { - writableHighWaterMark: () => hwm, - writableLength: () => 0 - }) - } - return { - sized, - capacities: highWaterMarks.map((hwm) => Math.max(0, hwm - relayWriterControlReserve(hwm))) - } - } - - function mulberry32(seed: number): () => number { - let a = seed - return () => { - a = (a + 0x6d2b79f5) | 0 - let t = Math.imul(a ^ (a >>> 15), 1 | a) - t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t - return ((t ^ (t >>> 14)) >>> 0) / 4294967296 - } - } - - // Multi-byte UTF-8, astral pairs, lone surrogates, controls, quotes, and backslashes. - const alphabet = 'aZ9 "\\\n\r\té߀中𝄞😀𐀀�' - - it('matches the pre-optimization sizing loop across randomized inputs', () => { - const random = mulberry32(0xc0ffee) - const hwmChoices = [1030, 1100, 1250, 1400, 2048, 1024 * 1024] - for (let trial = 0; trial < 300; trial++) { - const length = Math.floor(random() * 240) - let data = '' - for (let i = 0; i < length; i++) { - data += alphabet[Math.floor(random() * alphabet.length)] - } - const clientCount = 1 + Math.floor(random() * 2) - const hwms = Array.from( - { length: clientCount }, - () => hwmChoices[Math.floor(random() * hwmChoices.length)] - ) - const params = { id: `pty-${trial}`, seq: trial } - const { sized, capacities } = makeDispatcher(hwms) - try { - for (const limit of [0, 1, Math.floor(length / 2), length, length + 17]) { - expect(sized.maxLegacyPtyDataChars(params, data, limit)).toBe( - referenceMaxChars(capacities, params, data, limit) - ) - } - expect(sized.maxLegacyPtyDataChars(params, data)).toBe( - referenceMaxChars(capacities, params, data, data.length) - ) - } finally { - sized.dispose() - } - } - }) - - it('sizes a chunk that fits every client with a single frame encode', () => { - const { sized } = makeDispatcher([1024 * 1024]) - try { - const spy = vi.spyOn(sized as unknown as DispatcherInternals, 'estimateFrameBytes') - const data = 'x'.repeat(16 * 1024) - expect(sized.maxLegacyPtyDataChars({ id: 'pty-1' }, data)).toBe(data.length) - expect(spy).toHaveBeenCalledTimes(1) - } finally { - sized.dispose() - } - }) - - it('publishes PTY data with a single frame estimate', () => { - const frames: Buffer[] = [] - const publisher = new RelayDispatcher((data) => { - frames.push(Buffer.from(data)) - return true - }) - try { - const spy = vi.spyOn(publisher as unknown as DispatcherInternals, 'estimateFrameBytes') - expect(publisher.tryNotifyPtyData({ id: 'pty-1', data: 'hello' })).toBe(true) - expect(frames).toHaveLength(1) - expect(spy).toHaveBeenCalledTimes(1) - } finally { - publisher.dispose() - } - }) - - it('enqueueFrame with a caller-supplied estimate matches the computed default', () => { - const frames: Buffer[] = [] - const publisher = new RelayDispatcher((data) => { - frames.push(Buffer.from(data)) - return true - }) - try { - const internals = publisher as unknown as DispatcherInternals - const msg: JsonRpcNotification = { - jsonrpc: '2.0', - method: 'pty.data', - params: { id: 'pty-1', data: 'héllo "𝄞"\\\n\uD800' } - } - expect(internals.enqueueFrame(internals.primaryClient, msg, 'ordinary')).toBe(true) - expect( - internals.enqueueFrame( - internals.primaryClient, - msg, - 'ordinary', - undefined, - internals.estimateFrameBytes(msg) - ) - ).toBe(true) - expect(frames).toHaveLength(2) - expect( - decodeFirstFrame(frames[1]).payload.equals(decodeFirstFrame(frames[0]).payload) - ).toBe(true) - } finally { - publisher.dispose() - } - }) - - it('enqueueFrame rejects identically with and without a caller-supplied estimate', () => { - const { sized } = makeDispatcher([1030]) - try { - const internals = sized as unknown as DispatcherInternals - const msg: JsonRpcNotification = { - jsonrpc: '2.0', - method: 'pty.data', - params: { id: 'pty-1', data: 'x'.repeat(512) } - } - expect(internals.enqueueFrame(internals.primaryClient, msg, 'ordinary')).toBe(false) - expect( - internals.enqueueFrame( - internals.primaryClient, - msg, - 'ordinary', - undefined, - internals.estimateFrameBytes(msg) - ) - ).toBe(false) - } finally { - sized.dispose() - } - }) - }) }) diff --git a/src/relay/dispatcher.ts b/src/relay/dispatcher.ts index c95eef32117..f163ff7804c 100644 --- a/src/relay/dispatcher.ts +++ b/src/relay/dispatcher.ts @@ -40,9 +40,15 @@ export type RequestContext = { isStale: () => boolean signal?: AbortSignal sessionIdentity?: RelayClientSessionIdentity - onResponseSettled?: (handler: (result: SinkWriteSettlement) => void) => void + onResponsePrepared?: (handler: () => void) => void + onResponseSettled?: (handler: (result: ResponseSettlement) => void) => void } +export type ResponseSettlement = + | Readonly<{ ok: true }> + | Readonly<{ ok: false; error: Error; responseDelivered?: false }> + | Readonly<{ ok: false; error: Error; responseDelivered: true }> + export type RelayClientSessionIdentity = { principal: string authenticated: boolean @@ -604,6 +610,36 @@ export class RelayDispatcher { // Why: a caller that recovers from rejection itself (the watcher emitter re-sends the batch in // chunks) would otherwise log "Dropped" for a frame it goes on to deliver in full. options?: { logDrop?: boolean } + ): boolean { + return this.publishProducerNotificationForClient(clientId, method, params, { + logDrop: options?.logDrop !== false, + enforcePtyAdmission: true, + onSettled: () => {} + }) + } + + // Why: authority validates delivery identity; the control adapter's dormant local session cannot. + publishTerminalAuthorityData( + clientId: number, + params: Record, + onSettled: (result: SinkWriteSettlement) => void = () => {} + ): boolean { + return this.publishProducerNotificationForClient(clientId, 'pty.data', params, { + logDrop: false, + enforcePtyAdmission: false, + onSettled + }) + } + + private publishProducerNotificationForClient( + clientId: number, + method: string, + params: Record | undefined, + options: { + logDrop: boolean + enforcePtyAdmission: boolean + onSettled: (result: SinkWriteSettlement) => void + } ): boolean { if (this.disposed) { return false @@ -617,15 +653,28 @@ export class RelayDispatcher { method, ...(params !== undefined ? { params } : {}) } - if (method === 'pty.data' && !this.admitsPtyDataPublication(client.id, params ?? {})) { + if ( + options.enforcePtyAdmission && + method === 'pty.data' && + !this.admitsPtyDataPublication(client.id, params ?? {}) + ) { return false } const frameBytes = this.estimateFrameBytes(msg) - if (this.publishToClient(client, msg, 'ordinary', undefined, frameBytes)) { + if ( + this.publishToClient( + client, + msg, + 'ordinary', + options.onSettled, + frameBytes, + options.enforcePtyAdmission + ) + ) { return true } // Why: same diagnostics as notify() — a producer that drops here must not do so silently. - if (options?.logDrop !== false) { + if (options.logDrop) { this.logDroppedProducerNotification(client, method, frameBytes) } return false @@ -944,9 +993,21 @@ export class RelayDispatcher { client.id, req.id ) - const responseSettledHandlers = new Set<(result: SinkWriteSettlement) => void>() + const responsePreparedHandlers = new Set<() => void>() + const responseSettledHandlers = new Set<(result: ResponseSettlement) => void>() + let responsePrepared = false let responseSettled = false - const settleResponse = (result: SinkWriteSettlement): void => { + const prepareResponse = (): void => { + if (responsePrepared) { + return + } + responsePrepared = true + for (const callback of responsePreparedHandlers) { + callback() + } + responsePreparedHandlers.clear() + } + const settleResponse = (result: ResponseSettlement): void => { if (responseSettled) { return } @@ -969,6 +1030,12 @@ export class RelayDispatcher { client.generation !== gen || !this.clients.has(client.id) || abortController.signal.aborted, signal: abortController.signal, sessionIdentity: client.sessionIdentity, + onResponsePrepared: (handler) => { + if (responsePrepared) { + throw new Error('Response preparation callback registered after preparation') + } + responsePreparedHandlers.add(handler) + }, onResponseSettled: (handler) => { if (responseSettled) { throw new Error('Response settlement callback registered after settlement') @@ -982,6 +1049,7 @@ export class RelayDispatcher { settleResponse({ ok: false, error: new Error('Relay request became stale') }) return } + prepareResponse() const accepted = this.sendResponse(client, req.id, result, undefined, (settlement) => { settleResponse( context.isStale() @@ -1002,7 +1070,8 @@ export class RelayDispatcher { const accepted = this.sendResponse(client, req.id, undefined, { code, message }, (result) => { settleResponse({ ok: false, - error: result.ok ? new Error(message) : result.error + error: result.ok ? new Error(message) : result.error, + ...(result.ok ? { responseDelivered: true } : {}) }) }) if (!accepted) { @@ -1025,6 +1094,9 @@ export class RelayDispatcher { clientId: client.id, isStale: () => client.generation !== gen || !this.clients.has(client.id), sessionIdentity: client.sessionIdentity, + onResponsePrepared: () => { + throw new Error('Notifications do not have response preparation fences') + }, onResponseSettled: () => { throw new Error('Notifications do not have response publication fences') } @@ -1081,7 +1153,8 @@ export class RelayDispatcher { onSettled: (result: SinkWriteSettlement) => void = () => {}, // Why: publish paths already sized the frame; avoid a redundant encode. estimatedBytes?: number, - controlOverflow: 'close-client' | 'reject' = 'close-client' + controlOverflow: 'close-client' | 'reject' = 'close-client', + enforcePtyAdmission = true ): boolean { if (this.disposed || client.closed) { return false @@ -1092,7 +1165,7 @@ export class RelayDispatcher { return encodeJsonRpcFrame(msg, seq, client.highestReceivedSeq) } const isStillAdmitted = - 'method' in msg && msg.method === 'pty.data' + enforcePtyAdmission && 'method' in msg && msg.method === 'pty.data' ? () => this.admitsPtyDataPublication(client.id, msg.params ?? {}) : undefined return client.writer.enqueue( @@ -1211,7 +1284,8 @@ export class RelayDispatcher { lane: 'interactive' | 'ordinary' | 'fixed-bulk' | 'bulk', onSettled: (result: SinkWriteSettlement) => void = () => {}, // Why: broadcast callers size the frame once for every client; avoid a redundant encode. - estimatedBytes?: number + estimatedBytes?: number, + enforcePtyAdmission = true ): boolean { const bytes = estimatedBytes ?? this.estimateFrameBytes(msg) const fixedBlocked = @@ -1224,7 +1298,15 @@ export class RelayDispatcher { if (!leases) { return false } - return this.enqueueLeasedFrame(client, msg, lane, leases[0], bytes, onSettled) + return this.enqueueLeasedFrame( + client, + msg, + lane, + leases[0], + bytes, + onSettled, + enforcePtyAdmission + ) } private publishBulkWhenAvailable(client: RelayClient, msg: JsonRpcNotification): Promise { @@ -1274,7 +1356,8 @@ export class RelayDispatcher { lane: 'interactive' | 'ordinary' | 'fixed-bulk' | 'bulk', lease: LegacyPublicationLease, estimatedBytes: number, - onSettled: (result: SinkWriteSettlement) => void = () => {} + onSettled: (result: SinkWriteSettlement) => void = () => {}, + enforcePtyAdmission = true ): boolean { const accepted = this.enqueueFrame( client, @@ -1285,7 +1368,9 @@ export class RelayDispatcher { onSettled(result) this.notifyLegacyCapacityIfLow() }, - estimatedBytes + estimatedBytes, + 'close-client', + enforcePtyAdmission ) if (!accepted) { lease.release() diff --git a/src/relay/legacy-physical-worker-ack-drain.ts b/src/relay/legacy-physical-worker-ack-drain.ts new file mode 100644 index 00000000000..56337b4ff7a --- /dev/null +++ b/src/relay/legacy-physical-worker-ack-drain.ts @@ -0,0 +1,40 @@ +import type { ImportedPhysicalWorkerPtySession } from './legacy-physical-worker-authority-session' + +export function ensureLegacyPhysicalWorkerAckDrain( + session: ImportedPhysicalWorkerPtySession, + onDrained: () => void, + onFault: (error: Error) => void +): void { + if (session.ackTask || session.retired) { + return + } + const operation = drainLegacyPhysicalWorkerAcks(session) + session.ackTask = operation + void operation + .then(onDrained) + .catch((error) => onFault(error instanceof Error ? error : new Error(String(error)))) + .finally(() => { + if (session.ackTask === operation) { + session.ackTask = null + } + if ( + !session.retired && + session.route.isCurrent() && + session.requestedAckEndSu > session.proxy.snapshot().downstreamAckedEndSu + ) { + ensureLegacyPhysicalWorkerAckDrain(session, onDrained, onFault) + } + }) +} + +async function drainLegacyPhysicalWorkerAcks( + session: ImportedPhysicalWorkerPtySession +): Promise { + while (!session.retired && session.route.isCurrent()) { + const targetEndSu = session.requestedAckEndSu + await session.proxy.acknowledgeDownstream(targetEndSu) + if (targetEndSu >= session.requestedAckEndSu) { + return + } + } +} diff --git a/src/relay/legacy-physical-worker-attach-router.ts b/src/relay/legacy-physical-worker-attach-router.ts new file mode 100644 index 00000000000..656790bb760 --- /dev/null +++ b/src/relay/legacy-physical-worker-attach-router.ts @@ -0,0 +1,86 @@ +import type { + PtySourceRecoveryRequest, + PtySourceRecoveryResult +} from '../shared/pty-source-recovery-contract' +import type { + TerminalPaneGeneration, + TerminalSessionBinding +} from '../shared/terminal-session-authority-identity' +import type { RequestContext } from './dispatcher' +import type { PtySourceReceivingActivation } from '../shared/pty-source-receiving-activation' +import type { PtySourceDeliveryIdentity } from '../shared/pty-source-credit-contract' +import type { LegacyPhysicalWorkerMutation } from './legacy-physical-worker-mutation' +import type { + TerminalAuthorityOutcome, + TerminalSessionAuthorityEffect +} from '../shared/terminal-session-authority-mutation' +import type { TerminalAuthorityOutcomeDeliveryAttempt } from './terminal-session-authority-outcome-delivery' +import type { TerminalSessionAuthorityPtyAccess } from '../shared/terminal-session-authority-pty-access' + +export type LegacyPhysicalWorkerAttachResult = Readonly<{ + incarnationId: string + replay?: string + sourceRecovery?: PtySourceRecoveryResult + sourceActivation?: PtySourceReceivingActivation +}> + +export type LegacyPhysicalWorkerAttachRequest = Readonly<{ + pane: TerminalPaneGeneration + binding: TerminalSessionBinding + worktreeId: string + expectedTabId?: string + sourceRecovery?: PtySourceRecoveryRequest + suppressReplayNotification: boolean + context?: RequestContext +}> + +export type LegacyPhysicalWorkerAttachRouter = Readonly<{ + attachReachablePty: ( + request: LegacyPhysicalWorkerAttachRequest + ) => Promise +}> + +export type LegacyPhysicalWorkerPtyRouter = LegacyPhysicalWorkerAttachRouter & + Readonly<{ + dispatchMutation: ( + id: string, + incarnationId: string, + mutation: LegacyPhysicalWorkerMutation + ) => Promise + dispatchAuthorityMutation: ( + access: TerminalSessionAuthorityPtyAccess, + mutation: Exclude + ) => Promise + dispatchAuthorityShutdown: ( + access: TerminalSessionAuthorityPtyAccess, + mutation: Extract, + persistClose: () => Promise + ) => Promise + ensureAuthorityShutdown?: ( + access: TerminalSessionAuthorityPtyAccess, + mutation: Extract + ) => Promise + setDeliveryPaused: (identity: PtySourceDeliveryIdentity, paused: boolean) => boolean + setHeldProducerPause: ( + id: string, + incarnationId: string, + token: string, + paused: boolean + ) => Promise + handleDownstreamCredit: (identity: PtySourceDeliveryIdentity) => boolean + reservesPhysicalPtyId: (id: string) => boolean + reservesPublicPtyIdentity: (id: string, incarnationId: string) => boolean + publishAuthorityOutcome?: ( + outcome: TerminalAuthorityOutcome, + effect: Extract, + attempt: TerminalAuthorityOutcomeDeliveryAttempt + ) => boolean + dispose: () => void + }> + +export type LegacyPhysicalWorkerAttachIdentity = Readonly<{ + id: string + incarnationId: string + expectedPaneKey?: string + expectedTabId?: string +}> diff --git a/src/relay/legacy-physical-worker-authority-host.test.ts b/src/relay/legacy-physical-worker-authority-host.test.ts new file mode 100644 index 00000000000..c37b7f8f5b1 --- /dev/null +++ b/src/relay/legacy-physical-worker-authority-host.test.ts @@ -0,0 +1,367 @@ +import { describe, expect, it, vi } from 'vitest' +import type { + TerminalLegacyCutoverProof, + TerminalLegacyMigrationReceipt, + TerminalLegacyUnresolvedCandidate, + TerminalLegacyWorkerRoute +} from '../shared/terminal-legacy-cutover' +import { + LegacyPhysicalWorkerAuthorityHost, + type LegacyPhysicalWorkerAuthorityHostOperations, + type LegacyPhysicalWorkerMigrationAuthority +} from './legacy-physical-worker-authority-host' +import { + LegacyPhysicalWorkerClient, + type LegacyPhysicalWorkerRpc +} from './legacy-physical-worker-client' +import type { LegacyPhysicalWorkerDescriptor } from './legacy-physical-worker-control-protocol' +import type { LegacyPhysicalWorkerCutoverSession } from './legacy-physical-worker-cutover-session' +import type { LegacyPhysicalWorkerRegistration } from './legacy-physical-worker-registration' + +describe('legacy physical worker authority host', () => { + it('retries an authority response loss without repeating physical preservation', async () => { + const client = workerClient(new WorkerRpc()) + const order: string[] = [] + const preserved = preservation(client) + const physicalPreservation = vi.fn(async () => { + order.push('physical-preservation') + return preserved + }) + let preservedPromise: ReturnType | null = null + const cutover = vi.fn(() => (preservedPromise ??= physicalPreservation())) + const session = cutoverSession(client, cutover) + const operations: LegacyPhysicalWorkerAuthorityHostOperations = { + inspect: vi.fn(async () => session), + restore: vi.fn(async () => preserved.registration) + } + const registry = { + register: vi.fn(async () => { + order.push('registry-registration') + return Object.freeze({ status: 'registered' as const, replaced: false }) + }) + } + let committedReceipt: TerminalLegacyMigrationReceipt | null = null + const authority = migrationAuthority({ + importMigration: vi.fn(async (request) => { + order.push('authority-commit') + if (!committedReceipt) { + committedReceipt = receipt(request) + throw new Error('authority response lost') + } + return Object.freeze({ receipt: committedReceipt, duplicate: true }) + }), + activateWorker: vi.fn(async () => { + order.push('authority-activation') + return true + }) + }) + const host = new LegacyPhysicalWorkerAuthorityHost(registry, authority, {}, operations) + + await expect(host.migrate(migrationInput)).rejects.toThrow('authority response lost') + expect(order).toEqual(['physical-preservation', 'registry-registration', 'authority-commit']) + expect(host.gcProtection()).toEqual({ + relayDirectories: ['/legacy-relay'], + evidencePaths: [ + '/authority-state', + '/authority-state/credential', + '/authority-state/worker.sock', + '/legacy-relay/credential', + '/legacy-relay/relay.sock' + ] + }) + + await expect(host.migrate(migrationInput)).resolves.toMatchObject({ duplicate: true }) + expect(host.gcProtection()).toEqual(route.gcProtection) + expect(physicalPreservation).toHaveBeenCalledOnce() + expect(operations.inspect).toHaveBeenCalledOnce() + expect(order).toEqual([ + 'physical-preservation', + 'registry-registration', + 'authority-commit', + 'registry-registration', + 'authority-commit', + 'authority-activation' + ]) + }) + + it('restores authority preservation as live only while exact process evidence is reachable', async () => { + const rpc = new WorkerRpc() + const client = workerClient(rpc) + const restoredRegistration: LegacyPhysicalWorkerRegistration = Object.freeze({ + ...preservation(client).registration, + restored: true + }) + const registry = { + register: vi.fn(async () => Object.freeze({ status: 'registered' as const, replaced: false })) + } + const authority = migrationAuthority({ + physicalWorkerEntries: () => [Object.freeze({ route, cutover: proof })] + }) + const operations: LegacyPhysicalWorkerAuthorityHostOperations = { + inspect: vi.fn(async () => cutoverSession(client, async () => preservation(client))), + restore: vi.fn(async () => restoredRegistration) + } + const host = new LegacyPhysicalWorkerAuthorityHost(registry, authority, {}, operations) + + await expect(host.restoreAuthorityWorkers()).resolves.toEqual([ + { routeId: 'route-1', status: 'restored' } + ]) + expect(authority.activateWorker).toHaveBeenCalledWith( + expect.objectContaining({ + routeId: 'route-1', + ownerIncarnationId: 'owner-1', + endpoint: proof.endpointIdentity + }) + ) + rpc.disconnect() + await vi.waitFor(() => expect(authority.deactivateWorker).toHaveBeenCalledOnce()) + expect(host.gcProtection()).toEqual({ relayDirectories: [], evidencePaths: [] }) + expect(host.gcEligible()).toEqual(route.gcProtection) + host.dispose() + host.dispose() + }) +}) + +function migrationAuthority( + overrides: Partial = {} +): LegacyPhysicalWorkerMigrationAuthority { + return { + importMigration: vi.fn(async (request) => ({ + receipt: receipt(request), + duplicate: false + })), + activateWorker: vi.fn(async () => true), + deactivateWorker: vi.fn(async () => true), + gcProtection: () => ({ relayDirectories: [], evidencePaths: [] }), + physicalWorkerEntries: () => [], + projection: () => ({ revision: 3 }), + ...overrides + } +} + +function receipt( + request: Parameters[0] +): TerminalLegacyMigrationReceipt { + return Object.freeze({ + version: 1, + receiptId: 'receipt-1', + sequence: 4, + committedAtMs: 5, + request, + recoveries: Object.freeze([]) + }) +} + +function cutoverSession( + client: LegacyPhysicalWorkerClient, + cutover: LegacyPhysicalWorkerCutoverSession['cutover'] +): LegacyPhysicalWorkerCutoverSession { + return Object.freeze({ + descriptor, + client, + inventory: Object.freeze([ + Object.freeze({ + id: 'pty-1', + incarnationId: 'incarnation-1', + processId: 91, + cwd: '/workspace', + title: 'shell' + }) + ]), + cutover + }) +} + +function preservation(client: LegacyPhysicalWorkerClient) { + const registration: LegacyPhysicalWorkerRegistration = Object.freeze({ + route, + cutover: proof, + client, + processMatches: async () => true + }) + return Object.freeze({ route, proof, registration }) +} + +class WorkerRpc implements LegacyPhysicalWorkerRpc { + private open = true + private readonly closeListeners = new Set<() => void>() + + request(): Promise { + return Promise.reject(new Error('unexpected request')) + } + + notify(): void {} + + notifyWithSettlement( + _method: string, + _params: Record, + onSettled: (result: { ok: true } | { ok: false; error: Error }) => void + ): void { + onSettled({ ok: true }) + } + + onNotification(): () => void { + return () => {} + } + + isOpen(): boolean { + return this.open + } + + onClose(listener: () => void): () => void { + this.closeListeners.add(listener) + return () => this.closeListeners.delete(listener) + } + + close(): void { + this.disconnect() + } + + disconnect(): void { + if (!this.open) { + return + } + this.open = false + this.closeListeners.forEach((listener) => listener()) + this.closeListeners.clear() + } +} + +function workerClient(rpc: LegacyPhysicalWorkerRpc): LegacyPhysicalWorkerClient { + return new LegacyPhysicalWorkerClient( + rpc, + { + protocolVersion: 1, + serverBuildId: 'build-1', + clientGeneration: 2, + role: 'session-owner', + ownerGeneration: 3, + ownerLease: 'lease-1', + resumed: false + }, + { + consumerSessionVersion: 1, + outputFlowControlVersion: 1, + exactOperationsVersion: 1, + heldProducerPauseVersion: 1, + mutationMode: 'exact-v1', + sourceWindowSu: 1024 + }, + 'broker-1' + ) +} + +const endpoint = Object.freeze({ + kind: 'unix-socket' as const, + device: '1', + inode: '2', + changedAtNs: '3' +}) + +const route: TerminalLegacyWorkerRoute = Object.freeze({ + routeId: 'route-1', + workerId: 'worker-1', + ownerIncarnationId: 'owner-1', + buildId: 'build-1', + relayDirectory: '/legacy-relay', + socketPath: '/authority-state/worker.sock', + credentialFile: '/authority-state/credential', + process: Object.freeze({ pid: 90, birthMarker: 'birth-1' }), + endpoint, + sourceOwner: Object.freeze({ + clientInstanceId: 'broker-1', + ownerGeneration: 3, + ownerLease: 'lease-1', + outputWindowSourceUnits: 1024 + }), + gcProtection: Object.freeze({ + relayDirectories: Object.freeze(['/legacy-relay']), + evidencePaths: Object.freeze(['/authority-state/worker.sock']) + }) +}) + +const proof: TerminalLegacyCutoverProof = Object.freeze({ + kind: 'posix-relocated', + publicCredentialFile: '/legacy-relay/credential', + privateCredentialFile: '/authority-state/credential', + publicSocketPath: '/legacy-relay/relay.sock', + privateSocketPath: '/authority-state/worker.sock', + endpointIdentity: endpoint, + brokerClientCount: 1, + acceptedConnectionCount: 1, + quiescenceSamples: 2, + connectionProof: Object.freeze({ + method: 'linux-procfs-unix', + listenerIdentity: 'listener-1', + brokerConnectionIdentity: 'broker-1:2:3', + acceptedServerConnections: 1 + }), + graceConfiguration: Object.freeze({ + capabilityVersion: 1, + configuredGraceMs: 0, + acknowledged: true + }), + sealedAtMs: 3 +}) + +const descriptor: LegacyPhysicalWorkerDescriptor = Object.freeze({ + version: 1, + workerId: 'worker-1', + routeId: 'route-1', + ownerIncarnationId: 'owner-1', + buildId: 'build-1', + clientInstanceId: 'broker-1', + relayDirectory: '/legacy-relay', + process: route.process, + expectedEndpoint: endpoint, + requestedSourceWindowSu: 1024, + publicCredentialFile: '/legacy-relay/credential', + privateCredentialFile: '/authority-state/credential', + privateStateDirectory: '/authority-state', + platform: 'linux', + publicSocketPath: '/legacy-relay/relay.sock', + privateSocketPath: '/authority-state/worker.sock' +}) + +const unresolved: TerminalLegacyUnresolvedCandidate = Object.freeze({ + recoveryId: 'recovery-1', + namespace: Object.freeze({ authorityHostId: 'authority-1', namespaceId: 'namespace-1' }), + workspace: Object.freeze({ + kind: 'folder', + locator: Object.freeze({ kind: 'workspace', canonicalPath: '/workspace', pathFlavor: 'posix' }) + }), + physicalPty: Object.freeze({ + workerId: 'worker-1', + physicalPtyId: 'pty-1', + ptyIncarnationId: 'incarnation-1', + processId: 91 + }), + reason: 'workspace-mismatch', + evidenceCode: 'evidence-1', + inventoryEvidence: Object.freeze({ + evidenceDigest: 'digest-1', + observedAtMs: 1, + paneKey: 'pane-1', + tabId: 'tab-1', + worktreeId: '/workspace', + cwd: '/workspace', + serializedPtyIncarnationId: 'incarnation-1', + serializedProcessId: 91 + }), + preservation: Object.freeze({ + kind: 'isolated-grace-disabled', + endpointIdentityRetained: true, + graceDisabledAcknowledged: true + }) +}) + +const migrationInput = Object.freeze({ + descriptor, + catalog: Object.freeze({ + migrationId: 'migration-1', + authorityHostId: 'authority-1', + requestedAtMs: 2, + imports: Object.freeze([]), + unresolved: Object.freeze([unresolved]) + }) +}) diff --git a/src/relay/legacy-physical-worker-authority-host.ts b/src/relay/legacy-physical-worker-authority-host.ts new file mode 100644 index 00000000000..e06c7b41323 --- /dev/null +++ b/src/relay/legacy-physical-worker-authority-host.ts @@ -0,0 +1,321 @@ +import { isDeepStrictEqual } from 'node:util' +import { assertTerminalLegacyMigrationImportRequest } from '../shared/terminal-legacy-cutover-request-validation' +import type { + TerminalLegacyCutoverProof, + TerminalLegacyGcProtection, + TerminalLegacyMigrationImportRequest, + TerminalLegacyMigrationReceipt, + TerminalLegacyWorkerRoute +} from '../shared/terminal-legacy-cutover' +import type { TerminalLegacyWorkerLiveRegistration } from '../main/session-authority/terminal-legacy-worker-live-registration' +import type { TerminalLegacyPhysicalWorkerAuthorityEntry } from '../main/session-authority/terminal-session-authority-legacy-projection' +import type { LegacyPhysicalWorkerDescriptor } from './legacy-physical-worker-control-protocol' +import type { LegacyPhysicalWorkerCutoverSession } from './legacy-physical-worker-cutover-session' +import { + inspectLegacyPhysicalWorker, + restoreLegacyPhysicalWorker +} from './legacy-physical-worker-cutover-session' +import type { LegacyPhysicalWorkerRegistry } from './legacy-physical-worker-registry' + +const DEFAULT_MAX_PENDING_CUTOVERS = 16 + +export type LegacyPhysicalWorkerMigrationAuthority = Readonly<{ + importMigration: ( + request: TerminalLegacyMigrationImportRequest + ) => Promise> + activateWorker: (registration: TerminalLegacyWorkerLiveRegistration) => Promise + deactivateWorker: (routeId: string, registrationId: string) => Promise + gcProtection: () => TerminalLegacyGcProtection + physicalWorkerEntries: () => readonly TerminalLegacyPhysicalWorkerAuthorityEntry[] + projection: () => Readonly<{ revision: number }> +}> + +export type LegacyPhysicalWorkerInspection = Readonly<{ + workerId: string + routeId: string + buildId: string + ptys: LegacyPhysicalWorkerCutoverSession['inventory'] +}> + +export type LegacyPhysicalWorkerMigrationResult = Readonly<{ + receipt: TerminalLegacyMigrationReceipt + duplicate: boolean + inspection: LegacyPhysicalWorkerInspection + gcProtection: TerminalLegacyGcProtection +}> + +export type LegacyPhysicalWorkerAuthorityRegistry = Readonly<{ + register: LegacyPhysicalWorkerRegistry['register'] +}> + +export type LegacyPhysicalWorkerAuthorityHostOperations = Readonly<{ + inspect: typeof inspectLegacyPhysicalWorker + restore: typeof restoreLegacyPhysicalWorker +}> + +type PendingCutover = Readonly<{ + descriptor: LegacyPhysicalWorkerDescriptor + session: LegacyPhysicalWorkerCutoverSession +}> + +export class LegacyPhysicalWorkerAuthorityHost { + private readonly pending = new Map>() + private readonly activeProtection = new Map() + private readonly pendingProtection = new Map() + private readonly deactivationListeners = new Map void>() + private readonly maxPendingCutovers: number + private disposed = false + + constructor( + private readonly registry: LegacyPhysicalWorkerAuthorityRegistry, + private readonly authority: LegacyPhysicalWorkerMigrationAuthority, + limits: Readonly<{ maxPendingCutovers?: number }> = {}, + private readonly operations: LegacyPhysicalWorkerAuthorityHostOperations = { + inspect: inspectLegacyPhysicalWorker, + restore: restoreLegacyPhysicalWorker + } + ) { + this.maxPendingCutovers = positiveLimit(limits.maxPendingCutovers, DEFAULT_MAX_PENDING_CUTOVERS) + } + + async inspect( + descriptor: LegacyPhysicalWorkerDescriptor + ): Promise { + const pending = await this.pendingCutover(descriptor) + return inspection(pending.session) + } + + async migrate(input: { + descriptor: LegacyPhysicalWorkerDescriptor + catalog: Readonly<{ + migrationId: string + authorityHostId: string + requestedAtMs: number + imports: TerminalLegacyMigrationImportRequest['imports'] + unresolved: TerminalLegacyMigrationImportRequest['unresolved'] + }> + }): Promise { + this.assertOpen() + const pending = await this.pendingCutover(input.descriptor) + const preserved = await pending.session.cutover() + const registration = await this.registry.register(preserved.registration) + if (registration.status !== 'registered') { + throw new Error(`legacy physical worker registration failed: ${registration.status}`) + } + this.activeProtection.set(preserved.route.routeId, preserved.route.gcProtection) + const request = Object.freeze({ + version: 1 as const, + mode: 'cutover' as const, + migrationId: input.catalog.migrationId, + authorityHostId: input.catalog.authorityHostId, + requestedAtMs: input.catalog.requestedAtMs, + workerRoute: preserved.route, + cutover: preserved.proof, + imports: input.catalog.imports, + unresolved: input.catalog.unresolved + }) + assertTerminalLegacyMigrationImportRequest(request) + const committed = await this.authority.importMigration(request) + await this.activateRegistration(preserved.route, preserved.proof, preserved.registration.client) + this.pending.delete(input.descriptor.routeId) + this.pendingProtection.delete(input.descriptor.routeId) + return Object.freeze({ + ...committed, + inspection: inspection(pending.session), + gcProtection: this.gcProtection() + }) + } + + async restoreAuthorityWorkers(): Promise< + readonly Readonly<{ + routeId: string + status: 'restored' | 'unreachable' + reason?: string + }>[] + > { + this.assertOpen() + return Object.freeze( + await Promise.all( + this.authority.physicalWorkerEntries().map(async (entry) => { + this.activeProtection.set(entry.route.routeId, entry.route.gcProtection) + try { + const registration = await this.operations.restore({ + route: entry.route, + proof: entry.cutover + }) + const registered = await this.registry.register(registration) + if (registered.status !== 'registered') { + throw new Error(`registry-${registered.status}`) + } + await this.activateRegistration(entry.route, entry.cutover, registration.client) + return Object.freeze({ + routeId: entry.route.routeId, + status: 'restored' as const + }) + } catch (error) { + return Object.freeze({ + routeId: entry.route.routeId, + status: 'unreachable' as const, + reason: error instanceof Error ? error.message : String(error) + }) + } + }) + ) + ) + } + + gcProtection(): TerminalLegacyGcProtection { + const authority = this.authority.gcProtection() + const relayDirectories = new Set(authority.relayDirectories) + const evidencePaths = new Set(authority.evidencePaths) + for (const protection of this.activeProtection.values()) { + protection.relayDirectories.forEach((path) => relayDirectories.add(path)) + protection.evidencePaths.forEach((path) => evidencePaths.add(path)) + } + for (const protection of this.pendingProtection.values()) { + protection.relayDirectories.forEach((path) => relayDirectories.add(path)) + protection.evidencePaths.forEach((path) => evidencePaths.add(path)) + } + return Object.freeze({ + relayDirectories: Object.freeze([...relayDirectories].sort()), + evidencePaths: Object.freeze([...evidencePaths].sort()) + }) + } + + gcEligible(): TerminalLegacyGcProtection { + const relayDirectories = new Set() + const evidencePaths = new Set() + for (const entry of this.authority.physicalWorkerEntries()) { + entry.route.gcProtection.relayDirectories.forEach((path) => relayDirectories.add(path)) + entry.route.gcProtection.evidencePaths.forEach((path) => evidencePaths.add(path)) + } + return Object.freeze({ + relayDirectories: Object.freeze([...relayDirectories].sort()), + evidencePaths: Object.freeze([...evidencePaths].sort()) + }) + } + + catalogRevision(): number { + return this.authority.projection().revision + } + + dispose(): void { + if (this.disposed) { + return + } + this.disposed = true + for (const remove of this.deactivationListeners.values()) { + remove() + } + this.deactivationListeners.clear() + for (const pending of this.pending.values()) { + void pending.then(({ session }) => session.client.close()).catch(() => {}) + } + this.pending.clear() + this.pendingProtection.clear() + } + + private pendingCutover(descriptor: LegacyPhysicalWorkerDescriptor): Promise { + this.assertOpen() + const current = this.pending.get(descriptor.routeId) + if (current) { + return current.then((pending) => { + if (!isDeepStrictEqual(pending.descriptor, descriptor)) { + throw new Error('legacy physical worker route ID was reused') + } + return pending + }) + } + if (this.pending.size >= this.maxPendingCutovers) { + throw new Error('legacy physical worker cutover capacity exceeded') + } + const opened = this.operations + .inspect(descriptor) + .then((session) => Object.freeze({ descriptor: structuredClone(descriptor), session })) + this.pending.set(descriptor.routeId, opened) + this.pendingProtection.set( + descriptor.routeId, + Object.freeze({ + relayDirectories: Object.freeze([descriptor.relayDirectory]), + evidencePaths: Object.freeze([...descriptorEvidencePaths(descriptor)].sort()) + }) + ) + opened.catch(() => { + if (this.pending.get(descriptor.routeId) === opened) { + this.pending.delete(descriptor.routeId) + this.pendingProtection.delete(descriptor.routeId) + } + }) + return opened + } + + private async activateRegistration( + route: TerminalLegacyWorkerRoute, + cutover: TerminalLegacyCutoverProof, + client: LegacyPhysicalWorkerCutoverSession['client'] + ): Promise { + const registrationId = `${route.routeId}:${client.brokerConnectionIdentity}` + await this.authority.activateWorker({ + registrationId, + routeId: route.routeId, + workerId: route.workerId, + ownerIncarnationId: route.ownerIncarnationId, + buildId: route.buildId, + brokerConnectionIdentity: client.brokerConnectionIdentity, + process: route.process, + endpoint: cutover.endpointIdentity + }) + this.deactivationListeners.get(route.routeId)?.() + const remove = client.onClose(() => { + if (this.deactivationListeners.get(route.routeId) !== remove) { + return + } + this.deactivationListeners.delete(route.routeId) + this.activeProtection.delete(route.routeId) + void this.authority.deactivateWorker(route.routeId, registrationId).catch(() => {}) + }) + this.deactivationListeners.set(route.routeId, remove) + } + + private assertOpen(): void { + if (this.disposed) { + throw new Error('legacy physical worker authority host is disposed') + } + } +} + +function inspection(session: LegacyPhysicalWorkerCutoverSession): LegacyPhysicalWorkerInspection { + return Object.freeze({ + workerId: session.descriptor.workerId, + routeId: session.descriptor.routeId, + buildId: session.descriptor.buildId, + ptys: Object.freeze(structuredClone(session.inventory)) + }) +} + +function descriptorEvidencePaths(descriptor: LegacyPhysicalWorkerDescriptor): readonly string[] { + return descriptor.platform === 'win32' + ? [ + descriptor.pipeName, + descriptor.activePipeMarkerPath, + descriptor.privateActivePipeMarkerPath, + descriptor.publicCredentialFile, + descriptor.privateCredentialFile, + descriptor.privateStateDirectory + ] + : [ + descriptor.publicSocketPath, + descriptor.privateSocketPath, + descriptor.publicCredentialFile, + descriptor.privateCredentialFile, + descriptor.privateStateDirectory + ] +} + +function positiveLimit(value: number | undefined, fallback: number): number { + const selected = value ?? fallback + if (!Number.isSafeInteger(selected) || selected < 1 || selected > 256) { + throw new Error('legacy physical worker cutover limit is invalid') + } + return selected +} diff --git a/src/relay/legacy-physical-worker-authority-mutations.ts b/src/relay/legacy-physical-worker-authority-mutations.ts new file mode 100644 index 00000000000..4dd2b19a2cf --- /dev/null +++ b/src/relay/legacy-physical-worker-authority-mutations.ts @@ -0,0 +1,58 @@ +import type { TerminalSessionAuthorityPtyAccess } from '../shared/terminal-session-authority-pty-access' +import type { LegacyPhysicalWorkerAuthorityRouterOptions } from './legacy-physical-worker-authority-session' +import type { LegacyPhysicalWorkerMutation } from './legacy-physical-worker-mutation' + +type ShutdownMutation = Extract + +export class LegacyPhysicalWorkerAuthorityMutations { + constructor( + private readonly registry: LegacyPhysicalWorkerAuthorityRouterOptions['registry'], + private readonly isDisposed: () => boolean, + private readonly reportFault: (error: Error) => void + ) {} + + dispatch( + access: TerminalSessionAuthorityPtyAccess, + mutation: Exclude + ): Promise { + return this.isDisposed() ? Promise.resolve(false) : this.dispatchExact(access, mutation) + } + + async persistAndDispatchShutdown( + access: TerminalSessionAuthorityPtyAccess, + mutation: ShutdownMutation, + persistClose: () => Promise + ): Promise { + await persistClose() + if (this.isDisposed()) { + return true + } + try { + await this.dispatchExact(access, mutation) + } catch (error) { + this.reportFault(error instanceof Error ? error : new Error(String(error))) + } + return true + } + + ensureShutdown( + access: TerminalSessionAuthorityPtyAccess, + mutation: ShutdownMutation + ): Promise { + return this.isDisposed() ? Promise.resolve(false) : this.dispatchExact(access, mutation) + } + + private dispatchExact( + access: TerminalSessionAuthorityPtyAccess, + mutation: LegacyPhysicalWorkerMutation + ): Promise { + return this.registry.dispatchPtyMutation( + access.binding.ownerIncarnationId, + { + id: access.binding.physicalPtyId, + incarnationId: access.binding.ptyIncarnationId + }, + mutation + ) + } +} diff --git a/src/relay/legacy-physical-worker-authority-preservation.ts b/src/relay/legacy-physical-worker-authority-preservation.ts new file mode 100644 index 00000000000..d512d1a7797 --- /dev/null +++ b/src/relay/legacy-physical-worker-authority-preservation.ts @@ -0,0 +1,18 @@ +import type { LegacyPhysicalWorkerRegistry } from './legacy-physical-worker-registry' + +export type LegacyPhysicalWorkerAuthorityRoute = Readonly<{ + ownerIncarnationId: string + routeId: string +}> + +export function preserveLegacyPhysicalWorkerAuthorityRoutes( + registry: LegacyPhysicalWorkerRegistry, + routes: readonly LegacyPhysicalWorkerAuthorityRoute[] +): void { + for (const route of routes) { + const result = registry.preserve(route) + if (result.status !== 'preserved') { + throw new Error(`legacy physical worker authority preservation failed: ${result.status}`) + } + } +} diff --git a/src/relay/legacy-physical-worker-authority-router.test.ts b/src/relay/legacy-physical-worker-authority-router.test.ts new file mode 100644 index 00000000000..b4e595de292 --- /dev/null +++ b/src/relay/legacy-physical-worker-authority-router.test.ts @@ -0,0 +1,709 @@ +import { describe, expect, it, vi } from 'vitest' +import type { PtySourceSpan } from '../shared/pty-source-credit-contract' +import type { PtySourceReceivingActivation } from '../shared/pty-source-receiving-activation' +import type { SinkWriteSettlement } from './dispatcher' +import type { LegacyPhysicalWorkerAttachRequest } from './legacy-physical-worker-attach-router' +import { LegacyPhysicalWorkerAuthorityRouter } from './legacy-physical-worker-authority-router' +import { + LegacyPhysicalWorkerClient, + type LegacyPhysicalWorkerRpc +} from './legacy-physical-worker-client' +import type { LegacyPhysicalWorkerDownstreamAttachment } from './legacy-physical-worker-downstream' +import type { LegacyPtyProxyExit } from './legacy-pty-proxy' +import type { + LegacyPtyProxyCheckpointStore, + LegacyPtyProxyCursorCheckpoint +} from './legacy-pty-proxy-cursor' +import type { + LegacyPtyProxyCursorRepository, + LegacyPtyProxyCursorRestore +} from './legacy-pty-proxy-cursor-repository' +import type { LegacyPhysicalWorkerExactRoute } from './legacy-physical-worker-exact-route' + +class MemoryLegacyPtyProxyCursorRepository implements LegacyPtyProxyCursorRepository { + private readonly records = new Map() + + restore(bindingKey: string): LegacyPtyProxyCursorRestore | null { + const checkpoint = this.records.get(bindingKey) + if (!checkpoint) { + return null + } + return Object.freeze({ + checkpoint, + cursor: Object.freeze({ + durableDownstreamAckedEndSu: checkpoint.creditedEndSu, + upstreamAckedEndSu: 0 + }), + sourceRecovery: Object.freeze({ + status: 'checkpoint', + clientGeneration: checkpoint.identity.clientGeneration, + ownerGeneration: checkpoint.identity.ownerGeneration, + ptyIncarnation: checkpoint.identity.ptyIncarnation, + deliveryToken: checkpoint.identity.deliveryToken, + acceptedSourceEndSu: checkpoint.creditedEndSu + }) + }) + } + + checkpointStore(bindingKey: string): LegacyPtyProxyCheckpointStore { + return Object.freeze({ + commit: async (checkpoint: LegacyPtyProxyCursorCheckpoint) => { + const previous = this.records.get(bindingKey) + if (previous && checkpoint.creditedEndSu < previous.creditedEndSu) { + throw new Error('legacy PTY proxy cursor regressed') + } + this.records.set(bindingKey, Object.freeze(structuredClone(checkpoint))) + } + }) + } +} + +describe('legacy physical worker authority router', () => { + it('orders exact worker mutations and forwards pause to the imported delivery', async () => { + const fixture = routerFixture() + await fixture.router.attachReachablePty(attachRequest) + let releaseFirst!: (value: unknown) => void + fixture.rpc.mutationHandler + .mockImplementationOnce( + async () => await new Promise((resolve) => (releaseFirst = resolve)) + ) + .mockResolvedValueOnce({ accepted: true }) + + const first = fixture.router.dispatchMutation('pty-1', 'incarnation-1', { + kind: 'signal', + signal: 'SIGTERM' + }) + const second = fixture.router.dispatchMutation('pty-1', 'incarnation-1', { + kind: 'clear' + }) + await vi.waitFor(() => expect(fixture.rpc.mutationHandler).toHaveBeenCalledTimes(1)) + releaseFirst({ accepted: true }) + await expect(first).resolves.toBe(true) + await expect(second).resolves.toBe(true) + expect(fixture.rpc.mutationHandler.mock.calls.map((call) => call[0])).toEqual([ + 'pty.sendSignalExact', + 'pty.clearBufferExact' + ]) + expect(fixture.registry.resolveExactPtyRoute).toHaveBeenCalledOnce() + + fixture.router.setDeliveryPaused(fixture.downstream.identity, true) + fixture.router.setDeliveryPaused(fixture.downstream.identity, false) + expect( + fixture.rpc.notifications.filter((entry) => entry.method === 'pty.setDeliveryPaused') + ).toEqual([ + { + method: 'pty.setDeliveryPaused', + params: expect.objectContaining({ + id: 'pty-1', + deliveryToken: 'upstream-token:incarnation-1', + paused: true + }) + }, + { + method: 'pty.setDeliveryPaused', + params: expect.objectContaining({ + id: 'pty-1', + deliveryToken: 'upstream-token:incarnation-1', + paused: false + }) + } + ]) + await expect( + fixture.router.setHeldProducerPause('pty-1', 'incarnation-1', 'held-1', true) + ).resolves.toBe(true) + expect(fixture.rpc.mutationHandler).toHaveBeenLastCalledWith( + 'pty.setDeliveryPaused', + expect.objectContaining({ + id: 'pty-1', + ptyIncarnationId: 'incarnation-1', + heldPauseToken: 'held-1', + paused: true + }) + ) + }) + + it('publishes output before exit and advances upstream only after durable downstream ACK', async () => { + const fixture = routerFixture() + const attached = await fixture.router.attachReachablePty(attachRequest) + expect(attached).toEqual({ + incarnationId: 'incarnation-1', + sourceActivation: fixture.downstream.sourceActivation + }) + + fixture.rpc.emit('pty.data', sourceSpan(0, 4, 'four')) + fixture.rpc.emit('pty.exit', { id: 'pty-1', incarnationId: 'incarnation-1', code: 7 }) + expect(fixture.downstream.publications).toEqual(['data:4']) + expect(fixture.rpc.acknowledgements).toEqual([]) + + fixture.downstream.settleData({ ok: true }) + await vi.waitFor(() => expect(fixture.downstream.publications).toEqual(['data:4', 'exit:4'])) + fixture.downstream.settleExit({ ok: true }) + await vi.waitFor(() => expect(fixture.onExitSettled).toHaveBeenCalledWith(attachRequest, 7)) + expect(fixture.rpc.acknowledgements).toEqual([]) + + fixture.downstream.creditedEndSu = 4 + fixture.router.handleDownstreamCredit(fixture.downstream.identity) + await vi.waitFor(() => expect(fixture.rpc.acknowledgements).toHaveLength(1)) + expect(fixture.rpc.acknowledgements[0]).toMatchObject({ + id: 'pty-1', + deliveryToken: 'upstream-token:incarnation-1', + creditedEndSu: 4 + }) + expect(fixture.cursors.restore(bindingKey)?.checkpoint.creditedEndSu).toBe(4) + }) + + it('records an imported exit before publishing its stable authority outcome', async () => { + let keepOutcomePending!: () => void + const recordExit = vi.fn( + async () => await new Promise((resolve) => (keepOutcomePending = resolve)) + ) + const fixture = routerFixture({ recordExit }) + await fixture.router.attachReachablePty(attachRequest) + fixture.rpc.emit('pty.exit', { id: 'pty-1', incarnationId: 'incarnation-1', code: 7 }) + await vi.waitFor(() => expect(recordExit).toHaveBeenCalledWith(attachRequest, 7)) + expect(fixture.downstream.publications).toEqual([]) + const authorityOutcome = importedExitOutcome() + const effect = authorityOutcome.result.effects.find( + (candidate) => candidate.kind === 'terminal-exited' + )! + const attempt = { + identity: { + version: 1 as const, + namespace: authorityOutcome.result.namespace, + pane: attachRequest.pane, + binding: attachRequest.binding, + consumerId: authorityOutcome.consumerId, + outcomeId: authorityOutcome.outcomeId, + sequence: authorityOutcome.sequence + }, + supportsClient: () => true, + markPublished: vi.fn(), + markOrderedComplete: vi.fn() + } + + expect(fixture.router.publishAuthorityOutcome(authorityOutcome, effect, attempt)).toBe(true) + expect(fixture.downstream.publications).toEqual(['exit:0']) + expect(attempt.markPublished).toHaveBeenCalledWith([1]) + fixture.downstream.settleExit({ ok: true }) + expect(attempt.markOrderedComplete).toHaveBeenCalledOnce() + keepOutcomePending() + }) + + it('keeps duplicate attachment and disposal paths idempotent', async () => { + const fixture = routerFixture() + await fixture.router.attachReachablePty(attachRequest) + const first = fixture.downstreams[0] + await fixture.router.attachReachablePty(attachRequest) + expect(first.dispose).not.toHaveBeenCalled() + expect(fixture.downstreams).toHaveLength(1) + + fixture.router.dispose() + fixture.router.dispose() + expect(first.dispose).toHaveBeenCalledOnce() + await expect(fixture.router.attachReachablePty(attachRequest)).rejects.toThrow( + 'authority router is disposed' + ) + }) + + it('routes owner-scoped ID collisions by exact public incarnation and rejects ambiguity', async () => { + const fixture = routerFixture() + await fixture.router.attachReachablePty(attachRequest) + const second = attachRequestFor('legacy-owner-2', 'incarnation-2') + await expect(fixture.router.attachReachablePty(second)).resolves.toMatchObject({ + incarnationId: 'incarnation-2' + }) + expect(fixture.downstreams).toHaveLength(2) + + await expect( + fixture.router.dispatchMutation('pty-1', 'incarnation-2', { + kind: 'clear' + }) + ).resolves.toBe(true) + expect(fixture.rpc.mutationHandler).toHaveBeenCalledWith( + 'pty.clearBufferExact', + expect.objectContaining({ id: 'pty-1', incarnationId: 'incarnation-2' }) + ) + + await expect( + fixture.router.attachReachablePty(attachRequestFor('legacy-owner-3', 'incarnation-1')) + ).rejects.toThrow('public route identity is ambiguous') + }) + + it('rejects ID-only and post-exit mutations without probing inventory', async () => { + const fixture = routerFixture() + await fixture.router.attachReachablePty(attachRequest) + + await expect( + fixture.router.dispatchMutation('pty-1', undefined as never, { + kind: 'data', + data: 'stale' + }) + ).resolves.toBe(false) + fixture.rpc.emit('pty.exit', { + id: 'pty-1', + incarnationId: 'incarnation-1', + code: 0 + }) + await expect( + fixture.router.dispatchMutation('pty-1', 'incarnation-1', { + kind: 'data', + data: 'after-exit' + }) + ).resolves.toBe(false) + expect(fixture.rpc.mutationHandler).not.toHaveBeenCalled() + expect(fixture.registry.resolveExactPtyRoute).toHaveBeenCalledOnce() + }) + + it('persists an imported close before every exact physical shutdown attempt', async () => { + const fixture = routerFixture() + const events: string[] = [] + fixture.registry.dispatchPtyMutation.mockImplementation(async () => { + events.push('physical-shutdown') + return true + }) + const access = { + namespace: { authorityHostId: 'host-a', namespaceId: 'namespace-a' }, + pane: { paneKey: 'pane-a', paneGenerationId: 'renderer:3' }, + binding: attachRequest.binding + } + + const mutation = { kind: 'shutdown' as const, immediate: true, keepHistory: true } + const persistClose = vi.fn(async () => { + events.push('durable-close') + }) + + await expect( + fixture.router.dispatchAuthorityShutdown(access, mutation, persistClose) + ).resolves.toBe(true) + await expect( + fixture.router.dispatchAuthorityShutdown(access, mutation, persistClose) + ).resolves.toBe(true) + expect(events).toEqual([ + 'durable-close', + 'physical-shutdown', + 'durable-close', + 'physical-shutdown' + ]) + expect(fixture.registry.dispatchPtyMutation).toHaveBeenNthCalledWith( + 1, + attachRequest.binding.ownerIncarnationId, + { + id: attachRequest.binding.physicalPtyId, + incarnationId: attachRequest.binding.ptyIncarnationId + }, + mutation + ) + }) + + it('re-ensures a committed shutdown without a second topology mutation', async () => { + const fixture = routerFixture() + const access = { + namespace: { authorityHostId: 'host-a', namespaceId: 'namespace-a' }, + pane: { paneKey: 'pane-a', paneGenerationId: 'renderer:3' }, + binding: attachRequest.binding + } + const mutation = { kind: 'shutdown' as const, immediate: true } + + await expect(fixture.router.ensureAuthorityShutdown(access, mutation)).resolves.toBe(true) + expect(fixture.registry.dispatchPtyMutation).toHaveBeenCalledWith( + attachRequest.binding.ownerIncarnationId, + { + id: attachRequest.binding.physicalPtyId, + incarnationId: attachRequest.binding.ptyIncarnationId + }, + mutation + ) + + fixture.registry.dispatchPtyMutation.mockRejectedValueOnce(new Error('lost response')) + await expect(fixture.router.ensureAuthorityShutdown(access, mutation)).rejects.toThrow( + 'lost response' + ) + }) + + it('keeps thousands of imported writes on the captured O(1) route', async () => { + const fixture = routerFixture() + await fixture.router.attachReachablePty(attachRequest) + + await expect( + Promise.all( + Array.from({ length: 2_000 }, (_, index) => + fixture.router.dispatchMutation('pty-1', 'incarnation-1', { + kind: 'data', + data: String(index) + }) + ) + ) + ).resolves.toEqual(Array.from({ length: 2_000 }, () => true)) + expect(fixture.registry.resolveExactPtyRoute).toHaveBeenCalledOnce() + expect( + fixture.rpc.notifications.filter((entry) => entry.method === 'pty.dataExact') + ).toHaveLength(2_000) + }) + + it('revokes queued mutations and events when an exact route generation is superseded', async () => { + const firstRpc = new WorkerRpc() + const secondRpc = new WorkerRpc() + const firstClient = workerClient(firstRpc) + const secondClient = workerClient(secondRpc) + let generation = 1 + let route: LegacyPhysicalWorkerExactRoute = Object.freeze({ + client: firstClient, + generation: 1, + isCurrent: () => generation === 1 + }) + const downstreams: ControlledDownstream[] = [] + const router = new LegacyPhysicalWorkerAuthorityRouter({ + registry: { + resolveExactPtyRoute: async () => route, + dispatchPtyMutation: async () => false, + reservesPhysicalPtyId: () => false + }, + downstream: { + open: (input) => { + const downstream = new ControlledDownstream(input.id, input.incarnationId) + downstreams.push(downstream) + return Object.freeze({ status: 'attached' as const, attachment: downstream }) + } + }, + cursors: new MemoryLegacyPtyProxyCursorRepository() + }) + await router.attachReachablePty(attachRequest) + let releaseMutation!: () => void + firstRpc.mutationHandler.mockImplementationOnce( + async () => + await new Promise((resolve) => (releaseMutation = () => resolve({ accepted: true }))) + ) + const inFlight = router.dispatchMutation('pty-1', 'incarnation-1', { kind: 'clear' }) + const staleQueued = router.dispatchMutation('pty-1', 'incarnation-1', { kind: 'clear' }) + await vi.waitFor(() => expect(firstRpc.mutationHandler).toHaveBeenCalledOnce()) + + generation = 2 + route = Object.freeze({ + client: secondClient, + generation: 2, + isCurrent: () => generation === 2 + }) + await expect(router.attachReachablePty(attachRequest)).resolves.toMatchObject({ + incarnationId: 'incarnation-1' + }) + releaseMutation() + await expect(inFlight).resolves.toBe(true) + await expect(staleQueued).resolves.toBe(false) + + firstRpc.emit('pty.data', sourceSpan(0, 4, 'old!')) + expect(downstreams[1].publications).toEqual([]) + secondRpc.emit('pty.data', sourceSpan(0, 4, 'new!')) + expect(downstreams[1].publications).toEqual(['data:4']) + expect(firstRpc.mutationHandler).toHaveBeenCalledOnce() + expect(secondRpc.mutationHandler).not.toHaveBeenCalled() + }) +}) + +function routerFixture(options: { recordExit?: () => Promise } = {}) { + const rpc = new WorkerRpc() + const client = workerClient(rpc) + const route = Object.freeze({ client, generation: 1, isCurrent: () => true }) + const registry = { + resolveExactPtyRoute: vi.fn(async () => route), + dispatchPtyMutation: vi.fn(async () => true), + reservesPhysicalPtyId: vi.fn(() => false) + } + const downstreams: ControlledDownstream[] = [] + const cursors = new MemoryLegacyPtyProxyCursorRepository() + const onExitSettled = vi.fn(async () => {}) + const router = new LegacyPhysicalWorkerAuthorityRouter({ + registry, + downstream: { + open: (input) => { + const downstream = new ControlledDownstream(input.id, input.incarnationId) + downstreams.push(downstream) + return Object.freeze({ status: 'attached' as const, attachment: downstream }) + } + }, + cursors, + onExitSettled, + ...(options.recordExit ? { recordExit: options.recordExit } : {}) + }) + return { + router, + rpc, + registry, + downstreams, + get downstream(): ControlledDownstream { + const downstream = downstreams.at(-1) + if (!downstream) { + throw new Error('downstream was not opened') + } + return downstream + }, + cursors, + onExitSettled + } +} + +class ControlledDownstream implements LegacyPhysicalWorkerDownstreamAttachment { + readonly publications: string[] = [] + readonly sourceActivation: PtySourceReceivingActivation + readonly dispose = vi.fn() + readonly identity + creditedEndSu = 0 + private dataSettlement: ((result: SinkWriteSettlement) => void) | null = null + private exitSettlement: ((result: SinkWriteSettlement) => void) | null = null + + constructor(id: string, incarnationId: string) { + const deliveryToken = `downstream-token:${incarnationId}` + this.sourceActivation = Object.freeze({ + status: 'pending', + clientGeneration: 5, + ownerGeneration: 6, + ptyIncarnation: incarnationId, + deliveryToken, + checkpointSourceEndSu: 0, + recoveryEndSu: 0 + }) + this.identity = Object.freeze({ + id, + providerGeneration: 1, + clientGeneration: 5, + ownerGeneration: 6, + ptyIncarnation: incarnationId, + deliveryToken + }) + } + + publishData(span: PtySourceSpan, onSettled: (result: SinkWriteSettlement) => void): boolean { + this.publications.push(`data:${span.sourceEndSu}`) + this.dataSettlement = onSettled + return true + } + + publishExit(exit: LegacyPtyProxyExit, onSettled: (result: SinkWriteSettlement) => void): boolean { + this.publications.push(`exit:${exit.sourceEndSu}`) + if (exit.authorityOutcome?.supportsClient(1)) { + exit.authorityOutcome.markPublished([1]) + } + this.exitSettlement = (result) => { + onSettled(result) + if (result.ok) { + exit.authorityOutcome?.markOrderedComplete() + } + } + return true + } + + acknowledgedEndSu(): number { + return this.creditedEndSu + } + + onCreditAvailable(): void {} + + reopen(): null { + return null + } + + settleData(result: SinkWriteSettlement): void { + this.dataSettlement?.(result) + } + + settleExit(result: SinkWriteSettlement): void { + this.exitSettlement?.(result) + } +} + +class WorkerRpc implements LegacyPhysicalWorkerRpc { + readonly notifications: { method: string; params: Record }[] = [] + readonly acknowledgements: Record[] = [] + readonly mutationHandler = vi.fn< + (method: string, params: Record) => Promise + >(async (method) => (method === 'pty.setDeliveryPaused' ? { applied: true } : { accepted: true })) + private readonly notificationListeners = new Set< + (method: string, params: Record) => void + >() + + async request(method: string, params: Record = {}): Promise { + if (method !== 'pty.attach') { + return await this.mutationHandler(method, params) + } + return { + incarnationId: params.expectedIncarnationId, + sourceActivation: { + status: 'pending', + clientGeneration: 2, + ownerGeneration: 3, + ptyIncarnation: params.expectedIncarnationId, + deliveryToken: `upstream-token:${String(params.expectedIncarnationId)}`, + checkpointSourceEndSu: 0, + recoveryEndSu: 0 + } + } + } + + notify(method: string, params: Record): void { + this.notifications.push({ method, params }) + } + + notifyWithSettlement( + method: string, + params: Record, + onSettled: (result: SinkWriteSettlement) => void + ): void { + if (method === 'pty.ackData') { + this.acknowledgements.push( + ...((params.acknowledgements as Record[] | undefined) ?? []) + ) + } + onSettled({ ok: true }) + } + + onNotification(listener: (method: string, params: Record) => void): () => void { + this.notificationListeners.add(listener) + return () => this.notificationListeners.delete(listener) + } + + isOpen(): boolean { + return true + } + + onClose(): () => void { + return () => {} + } + + close(): void {} + + emit(method: string, params: Record): void { + this.notificationListeners.forEach((listener) => listener(method, params)) + } +} + +function workerClient(rpc: LegacyPhysicalWorkerRpc): LegacyPhysicalWorkerClient { + return new LegacyPhysicalWorkerClient( + rpc, + { + protocolVersion: 1, + serverBuildId: 'build-1', + clientGeneration: 2, + role: 'session-owner', + ownerGeneration: 3, + ownerLease: 'lease-1', + resumed: false + }, + { + consumerSessionVersion: 1, + outputFlowControlVersion: 1, + exactOperationsVersion: 1, + heldProducerPauseVersion: 1, + mutationMode: 'exact-v1', + sourceWindowSu: 1024 + } + ) +} + +function sourceSpan( + sourceStartSu: number, + sourceEndSu: number, + data: string +): Record { + return { + id: 'pty-1', + providerGeneration: 1, + clientGeneration: 2, + ownerGeneration: 3, + ptyIncarnation: 'incarnation-1', + deliveryToken: 'upstream-token:incarnation-1', + spanId: `span-${sourceEndSu}`, + sourceStartSu, + sourceEndSu, + displayStart: sourceStartSu, + displayEnd: sourceEndSu, + data, + splittable: true, + transform: { transformed: false, rawLengthSu: sourceEndSu - sourceStartSu, scalarSafe: true } + } +} + +function importedExitOutcome() { + const request = { + actorId: 'actor-1', + operationId: 'operation-1', + baseRevision: 1, + consumerId: 'consumer-1', + outcomeId: 'outcome-1', + change: { + kind: 'exit' as const, + pane: attachRequest.pane, + expected: { + paneGenerationId: attachRequest.pane.paneGenerationId, + binding: attachRequest.binding + }, + exit: { code: 7, signal: null } + } + } + return { + consumerId: request.consumerId, + sequence: 1, + outcomeId: request.outcomeId, + request, + result: { + namespace: { authorityHostId: 'host-1', namespaceId: 'namespace-1' }, + actorId: request.actorId, + operationId: request.operationId, + kind: 'exit' as const, + revision: 2, + pane: { + paneKey: attachRequest.pane.paneKey, + paneGenerationId: attachRequest.pane.paneGenerationId, + status: 'exited' as const, + binding: null, + lastBinding: attachRequest.binding, + revision: 2 + }, + replacementPane: null, + allocation: null, + effects: [ + { + kind: 'binding-retired' as const, + reason: 'exit' as const, + binding: attachRequest.binding + }, + { + kind: 'terminal-exited' as const, + binding: attachRequest.binding, + code: 7, + signal: null + } + ] + }, + byteLength: 1 + } +} + +const attachRequest: LegacyPhysicalWorkerAttachRequest = Object.freeze({ + pane: Object.freeze({ paneKey: 'pane-1', paneGenerationId: 'pane-generation-1' }), + binding: Object.freeze({ + ownerIncarnationId: 'legacy-owner-1', + physicalPtyId: 'pty-1', + ptyIncarnationId: 'incarnation-1' + }), + worktreeId: 'repo-1::/workspace', + suppressReplayNotification: true +}) + +function attachRequestFor( + ownerIncarnationId: string, + ptyIncarnationId: string +): LegacyPhysicalWorkerAttachRequest { + return Object.freeze({ + ...attachRequest, + pane: Object.freeze({ + paneKey: `pane-${ownerIncarnationId}`, + paneGenerationId: `pane-generation-${ownerIncarnationId}` + }), + binding: Object.freeze({ + ownerIncarnationId, + physicalPtyId: 'pty-1', + ptyIncarnationId + }) + }) +} + +const bindingKey = JSON.stringify(['legacy-owner-1', 'pty-1', 'incarnation-1']) diff --git a/src/relay/legacy-physical-worker-authority-router.ts b/src/relay/legacy-physical-worker-authority-router.ts new file mode 100644 index 00000000000..ddca92e470d --- /dev/null +++ b/src/relay/legacy-physical-worker-authority-router.ts @@ -0,0 +1,261 @@ +import type { PtySourceDeliveryIdentity } from '../shared/pty-source-credit-contract' +import type { + LegacyPhysicalWorkerAttachRequest, + LegacyPhysicalWorkerAttachResult, + LegacyPhysicalWorkerPtyRouter +} from './legacy-physical-worker-attach-router' +import type { + ImportedPhysicalWorkerPtySession, + LegacyPhysicalWorkerAuthorityRouterOptions +} from './legacy-physical-worker-authority-session' +import { + importedPhysicalWorkerBindingKey, + importedPhysicalWorkerPublicRouteKey +} from './legacy-physical-worker-authority-session' +import { LegacyPhysicalWorkerEventRouter } from './legacy-physical-worker-event-router' +import type { LegacyPhysicalWorkerMutation } from './legacy-physical-worker-mutation' +import type { + TerminalAuthorityOutcome, + TerminalSessionAuthorityEffect +} from '../shared/terminal-session-authority-mutation' +import type { TerminalAuthorityOutcomeDeliveryAttempt } from './terminal-session-authority-outcome-delivery' +import { admitLegacyPhysicalWorkerSession } from './legacy-physical-worker-session-admission' +import type { TerminalSessionAuthorityPtyAccess } from '../shared/terminal-session-authority-pty-access' +import { ensureLegacyPhysicalWorkerAckDrain } from './legacy-physical-worker-ack-drain' +import { LegacyPhysicalWorkerAuthorityMutations } from './legacy-physical-worker-authority-mutations' +import { resolveLegacyPhysicalWorkerSessionLimit } from './legacy-physical-worker-session-capacity' +import { LegacyPhysicalWorkerAuthoritySessionRegistry } from './legacy-physical-worker-authority-session-registry' +import { acceptLegacyPhysicalWorkerUpstreamExit } from './legacy-physical-worker-upstream-exit' + +const DEFAULT_MAX_IMPORTED_PTY_SESSIONS = 256 + +export type { LegacyPhysicalWorkerAuthorityRouterOptions } from './legacy-physical-worker-authority-session' + +export class LegacyPhysicalWorkerAuthorityRouter implements LegacyPhysicalWorkerPtyRouter { + private readonly sessionsByBinding = new Map() + private readonly sessionsByPublicRoute = new Map() + private readonly eventRouter: LegacyPhysicalWorkerEventRouter + private readonly sessionRegistry: LegacyPhysicalWorkerAuthoritySessionRegistry + private readonly authorityMutations: LegacyPhysicalWorkerAuthorityMutations + private readonly maxSessions: number + private disposed = false + + constructor(private readonly options: LegacyPhysicalWorkerAuthorityRouterOptions) { + this.maxSessions = resolveLegacyPhysicalWorkerSessionLimit( + options.maxSessions, + DEFAULT_MAX_IMPORTED_PTY_SESSIONS + ) + this.eventRouter = new LegacyPhysicalWorkerEventRouter( + (error) => this.reportWorkerFault(error), + (session, exit) => this.acceptUpstreamExit(session, exit.code) + ) + this.sessionRegistry = new LegacyPhysicalWorkerAuthoritySessionRegistry( + this.sessionsByBinding, + this.sessionsByPublicRoute, + this.eventRouter + ) + this.authorityMutations = new LegacyPhysicalWorkerAuthorityMutations( + options.registry, + () => this.disposed, + (error) => this.reportWorkerFault(error) + ) + } + + async attachReachablePty( + request: LegacyPhysicalWorkerAttachRequest + ): Promise { + this.assertOpen() + return await admitLegacyPhysicalWorkerSession( + { + sessionsByBinding: this.sessionsByBinding, + sessionsByPublicRoute: this.sessionsByPublicRoute, + eventRouter: this.eventRouter, + maxSessions: this.maxSessions, + options: this.options, + reportFault: (error) => this.reportWorkerFault(error), + retireSession: (session) => this.sessionRegistry.retireSession(session), + onExitPublished: (session, exit) => { + if (exit.authorityOutcome) { + this.sessionRegistry.maybeRetire(session) + } else { + this.recordLegacyExit(session, exit.code) + } + } + }, + request + ) + } + + async dispatchMutation( + id: string, + incarnationId: string, + mutation: LegacyPhysicalWorkerMutation + ): Promise { + const routeKey = importedPhysicalWorkerPublicRouteKey(id, incarnationId) + const session = this.sessionsByPublicRoute.get(routeKey) + if (!session || session.retired || session.upstreamExited || !session.route.isCurrent()) { + return false + } + const operation = session.mutationTail.then(async () => { + if ( + this.sessionsByPublicRoute.get(routeKey) !== session || + session.retired || + session.upstreamExited || + !session.route.isCurrent() + ) { + return false + } + return await session.client.dispatchVerifiedMutation({ id, incarnationId }, mutation) + }) + session.mutationTail = operation.then( + () => undefined, + () => undefined + ) + return await operation + } + + async dispatchAuthorityMutation( + access: TerminalSessionAuthorityPtyAccess, + mutation: Exclude + ): Promise { + return await this.authorityMutations.dispatch(access, mutation) + } + + async dispatchAuthorityShutdown( + access: TerminalSessionAuthorityPtyAccess, + mutation: Extract, + persistClose: () => Promise + ): Promise { + return await this.authorityMutations.persistAndDispatchShutdown(access, mutation, persistClose) + } + + async ensureAuthorityShutdown( + access: TerminalSessionAuthorityPtyAccess, + mutation: Extract + ): Promise { + return await this.authorityMutations.ensureShutdown(access, mutation) + } + + setDeliveryPaused(identity: PtySourceDeliveryIdentity, paused: boolean): boolean { + const session = this.sessionRegistry.downstreamSession(identity) + if (!session) { + return false + } + session.client.setDeliveryPaused({ + id: identity.id, + clientGeneration: session.upstreamIdentity.clientGeneration, + ownerGeneration: session.upstreamIdentity.ownerGeneration, + deliveryToken: session.upstreamIdentity.deliveryToken, + paused + }) + return true + } + + async setHeldProducerPause( + id: string, + incarnationId: string, + token: string, + paused: boolean + ): Promise { + const session = this.sessionsByPublicRoute.get( + importedPhysicalWorkerPublicRouteKey(id, incarnationId) + ) + if (!session || session.retired || session.upstreamExited || !session.route.isCurrent()) { + return false + } + return await session.client.setHeldProducerPause({ + id, + clientGeneration: session.upstreamIdentity.clientGeneration, + ownerGeneration: session.upstreamIdentity.ownerGeneration, + ptyIncarnationId: incarnationId, + heldPauseToken: token, + paused + }) + } + + handleDownstreamCredit(identity: PtySourceDeliveryIdentity): boolean { + const session = this.sessionRegistry.downstreamSession(identity) + if (!session) { + return false + } + session.downstream.onCreditAvailable() + const creditedEndSu = session.downstream.acknowledgedEndSu() + session.requestedAckEndSu = Math.max(session.requestedAckEndSu, creditedEndSu) + ensureLegacyPhysicalWorkerAckDrain( + session, + () => this.sessionRegistry.maybeRetire(session), + (error) => this.reportWorkerFault(error) + ) + return true + } + + reservesPhysicalPtyId(id: string): boolean { + return this.options.registry.reservesPhysicalPtyId(id) + } + + reservesPublicPtyIdentity(id: string, incarnationId: string): boolean { + return this.sessionsByPublicRoute.has(importedPhysicalWorkerPublicRouteKey(id, incarnationId)) + } + + publishAuthorityOutcome( + outcome: TerminalAuthorityOutcome, + effect: Extract, + attempt: TerminalAuthorityOutcomeDeliveryAttempt + ): boolean { + const session = this.sessionsByBinding.get(importedPhysicalWorkerBindingKey(effect.binding)) + if ( + !session || + session.retired || + !session.route.isCurrent() || + !session.upstreamExited || + session.pendingExitCode === null || + effect.code !== session.pendingExitCode || + session.attachRequest.pane.paneKey !== outcome.result.pane.paneKey || + session.attachRequest.pane.paneGenerationId !== outcome.result.pane.paneGenerationId + ) { + return false + } + const accepted = session.proxy.acceptExit({ + id: effect.binding.physicalPtyId, + incarnationId: effect.binding.ptyIncarnationId, + code: session.pendingExitCode, + authorityOutcome: attempt + }) + if (accepted) { + session.exitRecorded = true + } + return accepted + } + + dispose(): void { + if (this.disposed) { + return + } + this.disposed = true + this.sessionRegistry.dispose() + } + + private acceptUpstreamExit(session: ImportedPhysicalWorkerPtySession, code: number): void { + acceptLegacyPhysicalWorkerUpstreamExit(session, code, this.options, () => this.disposed) + } + + private recordLegacyExit(session: ImportedPhysicalWorkerPtySession, code: number): void { + if (session.exitRecorded) { + return + } + session.exitRecorded = true + void this.options.onExitSettled?.(session.attachRequest, code).catch((error) => { + this.reportWorkerFault(error instanceof Error ? error : new Error(String(error))) + }) + } + + private reportWorkerFault(error: Error): void { + this.options.onWorkerFault?.(error) + } + + private assertOpen(): void { + if (this.disposed) { + throw new Error('legacy physical worker authority router is disposed') + } + } +} diff --git a/src/relay/legacy-physical-worker-authority-session-registry.ts b/src/relay/legacy-physical-worker-authority-session-registry.ts new file mode 100644 index 00000000000..f9bdbe86fcd --- /dev/null +++ b/src/relay/legacy-physical-worker-authority-session-registry.ts @@ -0,0 +1,76 @@ +import type { PtySourceDeliveryIdentity } from '../shared/pty-source-credit-contract' +import { + importedPhysicalWorkerBindingKey, + importedPhysicalWorkerPublicRouteKey, + type ImportedPhysicalWorkerPtySession +} from './legacy-physical-worker-authority-session' +import type { LegacyPhysicalWorkerEventRouter } from './legacy-physical-worker-event-router' + +export class LegacyPhysicalWorkerAuthoritySessionRegistry { + constructor( + private readonly sessionsByBinding: Map, + private readonly sessionsByPublicRoute: Map, + private readonly eventRouter: LegacyPhysicalWorkerEventRouter + ) {} + + downstreamSession(identity: PtySourceDeliveryIdentity): ImportedPhysicalWorkerPtySession | null { + const session = this.sessionsByPublicRoute.get( + importedPhysicalWorkerPublicRouteKey(identity.id, identity.ptyIncarnation) + ) + const downstream = session?.downstream.identity + return session && + !session.retired && + !session.downstreamRotating && + session.route.isCurrent() && + downstream && + downstream.deliveryToken === identity.deliveryToken && + downstream.clientGeneration === identity.clientGeneration && + downstream.ownerGeneration === identity.ownerGeneration + ? session + : null + } + + maybeRetire(session: ImportedPhysicalWorkerPtySession): void { + const snapshot = session.proxy.snapshot() + if ( + !session.exitRecorded || + snapshot.exitState !== 'published' || + snapshot.upstreamAckedEndSu !== snapshot.receivedEndSu || + this.sessionsByBinding.get(importedPhysicalWorkerBindingKey(session.binding)) !== session + ) { + return + } + this.retireSession(session) + } + + retireSession(session: ImportedPhysicalWorkerPtySession): void { + if (session.retired) { + return + } + session.retired = true + const bindingKey = importedPhysicalWorkerBindingKey(session.binding) + const publicRouteKey = importedPhysicalWorkerPublicRouteKey( + session.binding.physicalPtyId, + session.binding.ptyIncarnationId + ) + if (this.sessionsByBinding.get(bindingKey) === session) { + this.sessionsByBinding.delete(bindingKey) + } + if (this.sessionsByPublicRoute.get(publicRouteKey) === session) { + this.sessionsByPublicRoute.delete(publicRouteKey) + } + this.eventRouter.unregisterSession(session) + session.downstream.dispose() + session.proxy.dispose() + } + + dispose(): void { + for (const session of this.sessionsByBinding.values()) { + session.downstream.dispose() + session.proxy.dispose() + } + this.sessionsByBinding.clear() + this.sessionsByPublicRoute.clear() + this.eventRouter.dispose() + } +} diff --git a/src/relay/legacy-physical-worker-authority-session.ts b/src/relay/legacy-physical-worker-authority-session.ts new file mode 100644 index 00000000000..3542f2cdf50 --- /dev/null +++ b/src/relay/legacy-physical-worker-authority-session.ts @@ -0,0 +1,151 @@ +import type { TerminalSessionBinding } from '../shared/terminal-session-authority-identity' +import type { + LegacyPhysicalWorkerAttachRequest, + LegacyPhysicalWorkerAttachResult +} from './legacy-physical-worker-attach-router' +import type { LegacyPhysicalWorkerClient } from './legacy-physical-worker-client' +import type { LegacyPhysicalWorkerExactRoute } from './legacy-physical-worker-exact-route' +import type { + LegacyPhysicalWorkerDownstream, + LegacyPhysicalWorkerDownstreamAttachment +} from './legacy-physical-worker-downstream' +import type { + LegacyPtyProxyCheckpointStore, + LegacyPtyProxyCursorCheckpoint +} from './legacy-pty-proxy-cursor' +import type { LegacyPtyProxyCursorRepository } from './legacy-pty-proxy-cursor-repository' +import type { OrderedLegacyPtyProxy } from './legacy-pty-proxy' +import type { parseLegacyPhysicalWorkerActivation } from './legacy-physical-worker-source-event' +import type { LegacyPhysicalWorkerMutation } from './legacy-physical-worker-mutation' + +export type ImportedPhysicalWorkerPtySession = { + binding: TerminalSessionBinding + client: LegacyPhysicalWorkerClient + route: LegacyPhysicalWorkerExactRoute + upstreamIdentity: ReturnType + downstream: LegacyPhysicalWorkerDownstreamAttachment + proxy: OrderedLegacyPtyProxy + cursorStore: LegacyPtyProxyCheckpointStore + ackTask: Promise | null + requestedAckEndSu: number + mutationTail: Promise + exitRecorded: boolean + exitRecording: boolean + pendingExitCode: number | null + upstreamExited: boolean + retired: boolean + downstreamRotating: boolean + attachRequest: LegacyPhysicalWorkerAttachRequest +} + +export type LegacyPhysicalWorkerAuthorityRouterOptions = Readonly<{ + registry: Readonly<{ + resolveExactPtyRoute: ( + ownerIncarnationId: string, + pty: Readonly<{ id: string; incarnationId: string }> + ) => Promise + dispatchPtyMutation: ( + ownerIncarnationId: string, + pty: Readonly<{ id: string; incarnationId: string }>, + mutation: LegacyPhysicalWorkerMutation + ) => Promise + reservesPhysicalPtyId: (id: string) => boolean + }> + downstream: Pick + cursors: LegacyPtyProxyCursorRepository + maxSessions?: number + onExitSettled?: (request: LegacyPhysicalWorkerAttachRequest, code: number) => Promise + recordExit?: (request: LegacyPhysicalWorkerAttachRequest, code: number) => Promise + onWorkerFault?: (error: Error) => void +}> + +export function sameImportedPhysicalWorkerBinding( + left: TerminalSessionBinding, + right: TerminalSessionBinding +): boolean { + return ( + left.ownerIncarnationId === right.ownerIncarnationId && + left.physicalPtyId === right.physicalPtyId && + left.ptyIncarnationId === right.ptyIncarnationId + ) +} + +export function importedPhysicalWorkerBindingKey(binding: TerminalSessionBinding): string { + return JSON.stringify([ + binding.ownerIncarnationId, + binding.physicalPtyId, + binding.ptyIncarnationId + ]) +} + +export function importedPhysicalWorkerPublicRouteKey(id: string, incarnationId: string): string { + return JSON.stringify([id, incarnationId]) +} + +export function importedPhysicalWorkerCheckpoint( + upstreamIdentity: ImportedPhysicalWorkerPtySession['upstreamIdentity'], + downstreamIdentity: LegacyPhysicalWorkerDownstreamAttachment['identity'], + creditedEndSu: number +): LegacyPtyProxyCursorCheckpoint { + const checkpointId = `legacy-pty-cursor:${upstreamIdentity.providerGeneration}:${upstreamIdentity.deliveryToken}` + return Object.freeze({ + checkpointId, + acknowledgementId: `${checkpointId}:${creditedEndSu}`, + identity: upstreamIdentity, + downstreamIdentity, + creditedEndSu + }) +} + +export async function reattachImportedPhysicalWorkerDownstream( + session: ImportedPhysicalWorkerPtySession, + request: LegacyPhysicalWorkerAttachRequest, + reportWorkerFault: (error: Error) => void +): Promise { + if ( + !request.sourceRecovery && + request.context?.clientId === session.attachRequest.context?.clientId + ) { + session.attachRequest = request + return Object.freeze({ + incarnationId: session.binding.ptyIncarnationId, + sourceActivation: session.downstream.sourceActivation + }) + } + const reopened = session.downstream.reopen({ + id: session.binding.physicalPtyId, + incarnationId: session.binding.ptyIncarnationId, + ...(request.sourceRecovery ? { sourceRecovery: request.sourceRecovery } : {}), + context: request.context, + onCapacity: () => session.proxy.onDownstreamCapacity() + }) + if (!reopened) { + return null + } + if (reopened.status === 'restore-required') { + return Object.freeze({ + incarnationId: session.binding.ptyIncarnationId, + sourceRecovery: reopened.sourceRecovery + }) + } + session.downstream = reopened.attachment + session.attachRequest = request + try { + await session.cursorStore.commit( + importedPhysicalWorkerCheckpoint( + session.upstreamIdentity, + reopened.attachment.identity, + session.proxy.snapshot().downstreamAckedEndSu + ) + ) + } catch (error) { + reportWorkerFault(error instanceof Error ? error : new Error(String(error))) + throw error + } + session.proxy.retryDownstreamExit() + return Object.freeze({ + incarnationId: session.binding.ptyIncarnationId, + sourceActivation: reopened.attachment.sourceActivation, + ...(reopened.sourceRecovery ? { sourceRecovery: reopened.sourceRecovery } : {}) + }) +} diff --git a/src/relay/legacy-physical-worker-client-exact-operations.ts b/src/relay/legacy-physical-worker-client-exact-operations.ts new file mode 100644 index 00000000000..b77be6c7d70 --- /dev/null +++ b/src/relay/legacy-physical-worker-client-exact-operations.ts @@ -0,0 +1,68 @@ +import type { LegacyPhysicalWorkerRpc } from './legacy-physical-worker-client' + +type ExactPtyIdentity = Readonly<{ id: string; incarnationId: string }> + +type HeldProducerPauseIdentity = Readonly<{ + id: string + clientGeneration: number + ownerGeneration: number + ptyIncarnationId: string + heldPauseToken: string + paused: boolean +}> + +export class LegacyPhysicalWorkerClientExactOperations { + constructor( + private readonly rpc: LegacyPhysicalWorkerRpc, + private readonly mutationMode: 'exact-v1' | 'legacy-fenced-v1' + ) {} + + write(id: string, incarnationId: string, data: string): void { + this.assertExactMutationMode() + this.rpc.notify('pty.dataExact', { id, incarnationId, data }) + } + + resize(id: string, incarnationId: string, cols: number, rows: number): void { + this.assertExactMutationMode() + this.rpc.notify('pty.resizeExact', { id, incarnationId, cols, rows }) + } + + async signal(id: string, incarnationId: string, signal: string): Promise { + this.assertExactMutationMode() + return await this.exactMutation('pty.sendSignalExact', { id, incarnationId, signal }) + } + + async clear(id: string, incarnationId: string): Promise { + this.assertExactMutationMode() + return await this.exactMutation('pty.clearBufferExact', { id, incarnationId }) + } + + async shutdown(id: string, incarnationId: string, immediate: boolean): Promise { + this.assertExactMutationMode() + return await this.exactMutation('pty.shutdownExact', { id, incarnationId, immediate }) + } + + async setHeldProducerPause(identity: HeldProducerPauseIdentity): Promise { + const result = await this.rpc.request('pty.setDeliveryPaused', identity) + return acceptedResult(result, 'applied') + } + + private async exactMutation(method: string, params: ExactPtyIdentity & Record) { + const result = await this.rpc.request(method, params) + return acceptedResult(result, 'accepted') + } + + private assertExactMutationMode(): void { + if (this.mutationMode !== 'exact-v1') { + throw new Error('legacy-fenced mutation requires registry verification') + } + } +} + +function acceptedResult(value: unknown, field: 'accepted' | 'applied'): boolean { + return ( + typeof value === 'object' && + value !== null && + (value as Record)[field] === true + ) +} diff --git a/src/relay/legacy-physical-worker-client-source-settlement.ts b/src/relay/legacy-physical-worker-client-source-settlement.ts new file mode 100644 index 00000000000..47d51fc0a93 --- /dev/null +++ b/src/relay/legacy-physical-worker-client-source-settlement.ts @@ -0,0 +1,40 @@ +import type { PtySourceCreditAck } from '../shared/pty-source-credit-contract' +import type { LegacyPhysicalWorkerRpc } from './legacy-physical-worker-client' + +export function publishLegacyPhysicalWorkerSourceAcknowledgement( + rpc: LegacyPhysicalWorkerRpc, + acknowledgement: PtySourceCreditAck, + acknowledgementId: string +): Promise { + if (!acknowledgementId) { + return Promise.reject(new Error('legacy physical worker ACK identity is invalid')) + } + const publish = rpc.notifyWithSettlement?.bind(rpc) + if (!publish) { + return Promise.reject( + new Error('legacy physical worker source-credit settlement is unsupported') + ) + } + return new Promise((resolve, reject) => { + let settled = false + const settle = (result: Readonly<{ ok: true }> | Readonly<{ ok: false; error: Error }>) => { + if (settled) { + return + } + settled = true + if (result.ok) { + resolve() + } else { + reject(result.error) + } + } + try { + publish('pty.ackData', { acknowledgements: [acknowledgement] }, settle) + } catch (error) { + settle({ + ok: false, + error: error instanceof Error ? error : new Error(String(error)) + }) + } + }) +} diff --git a/src/relay/legacy-physical-worker-client.ts b/src/relay/legacy-physical-worker-client.ts new file mode 100644 index 00000000000..8379ecfa05b --- /dev/null +++ b/src/relay/legacy-physical-worker-client.ts @@ -0,0 +1,266 @@ +import { + PTY_CONSUMER_SESSION_PROTOCOL_VERSION, + type PtyConsumerSessionGrant +} from '../shared/pty-consumer-session' +import type { PtySourceCreditAck } from '../shared/pty-source-credit-contract' +import type { PtySourceRecoveryRequest } from '../shared/pty-source-recovery-contract' +import { PTY_EXACT_OPERATION_PROTOCOL_VERSION } from '../shared/pty-exact-operation-protocol' +import type { LegacyRelayStatusSample } from './legacy-relay-broker-connection-proof' +import { parseLegacyPhysicalWorkerCutoverStatus } from './legacy-physical-worker-cutover-status' +import { + parseLegacyPhysicalWorkerPty, + type LegacyPhysicalWorkerPty +} from './legacy-physical-worker-inventory' +import { + isLegacyPhysicalWorkerOpenUnsupported, + validateLegacyPhysicalWorkerGrant, + type LegacyPhysicalWorkerCapabilities +} from './legacy-physical-worker-negotiation' +import { + dispatchVerifiedLegacyPhysicalWorkerMutation, + type LegacyPhysicalWorkerMutation, + type LegacyPhysicalWorkerPtyIdentity +} from './legacy-physical-worker-mutation' +import { LegacyPhysicalWorkerClientExactOperations } from './legacy-physical-worker-client-exact-operations' +import { publishLegacyPhysicalWorkerSourceAcknowledgement } from './legacy-physical-worker-client-source-settlement' +import { prepareLegacyPhysicalWorkerCutoverGrace } from './legacy-physical-worker-cutover-grace' + +const MAX_LEGACY_WORKER_PTYS = 50 + +export type LegacyPhysicalWorkerRpc = Readonly<{ + request: (method: string, params?: Record) => Promise + notify: (method: string, params: Record) => void + notifyWithSettlement?: ( + method: string, + params: Record, + onSettled: (result: Readonly<{ ok: true }> | Readonly<{ ok: false; error: Error }>) => void + ) => void + onNotification: ( + listener: (method: string, params: Record) => void + ) => () => void + isOpen: () => boolean + onClose: (listener: () => void) => () => void + close: () => void +}> + +export type { LegacyPhysicalWorkerPty } from './legacy-physical-worker-inventory' + +export type { LegacyPhysicalWorkerCapabilities } from './legacy-physical-worker-negotiation' + +export type LegacyPhysicalWorkerOpenResult = + | Readonly<{ status: 'supported'; client: LegacyPhysicalWorkerClient }> + | Readonly<{ status: 'unsupported'; reason: string }> + +export type { + LegacyPhysicalWorkerMutation, + LegacyPhysicalWorkerPtyIdentity +} from './legacy-physical-worker-mutation' + +export async function openLegacyPhysicalWorker(input: { + rpc: LegacyPhysicalWorkerRpc + clientInstanceId: string + expectedBuildId: string + requestedSourceWindowSu: number + resume?: Readonly<{ ownerGeneration: number; ownerLease: string }> +}): Promise { + if (!input.rpc.isOpen()) { + return Object.freeze({ status: 'unsupported', reason: 'worker-connection-closed' }) + } + if (!input.rpc.notifyWithSettlement) { + return Object.freeze({ status: 'unsupported', reason: 'source-credit-settlement-unsupported' }) + } + let result: unknown + try { + result = await input.rpc.request('pty.openClient', { + protocolVersion: PTY_CONSUMER_SESSION_PROTOCOL_VERSION, + clientInstanceId: input.clientInstanceId, + requestedRole: 'session-owner', + ...(input.resume ? { resume: input.resume } : {}), + capabilities: { + outputFlowControl: { + versions: [1], + requestedWindowSu: input.requestedSourceWindowSu + }, + exactOperations: { versions: [PTY_EXACT_OPERATION_PROTOCOL_VERSION] }, + heldProducerPause: { versions: [1] } + } + }) + } catch (error) { + if (isLegacyPhysicalWorkerOpenUnsupported(error)) { + return Object.freeze({ status: 'unsupported', reason: 'pty.openClient-unsupported' }) + } + throw error + } + const grant = validateLegacyPhysicalWorkerGrant(result, input) + if ('reason' in grant) { + return Object.freeze({ status: 'unsupported', reason: grant.reason }) + } + return Object.freeze({ + status: 'supported', + client: new LegacyPhysicalWorkerClient( + input.rpc, + grant.grant, + grant.capabilities, + input.clientInstanceId + ) + }) +} + +export class LegacyPhysicalWorkerClient { + readonly ownerGeneration: number + readonly ownerLease: string + readonly serverBuildId: string + readonly brokerConnectionIdentity: string + private cutoverGraceAcknowledged = false + private readonly exactOperations: LegacyPhysicalWorkerClientExactOperations + + constructor( + private readonly rpc: LegacyPhysicalWorkerRpc, + grant: Readonly, + readonly capabilities: LegacyPhysicalWorkerCapabilities, + clientInstanceId = 'legacy-broker' + ) { + this.ownerGeneration = grant.ownerGeneration! + this.ownerLease = grant.ownerLease! + this.serverBuildId = grant.serverBuildId + this.brokerConnectionIdentity = `${clientInstanceId}:${grant.clientGeneration}:${grant.ownerGeneration}` + this.exactOperations = new LegacyPhysicalWorkerClientExactOperations( + rpc, + capabilities.mutationMode + ) + } + + isOpen(): boolean { + return this.rpc.isOpen() + } + + onClose(listener: () => void): () => void { + return this.rpc.onClose(listener) + } + + onNotification(listener: (method: string, params: Record) => void): () => void { + return this.rpc.onNotification(listener) + } + + close(): void { + this.rpc.close() + } + + async prepareCutoverGrace(): Promise< + Readonly<{ status: 'ready' }> | Readonly<{ status: 'unsupported'; reason: string }> + > { + return await prepareLegacyPhysicalWorkerCutoverGrace(this.rpc, () => { + this.cutoverGraceAcknowledged = true + }) + } + + async sampleCutoverStatus(): Promise { + if (!this.cutoverGraceAcknowledged) { + throw new Error('legacy physical worker zero grace was not acknowledged') + } + return parseLegacyPhysicalWorkerCutoverStatus( + await this.rpc.request('relay.status'), + this.brokerConnectionIdentity + ) + } + + async listPtys(): Promise { + const result = await this.rpc.request('pty.listProcesses') + if (!Array.isArray(result) || result.length > MAX_LEGACY_WORKER_PTYS) { + throw new Error('legacy physical worker returned an invalid bounded PTY inventory') + } + return result.map(parseLegacyPhysicalWorkerPty) + } + + async attach( + pty: Pick & + Readonly<{ expectedPaneKey?: string; expectedTabId?: string }>, + sourceRecovery?: PtySourceRecoveryRequest + ): Promise> { + const result = await this.rpc.request('pty.attach', { + id: pty.id, + expectedIncarnationId: pty.incarnationId, + expectedPtyIncarnationId: pty.incarnationId, + ...(pty.expectedPaneKey ? { expectedPaneKey: pty.expectedPaneKey } : {}), + ...(pty.expectedTabId ? { expectedTabId: pty.expectedTabId } : {}), + ...(sourceRecovery ? { sourceRecovery } : {}), + suppressReplayNotification: true + }) + if (typeof result !== 'object' || result === null) { + throw new Error('legacy physical worker returned an invalid attach result') + } + const record = result as Record + if (record.incarnationId !== pty.incarnationId) { + throw new Error('legacy physical worker PTY incarnation changed during attach') + } + return record + } + + write(id: string, incarnationId: string, data: string): void { + this.exactOperations.write(id, incarnationId, data) + } + + resize(id: string, incarnationId: string, cols: number, rows: number): void { + this.exactOperations.resize(id, incarnationId, cols, rows) + } + + async signal(id: string, incarnationId: string, signal: string): Promise { + return await this.exactOperations.signal(id, incarnationId, signal) + } + + async clear(id: string, incarnationId: string): Promise { + return await this.exactOperations.clear(id, incarnationId) + } + + async shutdown(id: string, incarnationId: string, immediate = false): Promise { + return await this.exactOperations.shutdown(id, incarnationId, immediate) + } + + async dispatchVerifiedMutation( + pty: LegacyPhysicalWorkerPtyIdentity, + mutation: LegacyPhysicalWorkerMutation + ): Promise { + return await dispatchVerifiedLegacyPhysicalWorkerMutation( + this.rpc, + this.capabilities.mutationMode, + pty, + mutation + ) + } + + acknowledgeSource(acknowledgements: readonly PtySourceCreditAck[]): void { + this.rpc.notify('pty.ackData', { acknowledgements }) + } + + publishSourceAcknowledgement( + acknowledgement: PtySourceCreditAck, + acknowledgementId: string + ): Promise { + return publishLegacyPhysicalWorkerSourceAcknowledgement( + this.rpc, + acknowledgement, + acknowledgementId + ) + } + + setDeliveryPaused(identity: { + id: string + clientGeneration: number + ownerGeneration: number + deliveryToken: string + paused: boolean + }): void { + this.rpc.notify('pty.setDeliveryPaused', identity) + } + + async setHeldProducerPause(identity: { + id: string + clientGeneration: number + ownerGeneration: number + ptyIncarnationId: string + heldPauseToken: string + paused: boolean + }): Promise { + return await this.exactOperations.setHeldProducerPause(identity) + } +} diff --git a/src/relay/legacy-physical-worker-control-protocol.ts b/src/relay/legacy-physical-worker-control-protocol.ts new file mode 100644 index 00000000000..f7ef96a365b --- /dev/null +++ b/src/relay/legacy-physical-worker-control-protocol.ts @@ -0,0 +1,251 @@ +import type { + TerminalLegacyEndpointIdentity, + TerminalLegacyImportCandidate, + TerminalLegacyProcessIdentity, + TerminalLegacyUnresolvedCandidate +} from '../shared/terminal-legacy-cutover' +import { + assertAuthorityId, + assertAuthorityStoragePath, + isRecord +} from '../shared/terminal-session-authority-identity' + +export const LEGACY_PHYSICAL_WORKER_INSPECT_METHOD = + 'terminalAuthority.legacyPhysicalWorker.inspect' +export const LEGACY_PHYSICAL_WORKER_MIGRATE_METHOD = + 'terminalAuthority.legacyPhysicalWorker.migrate' +export const LEGACY_PHYSICAL_WORKER_GC_PROTECTION_METHOD = + 'terminalAuthority.legacyPhysicalWorker.gcProtection' +export const LEGACY_PHYSICAL_WORKER_MIGRATION_BARRIER_METHOD = + 'terminalAuthority.legacyPhysicalWorker.migrationBarrier' +export const LEGACY_PHYSICAL_WORKER_GC_METHOD = 'terminalAuthority.legacyPhysicalWorker.gc' + +type LegacyPhysicalWorkerDescriptorBase = Readonly<{ + version: 1 + workerId: string + routeId: string + ownerIncarnationId: string + buildId: string + clientInstanceId: string + relayDirectory: string + process: TerminalLegacyProcessIdentity + expectedEndpoint: TerminalLegacyEndpointIdentity + requestedSourceWindowSu: number + publicCredentialFile: string + privateCredentialFile: string + privateStateDirectory: string +}> + +export type LegacyPhysicalWorkerDescriptor = LegacyPhysicalWorkerDescriptorBase & + ( + | Readonly<{ + platform: 'linux' | 'darwin' + publicSocketPath: string + privateSocketPath: string + }> + | Readonly<{ + platform: 'win32' + pipeName: string + activePipeMarkerPath: string + privateActivePipeMarkerPath: string + }> + ) + +export type LegacyPhysicalWorkerMigrationCatalogInput = Readonly<{ + migrationId: string + authorityHostId: string + requestedAtMs: number + imports: readonly TerminalLegacyImportCandidate[] + unresolved: readonly TerminalLegacyUnresolvedCandidate[] +}> + +export type LegacyPhysicalWorkerInspectRequest = Readonly<{ + version: 1 + worker: LegacyPhysicalWorkerDescriptor +}> + +export type LegacyPhysicalWorkerMigrateRequest = Readonly<{ + version: 1 + worker: LegacyPhysicalWorkerDescriptor + catalog: LegacyPhysicalWorkerMigrationCatalogInput +}> + +export type LegacyPhysicalWorkerMigrationBarrierRequest = Readonly<{ + version: 1 + barrierId: string + expectedCatalogRevision: number +}> + +export type LegacyPhysicalWorkerGcProtectionRequest = Readonly<{ version: 1 }> + +export type LegacyPhysicalWorkerGcRequest = Readonly<{ + version: 1 + barrierId: string +}> + +export function parseLegacyPhysicalWorkerInspectRequest( + value: Record +): LegacyPhysicalWorkerInspectRequest { + if (value.version !== 1 || !isRecord(value.worker)) { + throw new Error('legacy physical worker inspect request is invalid') + } + return Object.freeze({ version: 1, worker: parseWorker(value.worker) }) +} + +export function parseLegacyPhysicalWorkerMigrateRequest( + value: Record +): LegacyPhysicalWorkerMigrateRequest { + if (value.version !== 1 || !isRecord(value.worker) || !isRecord(value.catalog)) { + throw new Error('legacy physical worker migration request is invalid') + } + const catalog = value.catalog + assertAuthorityId(catalog.migrationId, 'legacy migrationId') + assertAuthorityId(catalog.authorityHostId, 'legacy authorityHostId') + if ( + !nonNegativeInteger(catalog.requestedAtMs) || + !Array.isArray(catalog.imports) || + !Array.isArray(catalog.unresolved) + ) { + throw new Error('legacy physical worker migration catalog input is invalid') + } + return Object.freeze({ + version: 1, + worker: parseWorker(value.worker), + catalog: Object.freeze({ + migrationId: catalog.migrationId, + authorityHostId: catalog.authorityHostId, + requestedAtMs: Number(catalog.requestedAtMs), + imports: Object.freeze(structuredClone(catalog.imports)), + unresolved: Object.freeze(structuredClone(catalog.unresolved)) + }) + }) +} + +export function parseLegacyPhysicalWorkerMigrationBarrierRequest( + value: Record +): LegacyPhysicalWorkerMigrationBarrierRequest { + assertAuthorityId(value.barrierId, 'legacy migration barrierId') + if (value.version !== 1 || !nonNegativeInteger(value.expectedCatalogRevision)) { + throw new Error('legacy physical worker migration barrier request is invalid') + } + return Object.freeze({ + version: 1, + barrierId: value.barrierId, + expectedCatalogRevision: Number(value.expectedCatalogRevision) + }) +} + +export function parseLegacyPhysicalWorkerGcProtectionRequest( + value: Record +): LegacyPhysicalWorkerGcProtectionRequest { + if (value.version !== 1) { + throw new Error('legacy physical worker GC protection request is invalid') + } + return Object.freeze({ version: 1 }) +} + +export function parseLegacyPhysicalWorkerGcRequest( + value: Record +): LegacyPhysicalWorkerGcRequest { + assertAuthorityId(value.barrierId, 'legacy GC barrierId') + if ( + value.version !== 1 || + (value.relayDirectories !== undefined && + (!Array.isArray(value.relayDirectories) || value.relayDirectories.length > 256)) || + (value.evidencePaths !== undefined && + (!Array.isArray(value.evidencePaths) || value.evidencePaths.length > 1_024)) + ) { + throw new Error('legacy physical worker GC request is invalid') + } + for (const path of [ + ...((value.relayDirectories as unknown[] | undefined) ?? []), + ...((value.evidencePaths as unknown[] | undefined) ?? []) + ]) { + assertAuthorityStoragePath(path, 'legacy GC path') + } + return Object.freeze({ + version: 1, + barrierId: value.barrierId + }) +} + +function parseWorker(value: Record): LegacyPhysicalWorkerDescriptor { + for (const [field, selected] of [ + ['workerId', value.workerId], + ['routeId', value.routeId], + ['ownerIncarnationId', value.ownerIncarnationId], + ['buildId', value.buildId], + ['clientInstanceId', value.clientInstanceId] + ] as const) { + assertAuthorityId(selected, `legacy physical worker ${field}`) + } + for (const [field, selected] of [ + ['relayDirectory', value.relayDirectory], + ['publicCredentialFile', value.publicCredentialFile], + ['privateCredentialFile', value.privateCredentialFile], + ['privateStateDirectory', value.privateStateDirectory] + ] as const) { + assertAuthorityStoragePath(selected, `legacy physical worker ${field}`) + } + if ( + value.version !== 1 || + !isRecord(value.process) || + !isRecord(value.expectedEndpoint) || + !positiveInteger(value.process.pid) || + typeof value.process.birthMarker !== 'string' || + !positiveInteger(value.requestedSourceWindowSu) + ) { + throw new Error('legacy physical worker descriptor is invalid') + } + const base = { + version: 1 as const, + workerId: value.workerId as string, + routeId: value.routeId as string, + ownerIncarnationId: value.ownerIncarnationId as string, + buildId: value.buildId as string, + clientInstanceId: value.clientInstanceId as string, + relayDirectory: value.relayDirectory as string, + process: Object.freeze(structuredClone(value.process)) as TerminalLegacyProcessIdentity, + expectedEndpoint: Object.freeze( + structuredClone(value.expectedEndpoint) + ) as TerminalLegacyEndpointIdentity, + requestedSourceWindowSu: Number(value.requestedSourceWindowSu), + publicCredentialFile: value.publicCredentialFile as string, + privateCredentialFile: value.privateCredentialFile as string, + privateStateDirectory: value.privateStateDirectory as string + } + if (value.platform === 'linux' || value.platform === 'darwin') { + assertAuthorityStoragePath(value.publicSocketPath, 'legacy publicSocketPath') + assertAuthorityStoragePath(value.privateSocketPath, 'legacy privateSocketPath') + return Object.freeze({ + ...base, + platform: value.platform, + publicSocketPath: value.publicSocketPath, + privateSocketPath: value.privateSocketPath + }) + } + if (value.platform !== 'win32') { + throw new Error('legacy physical worker platform is invalid') + } + assertAuthorityStoragePath(value.pipeName, 'legacy pipeName') + assertAuthorityStoragePath(value.activePipeMarkerPath, 'legacy activePipeMarkerPath') + assertAuthorityStoragePath( + value.privateActivePipeMarkerPath, + 'legacy privateActivePipeMarkerPath' + ) + return Object.freeze({ + ...base, + platform: 'win32', + pipeName: value.pipeName, + activePipeMarkerPath: value.activePipeMarkerPath, + privateActivePipeMarkerPath: value.privateActivePipeMarkerPath + }) +} + +function positiveInteger(value: unknown): boolean { + return Number.isSafeInteger(value) && Number(value) > 0 +} + +function nonNegativeInteger(value: unknown): boolean { + return Number.isSafeInteger(value) && Number(value) >= 0 +} diff --git a/src/relay/legacy-physical-worker-control-surface.test.ts b/src/relay/legacy-physical-worker-control-surface.test.ts new file mode 100644 index 00000000000..44bb9d35a31 --- /dev/null +++ b/src/relay/legacy-physical-worker-control-surface.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, it, vi } from 'vitest' +import type { MethodHandler, RequestContext } from './dispatcher' +import type { + LegacyPhysicalWorkerControlGc, + LegacyPhysicalWorkerControlHost +} from './legacy-physical-worker-control-surface' +import { registerLegacyPhysicalWorkerControlSurface } from './legacy-physical-worker-control-surface' +import { + LEGACY_PHYSICAL_WORKER_GC_METHOD, + LEGACY_PHYSICAL_WORKER_GC_PROTECTION_METHOD, + LEGACY_PHYSICAL_WORKER_INSPECT_METHOD, + LEGACY_PHYSICAL_WORKER_MIGRATE_METHOD, + LEGACY_PHYSICAL_WORKER_MIGRATION_BARRIER_METHOD, + type LegacyPhysicalWorkerDescriptor +} from './legacy-physical-worker-control-protocol' + +describe('legacy physical worker authority control surface', () => { + it('admits only authenticated pre-open authority control', async () => { + const harness = controlHarness() + const inspect = harness.handler(LEGACY_PHYSICAL_WORKER_INSPECT_METHOD) + + await expect( + inspect({ version: 1, worker: descriptor }, unauthenticatedContext) + ).rejects.toThrow('terminal_authority_control_not_authenticated') + harness.active = true + await expect(inspect({ version: 1, worker: descriptor }, authenticatedContext)).rejects.toThrow( + 'legacy_physical_worker_control_requires_pre_open_client' + ) + harness.active = false + await expect( + inspect({ version: 1, worker: descriptor }, authenticatedContext) + ).resolves.toEqual(harness.inspection) + expect(harness.host.inspect).toHaveBeenCalledWith(descriptor) + }) + + it('registers bounded migration, protection, barrier, and local GC requests', async () => { + const harness = controlHarness() + expect([...harness.handlers.keys()].sort()).toEqual( + [ + LEGACY_PHYSICAL_WORKER_GC_METHOD, + LEGACY_PHYSICAL_WORKER_GC_PROTECTION_METHOD, + LEGACY_PHYSICAL_WORKER_INSPECT_METHOD, + LEGACY_PHYSICAL_WORKER_MIGRATE_METHOD, + LEGACY_PHYSICAL_WORKER_MIGRATION_BARRIER_METHOD + ].sort() + ) + + await expect( + harness.handler(LEGACY_PHYSICAL_WORKER_MIGRATE_METHOD)( + { + version: 1, + worker: descriptor, + catalog: { + migrationId: 'migration-1', + authorityHostId: 'authority-1', + requestedAtMs: 1, + imports: [], + unresolved: [] + } + }, + authenticatedContext + ) + ).rejects.toThrow('migration stub reached') + expect(harness.host.migrate).toHaveBeenCalledOnce() + + await expect( + harness.handler(LEGACY_PHYSICAL_WORKER_GC_PROTECTION_METHOD)( + { version: 1 }, + authenticatedContext + ) + ).resolves.toEqual({ catalogRevision: 7, protection: harness.protection }) + await expect( + harness.handler(LEGACY_PHYSICAL_WORKER_GC_PROTECTION_METHOD)( + { version: 2 }, + authenticatedContext + ) + ).rejects.toThrow('GC protection request is invalid') + + await harness.handler(LEGACY_PHYSICAL_WORKER_MIGRATION_BARRIER_METHOD)( + { version: 1, barrierId: 'barrier-1', expectedCatalogRevision: 7 }, + authenticatedContext + ) + expect(harness.gc.commitBarrier).toHaveBeenCalledWith({ + version: 1, + barrierId: 'barrier-1', + expectedCatalogRevision: 7 + }) + + await harness.handler(LEGACY_PHYSICAL_WORKER_GC_METHOD)( + { + version: 1, + barrierId: 'barrier-1', + relayDirectories: ['/old-relay'], + evidencePaths: ['/old-relay/socket'] + }, + authenticatedContext + ) + expect(harness.gc.collect).toHaveBeenCalledWith({ + version: 1, + barrierId: 'barrier-1' + }) + }) +}) + +function controlHarness() { + const handlers = new Map() + const inspection = Object.freeze({ + workerId: 'worker-1', + routeId: 'route-1', + buildId: 'build-1', + ptys: Object.freeze([]) + }) + const protection = Object.freeze({ + relayDirectories: Object.freeze(['/protected-relay']), + evidencePaths: Object.freeze(['/authority-state']) + }) + const host: LegacyPhysicalWorkerControlHost = { + inspect: vi.fn(async () => inspection), + migrate: vi.fn(async () => { + throw new Error('migration stub reached') + }), + gcProtection: () => protection, + catalogRevision: () => 7 + } + const gc: LegacyPhysicalWorkerControlGc = { + commitBarrier: vi.fn(async (input) => input), + collect: vi.fn(async (input) => input) + } + const harness = { + handlers, + host, + gc, + inspection, + protection, + active: false, + handler(method: string): MethodHandler { + const handler = handlers.get(method) + if (!handler) { + throw new Error(`missing handler ${method}`) + } + return handler + } + } + registerLegacyPhysicalWorkerControlSurface({ + dispatcher: { onRequest: (method, handler) => void handlers.set(method, handler) }, + host, + gc, + hasActiveClient: () => harness.active, + protection: () => protection + }) + return harness +} + +const authenticatedContext: RequestContext = Object.freeze({ + clientId: 4, + isStale: () => false, + sessionIdentity: Object.freeze({ + principal: 'terminal-authority:authority-1', + authenticated: true, + allowSessionOwner: true, + authenticationKind: 'endpoint-credential' + }) +}) + +const unauthenticatedContext: RequestContext = Object.freeze({ + clientId: 4, + isStale: () => false +}) + +const descriptor: LegacyPhysicalWorkerDescriptor = Object.freeze({ + version: 1, + workerId: 'worker-1', + routeId: 'route-1', + ownerIncarnationId: 'owner-1', + buildId: 'build-1', + clientInstanceId: 'broker-1', + relayDirectory: '/old-relay', + process: Object.freeze({ pid: 12, birthMarker: 'birth-1' }), + expectedEndpoint: Object.freeze({ + kind: 'unix-socket', + device: '1', + inode: '2', + changedAtNs: '3' + }), + requestedSourceWindowSu: 1024, + publicCredentialFile: '/old-relay/credential', + privateCredentialFile: '/authority-state/credential', + privateStateDirectory: '/authority-state', + platform: 'linux', + publicSocketPath: '/old-relay/relay.sock', + privateSocketPath: '/authority-state/worker.sock' +}) diff --git a/src/relay/legacy-physical-worker-control-surface.ts b/src/relay/legacy-physical-worker-control-surface.ts new file mode 100644 index 00000000000..1a847fbe95d --- /dev/null +++ b/src/relay/legacy-physical-worker-control-surface.ts @@ -0,0 +1,92 @@ +import type { TerminalLegacyGcProtection } from '../shared/terminal-legacy-cutover' +import type { RelayDispatcher, RequestContext } from './dispatcher' +import type { + LegacyPhysicalWorkerInspection, + LegacyPhysicalWorkerMigrationResult +} from './legacy-physical-worker-authority-host' +import { + LEGACY_PHYSICAL_WORKER_GC_METHOD, + LEGACY_PHYSICAL_WORKER_GC_PROTECTION_METHOD, + LEGACY_PHYSICAL_WORKER_INSPECT_METHOD, + LEGACY_PHYSICAL_WORKER_MIGRATE_METHOD, + LEGACY_PHYSICAL_WORKER_MIGRATION_BARRIER_METHOD, + parseLegacyPhysicalWorkerGcProtectionRequest, + parseLegacyPhysicalWorkerGcRequest, + parseLegacyPhysicalWorkerInspectRequest, + parseLegacyPhysicalWorkerMigrateRequest, + parseLegacyPhysicalWorkerMigrationBarrierRequest, + type LegacyPhysicalWorkerDescriptor, + type LegacyPhysicalWorkerMigrationCatalogInput +} from './legacy-physical-worker-control-protocol' +import { assertAuthenticatedTerminalAuthorityControl } from './terminal-authority-control-protocol' + +export type LegacyPhysicalWorkerControlHost = Readonly<{ + inspect: (descriptor: LegacyPhysicalWorkerDescriptor) => Promise + migrate: (input: { + descriptor: LegacyPhysicalWorkerDescriptor + catalog: LegacyPhysicalWorkerMigrationCatalogInput + }) => Promise + gcProtection: () => TerminalLegacyGcProtection + catalogRevision: () => number +}> + +export type LegacyPhysicalWorkerControlGc = Readonly<{ + commitBarrier: (input: { barrierId: string; expectedCatalogRevision: number }) => Promise + collect: (input: { barrierId: string }) => Promise +}> + +export type LegacyPhysicalWorkerControlDispatcher = Pick + +export function registerLegacyPhysicalWorkerControlSurface(input: { + dispatcher: LegacyPhysicalWorkerControlDispatcher + host: LegacyPhysicalWorkerControlHost + gc: LegacyPhysicalWorkerControlGc + hasActiveClient: (clientId: number) => boolean + protection?: () => TerminalLegacyGcProtection +}): void { + const protection = input.protection ?? (() => input.host.gcProtection()) + input.dispatcher.onRequest(LEGACY_PHYSICAL_WORKER_INSPECT_METHOD, async (params, context) => { + assertPreOpenControl(context, input.hasActiveClient) + const request = parseLegacyPhysicalWorkerInspectRequest(params) + return await input.host.inspect(request.worker) + }) + input.dispatcher.onRequest(LEGACY_PHYSICAL_WORKER_MIGRATE_METHOD, async (params, context) => { + assertPreOpenControl(context, input.hasActiveClient) + const request = parseLegacyPhysicalWorkerMigrateRequest(params) + return await input.host.migrate({ descriptor: request.worker, catalog: request.catalog }) + }) + input.dispatcher.onRequest( + LEGACY_PHYSICAL_WORKER_GC_PROTECTION_METHOD, + async (params, context) => { + assertPreOpenControl(context, input.hasActiveClient) + parseLegacyPhysicalWorkerGcProtectionRequest(params) + return Object.freeze({ + catalogRevision: input.host.catalogRevision(), + protection: protection() + }) + } + ) + input.dispatcher.onRequest( + LEGACY_PHYSICAL_WORKER_MIGRATION_BARRIER_METHOD, + async (params, context) => { + assertPreOpenControl(context, input.hasActiveClient) + const request = parseLegacyPhysicalWorkerMigrationBarrierRequest(params) + return await input.gc.commitBarrier(request) + } + ) + input.dispatcher.onRequest(LEGACY_PHYSICAL_WORKER_GC_METHOD, async (params, context) => { + assertPreOpenControl(context, input.hasActiveClient) + const request = parseLegacyPhysicalWorkerGcRequest(params) + return await input.gc.collect(request) + }) +} + +function assertPreOpenControl( + context: RequestContext, + hasActiveClient: (clientId: number) => boolean +): void { + assertAuthenticatedTerminalAuthorityControl(context) + if (hasActiveClient(context.clientId)) { + throw new Error('legacy_physical_worker_control_requires_pre_open_client') + } +} diff --git a/src/relay/legacy-physical-worker-cutover-grace.ts b/src/relay/legacy-physical-worker-cutover-grace.ts new file mode 100644 index 00000000000..7f33961b569 --- /dev/null +++ b/src/relay/legacy-physical-worker-cutover-grace.ts @@ -0,0 +1,27 @@ +import { SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD } from '../shared/ssh-types' +import { legacyPhysicalWorkerMethodMissing } from './legacy-physical-worker-negotiation' +import type { LegacyPhysicalWorkerRpc } from './legacy-physical-worker-client' + +export async function prepareLegacyPhysicalWorkerCutoverGrace( + rpc: LegacyPhysicalWorkerRpc, + onReady: () => void +): Promise | Readonly<{ status: 'unsupported'; reason: string }>> { + let result: unknown + try { + result = await rpc.request(SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD, { graceTimeSeconds: 0 }) + } catch (error) { + if (legacyPhysicalWorkerMethodMissing(error)) { + return Object.freeze({ status: 'unsupported', reason: 'zero-grace-unsupported' }) + } + throw error + } + if ( + typeof result !== 'object' || + result === null || + (result as { graceTimeMs?: unknown }).graceTimeMs !== 0 + ) { + return Object.freeze({ status: 'unsupported', reason: 'zero-grace-not-acknowledged' }) + } + onReady() + return Object.freeze({ status: 'ready' }) +} diff --git a/src/relay/legacy-physical-worker-cutover-session.ts b/src/relay/legacy-physical-worker-cutover-session.ts new file mode 100644 index 00000000000..0d199ce5048 --- /dev/null +++ b/src/relay/legacy-physical-worker-cutover-session.ts @@ -0,0 +1,295 @@ +import type { + TerminalLegacyCutoverProof, + TerminalLegacyWorkerRoute +} from '../shared/terminal-legacy-cutover' +import { proveSoleLegacyRelayBroker } from './legacy-relay-broker-connection-proof' +import type { LegacyRelayBrokerConnectionEvidence } from './legacy-relay-broker-connection-proof' +import { legacyRelayCutoverExclusion } from './legacy-relay-cutover-exclusion' +import { relocatePosixLegacyRelaySocket } from './legacy-relay-posix-cutover' +import { inspectLegacyUnixSocket } from './legacy-relay-unix-socket-inspection' +import { + inspectWindowsLegacyRelayPipe, + queryWindowsProcessCreationMarker, + sealWindowsLegacyRelayPipe +} from './legacy-relay-windows-cutover' +import { + openLegacyPhysicalWorker, + type LegacyPhysicalWorkerClient, + type LegacyPhysicalWorkerPty +} from './legacy-physical-worker-client' +import type { LegacyPhysicalWorkerDescriptor } from './legacy-physical-worker-control-protocol' +import type { LegacyPhysicalWorkerRegistration } from './legacy-physical-worker-registration' +import { connectLegacyPhysicalWorkerSocket } from './legacy-physical-worker-socket-connection' + +export type LegacyPhysicalWorkerCutoverSession = Readonly<{ + descriptor: LegacyPhysicalWorkerDescriptor + client: LegacyPhysicalWorkerClient + inventory: readonly LegacyPhysicalWorkerPty[] + cutover: () => Promise< + Readonly<{ + route: TerminalLegacyWorkerRoute + proof: TerminalLegacyCutoverProof + registration: LegacyPhysicalWorkerRegistration + }> + > +}> + +export async function inspectLegacyPhysicalWorker( + descriptor: LegacyPhysicalWorkerDescriptor +): Promise { + const socketPath = + descriptor.platform === 'win32' ? descriptor.pipeName : descriptor.publicSocketPath + const rpc = await connectLegacyPhysicalWorkerSocket({ + socketPath, + credentialFile: descriptor.publicCredentialFile, + expectedBuildId: descriptor.buildId + }) + const opened = await openLegacyPhysicalWorker({ + rpc, + clientInstanceId: descriptor.clientInstanceId, + expectedBuildId: descriptor.buildId, + requestedSourceWindowSu: descriptor.requestedSourceWindowSu + }) + if (opened.status !== 'supported') { + rpc.close() + throw new Error(`legacy physical worker is unsupported: ${opened.reason}`) + } + try { + const grace = await opened.client.prepareCutoverGrace() + if (grace.status !== 'ready') { + throw new Error(`legacy physical worker cannot disable grace: ${grace.reason}`) + } + const samples = [ + await opened.client.sampleCutoverStatus(), + await opened.client.sampleCutoverStatus() + ] + const inspection = + descriptor.platform === 'win32' + ? await inspectWindowsLegacyRelayPipe(descriptor.pipeName, descriptor.process) + : await inspectLegacyUnixSocket({ + platform: descriptor.platform, + socketPath: descriptor.publicSocketPath, + relayPid: descriptor.process.pid + }) + const evidence = proveSoleLegacyRelayBroker({ + endpointPath: socketPath, + relayPid: descriptor.process.pid, + brokerConnectionIdentity: opened.client.brokerConnectionIdentity, + samples, + platformInspection: inspection + }) + if (!sameEndpoint(evidence.endpointIdentity, descriptor.expectedEndpoint)) { + throw new Error('legacy physical worker endpoint proof changed before cutover') + } + const inventory = Object.freeze(await opened.client.listPtys()) + let preserved: Promise< + Readonly<{ + route: TerminalLegacyWorkerRoute + proof: TerminalLegacyCutoverProof + registration: LegacyPhysicalWorkerRegistration + }> + > | null = null + return Object.freeze({ + descriptor, + client: opened.client, + inventory, + cutover: () => { + preserved ??= (async () => { + const proof = await cutoverDescriptor(descriptor, evidence) + const route = workerRoute(descriptor, opened.client, proof) + return Object.freeze({ + route, + proof, + registration: Object.freeze({ + route, + cutover: proof, + client: opened.client, + processMatches: () => processMatches(descriptor, route) + }) + }) + })() + preserved.catch(() => { + preserved = null + }) + return preserved + } + }) + } catch (error) { + opened.client.close() + throw error + } +} + +export async function restoreLegacyPhysicalWorker(input: { + route: TerminalLegacyWorkerRoute + proof: TerminalLegacyCutoverProof +}): Promise { + const rpc = await connectLegacyPhysicalWorkerSocket({ + socketPath: input.route.socketPath, + credentialFile: input.route.credentialFile, + expectedBuildId: input.route.buildId + }) + const opened = await openLegacyPhysicalWorker({ + rpc, + clientInstanceId: input.route.sourceOwner.clientInstanceId, + expectedBuildId: input.route.buildId, + requestedSourceWindowSu: input.route.sourceOwner.outputWindowSourceUnits, + resume: { + ownerGeneration: input.route.sourceOwner.ownerGeneration, + ownerLease: input.route.sourceOwner.ownerLease + } + }) + if (opened.status !== 'supported') { + rpc.close() + throw new Error(`legacy physical worker restore is unsupported: ${opened.reason}`) + } + return Object.freeze({ + route: input.route, + cutover: input.proof, + client: opened.client, + processMatches: () => restoredProcessMatches(input.route), + restored: true + }) +} + +async function cutoverDescriptor( + descriptor: LegacyPhysicalWorkerDescriptor, + brokerEvidence: LegacyRelayBrokerConnectionEvidence +): Promise { + const launchExclusion = legacyRelayCutoverExclusion(descriptor.relayDirectory) + return descriptor.platform === 'win32' + ? await sealWindowsLegacyRelayPipe({ + pipeName: descriptor.pipeName, + relayProcess: descriptor.process, + activePipeMarkerPath: descriptor.activePipeMarkerPath, + privateActivePipeMarkerPath: descriptor.privateActivePipeMarkerPath, + publicCredentialFile: descriptor.publicCredentialFile, + privateCredentialFile: descriptor.privateCredentialFile, + privateStateDirectory: descriptor.privateStateDirectory, + brokerEvidence, + launchExclusion + }) + : await relocatePosixLegacyRelaySocket({ + publicSocketPath: descriptor.publicSocketPath, + privateSocketPath: descriptor.privateSocketPath, + publicCredentialFile: descriptor.publicCredentialFile, + privateCredentialFile: descriptor.privateCredentialFile, + privateStateDirectory: descriptor.privateStateDirectory, + expectedEndpointIdentity: unixEndpoint(descriptor.expectedEndpoint), + brokerEvidence, + launchExclusion + }) +} + +function workerRoute( + descriptor: LegacyPhysicalWorkerDescriptor, + client: LegacyPhysicalWorkerClient, + proof: TerminalLegacyCutoverProof +): TerminalLegacyWorkerRoute { + const socketPath = + proof.kind === 'posix-relocated' ? proof.privateSocketPath : proof.originalPipeName + const evidencePaths = + descriptor.platform === 'win32' + ? [ + descriptor.pipeName, + descriptor.activePipeMarkerPath, + descriptor.privateActivePipeMarkerPath, + descriptor.publicCredentialFile, + descriptor.privateCredentialFile, + descriptor.privateStateDirectory + ] + : [ + descriptor.publicSocketPath, + descriptor.privateSocketPath, + descriptor.publicCredentialFile, + descriptor.privateCredentialFile, + descriptor.privateStateDirectory + ] + return Object.freeze({ + routeId: descriptor.routeId, + workerId: descriptor.workerId, + ownerIncarnationId: descriptor.ownerIncarnationId, + buildId: descriptor.buildId, + relayDirectory: descriptor.relayDirectory, + socketPath, + credentialFile: proof.privateCredentialFile, + process: descriptor.process, + endpoint: proof.endpointIdentity, + sourceOwner: Object.freeze({ + clientInstanceId: descriptor.clientInstanceId, + ownerGeneration: client.ownerGeneration, + ownerLease: client.ownerLease, + outputWindowSourceUnits: client.capabilities.sourceWindowSu + }), + gcProtection: Object.freeze({ + relayDirectories: Object.freeze([descriptor.relayDirectory]), + evidencePaths: Object.freeze([...new Set(evidencePaths)].sort()) + }) + }) +} + +async function processMatches( + descriptor: LegacyPhysicalWorkerDescriptor, + route: TerminalLegacyWorkerRoute +): Promise { + if (descriptor.platform === 'win32') { + return ( + (await queryWindowsProcessCreationMarker(descriptor.process.pid)) === + descriptor.process.birthMarker + ) + } + try { + const inspection = await inspectLegacyUnixSocket({ + platform: descriptor.platform, + socketPath: route.socketPath, + relayPid: route.process.pid + }) + return sameEndpoint(inspection.endpointIdentity, route.endpoint) + } catch { + return false + } +} + +async function restoredProcessMatches(route: TerminalLegacyWorkerRoute): Promise { + if (route.endpoint.kind === 'windows-named-pipe') { + return ( + (await queryWindowsProcessCreationMarker(route.process.pid)) === route.process.birthMarker + ) + } + if (process.platform !== 'linux' && process.platform !== 'darwin') { + return false + } + try { + const inspection = await inspectLegacyUnixSocket({ + platform: process.platform, + socketPath: route.socketPath, + relayPid: route.process.pid + }) + return sameEndpoint(inspection.endpointIdentity, route.endpoint) + } catch { + return false + } +} + +function unixEndpoint( + endpoint: LegacyPhysicalWorkerDescriptor['expectedEndpoint'] +): Extract { + if (endpoint.kind !== 'unix-socket') { + throw new Error('legacy POSIX worker endpoint identity is invalid') + } + return endpoint +} + +function sameEndpoint( + left: LegacyPhysicalWorkerDescriptor['expectedEndpoint'], + right: LegacyPhysicalWorkerDescriptor['expectedEndpoint'] +): boolean { + return left.kind === 'unix-socket' && right.kind === 'unix-socket' + ? left.device === right.device && + left.inode === right.inode && + left.changedAtNs === right.changedAtNs + : left.kind === 'windows-named-pipe' && right.kind === 'windows-named-pipe' + ? left.pipeName.toLowerCase() === right.pipeName.toLowerCase() && + left.processCreationMarker === right.processCreationMarker + : false +} diff --git a/src/relay/legacy-physical-worker-cutover-status.ts b/src/relay/legacy-physical-worker-cutover-status.ts new file mode 100644 index 00000000000..b62dd7dc76e --- /dev/null +++ b/src/relay/legacy-physical-worker-cutover-status.ts @@ -0,0 +1,49 @@ +import type { LegacyRelayStatusSample } from './legacy-relay-broker-connection-proof' + +export function parseLegacyPhysicalWorkerCutoverStatus( + value: unknown, + brokerConnectionIdentity: string +): LegacyRelayStatusSample { + if (typeof value !== 'object' || value === null) { + throw new Error('legacy physical worker status is invalid') + } + const status = value as Record + const socket = status.socket + if (typeof socket !== 'object' || socket === null) { + throw new Error('legacy physical worker socket status is unavailable') + } + const socketStatus = socket as Record + if ( + !positiveInteger(status.pid) || + typeof socketStatus.path !== 'string' || + !socketStatus.path || + typeof socketStatus.listening !== 'boolean' || + !nonNegativeInteger(socketStatus.clients) || + !nonNegativeInteger(socketStatus.acceptedConnections) + ) { + throw new Error('legacy physical worker status identity is invalid') + } + return Object.freeze({ + pid: status.pid, + legacyCutover: Object.freeze({ + capabilityVersion: 1, + configuredGraceMs: 0, + acknowledged: true, + brokerConnectionIdentity + }), + socket: Object.freeze({ + path: socketStatus.path, + listening: socketStatus.listening, + clients: socketStatus.clients, + acceptedConnections: socketStatus.acceptedConnections + }) + }) +} + +function positiveInteger(value: unknown): value is number { + return Number.isSafeInteger(value) && Number(value) > 0 +} + +function nonNegativeInteger(value: unknown): value is number { + return Number.isSafeInteger(value) && Number(value) >= 0 +} diff --git a/src/relay/legacy-physical-worker-downstream-attachment.ts b/src/relay/legacy-physical-worker-downstream-attachment.ts new file mode 100644 index 00000000000..19a5e87fb91 --- /dev/null +++ b/src/relay/legacy-physical-worker-downstream-attachment.ts @@ -0,0 +1,238 @@ +import type { PtySourceDeliveryIdentity, PtySourceSpan } from '../shared/pty-source-credit-contract' +import type { PtySourceReceivingActivation } from '../shared/pty-source-receiving-activation' +import type { RequestContext, SinkWriteSettlement } from './dispatcher' +import type { LegacyPtyProxyExit } from './legacy-pty-proxy' +import { LegacyPhysicalWorkerDownstreamData } from './legacy-physical-worker-downstream-data' +import type { + LegacyPhysicalWorkerDownstreamAttachment, + LegacyPhysicalWorkerDownstreamDispatcher, + LegacyPhysicalWorkerDownstreamOpenInput, + LegacyPhysicalWorkerDownstreamOpenResult, + LegacyPhysicalWorkerDownstreamSession +} from './legacy-physical-worker-downstream-contract' +import { + legacyPhysicalWorkerRecoveryMatches, + legacyPhysicalWorkerRestoreRequired, + pendingLegacyPhysicalWorkerRecovery +} from './legacy-physical-worker-downstream-recovery' + +export function createLegacyPhysicalWorkerDownstreamAttachment(input: { + dispatcher: LegacyPhysicalWorkerDownstreamDispatcher + session: LegacyPhysicalWorkerDownstreamSession + context: RequestContext + identity: PtySourceDeliveryIdentity + checkpointSourceEndSu: number + recoveryEndSu: number + onCapacity: () => void + recoverySpan?: PtySourceSpan +}): LegacyPhysicalWorkerDownstreamAttachment { + return new DownstreamAttachment(input) +} + +class DownstreamAttachment implements LegacyPhysicalWorkerDownstreamAttachment { + readonly sourceActivation: PtySourceReceivingActivation + readonly identity: PtySourceDeliveryIdentity + private readonly data: LegacyPhysicalWorkerDownstreamData + private readonly removeCapacityListener: () => void + private exitPending = false + private pendingExitSettlement: ((result: SinkWriteSettlement) => void) | null = null + private responseReady = false + private disposed = false + + constructor( + private readonly input: { + dispatcher: LegacyPhysicalWorkerDownstreamDispatcher + session: LegacyPhysicalWorkerDownstreamSession + context: RequestContext + identity: PtySourceDeliveryIdentity + checkpointSourceEndSu: number + recoveryEndSu: number + onCapacity: () => void + recoverySpan?: PtySourceSpan + } + ) { + this.identity = input.identity + this.sourceActivation = Object.freeze({ + status: 'pending', + clientGeneration: input.identity.clientGeneration, + ownerGeneration: input.identity.ownerGeneration, + ptyIncarnation: input.identity.ptyIncarnation, + deliveryToken: input.identity.deliveryToken, + checkpointSourceEndSu: input.checkpointSourceEndSu, + recoveryEndSu: input.recoveryEndSu + }) + this.data = new LegacyPhysicalWorkerDownstreamData( + input.dispatcher, + input.session, + input.context, + input.identity, + () => !this.disposed && this.responseReady && !this.exitPending, + input.onCapacity, + input.recoverySpan ? { span: input.recoverySpan, endSu: input.recoveryEndSu } : undefined + ) + this.removeCapacityListener = + input.dispatcher.onClientCapacity(input.context.clientId, () => { + this.data.onPublicationCapacity() + input.onCapacity() + }) ?? (() => {}) + input.context.onResponseSettled!((settlement) => { + if (!settlement.ok) { + this.dispose() + return + } + this.responseReady = true + this.data.onPublicationCapacity() + input.onCapacity() + }) + } + + publishData(span: PtySourceSpan, onSettled: (result: SinkWriteSettlement) => void): boolean { + return this.data.publish(span, onSettled) + } + + publishExit(exit: LegacyPtyProxyExit, onSettled: (result: SinkWriteSettlement) => void): boolean { + if (this.disposed || !this.responseReady || this.data.hasPending || this.exitPending) { + return false + } + const snapshot = this.input.session.sourceDeliverySnapshotIfKnown(this.identity) + if (!snapshot || snapshot.sentEndSu !== exit.sourceEndSu) { + return false + } + this.input.session.sealDelivery(this.identity) + this.exitPending = true + let callbackRan = false + const settle = onceSettlement((result) => { + callbackRan = true + this.exitPending = false + this.pendingExitSettlement = null + try { + this.input.session.settleExitPublication(this.identity, result) + } catch (error) { + onSettled({ + ok: false, + error: error instanceof Error ? error : new Error(String(error)) + }) + return + } + onSettled(result) + if (result.ok) { + exit.authorityOutcome?.markOrderedComplete() + } + }) + this.pendingExitSettlement = onSettled + const admitted = this.input.dispatcher.tryNotifyPtyExitToClient( + this.input.context.clientId, + { + id: exit.id, + incarnationId: exit.incarnationId, + code: exit.code, + ...(exit.authorityOutcome?.supportsClient(this.input.context.clientId) + ? { authorityOutcome: exit.authorityOutcome.identity } + : {}) + }, + settle + ) + if (admitted && exit.authorityOutcome?.supportsClient(this.input.context.clientId)) { + exit.authorityOutcome.markPublished([this.input.context.clientId]) + } + if (!admitted && !callbackRan) { + this.exitPending = false + this.pendingExitSettlement = null + } + return admitted + } + + acknowledgedEndSu(): number { + return this.input.session.sourceDeliverySnapshotIfKnown(this.identity)?.creditedEndSu ?? 0 + } + + onCreditAvailable(): void { + if (this.disposed) { + return + } + this.data.onPublicationCapacity() + this.input.onCapacity() + } + + reopen( + input: Omit + ): LegacyPhysicalWorkerDownstreamOpenResult | null { + if (!input.context?.onResponseSettled || this.disposed) { + return null + } + if (!legacyPhysicalWorkerRecoveryMatches(input.sourceRecovery, this.identity)) { + return legacyPhysicalWorkerRestoreRequired('checkpointUnavailable') + } + const snapshot = this.input.session.sourceDeliverySnapshotIfKnown(this.identity) + if (!snapshot) { + return legacyPhysicalWorkerRestoreRequired('deliveryUnavailable') + } + this.data.rollbackReservation() + let rotation + try { + rotation = this.input.session.rotateDelivery( + this.identity, + input.context.clientId, + input.sourceRecovery.acceptedSourceEndSu + ) + } catch { + queueMicrotask(() => this.data.onPublicationCapacity()) + return legacyPhysicalWorkerRestoreRequired('checkpointUnavailable') + } + const recoverySpan = this.data.retireForRotation() ?? rotation.recovery.at(-1) + this.retireExitForRotation() + const attachment = createLegacyPhysicalWorkerDownstreamAttachment({ + dispatcher: this.input.dispatcher, + session: this.input.session, + context: input.context, + identity: rotation.identity, + checkpointSourceEndSu: input.sourceRecovery.acceptedSourceEndSu, + recoveryEndSu: snapshot.receivedEndSu, + onCapacity: input.onCapacity, + ...(recoverySpan ? { recoverySpan } : {}) + }) + return Object.freeze({ + status: 'attached', + attachment, + sourceRecovery: pendingLegacyPhysicalWorkerRecovery(attachment.sourceActivation) + }) + } + + dispose(): void { + if (this.disposed) { + return + } + this.disposed = true + this.removeCapacityListener() + this.data.dispose() + try { + this.input.session.cancelDelivery(this.identity, 'legacy-proxy-detached') + } catch { + /* The exact downstream delivery may already be closed by its exit. */ + } + } + + private retireExitForRotation(): void { + const pendingExitSettlement = this.pendingExitSettlement + this.disposed = true + this.removeCapacityListener() + this.pendingExitSettlement = null + pendingExitSettlement?.({ + ok: false, + error: new Error('legacy PTY downstream rotated') + }) + } +} + +function onceSettlement( + callback: (result: SinkWriteSettlement) => void +): (result: SinkWriteSettlement) => void { + let settled = false + return (result) => { + if (settled) { + return + } + settled = true + callback(result) + } +} diff --git a/src/relay/legacy-physical-worker-downstream-contract.ts b/src/relay/legacy-physical-worker-downstream-contract.ts new file mode 100644 index 00000000000..e5f4d0cb251 --- /dev/null +++ b/src/relay/legacy-physical-worker-downstream-contract.ts @@ -0,0 +1,76 @@ +import type { PtySourceDeliveryIdentity, PtySourceSpan } from '../shared/pty-source-credit-contract' +import type { + PtySourceRecoveryRequest, + PtySourceRecoveryResult +} from '../shared/pty-source-recovery-contract' +import type { PtySourceReceivingActivation } from '../shared/pty-source-receiving-activation' +import type { RelayDispatcher, RequestContext } from './dispatcher' +import type { LegacyPtyProxySink } from './legacy-pty-proxy' +import type { PtySourceSendReservation } from './pty-source-credit-ledger' +import type { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter' + +export type LegacyPhysicalWorkerDownstreamAttachment = LegacyPtyProxySink & + Readonly<{ + identity: PtySourceDeliveryIdentity + sourceActivation: PtySourceReceivingActivation + acknowledgedEndSu: () => number + onCreditAvailable: () => void + reopen: ( + input: Omit + ) => LegacyPhysicalWorkerDownstreamOpenResult | null + dispose: () => void + }> + +export type LegacyPhysicalWorkerDownstreamOpenInput = Readonly<{ + id: string + incarnationId: string + checkpointSourceEndSu: number + sourceRecovery?: PtySourceRecoveryRequest + durableDownstreamIdentity?: PtySourceDeliveryIdentity + context: RequestContext | undefined + onCapacity: () => void +}> + +export type LegacyPhysicalWorkerDownstreamOpenResult = + | Readonly<{ + status: 'attached' + attachment: LegacyPhysicalWorkerDownstreamAttachment + sourceRecovery?: PtySourceRecoveryResult + }> + | Readonly<{ + status: 'restore-required' + sourceRecovery: Extract + }> + +export type LegacyPhysicalWorkerDownstreamDispatcher = Pick< + RelayDispatcher, + 'onClientCapacity' | 'tryNotifyPtyDataToClient' | 'tryNotifyPtyExitToClient' +> + +export type LegacyPhysicalWorkerDownstreamSession = Readonly<{ + openDelivery: ( + clientId: number, + id: string, + ptyIncarnation: string, + checkpointSourceEndSu?: number + ) => PtySourceDeliveryIdentity | null + rotateDelivery: ( + oldIdentity: PtySourceDeliveryIdentity, + newClientId: number, + acceptedSourceEndSu: number + ) => Readonly<{ + identity: PtySourceDeliveryIdentity + recovery: readonly PtySourceSpan[] + }> + appendSource: SshPtyConsumerSessionAdapter['appendSource'] + reserveSourceSend: ( + identity: PtySourceDeliveryIdentity, + maxSourceSu?: number + ) => PtySourceSendReservation | null + commitSourceSend: (reservation: PtySourceSendReservation) => void + rollbackSourceSend: (reservation: PtySourceSendReservation) => void + sealDelivery: (identity: PtySourceDeliveryIdentity) => void + settleExitPublication: SshPtyConsumerSessionAdapter['settleExitPublication'] + sourceDeliverySnapshotIfKnown: SshPtyConsumerSessionAdapter['sourceDeliverySnapshotIfKnown'] + cancelDelivery: (identity: PtySourceDeliveryIdentity, reason: string) => void +}> diff --git a/src/relay/legacy-physical-worker-downstream-data.ts b/src/relay/legacy-physical-worker-downstream-data.ts new file mode 100644 index 00000000000..a6ae84d43ee --- /dev/null +++ b/src/relay/legacy-physical-worker-downstream-data.ts @@ -0,0 +1,208 @@ +import { + ptySourceSpanIsSplittable, + type PtySourceDeliveryIdentity, + type PtySourceSpan +} from '../shared/pty-source-credit-contract' +import type { RequestContext, SinkWriteSettlement } from './dispatcher' +import type { + LegacyPhysicalWorkerDownstreamDispatcher, + LegacyPhysicalWorkerDownstreamSession +} from './legacy-physical-worker-downstream-contract' +import type { PtySourceSendReservation } from './pty-source-credit-ledger' + +type PendingData = { + span: PtySourceSpan + targetEndSu: number + onSettled: (result: SinkWriteSettlement) => void + sending: boolean +} + +export class LegacyPhysicalWorkerDownstreamData { + private pending: PendingData | null = null + private reservation: PtySourceSendReservation | null = null + private retired = false + + constructor( + private readonly dispatcher: LegacyPhysicalWorkerDownstreamDispatcher, + private readonly session: LegacyPhysicalWorkerDownstreamSession, + private readonly context: RequestContext, + private readonly identity: PtySourceDeliveryIdentity, + private readonly canPublish: () => boolean, + private readonly onCapacity: () => void, + recovery?: Readonly<{ span: PtySourceSpan; endSu: number }> + ) { + if (recovery) { + this.pending = { + span: recovery.span, + targetEndSu: recovery.endSu, + onSettled: () => {}, + sending: false + } + } + } + + get hasPending(): boolean { + return this.pending !== null + } + + publish(span: PtySourceSpan, onSettled: (result: SinkWriteSettlement) => void): boolean { + if (this.retired || !this.canPublish()) { + return false + } + if (this.pending) { + if (!sameSourceSpan(this.pending.span, span)) { + return false + } + this.pending.onSettled = onSettled + this.pump() + return true + } + let appended: PtySourceSpan + try { + appended = this.session.appendSource(this.identity, { + spanId: span.spanId, + data: span.data, + displayStart: span.displayStart, + displayEnd: span.displayEnd, + splittable: ptySourceSpanIsSplittable(span), + transform: span.transform + }) + } catch { + return false + } + if ( + appended.sourceStartSu !== span.sourceStartSu || + appended.sourceEndSu !== span.sourceEndSu + ) { + this.session.cancelDelivery(this.identity, 'legacy-proxy-source-sequence-mismatch') + this.retired = true + throw new Error('legacy PTY downstream source sequence changed') + } + this.pending = { + span: Object.freeze({ ...span }), + targetEndSu: span.sourceEndSu, + onSettled, + sending: false + } + this.pump() + return true + } + + onPublicationCapacity(): void { + if (!this.retired) { + this.pump() + } + } + + rollbackReservation(): void { + if (!this.reservation) { + return + } + this.session.rollbackSourceSend(this.reservation) + this.reservation = null + if (this.pending) { + this.pending.sending = false + } + } + + retireForRotation(): PtySourceSpan | undefined { + const pending = this.pending + this.retired = true + this.pending = null + pending?.onSettled({ ok: false, error: new Error('legacy PTY downstream rotated') }) + return pending?.span + } + + dispose(): void { + if (this.retired) { + return + } + this.retired = true + if (this.reservation) { + try { + this.session.rollbackSourceSend(this.reservation) + } catch { + /* The transport settlement retired the reservation first. */ + } + this.reservation = null + } + this.pending = null + } + + private pump(): void { + const pending = this.pending + if ( + this.retired || + !this.canPublish() || + !pending || + pending.sending || + this.context.isStale() + ) { + return + } + const reservation = this.session.reserveSourceSend(this.identity) + if (!reservation) { + return + } + this.reservation = reservation + pending.sending = true + let callbackRan = false + const settle = onceSettlement((result) => { + callbackRan = true + if (this.reservation === reservation) { + this.reservation = null + } + pending.sending = false + if (this.retired || this.pending !== pending) { + return + } + if (!result.ok) { + this.session.rollbackSourceSend(reservation) + pending.onSettled(result) + this.onCapacity() + return + } + this.session.commitSourceSend(reservation) + if (reservation.span.sourceEndSu >= pending.targetEndSu) { + this.pending = null + pending.onSettled({ ok: true }) + this.onCapacity() + return + } + queueMicrotask(() => this.pump()) + }) + const admitted = this.dispatcher.tryNotifyPtyDataToClient( + this.context.clientId, + reservation.span as unknown as Record, + settle + ) + if (!admitted && !callbackRan) { + pending.sending = false + this.reservation = null + this.session.rollbackSourceSend(reservation) + } + } +} + +function sameSourceSpan(left: PtySourceSpan, right: PtySourceSpan): boolean { + return ( + left.deliveryToken === right.deliveryToken && + left.spanId === right.spanId && + left.sourceStartSu === right.sourceStartSu && + left.sourceEndSu === right.sourceEndSu && + left.data === right.data + ) +} + +function onceSettlement( + callback: (result: SinkWriteSettlement) => void +): (result: SinkWriteSettlement) => void { + let settled = false + return (result) => { + if (settled) { + return + } + settled = true + callback(result) + } +} diff --git a/src/relay/legacy-physical-worker-downstream-recovery.ts b/src/relay/legacy-physical-worker-downstream-recovery.ts new file mode 100644 index 00000000000..3075035cbae --- /dev/null +++ b/src/relay/legacy-physical-worker-downstream-recovery.ts @@ -0,0 +1,56 @@ +import type { PtySourceDeliveryIdentity } from '../shared/pty-source-credit-contract' +import type { + PtySourceRecoveryRequest, + PtySourceRecoveryResult +} from '../shared/pty-source-recovery-contract' +import type { PtySourceReceivingActivation } from '../shared/pty-source-receiving-activation' +import type { + LegacyPhysicalWorkerDownstreamOpenInput, + LegacyPhysicalWorkerDownstreamOpenResult +} from './legacy-physical-worker-downstream-contract' + +export function validateLegacyPhysicalWorkerDurableRecovery( + input: LegacyPhysicalWorkerDownstreamOpenInput +): Extract | null { + if (!input.sourceRecovery) { + return input.checkpointSourceEndSu === 0 + ? null + : legacyPhysicalWorkerRestoreRequired('deliveryUnavailable') + } + if ( + !input.durableDownstreamIdentity || + !legacyPhysicalWorkerRecoveryMatches(input.sourceRecovery, input.durableDownstreamIdentity) || + input.sourceRecovery.acceptedSourceEndSu !== input.checkpointSourceEndSu + ) { + return legacyPhysicalWorkerRestoreRequired('checkpointUnavailable') + } + return null +} + +export function legacyPhysicalWorkerRecoveryMatches( + recovery: PtySourceRecoveryRequest | undefined, + identity: PtySourceDeliveryIdentity +): recovery is Extract { + return ( + recovery?.status === 'checkpoint' && + recovery.clientGeneration === identity.clientGeneration && + recovery.ownerGeneration === identity.ownerGeneration && + recovery.ptyIncarnation === identity.ptyIncarnation && + recovery.deliveryToken === identity.deliveryToken + ) +} + +export function pendingLegacyPhysicalWorkerRecovery( + activation: PtySourceReceivingActivation +): Extract { + return Object.freeze({ ...activation }) +} + +export function legacyPhysicalWorkerRestoreRequired( + reason: string +): Extract { + return Object.freeze({ + status: 'restore-required', + sourceRecovery: Object.freeze({ status: 'restoreRequired', reason }) + }) +} diff --git a/src/relay/legacy-physical-worker-downstream.test.ts b/src/relay/legacy-physical-worker-downstream.test.ts new file mode 100644 index 00000000000..7ad8f6a4950 --- /dev/null +++ b/src/relay/legacy-physical-worker-downstream.test.ts @@ -0,0 +1,271 @@ +import { describe, expect, it, vi } from 'vitest' +import type { + PtySourceDeliveryIdentity, + PtySourceDeliverySnapshot, + PtySourceSpan +} from '../shared/pty-source-credit-contract' +import type { RequestContext, SinkWriteSettlement } from './dispatcher' +import { LegacyPhysicalWorkerDownstream } from './legacy-physical-worker-downstream' +import type { PtySourceSendReservation } from './pty-source-credit-ledger' + +describe('legacy physical worker downstream', () => { + it('opens restart recovery only from the exact durable downstream cursor', () => { + const session = new DownstreamSession() + const dispatcher = new DownstreamDispatcher() + const downstream = new LegacyPhysicalWorkerDownstream(dispatcher, session) + + expect( + downstream.open({ + id: 'pty-1', + incarnationId: 'incarnation-1', + checkpointSourceEndSu: 4, + sourceRecovery: recovery(oldIdentity, 4), + durableDownstreamIdentity: oldIdentity, + context: requestContext(8), + onCapacity: () => {} + }) + ).toMatchObject({ + status: 'attached', + sourceRecovery: { + status: 'pending', + deliveryToken: 'new-token', + checkpointSourceEndSu: 4, + recoveryEndSu: 4 + } + }) + expect(session.openDelivery).toHaveBeenCalledWith(8, 'pty-1', 'incarnation-1', 4) + + expect( + downstream.open({ + id: 'pty-1', + incarnationId: 'incarnation-1', + checkpointSourceEndSu: 4, + sourceRecovery: recovery({ ...oldIdentity, deliveryToken: 'stale' }, 4), + durableDownstreamIdentity: oldIdentity, + context: requestContext(9), + onCapacity: () => {} + }) + ).toEqual({ + status: 'restore-required', + sourceRecovery: { status: 'restoreRequired', reason: 'checkpointUnavailable' } + }) + expect(session.openDelivery).toHaveBeenCalledOnce() + }) + + it('rotates a live delivery and replays retained output before accepting new data', () => { + const session = new DownstreamSession() + const dispatcher = new DownstreamDispatcher() + const firstContext = requestContext(8) + const downstream = new LegacyPhysicalWorkerDownstream(dispatcher, session) + const opened = downstream.open({ + id: 'pty-1', + incarnationId: 'incarnation-1', + checkpointSourceEndSu: 0, + context: firstContext, + onCapacity: () => {} + }) + expect(opened?.status).toBe('attached') + if (!opened || opened.status !== 'attached') { + return + } + settleResponse(firstContext, { ok: true }) + session.snapshot = deliverySnapshot(opened.attachment.identity, 4) + session.rotationRecovery = [sourceSpan(replacementIdentity, 0, 4, 'four')] + const secondContext = requestContext(9) + + const reopened = opened.attachment.reopen({ + id: 'pty-1', + incarnationId: 'incarnation-1', + sourceRecovery: recovery(opened.attachment.identity, 0), + context: secondContext, + onCapacity: () => {} + }) + expect(reopened).toMatchObject({ + status: 'attached', + sourceRecovery: { + checkpointSourceEndSu: 0, + recoveryEndSu: 4, + deliveryToken: 'replacement-token' + } + }) + expect(session.rotateDelivery).toHaveBeenCalledWith(opened.attachment.identity, 9, 0) + settleResponse(secondContext, { ok: true }) + expect(dispatcher.data.map((span) => span.data)).toEqual(['four']) + expect(opened.attachment.publishData(sourceSpan(oldIdentity, 4, 8, 'next'), () => {})).toBe( + false + ) + }) +}) + +class DownstreamDispatcher { + readonly data: PtySourceSpan[] = [] + + onClientCapacity(): () => void { + return () => {} + } + + tryNotifyPtyDataToClient( + _clientId: number, + params: Record, + settle: (result: SinkWriteSettlement) => void + ): boolean { + this.data.push(params as unknown as PtySourceSpan) + settle({ ok: true }) + return true + } + + tryNotifyPtyExitToClient( + _clientId: number, + _params: Record, + settle: (result: SinkWriteSettlement) => void + ): boolean { + settle({ ok: true }) + return true + } +} + +class DownstreamSession { + snapshot: PtySourceDeliverySnapshot | null = null + rotationRecovery: PtySourceSpan[] = [] + private reservationSent = false + readonly openDelivery = vi.fn( + (_clientId: number, _id: string, _incarnationId: string, _checkpoint = 0) => newIdentity + ) + readonly rotateDelivery = vi.fn((old: PtySourceDeliveryIdentity, _clientId: number) => { + this.reservationSent = false + return Object.freeze({ + identity: replacementIdentity, + cancellation: Object.freeze({ + ...old, + reason: 'superseded', + sentEndSu: 4, + creditedEndSu: 0, + remainingStartSu: 0, + remainingEndSu: 4, + replacementDeliveryToken: replacementIdentity.deliveryToken + }), + recovery: Object.freeze(this.rotationRecovery) + }) + }) + + appendSource(): PtySourceSpan { + throw new Error('unexpected append') + } + + reserveSourceSend(identity: PtySourceDeliveryIdentity): PtySourceSendReservation | null { + const span = this.rotationRecovery[0] + if (identity !== replacementIdentity || !span || this.reservationSent) { + return null + } + this.reservationSent = true + return Object.freeze({ reservationId: 'reservation-1', identity, span }) + } + + commitSourceSend(): void {} + + rollbackSourceSend(): void { + this.reservationSent = false + } + + sealDelivery(): void {} + + settleExitPublication(): void {} + + sourceDeliverySnapshotIfKnown(): PtySourceDeliverySnapshot | null { + return this.snapshot + } + + cancelDelivery(): void {} +} + +function requestContext(clientId: number): RequestContext & { + settlements: ((result: SinkWriteSettlement) => void)[] +} { + const settlements: ((result: SinkWriteSettlement) => void)[] = [] + return { + clientId, + isStale: () => false, + onResponseSettled: (listener) => settlements.push(listener), + settlements + } +} + +function settleResponse( + context: ReturnType, + result: SinkWriteSettlement +): void { + context.settlements.forEach((settle) => settle(result)) +} + +function recovery(identity: PtySourceDeliveryIdentity, acceptedSourceEndSu: number) { + return Object.freeze({ + status: 'checkpoint' as const, + clientGeneration: identity.clientGeneration, + ownerGeneration: identity.ownerGeneration, + ptyIncarnation: identity.ptyIncarnation, + deliveryToken: identity.deliveryToken, + acceptedSourceEndSu + }) +} + +function deliverySnapshot( + identity: PtySourceDeliveryIdentity, + receivedEndSu: number +): PtySourceDeliverySnapshot { + return Object.freeze({ + ...identity, + state: 'active', + windowSu: 1024, + receivedEndSu, + sentEndSu: receivedEndSu, + creditedEndSu: 0, + exitPublished: false, + generationClosed: false + }) +} + +function sourceSpan( + identity: PtySourceDeliveryIdentity, + sourceStartSu: number, + sourceEndSu: number, + data: string +): PtySourceSpan { + return Object.freeze({ + ...identity, + spanId: `span-${sourceEndSu}`, + sourceStartSu, + sourceEndSu, + displayStart: sourceStartSu, + displayEnd: sourceEndSu, + data, + splittable: true, + transform: Object.freeze({ + transformed: false, + rawLengthSu: sourceEndSu - sourceStartSu, + scalarSafe: true + }) + }) +} + +const oldIdentity: PtySourceDeliveryIdentity = Object.freeze({ + id: 'pty-1', + providerGeneration: 1, + clientGeneration: 2, + ownerGeneration: 3, + ptyIncarnation: 'incarnation-1', + deliveryToken: 'old-token' +}) + +const newIdentity: PtySourceDeliveryIdentity = Object.freeze({ + ...oldIdentity, + clientGeneration: 8, + ownerGeneration: 6, + deliveryToken: 'new-token' +}) + +const replacementIdentity: PtySourceDeliveryIdentity = Object.freeze({ + ...oldIdentity, + clientGeneration: 9, + ownerGeneration: 7, + deliveryToken: 'replacement-token' +}) diff --git a/src/relay/legacy-physical-worker-downstream.ts b/src/relay/legacy-physical-worker-downstream.ts new file mode 100644 index 00000000000..80af4ab73a7 --- /dev/null +++ b/src/relay/legacy-physical-worker-downstream.ts @@ -0,0 +1,63 @@ +import { createLegacyPhysicalWorkerDownstreamAttachment } from './legacy-physical-worker-downstream-attachment' +import type { + LegacyPhysicalWorkerDownstreamDispatcher, + LegacyPhysicalWorkerDownstreamOpenInput, + LegacyPhysicalWorkerDownstreamOpenResult, + LegacyPhysicalWorkerDownstreamSession +} from './legacy-physical-worker-downstream-contract' +import { + pendingLegacyPhysicalWorkerRecovery, + validateLegacyPhysicalWorkerDurableRecovery +} from './legacy-physical-worker-downstream-recovery' + +export type { + LegacyPhysicalWorkerDownstreamAttachment, + LegacyPhysicalWorkerDownstreamOpenInput, + LegacyPhysicalWorkerDownstreamOpenResult +} from './legacy-physical-worker-downstream-contract' + +export class LegacyPhysicalWorkerDownstream { + constructor( + private readonly dispatcher: LegacyPhysicalWorkerDownstreamDispatcher, + private readonly session: LegacyPhysicalWorkerDownstreamSession + ) {} + + open( + input: LegacyPhysicalWorkerDownstreamOpenInput + ): LegacyPhysicalWorkerDownstreamOpenResult | null { + if (!input.context?.onResponseSettled) { + return null + } + const recovery = validateLegacyPhysicalWorkerDurableRecovery(input) + if (recovery) { + return recovery + } + const identity = this.session.openDelivery( + input.context.clientId, + input.id, + input.incarnationId, + input.checkpointSourceEndSu + ) + if (!identity) { + return null + } + const attachment = createLegacyPhysicalWorkerDownstreamAttachment({ + dispatcher: this.dispatcher, + session: this.session, + context: input.context, + identity, + checkpointSourceEndSu: input.checkpointSourceEndSu, + recoveryEndSu: input.checkpointSourceEndSu, + onCapacity: input.onCapacity + }) + return Object.freeze({ + status: 'attached', + attachment, + ...(input.sourceRecovery + ? { + sourceRecovery: pendingLegacyPhysicalWorkerRecovery(attachment.sourceActivation) + } + : {}) + }) + } +} diff --git a/src/relay/legacy-physical-worker-event-router.ts b/src/relay/legacy-physical-worker-event-router.ts new file mode 100644 index 00000000000..7fc00e922c1 --- /dev/null +++ b/src/relay/legacy-physical-worker-event-router.ts @@ -0,0 +1,205 @@ +import type { PtySourceSpan } from '../shared/pty-source-credit-contract' +import type { LegacyPhysicalWorkerClient } from './legacy-physical-worker-client' +import type { ImportedPhysicalWorkerPtySession } from './legacy-physical-worker-authority-session' +import { + parseLegacyPhysicalWorkerExit, + parseLegacyPhysicalWorkerSourceSpan +} from './legacy-physical-worker-source-event' + +const MAX_PENDING_WORKER_EVENTS = 128 +const MAX_PENDING_WORKER_EVENT_BYTES = 2 * 1024 * 1024 + +type BufferedWorkerEvent = Readonly<{ + method: string + params: Record + bytes: number +}> + +type WorkerSubscription = { + removeNotification: () => void + pending: BufferedWorkerEvent[] + pendingBytes: number +} + +export class LegacyPhysicalWorkerEventRouter { + private readonly subscriptions = new Map() + private readonly sessionsByClient = new Map< + LegacyPhysicalWorkerClient, + Map + >() + + constructor( + private readonly reportWorkerFault: (error: Error) => void, + private readonly acceptExit: ( + session: ImportedPhysicalWorkerPtySession, + exit: ReturnType + ) => void = (session, exit) => { + session.proxy.acceptExit(exit) + } + ) {} + + ensureSubscription(client: LegacyPhysicalWorkerClient): void { + if (this.subscriptions.has(client)) { + return + } + const subscription: WorkerSubscription = { + removeNotification: () => {}, + pending: [], + pendingBytes: 0 + } + subscription.removeNotification = client.onNotification((method, params) => { + this.accept(client, method, params, subscription) + }) + this.subscriptions.set(client, subscription) + } + + registerSession(session: ImportedPhysicalWorkerPtySession): void { + const sessions = this.sessionsByClient.get(session.client) ?? new Map() + const routeKey = workerEventRouteKey({ + id: session.binding.physicalPtyId, + incarnationId: session.binding.ptyIncarnationId + }) + const current = sessions.get(routeKey) + if (current && current !== session) { + throw new Error('legacy physical worker event route is already registered') + } + sessions.set(routeKey, session) + this.sessionsByClient.set(session.client, sessions) + this.drain(session.client) + } + + unregisterSession(session: ImportedPhysicalWorkerPtySession): void { + const sessions = this.sessionsByClient.get(session.client) + if (!sessions) { + return + } + const routeKey = workerEventRouteKey({ + id: session.binding.physicalPtyId, + incarnationId: session.binding.ptyIncarnationId + }) + if (sessions.get(routeKey) === session) { + sessions.delete(routeKey) + } + if (sessions.size === 0) { + this.sessionsByClient.delete(session.client) + const subscription = this.subscriptions.get(session.client) + subscription?.removeNotification() + this.subscriptions.delete(session.client) + } + } + + private drain(client: LegacyPhysicalWorkerClient): void { + const subscription = this.subscriptions.get(client) + if (!subscription) { + return + } + const retained: BufferedWorkerEvent[] = [] + let retainedBytes = 0 + for (const event of subscription.pending) { + const session = this.sessionFor(client, event.params) + if (session) { + this.deliver(session, event.method, event.params) + } else { + retained.push(event) + retainedBytes += event.bytes + } + } + subscription.pending = retained + subscription.pendingBytes = retainedBytes + } + + dispose(): void { + for (const subscription of this.subscriptions.values()) { + subscription.removeNotification() + } + this.subscriptions.clear() + this.sessionsByClient.clear() + } + + private accept( + client: LegacyPhysicalWorkerClient, + method: string, + params: Record, + subscription: WorkerSubscription + ): void { + if (method !== 'pty.data' && method !== 'pty.exit') { + return + } + const session = this.sessionFor(client, params) + if (session) { + this.deliver(session, method, params) + return + } + const bytes = Buffer.byteLength(JSON.stringify(params)) + if ( + subscription.pending.length >= MAX_PENDING_WORKER_EVENTS || + subscription.pendingBytes + bytes > MAX_PENDING_WORKER_EVENT_BYTES + ) { + client.close() + this.reportWorkerFault(new Error('legacy physical worker pending event capacity exceeded')) + return + } + subscription.pending.push({ method, params: structuredClone(params), bytes }) + subscription.pendingBytes += bytes + } + + private deliver( + session: ImportedPhysicalWorkerPtySession, + method: string, + params: Record + ): void { + try { + if (method === 'pty.data') { + const span = parseLegacyPhysicalWorkerSourceSpan(params) + assertSessionSpan(session, span) + session.proxy.acceptData(span) + return + } + const exit = parseLegacyPhysicalWorkerExit(params) + if ( + exit.id !== session.binding.physicalPtyId || + exit.incarnationId !== session.binding.ptyIncarnationId + ) { + throw new Error('legacy physical worker exit identity changed') + } + session.upstreamExited = true + this.acceptExit(session, exit) + } catch (error) { + session.client.close() + this.reportWorkerFault(error instanceof Error ? error : new Error(String(error))) + } + } + + private sessionFor( + client: LegacyPhysicalWorkerClient, + params: Record + ): ImportedPhysicalWorkerPtySession | undefined { + const session = this.sessionsByClient.get(client)?.get(workerEventRouteKey(params)) + return session && !session.retired && session.route.isCurrent() ? session : undefined + } +} + +function workerEventRouteKey(params: Record): string { + const id = typeof params.id === 'string' ? params.id : '' + const incarnationId = + typeof params.incarnationId === 'string' + ? params.incarnationId + : typeof params.ptyIncarnation === 'string' + ? params.ptyIncarnation + : '' + return JSON.stringify([id, incarnationId]) +} + +function assertSessionSpan(session: ImportedPhysicalWorkerPtySession, span: PtySourceSpan): void { + const expected = session.upstreamIdentity + if ( + span.id !== expected.id || + span.providerGeneration !== expected.providerGeneration || + span.clientGeneration !== expected.clientGeneration || + span.ownerGeneration !== expected.ownerGeneration || + span.ptyIncarnation !== expected.ptyIncarnation || + span.deliveryToken !== expected.deliveryToken + ) { + throw new Error('legacy physical worker source identity changed') + } +} diff --git a/src/relay/legacy-physical-worker-exact-route.ts b/src/relay/legacy-physical-worker-exact-route.ts new file mode 100644 index 00000000000..2941a481eea --- /dev/null +++ b/src/relay/legacy-physical-worker-exact-route.ts @@ -0,0 +1,7 @@ +import type { LegacyPhysicalWorkerClient } from './legacy-physical-worker-client' + +export type LegacyPhysicalWorkerExactRoute = Readonly<{ + client: LegacyPhysicalWorkerClient + generation: number + isCurrent: () => boolean +}> diff --git a/src/relay/legacy-physical-worker-gc-protection.ts b/src/relay/legacy-physical-worker-gc-protection.ts new file mode 100644 index 00000000000..fb9728d7960 --- /dev/null +++ b/src/relay/legacy-physical-worker-gc-protection.ts @@ -0,0 +1,16 @@ +import type { TerminalLegacyGcProtection } from '../shared/terminal-legacy-cutover' + +export function mergeLegacyPhysicalWorkerGcProtection( + protections: readonly TerminalLegacyGcProtection[] +): TerminalLegacyGcProtection { + const relayDirectories = new Set() + const evidencePaths = new Set() + for (const protection of protections) { + protection.relayDirectories.forEach((entry) => relayDirectories.add(entry)) + protection.evidencePaths.forEach((entry) => evidencePaths.add(entry)) + } + return Object.freeze({ + relayDirectories: Object.freeze([...relayDirectories].sort()), + evidencePaths: Object.freeze([...evidencePaths].sort()) + }) +} diff --git a/src/relay/legacy-physical-worker-inventory.ts b/src/relay/legacy-physical-worker-inventory.ts new file mode 100644 index 00000000000..f90e6e23155 --- /dev/null +++ b/src/relay/legacy-physical-worker-inventory.ts @@ -0,0 +1,39 @@ +export type LegacyPhysicalWorkerPty = Readonly<{ + id: string + incarnationId: string + processId: number | null + cwd: string + title: string + worktreeId?: string + terminalHandle?: string +}> + +export function parseLegacyPhysicalWorkerPty(value: unknown): LegacyPhysicalWorkerPty { + if (typeof value !== 'object' || value === null) { + throw new Error('legacy physical worker PTY inventory entry is invalid') + } + const entry = value as Record + if ( + typeof entry.id !== 'string' || + !entry.id || + typeof entry.incarnationId !== 'string' || + !entry.incarnationId || + typeof entry.cwd !== 'string' || + typeof entry.title !== 'string' + ) { + throw new Error('legacy physical worker PTY inventory identity is invalid') + } + return Object.freeze({ + id: entry.id, + incarnationId: entry.incarnationId, + processId: positiveInteger(entry.processId) ? entry.processId : null, + cwd: entry.cwd, + title: entry.title, + ...(typeof entry.worktreeId === 'string' ? { worktreeId: entry.worktreeId } : {}), + ...(typeof entry.terminalHandle === 'string' ? { terminalHandle: entry.terminalHandle } : {}) + }) +} + +function positiveInteger(value: unknown): value is number { + return Number.isSafeInteger(value) && Number(value) > 0 +} diff --git a/src/relay/legacy-physical-worker-lifecycle-signal.ts b/src/relay/legacy-physical-worker-lifecycle-signal.ts new file mode 100644 index 00000000000..aa1c73949da --- /dev/null +++ b/src/relay/legacy-physical-worker-lifecycle-signal.ts @@ -0,0 +1,27 @@ +export class LegacyPhysicalWorkerLifecycleSignal { + private listener: (() => void) | null = null + + subscribe(listener: () => void): () => void { + if (this.listener && this.listener !== listener) { + throw new Error('legacy physical worker lifecycle listener is already registered') + } + this.listener = listener + return () => { + if (this.listener === listener) { + this.listener = null + } + } + } + + notify(): void { + try { + this.listener?.() + } catch { + /* Registry state remains authoritative. */ + } + } + + clear(): void { + this.listener = null + } +} diff --git a/src/relay/legacy-physical-worker-mutation.ts b/src/relay/legacy-physical-worker-mutation.ts new file mode 100644 index 00000000000..bf21fde9302 --- /dev/null +++ b/src/relay/legacy-physical-worker-mutation.ts @@ -0,0 +1,84 @@ +export type LegacyPhysicalWorkerPtyIdentity = Readonly<{ + id: string + incarnationId: string +}> + +export type LegacyPhysicalWorkerMutation = + | Readonly<{ kind: 'data'; data: string }> + | Readonly<{ kind: 'resize'; cols: number; rows: number }> + | Readonly<{ kind: 'signal'; signal: string }> + | Readonly<{ kind: 'clear' }> + | Readonly<{ kind: 'shutdown'; immediate: boolean; keepHistory?: boolean }> + +type MutationRpc = Readonly<{ + request: (method: string, params?: Record) => Promise + notify: (method: string, params: Record) => void +}> + +export async function dispatchVerifiedLegacyPhysicalWorkerMutation( + rpc: MutationRpc, + mode: 'exact-v1' | 'legacy-fenced-v1', + pty: LegacyPhysicalWorkerPtyIdentity, + mutation: LegacyPhysicalWorkerMutation +): Promise { + if (mutation.kind === 'data' || mutation.kind === 'resize') { + rpc.notify( + mode === 'exact-v1' + ? mutation.kind === 'data' + ? 'pty.dataExact' + : 'pty.resizeExact' + : mutation.kind === 'data' + ? 'pty.data' + : 'pty.resize', + mutationParams(pty, mutation, mode === 'exact-v1') + ) + return true + } + const method = mutationMethod(mode, mutation.kind) + const result = await rpc.request(method, mutationParams(pty, mutation, mode === 'exact-v1')) + return mode === 'legacy-fenced-v1' || acceptedExactMutation(result) +} + +function mutationMethod( + mode: 'exact-v1' | 'legacy-fenced-v1', + kind: 'signal' | 'clear' | 'shutdown' +): string { + const suffix = mode === 'exact-v1' ? 'Exact' : '' + return { + signal: `pty.sendSignal${suffix}`, + clear: `pty.clearBuffer${suffix}`, + shutdown: `pty.shutdown${suffix}` + }[kind] +} + +function mutationParams( + pty: LegacyPhysicalWorkerPtyIdentity, + mutation: LegacyPhysicalWorkerMutation, + exact: boolean +): Record { + const identity = exact ? pty : { id: pty.id } + if (mutation.kind === 'data') { + return { ...identity, data: mutation.data } + } + if (mutation.kind === 'resize') { + return { ...identity, cols: mutation.cols, rows: mutation.rows } + } + if (mutation.kind === 'signal') { + return { ...identity, signal: mutation.signal } + } + return mutation.kind === 'shutdown' + ? { + ...identity, + immediate: mutation.immediate, + ...(mutation.keepHistory !== undefined ? { keepHistory: mutation.keepHistory } : {}) + } + : identity +} + +function acceptedExactMutation(value: unknown): boolean { + return ( + typeof value === 'object' && + value !== null && + (value as { accepted?: unknown }).accepted === true + ) +} diff --git a/src/relay/legacy-physical-worker-negotiation.ts b/src/relay/legacy-physical-worker-negotiation.ts new file mode 100644 index 00000000000..e1dbf52677d --- /dev/null +++ b/src/relay/legacy-physical-worker-negotiation.ts @@ -0,0 +1,80 @@ +import { + PTY_CONSUMER_SESSION_PROTOCOL_VERSION, + type PtyConsumerSessionGrant +} from '../shared/pty-consumer-session' +import { PTY_EXACT_OPERATION_PROTOCOL_VERSION } from '../shared/pty-exact-operation-protocol' + +export type LegacyPhysicalWorkerCapabilities = Readonly<{ + consumerSessionVersion: 1 + outputFlowControlVersion: 1 + exactOperationsVersion: 1 | null + heldProducerPauseVersion: 1 + mutationMode: 'exact-v1' | 'legacy-fenced-v1' + sourceWindowSu: number +}> + +export function validateLegacyPhysicalWorkerGrant( + value: unknown, + input: Readonly<{ expectedBuildId: string; requestedSourceWindowSu: number }> +): + | Readonly<{ + grant: Readonly + capabilities: LegacyPhysicalWorkerCapabilities + }> + | Readonly<{ reason: string }> { + if (typeof value !== 'object' || value === null) { + return { reason: 'invalid-open-client-grant' } + } + const grant = value as Partial + const flow = grant.capabilities?.outputFlowControl + const exact = grant.capabilities?.exactOperations + const heldPause = grant.capabilities?.heldProducerPause + if ( + grant.protocolVersion !== PTY_CONSUMER_SESSION_PROTOCOL_VERSION || + grant.serverBuildId !== input.expectedBuildId || + grant.role !== 'session-owner' || + !positiveInteger(grant.clientGeneration) || + !positiveInteger(grant.ownerGeneration) || + typeof grant.ownerLease !== 'string' || + !grant.ownerLease || + flow?.version !== 1 || + heldPause?.version !== 1 || + !positiveInteger(flow.windowSu) || + flow.windowSu > input.requestedSourceWindowSu || + (exact !== undefined && exact.version !== PTY_EXACT_OPERATION_PROTOCOL_VERSION) + ) { + return { reason: 'required-worker-capabilities-not-granted' } + } + return Object.freeze({ + grant: grant as Readonly, + capabilities: Object.freeze({ + consumerSessionVersion: 1, + outputFlowControlVersion: 1, + exactOperationsVersion: exact?.version === PTY_EXACT_OPERATION_PROTOCOL_VERSION ? 1 : null, + heldProducerPauseVersion: 1, + mutationMode: + exact?.version === PTY_EXACT_OPERATION_PROTOCOL_VERSION ? 'exact-v1' : 'legacy-fenced-v1', + sourceWindowSu: flow.windowSu + }) + }) +} + +export function isLegacyPhysicalWorkerOpenUnsupported(error: unknown): boolean { + return ( + legacyPhysicalWorkerMethodMissing(error) || + (error instanceof Error && /method not found|unsupported pty\.openClient/i.test(error.message)) + ) +} + +export function legacyPhysicalWorkerMethodMissing(error: unknown): boolean { + return ( + (typeof error === 'object' && + error !== null && + (error as { code?: unknown }).code === -32601) || + (error instanceof Error && /method not found/i.test(error.message)) + ) +} + +function positiveInteger(value: unknown): value is number { + return Number.isSafeInteger(value) && Number(value) > 0 +} diff --git a/src/relay/legacy-physical-worker-registration-record.ts b/src/relay/legacy-physical-worker-registration-record.ts new file mode 100644 index 00000000000..aa526e5822d --- /dev/null +++ b/src/relay/legacy-physical-worker-registration-record.ts @@ -0,0 +1,90 @@ +import { + legacyPhysicalWorkerRegistrationIsReachable, + type LegacyPhysicalWorkerRegistration +} from './legacy-physical-worker-registration' +import type { LegacyPhysicalWorkerPtyIdentity } from './legacy-physical-worker-client' +import type { LegacyPhysicalWorkerPty } from './legacy-physical-worker-inventory' + +export type LegacyPhysicalWorkerRegistrationRecord = LegacyPhysicalWorkerRegistration & { + removeCloseListener: () => void + routeGeneration: number + reachable: boolean + removed: boolean + ptyIdentities: readonly LegacyPhysicalWorkerPty[] +} + +export function createLegacyPhysicalWorkerRegistrationRecord( + registration: LegacyPhysicalWorkerRegistration, + ptyIdentities: readonly LegacyPhysicalWorkerPty[], + routeGeneration: number, + onClosed: (record: LegacyPhysicalWorkerRegistrationRecord) => void +): LegacyPhysicalWorkerRegistrationRecord { + const record: LegacyPhysicalWorkerRegistrationRecord = { + ...registration, + routeGeneration, + reachable: true, + removed: false, + ptyIdentities: Object.freeze(structuredClone(ptyIdentities)), + removeCloseListener: () => {} + } + record.removeCloseListener = registration.client.onClose(() => { + if (record.removed) { + return + } + record.reachable = false + onClosed(record) + }) + return record +} + +export async function legacyPhysicalWorkerRecordIsReachable( + record: LegacyPhysicalWorkerRegistrationRecord +): Promise { + return !record.removed && (await legacyPhysicalWorkerRegistrationIsReachable(record)) +} + +export function publicLegacyPhysicalWorkerRegistration( + record: LegacyPhysicalWorkerRegistrationRecord +): LegacyPhysicalWorkerRegistration { + return Object.freeze({ + route: record.route, + cutover: record.cutover, + client: record.client, + processMatches: record.processMatches + }) +} + +export async function legacyPhysicalWorkerRegistrationHasPty( + registration: LegacyPhysicalWorkerRegistration, + pty: LegacyPhysicalWorkerPtyIdentity +): Promise { + let inventory + try { + inventory = await registration.client.listPtys() + } catch { + return false + } + return ( + registration.client.isOpen() && + inventory.some( + (candidate) => candidate.id === pty.id && candidate.incarnationId === pty.incarnationId + ) + ) +} + +export function retireLegacyPhysicalWorkerRegistrationRecord( + record: LegacyPhysicalWorkerRegistrationRecord, + close: boolean +): void { + record.removed = true + record.reachable = false + record.removeCloseListener() + if (!close) { + return + } + try { + record.client.close() + } catch { + /* The registry no longer routes this connection. */ + } +} diff --git a/src/relay/legacy-physical-worker-registration.ts b/src/relay/legacy-physical-worker-registration.ts new file mode 100644 index 00000000000..d1fd62b5b1b --- /dev/null +++ b/src/relay/legacy-physical-worker-registration.ts @@ -0,0 +1,131 @@ +import type { + TerminalLegacyCutoverProof, + TerminalLegacyProcessIdentity, + TerminalLegacyWorkerRoute +} from '../shared/terminal-legacy-cutover' +import type { + LegacyPhysicalWorkerCapabilities, + LegacyPhysicalWorkerClient +} from './legacy-physical-worker-client' +import type { LegacyPhysicalWorkerPty } from './legacy-physical-worker-inventory' + +export type LegacyPhysicalWorkerProcessProbe = ( + process: TerminalLegacyProcessIdentity +) => Promise + +export type LegacyPhysicalWorkerRegistration = Readonly<{ + route: TerminalLegacyWorkerRoute + cutover: TerminalLegacyCutoverProof + client: LegacyPhysicalWorkerClient + processMatches: LegacyPhysicalWorkerProcessProbe + restored?: true +}> + +export type LegacyPhysicalWorkerRegistrationInspection = Readonly<{ + unsupported: string | null + inventory: readonly LegacyPhysicalWorkerPty[] +}> + +export async function inspectLegacyPhysicalWorkerRegistration( + registration: LegacyPhysicalWorkerRegistration +): Promise { + const unsupported = unsupportedCapabilities(registration.client.capabilities) + if (unsupported) { + return Object.freeze({ unsupported, inventory: Object.freeze([]) }) + } + if (!routeMatchesClient(registration)) { + return Object.freeze({ + unsupported: 'worker-route-session-mismatch', + inventory: Object.freeze([]) + }) + } + if (!cutoverMatchesRoute(registration)) { + return Object.freeze({ + unsupported: 'worker-cutover-proof-mismatch', + inventory: Object.freeze([]) + }) + } + if (!(await legacyPhysicalWorkerRegistrationIsReachable(registration))) { + return Object.freeze({ unsupported: 'worker-not-live', inventory: Object.freeze([]) }) + } + try { + const inventory = Object.freeze(await registration.client.listPtys()) + return registration.client.isOpen() + ? Object.freeze({ unsupported: null, inventory }) + : Object.freeze({ + unsupported: 'worker-inventory-unavailable', + inventory: Object.freeze([]) + }) + } catch { + return Object.freeze({ + unsupported: 'worker-inventory-unavailable', + inventory: Object.freeze([]) + }) + } +} + +export async function legacyPhysicalWorkerRegistrationIsReachable( + registration: LegacyPhysicalWorkerRegistration +): Promise { + if (!registration.client.isOpen()) { + return false + } + try { + const matches = await registration.processMatches(registration.route.process) + return matches && registration.client.isOpen() + } catch { + return false + } +} + +function unsupportedCapabilities(capabilities: LegacyPhysicalWorkerCapabilities): string | null { + if (capabilities.consumerSessionVersion !== 1) { + return 'pty.openClient-version-unsupported' + } + if (capabilities.outputFlowControlVersion !== 1) { + return 'source-credit-unsupported' + } + if ((capabilities.exactOperationsVersion === 1) !== (capabilities.mutationMode === 'exact-v1')) { + return 'worker-mutation-mode-invalid' + } + return null +} + +function cutoverMatchesRoute(registration: LegacyPhysicalWorkerRegistration): boolean { + const { cutover, route, client } = registration + if ( + (!registration.restored && + cutover.connectionProof.brokerConnectionIdentity !== client.brokerConnectionIdentity) || + !sameEndpoint(cutover.endpointIdentity, route.endpoint) || + cutover.privateCredentialFile !== route.credentialFile + ) { + return false + } + return cutover.kind === 'posix-relocated' + ? cutover.privateSocketPath === route.socketPath + : cutover.originalPipeName.toLowerCase() === route.socketPath.toLowerCase() +} + +function sameEndpoint( + left: TerminalLegacyCutoverProof['endpointIdentity'], + right: TerminalLegacyWorkerRoute['endpoint'] +): boolean { + return left.kind === 'unix-socket' && right.kind === 'unix-socket' + ? left.device === right.device && + left.inode === right.inode && + left.changedAtNs === right.changedAtNs + : left.kind === 'windows-named-pipe' && right.kind === 'windows-named-pipe' + ? left.pipeName.toLowerCase() === right.pipeName.toLowerCase() && + left.processCreationMarker === right.processCreationMarker + : false +} + +function routeMatchesClient(registration: LegacyPhysicalWorkerRegistration): boolean { + return ( + registration.route.buildId === registration.client.serverBuildId && + registration.route.sourceOwner.ownerGeneration === registration.client.ownerGeneration && + registration.route.sourceOwner.ownerLease === registration.client.ownerLease && + registration.route.sourceOwner.outputWindowSourceUnits === + registration.client.capabilities.sourceWindowSu + ) +} diff --git a/src/relay/legacy-physical-worker-registry-contract.ts b/src/relay/legacy-physical-worker-registry-contract.ts new file mode 100644 index 00000000000..848c123a4e6 --- /dev/null +++ b/src/relay/legacy-physical-worker-registry-contract.ts @@ -0,0 +1,42 @@ +export const MAX_LEGACY_PHYSICAL_WORKERS = 64 +export const ABSOLUTE_MAX_LEGACY_PHYSICAL_WORKERS = 256 + +export function assertLegacyPhysicalWorkerRegistryCapacity(capacity: number): void { + if ( + !Number.isSafeInteger(capacity) || + capacity < 1 || + capacity > ABSOLUTE_MAX_LEGACY_PHYSICAL_WORKERS + ) { + throw new Error('legacy physical worker registry capacity is invalid') + } +} + +export type LegacyPhysicalWorkerRegistrationResult = + | Readonly<{ status: 'registered'; replaced: boolean }> + | Readonly<{ status: 'unsupported'; reason: string }> + | Readonly<{ status: 'conflict'; routeId: string }> + | Readonly<{ status: 'capacity' }> + +export type LegacyPhysicalWorkerPreservation = Readonly<{ + ownerIncarnationId: string + routeId: string +}> + +export type LegacyPhysicalWorkerPreservationResult = + | Readonly<{ status: 'preserved'; alreadyPresent: boolean }> + | Readonly<{ status: 'conflict'; routeId: string }> + | Readonly<{ status: 'capacity' }> + +export type LegacyPhysicalWorkerLifecycleCounts = Readonly<{ + activeWorkerCount: number + pendingWorkerCount: number + lifecycleHoldCount: number +}> + +export function assertLegacyPhysicalWorkerPreservation( + obligation: LegacyPhysicalWorkerPreservation +): void { + if (!obligation.ownerIncarnationId || !obligation.routeId) { + throw new Error('legacy physical worker preservation identity is invalid') + } +} diff --git a/src/relay/legacy-physical-worker-registry.test.ts b/src/relay/legacy-physical-worker-registry.test.ts new file mode 100644 index 00000000000..74e78000077 --- /dev/null +++ b/src/relay/legacy-physical-worker-registry.test.ts @@ -0,0 +1,658 @@ +import { describe, expect, it } from 'vitest' +import type { + TerminalLegacyCutoverProof, + TerminalLegacyWorkerRoute +} from '../shared/terminal-legacy-cutover' +import { + LegacyPhysicalWorkerClient, + openLegacyPhysicalWorker, + type LegacyPhysicalWorkerRpc +} from './legacy-physical-worker-client' +import { preserveLegacyPhysicalWorkerAuthorityRoutes } from './legacy-physical-worker-authority-preservation' +import { legacyPhysicalWorkerRelayState } from './legacy-physical-worker-relay-state' +import { LegacyPhysicalWorkerRegistry } from './legacy-physical-worker-registry' + +describe('legacy physical worker client', () => { + it('negotiates existing owner, source-credit, and exact-operation protocols', async () => { + const rpc = new FakeWorkerRpc() + const opened = await openLegacyPhysicalWorker({ + rpc, + clientInstanceId: 'authority-broker', + expectedBuildId: 'build-a', + requestedSourceWindowSu: 1024 + }) + + expect(opened.status).toBe('supported') + if (opened.status !== 'supported') { + return + } + await expect(opened.client.prepareCutoverGrace()).resolves.toEqual({ status: 'ready' }) + await expect(opened.client.sampleCutoverStatus()).resolves.toMatchObject({ + pid: 99, + legacyCutover: { + configuredGraceMs: 0, + acknowledged: true, + brokerConnectionIdentity: 'authority-broker:2:3' + }, + socket: { clients: 1, acceptedConnections: 4 } + }) + expect(rpc.requests[0]).toEqual({ + method: 'pty.openClient', + params: expect.objectContaining({ + protocolVersion: 1, + clientInstanceId: 'authority-broker', + capabilities: { + outputFlowControl: { versions: [1], requestedWindowSu: 1024 }, + exactOperations: { versions: [1] }, + heldProducerPause: { versions: [1] } + } + }) + }) + await expect(opened.client.listPtys()).resolves.toEqual([ + { + id: 'pty-1', + incarnationId: 'incarnation-1', + processId: 123, + cwd: '/repo', + title: 'bash' + } + ]) + await expect( + opened.client.attach({ id: 'pty-1', incarnationId: 'incarnation-1' }) + ).resolves.toMatchObject({ incarnationId: 'incarnation-1' }) + await expect( + opened.client.setHeldProducerPause({ + id: 'pty-1', + clientGeneration: 2, + ownerGeneration: 3, + ptyIncarnationId: 'incarnation-1', + heldPauseToken: 'held-1', + paused: true + }) + ).resolves.toBe(true) + opened.client.write('pty-1', 'incarnation-1', 'echo ok') + opened.client.resize('pty-1', 'incarnation-1', 100, 40) + await expect(opened.client.shutdown('pty-1', 'incarnation-1')).resolves.toBe(true) + opened.client.acknowledgeSource([ + { + id: 'pty-1', + clientGeneration: 2, + ownerGeneration: 3, + deliveryToken: 'delivery', + creditedEndSu: 9 + } + ]) + await expect( + opened.client.publishSourceAcknowledgement( + { + id: 'pty-1', + clientGeneration: 2, + ownerGeneration: 3, + deliveryToken: 'delivery', + creditedEndSu: 9 + }, + 'ack-9' + ) + ).resolves.toBeUndefined() + expect(rpc.notifications.map((entry) => entry.method)).toEqual([ + 'pty.dataExact', + 'pty.resizeExact', + 'pty.ackData', + 'pty.ackData' + ]) + expect(rpc.requests.map((entry) => entry.method)).toContain('pty.shutdownExact') + }) + + it('degrades a mixed-version worker to unsupported without legacy mutation fallback', async () => { + const rpc = new FakeWorkerRpc() + rpc.requestImplementation = async (method) => { + if (method === 'pty.openClient') { + throw Object.assign(new Error('Method not found: pty.openClient'), { code: -32601 }) + } + throw new Error(`unexpected request ${method}`) + } + + await expect( + openLegacyPhysicalWorker({ + rpc, + clientInstanceId: 'authority-broker', + expectedBuildId: 'old-build', + requestedSourceWindowSu: 1024 + }) + ).resolves.toEqual({ status: 'unsupported', reason: 'pty.openClient-unsupported' }) + expect(rpc.notifications).toEqual([]) + expect(rpc.requests.map((entry) => entry.method)).toEqual(['pty.openClient']) + }) + + it('rejects a grant that omits exact mutations or source credit', async () => { + const rpc = new FakeWorkerRpc() + rpc.openGrant = { ...rpc.openGrant, capabilities: undefined } + await expect( + openLegacyPhysicalWorker({ + rpc, + clientInstanceId: 'authority-broker', + expectedBuildId: 'build-a', + requestedSourceWindowSu: 1024 + }) + ).resolves.toEqual({ + status: 'unsupported', + reason: 'required-worker-capabilities-not-granted' + }) + }) + + it('translates origin-main mutations only through fresh fenced identity proof', async () => { + const rpc = new FakeWorkerRpc() + rpc.openGrant = { + ...rpc.openGrant, + ownerLease: 'lease', + capabilities: { + outputFlowControl: { version: 1, windowSu: 1024 }, + heldProducerPause: { version: 1 } + } + } + const opened = await openLegacyPhysicalWorker({ + rpc, + clientInstanceId: 'authority-broker', + expectedBuildId: 'build-a', + requestedSourceWindowSu: 1024 + }) + expect(opened.status).toBe('supported') + if (opened.status !== 'supported') { + return + } + expect(opened.client.capabilities).toMatchObject({ + exactOperationsVersion: null, + mutationMode: 'legacy-fenced-v1' + }) + const route = workerRoute('owner-1', 'route-1') + const registry = new LegacyPhysicalWorkerRegistry() + let processMatches = true + await expect( + registry.register(workerRegistration(route, opened.client, async () => processMatches)) + ).resolves.toEqual({ status: 'registered', replaced: false }) + + const pty = { id: 'pty-1', incarnationId: 'incarnation-1' } + await expect( + registry.dispatchPtyMutation('owner-1', pty, { kind: 'data', data: 'echo safe' }) + ).resolves.toBe(true) + expect(rpc.notifications.at(-1)).toEqual({ + method: 'pty.data', + params: { id: 'pty-1', data: 'echo safe' } + }) + expect(() => opened.client.write(pty.id, pty.incarnationId, 'unsafe')).toThrow( + 'requires registry verification' + ) + + rpc.ptyInventory[0] = { ...rpc.ptyInventory[0], incarnationId: 'replacement-incarnation' } + await expect( + registry.dispatchPtyMutation('owner-1', pty, { kind: 'resize', cols: 90, rows: 30 }) + ).resolves.toBe(false) + expect(rpc.notifications.some((entry) => entry.method === 'pty.resize')).toBe(false) + rpc.ptyInventory[0] = { ...rpc.ptyInventory[0], incarnationId: pty.incarnationId } + processMatches = false + await expect(registry.attachPty('owner-1', pty)).resolves.toBeNull() + expect(rpc.requests.filter((entry) => entry.method === 'pty.attach')).toHaveLength(0) + expect(registry.lifecycleCounts()).toEqual({ + activeWorkerCount: 0, + pendingWorkerCount: 1, + lifecycleHoldCount: 1 + }) + registry.dispose() + }) + + it('does not infer zero grace when a mixed-version relay lacks the request', async () => { + const rpc = new FakeWorkerRpc() + const opened = await openLegacyPhysicalWorker({ + rpc, + clientInstanceId: 'authority-broker', + expectedBuildId: 'build-a', + requestedSourceWindowSu: 1024 + }) + expect(opened.status).toBe('supported') + if (opened.status !== 'supported') { + return + } + rpc.requestImplementation = async (method) => { + throw Object.assign(new Error(`Method not found: ${method}`), { code: -32601 }) + } + await expect(opened.client.prepareCutoverGrace()).resolves.toEqual({ + status: 'unsupported', + reason: 'zero-grace-unsupported' + }) + await expect(opened.client.sampleCutoverStatus()).rejects.toThrow( + 'zero grace was not acknowledged' + ) + }) +}) + +describe('legacy physical worker registry', () => { + it('preserves an unreachable owner until an exact causal unregister', async () => { + const rpc = new FakeWorkerRpc() + const client = workerClient(rpc) + const registry = new LegacyPhysicalWorkerRegistry(2) + let processMatches = true + const route = workerRoute('owner-1', 'route-1') + + await expect( + registry.register(workerRegistration(route, client, async () => processMatches)) + ).resolves.toEqual({ status: 'registered', replaced: false }) + await expect(registry.resolve('owner-1')).resolves.toMatchObject({ route }) + + processMatches = false + await expect(registry.resolve('owner-1')).resolves.toBeNull() + expect(registry.size).toBe(1) + expect(registry.lifecycleCounts()).toEqual({ + activeWorkerCount: 0, + pendingWorkerCount: 1, + lifecycleHoldCount: 1 + }) + expect(rpc.closeCalls).toBe(0) + + expect(registry.unregister({ ownerIncarnationId: 'owner-1', routeId: 'stale-route' })).toBe( + false + ) + expect(registry.unregister({ ownerIncarnationId: 'owner-1', routeId: 'route-1' })).toBe(true) + expect(registry.lifecycleHoldCount).toBe(0) + expect(rpc.closeCalls).toBe(1) + registry.dispose() + }) + + it('keeps close idempotent and permits only same-route reconnection', async () => { + const firstRpc = new FakeWorkerRpc() + const firstClient = workerClient(firstRpc) + const registry = new LegacyPhysicalWorkerRegistry() + await registry.register( + workerRegistration(workerRoute('owner-1', 'route-1'), firstClient, async () => true) + ) + const firstRoute = await registry.resolveExactPtyRoute('owner-1', { + id: 'pty-1', + incarnationId: 'incarnation-1' + }) + expect(firstRoute?.isCurrent()).toBe(true) + expect(firstRpc.requests.filter((entry) => entry.method === 'pty.listProcesses')).toHaveLength( + 1 + ) + + firstRpc.disconnect() + firstRpc.disconnect() + expect(firstRoute?.isCurrent()).toBe(false) + expect(registry.lifecycleCounts()).toEqual({ + activeWorkerCount: 0, + pendingWorkerCount: 1, + lifecycleHoldCount: 1 + }) + await expect( + registry.register( + workerRegistration( + workerRoute('owner-1', 'route-2'), + workerClient(new FakeWorkerRpc()), + async () => true + ) + ) + ).resolves.toEqual({ status: 'conflict', routeId: 'route-1' }) + + const replacementRpc = new FakeWorkerRpc() + const replacementClient = workerClient(replacementRpc) + await expect( + registry.register( + workerRegistration(workerRoute('owner-1', 'route-1'), replacementClient, async () => true) + ) + ).resolves.toEqual({ status: 'registered', replaced: true }) + const replacementRoute = await registry.resolveExactPtyRoute('owner-1', { + id: 'pty-1', + incarnationId: 'incarnation-1' + }) + expect(replacementRoute?.generation).toBeGreaterThan(firstRoute?.generation ?? 0) + expect(replacementRoute?.isCurrent()).toBe(true) + expect(registry.lifecycleCounts()).toEqual({ + activeWorkerCount: 1, + pendingWorkerCount: 0, + lifecycleHoldCount: 1 + }) + registry.dispose() + registry.dispose() + expect(registry.size).toBe(0) + expect(firstRpc.closeCalls).toBe(0) + expect(replacementRpc.closeCalls).toBe(1) + }) + + it('never evicts an unreachable preservation obligation to admit beyond its bound', async () => { + const firstRpc = new FakeWorkerRpc() + const secondRpc = new FakeWorkerRpc() + const registry = new LegacyPhysicalWorkerRegistry(1) + let firstProcessMatches = true + await registry.register( + workerRegistration( + workerRoute('owner-1', 'route-1'), + workerClient(firstRpc), + async () => firstProcessMatches + ) + ) + await expect( + registry.register( + workerRegistration( + workerRoute('owner-2', 'route-2'), + workerClient(secondRpc), + async () => true + ) + ) + ).resolves.toEqual({ status: 'capacity' }) + expect(firstRpc.closeCalls).toBe(0) + + firstProcessMatches = false + await expect(registry.resolve('owner-1')).resolves.toBeNull() + await expect( + registry.register( + workerRegistration( + workerRoute('owner-2', 'route-2'), + workerClient(secondRpc), + async () => true + ) + ) + ).resolves.toEqual({ status: 'capacity' }) + expect(firstRpc.closeCalls).toBe(0) + expect(registry.size).toBe(1) + registry.dispose() + }) + + it('hydrates bounded lifecycle holds without treating catalog rows as reachable', async () => { + const registry = new LegacyPhysicalWorkerRegistry(1) + expect(registry.preserve({ ownerIncarnationId: 'owner-1', routeId: 'route-1' })).toEqual({ + status: 'preserved', + alreadyPresent: false + }) + expect(registry.preserve({ ownerIncarnationId: 'owner-1', routeId: 'route-1' })).toEqual({ + status: 'preserved', + alreadyPresent: true + }) + expect(registry.lifecycleCounts()).toEqual({ + activeWorkerCount: 0, + pendingWorkerCount: 1, + lifecycleHoldCount: 1 + }) + await expect(registry.resolve('owner-1')).resolves.toBeNull() + expect(registry.preserve({ ownerIncarnationId: 'owner-2', routeId: 'route-2' })).toEqual({ + status: 'capacity' + }) + await expect( + registry.register( + workerRegistration( + workerRoute('owner-1', 'route-1'), + workerClient(new FakeWorkerRpc()), + async () => true + ) + ) + ).resolves.toEqual({ status: 'registered', replaced: false }) + expect(registry.lifecycleCounts()).toEqual({ + activeWorkerCount: 1, + pendingWorkerCount: 0, + lifecycleHoldCount: 1 + }) + expect( + registry.releasePreservation({ ownerIncarnationId: 'owner-1', routeId: 'route-1' }) + ).toBe(true) + expect(registry.lifecycleHoldCount).toBe(1) + registry.dispose() + }) + + it('keeps catalog preservation non-idle until exact causal release', () => { + const registry = new LegacyPhysicalWorkerRegistry(1) + const lifecycleHoldCounts: number[] = [] + const removeListener = registry.onLifecycleChanged(() => { + lifecycleHoldCounts.push(registry.lifecycleHoldCount) + }) + + preserveLegacyPhysicalWorkerAuthorityRoutes(registry, [ + { ownerIncarnationId: 'owner-1', routeId: 'route-1' } + ]) + expect(registry.lifecycleCounts()).toEqual({ + activeWorkerCount: 0, + pendingWorkerCount: 1, + lifecycleHoldCount: 1 + }) + expect( + legacyPhysicalWorkerRelayState({ + localActivePtyCount: 0, + pendingPtyCreationCount: 0, + lifecycle: registry + }) + ).toEqual({ protectedPtyCount: 1, idle: false }) + + expect( + registry.releasePreservation({ ownerIncarnationId: 'owner-1', routeId: 'route-1' }) + ).toBe(true) + expect(lifecycleHoldCounts).toEqual([1, 0]) + removeListener() + registry.dispose() + }) + + it('routes imported attach through exact owner, pane, and PTY identity proof', async () => { + const rpc = new FakeWorkerRpc() + const registry = new LegacyPhysicalWorkerRegistry() + await registry.register( + workerRegistration(workerRoute('owner-1', 'route-1'), workerClient(rpc), async () => true) + ) + + await expect( + registry.attachPty('owner-1', { + id: 'pty-1', + incarnationId: 'incarnation-1', + expectedPaneKey: 'pane-a', + expectedTabId: 'tab-a' + }) + ).resolves.toMatchObject({ incarnationId: 'incarnation-1' }) + expect(rpc.requests.at(-1)).toEqual({ + method: 'pty.attach', + params: { + id: 'pty-1', + expectedIncarnationId: 'incarnation-1', + expectedPtyIncarnationId: 'incarnation-1', + expectedPaneKey: 'pane-a', + expectedTabId: 'tab-a', + suppressReplayNotification: true + } + }) + registry.dispose() + }) +}) + +class FakeWorkerRpc implements LegacyPhysicalWorkerRpc { + readonly requests: { method: string; params?: Record }[] = [] + readonly notifications: { method: string; params: Record }[] = [] + private readonly closeListeners = new Set<() => void>() + private open = true + closeCalls = 0 + ptyInventory = [ + { + id: 'pty-1', + incarnationId: 'incarnation-1', + processId: 123, + cwd: '/repo', + title: 'bash' + } + ] + openGrant: Record = { + protocolVersion: 1, + serverBuildId: 'build-a', + clientGeneration: 2, + role: 'session-owner', + ownerGeneration: 3, + ownerLease: 'owner-lease', + resumed: false, + capabilities: { + outputFlowControl: { version: 1, windowSu: 1024 }, + exactOperations: { version: 1 }, + heldProducerPause: { version: 1 } + } + } + requestImplementation = async (method: string): Promise => { + if (method === 'pty.openClient') { + return this.openGrant + } + if (method === 'pty.listProcesses') { + return this.ptyInventory + } + if (method === 'relay.configureGraceTime') { + return { graceTimeMs: 0 } + } + if (method === 'relay.status') { + return { + pid: 99, + socket: { + path: '/relay.sock', + listening: true, + clients: 1, + acceptedConnections: 4 + } + } + } + if (method === 'pty.attach') { + return { incarnationId: 'incarnation-1', sourceActivation: { deliveryToken: 'delivery' } } + } + if (method === 'pty.setDeliveryPaused') { + return { applied: true } + } + if (method.endsWith('Exact')) { + return { accepted: true } + } + if (['pty.shutdown', 'pty.sendSignal', 'pty.clearBuffer'].includes(method)) { + return {} + } + throw new Error(`unexpected request ${method}`) + } + + async request(method: string, params?: Record): Promise { + this.requests.push({ method, ...(params ? { params } : {}) }) + return await this.requestImplementation(method) + } + + notify(method: string, params: Record): void { + this.notifications.push({ method, params }) + } + + notifyWithSettlement( + method: string, + params: Record, + onSettled: (result: { ok: true } | { ok: false; error: Error }) => void + ): void { + this.notify(method, params) + onSettled({ ok: true }) + } + + onNotification(): () => void { + return () => {} + } + + isOpen(): boolean { + return this.open + } + + onClose(listener: () => void): () => void { + this.closeListeners.add(listener) + return () => this.closeListeners.delete(listener) + } + + close(): void { + this.closeCalls++ + this.disconnect() + } + + disconnect(): void { + if (!this.open) { + return + } + this.open = false + for (const listener of this.closeListeners) { + listener() + } + } +} + +function workerClient(rpc: FakeWorkerRpc): LegacyPhysicalWorkerClient { + return new LegacyPhysicalWorkerClient( + rpc, + { + protocolVersion: 1, + serverBuildId: 'build-a', + clientGeneration: 2, + role: 'session-owner', + ownerGeneration: 3, + ownerLease: 'lease', + resumed: false + }, + { + consumerSessionVersion: 1, + outputFlowControlVersion: 1, + exactOperationsVersion: 1, + heldProducerPauseVersion: 1, + mutationMode: 'exact-v1', + sourceWindowSu: 1024 + } + ) +} + +function workerRegistration( + route: TerminalLegacyWorkerRoute, + client: LegacyPhysicalWorkerClient, + processMatches: () => Promise +) { + return { route, cutover: cutoverProof(route, client), client, processMatches } +} + +function cutoverProof( + route: TerminalLegacyWorkerRoute, + client: LegacyPhysicalWorkerClient +): TerminalLegacyCutoverProof { + if (route.endpoint.kind !== 'unix-socket') { + throw new Error('test route must use a Unix socket') + } + return Object.freeze({ + kind: 'posix-relocated', + publicSocketPath: '/relay/public.sock', + privateSocketPath: route.socketPath, + publicCredentialFile: '/relay/public.credential', + privateCredentialFile: route.credentialFile, + endpointIdentity: route.endpoint, + brokerClientCount: 1, + acceptedConnectionCount: 4, + quiescenceSamples: 2, + connectionProof: Object.freeze({ + method: 'linux-procfs-unix', + listenerIdentity: `${route.process.pid}:socket:${route.endpoint.inode}`, + brokerConnectionIdentity: client.brokerConnectionIdentity, + acceptedServerConnections: 1 + }), + graceConfiguration: Object.freeze({ + capabilityVersion: 1, + configuredGraceMs: 0, + acknowledged: true + }), + sealedAtMs: 1 + }) +} + +function workerRoute(ownerIncarnationId: string, routeId: string): TerminalLegacyWorkerRoute { + return { + routeId, + workerId: `worker-${routeId}`, + ownerIncarnationId, + buildId: 'build-a', + relayDirectory: '/relay', + socketPath: '/authority/worker.sock', + credentialFile: '/authority/worker.credential', + process: { pid: routeId === 'route-1' ? 41 : 42, birthMarker: `birth-${routeId}` }, + endpoint: { + kind: 'unix-socket', + device: '1', + inode: routeId === 'route-1' ? '2' : '3', + changedAtNs: '4' + }, + sourceOwner: { + clientInstanceId: 'authority-broker', + ownerGeneration: 3, + ownerLease: 'lease', + outputWindowSourceUnits: 1024 + }, + gcProtection: { relayDirectories: ['/relay'], evidencePaths: ['/authority'] } + } +} diff --git a/src/relay/legacy-physical-worker-registry.ts b/src/relay/legacy-physical-worker-registry.ts new file mode 100644 index 00000000000..6e34008471b --- /dev/null +++ b/src/relay/legacy-physical-worker-registry.ts @@ -0,0 +1,322 @@ +import type { PtySourceRecoveryRequest } from '../shared/pty-source-recovery-contract' +import type { LegacyPhysicalWorkerAttachIdentity } from './legacy-physical-worker-attach-router' +import type { + LegacyPhysicalWorkerMutation, + LegacyPhysicalWorkerPtyIdentity +} from './legacy-physical-worker-client' +import { + inspectLegacyPhysicalWorkerRegistration, + type LegacyPhysicalWorkerRegistration +} from './legacy-physical-worker-registration' +import { + createLegacyPhysicalWorkerRegistrationRecord, + legacyPhysicalWorkerRecordIsReachable, + legacyPhysicalWorkerRegistrationHasPty, + publicLegacyPhysicalWorkerRegistration, + retireLegacyPhysicalWorkerRegistrationRecord, + type LegacyPhysicalWorkerRegistrationRecord +} from './legacy-physical-worker-registration-record' +import { LegacyPhysicalWorkerLifecycleSignal } from './legacy-physical-worker-lifecycle-signal' +import type { LegacyPhysicalWorkerExactRoute } from './legacy-physical-worker-exact-route' +import { LegacyPhysicalWorkerRouteGeneration } from './legacy-physical-worker-route-generation' +import { + MAX_LEGACY_PHYSICAL_WORKERS, + assertLegacyPhysicalWorkerPreservation, + assertLegacyPhysicalWorkerRegistryCapacity, + type LegacyPhysicalWorkerLifecycleCounts, + type LegacyPhysicalWorkerPreservation, + type LegacyPhysicalWorkerPreservationResult, + type LegacyPhysicalWorkerRegistrationResult +} from './legacy-physical-worker-registry-contract' + +export type { + LegacyPhysicalWorkerProcessProbe, + LegacyPhysicalWorkerRegistration +} from './legacy-physical-worker-registration' +export * from './legacy-physical-worker-registry-contract' + +export class LegacyPhysicalWorkerRegistry { + private readonly records = new Map() + private readonly preservation = new Map() + private readonly lifecycleSignal = new LegacyPhysicalWorkerLifecycleSignal() + private readonly routeGeneration = new LegacyPhysicalWorkerRouteGeneration() + private disposed = false + + constructor(readonly capacity = MAX_LEGACY_PHYSICAL_WORKERS) { + assertLegacyPhysicalWorkerRegistryCapacity(capacity) + } + + get size(): number { + return this.records.size + } + + get activeWorkerCount(): number { + return [...this.records.values()].filter((record) => record.reachable && record.client.isOpen()) + .length + } + + get pendingWorkerCount(): number { + return this.lifecycleHoldCount - this.activeWorkerCount + } + + get lifecycleHoldCount(): number { + return new Set([...this.records.keys(), ...this.preservation.keys()]).size + } + + lifecycleCounts(): LegacyPhysicalWorkerLifecycleCounts { + return Object.freeze({ + activeWorkerCount: this.activeWorkerCount, + pendingWorkerCount: this.pendingWorkerCount, + lifecycleHoldCount: this.lifecycleHoldCount + }) + } + + preserve(obligation: LegacyPhysicalWorkerPreservation): LegacyPhysicalWorkerPreservationResult { + this.assertOpen() + assertLegacyPhysicalWorkerPreservation(obligation) + const recordRoute = this.records.get(obligation.ownerIncarnationId)?.route.routeId + const preservedRoute = this.preservation.get(obligation.ownerIncarnationId) + const currentRoute = recordRoute ?? preservedRoute + if (currentRoute && currentRoute !== obligation.routeId) { + return Object.freeze({ status: 'conflict', routeId: currentRoute }) + } + if (preservedRoute) { + return Object.freeze({ status: 'preserved', alreadyPresent: true }) + } + if (!recordRoute && this.lifecycleHoldCount >= this.capacity) { + return Object.freeze({ status: 'capacity' }) + } + this.preservation.set(obligation.ownerIncarnationId, obligation.routeId) + this.lifecycleSignal.notify() + return Object.freeze({ status: 'preserved', alreadyPresent: false }) + } + + releasePreservation(obligation: LegacyPhysicalWorkerPreservation): boolean { + assertLegacyPhysicalWorkerPreservation(obligation) + if (this.preservation.get(obligation.ownerIncarnationId) !== obligation.routeId) { + return false + } + this.preservation.delete(obligation.ownerIncarnationId) + this.lifecycleSignal.notify() + return true + } + + async register( + registration: LegacyPhysicalWorkerRegistration + ): Promise { + this.assertOpen() + const inspection = await inspectLegacyPhysicalWorkerRegistration(registration) + if (inspection.unsupported) { + return Object.freeze({ status: 'unsupported', reason: inspection.unsupported }) + } + const key = registration.route.ownerIncarnationId + const preservedRoute = this.preservation.get(key) + if (preservedRoute && preservedRoute !== registration.route.routeId) { + return Object.freeze({ status: 'conflict', routeId: preservedRoute }) + } + const current = this.records.get(key) + if (current) { + if (current.route.routeId !== registration.route.routeId) { + return Object.freeze({ status: 'conflict', routeId: current.route.routeId }) + } + if (current.client === registration.client) { + const changed = !current.reachable + current.reachable = true + current.ptyIdentities = inspection.inventory + if (changed) { + this.lifecycleSignal.notify() + } + return Object.freeze({ status: 'registered', replaced: false }) + } + if (current.client.isOpen()) { + return Object.freeze({ status: 'conflict', routeId: current.route.routeId }) + } + this.detachRecord(key, current, false, false) + } else if (!preservedRoute && this.lifecycleHoldCount >= this.capacity) { + return Object.freeze({ status: 'capacity' }) + } + const record = createLegacyPhysicalWorkerRegistrationRecord( + registration, + inspection.inventory, + this.routeGeneration.mint(), + (closed) => { + if (this.records.get(key) === closed) { + this.lifecycleSignal.notify() + } + } + ) + this.records.set(key, record) + this.lifecycleSignal.notify() + if (!registration.client.isOpen()) { + record.reachable = false + return Object.freeze({ status: 'unsupported', reason: 'worker-connection-closed' }) + } + return Object.freeze({ status: 'registered', replaced: current !== undefined }) + } + + async resolve(ownerIncarnationId: string): Promise { + const record = this.records.get(ownerIncarnationId) + if (!record || !(await this.refreshRecordReachability(ownerIncarnationId, record))) { + return null + } + return publicLegacyPhysicalWorkerRegistration(record) + } + + async listReachable(): Promise { + const entries = [...this.records.entries()] + const reachable = await Promise.all( + entries.map(async ([key, record]) => ({ + record, + reachable: await this.refreshRecordReachability(key, record) + })) + ) + return Object.freeze( + reachable + .filter((entry) => entry.reachable) + .map((entry) => publicLegacyPhysicalWorkerRegistration(entry.record)) + ) + } + + async attachPty( + ownerIncarnationId: string, + pty: LegacyPhysicalWorkerAttachIdentity, + sourceRecovery?: PtySourceRecoveryRequest + ): Promise | null> { + const registration = await this.resolvePty(ownerIncarnationId, pty) + return registration ? await registration.client.attach(pty, sourceRecovery) : null + } + + async resolvePtyRegistration( + ownerIncarnationId: string, + pty: LegacyPhysicalWorkerPtyIdentity + ): Promise { + return await this.resolvePty(ownerIncarnationId, pty) + } + + async resolveExactPtyRoute( + ownerIncarnationId: string, + pty: LegacyPhysicalWorkerPtyIdentity + ): Promise { + const record = await this.resolveCachedPtyRecord(ownerIncarnationId, pty) + if (!record) { + return null + } + return Object.freeze({ + client: record.client, + generation: record.routeGeneration, + isCurrent: () => + this.records.get(ownerIncarnationId) === record && + !record.removed && + record.reachable && + record.client.isOpen() + }) + } + + onLifecycleChanged(listener: () => void): () => void { + this.assertOpen() + return this.lifecycleSignal.subscribe(listener) + } + + async dispatchPtyMutation( + ownerIncarnationId: string, + pty: LegacyPhysicalWorkerPtyIdentity, + mutation: LegacyPhysicalWorkerMutation + ): Promise { + const registration = await this.resolvePty(ownerIncarnationId, pty) + return registration ? await registration.client.dispatchVerifiedMutation(pty, mutation) : false + } + + reservesPhysicalPtyId(id: string): boolean { + return [...this.records.values()].some( + (record) => + record.reachable && + record.client.isOpen() && + record.ptyIdentities.some((pty) => pty.id === id) + ) + } + + unregister(obligation: LegacyPhysicalWorkerPreservation): boolean { + assertLegacyPhysicalWorkerPreservation(obligation) + const record = this.records.get(obligation.ownerIncarnationId) + if (!record || record.route.routeId !== obligation.routeId) { + return false + } + this.detachRecord(obligation.ownerIncarnationId, record, true) + return true + } + + dispose(): void { + if (this.disposed) { + return + } + this.disposed = true + for (const [key, record] of this.records) { + this.detachRecord(key, record, true, false) + } + this.preservation.clear() + this.lifecycleSignal.notify() + this.lifecycleSignal.clear() + } + + private async resolvePty( + ownerIncarnationId: string, + pty: LegacyPhysicalWorkerPtyIdentity + ): Promise { + const registration = await this.resolve(ownerIncarnationId) + return registration && (await legacyPhysicalWorkerRegistrationHasPty(registration, pty)) + ? registration + : null + } + + private async resolveCachedPtyRecord( + ownerIncarnationId: string, + pty: LegacyPhysicalWorkerPtyIdentity + ): Promise { + const record = this.records.get(ownerIncarnationId) + if (!record || !(await this.refreshRecordReachability(ownerIncarnationId, record))) { + return null + } + return record.ptyIdentities.some( + (candidate) => candidate.id === pty.id && candidate.incarnationId === pty.incarnationId + ) + ? record + : null + } + + private async refreshRecordReachability( + key: string, + record: LegacyPhysicalWorkerRegistrationRecord + ): Promise { + const reachable = await legacyPhysicalWorkerRecordIsReachable(record) + if (this.records.get(key) === record) { + const changed = record.reachable !== reachable + record.reachable = reachable + if (changed) { + this.lifecycleSignal.notify() + } + } + return reachable + } + + private detachRecord( + key: string, + record: LegacyPhysicalWorkerRegistrationRecord, + close: boolean, + notify = true + ): void { + if (record.removed || this.records.get(key) !== record) { + return + } + this.records.delete(key) + retireLegacyPhysicalWorkerRegistrationRecord(record, close) + if (notify) { + this.lifecycleSignal.notify() + } + } + + private assertOpen(): void { + if (this.disposed) { + throw new Error('legacy physical worker registry is disposed') + } + } +} diff --git a/src/relay/legacy-physical-worker-relay-state.ts b/src/relay/legacy-physical-worker-relay-state.ts new file mode 100644 index 00000000000..451c34eb97b --- /dev/null +++ b/src/relay/legacy-physical-worker-relay-state.ts @@ -0,0 +1,18 @@ +export type LegacyPhysicalWorkerLifecycle = Readonly<{ + lifecycleHoldCount: number +}> + +export function legacyPhysicalWorkerRelayState(input: { + localActivePtyCount: number + pendingPtyCreationCount: number + lifecycle?: LegacyPhysicalWorkerLifecycle | null +}): Readonly<{ protectedPtyCount: number; idle: boolean }> { + const lifecycleHoldCount = input.lifecycle?.lifecycleHoldCount ?? 0 + return Object.freeze({ + protectedPtyCount: input.localActivePtyCount + lifecycleHoldCount, + idle: + input.localActivePtyCount === 0 && + input.pendingPtyCreationCount === 0 && + lifecycleHoldCount === 0 + }) +} diff --git a/src/relay/legacy-physical-worker-route-generation.ts b/src/relay/legacy-physical-worker-route-generation.ts new file mode 100644 index 00000000000..642d4d1dea0 --- /dev/null +++ b/src/relay/legacy-physical-worker-route-generation.ts @@ -0,0 +1,10 @@ +export class LegacyPhysicalWorkerRouteGeneration { + private next = 1 + + mint(): number { + if (!Number.isSafeInteger(this.next)) { + throw new Error('legacy physical worker route generation exhausted') + } + return this.next++ + } +} diff --git a/src/relay/legacy-physical-worker-session-admission.ts b/src/relay/legacy-physical-worker-session-admission.ts new file mode 100644 index 00000000000..513394dc0d6 --- /dev/null +++ b/src/relay/legacy-physical-worker-session-admission.ts @@ -0,0 +1,197 @@ +import { parsePtySourceReceivingActivation } from '../shared/pty-source-receiving-activation' +import type { + LegacyPhysicalWorkerAttachRequest, + LegacyPhysicalWorkerAttachResult +} from './legacy-physical-worker-attach-router' +import type { + ImportedPhysicalWorkerPtySession, + LegacyPhysicalWorkerAuthorityRouterOptions +} from './legacy-physical-worker-authority-session' +import { + importedPhysicalWorkerBindingKey, + importedPhysicalWorkerCheckpoint, + importedPhysicalWorkerPublicRouteKey, + reattachImportedPhysicalWorkerDownstream, + sameImportedPhysicalWorkerBinding +} from './legacy-physical-worker-authority-session' +import type { LegacyPhysicalWorkerEventRouter } from './legacy-physical-worker-event-router' +import { parseLegacyPhysicalWorkerActivation } from './legacy-physical-worker-source-event' +import { OrderedLegacyPtyProxy } from './legacy-pty-proxy' + +export type LegacyPhysicalWorkerSessionAdmissionState = Readonly<{ + sessionsByBinding: Map + sessionsByPublicRoute: Map + eventRouter: LegacyPhysicalWorkerEventRouter + maxSessions: number + options: LegacyPhysicalWorkerAuthorityRouterOptions + reportFault: (error: Error) => void + retireSession: (session: ImportedPhysicalWorkerPtySession) => void + onExitPublished: ( + session: ImportedPhysicalWorkerPtySession, + exit: Parameters[0] + ) => void +}> + +export async function admitLegacyPhysicalWorkerSession( + state: LegacyPhysicalWorkerSessionAdmissionState, + request: LegacyPhysicalWorkerAttachRequest +): Promise { + const binding = request.binding + const bindingKey = importedPhysicalWorkerBindingKey(binding) + const publicRouteKey = importedPhysicalWorkerPublicRouteKey( + binding.physicalPtyId, + binding.ptyIncarnationId + ) + const current = state.sessionsByBinding.get(bindingKey) + const publicCollision = state.sessionsByPublicRoute.get(publicRouteKey) + if (publicCollision && !sameImportedPhysicalWorkerBinding(publicCollision.binding, binding)) { + throw new Error('legacy physical PTY public route identity is ambiguous') + } + if (!current && state.sessionsByBinding.size >= state.maxSessions) { + throw new Error('legacy physical PTY session capacity exceeded') + } + const route = await state.options.registry.resolveExactPtyRoute(binding.ownerIncarnationId, { + id: binding.physicalPtyId, + incarnationId: binding.ptyIncarnationId + }) + if (!route?.isCurrent()) { + return null + } + state.eventRouter.ensureSubscription(route.client) + if ( + current?.client === route.client && + current.route.generation === route.generation && + current.route.isCurrent() + ) { + current.downstreamRotating = true + try { + await current.ackTask + if (current.retired || !current.route.isCurrent()) { + return null + } + return await reattachImportedPhysicalWorkerDownstream(current, request, state.reportFault) + } finally { + current.downstreamRotating = false + } + } + const restored = state.options.cursors.restore(bindingKey) + const checkpointSourceEndSu = restored?.checkpoint.creditedEndSu ?? 0 + let session: ImportedPhysicalWorkerPtySession | null = null + const openedDownstream = state.options.downstream.open({ + id: binding.physicalPtyId, + incarnationId: binding.ptyIncarnationId, + checkpointSourceEndSu, + ...(request.sourceRecovery ? { sourceRecovery: request.sourceRecovery } : {}), + ...(restored?.checkpoint.downstreamIdentity + ? { durableDownstreamIdentity: restored.checkpoint.downstreamIdentity } + : {}), + context: request.context, + onCapacity: () => session?.proxy.onDownstreamCapacity() + }) + if (!openedDownstream) { + return null + } + if (openedDownstream.status === 'restore-required') { + return Object.freeze({ + incarnationId: binding.ptyIncarnationId, + sourceRecovery: openedDownstream.sourceRecovery + }) + } + const downstream = openedDownstream.attachment + let upstream + try { + upstream = await route.client.attach( + { + id: binding.physicalPtyId, + incarnationId: binding.ptyIncarnationId, + expectedPaneKey: request.pane.paneKey, + ...(request.expectedTabId ? { expectedTabId: request.expectedTabId } : {}) + }, + restored?.sourceRecovery + ) + } catch (error) { + downstream.dispose() + throw error + } + const activation = parsePtySourceReceivingActivation(upstream.sourceActivation) + if (!activation || activation.ptyIncarnation !== binding.ptyIncarnationId) { + downstream.dispose() + throw new Error('legacy physical worker did not activate source credit') + } + const upstreamIdentity = parseLegacyPhysicalWorkerActivation( + activation, + binding.physicalPtyId, + binding.ptyIncarnationId + ) + if (!route.isCurrent()) { + downstream.dispose() + return null + } + if (current) { + state.retireSession(current) + } + const cursorStore = state.options.cursors.checkpointStore(bindingKey) + try { + await cursorStore.commit( + importedPhysicalWorkerCheckpoint(upstreamIdentity, downstream.identity, checkpointSourceEndSu) + ) + } catch (error) { + downstream.dispose() + state.reportFault(error instanceof Error ? error : new Error(String(error))) + throw error + } + const proxy = new OrderedLegacyPtyProxy( + upstreamIdentity, + { + publishData: (span, onSettled) => session?.downstream.publishData(span, onSettled) ?? false, + publishExit: (exit, onSettled) => + session?.downstream.publishExit(exit, (settlement) => { + onSettled(settlement) + if (settlement.ok && session) { + state.onExitPublished(session, exit) + } + }) ?? false + }, + { + commit: (checkpoint) => + cursorStore.commit( + Object.freeze({ + ...checkpoint, + downstreamIdentity: session?.downstream.identity ?? downstream.identity + }) + ) + }, + (ack, acknowledgementId) => route.client.publishSourceAcknowledgement(ack, acknowledgementId), + undefined, + 0, + restored?.cursor + ) + session = { + binding: Object.freeze({ ...binding }), + client: route.client, + route, + upstreamIdentity, + downstream, + proxy, + cursorStore, + ackTask: null, + requestedAckEndSu: checkpointSourceEndSu, + mutationTail: Promise.resolve(), + exitRecorded: false, + exitRecording: false, + pendingExitCode: null, + upstreamExited: false, + retired: false, + downstreamRotating: false, + attachRequest: request + } + state.sessionsByBinding.set(bindingKey, session) + state.sessionsByPublicRoute.set(publicRouteKey, session) + state.eventRouter.ensureSubscription(route.client) + state.eventRouter.registerSession(session) + return Object.freeze({ + incarnationId: binding.ptyIncarnationId, + sourceActivation: downstream.sourceActivation, + ...(openedDownstream.sourceRecovery ? { sourceRecovery: openedDownstream.sourceRecovery } : {}) + }) +} diff --git a/src/relay/legacy-physical-worker-session-capacity.ts b/src/relay/legacy-physical-worker-session-capacity.ts new file mode 100644 index 00000000000..15d38b5cc67 --- /dev/null +++ b/src/relay/legacy-physical-worker-session-capacity.ts @@ -0,0 +1,10 @@ +export function resolveLegacyPhysicalWorkerSessionLimit( + value: number | undefined, + fallback: number +): number { + const selected = value ?? fallback + if (!Number.isSafeInteger(selected) || selected < 1 || selected > 4_096) { + throw new Error('legacy physical PTY session limit is invalid') + } + return selected +} diff --git a/src/relay/legacy-physical-worker-socket-connection.ts b/src/relay/legacy-physical-worker-socket-connection.ts new file mode 100644 index 00000000000..e93befb81e4 --- /dev/null +++ b/src/relay/legacy-physical-worker-socket-connection.ts @@ -0,0 +1,172 @@ +import { constants, open } from 'node:fs/promises' +import { createConnection, type Socket } from 'node:net' +import type { SshChannelMultiplexer } from '../main/ssh/ssh-channel-multiplexer' +import { createTerminalAuthoritySocketMultiplexer } from './terminal-authority-gateway-connection' +import type { LegacyPhysicalWorkerRpc } from './legacy-physical-worker-client' +import { encodeHandshakeFrame, FrameDecoder, MessageType, parseHandshakeMessage } from './protocol' + +const MAX_CREDENTIAL_BYTES = 512 +const CONNECT_TIMEOUT_MS = 5_000 + +export async function connectLegacyPhysicalWorkerSocket(input: { + socketPath: string + credentialFile: string + expectedBuildId: string +}): Promise { + const credential = await readPrivateCredential(input.credentialFile) + const socket = await connectSocket(input.socketPath) + try { + const leftover = await negotiateLegacyRelay(socket, input.expectedBuildId, credential) + return legacyWorkerRpc(createTerminalAuthoritySocketMultiplexer(socket, leftover)) + } catch (error) { + socket.destroy() + throw error + } +} + +function legacyWorkerRpc(mux: SshChannelMultiplexer): LegacyPhysicalWorkerRpc { + let open = true + const closeListeners = new Set<() => void>() + mux.onDispose(() => { + if (!open) { + return + } + open = false + for (const listener of closeListeners) { + listener() + } + closeListeners.clear() + }) + return Object.freeze({ + request: (method, params) => mux.request(method, params), + notify: (method, params) => { + if (!mux.notify(method, params)) { + throw new Error(`legacy physical worker rejected ${method}`) + } + }, + notifyWithSettlement: (method, params, onSettled) => + mux.notifyWithSettlement(method, params, onSettled), + onNotification: (listener) => mux.onNotification(listener), + isOpen: () => open, + onClose: (listener) => { + if (!open) { + queueMicrotask(listener) + return () => {} + } + closeListeners.add(listener) + return () => closeListeners.delete(listener) + }, + close: () => mux.dispose('shutdown') + }) +} + +function connectSocket(path: string): Promise { + return new Promise((resolve, reject) => { + const socket = createConnection({ path }) + const timer = setTimeout(() => { + socket.destroy() + reject(new Error('legacy physical worker connection timed out')) + }, CONNECT_TIMEOUT_MS) + timer.unref?.() + const onError = (error: Error): void => { + clearTimeout(timer) + reject(error) + } + socket.once('error', onError) + socket.once('connect', () => { + clearTimeout(timer) + socket.removeListener('error', onError) + resolve(socket) + }) + }) +} + +function negotiateLegacyRelay( + socket: Socket, + expectedBuildId: string, + credential: string +): Promise { + return new Promise((resolve, reject) => { + let settled = false + const timer = setTimeout( + () => finish(new Error('legacy physical worker handshake timed out')), + CONNECT_TIMEOUT_MS + ) + timer.unref?.() + const decoder = new FrameDecoder( + (frame) => { + if (frame.type !== MessageType.Handshake) { + finish(new Error('legacy physical worker returned a non-handshake frame')) + return + } + let message: ReturnType + try { + message = parseHandshakeMessage(frame.payload) + } catch { + finish(new Error('legacy physical worker handshake is invalid')) + return + } + if (message.type !== 'orca-relay-handshake-ok' || message.version !== expectedBuildId) { + finish(new Error('legacy physical worker build changed before connection')) + return + } + const leftover = decoder.drain() + cleanup() + settled = true + resolve(leftover) + }, + (error) => finish(error) + ) + const onData = (data: Buffer): void => decoder.feed(data) + const onClose = (): void => finish(new Error('legacy physical worker closed during handshake')) + const onError = (error: Error): void => finish(error) + const cleanup = (): void => { + clearTimeout(timer) + socket.removeListener('data', onData) + socket.removeListener('close', onClose) + socket.removeListener('error', onError) + } + const finish = (error: Error): void => { + if (settled) { + return + } + settled = true + cleanup() + reject(error) + } + socket.on('data', onData) + socket.once('close', onClose) + socket.once('error', onError) + socket.write( + encodeHandshakeFrame({ + type: 'orca-relay-handshake', + version: expectedBuildId, + endpointCredential: credential + }) + ) + }) +} + +async function readPrivateCredential(path: string): Promise { + const noFollow = process.platform === 'win32' ? 0 : (constants.O_NOFOLLOW ?? 0) + const handle = await open(path, constants.O_RDONLY | noFollow) + try { + const stat = await handle.stat() + if (!stat.isFile() || stat.size < 1 || stat.size > MAX_CREDENTIAL_BYTES) { + throw new Error('legacy physical worker credential is not bounded') + } + if (process.platform !== 'win32') { + const effectiveUid = process.geteuid?.() ?? process.getuid?.() + if ((effectiveUid !== undefined && stat.uid !== effectiveUid) || (stat.mode & 0o077) !== 0) { + throw new Error('legacy physical worker credential is not private') + } + } + const credential = (await handle.readFile('utf8')).trim() + if (!/^[A-Za-z0-9_-]{32,256}$/.test(credential)) { + throw new Error('legacy physical worker credential is invalid') + } + return credential + } finally { + await handle.close() + } +} diff --git a/src/relay/legacy-physical-worker-source-event.ts b/src/relay/legacy-physical-worker-source-event.ts new file mode 100644 index 00000000000..18eed836a82 --- /dev/null +++ b/src/relay/legacy-physical-worker-source-event.ts @@ -0,0 +1,126 @@ +import type { PtySourceDeliveryIdentity, PtySourceSpan } from '../shared/pty-source-credit-contract' +import { ptySourceSpanIsSplittable } from '../shared/pty-source-credit-contract' +import type { PtySourceReceivingActivation } from '../shared/pty-source-receiving-activation' +import { parsePtySourceReceivingActivation } from '../shared/pty-source-receiving-activation' + +export function parseLegacyPhysicalWorkerActivation( + value: unknown, + id: string, + incarnationId: string +): PtySourceDeliveryIdentity { + const activation = parsePtySourceReceivingActivation(value) + if (!activation || activation.ptyIncarnation !== incarnationId) { + throw new Error('legacy physical worker source activation is invalid') + } + return identityFromActivation(id, activation) +} + +export function parseLegacyPhysicalWorkerSourceSpan(value: unknown): PtySourceSpan { + if (typeof value !== 'object' || value === null) { + throw new Error('legacy physical worker source span is invalid') + } + const span = value as Partial + if ( + typeof span.id !== 'string' || + !span.id || + !positiveInteger(span.providerGeneration) || + !positiveInteger(span.clientGeneration) || + !positiveInteger(span.ownerGeneration) || + typeof span.ptyIncarnation !== 'string' || + !span.ptyIncarnation || + typeof span.deliveryToken !== 'string' || + !span.deliveryToken || + typeof span.spanId !== 'string' || + !span.spanId || + !nonNegativeInteger(span.sourceStartSu) || + !positiveInteger(span.sourceEndSu) || + Number(span.sourceEndSu) <= Number(span.sourceStartSu) || + !nonNegativeInteger(span.displayStart) || + !nonNegativeInteger(span.displayEnd) || + Number(span.displayEnd) < Number(span.displayStart) || + typeof span.data !== 'string' || + span.data.length === 0 || + (span.splittable !== undefined && typeof span.splittable !== 'boolean') || + (span.indivisible !== undefined && typeof span.indivisible !== 'boolean') || + (span.splittable !== undefined && + span.indivisible !== undefined && + span.splittable === span.indivisible) || + typeof span.transform !== 'object' || + span.transform === null || + typeof span.transform.transformed !== 'boolean' || + !positiveInteger(span.transform.rawLengthSu) || + typeof span.transform.scalarSafe !== 'boolean' + ) { + throw new Error('legacy physical worker source span is invalid') + } + const parsed = Object.freeze({ + id: span.id, + providerGeneration: Number(span.providerGeneration), + clientGeneration: Number(span.clientGeneration), + ownerGeneration: Number(span.ownerGeneration), + ptyIncarnation: span.ptyIncarnation, + deliveryToken: span.deliveryToken, + spanId: span.spanId, + sourceStartSu: Number(span.sourceStartSu), + sourceEndSu: Number(span.sourceEndSu), + displayStart: Number(span.displayStart), + displayEnd: Number(span.displayEnd), + data: span.data, + ...(span.splittable !== undefined ? { splittable: span.splittable } : {}), + ...(span.indivisible !== undefined ? { indivisible: span.indivisible } : {}), + transform: Object.freeze({ ...span.transform }) + }) + if ( + !ptySourceSpanIsSplittable(parsed) && + parsed.sourceEndSu - parsed.sourceStartSu > 256 * 1024 + ) { + throw new Error('legacy physical worker indivisible source span is too large') + } + return parsed +} + +export function parseLegacyPhysicalWorkerExit( + value: unknown +): Readonly<{ id: string; incarnationId: string; code: number }> { + if (typeof value !== 'object' || value === null) { + throw new Error('legacy physical worker exit is invalid') + } + const exit = value as Record + const incarnationId = + typeof exit.incarnationId === 'string' + ? exit.incarnationId + : typeof exit.ptyIncarnation === 'string' + ? exit.ptyIncarnation + : '' + if ( + typeof exit.id !== 'string' || + !exit.id || + !incarnationId || + !Number.isSafeInteger(exit.code) + ) { + throw new Error('legacy physical worker exit is invalid') + } + return Object.freeze({ id: exit.id, incarnationId, code: Number(exit.code) }) +} + +function identityFromActivation( + id: string, + activation: PtySourceReceivingActivation +): PtySourceDeliveryIdentity { + return Object.freeze({ + id, + providerGeneration: 1, + clientGeneration: activation.clientGeneration, + ownerGeneration: activation.ownerGeneration, + ptyIncarnation: activation.ptyIncarnation, + deliveryToken: activation.deliveryToken + }) +} + +function positiveInteger(value: unknown): boolean { + return Number.isSafeInteger(value) && Number(value) > 0 +} + +function nonNegativeInteger(value: unknown): boolean { + return Number.isSafeInteger(value) && Number(value) >= 0 +} diff --git a/src/relay/legacy-physical-worker-upstream-exit.ts b/src/relay/legacy-physical-worker-upstream-exit.ts new file mode 100644 index 00000000000..348e0160648 --- /dev/null +++ b/src/relay/legacy-physical-worker-upstream-exit.ts @@ -0,0 +1,44 @@ +import type { + ImportedPhysicalWorkerPtySession, + LegacyPhysicalWorkerAuthorityRouterOptions +} from './legacy-physical-worker-authority-session' + +export function acceptLegacyPhysicalWorkerUpstreamExit( + session: ImportedPhysicalWorkerPtySession, + code: number, + options: LegacyPhysicalWorkerAuthorityRouterOptions, + isDisposed: () => boolean +): void { + if (session.exitRecording) { + if (session.pendingExitCode !== code) { + options.onWorkerFault?.(new Error('legacy physical worker exit code changed')) + } + return + } + session.exitRecording = true + session.pendingExitCode = code + if (!options.recordExit) { + session.proxy.acceptExit({ + id: session.binding.physicalPtyId, + incarnationId: session.binding.ptyIncarnationId, + code + }) + return + } + void options + .recordExit(session.attachRequest, code) + .then(() => { + if (!session.retired && !session.exitRecorded) { + session.proxy.acceptExit({ + id: session.binding.physicalPtyId, + incarnationId: session.binding.ptyIncarnationId, + code + }) + } + }) + .catch((error) => { + if (!isDisposed()) { + options.onWorkerFault?.(error instanceof Error ? error : new Error(String(error))) + } + }) +} diff --git a/src/relay/legacy-pty-proxy-contract.ts b/src/relay/legacy-pty-proxy-contract.ts new file mode 100644 index 00000000000..50497f9319b --- /dev/null +++ b/src/relay/legacy-pty-proxy-contract.ts @@ -0,0 +1,28 @@ +import type { PtySourceSpan } from '../shared/pty-source-credit-contract' +import type { TerminalAuthorityOutcomeDeliveryAttempt } from './terminal-session-authority-outcome-delivery' + +export const LEGACY_PTY_PROXY_MAX_RETAINED_BYTES = 2 * 1024 * 1024 +export const LEGACY_PTY_PROXY_MAX_FRAMES = 1_024 + +export type LegacyPtyProxyDownstreamSettlement = + | Readonly<{ ok: true }> + | Readonly<{ ok: false; error: Error }> + +export type LegacyPtyProxySink = Readonly<{ + publishData: ( + span: PtySourceSpan, + onSettled: (settlement: LegacyPtyProxyDownstreamSettlement) => void + ) => boolean + publishExit: ( + exit: LegacyPtyProxyExit, + onSettled: (settlement: LegacyPtyProxyDownstreamSettlement) => void + ) => boolean +}> + +export type LegacyPtyProxyExit = Readonly<{ + id: string + incarnationId: string + code: number + sourceEndSu: number + authorityOutcome?: TerminalAuthorityOutcomeDeliveryAttempt +}> diff --git a/src/relay/legacy-pty-proxy-cursor-repository.test.ts b/src/relay/legacy-pty-proxy-cursor-repository.test.ts new file mode 100644 index 00000000000..e1c095980f5 --- /dev/null +++ b/src/relay/legacy-pty-proxy-cursor-repository.test.ts @@ -0,0 +1,83 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import type { LegacyPtyProxyCursorCheckpoint } from './legacy-pty-proxy-cursor' +import { FileLegacyPtyProxyCursorRepository } from './legacy-pty-proxy-cursor-repository' + +const temporaryDirectories: string[] = [] + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })) + ) +}) + +describe('file legacy PTY proxy cursor repository', () => { + it('atomically restores a bounded cumulative ACK cursor after restart', async () => { + const root = await temporaryDirectory() + const file = path.join(root, 'private', 'proxy-cursors.json') + const first = await FileLegacyPtyProxyCursorRepository.open(file, { + maxRecords: 2, + maxBytes: 8 * 1024 + }) + await first.checkpointStore('binding-1').commit(checkpoint(4)) + + const restarted = await FileLegacyPtyProxyCursorRepository.open(file, { + maxRecords: 2, + maxBytes: 8 * 1024 + }) + expect(restarted.restore('binding-1')).toEqual({ + checkpoint: checkpoint(4), + cursor: { durableDownstreamAckedEndSu: 4, upstreamAckedEndSu: 0 }, + sourceRecovery: { + status: 'checkpoint', + clientGeneration: 2, + ownerGeneration: 3, + ptyIncarnation: 'incarnation-1', + deliveryToken: 'delivery-1', + acceptedSourceEndSu: 4 + } + }) + await expect(restarted.checkpointStore('binding-1').commit(checkpoint(3))).rejects.toThrow( + 'cursor regressed' + ) + }) + + it('never evicts a durable cursor to exceed its record bound', async () => { + const root = await temporaryDirectory() + const repository = await FileLegacyPtyProxyCursorRepository.open( + path.join(root, 'proxy-cursors.json'), + { maxRecords: 1 } + ) + await repository.checkpointStore('binding-1').commit(checkpoint(1)) + await expect(repository.checkpointStore('binding-2').commit(checkpoint(1))).rejects.toThrow( + 'repository is full' + ) + expect(repository.restore('binding-1')?.checkpoint.creditedEndSu).toBe(1) + }) +}) + +function checkpoint(creditedEndSu: number): LegacyPtyProxyCursorCheckpoint { + return Object.freeze({ + checkpointId: 'checkpoint-1', + acknowledgementId: `ack-${creditedEndSu}`, + identity: Object.freeze({ + id: 'pty-1', + providerGeneration: 1, + clientGeneration: 2, + ownerGeneration: 3, + ptyIncarnation: 'incarnation-1', + deliveryToken: 'delivery-1' + }), + creditedEndSu + }) +} + +async function temporaryDirectory(): Promise { + const directory = await mkdtemp(path.join(tmpdir(), 'orca-legacy-cursor-')) + temporaryDirectories.push(directory) + return directory +} diff --git a/src/relay/legacy-pty-proxy-cursor-repository.ts b/src/relay/legacy-pty-proxy-cursor-repository.ts new file mode 100644 index 00000000000..0865e8db2da --- /dev/null +++ b/src/relay/legacy-pty-proxy-cursor-repository.ts @@ -0,0 +1,242 @@ +import { mkdir, open, readFile, rename, rm } from 'node:fs/promises' +import { dirname } from 'node:path' +import type { PtySourceRecoveryRequest } from '../shared/pty-source-recovery-contract' +import type { + LegacyPtyProxyCheckpointStore, + LegacyPtyProxyCursorCheckpoint, + LegacyPtyProxyRestoredCursor +} from './legacy-pty-proxy-cursor' + +const CURSOR_REPOSITORY_VERSION = 1 +const DEFAULT_MAX_CURSOR_RECORDS = 256 +const DEFAULT_MAX_CURSOR_BYTES = 1024 * 1024 + +type CursorRecord = Readonly<{ + bindingKey: string + checkpoint: LegacyPtyProxyCursorCheckpoint +}> + +type CursorSnapshot = Readonly<{ + version: typeof CURSOR_REPOSITORY_VERSION + records: readonly CursorRecord[] +}> + +export type LegacyPtyProxyCursorRestore = Readonly<{ + checkpoint: LegacyPtyProxyCursorCheckpoint + cursor: LegacyPtyProxyRestoredCursor + sourceRecovery: PtySourceRecoveryRequest +}> + +export type LegacyPtyProxyCursorRepository = Readonly<{ + restore(bindingKey: string): LegacyPtyProxyCursorRestore | null + checkpointStore(bindingKey: string): LegacyPtyProxyCheckpointStore +}> + +export class FileLegacyPtyProxyCursorRepository implements LegacyPtyProxyCursorRepository { + private readonly records = new Map() + private tail: Promise = Promise.resolve() + + private constructor( + private readonly file: string, + private readonly maxRecords: number, + private readonly maxBytes: number + ) {} + + static async open( + file: string, + limits: Readonly<{ maxRecords?: number; maxBytes?: number }> = {} + ): Promise { + const maxRecords = positiveLimit(limits.maxRecords, DEFAULT_MAX_CURSOR_RECORDS) + const maxBytes = positiveLimit(limits.maxBytes, DEFAULT_MAX_CURSOR_BYTES) + await mkdir(dirname(file), { recursive: true }) + const repository = new FileLegacyPtyProxyCursorRepository(file, maxRecords, maxBytes) + const snapshot = await readSnapshot(file, maxBytes) + for (const record of snapshot?.records ?? []) { + assertBindingKey(record.bindingKey) + assertCheckpoint(record.checkpoint) + if (repository.records.has(record.bindingKey)) { + throw new Error('legacy PTY proxy cursor binding is duplicated') + } + repository.records.set(record.bindingKey, Object.freeze(structuredClone(record.checkpoint))) + } + if (repository.records.size > maxRecords) { + throw new Error('legacy PTY proxy cursor repository exceeds its bound') + } + return repository + } + + restore(bindingKey: string): LegacyPtyProxyCursorRestore | null { + assertBindingKey(bindingKey) + const checkpoint = this.records.get(bindingKey) + if (!checkpoint) { + return null + } + return Object.freeze({ + checkpoint: structuredClone(checkpoint), + cursor: Object.freeze({ + durableDownstreamAckedEndSu: checkpoint.creditedEndSu, + upstreamAckedEndSu: 0 + }), + sourceRecovery: Object.freeze({ + status: 'checkpoint', + clientGeneration: checkpoint.identity.clientGeneration, + ownerGeneration: checkpoint.identity.ownerGeneration, + ptyIncarnation: checkpoint.identity.ptyIncarnation, + deliveryToken: checkpoint.identity.deliveryToken, + acceptedSourceEndSu: checkpoint.creditedEndSu + }) + }) + } + + checkpointStore(bindingKey: string): LegacyPtyProxyCheckpointStore { + assertBindingKey(bindingKey) + return Object.freeze({ + commit: (checkpoint: LegacyPtyProxyCursorCheckpoint) => + this.enqueueCommit(bindingKey, checkpoint) + }) + } + + private enqueueCommit( + bindingKey: string, + unsafeCheckpoint: LegacyPtyProxyCursorCheckpoint + ): Promise { + const checkpoint = Object.freeze(structuredClone(unsafeCheckpoint)) + assertCheckpoint(checkpoint) + const operation = this.tail.then(async () => { + const previous = this.records.get(bindingKey) + if (previous && checkpoint.creditedEndSu < previous.creditedEndSu) { + throw new Error('legacy PTY proxy cursor regressed') + } + if (!previous && this.records.size >= this.maxRecords) { + throw new Error('legacy PTY proxy cursor repository is full') + } + this.records.set(bindingKey, checkpoint) + try { + await this.writeSnapshot() + } catch (error) { + if (previous) { + this.records.set(bindingKey, previous) + } else { + this.records.delete(bindingKey) + } + throw error + } + }) + this.tail = operation.catch(() => {}) + return operation + } + + private async writeSnapshot(): Promise { + const snapshot: CursorSnapshot = Object.freeze({ + version: CURSOR_REPOSITORY_VERSION, + records: Object.freeze( + [...this.records.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([bindingKey, checkpoint]) => Object.freeze({ bindingKey, checkpoint })) + ) + }) + const encoded = `${JSON.stringify(snapshot)}\n` + if (Buffer.byteLength(encoded) > this.maxBytes) { + throw new Error('legacy PTY proxy cursor snapshot exceeds its bound') + } + const temporary = `${this.file}.${process.pid}.tmp` + try { + const handle = await open(temporary, 'w', 0o600) + try { + await handle.writeFile(encoded, 'utf8') + await handle.sync() + } finally { + await handle.close() + } + await rename(temporary, this.file) + } catch (error) { + await rm(temporary, { force: true }).catch(() => {}) + throw error + } + await syncDirectory(dirname(this.file)) + } +} + +async function readSnapshot(file: string, maxBytes: number): Promise { + let encoded: Buffer + try { + encoded = await readFile(file) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return null + } + throw error + } + if (encoded.byteLength < 1 || encoded.byteLength > maxBytes) { + throw new Error('legacy PTY proxy cursor snapshot is not bounded') + } + const value = JSON.parse(encoded.toString('utf8')) as Partial + if (value.version !== CURSOR_REPOSITORY_VERSION || !Array.isArray(value.records)) { + throw new Error('legacy PTY proxy cursor snapshot is invalid') + } + return value as CursorSnapshot +} + +function assertCheckpoint(checkpoint: LegacyPtyProxyCursorCheckpoint): void { + if ( + !checkpoint.checkpointId || + !checkpoint.acknowledgementId || + !checkpoint.identity?.deliveryToken || + !Number.isSafeInteger(checkpoint.creditedEndSu) || + checkpoint.creditedEndSu < 0 + ) { + throw new Error('legacy PTY proxy cursor checkpoint is invalid') + } + assertDeliveryIdentity(checkpoint.identity) + if (checkpoint.downstreamIdentity) { + assertDeliveryIdentity(checkpoint.downstreamIdentity) + } +} + +function assertDeliveryIdentity(identity: LegacyPtyProxyCursorCheckpoint['identity']): void { + if ( + !identity.id || + !identity.ptyIncarnation || + !identity.deliveryToken || + !Number.isSafeInteger(identity.providerGeneration) || + identity.providerGeneration < 1 || + !Number.isSafeInteger(identity.clientGeneration) || + identity.clientGeneration < 1 || + !Number.isSafeInteger(identity.ownerGeneration) || + identity.ownerGeneration < 1 + ) { + throw new Error('legacy PTY proxy cursor delivery identity is invalid') + } +} + +function assertBindingKey(bindingKey: string): void { + if (!bindingKey || Buffer.byteLength(bindingKey) > 4_096 || bindingKey.includes('\0')) { + throw new Error('legacy PTY proxy cursor binding key is invalid') + } +} + +function positiveLimit(value: number | undefined, fallback: number): number { + const selected = value ?? fallback + if (!Number.isSafeInteger(selected) || selected < 1) { + throw new Error('legacy PTY proxy cursor repository limit is invalid') + } + return selected +} + +async function syncDirectory(path: string): Promise { + let directory + try { + directory = await open(path, 'r') + await directory.sync() + } catch (error) { + if ( + !['EACCES', 'EBADF', 'EINVAL', 'EISDIR', 'ENOTSUP', 'EPERM'].includes( + (error as NodeJS.ErrnoException).code ?? '' + ) + ) { + throw error + } + } finally { + await directory?.close() + } +} diff --git a/src/relay/legacy-pty-proxy-cursor.ts b/src/relay/legacy-pty-proxy-cursor.ts new file mode 100644 index 00000000000..461ab6b667d --- /dev/null +++ b/src/relay/legacy-pty-proxy-cursor.ts @@ -0,0 +1,126 @@ +import type { + PtySourceCreditAck, + PtySourceDeliveryIdentity +} from '../shared/pty-source-credit-contract' + +export type LegacyPtyProxyCursorCheckpoint = Readonly<{ + checkpointId: string + acknowledgementId: string + identity: PtySourceDeliveryIdentity + downstreamIdentity?: PtySourceDeliveryIdentity + creditedEndSu: number +}> + +export type LegacyPtyProxyCheckpointStore = Readonly<{ + commit: (checkpoint: LegacyPtyProxyCursorCheckpoint) => Promise +}> + +export type LegacyPtyProxyRestoredCursor = Readonly<{ + durableDownstreamAckedEndSu: number + upstreamAckedEndSu: number +}> + +export class LegacyPtyProxyCursor { + private durableEndSu: number + private publishedEndSu: number + private requestedEndSu: number + private drain: Promise | null = null + + constructor( + private readonly identity: PtySourceDeliveryIdentity, + checkpointSourceEndSu: number, + restored: LegacyPtyProxyRestoredCursor | undefined, + private readonly store: LegacyPtyProxyCheckpointStore, + private readonly publish: (ack: PtySourceCreditAck, acknowledgementId: string) => Promise + ) { + const durableEndSu = restored?.durableDownstreamAckedEndSu ?? checkpointSourceEndSu + const publishedEndSu = restored?.upstreamAckedEndSu ?? checkpointSourceEndSu + if ( + !Number.isSafeInteger(checkpointSourceEndSu) || + checkpointSourceEndSu < 0 || + !Number.isSafeInteger(durableEndSu) || + !Number.isSafeInteger(publishedEndSu) || + publishedEndSu > durableEndSu || + durableEndSu < checkpointSourceEndSu + ) { + throw new Error('legacy PTY proxy restored cursor is invalid') + } + this.durableEndSu = durableEndSu + this.publishedEndSu = publishedEndSu + this.requestedEndSu = durableEndSu + } + + get downstreamAckedEndSu(): number { + return this.durableEndSu + } + + get upstreamAckedEndSu(): number { + return this.publishedEndSu + } + + acknowledge(creditedEndSu: number): Promise { + if (creditedEndSu < this.durableEndSu) { + return Promise.resolve() + } + this.requestedEndSu = Math.max(this.requestedEndSu, creditedEndSu) + return this.startDrain() + } + + retry(): Promise { + this.requestedEndSu = Math.max(this.requestedEndSu, this.durableEndSu) + return this.startDrain() + } + + private startDrain(): Promise { + if (this.drain) { + return this.drain + } + const operation = Promise.resolve().then(() => this.commitAndPublishRequested()) + this.drain = operation + void operation + .finally(() => { + if (this.drain === operation) { + this.drain = null + } + }) + .catch(() => {}) + return operation + } + + private async commitAndPublishRequested(): Promise { + while (true) { + const requestedEndSu = this.requestedEndSu + if (requestedEndSu > this.durableEndSu) { + await this.store.commit(this.checkpoint(requestedEndSu)) + this.durableEndSu = requestedEndSu + } + if (this.durableEndSu > this.publishedEndSu) { + const checkpoint = this.checkpoint(this.durableEndSu) + await this.publish( + { + id: this.identity.id, + clientGeneration: this.identity.clientGeneration, + ownerGeneration: this.identity.ownerGeneration, + deliveryToken: this.identity.deliveryToken, + creditedEndSu: this.durableEndSu + }, + checkpoint.acknowledgementId + ) + this.publishedEndSu = this.durableEndSu + } + if (this.requestedEndSu <= this.durableEndSu && this.durableEndSu <= this.publishedEndSu) { + return + } + } + } + + private checkpoint(creditedEndSu: number): LegacyPtyProxyCursorCheckpoint { + const checkpointId = `legacy-pty-cursor:${this.identity.providerGeneration}:${this.identity.deliveryToken}` + return Object.freeze({ + checkpointId, + acknowledgementId: `${checkpointId}:${creditedEndSu}`, + identity: this.identity, + creditedEndSu + }) + } +} diff --git a/src/relay/legacy-pty-proxy.test.ts b/src/relay/legacy-pty-proxy.test.ts new file mode 100644 index 00000000000..ce6cb2daefb --- /dev/null +++ b/src/relay/legacy-pty-proxy.test.ts @@ -0,0 +1,361 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { PtySourceDeliveryIdentity, PtySourceSpan } from '../shared/pty-source-credit-contract' +import { + LegacyPhysicalWorkerClient, + type LegacyPhysicalWorkerRpc +} from './legacy-physical-worker-client' +import { + OrderedLegacyPtyProxy, + type LegacyPtyProxyCursorCheckpoint, + type LegacyPtyProxyExit, + type LegacyPtyProxySink +} from './legacy-pty-proxy' +import { LegacyPtyProxyCursor } from './legacy-pty-proxy-cursor' + +const identity: PtySourceDeliveryIdentity = Object.freeze({ + id: 'pty-1', + providerGeneration: 1, + clientGeneration: 2, + ownerGeneration: 3, + ptyIncarnation: 'incarnation-1', + deliveryToken: 'delivery-1' +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('ordered legacy PTY proxy', () => { + it('publishes data then exit and ACKs upstream only from cumulative downstream ACK', async () => { + vi.useFakeTimers() + const sink = new ControlledSink() + const upstreamAcks: number[] = [] + const order: string[] = [] + const checkpoints = new MemoryCheckpointStore(order) + const proxy = new OrderedLegacyPtyProxy(identity, sink, checkpoints, async (ack) => { + order.push(`upstream:${ack.creditedEndSu}`) + upstreamAcks.push(ack.creditedEndSu) + }) + + proxy.acceptData(sourceSpan('span-1', 0, 4, 'one!')) + proxy.acceptData(sourceSpan('span-2', 4, 8, 'two!')) + proxy.acceptExit({ id: 'pty-1', incarnationId: 'incarnation-1', code: 0 }) + expect(sink.publications).toEqual(['data:span-1']) + expect(upstreamAcks).toEqual([]) + + sink.settleData(0, { ok: true }) + await Promise.resolve() + expect(sink.publications).toEqual(['data:span-1', 'data:span-2']) + sink.settleData(1, { ok: true }) + await Promise.resolve() + expect(sink.publications).toEqual(['data:span-1', 'data:span-2', 'exit:8']) + expect(upstreamAcks).toEqual([]) + + await vi.advanceTimersByTimeAsync(60_000) + expect(upstreamAcks).toEqual([]) + await proxy.acknowledgeDownstream(4) + await proxy.acknowledgeDownstream(4) + await proxy.acknowledgeDownstream(8) + expect(upstreamAcks).toEqual([4, 8]) + expect(order).toEqual(['checkpoint:4', 'upstream:4', 'checkpoint:8', 'upstream:8']) + expect(proxy.snapshot()).toMatchObject({ + retainedBytes: 0, + retainedFrames: 0, + downstreamAckedEndSu: 8, + upstreamAckedEndSu: 8, + exitState: 'sending' + }) + sink.settleExit({ ok: true }) + expect(proxy.snapshot().exitState).toBe('published') + }) + + it('retains bounded data across downstream backpressure without early ACK', async () => { + const sink = new ControlledSink() + sink.admitData = false + const upstreamAck = vi.fn(async () => {}) + const proxy = new OrderedLegacyPtyProxy( + identity, + sink, + new MemoryCheckpointStore(), + upstreamAck, + { + maxRetainedBytes: 8, + maxFrames: 2 + } + ) + + expect(proxy.acceptData(sourceSpan('span-1', 0, 4, 'four'))).toBe(true) + expect(proxy.snapshot()).toMatchObject({ retainedBytes: 4, retainedFrames: 1 }) + expect(upstreamAck).not.toHaveBeenCalled() + sink.admitData = true + proxy.onDownstreamCapacity() + expect(sink.publications).toEqual(['data:span-1']) + sink.settleData(0, { ok: false, error: new Error('closed sink') }) + proxy.onDownstreamCapacity() + expect(sink.publications).toEqual(['data:span-1', 'data:span-1']) + sink.settleData(1, { ok: true }) + await Promise.resolve() + expect(upstreamAck).not.toHaveBeenCalled() + await proxy.acknowledgeDownstream(4) + expect(upstreamAck).toHaveBeenCalledTimes(1) + }) + + it('fails closed at its retention bound without ACK or liveness fallback', () => { + const sink = new ControlledSink() + sink.admitData = false + const upstreamAck = vi.fn(async () => {}) + const proxy = new OrderedLegacyPtyProxy( + identity, + sink, + new MemoryCheckpointStore(), + upstreamAck, + { + maxRetainedBytes: 4, + maxFrames: 1 + } + ) + proxy.acceptData(sourceSpan('span-1', 0, 4, 'four')) + expect(() => proxy.acceptData(sourceSpan('span-2', 4, 8, 'more'))).toThrow( + 'retention capacity exceeded' + ) + expect(proxy.snapshot()).toMatchObject({ failed: true, retainedBytes: 4 }) + expect(upstreamAck).not.toHaveBeenCalled() + }) + + it('recovers a crash after durable cursor commit with the same ACK identity', async () => { + const sink = new ControlledSink() + const store = new MemoryCheckpointStore() + const acknowledgementIds: string[] = [] + const rpc = new SettlementWorkerRpc() + const worker = settlementWorker(rpc) + const publish = async ( + ack: Parameters[0], + acknowledgementId: string + ) => { + acknowledgementIds.push(acknowledgementId) + await worker.publishSourceAcknowledgement(ack, acknowledgementId) + } + const proxy = new OrderedLegacyPtyProxy(identity, sink, store, publish) + proxy.acceptData(sourceSpan('span-1', 0, 4, 'four')) + sink.settleData(0, { ok: true }) + const firstPublication = proxy.acknowledgeDownstream(4) + await vi.waitFor(() => expect(rpc.publications).toHaveLength(1)) + expect(store.checkpoints).toHaveLength(1) + rpc.settle(0, { ok: false, error: new Error('crash after cursor commit') }) + await expect(firstPublication).rejects.toThrow('crash after cursor commit') + expect(proxy.snapshot()).toMatchObject({ + downstreamAckedEndSu: 4, + upstreamAckedEndSu: 0 + }) + proxy.dispose() + + const recovered = new OrderedLegacyPtyProxy( + identity, + new ControlledSink(), + store, + publish, + undefined, + 0, + { durableDownstreamAckedEndSu: 4, upstreamAckedEndSu: 0 } + ) + const retry = recovered.retryDurableUpstreamAck() + await vi.waitFor(() => expect(rpc.publications).toHaveLength(2)) + rpc.settle(1, { ok: true }) + await retry + expect(acknowledgementIds[0]).toBe(acknowledgementIds[1]) + expect(rpc.publications[0]).toEqual(rpc.publications[1]) + expect(recovered.snapshot().upstreamAckedEndSu).toBe(4) + }) + + it('coalesces thousands of cumulative cursor advances before durable publication', async () => { + const checkpoints: LegacyPtyProxyCursorCheckpoint[] = [] + const upstream: number[] = [] + const cursor = new LegacyPtyProxyCursor( + identity, + 0, + undefined, + { commit: async (checkpoint) => void checkpoints.push(checkpoint) }, + async (ack) => void upstream.push(ack.creditedEndSu) + ) + + const advances = Array.from({ length: 2_000 }, (_, index) => cursor.acknowledge(index + 1)) + expect(new Set(advances).size).toBe(1) + await Promise.all(advances) + expect(checkpoints.map((checkpoint) => checkpoint.creditedEndSu)).toEqual([2_000]) + expect(upstream).toEqual([2_000]) + }) + + it('makes duplicate exit and disposal paths idempotent', () => { + const sink = new ControlledSink() + const proxy = new OrderedLegacyPtyProxy( + identity, + sink, + new MemoryCheckpointStore(), + async () => {} + ) + expect(proxy.acceptExit({ id: 'pty-1', incarnationId: 'incarnation-1', code: 9 })).toBe(true) + expect(proxy.acceptExit({ id: 'pty-1', incarnationId: 'incarnation-1', code: 9 })).toBe(true) + expect(sink.publications).toEqual(['exit:0']) + proxy.dispose() + proxy.dispose() + expect(proxy.snapshot()).toMatchObject({ disposed: true, retainedFrames: 0 }) + expect(() => proxy.acknowledgeDownstream(0)).toThrow('proxy is closed') + }) + + it('rejects stale delivery identity and noncontiguous frames', () => { + const proxy = new OrderedLegacyPtyProxy( + identity, + new ControlledSink(), + new MemoryCheckpointStore(), + async () => {} + ) + expect(() => + proxy.acceptData({ ...sourceSpan('span-1', 0, 4, 'four'), deliveryToken: 'stale' }) + ).toThrow('source identity is stale') + + const second = new OrderedLegacyPtyProxy( + identity, + new ControlledSink(), + new MemoryCheckpointStore(), + async () => {} + ) + expect(() => second.acceptData(sourceSpan('span-2', 2, 4, 'xx'))).toThrow( + 'source sequence is not contiguous' + ) + }) +}) + +class ControlledSink implements LegacyPtyProxySink { + readonly publications: string[] = [] + private readonly dataSettlements: ((result: SinkSettlement) => void)[] = [] + private exitSettlement: ((result: SinkSettlement) => void) | null = null + admitData = true + admitExit = true + + publishData(span: PtySourceSpan, onSettled: (result: SinkSettlement) => void): boolean { + if (!this.admitData) { + return false + } + this.publications.push(`data:${span.spanId}`) + this.dataSettlements.push(onSettled) + return true + } + + publishExit(exit: LegacyPtyProxyExit, onSettled: (result: SinkSettlement) => void): boolean { + if (!this.admitExit) { + return false + } + this.publications.push(`exit:${exit.sourceEndSu}`) + this.exitSettlement = onSettled + return true + } + + settleData(index: number, result: SinkSettlement): void { + this.dataSettlements[index](result) + } + + settleExit(result: SinkSettlement): void { + this.exitSettlement?.(result) + } +} + +type SinkSettlement = Readonly<{ ok: true }> | Readonly<{ ok: false; error: Error }> + +class MemoryCheckpointStore { + readonly checkpoints: LegacyPtyProxyCursorCheckpoint[] = [] + + constructor(private readonly order: string[] = []) {} + + async commit(checkpoint: LegacyPtyProxyCursorCheckpoint): Promise { + const previous = this.checkpoints.at(-1) + if (previous && checkpoint.creditedEndSu < previous.creditedEndSu) { + throw new Error('checkpoint regression') + } + this.order.push(`checkpoint:${checkpoint.creditedEndSu}`) + this.checkpoints.push(checkpoint) + } +} + +class SettlementWorkerRpc implements LegacyPhysicalWorkerRpc { + readonly publications: { method: string; params: Record }[] = [] + private readonly settlements: (( + result: Readonly<{ ok: true }> | Readonly<{ ok: false; error: Error }> + ) => void)[] = [] + + request(): Promise { + return Promise.reject(new Error('unexpected request')) + } + + notify(): void { + throw new Error('unsettled notification is forbidden') + } + + notifyWithSettlement( + method: string, + params: Record, + onSettled: (result: { ok: true } | { ok: false; error: Error }) => void + ): void { + this.publications.push({ method, params }) + this.settlements.push(onSettled) + } + + onNotification(): () => void { + return () => {} + } + + isOpen(): boolean { + return true + } + + onClose(): () => void { + return () => {} + } + + close(): void {} + + settle(index: number, result: { ok: true } | { ok: false; error: Error }): void { + this.settlements[index](result) + } +} + +function settlementWorker(rpc: LegacyPhysicalWorkerRpc): LegacyPhysicalWorkerClient { + return new LegacyPhysicalWorkerClient( + rpc, + { + protocolVersion: 1, + serverBuildId: 'build-a', + clientGeneration: 2, + role: 'session-owner', + ownerGeneration: 3, + ownerLease: 'owner-lease', + resumed: false + }, + { + consumerSessionVersion: 1, + outputFlowControlVersion: 1, + exactOperationsVersion: 1, + heldProducerPauseVersion: 1, + mutationMode: 'exact-v1', + sourceWindowSu: 1024 + } + ) +} + +function sourceSpan( + spanId: string, + sourceStartSu: number, + sourceEndSu: number, + data: string +): PtySourceSpan { + return Object.freeze({ + ...identity, + spanId, + sourceStartSu, + sourceEndSu, + displayStart: sourceStartSu, + displayEnd: sourceEndSu, + data, + splittable: true, + transform: { transformed: false, rawLengthSu: sourceEndSu - sourceStartSu, scalarSafe: true } + }) +} diff --git a/src/relay/legacy-pty-proxy.ts b/src/relay/legacy-pty-proxy.ts new file mode 100644 index 00000000000..fbcfaf0b8a0 --- /dev/null +++ b/src/relay/legacy-pty-proxy.ts @@ -0,0 +1,312 @@ +import type { + PtySourceCreditAck, + PtySourceDeliveryIdentity, + PtySourceSpan +} from '../shared/pty-source-credit-contract' +import { + LegacyPtyProxyCursor, + type LegacyPtyProxyCheckpointStore, + type LegacyPtyProxyRestoredCursor +} from './legacy-pty-proxy-cursor' +import { + LEGACY_PTY_PROXY_MAX_FRAMES, + LEGACY_PTY_PROXY_MAX_RETAINED_BYTES, + type LegacyPtyProxyDownstreamSettlement, + type LegacyPtyProxyExit, + type LegacyPtyProxySink +} from './legacy-pty-proxy-contract' + +export type { + LegacyPtyProxyCheckpointStore, + LegacyPtyProxyCursorCheckpoint, + LegacyPtyProxyRestoredCursor +} from './legacy-pty-proxy-cursor' +export { + LEGACY_PTY_PROXY_MAX_FRAMES, + LEGACY_PTY_PROXY_MAX_RETAINED_BYTES, + type LegacyPtyProxyExit, + type LegacyPtyProxySink +} from './legacy-pty-proxy-contract' + +type ProxyDataRecord = { + span: PtySourceSpan + bytes: number + state: 'queued' | 'sending' | 'published' +} + +type ProxyExitRecord = { + exit: LegacyPtyProxyExit + state: 'queued' | 'sending' | 'published' +} + +export class OrderedLegacyPtyProxy { + private readonly queue: ProxyDataRecord[] = [] + private exit: ProxyExitRecord | null = null + private receivedEndSu: number + private publishedEndSu: number + private readonly cursor: LegacyPtyProxyCursor + private retainedBytes = 0 + private disposed = false + private failed = false + + constructor( + private readonly identity: PtySourceDeliveryIdentity, + private readonly sink: LegacyPtyProxySink, + checkpointStore: LegacyPtyProxyCheckpointStore, + publishUpstreamAck: (ack: PtySourceCreditAck, acknowledgementId: string) => Promise, + private readonly limits: Readonly<{ + maxRetainedBytes: number + maxFrames: number + }> = { + maxRetainedBytes: LEGACY_PTY_PROXY_MAX_RETAINED_BYTES, + maxFrames: LEGACY_PTY_PROXY_MAX_FRAMES + }, + checkpointSourceEndSu = 0, + restoredCursor?: LegacyPtyProxyRestoredCursor + ) { + if ( + !Number.isSafeInteger(checkpointSourceEndSu) || + checkpointSourceEndSu < 0 || + !Number.isSafeInteger(limits.maxRetainedBytes) || + limits.maxRetainedBytes < 1 || + !Number.isSafeInteger(limits.maxFrames) || + limits.maxFrames < 1 + ) { + throw new Error('legacy PTY proxy limits are invalid') + } + this.cursor = new LegacyPtyProxyCursor( + identity, + checkpointSourceEndSu, + restoredCursor, + checkpointStore, + async (ack, acknowledgementId) => { + if (this.disposed) { + throw new Error('legacy PTY proxy closed after cursor commit') + } + await publishUpstreamAck(ack, acknowledgementId) + } + ) + const downstreamEndSu = this.cursor.downstreamAckedEndSu + this.receivedEndSu = downstreamEndSu + this.publishedEndSu = downstreamEndSu + } + + acceptData(span: PtySourceSpan): boolean { + this.assertActive() + this.assertIdentity(span) + if (this.exit) { + return this.fail('legacy PTY proxy received data after exit') + } + if (span.sourceEndSu <= this.cursor.downstreamAckedEndSu) { + void this.retryDurableUpstreamAck().catch(() => {}) + return true + } + if ( + span.sourceStartSu !== this.receivedEndSu || + !Number.isSafeInteger(span.sourceEndSu) || + span.sourceEndSu <= span.sourceStartSu + ) { + return this.fail('legacy PTY proxy source sequence is not contiguous') + } + const bytes = Buffer.byteLength(span.data) + if ( + bytes < 1 || + this.queue.length >= this.limits.maxFrames || + this.retainedBytes + bytes > this.limits.maxRetainedBytes + ) { + return this.fail('legacy PTY proxy retention capacity exceeded') + } + this.queue.push({ span: Object.freeze({ ...span }), bytes, state: 'queued' }) + this.retainedBytes += bytes + this.receivedEndSu = span.sourceEndSu + this.pump() + return true + } + + acceptExit(exit: Omit): boolean { + this.assertActive() + if ( + exit.id !== this.identity.id || + exit.incarnationId !== this.identity.ptyIncarnation || + !Number.isSafeInteger(exit.code) + ) { + return this.fail('legacy PTY proxy exit identity is invalid') + } + if (this.exit) { + return this.exit.exit.code === exit.code + ? true + : this.fail('legacy PTY proxy exit changed after sealing') + } + this.exit = { + exit: Object.freeze({ ...exit, sourceEndSu: this.receivedEndSu }), + state: 'queued' + } + this.pump() + return true + } + + acknowledgeDownstream(creditedEndSu: number): Promise { + this.assertActive() + if ( + !Number.isSafeInteger(creditedEndSu) || + creditedEndSu < 0 || + creditedEndSu > this.publishedEndSu || + (creditedEndSu > this.cursor.downstreamAckedEndSu && + !this.queue.some( + (record) => record.state === 'published' && record.span.sourceEndSu === creditedEndSu + )) + ) { + this.fail('legacy PTY proxy downstream ACK is invalid') + } + return this.cursor.acknowledge(creditedEndSu).finally(() => this.releaseAcknowledgedData()) + } + + retryDurableUpstreamAck(): Promise { + this.assertActive() + return this.cursor.retry().finally(() => this.releaseAcknowledgedData()) + } + + onDownstreamCapacity(): void { + if (!this.disposed && !this.failed) { + this.pump() + } + } + + retryDownstreamExit(): void { + this.assertActive() + if (this.exit && this.exit.state === 'published') { + this.exit.state = 'queued' + } + this.pump() + } + + dispose(): void { + if (this.disposed) { + return + } + this.disposed = true + this.queue.length = 0 + this.exit = null + this.retainedBytes = 0 + } + + snapshot(): Readonly<{ + receivedEndSu: number + publishedEndSu: number + downstreamAckedEndSu: number + upstreamAckedEndSu: number + retainedBytes: number + retainedFrames: number + exitState: ProxyExitRecord['state'] | null + failed: boolean + disposed: boolean + }> { + return Object.freeze({ + receivedEndSu: this.receivedEndSu, + publishedEndSu: this.publishedEndSu, + downstreamAckedEndSu: this.cursor.downstreamAckedEndSu, + upstreamAckedEndSu: this.cursor.upstreamAckedEndSu, + retainedBytes: this.retainedBytes, + retainedFrames: this.queue.length, + exitState: this.exit?.state ?? null, + failed: this.failed, + disposed: this.disposed + }) + } + + private pump(): void { + if (this.disposed || this.failed || this.queue.some((record) => record.state === 'sending')) { + return + } + const next = this.queue.find((record) => record.state === 'queued') + if (next) { + next.state = 'sending' + let callbackRan = false + const admitted = this.sink.publishData( + next.span, + onceSettlement((settlement) => { + callbackRan = true + if (this.disposed || this.failed) { + return + } + if (!settlement.ok) { + next.state = 'queued' + return + } + next.state = 'published' + this.publishedEndSu = Math.max(this.publishedEndSu, next.span.sourceEndSu) + queueMicrotask(() => this.pump()) + }) + ) + if (!admitted && !callbackRan) { + next.state = 'queued' + } + return + } + if (!this.exit || this.exit.state !== 'queued') { + return + } + this.exit.state = 'sending' + const exitRecord = this.exit + let callbackRan = false + const admitted = this.sink.publishExit( + exitRecord.exit, + onceSettlement((settlement) => { + callbackRan = true + if (this.disposed || this.failed || this.exit !== exitRecord) { + return + } + exitRecord.state = settlement.ok ? 'published' : 'queued' + }) + ) + if (!admitted && !callbackRan) { + exitRecord.state = 'queued' + } + } + + private releaseAcknowledgedData(): void { + while ( + this.queue[0]?.state === 'published' && + this.queue[0].span.sourceEndSu <= this.cursor.downstreamAckedEndSu + ) { + this.retainedBytes -= this.queue.shift()!.bytes + } + } + + private assertIdentity(span: PtySourceSpan): void { + if ( + span.id !== this.identity.id || + span.providerGeneration !== this.identity.providerGeneration || + span.clientGeneration !== this.identity.clientGeneration || + span.ownerGeneration !== this.identity.ownerGeneration || + span.ptyIncarnation !== this.identity.ptyIncarnation || + span.deliveryToken !== this.identity.deliveryToken + ) { + this.fail('legacy PTY proxy source identity is stale') + } + } + + private assertActive(): void { + if (this.disposed || this.failed) { + throw new Error('legacy PTY proxy is closed') + } + } + + private fail(message: string): false { + this.failed = true + throw new Error(message) + } +} + +function onceSettlement( + listener: (settlement: LegacyPtyProxyDownstreamSettlement) => void +): (settlement: LegacyPtyProxyDownstreamSettlement) => void { + let settled = false + return (result) => { + if (settled) { + return + } + settled = true + listener(result) + } +} diff --git a/src/relay/legacy-relay-broker-connection-proof.ts b/src/relay/legacy-relay-broker-connection-proof.ts new file mode 100644 index 00000000000..73d7606c688 --- /dev/null +++ b/src/relay/legacy-relay-broker-connection-proof.ts @@ -0,0 +1,117 @@ +import { posix, win32 } from 'node:path' +import type { + TerminalLegacyCutoverProof, + TerminalLegacyEndpointIdentity +} from '../shared/terminal-legacy-cutover' +import type { LegacyUnixSocketInspection } from './legacy-relay-unix-socket-inspection' + +const MIN_QUIESCENCE_SAMPLES = 2 +const MAX_QUIESCENCE_SAMPLES = 16 + +export type LegacyRelayStatusSample = Readonly<{ + pid: number + legacyCutover: Readonly<{ + capabilityVersion: 1 + configuredGraceMs: 0 + acknowledged: true + brokerConnectionIdentity: string + }> + socket: Readonly<{ + path: string + listening: boolean + clients: number + acceptedConnections: number + }> +}> + +export type LegacyRelayBrokerConnectionEvidence = Readonly<{ + brokerConnectionIdentity: string + brokerClientCount: 1 + acceptedConnectionCount: number + quiescenceSamples: number + endpointIdentity: TerminalLegacyEndpointIdentity + connectionProof: TerminalLegacyCutoverProof['connectionProof'] + graceConfiguration: TerminalLegacyCutoverProof['graceConfiguration'] +}> + +type PlatformConnectionInspection = + | LegacyUnixSocketInspection + | Readonly<{ + method: 'windows-pipe-process' + endpointIdentity: Extract + listenerIdentity: string + acceptedServerConnections: 1 + }> + +export function proveSoleLegacyRelayBroker(input: { + endpointPath: string + relayPid: number + brokerConnectionIdentity: string + samples: readonly LegacyRelayStatusSample[] + platformInspection: PlatformConnectionInspection +}): LegacyRelayBrokerConnectionEvidence { + assertBrokerConnectionIdentity(input.brokerConnectionIdentity) + if ( + input.samples.length < MIN_QUIESCENCE_SAMPLES || + input.samples.length > MAX_QUIESCENCE_SAMPLES + ) { + throw new Error('legacy relay quiescence sample count is invalid') + } + if (input.platformInspection.acceptedServerConnections !== 1) { + throw new Error('legacy relay platform inspection is not sole-client proof') + } + const windows = input.platformInspection.method === 'windows-pipe-process' + const expectedEndpoint = normalizeEndpoint(input.endpointPath, windows) + let acceptedConnectionCount: number | null = null + for (const sample of input.samples) { + if ( + sample.pid !== input.relayPid || + sample.legacyCutover.capabilityVersion !== 1 || + sample.legacyCutover.configuredGraceMs !== 0 || + sample.legacyCutover.acknowledged !== true || + sample.legacyCutover.brokerConnectionIdentity !== input.brokerConnectionIdentity || + !sample.socket.listening || + sample.socket.clients !== 1 || + normalizeEndpoint(sample.socket.path, windows) !== expectedEndpoint || + !Number.isSafeInteger(sample.socket.acceptedConnections) || + sample.socket.acceptedConnections < 1 + ) { + throw new Error('legacy relay status does not prove one live broker client') + } + acceptedConnectionCount ??= sample.socket.acceptedConnections + if (sample.socket.acceptedConnections !== acceptedConnectionCount) { + throw new Error('legacy relay accepted a client during the quiescence proof') + } + } + return Object.freeze({ + brokerConnectionIdentity: input.brokerConnectionIdentity, + brokerClientCount: 1, + acceptedConnectionCount: acceptedConnectionCount!, + quiescenceSamples: input.samples.length, + endpointIdentity: input.platformInspection.endpointIdentity, + graceConfiguration: Object.freeze({ + capabilityVersion: 1, + configuredGraceMs: 0, + acknowledged: true + }), + connectionProof: Object.freeze({ + method: input.platformInspection.method, + listenerIdentity: input.platformInspection.listenerIdentity, + brokerConnectionIdentity: input.brokerConnectionIdentity, + acceptedServerConnections: 1 + }) + }) +} + +function normalizeEndpoint(value: string, windows: boolean): string { + if (!value || value.includes('\0')) { + throw new Error('legacy relay endpoint path is invalid') + } + return windows ? win32.normalize(value).toLowerCase() : posix.normalize(value) +} + +function assertBrokerConnectionIdentity(value: string): void { + if (!value || value.length > 512 || value.includes('\0')) { + throw new Error('legacy relay broker connection identity is invalid') + } +} diff --git a/src/relay/legacy-relay-cutover-exclusion.test.ts b/src/relay/legacy-relay-cutover-exclusion.test.ts new file mode 100644 index 00000000000..2dc693a2d53 --- /dev/null +++ b/src/relay/legacy-relay-cutover-exclusion.test.ts @@ -0,0 +1,102 @@ +import { randomUUID } from 'node:crypto' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { encodeTerminalAuthorityOwnerToken } from '../main/session-authority/terminal-session-authority-owner-token' +import { + readCurrentTerminalAuthorityOwnerProcessIdentity, + type TerminalAuthorityOwnerProcessIdentity +} from '../main/session-authority/terminal-session-authority-owner-process' +import { legacyRelayCutoverExclusion } from './legacy-relay-cutover-exclusion' +import { createRelayOwnerGuardDirectory } from './relay-owner-guard-directory' + +const roots: string[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('legacy relay cutover exclusion', () => { + it('rejects a concurrent live owner and releases cleanly for the next cutover', async () => { + const relayDirectory = await temporaryDirectory() + let release!: () => void + const wait = new Promise((resolve) => { + release = resolve + }) + let entered!: () => void + const started = new Promise((resolve) => { + entered = resolve + }) + const first = legacyRelayCutoverExclusion(relayDirectory).runExclusive(async () => { + entered() + await wait + return 'first' + }) + await started + + await expect( + legacyRelayCutoverExclusion(relayDirectory).runExclusive(async () => 'second') + ).rejects.toThrow('already in progress') + release() + await expect(first).resolves.toBe('first') + await expect( + legacyRelayCutoverExclusion(relayDirectory).runExclusive(async () => 'third') + ).resolves.toBe('third') + }) + + it('recovers a crash orphan only from exact same-scope process evidence', async () => { + const relayDirectory = await temporaryDirectory() + const guardPath = path.join(relayDirectory, '.terminal-authority-cutover.lock') + await createRelayOwnerGuardDirectory(guardPath, await ownerToken(await absentIdentity())) + + await expect( + legacyRelayCutoverExclusion(relayDirectory).runExclusive(async () => 'recovered') + ).resolves.toBe('recovered') + }) + + it('keeps cross-scope and malformed crash evidence fenced', async () => { + const relayDirectory = await temporaryDirectory() + const guardPath = path.join(relayDirectory, '.terminal-authority-cutover.lock') + const identity = await absentIdentity() + const crossScope: TerminalAuthorityOwnerProcessIdentity = { + ...identity, + ...(identity.platform === 'linux' + ? { linuxPidNamespace: 'pid:[different-namespace]' } + : { executionScope: 'different-execution-scope' }) + } + await createRelayOwnerGuardDirectory(guardPath, await ownerToken(crossScope)) + await expect( + legacyRelayCutoverExclusion(relayDirectory).runExclusive(async () => undefined) + ).rejects.toThrow('already in progress') + + const malformedDirectory = await temporaryDirectory() + await writeFile( + path.join(malformedDirectory, '.terminal-authority-cutover.lock'), + 'legacy-lock' + ) + await expect( + legacyRelayCutoverExclusion(malformedDirectory).runExclusive(async () => undefined) + ).rejects.toThrow('already in progress') + }) +}) + +async function ownerToken(identity: TerminalAuthorityOwnerProcessIdentity): Promise { + return encodeTerminalAuthorityOwnerToken(randomUUID(), { + ownerIncarnationId: randomUUID(), + process: identity + }) +} + +async function absentIdentity(): Promise { + return { + ...(await readCurrentTerminalAuthorityOwnerProcessIdentity()), + pid: 2_147_483_647 + } +} + +async function temporaryDirectory(): Promise { + const directory = await mkdtemp(path.join(tmpdir(), 'orca-legacy-cutover-lock-')) + roots.push(directory) + return directory +} diff --git a/src/relay/legacy-relay-cutover-exclusion.ts b/src/relay/legacy-relay-cutover-exclusion.ts new file mode 100644 index 00000000000..c468986d278 --- /dev/null +++ b/src/relay/legacy-relay-cutover-exclusion.ts @@ -0,0 +1,63 @@ +import { randomUUID } from 'node:crypto' +import { join } from 'node:path' +import type { LegacyRelayLaunchExclusion } from './legacy-relay-public-endpoint-fence' +import { + createRelayOwnerGuardDirectory, + inspectRelayOwnerGuardDirectory, + releaseRelayOwnerGuardDirectory +} from './relay-owner-guard-directory' +import { + createTerminalAuthorityRegistryOwnerToken, + terminalAuthorityRegistryOwnerTokenIsGone +} from './terminal-authority-registry-owner-token' + +const CUTOVER_GUARD_DIRECTORY = '.terminal-authority-cutover.lock' + +export function legacyRelayCutoverExclusion(relayDirectory: string): LegacyRelayLaunchExclusion { + const guardPath = join(relayDirectory, CUTOVER_GUARD_DIRECTORY) + return Object.freeze({ + runExclusive: async (operation: () => Promise): Promise => { + const ownerToken = await createTerminalAuthorityRegistryOwnerToken(randomUUID()) + if (!(await acquireCutoverGuard(guardPath, ownerToken))) { + throw new Error('legacy relay launch or cutover is already in progress') + } + let operationCompleted = false + let operationResult!: T + let operationError: unknown + try { + operationResult = await operation() + operationCompleted = true + } catch (error) { + operationError = error + } + if (!(await releaseRelayOwnerGuardDirectory(guardPath, ownerToken))) { + throw new Error('legacy relay cutover guard ownership changed before release') + } + if (!operationCompleted) { + throw operationError + } + return operationResult + } + }) +} + +async function acquireCutoverGuard(guardPath: string, ownerToken: string): Promise { + for (let attempt = 0; attempt < 2; attempt++) { + if (await createRelayOwnerGuardDirectory(guardPath, ownerToken)) { + return true + } + const existing = await inspectRelayOwnerGuardDirectory(guardPath) + if (existing.status === 'missing') { + continue + } + if ( + attempt > 0 || + existing.status !== 'owned' || + !(await terminalAuthorityRegistryOwnerTokenIsGone(existing.ownerToken)) || + !(await releaseRelayOwnerGuardDirectory(guardPath, existing.ownerToken)) + ) { + return false + } + } + return false +} diff --git a/src/relay/legacy-relay-endpoint-cutover.test.ts b/src/relay/legacy-relay-endpoint-cutover.test.ts new file mode 100644 index 00000000000..f8728cd79ad --- /dev/null +++ b/src/relay/legacy-relay-endpoint-cutover.test.ts @@ -0,0 +1,425 @@ +import { createConnection, createServer, type Socket } from 'node:net' +import { access, mkdtemp, readFile, rename, rm, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import type { LegacyRelayBrokerConnectionEvidence } from './legacy-relay-broker-connection-proof' +import { relocatePosixLegacyRelaySocket } from './legacy-relay-posix-cutover' +import { inspectUnixEndpoint } from './legacy-relay-unix-socket-inspection' +import { + inspectWindowsLegacyRelayPipe, + sealWindowsLegacyRelayPipe, + type WindowsLegacyRelayCutoverDependencies +} from './legacy-relay-windows-cutover' + +const cleanupPaths: string[] = [] +const WINDOWS_BIRTH_MARKER = '638900000000000000' + +afterEach(async () => { + await Promise.all( + cleanupPaths.splice(0).map((path) => rm(path, { recursive: true, force: true })) + ) +}) + +describe.skipIf(process.platform === 'win32')('POSIX legacy relay socket cutover', () => { + it('atomically relocates a live socket and seals its credential idempotently', async () => { + const root = await temporaryDirectory() + const publicSocketPath = join(root, 'relay.sock') + const publicCredentialFile = join(root, 'relay.credential') + const privateStateDirectory = join(root, 'authority', 'legacy') + const privateSocketPath = join(privateStateDirectory, 'worker.sock') + const privateCredentialFile = join(privateStateDirectory, 'worker.credential') + await writeFile(publicCredentialFile, 'A'.repeat(43), { mode: 0o600 }) + const server = createServer((socket) => socket.on('data', (data) => socket.write(data))) + await listen(server, publicSocketPath) + const broker = await connect(publicSocketPath) + const expectedEndpointIdentity = await inspectUnixEndpoint(publicSocketPath) + + try { + const proof = await relocatePosixLegacyRelaySocket({ + publicSocketPath, + privateSocketPath, + publicCredentialFile, + privateCredentialFile, + privateStateDirectory, + expectedEndpointIdentity, + brokerEvidence: posixBrokerEvidence(expectedEndpointIdentity), + launchExclusion: testLaunchExclusion(), + sealedAtMs: 123 + }) + expect((await stat(publicSocketPath)).isDirectory()).toBe(true) + expect((await stat(publicCredentialFile)).isDirectory()).toBe(true) + expect(await readFile(privateCredentialFile, 'utf8')).toBe('A'.repeat(43)) + expect((await stat(privateStateDirectory)).mode & 0o777).toBe(0o700) + expect((await stat(privateCredentialFile)).mode & 0o777).toBe(0o600) + const relocatedIdentity = await inspectUnixEndpoint(privateSocketPath) + expect(relocatedIdentity).toMatchObject({ + device: expectedEndpointIdentity.device, + inode: expectedEndpointIdentity.inode + }) + expect(proof).toMatchObject({ + kind: 'posix-relocated', + privateSocketPath, + privateCredentialFile, + endpointIdentity: relocatedIdentity, + sealedAtMs: 123 + }) + + const replacement = await connect(privateSocketPath) + await expect(roundTrip(replacement, 'still-live')).resolves.toBe('still-live') + replacement.destroy() + const legacyCredentialTemp = `${publicCredentialFile}.old-client.tmp` + await writeFile(legacyCredentialTemp, 'old-client-credential') + await expect(rename(legacyCredentialTemp, publicCredentialFile)).rejects.toThrow() + await rm(legacyCredentialTemp, { force: true }) + await expect(rm(publicSocketPath, { force: true })).rejects.toThrow() + const parallelRelay = createServer() + await expect(listen(parallelRelay, publicSocketPath)).rejects.toThrow() + expect(parallelRelay.listening).toBe(false) + await expect( + relocatePosixLegacyRelaySocket({ + publicSocketPath, + privateSocketPath, + publicCredentialFile, + privateCredentialFile, + privateStateDirectory, + expectedEndpointIdentity, + brokerEvidence: posixBrokerEvidence(expectedEndpointIdentity), + launchExclusion: testLaunchExclusion(), + sealedAtMs: 123 + }) + ).resolves.toEqual(proof) + } finally { + broker.destroy() + await closeServer(server) + } + }) + + it('restores the public credential when socket relocation fails before commit', async () => { + const root = await temporaryDirectory() + const publicSocketPath = join(root, 'relay.sock') + const publicCredentialFile = join(root, 'relay.credential') + const privateStateDirectory = join(root, 'authority') + const privateSocketPath = join(privateStateDirectory, 'worker.sock') + const privateCredentialFile = join(privateStateDirectory, 'worker.credential') + await writeFile(publicCredentialFile, 'B'.repeat(43), { mode: 0o600 }) + const server = createServer() + await listen(server, publicSocketPath) + const expectedEndpointIdentity = await inspectUnixEndpoint(publicSocketPath) + let rejectSocketMove = true + try { + await expect( + relocatePosixLegacyRelaySocket( + { + publicSocketPath, + privateSocketPath, + publicCredentialFile, + privateCredentialFile, + privateStateDirectory, + expectedEndpointIdentity, + brokerEvidence: posixBrokerEvidence(expectedEndpointIdentity), + launchExclusion: testLaunchExclusion() + }, + { + renamePath: async (from, to) => { + if (from === publicSocketPath && rejectSocketMove) { + rejectSocketMove = false + throw new Error('injected socket rename failure') + } + await rename(from, to) + } + } + ) + ).rejects.toThrow('injected socket rename failure') + expect(await readFile(publicCredentialFile, 'utf8')).toBe('B'.repeat(43)) + await expect(access(privateCredentialFile)).rejects.toThrow() + await expect(access(publicSocketPath)).resolves.toBeUndefined() + } finally { + await closeServer(server) + } + }) +}) + +describe('Windows legacy relay pipe cutover', () => { + const pipeName = '\\\\.\\pipe\\orca-relay-0123456789abcdef0123' + const relayProcess = Object.freeze({ pid: 77, birthMarker: WINDOWS_BIRTH_MARKER }) + const paths = Object.freeze({ + activePipeMarkerPath: 'C:\\orca\\relay\\.windows-active-pipe-relay.sock', + privateActivePipeMarkerPath: 'C:\\orca\\authority\\legacy\\active-pipe', + publicCredentialFile: 'C:\\orca\\relay\\relay.credential', + privateCredentialFile: 'C:\\orca\\authority\\legacy\\worker.credential', + privateStateDirectory: 'C:\\orca\\authority\\legacy' + }) + const brokerEvidence: LegacyRelayBrokerConnectionEvidence = Object.freeze({ + brokerConnectionIdentity: 'broker:owner:1', + brokerClientCount: 1, + acceptedConnectionCount: 5, + quiescenceSamples: 2, + endpointIdentity: Object.freeze({ + kind: 'windows-named-pipe', + pipeName, + processCreationMarker: relayProcess.birthMarker + }), + graceConfiguration: Object.freeze({ + capabilityVersion: 1, + configuredGraceMs: 0, + acknowledged: true + }), + connectionProof: Object.freeze({ + method: 'windows-pipe-process', + listenerIdentity: '77:windows:638900000000000000', + brokerConnectionIdentity: 'broker:owner:1', + acceptedServerConnections: 1 + }) + }) + + it('binds the exact named pipe to the process creation marker', async () => { + await expect( + inspectWindowsLegacyRelayPipe(pipeName, relayProcess, async () => relayProcess.birthMarker) + ).resolves.toMatchObject({ + method: 'windows-pipe-process', + acceptedServerConnections: 1, + endpointIdentity: { + pipeName, + processCreationMarker: relayProcess.birthMarker + } + }) + await expect( + inspectWindowsLegacyRelayPipe(pipeName, relayProcess, async () => 'reused-pid') + ).rejects.toThrow('process identity is stale') + }) + + it('seals the credential and fences active-pipe marker reuse idempotently', async () => { + const harness = windowsFileHarness( + new Map([ + [paths.activePipeMarkerPath, pipeName], + [paths.publicCredentialFile, 'C'.repeat(43)] + ]) + ) + const request = { + ...paths, + pipeName, + relayProcess, + brokerEvidence, + launchExclusion: testLaunchExclusion(), + sealedAtMs: 456 + } + const proof = await sealWindowsLegacyRelayPipe(request, harness.dependencies) + + expect(harness.files.get(paths.privateActivePipeMarkerPath)).toBe(pipeName) + expect(harness.files.get(paths.privateCredentialFile)).toBe('C'.repeat(43)) + expect(harness.fences.has(paths.activePipeMarkerPath)).toBe(true) + expect(harness.fences.has(paths.publicCredentialFile)).toBe(true) + expect(harness.writeLikeLegacyClient(paths.activePipeMarkerPath, pipeName)).toBe(false) + expect(harness.writeLikeLegacyClient(paths.publicCredentialFile, 'new-credential')).toBe(false) + expect(harness.sealed).toEqual([paths.privateCredentialFile, paths.privateActivePipeMarkerPath]) + expect(proof).toMatchObject({ + kind: 'windows-sealed', + originalPipeName: pipeName, + activePipeMarkerIgnored: true, + sealedAtMs: 456 + }) + await expect(sealWindowsLegacyRelayPipe(request, harness.dependencies)).resolves.toEqual(proof) + }) + + it('rejects a reused marker without moving the credential', async () => { + const harness = windowsFileHarness( + new Map([ + [paths.activePipeMarkerPath, '\\\\.\\pipe\\orca-relay-ffffffffffffffffffff'], + [paths.publicCredentialFile, 'D'.repeat(43)] + ]) + ) + await expect( + sealWindowsLegacyRelayPipe( + { + ...paths, + pipeName, + relayProcess, + brokerEvidence, + launchExclusion: testLaunchExclusion() + }, + harness.dependencies + ) + ).rejects.toThrow('active-pipe marker was reused') + expect(harness.files.get(paths.publicCredentialFile)).toBe('D'.repeat(43)) + expect(harness.files.has(paths.privateCredentialFile)).toBe(false) + }) + + it('restores the credential when marker fencing fails before commit', async () => { + const harness = windowsFileHarness( + new Map([ + [paths.activePipeMarkerPath, pipeName], + [paths.publicCredentialFile, 'E'.repeat(43)] + ]), + { failMoveFrom: paths.activePipeMarkerPath } + ) + await expect( + sealWindowsLegacyRelayPipe( + { + ...paths, + pipeName, + relayProcess, + brokerEvidence, + launchExclusion: testLaunchExclusion() + }, + harness.dependencies + ) + ).rejects.toThrow('marker fence failed') + expect(harness.files.get(paths.publicCredentialFile)).toBe('E'.repeat(43)) + expect(harness.files.has(paths.privateCredentialFile)).toBe(false) + }) +}) + +async function temporaryDirectory(): Promise { + const path = await mkdtemp(join(tmpdir(), 'orca-legacy-cutover-')) + cleanupPaths.push(path) + return path +} + +async function listen(server: ReturnType, socketPath: string): Promise { + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(socketPath, () => { + server.off('error', reject) + resolve() + }) + }) +} + +async function connect(socketPath: string): Promise { + return await new Promise((resolve, reject) => { + const socket = createConnection(socketPath) + socket.once('error', reject) + socket.once('connect', () => { + socket.off('error', reject) + resolve(socket) + }) + }) +} + +async function roundTrip(socket: Socket, value: string): Promise { + return await new Promise((resolve, reject) => { + socket.once('error', reject) + socket.once('data', (data) => resolve(data.toString())) + socket.write(value) + }) +} + +async function closeServer(server: ReturnType): Promise { + await new Promise((resolve) => server.close(() => resolve())) +} + +function windowsFileHarness( + initial: Map, + options: Readonly<{ failMoveFrom?: string }> = {} +): { + files: Map + fences: Map + sealed: string[] + writeLikeLegacyClient: (path: string, value: string) => boolean + dependencies: WindowsLegacyRelayCutoverDependencies & { + renamePath: NonNullable + } +} { + const files = new Map(initial) + const fences = new Map() + const sealed: string[] = [] + return { + files, + fences, + sealed, + writeLikeLegacyClient: (path, value) => { + if (fences.has(path)) { + return false + } + files.set(path, value) + return true + }, + dependencies: { + queryProcessCreationMarker: async () => WINDOWS_BIRTH_MARKER, + ensurePrivateDirectory: async () => {}, + readFileSnapshot: async (path) => { + const content = files.get(path) + return content === undefined ? null : { content } + }, + renamePath: async (from, to) => { + if (from === options.failMoveFrom) { + throw new Error('marker fence failed') + } + const content = files.get(from) + if (content === undefined || files.has(to) || fences.has(to)) { + throw new Error('invalid memory rename') + } + files.delete(from) + files.set(to, content) + }, + sealPrivateFile: async (path) => { + sealed.push(path) + }, + inspectPublicFence: async (path, identity) => { + const expected = JSON.stringify(identity) + const actual = fences.get(path) + if (actual !== undefined && actual !== expected) { + throw new Error('memory fence identity changed') + } + return actual === expected + }, + installPublicFence: async (path, identity) => { + const expected = JSON.stringify(identity) + const actual = fences.get(path) + if (files.has(path) || (actual !== undefined && actual !== expected)) { + throw new Error('memory fence target occupied') + } + fences.set(path, expected) + }, + removePublicFence: async (path, identity) => { + if (fences.get(path) !== JSON.stringify(identity)) { + throw new Error('memory fence identity changed') + } + fences.delete(path) + } + } + } +} + +function testLaunchExclusion(): { + runExclusive: (operation: () => Promise) => Promise +} { + let held = false + return { + runExclusive: async (operation) => { + if (held) { + throw new Error('test launch exclusion reentered') + } + held = true + try { + return await operation() + } finally { + held = false + } + } + } +} + +function posixBrokerEvidence( + endpointIdentity: LegacyRelayBrokerConnectionEvidence['endpointIdentity'] +): LegacyRelayBrokerConnectionEvidence { + return Object.freeze({ + brokerConnectionIdentity: 'broker:owner:1', + brokerClientCount: 1, + acceptedConnectionCount: 3, + quiescenceSamples: 2, + endpointIdentity, + graceConfiguration: Object.freeze({ + capabilityVersion: 1, + configuredGraceMs: 0, + acknowledged: true + }), + connectionProof: Object.freeze({ + method: 'darwin-lsof-unix', + listenerIdentity: '99:unix:1:2', + brokerConnectionIdentity: 'broker:owner:1', + acceptedServerConnections: 1 + }) + }) +} diff --git a/src/relay/legacy-relay-gc-path-policy.ts b/src/relay/legacy-relay-gc-path-policy.ts new file mode 100644 index 00000000000..b5c961ee336 --- /dev/null +++ b/src/relay/legacy-relay-gc-path-policy.ts @@ -0,0 +1,264 @@ +import type { BigIntStats } from 'node:fs' +import { lstat, realpath, rename, rm } from 'node:fs/promises' +import path from 'node:path' +import type { TerminalLegacyGcProtection } from '../shared/terminal-legacy-cutover' + +export type LegacyRelayGcFileSystem = Readonly<{ + lstat: typeof lstat + realpath: typeof realpath + rename: typeof rename + rm: typeof rm +}> + +export type LegacyRelayGcPathIdentity = Readonly<{ + device: string + inode: string + changedAtNs: string + bornAtNs: string + modifiedAtNs: string + mode: string + size: string + directory: boolean +}> + +export type LegacyRelayGcCandidate = Readonly<{ + reportedPath: string + removalPath: string + identity: LegacyRelayGcPathIdentity +}> + +export const defaultLegacyRelayGcFileSystem: LegacyRelayGcFileSystem = { + lstat, + realpath, + rename, + rm +} + +export async function canonicalLegacyRelayGcRoots( + roots: readonly string[], + fileSystem: LegacyRelayGcFileSystem = defaultLegacyRelayGcFileSystem +): Promise { + const canonical: string[] = [] + for (const root of roots) { + const normalized = assertSafeAbsolutePath(root, 'allowed root') + const stats = await fileSystem.lstat(normalized) + if (!stats.isDirectory() || stats.isSymbolicLink()) { + throw new Error('legacy relay GC allowed root is not a real directory') + } + const resolved = await fileSystem.realpath(normalized) + assertSafeAbsolutePath(resolved, 'canonical allowed root') + canonical.push(normalizeForComparison(normalized), normalizeForComparison(resolved)) + } + return Object.freeze([...new Set(canonical)].sort()) +} + +export async function eligibleLegacyRelayGcCandidates(input: { + eligible: TerminalLegacyGcProtection + protected: TerminalLegacyGcProtection + allowedRoots: readonly string[] + fileSystem?: LegacyRelayGcFileSystem +}): Promise { + const fileSystem = input.fileSystem ?? defaultLegacyRelayGcFileSystem + const protectedLexical = protectionPaths(input.protected) + const protectedCanonical = await existingCanonicalPaths(protectedLexical, fileSystem) + const raw = [ + ...input.eligible.evidencePaths.map((candidate) => ({ candidate, directory: false })), + ...input.eligible.relayDirectories.map((candidate) => ({ candidate, directory: true })) + ] + const candidates = new Map() + for (const entry of raw) { + if (isWindowsNamedPipe(entry.candidate)) { + continue + } + const lexical = assertLegacyRelayGcCandidatePath(entry.candidate, input.allowedRoots) + if (protectedLexical.some((protectedPath) => pathsOverlap(lexical, protectedPath))) { + continue + } + const inspected = await inspectCandidate( + lexical, + entry.directory, + input.allowedRoots, + protectedCanonical, + fileSystem + ) + if (inspected) { + candidates.set(normalizeForComparison(inspected.removalPath), inspected) + } + } + return Object.freeze( + [...candidates.values()].sort( + (left, right) => right.reportedPath.length - left.reportedPath.length + ) + ) +} + +export function assertLegacyRelayGcCandidatePath( + candidate: string, + allowedRoots: readonly string[] +): string { + const normalized = assertSafeAbsolutePath(candidate, 'candidate') + if (!allowedRoots.some((root) => strictlyContainsPath(root, normalized))) { + throw new Error('legacy relay GC candidate is outside its authority-owned root') + } + return normalized +} + +export async function legacyRelayGcCandidateIsProtected( + candidate: LegacyRelayGcCandidate, + protection: TerminalLegacyGcProtection, + fileSystem: LegacyRelayGcFileSystem = defaultLegacyRelayGcFileSystem +): Promise { + const lexical = protectionPaths(protection) + if ( + lexical.some( + (protectedPath) => + pathsOverlap(candidate.reportedPath, protectedPath) || + pathsOverlap(candidate.removalPath, protectedPath) + ) + ) { + return true + } + const canonical = await existingCanonicalPaths(lexical, fileSystem) + return canonical.some((protectedPath) => pathsOverlap(candidate.removalPath, protectedPath)) +} + +export async function readLegacyRelayGcPathIdentity( + candidatePath: string, + fileSystem: LegacyRelayGcFileSystem = defaultLegacyRelayGcFileSystem +): Promise { + let stats: BigIntStats + try { + stats = await fileSystem.lstat(candidatePath, { bigint: true }) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return null + } + throw error + } + if (stats.isSymbolicLink()) { + return null + } + return Object.freeze({ + device: String(stats.dev), + inode: String(stats.ino), + changedAtNs: String(stats.ctimeNs), + bornAtNs: String(stats.birthtimeNs), + modifiedAtNs: String(stats.mtimeNs), + mode: String(stats.mode), + size: String(stats.size), + directory: stats.isDirectory() + }) +} + +export function sameLegacyRelayGcPathIdentity( + left: LegacyRelayGcPathIdentity | null, + right: LegacyRelayGcPathIdentity | null +): boolean { + return Boolean( + left && + right && + left.device === right.device && + left.inode === right.inode && + left.bornAtNs === right.bornAtNs && + left.modifiedAtNs === right.modifiedAtNs && + left.mode === right.mode && + left.size === right.size && + left.directory === right.directory + ) +} + +async function inspectCandidate( + lexical: string, + directory: boolean, + allowedRoots: readonly string[], + protectedCanonical: readonly string[], + fileSystem: LegacyRelayGcFileSystem +): Promise { + const identity = await readLegacyRelayGcPathIdentity(lexical, fileSystem) + if (!identity) { + return null + } + if (directory && !identity.directory) { + throw new Error('legacy relay GC candidate type is unsafe') + } + const canonical = normalizeForComparison(await fileSystem.realpath(lexical)) + assertLegacyRelayGcCandidatePath(canonical, allowedRoots) + if (protectedCanonical.some((protectedPath) => pathsOverlap(canonical, protectedPath))) { + return null + } + return Object.freeze({ reportedPath: lexical, removalPath: canonical, identity }) +} + +async function existingCanonicalPaths( + paths: readonly string[], + fileSystem: LegacyRelayGcFileSystem +): Promise { + const canonical: string[] = [] + for (const candidate of paths) { + if (isWindowsNamedPipe(candidate)) { + continue + } + try { + canonical.push(normalizeForComparison(await fileSystem.realpath(candidate))) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error + } + } + } + return Object.freeze(canonical) +} + +function protectionPaths(protection: TerminalLegacyGcProtection): string[] { + return [...protection.relayDirectories, ...protection.evidencePaths] + .filter((candidate) => !isWindowsNamedPipe(candidate)) + .map((candidate) => assertSafeAbsolutePath(candidate, 'protected path')) +} + +function assertSafeAbsolutePath(candidate: string, role: string): string { + const api = pathApi(candidate) + if (!candidate || candidate.includes('\0') || !api.isAbsolute(candidate)) { + throw new Error(`legacy relay GC ${role} is not absolute`) + } + const normalized = api.normalize(candidate) + if (samePath(normalized, api.parse(normalized).root)) { + throw new Error(`legacy relay GC ${role} is too broad`) + } + return normalized +} + +function strictlyContainsPath(parent: string, child: string): boolean { + if (pathFlavor(parent) !== pathFlavor(child)) { + return false + } + const api = pathApi(parent) + const relative = api.relative(parent, child) + return relative !== '' && !relative.startsWith('..') && !api.isAbsolute(relative) +} + +function pathsOverlap(left: string, right: string): boolean { + return ( + samePath(left, right) || strictlyContainsPath(left, right) || strictlyContainsPath(right, left) + ) +} + +function samePath(left: string, right: string): boolean { + return normalizeForComparison(left) === normalizeForComparison(right) +} + +function normalizeForComparison(candidate: string): string { + const normalized = pathApi(candidate).normalize(candidate) + return pathFlavor(candidate) === 'win32' ? normalized.toLowerCase() : normalized +} + +function pathApi(candidate: string): typeof path.posix | typeof path.win32 { + return pathFlavor(candidate) === 'win32' ? path.win32 : path.posix +} + +function pathFlavor(candidate: string): 'posix' | 'win32' { + return /^[A-Za-z]:[\\/]/.test(candidate) || candidate.startsWith('\\\\') ? 'win32' : 'posix' +} + +function isWindowsNamedPipe(candidate: string): boolean { + return /^\\\\[.?]\\pipe\\/i.test(candidate) +} diff --git a/src/relay/legacy-relay-gc-quarantine-store.ts b/src/relay/legacy-relay-gc-quarantine-store.ts new file mode 100644 index 00000000000..1142c44f7a5 --- /dev/null +++ b/src/relay/legacy-relay-gc-quarantine-store.ts @@ -0,0 +1,163 @@ +import { randomUUID } from 'node:crypto' +import { open, readFile, rename, rm, type FileHandle } from 'node:fs/promises' +import path from 'node:path' +import type { LegacyRelayGcPathIdentity } from './legacy-relay-gc-path-policy' +import type { LegacyRelayGcQuarantineHold } from './legacy-relay-gc-quarantine' + +const STORE_VERSION = 1 +const MAX_STORE_BYTES = 256 * 1024 +const MAX_HOLDS = 256 + +type QuarantineStoreSnapshot = Readonly<{ + version: typeof STORE_VERSION + holds: readonly LegacyRelayGcQuarantineHold[] +}> + +export class LegacyRelayGcQuarantineStore { + private readonly holdsByPath = new Map() + + private constructor( + private readonly file: string, + holds: readonly LegacyRelayGcQuarantineHold[] + ) { + for (const hold of holds) { + this.holdsByPath.set(hold.quarantinePath, hold) + } + } + + static async open(file: string): Promise { + const snapshot = await readSnapshot(file) + return new LegacyRelayGcQuarantineStore(file, snapshot?.holds ?? []) + } + + values(): readonly LegacyRelayGcQuarantineHold[] { + return Object.freeze([...this.holdsByPath.values()]) + } + + async add(hold: LegacyRelayGcQuarantineHold): Promise { + assertHold(hold) + if (!this.holdsByPath.has(hold.quarantinePath) && this.holdsByPath.size >= MAX_HOLDS) { + throw new Error('legacy relay GC quarantine hold capacity exceeded') + } + this.holdsByPath.set(hold.quarantinePath, structuredClone(hold)) + await this.persist() + } + + async remove(quarantinePath: string): Promise { + if (!this.holdsByPath.delete(quarantinePath)) { + return + } + await this.persist() + } + + private async persist(): Promise { + const snapshot: QuarantineStoreSnapshot = Object.freeze({ + version: STORE_VERSION, + holds: this.values() + }) + const encoded = `${JSON.stringify(snapshot)}\n` + if (Buffer.byteLength(encoded) > MAX_STORE_BYTES) { + throw new Error('legacy relay GC quarantine store exceeds its bound') + } + const temporary = `${this.file}.${process.pid}.${randomUUID()}.tmp` + try { + const handle = await open(temporary, 'wx', 0o600) + try { + await handle.writeFile(encoded, 'utf8') + await handle.sync() + } finally { + await handle.close() + } + await rename(temporary, this.file) + await syncDirectory(path.dirname(this.file)) + } finally { + await rm(temporary, { force: true }) + } + } +} + +async function readSnapshot(file: string): Promise { + let encoded: Buffer + try { + encoded = await readFile(file) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return null + } + throw error + } + if (encoded.byteLength < 1 || encoded.byteLength > MAX_STORE_BYTES) { + throw new Error('legacy relay GC quarantine store is not bounded') + } + const value = JSON.parse(encoded.toString('utf8')) as Partial + if ( + value.version !== STORE_VERSION || + !Array.isArray(value.holds) || + value.holds.length > MAX_HOLDS + ) { + throw new Error('legacy relay GC quarantine store is invalid') + } + const holds = value.holds.map((hold) => { + assertHold(hold) + return Object.freeze(structuredClone(hold)) + }) + if (new Set(holds.map((hold) => hold.quarantinePath)).size !== holds.length) { + throw new Error('legacy relay GC quarantine store contains duplicate holds') + } + return Object.freeze({ version: STORE_VERSION, holds: Object.freeze(holds) }) +} + +function assertHold(value: unknown): asserts value is LegacyRelayGcQuarantineHold { + if (typeof value !== 'object' || value === null) { + throw new Error('legacy relay GC quarantine hold is invalid') + } + const hold = value as Partial + if ( + !boundedPath(hold.originalPath) || + !boundedPath(hold.canonicalPath) || + !boundedPath(hold.quarantinePath) || + !validIdentity(hold.identity) + ) { + throw new Error('legacy relay GC quarantine hold is invalid') + } +} + +function validIdentity(value: unknown): value is LegacyRelayGcPathIdentity { + if (typeof value !== 'object' || value === null) { + return false + } + const identity = value as Partial + return ( + typeof identity.directory === 'boolean' && + ['device', 'inode', 'changedAtNs', 'bornAtNs', 'modifiedAtNs', 'mode', 'size'].every((field) => + /^\d+$/.test(String(identity[field as keyof LegacyRelayGcPathIdentity] ?? '')) + ) + ) +} + +function boundedPath(value: unknown): value is string { + return ( + typeof value === 'string' && + value.length > 0 && + value.length <= 32 * 1024 && + !value.includes('\0') + ) +} + +async function syncDirectory(directory: string): Promise { + let handle: FileHandle | null = null + try { + handle = await open(directory, 'r') + await handle.sync() + } catch (error) { + if (!['EACCES', 'EBADF', 'EINVAL', 'EISDIR', 'ENOTSUP', 'EPERM'].includes(errorCode(error))) { + throw error + } + } finally { + await handle?.close() + } +} + +function errorCode(error: unknown): string { + return typeof error === 'object' && error !== null && 'code' in error ? String(error.code) : '' +} diff --git a/src/relay/legacy-relay-gc-quarantine.ts b/src/relay/legacy-relay-gc-quarantine.ts new file mode 100644 index 00000000000..9f010ff414a --- /dev/null +++ b/src/relay/legacy-relay-gc-quarantine.ts @@ -0,0 +1,128 @@ +import { randomUUID } from 'node:crypto' +import path from 'node:path' +import type { TerminalLegacyGcProtection } from '../shared/terminal-legacy-cutover' +import { + assertLegacyRelayGcCandidatePath, + defaultLegacyRelayGcFileSystem, + legacyRelayGcCandidateIsProtected, + readLegacyRelayGcPathIdentity, + sameLegacyRelayGcPathIdentity, + type LegacyRelayGcCandidate, + type LegacyRelayGcFileSystem, + type LegacyRelayGcPathIdentity +} from './legacy-relay-gc-path-policy' + +export type LegacyRelayGcQuarantineHold = Readonly<{ + originalPath: string + canonicalPath: string + quarantinePath: string + identity: LegacyRelayGcPathIdentity +}> + +export type LegacyRelayGcRemovalResult = + | Readonly<{ status: 'removed' | 'missing' | 'protected' }> + | Readonly<{ + status: 'preserved' + hold: LegacyRelayGcQuarantineHold + reason: 'identity-changed' | 'protection-changed' | 'original-replaced' + }> + +export function createLegacyRelayGcQuarantineHold( + candidate: LegacyRelayGcCandidate, + allowedRoots: readonly string[] +): LegacyRelayGcQuarantineHold { + const api = pathApi(candidate.reportedPath) + const quarantinePath = api.join( + api.dirname(candidate.reportedPath), + `.${api.basename(candidate.reportedPath)}.terminal-authority-gc-${randomUUID()}` + ) + assertLegacyRelayGcCandidatePath(quarantinePath, allowedRoots) + return Object.freeze({ + originalPath: candidate.reportedPath, + canonicalPath: candidate.removalPath, + quarantinePath, + identity: candidate.identity + }) +} + +export async function quarantineAndRemoveLegacyRelayGcCandidate(input: { + candidate: LegacyRelayGcCandidate + hold: LegacyRelayGcQuarantineHold + allowedRoots: readonly string[] + protection: () => TerminalLegacyGcProtection + fileSystem?: LegacyRelayGcFileSystem +}): Promise { + const fileSystem = input.fileSystem ?? defaultLegacyRelayGcFileSystem + if (await legacyRelayGcCandidateIsProtected(input.candidate, input.protection(), fileSystem)) { + return Object.freeze({ status: 'protected' }) + } + try { + await fileSystem.rename(input.hold.originalPath, input.hold.quarantinePath) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return Object.freeze({ status: 'missing' }) + } + throw error + } + return await removeExistingQuarantine(input) +} + +export async function resumeLegacyRelayGcQuarantine(input: { + candidate: LegacyRelayGcCandidate + hold: LegacyRelayGcQuarantineHold + allowedRoots: readonly string[] + protection: () => TerminalLegacyGcProtection + fileSystem?: LegacyRelayGcFileSystem +}): Promise { + const fileSystem = input.fileSystem ?? defaultLegacyRelayGcFileSystem + const quarantineIdentity = await readLegacyRelayGcPathIdentity( + input.hold.quarantinePath, + fileSystem + ) + if (!quarantineIdentity) { + return Object.freeze({ status: 'missing' }) + } + if (await readLegacyRelayGcPathIdentity(input.hold.originalPath, fileSystem)) { + return preserved(input.hold, 'original-replaced') + } + return await removeExistingQuarantine({ ...input, fileSystem }) +} + +async function removeExistingQuarantine(input: { + candidate: LegacyRelayGcCandidate + hold: LegacyRelayGcQuarantineHold + allowedRoots: readonly string[] + protection: () => TerminalLegacyGcProtection + fileSystem?: LegacyRelayGcFileSystem +}): Promise { + const fileSystem = input.fileSystem ?? defaultLegacyRelayGcFileSystem + const quarantinedIdentity = await readLegacyRelayGcPathIdentity( + input.hold.quarantinePath, + fileSystem + ) + if (!sameLegacyRelayGcPathIdentity(quarantinedIdentity, input.hold.identity)) { + return preserved(input.hold, 'identity-changed') + } + const canonicalQuarantine = await fileSystem.realpath(input.hold.quarantinePath) + assertLegacyRelayGcCandidatePath(canonicalQuarantine, input.allowedRoots) + if (await legacyRelayGcCandidateIsProtected(input.candidate, input.protection(), fileSystem)) { + return preserved(input.hold, 'protection-changed') + } + const finalIdentity = await readLegacyRelayGcPathIdentity(input.hold.quarantinePath, fileSystem) + if (!sameLegacyRelayGcPathIdentity(finalIdentity, input.hold.identity)) { + return preserved(input.hold, 'identity-changed') + } + await fileSystem.rm(input.hold.quarantinePath, { recursive: true, force: false }) + return Object.freeze({ status: 'removed' }) +} + +function preserved( + hold: LegacyRelayGcQuarantineHold, + reason: Extract['reason'] +): LegacyRelayGcRemovalResult { + return Object.freeze({ status: 'preserved', hold, reason }) +} + +function pathApi(candidate: string): typeof path.posix | typeof path.win32 { + return /^[A-Za-z]:[\\/]/.test(candidate) || candidate.startsWith('\\\\') ? path.win32 : path.posix +} diff --git a/src/relay/legacy-relay-posix-cutover-files.ts b/src/relay/legacy-relay-posix-cutover-files.ts new file mode 100644 index 00000000000..ed522bb26b6 --- /dev/null +++ b/src/relay/legacy-relay-posix-cutover-files.ts @@ -0,0 +1,122 @@ +import { chmod, lstat, mkdir, readFile } from 'node:fs/promises' +import { relative, resolve } from 'node:path' +import { + inspectPosixLegacyRelayPublicFence, + type LegacyRelayPublicEndpointFenceIdentity +} from './legacy-relay-public-endpoint-fence' + +export type PosixLegacyRelayCutoverPaths = Readonly<{ + publicSocketPath: string + privateSocketPath: string + publicCredentialFile: string + privateCredentialFile: string + privateStateDirectory: string +}> + +export function validatePosixLegacyRelayCutoverPaths( + input: PosixLegacyRelayCutoverPaths +): PosixLegacyRelayCutoverPaths { + const paths = { + publicSocketPath: resolveAbsolute(input.publicSocketPath), + privateSocketPath: resolveAbsolute(input.privateSocketPath), + publicCredentialFile: resolveAbsolute(input.publicCredentialFile), + privateCredentialFile: resolveAbsolute(input.privateCredentialFile), + privateStateDirectory: resolveAbsolute(input.privateStateDirectory) + } + if (new Set(Object.values(paths)).size !== Object.keys(paths).length) { + throw new Error('legacy relay cutover paths must be distinct') + } + assertPrivateChild(paths.privateStateDirectory, paths.privateSocketPath) + assertPrivateChild(paths.privateStateDirectory, paths.privateCredentialFile) + return Object.freeze(paths) +} + +export async function sealPosixLegacyRelayPrivateDirectory(path: string): Promise { + await mkdir(path, { recursive: true, mode: 0o700 }) + const stats = await lstat(path, { bigint: true }) + if (!stats.isDirectory() || stats.isSymbolicLink()) { + throw new Error('legacy relay authority state is not a private directory') + } + if (typeof process.getuid === 'function' && stats.uid !== BigInt(process.getuid())) { + throw new Error('legacy relay authority state has a foreign owner') + } + await chmod(path, 0o700) + return stats.dev.toString() +} + +export async function inspectPosixLegacyRelayCredentialPair( + paths: PosixLegacyRelayCutoverPaths, + publicFence: LegacyRelayPublicEndpointFenceIdentity, + directoryDevice: string +): Promise> { + const fenced = await inspectPosixLegacyRelayPublicFence(paths.publicCredentialFile, publicFence) + const [publicCredential, privateCredential] = await Promise.all([ + fenced ? null : inspectCredential(paths.publicCredentialFile), + inspectCredential(paths.privateCredentialFile) + ]) + if (fenced && !privateCredential) { + throw new Error('legacy relay public credential fence has no private credential') + } + if (Boolean(publicCredential) === Boolean(privateCredential)) { + throw new Error('legacy relay credential sealing state is ambiguous') + } + const credential = publicCredential ?? privateCredential! + if (credential.device !== directoryDevice) { + throw new Error('legacy relay credential cannot be atomically sealed across filesystems') + } + return Object.freeze({ location: publicCredential ? 'public' : 'private' }) +} + +export async function sealPosixLegacyRelayCredential(path: string): Promise { + await chmod(path, 0o600) +} + +export async function assertPosixLegacyRelayPathMissing(path: string): Promise { + try { + await lstat(path) + } catch (error) { + if (hasCode(error, 'ENOENT')) { + return + } + throw error + } + throw new Error(`legacy relay private target already exists: ${path}`) +} + +async function inspectCredential(path: string): Promise | null> { + let stats + try { + stats = await lstat(path, { bigint: true }) + } catch (error) { + if (hasCode(error, 'ENOENT')) { + return null + } + throw error + } + if (!stats.isFile() || stats.isSymbolicLink()) { + throw new Error('legacy relay credential is not a regular file') + } + const credential = (await readFile(path, 'utf8')).trim() + if (!/^[A-Za-z0-9_-]{32,256}$/.test(credential)) { + throw new Error('legacy relay credential is invalid') + } + return Object.freeze({ device: stats.dev.toString() }) +} + +function resolveAbsolute(value: string): string { + if (!value.startsWith('/') || value.includes('\0')) { + throw new Error('legacy relay POSIX cutover path must be absolute') + } + return resolve(value) +} + +function assertPrivateChild(directory: string, child: string): void { + const pathFromDirectory = relative(directory, child) + if (!pathFromDirectory || pathFromDirectory.startsWith('..') || resolve(child) === directory) { + throw new Error('legacy relay private path escapes authority state') + } +} + +function hasCode(error: unknown, code: string): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === code +} diff --git a/src/relay/legacy-relay-posix-cutover.ts b/src/relay/legacy-relay-posix-cutover.ts new file mode 100644 index 00000000000..ee3467fbc35 --- /dev/null +++ b/src/relay/legacy-relay-posix-cutover.ts @@ -0,0 +1,265 @@ +import { rename } from 'node:fs/promises' +import type { + TerminalLegacyCutoverProof, + TerminalLegacyEndpointIdentity +} from '../shared/terminal-legacy-cutover' +import type { LegacyRelayBrokerConnectionEvidence } from './legacy-relay-broker-connection-proof' +import { + assertPosixLegacyRelayPathMissing, + inspectPosixLegacyRelayCredentialPair, + sealPosixLegacyRelayCredential, + sealPosixLegacyRelayPrivateDirectory, + validatePosixLegacyRelayCutoverPaths, + type PosixLegacyRelayCutoverPaths +} from './legacy-relay-posix-cutover-files' +import { + inspectPosixLegacyRelayPublicFence, + installPosixLegacyRelayPublicFence, + removePosixLegacyRelayPublicFence, + runWithLegacyRelayLaunchExclusion, + type LegacyRelayLaunchExclusion, + type LegacyRelayPublicEndpointFenceIdentity +} from './legacy-relay-public-endpoint-fence' +import { inspectUnixEndpoint } from './legacy-relay-unix-socket-inspection' + +type UnixEndpointIdentity = Extract +type PosixCutoverProof = Extract + +export type PosixLegacyRelayCutoverRequest = Readonly<{ + publicSocketPath: string + privateSocketPath: string + publicCredentialFile: string + privateCredentialFile: string + privateStateDirectory: string + expectedEndpointIdentity: UnixEndpointIdentity + brokerEvidence: LegacyRelayBrokerConnectionEvidence + launchExclusion: LegacyRelayLaunchExclusion + sealedAtMs?: number +}> + +type PosixLegacyRelayCutoverDependencies = Readonly<{ + now?: () => number + renamePath?: (from: string, to: string) => Promise + inspectEndpoint?: (path: string) => Promise +}> + +export async function relocatePosixLegacyRelaySocket( + request: PosixLegacyRelayCutoverRequest, + dependencies: PosixLegacyRelayCutoverDependencies = {} +): Promise { + return await runWithLegacyRelayLaunchExclusion(request.launchExclusion, async () => + relocatePosixLegacyRelaySocketExcluded(request, dependencies) + ) +} + +async function relocatePosixLegacyRelaySocketExcluded( + request: PosixLegacyRelayCutoverRequest, + dependencies: PosixLegacyRelayCutoverDependencies +): Promise { + const paths = validatePosixLegacyRelayCutoverPaths(request) + const fences = publicFenceIdentities(paths, request.expectedEndpointIdentity) + assertBrokerEndpoint(request.brokerEvidence.endpointIdentity, request.expectedEndpointIdentity) + const directoryDevice = await sealPosixLegacyRelayPrivateDirectory(paths.privateStateDirectory) + const endpoint = await inspectEndpointPair(paths, fences.socket, dependencies.inspectEndpoint) + assertExpectedEndpoint(endpoint, request.expectedEndpointIdentity) + if (endpoint.identity.device !== directoryDevice) { + throw new Error('legacy relay socket cannot be atomically relocated across filesystems') + } + const credential = await inspectPosixLegacyRelayCredentialPair( + paths, + fences.credential, + directoryDevice + ) + let movedCredential = false + let sealedEndpointIdentity = endpoint.identity + let movedEndpoint = false + let socketFenced = false + let credentialFenced = false + try { + if (credential.location === 'public') { + await assertPosixLegacyRelayPathMissing(paths.privateCredentialFile) + await (dependencies.renamePath ?? rename)( + paths.publicCredentialFile, + paths.privateCredentialFile + ) + movedCredential = true + } + await sealPosixLegacyRelayCredential(paths.privateCredentialFile) + if (endpoint.location === 'public') { + await assertPosixLegacyRelayPathMissing(paths.privateSocketPath) + await (dependencies.renamePath ?? rename)(paths.publicSocketPath, paths.privateSocketPath) + movedEndpoint = true + const relocated = await inspectEndpoint(paths.privateSocketPath, dependencies.inspectEndpoint) + assertRelocatedEndpoint(relocated, request.expectedEndpointIdentity) + sealedEndpointIdentity = relocated! + } + await installPosixLegacyRelayPublicFence(paths.publicSocketPath, fences.socket) + socketFenced = true + await installPosixLegacyRelayPublicFence(paths.publicCredentialFile, fences.credential) + credentialFenced = true + } catch (error) { + await rollbackPosixCutover({ + paths, + fences, + movedCredential, + movedEndpoint, + socketFenced, + credentialFenced, + renamePath: dependencies.renamePath + }).catch((rollbackError) => { + throw new AggregateError([error, rollbackError], 'legacy relay cutover rollback failed') + }) + throw error + } + return Object.freeze({ + kind: 'posix-relocated', + publicSocketPath: paths.publicSocketPath, + privateSocketPath: paths.privateSocketPath, + publicCredentialFile: paths.publicCredentialFile, + privateCredentialFile: paths.privateCredentialFile, + endpointIdentity: sealedEndpointIdentity, + brokerClientCount: 1, + acceptedConnectionCount: request.brokerEvidence.acceptedConnectionCount, + quiescenceSamples: request.brokerEvidence.quiescenceSamples, + connectionProof: request.brokerEvidence.connectionProof, + graceConfiguration: request.brokerEvidence.graceConfiguration, + sealedAtMs: request.sealedAtMs ?? dependencies.now?.() ?? Date.now() + }) +} + +async function inspectEndpointPair( + paths: PosixLegacyRelayCutoverPaths, + publicFence: LegacyRelayPublicEndpointFenceIdentity, + inspect?: (path: string) => Promise +): Promise> { + const fenced = await inspectPosixLegacyRelayPublicFence(paths.publicSocketPath, publicFence) + const [publicEndpoint, privateEndpoint] = await Promise.all([ + fenced ? null : inspectEndpoint(paths.publicSocketPath, inspect), + inspectEndpoint(paths.privateSocketPath, inspect) + ]) + if (fenced) { + if (!privateEndpoint) { + throw new Error('legacy relay public socket fence has no private endpoint') + } + return Object.freeze({ location: 'private' as const, identity: privateEndpoint }) + } + if (Boolean(publicEndpoint) === Boolean(privateEndpoint)) { + throw new Error('legacy relay socket relocation state is ambiguous') + } + return publicEndpoint + ? Object.freeze({ location: 'public' as const, identity: publicEndpoint }) + : Object.freeze({ location: 'private' as const, identity: privateEndpoint! }) +} + +async function inspectEndpoint( + path: string, + inspect?: (path: string) => Promise +): Promise { + if (inspect) { + return await inspect(path) + } + try { + return await inspectUnixEndpoint(path) + } catch (error) { + if (hasCode(error, 'ENOENT')) { + return null + } + throw error + } +} + +function assertExpectedEndpoint( + endpoint: Readonly<{ location: 'public' | 'private'; identity: UnixEndpointIdentity }>, + expected: UnixEndpointIdentity +): void { + assertRelocatedEndpoint(endpoint.identity, expected) + if (endpoint.location === 'public' && endpoint.identity.changedAtNs !== expected.changedAtNs) { + throw new Error('legacy relay Unix socket identity changed before cutover') + } +} + +function assertRelocatedEndpoint( + actual: UnixEndpointIdentity | null, + expected: UnixEndpointIdentity +): void { + if ( + !actual || + actual.device !== expected.device || + actual.inode !== expected.inode || + BigInt(actual.changedAtNs) < BigInt(expected.changedAtNs) + ) { + throw new Error('legacy relay Unix socket identity changed during cutover') + } +} + +async function rollbackPosixCutover( + input: Readonly<{ + paths: PosixLegacyRelayCutoverPaths + fences: Readonly<{ + socket: LegacyRelayPublicEndpointFenceIdentity + credential: LegacyRelayPublicEndpointFenceIdentity + }> + movedCredential: boolean + movedEndpoint: boolean + socketFenced: boolean + credentialFenced: boolean + renamePath?: (from: string, to: string) => Promise + }> +): Promise { + if (input.socketFenced) { + await removePosixLegacyRelayPublicFence(input.paths.publicSocketPath, input.fences.socket) + } + if (input.credentialFenced) { + await removePosixLegacyRelayPublicFence( + input.paths.publicCredentialFile, + input.fences.credential + ) + } + const move = input.renamePath ?? rename + if (input.movedEndpoint) { + await move(input.paths.privateSocketPath, input.paths.publicSocketPath) + } + if (input.movedCredential) { + await move(input.paths.privateCredentialFile, input.paths.publicCredentialFile) + } +} + +function publicFenceIdentities( + paths: PosixLegacyRelayCutoverPaths, + endpoint: UnixEndpointIdentity +): Readonly<{ + socket: LegacyRelayPublicEndpointFenceIdentity + credential: LegacyRelayPublicEndpointFenceIdentity +}> { + const endpointIdentity = `${endpoint.device}:${endpoint.inode}:${endpoint.changedAtNs}` + return Object.freeze({ + socket: Object.freeze({ + role: 'socket', + endpointIdentity, + privatePath: paths.privateSocketPath + }), + credential: Object.freeze({ + role: 'credential', + endpointIdentity, + privatePath: paths.privateCredentialFile + }) + }) +} + +function assertBrokerEndpoint( + actual: TerminalLegacyEndpointIdentity, + expected: UnixEndpointIdentity +): void { + if ( + actual.kind !== 'unix-socket' || + actual.device !== expected.device || + actual.inode !== expected.inode || + actual.changedAtNs !== expected.changedAtNs + ) { + throw new Error('legacy relay broker proof targets a different Unix socket') + } +} + +function hasCode(error: unknown, code: string): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === code +} diff --git a/src/relay/legacy-relay-post-migration-gc.test.ts b/src/relay/legacy-relay-post-migration-gc.test.ts new file mode 100644 index 00000000000..9c0866b0bd4 --- /dev/null +++ b/src/relay/legacy-relay-post-migration-gc.test.ts @@ -0,0 +1,298 @@ +import { access, mkdtemp, mkdir, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import type { MethodHandler, RequestContext } from './dispatcher' +import type { LegacyPhysicalWorkerControlHost } from './legacy-physical-worker-control-surface' +import { registerLegacyPhysicalWorkerControlSurface } from './legacy-physical-worker-control-surface' +import { + LEGACY_PHYSICAL_WORKER_GC_METHOD, + LEGACY_PHYSICAL_WORKER_MIGRATION_BARRIER_METHOD +} from './legacy-physical-worker-control-protocol' +import { + assertLegacyRelayGcCandidatePath, + defaultLegacyRelayGcFileSystem, + type LegacyRelayGcFileSystem +} from './legacy-relay-gc-path-policy' +import { LegacyRelayPostMigrationGc } from './legacy-relay-post-migration-gc' + +const temporaryDirectories: string[] = [] + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })) + ) +}) + +describe('legacy relay post-migration GC', () => { + it('derives candidates from catalog eligibility after a durable restart-safe barrier', async () => { + const root = await temporaryDirectory() + const state = path.join(root, 'authority-state') + const firstCandidate = await legacyRelayDirectory(root, 'legacy-a') + const secondCandidate = await legacyRelayDirectory(root, 'legacy-b') + let eligible = protection([firstCandidate]) + const input = { + directory: state, + catalogRevision: () => 9, + protection: () => protection(), + eligible: () => eligible, + allowedRoots: [root] + } + const first = await LegacyRelayPostMigrationGc.open(input) + + await expect(first.collect({ barrierId: 'barrier-1' })).rejects.toThrow( + 'requires the durable migration barrier' + ) + await first.commitBarrier({ barrierId: 'barrier-1', expectedCatalogRevision: 9 }) + await expect(first.collect({ barrierId: 'barrier-1' })).resolves.toMatchObject({ + removed: [firstCandidate] + }) + + eligible = protection([secondCandidate]) + const restarted = await LegacyRelayPostMigrationGc.open(input) + await expect(restarted.collect({ barrierId: 'barrier-1' })).resolves.toMatchObject({ + removed: [secondCandidate] + }) + }) + + it('keeps a live old relay isolated when GC runs after migrationBarrier', async () => { + const root = await temporaryDirectory() + const state = path.join(root, 'authority-state') + const liveOldRelay = await legacyRelayDirectory(root, 'relay-0.1.0+111111111111') + const staleRelay = await legacyRelayDirectory(root, 'relay-0.1.0+222222222222') + const liveProtection = () => protection([liveOldRelay]) + const eligible = () => protection([liveOldRelay, staleRelay]) + const host: LegacyPhysicalWorkerControlHost = { + inspect: async () => { + throw new Error('inspection is not part of this GC oracle') + }, + migrate: async () => { + throw new Error('migration is not part of this GC oracle') + }, + gcProtection: liveProtection, + catalogRevision: () => 7 + } + const gc = await LegacyRelayPostMigrationGc.open({ + directory: state, + catalogRevision: host.catalogRevision, + protection: liveProtection, + eligible, + allowedRoots: [root] + }) + const handlers = new Map() + registerLegacyPhysicalWorkerControlSurface({ + dispatcher: { onRequest: (method, handler) => void handlers.set(method, handler) }, + host, + gc, + hasActiveClient: () => false, + protection: liveProtection + }) + + await handlers.get(LEGACY_PHYSICAL_WORKER_MIGRATION_BARRIER_METHOD)!( + { version: 1, barrierId: 'barrier-live-old-relay', expectedCatalogRevision: 7 }, + authenticatedControlContext + ) + const result = await handlers.get(LEGACY_PHYSICAL_WORKER_GC_METHOD)!( + { version: 1, barrierId: 'barrier-live-old-relay' }, + authenticatedControlContext + ) + + expect(result).toMatchObject({ + removed: [staleRelay], + protected: { relayDirectories: expect.arrayContaining([liveOldRelay]) } + }) + await expect(access(path.join(liveOldRelay, 'relay.sock'))).resolves.toBeUndefined() + await expect(access(staleRelay)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('skips exact, ancestor, and descendant overlap with live protection', async () => { + const root = await temporaryDirectory() + const protectedRoot = await legacyRelayDirectory(root, 'protected-relay') + const protectedChild = path.join(protectedRoot, 'relay.sock') + await writeFile(protectedChild, 'live') + const gc = await LegacyRelayPostMigrationGc.open({ + directory: path.join(root, 'authority-state'), + catalogRevision: () => 2, + protection: () => protection([protectedRoot], [protectedChild]), + eligible: () => protection([protectedRoot], [protectedChild]), + allowedRoots: [root] + }) + await gc.commitBarrier({ barrierId: 'barrier-2', expectedCatalogRevision: 2 }) + + await expect(gc.collect({ barrierId: 'barrier-2' })).resolves.toEqual({ + removed: [], + protected: protection([protectedRoot], [protectedChild]) + }) + await expect(access(protectedChild)).resolves.toBeUndefined() + }) + + it('rejects roots, parents, siblings, and cross-platform path escapes', () => { + const posixRoot = '/Users/alice/.orca-remote' + expect(assertLegacyRelayGcCandidatePath(`${posixRoot}/relay-old`, [posixRoot])).toBe( + `${posixRoot}/relay-old` + ) + for (const candidate of [posixRoot, '/Users/alice', '/Users/alice/.orca-sibling', '/']) { + expect(() => assertLegacyRelayGcCandidatePath(candidate, [posixRoot])).toThrow() + } + + const windowsRoot = 'C:\\Users\\Alice\\.orca-remote' + expect(assertLegacyRelayGcCandidatePath(`${windowsRoot}\\relay-old`, [windowsRoot])).toBe( + `${windowsRoot}\\relay-old` + ) + for (const candidate of [windowsRoot, 'C:\\Users\\Alice', 'D:\\relay-old', 'C:\\']) { + expect(() => assertLegacyRelayGcCandidatePath(candidate, [windowsRoot])).toThrow() + } + + const uncRoot = '\\\\server\\share\\.orca-remote' + expect(assertLegacyRelayGcCandidatePath(`${uncRoot}\\relay-old`, [uncRoot])).toBe( + `${uncRoot}\\relay-old` + ) + expect(() => + assertLegacyRelayGcCandidatePath('\\\\server\\other\\relay-old', [uncRoot]) + ).toThrow() + }) + + it('rejects direct and intermediate symlink escapes before deletion', async () => { + const root = await temporaryDirectory() + const outside = await temporaryDirectory() + const outsideCandidate = await legacyRelayDirectory(outside, 'outside-relay') + const directLink = path.join(root, 'direct-link') + const intermediateLink = path.join(root, 'intermediate-link') + await symlink(outsideCandidate, directLink, process.platform === 'win32' ? 'junction' : 'dir') + await symlink(outside, intermediateLink, process.platform === 'win32' ? 'junction' : 'dir') + const eligibleDirectories = [directLink, path.join(intermediateLink, 'outside-relay')] + const gc = await LegacyRelayPostMigrationGc.open({ + directory: path.join(root, 'authority-state'), + catalogRevision: () => 3, + protection: () => protection(), + eligible: () => protection(eligibleDirectories), + allowedRoots: [root] + }) + await gc.commitBarrier({ barrierId: 'barrier-3', expectedCatalogRevision: 3 }) + + await expect(gc.collect({ barrierId: 'barrier-3' })).rejects.toThrow( + /unsafe|outside its authority-owned root/ + ) + await expect(access(path.join(outsideCandidate, 'relay.sock'))).resolves.toBeUndefined() + }) + + it('quarantines and preserves a pathname replacement at the rename boundary', async () => { + const root = await temporaryDirectory() + const state = path.join(root, 'authority-state') + const candidate = await legacyRelayDirectory(root, 'legacy-replaced') + const original = `${candidate}.original` + let replaced = false + const fileSystem: LegacyRelayGcFileSystem = { + ...defaultLegacyRelayGcFileSystem, + rename: async (source, target) => { + if (source === candidate && !replaced) { + replaced = true + await defaultLegacyRelayGcFileSystem.rename(source, original) + await mkdir(source) + await writeFile(path.join(source, 'relay.sock'), 'replacement') + } + await defaultLegacyRelayGcFileSystem.rename(source, target) + } + } + const input = { + directory: state, + catalogRevision: () => 4, + protection: () => protection(), + eligible: () => protection([candidate]), + allowedRoots: [root], + fileSystem + } + const gc = await LegacyRelayPostMigrationGc.open(input) + await gc.commitBarrier({ barrierId: 'barrier-4', expectedCatalogRevision: 4 }) + + const result = await gc.collect({ barrierId: 'barrier-4' }) + expect(result.removed).toEqual([]) + const quarantine = result.protected.relayDirectories.find((entry) => + entry.includes('.terminal-authority-gc-') + ) + expect(quarantine).toBeDefined() + await expect(readFile(path.join(original, 'relay.sock'), 'utf8')).resolves.toBe('old') + await expect(readFile(path.join(quarantine!, 'relay.sock'), 'utf8')).resolves.toBe( + 'replacement' + ) + + const restarted = await LegacyRelayPostMigrationGc.open(input) + await expect(restarted.collect({ barrierId: 'barrier-4' })).resolves.toMatchObject({ + removed: [], + protected: { relayDirectories: expect.arrayContaining([candidate, quarantine!]) } + }) + }) + + it('durably preserves a candidate when live protection changes after quarantine', async () => { + const root = await temporaryDirectory() + const state = path.join(root, 'authority-state') + const candidate = await legacyRelayDirectory(root, 'legacy-protected-late') + let liveProtection = protection() + const fileSystem: LegacyRelayGcFileSystem = { + ...defaultLegacyRelayGcFileSystem, + rename: async (source, target) => { + await defaultLegacyRelayGcFileSystem.rename(source, target) + if (source === candidate) { + liveProtection = protection([candidate]) + } + } + } + const input = { + directory: state, + catalogRevision: () => 5, + protection: () => liveProtection, + eligible: () => protection([candidate]), + allowedRoots: [root], + fileSystem + } + const gc = await LegacyRelayPostMigrationGc.open(input) + await gc.commitBarrier({ barrierId: 'barrier-5', expectedCatalogRevision: 5 }) + + const result = await gc.collect({ barrierId: 'barrier-5' }) + expect(result.removed).toEqual([]) + const quarantine = result.protected.relayDirectories.find((entry) => + entry.includes('.terminal-authority-gc-') + ) + expect(quarantine).toBeDefined() + expect(await readdir(quarantine!)).toContain('relay.sock') + + const restarted = await LegacyRelayPostMigrationGc.open(input) + await expect(restarted.collect({ barrierId: 'barrier-5' })).resolves.toMatchObject({ + removed: [], + protected: { relayDirectories: expect.arrayContaining([candidate, quarantine!]) } + }) + }) +}) + +function protection(relayDirectories: string[] = [], evidencePaths: string[] = []) { + return Object.freeze({ + relayDirectories: Object.freeze([...relayDirectories]), + evidencePaths: Object.freeze([...evidencePaths]) + }) +} + +async function legacyRelayDirectory(root: string, name: string): Promise { + const directory = path.join(root, name) + await mkdir(directory) + await writeFile(path.join(directory, 'relay.sock'), 'old') + return directory +} + +async function temporaryDirectory(): Promise { + const directory = await mkdtemp(path.join(tmpdir(), 'orca-legacy-gc-')) + temporaryDirectories.push(directory) + return directory +} + +const authenticatedControlContext: RequestContext = Object.freeze({ + clientId: 1, + isStale: () => false, + sessionIdentity: Object.freeze({ + principal: 'terminal-authority:authority-1', + authenticated: true, + allowSessionOwner: true, + authenticationKind: 'endpoint-credential' + }) +}) diff --git a/src/relay/legacy-relay-post-migration-gc.ts b/src/relay/legacy-relay-post-migration-gc.ts new file mode 100644 index 00000000000..fd9d01358e1 --- /dev/null +++ b/src/relay/legacy-relay-post-migration-gc.ts @@ -0,0 +1,265 @@ +import { open, mkdir, readFile, rename, rm } from 'node:fs/promises' +import path from 'node:path' +import type { TerminalLegacyGcProtection } from '../shared/terminal-legacy-cutover' +import { + assertLegacyRelayGcCandidatePath, + canonicalLegacyRelayGcRoots, + eligibleLegacyRelayGcCandidates, + type LegacyRelayGcCandidate, + type LegacyRelayGcFileSystem +} from './legacy-relay-gc-path-policy' +import { + createLegacyRelayGcQuarantineHold, + quarantineAndRemoveLegacyRelayGcCandidate, + resumeLegacyRelayGcQuarantine, + type LegacyRelayGcQuarantineHold +} from './legacy-relay-gc-quarantine' +import { LegacyRelayGcQuarantineStore } from './legacy-relay-gc-quarantine-store' + +const BARRIER_VERSION = 1 +const MAX_BARRIER_BYTES = 16 * 1024 + +type MigrationBarrier = Readonly<{ + version: typeof BARRIER_VERSION + barrierId: string + catalogRevision: number + committedAtMs: number +}> + +export class LegacyRelayPostMigrationGc { + private barrier: MigrationBarrier | null = null + private tail: Promise = Promise.resolve() + + private constructor( + private readonly barrierFile: string, + private readonly catalogRevision: () => number, + private readonly protection: () => TerminalLegacyGcProtection, + private readonly eligible: () => TerminalLegacyGcProtection, + private readonly allowedRoots: readonly string[], + private readonly quarantines: LegacyRelayGcQuarantineStore, + private readonly fileSystem?: LegacyRelayGcFileSystem + ) {} + + static async open(input: { + directory: string + catalogRevision: () => number + protection: () => TerminalLegacyGcProtection + eligible: () => TerminalLegacyGcProtection + allowedRoots: readonly string[] + fileSystem?: LegacyRelayGcFileSystem + }): Promise { + await mkdir(input.directory, { recursive: true }) + const allowedRoots = await canonicalLegacyRelayGcRoots(input.allowedRoots, input.fileSystem) + const quarantines = await LegacyRelayGcQuarantineStore.open( + path.join(input.directory, 'quarantine-holds.json') + ) + const coordinator = new LegacyRelayPostMigrationGc( + path.join(input.directory, 'migration-barrier.json'), + input.catalogRevision, + input.protection, + input.eligible, + allowedRoots, + quarantines, + input.fileSystem + ) + coordinator.barrier = await readBarrier(coordinator.barrierFile) + return coordinator + } + + commitBarrier(input: { + barrierId: string + expectedCatalogRevision: number + committedAtMs?: number + }): Promise { + const operation = this.tail.then(async () => { + const revision = this.catalogRevision() + if (revision !== input.expectedCatalogRevision) { + throw new Error('legacy migration catalog changed before the GC barrier') + } + if (this.barrier) { + if ( + this.barrier.barrierId !== input.barrierId || + this.barrier.catalogRevision !== revision + ) { + throw new Error('legacy migration barrier identity was reused') + } + return this.barrier + } + const barrier = Object.freeze({ + version: BARRIER_VERSION, + barrierId: input.barrierId, + catalogRevision: revision, + committedAtMs: input.committedAtMs ?? Date.now() + }) + await writeBarrier(this.barrierFile, barrier) + this.barrier = barrier + return barrier + }) + this.tail = operation.then( + () => undefined, + () => undefined + ) + return operation + } + + collect(input: { + barrierId: string + }): Promise> { + const operation = this.tail.then(async () => { + if (!this.barrier || this.barrier.barrierId !== input.barrierId) { + throw new Error('legacy relay GC requires the durable migration barrier') + } + const removed = await this.resumeQuarantines() + const protectedSet = this.completeProtection() + const candidates = await eligibleLegacyRelayGcCandidates({ + eligible: this.eligible(), + protected: protectedSet, + allowedRoots: this.allowedRoots, + ...(this.fileSystem ? { fileSystem: this.fileSystem } : {}) + }) + for (const candidate of candidates) { + const hold = createLegacyRelayGcQuarantineHold(candidate, this.allowedRoots) + await this.quarantines.add(hold) + const result = await quarantineAndRemoveLegacyRelayGcCandidate({ + candidate, + hold, + allowedRoots: this.allowedRoots, + protection: this.protection, + ...(this.fileSystem ? { fileSystem: this.fileSystem } : {}) + }) + if (result.status !== 'preserved') { + await this.quarantines.remove(hold.quarantinePath) + } + if (result.status === 'removed') { + removed.push(candidate.reportedPath) + } + } + return Object.freeze({ + removed: Object.freeze([...new Set(removed)].sort()), + protected: this.completeProtection() + }) + }) + this.tail = operation.then( + () => undefined, + () => undefined + ) + return operation + } + + private async resumeQuarantines(): Promise { + const removed: string[] = [] + for (const hold of this.quarantines.values()) { + const candidate = this.candidateFromHold(hold) + const result = await resumeLegacyRelayGcQuarantine({ + candidate, + hold, + allowedRoots: this.allowedRoots, + protection: this.protection, + ...(this.fileSystem ? { fileSystem: this.fileSystem } : {}) + }) + if (result.status !== 'preserved') { + await this.quarantines.remove(hold.quarantinePath) + } + if (result.status === 'removed') { + removed.push(hold.originalPath) + } + } + return removed + } + + private candidateFromHold(hold: LegacyRelayGcQuarantineHold): LegacyRelayGcCandidate { + assertLegacyRelayGcCandidatePath(hold.originalPath, this.allowedRoots) + assertLegacyRelayGcCandidatePath(hold.canonicalPath, this.allowedRoots) + assertLegacyRelayGcCandidatePath(hold.quarantinePath, this.allowedRoots) + return Object.freeze({ + reportedPath: hold.originalPath, + removalPath: hold.canonicalPath, + identity: hold.identity + }) + } + + private completeProtection(): TerminalLegacyGcProtection { + const current = this.protection() + const relayDirectories = new Set(current.relayDirectories) + const evidencePaths = new Set(current.evidencePaths) + for (const hold of this.quarantines.values()) { + const target = hold.identity.directory ? relayDirectories : evidencePaths + target.add(hold.originalPath) + target.add(hold.quarantinePath) + } + return Object.freeze({ + relayDirectories: Object.freeze([...relayDirectories].sort()), + evidencePaths: Object.freeze([...evidencePaths].sort()) + }) + } +} + +async function readBarrier(file: string): Promise { + let encoded: Buffer + try { + encoded = await readFile(file) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return null + } + throw error + } + if (encoded.byteLength < 1 || encoded.byteLength > MAX_BARRIER_BYTES) { + throw new Error('legacy migration barrier is not bounded') + } + const value = JSON.parse(encoded.toString('utf8')) as Partial + if ( + value.version !== BARRIER_VERSION || + typeof value.barrierId !== 'string' || + !value.barrierId || + !nonNegativeInteger(value.catalogRevision) || + !nonNegativeInteger(value.committedAtMs) + ) { + throw new Error('legacy migration barrier is invalid') + } + return value as MigrationBarrier +} + +async function writeBarrier(file: string, barrier: MigrationBarrier): Promise { + const encoded = `${JSON.stringify(barrier)}\n` + if (Buffer.byteLength(encoded) > MAX_BARRIER_BYTES) { + throw new Error('legacy migration barrier exceeds its bound') + } + const temporary = `${file}.${process.pid}.tmp` + try { + const handle = await open(temporary, 'w', 0o600) + try { + await handle.writeFile(encoded, 'utf8') + await handle.sync() + } finally { + await handle.close() + } + await rename(temporary, file) + } catch (error) { + await rm(temporary, { force: true }).catch(() => {}) + throw error + } + await syncDirectory(path.dirname(file)) +} + +async function syncDirectory(directoryPath: string): Promise { + let handle + try { + handle = await open(directoryPath, 'r') + await handle.sync() + } catch (error) { + if ( + !['EACCES', 'EBADF', 'EINVAL', 'EISDIR', 'ENOTSUP', 'EPERM'].includes( + (error as NodeJS.ErrnoException).code ?? '' + ) + ) { + throw error + } + } finally { + await handle?.close() + } +} + +function nonNegativeInteger(value: unknown): boolean { + return Number.isSafeInteger(value) && Number(value) >= 0 +} diff --git a/src/relay/legacy-relay-public-endpoint-fence.ts b/src/relay/legacy-relay-public-endpoint-fence.ts new file mode 100644 index 00000000000..4761a2f25f7 --- /dev/null +++ b/src/relay/legacy-relay-public-endpoint-fence.ts @@ -0,0 +1,159 @@ +import { chmod, lstat, mkdtemp, open, readFile, rename, rm } from 'node:fs/promises' +import { basename, dirname, join } from 'node:path' + +const PUBLIC_FENCE_MARKER_NAME = '.orca-terminal-authority-fence-v1' + +export type LegacyRelayLaunchExclusion = Readonly<{ + runExclusive: (operation: () => Promise) => Promise +}> + +export type LegacyRelayPublicEndpointFenceIdentity = Readonly<{ + role: 'socket' | 'credential' | 'active-pipe-marker' + endpointIdentity: string + privatePath: string +}> + +export async function runWithLegacyRelayLaunchExclusion( + exclusion: LegacyRelayLaunchExclusion, + operation: () => Promise +): Promise { + let calls = 0 + const result = await exclusion.runExclusive(async () => { + calls++ + if (calls !== 1) { + throw new Error('legacy relay launch exclusion invoked cutover more than once') + } + return await operation() + }) + if (calls !== 1) { + throw new Error('legacy relay launch exclusion did not guard cutover') + } + return result +} + +export async function inspectPosixLegacyRelayPublicFence( + path: string, + identity: LegacyRelayPublicEndpointFenceIdentity +): Promise { + let stats + try { + stats = await lstat(path, { bigint: true }) + } catch (error) { + if (hasCode(error, 'ENOENT')) { + return false + } + throw error + } + if (!stats.isDirectory()) { + return false + } + if (stats.isSymbolicLink()) { + throw new Error('legacy relay public endpoint fence is a symlink') + } + assertOwned(stats.uid) + const markerPath = join(path, PUBLIC_FENCE_MARKER_NAME) + const markerStats = await lstat(markerPath, { bigint: true }).catch((error) => { + if (hasCode(error, 'ENOENT')) { + throw new Error('legacy relay public endpoint fence is unowned') + } + throw error + }) + if (!markerStats.isFile() || markerStats.isSymbolicLink()) { + throw new Error('legacy relay public endpoint fence marker is invalid') + } + assertOwned(markerStats.uid) + if ((await readFile(markerPath, 'utf8')) !== encodeLegacyRelayPublicEndpointFence(identity)) { + throw new Error('legacy relay public endpoint fence identity changed') + } + return true +} + +export async function installPosixLegacyRelayPublicFence( + path: string, + identity: LegacyRelayPublicEndpointFenceIdentity +): Promise { + if (await inspectPosixLegacyRelayPublicFence(path, identity)) { + return + } + await assertMissing(path) + const temporary = await mkdtemp(join(dirname(path), `.${basename(path)}.authority-fence-`)) + let published = false + try { + await chmod(temporary, 0o700) + const markerPath = join(temporary, PUBLIC_FENCE_MARKER_NAME) + const marker = await open(markerPath, 'wx', 0o600) + try { + await marker.writeFile(encodeLegacyRelayPublicEndpointFence(identity), 'utf8') + await marker.sync() + } finally { + await marker.close() + } + await rename(temporary, path) + published = true + await syncDirectory(dirname(path)) + } catch (error) { + await (published + ? removePosixLegacyRelayPublicFence(path, identity) + : rm(temporary, { recursive: true, force: true })) + throw error + } + if (!(await inspectPosixLegacyRelayPublicFence(path, identity))) { + throw new Error('legacy relay public endpoint fence publication failed') + } +} + +export async function removePosixLegacyRelayPublicFence( + path: string, + identity: LegacyRelayPublicEndpointFenceIdentity +): Promise { + try { + await lstat(path) + } catch (error) { + if (hasCode(error, 'ENOENT')) { + return + } + throw error + } + if (!(await inspectPosixLegacyRelayPublicFence(path, identity))) { + throw new Error('legacy relay rollback found a non-fence public path') + } + await rm(path, { recursive: true }) + await syncDirectory(dirname(path)) +} + +export function encodeLegacyRelayPublicEndpointFence( + identity: LegacyRelayPublicEndpointFenceIdentity +): string { + return JSON.stringify({ version: 1, ...identity }) +} + +async function assertMissing(path: string): Promise { + try { + await lstat(path) + } catch (error) { + if (hasCode(error, 'ENOENT')) { + return + } + throw error + } + throw new Error('legacy relay public endpoint fence target is occupied') +} + +async function syncDirectory(path: string): Promise { + const directory = await open(path, 'r') + try { + await directory.sync() + } finally { + await directory.close() + } +} + +function assertOwned(uid: bigint): void { + if (typeof process.getuid === 'function' && uid !== BigInt(process.getuid())) { + throw new Error('legacy relay public endpoint fence has a foreign owner') + } +} + +function hasCode(error: unknown, code: string): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === code +} diff --git a/src/relay/legacy-relay-unix-socket-inspection.test.ts b/src/relay/legacy-relay-unix-socket-inspection.test.ts new file mode 100644 index 00000000000..4c9f93e58ea --- /dev/null +++ b/src/relay/legacy-relay-unix-socket-inspection.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it } from 'vitest' +import { + proveSoleLegacyRelayBroker, + type LegacyRelayStatusSample +} from './legacy-relay-broker-connection-proof' +import { inspectLegacyUnixSocket } from './legacy-relay-unix-socket-inspection' +import { parseLinuxProcNetUnix, parseLsofUnixFields } from './legacy-relay-unix-socket-records' + +const socketPath = '/var/run/orca relay.sock' +const endpointIdentity = Object.freeze({ + kind: 'unix-socket' as const, + device: '18', + inode: '700', + changedAtNs: '900' +}) + +function procNetUnix(acceptedInodes = ['701']): string { + return [ + 'Num RefCount Protocol Flags Type St Inode Path', + `0001: 00000002 00000000 00010000 0001 01 700 ${socketPath}`, + ...acceptedInodes.map( + (inode) => `0002: 00000003 00000000 00000000 0001 03 ${inode} ${socketPath}` + ), + '0003: 00000003 00000000 00000000 0001 03 999 /tmp/unrelated.sock' + ].join('\n') +} + +describe('legacy relay Unix socket inspection', () => { + it('parses procfs paths with spaces without weakening inode identity', () => { + expect(parseLinuxProcNetUnix(procNetUnix())).toEqual([ + { + flags: '00010000', + type: '0001', + state: '01', + inode: '700', + path: socketPath + }, + { + flags: '00000000', + type: '0001', + state: '03', + inode: '701', + path: socketPath + }, + { + flags: '00000000', + type: '0001', + state: '03', + inode: '999', + path: '/tmp/unrelated.sock' + } + ]) + }) + + it('proves one Linux accepted socket from procfs and the relay fd table', async () => { + const result = await inspectLegacyUnixSocket( + { platform: 'linux', socketPath, relayPid: 42 }, + { + inspectEndpoint: async () => endpointIdentity, + readTextFile: async () => procNetUnix(), + listDirectory: async () => ['4', '5', '6'], + readLink: async (path) => + path.endsWith('/4') ? 'socket:[700]' : path.endsWith('/5') ? 'socket:[701]' : 'pipe:[12]' + } + ) + + expect(result).toEqual({ + method: 'linux-procfs-unix', + endpointIdentity, + listenerIdentity: '42:socket:700', + acceptedServerConnections: 1 + }) + }) + + it('fails closed when Linux has a second accepted client', async () => { + await expect( + inspectLegacyUnixSocket( + { platform: 'linux', socketPath, relayPid: 42 }, + { + inspectEndpoint: async () => endpointIdentity, + readTextFile: async () => procNetUnix(['701', '702']), + listDirectory: async () => ['4', '5', '6'], + readLink: async (path) => + path.endsWith('/4') + ? 'socket:[700]' + : path.endsWith('/5') + ? 'socket:[701]' + : 'socket:[702]' + } + ) + ).rejects.toThrow('exactly one accepted Unix client') + }) + + it('parses lsof NUL fields and proves one macOS accepted client', async () => { + const lsof = [ + 'p42', + 'f5u', + 'tunix', + `n${socketPath}`, + 'TST=LISTEN', + 'f6u', + 'tunix', + `n${socketPath}->`, + 'TST=CONNECTED' + ].join('\0') + expect(parseLsofUnixFields(lsof)).toHaveLength(2) + + await expect( + inspectLegacyUnixSocket( + { platform: 'darwin', socketPath, relayPid: 42 }, + { inspectEndpoint: async () => endpointIdentity, runLsof: async () => lsof } + ) + ).resolves.toEqual({ + method: 'darwin-lsof-unix', + endpointIdentity, + listenerIdentity: '42:unix:18:700', + acceptedServerConnections: 1 + }) + }) + + it('requires stable two-sample sole-broker status proof', () => { + const inspection = { + method: 'linux-procfs-unix' as const, + endpointIdentity, + listenerIdentity: '42:socket:700', + acceptedServerConnections: 1 as const + } + const samples: readonly LegacyRelayStatusSample[] = [ + { + pid: 42, + legacyCutover: { + capabilityVersion: 1, + configuredGraceMs: 0, + acknowledged: true, + brokerConnectionIdentity: 'owner:3:connection:9' + }, + socket: { path: socketPath, listening: true, clients: 1, acceptedConnections: 7 } + }, + { + pid: 42, + legacyCutover: { + capabilityVersion: 1, + configuredGraceMs: 0, + acknowledged: true, + brokerConnectionIdentity: 'owner:3:connection:9' + }, + socket: { path: socketPath, listening: true, clients: 1, acceptedConnections: 7 } + } + ] + expect( + proveSoleLegacyRelayBroker({ + endpointPath: socketPath, + relayPid: 42, + brokerConnectionIdentity: 'owner:3:connection:9', + samples, + platformInspection: inspection + }) + ).toMatchObject({ + brokerClientCount: 1, + acceptedConnectionCount: 7, + quiescenceSamples: 2, + graceConfiguration: { configuredGraceMs: 0, acknowledged: true }, + connectionProof: { acceptedServerConnections: 1 } + }) + expect(() => + proveSoleLegacyRelayBroker({ + endpointPath: socketPath, + relayPid: 42, + brokerConnectionIdentity: 'owner:3:connection:9', + samples: [samples[0], { ...samples[1], socket: { ...samples[1].socket, clients: 2 } }], + platformInspection: inspection + }) + ).toThrow('one live broker client') + expect(() => + proveSoleLegacyRelayBroker({ + endpointPath: socketPath, + relayPid: 42, + brokerConnectionIdentity: 'owner:3:connection:9', + samples: [ + samples[0], + { ...samples[1], socket: { ...samples[1].socket, acceptedConnections: 8 } } + ], + platformInspection: inspection + }) + ).toThrow('during the quiescence proof') + }) +}) diff --git a/src/relay/legacy-relay-unix-socket-inspection.ts b/src/relay/legacy-relay-unix-socket-inspection.ts new file mode 100644 index 00000000000..c5d4b7cfc79 --- /dev/null +++ b/src/relay/legacy-relay-unix-socket-inspection.ts @@ -0,0 +1,174 @@ +import { execFile } from 'node:child_process' +import { lstat, readFile, readdir, readlink } from 'node:fs/promises' +import { promisify } from 'node:util' +import type { TerminalLegacyEndpointIdentity } from '../shared/terminal-legacy-cutover' +import { + lsofNameReferencesSocket, + parseLinuxProcNetUnix, + parseLsofUnixFields +} from './legacy-relay-unix-socket-records' + +const execFileAsync = promisify(execFile) +const MAX_PROCESS_FDS = 4_096 + +type UnixEndpointIdentity = Extract + +export type LegacyUnixSocketInspection = Readonly<{ + method: 'linux-procfs-unix' | 'darwin-lsof-unix' + endpointIdentity: UnixEndpointIdentity + listenerIdentity: string + acceptedServerConnections: 1 +}> + +export type LegacyUnixSocketInspectionRequest = Readonly<{ + platform: 'linux' | 'darwin' + socketPath: string + relayPid: number +}> + +type LegacyUnixSocketInspectionDependencies = Readonly<{ + inspectEndpoint?: (socketPath: string) => Promise + readTextFile?: (path: string) => Promise + listDirectory?: (path: string) => Promise + readLink?: (path: string) => Promise + runLsof?: (relayPid: number) => Promise +}> + +export async function inspectLegacyUnixSocket( + request: LegacyUnixSocketInspectionRequest, + dependencies: LegacyUnixSocketInspectionDependencies = {} +): Promise { + if (!Number.isSafeInteger(request.relayPid) || request.relayPid <= 0) { + throw new Error('legacy relay pid is invalid') + } + if (!request.socketPath.startsWith('/')) { + throw new Error('legacy relay Unix socket path must be absolute') + } + const endpointIdentity = await (dependencies.inspectEndpoint ?? inspectUnixEndpoint)( + request.socketPath + ) + return request.platform === 'linux' + ? await inspectLinuxSocket(request, endpointIdentity, dependencies) + : await inspectDarwinSocket(request, endpointIdentity, dependencies) +} + +export async function inspectUnixEndpoint(socketPath: string): Promise { + const stats = await lstat(socketPath, { bigint: true }) + if (!stats.isSocket()) { + throw new Error('legacy relay endpoint is not a Unix socket') + } + return Object.freeze({ + kind: 'unix-socket', + device: stats.dev.toString(), + inode: stats.ino.toString(), + changedAtNs: stats.ctimeNs.toString() + }) +} + +async function inspectLinuxSocket( + request: LegacyUnixSocketInspectionRequest, + endpointIdentity: UnixEndpointIdentity, + dependencies: LegacyUnixSocketInspectionDependencies +): Promise { + const readTextFile = dependencies.readTextFile ?? readUtf8 + const records = parseLinuxProcNetUnix(await readTextFile('/proc/net/unix')) + const listener = records.filter( + (record) => + record.inode === endpointIdentity.inode && + record.path === request.socketPath && + record.type === '0001' && + record.state === '01' && + Number.parseInt(record.flags, 16) & 0x1_0000 + ) + if (listener.length !== 1) { + throw new Error('legacy relay listener identity is not exact in procfs') + } + const ownedInodes = await readProcessSocketInodes(request.relayPid, dependencies) + if (!ownedInodes.has(endpointIdentity.inode)) { + throw new Error('legacy relay process does not own the listener inode') + } + const connected = records.filter( + (record) => record.type === '0001' && record.state === '03' && ownedInodes.has(record.inode) + ) + if (connected.length !== 1) { + throw new Error('legacy relay does not have exactly one accepted Unix client') + } + return Object.freeze({ + method: 'linux-procfs-unix', + endpointIdentity, + listenerIdentity: `${request.relayPid}:socket:${endpointIdentity.inode}`, + acceptedServerConnections: 1 + }) +} + +async function inspectDarwinSocket( + request: LegacyUnixSocketInspectionRequest, + endpointIdentity: UnixEndpointIdentity, + dependencies: LegacyUnixSocketInspectionDependencies +): Promise { + const output = await (dependencies.runLsof ?? runLsof)(request.relayPid) + const records = parseLsofUnixFields(output).filter( + (record) => + record.pid === request.relayPid && + record.type.toLowerCase() === 'unix' && + lsofNameReferencesSocket(record.name, request.socketPath) + ) + const listeners = records.filter((record) => record.state?.toUpperCase() === 'LISTEN') + const connected = records.filter((record) => record.state?.toUpperCase() === 'CONNECTED') + if (listeners.length !== 1) { + throw new Error('legacy relay listener identity is not exact in lsof') + } + if (connected.length !== 1) { + throw new Error('legacy relay does not have exactly one accepted lsof client') + } + return Object.freeze({ + method: 'darwin-lsof-unix', + endpointIdentity, + listenerIdentity: `${request.relayPid}:unix:${endpointIdentity.device}:${endpointIdentity.inode}`, + acceptedServerConnections: 1 + }) +} + +async function readProcessSocketInodes( + pid: number, + dependencies: LegacyUnixSocketInspectionDependencies +): Promise> { + const directory = `/proc/${pid}/fd` + const names = await (dependencies.listDirectory ?? listNames)(directory) + if (names.length > MAX_PROCESS_FDS) { + throw new Error('legacy relay fd inspection exceeds its bound') + } + const readLink = dependencies.readLink ?? readlink + const targets = await Promise.all( + names.map(async (name) => { + try { + return await readLink(`${directory}/${name}`) + } catch { + return '' + } + }) + ) + return new Set( + targets.flatMap((target) => { + const match = /^socket:\[(\d+)\]$/.exec(target) + return match ? [match[1]] : [] + }) + ) +} + +async function runLsof(relayPid: number): Promise { + const { stdout } = await execFileAsync( + 'lsof', + ['-nP', '-a', '-U', '-p', String(relayPid), '-F0pftnT'], + { encoding: 'utf8', timeout: 3_000 } + ) + return stdout +} + +async function readUtf8(path: string): Promise { + return await readFile(path, 'utf8') +} + +async function listNames(path: string): Promise { + return await readdir(path) +} diff --git a/src/relay/legacy-relay-unix-socket-records.ts b/src/relay/legacy-relay-unix-socket-records.ts new file mode 100644 index 00000000000..75484097fbb --- /dev/null +++ b/src/relay/legacy-relay-unix-socket-records.ts @@ -0,0 +1,97 @@ +export type LinuxUnixSocketRecord = Readonly<{ + flags: string + type: string + state: string + inode: string + path: string | null +}> + +export type LsofUnixSocketRecord = Readonly<{ + pid: number + fd: string + type: string + name: string + state: string | null +}> + +const PROC_NET_UNIX_RECORD = /^\S+:\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)(?:\s+(.*))?$/ + +export function parseLinuxProcNetUnix(input: string): LinuxUnixSocketRecord[] { + const records: LinuxUnixSocketRecord[] = [] + for (const line of input.split(/\r?\n/).slice(1)) { + const match = PROC_NET_UNIX_RECORD.exec(line.trim()) + if (!match) { + continue + } + records.push( + Object.freeze({ + flags: match[3], + type: match[4], + state: match[5], + inode: match[6], + path: match[7]?.trim() || null + }) + ) + } + return records +} + +export function parseLsofUnixFields(input: string): LsofUnixSocketRecord[] { + const records: LsofUnixSocketRecord[] = [] + let pid: number | null = null + let current: MutableLsofRecord | null = null + const finish = (): void => { + if (current && pid !== null && current.fd && current.type) { + records.push( + Object.freeze({ + pid, + fd: current.fd, + type: current.type, + name: current.name, + state: current.state + }) + ) + } + current = null + } + for (const field of input.replaceAll('\0', '\n').split(/\r?\n+/)) { + if (!field) { + continue + } + const tag = field[0] + const value = field.slice(1) + if (tag === 'p') { + finish() + const parsed = Number(value) + pid = Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null + } else if (tag === 'f') { + finish() + current = { fd: value, type: '', name: '', state: null } + } else if (current && tag === 't') { + current.type = value + } else if (current && tag === 'n') { + current.name = value + } else if (current && tag === 'T' && value.startsWith('ST=')) { + current.state = value.slice(3) + } + } + finish() + return records +} + +type MutableLsofRecord = { + fd: string + type: string + name: string + state: string | null +} + +export function lsofNameReferencesSocket(name: string, socketPath: string): boolean { + if (name === socketPath) { + return true + } + return name + .split(/\s*->\s*/) + .map((part) => part.replace(/\s+type=\w+.*$/i, '').trim()) + .includes(socketPath) +} diff --git a/src/relay/legacy-relay-windows-cutover-io.ts b/src/relay/legacy-relay-windows-cutover-io.ts new file mode 100644 index 00000000000..b29c6076ee5 --- /dev/null +++ b/src/relay/legacy-relay-windows-cutover-io.ts @@ -0,0 +1,174 @@ +import { execFile } from 'node:child_process' +import { lstat, mkdir, mkdtemp, open, readFile, rename, rm } from 'node:fs/promises' +import { basename, dirname, join } from 'node:path' +import { promisify } from 'node:util' +import { + encodeLegacyRelayPublicEndpointFence, + type LegacyRelayPublicEndpointFenceIdentity +} from './legacy-relay-public-endpoint-fence' + +const execFileAsync = promisify(execFile) +const PUBLIC_FENCE_MARKER_NAME = '.orca-terminal-authority-fence-v1' + +export type WindowsCutoverFileSnapshot = Readonly<{ content: string }> | null + +export async function queryWindowsProcessCreationMarker(pid: number): Promise { + if (!Number.isSafeInteger(pid) || pid <= 0) { + return null + } + const script = + `$ErrorActionPreference='Stop';` + + `$p=Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}";` + + `if(!$p -or !$p.CreationDate){exit 3};` + + `[Console]::Out.Write(([DateTimeOffset]$p.CreationDate).ToUniversalTime().Ticks)` + try { + const { stdout } = await execFileAsync( + 'powershell.exe', + ['-NoProfile', '-NonInteractive', '-Command', script], + { encoding: 'utf8', timeout: 3_000 } + ) + return /^\d+$/.test(stdout.trim()) ? stdout.trim() : null + } catch { + return null + } +} + +export async function readWindowsCutoverFileSnapshot( + path: string +): Promise { + try { + const stats = await lstat(path) + if (!stats.isFile() || stats.isSymbolicLink()) { + throw new Error('legacy relay Windows cutover file is not regular') + } + return Object.freeze({ content: await readFile(path, 'utf8') }) + } catch (error) { + if (hasCode(error, 'ENOENT')) { + return null + } + throw error + } +} + +export async function ensureWindowsPrivateDirectory(path: string): Promise { + await mkdir(path, { recursive: true }) + const stats = await lstat(path) + if (!stats.isDirectory() || stats.isSymbolicLink()) { + throw new Error('legacy relay Windows authority state is not a directory') + } + await sealWindowsPrivateFile(path) +} + +export async function sealWindowsPrivateFile(path: string): Promise { + const user = process.env.USERNAME + if (!user) { + throw new Error('legacy relay Windows authority account is unavailable') + } + await execFileAsync('icacls.exe', [path, '/inheritance:r', '/grant:r', `${user}:(R,W)`], { + encoding: 'utf8', + timeout: 3_000 + }) +} + +export async function inspectWindowsLegacyRelayPublicFence( + path: string, + identity: LegacyRelayPublicEndpointFenceIdentity +): Promise { + let stats + try { + stats = await lstat(path) + } catch (error) { + if (hasCode(error, 'ENOENT')) { + return false + } + throw error + } + if (!stats.isDirectory()) { + return false + } + if (stats.isSymbolicLink()) { + throw new Error('legacy relay Windows public fence is a symlink') + } + const markerPath = join(path, PUBLIC_FENCE_MARKER_NAME) + let markerStats + try { + markerStats = await lstat(markerPath) + } catch (error) { + if (hasCode(error, 'ENOENT')) { + throw new Error('legacy relay Windows public fence is unowned') + } + throw error + } + if (!markerStats.isFile() || markerStats.isSymbolicLink()) { + throw new Error('legacy relay Windows public fence marker is invalid') + } + if ((await readFile(markerPath, 'utf8')) !== encodeLegacyRelayPublicEndpointFence(identity)) { + throw new Error('legacy relay Windows public fence identity changed') + } + return true +} + +export async function installWindowsLegacyRelayPublicFence( + path: string, + identity: LegacyRelayPublicEndpointFenceIdentity +): Promise { + if (await inspectWindowsLegacyRelayPublicFence(path, identity)) { + return + } + await assertMissing(path) + const temporary = await mkdtemp(join(dirname(path), `.${basename(path)}.authority-fence-`)) + const markerPath = join(temporary, PUBLIC_FENCE_MARKER_NAME) + let published = false + try { + const marker = await open(markerPath, 'wx') + try { + await marker.writeFile(encodeLegacyRelayPublicEndpointFence(identity), 'utf8') + await marker.sync() + } finally { + await marker.close() + } + await rename(temporary, path) + published = true + await sealWindowsPrivateFile(join(path, PUBLIC_FENCE_MARKER_NAME)) + await sealWindowsPrivateFile(path) + } catch (error) { + await (published + ? removeWindowsLegacyRelayPublicFence(path, identity) + : rm(temporary, { recursive: true, force: true })) + throw error + } +} + +export async function removeWindowsLegacyRelayPublicFence( + path: string, + identity: LegacyRelayPublicEndpointFenceIdentity +): Promise { + try { + await lstat(path) + } catch (error) { + if (hasCode(error, 'ENOENT')) { + return + } + throw error + } + if (!(await inspectWindowsLegacyRelayPublicFence(path, identity))) { + throw new Error('legacy relay Windows rollback found a non-fence public path') + } + await rm(path, { recursive: true }) +} + +async function assertMissing(path: string): Promise { + try { + await lstat(path) + } catch (error) { + if (hasCode(error, 'ENOENT')) { + return + } + throw error + } + throw new Error('legacy relay Windows public fence target is occupied') +} + +function hasCode(error: unknown, code: string): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === code +} diff --git a/src/relay/legacy-relay-windows-cutover-paths.ts b/src/relay/legacy-relay-windows-cutover-paths.ts new file mode 100644 index 00000000000..5af06d1785f --- /dev/null +++ b/src/relay/legacy-relay-windows-cutover-paths.ts @@ -0,0 +1,88 @@ +import { win32 } from 'node:path' +import type { LegacyRelayPublicEndpointFenceIdentity } from './legacy-relay-public-endpoint-fence' + +export type WindowsLegacyRelayCutoverPaths = Readonly<{ + activePipeMarkerPath: string + privateActivePipeMarkerPath: string + publicCredentialFile: string + privateCredentialFile: string + privateStateDirectory: string +}> + +export function validateWindowsLegacyRelayCutoverPaths( + input: WindowsLegacyRelayCutoverPaths & Readonly<{ pipeName: string }> +): WindowsLegacyRelayCutoverPaths { + assertWindowsLegacyRelayPipeName(input.pipeName) + const paths = { + activePipeMarkerPath: resolveWindowsAbsolute(input.activePipeMarkerPath), + privateActivePipeMarkerPath: resolveWindowsAbsolute(input.privateActivePipeMarkerPath), + publicCredentialFile: resolveWindowsAbsolute(input.publicCredentialFile), + privateCredentialFile: resolveWindowsAbsolute(input.privateCredentialFile), + privateStateDirectory: resolveWindowsAbsolute(input.privateStateDirectory) + } + if (new Set(Object.values(paths).map((value) => value.toLowerCase())).size !== 5) { + throw new Error('legacy relay Windows cutover paths must be distinct') + } + assertWindowsPrivateChild(paths.privateStateDirectory, paths.privateActivePipeMarkerPath) + assertWindowsPrivateChild(paths.privateStateDirectory, paths.privateCredentialFile) + const privateRoot = win32.parse(paths.privateStateDirectory).root.toLowerCase() + if ( + win32.parse(paths.activePipeMarkerPath).root.toLowerCase() !== privateRoot || + win32.parse(paths.publicCredentialFile).root.toLowerCase() !== privateRoot + ) { + throw new Error('legacy relay Windows state cannot be sealed across volumes') + } + return Object.freeze(paths) +} + +export function windowsLegacyRelayPublicFenceIdentities( + paths: WindowsLegacyRelayCutoverPaths, + pipeName: string, + processCreationMarker: string +): Readonly<{ + credential: LegacyRelayPublicEndpointFenceIdentity + marker: LegacyRelayPublicEndpointFenceIdentity +}> { + const endpointIdentity = `${canonicalWindowsLegacyRelayPipeName(pipeName)}:${processCreationMarker}` + return Object.freeze({ + credential: Object.freeze({ + role: 'credential', + endpointIdentity, + privatePath: paths.privateCredentialFile + }), + marker: Object.freeze({ + role: 'active-pipe-marker', + endpointIdentity, + privatePath: paths.privateActivePipeMarkerPath + }) + }) +} + +export function assertWindowsLegacyRelayPipeName(value: string): void { + if (!/^\\\\[.?]\\pipe\\.+$/i.test(value) || value.includes('\0') || value.length > 256) { + throw new Error('legacy relay Windows pipe name is invalid') + } +} + +export function canonicalWindowsLegacyRelayPipeName(value: string): string { + assertWindowsLegacyRelayPipeName(value) + return win32.normalize(value).toLowerCase() +} + +function resolveWindowsAbsolute(value: string): string { + if (!win32.isAbsolute(value) || value.includes('\0')) { + throw new Error('legacy relay Windows cutover path must be absolute') + } + return win32.resolve(value) +} + +function assertWindowsPrivateChild(directory: string, child: string): void { + const pathFromDirectory = win32.relative(directory, child) + if ( + !pathFromDirectory || + pathFromDirectory.startsWith('..') || + win32.isAbsolute(pathFromDirectory) + ) { + throw new Error('legacy relay Windows private path escapes authority state') + } +} diff --git a/src/relay/legacy-relay-windows-cutover.ts b/src/relay/legacy-relay-windows-cutover.ts new file mode 100644 index 00000000000..293f1fedfe3 --- /dev/null +++ b/src/relay/legacy-relay-windows-cutover.ts @@ -0,0 +1,303 @@ +import { rename } from 'node:fs/promises' +import type { + TerminalLegacyCutoverProof, + TerminalLegacyEndpointIdentity, + TerminalLegacyProcessIdentity +} from '../shared/terminal-legacy-cutover' +import type { LegacyRelayBrokerConnectionEvidence } from './legacy-relay-broker-connection-proof' +import { + runWithLegacyRelayLaunchExclusion, + type LegacyRelayLaunchExclusion, + type LegacyRelayPublicEndpointFenceIdentity +} from './legacy-relay-public-endpoint-fence' +import { + ensureWindowsPrivateDirectory, + inspectWindowsLegacyRelayPublicFence, + installWindowsLegacyRelayPublicFence, + queryWindowsProcessCreationMarker, + readWindowsCutoverFileSnapshot, + removeWindowsLegacyRelayPublicFence, + sealWindowsPrivateFile, + type WindowsCutoverFileSnapshot +} from './legacy-relay-windows-cutover-io' +import { + assertWindowsLegacyRelayPipeName, + canonicalWindowsLegacyRelayPipeName, + validateWindowsLegacyRelayCutoverPaths, + windowsLegacyRelayPublicFenceIdentities, + type WindowsLegacyRelayCutoverPaths +} from './legacy-relay-windows-cutover-paths' +type WindowsEndpointIdentity = Extract< + TerminalLegacyEndpointIdentity, + { kind: 'windows-named-pipe' } +> +type WindowsCutoverProof = Extract + +export type WindowsLegacyRelayPipeInspection = Readonly<{ + method: 'windows-pipe-process' + endpointIdentity: WindowsEndpointIdentity + listenerIdentity: string + acceptedServerConnections: 1 +}> + +export type WindowsLegacyRelayCutoverRequest = Readonly<{ + pipeName: string + relayProcess: TerminalLegacyProcessIdentity + activePipeMarkerPath: string + privateActivePipeMarkerPath: string + publicCredentialFile: string + privateCredentialFile: string + privateStateDirectory: string + brokerEvidence: LegacyRelayBrokerConnectionEvidence + launchExclusion: LegacyRelayLaunchExclusion + sealedAtMs?: number +}> + +export type WindowsLegacyRelayCutoverDependencies = Readonly<{ + now?: () => number + queryProcessCreationMarker?: (pid: number) => Promise + ensurePrivateDirectory?: (path: string) => Promise + readFileSnapshot?: (path: string) => Promise + renamePath?: (from: string, to: string) => Promise + sealPrivateFile?: (path: string) => Promise + inspectPublicFence?: ( + path: string, + identity: LegacyRelayPublicEndpointFenceIdentity + ) => Promise + installPublicFence?: ( + path: string, + identity: LegacyRelayPublicEndpointFenceIdentity + ) => Promise + removePublicFence?: ( + path: string, + identity: LegacyRelayPublicEndpointFenceIdentity + ) => Promise +}> + +export async function inspectWindowsLegacyRelayPipe( + pipeName: string, + relayProcess: TerminalLegacyProcessIdentity, + queryCreationMarker: (pid: number) => Promise = queryWindowsProcessCreationMarker +): Promise { + assertWindowsLegacyRelayPipeName(pipeName) + const creationMarker = await queryCreationMarker(relayProcess.pid) + if (!creationMarker || creationMarker !== relayProcess.birthMarker) { + throw new Error('legacy relay Windows process identity is stale') + } + const endpointIdentity = Object.freeze({ + kind: 'windows-named-pipe' as const, + pipeName, + processCreationMarker: creationMarker + }) + return Object.freeze({ + method: 'windows-pipe-process', + endpointIdentity, + listenerIdentity: `${relayProcess.pid}:windows:${creationMarker}:${canonicalWindowsLegacyRelayPipeName(pipeName)}`, + acceptedServerConnections: 1 + }) +} + +export async function sealWindowsLegacyRelayPipe( + request: WindowsLegacyRelayCutoverRequest, + dependencies: WindowsLegacyRelayCutoverDependencies = {} +): Promise { + return await runWithLegacyRelayLaunchExclusion(request.launchExclusion, async () => + sealWindowsLegacyRelayPipeExcluded(request, dependencies) + ) +} + +async function sealWindowsLegacyRelayPipeExcluded( + request: WindowsLegacyRelayCutoverRequest, + dependencies: WindowsLegacyRelayCutoverDependencies +): Promise { + const paths = validateWindowsLegacyRelayCutoverPaths(request) + const fences = windowsLegacyRelayPublicFenceIdentities( + paths, + request.pipeName, + request.relayProcess.birthMarker + ) + if (request.brokerEvidence.connectionProof.method !== 'windows-pipe-process') { + throw new Error('legacy relay Windows cutover requires pipe/process connection proof') + } + assertWindowsBrokerEndpoint(request) + const query = dependencies.queryProcessCreationMarker ?? queryWindowsProcessCreationMarker + await assertWindowsProcess(request.relayProcess, query) + await (dependencies.ensurePrivateDirectory ?? ensureWindowsPrivateDirectory)( + paths.privateStateDirectory + ) + const readSnapshot = dependencies.readFileSnapshot ?? readWindowsCutoverFileSnapshot + const inspectFence = dependencies.inspectPublicFence ?? inspectWindowsLegacyRelayPublicFence + const credential = await inspectFilePair( + paths.publicCredentialFile, + paths.privateCredentialFile, + fences.credential, + readSnapshot, + inspectFence, + validateCredential + ) + const marker = await inspectFilePair( + paths.activePipeMarkerPath, + paths.privateActivePipeMarkerPath, + fences.marker, + readSnapshot, + inspectFence, + (value) => assertMarkerPipe(value, request.pipeName) + ) + const move = dependencies.renamePath ?? rename + const sealFile = dependencies.sealPrivateFile ?? sealWindowsPrivateFile + let movedCredential = false + let movedMarker = false + let credentialFenced = false + let markerFenced = false + try { + if (credential.location === 'public') { + await move(paths.publicCredentialFile, paths.privateCredentialFile) + movedCredential = true + } + await sealFile(paths.privateCredentialFile) + if (marker.location === 'public') { + await move(paths.activePipeMarkerPath, paths.privateActivePipeMarkerPath) + movedMarker = true + } + await sealFile(paths.privateActivePipeMarkerPath) + const installFence = dependencies.installPublicFence ?? installWindowsLegacyRelayPublicFence + await installFence(paths.publicCredentialFile, fences.credential) + credentialFenced = true + await installFence(paths.activePipeMarkerPath, fences.marker) + markerFenced = true + } catch (error) { + await rollbackWindowsCutover({ + paths, + fences, + movedCredential, + movedMarker, + credentialFenced, + markerFenced, + renamePath: dependencies.renamePath, + removePublicFence: dependencies.removePublicFence + }).catch((rollbackError) => { + throw new AggregateError( + [error, rollbackError], + 'legacy relay Windows cutover rollback failed' + ) + }) + throw error + } + await assertWindowsProcess(request.relayProcess, query) + return Object.freeze({ + kind: 'windows-sealed', + originalPipeName: request.pipeName, + activePipeMarkerIgnored: true, + publicCredentialFile: paths.publicCredentialFile, + privateCredentialFile: paths.privateCredentialFile, + endpointIdentity: Object.freeze({ + kind: 'windows-named-pipe', + pipeName: request.pipeName, + processCreationMarker: request.relayProcess.birthMarker + }), + brokerClientCount: 1, + acceptedConnectionCount: request.brokerEvidence.acceptedConnectionCount, + quiescenceSamples: request.brokerEvidence.quiescenceSamples, + connectionProof: request.brokerEvidence.connectionProof, + graceConfiguration: request.brokerEvidence.graceConfiguration, + sealedAtMs: request.sealedAtMs ?? dependencies.now?.() ?? Date.now() + }) +} + +async function inspectFilePair( + publicPath: string, + privatePath: string, + publicFence: LegacyRelayPublicEndpointFenceIdentity, + readSnapshot: (path: string) => Promise, + inspectFence: ( + path: string, + identity: LegacyRelayPublicEndpointFenceIdentity + ) => Promise, + validate: (content: string) => void +): Promise> { + const fenced = await inspectFence(publicPath, publicFence) + const [publicFile, privateFile] = await Promise.all([ + fenced ? null : readSnapshot(publicPath), + readSnapshot(privatePath) + ]) + if (fenced && !privateFile) { + throw new Error('legacy relay Windows public fence has no private state') + } + if (Boolean(publicFile) === Boolean(privateFile)) { + throw new Error('legacy relay Windows sealing state is ambiguous') + } + validate((publicFile ?? privateFile!).content.trim()) + return Object.freeze({ location: publicFile ? 'public' : 'private' }) +} + +async function assertWindowsProcess( + processIdentity: TerminalLegacyProcessIdentity, + query: (pid: number) => Promise +): Promise { + const marker = await query(processIdentity.pid) + if (!marker || marker !== processIdentity.birthMarker) { + throw new Error('legacy relay Windows process identity changed during cutover') + } +} + +function validateCredential(value: string): void { + if (!/^[A-Za-z0-9_-]{32,256}$/.test(value)) { + throw new Error('legacy relay credential is invalid') + } +} + +function assertMarkerPipe(value: string, expectedPipe: string): void { + if ( + canonicalWindowsLegacyRelayPipeName(value) !== canonicalWindowsLegacyRelayPipeName(expectedPipe) + ) { + throw new Error('legacy relay active-pipe marker was reused') + } +} + +function assertWindowsBrokerEndpoint(request: WindowsLegacyRelayCutoverRequest): void { + const endpoint = request.brokerEvidence.endpointIdentity + if ( + endpoint.kind !== 'windows-named-pipe' || + canonicalWindowsLegacyRelayPipeName(endpoint.pipeName) !== + canonicalWindowsLegacyRelayPipeName(request.pipeName) || + endpoint.processCreationMarker !== request.relayProcess.birthMarker + ) { + throw new Error('legacy relay broker proof targets a different Windows pipe') + } +} + +async function rollbackWindowsCutover( + input: Readonly<{ + paths: WindowsLegacyRelayCutoverPaths + fences: Readonly<{ + credential: LegacyRelayPublicEndpointFenceIdentity + marker: LegacyRelayPublicEndpointFenceIdentity + }> + movedCredential: boolean + movedMarker: boolean + credentialFenced: boolean + markerFenced: boolean + renamePath?: (from: string, to: string) => Promise + removePublicFence?: ( + path: string, + identity: LegacyRelayPublicEndpointFenceIdentity + ) => Promise + }> +): Promise { + const removeFence = input.removePublicFence ?? removeWindowsLegacyRelayPublicFence + if (input.markerFenced) { + await removeFence(input.paths.activePipeMarkerPath, input.fences.marker) + } + if (input.credentialFenced) { + await removeFence(input.paths.publicCredentialFile, input.fences.credential) + } + const move = input.renamePath ?? rename + if (input.movedMarker) { + await move(input.paths.privateActivePipeMarkerPath, input.paths.activePipeMarkerPath) + } + if (input.movedCredential) { + await move(input.paths.privateCredentialFile, input.paths.publicCredentialFile) + } +} + +export { queryWindowsProcessCreationMarker } from './legacy-relay-windows-cutover-io' diff --git a/src/relay/protocol.ts b/src/relay/protocol.ts index 8cb4ab57a75..e51818da9fa 100644 --- a/src/relay/protocol.ts +++ b/src/relay/protocol.ts @@ -11,6 +11,11 @@ import { FRAME_DECODER_MAX_TURN_MS, FRAME_DECODER_MAX_RETAINED_BYTES } from './relay-frame-decoder' +import type { + RelayDaemonCompatibilityGrant, + RelayDaemonCompatibilityOffer +} from '../shared/relay-daemon-compatibility' +import type { SshTerminalAuthorityEndpointIdentity } from '../shared/ssh-terminal-authority-marker' export { FrameDecoder, @@ -38,9 +43,25 @@ export const MessageType = { // to refuse mismatched-version --connect bridges that would otherwise drive a // stale daemon. export type HandshakeMessage = - | { type: 'orca-relay-handshake'; version: string; endpointCredential?: string } - | { type: 'orca-relay-handshake-ok'; version: string } - | { type: 'orca-relay-handshake-mismatch'; expected: string; got: string } + | { + type: 'orca-relay-handshake' + version: string + endpointCredential?: string + compatibility?: RelayDaemonCompatibilityOffer + authorityExpectation?: SshTerminalAuthorityEndpointIdentity + } + | { + type: 'orca-relay-handshake-ok' + version: string + compatibility?: RelayDaemonCompatibilityGrant + authorityIdentity?: SshTerminalAuthorityEndpointIdentity + } + | { + type: 'orca-relay-handshake-mismatch' + expected: string + got: string + reason?: 'build' | 'protocol' | 'authority' + } export function encodeHandshakeFrame(msg: HandshakeMessage): Buffer { const payload = Buffer.from(JSON.stringify(msg), 'utf-8') diff --git a/src/relay/pty-handler-terminal-session-authority-identity.test.ts b/src/relay/pty-handler-terminal-session-authority-identity.test.ts new file mode 100644 index 00000000000..64af5b51368 --- /dev/null +++ b/src/relay/pty-handler-terminal-session-authority-identity.test.ts @@ -0,0 +1,188 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockPtySpawn } = vi.hoisted(() => ({ mockPtySpawn: vi.fn() })) + +vi.mock('node-pty', () => ({ spawn: mockPtySpawn })) +vi.mock('../main/pty/posix-pty-process-groups', () => ({ + forceKillPosixPtyProcessGroups: vi.fn((_pid: number, fallback: () => void) => fallback()) +})) + +import type { RelayDispatcher } from './dispatcher' +import { PtyHandler } from './pty-handler' +import type { TerminalSessionAuthorityPtyLifecycle } from '../main/session-authority/terminal-session-authority-pty-lifecycle' +import type { TerminalAuthorityExactPtyAccessResolver } from './terminal-authority-exact-pty-access' +import { + authorityAttachParams as attachParams, + authorityLifecycleMock, + authorityPolicyConsumer, + authorityRequestContext as requestContext, + authoritySpawnParams as spawnParams, + createAuthorityDispatcher as createDispatcher, + createAuthorityTerm as createTerm +} from './__tests__/pty-handler-terminal-session-authority-fixture' + +function currentOwnerResolver(): TerminalAuthorityExactPtyAccessResolver { + return { classify: async () => 'current-owner' } +} + +describe('PtyHandler terminal session authority identity and shutdown', () => { + let handlers: PtyHandler[] = [] + + function registerHandler( + handler: PtyHandler, + resolve = (clientId: number) => (clientId === 7 ? authorityPolicyConsumer() : null) + ): void { + handler.setTerminalAuthorityPolicyConsumerForClient(resolve) + handlers.push(handler) + } + + beforeEach(() => { + mockPtySpawn.mockReset() + }) + + afterEach(async () => { + await Promise.allSettled( + handlers.splice(0).map((handler) => handler.dispose({ waitForPhysicalExit: false })) + ) + vi.restoreAllMocks() + }) + + it('requires the exact managed worktree, pane generation, and PTY incarnation', async () => { + const events: string[] = [] + mockPtySpawn.mockReturnValue(createTerm(events)) + const lifecycle = authorityLifecycleMock() + const dispatcher = createDispatcher() + const handler = new PtyHandler(dispatcher as unknown as RelayDispatcher, undefined, { + terminalSessionAuthority: lifecycle as unknown as TerminalSessionAuthorityPtyLifecycle, + terminalAuthorityExactPtyAccessResolver: currentOwnerResolver() + }) + registerHandler(handler) + const spawned = (await dispatcher.callRequest( + 'pty.spawn', + spawnParams(), + requestContext(7) + )) as { + id: string + incarnationId: string + } + + await expect( + dispatcher.callRequest( + 'pty.attach', + attachParams(spawned.id, spawned.incarnationId), + requestContext(7) + ) + ).resolves.toMatchObject({ incarnationId: spawned.incarnationId }) + for (const stale of [ + { expectedWorktreeId: 'repo::/srv/other' }, + { expectedPaneGeneration: 2 }, + { expectedPtyIncarnationId: 'stale-incarnation' } + ]) { + await expect( + dispatcher.callRequest( + 'pty.attach', + attachParams(spawned.id, spawned.incarnationId, stale), + requestContext(7) + ) + ).rejects.toThrow('authority identity mismatch') + } + await expect( + dispatcher.callRequest('pty.attach', { id: spawned.id }, requestContext(7)) + ).rejects.toThrow('attach_identity_required') + }) + + it('accepts renderer pane generation zero as an exact identity', async () => { + const events: string[] = [] + mockPtySpawn.mockReturnValue(createTerm(events)) + const lifecycle = authorityLifecycleMock(0) + const dispatcher = createDispatcher() + const handler = new PtyHandler(dispatcher as unknown as RelayDispatcher, undefined, { + terminalSessionAuthority: lifecycle as unknown as TerminalSessionAuthorityPtyLifecycle, + terminalAuthorityExactPtyAccessResolver: currentOwnerResolver() + }) + registerHandler(handler) + const spawned = (await dispatcher.callRequest( + 'pty.spawn', + spawnParams(0), + requestContext(7) + )) as { + id: string + incarnationId: string + } + + await expect( + dispatcher.callRequest( + 'pty.attach', + attachParams(spawned.id, spawned.incarnationId, { expectedPaneGeneration: 0 }), + requestContext(7) + ) + ).resolves.toMatchObject({ incarnationId: spawned.incarnationId }) + }) + + it('keeps a lost authority spawn response adoptable without killing its PTY', async () => { + const events: string[] = [] + const term = createTerm(events) + mockPtySpawn.mockReturnValue(term) + const lifecycle = authorityLifecycleMock() + const dispatcher = createDispatcher() + const handler = new PtyHandler(dispatcher as unknown as RelayDispatcher, undefined, { + terminalSessionAuthority: lifecycle as unknown as TerminalSessionAuthorityPtyLifecycle, + terminalAuthorityExactPtyAccessResolver: currentOwnerResolver() + }) + registerHandler(handler) + const spawned = (await dispatcher.callRequest('pty.spawn', spawnParams(), { + clientId: 7, + isStale: () => mockPtySpawn.mock.calls.length > 0 + })) as { id: string; incarnationId: string } + const committed = await lifecycle.commitSpawn.mock.results[0].value + ;( + lifecycle.prepareSpawn as unknown as { + mockResolvedValueOnce: (value: unknown) => void + } + ).mockResolvedValueOnce({ + kind: 'adopt', + runtime: committed.runtime, + pane: committed.pane, + binding: committed.binding + }) + + const adopted = await dispatcher.callRequest('pty.spawn', spawnParams(), requestContext(7)) + + expect(adopted).toMatchObject(spawned) + expect(mockPtySpawn).toHaveBeenCalledOnce() + expect(term.kill).not.toHaveBeenCalled() + expect(lifecycle.closePty).not.toHaveBeenCalled() + }) + + it('records exit before retiring an authority binding during shutdown', async () => { + const events: string[] = [] + const term = createTerm(events) + mockPtySpawn.mockReturnValue(term) + const lifecycle = authorityLifecycleMock() + const dispatcher = createDispatcher() + const handler = new PtyHandler(dispatcher as unknown as RelayDispatcher, undefined, { + terminalSessionAuthority: lifecycle as unknown as TerminalSessionAuthorityPtyLifecycle, + terminalAuthorityExactPtyAccessResolver: currentOwnerResolver() + }) + registerHandler(handler) + const spawned = (await dispatcher.callRequest( + 'pty.spawn', + spawnParams(), + requestContext(7) + )) as { id: string } + + const shutdown = dispatcher.callRequest('pty.shutdown', { + id: spawned.id, + immediate: true + }) + expect(lifecycle.closePty).not.toHaveBeenCalled() + const exitListener = term.onExit.mock.calls.at(-1)?.[0] as + | ((event: { exitCode: number }) => void) + | undefined + exitListener?.({ exitCode: 137 }) + await shutdown + + expect(lifecycle.recordExit).toHaveBeenCalledWith(expect.anything(), 137) + expect(lifecycle.closePty).not.toHaveBeenCalled() + }) +}) diff --git a/src/relay/pty-handler-terminal-session-authority-outcome-order.test.ts b/src/relay/pty-handler-terminal-session-authority-outcome-order.test.ts new file mode 100644 index 00000000000..b6128afc737 --- /dev/null +++ b/src/relay/pty-handler-terminal-session-authority-outcome-order.test.ts @@ -0,0 +1,112 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +const { mockPtySpawn } = vi.hoisted(() => ({ mockPtySpawn: vi.fn() })) + +vi.mock('node-pty', () => ({ spawn: mockPtySpawn })) +vi.mock('../main/pty/posix-pty-process-groups', () => ({ + forceKillPosixPtyProcessGroups: vi.fn((_pid: number, fallback: () => void) => fallback()) +})) + +import type { TerminalSessionAuthorityPtyLifecycle } from '../main/session-authority/terminal-session-authority-pty-lifecycle' +import type { TerminalAuthorityExactPtyAccessResolver } from './terminal-authority-exact-pty-access' +import type { RelayDispatcher } from './dispatcher' +import { PtyHandler } from './pty-handler' +import { + authorityLifecycleMock, + authorityPolicyConsumer, + authorityRequestContext, + authoritySpawnParams, + createAuthorityDispatcher, + createAuthorityTerm, + terminalExitOutcome +} from './__tests__/pty-handler-terminal-session-authority-fixture' + +describe('PtyHandler authority outcome ordering', () => { + let handlers: PtyHandler[] = [] + + afterEach(async () => { + await Promise.allSettled( + handlers.splice(0).map((handler) => handler.dispose({ waitForPhysicalExit: false })) + ) + vi.restoreAllMocks() + }) + + it('orders final output before a capability-gated authority outcome exit', async () => { + const events: string[] = [] + const term = createAuthorityTerm(events) + mockPtySpawn.mockReturnValue(term) + const lifecycle = authorityLifecycleMock() + let keepOutcomePending!: () => void + lifecycle.recordExit.mockImplementation( + async () => await new Promise((resolve) => (keepOutcomePending = resolve)) + ) + const namespace = { authorityHostId: 'host-a', namespaceId: 'namespace-a' } + lifecycle.commitSpawn.mockImplementation(async (_prepared, incarnationId: string) => ({ + runtime: { service: { namespace } }, + pane: { paneKey: 'pane-a', paneGenerationId: 'renderer:3' }, + binding: { + ownerIncarnationId: 'owner-a', + physicalPtyId: 'pty-1', + ptyIncarnationId: incarnationId + } + })) + const dispatcher = createAuthorityDispatcher() + const handler = new PtyHandler(dispatcher as unknown as RelayDispatcher, undefined, { + terminalSessionAuthority: lifecycle as unknown as TerminalSessionAuthorityPtyLifecycle, + terminalAuthorityExactPtyAccessResolver: { + classify: async () => 'current-owner' + } as TerminalAuthorityExactPtyAccessResolver + }) + handlers.push(handler) + handler.setTerminalAuthorityPolicyConsumerForClient(() => authorityPolicyConsumer()) + const spawned = (await dispatcher.callRequest( + 'pty.spawn', + authoritySpawnParams(), + authorityRequestContext(1) + )) as { + id: string + incarnationId: string + } + const dataListener = term.onData.mock.calls.at(-1)?.[0] as ((data: string) => void) | undefined + dataListener?.('last output') + const exitListener = term.onExit.mock.calls.at(-1)?.[0] as + | ((event: { exitCode: number }) => void) + | undefined + exitListener?.({ exitCode: 7 }) + await vi.waitFor(() => expect(lifecycle.recordExit).toHaveBeenCalledOnce()) + const authorityOutcome = terminalExitOutcome(spawned.incarnationId) + const effect = authorityOutcome.result.effects.find( + (candidate) => candidate.kind === 'terminal-exited' + )! + const attempt = { + identity: { + version: 1 as const, + namespace, + pane: { paneKey: 'pane-a', paneGenerationId: 'renderer:3' }, + binding: effect.binding, + consumerId: authorityOutcome.consumerId, + outcomeId: authorityOutcome.outcomeId, + sequence: authorityOutcome.sequence + }, + supportsClient: () => true, + markPublished: vi.fn(), + markOrderedComplete: vi.fn() + } + + expect(handler.publishTerminalSessionAuthorityOutcome(authorityOutcome, effect, attempt)).toBe( + true + ) + expect(dispatcher.notify.mock.calls.map(([method]) => method)).toEqual(['pty.data', 'pty.exit']) + expect(dispatcher.notify).toHaveBeenLastCalledWith( + 'pty.exit', + expect.objectContaining({ + id: spawned.id, + incarnationId: spawned.incarnationId, + authorityOutcome: expect.objectContaining({ outcomeId: authorityOutcome.outcomeId }) + }) + ) + expect(attempt.markPublished).toHaveBeenCalledWith([1]) + expect(attempt.markOrderedComplete).toHaveBeenCalledOnce() + keepOutcomePending() + }) +}) diff --git a/src/relay/pty-handler-terminal-session-authority.test.ts b/src/relay/pty-handler-terminal-session-authority.test.ts new file mode 100644 index 00000000000..0c598ba7b36 --- /dev/null +++ b/src/relay/pty-handler-terminal-session-authority.test.ts @@ -0,0 +1,773 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockPtySpawn } = vi.hoisted(() => ({ mockPtySpawn: vi.fn() })) + +vi.mock('node-pty', () => ({ spawn: mockPtySpawn })) +vi.mock('../main/pty/posix-pty-process-groups', () => ({ + forceKillPosixPtyProcessGroups: vi.fn((_pid: number, fallback: () => void) => fallback()) +})) + +import type { RelayDispatcher } from './dispatcher' +import * as ptyShellUtils from './pty-shell-utils' +import { PtyHandler, TERMINAL_SESSION_AUTHORITY_BINDING_UNKNOWN_ERROR } from './pty-handler' +import { TERMINAL_AUTHORITY_EARLY_OUTPUT_MAX_BYTES } from './terminal-authority-early-output-buffer' +import type { + LegacyPhysicalWorkerAttachRouter, + LegacyPhysicalWorkerPtyRouter +} from './legacy-physical-worker-attach-router' +import type { TerminalSessionAuthorityPtyLifecycle } from '../main/session-authority/terminal-session-authority-pty-lifecycle' +import type { TerminalAuthorityExactPtyAccessResolver } from './terminal-authority-exact-pty-access' +import type { TerminalSessionAuthorityPtyAccess } from '../shared/terminal-session-authority-pty-access' +import { + authorityAttachParams as attachParams, + authorityLifecycleMock, + authorityPolicyConsumer, + authorityNamespace, + authorityRequestContext as requestContext, + authoritySpawnParams as spawnParams, + createAuthorityDispatcher as createDispatcher, + createAuthorityTerm as createTerm +} from './__tests__/pty-handler-terminal-session-authority-fixture' + +function currentOwnerResolver(): TerminalAuthorityExactPtyAccessResolver { + return { classify: async () => 'current-owner' } +} + +describe('PtyHandler terminal session authority boundary', () => { + let handlers: PtyHandler[] = [] + + function registerHandler( + handler: PtyHandler, + resolve = (clientId: number) => (clientId === 7 ? authorityPolicyConsumer() : null) + ): void { + handler.setTerminalAuthorityPolicyConsumerForClient(resolve) + handlers.push(handler) + } + + beforeEach(() => { + mockPtySpawn.mockReset() + }) + + afterEach(async () => { + await Promise.allSettled( + handlers.splice(0).map((handler) => handler.dispose({ waitForPhysicalExit: false })) + ) + vi.restoreAllMocks() + }) + + it('durably prepares before native spawn and commits the exact incarnation before resume', async () => { + const events: string[] = [] + const term = createTerm(events) + mockPtySpawn.mockImplementation(() => { + events.push('native-spawn') + return term + }) + const binding = { + ownerIncarnationId: 'owner-a', + physicalPtyId: 'pty-1', + ptyIncarnationId: 'incarnation-from-commit' + } + const lifecycle = { + prepareSpawn: vi.fn(async () => { + events.push('durable-prepare') + return { kind: 'spawn', runtime: {}, pane: {}, allocation: {} } + }), + commitSpawn: vi.fn(async (_prepared, incarnationId: string) => { + events.push('durable-commit') + const earlyData = term.onData.mock.calls[0]?.[0] as ((data: string) => void) | undefined + earlyData?.('boot output\n') + binding.ptyIncarnationId = incarnationId + return { + runtime: { service: { namespace: authorityNamespace } }, + pane: { paneKey: 'pane-a', paneGenerationId: 'renderer:3' }, + binding + } + }), + cancelSpawn: vi.fn(), + closePty: vi.fn(async () => {}), + recordExit: vi.fn(async () => {}), + bindingIsReachable: vi.fn(() => true) + } + const dispatcher = createDispatcher() + const handler = new PtyHandler(dispatcher as unknown as RelayDispatcher, undefined, { + terminalSessionAuthority: lifecycle as unknown as TerminalSessionAuthorityPtyLifecycle, + terminalAuthorityExactPtyAccessResolver: currentOwnerResolver() + }) + registerHandler(handler) + + const result = (await dispatcher.callRequest( + 'pty.spawn', + spawnParams(), + requestContext(7) + )) as { + id: string + incarnationId: string + } + + expect(events).toEqual([ + 'durable-prepare', + 'native-spawn', + 'pause', + 'exit-listener', + 'durable-commit', + 'exit-listener', + 'resume' + ]) + expect(lifecycle.commitSpawn).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'spawn' }), + result.incarnationId + ) + expect(binding).toMatchObject({ + physicalPtyId: result.id, + ptyIncarnationId: result.incarnationId + }) + await dispatcher.callRequest( + 'pty.attach', + attachParams(result.id, result.incarnationId), + requestContext(7) + ) + expect(dispatcher.notify).toHaveBeenCalledWith('pty.replay', { + id: result.id, + data: 'boot output\n' + }) + + dispatcher.callNotification('pty.data', { id: result.id, data: 'echo ok\n' }) + dispatcher.callNotification('pty.resize', { id: result.id, cols: 120, rows: 40 }) + expect(term.write).toHaveBeenCalledWith('echo ok\n') + expect(term.resize).toHaveBeenCalledWith(120, 40) + expect(lifecycle.prepareSpawn).toHaveBeenCalledOnce() + expect(lifecycle.commitSpawn).toHaveBeenCalledOnce() + }) + + it('fences creation and invokes fatal handling when commit fails after native spawn', async () => { + const events: string[] = [] + mockPtySpawn.mockImplementation(() => { + events.push('native-spawn') + return createTerm(events) + }) + const failure = new Error('simulated commit fsync failure') + const lifecycle = { + prepareSpawn: vi.fn(async () => ({ + kind: 'spawn', + runtime: { service: { namespace: authorityNamespace } }, + pane: {}, + allocation: {} + })), + commitSpawn: vi.fn(async () => { + throw failure + }), + cancelSpawn: vi.fn(), + closePty: vi.fn(), + recordExit: vi.fn(), + bindingIsReachable: vi.fn(() => true) + } + const onFailure = vi.fn() + const dispatcher = createDispatcher() + const handler = new PtyHandler(dispatcher as unknown as RelayDispatcher, undefined, { + terminalSessionAuthority: lifecycle as unknown as TerminalSessionAuthorityPtyLifecycle, + terminalAuthorityExactPtyAccessResolver: currentOwnerResolver(), + onTerminalSessionAuthorityFailure: onFailure + }) + registerHandler(handler) + + await expect( + dispatcher.callRequest('pty.spawn', spawnParams(), requestContext(7)) + ).rejects.toThrow(failure.message) + expect(mockPtySpawn).toHaveBeenCalledOnce() + expect(onFailure).toHaveBeenCalledWith(failure) + await expect( + dispatcher.callRequest('pty.spawn', spawnParams(), requestContext(7)) + ).rejects.toThrow('PTY handler is shutting down') + expect(mockPtySpawn).toHaveBeenCalledOnce() + }) + + it('fails closed when pre-commit PTY output exceeds its bound', async () => { + const events: string[] = [] + const term = createTerm(events) + mockPtySpawn.mockReturnValue(term) + const lifecycle = authorityLifecycleMock() + lifecycle.commitSpawn.mockImplementation(async (_prepared, incarnationId: string) => { + const earlyData = term.onData.mock.calls[0]?.[0] as ((data: string) => void) | undefined + earlyData?.('x'.repeat(TERMINAL_AUTHORITY_EARLY_OUTPUT_MAX_BYTES + 1)) + return { + runtime: { service: { namespace: authorityNamespace } }, + pane: { paneKey: 'pane-a', paneGenerationId: 'renderer:3' }, + binding: { + ownerIncarnationId: 'owner-a', + physicalPtyId: 'pty-1', + ptyIncarnationId: incarnationId + } + } + }) + const onFailure = vi.fn() + const dispatcher = createDispatcher() + const handler = new PtyHandler(dispatcher as unknown as RelayDispatcher, undefined, { + terminalSessionAuthority: lifecycle as unknown as TerminalSessionAuthorityPtyLifecycle, + terminalAuthorityExactPtyAccessResolver: currentOwnerResolver(), + onTerminalSessionAuthorityFailure: onFailure + }) + registerHandler(handler) + + await expect( + dispatcher.callRequest('pty.spawn', spawnParams(), requestContext(7)) + ).rejects.toThrow('terminal_session_authority_early_output_capacity_exceeded') + expect(onFailure).toHaveBeenCalledOnce() + expect(term.resume).not.toHaveBeenCalled() + await expect( + dispatcher.callRequest('pty.spawn', spawnParams(), requestContext(7)) + ).rejects.toThrow('PTY handler is shutting down') + }) + + it('does not turn an absent legacy authority binding into PTY death', async () => { + const dispatcher = createDispatcher() + const lifecycle = { + missingPtyState: vi.fn(async () => ({ kind: 'unknown' as const })) + } + const handler = new PtyHandler(dispatcher as unknown as RelayDispatcher, undefined, { + terminalSessionAuthority: lifecycle as unknown as TerminalSessionAuthorityPtyLifecycle, + terminalAuthorityExactPtyAccessResolver: currentOwnerResolver() + }) + registerHandler(handler) + + const params = attachParams('legacy-pty', 'legacy-incarnation') + await expect(dispatcher.callRequest('pty.attach', params, requestContext(7))).rejects.toThrow( + TERMINAL_SESSION_AUTHORITY_BINDING_UNKNOWN_ERROR + ) + expect(lifecycle.missingPtyState).toHaveBeenCalledWith(params, 'legacy-pty') + }) + + it('reports not-found only after the durable authority proves retirement', async () => { + const dispatcher = createDispatcher() + const lifecycle = { + missingPtyState: vi.fn(async () => ({ kind: 'retired' as const })) + } + const handler = new PtyHandler(dispatcher as unknown as RelayDispatcher, undefined, { + terminalSessionAuthority: lifecycle as unknown as TerminalSessionAuthorityPtyLifecycle, + terminalAuthorityExactPtyAccessResolver: currentOwnerResolver() + }) + registerHandler(handler) + + await expect( + dispatcher.callRequest( + 'pty.attach', + attachParams('retired-pty', 'retired-incarnation'), + requestContext(7) + ) + ).rejects.toThrow('PTY "retired-pty" not found') + }) + + it('keeps an unreachable imported predecessor distinct from PTY death', async () => { + const dispatcher = createDispatcher() + const lifecycle = { + missingPtyState: vi.fn(async () => ({ + kind: 'unreachable-predecessor' as const, + pane: { paneKey: 'pane-a', paneGenerationId: 'renderer:3' }, + binding: { + ownerIncarnationId: 'legacy-owner', + physicalPtyId: 'legacy-pty', + ptyIncarnationId: 'legacy-incarnation' + } + })) + } + const handler = new PtyHandler(dispatcher as unknown as RelayDispatcher, undefined, { + terminalSessionAuthority: lifecycle as unknown as TerminalSessionAuthorityPtyLifecycle, + terminalAuthorityExactPtyAccessResolver: currentOwnerResolver() + }) + registerHandler(handler) + + await expect( + dispatcher.callRequest( + 'pty.attach', + attachParams('legacy-pty', 'legacy-incarnation'), + requestContext(7) + ) + ).rejects.toThrow('terminal_session_authority_binding_unreachable') + }) + + it('routes an imported reachable binding with its exact owner and renderer generation', async () => { + const dispatcher = createDispatcher() + const binding = { + ownerIncarnationId: 'legacy-owner', + physicalPtyId: 'legacy-pty', + ptyIncarnationId: 'legacy-incarnation' + } + const lifecycle = { + missingPtyState: vi.fn(async () => ({ + kind: 'reachable-record' as const, + pane: { paneKey: 'pane-a', paneGenerationId: 'renderer:3' }, + binding + })) + } + const router = { + attachReachablePty: vi.fn(async () => ({ incarnationId: binding.ptyIncarnationId })) + } + const handler = new PtyHandler(dispatcher as unknown as RelayDispatcher, undefined, { + terminalSessionAuthority: lifecycle as unknown as TerminalSessionAuthorityPtyLifecycle, + terminalAuthorityExactPtyAccessResolver: currentOwnerResolver(), + legacyPhysicalWorkerAttachRouter: router as LegacyPhysicalWorkerAttachRouter + }) + registerHandler(handler) + + await expect( + dispatcher.callRequest( + 'pty.attach', + attachParams(binding.physicalPtyId, binding.ptyIncarnationId), + requestContext(7) + ) + ).resolves.toEqual({ incarnationId: binding.ptyIncarnationId }) + expect(router.attachReachablePty).toHaveBeenCalledWith( + expect.objectContaining({ + pane: { paneKey: 'pane-a', paneGenerationId: 'renderer:3' }, + binding, + worktreeId: 'repo::/srv/repo' + }) + ) + }) + + it('publishes authority access only to a client with the additive grant', async () => { + const events: string[] = [] + mockPtySpawn.mockReturnValue(createTerm(events)) + const lifecycle = authorityLifecycleMock() + const resolver = { + classify: vi.fn(async () => 'current-owner' as const) + } + const dispatcher = createDispatcher() + const handler = new PtyHandler(dispatcher as unknown as RelayDispatcher, undefined, { + terminalSessionAuthority: lifecycle as unknown as TerminalSessionAuthorityPtyLifecycle, + terminalAuthorityExactPtyAccessResolver: resolver as TerminalAuthorityExactPtyAccessResolver + }) + const policyConsumer = authorityPolicyConsumer() + registerHandler(handler, (clientId) => (clientId === 7 ? policyConsumer : null)) + + const spawned = (await dispatcher.callRequest( + 'pty.spawn', + spawnParams(), + requestContext(7) + )) as Record + expect(spawned.terminalSessionAuthorityAccess).toMatchObject({ + namespace: authorityNamespace, + pane: { paneKey: 'pane-a', paneGenerationId: 'renderer:3' }, + binding: { + physicalPtyId: spawned.id, + ptyIncarnationId: spawned.incarnationId + } + }) + + const grantedInventory = (await dispatcher.callRequest( + 'pty.listProcesses', + {}, + requestContext(7) + )) as Record[] + const legacyInventory = (await dispatcher.callRequest( + 'pty.listProcesses', + {}, + requestContext(8) + )) as Record[] + expect(grantedInventory[0].terminalSessionAuthorityAccess).toEqual( + spawned.terminalSessionAuthorityAccess + ) + expect(legacyInventory[0].terminalSessionAuthorityAccess).toBeUndefined() + }) + + it('applies all five mutations only to the exact current authority binding', async () => { + const events: string[] = [] + const term = createTerm(events) + mockPtySpawn.mockReturnValue(term) + const lifecycle = authorityLifecycleMock() + let closed = false + lifecycle.closePty.mockImplementation(async () => { + closed = true + }) + lifecycle.bindingIsReachable.mockImplementation(() => !closed) + const resolver = { + classify: vi.fn(async () => 'current-owner-closed' as const) + } + const onFailure = vi.fn() + const dispatcher = createDispatcher() + const handler = new PtyHandler(dispatcher as unknown as RelayDispatcher, undefined, { + terminalSessionAuthority: lifecycle as unknown as TerminalSessionAuthorityPtyLifecycle, + terminalAuthorityExactPtyAccessResolver: resolver as TerminalAuthorityExactPtyAccessResolver, + onTerminalSessionAuthorityFailure: onFailure + }) + const policyConsumer = authorityPolicyConsumer() + registerHandler(handler, () => policyConsumer) + const spawned = (await dispatcher.callRequest( + 'pty.spawn', + spawnParams(), + requestContext(7) + )) as Record + const exact = { + id: spawned.id, + terminalSessionAuthorityAccess: spawned.terminalSessionAuthorityAccess + } + + dispatcher.callNotification( + 'pty.dataAuthorityExact', + { ...exact, data: 'input' }, + requestContext(7) + ) + dispatcher.callNotification( + 'pty.resizeAuthorityExact', + { ...exact, cols: 120, rows: 40 }, + requestContext(7) + ) + await expect( + dispatcher.callRequest( + 'pty.sendSignalAuthorityExact', + { ...exact, signal: 'SIGINT' }, + requestContext(7) + ) + ).resolves.toEqual({ accepted: true }) + await expect( + dispatcher.callRequest('pty.clearBufferAuthorityExact', exact, requestContext(7)) + ).resolves.toEqual({ accepted: true }) + + expect(term.write).toHaveBeenCalledWith('input') + expect(term.resize).toHaveBeenCalledWith(120, 40) + expect(term.kill).toHaveBeenCalledWith('SIGINT') + expect(term.clear).toHaveBeenCalledOnce() + + const stale = { + ...exact, + terminalSessionAuthorityAccess: { + ...(spawned.terminalSessionAuthorityAccess as Record), + pane: { paneKey: 'pane-a', paneGenerationId: 'renderer:4' } + } + } + dispatcher.callNotification( + 'pty.dataAuthorityExact', + { ...stale, data: 'stale' }, + requestContext(7) + ) + await expect( + dispatcher.callRequest( + 'pty.sendSignalAuthorityExact', + { ...stale, signal: 'SIGTERM' }, + requestContext(7) + ) + ).resolves.toEqual({ accepted: false }) + expect(term.write).toHaveBeenCalledOnce() + expect(term.kill).toHaveBeenCalledOnce() + + dispatcher.callNotification('pty.data', { id: spawned.id, data: 'legacy' }, requestContext(7)) + dispatcher.callNotification( + 'pty.dataExact', + { id: spawned.id, incarnationId: spawned.incarnationId, data: 'incarnation-only' }, + requestContext(7) + ) + await expect( + dispatcher.callRequest( + 'pty.sendSignalExact', + { id: spawned.id, incarnationId: spawned.incarnationId, signal: 'SIGTERM' }, + requestContext(7) + ) + ).rejects.toThrow('terminal_authority_incarnation_mutation_rejected') + await expect( + dispatcher.callRequest('pty.shutdown', { id: spawned.id, immediate: true }, requestContext(7)) + ).rejects.toThrow('terminal_authority_legacy_mutation_rejected') + expect(term.write).toHaveBeenCalledOnce() + expect(term.kill).toHaveBeenCalledOnce() + + await expect( + dispatcher.callRequest( + 'pty.shutdownAuthorityExact', + { + ...exact, + immediate: false, + keepHistory: true + }, + requestContext(7) + ) + ).resolves.toEqual({ accepted: true }) + await expect( + dispatcher.callRequest( + 'pty.shutdownAuthorityExact', + { + ...exact, + immediate: false, + keepHistory: true + }, + requestContext(7) + ) + ).resolves.toEqual({ accepted: true }) + expect(term.kill).toHaveBeenCalledWith('SIGTERM') + expect(term.kill).toHaveBeenCalledTimes(2) + expect(lifecycle.closePty).toHaveBeenCalledTimes(2) + expect(lifecycle.closePty.mock.invocationCallOrder[0]).toBeLessThan( + term.kill.mock.invocationCallOrder[1] + ) + expect(resolver.classify).toHaveBeenCalledOnce() + expect(onFailure).not.toHaveBeenCalled() + }) + + it('re-ensures a durably retired current binding without closing topology again', async () => { + const events: string[] = [] + const term = createTerm(events) + mockPtySpawn.mockReturnValue(term) + const lifecycle = authorityLifecycleMock() + const resolver = { classify: vi.fn(async () => 'exited' as const) } + const dispatcher = createDispatcher() + const handler = new PtyHandler(dispatcher as unknown as RelayDispatcher, undefined, { + terminalSessionAuthority: lifecycle as unknown as TerminalSessionAuthorityPtyLifecycle, + terminalAuthorityExactPtyAccessResolver: resolver as TerminalAuthorityExactPtyAccessResolver + }) + registerHandler(handler) + const spawned = (await dispatcher.callRequest( + 'pty.spawn', + spawnParams(), + requestContext(7) + )) as Record + const access: TerminalSessionAuthorityPtyAccess = { + namespace: authorityNamespace, + pane: { paneKey: 'pane-a', paneGenerationId: 'renderer:3' }, + binding: { + ownerIncarnationId: 'owner-a', + physicalPtyId: String(spawned.id), + ptyIncarnationId: String(spawned.incarnationId) + } + } + const exit = term.onExit.mock.calls.at(-1)?.[0] as + | ((event: { exitCode: number }) => void) + | undefined + const kill = term.kill + kill.mockImplementation(() => exit?.({ exitCode: 137 })) + + await expect( + handler.ensureTerminalSessionAuthorityBindingRetired(access, 'close') + ).resolves.toBeUndefined() + await expect( + handler.ensureTerminalSessionAuthorityBindingRetired(access, 'close') + ).resolves.toBeUndefined() + + expect(kill).toHaveBeenCalledWith('SIGKILL') + expect(lifecycle.closePty).not.toHaveBeenCalled() + expect(lifecycle.recordExit).toHaveBeenCalledWith(expect.anything(), 137) + }) + + it('never retargets a retired effect at a successor reusing the physical ID', async () => { + const events: string[] = [] + const term = createTerm(events) + mockPtySpawn.mockReturnValue(term) + const lifecycle = authorityLifecycleMock() + const resolver = { classify: vi.fn(async () => 'current-owner-closed' as const) } + const dispatcher = createDispatcher() + const handler = new PtyHandler(dispatcher as unknown as RelayDispatcher, undefined, { + terminalSessionAuthority: lifecycle as unknown as TerminalSessionAuthorityPtyLifecycle, + terminalAuthorityExactPtyAccessResolver: resolver as TerminalAuthorityExactPtyAccessResolver + }) + registerHandler(handler) + const spawned = (await dispatcher.callRequest( + 'pty.spawn', + spawnParams(), + requestContext(7) + )) as Record + const retired: TerminalSessionAuthorityPtyAccess = { + namespace: authorityNamespace, + pane: { paneKey: 'pane-a', paneGenerationId: 'renderer:2' }, + binding: { + ownerIncarnationId: 'owner-a', + physicalPtyId: String(spawned.id), + ptyIncarnationId: 'retired-incarnation' + } + } + + await expect( + handler.ensureTerminalSessionAuthorityBindingRetired(retired, 'close') + ).resolves.toBeUndefined() + expect(term.kill).not.toHaveBeenCalled() + expect(resolver.classify).toHaveBeenCalledWith(retired) + }) + + it('replays an imported retirement only through the captured exact worker route', async () => { + const dispatcher = createDispatcher() + const lifecycle = authorityLifecycleMock() + const access: TerminalSessionAuthorityPtyAccess = { + namespace: authorityNamespace, + pane: { paneKey: 'pane-a', paneGenerationId: 'renderer:3' }, + binding: { + ownerIncarnationId: 'legacy-owner', + physicalPtyId: 'legacy-pty', + ptyIncarnationId: 'legacy-incarnation' + } + } + const ensureAuthorityShutdown = vi.fn(async () => true) + const router = { + attachReachablePty: vi.fn(), + dispatchMutation: vi.fn(), + dispatchAuthorityMutation: vi.fn(), + dispatchAuthorityShutdown: vi.fn(), + ensureAuthorityShutdown, + setDeliveryPaused: vi.fn(() => false), + handleDownstreamCredit: vi.fn(() => false), + reservesPhysicalPtyId: vi.fn(() => false), + reservesPublicPtyIdentity: vi.fn(() => false), + dispose: vi.fn() + } + const resolver = { classify: vi.fn(async () => 'imported-owner-closed' as const) } + const handler = new PtyHandler(dispatcher as unknown as RelayDispatcher, undefined, { + terminalSessionAuthority: lifecycle as unknown as TerminalSessionAuthorityPtyLifecycle, + terminalAuthorityExactPtyAccessResolver: resolver as TerminalAuthorityExactPtyAccessResolver, + legacyPhysicalWorkerPtyRouter: router as unknown as LegacyPhysicalWorkerPtyRouter + }) + registerHandler(handler) + + await expect( + handler.ensureTerminalSessionAuthorityBindingRetired(access, 'supersede') + ).resolves.toBeUndefined() + expect(ensureAuthorityShutdown).toHaveBeenCalledWith(access, { + kind: 'shutdown', + immediate: true + }) + + ensureAuthorityShutdown.mockResolvedValue(false) + await expect( + handler.ensureTerminalSessionAuthorityBindingRetired(access, 'supersede') + ).rejects.toThrow('terminal_session_authority_physical_shutdown_pending') + }) + + it('keeps ID-only mutations local while exact imported mutations use the worker route', async () => { + const dispatcher = createDispatcher() + const lifecycle = authorityLifecycleMock() + const access = { + namespace: authorityNamespace, + pane: { paneKey: 'pane-a', paneGenerationId: 'renderer:3' }, + binding: { + ownerIncarnationId: 'legacy-owner', + physicalPtyId: 'legacy-pty', + ptyIncarnationId: 'legacy-incarnation' + } + } + const router = { + attachReachablePty: vi.fn(), + dispatchMutation: vi.fn(async () => true), + dispatchAuthorityMutation: vi.fn(async () => true), + dispatchAuthorityShutdown: vi.fn( + async (_access, _mutation, persistClose: () => Promise) => { + await persistClose() + return true + } + ), + setDeliveryPaused: vi.fn(() => false), + handleDownstreamCredit: vi.fn(() => false), + reservesPhysicalPtyId: vi.fn(() => false), + reservesPublicPtyIdentity: vi.fn(() => false), + dispose: vi.fn() + } + const resolver = { classify: vi.fn(async () => 'imported-owner' as const) } + const handler = new PtyHandler(dispatcher as unknown as RelayDispatcher, undefined, { + terminalSessionAuthority: lifecycle as unknown as TerminalSessionAuthorityPtyLifecycle, + terminalAuthorityExactPtyAccessResolver: resolver as TerminalAuthorityExactPtyAccessResolver, + legacyPhysicalWorkerPtyRouter: router as unknown as LegacyPhysicalWorkerPtyRouter + }) + const policyConsumer = authorityPolicyConsumer() + registerHandler(handler, () => policyConsumer) + + await expect( + dispatcher.callRequest('pty.shutdown', { id: 'legacy-pty', immediate: false }) + ).resolves.toBeUndefined() + expect(router.dispatchMutation).not.toHaveBeenCalled() + + await expect( + dispatcher.callRequest('pty.shutdownExact', { + id: 'legacy-pty', + incarnationId: 'legacy-incarnation', + immediate: true, + keepHistory: true + }) + ).resolves.toEqual({ accepted: true }) + expect(router.dispatchMutation).toHaveBeenCalledWith('legacy-pty', 'legacy-incarnation', { + kind: 'shutdown', + immediate: true, + keepHistory: true + }) + + await expect( + dispatcher.callRequest( + 'pty.shutdownAuthorityExact', + { + id: 'legacy-pty', + terminalSessionAuthorityAccess: access, + immediate: true, + keepHistory: false + }, + requestContext(7) + ) + ).resolves.toEqual({ accepted: true }) + expect(lifecycle.closeExactPtyAccess).toHaveBeenCalledWith(access, policyConsumer) + expect(router.dispatchAuthorityShutdown).toHaveBeenCalledWith( + access, + { + kind: 'shutdown', + immediate: true, + keepHistory: false + }, + expect.any(Function) + ) + }) + + it('durably records an unknown-code exit when attach proves the managed PID is dead', async () => { + const events: string[] = [] + mockPtySpawn.mockReturnValue(createTerm(events)) + const lifecycle = authorityLifecycleMock() + const dispatcher = createDispatcher() + const handler = new PtyHandler(dispatcher as unknown as RelayDispatcher, undefined, { + terminalSessionAuthority: lifecycle as unknown as TerminalSessionAuthorityPtyLifecycle, + terminalAuthorityExactPtyAccessResolver: currentOwnerResolver() + }) + registerHandler(handler) + const spawned = (await dispatcher.callRequest( + 'pty.spawn', + spawnParams(), + requestContext(7) + )) as { + id: string + incarnationId: string + } + vi.spyOn(ptyShellUtils, 'isProcessAlive').mockReturnValue(false) + + await expect( + dispatcher.callRequest( + 'pty.attach', + attachParams(spawned.id, spawned.incarnationId), + requestContext(7) + ) + ).rejects.toThrow(`PTY "${spawned.id}" not found`) + expect(lifecycle.recordExit).toHaveBeenCalledWith(expect.anything(), null) + }) + + it('fences read-only PTY inspection when the durable binding is unreachable', async () => { + const events: string[] = [] + mockPtySpawn.mockReturnValue(createTerm(events)) + const lifecycle = authorityLifecycleMock() + const dispatcher = createDispatcher() + const handler = new PtyHandler(dispatcher as unknown as RelayDispatcher, undefined, { + terminalSessionAuthority: lifecycle as unknown as TerminalSessionAuthorityPtyLifecycle, + terminalAuthorityExactPtyAccessResolver: currentOwnerResolver() + }) + registerHandler(handler) + const spawned = (await dispatcher.callRequest( + 'pty.spawn', + spawnParams(), + requestContext(7) + )) as { id: string } + lifecycle.bindingIsReachable.mockReturnValue(false) + + await expect(dispatcher.callRequest('pty.getSize', { id: spawned.id })).resolves.toBeNull() + await expect(dispatcher.callRequest('pty.getCwd', { id: spawned.id })).rejects.toThrow( + 'not found' + ) + await expect(dispatcher.callRequest('pty.getInitialCwd', { id: spawned.id })).rejects.toThrow( + 'not found' + ) + await expect(dispatcher.callRequest('pty.hasChildProcesses', { id: spawned.id })).resolves.toBe( + false + ) + await expect( + dispatcher.callRequest('pty.getForegroundProcess', { id: spawned.id }) + ).resolves.toBeNull() + await expect(dispatcher.callRequest('pty.inspectProcess', { id: spawned.id })).rejects.toThrow( + 'terminal_gone' + ) + await expect(dispatcher.callRequest('pty.serialize', { ids: [spawned.id] })).resolves.toBe('[]') + }) +}) diff --git a/src/relay/pty-handler.test.ts b/src/relay/pty-handler.test.ts index 35d11461c53..ab9504c3105 100644 --- a/src/relay/pty-handler.test.ts +++ b/src/relay/pty-handler.test.ts @@ -163,10 +163,13 @@ describe('PtyHandler', () => { expect(methods).toContain('pty.spawn') expect(methods).toContain('pty.attach') expect(methods).toContain('pty.shutdown') + expect(methods).toContain('pty.shutdownExact') expect(methods).toContain('pty.sendSignal') + expect(methods).toContain('pty.sendSignalExact') expect(methods).toContain('pty.getCwd') expect(methods).toContain('pty.getInitialCwd') expect(methods).toContain('pty.clearBuffer') + expect(methods).toContain('pty.clearBufferExact') expect(methods).toContain('pty.hasChildProcesses') expect(methods).toContain('pty.getForegroundProcess') expect(methods).toContain('pty.inspectProcess') @@ -175,10 +178,76 @@ describe('PtyHandler', () => { const notifMethods = Array.from(dispatcher._notificationHandlers.keys()) expect(notifMethods).toContain('pty.data') + expect(notifMethods).toContain('pty.dataExact') expect(notifMethods).toContain('pty.resize') + expect(notifMethods).toContain('pty.resizeExact') expect(notifMethods).toContain('pty.ackData') }) + it('returns a cached exact-operation capability snapshot', async () => { + const first = await dispatcher.callRequest('pty.getCapabilities') + const second = await dispatcher.callRequest('pty.getCapabilities') + + expect(first).toBe(second) + expect(first).toMatchObject({ exactOperationVersion: 1 }) + }) + + it('fences every exact mutation at the managed incarnation', async () => { + const { id, incarnationId } = await spawnPty() + + dispatcher.callNotification('pty.dataExact', { + id, + incarnationId: 'stale-incarnation', + data: 'stale' + }) + dispatcher.callNotification('pty.resizeExact', { + id, + incarnationId: 'stale-incarnation', + cols: 120, + rows: 40 + }) + expect( + await dispatcher.callRequest('pty.shutdownExact', { + id, + incarnationId: 'stale-incarnation' + }) + ).toEqual({ accepted: false }) + expect( + await dispatcher.callRequest('pty.sendSignalExact', { + id, + incarnationId: 'stale-incarnation', + signal: 'SIGINT' + }) + ).toEqual({ accepted: false }) + expect( + await dispatcher.callRequest('pty.clearBufferExact', { + id, + incarnationId: 'stale-incarnation' + }) + ).toEqual({ accepted: false }) + expect(mockPtyInstance.write).not.toHaveBeenCalled() + expect(mockPtyInstance.resize).not.toHaveBeenCalled() + expect(mockPtyInstance.kill).not.toHaveBeenCalled() + expect(mockPtyInstance.clear).not.toHaveBeenCalled() + + dispatcher.callNotification('pty.dataExact', { id, incarnationId, data: 'current' }) + dispatcher.callNotification('pty.resizeExact', { id, incarnationId, cols: 120, rows: 40 }) + expect( + await dispatcher.callRequest('pty.sendSignalExact', { id, incarnationId, signal: 'SIGINT' }) + ).toEqual({ accepted: true }) + expect(await dispatcher.callRequest('pty.clearBufferExact', { id, incarnationId })).toEqual({ + accepted: true + }) + expect(await dispatcher.callRequest('pty.shutdownExact', { id, incarnationId })).toEqual({ + accepted: true + }) + expect(mockPtyInstance.write).toHaveBeenCalledWith('current') + expect(mockPtyInstance.resize).toHaveBeenCalledWith(120, 40) + expect(mockPtyInstance.kill).toHaveBeenCalledWith('SIGINT') + expect(mockPtyInstance.clear).toHaveBeenCalledOnce() + expect(mockPtyInstance.kill).toHaveBeenCalled() + }) + it('pauses native output at the producer hard water and resumes after retained writes settle', async () => { let onData: ((data: string) => void) | undefined const pause = vi.fn() diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 6e8116158b6..259a36ae327 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -70,6 +70,59 @@ import { type AgentSessionOwnerBinding } from '../shared/agent-session-host-authority' import { createPtySlaveEchoProbe, readPtySlavePath } from '../shared/pty-slave-line-discipline-echo' +import { + matchesPtyExactOperationIdentity, + PTY_EXACT_OPERATION_PROTOCOL_VERSION +} from '../shared/pty-exact-operation-protocol' +import { + TERMINAL_SESSION_AUTHORITY_SPAWN_VERSION, + parseTerminalSessionAuthorityAttachIdentity, + type TerminalSessionAuthorityAttachIdentity +} from '../shared/terminal-session-authority-wire' +import type { + TerminalSessionAuthorityPtyLifecycle, + TerminalAuthorityAdoptedPtySpawn, + TerminalAuthorityManagedPty, + TerminalAuthorityPreparedPtySpawn +} from '../main/session-authority/terminal-session-authority-pty-lifecycle' +import { TerminalAuthorityEarlyOutputBuffer } from './terminal-authority-early-output-buffer' +import type { + LegacyPhysicalWorkerAttachRouter, + LegacyPhysicalWorkerPtyRouter +} from './legacy-physical-worker-attach-router' +import type { LegacyPhysicalWorkerMutation } from './legacy-physical-worker-mutation' +import type { PtySourceDeliveryIdentity } from '../shared/pty-source-credit-contract' +import type { + TerminalAuthorityOutcome, + TerminalSessionAuthorityEffect +} from '../shared/terminal-session-authority-mutation' +import type { TerminalAuthorityOutcomeDeliveryAttempt } from './terminal-session-authority-outcome-delivery' +import type { TerminalSessionAuthorityPtyAccess } from '../shared/terminal-session-authority-pty-access' +import { + parsePtyAuthorityExactOperationRequest, + PTY_CLEAR_BUFFER_AUTHORITY_EXACT_METHOD, + PTY_DATA_AUTHORITY_EXACT_METHOD, + PTY_RESIZE_AUTHORITY_EXACT_METHOD, + PTY_SEND_SIGNAL_AUTHORITY_EXACT_METHOD, + PTY_SHUTDOWN_AUTHORITY_EXACT_METHOD, + type PtyAuthorityExactMutation, + type PtyAuthorityExactOperationRequest +} from '../shared/terminal-authority-exact-operation-protocol' +import { + managedPtyMatchesTerminalAuthorityAccess, + terminalAuthorityAccessForManagedPty, + type TerminalAuthorityExactPtyAccessResolver +} from './terminal-authority-exact-pty-access' +import { RelayHeldProducerPauseRegistry } from './relay-held-producer-pause-registry' +import type { TerminalAuthorityBindingRetiredEffect } from '../main/session-authority/terminal-session-authority-host-effect-applier' +import type { TerminalAuthorityPolicyConsumerConnection } from '../main/session-authority/terminal-session-authority-policy-consumers' + +const BASE_PTY_HANDLER_CAPABILITIES = Object.freeze({ + startupIngressVersion: PTY_STARTUP_INGRESS_VERSION, + agentSessionClaimVersion: AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION, + agentSessionCreateOperationVersion: AGENT_SESSION_CREATE_OPERATION_PROTOCOL_VERSION, + exactOperationVersion: PTY_EXACT_OPERATION_PROTOCOL_VERSION +}) // Why: only Linux compiles node-pty (no prebuilt), so the build-tools remedy is a closable setup gap // there and wrong advice anywhere node-pty ships one. The relay only sees an unloadable binding, never @@ -136,6 +189,8 @@ type ManagedPty = { disposed?: boolean /** True once external cleanup observers have been notified. */ exitListenerNotified?: boolean + exitFinalizationStarted?: boolean + authorityExitCleanupStarted?: boolean /** Renderer-supplied paneKey (ORCA_PANE_KEY); captured so exit observers can evict per-pane cache state. */ paneKey?: string tabId?: string @@ -154,6 +209,7 @@ type ManagedPty = { startupIngressIntent?: ReturnType ownerBackend: PtyOwnerBackend agentSessionOwners?: AgentSessionOwnerBinding[] + authority?: TerminalAuthorityManagedPty } type RelayAgentSessionCreateResult = { @@ -162,6 +218,7 @@ type RelayAgentSessionCreateResult = { replay?: string agentSessionEnsure?: unknown sourceActivation?: PtySourceReceivingActivation + terminalSessionAuthorityAccess?: TerminalSessionAuthorityPtyAccess } const AGENT_SESSION_CREATE_OPERATION_ID_PATTERN = /^[A-Za-z0-9_-]{43}$/ @@ -229,6 +286,9 @@ function disposeManagedPty(managed: ManagedPty): void { const DEFAULT_GRACE_TIME_MS = DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS * 1000 export const IMMEDIATE_PTY_EXIT_TIMEOUT_MS = 8_000 export const MAX_RELAY_PTY_SESSIONS = 50 +export const TERMINAL_AUTHORITY_REQUIRED_ERROR = 'terminal_authority_required' +export const TERMINAL_SESSION_AUTHORITY_BINDING_UNKNOWN_ERROR = + 'terminal_session_authority_binding_unknown' export const REPLAY_BUFFER_MAX = 100 * 1024 const PTY_OUTPUT_BATCH_INTERVAL_MS = 8 const PTY_OUTPUT_DRAIN_CONTINUE_MS = 1 @@ -289,11 +349,13 @@ function resolvePtyShellOverride(shellOverride: string): string { type PtyProcessSummary = { id: string incarnationId: string + processId: number cwd: string title: string worktreeId?: string terminalHandle?: string agentSessionOwners?: AgentSessionOwnerBinding[] + terminalSessionAuthorityAccess?: TerminalSessionAuthorityPtyAccess } type SerializedPtyEntry = { @@ -354,13 +416,29 @@ export type RelayPtyWorktreeRemovalCoordinator = { export class PtyHandler { private ptys = new Map() + private heldProducerPauses = new RelayHeldProducerPauseRegistry({ + resolveIncarnation: (id) => { + const managed = this.ptys.get(id) + return managed && !managed.disposed ? managed.incarnationId : null + }, + pause: (id) => this.pausePtyOutput(id), + resume: (id) => this.maybeResumePtyOutput(id) + }) private nextId = 1 private dispatcher: RelayDispatcher private graceTimeMs: number private graceTimer: ReturnType | null = null private outputFlushTimer: ReturnType | null = null private pendingOutputByPty = new Map() - private pendingExitByPty = new Map() + private pendingExitByPty = new Map< + string, + { + id: string + code: number + incarnationId: string + authorityOutcome?: TerminalAuthorityOutcomeDeliveryAttempt + } + >() private pausedOutputPtys = new Set() private consumerPausedOutputPtys = new Set() private removeLegacyCapacityListener: (() => void) | null = null @@ -387,16 +465,107 @@ export class PtyHandler { string, Promise >() + private readonly terminalSessionAuthority: TerminalSessionAuthorityPtyLifecycle | null + private readonly terminalAuthorityExactPtyAccessResolver: TerminalAuthorityExactPtyAccessResolver | null + private terminalAuthorityPolicyConsumerForClient: + | ((clientId: number) => TerminalAuthorityPolicyConsumerConnection | null) + | null = null + private legacyPhysicalWorkerAttachRouter: LegacyPhysicalWorkerAttachRouter | null + private legacyPhysicalWorkerPtyRouter: LegacyPhysicalWorkerPtyRouter | null + private readonly capabilities: Readonly> + private readonly onTerminalSessionAuthorityFailure: ((error: Error) => void) | null - constructor(dispatcher: RelayDispatcher, graceTimeMs = DEFAULT_GRACE_TIME_MS) { + constructor( + dispatcher: RelayDispatcher, + graceTimeMs = DEFAULT_GRACE_TIME_MS, + options?: { + controlAdapter?: boolean + terminalSessionAuthority?: TerminalSessionAuthorityPtyLifecycle + terminalAuthorityExactPtyAccessResolver?: TerminalAuthorityExactPtyAccessResolver + legacyPhysicalWorkerAttachRouter?: LegacyPhysicalWorkerAttachRouter + legacyPhysicalWorkerPtyRouter?: LegacyPhysicalWorkerPtyRouter + onTerminalSessionAuthorityFailure?: (error: Error) => void + } + ) { this.dispatcher = dispatcher this.graceTimeMs = graceTimeMs - this.registerHandlers() + this.terminalSessionAuthority = options?.terminalSessionAuthority ?? null + this.terminalAuthorityExactPtyAccessResolver = + options?.terminalAuthorityExactPtyAccessResolver ?? null + this.legacyPhysicalWorkerPtyRouter = options?.legacyPhysicalWorkerPtyRouter ?? null + this.legacyPhysicalWorkerAttachRouter = + this.legacyPhysicalWorkerPtyRouter ?? options?.legacyPhysicalWorkerAttachRouter ?? null + this.onTerminalSessionAuthorityFailure = options?.onTerminalSessionAuthorityFailure ?? null + this.capabilities = Object.freeze({ + ...BASE_PTY_HANDLER_CAPABILITIES, + ...(this.terminalSessionAuthority + ? { terminalSessionAuthorityVersion: TERMINAL_SESSION_AUTHORITY_SPAWN_VERSION } + : {}) + }) + if (options?.controlAdapter) { + this.registerControlAdapterHandlers() + } else { + this.registerHandlers() + } this.removeLegacyCapacityListener = this.dispatcher.onLegacyPtyCapacity?.(() => this.handleLegacyCapacity()) ?? null } - setConsumerDeliveryPaused(id: string, paused: boolean): void { + private registerControlAdapterHandlers(): void { + const reject = async (): Promise => { + throw new Error(TERMINAL_AUTHORITY_REQUIRED_ERROR) + } + for (const method of [ + 'pty.spawn', + 'pty.attach', + 'pty.shutdown', + 'pty.shutdownExact', + PTY_SHUTDOWN_AUTHORITY_EXACT_METHOD, + 'pty.sendSignal', + 'pty.sendSignalExact', + PTY_SEND_SIGNAL_AUTHORITY_EXACT_METHOD, + 'pty.getCwd', + 'pty.getInitialCwd', + 'pty.getSize', + 'pty.clearBuffer', + 'pty.clearBufferExact', + PTY_CLEAR_BUFFER_AUTHORITY_EXACT_METHOD, + 'pty.hasChildProcesses', + 'pty.getForegroundProcess', + 'pty.inspectProcess', + 'pty.listProcesses', + 'pty.getDefaultShell', + 'pty.serialize', + 'pty.revive', + 'pty.getProfiles', + 'pty.closeStartupQueryAuthority' + ]) { + this.dispatcher.onRequest(method, reject) + } + this.dispatcher.onRequest('pty.getCapabilities', async () => ({ + terminalAuthorityRequired: true + })) + for (const method of [ + 'pty.data', + 'pty.dataExact', + PTY_DATA_AUTHORITY_EXACT_METHOD, + 'pty.resize', + 'pty.resizeExact', + PTY_RESIZE_AUTHORITY_EXACT_METHOD, + 'pty.ackData' + ]) { + this.dispatcher.onNotification(method, () => {}) + } + } + + setConsumerDeliveryPaused( + id: string, + paused: boolean, + identity?: PtySourceDeliveryIdentity + ): void { + if (identity && this.legacyPhysicalWorkerPtyRouter?.setDeliveryPaused(identity, paused)) { + return + } if (paused) { this.consumerPausedOutputPtys.add(id) this.pausePtyOutput(id) @@ -406,11 +575,52 @@ export class PtyHandler { this.maybeResumePtyOutput(id) } + async setConsumerHeldProducerPause( + id: string, + incarnationId: string, + token: string, + paused: boolean + ): Promise { + if (this.legacyPhysicalWorkerPtyRouter?.reservesPublicPtyIdentity(id, incarnationId)) { + return await this.legacyPhysicalWorkerPtyRouter.setHeldProducerPause( + id, + incarnationId, + token, + paused + ) + } + return this.heldProducerPauses.set(id, incarnationId, token, paused) + } + setSourcePublication(publication: RelayPtySourcePublication): void { this.sourcePublication = publication } - handleSourceCreditAvailable(id: string): void { + setLegacyPhysicalWorkerPtyRouter(router: LegacyPhysicalWorkerPtyRouter): void { + if ( + (this.legacyPhysicalWorkerPtyRouter && this.legacyPhysicalWorkerPtyRouter !== router) || + (this.legacyPhysicalWorkerAttachRouter && this.legacyPhysicalWorkerAttachRouter !== router) + ) { + throw new Error('legacy physical worker router is already configured') + } + this.legacyPhysicalWorkerPtyRouter = router + this.legacyPhysicalWorkerAttachRouter = router + this.terminalSessionAuthority?.requestHostEffectDelivery() + } + + setTerminalAuthorityPolicyConsumerForClient( + resolve: (clientId: number) => TerminalAuthorityPolicyConsumerConnection | null + ): void { + if (this.terminalAuthorityPolicyConsumerForClient) { + throw new Error('terminal authority exact-operation admission is already configured') + } + this.terminalAuthorityPolicyConsumerForClient = resolve + } + + handleSourceCreditAvailable(id: string, identity?: PtySourceDeliveryIdentity): void { + if (identity && this.legacyPhysicalWorkerPtyRouter?.handleDownstreamCredit(identity)) { + return + } this.sourcePublication?.onCreditAvailable(id) } @@ -483,7 +693,7 @@ export class PtyHandler { } async shutdownForWorktreePath(rootPath: string): Promise { - const matchingIds = [...this.ptys.values()] + const matchingPtys = [...this.ptys.values()] .filter((managed) => { const ownedPath = managed.worktreeId ? splitWorktreeId(managed.worktreeId)?.worktreePath @@ -493,8 +703,12 @@ export class PtyHandler { isPathInsideOrEqual(rootPath, managed.initialCwd) ) }) - .map((managed) => managed.id) - await Promise.all(matchingIds.map((id) => this.shutdown({ id, immediate: true }))) + .map(({ id, incarnationId }) => ({ id, incarnationId })) + await Promise.all( + matchingPtys.map(({ id, incarnationId }) => + this.shutdownExact({ id, incarnationId, immediate: true }) + ) + ) } get configuredGraceTimeMs(): number { @@ -506,6 +720,55 @@ export class PtyHandler { this.exitListener = listener } + publishTerminalSessionAuthorityOutcome( + outcome: TerminalAuthorityOutcome, + effect: Extract, + attempt: TerminalAuthorityOutcomeDeliveryAttempt + ): boolean { + const managed = this.ptys.get(effect.binding.physicalPtyId) + if (managed?.authority && managedAuthorityMatchesOutcome(managed.authority, outcome, effect)) { + this.completeManagedPtyExit(managed, effect.code, attempt) + return true + } + return ( + this.legacyPhysicalWorkerPtyRouter?.publishAuthorityOutcome?.(outcome, effect, attempt) ?? + false + ) + } + + async ensureTerminalSessionAuthorityBindingRetired( + access: TerminalSessionAuthorityPtyAccess, + reason: TerminalAuthorityBindingRetiredEffect['reason'] + ): Promise { + if (reason === 'exit') { + return + } + const managed = this.ptys.get(access.binding.physicalPtyId) + if ( + managed && + !managed.disposed && + managed.authority && + managedPtyMatchesTerminalAuthorityAccess(managed.authority, access) + ) { + await this.shutdown({ id: managed.id, immediate: true }) + return + } + const owner = await this.classifyAuthorityExactPty(access) + if (owner === 'exited' || owner === 'current-owner-closed') { + return + } + if (owner !== 'imported-owner-closed') { + throw new Error('terminal_session_authority_retired_binding_is_not_fenced') + } + const accepted = await this.legacyPhysicalWorkerPtyRouter?.ensureAuthorityShutdown?.(access, { + kind: 'shutdown', + immediate: true + }) + if (!accepted) { + throw new Error('terminal_session_authority_physical_shutdown_pending') + } + } + /** Notified when the last PTY leaves the pool, so the relay can re-arm its idle grace. */ onPtyPoolEmpty(listener: () => void): () => void { this.ptyPoolEmptyListener = listener @@ -546,6 +809,7 @@ export class PtyHandler { // Why: the sole removal path, so the three exit routes can't drift on who announces an empty pool. private removePty(id: string): void { + this.heldProducerPauses.clear(id) this.ptys.delete(id) if (this.ptys.size > 0) { return @@ -675,6 +939,9 @@ export class PtyHandler { /** Wire onData/onExit listeners for a managed PTY and store it. */ private wireAndStore(managed: ManagedPty): void { + if (this.terminalSessionAuthority && !managed.authority) { + throw new Error('terminal_session_authority_binding_required') + } managed.physicalExit = new PhysicalExitTracker() this.ptys.set(managed.id, managed) // Why: a second announce covers any store whose admission window has already closed. @@ -698,49 +965,97 @@ export class PtyHandler { onEmission: emitIngressData, ...(echoProbe ? { echoProbe } : {}) }) - managed.pty.onData((data: string) => { - const startup = managed.startupCommand - if (startup?.waitForShellReady && startup.scanState && !startup.delivered) { - const scanned = scanForShellReady(startup.scanState, data) - data = scanned.output - if (scanned.matched) { - this.scheduleStartupCommandDelivery(managed, STARTUP_COMMAND_WRITE_DELAY_MS) - } + managed.pty.onData((data: string) => this.acceptManagedPtyData(managed, data)) + managed.pty.onExit(({ exitCode }: { exitCode: number }) => + this.beginManagedPtyExit(managed, exitCode) + ) + } + + private acceptManagedPtyData(managed: ManagedPty, data: string): void { + if (this.ptys.get(managed.id) !== managed || managed.disposed) { + return + } + const startup = managed.startupCommand + if (startup?.waitForShellReady && startup.scanState && !startup.delivered) { + const scanned = scanForShellReady(startup.scanState, data) + data = scanned.output + if (scanned.matched) { + this.scheduleStartupCommandDelivery(managed, STARTUP_COMMAND_WRITE_DELAY_MS) } - managed.startupIngress?.accept(data) + } + managed.startupIngress?.accept(data) + } + + private beginManagedPtyExit(managed: ManagedPty, exitCode: number): void { + managed.physicalExit?.markExited() + if ( + managed.exitFinalizationStarted || + this.ptys.get(managed.id) !== managed || + managed.disposed + ) { + return + } + managed.exitFinalizationStarted = true + // Why: neutralize pty.kill synchronously so node-pty's 'close' SIGHUP can't hit a recycled pid on POSIX. + if (process.platform !== 'win32') { + ;(managed.pty as unknown as { kill: (sig?: string) => void }).kill = () => {} + } + if (managed.killTimer) { + clearTimeout(managed.killTimer) + managed.killTimer = undefined + } + void this.finalizeManagedPtyExit(managed, exitCode).catch((error) => { + this.failTerminalSessionAuthority(error) }) - managed.pty.onExit(({ exitCode }: { exitCode: number }) => { - managed.physicalExit?.markExited() - if (managed.disposed) { - return - } - // Why: neutralize pty.kill synchronously so node-pty's 'close' SIGHUP can't hit a recycled pid on POSIX. - if (process.platform !== 'win32') { - ;(managed.pty as unknown as { kill: (sig?: string) => void }).kill = () => {} - } - // Why: clear the SIGKILL fallback timer on clean exit so it doesn't fire later. - if (managed.killTimer) { - clearTimeout(managed.killTimer) - managed.killTimer = undefined - } - this.clearStartupCommandTimer(managed) - this.releaseRelayIngress(managed) - this.pausedOutputPtys.delete(managed.id) - this.consumerPausedOutputPtys.delete(managed.id) - this.flushPtyOutput(managed.id) - this.pendingExitByPty.set(managed.id, { - id: managed.id, - code: exitCode, - incarnationId: managed.incarnationId - }) - this.publishPendingExit(managed.id) - this.notifyExitListener(managed) - this.agentSessionOwners.release(managed.id) - this.removePty(managed.id) - this.clearPtyInputState(managed.id) - // Why: release the ptmx fd on natural exit, else the master fd leaks until GC (docs/fix-pty-fd-leak.md). - disposeManagedPty(managed) + } + + private async finalizeManagedPtyExit( + managed: ManagedPty, + exitCode: number | null + ): Promise { + if (managed.authority) { + const recording = this.terminalSessionAuthority!.recordExit(managed.authority, exitCode) + void recording.then( + () => this.completeManagedPtyExit(managed, exitCode), + (error) => this.failTerminalSessionAuthority(error) + ) + return + } else if (this.terminalSessionAuthority) { + throw new Error('terminal_session_authority_binding_required') + } + this.completeManagedPtyExit(managed, exitCode) + } + + private completeManagedPtyExit( + managed: ManagedPty, + exitCode: number | null, + authorityOutcome?: TerminalAuthorityOutcomeDeliveryAttempt + ): void { + if (managed.authorityExitCleanupStarted) { + return + } + if (this.ptys.get(managed.id) !== managed || managed.disposed) { + return + } + managed.authorityExitCleanupStarted = true + this.clearStartupCommandTimer(managed) + this.releaseRelayIngress(managed) + this.pausedOutputPtys.delete(managed.id) + this.consumerPausedOutputPtys.delete(managed.id) + this.flushPtyOutput(managed.id) + this.pendingExitByPty.set(managed.id, { + id: managed.id, + code: exitCode ?? -1, + incarnationId: managed.incarnationId, + ...(authorityOutcome ? { authorityOutcome } : {}) }) + this.publishPendingExit(managed.id) + this.notifyExitListener(managed) + this.agentSessionOwners.release(managed.id) + this.removePty(managed.id) + this.clearPtyInputState(managed.id) + // Why: release the ptmx fd on natural exit, else the master fd leaks until GC (docs/fix-pty-fd-leak.md). + disposeManagedPty(managed) } private releaseRelayIngress(managed: ManagedPty): void { @@ -774,21 +1089,32 @@ export class PtyHandler { private registerHandlers(): void { this.dispatcher.onRequest('pty.spawn', (p, context) => this.spawn(p, context)) this.dispatcher.onRequest('pty.attach', (p, context) => this.attach(p, context)) - this.dispatcher.onRequest('pty.shutdown', (p) => this.shutdown(p)) - this.dispatcher.onRequest('pty.sendSignal', (p) => this.sendSignal(p)) + this.dispatcher.onRequest('pty.shutdown', (p, context) => this.shutdown(p, context)) + this.dispatcher.onRequest(PTY_SHUTDOWN_AUTHORITY_EXACT_METHOD, (p, context) => + this.requestAuthorityExactMutation(PTY_SHUTDOWN_AUTHORITY_EXACT_METHOD, p, context) + ) + this.dispatcher.onRequest(PTY_SEND_SIGNAL_AUTHORITY_EXACT_METHOD, (p, context) => + this.requestAuthorityExactMutation(PTY_SEND_SIGNAL_AUTHORITY_EXACT_METHOD, p, context) + ) + this.dispatcher.onRequest(PTY_CLEAR_BUFFER_AUTHORITY_EXACT_METHOD, (p, context) => + this.requestAuthorityExactMutation(PTY_CLEAR_BUFFER_AUTHORITY_EXACT_METHOD, p, context) + ) + this.dispatcher.onRequest('pty.sendSignal', (p, context) => this.sendSignal(p, context)) + this.dispatcher.onRequest('pty.sendSignalExact', (p, context) => + this.sendSignalExact(p, context) + ) this.dispatcher.onRequest('pty.getCwd', (p) => this.getCwd(p)) this.dispatcher.onRequest('pty.getInitialCwd', (p) => this.getInitialCwd(p)) this.dispatcher.onRequest('pty.getSize', (p) => this.getSize(p)) - this.dispatcher.onRequest('pty.clearBuffer', (p) => this.clearBuffer(p)) + this.dispatcher.onRequest('pty.clearBuffer', (p, context) => this.clearBuffer(p, context)) + this.dispatcher.onRequest('pty.clearBufferExact', (p, context) => + this.clearBufferExact(p, context) + ) this.dispatcher.onRequest('pty.hasChildProcesses', (p) => this.hasChildProcesses(p)) this.dispatcher.onRequest('pty.getForegroundProcess', (p) => this.getForegroundProcess(p)) this.dispatcher.onRequest('pty.inspectProcess', (p) => this.inspectProcess(p)) - this.dispatcher.onRequest('pty.getCapabilities', async () => ({ - startupIngressVersion: PTY_STARTUP_INGRESS_VERSION, - agentSessionClaimVersion: AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION, - agentSessionCreateOperationVersion: AGENT_SESSION_CREATE_OPERATION_PROTOCOL_VERSION - })) - this.dispatcher.onRequest('pty.listProcesses', () => this.listProcesses()) + this.dispatcher.onRequest('pty.getCapabilities', async () => this.capabilities) + this.dispatcher.onRequest('pty.listProcesses', (_p, context) => this.listProcesses(context)) this.dispatcher.onRequest('pty.getDefaultShell', async () => resolveDefaultShell()) this.dispatcher.onRequest('pty.serialize', (p) => this.serialize(p)) this.dispatcher.onRequest('pty.revive', (p) => this.revive(p)) @@ -797,8 +1123,17 @@ export class PtyHandler { this.closeStartupQueryAuthority(p) ) - this.dispatcher.onNotification('pty.data', (p) => this.writeData(p)) - this.dispatcher.onNotification('pty.resize', (p) => this.resize(p)) + this.dispatcher.onNotification('pty.data', (p, context) => this.writeData(p, context)) + this.dispatcher.onNotification('pty.dataExact', (p, context) => this.writeExactData(p, context)) + this.dispatcher.onNotification(PTY_DATA_AUTHORITY_EXACT_METHOD, (p, context) => + this.notifyAuthorityExactMutation(PTY_DATA_AUTHORITY_EXACT_METHOD, p, context) + ) + this.dispatcher.onNotification('pty.resize', (p, context) => this.resize(p, context)) + this.dispatcher.onNotification('pty.resizeExact', (p, context) => this.resizeExact(p, context)) + this.dispatcher.onNotification(PTY_RESIZE_AUTHORITY_EXACT_METHOD, (p, context) => + this.notifyAuthorityExactMutation(PTY_RESIZE_AUTHORITY_EXACT_METHOD, p, context) + ) + this.dispatcher.onRequest('pty.shutdownExact', (p, context) => this.shutdownExact(p, context)) this.dispatcher.onNotification('pty.ackData', (_p) => { /* flow control ack -- not yet enforced */ }) @@ -816,7 +1151,7 @@ export class PtyHandler { ): Promise<{ appliedSeq: number }> { const id = params.id as string const managed = this.ptys.get(id) - if (!managed || managed.disposed) { + if (!managed || managed.disposed || !this.managedAuthorityIsReachable(managed)) { throw new Error(`PTY "${id}" not found`) } return { appliedSeq: managed.startupIngress?.closeQueryAuthority() ?? 0 } @@ -1085,13 +1420,22 @@ export class PtyHandler { return true } - private publishPendingExit(id: string): void { + private publishPendingExit(id: string): boolean { if (this.pendingOutputByPty.has(id)) { - return + return false } const exit = this.pendingExitByPty.get(id) if (!exit) { - return + return false + } + const capableClients = exit.authorityOutcome + ? this.dispatcher.activeClientIds().filter(exit.authorityOutcome.supportsClient) + : [] + const params = { + id: exit.id, + code: exit.code, + incarnationId: exit.incarnationId, + ...(capableClients.length > 0 ? { authorityOutcome: exit.authorityOutcome!.identity } : {}) } if (this.sourcePublication?.accepts(id)) { try { @@ -1099,19 +1443,22 @@ export class PtyHandler { // ledger delivery; the settled state alone decides completion. if (this.sourcePublication.exitPublicationSettled(id)) { this.pendingExitByPty.delete(id) - return + exit.authorityOutcome?.markOrderedComplete() + return true } - if (!this.sourcePublication.sealAndPublishExit(exit)) { - return + if (!this.sourcePublication.sealAndPublishExit(params)) { + return false } + exit.authorityOutcome?.markPublished(capableClients) if ( this.sourcePublication.accepts(id) && !this.sourcePublication.exitPublicationSettled(id) ) { - return + return true } this.pendingExitByPty.delete(id) - return + exit.authorityOutcome?.markOrderedComplete() + return true } catch (err) { // Why: a source-publication fault must never escape onExit — it reaches // uncaughtException and kills the whole relay daemon. Fall back to the legacy exit. @@ -1126,7 +1473,7 @@ export class PtyHandler { // the broadcast below would hand them a second copy. let retiredExitPublished: boolean | null | undefined try { - retiredExitPublished = this.sourcePublication?.publishExitAfterRetire?.(exit) + retiredExitPublished = this.sourcePublication?.publishExitAfterRetire?.(params) } catch (err) { process.stderr.write( `[pty-handler] retired pty exit publication failed for ${id}: ${ @@ -1137,12 +1484,15 @@ export class PtyHandler { const published = retiredExitPublished ?? (this.dispatcher.tryNotifyPtyExit - ? this.dispatcher.tryNotifyPtyExit(exit) - : (this.dispatcher.notify('pty.exit', exit), true)) + ? this.dispatcher.tryNotifyPtyExit(params) + : (this.dispatcher.notify('pty.exit', params), true)) if (!published) { - return + return false } + exit.authorityOutcome?.markPublished(capableClients) + exit.authorityOutcome?.markOrderedComplete() this.pendingExitByPty.delete(id) + return true } private pendingProducerBytes(id: string): number { @@ -1169,6 +1519,7 @@ export class PtyHandler { if ( !this.pausedOutputPtys.has(id) || this.consumerPausedOutputPtys.has(id) || + this.heldProducerPauses.has(id) || this.pendingProducerBytes(id) > PTY_OUTPUT_PRODUCER_LOW_BYTES || this.dispatcher.legacyRetentionBelowLowWater === false ) { @@ -1261,7 +1612,7 @@ export class PtyHandler { ): Promise { const operationId = params.agentSessionCreateOperationId if (operationId === undefined) { - return await this.spawnOnce(params, context) + return this.spawnResultForClient(await this.spawnOnce(params, context), context) } if ( typeof operationId !== 'string' || @@ -1276,7 +1627,10 @@ export class PtyHandler { const sourceActivation = context && this.sourcePublication?.receivingActivation?.(result.id, context.clientId) const { sourceActivation: _staleActivation, ...stableResult } = result - return { ...stableResult, ...(sourceActivation ? { sourceActivation } : {}) } + return this.spawnResultForClient( + { ...stableResult, ...(sourceActivation ? { sourceActivation } : {}) }, + context + ) } if (this.agentSessionCreateOperations.size >= AGENT_SESSION_CREATE_OPERATION_LIMIT) { throw new Error('agent_session_operation_capacity') @@ -1286,7 +1640,7 @@ export class PtyHandler { try { const result = await operation this.expireAgentSessionCreateOperation(operationId, operation) - return result + return this.spawnResultForClient(result, context) } catch (error) { const outcomeUnknown = typeof error === 'object' && @@ -1390,6 +1744,7 @@ export class PtyHandler { id: managed.id, incarnationId: managed.incarnationId, agentSessionEnsure: result, + ...this.managedAuthorityAccess(managed), ...(sourceActivation ? { sourceActivation } : {}), ...(adoptedReplay ? { replay: adoptedReplay } : {}) } @@ -1413,7 +1768,9 @@ export class PtyHandler { ): Promise<{ id: string incarnationId: string + replay?: string sourceActivation?: PtySourceReceivingActivation + terminalSessionAuthorityAccess?: TerminalSessionAuthorityPtyAccess }> { const pty = await this.loadPty() if (!pty) { @@ -1440,7 +1797,11 @@ export class PtyHandler { let id: string do { id = `pty-${this.nextId++}` - } while (this.ptys.has(id) || this.pendingReviveIds.has(id)) + } while ( + this.ptys.has(id) || + this.pendingReviveIds.has(id) || + this.legacyPhysicalWorkerPtyRouter?.reservesPhysicalPtyId(id) + ) // Why: augmenter values override renderer env so remote paths and hook coords win over local userData. const paneKey = typeof env?.ORCA_PANE_KEY === 'string' ? env.ORCA_PANE_KEY : undefined @@ -1483,6 +1844,19 @@ export class PtyHandler { throw new Error('client_disconnected') } + let authorityPreparation: TerminalAuthorityPreparedPtySpawn | null = null + if (this.terminalSessionAuthority) { + const policyConsumer = this.policyConsumerForClient(context) + if (!policyConsumer) { + throw new Error(TERMINAL_AUTHORITY_REQUIRED_ERROR) + } + const authority = await this.terminalSessionAuthority.prepareSpawn(params, id, policyConsumer) + if (authority.kind === 'adopt') { + return this.adoptAuthorityPty(authority, context) + } + authorityPreparation = authority + } + // Why: SSH exec channels give the relay a minimal environment without // .zprofile/.bash_profile sourced. Spawning a login shell ensures PATH // includes Homebrew, nvm, and user-installed CLIs (claude, codex, gh). @@ -1500,6 +1874,14 @@ export class PtyHandler { env: { ...spawnEnv, ORCA_SHELL_READY_MARKER: '0', ...shellLaunch.env } }) } catch (error) { + if (authorityPreparation) { + try { + await this.terminalSessionAuthority!.cancelSpawn(authorityPreparation) + } catch (authorityError) { + this.failTerminalSessionAuthority(authorityError) + throw authorityError + } + } // Why: Windows loads conpty.node only on first spawn, so handle that late binding failure here. if (isMissingNodePtyNativeBinding(error)) { this.invalidatePtyModuleAfterBindingFailure() @@ -1508,6 +1890,35 @@ export class PtyHandler { throw error } onPhysicalSpawnCommitted?.() + const incarnationId = randomUUID() + let authority: TerminalAuthorityManagedPty | undefined + const earlyOutput = new TerminalAuthorityEarlyOutputBuffer() + let earlyExitCode: number | null = null + let earlyDataListener: { dispose: () => void } | null = null + let earlyExitListener: { dispose: () => void } | null = null + if (authorityPreparation) { + try { + term.pause() + earlyDataListener = term.onData((data: string) => { + earlyOutput.append(data) + }) + earlyExitListener = term.onExit(({ exitCode }: { exitCode: number }) => { + earlyExitCode = exitCode + }) + authority = await this.terminalSessionAuthority!.commitSpawn( + authorityPreparation, + incarnationId + ) + if (earlyOutput.exceededCapacity) { + throw new Error('terminal_session_authority_early_output_capacity_exceeded') + } + } catch (error) { + earlyDataListener?.dispose() + earlyExitListener?.dispose() + this.failTerminalSessionAuthority(error) + throw error + } + } // Why: capture paneKey so the exit listener can evict per-pane caches without a separate ptyId→paneKey map. const tabId = typeof env?.ORCA_TAB_ID === 'string' ? env.ORCA_TAB_ID : undefined @@ -1515,14 +1926,19 @@ export class PtyHandler { paneKey: typeof params.paneKey === 'string' ? params.paneKey : paneKey, tabId: typeof params.tabId === 'string' ? params.tabId : tabId } - const worktreeId = typeof env?.ORCA_WORKTREE_ID === 'string' ? env.ORCA_WORKTREE_ID : undefined + const worktreeId = + typeof params.worktreeId === 'string' + ? params.worktreeId + : typeof env?.ORCA_WORKTREE_ID === 'string' + ? env.ORCA_WORKTREE_ID + : undefined const startupIngressIntent = params.startupIngressVersion === PTY_STARTUP_INGRESS_VERSION ? parsePtyStartupIngressIntent(params.startupIngress) : undefined const managed: ManagedPty = { id, - incarnationId: randomUUID(), + incarnationId, pty: term, initialCwd: cwd, buffered: new RecentPtyOutputBuffer({ @@ -1541,6 +1957,7 @@ export class PtyHandler { shellPath: shell, wslDistro: terminalWindowsWslDistro }), + ...(authority ? { authority } : {}), ...(startupIngressIntent ? { startupIngressIntent } : {}), ...(terminalHandle ? { terminalHandle } : {}), ...(shouldProviderDeliverCommand @@ -1558,11 +1975,34 @@ export class PtyHandler { } : {}) } - this.sourcePublication?.activate(id, managed.incarnationId, context) - const sourceActivation = - context && this.sourcePublication?.receivingActivation?.(id, context.clientId) - this.wireAndStore(managed) - if (context?.isStale() && !params.agentSessionEnsure && !params.agentSessionCreateOperationId) { + let sourceActivation: PtySourceReceivingActivation | undefined + try { + this.sourcePublication?.activate(id, managed.incarnationId, context) + sourceActivation = + context && this.sourcePublication?.receivingActivation?.(id, context.clientId) + this.wireAndStore(managed) + earlyDataListener?.dispose() + earlyExitListener?.dispose() + for (const data of earlyOutput.values()) { + this.acceptManagedPtyData(managed, data) + } + if (earlyExitCode !== null) { + this.beginManagedPtyExit(managed, earlyExitCode) + } else if (authority) { + term.resume() + } + } catch (error) { + if (authority) { + this.failTerminalSessionAuthority(error) + } + throw error + } + if ( + context?.isStale() && + !params.agentSessionEnsure && + !params.agentSessionCreateOperationId && + !managed.authority + ) { // Why: if the client reconnected while pty.spawn was in flight, the // response is discarded and no renderer can own this PTY. Shut it down // immediately so it does not linger as an unreachable remote shell. @@ -1579,10 +2019,93 @@ export class PtyHandler { return { id, incarnationId: managed.incarnationId, + ...this.managedAuthorityAccess(managed), ...(sourceActivation ? { sourceActivation } : {}) } } + private adoptAuthorityPty( + adopted: TerminalAuthorityAdoptedPtySpawn, + context?: RequestContext + ): { + id: string + incarnationId: string + replay?: string + sourceActivation?: PtySourceReceivingActivation + terminalSessionAuthorityAccess?: TerminalSessionAuthorityPtyAccess + } { + const managed = this.ptys.get(adopted.binding.physicalPtyId) + if ( + !managed || + managed.disposed || + managed.incarnationId !== adopted.binding.ptyIncarnationId + ) { + throw new Error('terminal_session_authority_live_binding_missing') + } + const authority = this.terminalSessionAuthority!.managedFromAdoption(adopted) + if (!this.terminalSessionAuthority!.bindingIsReachable(authority)) { + throw new Error('terminal_session_authority_binding_unreachable') + } + managed.authority = authority + this.sourcePublication?.activate(managed.id, managed.incarnationId, context) + const sourceActivation = + context && this.sourcePublication?.receivingActivation?.(managed.id, context.clientId) + const replay = managed.buffered.read() + return { + id: managed.id, + incarnationId: managed.incarnationId, + ...this.managedAuthorityAccess(managed), + ...(replay ? { replay } : {}), + ...(sourceActivation ? { sourceActivation } : {}) + } + } + + private spawnResultForClient( + result: RelayAgentSessionCreateResult, + context?: RequestContext + ): RelayAgentSessionCreateResult { + if (this.clientSupportsTerminalAuthorityExactOperations(context)) { + return result + } + const { terminalSessionAuthorityAccess: _access, ...legacyResult } = result + return legacyResult + } + + private managedAuthorityAccess( + managed: ManagedPty + ): Readonly<{ terminalSessionAuthorityAccess?: TerminalSessionAuthorityPtyAccess }> { + return managed.authority + ? { terminalSessionAuthorityAccess: terminalAuthorityAccessForManagedPty(managed.authority) } + : {} + } + + private managedAuthorityAccessForClient( + managed: ManagedPty, + context?: RequestContext + ): Readonly<{ terminalSessionAuthorityAccess?: TerminalSessionAuthorityPtyAccess }> { + return this.clientSupportsTerminalAuthorityExactOperations(context) + ? this.managedAuthorityAccess(managed) + : {} + } + + private clientSupportsTerminalAuthorityExactOperations(context?: RequestContext): boolean { + return this.policyConsumerForClient(context) !== null + } + + private policyConsumerForClient( + context?: RequestContext + ): TerminalAuthorityPolicyConsumerConnection | null { + return context && this.terminalSessionAuthority && this.terminalAuthorityExactPtyAccessResolver + ? (this.terminalAuthorityPolicyConsumerForClient?.(context.clientId) ?? null) + : null + } + + private failTerminalSessionAuthority(error: unknown): void { + const normalized = error instanceof Error ? error : new Error(String(error)) + this.creationFenced = true + this.onTerminalSessionAuthorityFailure?.(normalized) + } + private async attach( params: Record, context?: RequestContext @@ -1591,24 +2114,108 @@ export class PtyHandler { replay?: string sourceRecovery?: PtySourceRecoveryResult sourceActivation?: PtySourceReceivingActivation + terminalSessionAuthorityAccess?: TerminalSessionAuthorityPtyAccess }> { const id = params.id as string - const managed = this.ptys.get(id) - // Why: after dispose, pty.kill is a POSIX no-op; treat disposed as not-found so failures aren't silent. + const authorityAttach = this.terminalSessionAuthority + ? parseTerminalSessionAuthorityAttachIdentity(params) + : null + if (this.terminalSessionAuthority && !authorityAttach) { + throw new Error('terminal_session_authority_attach_identity_required') + } + const policyConsumer = this.terminalSessionAuthority + ? this.policyConsumerForClient(context) + : null + if (this.terminalSessionAuthority && !policyConsumer) { + throw new Error(TERMINAL_AUTHORITY_REQUIRED_ERROR) + } + const candidate = this.ptys.get(id) + const managed = + candidate && + !candidate.disposed && + (!authorityAttach || !this.managedAuthorityAttachMismatches(candidate, authorityAttach)) + ? candidate + : undefined if (!managed || managed.disposed) { + if (this.terminalSessionAuthority) { + let state + try { + state = await this.terminalSessionAuthority.missingPtyState(params, id) + } catch (error) { + this.failTerminalSessionAuthority(error) + throw error + } + if (state.kind === 'unknown') { + if (candidate && authorityAttach) { + throw new Error(`PTY "${id}" not found (authority identity mismatch)`) + } + throw new Error(TERMINAL_SESSION_AUTHORITY_BINDING_UNKNOWN_ERROR) + } + if (state.kind === 'unreachable-predecessor') { + throw new Error('terminal_session_authority_binding_unreachable') + } + if (state.kind === 'reachable-record') { + if (state.ownerKind === 'current-owner') { + const error = new Error('terminal_session_authority_live_binding_missing') + this.failTerminalSessionAuthority(error) + throw error + } + if (!this.legacyPhysicalWorkerAttachRouter || !authorityAttach) { + throw new Error('terminal_session_authority_binding_unreachable') + } + if ( + candidate && + !candidate.disposed && + candidate.incarnationId === state.binding.ptyIncarnationId + ) { + throw new Error('terminal_session_authority_public_pty_identity_ambiguous') + } + const sourceRecovery = parseSourceRecoveryRequest(params.sourceRecovery) + const routed = await this.legacyPhysicalWorkerAttachRouter.attachReachablePty({ + pane: state.pane, + binding: state.binding, + worktreeId: authorityAttach.worktreeId, + ...(typeof params.expectedTabId === 'string' + ? { expectedTabId: params.expectedTabId } + : {}), + ...(sourceRecovery ? { sourceRecovery } : {}), + suppressReplayNotification: params.suppressReplayNotification === true, + ...(context ? { context } : {}) + }) + if (!routed) { + throw new Error('terminal_session_authority_binding_unreachable') + } + if (routed.incarnationId !== state.binding.ptyIncarnationId) { + const error = new Error('terminal_session_authority_routed_binding_mismatch') + this.failTerminalSessionAuthority(error) + throw error + } + return routed + } + } + if (candidate && authorityAttach) { + throw new Error(`PTY "${id}" not found (authority identity mismatch)`) + } throw new Error(`PTY "${id}" not found`) } + if (managed.authority) { + policyConsumer!.assertInstalled(managed.authority.runtime.service.namespace) + } + if (!this.managedAuthorityIsReachable(managed)) { + throw new Error('terminal_session_authority_binding_unreachable') + } + const authorityAccess = this.managedAuthorityAccessForClient(managed, context) // Why: verify liveness because shells can exit without node-pty onExit. if (managed.pty.pid && !isProcessAlive(managed.pty.pid)) { managed.physicalExit?.markExited() - this.releaseRelayIngress(managed) - this.flushPtyOutput(id) - this.notifyExitListener(managed) - this.agentSessionOwners.release(managed.id) - disposeManagedPty(managed) - this.removePty(id) - this.clearPtyFlowState(id) + managed.exitFinalizationStarted = true + try { + await this.finalizeManagedPtyExit(managed, null) + } catch (error) { + this.failTerminalSessionAuthority(error) + throw error + } throw new Error(`PTY "${id}" not found`) } @@ -1645,12 +2252,14 @@ export class PtyHandler { return { incarnationId: managed.incarnationId, sourceRecovery: activation, + ...authorityAccess, ...(sourceActivation ? { sourceActivation } : {}) } } if (activation === 'existing' && this.sourcePublication?.accepts(id)) { return { incarnationId: managed.incarnationId, + ...authorityAccess, ...(sourceActivation ? { sourceActivation } : {}) } } @@ -1667,6 +2276,7 @@ export class PtyHandler { return { incarnationId: managed.incarnationId, replay, + ...authorityAccess, ...(sourceActivation ? { sourceActivation } : {}) } } @@ -1674,45 +2284,358 @@ export class PtyHandler { } return { incarnationId: managed.incarnationId, + ...authorityAccess, ...(sourceActivation ? { sourceActivation } : {}) } } - private writeData(params: Record): void { + private managedAuthorityAttachMismatches( + managed: ManagedPty, + expected: TerminalSessionAuthorityAttachIdentity + ): boolean { + const authority = managed.authority + return ( + !authority || + managed.worktreeId !== expected.worktreeId || + authority.pane.paneKey !== expected.paneKey || + authority.pane.paneGenerationId !== `renderer:${expected.paneGeneration}` || + authority.binding.physicalPtyId !== managed.id || + authority.binding.ptyIncarnationId !== managed.incarnationId || + expected.ptyIncarnationId !== managed.incarnationId + ) + } + + private writeData(params: Record, context?: RequestContext): void { + if (context && this.clientSupportsTerminalAuthorityExactOperations(context)) { + return + } const id = params.id as string const data = params.data as string if (typeof data !== 'string') { return } const managed = this.ptys.get(id) - if (managed && !managed.disposed) { + if (managed && !managed.disposed && this.managedAuthorityIsReachable(managed)) { this.lastInputAtByPty.set(id, performance.now()) this.interactiveOutputCharsByPty.set(id, 0) managed.pty.write(data) } } - private resize(params: Record): void { + private writeExactData(params: Record, context?: RequestContext): void { + if (context && this.clientSupportsTerminalAuthorityExactOperations(context)) { + return + } + if (this.importedPtyCollidesWithManaged(params)) { + return + } + const managed = this.exactManagedPty(params) + if (typeof params.data !== 'string') { + return + } + if (!managed) { + void this.dispatchImportedMutation(params.id as string, params.incarnationId as string, { + kind: 'data', + data: params.data + }) + return + } + this.lastInputAtByPty.set(managed.id, performance.now()) + this.interactiveOutputCharsByPty.set(managed.id, 0) + managed.pty.write(params.data) + } + + private resize(params: Record, context?: RequestContext): void { + if (context && this.clientSupportsTerminalAuthorityExactOperations(context)) { + return + } const id = params.id as string const cols = Math.max(1, Math.min(500, Math.floor(Number(params.cols) || 80))) const rows = Math.max(1, Math.min(500, Math.floor(Number(params.rows) || 24))) const managed = this.ptys.get(id) - if (managed && !managed.disposed) { + if (managed && !managed.disposed && this.managedAuthorityIsReachable(managed)) { managed.pty.resize(cols, rows) } } + private resizeExact(params: Record, context?: RequestContext): void { + if (context && this.clientSupportsTerminalAuthorityExactOperations(context)) { + return + } + if (this.importedPtyCollidesWithManaged(params)) { + return + } + const managed = this.exactManagedPty(params) + const cols = Math.max(1, Math.min(500, Math.floor(Number(params.cols) || 80))) + const rows = Math.max(1, Math.min(500, Math.floor(Number(params.rows) || 24))) + if (!managed) { + void this.dispatchImportedMutation(params.id as string, params.incarnationId as string, { + kind: 'resize', + cols, + rows + }) + return + } + managed.pty.resize(cols, rows) + } + + private async shutdownExact( + params: Record, + context?: RequestContext + ): Promise<{ accepted: boolean }> { + if (context && this.clientSupportsTerminalAuthorityExactOperations(context)) { + throw new Error('terminal_authority_incarnation_mutation_rejected') + } + if (this.importedPtyCollidesWithManaged(params)) { + return { accepted: false } + } + if (!this.exactManagedPty(params)) { + return { + accepted: await this.dispatchImportedMutation( + params.id as string, + params.incarnationId as string, + { + kind: 'shutdown', + immediate: params.immediate === true, + keepHistory: params.keepHistory === true + } + ) + } + } + await this.shutdown(params) + return { accepted: true } + } + + private notifyAuthorityExactMutation( + method: typeof PTY_DATA_AUTHORITY_EXACT_METHOD | typeof PTY_RESIZE_AUTHORITY_EXACT_METHOD, + params: Record, + context: RequestContext + ): void { + const policyConsumer = this.policyConsumerForClient(context) + if (!policyConsumer) { + return + } + let request: PtyAuthorityExactOperationRequest + try { + request = parsePtyAuthorityExactOperationRequest(method, params) + } catch { + return + } + void this.dispatchAuthorityExactMutation(request, policyConsumer).catch((error) => { + this.failTerminalSessionAuthority(error) + }) + } + + private async requestAuthorityExactMutation( + method: + | typeof PTY_SHUTDOWN_AUTHORITY_EXACT_METHOD + | typeof PTY_SEND_SIGNAL_AUTHORITY_EXACT_METHOD + | typeof PTY_CLEAR_BUFFER_AUTHORITY_EXACT_METHOD, + params: Record, + context: RequestContext + ): Promise<{ accepted: boolean }> { + const policyConsumer = this.policyConsumerForClient(context) + if (!policyConsumer) { + throw new Error('terminal_authority_exact_operations_not_granted') + } + const request = parsePtyAuthorityExactOperationRequest(method, params) + return { accepted: await this.dispatchAuthorityExactMutation(request, policyConsumer) } + } + + private async dispatchAuthorityExactMutation( + request: PtyAuthorityExactOperationRequest, + policyConsumer: TerminalAuthorityPolicyConsumerConnection + ): Promise { + this.assertAllowedAuthorityMutation(request.mutation) + policyConsumer.assertInstalled(request.terminalSessionAuthorityAccess.namespace) + const managed = this.ptys.get(request.id) + if (managed && !managed.disposed) { + if ( + !managed.authority || + !managedPtyMatchesTerminalAuthorityAccess( + managed.authority, + request.terminalSessionAuthorityAccess + ) + ) { + return false + } + if (this.managedAuthorityIsReachable(managed)) { + return await this.applyAuthorityExactMutation(managed, request.mutation, policyConsumer) + } + if (request.mutation.kind !== 'shutdown') { + return false + } + const owner = await this.classifyAuthorityExactPty(request.terminalSessionAuthorityAccess) + if (owner === 'exited') { + return true + } + if (owner === 'current-owner-closed' || owner === 'imported-owner-closed') { + return await this.closeAndEnsureManagedShutdown(managed, request.mutation, policyConsumer) + } + return false + } + const owner = await this.classifyAuthorityExactPty(request.terminalSessionAuthorityAccess) + if (owner === 'exited') { + return request.mutation.kind === 'shutdown' + } + if (owner === 'current-owner-closed') { + if (request.mutation.kind !== 'shutdown') { + return false + } + await this.terminalSessionAuthority!.closeExactPtyAccess( + request.terminalSessionAuthorityAccess, + policyConsumer + ) + return true + } + if (owner === 'current-owner') { + const error = new Error('terminal_session_authority_live_binding_missing') + this.failTerminalSessionAuthority(error) + throw error + } + if ( + (owner !== 'imported-owner' && owner !== 'imported-owner-closed') || + !this.legacyPhysicalWorkerPtyRouter + ) { + return false + } + if (request.mutation.kind === 'shutdown') { + return await this.legacyPhysicalWorkerPtyRouter.dispatchAuthorityShutdown( + request.terminalSessionAuthorityAccess, + request.mutation, + () => + this.terminalSessionAuthority!.closeExactPtyAccess( + request.terminalSessionAuthorityAccess, + policyConsumer + ) + ) + } + if (owner !== 'imported-owner') { + return false + } + try { + return await this.legacyPhysicalWorkerPtyRouter.dispatchAuthorityMutation( + request.terminalSessionAuthorityAccess, + request.mutation + ) + } catch { + return false + } + } + + private async classifyAuthorityExactPty( + access: TerminalSessionAuthorityPtyAccess + ): Promise>> { + try { + return await this.terminalAuthorityExactPtyAccessResolver!.classify(access) + } catch (error) { + this.failTerminalSessionAuthority(error) + throw error + } + } + + private async applyAuthorityExactMutation( + managed: ManagedPty, + mutation: PtyAuthorityExactMutation, + policyConsumer: TerminalAuthorityPolicyConsumerConnection + ): Promise { + if (mutation.kind === 'data') { + this.lastInputAtByPty.set(managed.id, performance.now()) + this.interactiveOutputCharsByPty.set(managed.id, 0) + managed.pty.write(mutation.data) + return true + } + if (mutation.kind === 'resize') { + const cols = Math.max(1, Math.min(500, Math.floor(mutation.cols) || 80)) + const rows = Math.max(1, Math.min(500, Math.floor(mutation.rows) || 24)) + managed.pty.resize(cols, rows) + return true + } + if (mutation.kind === 'signal') { + managed.pty.kill(mutation.signal) + return true + } + if (mutation.kind === 'clear') { + managed.startupIngress?.snapshotBarrier() + managed.pty.clear() + return true + } + return await this.closeAndEnsureManagedShutdown(managed, mutation, policyConsumer) + } + + private async closeAndEnsureManagedShutdown( + managed: ManagedPty, + mutation: Extract, + policyConsumer: TerminalAuthorityPolicyConsumerConnection + ): Promise { + await this.terminalSessionAuthority!.closePty(managed.authority!, policyConsumer) + try { + await this.shutdown({ id: managed.id, ...mutation }) + } catch (error) { + process.stderr.write( + `[pty-handler] exact PTY shutdown remains pending for ${managed.id}: ${error instanceof Error ? error.message : String(error)}\n` + ) + } + return true + } + + private assertAllowedAuthorityMutation(mutation: PtyAuthorityExactMutation): void { + if (mutation.kind === 'signal' && !ALLOWED_SIGNALS.has(mutation.signal)) { + throw new Error(`Signal not allowed: ${mutation.signal}`) + } + } + + private exactManagedPty(params: Record): ManagedPty | null { + const managed = this.ptys.get(params.id as string) + return managed && + !managed.disposed && + this.managedAuthorityIsReachable(managed) && + matchesPtyExactOperationIdentity(managed.incarnationId, params.incarnationId) + ? managed + : null + } + + private importedPtyCollidesWithManaged(params: Record): boolean { + const id = typeof params.id === 'string' ? params.id : '' + const incarnationId = typeof params.incarnationId === 'string' ? params.incarnationId : '' + const managed = this.ptys.get(id) + return Boolean( + managed && + !managed.disposed && + managed.incarnationId === incarnationId && + this.legacyPhysicalWorkerPtyRouter?.reservesPublicPtyIdentity(id, incarnationId) + ) + } + + private async dispatchImportedMutation( + id: string, + incarnationId: string, + mutation: LegacyPhysicalWorkerMutation + ): Promise { + if (!id || !incarnationId || !this.legacyPhysicalWorkerPtyRouter) { + return false + } + try { + return await this.legacyPhysicalWorkerPtyRouter.dispatchMutation(id, incarnationId, mutation) + } catch { + return false + } + } + private async getSize( params: Record ): Promise<{ cols: number; rows: number } | null> { const managed = this.ptys.get(params.id as string) - if (!managed || managed.disposed) { + if (!managed || managed.disposed || !this.managedAuthorityIsReachable(managed)) { return null } return { cols: managed.pty.cols, rows: managed.pty.rows } } - private async shutdown(params: Record): Promise { + private async shutdown(params: Record, context?: RequestContext): Promise { + if (context && this.clientSupportsTerminalAuthorityExactOperations(context)) { + throw new Error('terminal_authority_legacy_mutation_rejected') + } const id = params.id as string const immediate = params.immediate as boolean const managed = this.ptys.get(id) @@ -1732,7 +2655,13 @@ export class PtyHandler { } } - private async sendSignal(params: Record): Promise { + private async sendSignal( + params: Record, + context?: RequestContext + ): Promise { + if (context && this.clientSupportsTerminalAuthorityExactOperations(context)) { + throw new Error('terminal_authority_legacy_mutation_rejected') + } const id = params.id as string const signal = params.signal as string if (!ALLOWED_SIGNALS.has(signal)) { @@ -1740,12 +2669,40 @@ export class PtyHandler { } const managed = this.ptys.get(id) // Why: dispose neutralizes pty.kill on POSIX; treat disposed as not-found so signals don't silently no-op. - if (!managed || managed.disposed) { + if (!managed || managed.disposed || !this.managedAuthorityIsReachable(managed)) { throw new Error(`PTY "${id}" not found`) } managed.pty.kill(signal) } + private async sendSignalExact( + params: Record, + context?: RequestContext + ): Promise<{ accepted: boolean }> { + if (context && this.clientSupportsTerminalAuthorityExactOperations(context)) { + throw new Error('terminal_authority_incarnation_mutation_rejected') + } + const signal = params.signal as string + if (!ALLOWED_SIGNALS.has(signal)) { + throw new Error(`Signal not allowed: ${signal}`) + } + if (this.importedPtyCollidesWithManaged(params)) { + return { accepted: false } + } + const managed = this.exactManagedPty(params) + if (!managed) { + return { + accepted: await this.dispatchImportedMutation( + params.id as string, + params.incarnationId as string, + { kind: 'signal', signal } + ) + } + } + managed.pty.kill(signal) + return { accepted: true } + } + private waitForPhysicalExit(managed: ManagedPty, timeoutMs: number): Promise { const physicalExit = managed.physicalExit if (!physicalExit) { @@ -1830,7 +2787,7 @@ export class PtyHandler { private async getCwd(params: Record): Promise { const id = params.id as string const managed = this.ptys.get(id) - if (!managed || managed.disposed) { + if (!managed || managed.disposed || !this.managedAuthorityIsReachable(managed)) { throw new Error(`PTY "${id}" not found`) } return resolveProcessCwd(managed.pty.pid, managed.initialCwd) @@ -1839,25 +2796,56 @@ export class PtyHandler { private async getInitialCwd(params: Record): Promise { const id = params.id as string const managed = this.ptys.get(id) - if (!managed || managed.disposed) { + if (!managed || managed.disposed || !this.managedAuthorityIsReachable(managed)) { throw new Error(`PTY "${id}" not found`) } return managed.initialCwd } - private async clearBuffer(params: Record): Promise { + private async clearBuffer( + params: Record, + context?: RequestContext + ): Promise { + if (context && this.clientSupportsTerminalAuthorityExactOperations(context)) { + throw new Error('terminal_authority_legacy_mutation_rejected') + } const id = params.id as string const managed = this.ptys.get(id) - if (managed && !managed.disposed) { + if (managed && !managed.disposed && this.managedAuthorityIsReachable(managed)) { managed.startupIngress?.snapshotBarrier() managed.pty.clear() } } + private async clearBufferExact( + params: Record, + context?: RequestContext + ): Promise<{ accepted: boolean }> { + if (context && this.clientSupportsTerminalAuthorityExactOperations(context)) { + throw new Error('terminal_authority_incarnation_mutation_rejected') + } + if (this.importedPtyCollidesWithManaged(params)) { + return { accepted: false } + } + const managed = this.exactManagedPty(params) + if (!managed) { + return { + accepted: await this.dispatchImportedMutation( + params.id as string, + params.incarnationId as string, + { kind: 'clear' } + ) + } + } + managed.startupIngress?.snapshotBarrier() + managed.pty.clear() + return { accepted: true } + } + private async hasChildProcesses(params: Record): Promise { const id = params.id as string const managed = this.ptys.get(id) - if (!managed || managed.disposed) { + if (!managed || managed.disposed || !this.managedAuthorityIsReachable(managed)) { return false } return await processHasChildren(managed.pty.pid) @@ -1866,7 +2854,7 @@ export class PtyHandler { private async getForegroundProcess(params: Record): Promise { const id = params.id as string const managed = this.ptys.get(id) - if (!managed || managed.disposed) { + if (!managed || managed.disposed || !this.managedAuthorityIsReachable(managed)) { return null } return await getForegroundProcessName(managed.pty.pid, managed.pty.process || null) @@ -1878,7 +2866,7 @@ export class PtyHandler { }> { const id = params.id as string const managed = this.ptys.get(id) - if (!managed || managed.disposed) { + if (!managed || managed.disposed || !this.managedAuthorityIsReachable(managed)) { throw new Error('terminal_gone') } const foregroundProcess = await getForegroundProcessName( @@ -1891,16 +2879,21 @@ export class PtyHandler { } } - private async listProcesses(): Promise { + private async listProcesses(context?: RequestContext): Promise { const results: PtyProcessSummary[] = [] for (const [id, managed] of this.ptys) { + if (!this.managedAuthorityIsReachable(managed)) { + continue + } const title = (await getForegroundProcessName(managed.pty.pid, managed.pty.process || null)) || 'shell' results.push({ id, incarnationId: managed.incarnationId, + processId: managed.pty.pid, cwd: managed.initialCwd, title, + ...this.managedAuthorityAccessForClient(managed, context), ...(managed.worktreeId ? { worktreeId: managed.worktreeId } : {}), ...(managed.terminalHandle ? { terminalHandle: managed.terminalHandle } : {}), ...(this.agentSessionOwners.listForPty(id).length @@ -1911,12 +2904,24 @@ export class PtyHandler { return results } + private managedAuthorityIsReachable(managed: ManagedPty): boolean { + if (!managed.authority) { + return this.terminalSessionAuthority === null + } + try { + return this.terminalSessionAuthority?.bindingIsReachable(managed.authority) === true + } catch (error) { + this.failTerminalSessionAuthority(error) + return false + } + } + private async serialize(params: Record): Promise { const ids = params.ids as string[] const entries: SerializedPtyEntry[] = [] for (const id of ids) { const managed = this.ptys.get(id) - if (!managed) { + if (!managed || managed.disposed || !this.managedAuthorityIsReachable(managed)) { continue } const { pid, cols, rows } = managed.pty @@ -1940,6 +2945,9 @@ export class PtyHandler { } private async revive(params: Record): Promise { + if (this.terminalSessionAuthority) { + throw new Error('terminal_session_authority_revival_unsupported') + } const state = params.state as string const entries = JSON.parse(state) as SerializedPtyEntry[] @@ -2097,8 +3105,10 @@ export class PtyHandler { this.pendingExitByPty.clear() this.pausedOutputPtys.clear() this.consumerPausedOutputPtys.clear() + this.heldProducerPauses.clearAll() this.lastInputAtByPty.clear() this.interactiveOutputCharsByPty.clear() + this.legacyPhysicalWorkerPtyRouter?.dispose() this.sourcePublication?.dispose() this.sourcePublication = null const results = await Promise.allSettled( @@ -2196,3 +3206,20 @@ export class PtyHandler { return this.graceTimer !== null } } + +function managedAuthorityMatchesOutcome( + managed: TerminalAuthorityManagedPty, + outcome: TerminalAuthorityOutcome, + effect: Extract +): boolean { + const namespace = managed.runtime.service.namespace + return ( + namespace.authorityHostId === outcome.result.namespace.authorityHostId && + namespace.namespaceId === outcome.result.namespace.namespaceId && + managed.pane.paneKey === outcome.result.pane.paneKey && + managed.pane.paneGenerationId === outcome.result.pane.paneGenerationId && + managed.binding.ownerIncarnationId === effect.binding.ownerIncarnationId && + managed.binding.physicalPtyId === effect.binding.physicalPtyId && + managed.binding.ptyIncarnationId === effect.binding.ptyIncarnationId + ) +} diff --git a/src/relay/pty-source-credit-notification.ts b/src/relay/pty-source-credit-notification.ts new file mode 100644 index 00000000000..2c6f7e0bce6 --- /dev/null +++ b/src/relay/pty-source-credit-notification.ts @@ -0,0 +1,19 @@ +import { notifyPtySourceCreditAvailable } from './pty-source-credit-availability-notice' +import type { PtySourceDeliveryIdentity } from '../shared/pty-source-credit-contract' + +export function notifyPtySourceCreditAvailability( + onCreditAvailable: ((id: string) => void) | undefined, + onExactCreditAvailable: ((identity: PtySourceDeliveryIdentity) => void) | undefined, + identity: PtySourceDeliveryIdentity +): void { + notifyPtySourceCreditAvailable(onCreditAvailable, identity.id) + try { + onExactCreditAvailable?.(identity) + } catch (error) { + process.stderr.write( + `[pty-source-credit] exact credit-available notification failed for ${identity.id}: ${ + error instanceof Error ? (error.stack ?? error.message) : String(error) + }\n` + ) + } +} diff --git a/src/relay/relay-filesystem-watch-registry.test.ts b/src/relay/relay-filesystem-watch-registry.test.ts index de924230f0f..073d86eef55 100644 --- a/src/relay/relay-filesystem-watch-registry.test.ts +++ b/src/relay/relay-filesystem-watch-registry.test.ts @@ -11,6 +11,7 @@ import type { import type { RelayDispatcher, RequestContext } from './dispatcher' import { RelayFilesystemWatchRegistry } from './relay-filesystem-watch-registry' import { createRelayWatcherProcessPool } from './relay-watcher-process-pool' +import { MAX_RELAY_WORKTREE_REMOVAL_LEASES } from './relay-worktree-removal-fence' type InstalledWatch = { rootPath: string @@ -97,6 +98,11 @@ function createDispatcher() { detached.add(listener) return () => detached.delete(listener) }), + detachClient: (clientId: number) => { + for (const listener of detached) { + listener(clientId) + } + }, // Detach here always retires the id, so the emitter's marker cleanup runs. isClientAttached: vi.fn(() => false), notificationFrameBytes: vi.fn(() => 64) @@ -290,7 +296,7 @@ describe('RelayFilesystemWatchRegistry', () => { it('waits admitted PTY creation, rejects late creation, then tears down before removal', async () => { const finishPtyCreation = registry.beginWorktreePtySpawn('/repo/nested') const order: string[] = [] - registry.setWorktreePtyTeardown(async (rootPath) => { + registry.worktreeRemovalFence.setBeforeRemove(async (rootPath) => { order.push(`pty:${rootPath}`) }) @@ -308,6 +314,194 @@ describe('RelayFilesystemWatchRegistry', () => { expect(order).toEqual(['pty:/repo', 'remove']) }) + it('holds an authority lease through control deletion and releases it after failure', async () => { + const authority = new RelayFilesystemWatchRegistry( + createDispatcher() as unknown as RelayDispatcher, + new FakeWatcherPool() + ) + let nextLease = 1 + registry.worktreeRemovalFence.setPeerAcquire(async (rootPath) => { + const leaseToken = `lease-${nextLease++}` + await authority.worktreeRemovalFence.acquireConnectionLease(rootPath, leaseToken, 41) + return async () => authority.worktreeRemovalFence.releaseConnectionLease(41, leaseToken) + }) + + await expect( + registry.runWithRemovalFence('/repo', async () => { + expect(() => authority.beginWorktreePtySpawn('/repo/late')).toThrow( + 'deletion already in progress' + ) + throw new Error('recursive deletion failed') + }) + ).rejects.toThrow('recursive deletion failed') + + const finish = authority.beginWorktreePtySpawn('/repo/retry') + finish() + authority.dispose() + }) + + it('reports an authority release failure without retaining the local removal root', async () => { + registry.worktreeRemovalFence.setPeerAcquire(async () => async () => { + throw new Error('authority release failed') + }) + + await expect(registry.runWithRemovalFence('/repo', async () => undefined)).rejects.toThrow( + 'authority release failed' + ) + const finish = registry.beginWorktreePtySpawn('/repo/retry') + finish() + }) + + it('drains an authority spawn and tears down its PTY before control deletion starts', async () => { + const authority = new RelayFilesystemWatchRegistry( + createDispatcher() as unknown as RelayDispatcher, + new FakeWatcherPool() + ) + const finishSpawn = authority.beginWorktreePtySpawn('/repo/nested') + const order: string[] = [] + authority.worktreeRemovalFence.setBeforeRemove(async () => { + order.push('authority-teardown') + }) + registry.worktreeRemovalFence.setPeerAcquire(async (rootPath) => { + await authority.worktreeRemovalFence.acquireConnectionLease(rootPath, 'lease-1', 17) + return async () => authority.worktreeRemovalFence.releaseConnectionLease(17, 'lease-1') + }) + + const removal = registry.runWithRemovalFence('/repo', async () => { + order.push('control-delete') + }) + await Promise.resolve() + expect(order).toEqual([]) + finishSpawn() + await removal + + expect(order).toEqual(['authority-teardown', 'control-delete']) + authority.dispose() + }) + + it('releases every authority lease immediately when its control connection detaches', async () => { + await registry.worktreeRemovalFence.acquireConnectionLease('/repo', 'lease-1', 72) + await registry.worktreeRemovalFence.acquireConnectionLease('/other', 'lease-2', 72) + expect(() => registry.beginWorktreePtySpawn('/repo/new')).toThrow( + 'deletion already in progress' + ) + + dispatcher.detachClient(72) + + const finishRepo = registry.beginWorktreePtySpawn('/repo/new') + const finishOther = registry.beginWorktreePtySpawn('/other/new') + finishRepo() + finishOther() + }) + + it('cancels a lease acquisition waiting on spawn when its control connection detaches', async () => { + const finishSpawn = registry.beginWorktreePtySpawn('/repo/in-flight') + const acquisition = registry.worktreeRemovalFence.acquireConnectionLease('/repo', 'lease-1', 81) + const rejected = expect(acquisition).rejects.toThrow('lease was released') + + dispatcher.detachClient(81) + await rejected + const finishReplacement = registry.beginWorktreePtySpawn('/repo/replacement') + + finishReplacement() + finishSpawn() + }) + + it('joins concurrent retries for the same authority lease until preparation completes', async () => { + let finishPreparation: () => void = () => undefined + const preparation = new Promise((resolve) => { + finishPreparation = resolve + }) + registry.worktreeRemovalFence.setBeforeRemove(() => preparation) + + const first = registry.worktreeRemovalFence.acquireConnectionLease('/repo', 'lease-1', 82) + await Promise.resolve() + const retry = registry.worktreeRemovalFence.acquireConnectionLease('/repo', 'lease-1', 82) + await expect( + registry.worktreeRemovalFence.acquireConnectionLease('/other', 'lease-1', 82) + ).rejects.toThrow('reused for another root') + let retrySettled = false + void retry.then(() => { + retrySettled = true + }) + await Promise.resolve() + + expect(retrySettled).toBe(false) + finishPreparation() + await expect(Promise.all([first, retry])).resolves.toEqual([undefined, undefined]) + }) + + it('rejects every joined authority lease retry when preparation fails', async () => { + let failPreparation: (error: Error) => void = () => undefined + const preparation = new Promise((_resolve, reject) => { + failPreparation = reject + }) + registry.worktreeRemovalFence.setBeforeRemove(() => preparation) + + const first = registry.worktreeRemovalFence.acquireConnectionLease('/repo', 'lease-1', 83) + await Promise.resolve() + const retry = registry.worktreeRemovalFence.acquireConnectionLease('/repo', 'lease-1', 83) + const firstRejected = expect(first).rejects.toThrow('PTY teardown failed') + const retryRejected = expect(retry).rejects.toThrow('PTY teardown failed') + failPreparation(new Error('PTY teardown failed')) + + await Promise.all([firstRejected, retryRejected]) + const finish = registry.beginWorktreePtySpawn('/repo/retry') + finish() + }) + + it('bounds connection-owned removal leases and recovers capacity on detach', async () => { + for (let index = 0; index < MAX_RELAY_WORKTREE_REMOVAL_LEASES; index++) { + await registry.worktreeRemovalFence.acquireConnectionLease( + `/repo-${index}`, + `lease-${index}`, + 9 + ) + } + + await expect( + registry.worktreeRemovalFence.acquireConnectionLease('/overflow', 'lease-overflow', 9) + ).rejects.toThrow('capacity exceeded') + + dispatcher.detachClient(9) + await expect( + registry.worktreeRemovalFence.acquireConnectionLease('/after-detach', 'lease-reused', 10) + ).resolves.toBeUndefined() + }) + + it('releases a failed authority acquisition without leaving a root fenced', async () => { + registry.worktreeRemovalFence.setBeforeRemove(async () => { + throw new Error('PTY teardown failed') + }) + + await expect( + registry.worktreeRemovalFence.acquireConnectionLease('/repo', 'lease-1', 3) + ).rejects.toThrow('PTY teardown failed') + const finish = registry.beginWorktreePtySpawn('/repo/retry') + finish() + }) + + it.each([ + ['Windows drive', 'C:\\Repo', 'c:/repo/nested', true], + ['Windows UNC', '\\\\Server\\Share\\Repo', '//server/share/repo/nested', true], + ['POSIX backslash', '/srv/team\\repo', '/srv/team/repo/nested', false] + ])( + 'uses host path semantics for a distributed %s lease', + async (_label, rootPath, candidatePath, shouldFence) => { + await registry.worktreeRemovalFence.acquireConnectionLease(rootPath, 'lease-1', 4) + + if (shouldFence) { + expect(() => registry.beginWorktreePtySpawn(candidatePath)).toThrow( + 'deletion already in progress' + ) + } else { + const finish = registry.beginWorktreePtySpawn(candidatePath) + finish() + } + registry.worktreeRemovalFence.releaseConnectionLease(4, 'lease-1') + } + ) + it.each([ ['drive spelling', 'C:\\Repo', 'c:/repo/'], ['UNC spelling', '\\\\Server\\Share\\Repo', '//server/share/repo/'] diff --git a/src/relay/relay-filesystem-watch-registry.ts b/src/relay/relay-filesystem-watch-registry.ts index de583b2f50a..1a03bab7908 100644 --- a/src/relay/relay-filesystem-watch-registry.ts +++ b/src/relay/relay-filesystem-watch-registry.ts @@ -22,7 +22,7 @@ import { trackRelayWatcherSetup, type RelayWatcherPendingSetup } from './relay-watcher-setup-tracking' -import { RelayWatcherRemovalFence } from './relay-watcher-removal-fence' +import { RelayWorktreeRemovalFence } from './relay-worktree-removal-fence' import { releaseStaleRelayWatches } from './relay-watcher-stale-client-release' import { PromiseSettlementWaiters } from '../shared/promise-settlement-waiters' import { joinRelayWatcherPendingSetup } from './relay-watcher-pending-setup-join' @@ -34,7 +34,7 @@ export class RelayFilesystemWatchRegistry { private readonly watches = new Map() private readonly pendingSetups = new Map() private readonly teardownTracker: RelayWatcherTeardownTracker - private readonly removalFence: RelayWatcherRemovalFence + readonly worktreeRemovalFence: RelayWorktreeRemovalFence constructor( private readonly dispatcher: RelayDispatcher, @@ -43,7 +43,7 @@ export class RelayFilesystemWatchRegistry { this.teardownTracker = new RelayWatcherTeardownTracker((rootPath) => this.watcherPool.forgetRoot(rootPath) ) - this.removalFence = new RelayWatcherRemovalFence( + this.worktreeRemovalFence = new RelayWorktreeRemovalFence( this.watches, this.pendingSetups, this.teardownTracker, @@ -54,7 +54,7 @@ export class RelayFilesystemWatchRegistry { watch(rootPath: string, context?: RequestContext, watchId?: number): Promise { const rootKey = normalizeRuntimePathForComparison(rootPath) - if (this.removalFence.isActive(rootKey)) { + if (this.worktreeRemovalFence.isActive(rootPath)) { return Promise.reject(new Error('Remote worktree deletion already in progress')) } const existing = this.watches.get(rootKey) @@ -167,18 +167,13 @@ export class RelayFilesystemWatchRegistry { } async runWithRemovalFence(rootPath: string, operation: () => Promise): Promise { - return this.removalFence.run(normalizeRuntimePathForComparison(rootPath), operation) + return this.worktreeRemovalFence.run(rootPath, operation) } - beginWorktreePtySpawn(path: string): () => void { - return this.removalFence.beginOperation(normalizeRuntimePathForComparison(path)) + return this.worktreeRemovalFence.beginOperation(path) } - - setWorktreePtyTeardown(teardown: (rootPath: string) => Promise): void { - this.removalFence.setBeforeRemove(teardown) - } - dispose(): void { + this.worktreeRemovalFence.dispose() this.watches.forEach((state) => void this.closeWatch(state).catch(() => {})) this.watcherPool.dispose() } diff --git a/src/relay/relay-grace-branch.test.ts b/src/relay/relay-grace-branch.test.ts index e7fee583239..2b5eafc474c 100644 --- a/src/relay/relay-grace-branch.test.ts +++ b/src/relay/relay-grace-branch.test.ts @@ -3,6 +3,7 @@ import { applyRelayGraceTimeConfiguration, decideRelayGrace, decideRelayGraceReconfigure, + mayDisposeRelayPtysForShutdown, type RelayGraceBranch, type RelayGraceDecisionInput, type RelayGraceReconfigureInput @@ -13,6 +14,7 @@ const IDLE_RELAY_GRACE_MS = 15 * 60_000 function decide(overrides: Partial = {}) { return decideRelayGrace({ + terminalSessionAuthorityAdmitted: false, configuredGraceMs: 0, relayIdle: false, detached: false, @@ -33,6 +35,7 @@ describe('decideRelayGrace', () => { expect(decide({ configuredGraceMs: 86_400_000, relayIdle: true, activePtyCount: 0 })).toEqual( { branch: 'configured', + armShutdown: true, timeoutMs: 86_400_000 } ) @@ -44,6 +47,7 @@ describe('decideRelayGrace', () => { // instead of living forever. Pinned so the revived path stays a decision, not an accident. expect(decide({ configuredGraceMs: 0, relayIdle: true, activePtyCount: 0 })).toEqual({ branch: 'idle-no-ptys', + armShutdown: true, timeoutMs: IDLE_RELAY_GRACE_MS }) }) @@ -53,6 +57,7 @@ describe('decideRelayGrace', () => { // it would kill the shell that creation is about to produce (#6955). expect(decide({ configuredGraceMs: 0, relayIdle: false })).toEqual({ branch: 'configured', + armShutdown: true, timeoutMs: 0 }) }) @@ -68,7 +73,11 @@ describe('decideRelayGrace', () => { relayIdle: true, activePtyCount: 0 }) - ).toEqual({ branch: 'shutdown-deferred', timeoutMs: IDLE_RELAY_GRACE_MS }) + ).toEqual({ + branch: 'shutdown-deferred', + armShutdown: true, + timeoutMs: IDLE_RELAY_GRACE_MS + }) }) it('prefers startup-empty-detached over the idle cap', () => { @@ -79,7 +88,11 @@ describe('decideRelayGrace', () => { relayIdle: true, activePtyCount: 0 }) - ).toEqual({ branch: 'startup-empty-detached', timeoutMs: EMPTY_DETACHED_STARTUP_GRACE_MS }) + ).toEqual({ + branch: 'startup-empty-detached', + armShutdown: true, + timeoutMs: EMPTY_DETACHED_STARTUP_GRACE_MS + }) }) it('bounds a configured grace on the startup-empty-detached branch', () => { @@ -90,7 +103,7 @@ describe('decideRelayGrace', () => { hasAcceptedSocketClient: false, activePtyCount: 0 }) - ).toEqual({ branch: 'startup-empty-detached', timeoutMs: 5_000 }) + ).toEqual({ branch: 'startup-empty-detached', armShutdown: true, timeoutMs: 5_000 }) }) it('does not treat a detached relay that already accepted a client as startup-empty', () => { @@ -101,9 +114,26 @@ describe('decideRelayGrace', () => { relayIdle: true, activePtyCount: 0 }) - ).toEqual({ branch: 'idle-no-ptys', timeoutMs: IDLE_RELAY_GRACE_MS }) + ).toEqual({ + branch: 'idle-no-ptys', + armShutdown: true, + timeoutMs: IDLE_RELAY_GRACE_MS + }) }) }) + + it.each([ + { activePtyCount: 1, relayIdle: false, hasAcceptedSocketClient: true }, + { activePtyCount: 0, relayIdle: true, hasAcceptedSocketClient: false } + ])('removes time-based shutdown after terminal authority admission', (state) => { + expect( + decide({ + ...state, + terminalSessionAuthorityAdmitted: true, + configuredGraceMs: 1 + }) + ).toEqual({ branch: 'terminal-authority-retained', armShutdown: false }) + }) }) function reconfigure(overrides: Partial = {}) { @@ -133,7 +163,11 @@ describe('decideRelayGraceReconfigure', () => { configuredGraceMs: 0, retryDeferredShutdown: rearmed.rearm && rearmed.retryDeferredShutdown }) - ).toEqual({ branch: 'shutdown-deferred', timeoutMs: IDLE_RELAY_GRACE_MS }) + ).toEqual({ + branch: 'shutdown-deferred', + armShutdown: true, + timeoutMs: IDLE_RELAY_GRACE_MS + }) }) it('ignores a re-assertion of the same grace', () => { @@ -149,6 +183,10 @@ describe('decideRelayGraceReconfigure', () => { it('does not re-arm once shutdown is in flight', () => { expect(reconfigure({ shutdownInFlight: true })).toEqual({ rearm: false }) }) + + it('cannot re-arm a retained authority through grace reconfiguration', () => { + expect(reconfigure({ currentBranch: 'terminal-authority-retained' })).toEqual({ rearm: false }) + }) }) /** @@ -170,6 +208,7 @@ function relayGraceHost( const startGrace = vi.fn( (_reason: string, options?: { retryDeferredShutdown?: boolean }): void => { graceBranch = decide({ + terminalSessionAuthorityAdmitted: false, configuredGraceMs, relayIdle: overrides.relayIdle ?? true, activePtyCount: overrides.activePtyCount ?? 0, @@ -252,3 +291,11 @@ describe('applyRelayGraceTimeConfiguration', () => { } }) }) + +describe('relay PTY disposal authority', () => { + it('rejects every timer-originated authority shutdown but preserves administrative shutdown', () => { + expect(mayDisposeRelayPtysForShutdown(true, 'grace-expired')).toBe(false) + expect(mayDisposeRelayPtysForShutdown(true, 'administrative')).toBe(true) + expect(mayDisposeRelayPtysForShutdown(false, 'grace-expired')).toBe(true) + }) +}) diff --git a/src/relay/relay-grace-branch.ts b/src/relay/relay-grace-branch.ts index 68ba3e2ec9d..83b52fc0431 100644 --- a/src/relay/relay-grace-branch.ts +++ b/src/relay/relay-grace-branch.ts @@ -1,11 +1,15 @@ /** Which rule decided the current relay grace window. */ export type RelayGraceBranch = + | 'terminal-authority-retained' | 'shutdown-deferred' | 'startup-empty-detached' | 'idle-no-ptys' | 'configured' +type TimedRelayGraceBranch = Exclude + export type RelayGraceDecisionInput = { + terminalSessionAuthorityAdmitted: boolean /** The live configured grace, not the launch-time argv value; 0 means unlimited. */ configuredGraceMs: number /** Zero pooled PTYs *and* zero admitted-but-unpooled creations. */ @@ -18,10 +22,11 @@ export type RelayGraceDecisionInput = { idleRelayGraceMs: number } -export type RelayGraceDecision = { - branch: RelayGraceBranch - timeoutMs: number -} +export type RelayGraceDecision = + | { branch: 'terminal-authority-retained'; armShutdown: false } + | { branch: TimedRelayGraceBranch; armShutdown: true; timeoutMs: number } + +export type RelayShutdownCause = 'administrative' | 'grace-expired' export type RelayGraceReconfigureInput = { previousConfiguredGraceMs: number @@ -92,7 +97,8 @@ export function decideRelayGraceReconfigure( if ( input.nextConfiguredGraceMs === input.previousConfiguredGraceMs || !input.graceTimerArmed || - input.shutdownInFlight + input.shutdownInFlight || + input.currentBranch === 'terminal-authority-retained' ) { return { rearm: false } } @@ -109,6 +115,9 @@ export function decideRelayGraceReconfigure( * could not otherwise be tested. */ export function decideRelayGrace(input: RelayGraceDecisionInput): RelayGraceDecision { + if (input.terminalSessionAuthorityAdmitted) { + return { branch: 'terminal-authority-retained', armShutdown: false } + } // Why: a detached relay that never accepted a client has no PTY state and shouldn't linger forever. const startupEmptyDetached = input.detached && !input.hasAcceptedSocketClient && input.activePtyCount === 0 @@ -118,7 +127,7 @@ export function decideRelayGrace(input: RelayGraceDecisionInput): RelayGraceDeci // shell it is about to produce. const idleNoPtys = input.relayIdle && input.configuredGraceMs === 0 - const branch: RelayGraceBranch = input.retryDeferredShutdown + const branch: TimedRelayGraceBranch = input.retryDeferredShutdown ? 'shutdown-deferred' : startupEmptyDetached ? 'startup-empty-detached' @@ -137,5 +146,12 @@ export function decideRelayGrace(input: RelayGraceDecisionInput): RelayGraceDeci ? input.idleRelayGraceMs : input.configuredGraceMs - return { branch, timeoutMs } + return { branch, armShutdown: true, timeoutMs } +} + +export function mayDisposeRelayPtysForShutdown( + terminalSessionAuthorityAdmitted: boolean, + cause: RelayShutdownCause +): boolean { + return cause === 'administrative' || !terminalSessionAuthorityAdmitted } diff --git a/src/relay/relay-grace-time-coordinator.test.ts b/src/relay/relay-grace-time-coordinator.test.ts new file mode 100644 index 00000000000..2f132fca326 --- /dev/null +++ b/src/relay/relay-grace-time-coordinator.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it, vi } from 'vitest' +import { configureAcknowledgedRelayGraceTime } from './relay-grace-time-coordinator' + +describe('acknowledged relay grace configuration', () => { + it('pins authority retention before applying the control-adapter grace', async () => { + const order: string[] = [] + const authorityValues: number[] = [] + const controlValues: number[] = [] + const result = await configureAcknowledgedRelayGraceTime({ + params: { graceTimeSeconds: 600 }, + configureAuthority: async (graceTimeSeconds) => { + order.push('authority') + authorityValues.push(graceTimeSeconds) + return { graceTimeMs: 0 } + }, + configureControl: (graceTimeSeconds) => { + order.push('control') + controlValues.push(graceTimeSeconds) + return { graceTimeMs: 600_000 } + } + }) + + expect(order).toEqual(['authority', 'control']) + expect(authorityValues).toEqual([0]) + expect(controlValues).toEqual([600]) + expect(result).toEqual({ graceTimeMs: 600_000 }) + }) + + it('leaves control unchanged when authority rejects or returns a false acknowledgement', async () => { + const configureControl = vi.fn(() => ({ graceTimeMs: 60_000 })) + + await expect( + configureAcknowledgedRelayGraceTime({ + params: { graceTimeSeconds: 60 }, + configureAuthority: async () => { + throw new Error('authority unavailable') + }, + configureControl + }) + ).rejects.toThrow('authority unavailable') + await expect( + configureAcknowledgedRelayGraceTime({ + params: { graceTimeSeconds: 60 }, + configureAuthority: async () => ({ graceTimeMs: 59_000 }), + configureControl + }) + ).rejects.toThrow('not_applied') + + expect(configureControl).not.toHaveBeenCalled() + }) + + it('preserves the combined-relay path without requiring an authority peer', async () => { + const configureControl = vi.fn(() => ({ graceTimeMs: 0 })) + + await expect( + configureAcknowledgedRelayGraceTime({ + params: { graceTimeSeconds: 0 }, + configureControl + }) + ).resolves.toEqual({ graceTimeMs: 0 }) + expect(configureControl).toHaveBeenCalledWith(0) + }) + + it.each([-1, 604_801, 1.5, '60', undefined])( + 'rejects malformed grace value %j before touching either process', + async (graceTimeSeconds) => { + const configureAuthority = vi.fn(async () => ({ graceTimeMs: 0 })) + const configureControl = vi.fn(() => ({ graceTimeMs: 0 })) + + await expect( + configureAcknowledgedRelayGraceTime({ + params: { graceTimeSeconds }, + configureAuthority, + configureControl + }) + ).rejects.toThrow('grace_time_invalid') + expect(configureAuthority).not.toHaveBeenCalled() + expect(configureControl).not.toHaveBeenCalled() + } + ) +}) diff --git a/src/relay/relay-grace-time-coordinator.ts b/src/relay/relay-grace-time-coordinator.ts new file mode 100644 index 00000000000..2f8406bddb4 --- /dev/null +++ b/src/relay/relay-grace-time-coordinator.ts @@ -0,0 +1,24 @@ +import { + assertGraceTimeApplied, + parseRelayGraceTimeSeconds +} from './terminal-authority-control-protocol' + +export type RelayGraceTimeResult = Readonly<{ graceTimeMs: number }> + +const RETAIN_AUTHORITY_GRACE_TIME_SECONDS = 0 + +export async function configureAcknowledgedRelayGraceTime(options: { + params: Record + configureControl: (graceTimeSeconds: number) => RelayGraceTimeResult + configureAuthority?: (graceTimeSeconds: number) => Promise +}): Promise { + const graceTimeSeconds = parseRelayGraceTimeSeconds(options.params) + if (options.configureAuthority) { + // Older authority hosts still interpret finite grace as permission to terminate PTYs. + const authorityResult = await options.configureAuthority(RETAIN_AUTHORITY_GRACE_TIME_SECONDS) + assertGraceTimeApplied(authorityResult, RETAIN_AUTHORITY_GRACE_TIME_SECONDS) + } + const result = options.configureControl(graceTimeSeconds) + assertGraceTimeApplied(result, graceTimeSeconds) + return result +} diff --git a/src/relay/relay-handshake-roundtrip.test.ts b/src/relay/relay-handshake-roundtrip.test.ts index 0713d62295e..4604c03a999 100644 --- a/src/relay/relay-handshake-roundtrip.test.ts +++ b/src/relay/relay-handshake-roundtrip.test.ts @@ -7,16 +7,23 @@ import { join } from 'node:path' import { setupDaemonHandshake, runConnectHandshake, - EXIT_CODE_VERSION_MISMATCH + EXIT_CODE_VERSION_MISMATCH, + type DaemonHandshakeClientRole } from './relay-handshake' import { encodeHandshakeFrame, encodeJsonRpcFrame, FrameDecoder, + parseHandshakeMessage, type DecodedFrame, MessageType } from './protocol' import { relayTestSocketPath } from './relay-test-socket-path' +import { + CURRENT_RELAY_DAEMON_COMPATIBILITY, + type RelayDaemonCompatibilityOffer +} from '../shared/relay-daemon-compatibility' +import type { SshTerminalAuthorityEndpointIdentity } from '../shared/ssh-terminal-authority-marker' // Why: --connect normally calls process.exit on mismatch / fatal handshake // errors. Stub it for tests so the harness sees a thrown sentinel error @@ -29,6 +36,12 @@ class ExitCalled extends Error { } } +const AUTHORITY_IDENTITY: SshTerminalAuthorityEndpointIdentity = { + authorityHostId: 'authority-host', + ownerInstanceId: 'owner-instance', + revision: 3 +} + describe('handshake round-trip over a real Socket pair', () => { let server: Server let sockPath: string @@ -78,17 +91,39 @@ describe('handshake round-trip over a real Socket pair', () => { function startDaemon( version: string, - endpointCredential?: string + endpointCredential?: string, + compatibility?: RelayDaemonCompatibilityOffer, + authorityIdentity?: SshTerminalAuthorityEndpointIdentity ): Promise<{ - accepted: Promise<{ sock: Socket; leftover: Buffer }> + accepted: Promise<{ + sock: Socket + leftover: Buffer + clientRole: DaemonHandshakeClientRole + }> }> { return new Promise((resolve) => { const acceptedDeferred: { - promise: Promise<{ sock: Socket; leftover: Buffer }> - resolve: (v: { sock: Socket; leftover: Buffer }) => void + promise: Promise<{ + sock: Socket + leftover: Buffer + clientRole: DaemonHandshakeClientRole + }> + resolve: (v: { + sock: Socket + leftover: Buffer + clientRole: DaemonHandshakeClientRole + }) => void } = (() => { - let _resolve: (v: { sock: Socket; leftover: Buffer }) => void = () => {} - const promise = new Promise<{ sock: Socket; leftover: Buffer }>((r) => { + let _resolve: (v: { + sock: Socket + leftover: Buffer + clientRole: DaemonHandshakeClientRole + }) => void = () => {} + const promise = new Promise<{ + sock: Socket + leftover: Buffer + clientRole: DaemonHandshakeClientRole + }>((r) => { _resolve = r }) return { promise, resolve: _resolve } @@ -99,7 +134,10 @@ describe('handshake round-trip over a real Socket pair', () => { setupDaemonHandshake(sock, { launchVersion: version, endpointCredential, - onAccepted: (s, leftover) => acceptedDeferred.resolve({ sock: s, leftover }) + compatibility, + authorityIdentity, + onAccepted: (s, leftover, clientRole) => + acceptedDeferred.resolve({ sock: s, leftover, clientRole }) }) }) server.listen(sockPath, () => resolve({ accepted: acceptedDeferred.promise })) @@ -115,8 +153,9 @@ describe('handshake round-trip over a real Socket pair', () => { const acceptedCb = vi.fn<(leftover: Buffer) => void>() runConnectHandshake(bridgeSock, '0.1.0+match', { onAccepted: acceptedCb }) - const { leftover } = await accepted + const { leftover, clientRole } = await accepted expect(leftover.length).toBe(0) + expect(clientRole).toBe('relay') await vi.waitFor(() => expect(acceptedCb).toHaveBeenCalledTimes(1)) expect(acceptedCb.mock.calls[0][0].length).toBe(0) @@ -124,6 +163,97 @@ describe('handshake round-trip over a real Socket pair', () => { bridgeSock.destroy() }) + it('admits an authenticated authority probe with an exact endpoint expectation', async () => { + const { accepted } = await startDaemon( + '0.1.0+authority', + 'secret-credential', + undefined, + AUTHORITY_IDENTITY + ) + const bridgeSock = connect(sockPath) + await new Promise((resolve) => bridgeSock.once('connect', resolve)) + const acceptedCb = vi.fn<(leftover: Buffer) => void>() + + runConnectHandshake( + bridgeSock, + '0.1.0+authority', + { onAccepted: acceptedCb }, + 'secret-credential', + AUTHORITY_IDENTITY + ) + + const admission = await accepted + expect(admission.clientRole).toBe('terminal-authority') + await vi.waitFor(() => expect(acceptedCb).toHaveBeenCalledOnce()) + bridgeSock.destroy() + }) + + it.each([ + ['0.1.0+older-authority', '0.1.0+newer-bridge'], + ['0.1.0+newer-authority', '0.1.0+older-bridge'] + ])('admits compatible mixed authority builds: %s / %s', async (daemonVersion, bridgeVersion) => { + const { accepted } = await startDaemon( + daemonVersion, + 'secret-credential', + CURRENT_RELAY_DAEMON_COMPATIBILITY, + AUTHORITY_IDENTITY + ) + const bridgeSock = connect(sockPath) + await new Promise((resolve) => bridgeSock.once('connect', resolve)) + const acceptedCb = vi.fn<(leftover: Buffer) => void>() + + runConnectHandshake( + bridgeSock, + bridgeVersion, + { onAccepted: acceptedCb }, + 'secret-credential', + AUTHORITY_IDENTITY + ) + + await accepted + await vi.waitFor(() => expect(acceptedCb).toHaveBeenCalledOnce()) + expect(exitSpy).not.toHaveBeenCalled() + bridgeSock.destroy() + }) + + it('keeps ordinary relay connections exact-build', async () => { + await startDaemon('0.1.0+older-compatible') + const bridgeSock = connect(sockPath) + await new Promise((resolve) => bridgeSock.once('connect', resolve)) + const acceptedCb = vi.fn<(leftover: Buffer) => void>() + + runConnectHandshake(bridgeSock, '0.1.0+newer-compatible', { onAccepted: acceptedCb }) + + await vi.waitFor(() => expect(exitSpy).toHaveBeenCalledWith(EXIT_CODE_VERSION_MISMATCH)) + expect(acceptedCb).not.toHaveBeenCalled() + bridgeSock.destroy() + }) + + it('does not let an ordinary relay opt into mixed builds with authority capabilities', async () => { + await startDaemon('0.1.0+server') + const bridgeSock = connect(sockPath) + await new Promise((resolve) => bridgeSock.once('connect', resolve)) + const replies: ReturnType[] = [] + const decoder = new FrameDecoder((frame) => { + if (frame.type === MessageType.Handshake) { + replies.push(parseHandshakeMessage(frame.payload)) + } + }) + bridgeSock.on('data', (data) => decoder.feed(Buffer.isBuffer(data) ? data : Buffer.from(data))) + + bridgeSock.write( + encodeHandshakeFrame({ + type: 'orca-relay-handshake', + version: '0.1.0+client', + compatibility: CURRENT_RELAY_DAEMON_COMPATIBILITY + }) + ) + + await vi.waitFor(() => expect(replies).toHaveLength(1)) + expect(replies[0]).toMatchObject({ type: 'orca-relay-handshake-mismatch', reason: 'build' }) + bridgeSock.destroy() + }) + it('rejects a same-build socket that lacks the detached endpoint credential', async () => { const { accepted } = await startDaemon('0.1.0+match', 'secret-credential') const bridgeSock = connect(sockPath) @@ -211,7 +341,13 @@ describe('handshake round-trip over a real Socket pair', () => { }) it('exits with EXIT_CODE_VERSION_MISMATCH when the daemon reports a mismatch', async () => { - await startDaemon('0.1.0+server-version') + await startDaemon('0.1.0+server-version', undefined, { + major: 2, + minMinor: 0, + maxMinor: 0, + capabilities: ['relay.rpc.v1', 'terminal-session.authority.v1'], + requiredCapabilities: ['relay.rpc.v1', 'terminal-session.authority.v1'] + }) const bridgeSock = connect(sockPath) await new Promise((r) => bridgeSock.once('connect', () => r())) diff --git a/src/relay/relay-handshake.ts b/src/relay/relay-handshake.ts index fbbe0283045..bea91f687d7 100644 --- a/src/relay/relay-handshake.ts +++ b/src/relay/relay-handshake.ts @@ -1,10 +1,7 @@ // Wire-level handshake helpers for the Orca relay. -import { dirname, join } from 'node:path' -import { existsSync, readFileSync, realpathSync } from 'node:fs' import type { Socket } from 'node:net' import { - RELAY_VERSION, MessageType, FrameDecoder, encodeHandshakeFrame, @@ -12,46 +9,32 @@ import { type DecodedFrame } from './protocol' import { relayLogLine } from './relay-diagnostic-log' +import { + CURRENT_RELAY_DAEMON_COMPATIBILITY, + negotiateRelayDaemonCompatibility, + relayDaemonGrantSatisfiesOffer, + type RelayDaemonCompatibilityOffer +} from '../shared/relay-daemon-compatibility' +import { + parseTerminalAuthorityEndpointIdentity, + sameTerminalAuthorityEndpointIdentity, + type SshTerminalAuthorityEndpointIdentity +} from '../shared/ssh-terminal-authority-marker' // Why: clients treat this exit code as non-retryable; other non-zero exits are transient. export const EXIT_CODE_VERSION_MISMATCH = 42 -// Why: read .version beside the resolved script path, not the arbitrary launch cwd. -export function readLaunchVersion(): string { - try { - const entry = process.argv[1] - let dir: string - if (entry) { - let resolved = entry - try { - resolved = realpathSync(entry) - } catch { - /* fall back to the unresolved path */ - } - dir = dirname(resolved) - } else { - dir = process.cwd() - } - const versionFile = join(dir, '.version') - if (existsSync(versionFile)) { - const v = readFileSync(versionFile, 'utf-8').trim() - if (v) { - return v - } - } - } catch { - /* fall through */ - } - return RELAY_VERSION -} - // ── Daemon side ───────────────────────────────────────────────────── +export type DaemonHandshakeClientRole = 'relay' | 'terminal-authority' + export type DaemonHandshakeCallbacks = { // leftover: bytes buffered after the handshake frame; caller must feed the dispatcher before attaching the data listener or they're lost. - onAccepted: (sock: Socket, leftover: Buffer) => void + onAccepted: (sock: Socket, leftover: Buffer, clientRole: DaemonHandshakeClientRole) => void launchVersion: string endpointCredential?: string + compatibility?: RelayDaemonCompatibilityOffer + authorityIdentity?: SshTerminalAuthorityEndpointIdentity } // Why: read one handshake frame before attaching the dispatcher; version mismatch closes the socket so the bridge exits 42. @@ -62,17 +45,19 @@ export function setupDaemonHandshake(sock: Socket, cb: DaemonHandshakeCallbacks) if (handshakeResolved) { return } - const accepted = handleDaemonHandshakeFrame( + const clientRole = handleDaemonHandshakeFrame( sock, frame, cb.launchVersion, - cb.endpointCredential + cb.endpointCredential, + cb.authorityIdentity ? (cb.compatibility ?? CURRENT_RELAY_DAEMON_COMPATIBILITY) : undefined, + cb.authorityIdentity ) - if (accepted) { + if (clientRole) { handshakeResolved = true const leftover = decoder.drain() detachHandshakeListener(sock) - cb.onAccepted(sock, leftover) + cb.onAccepted(sock, leftover, clientRole) } }, (err) => { @@ -101,14 +86,16 @@ function handleDaemonHandshakeFrame( sock: Socket, frame: DecodedFrame, launchVersion: string, - endpointCredential?: string -): boolean { + endpointCredential: string | undefined, + authorityCompatibility: RelayDaemonCompatibilityOffer | undefined, + serverAuthorityIdentity: SshTerminalAuthorityEndpointIdentity | undefined +): DaemonHandshakeClientRole | null { if (frame.type !== MessageType.Handshake) { process.stderr.write( `[relay] Protocol violation pre-handshake: type=${frame.type}; closing socket\n` ) sock.destroy() - return false + return null } let msg: ReturnType try { @@ -116,30 +103,39 @@ function handleDaemonHandshakeFrame( } catch (err) { relayLogLine(`[relay] Could not parse handshake: ${(err as Error).message}; closing socket`) sock.destroy() - return false + return null } if (msg.type !== 'orca-relay-handshake') { relayLogLine(`[relay] Unexpected handshake type from client: ${msg.type}; closing socket`) sock.destroy() - return false + return null } - if (msg.version !== launchVersion) { + const authorityEndpoint = serverAuthorityIdentity !== undefined + const compatibility = authorityCompatibility + ? negotiateRelayDaemonCompatibility(authorityCompatibility, msg.compatibility) + : null + const versionMatches = msg.version === launchVersion + if ( + (!versionMatches && !compatibility) || + (authorityEndpoint && (!endpointCredential || !compatibility)) + ) { relayLogLine( - `[relay] Handshake mismatch: own=${launchVersion}, client=${msg.version}; closing socket` + `[relay] Handshake mismatch: own=${launchVersion}, client=${msg.version}, compatible=${Boolean(compatibility)}; closing socket` ) try { sock.write( encodeHandshakeFrame({ type: 'orca-relay-handshake-mismatch', expected: launchVersion, - got: msg.version + got: msg.version, + reason: versionMatches ? 'protocol' : 'build' }) ) } catch { /* best-effort — close+exit-42 still wins */ } sock.end() - return false + return null } if ( endpointCredential !== undefined && @@ -147,11 +143,47 @@ function handleDaemonHandshakeFrame( ) { relayLogLine('[relay] Endpoint credential mismatch; closing socket') sock.destroy() - return false + return null } - process.stderr.write(`[relay] Handshake OK from version=${msg.version}\n`) - sock.write(encodeHandshakeFrame({ type: 'orca-relay-handshake-ok', version: launchVersion })) - return true + const authorityExpectation = + msg.authorityExpectation === undefined + ? null + : parseTerminalAuthorityEndpointIdentity(msg.authorityExpectation) + const authorityMatches = serverAuthorityIdentity + ? Boolean( + authorityExpectation && + sameTerminalAuthorityEndpointIdentity(serverAuthorityIdentity, authorityExpectation) + ) + : msg.authorityExpectation === undefined + if (!authorityMatches) { + relayLogLine('[relay] Terminal authority endpoint identity mismatch; closing socket') + try { + sock.write( + encodeHandshakeFrame({ + type: 'orca-relay-handshake-mismatch', + expected: launchVersion, + got: msg.version, + reason: 'authority' + }) + ) + } catch { + /* best-effort */ + } + sock.end() + return null + } + process.stderr.write( + `[relay] Handshake OK from version=${msg.version}, protocol=${compatibility ? `${compatibility.major}.${compatibility.minor}` : 'legacy-exact'}\n` + ) + sock.write( + encodeHandshakeFrame({ + type: 'orca-relay-handshake-ok', + version: launchVersion, + ...(compatibility ? { compatibility } : {}), + ...(serverAuthorityIdentity ? { authorityIdentity: serverAuthorityIdentity } : {}) + }) + ) + return authorityEndpoint ? 'terminal-authority' : 'relay' } // ── --connect side ────────────────────────────────────────────────── @@ -166,7 +198,8 @@ export function runConnectHandshake( sock: Socket, myVersion: string, cb: ConnectHandshakeCallbacks, - endpointCredential?: string + endpointCredential?: string, + authorityExpectation?: SshTerminalAuthorityEndpointIdentity ): void { let handshakeDone = false @@ -193,6 +226,40 @@ export function runConnectHandshake( process.exit(1) } if (msg.type === 'orca-relay-handshake-ok') { + const compatibleAuthority = Boolean( + authorityExpectation && + msg.compatibility !== undefined && + relayDaemonGrantSatisfiesOffer(msg.compatibility, CURRENT_RELAY_DAEMON_COMPATIBILITY) + ) + if ( + (authorityExpectation && msg.compatibility === undefined) || + (msg.compatibility !== undefined && + !relayDaemonGrantSatisfiesOffer(msg.compatibility, CURRENT_RELAY_DAEMON_COMPATIBILITY)) + ) { + process.stderr.write('[relay-connect] Daemon returned an incompatible protocol grant\n') + sock.destroy() + process.exit(1) + } + if (msg.version !== myVersion && !compatibleAuthority) { + process.stderr.write('[relay-connect] Daemon accepted a different build\n') + sock.destroy() + process.exit(1) + } + const authorityIdentity = + msg.authorityIdentity === undefined + ? null + : parseTerminalAuthorityEndpointIdentity(msg.authorityIdentity) + const authorityMatches = authorityExpectation + ? Boolean( + authorityIdentity && + sameTerminalAuthorityEndpointIdentity(authorityExpectation, authorityIdentity) + ) + : msg.authorityIdentity === undefined + if (!authorityMatches) { + process.stderr.write('[relay-connect] Daemon returned a different authority identity\n') + sock.destroy() + process.exit(1) + } process.stderr.write(`[relay-connect] Handshake OK at version=${msg.version}\n`) handshakeDone = true const leftover = decoder.drain() @@ -232,7 +299,9 @@ export function runConnectHandshake( encodeHandshakeFrame({ type: 'orca-relay-handshake', version: myVersion, - ...(endpointCredential ? { endpointCredential } : {}) + ...(endpointCredential ? { endpointCredential } : {}), + compatibility: CURRENT_RELAY_DAEMON_COMPATIBILITY, + ...(authorityExpectation ? { authorityExpectation } : {}) }) ) } diff --git a/src/relay/relay-held-producer-pause-registry.test.ts b/src/relay/relay-held-producer-pause-registry.test.ts new file mode 100644 index 00000000000..18532ee0e6e --- /dev/null +++ b/src/relay/relay-held-producer-pause-registry.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it, vi } from 'vitest' +import { RelayHeldProducerPauseRegistry } from './relay-held-producer-pause-registry' + +describe('RelayHeldProducerPauseRegistry', () => { + it('pauses on the first token and resumes after the last token', () => { + const pause = vi.fn() + const resume = vi.fn() + const registry = new RelayHeldProducerPauseRegistry({ + resolveIncarnation: () => 'incarnation-1', + pause, + resume + }) + + expect(registry.set('pty-1', 'incarnation-1', 'token-1', true)).toBe(true) + expect(registry.set('pty-1', 'incarnation-1', 'token-2', true)).toBe(true) + expect(registry.set('pty-1', 'incarnation-1', 'token-1', true)).toBe(true) + expect(pause).toHaveBeenCalledOnce() + + expect(registry.set('pty-1', 'incarnation-1', 'token-1', false)).toBe(true) + expect(resume).not.toHaveBeenCalled() + expect(registry.set('pty-1', 'incarnation-1', 'token-2', false)).toBe(true) + expect(resume).toHaveBeenCalledOnce() + }) + + it('rejects stale incarnations and makes repeated release idempotent', () => { + const resume = vi.fn() + let incarnation = 'incarnation-1' + const registry = new RelayHeldProducerPauseRegistry({ + resolveIncarnation: () => incarnation, + pause: vi.fn(), + resume + }) + registry.set('pty-1', incarnation, 'token-1', true) + + incarnation = 'incarnation-2' + expect(registry.set('pty-1', 'incarnation-1', 'token-1', false)).toBe(false) + expect(resume).not.toHaveBeenCalled() + registry.clear('pty-1') + expect(registry.set('pty-1', 'incarnation-2', 'token-1', false)).toBe(true) + }) +}) diff --git a/src/relay/relay-held-producer-pause-registry.ts b/src/relay/relay-held-producer-pause-registry.ts new file mode 100644 index 00000000000..c66158f3c17 --- /dev/null +++ b/src/relay/relay-held-producer-pause-registry.ts @@ -0,0 +1,83 @@ +const MAX_HELD_PRODUCER_PAUSE_TOKENS = 4_096 + +type HeldProducerPause = { + incarnationId: string + tokens: Set +} + +type RelayHeldProducerPauseRegistryDeps = { + resolveIncarnation: (id: string) => string | null + pause: (id: string) => void + resume: (id: string) => void +} + +export class RelayHeldProducerPauseRegistry { + private readonly pauses = new Map() + private tokenCount = 0 + + constructor(private readonly deps: RelayHeldProducerPauseRegistryDeps) {} + + set(id: string, incarnationId: string, token: string, paused: boolean): boolean { + if (this.deps.resolveIncarnation(id) !== incarnationId) { + return false + } + return paused ? this.acquire(id, incarnationId, token) : this.release(id, incarnationId, token) + } + + has(id: string): boolean { + return this.pauses.has(id) + } + + clear(id: string): void { + const current = this.pauses.get(id) + if (!current) { + return + } + this.pauses.delete(id) + this.tokenCount -= current.tokens.size + } + + clearAll(): void { + this.pauses.clear() + this.tokenCount = 0 + } + + private acquire(id: string, incarnationId: string, token: string): boolean { + const current = this.pauses.get(id) + if (current) { + if (current.incarnationId !== incarnationId) { + return false + } + if (current.tokens.has(token)) { + return true + } + } + if (this.tokenCount >= MAX_HELD_PRODUCER_PAUSE_TOKENS) { + return false + } + if (current) { + current.tokens.add(token) + } else { + this.pauses.set(id, { incarnationId, tokens: new Set([token]) }) + this.deps.pause(id) + } + this.tokenCount += 1 + return true + } + + private release(id: string, incarnationId: string, token: string): boolean { + const current = this.pauses.get(id) + if (!current) { + return true + } + if (current.incarnationId !== incarnationId || !current.tokens.delete(token)) { + return current.incarnationId === incarnationId + } + this.tokenCount -= 1 + if (current.tokens.size === 0) { + this.pauses.delete(id) + this.deps.resume(id) + } + return true + } +} diff --git a/src/relay/relay-launch-fence.test.ts b/src/relay/relay-launch-fence.test.ts new file mode 100644 index 00000000000..bbd492a85f0 --- /dev/null +++ b/src/relay/relay-launch-fence.test.ts @@ -0,0 +1,103 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { + relayGcClaimOwnerFileName, + relayInstallLockOwnerFileName +} from '../shared/relay-launch-fence-owner' +import { releaseRelayLaunchFence } from './relay-launch-fence' + +const cleanupDirs: string[] = [] +const installOwner = 'install-owner-token-00000001' +const gcOwner = 'gc-owner-token-00000000000001' + +afterEach(() => { + for (const directory of cleanupDirs.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + rmSync(`${directory}.gc-claim`, { recursive: true, force: true }) + } +}) + +describe('relay launch fence', () => { + it('releases the exact tokenized install lock after liveness is proven', () => { + const directory = relayDir() + const lockPath = join(directory, '.install-lock') + mkdirSync(lockPath) + writeFileSync(join(lockPath, '.owner'), '') + writeFileSync(join(lockPath, relayInstallLockOwnerFileName(installOwner)), installOwner) + + releaseRelayLaunchFence(directory, { + releaseInstallLock: true, + installLockOwnerToken: installOwner + }) + + expect(existsSync(lockPath)).toBe(false) + }) + + it('conditionally releases its exact GC claim while retaining the legacy marker shape', () => { + const directory = relayDir() + const claimPath = `${directory}.gc-claim` + mkdirSync(claimPath) + writeFileSync(join(claimPath, '.gc-owner'), gcOwner) + writeFileSync(join(claimPath, relayGcClaimOwnerFileName(gcOwner)), gcOwner) + + releaseRelayLaunchFence(directory, { gcClaimOwnerToken: gcOwner }) + + expect(existsSync(claimPath)).toBe(false) + }) + + it('preserves successor install and GC generations at the replacement boundary', () => { + const directory = relayDir() + const lockPath = join(directory, '.install-lock') + const claimPath = `${directory}.gc-claim` + const successorInstall = 'install-owner-token-00000002' + const successorGc = 'gc-owner-token-00000000000002' + mkdirSync(lockPath) + writeFileSync(join(lockPath, relayInstallLockOwnerFileName(successorInstall)), successorInstall) + mkdirSync(claimPath) + writeFileSync(join(claimPath, '.gc-owner'), successorGc) + writeFileSync(join(claimPath, relayGcClaimOwnerFileName(successorGc)), successorGc) + + expect(() => + releaseRelayLaunchFence(directory, { + releaseInstallLock: true, + installLockOwnerToken: installOwner + }) + ).toThrow('ownership changed') + expect(() => releaseRelayLaunchFence(directory, { gcClaimOwnerToken: gcOwner })).toThrow( + 'ownership changed' + ) + expect( + readFileSync(join(lockPath, relayInstallLockOwnerFileName(successorInstall)), 'utf8') + ).toBe(successorInstall) + expect(readFileSync(join(claimPath, '.gc-owner'), 'utf8')).toBe(successorGc) + }) + + it('fails closed when an install release omits its acquisition identity', () => { + const directory = relayDir() + const lockPath = join(directory, '.install-lock') + mkdirSync(lockPath) + + expect(() => releaseRelayLaunchFence(directory, { releaseInstallLock: true })).toThrow( + 'identity is missing' + ) + expect(existsSync(lockPath)).toBe(true) + }) + + it('accepts a fence already released by the detached daemon', () => { + expect(() => + releaseRelayLaunchFence(relayDir(), { + releaseInstallLock: true, + installLockOwnerToken: installOwner, + gcClaimOwnerToken: gcOwner + }) + ).not.toThrow() + }) +}) + +function relayDir(): string { + const directory = mkdtempSync(join(tmpdir(), 'orca-relay-launch-fence-')) + cleanupDirs.push(directory) + return directory +} diff --git a/src/relay/relay-launch-fence.ts b/src/relay/relay-launch-fence.ts new file mode 100644 index 00000000000..a0847c6bbfa --- /dev/null +++ b/src/relay/relay-launch-fence.ts @@ -0,0 +1,83 @@ +import { existsSync, lstatSync, readFileSync, rmSync, unlinkSync } from 'node:fs' +import { join } from 'node:path' +import { + isRelayLaunchFenceOwnerToken, + relayGcClaimOwnerFileName, + relayInstallLockOwnerFileName +} from '../shared/relay-launch-fence-owner' + +const INSTALL_LOCK_NAME = '.install-lock' +const GC_CLAIM_SUFFIX = '.gc-claim' + +export type RelayLaunchFence = Readonly<{ + releaseInstallLock?: true + installLockOwnerToken?: string + gcClaimOwnerToken?: string +}> + +export { isRelayLaunchFenceOwnerToken } + +export function releaseRelayLaunchFence(relayDir: string, fence?: RelayLaunchFence): void { + if (!fence) { + return + } + if (fence.gcClaimOwnerToken) { + releaseExactFenceDirectory( + `${relayDir}${GC_CLAIM_SUFFIX}`, + fence.gcClaimOwnerToken, + relayGcClaimOwnerFileName(fence.gcClaimOwnerToken) + ) + } + if (fence.releaseInstallLock) { + if (!fence.installLockOwnerToken) { + throw new Error('Relay launch install lock owner identity is missing') + } + releaseExactFenceDirectory( + join(relayDir, INSTALL_LOCK_NAME), + fence.installLockOwnerToken, + relayInstallLockOwnerFileName(fence.installLockOwnerToken) + ) + } +} + +function releaseExactFenceDirectory( + fencePath: string, + ownerToken: string, + ownerFileName: string +): void { + if (!existsSync(fencePath)) { + return + } + const fenceMetadata = lstatSync(fencePath) + if (!fenceMetadata.isDirectory() || fenceMetadata.isSymbolicLink()) { + throw new Error('Relay launch fence is not an owned directory') + } + const ownerPath = join(fencePath, ownerFileName) + let metadata + try { + metadata = lstatSync(ownerPath) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + throw new Error('Relay launch fence ownership changed before release') + } + throw error + } + if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.size > 4 * 1024) { + throw new Error('Relay launch fence owner identity is invalid') + } + if (readFileSync(ownerPath, 'utf8') !== ownerToken) { + throw new Error('Relay launch fence ownership changed before release') + } + try { + unlinkSync(ownerPath) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + throw new Error('Relay launch fence ownership changed before release') + } + throw error + } + rmSync(fencePath, { recursive: true, force: false }) + if (existsSync(fencePath)) { + throw new Error(`Relay launch fence remains at ${fencePath}`) + } +} diff --git a/src/relay/relay-launch-version.ts b/src/relay/relay-launch-version.ts new file mode 100644 index 00000000000..eae5d607eab --- /dev/null +++ b/src/relay/relay-launch-version.ts @@ -0,0 +1,32 @@ +import { existsSync, readFileSync, realpathSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { RELAY_VERSION } from './protocol' + +// Why: launch cwd is arbitrary, while the deployed .version sits beside the resolved relay script. +export function readLaunchVersion(): string { + try { + const entry = process.argv[1] + let directory: string + if (entry) { + let resolved = entry + try { + resolved = realpathSync(entry) + } catch { + // Fall back to the unresolved entry path. + } + directory = dirname(resolved) + } else { + directory = process.cwd() + } + const versionFile = join(directory, '.version') + if (existsSync(versionFile)) { + const version = readFileSync(versionFile, 'utf8').trim() + if (version) { + return version + } + } + } catch { + // Fall back to the embedded protocol version. + } + return RELAY_VERSION +} diff --git a/src/relay/relay-owner-guard-directory.test.ts b/src/relay/relay-owner-guard-directory.test.ts new file mode 100644 index 00000000000..a3483a7f4fe --- /dev/null +++ b/src/relay/relay-owner-guard-directory.test.ts @@ -0,0 +1,63 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + createRelayOwnerGuardDirectory, + inspectRelayOwnerGuardDirectory, + releaseRelayOwnerGuardDirectory +} from './relay-owner-guard-directory' + +const roots: string[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('relay owner guard directory', () => { + it('publishes one nonempty owner generation and releases idempotently', async () => { + const guard = await guardPath() + + await expect(createRelayOwnerGuardDirectory(guard, 'owner-a')).resolves.toBe(true) + await expect(createRelayOwnerGuardDirectory(guard, 'owner-b')).resolves.toBe(false) + await expect(inspectRelayOwnerGuardDirectory(guard)).resolves.toEqual({ + status: 'owned', + ownerToken: 'owner-a' + }) + await expect(releaseRelayOwnerGuardDirectory(guard, 'owner-a')).resolves.toBe(true) + await expect(releaseRelayOwnerGuardDirectory(guard, 'owner-a')).resolves.toBe(true) + }) + + it('prevents a stale releaser from deleting a replacement generation', async () => { + const guard = await guardPath() + await createRelayOwnerGuardDirectory(guard, 'owner-a') + const staleInspection = await inspectRelayOwnerGuardDirectory(guard) + await releaseRelayOwnerGuardDirectory(guard, 'owner-a') + await createRelayOwnerGuardDirectory(guard, 'owner-b') + + expect(staleInspection).toEqual({ status: 'owned', ownerToken: 'owner-a' }) + await expect(releaseRelayOwnerGuardDirectory(guard, 'owner-a')).resolves.toBe(false) + await expect(inspectRelayOwnerGuardDirectory(guard)).resolves.toEqual({ + status: 'owned', + ownerToken: 'owner-b' + }) + }) + + it('fails closed for legacy files and malformed directory contents', async () => { + const guard = await guardPath() + await writeFile(guard, 'legacy-lock') + await expect(inspectRelayOwnerGuardDirectory(guard)).resolves.toEqual({ status: 'invalid' }) + await expect(releaseRelayOwnerGuardDirectory(guard, 'owner-a')).resolves.toBe(false) + + await rm(guard) + await mkdir(guard) + await writeFile(path.join(guard, 'unexpected'), 'owner-a') + await expect(inspectRelayOwnerGuardDirectory(guard)).resolves.toEqual({ status: 'invalid' }) + }) +}) + +async function guardPath(): Promise { + const root = await mkdtemp(path.join(tmpdir(), 'orca-relay-owner-guard-')) + roots.push(root) + return path.join(root, 'guard') +} diff --git a/src/relay/relay-owner-guard-directory.ts b/src/relay/relay-owner-guard-directory.ts new file mode 100644 index 00000000000..3dcf2b7e9e4 --- /dev/null +++ b/src/relay/relay-owner-guard-directory.ts @@ -0,0 +1,181 @@ +import { createHash, randomUUID } from 'node:crypto' +import { + lstat, + mkdir, + open, + readFile, + readdir, + rename, + rm, + rmdir, + unlink, + type FileHandle +} from 'node:fs/promises' +import path from 'node:path' + +const OWNER_FILE_PREFIX = 'owner-' +const MAX_OWNER_TOKEN_BYTES = 4 * 1024 + +export type RelayOwnerGuardInspection = + | Readonly<{ status: 'missing' | 'invalid' }> + | Readonly<{ status: 'owned'; ownerToken: string }> + +export async function createRelayOwnerGuardDirectory( + directory: string, + ownerToken: string +): Promise { + assertOwnerToken(ownerToken) + const temporary = `${directory}.${process.pid}.${randomUUID()}.tmp` + try { + await mkdir(temporary, { mode: 0o700 }) + await writeSyncedFile(path.join(temporary, ownerFileName(ownerToken)), `${ownerToken}\n`) + await syncDirectory(temporary) + try { + await rename(temporary, directory) + await syncDirectory(path.dirname(directory)) + return true + } catch (error) { + try { + await lstat(directory) + return false + } catch (targetError) { + if (errorCode(targetError) !== 'ENOENT') { + throw targetError + } + throw error + } + } + } finally { + await rm(temporary, { recursive: true, force: true }) + } +} + +export async function inspectRelayOwnerGuardDirectory( + directory: string +): Promise { + let directoryMetadata + try { + directoryMetadata = await lstat(directory) + } catch (error) { + if (errorCode(error) === 'ENOENT') { + return Object.freeze({ status: 'missing' }) + } + throw error + } + if (!directoryMetadata.isDirectory() || directoryMetadata.isSymbolicLink()) { + return Object.freeze({ status: 'invalid' }) + } + const entries = await readdir(directory) + if (entries.length !== 1 || !entries[0]!.startsWith(OWNER_FILE_PREFIX)) { + return Object.freeze({ status: 'invalid' }) + } + const ownerPath = path.join(directory, entries[0]!) + const ownerMetadata = await lstat(ownerPath) + if (!ownerMetadata.isFile() || ownerMetadata.isSymbolicLink()) { + return Object.freeze({ status: 'invalid' }) + } + const ownerToken = await readBoundedOwnerToken(ownerPath, ownerMetadata.size) + if (!ownerToken || entries[0] !== ownerFileName(ownerToken)) { + return Object.freeze({ status: 'invalid' }) + } + return Object.freeze({ status: 'owned', ownerToken }) +} + +export async function relayOwnerGuardIsOwnedBy( + directory: string, + ownerToken: string +): Promise { + const inspection = await inspectRelayOwnerGuardDirectory(directory) + return inspection.status === 'owned' && inspection.ownerToken === ownerToken +} + +export async function releaseRelayOwnerGuardDirectory( + directory: string, + ownerToken: string +): Promise { + assertOwnerToken(ownerToken) + const inspection = await inspectRelayOwnerGuardDirectory(directory) + if (inspection.status === 'missing') { + return true + } + if (inspection.status !== 'owned' || inspection.ownerToken !== ownerToken) { + return false + } + try { + await unlink(path.join(directory, ownerFileName(ownerToken))) + } catch (error) { + if (errorCode(error) !== 'ENOENT') { + throw error + } + return (await inspectRelayOwnerGuardDirectory(directory)).status === 'missing' + } + try { + await rmdir(directory) + await syncDirectory(path.dirname(directory)) + return true + } catch (error) { + if (errorCode(error) === 'ENOENT') { + return true + } + if (['EEXIST', 'ENOTEMPTY', 'ENOTDIR'].includes(errorCode(error))) { + return false + } + throw error + } +} + +async function readBoundedOwnerToken(file: string, size: number): Promise { + if (size < 1 || size > MAX_OWNER_TOKEN_BYTES) { + return null + } + const ownerToken = (await readFile(file, 'utf8')).trim() + try { + assertOwnerToken(ownerToken) + return ownerToken + } catch { + return null + } +} + +async function writeSyncedFile(file: string, contents: string): Promise { + const handle = await open(file, 'wx', 0o600) + try { + await handle.writeFile(contents, 'utf8') + await handle.sync() + } finally { + await handle.close() + } +} + +async function syncDirectory(directory: string): Promise { + let handle: FileHandle | null = null + try { + handle = await open(directory, 'r') + await handle.sync() + } catch (error) { + if (!['EACCES', 'EBADF', 'EINVAL', 'EISDIR', 'ENOTSUP', 'EPERM'].includes(errorCode(error))) { + throw error + } + } finally { + await handle?.close() + } +} + +function ownerFileName(ownerToken: string): string { + return `${OWNER_FILE_PREFIX}${createHash('sha256').update(ownerToken).digest('hex')}` +} + +function assertOwnerToken(ownerToken: string): void { + if ( + typeof ownerToken !== 'string' || + ownerToken.trim().length === 0 || + ownerToken.includes('\0') || + Buffer.byteLength(ownerToken) > MAX_OWNER_TOKEN_BYTES + ) { + throw new Error('relay owner guard token is invalid') + } +} + +function errorCode(error: unknown): string { + return typeof error === 'object' && error !== null && 'code' in error ? String(error.code) : '' +} diff --git a/src/relay/relay-startup-options.test.ts b/src/relay/relay-startup-options.test.ts new file mode 100644 index 00000000000..f3a86dd6295 --- /dev/null +++ b/src/relay/relay-startup-options.test.ts @@ -0,0 +1,264 @@ +import { describe, expect, it } from 'vitest' +import { join } from 'node:path' +import { parseRelayStartupOptions } from './relay-startup-options' + +const argv = (...args: string[]): string[] => ['node', 'relay.js', ...args] + +describe('relay startup options', () => { + it('uses the legacy role and working-directory socket by default', () => { + expect(parseRelayStartupOptions(argv(), 'relay-home')).toMatchObject({ + connectMode: false, + terminalAuthority: false, + controlAdapter: false, + sockPath: join('relay-home', 'relay.sock') + }) + }) + + it('parses a complete terminal authority owner claim', () => { + expect( + parseRelayStartupOptions( + argv( + '--detached', + '--terminal-authority', + '--authority-state-dir', + 'state', + '--authority-marker-path', + 'marker', + '--authority-process-token', + 'new-owner', + '--authority-takeover-token', + 'old-owner', + '--authority-takeover-revision', + '7' + ) + ) + ).toMatchObject({ + detached: true, + terminalAuthority: true, + controlAdapter: false, + authorityOwner: { + stateDir: 'state', + markerPath: 'marker', + processToken: 'new-owner', + takeover: { ownerProcessToken: 'old-owner', revision: 7 } + } + }) + }) + + it('parses the PTY-blocking control-adapter role', () => { + expect(parseRelayStartupOptions(argv('--detached', '--control-adapter'))).toMatchObject({ + detached: true, + terminalAuthority: false, + controlAdapter: true + }) + }) + + it('parses one complete local authority gateway binding', () => { + expect( + parseRelayStartupOptions( + argv( + '--control-adapter', + '--authority-gateway-marker-path', + 'active-endpoint', + '--authority-gateway-host-id', + 'authority-host', + '--authority-gateway-owner-instance', + 'owner-instance', + '--authority-gateway-revision', + '9' + ) + ) + ).toMatchObject({ + controlAdapter: true, + authorityGateway: { + markerPath: 'active-endpoint', + authorityHostId: 'authority-host', + ownerInstanceId: 'owner-instance', + revision: 9 + } + }) + }) + + it('parses one exact authority expectation in connect mode', () => { + expect( + parseRelayStartupOptions( + argv( + '--connect', + '--authority-expect-host-id', + 'authority-host', + '--authority-expect-owner-instance', + 'owner-instance', + '--authority-expect-revision', + '9' + ) + ) + ).toMatchObject({ + connectMode: true, + authorityConnectExpectation: { + authorityHostId: 'authority-host', + ownerInstanceId: 'owner-instance', + revision: 9 + } + }) + }) + + it('parses launch-fence ownership for connect and detached control modes', () => { + expect( + parseRelayStartupOptions( + argv('--connect', '--release-launch-gc-claim-owner', 'gc-owner-token-0001') + ) + ).toMatchObject({ + launchFence: { gcClaimOwnerToken: 'gc-owner-token-0001' } + }) + expect( + parseRelayStartupOptions( + argv( + '--detached', + '--control-adapter', + '--release-launch-install-lock', + '--release-launch-install-lock-owner', + 'install-owner-token-0001' + ) + ) + ).toMatchObject({ + launchFence: { + releaseInstallLock: true, + installLockOwnerToken: 'install-owner-token-0001' + } + }) + }) + + it.each([ + ['missing owner claim', argv('--terminal-authority')], + ['partial owner claim', argv('--terminal-authority', '--authority-state-dir', 'state')], + [ + 'owner claim outside authority mode', + argv( + '--authority-state-dir', + 'state', + '--authority-marker-path', + 'marker', + '--authority-process-token', + 'owner' + ) + ], + [ + 'partial takeover', + argv( + '--terminal-authority', + '--authority-state-dir', + 'state', + '--authority-marker-path', + 'marker', + '--authority-process-token', + 'owner', + '--authority-takeover-token', + 'old-owner' + ) + ], + [ + 'invalid takeover revision', + argv( + '--terminal-authority', + '--authority-state-dir', + 'state', + '--authority-marker-path', + 'marker', + '--authority-process-token', + 'owner', + '--authority-takeover-token', + 'old-owner', + '--authority-takeover-revision', + '0' + ) + ], + [ + 'conflicting relay roles', + argv( + '--terminal-authority', + '--control-adapter', + '--authority-state-dir', + 'state', + '--authority-marker-path', + 'marker', + '--authority-process-token', + 'owner' + ) + ], + [ + 'partial gateway binding', + argv( + '--control-adapter', + '--authority-gateway-marker-path', + 'active-endpoint', + '--authority-gateway-host-id', + 'authority-host', + '--authority-gateway-owner-instance', + 'owner-instance' + ) + ], + [ + 'gateway binding without host identity', + argv( + '--control-adapter', + '--authority-gateway-marker-path', + 'active-endpoint', + '--authority-gateway-owner-instance', + 'owner-instance', + '--authority-gateway-revision', + '9' + ) + ], + [ + 'gateway binding outside control mode', + argv( + '--authority-gateway-marker-path', + 'active-endpoint', + '--authority-gateway-host-id', + 'authority-host', + '--authority-gateway-owner-instance', + 'owner-instance', + '--authority-gateway-revision', + '9' + ) + ], + [ + 'partial authority connect expectation', + argv( + '--connect', + '--authority-expect-host-id', + 'authority-host', + '--authority-expect-owner-instance', + 'owner-instance' + ) + ], + [ + 'authority expectation outside connect mode', + argv( + '--authority-expect-host-id', + 'authority-host', + '--authority-expect-owner-instance', + 'owner-instance', + '--authority-expect-revision', + '9' + ) + ], + [ + 'malformed authority expectation revision', + argv( + '--connect', + '--authority-expect-host-id', + 'authority-host', + '--authority-expect-owner-instance', + 'owner-instance', + '--authority-expect-revision', + '9junk' + ) + ], + ['launch fence outside a live control path', argv('--release-launch-install-lock')], + ['install fence without owner identity', argv('--connect', '--release-launch-install-lock')], + ['malformed launch GC owner', argv('--connect', '--release-launch-gc-claim-owner', '../claim')] + ])('rejects %s', (_name, args) => { + expect(() => parseRelayStartupOptions(args)).toThrow() + }) +}) diff --git a/src/relay/relay-startup-options.ts b/src/relay/relay-startup-options.ts new file mode 100644 index 00000000000..e0b9a69dd75 --- /dev/null +++ b/src/relay/relay-startup-options.ts @@ -0,0 +1,275 @@ +import { join } from 'node:path' +import { DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../shared/ssh-types' +import type { SshTerminalAuthorityEndpointIdentity } from '../shared/ssh-terminal-authority-marker' +import { isRelayLaunchFenceOwnerToken, type RelayLaunchFence } from './relay-launch-fence' + +const DEFAULT_GRACE_MS = DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS * 1000 +const DEFAULT_SOCKET_NAME = 'relay.sock' + +export type TerminalAuthorityOwnerOptions = Readonly<{ + stateDir: string + markerPath: string + processToken: string + takeover?: Readonly<{ ownerProcessToken: string; revision: number }> +}> + +export type RelayStartupOptions = Readonly<{ + graceTimeMs: number + connectMode: boolean + detached: boolean + terminalAuthority: boolean + controlAdapter: boolean + cliMode: boolean + sockPath: string + endpointDir?: string + logFile?: string + credentialFile?: string + launchFence?: RelayLaunchFence + authorityOwner?: TerminalAuthorityOwnerOptions + authorityConnectExpectation?: SshTerminalAuthorityEndpointIdentity + authorityGateway?: Readonly<{ + markerPath: string + authorityHostId: string + ownerInstanceId: string + revision: number + }> +}> + +export function parseRelayStartupOptions( + argv: readonly string[], + workingDirectory = process.cwd() +): RelayStartupOptions { + let graceTimeMs = DEFAULT_GRACE_MS + let connectMode = false + let detached = false + let terminalAuthority = false + let controlAdapter = false + let cliMode = false + let sockPath = '' + let endpointDir: string | undefined + let logFile: string | undefined + let credentialFile: string | undefined + let releaseLaunchInstallLock = false + let releaseLaunchInstallLockOwner: string | undefined + let releaseLaunchGcClaimOwner: string | undefined + let authorityStateDir: string | undefined + let authorityMarkerPath: string | undefined + let authorityProcessToken: string | undefined + let authorityTakeoverToken: string | undefined + let authorityTakeoverRevision: number | undefined + let authorityGatewayMarkerPath: string | undefined + let authorityGatewayHostId: string | undefined + let authorityGatewayOwnerInstanceId: string | undefined + let authorityGatewayRevision: number | undefined + let authorityExpectationHostId: string | undefined + let authorityExpectationOwnerInstanceId: string | undefined + let authorityExpectationRevision: number | undefined + + for (let i = 2; i < argv.length; i++) { + const value = argv[i] + const next = argv[i + 1] + if (value === '--grace-time' && next) { + const parsed = Number.parseInt(next, 10) + if (!Number.isNaN(parsed) && parsed >= 0) { + graceTimeMs = parsed * 1000 + } + i++ + } else if (value === '--connect') { + connectMode = true + } else if (value === '--orca-cli') { + cliMode = true + } else if (value === '--detached') { + detached = true + } else if (value === '--terminal-authority') { + terminalAuthority = true + } else if (value === '--control-adapter') { + controlAdapter = true + } else if (value === '--sock-path' && next) { + sockPath = next + i++ + } else if (value === '--endpoint-dir' && next) { + endpointDir = next + i++ + } else if (value === '--log-file' && next) { + logFile = next + i++ + } else if (value === '--credential-file' && next) { + credentialFile = next + i++ + } else if (value === '--release-launch-install-lock') { + releaseLaunchInstallLock = true + } else if (value === '--release-launch-install-lock-owner') { + if (!next || !isRelayLaunchFenceOwnerToken(next)) { + throw new Error('Invalid relay launch install lock owner token') + } + releaseLaunchInstallLockOwner = next + i++ + } else if (value === '--release-launch-gc-claim-owner') { + if (!next || !isRelayLaunchFenceOwnerToken(next)) { + throw new Error('Invalid relay launch GC claim owner token') + } + releaseLaunchGcClaimOwner = next + i++ + } else if (value === '--authority-state-dir' && next) { + authorityStateDir = next + i++ + } else if (value === '--authority-marker-path' && next) { + authorityMarkerPath = next + i++ + } else if (value === '--authority-process-token' && next) { + authorityProcessToken = next + i++ + } else if (value === '--authority-takeover-token' && next) { + authorityTakeoverToken = next + i++ + } else if (value === '--authority-takeover-revision' && next) { + const revision = Number(next) + if (Number.isSafeInteger(revision) && revision > 0) { + authorityTakeoverRevision = revision + } + i++ + } else if (value === '--authority-gateway-marker-path' && next) { + authorityGatewayMarkerPath = next + i++ + } else if (value === '--authority-gateway-host-id' && next) { + authorityGatewayHostId = next + i++ + } else if (value === '--authority-gateway-owner-instance' && next) { + authorityGatewayOwnerInstanceId = next + i++ + } else if (value === '--authority-gateway-revision' && next) { + const revision = Number(next) + if (Number.isSafeInteger(revision) && revision > 0) { + authorityGatewayRevision = revision + } + i++ + } else if (value === '--authority-expect-host-id' && next) { + authorityExpectationHostId = next + i++ + } else if (value === '--authority-expect-owner-instance' && next) { + authorityExpectationOwnerInstanceId = next + i++ + } else if (value === '--authority-expect-revision' && next) { + const revision = Number(next) + if (Number.isSafeInteger(revision) && revision > 0) { + authorityExpectationRevision = revision + } + i++ + } + } + + const authorityValues = [authorityStateDir, authorityMarkerPath, authorityProcessToken] + const hasAnyAuthorityValue = authorityValues.some((value) => value !== undefined) + const hasAllAuthorityValues = authorityValues.every((value) => value !== undefined) + if (hasAnyAuthorityValue && !hasAllAuthorityValues) { + throw new Error('Incomplete terminal authority owner arguments') + } + if ((authorityTakeoverToken === undefined) !== (authorityTakeoverRevision === undefined)) { + throw new Error('Incomplete terminal authority takeover arguments') + } + + const authorityOwner = + authorityStateDir && authorityMarkerPath && authorityProcessToken + ? { + stateDir: authorityStateDir, + markerPath: authorityMarkerPath, + processToken: authorityProcessToken, + ...(authorityTakeoverToken && authorityTakeoverRevision + ? { + takeover: { + ownerProcessToken: authorityTakeoverToken, + revision: authorityTakeoverRevision + } + } + : {}) + } + : undefined + if (Boolean(authorityOwner) !== terminalAuthority) { + throw new Error('Terminal authority mode requires one complete owner claim') + } + if (terminalAuthority && controlAdapter) { + throw new Error('Relay cannot be both a terminal authority and a control adapter') + } + const gatewayValues = [ + authorityGatewayMarkerPath, + authorityGatewayHostId, + authorityGatewayOwnerInstanceId, + authorityGatewayRevision + ] + const hasAnyGatewayValue = gatewayValues.some((value) => value !== undefined) + const hasAllGatewayValues = gatewayValues.every((value) => value !== undefined) + if (hasAnyGatewayValue && !hasAllGatewayValues) { + throw new Error('Incomplete terminal authority gateway arguments') + } + const authorityGateway = + authorityGatewayMarkerPath && + authorityGatewayHostId && + authorityGatewayOwnerInstanceId && + authorityGatewayRevision + ? { + markerPath: authorityGatewayMarkerPath, + authorityHostId: authorityGatewayHostId, + ownerInstanceId: authorityGatewayOwnerInstanceId, + revision: authorityGatewayRevision + } + : undefined + if (authorityGateway && !controlAdapter) { + throw new Error('Terminal authority gateway requires control-adapter mode') + } + const expectationValues = [ + authorityExpectationHostId, + authorityExpectationOwnerInstanceId, + authorityExpectationRevision + ] + const hasAnyExpectationValue = expectationValues.some((value) => value !== undefined) + const hasAllExpectationValues = expectationValues.every((value) => value !== undefined) + if (hasAnyExpectationValue && !hasAllExpectationValues) { + throw new Error('Incomplete terminal authority connect expectation') + } + const authorityConnectExpectation = + authorityExpectationHostId && + authorityExpectationOwnerInstanceId && + authorityExpectationRevision + ? { + authorityHostId: authorityExpectationHostId, + ownerInstanceId: authorityExpectationOwnerInstanceId, + revision: authorityExpectationRevision + } + : undefined + if (authorityConnectExpectation && !connectMode) { + throw new Error('Terminal authority expectation requires connect mode') + } + if (releaseLaunchInstallLock !== Boolean(releaseLaunchInstallLockOwner)) { + throw new Error('Incomplete relay launch install lock owner identity') + } + const launchFence = + releaseLaunchInstallLock || releaseLaunchGcClaimOwner + ? { + ...(releaseLaunchInstallLock ? { releaseInstallLock: true as const } : {}), + ...(releaseLaunchInstallLockOwner + ? { installLockOwnerToken: releaseLaunchInstallLockOwner } + : {}), + ...(releaseLaunchGcClaimOwner ? { gcClaimOwnerToken: releaseLaunchGcClaimOwner } : {}) + } + : undefined + if (launchFence && !connectMode && !(detached && controlAdapter)) { + throw new Error('Relay launch fence release requires connect or detached control mode') + } + + return { + graceTimeMs, + connectMode, + detached, + terminalAuthority, + controlAdapter, + cliMode, + sockPath: sockPath || join(workingDirectory, DEFAULT_SOCKET_NAME), + endpointDir, + logFile, + credentialFile, + ...(launchFence ? { launchFence } : {}), + ...(authorityOwner ? { authorityOwner } : {}), + ...(authorityConnectExpectation ? { authorityConnectExpectation } : {}), + ...(authorityGateway ? { authorityGateway } : {}) + } +} diff --git a/src/relay/relay-terminal-authority-grace-subprocess.test.ts b/src/relay/relay-terminal-authority-grace-subprocess.test.ts new file mode 100644 index 00000000000..feab68de8f5 --- /dev/null +++ b/src/relay/relay-terminal-authority-grace-subprocess.test.ts @@ -0,0 +1,379 @@ +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { build } from 'esbuild' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { relayTestSocketPath } from './relay-test-socket-path' +import { spawnRelay, type RelayProcess } from './subprocess-test-utils' +import { + SSH_TERMINAL_AUTHORITY_CONSUMER_CHALLENGE_METHOD, + SSH_TERMINAL_AUTHORITY_CONSUMER_GRANT_METHOD, + SSH_TERMINAL_AUTHORITY_CONSUMER_RESOLVE_NAMESPACE_METHOD +} from '../shared/terminal-authority-consumer-methods' +import { + TERMINAL_AUTHORITY_NAMESPACE_BOUNDARY_ACCEPT_METHOD, + TERMINAL_AUTHORITY_NAMESPACE_BOUNDARY_NOTIFICATION, + TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_ACK_METHOD, + TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_NOTIFICATION, + TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_VERSION, + type TerminalAuthorityNamespaceOutcomeBoundary, + type TerminalAuthorityNamespaceOutcomePublication +} from '../shared/terminal-session-authority-consumer-transport' +import { + TERMINAL_AUTHORITY_CONSUMER_PROOF_ALGORITHM, + TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION, + type TerminalAuthorityNamespaceAdmissionChallenge +} from '../shared/terminal-session-authority-consumer-proof' +import { + createTerminalAuthorityConsumerProof, + createTerminalAuthorityProofEphemeralKeypair +} from '../main/session-authority/terminal-session-authority-consumer-proof' +import type { TerminalAuthorityNamespace } from '../shared/terminal-session-authority-identity' + +const APP_KEYPAIR = createTerminalAuthorityProofEphemeralKeypair() + +const RELAY_ENTRY = path.resolve(__dirname, 'relay.ts') +const CREDENTIAL = 'authority-test-credential-0000000000000001' +const PROCESS_TOKEN = 'authority-process-token-0001' + +let bundleRoot: string +let relayEntry: string + +beforeAll(async () => { + bundleRoot = mkdtempSync(path.join(tmpdir(), 'relay-authority-grace-')) + relayEntry = path.join(bundleRoot, 'relay.js') + await build({ + entryPoints: [RELAY_ENTRY], + bundle: true, + platform: 'node', + target: 'node18', + format: 'cjs', + outfile: relayEntry, + external: ['node-pty', '@parcel/watcher', 'electron'] + }) + writeAuthorityNodePty(bundleRoot) +}, 30_000) + +afterAll(async () => { + await rm(bundleRoot, { recursive: true, force: true }) +}) + +describe('terminal authority relay grace lifecycle', () => { + it('retains PTYs without clients until exact retirement and allows administrative shutdown', async () => { + const root = mkdtempSync(path.join(tmpdir(), 'relay-authority-live-')) + const authority = spawnAuthority(root) + let bridge: RelayProcess | null = null + let stopDelivery: (() => void) | null = null + try { + await within(authority.sentinelReceived, 'authority sentinel') + await delay(750) + expect(authority.proc.exitCode).toBeNull() + const expectation = readAuthorityExpectation(root) + + bridge = spawnBridge(root, expectation) + stopDelivery = driveAuthorityDelivery(bridge) + await within(bridge.sentinelReceived, 'initial bridge sentinel') + const grant = await within(openAuthorityClient(bridge), 'authority client admission') + await within(admitAuthorityConsumer(bridge, root, 'first'), 'authority consumer admission') + const spawned = await within(spawnAuthorityPty(bridge, root), 'authority PTY spawn') + await expect( + within( + bridge.waitForResponse(bridge.send('relay.configureGraceTime', { graceTimeSeconds: 1 })), + 'grace configuration' + ) + ).resolves.toMatchObject({ result: { graceTimeMs: 1_000 } }) + + stopDelivery?.() + await stopBridge(bridge) + bridge = null + await delay(1_250) + expect(authority.proc.exitCode).toBeNull() + + bridge = spawnBridge(root, expectation) + stopDelivery = driveAuthorityDelivery(bridge) + await within(bridge.sentinelReceived, 'reconnect sentinel') + await within(openAuthorityClient(bridge, grant), 'authority client resume') + await within(admitAuthorityConsumer(bridge, root, 'resume'), 'authority consumer resume') + await expect( + within( + bridge.waitForResponse( + bridge.send('pty.shutdownAuthorityExact', { + id: spawned.id, + terminalSessionAuthorityAccess: spawned.terminalSessionAuthorityAccess, + immediate: false, + keepHistory: false + }) + ), + 'exact retirement' + ) + ).resolves.toMatchObject({ result: { accepted: true } }) + + stopDelivery?.() + await stopBridge(bridge) + bridge = null + await delay(1_250) + expect(authority.proc.exitCode).toBeNull() + + const exit = authority.waitForExit(5_000) + authority.kill('SIGTERM') + await expect(within(exit, 'administrative shutdown')).resolves.toBe(0) + } finally { + stopDelivery?.() + if (bridge) { + await stopBridge(bridge) + } + if (authority.proc.exitCode === null && authority.proc.signalCode === null) { + authority.proc.kill('SIGKILL') + await authority.waitForExit().catch(() => {}) + } + await rm(root, { recursive: true, force: true }) + } + }, 30_000) +}) + +function spawnAuthority(root: string): RelayProcess { + const stateDir = path.join(root, 'authority-state') + const credentialFile = path.join(root, 'endpoint.credential') + writeFileSync(credentialFile, `${CREDENTIAL}\n`, 'utf8') + return spawnRelay( + relayEntry, + [ + '--detached', + '--terminal-authority', + '--grace-time', + '1', + '--sock-path', + relayTestSocketPath(root), + '--endpoint-dir', + path.join(root, 'agent-hooks'), + '--credential-file', + credentialFile, + '--authority-state-dir', + stateDir, + '--authority-marker-path', + path.join(stateDir, 'active-endpoint'), + '--authority-process-token', + PROCESS_TOKEN + ], + { env: { ...process.env, ORCA_RELAY_EMPTY_STARTUP_GRACE_MS: '500' } } + ) +} + +function spawnBridge( + root: string, + expectation: { authorityHostId: string; ownerInstanceId: string; revision: number } +): RelayProcess { + return spawnRelay(relayEntry, [ + '--connect', + '--sock-path', + relayTestSocketPath(root), + '--credential-file', + path.join(root, 'endpoint.credential'), + '--authority-expect-host-id', + expectation.authorityHostId, + '--authority-expect-owner-instance', + expectation.ownerInstanceId, + '--authority-expect-revision', + String(expectation.revision) + ]) +} + +function readAuthorityExpectation(root: string): { + authorityHostId: string + ownerInstanceId: string + revision: number +} { + return JSON.parse( + readFileSync(path.join(root, 'authority-state', 'active-endpoint'), 'utf8') + ) as { authorityHostId: string; ownerInstanceId: string; revision: number } +} + +async function openAuthorityClient( + bridge: RelayProcess, + prior?: { ownerGeneration: number; ownerLease: string } +): Promise<{ ownerGeneration: number; ownerLease: string }> { + const response = await bridge.waitForResponse( + bridge.send('pty.openClient', { + protocolVersion: 1, + clientInstanceId: 'authority-grace-client', + requestedRole: 'session-owner', + ...(prior ? { resume: prior } : {}), + capabilities: { + terminalAuthorityExactOperations: { versions: [1] }, + terminalAuthorityConsumerProof: { versions: [TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION] } + } + }) + ) + expect(response.error).toBeUndefined() + expect(response.result).toMatchObject({ + role: 'session-owner', + capabilities: { + terminalAuthorityExactOperations: { version: 1 }, + terminalAuthorityConsumerProof: { version: TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION } + } + }) + const result = response.result as { ownerGeneration: number; ownerLease: string } + return { ownerGeneration: result.ownerGeneration, ownerLease: result.ownerLease } +} + +// The full challenge -> proof -> grant handshake. Nothing else can reach a durable consumer claim, +// so the grace journey only holds authority once this completes. +async function admitAuthorityConsumer( + bridge: RelayProcess, + root: string, + intent: 'first' | 'resume' +): Promise { + const resolved = await bridge.waitForResponse( + bridge.send(SSH_TERMINAL_AUTHORITY_CONSUMER_RESOLVE_NAMESPACE_METHOD, { + worktreeId: `repo::${root}` + }) + ) + expect(resolved.error).toBeUndefined() + const namespace = resolved.result as TerminalAuthorityNamespace + const challenged = await bridge.waitForResponse( + bridge.send(SSH_TERMINAL_AUTHORITY_CONSUMER_CHALLENGE_METHOD, { + start: { + version: TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION, + algorithm: TERMINAL_AUTHORITY_CONSUMER_PROOF_ALGORITHM, + namespace, + appPublicKeyB64: Buffer.from(APP_KEYPAIR.publicKey).toString('base64'), + candidateProcessIncarnationId: `app-process:grace-${intent}`, + candidateSessionNonce: `session-nonce:grace-${intent}`, + requestId: `grace-request-${intent}`, + intent + } + }) + ) + expect(challenged.error).toBeUndefined() + // The host stages the namespace boundary and waits for this acceptance before it will seal and + // append the claim, so the grant response only settles once the app answers. + const granted = await bridge.waitForResponse( + bridge.send(SSH_TERMINAL_AUTHORITY_CONSUMER_GRANT_METHOD, { + proof: createTerminalAuthorityConsumerProof( + challenged.result as TerminalAuthorityNamespaceAdmissionChallenge, + APP_KEYPAIR + ) + }), + 10_000 + ) + expect(granted.error).toBeUndefined() +} + +/** + * Stands in for the app's outcome consumer: accepts staged boundaries and ACKs published outcomes for + * as long as the bridge lives. Without it the host's publication never settles and exact retirement + * has nowhere to land. + */ +function driveAuthorityDelivery(bridge: RelayProcess): () => void { + let index = 0 + let stopped = false + const pump = setInterval(() => { + if (stopped) { + return + } + while (index < bridge.responses.length) { + const message = bridge.responses[index++]! + if (!('method' in message) || !message.params) { + continue + } + if (message.method === TERMINAL_AUTHORITY_NAMESPACE_BOUNDARY_NOTIFICATION) { + const { boundary } = message.params as { + boundary: TerminalAuthorityNamespaceOutcomeBoundary + } + bridge.send(TERMINAL_AUTHORITY_NAMESPACE_BOUNDARY_ACCEPT_METHOD, { + acceptance: { + version: TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_VERSION, + consumer: boundary.consumer, + namespace: boundary.namespace, + boundaryId: boundary.boundaryId, + acknowledgedSequence: boundary.acknowledgedSequence, + outcomeHighWatermark: boundary.outcomeHighWatermark + } + }) + } else if (message.method === TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_NOTIFICATION) { + const { publication } = message.params as { + publication: TerminalAuthorityNamespaceOutcomePublication + } + const tail = (publication.outcomes ?? [publication.outcome]).at(-1)! + bridge.send(TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_ACK_METHOD, { + ack: { + version: TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_VERSION, + consumer: publication.consumer, + namespace: publication.namespace, + sequence: tail.sequence, + outcomeId: tail.outcomeId + } + }) + } + } + }, 5) + pump.unref?.() + return () => { + stopped = true + clearInterval(pump) + } +} + +async function spawnAuthorityPty( + bridge: RelayProcess, + root: string +): Promise<{ id: string; terminalSessionAuthorityAccess: unknown }> { + const response = await bridge.waitForResponse( + bridge.send('pty.spawn', { + terminalSessionAuthorityVersion: 1, + paneKey: 'pane-a', + paneGeneration: 1, + worktreeId: `repo::${root}`, + cwd: root, + env: { ORCA_PANE_KEY: 'pane-a', ORCA_WORKTREE_ID: `repo::${root}` } + }) + ) + expect(response.error).toBeUndefined() + expect(response.result).toMatchObject({ + id: expect.any(String), + terminalSessionAuthorityAccess: expect.any(Object) + }) + return response.result as { id: string; terminalSessionAuthorityAccess: unknown } +} + +async function stopBridge(bridge: RelayProcess): Promise { + if (bridge.proc.exitCode !== null || bridge.proc.signalCode !== null) { + return + } + const exit = bridge.waitForExit(2_000) + bridge.kill('SIGTERM') + await exit.catch(() => bridge.proc.kill('SIGKILL')) +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +function within(promise: Promise, label: string): Promise { + return Promise.race([ + promise, + delay(10_000).then(() => { + throw new Error(`Timed out waiting for ${label}`) + }) + ]) +} + +function writeAuthorityNodePty(root: string): void { + const directory = path.join(root, 'node_modules', 'node-pty', 'lib') + mkdirSync(directory, { recursive: true }) + writeFileSync( + path.join(directory, 'index.js'), + `module.exports = { spawn() { + const exitHandlers = [] + return { + pid: process.pid, process: 'mock-shell', cols: 80, rows: 24, + onData() { return { dispose() {} } }, + onExit(callback) { exitHandlers.push(callback); return { dispose() {} } }, + write() {}, resize() {}, clear() {}, pause() {}, resume() {}, + kill() { setTimeout(() => exitHandlers.forEach((callback) => callback({ exitCode: 0 })), 0) } + } +} }\n`, + 'utf8' + ) +} diff --git a/src/relay/relay-watcher-removal-fence.ts b/src/relay/relay-watcher-removal-fence.ts deleted file mode 100644 index 9b0b62ab780..00000000000 --- a/src/relay/relay-watcher-removal-fence.ts +++ /dev/null @@ -1,131 +0,0 @@ -import type { - RelayWatcherTeardownState, - RelayWatcherTeardownTracker -} from './relay-watcher-teardown-tracker' -import type { RelayDispatcher } from './dispatcher' -import { emitRelayWatcherTerminalFailure } from './relay-watcher-terminal-notifier' -import { isPathInsideOrEqual } from '../shared/cross-platform-path' -import type { RelayWatcherPendingSetup } from './relay-watcher-setup-tracking' - -export class RelayWatcherRemovalFence { - private readonly roots = new Set() - private readonly operations = new Map() - private readonly operationWaiters = new Map void>>() - private beforeRemove: ((rootKey: string) => Promise) | null = null - - constructor( - private readonly watches: Map, - private readonly pendingSetups: Map, - private readonly teardownTracker: RelayWatcherTeardownTracker, - private readonly dispatcher: RelayDispatcher - ) {} - - isActive(rootKey: string): boolean { - return [...this.roots].some((activeRoot) => isPathInsideOrEqual(activeRoot, rootKey)) - } - - setBeforeRemove(beforeRemove: (rootKey: string) => Promise): void { - this.beforeRemove = beforeRemove - } - - beginOperation(operationPath: string): () => void { - if ([...this.roots].some((rootKey) => isPathInsideOrEqual(rootKey, operationPath))) { - throw new Error('Remote worktree deletion already in progress') - } - this.operations.set(operationPath, (this.operations.get(operationPath) ?? 0) + 1) - let finished = false - return () => { - if (finished) { - return - } - finished = true - const remaining = (this.operations.get(operationPath) ?? 1) - 1 - if (remaining > 0) { - this.operations.set(operationPath, remaining) - } else { - this.operations.delete(operationPath) - } - this.resolveOperationWaiters() - } - } - - async run(rootKey: string, operation: () => Promise): Promise { - if ( - [...this.roots].some( - (activeRoot) => - isPathInsideOrEqual(activeRoot, rootKey) || isPathInsideOrEqual(rootKey, activeRoot) - ) - ) { - throw new Error('Remote worktree deletion already in progress') - } - this.roots.add(rootKey) - try { - await this.waitForOperations(rootKey) - await this.closeRoot(rootKey) - await this.beforeRemove?.(rootKey) - return await operation() - } finally { - this.roots.delete(rootKey) - } - } - - private hasOperationInside(rootKey: string): boolean { - return [...this.operations.keys()].some((operationPath) => - isPathInsideOrEqual(rootKey, operationPath) - ) - } - - private waitForOperations(rootKey: string): Promise { - if (!this.hasOperationInside(rootKey)) { - return Promise.resolve() - } - return new Promise((resolve) => { - const waiters = this.operationWaiters.get(rootKey) ?? new Set<() => void>() - waiters.add(resolve) - this.operationWaiters.set(rootKey, waiters) - }) - } - - private resolveOperationWaiters(): void { - for (const [rootKey, waiters] of this.operationWaiters) { - if (this.hasOperationInside(rootKey)) { - continue - } - this.operationWaiters.delete(rootKey) - for (const resolve of waiters) { - resolve() - } - } - } - - private async closeRoot(rootKey: string): Promise { - const pending = [...this.pendingSetups.entries()].filter(([setupRoot]) => - isPathInsideOrEqual(rootKey, setupRoot) - ) - await Promise.all(pending.map(([, setup]) => setup.promise.catch(() => undefined))) - const states = [...this.watches.entries()] - .filter(([watchRoot]) => isPathInsideOrEqual(rootKey, watchRoot)) - .map(([, state]) => state) - for (const state of states) { - emitRelayWatcherTerminalFailure(this.dispatcher, state, 'Remote worktree is being removed') - state.clients.clear() - state.clientWatchIds.clear() - } - await Promise.all(states.map((state) => this.closeWatch(state))) - const trackedRoots = this.teardownTracker - .rootPaths() - .filter((trackedRoot) => isPathInsideOrEqual(rootKey, trackedRoot)) - for (const trackedRoot of trackedRoots) { - const failed = this.teardownTracker.failedState(trackedRoot) - await (failed ? this.closeWatch(failed) : this.teardownTracker.join(trackedRoot)) - } - } - - private closeWatch(state: RelayWatcherTeardownState): Promise { - return this.teardownTracker.close(state, () => { - if (this.watches.get(state.rootKey) === state) { - this.watches.delete(state.rootKey) - } - }) - } -} diff --git a/src/relay/relay-worktree-removal-fence.ts b/src/relay/relay-worktree-removal-fence.ts new file mode 100644 index 00000000000..6543e0e1440 --- /dev/null +++ b/src/relay/relay-worktree-removal-fence.ts @@ -0,0 +1,296 @@ +import type { + RelayWatcherTeardownState, + RelayWatcherTeardownTracker +} from './relay-watcher-teardown-tracker' +import type { RelayDispatcher } from './dispatcher' +import { emitRelayWatcherTerminalFailure } from './relay-watcher-terminal-notifier' +import { normalizeRuntimePathForComparison } from '../shared/cross-platform-path' +import type { RelayWatcherPendingSetup } from './relay-watcher-setup-tracking' + +export const MAX_RELAY_WORKTREE_REMOVAL_LEASES = 64 + +type ConnectionLease = Readonly<{ + clientId: number + leaseToken: string + readiness: Promise + rootKey: string +}> + +type OperationWaiter = Readonly<{ + isCurrent: () => boolean + resolve: () => void +}> + +export type RelayWorktreeRemovalPeerAcquire = (rootPath: string) => Promise<() => Promise> + +function isNormalizedPathInsideOrEqual(rootKey: string, candidateKey: string): boolean { + if (candidateKey === rootKey) { + return true + } + const boundary = rootKey === '/' || /^[a-z]:\/$/.test(rootKey) ? rootKey : `${rootKey}/` + return candidateKey.startsWith(boundary) +} + +export class RelayWorktreeRemovalFence { + private readonly roots = new Set() + private readonly operations = new Map() + private readonly operationWaiters = new Map>() + private readonly connectionLeases = new Map() + private beforeRemove: ((rootPath: string) => Promise) | null = null + private acquirePeer: RelayWorktreeRemovalPeerAcquire | null = null + private readonly removeClientDetachListener: () => void + private readonly removeDispatcherDisposeListener: () => void + + constructor( + private readonly watches: Map, + private readonly pendingSetups: Map, + private readonly teardownTracker: RelayWatcherTeardownTracker, + private readonly dispatcher: RelayDispatcher + ) { + this.removeClientDetachListener = + this.dispatcher.onClientDetached?.((clientId) => this.releaseClientLeases(clientId)) ?? + (() => {}) + this.removeDispatcherDisposeListener = + this.dispatcher.onDisposed?.(() => this.releaseAllConnectionLeases()) ?? (() => {}) + } + + isActive(rootPath: string): boolean { + const rootKey = normalizeRuntimePathForComparison(rootPath) + return [...this.roots].some((activeRoot) => isNormalizedPathInsideOrEqual(activeRoot, rootKey)) + } + + setBeforeRemove(beforeRemove: (rootPath: string) => Promise): void { + this.beforeRemove = beforeRemove + } + + setPeerAcquire(acquirePeer: RelayWorktreeRemovalPeerAcquire | null): void { + this.acquirePeer = acquirePeer + } + + beginOperation(operationPath: string): () => void { + const operationKey = normalizeRuntimePathForComparison(operationPath) + if ([...this.roots].some((rootKey) => isNormalizedPathInsideOrEqual(rootKey, operationKey))) { + throw new Error('Remote worktree deletion already in progress') + } + this.operations.set(operationKey, (this.operations.get(operationKey) ?? 0) + 1) + let finished = false + return () => { + if (finished) { + return + } + finished = true + const remaining = (this.operations.get(operationKey) ?? 1) - 1 + if (remaining > 0) { + this.operations.set(operationKey, remaining) + } else { + this.operations.delete(operationKey) + } + this.resolveOperationWaiters() + } + } + + async run(rootPath: string, operation: () => Promise): Promise { + const rootKey = normalizeRuntimePathForComparison(rootPath) + this.acquireRoot(rootKey) + let releasePeer: (() => Promise) | null = null + let outcome: { ok: true; value: T } | { ok: false; error: unknown } + try { + await this.prepareRoot(rootKey, rootPath) + releasePeer = (await this.acquirePeer?.(rootPath)) ?? null + outcome = { ok: true, value: await operation() } + } catch (error) { + outcome = { ok: false, error } + } + let releaseOutcome: { ok: true } | { ok: false; error: unknown } = { ok: true } + try { + await releasePeer?.() + } catch (error) { + releaseOutcome = { ok: false, error } + } + this.roots.delete(rootKey) + if (!releaseOutcome.ok) { + if (!outcome.ok) { + throw new AggregateError( + [outcome.error, releaseOutcome.error], + 'Worktree removal and authority fence release both failed' + ) + } + throw releaseOutcome.error + } + if (!outcome.ok) { + throw outcome.error + } + return outcome.value + } + + async acquireConnectionLease( + rootPath: string, + leaseToken: string, + clientId: number + ): Promise { + const leaseKey = this.connectionLeaseKey(clientId, leaseToken) + const rootKey = normalizeRuntimePathForComparison(rootPath) + const existing = this.connectionLeases.get(leaseKey) + if (existing) { + if (existing.rootKey !== rootKey) { + throw new Error('Terminal authority removal lease token was reused for another root') + } + await existing.readiness + return + } + this.acquireRoot(rootKey) + let lease: ConnectionLease + const readiness = Promise.resolve().then(() => + this.prepareRoot(rootKey, rootPath, () => this.connectionLeases.get(leaseKey) === lease) + ) + lease = Object.freeze({ clientId, leaseToken, readiness, rootKey }) + this.connectionLeases.set(leaseKey, lease) + try { + await readiness + } catch (error) { + this.releaseConnectionLeaseIfCurrent(leaseKey, lease) + throw error + } + } + + releaseConnectionLease(clientId: number, leaseToken: string): void { + const leaseKey = this.connectionLeaseKey(clientId, leaseToken) + const lease = this.connectionLeases.get(leaseKey) + if (lease) { + this.releaseConnectionLeaseIfCurrent(leaseKey, lease) + } + } + + dispose(): void { + this.removeClientDetachListener() + this.removeDispatcherDisposeListener() + this.releaseAllConnectionLeases() + } + + private acquireRoot(rootKey: string): void { + if ( + [...this.roots].some( + (activeRoot) => + isNormalizedPathInsideOrEqual(activeRoot, rootKey) || + isNormalizedPathInsideOrEqual(rootKey, activeRoot) + ) + ) { + throw new Error('Remote worktree deletion already in progress') + } + if (this.roots.size >= MAX_RELAY_WORKTREE_REMOVAL_LEASES) { + throw new Error('Remote worktree deletion capacity exceeded') + } + this.roots.add(rootKey) + } + + private async prepareRoot( + rootKey: string, + rootPath: string, + isCurrent: () => boolean = () => true + ): Promise { + await this.waitForOperations(rootKey, isCurrent) + this.assertLeaseCurrent(isCurrent) + await this.closeRoot(rootKey) + this.assertLeaseCurrent(isCurrent) + await this.beforeRemove?.(rootPath) + this.assertLeaseCurrent(isCurrent) + } + + private assertLeaseCurrent(isCurrent: () => boolean): void { + if (!isCurrent()) { + throw new Error('Terminal authority removal lease was released') + } + } + + private hasOperationInside(rootKey: string): boolean { + return [...this.operations.keys()].some((operationPath) => + isNormalizedPathInsideOrEqual(rootKey, operationPath) + ) + } + + private waitForOperations(rootKey: string, isCurrent: () => boolean): Promise { + if (!isCurrent() || !this.hasOperationInside(rootKey)) { + return Promise.resolve() + } + return new Promise((resolve) => { + const waiters = this.operationWaiters.get(rootKey) ?? new Set() + waiters.add({ isCurrent, resolve }) + this.operationWaiters.set(rootKey, waiters) + }) + } + + private resolveOperationWaiters(): void { + for (const [rootKey, waiters] of this.operationWaiters) { + const operationActive = this.hasOperationInside(rootKey) + for (const waiter of waiters) { + if (!operationActive || !waiter.isCurrent()) { + waiters.delete(waiter) + waiter.resolve() + } + } + if (waiters.size === 0) { + this.operationWaiters.delete(rootKey) + } + } + } + + private async closeRoot(rootKey: string): Promise { + const pending = [...this.pendingSetups.entries()].filter(([setupRoot]) => + isNormalizedPathInsideOrEqual(rootKey, setupRoot) + ) + await Promise.all(pending.map(([, setup]) => setup.promise.catch(() => undefined))) + const states = [...this.watches.entries()] + .filter(([watchRoot]) => isNormalizedPathInsideOrEqual(rootKey, watchRoot)) + .map(([, state]) => state) + for (const state of states) { + emitRelayWatcherTerminalFailure(this.dispatcher, state, 'Remote worktree is being removed') + state.clients.clear() + state.clientWatchIds.clear() + } + await Promise.all(states.map((state) => this.closeWatch(state))) + const trackedRoots = this.teardownTracker + .rootPaths() + .filter((trackedRoot) => + isNormalizedPathInsideOrEqual(rootKey, normalizeRuntimePathForComparison(trackedRoot)) + ) + for (const trackedRoot of trackedRoots) { + const failed = this.teardownTracker.failedState(trackedRoot) + await (failed ? this.closeWatch(failed) : this.teardownTracker.join(trackedRoot)) + } + } + + private closeWatch(state: RelayWatcherTeardownState): Promise { + return this.teardownTracker.close(state, () => { + if (this.watches.get(state.rootKey) === state) { + this.watches.delete(state.rootKey) + } + }) + } + + private releaseClientLeases(clientId: number): void { + for (const [leaseKey, lease] of this.connectionLeases) { + if (lease.clientId === clientId) { + this.releaseConnectionLeaseIfCurrent(leaseKey, lease) + } + } + } + + private releaseAllConnectionLeases(): void { + for (const [leaseKey, lease] of this.connectionLeases) { + this.releaseConnectionLeaseIfCurrent(leaseKey, lease) + } + } + + private releaseConnectionLeaseIfCurrent(leaseKey: string, lease: ConnectionLease): void { + if (this.connectionLeases.get(leaseKey) !== lease) { + return + } + this.connectionLeases.delete(leaseKey) + this.roots.delete(lease.rootKey) + this.resolveOperationWaiters() + } + + private connectionLeaseKey(clientId: number, leaseToken: string): string { + return `${clientId}:${leaseToken}` + } +} diff --git a/src/relay/relay.ts b/src/relay/relay.ts index 1da1dd70b5d..06185abed79 100644 --- a/src/relay/relay.ts +++ b/src/relay/relay.ts @@ -9,7 +9,8 @@ // reconnects via `relay.js --connect`, bridging the new SSH channel's stdio to the existing relay's socket. import { createServer, createConnection, type Socket, type Server } from 'node:net' -import { join } from 'node:path' +import { createHash } from 'node:crypto' +import { dirname, join } from 'node:path' import { unlinkSync, existsSync, @@ -28,8 +29,13 @@ import { type DecodedFrame, type JsonRpcResponse } from './protocol' -import { readLaunchVersion, runConnectHandshake, setupDaemonHandshake } from './relay-handshake' -import { RelayDispatcher } from './dispatcher' +import { + runConnectHandshake, + setupDaemonHandshake, + type DaemonHandshakeClientRole +} from './relay-handshake' +import { readLaunchVersion } from './relay-launch-version' +import { RelayDispatcher, type RelayClientSessionIdentity } from './dispatcher' import { RelayContext, expandTilde } from './context' import { PtyHandler } from './pty-handler' import { FsHandler } from './fs-handler' @@ -48,10 +54,7 @@ import { AGENT_HOOK_REQUEST_REPLAY_METHOD } from '../shared/agent-hook-relay' import { publishAgentHookEnvelope } from './agent-hook-envelope-publication' -import { - DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS, - SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD -} from '../shared/ssh-types' +import { SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD } from '../shared/ssh-types' import { assertPluginSourceUnderByteCap } from './plugin-source-limit' import { resolveOpenCodeSourceConfigDir, resolvePiSourceAgentDir } from './plugin-overlay-env' import { @@ -63,7 +66,9 @@ import { pickRemoteCliEnv } from './remote-cli-env' import { applyRelayGraceTimeConfiguration, decideRelayGrace, - type RelayGraceBranch + mayDisposeRelayPtysForShutdown, + type RelayGraceBranch, + type RelayShutdownCause } from './relay-grace-branch' import { relayLogLine } from './relay-diagnostic-log' import { remoteCliRequestTimeoutMs } from './remote-cli-timeout' @@ -73,9 +78,44 @@ import { registerRelayPluginHostCallHandlers } from './plugin-host-call-handler' import { DispatcherClientWriter } from './dispatcher-client-writer' import { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter' import { RelayPtySourcePublication } from './relay-pty-source-publication' +import { claimTerminalAuthorityOwnership } from './terminal-authority-owner-marker' +import { parseRelayStartupOptions } from './relay-startup-options' +import { + terminalAuthorityEndpointIdentity, + type SshTerminalAuthorityEndpointIdentity, + type SshTerminalAuthorityMarker +} from '../shared/ssh-terminal-authority-marker' +import { connectTerminalAuthorityGateway } from './terminal-authority-gateway-connection' +import { TerminalAuthorityGateway } from './terminal-authority-gateway' +import { TerminalAuthorityControlClient } from './terminal-authority-control-client' +import { + TERMINAL_AUTHORITY_ACQUIRE_WORKTREE_REMOVAL_METHOD, + TERMINAL_AUTHORITY_CONFIGURE_GRACE_TIME_METHOD, + TERMINAL_AUTHORITY_RELEASE_WORKTREE_REMOVAL_METHOD, + assertAuthenticatedTerminalAuthorityControl, + parseRelayGraceTimeSeconds, + parseTerminalAuthorityWorktreeRemovalParams +} from './terminal-authority-control-protocol' +import { configureAcknowledgedRelayGraceTime } from './relay-grace-time-coordinator' +import { TerminalSessionAuthorityRegistry } from '../main/session-authority/terminal-session-authority-registry' +import { TerminalSessionAuthorityPtyLifecycle } from '../main/session-authority/terminal-session-authority-pty-lifecycle' +import { TerminalAuthorityTopologyPublisher } from './terminal-authority-topology-publisher' +import { RemoteCliLauncherInstaller } from './remote-cli-launcher-installer' +import { releaseRelayLaunchFence, type RelayLaunchFence } from './relay-launch-fence' +import { LegacyPhysicalWorkerRegistry } from './legacy-physical-worker-registry' +import { preserveLegacyPhysicalWorkerAuthorityRoutes } from './legacy-physical-worker-authority-preservation' +import { legacyPhysicalWorkerRelayState } from './legacy-physical-worker-relay-state' +import { LegacyPhysicalWorkerAuthorityHost } from './legacy-physical-worker-authority-host' +import { LegacyPhysicalWorkerAuthorityRouter } from './legacy-physical-worker-authority-router' +import { LegacyPhysicalWorkerDownstream } from './legacy-physical-worker-downstream' +import { FileLegacyPtyProxyCursorRepository } from './legacy-pty-proxy-cursor-repository' +import { LegacyRelayPostMigrationGc } from './legacy-relay-post-migration-gc' +import { registerLegacyPhysicalWorkerControlSurface } from './legacy-physical-worker-control-surface' +import type { TerminalLegacyGcProtection } from '../shared/terminal-legacy-cutover' +import { mergeLegacyPhysicalWorkerGcProtection } from './legacy-physical-worker-gc-protection' +import { RegistryTerminalAuthorityExactPtyAccessResolver } from './terminal-authority-exact-pty-access' +import { terminalAuthorityRegistryOwnerTokenIsGone } from './terminal-authority-registry-owner-token' -const DEFAULT_GRACE_MS = DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS * 1000 -const SOCK_NAME = 'relay.sock' const CONNECT_TIMEOUT_MS = 5_000 const STALE_SOCKET_PROBE_TIMEOUT_MS = 500 const EMPTY_DETACHED_STARTUP_GRACE_MS = parseNonNegativeIntEnv( @@ -121,67 +161,6 @@ function isWindowsNamedPipePath(sockPath: string): boolean { return process.platform === 'win32' && /^\\\\[.?]\\pipe\\/i.test(sockPath) } -function parseArgs(argv: string[]): { - graceTimeMs: number - connectMode: boolean - detached: boolean - cliMode: boolean - sockPath: string - endpointDir?: string - logFile?: string - credentialFile?: string -} { - let graceTimeMs = DEFAULT_GRACE_MS - let connectMode = false - let detached = false - let cliMode = false - let sockPath = '' - let endpointDir: string | undefined - let logFile: string | undefined - let credentialFile: string | undefined - for (let i = 2; i < argv.length; i++) { - if (argv[i] === '--grace-time' && argv[i + 1]) { - const parsed = Number.parseInt(argv[i + 1], 10) - // Why: flag is seconds (internally ms); 0 keeps the relay alive until explicitly terminated for synced workspaces. - if (!Number.isNaN(parsed) && parsed >= 0) { - graceTimeMs = parsed * 1000 - } - i++ - } else if (argv[i] === '--connect') { - connectMode = true - } else if (argv[i] === '--orca-cli') { - cliMode = true - } else if (argv[i] === '--detached') { - detached = true - } else if (argv[i] === '--sock-path' && argv[i + 1]) { - sockPath = argv[i + 1] - i++ - } else if (argv[i] === '--endpoint-dir' && argv[i + 1]) { - endpointDir = argv[i + 1] - i++ - } else if (argv[i] === '--log-file' && argv[i + 1]) { - logFile = argv[i + 1] - i++ - } else if (argv[i] === '--credential-file' && argv[i + 1]) { - credentialFile = argv[i + 1] - i++ - } - } - if (!sockPath) { - sockPath = join(process.cwd(), SOCK_NAME) - } - return { - graceTimeMs, - connectMode, - detached, - cliMode, - sockPath, - endpointDir, - logFile, - credentialFile - } -} - function readEndpointCredential(credentialFile: string | undefined): string | undefined { if (!credentialFile) { return undefined @@ -199,7 +178,12 @@ function readEndpointCredential(credentialFile: string | undefined): string | un // ── Connect mode ───────────────────────────────────────────────────── // Why: --connect bridges a new SSH channel's stdin/stdout to the existing relay's socket so the client keeps talking to the process that owns the live PTYs. -function runConnectMode(sockPath: string, endpointCredential?: string): void { +function runConnectMode( + sockPath: string, + endpointCredential?: string, + authorityExpectation?: SshTerminalAuthorityEndpointIdentity, + launchFence?: RelayLaunchFence +): void { const myVersion = readLaunchVersion() const sock = createConnection({ path: sockPath }) const stdoutWriter = new DispatcherClientWriter( @@ -235,6 +219,16 @@ function runConnectMode(sockPath: string, endpointCredential?: string): void { myVersion, { onAccepted: (leftover: Buffer) => { + try { + releaseRelayLaunchFence(process.cwd(), launchFence) + } catch (error) { + process.stderr.write( + `[relay-connect] Launch fence release failed: ${error instanceof Error ? error.message : String(error)}\n` + ) + sock.destroy() + process.exit(1) + return + } stdoutWriter.enqueue('control', () => Buffer.from(RELAY_SENTINEL), RELAY_SENTINEL.length) if (leftover.length > 0) { stdoutWriter.enqueue('control', () => leftover, leftover.length) @@ -275,7 +269,8 @@ function runConnectMode(sockPath: string, endpointCredential?: string): void { }) } }, - endpointCredential + endpointCredential, + authorityExpectation ) }) @@ -507,12 +502,18 @@ async function main(): Promise { sockPath, endpointDir, logFile, - credentialFile - } = parseArgs(process.argv) + credentialFile, + terminalAuthority, + controlAdapter, + authorityOwner, + authorityConnectExpectation, + authorityGateway, + launchFence + } = parseRelayStartupOptions(process.argv) const endpointCredential = readEndpointCredential(credentialFile) if (connectMode) { - runConnectMode(sockPath, endpointCredential) + runConnectMode(sockPath, endpointCredential, authorityConnectExpectation, launchFence) return } if (cliMode) { @@ -525,6 +526,30 @@ async function main(): Promise { return } + let mayRemoveStaleSocket = authorityOwner === undefined + let terminalAuthorityMarker: SshTerminalAuthorityMarker | undefined + let terminalAuthorityReplacedMarker: SshTerminalAuthorityMarker | undefined + if (terminalAuthority && authorityOwner) { + if (!detached || !credentialFile) { + throw new Error('Terminal authority ownership requires detached mode and a credential') + } + const claim = await claimTerminalAuthorityOwnership({ + ...authorityOwner, + ownerBuildId: readLaunchVersion(), + ownerRelayDir: process.cwd(), + socketPath: sockPath, + credentialFile + }) + if (claim.status !== 'claimed') { + relayLogLine(`[relay] Terminal authority owner claim refused: ${claim.status}`) + process.exitCode = 43 + return + } + terminalAuthorityMarker = claim.marker + terminalAuthorityReplacedMarker = claim.replacedMarker + mayRemoveStaleSocket = claim.mayRemoveStaleSocket + } + // Why: only the long-lived detached daemon accumulates relay.log; route it through a size-capped rotator so it can't grow forever. if (detached && logFile) { installRelayLogRotation(logFile) @@ -636,6 +661,64 @@ async function main(): Promise { { pauseReads: () => process.stdin.pause(), resumeReads: () => process.stdin.resume() } ) const launchVersion = readLaunchVersion() + let terminalAuthorityGateway: TerminalAuthorityGateway | null = null + let terminalAuthorityControl: TerminalAuthorityControlClient | null = null + let terminalSessionAuthorityRegistry: TerminalSessionAuthorityRegistry | null = null + let terminalAuthorityTopologyPublisher: TerminalAuthorityTopologyPublisher | null = null + let terminalSessionAuthorityLifecycle: TerminalSessionAuthorityPtyLifecycle | undefined + let legacyPhysicalWorkerRegistry: LegacyPhysicalWorkerRegistry | null = null + let legacyPhysicalWorkerHost: LegacyPhysicalWorkerAuthorityHost | null = null + let legacyPhysicalWorkerStateDirectory: string | null = null + const terminateForTerminalAuthorityFailure = (error: Error): never => { + relayLogLine(`[relay] Terminal session authority failed: ${error.message}\n${error.stack}`) + cleanupOwnedSocket() + process.exit(1) + } + if (terminalAuthorityMarker && authorityOwner) { + const registryOwnerToken = terminalAuthorityMarker.registryWriterOwnerToken + if (!registryOwnerToken) { + throw new Error('Terminal authority registry owner proof is missing') + } + const takeoverOwnerToken = terminalAuthorityReplacedMarker?.registryWriterOwnerToken + terminalSessionAuthorityRegistry = await TerminalSessionAuthorityRegistry.open({ + directory: join(authorityOwner.stateDir, 'session-authority'), + authorityHostId: terminalAuthorityMarker.authorityHostId, + ownerToken: registryOwnerToken, + ...(takeoverOwnerToken ? { takeoverOwnerToken } : {}), + writerClaimIsGone: terminalAuthorityRegistryOwnerTokenIsGone, + ownerIncarnationId: terminalAuthorityMarker.ownerInstanceId, + writerActorId: terminalAuthorityMarker.ownerInstanceId + }) + terminalAuthorityTopologyPublisher = new TerminalAuthorityTopologyPublisher( + dispatcher, + terminalSessionAuthorityRegistry, + terminateForTerminalAuthorityFailure + ) + legacyPhysicalWorkerRegistry = new LegacyPhysicalWorkerRegistry() + preserveLegacyPhysicalWorkerAuthorityRoutes( + legacyPhysicalWorkerRegistry, + terminalSessionAuthorityRegistry.legacy.projection().workers + ) + legacyPhysicalWorkerHost = new LegacyPhysicalWorkerAuthorityHost( + legacyPhysicalWorkerRegistry, + terminalSessionAuthorityRegistry.legacy + ) + legacyPhysicalWorkerStateDirectory = join(authorityOwner.stateDir, 'legacy-physical-workers') + const restoredWorkers = await legacyPhysicalWorkerHost.restoreAuthorityWorkers() + for (const worker of restoredWorkers) { + if (worker.status === 'unreachable') { + relayLogLine( + `[relay] Legacy physical worker ${worker.routeId} is unreachable: ${worker.reason ?? 'unknown'}` + ) + } + } + terminalSessionAuthorityLifecycle = new TerminalSessionAuthorityPtyLifecycle( + terminalSessionAuthorityRegistry, + terminalAuthorityMarker.ownerInstanceId + ) + await terminalSessionAuthorityLifecycle.start() + } + const terminalSessionAuthorityAdmitted = terminalSessionAuthorityLifecycle !== undefined const context = new RelayContext() @@ -664,13 +747,114 @@ async function main(): Promise { return { resolvedPath: expandTilde(inputPath) } }) - const ptyHandler = new PtyHandler(dispatcher, graceTimeMs) + const ptyHandler = new PtyHandler(dispatcher, graceTimeMs, { + controlAdapter, + ...(terminalSessionAuthorityLifecycle + ? { + terminalSessionAuthority: terminalSessionAuthorityLifecycle, + terminalAuthorityExactPtyAccessResolver: + new RegistryTerminalAuthorityExactPtyAccessResolver( + terminalSessionAuthorityRegistry!, + terminalAuthorityMarker!.ownerInstanceId + ), + onTerminalSessionAuthorityFailure: terminateForTerminalAuthorityFailure + } + : {}) + }) + const removeTerminalSessionAuthorityHostEffectApplier = terminalSessionAuthorityLifecycle + ? terminalSessionAuthorityLifecycle.installHostEffectApplier({ + ensureBindingRetired: (access, reason) => + ptyHandler.ensureTerminalSessionAuthorityBindingRetired(access, reason) + }) + : (): void => {} const ptyConsumerSessionAdapter = new SshPtyConsumerSessionAdapter( dispatcher, launchVersion, - (id, paused) => ptyHandler.setConsumerDeliveryPaused(id, paused), - (id) => ptyHandler.handleSourceCreditAvailable(id) + (id, paused, identity, heldPause) => { + if (heldPause) { + return ptyHandler.setConsumerHeldProducerPause( + id, + heldPause.incarnationId, + heldPause.token, + paused + ) + } + ptyHandler.setConsumerDeliveryPaused(id, paused, identity) + return true + }, + (id, identity) => ptyHandler.handleSourceCreditAvailable(id, identity), + { + ...(terminalAuthority ? { ownerScope: 'principal-client-instance' as const } : {}), + terminalAuthorityExactOperations: terminalSessionAuthorityLifecycle !== undefined, + ...(terminalSessionAuthorityLifecycle + ? { + terminalAuthorityOutcomeDelivery: false, + terminalAuthorityPolicyConsumers: terminalSessionAuthorityLifecycle, + terminalAuthorityConsumerProofHostId: terminalAuthorityMarker!.authorityHostId + } + : {}) + } ) + ptyHandler.setTerminalAuthorityPolicyConsumerForClient((clientId) => + ptyConsumerSessionAdapter.terminalAuthorityPolicyConsumer(clientId) + ) + if ( + legacyPhysicalWorkerRegistry && + legacyPhysicalWorkerHost && + legacyPhysicalWorkerStateDirectory && + authorityOwner && + terminalAuthorityMarker + ) { + const physicalWorkerHost = legacyPhysicalWorkerHost + const physicalWorkerStateDirectory = legacyPhysicalWorkerStateDirectory + const authorityStateDirectory = authorityOwner.stateDir + const cursorRepository = await FileLegacyPtyProxyCursorRepository.open( + join(physicalWorkerStateDirectory, 'proxy-cursors.json') + ) + const physicalWorkerRouter = new LegacyPhysicalWorkerAuthorityRouter({ + registry: legacyPhysicalWorkerRegistry, + downstream: new LegacyPhysicalWorkerDownstream(dispatcher, ptyConsumerSessionAdapter), + cursors: cursorRepository, + ...(terminalSessionAuthorityLifecycle + ? { + recordExit: async (request, code) => { + await terminalSessionAuthorityLifecycle.recordImportedExit( + { + worktreeId: request.worktreeId, + pane: request.pane, + binding: request.binding + }, + code + ) + } + } + : {}), + onWorkerFault: terminateForTerminalAuthorityFailure + }) + ptyHandler.setLegacyPhysicalWorkerPtyRouter(physicalWorkerRouter) + const completeLegacyGcProtection = (): TerminalLegacyGcProtection => + mergeLegacyPhysicalWorkerGcProtection([ + physicalWorkerHost.gcProtection(), + Object.freeze({ + relayDirectories: Object.freeze([]), + evidencePaths: Object.freeze([authorityStateDirectory]) + }) + ]) + const physicalWorkerGc = await LegacyRelayPostMigrationGc.open({ + directory: join(physicalWorkerStateDirectory, 'post-migration-gc'), + catalogRevision: () => physicalWorkerHost.catalogRevision(), + protection: completeLegacyGcProtection, + eligible: () => physicalWorkerHost.gcEligible(), + allowedRoots: [dirname(terminalAuthorityMarker.ownerRelayDir)] + }) + registerLegacyPhysicalWorkerControlSurface({ + dispatcher, + host: physicalWorkerHost, + gc: physicalWorkerGc, + hasActiveClient: (clientId) => ptyConsumerSessionAdapter.hasActiveClient(clientId), + protection: completeLegacyGcProtection + }) + } const ptySourcePublication = new RelayPtySourcePublication( dispatcher, ptyConsumerSessionAdapter, @@ -680,9 +864,47 @@ async function main(): Promise { const fsHandler = new FsHandler(dispatcher, context) const watchRegistry = fsHandler.getWatchRegistry() ptyHandler.setWorktreeRemovalCoordinator(watchRegistry) - watchRegistry.setWorktreePtyTeardown((rootPath) => ptyHandler.shutdownForWorktreePath(rootPath)) + watchRegistry.worktreeRemovalFence.setBeforeRemove((rootPath) => + ptyHandler.shutdownForWorktreePath(rootPath) + ) const gitHandler = new GitHandler(dispatcher, context, watchRegistry) + if (terminalAuthority) { + dispatcher.onRequest( + TERMINAL_AUTHORITY_CONFIGURE_GRACE_TIME_METHOD, + async (params, requestContext) => { + assertAuthenticatedTerminalAuthorityControl(requestContext) + const graceTimeSeconds = parseRelayGraceTimeSeconds(params) + return applyLocalRelayGraceTime(graceTimeSeconds) + } + ) + dispatcher.onRequest( + TERMINAL_AUTHORITY_ACQUIRE_WORKTREE_REMOVAL_METHOD, + async (params, requestContext) => { + assertAuthenticatedTerminalAuthorityControl(requestContext) + const { leaseToken, rootPath } = parseTerminalAuthorityWorktreeRemovalParams(params) + await watchRegistry.worktreeRemovalFence.acquireConnectionLease( + rootPath, + leaseToken, + requestContext.clientId + ) + return { leaseToken } + } + ) + dispatcher.onRequest( + TERMINAL_AUTHORITY_RELEASE_WORKTREE_REMOVAL_METHOD, + async (params, requestContext) => { + assertAuthenticatedTerminalAuthorityControl(requestContext) + const { leaseToken } = parseTerminalAuthorityWorktreeRemovalParams(params) + watchRegistry.worktreeRemovalFence.releaseConnectionLease( + requestContext.clientId, + leaseToken + ) + return { leaseToken } + } + ) + } + const _preflightHandler = new PreflightHandler(dispatcher) const _externalAutomationsHandler = new ExternalAutomationsHandler(dispatcher) void _preflightHandler @@ -694,6 +916,9 @@ async function main(): Promise { const _agentExecHandler = new AgentExecHandler(dispatcher) void _agentExecHandler + const _remoteCliLauncherInstaller = new RemoteCliLauncherInstaller(dispatcher) + void _remoteCliLauncherInstaller + const _workspaceSessionHandler = new WorkspaceSessionHandler(dispatcher) void _workspaceSessionHandler @@ -721,8 +946,8 @@ async function main(): Promise { }) }) - function configureRelayGraceTime(params: Record): { graceTimeMs: number } { - return applyRelayGraceTimeConfiguration(params.graceTimeSeconds, { + function applyLocalRelayGraceTime(graceTimeSeconds: unknown): { graceTimeMs: number } { + return applyRelayGraceTimeConfiguration(graceTimeSeconds, { readConfiguredGraceMs: () => ptyHandler.configuredGraceTimeMs, writeConfiguredGraceMs: (graceMs) => ptyHandler.setGraceTimeMs(graceMs), isGraceTimerArmed: () => graceDeadlineAt !== null && graceReason !== null, @@ -733,11 +958,27 @@ async function main(): Promise { } dispatcher.onNotification(SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD, (params) => { - configureRelayGraceTime(params) + // Split mode requires the request/response form so both processes acknowledge the same value. + if (!controlAdapter) { + applyLocalRelayGraceTime(params.graceTimeSeconds) + } + }) + dispatcher.onRequest(SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD, async (params) => { + return await configureAcknowledgedRelayGraceTime({ + params, + configureControl: applyLocalRelayGraceTime, + ...(controlAdapter + ? { + configureAuthority: async (graceTimeSeconds: number) => { + if (!terminalAuthorityControl) { + throw new Error('Terminal authority gateway is unavailable') + } + return await terminalAuthorityControl.configureGraceTime(graceTimeSeconds) + } + } + : {}) + }) }) - dispatcher.onRequest(SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD, async (params) => - configureRelayGraceTime(params) - ) // ── Agent-hook server ───────────────────────────────────────────── // Why: loopback HTTP receiver so remote-PTY agent CLIs post hook events locally, forwarded to Orca as agent.hook notifications. See docs/design/agent-status-over-ssh.md §2-§5. @@ -874,6 +1115,29 @@ async function main(): Promise { // Why: only the idle branch is a "nothing left to preserve" bet, so only it may be revoked when a // PTY appears mid-window; the other branches keep their armed deadline. let graceBranch: RelayGraceBranch | null = null + if (controlAdapter) { + if (!authorityGateway) { + throw new Error('Control adapter requires an exact terminal authority gateway binding') + } + const connection = await connectTerminalAuthorityGateway(authorityGateway, launchVersion) + terminalAuthorityGateway = new TerminalAuthorityGateway( + dispatcher, + connection.mux, + (error) => { + relayLogLine(`[relay] Terminal authority gateway failed: ${error.message}`) + cleanupOwnedSocket() + process.exit(1) + }, + connection.compatibility.capabilities + ) + terminalAuthorityControl = new TerminalAuthorityControlClient(connection.mux) + watchRegistry.worktreeRemovalFence.setPeerAcquire((rootPath) => { + if (!terminalAuthorityControl) { + return Promise.reject(new Error('Terminal authority gateway is unavailable')) + } + return terminalAuthorityControl.acquireWorktreeRemoval(rootPath) + }) + } dispatcher.onRequest('relay.status', async () => ({ pid: process.pid, @@ -882,7 +1146,10 @@ async function main(): Promise { stdoutAlive, memory: process.memoryUsage(), ptys: { - active: ptyHandler.activePtyCount + active: ptyHandler.activePtyCount, + ...(legacyPhysicalWorkerRegistry + ? { legacyPhysicalWorkers: legacyPhysicalWorkerRegistry.lifecycleCounts() } + : {}) }, ptySourceCredit: { enabled: true, @@ -913,7 +1180,11 @@ async function main(): Promise { ptyHandler.cancelGraceTimer() } - function attachAcceptedSocket(sock: Socket, leftover: Buffer): void { + function attachAcceptedSocket( + sock: Socket, + leftover: Buffer, + clientRole: DaemonHandshakeClientRole + ): void { // Why: remove the initial stdin data listener once a socket client is accepted, so stale SSH-channel bytes can't interleave. process.stdin.pause() process.stdin.removeAllListeners('data') @@ -936,6 +1207,22 @@ async function main(): Promise { sock.on('drain', flushSockDrainWaiters) sock.on('close', flushSockDrainWaiters) sock.on('error', flushSockDrainWaiters) + const sessionIdentity: RelayClientSessionIdentity = + clientRole === 'terminal-authority' && terminalAuthorityMarker + ? { + principal: `terminal-authority:${terminalAuthorityMarker.authorityHostId}:${terminalAuthorityMarker.ownerInstanceId}:${terminalAuthorityMarker.revision}`, + authenticated: true, + allowSessionOwner: true, + authenticationKind: 'endpoint-credential' + } + : { + principal: endpointCredential + ? `relay-endpoint:v1:${createHash('sha256').update(endpointCredential).digest('base64url')}` + : `relay-unproved:${launchVersion}`, + authenticated: endpointCredential !== undefined, + allowSessionOwner: endpointCredential !== undefined, + authenticationKind: endpointCredential ? 'endpoint-credential' : 'unproved' + } const clientId = dispatcher.attachClient( (data, onSettled) => { if (!sock.destroyed) { @@ -960,12 +1247,7 @@ async function main(): Promise { return () => sockDrainWaiters.delete(cb) } }, - { - principal: `relay-endpoint:${launchVersion}`, - authenticated: endpointCredential !== undefined, - allowSessionOwner: endpointCredential !== undefined, - authenticationKind: endpointCredential ? 'endpoint-credential' : 'unproved' - }, + sessionIdentity, { pauseReads: () => sock.pause(), resumeReads: () => sock.resume() @@ -990,6 +1272,9 @@ async function main(): Promise { setupDaemonHandshake(sock, { launchVersion, endpointCredential, + ...(terminalAuthorityMarker + ? { authorityIdentity: terminalAuthorityEndpointIdentity(terminalAuthorityMarker) } + : {}), onAccepted: attachAcceptedSocket }) @@ -1093,6 +1378,10 @@ async function main(): Promise { failInitial(err) return } + if (!mayRemoveStaleSocket) { + failInitial(err) + return + } if (isWindowsNamedPipePath(sockPath)) { failInitial(err) return @@ -1154,6 +1443,7 @@ async function main(): Promise { } catch { process.exit(1) } + releaseRelayLaunchFence(process.cwd(), launchFence) // ── stdin/stdout transport (initial connection) ───────────────────── @@ -1168,22 +1458,33 @@ async function main(): Promise { // Why: the live configured value, not the launch-time argv closure — the host can raise the grace // after launch via relay.configureGraceTime, and a zero-only gate reading a stale zero would be // zero-at-launch-only, i.e. correct only by coincidence. + const { protectedPtyCount, idle } = legacyPhysicalWorkerRelayState({ + localActivePtyCount: ptyHandler.activePtyCount, + pendingPtyCreationCount: ptyHandler.pendingPtyCreationCount, + lifecycle: legacyPhysicalWorkerRegistry + }) const decision = decideRelayGrace({ + terminalSessionAuthorityAdmitted, configuredGraceMs: ptyHandler.configuredGraceTimeMs, - relayIdle: isRelayIdle(), + relayIdle: idle, detached, hasAcceptedSocketClient, - activePtyCount: ptyHandler.activePtyCount, + activePtyCount: protectedPtyCount, retryDeferredShutdown: options?.retryDeferredShutdown === true, emptyDetachedStartupGraceMs: EMPTY_DETACHED_STARTUP_GRACE_MS, idleRelayGraceMs: IDLE_RELAY_GRACE_MS }) + if (!decision.armShutdown) { + cancelGrace(`terminal authority retained after ${reason}`) + relayLogLine(`[relay] Grace not armed (${reason}): terminal authority owns PTY lifecycle`) + return + } graceBranch = decision.branch const timeoutMs = decision.timeoutMs graceDeadlineAt = timeoutMs === 0 ? null : Date.now() + timeoutMs graceReason = reason relayLogLine( - `[relay] Grace started (${reason}): timeoutMs=${timeoutMs}, branch=${graceBranch}, ptys=${ptyHandler.activePtyCount}, clients=${socketClients.size}` + `[relay] Grace started (${reason}): timeoutMs=${timeoutMs}, branch=${graceBranch}, ptys=${protectedPtyCount}, clients=${socketClients.size}` ) ptyHandler.startGraceTimer(() => { // Why: last line of defense for the idle cap — a PTY that appeared without announcing itself @@ -1194,13 +1495,17 @@ async function main(): Promise { return } relayLogLine(`[relay] Grace expired (${reason}); shutting down`) - shutdown() + shutdown('grace-expired') }, timeoutMs) } // Why: a creation admitted but not yet pooled already owns a shell, so it counts as non-idle. function isRelayIdle(): boolean { - return ptyHandler.activePtyCount === 0 && ptyHandler.pendingPtyCreationCount === 0 + return legacyPhysicalWorkerRelayState({ + localActivePtyCount: ptyHandler.activePtyCount, + pendingPtyCreationCount: ptyHandler.pendingPtyCreationCount, + lifecycle: legacyPhysicalWorkerRegistry + }).idle } if (detached) { @@ -1233,10 +1538,23 @@ async function main(): Promise { } let shutdownInFlight = false - function shutdown(): void { + let deferredShutdownCause: RelayShutdownCause | null = null + function shutdown(cause: RelayShutdownCause): void { + if (!mayDisposeRelayPtysForShutdown(terminalSessionAuthorityAdmitted, cause)) { + cancelGrace('terminal authority rejected grace shutdown') + relayLogLine('[relay] Grace expiry cannot terminate terminal-authority PTYs') + return + } if (shutdownInFlight) { return } + const legacyLifecycleHoldCount = legacyPhysicalWorkerRegistry?.lifecycleHoldCount ?? 0 + if (legacyLifecycleHoldCount > 0) { + deferredShutdownCause = cause + relayLogLine(`[relay] Shutdown deferred: legacyPhysicalWorkers=${legacyLifecycleHoldCount}`) + return + } + deferredShutdownCause = null shutdownInFlight = true relayLogLine( `[relay] Shutdown: ptys=${ptyHandler.activePtyCount}, clients=${socketClients.size}, ownsSocket=${ownsSocketPath}` @@ -1246,9 +1564,22 @@ async function main(): Promise { graceBranch = null void ptyHandler .dispose() - .then(() => { + .then(async () => { + stopLegacyPhysicalWorkerWatch() + removeTerminalSessionAuthorityHostEffectApplier() + legacyPhysicalWorkerHost?.dispose() + legacyPhysicalWorkerRegistry?.dispose() + terminalAuthorityTopologyPublisher?.dispose() + try { + await terminalSessionAuthorityRegistry?.close() + } catch (error) { + terminateForTerminalAuthorityFailure( + error instanceof Error ? error : new Error(String(error)) + ) + } stopPoolWatch() stopPoolActiveWatch() + terminalAuthorityGateway?.dispose() dispatcher.dispose() fsHandler.dispose() gitHandler.dispose() @@ -1270,7 +1601,7 @@ async function main(): Promise { ) // Why: shutdown() already cleared graceReason, so without re-arming a client-less relay // whose kill was refused would stay resident forever with nothing left to retry it. - if (socketClients.size === 0) { + if (!terminalSessionAuthorityAdmitted && socketClients.size === 0) { startGrace('shutdown deferred', { retryDeferredShutdown: true }) } }) @@ -1297,8 +1628,20 @@ async function main(): Promise { } }) - process.on('SIGTERM', shutdown) - process.on('SIGINT', shutdown) + const stopLegacyPhysicalWorkerWatch = + legacyPhysicalWorkerRegistry?.onLifecycleChanged(() => { + const cause = deferredShutdownCause + if (cause && legacyPhysicalWorkerRegistry?.lifecycleHoldCount === 0) { + shutdown(cause) + return + } + if (graceReason !== null && !shutdownInFlight) { + startGrace('legacy physical worker lifecycle changed') + } + }) ?? (() => {}) + + process.on('SIGTERM', () => shutdown('administrative')) + process.on('SIGINT', () => shutdown('administrative')) // Why: default SIGHUP exits immediately, killing PTYs before grace; ignore it so the relay survives SSH disconnect. process.on('SIGHUP', () => { relayLogLine('[relay] Received SIGHUP (SSH session dropped), ignoring') diff --git a/src/relay/remote-cli-launcher-installer.test.ts b/src/relay/remote-cli-launcher-installer.test.ts new file mode 100644 index 00000000000..187417f4a9e --- /dev/null +++ b/src/relay/remote-cli-launcher-installer.test.ts @@ -0,0 +1,204 @@ +import { spawnSync } from 'node:child_process' +import { chmod, mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + SSH_REMOTE_CLI_LAUNCHER_INSTALL_METHOD, + SSH_REMOTE_CLI_LAUNCHER_INSTALL_VERSION, + type SshRemoteCliLauncherInstallRequest, + type SshRemoteCliLauncherInstallResult +} from '../shared/ssh-remote-cli-launcher-install' +import type { RelayDispatcher, RequestContext } from './dispatcher' +import { + RemoteCliLauncherInstaller, + createRemoteCliLauncherPlan, + windowsRemoteCliCompilerCandidates +} from './remote-cli-launcher-installer' + +type InstallHandler = (params: Record, context: RequestContext) => Promise + +const temporaryRoots: string[] = [] + +function createInstaller(): InstallHandler { + let handler: InstallHandler | null = null + const dispatcher = { + onRequest: (method: string, candidate: InstallHandler) => { + if (method === SSH_REMOTE_CLI_LAUNCHER_INSTALL_METHOD) { + handler = candidate + } + } + } as unknown as RelayDispatcher + new RemoteCliLauncherInstaller(dispatcher) + if (!handler) { + throw new Error('installer handler was not registered') + } + return handler +} + +function requestForRoot(root: string): SshRemoteCliLauncherInstallRequest { + return { + version: SSH_REMOTE_CLI_LAUNCHER_INSTALL_VERSION, + binDir: join(root, 'bin'), + relayDir: join(root, 'relay'), + nodePath: process.execPath, + sockPath: join(root, 'relay.sock'), + credentialFile: join(root, 'relay.sock.credential') + } +} + +function requestContext(signal?: AbortSignal): RequestContext { + return { + clientId: 1, + isStale: () => signal?.aborted === true, + ...(signal ? { signal } : {}) + } +} + +async function makeRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-remote-cli-installer-')) + temporaryRoots.push(root) + return root +} + +afterEach(async () => { + await Promise.all( + temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })) + ) +}) + +describe('remote CLI launcher installer', () => { + const itPosix = process.platform === 'win32' ? it.skip : it + const itWindows = process.platform === 'win32' ? it : it.skip + + itPosix('atomically installs one executable launcher through the relay handler', async () => { + const root = await makeRoot() + const request = requestForRoot(root) + const install = createInstaller() + + const result = (await install(request, requestContext())) as SshRemoteCliLauncherInstallResult + const launcher = await readFile(result.launcherPath, 'utf8') + const launcherStat = await stat(result.launcherPath) + + expect(result).toMatchObject({ version: 1, changed: true }) + expect(launcher).toContain('--orca-cli "$@"') + expect(launcher).toContain(request.credentialFile) + expect(launcherStat.mode & 0o111).not.toBe(0) + }) + + itPosix('coalesces concurrent installs and skips an unchanged reconnect', async () => { + const root = await makeRoot() + const request = requestForRoot(root) + const install = createInstaller() + + const concurrent = (await Promise.all([ + install(request, requestContext()), + install(request, requestContext()) + ])) as SshRemoteCliLauncherInstallResult[] + const reconnect = (await install( + request, + requestContext() + )) as SshRemoteCliLauncherInstallResult + + expect(concurrent.map((result) => result.changed)).toEqual([true, false]) + expect(reconnect.changed).toBe(false) + }) + + itPosix('repairs executable permissions without rewriting an unchanged launcher', async () => { + const root = await makeRoot() + const request = requestForRoot(root) + const install = createInstaller() + const first = (await install(request, requestContext())) as SshRemoteCliLauncherInstallResult + await chmod(first.launcherPath, 0o644) + + const repaired = (await install(request, requestContext())) as SshRemoteCliLauncherInstallResult + + expect(repaired.changed).toBe(false) + expect((await stat(first.launcherPath)).mode & 0o111).not.toBe(0) + }) + + it('rejects stale or relative install requests before mutation', async () => { + const root = await makeRoot() + const install = createInstaller() + const controller = new AbortController() + controller.abort() + + await expect(install(requestForRoot(root), requestContext(controller.signal))).rejects.toThrow( + 'client_disconnected' + ) + await expect( + install({ ...requestForRoot(root), binDir: 'relative/bin' }, requestContext()) + ).rejects.toThrow('remote_cli_launcher_path_not_absolute') + await expect(stat(join(root, 'bin'))).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('builds a native Windows launcher plan without cmd.exe or client-provided code', () => { + const request: SshRemoteCliLauncherInstallRequest = { + version: 1, + binDir: 'C:\\Users\\me user\\.orca-relay\\bin', + relayDir: 'C:\\Users\\me user\\.orca-relay\\relay', + nodePath: 'C:\\Program Files\\nodejs\\node.exe', + sockPath: '\\\\.\\pipe\\orca-relay', + credentialFile: 'C:\\Users\\me user\\.orca-relay\\relay.credential' + } + + const plan = createRemoteCliLauncherPlan(request, 'win32') + + expect(plan.launcherPath).toBe('C:\\Users\\me user\\.orca-relay\\bin\\orca.exe') + expect(plan.source).toContain('ProcessStartInfo') + expect(plan.source).toContain('"--orca-cli"') + expect(plan.source).not.toContain('cmd.exe') + expect(plan.source).not.toContain('%*') + expect(windowsRemoteCliCompilerCandidates({ WINDIR: 'C:\\Windows' })).toEqual([ + 'C:\\Windows\\Microsoft.NET\\Framework64\\v4.0.30319\\csc.exe', + 'C:\\Windows\\Microsoft.NET\\Framework\\v4.0.30319\\csc.exe' + ]) + }) + + itWindows( + 'preserves multiline argv through the compiled Windows launcher', + async () => { + const root = await makeRoot() + const request = requestForRoot(root) + await mkdir(request.relayDir, { recursive: true }) + await writeFile( + join(request.relayDir, 'relay.js'), + 'process.stdout.write(JSON.stringify(process.argv.slice(2)))\n', + 'utf8' + ) + const install = createInstaller() + const result = (await install(request, requestContext())) as SshRemoteCliLauncherInstallResult + const body = 'line one\nline two & whoami\n"quoted" C:\\tail\\' + + const launched = spawnSync( + result.launcherPath, + ['orchestration', 'send', '--body', body, '--json'], + { + encoding: 'utf8', + env: { + ...process.env, + ORCA_RELAY_NODE_PATH: process.execPath, + ORCA_RELAY_DIR: request.relayDir, + ORCA_RELAY_SOCKET_PATH: request.sockPath, + ORCA_RELAY_CREDENTIAL_FILE: request.credentialFile + } + } + ) + + expect(launched.status, launched.stderr).toBe(0) + expect(JSON.parse(launched.stdout)).toEqual([ + '--sock-path', + request.sockPath, + '--credential-file', + request.credentialFile, + '--orca-cli', + 'orchestration', + 'send', + '--body', + body, + '--json' + ]) + }, + 20_000 + ) +}) diff --git a/src/relay/remote-cli-launcher-installer.ts b/src/relay/remote-cli-launcher-installer.ts new file mode 100644 index 00000000000..0be2e1b006f --- /dev/null +++ b/src/relay/remote-cli-launcher-installer.ts @@ -0,0 +1,282 @@ +import { execFile } from 'node:child_process' +import { createHash, randomUUID } from 'node:crypto' +import { constants } from 'node:fs' +import { access, chmod, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises' +import { basename, posix, win32 } from 'node:path' +import { promisify } from 'node:util' +import { + SSH_REMOTE_CLI_LAUNCHER_INSTALL_METHOD, + SSH_REMOTE_CLI_LAUNCHER_INSTALL_VERSION, + parseSshRemoteCliLauncherInstallRequest, + type SshRemoteCliLauncherInstallRequest, + type SshRemoteCliLauncherInstallResult +} from '../shared/ssh-remote-cli-launcher-install' +import type { RelayDispatcher, RequestContext } from './dispatcher' +import { WINDOWS_REMOTE_CLI_LAUNCHER_SOURCE } from './remote-cli-windows-launcher-source' + +const execFileAsync = promisify(execFile) +const INSTALL_MARKER_NAME = '.orca-launcher.sha256' +const COMPILER_TIMEOUT_MS = 45_000 +const COMPILER_OUTPUT_LIMIT_BYTES = 1024 * 1024 + +type InstallPlatform = 'posix' | 'win32' + +export type RemoteCliLauncherPlan = { + platform: InstallPlatform + binDir: string + launcherPath: string + markerPath: string + source: string + sourceDigest: string +} + +function quoteSh(value: string): string { + return `'${value.replaceAll("'", `'\\''`)}'` +} + +function createPosixLauncherSource(request: SshRemoteCliLauncherInstallRequest): string { + return [ + '#!/usr/bin/env sh', + 'set -eu', + `ORCA_RELAY_NODE_PATH=\${ORCA_RELAY_NODE_PATH:-${quoteSh(request.nodePath)}}`, + `ORCA_RELAY_DIR=\${ORCA_RELAY_DIR:-${quoteSh(request.relayDir)}}`, + `ORCA_RELAY_SOCKET_PATH=\${ORCA_RELAY_SOCKET_PATH:-${quoteSh(request.sockPath)}}`, + `ORCA_RELAY_CREDENTIAL_FILE=\${ORCA_RELAY_CREDENTIAL_FILE:-${quoteSh(request.credentialFile ?? `${request.sockPath}.credential`)}}`, + 'if [ ! -S "$ORCA_RELAY_SOCKET_PATH" ]; then', + ' echo "Orca SSH CLI bridge cannot find the relay socket: $ORCA_RELAY_SOCKET_PATH" >&2', + ' exit 1', + 'fi', + 'exec "$ORCA_RELAY_NODE_PATH" "$ORCA_RELAY_DIR/relay.js" --sock-path "$ORCA_RELAY_SOCKET_PATH" --credential-file "$ORCA_RELAY_CREDENTIAL_FILE" --orca-cli "$@"', + '' + ].join('\n') +} + +function installPlatformForNode(platform: NodeJS.Platform): InstallPlatform { + return platform === 'win32' ? 'win32' : 'posix' +} + +function pathOpsForPlatform(platform: InstallPlatform): typeof posix | typeof win32 { + return platform === 'win32' ? win32 : posix +} + +function sourceDigest(platform: InstallPlatform, source: string): string { + return createHash('sha256').update(`${platform}\0${source}`).digest('hex') +} + +export function createRemoteCliLauncherPlan( + requestValue: unknown, + platform: InstallPlatform = installPlatformForNode(process.platform) +): RemoteCliLauncherPlan { + const request = parseSshRemoteCliLauncherInstallRequest(requestValue) + const pathOps = pathOpsForPlatform(platform) + const requiredAbsolutePaths = [request.binDir, request.relayDir, request.nodePath] + if ( + requiredAbsolutePaths.some((value) => !pathOps.isAbsolute(value)) || + (request.credentialFile !== undefined && !pathOps.isAbsolute(request.credentialFile)) || + !isAbsoluteRelaySocketPath(request.sockPath, platform) + ) { + throw new Error('remote_cli_launcher_path_not_absolute') + } + const binDir = pathOps.normalize(request.binDir) + const source = + platform === 'win32' ? WINDOWS_REMOTE_CLI_LAUNCHER_SOURCE : createPosixLauncherSource(request) + return { + platform, + binDir, + launcherPath: pathOps.join(binDir, platform === 'win32' ? 'orca.exe' : 'orca'), + markerPath: pathOps.join(binDir, INSTALL_MARKER_NAME), + source, + sourceDigest: sourceDigest(platform, source) + } +} + +function isAbsoluteRelaySocketPath(value: string, platform: InstallPlatform): boolean { + return platform === 'win32' + ? win32.isAbsolute(value) || /^\\\\[.?]\\pipe\\/iu.test(value) + : posix.isAbsolute(value) +} + +function throwIfRequestStale(context: RequestContext): void { + if (context.signal?.aborted || context.isStale()) { + throw new Error('client_disconnected') + } +} + +async function readCurrentInstall(plan: RemoteCliLauncherPlan): Promise { + try { + const [marker, launcherStat] = await Promise.all([ + readFile(plan.markerPath, 'utf8'), + stat(plan.launcherPath) + ]) + if (!launcherStat.isFile() || marker.trim() !== plan.sourceDigest) { + return false + } + if (plan.platform === 'posix' && (launcherStat.mode & 0o111) === 0) { + await chmod(plan.launcherPath, 0o755) + } + return true + } catch { + return false + } +} + +async function writeAtomicText( + targetPath: string, + contents: string, + context: RequestContext, + mode?: number +): Promise { + const pathOps = pathOpsForPlatform(installPlatformForNode(process.platform)) + const temporaryPath = pathOps.join( + pathOps.dirname(targetPath), + `.${pathOps.basename(targetPath)}.${randomUUID()}.tmp` + ) + try { + await writeFile(temporaryPath, contents, { + encoding: 'utf8', + flag: 'wx', + ...(mode === undefined ? {} : { mode }), + ...(context.signal ? { signal: context.signal } : {}) + }) + if (mode !== undefined) { + await chmod(temporaryPath, mode) + } + throwIfRequestStale(context) + await rename(temporaryPath, targetPath) + } finally { + await rm(temporaryPath, { force: true }).catch(() => {}) + } +} + +async function installPosixLauncher( + plan: RemoteCliLauncherPlan, + context: RequestContext +): Promise { + await writeAtomicText(plan.launcherPath, plan.source, context, 0o755) +} + +export function windowsRemoteCliCompilerCandidates(env: NodeJS.ProcessEnv = process.env): string[] { + const windowsDirectory = env.WINDIR || env.SystemRoot + if (!windowsDirectory) { + return [] + } + return [ + win32.join(windowsDirectory, 'Microsoft.NET', 'Framework64', 'v4.0.30319', 'csc.exe'), + win32.join(windowsDirectory, 'Microsoft.NET', 'Framework', 'v4.0.30319', 'csc.exe') + ] +} + +async function resolveWindowsCompiler(): Promise { + for (const candidate of windowsRemoteCliCompilerCandidates()) { + try { + await access(candidate, constants.F_OK) + return candidate + } catch { + // Try the 32-bit .NET Framework compiler next. + } + } + throw new Error('remote_cli_launcher_compiler_unavailable') +} + +async function installWindowsLauncher( + plan: RemoteCliLauncherPlan, + context: RequestContext +): Promise { + const token = randomUUID() + const sourcePath = win32.join(plan.binDir, `.orca-launcher.${token}.cs`) + const outputPath = win32.join(plan.binDir, `.orca.${token}.exe`) + const legacyShimPath = win32.join(plan.binDir, 'orca.cmd') + try { + await writeFile(sourcePath, plan.source, { + encoding: 'utf8', + flag: 'wx', + ...(context.signal ? { signal: context.signal } : {}) + }) + const compiler = await resolveWindowsCompiler() + throwIfRequestStale(context) + await execFileAsync( + compiler, + [ + '/nologo', + '/target:exe', + '/optimize+', + '/warnaserror+', + `/out:${basename(outputPath)}`, + basename(sourcePath) + ], + { + cwd: plan.binDir, + encoding: 'utf8', + windowsHide: true, + timeout: COMPILER_TIMEOUT_MS, + maxBuffer: COMPILER_OUTPUT_LIMIT_BYTES, + ...(context.signal ? { signal: context.signal } : {}) + } + ) + const outputStat = await stat(outputPath) + if (!outputStat.isFile()) { + throw new Error('remote_cli_launcher_compiler_produced_no_output') + } + throwIfRequestStale(context) + await rename(outputPath, plan.launcherPath) + await rm(legacyShimPath, { force: true }).catch(() => {}) + } finally { + await Promise.all([ + rm(sourcePath, { force: true }).catch(() => {}), + rm(outputPath, { force: true }).catch(() => {}) + ]) + } +} + +async function installLauncher( + plan: RemoteCliLauncherPlan, + context: RequestContext +): Promise { + throwIfRequestStale(context) + await mkdir(plan.binDir, { recursive: true }) + if (await readCurrentInstall(plan)) { + return { + version: SSH_REMOTE_CLI_LAUNCHER_INSTALL_VERSION, + launcherPath: plan.launcherPath, + changed: false + } + } + await (plan.platform === 'win32' + ? installWindowsLauncher(plan, context) + : installPosixLauncher(plan, context)) + await writeAtomicText(plan.markerPath, `${plan.sourceDigest}\n`, context).catch(() => {}) + return { + version: SSH_REMOTE_CLI_LAUNCHER_INSTALL_VERSION, + launcherPath: plan.launcherPath, + changed: true + } +} + +export class RemoteCliLauncherInstaller { + private readonly installTails = new Map>() + + constructor(dispatcher: RelayDispatcher) { + dispatcher.onRequest(SSH_REMOTE_CLI_LAUNCHER_INSTALL_METHOD, (params, context) => + this.install(params, context) + ) + } + + private async install( + requestValue: unknown, + context: RequestContext + ): Promise { + const plan = createRemoteCliLauncherPlan(requestValue) + const previous = this.installTails.get(plan.binDir) + const current = (previous ? previous.catch(() => undefined) : Promise.resolve()).then(() => + installLauncher(plan, context) + ) + this.installTails.set(plan.binDir, current) + try { + return await current + } finally { + if (this.installTails.get(plan.binDir) === current) { + this.installTails.delete(plan.binDir) + } + } + } +} diff --git a/src/relay/remote-cli-windows-launcher-source.ts b/src/relay/remote-cli-windows-launcher-source.ts new file mode 100644 index 00000000000..78d6c6fbaab --- /dev/null +++ b/src/relay/remote-cli-windows-launcher-source.ts @@ -0,0 +1,127 @@ +export const WINDOWS_REMOTE_CLI_LAUNCHER_SOURCE = String.raw`using System; +using System.Diagnostics; +using System.IO; +using System.Text; + +internal static class OrcaRemoteCliLauncher +{ + private static int Main(string[] args) + { + try + { + string nodePath = RequireEnvironmentVariable("ORCA_RELAY_NODE_PATH"); + string relayDirectory = RequireEnvironmentVariable("ORCA_RELAY_DIR"); + string socketPath = RequireEnvironmentVariable("ORCA_RELAY_SOCKET_PATH"); + string credentialFile = Environment.GetEnvironmentVariable("ORCA_RELAY_CREDENTIAL_FILE"); + if (String.IsNullOrEmpty(credentialFile)) + { + credentialFile = socketPath + ".credential"; + } + string relayPath = Path.Combine(relayDirectory, "relay.js"); + + if (!File.Exists(nodePath)) + { + Console.Error.WriteLine("Orca SSH CLI bridge cannot find Node.js at \"{0}\"", nodePath); + return 1; + } + if (!File.Exists(relayPath)) + { + Console.Error.WriteLine("Orca SSH CLI bridge cannot find the relay at \"{0}\"", relayPath); + return 1; + } + + ProcessStartInfo startInfo = new ProcessStartInfo + { + FileName = nodePath, + Arguments = BuildArguments(relayPath, socketPath, credentialFile, args), + UseShellExecute = false + }; + + using (Process child = Process.Start(startInfo)) + { + child.WaitForExit(); + return child.ExitCode; + } + } + catch (Exception error) + { + Console.Error.WriteLine("Unable to start the Orca SSH CLI bridge: {0}", error.Message); + return 1; + } + } + + private static string RequireEnvironmentVariable(string name) + { + string value = Environment.GetEnvironmentVariable(name); + if (String.IsNullOrEmpty(value)) + { + throw new InvalidOperationException(name + " is not set."); + } + return value; + } + + private static string BuildArguments(string relayPath, string socketPath, string credentialFile, string[] args) + { + StringBuilder commandLine = new StringBuilder(); + AppendArgument(commandLine, relayPath); + AppendArgument(commandLine, "--sock-path"); + AppendArgument(commandLine, socketPath); + AppendArgument(commandLine, "--credential-file"); + AppendArgument(commandLine, credentialFile); + AppendArgument(commandLine, "--orca-cli"); + foreach (string arg in args) + { + AppendArgument(commandLine, arg); + } + return commandLine.ToString(); + } + + private static void AppendArgument(StringBuilder commandLine, string value) + { + if (commandLine.Length > 0) + { + commandLine.Append(' '); + } + commandLine.Append(QuoteArgument(value)); + } + + private static string QuoteArgument(string value) + { + bool requiresQuotes = value.Length == 0; + for (int index = 0; index < value.Length && !requiresQuotes; index += 1) + { + requiresQuotes = value[index] == '"' || Char.IsWhiteSpace(value[index]); + } + if (!requiresQuotes) + { + return value; + } + + StringBuilder quoted = new StringBuilder("\""); + int backslashCount = 0; + foreach (char character in value) + { + if (character == '\\') + { + backslashCount += 1; + continue; + } + if (character == '"') + { + quoted.Append('\\', backslashCount * 2 + 1); + quoted.Append('"'); + } + else + { + quoted.Append('\\', backslashCount); + quoted.Append(character); + } + backslashCount = 0; + } + + quoted.Append('\\', backslashCount * 2); + quoted.Append('"'); + return quoted.ToString(); + } +} +` diff --git a/src/relay/ssh-pty-consumer-delivery-state.ts b/src/relay/ssh-pty-consumer-delivery-state.ts new file mode 100644 index 00000000000..0f582a932d0 --- /dev/null +++ b/src/relay/ssh-pty-consumer-delivery-state.ts @@ -0,0 +1,320 @@ +import type { PtyConsumerSessionGrant } from '../shared/pty-consumer-session' +import type { + PtySourceDeliveryIdentity, + PtySourceDeliverySnapshot, + PtySourceSpan, + PtySourceTransform +} from '../shared/pty-source-credit-contract' +import type { RelayDispatcher, RequestContext } from './dispatcher' +import { + admitsSshPtyDataPublication, + sshPtyDeliveryMode, + type SshPtyDeliveryMode +} from './ssh-pty-data-publication-admission' +import type { PtySourceSendReservation } from './pty-source-credit-ledger' +import { SshPtySourceCreditAdapter } from './ssh-pty-source-credit-adapter' + +const MAX_HELD_PRODUCER_PAUSES = 4_096 + +type HeldProducerPause = Readonly<{ + id: string + incarnationId: string + token: string + clientGeneration: number + ownerGeneration: number +}> + +export type SshPtyDeliveryPauseHandler = ( + id: string, + paused: boolean, + identity?: PtySourceDeliveryIdentity, + heldPause?: Readonly<{ incarnationId: string; token: string }> +) => boolean | Promise | void + +export class SshPtyConsumerDeliveryState { + private readonly sourceCredit: SshPtySourceCreditAdapter + private readonly pausedDeliveryByPty = new Map() + private readonly heldProducerPauses = new Map() + + constructor( + dispatcher: RelayDispatcher, + private readonly activeGrant: (clientId: number) => Readonly | null, + private readonly setDeliveryPaused?: SshPtyDeliveryPauseHandler, + onSourceCreditAvailable?: (id: string, identity?: PtySourceDeliveryIdentity) => void + ) { + this.sourceCredit = new SshPtySourceCreditAdapter( + (proof) => + dispatcher.notifyControl( + 'pty.deliveryCanceled', + proof as unknown as Record + ), + undefined, + (identity) => onSourceCreditAvailable?.(identity.id, identity) + ) + dispatcher.registerPtyDataPublicationAdmission((clientId, params) => + admitsSshPtyDataPublication(this.activeGrant(clientId), params, this.sourceCredit) + ) + dispatcher.onNotification('pty.setDeliveryPaused', (params, context) => { + this.setPausedDelivery(params, context) + }) + dispatcher.onRequest('pty.setDeliveryPaused', async (params, context) => + this.setHeldProducerPause(params, context) + ) + dispatcher.onNotification('pty.ackData', (params, context) => { + this.sourceCredit.acknowledge(params, this.activeGrant(context.clientId)) + }) + dispatcher.onRequest('pty.cancelDelivery', async (params, context) => + this.sourceCredit.cancel(params, this.activeGrant(context.clientId)) + ) + dispatcher.onDisposed(() => this.dispose()) + } + + detach(grant: Readonly): void { + this.clearPausedForGrant(grant) + this.clearHeldPausesForGrant(grant) + this.sourceCredit.retainOrCloseOnDetach(grant) + } + + open( + clientId: number, + id: string, + ptyIncarnation: string, + checkpointSourceEndSu = 0 + ): PtySourceDeliveryIdentity | null { + return this.sourceCredit.open( + this.activeGrant(clientId), + id, + ptyIncarnation, + checkpointSourceEndSu + ) + } + + rotate(oldIdentity: PtySourceDeliveryIdentity, newClientId: number, acceptedSourceEndSu: number) { + this.clearPausedIdentity(oldIdentity) + return this.sourceCredit.rotate(oldIdentity, this.activeGrant(newClientId), acceptedSourceEndSu) + } + + append( + identity: PtySourceDeliveryIdentity, + input: Readonly<{ + spanId: string + data: string + displayStart: number + displayEnd: number + splittable: boolean + transform: PtySourceTransform + }> + ): PtySourceSpan { + return this.sourceCredit.append(identity, input) + } + + reserveSend( + identity: PtySourceDeliveryIdentity, + maxSourceSu?: number + ): PtySourceSendReservation | null { + return this.sourceCredit.reserveSend(identity, maxSourceSu) + } + + commitSend(reservation: PtySourceSendReservation): void { + this.sourceCredit.commitSend(reservation) + } + + rollbackSend(reservation: PtySourceSendReservation): void { + this.sourceCredit.rollbackSend(reservation) + } + + seal(identity: PtySourceDeliveryIdentity): void { + this.sourceCredit.seal(identity) + } + + settleExit( + identity: PtySourceDeliveryIdentity, + result: { ok: true } | { ok: false; error: Error } + ): void { + this.sourceCredit.settleExit(identity, result) + } + + snapshot(identity: PtySourceDeliveryIdentity): PtySourceDeliverySnapshot { + return this.sourceCredit.snapshot(identity) + } + + snapshotIfKnown(identity: PtySourceDeliveryIdentity): PtySourceDeliverySnapshot | null { + return this.sourceCredit.snapshotIfKnown(identity) + } + + cancel(identity: PtySourceDeliveryIdentity, reason: string): void { + this.clearPausedIdentity(identity) + this.sourceCredit.cancelIdentity(identity, reason) + } + + debugSnapshot(): Readonly<{ + deliveryTokens: number + graceTimers: number + sourceSu: number + dataBytes: number + spans: number + }> { + return this.sourceCredit.retentionSnapshot() + } + + mode(clientId: number): SshPtyDeliveryMode { + return sshPtyDeliveryMode(this.activeGrant(clientId)) + } + + private setPausedDelivery(params: Record, context: RequestContext): void { + const grant = this.activeGrant(context.clientId) + if ( + !grant || + grant.clientGeneration !== params.clientGeneration || + grant.ownerGeneration !== params.ownerGeneration || + typeof params.id !== 'string' || + typeof params.paused !== 'boolean' + ) { + return + } + let identity: PtySourceDeliveryIdentity | null = null + if (grant.capabilities?.outputFlowControl) { + const token = typeof params.deliveryToken === 'string' ? params.deliveryToken : '' + identity = this.sourceCredit.ownsDelivery(token, grant, params.id) + if (!identity) { + return + } + if (params.paused) { + this.pausedDeliveryByPty.set(params.id, identity) + } else if (this.pausedDeliveryByPty.get(params.id) !== identity) { + return + } else { + this.pausedDeliveryByPty.delete(params.id) + } + } + this.setDeliveryPaused?.(params.id, params.paused, identity ?? undefined) + } + + private async setHeldProducerPause( + params: Record, + context: RequestContext + ): Promise<{ applied: boolean }> { + const grant = this.activeGrant(context.clientId) + const requestedPause = parseHeldProducerPause(params, grant) + if (!requestedPause) { + return { applied: false } + } + const key = heldProducerPauseKey(requestedPause) + const current = this.heldProducerPauses.get(key) + if ( + current && + (current.id !== requestedPause.id || current.incarnationId !== requestedPause.incarnationId) + ) { + return { applied: false } + } + if (requestedPause.paused && current) { + return { applied: true } + } + if (requestedPause.paused && this.heldProducerPauses.size >= MAX_HELD_PRODUCER_PAUSES) { + return { applied: false } + } + const applied = + (await this.setDeliveryPaused?.(requestedPause.id, requestedPause.paused, undefined, { + incarnationId: requestedPause.incarnationId, + token: requestedPause.token + })) === true + if (requestedPause.paused && applied) { + this.heldProducerPauses.set(key, requestedPause) + } else if (!requestedPause.paused) { + this.heldProducerPauses.delete(key) + } + return { applied } + } + + private clearPausedIdentity(identity: PtySourceDeliveryIdentity): void { + if (this.pausedDeliveryByPty.get(identity.id) === identity) { + this.pausedDeliveryByPty.delete(identity.id) + this.setDeliveryPaused?.(identity.id, false) + } + } + + private clearPausedForGrant(grant: Readonly): void { + for (const [id, identity] of this.pausedDeliveryByPty) { + if ( + identity.clientGeneration === grant.clientGeneration && + identity.ownerGeneration === grant.ownerGeneration + ) { + this.pausedDeliveryByPty.delete(id) + this.setDeliveryPaused?.(id, false) + } + } + } + + private clearHeldPausesForGrant(grant: Readonly): void { + for (const [key, pause] of this.heldProducerPauses) { + if ( + pause.clientGeneration === grant.clientGeneration && + pause.ownerGeneration === grant.ownerGeneration + ) { + this.heldProducerPauses.delete(key) + this.releaseHeldProducerPause(pause) + } + } + } + + private releaseHeldProducerPause(pause: HeldProducerPause): void { + try { + const releasing = this.setDeliveryPaused?.(pause.id, false, undefined, { + incarnationId: pause.incarnationId, + token: pause.token + }) + void Promise.resolve(releasing).catch(() => undefined) + } catch { + /* PTY disposal is the final release boundary. */ + } + } + + private dispose(): void { + for (const id of this.pausedDeliveryByPty.keys()) { + this.setDeliveryPaused?.(id, false) + } + for (const pause of this.heldProducerPauses.values()) { + this.releaseHeldProducerPause(pause) + } + this.pausedDeliveryByPty.clear() + this.heldProducerPauses.clear() + this.sourceCredit.dispose() + } +} + +function parseHeldProducerPause( + params: Record, + grant: Readonly | null +): (HeldProducerPause & Readonly<{ paused: boolean }>) | null { + if ( + grant?.role !== 'session-owner' || + grant.capabilities?.heldProducerPause?.version !== 1 || + grant.ownerGeneration === undefined || + grant.clientGeneration !== params.clientGeneration || + grant.ownerGeneration !== params.ownerGeneration || + typeof params.id !== 'string' || + params.id.length === 0 || + params.id.length > 4_096 || + typeof params.ptyIncarnationId !== 'string' || + params.ptyIncarnationId.length === 0 || + params.ptyIncarnationId.length > 512 || + typeof params.heldPauseToken !== 'string' || + params.heldPauseToken.length === 0 || + params.heldPauseToken.length > 512 || + typeof params.paused !== 'boolean' + ) { + return null + } + return Object.freeze({ + id: params.id, + incarnationId: params.ptyIncarnationId, + token: params.heldPauseToken, + clientGeneration: grant.clientGeneration, + ownerGeneration: grant.ownerGeneration, + paused: params.paused + }) +} + +function heldProducerPauseKey(pause: HeldProducerPause): string { + return `${pause.clientGeneration}:${pause.ownerGeneration}:${pause.token}` +} diff --git a/src/relay/ssh-pty-consumer-held-pause.test.ts b/src/relay/ssh-pty-consumer-held-pause.test.ts new file mode 100644 index 00000000000..98ea9ab6b31 --- /dev/null +++ b/src/relay/ssh-pty-consumer-held-pause.test.ts @@ -0,0 +1,85 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { PtyConsumerSessionGrant } from '../shared/pty-consumer-session' +import { RelayDispatcher } from './dispatcher' +import { encodeJsonRpcFrame, MessageType } from './protocol' +import { SshPtyConsumerDeliveryState } from './ssh-pty-consumer-delivery-state' + +const grant: Readonly = { + protocolVersion: 1, + serverBuildId: 'build-a', + clientGeneration: 1, + role: 'session-owner', + ownerGeneration: 1, + ownerLease: 'lease-a', + resumed: false, + capabilities: { heldProducerPause: { version: 1 } } +} + +describe('SSH PTY consumer held pause', () => { + let dispatcher: RelayDispatcher | null = null + + afterEach(() => { + dispatcher?.dispose() + dispatcher = null + }) + + it('awaits a routed physical-worker pause and releases it on detach', async () => { + const writes: Buffer[] = [] + const setPaused = vi.fn(async () => true) + dispatcher = new RelayDispatcher( + (data, onSettled) => { + writes.push(Buffer.from(data)) + onSettled({ ok: true }) + return true + }, + { supportsWriteCallback: true } + ) + const delivery = new SshPtyConsumerDeliveryState( + dispatcher, + (clientId) => (clientId === 1 ? grant : null), + setPaused + ) + + dispatcher.feed( + encodeJsonRpcFrame( + { + jsonrpc: '2.0', + id: 1, + method: 'pty.setDeliveryPaused', + params: { + id: 'pty-1', + paused: true, + clientGeneration: 1, + ownerGeneration: 1, + ptyIncarnationId: 'incarnation-1', + heldPauseToken: 'held-1' + } + }, + 1, + 0 + ) + ) + await new Promise((resolve) => setImmediate(resolve)) + + expect(responseResult(writes[0])).toEqual({ applied: true }) + expect(setPaused).toHaveBeenCalledWith('pty-1', true, undefined, { + incarnationId: 'incarnation-1', + token: 'held-1' + }) + + delivery.detach(grant) + await vi.waitFor(() => + expect(setPaused).toHaveBeenLastCalledWith('pty-1', false, undefined, { + incarnationId: 'incarnation-1', + token: 'held-1' + }) + ) + }) +}) + +function responseResult(buffer: Buffer): Record { + expect(buffer[0]).toBe(MessageType.Regular) + const length = buffer.readUInt32BE(9) + const response = JSON.parse(buffer.subarray(13, 13 + length).toString('utf8')) + return response.result as Record +} diff --git a/src/relay/ssh-pty-consumer-session-adapter.test.ts b/src/relay/ssh-pty-consumer-session-adapter.test.ts index 7c38799b876..cd8c82c0281 100644 --- a/src/relay/ssh-pty-consumer-session-adapter.test.ts +++ b/src/relay/ssh-pty-consumer-session-adapter.test.ts @@ -61,6 +61,55 @@ describe('SshPtyConsumerSessionAdapter', () => { vi.useRealTimers() }) + it('grants exact operations only when the client offers them', async () => { + const writes: Buffer[] = [] + dispatcher = new RelayDispatcher( + (data, onSettled) => { + writes.push(Buffer.from(data)) + onSettled({ ok: true }) + return true + }, + { supportsWriteCallback: true }, + endpointIdentity + ) + new SshPtyConsumerSessionAdapter(dispatcher, 'build-a') + + dispatcher.feed(openFrame(1, { capabilities: { exactOperations: { versions: [1] } } })) + await flushRequests() + + expect(responseResult(writes[0])).toMatchObject({ + capabilities: { exactOperations: { version: 1 } } + }) + }) + + it('does not admit authority exact operations without its namespace consumer', async () => { + const writes: Buffer[] = [] + dispatcher = new RelayDispatcher( + (data, onSettled) => { + writes.push(Buffer.from(data)) + onSettled({ ok: true }) + return true + }, + { supportsWriteCallback: true }, + endpointIdentity + ) + const adapter = new SshPtyConsumerSessionAdapter(dispatcher, 'build-a', undefined, undefined, { + terminalAuthorityExactOperations: true + }) + + dispatcher.feed( + openFrame(1, { + capabilities: { terminalAuthorityExactOperations: { versions: [1] } } + }) + ) + await flushRequests() + + expect(responseResult(writes[0])).toMatchObject({ + capabilities: { terminalAuthorityExactOperations: { version: 1 } } + }) + expect(adapter.terminalAuthorityExactOperations(1)).toBe(false) + }) + it('does not activate owner authority until the grant write settles', async () => { const firstWrites: Buffer[] = [] const firstSettlements: ((result: { ok: true } | { ok: false; error: Error }) => void)[] = [] diff --git a/src/relay/ssh-pty-consumer-session-adapter.ts b/src/relay/ssh-pty-consumer-session-adapter.ts index 83e61f17fa3..7099ffc9815 100644 --- a/src/relay/ssh-pty-consumer-session-adapter.ts +++ b/src/relay/ssh-pty-consumer-session-adapter.ts @@ -5,6 +5,8 @@ import { type PtyConsumerSessionGrant } from '../shared/pty-consumer-session' import { DEFAULT_PTY_SOURCE_WINDOW_SU } from '../shared/pty-source-credit-contract' +import { PTY_EXACT_OPERATION_PROTOCOL_VERSION } from '../shared/pty-exact-operation-protocol' +import { TERMINAL_AUTHORITY_EXACT_OPERATIONS_VERSION } from '../shared/terminal-authority-exact-operation-protocol' import type { PtySourceDeliveryIdentity, PtySourceDeliverySnapshot, @@ -13,48 +15,76 @@ import type { } from '../shared/pty-source-credit-contract' import type { RelayDispatcher, RequestContext } from './dispatcher' import type { PtySourceSendReservation } from './pty-source-credit-ledger' -import { SshPtySourceCreditAdapter } from './ssh-pty-source-credit-adapter' -import { - admitsSshPtyDataPublication, - sshPtyDeliveryMode, - type SshPtyDeliveryMode -} from './ssh-pty-data-publication-admission' +import type { SshPtyDeliveryMode } from './ssh-pty-data-publication-admission' import { parseOpenClientParams, requireIdentity } from './ssh-pty-open-client-request' +import type { TerminalSessionAuthorityPtyLifecycle } from '../main/session-authority/terminal-session-authority-pty-lifecycle' +import { SshTerminalAuthorityPolicyConsumers } from './ssh-terminal-authority-policy-consumers' +import type { TerminalAuthorityPolicyConsumerConnection } from '../main/session-authority/terminal-session-authority-policy-consumers' +import { TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION } from '../shared/terminal-session-authority-consumer-proof' +import { TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_VERSION } from '../shared/terminal-session-authority-consumer-retirement' +import { + SshPtyConsumerDeliveryState, + type SshPtyDeliveryPauseHandler +} from './ssh-pty-consumer-delivery-state' export const SSH_PTY_OPEN_CLIENT_METHOD = 'pty.openClient' export class SshPtyConsumerSessionAdapter { private readonly session: PtyConsumerSession - private readonly sourceCredit: SshPtySourceCreditAdapter - private readonly pausedDeliveryByPty = new Map() + private readonly deliveryState: SshPtyConsumerDeliveryState + private readonly outcomeDeliveryClientListeners = new Set<(clientId: number) => void>() + private readonly policyConsumers: SshTerminalAuthorityPolicyConsumers | null constructor( private readonly dispatcher: RelayDispatcher, serverBuildId: string, - private readonly setDeliveryPaused?: (id: string, paused: boolean) => void, - onSourceCreditAvailable?: (id: string) => void + setDeliveryPaused?: SshPtyDeliveryPauseHandler, + onSourceCreditAvailable?: (id: string, identity?: PtySourceDeliveryIdentity) => void, + options: { + ownerScope?: 'global' | 'principal-client-instance' + terminalAuthorityExactOperations?: boolean + terminalAuthorityOutcomeDelivery?: boolean + terminalAuthorityPolicyConsumers?: TerminalSessionAuthorityPtyLifecycle + terminalAuthorityConsumerProofHostId?: string + } = {} ) { - this.sourceCredit = new SshPtySourceCreditAdapter( - (proof) => - dispatcher.notifyControl( - 'pty.deliveryCanceled', - proof as unknown as Record - ), - onSourceCreditAvailable - ) + this.policyConsumers = options.terminalAuthorityPolicyConsumers + ? new SshTerminalAuthorityPolicyConsumers( + dispatcher, + options.terminalAuthorityPolicyConsumers + ) + : null this.session = new PtyConsumerSession({ serverBuildId, - outputFlowControl: { versions: [1], maxWindowSu: DEFAULT_PTY_SOURCE_WINDOW_SU } + outputFlowControl: { versions: [1], maxWindowSu: DEFAULT_PTY_SOURCE_WINDOW_SU }, + exactOperations: { versions: [PTY_EXACT_OPERATION_PROTOCOL_VERSION] }, + heldProducerPause: { versions: [1] }, + ...(options.terminalAuthorityExactOperations + ? { + terminalAuthorityExactOperations: { + versions: [TERMINAL_AUTHORITY_EXACT_OPERATIONS_VERSION] + } + } + : {}), + ...(options.terminalAuthorityOutcomeDelivery === false + ? {} + : { terminalAuthorityOutcomeDelivery: { versions: [1] } }), + ...(this.policyConsumers && options.terminalAuthorityConsumerProofHostId + ? { + terminalAuthorityConsumerProof: { + versions: [TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION], + retirementVersions: [TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_VERSION], + authorityHostId: options.terminalAuthorityConsumerProofHostId + } + } + : {}), + ownerScope: options.ownerScope }) - // Why the admission is consulted again at drain time (see isStillAdmitted): a frame can sit - // queued behind a saturated socket long enough for the grant or the delivery to be retired, and - // publishing it then hands the client output from an owner it no longer is. - dispatcher.registerPtyDataPublicationAdmission((clientId, params) => - admitsSshPtyDataPublication( - this.session.activeGrant(String(clientId)), - params, - this.sourceCredit - ) + this.deliveryState = new SshPtyConsumerDeliveryState( + dispatcher, + (clientId) => this.session.activeGrant(String(clientId)), + setDeliveryPaused, + onSourceCreditAvailable ) dispatcher.onRequest(SSH_PTY_OPEN_CLIENT_METHOD, (params, context) => this.openClient(params, context) @@ -62,53 +92,15 @@ export class SshPtyConsumerSessionAdapter { dispatcher.onClientDetached((clientId, cause) => { const connectionKey = String(clientId) const grant = this.session.activeGrant(connectionKey) - if (grant) { - this.clearPausedForGrant(grant) - } this.session.close(connectionKey, cause) + this.policyConsumers?.disconnect(clientId) if (grant) { - this.sourceCredit.retainOrCloseOnDetach(grant) + this.deliveryState.detach(grant) } }) - dispatcher.onNotification('pty.setDeliveryPaused', (params, context) => { - const grant = this.session.activeGrant(String(context.clientId)) - if ( - !grant || - grant.clientGeneration !== params.clientGeneration || - grant.ownerGeneration !== params.ownerGeneration || - typeof params.id !== 'string' || - typeof params.paused !== 'boolean' - ) { - return - } - if (grant.capabilities?.outputFlowControl) { - const token = typeof params.deliveryToken === 'string' ? params.deliveryToken : '' - const identity = this.sourceCredit.ownsDelivery(token, grant, params.id) - if (!identity) { - return - } - if (params.paused) { - this.pausedDeliveryByPty.set(params.id, identity) - } else if (this.pausedDeliveryByPty.get(params.id) !== identity) { - return - } else { - this.pausedDeliveryByPty.delete(params.id) - } - } - this.setDeliveryPaused?.(params.id, params.paused) - }) - dispatcher.onNotification('pty.ackData', (params, context) => { - this.sourceCredit.acknowledge(params, this.session.activeGrant(String(context.clientId))) - }) - dispatcher.onRequest('pty.cancelDelivery', async (params, context) => - this.sourceCredit.cancel(params, this.session.activeGrant(String(context.clientId))) - ) dispatcher.onDisposed(() => { - for (const id of this.pausedDeliveryByPty.keys()) { - this.setDeliveryPaused?.(id, false) - } - this.pausedDeliveryByPty.clear() - this.sourceCredit.dispose() + this.outcomeDeliveryClientListeners.clear() + this.policyConsumers?.dispose() }) } @@ -118,12 +110,7 @@ export class SshPtyConsumerSessionAdapter { ptyIncarnation: string, checkpointSourceEndSu = 0 ): PtySourceDeliveryIdentity | null { - return this.sourceCredit.open( - this.session.activeGrant(String(clientId)), - id, - ptyIncarnation, - checkpointSourceEndSu - ) + return this.deliveryState.open(clientId, id, ptyIncarnation, checkpointSourceEndSu) } rotateDelivery( @@ -131,12 +118,7 @@ export class SshPtyConsumerSessionAdapter { newClientId: number, acceptedSourceEndSu: number ) { - this.clearPausedIdentity(oldIdentity) - return this.sourceCredit.rotate( - oldIdentity, - this.session.activeGrant(String(newClientId)), - acceptedSourceEndSu - ) + return this.deliveryState.rotate(oldIdentity, newClientId, acceptedSourceEndSu) } appendSource( @@ -150,48 +132,47 @@ export class SshPtyConsumerSessionAdapter { transform: PtySourceTransform }> ): PtySourceSpan { - return this.sourceCredit.append(identity, input) + return this.deliveryState.append(identity, input) } reserveSourceSend( identity: PtySourceDeliveryIdentity, maxSourceSu?: number ): PtySourceSendReservation | null { - return this.sourceCredit.reserveSend(identity, maxSourceSu) + return this.deliveryState.reserveSend(identity, maxSourceSu) } commitSourceSend(reservation: PtySourceSendReservation): void { - this.sourceCredit.commitSend(reservation) + this.deliveryState.commitSend(reservation) } rollbackSourceSend(reservation: PtySourceSendReservation): void { - this.sourceCredit.rollbackSend(reservation) + this.deliveryState.rollbackSend(reservation) } sealDelivery(identity: PtySourceDeliveryIdentity): void { - this.sourceCredit.seal(identity) + this.deliveryState.seal(identity) } settleExitPublication( identity: PtySourceDeliveryIdentity, result: { ok: true } | { ok: false; error: Error } ): void { - this.sourceCredit.settleExit(identity, result) + this.deliveryState.settleExit(identity, result) } sourceDeliverySnapshot(identity: PtySourceDeliveryIdentity): PtySourceDeliverySnapshot { - return this.sourceCredit.snapshot(identity) + return this.deliveryState.snapshot(identity) } sourceDeliverySnapshotIfKnown( identity: PtySourceDeliveryIdentity ): PtySourceDeliverySnapshot | null { - return this.sourceCredit.snapshotIfKnown(identity) + return this.deliveryState.snapshotIfKnown(identity) } cancelDelivery(identity: PtySourceDeliveryIdentity, reason: string): void { - this.clearPausedIdentity(identity) - this.sourceCredit.cancelIdentity(identity, reason) + this.deliveryState.cancel(identity, reason) } getDebugSnapshot(): Readonly<{ @@ -201,11 +182,50 @@ export class SshPtyConsumerSessionAdapter { dataBytes: number spans: number }> { - return this.sourceCredit.retentionSnapshot() + return this.deliveryState.debugSnapshot() } deliveryMode(clientId: number): SshPtyDeliveryMode { - return sshPtyDeliveryMode(this.session.activeGrant(String(clientId))) + return this.deliveryState.mode(clientId) + } + + hasActiveClient(clientId: number): boolean { + return this.session.activeGrant(String(clientId)) !== null + } + + terminalAuthorityOutcomeDelivery( + clientId: number + ): Readonly<{ clientGeneration: number; ownerGeneration: number }> | null { + const grant = this.session.activeGrant(String(clientId)) + return grant?.role === 'session-owner' && + grant.capabilities?.terminalAuthorityOutcomeDelivery?.version === 1 && + grant.ownerGeneration !== undefined + ? Object.freeze({ + clientGeneration: grant.clientGeneration, + ownerGeneration: grant.ownerGeneration + }) + : null + } + + terminalAuthorityExactOperations(clientId: number): boolean { + return this.terminalAuthorityPolicyConsumer(clientId) !== null + } + + terminalAuthorityPolicyConsumer( + clientId: number + ): TerminalAuthorityPolicyConsumerConnection | null { + const grant = this.session.activeGrant(String(clientId)) + return grant?.role === 'session-owner' && + grant.capabilities?.terminalAuthorityExactOperations?.version === + TERMINAL_AUTHORITY_EXACT_OPERATIONS_VERSION && + this.policyConsumers?.isInstalled(clientId) === true + ? (this.policyConsumers.connection(clientId) ?? null) + : null + } + + onTerminalAuthorityOutcomeDeliveryClient(listener: (clientId: number) => void): () => void { + this.outcomeDeliveryClientListeners.add(listener) + return () => this.outcomeDeliveryClientListeners.delete(listener) } private async openClient( @@ -225,17 +245,48 @@ export class SshPtyConsumerSessionAdapter { authenticated: identity.authenticated, allowSessionOwner: identity.allowSessionOwner }) + const authorityProof = admission.grant.capabilities?.terminalAuthorityConsumerProof + const authorityTransport = authorityProof?.version === TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION + const policyConsumers = this.policyConsumers if (!context.onResponseSettled) { admission.rollbackPublication() throw new Error('SSH PTY consumer response publication fence is unavailable') } - context.onResponseSettled((result) => { - if (!result.ok) { - admission.rollbackPublication() - return - } + const commitAdmission = (): void => { admission.commitPublication() this.closeDisplacedOwner(admission.displacedOwner) + if (admission.grant.capabilities?.terminalAuthorityOutcomeDelivery?.version === 1) { + for (const listener of this.outcomeDeliveryClientListeners) { + listener(context.clientId) + } + } + } + if (authorityProof?.version === TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION) { + if (!policyConsumers) { + admission.rollbackPublication() + throw new Error('SSH PTY consumer authenticated transport is unavailable') + } + try { + // Why: a follow-up can arrive before the response fence runs; the authenticated consumer and + // owner claim must be live before this handler exposes a successful grant to the dispatcher. + policyConsumers.installTransport( + context.clientId, + identity, + authorityProof.retirementVersion === TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_VERSION + ) + commitAdmission() + } catch (error) { + admission.rollbackPublication() + policyConsumers.disconnect(context.clientId) + throw error + } + } + context.onResponseSettled((result) => { + if (!authorityTransport && !result.ok) { + admission.rollbackPublication() + } else if (!authorityTransport) { + commitAdmission() + } }) return admission.grant } @@ -246,31 +297,10 @@ export class SshPtyConsumerSessionAdapter { if (!displaced) { return } - this.clearPausedForGrant(displaced.grant) - this.sourceCredit.retainOrCloseOnDetach(displaced.grant) + this.deliveryState.detach(displaced.grant) const clientId = Number(displaced.connectionId) if (Number.isSafeInteger(clientId)) { this.dispatcher.releaseDisplacedClient(clientId) } } - - private clearPausedIdentity(identity: PtySourceDeliveryIdentity): void { - if (this.pausedDeliveryByPty.get(identity.id) === identity) { - this.pausedDeliveryByPty.delete(identity.id) - this.setDeliveryPaused?.(identity.id, false) - } - } - - private clearPausedForGrant(grant: Readonly): void { - for (const [id, identity] of this.pausedDeliveryByPty) { - if ( - identity.clientGeneration !== grant.clientGeneration || - identity.ownerGeneration !== grant.ownerGeneration - ) { - continue - } - this.pausedDeliveryByPty.delete(id) - this.setDeliveryPaused?.(id, false) - } - } } diff --git a/src/relay/ssh-pty-open-client-capabilities.ts b/src/relay/ssh-pty-open-client-capabilities.ts new file mode 100644 index 00000000000..ae36f54256d --- /dev/null +++ b/src/relay/ssh-pty-open-client-capabilities.ts @@ -0,0 +1,103 @@ +import type { PtyConsumerRole, PtyConsumerSessionHello } from '../shared/pty-consumer-session' +import { parseTerminalAuthorityPolicyConsumerIdentity } from '../shared/terminal-session-authority-consumer-transport' + +type RawCapabilities = Record +type HelloCapabilities = NonNullable + +// Authority capabilities are role-gated to `session-owner`, and `terminalAuthorityTopology` is never +// carried through: `TerminalAuthorityTopologyGateway` mints that grant and strips the offer before it +// reaches this generic adapter, so anything still present is a bypass, not a negotiation. +export function parseOpenClientCapabilities( + params: Record, + requestedRole: PtyConsumerRole +): Pick { + const capabilities = record(params.capabilities) + if (!capabilities) { + return {} + } + const authorityOwner = requestedRole === 'session-owner' + const parsed: HelloCapabilities = { + ...outputFlowControl(capabilities), + ...versionedOffer(capabilities, 'exactOperations'), + ...versionedOffer(capabilities, 'heldProducerPause'), + ...(authorityOwner + ? { + ...versionedOffer(capabilities, 'terminalAuthorityExactOperations'), + ...versionedOffer(capabilities, 'terminalAuthorityOutcomeDelivery'), + ...namespaceOutcomes(capabilities), + ...consumerProof(capabilities) + } + : {}) + } + return Object.keys(parsed).length > 0 ? { capabilities: parsed } : {} +} + +function outputFlowControl(capabilities: RawCapabilities): HelloCapabilities { + const offer = record(capabilities.outputFlowControl) + return offer + ? { + outputFlowControl: { + versions: numericVersions(offer.versions), + requestedWindowSu: Number(offer.requestedWindowSu) + } + } + : {} +} + +function versionedOffer< + Name extends + | 'exactOperations' + | 'heldProducerPause' + | 'terminalAuthorityExactOperations' + | 'terminalAuthorityOutcomeDelivery' +>(capabilities: RawCapabilities, name: Name): HelloCapabilities { + const offer = record(capabilities[name]) + return offer + ? ({ [name]: { versions: numericVersions(offer.versions) } } as HelloCapabilities) + : {} +} + +// An unvalidatable consumer drops the capability: that identity is what a durable claim is attributed to. +function namespaceOutcomes(capabilities: RawCapabilities): HelloCapabilities { + const offer = record(capabilities.terminalAuthorityNamespaceOutcomes) + const consumer = offer && parseTerminalAuthorityPolicyConsumerIdentity(offer.consumer) + if (!offer || !consumer) { + return {} + } + return { + terminalAuthorityNamespaceOutcomes: { + versions: numericVersions(offer.versions), + consumer, + expectedConsumerIncarnationId: + offer.expectedConsumerIncarnationId === null + ? null + : String(offer.expectedConsumerIncarnationId ?? '') + } + } +} + +function consumerProof(capabilities: RawCapabilities): HelloCapabilities { + const offer = record(capabilities.terminalAuthorityConsumerProof) + if (!offer) { + return {} + } + return { + terminalAuthorityConsumerProof: { + versions: numericVersions(offer.versions), + // Absent stays absent so an old peer is not read as offering an empty retirement set. + ...(offer.retirementVersions !== undefined + ? { retirementVersions: numericVersions(offer.retirementVersions) } + : {}) + } + } +} + +function record(value: unknown): RawCapabilities | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as RawCapabilities) + : undefined +} + +function numericVersions(value: unknown): number[] { + return Array.isArray(value) ? value.map(Number) : [] +} diff --git a/src/relay/ssh-pty-open-client-exact-capability.test.ts b/src/relay/ssh-pty-open-client-exact-capability.test.ts new file mode 100644 index 00000000000..8f05ac1a23d --- /dev/null +++ b/src/relay/ssh-pty-open-client-exact-capability.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest' +import { parseOpenClientParams } from './ssh-pty-open-client-request' + +describe('SSH PTY open-client exact capability parsing', () => { + it('preserves exact and flow-control offers from the transport', () => { + expect( + parseOpenClientParams({ + protocolVersion: 1, + clientInstanceId: 'client-1', + requestedRole: 'session-owner', + capabilities: { + outputFlowControl: { versions: [1], requestedWindowSu: 64 }, + exactOperations: { versions: [1, '2'] }, + terminalAuthorityExactOperations: { versions: [1] }, + terminalAuthorityOutcomeDelivery: { versions: [1] } + } + }).capabilities + ).toEqual({ + outputFlowControl: { versions: [1], requestedWindowSu: 64 }, + exactOperations: { versions: [1, 2] }, + terminalAuthorityExactOperations: { versions: [1] }, + terminalAuthorityOutcomeDelivery: { versions: [1] } + }) + }) + + it('keeps an old client capability-free', () => { + expect( + parseOpenClientParams({ + protocolVersion: 1, + clientInstanceId: 'client-1', + requestedRole: 'session-owner' + }).capabilities + ).toBeUndefined() + }) + + it('does not let the generic owner adapter mint the gateway-owned topology grant', () => { + expect( + parseOpenClientParams({ + protocolVersion: 1, + clientInstanceId: 'client-1', + requestedRole: 'session-owner', + capabilities: { terminalAuthorityTopology: { versions: [1] } } + }).capabilities + ).toBeUndefined() + }) + + it('drops every terminal-authority offer from a subscriber', () => { + expect( + parseOpenClientParams({ + protocolVersion: 1, + clientInstanceId: 'client-1', + requestedRole: 'subscriber', + capabilities: { + outputFlowControl: { versions: [1], requestedWindowSu: 64 }, + terminalAuthorityExactOperations: { versions: [1] }, + terminalAuthorityOutcomeDelivery: { versions: [1] }, + terminalAuthorityConsumerProof: { versions: [1] }, + terminalAuthorityNamespaceOutcomes: { + versions: [1], + consumer: { consumerId: 'app-profile:a', consumerIncarnationId: 'app-process:a' }, + expectedConsumerIncarnationId: null + } + } + }).capabilities + ).toEqual({ outputFlowControl: { versions: [1], requestedWindowSu: 64 } }) + }) + + it('drops a namespace-outcome offer whose consumer identity does not validate', () => { + expect( + parseOpenClientParams({ + protocolVersion: 1, + clientInstanceId: 'client-1', + requestedRole: 'session-owner', + capabilities: { + terminalAuthorityNamespaceOutcomes: { + versions: [1], + consumer: { consumerId: 42, consumerIncarnationId: null }, + expectedConsumerIncarnationId: null + } + } + }).capabilities + ).toBeUndefined() + }) + + it('preserves a validated consumer identity and an absent optional retirement offer', () => { + expect( + parseOpenClientParams({ + protocolVersion: 1, + clientInstanceId: 'client-1', + requestedRole: 'session-owner', + capabilities: { + terminalAuthorityConsumerProof: { versions: [1] }, + terminalAuthorityNamespaceOutcomes: { + versions: [1], + consumer: { consumerId: 'app-profile:a', consumerIncarnationId: 'app-process:a' }, + expectedConsumerIncarnationId: null + } + } + }).capabilities + ).toEqual({ + terminalAuthorityConsumerProof: { versions: [1] }, + terminalAuthorityNamespaceOutcomes: { + versions: [1], + consumer: { consumerId: 'app-profile:a', consumerIncarnationId: 'app-process:a' }, + expectedConsumerIncarnationId: null + } + }) + }) +}) diff --git a/src/relay/ssh-pty-open-client-request.ts b/src/relay/ssh-pty-open-client-request.ts index 48d6d255117..dbdb92cba22 100644 --- a/src/relay/ssh-pty-open-client-request.ts +++ b/src/relay/ssh-pty-open-client-request.ts @@ -1,5 +1,6 @@ import type { PtyConsumerSessionHello } from '../shared/pty-consumer-session' import type { RelayClientSessionIdentity, RequestContext } from './dispatcher' +import { parseOpenClientCapabilities } from './ssh-pty-open-client-capabilities' export type OpenClientParams = PtyConsumerSessionHello & { protocolVersion: number @@ -13,18 +14,13 @@ export function parseOpenClientParams(params: Record): OpenClie typeof params.resume === 'object' && params.resume !== null ? (params.resume as Record) : undefined - const capabilities = - typeof params.capabilities === 'object' && params.capabilities !== null - ? (params.capabilities as Record) - : undefined - const outputFlowControl = - typeof capabilities?.outputFlowControl === 'object' && capabilities.outputFlowControl !== null - ? (capabilities.outputFlowControl as Record) - : undefined + const requestedRole = String( + params.requestedRole ?? '' + ) as PtyConsumerSessionHello['requestedRole'] return { protocolVersion: Number(params.protocolVersion), clientInstanceId: String(params.clientInstanceId ?? ''), - requestedRole: String(params.requestedRole ?? '') as PtyConsumerSessionHello['requestedRole'], + requestedRole, ...(resume ? { resume: { @@ -33,18 +29,7 @@ export function parseOpenClientParams(params: Record): OpenClie } } : {}), - ...(outputFlowControl - ? { - capabilities: { - outputFlowControl: { - versions: Array.isArray(outputFlowControl.versions) - ? outputFlowControl.versions.map(Number) - : [], - requestedWindowSu: Number(outputFlowControl.requestedWindowSu) - } - } - } - : {}) + ...parseOpenClientCapabilities(params, requestedRole) } } diff --git a/src/relay/ssh-pty-source-credit-adapter.ts b/src/relay/ssh-pty-source-credit-adapter.ts index 1c900fb9684..f0d6a019370 100644 --- a/src/relay/ssh-pty-source-credit-adapter.ts +++ b/src/relay/ssh-pty-source-credit-adapter.ts @@ -7,33 +7,39 @@ import { type PtySourceSpan, type PtySourceTransform } from '../shared/pty-source-credit-contract' -import { - PTY_CONSUMER_OWNER_GRACE_MS, - type PtyConsumerSessionGrant -} from '../shared/pty-consumer-session' -import { - ptySourceCancellationResult, - RecentPtySourceCancellationIndex -} from './pty-source-cancellation-index' -import { notifyPtySourceCreditAvailable } from './pty-source-credit-availability-notice' +import type { PtyConsumerSessionGrant } from '../shared/pty-consumer-session' +import { ptySourceCancellationResult } from './pty-source-cancellation-index' import { ownedPtySourceDelivery } from './pty-source-delivery-ownership' -import { - RelayPtySourceCreditLedger, - type PtySourceSendReservation -} from './pty-source-credit-ledger' +import type { PtySourceSendReservation } from './pty-source-credit-ledger' +import { SshPtySourceCreditRetention } from './ssh-pty-source-credit-retention' export class SshPtySourceCreditAdapter { - private readonly sourceCredit = new RelayPtySourceCreditLedger() - private readonly identityByToken = new Map() - private readonly graceTimers = new Map>() - private readonly recentCancellations = new RecentPtySourceCancellationIndex() - private readonly relayProviderGeneration = 1 + private readonly retention: SshPtySourceCreditRetention private disposed = false constructor( - private readonly publishCancellation?: (proof: PtySourceDeliveryCancellation) => void, - private readonly onCreditAvailable?: (id: string) => void - ) {} + publishCancellation?: (proof: PtySourceDeliveryCancellation) => void, + onCreditAvailable?: (id: string) => void, + onExactCreditAvailable?: (identity: PtySourceDeliveryIdentity) => void + ) { + this.retention = new SshPtySourceCreditRetention( + publishCancellation, + onCreditAvailable, + onExactCreditAvailable + ) + } + + private get sourceCredit(): SshPtySourceCreditRetention['sourceCredit'] { + return this.retention.sourceCredit + } + + private get identityByToken(): SshPtySourceCreditRetention['identityByToken'] { + return this.retention.identityByToken + } + + private get recentCancellations(): SshPtySourceCreditRetention['recentCancellations'] { + return this.retention.recentCancellations + } open( grant: Readonly | null, @@ -57,7 +63,7 @@ export class SshPtySourceCreditAdapter { } const identity = Object.freeze({ id, - providerGeneration: this.relayProviderGeneration, + providerGeneration: this.retention.relayProviderGeneration, clientGeneration: grant.clientGeneration, ownerGeneration: grant.ownerGeneration, ptyIncarnation, @@ -91,9 +97,9 @@ export class SshPtySourceCreditAdapter { ) this.identityByToken.delete(oldIdentity.deliveryToken) this.recentCancellations.remember(rotation.cancellation) - this.publishCancellation?.(rotation.cancellation) + this.retention.publishCancellationProof(rotation.cancellation) this.identityByToken.set(replacement.deliveryToken, replacement) - this.clearGraceWhenSettled(oldIdentity.ownerGeneration) + this.retention.clearGraceWhenSettled(oldIdentity.ownerGeneration) return Object.freeze({ identity: replacement, ...rotation }) } @@ -135,7 +141,7 @@ export class SshPtySourceCreditAdapter { result: { ok: true } | { ok: false; error: Error } ): void { this.sourceCredit.settleExitPublication(identity, result) - this.pruneClosed(identity.deliveryToken, identity) + this.retention.pruneClosed(identity.deliveryToken, identity) } snapshot(identity: PtySourceDeliveryIdentity): PtySourceDeliverySnapshot { @@ -177,9 +183,9 @@ export class SshPtySourceCreditAdapter { creditedEndSu: Number(candidate.creditedEndSu) }) if (result === 'advanced') { - this.onCreditAvailable?.(identity.id) + this.retention.notifyCreditAvailable(identity) } - this.pruneClosed(token, identity) + this.retention.pruneClosed(token, identity) } catch { /* Invalid and stale cumulative ACKs never mutate credit. */ } @@ -209,27 +215,21 @@ export class SshPtySourceCreditAdapter { const proof = this.sourceCredit.cancel(identity, 'client-request') this.identityByToken.delete(token) this.recentCancellations.remember(proof) - this.clearGraceWhenSettled(identity.ownerGeneration) + this.retention.clearGraceWhenSettled(identity.ownerGeneration) // Why: the publication must retire its record the moment the delivery closes, or the next // exit seals a dead ledger entry. - notifyPtySourceCreditAvailable(this.onCreditAvailable, identity.id) + this.retention.notifyCreditAvailable(identity) return ptySourceCancellationResult(proof) } retainOrCloseOnDetach(grant: Readonly): void { - if (grant.role === 'session-owner' && grant.capabilities?.outputFlowControl) { - if (this.hasOwnerDeliveries(grant.ownerGeneration!)) { - this.scheduleGraceExpiry(grant.ownerGeneration!) - } - return - } - this.closeClientDeliveries(grant.clientGeneration) + this.retention.retainOrCloseOnDetach(grant) } retentionSnapshot() { return Object.freeze({ deliveryTokens: this.identityByToken.size, - graceTimers: this.graceTimers.size, + graceTimers: this.retention.graceTimers.size, ...this.sourceCredit.retentionSnapshot() }) } @@ -247,7 +247,7 @@ export class SshPtySourceCreditAdapter { if (this.identityByToken.get(token) !== identity) { return } - this.cancelExact(token, identity, reason) + this.retention.cancelIdentity(identity, reason) } dispose(): void { @@ -255,77 +255,6 @@ export class SshPtySourceCreditAdapter { return } this.disposed = true - this.graceTimers.forEach((timer) => clearTimeout(timer)) - this.graceTimers.clear() - this.sourceCredit.closeGeneration(this.relayProviderGeneration) - this.identityByToken.clear() - this.recentCancellations.clear() - } - - private closeClientDeliveries(clientGeneration: number): void { - for (const [token, identity] of this.identityByToken) { - if (identity.clientGeneration !== clientGeneration) { - continue - } - this.cancelExact(token, identity, 'client-detached') - notifyPtySourceCreditAvailable(this.onCreditAvailable, identity.id) - } - } - - private scheduleGraceExpiry(ownerGeneration: number): void { - this.clearGraceTimer(ownerGeneration) - const timer = setTimeout(() => { - this.graceTimers.delete(ownerGeneration) - for (const [token, identity] of this.identityByToken) { - if (identity.ownerGeneration !== ownerGeneration) { - continue - } - this.cancelExact(token, identity, 'reconnect-grace-expired') - notifyPtySourceCreditAvailable(this.onCreditAvailable, identity.id) - } - }, PTY_CONSUMER_OWNER_GRACE_MS) - timer.unref?.() - this.graceTimers.set(ownerGeneration, timer) - } - - private cancelExact(token: string, identity: PtySourceDeliveryIdentity, reason: string): void { - // Why: this runs from the bare grace setTimeout, where an evicted tombstone probing unknown - // would throw straight into uncaughtException. - const snapshot = this.sourceCredit.snapshotIfKnown(identity) - if (snapshot && snapshot.state !== 'closed') { - const proof = this.sourceCredit.cancel(identity, reason) - this.recentCancellations.remember(proof) - this.publishCancellation?.(proof) - } - if (this.identityByToken.get(token) === identity) { - this.identityByToken.delete(token) - } - this.clearGraceWhenSettled(identity.ownerGeneration) - } - - private pruneClosed(token: string, identity: PtySourceDeliveryIdentity): void { - if ( - this.sourceCredit.snapshot(identity).state === 'closed' && - this.identityByToken.get(token) === identity - ) { - this.identityByToken.delete(token) - this.clearGraceWhenSettled(identity.ownerGeneration) - } - } - - private hasOwnerDeliveries = (ownerGeneration: number): boolean => - Array.from(this.identityByToken.values()).some( - (identity) => identity.ownerGeneration === ownerGeneration - ) - - private clearGraceWhenSettled(ownerGeneration: number): void { - if (!this.hasOwnerDeliveries(ownerGeneration)) { - this.clearGraceTimer(ownerGeneration) - } - } - - private clearGraceTimer(ownerGeneration: number): void { - clearTimeout(this.graceTimers.get(ownerGeneration)) - this.graceTimers.delete(ownerGeneration) + this.retention.dispose() } } diff --git a/src/relay/ssh-pty-source-credit-retention.ts b/src/relay/ssh-pty-source-credit-retention.ts new file mode 100644 index 00000000000..0173df5d7d7 --- /dev/null +++ b/src/relay/ssh-pty-source-credit-retention.ts @@ -0,0 +1,125 @@ +import { + PTY_CONSUMER_OWNER_GRACE_MS, + type PtyConsumerSessionGrant +} from '../shared/pty-consumer-session' +import type { + PtySourceDeliveryCancellation, + PtySourceDeliveryIdentity +} from '../shared/pty-source-credit-contract' +import { RecentPtySourceCancellationIndex } from './pty-source-cancellation-index' +import { notifyPtySourceCreditAvailability } from './pty-source-credit-notification' +import { RelayPtySourceCreditLedger } from './pty-source-credit-ledger' + +export class SshPtySourceCreditRetention { + readonly sourceCredit = new RelayPtySourceCreditLedger() + readonly identityByToken = new Map() + readonly graceTimers = new Map>() + readonly recentCancellations = new RecentPtySourceCancellationIndex() + readonly relayProviderGeneration = 1 + + constructor( + private readonly publishCancellation?: (proof: PtySourceDeliveryCancellation) => void, + private readonly onCreditAvailable?: (id: string) => void, + private readonly onExactCreditAvailable?: (identity: PtySourceDeliveryIdentity) => void + ) {} + + notifyCreditAvailable(identity: PtySourceDeliveryIdentity): void { + notifyPtySourceCreditAvailability(this.onCreditAvailable, this.onExactCreditAvailable, identity) + } + + publishCancellationProof(proof: PtySourceDeliveryCancellation): void { + this.publishCancellation?.(proof) + } + + pruneClosed(token: string, identity: PtySourceDeliveryIdentity): void { + if ( + this.sourceCredit.snapshot(identity).state === 'closed' && + this.identityByToken.get(token) === identity + ) { + this.identityByToken.delete(token) + this.clearGraceWhenSettled(identity.ownerGeneration) + } + } + + clearGraceWhenSettled(ownerGeneration: number): void { + if (!this.hasOwnerDeliveries(ownerGeneration)) { + this.clearGraceTimer(ownerGeneration) + } + } + + retainOrCloseOnDetach(grant: Readonly): void { + if (grant.role === 'session-owner' && grant.capabilities?.outputFlowControl) { + if (this.hasOwnerDeliveries(grant.ownerGeneration!)) { + this.scheduleGraceExpiry(grant.ownerGeneration!) + } + return + } + this.closeClientDeliveries(grant.clientGeneration) + } + + cancelIdentity(identity: PtySourceDeliveryIdentity, reason: string): void { + const token = identity.deliveryToken + if (this.identityByToken.get(token) !== identity) { + return + } + this.cancelExact(token, identity, reason) + } + + dispose(): void { + this.graceTimers.forEach((timer) => clearTimeout(timer)) + this.graceTimers.clear() + this.sourceCredit.closeGeneration(this.relayProviderGeneration) + this.identityByToken.clear() + this.recentCancellations.clear() + } + + private closeClientDeliveries(clientGeneration: number): void { + for (const [token, identity] of this.identityByToken) { + if (identity.clientGeneration !== clientGeneration) { + continue + } + this.cancelExact(token, identity, 'client-detached') + this.notifyCreditAvailable(identity) + } + } + + private scheduleGraceExpiry(ownerGeneration: number): void { + this.clearGraceTimer(ownerGeneration) + const timer = setTimeout(() => { + this.graceTimers.delete(ownerGeneration) + for (const [token, identity] of this.identityByToken) { + if (identity.ownerGeneration !== ownerGeneration) { + continue + } + this.cancelExact(token, identity, 'reconnect-grace-expired') + this.notifyCreditAvailable(identity) + } + }, PTY_CONSUMER_OWNER_GRACE_MS) + timer.unref?.() + this.graceTimers.set(ownerGeneration, timer) + } + + private cancelExact(token: string, identity: PtySourceDeliveryIdentity, reason: string): void { + const snapshot = this.sourceCredit.snapshotIfKnown(identity) + if (snapshot && snapshot.state !== 'closed') { + const proof = this.sourceCredit.cancel(identity, reason) + this.recentCancellations.remember(proof) + this.publishCancellationProof(proof) + } + if (this.identityByToken.get(token) === identity) { + this.identityByToken.delete(token) + } + this.clearGraceWhenSettled(identity.ownerGeneration) + } + + private hasOwnerDeliveries(ownerGeneration: number): boolean { + return Array.from(this.identityByToken.values()).some( + (identity) => identity.ownerGeneration === ownerGeneration + ) + } + + private clearGraceTimer(ownerGeneration: number): void { + clearTimeout(this.graceTimers.get(ownerGeneration)) + this.graceTimers.delete(ownerGeneration) + } +} diff --git a/src/relay/ssh-terminal-authority-client-consumers.ts b/src/relay/ssh-terminal-authority-client-consumers.ts new file mode 100644 index 00000000000..655473fbaad --- /dev/null +++ b/src/relay/ssh-terminal-authority-client-consumers.ts @@ -0,0 +1,136 @@ +import type { TerminalAuthorityNamespace } from '../shared/terminal-session-authority-identity' +import { + sameTerminalAuthorityPolicyConsumer, + type TerminalAuthorityNamespaceOutcomeAck, + type TerminalAuthorityPolicyConsumerIdentity +} from '../shared/terminal-session-authority-consumer-transport' +import type { TerminalAuthorityAuthenticatedConsumerTransport } from '../main/session-authority/terminal-session-authority-consumer-admission' +import type { TerminalAuthorityAuthenticatedNamespaceSession } from '../main/session-authority/terminal-session-authority-authenticated-consumers' +import type { TerminalAuthorityPolicyConsumerConnection } from '../main/session-authority/terminal-session-authority-policy-consumers' +import type { TerminalSessionAuthorityService } from '../main/session-authority/terminal-session-authority-service' +import type { RelayClientSessionIdentity } from './dispatcher' +import type { SshTerminalAuthorityPolicyPublication } from './ssh-terminal-authority-policy-publication' + +export type SshTerminalAuthorityInstalledNamespace = Readonly<{ + session: TerminalAuthorityAuthenticatedNamespaceSession + publication: SshTerminalAuthorityPolicyPublication +}> + +export class SshTerminalAuthorityClientConsumers { + readonly transport: TerminalAuthorityAuthenticatedConsumerTransport + private readonly namespaces = new Map() + private facade: TerminalAuthorityPolicyConsumerConnection | null = null + + constructor( + readonly identity: RelayClientSessionIdentity, + connectionGrantId: string, + capability: string, + readonly consumerRetirementSupported: boolean, + private readonly disconnectClient: () => void + ) { + this.transport = Object.freeze({ + connectionGrantId, + principal: identity.principal, + capability, + token: Object.freeze({}) + }) + } + + install(installed: SshTerminalAuthorityInstalledNamespace): void { + const identity = installed.session.grant.consumer + if (this.facade && !sameTerminalAuthorityPolicyConsumer(this.facade.identity, identity)) { + throw new Error('SSH terminal authority consumer identity changed on one connection') + } + const key = namespaceKey(installed.session.grant.namespace) + const current = this.namespaces.get(key) + if (current?.session === installed.session) { + installed.publication.close() + return + } + if (current && current.session !== installed.session) { + current.publication.close() + current.session.disconnect() + } + this.namespaces.set(key, installed) + this.facade ??= this.createFacade(identity) + } + + connection(): TerminalAuthorityPolicyConsumerConnection | null { + return this.namespaces.size > 0 ? this.facade : null + } + + installed(namespace: TerminalAuthorityNamespace): SshTerminalAuthorityInstalledNamespace | null { + return this.namespaces.get(namespaceKey(namespace)) ?? null + } + + remove(namespace: TerminalAuthorityNamespace): void { + const installed = this.namespaces.get(namespaceKey(namespace)) + installed?.publication.close() + installed?.session.disconnect() + this.namespaces.delete(namespaceKey(namespace)) + if (this.namespaces.size === 0) { + this.facade = null + } + } + + disconnect(): void { + for (const installed of this.namespaces.values()) { + installed.publication.close() + installed.session.disconnect() + } + this.namespaces.clear() + this.facade = null + } + + private createFacade( + identity: TerminalAuthorityPolicyConsumerIdentity + ): TerminalAuthorityPolicyConsumerConnection { + return Object.freeze({ + identity: Object.freeze({ ...identity }), + activate: async () => {}, + ensureNamespace: (service) => this.ensureNamespace(service), + assertInstalled: (namespace) => this.assertInstalled(namespace), + acknowledge: (ack) => this.acknowledge(ack), + retire: () => this.retireAll(), + isInstalled: (namespace) => this.isInstalled(namespace), + disconnect: this.disconnectClient + }) + } + + private async ensureNamespace(service: TerminalSessionAuthorityService): Promise { + this.assertInstalled(service.namespace) + } + + private assertInstalled(namespace: TerminalAuthorityNamespace): void { + const installed = this.installed(namespace) + if (!installed || !installed.session.policyConsumer.isInstalled(namespace)) { + throw new Error('SSH terminal authority namespace consumer is not installed') + } + installed.session.policyConsumer.assertInstalled(namespace) + } + + private isInstalled(namespace: TerminalAuthorityNamespace): boolean { + return this.installed(namespace)?.session.policyConsumer.isInstalled(namespace) === true + } + + private acknowledge(ack: TerminalAuthorityNamespaceOutcomeAck): Promise { + const installed = this.installed(ack.namespace) + if (!installed) { + throw new Error('SSH terminal authority namespace consumer is not installed') + } + return installed.session.policyConsumer.acknowledge(ack) + } + + private async retireAll(): Promise { + let retired = 0 + for (const installed of this.namespaces.values()) { + retired += await installed.session.policyConsumer.retire() + } + this.disconnect() + return retired + } +} + +function namespaceKey(namespace: TerminalAuthorityNamespace): string { + return JSON.stringify([namespace.authorityHostId, namespace.namespaceId]) +} diff --git a/src/relay/ssh-terminal-authority-policy-consumers.test.ts b/src/relay/ssh-terminal-authority-policy-consumers.test.ts new file mode 100644 index 00000000000..9281f43d3c3 --- /dev/null +++ b/src/relay/ssh-terminal-authority-policy-consumers.test.ts @@ -0,0 +1,637 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { TerminalAuthorityAuthenticatedNamespacePreparation } from '../main/session-authority/terminal-session-authority-authenticated-consumers' +import type { TerminalAuthorityAuthenticatedConsumerTransport } from '../main/session-authority/terminal-session-authority-consumer-admission' +import { + createTerminalAuthorityConsumerProof, + createTerminalAuthorityConsumerRetirementProof, + createTerminalAuthorityProofEphemeralKeypair, + terminalAuthorityAdmissionCas, + terminalAuthorityHostAppConsumerId, + terminalAuthorityRetirementCas +} from '../main/session-authority/terminal-session-authority-consumer-proof' +import type { TerminalSessionAuthorityPtyLifecycle } from '../main/session-authority/terminal-session-authority-pty-lifecycle' +import type { TerminalAuthorityPolicyConsumerConnection } from '../main/session-authority/terminal-session-authority-policy-consumers' +import { + TERMINAL_AUTHORITY_CONSUMER_PROOF_ALGORITHM, + TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION, + type TerminalAuthorityNamespaceAdmissionProof, + type TerminalAuthorityNamespaceAdmissionStart +} from '../shared/terminal-session-authority-consumer-proof' +import { + TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_ALGORITHM, + TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_VERSION, + type TerminalAuthorityConsumerRetirementProof, + type TerminalAuthorityConsumerRetirementStart +} from '../shared/terminal-session-authority-consumer-retirement' +import type { TerminalAuthorityNamespace } from '../shared/terminal-session-authority-identity' +import { RelayDispatcher, type RelayClientSessionIdentity } from './dispatcher' +import { encodeJsonRpcFrame, MessageType } from './protocol' +import { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter' +import { + SSH_TERMINAL_AUTHORITY_CONSUMER_CHALLENGE_METHOD, + SSH_TERMINAL_AUTHORITY_CONSUMER_GRANT_METHOD, + SSH_TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_CHALLENGE_METHOD, + SSH_TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_METHOD, + SSH_TERMINAL_AUTHORITY_CONSUMER_RESOLVE_NAMESPACE_METHOD +} from '../main/ssh/ssh-terminal-authority-consumer-methods' + +const HOST_ID = 'authority-host:ssh-policy-test' +const NAMESPACE: TerminalAuthorityNamespace = Object.freeze({ + authorityHostId: HOST_ID, + namespaceId: 'namespace:ssh-policy-test' +}) +const APP_KEYPAIR = createTerminalAuthorityProofEphemeralKeypair() +const CONSUMER = Object.freeze({ + consumerId: terminalAuthorityHostAppConsumerId(HOST_ID, APP_KEYPAIR.publicKey), + consumerIncarnationId: 'app-process:ssh-policy-test' +}) +const PRINCIPAL: RelayClientSessionIdentity = Object.freeze({ + principal: 'endpoint-principal:a', + authenticated: true, + allowSessionOwner: true, + authenticationKind: 'endpoint-credential' +}) +const dispatchers: RelayDispatcher[] = [] + +afterEach(() => { + for (const dispatcher of dispatchers.splice(0)) { + dispatcher.dispose() + } +}) + +describe('SSH terminal authority policy consumers', () => { + it('derives identity through proof and admits exact mutations only after committed installation', async () => { + const connection = fakeConnection() + const lifecycle = fakeLifecycle(connection) + const { adapter, writes } = createAdapter(lifecycle) + + feedOpen(dispatchers[0]!) + await waitForWrites(writes, 1) + expect(responseResult(writes[0])).toMatchObject({ + capabilities: { + terminalAuthorityExactOperations: { version: 1 }, + terminalAuthorityConsumerProof: { version: 1, authorityHostId: HOST_ID } + } + }) + expect(responseResult(writes[0])).not.toHaveProperty( + 'capabilities.terminalAuthorityNamespaceOutcomes' + ) + expect(adapter.terminalAuthorityExactOperations(1)).toBe(false) + + const proof = await requestProof(dispatchers[0]!, writes) + feedRequest(dispatchers[0]!, 3, 3, SSH_TERMINAL_AUTHORITY_CONSUMER_GRANT_METHOD, { proof }) + await waitForWrites(writes, 3) + + expect(responseResult(writes[2])).toMatchObject({ consumer: CONSUMER, namespace: NAMESPACE }) + expect(adapter.terminalAuthorityExactOperations(1)).toBe(true) + expect(lifecycle.prepareAuthenticatedPolicyConsumerNamespace).toHaveBeenCalledOnce() + }) + + it('installs the proof transport before the open-client write settles', async () => { + const lifecycle = fakeLifecycle(fakeConnection()) + const { writes, heldSettlements } = createAdapter(lifecycle, undefined, 1) + + feedOpen(dispatchers[0]!) + await waitForWrites(writes, 1) + feedRequest(dispatchers[0]!, 2, 2, SSH_TERMINAL_AUTHORITY_CONSUMER_RESOLVE_NAMESPACE_METHOD, { + worktreeId: 'repo::/workspace' + }) + await waitForWrites(writes, 2) + + expect(responseResult(writes[1])).toEqual(NAMESPACE) + expect(lifecycle.resolvePolicyConsumerNamespace).toHaveBeenCalledOnce() + heldSettlements[0]?.({ ok: true }) + }) + + it('installs the proof transport before an immediate follow-up request can run', async () => { + const lifecycle = fakeLifecycle(fakeConnection()) + const { adapter, writes } = createAdapter(lifecycle) + + feedOpen(dispatchers[0]!) + feedRequest(dispatchers[0]!, 2, 2, SSH_TERMINAL_AUTHORITY_CONSUMER_RESOLVE_NAMESPACE_METHOD, { + worktreeId: 'repo::/workspace' + }) + + await waitForWrites(writes, 2) + + expect(adapter.hasActiveClient(1)).toBe(true) + expect(adapter.terminalAuthorityExactOperations(1)).toBe(false) + expect(responseResult(writes[1])).toEqual(NAMESPACE) + }) + + it('commits before a grant response and never rolls durability back on write failure', async () => { + const connection = fakeConnection() + const lifecycle = fakeLifecycle(connection) + const rollback = lifecycle.rollback + const commit = lifecycle.commit + const { adapter, writes } = createAdapter(lifecycle, 3) + + feedOpen(dispatchers[0]!) + await waitForWrites(writes, 1) + const proof = await requestProof(dispatchers[0]!, writes) + feedRequest(dispatchers[0]!, 3, 3, SSH_TERMINAL_AUTHORITY_CONSUMER_GRANT_METHOD, { proof }) + await waitForWrites(writes, 3) + await vi.waitFor(() => expect(commit).toHaveBeenCalledOnce()) + + expect(rollback).not.toHaveBeenCalled() + expect(adapter.terminalAuthorityExactOperations(1)).toBe(false) + }) + + it('surfaces primary and rollback failures when grant cleanup fails', async () => { + const connection = fakeConnection() + const lifecycle = fakeLifecycle(connection) + const primaryFailure = new Error('grant commit failed') + const rollbackFailure = new Error('grant rollback failed') + lifecycle.prepareAuthenticatedPolicyConsumerNamespace.mockImplementationOnce(async (proof) => { + const preparation = preparationFor(proof, connection, lifecycle.commit, lifecycle.rollback) + return Object.freeze({ + ...preparation, + commit: async () => { + throw primaryFailure + }, + rollback: async () => { + throw rollbackFailure + } + }) + }) + const report = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + const { writes } = createAdapter(lifecycle) + feedOpen(dispatchers[0]!) + await waitForWrites(writes, 1) + const proof = await requestProof(dispatchers[0]!, writes) + feedRequest(dispatchers[0]!, 3, 3, SSH_TERMINAL_AUTHORITY_CONSUMER_GRANT_METHOD, { proof }) + await waitForWrites(writes, 3) + + const reported = report.mock.calls.at(-1)?.[1] + expect(reported).toBeInstanceOf(AggregateError) + expect((reported as AggregateError).errors).toEqual([primaryFailure, rollbackFailure]) + expect(responsePayload(writes[2])).toHaveProperty('error') + } finally { + report.mockRestore() + } + }) + + it('withholds the grant response until the host commit settles', async () => { + const connection = fakeConnection() + const lifecycle = fakeLifecycle(connection) + const committed = deferred() + lifecycle.prepareAuthenticatedPolicyConsumerNamespace.mockImplementationOnce(async (proof) => { + const preparation = preparationFor(proof, connection, lifecycle.commit, lifecycle.rollback) + return Object.freeze({ + ...preparation, + commit: async () => { + await committed.promise + return await preparation.commit() + } + }) + }) + const { writes } = createAdapter(lifecycle) + feedOpen(dispatchers[0]!) + await waitForWrites(writes, 1) + const proof = await requestProof(dispatchers[0]!, writes) + + feedRequest(dispatchers[0]!, 3, 3, SSH_TERMINAL_AUTHORITY_CONSUMER_GRANT_METHOD, { proof }) + await vi.waitFor(() => + expect(lifecycle.prepareAuthenticatedPolicyConsumerNamespace).toHaveBeenCalledOnce() + ) + expect(writes).toHaveLength(2) + committed.resolve() + await waitForWrites(writes, 3) + expect(responseResult(writes[2])).toMatchObject({ consumer: CONSUMER, namespace: NAMESPACE }) + }) + + it('resolves namespaces only after the proof transport grant is published', async () => { + const lifecycle = fakeLifecycle(fakeConnection()) + const { writes } = createAdapter(lifecycle) + + feedRequest(dispatchers[0]!, 1, 1, SSH_TERMINAL_AUTHORITY_CONSUMER_RESOLVE_NAMESPACE_METHOD, { + worktreeId: 'repo::/workspace' + }) + await waitForWrites(writes, 1) + expect(responsePayload(writes[0])).toHaveProperty('error') + + feedOpen(dispatchers[0]!, 2, 2) + await waitForWrites(writes, 2) + feedRequest(dispatchers[0]!, 3, 3, SSH_TERMINAL_AUTHORITY_CONSUMER_RESOLVE_NAMESPACE_METHOD, { + worktreeId: 'repo::/workspace' + }) + await waitForWrites(writes, 3) + + expect(responseResult(writes[2])).toEqual(NAMESPACE) + expect(lifecycle.resolvePolicyConsumerNamespace).toHaveBeenCalledWith('repo::/workspace') + }) + + it('retains no authenticated namespace when the client disconnects', async () => { + const connection = fakeConnection() + const lifecycle = fakeLifecycle(connection) + const { adapter, writes } = createAdapter(lifecycle) + feedOpen(dispatchers[0]!) + await waitForWrites(writes, 1) + const proof = await requestProof(dispatchers[0]!, writes) + feedRequest(dispatchers[0]!, 3, 3, SSH_TERMINAL_AUTHORITY_CONSUMER_GRANT_METHOD, { proof }) + await waitForWrites(writes, 3) + expect(adapter.terminalAuthorityExactOperations(1)).toBe(true) + + dispatchers[0]!.invalidateClient('peer-closed') + + expect(adapter.terminalAuthorityExactOperations(1)).toBe(false) + expect(lifecycle.releaseAuthenticatedPolicyConsumerTransport).toHaveBeenCalledOnce() + }) + + it('reports a rollback failure when a client disconnects during a pending commit', async () => { + const connection = fakeConnection() + const lifecycle = fakeLifecycle(connection) + const commitStarted = deferred() + const releaseCommit = deferred() + const rollbackFailure = new Error('disconnect rollback failed') + lifecycle.prepareAuthenticatedPolicyConsumerNamespace.mockImplementationOnce(async (proof) => { + const preparation = preparationFor(proof, connection, lifecycle.commit, lifecycle.rollback) + return Object.freeze({ + ...preparation, + commit: async () => { + commitStarted.resolve() + await releaseCommit.promise + return await preparation.commit() + }, + rollback: async () => { + throw rollbackFailure + } + }) + }) + const report = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + const { writes } = createAdapter(lifecycle) + feedOpen(dispatchers[0]!) + await waitForWrites(writes, 1) + const proof = await requestProof(dispatchers[0]!, writes) + feedRequest(dispatchers[0]!, 3, 3, SSH_TERMINAL_AUTHORITY_CONSUMER_GRANT_METHOD, { proof }) + await commitStarted.promise + + dispatchers[0]!.invalidateClient('peer-closed') + await vi.waitFor(() => expect(report).toHaveBeenCalled()) + expect( + report.mock.calls.some((call) => + (call[1] as AggregateError)?.errors?.includes(rollbackFailure) + ) + ).toBe(true) + + releaseCommit.resolve() + } finally { + releaseCommit.resolve() + report.mockRestore() + } + }) + + it('rolls back a preparation that resolves after its client disconnects', async () => { + const connection = fakeConnection() + const lifecycle = fakeLifecycle(connection) + const gate = deferred() + lifecycle.prepareAuthenticatedPolicyConsumerNamespace.mockImplementationOnce( + async (proof: TerminalAuthorityNamespaceAdmissionProof) => { + await gate.promise + return preparationFor(proof, connection, lifecycle.commit, lifecycle.rollback) + } + ) + const { adapter, writes } = createAdapter(lifecycle) + feedOpen(dispatchers[0]!) + await waitForWrites(writes, 1) + const proof = await requestProof(dispatchers[0]!, writes) + feedRequest(dispatchers[0]!, 3, 3, SSH_TERMINAL_AUTHORITY_CONSUMER_GRANT_METHOD, { proof }) + await vi.waitFor(() => + expect(lifecycle.prepareAuthenticatedPolicyConsumerNamespace).toHaveBeenCalledOnce() + ) + + dispatchers[0]!.invalidateClient('peer-closed') + gate.resolve() + + await vi.waitFor(() => expect(lifecycle.rollback).toHaveBeenCalledOnce()) + expect(lifecycle.commit).not.toHaveBeenCalled() + expect(adapter.terminalAuthorityExactOperations(1)).toBe(false) + }) + + it('returns an authenticated durable absence without claiming the consumer', async () => { + const lifecycle = fakeLifecycle(fakeConnection()) + const { writes } = createAdapter(lifecycle) + feedOpen(dispatchers[0]!) + await waitForWrites(writes, 1) + const proof = await requestRetirementProof(dispatchers[0]!, writes) + + feedRequest(dispatchers[0]!, 3, 3, SSH_TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_METHOD, { proof }) + await waitForWrites(writes, 3) + + expect(responseResult(writes[2])).toMatchObject({ + consumerId: CONSUMER.consumerId, + retired: true, + alreadyAbsent: true + }) + expect(lifecycle.retireAuthenticatedPolicyConsumer).toHaveBeenCalledOnce() + expect(lifecycle.prepareAuthenticatedPolicyConsumerNamespace).not.toHaveBeenCalled() + }) + + it('rejects retirement before mutation when it was not negotiated', async () => { + const lifecycle = fakeLifecycle(fakeConnection()) + const { writes } = createAdapter(lifecycle) + feedOpen(dispatchers[0]!, 1, 1, false) + await waitForWrites(writes, 1) + + feedRequest( + dispatchers[0]!, + 2, + 2, + SSH_TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_CHALLENGE_METHOD, + { start: retirementStart() } + ) + await waitForWrites(writes, 2) + + expect(responsePayload(writes[1])).toHaveProperty('error') + expect(lifecycle.issuePolicyConsumerRetirementChallenge).not.toHaveBeenCalled() + }) + + // The proofless owner admission stays for legacy PTY compatibility. This pins the isolation that + // makes it safe: no client consumer record exists, so no challenge, no claim, and no authority + // journal mutation are reachable from it. + it('isolates a proofless owner from every authority admission and mutation seam', async () => { + const lifecycle = fakeLifecycle(fakeConnection()) + const { adapter, writes } = createAdapter(lifecycle) + + feedRequest(dispatchers[0]!, 1, 1, 'pty.openClient', { + protocolVersion: 1, + clientInstanceId: 'client-legacy', + requestedRole: 'session-owner', + capabilities: { terminalAuthorityExactOperations: { versions: [1] } } + }) + await waitForWrites(writes, 1) + + const grant = responseResult(writes[0]) + expect(grant).toMatchObject({ role: 'session-owner' }) + expect(grant.capabilities).not.toHaveProperty('terminalAuthorityConsumerProof') + expect(adapter.terminalAuthorityPolicyConsumer(1)).toBeNull() + expect(adapter.terminalAuthorityExactOperations(1)).toBe(false) + + for (const [index, method] of [ + SSH_TERMINAL_AUTHORITY_CONSUMER_RESOLVE_NAMESPACE_METHOD, + SSH_TERMINAL_AUTHORITY_CONSUMER_CHALLENGE_METHOD, + SSH_TERMINAL_AUTHORITY_CONSUMER_GRANT_METHOD + ].entries()) { + feedRequest(dispatchers[0]!, index + 2, index + 2, method, { + worktreeId: 'repo::/srv/repo', + start: admissionStart(), + proof: { version: TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION } + }) + await waitForWrites(writes, index + 2) + expect(responsePayload(writes[index + 1])).toHaveProperty('error') + } + + expect(lifecycle.resolvePolicyConsumerNamespace).not.toHaveBeenCalled() + expect(lifecycle.issuePolicyConsumerChallenge).not.toHaveBeenCalled() + expect(lifecycle.prepareAuthenticatedPolicyConsumerNamespace).not.toHaveBeenCalled() + expect(lifecycle.commit).not.toHaveBeenCalled() + }) +}) + +type FakeLifecycle = TerminalSessionAuthorityPtyLifecycle & { + commit: ReturnType + rollback: ReturnType + prepareAuthenticatedPolicyConsumerNamespace: ReturnType + releaseAuthenticatedPolicyConsumerTransport: ReturnType + resolvePolicyConsumerNamespace: ReturnType + issuePolicyConsumerRetirementChallenge: ReturnType + retireAuthenticatedPolicyConsumer: ReturnType +} + +function fakeLifecycle(connection: TerminalAuthorityPolicyConsumerConnection): FakeLifecycle { + const hostEphemeral = createTerminalAuthorityProofEphemeralKeypair() + const commit = vi.fn() + const rollback = vi.fn(async () => {}) + const retirementHost = createTerminalAuthorityProofEphemeralKeypair() + const lifecycle = { + issuePolicyConsumerChallenge: vi.fn( + async ( + start: TerminalAuthorityNamespaceAdmissionStart, + transport: TerminalAuthorityAuthenticatedConsumerTransport + ) => ({ + ...start, + currentAdmissionCas: terminalAuthorityAdmissionCas(NAMESPACE, CONSUMER.consumerId, null), + connectionGrantId: transport.connectionGrantId, + authenticatedTransportPrincipal: transport.principal, + authenticatedTransportCapability: transport.capability, + hostEphemeralPublicKeyB64: Buffer.from(hostEphemeral.publicKey).toString('base64'), + expiresAtMs: Date.now() + 30_000 + }) + ), + prepareAuthenticatedPolicyConsumerNamespace: vi.fn( + async (proof: TerminalAuthorityNamespaceAdmissionProof) => + preparationFor(proof, connection, commit, rollback) + ), + releaseAuthenticatedPolicyConsumerTransport: vi.fn(), + resolvePolicyConsumerNamespace: vi.fn(async () => NAMESPACE), + issuePolicyConsumerRetirementChallenge: vi.fn( + async ( + start: TerminalAuthorityConsumerRetirementStart, + transport: TerminalAuthorityAuthenticatedConsumerTransport + ) => ({ + ...start, + consumerId: CONSUMER.consumerId, + currentConsumerIncarnationId: null, + retirementCas: terminalAuthorityRetirementCas(NAMESPACE, CONSUMER.consumerId, null), + connectionGrantId: transport.connectionGrantId, + liveAdmission: null, + authenticatedTransportPrincipal: transport.principal, + authenticatedTransportCapability: transport.capability, + hostEphemeralPublicKeyB64: Buffer.from(retirementHost.publicKey).toString('base64'), + expiresAtMs: Date.now() + 30_000 + }) + ), + retireAuthenticatedPolicyConsumer: vi.fn( + async (proof: TerminalAuthorityConsumerRetirementProof) => ({ + version: TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_VERSION, + namespace: NAMESPACE, + consumerId: CONSUMER.consumerId, + retiredConsumerIncarnationId: null, + requestId: proof.challenge.requestId, + candidateProcessIncarnationId: proof.challenge.candidateProcessIncarnationId, + candidateSessionNonce: proof.challenge.candidateSessionNonce, + connectionGrantId: proof.challenge.connectionGrantId, + retirementCas: proof.challenge.retirementCas, + retired: true, + alreadyAbsent: true, + replayed: false + }) + ), + commit, + rollback + } + return lifecycle as unknown as FakeLifecycle +} + +function preparationFor( + proof: TerminalAuthorityNamespaceAdmissionProof, + connection: TerminalAuthorityPolicyConsumerConnection, + commit: ReturnType, + rollback: ReturnType +): TerminalAuthorityAuthenticatedNamespacePreparation { + const grant = Object.freeze({ + version: TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION, + consumer: CONSUMER, + namespace: NAMESPACE, + requestId: proof.challenge.requestId, + connectionGrantId: proof.challenge.connectionGrantId, + admissionCas: terminalAuthorityAdmissionCas( + NAMESPACE, + CONSUMER.consumerId, + CONSUMER.consumerIncarnationId + ), + replayed: false + }) + const session = Object.freeze({ grant, policyConsumer: connection, disconnect: vi.fn() }) + commit.mockImplementation(() => session) + return Object.freeze({ + grant, + policyConsumer: connection, + commit: commit as TerminalAuthorityAuthenticatedNamespacePreparation['commit'], + rollback: rollback as TerminalAuthorityAuthenticatedNamespacePreparation['rollback'] + }) +} + +function fakeConnection(): TerminalAuthorityPolicyConsumerConnection { + return { + identity: CONSUMER, + activate: async () => {}, + ensureNamespace: async () => {}, + assertInstalled: () => {}, + acknowledge: async (ack) => ack.sequence, + retire: async () => 0, + isInstalled: () => true, + disconnect: vi.fn() + } +} + +function createAdapter( + lifecycle: TerminalSessionAuthorityPtyLifecycle, + failWrite?: number, + holdWrite?: number +) { + const writes: Buffer[] = [] + const heldSettlements: ((result: { ok: true } | { ok: false; error: Error }) => void)[] = [] + const dispatcher = new RelayDispatcher( + (data, settle) => { + writes.push(Buffer.from(data)) + const writeNumber = writes.length + if (holdWrite === writeNumber) { + heldSettlements.push(settle) + return true + } + settle( + failWrite === writeNumber ? { ok: false, error: new Error('grant failed') } : { ok: true } + ) + return true + }, + { supportsWriteCallback: true }, + PRINCIPAL + ) + dispatchers.push(dispatcher) + const adapter = new SshPtyConsumerSessionAdapter(dispatcher, 'build-a', undefined, undefined, { + terminalAuthorityExactOperations: true, + terminalAuthorityPolicyConsumers: lifecycle, + terminalAuthorityConsumerProofHostId: HOST_ID + }) + return { adapter, writes, heldSettlements } +} + +async function requestProof( + dispatcher: RelayDispatcher, + writes: readonly Buffer[] +): Promise { + const start = admissionStart() + feedRequest(dispatcher, 2, 2, SSH_TERMINAL_AUTHORITY_CONSUMER_CHALLENGE_METHOD, { start }) + await waitForWrites(writes, 2) + return createTerminalAuthorityConsumerProof(responseResult(writes[1]) as never, APP_KEYPAIR) +} + +async function requestRetirementProof( + dispatcher: RelayDispatcher, + writes: readonly Buffer[] +): Promise { + const start = retirementStart() + feedRequest(dispatcher, 2, 2, SSH_TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_CHALLENGE_METHOD, { + start + }) + await waitForWrites(writes, 2) + return createTerminalAuthorityConsumerRetirementProof( + responseResult(writes[1]) as never, + APP_KEYPAIR + ) +} + +function admissionStart(): TerminalAuthorityNamespaceAdmissionStart { + return Object.freeze({ + version: TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION, + algorithm: TERMINAL_AUTHORITY_CONSUMER_PROOF_ALGORITHM, + namespace: NAMESPACE, + appPublicKeyB64: Buffer.from(APP_KEYPAIR.publicKey).toString('base64'), + candidateProcessIncarnationId: CONSUMER.consumerIncarnationId, + candidateSessionNonce: 'app-session:ssh-policy-test', + requestId: 'app-request:ssh-policy-test', + intent: 'first' + }) +} + +function retirementStart(): TerminalAuthorityConsumerRetirementStart { + return Object.freeze({ + version: TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_VERSION, + algorithm: TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_ALGORITHM, + namespace: NAMESPACE, + appPublicKeyB64: Buffer.from(APP_KEYPAIR.publicKey).toString('base64'), + candidateProcessIncarnationId: CONSUMER.consumerIncarnationId, + candidateSessionNonce: 'app-retirement-session:ssh-policy-test', + requestId: 'app-retirement-request:ssh-policy-test' + }) +} + +function feedOpen(dispatcher: RelayDispatcher, id = 1, sequence = 1, retirement = true): void { + feedRequest(dispatcher, id, sequence, 'pty.openClient', { + protocolVersion: 1, + clientInstanceId: 'client-1', + requestedRole: 'session-owner', + capabilities: { + terminalAuthorityExactOperations: { versions: [1] }, + terminalAuthorityConsumerProof: { + versions: [1], + ...(retirement ? { retirementVersions: [1] } : {}) + } + } + }) +} + +function feedRequest( + dispatcher: RelayDispatcher, + id: number, + sequence: number, + method: string, + params: Record +): void { + dispatcher.feed(encodeJsonRpcFrame({ jsonrpc: '2.0', id, method, params }, sequence, 0)) +} + +function responseResult(buffer: Buffer): Record { + return responsePayload(buffer).result as Record +} + +function responsePayload(buffer: Buffer): Record { + expect(buffer[0]).toBe(MessageType.Regular) + const length = buffer.readUInt32BE(9) + return JSON.parse(buffer.subarray(13, 13 + length).toString('utf8')) +} + +async function waitForWrites(writes: readonly Buffer[], count: number): Promise { + await vi.waitFor(() => expect(writes).toHaveLength(count)) +} + +function deferred() { + let resolve!: (value: T | PromiseLike) => void + const promise = new Promise((accept) => { + resolve = accept + }) + return { promise, resolve } +} diff --git a/src/relay/ssh-terminal-authority-policy-consumers.ts b/src/relay/ssh-terminal-authority-policy-consumers.ts new file mode 100644 index 00000000000..2675e13e4e1 --- /dev/null +++ b/src/relay/ssh-terminal-authority-policy-consumers.ts @@ -0,0 +1,292 @@ +import { randomUUID } from 'node:crypto' +import { + TERMINAL_AUTHORITY_CONSUMER_PROOF_CAPABILITY, + parseTerminalAuthorityNamespaceAdmissionProof, + parseTerminalAuthorityNamespaceAdmissionStart +} from '../shared/terminal-session-authority-consumer-proof' +import { + parseTerminalAuthorityConsumerRetirementProof, + parseTerminalAuthorityConsumerRetirementStart +} from '../shared/terminal-session-authority-consumer-retirement' +import type { TerminalAuthorityNamespace } from '../shared/terminal-session-authority-identity' +import { terminalAuthorityHostAppConsumerId } from '../main/session-authority/terminal-session-authority-consumer-proof' +import type { TerminalSessionAuthorityPtyLifecycle } from '../main/session-authority/terminal-session-authority-pty-lifecycle' +import type { TerminalAuthorityPolicyConsumerConnection } from '../main/session-authority/terminal-session-authority-policy-consumers' +import { joinTerminalAuthorityRollbackFailure } from '../main/session-authority/terminal-session-authority-consumer-rollback-failure' +import { + SSH_TERMINAL_AUTHORITY_CONSUMER_CHALLENGE_METHOD, + SSH_TERMINAL_AUTHORITY_CONSUMER_GRANT_METHOD, + SSH_TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_CHALLENGE_METHOD, + SSH_TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_METHOD, + SSH_TERMINAL_AUTHORITY_CONSUMER_RESOLVE_NAMESPACE_METHOD +} from '../main/ssh/ssh-terminal-authority-consumer-methods' +import type { RelayClientSessionIdentity, RelayDispatcher, RequestContext } from './dispatcher' +import { SshTerminalAuthorityClientConsumers } from './ssh-terminal-authority-client-consumers' +import { SshTerminalAuthorityPolicyPublication } from './ssh-terminal-authority-policy-publication' +import { + SshTerminalAuthorityPolicyOperations, + type SshTerminalAuthorityPendingNamespace +} from './ssh-terminal-authority-policy-operations' + +export class SshTerminalAuthorityPolicyConsumers { + private readonly clients = new Map() + private readonly pending = new Map>() + private disposed = false + + constructor( + private readonly dispatcher: RelayDispatcher, + private readonly authority: TerminalSessionAuthorityPtyLifecycle, + private readonly onRollbackFailure: (error: unknown) => void = reportRollbackFailure + ) { + dispatcher.onRequest(SSH_TERMINAL_AUTHORITY_CONSUMER_CHALLENGE_METHOD, (params, context) => + this.issueChallenge(params, context) + ) + dispatcher.onRequest(SSH_TERMINAL_AUTHORITY_CONSUMER_GRANT_METHOD, (params, context) => + this.prepareGrant(params, context) + ) + dispatcher.onRequest( + SSH_TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_CHALLENGE_METHOD, + (params, context) => this.issueRetirementChallenge(params, context) + ) + dispatcher.onRequest(SSH_TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_METHOD, (params, context) => + this.retireConsumer(params, context) + ) + dispatcher.onRequest( + SSH_TERMINAL_AUTHORITY_CONSUMER_RESOLVE_NAMESPACE_METHOD, + (params, context) => this.resolveNamespace(params, context) + ) + new SshTerminalAuthorityPolicyOperations(dispatcher, { + requireClient: (context) => this.requireClient(context), + pending: (clientId) => this.pending.get(clientId) + }) + } + + installTransport( + clientId: number, + identity: RelayClientSessionIdentity, + consumerRetirementSupported = false + ): void { + if (this.disposed || !identity.authenticated || identity.authenticationKind === 'unproved') { + throw new Error('SSH terminal authority authenticated transport is unavailable') + } + this.disconnect(clientId) + const client = new SshTerminalAuthorityClientConsumers( + identity, + randomUUID(), + TERMINAL_AUTHORITY_CONSUMER_PROOF_CAPABILITY, + consumerRetirementSupported, + () => this.disconnect(clientId) + ) + this.clients.set(clientId, client) + } + + disconnect(clientId: number): void { + const pending = this.pending.get(clientId) + this.pending.delete(clientId) + for (const entry of pending ?? []) { + entry.active = false + entry.publication.close() + const preparation = entry.preparation + if (preparation) { + const cause = new Error('SSH terminal authority client disconnected during admission') + void joinTerminalAuthorityRollbackFailure(cause, () => preparation.rollback()).catch( + (failure) => { + if (failure !== cause) { + this.onRollbackFailure(failure) + } + } + ) + } + } + const client = this.clients.get(clientId) + this.clients.delete(clientId) + client?.disconnect() + if (client) { + this.authority.releaseAuthenticatedPolicyConsumerTransport(client.transport.token) + } + } + + isInstalled(clientId: number): boolean { + return this.connection(clientId) !== null + } + + connection(clientId: number): TerminalAuthorityPolicyConsumerConnection | null { + return this.clients.get(clientId)?.connection() ?? null + } + + dispose(): void { + if (this.disposed) { + return + } + this.disposed = true + for (const clientId of [...this.clients.keys(), ...this.pending.keys()]) { + this.disconnect(clientId) + } + } + + private async issueChallenge(params: Record, context: RequestContext) { + const client = this.requireClient(context) + const start = parseTerminalAuthorityNamespaceAdmissionStart(params.start ?? params) + if (!start) { + throw new Error('SSH terminal authority admission start is invalid') + } + return await this.authority.issuePolicyConsumerChallenge(start, client.transport) + } + + private async resolveNamespace(params: Record, context: RequestContext) { + this.requireClient(context) + if (typeof params.worktreeId !== 'string') { + throw new Error('SSH terminal authority namespace resolution is invalid') + } + return await this.authority.resolvePolicyConsumerNamespace(params.worktreeId) + } + + private async issueRetirementChallenge(params: Record, context: RequestContext) { + const client = this.requireClient(context) + this.requireConsumerRetirement(client) + const start = parseTerminalAuthorityConsumerRetirementStart(params.start ?? params) + if (!start) { + throw new Error('SSH terminal authority consumer retirement start is invalid') + } + return await this.authority.issuePolicyConsumerRetirementChallenge(start, client.transport) + } + + private async retireConsumer(params: Record, context: RequestContext) { + const client = this.requireClient(context) + this.requireConsumerRetirement(client) + const proof = parseTerminalAuthorityConsumerRetirementProof(params.proof ?? params) + if (!proof) { + throw new Error('SSH terminal authority consumer retirement proof is invalid') + } + const result = await this.authority.retireAuthenticatedPolicyConsumer(proof, client.transport) + const installed = client.installed(result.namespace) + if (installed?.session.grant.consumer.consumerId === result.consumerId) { + client.remove(result.namespace) + } + return result + } + + private async prepareGrant(params: Record, context: RequestContext) { + const client = this.requireClient(context) + const proof = parseTerminalAuthorityNamespaceAdmissionProof(params.proof ?? params) + if (!proof) { + throw new Error('SSH terminal authority admission proof is invalid') + } + const publication = new SshTerminalAuthorityPolicyPublication( + this.dispatcher, + context.clientId, + (error) => this.failNamespace(context.clientId, proof.challenge.namespace, error) + ) + const pending: SshTerminalAuthorityPendingNamespace = { + active: true, + preparation: null, + publication, + namespace: Object.freeze({ ...proof.challenge.namespace }), + requestId: proof.challenge.requestId, + connectionGrantId: proof.challenge.connectionGrantId, + consumer: Object.freeze({ + consumerId: terminalAuthorityHostAppConsumerId( + proof.challenge.namespace.authorityHostId, + Uint8Array.from(Buffer.from(proof.challenge.appPublicKeyB64, 'base64')) + ), + consumerIncarnationId: proof.challenge.candidateProcessIncarnationId + }) + } + this.pendingFor(context.clientId).add(pending) + try { + pending.preparation = await this.authority.prepareAuthenticatedPolicyConsumerNamespace( + proof, + client.transport, + publication.transport() + ) + } catch (error) { + pending.active = false + this.pending.get(context.clientId)?.delete(pending) + publication.close() + throw error + } + if (!pending.active || this.clients.get(context.clientId) !== client) { + pending.active = false + this.pending.get(context.clientId)?.delete(pending) + publication.close() + const preparation = pending.preparation + if (!preparation) { + throw new Error('SSH terminal authority authenticated transport is unavailable') + } + return await joinTerminalAuthorityRollbackFailure( + new Error('SSH terminal authority authenticated transport is unavailable'), + () => preparation.rollback() + ) + } + const preparation = pending.preparation + if (!preparation) { + throw new Error('SSH terminal authority authenticated transport is unavailable') + } + try { + const session = await preparation.commit() + if (!pending.active || this.clients.get(context.clientId) !== client) { + session.disconnect() + throw new Error('SSH terminal authority authenticated transport is unavailable') + } + client.install(Object.freeze({ session, publication: pending.publication })) + return preparation.grant + } catch (error) { + pending.publication.close() + try { + return await joinTerminalAuthorityRollbackFailure(error, () => preparation.rollback()) + } catch (failure) { + if (failure !== error) { + this.onRollbackFailure(failure) + } + this.failNamespace(context.clientId, pending.namespace, failure) + throw failure + } + } finally { + this.pending.get(context.clientId)?.delete(pending) + pending.active = false + } + } + + private failNamespace( + clientId: number, + namespace: TerminalAuthorityNamespace, + _error: unknown + ): void { + const client = this.clients.get(clientId) + if (!client?.installed(namespace)) { + return + } + client.remove(namespace) + } + + private requireClient(context: RequestContext): SshTerminalAuthorityClientConsumers { + const client = this.clients.get(context.clientId) + const actual = context.sessionIdentity + if ( + this.disposed || + !client || + !actual?.authenticated || + actual.authenticationKind === 'unproved' || + actual.principal !== client.identity.principal + ) { + throw new Error('SSH terminal authority authenticated transport is unavailable') + } + return client + } + + private requireConsumerRetirement(client: SshTerminalAuthorityClientConsumers): void { + if (!client.consumerRetirementSupported) { + throw new Error('SSH terminal authority consumer retirement is unsupported') + } + } + + private pendingFor(clientId: number): Set { + const pending = this.pending.get(clientId) ?? new Set() + this.pending.set(clientId, pending) + return pending + } +} + +function reportRollbackFailure(error: unknown): void { + console.error('SSH terminal authority consumer rollback failed', error) +} diff --git a/src/relay/ssh-terminal-authority-policy-operations.ts b/src/relay/ssh-terminal-authority-policy-operations.ts new file mode 100644 index 00000000000..9d88d3ae2ae --- /dev/null +++ b/src/relay/ssh-terminal-authority-policy-operations.ts @@ -0,0 +1,148 @@ +import { parseTerminalAuthorityNamespaceAdmissionCancellation } from '../shared/terminal-session-authority-consumer-proof' +import type { TerminalAuthorityNamespace } from '../shared/terminal-session-authority-identity' +import { + TERMINAL_AUTHORITY_NAMESPACE_BOUNDARY_ACCEPT_METHOD, + TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_ACK_METHOD, + parseTerminalAuthorityNamespaceBoundaryAcceptance, + parseTerminalAuthorityNamespaceOutcomeAck, + sameTerminalAuthorityPolicyConsumer, + type TerminalAuthorityPolicyConsumerIdentity +} from '../shared/terminal-session-authority-consumer-transport' +import type { TerminalAuthorityAuthenticatedNamespacePreparation } from '../main/session-authority/terminal-session-authority-authenticated-consumers' +import { SSH_TERMINAL_AUTHORITY_CONSUMER_CANCEL_METHOD } from '../main/ssh/ssh-terminal-authority-consumer-methods' +import type { RelayDispatcher, RequestContext } from './dispatcher' +import type { + SshTerminalAuthorityClientConsumers, + SshTerminalAuthorityInstalledNamespace +} from './ssh-terminal-authority-client-consumers' +import type { SshTerminalAuthorityPolicyPublication } from './ssh-terminal-authority-policy-publication' + +export type SshTerminalAuthorityPendingNamespace = { + active: boolean + preparation: TerminalAuthorityAuthenticatedNamespacePreparation | null + publication: SshTerminalAuthorityPolicyPublication + namespace: TerminalAuthorityNamespace + requestId: string + connectionGrantId: string + consumer: TerminalAuthorityPolicyConsumerIdentity +} + +type OperationsOptions = Readonly<{ + requireClient(context: RequestContext): SshTerminalAuthorityClientConsumers + pending(clientId: number): Set | undefined +}> + +export class SshTerminalAuthorityPolicyOperations { + constructor( + dispatcher: RelayDispatcher, + private readonly options: OperationsOptions + ) { + dispatcher.onRequest(SSH_TERMINAL_AUTHORITY_CONSUMER_CANCEL_METHOD, (params, context) => + this.cancel(params, context) + ) + dispatcher.onRequest(TERMINAL_AUTHORITY_NAMESPACE_BOUNDARY_ACCEPT_METHOD, (params, context) => + this.acceptBoundary(params, context) + ) + dispatcher.onRequest(TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_ACK_METHOD, (params, context) => + this.acknowledge(params, context) + ) + } + + private async cancel( + params: Record, + context: RequestContext + ): Promise<{ canceled: boolean }> { + const client = this.options.requireClient(context) + const cancellation = parseTerminalAuthorityNamespaceAdmissionCancellation( + params.cancellation ?? params + ) + if (!cancellation || cancellation.connectionGrantId !== client.transport.connectionGrantId) { + throw new Error('SSH terminal authority namespace cancellation is unauthorized') + } + let canceled = false + for (const pending of this.options.pending(context.clientId) ?? []) { + if ( + pending.requestId === cancellation.requestId && + pending.connectionGrantId === cancellation.connectionGrantId && + sameNamespace(pending.namespace, cancellation.namespace) && + sameTerminalAuthorityPolicyConsumer(pending.consumer, cancellation.consumer) + ) { + this.options.pending(context.clientId)?.delete(pending) + pending.active = false + pending.publication.close() + await pending.preparation?.rollback() + canceled = true + } + } + const installed = client.installed(cancellation.namespace) + if (installed && grantMatches(installed, cancellation)) { + client.remove(cancellation.namespace) + canceled = true + } + return { canceled } + } + + private acceptBoundary(params: Record, context: RequestContext): Promise { + const client = this.options.requireClient(context) + const acceptance = parseTerminalAuthorityNamespaceBoundaryAcceptance( + params.acceptance ?? params + ) + const installed = acceptance ? client.installed(acceptance.namespace) : null + const pending = acceptance + ? [...(this.options.pending(context.clientId) ?? [])].find( + (entry) => + entry.active && + sameNamespace(entry.namespace, acceptance.namespace) && + sameTerminalAuthorityPolicyConsumer(entry.consumer, acceptance.consumer) + ) + : undefined + if ( + !acceptance || + (!pending && + (!installed || + !sameTerminalAuthorityPolicyConsumer( + installed.session.grant.consumer, + acceptance.consumer + ))) + ) { + throw new Error('SSH terminal authority boundary acceptance is unauthorized') + } + ;(pending?.publication ?? installed!.publication).accept(acceptance) + return Promise.resolve() + } + + private async acknowledge( + params: Record, + context: RequestContext + ): Promise<{ acknowledgedSequence: number }> { + const client = this.options.requireClient(context) + const ack = parseTerminalAuthorityNamespaceOutcomeAck(params.ack ?? params) + const installed = ack ? client.installed(ack.namespace) : null + if ( + !ack || + !installed || + !sameTerminalAuthorityPolicyConsumer(installed.session.grant.consumer, ack.consumer) + ) { + throw new Error('SSH terminal authority outcome ACK is unauthorized') + } + return { acknowledgedSequence: await installed.session.policyConsumer.acknowledge(ack) } + } +} + +function grantMatches( + installed: SshTerminalAuthorityInstalledNamespace, + cancellation: NonNullable> +): boolean { + return ( + installed.session.grant.requestId === cancellation.requestId && + installed.session.grant.connectionGrantId === cancellation.connectionGrantId && + sameTerminalAuthorityPolicyConsumer(installed.session.grant.consumer, cancellation.consumer) + ) +} + +function sameNamespace( + left: TerminalAuthorityNamespace, + right: TerminalAuthorityNamespace +): boolean { + return left.authorityHostId === right.authorityHostId && left.namespaceId === right.namespaceId +} diff --git a/src/relay/ssh-terminal-authority-policy-publication.ts b/src/relay/ssh-terminal-authority-policy-publication.ts new file mode 100644 index 00000000000..ee924eaff41 --- /dev/null +++ b/src/relay/ssh-terminal-authority-policy-publication.ts @@ -0,0 +1,59 @@ +import { + TERMINAL_AUTHORITY_NAMESPACE_BOUNDARY_NOTIFICATION, + TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_NOTIFICATION, + type TerminalAuthorityNamespaceBoundaryAcceptance +} from '../shared/terminal-session-authority-consumer-transport' +import { TerminalSessionAuthorityBoundaryAcceptances } from '../main/session-authority/terminal-session-authority-boundary-acceptance' +import type { TerminalAuthorityPolicyOutcomeTransport } from '../main/session-authority/terminal-session-authority-policy-consumers' +import type { RelayDispatcher } from './dispatcher' + +export class SshTerminalAuthorityPolicyPublication { + private readonly acceptances = new TerminalSessionAuthorityBoundaryAcceptances() + + constructor( + private readonly dispatcher: RelayDispatcher, + private readonly clientId: number, + private readonly onFailure: (error: unknown) => void + ) {} + + transport(): TerminalAuthorityPolicyOutcomeTransport { + return Object.freeze({ + publishBoundary: async (boundary) => { + const accepted = this.acceptances.wait(boundary) + try { + await this.publish(TERMINAL_AUTHORITY_NAMESPACE_BOUNDARY_NOTIFICATION, { boundary }) + await accepted + } catch (error) { + this.close(error instanceof Error ? error : new Error(String(error))) + throw error + } + }, + publishOutcome: (publication) => + this.publish(TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_NOTIFICATION, { publication }), + onFailure: (error) => this.onFailure(error) + }) + } + + accept(acceptance: TerminalAuthorityNamespaceBoundaryAcceptance): void { + this.acceptances.accept(acceptance) + } + + close(error?: Error): void { + this.acceptances.close(error) + } + + private publish(method: string, params: Record): Promise { + return new Promise((resolve, reject) => { + const admitted = this.dispatcher.tryNotifyClient( + this.clientId, + method, + params, + (settlement) => (settlement.ok ? resolve() : reject(settlement.error)), + { controlOverflow: 'close-client' } + ) + if (!admitted) { + reject(new Error('SSH terminal authority outcome transport is unavailable')) + } + }) + } +} diff --git a/src/relay/terminal-authority-control-client.test.ts b/src/relay/terminal-authority-control-client.test.ts new file mode 100644 index 00000000000..a8b22e21e12 --- /dev/null +++ b/src/relay/terminal-authority-control-client.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it, vi } from 'vitest' +import type { SshChannelMultiplexer } from '../main/ssh/ssh-channel-multiplexer' +import { TerminalAuthorityControlClient } from './terminal-authority-control-client' + +function harness() { + const mux = { + request: vi.fn(), + dispose: vi.fn() + } + return { + mux, + client: new TerminalAuthorityControlClient(mux as unknown as SshChannelMultiplexer) + } +} + +describe('terminal authority control client', () => { + it('acknowledges grace only after the authority applies the exact value', async () => { + const { client, mux } = harness() + mux.request.mockResolvedValueOnce({ graceTimeMs: 600_000 }) + + await expect(client.configureGraceTime(600)).resolves.toEqual({ graceTimeMs: 600_000 }) + expect(mux.request).toHaveBeenCalledWith('terminalAuthority.configureGraceTime', { + graceTimeSeconds: 600 + }) + }) + + it('holds and explicitly releases one connection-scoped removal lease', async () => { + const { client, mux } = harness() + mux.request + .mockResolvedValueOnce({ leaseToken: 'control-1' }) + .mockResolvedValueOnce({ leaseToken: 'control-1' }) + + const release = await client.acquireWorktreeRemoval('/repo') + await release() + await release() + + expect(mux.request).toHaveBeenNthCalledWith(1, 'terminalAuthority.acquireWorktreeRemoval', { + leaseToken: 'control-1', + rootPath: '/repo' + }) + expect(mux.request).toHaveBeenNthCalledWith(2, 'terminalAuthority.releaseWorktreeRemoval', { + leaseToken: 'control-1', + rootPath: '/repo' + }) + expect(mux.request).toHaveBeenCalledTimes(2) + }) + + it('fails closed on missing methods and false acknowledgements', async () => { + const missing = harness() + missing.mux.request.mockRejectedValueOnce(new Error('Method not found')) + await expect(missing.client.configureGraceTime(60)).rejects.toThrow('control request failed') + expect(missing.mux.dispose).toHaveBeenCalledWith('connection_lost') + + const falseAck = harness() + falseAck.mux.request.mockResolvedValueOnce({ graceTimeMs: 59_000 }) + await expect(falseAck.client.configureGraceTime(60)).rejects.toThrow('not_applied') + expect(falseAck.mux.dispose).toHaveBeenCalledWith('connection_lost') + }) +}) diff --git a/src/relay/terminal-authority-control-client.ts b/src/relay/terminal-authority-control-client.ts new file mode 100644 index 00000000000..107ca829b99 --- /dev/null +++ b/src/relay/terminal-authority-control-client.ts @@ -0,0 +1,57 @@ +import type { SshChannelMultiplexer } from '../main/ssh/ssh-channel-multiplexer' +import { + TERMINAL_AUTHORITY_ACQUIRE_WORKTREE_REMOVAL_METHOD, + TERMINAL_AUTHORITY_CONFIGURE_GRACE_TIME_METHOD, + TERMINAL_AUTHORITY_RELEASE_WORKTREE_REMOVAL_METHOD, + assertGraceTimeApplied, + assertWorktreeRemovalLeaseResult +} from './terminal-authority-control-protocol' + +export class TerminalAuthorityControlClient { + private nextWorktreeRemovalLease = 1 + + constructor(private readonly authorityMux: SshChannelMultiplexer) {} + + async configureGraceTime(graceTimeSeconds: number): Promise<{ graceTimeMs: number }> { + await this.request( + TERMINAL_AUTHORITY_CONFIGURE_GRACE_TIME_METHOD, + { graceTimeSeconds }, + (value) => assertGraceTimeApplied(value, graceTimeSeconds) + ) + return { graceTimeMs: graceTimeSeconds * 1_000 } + } + + async acquireWorktreeRemoval(rootPath: string): Promise<() => Promise> { + const leaseToken = `control-${this.nextWorktreeRemovalLease++}` + const params = { leaseToken, rootPath } + await this.request(TERMINAL_AUTHORITY_ACQUIRE_WORKTREE_REMOVAL_METHOD, params, (value) => + assertWorktreeRemovalLeaseResult(value, leaseToken) + ) + let release: Promise | null = null + return () => { + release ??= this.request( + TERMINAL_AUTHORITY_RELEASE_WORKTREE_REMOVAL_METHOD, + params, + (value) => assertWorktreeRemovalLeaseResult(value, leaseToken) + ).then(() => {}) + return release + } + } + + private async request( + method: string, + params: Record, + validate: (result: unknown) => void + ): Promise { + try { + const result = await this.authorityMux.request(method, params) + validate(result) + return result + } catch (error) { + this.authorityMux.dispose('connection_lost') + throw new Error( + `Terminal authority control request failed: ${error instanceof Error ? error.message : String(error)}` + ) + } + } +} diff --git a/src/relay/terminal-authority-control-protocol.test.ts b/src/relay/terminal-authority-control-protocol.test.ts new file mode 100644 index 00000000000..bc7b11b6a23 --- /dev/null +++ b/src/relay/terminal-authority-control-protocol.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest' +import type { RequestContext } from './dispatcher' +import { + assertAuthenticatedTerminalAuthorityControl, + parseTerminalAuthorityWorktreeRemovalParams +} from './terminal-authority-control-protocol' + +function context(overrides: Partial> = {}) { + return { + clientId: 1, + isStale: () => false, + sessionIdentity: { + principal: 'terminal-authority:host-1', + authenticated: true, + allowSessionOwner: true, + authenticationKind: 'endpoint-credential' as const, + ...overrides + } + } satisfies RequestContext +} + +describe('terminal authority control protocol', () => { + it('admits only an endpoint-authenticated authority principal', () => { + expect(() => assertAuthenticatedTerminalAuthorityControl(context())).not.toThrow() + expect(() => + assertAuthenticatedTerminalAuthorityControl(context({ authenticated: false })) + ).toThrow('not_authenticated') + expect(() => + assertAuthenticatedTerminalAuthorityControl(context({ principal: 'relay-endpoint:build' })) + ).toThrow('not_authenticated') + }) + + it('accepts bounded removal identities and rejects ambiguous payloads', () => { + expect( + parseTerminalAuthorityWorktreeRemovalParams({ + leaseToken: 'control-17', + rootPath: 'C:\\Repo' + }) + ).toEqual({ leaseToken: 'control-17', rootPath: 'C:\\Repo' }) + + for (const params of [ + { leaseToken: '../escape', rootPath: '/repo' }, + { leaseToken: 'lease-1', rootPath: '' }, + { leaseToken: 'lease-1', rootPath: '/repo\0nested' }, + { leaseToken: 'lease-1', rootPath: 'relative/repo' } + ]) { + expect(() => parseTerminalAuthorityWorktreeRemovalParams(params)).toThrow('invalid') + } + }) +}) diff --git a/src/relay/terminal-authority-control-protocol.ts b/src/relay/terminal-authority-control-protocol.ts new file mode 100644 index 00000000000..cc3d32f9183 --- /dev/null +++ b/src/relay/terminal-authority-control-protocol.ts @@ -0,0 +1,80 @@ +import { MAX_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../shared/ssh-types' +import { isRuntimePathAbsolute } from '../shared/cross-platform-path' +import type { RequestContext } from './dispatcher' + +export const TERMINAL_AUTHORITY_CONFIGURE_GRACE_TIME_METHOD = 'terminalAuthority.configureGraceTime' +export const TERMINAL_AUTHORITY_ACQUIRE_WORKTREE_REMOVAL_METHOD = + 'terminalAuthority.acquireWorktreeRemoval' +export const TERMINAL_AUTHORITY_RELEASE_WORKTREE_REMOVAL_METHOD = + 'terminalAuthority.releaseWorktreeRemoval' + +const REMOVAL_LEASE_TOKEN_PATTERN = /^[A-Za-z0-9_-]{1,128}$/ +const MAX_REMOTE_PATH_LENGTH = 4_096 + +export type TerminalAuthorityWorktreeRemovalParams = Readonly<{ + leaseToken: string + rootPath: string +}> + +export function parseRelayGraceTimeSeconds(params: Record): number { + const value = params.graceTimeSeconds + if ( + !Number.isSafeInteger(value) || + Number(value) < 0 || + Number(value) > MAX_SSH_RELAY_GRACE_PERIOD_SECONDS + ) { + throw new Error('relay_grace_time_invalid') + } + return Number(value) +} + +export function parseTerminalAuthorityWorktreeRemovalParams( + params: Record +): TerminalAuthorityWorktreeRemovalParams { + const leaseToken = params.leaseToken + const rootPath = params.rootPath + if ( + typeof leaseToken !== 'string' || + !REMOVAL_LEASE_TOKEN_PATTERN.test(leaseToken) || + typeof rootPath !== 'string' || + rootPath.length === 0 || + rootPath.length > MAX_REMOTE_PATH_LENGTH || + rootPath.includes('\0') || + !isRuntimePathAbsolute(rootPath) + ) { + throw new Error('terminal_authority_worktree_removal_invalid') + } + return Object.freeze({ leaseToken, rootPath }) +} + +export function assertAuthenticatedTerminalAuthorityControl(context: RequestContext): void { + const identity = context.sessionIdentity + if ( + !identity?.authenticated || + !identity.allowSessionOwner || + identity.authenticationKind !== 'endpoint-credential' || + !identity.principal.startsWith('terminal-authority:') + ) { + throw new Error('terminal_authority_control_not_authenticated') + } +} + +export function assertGraceTimeApplied(result: unknown, graceTimeSeconds: number): void { + if ( + typeof result !== 'object' || + result === null || + (result as { graceTimeMs?: unknown }).graceTimeMs !== graceTimeSeconds * 1_000 + ) { + throw new Error('terminal_authority_grace_time_not_applied') + } +} + +export function assertWorktreeRemovalLeaseResult(result: unknown, leaseToken: string): void { + if ( + typeof result !== 'object' || + result === null || + (result as { leaseToken?: unknown }).leaseToken !== leaseToken + ) { + throw new Error('terminal_authority_worktree_removal_not_acknowledged') + } +} diff --git a/src/relay/terminal-authority-early-output-buffer.ts b/src/relay/terminal-authority-early-output-buffer.ts new file mode 100644 index 00000000000..11810ddb2ed --- /dev/null +++ b/src/relay/terminal-authority-early-output-buffer.ts @@ -0,0 +1,31 @@ +export const TERMINAL_AUTHORITY_EARLY_OUTPUT_MAX_BYTES = 100 * 1024 +export const TERMINAL_AUTHORITY_EARLY_OUTPUT_MAX_FRAMES = 1_024 + +export class TerminalAuthorityEarlyOutputBuffer { + private readonly chunks: string[] = [] + private bytes = 0 + private overflowed = false + + append(data: string): boolean { + const bytes = Buffer.byteLength(data) + if ( + this.overflowed || + this.chunks.length >= TERMINAL_AUTHORITY_EARLY_OUTPUT_MAX_FRAMES || + this.bytes + bytes > TERMINAL_AUTHORITY_EARLY_OUTPUT_MAX_BYTES + ) { + this.overflowed = true + return false + } + this.chunks.push(data) + this.bytes += bytes + return true + } + + values(): readonly string[] { + return this.chunks + } + + get exceededCapacity(): boolean { + return this.overflowed + } +} diff --git a/src/relay/terminal-authority-event-delivery.test.ts b/src/relay/terminal-authority-event-delivery.test.ts new file mode 100644 index 00000000000..c22d4e522f8 --- /dev/null +++ b/src/relay/terminal-authority-event-delivery.test.ts @@ -0,0 +1,94 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + RelayDispatcher, + type RelayClientSinkOptions, + type SinkWriteSettlement +} from './dispatcher' +import { + TerminalAuthorityEventBuffer, + TerminalAuthorityEventDelivery +} from './terminal-authority-event-delivery' + +type PendingWrite = { + settle: (result: SinkWriteSettlement) => void +} + +class SaturatedSink { + readonly frames: Record[] = [] + readonly writes: PendingWrite[] = [] + private readonly drainWaiters: (() => void)[] = [] + private saturated = false + + readonly options: RelayClientSinkOptions = { + supportsWriteCallback: true, + writableHighWaterMark: () => 64 * 1024, + writableLength: () => (this.saturated ? 64 * 1024 : 0), + waitWriteDrain: (callback) => { + this.drainWaiters.push(callback) + return () => { + const index = this.drainWaiters.indexOf(callback) + if (index >= 0) { + this.drainWaiters.splice(index, 1) + } + } + } + } + + readonly write = (data: Buffer, settle: (result: SinkWriteSettlement) => void): boolean => { + const length = data.readUInt32BE(9) + this.frames.push(JSON.parse(data.subarray(13, 13 + length).toString('utf8'))) + this.writes.push({ settle }) + this.saturated = true + return false + } + + settleNext(): void { + this.writes.shift()?.settle({ ok: true }) + } + + drainNext(): void { + this.saturated = false + this.drainWaiters[0]?.() + } +} + +describe('terminal authority event delivery', () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => vi.useRealTimers()) + + it('keeps a terminal outcome behind queued data on a saturated downstream sink', () => { + const sink = new SaturatedSink() + const dispatcher = new RelayDispatcher(sink.write, sink.options) + const clientId = dispatcher.activeClientIds()[0] + const failures = vi.fn() + const delivery = new TerminalAuthorityEventDelivery( + dispatcher, + new TerminalAuthorityEventBuffer(), + () => clientId, + failures + ) + try { + delivery.publish('pty.data', { id: 'pty-1', data: 'first' }) + delivery.publish('pty.data', { id: 'pty-1', data: 'second' }) + delivery.publish('pty.deliveryCanceled', { id: 'pty-1' }) + + expect(sink.frames.map((frame) => frame.method)).toEqual(['pty.data']) + sink.settleNext() + sink.drainNext() + expect(sink.frames.map((frame) => frame.method)).toEqual(['pty.data', 'pty.data']) + sink.settleNext() + expect(sink.frames.map((frame) => frame.method)).toEqual(['pty.data', 'pty.data']) + sink.drainNext() + + expect(sink.frames.map((frame) => frame.method)).toEqual([ + 'pty.data', + 'pty.data', + 'pty.deliveryCanceled' + ]) + expect(failures).not.toHaveBeenCalled() + } finally { + delivery.clear() + dispatcher.dispose() + } + }) +}) diff --git a/src/relay/terminal-authority-event-delivery.ts b/src/relay/terminal-authority-event-delivery.ts new file mode 100644 index 00000000000..0cfe8a3bc7c --- /dev/null +++ b/src/relay/terminal-authority-event-delivery.ts @@ -0,0 +1,184 @@ +import type { TerminalAuthorityEventMethod } from '../shared/terminal-authority-routing' +import type { RelayDispatcher, SinkWriteSettlement } from './dispatcher' +import { MAX_MESSAGE_SIZE } from './protocol' + +export type BufferedAuthorityEvent = Readonly<{ + method: TerminalAuthorityEventMethod + params: Record + bytes: number +}> + +type PtyDeliveryFence = { + ptyId: string + pendingDataSettlements: number + events: BufferedAuthorityEvent[] + draining: boolean +} + +export class TerminalAuthorityEventBuffer { + private bytes = 0 + + push( + target: BufferedAuthorityEvent[], + method: TerminalAuthorityEventMethod, + params: Record + ): boolean { + const bytes = Buffer.byteLength(JSON.stringify({ method, params }), 'utf8') + if (this.bytes + bytes > MAX_MESSAGE_SIZE) { + return false + } + target.push({ method, params, bytes }) + this.bytes += bytes + return true + } + + release(events: readonly BufferedAuthorityEvent[]): void { + for (const event of events) { + this.bytes -= event.bytes + } + } + + clear(): void { + this.bytes = 0 + } +} + +export class TerminalAuthorityEventDelivery { + private readonly fences = new Map() + + constructor( + private readonly dispatcher: RelayDispatcher, + private readonly buffer: TerminalAuthorityEventBuffer, + private readonly activeClientId: () => number | null, + private readonly onFailure: (error: Error) => void + ) {} + + clear(): void { + for (const fence of this.fences.values()) { + this.buffer.release(fence.events) + } + this.fences.clear() + } + + publish(method: TerminalAuthorityEventMethod, params: Record): void { + if (this.activeClientId() === null) { + this.onFailure(new Error(`Terminal authority event has no admitted client: ${method}`)) + return + } + const ptyId = method === 'agent.hook' ? null : this.parsePtyId(params) + if (method !== 'agent.hook' && ptyId === null) { + this.onFailure(new Error(`Terminal authority published ${method} without a PTY identity`)) + return + } + if (ptyId === null) { + this.publishNow(method, params, null) + return + } + const fence = this.fences.get(ptyId) + if ( + fence && + (fence.events.length > 0 || (method !== 'pty.data' && fence.pendingDataSettlements > 0)) + ) { + this.bufferEvent(fence, method, params) + return + } + this.publishNow(method, params, ptyId) + } + + private parsePtyId(params: Record): string | null { + return typeof params.id === 'string' && params.id.length > 0 ? params.id : null + } + + private publishNow( + method: TerminalAuthorityEventMethod, + params: Record, + ptyId: string | null + ): void { + const clientId = this.activeClientId() + if (clientId === null) { + this.onFailure(new Error(`Terminal authority event has no admitted client: ${method}`)) + return + } + if (method !== 'pty.data' || ptyId === null) { + if (!this.dispatcher.tryNotifyClient(clientId, method, params)) { + this.onFailure(new Error(`Terminal authority event could not be forwarded: ${method}`)) + } + return + } + const fence = this.fenceFor(ptyId) + fence.pendingDataSettlements++ + let settled = false + const settle = (result: SinkWriteSettlement): void => { + if (settled) { + return + } + settled = true + this.settleData(fence, result) + } + if (!this.dispatcher.publishTerminalAuthorityData(clientId, params, settle)) { + settle({ ok: false, error: new Error('Terminal authority data was not admitted') }) + } + } + + private fenceFor(ptyId: string): PtyDeliveryFence { + const existing = this.fences.get(ptyId) + if (existing) { + return existing + } + const fence: PtyDeliveryFence = { + ptyId, + pendingDataSettlements: 0, + events: [], + draining: false + } + this.fences.set(ptyId, fence) + return fence + } + + private bufferEvent( + target: PtyDeliveryFence | BufferedAuthorityEvent[], + method: TerminalAuthorityEventMethod, + params: Record + ): void { + const events = Array.isArray(target) ? target : target.events + if (!this.buffer.push(events, method, params)) { + this.onFailure( + new Error('Terminal authority delivery-order buffer exceeded its bounded capacity') + ) + } + } + + private settleData(fence: PtyDeliveryFence, result: SinkWriteSettlement): void { + if (this.fences.get(fence.ptyId) !== fence) { + return + } + fence.pendingDataSettlements-- + if (!result.ok) { + this.onFailure(new Error('Terminal authority data did not reach the gateway client')) + return + } + this.drain(fence) + } + + private drain(fence: PtyDeliveryFence): void { + if (fence.draining || fence.pendingDataSettlements > 0) { + return + } + fence.draining = true + try { + while (fence.pendingDataSettlements === 0 && fence.events.length > 0) { + const event = fence.events.shift()! + this.buffer.release([event]) + this.publishNow(event.method, event.params, fence.ptyId) + if (this.fences.get(fence.ptyId) !== fence) { + return + } + } + } finally { + fence.draining = false + } + if (fence.pendingDataSettlements === 0 && fence.events.length === 0) { + this.fences.delete(fence.ptyId) + } + } +} diff --git a/src/relay/terminal-authority-exact-pty-access.ts b/src/relay/terminal-authority-exact-pty-access.ts new file mode 100644 index 00000000000..e5fabbe2ef3 --- /dev/null +++ b/src/relay/terminal-authority-exact-pty-access.ts @@ -0,0 +1,82 @@ +import type { TerminalSessionAuthorityRegistry } from '../main/session-authority/terminal-session-authority-registry' +import type { TerminalAuthorityManagedPty } from '../main/session-authority/terminal-session-authority-pty-lifecycle' +import { + TerminalSessionAuthorityError, + type TerminalBindingAuthority +} from '../shared/terminal-session-authority-mutation' +import { + sameTerminalSessionAuthorityPtyAccess, + type TerminalSessionAuthorityPtyAccess +} from '../shared/terminal-session-authority-pty-access' + +export type TerminalAuthorityExactPtyOwner = + | 'current-owner' + | 'current-owner-closed' + | 'imported-owner' + | 'imported-owner-closed' + | 'exited' + | 'unreachable' + | 'unknown' + +export type TerminalAuthorityExactPtyAccessResolver = Readonly<{ + classify: (access: TerminalSessionAuthorityPtyAccess) => Promise +}> + +export class RegistryTerminalAuthorityExactPtyAccessResolver implements TerminalAuthorityExactPtyAccessResolver { + constructor( + private readonly registry: TerminalSessionAuthorityRegistry, + private readonly currentOwnerIncarnationId: string + ) {} + + async classify( + access: TerminalSessionAuthorityPtyAccess + ): Promise { + let authority: TerminalBindingAuthority + try { + const service = await this.registry.openNamespace(access.namespace) + authority = service.bindingAuthority(service.writerAccess, access.pane, access.binding) + } catch (error) { + if (error instanceof TerminalSessionAuthorityError && error.code === 'expectation-mismatch') { + return 'unknown' + } + throw error + } + if (authority === 'owner-unreachable') { + return 'unreachable' + } + if (authority === 'exited') { + return 'exited' + } + if (authority === 'closed') { + return access.binding.ownerIncarnationId === this.currentOwnerIncarnationId + ? 'current-owner-closed' + : 'imported-owner-closed' + } + if (authority !== 'reachable') { + return 'unknown' + } + return access.binding.ownerIncarnationId === this.currentOwnerIncarnationId + ? 'current-owner' + : 'imported-owner' + } +} + +export function terminalAuthorityAccessForManagedPty( + managed: TerminalAuthorityManagedPty +): TerminalSessionAuthorityPtyAccess { + return Object.freeze({ + namespace: Object.freeze({ ...managed.runtime.service.namespace }), + pane: Object.freeze({ ...managed.pane }), + binding: Object.freeze({ ...managed.binding }) + }) +} + +export function managedPtyMatchesTerminalAuthorityAccess( + managed: TerminalAuthorityManagedPty, + access: TerminalSessionAuthorityPtyAccess +): boolean { + return sameTerminalSessionAuthorityPtyAccess( + terminalAuthorityAccessForManagedPty(managed), + access + ) +} diff --git a/src/relay/terminal-authority-gateway-capabilities.ts b/src/relay/terminal-authority-gateway-capabilities.ts new file mode 100644 index 00000000000..001b1686817 --- /dev/null +++ b/src/relay/terminal-authority-gateway-capabilities.ts @@ -0,0 +1,43 @@ +import { PTY_EXACT_OPERATION_PROTOCOL_VERSION } from '../shared/pty-exact-operation-protocol' +import { TERMINAL_AUTHORITY_EXACT_OPERATIONS_VERSION } from '../shared/terminal-authority-exact-operation-protocol' + +export function requestsExactOperations(params: Record): boolean { + return offersVersion(params.capabilities, 'exactOperations', PTY_EXACT_OPERATION_PROTOCOL_VERSION) +} + +export function grantsExactOperations(value: unknown): boolean { + return grantsVersion(value, 'exactOperations', PTY_EXACT_OPERATION_PROTOCOL_VERSION) +} + +export function requestsTerminalAuthorityExactOperations(params: Record): boolean { + return offersVersion( + params.capabilities, + 'terminalAuthorityExactOperations', + TERMINAL_AUTHORITY_EXACT_OPERATIONS_VERSION + ) +} + +export function grantsTerminalAuthorityExactOperations(value: unknown): boolean { + return grantsVersion( + value, + 'terminalAuthorityExactOperations', + TERMINAL_AUTHORITY_EXACT_OPERATIONS_VERSION + ) +} + +function offersVersion(value: unknown, key: string, version: number): boolean { + const capability = recordValue(recordValue(value, key), 'versions') + return Array.isArray(capability) && capability.includes(version) +} + +function grantsVersion(value: unknown, key: string, version: number): boolean { + const capabilities = recordValue(value, 'capabilities') + const capability = recordValue(capabilities, key) + return recordValue(capability, 'version') === version +} + +function recordValue(value: unknown, key: string): unknown { + return typeof value === 'object' && value !== null + ? (value as Record)[key] + : undefined +} diff --git a/src/relay/terminal-authority-gateway-connection.test.ts b/src/relay/terminal-authority-gateway-connection.test.ts new file mode 100644 index 00000000000..9e992b899a9 --- /dev/null +++ b/src/relay/terminal-authority-gateway-connection.test.ts @@ -0,0 +1,190 @@ +import { createServer, Socket, type Server } from 'node:net' +import { chmodSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { CURRENT_RELAY_DAEMON_COMPATIBILITY } from '../shared/relay-daemon-compatibility' +import { + terminalAuthorityEndpointIdentity, + type SshTerminalAuthorityMarker +} from '../shared/ssh-terminal-authority-marker' +import { setupDaemonHandshake } from './relay-handshake' +import { relayTestSocketPath } from './relay-test-socket-path' +import { + connectTerminalAuthorityGateway, + createTerminalAuthoritySocketMultiplexer +} from './terminal-authority-gateway-connection' + +const CREDENTIAL = 'abcdefghijklmnopqrstuvwxyzABCDEFGH123456789' + +describe('terminal authority gateway connection', () => { + let directory: string | null = null + let server: Server | null = null + const sockets = new Set() + + afterEach(async () => { + for (const socket of sockets) { + socket.destroy() + } + sockets.clear() + if (server) { + await new Promise((resolve) => server?.close(() => resolve())) + server = null + } + if (directory) { + rmSync(directory, { recursive: true, force: true }) + directory = null + } + }) + + function fixture(): { + marker: SshTerminalAuthorityMarker + markerPath: string + credentialPath: string + } { + directory = mkdtempSync(join(tmpdir(), 'orca-authority-gateway-')) + const socketPath = relayTestSocketPath(directory) + const markerPath = join(directory, 'active-endpoint') + const credentialPath = join(directory, 'endpoint.credential') + const marker: SshTerminalAuthorityMarker = { + markerVersion: 1, + authorityHostId: 'authority-host', + ownerInstanceId: 'owner-instance', + ownerPid: process.pid, + ownerProcessToken: 'owner-process-token-1234', + ownerBuildId: 'owner-build', + ownerRelayDir: directory, + socketPath, + credentialFile: credentialPath, + compatibility: CURRENT_RELAY_DAEMON_COMPATIBILITY, + revision: 4 + } + writeFileSync(markerPath, JSON.stringify(marker), { mode: 0o600 }) + writeFileSync(credentialPath, CREDENTIAL, { mode: 0o600 }) + return { marker, markerPath, credentialPath } + } + + function expectation(marker: SshTerminalAuthorityMarker, markerPath: string) { + return { + markerPath, + authorityHostId: marker.authorityHostId, + ownerInstanceId: marker.ownerInstanceId, + revision: marker.revision + } + } + + async function listen( + marker: SshTerminalAuthorityMarker, + credential = CREDENTIAL, + endpointMarker = marker + ): Promise { + server = createServer((socket) => { + sockets.add(socket) + socket.once('close', () => sockets.delete(socket)) + setupDaemonHandshake(socket, { + launchVersion: marker.ownerBuildId, + endpointCredential: credential, + authorityIdentity: terminalAuthorityEndpointIdentity(endpointMarker), + onAccepted: () => {} + }) + }) + await new Promise((resolve, reject) => { + server?.once('error', reject) + server?.listen(marker.socketPath, resolve) + }) + } + + it('connects different compatible gateway and owner builds over the local endpoint', async () => { + const { marker, markerPath } = fixture() + await listen(marker) + + const connection = await connectTerminalAuthorityGateway( + expectation(marker, markerPath), + 'gateway-build' + ) + + expect(connection.marker).toEqual(marker) + expect(connection.compatibility).toEqual( + expect.objectContaining({ + capabilities: expect.arrayContaining(['terminal-session.authority-topology-stream.v1']) + }) + ) + connection.mux.dispose('shutdown') + }) + + it('fails closed when the admitted owner marker changed before connection', async () => { + const { marker, markerPath } = fixture() + + await expect( + connectTerminalAuthorityGateway( + { ...expectation(marker, markerPath), ownerInstanceId: 'superseded-owner' }, + 'gateway-build' + ) + ).rejects.toThrow('owner changed') + }) + + it('rejects a socket that cannot prove the marker credential', async () => { + const { marker, markerPath } = fixture() + await listen(marker, 'different-credential-abcdefghijklmnopqrstuvwxyz') + + await expect( + connectTerminalAuthorityGateway(expectation(marker, markerPath), 'gateway-build') + ).rejects.toThrow('handshake') + }) + + it('rejects a same-build socket owned by a different authority incarnation', async () => { + const { marker, markerPath } = fixture() + await listen(marker, CREDENTIAL, { + ...marker, + ownerInstanceId: 'replacement-owner', + revision: marker.revision + 1 + }) + + await expect( + connectTerminalAuthorityGateway(expectation(marker, markerPath), 'gateway-build') + ).rejects.toThrow('handshake') + }) + + it('fails closed when the admitted authority host identity changes', async () => { + const { marker, markerPath } = fixture() + + await expect( + connectTerminalAuthorityGateway( + { ...expectation(marker, markerPath), authorityHostId: 'different-host' }, + 'gateway-build' + ) + ).rejects.toThrow('owner changed') + }) + + it.runIf(process.platform !== 'win32')( + 'rejects a credential readable by another local principal', + async () => { + const { marker, markerPath, credentialPath } = fixture() + chmodSync(credentialPath, 0o644) + + await expect( + connectTerminalAuthorityGateway(expectation(marker, markerPath), 'gateway-build') + ).rejects.toThrow('permissions') + } + ) + + it.runIf(process.platform !== 'win32')('does not follow a marker symlink', async () => { + const { marker, markerPath } = fixture() + const symlinkPath = join(directory!, 'active-endpoint-link') + symlinkSync(markerPath, symlinkPath) + + await expect( + connectTerminalAuthorityGateway(expectation(marker, symlinkPath), 'gateway-build') + ).rejects.toThrow('marker is unavailable') + }) + + it('turns a post-handshake socket error into connection loss', () => { + const socket = new Socket() + const mux = createTerminalAuthoritySocketMultiplexer(socket, Buffer.alloc(0)) + const disposed = vi.fn() + mux.onDispose(disposed) + + expect(() => socket.emit('error', new Error('connection reset'))).not.toThrow() + expect(disposed).toHaveBeenCalledWith('connection_lost') + }) +}) diff --git a/src/relay/terminal-authority-gateway-connection.ts b/src/relay/terminal-authority-gateway-connection.ts new file mode 100644 index 00000000000..73aeb82161a --- /dev/null +++ b/src/relay/terminal-authority-gateway-connection.ts @@ -0,0 +1,302 @@ +import { closeSync, constants, fstatSync, openSync, readSync } from 'node:fs' +import { createConnection, type Socket } from 'node:net' +import { SshChannelMultiplexer } from '../main/ssh/ssh-channel-multiplexer' +import type { MultiplexerTransport } from '../main/ssh/ssh-multiplexer-transport-writer' +import { + CURRENT_RELAY_DAEMON_COMPATIBILITY, + relayDaemonGrantSatisfiesOffer, + type RelayDaemonCompatibilityGrant +} from '../shared/relay-daemon-compatibility' +import { + parseSshTerminalAuthorityMarker, + parseTerminalAuthorityEndpointIdentity, + sameTerminalAuthorityEndpointIdentity, + terminalAuthorityEndpointIdentity, + type SshTerminalAuthorityMarker +} from '../shared/ssh-terminal-authority-marker' +import { encodeHandshakeFrame, FrameDecoder, MessageType, parseHandshakeMessage } from './protocol' + +const MAX_MARKER_BYTES = 16 * 1024 +const MAX_CREDENTIAL_BYTES = 512 +const CONNECT_TIMEOUT_MS = 5_000 + +export type TerminalAuthorityGatewayExpectation = Readonly<{ + markerPath: string + authorityHostId: string + ownerInstanceId: string + revision: number +}> + +export type TerminalAuthorityGatewayConnection = Readonly<{ + marker: SshTerminalAuthorityMarker + mux: SshChannelMultiplexer + compatibility: RelayDaemonCompatibilityGrant +}> + +export async function connectTerminalAuthorityGateway( + expectation: TerminalAuthorityGatewayExpectation, + gatewayBuildId: string +): Promise { + const marker = readExpectedMarker(expectation) + const credential = readCredential(marker.credentialFile) + const socket = await connectSocket(marker.socketPath) + try { + const negotiated = await negotiateAuthoritySocket(socket, marker, gatewayBuildId, credential) + const connected = createTerminalAuthoritySocketMultiplexer(socket, negotiated.leftover) + return Object.freeze({ marker, mux: connected, compatibility: negotiated.compatibility }) + } catch (error) { + socket.destroy() + throw error + } +} + +function endpointIdentityMatchesMarker( + value: unknown, + marker: SshTerminalAuthorityMarker +): boolean { + const identity = parseTerminalAuthorityEndpointIdentity(value) + return Boolean( + identity && + sameTerminalAuthorityEndpointIdentity(terminalAuthorityEndpointIdentity(marker), identity) + ) +} + +function readExpectedMarker( + expectation: TerminalAuthorityGatewayExpectation +): SshTerminalAuthorityMarker { + let raw: string + try { + raw = readPrivateBoundedFile(expectation.markerPath, MAX_MARKER_BYTES) + } catch (error) { + throw new Error( + `Terminal authority gateway marker is unavailable: ${error instanceof Error ? error.message : String(error)}` + ) + } + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + throw new Error('Terminal authority gateway marker is invalid') + } + const marker = parseSshTerminalAuthorityMarker(parsed) + if ( + !marker || + marker.authorityHostId !== expectation.authorityHostId || + marker.ownerInstanceId !== expectation.ownerInstanceId || + marker.revision !== expectation.revision + ) { + throw new Error('Terminal authority owner changed before gateway connection') + } + return marker +} + +function readCredential(path: string): string { + const credential = readPrivateBoundedFile(path, MAX_CREDENTIAL_BYTES).trim() + if (!/^[A-Za-z0-9_-]{32,256}$/.test(credential)) { + throw new Error('Terminal authority gateway credential is invalid') + } + return credential +} + +function readPrivateBoundedFile(path: string, maxBytes: number): string { + const noFollow = process.platform === 'win32' ? 0 : (constants.O_NOFOLLOW ?? 0) + const fd = openSync(path, constants.O_RDONLY | noFollow) + try { + const stat = fstatSync(fd) + if (!stat.isFile() || stat.size < 1 || stat.size > maxBytes) { + throw new Error('file is not a bounded regular file') + } + if (process.platform !== 'win32') { + const effectiveUid = process.geteuid?.() ?? process.getuid?.() + if ((effectiveUid !== undefined && stat.uid !== effectiveUid) || (stat.mode & 0o077) !== 0) { + throw new Error('file permissions are not private') + } + } + const buffer = Buffer.allocUnsafe(maxBytes + 1) + let offset = 0 + while (offset <= maxBytes) { + const bytesRead = readSync(fd, buffer, offset, buffer.length - offset, null) + if (bytesRead === 0) { + return buffer.subarray(0, offset).toString('utf8') + } + offset += bytesRead + } + throw new Error('file exceeded its bounded capacity while reading') + } finally { + closeSync(fd) + } +} + +function connectSocket(path: string): Promise { + return new Promise((resolve, reject) => { + const socket = createConnection({ path }) + const timer = setTimeout(() => { + socket.destroy() + reject(new Error('Terminal authority gateway connection timed out')) + }, CONNECT_TIMEOUT_MS) + timer.unref?.() + socket.once('connect', () => { + clearTimeout(timer) + socket.removeListener('error', onError) + resolve(socket) + }) + const onError = (error: Error): void => { + clearTimeout(timer) + reject(error) + } + socket.once('error', onError) + }) +} + +function negotiateAuthoritySocket( + socket: Socket, + marker: SshTerminalAuthorityMarker, + gatewayBuildId: string, + credential: string +): Promise> { + return new Promise((resolve, reject) => { + let settled = false + const timer = setTimeout( + () => finish(new Error('Terminal authority gateway handshake timed out')), + CONNECT_TIMEOUT_MS + ) + timer.unref?.() + const decoder = new FrameDecoder( + (frame) => { + if (frame.type !== MessageType.Handshake) { + finish(new Error('Terminal authority gateway received a non-handshake frame')) + return + } + let message: ReturnType + try { + message = parseHandshakeMessage(frame.payload) + } catch { + finish(new Error('Terminal authority gateway handshake response is invalid')) + return + } + if ( + message.type !== 'orca-relay-handshake-ok' || + message.version !== marker.ownerBuildId || + !relayDaemonGrantSatisfiesOffer( + message.compatibility, + CURRENT_RELAY_DAEMON_COMPATIBILITY + ) || + !endpointIdentityMatchesMarker(message.authorityIdentity, marker) + ) { + finish(new Error('Terminal authority gateway handshake was not admitted')) + return + } + const leftover = decoder.drain() + const compatibility = { + major: message.compatibility.major, + minor: message.compatibility.minor, + capabilities: [...message.compatibility.capabilities] + } + cleanup() + settled = true + resolve(Object.freeze({ leftover, compatibility })) + }, + (error) => finish(error) + ) + const onData = (data: Buffer): void => decoder.feed(data) + const onClose = (): void => + finish(new Error('Terminal authority gateway closed during handshake')) + const onError = (error: Error): void => finish(error) + const cleanup = (): void => { + clearTimeout(timer) + socket.removeListener('data', onData) + socket.removeListener('close', onClose) + socket.removeListener('error', onError) + } + const finish = (error: Error): void => { + if (settled) { + return + } + settled = true + cleanup() + reject(error) + } + socket.on('data', onData) + socket.once('close', onClose) + socket.once('error', onError) + socket.write( + encodeHandshakeFrame({ + type: 'orca-relay-handshake', + version: gatewayBuildId, + endpointCredential: credential, + compatibility: CURRENT_RELAY_DAEMON_COMPATIBILITY, + authorityExpectation: terminalAuthorityEndpointIdentity(marker) + }) + ) + }) +} + +export function createTerminalAuthoritySocketMultiplexer( + socket: Socket, + leftover: Buffer +): SshChannelMultiplexer { + const dataListeners = new Set<(data: Buffer) => void>() + const closeListeners = new Set<() => void>() + let closed = false + const notifyClosed = (): void => { + if (closed) { + return + } + closed = true + for (const listener of closeListeners) { + listener() + } + closeListeners.clear() + dataListeners.clear() + } + const transport: MultiplexerTransport = { + supportsWriteSettlement: true, + write: (data, onSettled) => { + try { + return socket.write(data, (error) => { + onSettled?.(error ? { ok: false, error } : { ok: true }) + }) + } catch (error) { + onSettled?.({ + ok: false, + error: error instanceof Error ? error : new Error(String(error)) + }) + return false + } + }, + onData: (listener) => { + dataListeners.add(listener) + }, + onClose: (listener) => { + closeListeners.add(listener) + }, + onDrain: (listener) => { + socket.on('drain', listener) + return () => socket.off('drain', listener) + }, + pauseReads: () => socket.pause(), + resumeReads: () => socket.resume(), + close: () => socket.destroy() + } + socket.on('data', (data: Buffer) => { + for (const listener of dataListeners) { + listener(data) + } + }) + const onSocketError = (): void => { + notifyClosed() + socket.destroy() + } + socket.on('error', onSocketError) + socket.once('close', () => { + socket.off('error', onSocketError) + notifyClosed() + }) + const mux = new SshChannelMultiplexer(transport) + if (leftover.length > 0) { + for (const listener of dataListeners) { + listener(leftover) + } + } + return mux +} diff --git a/src/relay/terminal-authority-gateway-request-admission.ts b/src/relay/terminal-authority-gateway-request-admission.ts new file mode 100644 index 00000000000..5d8961ab88f --- /dev/null +++ b/src/relay/terminal-authority-gateway-request-admission.ts @@ -0,0 +1,60 @@ +import type { TerminalAuthorityRequestMethod } from '../shared/terminal-authority-routing' +import type { RequestContext } from './dispatcher' +import { + AUTHORITY_EXACT_MUTATION_REQUESTS, + EXACT_MUTATION_REQUESTS, + LEGACY_MUTATION_REQUESTS, + PREOPEN_MIGRATION_REQUESTS +} from './terminal-authority-gateway-rules' + +export type TerminalAuthorityGatewayRequestAdmission = Readonly<{ + activeClientId: number | null + pendingClientId: number | null + activeClientHasAuthorityExactOperations: boolean + unavailable: boolean +}> + +export function assertTerminalAuthorityGatewayRequestAdmission( + method: TerminalAuthorityRequestMethod, + context: RequestContext, + admission: TerminalAuthorityGatewayRequestAdmission +): void { + if (admission.unavailable) { + throw new Error('terminal_authority_gateway_unavailable') + } + if (method === 'pty.openClient') { + assertAuthenticatedOwner(context) + if (admission.pendingClientId !== null || admission.activeClientId !== null) { + throw new Error('terminal_authority_client_already_admitted') + } + return + } + if (PREOPEN_MIGRATION_REQUESTS.has(method)) { + assertAuthenticatedOwner(context) + if (admission.pendingClientId !== null || admission.activeClientId !== null) { + throw new Error('terminal_authority_client_already_admitted') + } + return + } + if (admission.activeClientId !== context.clientId) { + throw new Error('terminal_authority_client_not_admitted') + } + if (LEGACY_MUTATION_REQUESTS.has(method)) { + throw new Error('terminal_authority_legacy_mutation_rejected') + } + if (EXACT_MUTATION_REQUESTS.has(method)) { + throw new Error('terminal_authority_incarnation_mutation_rejected') + } + if ( + AUTHORITY_EXACT_MUTATION_REQUESTS.has(method) && + !admission.activeClientHasAuthorityExactOperations + ) { + throw new Error('terminal_authority_exact_operations_not_granted') + } +} + +function assertAuthenticatedOwner(context: RequestContext): void { + if (!context.sessionIdentity?.authenticated || !context.sessionIdentity.allowSessionOwner) { + throw new Error('terminal_authority_client_not_authenticated') + } +} diff --git a/src/relay/terminal-authority-gateway-rules.ts b/src/relay/terminal-authority-gateway-rules.ts new file mode 100644 index 00000000000..7623450166d --- /dev/null +++ b/src/relay/terminal-authority-gateway-rules.ts @@ -0,0 +1,35 @@ +import type { TerminalAuthorityRequestMethod } from '../shared/terminal-authority-routing' + +export const RESPONSE_ORDERED_REQUESTS = new Set([ + 'pty.openClient', + 'pty.spawn', + 'pty.attach' +]) +export const LEGACY_MUTATION_REQUESTS = new Set([ + 'pty.shutdown', + 'pty.sendSignal', + 'pty.clearBuffer' +]) +export const EXACT_MUTATION_REQUESTS = new Set([ + 'pty.shutdownExact', + 'pty.sendSignalExact', + 'pty.clearBufferExact' +]) +export const AUTHORITY_EXACT_MUTATION_REQUESTS = new Set([ + 'pty.shutdownAuthorityExact', + 'pty.sendSignalAuthorityExact', + 'pty.clearBufferAuthorityExact' +]) +export const PREOPEN_MIGRATION_REQUESTS = new Set([ + 'terminalAuthority.legacyPhysicalWorker.inspect', + 'terminalAuthority.legacyPhysicalWorker.migrate', + 'terminalAuthority.legacyPhysicalWorker.gcProtection', + 'terminalAuthority.legacyPhysicalWorker.migrationBarrier', + 'terminalAuthority.legacyPhysicalWorker.gc' +]) +export const LEGACY_MUTATION_NOTIFICATIONS = new Set(['pty.data', 'pty.resize']) +export const EXACT_MUTATION_NOTIFICATIONS = new Set(['pty.dataExact', 'pty.resizeExact']) +export const AUTHORITY_EXACT_MUTATION_NOTIFICATIONS = new Set([ + 'pty.dataAuthorityExact', + 'pty.resizeAuthorityExact' +]) diff --git a/src/relay/terminal-authority-gateway.test.ts b/src/relay/terminal-authority-gateway.test.ts new file mode 100644 index 00000000000..c990a8fed40 --- /dev/null +++ b/src/relay/terminal-authority-gateway.test.ts @@ -0,0 +1,750 @@ +import { describe, expect, it, vi } from 'vitest' +import type { + SshChannelMultiplexer, + SshMultiplexerRequestOptions +} from '../main/ssh/ssh-channel-multiplexer' +import type { RelayDispatcher, RequestContext } from './dispatcher' +import { TerminalAuthorityGateway } from './terminal-authority-gateway' +import { + TERMINAL_AUTHORITY_NAMESPACE_BOUNDARY_NOTIFICATION, + TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_NOTIFICATION +} from '../shared/terminal-session-authority-consumer-transport' + +type RequestHandler = (params: Record, context: RequestContext) => Promise +type NotificationHandler = (params: Record, context: RequestContext) => void +type ResponseSettlement = + | { ok: true } + | { ok: false; error: Error; responseDelivered?: false } + | { ok: false; error: Error; responseDelivered: true } + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((settle) => { + resolve = settle + }) + return { promise, resolve } +} + +function harness() { + const requests = new Map() + const notifications = new Map() + let clientDetached: ((clientId: number) => void) | undefined + let authorityNotification: ((method: string, params: Record) => void) | undefined + let authorityDisposed: ((reason: 'shutdown' | 'connection_lost') => void) | undefined + const dataSettlements: ((result: ResponseSettlement) => void)[] = [] + const dispatcher = { + onRequest: vi.fn((method: string, handler: RequestHandler) => requests.set(method, handler)), + onNotification: vi.fn((method: string, handler: NotificationHandler) => + notifications.set(method, handler) + ), + onClientDetached: vi.fn((handler: (clientId: number) => void) => { + clientDetached = handler + return () => {} + }), + publishProducerNotification: vi.fn(() => true), + publishTerminalAuthorityData: vi.fn( + ( + _clientId: number, + _params: Record, + onSettled: (result: ResponseSettlement) => void + ) => { + dataSettlements.push(onSettled) + return true + } + ), + tryNotifyClient: vi.fn(() => true) + } + const mux = { + request: vi.fn( + async ( + method: string, + params?: Record, + options?: SshMultiplexerRequestOptions + ): Promise> => { + const result: Record = + method === 'pty.spawn' + ? { id: 'pty-1' } + : method === 'pty.openClient' + ? { + capabilities: { + exactOperations: { version: 1 }, + ...(typeof params?.capabilities === 'object' && + params.capabilities !== null && + 'terminalAuthorityExactOperations' in params.capabilities + ? { terminalAuthorityExactOperations: { version: 1 } } + : {}) + } + } + : { granted: true } + options?.beforeResolve?.(result) + return result + } + ), + notify: vi.fn(() => true), + onNotification: vi.fn((handler: (method: string, params: Record) => void) => { + authorityNotification = handler + return () => {} + }), + onDispose: vi.fn((handler: (reason: 'shutdown' | 'connection_lost') => void) => { + authorityDisposed = handler + return () => {} + }), + dispose: vi.fn() + } + const onFailure = vi.fn() + const gateway = new TerminalAuthorityGateway( + dispatcher as unknown as RelayDispatcher, + mux as unknown as SshChannelMultiplexer, + onFailure + ) + const context = ( + clientId: number, + settle?: (handler: (result: ResponseSettlement) => void) => void, + prepare?: (handler: () => void) => void + ) => + ({ + clientId, + isStale: () => false, + signal: new AbortController().signal, + sessionIdentity: { + principal: 'test', + authenticated: true, + allowSessionOwner: true, + authenticationKind: 'endpoint-credential' + }, + onResponsePrepared: prepare ?? (() => {}), + onResponseSettled: settle ?? (() => {}) + }) satisfies RequestContext + const openClient = async ( + clientId: number, + params: Record = {} + ): Promise => { + let prepare: (() => void) | undefined + let settle: ((result: ResponseSettlement) => void) | undefined + const result = await requests.get('pty.openClient')?.( + { + capabilities: { + exactOperations: { versions: [1] }, + terminalAuthorityExactOperations: { versions: [1] } + }, + ...params + }, + context( + clientId, + (handler) => { + settle = handler + }, + (handler) => { + prepare = handler + } + ) + ) + prepare?.() + settle?.({ ok: true }) + return result + } + return { + requests, + notifications, + dispatcher, + mux, + gateway, + onFailure, + context, + openClient, + dataSettlements, + authorityEvent: (method: string, params: Record) => + authorityNotification?.(method, params), + detachClient: (clientId: number) => clientDetached?.(clientId), + loseAuthority: () => authorityDisposed?.('connection_lost') + } +} + +describe('terminal authority gateway', () => { + it('permits authenticated pre-open legacy migration controls only to session owners', async () => { + const methods = [ + 'terminalAuthority.legacyPhysicalWorker.inspect', + 'terminalAuthority.legacyPhysicalWorker.migrationBarrier', + 'terminalAuthority.legacyPhysicalWorker.migrate', + 'terminalAuthority.legacyPhysicalWorker.gcProtection', + 'terminalAuthority.legacyPhysicalWorker.gc' + ] + const h = harness() + for (const method of methods) { + await expect(h.requests.get(method)?.({}, h.context(1))).resolves.toEqual({ granted: true }) + await expect( + h.requests.get(method)?.( + {}, + { + ...h.context(1), + sessionIdentity: { ...h.context(1).sessionIdentity, authenticated: false } + } + ) + ).rejects.toThrow('not_authenticated') + await expect( + h.requests.get(method)?.( + {}, + { + ...h.context(1), + sessionIdentity: { ...h.context(1).sessionIdentity, allowSessionOwner: false } + } + ) + ).rejects.toThrow('not_authenticated') + } + await h.openClient(1) + await expect(h.requests.get(methods[0])?.({}, h.context(1))).rejects.toThrow('already_admitted') + }) + + it('admits one downstream owner before forwarding PTY mutations', async () => { + const h = harness() + await expect(h.requests.get('pty.spawn')?.({}, h.context(7))).rejects.toThrow( + 'client_not_admitted' + ) + + await h.openClient(7, { clientInstanceId: 'client' }) + h.notifications.get('pty.dataAuthorityExact')?.( + { id: 'pty-1', terminalSessionAuthorityAccess: { marker: 'access' }, data: 'x' }, + h.context(7) + ) + + expect(h.mux.request).toHaveBeenCalledWith( + 'pty.openClient', + { + clientInstanceId: 'client', + capabilities: { + exactOperations: { versions: [1] }, + terminalAuthorityExactOperations: { versions: [1] } + } + }, + expect.objectContaining({ signal: expect.any(AbortSignal) }) + ) + expect(h.mux.notify).toHaveBeenCalledWith('pty.dataAuthorityExact', { + id: 'pty-1', + terminalSessionAuthorityAccess: { marker: 'access' }, + data: 'x' + }) + }) + + it('forwards namespace outcome notifications without requiring a PTY identity', async () => { + const h = harness() + await h.openClient(7) + + h.authorityEvent(TERMINAL_AUTHORITY_NAMESPACE_BOUNDARY_NOTIFICATION, { + boundary: { version: 1 } + }) + h.authorityEvent(TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_NOTIFICATION, { + publication: { version: 1 } + }) + + expect(h.dispatcher.tryNotifyClient).toHaveBeenNthCalledWith( + 1, + 7, + TERMINAL_AUTHORITY_NAMESPACE_BOUNDARY_NOTIFICATION, + { boundary: { version: 1 } } + ) + expect(h.dispatcher.tryNotifyClient).toHaveBeenNthCalledWith( + 2, + 7, + TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_NOTIFICATION, + { publication: { version: 1 } } + ) + expect(h.onFailure).not.toHaveBeenCalled() + }) + + it('rejects omitted exact negotiation before admitting the stable authority client', async () => { + const h = harness() + + await expect(h.requests.get('pty.openClient')?.({}, h.context(7))).rejects.toThrow( + 'exact_operations_required' + ) + + expect(h.mux.request).not.toHaveBeenCalled() + expect(h.mux.notify).not.toHaveBeenCalled() + }) + + it('fails closed when the authority omits the requested exact grant', async () => { + const h = harness() + h.mux.request.mockResolvedValueOnce({ capabilities: {} }) + + await expect(h.openClient(7)).rejects.toThrow('exact_operations_not_granted') + h.notifications.get('pty.data')?.({ id: 'pty-1', data: 'legacy' }, h.context(7)) + + expect(h.mux.notify).not.toHaveBeenCalled() + expect(h.onFailure).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining('not_granted') }) + ) + }) + + it('rejects legacy mutations after exact authority admission', async () => { + const h = harness() + await h.openClient(7) + + await expect(h.requests.get('pty.shutdown')?.({ id: 'pty-1' }, h.context(7))).rejects.toThrow( + 'legacy_mutation_rejected' + ) + h.notifications.get('pty.data')?.({ id: 'pty-1', data: 'legacy' }, h.context(7)) + + expect(h.mux.request).not.toHaveBeenCalledWith( + 'pty.shutdown', + expect.anything(), + expect.anything() + ) + expect(h.mux.notify).not.toHaveBeenCalledWith('pty.data', expect.anything()) + expect(h.onFailure).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining('legacy mutation') }) + ) + }) + + it('forwards all full-binding mutations only after the authority grant', async () => { + const h = harness() + await h.openClient(7, { + capabilities: { + exactOperations: { versions: [1] }, + terminalAuthorityExactOperations: { versions: [1] } + } + }) + const request = { + id: 'pty-1', + terminalSessionAuthorityAccess: { marker: 'exact' } + } + + h.notifications.get('pty.dataAuthorityExact')?.({ ...request, data: 'input' }, h.context(7)) + h.notifications.get('pty.resizeAuthorityExact')?.( + { ...request, cols: 120, rows: 40 }, + h.context(7) + ) + await expect( + h.requests.get('pty.sendSignalAuthorityExact')?.( + { ...request, signal: 'SIGTERM' }, + h.context(7) + ) + ).resolves.toEqual({ granted: true }) + await expect( + h.requests.get('pty.clearBufferAuthorityExact')?.(request, h.context(7)) + ).resolves.toEqual({ granted: true }) + + await expect( + h.requests.get('pty.shutdownAuthorityExact')?.( + { ...request, immediate: true, keepHistory: false }, + h.context(7) + ) + ).resolves.toEqual({ granted: true }) + expect(h.mux.notify).toHaveBeenCalledWith('pty.dataAuthorityExact', { + ...request, + data: 'input' + }) + expect(h.mux.notify).toHaveBeenCalledWith('pty.resizeAuthorityExact', { + ...request, + cols: 120, + rows: 40 + }) + expect(h.mux.request).toHaveBeenLastCalledWith( + 'pty.shutdownAuthorityExact', + { ...request, immediate: true, keepHistory: false }, + expect.objectContaining({ signal: expect.any(AbortSignal) }) + ) + }) + + it('rejects incarnation-only exact mutations after authority admission', async () => { + const h = harness() + await h.openClient(7) + + await expect( + h.requests.get('pty.shutdownExact')?.( + { id: 'pty-1', incarnationId: 'incarnation-1' }, + h.context(7) + ) + ).rejects.toThrow('incarnation_mutation_rejected') + h.notifications.get('pty.dataExact')?.( + { id: 'pty-1', incarnationId: 'incarnation-1', data: 'blocked' }, + h.context(7) + ) + + expect(h.mux.request).not.toHaveBeenCalledWith( + 'pty.shutdownExact', + expect.anything(), + expect.anything() + ) + expect(h.mux.notify).not.toHaveBeenCalledWith('pty.dataExact', expect.anything()) + expect(h.onFailure).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining('incarnation mutation') }) + ) + }) + + it('rejects authority exact shutdown when the client omitted its offer', async () => { + const h = harness() + await expect( + h.requests.get('pty.openClient')?.( + { capabilities: { exactOperations: { versions: [1] } } }, + h.context(7) + ) + ).rejects.toThrow('exact_operations_required') + expect(h.mux.request).not.toHaveBeenCalled() + }) + + it('holds a saturated PTY outcome until preceding downstream data settles', async () => { + const h = harness() + await h.openClient(3) + + h.authorityEvent('pty.data', { id: 'pty-1', data: 'output' }) + h.authorityEvent('pty.exit', { id: 'pty-1', code: 0 }) + + expect(h.dispatcher.publishTerminalAuthorityData).toHaveBeenCalledWith( + 3, + { id: 'pty-1', data: 'output' }, + expect.any(Function) + ) + expect(h.dispatcher.tryNotifyClient).not.toHaveBeenCalled() + + h.dataSettlements[0]({ ok: true }) + expect(h.dispatcher.tryNotifyClient).toHaveBeenCalledWith(3, 'pty.exit', { + id: 'pty-1', + code: 0 + }) + }) + + it('holds open-client recovery until the downstream grant is settled', async () => { + const h = harness() + h.mux.request.mockImplementationOnce(async () => { + h.authorityEvent('pty.recoveryComplete', { id: 'pty-1', clientGeneration: 1 }) + return { + capabilities: { + exactOperations: { version: 1 }, + terminalAuthorityExactOperations: { version: 1 } + } + } + }) + let prepareResponse: (() => void) | undefined + let settleResponse: ((result: ResponseSettlement) => void) | undefined + + await h.requests.get('pty.openClient')?.( + { + capabilities: { + exactOperations: { versions: [1] }, + terminalAuthorityExactOperations: { versions: [1] } + } + }, + h.context( + 8, + (handler) => { + settleResponse = handler + }, + (handler) => { + prepareResponse = handler + } + ) + ) + + prepareResponse?.() + expect(h.dispatcher.tryNotifyClient).not.toHaveBeenCalled() + settleResponse?.({ ok: true }) + expect(h.dispatcher.tryNotifyClient).toHaveBeenCalledWith(8, 'pty.recoveryComplete', { + id: 'pty-1', + clientGeneration: 1 + }) + }) + + it('admits an exact follow-up before the open-client write settles', async () => { + const h = harness() + let prepareResponse: (() => void) | undefined + let settleResponse: ((result: ResponseSettlement) => void) | undefined + + await h.requests.get('pty.openClient')?.( + { + capabilities: { + exactOperations: { versions: [1] }, + terminalAuthorityExactOperations: { versions: [1] } + } + }, + h.context( + 17, + (handler) => { + settleResponse = handler + }, + (handler) => { + prepareResponse = handler + } + ) + ) + prepareResponse?.() + + await expect(h.requests.get('pty.attach')?.({ id: 'pty-1' }, h.context(17))).resolves.toEqual({ + granted: true + }) + expect(h.onFailure).not.toHaveBeenCalled() + settleResponse?.({ ok: true }) + }) + + it('forwards held-producer pause requests to the authority relay', async () => { + const h = harness() + await h.openClient(17) + const params = { + id: 'pty-1', + paused: true, + clientGeneration: 1, + ownerGeneration: 1, + ptyIncarnationId: 'incarnation-1', + heldPauseToken: 'pause-1' + } + + await expect(h.requests.get('pty.setDeliveryPaused')?.(params, h.context(17))).resolves.toEqual( + { granted: true } + ) + expect(h.mux.request).toHaveBeenCalledWith( + 'pty.setDeliveryPaused', + params, + expect.objectContaining({ signal: expect.any(AbortSignal) }) + ) + }) + + it('rejects a second downstream owner', async () => { + const h = harness() + await h.openClient(1) + + await expect(h.openClient(2)).rejects.toThrow('client_already_admitted') + expect(h.mux.request).toHaveBeenCalledTimes(1) + }) + + it('does not elevate an unproved control-adapter client through the authority socket', async () => { + const h = harness() + const unproved: RequestContext = { + ...h.context(2), + sessionIdentity: { + principal: 'unproved:2', + authenticated: false, + allowSessionOwner: false, + authenticationKind: 'unproved' + } + } + + await expect(h.requests.get('pty.openClient')?.({}, unproved)).rejects.toThrow( + 'not_authenticated' + ) + expect(h.mux.request).not.toHaveBeenCalled() + }) + + it('holds spawn output until the downstream response is durably settled', async () => { + const h = harness() + await h.openClient(4) + const upstream = deferred<{ id: string; granted: boolean }>() + h.mux.request.mockImplementationOnce(async (_method, _params, options) => { + const result = await upstream.promise + options?.beforeResolve?.(result) + return result + }) + let settleResponse: ((result: ResponseSettlement) => void) | undefined + const spawn = h.requests.get('pty.spawn')?.( + {}, + h.context(4, (handler) => { + settleResponse = handler + }) + ) + + upstream.resolve({ id: 'pty-1', granted: true }) + await spawn + h.authorityEvent('pty.data', { id: 'pty-1', data: 'early' }) + expect(h.dispatcher.publishTerminalAuthorityData).not.toHaveBeenCalled() + + settleResponse?.({ ok: true }) + expect(h.dispatcher.publishTerminalAuthorityData).toHaveBeenCalledWith( + 4, + { id: 'pty-1', data: 'early' }, + expect.any(Function) + ) + }) + + it('remembers a failed response while another ordered response remains in flight', async () => { + const h = harness() + await h.openClient(6) + let settleSpawn: ((result: ResponseSettlement) => void) | undefined + let settleAttach: ((result: ResponseSettlement) => void) | undefined + + await Promise.all([ + h.requests.get('pty.spawn')?.( + {}, + h.context(6, (handler) => { + settleSpawn = handler + }) + ), + h.requests.get('pty.attach')?.( + { id: 'pty-1' }, + h.context(6, (handler) => { + settleAttach = handler + }) + ) + ]) + h.authorityEvent('pty.data', { id: 'pty-1', data: 'held' }) + + settleSpawn?.({ ok: false, error: new Error('response lost') }) + settleAttach?.({ ok: true }) + + expect(h.dispatcher.publishTerminalAuthorityData).not.toHaveBeenCalled() + expect(h.onFailure).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining('did not reach') }) + ) + }) + + it('does not fail the gateway when an authority application error is delivered', async () => { + const h = harness() + await h.openClient(16) + let settleAttach: ((result: ResponseSettlement) => void) | undefined + + await h.requests.get('pty.attach')?.( + { id: 'pty-1' }, + h.context(16, (handler) => { + settleAttach = handler + }) + ) + h.authorityEvent('pty.recoveryComplete', { id: 'pty-1' }) + + settleAttach?.({ + ok: false, + error: new Error('terminal_authority_required'), + responseDelivered: true + }) + + expect(h.onFailure).not.toHaveBeenCalled() + expect(h.dispatcher.tryNotifyClient).toHaveBeenCalledWith(16, 'pty.recoveryComplete', { + id: 'pty-1' + }) + }) + + it('does not hold unrelated output behind an attach response', async () => { + const h = harness() + await h.openClient(10) + let settleAttach: ((result: ResponseSettlement) => void) | undefined + + await h.requests.get('pty.attach')?.( + { id: 'pty-fenced' }, + h.context(10, (handler) => { + settleAttach = handler + }) + ) + h.authorityEvent('pty.data', { id: 'pty-other', data: 'immediate' }) + h.authorityEvent('pty.replay', { id: 'pty-fenced', data: 'ordered' }) + + expect(h.dispatcher.publishTerminalAuthorityData).toHaveBeenCalledWith( + 10, + { id: 'pty-other', data: 'immediate' }, + expect.any(Function) + ) + expect(h.dispatcher.tryNotifyClient).not.toHaveBeenCalledWith( + 10, + 'pty.replay', + expect.anything() + ) + + settleAttach?.({ ok: true }) + expect(h.dispatcher.tryNotifyClient).toHaveBeenCalledWith(10, 'pty.replay', { + id: 'pty-fenced', + data: 'ordered' + }) + }) + + it('does not charge unrelated output to a response-order buffer', async () => { + const h = harness() + await h.openClient(11) + + await h.requests.get('pty.attach')?.( + { id: 'pty-fenced' }, + h.context(11, () => {}) + ) + h.authorityEvent('pty.data', { + id: 'pty-other', + data: 'x'.repeat(1024 * 1024) + }) + + expect(h.dispatcher.publishTerminalAuthorityData).toHaveBeenCalledOnce() + expect(h.onFailure).not.toHaveBeenCalled() + }) + + it('waits for every in-flight data settlement without blocking another PTY', async () => { + const h = harness() + await h.openClient(12) + + h.authorityEvent('pty.data', { id: 'pty-slow', data: 'first' }) + h.authorityEvent('pty.data', { id: 'pty-slow', data: 'second' }) + h.authorityEvent('pty.deliveryCanceled', { id: 'pty-slow' }) + h.authorityEvent('pty.data', { id: 'pty-fast', data: 'unrelated' }) + + expect(h.dispatcher.publishTerminalAuthorityData).toHaveBeenCalledTimes(3) + expect(h.dispatcher.tryNotifyClient).not.toHaveBeenCalled() + h.dataSettlements[1]({ ok: true }) + expect(h.dispatcher.tryNotifyClient).not.toHaveBeenCalled() + h.dataSettlements[0]({ ok: true }) + expect(h.dispatcher.tryNotifyClient).toHaveBeenCalledWith(12, 'pty.deliveryCanceled', { + id: 'pty-slow' + }) + }) + + it('keeps later same-PTY data behind its terminal outcome', async () => { + const h = harness() + await h.openClient(13) + + h.authorityEvent('pty.data', { id: 'pty-1', data: 'before' }) + h.authorityEvent('pty.recoveryComplete', { id: 'pty-1' }) + h.authorityEvent('pty.data', { id: 'pty-1', data: 'after' }) + + expect(h.dispatcher.publishTerminalAuthorityData).toHaveBeenCalledOnce() + h.dataSettlements[0]({ ok: true }) + expect(h.dispatcher.tryNotifyClient).toHaveBeenCalledWith(13, 'pty.recoveryComplete', { + id: 'pty-1' + }) + expect(h.dispatcher.publishTerminalAuthorityData).toHaveBeenCalledTimes(2) + }) + + it('fails closed when a delivery-order fence exceeds its bounded capacity', async () => { + const h = harness() + await h.openClient(14) + + h.authorityEvent('pty.data', { id: 'pty-1', data: 'pending' }) + h.authorityEvent('pty.exit', { id: 'pty-1', code: 0 }) + h.authorityEvent('pty.data', { id: 'pty-1', data: 'x'.repeat(16 * 1024 * 1024) }) + + expect(h.onFailure).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining('bounded capacity') }) + ) + }) + + it('closes the authority transport when the downstream client detaches', async () => { + const h = harness() + await h.openClient(9) + + h.detachClient(9) + + expect(h.mux.dispose).toHaveBeenCalledWith('connection_lost') + expect(h.onFailure).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining('client disconnected') }) + ) + await expect(h.openClient(10)).rejects.toThrow('gateway_unavailable') + }) + + it('fails closed when authority output has no admitted downstream client', () => { + const h = harness() + + h.authorityEvent('pty.data', { id: 'pty-1', data: 'unowned' }) + + expect(h.mux.dispose).toHaveBeenCalledWith('connection_lost') + expect(h.onFailure).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining('no admitted client') }) + ) + }) + + it('fails the gateway instead of silently dropping an authority event', async () => { + const h = harness() + await h.openClient(5) + h.dispatcher.publishTerminalAuthorityData.mockReturnValueOnce(false) + + h.authorityEvent('pty.data', { id: 'pty-1', data: 'lost' }) + + expect(h.onFailure).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining('data did not reach') }) + ) + }) + + it('fails when the local authority connection is lost', () => { + const h = harness() + h.loseAuthority() + expect(h.onFailure).toHaveBeenCalledOnce() + }) +}) diff --git a/src/relay/terminal-authority-gateway.ts b/src/relay/terminal-authority-gateway.ts new file mode 100644 index 00000000000..71c3b6bdd4f --- /dev/null +++ b/src/relay/terminal-authority-gateway.ts @@ -0,0 +1,310 @@ +import type { SshChannelMultiplexer } from '../main/ssh/ssh-channel-multiplexer' +import { + TERMINAL_AUTHORITY_NOTIFICATION_METHODS, + TERMINAL_AUTHORITY_REQUEST_METHODS, + isTerminalAuthorityEventMethod, + type TerminalAuthorityEventMethod, + type TerminalAuthorityRequestMethod +} from '../shared/terminal-authority-routing' +import type { RelayDispatcher, RequestContext, ResponseSettlement } from './dispatcher' +import { + TERMINAL_AUTHORITY_NAMESPACE_BOUNDARY_NOTIFICATION, + TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_NOTIFICATION +} from '../shared/terminal-session-authority-consumer-transport' +import { + TerminalAuthorityEventBuffer, + TerminalAuthorityEventDelivery +} from './terminal-authority-event-delivery' +import { TerminalAuthorityTopologyGateway } from './terminal-authority-topology-gateway' +import { + grantsExactOperations, + grantsTerminalAuthorityExactOperations, + requestsExactOperations, + requestsTerminalAuthorityExactOperations +} from './terminal-authority-gateway-capabilities' +import { + TerminalAuthorityResponseOrderBuffer, + type TerminalAuthorityResponseOrderFence +} from './terminal-authority-response-order-buffer' +import { + RESPONSE_ORDERED_REQUESTS, + LEGACY_MUTATION_NOTIFICATIONS, + EXACT_MUTATION_NOTIFICATIONS, + AUTHORITY_EXACT_MUTATION_NOTIFICATIONS +} from './terminal-authority-gateway-rules' +import { assertTerminalAuthorityGatewayRequestAdmission } from './terminal-authority-gateway-request-admission' + +export class TerminalAuthorityGateway { + private activeClientId: number | null = null + private pendingClientId: number | null = null + private activeClientHasAuthorityExactOperations = false + private pendingClientHasAuthorityExactOperations = false + private readonly eventBuffer = new TerminalAuthorityEventBuffer() + private readonly eventDelivery: TerminalAuthorityEventDelivery + private readonly responseOrdering: TerminalAuthorityResponseOrderBuffer + private readonly topologyGateway: TerminalAuthorityTopologyGateway + private disposed = false + private failed = false + + constructor( + private readonly dispatcher: RelayDispatcher, + private readonly authorityMux: SshChannelMultiplexer, + private readonly onFailure: (error: Error) => void, + ownerCapabilities: readonly string[] = [] + ) { + this.eventDelivery = new TerminalAuthorityEventDelivery( + dispatcher, + this.eventBuffer, + () => this.activeClientId, + (error) => this.fail(error) + ) + this.responseOrdering = new TerminalAuthorityResponseOrderBuffer( + this.eventBuffer, + (method, params) => this.eventDelivery.publish(method, params), + (error) => this.fail(error) + ) + this.topologyGateway = new TerminalAuthorityTopologyGateway( + dispatcher, + authorityMux, + ownerCapabilities + ) + for (const method of TERMINAL_AUTHORITY_REQUEST_METHODS) { + dispatcher.onRequest(method, (params, context) => + this.forwardRequest(method, params, context) + ) + } + for (const method of TERMINAL_AUTHORITY_NOTIFICATION_METHODS) { + dispatcher.onNotification(method, (params, context) => { + this.forwardNotification(method, params, context) + }) + } + dispatcher.onClientDetached((clientId) => { + if (this.activeClientId === clientId || this.pendingClientId === clientId) { + this.fail(new Error('Terminal authority gateway client disconnected')) + } + }) + authorityMux.onNotification((method, params) => { + if (this.topologyGateway.acceptAuthorityNotification(method, params)) { + return + } + if ( + method === TERMINAL_AUTHORITY_NAMESPACE_BOUNDARY_NOTIFICATION || + method === TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_NOTIFICATION + ) { + this.forwardNamespaceOutcome(method, params) + return + } + if (!isTerminalAuthorityEventMethod(method)) { + this.fail(new Error(`Terminal authority published an unexpected event: ${method}`)) + return + } + this.acceptEvent(method, params) + }) + authorityMux.onDispose((reason) => { + if (!this.disposed) { + this.fail( + new Error(`Terminal authority gateway connection ${reason.replace('_', ' ')}`), + false + ) + } + }) + } + + dispose(): void { + if (this.disposed) { + return + } + this.disposed = true + this.activeClientId = null + this.pendingClientId = null + this.activeClientHasAuthorityExactOperations = false + this.pendingClientHasAuthorityExactOperations = false + this.topologyGateway.clear() + this.responseOrdering.clear() + this.eventDelivery.clear() + this.authorityMux.dispose('shutdown') + } + + private async forwardRequest( + method: TerminalAuthorityRequestMethod, + params: Record, + context: RequestContext + ): Promise { + assertTerminalAuthorityGatewayRequestAdmission(method, context, { + activeClientId: this.activeClientId, + pendingClientId: this.pendingClientId, + activeClientHasAuthorityExactOperations: this.activeClientHasAuthorityExactOperations, + unavailable: this.failed || this.disposed + }) + const opensClient = method === 'pty.openClient' + let forwardedParams = params + if (opensClient) { + if (!requestsExactOperations(params) || !requestsTerminalAuthorityExactOperations(params)) { + throw new Error('terminal_authority_exact_operations_required') + } + this.pendingClientId = context.clientId + this.pendingClientHasAuthorityExactOperations = false + forwardedParams = this.topologyGateway.prepareOpenClient(params) + } + let responseFence: TerminalAuthorityResponseOrderFence | null = null + if (RESPONSE_ORDERED_REQUESTS.has(method)) { + if (!context.onResponseSettled || (opensClient && !context.onResponsePrepared)) { + if (opensClient) { + this.pendingClientId = null + this.pendingClientHasAuthorityExactOperations = false + this.topologyGateway.cancelPending() + } + throw new Error('terminal_authority_response_settlement_unavailable') + } + if (opensClient) { + context.onResponsePrepared?.(() => this.activateAdmission(context.clientId)) + } + if (method === 'pty.attach') { + responseFence = this.responseOrdering.register(params.id) + } + context.onResponseSettled((settlement) => { + if (opensClient) { + this.settleAdmissionResponse(settlement, context.clientId) + } else if (responseFence) { + this.responseOrdering.settleFence(responseFence, settlement) + } + }) + } + let result = await this.authorityMux.request(method, forwardedParams, { + signal: context.signal, + ...(method === 'pty.spawn' + ? { + beforeResolve: (result) => { + responseFence = this.responseOrdering.register( + (result as Record | null)?.id + ) + } + } + : {}) + }) + if (opensClient) { + if (!grantsExactOperations(result) || !grantsTerminalAuthorityExactOperations(result)) { + const error = new Error('terminal_authority_exact_operations_not_granted') + this.fail(error) + throw error + } + this.pendingClientHasAuthorityExactOperations = true + result = this.topologyGateway.grantOpenClient(result) + } + return result + } + + private forwardNotification( + method: (typeof TERMINAL_AUTHORITY_NOTIFICATION_METHODS)[number], + params: Record, + context: RequestContext + ): void { + if (this.activeClientId !== context.clientId) { + return + } + if (LEGACY_MUTATION_NOTIFICATIONS.has(method)) { + this.fail(new Error(`Terminal authority rejected legacy mutation ${method}`)) + return + } + if (EXACT_MUTATION_NOTIFICATIONS.has(method)) { + this.fail(new Error(`Terminal authority rejected incarnation mutation ${method}`)) + return + } + if ( + AUTHORITY_EXACT_MUTATION_NOTIFICATIONS.has(method) && + !this.activeClientHasAuthorityExactOperations + ) { + this.fail(new Error(`Terminal authority rejected ungranted exact mutation ${method}`)) + return + } + if (!this.authorityMux.notify(method, params)) { + this.fail(new Error(`Terminal authority rejected ${method}`)) + } + } + + private acceptEvent(method: TerminalAuthorityEventMethod, params: Record): void { + if (this.responseOrdering.accept(this.pendingClientId !== null, method, params)) { + return + } + this.eventDelivery.publish(method, params) + } + + private forwardNamespaceOutcome( + method: + | typeof TERMINAL_AUTHORITY_NAMESPACE_BOUNDARY_NOTIFICATION + | typeof TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_NOTIFICATION, + params: Record + ): void { + const clientId = this.activeClientId + if (clientId === null || !this.dispatcher.tryNotifyClient(clientId, method, params)) { + this.fail(new Error(`Terminal authority namespace outcome could not be forwarded: ${method}`)) + } + } + + private activateAdmission(openingClientId: number): void { + if (this.failed || this.disposed) { + return + } + if (this.pendingClientId !== openingClientId) { + this.fail(new Error('Terminal authority gateway admission preparation was stale')) + return + } + this.activeClientId = openingClientId + this.activeClientHasAuthorityExactOperations = this.pendingClientHasAuthorityExactOperations + this.topologyGateway.settleAdmission(openingClientId, true) + } + + private settleAdmissionResponse(settlement: ResponseSettlement, openingClientId: number): void { + if (this.failed || this.disposed) { + return + } + if (this.pendingClientId !== openingClientId) { + this.fail(new Error('Terminal authority gateway admission settlement was stale')) + return + } + if (!settlement.ok) { + this.activeClientId = null + this.activeClientHasAuthorityExactOperations = false + this.pendingClientId = null + this.pendingClientHasAuthorityExactOperations = false + this.topologyGateway.settleAdmission(openingClientId, false) + if (settlement.responseDelivered === true) { + return + } + this.fail( + new Error( + `Terminal authority admission response did not reach the gateway client: ${settlement.error.message}` + ) + ) + return + } + if (this.activeClientId !== openingClientId) { + this.fail(new Error('Terminal authority gateway admission preparation was stale')) + return + } + this.pendingClientId = null + this.pendingClientHasAuthorityExactOperations = false + const events = this.responseOrdering.takeAdmissionEvents() + for (const event of events) { + this.eventDelivery.publish(event.method, event.params) + } + } + + private fail(error: Error, closeAuthority = true): void { + if (this.failed || this.disposed) { + return + } + this.failed = true + this.activeClientId = null + this.pendingClientId = null + this.activeClientHasAuthorityExactOperations = false + this.pendingClientHasAuthorityExactOperations = false + this.topologyGateway.clear() + this.responseOrdering.clear() + this.eventDelivery.clear() + if (closeAuthority) { + this.authorityMux.dispose('connection_lost') + } + this.onFailure(error) + } +} diff --git a/src/relay/terminal-authority-owner-marker-storage.ts b/src/relay/terminal-authority-owner-marker-storage.ts new file mode 100644 index 00000000000..59fdf2ccb67 --- /dev/null +++ b/src/relay/terminal-authority-owner-marker-storage.ts @@ -0,0 +1,136 @@ +import { randomUUID } from 'node:crypto' +import { + chmodSync, + closeSync, + existsSync, + fsyncSync, + linkSync, + mkdirSync, + openSync, + readFileSync, + renameSync, + statSync, + unlinkSync, + writeFileSync +} from 'node:fs' +import { join } from 'node:path' +import { + parseSshTerminalAuthorityMarker, + type SshTerminalAuthorityMarker +} from '../shared/ssh-terminal-authority-marker' + +const AUTHORITY_HOST_ID_FILE = 'authority-host-id' +const MAX_MARKER_BYTES = 16 * 1024 + +export function prepareTerminalAuthorityStateDirectory(stateDir: string): void { + mkdirSync(stateDir, { recursive: true, mode: 0o700 }) + if (process.platform !== 'win32') { + chmodSync(stateDir, 0o700) + } +} + +export function readOrCreateTerminalAuthorityHostId(stateDir: string): string { + const path = join(stateDir, AUTHORITY_HOST_ID_FILE) + if (!existsSync(path)) { + createTerminalAuthorityMarkerFile(path, `${randomUUID()}\n`, stateDir) + } + const value = readFileSync(path, 'utf8').trim() + if (!/^[0-9a-f-]{36}$/i.test(value)) { + throw new Error('Terminal authority host identity is invalid') + } + return value +} + +export function readTerminalAuthorityOwnerMarker( + markerPath: string +): SshTerminalAuthorityMarker | null { + try { + const metadata = statSync(markerPath) + if (!metadata.isFile() || metadata.size > MAX_MARKER_BYTES) { + return null + } + return parseSshTerminalAuthorityMarker(JSON.parse(readFileSync(markerPath, 'utf8'))) + } catch { + return null + } +} + +export function createTerminalAuthorityMarkerFile( + markerPath: string, + contents: string, + stateDir: string +): boolean { + const temporary = `${markerPath}.${process.pid}.${randomUUID()}.tmp` + try { + writeDurableTemporaryFile(temporary, contents) + try { + linkSync(temporary, markerPath) + syncDirectory(stateDir) + return true + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EEXIST') { + return false + } + throw error + } + } finally { + unlinkIfPresent(temporary) + } +} + +export function replaceTerminalAuthorityMarkerFile( + markerPath: string, + contents: string, + stateDir: string +): void { + const temporary = `${markerPath}.${process.pid}.${randomUUID()}.tmp` + try { + writeDurableTemporaryFile(temporary, contents) + renameSync(temporary, markerPath) + syncDirectory(stateDir) + } finally { + unlinkIfPresent(temporary) + } +} + +function writeDurableTemporaryFile(file: string, contents: string): void { + const fd = openSync(file, 'wx', 0o600) + try { + writeFileSync(fd, contents, 'utf8') + fsyncSync(fd) + } finally { + closeSync(fd) + } +} + +function unlinkIfPresent(file: string): void { + try { + unlinkSync(file) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error + } + } +} + +function syncDirectory(directory: string): void { + let fd: number | undefined + try { + fd = openSync(directory, 'r') + fsyncSync(fd) + } catch (error) { + if (!isUnsupportedDirectorySync(error)) { + throw error + } + } finally { + if (fd !== undefined) { + closeSync(fd) + } + } +} + +function isUnsupportedDirectorySync(error: unknown): boolean { + return ['EBADF', 'EISDIR', 'EINVAL', 'EPERM'].includes( + (error as NodeJS.ErrnoException).code ?? '' + ) +} diff --git a/src/relay/terminal-authority-owner-marker.test.ts b/src/relay/terminal-authority-owner-marker.test.ts new file mode 100644 index 00000000000..4995deb894b --- /dev/null +++ b/src/relay/terminal-authority-owner-marker.test.ts @@ -0,0 +1,204 @@ +import { randomUUID } from 'node:crypto' +import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + parseSshTerminalAuthorityMarker, + type SshTerminalAuthorityMarker +} from '../shared/ssh-terminal-authority-marker' +import { encodeTerminalAuthorityOwnerToken } from '../main/session-authority/terminal-session-authority-owner-token' +import { + readCurrentTerminalAuthorityOwnerProcessIdentity, + type TerminalAuthorityOwnerProcessIdentity +} from '../main/session-authority/terminal-session-authority-owner-process' +import { createRelayOwnerGuardDirectory } from './relay-owner-guard-directory' +import { claimTerminalAuthorityOwnership } from './terminal-authority-owner-marker' + +const roots: string[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('terminal authority owner marker', () => { + it('creates one durable host identity and self-describing owner marker', async () => { + const options = paths() + const claim = await claimTerminalAuthorityOwnership(options) + expect(claim).toMatchObject({ + status: 'claimed', + mayRemoveStaleSocket: false, + marker: { + ownerPid: process.pid, + ownerProcessToken: options.processToken, + registryWriterOwnerToken: expect.stringMatching(/^terminal-authority-owner-v1\./), + revision: 1 + } + }) + const marker = persistedMarker(options.markerPath) + expect(marker).toEqual(claim.status === 'claimed' ? claim.marker : null) + }) + + it('refuses a second owner and a forged takeover while the exact owner is live', async () => { + const options = paths() + const first = await claimed(options) + + await expect( + claimTerminalAuthorityOwnership({ ...options, processToken: 'second-process-token-123' }) + ).resolves.toMatchObject({ status: 'occupied', marker: { revision: 1 } }) + await expect( + claimTerminalAuthorityOwnership({ + ...options, + processToken: 'forged-takeover-token-123', + takeover: { + ownerProcessToken: first.ownerProcessToken, + revision: first.revision + } + }) + ).resolves.toMatchObject({ status: 'occupied', marker: { ownerPid: process.pid } }) + }) + + it('replaces only an exact marker whose recorded process is locally proven gone', async () => { + const options = paths() + const first = await claimed(options) + const stale = await markerWithProcess(first, await absentProcessIdentity()) + writeMarker(options.markerPath, stale) + + const takeover = await claimTerminalAuthorityOwnership({ + ...options, + processToken: 'replacement-process-token-123', + takeover: { ownerProcessToken: stale.ownerProcessToken, revision: stale.revision } + }) + + expect(takeover).toMatchObject({ + status: 'claimed', + mayRemoveStaleSocket: true, + marker: { authorityHostId: first.authorityHostId, revision: first.revision + 1 }, + replacedMarker: stale + }) + }) + + it('keeps old or cross-scope process evidence occupied and never authorizes socket removal', async () => { + const options = paths() + const first = await claimed(options) + const incomplete = { ...first, registryWriterOwnerToken: undefined } + writeMarker(options.markerPath, incomplete) + await expect(takeover(options, incomplete)).resolves.toMatchObject({ status: 'occupied' }) + + const outOfScopeIdentity = await absentProcessIdentity() + const outOfScope = await markerWithProcess(first, { + ...outOfScopeIdentity, + ...(outOfScopeIdentity.platform === 'linux' + ? { linuxPidNamespace: 'pid:[different-namespace]' } + : { executionScope: 'different-execution-scope' }) + }) + writeMarker(options.markerPath, outOfScope) + await expect(takeover(options, outOfScope)).resolves.toMatchObject({ status: 'occupied' }) + }) + + it('recovers an orphan takeover guard only after its exact claimant is gone', async () => { + const options = paths() + const first = await claimed(options) + const stale = await markerWithProcess(first, await absentProcessIdentity()) + writeMarker(options.markerPath, stale) + const orphanToken = await ownerToken(randomUUID(), await absentProcessIdentity()) + await createRelayOwnerGuardDirectory(join(options.stateDir, 'takeover-lock'), orphanToken) + + await expect(takeover(options, stale)).resolves.toMatchObject({ + status: 'claimed', + marker: { revision: stale.revision + 1 } + }) + }) + + it('does not clear a live or malformed takeover guard', async () => { + const options = paths() + const first = await claimed(options) + const stale = await markerWithProcess(first, await absentProcessIdentity()) + writeMarker(options.markerPath, stale) + const liveToken = await ownerToken(randomUUID()) + await createRelayOwnerGuardDirectory(join(options.stateDir, 'takeover-lock'), liveToken) + await expect(takeover(options, stale)).resolves.toEqual({ status: 'contended' }) + + const malformed = paths() + const malformedMarker = await claimed(malformed) + writeFileSync(join(malformed.stateDir, 'takeover-lock'), '{broken', 'utf8') + await expect(takeover(malformed, malformedMarker)).resolves.toEqual({ status: 'contended' }) + }) + + it('fails closed for malformed persisted state and unsafe process tokens', async () => { + const options = paths() + writeFileSync(options.markerPath, '{broken', 'utf8') + await expect(claimTerminalAuthorityOwnership(options)).resolves.toEqual({ status: 'invalid' }) + await expect( + claimTerminalAuthorityOwnership({ ...paths(), processToken: '$(unsafe process token)' }) + ).resolves.toEqual({ status: 'invalid' }) + }) +}) + +async function claimed(options: ReturnType): Promise { + const claim = await claimTerminalAuthorityOwnership(options) + expect(claim.status).toBe('claimed') + if (claim.status !== 'claimed') { + throw new Error('test owner claim failed') + } + return claim.marker +} + +function takeover(options: ReturnType, marker: SshTerminalAuthorityMarker) { + return claimTerminalAuthorityOwnership({ + ...options, + processToken: 'replacement-process-token-123', + takeover: { ownerProcessToken: marker.ownerProcessToken, revision: marker.revision } + }) +} + +async function markerWithProcess( + marker: SshTerminalAuthorityMarker, + processIdentity: TerminalAuthorityOwnerProcessIdentity +): Promise { + return { + ...marker, + ownerPid: processIdentity.pid, + registryWriterOwnerToken: await ownerToken(marker.ownerInstanceId, processIdentity) + } +} + +async function ownerToken( + ownerIncarnationId: string, + processIdentity?: TerminalAuthorityOwnerProcessIdentity +): Promise { + return encodeTerminalAuthorityOwnerToken(randomUUID(), { + ownerIncarnationId, + process: processIdentity ?? (await readCurrentTerminalAuthorityOwnerProcessIdentity()) + }) +} + +async function absentProcessIdentity(): Promise { + return { + ...(await readCurrentTerminalAuthorityOwnerProcessIdentity()), + pid: 2_147_483_647 + } +} + +function persistedMarker(markerPath: string): SshTerminalAuthorityMarker | null { + return parseSshTerminalAuthorityMarker(JSON.parse(readFileSync(markerPath, 'utf8'))) +} + +function writeMarker(markerPath: string, marker: SshTerminalAuthorityMarker): void { + writeFileSync(markerPath, `${JSON.stringify(marker)}\n`, 'utf8') +} + +function paths() { + const stateDir = mkdtempSync(join(tmpdir(), 'orca-authority-owner-')) + roots.push(stateDir) + return { + stateDir, + markerPath: join(stateDir, 'active-endpoint'), + ownerBuildId: '1.2.3+abcdef', + ownerRelayDir: join(stateDir, '..', 'relay-1.2.3+abcdef'), + socketPath: join(stateDir, 'authority.sock'), + credentialFile: join(stateDir, 'endpoint.credential'), + processToken: 'process-token-123456' + } +} diff --git a/src/relay/terminal-authority-owner-marker.ts b/src/relay/terminal-authority-owner-marker.ts new file mode 100644 index 00000000000..771212cb115 --- /dev/null +++ b/src/relay/terminal-authority-owner-marker.ts @@ -0,0 +1,234 @@ +import { randomUUID } from 'node:crypto' +import { join } from 'node:path' +import { + SSH_TERMINAL_AUTHORITY_MARKER_VERSION, + parseSshTerminalAuthorityMarker, + type SshTerminalAuthorityMarker +} from '../shared/ssh-terminal-authority-marker' +import { CURRENT_RELAY_DAEMON_COMPATIBILITY } from '../shared/relay-daemon-compatibility' +import { + createRelayOwnerGuardDirectory, + inspectRelayOwnerGuardDirectory, + relayOwnerGuardIsOwnedBy, + releaseRelayOwnerGuardDirectory +} from './relay-owner-guard-directory' +import { + createTerminalAuthorityMarkerFile, + prepareTerminalAuthorityStateDirectory, + readOrCreateTerminalAuthorityHostId, + readTerminalAuthorityOwnerMarker, + replaceTerminalAuthorityMarkerFile +} from './terminal-authority-owner-marker-storage' +import { + createTerminalAuthorityRegistryOwnerToken, + terminalAuthorityMarkerOwnerIsGone, + terminalAuthorityRegistryOwnerTokenIsGone +} from './terminal-authority-registry-owner-token' + +const TAKEOVER_GUARD_DIRECTORY = 'takeover-lock' + +export type TerminalAuthorityOwnerClaimOptions = { + stateDir: string + markerPath: string + ownerBuildId: string + ownerRelayDir: string + socketPath: string + credentialFile: string + processToken: string + takeover?: { ownerProcessToken: string; revision: number } +} + +export type TerminalAuthorityOwnerClaim = + | Readonly<{ + status: 'claimed' + marker: SshTerminalAuthorityMarker + mayRemoveStaleSocket: boolean + replacedMarker?: SshTerminalAuthorityMarker + }> + | Readonly<{ status: 'occupied'; marker: SshTerminalAuthorityMarker }> + | Readonly<{ status: 'contended' | 'invalid' }> + +export async function claimTerminalAuthorityOwnership( + options: TerminalAuthorityOwnerClaimOptions +): Promise { + if (!/^[A-Za-z0-9_-]{16,128}$/.test(options.processToken)) { + return Object.freeze({ status: 'invalid' }) + } + prepareTerminalAuthorityStateDirectory(options.stateDir) + const authorityHostId = readOrCreateTerminalAuthorityHostId(options.stateDir) + const ownerInstanceId = randomUUID() + const registryWriterOwnerToken = await createTerminalAuthorityRegistryOwnerToken(ownerInstanceId) + const freshMarker = createMarker( + options, + authorityHostId, + ownerInstanceId, + registryWriterOwnerToken, + 1 + ) + if ( + createTerminalAuthorityMarkerFile( + options.markerPath, + serializeMarker(freshMarker), + options.stateDir + ) + ) { + return Object.freeze({ + status: 'claimed', + marker: freshMarker, + mayRemoveStaleSocket: false + }) + } + + const existing = readTerminalAuthorityOwnerMarker(options.markerPath) + if (!existing) { + return Object.freeze({ status: 'invalid' }) + } + if (!options.takeover) { + return Object.freeze({ status: 'occupied', marker: existing }) + } + + const guardPath = join(options.stateDir, TAKEOVER_GUARD_DIRECTORY) + if (!(await acquireTakeoverGuard(guardPath, registryWriterOwnerToken))) { + return Object.freeze({ status: 'contended' }) + } + let claim: TerminalAuthorityOwnerClaim + try { + claim = await replaceStaleOwnerMarker({ + options, + guardPath, + ownerInstanceId, + registryWriterOwnerToken + }) + } catch (error) { + await releaseTakeoverGuard(guardPath, registryWriterOwnerToken) + throw error + } + await releaseTakeoverGuard(guardPath, registryWriterOwnerToken) + return claim +} + +async function replaceStaleOwnerMarker(input: { + options: TerminalAuthorityOwnerClaimOptions + guardPath: string + ownerInstanceId: string + registryWriterOwnerToken: string +}): Promise { + const current = readTerminalAuthorityOwnerMarker(input.options.markerPath) + if (!current) { + return Object.freeze({ status: 'invalid' }) + } + if (!markerMatchesTakeover(current, input.options.takeover!)) { + return Object.freeze({ status: 'occupied', marker: current }) + } + if (!(await terminalAuthorityMarkerOwnerIsGone(current))) { + return Object.freeze({ status: 'occupied', marker: current }) + } + const verified = readTerminalAuthorityOwnerMarker(input.options.markerPath) + if (!verified || !sameMarkerOwner(current, verified)) { + return verified + ? Object.freeze({ status: 'occupied', marker: verified }) + : Object.freeze({ status: 'invalid' }) + } + if (!(await relayOwnerGuardIsOwnedBy(input.guardPath, input.registryWriterOwnerToken))) { + return Object.freeze({ status: 'contended' }) + } + const next = createMarker( + input.options, + current.authorityHostId, + input.ownerInstanceId, + input.registryWriterOwnerToken, + current.revision + 1 + ) + replaceTerminalAuthorityMarkerFile( + input.options.markerPath, + serializeMarker(next), + input.options.stateDir + ) + return Object.freeze({ + status: 'claimed', + marker: next, + mayRemoveStaleSocket: true, + replacedMarker: current + }) +} + +async function releaseTakeoverGuard(guardPath: string, ownerToken: string): Promise { + if (!(await releaseRelayOwnerGuardDirectory(guardPath, ownerToken))) { + throw new Error('Terminal authority takeover guard ownership changed before release') + } +} + +async function acquireTakeoverGuard(guardPath: string, ownerToken: string): Promise { + for (let attempt = 0; attempt < 2; attempt++) { + if (await createRelayOwnerGuardDirectory(guardPath, ownerToken)) { + return true + } + const existing = await inspectRelayOwnerGuardDirectory(guardPath) + if (existing.status === 'missing') { + continue + } + if ( + attempt > 0 || + existing.status !== 'owned' || + !(await terminalAuthorityRegistryOwnerTokenIsGone(existing.ownerToken)) || + !(await releaseRelayOwnerGuardDirectory(guardPath, existing.ownerToken)) + ) { + return false + } + } + return false +} + +function createMarker( + options: TerminalAuthorityOwnerClaimOptions, + authorityHostId: string, + ownerInstanceId: string, + registryWriterOwnerToken: string, + revision: number +): SshTerminalAuthorityMarker { + const marker = parseSshTerminalAuthorityMarker({ + markerVersion: SSH_TERMINAL_AUTHORITY_MARKER_VERSION, + authorityHostId, + ownerInstanceId, + ownerPid: process.pid, + ownerProcessToken: options.processToken, + registryWriterOwnerToken, + ownerBuildId: options.ownerBuildId, + ownerRelayDir: options.ownerRelayDir, + socketPath: options.socketPath, + credentialFile: options.credentialFile, + compatibility: CURRENT_RELAY_DAEMON_COMPATIBILITY, + revision + }) + if (!marker) { + throw new Error('Terminal authority owner marker arguments are invalid') + } + return marker +} + +function markerMatchesTakeover( + marker: SshTerminalAuthorityMarker, + takeover: NonNullable +): boolean { + return ( + marker.ownerProcessToken === takeover.ownerProcessToken && marker.revision === takeover.revision + ) +} + +function sameMarkerOwner( + left: SshTerminalAuthorityMarker, + right: SshTerminalAuthorityMarker +): boolean { + return ( + left.authorityHostId === right.authorityHostId && + left.ownerInstanceId === right.ownerInstanceId && + left.ownerPid === right.ownerPid && + left.ownerProcessToken === right.ownerProcessToken && + left.registryWriterOwnerToken === right.registryWriterOwnerToken && + left.revision === right.revision + ) +} + +function serializeMarker(marker: SshTerminalAuthorityMarker): string { + return `${JSON.stringify(marker)}\n` +} diff --git a/src/relay/terminal-authority-registry-owner-token.ts b/src/relay/terminal-authority-registry-owner-token.ts new file mode 100644 index 00000000000..43f028ddeed --- /dev/null +++ b/src/relay/terminal-authority-registry-owner-token.ts @@ -0,0 +1,43 @@ +import { randomUUID } from 'node:crypto' +import type { SshTerminalAuthorityMarker } from '../shared/ssh-terminal-authority-marker' +import { + encodeTerminalAuthorityOwnerToken, + parseTerminalAuthorityOwnerToken +} from '../main/session-authority/terminal-session-authority-owner-token' +import { + readCurrentTerminalAuthorityOwnerProcessIdentity, + terminalAuthorityOwnerProcessIsGone +} from '../main/session-authority/terminal-session-authority-owner-process' + +export async function createTerminalAuthorityRegistryOwnerToken( + ownerIncarnationId: string +): Promise { + return encodeTerminalAuthorityOwnerToken(randomUUID(), { + ownerIncarnationId, + process: await readCurrentTerminalAuthorityOwnerProcessIdentity() + }) +} + +export async function terminalAuthorityRegistryOwnerTokenIsGone( + ownerToken: string +): Promise { + const proof = parseTerminalAuthorityOwnerToken(ownerToken) + return proof ? await terminalAuthorityOwnerProcessIsGone(proof.process) : false +} + +export async function terminalAuthorityMarkerOwnerIsGone( + marker: SshTerminalAuthorityMarker +): Promise { + if (!marker.registryWriterOwnerToken) { + return false + } + const proof = parseTerminalAuthorityOwnerToken(marker.registryWriterOwnerToken) + if ( + !proof || + proof.ownerIncarnationId !== marker.ownerInstanceId || + proof.process.pid !== marker.ownerPid + ) { + return false + } + return await terminalAuthorityOwnerProcessIsGone(proof.process) +} diff --git a/src/relay/terminal-authority-response-order-buffer.ts b/src/relay/terminal-authority-response-order-buffer.ts new file mode 100644 index 00000000000..e9f81fc9d31 --- /dev/null +++ b/src/relay/terminal-authority-response-order-buffer.ts @@ -0,0 +1,115 @@ +import type { TerminalAuthorityEventMethod } from '../shared/terminal-authority-routing' +import type { ResponseSettlement } from './dispatcher' +import type { + BufferedAuthorityEvent, + TerminalAuthorityEventBuffer +} from './terminal-authority-event-delivery' + +export type TerminalAuthorityResponseOrderFence = { + ptyId: string + pendingSettlements: number + failure: Error | null + events: BufferedAuthorityEvent[] +} + +export class TerminalAuthorityResponseOrderBuffer { + private admissionEvents: BufferedAuthorityEvent[] = [] + private readonly responseFences = new Map() + + constructor( + private readonly eventBuffer: TerminalAuthorityEventBuffer, + private readonly publishEvent: ( + method: TerminalAuthorityEventMethod, + params: Record + ) => void, + private readonly fail: (error: Error) => void + ) {} + + accept( + admissionPending: boolean, + method: TerminalAuthorityEventMethod, + params: Record + ): boolean { + const target = admissionPending + ? this.admissionEvents + : this.responseFenceForEvent(method, params)?.events + if (!target) { + return false + } + if (!this.eventBuffer.push(target, method, params)) { + this.fail(new Error('Terminal authority response-order buffer exceeded its bounded capacity')) + } + return true + } + + register(value: unknown): TerminalAuthorityResponseOrderFence | null { + if (typeof value !== 'string' || value.length === 0) { + return null + } + const existing = this.responseFences.get(value) + if (existing) { + existing.pendingSettlements++ + return existing + } + const fence: TerminalAuthorityResponseOrderFence = { + ptyId: value, + pendingSettlements: 1, + failure: null, + events: [] + } + this.responseFences.set(value, fence) + return fence + } + + takeAdmissionEvents(): readonly BufferedAuthorityEvent[] { + const events = this.admissionEvents + this.admissionEvents = [] + this.eventBuffer.release(events) + return events + } + + settleFence(fence: TerminalAuthorityResponseOrderFence, settlement: ResponseSettlement): void { + if (this.responseFences.get(fence.ptyId) !== fence) { + return + } + if (!settlement.ok && settlement.responseDelivered !== true && fence.failure === null) { + fence.failure = settlement.error + } + fence.pendingSettlements-- + if (fence.pendingSettlements > 0) { + return + } + this.responseFences.delete(fence.ptyId) + this.eventBuffer.release(fence.events) + if (fence.failure) { + this.fail( + new Error( + `Terminal authority response did not reach the gateway client: ${fence.failure.message}` + ) + ) + return + } + for (const event of fence.events) { + this.publishEvent(event.method, event.params) + } + } + + clear(): void { + this.eventBuffer.release(this.admissionEvents) + this.admissionEvents = [] + for (const fence of this.responseFences.values()) { + this.eventBuffer.release(fence.events) + } + this.responseFences.clear() + } + + private responseFenceForEvent( + method: TerminalAuthorityEventMethod, + params: Record + ): TerminalAuthorityResponseOrderFence | null { + if (!method.startsWith('pty.') || typeof params.id !== 'string') { + return null + } + return this.responseFences.get(params.id) ?? null + } +} diff --git a/src/relay/terminal-authority-topology-change-plan.ts b/src/relay/terminal-authority-topology-change-plan.ts new file mode 100644 index 00000000000..77f5ceaa474 --- /dev/null +++ b/src/relay/terminal-authority-topology-change-plan.ts @@ -0,0 +1,79 @@ +import { + TERMINAL_AUTHORITY_TOPOLOGY_MAX_CHANGE_OPERATIONS, + type TerminalAuthorityTopologyPaneChange +} from '../shared/terminal-authority-topology-stream-contract' +import type { TerminalLegacyRecoveryNoticeProjection } from '../shared/terminal-legacy-cutover' + +export type TerminalAuthorityTopologyChangeBatch = Readonly<{ + paneChanges: readonly TerminalAuthorityTopologyPaneChange[] + recoveryNotices?: TerminalLegacyRecoveryNoticeProjection +}> + +type BatchFits = ( + batchIndex: number, + paneChanges: readonly TerminalAuthorityTopologyPaneChange[], + recoveryNotices?: TerminalLegacyRecoveryNoticeProjection +) => boolean + +export function createTerminalAuthorityTopologyChangePlan( + groups: readonly (readonly TerminalAuthorityTopologyPaneChange[])[], + recoveryNotices: TerminalLegacyRecoveryNoticeProjection | null, + batchFits: BatchFits +): readonly TerminalAuthorityTopologyChangeBatch[] | null { + const batches: TerminalAuthorityTopologyChangeBatch[] = [] + let pending: TerminalAuthorityTopologyPaneChange[] = [] + for (const group of groups) { + if (group.length > TERMINAL_AUTHORITY_TOPOLOGY_MAX_CHANGE_OPERATIONS) { + return null + } + const candidate = [...pending, ...group] + if ( + candidate.length <= TERMINAL_AUTHORITY_TOPOLOGY_MAX_CHANGE_OPERATIONS && + batchFits(batches.length, candidate) + ) { + pending = candidate + continue + } + if (pending.length === 0) { + return null + } + batches.push(Object.freeze({ paneChanges: Object.freeze(pending) })) + pending = [...group] + if (!batchFits(batches.length, pending)) { + return null + } + } + if (pending.length > 0) { + batches.push(Object.freeze({ paneChanges: Object.freeze(pending) })) + } + if (!recoveryNotices) { + return Object.freeze(batches) + } + const finalIndex = Math.max(0, batches.length - 1) + const finalPaneChanges = batches[finalIndex]?.paneChanges ?? [] + if (batchFits(finalIndex, finalPaneChanges, recoveryNotices)) { + const withRecovery = Object.freeze({ paneChanges: finalPaneChanges, recoveryNotices }) + if (batches.length === 0) { + batches.push(withRecovery) + } else { + batches[finalIndex] = withRecovery + } + return Object.freeze(batches) + } + if (!batchFits(batches.length, [], recoveryNotices)) { + return null + } + batches.push(Object.freeze({ paneChanges: Object.freeze([]), recoveryNotices })) + return Object.freeze(batches) +} + +export function terminalAuthorityTopologyGapSignalBatch( + batches: readonly TerminalAuthorityTopologyChangeBatch[] +): TerminalAuthorityTopologyChangeBatch { + const first = batches[0] + if (!first) { + throw new Error('terminal_authority_topology_change_plan_empty') + } + const paneChange = first.paneChanges[0] + return paneChange ? Object.freeze({ paneChanges: Object.freeze([paneChange]) }) : first +} diff --git a/src/relay/terminal-authority-topology-channel-contract.ts b/src/relay/terminal-authority-topology-channel-contract.ts new file mode 100644 index 00000000000..7dc0495bb57 --- /dev/null +++ b/src/relay/terminal-authority-topology-channel-contract.ts @@ -0,0 +1,38 @@ +import type { TerminalAuthorityObserverAccess } from '../main/session-authority/terminal-session-authority-access' +import type { TerminalAuthorityProjectionChange } from '../main/session-authority/terminal-session-authority-service-contract' +import type { TerminalAuthorityNamespace } from '../shared/terminal-session-authority-identity' +import type { TerminalAuthorityProjection } from '../shared/terminal-session-authority-mutation' +import type { TerminalLegacyRecoveryNoticeProjection } from '../shared/terminal-legacy-cutover' +import type { + TERMINAL_AUTHORITY_TOPOLOGY_CHANGED_NOTIFICATION, + TerminalAuthorityTopologyChange +} from '../shared/terminal-authority-topology-stream-contract' + +export type TerminalAuthorityTopologyChannelService = Readonly<{ + namespace: TerminalAuthorityNamespace + subscribeProjection: ( + actorId: string, + listener: (change: TerminalAuthorityProjectionChange) => void + ) => TerminalAuthorityObserverAccess + revokeObserver: (access: TerminalAuthorityObserverAccess) => void + snapshotForObserver: (access: TerminalAuthorityObserverAccess) => TerminalAuthorityProjection +}> + +export type TerminalAuthorityTopologyRecoverySource = Readonly<{ + recoveryNoticesForNamespace: ( + namespace: TerminalAuthorityNamespace + ) => TerminalLegacyRecoveryNoticeProjection +}> + +export type TerminalAuthorityTopologyChannelTransport = Readonly<{ + notify: ( + clientId: number, + method: typeof TERMINAL_AUTHORITY_TOPOLOGY_CHANGED_NOTIFICATION, + change: TerminalAuthorityTopologyChange + ) => boolean + disconnect: (clientId: number, error: Error) => void +}> + +export type TerminalAuthorityTopologyChannelSubscription = Readonly<{ + dispose: () => void +}> diff --git a/src/relay/terminal-authority-topology-gateway.ts b/src/relay/terminal-authority-topology-gateway.ts new file mode 100644 index 00000000000..41d185be481 --- /dev/null +++ b/src/relay/terminal-authority-topology-gateway.ts @@ -0,0 +1,115 @@ +import type { SshChannelMultiplexer } from '../main/ssh/ssh-channel-multiplexer' +import { + TERMINAL_AUTHORITY_TOPOLOGY_CHANGED_NOTIFICATION, + TERMINAL_AUTHORITY_TOPOLOGY_PTY_CAPABILITY, + TERMINAL_AUTHORITY_TOPOLOGY_SNAPSHOT_METHOD, + TERMINAL_AUTHORITY_TOPOLOGY_UNSUBSCRIBE_NOTIFICATION, + ptyCapabilitiesOfferTerminalAuthorityTopology, + relayDaemonGrantHasTerminalAuthorityTopology +} from '../shared/terminal-authority-topology-stream-contract' +import type { RelayDispatcher, RequestContext } from './dispatcher' + +export class TerminalAuthorityTopologyGateway { + private pendingGrant = false + private activeClientId: number | null = null + + constructor( + private readonly dispatcher: RelayDispatcher, + private readonly authorityMux: SshChannelMultiplexer, + ownerCapabilities: readonly string[] + ) { + this.ownerHopGranted = relayDaemonGrantHasTerminalAuthorityTopology(ownerCapabilities) + dispatcher.onRequest(TERMINAL_AUTHORITY_TOPOLOGY_SNAPSHOT_METHOD, (params, context) => + this.forwardSnapshot(params, context) + ) + dispatcher.onNotification( + TERMINAL_AUTHORITY_TOPOLOGY_UNSUBSCRIBE_NOTIFICATION, + (params, context) => this.forwardUnsubscribe(params, context) + ) + } + + private readonly ownerHopGranted: boolean + + prepareOpenClient(params: Record): Record { + this.pendingGrant = + this.ownerHopGranted && ptyCapabilitiesOfferTerminalAuthorityTopology(params.capabilities) + return stripTopologyOffer(params) + } + + grantOpenClient(result: unknown): unknown { + if (typeof result !== 'object' || result === null) { + return result + } + const record = result as Record + const capabilities = + typeof record.capabilities === 'object' && record.capabilities !== null + ? { ...(record.capabilities as Record) } + : {} + delete capabilities[TERMINAL_AUTHORITY_TOPOLOGY_PTY_CAPABILITY] + if (this.pendingGrant) { + capabilities[TERMINAL_AUTHORITY_TOPOLOGY_PTY_CAPABILITY] = Object.freeze({ version: 1 }) + } + return Object.freeze({ + ...record, + ...(Object.keys(capabilities).length > 0 ? { capabilities } : {}) + }) + } + + settleAdmission(clientId: number, admitted: boolean): void { + this.activeClientId = admitted && this.pendingGrant ? clientId : null + this.pendingGrant = false + } + + cancelPending(): void { + this.pendingGrant = false + } + + clear(): void { + this.pendingGrant = false + this.activeClientId = null + } + + acceptAuthorityNotification(method: string, params: Record): boolean { + if (method !== TERMINAL_AUTHORITY_TOPOLOGY_CHANGED_NOTIFICATION) { + return false + } + if (this.activeClientId !== null) { + this.dispatcher.tryNotifyClient(this.activeClientId, method, params) + } + return true + } + + private forwardSnapshot( + params: Record, + context: RequestContext + ): Promise { + this.assertGrantedClient(context) + return this.authorityMux.request(TERMINAL_AUTHORITY_TOPOLOGY_SNAPSHOT_METHOD, params, { + signal: context.signal + }) + } + + private forwardUnsubscribe(params: Record, context: RequestContext): void { + if (this.activeClientId !== context.clientId) { + return + } + this.authorityMux.notify(TERMINAL_AUTHORITY_TOPOLOGY_UNSUBSCRIBE_NOTIFICATION, params) + } + + private assertGrantedClient(context: RequestContext): void { + if (this.activeClientId !== context.clientId) { + throw new Error('terminal_authority_topology_not_granted') + } + } +} + +function stripTopologyOffer(params: Record): Record { + if (typeof params.capabilities !== 'object' || params.capabilities === null) { + return params + } + const capabilities = { ...(params.capabilities as Record) } + delete capabilities[TERMINAL_AUTHORITY_TOPOLOGY_PTY_CAPABILITY] + const forwarded = { ...params } + delete forwarded.capabilities + return Object.keys(capabilities).length > 0 ? { ...forwarded, capabilities } : forwarded +} diff --git a/src/relay/terminal-authority-topology-namespace-channel.test.ts b/src/relay/terminal-authority-topology-namespace-channel.test.ts new file mode 100644 index 00000000000..15bdac2657a --- /dev/null +++ b/src/relay/terminal-authority-topology-namespace-channel.test.ts @@ -0,0 +1,303 @@ +import { describe, expect, it, vi } from 'vitest' +import type { TerminalAuthorityObserverAccess } from '../main/session-authority/terminal-session-authority-access' +import type { TerminalAuthorityProjectionChange } from '../main/session-authority/terminal-session-authority-service-contract' +import { + SshTerminalAuthorityTopologyClient, + type SshTerminalAuthorityTopologyClientStatus +} from '../main/ssh/ssh-terminal-authority-topology-client' +import type { SshTerminalAuthorityTopologyTransport } from '../main/ssh/ssh-terminal-authority-topology-client-contract' +import type { + TerminalAuthorityProjection, + TerminalPaneAuthorityProjection +} from '../shared/terminal-session-authority-mutation' +import type { + TerminalAuthorityTopologyChange, + TerminalAuthorityTopologySnapshot +} from '../shared/terminal-authority-topology-stream-contract' +import { + type TerminalAuthorityTopologyChannelService, + TerminalAuthorityTopologyNamespaceChannel +} from './terminal-authority-topology-namespace-channel' + +const namespace = Object.freeze({ authorityHostId: 'host-1', namespaceId: 'namespace-1' }) +const binding = Object.freeze({ + ownerIncarnationId: 'owner-1', + physicalPtyId: 'pty-1', + ptyIncarnationId: 'incarnation-1' +}) + +function pane( + paneKey: string, + paneGenerationId: string, + revision: number, + options: Partial = {} +): TerminalPaneAuthorityProjection { + const liveBinding = options.binding === undefined ? binding : options.binding + return Object.freeze({ + paneKey, + paneGenerationId, + status: 'open', + binding: liveBinding, + lastBinding: liveBinding, + revision, + ownerStatus: liveBinding ? 'reachable' : null, + ...options + }) +} + +function projection( + revision: number, + panes: readonly TerminalPaneAuthorityProjection[], + allocations: readonly unknown[] = [] +): TerminalAuthorityProjection { + return Object.freeze({ + namespace, + writerEpoch: 1, + revision, + panes: Object.freeze([...panes]), + allocations: Object.freeze(allocations) as TerminalAuthorityProjection['allocations'] + }) +} + +class FakeService implements TerminalAuthorityTopologyChannelService { + readonly namespace = namespace + readonly events: string[] = [] + private listener: ((change: TerminalAuthorityProjectionChange) => void) | null = null + private current: TerminalAuthorityProjection + + constructor(initial: TerminalAuthorityProjection) { + this.current = initial + } + + subscribeProjection( + actorId: string, + listener: (change: TerminalAuthorityProjectionChange) => void + ): TerminalAuthorityObserverAccess { + this.events.push('subscribe') + this.listener = listener + return Object.freeze({ + role: 'observer', + serviceInstanceId: 'service-1', + accessId: 'observer-1', + actorId + }) + } + + revokeObserver(): void { + this.events.push('revoke') + this.listener = null + } + + snapshotForObserver(): TerminalAuthorityProjection { + this.events.push('snapshot') + return this.current + } + + publish(next: TerminalAuthorityProjection): void { + this.current = next + this.listener?.(Object.freeze({ reason: 'mutation', projection: next })) + } +} + +function harness(initial = projection(0, [])) { + const service = new FakeService(initial) + const changes: TerminalAuthorityTopologyChange[] = [] + const disconnect = vi.fn() + const notificationHandlers = new Set<(params: Record) => void>() + let recoveryRevision = 0 + let clientSnapshotRequests = 0 + let channel: TerminalAuthorityTopologyNamespaceChannel + const clientTransport: SshTerminalAuthorityTopologyTransport = { + onNotificationByMethod: (_method, handler) => { + notificationHandlers.add(handler) + return () => notificationHandlers.delete(handler) + }, + request: (_method, params) => { + clientSnapshotRequests += 1 + return channel.captureSnapshot(String(params.subscriptionId)) + }, + notify: () => true + } + channel = new TerminalAuthorityTopologyNamespaceChannel( + service, + { + recoveryNoticesForNamespace: () => + Object.freeze({ version: 1, revision: recoveryRevision, notices: Object.freeze([]) }) + }, + { + notify: (_clientId, _method, change) => { + changes.push(change) + for (const handler of notificationHandlers) { + handler(change as unknown as Record) + } + return true + }, + disconnect + }, + (empty) => empty.dispose(), + (error) => { + throw error + }, + (() => { + let id = 0 + return () => `stream-${++id}` + })() + ) + return { + service, + channel, + changes, + disconnect, + clientTransport, + clientSnapshotRequests: () => clientSnapshotRequests, + setRecoveryRevision: (revision: number) => { + recoveryRevision = revision + } + } +} + +describe('terminal authority topology namespace channel', () => { + it('installs the service listener before capturing an allocation-free snapshot', async () => { + const h = harness(projection(1, [pane('pane-1', 'generation-1', 1)], [{ secret: true }])) + const subscription = h.channel.subscribe(7, 'subscription-1') + + const snapshot = await h.channel.captureSnapshot('subscription-1') + + expect(h.service.events.slice(0, 2)).toEqual(['subscribe', 'snapshot']) + expect(snapshot.panes).toHaveLength(1) + expect(JSON.stringify(snapshot)).not.toContain('allocations') + expect(JSON.stringify(snapshot)).not.toContain('secret') + subscription.dispose() + }) + + it('does no diff or transport work synchronously in the authority mutation callback', async () => { + const h = harness() + h.channel.subscribe(7, 'subscription-1') + await h.channel.captureSnapshot('subscription-1') + + h.service.publish(projection(1, [pane('pane-1', 'generation-1', 1)])) + expect(h.changes).toHaveLength(0) + await h.channel.captureSnapshot('subscription-1') + + expect(h.changes).toHaveLength(1) + expect(h.changes[0]).toMatchObject({ + baseAuthorityRevision: 0, + authorityRevision: 1, + changeSequence: 1 + }) + }) + + it('keeps supersede changes atomic and an open unbound pane valid', async () => { + const original = pane('pane-1', 'generation-1', 1) + const h = harness(projection(1, [original])) + h.channel.subscribe(7, 'subscription-1') + await h.channel.captureSnapshot('subscription-1') + const superseded = pane('pane-1', 'generation-1', 2, { + status: 'superseded', + binding: null, + ownerStatus: null + }) + const unbound = pane('pane-1', 'generation-2', 2, { + binding: null, + lastBinding: null, + ownerStatus: null + }) + + h.service.publish(projection(2, [superseded, unbound])) + await h.channel.captureSnapshot('subscription-1') + + expect(h.changes).toHaveLength(1) + expect(h.changes[0].paneChanges).toHaveLength(2) + expect(JSON.stringify(h.changes[0])).not.toContain('allocation') + expect(JSON.stringify(h.changes[0])).not.toContain('spawn') + }) + + it('publishes derived owner status changes even at the same authority revision', async () => { + const h = harness(projection(1, [pane('pane-1', 'generation-1', 1)])) + h.channel.subscribe(7, 'subscription-1') + await h.channel.captureSnapshot('subscription-1') + + h.service.publish( + projection(1, [pane('pane-1', 'generation-1', 1, { ownerStatus: 'owner-unreachable' })]) + ) + await h.channel.captureSnapshot('subscription-1') + + expect(h.changes[0]).toMatchObject({ + baseAuthorityRevision: 1, + authorityRevision: 1, + paneChanges: [{ kind: 'upsert', pane: { ownerStatus: 'owner-unreachable' } }] + }) + }) + + it('forces an exact resnapshot instead of exposing a partial multi-batch revision', async () => { + const h = harness() + h.channel.subscribe(7, 'subscription-1') + const authoritativeStates: TerminalAuthorityTopologySnapshot[] = [] + const statuses: SshTerminalAuthorityTopologyClientStatus[] = [] + const client = new SshTerminalAuthorityTopologyClient({ + transport: h.clientTransport, + capabilityGrant: { version: 1 }, + subscriptionId: 'subscription-1', + namespace, + onStatusChange: (status) => statuses.push(status), + onAuthoritativeState: (snapshot) => authoritativeStates.push(snapshot) + }) + await client.start() + const panes = Array.from({ length: 1_025 }, (_, index) => + pane(`pane-${index}`, `generation-${index}`, 2, { + binding: null, + lastBinding: null, + ownerStatus: null + }) + ) + + h.service.publish(projection(2, panes)) + await h.channel.captureSnapshot('flush-subscription') + await vi.waitFor(() => expect(client.authoritativeState()?.panes).toHaveLength(1_025)) + + expect(h.changes).toHaveLength(1) + expect(h.changes[0]).toMatchObject({ + changeSequence: 2, + baseAuthorityRevision: 0, + authorityRevision: 2 + }) + expect(h.changes[0].paneChanges).toHaveLength(1) + expect(statuses).toContainEqual( + expect.objectContaining({ kind: 'synchronizing', reason: 'sequence-gap' }) + ) + expect(h.clientSnapshotRequests()).toBe(2) + expect(authoritativeStates.map((snapshot) => snapshot.panes.length)).toEqual([0, 1_025]) + client.dispose() + }) + + it('publishes namespace recovery revisions without inventing pane changes', async () => { + const h = harness() + h.channel.subscribe(7, 'subscription-1') + await h.channel.captureSnapshot('subscription-1') + h.setRecoveryRevision(3) + + h.service.publish(projection(0, [])) + await h.channel.captureSnapshot('subscription-1') + + expect(h.changes[0]).toMatchObject({ + paneChanges: [], + namespaceRecoveryNotices: { revision: 3 } + }) + }) + + it('fences a writer-epoch change by disconnecting instead of continuing a lineage', async () => { + const h = harness() + h.channel.subscribe(7, 'subscription-1') + await h.channel.captureSnapshot('subscription-1') + const restarted = { ...projection(0, []), writerEpoch: 2 } + + h.service.publish(Object.freeze(restarted)) + await h.channel.captureSnapshot('subscription-1') + + expect(h.disconnect).toHaveBeenCalledWith( + 7, + expect.objectContaining({ message: 'terminal_authority_topology_writer_epoch_changed' }) + ) + expect(h.changes).toHaveLength(0) + }) +}) diff --git a/src/relay/terminal-authority-topology-namespace-channel.ts b/src/relay/terminal-authority-topology-namespace-channel.ts new file mode 100644 index 00000000000..707eb7cf993 --- /dev/null +++ b/src/relay/terminal-authority-topology-namespace-channel.ts @@ -0,0 +1,317 @@ +import { randomUUID } from 'node:crypto' +import type { TerminalAuthorityObserverAccess } from '../main/session-authority/terminal-session-authority-access' +import type { TerminalAuthorityNamespace } from '../shared/terminal-session-authority-identity' +import type { TerminalAuthorityProjection } from '../shared/terminal-session-authority-mutation' +import { + TERMINAL_AUTHORITY_TOPOLOGY_CHANGED_NOTIFICATION, + type TerminalAuthorityTopologyChange, + type TerminalAuthorityTopologyPaneChange, + type TerminalAuthorityTopologySnapshot +} from '../shared/terminal-authority-topology-stream-contract' +import { + parseTerminalAuthorityTopologyChange, + parseTerminalAuthorityTopologySnapshot +} from '../shared/terminal-authority-topology-stream-validation' +import type { TerminalLegacyRecoveryNoticeProjection } from '../shared/terminal-legacy-cutover' +import type { + TerminalAuthorityTopologyChannelService, + TerminalAuthorityTopologyChannelSubscription, + TerminalAuthorityTopologyChannelTransport, + TerminalAuthorityTopologyRecoverySource +} from './terminal-authority-topology-channel-contract' +import { + createTerminalAuthorityTopologyChangePlan, + terminalAuthorityTopologyGapSignalBatch, + type TerminalAuthorityTopologyChangeBatch +} from './terminal-authority-topology-change-plan' +import { groupAtomicTerminalAuthorityTopologyPaneChanges } from './terminal-authority-topology-pane-diff' + +export type { + TerminalAuthorityTopologyChannelService, + TerminalAuthorityTopologyChannelSubscription, + TerminalAuthorityTopologyChannelTransport, + TerminalAuthorityTopologyRecoverySource +} from './terminal-authority-topology-channel-contract' + +type Subscription = { + clientId: number + subscriptionId: string + disposed: boolean +} + +export class TerminalAuthorityTopologyNamespaceChannel { + private readonly subscriptions = new Set() + private readonly observer: TerminalAuthorityObserverAccess + private streamIncarnationId: string + private projection: TerminalAuthorityProjection | null = null + private recoveryNotices: TerminalLegacyRecoveryNoticeProjection | null = null + private operationQueue: Promise = Promise.resolve() + private pendingProjection: TerminalAuthorityProjection | null = null + private drainQueued = false + private changeSequence = 0 + private disposed = false + + constructor( + private readonly service: TerminalAuthorityTopologyChannelService, + private readonly recoverySource: TerminalAuthorityTopologyRecoverySource, + private readonly transport: TerminalAuthorityTopologyChannelTransport, + private readonly onEmpty: (channel: TerminalAuthorityTopologyNamespaceChannel) => void, + private readonly onFailure: (error: Error) => void, + private readonly createId: () => string = randomUUID + ) { + this.streamIncarnationId = createId() + this.observer = service.subscribeProjection( + `terminal-authority-topology:${this.streamIncarnationId}`, + (change) => this.acceptProjection(change.projection) + ) + } + + get namespace(): TerminalAuthorityNamespace { + return this.service.namespace + } + + get subscriptionCount(): number { + return this.subscriptions.size + } + + subscribe( + clientId: number, + subscriptionId: string + ): TerminalAuthorityTopologyChannelSubscription { + this.assertActive() + const subscription: Subscription = { clientId, subscriptionId, disposed: false } + this.subscriptions.add(subscription) + return Object.freeze({ dispose: () => this.removeSubscription(subscription) }) + } + + captureSnapshot(subscriptionId: string): Promise { + return this.enqueue(() => { + const projection = this.service.snapshotForObserver(this.observer) + this.pendingProjection = null + this.applyProjection(projection) + const current = this.projection + const recoveryNotices = this.recoveryNotices + if (!current || !recoveryNotices) { + throw new Error('terminal_authority_topology_snapshot_unavailable') + } + return parseTerminalAuthorityTopologySnapshot({ + protocolVersion: 1, + subscriptionId, + streamIncarnationId: this.streamIncarnationId, + namespace: current.namespace, + writerEpoch: current.writerEpoch, + authorityRevision: current.revision, + appliedChangeSequence: this.changeSequence, + panes: current.panes, + namespaceRecoveryNotices: recoveryNotices + }) + }) + } + + dispose(): void { + if (this.disposed) { + return + } + this.disposed = true + this.pendingProjection = null + this.subscriptions.clear() + try { + this.service.revokeObserver(this.observer) + } catch (error) { + this.onFailure(error instanceof Error ? error : new Error(String(error))) + } + } + + private acceptProjection(projection: TerminalAuthorityProjection): void { + if (this.disposed) { + return + } + this.pendingProjection = projection + if (this.drainQueued) { + return + } + this.drainQueued = true + void this.enqueue(() => { + this.drainQueued = false + const pending = this.pendingProjection + this.pendingProjection = null + if (pending) { + this.applyProjection(pending) + } + }).catch((error) => this.disconnectAll(error)) + } + + private applyProjection(next: TerminalAuthorityProjection): void { + const nextRecovery = this.recoverySource.recoveryNoticesForNamespace(next.namespace) + const current = this.projection + if (!current) { + this.projection = next + this.recoveryNotices = nextRecovery + return + } + if (next.writerEpoch !== current.writerEpoch) { + this.streamIncarnationId = this.createId() + this.changeSequence = 0 + this.projection = next + this.recoveryNotices = nextRecovery + this.disconnectAll(new Error('terminal_authority_topology_writer_epoch_changed')) + return + } + if (next.revision < current.revision) { + this.disconnectAll(new Error('terminal_authority_topology_projection_regressed')) + return + } + const recoveryChanged = + !this.recoveryNotices || JSON.stringify(this.recoveryNotices) !== JSON.stringify(nextRecovery) + const groups = groupAtomicTerminalAuthorityTopologyPaneChanges(current.panes, next.panes) + if (groups.length === 0 && !recoveryChanged) { + this.projection = next + return + } + const plannedSequenceCeiling = this.changeSequence + groups.length + 1 + const batchFits = this.batchFits.bind(this, current, next, plannedSequenceCeiling) + const batches = createTerminalAuthorityTopologyChangePlan( + groups, + recoveryChanged ? nextRecovery : null, + batchFits + ) + if (!batches) { + this.projection = next + this.recoveryNotices = nextRecovery + this.disconnectAll(new Error('terminal_authority_topology_change_capacity')) + return + } + this.projection = next + this.recoveryNotices = nextRecovery + if (batches.length > 1) { + this.changeSequence += batches.length + this.publishBatch(current, next, 0, terminalAuthorityTopologyGapSignalBatch(batches)) + return + } + this.changeSequence += 1 + this.publishBatch(current, next, 0, batches[0]) + } + + private batchFits( + current: TerminalAuthorityProjection, + next: TerminalAuthorityProjection, + changeSequence: number, + batchIndex: number, + paneChanges: readonly TerminalAuthorityTopologyPaneChange[], + recoveryNotices?: TerminalLegacyRecoveryNoticeProjection + ): boolean { + try { + for (const subscription of this.publicationSubscriptions()) { + this.buildChange( + subscription, + current, + next, + batchIndex, + changeSequence, + paneChanges, + recoveryNotices + ) + } + return this.subscriptions.size > 0 + } catch { + return false + } + } + + private publishBatch( + current: TerminalAuthorityProjection, + next: TerminalAuthorityProjection, + batchIndex: number, + batch: TerminalAuthorityTopologyChangeBatch + ): void { + for (const subscription of this.publicationSubscriptions()) { + const change = this.buildChange( + subscription, + current, + next, + batchIndex, + this.changeSequence, + batch.paneChanges, + batch.recoveryNotices + ) + if ( + !this.transport.notify( + subscription.clientId, + TERMINAL_AUTHORITY_TOPOLOGY_CHANGED_NOTIFICATION, + change + ) + ) { + this.transport.disconnect( + subscription.clientId, + new Error('terminal_authority_topology_notification_rejected') + ) + } + } + } + + private buildChange( + subscription: Subscription, + current: TerminalAuthorityProjection, + next: TerminalAuthorityProjection, + batchIndex: number, + changeSequence: number, + paneChanges: readonly TerminalAuthorityTopologyPaneChange[], + recoveryNotices?: TerminalLegacyRecoveryNoticeProjection + ): TerminalAuthorityTopologyChange { + return parseTerminalAuthorityTopologyChange({ + protocolVersion: 1, + subscriptionId: subscription.subscriptionId, + streamIncarnationId: this.streamIncarnationId, + namespace: next.namespace, + writerEpoch: next.writerEpoch, + baseAuthorityRevision: batchIndex === 0 ? current.revision : next.revision, + authorityRevision: next.revision, + changeSequence, + paneChanges, + ...(recoveryNotices ? { namespaceRecoveryNotices: recoveryNotices } : {}) + }) + } + + private disconnectAll(error: unknown): void { + const normalized = error instanceof Error ? error : new Error(String(error)) + const clients = new Set([...this.subscriptions].map(({ clientId }) => clientId)) + for (const clientId of clients) { + this.transport.disconnect(clientId, normalized) + } + } + + private publicationSubscriptions(): readonly Subscription[] { + const unique = new Map() + for (const subscription of this.subscriptions) { + unique.set(JSON.stringify([subscription.clientId, subscription.subscriptionId]), subscription) + } + return [...unique.values()] + } + + private removeSubscription(subscription: Subscription): void { + if (subscription.disposed) { + return + } + subscription.disposed = true + this.subscriptions.delete(subscription) + if (this.subscriptions.size === 0) { + this.onEmpty(this) + } + } + + private enqueue(operation: () => T | Promise): Promise { + this.assertActive() + const result = this.operationQueue.then(operation) + this.operationQueue = result.then( + () => undefined, + () => undefined + ) + return result + } + + private assertActive(): void { + if (this.disposed) { + throw new Error('terminal_authority_topology_channel_disposed') + } + } +} diff --git a/src/relay/terminal-authority-topology-pane-diff.ts b/src/relay/terminal-authority-topology-pane-diff.ts new file mode 100644 index 00000000000..dbd4f67b2f2 --- /dev/null +++ b/src/relay/terminal-authority-topology-pane-diff.ts @@ -0,0 +1,150 @@ +import { + sameTerminalBinding, + terminalPaneGenerationKey, + terminalPtyIncarnationKey +} from '../shared/terminal-session-authority-identity' +import type { TerminalPaneAuthorityProjection } from '../shared/terminal-session-authority-mutation' +import type { TerminalAuthorityTopologyPaneChange } from '../shared/terminal-authority-topology-stream-contract' + +type PaneRecord = TerminalPaneAuthorityProjection + +function samePaneRecord(left: PaneRecord, right: PaneRecord): boolean { + return ( + left.paneKey === right.paneKey && + left.paneGenerationId === right.paneGenerationId && + left.status === right.status && + sameTerminalBinding(left.binding, right.binding) && + sameTerminalBinding(left.lastBinding, right.lastBinding) && + left.revision === right.revision && + left.ownerStatus === right.ownerStatus + ) +} + +function changeKey(change: TerminalAuthorityTopologyPaneChange): string { + return terminalPaneGenerationKey(change.pane) +} + +export function diffTerminalAuthorityTopologyPanes( + previous: readonly PaneRecord[], + next: readonly PaneRecord[] +): readonly TerminalAuthorityTopologyPaneChange[] { + const previousByKey = new Map(previous.map((pane) => [terminalPaneGenerationKey(pane), pane])) + const nextByKey = new Map(next.map((pane) => [terminalPaneGenerationKey(pane), pane])) + const changes: TerminalAuthorityTopologyPaneChange[] = [] + for (const [key, pane] of previousByKey) { + if (!nextByKey.has(key)) { + changes.push( + Object.freeze({ + kind: 'remove', + pane: Object.freeze({ paneKey: pane.paneKey, paneGenerationId: pane.paneGenerationId }) + }) + ) + } + } + for (const [key, pane] of nextByKey) { + const current = previousByKey.get(key) + if (!current || !samePaneRecord(current, pane)) { + changes.push(Object.freeze({ kind: 'upsert', pane })) + } + } + return Object.freeze( + changes.sort((left, right) => changeKey(left).localeCompare(changeKey(right))) + ) +} + +class ChangeSets { + private readonly parents: number[] + + constructor(size: number) { + this.parents = Array.from({ length: size }, (_, index) => index) + } + + find(index: number): number { + const parent = this.parents[index] + if (parent === index) { + return index + } + const root = this.find(parent) + this.parents[index] = root + return root + } + + union(left: number, right: number): void { + const leftRoot = this.find(left) + const rightRoot = this.find(right) + if (leftRoot !== rightRoot) { + this.parents[rightRoot] = leftRoot + } + } +} + +function connectByKey( + changes: readonly TerminalAuthorityTopologyPaneChange[], + sets: ChangeSets, + keyFor: (change: TerminalAuthorityTopologyPaneChange) => string | null +): void { + const firstByKey = new Map() + changes.forEach((change, index) => { + const key = keyFor(change) + if (key === null) { + return + } + const first = firstByKey.get(key) + if (first === undefined) { + firstByKey.set(key, index) + } else { + sets.union(first, index) + } + }) +} + +function connectBindingTransfers( + previous: readonly PaneRecord[], + next: readonly PaneRecord[], + changes: readonly TerminalAuthorityTopologyPaneChange[], + sets: ChangeSets +): void { + const changeByPane = new Map(changes.map((change, index) => [changeKey(change), index])) + const previousHolder = new Map() + const nextHolder = new Map() + for (const pane of previous) { + const index = changeByPane.get(terminalPaneGenerationKey(pane)) + if (index !== undefined && pane.binding) { + previousHolder.set(terminalPtyIncarnationKey(pane.binding), index) + } + } + for (const pane of next) { + const index = changeByPane.get(terminalPaneGenerationKey(pane)) + if (index !== undefined && pane.binding) { + nextHolder.set(terminalPtyIncarnationKey(pane.binding), index) + } + } + for (const [binding, previousIndex] of previousHolder) { + const nextIndex = nextHolder.get(binding) + if (nextIndex !== undefined) { + sets.union(previousIndex, nextIndex) + } + } +} + +export function groupAtomicTerminalAuthorityTopologyPaneChanges( + previous: readonly PaneRecord[], + next: readonly PaneRecord[] +): readonly (readonly TerminalAuthorityTopologyPaneChange[])[] { + const changes = diffTerminalAuthorityTopologyPanes(previous, next) + const sets = new ChangeSets(changes.length) + connectByKey(changes, sets, (change) => change.pane.paneKey) + connectBindingTransfers(previous, next, changes, sets) + const groups = new Map() + changes.forEach((change, index) => { + const root = sets.find(index) + const group = groups.get(root) ?? [] + group.push(change) + groups.set(root, group) + }) + return Object.freeze( + [...groups.values()] + .map((group) => Object.freeze(group)) + .sort((left, right) => changeKey(left[0]).localeCompare(changeKey(right[0]))) + ) +} diff --git a/src/relay/terminal-authority-topology-publisher.test.ts b/src/relay/terminal-authority-topology-publisher.test.ts new file mode 100644 index 00000000000..3f52b13e519 --- /dev/null +++ b/src/relay/terminal-authority-topology-publisher.test.ts @@ -0,0 +1,307 @@ +import { describe, expect, it, vi } from 'vitest' +import type { TerminalAuthorityObserverAccess } from '../main/session-authority/terminal-session-authority-access' +import type { TerminalAuthorityProjectionChange } from '../main/session-authority/terminal-session-authority-service-contract' +import type { TerminalAuthorityNamespace } from '../shared/terminal-session-authority-identity' +import type { TerminalAuthorityProjection } from '../shared/terminal-session-authority-mutation' +import { + TERMINAL_AUTHORITY_TOPOLOGY_CHANGED_NOTIFICATION, + TERMINAL_AUTHORITY_TOPOLOGY_SNAPSHOT_METHOD, + TERMINAL_AUTHORITY_TOPOLOGY_UNSUBSCRIBE_NOTIFICATION +} from '../shared/terminal-authority-topology-stream-contract' +import type { RelayDispatcher, RequestContext } from './dispatcher' +import { TerminalAuthorityTopologyPublisher } from './terminal-authority-topology-publisher' + +type RequestHandler = (params: Record, context: RequestContext) => Promise +type NotificationHandler = (params: Record, context: RequestContext) => void + +function projection( + namespace: TerminalAuthorityNamespace, + revision = 0, + panes: TerminalAuthorityProjection['panes'] = [], + allocations: TerminalAuthorityProjection['allocations'] = [] +): TerminalAuthorityProjection { + return Object.freeze({ + namespace, + writerEpoch: 1, + revision, + panes: Object.freeze([...panes]), + allocations: Object.freeze([...allocations]) + }) +} + +function unboundPane(revision: number): TerminalAuthorityProjection['panes'][number] { + return Object.freeze({ + paneKey: 'pane-1', + paneGenerationId: 'generation-1', + status: 'open', + binding: null, + lastBinding: null, + revision, + ownerStatus: null + }) +} + +class FakeService { + readonly events: string[] = [] + private listener: ((change: TerminalAuthorityProjectionChange) => void) | null = null + private current: TerminalAuthorityProjection + + constructor(readonly namespace: TerminalAuthorityNamespace) { + this.current = projection(namespace) + } + + subscribeProjection( + actorId: string, + listener: (change: TerminalAuthorityProjectionChange) => void + ): TerminalAuthorityObserverAccess { + this.events.push('subscribe') + this.listener = listener + return Object.freeze({ + role: 'observer', + serviceInstanceId: this.namespace.namespaceId, + accessId: `observer-${this.namespace.namespaceId}`, + actorId + }) + } + + revokeObserver(): void { + this.events.push('revoke') + this.listener = null + } + + snapshotForObserver(): TerminalAuthorityProjection { + this.events.push('snapshot') + return this.current + } + + publish(next: TerminalAuthorityProjection): void { + this.current = next + this.listener?.(Object.freeze({ reason: 'mutation', projection: next })) + } +} + +function harness() { + const requests = new Map() + const notifications = new Map() + const detachListeners = new Set<(clientId: number) => void>() + const disposeListeners = new Set<() => void>() + const published: { clientId: number; method: string; params: Record }[] = [] + const dispatcher = { + onRequest: vi.fn((method: string, handler: RequestHandler) => requests.set(method, handler)), + onNotification: vi.fn((method: string, handler: NotificationHandler) => + notifications.set(method, handler) + ), + onClientDetached: vi.fn((listener: (clientId: number) => void) => { + detachListeners.add(listener) + return () => detachListeners.delete(listener) + }), + onDisposed: vi.fn((listener: () => void) => { + disposeListeners.add(listener) + return () => disposeListeners.delete(listener) + }), + tryNotifyClient: vi.fn((clientId: number, method: string, params: Record) => { + published.push({ clientId, method, params }) + return true + }), + releaseDisplacedClient: vi.fn((clientId: number) => { + for (const listener of detachListeners) { + listener(clientId) + } + }) + } + const services = new Map() + const serviceFor = (namespace: TerminalAuthorityNamespace): FakeService => { + let service = services.get(namespace.namespaceId) + if (!service) { + service = new FakeService(namespace) + services.set(namespace.namespaceId, service) + } + return service + } + const registry = { + openNamespace: vi.fn(async (namespace: TerminalAuthorityNamespace) => serviceFor(namespace)), + legacy: { + recoveryNoticesForNamespace: vi.fn(() => ({ + version: 1 as const, + revision: 0, + notices: Object.freeze([]) + })) + } + } + const onFailure = vi.fn() + const publisher = new TerminalAuthorityTopologyPublisher( + dispatcher as unknown as RelayDispatcher, + registry as never, + onFailure + ) + const context = (clientId: number, overrides: Partial = {}): RequestContext => ({ + clientId, + isStale: () => false, + sessionIdentity: { + principal: 'terminal-authority:host-1', + authenticated: true, + allowSessionOwner: true, + authenticationKind: 'endpoint-credential' + }, + ...overrides + }) + const request = (namespace: TerminalAuthorityNamespace, subscriptionId: string) => ({ + protocolVersion: 1, + subscriptionId, + namespace + }) + return { + requests, + notifications, + dispatcher, + registry, + publisher, + published, + serviceFor, + context, + request, + detach: (clientId: number) => { + for (const listener of detachListeners) { + listener(clientId) + } + }, + disposeDispatcher: () => { + for (const listener of disposeListeners) { + listener() + } + } + } +} + +describe('terminal authority topology publisher', () => { + it('registers only the snapshot and unsubscribe contract and authenticates before opening state', async () => { + const h = harness() + const namespace = { authorityHostId: 'host-1', namespaceId: 'namespace-1' } + const unauthenticated = h.context(7, { + sessionIdentity: { + principal: 'relay-endpoint:build', + authenticated: false, + allowSessionOwner: false, + authenticationKind: 'unproved' + } + }) + + await expect( + h.requests.get(TERMINAL_AUTHORITY_TOPOLOGY_SNAPSHOT_METHOD)?.( + h.request(namespace, 'subscription-1'), + unauthenticated + ) + ).rejects.toThrow('not_authenticated') + + expect(h.registry.openNamespace).not.toHaveBeenCalled() + expect([...h.requests.keys()]).toEqual([TERMINAL_AUTHORITY_TOPOLOGY_SNAPSHOT_METHOD]) + expect([...h.notifications.keys()]).toEqual([ + TERMINAL_AUTHORITY_TOPOLOGY_UNSUBSCRIBE_NOTIFICATION + ]) + }) + + it('subscribes before capture and never exposes authority allocations', async () => { + const h = harness() + const namespace = { authorityHostId: 'host-1', namespaceId: 'namespace-1' } + const service = h.serviceFor(namespace) + service.publish( + projection( + namespace, + 1, + [], + [ + { + allocationId: 'allocation-1', + spawnFingerprint: 'secret' + } as never + ] + ) + ) + + const snapshot = await h.requests.get(TERMINAL_AUTHORITY_TOPOLOGY_SNAPSHOT_METHOD)?.( + h.request(namespace, 'subscription-1'), + h.context(7) + ) + + expect(service.events.slice(0, 2)).toEqual(['subscribe', 'snapshot']) + expect(JSON.stringify(snapshot)).not.toContain('allocations') + expect(JSON.stringify(snapshot)).not.toContain('spawnFingerprint') + }) + + it('replaces a stable subscription ID without leaking observers or duplicate changes', async () => { + const h = harness() + const namespace = { authorityHostId: 'host-1', namespaceId: 'namespace-1' } + const params = h.request(namespace, 'subscription-1') + const handler = h.requests.get(TERMINAL_AUTHORITY_TOPOLOGY_SNAPSHOT_METHOD)! + await handler(params, h.context(7)) + const service = h.serviceFor(namespace) + service.publish(projection(namespace, 1, [unboundPane(1)])) + + await handler(params, h.context(7)) + h.notifications.get(TERMINAL_AUTHORITY_TOPOLOGY_UNSUBSCRIBE_NOTIFICATION)?.( + params, + h.context(7) + ) + + expect(h.published).toHaveLength(1) + expect(service.events.filter((event) => event === 'subscribe')).toHaveLength(1) + expect(service.events.filter((event) => event === 'revoke')).toHaveLength(1) + }) + + it('isolates namespace changes and releases every subscription on client detach', async () => { + const h = harness() + const namespaceA = { authorityHostId: 'host-1', namespaceId: 'namespace-a' } + const namespaceB = { authorityHostId: 'host-1', namespaceId: 'namespace-b' } + const handler = h.requests.get(TERMINAL_AUTHORITY_TOPOLOGY_SNAPSHOT_METHOD)! + await handler(h.request(namespaceA, 'subscription-a'), h.context(7)) + await handler(h.request(namespaceB, 'subscription-b'), h.context(8)) + h.serviceFor(namespaceA).publish(projection(namespaceA, 1, [unboundPane(1)])) + await handler(h.request(namespaceA, 'subscription-a'), h.context(7)) + + expect(h.published).toEqual([ + expect.objectContaining({ + clientId: 7, + method: TERMINAL_AUTHORITY_TOPOLOGY_CHANGED_NOTIFICATION, + params: expect.objectContaining({ namespace: namespaceA, changeSequence: 1 }) + }) + ]) + h.detach(7) + h.detach(8) + expect(h.serviceFor(namespaceA).events).toContain('revoke') + expect(h.serviceFor(namespaceB).events).toContain('revoke') + }) + + it('revokes pending resources when a snapshot request becomes stale', async () => { + const h = harness() + const namespace = { authorityHostId: 'host-1', namespaceId: 'namespace-1' } + let stale = false + const context = h.context(7, { isStale: () => stale }) + h.registry.openNamespace.mockImplementationOnce(async (value) => { + stale = true + return h.serviceFor(value) + }) + + await expect( + h.requests.get(TERMINAL_AUTHORITY_TOPOLOGY_SNAPSHOT_METHOD)?.( + h.request(namespace, 'subscription-1'), + context + ) + ).rejects.toThrow('request_stale') + + expect(h.serviceFor(namespace).events).toEqual(['subscribe', 'revoke']) + }) + + it('disposes channels before the dispatcher finishes teardown', async () => { + const h = harness() + const namespace = { authorityHostId: 'host-1', namespaceId: 'namespace-1' } + await h.requests.get(TERMINAL_AUTHORITY_TOPOLOGY_SNAPSHOT_METHOD)?.( + h.request(namespace, 'subscription-1'), + h.context(7) + ) + + h.disposeDispatcher() + + expect(h.serviceFor(namespace).events).toContain('revoke') + expect(h.publisher.dispose()).toBeUndefined() + }) +}) diff --git a/src/relay/terminal-authority-topology-publisher.ts b/src/relay/terminal-authority-topology-publisher.ts new file mode 100644 index 00000000000..8f861229beb --- /dev/null +++ b/src/relay/terminal-authority-topology-publisher.ts @@ -0,0 +1,223 @@ +import type { TerminalSessionAuthorityService } from '../main/session-authority/terminal-session-authority-service' +import type { TerminalLegacyRecoveryNoticeProjection } from '../shared/terminal-legacy-cutover' +import type { TerminalAuthorityNamespace } from '../shared/terminal-session-authority-identity' +import { + TERMINAL_AUTHORITY_TOPOLOGY_SNAPSHOT_METHOD, + TERMINAL_AUTHORITY_TOPOLOGY_UNSUBSCRIBE_NOTIFICATION +} from '../shared/terminal-authority-topology-stream-contract' +import { TerminalAuthorityTopologySubscriptionRegistry } from '../shared/terminal-authority-topology-subscription-registry' +import { parseTerminalAuthorityTopologySnapshotRequest } from '../shared/terminal-authority-topology-stream-validation' +import type { RelayDispatcher, RequestContext } from './dispatcher' +import { + type TerminalAuthorityTopologyChannelSubscription, + TerminalAuthorityTopologyNamespaceChannel +} from './terminal-authority-topology-namespace-channel' +import { assertAuthenticatedTerminalAuthorityControl } from './terminal-authority-control-protocol' + +export type TerminalAuthorityTopologyPublisherRegistry = Readonly<{ + openNamespace: (namespace: TerminalAuthorityNamespace) => Promise + legacy: Readonly<{ + recoveryNoticesForNamespace: ( + namespace: TerminalAuthorityNamespace + ) => TerminalLegacyRecoveryNoticeProjection + }> +}> + +type ChannelEntry = { + promise: Promise + channel?: TerminalAuthorityTopologyNamespaceChannel +} + +function namespaceKey(namespace: TerminalAuthorityNamespace): string { + return JSON.stringify([namespace.authorityHostId, namespace.namespaceId]) +} + +export class TerminalAuthorityTopologyPublisher { + private readonly clientSubscriptions = new Map< + number, + TerminalAuthorityTopologySubscriptionRegistry + >() + private readonly channels = new Map() + private readonly removeDetachListener: () => void + private readonly removeDisposeListener: () => void + private disposed = false + + constructor( + private readonly dispatcher: RelayDispatcher, + private readonly registry: TerminalAuthorityTopologyPublisherRegistry, + private readonly onFailure: (error: Error) => void + ) { + dispatcher.onRequest(TERMINAL_AUTHORITY_TOPOLOGY_SNAPSHOT_METHOD, (params, context) => + this.snapshot(params, context) + ) + dispatcher.onNotification( + TERMINAL_AUTHORITY_TOPOLOGY_UNSUBSCRIBE_NOTIFICATION, + (params, context) => this.unsubscribe(params, context) + ) + this.removeDetachListener = dispatcher.onClientDetached((clientId) => + this.releaseClient(clientId) + ) + this.removeDisposeListener = dispatcher.onDisposed(() => this.dispose()) + } + + dispose(): void { + if (this.disposed) { + return + } + this.disposed = true + this.removeDetachListener() + this.removeDisposeListener() + for (const clientId of this.clientSubscriptions.keys()) { + this.releaseClient(clientId) + } + for (const entry of this.channels.values()) { + entry.channel?.dispose() + if (!entry.channel) { + void entry.promise.then((channel) => channel.dispose()).catch(() => undefined) + } + } + this.channels.clear() + } + + private async snapshot( + params: Record, + context: RequestContext + ): Promise { + this.assertRequest(context) + const request = parseTerminalAuthorityTopologySnapshotRequest(params) + const channel = await this.channelFor(request.namespace) + try { + this.assertRequest(context) + } catch (error) { + this.releaseUnusedChannel(request.namespace, channel) + throw error + } + const subscription = channel.subscribe(context.clientId, request.subscriptionId) + try { + const snapshot = await channel.captureSnapshot(request.subscriptionId) + this.assertRequest(context) + const subscriptions = this.subscriptionsForClient(context.clientId) + const replaced = subscriptions.upsert(request, subscription) + replaced?.dispose() + return snapshot + } catch (error) { + subscription.dispose() + throw error + } + } + + private unsubscribe(params: Record, context: RequestContext): void { + if (this.disposed) { + return + } + assertAuthenticatedTerminalAuthorityControl(context) + const request = parseTerminalAuthorityTopologySnapshotRequest(params) + const subscriptions = this.clientSubscriptions.get(context.clientId) + subscriptions?.remove(request)?.dispose() + if (subscriptions?.size === 0) { + this.clientSubscriptions.delete(context.clientId) + } + } + + private channelFor( + namespace: TerminalAuthorityNamespace + ): Promise { + const key = namespaceKey(namespace) + const current = this.channels.get(key) + if (current) { + return current.promise + } + const entry = {} as ChannelEntry + entry.promise = this.registry + .openNamespace(namespace) + .then((service) => { + if (this.disposed) { + throw new Error('terminal_authority_topology_publisher_disposed') + } + const channel = new TerminalAuthorityTopologyNamespaceChannel( + service, + { + recoveryNoticesForNamespace: (value) => + this.registry.legacy.recoveryNoticesForNamespace(value) + }, + { + notify: (clientId, method, change) => + this.dispatcher.tryNotifyClient(clientId, method, { ...change }), + disconnect: (clientId) => this.dispatcher.releaseDisplacedClient(clientId) + }, + (empty) => this.releaseChannel(key, entry, empty), + this.onFailure + ) + entry.channel = channel + return channel + }) + .catch((error) => { + if (this.channels.get(key) === entry) { + this.channels.delete(key) + } + throw error + }) + this.channels.set(key, entry) + return entry.promise + } + + private releaseChannel( + key: string, + entry: ChannelEntry, + channel: TerminalAuthorityTopologyNamespaceChannel + ): void { + if (this.channels.get(key) !== entry || entry.channel !== channel) { + return + } + this.channels.delete(key) + channel.dispose() + } + + private releaseUnusedChannel( + namespace: TerminalAuthorityNamespace, + channel: TerminalAuthorityTopologyNamespaceChannel + ): void { + if (channel.subscriptionCount !== 0) { + return + } + const key = namespaceKey(namespace) + const entry = this.channels.get(key) + if (entry?.channel === channel) { + this.releaseChannel(key, entry, channel) + } + } + + private subscriptionsForClient( + clientId: number + ): TerminalAuthorityTopologySubscriptionRegistry { + const current = this.clientSubscriptions.get(clientId) + if (current) { + return current + } + const subscriptions = + new TerminalAuthorityTopologySubscriptionRegistry() + this.clientSubscriptions.set(clientId, subscriptions) + return subscriptions + } + + private releaseClient(clientId: number): void { + const subscriptions = this.clientSubscriptions.get(clientId) + if (!subscriptions) { + return + } + this.clientSubscriptions.delete(clientId) + for (const subscription of subscriptions.clear()) { + subscription.dispose() + } + } + + private assertRequest(context: RequestContext): void { + if (this.disposed) { + throw new Error('terminal_authority_topology_publisher_disposed') + } + assertAuthenticatedTerminalAuthorityControl(context) + if (context.isStale() || context.signal?.aborted) { + throw new Error('terminal_authority_topology_request_stale') + } + } +} diff --git a/src/relay/terminal-session-authority-hot-path.test.ts b/src/relay/terminal-session-authority-hot-path.test.ts new file mode 100644 index 00000000000..1c0bc29d797 --- /dev/null +++ b/src/relay/terminal-session-authority-hot-path.test.ts @@ -0,0 +1,265 @@ +import { performance } from 'node:perf_hooks' +import { mkdtempSync, rmSync, statSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockPtySpawn } = vi.hoisted(() => ({ mockPtySpawn: vi.fn() })) + +vi.mock('node-pty', () => ({ spawn: mockPtySpawn })) +vi.mock('../main/pty/posix-pty-process-groups', () => ({ + forceKillPosixPtyProcessGroups: vi.fn((_pid: number, fallback: () => void) => fallback()) +})) + +import { terminalSessionAuthorityNamespaceDirectory } from '../main/session-authority/terminal-session-authority-namespace-directory' +import { TERMINAL_AUTHORITY_LOG_FILE } from '../main/session-authority/terminal-session-authority-record-files' +import { TerminalSessionAuthorityRegistry } from '../main/session-authority/terminal-session-authority-registry' +import { terminalAuthorityWorkspaceLocator } from '../main/session-authority/terminal-session-authority-workspace-locator' +import type { RelayDispatcher } from './dispatcher' +import { PtyHandler, REPLAY_BUFFER_MAX } from './pty-handler' +import { + TerminalSessionAuthorityPtyLifecycle, + type TerminalAuthorityManagedPty +} from '../main/session-authority/terminal-session-authority-pty-lifecycle' +import { admitAuthenticatedPolicyConsumer } from '../main/session-authority/__tests__/authenticated-policy-consumer-admission' + +const HOT_PATH_OPERATIONS = 25_000 +const HOT_PATH_CLIENT_ID = 7 +const PHASE_BUDGET_MS = 2_000 +const HEAP_GROWTH_BUDGET_BYTES = 32 * 1024 * 1024 + +type RequestHandler = ( + params: Record, + context: { clientId: number; isStale: () => boolean } +) => unknown | Promise +type NotificationHandler = (params: Record) => void + +type HotPathHandlerState = { + ptys: Map< + string, + { + authority?: TerminalAuthorityManagedPty + buffered: { read: () => string } + } + > + pendingOutputByPty: Map + pendingExitByPty: Map + pausedOutputPtys: Set + outputFlushTimer: ReturnType | null + lastInputAtByPty: Map + interactiveOutputCharsByPty: Map +} + +const directories: string[] = [] +const handlers: PtyHandler[] = [] +const registries: TerminalSessionAuthorityRegistry[] = [] + +beforeEach(() => mockPtySpawn.mockReset()) + +afterEach(async () => { + await Promise.allSettled( + handlers.splice(0).map((handler) => handler.dispose({ waitForPhysicalExit: false })) + ) + await Promise.allSettled(registries.splice(0).map((registry) => registry.close())) + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } + vi.restoreAllMocks() +}) + +describe('terminal session authority hot paths', () => { + it('keeps 25k reachability, exact-input, and output operations off topology writes', async () => { + const root = freshDirectory() + const registry = await TerminalSessionAuthorityRegistry.open({ + directory: path.join(root, 'authority'), + authorityHostId: 'host-a', + ownerToken: 'owner-token-a', + ownerIncarnationId: 'owner-a', + writerActorId: 'owner-a' + }) + registries.push(registry) + const lifecycle = new TerminalSessionAuthorityPtyLifecycle(registry, 'owner-a') + lifecycle.installHostEffectApplier({ ensureBindingRetired: async () => {} }) + // Admission goes through the real proof handshake: the hot path is only meaningful for a consumer + // that actually holds an authenticated grant. + const admitted = await admitAuthenticatedPolicyConsumer(lifecycle, { + namespace: await lifecycle.resolvePolicyConsumerNamespace(`repo::${root}`), + processIncarnationId: 'hot-path-test', + requestId: 'hot-path-request' + }) + const connection = admitted.session.policyConsumer + const terminal = measuredTerminal() + mockPtySpawn.mockReturnValue(terminal.pty) + const dispatch = measuredDispatcher() + const handler = new PtyHandler(dispatch.dispatcher as unknown as RelayDispatcher, undefined, { + terminalSessionAuthority: lifecycle, + terminalAuthorityExactPtyAccessResolver: { classify: async () => 'current-owner' } + }) + handler.setTerminalAuthorityPolicyConsumerForClient((clientId) => + clientId === HOT_PATH_CLIENT_ID ? connection : null + ) + handlers.push(handler) + + const spawned = (await dispatch.request('pty.spawn', spawnParams(root))) as { + id: string + incarnationId: string + } + const locator = terminalAuthorityWorkspaceLocator(root) + const namespace = registry.namespaceForLocator(locator) + if (!namespace) { + throw new Error('expected authority namespace') + } + const service = await registry.openNamespace(namespace) + const observer = service.observe('hot-path-observer') + const state = handler as unknown as HotPathHandlerState + const managed = state.ptys.get(spawned.id)?.authority + if (!managed) { + throw new Error('expected managed PTY hot-path bindings') + } + const logPath = path.join( + terminalSessionAuthorityNamespaceDirectory(path.join(root, 'authority'), namespace), + TERMINAL_AUTHORITY_LOG_FILE + ) + const revisionBefore = service.snapshotForObserver(observer).revision + const logBytesBefore = statSync(logPath).size + const mutationSpy = vi.spyOn(service, 'mutate') + const heapBefore = process.memoryUsage().heapUsed + + const reachabilityStartedAt = performance.now() + for (let index = 0; index < HOT_PATH_OPERATIONS; index++) { + if (!lifecycle.bindingIsReachable(managed)) { + throw new Error('binding unexpectedly became unreachable') + } + } + const reachabilityMs = performance.now() - reachabilityStartedAt + + const writesBefore = terminal.writeCount + const inputStartedAt = performance.now() + for (let index = 0; index < HOT_PATH_OPERATIONS; index++) { + dispatch.notify('pty.dataExact', { + id: spawned.id, + incarnationId: spawned.incarnationId, + data: 'i' + }) + } + const inputMs = performance.now() - inputStartedAt + + const outputStartedAt = performance.now() + for (let index = 0; index < HOT_PATH_OPERATIONS; index++) { + terminal.emitData('o') + } + const outputMs = performance.now() - outputStartedAt + const heapGrowthBytes = Math.max(0, process.memoryUsage().heapUsed - heapBefore) + + expect(reachabilityMs).toBeLessThan(PHASE_BUDGET_MS) + expect(inputMs).toBeLessThan(PHASE_BUDGET_MS) + expect(outputMs).toBeLessThan(PHASE_BUDGET_MS) + expect(heapGrowthBytes).toBeLessThan(HEAP_GROWTH_BUDGET_BYTES) + expect(terminal.writeCount - writesBefore).toBe(HOT_PATH_OPERATIONS) + expect(dispatch.publishedData).toBe(HOT_PATH_OPERATIONS) + expect(state.pendingOutputByPty.size).toBe(0) + expect(state.pendingExitByPty.size).toBe(0) + expect(state.pausedOutputPtys.size).toBe(0) + expect(state.outputFlushTimer).toBeNull() + expect(state.lastInputAtByPty.size).toBe(1) + expect(state.interactiveOutputCharsByPty.size).toBe(1) + expect(state.ptys.get(spawned.id)?.buffered.read()).toHaveLength(HOT_PATH_OPERATIONS) + expect(HOT_PATH_OPERATIONS).toBeLessThan(REPLAY_BUFFER_MAX) + expect(mutationSpy).not.toHaveBeenCalled() + expect(statSync(logPath).size).toBe(logBytesBefore) + expect(service.snapshotForObserver(observer).revision).toBe(revisionBefore) + + process.stdout.write( + `[terminal-authority-hot-path] ${JSON.stringify({ + operationsPerPhase: HOT_PATH_OPERATIONS, + reachabilityMs: Number(reachabilityMs.toFixed(2)), + inputMs: Number(inputMs.toFixed(2)), + outputMs: Number(outputMs.toFixed(2)), + heapGrowthBytes, + topologyWrites: 0, + pendingOutputQueues: state.pendingOutputByPty.size + })}\n` + ) + }) +}) + +function measuredDispatcher() { + const requests = new Map() + const notifications = new Map() + const result = { + publishedData: 0, + dispatcher: { + onRequest: (method: string, handler: RequestHandler) => requests.set(method, handler), + onNotification: (method: string, handler: NotificationHandler) => + notifications.set(method, handler), + notify: () => {}, + tryNotifyPtyData: () => { + result.publishedData++ + return true + }, + tryNotifyPtyExit: () => true, + legacyRetentionBelowLowWater: true + }, + request: (method: string, params: Record) => + requests.get(method)!(params, { + clientId: HOT_PATH_CLIENT_ID, + isStale: () => false + }), + notify: (method: string, params: Record) => notifications.get(method)!(params) + } + return result +} + +function measuredTerminal() { + let dataListener: ((data: string) => void) | null = null + const result = { + writeCount: 0, + emitData: (data: string) => dataListener?.(data), + pty: { + pid: process.pid, + process: 'shell', + cols: 80, + rows: 24, + onData: (listener: (data: string) => void) => { + dataListener = listener + return { + dispose: () => { + if (dataListener === listener) { + dataListener = null + } + } + } + }, + onExit: () => ({ dispose: () => {} }), + write: () => { + result.writeCount++ + }, + resize: () => {}, + kill: () => {}, + clear: () => {}, + pause: () => {}, + resume: () => {}, + destroy: () => {} + } + } + return result +} + +function spawnParams(root: string): Record { + return { + terminalSessionAuthorityVersion: 1, + paneKey: 'pane-a', + paneGeneration: 1, + worktreeId: `repo::${root}`, + cwd: root, + cols: 80, + rows: 24, + env: { TERM: 'xterm-256color' } + } +} + +function freshDirectory(): string { + const directory = mkdtempSync(path.join(tmpdir(), 'orca-authority-hot-path-')) + directories.push(directory) + return directory +} diff --git a/src/relay/terminal-session-authority-outcome-delivery.test.ts b/src/relay/terminal-session-authority-outcome-delivery.test.ts new file mode 100644 index 00000000000..c01b6120353 --- /dev/null +++ b/src/relay/terminal-session-authority-outcome-delivery.test.ts @@ -0,0 +1,206 @@ +import { describe, expect, it, vi } from 'vitest' +import type { + TerminalAuthorityOutcome, + TerminalAuthoritySemanticOutcome +} from '../shared/terminal-session-authority-mutation' +import type { RelayDispatcher, RequestContext } from './dispatcher' +import type { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter' +import { TerminalSessionAuthorityOutcomeDelivery } from './terminal-session-authority-outcome-delivery' + +describe('TerminalSessionAuthorityOutcomeDelivery', () => { + it('settles outcomes without a terminal exit without publishing a frame', async () => { + const fixture = createFixture() + + await fixture.delivery.publish(outcome([])) + + expect(fixture.publishExit).not.toHaveBeenCalled() + }) + + it('keeps a legacy-only exit unacknowledged after ordered publication', async () => { + const fixture = createFixture() + fixture.delivery.installConsumer((_outcome, _effect, attempt) => { + attempt.markOrderedComplete() + return true + }) + let settled = false + + void fixture.delivery.publish(exitOutcome()).then(() => { + settled = true + }) + await Promise.resolve() + + expect(settled).toBe(false) + expect(fixture.publishExit).not.toHaveBeenCalled() + fixture.delivery.dispose() + }) + + it('settles only an exact ACK from a client that received the negotiated outcome', async () => { + const fixture = createFixture({ capableClients: [7] }) + let identity: unknown + fixture.delivery.installConsumer((_outcome, _effect, attempt) => { + identity = attempt.identity + attempt.markPublished([7]) + attempt.markOrderedComplete() + return true + }) + let settled = false + const published = fixture.delivery.publish(exitOutcome()).then(() => { + settled = true + }) + await Promise.resolve() + + fixture.ack({ authorityOutcome: identity }, 8) + await Promise.resolve() + expect(settled).toBe(false) + + fixture.ack({ authorityOutcome: identity }, 7) + await published + expect(settled).toBe(true) + }) + + it('replays directly to a negotiated owner when no physical consumer remains', async () => { + const fixture = createFixture({ capableClients: [3] }) + const published = fixture.delivery.publish(exitOutcome()) + await Promise.resolve() + const params = fixture.publishExit.mock.calls[0]?.[1] + + expect(params).toMatchObject({ + id: 'pty-1', + code: 7, + incarnationId: 'incarnation-1', + authorityOutcome: { outcomeId: 'outcome-1', sequence: 1 } + }) + fixture.ack({ authorityOutcome: params?.authorityOutcome }, 3) + await published + }) + + it('keeps an unwired semantic outcome replayable', async () => { + const fixture = createFixture() + + await expect(fixture.delivery.publish(semanticOutcome())).rejects.toThrow( + 'semantic outcome delivery is unavailable' + ) + }) +}) + +function semanticOutcome(): TerminalAuthoritySemanticOutcome { + return { + kind: 'semantic', + sequence: 1, + outcomeId: 'semantic-1', + access: { + namespace: { authorityHostId: 'host-1', namespaceId: 'namespace-1' }, + pane: { paneKey: 'pane-1', paneGenerationId: 'generation-1' }, + binding: binding() + }, + producerIncarnationId: 'producer-1', + producerSequence: 1, + fact: { kind: 'bell' }, + appendedAtRevision: 1, + byteLength: 1 + } +} + +function createFixture(options: { capableClients?: readonly number[] } = {}) { + const capableClients = new Set(options.capableClients ?? []) + let ackHandler: ((params: Record, context: RequestContext) => void) | undefined + const publishExit = vi.fn( + ( + _clientId: number, + _params: Record, + onSettled: (result: { ok: true }) => void + ) => { + onSettled({ ok: true }) + return true + } + ) + const dispatcher = { + onNotification: ( + _method: string, + handler: (params: Record, context: RequestContext) => void + ) => { + ackHandler = handler + }, + onLegacyPtyCapacity: () => () => {}, + onDisposed: () => () => {}, + activeClientIds: () => [...capableClients], + tryNotifyPtyExitToClient: publishExit + } as unknown as RelayDispatcher + const session = { + onTerminalAuthorityOutcomeDeliveryClient: () => () => {}, + terminalAuthorityOutcomeDelivery: (clientId: number) => + capableClients.has(clientId) + ? { clientGeneration: clientId * 10, ownerGeneration: clientId * 100 } + : null + } as unknown as SshPtyConsumerSessionAdapter + const delivery = new TerminalSessionAuthorityOutcomeDelivery(dispatcher, session) + return { + delivery, + publishExit, + ack: (params: Record, clientId: number) => + ackHandler?.(params, { clientId } as RequestContext) + } +} + +function exitOutcome(): TerminalAuthorityOutcome { + return outcome([ + { + kind: 'binding-retired', + reason: 'exit', + binding: binding() + }, + { + kind: 'terminal-exited', + binding: binding(), + code: 7, + signal: null + } + ]) +} + +function outcome(effects: TerminalAuthorityOutcome['result']['effects']): TerminalAuthorityOutcome { + const request = { + actorId: 'actor-1', + operationId: 'operation-1', + baseRevision: 1, + outcomeId: 'outcome-1', + change: { + kind: 'exit' as const, + pane: { paneKey: 'pane-1', paneGenerationId: 'generation-1' }, + expected: { paneGenerationId: 'generation-1', binding: binding() }, + exit: { code: 7, signal: null } + } + } + return { + sequence: 1, + outcomeId: request.outcomeId, + request, + result: { + namespace: { authorityHostId: 'host-1', namespaceId: 'namespace-1' }, + actorId: request.actorId, + operationId: request.operationId, + kind: 'exit', + revision: 2, + pane: { + paneKey: 'pane-1', + paneGenerationId: 'generation-1', + status: 'exited', + binding: null, + lastBinding: binding(), + revision: 2 + }, + replacementPane: null, + allocation: null, + effects + }, + byteLength: 1 + } +} + +function binding() { + return { + ownerIncarnationId: 'owner-1', + physicalPtyId: 'pty-1', + ptyIncarnationId: 'incarnation-1' + } +} diff --git a/src/relay/terminal-session-authority-outcome-delivery.ts b/src/relay/terminal-session-authority-outcome-delivery.ts new file mode 100644 index 00000000000..e573be4314e --- /dev/null +++ b/src/relay/terminal-session-authority-outcome-delivery.ts @@ -0,0 +1,295 @@ +import type { + TerminalAuthorityDurableOutcome, + TerminalAuthorityOutcome, + TerminalSessionAuthorityEffect +} from '../shared/terminal-session-authority-mutation' +import { + TERMINAL_AUTHORITY_OUTCOME_ACK_NOTIFICATION, + TERMINAL_AUTHORITY_OUTCOME_DELIVERY_VERSION, + parseTerminalAuthorityOutcomeDeliveryIdentity, + terminalAuthorityOutcomeDeliveryKey, + type TerminalAuthorityOutcomeDeliveryIdentity +} from '../shared/terminal-authority-outcome-delivery' +import type { RelayDispatcher } from './dispatcher' +import type { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter' + +const MAX_PENDING_AUTHORITY_OUTCOMES = 4_096 + +type TerminalExitedEffect = Extract + +type PublishedClient = Readonly<{ + clientGeneration: number + ownerGeneration: number +}> + +type PendingOutcome = { + outcome: TerminalAuthorityOutcome + effect: TerminalExitedEffect + identity: TerminalAuthorityOutcomeDeliveryIdentity + promise: Promise + resolve: () => void + publishedClients: Map + orderedComplete: boolean +} + +export type TerminalAuthorityOutcomeDeliveryAttempt = Readonly<{ + identity: TerminalAuthorityOutcomeDeliveryIdentity + supportsClient: (clientId: number) => boolean + markPublished: (clientIds: readonly number[]) => void + markOrderedComplete: () => void +}> + +export type TerminalAuthorityOutcomePublicationConsumer = ( + outcome: TerminalAuthorityOutcome, + effect: TerminalExitedEffect, + attempt: TerminalAuthorityOutcomeDeliveryAttempt +) => boolean + +export class TerminalSessionAuthorityOutcomeDelivery { + private readonly pending = new Map() + private readonly removeCapacityListener: () => void + private readonly removeClientListener: () => void + private readonly removeDisposeListener: () => void + private consumer: TerminalAuthorityOutcomePublicationConsumer | null = null + private disposed = false + + constructor( + private readonly dispatcher: RelayDispatcher, + private readonly session: SshPtyConsumerSessionAdapter + ) { + dispatcher.onNotification(TERMINAL_AUTHORITY_OUTCOME_ACK_NOTIFICATION, (params, context) => { + this.acknowledge(context.clientId, params.authorityOutcome ?? params) + }) + this.removeCapacityListener = dispatcher.onLegacyPtyCapacity(() => this.retryStandalone()) + this.removeClientListener = session.onTerminalAuthorityOutcomeDeliveryClient(() => + this.retryStandalone() + ) + this.removeDisposeListener = dispatcher.onDisposed(() => this.dispose()) + } + + installConsumer(consumer: TerminalAuthorityOutcomePublicationConsumer): () => void { + if (this.consumer) { + throw new Error('terminal authority outcome publication consumer is already installed') + } + this.consumer = consumer + return () => { + if (this.consumer === consumer) { + this.consumer = null + } + } + } + + async publish(outcome: TerminalAuthorityDurableOutcome): Promise { + this.assertOpen() + if (outcome.kind === 'semantic') { + throw new Error('terminal authority semantic outcome delivery is unavailable') + } + const effect = terminalExitedEffect(outcome) + if (!effect) { + return + } + const identity = outcomeDeliveryIdentity(outcome, effect) + const key = terminalAuthorityOutcomeDeliveryKey(identity) + const current = this.pending.get(key) + if (current) { + await current.promise + return + } + if (this.pending.size >= MAX_PENDING_AUTHORITY_OUTCOMES) { + throw new Error('terminal authority outcome delivery capacity exceeded') + } + const pending = createPendingOutcome(outcome, effect, identity) + this.pending.set(key, pending) + const attempt = this.attemptFor(key, pending) + let ordered = false + try { + ordered = this.consumer?.(outcome, effect, attempt) ?? false + } catch (error) { + this.pending.delete(key) + throw error + } + if (!ordered) { + pending.orderedComplete = true + this.publishStandalone(pending) + } + await pending.promise + } + + dispose(): void { + if (this.disposed) { + return + } + this.disposed = true + this.removeCapacityListener() + this.removeClientListener() + this.removeDisposeListener() + this.consumer = null + this.pending.clear() + } + + private attemptFor( + key: string, + pending: PendingOutcome + ): TerminalAuthorityOutcomeDeliveryAttempt { + return Object.freeze({ + identity: pending.identity, + supportsClient: (clientId) => + this.session.terminalAuthorityOutcomeDelivery(clientId) !== null, + markPublished: (clientIds) => { + if (this.pending.get(key) !== pending) { + return + } + for (const clientId of clientIds) { + const delivery = this.session.terminalAuthorityOutcomeDelivery(clientId) + if (delivery) { + pending.publishedClients.set(clientId, delivery) + } + } + }, + markOrderedComplete: () => { + if (this.pending.get(key) !== pending) { + return + } + pending.orderedComplete = true + this.publishStandalone(pending) + } + }) + } + + private acknowledge(clientId: number, rawIdentity: unknown): void { + if (this.disposed) { + return + } + const identity = parseTerminalAuthorityOutcomeDeliveryIdentity(rawIdentity) + if (!identity) { + return + } + const key = terminalAuthorityOutcomeDeliveryKey(identity) + const pending = this.pending.get(key) + const published = pending?.publishedClients.get(clientId) + const current = this.session.terminalAuthorityOutcomeDelivery(clientId) + if ( + !pending || + !published || + !current || + published.clientGeneration !== current.clientGeneration || + published.ownerGeneration !== current.ownerGeneration + ) { + return + } + this.pending.delete(key) + pending.resolve() + } + + private retryStandalone(): void { + if (this.disposed) { + return + } + for (const pending of this.pending.values()) { + if (pending.orderedComplete) { + this.publishStandalone(pending) + } + } + } + + private publishStandalone(pending: PendingOutcome): void { + const params = outcomeExitParams(pending.effect, pending.identity) + for (const clientId of this.dispatcher.activeClientIds()) { + const delivery = this.session.terminalAuthorityOutcomeDelivery(clientId) + const published = pending.publishedClients.get(clientId) + if ( + !delivery || + (published?.clientGeneration === delivery.clientGeneration && + published.ownerGeneration === delivery.ownerGeneration) + ) { + continue + } + let failedSynchronously = false + const admitted = this.dispatcher.tryNotifyPtyExitToClient(clientId, params, (settlement) => { + if (settlement.ok) { + return + } + failedSynchronously = true + const current = pending.publishedClients.get(clientId) + if ( + current?.clientGeneration === delivery.clientGeneration && + current.ownerGeneration === delivery.ownerGeneration + ) { + pending.publishedClients.delete(clientId) + } + }) + if (admitted && !failedSynchronously) { + pending.publishedClients.set(clientId, delivery) + } + } + } + + private assertOpen(): void { + if (this.disposed) { + throw new Error('terminal authority outcome delivery is disposed') + } + } +} + +function createPendingOutcome( + outcome: TerminalAuthorityOutcome, + effect: TerminalExitedEffect, + identity: TerminalAuthorityOutcomeDeliveryIdentity +): PendingOutcome { + let resolve!: () => void + const promise = new Promise((accept) => { + resolve = accept + }) + return { + outcome, + effect, + identity, + promise, + resolve, + publishedClients: new Map(), + orderedComplete: false + } +} + +function terminalExitedEffect(outcome: TerminalAuthorityOutcome): TerminalExitedEffect | null { + const effects = outcome.result.effects.filter( + (effect): effect is TerminalExitedEffect => effect.kind === 'terminal-exited' + ) + if (effects.length > 1) { + throw new Error('terminal authority outcome has multiple terminal exit effects') + } + return effects[0] ?? null +} + +function outcomeDeliveryIdentity( + outcome: TerminalAuthorityOutcome, + effect: TerminalExitedEffect +): TerminalAuthorityOutcomeDeliveryIdentity { + const parsed = parseTerminalAuthorityOutcomeDeliveryIdentity({ + version: TERMINAL_AUTHORITY_OUTCOME_DELIVERY_VERSION, + namespace: outcome.result.namespace, + pane: { + paneKey: outcome.result.pane.paneKey, + paneGenerationId: outcome.result.pane.paneGenerationId + }, + binding: effect.binding, + outcomeId: outcome.outcomeId, + sequence: outcome.sequence + }) + if (!parsed) { + throw new Error('terminal authority outcome delivery identity is invalid') + } + return parsed +} + +function outcomeExitParams( + effect: TerminalExitedEffect, + identity: TerminalAuthorityOutcomeDeliveryIdentity +): Record { + return { + id: effect.binding.physicalPtyId, + code: effect.code ?? -1, + incarnationId: effect.binding.ptyIncarnationId, + authorityOutcome: identity + } +} diff --git a/src/relay/terminal-session-authority-pty-lifecycle.test.ts b/src/relay/terminal-session-authority-pty-lifecycle.test.ts new file mode 100644 index 00000000000..c2061c987b9 --- /dev/null +++ b/src/relay/terminal-session-authority-pty-lifecycle.test.ts @@ -0,0 +1,694 @@ +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' +import { once } from 'node:events' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { build } from 'esbuild' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { TerminalSessionAuthorityRegistry } from '../main/session-authority/terminal-session-authority-registry' +import { terminalAuthorityWorkspaceLocator } from '../main/session-authority/terminal-session-authority-workspace-locator' +import { + TERMINAL_SESSION_AUTHORITY_ATTACH_VERSION, + TERMINAL_SESSION_AUTHORITY_SPAWN_VERSION +} from '../shared/terminal-session-authority-wire' +import { TerminalSessionAuthorityPtyLifecycle } from '../main/session-authority/terminal-session-authority-pty-lifecycle' +import type { TerminalSessionAuthorityService } from '../main/session-authority/terminal-session-authority-service' +import type { TerminalAuthorityDurableOutcome } from '../shared/terminal-session-authority-mutation' +import type { TerminalAuthorityPolicyConsumerConnection } from '../main/session-authority/terminal-session-authority-policy-consumers' +import { + createTerminalAuthorityProofEphemeralKeypair, + terminalAuthorityHostAppConsumerId +} from '../main/session-authority/terminal-session-authority-consumer-proof' +import { admitAuthenticatedPolicyConsumer } from '../main/session-authority/__tests__/authenticated-policy-consumer-admission' + +// One app keypair for the whole file: the consumer id is derived from it, so every admission here +// resumes the same durable consumer exactly as one app process would across restarts. +const APP_KEYPAIR = createTerminalAuthorityProofEphemeralKeypair() +let nextAdmissionRequest = 0 + +const directories: string[] = [] +const registries: TerminalSessionAuthorityRegistry[] = [] +const children: ChildProcessWithoutNullStreams[] = [] +const policyConnections = new WeakMap< + TerminalSessionAuthorityPtyLifecycle, + TerminalAuthorityPolicyConsumerConnection +>() +const registryRoots = new WeakMap() +const lifecycleRoots = new WeakMap() + +function rememberLifecycleRoot( + registry: TerminalSessionAuthorityRegistry, + lifecycle: TerminalSessionAuthorityPtyLifecycle +): void { + const root = registryRoots.get(registry) + if (!root) { + throw new Error('test registry root is unavailable') + } + lifecycleRoots.set(lifecycle, root) +} + +async function namespaceForLifecycle(lifecycle: TerminalSessionAuthorityPtyLifecycle) { + const root = lifecycleRoots.get(lifecycle) + if (!root) { + throw new Error('test lifecycle root is unavailable') + } + return await lifecycle.resolvePolicyConsumerNamespace(`repo::${root}`) +} +async function createLifecycle( + registry: TerminalSessionAuthorityRegistry, + ownerIncarnationId: string +): Promise { + const lifecycle = new TerminalSessionAuthorityPtyLifecycle(registry, ownerIncarnationId) + lifecycle.installHostEffectApplier({ ensureBindingRetired: async () => {} }) + rememberLifecycleRoot(registry, lifecycle) + const admitted = await admitAuthenticatedPolicyConsumer(lifecycle, { + namespace: await namespaceForLifecycle(lifecycle), + appKeypair: APP_KEYPAIR, + processIncarnationId: ownerIncarnationId, + requestId: `lifecycle-request-${++nextAdmissionRequest}` + }) + policyConnections.set(lifecycle, admitted.session.policyConsumer) + return lifecycle +} + +afterEach(async () => { + await Promise.all(children.splice(0).map(stopChild)) + await Promise.allSettled(registries.splice(0).map((registry) => registry.close())) + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +describe('TerminalSessionAuthorityPtyLifecycle', () => { + it('prepares, commits, and adopts one exact PTY across operation and geometry retries', async () => { + const root = freshDirectory() + const registry = await openRegistry(root, 'owner-token-a', 'owner-a') + const lifecycle = await createLifecycle(registry, 'owner-a') + const params = spawnParams(root, 7) + + const prepared = await lifecycle.prepareSpawn(params, 'pty-1', policyFor(lifecycle)) + expect(prepared.kind).toBe('spawn') + if (prepared.kind !== 'spawn') { + throw new Error('expected a fresh allocation') + } + const managed = await lifecycle.commitSpawn(prepared, 'incarnation-1') + expect(managed.binding).toEqual({ + ownerIncarnationId: 'owner-a', + physicalPtyId: 'pty-1', + ptyIncarnationId: 'incarnation-1' + }) + + const replay = await lifecycle.prepareSpawn( + { + ...params, + cols: 132, + rows: 44, + agentSessionCreateOperationId: '1720000000000-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + }, + 'pty-2', + policyFor(lifecycle) + ) + expect(replay).toMatchObject({ kind: 'adopt', binding: managed.binding }) + }) + + it('records physical exit as a later outcome after an idempotent durable close', async () => { + const root = freshDirectory() + const registry = await openRegistry(root, 'owner-token-a', 'owner-a') + const published: unknown[] = [] + const lifecycle = new TerminalSessionAuthorityPtyLifecycle(registry, 'owner-a') + lifecycle.installHostEffectApplier({ ensureBindingRetired: async () => {} }) + rememberLifecycleRoot(registry, lifecycle) + await connectTestPolicyConsumer(lifecycle, 'owner-a', async (outcome) => { + published.push(outcome) + }) + const prepared = await lifecycle.prepareSpawn( + spawnParams(root, 1), + 'pty-1', + policyFor(lifecycle) + ) + if (prepared.kind !== 'spawn') { + throw new Error('expected a fresh allocation') + } + const managed = await lifecycle.commitSpawn(prepared, 'incarnation-1') + + await lifecycle.closeExactPtyAccess( + { + namespace: managed.runtime.service.namespace, + pane: managed.pane, + binding: managed.binding + }, + policyFor(lifecycle) + ) + await lifecycle.closePty(managed, policyFor(lifecycle)) + expect( + managed.runtime.service.bindingAuthority( + managed.runtime.service.writerAccess, + managed.pane, + managed.binding + ) + ).toBe('closed') + + await lifecycle.recordExit(managed, 137) + await lifecycle.recordExit(managed, 137) + expect( + managed.runtime.service.bindingAuthority( + managed.runtime.service.writerAccess, + managed.pane, + managed.binding + ) + ).toBe('exited') + await vi.waitFor(() => + expect( + published.filter( + (outcome) => + typeof outcome === 'object' && + outcome !== null && + 'result' in outcome && + (outcome.result as { effects: { kind: string }[] }).effects.some( + (effect) => effect.kind === 'terminal-exited' + ) + ) + ).toHaveLength(1) + ) + }) + + it('never supersedes an owner-unreachable predecessor binding', async () => { + const root = freshDirectory() + const firstRegistry = await openRegistry(root, 'owner-token-a', 'owner-a') + const first = await createLifecycle(firstRegistry, 'owner-a') + const prepared = await first.prepareSpawn(spawnParams(root, 1), 'pty-1', policyFor(first)) + if (prepared.kind !== 'spawn') { + throw new Error('expected a fresh allocation') + } + await first.commitSpawn(prepared, 'incarnation-1') + await firstRegistry.close() + + const secondRegistry = await openRegistry(root, 'owner-token-b', 'owner-b') + const second = await createLifecycle(secondRegistry, 'owner-b') + await expect( + second.prepareSpawn(spawnParams(root, 1), 'pty-2', policyFor(second)) + ).rejects.toMatchObject({ + code: 'writer-fenced' + }) + await expect( + second.prepareSpawn(spawnParams(root, 2), 'pty-2', policyFor(second)) + ).rejects.toMatchObject({ + code: 'writer-fenced' + }) + const namespace = secondRegistry.namespaceForLocator(terminalAuthorityWorkspaceLocator(root)) + if (!namespace) { + throw new Error('expected authority namespace') + } + const service = await secondRegistry.openNamespace(namespace) + const observer = service.observe('supersede-observer') + expect(service.snapshotForObserver(observer).panes).toMatchObject([ + { + paneGenerationId: 'renderer:1', + status: 'open', + ownerStatus: 'owner-unreachable', + binding: { physicalPtyId: 'pty-1', ptyIncarnationId: 'incarnation-1' } + } + ]) + }) + + it('never cancels a predecessor pending allocation to admit a newer generation', async () => { + const root = freshDirectory() + const firstRegistry = await openRegistry(root, 'owner-token-a', 'owner-a') + const first = await createLifecycle(firstRegistry, 'owner-a') + expect(await first.prepareSpawn(spawnParams(root, 1), 'pty-1', policyFor(first))).toMatchObject( + { kind: 'spawn' } + ) + await firstRegistry.close() + + const secondRegistry = await openRegistry(root, 'owner-token-b', 'owner-b') + const second = await createLifecycle(secondRegistry, 'owner-b') + await expect( + second.prepareSpawn(spawnParams(root, 2), 'pty-2', policyFor(second)) + ).rejects.toMatchObject({ + code: 'writer-fenced' + }) + }) + + it('keeps a legacy lease unknown when no durable namespace or binding exists', async () => { + const root = freshDirectory() + const registry = await openRegistry(root, 'owner-token-a', 'owner-a') + // No consumer is admitted here on purpose: the probe must not register a namespace by itself. + const lifecycle = new TerminalSessionAuthorityPtyLifecycle(registry, 'owner-a') + lifecycle.installHostEffectApplier({ ensureBindingRetired: async () => {} }) + const params = attachParams(root, 'pty-legacy', 'incarnation-legacy', 1) + + expect(await lifecycle.missingPtyState(params, 'pty-legacy')).toEqual({ kind: 'unknown' }) + expect(registry.registeredNamespaces()).toHaveLength(0) + + await registry.resolveNamespace(terminalAuthorityWorkspaceLocator(root)) + expect(await lifecycle.missingPtyState(params, 'pty-legacy')).toEqual({ kind: 'unknown' }) + }) + + it('returns the exact owner binding for a reachable missing PTY', async () => { + const root = freshDirectory() + const registry = await openRegistry(root, 'owner-token-a', 'owner-a') + const lifecycle = await createLifecycle(registry, 'owner-a') + const prepared = await lifecycle.prepareSpawn( + spawnParams(root, 0), + 'pty-legacy', + policyFor(lifecycle) + ) + if (prepared.kind !== 'spawn') { + throw new Error('expected a fresh allocation') + } + await lifecycle.commitSpawn(prepared, 'incarnation-legacy') + + await expect( + lifecycle.missingPtyState( + attachParams(root, 'pty-legacy', 'incarnation-legacy', 0), + 'pty-legacy' + ) + ).resolves.toMatchObject({ + kind: 'reachable-record', + pane: { paneKey: 'pane-a', paneGenerationId: 'renderer:0' }, + binding: { + ownerIncarnationId: 'owner-a', + physicalPtyId: 'pty-legacy', + ptyIncarnationId: 'incarnation-legacy' + } + }) + }) + + it('keeps an unreachable predecessor live until its owner publishes a causal exit', async () => { + const root = freshDirectory() + const firstRegistry = await openRegistry(root, 'owner-token-a', 'owner-a') + const first = await createLifecycle(firstRegistry, 'owner-a') + const prepared = await first.prepareSpawn(spawnParams(root, 1), 'pty-1', policyFor(first)) + if (prepared.kind !== 'spawn') { + throw new Error('expected a fresh allocation') + } + await first.commitSpawn(prepared, 'incarnation-1') + + const replacementRegistry = await openRegistry( + root, + 'owner-token-b', + 'owner-b', + 'owner-token-a' + ) + const replacement = await createLifecycle(replacementRegistry, 'owner-b') + expect( + await replacement.missingPtyState(attachParams(root, 'pty-1', 'incarnation-1', 1), 'pty-1') + ).toMatchObject({ + kind: 'unreachable-predecessor', + pane: { paneGenerationId: 'renderer:1' }, + binding: { physicalPtyId: 'pty-1', ptyIncarnationId: 'incarnation-1' } + }) + + const namespace = replacementRegistry.namespaceForLocator( + terminalAuthorityWorkspaceLocator(root) + ) + if (!namespace) { + throw new Error('expected authority namespace') + } + const service = await replacementRegistry.openNamespace(namespace) + const observer = service.observe('test-observer') + expect(service.snapshotForObserver(observer).panes).toMatchObject([ + { + paneGenerationId: 'renderer:1', + status: 'open', + ownerStatus: 'owner-unreachable', + binding: { physicalPtyId: 'pty-1', ptyIncarnationId: 'incarnation-1' } + } + ]) + }) + + it('requires exact pane generation even when the binding is already retired', async () => { + const root = freshDirectory() + const registry = await openRegistry(root, 'owner-token-a', 'owner-a') + const lifecycle = await createLifecycle(registry, 'owner-a') + const prepared = await lifecycle.prepareSpawn( + spawnParams(root, 1), + 'pty-1', + policyFor(lifecycle) + ) + if (prepared.kind !== 'spawn') { + throw new Error('expected a fresh allocation') + } + const managed = await lifecycle.commitSpawn(prepared, 'incarnation-1') + await lifecycle.recordExit(managed, 0) + + expect( + await lifecycle.missingPtyState(attachParams(root, 'pty-1', 'incarnation-1'), 'pty-1') + ).toEqual({ kind: 'unknown' }) + expect( + await lifecycle.missingPtyState(attachParams(root, 'pty-1', 'incarnation-1', 1), 'pty-1') + ).toEqual({ kind: 'retired' }) + }) + + it('restores exact authority state after an abrupt child-process owner crash', async () => { + const root = freshDirectory() + const child = await startCrashOwner(root) + children.push(child) + const exited = once(child, 'exit') + child.kill() + await exited + + const registry = await openRegistry(root, 'owner-token-b', 'owner-b', 'owner-token-a') + const lifecycle = await createLifecycle(registry, 'owner-b') + expect( + await lifecycle.missingPtyState( + attachParams(root, 'pty-child', 'incarnation-child', 1), + 'pty-child' + ) + ).toMatchObject({ + kind: 'unreachable-predecessor', + pane: { paneGenerationId: 'renderer:1' }, + binding: { physicalPtyId: 'pty-child', ptyIncarnationId: 'incarnation-child' } + }) + }) + + it('keeps repeated binding reachability checks off the durable mutation path', async () => { + const root = freshDirectory() + const registry = await openRegistry(root, 'owner-token-a', 'owner-a') + const lifecycle = await createLifecycle(registry, 'owner-a') + const prepared = await lifecycle.prepareSpawn( + spawnParams(root, 1), + 'pty-1', + policyFor(lifecycle) + ) + if (prepared.kind !== 'spawn') { + throw new Error('expected a fresh allocation') + } + const managed = await lifecycle.commitSpawn(prepared, 'incarnation-1') + const namespace = registry.namespaceForLocator(terminalAuthorityWorkspaceLocator(root)) + if (!namespace) { + throw new Error('expected authority namespace') + } + const service = await registry.openNamespace(namespace) + const observer = service.observe('performance-observer') + const beforeRevision = service.snapshotForObserver(observer).revision + + for (let index = 0; index < 20_000; index++) { + if (!lifecycle.bindingIsReachable(managed)) { + throw new Error('binding unexpectedly became unreachable') + } + } + + expect(service.snapshotForObserver(observer).revision).toBe(beforeRevision) + }) + + it('keeps a rejected terminal outcome unacknowledged without rejecting durable exit', async () => { + const root = freshDirectory() + const registry = await openRegistry(root, 'owner-token-a', 'owner-a') + let rejectExit = false + const publishedExitSequences: number[] = [] + const lifecycle = new TerminalSessionAuthorityPtyLifecycle(registry, 'owner-a') + lifecycle.installHostEffectApplier({ ensureBindingRetired: async () => {} }) + rememberLifecycleRoot(registry, lifecycle) + const publish = async (outcome: TerminalAuthorityDurableOutcome): Promise => { + if ( + outcome.kind !== 'semantic' && + outcome.result.effects.some((effect) => effect.kind === 'terminal-exited') + ) { + if (rejectExit) { + throw new Error('downstream unavailable') + } + publishedExitSequences.push(outcome.sequence) + } + } + await connectTestPolicyConsumer(lifecycle, 'owner-a', publish) + const prepared = await lifecycle.prepareSpawn( + spawnParams(root, 1), + 'pty-1', + policyFor(lifecycle) + ) + if (prepared.kind !== 'spawn') { + throw new Error('expected a fresh allocation') + } + const managed = await lifecycle.commitSpawn(prepared, 'incarnation-1') + rejectExit = true + + await lifecycle.recordExit(managed, 0) + let beforeRetry = await consumerSnapshot(managed.runtime.service, 'owner-a') + await vi.waitFor(async () => { + beforeRetry = await consumerSnapshot(managed.runtime.service, 'owner-a') + expect(beforeRetry.acknowledgedSequence).toBeLessThan(beforeRetry.outcomeHighWatermark) + }) + expect(beforeRetry.acknowledgedSequence).toBeLessThan(beforeRetry.outcomeHighWatermark) + + rejectExit = false + await connectTestPolicyConsumer(lifecycle, 'owner-a', publish, managed.runtime.service) + await vi.waitFor(async () => { + const afterRetry = await consumerSnapshot(managed.runtime.service, 'owner-a') + expect(afterRetry.acknowledgedSequence).toBe(afterRetry.outcomeHighWatermark) + }) + expect(publishedExitSequences).toEqual([beforeRetry.outcomeHighWatermark]) + }) + + it('replays an unacknowledged terminal outcome after authority restart', async () => { + const root = freshDirectory() + const firstRegistry = await openRegistry(root, 'owner-token-a', 'owner-a') + const first = new TerminalSessionAuthorityPtyLifecycle(firstRegistry, 'owner-a') + first.installHostEffectApplier({ ensureBindingRetired: async () => {} }) + rememberLifecycleRoot(firstRegistry, first) + await connectTestPolicyConsumer(first, 'owner-a', async (outcome) => { + if ( + outcome.kind !== 'semantic' && + outcome.result.effects.some((effect) => effect.kind === 'terminal-exited') + ) { + throw new Error('crash before downstream settlement') + } + }) + const prepared = await first.prepareSpawn(spawnParams(root, 1), 'pty-1', policyFor(first)) + if (prepared.kind !== 'spawn') { + throw new Error('expected a fresh allocation') + } + const managed = await first.commitSpawn(prepared, 'incarnation-1') + await first.recordExit(managed, 17) + await vi.waitFor(async () => { + const snapshot = await consumerSnapshot(managed.runtime.service, 'owner-a') + expect(snapshot.acknowledgedSequence).toBeLessThan(snapshot.outcomeHighWatermark) + }) + first.dispose() + await firstRegistry.close() + + const replayed: number[] = [] + const secondRegistry = await openRegistry(root, 'owner-token-b', 'owner-b') + const second = new TerminalSessionAuthorityPtyLifecycle(secondRegistry, 'owner-b') + second.installHostEffectApplier({ ensureBindingRetired: async () => {} }) + rememberLifecycleRoot(secondRegistry, second) + const namespace = secondRegistry.namespaceForLocator(terminalAuthorityWorkspaceLocator(root)) + if (!namespace) { + throw new Error('expected authority namespace') + } + const secondService = await secondRegistry.openNamespace(namespace) + await connectTestPolicyConsumer( + second, + 'owner-b', + async (outcome) => { + if ( + outcome.kind !== 'semantic' && + outcome.result.effects.some((effect) => effect.kind === 'terminal-exited') + ) { + replayed.push(outcome.sequence) + } + }, + secondService + ) + await second.missingPtyState(attachParams(root, 'pty-1', 'incarnation-1', 1), 'pty-1') + await vi.waitFor(() => expect(replayed).toHaveLength(1)) + + const snapshot = await consumerSnapshot(secondService, 'owner-b') + expect(snapshot.acknowledgedSequence).toBe(snapshot.outcomeHighWatermark) + }) +}) + +async function connectTestPolicyConsumer( + lifecycle: TerminalSessionAuthorityPtyLifecycle, + incarnation: string, + publish: (outcome: TerminalAuthorityDurableOutcome) => Promise, + service?: TerminalSessionAuthorityService +): Promise { + let connection!: TerminalAuthorityPolicyConsumerConnection + const admitted = await admitAuthenticatedPolicyConsumer(lifecycle, { + namespace: service?.namespace ?? (await namespaceForLifecycle(lifecycle)), + appKeypair: APP_KEYPAIR, + processIncarnationId: incarnation, + requestId: `lifecycle-request-${++nextAdmissionRequest}`, + outcomeTransport: { + publishBoundary: async () => {}, + publishOutcome: async (publication) => { + const outcomes = publication.outcomes ?? [publication.outcome] + for (const outcome of outcomes) { + await publish(outcome) + } + const tail = outcomes.at(-1)! + await connection.acknowledge({ + version: 1, + consumer: publication.consumer, + namespace: publication.namespace, + sequence: tail.sequence, + outcomeId: tail.outcomeId + }) + } + } + }) + connection = admitted.session.policyConsumer + policyConnections.set(lifecycle, connection) + return connection +} + +function policyFor( + lifecycle: TerminalSessionAuthorityPtyLifecycle +): TerminalAuthorityPolicyConsumerConnection { + const connection = policyConnections.get(lifecycle) + if (!connection) { + throw new Error('test policy consumer is unavailable') + } + return connection +} + +async function consumerSnapshot( + service: TerminalSessionAuthorityService, + incarnation: string +): ReturnType { + const identity = testConsumerIdentity(incarnation) + const consumer = await service.claimConsumer(service.writerAccess, { + consumerId: identity.consumerId, + expectedIncarnationId: identity.consumerIncarnationId, + consumerIncarnationId: identity.consumerIncarnationId + }) + return await service.snapshotForConsumer(consumer) +} + +function testConsumerIdentity(incarnation: string) { + return Object.freeze({ + consumerId: terminalAuthorityHostAppConsumerId('host-a', APP_KEYPAIR.publicKey), + consumerIncarnationId: `app-process:${incarnation}` + }) +} + +async function openRegistry( + root: string, + ownerToken: string, + ownerIncarnationId: string, + takeoverOwnerToken?: string +): Promise { + const registry = await TerminalSessionAuthorityRegistry.open({ + directory: path.join(root, 'authority'), + authorityHostId: 'host-a', + ownerToken, + ...(takeoverOwnerToken ? { takeoverOwnerToken } : {}), + ownerIncarnationId, + writerActorId: ownerIncarnationId + }) + registries.push(registry) + registryRoots.set(registry, root) + return registry +} + +function attachParams( + workspacePath: string, + physicalPtyId: string, + ptyIncarnationId: string, + paneGeneration?: number +): Record { + return { + terminalSessionAuthorityAttachVersion: TERMINAL_SESSION_AUTHORITY_ATTACH_VERSION, + expectedWorktreeId: `repo::${workspacePath}`, + expectedPaneKey: 'pane-a', + expectedPtyIncarnationId: ptyIncarnationId, + ...(paneGeneration === undefined ? {} : { expectedPaneGeneration: paneGeneration }), + id: physicalPtyId + } +} + +function spawnParams(workspacePath: string, paneGeneration: number): Record { + return { + terminalSessionAuthorityVersion: TERMINAL_SESSION_AUTHORITY_SPAWN_VERSION, + paneKey: 'pane-a', + paneGeneration, + worktreeId: `repo::${workspacePath}`, + cols: 80, + rows: 24, + cwd: workspacePath, + env: { TERM: 'xterm-256color' } + } +} + +function freshDirectory(): string { + const directory = mkdtempSync(path.join(tmpdir(), 'orca-pty-authority-')) + directories.push(directory) + return directory +} + +async function startCrashOwner(root: string): Promise { + const entry = path.join(root, 'authority-crash-owner.ts') + const bundle = path.join(root, 'authority-crash-owner.cjs') + const registryModule = path.resolve( + 'src/main/session-authority/terminal-session-authority-registry.ts' + ) + const lifecycleModule = path.resolve( + 'src/main/session-authority/terminal-session-authority-pty-lifecycle.ts' + ) + const admissionModule = path.resolve( + 'src/main/session-authority/__tests__/authenticated-policy-consumer-admission.ts' + ) + writeFileSync( + entry, + `import path from 'node:path' +import { TerminalSessionAuthorityRegistry } from ${JSON.stringify(registryModule)} +import { TerminalSessionAuthorityPtyLifecycle } from ${JSON.stringify(lifecycleModule)} +import { admitAuthenticatedPolicyConsumer } from ${JSON.stringify(admissionModule)} +void (async () => { + const root = process.argv[2] + const registry = await TerminalSessionAuthorityRegistry.open({ + directory: path.join(root, 'authority'), authorityHostId: 'host-a', + ownerToken: 'owner-token-a', ownerIncarnationId: 'owner-a', writerActorId: 'owner-a' + }) + const lifecycle = new TerminalSessionAuthorityPtyLifecycle(registry, 'owner-a') + lifecycle.installHostEffectApplier({ ensureBindingRetired: async () => {} }) + const admitted = await admitAuthenticatedPolicyConsumer(lifecycle, { + namespace: await lifecycle.resolvePolicyConsumerNamespace('repo::' + root), + processIncarnationId: 'crash-test', + requestId: 'crash-request' + }) + const connection = admitted.session.policyConsumer + const prepared = await lifecycle.prepareSpawn({ + terminalSessionAuthorityVersion: 1, paneKey: 'pane-a', paneGeneration: 1, + worktreeId: 'repo::' + root, cols: 80, rows: 24, cwd: root, env: { TERM: 'xterm' } + }, 'pty-child', connection) + if (prepared.kind !== 'spawn') throw new Error('expected spawn') + await lifecycle.commitSpawn(prepared, 'incarnation-child') + process.stdout.write('READY\\n') + setInterval(() => {}, 1_000) +})().catch((error) => { + process.stderr.write(String(error) + String.fromCharCode(10)) + process.exit(1) +}) +`, + 'utf8' + ) + await build({ entryPoints: [entry], outfile: bundle, bundle: true, platform: 'node' }) + const child = spawn(process.execPath, [bundle, root], { stdio: ['pipe', 'pipe', 'pipe'] }) + await waitForReady(child) + return child +} + +function waitForReady(child: ChildProcessWithoutNullStreams): Promise { + return new Promise((resolve, reject) => { + let output = '' + child.stdout.setEncoding('utf8') + child.stdout.on('data', (chunk: string) => { + output += chunk + if (output.includes('READY\n')) { + resolve() + } + }) + child.once('exit', (code) => reject(new Error(`authority child exited before ready: ${code}`))) + }) +} + +async function stopChild(child: ChildProcessWithoutNullStreams): Promise { + if (child.exitCode !== null || child.signalCode !== null) { + return + } + const exited = once(child, 'exit') + child.kill() + await exited +} diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 8319d686597..956d936ee1e 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -218,6 +218,7 @@ import { refreshTerminalProviderSnapshotCapabilities } from './components/terminal/terminal-provider-snapshot-capability' import { useRemoteRuntimeRecoveryTriggers } from './runtime/use-remote-runtime-recovery-triggers' +import { startTerminalAuthorityAppProjectionController } from './lib/terminal-authority-app-projection-startup' // Why: bound the resume-record loss window on a hard kill to ~1 min; capture skips unchanged records so per-tick cost is negligible. const SLEEPING_AGENT_RESUME_CAPTURE_INTERVAL_MS = 60_000 @@ -746,6 +747,10 @@ function App(): React.JSX.Element { } }, [activeModal, shouldMountAddRepoDialog]) + useEffect(() => { + startTerminalAuthorityAppProjectionController() + }, []) + // Subscribe to IPC push events useIpcEvents() useRemoteRuntimeRecoveryTriggers() diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index dc39c3a0644..2b3d55b5ee6 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -25,7 +25,6 @@ import { } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' import TabBar from './tab-bar/TabBar' -import TerminalPane from './terminal-pane/TerminalPane' import { ORCA_EDITOR_REQUEST_FILE_CLOSE_EVENT, ORCA_EDITOR_SAVE_AND_CLOSE_EVENT, @@ -93,7 +92,6 @@ import { planColdActivationTabDeferral, pruneClosedBackgroundMountTabs, revealActivationDeferredTabs, - shouldMountBackgroundWorktreeTab, takeAllPendingBackgroundTerminalWorktreeMounts, takePendingBackgroundTerminalWorktreeMount } from './terminal/background-terminal-worktree-mount' @@ -133,14 +131,13 @@ import { disposeAllParkedTerminalWatchers, pruneParkedTerminalWatchers, shouldDeferParkedPtyExitTabClose, - syncParkedTerminalTabWatchers, terminalWatcherLiveWorkspaceIds } from './terminal-pane/terminal-parked-tab-watchers' +import { LegacyTerminalWorkspaceSurface } from './terminal-pane/LegacyTerminalWorkspaceSurface' import { isMainTerminalSideEffectAuthorityForPty } from './terminal-pane/terminal-side-effect-facts-handler' import { appendUniqueOpenFileIds } from './terminal/unsaved-close-queue' import { setWindowCloseRequestHandler } from './window-close-request-coordinator' import { - findActivityTerminalPortal, useActivityTerminalPortals, type ActivityTerminalPortalTarget } from './activity/activity-terminal-portal' @@ -1389,91 +1386,12 @@ function Terminal(): React.JSX.Element | null { groupsByWorktree, activeGroupIdByWorktree ) - // Why: legacy (non-split) host owns watcher reconciliation; split mode's overlay layers own theirs, so only dispose worktrees with no overlay layer. + // Surface components own handoff ordering; this host only prunes workspaces that no longer exist. useEffect(() => { pruneParkedTerminalWatchers( terminalWatcherLiveWorkspaceIds(workspaceSurfaces.map((workspace) => workspace.id)) ) - for (const workspace of workspaceSurfaces) { - if ( - anyMountedWorktreeHasLayout && - mountedWorktreeIdsRef.current.has(workspace.id) && - getEffectiveLayoutForWorktree(workspace.id) - ) { - continue - } - const tabs = tabsByWorktree[workspace.id] ?? [] - const parkedTabIds = new Set() - let deferredTabIds: ReadonlySet | null = null - if (!anyMountedWorktreeHasLayout && mountedWorktreeIdsRef.current.has(workspace.id)) { - const isVisible = activeView === 'terminal' && workspace.id === renderedActiveWorktreeId - const shouldMeasureHiddenWorktree = - !isVisible && measurableBackgroundWorktreeIdsRef.current.has(workspace.id) - const parked = - !isVisible && - !shouldMeasureHiddenWorktree && - effectiveParkedTerminalWorktreeIds.has(workspace.id) - if (parked) { - for (const tab of tabs) { - const activityTerminalPortal = findActivityTerminalPortal(activityTerminalPortals, { - worktreeId: workspace.id, - tabId: tab.id - }) - // Why the exempt exclusion: force-park keeps eviction-exempt tabs' - // panes mounted (a remount would orphan their live pty) — same - // per-tab carve-out as Activity portals, so no watcher owns them. - // The set is resolved in the force-park pass and holds only those - // worktrees' tabs. - if (!activityTerminalPortal && !evictionExemptTerminalTabIds.has(tab.id)) { - parkedTabIds.add(tab.id) - } - } - } - // Why: activation-deferred tabs are unmounted like parked ones — the same byte watchers own their side effects until first reveal. - deferredTabIds = - activationDeferredMountTabIdsByWorktreeRef.current.get(workspace.id) ?? null - for (const tab of tabs) { - if ( - deferredTabIds?.has(tab.id) && - !parkedTabIds.has(tab.id) && - canWatcherCoverParkedTerminalTab(workspace.id, tab) && - !findActivityTerminalPortal(activityTerminalPortals, { - worktreeId: workspace.id, - tabId: tab.id - }) - ) { - parkedTabIds.add(tab.id) - } - } - } - syncParkedTerminalTabWatchers({ - worktreeId: workspace.id, - tabs, - parkedTabIds, - // Why: activation-deferred tabs never mounted a pane to restore their title, unlike ordinary parked tabs. - ...(deferredTabIds ? { restoreTitleOnStartTabIds: deferredTabIds } : {}) - }) - } - }, [ - // Why activeTabId: revealing a deferred tab mutates the mount restriction in the same render; sync must re-run so its watcher disposes before the pane attaches. - activeTabId, - activeView, - activityTerminalPortals, - activeTabIdByWorktree, - anyMountedWorktreeHasLayout, - backgroundMountRevision, - evictionExemptTerminalTabIds, - getEffectiveLayoutForWorktree, - groupsByWorktree, - effectiveParkedTerminalWorktreeIds, - pendingStartupByTabId, - renderedActiveWorktreeId, - tabsByWorktree, - terminalParkingEnabled, - terminalTitleSnapshotAuthorityEnabled, - workspaceSessionReady, - workspaceSurfaces - ]) + }, [workspaceSurfaces]) // Why: on host unmount no reconciliation effect runs again, so dispose every remaining parked watcher. useEffect(() => () => disposeAllParkedTerminalWatchers(), []) // Auto-create first tab when worktree activates @@ -2500,71 +2418,27 @@ function Terminal(): React.JSX.Element | null { !shouldMeasureHiddenWorktree && effectiveParkedTerminalWorktreeIds.has(workspace.id) return ( -
- {(tabsByWorktree[workspace.id] ?? []) - .filter((tab) => - shouldMountBackgroundWorktreeTab( - backgroundMountTabIdsByWorktreeRef.current.get(workspace.id) ?? null, - tab.id - ) - ) - .map((tab) => { - const activityTerminalPortal = findActivityTerminalPortal( - activityTerminalPortals, - { worktreeId: workspace.id, tabId: tab.id } - ) - const isActivityPortalTab = activityTerminalPortal !== null - const isActiveTerminalTab = - isVisible && tab.id === activeTabId && activeTabType === 'terminal' - // Why: parking unmounts the view but keeps the PTY; an Activity portal stays - // mounted as a visible consumer, and a force-parked worktree's eviction-exempt - // tabs stay mounted because a remount would orphan their live pty. - if ( - shouldColdParkTerminalPanes && - !isActivityPortalTab && - !evictionExemptTerminalTabIds.has(tab.id) - ) { - return null - } - const terminalPane = ( - handlePtyExit(tab.id, ptyId)} - onCloseTab={() => handleCloseTab(tab.id)} - /> - ) - if (activityTerminalPortal) { - return createPortal( - terminalPane, - activityTerminalPortal.target, - `activity-terminal-${tab.id}` - ) - } - return terminalPane - })} -
+ activationDeferredMountTabIds={ + activationDeferredMountTabIdsByWorktreeRef.current.get(workspace.id) ?? null + } + onPtyExit={handlePtyExit} + onCloseTab={handleCloseTab} + /> ) })} diff --git a/src/renderer/src/components/activity/ActivityPrototypePage.tsx b/src/renderer/src/components/activity/ActivityPrototypePage.tsx index 8c8058fbeeb..1a59f86d586 100644 --- a/src/renderer/src/components/activity/ActivityPrototypePage.tsx +++ b/src/renderer/src/components/activity/ActivityPrototypePage.tsx @@ -55,12 +55,6 @@ import { reconcileActivityPortalThreads, resolveActivityPortalSwap } from './activity-portal-thread-reconciliation' -import { - ACTIVITY_PORTAL_READINESS_BURST_WINDOW_MS, - createActivityPortalReadinessLatch, - type ActivityPortalReadinessLatch, - type ActivityPortalReadinessStatus -} from './activity-portal-readiness-oscillation' import type { Repo, TerminalTab, Worktree } from '../../../../shared/types' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import { @@ -142,7 +136,7 @@ type ActivityThreadGroup = { type ActivityTerminalPortalReadiness = { target: HTMLElement | null paneKey: string | null - status: ActivityPortalReadinessStatus + status: 'loading' | 'ready' | 'unavailable' } type ActivityTerminalPortalDomStatus = { @@ -291,13 +285,10 @@ export function useActivityTerminalPortalStatus( paneKey: null, status: 'loading' }) - // Why: portal churn replaces every subscription identity, so the burst budget must outlive it. - const readinessLatchRef = useRef(null) useLayoutEffect(() => { let disposed = false let readinessFrame: number | null = null - let readinessReleaseTimer: number | null = null let pendingStatus: ActivityTerminalPortalReadiness['status'] | null = null // Why: coalesce observer bursts and cancel frames from stale portal subscriptions. @@ -330,10 +321,6 @@ export function useActivityTerminalPortalStatus( cancelAnimationFrame(readinessFrame) readinessFrame = null } - if (readinessReleaseTimer !== null) { - window.clearTimeout(readinessReleaseTimer) - readinessReleaseTimer = null - } } if (!target || !paneKey) { @@ -345,35 +332,17 @@ export function useActivityTerminalPortalStatus( return disposeFrame } - const readinessLatch = (readinessLatchRef.current ??= createActivityPortalReadinessLatch()) - - const updateReadiness = (status: ActivityTerminalPortalReadiness['status']): void => { - const nextStatus = readinessLatch.next(status) - scheduleReadiness(nextStatus) - if (readinessReleaseTimer !== null) { - window.clearTimeout(readinessReleaseTimer) - readinessReleaseTimer = null - } - if (nextStatus !== status) { - // Why: a quiet loading pane has no mutation to release the burst latch on its own. - readinessReleaseTimer = window.setTimeout( - checkReadiness, - ACTIVITY_PORTAL_READINESS_BURST_WINDOW_MS - ) - } - } - const checkReadiness = (): void => { const status = getSelectedActivityTerminalPortalStatus(target, paneKey) if (status.unavailable) { - updateReadiness('unavailable') + scheduleReadiness('unavailable') return } if (status.ready) { - updateReadiness('ready') + scheduleReadiness('ready') return } - updateReadiness('loading') + scheduleReadiness('loading') } checkReadiness() @@ -1600,9 +1569,10 @@ export default function ActivityPrototypePage(): React.JSX.Element { visibleThread ]) - // Why: swap-staged makes the displayed thread selected, so this branch cannot repeat by itself. + // Why: current displayed state makes every layout-effect write a one-way reconciliation. useLayoutEffect(() => { const swap = resolveActivityPortalSwap({ + displayedPaneKey, selectedThread, selectedHasLiveTab: Boolean(selectedHasLiveTab), visibleThread, @@ -1625,6 +1595,7 @@ export default function ActivityPrototypePage(): React.JSX.Element { setDisplayedPaneKey(swap.paneKey) } }, [ + displayedPaneKey, inactivePortalSlotId, selectedHasLiveTab, selectedThread, diff --git a/src/renderer/src/components/activity/activity-portal-churn-budget.test.ts b/src/renderer/src/components/activity/activity-portal-churn-budget.test.ts deleted file mode 100644 index 65b5d264899..00000000000 --- a/src/renderer/src/components/activity/activity-portal-churn-budget.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - createActivityPortalChurnBudget, - type ActivityPortalChurnBudget -} from './activity-portal-churn-budget' - -function budgetAt(clock: { ms: number }): ActivityPortalChurnBudget { - return createActivityPortalChurnBudget({ limit: 3, windowMs: 1_000, now: () => clock.ms }) -} - -describe('createActivityPortalChurnBudget', () => { - it('spends once the window holds a full burst', () => { - const clock = { ms: 0 } - const budget = budgetAt(clock) - expect(budget.record()).toBe(false) - expect(budget.record()).toBe(false) - expect(budget.record()).toBe(true) - clock.ms += 900 - expect(budget.isSpent()).toBe(true) - }) - - it('stays spent while the churn keeps firing', () => { - const clock = { ms: 0 } - const budget = budgetAt(clock) - for (let i = 0; i < 500; i += 1) { - clock.ms += 10 - budget.record() - } - expect(budget.isSpent()).toBe(true) - }) - - it('releases a window after the churn stops', () => { - const clock = { ms: 0 } - const budget = budgetAt(clock) - for (let i = 0; i < 3; i += 1) { - budget.record() - } - clock.ms += 1_000 - expect(budget.isSpent()).toBe(false) - expect(budget.record()).toBe(false) - }) - - it('never spends on events paced slower than the window allows', () => { - // A user hopping between threads must keep the seamless path, however long they keep hopping. - const clock = { ms: 0 } - const budget = budgetAt(clock) - for (let i = 0; i < 100; i += 1) { - clock.ms += 500 - expect(budget.record()).toBe(false) - } - }) - - it('treats a backwards clock as an expired window', () => { - // Date.now() jumps backwards on NTP/sleep-wake. - const clock = { ms: 10_000 } - const budget = budgetAt(clock) - for (let i = 0; i < 3; i += 1) { - budget.record() - } - clock.ms = 5_000 - expect(budget.isSpent()).toBe(false) - }) - - it('clears on demand', () => { - const clock = { ms: 0 } - const budget = budgetAt(clock) - for (let i = 0; i < 3; i += 1) { - budget.record() - } - budget.clear() - expect(budget.isSpent()).toBe(false) - }) -}) diff --git a/src/renderer/src/components/activity/activity-portal-churn-budget.ts b/src/renderer/src/components/activity/activity-portal-churn-budget.ts deleted file mode 100644 index ced7dc31f4a..00000000000 --- a/src/renderer/src/components/activity/activity-portal-churn-budget.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** Sliding-window burst budget for Activity portal readiness churn. */ -export type ActivityPortalChurnBudget = { - /** Records one churn event; returns whether the budget is spent afterwards. */ - record: () => boolean - /** True while the window still holds a full burst. */ - isSpent: () => boolean - clear: () => void -} - -export function createActivityPortalChurnBudget(args: { - limit: number - windowMs: number - now?: () => number -}): ActivityPortalChurnBudget { - const { limit, windowMs, now = () => Date.now() } = args - // Why: only the newest limit events can affect the verdict. - let eventsAt: number[] = [] - - const prune = (at: number): void => { - // Why: clock rollback must not preserve a stale spent budget. - eventsAt = eventsAt.filter((eventAt) => eventAt > at - windowMs && eventAt <= at) - } - - return { - record() { - const at = now() - prune(at) - eventsAt.push(at) - if (eventsAt.length > limit) { - eventsAt.shift() - } - return eventsAt.length >= limit - }, - isSpent() { - prune(now()) - return eventsAt.length >= limit - }, - clear() { - eventsAt = [] - } - } -} diff --git a/src/renderer/src/components/activity/activity-portal-readiness-decay.react185.test.tsx b/src/renderer/src/components/activity/activity-portal-readiness-decay.react185.test.tsx deleted file mode 100644 index f9a6581d608..00000000000 --- a/src/renderer/src/components/activity/activity-portal-readiness-decay.react185.test.tsx +++ /dev/null @@ -1,111 +0,0 @@ -/** @vitest-environment happy-dom */ -import { act } from 'react' -import { createRoot, type Root } from 'react-dom/client' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { useActivityTerminalPortalStatus } from './ActivityPrototypePage' -import { - ACTIVITY_PORTAL_READINESS_BURST_WINDOW_MS, - ACTIVITY_PORTAL_READINESS_MAX_FLIPS -} from './activity-portal-readiness-oscillation' - -;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true - -const TAB_ID = 'tab-readiness-decay' -const LEAF_A = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1' -const LEAF_B = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb1' -const PANE_KEY = `${TAB_ID}:${LEAF_A}` - -let root: Root - -beforeEach(() => vi.useFakeTimers()) - -afterEach(() => { - act(() => root?.unmount()) - vi.useRealTimers() - vi.unstubAllGlobals() - document.body.replaceChildren() -}) - -function installControllers(): { flushFrame: () => Promise; notify: () => void } { - let frame: FrameRequestCallback | null = null - const observers = new Map() - vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { - frame = callback - return 1 - }) - vi.stubGlobal('cancelAnimationFrame', () => { - frame = null - }) - class ControlledMutationObserver implements MutationObserver { - constructor(callback: MutationCallback) { - observers.set(this, callback) - } - observe(): void {} - disconnect(): void { - observers.delete(this) - } - takeRecords(): MutationRecord[] { - return [] - } - } - vi.stubGlobal('MutationObserver', ControlledMutationObserver) - return { - async flushFrame() { - const callback = frame - frame = null - await act(async () => callback?.(performance.now())) - }, - notify() { - for (const [observer, callback] of observers) { - callback([], observer) - } - } - } -} - -function renderPortalDom(target: HTMLElement, loading: boolean): void { - const tabRoot = document.createElement('div') - tabRoot.dataset.terminalTabId = TAB_ID - for (const leafId of loading ? [LEAF_A, LEAF_B] : [LEAF_B]) { - const pane = document.createElement('div') - pane.dataset.leafId = leafId - pane.dataset.ptyId = `pty-${leafId}` - pane.appendChild(Object.assign(document.createElement('div'), { className: 'xterm-screen' })) - Object.defineProperty(pane, 'getClientRects', { value: () => [{}] }) - tabRoot.appendChild(pane) - } - target.replaceChildren(tabRoot) -} - -describe('Activity portal readiness latch decay', () => { - it('rechecks a quiet loading pane when the burst window expires', async () => { - const controls = installControllers() - const target = document.createElement('div') - document.body.append(target) - renderPortalDom(target, false) - let status = 'loading' - - function PortalStatus(): null { - status = useActivityTerminalPortalStatus(target, PANE_KEY) - return null - } - - root = createRoot(document.createElement('div')) - act(() => root.render()) - await controls.flushFrame() - - for (let flip = 0; flip < ACTIVITY_PORTAL_READINESS_MAX_FLIPS * 2; flip += 1) { - renderPortalDom(target, flip % 2 === 0) - controls.notify() - await controls.flushFrame() - } - renderPortalDom(target, true) - controls.notify() - await controls.flushFrame() - expect(status).toBe('unavailable') - - act(() => vi.advanceTimersByTime(ACTIVITY_PORTAL_READINESS_BURST_WINDOW_MS)) - await controls.flushFrame() - expect(status).toBe('loading') - }) -}) diff --git a/src/renderer/src/components/activity/activity-portal-readiness-loop.react185.test.tsx b/src/renderer/src/components/activity/activity-portal-readiness-loop.react185.test.tsx index 2389e753bb3..6ab05c2bf3d 100644 --- a/src/renderer/src/components/activity/activity-portal-readiness-loop.react185.test.tsx +++ b/src/renderer/src/components/activity/activity-portal-readiness-loop.react185.test.tsx @@ -1,7 +1,7 @@ /** @vitest-environment happy-dom */ import { act, useLayoutEffect, useState } from 'react' import { createRoot, type Root } from 'react-dom/client' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true @@ -15,15 +15,6 @@ import { resolveActivityPortalSwap, type ActivityPortalThreadRef } from './activity-portal-thread-reconciliation' -import { - ACTIVITY_PORTAL_READINESS_MAX_FLIPS, - type ActivityPortalReadinessStatus -} from './activity-portal-readiness-oscillation' - -// Why: re-applying ready DOM never consumes the readiness flip budget (a 'ready' -// status resets the latch), so this retry budget is independent of -// ACTIVITY_PORTAL_READINESS_MAX_FLIPS and must not be derived from it. -const PORTAL_READY_REAPPLY_ATTEMPTS = 32 const WORKTREE_ID = 'wt-1' const TAB_ID = 'tab-react185' @@ -38,33 +29,19 @@ const thread = (tabId: string, leafId: string): ActivityPortalThreadRef => ({ tab: { id: tabId } }) -// Same-tab panes share one TerminalPane and swap via isolatedPaneKey. const PANE_A = thread(TAB_ID, LEAF_A) const PANE_B = thread(TAB_ID, LEAF_B) -// Cross-tab panes use separate TerminalPanes, so staging applies. const PANE_C = thread(OTHER_TAB_ID, LEAF_C) let root: Root -// Freeze Date (not timers/rAF) so the latch's flip window cannot expire between two drains on a -// loaded CI machine; the readiness frames are still driven by the controllers below. -beforeEach(() => { - vi.useFakeTimers({ toFake: ['Date'] }) -}) - afterEach(() => { - act(() => { - root?.unmount() - }) - vi.useRealTimers() + act(() => root?.unmount()) document.body.replaceChildren() vi.unstubAllGlobals() }) -function installAnimationFrameController(): { - flush: () => Promise - pending: () => number -} { +function installAnimationFrameController(): { flushUntilIdle: () => Promise } { let nextFrameId = 1 const callbacks = new Map() vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback): number => { @@ -76,80 +53,31 @@ function installAnimationFrameController(): { vi.stubGlobal('cancelAnimationFrame', (frameId: number): void => { callbacks.delete(frameId) }) + return { - async flush() { - const queued = Array.from(callbacks.values()) - callbacks.clear() - await act(async () => { - for (const callback of queued) { - callback(performance.now()) + async flushUntilIdle() { + for (let attempt = 0; attempt < 12; attempt += 1) { + await act(() => Promise.resolve()) + if (callbacks.size === 0) { + await act(() => Promise.resolve()) + if (callbacks.size === 0) { + return + } } - await Promise.resolve() - }) - }, - pending: () => callbacks.size - } -} - -function installMutationObserverController(): { notify: () => void } { - const callbacks = new Map() - class ControlledMutationObserver implements MutationObserver { - constructor(callback: MutationCallback) { - callbacks.set(this, callback) - } - - observe(): void {} - - disconnect(): void { - callbacks.delete(this) - } - - takeRecords(): MutationRecord[] { - return [] - } - } - vi.stubGlobal('MutationObserver', ControlledMutationObserver) - return { - notify() { - for (const [observer, callback] of callbacks) { - callback([], observer) + const queued = Array.from(callbacks.values()) + callbacks.clear() + await act(async () => { + for (const callback of queued) { + callback(performance.now()) + } + await Promise.resolve() + }) } + throw new Error('Activity portal readiness did not settle') } } } -async function flushPortalFramesUntil( - frames: ReturnType, - settled: () => boolean -): Promise { - for (let frame = 0; frame < 4 && !settled(); frame += 1) { - await frames.flush() - } -} - -// Drain MutationObserver microtasks and the readiness rAF they schedule. Reports whether the -// drain settled so a caller never reads a transition whose readiness callbacks are still queued. -async function flushPortalReadiness( - frames: ReturnType -): Promise { - for (let attempt = 0; attempt < 8; attempt += 1) { - await act(async () => { - await Promise.resolve() - }) - if (frames.pending() === 0) { - await act(async () => { - await Promise.resolve() - }) - if (frames.pending() === 0) { - return true - } - } - await frames.flush() - } - return frames.pending() === 0 -} - -// Models the tab-root DOM and sibling hiding emitted by a portaled TerminalPane. function renderPortaledTerminalPane(target: HTMLElement, tabId: string, leafIds: string[]): void { const isolatedLeafId = leafIds[0] const tabRoot = document.createElement('div') @@ -171,12 +99,12 @@ function renderPortaledTerminalPane(target: HTMLElement, tabId: string, leafIds: target.replaceChildren(tabRoot) } -// Exercises reconciliation, routing, readiness, and swapping on React's sync lane. async function runActivityPortalPage(args: { selectedThread: ActivityPortalThreadRef initialDisplayed: ActivityPortalThreadRef leafIdsByTabId: Record }): Promise<{ displayedPaneKey: string | null; renders: number }> { + const frames = installAnimationFrameController() const { selectedThread, initialDisplayed, leafIdsByTabId } = args const slotEls = { primary: document.createElement('div'), @@ -227,7 +155,6 @@ async function runActivityPortalPage(args: { }) } - // Match Terminal's one-pane-per-(worktree, tab) routing. useLayoutEffect(() => { slotEls.primary.replaceChildren() slotEls.secondary.replaceChildren() @@ -256,6 +183,7 @@ async function runActivityPortalPage(args: { useLayoutEffect(() => { const swap = resolveActivityPortalSwap({ + displayedPaneKey: displayed, selectedThread, selectedHasLiveTab: true, visibleThread, @@ -276,32 +204,25 @@ async function runActivityPortalPage(args: { if (swap?.kind === 'settle-visible') { setDisplayed(swap.paneKey) } - // Mirror ActivityPrototypePage's swap dependencies. - }, [inactiveSlotId, stagedStatus, stagedThread, visibleStatus, visibleThread]) + }, [displayed, inactiveSlotId, stagedStatus, stagedThread, visibleStatus, visibleThread]) return null } root = createRoot(document.createElement('div')) - await act(async () => { - root.render() - await new Promise((resolve) => setTimeout(resolve, 40)) - }) + act(() => root.render()) + await frames.flushUntilIdle() return { displayedPaneKey, renders } } describe('Activity portal pane switching', () => { it('converges on a newly selected pane of the tab already on screen', async () => { - // Same-tab staging would wait forever for a second TerminalPane that never mounts. - const run = (): Promise<{ displayedPaneKey: string | null; renders: number }> => - runActivityPortalPage({ - selectedThread: PANE_B, - initialDisplayed: PANE_A, - leafIdsByTabId: { [TAB_ID]: [LEAF_A, LEAF_B] } - }) - - const result = await run() + const result = await runActivityPortalPage({ + selectedThread: PANE_B, + initialDisplayed: PANE_A, + leafIdsByTabId: { [TAB_ID]: [LEAF_A, LEAF_B] } + }) expect(result.displayedPaneKey).toBe(PANE_B.paneKey) - expect(result.renders).toBeLessThan(50) + expect(result.renders).toBeLessThan(10) }) it('stages and swaps when the selected pane belongs to a different tab', async () => { @@ -311,162 +232,6 @@ describe('Activity portal pane switching', () => { leafIdsByTabId: { [TAB_ID]: [LEAF_A, LEAF_B], [OTHER_TAB_ID]: [LEAF_C] } }) expect(result.displayedPaneKey).toBe(PANE_C.paneKey) - expect(result.renders).toBeLessThan(50) - }) - - // Drive the latch through production wiring because React's nested-update limit is root-wide. - it('bounds a readiness oscillation driven through the real portal-status hook', async () => { - const frames = installAnimationFrameController() - const target = document.createElement('div') - document.body.append(target) - // Alternate hidden and ambiguous DOM states so ready remains unreachable. - const buildRoot = (hiddenLeafId: string | null): void => { - const tabRoot = document.createElement('div') - tabRoot.dataset.terminalTabId = TAB_ID - for (const leafId of [LEAF_A, LEAF_B]) { - const pane = document.createElement('div') - pane.dataset.leafId = leafId - pane.setAttribute('data-pty-id', `pty-${leafId}`) - pane.appendChild( - Object.assign(document.createElement('div'), { className: 'xterm-screen' }) - ) - if (leafId === hiddenLeafId) { - pane.style.display = 'none' - } - Object.defineProperty(pane, 'getClientRects', { value: () => [{}], configurable: true }) - tabRoot.appendChild(pane) - } - target.replaceChildren(tabRoot) - } - buildRoot(LEAF_A) - - let renders = 0 - const statuses: ActivityPortalReadinessStatus[] = [] - // Stop feeding an unlatched spin so failure is immediate and legible. - const RENDER_CAP = 50 - - function ActivityTerminalSlot(): null { - renders += 1 - const status = useActivityTerminalPortalStatus(target, PANE_A.paneKey) - statuses.push(status) - // Reapply opposite isolation so MutationObserver reports opposite readiness. - useLayoutEffect(() => { - if (renders > RENDER_CAP) { - return - } - if (status === 'unavailable') { - buildRoot(null) - } else if (status === 'loading') { - buildRoot(LEAF_A) - } - }) - return null - } - - root = createRoot(document.createElement('div')) - await act(async () => { - root.render() - }) - for (let frame = 0; frame < 20 && frames.pending() > 0; frame += 1) { - await frames.flush() - } - const settledRenders = renders - await frames.flush() - - expect(renders).toBeLessThanOrEqual(RENDER_CAP) - expect(renders).toBe(settledRenders) - expect(frames.pending()).toBe(0) - expect(statuses.at(-1)).toBe('unavailable') - }) - - it('releases a latched readiness once the terminal attaches', async () => { - const frames = installAnimationFrameController() - const mutations = installMutationObserverController() - const target = document.createElement('div') - document.body.append(target) - const buildRoot = (mode: 'hidden' | 'sibling' | 'ready'): void => { - const tabRoot = document.createElement('div') - tabRoot.dataset.terminalTabId = TAB_ID - for (const leafId of [LEAF_A, LEAF_B]) { - const pane = document.createElement('div') - pane.dataset.leafId = leafId - pane.setAttribute('data-pty-id', `pty-${leafId}`) - pane.appendChild( - Object.assign(document.createElement('div'), { className: 'xterm-screen' }) - ) - if (mode === 'hidden' && leafId === LEAF_A) { - pane.style.display = 'none' - } - if (mode === 'ready' && leafId === LEAF_B) { - pane.style.display = 'none' - } - Object.defineProperty(pane, 'getClientRects', { value: () => [{}], configurable: true }) - tabRoot.appendChild(pane) - } - target.replaceChildren(tabRoot) - } - buildRoot('hidden') - - const statuses: ActivityPortalReadinessStatus[] = [] - - function ActivityTerminalSlot(): null { - const status = useActivityTerminalPortalStatus(target, PANE_A.paneKey) - statuses.push(status) - return null - } - - root = createRoot(document.createElement('div')) - await act(async () => { - root.render() - }) - expect(await flushPortalReadiness(frames)).toBe(true) - await flushPortalFramesUntil(frames, () => statuses.at(-1) === 'unavailable') - expect(statuses.at(-1)).toBe('unavailable') - - // Feed each DOM state separately and keep going until sibling DOM reports latched - // unavailable. A fixed 9-flip budget flakes when CI load drops MutationObserver - // deliveries below ACTIVITY_PORTAL_READINESS_MAX_FLIPS transitions. - let sawSiblingLoading = false - let sawLatchedSibling = false - for ( - let flip = 0; - flip < ACTIVITY_PORTAL_READINESS_MAX_FLIPS * 4 && !sawLatchedSibling; - flip += 1 - ) { - const mode = flip % 2 === 0 ? 'sibling' : 'hidden' - const statusesBefore = statuses.length - await act(async () => { - buildRoot(mode) - mutations.notify() - }) - expect(await flushPortalReadiness(frames)).toBe(true) - if (mode !== 'sibling') { - continue - } - // Transition-local evidence: an unlatched subscription answers sibling DOM with 'loading', - // so the latch is only proven once a sibling transition that previously emitted 'loading' - // stops doing so and leaves 'unavailable' standing. - if (statuses.slice(statusesBefore).includes('loading')) { - sawSiblingLoading = true - } else if (sawSiblingLoading && statuses.at(-1) === 'unavailable') { - sawLatchedSibling = true - } - } - expect(sawLatchedSibling).toBe(true) - expect(statuses.at(-1)).toBe('unavailable') - - // Why: under CI load MutationObserver may miss one replaceChildren; re-apply ready DOM - // and keep draining until attach is observed. - let sawReady = false - for (let attempt = 0; attempt < PORTAL_READY_REAPPLY_ATTEMPTS && !sawReady; attempt += 1) { - await act(async () => { - buildRoot('ready') - mutations.notify() - }) - expect(await flushPortalReadiness(frames)).toBe(true) - await flushPortalFramesUntil(frames, () => statuses.at(-1) === 'ready') - sawReady = statuses.at(-1) === 'ready' - } - expect(statuses.at(-1)).toBe('ready') + expect(result.renders).toBeLessThan(10) }) }) diff --git a/src/renderer/src/components/activity/activity-portal-readiness-oscillation.test.ts b/src/renderer/src/components/activity/activity-portal-readiness-oscillation.test.ts deleted file mode 100644 index ffb068c88ee..00000000000 --- a/src/renderer/src/components/activity/activity-portal-readiness-oscillation.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - ACTIVITY_PORTAL_READINESS_BURST_WINDOW_MS, - ACTIVITY_PORTAL_READINESS_MAX_FLIPS, - createActivityPortalReadinessLatch -} from './activity-portal-readiness-oscillation' - -describe('createActivityPortalReadinessLatch', () => { - it('latches to unavailable once loading<->unavailable keeps flipping', () => { - const latch = createActivityPortalReadinessLatch() - const seen: string[] = [] - for (let i = 0; i < 40; i += 1) { - seen.push(latch.next(i % 2 === 0 ? 'loading' : 'unavailable')) - } - // Settle well before React's 50 nested sync updates throw #185. - expect(seen.slice(-10).every((status) => status === 'unavailable')).toBe(true) - expect(seen.indexOf('unavailable')).toBeLessThan(ACTIVITY_PORTAL_READINESS_MAX_FLIPS + 2) - }) - - it('passes through a normal loading -> ready startup', () => { - const latch = createActivityPortalReadinessLatch() - expect(latch.next('loading')).toBe('loading') - expect(latch.next('ready')).toBe('ready') - expect(latch.next('ready')).toBe('ready') - }) - - it('does not latch when ready keeps refunding the budget', () => { - const latch = createActivityPortalReadinessLatch() - for (let i = 0; i < 30; i += 1) { - expect(latch.next('loading')).toBe('loading') - expect(latch.next('ready')).toBe('ready') - } - }) - - it('tolerates a few legitimate flips while xterm attaches', () => { - const latch = createActivityPortalReadinessLatch() - expect(latch.next('loading')).toBe('loading') - expect(latch.next('unavailable')).toBe('unavailable') - expect(latch.next('loading')).toBe('loading') - expect(latch.next('ready')).toBe('ready') - }) - - it('releases once the terminal genuinely comes up after a churny attach', () => { - // A slow SSH host may burn the flip budget before attaching successfully. - const latch = createActivityPortalReadinessLatch() - for (let i = 0; i < ACTIVITY_PORTAL_READINESS_MAX_FLIPS + 4; i += 1) { - latch.next(i % 2 === 0 ? 'loading' : 'unavailable') - } - expect(latch.next('unavailable')).toBe('unavailable') - expect(latch.next('ready')).toBe('ready') - expect(latch.next('loading')).toBe('loading') - expect(latch.next('ready')).toBe('ready') - }) - - it('holds the latch for as long as the DOM keeps flipping', () => { - // The field loop ran 239 renders in 847ms, so the window never empties while it spins. - const clock = { ms: 0 } - const latch = createActivityPortalReadinessLatch(() => clock.ms) - const seen: string[] = [] - for (let i = 0; i < 240; i += 1) { - clock.ms += 4 - seen.push(latch.next(i % 2 === 0 ? 'loading' : 'unavailable')) - } - expect( - seen.slice(ACTIVITY_PORTAL_READINESS_MAX_FLIPS + 2).every((s) => s === 'unavailable') - ).toBe(true) - }) - - it('releases a latched pane once the churn stops, so a slow attach still reports loading', () => { - // A latched pane must not answer for the next pane the Activity slot shows (SSH panes take seconds). - const clock = { ms: 0 } - const latch = createActivityPortalReadinessLatch(() => clock.ms) - for (let i = 0; i < ACTIVITY_PORTAL_READINESS_MAX_FLIPS * 2; i += 1) { - clock.ms += 5 - latch.next(i % 2 === 0 ? 'loading' : 'unavailable') - } - expect(latch.next('loading')).toBe('unavailable') - clock.ms += ACTIVITY_PORTAL_READINESS_BURST_WINDOW_MS - expect(latch.next('loading')).toBe('loading') - }) - - it('ignores flips paced by a human clicking between threads', () => { - // The latch outlives the subscription, so thread-hopping must never spend the burst budget. - const clock = { ms: 0 } - const latch = createActivityPortalReadinessLatch(() => clock.ms) - for (let i = 0; i < ACTIVITY_PORTAL_READINESS_MAX_FLIPS * 4; i += 1) { - clock.ms += 500 - expect(latch.next(i % 2 === 0 ? 'loading' : 'unavailable')).toBe( - i % 2 === 0 ? 'loading' : 'unavailable' - ) - } - }) -}) diff --git a/src/renderer/src/components/activity/activity-portal-readiness-oscillation.ts b/src/renderer/src/components/activity/activity-portal-readiness-oscillation.ts deleted file mode 100644 index e632bf7d8bd..00000000000 --- a/src/renderer/src/components/activity/activity-portal-readiness-oscillation.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { createActivityPortalChurnBudget } from './activity-portal-churn-budget' - -export type ActivityPortalReadinessStatus = 'loading' | 'ready' | 'unavailable' - -// Why: stop a subscription from repainting forever after frame coalescing breaks its sync cascade. -export const ACTIVITY_PORTAL_READINESS_MAX_FLIPS = 8 -/** Separates render-cadence churn from deliberate thread selection. */ -export const ACTIVITY_PORTAL_READINESS_BURST_WINDOW_MS = 500 - -export type ActivityPortalReadinessLatch = { - next: (status: ActivityPortalReadinessStatus) => ActivityPortalReadinessStatus -} - -/** Bounds non-ready flips across every identity hosted by one portal slot. */ -export function createActivityPortalReadinessLatch( - now: () => number = () => Date.now() -): ActivityPortalReadinessLatch { - let lastStatus: ActivityPortalReadinessStatus | null = null - const flipBudget = createActivityPortalChurnBudget({ - limit: ACTIVITY_PORTAL_READINESS_MAX_FLIPS, - windowMs: ACTIVITY_PORTAL_READINESS_BURST_WINDOW_MS, - now - }) - - return { - next(status) { - // Why: a slow terminal may become ready after exhausting the flip budget. - if (status === 'ready') { - lastStatus = status - flipBudget.clear() - return status - } - // Why: underlying flips must keep the latch engaged even while its output is stable. - const flipped = lastStatus !== null && lastStatus !== status - lastStatus = status - const spent = flipped ? flipBudget.record() : flipBudget.isSpent() - return spent ? 'unavailable' : status - } - } -} diff --git a/src/renderer/src/components/activity/activity-portal-readiness-subscription-churn.react185.test.tsx b/src/renderer/src/components/activity/activity-portal-readiness-subscription-churn.react185.test.tsx index d8b9c3caff4..f068959885b 100644 --- a/src/renderer/src/components/activity/activity-portal-readiness-subscription-churn.react185.test.tsx +++ b/src/renderer/src/components/activity/activity-portal-readiness-subscription-churn.react185.test.tsx @@ -2,40 +2,29 @@ import { act, useState } from 'react' import { flushSync } from 'react-dom' import { createRoot, type Root } from 'react-dom/client' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { useActivityTerminalPortalStatus } from './ActivityPrototypePage' -import { ACTIVITY_PORTAL_READINESS_MAX_FLIPS } from './activity-portal-readiness-oscillation' ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true const TAB_ID = 'tab-readiness-churn' -const OTHER_TAB_ID = 'tab-readiness-churn-other' const LEAF_A = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1' const LEAF_B = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb1' const LEAF_C = 'cccccccc-cccc-4ccc-8ccc-ccccccccccc1' const PANE_A = `${TAB_ID}:${LEAF_A}` const PANE_B = `${TAB_ID}:${LEAF_B}` -const OTHER_PANE_B = `${OTHER_TAB_ID}:${LEAF_B}` let root: Root -// Freeze Date (not timers/rAF, which still drive the readiness frames) so the latch's flip window is -// decided by the churn under test rather than by how slowly the machine runs it. -beforeEach(() => { - vi.useFakeTimers({ toFake: ['Date'] }) -}) - afterEach(() => { - act(() => { - root?.unmount() - }) - vi.useRealTimers() + act(() => root?.unmount()) document.body.replaceChildren() + vi.unstubAllGlobals() }) -function buildNeverReadyRoot(target: HTMLElement, tabId: string = TAB_ID): void { +function buildNeverReadyRoot(target: HTMLElement): void { const tabRoot = document.createElement('div') - tabRoot.dataset.terminalTabId = tabId + tabRoot.dataset.terminalTabId = TAB_ID for (const leafId of [LEAF_A, LEAF_C]) { const pane = document.createElement('div') pane.dataset.leafId = leafId @@ -47,20 +36,27 @@ function buildNeverReadyRoot(target: HTMLElement, tabId: string = TAB_ID): void target.replaceChildren(tabRoot) } -/** Lets the readiness rAF this churn step scheduled land in React state. */ -async function drainReadinessFrame(): Promise { - await act(async () => { - await new Promise((resolve) => requestAnimationFrame(() => resolve())) +function installAnimationFrameController(): { flush: () => Promise } { + let frame: FrameRequestCallback | null = null + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback): number => { + frame = callback + return 1 }) + vi.stubGlobal('cancelAnimationFrame', (): void => { + frame = null + }) + return { + async flush() { + const callback = frame + frame = null + await act(async () => callback?.(performance.now())) + } + } } describe('Activity portal readiness subscription churn', () => { - // Why this bound matters: React raises #185 only after >50 *consecutive* commits that each leave a - // pending sync/default lane; any commit that leaves none resets nestedUpdateCount. This pins the - // reason the readiness subscription can never be that source -- its only writer is an rAF callback, - // and the effect cleanup cancels that frame on every churn step, so a synchronous cascade of - // subscription churn commits nothing at all. A #185 on this page must come from another setState. it('contributes no state updates to a synchronous churn cascade past React nested-update limit', () => { + installAnimationFrameController() const target = document.createElement('div') buildNeverReadyRoot(target) document.body.append(target) @@ -81,17 +77,13 @@ describe('Activity portal readiness subscription churn', () => { } root = createRoot(document.createElement('div')) - act(() => { - root.render() - }) + act(() => root.render()) readinessCommits = 0 act(() => { expect(() => { for (let index = 0; index < 60; index += 1) { - flushSync(() => { - selectPane(index % 2 === 0 ? PANE_B : PANE_A) - }) + flushSync(() => selectPane(index % 2 === 0 ? PANE_B : PANE_A)) } }).not.toThrow() }) @@ -99,6 +91,7 @@ describe('Activity portal readiness subscription churn', () => { }) it('coalesces pane-key churn and commits the latest readiness', async () => { + const frames = installAnimationFrameController() const target = document.createElement('div') buildNeverReadyRoot(target) document.body.append(target) @@ -116,156 +109,52 @@ describe('Activity portal readiness subscription churn', () => { } root = createRoot(document.createElement('div')) - act(() => { - root.render() - }) + act(() => root.render()) act(() => { expect(() => { for (let index = 0; index < 29; index += 1) { - flushSync(() => { - selectPane(index % 2 === 0 ? PANE_B : PANE_A) - }) + flushSync(() => selectPane(index % 2 === 0 ? PANE_B : PANE_A)) } }).not.toThrow() }) expect(renders).toBeLessThanOrEqual(31) - await act(async () => { - await new Promise((resolve) => requestAnimationFrame(() => resolve())) - }) + await frames.flush() expect(status).toBe('unavailable') expect(renders).toBeLessThanOrEqual(32) }) - // Asserts the loop-terminating property, not just a final value: the status that drives the page's - // swap effect (stagedPortalUnavailable) must stop alternating part-way through the churn and stay - // put. While it alternates, every step re-targets both readiness subscriptions and re-stages. - function expectReadinessStopsAlternating(statuses: string[]): void { - // Sanity: the fixture really does alternate early, so the run below is the latch, not a rig. - const head = statuses.slice(0, ACTIVITY_PORTAL_READINESS_MAX_FLIPS) - expect(new Set(head)).toEqual(new Set(['loading', 'unavailable'])) - // Termination: the churn keeps flipping the DOM but the status the swap effect reads stops moving. - const tail = statuses.slice(ACTIVITY_PORTAL_READINESS_MAX_FLIPS) - expect(tail.length).toBeGreaterThan(20) - expect(new Set(tail)).toEqual(new Set(['unavailable'])) - } - - // Why: staging only happens across tabs (reconcileActivityPortalThreads returns no stagedThread for - // same-tab panes), so the production swap moves slot element, pane key AND tab id in lockstep -- - // every identity a subscription-scoped latch could key on. - it('stops alternating readiness when the slot, pane key and tab id all churn together', async () => { - const targets = [document.createElement('div'), document.createElement('div')] - buildNeverReadyRoot(targets[0], TAB_ID) - buildNeverReadyRoot(targets[1], OTHER_TAB_ID) - for (const target of targets) { - document.body.append(target) - } - - let selectFlip: (flip: number) => void = () => {} - let status = 'loading' - const settledStatuses: string[] = [] - - function ActivityTerminalSlot(): null { - const [flip, setFlip] = useState(0) - selectFlip = setFlip - // Alternating panes report 'loading' (unisolated sibling) then 'unavailable' (missing leaf). - status = useActivityTerminalPortalStatus( - targets[flip % 2], - flip % 2 === 0 ? PANE_A : OTHER_PANE_B - ) - return null - } - - root = createRoot(document.createElement('div')) - act(() => { - root.render() - }) - - for (let flip = 1; flip <= 40; flip += 1) { - await act(async () => { - selectFlip(flip) - }) - await drainReadinessFrame() - settledStatuses.push(status) - } - - expectReadinessStopsAlternating(settledStatuses) - }) - - it('stops alternating readiness when only the pane key churns within one tab', async () => { + it('reports each latest pane readiness after rapid changes', async () => { + const frames = installAnimationFrameController() const target = document.createElement('div') - buildNeverReadyRoot(target, TAB_ID) + buildNeverReadyRoot(target) document.body.append(target) let selectPane: (paneKey: string) => void = () => {} let status = 'loading' - const settledStatuses: string[] = [] function ActivityTerminalSlot(): null { - const [paneKey, setPaneKey] = useState(PANE_A) + const [paneKey, setPaneKey] = useState(PANE_B) selectPane = setPaneKey status = useActivityTerminalPortalStatus(target, paneKey) return null } root = createRoot(document.createElement('div')) - act(() => { - root.render() - }) + act(() => root.render()) + await frames.flush() + expect(status).toBe('unavailable') - for (let flip = 0; flip < 40; flip += 1) { - await act(async () => { - selectPane(flip % 2 === 0 ? PANE_B : PANE_A) - }) - await drainReadinessFrame() - settledStatuses.push(status) + for (let index = 0; index < 10; index += 1) { + const paneKey = index % 2 === 0 ? PANE_A : PANE_B + act(() => selectPane(paneKey)) + await frames.flush() + expect(status).toBe(paneKey === PANE_A ? 'loading' : 'unavailable') } - expectReadinessStopsAlternating(settledStatuses) - }) - - // Why: the flip budget outlives the subscription, so a burst window wide enough to cover ordinary - // clicking would let one user's thread-hopping answer 'unavailable' for the next healthy pane. - it('does not answer for a later attaching pane after human-paced selections', async () => { - const churnTarget = document.createElement('div') - buildNeverReadyRoot(churnTarget, TAB_ID) - const attachingTarget = document.createElement('div') - buildNeverReadyRoot(attachingTarget, OTHER_TAB_ID) - for (const target of [churnTarget, attachingTarget]) { - document.body.append(target) - } - - type SelectedPane = { target: HTMLElement; paneKey: string } - let selectPane: (pane: SelectedPane) => void = () => {} - let status = 'loading' - - function ActivityTerminalSlot(): null { - const [pane, setPane] = useState({ target: churnTarget, paneKey: PANE_A }) - selectPane = setPane - status = useActivityTerminalPortalStatus(pane.target, pane.paneKey) - return null - } - - root = createRoot(document.createElement('div')) - act(() => { - root.render() - }) - - // Two clicks a second between a live thread ('loading') and a retained one ('unavailable'). - for (let click = 1; click <= 10; click += 1) { - vi.setSystemTime(click * 500) - await act(async () => { - selectPane({ target: churnTarget, paneKey: click % 2 === 0 ? PANE_A : PANE_B }) - }) - await drainReadinessFrame() - } - - vi.setSystemTime(5_500) - await act(async () => { - selectPane({ target: attachingTarget, paneKey: `${OTHER_TAB_ID}:${LEAF_A}` }) - }) - await drainReadinessFrame() + act(() => selectPane(PANE_A)) + await frames.flush() expect(status).toBe('loading') }) }) diff --git a/src/renderer/src/components/activity/activity-portal-thread-reconciliation.test.ts b/src/renderer/src/components/activity/activity-portal-thread-reconciliation.test.ts index 8da62b43e56..26e18943aa8 100644 --- a/src/renderer/src/components/activity/activity-portal-thread-reconciliation.test.ts +++ b/src/renderer/src/components/activity/activity-portal-thread-reconciliation.test.ts @@ -38,6 +38,7 @@ function replaySwapCascade(args: { }) // Most permissive readiness the page can hand in, so no swap is withheld. const swap = resolveActivityPortalSwap({ + displayedPaneKey, selectedThread: args.selectedThread, selectedHasLiveTab: true, visibleThread, @@ -74,11 +75,30 @@ describe('resolveActivityPortalSwap cascade', () => { `${label}: ${kinds.join(',')}` ).toBeLessThanOrEqual(1) // And the cascade settles: no write at all once displayedPaneKey caught up. - expect(kinds.at(-1), `${label}: ${kinds.join(',')}`).toBe('settle-visible') + expect(kinds.at(-1), `${label}: ${kinds.join(',')}`).toBe('none') } } }) + it('clears only when a displayed pane remains', () => { + const args = { + selectedThread: null, + selectedHasLiveTab: false, + visibleThread: null, + stagedThread: null, + visiblePortalReady: false, + stagedPortalReady: false, + stagedPortalUnavailable: false + } + + expect( + resolveActivityPortalSwap({ ...args, displayedPaneKey: ALL_THREADS[0].paneKey }) + ).toEqual({ + kind: 'clear' + }) + expect(resolveActivityPortalSwap({ ...args, displayedPaneKey: null })).toBeNull() + }) + it('stages across tabs and refuses to stage inside one tab', () => { const sameTabDisplayed = reconcileActivityPortalThreads({ selectedThread: thread('tab-a', 'leaf-9'), diff --git a/src/renderer/src/components/activity/activity-portal-thread-reconciliation.ts b/src/renderer/src/components/activity/activity-portal-thread-reconciliation.ts index a65aefaeb02..a2dc25ddb0c 100644 --- a/src/renderer/src/components/activity/activity-portal-thread-reconciliation.ts +++ b/src/renderer/src/components/activity/activity-portal-thread-reconciliation.ts @@ -52,6 +52,7 @@ export type ActivityPortalSwap = /** Decides how displayedPaneKey advances for one commit. */ export function resolveActivityPortalSwap(args: { + displayedPaneKey: string | null selectedThread: TThread | null selectedHasLiveTab: boolean visibleThread: TThread | null @@ -61,6 +62,7 @@ export function resolveActivityPortalSwap> + +export function MobileIdentityResetSection(): React.JSX.Element { + const confirm = useConfirmationDialog() + const mountedRef = useMountedRef() + const [status, setStatus] = useState({ + inProgress: false, + record: null + }) + const [busy, setBusy] = useState(false) + + const refresh = useCallback(async (): Promise => { + try { + const next = await window.api.mobile.getIdentityResetStatus() + if (mountedRef.current) { + setStatus(next) + } + } catch { + // The status is advisory; reset itself remains authoritative in the main process. + } + }, [mountedRef]) + + useEffect(() => { + void refresh() + }, [refresh]) + + useEffect(() => { + if (!status.inProgress) { + return + } + const timer = window.setInterval(() => void refresh(), 1000) + return () => window.clearInterval(timer) + }, [refresh, status.inProgress]) + + const reset = useCallback(async (): Promise => { + if (busy) { + return + } + if (!status.inProgress) { + const confirmed = await confirm({ + title: translate( + 'auto.components.settings.MobileIdentityResetSection.confirmTitle', + 'Reset access identity?' + ), + description: translate( + 'auto.components.settings.MobileIdentityResetSection.confirmDescription', + 'Existing mobile, runtime, and remote connections will be disconnected and must be paired again.' + ), + confirmLabel: translate( + 'auto.components.settings.MobileIdentityResetSection.confirmLabel', + 'Reset access identity' + ), + confirmVariant: 'destructive' + }) + if (!confirmed) { + return + } + } + setBusy(true) + try { + await window.api.mobile.resetIdentity() + await refresh() + toast.success( + translate( + 'auto.components.settings.MobileIdentityResetSection.completed', + 'Access identity reset. Pair devices again to restore access.' + ) + ) + } catch (error) { + await refresh() + toast.error( + error instanceof Error + ? error.message + : translate( + 'auto.components.settings.MobileIdentityResetSection.failed', + 'Access identity reset is still pending. Retry when the required hosts are available.' + ) + ) + } finally { + if (mountedRef.current) { + setBusy(false) + } + } + }, [busy, confirm, mountedRef, refresh, status.inProgress]) + + const phase = status.record?.phase + return ( + +
+

+ {status.inProgress + ? translate( + 'auto.components.settings.MobileIdentityResetSection.pending', + 'Reset is waiting for every known host and Relay acknowledgement. Keep Orca open or retry after unavailable hosts return.' + ) + : translate( + 'auto.components.settings.MobileIdentityResetSection.helper', + 'Use this when the current access identity should no longer be trusted. All devices must be paired again.' + )} +

+ {phase ? ( +

Phase: {phase}

+ ) : null} + +
+
+ ) +} diff --git a/src/renderer/src/components/settings/MobileSettingsPane.tsx b/src/renderer/src/components/settings/MobileSettingsPane.tsx index 6e0ccc2dfe3..1ca4aae84c4 100644 --- a/src/renderer/src/components/settings/MobileSettingsPane.tsx +++ b/src/renderer/src/components/settings/MobileSettingsPane.tsx @@ -9,6 +9,7 @@ import { import { translate } from '@/i18n/i18n' import { useAppStore } from '@/store' import { MobileRelayBetaNotice } from './MobileRelayBetaNotice' +import { MobileIdentityResetSection } from './MobileIdentityResetSection' export { getMobileSettingsPaneSearchEntries } const ORCA_IOS_APP_STORE_URL = 'https://apps.apple.com/app/orca-ide/id6766130217' @@ -91,6 +92,8 @@ export function MobileSettingsPane(): React.JSX.Element { /> + +
diff --git a/src/renderer/src/components/shared/kill-all-terminal-surfaces.ts b/src/renderer/src/components/shared/kill-all-terminal-surfaces.ts index 311b69c3592..8d7901e0dd6 100644 --- a/src/renderer/src/components/shared/kill-all-terminal-surfaces.ts +++ b/src/renderer/src/components/shared/kill-all-terminal-surfaces.ts @@ -1,4 +1,5 @@ import { useAppStore, type AppState } from '@/store' +import { killPtyAtCurrentIncarnation } from '@/lib/pty-administrative-mutations' import { closeTerminalTab, type PrecomputedTerminalCloseState @@ -131,7 +132,7 @@ function createDefaultDependencies(): KillAllTerminalSurfaceDependencies { killDaemonSessions: () => window.api.pty.management.killAll(), notifyInventoryInvalidated: notifyDaemonSessionInventoryInvalidated, closeSurface: closeTerminalTab, - killPty: (ptyId) => window.api.pty.kill(ptyId), + killPty: killPtyAtCurrentIncarnation, now: () => globalThis.performance?.now() ?? Date.now(), yieldToRenderer: () => new Promise((resolve) => { diff --git a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx index fe0f5e1a297..1616b33d616 100644 --- a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx @@ -27,6 +27,10 @@ import { cn } from '@/lib/utils' import { useMountedRef } from '@/hooks/useMountedRef' import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane' +import { + killPtyAtCurrentIncarnation, + killPtyWithAdministrativeMutationAccess +} from '@/lib/pty-administrative-mutations' import { useAppStore } from '../../store' import { useWorktreeMap } from '../../store/selectors' import { runWorktreeDelete } from '../sidebar/delete-worktree-flow' @@ -1052,7 +1056,12 @@ export function ResourceUsageStatusSegment({ // Why: await the kill before refreshing, else the refresh re-reads the daemon list before the kill lands and re-adds the row. void (async () => { try { - await window.api.pty.kill(session.sessionId) + await (session.administrativeMutationAccess + ? killPtyWithAdministrativeMutationAccess( + session.sessionId, + session.administrativeMutationAccess + ) + : killPtyAtCurrentIncarnation(session.sessionId)) } catch { /* already dead */ } @@ -1078,7 +1087,16 @@ export function ResourceUsageStatusSegment({ // Why: optimistic removal so rows disappear immediately instead of waiting for the next daemon-side list refresh. const orphanIds = new Set(orphans.map((s) => s.id)) removeSessions(orphanIds) - await Promise.allSettled(orphans.map((s) => window.api.pty.kill(s.id))) + await Promise.allSettled( + orphans.map((session) => + session.administrativeMutationAccess + ? killPtyWithAdministrativeMutationAccess( + session.id, + session.administrativeMutationAccess + ) + : killPtyAtCurrentIncarnation(session.id) + ) + ) void refreshSessions() }, [sessions, resourceSessionBindings, workspaceSessionReady, refreshSessions, removeSessions]) @@ -1091,7 +1109,12 @@ export function ResourceUsageStatusSegment({ // Why: optimistic removal avoids a flash where the dialog closes but the killed row lingers until the next list refresh. removeSession(target.sessionId) try { - await window.api.pty.kill(target.sessionId) + await (target.administrativeMutationAccess + ? killPtyWithAdministrativeMutationAccess( + target.sessionId, + target.administrativeMutationAccess + ) + : killPtyAtCurrentIncarnation(target.sessionId)) } catch { /* already dead — fall through */ } finally { diff --git a/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts b/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts index d02588001b9..15a287f0307 100644 --- a/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts +++ b/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts @@ -148,6 +148,13 @@ export function mergeSnapshotAndSessions( const ownershipBySessionId = new Map( daemonSessions.map((session) => [session.id, session.agentOwnership]) ) + const mutationAccessBySessionId = new Map( + daemonSessions.flatMap((session) => + session.administrativeMutationAccess + ? [[session.id, session.administrativeMutationAccess] as const] + : [] + ) + ) function isRepoRemote(repoId: string): boolean { // Why: missing entry === we don't know about this repo (typically the @@ -209,6 +216,7 @@ export function mergeSnapshotAndSessions( label: resolveSnapshotSessionLabel(s, wt.worktreeId, ctx), bound: ctx.workspaceSessionReady && boundPtyIds.has(s.sessionId), agentOwnership: ownershipBySessionId.get(s.sessionId) ?? 'unknown', + administrativeMutationAccess: mutationAccessBySessionId.get(s.sessionId), tabId, cpu: s.cpu, memory: s.memory, @@ -297,6 +305,7 @@ export function mergeSnapshotAndSessions( label: resolveDaemonSessionLabel(session, worktreeId, tabId, ctx), bound: ctx.workspaceSessionReady && boundPtyIds.has(session.id), agentOwnership: session.agentOwnership, + administrativeMutationAccess: session.administrativeMutationAccess, tabId, cpu: null, memory: null, diff --git a/src/renderer/src/components/status-bar/resource-usage-merge-types.ts b/src/renderer/src/components/status-bar/resource-usage-merge-types.ts index c365329c110..90123cc9979 100644 --- a/src/renderer/src/components/status-bar/resource-usage-merge-types.ts +++ b/src/renderer/src/components/status-bar/resource-usage-merge-types.ts @@ -8,6 +8,7 @@ import type { AgentOwnershipEvidence, PtyListedSession } from '../../../../shared/pty-listed-session' +import type { PtyAdministrativeMutationAccess } from '../../../../shared/pty-mutation-identity' /** `null` === "no local sample" (e.g. SSH PTY); UI renders as em-dash. */ export type Metric = number | null @@ -23,6 +24,7 @@ export type UnifiedSessionRow = { bound: boolean /** Ownership as the provider could establish it; anything but `absent` means confirm first. */ agentOwnership: AgentOwnershipEvidence + administrativeMutationAccess?: PtyAdministrativeMutationAccess tabId: string | null cpu: Metric memory: Metric diff --git a/src/renderer/src/components/terminal-pane/LegacyTerminalWorkspaceSurface.test.tsx b/src/renderer/src/components/terminal-pane/LegacyTerminalWorkspaceSurface.test.tsx new file mode 100644 index 00000000000..2ec522c0a63 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/LegacyTerminalWorkspaceSurface.test.tsx @@ -0,0 +1,122 @@ +// @vitest-environment happy-dom +import { render } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { TerminalTab } from '../../../../shared/types' + +const mocks = vi.hoisted(() => ({ + parkableTabIds: new Set(), + parkedTabIds: new Set(), + handoffCalls: [] as { + desiredParkedTabIds: ReadonlySet + activationDeferredMountTabIds?: ReadonlySet | null + }[], + terminalPaneProps: [] as Record[] +})) + +vi.mock('./terminal-parked-tab-watchers', () => ({ + canWatcherCoverParkedTerminalTab: (_worktreeId: string, tab: { id: string }) => + mocks.parkableTabIds.has(tab.id) +})) + +vi.mock('./use-terminal-tab-park-handoff', () => ({ + useTerminalTabParkHandoff: (args: { + desiredParkedTabIds: ReadonlySet + activationDeferredMountTabIds?: ReadonlySet | null + }) => { + mocks.handoffCalls.push(args) + return new Set(mocks.parkedTabIds) + } +})) + +vi.mock('./TerminalPane', () => ({ + default: (props: Record) => { + mocks.terminalPaneProps.push({ + tabId: props.tabId, + terminalGeneration: props.terminalGeneration, + cwd: props.cwd + }) + return
+ } +})) + +import { LegacyTerminalWorkspaceSurface } from './LegacyTerminalWorkspaceSurface' + +const WORKTREE_ID = 'folder:workspace-1' + +function terminalTab(id: string, generation: number): TerminalTab { + return { + id, + generation, + ptyId: `ssh:connection@@${id}`, + worktreeId: WORKTREE_ID, + title: id, + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 0 + } +} + +function renderSurface(args?: { + shouldColdParkTerminalPanes?: boolean + activationDeferredMountTabIds?: ReadonlySet | null +}): React.JSX.Element { + return ( + + ) +} + +describe('LegacyTerminalWorkspaceSurface', () => { + beforeEach(() => { + mocks.parkableTabIds.clear() + mocks.parkedTabIds.clear() + mocks.handoffCalls.length = 0 + mocks.terminalPaneProps.length = 0 + }) + + it('keeps panes mounted until watcher preparation completes and preserves generation identity', () => { + const view = render(renderSurface({ shouldColdParkTerminalPanes: true })) + + expect(view.getByTestId('terminal-pane-tab-1')).toBeTruthy() + expect(mocks.handoffCalls.at(-1)?.desiredParkedTabIds).toEqual(new Set(['tab-1', 'tab-2'])) + expect(mocks.terminalPaneProps[0]).toMatchObject({ + tabId: 'tab-1', + terminalGeneration: 7, + cwd: 'C:\\folder workspace' + }) + + mocks.parkedTabIds = new Set(['tab-1', 'tab-2']) + view.rerender(renderSurface({ shouldColdParkTerminalPanes: true })) + expect(view.queryByTestId('terminal-pane-tab-1')).toBeNull() + + mocks.parkedTabIds.clear() + view.rerender(renderSurface()) + expect(view.getByTestId('terminal-pane-tab-1')).toBeTruthy() + expect(mocks.handoffCalls.at(-1)?.desiredParkedTabIds).toEqual(new Set()) + }) + + it('defers only tabs with exact durable watcher coverage', () => { + mocks.parkableTabIds.add('tab-2') + const deferredTabIds = new Set(['tab-1', 'tab-2']) + + render(renderSurface({ activationDeferredMountTabIds: deferredTabIds })) + + expect(mocks.handoffCalls.at(-1)?.desiredParkedTabIds).toEqual(new Set(['tab-2'])) + expect(mocks.handoffCalls.at(-1)?.activationDeferredMountTabIds).toBe(deferredTabIds) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/LegacyTerminalWorkspaceSurface.tsx b/src/renderer/src/components/terminal-pane/LegacyTerminalWorkspaceSurface.tsx new file mode 100644 index 00000000000..7e342753d8a --- /dev/null +++ b/src/renderer/src/components/terminal-pane/LegacyTerminalWorkspaceSurface.tsx @@ -0,0 +1,132 @@ +import { memo, useMemo } from 'react' +import { createPortal } from 'react-dom' +import type { TerminalTab } from '../../../../shared/types' +import { + findActivityTerminalPortal, + type ActivityTerminalPortalTarget +} from '../activity/activity-terminal-portal' +import { shouldMountBackgroundWorktreeTab } from '../terminal/background-terminal-worktree-mount' +import TerminalPane from './TerminalPane' +import { canWatcherCoverParkedTerminalTab } from './terminal-parked-tab-watchers' +import { useTerminalTabParkHandoff } from './use-terminal-tab-park-handoff' + +export const LegacyTerminalWorkspaceSurface = memo(function LegacyTerminalWorkspaceSurface({ + worktreeId, + worktreePath, + terminalTabs, + activeTabId, + isVisible, + isTerminalTabTypeActive, + shouldMeasureHiddenWorktree, + shouldColdParkTerminalPanes, + activityTerminalPortals, + evictionExemptTerminalTabIds, + backgroundMountTabIds, + activationDeferredMountTabIds, + onPtyExit, + onCloseTab +}: { + worktreeId: string + worktreePath: string + terminalTabs: readonly TerminalTab[] + activeTabId: string | null + isVisible: boolean + isTerminalTabTypeActive: boolean + shouldMeasureHiddenWorktree: boolean + shouldColdParkTerminalPanes: boolean + activityTerminalPortals: ActivityTerminalPortalTarget[] + evictionExemptTerminalTabIds: ReadonlySet + backgroundMountTabIds: ReadonlySet | null + activationDeferredMountTabIds: ReadonlySet | null + onPtyExit: (tabId: string, ptyId: string) => void + onCloseTab: (tabId: string) => void +}): React.JSX.Element { + const desiredParkedTerminalTabIds = useMemo(() => { + const parked = new Set() + for (const tab of terminalTabs) { + const hasActivityPortal = + findActivityTerminalPortal(activityTerminalPortals, { + worktreeId, + tabId: tab.id + }) !== null + if ( + shouldColdParkTerminalPanes && + !hasActivityPortal && + !evictionExemptTerminalTabIds.has(tab.id) + ) { + parked.add(tab.id) + } + if ( + activationDeferredMountTabIds?.has(tab.id) && + !hasActivityPortal && + canWatcherCoverParkedTerminalTab(worktreeId, tab) + ) { + parked.add(tab.id) + } + } + return parked + }, [ + activationDeferredMountTabIds, + activityTerminalPortals, + evictionExemptTerminalTabIds, + shouldColdParkTerminalPanes, + terminalTabs, + worktreeId + ]) + const parkedTerminalTabIds = useTerminalTabParkHandoff({ + worktreeId, + terminalTabs, + desiredParkedTabIds: desiredParkedTerminalTabIds, + shouldTrackPaneCandidates: + shouldColdParkTerminalPanes || (activationDeferredMountTabIds?.size ?? 0) > 0, + activationDeferredMountTabIds + }) + + return ( +
+ {terminalTabs + .filter((tab) => shouldMountBackgroundWorktreeTab(backgroundMountTabIds, tab.id)) + .map((tab) => { + const activityTerminalPortal = findActivityTerminalPortal(activityTerminalPortals, { + worktreeId, + tabId: tab.id + }) + if (parkedTerminalTabIds.has(tab.id)) { + return null + } + const isActiveTerminalTab = isVisible && tab.id === activeTabId && isTerminalTabTypeActive + const terminalPane = ( + onPtyExit(tab.id, ptyId)} + onCloseTab={() => onCloseTab(tab.id)} + /> + ) + return activityTerminalPortal + ? createPortal( + terminalPane, + activityTerminalPortal.target, + `activity-terminal-${tab.id}` + ) + : terminalPane + })} +
+ ) +}) diff --git a/src/renderer/src/components/terminal-pane/TerminalLegacyRecoveryBanner.test.tsx b/src/renderer/src/components/terminal-pane/TerminalLegacyRecoveryBanner.test.tsx new file mode 100644 index 00000000000..15d234ff957 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/TerminalLegacyRecoveryBanner.test.tsx @@ -0,0 +1,256 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' +import { cleanup, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { + TerminalLegacyPreservationFacts, + TerminalLegacyRecoveryReason +} from '../../../../shared/terminal-legacy-cutover' +import { TerminalLegacyRecoveryBanner } from './TerminalLegacyRecoveryBanner' +import type { + TerminalLegacyRecoveryNotice, + TerminalLegacyUnresolvedRecoveryNotice +} from './terminal-legacy-recovery-view-model' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string, options?: Record) => + fallback.replace('{{value0}}', String(options?.value0 ?? '{{value0}}')) +})) + +afterEach(cleanup) + +const REDACTED_VALUES = { + canonicalPath: '/Users/person/secret-project', + workspaceId: 'folder:/Users/person/secret-project', + paneId: 'secret-tab:secret-pane', + ptyId: 'secret-physical-pty', + processId: 9842, + endpoint: '\\\\.\\pipe\\secret-legacy-relay', + credential: '/Users/person/.orca/secret-credential', + receiptId: 'secret-receipt', + birthMarker: 'secret-process-birth-marker' +} as const + +type WorkspaceKind = TerminalLegacyUnresolvedRecoveryNotice['workspaceKind'] + +function makeUnresolved( + options: Readonly<{ + recoveryKey?: string + reason?: TerminalLegacyRecoveryReason + preservationKind?: TerminalLegacyPreservationFacts['kind'] + workspaceKind?: WorkspaceKind + evidenceDigest?: string + observedAtMs?: number + }> = {} +): TerminalLegacyUnresolvedRecoveryNotice { + return { + recoveryKey: options.recoveryKey ?? 'recovery-key-a', + status: 'unresolved', + reason: options.reason ?? 'ambiguous-pane-generation', + preservationKind: options.preservationKind ?? 'evidence-gc-retained', + workspaceKind: options.workspaceKind ?? 'git-worktree', + evidenceDigest: options.evidenceDigest ?? 'sha256:safe-support-digest', + observedAtMs: options.observedAtMs ?? Date.UTC(2026, 7, 6, 10, 30), + discoveredAtMs: Date.UTC(2026, 7, 6, 10, 31), + updatedAtMs: Date.UTC(2026, 7, 6, 10, 32) + } +} + +function makeImported(): TerminalLegacyRecoveryNotice { + return { + recoveryKey: 'recovery-key-imported', + status: 'imported', + workspaceKind: 'folder', + evidenceDigest: 'sha256:imported', + observedAtMs: 100, + discoveredAtMs: 101, + updatedAtMs: 102 + } +} + +function makeAcknowledged(): TerminalLegacyRecoveryNotice { + return { + ...makeUnresolved({ recoveryKey: 'recovery-key-acknowledged' }), + status: 'acknowledged' + } +} + +function renderBanner( + recoveries: readonly TerminalLegacyRecoveryNotice[], + writeClipboardText = vi.fn<(text: string) => Promise>().mockResolvedValue(undefined), + rootClassName?: string +) { + return render( + + ) +} + +async function revealDetails(): Promise { + await userEvent.setup().click(screen.getByRole('button', { name: 'Show details' })) +} + +describe('TerminalLegacyRecoveryBanner summary', () => { + it('shows one unresolved terminal without claiming a process outcome', () => { + renderBanner([ + makeUnresolved({ preservationKind: 'isolated-grace-disabled', workspaceKind: 'folder' }) + ]) + + expect(screen.getByText('Previous terminal needs review')).toBeInTheDocument() + expect(screen.getByText(/did not attach to, stop, or replace it/)).toBeInTheDocument() + expect(screen.getByText(/shutdown grace is disabled/)).toBeInTheDocument() + expect(screen.queryByText('Folder workspace')).not.toBeInTheDocument() + }) + + it('counts only unresolved rows and distinguishes mixed preservation facts', () => { + renderBanner([ + makeImported(), + makeAcknowledged(), + makeUnresolved({ + recoveryKey: 'unresolved-a', + preservationKind: 'isolated-grace-disabled' + }), + makeUnresolved({ recoveryKey: 'unresolved-b', preservationKind: 'worker-unreachable' }) + ]) + + expect(screen.getByText('2 previous terminals need review')).toBeInTheDocument() + expect(screen.getByText(/did not attach to, stop, or replace them/)).toBeInTheDocument() + expect(screen.getByText(/Some previous endpoints are isolated/)).toBeInTheDocument() + }) + + it('renders nothing when every row is resolved', () => { + const { container } = renderBanner([makeImported(), makeAcknowledged()]) + expect(container).toBeEmptyDOMElement() + }) + + it.each([ + ['evidence-gc-retained', /retained the recovery evidence.*could not prove/s], + ['worker-unreachable', /previous worker was unreachable.*could not prove/s], + ['unsupported-platform', /platform could not provide.*could not prove/s] + ] as const)('states the limits of %s preservation', (preservationKind, expected) => { + renderBanner([makeUnresolved({ preservationKind })]) + expect(screen.getByText(expected)).toBeInTheDocument() + }) +}) + +describe('TerminalLegacyRecoveryBanner disclosure', () => { + it('supports keyboard disclosure without resizing terminal layout', async () => { + renderBanner([makeUnresolved()], undefined, 'bottom-16') + const trigger = screen.getByRole('button', { name: 'Show details' }) + const root = document.querySelector('[data-terminal-legacy-recovery-banner]') + + expect(trigger).toHaveAttribute('aria-expanded', 'false') + expect(root).toHaveClass('absolute', 'bottom-16') + expect(screen.getByRole('status')).toHaveAttribute('aria-live', 'polite') + + trigger.focus() + await userEvent.setup().keyboard('{Enter}') + expect(screen.getByRole('button', { name: 'Hide details' })).toHaveAttribute( + 'aria-expanded', + 'true' + ) + expect(screen.getByText('Evidence digest')).toBeInTheDocument() + }) + + it.each([ + ['ambiguous-pane-generation', 'Pane generation is ambiguous'], + ['endpoint-identity-unproved', 'Endpoint identity could not be proved'], + ['physical-pty-incarnation-unproved', 'Physical terminal identity could not be proved'], + ['unsupported-platform', 'Platform proof is unavailable'], + ['worker-unreachable', 'Previous terminal worker was unreachable'], + ['workspace-mismatch', 'Workspace evidence did not match'] + ] as const)('labels the %s reason', async (reason, label) => { + renderBanner([makeUnresolved({ reason })]) + await revealDetails() + expect(screen.getByText(label)).toBeInTheDocument() + }) + + it.each([ + ['git-worktree', 'Git worktree'], + ['folder', 'Folder workspace'], + ['floating', 'Floating terminal'] + ] as const)('labels %s recovery evidence', async (workspaceKind, label) => { + renderBanner([makeUnresolved({ workspaceKind })]) + await revealDetails() + expect(screen.getByText(label)).toBeInTheDocument() + }) + + it('labels an extreme timestamp unavailable instead of throwing', async () => { + renderBanner([makeUnresolved({ observedAtMs: Number.MAX_SAFE_INTEGER })]) + await revealDetails() + expect(screen.getByText('Unavailable')).toBeInTheDocument() + }) + + it('exposes only disclosure and copy controls', async () => { + renderBanner([makeUnresolved()]) + await revealDetails() + + expect(screen.getAllByRole('button').map((button) => button.textContent?.trim())).toEqual([ + 'Hide details', + 'Copy details' + ]) + expect( + screen.queryByRole('button', { + name: /attach|stop|kill|replace|respawn|restart|expire|dismiss|delete/i + }) + ).not.toBeInTheDocument() + }) +}) + +describe('TerminalLegacyRecoveryBanner copy', () => { + it('never projects unexpected catalog fields into the DOM or clipboard', async () => { + const contaminatedUnresolved = { ...makeUnresolved(), ...REDACTED_VALUES } + const contaminatedImported = { ...makeImported(), ...REDACTED_VALUES } + const writeClipboardText = vi.fn<(text: string) => Promise>().mockResolvedValue() + const { container } = renderBanner( + [contaminatedImported, contaminatedUnresolved], + writeClipboardText + ) + await revealDetails() + await userEvent.setup().click(screen.getByRole('button', { name: 'Copy details' })) + + await waitFor(() => expect(writeClipboardText).toHaveBeenCalledOnce()) + expect(await screen.findByText('Recovery details copied.')).toBeInTheDocument() + const copied = writeClipboardText.mock.calls[0][0] + expect(JSON.parse(copied)).toEqual({ + kind: 'orca-terminal-legacy-recovery', + formatVersion: 1, + reason: 'ambiguous-pane-generation', + preservationKind: 'evidence-gc-retained', + workspaceKind: 'git-worktree', + evidenceDigest: 'sha256:safe-support-digest', + observedAtMs: Date.UTC(2026, 7, 6, 10, 30), + discoveredAtMs: Date.UTC(2026, 7, 6, 10, 31), + updatedAtMs: Date.UTC(2026, 7, 6, 10, 32) + }) + for (const secret of Object.values(REDACTED_VALUES)) { + expect(container.innerHTML).not.toContain(String(secret)) + expect(copied).not.toContain(String(secret)) + } + }) + + it('reports a failed copy inline and allows a retry', async () => { + const writeClipboardText = vi + .fn<(text: string) => Promise>() + .mockRejectedValueOnce(new Error('clipboard unavailable')) + .mockResolvedValueOnce(undefined) + renderBanner([makeUnresolved()], writeClipboardText) + await revealDetails() + const user = userEvent.setup() + + await user.click(screen.getByRole('button', { name: 'Copy details' })) + expect(await screen.findByRole('alert')).toHaveTextContent( + 'Recovery details could not be copied. Try again.' + ) + + await user.click(screen.getByRole('button', { name: 'Copy details' })) + expect(await screen.findByText('Recovery details copied.')).toBeInTheDocument() + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + expect(writeClipboardText).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/TerminalLegacyRecoveryBanner.tsx b/src/renderer/src/components/terminal-pane/TerminalLegacyRecoveryBanner.tsx new file mode 100644 index 00000000000..891debe4ecf --- /dev/null +++ b/src/renderer/src/components/terminal-pane/TerminalLegacyRecoveryBanner.tsx @@ -0,0 +1,355 @@ +import { useId, useMemo, useState } from 'react' +import { Check, ChevronRight, Copy, ShieldAlert } from 'lucide-react' +import type { + TerminalLegacyPreservationFacts, + TerminalLegacyRecoveryReason +} from '../../../../shared/terminal-legacy-cutover' +import { Button } from '@/components/ui/button' +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' +import { translate } from '@/i18n/i18n' +import { cn } from '@/lib/utils' +import { + formatLegacyRecoveryDetailsForClipboard, + selectUnresolvedLegacyRecoveries, + type TerminalLegacyRecoveryNotice, + type TerminalLegacyUnresolvedRecoveryNotice +} from './terminal-legacy-recovery-view-model' + +type CopyState = 'copying' | 'copied' | 'failed' + +function reasonLabel(reason: TerminalLegacyRecoveryReason): string { + switch (reason) { + case 'ambiguous-pane-generation': + return translate( + 'auto.components.terminal.pane.TerminalLegacyRecoveryBanner.reason.ambiguousPane', + 'Pane generation is ambiguous' + ) + case 'endpoint-identity-unproved': + return translate( + 'auto.components.terminal.pane.TerminalLegacyRecoveryBanner.reason.endpointUnproved', + 'Endpoint identity could not be proved' + ) + case 'physical-pty-incarnation-unproved': + return translate( + 'auto.components.terminal.pane.TerminalLegacyRecoveryBanner.reason.ptyUnproved', + 'Physical terminal identity could not be proved' + ) + case 'unsupported-platform': + return translate( + 'auto.components.terminal.pane.TerminalLegacyRecoveryBanner.reason.unsupportedPlatform', + 'Platform proof is unavailable' + ) + case 'worker-unreachable': + return translate( + 'auto.components.terminal.pane.TerminalLegacyRecoveryBanner.reason.workerUnreachable', + 'Previous terminal worker was unreachable' + ) + case 'workspace-mismatch': + return translate( + 'auto.components.terminal.pane.TerminalLegacyRecoveryBanner.reason.workspaceMismatch', + 'Workspace evidence did not match' + ) + } +} + +function preservationDescription(kind: TerminalLegacyPreservationFacts['kind']): string { + switch (kind) { + case 'isolated-grace-disabled': + return translate( + 'auto.components.terminal.pane.TerminalLegacyRecoveryBanner.preservation.isolated', + 'Orca retained the previous endpoint identity, isolated it from current terminal authority, and confirmed shutdown grace is disabled.' + ) + case 'evidence-gc-retained': + return translate( + 'auto.components.terminal.pane.TerminalLegacyRecoveryBanner.preservation.evidenceOnly', + 'Orca retained the recovery evidence, but could not prove whether the previous terminal process is still running.' + ) + case 'worker-unreachable': + return translate( + 'auto.components.terminal.pane.TerminalLegacyRecoveryBanner.preservation.unreachable', + 'The previous worker was unreachable. Orca retained this recovery record, but could not prove whether the terminal process is still running.' + ) + case 'unsupported-platform': + return translate( + 'auto.components.terminal.pane.TerminalLegacyRecoveryBanner.preservation.unsupported', + 'This platform could not provide the proof required for migration. Orca retained this recovery record, but could not prove whether the terminal process is still running.' + ) + } +} + +function workspaceLabel(kind: TerminalLegacyUnresolvedRecoveryNotice['workspaceKind']): string { + switch (kind) { + case 'git-worktree': + return translate( + 'auto.components.terminal.pane.TerminalLegacyRecoveryBanner.workspace.gitWorktree', + 'Git worktree' + ) + case 'folder': + return translate( + 'auto.components.terminal.pane.TerminalLegacyRecoveryBanner.workspace.folder', + 'Folder workspace' + ) + case 'floating': + return translate( + 'auto.components.terminal.pane.TerminalLegacyRecoveryBanner.workspace.floating', + 'Floating terminal' + ) + } +} + +function preservationSummary( + recoveries: readonly TerminalLegacyUnresolvedRecoveryNotice[] +): string { + if (recoveries.length === 1) { + return preservationDescription(recoveries[0].preservationKind) + } + const isolatedCount = recoveries.filter( + (recovery) => recovery.preservationKind === 'isolated-grace-disabled' + ).length + if (isolatedCount === recoveries.length) { + return translate( + 'auto.components.terminal.pane.TerminalLegacyRecoveryBanner.preservation.allIsolated', + 'Orca retained the previous endpoint identities, isolated them from current terminal authority, and confirmed shutdown grace is disabled.' + ) + } + if (isolatedCount === 0) { + return translate( + 'auto.components.terminal.pane.TerminalLegacyRecoveryBanner.preservation.allEvidenceOnly', + 'Orca retained recovery records, but could not prove whether the previous terminal processes are still running.' + ) + } + return translate( + 'auto.components.terminal.pane.TerminalLegacyRecoveryBanner.preservation.mixed', + 'Some previous endpoints are isolated with shutdown grace disabled. For the others, Orca retained recovery records but could not prove whether the processes are still running.' + ) +} + +function DetailField({ label, value }: { label: string; value: string | number | null }) { + if (value === null) { + return null + } + return ( + <> +
{label}
+
{value}
+ + ) +} + +function formatObservedAt(value: number): string { + const date = new Date(value) + if (Number.isNaN(date.getTime())) { + return translate( + 'auto.components.terminal.pane.TerminalLegacyRecoveryBanner.timestampUnavailable', + 'Unavailable' + ) + } + return date.toISOString() +} + +function LegacyRecoveryRow({ + recovery, + index, + copyState, + onCopy +}: { + recovery: TerminalLegacyUnresolvedRecoveryNotice + index: number + copyState?: CopyState + onCopy: () => void +}): React.JSX.Element { + const headingId = useId() + const terminalLabel = translate( + 'auto.components.terminal.pane.TerminalLegacyRecoveryBanner.terminalLabel', + 'Terminal {{value0}}', + { value0: index + 1 } + ) + return ( +
+
+
+

+ {terminalLabel} +

+

{reasonLabel(recovery.reason)}

+
+ +
+

+ {preservationDescription(recovery.preservationKind)} +

+
+ + + +
+ {copyState === 'copied' ? ( +

+ + {translate( + 'auto.components.terminal.pane.TerminalLegacyRecoveryBanner.copySucceeded', + 'Recovery details copied.' + )} +

+ ) : copyState === 'failed' ? ( +

+ {translate( + 'auto.components.terminal.pane.TerminalLegacyRecoveryBanner.copyFailed', + 'Recovery details could not be copied. Try again.' + )} +

+ ) : null} +
+ ) +} + +function defaultClipboardWriter(text: string): Promise { + return window.api.ui.writeClipboardText(text) +} + +export function TerminalLegacyRecoveryBanner({ + recoveries, + writeClipboardText = defaultClipboardWriter, + rootClassName +}: { + recoveries: readonly TerminalLegacyRecoveryNotice[] + writeClipboardText?: (text: string) => Promise + rootClassName?: string +}): React.JSX.Element | null { + const unresolved = useMemo(() => selectUnresolvedLegacyRecoveries(recoveries), [recoveries]) + const [detailsOpen, setDetailsOpen] = useState(false) + const [copyStates, setCopyStates] = useState>>({}) + + if (unresolved.length === 0) { + return null + } + + const handleCopy = async (recovery: TerminalLegacyUnresolvedRecoveryNotice): Promise => { + if (copyStates[recovery.recoveryKey] === 'copying') { + return + } + setCopyStates((current) => ({ ...current, [recovery.recoveryKey]: 'copying' })) + try { + await writeClipboardText(formatLegacyRecoveryDetailsForClipboard(recovery)) + setCopyStates((current) => ({ ...current, [recovery.recoveryKey]: 'copied' })) + } catch { + setCopyStates((current) => ({ ...current, [recovery.recoveryKey]: 'failed' })) + } + } + + const singular = unresolved.length === 1 + return ( +
+ +
+
+ +
+
+

+ {singular + ? translate( + 'auto.components.terminal.pane.TerminalLegacyRecoveryBanner.singularTitle', + 'Previous terminal needs review' + ) + : translate( + 'auto.components.terminal.pane.TerminalLegacyRecoveryBanner.pluralTitle', + '{{value0}} previous terminals need review', + { value0: unresolved.length } + )} +

+

+ {singular + ? translate( + 'auto.components.terminal.pane.TerminalLegacyRecoveryBanner.singularBody', + 'Orca could not safely match this terminal after an upgrade. Orca did not attach to, stop, or replace it.' + ) + : translate( + 'auto.components.terminal.pane.TerminalLegacyRecoveryBanner.pluralBody', + 'Orca could not safely match these terminals after an upgrade. Orca did not attach to, stop, or replace them.' + )}{' '} + {preservationSummary(unresolved)} +

+
+ + + +
+ +
+ {unresolved.map((recovery, index) => ( + void handleCopy(recovery)} + /> + ))} +
+
+
+
+ ) +} diff --git a/src/renderer/src/components/terminal-pane/TerminalOverlaySlot.tsx b/src/renderer/src/components/terminal-pane/TerminalOverlaySlot.tsx index 20e5f99064a..6238b611649 100644 --- a/src/renderer/src/components/terminal-pane/TerminalOverlaySlot.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalOverlaySlot.tsx @@ -218,6 +218,7 @@ export const TerminalOverlaySlot = memo(function TerminalOverlaySlot({ void onCloseTab: () => void } @@ -288,6 +293,7 @@ function formatClipboardImagePasteError(error: unknown): string { function TerminalPane( { tabId, + terminalGeneration = 0, worktreeId, cwd, isActive, @@ -295,6 +301,7 @@ function TerminalPane( isWorktreeActive = isVisible, isolatedPaneKey = null, showSplitButton = true, + legacyRecoveryNotices = EMPTY_TERMINAL_LEGACY_RECOVERIES, onPtyExit, onCloseTab }: TerminalPaneProps, @@ -972,11 +979,12 @@ function TerminalPane( clearedScrollbackLeafIdsRef.current.add(pane.leafId) clearTerminalScrollbackAndFollowOutput(pane.terminal) // Why: also clear the host buffer for remote-server panes, or the next host snapshot replays what we just cleared. - const ptyId = paneTransportsRef.current.get(pane.id)?.getPtyId() ?? null + const transport = paneTransportsRef.current.get(pane.id) + const ptyId = transport?.getPtyId() ?? null const clearedRemoteHostBuffer = clearWebRuntimeTerminalBuffer(ptyId) if (!clearedRemoteHostBuffer && ptyId) { // Why: local/daemon/SSH PTYs keep their own screen state (ConPTY on Windows); clear it too or the next prompt repaints below a blank gap. - window.api.pty.clearBuffer(ptyId) + transport?.clearBuffer?.() } persistLayoutSnapshot() }, @@ -1332,6 +1340,7 @@ function TerminalPane( useTerminalPaneLifecycle({ tabId, + paneGeneration: terminalGeneration, worktreeId, cwd, startup, @@ -1524,8 +1533,8 @@ function TerminalPane( } }, []) - const handleRestartCodexPane = useCallback( - (paneId: number) => { + const restartCodexPaneAtGeneration = useCallback( + (paneId: number, paneGeneration: number) => { const manager = managerRef.current const pane = manager?.getPanes().find((candidate) => candidate.id === paneId) if (!manager || !pane) { @@ -1553,6 +1562,7 @@ function TerminalPane( const newPaneBinding = connectPanePty(pane, manager, { tabId, + paneGeneration, worktreeId, cwd, startup: CODEX_ACCOUNT_RESTART_STARTUP, @@ -1615,6 +1625,10 @@ function TerminalPane( worktreeId ] ) + const handleRestartCodexPane = useCodexPaneRestartHandler({ + paneGeneration: terminalGeneration, + restartPaneAtGeneration: restartCodexPaneAtGeneration + }) // Why leaf bindings are a dep: a parked or deferred tab mounts with no // transport, so a queued restart has no ptyId to match on the mount pass. The @@ -2755,6 +2769,11 @@ function TerminalPane( sshReconnectStatus && sshReconnectStatus !== 'connected' ) + const showLegacyRecoveryOverlay = Boolean( + isActive && + isVisible && + legacyRecoveryNotices.some((recovery) => recovery.status === 'unresolved') + ) // Why: while the reconnect banner owns recovery, strip only the SSH-owned lines from the // (possibly aggregated) error, so a later successful connect can't flash the raw ssh:connect // failure and any unrelated error still surfaces after reconnect. @@ -2904,22 +2923,35 @@ function TerminalPane( onRestartDaemon={() => daemonActions.setPending('restart')} /> ) : null} - {/* Why: portal into the pane so the banner stacks above the xterm canvas (sibling mount painted under WebGL). */} - {showSshReconnectOverlay && sshReconnectTargetId && sshReconnectStatus - ? managedPanes.map((pane) => - createPortal( - { + const showLegacyInPane = showLegacyRecoveryOverlay && pane.id === activePane?.id + if (!showSshReconnectOverlay && !showLegacyInPane) { + return null + } + return createPortal( + , pane.container, - `ssh-reconnect-${pane.id}` + `terminal-recovery-stack-${pane.id}` ) - ) + }) : null} {isActive && ( diff --git a/src/renderer/src/components/terminal-pane/TerminalRecoveryOverlayStack.test.tsx b/src/renderer/src/components/terminal-pane/TerminalRecoveryOverlayStack.test.tsx new file mode 100644 index 00000000000..93aee5df2ec --- /dev/null +++ b/src/renderer/src/components/terminal-pane/TerminalRecoveryOverlayStack.test.tsx @@ -0,0 +1,92 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { TerminalLegacyRecoveryNotice } from './terminal-legacy-recovery-view-model' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +vi.mock('./TerminalSshReconnectOverlay', () => ({ + TerminalSshReconnectOverlay: ({ rootClassName }: { rootClassName?: string }) => ( +
+ ) +})) + +import { TerminalRecoveryOverlayStack } from './TerminalRecoveryOverlayStack' + +afterEach(cleanup) + +function unresolvedNotice(): TerminalLegacyRecoveryNotice { + return { + recoveryKey: 'recovery-key-a', + status: 'unresolved', + reason: 'worker-unreachable', + preservationKind: 'worker-unreachable', + workspaceKind: 'folder', + evidenceDigest: 'sha256:support', + observedAtMs: 100, + discoveredAtMs: 101, + updatedAtMs: 102 + } +} + +function importedNotice(): TerminalLegacyRecoveryNotice { + return { + recoveryKey: 'recovery-key-imported', + status: 'imported', + workspaceKind: 'folder', + evidenceDigest: 'sha256:imported', + observedAtMs: 100, + discoveredAtMs: 101, + updatedAtMs: 102 + } +} + +const SSH_RECONNECT = { + targetId: 'ssh-target-1', + targetLabel: 'devbox', + status: 'disconnected' as const +} + +describe('TerminalRecoveryOverlayStack', () => { + it('stacks legacy recovery above SSH chrome without entering terminal layout', () => { + const { container } = render( + + ) + + const stack = container.querySelector('[data-terminal-recovery-overlay-stack]') + const legacy = container.querySelector('[data-terminal-legacy-recovery-banner]') + const ssh = screen.getByTestId('ssh-reconnect') + expect(stack).toHaveClass('absolute', 'bottom-3', 'flex-col', 'gap-2') + expect(legacy).toHaveClass('relative', 'inset-x-auto', 'bottom-auto', 'z-auto', 'w-full') + expect(legacy).not.toHaveClass('absolute', 'bottom-3') + expect(ssh).toHaveClass('relative', 'inset-x-auto', 'bottom-auto', 'z-auto', 'w-full') + expect(stack?.children[0]).toBe(legacy) + expect(stack?.children[1]).toBe(ssh) + }) + + it('keeps SSH at the bottom when resolved legacy rows are present', () => { + const { container } = render( + + ) + + expect(container.querySelector('[data-terminal-legacy-recovery-banner]')).toBeNull() + expect(screen.getByTestId('ssh-reconnect')).toBeInTheDocument() + }) + + it('renders no overlay root without unresolved recovery or reconnect chrome', () => { + const { container } = render( + + ) + expect(container).toBeEmptyDOMElement() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/TerminalRecoveryOverlayStack.tsx b/src/renderer/src/components/terminal-pane/TerminalRecoveryOverlayStack.tsx new file mode 100644 index 00000000000..79513f22f92 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/TerminalRecoveryOverlayStack.tsx @@ -0,0 +1,42 @@ +import type { TerminalLegacyRecoveryNotice } from './terminal-legacy-recovery-view-model' +import { TerminalLegacyRecoveryBanner } from './TerminalLegacyRecoveryBanner' +import { + TerminalSshReconnectOverlay, + type TerminalSshReconnectOverlayProps +} from './TerminalSshReconnectOverlay' + +const STACKED_BANNER_CLASS = 'relative inset-x-auto bottom-auto z-auto w-full' + +export type TerminalRecoveryOverlayStackProps = Readonly<{ + legacyRecoveries: readonly TerminalLegacyRecoveryNotice[] + sshReconnect: Omit | null +}> + +export function TerminalRecoveryOverlayStack({ + legacyRecoveries, + sshReconnect +}: TerminalRecoveryOverlayStackProps): React.JSX.Element | null { + const hasUnresolvedLegacyRecovery = legacyRecoveries.some( + (recovery) => recovery.status === 'unresolved' + ) + if (!hasUnresolvedLegacyRecovery && !sshReconnect) { + return null + } + + return ( +
+ {hasUnresolvedLegacyRecovery ? ( + + ) : null} + {sshReconnect ? ( + + ) : null} +
+ ) +} diff --git a/src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.test.tsx b/src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.test.tsx index f95d38d639f..8895babfb81 100644 --- a/src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.test.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.test.tsx @@ -95,6 +95,23 @@ describe('TerminalSshReconnectOverlay', () => { expect(connect).toHaveBeenCalledWith({ targetId: 'ssh-target-1' }) }) + it('can participate in an externally positioned recovery stack', () => { + const connect = vi.fn().mockResolvedValue(undefined) + installSshConnect(connect) + const { container } = render( + + ) + + const banner = container.querySelector('[data-terminal-ssh-reconnect-banner="disconnected"]') + expect(banner).toHaveClass('relative', 'inset-x-auto', 'bottom-auto', 'z-auto', 'w-full') + expect(banner).not.toHaveClass('absolute', 'inset-x-3', 'bottom-3', 'z-40') + }) + it('shows an in-flight state while the SSH target is reconnecting', () => { const connect = vi.fn().mockResolvedValue(undefined) installSshConnect(connect) diff --git a/src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.tsx b/src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.tsx index bf74d617f3f..8be30656b8d 100644 --- a/src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalSshReconnectOverlay.tsx @@ -2,6 +2,7 @@ import { useCallback } from 'react' import { Loader2, Server, ServerOff } from 'lucide-react' import { toast } from 'sonner' import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' import { useAppStore } from '@/store' import type { SshConnectionStatus } from '../../../../shared/ssh-types' import { translate } from '@/i18n/i18n' @@ -19,7 +20,7 @@ import { useSshConnectInFlight } from '@/ssh/ssh-connect-in-flight' -type TerminalSshReconnectOverlayProps = { +export type TerminalSshReconnectOverlayProps = { targetId: string targetLabel: string status: SshConnectionStatus @@ -31,6 +32,7 @@ type TerminalSshReconnectOverlayProps = { // environment): Connect and the failed-connect resync then route to that // environment's runtime RPC and bucket instead of the local ssh.* API. sshOwnerEnvironmentId?: string | null + rootClassName?: string } function messageForStatus(status: SshConnectionStatus, targetLabel: string): string { @@ -76,7 +78,8 @@ export function TerminalSshReconnectOverlay({ status, targetRemoved = false, worktreeId, - sshOwnerEnvironmentId = null + sshOwnerEnvironmentId = null, + rootClassName }: TerminalSshReconnectOverlayProps): React.JSX.Element { const setSshConnectionState = useAppStore((store) => store.setSshConnectionState) // Why: shared registry, not local state — the sidebar card control can dial the same @@ -141,7 +144,10 @@ export function TerminalSshReconnectOverlay({ // Why: z-40 clears pane-local chrome (focus rim z-30); bg-card is fully opaque so terminal text cannot paint through. return (
{ pty: { ...originalWindow?.api?.pty, getSize: vi.fn().mockResolvedValue({ cols: 120, rows: 30 }), - spawn: vi.fn().mockResolvedValue({ id: NEW_PTY }), + spawn: vi.fn().mockResolvedValue({ id: NEW_PTY, mutationAccess: { mode: 'legacy' } }), kill: vi.fn().mockResolvedValue(undefined) } } @@ -312,7 +312,7 @@ describe('codex detached pane restart executor', () => { it('reaps a detached spawn and requeues when a pane mounts during the spawn', async () => { seedQueuedRestart() - const pendingSpawn = deferred<{ id: string }>() + const pendingSpawn = deferred>>() const pendingKill = deferred() vi.mocked(window.api.pty.spawn).mockReturnValue(pendingSpawn.promise) vi.mocked(window.api.pty.kill).mockReturnValue(pendingKill.promise) @@ -327,7 +327,7 @@ describe('codex detached pane restart executor', () => { getPtyIdForPane: () => OLD_PTY }) try { - pendingSpawn.resolve({ id: NEW_PTY }) + pendingSpawn.resolve({ id: NEW_PTY, mutationAccess: { mode: 'legacy' } }) await vi.waitFor(() => expect(window.api.pty.kill).toHaveBeenCalledExactlyOnceWith(NEW_PTY)) expect(useAppStore.getState().ptyIdsByTabId['tab-1']).toEqual([OLD_PTY]) @@ -341,7 +341,7 @@ describe('codex detached pane restart executor', () => { it('rejects a detached spawn after the tab generation and leaf owner change', async () => { seedQueuedRestart() - const pendingSpawn = deferred<{ id: string }>() + const pendingSpawn = deferred>>() const pendingKill = deferred() vi.mocked(window.api.pty.spawn).mockReturnValue(pendingSpawn.promise) vi.mocked(window.api.pty.kill).mockReturnValue(pendingKill.promise) @@ -364,7 +364,7 @@ describe('codex detached pane restart executor', () => { codexRestartNoticeByPtyId: { 'wt1@@successor': notice! } }) - pendingSpawn.resolve({ id: NEW_PTY }) + pendingSpawn.resolve({ id: NEW_PTY, mutationAccess: { mode: 'legacy' } }) await vi.waitFor(() => expect(window.api.pty.kill).toHaveBeenCalledExactlyOnceWith(NEW_PTY)) const after = useAppStore.getState() diff --git a/src/renderer/src/components/terminal-pane/codex-detached-pane-restart.ts b/src/renderer/src/components/terminal-pane/codex-detached-pane-restart.ts index 1ad6528c935..bf7cb689c13 100644 --- a/src/renderer/src/components/terminal-pane/codex-detached-pane-restart.ts +++ b/src/renderer/src/components/terminal-pane/codex-detached-pane-restart.ts @@ -19,6 +19,7 @@ import { hasRegisteredRuntimeTerminalTab } from '@/runtime/sync-runtime-graph' import { CODEX_ACCOUNT_RESTART_STARTUP } from '@/lib/codex-session-restart' import { isForeignMachineCodexPtyId } from '@/lib/codex-pane-selection-lane' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' +import { killPtyAtCurrentIncarnation } from '@/lib/pty-administrative-mutations' import { getCachedWindowsTerminalCapabilities, hasCachedWindowsTerminalCapabilities @@ -322,7 +323,7 @@ function rebindCodexPaneLayoutLeaf(tabId: string, leafId: string, newPtyId: stri function reapUnboundCodexPty(ptyId: string, reason: string): void { try { - void window.api.pty.kill(ptyId).catch((err) => { + void killPtyAtCurrentIncarnation(ptyId).catch((err) => { console.warn(`[codex-restart] failed to reap ${reason}:`, err) }) } catch (err) { diff --git a/src/renderer/src/components/terminal-pane/keyboard-handlers.ts b/src/renderer/src/components/terminal-pane/keyboard-handlers.ts index 6519c5f8655..2cfedbbbc0c 100644 --- a/src/renderer/src/components/terminal-pane/keyboard-handlers.ts +++ b/src/renderer/src/components/terminal-pane/keyboard-handlers.ts @@ -431,6 +431,7 @@ export function useTerminalKeyboardShortcuts({ ): (() => void) => { const capturedTransport = paneTransportsRef.current.get(pane.id) const capturedPtyId = capturedTransport?.getPtyId() ?? null + const capturedInputTarget = capturedTransport?.captureInputTarget?.() ?? null const capturedBinding = panePtyBindingsRef.current.get(pane.id) as | (IDisposable & { requestWindowsShiftEnterReconfirmation?: () => void }) | undefined @@ -448,6 +449,7 @@ export function useTerminalKeyboardShortcuts({ currentTransport: getCurrentTransport(), capturedTransport, capturedPtyId, + capturedInputTarget, data }) if (sent) { diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher-handoff.test.ts b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher-handoff.test.ts new file mode 100644 index 00000000000..884ce042ebc --- /dev/null +++ b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher-handoff.test.ts @@ -0,0 +1,164 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ParkedTerminalByteWatcherOptions } from './parked-terminal-byte-watcher' + +const PTY_ID = 'pty-parked-handoff' +const TAB_ID = 'tab-parked-handoff' +const WORKTREE_ID = 'repo-1::/tmp/wt-1' +const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const PANE_ID = 1 +const IDLE_TITLE = '✳ Build feature' + +const commandStatusPolicy = { + onCommandFinished: vi.fn(), + onCommandCodeWorking: vi.fn(), + onCommandCodeDone: vi.fn(), + dispose: vi.fn() +} + +vi.mock('./parked-terminal-command-status', () => ({ + createParkedTerminalCommandStatusPolicy: vi.fn(() => commandStatusPolicy), + readInFlightCommandCodeTurn: vi.fn(() => null) +})) +vi.mock('@/lib/terminal-theme', () => ({ getSystemPrefersDark: () => true })) +vi.mock('./use-notification-dispatch', () => ({ dispatchTerminalNotification: vi.fn() })) + +const storeState = { + settings: { + terminalMainSideEffectAuthority: false, + terminalHiddenDeliveryGate: true, + promptCacheTimerEnabled: true, + notifications: { enabled: true, agentTaskComplete: true } + }, + setRuntimePaneTitle: vi.fn(), + clearRuntimePaneTitle: vi.fn(), + updateTabTitle: vi.fn(), + markWorktreeUnread: vi.fn(), + markTerminalTabUnread: vi.fn(), + markTerminalPaneUnread: vi.fn(), + setCacheTimerStartedAt: vi.fn(), + observeTerminalGitHubPullRequestLink: vi.fn(), + agentStatusByPaneKey: {} +} + +vi.mock('@/store', () => ({ + useAppStore: { getState: () => storeState } +})) + +describe('parked terminal byte watcher handoff', () => { + const originalWindow = (globalThis as { window?: typeof window }).window + let onData: ((payload: { id: string; data: string }) => void) | null + + async function startWatcher( + overrides: Partial = {}, + activate = true + ) { + const { startParkedTerminalByteWatcher } = await import('./parked-terminal-byte-watcher') + const watcher = startParkedTerminalByteWatcher({ + ptyId: PTY_ID, + tabId: TAB_ID, + worktreeId: WORKTREE_ID, + leafId: LEAF_ID, + paneId: PANE_ID, + sendInput: vi.fn(), + ...overrides + }) + if (activate) { + watcher.activateParked() + } + return watcher + } + + function emitAndFlush(data: string): void { + onData?.({ id: PTY_ID, data }) + vi.advanceTimersByTime(0) + } + + beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + vi.useFakeTimers() + storeState.settings.terminalMainSideEffectAuthority = false + storeState.settings.terminalHiddenDeliveryGate = true + onData = null + ;(globalThis as { window: typeof window }).window = { + ...originalWindow, + api: { + pty: { + onData: vi.fn((callback: (payload: { id: string; data: string }) => void) => { + onData = callback + return () => {} + }), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}), + ackData: vi.fn() + } + } + } as unknown as typeof window + }) + + afterEach(() => { + vi.useRealTimers() + if (originalWindow) { + ;(globalThis as { window: typeof window }).window = originalWindow + } else { + delete (globalThis as { window?: typeof window }).window + } + }) + + it('releases constructed policy state when byte subscription setup throws', async () => { + ;(window as unknown as { api: { pty: { onData: () => never } } }).api.pty.onData = vi.fn(() => { + throw new Error('byte subscription failed') + }) + + await expect(startWatcher({}, false)).rejects.toThrow('byte subscription failed') + expect(commandStatusPolicy.dispose).toHaveBeenCalledOnce() + }) + + it('does not parse raw sidecar bytes while the mounted primary exists', async () => { + const watcher = await startWatcher() + const { ptyDataHandlers } = await import('./pty-dispatcher') + const primaryHandler = vi.fn() + const workingTitleOsc = '\x1b]0;⠋ Build feature\x07' + ptyDataHandlers.set(PTY_ID, primaryHandler) + + emitAndFlush(workingTitleOsc) + expect(primaryHandler).toHaveBeenCalledWith(workingTitleOsc, undefined) + expect(storeState.setRuntimePaneTitle).not.toHaveBeenCalled() + + ptyDataHandlers.delete(PTY_ID) + emitAndFlush(`\x1b]0;${IDLE_TITLE}\x07`) + expect(storeState.setRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, PANE_ID, IDLE_TITLE) + watcher.dispose() + }) + + it('drops the bare cursor-agent native title before it reaches the store', async () => { + const watcher = await startWatcher() + emitAndFlush('\x1b]0;Cursor Agent\x07') + expect(storeState.setRuntimePaneTitle).not.toHaveBeenCalled() + expect(storeState.updateTabTitle).not.toHaveBeenCalled() + watcher.dispose() + }) + + it('does not drive the tab title when drivesTabTitle is false', async () => { + const watcher = await startWatcher({ drivesTabTitle: false }) + emitAndFlush(`\x1b]0;${IDLE_TITLE}\x07`) + expect(storeState.setRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, PANE_ID, IDLE_TITLE) + expect(storeState.updateTabTitle).not.toHaveBeenCalled() + watcher.dispose() + }) + + it('activates hidden delivery only after preparation and clears it on dispose', async () => { + storeState.settings.terminalMainSideEffectAuthority = true + const setHiddenRendererPty = vi.fn() + ;(window as unknown as { api: { pty: Record } }).api.pty.setHiddenRendererPty = + setHiddenRendererPty + const watcher = await startWatcher({}, false) + + expect(setHiddenRendererPty).not.toHaveBeenCalled() + expect(watcher.activateParked()).toBe(true) + expect(setHiddenRendererPty).toHaveBeenCalledWith(PTY_ID, true) + + watcher.dispose() + expect(setHiddenRendererPty).toHaveBeenLastCalledWith(PTY_ID, false) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts index cd3cfd99999..43c4c1840e6 100644 --- a/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts +++ b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts @@ -106,12 +106,22 @@ describe('startParkedTerminalByteWatcher', () => { vi.advanceTimersByTime(0) } + function emitAndFlushSideEffects(data: string): void { + emit(data) + flushSideEffects() + } + async function startWatcher( - overrides: Partial = {} - ): Promise<{ dispose: () => void; sendInput: ReturnType }> { + overrides: Partial = {}, + activate = true + ): Promise<{ + activateParked: () => boolean + dispose: () => void + sendInput: ReturnType + }> { const { startParkedTerminalByteWatcher } = await import('./parked-terminal-byte-watcher') const sendInput = vi.fn() - const dispose = startParkedTerminalByteWatcher({ + const watcher = startParkedTerminalByteWatcher({ ptyId: PTY_ID, tabId: TAB_ID, worktreeId: WORKTREE_ID, @@ -120,7 +130,10 @@ describe('startParkedTerminalByteWatcher', () => { sendInput, ...overrides }) - return { dispose, sendInput } + if (activate) { + watcher.activateParked() + } + return { ...watcher, sendInput } } beforeEach(() => { @@ -161,8 +174,7 @@ describe('startParkedTerminalByteWatcher', () => { it('forwards every OSC title in order to the pane and tab title store actions', async () => { const { dispose } = await startWatcher() - emit(`${WORKING_TITLE_OSC}${IDLE_TITLE_OSC}`) - flushSideEffects() + emitAndFlushSideEffects(`${WORKING_TITLE_OSC}${IDLE_TITLE_OSC}`) expect(mockStoreState.setRuntimePaneTitle.mock.calls).toEqual([ [TAB_ID, PANE_ID, '⠋ Build feature'], @@ -175,33 +187,10 @@ describe('startParkedTerminalByteWatcher', () => { dispose() }) - it('drops the bare cursor-agent native title before it reaches the store', async () => { - const { dispose } = await startWatcher() - - emit('\x1b]0;Cursor Agent\x07') - flushSideEffects() - - expect(mockStoreState.setRuntimePaneTitle).not.toHaveBeenCalled() - expect(mockStoreState.updateTabTitle).not.toHaveBeenCalled() - dispose() - }) - - it('does not drive the tab title when drivesTabTitle is false', async () => { - const { dispose } = await startWatcher({ drivesTabTitle: false }) - - emit(IDLE_TITLE_OSC) - flushSideEffects() - - expect(mockStoreState.setRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, PANE_ID, IDLE_TITLE) - expect(mockStoreState.updateTabTitle).not.toHaveBeenCalled() - dispose() - }) - it('marks unread on BEL and schedules the delayed terminal-bell OS notification', async () => { const { dispose } = await startWatcher() - emit('build finished\x07') - flushSideEffects() + emitAndFlushSideEffects('build finished\x07') expect(mockStoreState.markWorktreeUnread).toHaveBeenCalledWith(WORKTREE_ID) expect(mockStoreState.markTerminalTabUnread).toHaveBeenCalledWith(TAB_ID) @@ -225,8 +214,7 @@ describe('startParkedTerminalByteWatcher', () => { } const { dispose } = await startWatcher() - emit('\x07') - flushSideEffects() + emitAndFlushSideEffects('\x07') expect(mockStoreState.markTerminalPaneUnread).toHaveBeenCalledWith(PANE_KEY) dispose() @@ -236,8 +224,7 @@ describe('startParkedTerminalByteWatcher', () => { const { dispose } = await startWatcher() emit('\x1b]0;par') - emit('tial title\x07') - flushSideEffects() + emitAndFlushSideEffects('tial title\x07') vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4) expect(mockStoreState.markWorktreeUnread).not.toHaveBeenCalled() @@ -249,12 +236,10 @@ describe('startParkedTerminalByteWatcher', () => { it('fires the prompt-cache timer and agent-task-complete on working→idle', async () => { const { dispose } = await startWatcher() - emit(WORKING_TITLE_OSC) - flushSideEffects() + emitAndFlushSideEffects(WORKING_TITLE_OSC) expect(mockStoreState.setCacheTimerStartedAt).toHaveBeenLastCalledWith(PANE_KEY, null) - emit(IDLE_TITLE_OSC) - flushSideEffects() + emitAndFlushSideEffects(IDLE_TITLE_OSC) expect(mockStoreState.setCacheTimerStartedAt).toHaveBeenLastCalledWith( PANE_KEY, expect.any(Number) @@ -281,8 +266,7 @@ describe('startParkedTerminalByteWatcher', () => { const { dispose } = await startWatcher() emit(WORKING_TITLE_OSC) - emit(IDLE_TITLE_OSC) - flushSideEffects() + emitAndFlushSideEffects(IDLE_TITLE_OSC) vi.advanceTimersByTime(NOTIFICATION_GRACE_MS) expect(dispatchTerminalNotification).toHaveBeenCalledWith(WORKTREE_ID, { @@ -303,8 +287,7 @@ describe('startParkedTerminalByteWatcher', () => { const { dispose } = await startWatcher() emit(WORKING_TITLE_OSC) - emit(IDLE_TITLE_OSC) - flushSideEffects() + emitAndFlushSideEffects(IDLE_TITLE_OSC) vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4) expect(dispatchTerminalNotification).not.toHaveBeenCalled() @@ -318,10 +301,8 @@ describe('startParkedTerminalByteWatcher', () => { it('lets a same-burst completion supersede the pending bell OS notification', async () => { const { dispose } = await startWatcher() - emit(WORKING_TITLE_OSC) - flushSideEffects() - emit(`${IDLE_TITLE_OSC}\x07`) - flushSideEffects() + emitAndFlushSideEffects(WORKING_TITLE_OSC) + emitAndFlushSideEffects(`${IDLE_TITLE_OSC}\x07`) vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 8) // The bell still marks unread immediately; only the OS notification yields. @@ -338,10 +319,8 @@ describe('startParkedTerminalByteWatcher', () => { const { dispose } = await startWatcher() emit(WORKING_TITLE_OSC) - emit(IDLE_TITLE_OSC) - flushSideEffects() - emit(WORKING_TITLE_OSC) - flushSideEffects() + emitAndFlushSideEffects(IDLE_TITLE_OSC) + emitAndFlushSideEffects(WORKING_TITLE_OSC) vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4) expect(dispatchTerminalNotification).not.toHaveBeenCalled() @@ -457,8 +436,7 @@ describe('startParkedTerminalByteWatcher', () => { // must be seeded or this working→idle transition can never fire. const { dispose } = await startWatcher({ initialTitle: '⠋ Build feature' }) - emit(IDLE_TITLE_OSC) - flushSideEffects() + emitAndFlushSideEffects(IDLE_TITLE_OSC) vi.advanceTimersByTime(NOTIFICATION_GRACE_MS) expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1) @@ -473,8 +451,7 @@ describe('startParkedTerminalByteWatcher', () => { it('does not fire completion for an idle title without a seed or observed transition', async () => { const { dispose } = await startWatcher() - emit(IDLE_TITLE_OSC) - flushSideEffects() + emitAndFlushSideEffects(IDLE_TITLE_OSC) vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4) expect(dispatchTerminalNotification).not.toHaveBeenCalled() @@ -484,8 +461,7 @@ describe('startParkedTerminalByteWatcher', () => { it('clears the watcher-written runtime title slot on dispose', async () => { const { dispose } = await startWatcher() - emit(IDLE_TITLE_OSC) - flushSideEffects() + emitAndFlushSideEffects(IDLE_TITLE_OSC) expect(mockStoreState.setRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, PANE_ID, IDLE_TITLE) dispose() @@ -495,8 +471,7 @@ describe('startParkedTerminalByteWatcher', () => { it('leaves the runtime title slot alone on dispose when it never wrote one', async () => { const { dispose } = await startWatcher() - emit('plain output with no titles\r\n') - flushSideEffects() + emitAndFlushSideEffects('plain output with no titles\r\n') dispose() expect(mockStoreState.clearRuntimePaneTitle).not.toHaveBeenCalled() @@ -506,8 +481,7 @@ describe('startParkedTerminalByteWatcher', () => { const { dispose } = await startWatcher() emit(WORKING_TITLE_OSC) - emit(IDLE_TITLE_OSC) - flushSideEffects() + emitAndFlushSideEffects(IDLE_TITLE_OSC) // Equivalent to shutdownWorktreeTerminals → disposeParkedTerminalWatchersForPtyIds. dispose() @@ -515,8 +489,7 @@ describe('startParkedTerminalByteWatcher', () => { expect(dispatchTerminalNotification).not.toHaveBeenCalled() // The teardown flush that main emits after pty.kill must be a no-op. - emit('final teardown flush\x07') - flushSideEffects() + emitAndFlushSideEffects('final teardown flush\x07') vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4) expect(mockStoreState.markWorktreeUnread).not.toHaveBeenCalled() expect(mockStoreState.markTerminalTabUnread).not.toHaveBeenCalled() @@ -526,16 +499,14 @@ describe('startParkedTerminalByteWatcher', () => { it('dispose unregisters the sidecar and cancels the pending bell notification', async () => { const { dispose } = await startWatcher() - emit('\x07') - flushSideEffects() + emitAndFlushSideEffects('\x07') expect(mockStoreState.markWorktreeUnread).toHaveBeenCalledTimes(1) dispose() vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4) expect(dispatchTerminalNotification).not.toHaveBeenCalled() - emit('\x07') - flushSideEffects() + emitAndFlushSideEffects('\x07') expect(mockStoreState.markWorktreeUnread).toHaveBeenCalledTimes(1) // Idempotent: a second dispose must not throw or clobber another watcher. @@ -559,8 +530,7 @@ describe('startParkedTerminalByteWatcher', () => { await startWatcher({ paneId: 1 }) const second = await startWatcher({ paneId: 2 }) - emit(IDLE_TITLE_OSC) - flushSideEffects() + emitAndFlushSideEffects(IDLE_TITLE_OSC) expect(mockStoreState.setRuntimePaneTitle).toHaveBeenCalledTimes(1) expect(mockStoreState.setRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, 2, IDLE_TITLE) @@ -684,8 +654,7 @@ describe('startParkedTerminalByteWatcher', () => { enableMainAuthority() const { dispose } = await startWatcher() - emit('build finished\x07') - flushSideEffects() + emitAndFlushSideEffects('build finished\x07') vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4) expect(mockStoreState.markWorktreeUnread).not.toHaveBeenCalled() @@ -694,6 +663,53 @@ describe('startParkedTerminalByteWatcher', () => { dispose() }) + it('releases policy state when fact-consumer preparation throws', async () => { + enableMainAuthority() + ;(window as unknown as { api: { pty: { onSideEffect: () => never } } }).api.pty.onSideEffect = + vi.fn(() => { + throw new Error('fact consumer failed') + }) + + await expect(startWatcher({}, false)).rejects.toThrow('fact consumer failed') + expect(commandStatusPolicy.dispose).toHaveBeenCalledOnce() + }) + + it('rolls back the prepared fact consumer when hidden-claim publication fails', async () => { + enableMainAuthority() + const setHiddenRendererPty = vi.fn(() => { + throw new Error('hidden claim failed') + }) + ;( + window as unknown as { api: { pty: Record } } + ).api.pty.setHiddenRendererPty = setHiddenRendererPty + const { activateParked } = await startWatcher({}, false) + + expect(activateParked()).toBe(false) + await dispatchFacts([{ kind: 'bell' }]) + expect(mockStoreState.markWorktreeUnread).not.toHaveBeenCalled() + expect(commandStatusPolicy.dispose).toHaveBeenCalledOnce() + }) + + it('rolls back the prepared fact consumer when the parked responder fails', async () => { + enableMainAuthority() + mockStoreState.settings = { + ...mockStoreState.settings, + terminalHiddenDeliveryGate: false + } + const setPtyDeliveryInterest = vi.fn(() => { + throw new Error('responder interest failed') + }) + ;( + window as unknown as { api: { pty: Record } } + ).api.pty.setPtyDeliveryInterest = setPtyDeliveryInterest + const { activateParked } = await startWatcher({}, false) + + expect(activateParked()).toBe(false) + await dispatchFacts([{ kind: 'bell' }]) + expect(mockStoreState.markWorktreeUnread).not.toHaveBeenCalled() + expect(commandStatusPolicy.dispose).toHaveBeenCalledOnce() + }) + it('routes command lifecycle facts into the parked command policy', async () => { enableMainAuthority() const { dispose } = await startWatcher() @@ -885,22 +901,6 @@ describe('startParkedTerminalByteWatcher', () => { dispose() }) - it('marks the PTY hidden for delivery on start and clears it on dispose', async () => { - enableMainAuthority() - const setHiddenRendererPty = vi.fn() - ;( - window as unknown as { api: { pty: Record } } - ).api.pty.setHiddenRendererPty = setHiddenRendererPty - const { dispose } = await startWatcher() - - expect(setHiddenRendererPty).toHaveBeenCalledWith(PTY_ID, true) - - dispose() - // Why: the unhide must land before reveal re-registers pane handlers — - // the watcher registry disposes watchers before the remount effect runs. - expect(setHiddenRendererPty).toHaveBeenLastCalledWith(PTY_ID, false) - }) - it('keeps the byte 2031 responder and no hidden bit when the gate kill switch is off', async () => { enableMainAuthority() mockStoreState.settings = { @@ -975,8 +975,7 @@ describe('startParkedTerminalByteWatcher', () => { { const { dispose } = await startWatcher() for (const chunk of fixtureChunks) { - emit(chunk) - flushSideEffects() + emitAndFlushSideEffects(chunk) } vi.advanceTimersByTime(NOTIFICATION_GRACE_MS) dispose() diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts index 42dff141d34..fd0e45ebc02 100644 --- a/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts +++ b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts @@ -2,7 +2,6 @@ * Parked terminal side-effect watcher. * Why: parking unmounts TerminalPane, so this replays its bell/title/agent-completion/PR-link side effects while parked. */ -import { isClaudeAgent } from '../../../../shared/agent-detection' import { makePaneKey } from '../../../../shared/stable-pane-id' import { useAppStore } from '@/store' import { @@ -11,11 +10,6 @@ import { } from '../../../../shared/terminal-color-scheme-protocol' import { createTerminalGitHubPRLinkDetector } from '../../../../shared/terminal-github-pr-link-detector' import { getSystemPrefersDark } from '@/lib/terminal-theme' -import { - AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS, - isAgentTaskCompleteOsNotificationEnabledFromState, - isAgentTaskCompleteTrackingEnabledFromState -} from './agent-task-complete-policy' import { createCommandCodeOutputStatusDetector } from '../../../../shared/command-code-output-status' import { createOsc133CommandFinishedScanner } from '../../../../shared/terminal-osc133-command-finished' import { @@ -23,32 +17,23 @@ import { readInFlightCommandCodeTurn } from './parked-terminal-command-status' import { startParkedTerminalMode2031Responder } from './parked-terminal-mode2031-responder' -import { subscribeToPtyData } from './pty-data-sidecar-subscriptions' +import { hasPrimaryPtyDataHandler, subscribeToPtyData } from './pty-data-sidecar-subscriptions' import { createPtyOutputProcessor } from './pty-transport' import { isRendererHiddenPtyDeliveryGateEnabled } from './terminal-hidden-delivery-gate' import { isMainTerminalSideEffectAuthorityForPty, - registerTerminalSideEffectFactConsumer + prepareTerminalSideEffectFactConsumer } from './terminal-side-effect-facts-handler' -import { dispatchTerminalNotification } from './use-notification-dispatch' import { acquireHiddenRendererPtyDeliveryClaim } from './pty-renderer-delivery-claims' import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection' - -// Why: keep the live path's BEL-vs-completion race window so notification behavior is identical whether a tab is parked or mounted. -const PARKED_NOTIFICATION_GRACE_MS = AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS - -type StoreState = ReturnType - -function isAgentTaskCompleteOsNotificationEnabled(state: StoreState): boolean { - return isAgentTaskCompleteOsNotificationEnabledFromState(state) -} - -function isAgentTaskCompleteTrackingEnabled(state: StoreState): boolean { - return isAgentTaskCompleteTrackingEnabledFromState(state) -} +import type { PtyMutationIdentity } from '../../../../shared/pty-mutation-identity' +import type { ParkedTerminalSideEffectIdentity } from './terminal-parked-side-effect-identity' +import { createParkedTerminalSideEffectPolicy } from './parked-terminal-side-effect-policy' export type ParkedTerminalByteWatcherOptions = { ptyId: string + mutationIdentity?: PtyMutationIdentity + sideEffectIdentity?: ParkedTerminalSideEffectIdentity tabId: string worktreeId: string /** Stable terminal-layout leaf UUID; combined with tabId into the paneKey for cache-timer, unread, and notification attribution. */ @@ -65,135 +50,43 @@ export type ParkedTerminalByteWatcherOptions = { sendInput: (data: string) => void } -const parkedWatcherDisposersByPtyId = new Map void>() +export type ParkedTerminalByteWatcherHandle = { + activateParked: () => boolean + dispose: (options?: { preserveRuntimeTitle?: boolean }) => void +} + +const parkedWatcherDisposersByPtyId = new Map< + string, + (options?: { preserveRuntimeTitle?: boolean }) => void +>() + +function runParkedWatcherCleanups(cleanups: readonly ((() => void) | null | undefined)[]): void { + for (const cleanup of cleanups) { + try { + cleanup?.() + } catch (error) { + console.error('[terminal] parked watcher cleanup failed', error) + } + } +} export function startParkedTerminalByteWatcher( options: ParkedTerminalByteWatcherOptions -): () => void { +): ParkedTerminalByteWatcherHandle { const { ptyId, tabId, worktreeId, paneId, sendInput } = options const remoteRuntimePty = isRemoteRuntimePtyId(ptyId) const drivesTabTitle = options.drivesTabTitle ?? true const paneKey = makePaneKey(tabId, options.leafId) - // Why: one watcher per PTY — a stale watcher from a previous park cycle would double-fire bell/completion for the same bytes. - parkedWatcherDisposersByPtyId.get(ptyId)?.() - let disposed = false - let pendingBellNotification = false - // Why: a watcher-written title has no pane to overwrite it after reveal (stale 'working' pins status); track writes so dispose clears the exact slot. - let wroteRuntimeTitleSlot = false - let bellNotificationTimer: ReturnType | null = null - let agentTaskCompleteTimer: ReturnType | null = null - - const clearBellNotificationTimer = (): void => { - if (bellNotificationTimer !== null) { - clearTimeout(bellNotificationTimer) - bellNotificationTimer = null - } - } - - const clearAgentTaskCompleteTimer = (): void => { - if (agentTaskCompleteTimer !== null) { - clearTimeout(agentTaskCompleteTimer) - agentTaskCompleteTimer = null - } - } - - // Why: a BEL OS notification only yields when the pending completion would itself produce an OS notification (live-path parity). - const hasPendingAgentTaskCompleteNotification = (): boolean => - agentTaskCompleteTimer !== null && - isAgentTaskCompleteOsNotificationEnabled(useAppStore.getState()) - - const scheduleTerminalBellNotification = (): void => { - if (bellNotificationTimer !== null) { - return - } - bellNotificationTimer = setTimeout(() => { - bellNotificationTimer = null - if (disposed) { - pendingBellNotification = false - return - } - if (hasPendingAgentTaskCompleteNotification()) { - return - } - pendingBellNotification = false - dispatchTerminalNotification(worktreeId, { source: 'terminal-bell', paneKey }) - }, PARKED_NOTIFICATION_GRACE_MS) - } - - // Why: one policy block shared by both modes (byte parsing and facts) — semantics must be identical or flipping the switch changes behavior. - const sideEffectCallbacks = { - onTitleChange: (title: string): void => { - const state = useAppStore.getState() - wroteRuntimeTitleSlot = true - state.setRuntimePaneTitle(tabId, paneId, title) - if (drivesTabTitle) { - state.updateTabTitle(tabId, title) - } - }, - onBell: (): void => { - const state = useAppStore.getState() - state.markWorktreeUnread(worktreeId) - state.markTerminalTabUnread(tabId) - if (state.settings?.experimentalTerminalAttention === true) { - state.markTerminalPaneUnread(paneKey) - } - // Why: agents emit BEL in the same burst as working→idle, so delay only the OS notification to let the richer completion notification win. - pendingBellNotification = true - if (!hasPendingAgentTaskCompleteNotification()) { - scheduleTerminalBellNotification() - } - }, - onAgentBecameIdle: (title: string, meta?: { staleWorkingTitleClear?: boolean }): void => { - // Why: stale-derived idles (main's 3s timer, not observed bytes) clear session state but must not schedule a completion a paused agent didn't earn. - if (meta?.staleWorkingTitleClear) { - useAppStore.getState().setCacheTimerStartedAt(paneKey, null) - return - } - const state = useAppStore.getState() - // Why: null settings means "not hydrated yet"; a spurious timestamp is harmless while a dropped one loses the timer. - if ( - isClaudeAgent(title) && - (state.settings === null || state.settings.promptCacheTimerEnabled) - ) { - state.setCacheTimerStartedAt(paneKey, Date.now()) - } - if (!isAgentTaskCompleteTrackingEnabled(state)) { - return - } - clearAgentTaskCompleteTimer() - agentTaskCompleteTimer = setTimeout(() => { - agentTaskCompleteTimer = null - if (disposed) { - return - } - // Why: completion supersedes a concurrent BEL so each burst yields exactly one OS notification (live-path parity). - pendingBellNotification = false - clearBellNotificationTimer() - dispatchTerminalNotification(worktreeId, { - source: 'agent-task-complete', - terminalTitle: title, - paneKey, - ...(isAgentTaskCompleteOsNotificationEnabled(useAppStore.getState()) - ? {} - : { suppressOsNotification: true }) - }) - }, PARKED_NOTIFICATION_GRACE_MS) - }, - onAgentBecameWorking: (): void => { - // Why: a new API call refreshes the prompt-cache TTL, so clear the running countdown; it restarts when the agent next becomes idle. - useAppStore.getState().setCacheTimerStartedAt(paneKey, null) - clearAgentTaskCompleteTimer() - if (pendingBellNotification) { - scheduleTerminalBellNotification() - } - }, - onAgentExited: (): void => { - // Why: title reverting to a plain shell means the agent session ended; clear the countdown so it doesn't survive in the sidebar while parked. - useAppStore.getState().setCacheTimerStartedAt(paneKey, null) - } - } + const sideEffectPolicy = createParkedTerminalSideEffectPolicy({ + tabId, + worktreeId, + paneId, + paneKey, + drivesTabTitle + }) + const sideEffectCallbacks = sideEffectPolicy.callbacks // Why: command-lifecycle signals drive store-level policy only (git nudge, SSH same-turn // status drop, Command Code seed/settle); the pane-coupled parts stay with the mounted pane. @@ -226,62 +119,28 @@ export function startParkedTerminalByteWatcher( // Why (byte-parser mode only): reuse the transport's output processor to keep exact live-path parsing semantics. // initialAgentTitle: an agent already working at park time still produces a working→idle transition. - const processor = factSideEffectAuthority - ? null - : createPtyOutputProcessor({ - ...(options.initialTitle !== undefined ? { initialAgentTitle: options.initialTitle } : {}), - ...sideEffectCallbacks - }) + let processor: ReturnType | null = null // Why (byte-parser mode only): under main authority, byte-scanning PR links too would observe every link twice (facts already carry them). - const observeTerminalGitHubPRLink = factSideEffectAuthority - ? null - : createTerminalGitHubPRLinkDetector() + let observeTerminalGitHubPRLink: ReturnType | null = + null // Why (byte-parser mode only): mode parity — main's tracker emits these as facts; the byte // path scans the same shared parsers the mounted kill-switch-off pane uses. - const commandFinishedScanner = factSideEffectAuthority - ? null - : createOsc133CommandFinishedScanner(commandStatusPolicy.onCommandFinished) + let commandFinishedScanner: ReturnType | null = null // Why the seed: this detector is recreated per park cycle with no startup command // to fast-arm it, and a Command Code TUI parked mid-turn is long past its banner — // unseeded it would never scrape the turn's return to the idle composer. - const commandCodeOutputStatusDetector = factSideEffectAuthority - ? null - : createCommandCodeOutputStatusDetector({ - inFlightTurn: readInFlightCommandCodeTurn(paneKey), - onWorking: commandStatusPolicy.onCommandCodeWorking, - onDone: commandStatusPolicy.onCommandCodeDone - }) - const unregisterFactConsumer = factSideEffectAuthority - ? registerTerminalSideEffectFactConsumer({ - ptyId, - // Why: ordinary park already has a pane-owned title; the flag below requests a snapshot only when no pane did. - callbacks: { - ...sideEffectCallbacks, - onCommandFinished: commandStatusPolicy.onCommandFinished, - onCommandCodeWorking: commandStatusPolicy.onCommandCodeWorking, - onCommandCodeDone: commandStatusPolicy.onCommandCodeDone, - onPrLink: (link) => - useAppStore.getState().observeTerminalGitHubPullRequestLink(worktreeId, link), - // Why (gate mode only): the 2031 subscribe arrives as a fact, but the reply stays here — query authority stays with the view/watcher (invariant 6). - ...(factOwnsMode2031 ? { onMode2031Subscribe: sendMode2031Reply } : {}) - }, - // Why: activation-deferred tabs can start a watcher before any pane restored the title; ordinary parked tabs avoid this IPC. - restoreTitleOnRegister: options.restoreTitleOnRegister === true - }) - : null - + let commandCodeOutputStatusDetector: ReturnType< + typeof createCommandCodeOutputStatusDetector + > | null = null // Why: no xterm answers DECSET 2031 while parked; with the gate ON, the responder's sidecar would force-feed bytes to the gated PTY, so skip it. - const stopMode2031Responder = factOwnsMode2031 - ? null - : startParkedTerminalMode2031Responder({ ptyId, sendInput }) + let stopMode2031Responder: (() => void) | null = null // Why: parked tabs are the canonical hidden view — mark the PTY gated so main stops renderer byte delivery. - const releaseHiddenDeliveryClaim = hiddenDeliveryGateActive - ? acquireHiddenRendererPtyDeliveryClaim(ptyId) - : null + let activated = false + let releaseHiddenDeliveryClaim: (() => void) | null = null const processLiveData = (data: string): void => { - if (!processor) { + if (!activated || !processor || hasPrimaryPtyDataHandler(ptyId)) { return } processor.processData(data, {}) @@ -295,38 +154,134 @@ export function startParkedTerminalByteWatcher( } // Why: paired hosts forward derived facts over the one environment event // stream; parked PTYs never consume terminal multiplex slots or raw bytes. - const unsubscribeByteParsers = - processor === null ? null : subscribeToPtyData(ptyId, processLiveData) + let unsubscribeByteParsers: (() => void) | null = null + let preparedFactConsumer: ReturnType | null = null + try { + if (!factSideEffectAuthority) { + processor = createPtyOutputProcessor({ + ...(options.initialTitle !== undefined ? { initialAgentTitle: options.initialTitle } : {}), + ...sideEffectCallbacks + }) + observeTerminalGitHubPRLink = createTerminalGitHubPRLinkDetector() + commandFinishedScanner = createOsc133CommandFinishedScanner( + commandStatusPolicy.onCommandFinished + ) + commandCodeOutputStatusDetector = createCommandCodeOutputStatusDetector({ + inFlightTurn: readInFlightCommandCodeTurn(paneKey), + onWorking: commandStatusPolicy.onCommandCodeWorking, + onDone: commandStatusPolicy.onCommandCodeDone + }) + unsubscribeByteParsers = subscribeToPtyData(ptyId, processLiveData) + } else { + preparedFactConsumer = prepareTerminalSideEffectFactConsumer({ + ptyId, + ...(options.sideEffectIdentity + ? { incarnationId: options.sideEffectIdentity.incarnationId } + : {}), + callbacks: { + ...sideEffectCallbacks, + onCommandFinished: commandStatusPolicy.onCommandFinished, + onCommandCodeWorking: commandStatusPolicy.onCommandCodeWorking, + onCommandCodeDone: commandStatusPolicy.onCommandCodeDone, + onPrLink: (link) => + useAppStore.getState().observeTerminalGitHubPullRequestLink(worktreeId, link), + ...(factOwnsMode2031 ? { onMode2031Subscribe: sendMode2031Reply } : {}) + }, + restoreTitleOnRegister: options.restoreTitleOnRegister === true, + activateOnPredecessorRelease: true + }) + } + } catch (error) { + runParkedWatcherCleanups([ + () => preparedFactConsumer?.cancel(), + unsubscribeByteParsers, + () => processor?.disposePendingSideEffectGauge(), + () => processor?.clearAccumulatedState(), + () => commandFinishedScanner?.reset(), + commandStatusPolicy.dispose, + () => sideEffectPolicy.dispose() + ]) + throw error + } - const dispose = (): void => { + const activateParked = (): boolean => { + if (disposed || activated) { + return activated && !disposed + } + let nextHiddenDeliveryClaim: (() => void) | null = null + let nextMode2031Responder: (() => void) | null = null + try { + if (hiddenDeliveryGateActive) { + nextHiddenDeliveryClaim = acquireHiddenRendererPtyDeliveryClaim(ptyId) + } + if (!factOwnsMode2031) { + nextMode2031Responder = startParkedTerminalMode2031Responder({ ptyId, sendInput }) + } + } catch { + runParkedWatcherCleanups([ + nextHiddenDeliveryClaim, + nextMode2031Responder, + () => preparedFactConsumer?.cancel(), + dispose + ]) + return false + } + try { + if (preparedFactConsumer && !preparedFactConsumer.activate()) { + runParkedWatcherCleanups([ + nextHiddenDeliveryClaim, + nextMode2031Responder, + () => preparedFactConsumer?.cancel(), + dispose + ]) + return false + } + } catch { + runParkedWatcherCleanups([ + nextHiddenDeliveryClaim, + nextMode2031Responder, + () => preparedFactConsumer?.cancel(), + dispose + ]) + return false + } + activated = true + releaseHiddenDeliveryClaim = nextHiddenDeliveryClaim + stopMode2031Responder = nextMode2031Responder + const predecessor = parkedWatcherDisposersByPtyId.get(ptyId) + parkedWatcherDisposersByPtyId.set(ptyId, dispose) + runParkedWatcherCleanups([ + predecessor ? () => predecessor({ preserveRuntimeTitle: true }) : null + ]) + return true + } + + const dispose = (disposeOptions?: { preserveRuntimeTitle?: boolean }): void => { if (disposed) { return } disposed = true // Why first: each park/reveal cycle owns a distinct processor gauge, and the store/IPC // teardown below must not be able to throw its way past the census drop. - processor?.disposePendingSideEffectGauge() - // Why: unhide BEFORE the reveal remount registers pane handlers, so main resumes delivery and emits the restore marker the pane consumes. - releaseHiddenDeliveryClaim?.() - stopMode2031Responder?.() - unsubscribeByteParsers?.() - unregisterFactConsumer?.() - // Why: clears tracker/timer/detector state so the watcher can't fire after the revealed pane's live parsers take over. - processor?.clearAccumulatedState() - commandFinishedScanner?.reset() - commandStatusPolicy.dispose() - clearBellNotificationTimer() - clearAgentTaskCompleteTimer() - pendingBellNotification = false - // Why: store merge never deletes title slots, so a watcher-written entry would strand after reveal and pin worktree status 'working'. - if (wroteRuntimeTitleSlot) { - wroteRuntimeTitleSlot = false - useAppStore.getState().clearRuntimePaneTitle(tabId, paneId) - } + const hiddenDeliveryClaim = releaseHiddenDeliveryClaim + releaseHiddenDeliveryClaim = null + const mode2031Responder = stopMode2031Responder + stopMode2031Responder = null + runParkedWatcherCleanups([ + // Why: unhide before a reveal successor registers so main emits its restore marker to the live pane. + hiddenDeliveryClaim, + mode2031Responder, + unsubscribeByteParsers, + () => preparedFactConsumer?.unregister(), + () => processor?.disposePendingSideEffectGauge(), + () => processor?.clearAccumulatedState(), + () => commandFinishedScanner?.reset(), + commandStatusPolicy.dispose + ]) + sideEffectPolicy.dispose(disposeOptions) if (parkedWatcherDisposersByPtyId.get(ptyId) === dispose) { parkedWatcherDisposersByPtyId.delete(ptyId) } } - parkedWatcherDisposersByPtyId.set(ptyId, dispose) - return dispose + return { activateParked, dispose } } diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-mode2031-responder.test.ts b/src/renderer/src/components/terminal-pane/parked-terminal-mode2031-responder.test.ts index c619790fdb1..da33d4eeeb2 100644 --- a/src/renderer/src/components/terminal-pane/parked-terminal-mode2031-responder.test.ts +++ b/src/renderer/src/components/terminal-pane/parked-terminal-mode2031-responder.test.ts @@ -11,9 +11,11 @@ const PTY_ID = 'pty-parked-2031' const FISH_PROMPT_HANDOFF = `${ESC}[?2031h${ESC}[0m~/orca ${ESC}[32m❯${ESC}[0m ${ESC}[?2031l` let sidecarWatcher: ((data: string) => void) | null = null +let primaryHandlerRegistered = false const unsubscribe = vi.fn() vi.mock('./pty-data-sidecar-subscriptions', () => ({ + hasPrimaryPtyDataHandler: () => primaryHandlerRegistered, subscribeToPtyData: (_ptyId: string, watcher: (data: string) => void) => { sidecarWatcher = watcher return unsubscribe @@ -44,6 +46,7 @@ function startResponder(): { sendInput: ReturnType; feed: (data: s beforeEach(() => { sidecarWatcher = null + primaryHandlerRegistered = false vi.clearAllMocks() }) @@ -65,6 +68,15 @@ describe('parked-tab DECSET 2031 responder honors the chunk-final state (#9993)' expect(sendInput).toHaveBeenCalledWith(`${ESC}[?997;1n`) }) + it('leaves mounted-pane subscriptions to the primary handler', () => { + const { sendInput, feed } = startResponder() + primaryHandlerRegistered = true + + feed(`${ESC}[?2031h`) + + expect(sendInput).not.toHaveBeenCalled() + }) + it('stays silent across a run of fish prompts', () => { const { sendInput, feed } = startResponder() diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-mode2031-responder.ts b/src/renderer/src/components/terminal-pane/parked-terminal-mode2031-responder.ts index 4c2ca568de9..af17aafe0ad 100644 --- a/src/renderer/src/components/terminal-pane/parked-terminal-mode2031-responder.ts +++ b/src/renderer/src/components/terminal-pane/parked-terminal-mode2031-responder.ts @@ -22,7 +22,7 @@ import { } from '../../../../shared/terminal-color-scheme-protocol' import { useAppStore } from '@/store' import { getSystemPrefersDark } from '@/lib/terminal-theme' -import { subscribeToPtyData } from './pty-data-sidecar-subscriptions' +import { hasPrimaryPtyDataHandler, subscribeToPtyData } from './pty-data-sidecar-subscriptions' export type ParkedTerminalMode2031ResponderOptions = { ptyId: string @@ -36,6 +36,9 @@ export function startParkedTerminalMode2031Responder( const { ptyId, sendInput } = options let scanState = INITIAL_MODE_2031_REPLY_SCAN_STATE return subscribeToPtyData(ptyId, (data) => { + if (hasPrimaryPtyDataHandler(ptyId)) { + return + } const result = scanMode2031ReplyDecision(scanState, data) scanState = result.state if (result.decision !== 'subscribed') { diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-side-effect-policy.ts b/src/renderer/src/components/terminal-pane/parked-terminal-side-effect-policy.ts new file mode 100644 index 00000000000..bbb96edaff6 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/parked-terminal-side-effect-policy.ts @@ -0,0 +1,154 @@ +import { isClaudeAgent } from '../../../../shared/agent-detection' +import { useAppStore } from '@/store' +import { + AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS, + isAgentTaskCompleteOsNotificationEnabledFromState, + isAgentTaskCompleteTrackingEnabledFromState +} from './agent-task-complete-policy' +import { dispatchTerminalNotification } from './use-notification-dispatch' + +type ParkedTerminalSideEffectPolicyOptions = { + tabId: string + worktreeId: string + paneId: number + paneKey: string + drivesTabTitle: boolean +} + +export type ParkedTerminalSideEffectPolicy = { + callbacks: { + onTitleChange: (title: string) => void + onBell: () => void + onAgentBecameIdle: (title: string, meta?: { staleWorkingTitleClear?: boolean }) => void + onAgentBecameWorking: () => void + onAgentExited: () => void + } + dispose: (options?: { preserveRuntimeTitle?: boolean }) => void +} + +export function createParkedTerminalSideEffectPolicy( + options: ParkedTerminalSideEffectPolicyOptions +): ParkedTerminalSideEffectPolicy { + let disposed = false + let pendingBellNotification = false + let wroteRuntimeTitleSlot = false + let bellNotificationTimer: ReturnType | null = null + let agentTaskCompleteTimer: ReturnType | null = null + + const clearBellNotificationTimer = (): void => { + if (bellNotificationTimer !== null) { + clearTimeout(bellNotificationTimer) + bellNotificationTimer = null + } + } + const clearAgentTaskCompleteTimer = (): void => { + if (agentTaskCompleteTimer !== null) { + clearTimeout(agentTaskCompleteTimer) + agentTaskCompleteTimer = null + } + } + const hasPendingAgentTaskCompleteNotification = (): boolean => + agentTaskCompleteTimer !== null && + isAgentTaskCompleteOsNotificationEnabledFromState(useAppStore.getState()) + const scheduleTerminalBellNotification = (): void => { + if (bellNotificationTimer !== null) { + return + } + bellNotificationTimer = setTimeout(() => { + bellNotificationTimer = null + if (disposed) { + pendingBellNotification = false + return + } + if (hasPendingAgentTaskCompleteNotification()) { + return + } + pendingBellNotification = false + dispatchTerminalNotification(options.worktreeId, { + source: 'terminal-bell', + paneKey: options.paneKey + }) + }, AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS) + } + + return { + callbacks: { + onTitleChange(title): void { + const state = useAppStore.getState() + wroteRuntimeTitleSlot = true + state.setRuntimePaneTitle(options.tabId, options.paneId, title) + if (options.drivesTabTitle) { + state.updateTabTitle(options.tabId, title) + } + }, + onBell(): void { + const state = useAppStore.getState() + state.markWorktreeUnread(options.worktreeId) + state.markTerminalTabUnread(options.tabId) + if (state.settings?.experimentalTerminalAttention === true) { + state.markTerminalPaneUnread(options.paneKey) + } + pendingBellNotification = true + if (!hasPendingAgentTaskCompleteNotification()) { + scheduleTerminalBellNotification() + } + }, + onAgentBecameIdle(title, meta): void { + if (meta?.staleWorkingTitleClear) { + useAppStore.getState().setCacheTimerStartedAt(options.paneKey, null) + return + } + const state = useAppStore.getState() + if ( + isClaudeAgent(title) && + (state.settings === null || state.settings.promptCacheTimerEnabled) + ) { + state.setCacheTimerStartedAt(options.paneKey, Date.now()) + } + if (!isAgentTaskCompleteTrackingEnabledFromState(state)) { + return + } + clearAgentTaskCompleteTimer() + agentTaskCompleteTimer = setTimeout(() => { + agentTaskCompleteTimer = null + if (disposed) { + return + } + pendingBellNotification = false + clearBellNotificationTimer() + dispatchTerminalNotification(options.worktreeId, { + source: 'agent-task-complete', + terminalTitle: title, + paneKey: options.paneKey, + ...(isAgentTaskCompleteOsNotificationEnabledFromState(useAppStore.getState()) + ? {} + : { suppressOsNotification: true }) + }) + }, AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS) + }, + onAgentBecameWorking(): void { + useAppStore.getState().setCacheTimerStartedAt(options.paneKey, null) + clearAgentTaskCompleteTimer() + if (pendingBellNotification) { + scheduleTerminalBellNotification() + } + }, + onAgentExited(): void { + useAppStore.getState().setCacheTimerStartedAt(options.paneKey, null) + } + }, + dispose(disposeOptions): void { + if (disposed) { + return + } + disposed = true + clearBellNotificationTimer() + clearAgentTaskCompleteTimer() + pendingBellNotification = false + if (wroteRuntimeTitleSlot && disposeOptions?.preserveRuntimeTitle !== true) { + wroteRuntimeTitleSlot = false + useAppStore.getState().clearRuntimePaneTitle(options.tabId, options.paneId) + } + } + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-authority-outcome-exit-policy.ts b/src/renderer/src/components/terminal-pane/pty-authority-outcome-exit-policy.ts new file mode 100644 index 00000000000..14bb78d6c0f --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-authority-outcome-exit-policy.ts @@ -0,0 +1,29 @@ +import { clearProcessedPtyCharTotal } from './terminal-pty-ack-gate' +import { clearReceivedPtyCharTotal } from './terminal-delivery-watchdog' +import { + isEventKeyedPtyExitSnapshot, + onPtyExitPolicyAvailable, + releasePtyExitHandlers, + settleAuthoritativePtyExitHandlers, + snapshotPtyExitHandlers, + type PtyExitDelivery +} from './pty-exit-delivery' + +export async function applyAuthoritativePtyExit( + delivery: Pick +): Promise { + const handlers = snapshotPtyExitHandlers(delivery.ptyId) + if ( + (!handlers.primary && handlers.sidecars.length === 0) || + !isEventKeyedPtyExitSnapshot(handlers) + ) { + return false + } + await settleAuthoritativePtyExitHandlers({ ...delivery, ...handlers }) + clearProcessedPtyCharTotal(delivery.ptyId) + clearReceivedPtyCharTotal(delivery.ptyId) + releasePtyExitHandlers(delivery.ptyId, handlers) + return true +} + +export { onPtyExitPolicyAvailable as onAuthoritativePtyExitPolicyAvailable } diff --git a/src/renderer/src/components/terminal-pane/pty-buffer-serializer.ts b/src/renderer/src/components/terminal-pane/pty-buffer-serializer.ts index c32ea0aafaf..a133f376e26 100644 --- a/src/renderer/src/components/terminal-pane/pty-buffer-serializer.ts +++ b/src/renderer/src/components/terminal-pane/pty-buffer-serializer.ts @@ -4,6 +4,7 @@ // main process to serialize a specific terminal's buffer. import type { IDisposable } from '@xterm/xterm' +import type { PtyMutationIdentity } from '../../../../shared/pty-mutation-identity' export type SerializeOpts = { scrollbackRows?: number @@ -29,6 +30,7 @@ export type SerializeFn = ( type SerializerEntry = { fn: SerializeFn clear?: () => void + mutationIdentity?: PtyMutationIdentity owner: symbol } @@ -45,10 +47,16 @@ let listenerAttached = false export function registerPtySerializer( ptyId: string, serialize: SerializeFn, - clear?: () => void + clear?: () => void, + mutationIdentity?: PtyMutationIdentity | null ): () => void { const owner = Symbol(ptyId) - serializersByPtyId.set(ptyId, { fn: serialize, clear, owner }) + serializersByPtyId.set(ptyId, { + fn: serialize, + clear, + ...(mutationIdentity ? { mutationIdentity } : {}), + owner + }) ensureSerializerListener() return () => { const current = serializersByPtyId.get(ptyId) @@ -125,7 +133,17 @@ function ensureSerializerListener(): void { // Why: mobile clear is a terminal action, not a PTY byte. Clearing the // renderer-owned xterm keeps future mobile snapshots from rehydrating // scrollback that the user explicitly removed. - serializersByPtyId.get(request.ptyId)?.clear?.() + const entry = serializersByPtyId.get(request.ptyId) + const mutationIdentity = entry?.mutationIdentity + if ( + request.incarnationId !== undefined && + (!mutationIdentity || + mutationIdentity.incarnationId !== request.incarnationId || + mutationIdentity.paneGeneration !== request.paneGeneration) + ) { + return + } + entry?.clear?.() }) window.api.pty.onSerializeBufferRequest((request) => { diff --git a/src/renderer/src/components/terminal-pane/pty-connection-types.ts b/src/renderer/src/components/terminal-pane/pty-connection-types.ts index 50a5946e770..67651b486bb 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-types.ts @@ -18,6 +18,7 @@ import type { DirectSshPaneRetryAttemptId } from '@/store/slices/direct-ssh-term export type PtyConnectionDeps = { tabId: string + paneGeneration: number worktreeId: string cwd?: string startup?: { diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 29f693640bb..8d7f5d92423 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -21,6 +21,7 @@ import { getEagerPtyBufferHandle } from './pty-dispatcher' import type { AgentType } from '../../../../shared/agent-status-types' import { makePaneKey } from '../../../../shared/stable-pane-id' import { toAppSshPtyId } from '../../../../shared/ssh-pty-id' +import type { PtyMutationIdentity } from '../../../../shared/pty-mutation-identity' import type { SshConnectionState } from '../../../../shared/ssh-types' import type { TerminalLayoutSnapshot, TuiAgent } from '../../../../shared/types' import { YOLO_TUI_AGENT_ARGS } from '../../../../shared/tui-agent-permissions' @@ -309,6 +310,7 @@ type ConnectCallbacks = { onReplayData?: (data: string, meta?: { clearBeforeReplay?: boolean }) => void onError?: (msg: string) => void onWriteUnavailable?: () => void + onMutationAccessAvailable?: () => void onOutputPauseChanged?: (paused: boolean, supported: boolean) => void } @@ -324,10 +326,13 @@ type MockTransport = { sendInput: ReturnType sendInputImmediate: ReturnType sendInputAccepted?: ReturnType + signal?: ReturnType claimViewport: ReturnType setOutputPaused?: ReturnType resize: ReturnType getPtyId: ReturnType + getMutationIdentity?: ReturnType + hasMutationAccess?: ReturnType getConnectionId: ReturnType serializeBuffer?: ReturnType } @@ -462,6 +467,13 @@ function createMockTransport(initialPtyId: string | null = null): MockTransport ptyId = null }), sendInput: vi.fn(() => true), + signal: vi.fn((signal: string) => { + if (!ptyId) { + return false + } + window.api.pty.signal(ptyId, signal) + return true + }), claimViewport: vi.fn(() => true), resize: vi.fn(() => true), getPtyId: vi.fn(() => ptyId), @@ -11269,6 +11281,78 @@ describe('connectPanePty', () => { expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(1, 'tab-pty') }) + it('releases exact renderer delivery only after reattach bytes parse into xterm', async () => { + const { connectPanePty } = await import('./pty-connection') + const { deliverTerminalDataWithDeferredCredit } = + await import('@/lib/pane-manager/terminal-delivery-credit') + const transport = createMockTransport('tab-pty') + const markRendererReady = vi.fn() + const cancelRendererBinding = vi.fn() + const acknowledgePostReconnectFrame = vi.fn() + transport.connect.mockImplementation( + async ({ sessionId, callbacks }: { sessionId?: string; callbacks?: ConnectCallbacks }) => { + if (!sessionId) { + return null + } + return { + id: sessionId, + snapshot: 'AUTHORITY_BEFORE_RECONNECT', + rendererBindingSettlement: { + id: sessionId, + paneGeneration: 7, + ready: () => { + markRendererReady() + deliverTerminalDataWithDeferredCredit(acknowledgePostReconnectFrame, () => { + callbacks?.onData?.('AUTHORITY_AFTER_RECONNECT') + }) + }, + cancel: cancelRendererBinding + } + } + } + ) + transportFactoryQueue.push(transport) + const pane = createPane(1) + const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) + const deps = createDeps({ + paneGeneration: 7, + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, createManager(1) as never, deps as never) + await flushAsyncTicks(20) + + expect(markRendererReady).not.toHaveBeenCalled() + expect(writes).toContain('AUTHORITY_BEFORE_RECONNECT') + expect(writes).not.toContain('AUTHORITY_AFTER_RECONNECT') + expect(acknowledgePostReconnectFrame).not.toHaveBeenCalled() + + for (let step = 0; step < 40 && markRendererReady.mock.calls.length === 0; step += 1) { + parseCallbacks.shift()?.() + await flushAsyncTicks(2) + } + await flushAsyncTicks(8) + + const beforeIndex = writes.indexOf('AUTHORITY_BEFORE_RECONNECT') + const resetIndex = writes.indexOf(POST_REPLAY_REATTACH_RESET) + const afterIndex = writes.indexOf('AUTHORITY_AFTER_RECONNECT') + expect(markRendererReady).toHaveBeenCalledOnce() + expect(cancelRendererBinding).not.toHaveBeenCalled() + expect(resetIndex).toBeGreaterThan(beforeIndex) + expect(afterIndex).toBeGreaterThan(resetIndex) + expect(writes.filter((data) => data === 'AUTHORITY_AFTER_RECONNECT')).toHaveLength(1) + expect(acknowledgePostReconnectFrame).not.toHaveBeenCalled() + + while (parseCallbacks.length > 0) { + parseCallbacks.shift()?.() + await flushAsyncTicks(2) + } + await flushAsyncTicks(8) + + expect(acknowledgePostReconnectFrame).toHaveBeenCalledOnce() + }) + it('re-enforces follow intent after deferred reattach live output parses', async () => { const { connectPanePty } = await import('./pty-connection') const { markTerminalFollowOutput } = await import('@/lib/pane-manager/terminal-scroll-intent') @@ -18915,6 +18999,67 @@ describe('connectPanePty', () => { ) }) + it('upgrades an attached fact consumer when exact mutation access resolves', async () => { + enableMainAuthority() + const ptyId = 'pty-fact-attached' + vi.mocked(getEagerPtyBufferHandle).mockImplementation((candidatePtyId: string) => + candidatePtyId === ptyId ? { flush: () => '', dispose: () => {} } : undefined + ) + const { connectPanePty } = await import('./pty-connection') + const handler = await import('./terminal-side-effect-facts-handler') + const transport = createMockTransport(ptyId) + let mutationIdentity: PtyMutationIdentity | null = null + const mutationAccessCallback: { current: (() => void) | null } = { current: null } + transport.getMutationIdentity = vi.fn(() => mutationIdentity) + transport.hasMutationAccess = vi.fn(() => mutationIdentity !== null) + transport.attach.mockImplementation(({ callbacks }: { callbacks: ConnectCallbacks }) => { + mutationAccessCallback.current = callbacks.onMutationAccessAvailable ?? null + }) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId }] }, + ptyIdsByTabId: { 'tab-1': [ptyId] }, + terminalLayoutsByTabId: { + 'tab-1': { + root: { type: 'leaf', leafId: LEAF_1 }, + activeLeafId: LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_1]: ptyId } + } + } + } as StoreState + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: ptyId } + }) + + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + await flushAsyncTicks() + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId, + ptyIncarnationId: 'incarnation-attached', + seq: 1, + facts: [{ kind: 'title', normalizedTitle: 'before proof', rawTitle: 'before proof' }] + }) + expect(deps.setRuntimePaneTitle).not.toHaveBeenCalled() + + mutationIdentity = { + incarnationId: 'incarnation-attached', + paneGeneration: 0, + mutationLeaseId: 'lease-attached' + } + mutationAccessCallback.current?.() + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId, + ptyIncarnationId: 'incarnation-attached', + seq: 2, + facts: [{ kind: 'title', normalizedTitle: 'after proof', rawTitle: 'after proof' }] + }) + + expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'after proof') + }) + it('stops consuming facts after the pane binding is disposed', async () => { enableMainAuthority() const { connectPanePty } = await import('./pty-connection') diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 8167a1c49cc..c0a4f8f3ac7 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -272,7 +272,11 @@ import { setCommandCodeDoneSettleExecutor } from './command-code-done-settle' import { canCommandCodeOutputOwnPane } from './command-code-output-ownership' -import { isTerminalTabParked } from './terminal-parked-watcher-registry' +import { + beginParkedTerminalWatcherReplacement, + isTerminalTabParked, + type ParkedTerminalWatcherReplacement +} from './terminal-parked-watcher-registry' import { getExecutionHostIdForWorktree, getSettingsForWorktreeRuntimeOwner @@ -321,8 +325,13 @@ import { } from './agent-task-complete-policy' import { isMainTerminalSideEffectAuthorityForPty, + prepareTerminalSideEffectFactConsumer, registerTerminalSideEffectFactConsumer } from './terminal-side-effect-facts-handler' +import type { + PreparedTerminalSideEffectFactConsumer, + TerminalSideEffectFactConsumerCallbacks +} from './terminal-side-effect-facts-handler' import { isRendererHiddenPtyDeliveryGateEnabled } from './terminal-hidden-delivery-gate' import { markRendererOwnedAgentStatusWrite, @@ -2316,6 +2325,33 @@ export function connectPanePty( // restoreTitleOnRegister replaces the eager-replay title restore: main's // title-only snapshot carries the no-attention-replay rule. let unregisterSideEffectFactConsumer: (() => void) | null = null + let preparedRevealFactConsumer: PreparedTerminalSideEffectFactConsumer | null = null + let parkedWatcherReplacement: ParkedTerminalWatcherReplacement | null = null + let parkedWatcherReplacementPtyId: string | null = null + let completeParkedWatcherReplacement = (_ptyId: string): boolean => false + const sideEffectFactCallbacks = ( + remoteOutputPaused = false + ): TerminalSideEffectFactConsumerCallbacks => ({ + onTitleChange, + onBell, + onAgentBecameIdle, + onAgentBecameWorking, + onAgentExited, + onCommandFinished: handleCommandFinished, + onPrLink: (link) => + useAppStore.getState().observeTerminalGitHubPullRequestLink(deps.worktreeId, link), + onCommandCodeWorking: seedCommandCodeOutputWorkingStatus, + onCommandCodeDone: scheduleCommandCodeOutputDoneStatus, + ...(shouldOwnAgentStatusInRenderer + ? { onAgentStatus: (payload) => handleRendererOwnedAgentStatus(payload) } + : {}), + ...(hiddenDeliveryGateActive || remoteOutputPaused + ? { + onMode2031Subscribe: handleHiddenMode2031SubscribeFact, + onMode2031Unsubscribe: handleHiddenMode2031UnsubscribeFact + } + : {}) + }) const registerSideEffectFactConsumerForPty = ( ptyId: string, remoteOutputPaused = false @@ -2323,35 +2359,15 @@ export function connectPanePty( if ((!mainSideEffectAuthority && !remoteOutputPaused) || disposed) { return } + if (preparedRevealFactConsumer && parkedWatcherReplacementPtyId === ptyId) { + return + } unregisterSideEffectFactConsumer?.() + const mutationIncarnationId = transport.getMutationIdentity?.()?.incarnationId unregisterSideEffectFactConsumer = registerTerminalSideEffectFactConsumer({ ptyId, - callbacks: { - onTitleChange, - onBell, - onAgentBecameIdle, - onAgentBecameWorking, - onAgentExited, - onCommandFinished: handleCommandFinished, - onPrLink: (link) => - useAppStore.getState().observeTerminalGitHubPullRequestLink(deps.worktreeId, link), - // Why: the Command Code settle policy stays here — the done settle - // timer must consult the live store row (which hook events and - // renderer seeds also write), so main only emits scrape facts. - onCommandCodeWorking: seedCommandCodeOutputWorkingStatus, - onCommandCodeDone: scheduleCommandCodeOutputDoneStatus, - ...(shouldOwnAgentStatusInRenderer - ? { onAgentStatus: (payload) => handleRendererOwnedAgentStatus(payload) } - : {}), - // Why: gated hidden panes never see the subscribe bytes; the fact - // replaces the byte scan (and the old post-latch subscribe drop). - ...(hiddenDeliveryGateActive || remoteOutputPaused - ? { - onMode2031Subscribe: handleHiddenMode2031SubscribeFact, - onMode2031Unsubscribe: handleHiddenMode2031UnsubscribeFact - } - : {}) - }, + ...(mutationIncarnationId ? { incarnationId: mutationIncarnationId } : {}), + callbacks: sideEffectFactCallbacks(remoteOutputPaused), restoreTitleOnRegister: true }) } @@ -2957,6 +2973,7 @@ export function connectPanePty( // guard knows this binding is newer than any pre-bind snapshot. activePanePtyBindingBoundAt = performance.now() registerSideEffectFactConsumerForPty(ptyId) + completeParkedWatcherReplacement(ptyId) syncHiddenRendererPtyDelivery() deps.syncPanePtyLayoutBinding(pane.id, ptyId) notifyCodexPaneBoundForStaleSweep(ptyId) @@ -3778,6 +3795,7 @@ export function connectPanePty( // and the main-side guard short-circuits. tabId: deps.tabId, leafId: pane.leafId, + paneGeneration: deps.paneGeneration, activate: deps.isActiveRef.current && deps.isVisibleRef.current, ...(shellOverride ? { shellOverride } : {}), ...(projectRuntime ? { projectRuntime } : {}), @@ -3834,6 +3852,58 @@ export function connectPanePty( : runtimeEnvironmentId ? createRemoteRuntimePtyTransport(runtimeEnvironmentId, transportOptions) : createIpcPtyTransport(transportOptions) + const parkedRevealPtyId = mountFollowsTerminalPark + ? (restoredPtyIdForTransport ?? tab?.ptyId ?? null) + : null + if (parkedRevealPtyId) { + parkedWatcherReplacement = beginParkedTerminalWatcherReplacement(deps.tabId, parkedRevealPtyId) + if (parkedWatcherReplacement) { + parkedWatcherReplacementPtyId = parkedRevealPtyId + if (mainSideEffectAuthority) { + preparedRevealFactConsumer = prepareTerminalSideEffectFactConsumer({ + ptyId: parkedRevealPtyId, + callbacks: sideEffectFactCallbacks(), + restoreTitleOnRegister: true + }) + } + } + } + completeParkedWatcherReplacement = (boundPtyId): boolean => { + const replacement = parkedWatcherReplacement + const replacedPtyId = parkedWatcherReplacementPtyId + if (!replacement || !replacedPtyId) { + return false + } + const replacesSamePty = boundPtyId === replacedPtyId + if ( + replacesSamePty && + !isRemoteRuntimePtyId(boundPtyId) && + transport.hasMutationAccess?.() !== true + ) { + return false + } + const prepared = preparedRevealFactConsumer + const mutationIncarnationId = transport.getMutationIdentity?.()?.incarnationId + const committed = replacement.commit(() => { + if (!replacesSamePty || !prepared) { + return true + } + return prepared.activate(mutationIncarnationId) + }) + if (!committed) { + return false + } + if (replacesSamePty && prepared) { + unregisterSideEffectFactConsumer = prepared.unregister + } else { + prepared?.cancel() + } + preparedRevealFactConsumer = null + parkedWatcherReplacement = null + parkedWatcherReplacementPtyId = null + mountFollowsTerminalPark = false + return replacesSamePty && prepared !== null + } const canSendDesktopQueryReply = (): boolean => { const ptyId = transport.getPtyId() return !ptyId || !isPtyLocked(ptyId) @@ -4684,7 +4754,8 @@ export function connectPanePty( clearHiddenOutputRestoreState() discardTerminalOutput(pane.terminal) clearTerminalScrollbackAndFollowOutput(pane.terminal) - } + }, + transport.getMutationIdentity?.() ) const unregisterTitleSource = registerPtyTitleSource(ptyId, (handler) => pane.terminal.onTitleChange(handler) @@ -5240,6 +5311,7 @@ export function connectPanePty( const trackedPromise: Promise = Promise.resolve(spawnedRaw) .then(async (spawnedPtyId) => { if (outputCallbacks.generation !== transportStreamGeneration) { + cancelRendererBindingSettlement(spawnedPtyId) finishReattachLiveDataDeferral(false, outputCallbacks.generation) const gen = await preSignalPromise if (typeof gen === 'number') { @@ -5254,6 +5326,7 @@ export function connectPanePty( ? spawnedPtyId : transport.getPtyId() if (resolvedPtyId && !claimCapturedDirectSshRetryPty(resolvedPtyId)) { + cancelRendererBindingSettlement(spawnedPtyId) finishReattachLiveDataDeferral(false, outputCallbacks.generation) return null } @@ -5831,6 +5904,17 @@ export function connectPanePty( requestRecoveryForUndeliverableInput(true) } }, + onMutationAccessAvailable: (): void => { + if (isCurrent()) { + const ptyId = transport.getPtyId() + if (ptyId) { + const installedPreparedConsumer = completeParkedWatcherReplacement(ptyId) + if (!installedPreparedConsumer) { + registerSideEffectFactConsumerForPty(ptyId) + } + } + } + }, onRecoveryStateChange: (state: PtyTransportRecoveryState): void => { if (isCurrent()) { // Why: cached pixels remain visible while detached; expose transport truth for diagnostics and recovery UI. @@ -7347,7 +7431,7 @@ export function connectPanePty( transport.resize(pane.terminal.cols, pane.terminal.rows) if (!isRemoteRuntimePtyId(currentPtyId)) { // Why: redundant SIGWINCH makes alt-screen TUIs rebuild their scroll viewport to the top on tab return. - window.api.pty.signal(currentPtyId, 'SIGWINCH') + transport.signal?.('SIGWINCH') } } }, @@ -8043,7 +8127,7 @@ export function connectPanePty( .catch(() => {}) } - const handleReattachResult = async ( + const applyReattachResult = async ( result: PtyConnectResult | string | void, staleSessionId?: string | null, coldRestoreStartup?: ColdRestoreAgentResumeStartup | null, @@ -8154,6 +8238,7 @@ export function connectPanePty( setPanePtyFitBinding(ptyId) reportPanePtyVisibility(ptyId, deps.isVisibleRef.current) registerSideEffectFactConsumerForPty(ptyId) + completeParkedWatcherReplacement(ptyId) syncHiddenRendererPtyDelivery() deps.syncPanePtyLayoutBinding(pane.id, ptyId) notifyCodexPaneBoundForStaleSweep(ptyId) @@ -8170,26 +8255,36 @@ export function connectPanePty( // Why (C1 SSH parking): main's headless model holds ~5k rows for SSH ptys // while the relay replay is a 100KiB raw-byte tail; prefer the model on - // reveal. Only a non-empty 'headless'-sourced snapshot qualifies — the - // renderer-serializer fallback has no mounted xterm after a park. The - // paint happens inline in the snapshot-branch style: applyMainBufferSnapshot - // would nest structuralReplayCoordinator.run inside the reattach task and - // deadlock on the coordinator's tail chain. + // reveal. A reconnect can also arrive after the connection-level reattach + // already drained the relay replay into main, leaving this pane with no + // payload of its own, so use the same model fallback for a replay-less + // SSH reattach. Only a non-empty 'headless'-sourced snapshot qualifies — + // the renderer-serializer fallback has no mounted xterm after a park. + // The paint happens inline in the snapshot-branch style: + // applyMainBufferSnapshot would nest structuralReplayCoordinator.run + // inside the reattach task and deadlock on the coordinator's tail chain. // Memoized: the prefetch and the payload task share one probe result, so a // null prefetch can never buy a second timeout before the relay paint. const fetchSshMainModelReattachSnapshot = getSshMainModelSnapshotProbe(ptyId) // Why consume-once: only the first reattach of a reveal remount may pay // the probe; a later in-place reconnect on this same mount must not buy a // second timeout before the relay paint. - const revealFollowsTerminalPark = - mountFollowsTerminalPark && - (connectResult?.isReattach === true || isRemoteRuntimePtyId(ptyId)) + const needsSshReattachModelSnapshot = + connectResult?.isReattach === true && + !connectResult.snapshot && + !connectResult.replay && + !connectResult.coldRestore && + parseAppSshPtyId(ptyId) !== null + const shouldUseSshModelSnapshot = + (mountFollowsTerminalPark && + (connectResult?.isReattach === true || isRemoteRuntimePtyId(ptyId))) || + needsSshReattachModelSnapshot mountFollowsTerminalPark = false // Why: ordinary parking destroys xterm. Rebuild from the authoritative // host snapshot before releasing queued live bytes; null falls back to // the subscribe screen without keeping the old xterm mounted. let prefetchedParkModelSnapshot: PtyBufferSnapshot | null = null - if (revealFollowsTerminalPark && (!hasStructuralReplay || isRemoteRuntimePtyId(ptyId))) { + if (shouldUseSshModelSnapshot && (!hasStructuralReplay || isRemoteRuntimePtyId(ptyId))) { if (parseAppSshPtyId(ptyId)) { prefetchedParkModelSnapshot = await fetchSshMainModelReattachSnapshot() } else { @@ -8258,7 +8353,7 @@ export function connectPanePty( // model still holds, but an in-place reattach (network reconnect, wake, // reload) already has that replay in hand, so probing would only delay its // paint by the timeout. Memoized, so this is never a second probe. - const modelSnapshot = revealFollowsTerminalPark + const modelSnapshot = shouldUseSshModelSnapshot ? (prefetchedParkModelSnapshot ?? (isRemoteRuntimePtyId(ptyId) ? null : await fetchSshMainModelReattachSnapshot())) : null @@ -8430,7 +8525,7 @@ export function connectPanePty( } // Why: POSIX only sends SIGWINCH on an actual dimension change; signal explicitly so restored TUIs repaint at the correct cursor after replay. if (!isRemoteRuntimePtyId(reattachPtyId)) { - window.api.pty.signal(reattachPtyId, 'SIGWINCH') + transport.signal?.('SIGWINCH') } }, { shouldContinue: isCurrentReattachPayload, retryIfUnmeasurable: true } @@ -8449,7 +8544,7 @@ export function connectPanePty( ptySizeReassertion.request({ fit: false }) } } else if (isCurrentReattachPayload() && !isRemoteRuntimePtyId(reattachPtyId)) { - window.api.pty.signal(reattachPtyId, 'SIGWINCH') + transport.signal?.('SIGWINCH') } } if (hasStructuralReplay || prefetchedParkModelSnapshot) { @@ -8470,6 +8565,40 @@ export function connectPanePty( return true } + const cancelRendererBindingSettlement = (result: PtyConnectResult | string | void): void => { + if (result && typeof result === 'object' && 'id' in result) { + result.rendererBindingSettlement?.cancel() + } + } + + const handleReattachResult = async ( + result: PtyConnectResult | string | void, + staleSessionId?: string | null, + coldRestoreStartup?: ColdRestoreAgentResumeStartup | null, + attemptGeneration = transportStreamGeneration + ): Promise => { + const rendererBindingSettlement = + result && typeof result === 'object' && 'id' in result + ? result.rendererBindingSettlement + : undefined + let accepted = false + try { + accepted = await applyReattachResult( + result, + staleSessionId, + coldRestoreStartup, + attemptGeneration + ) + return accepted + } finally { + if (accepted) { + rendererBindingSettlement?.ready() + } else { + rendererBindingSettlement?.cancel() + } + } + } + const attachRetainedLegacyPty = (ptyId: string): boolean => { try { authoritativeReattachGeneration += 1 @@ -8701,6 +8830,7 @@ export function connectPanePty( const trackedReattachPromise = Promise.resolve(reattachPromise) .then(async (result) => { if (outputCallbacks.generation !== transportStreamGeneration) { + cancelRendererBindingSettlement(result) finishReattachLiveDataDeferral(false, outputCallbacks.generation) const gen = await preSignalPromise if (typeof gen === 'number') { @@ -8947,6 +9077,7 @@ export function connectPanePty( const trackedReattachPromise = Promise.resolve(reattachPromise) .then(async (result) => { if (outputCallbacks.generation !== transportStreamGeneration) { + cancelRendererBindingSettlement(result) finishReattachLiveDataDeferral(false, outputCallbacks.generation) const gen = await preSignalPromise if (typeof gen === 'number') { @@ -9382,6 +9513,11 @@ export function connectPanePty( unregisterDocumentVisibilityRecovery?.() unregisterDocumentVisibilityRecovery = null releaseRendererPtyVisibilityClaim(transport) + parkedWatcherReplacement?.abort() + parkedWatcherReplacement = null + parkedWatcherReplacementPtyId = null + preparedRevealFactConsumer?.cancel() + preparedRevealFactConsumer = null // Why: the pane's fact consumer must be gone before a parked-tab watcher takes over this PTY's facts in the same effect flush. dropSideEffectFactConsumer() clearPanePtyFitBinding() diff --git a/src/renderer/src/components/terminal-pane/pty-data-sidecar-subscriptions.ts b/src/renderer/src/components/terminal-pane/pty-data-sidecar-subscriptions.ts index 3625a8e6007..f767ba2e74a 100644 --- a/src/renderer/src/components/terminal-pane/pty-data-sidecar-subscriptions.ts +++ b/src/renderer/src/components/terminal-pane/pty-data-sidecar-subscriptions.ts @@ -1,5 +1,9 @@ import { acquirePtyDeliveryInterest } from './pty-delivery-interest' -import { ensurePtyDispatcher, ptyDataSidecars } from './pty-dispatcher' +import { ensurePtyDispatcher, ptyDataHandlers, ptyDataSidecars } from './pty-dispatcher' + +export function hasPrimaryPtyDataHandler(ptyId: string): boolean { + return ptyDataHandlers.has(ptyId) +} /** Register a side-channel data watcher for a PTY without taking ownership * of the primary handler. Returns an unsubscribe fn. */ @@ -9,12 +13,17 @@ export function subscribeToPtyData(ptyId: string, watcher: (data: string) => voi // doubles as the delivery-interest signal that suppresses main's // hidden-delivery gate (terminal-side-effect-authority.md, Open Items). const releaseDeliveryInterest = acquirePtyDeliveryInterest(ptyId) - let set = ptyDataSidecars.get(ptyId) - if (!set) { - set = new Set() - ptyDataSidecars.set(ptyId, set) + try { + let set = ptyDataSidecars.get(ptyId) + if (!set) { + set = new Set() + ptyDataSidecars.set(ptyId, set) + } + set.add(watcher) + } catch (error) { + releaseDeliveryInterest() + throw error } - set.add(watcher) return () => { releaseDeliveryInterest() const current = ptyDataSidecars.get(ptyId) diff --git a/src/renderer/src/components/terminal-pane/pty-delivery-interest.ts b/src/renderer/src/components/terminal-pane/pty-delivery-interest.ts index bb8e7d65a38..5fd6958c2ee 100644 --- a/src/renderer/src/components/terminal-pane/pty-delivery-interest.ts +++ b/src/renderer/src/components/terminal-pane/pty-delivery-interest.ts @@ -15,11 +15,11 @@ function sendPtyDeliveryInterest(ptyId: string, interested: boolean): void { /** Acquire a delivery-interest hold for a PTY. Returns a release fn that is * safe to call more than once (only the first call decrements). */ export function acquirePtyDeliveryInterest(ptyId: string): () => void { - const next = (ptyDeliveryInterestRefCounts.get(ptyId) ?? 0) + 1 - ptyDeliveryInterestRefCounts.set(ptyId, next) - if (next === 1) { + const current = ptyDeliveryInterestRefCounts.get(ptyId) ?? 0 + if (current === 0) { sendPtyDeliveryInterest(ptyId, true) } + ptyDeliveryInterestRefCounts.set(ptyId, current + 1) let released = false return () => { if (released) { diff --git a/src/renderer/src/components/terminal-pane/pty-dispatcher-delivery-interest.test.ts b/src/renderer/src/components/terminal-pane/pty-dispatcher-delivery-interest.test.ts index c8f84865fed..018d839f06c 100644 --- a/src/renderer/src/components/terminal-pane/pty-dispatcher-delivery-interest.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-dispatcher-delivery-interest.test.ts @@ -69,6 +69,20 @@ describe('pty dispatcher delivery interest', () => { expect(setPtyDeliveryInterest).toHaveBeenLastCalledWith('pty-1', false) }) + it('does not retain interest when the first publication throws', async () => { + const { subscribeToPtyData } = await import('./pty-data-sidecar-subscriptions') + setPtyDeliveryInterest.mockImplementationOnce(() => { + throw new Error('interest publication failed') + }) + + expect(() => subscribeToPtyData('pty-1', vi.fn())).toThrow('interest publication failed') + const unsubscribe = subscribeToPtyData('pty-1', vi.fn()) + expect(setPtyDeliveryInterest).toHaveBeenLastCalledWith('pty-1', true) + + unsubscribe() + expect(setPtyDeliveryInterest).toHaveBeenLastCalledWith('pty-1', false) + }) + it('does not let an eager pre-mount buffer defeat hidden delivery gating', async () => { const { registerEagerPtyBuffer } = await import('./pty-dispatcher') diff --git a/src/renderer/src/components/terminal-pane/pty-dispatcher.ts b/src/renderer/src/components/terminal-pane/pty-dispatcher.ts index 73f756c0857..84b3950d96b 100644 --- a/src/renderer/src/components/terminal-pane/pty-dispatcher.ts +++ b/src/renderer/src/components/terminal-pane/pty-dispatcher.ts @@ -13,7 +13,11 @@ import { drainPreHandlerPtyData, drainPreHandlerPtyExit } from './pty-pre-handler-buffer' -import { deliverPtyExitToHandlers } from './pty-exit-delivery' +import { + deliverPtyExitToHandlers, + registerPtyExitSidecar, + takePtyExitHandlers +} from './pty-exit-delivery' import { clearReceivedPtyCharTotal, isPtyPushDeliveryBlackholed, @@ -60,10 +64,6 @@ export type PtyDataMeta = { /** Sidecar PTY-data observers, invoked AFTER the primary handler so a side-effect-only watcher can't delay xterm rendering. */ /** Per-PTY replay handlers on a dedicated pty:replay channel so the renderer can engage the replay guard and suppress xterm auto-replies. */ -const ptyExitSidecars = new Map< - string, - Set<(code: number, context: { hadPrimary: boolean }) => void> ->() export const ptyWriteUnavailableHandlers = new Map void>() let ptyDispatcherAttached = false @@ -182,32 +182,7 @@ function attachPtySecondaryPushListeners(unsubscribes: (() => void)[]): void { ptyReplayHandlers.get(payload.id)?.(payload.data) }) ) - unsubscribes.push( - window.api.pty.onExit((payload) => { - if (payload.preserveRendererBinding === true) { - // Why: host-initiated remote sleep has no requester transaction in this renderer; classify its ordered exit before pane cleanup runs. - markCommittedPtyShutdowns([payload.id]) - } - // Why: main drops its accounting on exit; drop totals too so a reused id restarts at zero on both sides. - clearProcessedPtyCharTotal(payload.id) - clearReceivedPtyCharTotal(payload.id) - const sidecars = ptyExitSidecars.get(payload.id) - if (sidecars) { - ptyExitSidecars.delete(payload.id) - } - const primary = ptyExitHandlers.get(payload.id) - if (primary) { - // Why: one-shot owner — remove before invoking so a throwing callback can't stay registered for a duplicate exit. - ptyExitHandlers.delete(payload.id) - } - deliverPtyExitToHandlers({ - ptyId: payload.id, - code: payload.code, - ...(primary ? { primary } : {}), - sidecars: sidecars ? Array.from(sidecars) : [] - }) - }) - ) + unsubscribes.push(window.api.pty.onExit(dispatchPtyExit)) // Why: main probes on suspected lost ACKs; replying with processed totals lets it reconcile instead of resetting blindly. const unsubscribeResync = window.api.pty.onDeliveryResyncRequest?.((payload) => { window.api.pty.respondDeliveryResync?.({ @@ -222,27 +197,32 @@ function attachPtySecondaryPushListeners(unsubscribes: (() => void)[]): void { window.api.pty.rendererDispatcherReady?.() } +function dispatchPtyExit(payload: { + id: string + code: number + incarnationId?: string + preserveRendererBinding?: boolean +}): void { + const handlers = takePtyExitHandlers(payload.id) + if (payload.preserveRendererBinding === true) { + markCommittedPtyShutdowns([payload.id]) + } + clearProcessedPtyCharTotal(payload.id) + clearReceivedPtyCharTotal(payload.id) + deliverPtyExitToHandlers({ + ptyId: payload.id, + code: payload.code, + ...(payload.incarnationId ? { incarnationId: payload.incarnationId } : {}), + ...handlers + }) +} + export function subscribeToPtyExit( ptyId: string, - watcher: (code: number, context: { hadPrimary: boolean }) => void + watcher: (code: number, context: { hadPrimary: boolean; incarnationId?: string }) => void ): () => void { ensurePtyDispatcher() - let set = ptyExitSidecars.get(ptyId) - if (!set) { - set = new Set() - ptyExitSidecars.set(ptyId, set) - } - set.add(watcher) - return () => { - const current = ptyExitSidecars.get(ptyId) - if (!current) { - return - } - current.delete(watcher) - if (current.size === 0) { - ptyExitSidecars.delete(ptyId) - } - } + return registerPtyExitSidecar(ptyId, watcher) } // ─── Eager PTY buffer for reconnection on restart ──────────────────── diff --git a/src/renderer/src/components/terminal-pane/pty-exit-delivery.ts b/src/renderer/src/components/terminal-pane/pty-exit-delivery.ts index 9180aabf890..51d940d41a5 100644 --- a/src/renderer/src/components/terminal-pane/pty-exit-delivery.ts +++ b/src/renderer/src/components/terminal-pane/pty-exit-delivery.ts @@ -3,12 +3,116 @@ import { clearPreHandlerPtyState, consumePreHandlerPtyState } from './pty-pre-handler-buffer' +import { + isEventKeyedPtyExitHandler, + onPtyExitHandlerAvailable, + ptyExitHandlers, + type PtyExitHandler +} from './pty-shutdown-data-suspension' +import type { + TerminalAuthorityAppOutcomeIdentity, + TerminalAuthorityOutcomeProjectionKind +} from '@/lib/terminal-authority-app-projection-controller' -type PtyExitDelivery = { +export type PtyExitSidecar = ( + code: number, + context: { + hadPrimary: boolean + incarnationId?: string + authorityOutcome?: TerminalAuthorityAppOutcomeIdentity + } +) => unknown + +export type PtyExitHandlerSnapshot = Readonly<{ + primary?: PtyExitHandler + sidecars: readonly PtyExitSidecar[] +}> + +const ptyExitSidecars = new Map>() +const ptyExitSidecarAvailableListeners = new Set<() => void>() +const eventKeyedPtyExitSidecars = new WeakSet() + +export type PtyExitDelivery = { ptyId: string code: number - primary?: (code: number) => void - sidecars: readonly ((code: number, context: { hadPrimary: boolean }) => void)[] + incarnationId?: string + authorityOutcome?: TerminalAuthorityAppOutcomeIdentity + primary?: PtyExitHandler + sidecars: readonly PtyExitSidecar[] +} + +export function snapshotPtyExitHandlers(ptyId: string): PtyExitHandlerSnapshot { + return { + ...(ptyExitHandlers.get(ptyId) ? { primary: ptyExitHandlers.get(ptyId) } : {}), + sidecars: Array.from(ptyExitSidecars.get(ptyId) ?? []) + } +} + +export function takePtyExitHandlers(ptyId: string): PtyExitHandlerSnapshot { + const snapshot = snapshotPtyExitHandlers(ptyId) + ptyExitSidecars.delete(ptyId) + if (snapshot.primary && ptyExitHandlers.get(ptyId) === snapshot.primary) { + ptyExitHandlers.delete(ptyId) + } + return snapshot +} + +export function releasePtyExitHandlers(ptyId: string, snapshot: PtyExitHandlerSnapshot): void { + if (snapshot.primary && ptyExitHandlers.get(ptyId) === snapshot.primary) { + ptyExitHandlers.delete(ptyId) + } + const currentSidecars = ptyExitSidecars.get(ptyId) + if (!currentSidecars) { + return + } + for (const sidecar of snapshot.sidecars) { + currentSidecars.delete(sidecar) + } + if (currentSidecars.size === 0) { + ptyExitSidecars.delete(ptyId) + } +} + +export function isEventKeyedPtyExitSnapshot(snapshot: PtyExitHandlerSnapshot): boolean { + return ( + (!snapshot.primary || isEventKeyedPtyExitHandler(snapshot.primary)) && + snapshot.sidecars.every((sidecar) => eventKeyedPtyExitSidecars.has(sidecar)) + ) +} + +export function registerPtyExitSidecar( + ptyId: string, + sidecar: PtyExitSidecar, + projection?: TerminalAuthorityOutcomeProjectionKind +): () => void { + let sidecars = ptyExitSidecars.get(ptyId) + if (!sidecars) { + sidecars = new Set() + ptyExitSidecars.set(ptyId, sidecars) + } + if (projection === 'event-keyed-idempotent') { + eventKeyedPtyExitSidecars.add(sidecar) + } + sidecars.add(sidecar) + for (const listener of ptyExitSidecarAvailableListeners) { + listener() + } + return () => { + const current = ptyExitSidecars.get(ptyId) + current?.delete(sidecar) + if (current?.size === 0) { + ptyExitSidecars.delete(ptyId) + } + } +} + +export function onPtyExitPolicyAvailable(listener: () => void): () => void { + const stopPrimary = onPtyExitHandlerAvailable(listener) + ptyExitSidecarAvailableListeners.add(listener) + return () => { + stopPrimary() + ptyExitSidecarAvailableListeners.delete(listener) + } } /** Delivers one exit to its primary owner and every observational sidecar. */ @@ -19,14 +123,18 @@ export function deliverPtyExitToHandlers(delivery: PtyExitDelivery): void { if (delivery.primary) { clearPreHandlerPtyState(delivery.ptyId) try { - delivery.primary(delivery.code) + if (delivery.incarnationId) { + delivery.primary(delivery.code, delivery.incarnationId, delivery.authorityOutcome) + } else { + delivery.primary(delivery.code, undefined, delivery.authorityOutcome) + } } finally { // Why: ownership is final even when cleanup throws; a duplicate exit // must not become a new pre-handler event for a future mount. consumePreHandlerPtyState(delivery.ptyId) } } else { - bufferPreHandlerPtyExit(delivery.ptyId, delivery.code) + bufferPreHandlerPtyExit(delivery.ptyId, delivery.code, delivery.incarnationId) } } catch (error) { firstError = error @@ -35,7 +143,11 @@ export function deliverPtyExitToHandlers(delivery: PtyExitDelivery): void { for (const sidecar of delivery.sidecars) { try { - sidecar(delivery.code, { hadPrimary: delivery.primary !== undefined }) + sidecar(delivery.code, { + hadPrimary: delivery.primary !== undefined, + ...(delivery.incarnationId ? { incarnationId: delivery.incarnationId } : {}), + ...(delivery.authorityOutcome ? { authorityOutcome: delivery.authorityOutcome } : {}) + }) } catch (error) { if (!hasError) { firstError = error @@ -47,3 +159,31 @@ export function deliverPtyExitToHandlers(delivery: PtyExitDelivery): void { throw firstError } } + +export async function settleAuthoritativePtyExitHandlers(delivery: PtyExitDelivery): Promise { + let firstError: unknown + if (delivery.primary) { + clearPreHandlerPtyState(delivery.ptyId) + try { + await delivery.primary(delivery.code, delivery.incarnationId, delivery.authorityOutcome) + } catch (error) { + firstError = error + } finally { + consumePreHandlerPtyState(delivery.ptyId) + } + } + for (const sidecar of delivery.sidecars) { + try { + await sidecar(delivery.code, { + hadPrimary: delivery.primary !== undefined, + ...(delivery.incarnationId ? { incarnationId: delivery.incarnationId } : {}), + ...(delivery.authorityOutcome ? { authorityOutcome: delivery.authorityOutcome } : {}) + }) + } catch (error) { + firstError ??= error + } + } + if (firstError !== undefined) { + throw firstError + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts b/src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts index ddf856f05ae..051764fee60 100644 --- a/src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from 'vitest' import { TERMINAL_INPUT_COALESCE_MAX_CODE_UNITS, - createPtyInputWriteQueue + createPtyInputWriteQueue, + type PtyInputWriteQueue } from './pty-input-write-queue' import { TERMINAL_INPUT_CHUNK_MAX_BYTES, @@ -14,10 +15,10 @@ type WriteRecord = { id: string; data: string } function createRecordingQueue(options: { writable?: () => boolean } = {}): { writes: WriteRecord[] - queue: ReturnType + queue: PtyInputWriteQueue } { const writes: WriteRecord[] = [] - const queue = createPtyInputWriteQueue({ + const queue = createPtyInputWriteQueue({ isWritable: () => options.writable?.() ?? true, write: (id, data) => writes.push({ id, data }) }) @@ -57,7 +58,7 @@ describe('pty input write queue', () => { it('does not coalesce across different PTY ids', async () => { const writes: WriteRecord[] = [] - const queue = createPtyInputWriteQueue({ + const queue = createPtyInputWriteQueue({ isWritable: () => true, write: (id, data) => writes.push({ id, data }) }) @@ -132,7 +133,7 @@ describe('pty input write queue', () => { it('clear() drops pending input that has not been written yet', async () => { const writes: WriteRecord[] = [] const pendingYields: (() => void)[] = [] - const queue = createPtyInputWriteQueue({ + const queue = createPtyInputWriteQueue({ isWritable: () => true, write: (id, data) => writes.push({ id, data }), yieldBetweenWrites: () => diff --git a/src/renderer/src/components/terminal-pane/pty-input-write-queue.ts b/src/renderer/src/components/terminal-pane/pty-input-write-queue.ts index 11764c3b224..c9491074225 100644 --- a/src/renderer/src/components/terminal-pane/pty-input-write-queue.ts +++ b/src/renderer/src/components/terminal-pane/pty-input-write-queue.ts @@ -10,22 +10,23 @@ import { export const TERMINAL_INPUT_COALESCE_MAX_CODE_UNITS = 4096 type PendingPtyInputWrite = { - id: string + target: unknown text: string tooLarge: boolean | Promise chunks?: Iterator nextChunk?: string } -export type PtyInputWriteQueue = { - enqueue: (id: string, data: string) => boolean +export type PtyInputWriteQueue = { + enqueue: (target: Target, data: string) => boolean waitForDrain: () => Promise clear: () => void } -export type PtyInputWriteQueueDeps = { - isWritable: (id: string) => boolean - write: (id: string, data: string) => void +export type PtyInputWriteQueueDeps = { + isWritable: (target: Target) => boolean + write: (target: Target, data: string) => void + sameTarget?: (left: Target, right: Target) => boolean yieldBetweenWrites?: () => Promise } @@ -33,8 +34,11 @@ function isCoalescibleText(text: string): boolean { return text.length <= TERMINAL_INPUT_COALESCE_MAX_CODE_UNITS } -export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInputWriteQueue { +export function createPtyInputWriteQueue( + deps: PtyInputWriteQueueDeps +): PtyInputWriteQueue { const yieldBetweenWrites = deps.yieldBetweenWrites ?? yieldToEventLoop + const sameTarget = deps.sameTarget ?? Object.is let pending: PendingPtyInputWrite[] = [] let drainPromise: Promise | null = null @@ -45,7 +49,8 @@ export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInput pending.shift() continue } - if (!deps.isWritable(next.id)) { + const target = next.target as Target + if (!deps.isWritable(target)) { pending.shift() continue } @@ -55,7 +60,7 @@ export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInput pending.shift() continue } - if (!deps.isWritable(next.id)) { + if (!deps.isWritable(target)) { pending.shift() continue } @@ -74,7 +79,7 @@ export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInput const peek = pending[0] if ( !peek || - peek.id !== next.id || + !sameTarget(peek.target as Target, target) || peek.tooLarge !== false || peek.chunks !== undefined || !isCoalescibleText(peek.text) || @@ -85,7 +90,7 @@ export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInput payload += peek.text pending.shift() } - deps.write(next.id, payload) + deps.write(target, payload) if (pending.length > 0) { await yieldBetweenWrites() } @@ -99,7 +104,7 @@ export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInput pending.shift() continue } - deps.write(next.id, chunk.value) + deps.write(target, chunk.value) const following = next.chunks.next() if (following.done) { pending.shift() @@ -125,13 +130,13 @@ export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInput } return { - enqueue(id: string, data: string): boolean { + enqueue(target: Target, data: string): boolean { try { const tooLarge = isTerminalInputTooLargeWithDeferredMeasurement(data) if (tooLarge === true) { return false } - pending.push({ id, text: data, tooLarge }) + pending.push({ target, text: data, tooLarge }) scheduleDrain() return true } catch { diff --git a/src/renderer/src/components/terminal-pane/pty-mutation-access-claim.ts b/src/renderer/src/components/terminal-pane/pty-mutation-access-claim.ts new file mode 100644 index 00000000000..d4eb6fa4d64 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-mutation-access-claim.ts @@ -0,0 +1,90 @@ +import type { + PtyMutationAccess, + PtyMutationClaimant +} from '../../../../shared/pty-mutation-identity' +import { parsePtyMutationAccess } from '../../../../shared/pty-mutation-identity' + +type ClaimCallbacks = { + onClaimed: (access: PtyMutationAccess) => void + onUnavailable: (error: Error) => void +} + +export function normalizePtyMutationAccess( + value: unknown, + paneGeneration: number | undefined, + expectedClaimant?: PtyMutationClaimant +): PtyMutationAccess { + const parsed = parsePtyMutationAccess(value) + if ( + parsed?.mode === 'exact' && + ((paneGeneration !== undefined && parsed.identity.paneGeneration !== paneGeneration) || + (expectedClaimant !== undefined && + (parsed.claimant.rendererEpochId !== expectedClaimant.rendererEpochId || + parsed.claimant.sequence !== expectedClaimant.sequence))) + ) { + return { mode: 'unavailable' } + } + return parsed ?? { mode: 'unavailable' } +} + +export function createPtyMutationAccessClaim(options: { + tabId?: string + leafId?: string + paneGeneration?: number +}): { + start: (id: string, claimant: PtyMutationClaimant, callbacks: ClaimCallbacks) => void + cancel: () => void +} { + let epoch = 0 + + const canClaim = (): boolean => + Boolean(options.tabId && options.leafId && options.paneGeneration !== undefined) + + function cancel(): void { + epoch += 1 + } + + return { + start(id, claimant, callbacks): void { + cancel() + const claimEpoch = epoch + if (!canClaim()) { + callbacks.onUnavailable(new Error('pty_mutation_access_claim_identity_missing')) + return + } + try { + void window.api.pty + .claimMutationAccess({ + id, + tabId: options.tabId!, + leafId: options.leafId!, + paneGeneration: options.paneGeneration!, + claimant + }) + .then( + (value) => { + if (claimEpoch !== epoch) { + return + } + const access = normalizePtyMutationAccess(value, options.paneGeneration, claimant) + if (access.mode === 'unavailable') { + callbacks.onUnavailable(new Error('pty_mutation_access_unavailable')) + return + } + callbacks.onClaimed(access) + }, + () => { + if (claimEpoch === epoch) { + callbacks.onUnavailable(new Error('pty_mutation_access_unavailable')) + } + } + ) + } catch { + if (claimEpoch === epoch) { + callbacks.onUnavailable(new Error('pty_mutation_access_unavailable')) + } + } + }, + cancel + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-mutation-access-controller.test.ts b/src/renderer/src/components/terminal-pane/pty-mutation-access-controller.test.ts new file mode 100644 index 00000000000..a8b604de0ac --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-mutation-access-controller.test.ts @@ -0,0 +1,268 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { PtyMutationAccess } from '../../../../shared/pty-mutation-identity' +import { + createPtyMutationAccessController, + PTY_MUTATION_PENDING_OPERATION_LIMIT +} from './pty-mutation-access-controller' + +function deferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve!: (value: T) => void + const promise = new Promise((settle) => { + resolve = settle + }) + return { promise, resolve } +} + +describe('PTY mutation access controller', () => { + const originalWindow = globalThis.window + const api = { + claimMutationAccess: vi.fn(), + write: vi.fn(), + writeAccepted: vi.fn().mockResolvedValue(true), + resize: vi.fn(), + claimViewport: vi.fn(), + signal: vi.fn(), + clearBuffer: vi.fn(), + kill: vi.fn().mockResolvedValue(undefined) + } + + beforeEach(() => { + vi.clearAllMocks() + ;(globalThis as { window: Window }).window = { + setTimeout, + clearTimeout, + api: { ...originalWindow?.api, pty: api } + } as unknown as Window + }) + + afterEach(() => { + vi.useRealTimers() + if (originalWindow) { + ;(globalThis as { window: Window }).window = originalWindow + } else { + delete (globalThis as { window?: Window }).window + } + }) + + it('queues structural attach mutations but refuses input until exact access resolves', async () => { + const claim = deferred() + api.claimMutationAccess.mockReturnValueOnce(claim.promise) + const controller = createPtyMutationAccessController({ + tabId: 'terminal-tab-1', + leafId: 'leaf-1', + paneGeneration: 4 + }) + const identity = { + incarnationId: 'incarnation-1', + paneGeneration: 4, + mutationLeaseId: 'lease-1' + } + + controller.bind('pty-1') + expect(controller.canMutate('pty-1')).toBe(false) + expect(controller.write('pty-1', 'a')).toBe(false) + expect(controller.resize('pty-1', 120, 40, true)).toBe(true) + expect(controller.signal('pty-1', 'SIGWINCH')).toBe(true) + expect(controller.clearBuffer('pty-1')).toBe(true) + const accepted = controller.writeAccepted('pty-1', 'b') + const killed = controller.kill('pty-1', true) + + expect(api.write).not.toHaveBeenCalled() + expect(api.kill).not.toHaveBeenCalled() + const claimant = api.claimMutationAccess.mock.calls[0]?.[0].claimant + claim.resolve({ mode: 'exact', identity, claimant }) + await expect(accepted).resolves.toBe(false) + await expect(killed).resolves.toBeUndefined() + expect(controller.canMutate('pty-1')).toBe(true) + expect(controller.write('pty-1', 'after-claim')).toBe(true) + await expect(controller.writeAccepted('pty-1', 'acknowledged')).resolves.toBe(true) + + expect(api.claimMutationAccess).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'pty-1', + tabId: 'terminal-tab-1', + leafId: 'leaf-1', + paneGeneration: 4, + claimant + }) + ) + expect(api.write).toHaveBeenCalledExactlyOnceWith('pty-1', 'after-claim', identity) + expect(api.resize).toHaveBeenCalledWith('pty-1', 120, 40, identity) + expect(api.claimViewport).toHaveBeenCalledWith('pty-1', 120, 40, identity) + expect(api.signal).toHaveBeenCalledWith('pty-1', 'SIGWINCH', identity) + expect(api.clearBuffer).toHaveBeenCalledWith('pty-1', identity) + expect(api.writeAccepted).toHaveBeenCalledExactlyOnceWith('pty-1', 'acknowledged', identity) + expect(api.kill).toHaveBeenCalledWith('pty-1', { + keepHistory: true, + mutationIdentity: identity + }) + }) + + it('drops queued mutations when the attaching pane releases before its claim settles', async () => { + const claim = deferred() + api.claimMutationAccess.mockReturnValueOnce(claim.promise) + const controller = createPtyMutationAccessController({ + tabId: 'terminal-tab-1', + leafId: 'leaf-1', + paneGeneration: 1 + }) + + controller.bind('pty-reused') + const claimant = api.claimMutationAccess.mock.calls[0]?.[0].claimant + controller.write('pty-reused', 'stale') + controller.release() + claim.resolve({ + mode: 'exact', + identity: { + incarnationId: 'replacement-incarnation', + paneGeneration: 2, + mutationLeaseId: 'replacement-lease' + }, + claimant + }) + await Promise.resolve() + await Promise.resolve() + + expect(api.write).not.toHaveBeenCalled() + }) + + it('keeps explicitly negotiated legacy peers on the allocation-free mutation path', () => { + const controller = createPtyMutationAccessController({}) + controller.bind('legacy-pty', { mode: 'legacy' }) + + controller.write('legacy-pty', 'input') + controller.resize('legacy-pty', 80, 24) + + expect(api.claimMutationAccess).not.toHaveBeenCalled() + expect(api.write).toHaveBeenCalledWith('legacy-pty', 'input') + expect(api.resize).toHaveBeenCalledWith('legacy-pty', 80, 24) + }) + + it('defaults unmarked callers to the backward-compatible legacy path', () => { + const controller = createPtyMutationAccessController({}) + controller.bind('legacy-pty') + + controller.write('legacy-pty', 'input') + + expect(api.claimMutationAccess).not.toHaveBeenCalled() + expect(api.write).toHaveBeenCalledWith('legacy-pty', 'input') + }) + + it('keeps exact write overhead to one synchronous IPC send with one stable identity', () => { + const controller = createPtyMutationAccessController({ + tabId: 'terminal-tab-1', + leafId: 'leaf-1', + paneGeneration: 4 + }) + const claimant = controller.prepareBinding() + const identity = { + incarnationId: 'incarnation-1', + paneGeneration: 4, + mutationLeaseId: 'lease-1' + } + controller.bind('pty-1', { mode: 'exact', identity, claimant }, claimant) + const stableTarget = controller.captureTarget('pty-1') + + for (let index = 0; index < 10_000; index += 1) { + controller.write('pty-1', 'a') + } + + expect(api.claimMutationAccess).not.toHaveBeenCalled() + expect(api.write).toHaveBeenCalledTimes(10_000) + const dispatchedIdentity = api.write.mock.calls[0]?.[2] + expect(dispatchedIdentity).toEqual(identity) + expect(api.write.mock.calls.every((call) => call[2] === dispatchedIdentity)).toBe(true) + expect(controller.captureTarget('pty-1')).toBe(stableTarget) + }) + + it('keeps one claim pending beyond the former deadline without losing queued work', async () => { + vi.useFakeTimers() + api.claimMutationAccess.mockImplementation(() => new Promise(() => {})) + const onUnavailable = vi.fn() + const controller = createPtyMutationAccessController({ + tabId: 'terminal-tab-1', + leafId: 'leaf-1', + paneGeneration: 4, + onUnavailable + }) + + controller.bind('pty-1') + expect(controller.write('pty-1', 'unacknowledged')).toBe(false) + expect(controller.resize('pty-1', 100, 30)).toBe(true) + const accepted = controller.writeAccepted('pty-1', 'acknowledged') + const killed = controller.kill('pty-1').catch((error: unknown) => error) + await expect(accepted).resolves.toBe(false) + await vi.advanceTimersByTimeAsync(75_000) + + expect(api.write).not.toHaveBeenCalled() + expect(api.resize).not.toHaveBeenCalled() + expect(api.kill).not.toHaveBeenCalled() + expect(onUnavailable).not.toHaveBeenCalled() + expect(api.claimMutationAccess).toHaveBeenCalledOnce() + + controller.release() + await expect(killed).resolves.toMatchObject({ message: 'pty_mutation_access_released' }) + }) + + it('fails only after an explicit unavailable response and never retries it', async () => { + vi.useFakeTimers() + api.claimMutationAccess.mockResolvedValueOnce({ mode: 'unavailable' }) + const onUnavailable = vi.fn() + const controller = createPtyMutationAccessController({ + tabId: 'terminal-tab-1', + leafId: 'leaf-1', + paneGeneration: 4, + onUnavailable + }) + + controller.bind('pty-1') + const killed = controller.kill('pty-1').catch((error: unknown) => error) + await Promise.resolve() + await Promise.resolve() + + await expect(killed).resolves.toMatchObject({ message: 'pty_mutation_access_unavailable' }) + expect(onUnavailable).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ message: 'pty_mutation_access_unavailable' }) + ) + await vi.advanceTimersByTimeAsync(75_000) + expect(api.claimMutationAccess).toHaveBeenCalledOnce() + }) + + it('refuses only the overflowing operation and preserves accepted pending work', async () => { + const claim = deferred() + api.claimMutationAccess.mockReturnValueOnce(claim.promise) + const onUnavailable = vi.fn() + const controller = createPtyMutationAccessController({ + tabId: 'terminal-tab-1', + leafId: 'leaf-1', + paneGeneration: 4, + onUnavailable + }) + controller.bind('pty-1') + + for (let index = 0; index < PTY_MUTATION_PENDING_OPERATION_LIMIT; index += 1) { + expect(controller.resize('pty-1', 80 + (index % 2), 24)).toBe(true) + } + expect(controller.resize('pty-1', 120, 40)).toBe(false) + const accepted = controller.writeAccepted('pty-1', 'after-overflow') + + await expect(accepted).resolves.toBe(false) + expect(api.resize).not.toHaveBeenCalled() + expect(onUnavailable).not.toHaveBeenCalled() + expect(api.claimMutationAccess).toHaveBeenCalledOnce() + + const claimant = api.claimMutationAccess.mock.calls[0]?.[0].claimant + claim.resolve({ + mode: 'exact', + identity: { + incarnationId: 'incarnation-overflow', + paneGeneration: 4, + mutationLeaseId: 'lease-overflow' + }, + claimant + }) + await vi.waitFor(() => { + expect(api.resize).toHaveBeenCalledTimes(PTY_MUTATION_PENDING_OPERATION_LIMIT) + }) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-mutation-access-controller.ts b/src/renderer/src/components/terminal-pane/pty-mutation-access-controller.ts new file mode 100644 index 00000000000..3f1009b1677 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-mutation-access-controller.ts @@ -0,0 +1,295 @@ +import type { + PtyMutationAccess, + PtyMutationClaimant, + PtyMutationIdentity +} from '../../../../shared/pty-mutation-identity' +import { + createPtyMutationAccessClaim, + normalizePtyMutationAccess +} from './pty-mutation-access-claim' +import { killPtyWithMutationIdentity, type PendingPtyMutation } from './pty-mutation-operation' +import { mintPtyMutationClaimant } from './pty-mutation-claimant' +import type { PtyMutationBindingTarget } from './pty-mutation-binding-target' +import { dispatchPtyMutationOperation } from './pty-mutation-operation-dispatch' + +export const PTY_MUTATION_PENDING_OPERATION_LIMIT = 4_096 + +export type PtyMutationAccessController = ReturnType + +export function createPtyMutationAccessController(options: { + tabId?: string + leafId?: string + paneGeneration?: number + onUnavailable?: (error: Error) => void + onAccessAvailable?: (id: string) => void +}) { + let boundId: string | null = null + let bindingRevision = 0 + let bindingTarget: PtyMutationBindingTarget | null = null + let access: PtyMutationAccess = { mode: 'unavailable' } + let pending: PendingPtyMutation[] = [] + let pendingIndex = 0 + let flushing = false + let terminalError: Error | null = null + let preparedClaimant: PtyMutationClaimant | null = null + const accessClaim = createPtyMutationAccessClaim(options) + + const mutationIdentity = (): PtyMutationIdentity | undefined => + access.mode === 'exact' ? access.identity : undefined + const hasMutationAccess = (): boolean => access.mode !== 'unavailable' + const isCurrentBinding = (id: string, revision: number): boolean => + id === boundId && revision === bindingRevision && terminalError === null + const refreshBindingTarget = (): void => { + bindingTarget = + boundId && access.mode !== 'unavailable' + ? Object.freeze({ id: boundId, bindingRevision, access }) + : null + } + const captureTarget = (id: string): PtyMutationBindingTarget | null => + bindingTarget?.id === id && isCurrentBinding(id, bindingTarget.bindingRevision) + ? bindingTarget + : null + const isCurrentTarget = (target: PtyMutationBindingTarget): boolean => + isCurrentBinding(target.id, target.bindingRevision) && access.mode !== 'unavailable' + + const settleOperation = (operation: PendingPtyMutation, error: Error): void => { + if (operation.kind === 'write-accepted') { + operation.resolve(false) + } else if (operation.kind === 'kill') { + operation.reject(error) + } + } + + const settlePending = (error: Error): void => { + for (let index = pendingIndex; index < pending.length; index += 1) { + const operation = pending[index] + if (!operation) { + continue + } + if (operation.kind === 'write-accepted') { + operation.resolve(false) + } else if (operation.kind === 'kill') { + operation.reject(error) + } + } + pending = [] + pendingIndex = 0 + } + + const fail = (error: Error): void => { + if (terminalError) { + return + } + terminalError = error + bindingTarget = null + accessClaim.cancel() + settlePending(error) + options.onUnavailable?.(error) + } + + const dispatch = async (operation: PendingPtyMutation): Promise => { + if ( + !isCurrentBinding(operation.id, operation.bindingRevision) || + access.mode === 'unavailable' + ) { + if (operation.kind === 'write-accepted') { + operation.resolve(false) + } else if (operation.kind === 'kill') { + operation.reject(new Error('pty_mutation_access_released')) + } + return + } + await dispatchPtyMutationOperation(operation, access, () => + isCurrentBinding(operation.id, operation.bindingRevision) + ) + } + + const flush = async (): Promise => { + if (flushing || access.mode === 'unavailable' || terminalError) { + return + } + flushing = true + try { + while (pendingIndex < pending.length && hasMutationAccess() && !terminalError) { + const operation = pending[pendingIndex] + if (operation) { + pendingIndex += 1 + try { + await dispatch(operation) + } catch (error) { + fail(error instanceof Error ? error : new Error(String(error))) + } + } + } + } finally { + if (pendingIndex === pending.length) { + pending = [] + pendingIndex = 0 + } + flushing = false + } + } + + const enqueue = (operation: PendingPtyMutation): boolean => { + if (!isCurrentBinding(operation.id, operation.bindingRevision)) { + settleOperation(operation, new Error('pty_mutation_access_released')) + return false + } + if (terminalError) { + settleOperation(operation, terminalError) + return false + } + if (access.mode === 'unavailable' || flushing) { + if (pending.length - pendingIndex >= PTY_MUTATION_PENDING_OPERATION_LIMIT) { + const error = new Error('pty_mutation_access_queue_overflow') + settleOperation(operation, error) + return false + } + pending.push(operation) + return true + } + void dispatch(operation).catch((error) => + fail(error instanceof Error ? error : new Error(String(error))) + ) + return true + } + + return { + prepareBinding(): PtyMutationClaimant { + preparedClaimant = mintPtyMutationClaimant() + return preparedClaimant + }, + bind( + id: string, + initialAccess?: PtyMutationAccess, + suppliedClaimant?: PtyMutationClaimant + ): void { + bindingRevision += 1 + accessClaim.cancel() + settlePending(new Error('pty_mutation_access_released')) + boundId = id + const claimant = + options.paneGeneration !== undefined + ? (suppliedClaimant ?? preparedClaimant ?? mintPtyMutationClaimant()) + : undefined + preparedClaimant = null + access = normalizePtyMutationAccess( + initialAccess ?? + (options.paneGeneration === undefined ? { mode: 'legacy' } : { mode: 'unavailable' }), + options.paneGeneration, + claimant + ) + terminalError = null + refreshBindingTarget() + if (access.mode === 'unavailable') { + accessClaim.start(id, claimant!, { + onClaimed: (claimed) => { + if (boundId !== id || terminalError) { + return + } + access = claimed + refreshBindingTarget() + options.onAccessAvailable?.(id) + void flush() + }, + onUnavailable: fail + }) + } else { + options.onAccessAvailable?.(id) + } + }, + canMutate: (id) => id === boundId && hasMutationAccess() && !flushing && terminalError === null, + hasAccess: (id) => id === boundId && hasMutationAccess() && terminalError === null, + captureTarget, + isCurrentTarget, + currentIdentity: (id) => + id === boundId && access.mode === 'exact' ? access.identity : undefined, + isLegacyBinding: (id) => id === boundId && access.mode === 'legacy', + write(id, data): boolean { + const target = captureTarget(id) + if (target && !flushing) { + if (target.access.mode === 'exact') { + window.api.pty.write(id, data, target.access.identity) + } else { + window.api.pty.write(id, data) + } + return true + } + return false + }, + writeTarget(target: PtyMutationBindingTarget, data: string): boolean { + if (!isCurrentTarget(target) || flushing) { + return false + } + if (target.access.mode === 'exact') { + window.api.pty.write(target.id, data, target.access.identity) + } else { + window.api.pty.write(target.id, data) + } + return true + }, + writeAccepted: (id, data) => { + if (id !== boundId || !hasMutationAccess() || flushing || terminalError) { + return Promise.resolve(false) + } + return new Promise((resolve) => + enqueue({ kind: 'write-accepted', id, bindingRevision, data, resolve }) + ) + }, + writeAcceptedTarget: (target: PtyMutationBindingTarget, data: string) => { + if (!isCurrentTarget(target) || flushing) { + return Promise.resolve(false) + } + const request = + target.access.mode === 'exact' + ? window.api.pty.writeAccepted(target.id, data, target.access.identity) + : window.api.pty.writeAccepted(target.id, data) + return Promise.resolve(request) + .then((accepted) => accepted && isCurrentTarget(target)) + .catch(() => false) + }, + resize(id, cols, rows, claimViewport = false): boolean { + if (id === boundId && hasMutationAccess() && !flushing) { + const identity = mutationIdentity() + if (identity) { + window.api.pty.resize(id, cols, rows, identity) + } else { + window.api.pty.resize(id, cols, rows) + } + if (claimViewport) { + if (identity) { + window.api.pty.claimViewport(id, cols, rows, identity) + } else { + window.api.pty.claimViewport(id, cols, rows) + } + } + return true + } + return enqueue({ kind: 'resize', id, bindingRevision, cols, rows, claim: claimViewport }) + }, + claimViewport: (id, cols, rows) => + enqueue({ kind: 'claim-viewport', id, bindingRevision, cols, rows }), + signal: (id, signal) => enqueue({ kind: 'signal', id, bindingRevision, signal }), + clearBuffer: (id) => enqueue({ kind: 'clear', id, bindingRevision }), + kill: (id, keepHistory = false) => + id === boundId && hasMutationAccess() && !flushing && !terminalError + ? killPtyWithMutationIdentity( + id, + keepHistory, + access.mode === 'exact' ? access.identity : undefined + ) + : new Promise((resolve, reject) => + enqueue({ kind: 'kill', id, bindingRevision, keepHistory, resolve, reject }) + ), + release(): void { + bindingRevision += 1 + accessClaim.cancel() + boundId = null + bindingTarget = null + preparedClaimant = null + access = { mode: 'unavailable' } + terminalError = new Error('pty_mutation_access_released') + settlePending(terminalError) + } + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-mutation-binding-target.test.ts b/src/renderer/src/components/terminal-pane/pty-mutation-binding-target.test.ts new file mode 100644 index 00000000000..5e3594fc6b3 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-mutation-binding-target.test.ts @@ -0,0 +1,102 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { TERMINAL_INPUT_CHUNK_MAX_BYTES } from '../../../../shared/terminal-input' +import { createPtyInputWriteQueue } from './pty-input-write-queue' +import { createPtyMutationAccessController } from './pty-mutation-access-controller' + +function deferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve!: (value: T) => void + const promise = new Promise((settle) => { + resolve = settle + }) + return { promise, resolve } +} + +describe('PTY mutation binding targets', () => { + const originalWindow = globalThis.window + const api = { + claimMutationAccess: vi.fn(), + write: vi.fn(), + writeAccepted: vi.fn(), + resize: vi.fn(), + claimViewport: vi.fn(), + signal: vi.fn(), + clearBuffer: vi.fn(), + kill: vi.fn() + } + + beforeEach(() => { + vi.clearAllMocks() + ;(globalThis as { window: Window }).window = { + api: { ...originalWindow?.api, pty: api } + } as unknown as Window + }) + + afterEach(() => { + if (originalWindow) { + ;(globalThis as { window: Window }).window = originalWindow + } else { + delete (globalThis as { window?: Window }).window + } + }) + + it('does not let an acknowledged old-binding write continue into a same-ID successor', async () => { + const controller = createPtyMutationAccessController({ paneGeneration: 1 }) + const oldClaimant = controller.prepareBinding() + const oldIdentity = { + incarnationId: 'incarnation-old', + paneGeneration: 1, + mutationLeaseId: 'lease-old' + } + controller.bind('same-id', { mode: 'exact', identity: oldIdentity, claimant: oldClaimant }) + const oldTarget = controller.captureTarget('same-id')! + const oldWrite = deferred() + api.writeAccepted.mockReturnValueOnce(oldWrite.promise) + + const accepted = controller.writeAcceptedTarget(oldTarget, 'old-chunk') + const successorClaimant = controller.prepareBinding() + const successorIdentity = { + incarnationId: 'incarnation-successor', + paneGeneration: 1, + mutationLeaseId: 'lease-successor' + } + controller.bind('same-id', { + mode: 'exact', + identity: successorIdentity, + claimant: successorClaimant + }) + oldWrite.resolve(true) + + await expect(accepted).resolves.toBe(false) + expect(controller.writeTarget(oldTarget, 'stale-tail')).toBe(false) + expect(controller.writeTarget(controller.captureTarget('same-id')!, 'successor')).toBe(true) + expect(api.writeAccepted).toHaveBeenCalledWith('same-id', 'old-chunk', oldIdentity) + expect(api.write).toHaveBeenCalledExactlyOnceWith('same-id', 'successor', successorIdentity) + }) + + it('drops the remaining chunks when the queue target is superseded between yields', async () => { + type Target = { id: string; revision: number } + let currentRevision = 1 + const writes: { target: Target; data: string }[] = [] + const pendingYield = deferred() + const queue = createPtyInputWriteQueue({ + isWritable: (target) => target.revision === currentRevision, + write: (target, data) => writes.push({ target, data }), + sameTarget: (left, right) => left.id === right.id && left.revision === right.revision, + yieldBetweenWrites: () => pendingYield.promise + }) + const oldTarget = { id: 'same-id', revision: 1 } + const successorTarget = { id: 'same-id', revision: 2 } + + queue.enqueue(oldTarget, 'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES * 3)) + expect(writes).toHaveLength(1) + currentRevision = 2 + queue.enqueue(successorTarget, 'successor') + pendingYield.resolve() + await queue.waitForDrain() + + expect(writes).toEqual([ + { target: oldTarget, data: 'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES) }, + { target: successorTarget, data: 'successor' } + ]) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-mutation-binding-target.ts b/src/renderer/src/components/terminal-pane/pty-mutation-binding-target.ts new file mode 100644 index 00000000000..030e6a81f26 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-mutation-binding-target.ts @@ -0,0 +1,16 @@ +import type { PtyMutationAccess } from '../../../../shared/pty-mutation-identity' + +export type BoundPtyMutationAccess = Exclude + +export type PtyMutationBindingTarget = Readonly<{ + id: string + bindingRevision: number + access: BoundPtyMutationAccess +}> + +export function samePtyMutationBindingTarget( + left: PtyMutationBindingTarget, + right: PtyMutationBindingTarget +): boolean { + return left.id === right.id && left.bindingRevision === right.bindingRevision +} diff --git a/src/renderer/src/components/terminal-pane/pty-mutation-claimant.ts b/src/renderer/src/components/terminal-pane/pty-mutation-claimant.ts new file mode 100644 index 00000000000..b393642dd01 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-mutation-claimant.ts @@ -0,0 +1,9 @@ +import type { PtyMutationClaimant } from '../../../../shared/pty-mutation-identity' + +const rendererEpochId = globalThis.crypto.randomUUID() +let nextSequence = 0 + +export function mintPtyMutationClaimant(): PtyMutationClaimant { + nextSequence += 1 + return { rendererEpochId, sequence: nextSequence } +} diff --git a/src/renderer/src/components/terminal-pane/pty-mutation-operation-dispatch.ts b/src/renderer/src/components/terminal-pane/pty-mutation-operation-dispatch.ts new file mode 100644 index 00000000000..135541fc3e9 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-mutation-operation-dispatch.ts @@ -0,0 +1,62 @@ +import type { BoundPtyMutationAccess } from './pty-mutation-binding-target' +import { killPtyWithMutationIdentity, type PendingPtyMutation } from './pty-mutation-operation' + +export async function dispatchPtyMutationOperation( + operation: PendingPtyMutation, + access: BoundPtyMutationAccess, + isCurrent: () => boolean +): Promise { + const identity = access.mode === 'exact' ? access.identity : undefined + switch (operation.kind) { + case 'write-accepted': + operation.resolve( + (await ( + identity + ? window.api.pty.writeAccepted(operation.id, operation.data, identity) + : window.api.pty.writeAccepted(operation.id, operation.data) + ).catch(() => false)) && isCurrent() + ) + break + case 'resize': + if (identity) { + window.api.pty.resize(operation.id, operation.cols, operation.rows, identity) + } else { + window.api.pty.resize(operation.id, operation.cols, operation.rows) + } + if (operation.claim) { + if (identity) { + window.api.pty.claimViewport(operation.id, operation.cols, operation.rows, identity) + } else { + window.api.pty.claimViewport(operation.id, operation.cols, operation.rows) + } + } + break + case 'claim-viewport': + if (identity) { + window.api.pty.claimViewport(operation.id, operation.cols, operation.rows, identity) + } else { + window.api.pty.claimViewport(operation.id, operation.cols, operation.rows) + } + break + case 'signal': + if (identity) { + window.api.pty.signal(operation.id, operation.signal, identity) + } else { + window.api.pty.signal(operation.id, operation.signal) + } + break + case 'clear': + if (identity) { + window.api.pty.clearBuffer(operation.id, identity) + } else { + window.api.pty.clearBuffer(operation.id) + } + break + case 'kill': + await killPtyWithMutationIdentity(operation.id, operation.keepHistory, identity).then( + operation.resolve, + operation.reject + ) + break + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-mutation-operation.ts b/src/renderer/src/components/terminal-pane/pty-mutation-operation.ts new file mode 100644 index 00000000000..500f4a054eb --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-mutation-operation.ts @@ -0,0 +1,42 @@ +import type { PtyMutationIdentity } from '../../../../shared/pty-mutation-identity' + +export type PendingPtyMutation = + | { + kind: 'write-accepted' + id: string + bindingRevision: number + data: string + resolve: (accepted: boolean) => void + } + | { + kind: 'resize' + id: string + bindingRevision: number + cols: number + rows: number + claim: boolean + } + | { kind: 'claim-viewport'; id: string; bindingRevision: number; cols: number; rows: number } + | { kind: 'signal'; id: string; bindingRevision: number; signal: string } + | { kind: 'clear'; id: string; bindingRevision: number } + | { + kind: 'kill' + id: string + bindingRevision: number + keepHistory: boolean + resolve: () => void + reject: (error: unknown) => void + } + +export function killPtyWithMutationIdentity( + id: string, + keepHistory: boolean, + identity?: PtyMutationIdentity +): Promise { + if (identity) { + return Promise.resolve(window.api.pty.kill(id, { keepHistory, mutationIdentity: identity })) + } + return Promise.resolve( + keepHistory ? window.api.pty.kill(id, { keepHistory: true }) : window.api.pty.kill(id) + ) +} diff --git a/src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.test.ts b/src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.test.ts index 461e95edfed..6596bb025f4 100644 --- a/src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.test.ts @@ -125,6 +125,15 @@ describe('pre-handler PTY buffer', () => { expect(hasPreHandlerPtyExit(EXIT_PTY_ID)).toBe(false) }) + it('preserves exit incarnation identity through buffering', () => { + const onExit = vi.fn() + + bufferPreHandlerPtyExit(EXIT_PTY_ID, 7, 'incarnation-a') + drainPreHandlerPtyExit(EXIT_PTY_ID, onExit) + + expect(onExit).toHaveBeenCalledExactlyOnceWith(7, 'incarnation-a') + }) + it('discards delayed data and exit until an explicit reconnect', () => { discardPreHandlerPtyState(EXIT_PTY_ID) bufferPreHandlerPtyData(EXIT_PTY_ID, 'late data') diff --git a/src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.ts b/src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.ts index eca3b7644b9..565373e3ac7 100644 --- a/src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.ts +++ b/src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.ts @@ -13,8 +13,13 @@ type BufferedPreHandlerPtyState = { bytes: number } +export type BufferedPreHandlerPtyExit = { + code: number + incarnationId?: string +} + const preHandlerPtyData = new Map() -const preHandlerPtyExit = new Map() +const preHandlerPtyExit = new Map() const consumedPreHandlerPtyExits = new Map() const discardedPreHandlerPtyStates = new Map>() const DISCARDED_PRE_HANDLER_PTY_STATE_TTL_MS = 60_000 @@ -96,7 +101,7 @@ export function drainPreHandlerPtyData( } } -export function bufferPreHandlerPtyExit(ptyId: string, code: number): void { +export function bufferPreHandlerPtyExit(ptyId: string, code: number, incarnationId?: string): void { if (consumedPreHandlerPtyExits.has(ptyId) || discardedPreHandlerPtyStates.has(ptyId)) { return } @@ -106,7 +111,10 @@ export function bufferPreHandlerPtyExit(ptyId: string, code: number): void { preHandlerPtyExit.delete(oldestPtyId) } } - preHandlerPtyExit.set(ptyId, code) + preHandlerPtyExit.set(ptyId, { + code, + ...(incarnationId ? { incarnationId } : {}) + }) } // Why: primary handlers and pane-less parked owners have fully handled this @@ -161,14 +169,21 @@ export function hasPreHandlerPtyExit(ptyId: string): boolean { return preHandlerPtyExit.has(ptyId) } -export function drainPreHandlerPtyExit(ptyId: string, handler: (code: number) => void): void { - const code = preHandlerPtyExit.get(ptyId) - if (code === undefined) { +export function drainPreHandlerPtyExit( + ptyId: string, + handler: (code: number, incarnationId?: string) => void +): void { + const exit = preHandlerPtyExit.get(ptyId) + if (!exit) { return } preHandlerPtyExit.delete(ptyId) try { - handler(code) + if (exit.incarnationId) { + handler(exit.code, exit.incarnationId) + } else { + handler(exit.code) + } } finally { // Why: draining transfers ownership to this handler. Even when it throws, // a duplicate exit must not become a new pre-handler event. diff --git a/src/renderer/src/components/terminal-pane/pty-renderer-delivery-claims.test.ts b/src/renderer/src/components/terminal-pane/pty-renderer-delivery-claims.test.ts index 5da2177f1b2..e669b5343a9 100644 --- a/src/renderer/src/components/terminal-pane/pty-renderer-delivery-claims.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-renderer-delivery-claims.test.ts @@ -40,6 +40,19 @@ describe('renderer PTY delivery claims', () => { expect(setHiddenRendererPty).toHaveBeenCalledTimes(2) }) + it('does not retain a hidden claim when the first publication throws', () => { + setHiddenRendererPty.mockImplementationOnce(() => { + throw new Error('hidden publication failed') + }) + + expect(() => acquireHiddenRendererPtyDeliveryClaim(PTY_ID)).toThrow('hidden publication failed') + const release = acquireHiddenRendererPtyDeliveryClaim(PTY_ID) + expect(setHiddenRendererPty).toHaveBeenLastCalledWith(PTY_ID, true) + + release() + expect(setHiddenRendererPty).toHaveBeenLastCalledWith(PTY_ID, false) + }) + it('does not let a retiring visible pane hide its replacement', () => { const oldPane = {} const newPane = {} diff --git a/src/renderer/src/components/terminal-pane/pty-renderer-delivery-claims.ts b/src/renderer/src/components/terminal-pane/pty-renderer-delivery-claims.ts index 57644da8a83..55cc9281a55 100644 --- a/src/renderer/src/components/terminal-pane/pty-renderer-delivery-claims.ts +++ b/src/renderer/src/components/terminal-pane/pty-renderer-delivery-claims.ts @@ -24,11 +24,11 @@ function sendVisibility(ptyId: string, visible: boolean): void { * Main sees only the first-acquire/last-release transitions for each PTY. */ export function acquireHiddenRendererPtyDeliveryClaim(ptyId: string): () => void { - const nextCount = (hiddenClaimCounts.get(ptyId) ?? 0) + 1 - hiddenClaimCounts.set(ptyId, nextCount) - if (nextCount === 1) { + const currentCount = hiddenClaimCounts.get(ptyId) ?? 0 + if (currentCount === 0) { sendHiddenState(ptyId, true) } + hiddenClaimCounts.set(ptyId, currentCount + 1) let released = false return () => { diff --git a/src/renderer/src/components/terminal-pane/pty-renderer-handler-claim.ts b/src/renderer/src/components/terminal-pane/pty-renderer-handler-claim.ts new file mode 100644 index 00000000000..4a62b512010 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-renderer-handler-claim.ts @@ -0,0 +1,45 @@ +import type { PtyMutationClaimant } from '../../../../shared/pty-mutation-identity' +import { + comparePtyRendererBindingClaims, + exactPtyMutationAccessesEqual, + type ExactPtyMutationAccess +} from '../../../../shared/pty-renderer-binding' + +export type PtyRendererHandlerClaim = { + paneGeneration: number + claimant: PtyMutationClaimant + access?: ExactPtyMutationAccess +} + +const claimsByPty = new Map() + +export function claimPtyRendererHandlers(id: string, claim?: PtyRendererHandlerClaim): boolean { + if (!claim) { + return !claimsByPty.has(id) + } + const current = claimsByPty.get(id) + if (current) { + const comparison = comparePtyRendererBindingClaims(claim, current) + if (comparison < 0) { + return false + } + if ( + comparison === 0 && + current.access && + (!claim.access || !exactPtyMutationAccessesEqual(claim.access, current.access)) + ) { + return false + } + } + claimsByPty.set(id, claim) + return true +} + +export function releasePtyRendererHandlerClaim( + id: string, + claim: PtyRendererHandlerClaim | undefined +): void { + if (claim && claimsByPty.get(id) === claim) { + claimsByPty.delete(id) + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-shutdown-data-suspension.ts b/src/renderer/src/components/terminal-pane/pty-shutdown-data-suspension.ts index 0bc654bfe8e..19e908201cb 100644 --- a/src/renderer/src/components/terminal-pane/pty-shutdown-data-suspension.ts +++ b/src/renderer/src/components/terminal-pane/pty-shutdown-data-suspension.ts @@ -2,17 +2,63 @@ import { TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT } from '../../../../share import { clampUtf8Tail } from './pty-eager-buffer-clamp' import { clearPreHandlerPtyState, drainPreHandlerPtyData } from './pty-pre-handler-buffer' import type { PtyDataMeta } from './pty-dispatcher' +import type { + TerminalAuthorityAppOutcomeIdentity, + TerminalAuthorityOutcomeProjectionKind +} from '@/lib/terminal-authority-app-projection-controller' export const ptyDataHandlers = new Map void>() export const ptyDataSidecars = new Map void>>() export const ptyReplayHandlers = new Map void>() -export const ptyExitHandlers = new Map void>() +const ptyExitHandlerAvailableListeners = new Set<() => void>() +export type PtyExitHandler = ( + code: number, + incarnationId?: string, + authorityOutcome?: TerminalAuthorityAppOutcomeIdentity +) => unknown +class PtyExitHandlerMap extends Map { + override set(key: string, value: PtyExitHandler): this { + super.set(key, value) + for (const listener of ptyExitHandlerAvailableListeners) { + listener() + } + return this + } +} +export const ptyExitHandlers = new PtyExitHandlerMap() +const eventKeyedPtyExitHandlers = new WeakSet() export const ptyTeardownHandlers = new Map void>() export const ptyShutdownLifecycleHandlers = new Map< string, { pause: () => void; rollback: () => void; commit: () => void } >() +export function onPtyExitHandlerAvailable(listener: () => void): () => void { + ptyExitHandlerAvailableListeners.add(listener) + return () => ptyExitHandlerAvailableListeners.delete(listener) +} + +export function registerEventKeyedPtyExitHandler( + ptyId: string, + handler: PtyExitHandler, + projection: TerminalAuthorityOutcomeProjectionKind +): () => void { + if (projection !== 'event-keyed-idempotent') { + throw new Error('terminal_authority_outcome_projection_invalid') + } + eventKeyedPtyExitHandlers.add(handler) + ptyExitHandlers.set(ptyId, handler) + return () => { + if (ptyExitHandlers.get(ptyId) === handler) { + ptyExitHandlers.delete(ptyId) + } + } +} + +export function isEventKeyedPtyExitHandler(handler: PtyExitHandler): boolean { + return eventKeyedPtyExitHandlers.has(handler) +} + export type PtyDataHandlerShutdownSnapshot = { ptyId: string dataHandler?: (data: string, meta?: PtyDataMeta) => void diff --git a/src/renderer/src/components/terminal-pane/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport-types.ts index 8c82e7584a1..f7e82e2b9c2 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -13,6 +13,10 @@ import type { EventProps } from '../../../../shared/telemetry-events' import type { TerminalOscColorQueryReplyColors } from '../../../../shared/terminal-osc-color-reply' import type { TuiAgent } from '../../../../shared/types' import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { + PtyMutationAccess, + PtyMutationIdentity +} from '../../../../shared/pty-mutation-identity' import type { PtyDataMeta } from './pty-dispatcher' import type { RemoteRuntimeSnapshotOutcome } from '../../runtime/remote-runtime-terminal-multiplexer' @@ -46,8 +50,18 @@ export type LocalPtySessionMetadata = { shellOverride?: string } +export type PtyRendererBindingSettlement = Readonly<{ + id: string + paneGeneration: number + ready: () => void + cancel: () => void +}> + export type PtyConnectResult = { id: string + mutationAccess?: PtyMutationAccess + /** Local-only exact fence; release only after the bound xterm has consumed reattach state. */ + rendererBindingSettlement?: PtyRendererBindingSettlement /** The requested session exited while it had no primary pane handler. Its * buffered final data/exit were delivered, so callers must not fresh-spawn. */ exitedBeforeAttach?: boolean @@ -86,6 +100,7 @@ type PtyCallbacks = { onError?: (message: string, errors?: string[]) => void onExit?: (code: number) => void onWriteUnavailable?: () => void + onMutationAccessAvailable?: () => void onRecoveryStateChange?: (state: PtyTransportRecoveryState) => void onOutputPauseChanged?: (paused: boolean, supported: boolean) => void } @@ -104,6 +119,11 @@ export type PtyTransportRecoveryState = { attempt: number } +export type PtyTransportInputTarget = Readonly<{ + owner: object + binding: object +}> + export type PtyTransport = { connect: (options: { url: string @@ -136,6 +156,10 @@ export type PtyTransport = { }) => void disconnect: () => void sendInput: (data: string) => boolean + captureInputTarget?: () => PtyTransportInputTarget | null + isInputTargetCurrent?: (target: PtyTransportInputTarget) => boolean + sendInputToTarget?: (target: PtyTransportInputTarget, data: string) => boolean + sendInputAcceptedToTarget?: (target: PtyTransportInputTarget, data: string) => Promise // Why: latency-critical terminal query replies (CPR/DSR/DA/OSC color/pixel // size) must skip input coalescing — a querying program reads them in raw // mode with a short timeout, so a debounced reply lands on the shell prompt @@ -144,6 +168,8 @@ export type PtyTransport = { // (preserving order) and sends the reply immediately. sendInputImmediate: (data: string) => boolean sendInputAccepted?: (data: string) => Promise + signal?: (signal: string) => boolean + clearBuffer?: () => boolean claimViewport?: (cols: number, rows: number) => boolean /** Capability-negotiated paired-runtime delivery gate; false preserves legacy delivery. */ setOutputPaused?: (paused: boolean) => boolean @@ -165,6 +191,8 @@ export type PtyTransport = { /** The user dismissed the error surface; the next occurrence of the same message must surface again. */ notifyErrorSurfaceDismissed?: () => void getPtyId: () => string | null + getMutationIdentity?: () => PtyMutationIdentity | null + hasMutationAccess?: () => boolean getConnectionId?: () => string | null | undefined /** The runtime captured by this transport; legacy remote PTY ids do not * encode their owner, and current worktree settings may have changed. */ @@ -210,6 +238,7 @@ export type IpcPtyTransportOptions = { worktreeId?: string tabId?: string leafId?: string + paneGeneration?: number activate?: boolean shellOverride?: string projectRuntime?: ProjectExecutionRuntimeResolution diff --git a/src/renderer/src/components/terminal-pane/pty-transport.test.ts b/src/renderer/src/components/terminal-pane/pty-transport.test.ts index 72b340d2620..9fbd28fedca 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.test.ts @@ -14,13 +14,19 @@ import { TERMINAL_INPUT_MAX_BYTES } from '../../../../shared/terminal-input' import { CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS } from '../../../../shared/clipboard-text' +import type { PtyMutationAccess } from '../../../../shared/pty-mutation-identity' describe('createIpcPtyTransport', () => { const originalWindow = (globalThis as { window?: typeof window }).window let onData: ((payload: { id: string; data: string }) => void) | null = null let onReplay: ((payload: { id: string; data: string }) => void) | null = null let onExit: - | ((payload: { id: string; code: number; preserveRendererBinding?: boolean }) => void) + | ((payload: { + id: string + code: number + incarnationId?: string + preserveRendererBinding?: boolean + }) => void) | null = null let onWriteUnavailable: ((payload: { id: string }) => void) | null = null @@ -41,7 +47,10 @@ describe('createIpcPtyTransport', () => { ...originalWindow?.api, pty: { ...originalWindow?.api?.pty, - spawn: vi.fn().mockResolvedValue({ id: 'pty-1' }), + spawn: vi.fn().mockResolvedValue({ id: 'pty-1', mutationAccess: { mode: 'legacy' } }), + claimMutationAccess: vi.fn().mockResolvedValue({ mode: 'legacy' }), + rendererBindingReady: vi.fn(), + cancelRendererBinding: vi.fn(), write: vi.fn(), writeAccepted: vi.fn().mockResolvedValue(true), onWriteUnavailable: vi.fn((callback: (payload: { id: string }) => void) => { @@ -63,6 +72,7 @@ describe('createIpcPtyTransport', () => { callback: (payload: { id: string code: number + incarnationId?: string preserveRendererBinding?: boolean }) => void ) => { @@ -345,7 +355,11 @@ describe('createIpcPtyTransport', () => { expect(replayedToNewPane).toHaveBeenCalledWith('replay output') expect(receivedByOldPane).not.toHaveBeenCalled() - onExit?.({ id: 'pty-1', code: 0 }) + onExit?.({ + id: 'pty-1', + code: 0, + incarnationId: '11111111-1111-4111-8111-111111111111' + }) expect(exitSeenByNewPane).toHaveBeenCalledWith(0) }) @@ -387,6 +401,226 @@ describe('createIpcPtyTransport', () => { expect(window.api.pty.kill).not.toHaveBeenCalled() }) + it('keeps the newer exact claimant when overlapping reconnects resolve out of order', async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + const spawn = window.api.pty.spawn as unknown as ReturnType + const ready = window.api.pty.rendererBindingReady as unknown as ReturnType + const cancel = window.api.pty.cancelRendererBinding as unknown as ReturnType + let resolveStale!: (value: unknown) => void + let resolveCurrent!: (value: unknown) => void + spawn + .mockReturnValueOnce(new Promise((resolve) => (resolveStale = resolve))) + .mockReturnValueOnce(new Promise((resolve) => (resolveCurrent = resolve))) + const createPane = () => + createIpcPtyTransport({ + tabId: 'tab-1', + leafId: '11111111-1111-4111-8111-111111111111', + paneGeneration: 7 + }) + const staleData = vi.fn() + const stalePane = createPane() + const staleConnect = stalePane.connect({ + url: '', + sessionId: 'pty-1', + callbacks: { onData: staleData } + }) + const staleClaimant = spawn.mock.calls[0]?.[0].mutationClaimant + const currentData = vi.fn() + const currentPane = createPane() + const currentConnect = currentPane.connect({ + url: '', + sessionId: 'pty-1', + callbacks: { onData: currentData } + }) + const currentClaimant = spawn.mock.calls[1]?.[0].mutationClaimant + const exactAccess = (claimant: unknown, mutationLeaseId: string) => ({ + mode: 'exact' as const, + identity: { + incarnationId: '11111111-1111-4111-8111-111111111111', + paneGeneration: 7, + mutationLeaseId + }, + claimant + }) + const currentAccess = exactAccess(currentClaimant, 'lease-current') + resolveCurrent({ id: 'pty-1', isReattach: true, mutationAccess: currentAccess }) + const currentResult = await currentConnect + expect(ready).not.toHaveBeenCalled() + const staleAccess = exactAccess(staleClaimant, 'lease-stale') + resolveStale({ id: 'pty-1', isReattach: true, mutationAccess: staleAccess }) + await staleConnect + if (!currentResult || typeof currentResult !== 'object' || !('id' in currentResult)) { + throw new Error('Expected an exact reattach result') + } + currentResult.rendererBindingSettlement?.ready() + + onData?.({ id: 'pty-1', data: 'recovered once' }) + + expect(currentData).toHaveBeenCalledExactlyOnceWith('recovered once') + expect(staleData).not.toHaveBeenCalled() + expect(ready).toHaveBeenCalledExactlyOnceWith({ id: 'pty-1', access: currentAccess }) + expect(cancel).toHaveBeenCalledWith({ + id: 'pty-1', + paneGeneration: 7, + claimant: staleClaimant, + access: staleAccess + }) + }) + + it('prevents a late stale attach from stealing the surviving exact handler', async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + const spawn = window.api.pty.spawn as unknown as ReturnType + let resolveCurrent!: (value: unknown) => void + spawn.mockReturnValueOnce(new Promise((resolve) => (resolveCurrent = resolve))) + const currentData = vi.fn() + const currentExit = vi.fn() + const currentPane = createIpcPtyTransport({ + tabId: 'tab-1', + leafId: '11111111-1111-4111-8111-111111111111', + paneGeneration: 3 + }) + const currentConnect = currentPane.connect({ + url: '', + sessionId: 'pty-1', + callbacks: { onData: currentData, onExit: currentExit } + }) + const currentClaimant = spawn.mock.calls[0]?.[0].mutationClaimant + const currentAccess = { + mode: 'exact' as const, + identity: { + incarnationId: '11111111-1111-4111-8111-111111111111', + paneGeneration: 3, + mutationLeaseId: 'lease-current' + }, + claimant: currentClaimant + } + resolveCurrent({ id: 'pty-1', isReattach: true, mutationAccess: currentAccess }) + const currentResult = await currentConnect + if (!currentResult || typeof currentResult !== 'object' || !('id' in currentResult)) { + throw new Error('Expected an exact reattach result') + } + currentResult.rendererBindingSettlement?.ready() + + const staleData = vi.fn() + const staleExit = vi.fn() + const stalePane = createIpcPtyTransport({ + tabId: 'tab-1', + leafId: '11111111-1111-4111-8111-111111111111', + paneGeneration: 2 + }) + stalePane.attach({ + existingPtyId: 'pty-1', + callbacks: { onData: staleData, onExit: staleExit } + }) + + onData?.({ id: 'pty-1', data: 'recovered once' }) + onExit?.({ + id: 'pty-1', + code: 0, + incarnationId: currentAccess.identity.incarnationId + }) + + expect(currentData).toHaveBeenCalledExactlyOnceWith('recovered once') + expect(currentExit).toHaveBeenCalledExactlyOnceWith(0) + expect(staleData).not.toHaveBeenCalled() + expect(staleExit).not.toHaveBeenCalled() + expect(stalePane.getPtyId()).toBeNull() + }) + + it('keeps one transport bound when its stale reconnect resolves after the current reconnect', async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + const spawn = window.api.pty.spawn as unknown as ReturnType + const ready = window.api.pty.rendererBindingReady as unknown as ReturnType + let resolveStale!: (value: unknown) => void + let resolveCurrent!: (value: unknown) => void + spawn + .mockReturnValueOnce(new Promise((resolve) => (resolveStale = resolve))) + .mockReturnValueOnce(new Promise((resolve) => (resolveCurrent = resolve))) + const transport = createIpcPtyTransport({ + tabId: 'tab-1', + leafId: '11111111-1111-4111-8111-111111111111', + paneGeneration: 7 + }) + const staleData = vi.fn() + const staleConnect = transport.connect({ + url: '', + sessionId: 'pty-1', + callbacks: { onData: staleData } + }) + const staleClaimant = spawn.mock.calls[0]?.[0].mutationClaimant + const currentData = vi.fn() + const currentConnect = transport.connect({ + url: '', + sessionId: 'pty-1', + callbacks: { onData: currentData } + }) + const currentClaimant = spawn.mock.calls[1]?.[0].mutationClaimant + const exactAccess = (claimant: unknown, mutationLeaseId: string) => ({ + mode: 'exact' as const, + identity: { + incarnationId: '11111111-1111-4111-8111-111111111111', + paneGeneration: 7, + mutationLeaseId + }, + claimant + }) + const currentAccess = exactAccess(currentClaimant, 'lease-current') + resolveCurrent({ id: 'pty-1', isReattach: true, mutationAccess: currentAccess }) + const currentResult = await currentConnect + const staleAccess = exactAccess(staleClaimant, 'lease-stale') + resolveStale({ id: 'pty-1', isReattach: true, mutationAccess: staleAccess }) + await staleConnect + if (!currentResult || typeof currentResult !== 'object' || !('id' in currentResult)) { + throw new Error('Expected an exact reattach result') + } + currentResult.rendererBindingSettlement?.ready() + + onData?.({ id: 'pty-1', data: 'recovered once' }) + + expect(transport.getPtyId()).toBe('pty-1') + expect(transport.isConnected()).toBe(true) + expect(currentData).toHaveBeenCalledExactlyOnceWith('recovered once') + expect(staleData).not.toHaveBeenCalled() + expect(ready).toHaveBeenCalledExactlyOnceWith({ id: 'pty-1', access: currentAccess }) + }) + + it('cancels an unsettled exact renderer binding when its transport detaches', async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + const spawn = window.api.pty.spawn as unknown as ReturnType + const ready = window.api.pty.rendererBindingReady as unknown as ReturnType + const cancel = window.api.pty.cancelRendererBinding as unknown as ReturnType + let resolveSpawn!: (value: unknown) => void + spawn.mockReturnValueOnce(new Promise((resolve) => (resolveSpawn = resolve))) + const transport = createIpcPtyTransport({ + tabId: 'tab-1', + leafId: '11111111-1111-4111-8111-111111111111', + paneGeneration: 9 + }) + const pendingConnect = transport.connect({ url: '', sessionId: 'pty-1', callbacks: {} }) + const claimant = spawn.mock.calls[0]?.[0].mutationClaimant + const access = { + mode: 'exact' as const, + identity: { + incarnationId: '11111111-1111-4111-8111-111111111111', + paneGeneration: 9, + mutationLeaseId: 'lease-detached' + }, + claimant + } + resolveSpawn({ id: 'pty-1', isReattach: true, mutationAccess: access }) + await pendingConnect + + transport.detach?.() + + expect(ready).not.toHaveBeenCalled() + expect(cancel).toHaveBeenCalledExactlyOnceWith({ + id: 'pty-1', + paneGeneration: 9, + claimant, + access + }) + }) + it('retires a rejected fresh fallback before it can publish PTY handlers', async () => { const { createIpcPtyTransport } = await import('./pty-transport') const spawn = window.api.pty.spawn as unknown as ReturnType @@ -638,6 +872,94 @@ describe('createIpcPtyTransport', () => { expect(transport.isConnected()).toBe(false) }) + it('keeps a reused exact incarnation live when the buffered exit belongs to its predecessor', async () => { + const { bufferPreHandlerPtyExit } = await import('./pty-pre-handler-buffer') + const { createIpcPtyTransport } = await import('./pty-transport') + const spawn = window.api.pty.spawn as unknown as ReturnType + const write = window.api.pty.write as unknown as ReturnType + const sessionId = 'reused-exact-session' + const currentIdentity = { + incarnationId: 'incarnation-b', + paneGeneration: 11, + mutationLeaseId: 'lease-b' + } + bufferPreHandlerPtyExit(sessionId, 17, 'incarnation-a') + spawn.mockImplementationOnce(async (args: { mutationClaimant?: unknown }) => ({ + id: sessionId, + isReattach: true, + mutationAccess: { + mode: 'exact', + identity: currentIdentity, + claimant: args.mutationClaimant + } + })) + const onExitCallback = vi.fn() + const transport = createIpcPtyTransport({ + tabId: 'tab-exact', + leafId: '11111111-1111-4111-8111-111111111111', + paneGeneration: 11 + }) + + const result = await transport.connect({ + url: '', + sessionId, + callbacks: { onExit: onExitCallback } + }) + + expect(result).toEqual(expect.objectContaining({ id: sessionId, isReattach: true })) + expect(onExitCallback).not.toHaveBeenCalled() + expect(transport.isConnected()).toBe(true) + expect(transport.sendInput('still live')).toBe(true) + expect(write).toHaveBeenCalledWith(sessionId, 'still live', currentIdentity) + + onExit?.({ id: sessionId, code: 0, incarnationId: 'incarnation-b' }) + expect(onExitCallback).toHaveBeenCalledExactlyOnceWith(0) + expect(transport.isConnected()).toBe(false) + }) + + it('delivers a matching buffered exact exit once', async () => { + const { bufferPreHandlerPtyExit, drainPreHandlerPtyExit } = + await import('./pty-pre-handler-buffer') + const { createIpcPtyTransport } = await import('./pty-transport') + const spawn = window.api.pty.spawn as unknown as ReturnType + const sessionId = 'matching-exact-session' + bufferPreHandlerPtyExit(sessionId, 23, 'incarnation-current') + spawn.mockImplementationOnce(async (args: { mutationClaimant?: unknown }) => ({ + id: sessionId, + isReattach: true, + mutationAccess: { + mode: 'exact', + identity: { + incarnationId: 'incarnation-current', + paneGeneration: 12, + mutationLeaseId: 'lease-current' + }, + claimant: args.mutationClaimant + } + })) + const onExitCallback = vi.fn() + const transport = createIpcPtyTransport({ + tabId: 'tab-exact', + leafId: '11111111-1111-4111-8111-111111111111', + paneGeneration: 12 + }) + + await expect( + transport.connect({ + url: '', + sessionId, + callbacks: { onExit: onExitCallback } + }) + ).resolves.toEqual({ id: sessionId, exitedBeforeAttach: true }) + expect(onExitCallback).toHaveBeenCalledExactlyOnceWith(23) + + onExit?.({ id: sessionId, code: 23, incarnationId: 'incarnation-current' }) + const duplicateExit = vi.fn() + drainPreHandlerPtyExit(sessionId, duplicateExit) + expect(onExitCallback).toHaveBeenCalledTimes(1) + expect(duplicateExit).not.toHaveBeenCalled() + }) + it('rejects a buffered dead-session exit before publishing its final frame', async () => { const { bufferPreHandlerPtyData, bufferPreHandlerPtyExit, clearPreHandlerPtyState } = await import('./pty-pre-handler-buffer') @@ -1109,6 +1431,50 @@ describe('createIpcPtyTransport', () => { expect(sshTransport.sendInputAccepted).toBeUndefined() }) + it('refuses attach input until the exact mutation claim is authoritative', async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + let resolveClaim!: (value: PtyMutationAccess | PromiseLike) => void + vi.mocked(window.api.pty.claimMutationAccess).mockReturnValueOnce( + new Promise((resolve) => { + resolveClaim = resolve + }) + ) + const onMutationAccessAvailable = vi.fn() + const transport = createIpcPtyTransport({ + tabId: 'terminal-tab-1', + leafId: '11111111-1111-4111-8111-111111111111', + paneGeneration: 4 + }) + + transport.attach({ + existingPtyId: 'pending-access-pty', + callbacks: { onMutationAccessAvailable } + }) + + expect(transport.hasMutationAccess?.()).toBe(false) + expect(transport.sendInput('must-not-buffer')).toBe(false) + await expect(transport.sendInputAccepted?.('must-not-buffer')).resolves.toBe(false) + expect(window.api.pty.write).not.toHaveBeenCalled() + const claimant = vi.mocked(window.api.pty.claimMutationAccess).mock.calls[0]?.[0].claimant + const identity = { + incarnationId: 'incarnation-attach', + paneGeneration: 4, + mutationLeaseId: 'lease-attach' + } + resolveClaim({ mode: 'exact', identity, claimant }) + await Promise.resolve() + await Promise.resolve() + + expect(transport.hasMutationAccess?.()).toBe(true) + expect(onMutationAccessAvailable).toHaveBeenCalledOnce() + expect(transport.sendInput('accepted-after-claim')).toBe(true) + expect(window.api.pty.write).toHaveBeenCalledExactlyOnceWith( + 'pending-access-pty', + 'accepted-after-claim', + identity + ) + }) + it('chunks large local IPC terminal input before renderer-to-main writes', async () => { vi.useFakeTimers() try { diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index 95528b43070..6b82323a253 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -31,8 +31,25 @@ import { isPreHandlerPtyStateDiscarded } from './pty-pre-handler-buffer' import { createPtyInputWriteQueue } from './pty-input-write-queue' +import { killPtyWithMutationIdentity } from './pty-mutation-operation' +import { createPtyMutationAccessController } from './pty-mutation-access-controller' +import { + samePtyMutationBindingTarget, + type PtyMutationBindingTarget +} from './pty-mutation-binding-target' +import { normalizePtyMutationAccess } from './pty-mutation-access-claim' +import { + claimPtyRendererHandlers, + releasePtyRendererHandlerClaim, + type PtyRendererHandlerClaim +} from './pty-renderer-handler-claim' import type { PtyDataMeta } from './pty-dispatcher' -import type { IpcPtyTransportOptions, PtyConnectResult, PtyTransport } from './pty-transport-types' +import type { + IpcPtyTransportOptions, + PtyConnectResult, + PtyTransport, + PtyTransportInputTarget +} from './pty-transport-types' import { createBellDetector } from '../../../../shared/terminal-bell-detector' import { hasTerminalDisplayContent, @@ -64,7 +81,8 @@ export type { LocalPtySessionMetadata, PtyBufferSnapshot, PtyConnectResult, - PtyTransport + PtyTransport, + PtyTransportInputTarget } from './pty-transport-types' export { extractLastOscTitle } from '../../../../shared/agent-detection' @@ -539,6 +557,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra worktreeId, tabId, leafId, + paneGeneration, shellOverride, projectRuntime, terminalColorQueryReplies, @@ -555,12 +574,50 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra let connected = false let destroyed = false let ptyId: string | null = null + let connectionAttemptGeneration = 0 + let pendingIdentityExits: { + id: string + code: number + incarnationId?: string + handler: (code: number, incarnationId?: string) => void + }[] = [] + const inputTargetOwner = Object.freeze({}) + let storedCallbacks: Parameters[0]['callbacks'] = {} + const mutationAccess = createPtyMutationAccessController({ + tabId, + leafId, + paneGeneration, + onUnavailable: () => storedCallbacks.onWriteUnavailable?.(), + onAccessAvailable: (id) => { + deliverPendingIdentityExits(id) + if (connected && ptyId === id) { + storedCallbacks.onMutationAccessAvailable?.() + } + } + }) // Why: replayed eager-buffer data (often from a prior app session) must not fire fresh bells, unread marks, or notifications on reconnect. let suppressAttentionEvents = false - const inputWriteQueue = createPtyInputWriteQueue({ - isWritable: (id) => connected && ptyId === id, - write: (id, data) => window.api.pty.write(id, data) + const inputWriteQueue = createPtyInputWriteQueue({ + isWritable: (target) => + connected && ptyId === target.id && mutationAccess.isCurrentTarget(target), + write: (target, data) => mutationAccess.writeTarget(target, data), + sameTarget: samePtyMutationBindingTarget }) + const captureInputTarget = (): PtyTransportInputTarget | null => { + const binding = ptyId ? mutationAccess.captureTarget(ptyId) : null + return connected && binding ? { owner: inputTargetOwner, binding } : null + } + const inputBindingForTarget = ( + target: PtyTransportInputTarget + ): PtyMutationBindingTarget | null => { + if (target.owner !== inputTargetOwner) { + return null + } + const binding = target.binding as PtyMutationBindingTarget + return connected && ptyId === binding.id && mutationAccess.isCurrentTarget(binding) + ? binding + : null + } const outputProcessor = createPtyOutputProcessor({ onTitleChange, onBell, @@ -573,8 +630,6 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra onAgentExited, onAgentStatus }) - let storedCallbacks: Parameters[0]['callbacks'] = {} - // Why: a new pane can attach to the same ptyId before the old instance's detach() runs; track owned handlers so unregister never deletes the live one. const ownedDataAndReplayHandlers = new Map< string, @@ -582,9 +637,34 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra data: (data: string, meta?: PtyDataMeta) => void replay: (data: string) => void writeUnavailable: () => void + claim?: PtyRendererHandlerClaim } >() - const ownedExitHandlers = new Map void>() + const ownedExitHandlers = new Map void>() + const pendingRendererBindingCancellations = new Map void>>() + + function trackPendingRendererBinding(id: string, cancel: () => void): void { + const pending = pendingRendererBindingCancellations.get(id) ?? new Set() + pending.add(cancel) + pendingRendererBindingCancellations.set(id, pending) + } + + function untrackPendingRendererBinding(id: string, cancel: () => void): void { + const pending = pendingRendererBindingCancellations.get(id) + pending?.delete(cancel) + if (pending?.size === 0) { + pendingRendererBindingCancellations.delete(id) + } + } + + function cancelPendingRendererBindings(id?: string): void { + const pending = id + ? [...(pendingRendererBindingCancellations.get(id) ?? [])] + : [...pendingRendererBindingCancellations.values()].flatMap((entries) => [...entries]) + for (const cancel of pending) { + cancel() + } + } function unregisterPtyHandlers(id: string): void { unregisterPtyDataAndStatusHandlers(id) @@ -593,6 +673,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra ptyExitHandlers.delete(id) } ownedExitHandlers.delete(id) + pendingIdentityExits = pendingIdentityExits.filter((exit) => exit.id !== id) if (ptyTeardownHandlers.get(id) === clearAccumulatedState) { ptyTeardownHandlers.delete(id) } @@ -614,10 +695,11 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra ptyWriteUnavailableHandlers.delete(id) } } + releasePtyRendererHandlerClaim(id, owned?.claim) ownedDataAndReplayHandlers.delete(id) } - function registerPtyDataHandler(id: string): void { + function registerPtyDataHandler(id: string, rendererClaim?: PtyRendererHandlerClaim): boolean { // Why: route relay replay data through onReplayData so the replay guard stops xterm auto-replies from leaking into the shell. const replayHandler = (data: string): void => { if (ptyId !== id) { @@ -629,7 +711,6 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra storedCallbacks.onData?.(data) } } - ptyReplayHandlers.set(id, replayHandler) const dataHandler = (data: string, meta?: PtyDataMeta): void => { if (ptyId !== id) { return @@ -643,6 +724,10 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra meta ) } + if (!claimPtyRendererHandlers(id, rendererClaim)) { + return false + } + ptyReplayHandlers.set(id, replayHandler) ptyDataHandlers.set(id, dataHandler) // Guard like the data/replay handlers: a transport that rebinds to a new id without // detaching leaves this entry behind, and a fan-out for the stale id would otherwise @@ -656,12 +741,14 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra ownedDataAndReplayHandlers.set(id, { data: dataHandler, replay: replayHandler, - writeUnavailable + writeUnavailable, + ...(rendererClaim ? { claim: rendererClaim } : {}) }) if (!isPtyDataHandlerShutdownPending(id)) { drainPreHandlerPtyData(id, dataHandler) drainRolledBackPtyShutdownData(id) } + return true } function clearAccumulatedState(): void { @@ -678,7 +765,10 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra return new Promise((resolve) => setTimeout(resolve, 0)) } - async function writeAcceptedPtyInput(id: string, data: string): Promise { + async function writeAcceptedPtyInput( + target: PtyMutationBindingTarget, + data: string + ): Promise { try { const tooLarge = isTerminalInputTooLargeWithDeferredMeasurement(data) if (typeof tooLarge === 'boolean' ? tooLarge : await tooLarge) { @@ -687,10 +777,10 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra const chunks = iterateTerminalInputChunks(data) let chunk = chunks.next() while (!chunk.done) { - if (!connected || ptyId !== id) { + if (!connected || ptyId !== target.id || !mutationAccess.isCurrentTarget(target)) { return false } - const accepted = await window.api.pty.writeAccepted(id, chunk.value) + const accepted = await mutationAccess.writeAcceptedTarget(target, chunk.value) if (!accepted) { return false } @@ -705,17 +795,66 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra } } + function deliverPendingIdentityExits(id: string): void { + const pending = pendingIdentityExits.filter((exit) => exit.id === id) + pendingIdentityExits = pendingIdentityExits.filter((exit) => exit.id !== id) + for (const exit of pending) { + if ( + ptyId !== id || + ownedExitHandlers.get(id) !== exit.handler || + ptyExitHandlers.get(id) !== exit.handler + ) { + continue + } + exit.handler(exit.code, exit.incarnationId) + if (ptyId !== id) { + return + } + } + } + function registerPtyExitHandler(id: string): boolean { const hadBufferedExit = hasPreHandlerPtyExit(id) - const exitHandler = (code: number): void => { + let exitDelivered = false + let expectedIncarnationId = mutationAccess.currentIdentity(id)?.incarnationId + let acceptsLegacyExit = mutationAccess.isLegacyBinding(id) + const exitHandler = (code: number, incarnationId?: string): void => { if (ptyId !== null && ptyId !== id) { // Why: a preserved sleep/reconnect session can report its old exit after this transport already rebound to a replacement PTY. unregisterPtyHandlers(id) return } + if (!expectedIncarnationId && !acceptsLegacyExit) { + expectedIncarnationId = mutationAccess.currentIdentity(id)?.incarnationId + acceptsLegacyExit = mutationAccess.isLegacyBinding(id) + } + if (expectedIncarnationId) { + if (!incarnationId || incarnationId !== expectedIncarnationId) { + if (ownedExitHandlers.get(id) === exitHandler && ptyId === id) { + ptyExitHandlers.set(id, exitHandler) + } + return + } + } else if (!acceptsLegacyExit) { + if (pendingIdentityExits.length < 8) { + pendingIdentityExits.push({ + id, + code, + ...(incarnationId ? { incarnationId } : {}), + handler: exitHandler + }) + } + if (ownedExitHandlers.get(id) === exitHandler && ptyId === id) { + ptyExitHandlers.set(id, exitHandler) + } + return + } + exitDelivered = true + cancelPendingRendererBindings(id) clearAccumulatedState() connected = false ptyId = null + mutationAccess.release() unregisterPtyHandlers(id) storedCallbacks.onExit?.(code) storedCallbacks.onDisconnect?.() @@ -735,11 +874,12 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra // Why: a cleanup failure must not turn an already-delivered pre-attach exit into a connect rejection and fallback spawn. console.error('[pty] buffered pre-attach exit cleanup failed', error) } - return hadBufferedExit + return hadBufferedExit && exitDelivered } return { async connect(options) { + const attemptGeneration = (connectionAttemptGeneration += 1) storedCallbacks = options.callbacks ensurePtyDispatcher() @@ -747,14 +887,25 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra return } - if (options.sessionId && hasPreHandlerPtyExit(options.sessionId)) { + if ( + paneGeneration === undefined && + options.sessionId && + hasPreHandlerPtyExit(options.sessionId) + ) { if (options.admitPtyId && !options.admitPtyId(options.sessionId)) { return { id: options.sessionId } satisfies PtyConnectResult } // Why: deliver the exited parked session's buffered final frame/exit before spawn, so the dead incarnation can't orphan a fresh shell reusing its id. ptyId = options.sessionId connected = true - registerPtyDataHandler(options.sessionId) + mutationAccess.bind(options.sessionId, { mode: 'legacy' }) + if (!registerPtyDataHandler(options.sessionId)) { + connected = false + ptyId = null + mutationAccess.release() + storedCallbacks = {} + return { id: options.sessionId } satisfies PtyConnectResult + } registerPtyExitHandler(options.sessionId) return { id: options.sessionId, exitedBeforeAttach: true } satisfies PtyConnectResult } @@ -769,10 +920,13 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra clearConsumedPreHandlerPtyExit(admittedSessionId) } + let cancelPendingRendererBinding: (() => void) | null = null try { // Why: cwd fallback is only for fresh local spawns — reattach keeps the session's cwd and SSH transports resolve cwd on the remote host. const shouldSendLocalCwdFallback = cwdFallback === 'worktree' && !connectionId && !admittedSessionId + const mutationClaimant = + paneGeneration !== undefined ? mutationAccess.prepareBinding() : undefined const result = await window.api.pty.spawn({ cols: options.cols ?? 80, rows: options.rows ?? 24, @@ -807,29 +961,102 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra worktreeId, ...(tabId ? { tabId } : {}), ...(leafId ? { leafId } : {}), + ...(paneGeneration !== undefined ? { tabGeneration: paneGeneration } : {}), + ...(mutationClaimant ? { mutationClaimant } : {}), ...(shellOverride ? { shellOverride } : {}), ...(projectRuntime ? { projectRuntime } : {}), ...(terminalColorQueryReplies ? { terminalColorQueryReplies } : {}), ...(telemetry ? { telemetry } : {}) }) const spawnResult = result as PtyConnectResult & { isReattach?: boolean } + const rendererBindingAccess = normalizePtyMutationAccess( + spawnResult.mutationAccess, + paneGeneration, + mutationClaimant + ) + const rendererHandlerClaim = + paneGeneration !== undefined && mutationClaimant + ? { + paneGeneration, + claimant: mutationClaimant, + ...(rendererBindingAccess.mode === 'exact' ? { access: rendererBindingAccess } : {}) + } + : undefined + let rendererBindingPending = Boolean(admittedSessionId && rendererHandlerClaim) + const cancelRendererBinding = (): void => { + if (!rendererBindingPending || !rendererHandlerClaim) { + return + } + rendererBindingPending = false + cancelPendingRendererBinding = null + untrackPendingRendererBinding(spawnResult.id, cancelRendererBinding) + window.api.pty.cancelRendererBinding?.({ + id: spawnResult.id, + paneGeneration: rendererHandlerClaim.paneGeneration, + claimant: rendererHandlerClaim.claimant, + ...(rendererHandlerClaim.access ? { access: rendererHandlerClaim.access } : {}) + }) + } + const settleRendererBinding = (): void => { + if (!rendererBindingPending || !rendererHandlerClaim) { + return + } + rendererBindingPending = false + cancelPendingRendererBinding = null + untrackPendingRendererBinding(spawnResult.id, cancelRendererBinding) + if (rendererHandlerClaim.access) { + window.api.pty.rendererBindingReady?.({ + id: spawnResult.id, + access: rendererHandlerClaim.access + }) + } else { + window.api.pty.cancelRendererBinding?.({ + id: spawnResult.id, + paneGeneration: rendererHandlerClaim.paneGeneration, + claimant: rendererHandlerClaim.claimant + }) + } + } + cancelPendingRendererBinding = cancelRendererBinding + if (rendererBindingPending) { + trackPendingRendererBinding(spawnResult.id, cancelRendererBinding) + } + // Why: an older SSH attach may resolve last; clean only its result before it can clear the successor's shared binding. + if (destroyed || attemptGeneration !== connectionAttemptGeneration) { + cancelRendererBinding() + if (!spawnResult.isReattach && !spawnResult.coldRestore && spawnResult.id !== ptyId) { + await killPtyWithMutationIdentity( + spawnResult.id, + false, + rendererBindingAccess.mode === 'exact' ? rendererBindingAccess.identity : undefined + ).catch(() => {}) + } + return undefined + } + mutationAccess.bind(spawnResult.id, spawnResult.mutationAccess, mutationClaimant) const resultLaunchAgent = isTuiAgent(spawnResult.launchAgent) ? spawnResult.launchAgent : undefined const retireFreshSpawn = async (): Promise => { - if (!spawnResult.isReattach && !spawnResult.coldRestore) { - await window.api.pty.kill(spawnResult.id) + try { + if (!spawnResult.isReattach && !spawnResult.coldRestore) { + await mutationAccess.kill(spawnResult.id) + } + } finally { + mutationAccess.release() } } // Why: on destroy mid-connect, kill only a fresh spawn — killing a reattached session (owned by the tab lifecycle) loses a live shell. if (destroyed) { + cancelRendererBinding() await retireFreshSpawn() return } if (options.admitPtyId && !options.admitPtyId(spawnResult.id)) { // Why: a rejected session-expired fallback has no owner to retire its newly created process. + cancelRendererBinding() await retireFreshSpawn() return spawnResult } @@ -845,15 +1072,36 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra onPtySpawn?.(spawnResult.id) } - registerPtyDataHandler(spawnResult.id) + if (!registerPtyDataHandler(spawnResult.id, rendererHandlerClaim)) { + cancelRendererBinding() + connected = false + ptyId = null + mutationAccess.release() + return spawnResult + } const exitedBeforeAttach = registerPtyExitHandler(spawnResult.id) if (exitedBeforeAttach) { + cancelRendererBinding() return { id: spawnResult.id, exitedBeforeAttach: true } satisfies PtyConnectResult } if (!connected || ptyId !== spawnResult.id) { + cancelRendererBinding() return undefined } + const rendererBindingSettlement = + rendererBindingPending && rendererHandlerClaim?.access + ? Object.freeze({ + id: spawnResult.id, + paneGeneration: rendererHandlerClaim.paneGeneration, + ready: settleRendererBinding, + cancel: cancelRendererBinding + }) + : undefined + if (!rendererBindingSettlement) { + settleRendererBinding() + } + storedCallbacks.onConnect?.() storedCallbacks.onStatus?.('shell') @@ -872,6 +1120,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra coldRestore: spawnResult.coldRestore, replay: spawnResult.replay, pendingEscapeTailAnsi: spawnResult.pendingEscapeTailAnsi, + ...(rendererBindingSettlement ? { rendererBindingSettlement } : {}), // Why: the cold-restore path re-runs the launch command, so it needs the // same "main declined the resume" signal the fresh-spawn path gets. ...(spawnResult.agentResumeUnavailable ? { agentResumeUnavailable: true as const } : {}) @@ -890,11 +1139,21 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra ...(spawnResult.startupCwdFallback ? { startupCwdFallback: spawnResult.startupCwdFallback } : {}), - ...(spawnResult.agentResumeUnavailable ? { agentResumeUnavailable: true as const } : {}) + ...(spawnResult.agentResumeUnavailable + ? { agentResumeUnavailable: true as const } + : {}), + ...(rendererBindingSettlement ? { rendererBindingSettlement } : {}) } satisfies PtyConnectResult } + if (rendererBindingSettlement) { + return { id: spawnResult.id, rendererBindingSettlement } satisfies PtyConnectResult + } return spawnResult.id } catch (err) { + cancelPendingRendererBinding?.() + if (destroyed || attemptGeneration !== connectionAttemptGeneration) { + return undefined + } const msg = extractIpcErrorMessage(err, err instanceof Error ? err.message : String(err)) if ( connectionId && @@ -927,6 +1186,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra }, attach(options) { + connectionAttemptGeneration += 1 storedCallbacks = options.callbacks ensurePtyDispatcher() @@ -935,10 +1195,23 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra } const id = options.existingPtyId + const mutationClaimant = + paneGeneration !== undefined ? mutationAccess.prepareBinding() : undefined ptyId = id connected = true // Why: skip onPtySpawn — it would reset lastActivityAt and destroy the recency sort order reconnectPersistedTerminals preserved. - registerPtyDataHandler(id) + const rendererHandlerClaim = + paneGeneration !== undefined && mutationClaimant + ? { paneGeneration, claimant: mutationClaimant } + : undefined + if (!registerPtyDataHandler(id, rendererHandlerClaim)) { + connected = false + ptyId = null + mutationAccess.release() + storedCallbacks = {} + return + } + mutationAccess.bind(id, undefined, mutationClaimant) registerPtyExitHandler(id) if (!connected || ptyId !== id) { return @@ -980,7 +1253,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra } if (options.cols && options.rows) { - window.api.pty.resize(id, options.cols, options.rows) + mutationAccess.resize(id, options.cols, options.rows) } storedCallbacks.onConnect?.() @@ -988,11 +1261,16 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra }, disconnect() { + connectionAttemptGeneration += 1 + cancelPendingRendererBindings() clearAccumulatedState() inputWriteQueue.clear() if (ptyId) { const id = ptyId - window.api.pty.kill(id) + void mutationAccess + .kill(id) + .catch(() => {}) + .finally(() => mutationAccess.release()) connected = false ptyId = null unregisterPtyHandlers(id) @@ -1001,9 +1279,11 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra }, detach(options) { + connectionAttemptGeneration += 1 // Why first: the successor transport owns the PTY after detach, and nothing below may // throw its way past the census drop — a stranded gauge outlives the transport. outputProcessor.disposePendingSideEffectGauge() + cancelPendingRendererBindings() clearAccumulatedState() inputWriteQueue.clear() if (ptyId) { @@ -1016,37 +1296,62 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra } connected = false ptyId = null + mutationAccess.release() storedCallbacks = {} }, sendInput(data: string): boolean { - if (!connected || !ptyId) { + const target = ptyId ? mutationAccess.captureTarget(ptyId) : null + if (!connected || !target) { return false } - return inputWriteQueue.enqueue(ptyId, data) + return inputWriteQueue.enqueue(target, data) + }, + + captureInputTarget, + + isInputTargetCurrent(target): boolean { + return inputBindingForTarget(target) !== null + }, + + sendInputToTarget(target, data): boolean { + const binding = inputBindingForTarget(target) + return binding ? inputWriteQueue.enqueue(binding, data) : false }, // Why: kept distinct from sendInput so the remote transport can override with flush-then-send (#7329); local queue drains same-turn. sendInputImmediate(data: string): boolean { - if (!connected || !ptyId) { + const target = ptyId ? mutationAccess.captureTarget(ptyId) : null + if (!connected || !target) { return false } - return inputWriteQueue.enqueue(ptyId, data) + return inputWriteQueue.enqueue(target, data) }, ...(connectionId ? {} : { async sendInputAccepted(data: string): Promise { - if (!connected || !ptyId) { + const target = ptyId ? mutationAccess.captureTarget(ptyId) : null + if (!connected || !target) { return false } - const id = ptyId await inputWriteQueue.waitForDrain() - if (!connected || ptyId !== id) { + if (!connected || ptyId !== target.id || !mutationAccess.isCurrentTarget(target)) { return false } - return writeAcceptedPtyInput(id, data) + return writeAcceptedPtyInput(target, data) + }, + async sendInputAcceptedToTarget( + target: PtyTransportInputTarget, + data: string + ): Promise { + const binding = inputBindingForTarget(target) + if (!binding) { + return false + } + await inputWriteQueue.waitForDrain() + return inputBindingForTarget(target) ? writeAcceptedPtyInput(binding, data) : false } }), @@ -1054,21 +1359,30 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra if (!connected || !ptyId) { return false } - window.api.pty.claimViewport(ptyId, cols, rows) - return true + return mutationAccess.claimViewport(ptyId, cols, rows) }, resize(cols: number, rows: number, meta): boolean { if (!connected || !ptyId) { return false } - if (meta?.claim) { - window.api.pty.resize(ptyId, cols, rows) - window.api.pty.claimViewport(ptyId, cols, rows) - } else { - window.api.pty.resize(ptyId, cols, rows) + return meta?.claim + ? mutationAccess.resize(ptyId, cols, rows, true) + : mutationAccess.resize(ptyId, cols, rows) + }, + + signal(signal: string): boolean { + if (!connected || !ptyId) { + return false } - return true + return mutationAccess.signal(ptyId, signal) + }, + + clearBuffer(): boolean { + if (!connected || !ptyId) { + return false + } + return mutationAccess.clearBuffer(ptyId) }, isConnected() { @@ -1079,6 +1393,14 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra return ptyId }, + getMutationIdentity() { + return ptyId ? (mutationAccess.currentIdentity(ptyId) ?? null) : null + }, + + hasMutationAccess() { + return ptyId !== null && mutationAccess.hasAccess(ptyId) + }, + getConnectionId() { return connectionId ?? null }, diff --git a/src/renderer/src/components/terminal-pane/terminal-captured-input-dispatch.test.ts b/src/renderer/src/components/terminal-pane/terminal-captured-input-dispatch.test.ts index 521100fbd65..e42213d76bc 100644 --- a/src/renderer/src/components/terminal-pane/terminal-captured-input-dispatch.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-captured-input-dispatch.test.ts @@ -66,6 +66,25 @@ describe('sendCapturedTerminalInput', () => { expect(transport.sendInput).not.toHaveBeenCalled() }) + it('does not fall through to a same-ID successor after an exact target is superseded', () => { + const transport = createTransport('same-id') + const target = { owner: {}, binding: {} } + transport.sendInputToTarget = vi.fn(() => false) + + expect( + sendCapturedTerminalInput({ + targetPaneMounted: true, + currentTransport: transport, + capturedTransport: transport, + capturedPtyId: 'same-id', + capturedInputTarget: target, + data: '\r' + }) + ).toBe(false) + expect(transport.sendInputToTarget).toHaveBeenCalledWith(target, '\r') + expect(transport.sendInput).not.toHaveBeenCalled() + }) + it('does not deliver after pane disposal', () => { const transport = createTransport('pty-original') diff --git a/src/renderer/src/components/terminal-pane/terminal-captured-input-dispatch.ts b/src/renderer/src/components/terminal-pane/terminal-captured-input-dispatch.ts index e275f15bd99..ddb13f10a9f 100644 --- a/src/renderer/src/components/terminal-pane/terminal-captured-input-dispatch.ts +++ b/src/renderer/src/components/terminal-pane/terminal-captured-input-dispatch.ts @@ -1,10 +1,11 @@ -import type { PtyTransport } from './pty-transport' +import type { PtyTransport, PtyTransportInputTarget } from './pty-transport' type CapturedTerminalInputDispatch = { targetPaneMounted: boolean currentTransport: PtyTransport | undefined capturedTransport: PtyTransport | undefined capturedPtyId: string | null + capturedInputTarget?: PtyTransportInputTarget | null data: string } @@ -17,6 +18,7 @@ export function sendCapturedTerminalInput({ currentTransport, capturedTransport, capturedPtyId, + capturedInputTarget, data }: CapturedTerminalInputDispatch): boolean { if ( @@ -28,6 +30,9 @@ export function sendCapturedTerminalInput({ ) { return false } + if (capturedInputTarget) { + return capturedTransport.sendInputToTarget?.(capturedInputTarget, data) ?? false + } return capturedTransport.sendInput(data) } diff --git a/src/renderer/src/components/terminal-pane/terminal-cold-park-recheck-deadlines.test.ts b/src/renderer/src/components/terminal-pane/terminal-cold-park-recheck-deadlines.test.ts index 51a0716c13b..b5692be08b1 100644 --- a/src/renderer/src/components/terminal-pane/terminal-cold-park-recheck-deadlines.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-cold-park-recheck-deadlines.test.ts @@ -169,45 +169,4 @@ describe('getTerminalTabColdParkRecheckDelayMs', () => { }) ).toBe(250) }) - - // Why: a flip-damped tab is long past every hysteresis deadline, so the pin - // expiry is the only wakeup left — drop it and the tab never re-parks. - it('wakes at the flip-damping pin deadline when all ordinary deadlines have passed', () => { - expect( - getTerminalTabColdParkRecheckDelayMs({ - parkingEnabled: true, - hiddenSinceMs: 1_000, - nowMs: 1_000_000, - coldParkDelayMs: 100, - hotRetainMs: 1_000, - parkVerdictPinUntilMs: 1_060_000 - }) - ).toBe(60_000) - }) - - // Why: the earliest pending deadline wins — a pin must not delay a nearer - // cool-down wakeup, and vice versa. - it('takes the nearest of the pin and cool-down deadlines', () => { - const base = { - parkingEnabled: true, - hiddenSinceMs: 1_000, - nowMs: 2_500, - coldParkDelayMs: 100, - hotRetainMs: 1_000 - } - expect( - getTerminalTabColdParkRecheckDelayMs({ - ...base, - parkCooldownUntilMs: 2_750, - parkVerdictPinUntilMs: 62_500 - }) - ).toBe(250) - expect( - getTerminalTabColdParkRecheckDelayMs({ - ...base, - parkCooldownUntilMs: 62_500, - parkVerdictPinUntilMs: 2_750 - }) - ).toBe(250) - }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-cold-park-recheck-deadlines.ts b/src/renderer/src/components/terminal-pane/terminal-cold-park-recheck-deadlines.ts index c2ddc024119..ba106054c87 100644 --- a/src/renderer/src/components/terminal-pane/terminal-cold-park-recheck-deadlines.ts +++ b/src/renderer/src/components/terminal-pane/terminal-cold-park-recheck-deadlines.ts @@ -18,7 +18,6 @@ function nextColdParkDeadlineDelayMs(args: { hotRetainMs: number retentionTtlMs?: number parkCooldownUntilMs?: number | null - parkVerdictPinUntilMs?: number | null }): number | null { if (!args.parkingEnabled || args.hiddenSinceMs === null) { return null @@ -28,9 +27,7 @@ function nextColdParkDeadlineDelayMs(args: { args.hiddenSinceMs + args.hotRetainMs, ...(args.retentionTtlMs !== undefined ? [args.hiddenSinceMs + args.retentionTtlMs] : []), // Why: the cool-down holds past-deadline candidates out of the parked set; without this wakeup nothing re-parks them. - ...(args.parkCooldownUntilMs != null ? [args.parkCooldownUntilMs] : []), - // Why: damping stops the churn that would otherwise wake this deadline. - ...(args.parkVerdictPinUntilMs != null ? [args.parkVerdictPinUntilMs] : []) + ...(args.parkCooldownUntilMs != null ? [args.parkCooldownUntilMs] : []) ].filter((deadlineMs) => deadlineMs > args.nowMs) return pendingDeadlines.length === 0 ? null : Math.min(...pendingDeadlines) - args.nowMs } @@ -63,8 +60,6 @@ export function getTerminalTabColdParkRecheckDelayMs(args: { coldParkDelayMs?: number hotRetainMs?: number parkCooldownUntilMs?: number | null - /** Provided only for tabs damped out of the parked set by flip churn. */ - parkVerdictPinUntilMs?: number | null }): number | null { return nextColdParkDeadlineDelayMs({ parkingEnabled: args.parkingEnabled, @@ -72,7 +67,6 @@ export function getTerminalTabColdParkRecheckDelayMs(args: { nowMs: args.nowMs, coldParkDelayMs: args.coldParkDelayMs ?? TERMINAL_TAB_COLD_PARK_DELAY_MS, hotRetainMs: args.hotRetainMs ?? TERMINAL_TAB_HOT_RETAIN_MS, - parkCooldownUntilMs: args.parkCooldownUntilMs, - parkVerdictPinUntilMs: args.parkVerdictPinUntilMs + parkCooldownUntilMs: args.parkCooldownUntilMs }) } diff --git a/src/renderer/src/components/terminal-pane/terminal-cold-park-verdict-loop.test.tsx b/src/renderer/src/components/terminal-pane/terminal-cold-park-verdict-loop.test.tsx index 97cb2db3428..ec3391f3cc1 100644 --- a/src/renderer/src/components/terminal-pane/terminal-cold-park-verdict-loop.test.tsx +++ b/src/renderer/src/components/terminal-pane/terminal-cold-park-verdict-loop.test.tsx @@ -1,30 +1,17 @@ /** @vitest-environment happy-dom */ -/** - * Cold-park verdict oscillation (crash cluster: React #185 in the terminal overlay). - * - * Why this shape: canWatcherCoverParkedTerminalTab is re-derived from store and - * registry state that mounting/unmounting the very pane the verdict controls - * rewrites, and the parked watchers write the tab model back. The pane stand-in - * models that edge — mounted pane grants coverage, parked pane withdraws it, - * and either transition re-mints the tab array the cold-park effect depends on. - * - * Why the store-write chain is parameterised: React bails on *commits*, not on - * flips, so a damping threshold expressed in flips is only safe if it holds at - * the real cycle's commits-per-flip cost (pane-connect updateTabPtyId, the - * watcher-sync writes, the overlay's own subscriber renders). The chain models - * that cost so the derived burst limit stays regression-tested. - */ -import { act, useEffect, useRef, useState } from 'react' +import { act, useEffect } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { TerminalTab } from '../../../../shared/types' ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true -const park = vi.hoisted(() => ({ - worktreeId: 'repo::/wt-park-loop', - /** Whether the byte watchers could cover the tab if it parked right now. */ - coverage: true +const handoff = vi.hoisted(() => ({ + mounted: false, + prepared: false, + allowPrepare: true, + activated: false, + entry: undefined as object | undefined })) vi.mock('../../store', async () => { @@ -32,19 +19,39 @@ vi.mock('../../store', async () => { const useAppStore = create(() => ({ pendingStartupByTabId: {} as Record, runtimeStatusByEnvironmentId: new Map(), + sleepingAgentSessionsByPaneKey: {}, settings: {} as Record, terminalLayoutsByTabId: {} as Record, - runtimePaneTitlesByTabId: {} as Record, tabsByWorktree: {} as Record })) return { useAppStore } }) vi.mock('./terminal-parked-tab-watchers', () => ({ - canWatcherCoverParkedTerminalTab: () => park.coverage, + activatePreparedParkedTerminalTabWatchers: (args: { parkedTabIds: ReadonlySet }) => { + if (args.parkedTabIds.has('tab-b') && handoff.prepared) { + handoff.activated = true + } + }, disposeParkedTerminalWatchersForWorktree: () => {}, - resolveParkedTerminalPaneCandidates: () => [], - syncParkedTerminalTabWatchers: () => {} + getParkedTerminalWatcherEntry: () => handoff.entry, + isParkedTerminalTabPreparationCurrent: () => handoff.prepared, + selectParkedTerminalPaneCandidateKey: () => '', + syncParkedTerminalTabWatchers: (args: { desiredParkedTabIds: ReadonlySet }) => { + if (!args.desiredParkedTabIds.has('tab-b')) { + handoff.prepared = false + handoff.entry = undefined + return new Set() + } + if (handoff.allowPrepare && (handoff.mounted || handoff.prepared)) { + if (!handoff.prepared) { + handoff.entry = {} + } + handoff.prepared = true + return new Set(['tab-b']) + } + return new Set() + } })) vi.mock('./terminal-parking-e2e-overrides', () => ({ @@ -52,20 +59,9 @@ vi.mock('./terminal-parking-e2e-overrides', () => ({ })) import { useAppStore } from '../../store' -import { - TERMINAL_TAB_PARK_FLIP_BURST_LIMIT, - TERMINAL_TAB_PARK_FLIP_COMMIT_COST -} from './terminal-park-verdict-flip-telemetry' import { useTerminalTabColdParking } from './use-terminal-tab-cold-parking' -/** The most recently hidden tab holds the last-active retain exemption. */ -const EXEMPT_TAB_ID = 'tab-a' -const PARKABLE_TAB_ID = 'tab-b' -/** Stops an unfixed loop from hanging the run if React ever raises its bail. */ -const RENDER_HARD_STOP = 500 -/** +1: the pin lands in the passive effect observing the burst flip, so the - * verdict settles one flip after damping engages. */ -const SETTLED_FLIP_BUDGET = TERMINAL_TAB_PARK_FLIP_BURST_LIMIT + 1 +const WORKTREE_ID = 'repo::/wt-park-handoff' const EMPTY_ASSIGNMENTS = new Map() const EMPTY_PORTALS: never[] = [] @@ -76,172 +72,130 @@ const parkingStore = useAppStore as unknown as { } function terminalTab(id: string): TerminalTab { - return { id, ptyId: `${park.worktreeId}@@session-${id}`, title: id } as TerminalTab + return { id, ptyId: `${WORKTREE_ID}@@session-${id}`, title: id } as TerminalTab } -let tabModelRevision = 0 - -// Models the store writes a pane mount/unmount produces — updateTabPtyId on -// connect, parked-watcher title writes — both of which re-mint the tab array. -function rewriteTabModel(tabId: string): void { - tabModelRevision += 1 - const revision = tabModelRevision +let modelRevision = 0 +function rewriteTabModel(): void { + modelRevision += 1 parkingStore.setState((state) => ({ tabsByWorktree: { ...state.tabsByWorktree, - [park.worktreeId]: (state.tabsByWorktree[park.worktreeId] ?? []).map((tab) => - tab.id === tabId ? { ...tab, title: `${tab.id}-${revision}` } : tab - ) + [WORKTREE_ID]: state.tabsByWorktree[WORKTREE_ID].map((tab) => ({ + ...tab, + title: `${tab.id}-${modelRevision}` + })) } })) } -/** Store writes a single park transition costs; each lands in its own commit. */ -let storeCommitsPerParkTransition = 1 -let paneMountCount = 0 - -/** The pane whose mount/unmount the park verdict authorizes. */ -function TerminalPaneStandIn(): null { +let paneMounts = 0 +let paneUnmounts = 0 +let rewritePaneModel = true +function PaneStandIn(): null { useEffect(() => { - paneMountCount += 1 + handoff.mounted = true + paneMounts += 1 + if (rewritePaneModel) { + rewriteTabModel() + } + return () => { + handoff.mounted = false + paneUnmounts += 1 + if (rewritePaneModel) { + rewriteTabModel() + } + } }, []) return null } -/** - * The store writes a park transition drags behind it — pane-connect - * updateTabPtyId, then the watcher-sync writes that each only see the tab model - * the previous write produced. Coverage is re-derived from the last of them, so - * the verdict cannot flip until the whole chain has committed. Why staged - * rather than batched: nested commits are what React counts against - * NESTED_UPDATE_LIMIT, so this is the per-flip cost the damping must beat. - */ -function ParkTransitionStoreWrites({ parked }: { parked: boolean }): null { - const pendingWritesRef = useRef(storeCommitsPerParkTransition) - const lastParkedRef = useRef(parked) - const [writeTick, setWriteTick] = useState(0) - useEffect(() => { - if (lastParkedRef.current !== parked) { - lastParkedRef.current = parked - pendingWritesRef.current = storeCommitsPerParkTransition - } - if (pendingWritesRef.current <= 0) { - return - } - pendingWritesRef.current -= 1 - if (pendingWritesRef.current === 0) { - // A mounted pane grants byte-watcher coverage; a parked one withdraws it. - park.coverage = !parked - } - rewriteTabModel(PARKABLE_TAB_ID) - setWriteTick((tick) => tick + 1) - }, [parked, writeTick]) - return null -} - -let hostRenderCount = 0 -let parkVerdictFlipCount = 0 -let lastParkVerdict = false - +let hostRenders = 0 +let lastParked = false function OverlayHost(): React.JSX.Element | null { - hostRenderCount += 1 + hostRenders += 1 const terminalTabs = useAppStore( - (state) => (state as ParkingStoreState).tabsByWorktree[park.worktreeId] + (state) => (state as ParkingStoreState).tabsByWorktree[WORKTREE_ID] ) as TerminalTab[] - const parkedTerminalTabIds = useTerminalTabColdParking({ - worktreeId: park.worktreeId, + const parkedTabIds = useTerminalTabColdParking({ + worktreeId: WORKTREE_ID, terminalTabs, assignments: EMPTY_ASSIGNMENTS, isWorktreeActive: false, coldParkTerminalPanes: false, shouldMeasureHiddenWorktree: false, - activityTerminalPortals: EMPTY_PORTALS, - activationDeferredMountTabIds: null + activityTerminalPortals: EMPTY_PORTALS }) - const parked = parkedTerminalTabIds.has(PARKABLE_TAB_ID) - if (parked !== lastParkVerdict) { - parkVerdictFlipCount += 1 - } - lastParkVerdict = parked - if (hostRenderCount > RENDER_HARD_STOP) { - return null - } - return ( - <> - {parked ? null : } - - - ) + lastParked = parkedTabIds.has('tab-b') + return lastParked ? null : } -function renderOverlayHost(root: Root): unknown { - const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) - let thrown: unknown = null - try { - act(() => { - root.render() - }) - } catch (error) { - thrown = error - } - consoleError.mockRestore() - return thrown -} - -describe('cold-park verdict oscillation', () => { +describe('cold-park prepare and commit handoff', () => { let container: HTMLDivElement - // Why optional: a throwing createRoot must not be masked by an afterEach TypeError. - let root: Root | undefined + let root: Root beforeEach(() => { - park.coverage = true - hostRenderCount = 0 - parkVerdictFlipCount = 0 - lastParkVerdict = false - tabModelRevision = 0 - paneMountCount = 0 - storeCommitsPerParkTransition = 1 + handoff.mounted = false + handoff.prepared = false + handoff.allowPrepare = true + handoff.activated = false + handoff.entry = undefined + paneMounts = 0 + paneUnmounts = 0 + rewritePaneModel = true + hostRenders = 0 + lastParked = false + modelRevision = 0 parkingStore.setState(() => ({ tabsByWorktree: { - [park.worktreeId]: [terminalTab(EXEMPT_TAB_ID), terminalTab(PARKABLE_TAB_ID)] + [WORKTREE_ID]: [terminalTab('tab-a'), terminalTab('tab-b')] } })) container = document.createElement('div') document.body.appendChild(container) + root = createRoot(container) }) afterEach(() => { - act(() => { - root?.unmount() - }) - root = undefined + act(() => root.unmount()) container.remove() }) - it('settles the park verdict when watcher coverage tracks the pane it unmounts', () => { - root = createRoot(container) - const thrown = renderOverlayHost(root) + it('prepares while mounted, parks once, and stays parked through unmount writes', () => { + act(() => root.render()) - // Damping must land inside the burst window and settle on the safe side: - // the pane stays mounted, so bells/titles/completions never go silent. - expect(thrown).toBeNull() - expect(parkVerdictFlipCount).toBeLessThanOrEqual(SETTLED_FLIP_BUDGET) - expect(lastParkVerdict).toBe(false) - expect(paneMountCount).toBeGreaterThan(0) + expect(lastParked).toBe(true) + expect(handoff.activated).toBe(true) + expect(paneMounts).toBe(1) + expect(paneUnmounts).toBe(1) + expect(hostRenders).toBeLessThan(20) }) - // Why: the burst limit is derived from React's 50-commit bail divided by an - // assumed worst-case commits-per-flip. Running the loop at exactly that cost - // is what keeps the derivation honest — a limit tuned on a one-write-per-flip - // model pins only after React has already thrown #185. - it('settles at the assumed worst-case commit cost per park transition', () => { - storeCommitsPerParkTransition = TERMINAL_TAB_PARK_FLIP_COMMIT_COST - root = createRoot(container) - const thrown = renderOverlayHost(root) + it('keeps an unwatchable candidate mounted without verdict churn', () => { + handoff.allowPrepare = false + act(() => root.render()) - expect(thrown).toBeNull() - expect(parkVerdictFlipCount).toBeLessThanOrEqual(SETTLED_FLIP_BUDGET) - expect(lastParkVerdict).toBe(false) - expect(paneMountCount).toBeGreaterThan(0) + expect(lastParked).toBe(false) + expect(handoff.activated).toBe(false) + expect(paneMounts).toBe(1) + expect(paneUnmounts).toBe(0) + expect(hostRenders).toBeLessThan(20) + }) + + it('reactivates a replacement preparation for the same parked tab', () => { + act(() => root.render()) + expect(lastParked).toBe(true) + + rewritePaneModel = false + handoff.prepared = false + handoff.activated = false + lastParked = false + act(() => rewriteTabModel()) + + expect(lastParked).toBe(true) + expect(handoff.activated).toBe(true) + expect(paneMounts).toBe(2) + expect(paneUnmounts).toBe(2) + expect(hostRenders).toBeLessThan(30) }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-cold-park-withheld-tabs.test.ts b/src/renderer/src/components/terminal-pane/terminal-cold-park-withheld-tabs.test.ts deleted file mode 100644 index a72867e3c22..00000000000 --- a/src/renderer/src/components/terminal-pane/terminal-cold-park-withheld-tabs.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { TerminalTab } from '../../../../shared/types' -import type { ParkVerdictFlipRecord } from './terminal-park-verdict-flip-telemetry' -import { withholdUnparkableTerminalTabs } from './terminal-cold-park-withheld-tabs' - -const coverage = vi.hoisted(() => ({ byTabId: new Map() })) - -vi.mock('./terminal-parked-tab-watchers', () => ({ - canWatcherCoverParkedTerminalTab: (_worktreeId: string, tab: TerminalTab) => - coverage.byTabId.get(tab.id) ?? true -})) - -const WORKTREE = 'repo::/wt' - -function terminalTab(id: string): TerminalTab { - return { id, ptyId: `${WORKTREE}@@${id}`, title: id } as TerminalTab -} - -function pinnedRecord(pinnedUntilMs: number | null): ParkVerdictFlipRecord { - return { - parked: false, - windowStartMs: 0, - flips: 0, - notified: false, - burstStartMs: 0, - burstFlips: 0, - pinnedUntilMs - } -} - -beforeEach(() => { - coverage.byTabId.clear() -}) - -describe('withholdUnparkableTerminalTabs', () => { - it('parks a covered tab with no pin', () => { - const { parkedTabIds, parkVerdictPinUntilMsByTabId } = withholdUnparkableTerminalTabs({ - worktreeId: WORKTREE, - terminalTabs: [terminalTab('tab-a')], - coldParkedTabIds: new Set(['tab-a']), - parkVerdictRecords: new Map(), - nowMs: 1_000 - }) - - expect(parkedTabIds).toEqual(new Set(['tab-a'])) - expect(parkVerdictPinUntilMsByTabId.size).toBe(0) - }) - - // Why both returned values matter: the caller needs the deadline to schedule - // the recheck that lets the tab park again once damping lapses. - it('withholds a pinned tab and reports its deadline', () => { - const records = new Map([['tab-a', pinnedRecord(9_000)]]) - - const { parkedTabIds, parkVerdictPinUntilMsByTabId } = withholdUnparkableTerminalTabs({ - worktreeId: WORKTREE, - terminalTabs: [terminalTab('tab-a')], - coldParkedTabIds: new Set(['tab-a']), - parkVerdictRecords: records, - nowMs: 1_000 - }) - - expect(parkedTabIds.has('tab-a')).toBe(false) - expect(parkVerdictPinUntilMsByTabId.get('tab-a')).toBe(9_000) - }) - - it('withholds a tab the watchers cannot cover', () => { - coverage.byTabId.set('tab-a', false) - - const { parkedTabIds, parkVerdictPinUntilMsByTabId } = withholdUnparkableTerminalTabs({ - worktreeId: WORKTREE, - terminalTabs: [terminalTab('tab-a')], - coldParkedTabIds: new Set(['tab-a']), - parkVerdictRecords: new Map(), - nowMs: 1_000 - }) - - expect(parkedTabIds.has('tab-a')).toBe(false) - // No pin: an uncovered tab has no damping deadline to recheck at. - expect(parkVerdictPinUntilMsByTabId.size).toBe(0) - }) - - // Why: an expired pin must hand the tab back to the parking policy, not - // strand it mounted for the rest of the session. - it('parks a tab whose pin has lapsed', () => { - const records = new Map([['tab-a', pinnedRecord(500)]]) - - const { parkedTabIds, parkVerdictPinUntilMsByTabId } = withholdUnparkableTerminalTabs({ - worktreeId: WORKTREE, - terminalTabs: [terminalTab('tab-a')], - coldParkedTabIds: new Set(['tab-a']), - parkVerdictRecords: records, - nowMs: 1_000 - }) - - expect(parkedTabIds.has('tab-a')).toBe(true) - expect(parkVerdictPinUntilMsByTabId.size).toBe(0) - }) - - it('does not consult non-candidates and leaves the input set untouched', () => { - const coldParkedTabIds = new Set(['tab-a']) - coverage.byTabId.set('tab-b', false) - - const { parkedTabIds } = withholdUnparkableTerminalTabs({ - worktreeId: WORKTREE, - terminalTabs: [terminalTab('tab-a'), terminalTab('tab-b')], - coldParkedTabIds, - parkVerdictRecords: new Map(), - nowMs: 1_000 - }) - - expect(parkedTabIds).toEqual(new Set(['tab-a'])) - expect(coldParkedTabIds).toEqual(new Set(['tab-a'])) - }) -}) diff --git a/src/renderer/src/components/terminal-pane/terminal-cold-park-withheld-tabs.ts b/src/renderer/src/components/terminal-pane/terminal-cold-park-withheld-tabs.ts deleted file mode 100644 index 35be4446cb5..00000000000 --- a/src/renderer/src/components/terminal-pane/terminal-cold-park-withheld-tabs.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * Last gate between a cold-park candidate and an unmounted pane. - * - * Two reasons withhold a tab, both settling on the safe mounted side: the byte - * watchers cannot cover it, or a verdict-flip burst pinned it. The pin deadline - * is returned because it is also the only remaining recheck wakeup once damping - * has stopped the churn that was waking the parking effect. - */ -import type { TerminalTab } from '../../../../shared/types' -import { - getParkVerdictUnparkPinUntilMs, - type ParkVerdictFlipRecord -} from './terminal-park-verdict-flip-telemetry' -import { canWatcherCoverParkedTerminalTab } from './terminal-parked-tab-watchers' - -export type WithheldTerminalTabParking = { - parkedTabIds: Set - parkVerdictPinUntilMsByTabId: Map -} - -export function withholdUnparkableTerminalTabs(args: { - worktreeId: string - terminalTabs: readonly TerminalTab[] - coldParkedTabIds: ReadonlySet - parkVerdictRecords: Map - nowMs: number -}): WithheldTerminalTabParking { - const parkedTabIds = new Set(args.coldParkedTabIds) - const parkVerdictPinUntilMsByTabId = new Map() - - for (const terminalTab of args.terminalTabs) { - if (!parkedTabIds.has(terminalTab.id)) { - continue - } - const parkVerdictPinUntilMs = getParkVerdictUnparkPinUntilMs({ - records: args.parkVerdictRecords, - tabId: terminalTab.id, - nowMs: args.nowMs - }) - if (parkVerdictPinUntilMs !== null) { - parkVerdictPinUntilMsByTabId.set(terminalTab.id, parkVerdictPinUntilMs) - } - // Why coverage matters: a parked tab the watchers cannot reach goes silent - // for bells/titles/completions — the failure that sank the first attempt. - if ( - parkVerdictPinUntilMs !== null || - !canWatcherCoverParkedTerminalTab(args.worktreeId, terminalTab) - ) { - parkedTabIds.delete(terminalTab.id) - } - } - - return { parkedTabIds, parkVerdictPinUntilMsByTabId } -} diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-path-writer.test.ts b/src/renderer/src/components/terminal-pane/terminal-drop-path-writer.test.ts index 7fcb898d7eb..349d293b8b0 100644 --- a/src/renderer/src/components/terminal-pane/terminal-drop-path-writer.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-drop-path-writer.test.ts @@ -258,4 +258,44 @@ describe('terminal drop path writer', () => { }) expect(sendInputAccepted).toHaveBeenCalledTimes(1) }) + + it('does not write a later path to a same-ID successor binding', async () => { + const sendInput = vi.fn(() => true) + const inputTarget = { owner: {}, binding: {} } + let targetCurrent = true + const { manager, pane } = createManager() + const transport = Object.assign(createTransport(sendInput), { + isInputTargetCurrent: vi.fn(() => targetCurrent), + sendInputAcceptedToTarget: vi.fn(async () => { + targetCurrent = false + return true + }) + }) + + const result = await writeTerminalDropPathsToCapturedTarget({ + dropTarget: { + paneId: pane.id, + leafId: pane.leafId, + ptyId: 'pty-1', + transport, + inputTarget + } as never, + manager: manager as never, + paneTransports: new Map([[pane.id, transport]]) as never, + paths: ['/repo/a.ts', '/repo/b.ts'], + targetShell: 'posix' + }) + + expect(result).toEqual({ + sentAnyPath: true, + targetCurrent: false, + pathsWritten: 1, + failureReason: 'target-stale' + }) + expect(transport.sendInputAcceptedToTarget).toHaveBeenCalledExactlyOnceWith( + inputTarget, + '/repo/a.ts ' + ) + expect(sendInput).not.toHaveBeenCalled() + }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-path-writer.ts b/src/renderer/src/components/terminal-pane/terminal-drop-path-writer.ts index d97bca17a63..80885e6ac3c 100644 --- a/src/renderer/src/components/terminal-pane/terminal-drop-path-writer.ts +++ b/src/renderer/src/components/terminal-pane/terminal-drop-path-writer.ts @@ -68,7 +68,7 @@ export async function writeTerminalDropPathsToCapturedTarget({ ? `${wrapTerminalBracketedPasteText(path)}${needsSeparatorAfterImage ? ' ' : ''}` : `${shellEscapePath(path, targetShell)} ` const writeResult = await runTerminalPasteOperationWithTimeout( - () => writeTerminalPastePtyInput(liveTransport, payload), + () => writeTerminalPastePtyInput(liveTransport, payload, dropTarget.inputTarget), operationTimeoutMs ) if (writeResult.timedOut) { diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-target.ts b/src/renderer/src/components/terminal-pane/terminal-drop-target.ts index 3247bb5ce7d..615a5cb771f 100644 --- a/src/renderer/src/components/terminal-pane/terminal-drop-target.ts +++ b/src/renderer/src/components/terminal-pane/terminal-drop-target.ts @@ -1,11 +1,12 @@ import type { PaneManager } from '@/lib/pane-manager/pane-manager' -import type { PtyTransport } from './pty-transport' +import type { PtyTransport, PtyTransportInputTarget } from './pty-transport' export type CapturedTerminalDropTarget = { paneId: number leafId: string ptyId: string | null transport: PtyTransport + inputTarget: PtyTransportInputTarget | null } export function captureTerminalDropTarget( @@ -16,7 +17,8 @@ export function captureTerminalDropTarget( paneId: pane.id, leafId: pane.leafId, ptyId: transport.getPtyId(), - transport + transport, + inputTarget: transport.captureInputTarget?.() ?? null } } @@ -29,7 +31,10 @@ export function getCurrentTerminalDropTransport( if ( liveTransport !== target.transport || !liveTransport.isConnected() || - liveTransport.getPtyId() !== target.ptyId + liveTransport.getPtyId() !== target.ptyId || + (target.inputTarget !== null && + target.inputTarget !== undefined && + liveTransport.isInputTargetCurrent?.(target.inputTarget) !== true) ) { return null } diff --git a/src/renderer/src/components/terminal-pane/terminal-legacy-recovery-view-model.ts b/src/renderer/src/components/terminal-pane/terminal-legacy-recovery-view-model.ts new file mode 100644 index 00000000000..fb25e3264c8 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-legacy-recovery-view-model.ts @@ -0,0 +1,61 @@ +import type { + TerminalLegacyPreservationFacts, + TerminalLegacyRecoveryReason +} from '../../../../shared/terminal-legacy-cutover' + +type TerminalLegacyRecoveryNoticeBase = Readonly<{ + recoveryKey: string + workspaceKind: 'git-worktree' | 'folder' | 'floating' + evidenceDigest: string + observedAtMs: number + discoveredAtMs: number + updatedAtMs: number +}> + +export type TerminalLegacyUnresolvedRecoveryNotice = TerminalLegacyRecoveryNoticeBase & + Readonly<{ + status: 'unresolved' + reason: TerminalLegacyRecoveryReason + preservationKind: TerminalLegacyPreservationFacts['kind'] + }> + +export type TerminalLegacyRecoveryNotice = + | TerminalLegacyUnresolvedRecoveryNotice + | (TerminalLegacyRecoveryNoticeBase & Readonly<{ status: 'imported' }>) + | (TerminalLegacyRecoveryNoticeBase & + Readonly<{ + status: 'acknowledged' + reason: TerminalLegacyRecoveryReason + preservationKind: TerminalLegacyPreservationFacts['kind'] + }>) + +export function selectUnresolvedLegacyRecoveries( + recoveries: readonly TerminalLegacyRecoveryNotice[] +): readonly TerminalLegacyUnresolvedRecoveryNotice[] { + return Object.freeze( + recoveries.filter( + (recovery): recovery is TerminalLegacyUnresolvedRecoveryNotice => + recovery.status === 'unresolved' + ) + ) +} + +export function formatLegacyRecoveryDetailsForClipboard( + recovery: TerminalLegacyUnresolvedRecoveryNotice +): string { + return JSON.stringify( + { + kind: 'orca-terminal-legacy-recovery', + formatVersion: 1, + reason: recovery.reason, + preservationKind: recovery.preservationKind, + workspaceKind: recovery.workspaceKind, + evidenceDigest: recovery.evidenceDigest, + observedAtMs: recovery.observedAtMs, + discoveredAtMs: recovery.discoveredAtMs, + updatedAtMs: recovery.updatedAtMs + }, + null, + 2 + ) +} diff --git a/src/renderer/src/components/terminal-pane/terminal-park-verdict-flip-telemetry.test.ts b/src/renderer/src/components/terminal-pane/terminal-park-verdict-flip-telemetry.test.ts index 49cebe2aea4..0e23e98b182 100644 --- a/src/renderer/src/components/terminal-pane/terminal-park-verdict-flip-telemetry.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-park-verdict-flip-telemetry.test.ts @@ -1,339 +1,56 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { - TERMINAL_TAB_PARK_FLIP_BURST_LIMIT, - TERMINAL_TAB_PARK_FLIP_BURST_WINDOW_MS, - TERMINAL_TAB_PARK_FLIP_COMMIT_COST, - TERMINAL_TAB_PARK_FLIP_NOTICE_LIMIT, - TERMINAL_TAB_PARK_FLIP_WINDOW_MS, - getParkVerdictUnparkPinUntilMs, - recordParkVerdictFlips, - type ParkVerdictFlipRecord -} from './terminal-park-verdict-flip-telemetry' const recordBreadcrumb = vi.fn() vi.mock('@/lib/crash-breadcrumb-recorder', () => ({ - recordRendererCrashBreadcrumb: (name: string, data?: unknown) => recordBreadcrumb(name, data) + recordRendererCrashBreadcrumb: (...args: unknown[]) => recordBreadcrumb(...args) })) +const { recordParkVerdictFlips } = await import('./terminal-park-verdict-flip-telemetry') + const TAB = 'tab-1' -/** Slower than the burst window, so only the notice limit can fire. */ -const SLOW_CHURN_STEP_MS = TERMINAL_TAB_PARK_FLIP_BURST_WINDOW_MS * 4 function observe(args: { - records: Map + previousParkedByTabId: Map parked: boolean - nowMs: number liveTabIds?: ReadonlySet }): void { recordParkVerdictFlips({ - records: args.records, + previousParkedByTabId: args.previousParkedByTabId, liveTabIds: args.liveTabIds ?? new Set([TAB]), - nextParkedTabIds: args.parked ? new Set([TAB]) : new Set(), - nowMs: args.nowMs + nextParkedTabIds: args.parked ? new Set([TAB]) : new Set() }) } -beforeEach(() => { - recordBreadcrumb.mockClear() -}) - -// Why asserted: the whole point of the burst trigger is that it is derived from -// React's 50-commit bail, not copied from the breadcrumb notice limit. If the -// two ever converge again the damping stops firing before React throws #185. -describe('burst damping threshold', () => { - it('stays under React NESTED_UPDATE_LIMIT at the assumed commits-per-flip cost', () => { - expect(TERMINAL_TAB_PARK_FLIP_BURST_LIMIT * TERMINAL_TAB_PARK_FLIP_COMMIT_COST).toBeLessThan(50) - expect(TERMINAL_TAB_PARK_FLIP_BURST_LIMIT).toBeLessThan(TERMINAL_TAB_PARK_FLIP_NOTICE_LIMIT) - }) -}) +beforeEach(() => recordBreadcrumb.mockClear()) describe('recordParkVerdictFlips', () => { - it('stays silent for a stable verdict', () => { - const records = new Map() - for (let i = 0; i < 100; i += 1) { - observe({ records, parked: true, nowMs: 1_000 + i * 1_000 }) - } + it('seeds and repeats stable verdicts without recording noise', () => { + const previousParkedByTabId = new Map() + observe({ previousParkedByTabId, parked: false }) + observe({ previousParkedByTabId, parked: false }) expect(recordBreadcrumb).not.toHaveBeenCalled() - expect(records.get(TAB)?.flips).toBe(0) + expect(previousParkedByTabId.get(TAB)).toBe(false) }) - it('emits one burst breadcrumb once the verdict churns at render cadence', () => { - const records = new Map() - for (let i = 0; i < 40; i += 1) { - observe({ records, parked: i % 2 === 0, nowMs: 1_000 + i * 10 }) - } + it('records each observed final-verdict transition without changing it', () => { + const previousParkedByTabId = new Map() + observe({ previousParkedByTabId, parked: false }) + observe({ previousParkedByTabId, parked: true }) + observe({ previousParkedByTabId, parked: false }) - expect(recordBreadcrumb).toHaveBeenCalledTimes(1) - expect(recordBreadcrumb).toHaveBeenCalledWith( - 'terminal_park_verdict_churn', - expect.objectContaining({ - tabId: TAB, - trigger: 'burst', - flips: TERMINAL_TAB_PARK_FLIP_BURST_LIMIT, - pinnedForMs: TERMINAL_TAB_PARK_FLIP_WINDOW_MS - }) - ) + expect(recordBreadcrumb.mock.calls).toEqual([ + ['terminal_park_verdict_churn', { tabId: TAB, trigger: 'flip', parked: true }], + ['terminal_park_verdict_churn', { tabId: TAB, trigger: 'flip', parked: false }] + ]) + expect(previousParkedByTabId.get(TAB)).toBe(false) }) - // Why: the two triggers answer different questions — 'burst' means damping - // engaged before React could bail, 'window' means churn too slow to loop. - it('separates a damped burst from slow churn', () => { - const tightRecords = new Map() - for (let i = 0; i < 40; i += 1) { - observe({ records: tightRecords, parked: i % 2 === 0, nowMs: 1_000 + i }) - } - expect(recordBreadcrumb).toHaveBeenCalledWith( - 'terminal_park_verdict_churn', - expect.objectContaining({ - trigger: 'burst', - flips: TERMINAL_TAB_PARK_FLIP_BURST_LIMIT, - elapsedMs: TERMINAL_TAB_PARK_FLIP_BURST_LIMIT, - windowMs: TERMINAL_TAB_PARK_FLIP_BURST_WINDOW_MS - }) - ) + it('drops verdict state for tabs that no longer exist', () => { + const previousParkedByTabId = new Map([[TAB, true]]) + observe({ previousParkedByTabId, parked: false, liveTabIds: new Set() }) - recordBreadcrumb.mockClear() - - const slowRecords = new Map() - for (let i = 0; i < TERMINAL_TAB_PARK_FLIP_NOTICE_LIMIT + 1; i += 1) { - observe({ records: slowRecords, parked: i % 2 === 0, nowMs: 1_000 + i * SLOW_CHURN_STEP_MS }) - } - expect(recordBreadcrumb).toHaveBeenCalledTimes(1) - expect(recordBreadcrumb).toHaveBeenCalledWith( - 'terminal_park_verdict_churn', - expect.objectContaining({ - trigger: 'window', - flips: TERMINAL_TAB_PARK_FLIP_NOTICE_LIMIT, - elapsedMs: TERMINAL_TAB_PARK_FLIP_NOTICE_LIMIT * SLOW_CHURN_STEP_MS, - windowMs: TERMINAL_TAB_PARK_FLIP_WINDOW_MS - }) - ) - }) - - // Why: slow churn must not be damped — parking it out for a minute would cost - // a mounted pane's memory for a verdict that was never near React's bail. - it('does not pin churn spread past the burst window', () => { - const records = new Map() - for (let i = 0; i < TERMINAL_TAB_PARK_FLIP_NOTICE_LIMIT + 1; i += 1) { - observe({ records, parked: i % 2 === 0, nowMs: 1_000 + i * SLOW_CHURN_STEP_MS }) - } - - expect(records.get(TAB)?.pinnedUntilMs ?? null).toBeNull() - }) - - it('re-arms after the window elapses', () => { - const records = new Map() - for (let i = 0; i < 40; i += 1) { - observe({ records, parked: i % 2 === 0, nowMs: 1_000 + i * 10 }) - } - expect(recordBreadcrumb).toHaveBeenCalledTimes(1) - - const laterMs = 1_000 + TERMINAL_TAB_PARK_FLIP_WINDOW_MS * 2 - for (let i = 0; i < 40; i += 1) { - observe({ records, parked: i % 2 === 0, nowMs: laterMs + i * 10 }) - } - - expect(recordBreadcrumb).toHaveBeenCalledTimes(2) - }) - - // Why: an unclamped backwards jump would freeze the window and suppress the - // very signal this module exists to capture. - it('treats a backwards clock jump as a fresh window', () => { - const records = new Map() - observe({ records, parked: true, nowMs: 10_000_000 }) - observe({ records, parked: false, nowMs: 1_000 }) - - expect(records.get(TAB)?.windowStartMs).toBe(1_000) - expect(records.get(TAB)?.flips).toBe(1) - }) - - // Why: >= is the boundary operator; a > regression would silently stretch the - // window and delay every notice by one full period. - it('treats an exactly-elapsed window as expired', () => { - const records = new Map() - observe({ records, parked: true, nowMs: 1_000 }) - observe({ records, parked: false, nowMs: 1_000 + TERMINAL_TAB_PARK_FLIP_WINDOW_MS }) - - expect(records.get(TAB)?.windowStartMs).toBe(1_000 + TERMINAL_TAB_PARK_FLIP_WINDOW_MS) - expect(records.get(TAB)?.flips).toBe(1) - }) - - it('honours the window, notice, burst-window and burst-limit overrides', () => { - const records = new Map() - for (let i = 0; i < 10; i += 1) { - recordParkVerdictFlips({ - records, - liveTabIds: new Set([TAB]), - nextParkedTabIds: i % 2 === 0 ? new Set([TAB]) : new Set(), - nowMs: 1_000 + i * 100, - flipWindowMs: 5_000, - noticeLimit: 3, - burstWindowMs: 10, - burstLimit: 2 - }) - } - - // Why 'window': the 100ms step outruns the 10ms burst window, so the burst - // counter resets on every flip and only the notice limit can fire. - expect(recordBreadcrumb).toHaveBeenCalledTimes(1) - expect(recordBreadcrumb).toHaveBeenCalledWith( - 'terminal_park_verdict_churn', - expect.objectContaining({ trigger: 'window', flips: 3, windowMs: 5_000 }) - ) - }) - - it('keeps per-tab windows and notices independent', () => { - const records = new Map() - const other = 'tab-2' - for (let i = 0; i < 40; i += 1) { - recordParkVerdictFlips({ - records, - liveTabIds: new Set([TAB, other]), - // Why: TAB churns every call, other stays parked throughout. - nextParkedTabIds: i % 2 === 0 ? new Set([TAB, other]) : new Set([other]), - nowMs: 1_000 + i * 10 - }) - } - - expect(recordBreadcrumb).toHaveBeenCalledTimes(1) - expect(recordBreadcrumb).toHaveBeenCalledWith( - 'terminal_park_verdict_churn', - expect.objectContaining({ tabId: TAB }) - ) - expect(records.get(other)?.flips).toBe(0) - }) - - it('drops records for tabs that no longer exist', () => { - const records = new Map() - observe({ records, parked: true, nowMs: 1_000 }) - - recordParkVerdictFlips({ - records, - liveTabIds: new Set(), - nextParkedTabIds: new Set(), - nowMs: 2_000 - }) - - expect(records.size).toBe(0) - }) -}) - -describe('getParkVerdictUnparkPinUntilMs', () => { - function churnToBurst(records: Map, startMs = 1_000): number { - for (let i = 0; i < 40; i += 1) { - observe({ records, parked: i % 2 === 0, nowMs: startMs + i * 10 }) - } - // The first observation only seeds the record, so flip N lands one step later. - return startMs + TERMINAL_TAB_PARK_FLIP_BURST_LIMIT * 10 - } - - it('does not pin a stable verdict', () => { - const records = new Map() - observe({ records, parked: true, nowMs: 1_000 }) - - expect(getParkVerdictUnparkPinUntilMs({ records, tabId: TAB, nowMs: 2_000 })).toBeNull() - expect(getParkVerdictUnparkPinUntilMs({ records, tabId: 'missing', nowMs: 2_000 })).toBeNull() - }) - - // Why the deadline and not a boolean: the caller has to schedule a recheck at - // it, or the pin never lifts once it has stopped the churn that woke the - // verdict effect. - it('reports the pin deadline one window out, then re-arms', () => { - const records = new Map() - const pinnedAtMs = churnToBurst(records) - const pinUntilMs = pinnedAtMs + TERMINAL_TAB_PARK_FLIP_WINDOW_MS - - expect(getParkVerdictUnparkPinUntilMs({ records, tabId: TAB, nowMs: pinnedAtMs + 1 })).toBe( - pinUntilMs - ) - expect(getParkVerdictUnparkPinUntilMs({ records, tabId: TAB, nowMs: pinUntilMs })).toBeNull() - expect(records.get(TAB)?.flips).toBe(0) - expect(records.get(TAB)?.burstFlips).toBe(0) - - recordBreadcrumb.mockClear() - churnToBurst(records, pinUntilMs) - expect(recordBreadcrumb).toHaveBeenCalledTimes(1) - expect(getParkVerdictUnparkPinUntilMs({ records, tabId: TAB, nowMs: pinUntilMs + 1 })).not.toBe( - null - ) - }) - - // Why: a backwards clock jump must release the pin, not strand it for a window. - it('releases the pin when the clock jumps backwards', () => { - const records = new Map() - churnToBurst(records) - - expect(getParkVerdictUnparkPinUntilMs({ records, tabId: TAB, nowMs: 5 })).toBeNull() - }) - - // Why: the notice window starts at the first flip and the pin starts one - // burst later, so the notice window always lapses first. Resetting it must - // not hand the pane back to the parking policy mid-damping. - it('survives a notice-window expiry that lands mid-pin', () => { - const records = new Map() - const pinnedAtMs = churnToBurst(records) - const pinUntilMs = pinnedAtMs + TERMINAL_TAB_PARK_FLIP_WINDOW_MS - const windowLapseMs = 1_000 + TERMINAL_TAB_PARK_FLIP_WINDOW_MS - - // An exogenous flip (visibility change, tab removal) after the notice - // window lapsed but before the pin deadline. - expect(windowLapseMs).toBeLessThan(pinUntilMs) - observe({ records, parked: true, nowMs: windowLapseMs }) - - expect(records.get(TAB)?.flips).toBe(1) - expect(getParkVerdictUnparkPinUntilMs({ records, tabId: TAB, nowMs: windowLapseMs })).toBe( - pinUntilMs - ) - }) -}) - -// Why liveness and not presence: a pinned tab can stop being cold-park eligible -// before its deadline, and nothing consults getParkVerdictUnparkPinUntilMs for -// it again. A stale deadline must not silence churn telemetry forever. -describe('expired pins stop gating breadcrumbs', () => { - it('re-arms damping and notices without a getParkVerdictUnparkPinUntilMs call', () => { - const records = new Map() - for (let i = 0; i < 40; i += 1) { - observe({ records, parked: i % 2 === 0, nowMs: 1_000 + i * 10 }) - } - expect(recordBreadcrumb).toHaveBeenCalledTimes(1) - expect(recordBreadcrumb).toHaveBeenLastCalledWith( - 'terminal_park_verdict_churn', - expect.objectContaining({ trigger: 'burst' }) - ) - - // Churn resumes past the pin deadline; the pin was never read back. - const afterPinMs = 1_000 + TERMINAL_TAB_PARK_FLIP_WINDOW_MS * 2 - for (let i = 0; i < 40; i += 1) { - observe({ records, parked: i % 2 === 0, nowMs: afterPinMs + i * 10 }) - } - - expect(recordBreadcrumb).toHaveBeenCalledTimes(2) - expect(recordBreadcrumb).toHaveBeenLastCalledWith( - 'terminal_park_verdict_churn', - expect.objectContaining({ trigger: 'burst' }) - ) - expect(records.get(TAB)?.pinnedUntilMs).toBeGreaterThan(afterPinMs) - }) - - it('still reports slow churn after a pin lapses', () => { - const records = new Map() - for (let i = 0; i < 40; i += 1) { - observe({ records, parked: i % 2 === 0, nowMs: 1_000 + i * 10 }) - } - recordBreadcrumb.mockClear() - - // Slow churn only: each step outruns the burst window, so the notice limit - // is the only trigger left. It must not stay gated by the lapsed pin. - const afterPinMs = 1_000 + TERMINAL_TAB_PARK_FLIP_WINDOW_MS * 2 - for (let i = 0; i <= TERMINAL_TAB_PARK_FLIP_NOTICE_LIMIT; i += 1) { - observe({ records, parked: i % 2 === 0, nowMs: afterPinMs + i * SLOW_CHURN_STEP_MS }) - } - - expect(recordBreadcrumb).toHaveBeenCalledWith( - 'terminal_park_verdict_churn', - expect.objectContaining({ trigger: 'window' }) - ) + expect(previousParkedByTabId.size).toBe(0) + expect(recordBreadcrumb).not.toHaveBeenCalled() }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-park-verdict-flip-telemetry.ts b/src/renderer/src/components/terminal-pane/terminal-park-verdict-flip-telemetry.ts index 17b53d53785..63dfb4858da 100644 --- a/src/renderer/src/components/terminal-pane/terminal-park-verdict-flip-telemetry.ts +++ b/src/renderer/src/components/terminal-pane/terminal-park-verdict-flip-telemetry.ts @@ -1,172 +1,47 @@ -/** - * Cold-park verdict telemetry and a safe-side circuit breaker. - * Field breadcrumbs prove render-cadence flips, but not which eligibility input - * oscillates; burst damping keeps the pane mounted before React reaches #185. - * - * Scope: flips are counted on the rendered verdict, but the pin can only - * subtract from the cold-park candidate set. Churn driven by the other parked - * inputs (forced parking, portal ownership, deferred activation mounts) is - * observed and breadcrumbed, not damped — read a repeating `burst` crumb for - * one tab as "damping did not reach the oscillating input". - */ +import { useEffect, useRef } from 'react' import { recordRendererCrashBreadcrumb } from '@/lib/crash-breadcrumb-recorder' -export const TERMINAL_TAB_PARK_FLIP_WINDOW_MS = 60_000 -/** Flips per window that no sane park policy should reach. Breadcrumb only. */ -export const TERMINAL_TAB_PARK_FLIP_NOTICE_LIMIT = 12 - -/** react-dom 19.2.x nested commit limit. */ -const REACT_NESTED_UPDATE_LIMIT = 50 -/** Measured upper bound after the passive-effect pin engages. */ -const PARK_PIN_SETTLE_COMMITS = 6 -/** Worst-case commits from pane, watcher, and store work per verdict flip. */ -export const TERMINAL_TAB_PARK_FLIP_COMMIT_COST = 12 -/** Pin threshold derived from React's remaining commit budget. */ -export const TERMINAL_TAB_PARK_FLIP_BURST_LIMIT = Math.max( - 2, - Math.floor( - (REACT_NESTED_UPDATE_LIMIT - PARK_PIN_SETTLE_COMMITS) / TERMINAL_TAB_PARK_FLIP_COMMIT_COST - ) -) -/** Honest cold parking cannot round-trip inside this horizon. */ -export const TERMINAL_TAB_PARK_FLIP_BURST_WINDOW_MS = 1_000 - -export type ParkVerdictFlipRecord = { - parked: boolean - windowStartMs: number - flips: number - notified: boolean - burstStartMs: number - burstFlips: number - /** Set when a flip burst engaged damping; the verdict stays unparked until then. */ - pinnedUntilMs?: number | null -} - -// Why it leaves pinnedUntilMs alone: the notice window is 60s from the first -// flip, so it lapses mid-pin; clearing here would release damping early. -function resetFlipWindows(record: ParkVerdictFlipRecord, nowMs: number): void { - record.windowStartMs = nowMs - record.flips = 0 - record.notified = false - record.burstStartMs = nowMs - record.burstFlips = 0 -} - -// Why liveness and not presence: a tab pinned while cold-park-eligible can stop -// being a candidate before its deadline, and nothing would consult it again. An -// expired pin must stop damping and stop gating breadcrumbs on its own. -function isParkVerdictPinLive(record: ParkVerdictFlipRecord, nowMs: number): boolean { - return record.pinnedUntilMs != null && nowMs < record.pinnedUntilMs -} - -/** Returns the safe-side pin deadline and re-arms an expired window. */ -export function getParkVerdictUnparkPinUntilMs(args: { - records: Map - tabId: string - nowMs: number -}): number | null { - const record = args.records.get(args.tabId) - if (record?.pinnedUntilMs == null) { - return null - } - if (!isParkVerdictPinLive(record, args.nowMs) || args.nowMs < record.windowStartMs) { - resetFlipWindows(record, args.nowMs) - record.pinnedUntilMs = null - return null - } - return record.pinnedUntilMs -} - -/** Records park-verdict churn per tab; damps bursts and breadcrumbs the rest. */ +/** Emits final verdict changes; main-process coalescing bounds repeated flips. */ export function recordParkVerdictFlips(args: { - records: Map + previousParkedByTabId: Map liveTabIds: ReadonlySet nextParkedTabIds: ReadonlySet - nowMs: number - flipWindowMs?: number - noticeLimit?: number - burstWindowMs?: number - burstLimit?: number }): void { - const { - records, - liveTabIds, - nextParkedTabIds, - nowMs, - flipWindowMs = TERMINAL_TAB_PARK_FLIP_WINDOW_MS, - noticeLimit = TERMINAL_TAB_PARK_FLIP_NOTICE_LIMIT, - burstWindowMs = TERMINAL_TAB_PARK_FLIP_BURST_WINDOW_MS, - burstLimit = TERMINAL_TAB_PARK_FLIP_BURST_LIMIT - } = args - - for (const tabId of Array.from(records.keys())) { - if (!liveTabIds.has(tabId)) { - records.delete(tabId) + for (const tabId of Array.from(args.previousParkedByTabId.keys())) { + if (!args.liveTabIds.has(tabId)) { + args.previousParkedByTabId.delete(tabId) } } - for (const tabId of liveTabIds) { - const parked = nextParkedTabIds.has(tabId) - const record = records.get(tabId) - - if (!record) { - records.set(tabId, { - parked, - windowStartMs: nowMs, - flips: 0, - notified: false, - burstStartMs: nowMs, - burstFlips: 0, - pinnedUntilMs: null - }) + for (const tabId of args.liveTabIds) { + const parked = args.nextParkedTabIds.has(tabId) + const hadPreviousVerdict = args.previousParkedByTabId.has(tabId) + const previousParked = args.previousParkedByTabId.get(tabId) + args.previousParkedByTabId.set(tabId, parked) + if (!hadPreviousVerdict || previousParked === parked) { continue } - if (parked === record.parked) { - continue - } - - // Why: Date.now() jumps backwards on NTP/sleep-wake; treat any out-of-range - // elapsed value as a fresh window rather than trusting the delta. - const elapsedMs = nowMs - record.windowStartMs - if (elapsedMs >= flipWindowMs || elapsedMs < 0) { - resetFlipWindows(record, nowMs) - } - const burstElapsedMs = nowMs - record.burstStartMs - if (burstElapsedMs >= burstWindowMs || burstElapsedMs < 0) { - record.burstStartMs = nowMs - record.burstFlips = 0 - } - - record.parked = parked - record.flips += 1 - record.burstFlips += 1 - - if (!isParkVerdictPinLive(record, nowMs) && record.burstFlips >= burstLimit) { - record.pinnedUntilMs = nowMs + flipWindowMs - recordRendererCrashBreadcrumb('terminal_park_verdict_churn', { - tabId, - trigger: 'burst', - flips: record.burstFlips, - elapsedMs: nowMs - record.burstStartMs, - windowMs: burstWindowMs, - pinnedForMs: flipWindowMs - }) - continue - } - // Why a live pin gates this: the burst crumb already reported the same - // window, so a second crumb would only double the volume the notice limit - // exists to keep down. - if (!isParkVerdictPinLive(record, nowMs) && !record.notified && record.flips >= noticeLimit) { - record.notified = true - // Why: flips is always exactly noticeLimit here, so elapsedMs is the only - // field that separates slow churn from a burst the damping already caught. - recordRendererCrashBreadcrumb('terminal_park_verdict_churn', { - tabId, - trigger: 'window', - flips: record.flips, - elapsedMs: nowMs - record.windowStartMs, - windowMs: flipWindowMs - }) - } + recordRendererCrashBreadcrumb('terminal_park_verdict_churn', { + tabId, + trigger: 'flip', + parked + }) } } + +/** Observes the final prepare/activate handoff verdict without changing it. */ +export function useTerminalParkVerdictTelemetry(args: { + terminalTabs: readonly { id: string }[] + parkedTabIds: ReadonlySet +}): void { + const previousParkedByTabIdRef = useRef(new Map()) + const { parkedTabIds, terminalTabs } = args + + useEffect(() => { + recordParkVerdictFlips({ + previousParkedByTabId: previousParkedByTabIdRef.current, + liveTabIds: new Set(terminalTabs.map((terminalTab) => terminalTab.id)), + nextParkedTabIds: parkedTabIds + }) + }, [parkedTabIds, terminalTabs]) +} diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-pty-watcher.ts b/src/renderer/src/components/terminal-pane/terminal-parked-pty-watcher.ts index 4c091456e32..c9f8826e02d 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-pty-watcher.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-pty-watcher.ts @@ -1,6 +1,8 @@ import { isTerminalLeafId } from '../../../../shared/stable-pane-id' import { isRemoteRuntimePtyId, sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection' +import { toPtyAdministrativeMutationEvidence } from '../../../../shared/pty-mutation-identity' import { useAppStore } from '@/store' +import { writePtyWithAdministrativeMutationAccess } from '@/lib/pty-administrative-mutations' import { closeTerminalTab } from '../terminal/terminal-tab-actions' import { startParkedTerminalByteWatcher } from './parked-terminal-byte-watcher' import { subscribeToPtyExit } from './pty-dispatcher' @@ -17,6 +19,7 @@ import { } from './terminal-pane-close-identity' import { capturedPanesByTabId, + ensurePhasedParkedTabWatcherEntry, parkedWatchersByTabId, type ParkedTabWatcherEntry, type ParkedTerminalPaneCapture @@ -29,8 +32,9 @@ export function startParkedPtyWatcher(args: { entry: ParkedTabWatcherEntry restoreTitleOnRegister: boolean restorePolicy: TerminalParkRestorePolicy -}): void { - const { worktreeId, tab, pane, entry, restoreTitleOnRegister, restorePolicy } = args +}): boolean { + const { worktreeId, tab, pane, restoreTitleOnRegister, restorePolicy } = args + const entry = ensurePhasedParkedTabWatcherEntry(args.entry) const state = useAppStore.getState() const ptyId = pane.ptyId // Why: the tab model can change after the park decision, and legacy leaf ids make pane keys throw. @@ -40,26 +44,39 @@ export function startParkedPtyWatcher(args: { !isTerminalLeafId(pane.leafId) || !isParkRestorableTerminalPty(ptyId, worktreeId, restorePolicy) ) { - return + return false + } + const disposeCurrentWatcher = (retainPaneIdentity = false): void => { + entry.disposersByPtyId.get(ptyId)?.() + entry.disposersByPtyId.delete(ptyId) + entry.activateByPtyId.delete(ptyId) + entry.revealReplacementAttemptByPtyId.delete(ptyId) + entry.retainedRevealPtyIds.delete(ptyId) + if (!retainPaneIdentity) { + entry.paneCaptureByPtyId.delete(ptyId) + entry.paneIdByPtyId.delete(ptyId) + } } const handlePtyExit = (_code: number, { hadPrimary }: { hadPrimary: boolean }): void => { + if (entry.phase === 'prepared') { + entry.preparationValid = false + disposeCurrentWatcher() + return + } useAppStore.getState().clearRuntimePaneTitle(tab.id, pane.paneId) if (entry.disposersByPtyId.size > 1) { discardPreHandlerPtyState(ptyId) collapseParkedExitedLeaf(tab.id, ptyId) - entry.disposersByPtyId.get(ptyId)?.() - entry.disposersByPtyId.delete(ptyId) + disposeCurrentWatcher() return } if (hadPrimary) { - entry.disposersByPtyId.get(ptyId)?.() - entry.disposersByPtyId.delete(ptyId) + disposeCurrentWatcher() return } - // Why: the empty entry prevents a pending pinned-close confirmation from restarting the dead PTY. - entry.disposersByPtyId.get(ptyId)?.() - entry.disposersByPtyId.delete(ptyId) + // Why: the empty entry prevents a pending close confirmation from restarting the dead PTY. + disposeCurrentWatcher(true) closeTerminalTab(tab.id, { captureRecentlyClosed: false, hostCloseReason: 'pty-exit', @@ -74,8 +91,16 @@ export function startParkedPtyWatcher(args: { }) } const initialTitle = state.runtimePaneTitlesByTabId[tab.id]?.[pane.paneId] - const disposeWatcher = startParkedTerminalByteWatcher({ + const administrativeMutationAccess = pane.mutationIdentity + ? { + mode: 'exact' as const, + evidence: toPtyAdministrativeMutationEvidence(pane.mutationIdentity) + } + : null + const watcher = startParkedTerminalByteWatcher({ ptyId, + ...(pane.mutationIdentity ? { mutationIdentity: pane.mutationIdentity } : {}), + ...(pane.sideEffectIdentity ? { sideEffectIdentity: pane.sideEffectIdentity } : {}), tabId: tab.id, worktreeId, leafId: pane.leafId, @@ -84,17 +109,47 @@ export function startParkedPtyWatcher(args: { ...(initialTitle !== undefined ? { initialTitle } : {}), ...(restoreTitleOnRegister ? { restoreTitleOnRegister: true } : {}), sendInput: (data) => { - sendRuntimePtyInput(useAppStore.getState().settings, ptyId, data) + if (administrativeMutationAccess) { + writePtyWithAdministrativeMutationAccess(ptyId, data, administrativeMutationAccess) + } else { + sendRuntimePtyInput(useAppStore.getState().settings, ptyId, data) + } } }) - const unsubscribeExit = isRemoteRuntimePtyId(ptyId) - ? () => {} - : subscribeToPtyExit(ptyId, handlePtyExit) - entry.paneIdByPtyId.set(ptyId, pane.paneId) - entry.disposersByPtyId.set(ptyId, () => { - unsubscribeExit() - disposeWatcher() - }) + let unsubscribeExit: (() => void) | null = null + try { + unsubscribeExit = isRemoteRuntimePtyId(ptyId) + ? () => {} + : subscribeToPtyExit(ptyId, handlePtyExit) + if (!entry.preparationValid) { + throw new Error('parked_pty_exited_during_preparation') + } + entry.paneCaptureByPtyId.set(ptyId, pane) + entry.suspendedPtyIds.delete(ptyId) + entry.revealReplacementAttemptByPtyId.delete(ptyId) + entry.retainedRevealPtyIds.delete(ptyId) + entry.paneIdByPtyId.set(ptyId, pane.paneId) + entry.activateByPtyId.set(ptyId, watcher.activateParked) + entry.disposersByPtyId.set(ptyId, (options) => { + try { + unsubscribeExit?.() + } finally { + watcher.dispose(options) + } + }) + return true + } catch { + entry.paneCaptureByPtyId.delete(ptyId) + entry.paneIdByPtyId.delete(ptyId) + entry.activateByPtyId.delete(ptyId) + entry.disposersByPtyId.delete(ptyId) + try { + unsubscribeExit?.() + } finally { + watcher.dispose() + } + return false + } } export function collapseParkedExitedLeaf(tabId: string, ptyId: string): void { diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-side-effect-identity.ts b/src/renderer/src/components/terminal-pane/terminal-parked-side-effect-identity.ts new file mode 100644 index 00000000000..372be5a3b8d --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-parked-side-effect-identity.ts @@ -0,0 +1,27 @@ +import type { PtyIncarnationId } from '../../../../shared/pty-incarnation' +import type { PtyMutationIdentity } from '../../../../shared/pty-mutation-identity' + +export type ParkedTerminalSideEffectIdentity = { + incarnationId: PtyIncarnationId + paneGeneration: number +} + +export function parkedTerminalSideEffectIdentityFromMutation( + identity: PtyMutationIdentity | null | undefined +): ParkedTerminalSideEffectIdentity | null { + return identity?.paneGeneration === undefined + ? null + : { + incarnationId: identity.incarnationId, + paneGeneration: identity.paneGeneration + } +} + +export function parkedTerminalSideEffectIdentitiesEqual( + left: ParkedTerminalSideEffectIdentity | undefined, + right: ParkedTerminalSideEffectIdentity | undefined +): boolean { + return ( + left?.incarnationId === right?.incarnationId && left?.paneGeneration === right?.paneGeneration + ) +} diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts index 2e9b3a52ef2..9c20915c7f9 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts @@ -11,14 +11,16 @@ const SECOND_LEAF_ID = '22222222-2222-4222-8222-222222222222' type StartedWatcher = { options: ParkedTerminalByteWatcherOptions + activateParked: ReturnType dispose: ReturnType } const startedWatchers: StartedWatcher[] = [] const startParkedTerminalByteWatcher = vi.fn((options: ParkedTerminalByteWatcherOptions) => { + const activateParked = vi.fn(() => true) const dispose = vi.fn() - startedWatchers.push({ options, dispose }) - return dispose + startedWatchers.push({ options, activateParked, dispose }) + return { activateParked, dispose } }) vi.mock('./parked-terminal-byte-watcher', () => ({ @@ -112,18 +114,38 @@ import { disposeParkedTerminalWatchersForWorktree, getParkedTerminalWatcherTabIds, pruneParkedTerminalWatchers, + registerMountedTerminalPaneCandidateReader, shouldDeferParkedPtyExitTabClose, + activatePreparedParkedTerminalTabWatchers, syncParkedTerminalTabWatchers } from './terminal-parked-tab-watchers' const ptyWrite = vi.fn() const originalWindow = (globalThis as { window?: unknown }).window +const mountedReaderDisposersByTabId = new Map void>() + +function detachMountedReader(tabId = TAB_ID): void { + mountedReaderDisposersByTabId.get(tabId)?.() + mountedReaderDisposersByTabId.delete(tabId) +} function capturePanes( - panes: { ptyId: string | null; paneId: number; leafId: string; drivesTabTitle: boolean }[], + panes: { + ptyId: string | null + paneId: number + leafId: string + drivesTabTitle: boolean + }[], args?: { tabId?: string; worktreeId?: string } ): void { - captureParkedTerminalPaneCandidates(args?.tabId ?? TAB_ID, args?.worktreeId ?? WORKTREE_ID, panes) + const tabId = args?.tabId ?? TAB_ID + const worktreeId = args?.worktreeId ?? WORKTREE_ID + captureParkedTerminalPaneCandidates(tabId, worktreeId, panes) + mountedReaderDisposersByTabId.get(tabId)?.() + mountedReaderDisposersByTabId.set( + tabId, + registerMountedTerminalPaneCandidateReader(tabId, worktreeId, () => panes) + ) } function syncParked(args?: { @@ -132,14 +154,22 @@ function syncParked(args?: { parkedTabIds?: Iterable restoreTitleOnStartTabIds?: Iterable }): void { + const worktreeId = args?.worktreeId ?? WORKTREE_ID + const tabs = args?.tabs ?? [{ id: TAB_ID, ptyId: PTY_ID }] + const parkedTabIds = new Set(args?.parkedTabIds ?? [TAB_ID]) syncParkedTerminalTabWatchers({ - worktreeId: args?.worktreeId ?? WORKTREE_ID, - tabs: args?.tabs ?? [{ id: TAB_ID, ptyId: PTY_ID }], - parkedTabIds: new Set(args?.parkedTabIds ?? [TAB_ID]), + worktreeId, + tabs, + parkedTabIds, ...(args?.restoreTitleOnStartTabIds ? { restoreTitleOnStartTabIds: new Set(args.restoreTitleOnStartTabIds) } : {}) }) + activatePreparedParkedTerminalTabWatchers({ worktreeId, tabs, parkedTabIds }) + for (const tabId of parkedTabIds) { + mountedReaderDisposersByTabId.get(tabId)?.() + mountedReaderDisposersByTabId.delete(tabId) + } } describe('terminal-parked-tab-watchers', () => { @@ -168,6 +198,10 @@ describe('terminal-parked-tab-watchers', () => { // public prune path so each test starts from an empty parked state. pruneParkedTerminalWatchers(new Set()) startedWatchers.length = 0 + for (const dispose of mountedReaderDisposersByTabId.values()) { + dispose() + } + mountedReaderDisposersByTabId.clear() exitSubscriptions.length = 0 vi.clearAllMocks() clearTerminalProviderSnapshotCapabilities() @@ -214,15 +248,15 @@ describe('terminal-parked-tab-watchers', () => { expect(ptyWrite).toHaveBeenCalledWith(PTY_ID, '\x1b[?2031;1$y') }) - it('skips legacy non-UUID leaf ids instead of throwing in makePaneKey', () => { + it('keeps the whole tab mounted when any split leaf identity is invalid', () => { capturePanes([ { ptyId: PTY_ID, paneId: 1, leafId: 'legacy-leaf-1', drivesTabTitle: true }, { ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false } ]) syncParked() - expect(startParkedTerminalByteWatcher).toHaveBeenCalledTimes(1) - expect(startedWatchers[0].options).toMatchObject({ ptyId: SECOND_PTY_ID }) + expect(startParkedTerminalByteWatcher).not.toHaveBeenCalled() + expect(getParkedTerminalWatcherTabIds()).toEqual([]) }) it('never starts watchers for remote-runtime PTYs', () => { @@ -232,9 +266,7 @@ describe('terminal-parked-tab-watchers', () => { syncParked({ tabs: [{ id: TAB_ID, ptyId: null }] }) expect(startParkedTerminalByteWatcher).not.toHaveBeenCalled() - // Why: the tab is still tracked as parked so debug introspection - // (window.__terminalParkingDebug) reflects every parked tab. - expect(getParkedTerminalWatcherTabIds()).toEqual([TAB_ID]) + expect(getParkedTerminalWatcherTabIds()).toEqual([]) }) it('starts a fact watcher for snapshot-capable paired PTYs', () => { @@ -446,17 +478,21 @@ describe('terminal-parked-tab-watchers', () => { expect(startParkedTerminalByteWatcher).toHaveBeenCalledTimes(2) }) - it('restarts watchers from store layout when the tab PTY was re-minted', () => { + it('restarts watchers from store layout when the tab PTY was re-minted', async () => { capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) syncParked() const remintedPtyId = `${WORKTREE_ID}@@session-after-wake` + await synchronizeTerminalProviderSnapshotCapabilities([remintedPtyId], async (ids) => + ids.map((id) => ({ id, authoritative: true })) + ) mockStoreState.terminalLayoutsByTabId[TAB_ID] = { root: { type: 'leaf', leafId: LEAF_ID }, activeLeafId: LEAF_ID, expandedLeafId: null, ptyIdsByLeafId: { [LEAF_ID]: remintedPtyId } } + capturePanes([{ ptyId: remintedPtyId, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) syncParked({ tabs: [{ id: TAB_ID, ptyId: remintedPtyId }] }) expect(startedWatchers[0].dispose).toHaveBeenCalledTimes(1) @@ -802,6 +838,7 @@ describe('terminal-parked-tab-watchers', () => { it('accepts layout-derived candidates when the capture is stale', () => { capturePanes([{ ptyId: 'old-pty', paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) + detachMountedReader() mockStoreState.terminalLayoutsByTabId[TAB_ID] = { root: { type: 'leaf', leafId: LEAF_ID }, activeLeafId: LEAF_ID, diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts index 42de06cac37..e819abc5234 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts @@ -1,36 +1,33 @@ /** * Parked terminal tab watcher lifecycle. * - * Why: parking unmounts a tab's TerminalPane, so its PTYs lose the renderer byte - * parsers. This module runs a pane-less byte watcher per PTY while parked and - * disposes them on reveal, tab close, PTY exit, or worktree teardown. + * Parking is a two-phase handoff: prepare exact pane observers while mounted, + * then activate hidden delivery only after React commits the parked render. */ -import { isTerminalLeafId } from '../../../../shared/stable-pane-id' import { useAppStore } from '@/store' -import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection' -import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' import { discardPreHandlerPtyState } from './pty-pre-handler-buffer' -import { terminalProviderHasAuthoritativeSnapshot } from '../terminal/terminal-provider-snapshot-capability' +import { collapseParkedExitedLeaf } from './terminal-parked-pty-watcher' import { - isParkRestorableTerminalPty, - selectPairedRuntimeParkingEnvironmentIds, - type TerminalParkRestorePolicy -} from './terminal-hidden-view-parking' -import { - reconcileParkedWatcherPtyIds, - resolveParkedTerminalPaneCandidates, - type ParkableTerminalTabModel -} from './terminal-parked-watcher-reconciliation' -import { collapseParkedExitedLeaf, startParkedPtyWatcher } from './terminal-parked-pty-watcher' + activateParkedTerminalWatcherEntry, + disposeParkedTerminalWatcherEntry, + isParkedTerminalTabPreparationCurrent, + prepareParkedTerminalTabWatchers +} from './terminal-parked-watcher-preparation' +import type { ParkableTerminalTabModel } from './terminal-parked-watcher-reconciliation' import { capturedPanesByTabId, disposeParkedTabWatchers, + ensurePhasedParkedTabWatcherEntry, parkedWatchersByTabId, - type ParkedTabWatcherEntry, - type ParkedTerminalPaneCapture + readMountedTerminalPaneCandidates, + type ParkedTabWatcherEntry } from './terminal-parked-watcher-registry' -// Why: re-export so callers keep one import surface; the registry split only breaks the store-slice import cycle. +export { + canWatcherCoverParkedTerminalTab, + type ParkedTerminalPtyEligibility +} from './terminal-parked-watcher-preparation' +export { isParkedTerminalTabPreparationCurrent } export { captureParkedTerminalPaneCandidates, disposeAllParkedTerminalWatchers, @@ -39,176 +36,65 @@ export { disposeParkedTerminalWatchersForWorktree, getParkedTerminalWatcherTabIds, pruneParkedTerminalWatchers, + registerMountedTerminalPaneCandidateReader, terminalWatcherLiveWorkspaceIds } from './terminal-parked-watcher-registry' -export type { ParkedTerminalPaneCapture } from './terminal-parked-watcher-registry' +export type { + ParkedTabWatcherEntry, + ParkedTerminalPaneCapture +} from './terminal-parked-watcher-registry' export { fallbackParkedPaneCandidates, - resolveParkedTerminalPaneCandidates + resolveParkedTerminalPaneCandidates, + selectParkedTerminalPaneCandidateKey } from './terminal-parked-watcher-reconciliation' export type { ParkableTerminalTabModel } from './terminal-parked-watcher-reconciliation' -export type ParkedTerminalPtyEligibility = (ptyId: string) => boolean -const allowOrdinaryParkRestore = (ptyId: string): boolean => - isRemoteRuntimePtyId(ptyId) || - parseAppSshPtyId(ptyId) !== null || - terminalProviderHasAuthoritativeSnapshot(ptyId) - -// Why: fact-mode watchers work for any pty whose bytes transit local main — -// SSH included — so watcher coverage follows the park-restore policy, not the -// stricter daemon-snapshot predicate. -function parkRestorePolicyFromState(state: { - settings: { terminalSshViewParking?: boolean } | null - runtimeStatusByEnvironmentId: ReadonlyMap< - string, - { status: { capabilities?: readonly string[] } | null | undefined } - > -}): TerminalParkRestorePolicy { - return { - sshParkingEnabled: state.settings?.terminalSshViewParking !== false, - pairedRuntimeParkingEnvironmentIds: selectPairedRuntimeParkingEnvironmentIds( - state.runtimeStatusByEnvironmentId - ) - } +export function getParkedTerminalWatcherEntry(tabId: string): ParkedTabWatcherEntry | undefined { + return parkedWatchersByTabId.get(tabId) } -/** - * Whether parked byte watchers can fully cover this tab's PTYs (every candidate - * has a park-restorable PTY on a valid leaf). Hosts must refuse to park a tab - * that fails this check, or bell/title/completion side effects silently drop. - */ -export function canWatcherCoverParkedTerminalTab( - worktreeId: string, - tab: ParkableTerminalTabModel, - // Why: paired and SSH restore through their own policy; local parking must not - // unmount a pane whose preserved daemon can only return a lossy snapshot. - isPtyEligible: ParkedTerminalPtyEligibility = allowOrdinaryParkRestore -): boolean { - const state = useAppStore.getState() - const panes = resolveParkedTerminalPaneCandidates(tab, state) - const restorePolicy = parkRestorePolicyFromState(state) - return ( - panes.length > 0 && - panes.every( - (pane) => - pane.ptyId !== null && - isTerminalLeafId(pane.leafId) && - isParkRestorableTerminalPty(pane.ptyId, worktreeId, restorePolicy) && - isPtyEligible(pane.ptyId) - ) - ) -} - -function startParkedTabWatchers( - worktreeId: string, - tab: ParkableTerminalTabModel, - restoreTitleOnRegister: boolean -): void { - const entry: ParkedTabWatcherEntry = { - worktreeId, - tabPtyId: tab.ptyId, - paneIdByPtyId: new Map(), - disposersByPtyId: new Map() - } - parkedWatchersByTabId.set(tab.id, entry) - const restorePolicy = parkRestorePolicyFromState(useAppStore.getState()) - for (const pane of resolveParkedTerminalPaneCandidates(tab, useAppStore.getState())) { - startParkedPtyWatcher({ - worktreeId, - tab, - pane, - entry, - restoreTitleOnRegister, - restorePolicy - }) - } -} - -function reconcileParkedTabWatchers( - worktreeId: string, - tab: ParkableTerminalTabModel, - entry: ParkedTabWatcherEntry, - restoreTitleOnRegister: boolean -): void { - const state = useAppStore.getState() - const expectedPanes = watchablePanes(worktreeId, tab) - const expectedPtyIds = new Set(expectedPanes.keys()) - const reconciliation = reconcileParkedWatcherPtyIds({ - currentTabPtyId: tab.ptyId, - entryTabPtyId: entry.tabPtyId, - paneIdByPtyId: entry.paneIdByPtyId, - expectedPtyIds - }) - if (reconciliation.restartAll) { - const retainedTitles = reconciliation.retainedPtyIds.flatMap((ptyId) => { - const paneId = entry.paneIdByPtyId.get(ptyId) - const title = - paneId === undefined ? undefined : state.runtimePaneTitlesByTabId[tab.id]?.[paneId] - return paneId !== undefined && title !== undefined ? [{ paneId, title }] : [] - }) - for (const paneId of reconciliation.retiredPaneIds) { - state.clearRuntimePaneTitle(tab.id, paneId) - } - disposeParkedTabWatchers(tab.id) - for (const { paneId, title } of retainedTitles) { - useAppStore.getState().setRuntimePaneTitle(tab.id, paneId, title) - } - startParkedTabWatchers(worktreeId, tab, restoreTitleOnRegister) - return - } - for (const [ptyId, paneId] of Array.from(entry.paneIdByPtyId)) { - if (expectedPtyIds.has(ptyId)) { +export function activatePreparedParkedTerminalTabWatchers(args: { + worktreeId: string + tabs: readonly ParkableTerminalTabModel[] + parkedTabIds: ReadonlySet +}): Set { + const failedTabIds = new Set() + for (const tab of args.tabs) { + if ( + !args.parkedTabIds.has(tab.id) || + !isParkedTerminalTabPreparationCurrent(args.worktreeId, tab) + ) { continue } - entry.paneIdByPtyId.delete(ptyId) - const dispose = entry.disposersByPtyId.get(ptyId) - entry.disposersByPtyId.delete(ptyId) - dispose?.() - state.clearRuntimePaneTitle(tab.id, paneId) - } - const restorePolicy = parkRestorePolicyFromState(useAppStore.getState()) - for (const ptyId of reconciliation.addedPtyIds) { - const pane = expectedPanes.get(ptyId) - if (pane) { - startParkedPtyWatcher({ - worktreeId, - tab, - pane, + const registeredEntry = parkedWatchersByTabId.get(tab.id) + if (!registeredEntry) { + continue + } + const entry = ensurePhasedParkedTabWatcherEntry(registeredEntry) + if (entry.phase === 'parked') { + continue + } + if ( + !activateParkedTerminalWatcherEntry( + tab.id, entry, - restoreTitleOnRegister, - restorePolicy - }) + readMountedTerminalPaneCandidates(tab.id, args.worktreeId) + ) + ) { + failedTabIds.add(tab.id) } } + return failedTabIds } -function watchablePanes( - worktreeId: string, - tab: ParkableTerminalTabModel -): Map { - const state = useAppStore.getState() - const restorePolicy = parkRestorePolicyFromState(state) - return new Map( - resolveParkedTerminalPaneCandidates(tab, state).flatMap((pane) => - pane.ptyId && - isTerminalLeafId(pane.leafId) && - isParkRestorableTerminalPty(pane.ptyId, worktreeId, restorePolicy) - ? [[pane.ptyId, pane] as const] - : [] - ) - ) -} - -/** - * Called from hosts' onPtyExit before closing the tab; returns true to defer. - * A parked tab has no PaneManager to promote split siblings, so the live exit - * path would close the whole tab and kill surviving siblings — reveal remount - * handles dead PTYs per leaf instead. Single-leaf tabs return false to keep - * exit→closeTab parity. Also clears the dead leaf's runtime-title slot. - */ export function shouldDeferParkedPtyExitTabClose(tabId: string, ptyId: string): boolean { - const entry = parkedWatchersByTabId.get(tabId) - if (!entry) { + const registeredEntry = parkedWatchersByTabId.get(tabId) + if (!registeredEntry) { + return false + } + const entry = ensurePhasedParkedTabWatcherEntry(registeredEntry) + if (entry.phase !== 'parked') { return false } const paneId = entry.paneIdByPtyId.get(ptyId) @@ -218,13 +104,11 @@ export function shouldDeferParkedPtyExitTabClose(tabId: string, ptyId: string): const remaining = entry.disposersByPtyId.size if (remaining === 0) { if (paneId !== undefined) { - // Why: empty entry is the pinned-close tombstone; the reveal-mounted pane owns the exit, so suppress once and drop it. parkedWatchersByTabId.delete(tabId) return true } return false } - // Why: runs before the sidecar removes the dead watcher, so >1 (or an unwatched PTY) means live siblings remain. const defer = remaining > 1 || !entry.disposersByPtyId.has(ptyId) if (defer) { collapseParkedExitedLeaf(tabId, ptyId) @@ -232,11 +116,10 @@ export function shouldDeferParkedPtyExitTabClose(tabId: string, ptyId: string): return defer } -function disposeClosedParkedTabWatchers( +function disposeClosedTabWatchers( tabId: string, entry: { paneIdByPtyId: ReadonlyMap } ): void { - // Why: a queued pinned-close may close the tab first, leaving no pane to drain retained frames. for (const ptyId of entry.paneIdByPtyId.keys()) { discardPreHandlerPtyState(ptyId) } @@ -246,47 +129,56 @@ function disposeClosedParkedTabWatchers( disposeParkedTabWatchers(tabId) } -/** - * Reconciles watchers for one worktree against its rendered parked set. - * Run from an effect keyed on committed render state so disposal shares the - * reveal remount's flush (before PTY data IPC) and start follows the park unmount. - */ export function syncParkedTerminalTabWatchers(args: { worktreeId: string tabs: readonly ParkableTerminalTabModel[] parkedTabIds: ReadonlySet - /** Parked-equivalent tabs whose pane has not restored the current title. */ + desiredParkedTabIds?: ReadonlySet restoreTitleOnStartTabIds?: ReadonlySet -}): void { +}): Set { + const desiredParkedTabIds = args.desiredParkedTabIds ?? args.parkedTabIds const liveTabIds = new Set(args.tabs.map((tab) => tab.id)) - for (const [tabId, entry] of parkedWatchersByTabId) { + for (const [tabId, registeredEntry] of parkedWatchersByTabId) { + const entry = ensurePhasedParkedTabWatcherEntry(registeredEntry) if (entry.worktreeId !== args.worktreeId) { continue } if (!liveTabIds.has(tabId)) { - disposeClosedParkedTabWatchers(tabId, entry) + disposeClosedTabWatchers(tabId, entry) continue } - if (!args.parkedTabIds.has(tabId) && entry.disposersByPtyId.size > 0) { - disposeParkedTabWatchers(tabId) + if (!desiredParkedTabIds.has(tabId) && entry.disposersByPtyId.size > 0) { + disposeParkedTerminalWatcherEntry( + tabId, + entry, + readMountedTerminalPaneCandidates(tabId, args.worktreeId), + entry.retainedRevealPtyIds + ) } } - // Why: closed tabs never park/reveal again; drop captures to keep the registry bounded. for (const [tabId, capture] of capturedPanesByTabId) { if (capture.worktreeId === args.worktreeId && !liveTabIds.has(tabId)) { capturedPanesByTabId.delete(tabId) } } + const preparedTabIds = new Set() for (const tab of args.tabs) { - if (!args.parkedTabIds.has(tab.id)) { + if (!desiredParkedTabIds.has(tab.id)) { continue } - const entry = parkedWatchersByTabId.get(tab.id) + const registeredEntry = parkedWatchersByTabId.get(tab.id) + const entry = registeredEntry ? ensurePhasedParkedTabWatcherEntry(registeredEntry) : undefined const restoreTitleOnRegister = args.restoreTitleOnStartTabIds?.has(tab.id) === true - if (entry) { - reconcileParkedTabWatchers(args.worktreeId, tab, entry, restoreTitleOnRegister) - } else { - startParkedTabWatchers(args.worktreeId, tab, restoreTitleOnRegister) + if ( + prepareParkedTerminalTabWatchers({ + worktreeId: args.worktreeId, + tab, + allowDurableFallback: entry?.phase === 'parked' || restoreTitleOnRegister, + restoreTitleOnRegister + }) + ) { + preparedTabIds.add(tab.id) } } + return preparedTabIds } diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-watcher-handoff.test.ts b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-handoff.test.ts new file mode 100644 index 00000000000..4538510962d --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-handoff.test.ts @@ -0,0 +1,334 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ParkedTerminalByteWatcherOptions } from './parked-terminal-byte-watcher' + +const WORKTREE_ID = 'repo::/worktree' +const TAB_ID = 'tab-1' +const PTY_ID = 'ssh:connection-1@@pty-1' +const SECOND_PTY_ID = 'ssh:connection-1@@pty-2' +const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const SECOND_LEAF_ID = '22222222-2222-4222-8222-222222222222' + +type StartedWatcher = { + options: ParkedTerminalByteWatcherOptions + activateParked: ReturnType + dispose: ReturnType +} + +type ExitSubscription = { + ptyId: string + callback: (code: number, context: { hadPrimary: boolean }) => void +} + +const { + closeTerminalTab, + activationFailurePtyIds, + exitSubscriptionFailurePtyIds, + exitSubscriptions, + mockStoreState, + startedWatchers, + watcherStartFailurePtyIds +} = vi.hoisted(() => ({ + closeTerminalTab: vi.fn(), + activationFailurePtyIds: new Set(), + exitSubscriptionFailurePtyIds: new Set(), + exitSubscriptions: [] as ExitSubscription[], + mockStoreState: { + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + runtimePaneTitlesByTabId: {}, + settings: null, + runtimeStatusByEnvironmentId: new Map(), + clearTabLaunchAgent: vi.fn(), + clearRuntimePaneTitle: vi.fn(), + setTabLayout: vi.fn(), + updateTabTitle: vi.fn() + }, + startedWatchers: [] as StartedWatcher[], + watcherStartFailurePtyIds: new Set() +})) + +vi.mock('./parked-terminal-byte-watcher', () => ({ + startParkedTerminalByteWatcher: (options: ParkedTerminalByteWatcherOptions) => { + if (watcherStartFailurePtyIds.has(options.ptyId)) { + throw new Error('watcher start failed') + } + const watcher = { + options, + activateParked: vi.fn(() => !activationFailurePtyIds.has(options.ptyId)), + dispose: vi.fn() + } + startedWatchers.push(watcher) + return watcher + } +})) + +vi.mock('./pty-dispatcher', () => ({ + subscribeToPtyExit: ( + ptyId: string, + callback: (code: number, context: { hadPrimary: boolean }) => void + ) => { + if (exitSubscriptionFailurePtyIds.has(ptyId)) { + throw new Error('exit subscription failed') + } + exitSubscriptions.push({ ptyId, callback }) + return vi.fn() + } +})) + +vi.mock('../terminal/terminal-tab-actions', () => ({ closeTerminalTab })) + +vi.mock('@/store', () => ({ useAppStore: { getState: () => mockStoreState } })) + +import { + activatePreparedParkedTerminalTabWatchers, + getParkedTerminalWatcherTabIds, + pruneParkedTerminalWatchers, + registerMountedTerminalPaneCandidateReader, + syncParkedTerminalTabWatchers +} from './terminal-parked-tab-watchers' +import { + beginParkedTerminalWatcherReplacement, + type ParkedTerminalPaneCapture +} from './terminal-parked-watcher-registry' + +const panes: ParkedTerminalPaneCapture[] = [ + { ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }, + { ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false } +] +const tabs = [{ id: TAB_ID, ptyId: PTY_ID }] +let disposeMountedReader: (() => void) | null = null + +function registerMountedReader( + mountedPanes: typeof panes = panes +): ReturnType { + disposeMountedReader?.() + disposeMountedReader = registerMountedTerminalPaneCandidateReader( + TAB_ID, + WORKTREE_ID, + () => mountedPanes + ) + return disposeMountedReader +} + +function prepare(mountedPanes: typeof panes = panes): Set { + registerMountedReader(mountedPanes) + return syncParkedTerminalTabWatchers({ + worktreeId: WORKTREE_ID, + tabs, + parkedTabIds: new Set(), + desiredParkedTabIds: new Set([TAB_ID]) + }) +} + +describe('parked terminal watcher handoff', () => { + const originalWindow = (globalThis as { window?: unknown }).window + const administrativeWrite = vi.fn() + + beforeEach(() => { + ;(globalThis as { window?: unknown }).window = { + api: { pty: { write: vi.fn(), administrativeWrite } } + } + }) + + afterEach(() => { + disposeMountedReader?.() + disposeMountedReader = null + pruneParkedTerminalWatchers(new Set()) + startedWatchers.length = 0 + exitSubscriptions.length = 0 + watcherStartFailurePtyIds.clear() + activationFailurePtyIds.clear() + exitSubscriptionFailurePtyIds.clear() + vi.clearAllMocks() + ;(globalThis as { window?: unknown }).window = originalWindow + }) + + it('prepares every split watcher before activating the parked phase', () => { + expect(prepare()).toEqual(new Set([TAB_ID])) + expect(startedWatchers).toHaveLength(2) + expect( + startedWatchers.every(({ activateParked }) => activateParked.mock.calls.length === 0) + ).toBe(true) + + activatePreparedParkedTerminalTabWatchers({ + worktreeId: WORKTREE_ID, + tabs, + parkedTabIds: new Set([TAB_ID]) + }) + expect( + startedWatchers.every(({ activateParked }) => activateParked.mock.calls.length === 1) + ).toBe(true) + }) + + it('leaves split-exit policy to the mounted primary during preparation', () => { + prepare() + exitSubscriptions + .find(({ ptyId }) => ptyId === SECOND_PTY_ID) + ?.callback(0, { hadPrimary: true }) + + expect(startedWatchers[1].dispose).toHaveBeenCalledOnce() + expect(mockStoreState.setTabLayout).not.toHaveBeenCalled() + expect(closeTerminalTab).not.toHaveBeenCalled() + }) + + it('rolls back every partial watcher when split preparation fails', () => { + watcherStartFailurePtyIds.add(SECOND_PTY_ID) + + expect(prepare()).toEqual(new Set()) + expect(startedWatchers[0].dispose).toHaveBeenCalledOnce() + expect(getParkedTerminalWatcherTabIds()).toEqual([]) + }) + + it('disposes a constructed byte watcher when exit subscription fails', () => { + exitSubscriptionFailurePtyIds.add(SECOND_PTY_ID) + + expect(prepare()).toEqual(new Set()) + expect(startedWatchers).toHaveLength(2) + expect(startedWatchers[0].dispose).toHaveBeenCalledOnce() + expect(startedWatchers[1].dispose).toHaveBeenCalledOnce() + expect(getParkedTerminalWatcherTabIds()).toEqual([]) + }) + + it('rolls back every split leaf when one activation fails', () => { + expect(prepare()).toEqual(new Set([TAB_ID])) + activationFailurePtyIds.add(SECOND_PTY_ID) + + expect( + activatePreparedParkedTerminalTabWatchers({ + worktreeId: WORKTREE_ID, + tabs, + parkedTabIds: new Set([TAB_ID]) + }) + ).toEqual(new Set([TAB_ID])) + expect(startedWatchers[0].activateParked).toHaveBeenCalledOnce() + expect(startedWatchers[0].dispose).toHaveBeenCalledOnce() + expect(startedWatchers[1].dispose).toHaveBeenCalledOnce() + expect(getParkedTerminalWatcherTabIds()).toEqual([]) + }) + + it('keeps the active parked entry when its replacement cannot fully prepare', () => { + prepare() + activatePreparedParkedTerminalTabWatchers({ + worktreeId: WORKTREE_ID, + tabs, + parkedTabIds: new Set([TAB_ID]) + }) + watcherStartFailurePtyIds.add(SECOND_PTY_ID) + registerMountedReader([ + { ...panes[0], paneId: 3 }, + { ...panes[1], paneId: 4 } + ]) + + expect( + syncParkedTerminalTabWatchers({ + worktreeId: WORKTREE_ID, + tabs, + parkedTabIds: new Set([TAB_ID]), + desiredParkedTabIds: new Set([TAB_ID]) + }) + ).toEqual(new Set()) + + expect(startedWatchers[0].dispose).not.toHaveBeenCalled() + expect(startedWatchers[1].dispose).not.toHaveBeenCalled() + expect(startedWatchers[2].dispose).toHaveBeenCalledOnce() + expect(getParkedTerminalWatcherTabIds()).toEqual([TAB_ID]) + }) + + it('fences a parked reply to the captured incarnation when the PTY id is reused', () => { + prepare([ + { + ...panes[0], + mutationIdentity: { + incarnationId: 'incarnation-before-reuse', + paneGeneration: 7, + mutationLeaseId: 'lease-before-reuse' + }, + sideEffectIdentity: { + incarnationId: 'incarnation-before-reuse', + paneGeneration: 7 + } + } + ]) + + startedWatchers[0].options.sendInput('\x1b[?997;1n') + + expect(administrativeWrite).toHaveBeenCalledWith(PTY_ID, '\x1b[?997;1n', { + mode: 'exact', + evidence: { incarnationId: 'incarnation-before-reuse', paneGeneration: 7 } + }) + expect(window.api.pty.write).not.toHaveBeenCalled() + expect(startedWatchers[0].options.sideEffectIdentity).toEqual({ + incarnationId: 'incarnation-before-reuse', + paneGeneration: 7 + }) + }) + + it('preserves a runtime-title slot already owned by the revealed successor', () => { + prepare() + activatePreparedParkedTerminalTabWatchers({ + worktreeId: WORKTREE_ID, + tabs, + parkedTabIds: new Set([TAB_ID]) + }) + registerMountedReader() + + syncParkedTerminalTabWatchers({ + worktreeId: WORKTREE_ID, + tabs, + parkedTabIds: new Set(), + desiredParkedTabIds: new Set() + }) + + expect(startedWatchers[0].dispose).toHaveBeenCalledWith({ preserveRuntimeTitle: true }) + expect(mockStoreState.clearRuntimePaneTitle).not.toHaveBeenCalledWith(TAB_ID, 1) + }) + + it('keeps the parked owner live until the revealed successor commits', () => { + prepare() + activatePreparedParkedTerminalTabWatchers({ + worktreeId: WORKTREE_ID, + tabs, + parkedTabIds: new Set([TAB_ID]) + }) + const replacement = beginParkedTerminalWatcherReplacement(TAB_ID, PTY_ID) + expect(replacement).not.toBeNull() + + syncParkedTerminalTabWatchers({ + worktreeId: WORKTREE_ID, + tabs, + parkedTabIds: new Set(), + desiredParkedTabIds: new Set() + }) + + expect(startedWatchers[0].dispose).not.toHaveBeenCalled() + expect(startedWatchers[1].dispose).toHaveBeenCalledOnce() + const installSuccessor = vi.fn(() => true) + expect(replacement?.commit(installSuccessor)).toBe(true) + expect(installSuccessor).toHaveBeenCalledOnce() + expect(startedWatchers[0].dispose).toHaveBeenCalledWith({ preserveRuntimeTitle: true }) + expect(getParkedTerminalWatcherTabIds()).toEqual([]) + }) + + it('leaves the predecessor reachable when replacement aborts or is superseded', () => { + prepare() + activatePreparedParkedTerminalTabWatchers({ + worktreeId: WORKTREE_ID, + tabs, + parkedTabIds: new Set([TAB_ID]) + }) + const stale = beginParkedTerminalWatcherReplacement(TAB_ID, PTY_ID) + const current = beginParkedTerminalWatcherReplacement(TAB_ID, PTY_ID) + + expect(stale?.commit(() => true)).toBe(false) + current?.abort() + syncParkedTerminalTabWatchers({ + worktreeId: WORKTREE_ID, + tabs, + parkedTabIds: new Set(), + desiredParkedTabIds: new Set() + }) + + expect(startedWatchers[0].dispose).not.toHaveBeenCalled() + expect(getParkedTerminalWatcherTabIds()).toEqual([TAB_ID]) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-watcher-partial-reconciliation.test.ts b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-partial-reconciliation.test.ts index d5fd24ecae3..27802153a06 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-watcher-partial-reconciliation.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-partial-reconciliation.test.ts @@ -29,7 +29,13 @@ vi.mock('./terminal-parked-pty-watcher', () => ({ const dispose = vi.fn() startedWatchers.push({ pane: args.pane, dispose }) args.entry.paneIdByPtyId.set(args.pane.ptyId, args.pane.paneId) + args.entry.paneCaptureByPtyId?.set(args.pane.ptyId, args.pane) + args.entry.activateByPtyId?.set( + args.pane.ptyId, + vi.fn(() => true) + ) args.entry.disposersByPtyId.set(args.pane.ptyId, dispose) + return true } })) @@ -37,6 +43,10 @@ vi.mock('./pty-pre-handler-buffer', () => ({ discardPreHandlerPtyState: vi.fn() })) +vi.mock('../terminal/terminal-provider-snapshot-capability', () => ({ + terminalProviderHasAuthoritativeSnapshot: () => true +})) + const state = { tabsByWorktree: {}, terminalLayoutsByTabId: {} as Record, @@ -52,8 +62,10 @@ vi.mock('@/store', () => ({ })) import { + activatePreparedParkedTerminalTabWatchers, captureParkedTerminalPaneCandidates, pruneParkedTerminalWatchers, + registerMountedTerminalPaneCandidateReader, syncParkedTerminalTabWatchers } from './terminal-parked-tab-watchers' @@ -76,12 +88,24 @@ afterEach(() => { pruneParkedTerminalWatchers(new Set()) }) -it('retains a continuing watcher and title while reconciling a reminted split leaf', () => { - captureParkedTerminalPaneCandidates(TAB_ID, WORKTREE_ID, [ +it('replaces the exact watcher set while preserving a continuing pane title', () => { + const mountedPanes = [ { ptyId: FIRST_PTY_ID, paneId: 1, leafId: FIRST_LEAF_ID, drivesTabTitle: true }, { ptyId: OLD_SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false } - ]) + ] + captureParkedTerminalPaneCandidates(TAB_ID, WORKTREE_ID, mountedPanes) + const unregisterReader = registerMountedTerminalPaneCandidateReader( + TAB_ID, + WORKTREE_ID, + () => mountedPanes + ) sync() + activatePreparedParkedTerminalTabWatchers({ + worktreeId: WORKTREE_ID, + tabs: [{ id: TAB_ID, ptyId: FIRST_PTY_ID }], + parkedTabIds: new Set([TAB_ID]) + }) + unregisterReader() state.terminalLayoutsByTabId[TAB_ID] = { root: { @@ -97,11 +121,20 @@ it('retains a continuing watcher and title while reconciling a reminted split le [SECOND_LEAF_ID]: NEW_SECOND_PTY_ID } } + const unregisterReplacementReader = registerMountedTerminalPaneCandidateReader( + TAB_ID, + WORKTREE_ID, + () => [ + { ptyId: FIRST_PTY_ID, paneId: 1, leafId: FIRST_LEAF_ID, drivesTabTitle: true }, + { ptyId: NEW_SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false } + ] + ) sync() + unregisterReplacementReader() - expect(startedWatchers[0].dispose).not.toHaveBeenCalled() + expect(startedWatchers[0].dispose).toHaveBeenCalledOnce() expect(startedWatchers[1].dispose).toHaveBeenCalledOnce() - expect(startedWatchers[2].pane).toMatchObject({ + expect(startedWatchers[3].pane).toMatchObject({ ptyId: NEW_SECOND_PTY_ID, paneId: 2, drivesTabTitle: false diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-watcher-preparation.ts b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-preparation.ts new file mode 100644 index 00000000000..d8875b0a0bf --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-preparation.ts @@ -0,0 +1,291 @@ +import { isTerminalLeafId } from '../../../../shared/stable-pane-id' +import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' +import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection' +import { useAppStore } from '@/store' +import { terminalProviderHasAuthoritativeSnapshot } from '../terminal/terminal-provider-snapshot-capability' +import { + isParkRestorableTerminalPty, + selectPairedRuntimeParkingEnvironmentIds, + type TerminalParkRestorePolicy +} from './terminal-hidden-view-parking' +import { startParkedPtyWatcher } from './terminal-parked-pty-watcher' +import { + resolveParkedTerminalPaneCandidates, + type ParkableTerminalTabModel +} from './terminal-parked-watcher-reconciliation' +import { + ensurePhasedParkedTabWatcherEntry, + parkedWatchersByTabId, + readMountedTerminalPaneCandidates, + type ParkedTerminalPaneCapture, + type PhasedParkedTabWatcherEntry +} from './terminal-parked-watcher-registry' +import { parkedTerminalSideEffectIdentitiesEqual } from './terminal-parked-side-effect-identity' + +export type ParkedTerminalPtyEligibility = (ptyId: string) => boolean + +const allowOrdinaryParkRestore = (ptyId: string): boolean => + isRemoteRuntimePtyId(ptyId) || + parseAppSshPtyId(ptyId) !== null || + terminalProviderHasAuthoritativeSnapshot(ptyId) + +function parkRestorePolicyFromState(state: { + settings: { terminalSshViewParking?: boolean } | null + runtimeStatusByEnvironmentId: ReadonlyMap< + string, + { status: { capabilities?: readonly string[] } | null | undefined } + > +}): TerminalParkRestorePolicy { + return { + sshParkingEnabled: state.settings?.terminalSshViewParking !== false, + pairedRuntimeParkingEnvironmentIds: selectPairedRuntimeParkingEnvironmentIds( + state.runtimeStatusByEnvironmentId + ) + } +} + +function validatePaneCandidates( + worktreeId: string, + panes: readonly ParkedTerminalPaneCapture[], + restorePolicy: TerminalParkRestorePolicy, + isPtyEligible: ParkedTerminalPtyEligibility = allowOrdinaryParkRestore +): Map | null { + const panesByPtyId = new Map() + const leafIds = new Set() + for (const pane of panes) { + if ( + !pane.ptyId || + !isTerminalLeafId(pane.leafId) || + leafIds.has(pane.leafId) || + panesByPtyId.has(pane.ptyId) || + !isParkRestorableTerminalPty(pane.ptyId, worktreeId, restorePolicy) || + !isPtyEligible(pane.ptyId) + ) { + return null + } + leafIds.add(pane.leafId) + panesByPtyId.set(pane.ptyId, pane) + } + return panesByPtyId.size > 0 ? panesByPtyId : null +} + +function readPreparationCandidates(args: { + worktreeId: string + tab: ParkableTerminalTabModel + allowDurableFallback: boolean +}): { + mountedPanes: ParkedTerminalPaneCapture[] | null + panesByPtyId: Map | null +} { + const state = useAppStore.getState() + const mountedPanes = readMountedTerminalPaneCandidates(args.tab.id, args.worktreeId) + const panes = + mountedPanes ?? + (args.allowDurableFallback ? resolveParkedTerminalPaneCandidates(args.tab, state) : []) + return { + mountedPanes, + panesByPtyId: validatePaneCandidates(args.worktreeId, panes, parkRestorePolicyFromState(state)) + } +} + +export function canWatcherCoverParkedTerminalTab( + worktreeId: string, + tab: ParkableTerminalTabModel, + isPtyEligible: ParkedTerminalPtyEligibility = allowOrdinaryParkRestore +): boolean { + const state = useAppStore.getState() + const panes = + readMountedTerminalPaneCandidates(tab.id, worktreeId) ?? + resolveParkedTerminalPaneCandidates(tab, state) + return ( + validatePaneCandidates(worktreeId, panes, parkRestorePolicyFromState(state), isPtyEligible) !== + null + ) +} + +function entryMatchesPreparation( + tab: ParkableTerminalTabModel, + entry: PhasedParkedTabWatcherEntry, + panesByPtyId: ReadonlyMap +): boolean { + if ( + !entry.preparationValid || + entry.tabPtyId !== tab.ptyId || + entry.paneCaptureByPtyId.size !== panesByPtyId.size || + entry.disposersByPtyId.size + entry.suspendedPtyIds.size !== panesByPtyId.size + ) { + return false + } + for (const [ptyId, pane] of panesByPtyId) { + const prepared = entry.paneCaptureByPtyId.get(ptyId) + if ( + !prepared || + prepared.paneId !== pane.paneId || + prepared.leafId !== pane.leafId || + (prepared.mutationIdentity === undefined) !== (pane.mutationIdentity === undefined) || + (prepared.mutationIdentity !== undefined && + (prepared.mutationIdentity.incarnationId !== pane.mutationIdentity?.incarnationId || + prepared.mutationIdentity.paneGeneration !== pane.mutationIdentity?.paneGeneration || + prepared.mutationIdentity.mutationLeaseId !== pane.mutationIdentity?.mutationLeaseId)) || + !parkedTerminalSideEffectIdentitiesEqual( + prepared.sideEffectIdentity, + pane.sideEffectIdentity + ) || + prepared.drivesTabTitle !== pane.drivesTabTitle || + (!entry.disposersByPtyId.has(ptyId) && !entry.suspendedPtyIds.has(ptyId)) + ) { + return false + } + } + return true +} + +function startPreparedTabWatchers(args: { + worktreeId: string + tab: ParkableTerminalTabModel + panesByPtyId: ReadonlyMap + restoreTitleOnRegister: boolean + usesDurableCandidates: boolean +}): PhasedParkedTabWatcherEntry { + const entry: PhasedParkedTabWatcherEntry = { + worktreeId: args.worktreeId, + tabPtyId: args.tab.ptyId, + phase: 'prepared', + preparationValid: true, + usesDurableCandidates: args.usesDurableCandidates, + paneCaptureByPtyId: new Map(), + suspendedPtyIds: new Set(), + paneIdByPtyId: new Map(), + activateByPtyId: new Map(), + disposersByPtyId: new Map(), + revealReplacementAttemptByPtyId: new Map(), + retainedRevealPtyIds: new Set() + } + const restorePolicy = parkRestorePolicyFromState(useAppStore.getState()) + for (const pane of args.panesByPtyId.values()) { + try { + entry.preparationValid &&= startParkedPtyWatcher({ + worktreeId: args.worktreeId, + tab: args.tab, + pane, + entry, + restoreTitleOnRegister: args.restoreTitleOnRegister, + restorePolicy + }) + } catch { + entry.preparationValid = false + } + } + entry.preparationValid &&= entry.disposersByPtyId.size === args.panesByPtyId.size + return entry +} + +export function activateParkedTerminalWatcherEntry( + tabId: string, + entry: PhasedParkedTabWatcherEntry, + mountedPanes: readonly ParkedTerminalPaneCapture[] | null +): boolean { + for (const activate of entry.activateByPtyId.values()) { + try { + if (!activate()) { + throw new Error('parked_watcher_activation_rejected') + } + } catch { + entry.preparationValid = false + disposeParkedTerminalWatcherEntry(tabId, entry, mountedPanes) + return false + } + } + entry.phase = 'parked' + return true +} + +export function disposeParkedTerminalWatcherEntry( + tabId: string, + entry: PhasedParkedTabWatcherEntry, + mountedPanes: readonly ParkedTerminalPaneCapture[] | null, + retainedPtyIds: ReadonlySet = new Set() +): void { + for (const [ptyId, dispose] of entry.disposersByPtyId) { + if (retainedPtyIds.has(ptyId)) { + continue + } + const preparedPane = entry.paneCaptureByPtyId.get(ptyId) + const successorOwnsTitle = mountedPanes?.some( + (pane) => pane.ptyId === ptyId && pane.paneId === preparedPane?.paneId + ) + dispose({ preserveRuntimeTitle: successorOwnsTitle === true }) + if (!successorOwnsTitle && preparedPane) { + useAppStore.getState().clearRuntimePaneTitle(tabId, preparedPane.paneId) + } + entry.activateByPtyId.delete(ptyId) + entry.disposersByPtyId.delete(ptyId) + entry.suspendedPtyIds.delete(ptyId) + entry.revealReplacementAttemptByPtyId.delete(ptyId) + entry.retainedRevealPtyIds.delete(ptyId) + } + if (entry.disposersByPtyId.size === 0 && parkedWatchersByTabId.get(tabId) === entry) { + parkedWatchersByTabId.delete(tabId) + } +} + +export function isParkedTerminalTabPreparationCurrent( + worktreeId: string, + tab: ParkableTerminalTabModel +): boolean { + const registeredEntry = parkedWatchersByTabId.get(tab.id) + if (!registeredEntry || registeredEntry.worktreeId !== worktreeId) { + return false + } + const entry = ensurePhasedParkedTabWatcherEntry(registeredEntry) + const { panesByPtyId } = readPreparationCandidates({ + worktreeId, + tab, + allowDurableFallback: entry.phase === 'parked' || entry.usesDurableCandidates + }) + return panesByPtyId !== null && entryMatchesPreparation(tab, entry, panesByPtyId) +} + +export function prepareParkedTerminalTabWatchers(args: { + worktreeId: string + tab: ParkableTerminalTabModel + allowDurableFallback: boolean + restoreTitleOnRegister: boolean +}): boolean { + const registeredEntry = parkedWatchersByTabId.get(args.tab.id) + const currentEntry = registeredEntry + ? ensurePhasedParkedTabWatcherEntry(registeredEntry) + : undefined + const { mountedPanes, panesByPtyId } = readPreparationCandidates(args) + if (!panesByPtyId) { + if (currentEntry) { + currentEntry.preparationValid = false + } + return false + } + if (currentEntry && entryMatchesPreparation(args.tab, currentEntry, panesByPtyId)) { + return true + } + const nextEntry = startPreparedTabWatchers({ + ...args, + panesByPtyId, + usesDurableCandidates: mountedPanes === null + }) + if (!nextEntry.preparationValid) { + disposeParkedTerminalWatcherEntry(args.tab.id, nextEntry, mountedPanes) + return false + } + if (currentEntry?.phase === 'parked') { + if (!activateParkedTerminalWatcherEntry(args.tab.id, nextEntry, mountedPanes)) { + // A partial successor can already have replaced one old fact consumer; reveal instead of retaining a split owner with mixed generations. + disposeParkedTerminalWatcherEntry(args.tab.id, currentEntry, mountedPanes) + return false + } + } + parkedWatchersByTabId.set(args.tab.id, nextEntry) + if (currentEntry) { + disposeParkedTerminalWatcherEntry(args.tab.id, currentEntry, mountedPanes) + parkedWatchersByTabId.set(args.tab.id, nextEntry) + } + return true +} diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-watcher-reconciliation.test.ts b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-reconciliation.test.ts index 18e8f41cac6..32d8239ebf6 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-watcher-reconciliation.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-reconciliation.test.ts @@ -4,8 +4,8 @@ import { retireParkedTerminalTab } from './terminal-parked-watcher-registry' import { - reconcileParkedWatcherPtyIds, - resolveParkedTerminalPaneCandidates + resolveParkedTerminalPaneCandidates, + selectParkedTerminalPaneCandidateKey } from './terminal-parked-watcher-reconciliation' const TAB_ID = 'tab-1' @@ -21,6 +21,41 @@ afterEach(() => { }) describe('paired parked-watcher reconciliation', () => { + it('projects host-owned cold identity only for the persisted pane generation', () => { + const paneKey = `${TAB_ID}:${FIRST_LEAF_ID}` + const state = { + runtimePaneTitlesByTabId: {}, + hostTerminalSideEffectIdentityByPaneKey: { + [paneKey]: { + incarnationId: '33333333-3333-4333-8333-333333333333', + paneGeneration: 4 + } + }, + terminalLayoutsByTabId: { + [TAB_ID]: { + root: { type: 'leaf' as const, leafId: FIRST_LEAF_ID }, + activeLeafId: FIRST_LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [FIRST_LEAF_ID]: FIRST_PTY_ID } + } + } + } + + expect( + resolveParkedTerminalPaneCandidates({ id: TAB_ID, ptyId: FIRST_PTY_ID, generation: 4 }, state) + ).toEqual([ + expect.objectContaining({ + sideEffectIdentity: { + incarnationId: '33333333-3333-4333-8333-333333333333', + paneGeneration: 4 + } + }) + ]) + expect( + resolveParkedTerminalPaneCandidates({ id: TAB_ID, ptyId: FIRST_PTY_ID, generation: 5 }, state) + ).toEqual([expect.not.objectContaining({ sideEffectIdentity: expect.anything() })]) + }) + it('prefers an authoritative inactive split-leaf remint over the unmount capture', () => { captureParkedTerminalPaneCandidates(TAB_ID, WORKTREE_ID, [ { ptyId: FIRST_PTY_ID, paneId: 1, leafId: FIRST_LEAF_ID, drivesTabTitle: true }, @@ -66,22 +101,34 @@ describe('paired parked-watcher reconciliation', () => { ]) }) - it('surgically reconciles a reminted split leaf without restarting its sibling', () => { - expect( - reconcileParkedWatcherPtyIds({ - currentTabPtyId: FIRST_PTY_ID, - entryTabPtyId: FIRST_PTY_ID, - paneIdByPtyId: new Map([ - [FIRST_PTY_ID, 1], - [OLD_SECOND_PTY_ID, 2] - ]), - expectedPtyIds: new Set([FIRST_PTY_ID, NEW_SECOND_PTY_ID]) - }) - ).toEqual({ - restartAll: false, - addedPtyIds: [NEW_SECOND_PTY_ID], - retainedPtyIds: [FIRST_PTY_ID], - retiredPaneIds: [2] - }) + it('keys PTY, leaf, and title-driving mutations that require a fresh handoff', () => { + const state = { + runtimePaneTitlesByTabId: {}, + terminalLayoutsByTabId: { + [TAB_ID]: { + root: { + type: 'split' as const, + direction: 'vertical' as const, + first: { type: 'leaf' as const, leafId: FIRST_LEAF_ID }, + second: { type: 'leaf' as const, leafId: SECOND_LEAF_ID } + }, + activeLeafId: FIRST_LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { + [FIRST_LEAF_ID]: FIRST_PTY_ID, + [SECOND_LEAF_ID]: OLD_SECOND_PTY_ID + } + } + } + } + const tabs = [{ id: TAB_ID, ptyId: FIRST_PTY_ID }] + const initial = selectParkedTerminalPaneCandidateKey(state, tabs) + + state.terminalLayoutsByTabId[TAB_ID].activeLeafId = SECOND_LEAF_ID + const activeChanged = selectParkedTerminalPaneCandidateKey(state, tabs) + state.terminalLayoutsByTabId[TAB_ID].ptyIdsByLeafId[SECOND_LEAF_ID] = NEW_SECOND_PTY_ID + + expect(activeChanged).not.toBe(initial) + expect(selectParkedTerminalPaneCandidateKey(state, tabs)).not.toBe(activeChanged) }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-watcher-reconciliation.ts b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-reconciliation.ts index 5376cb9ed13..13bf134bdcd 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-watcher-reconciliation.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-reconciliation.ts @@ -1,5 +1,6 @@ import type { TerminalTab } from '../../../../shared/types' import type { useAppStore } from '@/store' +import { isTerminalLeafId, makePaneKey } from '../../../../shared/stable-pane-id' import { collectLeafIdsInOrder, resolveRootlessTerminalLayoutLeafId @@ -9,11 +10,36 @@ import { type ParkedTerminalPaneCapture } from './terminal-parked-watcher-registry' -export type ParkableTerminalTabModel = Pick +export type ParkableTerminalTabModel = Pick -type ParkedPaneFallbackState = { +type ParkedPaneLayoutState = { terminalLayoutsByTabId: ReturnType['terminalLayoutsByTabId'] +} + +type ParkedPaneFallbackState = ParkedPaneLayoutState & { runtimePaneTitlesByTabId: ReturnType['runtimePaneTitlesByTabId'] + hostTerminalSideEffectIdentityByPaneKey?: ReturnType< + typeof useAppStore.getState + >['hostTerminalSideEffectIdentityByPaneKey'] +} + +export function selectParkedTerminalPaneCandidateKey( + state: ParkedPaneLayoutState, + tabs: readonly ParkableTerminalTabModel[] +): string { + return tabs + .map((tab) => { + const layout = state.terminalLayoutsByTabId[tab.id] + const rootLeafIds = collectLeafIdsInOrder(layout?.root) + const rootlessLeafId = layout ? resolveRootlessTerminalLayoutLeafId(layout) : null + const leafIds = + rootLeafIds.length > 0 ? rootLeafIds : rootlessLeafId !== null ? [rootlessLeafId] : [] + const ptyIdsByLeafId = layout?.ptyIdsByLeafId ?? {} + return `${tab.id}:${layout?.activeLeafId ?? ''}:${leafIds + .map((leafId) => `${leafId}=${ptyIdsByLeafId[leafId] ?? ''}`) + .join(',')}` + }) + .join('|') } export function fallbackParkedPaneCandidates( @@ -32,12 +58,22 @@ export function fallbackParkedPaneCandidates( const titleSlots = Object.keys(state.runtimePaneTitlesByTabId[tab.id] ?? {}) const reusableSlot = leafIds.length === 1 && titleSlots.length === 1 ? Number(titleSlots[0]) : null - return leafIds.map((leafId, index) => ({ - ptyId: ptyIdsByLeafId[leafId] ?? (leafIds.length === 1 ? tab.ptyId : null), - paneId: reusableSlot ?? -(index + 1), - leafId, - drivesTabTitle: layout?.activeLeafId ? leafId === layout.activeLeafId : index === 0 - })) + const paneGeneration = tab.generation ?? 0 + return leafIds.map((leafId, index) => { + const paneKey = + tab.id.length > 0 && !tab.id.includes(':') && isTerminalLeafId(leafId) + ? makePaneKey(tab.id, leafId) + : null + const sideEffectIdentity = + paneKey !== null ? state.hostTerminalSideEffectIdentityByPaneKey?.[paneKey] : undefined + return { + ptyId: ptyIdsByLeafId[leafId] ?? (leafIds.length === 1 ? tab.ptyId : null), + paneId: reusableSlot ?? -(index + 1), + leafId, + drivesTabTitle: layout?.activeLeafId ? leafId === layout.activeLeafId : index === 0, + ...(sideEffectIdentity?.paneGeneration === paneGeneration ? { sideEffectIdentity } : {}) + } + }) } export function resolveParkedTerminalPaneCandidates( @@ -54,7 +90,10 @@ export function resolveParkedTerminalPaneCandidates( (captured.panes.length === fallback.length && fallback.every((pane) => captured.panes.some( - (candidate) => candidate.leafId === pane.leafId && candidate.ptyId === pane.ptyId + (candidate) => + candidate.leafId === pane.leafId && + candidate.ptyId === pane.ptyId && + candidate.drivesTabTitle === pane.drivesTabTitle ) ))) if (capturedIsCurrent) { @@ -62,30 +101,17 @@ export function resolveParkedTerminalPaneCandidates( } return fallback.map((pane) => { const prior = captured?.panes.find((candidate) => candidate.leafId === pane.leafId) - return prior ? { ...pane, paneId: prior.paneId, drivesTabTitle: prior.drivesTabTitle } : pane + return prior + ? { + ...pane, + paneId: prior.paneId, + ...(prior.ptyId === pane.ptyId && prior.mutationIdentity + ? { mutationIdentity: prior.mutationIdentity } + : {}), + ...(prior.ptyId === pane.ptyId && prior.sideEffectIdentity + ? { sideEffectIdentity: prior.sideEffectIdentity } + : {}) + } + : pane }) } - -export function reconcileParkedWatcherPtyIds(args: { - currentTabPtyId: string | null - entryTabPtyId: string | null - paneIdByPtyId: ReadonlyMap - expectedPtyIds: ReadonlySet -}): { - restartAll: boolean - addedPtyIds: string[] - retainedPtyIds: string[] - retiredPaneIds: number[] -} { - const retainedPtyIds = Array.from(args.paneIdByPtyId.keys()).filter((ptyId) => - args.expectedPtyIds.has(ptyId) - ) - return { - restartAll: args.entryTabPtyId !== args.currentTabPtyId, - addedPtyIds: Array.from(args.expectedPtyIds).filter((ptyId) => !args.paneIdByPtyId.has(ptyId)), - retainedPtyIds, - retiredPaneIds: Array.from(args.paneIdByPtyId) - .filter(([ptyId]) => !args.expectedPtyIds.has(ptyId)) - .map(([, paneId]) => paneId) - } -} diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.test.ts b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.test.ts index eb436c8e127..f56b32029e1 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.test.ts @@ -11,6 +11,9 @@ import { capturedPanesByTabId, parkedWatchersByTabId, pruneParkedTerminalWatchers, + readMountedTerminalPaneCandidates, + registerMountedTerminalPaneCandidateReader, + retireParkedTerminalTab, terminalWatcherLiveWorkspaceIds } from './terminal-parked-watcher-registry' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' @@ -90,4 +93,18 @@ describe('terminal parked watcher registry removal', () => { expect(capturedPanesByTabId.has(FLOATING_TAB_ID)).toBe(true) expect(floatingDispose).not.toHaveBeenCalled() }) + + it('drops a mounted candidate reader when its tab retires', () => { + const unregister = registerMountedTerminalPaneCandidateReader( + TAB_ID, + 'removed-worktree', + () => [] + ) + expect(readMountedTerminalPaneCandidates(TAB_ID, 'removed-worktree')).toEqual([]) + + retireParkedTerminalTab(TAB_ID) + + expect(readMountedTerminalPaneCandidates(TAB_ID, 'removed-worktree')).toBeNull() + unregister() + }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts index 1f4b522040a..5b0404ed4ad 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts @@ -9,9 +9,13 @@ */ import { discardPreHandlerPtyState } from './pty-pre-handler-buffer' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import type { PtyMutationIdentity } from '../../../../shared/pty-mutation-identity' +import type { ParkedTerminalSideEffectIdentity } from './terminal-parked-side-effect-identity' export type ParkedTerminalPaneCapture = { ptyId: string | null + mutationIdentity?: PtyMutationIdentity + sideEffectIdentity?: ParkedTerminalSideEffectIdentity /** PaneManager numeric pane id the live pane used for runtime titles. */ paneId: number /** Stable terminal-layout leaf UUID (paneKey attribution). */ @@ -23,6 +27,42 @@ export type CapturedTabPanes = { worktreeId: string; panes: ParkedTerminalPaneCa export const capturedPanesByTabId = new Map() +type MountedPaneCandidateReaderEntry = { + worktreeId: string + read: () => ParkedTerminalPaneCapture[] +} + +const mountedPaneCandidateReadersByTabId = new Map() + +export function registerMountedTerminalPaneCandidateReader( + tabId: string, + worktreeId: string, + read: () => ParkedTerminalPaneCapture[] +): () => void { + const entry = { worktreeId, read } + mountedPaneCandidateReadersByTabId.set(tabId, entry) + return () => { + if (mountedPaneCandidateReadersByTabId.get(tabId) === entry) { + mountedPaneCandidateReadersByTabId.delete(tabId) + } + } +} + +export function readMountedTerminalPaneCandidates( + tabId: string, + worktreeId: string +): ParkedTerminalPaneCapture[] | null { + const entry = mountedPaneCandidateReadersByTabId.get(tabId) + if (!entry || entry.worktreeId !== worktreeId) { + return null + } + try { + return entry.read().map((pane) => ({ ...pane })) + } catch { + return null + } +} + // Why: PaneManager pane ids die with the unmounted pane, but the watcher must // keep writing the exact runtime-title slots the live pane used — a different // slot would strand a stale "working" title that pins worktree status. @@ -40,13 +80,101 @@ export type ParkedTabWatcherEntry = { /** Tab-level ptyId at watcher start; a change means the PTY was re-minted * (e.g. wake respawn) and the watchers must restart against fresh ids. */ tabPtyId: string | null + phase?: 'prepared' | 'parked' + preparationValid?: boolean + usesDurableCandidates?: boolean + paneCaptureByPtyId?: Map + suspendedPtyIds?: Set /** Runtime-title slot each watcher writes, so parked PTY-exit handling can * clear the dead leaf's slot (no live pane will ever overwrite it). */ paneIdByPtyId: Map - disposersByPtyId: Map void> + activateByPtyId?: Map boolean> + disposersByPtyId: Map void> + revealReplacementAttemptByPtyId?: Map + retainedRevealPtyIds?: Set +} + +export type ParkedWatcherDisposeOptions = { preserveRuntimeTitle?: boolean } + +export type PhasedParkedTabWatcherEntry = ParkedTabWatcherEntry & { + phase: 'prepared' | 'parked' + preparationValid: boolean + usesDurableCandidates: boolean + paneCaptureByPtyId: Map + suspendedPtyIds: Set + activateByPtyId: Map boolean> + revealReplacementAttemptByPtyId: Map + retainedRevealPtyIds: Set +} + +export function ensurePhasedParkedTabWatcherEntry( + entry: ParkedTabWatcherEntry +): PhasedParkedTabWatcherEntry { + entry.phase ??= 'parked' + entry.preparationValid ??= true + entry.usesDurableCandidates ??= true + entry.paneCaptureByPtyId ??= new Map() + entry.suspendedPtyIds ??= new Set() + entry.activateByPtyId ??= new Map() + entry.revealReplacementAttemptByPtyId ??= new Map() + entry.retainedRevealPtyIds ??= new Set() + return entry as PhasedParkedTabWatcherEntry } export const parkedWatchersByTabId = new Map() +let nextRevealReplacementAttempt = 0 + +export type ParkedTerminalWatcherReplacement = { + commit: (installSuccessor: () => boolean) => boolean + abort: () => void +} + +/** Keep the parked owner live until a revealed pane proves its replacement binding. */ +export function beginParkedTerminalWatcherReplacement( + tabId: string, + ptyId: string +): ParkedTerminalWatcherReplacement | null { + const registeredEntry = parkedWatchersByTabId.get(tabId) + if (!registeredEntry) { + return null + } + const entry = ensurePhasedParkedTabWatcherEntry(registeredEntry) + if (entry.phase !== 'parked' || !entry.disposersByPtyId.has(ptyId)) { + return null + } + nextRevealReplacementAttempt += 1 + const attempt = nextRevealReplacementAttempt + entry.revealReplacementAttemptByPtyId.set(ptyId, attempt) + entry.retainedRevealPtyIds.add(ptyId) + + const isCurrent = (): boolean => + parkedWatchersByTabId.get(tabId) === entry && + entry.revealReplacementAttemptByPtyId.get(ptyId) === attempt + + return { + commit(installSuccessor): boolean { + if (!isCurrent() || !installSuccessor()) { + return false + } + entry.revealReplacementAttemptByPtyId.delete(ptyId) + entry.retainedRevealPtyIds.delete(ptyId) + const dispose = entry.disposersByPtyId.get(ptyId) + entry.disposersByPtyId.delete(ptyId) + entry.activateByPtyId.delete(ptyId) + entry.suspendedPtyIds.delete(ptyId) + dispose?.({ preserveRuntimeTitle: true }) + if (entry.disposersByPtyId.size === 0 && parkedWatchersByTabId.get(tabId) === entry) { + parkedWatchersByTabId.delete(tabId) + } + return true + }, + abort(): void { + if (isCurrent()) { + entry.revealReplacementAttemptByPtyId.delete(ptyId) + } + } + } +} export function getParkedTerminalWatcherTabIds(): string[] { return Array.from(parkedWatchersByTabId.keys()) @@ -61,22 +189,30 @@ export function terminalWatcherLiveWorkspaceIds(workspaceIds: Iterable): * Whether this tab is parked right now — the reveal remount's own mount effect * runs before the host effect that disposes the watcher (child effects first), * so a pane reading this at connect time can tell a park-reveal from an - * in-place reattach. Empty entries are pinned-close tombstones, not live parks. + * in-place reattach. Empty entries are pending-close tombstones, not live parks. */ export function isTerminalTabParked(tabId: string): boolean { - return (parkedWatchersByTabId.get(tabId)?.disposersByPtyId.size ?? 0) > 0 + const entry = parkedWatchersByTabId.get(tabId) + return entry !== undefined && entry.phase !== 'prepared' && entry.disposersByPtyId.size > 0 } -export function disposeParkedTabWatchers(tabId: string): void { +export function disposeParkedTabWatchers( + tabId: string, + options?: ParkedWatcherDisposeOptions +): void { const entry = parkedWatchersByTabId.get(tabId) if (!entry) { return } parkedWatchersByTabId.delete(tabId) for (const dispose of entry.disposersByPtyId.values()) { - dispose() + dispose(options) } + entry.activateByPtyId?.clear() entry.disposersByPtyId.clear() + entry.suspendedPtyIds?.clear() + entry.revealReplacementAttemptByPtyId?.clear() + entry.retainedRevealPtyIds?.clear() } export function retireParkedTerminalTab(tabId: string): void { @@ -84,6 +220,7 @@ export function retireParkedTerminalTab(tabId: string): void { // observers and unmounted-pane candidates; neither may reattach later. disposeParkedTabWatchers(tabId) capturedPanesByTabId.delete(tabId) + mountedPaneCandidateReadersByTabId.delete(tabId) } /** @@ -99,6 +236,10 @@ export function disposeParkedTerminalWatchersForPtyIds(ptyIds: readonly string[] const dispose = entry.disposersByPtyId.get(ptyId) if (dispose) { entry.disposersByPtyId.delete(ptyId) + entry.activateByPtyId?.delete(ptyId) + entry.suspendedPtyIds?.add(ptyId) + entry.revealReplacementAttemptByPtyId?.delete(ptyId) + entry.retainedRevealPtyIds?.delete(ptyId) dispose() } } @@ -161,4 +302,9 @@ export function pruneParkedTerminalWatchers(liveWorktreeIds: ReadonlySet capturedPanesByTabId.delete(tabId) } } + for (const [tabId, reader] of mountedPaneCandidateReadersByTabId) { + if (!liveWorktreeIds.has(reader.worktreeId)) { + mountedPaneCandidateReadersByTabId.delete(tabId) + } + } } diff --git a/src/renderer/src/components/terminal-pane/terminal-pty-paste-writer.ts b/src/renderer/src/components/terminal-pane/terminal-pty-paste-writer.ts index dd7e257615a..d49bc1fcb69 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pty-paste-writer.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pty-paste-writer.ts @@ -1,14 +1,25 @@ -import type { PtyTransport } from './pty-transport' +import type { PtyTransport, PtyTransportInputTarget } from './pty-transport' -type TerminalPastePtyWriter = Pick +type TerminalPastePtyWriter = Pick< + PtyTransport, + 'sendInput' | 'sendInputAccepted' | 'sendInputToTarget' | 'sendInputAcceptedToTarget' +> export function writeTerminalPastePtyInput( transport: TerminalPastePtyWriter | undefined, - data: string + data: string, + target?: PtyTransportInputTarget | null ): boolean | Promise { if (!transport) { return false } + if (target) { + return ( + transport.sendInputAcceptedToTarget?.(target, data) ?? + transport.sendInputToTarget?.(target, data) ?? + false + ) + } // Why: paste chunking must respect PTY backpressure. sendInput only queues // local writes, while sendInputAccepted resolves after the PTY accepts them. return transport.sendInputAccepted?.(data) ?? transport.sendInput(data) diff --git a/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts index e05137ffb64..2dc181f1778 100644 --- a/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts @@ -4,6 +4,7 @@ import { _dispatchTerminalSideEffectBatchForTest, _resetTerminalSideEffectFactConsumersForTest, isMainTerminalSideEffectAuthorityForPty, + prepareTerminalSideEffectFactConsumer, registerTerminalSideEffectFactConsumer, type TerminalSideEffectFactConsumerCallbacks } from './terminal-side-effect-facts-handler' @@ -440,59 +441,28 @@ describe('registerTerminalSideEffectFactConsumer', () => { expect(events).toEqual([]) }) - it('hands off facts emitted between the parked watcher unregistering and the pane registering', () => { - // Why: the reveal window — the watcher unregisters synchronously in the - // effect flush, the pane registers only after its async reattach resolves, - // and replay is title-only, so an unbuffered bell/command-code-done is lost. - const watcher = createCallbackRecorder() - const disposeWatcher = registerTerminalSideEffectFactConsumer({ + it('keeps the predecessor live across an arbitrarily slow prepared replacement', () => { + vi.useFakeTimers() + const predecessor = createCallbackRecorder() + registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks: predecessor.callbacks }) + const successor = createCallbackRecorder() + const prepared = prepareTerminalSideEffectFactConsumer({ ptyId: PTY_ID, - callbacks: watcher.callbacks - }) - disposeWatcher() - - const events: unknown[][] = [] - _dispatchTerminalSideEffectBatchForTest( - batch([{ kind: 'bell' }, { kind: 'command-code-done', prompt: 'Fix the spinner' }], { - seq: 7 - }) - ) - // A PTY that never had a consumer stays dropped — no buffer was opened. - _dispatchTerminalSideEffectBatchForTest(batch([{ kind: 'bell' }], { ptyId: 'never-consumed' })) - - expect(events).toEqual([]) - expect(watcher.events).toEqual([]) - - const neverConsumed = createCallbackRecorder() - registerTerminalSideEffectFactConsumer({ - ptyId: 'never-consumed', - callbacks: neverConsumed.callbacks - }) - expect(neverConsumed.events).toEqual([]) - - const dispose = registerTerminalSideEffectFactConsumer({ - ptyId: PTY_ID, - callbacks: { - onBell: () => events.push(['bell']), - onCommandCodeDone: (prompt) => events.push(['cc-done', prompt]) - } + callbacks: successor.callbacks }) - expect(events).toEqual([['bell'], ['cc-done', 'Fix the spinner']]) - expect(watcher.events).toEqual([]) + vi.advanceTimersByTime(75_000) + for (let seq = 1; seq <= 100; seq += 1) { + _dispatchTerminalSideEffectBatchForTest(batch([{ kind: 'bell' }], { seq })) + } - // Drained exactly once: a later registration gets nothing. - dispose() - const later: unknown[][] = [] - registerTerminalSideEffectFactConsumer({ - ptyId: PTY_ID, - callbacks: { - onBell: () => later.push(['bell']), - onCommandCodeDone: (prompt) => later.push(['cc-done', prompt]) - } - }) - - expect(later).toEqual([]) + expect(predecessor.events).toHaveLength(100) + expect(successor.events).toEqual([]) + expect(vi.getTimerCount()).toBe(0) + expect(prepared.activate()).toBe(true) + _dispatchTerminalSideEffectBatchForTest(batch([{ kind: 'bell' }], { seq: 101 })) + expect(successor.events).toEqual([['bell']]) + vi.useRealTimers() }) it('does not buffer replay batches across a handoff', () => { @@ -517,59 +487,157 @@ describe('registerTerminalSideEffectFactConsumer', () => { expect(events).toEqual([]) }) - it('drains the handoff buffer before the register snapshot, so the replay is stale', async () => { - // Why: the drained live title sets lastLiveTitleSeq, which is what makes - // the async snapshot's older title lose to the fact the pane already got. + it('auto-activates a prepared parked owner when its predecessor releases', async () => { const snapshot = batch([{ kind: 'title', normalizedTitle: 'snapshot', rawTitle: 'snapshot' }], { replay: true, - seq: 20 + seq: 20, + ptyIncarnationId: 'incarnation-1' }) ;(globalThis as { window: unknown }).window = { api: { pty: { getSideEffectSnapshot: vi.fn(async () => snapshot) } } } + const predecessor = createCallbackRecorder() const dispose = registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, - callbacks: createCallbackRecorder().callbacks + incarnationId: 'incarnation-1', + callbacks: predecessor.callbacks + }) + _dispatchTerminalSideEffectBatchForTest( + batch([{ kind: 'title', normalizedTitle: 'live', rawTitle: 'live' }], { + seq: 20, + ptyIncarnationId: 'incarnation-1' + }) + ) + + const successor = createCallbackRecorder() + prepareTerminalSideEffectFactConsumer({ + ptyId: PTY_ID, + incarnationId: 'incarnation-1', + callbacks: successor.callbacks, + restoreTitleOnRegister: true, + activateOnPredecessorRelease: true }) dispose() _dispatchTerminalSideEffectBatchForTest( - batch([{ kind: 'title', normalizedTitle: 'live', rawTitle: 'live' }], { seq: 20 }) + batch([{ kind: 'bell' }], { seq: 21, ptyIncarnationId: 'incarnation-1' }) ) - - const { callbacks, events } = createCallbackRecorder() - registerTerminalSideEffectFactConsumer({ - ptyId: PTY_ID, - callbacks, - restoreTitleOnRegister: true - }) await Promise.resolve() await Promise.resolve() - expect(events).toEqual([['title', 'live', 'live']]) + expect(predecessor.events).toEqual([['title', 'live', 'live']]) + expect(successor.events).toEqual([['bell']]) }) - it('drops a handoff-buffered batch once the buffer TTL expires', () => { - vi.useFakeTimers() - try { - const dispose = registerTerminalSideEffectFactConsumer({ - ptyId: PTY_ID, - callbacks: createCallbackRecorder().callbacks - }) - dispose() - _dispatchTerminalSideEffectBatchForTest(batch([{ kind: 'bell' }])) - expect(vi.getTimerCount()).toBe(1) + it('leaves the predecessor active when a prepared successor is cancelled', () => { + const predecessor = createCallbackRecorder() + registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks: predecessor.callbacks }) + const successor = createCallbackRecorder() + const prepared = prepareTerminalSideEffectFactConsumer({ + ptyId: PTY_ID, + callbacks: successor.callbacks + }) - vi.advanceTimersByTime(15_001) - expect(vi.getTimerCount()).toBe(0) + prepared.cancel() + _dispatchTerminalSideEffectBatchForTest(batch([{ kind: 'bell' }])) - const { callbacks, events } = createCallbackRecorder() - registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks }) + expect(predecessor.events).toEqual([['bell']]) + expect(successor.events).toEqual([]) + expect(prepared.activate()).toBe(false) + }) - expect(events).toEqual([]) - } finally { - vi.useRealTimers() + it('allows only the latest prepared successor to activate', () => { + const predecessor = createCallbackRecorder() + registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks: predecessor.callbacks }) + const stale = createCallbackRecorder() + const stalePrepared = prepareTerminalSideEffectFactConsumer({ + ptyId: PTY_ID, + callbacks: stale.callbacks + }) + const current = createCallbackRecorder() + const currentPrepared = prepareTerminalSideEffectFactConsumer({ + ptyId: PTY_ID, + callbacks: current.callbacks + }) + + expect(stalePrepared.activate()).toBe(false) + expect(currentPrepared.activate()).toBe(true) + _dispatchTerminalSideEffectBatchForTest(batch([{ kind: 'bell' }])) + + expect(predecessor.events).toEqual([]) + expect(stale.events).toEqual([]) + expect(current.events).toEqual([['bell']]) + }) + + it('does not inherit the title cursor across exact PTY incarnations', async () => { + const snapshot = batch([{ kind: 'title', normalizedTitle: 'new', rawTitle: 'new' }], { + replay: true, + seq: 5, + ptyIncarnationId: 'incarnation-new' + }) + ;(globalThis as { window: unknown }).window = { + api: { pty: { getSideEffectSnapshot: vi.fn(async () => snapshot) } } } + registerTerminalSideEffectFactConsumer({ + ptyId: PTY_ID, + incarnationId: 'incarnation-old', + callbacks: createCallbackRecorder().callbacks + }) + _dispatchTerminalSideEffectBatchForTest( + batch([{ kind: 'title', normalizedTitle: 'old', rawTitle: 'old' }], { + seq: 50, + ptyIncarnationId: 'incarnation-old' + }) + ) + const successor = createCallbackRecorder() + const prepared = prepareTerminalSideEffectFactConsumer({ + ptyId: PTY_ID, + incarnationId: 'incarnation-new', + callbacks: successor.callbacks, + restoreTitleOnRegister: true + }) + + expect(prepared.activate('incarnation-new')).toBe(true) + await Promise.resolve() + await Promise.resolve() + + expect(successor.events).toEqual([['title', 'new', 'new']]) + }) + + it('fences same-id facts by exact incarnation before and after commit', () => { + const predecessor = createCallbackRecorder() + registerTerminalSideEffectFactConsumer({ + ptyId: PTY_ID, + incarnationId: 'incarnation-old', + callbacks: predecessor.callbacks + }) + const successor = createCallbackRecorder() + const prepared = prepareTerminalSideEffectFactConsumer({ + ptyId: PTY_ID, + incarnationId: 'incarnation-new', + callbacks: successor.callbacks + }) + + _dispatchTerminalSideEffectBatchForTest( + batch([{ kind: 'bell' }], { seq: 1, ptyIncarnationId: 'incarnation-new' }) + ) + _dispatchTerminalSideEffectBatchForTest( + batch([{ kind: 'bell' }], { seq: 2, ptyIncarnationId: 'incarnation-old' }) + ) + expect(predecessor.events).toEqual([['bell']]) + expect(successor.events).toEqual([]) + + expect(prepared.activate('incarnation-new')).toBe(true) + _dispatchTerminalSideEffectBatchForTest(batch([{ kind: 'bell' }], { seq: 3 })) + _dispatchTerminalSideEffectBatchForTest( + batch([{ kind: 'bell' }], { seq: 4, ptyIncarnationId: 'incarnation-old' }) + ) + _dispatchTerminalSideEffectBatchForTest( + batch([{ kind: 'bell' }], { seq: 5, ptyIncarnationId: 'incarnation-new' }) + ) + + expect(predecessor.events).toEqual([['bell']]) + expect(successor.events).toEqual([['bell']]) }) it('subscribes to the channel once and routes IPC batches', () => { diff --git a/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts index 546af83987b..7bc3fe55f93 100644 --- a/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts +++ b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts @@ -7,10 +7,8 @@ * the single channel subscriber; mounted panes and parked-tab watchers * register exactly one fact consumer per PTY (their existing policy * callbacks), so every fact has exactly one policy consumer regardless of - * whether the tab is mounted, hidden, or parked. Facts for PTYs without a - * registered consumer are dropped — mirroring today's eager-buffer behavior - * where pre-mount output produces no attention side effects. The one exception - * is a PTY whose consumer just unregistered: see the handoff buffer below. + * whether the tab is mounted, hidden, or parked. The legacy push channel drops + * missing consumers; durable authority policy returns unavailable without ACK. */ import type { GlobalSettings } from '../../../../shared/types' import type { ParsedAgentStatusPayload } from '../../../../shared/agent-status-types' @@ -19,6 +17,10 @@ import type { TerminalSideEffectBatch, TerminalSideEffectFact } from '../../../../shared/terminal-side-effect-facts' +import type { + TerminalAuthorityAppOutcomeIdentity, + TerminalAuthorityOutcomeProjectionKind +} from '@/lib/terminal-authority-app-projection-controller' // Why: cached once per session — the blocking read should only ever run on // the pre-hydration startup path, never per pane bind. @@ -63,207 +65,167 @@ export function isMainTerminalSideEffectAuthorityForPty(args: { return readPersistedSideEffectAuthorityFlagSync() !== false } +type TerminalAuthorityAwareHandler = ( + ...args: [...Args, TerminalAuthorityAppOutcomeIdentity?] +) => unknown + export type TerminalSideEffectFactConsumerCallbacks = { - onAgentStatus?: (payload: ParsedAgentStatusPayload) => void + onAgentStatus?: TerminalAuthorityAwareHandler<[payload: ParsedAgentStatusPayload]> /** `meta.staleWorkingTitleClear` marks facts derived from main's 3s * stale-title timer — policy must clear title/cache state without * scheduling task-complete notifications or unread attention. */ - onTitleChange?: ( - normalizedTitle: string, - rawTitle: string, - meta?: { staleWorkingTitleClear?: boolean } - ) => void - onBell?: () => void - onAgentBecameIdle?: (title: string, meta?: { staleWorkingTitleClear?: boolean }) => void - onAgentBecameWorking?: () => void - onAgentExited?: () => void + onTitleChange?: TerminalAuthorityAwareHandler< + [normalizedTitle: string, rawTitle: string, meta?: { staleWorkingTitleClear?: boolean }] + > + onBell?: TerminalAuthorityAwareHandler<[]> + onAgentBecameIdle?: TerminalAuthorityAwareHandler< + [title: string, meta?: { staleWorkingTitleClear?: boolean }] + > + onAgentBecameWorking?: TerminalAuthorityAwareHandler<[]> + onAgentExited?: TerminalAuthorityAwareHandler<[]> /** OSC 133;D — same policy hook the byte-mode commandLifecycle drove * (stale agent-status row drop + interrupt-inference coordination). */ - onCommandFinished?: (bestEffortExitCode: number | null) => void - onPrLink?: (link: TerminalGitHubPRLink) => void + onCommandFinished?: TerminalAuthorityAwareHandler<[bestEffortExitCode: number | null]> + onPrLink?: TerminalAuthorityAwareHandler<[link: TerminalGitHubPRLink]> /** Command Code output scrape (no hooks): working seeds the status row; * done is settle-checked by the pane policy before completing the turn. */ - onCommandCodeWorking?: (prompt: string) => void - onCommandCodeDone?: (prompt: string) => void + onCommandCodeWorking?: TerminalAuthorityAwareHandler<[prompt: string]> + onCommandCodeDone?: TerminalAuthorityAwareHandler<[prompt: string]> /** DECSET 2031 subscribe observed by main's tracker. Registered only by * hidden-delivery-gated consumers (their bytes never arrive); the theme * reply is sent renderer-side — query authority stays with the view. */ - onMode2031Subscribe?: () => void + onMode2031Subscribe?: TerminalAuthorityAwareHandler<[]> /** DECSET 2031 withdrawal observed by main's tracker. Clears the pane's * subscription registry so later theme flips stop pushing CSI 997. */ - onMode2031Unsubscribe?: () => void + onMode2031Unsubscribe?: TerminalAuthorityAwareHandler<[]> } type ConsumerEntry = { callbacks: TerminalSideEffectFactConsumerCallbacks + activateOnPredecessorRelease: boolean + authorityOutcomeProjection: boolean + incarnationId?: string + restoreTitleOnRegister: boolean /** Output sequence of the last live title fact applied. Replay snapshots at * or before this point are stale and must not regress the title state. */ lastLiveTitleSeq: number | null } const consumersByPtyId = new Map() +const preparedConsumersByPtyId = new Map() +const consumerAvailableListeners = new Set<(ptyId: string) => void>() let channelUnsubscribe: (() => void) | null = null -function applyLiveFact(entry: ConsumerEntry, fact: TerminalSideEffectFact, seq: number): void { +function applyLiveFact( + entry: ConsumerEntry, + fact: TerminalSideEffectFact, + seq: number, + authorityOutcome?: TerminalAuthorityAppOutcomeIdentity +): unknown { switch (fact.kind) { case 'agent-status': - entry.callbacks.onAgentStatus?.(fact.payload) - return + return entry.callbacks.onAgentStatus?.(fact.payload, authorityOutcome) case 'title': entry.lastLiveTitleSeq = seq - entry.callbacks.onTitleChange?.( + return entry.callbacks.onTitleChange?.( fact.normalizedTitle, fact.rawTitle, - fact.staleWorkingTitleClear ? { staleWorkingTitleClear: true } : undefined + fact.staleWorkingTitleClear ? { staleWorkingTitleClear: true } : undefined, + authorityOutcome ) - return case 'bell': - entry.callbacks.onBell?.() - return + return entry.callbacks.onBell?.(authorityOutcome) case 'agent-working': - entry.callbacks.onAgentBecameWorking?.() - return + return entry.callbacks.onAgentBecameWorking?.(authorityOutcome) case 'agent-idle': - entry.callbacks.onAgentBecameIdle?.( + return entry.callbacks.onAgentBecameIdle?.( fact.title, - fact.staleWorkingTitleClear ? { staleWorkingTitleClear: true } : undefined + fact.staleWorkingTitleClear ? { staleWorkingTitleClear: true } : undefined, + authorityOutcome ) - return case 'agent-exited': - entry.callbacks.onAgentExited?.() - return + return entry.callbacks.onAgentExited?.(authorityOutcome) case 'command-finished': - entry.callbacks.onCommandFinished?.(fact.exitCode) - return + return entry.callbacks.onCommandFinished?.(fact.exitCode, authorityOutcome) case 'pr-link': - entry.callbacks.onPrLink?.(fact.link) - return + return entry.callbacks.onPrLink?.(fact.link, authorityOutcome) case 'command-code-working': - entry.callbacks.onCommandCodeWorking?.(fact.prompt) - return + return entry.callbacks.onCommandCodeWorking?.(fact.prompt, authorityOutcome) case 'command-code-done': - entry.callbacks.onCommandCodeDone?.(fact.prompt) - return + return entry.callbacks.onCommandCodeDone?.(fact.prompt, authorityOutcome) case '2031-subscribe': - entry.callbacks.onMode2031Subscribe?.() - return + return entry.callbacks.onMode2031Subscribe?.(authorityOutcome) case '2031-unsubscribe': - entry.callbacks.onMode2031Unsubscribe?.() + return entry.callbacks.onMode2031Unsubscribe?.(authorityOutcome) } } -function applyBatchToConsumer(entry: ConsumerEntry, batch: TerminalSideEffectBatch): void { +function applyBatchToConsumer( + entry: ConsumerEntry, + batch: TerminalSideEffectBatch, + authorityOutcome?: TerminalAuthorityAppOutcomeIdentity +): boolean { + if (!consumerMatchesBatch(entry, batch)) { + return false + } if (batch.replay) { // Why: the no-attention-replay rule — (re)attach snapshots restore title // state only; historical bells/completions must never fire again. A replay // older (by output sequence) than the last live title fact is stale. if (entry.lastLiveTitleSeq !== null && batch.seq <= entry.lastLiveTitleSeq) { - return + return false } for (const fact of batch.facts) { if (fact.kind === 'title') { entry.callbacks.onTitleChange?.(fact.normalizedTitle, fact.rawTitle) } } - return + return true } for (const fact of batch.facts) { - applyLiveFact(entry, fact, batch.seq) + applyLiveFact(entry, fact, batch.seq, authorityOutcome) } + return true } -// Why: a reveal remount unregisters the parked watcher synchronously, but the -// replacement pane registers only after its deferred rAF + async reattach -// resolves — and replay is title-only, so a bell or a command-code 'done' -// dropped in that window is lost for good. Only a PTY that just lost its -// consumer buffers (never-consumed PTYs still drop, per the module contract), -// bounded in time, batches, and PTYs so an abandoned handoff retains nothing. -const HANDOFF_FACT_BUFFER_TTL_MS = 15_000 -const MAX_HANDOFF_FACT_BATCHES = 64 -const MAX_HANDOFF_FACT_PTYS = 32 - -type HandoffFactBuffer = { - batches: TerminalSideEffectBatch[] - expiresAtMs: number - expiryTimer: ReturnType -} -const handoffFactBuffersByPtyId = new Map() - -function deleteHandoffFactBuffer(ptyId: string): void { - const buffer = handoffFactBuffersByPtyId.get(ptyId) - if (buffer) { - clearTimeout(buffer.expiryTimer) - handoffFactBuffersByPtyId.delete(ptyId) - } +function consumerMatchesBatch(entry: ConsumerEntry, batch: TerminalSideEffectBatch): boolean { + const batchIncarnationId = batch.ptyIncarnationId + return ( + (entry.incarnationId === undefined && batchIncarnationId === undefined) || + entry.incarnationId === batchIncarnationId + ) } -function openHandoffFactBuffer(ptyId: string): void { - const nowMs = Date.now() - for (const [bufferedPtyId, buffer] of handoffFactBuffersByPtyId) { - if (buffer.expiresAtMs <= nowMs) { - deleteHandoffFactBuffer(bufferedPtyId) - } - } - deleteHandoffFactBuffer(ptyId) - if (handoffFactBuffersByPtyId.size >= MAX_HANDOFF_FACT_PTYS) { - const oldestPtyId = handoffFactBuffersByPtyId.keys().next().value - if (typeof oldestPtyId === 'string') { - deleteHandoffFactBuffer(oldestPtyId) - } - } - const buffer: HandoffFactBuffer = { - batches: [], - expiresAtMs: nowMs + HANDOFF_FACT_BUFFER_TTL_MS, - expiryTimer: setTimeout(() => { - if (handoffFactBuffersByPtyId.get(ptyId) === buffer) { - handoffFactBuffersByPtyId.delete(ptyId) - } - }, HANDOFF_FACT_BUFFER_TTL_MS) - } - buffer.expiryTimer.unref?.() - handoffFactBuffersByPtyId.set(ptyId, buffer) -} - -function bufferHandoffFactBatch(batch: TerminalSideEffectBatch): void { - const buffer = handoffFactBuffersByPtyId.get(batch.ptyId) - if (!buffer) { - return - } - if (buffer.expiresAtMs <= Date.now()) { - deleteHandoffFactBuffer(batch.ptyId) - return - } - // Why: replay batches are snapshots the next consumer requests for itself. - if (batch.replay) { - return - } - if (buffer.batches.length >= MAX_HANDOFF_FACT_BATCHES) { - buffer.batches.shift() - } - buffer.batches.push(batch) -} - -function drainHandoffFactBuffer(ptyId: string, entry: ConsumerEntry): void { - const buffer = handoffFactBuffersByPtyId.get(ptyId) - if (!buffer) { - return - } - deleteHandoffFactBuffer(ptyId) - if (buffer.expiresAtMs <= Date.now()) { - return - } - for (const batch of buffer.batches) { - applyBatchToConsumer(entry, batch) - } -} - -export function dispatchTerminalSideEffectBatch(batch: TerminalSideEffectBatch): void { +export function dispatchTerminalSideEffectBatch( + batch: TerminalSideEffectBatch, + authorityOutcome?: TerminalAuthorityAppOutcomeIdentity +): boolean { const entry = consumersByPtyId.get(batch.ptyId) if (!entry) { - bufferHandoffFactBatch(batch) - return + return false } - applyBatchToConsumer(entry, batch) + return applyBatchToConsumer(entry, batch, authorityOutcome) +} + +export async function settleTerminalAuthoritySideEffectBatch( + batch: TerminalSideEffectBatch, + authorityOutcome: TerminalAuthorityAppOutcomeIdentity +): Promise { + const entry = consumersByPtyId.get(batch.ptyId) + if (!entry?.authorityOutcomeProjection || !consumerMatchesBatch(entry, batch)) { + return false + } + for (const fact of batch.facts) { + await applyLiveFact(entry, fact, batch.seq, authorityOutcome) + } + return true +} + +export function onTerminalSideEffectFactConsumerAvailable( + listener: (ptyId: string) => void +): () => void { + consumerAvailableListeners.add(listener) + return () => consumerAvailableListeners.delete(listener) } function ensureSideEffectChannelSubscription(): void { @@ -281,7 +243,11 @@ function ensureSideEffectChannelSubscription(): void { export type TerminalSideEffectFactConsumerOptions = { ptyId: string + /** Exact local/SSH PTY incarnation; absent legacy/remote consumers never inherit cursors. */ + incarnationId?: string callbacks: TerminalSideEffectFactConsumerCallbacks + /** Declared only by projections that stay idempotent across process restart. */ + authorityOutcomeProjection?: TerminalAuthorityOutcomeProjectionKind /** Pull main's title-only replay snapshot on registration. Pane transports * use this in place of deriving titles from eager-buffer byte replay. * Ordinary parked watchers already have a current pane title; cold-started @@ -289,6 +255,105 @@ export type TerminalSideEffectFactConsumerOptions = { restoreTitleOnRegister?: boolean } +export type PreparedTerminalSideEffectFactConsumer = { + /** Atomically replace the active consumer, carrying its live-title cursor. */ + activate: (incarnationId?: string) => boolean + /** Cancel only this unactivated successor. */ + cancel: () => void + /** Release this consumer; an auto-activate successor takes over synchronously. */ + unregister: () => void +} + +function requestTitleSnapshot(ptyId: string, entry: ConsumerEntry): void { + if (!entry.restoreTitleOnRegister) { + return + } + const getSnapshot = (globalThis as { window?: Window }).window?.api?.pty?.getSideEffectSnapshot + if (typeof getSnapshot !== 'function') { + return + } + void getSnapshot(ptyId) + .then((batch) => { + if (batch && consumersByPtyId.get(ptyId) === entry) { + applyBatchToConsumer(entry, { ...batch, replay: true }) + } + }) + .catch(() => {}) +} + +function activatePreparedConsumer( + ptyId: string, + entry: ConsumerEntry, + incarnationId?: string +): boolean { + if (consumersByPtyId.get(ptyId) === entry) { + return true + } + if (preparedConsumersByPtyId.get(ptyId) !== entry) { + return false + } + entry.incarnationId = incarnationId ?? entry.incarnationId + const predecessor = consumersByPtyId.get(ptyId) + if ( + entry.incarnationId !== undefined && + predecessor?.incarnationId === entry.incarnationId && + predecessor.lastLiveTitleSeq !== null + ) { + entry.lastLiveTitleSeq = predecessor.lastLiveTitleSeq + } + preparedConsumersByPtyId.delete(ptyId) + consumersByPtyId.set(ptyId, entry) + for (const listener of consumerAvailableListeners) { + listener(ptyId) + } + requestTitleSnapshot(ptyId, entry) + return true +} + +export function prepareTerminalSideEffectFactConsumer( + options: TerminalSideEffectFactConsumerOptions & { + activateOnPredecessorRelease?: boolean + } +): PreparedTerminalSideEffectFactConsumer { + ensureSideEffectChannelSubscription() + const entry: ConsumerEntry = { + callbacks: options.callbacks, + activateOnPredecessorRelease: options.activateOnPredecessorRelease === true, + authorityOutcomeProjection: options.authorityOutcomeProjection === 'event-keyed-idempotent', + ...(options.incarnationId ? { incarnationId: options.incarnationId } : {}), + restoreTitleOnRegister: options.restoreTitleOnRegister === true, + lastLiveTitleSeq: null + } + preparedConsumersByPtyId.set(options.ptyId, entry) + + const activate = (incarnationId?: string): boolean => + activatePreparedConsumer(options.ptyId, entry, incarnationId) + + return { + activate, + cancel(): void { + if (preparedConsumersByPtyId.get(options.ptyId) === entry) { + preparedConsumersByPtyId.delete(options.ptyId) + } + }, + unregister(): void { + if (preparedConsumersByPtyId.get(options.ptyId) === entry) { + preparedConsumersByPtyId.delete(options.ptyId) + return + } + if (consumersByPtyId.get(options.ptyId) !== entry) { + return + } + const successor = preparedConsumersByPtyId.get(options.ptyId) + if (successor?.activateOnPredecessorRelease) { + activatePreparedConsumer(options.ptyId, successor, successor.incarnationId) + return + } + consumersByPtyId.delete(options.ptyId) + } + } +} + /** * Register the single fact consumer for a PTY. A new registration replaces a * stale one for the same PTY (same semantics as the parked watcher registry): @@ -297,37 +362,9 @@ export type TerminalSideEffectFactConsumerOptions = { export function registerTerminalSideEffectFactConsumer( options: TerminalSideEffectFactConsumerOptions ): () => void { - ensureSideEffectChannelSubscription() - const entry: ConsumerEntry = { - callbacks: options.callbacks, - lastLiveTitleSeq: null - } - consumersByPtyId.set(options.ptyId, entry) - // Why before the snapshot request: draining live facts sets lastLiveTitleSeq, so the async title replay is correctly dropped as stale. - drainHandoffFactBuffer(options.ptyId, entry) - - if (options.restoreTitleOnRegister) { - const getSnapshot = (globalThis as { window?: Window }).window?.api?.pty?.getSideEffectSnapshot - if (typeof getSnapshot === 'function') { - void getSnapshot(options.ptyId) - .then((batch) => { - // Why: apply only while this registration is still the live - // consumer; a slow snapshot must not fire into a replaced one. - if (batch && consumersByPtyId.get(options.ptyId) === entry) { - applyBatchToConsumer(entry, { ...batch, replay: true }) - } - }) - .catch(() => {}) - } - } - - return () => { - if (consumersByPtyId.get(options.ptyId) === entry) { - consumersByPtyId.delete(options.ptyId) - // Why: the pane that replaces this consumer registers only after its async reattach resolves; hold facts across that handoff. - openHandoffFactBuffer(options.ptyId) - } - } + const prepared = prepareTerminalSideEffectFactConsumer(options) + prepared.activate() + return prepared.unregister } /** Test seam: deliver a batch as if it arrived on the channel. */ @@ -338,9 +375,8 @@ export function _dispatchTerminalSideEffectBatchForTest(batch: TerminalSideEffec /** Test seam: reset module state between tests. */ export function _resetTerminalSideEffectFactConsumersForTest(): void { consumersByPtyId.clear() - for (const ptyId of Array.from(handoffFactBuffersByPtyId.keys())) { - deleteHandoffFactBuffer(ptyId) - } + preparedConsumersByPtyId.clear() + consumerAvailableListeners.clear() channelUnsubscribe?.() channelUnsubscribe = null persistedAuthorityFlagCache = undefined diff --git a/src/renderer/src/components/terminal-pane/use-codex-pane-restart-handler.test.ts b/src/renderer/src/components/terminal-pane/use-codex-pane-restart-handler.test.ts new file mode 100644 index 00000000000..354ebcb5cfc --- /dev/null +++ b/src/renderer/src/components/terminal-pane/use-codex-pane-restart-handler.test.ts @@ -0,0 +1,23 @@ +// @vitest-environment happy-dom + +import { act, renderHook } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { useCodexPaneRestartHandler } from './use-codex-pane-restart-handler' + +describe('useCodexPaneRestartHandler', () => { + it('uses the current pane generation after a rerender', () => { + const restartPaneAtGeneration = vi.fn() + const view = renderHook( + ({ paneGeneration }) => + useCodexPaneRestartHandler({ paneGeneration, restartPaneAtGeneration }), + { initialProps: { paneGeneration: 7 } } + ) + + act(() => view.result.current(3)) + view.rerender({ paneGeneration: 8 }) + act(() => view.result.current(3)) + + expect(restartPaneAtGeneration).toHaveBeenNthCalledWith(1, 3, 7) + expect(restartPaneAtGeneration).toHaveBeenNthCalledWith(2, 3, 8) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/use-codex-pane-restart-handler.ts b/src/renderer/src/components/terminal-pane/use-codex-pane-restart-handler.ts new file mode 100644 index 00000000000..3995dd4a139 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/use-codex-pane-restart-handler.ts @@ -0,0 +1,12 @@ +import { useCallback } from 'react' + +export function useCodexPaneRestartHandler(args: { + paneGeneration: number + restartPaneAtGeneration: (paneId: number, paneGeneration: number) => void +}): (paneId: number) => void { + const { paneGeneration, restartPaneAtGeneration } = args + return useCallback( + (paneId) => restartPaneAtGeneration(paneId, paneGeneration), + [paneGeneration, restartPaneAtGeneration] + ) +} diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index bc230ecd419..afaa43b2a8a 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -122,7 +122,11 @@ import { fitAndFocusPanes, fitPanes } from './pane-helpers' import { markTerminalPinnedViewport } from '@/lib/pane-manager/terminal-scroll-intent' import { syncTerminalScrollIntentSoon } from '@/lib/pane-manager/terminal-scroll-intent-settle' import { registerRuntimeTerminalTab, scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph' -import { captureParkedTerminalPaneCandidates } from './terminal-parked-tab-watchers' +import { + captureParkedTerminalPaneCandidates, + registerMountedTerminalPaneCandidateReader +} from './terminal-parked-tab-watchers' +import { parkedTerminalSideEffectIdentityFromMutation } from './terminal-parked-side-effect-identity' import { e2eConfig } from '@/lib/e2e-config' import { PRIMARY_SELECTION_MAX_LENGTH, @@ -221,6 +225,7 @@ async function formatTerminalUrlTooltip( type UseTerminalPaneLifecycleDeps = { tabId: string + paneGeneration: number worktreeId: string cwd?: string startup?: { @@ -586,6 +591,7 @@ export function retireMountedTerminalPaneSurface(args: { /** Wires mounted terminal panes to renderer state and terminal event handling. */ export function useTerminalPaneLifecycle({ tabId, + paneGeneration, worktreeId, cwd, startup, @@ -794,6 +800,7 @@ export function useTerminalPaneLifecycle({ : startup const ptyDeps = { tabId, + paneGeneration, worktreeId, cwd: startupCwd, startup: startupWithSetupSplitWait, @@ -1604,6 +1611,28 @@ export function useTerminalPaneLifecycle({ persistLayoutSnapshot() scheduleRuntimeGraphSync() + const readParkedPaneCandidates = () => { + const activePaneId = manager.getActivePane()?.id + return manager.getPanes().map((candidatePane) => { + const transport = paneTransports.get(candidatePane.id) + const mutationIdentity = transport?.getMutationIdentity?.() + const sideEffectIdentity = parkedTerminalSideEffectIdentityFromMutation(mutationIdentity) + return { + ptyId: transport?.getPtyId() ?? null, + ...(mutationIdentity ? { mutationIdentity: { ...mutationIdentity } } : {}), + ...(sideEffectIdentity ? { sideEffectIdentity } : {}), + paneId: candidatePane.id, + leafId: candidatePane.leafId, + drivesTabTitle: candidatePane.id === activePaneId + } + }) + } + const unregisterParkedPaneCandidateReader = registerMountedTerminalPaneCandidateReader( + tabId, + worktreeId, + readParkedPaneCandidates + ) + // Why: deliver the startup command via the PTY connection path (waits for shell readiness), not terminal.paste() which can lose input before the shell reads stdin. function onCliSplitPane(event: Event): void { const detail = (event as CustomEvent).detail @@ -1742,16 +1771,8 @@ export function useTerminalPaneLifecycle({ } imeNativeTextForwarderDisposables.clear() // Why: hidden-view parking starts pane-less byte watchers right after unmount; record pane identities first so watchers write the same runtime-title slots. - captureParkedTerminalPaneCandidates( - tabId, - worktreeId, - manager.getPanes().map((capturedPane) => ({ - ptyId: paneTransports.get(capturedPane.id)?.getPtyId() ?? null, - paneId: capturedPane.id, - leafId: capturedPane.leafId, - drivesTabTitle: manager.getActivePane()?.id === capturedPane.id - })) - ) + captureParkedTerminalPaneCandidates(tabId, worktreeId, readParkedPaneCandidates()) + unregisterParkedPaneCandidateReader() for (const transport of paneTransports.values()) { const ptyId = transport.getPtyId() if ( diff --git a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts index 888f82237ee..26276ecfbfa 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts @@ -16,8 +16,8 @@ const mocks = vi.hoisted(() => ({ }, exemptTabIds: new Set(), exemptSelectCalls: 0, - /** Toggled to churn the park verdict the way the crash cluster does. */ - watcherCoverage: true + recordBreadcrumb: vi.fn(), + watcherEntriesByTabId: new Map() })) vi.mock('../../store', () => ({ @@ -41,23 +41,34 @@ vi.mock('./terminal-eviction-exempt-tabs', () => ({ })) vi.mock('./terminal-parked-tab-watchers', () => ({ - canWatcherCoverParkedTerminalTab: () => mocks.watcherCoverage, + activatePreparedParkedTerminalTabWatchers: vi.fn(), disposeParkedTerminalWatchersForWorktree: vi.fn(), - syncParkedTerminalTabWatchers: vi.fn() + getParkedTerminalWatcherEntry: (tabId: string) => mocks.watcherEntriesByTabId.get(tabId), + isParkedTerminalTabPreparationCurrent: () => true, + selectParkedTerminalPaneCandidateKey: () => '', + syncParkedTerminalTabWatchers: (args: { desiredParkedTabIds: ReadonlySet }) => { + for (const tabId of Array.from(mocks.watcherEntriesByTabId.keys())) { + if (!args.desiredParkedTabIds.has(tabId)) { + mocks.watcherEntriesByTabId.delete(tabId) + } + } + for (const tabId of args.desiredParkedTabIds) { + if (!mocks.watcherEntriesByTabId.has(tabId)) { + mocks.watcherEntriesByTabId.set(tabId, {}) + } + } + return new Set(args.desiredParkedTabIds) + } })) vi.mock('@/lib/crash-breadcrumb-recorder', () => ({ - recordRendererCrashBreadcrumb: vi.fn() + recordRendererCrashBreadcrumb: (...args: unknown[]) => mocks.recordBreadcrumb(...args) })) import { TERMINAL_TAB_COLD_PARK_DELAY_MS, TERMINAL_TAB_HOT_RETAIN_MS } from './terminal-hidden-view-parking' -import { - TERMINAL_TAB_PARK_FLIP_BURST_LIMIT, - TERMINAL_TAB_PARK_FLIP_WINDOW_MS -} from './terminal-park-verdict-flip-telemetry' import { useTerminalTabColdParking } from './use-terminal-tab-cold-parking' const WORKTREE_ID = 'wt-1' @@ -89,10 +100,11 @@ describe('useTerminalTabColdParking measure-clock contract', () => { vi.useRealTimers() mocks.exemptTabIds = new Set() mocks.exemptSelectCalls = 0 - mocks.watcherCoverage = true mocks.storeState.terminalLayoutsByTabId = {} mocks.storeState.sleepingAgentSessionsByPaneKey = {} mocks.storeState.runtimeStatusByEnvironmentId = new Map() + mocks.watcherEntriesByTabId.clear() + mocks.recordBreadcrumb.mockClear() }) it('parks paired-runtime tabs only when their exact host advertises restore', () => { @@ -122,39 +134,15 @@ describe('useTerminalTabColdParking measure-clock contract', () => { } }) - // Why: the flip-damping pin removes the tab from the parked set, and every - // hysteresis deadline of a long-hidden tab is already past — so the pin - // deadline is the only wakeup that can ever re-park it. Without scheduling - // it, damping silently becomes a permanent unpark: the pane (~4-5MB) stays - // mounted for the life of the window, which is the renderer-OOM cluster. - it('re-parks a damped tab once the pin expires with no other store change', () => { - const { result, rerender } = renderHook( - (args: ReturnType) => useTerminalTabColdParking(args), - { initialProps: hookArgs(false) } - ) - act(() => { - vi.advanceTimersByTime(TERMINAL_TAB_HOT_RETAIN_MS + 1) - }) - expect(result.current).toEqual(new Set(['tab-2'])) + it('records the final prepared handoff verdict instead of policy intent', () => { + const args = { ...hookArgs(false), coldParkTerminalPanes: true } + const { result } = renderHook(() => useTerminalTabColdParking(args)) - // Churn the coverage veto at render cadence — no clock advance, so every - // flip lands inside the burst window and damping engages. - for (let flip = 0; flip <= TERMINAL_TAB_PARK_FLIP_BURST_LIMIT + 1; flip += 1) { - mocks.watcherCoverage = flip % 2 === 1 - act(() => { - rerender(hookArgs(false)) - }) - } - mocks.watcherCoverage = true - act(() => { - rerender(hookArgs(false)) - }) - expect(result.current.size).toBe(0) - - act(() => { - vi.advanceTimersByTime(TERMINAL_TAB_PARK_FLIP_WINDOW_MS) - }) - expect(result.current).toEqual(new Set(['tab-2'])) + expect(result.current).toEqual(new Set(['tab-1', 'tab-2'])) + expect(mocks.recordBreadcrumb.mock.calls).toEqual([ + ['terminal_park_verdict_churn', { tabId: 'tab-1', trigger: 'flip', parked: true }], + ['terminal_park_verdict_churn', { tabId: 'tab-2', trigger: 'flip', parked: true }] + ]) }) // Why: the worktree layer preserves hiddenSince through a background-measure diff --git a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts index 6a7a0e1a498..f1e4fec8349 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts @@ -20,22 +20,14 @@ import { selectColdParkedTerminalTabs, type TerminalTabColdParkCandidate } from './terminal-hidden-view-parking' -import { - recordParkVerdictFlips, - type ParkVerdictFlipRecord -} from './terminal-park-verdict-flip-telemetry' -import { withholdUnparkableTerminalTabs } from './terminal-cold-park-withheld-tabs' import { getTerminalParkingPolicyOverrides } from './terminal-parking-e2e-overrides' import { selectEvictionExemptTerminalTabIds, selectEvictionExemptTerminalTabLayoutKey } from './terminal-eviction-exempt-tabs' import { selectSleepingRecordParkExemptTabIds } from './sleeping-record-park-exemption' -import { - canWatcherCoverParkedTerminalTab, - disposeParkedTerminalWatchersForWorktree, - syncParkedTerminalTabWatchers -} from './terminal-parked-tab-watchers' +import { useTerminalParkVerdictTelemetry } from './terminal-park-verdict-flip-telemetry' +import { useTerminalTabParkHandoff } from './use-terminal-tab-park-handoff' type TerminalOverlayTabAssignment = { groupId: string @@ -113,7 +105,6 @@ export function useTerminalTabColdParking(args: { const wasMeasuringHiddenWorktreeRef = useRef(false) const measureParkCooldownUntilRef = useRef(null) const terminalTabParkingTimersRef = useRef(new Map()) - const parkVerdictRecordsRef = useRef(new Map()) const [terminalTabParkingRevision, setTerminalTabParkingRevision] = useState(0) const [coldParkedTerminalTabIds, setColdParkedTerminalTabIds] = useState>( () => new Set() @@ -210,22 +201,17 @@ export function useTerminalTabColdParking(args: { }, ...overrides }) - const { parkedTabIds, parkVerdictPinUntilMsByTabId } = withholdUnparkableTerminalTabs({ - worktreeId, - terminalTabs, - coldParkedTabIds: nextColdParkedTerminalTabIds, - parkVerdictRecords: parkVerdictRecordsRef.current, - nowMs - }) setColdParkedTerminalTabIds((current) => - haveSameTerminalTabIds(current, parkedTabIds) ? current : parkedTabIds + haveSameTerminalTabIds(current, nextColdParkedTerminalTabIds) + ? current + : nextColdParkedTerminalTabIds ) for (const candidate of candidates) { if ( candidate.isVisible || candidate.hasActivityTerminalPortal || - parkedTabIds.has(candidate.id) + nextColdParkedTerminalTabIds.has(candidate.id) ) { continue } @@ -233,8 +219,6 @@ export function useTerminalTabColdParking(args: { parkingEnabled: terminalParkingEnabled, hiddenSinceMs: candidate.hiddenSinceMs, parkCooldownUntilMs: measureParkCooldownUntilRef.current, - // Why: pin expiry may be the only remaining wakeup after damping stops churn. - parkVerdictPinUntilMs: parkVerdictPinUntilMsByTabId.get(candidate.id) ?? null, nowMs, ...overrides }) @@ -278,11 +262,8 @@ export function useTerminalTabColdParking(args: { [evictionExemptLayoutKey, isForceParked, terminalTabs, worktreeId] ) - // Why: the rendered park verdict — worktree-level park (prop from - // Terminal.tsx) or per-tab cold park, never portal-hosted tabs. Render and - // the watcher-sync effect must share this exact set so watcher lifecycle - // tracks the committed unmounts. - const parkedTerminalTabIds = useMemo(() => { + // Policy intent only; observer preparation below decides what may unmount. + const desiredParkedTerminalTabIds = useMemo(() => { const parked = new Set() for (const terminalTab of terminalTabs) { const assignment = assignments.get(terminalTab.id) @@ -316,11 +297,7 @@ export function useTerminalTabColdParking(args: { // Why: activation-deferred tabs render no pane regardless of the park // policy, so watchers must own their side effects immediately. Targeted // restrictions do not enter this set or add a new eager watcher burst. - if ( - activationDeferredMountTabIds?.has(terminalTab.id) && - !hasActivityTerminalPortal && - canWatcherCoverParkedTerminalTab(worktreeId, terminalTab) - ) { + if (activationDeferredMountTabIds?.has(terminalTab.id) && !hasActivityTerminalPortal) { parked.add(terminalTab.id) } } @@ -339,36 +316,16 @@ export function useTerminalTabColdParking(args: { worktreeId ]) - // Why: observation only — records whether the *rendered* park verdict churns, - // so a crash bundle can confirm or refute a park-flip update loop. Watching - // the pre-gate cold set instead would miss loops driven by coldParkTerminalPanes - // or the portal/measuring gates. Changes no verdict; see - // terminal-park-verdict-flip-telemetry.ts. - useEffect(() => { - recordParkVerdictFlips({ - records: parkVerdictRecordsRef.current, - liveTabIds: new Set(terminalTabs.map((terminalTab) => terminalTab.id)), - nextParkedTabIds: parkedTerminalTabIds, - nowMs: Date.now() - }) - }, [parkedTerminalTabIds, terminalTabs]) - - // Why: runs in the same effect flush as the commit that parked/revealed the - // panes — watcher disposal therefore lands before any PTY data IPC can - // reach a freshly remounted pane, and watcher start lands after the parked - // pane's unmount capture. - useEffect(() => { - syncParkedTerminalTabWatchers({ - worktreeId, - tabs: terminalTabs, - parkedTabIds: parkedTerminalTabIds, - // Why: activation-deferred tabs have no prior pane-owned title slot; - // pull main's title-only snapshot when their watcher starts. - restoreTitleOnStartTabIds: activationDeferredMountTabIds ?? undefined - }) - }, [activationDeferredMountTabIds, parkedTerminalTabIds, terminalTabs, worktreeId]) - - useEffect(() => () => disposeParkedTerminalWatchersForWorktree(worktreeId), [worktreeId]) - + const parkedTerminalTabIds = useTerminalTabParkHandoff({ + worktreeId, + terminalTabs, + desiredParkedTabIds: desiredParkedTerminalTabIds, + shouldTrackPaneCandidates: + coldParkTerminalPanes || + coldParkedTerminalTabIds.size > 0 || + (activationDeferredMountTabIds?.size ?? 0) > 0, + activationDeferredMountTabIds + }) + useTerminalParkVerdictTelemetry({ terminalTabs, parkedTabIds: parkedTerminalTabIds }) return parkedTerminalTabIds } diff --git a/src/renderer/src/components/terminal-pane/use-terminal-tab-park-handoff.ts b/src/renderer/src/components/terminal-pane/use-terminal-tab-park-handoff.ts new file mode 100644 index 00000000000..0b285d23e9d --- /dev/null +++ b/src/renderer/src/components/terminal-pane/use-terminal-tab-park-handoff.ts @@ -0,0 +1,147 @@ +import { useEffect, useLayoutEffect, useMemo, useState } from 'react' +import type { TerminalTab } from '../../../../shared/types' +import { useAppStore } from '../../store' +import { + activatePreparedParkedTerminalTabWatchers, + disposeParkedTerminalWatchersForWorktree, + getParkedTerminalWatcherEntry, + isParkedTerminalTabPreparationCurrent, + selectParkedTerminalPaneCandidateKey, + syncParkedTerminalTabWatchers, + type ParkedTabWatcherEntry +} from './terminal-parked-tab-watchers' + +const EMPTY_TERMINAL_LAYOUTS_BY_TAB_ID: ReturnType< + typeof useAppStore.getState +>['terminalLayoutsByTabId'] = {} + +function haveSamePreparedWatcherEntries( + left: ReadonlyMap, + right: ReadonlyMap +): boolean { + if (left.size !== right.size) { + return false + } + for (const [tabId, entry] of left) { + if (right.get(tabId) !== entry) { + return false + } + } + return true +} + +function selectPreparedParkedTerminalTabIds(args: { + worktreeId: string + terminalTabs: readonly TerminalTab[] + desiredParkedTabIds: ReadonlySet + preparedWatcherEntriesByTabId: ReadonlyMap + paneCandidateKey: string +}): Set { + // Why: the key invalidates preparations after leaf, active-leaf, or PTY changes. + void args.paneCandidateKey + const parked = new Set() + for (const terminalTab of args.terminalTabs) { + if ( + args.desiredParkedTabIds.has(terminalTab.id) && + args.preparedWatcherEntriesByTabId.has(terminalTab.id) && + isParkedTerminalTabPreparationCurrent(args.worktreeId, terminalTab) + ) { + parked.add(terminalTab.id) + } + } + return parked +} + +export function useTerminalTabParkHandoff(args: { + worktreeId: string + terminalTabs: readonly TerminalTab[] + desiredParkedTabIds: ReadonlySet + shouldTrackPaneCandidates: boolean + activationDeferredMountTabIds?: ReadonlySet | null +}): ReadonlySet { + const [preparedWatcherEntriesByTabId, setPreparedWatcherEntriesByTabId] = useState< + ReadonlyMap + >(() => new Map()) + const trackPaneCandidates = + args.shouldTrackPaneCandidates || preparedWatcherEntriesByTabId.size > 0 + const terminalLayoutsByTabId = useAppStore((state) => + trackPaneCandidates ? state.terminalLayoutsByTabId : EMPTY_TERMINAL_LAYOUTS_BY_TAB_ID + ) + // Why: unrelated store traffic must not rescan every terminal layout. + const paneCandidateKey = useMemo( + () => + trackPaneCandidates + ? selectParkedTerminalPaneCandidateKey({ terminalLayoutsByTabId }, args.terminalTabs) + : '', + [args.terminalTabs, terminalLayoutsByTabId, trackPaneCandidates] + ) + const parkedTabIds = useMemo( + () => + selectPreparedParkedTerminalTabIds({ + worktreeId: args.worktreeId, + terminalTabs: args.terminalTabs, + desiredParkedTabIds: args.desiredParkedTabIds, + preparedWatcherEntriesByTabId, + paneCandidateKey + }), + [ + args.desiredParkedTabIds, + args.terminalTabs, + args.worktreeId, + paneCandidateKey, + preparedWatcherEntriesByTabId + ] + ) + + useLayoutEffect(() => { + const failedTabIds = activatePreparedParkedTerminalTabWatchers({ + worktreeId: args.worktreeId, + tabs: args.terminalTabs, + parkedTabIds + }) + if (failedTabIds.size > 0) { + setPreparedWatcherEntriesByTabId((current) => { + const next = new Map(current) + for (const tabId of failedTabIds) { + next.delete(tabId) + } + return next + }) + } + }, [args.terminalTabs, args.worktreeId, parkedTabIds]) + + // Child readers mount before preparation and reveal successors before disposal. + useEffect(() => { + const preparedTabIds = syncParkedTerminalTabWatchers({ + worktreeId: args.worktreeId, + tabs: args.terminalTabs, + parkedTabIds, + desiredParkedTabIds: args.desiredParkedTabIds, + restoreTitleOnStartTabIds: args.activationDeferredMountTabIds ?? undefined + }) + const preparedEntries = new Map() + for (const tabId of preparedTabIds) { + const entry = getParkedTerminalWatcherEntry(tabId) + if (entry) { + preparedEntries.set(tabId, entry) + } + } + setPreparedWatcherEntriesByTabId((current) => + haveSamePreparedWatcherEntries(current, preparedEntries) ? current : preparedEntries + ) + }, [ + args.activationDeferredMountTabIds, + args.desiredParkedTabIds, + args.terminalTabs, + args.worktreeId, + paneCandidateKey, + parkedTabIds + ]) + + useEffect( + () => () => disposeParkedTerminalWatchersForWorktree(args.worktreeId), + [args.worktreeId] + ) + + return parkedTabIds +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 9a4dc1498f9..6483167d2c9 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -2936,6 +2936,46 @@ "retryingBody": "Orca will retry for up to one minute. This terminal will resume if the connection returns.", "disconnectedBody": "Automatic retries stopped. Reconnect to resume this terminal session.", "reconnectButton": "Reconnect" + }, + "TerminalLegacyRecoveryBanner": { + "reason": { + "ambiguousPane": "Pane generation is ambiguous", + "endpointUnproved": "Endpoint identity could not be proved", + "ptyUnproved": "Physical terminal identity could not be proved", + "unsupportedPlatform": "Platform proof is unavailable", + "workerUnreachable": "Previous terminal worker was unreachable", + "workspaceMismatch": "Workspace evidence did not match" + }, + "preservation": { + "isolated": "Orca retained the previous endpoint identity, isolated it from current terminal authority, and confirmed shutdown grace is disabled.", + "evidenceOnly": "Orca retained the recovery evidence, but could not prove whether the previous terminal process is still running.", + "unreachable": "The previous worker was unreachable. Orca retained this recovery record, but could not prove whether the terminal process is still running.", + "unsupported": "This platform could not provide the proof required for migration. Orca retained this recovery record, but could not prove whether the terminal process is still running.", + "allIsolated": "Orca retained the previous endpoint identities, isolated them from current terminal authority, and confirmed shutdown grace is disabled.", + "allEvidenceOnly": "Orca retained recovery records, but could not prove whether the previous terminal processes are still running.", + "mixed": "Some previous endpoints are isolated with shutdown grace disabled. For the others, Orca retained recovery records but could not prove whether the processes are still running." + }, + "workspace": { + "gitWorktree": "Git worktree", + "folder": "Folder workspace", + "floating": "Floating terminal" + }, + "timestampUnavailable": "Unavailable", + "terminalLabel": "Terminal {{value0}}", + "copyDetails": "Copy details", + "field": { + "workspace": "Workspace", + "evidenceDigest": "Evidence digest", + "observed": "Observed" + }, + "copySucceeded": "Recovery details copied.", + "copyFailed": "Recovery details could not be copied. Try again.", + "singularTitle": "Previous terminal needs review", + "pluralTitle": "{{value0}} previous terminals need review", + "singularBody": "Orca could not safely match this terminal after an upgrade. Orca did not attach to, stop, or replace it.", + "pluralBody": "Orca could not safely match these terminals after an upgrade. Orca did not attach to, stop, or replace them.", + "hideDetails": "Hide details", + "showDetails": "Show details" } } }, @@ -6450,6 +6490,19 @@ "682293cadf": "Show the Orca Mobile button at the top of the left sidebar.", "d4f2b65f30": "Show the Orca Mobile shortcut in the sidebar." }, + "MobileIdentityResetSection": { + "title": "Reset access identity", + "description": "Replace the identity used to authorize paired devices and remote connections.", + "helper": "Use this when the current access identity should no longer be trusted. All devices must be paired again.", + "pending": "Reset is waiting for every known host and Relay acknowledgement. Keep Orca open or retry after unavailable hosts return.", + "confirmTitle": "Reset access identity?", + "confirmDescription": "Existing mobile, runtime, and remote connections will be disconnected and must be paired again.", + "confirmLabel": "Reset access identity", + "action": "Reset access identity", + "retry": "Retry reset", + "completed": "Access identity reset. Pair devices again to restore access.", + "failed": "Access identity reset is still pending. Retry when the required hosts are available." + }, "NotificationsPane": { "906b4afebf": "Send Test Notification", "2772d2f257": "Skip notifications when the triggering worktree is already visible.", diff --git a/src/renderer/src/lib/agent-background-session-exit-handler.ts b/src/renderer/src/lib/agent-background-session-exit-handler.ts new file mode 100644 index 00000000000..f3e803c8c5e --- /dev/null +++ b/src/renderer/src/lib/agent-background-session-exit-handler.ts @@ -0,0 +1,33 @@ +import type { LaunchAgentBackgroundSessionArgs } from './agent-background-session-contract' + +type AgentBackgroundSessionExitHandlerOptions = { + isHandled: () => boolean + markHandled: () => void + unsubscribeExit: () => void + unsubscribeData: () => void + clearStartupDelivery: () => void + getTabId: () => string | null + clearTabPtyId: (tabId: string, ptyId: string) => void + clearAgentLaunchConfig: () => void + onExit: LaunchAgentBackgroundSessionArgs['onExit'] +} + +export function createAgentBackgroundSessionExitHandler( + options: AgentBackgroundSessionExitHandlerOptions +): (ptyId: string, code: number) => void { + return (ptyId, code) => { + if (options.isHandled()) { + return + } + options.markHandled() + options.unsubscribeExit() + options.unsubscribeData() + options.clearStartupDelivery() + const tabId = options.getTabId() + if (tabId) { + options.clearTabPtyId(tabId, ptyId) + } + options.clearAgentLaunchConfig() + options.onExit?.(ptyId, code) + } +} diff --git a/src/renderer/src/lib/agent-background-session-failure-cleanup.ts b/src/renderer/src/lib/agent-background-session-failure-cleanup.ts new file mode 100644 index 00000000000..34848d3c2d9 --- /dev/null +++ b/src/renderer/src/lib/agent-background-session-failure-cleanup.ts @@ -0,0 +1,22 @@ +import { runBestEffortAgentBackgroundCleanups } from './agent-background-session-cleanup' + +export async function cleanupFailedAgentBackgroundSession(args: { + releaseTerminalOwnership: () => void + unsubscribeExit: () => void + unsubscribeData: () => void + disposeEagerPtyBuffer: () => void + clearStartupDelivery: () => void + clearAgentLaunchConfig: () => void + clearTabPtyId?: () => void + retireProvider: () => Promise + closeCreatedTab?: () => void +}): Promise { + runBestEffortAgentBackgroundCleanups(args.releaseTerminalOwnership) + runBestEffortAgentBackgroundCleanups(args.unsubscribeExit, args.unsubscribeData) + runBestEffortAgentBackgroundCleanups(args.disposeEagerPtyBuffer) + runBestEffortAgentBackgroundCleanups(args.clearStartupDelivery) + runBestEffortAgentBackgroundCleanups(args.clearTabPtyId ?? (() => {})) + runBestEffortAgentBackgroundCleanups(args.clearAgentLaunchConfig) + await args.retireProvider() + runBestEffortAgentBackgroundCleanups(args.closeCreatedTab ?? (() => {})) +} diff --git a/src/renderer/src/lib/agent-background-session-startup-plan.ts b/src/renderer/src/lib/agent-background-session-startup-plan.ts new file mode 100644 index 00000000000..c68d7fc8a4e --- /dev/null +++ b/src/renderer/src/lib/agent-background-session-startup-plan.ts @@ -0,0 +1,70 @@ +import type { useAppStore } from '@/store' +import { buildAgentStartupPlan, type AgentStartupPlan } from '@/lib/tui-agent-startup' +import type { AgentBackgroundLaunchHost } from '@/lib/agent-background-session-launch-host' +import { + resolveTuiAgentLaunchArgs, + resolveTuiAgentLaunchEnv +} from '../../../shared/tui-agent-launch-defaults' +import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' +import { resolveLocalWindowsAgentStartupShell } from '../../../shared/windows-terminal-shell' +import type { TuiAgent } from '../../../shared/types' + +type LaunchStore = ReturnType + +export type AgentBackgroundSessionStartupPlan = { + startupPlan: AgentStartupPlan + trimmedPrompt: string + hasPrompt: boolean + isFollowupPath: boolean + pasteDraftAfterLaunch: string | null +} + +export async function prepareAgentBackgroundSessionStartup(args: { + store: LaunchStore + agent: TuiAgent + worktreePath: string | undefined + launchHost: AgentBackgroundLaunchHost + prompt?: string +}): Promise { + const { store, agent, worktreePath, launchHost, prompt } = args + const cmdOverrides = store.settings?.agentCmdOverrides ?? {} + const agentArgs = resolveTuiAgentLaunchArgs(agent, store.settings?.agentDefaultArgs) + const agentEnv = resolveTuiAgentLaunchEnv(agent, store.settings?.agentDefaultEnv) + const preflight = TUI_AGENT_CONFIG[agent].preflightTrust + if (preflight && worktreePath && window.api.agentTrust?.markTrusted) { + try { + await window.api.agentTrust.markTrusted({ + preset: preflight, + workspacePath: worktreePath, + ...(launchHost.connectionId ? { connectionId: launchHost.connectionId } : {}) + }) + } catch { + // Best-effort: the user can still accept the trust prompt. + } + } + const { platform, isRemote } = launchHost + const startupShell = resolveLocalWindowsAgentStartupShell({ + platform, + isRemote, + terminalWindowsShell: store.settings?.terminalWindowsShell + }) + const trimmedPrompt = prompt?.trim() ?? '' + const hasPrompt = trimmedPrompt.length > 0 + const isFollowupPath = TUI_AGENT_CONFIG[agent].promptInjectionMode === 'stdin-after-start' + const pasteDraftAfterLaunch = hasPrompt && isFollowupPath ? trimmedPrompt : null + const startupPlan = buildAgentStartupPlan({ + agent, + prompt: hasPrompt && !isFollowupPath ? trimmedPrompt : '', + cmdOverrides, + agentArgs, + agentEnv, + platform, + shell: startupShell, + isRemote, + allowEmptyPromptLaunch: !hasPrompt || isFollowupPath + }) + if (!startupPlan) { + return null + } + return { startupPlan, trimmedPrompt, hasPrompt, isFollowupPath, pasteDraftAfterLaunch } +} diff --git a/src/renderer/src/lib/launch-agent-background-session.test.ts b/src/renderer/src/lib/launch-agent-background-session.test.ts index 78fd3a91f6e..059487c3d4c 100644 --- a/src/renderer/src/lib/launch-agent-background-session.test.ts +++ b/src/renderer/src/lib/launch-agent-background-session.test.ts @@ -385,6 +385,43 @@ describe('launchAgentBackgroundSession', () => { ) }) + it('keeps delayed SSH startup delivery bound to the spawned incarnation', async () => { + vi.useFakeTimers() + const administrativeWrite = vi.fn() + const ptyId = toAppSshPtyId('ssh-a', 'pty-1') + const access = { + mode: 'exact' as const, + evidence: { incarnationId: 'incarnation-1', paneGeneration: 0 } + } + state.repos = [{ id: 'repo-1', connectionId: 'ssh-a', path: '/repo' }] + state.sshConnectionStates = new Map([['ssh-a', { status: 'connected' }]]) + mockSpawn.mockResolvedValue({ id: ptyId, administrativeMutationAccess: access }) + ;( + window.api.pty as typeof window.api.pty & { administrativeWrite: typeof administrativeWrite } + ).administrativeWrite = administrativeWrite + try { + const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') + await launchAgentBackgroundSession({ + agent: 'codex', + worktreeId: 'wt-1', + prompt: 'run the automation' + }) + + const dataSidecar = mockSubscribeToPtyData.mock.calls[0]?.[1] as (data: string) => void + dataSidecar('\x1b]777;orca-shell-ready\x07remote$ ') + await vi.advanceTimersByTimeAsync(50) + + expect(administrativeWrite).toHaveBeenCalledWith( + ptyId, + expect.stringContaining('codex'), + access + ) + expect(mockWrite).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + it('skips the duplicate OSC store write under main side-effect authority', async () => { // Why: main already routes OSC 9999 through the hook server to the store // (agentStatus:set); a second write here would race the authoritative diff --git a/src/renderer/src/lib/launch-agent-background-session.ts b/src/renderer/src/lib/launch-agent-background-session.ts index f65204d6558..74851a40bd0 100644 --- a/src/renderer/src/lib/launch-agent-background-session.ts +++ b/src/renderer/src/lib/launch-agent-background-session.ts @@ -1,5 +1,4 @@ import { useAppStore } from '@/store' -import { buildAgentStartupPlan } from '@/lib/tui-agent-startup' import type { LaunchAgentBackgroundSessionArgs, LaunchAgentBackgroundSessionResult @@ -7,11 +6,6 @@ import type { import { tuiAgentToAgentKind } from '@/lib/telemetry' import { scheduleAgentBackgroundDraft } from '@/lib/agent-background-draft-delivery' import { requestBackgroundTerminalWorktreeMount } from '@/components/terminal/background-terminal-worktree-mount' -import { - resolveTuiAgentLaunchArgs, - resolveTuiAgentLaunchEnv -} from '../../../shared/tui-agent-launch-defaults' -import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' import { resolveAgentBackgroundLaunchHost } from '@/lib/agent-background-session-launch-host' import { makePaneKey } from '../../../shared/stable-pane-id' import { @@ -31,8 +25,8 @@ import { import { createSshBackgroundStartupDelivery } from '@/lib/ssh-background-startup-delivery' import { shouldUseShellReadyStartupDelivery } from '../../../shared/codex-startup-delivery' import { isMainTerminalSideEffectAuthorityForPty } from '@/components/terminal-pane/terminal-side-effect-facts-handler' -import { resolveLocalWindowsAgentStartupShell } from '../../../shared/windows-terminal-shell' -import { runBestEffortAgentBackgroundCleanups } from '@/lib/agent-background-session-cleanup' +import { cleanupFailedAgentBackgroundSession } from '@/lib/agent-background-session-failure-cleanup' +import { prepareAgentBackgroundSessionStartup } from '@/lib/agent-background-session-startup-plan' import type { bindAutomationTerminal } from '@/lib/automation-terminal-ownership' import { adoptAgentBackgroundSessionTab, @@ -40,6 +34,9 @@ import { } from '@/lib/adopt-agent-background-session-tab' import { createBackgroundAgentStatusConsumer } from '@/lib/background-agent-status-consumer' import { isWslUncPath } from '../../../shared/wsl-paths' +import { writePtyWithAdministrativeMutationAccess } from '@/lib/pty-administrative-mutations' +import type { PtyAdministrativeMutationAccess } from '../../../shared/pty-mutation-identity' +import { createAgentBackgroundSessionExitHandler } from '@/lib/agent-background-session-exit-handler' export async function launchAgentBackgroundSession( args: LaunchAgentBackgroundSessionArgs @@ -52,9 +49,6 @@ export async function launchAgentBackgroundSession( if (!worktree) { throw new Error('The target workspace is no longer available.') } - const cmdOverrides = store.settings?.agentCmdOverrides ?? {} - const agentArgs = resolveTuiAgentLaunchArgs(agent, store.settings?.agentDefaultArgs) - const agentEnv = resolveTuiAgentLaunchEnv(agent, store.settings?.agentDefaultEnv) // Folder launch ownership cannot be derived from a repo row (#2989). const launchHost = resolveAgentBackgroundLaunchHost({ store, @@ -62,43 +56,17 @@ export async function launchAgentBackgroundSession( worktreePath: worktree.path, repo }) - const preflight = TUI_AGENT_CONFIG[agent].preflightTrust - if (preflight && worktree.path && window.api.agentTrust?.markTrusted) { - try { - await window.api.agentTrust.markTrusted({ - preset: preflight, - workspacePath: worktree.path, - ...(launchHost.connectionId ? { connectionId: launchHost.connectionId } : {}) - }) - } catch { - // Best-effort: the user can still accept the trust prompt. - } - } - const { platform: launchPlatform, isRemote } = launchHost - const startupShell = resolveLocalWindowsAgentStartupShell({ - platform: launchPlatform, - isRemote, - terminalWindowsShell: store.settings?.terminalWindowsShell - }) - const trimmedPrompt = prompt?.trim() ?? '' - const hasPrompt = trimmedPrompt.length > 0 - const isFollowupPath = TUI_AGENT_CONFIG[agent].promptInjectionMode === 'stdin-after-start' - - const pasteDraftAfterLaunch = hasPrompt && isFollowupPath ? trimmedPrompt : null - const startupPlan = buildAgentStartupPlan({ + const startup = await prepareAgentBackgroundSessionStartup({ + store, agent, - prompt: hasPrompt && !isFollowupPath ? trimmedPrompt : '', - cmdOverrides, - agentArgs, - agentEnv, - platform: launchPlatform, - shell: startupShell, - isRemote, - allowEmptyPromptLaunch: !hasPrompt || isFollowupPath + worktreePath: worktree.path, + launchHost, + prompt }) - if (!startupPlan) { + if (!startup) { return null } + const { startupPlan, trimmedPrompt, hasPrompt, isFollowupPath, pasteDraftAfterLaunch } = startup // A hidden run tab must never be store-visible without its PTY (#2989). const { reservedTabId, leafId, launchToken, launchRegistration, paneEnv } = @@ -111,6 +79,7 @@ export async function launchAgentBackgroundSession( }) let paneKey = makePaneKey(reservedTabId, leafId) const sshConnectionId = launchHost.connectionId + let startupMutationAccess: PtyAdministrativeMutationAccess = { mode: 'unavailable' } const sshStartupDelivery = createSshBackgroundStartupDelivery({ command: sshConnectionId ? startupPlan.launchCommand : null, waitForShellReady: @@ -119,7 +88,9 @@ export async function launchAgentBackgroundSession( command: startupPlan.launchCommand, startupCommandDelivery: startupPlan.startupCommandDelivery }), - write: (ptyId, data) => window.api.pty.write(ptyId, data) + write: (ptyId, data) => { + writePtyWithAdministrativeMutationAccess(ptyId, data, startupMutationAccess) + } }) // Route by the worktree's owner host, not the focused runtime. const runtimeTarget = getActiveRuntimeTarget( @@ -134,20 +105,19 @@ export async function launchAgentBackgroundSession( let terminalOwnership: ReturnType = null let unsubscribeExit = (): void => {}, unsubscribeData = (): void => {} - const handleExit = (exitPtyId: string, code: number): void => { - if (exitHandled) { - return - } - exitHandled = true - unsubscribeExit() - unsubscribeData() - sshStartupDelivery.clear() - if (tab) { - useAppStore.getState().clearTabPtyId(tab.id, exitPtyId) - } - useAppStore.getState().clearAgentLaunchConfig(paneKey) - onExit?.(exitPtyId, code) - } + const handleExit = createAgentBackgroundSessionExitHandler({ + isHandled: () => exitHandled, + markHandled: () => { + exitHandled = true + }, + unsubscribeExit: () => unsubscribeExit(), + unsubscribeData: () => unsubscribeData(), + clearStartupDelivery: () => sshStartupDelivery.clear(), + getTabId: () => tab?.id ?? null, + clearTabPtyId: (tabId, exitPtyId) => useAppStore.getState().clearTabPtyId(tabId, exitPtyId), + clearAgentLaunchConfig: () => useAppStore.getState().clearAgentLaunchConfig(paneKey), + onExit + }) // Why: local/SSH status facts already pass through main's authoritative // scanner; remote-runtime bytes still need this renderer-side store write. const mainOwnsAgentStatusWrites = isMainTerminalSideEffectAuthorityForPty({ @@ -219,6 +189,7 @@ export async function launchAgentBackgroundSession( } }) ptyId = result.id + startupMutationAccess = result.administrativeMutationAccess ?? { mode: 'legacy' } returnedLaunchConfig = result.launchConfig } const adopted = await adoptAgentBackgroundSessionTab({ @@ -301,24 +272,25 @@ export async function launchAgentBackgroundSession( // Why: terminal creation and stream subscription are separate remote calls. // A failure between them must not strand an invisible runtime terminal. exitHandled = true - terminalOwnership?.release() const createdTab = tab - runBestEffortAgentBackgroundCleanups(unsubscribeExit, unsubscribeData) - runBestEffortAgentBackgroundCleanups(() => eagerPtyBuffer?.dispose()) - runBestEffortAgentBackgroundCleanups(() => sshStartupDelivery.clear()) - if (createdTab) { - runBestEffortAgentBackgroundCleanups(() => store.clearTabPtyId(createdTab.id, ptyId)) - } - runBestEffortAgentBackgroundCleanups(() => store.clearAgentLaunchConfig(paneKey)) - if (ptyId) { - await retireProvider({ ptyId, runtimeTarget, runtimeTerminalHandle }) - } - if (createdTab) { - // Cleanup closes must not enter the reopen stack. - runBestEffortAgentBackgroundCleanups(() => - store.closeTab(createdTab.id, { recordInteraction: false, reason: 'cleanup' }) - ) - } + await cleanupFailedAgentBackgroundSession({ + releaseTerminalOwnership: () => terminalOwnership?.release(), + unsubscribeExit, + unsubscribeData, + disposeEagerPtyBuffer: () => eagerPtyBuffer?.dispose(), + clearStartupDelivery: () => sshStartupDelivery.clear(), + clearAgentLaunchConfig: () => store.clearAgentLaunchConfig(paneKey), + ...(createdTab + ? { + clearTabPtyId: () => store.clearTabPtyId(createdTab.id, ptyId), + closeCreatedTab: () => + // Cleanup closes must not enter the reopen stack. + store.closeTab(createdTab.id, { recordInteraction: false, reason: 'cleanup' }) + } + : {}), + retireProvider: () => + ptyId ? retireProvider({ ptyId, runtimeTarget, runtimeTerminalHandle }) : Promise.resolve() + }) throw error } } diff --git a/src/renderer/src/lib/pty-administrative-mutations.test.ts b/src/renderer/src/lib/pty-administrative-mutations.test.ts new file mode 100644 index 00000000000..3321c453b00 --- /dev/null +++ b/src/renderer/src/lib/pty-administrative-mutations.test.ts @@ -0,0 +1,118 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + capturePtyAdministrativeMutationAccess, + killPtyAtCurrentIncarnation, + killPtyWithAdministrativeMutationAccess, + writeImmediateCurrentPty, + writeImmediateCurrentPtyAccepted, + writePtyWithAdministrativeMutationAccess +} from './pty-administrative-mutations' + +describe('PTY administrative mutations', () => { + const originalWindow = globalThis.window + + afterEach(() => { + ;(globalThis as { window: Window }).window = originalWindow + }) + + it('routes current-session operations through the explicit administrative API', async () => { + const administrativeWriteImmediateCurrent = vi.fn() + const administrativeWriteImmediateCurrentAccepted = vi.fn(async () => true) + const captureAdministrativeMutationAccess = vi.fn(async () => [ + { id: 'pty-1', access: { mode: 'legacy' as const } } + ]) + const administrativeKill = vi.fn(async () => undefined) + ;(globalThis as { window: Window }).window = { + api: { + pty: { + administrativeWriteImmediateCurrent, + administrativeWriteImmediateCurrentAccepted, + captureAdministrativeMutationAccess, + administrativeKill + } + } + } as unknown as Window + + writeImmediateCurrentPty('pty-1', 'input') + await expect(writeImmediateCurrentPtyAccepted('pty-1', 'interrupt')).resolves.toBe(true) + await killPtyAtCurrentIncarnation('pty-1') + + expect(administrativeWriteImmediateCurrent).toHaveBeenCalledWith('pty-1', 'input') + expect(administrativeWriteImmediateCurrentAccepted).toHaveBeenCalledWith('pty-1', 'interrupt') + expect(captureAdministrativeMutationAccess).toHaveBeenCalledWith(['pty-1']) + expect(administrativeKill).toHaveBeenCalledWith('pty-1', { mode: 'legacy' }) + }) + + it('keeps old preload peers on the legacy API without changing call shape', async () => { + const write = vi.fn() + const writeAccepted = vi.fn(async () => true) + const kill = vi.fn(async () => undefined) + ;(globalThis as { window: Window }).window = { + api: { pty: { write, writeAccepted, kill } } + } as unknown as Window + + writeImmediateCurrentPty('pty-1', 'input') + await writeImmediateCurrentPtyAccepted('pty-1', 'interrupt') + await killPtyAtCurrentIncarnation('pty-1') + + expect(write).toHaveBeenCalledWith('pty-1', 'input') + expect(writeAccepted).toHaveBeenCalledWith('pty-1', 'interrupt') + expect(kill).toHaveBeenCalledWith('pty-1') + }) + + it('preserves captured incarnation evidence across delayed write and kill dispatch', async () => { + const access = { + mode: 'exact' as const, + evidence: { incarnationId: 'incarnation-1', paneGeneration: 7 } + } + const administrativeWrite = vi.fn() + const administrativeKill = vi.fn(async () => undefined) + ;(globalThis as { window: Window }).window = { + api: { + pty: { administrativeWrite, administrativeKill } + } + } as unknown as Window + + expect(writePtyWithAdministrativeMutationAccess('pty-reused', 'input', access)).toBe(true) + await killPtyWithAdministrativeMutationAccess('pty-reused', access) + + expect(administrativeWrite).toHaveBeenCalledWith('pty-reused', 'input', access) + expect(administrativeKill).toHaveBeenCalledWith('pty-reused', access) + }) + + it('fails closed when an old preload cannot carry exact evidence', async () => { + const write = vi.fn() + const kill = vi.fn(async () => undefined) + const access = { + mode: 'exact' as const, + evidence: { incarnationId: 'incarnation-1' } + } + ;(globalThis as { window: Window }).window = { + api: { pty: { write, writeAccepted: vi.fn(), kill } } + } as unknown as Window + + expect(writePtyWithAdministrativeMutationAccess('pty-reused', 'input', access)).toBe(false) + await expect(killPtyWithAdministrativeMutationAccess('pty-reused', access)).rejects.toThrow( + 'access_unsupported' + ) + expect(write).not.toHaveBeenCalled() + expect(kill).not.toHaveBeenCalled() + }) + + it('marks omitted capture rows unavailable instead of inventing legacy access', async () => { + ;(globalThis as { window: Window }).window = { + api: { + pty: { + captureAdministrativeMutationAccess: vi.fn(async () => []), + write: vi.fn(), + writeAccepted: vi.fn(), + kill: vi.fn() + } + } + } as unknown as Window + + const access = await capturePtyAdministrativeMutationAccess(['missing']) + + expect(access.get('missing')).toBeUndefined() + }) +}) diff --git a/src/renderer/src/lib/pty-administrative-mutations.ts b/src/renderer/src/lib/pty-administrative-mutations.ts new file mode 100644 index 00000000000..49893bd0809 --- /dev/null +++ b/src/renderer/src/lib/pty-administrative-mutations.ts @@ -0,0 +1,72 @@ +import type { PtyAdministrativeMutationAccess } from '../../../shared/pty-mutation-identity' + +export function writeImmediateCurrentPty(ptyId: string, data: string): void { + const write = + window.api.pty.administrativeWriteImmediateCurrent ?? + window.api.pty.administrativeWriteCurrent ?? + window.api.pty.write + write(ptyId, data) +} + +export function writeImmediateCurrentPtyAccepted(ptyId: string, data: string): Promise { + const write = + window.api.pty.administrativeWriteImmediateCurrentAccepted ?? + window.api.pty.administrativeWriteCurrentAccepted ?? + window.api.pty.writeAccepted + return write(ptyId, data) +} + +export function writePtyWithAdministrativeMutationAccess( + ptyId: string, + data: string, + access: PtyAdministrativeMutationAccess +): boolean { + const exactWrite = window.api.pty.administrativeWrite + if (exactWrite) { + if (access.mode === 'unavailable') { + return false + } + exactWrite(ptyId, data, access) + return true + } + if (access.mode !== 'legacy') { + return false + } + writeImmediateCurrentPty(ptyId, data) + return true +} + +export async function capturePtyAdministrativeMutationAccess( + ptyIds: readonly string[] +): Promise> { + const capture = window.api.pty.captureAdministrativeMutationAccess + if (!capture) { + return new Map(ptyIds.map((ptyId) => [ptyId, { mode: 'legacy' } as const])) + } + return new Map((await capture([...new Set(ptyIds)])).map(({ id, access }) => [id, access])) +} + +export function killPtyWithAdministrativeMutationAccess( + ptyId: string, + access: PtyAdministrativeMutationAccess, + opts?: { keepHistory?: boolean } +): Promise { + const exactKill = window.api.pty.administrativeKill + if (exactKill) { + return opts ? exactKill(ptyId, access, opts) : exactKill(ptyId, access) + } + if (access.mode !== 'legacy') { + return Promise.reject(new Error('pty_administrative_mutation_access_unsupported')) + } + const legacyKill = window.api.pty.administrativeKillCurrent ?? window.api.pty.kill + return opts ? legacyKill(ptyId, opts) : legacyKill(ptyId) +} + +export async function killPtyAtCurrentIncarnation( + ptyId: string, + opts?: { keepHistory?: boolean } +): Promise { + const accessByPtyId = await capturePtyAdministrativeMutationAccess([ptyId]) + const access = accessByPtyId.get(ptyId) ?? { mode: 'unavailable' } + await killPtyWithAdministrativeMutationAccess(ptyId, access, opts) +} diff --git a/src/renderer/src/lib/retire-unowned-background-terminal.ts b/src/renderer/src/lib/retire-unowned-background-terminal.ts index 40a0b0afb60..11d50632026 100644 --- a/src/renderer/src/lib/retire-unowned-background-terminal.ts +++ b/src/renderer/src/lib/retire-unowned-background-terminal.ts @@ -1,6 +1,7 @@ import { useAppStore } from '@/store' import { callRuntimeRpc, type RuntimeClientTarget } from '@/runtime/runtime-rpc-client' import { isTerminalTabPresent } from '@/store/slices/terminal-tab-retirement' +import { killPtyAtCurrentIncarnation } from '@/lib/pty-administrative-mutations' export async function retireUnownedTerminal(args: { /** Present tab id, or `{ worktreeId }` for a launch whose tab is created after the spawn. */ @@ -37,7 +38,7 @@ export async function retireProvider(args: { terminal: args.runtimeTerminalHandle }) } else if (args.runtimeTarget.kind === 'local') { - await window.api.pty.kill(args.ptyId) + await killPtyAtCurrentIncarnation(args.ptyId) } } catch { // Best-effort provider teardown; the retired tab must not be recreated. diff --git a/src/renderer/src/lib/terminal-authority-app-projection-controller.test.ts b/src/renderer/src/lib/terminal-authority-app-projection-controller.test.ts new file mode 100644 index 00000000000..ab5b285ae1e --- /dev/null +++ b/src/renderer/src/lib/terminal-authority-app-projection-controller.test.ts @@ -0,0 +1,262 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + TERMINAL_AUTHORITY_APP_PROJECTION_VERSION, + type TerminalAuthorityAppEventKey, + type TerminalAuthorityAppFactProjection, + type TerminalAuthorityAppPaneProjection, + type TerminalAuthorityAppProjectionDelta +} from '../../../shared/terminal-authority-app-projection' + +const { applyFact, applyExit } = vi.hoisted(() => ({ + applyFact: vi.fn(() => Promise.resolve(true)), + applyExit: vi.fn(() => Promise.resolve(true)) +})) + +vi.mock('./terminal-authority-app-projection-policy', () => ({ + applyTerminalAuthorityAppProjectionFact: applyFact, + applyTerminalAuthorityAppProjectionExit: applyExit +})) + +import { TerminalAuthorityAppProjectionController } from './terminal-authority-app-projection-controller' + +beforeEach(() => { + applyFact.mockReset().mockResolvedValue(true) + applyExit.mockReset().mockResolvedValue(true) +}) + +describe('TerminalAuthorityAppProjectionController', () => { + it('installs the listener before snapshot and coalesces a racing newer delta', async () => { + const snapshotReady = deferred() + const harness = transportHarness(() => snapshotReady.promise) + const controller = controllerFor(harness) + const started = controller.start() + const newer = row(2, 'title') + + harness.emit(delta([newer])) + snapshotReady.resolve(snapshot([row(1, 'title')])) + await started + await vi.waitFor(() => expect(applyFact).toHaveBeenCalled()) + + expect(harness.listenOrder).toEqual(['listen', 'subscribe']) + expect(controller.snapshotRows()[0]?.latestEvent?.sequence).toBe(2) + expect(applyFact).toHaveBeenLastCalledWith(newer, newer.facts.title) + controller.dispose() + }) + + it('reapplies the same event identity safely after renderer restart without ACK authority', async () => { + const current = row(7, 'title') + const firstHarness = transportHarness(() => Promise.resolve(snapshot([current]))) + const first = controllerFor(firstHarness, 'renderer-1') + await first.start() + await vi.waitFor(() => expect(applyFact).toHaveBeenCalledTimes(1)) + first.dispose() + + const secondHarness = transportHarness(() => Promise.resolve(snapshot([current], 'renderer-2'))) + const second = controllerFor(secondHarness, 'renderer-2') + await second.start() + await vi.waitFor(() => expect(applyFact).toHaveBeenCalledTimes(2)) + + const calls = applyFact.mock.calls as unknown as readonly [ + TerminalAuthorityAppPaneProjection, + TerminalAuthorityAppFactProjection + ][] + expect(calls.map((call) => call[1].event)).toEqual([ + current.facts.title!.event, + current.facts.title!.event + ]) + second.dispose() + }) + + it('retains a failed unmounted policy row and retries when policy becomes available', async () => { + applyFact.mockResolvedValueOnce(false).mockResolvedValueOnce(true) + const errors: string[] = [] + const harness = transportHarness(() => Promise.resolve(snapshot([row(1, 'title')]))) + const controller = controllerFor(harness, 'renderer-1', (error) => errors.push(error.message)) + await controller.start() + await vi.waitFor(() => + expect(errors).toContain('terminal_authority_projection_policy_unavailable') + ) + + controller.retryFailedRows() + await vi.waitFor(() => expect(applyFact).toHaveBeenCalledTimes(2)) + controller.dispose() + }) + + it('clears bell presentation only after its event-keyed handler settles', async () => { + const settled = deferred() + applyFact.mockReturnValueOnce(settled.promise) + const bell = row(3, 'bell') + const harness = transportHarness(() => Promise.resolve(snapshot([bell]))) + const controller = controllerFor(harness) + await controller.start() + await vi.waitFor(() => expect(applyFact).toHaveBeenCalledOnce()) + expect(harness.clearBell).not.toHaveBeenCalled() + + settled.resolve(true) + await vi.waitFor(() => expect(harness.clearBell).toHaveBeenCalledOnce()) + expect(harness.clearBell).toHaveBeenCalledWith( + expect.objectContaining({ expectedEvent: bell.facts.bell!.event }) + ) + controller.dispose() + }) + + it('isolates namespaces and applies authoritative deletion deltas', async () => { + const first = row(1, 'title', 'namespace-a') + const second = row(1, 'title', 'namespace-b') + const harness = transportHarness(() => Promise.resolve(snapshot([first, second]))) + const controller = controllerFor(harness) + await controller.start() + await vi.waitFor(() => expect(applyFact).toHaveBeenCalledTimes(2)) + + harness.emit( + delta([], [{ consumerId: first.consumerId, namespace: first.namespace, pane: first.pane }]) + ) + + expect(controller.snapshotRows()).toEqual([second]) + controller.dispose() + }) + + it('fences a handler callback that settles after teardown', async () => { + const settled = deferred() + applyFact.mockReturnValueOnce(settled.promise) + const harness = transportHarness(() => Promise.resolve(snapshot([row(1, 'bell')]))) + const controller = controllerFor(harness) + await controller.start() + await vi.waitFor(() => expect(applyFact).toHaveBeenCalledOnce()) + + controller.dispose() + settled.resolve(true) + await Promise.resolve() + await Promise.resolve() + + expect(harness.clearBell).not.toHaveBeenCalled() + }) +}) + +function controllerFor( + harness: ReturnType, + subscriptionIncarnationId = 'renderer-1', + onError: (error: Error) => void = vi.fn() +) { + return new TerminalAuthorityAppProjectionController({ + transport: harness.transport, + subscriptionIncarnationId, + onError + }) +} + +function transportHarness(subscribeResult: () => Promise) { + let listener: ((value: unknown) => void) | null = null + const listenOrder: string[] = [] + const clearBell = vi.fn(() => Promise.resolve(true)) + return { + listenOrder, + clearBell, + transport: { + onDelta(next: (value: unknown) => void) { + listenOrder.push('listen') + listener = next + return () => { + listener = null + } + }, + subscribe() { + listenOrder.push('subscribe') + return subscribeResult() + }, + clearBell + }, + emit(value: unknown) { + listener?.(value) + } + } +} + +function snapshot(rows: readonly TerminalAuthorityAppPaneProjection[], incarnation = 'renderer-1') { + return { + version: TERMINAL_AUTHORITY_APP_PROJECTION_VERSION, + subscriptionIncarnationId: incarnation, + rows + } +} + +function delta( + rows: readonly TerminalAuthorityAppPaneProjection[], + deleted: TerminalAuthorityAppProjectionDelta['deleted'] = [] +): TerminalAuthorityAppProjectionDelta { + return { + version: TERMINAL_AUTHORITY_APP_PROJECTION_VERSION, + subscriptionIncarnationId: 'renderer-1', + rows, + ...(deleted.length ? { deleted } : {}) + } +} + +function row( + sequence: number, + kind: 'title' | 'bell', + namespaceId = 'namespace-1' +): TerminalAuthorityAppPaneProjection { + const namespace = { authorityHostId: 'host-1', namespaceId } + const event: TerminalAuthorityAppEventKey = { + consumerId: 'app-profile:test', + namespace, + sequence, + outcomeId: `${kind}-${namespaceId}-${sequence}` + } + const binding = { + ownerIncarnationId: 'owner-1', + physicalPtyId: `pty-${namespaceId}`, + ptyIncarnationId: `pty-incarnation-${namespaceId}` + } + const fact = + kind === 'bell' + ? { kind: 'bell' as const } + : { + kind: 'title' as const, + normalizedTitle: `title-${sequence}`, + rawTitle: `title-${sequence}` + } + return { + version: TERMINAL_AUTHORITY_APP_PROJECTION_VERSION, + consumerId: event.consumerId, + namespace, + pane: { + paneKey: 'tab-1:11111111-1111-4111-8111-111111111111', + paneGenerationId: `pane-generation-${namespaceId}` + }, + layout: { tabId: 'tab-1', leafId: '11111111-1111-4111-8111-111111111111' }, + binding, + latestEvent: event, + topology: { + status: 'open', + binding, + lastBinding: binding, + authorityRevision: 1, + ownerStatus: 'reachable' + }, + attention: { + event: kind === 'bell' ? event : null, + pendingBellCount: kind === 'bell' ? 1 : 0, + updatedAt: sequence + }, + status: { + event, + pane: 'open', + agent: null, + attention: kind === 'bell', + updatedAt: sequence + }, + facts: { + [kind]: { event, binding, fact, appliedAt: sequence } + } + } +} + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((settle) => { + resolve = settle + }) + return { promise, resolve } +} diff --git a/src/renderer/src/lib/terminal-authority-app-projection-controller.ts b/src/renderer/src/lib/terminal-authority-app-projection-controller.ts new file mode 100644 index 00000000000..516c7987de2 --- /dev/null +++ b/src/renderer/src/lib/terminal-authority-app-projection-controller.ts @@ -0,0 +1,298 @@ +import { + TERMINAL_AUTHORITY_APP_PROJECTION_VERSION, + terminalAuthorityAppProjectionRowKey, + type TerminalAuthorityAppBellClearRequest, + type TerminalAuthorityAppEventKey, + type TerminalAuthorityAppFactProjection, + type TerminalAuthorityAppPaneProjection, + type TerminalAuthorityAppProjectionDelta, + type TerminalAuthorityAppProjectionSnapshot, + type TerminalAuthorityAppProjectionSubscribe +} from '../../../shared/terminal-authority-app-projection' +import { + parseTerminalAuthorityAppProjectionDelta, + parseTerminalAuthorityAppProjectionSnapshot +} from '../../../shared/terminal-authority-app-projection-validation' +import { + applyTerminalAuthorityAppProjectionExit, + applyTerminalAuthorityAppProjectionFact +} from './terminal-authority-app-projection-policy' + +const MAX_BUFFERED_DELTAS = 64 + +export type TerminalAuthorityAppOutcomeIdentity = TerminalAuthorityAppEventKey +export type TerminalAuthorityOutcomeProjectionKind = 'event-keyed-idempotent' + +export type TerminalAuthorityAppProjectionTransport = Readonly<{ + onDelta(listener: (value: unknown) => void): () => void + subscribe(request: TerminalAuthorityAppProjectionSubscribe): Promise + clearBell(request: TerminalAuthorityAppBellClearRequest): Promise +}> + +export type TerminalAuthorityAppProjectionControllerOptions = Readonly<{ + transport: TerminalAuthorityAppProjectionTransport + subscriptionIncarnationId: string + expectedSubscriptionIncarnationId?: string | null + onError?: (error: Error) => void +}> + +export class TerminalAuthorityAppProjectionController { + private readonly rows = new Map() + private readonly appliedEvents = new Map() + private readonly pendingRows = new Map() + private readonly failedRows = new Set() + private epoch = 0 + private active = false + private initialized = false + private unsubscribe: (() => void) | null = null + private racingDeltas: TerminalAuthorityAppProjectionDelta[] | null = null + private drainPromise: Promise | null = null + + constructor(private readonly options: TerminalAuthorityAppProjectionControllerOptions) {} + + async start(): Promise { + if (this.active) { + throw new Error('terminal_authority_projection_controller_started') + } + const epoch = ++this.epoch + this.active = true + this.racingDeltas = [] + this.unsubscribe = this.options.transport.onDelta((value) => this.receive(epoch, value)) + try { + const value = await this.options.transport.subscribe({ + version: TERMINAL_AUTHORITY_APP_PROJECTION_VERSION, + subscriptionIncarnationId: this.options.subscriptionIncarnationId, + expectedSubscriptionIncarnationId: this.options.expectedSubscriptionIncarnationId ?? null + }) + if (!this.isCurrent(epoch)) { + return + } + const snapshot = parseTerminalAuthorityAppProjectionSnapshot(value) + if ( + !snapshot || + snapshot.subscriptionIncarnationId !== this.options.subscriptionIncarnationId + ) { + throw new Error('terminal_authority_projection_snapshot_invalid') + } + this.installSnapshot(snapshot) + const racing = this.racingDeltas ?? [] + this.racingDeltas = null + this.initialized = true + for (const delta of racing) { + this.applyDelta(delta) + } + this.kick(epoch) + } catch (error) { + if (!this.isCurrent(epoch)) { + return + } + const failure = asError(error) + this.report(failure) + this.dispose() + throw failure + } + } + + dispose(): void { + if (!this.active) { + return + } + this.active = false + this.initialized = false + this.epoch += 1 + this.unsubscribe?.() + this.unsubscribe = null + this.racingDeltas = null + this.rows.clear() + this.pendingRows.clear() + this.failedRows.clear() + this.appliedEvents.clear() + } + + retryFailedRows(): void { + if (!this.active || !this.initialized) { + return + } + for (const key of this.failedRows) { + const row = this.rows.get(key) + if (row) { + this.pendingRows.set(key, row) + } + } + this.failedRows.clear() + this.kick(this.epoch) + } + + snapshotRows(): readonly TerminalAuthorityAppPaneProjection[] { + return Object.freeze([...this.rows.values()]) + } + + private receive(epoch: number, value: unknown): void { + if (!this.isCurrent(epoch)) { + return + } + const delta = parseTerminalAuthorityAppProjectionDelta(value) + if (!delta || delta.subscriptionIncarnationId !== this.options.subscriptionIncarnationId) { + this.report(new Error('terminal_authority_projection_delta_invalid')) + return + } + if (this.racingDeltas) { + if (this.racingDeltas.length >= MAX_BUFFERED_DELTAS) { + this.report(new Error('terminal_authority_projection_delta_capacity_exceeded')) + this.dispose() + return + } + this.racingDeltas.push(delta) + return + } + this.applyDelta(delta) + this.kick(epoch) + } + + private installSnapshot(snapshot: TerminalAuthorityAppProjectionSnapshot): void { + this.rows.clear() + this.pendingRows.clear() + for (const row of snapshot.rows) { + this.installRow(row) + } + } + + private applyDelta(delta: TerminalAuthorityAppProjectionDelta): void { + for (const identity of delta.deleted ?? []) { + const key = terminalAuthorityAppProjectionRowKey(identity) + this.rows.delete(key) + this.pendingRows.delete(key) + this.failedRows.delete(key) + for (const appliedKey of this.appliedEvents.keys()) { + if (appliedKey.startsWith(`${key}:`)) { + this.appliedEvents.delete(appliedKey) + } + } + } + for (const row of delta.rows) { + this.installRow(row) + } + } + + private installRow(row: TerminalAuthorityAppPaneProjection): void { + const key = terminalAuthorityAppProjectionRowKey(row) + this.rows.set(key, row) + this.pendingRows.set(key, row) + this.failedRows.delete(key) + } + + private kick(epoch: number): void { + if (this.drainPromise || !this.isCurrent(epoch)) { + return + } + const drain = this.drain(epoch) + this.drainPromise = drain + void drain.finally(() => { + if (this.drainPromise !== drain) { + return + } + this.drainPromise = null + if (this.pendingRows.size > 0 && this.isCurrent(epoch)) { + this.kick(epoch) + } + }) + } + + private async drain(epoch: number): Promise { + while (this.pendingRows.size > 0 && this.isCurrent(epoch)) { + const entry = this.pendingRows.entries().next().value as + | [string, TerminalAuthorityAppPaneProjection] + | undefined + if (!entry) { + return + } + const [key, row] = entry + this.pendingRows.delete(key) + try { + await this.applyRow(epoch, key, row) + } catch (error) { + if (!this.isCurrent(epoch)) { + return + } + this.failedRows.add(key) + this.report(asError(error)) + } + } + } + + private async applyRow( + epoch: number, + rowKey: string, + row: TerminalAuthorityAppPaneProjection + ): Promise { + const facts = Object.values(row.facts) + .filter((field): field is TerminalAuthorityAppFactProjection => Boolean(field)) + .sort((left, right) => left.event.sequence - right.event.sequence) + for (const field of facts) { + const appliedKey = `${rowKey}:fact:${field.fact.kind}` + if (this.appliedEvents.get(appliedKey) === eventKey(field.event)) { + continue + } + if (!(await applyTerminalAuthorityAppProjectionFact(row, field))) { + throw new Error('terminal_authority_projection_policy_unavailable') + } + this.assertCurrent(epoch) + if (field.fact.kind === 'bell' && row.attention.pendingBellCount > 0) { + await this.clearBell(row, field.event) + this.assertCurrent(epoch) + } + this.appliedEvents.set(appliedKey, eventKey(field.event)) + } + if (row.exit) { + const appliedKey = `${rowKey}:exit` + if (this.appliedEvents.get(appliedKey) !== eventKey(row.exit.event)) { + if (!(await applyTerminalAuthorityAppProjectionExit(row))) { + throw new Error('terminal_authority_projection_exit_policy_unavailable') + } + this.assertCurrent(epoch) + this.appliedEvents.set(appliedKey, eventKey(row.exit.event)) + } + } + } + + private clearBell( + row: TerminalAuthorityAppPaneProjection, + expectedEvent: TerminalAuthorityAppEventKey + ): Promise { + return this.options.transport.clearBell({ + version: TERMINAL_AUTHORITY_APP_PROJECTION_VERSION, + consumerId: row.consumerId, + namespace: row.namespace, + pane: row.pane, + expectedEvent + }) + } + + private assertCurrent(epoch: number): void { + if (!this.isCurrent(epoch)) { + throw new Error('terminal_authority_projection_callback_stale') + } + } + + private isCurrent(epoch: number): boolean { + return this.active && epoch === this.epoch + } + + private report(error: Error): void { + this.options.onError?.(error) + } +} + +function eventKey(event: TerminalAuthorityAppEventKey): string { + return JSON.stringify([ + event.consumerId, + event.namespace.authorityHostId, + event.namespace.namespaceId, + event.sequence, + event.outcomeId + ]) +} + +function asError(value: unknown): Error { + return value instanceof Error ? value : new Error(String(value)) +} diff --git a/src/renderer/src/lib/terminal-authority-app-projection-policy.ts b/src/renderer/src/lib/terminal-authority-app-projection-policy.ts new file mode 100644 index 00000000000..7f2581613fa --- /dev/null +++ b/src/renderer/src/lib/terminal-authority-app-projection-policy.ts @@ -0,0 +1,97 @@ +import type { + TerminalAuthorityAppEventKey, + TerminalAuthorityAppFactProjection, + TerminalAuthorityAppPaneProjection +} from '../../../shared/terminal-authority-app-projection' +import { + onTerminalSideEffectFactConsumerAvailable, + settleTerminalAuthoritySideEffectBatch +} from '@/components/terminal-pane/terminal-side-effect-facts-handler' +import { + applyAuthoritativePtyExit, + onAuthoritativePtyExitPolicyAvailable +} from '@/components/terminal-pane/pty-authority-outcome-exit-policy' + +const applications = new Map>() + +export function applyTerminalAuthorityAppProjectionFact( + row: TerminalAuthorityAppPaneProjection, + field: TerminalAuthorityAppFactProjection +): Promise { + if (!row.binding || !sameBinding(row.binding, field.binding)) { + return Promise.resolve(false) + } + return coalesce(`${eventKey(field.event)}:${field.fact.kind}`, () => + settleTerminalAuthoritySideEffectBatch( + { + ptyId: field.binding.physicalPtyId, + ptyIncarnationId: field.binding.ptyIncarnationId, + seq: field.event.sequence, + facts: [field.fact] + }, + field.event + ) + ) +} + +export function applyTerminalAuthorityAppProjectionExit( + row: TerminalAuthorityAppPaneProjection +): Promise { + const exit = row.exit + if (!exit) { + return Promise.resolve(false) + } + return coalesce(`${eventKey(exit.event)}:exit`, () => + applyAuthoritativePtyExit({ + ptyId: exit.binding.physicalPtyId, + code: exit.code ?? 0, + incarnationId: exit.binding.ptyIncarnationId, + authorityOutcome: exit.event + }) + ) +} + +export function onTerminalAuthorityAppProjectionPolicyAvailable(listener: () => void): () => void { + const stopSideEffects = onTerminalSideEffectFactConsumerAvailable(listener) + const stopExit = onAuthoritativePtyExitPolicyAvailable(listener) + return () => { + stopSideEffects() + stopExit() + } +} + +function coalesce(key: string, apply: () => boolean | Promise): Promise { + const current = applications.get(key) + if (current) { + return current + } + const application = Promise.resolve().then(apply) + applications.set(key, application) + void application.finally(() => { + if (applications.get(key) === application) { + applications.delete(key) + } + }) + return application +} + +function eventKey(event: TerminalAuthorityAppEventKey): string { + return JSON.stringify([ + event.consumerId, + event.namespace.authorityHostId, + event.namespace.namespaceId, + event.sequence, + event.outcomeId + ]) +} + +function sameBinding( + left: { ownerIncarnationId: string; physicalPtyId: string; ptyIncarnationId: string }, + right: { ownerIncarnationId: string; physicalPtyId: string; ptyIncarnationId: string } +): boolean { + return ( + left.ownerIncarnationId === right.ownerIncarnationId && + left.physicalPtyId === right.physicalPtyId && + left.ptyIncarnationId === right.ptyIncarnationId + ) +} diff --git a/src/renderer/src/lib/terminal-authority-app-projection-startup.ts b/src/renderer/src/lib/terminal-authority-app-projection-startup.ts new file mode 100644 index 00000000000..6283984cbd0 --- /dev/null +++ b/src/renderer/src/lib/terminal-authority-app-projection-startup.ts @@ -0,0 +1,48 @@ +import { isWebClientLocation } from './web-client-location' +import { TerminalAuthorityAppProjectionController } from './terminal-authority-app-projection-controller' +import { onTerminalAuthorityAppProjectionPolicyAvailable } from './terminal-authority-app-projection-policy' + +let controller: TerminalAuthorityAppProjectionController | null = null +let previousSubscriptionIncarnationId: string | null = null +let stopPolicyAvailability: (() => void) | null = null +let pageHideInstalled = false + +export function startTerminalAuthorityAppProjectionController(): void { + if (controller || isWebClientLocation()) { + return + } + const subscriptionIncarnationId = crypto.randomUUID() + const next = new TerminalAuthorityAppProjectionController({ + transport: { + onDelta: (listener) => window.api.pty.onAuthorityProjection(listener), + subscribe: (request) => window.api.pty.subscribeAuthorityProjection(request), + clearBell: (request) => window.api.pty.clearAuthorityProjectionBell(request) + }, + subscriptionIncarnationId, + expectedSubscriptionIncarnationId: previousSubscriptionIncarnationId, + onError: (error) => console.error('[terminal-authority] projection observation failed', error) + }) + controller = next + previousSubscriptionIncarnationId = subscriptionIncarnationId + stopPolicyAvailability = onTerminalAuthorityAppProjectionPolicyAvailable(() => + next.retryFailedRows() + ) + void next.start().catch(() => { + if (controller === next) { + stopTerminalAuthorityAppProjectionController() + } + }) + if (!pageHideInstalled) { + pageHideInstalled = true + window.addEventListener('pagehide', stopTerminalAuthorityAppProjectionController, { + once: true + }) + } +} + +export function stopTerminalAuthorityAppProjectionController(): void { + controller?.dispose() + controller = null + stopPolicyAvailability?.() + stopPolicyAvailability = null +} diff --git a/src/renderer/src/runtime/runtime-terminal-inspection.ts b/src/renderer/src/runtime/runtime-terminal-inspection.ts index 741d381e11f..7ca10371df9 100644 --- a/src/renderer/src/runtime/runtime-terminal-inspection.ts +++ b/src/renderer/src/runtime/runtime-terminal-inspection.ts @@ -3,6 +3,10 @@ import type { RuntimeTerminalSend } from '../../../shared/runtime-types' import { makePaneKey } from '../../../shared/stable-pane-id' import { isTerminalInputTooLargeWithDeferredMeasurement } from '../../../shared/terminal-input' import { useAppStore } from '../store' +import { + writeImmediateCurrentPty, + writeImmediateCurrentPtyAccepted +} from '../lib/pty-administrative-mutations' import { RuntimeRpcCallError, callRuntimeRpc, getActiveRuntimeTarget } from './runtime-rpc-client' import { getRemoteRuntimePtyEnvironmentId, @@ -157,7 +161,7 @@ function sendRuntimePtyInputWithinLimit( : getActiveRuntimeTarget(settings) const terminal = getRemoteRuntimeTerminalHandle(ptyId) if (target.kind !== 'environment' || !terminal) { - window.api.pty.write(ptyId, data) + writeImmediateCurrentPty(ptyId, data) recordRuntimeTerminalInputForPtyId(ptyId) return true } @@ -195,9 +199,9 @@ export async function sendRuntimePtyInputVerified( : getActiveRuntimeTarget(settings) const terminal = getRemoteRuntimeTerminalHandle(ptyId) if (target.kind !== 'environment' || !terminal) { - const accepted = await window.api.pty.writeAccepted(ptyId, data) + const accepted = await writeImmediateCurrentPtyAccepted(ptyId, data) if (!accepted) { - window.api.pty.write(ptyId, data) + writeImmediateCurrentPty(ptyId, data) // Why: SSH/local fallback writes are fire-and-forget. Callers use this // boolean to continue UX flow, while hook telemetry confirms real turns. recordRuntimeTerminalInputForPtyId(ptyId) diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index f8fa9ebc61e..a8f0edd2ed4 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -1,6 +1,7 @@ /* eslint-disable max-lines -- Why: repo slice owns local/runtime routing, add/remove/reorder side effects, and cross-slice teardown; splitting mid-refactor would obscure its invariants. */ import type { StateCreator } from 'zustand' import { toast } from 'sonner' +import { killPtyAtCurrentIncarnation } from '@/lib/pty-administrative-mutations' import type { AppState } from '../types' import type { SshRepoReadoption } from '../../../../shared/ssh-types' import type { @@ -3354,7 +3355,7 @@ export const createRepoSlice: StateCreator = (set, killedTabIds.add(tab.id) for (const ptyId of get().ptyIdsByTabId[tab.id] ?? []) { if (!ptyId.startsWith('remote:')) { - window.api.pty.kill(ptyId) + killPtyAtCurrentIncarnation(ptyId) } } } diff --git a/src/renderer/src/store/slices/terminal-side-effect-identity-hydration.test.ts b/src/renderer/src/store/slices/terminal-side-effect-identity-hydration.test.ts new file mode 100644 index 00000000000..fcb1a68a78c --- /dev/null +++ b/src/renderer/src/store/slices/terminal-side-effect-identity-hydration.test.ts @@ -0,0 +1,62 @@ +import { expect, it, vi } from 'vitest' + +vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) +vi.mock('@/runtime/sync-runtime-graph', () => ({ scheduleRuntimeGraphSync: vi.fn() })) +vi.mock('@/components/terminal-pane/pty-transport', () => ({ + registerEagerPtyBuffer: vi.fn(), + ensurePtyDispatcher: vi.fn() +})) + +// @ts-expect-error -- mocked browser preload API +globalThis.window = { api: {} } + +import type { WorkspaceSessionState } from '../../../../shared/types' +import { getDefaultWorkspaceSession } from '../../../../shared/constants' +import { buildWorkspaceSessionPayload } from '../../lib/workspace-session' +import { createTestStore, makeTab, makeWorktree, seedStore } from './store-test-helpers' + +it('hydrates host-owned parked fact identity without echoing it from renderer persistence', () => { + const store = createTestStore() + const worktreeId = 'repo1::/wt-side-effect-identity' + const tabId = 'tab-side-effect-identity' + const leafId = '11111111-1111-4111-8111-111111111111' + const paneKey = `${tabId}:${leafId}` + seedStore(store, { + worktreesByRepo: { + repo1: [makeWorktree({ id: worktreeId, repoId: 'repo1', path: '/wt-side-effect-identity' })] + } + }) + const session: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + activeRepoId: 'repo1', + activeWorktreeId: worktreeId, + activeTabId: tabId, + tabsByWorktree: { + [worktreeId]: [makeTab({ id: tabId, worktreeId, ptyId: 'pty-1', generation: 6 })] + }, + terminalLayoutsByTabId: { + [tabId]: { + root: { type: 'leaf', leafId }, + activeLeafId: leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [leafId]: 'pty-1' } + } + }, + terminalPtyIncarnationsByPaneKey: { + [paneKey]: '22222222-2222-4222-8222-222222222222', + 'unknown-tab:33333333-3333-4333-8333-333333333333': '44444444-4444-4444-8444-444444444444' + } + } + + store.getState().hydrateWorkspaceSession(session) + + expect(store.getState().hostTerminalSideEffectIdentityByPaneKey).toEqual({ + [paneKey]: { + incarnationId: '22222222-2222-4222-8222-222222222222', + paneGeneration: 6 + } + }) + expect(buildWorkspaceSessionPayload(store.getState())).not.toHaveProperty( + 'terminalPtyIncarnationsByPaneKey' + ) +}) diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 9edf537671a..3baaa154032 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -1,5 +1,6 @@ /* eslint-disable max-lines */ import type { StateCreator } from 'zustand' +import { killPtyAtCurrentIncarnation } from '@/lib/pty-administrative-mutations' import type { AppState } from '../types' import type { Repo, @@ -37,6 +38,7 @@ import { parsePaneKey } from '../../../../shared/stable-pane-id' import { isValidHostTerminalTabId, isValidTerminalTabId } from '../../../../shared/terminal-tab-id' +import { isPtyIncarnationId } from '../../../../shared/pty-incarnation' import { buildByIdIndex, buildWorktreeByIdIndex } from './worktree-by-id-index' import { resolveActiveTabOwnerWorktreeId } from './active-tab-owner-worktree' import { isSameCodexRestartNoticeAccount } from './codex-restart-notice-account-identity' @@ -83,6 +85,7 @@ import { disposeParkedTerminalWatchersForPtyIds, retireParkedTerminalTab } from '@/components/terminal-pane/terminal-parked-watcher-registry' +import type { ParkedTerminalSideEffectIdentity } from '@/components/terminal-pane/terminal-parked-side-effect-identity' import { clearCommittedPtyShutdownSettlements, hasCommittedPtyShutdownSettlement, @@ -634,6 +637,8 @@ export type TerminalSlice = { pendingReconnectTabByWorktree: Record /** tabId → previous session's ptyId; for daemon backends it doubles as the sessionId, so spawn createOrAttach returns the surviving terminal. */ pendingReconnectPtyIdByTabId: Record + /** Read-only renderer projection of host-owned durable PTY incarnation bindings. */ + hostTerminalSideEffectIdentityByPaneKey: Record // Why: clearTabPtyId nulls tab.ptyId on disconnect; keep the last relay ID here so session save can still capture it for reattach after restart. lastKnownRelayPtyIdByTabId: Record /** ANSI snapshots from daemon reattach, keyed by new ptyId; TerminalPane writes them to xterm.js to restore visual state. */ @@ -875,6 +880,7 @@ type WorkspaceHydrationPatch = Pick< | 'pendingReconnectWorktreeIds' | 'pendingReconnectTabByWorktree' | 'pendingReconnectPtyIdByTabId' + | 'hostTerminalSideEffectIdentityByPaneKey' | 'everActivatedWorktreeIds' | 'worktreeNavHistory' | 'worktreeNavHistoryIndex' @@ -893,6 +899,42 @@ function replaceHydratedRecordKeys( } } +function buildHostTerminalSideEffectIdentityByPaneKey( + session: WorkspaceSessionState, + tabsByWorktree: Readonly> +): Record { + const tabById = buildByIdIndex(Object.values(tabsByWorktree).flat()) + return Object.fromEntries( + Object.entries(session.terminalPtyIncarnationsByPaneKey ?? {}).flatMap( + ([paneKey, incarnationId]) => { + const parsed = parsePaneKey(paneKey) + const tab = parsed ? tabById.get(parsed.tabId) : undefined + if (!parsed || !tab || !isPtyIncarnationId(incarnationId)) { + return [] + } + const paneGeneration = + Number.isSafeInteger(tab.generation) && (tab.generation ?? -1) >= 0 ? tab.generation! : 0 + return [[paneKey, { incarnationId, paneGeneration }] as const] + } + ) + ) +} + +function replaceHostTerminalSideEffectIdentities( + current: Readonly>, + hydrated: Readonly>, + targetTabIds: ReadonlySet +): Record { + const belongsToTarget = (paneKey: string): boolean => { + const parsed = parsePaneKey(paneKey) + return parsed !== null && targetTabIds.has(parsed.tabId) + } + return Object.fromEntries([ + ...Object.entries(current).filter(([paneKey]) => !belongsToTarget(paneKey)), + ...Object.entries(hydrated).filter(([paneKey]) => belongsToTarget(paneKey)) + ]) +} + type TerminalLayoutPtyOwnershipTransfer = ReturnType< typeof resolveTerminalLayoutPtyOwnershipTransfers >[number] @@ -1037,6 +1079,11 @@ function targetScopedWorkspaceHydrationPatch( workspaceKeys ), pendingReconnectPtyIdByTabId, + hostTerminalSideEffectIdentityByPaneKey: replaceHostTerminalSideEffectIdentities( + state.hostTerminalSideEffectIdentityByPaneKey, + hydrated.hostTerminalSideEffectIdentityByPaneKey, + targetTabIds + ), everActivatedWorktreeIds, directSshPaneRetryByTabId: replaceHydratedRecordKeys( state.directSshPaneRetryByTabId, @@ -1115,6 +1162,7 @@ export const createTerminalSlice: StateCreator pendingReconnectWorktreeIds: [], pendingReconnectTabByWorktree: {}, pendingReconnectPtyIdByTabId: {}, + hostTerminalSideEffectIdentityByPaneKey: {}, lastKnownRelayPtyIdByTabId: {}, pendingSnapshotByPtyId: {}, pendingColdRestoreByPtyId: {}, @@ -1593,7 +1641,7 @@ export const createTerminalSlice: StateCreator : { runtimeEnvironmentId: null } const retirementTasks: Promise[] = opts?.localPtyTeardownOwnedExternally ? [] - : retirementPlan.localOrSshPtyIds.map(async (ptyId) => window.api.pty.kill(ptyId)) + : retirementPlan.localOrSshPtyIds.map(async (ptyId) => killPtyAtCurrentIncarnation(ptyId)) const localOrSshTaskCount = retirementTasks.length if (!opts?.remoteCloseOwnedByHost) { for (const terminal of retirementPlan.runtimeTerminals) { @@ -2841,7 +2889,7 @@ export const createTerminalSlice: StateCreator // Why: pty.kill can flush final data before exit; unregister first so stale handlers can't fire phantom notifications during hibernation. const handlerSnapshots = unregisterPtyDataHandlers(rendererShutdownPtyIds) ?? [] try { - await window.api.pty.kill(opts.ptyId, { keepHistory: true }) + await killPtyAtCurrentIncarnation(opts.ptyId, { keepHistory: true }) } catch (err) { restorePtyDataHandlersAfterFailedShutdown(handlerSnapshots) rollbackTargetShutdownState() @@ -3087,7 +3135,9 @@ export const createTerminalSlice: StateCreator }> => { const localPtyIds = rendererShutdownPtyIds.filter((ptyId) => !ptyId.startsWith('remote:')) const results = await Promise.allSettled( - localPtyIds.map((ptyId) => window.api.pty.kill(ptyId, { keepHistory: keepIdentifiers })) + localPtyIds.map((ptyId) => + killPtyAtCurrentIncarnation(ptyId, { keepHistory: keepIdentifiers }) + ) ) const stoppedPtyIds = [ ...(runtimeEnvironmentId @@ -4128,6 +4178,10 @@ export const createTerminalSlice: StateCreator pendingReconnectWorktreeIds, pendingReconnectTabByWorktree, pendingReconnectPtyIdByTabId, + hostTerminalSideEffectIdentityByPaneKey: buildHostTerminalSideEffectIdentityByPaneKey( + session, + tabsByWorktree + ), everActivatedWorktreeIds: nextEverActivated, // Why: seed hydrated active worktrees so the first activation has a Back target. worktreeNavHistory: activeWorktreeId ? [activeWorktreeId] : [], diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 131be65a6df..b4d212e2c1b 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -869,6 +869,9 @@ function createWebPreloadApi(): Partial { revokeDevice: () => Promise.resolve({ revoked: false }), listRuntimeAccessGrants: () => Promise.resolve({ grants: [] }), revokeRuntimeAccess: () => Promise.resolve({ revoked: false }), + getIdentityResetStatus: () => Promise.resolve({ inProgress: false, record: null }), + resetIdentity: () => + Promise.reject(new Error('identity reset is unavailable in the web client')), isWebSocketReady: () => Promise.resolve({ ready: Boolean(activeEnvironment), endpoint: null }), getRelayStatus: () => Promise.resolve({ status: 'offline' as const }), @@ -3140,8 +3143,16 @@ function createShellApi(): NonNullable['shell']> { function createPtyApi(): NonNullable['pty']> { return { spawn: () => Promise.reject(new Error('Local PTYs are unavailable in the web client.')), + claimMutationAccess: () => Promise.resolve({ mode: 'unavailable' }), + captureAdministrativeMutationAccess: () => Promise.resolve([]), write: () => {}, writeAccepted: () => Promise.resolve(false), + administrativeWriteCurrent: () => {}, + administrativeWriteCurrentAccepted: () => Promise.resolve(false), + administrativeWriteImmediateCurrent: () => {}, + administrativeWriteImmediateCurrentAccepted: () => Promise.resolve(false), + administrativeWrite: () => {}, + administrativeWriteAccepted: () => Promise.resolve(false), resize: () => {}, claimViewport: () => {}, reportGeometry: () => {}, @@ -3149,6 +3160,8 @@ function createPtyApi(): NonNullable['pty']> { // Web panes clear the host buffer via the terminal.clearBuffer runtime RPC. clearBuffer: () => {}, kill: () => Promise.resolve(), + administrativeKillCurrent: () => Promise.resolve(), + administrativeKill: () => Promise.resolve(), ackColdRestore: () => {}, ackData: () => {}, onDeliveryResyncRequest: () => noopUnsubscribe, @@ -3178,6 +3191,15 @@ function createPtyApi(): NonNullable['pty']> { getMainBufferSnapshot: () => Promise.resolve(null), // Why: remote-runtime PTYs skip local main (no side-effect source); renderer byte parsing stays authoritative. onSideEffect: () => noopUnsubscribe, + onAuthorityProjection: () => noopUnsubscribe, + subscribeAuthorityProjection: () => + Promise.reject( + new Error('Terminal authority projection requires a negotiated host transport.') + ), + clearAuthorityProjectionBell: () => + Promise.reject( + new Error('Terminal authority projection requires a negotiated host transport.') + ), getSideEffectSnapshot: () => Promise.resolve(null), getRendererDeliveryDebugSnapshot: () => Promise.resolve({ @@ -3208,6 +3230,8 @@ function createPtyApi(): NonNullable['pty']> { rendererDispatcherReadyForcedCount: 0 }), resetRendererDeliveryDebug: () => Promise.resolve(), + rendererBindingReady: () => {}, + cancelRendererBinding: () => {}, onData: () => noopUnsubscribe, onReplay: () => noopUnsubscribe, onModelRestoreNeeded: () => noopUnsubscribe, diff --git a/src/shared/durable-file-write.test.ts b/src/shared/durable-file-write.test.ts new file mode 100644 index 00000000000..7f267a8741c --- /dev/null +++ b/src/shared/durable-file-write.test.ts @@ -0,0 +1,43 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import type * as NodeFs from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const openedFlags: string[] = [] + +vi.mock('node:fs', async () => { + const actual = await vi.importActual('node:fs') + return { + ...actual, + openSync: (path: Parameters[0], flags: string | number) => { + openedFlags.push(String(flags)) + return actual.openSync(path, flags) + } + } +}) + +describe('durable file fsync handles', () => { + let directory: string | undefined + + afterEach(() => { + openedFlags.length = 0 + if (directory) { + rmSync(directory, { recursive: true, force: true }) + directory = undefined + } + }) + + it('opens lifecycle files with a writable fsync handle', async () => { + // macOS proves the flag; real Windows FlushFileBuffers proof remains outstanding here. + const { fsyncFileSync } = await import('./durable-file-write.js') + directory = mkdtempSync(join(tmpdir(), 'orca-durable-fsync-handle-')) + const filePath = join(directory, 'state.json') + writeFileSync(filePath, '{"ok":true}') + + fsyncFileSync(filePath) + + expect(openedFlags).toContain('r+') + expect(openedFlags).not.toContain('r') + }) +}) diff --git a/src/shared/durable-file-write.ts b/src/shared/durable-file-write.ts new file mode 100644 index 00000000000..6cc4c740c68 --- /dev/null +++ b/src/shared/durable-file-write.ts @@ -0,0 +1,95 @@ +import { closeSync, fsyncSync, openSync, renameSync, rmSync, writeFileSync } from 'node:fs' +import { dirname } from 'node:path' + +export type DurableRenameOptions = { + move?: (source: string, destination: string) => void + afterRename?: () => void +} + +/** Sync a file before publishing its name so a crash cannot expose unwritten contents. */ +export function fsyncFileSync(path: string): void { + // Windows FlushFileBuffers requires a handle opened with write access. + const fd = openSync(path, 'r+') + try { + fsyncSync(fd) + } finally { + closeSync(fd) + } +} + +/** Sync a directory after a rename/unlink where the host filesystem supports directory fsync. */ +export function fsyncDirectorySync(directory: string): void { + if (process.platform === 'win32') { + return + } + let fd: number | null = null + try { + fd = openSync(directory, 'r') + fsyncSync(fd) + } catch (error) { + if (!isDirectoryFsyncUnsupported(error)) { + throw error + } + } finally { + if (fd !== null) { + try { + closeSync(fd) + } catch { + // The directory fsync is best effort after the operation has committed. + } + } + } +} + +function isDirectoryFsyncUnsupported(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException).code + return ( + code === 'EINVAL' || + code === 'EISDIR' || + code === 'ENOSYS' || + code === 'ENOTSUP' || + code === 'EOPNOTSUPP' + ) +} + +/** Fsync a file, rename it, run post-publication hardening, then fsync its directory. */ +export function renameFileDurableSync( + source: string, + destination: string, + options: DurableRenameOptions = {} +): void { + const move = options.move ?? renameSync + fsyncFileSync(source) + let renamed = false + try { + move(source, destination) + renamed = true + options.afterRename?.() + } finally { + if (renamed) { + fsyncDirectorySync(dirname(destination)) + } + } +} + +/** Remove a lifecycle artifact and make the containing directory durable. */ +export function removeFileDurableSync(path: string): void { + rmSync(path, { force: true }) + fsyncDirectorySync(dirname(path)) +} + +/** Write a payload, fsync it, rename it atomically, and fsync the containing directory. */ +export function writeFileDurableSync( + tmpPath: string, + finalPath: string, + payload: string, + options: DurableRenameOptions = {} +): void { + writeFileSync(tmpPath, payload, 'utf-8') + try { + renameFileDurableSync(tmpPath, finalPath, options) + } catch (error) { + rmSync(tmpPath, { force: true }) + throw error + } +} diff --git a/src/shared/pty-consumer-exact-capability.test.ts b/src/shared/pty-consumer-exact-capability.test.ts new file mode 100644 index 00000000000..1dabb431a4f --- /dev/null +++ b/src/shared/pty-consumer-exact-capability.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest' +import { + PtyConsumerSession, + type PtyConsumerSessionHello, + type PtyConsumerSessionOptions +} from './pty-consumer-session' + +const authentication = { + connectionId: 'connection-1', + principal: 'desktop', + authenticated: true, + allowSessionOwner: true +} + +function admit( + options: Partial, + capabilities?: PtyConsumerSessionHello['capabilities'] +) { + const session = new PtyConsumerSession({ + serverBuildId: 'relay-build', + createLease: () => 'lease-1', + ...options + }) + return session.admit( + { + clientInstanceId: 'client-1', + requestedRole: 'session-owner', + ...(capabilities ? { capabilities } : {}) + }, + authentication + ) +} + +describe('PTY consumer exact-operation capability', () => { + it('grants the mutually supported exact-operation version', () => { + const admission = admit( + { exactOperations: { versions: [1] } }, + { exactOperations: { versions: [1, 2] } } + ) + + expect(admission.grant.capabilities?.exactOperations).toEqual({ version: 1 }) + }) + + it('grants terminal authority outcome delivery only when both peers offer it', () => { + const admission = admit( + { terminalAuthorityOutcomeDelivery: { versions: [1] } }, + { terminalAuthorityOutcomeDelivery: { versions: [1, 2] } } + ) + + expect(admission.grant.capabilities?.terminalAuthorityOutcomeDelivery).toEqual({ version: 1 }) + expect( + admit({ terminalAuthorityOutcomeDelivery: { versions: [1] } }).grant.capabilities + ?.terminalAuthorityOutcomeDelivery + ).toBeUndefined() + }) + + it('grants authority exact operations only after an additive mutual offer', () => { + const admission = admit( + { terminalAuthorityExactOperations: { versions: [1] } }, + { terminalAuthorityExactOperations: { versions: [1, 2] } } + ) + + expect(admission.grant.capabilities?.terminalAuthorityExactOperations).toEqual({ version: 1 }) + expect( + admit({ terminalAuthorityExactOperations: { versions: [1] } }).grant.capabilities + ?.terminalAuthorityExactOperations + ).toBeUndefined() + }) + + it.each([ + { + name: 'old client', + options: { exactOperations: { versions: [1] } }, + capabilities: undefined + }, + { + name: 'old relay', + options: {}, + capabilities: { exactOperations: { versions: [1] } } + } + ])('omits exact operations for an $name', ({ options, capabilities }) => { + const admission = admit(options, capabilities) + + expect(admission.grant.capabilities?.exactOperations).toBeUndefined() + }) + + it('bounds client and relay version offers', () => { + expect(() => + admit( + { exactOperations: { versions: [1] } }, + { exactOperations: { versions: Array.from({ length: 9 }, (_, index) => index + 1) } } + ) + ).toThrow('exactOperations.versions') + expect( + () => + new PtyConsumerSession({ + serverBuildId: 'relay-build', + exactOperations: { versions: [0] } + }) + ).toThrow('exactOperations support') + }) +}) diff --git a/src/shared/pty-consumer-session-authority-proof.test.ts b/src/shared/pty-consumer-session-authority-proof.test.ts new file mode 100644 index 00000000000..b32fa3d7aff --- /dev/null +++ b/src/shared/pty-consumer-session-authority-proof.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from 'vitest' +import { + PTY_CONSUMER_SESSION_PROTOCOL_VERSION, + PtyConsumerSession, + type PtyConsumerAuthentication, + type PtyConsumerSessionHello +} from './pty-consumer-session' +import { TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION } from './terminal-session-authority-consumer-proof' +import { TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_VERSION } from './terminal-session-authority-consumer-transport' + +const AUTHORITY_HOST_ID = 'authority-host:ssh-proof-test' + +describe('PTY consumer authority proof negotiation', () => { + it('grants proof admission only when both current peers negotiate it', () => { + const session = proofHost() + + const admission = session.admit(proofClient(), authentication('connection:new-new')) + + expect(admission.grant.protocolVersion).toBe(PTY_CONSUMER_SESSION_PROTOCOL_VERSION) + expect(admission.grant.capabilities?.terminalAuthorityConsumerProof).toEqual({ + version: TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION, + authorityHostId: AUTHORITY_HOST_ID + }) + }) + + it('keeps a new client on omission behavior with an older host', () => { + const session = new PtyConsumerSession({ serverBuildId: 'relay-build' }) + + const admission = session.admit(proofClient(), authentication('connection:new-old')) + + expect(admission.grant.capabilities?.terminalAuthorityConsumerProof).toBeUndefined() + }) + + it('keeps an older client on omission behavior with a new host', () => { + const session = proofHost() + + const admission = session.admit( + { + clientInstanceId: 'client:old-new', + requestedRole: 'session-owner' + }, + authentication('connection:old-new') + ) + + expect(admission.grant.capabilities?.terminalAuthorityConsumerProof).toBeUndefined() + }) + + it('never falls back from an offered proof to a client-supplied legacy consumer', () => { + const client = proofClient() + client.capabilities = { + ...client.capabilities, + terminalAuthorityConsumerProof: { versions: [2] }, + terminalAuthorityNamespaceOutcomes: { + versions: [TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_VERSION], + consumer: { + consumerId: 'app-profile:client-supplied', + consumerIncarnationId: 'app-process:client-supplied' + }, + expectedConsumerIncarnationId: null + } + } + const host = new PtyConsumerSession({ + serverBuildId: 'relay-build', + terminalAuthorityConsumerProof: { + versions: [TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION], + authorityHostId: AUTHORITY_HOST_ID + }, + terminalAuthorityNamespaceOutcomes: { + versions: [TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_VERSION] + } + }) + + const admission = host.admit(client, authentication('connection:proof-downgrade')) + + expect(admission.grant.capabilities?.terminalAuthorityConsumerProof).toBeUndefined() + expect(admission.grant.capabilities?.terminalAuthorityNamespaceOutcomes).toBeUndefined() + }) + + it('rejects an invalid proof capability without allocating an owner grant', () => { + const session = proofHost() + const hello = proofClient() + hello.capabilities!.terminalAuthorityConsumerProof!.versions = Array.from( + { length: 9 }, + (_, index) => index + 1 + ) + + expect(() => session.admit(hello, authentication('connection:invalid'))).toThrow( + 'terminalAuthorityConsumerProof.versions' + ) + expect(session.activeGrant('connection:invalid')).toBeNull() + }) +}) + +function proofHost(): PtyConsumerSession { + return new PtyConsumerSession({ + serverBuildId: 'relay-build', + terminalAuthorityConsumerProof: { + versions: [TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION], + authorityHostId: AUTHORITY_HOST_ID + } + }) +} + +function proofClient(): PtyConsumerSessionHello { + return { + clientInstanceId: 'client:new', + requestedRole: 'session-owner', + capabilities: { + terminalAuthorityConsumerProof: { + versions: [TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION] + } + } + } +} + +function authentication(connectionId: string): PtyConsumerAuthentication { + return { + connectionId, + principal: 'ssh-endpoint:authenticated', + authenticated: true, + allowSessionOwner: true + } +} diff --git a/src/shared/pty-consumer-session-capabilities.ts b/src/shared/pty-consumer-session-capabilities.ts index 89962f6b3b3..c93faf9b38f 100644 --- a/src/shared/pty-consumer-session-capabilities.ts +++ b/src/shared/pty-consumer-session-capabilities.ts @@ -4,6 +4,9 @@ import type { PtyConsumerSessionOptions } from './pty-consumer-session-contract' import { assertNonEmptyString, MAX_CAPABILITY_VERSIONS } from './pty-consumer-session-hello' +import { TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_VERSION } from './terminal-session-authority-consumer-transport' +import { TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION } from './terminal-session-authority-consumer-proof' +import { assertAuthorityId } from './terminal-session-authority-identity' export function assertPtyConsumerSessionOptions(options: PtyConsumerSessionOptions): void { assertNonEmptyString(options.serverBuildId, 'serverBuildId') @@ -18,28 +21,194 @@ export function assertPtyConsumerSessionOptions(options: PtyConsumerSessionOptio ) { throw new Error('outputFlowControl support is invalid') } + if ( + options.exactOperations && + (options.exactOperations.versions.length > MAX_CAPABILITY_VERSIONS || + options.exactOperations.versions.some( + (version) => !Number.isSafeInteger(version) || version <= 0 + )) + ) { + throw new Error('exactOperations support is invalid') + } + if ( + options.heldProducerPause && + (options.heldProducerPause.versions.length > MAX_CAPABILITY_VERSIONS || + options.heldProducerPause.versions.some( + (version) => !Number.isSafeInteger(version) || version <= 0 + )) + ) { + throw new Error('heldProducerPause support is invalid') + } + if ( + options.terminalAuthorityExactOperations && + (options.terminalAuthorityExactOperations.versions.length > MAX_CAPABILITY_VERSIONS || + options.terminalAuthorityExactOperations.versions.some( + (version) => !Number.isSafeInteger(version) || version <= 0 + )) + ) { + throw new Error('terminalAuthorityExactOperations support is invalid') + } + if ( + options.terminalAuthorityOutcomeDelivery && + (options.terminalAuthorityOutcomeDelivery.versions.length > MAX_CAPABILITY_VERSIONS || + options.terminalAuthorityOutcomeDelivery.versions.some( + (version) => !Number.isSafeInteger(version) || version <= 0 + )) + ) { + throw new Error('terminalAuthorityOutcomeDelivery support is invalid') + } + if ( + options.terminalAuthorityNamespaceOutcomes && + (options.terminalAuthorityNamespaceOutcomes.versions.length > MAX_CAPABILITY_VERSIONS || + options.terminalAuthorityNamespaceOutcomes.versions.some( + (version) => !Number.isSafeInteger(version) || version <= 0 + )) + ) { + throw new Error('terminalAuthorityNamespaceOutcomes support is invalid') + } + if (options.terminalAuthorityConsumerProof) { + const proof = options.terminalAuthorityConsumerProof + if ( + proof.versions.length > MAX_CAPABILITY_VERSIONS || + proof.versions.some((version) => !Number.isSafeInteger(version) || version <= 0) + ) { + throw new Error('terminalAuthorityConsumerProof support is invalid') + } + assertAuthorityId(proof.authorityHostId, 'terminal authority proof host id') + if ( + proof.retirementVersions && + (proof.retirementVersions.length > MAX_CAPABILITY_VERSIONS || + proof.retirementVersions.some((version) => !Number.isSafeInteger(version) || version <= 0)) + ) { + throw new Error('terminalAuthorityConsumerProof retirement support is invalid') + } + } if ( options.ownerGraceMs !== undefined && (!Number.isSafeInteger(options.ownerGraceMs) || options.ownerGraceMs < 0) ) { throw new Error('ownerGraceMs must be a non-negative safe integer') } + if ( + options.ownerScope !== undefined && + options.ownerScope !== 'global' && + options.ownerScope !== 'principal-client-instance' + ) { + throw new Error('ownerScope is invalid') + } } export function intersectPtyConsumerCapabilities( hello: PtyConsumerSessionHello, - support: PtyConsumerSessionOptions['outputFlowControl'] + support: Pick< + PtyConsumerSessionOptions, + | 'outputFlowControl' + | 'exactOperations' + | 'heldProducerPause' + | 'terminalAuthorityExactOperations' + | 'terminalAuthorityOutcomeDelivery' + | 'terminalAuthorityNamespaceOutcomes' + | 'terminalAuthorityConsumerProof' + > ): Pick { - const offer = hello.capabilities?.outputFlowControl - if (!offer || !support || !offer.versions.includes(1) || !support.versions.includes(1)) { + const flowOffer = hello.capabilities?.outputFlowControl + const flowSupported = + flowOffer && + support.outputFlowControl && + flowOffer.versions.includes(1) && + support.outputFlowControl.versions.includes(1) + const exactOffer = hello.capabilities?.exactOperations + const exactSupported = + exactOffer && + support.exactOperations && + exactOffer.versions.includes(1) && + support.exactOperations.versions.includes(1) + const heldPauseOffer = hello.capabilities?.heldProducerPause + const heldPauseSupported = + heldPauseOffer && + support.heldProducerPause && + heldPauseOffer.versions.includes(1) && + support.heldProducerPause.versions.includes(1) + const outcomeOffer = hello.capabilities?.terminalAuthorityOutcomeDelivery + const authorityExactOffer = hello.capabilities?.terminalAuthorityExactOperations + const authorityExactSupported = + authorityExactOffer && + support.terminalAuthorityExactOperations && + authorityExactOffer.versions.includes(1) && + support.terminalAuthorityExactOperations.versions.includes(1) + const outcomeSupported = + outcomeOffer && + support.terminalAuthorityOutcomeDelivery && + outcomeOffer.versions.includes(1) && + support.terminalAuthorityOutcomeDelivery.versions.includes(1) + const consumerProofOffer = hello.capabilities?.terminalAuthorityConsumerProof + const namespaceOutcomeOffer = hello.capabilities?.terminalAuthorityNamespaceOutcomes + const namespaceOutcomeSupported = + namespaceOutcomeOffer && + !consumerProofOffer && + support.terminalAuthorityNamespaceOutcomes && + namespaceOutcomeOffer.versions.includes(TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_VERSION) && + support.terminalAuthorityNamespaceOutcomes.versions.includes( + TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_VERSION + ) + const consumerProofSupported = + consumerProofOffer && + support.terminalAuthorityConsumerProof && + consumerProofOffer.versions.includes(TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION) && + support.terminalAuthorityConsumerProof.versions.includes( + TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION + ) + const consumerRetirementSupported = + consumerProofSupported && + consumerProofOffer.retirementVersions?.includes(1) && + support.terminalAuthorityConsumerProof!.retirementVersions?.includes(1) + if ( + !flowSupported && + !exactSupported && + !heldPauseSupported && + !authorityExactSupported && + !outcomeSupported && + !namespaceOutcomeSupported && + !consumerProofSupported + ) { return {} } return { capabilities: { - outputFlowControl: { - version: 1, - windowSu: Math.min(offer.requestedWindowSu, support.maxWindowSu) - } + ...(flowSupported + ? { + outputFlowControl: { + version: 1 as const, + windowSu: Math.min( + flowOffer.requestedWindowSu, + support.outputFlowControl!.maxWindowSu + ) + } + } + : {}), + ...(exactSupported ? { exactOperations: { version: 1 as const } } : {}), + ...(heldPauseSupported ? { heldProducerPause: { version: 1 as const } } : {}), + ...(authorityExactSupported + ? { terminalAuthorityExactOperations: { version: 1 as const } } + : {}), + ...(outcomeSupported ? { terminalAuthorityOutcomeDelivery: { version: 1 as const } } : {}), + ...(namespaceOutcomeSupported + ? { + terminalAuthorityNamespaceOutcomes: { + version: TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_VERSION, + consumer: namespaceOutcomeOffer.consumer + } + } + : {}), + ...(consumerProofSupported + ? { + terminalAuthorityConsumerProof: { + version: TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION, + authorityHostId: support.terminalAuthorityConsumerProof!.authorityHostId, + ...(consumerRetirementSupported ? { retirementVersion: 1 as const } : {}) + } + } + : {}) } } } diff --git a/src/shared/pty-consumer-session-contract.ts b/src/shared/pty-consumer-session-contract.ts index 49527f83959..3a392517155 100644 --- a/src/shared/pty-consumer-session-contract.ts +++ b/src/shared/pty-consumer-session-contract.ts @@ -1,3 +1,5 @@ +import type { TerminalAuthorityPolicyConsumerIdentity } from './terminal-session-authority-consumer-transport' + export const PTY_CONSUMER_SESSION_PROTOCOL_VERSION = 1 export const PTY_CONSUMER_OWNER_GRACE_MS = 30_000 export const PTY_CONSUMER_STALE_OWNER_RECOVERY_ERROR = -32041 @@ -16,6 +18,7 @@ export const PTY_CONSUMER_OWNER_HELD_SELF_ERROR = -32046 // Why: a disconnected incumbent keeps at most this much of its remaining grace once a different // owner-capable client asks, so admission converges inside one bounded retry instead of the full grace. export const PTY_CONSUMER_OWNER_HELD_GRACE_FLOOR_MS = 250 +export const PTY_CONSUMER_SCOPED_OWNER_LIMIT = 256 // Why the grace floor needs this: shortening a grace is only safe against an owner the relay has // evidence is gone, and that evidence exists only where the transport ended on the peer's side. A @@ -37,6 +40,28 @@ export type PtyConsumerSessionHello = { versions: number[] requestedWindowSu: number } + exactOperations?: { + versions: number[] + } + heldProducerPause?: { + versions: number[] + } + terminalAuthorityExactOperations?: { + versions: number[] + } + terminalAuthorityOutcomeDelivery?: { + versions: number[] + } + terminalAuthorityNamespaceOutcomes?: { + versions: number[] + consumer: TerminalAuthorityPolicyConsumerIdentity + expectedConsumerIncarnationId: string | null + } + terminalAuthorityConsumerProof?: { + versions: number[] + retirementVersions?: number[] + } + terminalAuthorityTopology?: TerminalAuthorityTopologyCapabilityOffer } } @@ -55,6 +80,28 @@ export type PtyConsumerSessionGrant = { version: 1 windowSu: number } + exactOperations?: { + version: 1 + } + heldProducerPause?: { + version: 1 + } + terminalAuthorityExactOperations?: { + version: 1 + } + terminalAuthorityOutcomeDelivery?: { + version: 1 + } + terminalAuthorityNamespaceOutcomes?: { + version: 1 + consumer: TerminalAuthorityPolicyConsumerIdentity + } + terminalAuthorityConsumerProof?: { + version: 1 + authorityHostId: string + retirementVersion?: 1 + } + terminalAuthorityTopology?: TerminalAuthorityTopologyCapabilityGrant } } @@ -85,7 +132,32 @@ export type PtyConsumerSessionOptions = { versions: readonly number[] maxWindowSu: number } + exactOperations?: { + versions: readonly number[] + } + heldProducerPause?: { + versions: readonly number[] + } + terminalAuthorityExactOperations?: { + versions: readonly number[] + } + terminalAuthorityOutcomeDelivery?: { + versions: readonly number[] + } + terminalAuthorityNamespaceOutcomes?: { + versions: readonly number[] + } + terminalAuthorityConsumerProof?: { + versions: readonly number[] + retirementVersions?: readonly number[] + authorityHostId: string + } ownerGraceMs?: number + ownerScope?: 'global' | 'principal-client-instance' now?: () => number createLease?: () => string } +import type { + TerminalAuthorityTopologyCapabilityGrant, + TerminalAuthorityTopologyCapabilityOffer +} from './terminal-authority-topology-stream-contract' diff --git a/src/shared/pty-consumer-session-hello.ts b/src/shared/pty-consumer-session-hello.ts index 9dc54adf30c..1884db6e92b 100644 --- a/src/shared/pty-consumer-session-hello.ts +++ b/src/shared/pty-consumer-session-hello.ts @@ -1,7 +1,21 @@ import type { PtyConsumerSessionHello } from './pty-consumer-session-contract' +import { + TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_VERSION, + parseTerminalAuthorityPolicyConsumerClaim +} from './terminal-session-authority-consumer-identity' export const MAX_CAPABILITY_VERSIONS = 8 +function validateCapabilityVersions(value: unknown, name: string): void { + if ( + !Array.isArray(value) || + value.length > MAX_CAPABILITY_VERSIONS || + value.some((version) => !Number.isSafeInteger(version) || version <= 0) + ) { + throw new Error(`${name} must contain positive safe integers`) + } +} + export function assertNonEmptyString(value: unknown, name: string): asserts value is string { if (typeof value !== 'string' || value.length === 0 || value.length > 512) { throw new Error(`${name} must be a non-empty string of at most 512 characters`) @@ -20,17 +34,60 @@ export function validateHello(hello: PtyConsumerSessionHello): void { assertNonEmptyString(hello.resume.ownerLease, 'resume.ownerLease') } const flow = hello.capabilities?.outputFlowControl - if (!flow) { - return + if (flow) { + validateCapabilityVersions(flow.versions, 'outputFlowControl.versions') + if (!Number.isSafeInteger(flow.requestedWindowSu) || flow.requestedWindowSu <= 0) { + throw new Error('outputFlowControl.requestedWindowSu must be a positive safe integer') + } } - if ( - !Array.isArray(flow.versions) || - flow.versions.length > MAX_CAPABILITY_VERSIONS || - flow.versions.some((version) => !Number.isSafeInteger(version) || version <= 0) - ) { - throw new Error('outputFlowControl.versions must contain positive safe integers') + const exact = hello.capabilities?.exactOperations + if (exact) { + validateCapabilityVersions(exact.versions, 'exactOperations.versions') } - if (!Number.isSafeInteger(flow.requestedWindowSu) || flow.requestedWindowSu <= 0) { - throw new Error('outputFlowControl.requestedWindowSu must be a positive safe integer') + const heldPause = hello.capabilities?.heldProducerPause + if (heldPause) { + validateCapabilityVersions(heldPause.versions, 'heldProducerPause.versions') + } + const authorityExact = hello.capabilities?.terminalAuthorityExactOperations + if (authorityExact) { + validateCapabilityVersions(authorityExact.versions, 'terminalAuthorityExactOperations.versions') + } + const outcomeDelivery = hello.capabilities?.terminalAuthorityOutcomeDelivery + if (outcomeDelivery) { + validateCapabilityVersions( + outcomeDelivery.versions, + 'terminalAuthorityOutcomeDelivery.versions' + ) + } + const namespaceOutcomes = hello.capabilities?.terminalAuthorityNamespaceOutcomes + if (namespaceOutcomes) { + validateCapabilityVersions( + namespaceOutcomes.versions, + 'terminalAuthorityNamespaceOutcomes.versions' + ) + if ( + !namespaceOutcomes.versions.includes(TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_VERSION) || + !parseTerminalAuthorityPolicyConsumerClaim({ + version: TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_VERSION, + consumer: namespaceOutcomes.consumer, + expectedConsumerIncarnationId: namespaceOutcomes.expectedConsumerIncarnationId + }) + ) { + throw new Error('terminalAuthorityNamespaceOutcomes claim is invalid') + } + } + const consumerProof = hello.capabilities?.terminalAuthorityConsumerProof + if (consumerProof) { + validateCapabilityVersions(consumerProof.versions, 'terminalAuthorityConsumerProof.versions') + if (consumerProof.retirementVersions) { + validateCapabilityVersions( + consumerProof.retirementVersions, + 'terminalAuthorityConsumerProof.retirementVersions' + ) + } + } + const topology = hello.capabilities?.terminalAuthorityTopology + if (topology) { + validateCapabilityVersions(topology.versions, 'terminalAuthorityTopology.versions') } } diff --git a/src/shared/pty-consumer-session.test.ts b/src/shared/pty-consumer-session.test.ts index c53991a0f50..e38043c1937 100644 --- a/src/shared/pty-consumer-session.test.ts +++ b/src/shared/pty-consumer-session.test.ts @@ -6,6 +6,7 @@ import { PTY_CONSUMER_OWNER_HELD_SELF_ERROR, PTY_CONSUMER_OWNER_RECOVERY_PENDING_ERROR, PTY_CONSUMER_OWNER_RECOVERY_SUPERSEDED_ERROR, + PTY_CONSUMER_SCOPED_OWNER_LIMIT, PtyConsumerSession, type PtyConsumerAuthentication, type PtyConsumerSessionHello @@ -32,7 +33,12 @@ function ownerHello(overrides: Partial = {}): PtyConsum } } -function createSession(options: { now?: () => number } = {}): PtyConsumerSession { +function createSession( + options: { + now?: () => number + ownerScope?: 'global' | 'principal-client-instance' + } = {} +): PtyConsumerSession { let lease = 0 return new PtyConsumerSession({ serverBuildId: 'relay-build', @@ -90,6 +96,38 @@ describe('PtyConsumerSession', () => { ).toThrow(expect.objectContaining({ code: PTY_CONSUMER_OWNER_HELD_ATTACHED_ERROR })) }) + it('isolates authority ownership by authenticated logical client when configured', () => { + const session = createSession({ ownerScope: 'principal-client-instance' }) + const first = session.admit(ownerHello(), auth('connection-1')) + const second = session.admit(ownerHello({ clientInstanceId: 'client-b' }), auth('connection-2')) + first.commitPublication() + second.commitPublication() + + expect(first.grant).toMatchObject({ role: 'session-owner', ownerGeneration: 1 }) + expect(second.grant).toMatchObject({ role: 'session-owner', ownerGeneration: 2 }) + expect(session.activeGrant('connection-1')).toBe(first.grant) + expect(session.activeGrant('connection-2')).toBe(second.grant) + expect(() => session.admit(ownerHello(), auth('connection-3'))).toThrow( + expect.objectContaining({ code: PTY_CONSUMER_OWNER_HELD_SELF_ERROR }) + ) + }) + + it('bounds authenticated logical owner scopes', () => { + const session = createSession({ ownerScope: 'principal-client-instance' }) + for (let index = 0; index < PTY_CONSUMER_SCOPED_OWNER_LIMIT; index++) { + session + .admit(ownerHello({ clientInstanceId: `client-${index}` }), auth(`connection-${index}`)) + .commitPublication() + } + + expect(() => + session.admit( + ownerHello({ clientInstanceId: 'over-capacity' }), + auth('over-capacity-connection') + ) + ).toThrow('scope capacity') + }) + it('rolls back an unpublished owner without consuming authority', () => { const session = createSession() session.admit(ownerHello(), auth('failed')).rollbackPublication() @@ -567,5 +605,12 @@ describe('PtyConsumerSession', () => { expect( () => new PtyConsumerSession({ serverBuildId: 'build', ownerGraceMs: Number.MAX_VALUE }) ).toThrow('ownerGraceMs') + expect( + () => + new PtyConsumerSession({ + serverBuildId: 'build', + ownerScope: 'invalid' as 'global' + }) + ).toThrow('ownerScope') }) }) diff --git a/src/shared/pty-consumer-session.ts b/src/shared/pty-consumer-session.ts index 6b040be47cf..e45a113fdb6 100644 --- a/src/shared/pty-consumer-session.ts +++ b/src/shared/pty-consumer-session.ts @@ -1,6 +1,7 @@ import { randomUUID } from 'node:crypto' import { PTY_CONSUMER_OWNER_GRACE_MS, + PTY_CONSUMER_SCOPED_OWNER_LIMIT, PTY_CONSUMER_SESSION_PROTOCOL_VERSION, type PtyConsumerAuthentication, type PtyConsumerCloseCause, @@ -27,6 +28,7 @@ export * from './pty-consumer-session-contract' type ClientRecord = { principal: string clientInstanceId: string + ownerScope: string grant: Readonly state: 'pending' | 'active' | 'displaced' publicationState: 'pending' | 'committed' | 'rolled-back' @@ -52,7 +54,7 @@ export class PtyConsumerSession { private readonly ownerGraceMs: number private nextClientGeneration = 1 private nextOwnerGeneration = 1 - private owner: OwnerRecord | null = null + private readonly owners = new Map() constructor(private readonly options: PtyConsumerSessionOptions) { assertPtyConsumerSessionOptions(options) @@ -71,7 +73,8 @@ export class PtyConsumerSession { if (!authentication.authenticated) { throw new Error('PTY consumer authentication required') } - this.expireOwner() + const ownerScope = this.ownerScopeFor(hello, authentication) + this.expireOwner(ownerScope) // Why even an identical repeat is rejected: the two responses settle their publications // independently, so one shared admission cannot make one response's rollback and the other's @@ -80,7 +83,7 @@ export class PtyConsumerSession { throw new Error('pty.openClient may be used only once per transport connection') } - const owner = this.selectOwner(hello, authentication) + const owner = this.selectOwner(hello, authentication, ownerScope) const grant = Object.freeze({ protocolVersion: PTY_CONSUMER_SESSION_PROTOCOL_VERSION, serverBuildId: this.options.serverBuildId, @@ -89,18 +92,19 @@ export class PtyConsumerSession { ...(owner ? { ownerGeneration: owner.generation, ownerLease: owner.lease, resumed: owner.resumed } : {}), - ...intersectPtyConsumerCapabilities(hello, this.options.outputFlowControl) + ...intersectPtyConsumerCapabilities(hello, this.options) }) const client: ClientRecord = { principal: authentication.principal, clientInstanceId: hello.clientInstanceId, + ownerScope, grant, state: 'pending', publicationState: 'pending' } this.clients.set(authentication.connectionId, client) if (owner) { - this.owner = owner + this.owners.set(ownerScope, owner) } return this.admissionFor(client, this.displacedOwnerFor(owner)) } @@ -113,39 +117,39 @@ export class PtyConsumerSession { return } this.clients.delete(connectionId) - if (this.owner?.connectionId !== connectionId) { + const owner = this.owners.get(client.ownerScope) + if (owner?.connectionId !== connectionId) { // Why: a pending replacement can still roll back onto the owner it is displacing; restoring an // 'active' record whose connection has since closed would wedge an owner that can never expire. - if ( - this.owner?.replaces?.connectionId === connectionId && - this.owner.replaces.state === 'active' - ) { - this.owner = { - ...this.owner, + if (owner?.replaces?.connectionId === connectionId && owner.replaces.state === 'active') { + this.owners.set(client.ownerScope, { + ...owner, replaces: { - ...this.owner.replaces, + ...owner.replaces, state: 'disconnected', disconnectedAt: this.now(), disconnectCause: cause } - } + }) } return } - if (this.owner.state === 'pending') { - this.owner = this.owner.replaces ?? null + if (owner.state === 'pending') { + this.restoreOrDeleteOwner(client.ownerScope, owner.replaces) return } - this.owner = { - ...this.owner, + this.owners.set(client.ownerScope, { + ...owner, state: 'disconnected', disconnectedAt: this.now(), disconnectCause: cause - } + }) } sweepExpired(): void { - this.expireOwner() + for (const ownerScope of this.owners.keys()) { + this.expireOwner(ownerScope) + } } activeGrant(connectionId: string): Readonly | null { @@ -169,10 +173,14 @@ export class PtyConsumerSession { return } client.state = 'active' - const owner = this.owner + const owner = this.owners.get(client.ownerScope) if (owner?.connectionId === this.connectionIdFor(client) && owner.state === 'pending') { this.retireDisplacedOwner(owner.replaces) - this.owner = { ...owner, state: 'active', replaces: undefined } + this.owners.set(client.ownerScope, { + ...owner, + state: 'active', + replaces: undefined + }) } }, rollbackPublication: () => { @@ -185,8 +193,9 @@ export class PtyConsumerSession { } const connectionId = this.connectionIdFor(client) this.clients.delete(connectionId) - if (this.owner?.connectionId === connectionId && this.owner.state === 'pending') { - this.owner = this.owner.replaces ?? null + const owner = this.owners.get(client.ownerScope) + if (owner?.connectionId === connectionId && owner.state === 'pending') { + this.restoreOrDeleteOwner(client.ownerScope, owner.replaces) } } } @@ -203,16 +212,18 @@ export class PtyConsumerSession { private selectOwner( hello: PtyConsumerSessionHello, - authentication: PtyConsumerAuthentication + authentication: PtyConsumerAuthentication, + ownerScope: string ): OwnerRecord | null { if (hello.requestedRole !== 'session-owner' || !authentication.allowSessionOwner) { return null } - const current = this.owner + const current = this.owners.get(ownerScope) ?? null // Why resume proof for a vacant record is not an error: the relay simply no longer has the record // the client is naming. Minting a fresh claim here resolves it in one round trip, and `resumed: // false` tells the client its checkpoints are void without making it delete its identity first. if (!current) { + this.assertOwnerScopeCapacity() return this.newOwner(hello, authentication, null) } if (!matchesPtyConsumerOwnerClaim(hello, authentication, current)) { @@ -221,7 +232,7 @@ export class PtyConsumerSession { now: this.now(), sameClient: isPtyConsumerOwnerSameClient(hello, authentication, current), clampGraceTo: (disconnectedAt) => { - this.owner = { ...current, disconnectedAt } + this.owners.set(ownerScope, { ...current, disconnectedAt }) } }) } @@ -278,12 +289,43 @@ export class PtyConsumerSession { } } - private expireOwner(): void { + private ownerScopeFor( + hello: PtyConsumerSessionHello, + authentication: PtyConsumerAuthentication + ): string { + return this.options.ownerScope === 'principal-client-instance' + ? JSON.stringify([authentication.principal, hello.clientInstanceId]) + : 'global' + } + + private restoreOrDeleteOwner(ownerScope: string, owner: OwnerRecord | undefined): void { + if (owner) { + this.owners.set(ownerScope, owner) + } else { + this.owners.delete(ownerScope) + } + } + + private assertOwnerScopeCapacity(): void { if ( - this.owner?.state === 'disconnected' && - this.now() - (this.owner.disconnectedAt ?? this.now()) >= this.ownerGraceMs + this.options.ownerScope !== 'principal-client-instance' || + this.owners.size < PTY_CONSUMER_SCOPED_OWNER_LIMIT ) { - this.owner = null + return + } + this.sweepExpired() + if (this.owners.size >= PTY_CONSUMER_SCOPED_OWNER_LIMIT) { + throw new Error('PTY consumer owner scope capacity reached') + } + } + + private expireOwner(ownerScope: string): void { + const owner = this.owners.get(ownerScope) + if ( + owner?.state === 'disconnected' && + this.now() - (owner.disconnectedAt ?? this.now()) >= this.ownerGraceMs + ) { + this.owners.delete(ownerScope) } } } diff --git a/src/shared/pty-exact-operation-protocol.test.ts b/src/shared/pty-exact-operation-protocol.test.ts new file mode 100644 index 00000000000..015cd75459c --- /dev/null +++ b/src/shared/pty-exact-operation-protocol.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'vitest' +import { matchesPtyExactOperationIdentity } from './pty-exact-operation-protocol' + +describe('PTY exact operation protocol', () => { + it('matches only a present current incarnation', () => { + expect(matchesPtyExactOperationIdentity('inc-a', 'inc-a')).toBe(true) + expect(matchesPtyExactOperationIdentity('inc-a', 'inc-b')).toBe(false) + expect(matchesPtyExactOperationIdentity(undefined, 'inc-a')).toBe(false) + }) +}) diff --git a/src/shared/pty-exact-operation-protocol.ts b/src/shared/pty-exact-operation-protocol.ts new file mode 100644 index 00000000000..eb466352e87 --- /dev/null +++ b/src/shared/pty-exact-operation-protocol.ts @@ -0,0 +1,10 @@ +import { isPtyIncarnationId, type PtyIncarnationId } from './pty-incarnation' + +export const PTY_EXACT_OPERATION_PROTOCOL_VERSION = 1 + +export function matchesPtyExactOperationIdentity( + currentIncarnationId: string | undefined, + expectedIncarnationId: unknown +): expectedIncarnationId is PtyIncarnationId { + return isPtyIncarnationId(expectedIncarnationId) && currentIncarnationId === expectedIncarnationId +} diff --git a/src/shared/pty-listed-session.ts b/src/shared/pty-listed-session.ts index 064477dd12a..8af8bc4a808 100644 --- a/src/shared/pty-listed-session.ts +++ b/src/shared/pty-listed-session.ts @@ -22,6 +22,8 @@ export type PtyListedSession = { * Manager force-kill live agent sessions (#8459). */ agentOwnership: AgentOwnershipEvidence + /** Exact destructive-action target captured with this inventory row. */ + administrativeMutationAccess?: PtyAdministrativeMutationAccess } /** Only proven absence authorizes destroying a session without asking. */ @@ -30,3 +32,4 @@ export function mayDestroyWithoutOwnerEvidence(session: { }): boolean { return session.agentOwnership === 'absent' } +import type { PtyAdministrativeMutationAccess } from './pty-mutation-identity' diff --git a/src/shared/pty-mutation-identity.ts b/src/shared/pty-mutation-identity.ts new file mode 100644 index 00000000000..111caec2d42 --- /dev/null +++ b/src/shared/pty-mutation-identity.ts @@ -0,0 +1,181 @@ +import { isPtyIncarnationId, type PtyIncarnationId } from './pty-incarnation' + +export type PtyMutationIdentity = { + incarnationId: PtyIncarnationId + paneGeneration?: number + mutationLeaseId?: string +} + +export type PtyMutationClaimant = { + rendererEpochId: string + sequence: number +} + +export type PtyAdministrativeMutationEvidence = { + incarnationId: PtyIncarnationId + paneGeneration?: number +} + +export type PtyAdministrativeMutationAccess = + | { mode: 'legacy'; providerRouteId?: string } + | { + mode: 'exact' + evidence: PtyAdministrativeMutationEvidence + providerRouteId?: string + } + | { mode: 'unavailable'; providerRouteId?: string } + +export type PtyMutationAccess = + | { mode: 'legacy' } + | { mode: 'exact'; identity: PtyMutationIdentity; claimant: PtyMutationClaimant } + | { mode: 'unavailable' } + +export function parsePtyMutationAccess(value: unknown): PtyMutationAccess | null { + if (typeof value !== 'object' || value === null) { + return null + } + const candidate = value as { mode?: unknown; identity?: unknown } + if (candidate.mode === 'legacy' || candidate.mode === 'unavailable') { + return { mode: candidate.mode } + } + if (candidate.mode !== 'exact') { + return null + } + const identity = parsePtyMutationIdentity(candidate.identity) + const claimant = parsePtyMutationClaimant((candidate as { claimant?: unknown }).claimant) + return identity?.mutationLeaseId && claimant ? { mode: 'exact', identity, claimant } : null +} + +export function parsePtyMutationIdentity(value: unknown): PtyMutationIdentity | null { + if (typeof value !== 'object' || value === null) { + return null + } + const candidate = value as { + incarnationId?: unknown + paneGeneration?: unknown + mutationLeaseId?: unknown + } + if (!isPtyIncarnationId(candidate.incarnationId)) { + return null + } + if ( + candidate.paneGeneration !== undefined && + (!Number.isSafeInteger(candidate.paneGeneration) || (candidate.paneGeneration as number) < 0) + ) { + return null + } + if ( + candidate.mutationLeaseId !== undefined && + (typeof candidate.mutationLeaseId !== 'string' || + candidate.mutationLeaseId.length === 0 || + candidate.mutationLeaseId.length > 128) + ) { + return null + } + return { + incarnationId: candidate.incarnationId, + ...(candidate.paneGeneration !== undefined + ? { paneGeneration: candidate.paneGeneration as number } + : {}), + ...(candidate.mutationLeaseId !== undefined + ? { mutationLeaseId: candidate.mutationLeaseId } + : {}) + } +} + +export function parsePtyMutationClaimant(value: unknown): PtyMutationClaimant | null { + if (typeof value !== 'object' || value === null) { + return null + } + const candidate = value as { rendererEpochId?: unknown; sequence?: unknown } + if ( + typeof candidate.rendererEpochId !== 'string' || + candidate.rendererEpochId.length === 0 || + candidate.rendererEpochId.length > 128 || + !Number.isSafeInteger(candidate.sequence) || + (candidate.sequence as number) <= 0 + ) { + return null + } + return { + rendererEpochId: candidate.rendererEpochId, + sequence: candidate.sequence as number + } +} + +export function parsePtyAdministrativeMutationEvidence( + value: unknown +): PtyAdministrativeMutationEvidence | null { + const identity = parsePtyMutationIdentity(value) + return identity + ? { + incarnationId: identity.incarnationId, + ...(identity.paneGeneration !== undefined + ? { paneGeneration: identity.paneGeneration } + : {}) + } + : null +} + +export function parsePtyAdministrativeMutationAccess( + value: unknown +): PtyAdministrativeMutationAccess | null { + if (typeof value !== 'object' || value === null) { + return null + } + const candidate = value as { mode?: unknown; evidence?: unknown; providerRouteId?: unknown } + if ( + candidate.providerRouteId !== undefined && + (typeof candidate.providerRouteId !== 'string' || + candidate.providerRouteId.length === 0 || + candidate.providerRouteId.length > 128) + ) { + return null + } + const providerRoute = + candidate.providerRouteId === undefined + ? {} + : { providerRouteId: candidate.providerRouteId as string } + if (candidate.mode === 'legacy' || candidate.mode === 'unavailable') { + return { mode: candidate.mode, ...providerRoute } + } + const evidence = parsePtyAdministrativeMutationEvidence(candidate.evidence) + return candidate.mode === 'exact' && evidence + ? { mode: 'exact', evidence, ...providerRoute } + : null +} + +export function ptyMutationIdentityMatchesAdministrativeEvidence( + identity: PtyMutationIdentity | undefined, + evidence: PtyAdministrativeMutationEvidence | null | undefined +): boolean { + return Boolean( + identity && + evidence && + identity.incarnationId === evidence.incarnationId && + identity.paneGeneration === evidence.paneGeneration + ) +} + +export function toPtyAdministrativeMutationEvidence( + identity: PtyMutationIdentity +): PtyAdministrativeMutationEvidence { + return { + incarnationId: identity.incarnationId, + ...(identity.paneGeneration !== undefined ? { paneGeneration: identity.paneGeneration } : {}) + } +} + +export function ptyMutationIdentitiesEqual( + left: PtyMutationIdentity | undefined, + right: PtyMutationIdentity | null +): boolean { + return Boolean( + left && + right && + left.incarnationId === right.incarnationId && + left.paneGeneration === right.paneGeneration && + left.mutationLeaseId !== undefined && + left.mutationLeaseId === right.mutationLeaseId + ) +} diff --git a/src/shared/pty-renderer-binding.ts b/src/shared/pty-renderer-binding.ts new file mode 100644 index 00000000000..d95ec486b98 --- /dev/null +++ b/src/shared/pty-renderer-binding.ts @@ -0,0 +1,50 @@ +import type { PtyMutationAccess, PtyMutationClaimant } from './pty-mutation-identity' + +export type ExactPtyMutationAccess = Extract + +export type PtyRendererBindingClaim = { + id: string + paneGeneration: number + claimant: PtyMutationClaimant +} + +export type PtyRendererBindingReady = { + id: string + access: ExactPtyMutationAccess +} + +export type PtyRendererBindingCancel = PtyRendererBindingClaim & { + access?: ExactPtyMutationAccess +} + +export function ptyMutationClaimantsEqual( + left: PtyMutationClaimant, + right: PtyMutationClaimant +): boolean { + return left.rendererEpochId === right.rendererEpochId && left.sequence === right.sequence +} + +export function exactPtyMutationAccessesEqual( + left: ExactPtyMutationAccess, + right: ExactPtyMutationAccess +): boolean { + return ( + ptyMutationClaimantsEqual(left.claimant, right.claimant) && + left.identity.incarnationId === right.identity.incarnationId && + left.identity.paneGeneration === right.identity.paneGeneration && + left.identity.mutationLeaseId === right.identity.mutationLeaseId + ) +} + +export function comparePtyRendererBindingClaims( + left: Pick, + right: Pick +): number { + if (left.paneGeneration !== right.paneGeneration) { + return left.paneGeneration - right.paneGeneration + } + if (left.claimant.rendererEpochId !== right.claimant.rendererEpochId) { + return 1 + } + return left.claimant.sequence - right.claimant.sequence +} diff --git a/src/shared/pty-stream-binding-protocol.test.ts b/src/shared/pty-stream-binding-protocol.test.ts new file mode 100644 index 00000000000..57951fba4d1 --- /dev/null +++ b/src/shared/pty-stream-binding-protocol.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'vitest' +import { isPtyStreamSource } from './pty-stream-binding-protocol' + +describe('PTY stream binding protocol', () => { + it('requires both a bounded nonce and an incarnation', () => { + expect(isPtyStreamSource({ incarnationId: 'inc-a', streamBindingNonce: 'nonce-a' })).toBe(true) + expect(isPtyStreamSource({ incarnationId: 'inc-a', streamBindingNonce: '' })).toBe(false) + expect(isPtyStreamSource({ incarnationId: '', streamBindingNonce: 'nonce-a' })).toBe(false) + }) +}) diff --git a/src/shared/pty-stream-binding-protocol.ts b/src/shared/pty-stream-binding-protocol.ts new file mode 100644 index 00000000000..f1700e0db71 --- /dev/null +++ b/src/shared/pty-stream-binding-protocol.ts @@ -0,0 +1,25 @@ +import { isPtyIncarnationId, type PtyIncarnationId } from './pty-incarnation' + +export const PTY_STREAM_BINDING_NONCE_MAX_LENGTH = 128 + +export type PtyStreamSource = Readonly<{ + incarnationId: PtyIncarnationId + streamBindingNonce: string +}> + +export function isPtyStreamBindingNonce(value: unknown): value is string { + return ( + typeof value === 'string' && + value.length > 0 && + value.length <= PTY_STREAM_BINDING_NONCE_MAX_LENGTH + ) +} + +export function isPtyStreamSource(value: unknown): value is PtyStreamSource { + return ( + typeof value === 'object' && + value !== null && + isPtyIncarnationId((value as { incarnationId?: unknown }).incarnationId) && + isPtyStreamBindingNonce((value as { streamBindingNonce?: unknown }).streamBindingNonce) + ) +} diff --git a/src/shared/relay-daemon-compatibility.test.ts b/src/shared/relay-daemon-compatibility.test.ts new file mode 100644 index 00000000000..a9f92af60c3 --- /dev/null +++ b/src/shared/relay-daemon-compatibility.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from 'vitest' +import { + CURRENT_RELAY_DAEMON_COMPATIBILITY, + negotiateRelayDaemonCompatibility, + parseRelayDaemonCompatibilityOffer, + relayDaemonGrantSatisfiesOffer +} from './relay-daemon-compatibility' +import { TERMINAL_AUTHORITY_TOPOLOGY_STREAM_CAPABILITY } from './terminal-authority-topology-stream-contract' + +describe('relay daemon compatibility', () => { + it('negotiates the newest overlapping minor and shared capabilities', () => { + const grant = negotiateRelayDaemonCompatibility( + { + major: 3, + minMinor: 1, + maxMinor: 5, + capabilities: ['base', 'exact', 'newer'], + requiredCapabilities: ['base'] + }, + { + major: 3, + minMinor: 2, + maxMinor: 4, + capabilities: ['base', 'exact', 'client'], + requiredCapabilities: ['exact'] + } + ) + + expect(grant).toEqual({ major: 3, minor: 4, capabilities: ['base', 'exact'] }) + }) + + it.each([ + [ + { major: 1, minMinor: 0, maxMinor: 0, capabilities: ['base'], requiredCapabilities: [] }, + { major: 2, minMinor: 0, maxMinor: 0, capabilities: ['base'], requiredCapabilities: [] } + ], + [ + { major: 1, minMinor: 0, maxMinor: 1, capabilities: ['base'], requiredCapabilities: [] }, + { major: 1, minMinor: 2, maxMinor: 3, capabilities: ['base'], requiredCapabilities: [] } + ], + [ + { + major: 1, + minMinor: 0, + maxMinor: 0, + capabilities: ['base'], + requiredCapabilities: ['base'] + }, + { major: 1, minMinor: 0, maxMinor: 0, capabilities: [], requiredCapabilities: [] } + ] + ])('rejects incompatible protocol offers', (server, client) => { + expect(negotiateRelayDaemonCompatibility(server, client)).toBeNull() + }) + + it('rejects malformed offers before negotiation', () => { + expect( + parseRelayDaemonCompatibilityOffer({ + major: 1, + minMinor: 2, + maxMinor: 1, + capabilities: [], + requiredCapabilities: [] + }) + ).toBeNull() + expect( + parseRelayDaemonCompatibilityOffer({ + major: 1, + minMinor: 0, + maxMinor: 0, + capabilities: [], + requiredCapabilities: ['missing'] + }) + ).toBeNull() + }) + + it('requires every mandatory capability while allowing an optional topology omission', () => { + expect( + relayDaemonGrantSatisfiesOffer( + { major: 1, minor: 0, capabilities: ['relay.rpc.v1'] }, + CURRENT_RELAY_DAEMON_COMPATIBILITY + ) + ).toBe(false) + expect( + relayDaemonGrantSatisfiesOffer( + { + major: 1, + minor: 0, + capabilities: [ + 'relay.rpc.v1', + 'terminal-session.authority.v1', + 'terminal-session.distributed-control.v1', + 'remote-cli.relay-install.v1' + ] + }, + CURRENT_RELAY_DAEMON_COMPATIBILITY + ) + ).toBe(true) + }) + + it('keeps topology optional in both mixed-version directions', () => { + const withoutTopology = { + ...CURRENT_RELAY_DAEMON_COMPATIBILITY, + capabilities: CURRENT_RELAY_DAEMON_COMPATIBILITY.capabilities.filter( + (capability) => capability !== TERMINAL_AUTHORITY_TOPOLOGY_STREAM_CAPABILITY + ) + } + + expect( + negotiateRelayDaemonCompatibility(CURRENT_RELAY_DAEMON_COMPATIBILITY, withoutTopology) + ?.capabilities + ).not.toContain(TERMINAL_AUTHORITY_TOPOLOGY_STREAM_CAPABILITY) + expect( + negotiateRelayDaemonCompatibility(withoutTopology, CURRENT_RELAY_DAEMON_COMPATIBILITY) + ?.capabilities + ).not.toContain(TERMINAL_AUTHORITY_TOPOLOGY_STREAM_CAPABILITY) + }) + + it.each([ + [ + 'distributed control fencing', + ['relay.rpc.v1', 'terminal-session.authority.v1', 'remote-cli.relay-install.v1'] + ], + [ + 'single-channel remote CLI installation', + ['relay.rpc.v1', 'terminal-session.authority.v1', 'terminal-session.distributed-control.v1'] + ] + ])('rejects a stable authority that predates %s', (_contract, capabilities) => { + expect( + negotiateRelayDaemonCompatibility( + { + major: 1, + minMinor: 0, + maxMinor: 0, + capabilities, + requiredCapabilities: capabilities + }, + CURRENT_RELAY_DAEMON_COMPATIBILITY + ) + ).toBeNull() + }) +}) diff --git a/src/shared/relay-daemon-compatibility.ts b/src/shared/relay-daemon-compatibility.ts new file mode 100644 index 00000000000..545851ee8c1 --- /dev/null +++ b/src/shared/relay-daemon-compatibility.ts @@ -0,0 +1,145 @@ +import { TERMINAL_AUTHORITY_TOPOLOGY_STREAM_CAPABILITY } from './terminal-authority-topology-stream-contract' +import { TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_CAPABILITY } from './terminal-session-authority-consumer-transport' + +export const RELAY_DAEMON_PROTOCOL_MAJOR = 1 +export const RELAY_DAEMON_PROTOCOL_MINOR = 0 + +export const RELAY_DAEMON_CAPABILITIES = [ + 'relay.rpc.v1', + 'terminal-session.authority.v1', + 'terminal-session.distributed-control.v1', + 'remote-cli.relay-install.v1', + TERMINAL_AUTHORITY_TOPOLOGY_STREAM_CAPABILITY, + TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_CAPABILITY +] as const + +export const RELAY_DAEMON_REQUIRED_CAPABILITIES = [ + 'relay.rpc.v1', + 'terminal-session.authority.v1', + 'terminal-session.distributed-control.v1', + 'remote-cli.relay-install.v1' +] as const + +export type RelayDaemonCapability = (typeof RELAY_DAEMON_CAPABILITIES)[number] + +export type RelayDaemonCompatibilityOffer = { + major: number + minMinor: number + maxMinor: number + capabilities: string[] + requiredCapabilities: string[] +} + +export type RelayDaemonCompatibilityGrant = { + major: number + minor: number + capabilities: string[] +} + +export const CURRENT_RELAY_DAEMON_COMPATIBILITY: RelayDaemonCompatibilityOffer = Object.freeze({ + major: RELAY_DAEMON_PROTOCOL_MAJOR, + minMinor: RELAY_DAEMON_PROTOCOL_MINOR, + maxMinor: RELAY_DAEMON_PROTOCOL_MINOR, + capabilities: [...RELAY_DAEMON_CAPABILITIES], + requiredCapabilities: [...RELAY_DAEMON_REQUIRED_CAPABILITIES] +}) + +function isSafeProtocolNumber(value: unknown): value is number { + return Number.isSafeInteger(value) && Number(value) >= 0 +} + +function boundedStringSet(value: unknown): Set | null { + if (!Array.isArray(value) || value.length > 128) { + return null + } + const result = new Set() + for (const entry of value) { + if (typeof entry !== 'string' || entry.length === 0 || entry.length > 128) { + return null + } + result.add(entry) + } + return result +} + +export function parseRelayDaemonCompatibilityOffer( + value: unknown +): RelayDaemonCompatibilityOffer | null { + if (typeof value !== 'object' || value === null) { + return null + } + const offer = value as Partial + const capabilities = boundedStringSet(offer.capabilities) + const requiredCapabilities = boundedStringSet(offer.requiredCapabilities) + if ( + !isSafeProtocolNumber(offer.major) || + !isSafeProtocolNumber(offer.minMinor) || + !isSafeProtocolNumber(offer.maxMinor) || + offer.minMinor > offer.maxMinor || + !capabilities || + !requiredCapabilities + ) { + return null + } + for (const required of requiredCapabilities) { + if (!capabilities.has(required)) { + return null + } + } + return { + major: offer.major, + minMinor: offer.minMinor, + maxMinor: offer.maxMinor, + capabilities: [...capabilities], + requiredCapabilities: [...requiredCapabilities] + } +} + +export function negotiateRelayDaemonCompatibility( + serverValue: unknown, + clientValue: unknown +): RelayDaemonCompatibilityGrant | null { + const server = parseRelayDaemonCompatibilityOffer(serverValue) + const client = parseRelayDaemonCompatibilityOffer(clientValue) + if (!server || !client || server.major !== client.major) { + return null + } + const minMinor = Math.max(server.minMinor, client.minMinor) + const maxMinor = Math.min(server.maxMinor, client.maxMinor) + if (minMinor > maxMinor) { + return null + } + const serverCapabilities = new Set(server.capabilities) + const clientCapabilities = new Set(client.capabilities) + if ( + server.requiredCapabilities.some((capability) => !clientCapabilities.has(capability)) || + client.requiredCapabilities.some((capability) => !serverCapabilities.has(capability)) + ) { + return null + } + return { + major: server.major, + minor: maxMinor, + capabilities: server.capabilities.filter((capability) => clientCapabilities.has(capability)) + } +} + +export function relayDaemonGrantSatisfiesOffer( + grantValue: unknown, + offerValue: unknown +): grantValue is RelayDaemonCompatibilityGrant { + const offer = parseRelayDaemonCompatibilityOffer(offerValue) + if (!offer || typeof grantValue !== 'object' || grantValue === null) { + return false + } + const grant = grantValue as Partial + const capabilities = boundedStringSet(grant.capabilities) + return Boolean( + grant.major === offer.major && + isSafeProtocolNumber(grant.minor) && + grant.minor >= offer.minMinor && + grant.minor <= offer.maxMinor && + capabilities && + offer.requiredCapabilities.every((capability) => capabilities.has(capability)) + ) +} diff --git a/src/shared/relay-launch-fence-owner.ts b/src/shared/relay-launch-fence-owner.ts new file mode 100644 index 00000000000..30b7bfc1cf9 --- /dev/null +++ b/src/shared/relay-launch-fence-owner.ts @@ -0,0 +1,27 @@ +import { randomUUID } from 'node:crypto' + +export const RELAY_LAUNCH_FENCE_OWNER_TOKEN_PATTERN = /^[A-Za-z0-9_-]{16,256}$/u + +export function relayInstallLockOwnerFileName(ownerToken: string): string { + assertRelayLaunchFenceOwnerToken(ownerToken) + return `.install-owner-${ownerToken}` +} + +export function relayGcClaimOwnerFileName(ownerToken: string): string { + assertRelayLaunchFenceOwnerToken(ownerToken) + return `.gc-owner-${ownerToken}` +} + +export function isRelayLaunchFenceOwnerToken(value: string): boolean { + return RELAY_LAUNCH_FENCE_OWNER_TOKEN_PATTERN.test(value) +} + +export function createRelayInstallLockOwnerToken(): string { + return `install-${process.pid}-${Date.now()}-${randomUUID()}` +} + +function assertRelayLaunchFenceOwnerToken(ownerToken: string): void { + if (!isRelayLaunchFenceOwnerToken(ownerToken)) { + throw new Error('relay launch fence owner token is invalid') + } +} diff --git a/src/shared/secure-file.ts b/src/shared/secure-file.ts index 36231de6835..0561be165ed 100644 --- a/src/shared/secure-file.ts +++ b/src/shared/secure-file.ts @@ -2,6 +2,7 @@ import { randomBytes } from 'node:crypto' import { chmodSync, existsSync, + lstatSync, mkdirSync, renameSync, rmSync, @@ -18,6 +19,7 @@ import { resetSecureFileWindowsUserSidForTests, restrictWindowsPathSync } from './secure-path-windows-acl' +import { renameFileDurableSync } from './durable-file-write' type HardenedPathCacheEntry = { isDirectory: boolean @@ -88,6 +90,19 @@ export function writeSecureJsonFile(targetPath: string, value: unknown): void { } export function writeSecureFile(targetPath: string, contents: string): void { + writeSecureFileInternal(targetPath, contents, false) +} + +/** Durable variant for identity and other explicit crash-recovery boundaries. */ +export function writeSecureFileDurable(targetPath: string, contents: string): void { + writeSecureFileInternal(targetPath, contents, true) +} + +export function writeSecureJsonFileDurable(targetPath: string, value: unknown): void { + writeSecureFileDurable(targetPath, JSON.stringify(value, null, 2)) +} + +function writeSecureFileInternal(targetPath: string, contents: string, durable: boolean): void { const dir = dirname(targetPath) if (!existsSync(dir)) { mkdirSync(dir, { recursive: true, mode: 0o700 }) @@ -103,10 +118,17 @@ export function writeSecureFile(targetPath: string, contents: string): void { }) // Why: writeFileSync mode is a no-op on Windows, so restrict the credential's ACL synchronously before the rename publishes it under inherited ACLs. applySecurePathRestriction(tmpFile, false, process.platform, true) - renameSync(tmpFile, targetPath) - // Why: these hold auth credentials, so the published path must stay current-user only; cache only on confirmed success so failures retry. - if (applySecurePathRestriction(targetPath, false, process.platform, true)) { - rememberHardenedPath(targetPath, false) + const hardenPublishedFile = (): void => { + // Why: these hold auth credentials, so the published path must stay current-user only; cache only on confirmed success so failures retry. + if (applySecurePathRestriction(targetPath, false, process.platform, true)) { + rememberHardenedPath(targetPath, false) + } + } + if (durable) { + renameFileDurableSync(tmpFile, targetPath, { afterRename: hardenPublishedFile }) + } else { + renameSync(tmpFile, targetPath) + hardenPublishedFile() } } catch (error) { rmSync(tmpFile, { force: true }) @@ -114,6 +136,19 @@ export function writeSecureFile(targetPath: string, contents: string): void { } } +export function assertSecureRegularFile(targetPath: string, field: string): void { + try { + if (lstatSync(targetPath).isSymbolicLink() || !statSync(targetPath).isFile()) { + throw new Error(`${field} is invalid`) + } + } catch (error) { + if (error instanceof Error && error.message === `${field} is invalid`) { + throw error + } + throw new Error(`${field} is unavailable`) + } +} + export function hardenExistingSecureFile(targetPath: string): void { const dir = dirname(targetPath) if (existsSync(dir)) { diff --git a/src/shared/ssh-remote-cli-launcher-install.test.ts b/src/shared/ssh-remote-cli-launcher-install.test.ts new file mode 100644 index 00000000000..8d00614e04d --- /dev/null +++ b/src/shared/ssh-remote-cli-launcher-install.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { + SSH_REMOTE_CLI_LAUNCHER_INSTALL_VERSION, + parseSshRemoteCliLauncherInstallRequest, + parseSshRemoteCliLauncherInstallResult +} from './ssh-remote-cli-launcher-install' + +describe('SSH remote CLI launcher install protocol', () => { + it('parses a bounded cross-platform request', () => { + expect( + parseSshRemoteCliLauncherInstallRequest({ + version: SSH_REMOTE_CLI_LAUNCHER_INSTALL_VERSION, + binDir: 'C:\\Users\\me user\\.orca-relay\\bin', + relayDir: 'C:\\Users\\me user\\.orca-relay\\relay', + nodePath: 'C:\\Program Files\\nodejs\\node.exe', + sockPath: '\\\\.\\pipe\\orca-relay', + credentialFile: 'C:\\Users\\me user\\.orca-relay\\relay.credential' + }) + ).toEqual({ + version: 1, + binDir: 'C:\\Users\\me user\\.orca-relay\\bin', + relayDir: 'C:\\Users\\me user\\.orca-relay\\relay', + nodePath: 'C:\\Program Files\\nodejs\\node.exe', + sockPath: '\\\\.\\pipe\\orca-relay', + credentialFile: 'C:\\Users\\me user\\.orca-relay\\relay.credential' + }) + }) + + it.each([ + [{ version: 2 }, 'remote_cli_launcher_incompatible_version'], + [ + { + version: 1, + binDir: '/tmp/bin\nignored', + relayDir: '/tmp/relay', + nodePath: '/usr/bin/node', + sockPath: '/tmp/relay.sock' + }, + 'remote_cli_launcher_invalid_bin_dir' + ] + ])('rejects malformed request %#', (request, error) => { + expect(() => parseSshRemoteCliLauncherInstallRequest(request)).toThrow(error) + }) + + it('rejects an unacknowledged install result', () => { + expect(() => + parseSshRemoteCliLauncherInstallResult({ + version: 1, + launcherPath: '/home/me/.orca-relay/bin/orca' + }) + ).toThrow('remote_cli_launcher_invalid_result') + }) +}) diff --git a/src/shared/ssh-remote-cli-launcher-install.ts b/src/shared/ssh-remote-cli-launcher-install.ts new file mode 100644 index 00000000000..8d62e5d81c6 --- /dev/null +++ b/src/shared/ssh-remote-cli-launcher-install.ts @@ -0,0 +1,77 @@ +export const SSH_REMOTE_CLI_LAUNCHER_INSTALL_METHOD = 'session.installRemoteCliLauncher' as const +export const SSH_REMOTE_CLI_LAUNCHER_INSTALL_VERSION = 1 as const + +const MAX_REMOTE_PATH_LENGTH = 32_768 + +export type SshRemoteCliLauncherInstallRequest = { + version: typeof SSH_REMOTE_CLI_LAUNCHER_INSTALL_VERSION + binDir: string + relayDir: string + nodePath: string + sockPath: string + credentialFile?: string +} + +export type SshRemoteCliLauncherInstallResult = { + version: typeof SSH_REMOTE_CLI_LAUNCHER_INSTALL_VERSION + launcherPath: string + changed: boolean +} + +function parseRemotePath(value: unknown, field: string): string { + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > MAX_REMOTE_PATH_LENGTH || + value.includes('\0') || + value.includes('\r') || + value.includes('\n') + ) { + throw new Error(`remote_cli_launcher_invalid_${field}`) + } + return value +} + +export function parseSshRemoteCliLauncherInstallRequest( + value: unknown +): SshRemoteCliLauncherInstallRequest { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('remote_cli_launcher_invalid_request') + } + const request = value as Record + if (request.version !== SSH_REMOTE_CLI_LAUNCHER_INSTALL_VERSION) { + throw new Error('remote_cli_launcher_incompatible_version') + } + const credentialFile = + request.credentialFile === undefined + ? undefined + : parseRemotePath(request.credentialFile, 'credential_file') + return { + version: SSH_REMOTE_CLI_LAUNCHER_INSTALL_VERSION, + binDir: parseRemotePath(request.binDir, 'bin_dir'), + relayDir: parseRemotePath(request.relayDir, 'relay_dir'), + nodePath: parseRemotePath(request.nodePath, 'node_path'), + sockPath: parseRemotePath(request.sockPath, 'socket_path'), + ...(credentialFile ? { credentialFile } : {}) + } +} + +export function parseSshRemoteCliLauncherInstallResult( + value: unknown +): SshRemoteCliLauncherInstallResult { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('remote_cli_launcher_invalid_result') + } + const result = value as Record + if ( + result.version !== SSH_REMOTE_CLI_LAUNCHER_INSTALL_VERSION || + typeof result.changed !== 'boolean' + ) { + throw new Error('remote_cli_launcher_invalid_result') + } + return { + version: SSH_REMOTE_CLI_LAUNCHER_INSTALL_VERSION, + launcherPath: parseRemotePath(result.launcherPath, 'result_path'), + changed: result.changed + } +} diff --git a/src/shared/ssh-terminal-authority-marker.test.ts b/src/shared/ssh-terminal-authority-marker.test.ts new file mode 100644 index 00000000000..21bdac30cb5 --- /dev/null +++ b/src/shared/ssh-terminal-authority-marker.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest' +import { CURRENT_RELAY_DAEMON_COMPATIBILITY } from './relay-daemon-compatibility' +import { + SSH_TERMINAL_AUTHORITY_MARKER_VERSION, + parseSshTerminalAuthorityMarker, + parseTerminalAuthorityEndpointIdentity, + sameTerminalAuthorityEndpointIdentity, + terminalAuthorityEndpointIdentity, + type SshTerminalAuthorityMarker +} from './ssh-terminal-authority-marker' + +const marker: SshTerminalAuthorityMarker = { + markerVersion: SSH_TERMINAL_AUTHORITY_MARKER_VERSION, + authorityHostId: 'authority-host', + ownerInstanceId: 'owner-instance', + ownerPid: 42, + ownerProcessToken: 'owner-process-token', + ownerBuildId: '1.2.3+abcdef', + ownerRelayDir: '/home/orca/.orca-remote/relay-v1', + socketPath: '/home/orca/.orca-remote/relay-v1/relay.sock', + credentialFile: '/home/orca/.orca-remote/relay-v1/relay.sock.credential', + compatibility: CURRENT_RELAY_DAEMON_COMPATIBILITY, + revision: 1 +} + +describe('SSH terminal authority marker', () => { + it('accepts a bounded current marker', () => { + expect(parseSshTerminalAuthorityMarker(marker)).toEqual(marker) + expect( + parseSshTerminalAuthorityMarker({ + ...marker, + registryWriterOwnerToken: 'terminal-authority-owner-v1.proof' + }) + ).toMatchObject({ registryWriterOwnerToken: 'terminal-authority-owner-v1.proof' }) + }) + + it('projects and validates the exact socket endpoint identity', () => { + const identity = terminalAuthorityEndpointIdentity(marker) + expect(parseTerminalAuthorityEndpointIdentity(identity)).toEqual(identity) + expect(sameTerminalAuthorityEndpointIdentity(identity, { ...identity })).toBe(true) + expect( + sameTerminalAuthorityEndpointIdentity(identity, { + ...identity, + revision: identity.revision + 1 + }) + ).toBe(false) + expect(parseTerminalAuthorityEndpointIdentity({ ...identity, ownerInstanceId: '' })).toBeNull() + }) + + it.each([ + { ...marker, markerVersion: 2 }, + { ...marker, authorityHostId: '' }, + { ...marker, ownerPid: 0 }, + { ...marker, ownerProcessToken: '' }, + { ...marker, registryWriterOwnerToken: 'x'.repeat(1_025) }, + { ...marker, ownerBuildId: '' }, + { ...marker, ownerRelayDir: 'x'.repeat(4_097) }, + { ...marker, compatibility: { ...CURRENT_RELAY_DAEMON_COMPATIBILITY, major: -1 } }, + { ...marker, revision: 0 } + ])('rejects malformed marker fields', (value) => { + expect(parseSshTerminalAuthorityMarker(value)).toBeNull() + }) +}) diff --git a/src/shared/ssh-terminal-authority-marker.ts b/src/shared/ssh-terminal-authority-marker.ts new file mode 100644 index 00000000000..d075a5ed93c --- /dev/null +++ b/src/shared/ssh-terminal-authority-marker.ts @@ -0,0 +1,104 @@ +import { + parseRelayDaemonCompatibilityOffer, + type RelayDaemonCompatibilityOffer +} from './relay-daemon-compatibility' + +export const SSH_TERMINAL_AUTHORITY_MARKER_VERSION = 1 + +export type SshTerminalAuthorityMarker = { + markerVersion: typeof SSH_TERMINAL_AUTHORITY_MARKER_VERSION + authorityHostId: string + ownerInstanceId: string + ownerPid: number + ownerProcessToken: string + registryWriterOwnerToken?: string + ownerBuildId: string + ownerRelayDir: string + socketPath: string + credentialFile: string + compatibility: RelayDaemonCompatibilityOffer + revision: number +} + +export type SshTerminalAuthorityEndpointIdentity = Pick< + SshTerminalAuthorityMarker, + 'authorityHostId' | 'ownerInstanceId' | 'revision' +> + +function isBoundedString(value: unknown, maxLength: number): value is string { + return typeof value === 'string' && value.length > 0 && value.length <= maxLength +} + +export function parseSshTerminalAuthorityMarker(value: unknown): SshTerminalAuthorityMarker | null { + if (typeof value !== 'object' || value === null) { + return null + } + const marker = value as Partial + const compatibility = parseRelayDaemonCompatibilityOffer(marker.compatibility) + if ( + marker.markerVersion !== SSH_TERMINAL_AUTHORITY_MARKER_VERSION || + !isBoundedString(marker.authorityHostId, 128) || + !isBoundedString(marker.ownerInstanceId, 128) || + !Number.isSafeInteger(marker.ownerPid) || + Number(marker.ownerPid) <= 0 || + !isBoundedString(marker.ownerProcessToken, 128) || + (marker.registryWriterOwnerToken !== undefined && + !isBoundedString(marker.registryWriterOwnerToken, 1_024)) || + !isBoundedString(marker.ownerBuildId, 512) || + !isBoundedString(marker.ownerRelayDir, 4_096) || + !isBoundedString(marker.socketPath, 4_096) || + !isBoundedString(marker.credentialFile, 4_096) || + !compatibility || + !Number.isSafeInteger(marker.revision) || + Number(marker.revision) <= 0 + ) { + return null + } + return { + ...marker, + compatibility + } as SshTerminalAuthorityMarker +} + +export function terminalAuthorityEndpointIdentity( + marker: SshTerminalAuthorityMarker +): SshTerminalAuthorityEndpointIdentity { + return { + authorityHostId: marker.authorityHostId, + ownerInstanceId: marker.ownerInstanceId, + revision: marker.revision + } +} + +export function parseTerminalAuthorityEndpointIdentity( + value: unknown +): SshTerminalAuthorityEndpointIdentity | null { + if (typeof value !== 'object' || value === null) { + return null + } + const identity = value as Partial + if ( + !isBoundedString(identity.authorityHostId, 128) || + !isBoundedString(identity.ownerInstanceId, 128) || + !Number.isSafeInteger(identity.revision) || + Number(identity.revision) <= 0 + ) { + return null + } + return { + authorityHostId: identity.authorityHostId, + ownerInstanceId: identity.ownerInstanceId, + revision: Number(identity.revision) + } +} + +export function sameTerminalAuthorityEndpointIdentity( + left: SshTerminalAuthorityEndpointIdentity, + right: SshTerminalAuthorityEndpointIdentity +): boolean { + return ( + left.authorityHostId === right.authorityHostId && + left.ownerInstanceId === right.ownerInstanceId && + left.revision === right.revision + ) +} diff --git a/src/shared/ssh-types.ts b/src/shared/ssh-types.ts index fd6a9a3bcb0..f56ab0925af 100644 --- a/src/shared/ssh-types.ts +++ b/src/shared/ssh-types.ts @@ -1,3 +1,5 @@ +import type { TerminalSessionAuthorityPtyAccess } from './terminal-session-authority-pty-access' + // ─── SSH Connection Types ─────────────────────────────────────────── export const MIN_SSH_RELAY_GRACE_PERIOD_SECONDS = 60 @@ -192,12 +194,23 @@ export type SshMutationExpectation = { export type SshRemotePtyLeaseState = 'attached' | 'detached' | 'terminated' | 'expired' +export type SshRemotePtyPendingClose = { + incarnationId: string + terminalSessionAuthorityAccess: TerminalSessionAuthorityPtyAccess + keepHistory: boolean + requestedAt: number +} + export type SshRemotePtyLease = { targetId: string ptyId: string + incarnationId?: string worktreeId?: string tabId?: string leafId?: string + paneGeneration?: number + terminalSessionAuthorityAccess?: TerminalSessionAuthorityPtyAccess + pendingClose?: SshRemotePtyPendingClose state: SshRemotePtyLeaseState createdAt: number updatedAt: number diff --git a/src/shared/terminal-authority-app-projection-event-validation.ts b/src/shared/terminal-authority-app-projection-event-validation.ts new file mode 100644 index 00000000000..9413b0fb941 --- /dev/null +++ b/src/shared/terminal-authority-app-projection-event-validation.ts @@ -0,0 +1,62 @@ +import type { TerminalAuthorityAppEventKey } from './terminal-authority-app-projection' +import { + assertAuthorityId, + assertAuthorityNamespace, + isRecord +} from './terminal-session-authority-identity' + +export function parseTerminalAuthorityAppEventKey( + value: unknown +): TerminalAuthorityAppEventKey | null { + if ( + !isRecord(value) || + !isAuthorityId(value.consumerId) || + !Number.isSafeInteger(value.sequence) || + Number(value.sequence) < 1 || + !isAuthorityId(value.outcomeId) + ) { + return null + } + try { + assertAuthorityNamespace(value.namespace) + return Object.freeze({ + consumerId: value.consumerId, + namespace: Object.freeze({ ...value.namespace }), + sequence: Number(value.sequence), + outcomeId: value.outcomeId + }) + } catch { + return null + } +} + +export function assertTerminalAuthorityAppMatchingEvent( + value: unknown, + row: Record +): TerminalAuthorityAppEventKey { + const event = parseTerminalAuthorityAppEventKey(value) + if ( + !event || + event.consumerId !== row.consumerId || + !sameTerminalAuthorityAppProjectionNamespace(event.namespace, row.namespace as never) + ) { + throw new Error('terminal authority app projection event identity changed') + } + return event +} + +export function sameTerminalAuthorityAppProjectionNamespace( + left: { authorityHostId: string; namespaceId: string }, + right: { authorityHostId: string; namespaceId: string } +): boolean { + return left.authorityHostId === right.authorityHostId && left.namespaceId === right.namespaceId +} + +function isAuthorityId(value: unknown): value is string { + try { + assertAuthorityId(value, 'authority projection ID') + return true + } catch { + return false + } +} diff --git a/src/shared/terminal-authority-app-projection-row-validation.ts b/src/shared/terminal-authority-app-projection-row-validation.ts new file mode 100644 index 00000000000..86bf1094a78 --- /dev/null +++ b/src/shared/terminal-authority-app-projection-row-validation.ts @@ -0,0 +1,297 @@ +import { AGENT_STATUS_STATES } from './agent-status-types' +import { + TERMINAL_AUTHORITY_APP_PROJECTION_VERSION, + type TerminalAuthorityAppEventKey, + type TerminalAuthorityAppPaneProjection +} from './terminal-authority-app-projection' +import { + assertAuthorityId, + assertAuthorityNamespace, + assertPaneGeneration, + assertTerminalBinding, + isRecord, + sameTerminalBinding +} from './terminal-session-authority-identity' +import { + assertAllocationRecord, + assertPaneRecord, + assertSafeInteger, + assertSemanticFact +} from './terminal-session-authority-record-validation' +import { parsePaneKey } from './stable-pane-id' +import { assertTerminalAuthorityAppMatchingEvent } from './terminal-authority-app-projection-event-validation' + +const FACT_KINDS = new Set([ + 'agent-status', + 'title', + 'bell', + 'agent-working', + 'agent-idle', + 'agent-exited', + 'command-finished', + 'pr-link', + 'command-code-working', + 'command-code-done', + '2031-subscribe', + '2031-unsubscribe' +]) +const AGENT_STATES = new Set([...AGENT_STATUS_STATES, 'idle', 'exited']) + +export function assertTerminalAuthorityAppPaneProjection( + value: unknown +): asserts value is TerminalAuthorityAppPaneProjection { + if (!isVersionedRecord(value) || !isAuthorityId(value.consumerId) || !isRecord(value.facts)) { + throw new Error('terminal authority app projection row is invalid') + } + assertAuthorityNamespace(value.namespace) + assertPaneGeneration(value.pane) + assertLayout(value.layout, value.pane.paneKey) + if (value.binding !== null) { + assertTerminalBinding(value.binding) + } + assertTopology(value) + if (!sameNullableBinding(value.binding, value.topology.binding)) { + throw new Error('terminal authority app projection binding changed') + } + if (value.allocation !== undefined) { + assertAllocationRecord(value.allocation) + if (!samePane(value.allocation.pane, value.pane)) { + throw new Error('terminal authority app projection allocation changed pane') + } + } + const events = assertFacts(value) + assertDerivedProjection(value, events) +} + +function assertTopology( + value: Record +): asserts value is Record & { topology: Record } { + if (!isRecord(value.topology)) { + throw new Error('terminal authority app projection topology is invalid') + } + const pane = value.pane + if (!isRecord(pane)) { + throw new Error('terminal authority app projection pane is invalid') + } + assertPaneRecord({ ...pane, ...value.topology, revision: value.topology.authorityRevision }) + if ( + value.topology.ownerStatus !== null && + value.topology.ownerStatus !== 'reachable' && + value.topology.ownerStatus !== 'owner-unreachable' + ) { + throw new Error('terminal authority app projection owner status is invalid') + } + if (value.topology.event !== undefined) { + assertTerminalAuthorityAppMatchingEvent(value.topology.event, value) + } +} + +function assertFacts(value: Record): TerminalAuthorityAppEventKey[] { + const facts = value.facts as Record + const events: TerminalAuthorityAppEventKey[] = [] + for (const [kind, field] of Object.entries(facts)) { + if (!FACT_KINDS.has(kind) || !isRecord(field)) { + throw new Error('terminal authority app projection fact is invalid') + } + const event = assertTerminalAuthorityAppMatchingEvent(field.event, value) + assertTerminalBinding(field.binding) + assertSemanticFact(field.fact) + assertSafeInteger(field.appliedAt, 'projection fact appliedAt') + if (field.fact.kind !== kind || !bindingBelongsToRow(field.binding, value)) { + throw new Error('terminal authority app projection fact identity changed') + } + if (kind === 'pr-link') { + assertSafeInteger(field.verifyAfter, 'projection PR verification deadline') + if (!['pending', 'verified', 'failed'].includes(String(field.verification))) { + throw new Error('terminal authority app projection PR verification is invalid') + } + } else if (field.verifyAfter !== undefined || field.verification !== undefined) { + throw new Error('terminal authority app projection fact fields conflict') + } + events.push(event) + } + return events +} + +function assertDerivedProjection( + value: Record, + factEvents: TerminalAuthorityAppEventKey[] +): void { + const events = [...factEvents] + if (value.exit !== undefined) { + assertExit(value.exit, value, events) + } + if (value.agent !== undefined) { + assertAgent(value.agent, value, events) + } + if (value.commandCode !== undefined) { + assertCommandCode(value.commandCode, value, events) + } + const attention = assertAttention(value.attention, value, events) + assertStatus(value.status, value, events, attention) + if (isRecord(value.topology) && value.topology.event !== undefined) { + events.push(assertTerminalAuthorityAppMatchingEvent(value.topology.event, value)) + } + const latest = + value.latestEvent === undefined + ? null + : assertTerminalAuthorityAppMatchingEvent(value.latestEvent, value) + if (events.length > 0 && (!latest || events.some((event) => event.sequence > latest.sequence))) { + throw new Error('terminal authority app projection latest event regressed') + } +} + +function assertExit( + value: unknown, + row: Record, + events: TerminalAuthorityAppEventKey[] +): void { + if (!isRecord(value)) { + throw new Error('terminal authority app projection exit is invalid') + } + events.push(assertTerminalAuthorityAppMatchingEvent(value.event, row)) + assertTerminalBinding(value.binding) + if (!bindingBelongsToRow(value.binding, row)) { + throw new Error('projection exit binding changed') + } + if (value.code !== null) { + assertSafeInteger(value.code, 'projection exit code') + } + if (value.signal !== null && typeof value.signal !== 'string') { + throw new Error('terminal authority app projection exit signal is invalid') + } +} + +function assertAgent( + value: unknown, + row: Record, + events: TerminalAuthorityAppEventKey[] +): void { + if ( + !isRecord(value) || + !AGENT_STATES.has(String(value.state)) || + typeof value.prompt !== 'string' + ) { + throw new Error('terminal authority app projection agent is invalid') + } + events.push(assertTerminalAuthorityAppMatchingEvent(value.event, row)) + assertTerminalBinding(value.binding) + assertSafeInteger(value.transitionedAt, 'projection agent transition') + if (!bindingBelongsToRow(value.binding, row)) { + throw new Error('projection agent binding changed') + } +} + +function assertCommandCode( + value: unknown, + row: Record, + events: TerminalAuthorityAppEventKey[] +): void { + if ( + !isRecord(value) || + !['working', 'settling', 'done'].includes(String(value.state)) || + typeof value.prompt !== 'string' + ) { + throw new Error('terminal authority app projection Command Code state is invalid') + } + events.push(assertTerminalAuthorityAppMatchingEvent(value.event, row)) + assertTerminalBinding(value.binding) + assertSafeInteger(value.transitionedAt, 'projection Command Code transition') + if (value.settleAt !== null) { + assertSafeInteger(value.settleAt, 'projection Command Code deadline') + } + if ((value.state === 'settling') !== (value.settleAt !== null)) { + throw new Error('terminal authority app projection Command Code deadline is invalid') + } + if (!bindingBelongsToRow(value.binding, row)) { + throw new Error('projection Command Code binding changed') + } +} + +function assertAttention( + value: unknown, + row: Record, + events: TerminalAuthorityAppEventKey[] +): Record { + if (!isRecord(value)) { + throw new Error('terminal authority app projection attention is invalid') + } + assertSafeInteger(value.pendingBellCount, 'projection bell count') + assertSafeInteger(value.updatedAt, 'projection attention timestamp') + if (Number(value.pendingBellCount) > 99) { + throw new Error('projection bell count exceeds capacity') + } + if (value.event !== null) { + events.push(assertTerminalAuthorityAppMatchingEvent(value.event, row)) + } + return value +} + +function assertStatus( + value: unknown, + row: Record, + events: TerminalAuthorityAppEventKey[], + attention: Record +): void { + if (!isRecord(value) || !isRecord(row.topology)) { + throw new Error('terminal authority app projection status is invalid') + } + assertSafeInteger(value.updatedAt, 'projection status timestamp') + if (value.event !== null) { + events.push(assertTerminalAuthorityAppMatchingEvent(value.event, row)) + } + const agentState = isRecord(row.agent) ? row.agent.state : null + if ( + value.pane !== row.topology.status || + value.agent !== agentState || + value.attention !== Number(attention.pendingBellCount) > 0 + ) { + throw new Error('terminal authority app projection status conflicts') + } +} + +function assertLayout(value: unknown, paneKey: string): void { + const expected = parsePaneKey(paneKey) + if ( + expected === null + ? value !== null + : !isRecord(value) || value.tabId !== expected.tabId || value.leafId !== expected.leafId + ) { + throw new Error('terminal authority app projection layout is invalid') + } +} + +function bindingBelongsToRow(binding: unknown, row: Record): boolean { + if (!isRecord(row.topology)) { + return false + } + return [row.topology.binding, row.topology.lastBinding].some( + (candidate) => candidate !== null && sameTerminalBinding(binding as never, candidate as never) + ) +} + +function sameNullableBinding(left: unknown, right: unknown): boolean { + return left === null || right === null + ? left === right + : sameTerminalBinding(left as never, right as never) +} + +function samePane( + left: { paneKey: string; paneGenerationId: string }, + right: { paneKey: string; paneGenerationId: string } +): boolean { + return left.paneKey === right.paneKey && left.paneGenerationId === right.paneGenerationId +} + +function isVersionedRecord(value: unknown): value is Record { + return isRecord(value) && value.version === TERMINAL_AUTHORITY_APP_PROJECTION_VERSION +} + +function isAuthorityId(value: unknown): value is string { + try { + assertAuthorityId(value, 'authority projection ID') + return true + } catch { + return false + } +} diff --git a/src/shared/terminal-authority-app-projection-validation.ts b/src/shared/terminal-authority-app-projection-validation.ts new file mode 100644 index 00000000000..0695efabde2 --- /dev/null +++ b/src/shared/terminal-authority-app-projection-validation.ts @@ -0,0 +1,178 @@ +import { + TERMINAL_AUTHORITY_APP_PROJECTION_MAX_ROWS, + TERMINAL_AUTHORITY_APP_PROJECTION_VERSION, + terminalAuthorityAppProjectionRowKey, + type TerminalAuthorityAppBellClearRequest, + type TerminalAuthorityAppPaneProjection, + type TerminalAuthorityAppProjectionDelta, + type TerminalAuthorityAppProjectionRowIdentity, + type TerminalAuthorityAppProjectionSnapshot, + type TerminalAuthorityAppProjectionSubscribe +} from './terminal-authority-app-projection' +import { + assertAuthorityId, + assertAuthorityNamespace, + assertPaneGeneration, + isRecord +} from './terminal-session-authority-identity' +import { assertTerminalAuthorityAppPaneProjection } from './terminal-authority-app-projection-row-validation' +import { + parseTerminalAuthorityAppEventKey, + sameTerminalAuthorityAppProjectionNamespace +} from './terminal-authority-app-projection-event-validation' + +export { assertTerminalAuthorityAppPaneProjection } from './terminal-authority-app-projection-row-validation' + +export function parseTerminalAuthorityAppProjectionSubscribe( + value: unknown +): TerminalAuthorityAppProjectionSubscribe | null { + if (!isVersionedRecord(value) || !isAuthorityId(value.subscriptionIncarnationId)) { + return null + } + const expected = value.expectedSubscriptionIncarnationId + if (expected !== undefined && expected !== null && !isAuthorityId(expected)) { + return null + } + return Object.freeze({ + version: TERMINAL_AUTHORITY_APP_PROJECTION_VERSION, + subscriptionIncarnationId: value.subscriptionIncarnationId, + ...(expected !== undefined ? { expectedSubscriptionIncarnationId: expected } : {}) + }) +} + +export function parseTerminalAuthorityAppProjectionSnapshot( + value: unknown +): TerminalAuthorityAppProjectionSnapshot | null { + return parseProjectionEnvelope(value, false) +} + +export function parseTerminalAuthorityAppProjectionDelta( + value: unknown +): TerminalAuthorityAppProjectionDelta | null { + return parseProjectionEnvelope(value, true) +} + +export function parseTerminalAuthorityAppBellClearRequest( + value: unknown +): TerminalAuthorityAppBellClearRequest | null { + if (!isVersionedRecord(value) || !isAuthorityId(value.consumerId)) { + return null + } + try { + assertAuthorityNamespace(value.namespace) + assertPaneGeneration(value.pane) + } catch { + return null + } + const expectedEvent = parseTerminalAuthorityAppEventKey(value.expectedEvent) + if ( + !expectedEvent || + expectedEvent.consumerId !== value.consumerId || + !sameTerminalAuthorityAppProjectionNamespace(expectedEvent.namespace, value.namespace) + ) { + return null + } + return Object.freeze({ + version: TERMINAL_AUTHORITY_APP_PROJECTION_VERSION, + consumerId: value.consumerId, + namespace: Object.freeze({ ...value.namespace }), + pane: Object.freeze({ ...value.pane }), + expectedEvent + }) +} + +export function parseTerminalAuthorityAppPaneProjection( + value: unknown +): TerminalAuthorityAppPaneProjection | null { + try { + assertTerminalAuthorityAppPaneProjection(value) + return Object.freeze(structuredClone(value)) + } catch { + return null + } +} + +function parseProjectionEnvelope( + value: unknown, + allowDeleted: boolean +): TerminalAuthorityAppProjectionSnapshot | null { + if ( + !isVersionedRecord(value) || + !isAuthorityId(value.subscriptionIncarnationId) || + !Array.isArray(value.rows) || + value.rows.length > TERMINAL_AUTHORITY_APP_PROJECTION_MAX_ROWS + ) { + return null + } + const rows = value.rows.map(parseTerminalAuthorityAppPaneProjection) + if (rows.some((row) => row === null)) { + return null + } + const parsedRows = rows as TerminalAuthorityAppPaneProjection[] + const rowKeys = new Set(parsedRows.map(terminalAuthorityAppProjectionRowKey)) + if (rowKeys.size !== parsedRows.length) { + return null + } + const deleted = allowDeleted ? parseDeleted(value.deleted, rowKeys) : null + if (allowDeleted && value.deleted !== undefined && !deleted) { + return null + } + return Object.freeze({ + version: TERMINAL_AUTHORITY_APP_PROJECTION_VERSION, + subscriptionIncarnationId: value.subscriptionIncarnationId, + rows: Object.freeze(parsedRows), + ...(deleted ? { deleted } : {}) + }) +} + +function parseDeleted( + value: unknown, + rowKeys: ReadonlySet +): readonly TerminalAuthorityAppProjectionRowIdentity[] | null { + if (value === undefined) { + return null + } + if (!Array.isArray(value) || value.length > TERMINAL_AUTHORITY_APP_PROJECTION_MAX_ROWS) { + return null + } + const parsed = value.map(parseRowIdentity) + if (parsed.some((row) => row === null)) { + return null + } + const rows = parsed as TerminalAuthorityAppProjectionRowIdentity[] + const keys = rows.map(terminalAuthorityAppProjectionRowKey) + if (new Set(keys).size !== keys.length || keys.some((key) => rowKeys.has(key))) { + return null + } + return Object.freeze(rows) +} + +function parseRowIdentity(value: unknown): TerminalAuthorityAppProjectionRowIdentity | null { + if (!isRecord(value) || !isAuthorityId(value.consumerId)) { + return null + } + try { + assertAuthorityNamespace(value.namespace) + assertPaneGeneration(value.pane) + return Object.freeze({ + consumerId: value.consumerId, + namespace: Object.freeze({ ...value.namespace }), + pane: Object.freeze({ ...value.pane }) + }) + } catch { + return null + } +} + +function isVersionedRecord(value: unknown): value is Record { + return isRecord(value) && value.version === TERMINAL_AUTHORITY_APP_PROJECTION_VERSION +} + +function isAuthorityId(value: unknown): value is string { + try { + assertAuthorityId(value, 'authority projection ID') + return true + } catch { + return false + } +} diff --git a/src/shared/terminal-authority-app-projection.ts b/src/shared/terminal-authority-app-projection.ts new file mode 100644 index 00000000000..cafe81ca5cf --- /dev/null +++ b/src/shared/terminal-authority-app-projection.ts @@ -0,0 +1,157 @@ +import type { AgentStatusState } from './agent-status-types' +import type { + TerminalAuthorityNamespace, + TerminalPaneGeneration, + TerminalSessionBinding +} from './terminal-session-authority-identity' +import type { + TerminalPaneAuthorityStatus, + TerminalSessionAuthoritySemanticFact, + TerminalSessionPtyAllocation +} from './terminal-session-authority-mutation' + +export const TERMINAL_AUTHORITY_APP_PROJECTION_VERSION = 1 as const +export const TERMINAL_AUTHORITY_APP_PROJECTION_EVENT = 'pty:authorityProjection' +export const TERMINAL_AUTHORITY_APP_PROJECTION_SUBSCRIBE = 'pty:authorityProjection:subscribe' +export const TERMINAL_AUTHORITY_APP_PROJECTION_CLEAR_BELL = 'pty:authorityProjection:clearBell' +export const TERMINAL_AUTHORITY_APP_PROJECTION_MAX_ROWS = 4_096 + +export type TerminalAuthorityAppEventKey = Readonly<{ + consumerId: string + namespace: TerminalAuthorityNamespace + sequence: number + outcomeId: string +}> + +export type TerminalAuthorityAppFactProjection = Readonly<{ + event: TerminalAuthorityAppEventKey + binding: TerminalSessionBinding + fact: TerminalSessionAuthoritySemanticFact + appliedAt: number + verifyAfter?: number + verification?: 'pending' | 'verified' | 'failed' +}> + +export type TerminalAuthorityAppTopologyProjection = Readonly<{ + event?: TerminalAuthorityAppEventKey + status: TerminalPaneAuthorityStatus + binding: TerminalSessionBinding | null + lastBinding: TerminalSessionBinding | null + authorityRevision: number + ownerStatus: 'reachable' | 'owner-unreachable' | null +}> + +export type TerminalAuthorityAppExitProjection = Readonly<{ + event: TerminalAuthorityAppEventKey + binding: TerminalSessionBinding + code: number | null + signal: string | null +}> + +export type TerminalAuthorityAppLayoutProjection = Readonly<{ + tabId: string + leafId: string +}> | null + +export type TerminalAuthorityAppAgentProjection = Readonly<{ + event: TerminalAuthorityAppEventKey + binding: TerminalSessionBinding + state: AgentStatusState | 'idle' | 'exited' + prompt: string + transitionedAt: number +}> + +export type TerminalAuthorityAppCommandCodeProjection = Readonly<{ + event: TerminalAuthorityAppEventKey + binding: TerminalSessionBinding + state: 'working' | 'settling' | 'done' + prompt: string + transitionedAt: number + settleAt: number | null +}> + +export type TerminalAuthorityAppAttentionProjection = Readonly<{ + event: TerminalAuthorityAppEventKey | null + pendingBellCount: number + updatedAt: number +}> + +export type TerminalAuthorityAppStatusProjection = Readonly<{ + event: TerminalAuthorityAppEventKey | null + pane: TerminalPaneAuthorityStatus + agent: TerminalAuthorityAppAgentProjection['state'] | null + attention: boolean + updatedAt: number +}> + +export type TerminalAuthorityAppPaneProjection = Readonly<{ + version: typeof TERMINAL_AUTHORITY_APP_PROJECTION_VERSION + consumerId: string + namespace: TerminalAuthorityNamespace + pane: TerminalPaneGeneration + layout: TerminalAuthorityAppLayoutProjection + binding: TerminalSessionBinding | null + latestEvent?: TerminalAuthorityAppEventKey + topology: TerminalAuthorityAppTopologyProjection + allocation?: TerminalSessionPtyAllocation + exit?: TerminalAuthorityAppExitProjection + agent?: TerminalAuthorityAppAgentProjection + commandCode?: TerminalAuthorityAppCommandCodeProjection + attention: TerminalAuthorityAppAttentionProjection + status: TerminalAuthorityAppStatusProjection + facts: Readonly< + Partial< + Record + > + > +}> + +export type TerminalAuthorityAppProjectionRowIdentity = Readonly<{ + consumerId: string + namespace: TerminalAuthorityNamespace + pane: TerminalPaneGeneration +}> + +export type TerminalAuthorityAppProjectionChange = Readonly<{ + rows: readonly TerminalAuthorityAppPaneProjection[] + deleted: readonly TerminalAuthorityAppProjectionRowIdentity[] +}> + +export type TerminalAuthorityAppProjectionSubscribe = Readonly<{ + version: typeof TERMINAL_AUTHORITY_APP_PROJECTION_VERSION + subscriptionIncarnationId: string + expectedSubscriptionIncarnationId?: string | null +}> + +export type TerminalAuthorityAppProjectionSnapshot = Readonly<{ + version: typeof TERMINAL_AUTHORITY_APP_PROJECTION_VERSION + subscriptionIncarnationId: string + rows: readonly TerminalAuthorityAppPaneProjection[] +}> + +export type TerminalAuthorityAppProjectionDelta = Readonly<{ + version: typeof TERMINAL_AUTHORITY_APP_PROJECTION_VERSION + subscriptionIncarnationId: string + rows: readonly TerminalAuthorityAppPaneProjection[] + deleted?: readonly TerminalAuthorityAppProjectionRowIdentity[] +}> + +export type TerminalAuthorityAppBellClearRequest = Readonly<{ + version: typeof TERMINAL_AUTHORITY_APP_PROJECTION_VERSION + consumerId: string + namespace: TerminalAuthorityNamespace + pane: TerminalPaneGeneration + expectedEvent: TerminalAuthorityAppEventKey +}> + +export function terminalAuthorityAppProjectionRowKey( + value: Pick +): string { + return JSON.stringify([ + value.consumerId, + value.namespace.authorityHostId, + value.namespace.namespaceId, + value.pane.paneKey, + value.pane.paneGenerationId + ]) +} diff --git a/src/shared/terminal-authority-consumer-methods.ts b/src/shared/terminal-authority-consumer-methods.ts new file mode 100644 index 00000000000..cbad13cfc90 --- /dev/null +++ b/src/shared/terminal-authority-consumer-methods.ts @@ -0,0 +1,12 @@ +export const SSH_TERMINAL_AUTHORITY_CONSUMER_CHALLENGE_METHOD = + 'terminalAuthority.beginConsumerAdmission' +export const SSH_TERMINAL_AUTHORITY_CONSUMER_GRANT_METHOD = + 'terminalAuthority.completeConsumerAdmission' +export const SSH_TERMINAL_AUTHORITY_CONSUMER_CANCEL_METHOD = + 'terminalAuthority.cancelConsumerAdmission' +export const SSH_TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_CHALLENGE_METHOD = + 'terminalAuthority.beginConsumerRetirement' +export const SSH_TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_METHOD = + 'terminalAuthority.completeConsumerRetirement' +export const SSH_TERMINAL_AUTHORITY_CONSUMER_RESOLVE_NAMESPACE_METHOD = + 'terminalAuthority.resolveConsumerNamespace' diff --git a/src/shared/terminal-authority-durable-outcome-validation.ts b/src/shared/terminal-authority-durable-outcome-validation.ts new file mode 100644 index 00000000000..719f8789bf0 --- /dev/null +++ b/src/shared/terminal-authority-durable-outcome-validation.ts @@ -0,0 +1,41 @@ +import { + assertAuthorityNamespace, + isRecord, + type TerminalAuthorityNamespace +} from './terminal-session-authority-identity' +import type { TerminalAuthorityDurableOutcome } from './terminal-session-authority-mutation' +import { validateRestoredOutcome } from './terminal-session-authority-restore-validation' + +export function parseTerminalAuthorityDurableOutcome( + value: unknown +): TerminalAuthorityDurableOutcome | null { + if (!isRecord(value) || (value.kind !== undefined && value.kind !== 'semantic')) { + return null + } + try { + const namespace = outcomeNamespaceFromUnknown(value) + const revision = + value.kind === 'semantic' + ? Number(value.appendedAtRevision) + : Number((value.result as Record).revision) + validateRestoredOutcome(value as TerminalAuthorityDurableOutcome, namespace, revision) + return Object.freeze(structuredClone(value)) as TerminalAuthorityDurableOutcome + } catch { + return null + } +} + +export function terminalAuthorityDurableOutcomeNamespace( + outcome: TerminalAuthorityDurableOutcome +): TerminalAuthorityNamespace { + return outcome.kind === 'semantic' ? outcome.access.namespace : outcome.result.namespace +} + +function outcomeNamespaceFromUnknown(value: Record): TerminalAuthorityNamespace { + const container = value.kind === 'semantic' ? value.access : value.result + if (!isRecord(container)) { + throw new Error('authority outcome namespace is invalid') + } + assertAuthorityNamespace(container.namespace) + return container.namespace +} diff --git a/src/shared/terminal-authority-exact-operation-protocol.test.ts b/src/shared/terminal-authority-exact-operation-protocol.test.ts new file mode 100644 index 00000000000..512e0629e9b --- /dev/null +++ b/src/shared/terminal-authority-exact-operation-protocol.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' +import { + parsePtyAuthorityExactOperationRequest, + PTY_CLEAR_BUFFER_AUTHORITY_EXACT_METHOD, + PTY_DATA_AUTHORITY_EXACT_METHOD, + PTY_RESIZE_AUTHORITY_EXACT_METHOD, + PTY_SEND_SIGNAL_AUTHORITY_EXACT_METHOD, + PTY_SHUTDOWN_AUTHORITY_EXACT_METHOD +} from './terminal-authority-exact-operation-protocol' + +const access = { + namespace: { authorityHostId: 'host-a', namespaceId: 'namespace-a' }, + pane: { paneKey: 'pane-a', paneGenerationId: 'renderer:3' }, + binding: { + ownerIncarnationId: 'owner-a', + physicalPtyId: 'pty-1', + ptyIncarnationId: 'incarnation-a' + } +} + +describe('terminal authority exact-operation protocol', () => { + it.each([ + [PTY_DATA_AUTHORITY_EXACT_METHOD, { data: 'input' }, { kind: 'data', data: 'input' }], + [ + PTY_RESIZE_AUTHORITY_EXACT_METHOD, + { cols: 120, rows: 40 }, + { kind: 'resize', cols: 120, rows: 40 } + ], + [ + PTY_SEND_SIGNAL_AUTHORITY_EXACT_METHOD, + { signal: 'SIGTERM' }, + { kind: 'signal', signal: 'SIGTERM' } + ], + [PTY_CLEAR_BUFFER_AUTHORITY_EXACT_METHOD, {}, { kind: 'clear' }], + [ + PTY_SHUTDOWN_AUTHORITY_EXACT_METHOD, + { immediate: true, keepHistory: false }, + { kind: 'shutdown', immediate: true, keepHistory: false } + ] + ] as const)('parses %s with one complete binding', (method, params, mutation) => { + expect( + parsePtyAuthorityExactOperationRequest(method, { + id: 'pty-1', + terminalSessionAuthorityAccess: access, + ...params + }) + ).toEqual({ + id: 'pty-1', + terminalSessionAuthorityAccess: access, + mutation + }) + }) + + it.each([ + { id: 'pty-other' }, + { terminalSessionAuthorityAccess: null }, + { immediate: 1 }, + { keepHistory: undefined } + ])('rejects a partial or mismatched shutdown request: $id', (override) => { + expect(() => + parsePtyAuthorityExactOperationRequest(PTY_SHUTDOWN_AUTHORITY_EXACT_METHOD, { + id: 'pty-1', + terminalSessionAuthorityAccess: access, + immediate: true, + keepHistory: false, + ...override + }) + ).toThrow() + }) +}) diff --git a/src/shared/terminal-authority-exact-operation-protocol.ts b/src/shared/terminal-authority-exact-operation-protocol.ts new file mode 100644 index 00000000000..9d0cf9b28c4 --- /dev/null +++ b/src/shared/terminal-authority-exact-operation-protocol.ts @@ -0,0 +1,93 @@ +import { assertAuthorityId, isRecord } from './terminal-session-authority-identity' +import { + parseTerminalSessionAuthorityPtyAccess, + type TerminalSessionAuthorityPtyAccess +} from './terminal-session-authority-pty-access' + +export const TERMINAL_AUTHORITY_EXACT_OPERATIONS_VERSION = 1 + +export const PTY_DATA_AUTHORITY_EXACT_METHOD = 'pty.dataAuthorityExact' +export const PTY_RESIZE_AUTHORITY_EXACT_METHOD = 'pty.resizeAuthorityExact' +export const PTY_SEND_SIGNAL_AUTHORITY_EXACT_METHOD = 'pty.sendSignalAuthorityExact' +export const PTY_CLEAR_BUFFER_AUTHORITY_EXACT_METHOD = 'pty.clearBufferAuthorityExact' +export const PTY_SHUTDOWN_AUTHORITY_EXACT_METHOD = 'pty.shutdownAuthorityExact' + +export type PtyAuthorityExactOperationMethod = + | typeof PTY_DATA_AUTHORITY_EXACT_METHOD + | typeof PTY_RESIZE_AUTHORITY_EXACT_METHOD + | typeof PTY_SEND_SIGNAL_AUTHORITY_EXACT_METHOD + | typeof PTY_CLEAR_BUFFER_AUTHORITY_EXACT_METHOD + | typeof PTY_SHUTDOWN_AUTHORITY_EXACT_METHOD + +export type PtyAuthorityExactMutation = + | Readonly<{ kind: 'data'; data: string }> + | Readonly<{ kind: 'resize'; cols: number; rows: number }> + | Readonly<{ kind: 'signal'; signal: string }> + | Readonly<{ kind: 'clear' }> + | Readonly<{ kind: 'shutdown'; immediate: boolean; keepHistory: boolean }> + +export type PtyAuthorityExactOperationRequest = Readonly<{ + id: string + terminalSessionAuthorityAccess: TerminalSessionAuthorityPtyAccess + mutation: PtyAuthorityExactMutation +}> + +export function parsePtyAuthorityExactOperationRequest( + method: PtyAuthorityExactOperationMethod, + value: Record +): PtyAuthorityExactOperationRequest { + assertAuthorityId(value.id, 'authority exact PTY id') + const access = parseTerminalSessionAuthorityPtyAccess(value.terminalSessionAuthorityAccess) + if ( + !access || + !isRecord(value.terminalSessionAuthorityAccess) || + access.binding.physicalPtyId !== value.id + ) { + throw new Error('authority exact PTY access is invalid') + } + return Object.freeze({ + id: value.id, + terminalSessionAuthorityAccess: access, + mutation: parseMutation(method, value) + }) +} + +function parseMutation( + method: PtyAuthorityExactOperationMethod, + value: Record +): PtyAuthorityExactMutation { + if (method === PTY_DATA_AUTHORITY_EXACT_METHOD) { + if (typeof value.data !== 'string') { + throw new Error('authority exact PTY data is invalid') + } + return Object.freeze({ kind: 'data', data: value.data }) + } + if (method === PTY_RESIZE_AUTHORITY_EXACT_METHOD) { + if ( + typeof value.cols !== 'number' || + !Number.isFinite(value.cols) || + typeof value.rows !== 'number' || + !Number.isFinite(value.rows) + ) { + throw new Error('authority exact PTY size is invalid') + } + return Object.freeze({ kind: 'resize', cols: value.cols, rows: value.rows }) + } + if (method === PTY_SEND_SIGNAL_AUTHORITY_EXACT_METHOD) { + if (typeof value.signal !== 'string' || value.signal.length === 0) { + throw new Error('authority exact PTY signal is invalid') + } + return Object.freeze({ kind: 'signal', signal: value.signal }) + } + if (method === PTY_CLEAR_BUFFER_AUTHORITY_EXACT_METHOD) { + return Object.freeze({ kind: 'clear' }) + } + if (typeof value.immediate !== 'boolean' || typeof value.keepHistory !== 'boolean') { + throw new Error('authority exact PTY shutdown request is invalid') + } + return Object.freeze({ + kind: 'shutdown', + immediate: value.immediate, + keepHistory: value.keepHistory + }) +} diff --git a/src/shared/terminal-authority-outcome-delivery.ts b/src/shared/terminal-authority-outcome-delivery.ts new file mode 100644 index 00000000000..8b6f072ef5f --- /dev/null +++ b/src/shared/terminal-authority-outcome-delivery.ts @@ -0,0 +1,63 @@ +import { + assertAuthorityId, + assertAuthorityNamespace, + assertPaneGeneration, + assertTerminalBinding, + isRecord, + type TerminalAuthorityNamespace, + type TerminalPaneGeneration, + type TerminalSessionBinding +} from './terminal-session-authority-identity' + +export const TERMINAL_AUTHORITY_OUTCOME_DELIVERY_VERSION = 1 +export const TERMINAL_AUTHORITY_OUTCOME_ACK_NOTIFICATION = 'pty.ackAuthorityOutcome' + +export type TerminalAuthorityOutcomeDeliveryIdentity = Readonly<{ + version: typeof TERMINAL_AUTHORITY_OUTCOME_DELIVERY_VERSION + namespace: TerminalAuthorityNamespace + pane: TerminalPaneGeneration + binding: TerminalSessionBinding + outcomeId: string + sequence: number +}> + +export function parseTerminalAuthorityOutcomeDeliveryIdentity( + value: unknown +): TerminalAuthorityOutcomeDeliveryIdentity | null { + if (!isRecord(value)) { + return null + } + try { + if (value.version !== TERMINAL_AUTHORITY_OUTCOME_DELIVERY_VERSION) { + return null + } + assertAuthorityNamespace(value.namespace) + assertPaneGeneration(value.pane) + assertTerminalBinding(value.binding) + assertAuthorityId(value.outcomeId, 'outcomeId') + if (!Number.isSafeInteger(value.sequence) || Number(value.sequence) < 1) { + return null + } + } catch { + return null + } + return Object.freeze({ + version: TERMINAL_AUTHORITY_OUTCOME_DELIVERY_VERSION, + namespace: Object.freeze({ ...value.namespace }), + pane: Object.freeze({ ...value.pane }), + binding: Object.freeze({ ...value.binding }), + outcomeId: value.outcomeId, + sequence: Number(value.sequence) + }) +} + +export function terminalAuthorityOutcomeDeliveryKey( + identity: TerminalAuthorityOutcomeDeliveryIdentity +): string { + return JSON.stringify([ + identity.namespace.authorityHostId, + identity.namespace.namespaceId, + identity.outcomeId, + identity.sequence + ]) +} diff --git a/src/shared/terminal-authority-routing.test.ts b/src/shared/terminal-authority-routing.test.ts new file mode 100644 index 00000000000..8cfa175b741 --- /dev/null +++ b/src/shared/terminal-authority-routing.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { + isTerminalAuthorityEventMethod, + TERMINAL_AUTHORITY_NOTIFICATION_METHODS, + TERMINAL_AUTHORITY_REQUEST_METHODS +} from './terminal-authority-routing' + +describe('terminal authority routing', () => { + it('routes PTY ownership and terminal hook requests to the authority', () => { + expect(TERMINAL_AUTHORITY_REQUEST_METHODS).toContain('pty.spawn') + expect(TERMINAL_AUTHORITY_REQUEST_METHODS).toContain('pty.shutdownExact') + expect(TERMINAL_AUTHORITY_REQUEST_METHODS).toContain('pty.shutdownAuthorityExact') + expect(TERMINAL_AUTHORITY_REQUEST_METHODS).toContain('pty.sendSignalExact') + expect(TERMINAL_AUTHORITY_REQUEST_METHODS).toContain('pty.sendSignalAuthorityExact') + expect(TERMINAL_AUTHORITY_REQUEST_METHODS).toContain('pty.clearBufferExact') + expect(TERMINAL_AUTHORITY_REQUEST_METHODS).toContain('pty.clearBufferAuthorityExact') + expect(TERMINAL_AUTHORITY_REQUEST_METHODS).toContain('agent_hook.installPlugins') + expect(TERMINAL_AUTHORITY_REQUEST_METHODS).toContain( + 'terminalAuthority.resolveConsumerNamespace' + ) + expect(TERMINAL_AUTHORITY_REQUEST_METHODS).toContain( + 'terminalAuthority.acceptNamespaceOutcomeBoundary' + ) + expect(TERMINAL_AUTHORITY_REQUEST_METHODS).toContain('terminalAuthority.ackNamespaceOutcome') + expect(TERMINAL_AUTHORITY_REQUEST_METHODS).not.toContain('git.status') + expect(TERMINAL_AUTHORITY_REQUEST_METHODS).not.toContain('orca.cli') + }) + + it('routes exact PTY mutations but not control-plane notifications', () => { + expect(TERMINAL_AUTHORITY_NOTIFICATION_METHODS).toContain('pty.dataExact') + expect(TERMINAL_AUTHORITY_NOTIFICATION_METHODS).toContain('pty.dataAuthorityExact') + expect(TERMINAL_AUTHORITY_NOTIFICATION_METHODS).toContain('pty.resizeExact') + expect(TERMINAL_AUTHORITY_NOTIFICATION_METHODS).toContain('pty.resizeAuthorityExact') + expect(TERMINAL_AUTHORITY_NOTIFICATION_METHODS).not.toContain('fs.unwatch') + expect(TERMINAL_AUTHORITY_NOTIFICATION_METHODS).not.toContain('relay.configureGraceTime') + }) + + it('admits only authority-originated terminal events', () => { + expect(isTerminalAuthorityEventMethod('pty.data')).toBe(true) + expect(isTerminalAuthorityEventMethod('pty.recoveryComplete')).toBe(true) + expect(isTerminalAuthorityEventMethod('agent.hook')).toBe(true) + expect(isTerminalAuthorityEventMethod('fs.changed')).toBe(false) + }) +}) diff --git a/src/shared/terminal-authority-routing.ts b/src/shared/terminal-authority-routing.ts new file mode 100644 index 00000000000..93c7de69ce0 --- /dev/null +++ b/src/shared/terminal-authority-routing.ts @@ -0,0 +1,93 @@ +import { + SSH_TERMINAL_AUTHORITY_CONSUMER_CANCEL_METHOD, + SSH_TERMINAL_AUTHORITY_CONSUMER_CHALLENGE_METHOD, + SSH_TERMINAL_AUTHORITY_CONSUMER_GRANT_METHOD, + SSH_TERMINAL_AUTHORITY_CONSUMER_RESOLVE_NAMESPACE_METHOD, + SSH_TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_CHALLENGE_METHOD, + SSH_TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_METHOD +} from './terminal-authority-consumer-methods' +import { + TERMINAL_AUTHORITY_NAMESPACE_BOUNDARY_ACCEPT_METHOD, + TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_ACK_METHOD +} from './terminal-session-authority-consumer-transport' + +export const TERMINAL_AUTHORITY_REQUEST_METHODS = Object.freeze([ + 'pty.spawn', + 'pty.attach', + 'pty.shutdown', + 'pty.shutdownExact', + 'pty.shutdownAuthorityExact', + 'pty.sendSignal', + 'pty.sendSignalExact', + 'pty.sendSignalAuthorityExact', + 'pty.getCwd', + 'pty.getInitialCwd', + 'pty.getSize', + 'pty.clearBuffer', + 'pty.clearBufferExact', + 'pty.clearBufferAuthorityExact', + 'pty.hasChildProcesses', + 'pty.getForegroundProcess', + 'pty.inspectProcess', + 'pty.getCapabilities', + 'pty.listProcesses', + 'pty.getDefaultShell', + 'pty.serialize', + 'pty.revive', + 'pty.getProfiles', + 'pty.closeStartupQueryAuthority', + 'pty.openClient', + 'pty.setDeliveryPaused', + SSH_TERMINAL_AUTHORITY_CONSUMER_CHALLENGE_METHOD, + SSH_TERMINAL_AUTHORITY_CONSUMER_GRANT_METHOD, + SSH_TERMINAL_AUTHORITY_CONSUMER_CANCEL_METHOD, + SSH_TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_CHALLENGE_METHOD, + SSH_TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_METHOD, + SSH_TERMINAL_AUTHORITY_CONSUMER_RESOLVE_NAMESPACE_METHOD, + TERMINAL_AUTHORITY_NAMESPACE_BOUNDARY_ACCEPT_METHOD, + TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_ACK_METHOD, + // Authenticated pre-open legacy worker migration controls. + 'terminalAuthority.legacyPhysicalWorker.inspect', + 'terminalAuthority.legacyPhysicalWorker.migrate', + 'terminalAuthority.legacyPhysicalWorker.gcProtection', + 'terminalAuthority.legacyPhysicalWorker.migrationBarrier', + 'terminalAuthority.legacyPhysicalWorker.gc', + 'pty.cancelDelivery', + 'agent_hook.installPlugins', + 'agent_hook.installManagedHooks', + 'agent_hook.requestReplay' +] as const) + +export const TERMINAL_AUTHORITY_NOTIFICATION_METHODS = Object.freeze([ + 'pty.data', + 'pty.dataExact', + 'pty.dataAuthorityExact', + 'pty.resize', + 'pty.resizeExact', + 'pty.resizeAuthorityExact', + 'pty.setDeliveryPaused', + 'pty.ackData' +] as const) + +export const TERMINAL_AUTHORITY_EVENT_METHODS = Object.freeze([ + 'pty.data', + 'pty.exit', + 'pty.replay', + 'pty.restoreRequired', + 'pty.deliveryCanceled', + 'pty.recoveryComplete', + 'agent.hook' +] as const) + +export type TerminalAuthorityRequestMethod = (typeof TERMINAL_AUTHORITY_REQUEST_METHODS)[number] +export type TerminalAuthorityNotificationMethod = + (typeof TERMINAL_AUTHORITY_NOTIFICATION_METHODS)[number] +export type TerminalAuthorityEventMethod = (typeof TERMINAL_AUTHORITY_EVENT_METHODS)[number] + +const eventMethods = new Set(TERMINAL_AUTHORITY_EVENT_METHODS) + +export function isTerminalAuthorityEventMethod( + method: string +): method is TerminalAuthorityEventMethod { + return eventMethods.has(method) +} diff --git a/src/shared/terminal-authority-topology-record-validation.ts b/src/shared/terminal-authority-topology-record-validation.ts new file mode 100644 index 00000000000..2fe32875a84 --- /dev/null +++ b/src/shared/terminal-authority-topology-record-validation.ts @@ -0,0 +1,190 @@ +import type { + TerminalLegacyRecoveryNotice, + TerminalLegacyRecoveryNoticeProjection, + TerminalLegacyRecoveryReason +} from './terminal-legacy-cutover' +import { + assertAuthorityId, + assertPaneGeneration, + assertTerminalBinding, + isRecord, + terminalPaneGenerationKey, + terminalPtyIncarnationKey, + type TerminalPaneGeneration, + type TerminalSessionBinding +} from './terminal-session-authority-identity' +import type { TerminalPaneAuthorityProjection } from './terminal-session-authority-mutation' +import { TERMINAL_AUTHORITY_TOPOLOGY_MAX_RECOVERY_NOTICES } from './terminal-authority-topology-stream-contract' +import { failTerminalAuthorityTopologyStreamValidation as reject } from './terminal-authority-topology-stream-errors' + +const PANE_STATUSES = new Set(['open', 'closed', 'superseded', 'exited']) +const OWNER_STATUSES = new Set(['reachable', 'owner-unreachable']) +const RECOVERY_REASONS = new Set([ + 'ambiguous-pane-generation', + 'endpoint-identity-unproved', + 'physical-pty-incarnation-unproved', + 'unsupported-platform', + 'worker-unreachable', + 'workspace-mismatch' +]) +const PRESERVATION_KINDS = new Set([ + 'isolated-grace-disabled', + 'evidence-gc-retained', + 'worker-unreachable', + 'unsupported-platform' +]) +const WORKSPACE_KINDS = new Set(['git-worktree', 'folder', 'floating']) + +function safeInteger(value: unknown, field: string, minimum = 0): number { + if (!Number.isSafeInteger(value) || Number(value) < minimum) { + reject(`${field} is invalid`) + } + return Number(value) +} + +export function parseTerminalAuthorityTopologyPaneGeneration( + value: unknown +): TerminalPaneGeneration { + assertPaneGeneration(value) + return Object.freeze({ paneKey: value.paneKey, paneGenerationId: value.paneGenerationId }) +} + +function binding(value: unknown): TerminalSessionBinding { + assertTerminalBinding(value) + return Object.freeze({ + ownerIncarnationId: value.ownerIncarnationId, + physicalPtyId: value.physicalPtyId, + ptyIncarnationId: value.ptyIncarnationId + }) +} + +function optionalBinding(value: unknown): TerminalSessionBinding | null { + return value === null ? null : binding(value) +} + +export function parseTerminalAuthorityTopologyPane( + value: unknown +): TerminalPaneAuthorityProjection { + if (!isRecord(value)) { + reject('topology pane is invalid') + } + assertAuthorityId(value.paneKey, 'paneKey') + assertAuthorityId(value.paneGenerationId, 'paneGenerationId') + if (!PANE_STATUSES.has(String(value.status))) { + reject('topology pane status is invalid') + } + const liveBinding = optionalBinding(value.binding) + const lastBinding = optionalBinding(value.lastBinding) + const revision = safeInteger(value.revision, 'topology pane revision', 1) + if (value.status !== 'open' && liveBinding !== null) { + reject('inactive topology pane retains a binding') + } + if ( + (liveBinding === null && value.ownerStatus !== null) || + (liveBinding !== null && !OWNER_STATUSES.has(String(value.ownerStatus))) + ) { + reject('topology pane owner status is invalid') + } + return Object.freeze({ + paneKey: value.paneKey, + paneGenerationId: value.paneGenerationId, + status: value.status as TerminalPaneAuthorityProjection['status'], + binding: liveBinding, + lastBinding, + revision, + ownerStatus: value.ownerStatus as TerminalPaneAuthorityProjection['ownerStatus'] + }) +} + +function recoveryNotice(value: unknown): TerminalLegacyRecoveryNotice { + if (!isRecord(value)) { + reject('legacy recovery notice is invalid') + } + assertAuthorityId(value.recoveryKey, 'recoveryKey') + assertAuthorityId(value.evidenceDigest, 'evidenceDigest') + if (!WORKSPACE_KINDS.has(String(value.workspaceKind))) { + reject('legacy recovery workspace kind is invalid') + } + const discoveredAtMs = safeInteger(value.discoveredAtMs, 'recovery discoveredAtMs') + const base = { + recoveryKey: value.recoveryKey, + workspaceKind: value.workspaceKind as TerminalLegacyRecoveryNotice['workspaceKind'], + evidenceDigest: value.evidenceDigest, + observedAtMs: safeInteger(value.observedAtMs, 'recovery observedAtMs'), + discoveredAtMs, + updatedAtMs: safeInteger(value.updatedAtMs, 'recovery updatedAtMs', discoveredAtMs) + } + if (value.status === 'imported') { + return Object.freeze({ ...base, status: 'imported' }) + } + if ( + (value.status !== 'unresolved' && value.status !== 'acknowledged') || + !RECOVERY_REASONS.has(value.reason as TerminalLegacyRecoveryReason) || + !PRESERVATION_KINDS.has(String(value.preservationKind)) + ) { + reject('legacy recovery notice state is invalid') + } + type NoticeWithPreservation = Extract< + TerminalLegacyRecoveryNotice, + { status: 'unresolved' | 'acknowledged' } + > + return Object.freeze({ + ...base, + status: value.status, + reason: value.reason as TerminalLegacyRecoveryReason, + preservationKind: value.preservationKind as NoticeWithPreservation['preservationKind'] + }) +} + +export function parseTerminalAuthorityRecoveryNoticeProjection( + value: unknown +): TerminalLegacyRecoveryNoticeProjection { + if (!isRecord(value) || value.version !== 1 || !Array.isArray(value.notices)) { + reject('legacy recovery notice projection is invalid') + } + if (value.notices.length > TERMINAL_AUTHORITY_TOPOLOGY_MAX_RECOVERY_NOTICES) { + reject('legacy recovery notice capacity exceeded') + } + const notices = value.notices.map(recoveryNotice) + const keys = new Set() + for (const notice of notices) { + if (keys.has(notice.recoveryKey)) { + reject('legacy recovery notice is duplicated') + } + keys.add(notice.recoveryKey) + } + return Object.freeze({ + version: 1, + revision: safeInteger(value.revision, 'legacy recovery notice revision'), + notices: Object.freeze(notices) + }) +} + +export function assertTerminalAuthorityTopologyPanes( + panes: readonly TerminalPaneAuthorityProjection[], + authorityRevision: number +): void { + const paneGenerations = new Set() + const openPaneKeys = new Set() + const liveBindings = new Set() + for (const record of panes) { + const key = terminalPaneGenerationKey(record) + if (record.revision > authorityRevision || paneGenerations.has(key)) { + reject('topology pane collection is inconsistent') + } + if (record.status === 'open' && openPaneKeys.has(record.paneKey)) { + reject('topology contains two open pane generations') + } + if (record.status === 'open') { + openPaneKeys.add(record.paneKey) + } + if (record.binding) { + const bindingKey = terminalPtyIncarnationKey(record.binding) + if (liveBindings.has(bindingKey)) { + reject('topology binds one PTY incarnation twice') + } + liveBindings.add(bindingKey) + } + paneGenerations.add(key) + } +} diff --git a/src/shared/terminal-authority-topology-stream-contract.ts b/src/shared/terminal-authority-topology-stream-contract.ts new file mode 100644 index 00000000000..8b527b00d10 --- /dev/null +++ b/src/shared/terminal-authority-topology-stream-contract.ts @@ -0,0 +1,116 @@ +import type { TerminalLegacyRecoveryNoticeProjection } from './terminal-legacy-cutover' +import type { + TerminalAuthorityNamespace, + TerminalPaneGeneration +} from './terminal-session-authority-identity' +import type { TerminalPaneAuthorityProjection } from './terminal-session-authority-mutation' + +export const TERMINAL_AUTHORITY_TOPOLOGY_STREAM_VERSION = 1 +export const TERMINAL_AUTHORITY_TOPOLOGY_STREAM_CAPABILITY = + 'terminal-session.authority-topology-stream.v1' +export const TERMINAL_AUTHORITY_TOPOLOGY_PTY_CAPABILITY = 'terminalAuthorityTopology' +export const TERMINAL_AUTHORITY_TOPOLOGY_SNAPSHOT_METHOD = 'terminalAuthority.topologySnapshot' +export const TERMINAL_AUTHORITY_TOPOLOGY_CHANGED_NOTIFICATION = 'terminalAuthority.topologyChanged' +export const TERMINAL_AUTHORITY_TOPOLOGY_UNSUBSCRIBE_NOTIFICATION = + 'terminalAuthority.topologyUnsubscribe' + +export const TERMINAL_AUTHORITY_TOPOLOGY_MAX_SNAPSHOT_BYTES = 8 * 1024 * 1024 +export const TERMINAL_AUTHORITY_TOPOLOGY_MAX_CHANGE_BYTES = 512 * 1024 +export const TERMINAL_AUTHORITY_TOPOLOGY_MAX_REQUEST_BYTES = 16 * 1024 +export const TERMINAL_AUTHORITY_TOPOLOGY_MAX_PANES = 16_384 +export const TERMINAL_AUTHORITY_TOPOLOGY_MAX_CHANGE_OPERATIONS = 1_024 +export const TERMINAL_AUTHORITY_TOPOLOGY_MAX_RECOVERY_NOTICES = 4_096 +export const TERMINAL_AUTHORITY_TOPOLOGY_MAX_BUFFERED_CHANGES = 256 +export const TERMINAL_AUTHORITY_TOPOLOGY_MAX_BUFFERED_BYTES = 2 * 1024 * 1024 +export const TERMINAL_AUTHORITY_TOPOLOGY_MAX_SUBSCRIPTIONS_PER_CONNECTION = 256 + +export type TerminalAuthorityTopologyCapabilityGrant = Readonly<{ version: 1 }> +export type TerminalAuthorityTopologyCapabilityOffer = Readonly<{ versions: readonly number[] }> + +export type TerminalAuthorityTopologySnapshotRequest = Readonly<{ + protocolVersion: 1 + subscriptionId: string + namespace: TerminalAuthorityNamespace +}> + +export type TerminalAuthorityTopologyUnsubscribe = TerminalAuthorityTopologySnapshotRequest + +export type TerminalAuthorityTopologyPaneChange = + | Readonly<{ kind: 'upsert'; pane: TerminalPaneAuthorityProjection }> + | Readonly<{ kind: 'remove'; pane: TerminalPaneGeneration }> + +export type TerminalAuthorityTopologySnapshot = Readonly<{ + protocolVersion: 1 + subscriptionId: string + streamIncarnationId: string + namespace: TerminalAuthorityNamespace + writerEpoch: number + authorityRevision: number + appliedChangeSequence: number + panes: readonly TerminalPaneAuthorityProjection[] + namespaceRecoveryNotices: TerminalLegacyRecoveryNoticeProjection +}> + +export type TerminalAuthorityTopologyChange = Readonly<{ + protocolVersion: 1 + subscriptionId: string + streamIncarnationId: string + namespace: TerminalAuthorityNamespace + writerEpoch: number + baseAuthorityRevision: number + authorityRevision: number + changeSequence: number + paneChanges: readonly TerminalAuthorityTopologyPaneChange[] + namespaceRecoveryNotices?: TerminalLegacyRecoveryNoticeProjection +}> + +export function assertTerminalAuthorityTopologyCapabilityGrant( + value: unknown +): asserts value is TerminalAuthorityTopologyCapabilityGrant { + if ( + typeof value !== 'object' || + value === null || + (value as { version?: unknown }).version !== TERMINAL_AUTHORITY_TOPOLOGY_STREAM_VERSION + ) { + throw new Error('terminal_authority_topology_capability_not_granted') + } +} + +export function relayDaemonGrantHasTerminalAuthorityTopology( + capabilities: readonly string[] | undefined +): boolean { + return capabilities?.includes(TERMINAL_AUTHORITY_TOPOLOGY_STREAM_CAPABILITY) === true +} + +export function terminalAuthorityTopologyGrantFromPtyCapabilities( + capabilities: unknown +): TerminalAuthorityTopologyCapabilityGrant | null { + if (typeof capabilities !== 'object' || capabilities === null) { + return null + } + const topology = (capabilities as Record)[ + TERMINAL_AUTHORITY_TOPOLOGY_PTY_CAPABILITY + ] + return typeof topology === 'object' && topology !== null && 'version' in topology + ? topology.version === TERMINAL_AUTHORITY_TOPOLOGY_STREAM_VERSION + ? Object.freeze({ version: TERMINAL_AUTHORITY_TOPOLOGY_STREAM_VERSION }) + : null + : null +} + +export function ptyCapabilitiesOfferTerminalAuthorityTopology(capabilities: unknown): boolean { + if (typeof capabilities !== 'object' || capabilities === null) { + return false + } + const topology = (capabilities as Record)[ + TERMINAL_AUTHORITY_TOPOLOGY_PTY_CAPABILITY + ] + return ( + typeof topology === 'object' && + topology !== null && + Array.isArray((topology as { versions?: unknown }).versions) && + (topology as { versions: unknown[] }).versions.includes( + TERMINAL_AUTHORITY_TOPOLOGY_STREAM_VERSION + ) + ) +} diff --git a/src/shared/terminal-authority-topology-stream-errors.ts b/src/shared/terminal-authority-topology-stream-errors.ts new file mode 100644 index 00000000000..9911439f301 --- /dev/null +++ b/src/shared/terminal-authority-topology-stream-errors.ts @@ -0,0 +1,7 @@ +export class TerminalAuthorityTopologyStreamValidationError extends Error { + readonly name = 'TerminalAuthorityTopologyStreamValidationError' +} + +export function failTerminalAuthorityTopologyStreamValidation(message: string): never { + throw new TerminalAuthorityTopologyStreamValidationError(message) +} diff --git a/src/shared/terminal-authority-topology-stream-validation.ts b/src/shared/terminal-authority-topology-stream-validation.ts new file mode 100644 index 00000000000..36f878871b5 --- /dev/null +++ b/src/shared/terminal-authority-topology-stream-validation.ts @@ -0,0 +1,227 @@ +import { + assertAuthorityId, + assertAuthorityNamespace, + isRecord, + terminalPaneGenerationKey, + type TerminalAuthorityNamespace +} from './terminal-session-authority-identity' +import { + TERMINAL_AUTHORITY_TOPOLOGY_MAX_CHANGE_BYTES, + TERMINAL_AUTHORITY_TOPOLOGY_MAX_CHANGE_OPERATIONS, + TERMINAL_AUTHORITY_TOPOLOGY_MAX_PANES, + TERMINAL_AUTHORITY_TOPOLOGY_MAX_REQUEST_BYTES, + TERMINAL_AUTHORITY_TOPOLOGY_MAX_SNAPSHOT_BYTES, + TERMINAL_AUTHORITY_TOPOLOGY_STREAM_VERSION, + type TerminalAuthorityTopologyChange, + type TerminalAuthorityTopologyPaneChange, + type TerminalAuthorityTopologySnapshot, + type TerminalAuthorityTopologySnapshotRequest +} from './terminal-authority-topology-stream-contract' +import { + TerminalAuthorityTopologyStreamValidationError, + failTerminalAuthorityTopologyStreamValidation as reject +} from './terminal-authority-topology-stream-errors' +import { + assertTerminalAuthorityTopologyPanes, + parseTerminalAuthorityRecoveryNoticeProjection, + parseTerminalAuthorityTopologyPane, + parseTerminalAuthorityTopologyPaneGeneration +} from './terminal-authority-topology-record-validation' + +export { TerminalAuthorityTopologyStreamValidationError } + +export type ParsedTerminalAuthorityTopologyChange = Readonly<{ + value: TerminalAuthorityTopologyChange + byteLength: number +}> + +function safeInteger(value: unknown, field: string, minimum = 0): number { + if (!Number.isSafeInteger(value) || Number(value) < minimum) { + reject(`${field} is invalid`) + } + return Number(value) +} + +function jsonByteLengthWithin(value: unknown, limit: number, field: string): number { + let serialized: string | undefined + try { + serialized = JSON.stringify(value) + } catch { + reject(`${field} is not JSON serializable`) + } + if (serialized === undefined) { + reject(`${field} is not JSON serializable`) + } + const byteLength = new TextEncoder().encode(serialized).byteLength + if (byteLength > limit) { + reject(`${field} exceeds its byte capacity`) + } + return byteLength +} + +function namespace(value: unknown): TerminalAuthorityNamespace { + assertAuthorityNamespace(value) + return Object.freeze({ + authorityHostId: value.authorityHostId, + namespaceId: value.namespaceId + }) +} + +function assertProtocolHeader(value: unknown): asserts value is Record { + if (!isRecord(value) || value.protocolVersion !== TERMINAL_AUTHORITY_TOPOLOGY_STREAM_VERSION) { + reject('terminal authority topology protocol version is invalid') + } +} + +function topologyHeader(value: Record): { + subscriptionId: string + streamIncarnationId: string + namespace: TerminalAuthorityNamespace + writerEpoch: number +} { + assertAuthorityId(value.subscriptionId, 'subscriptionId') + assertAuthorityId(value.streamIncarnationId, 'streamIncarnationId') + return { + subscriptionId: value.subscriptionId, + streamIncarnationId: value.streamIncarnationId, + namespace: namespace(value.namespace), + writerEpoch: safeInteger(value.writerEpoch, 'topology writer epoch', 1) + } +} + +export function parseTerminalAuthorityTopologySnapshotRequest( + value: unknown +): TerminalAuthorityTopologySnapshotRequest { + jsonByteLengthWithin(value, TERMINAL_AUTHORITY_TOPOLOGY_MAX_REQUEST_BYTES, 'topology request') + assertProtocolHeader(value) + assertAuthorityId(value.subscriptionId, 'subscriptionId') + return Object.freeze({ + protocolVersion: 1, + subscriptionId: value.subscriptionId, + namespace: namespace(value.namespace) + }) +} + +export function parseTerminalAuthorityTopologySnapshot( + value: unknown +): TerminalAuthorityTopologySnapshot { + jsonByteLengthWithin(value, TERMINAL_AUTHORITY_TOPOLOGY_MAX_SNAPSHOT_BYTES, 'topology snapshot') + assertProtocolHeader(value) + const header = topologyHeader(value) + if (!Array.isArray(value.panes)) { + reject('terminal authority topology snapshot shape is invalid') + } + if (value.panes.length > TERMINAL_AUTHORITY_TOPOLOGY_MAX_PANES) { + reject('terminal authority topology snapshot capacity exceeded') + } + const authorityRevision = safeInteger(value.authorityRevision, 'topology authority revision') + const panes = value.panes.map(parseTerminalAuthorityTopologyPane) + assertTerminalAuthorityTopologyPanes(panes, authorityRevision) + return Object.freeze({ + protocolVersion: 1, + ...header, + authorityRevision, + appliedChangeSequence: safeInteger( + value.appliedChangeSequence, + 'topology applied change sequence' + ), + panes: Object.freeze(panes), + namespaceRecoveryNotices: parseTerminalAuthorityRecoveryNoticeProjection( + value.namespaceRecoveryNotices + ) + }) +} + +function paneChange(value: unknown): TerminalAuthorityTopologyPaneChange { + if (!isRecord(value)) { + reject('topology pane change is invalid') + } + if (value.kind === 'upsert') { + return Object.freeze({ kind: 'upsert', pane: parseTerminalAuthorityTopologyPane(value.pane) }) + } + if (value.kind === 'remove') { + return Object.freeze({ + kind: 'remove', + pane: parseTerminalAuthorityTopologyPaneGeneration(value.pane) + }) + } + reject('topology pane change kind is invalid') +} + +function assertUniqueChangeKeys(change: TerminalAuthorityTopologyChange): void { + const paneKeys = new Set() + for (const operation of change.paneChanges) { + const key = terminalPaneGenerationKey(operation.pane) + if (paneKeys.has(key)) { + reject('topology pane change is duplicated') + } + paneKeys.add(key) + } +} + +export function parseTerminalAuthorityTopologyChangeWithByteLength( + value: unknown +): ParsedTerminalAuthorityTopologyChange { + const byteLength = jsonByteLengthWithin( + value, + TERMINAL_AUTHORITY_TOPOLOGY_MAX_CHANGE_BYTES, + 'topology change' + ) + assertProtocolHeader(value) + const header = topologyHeader(value) + if (!Array.isArray(value.paneChanges)) { + reject('terminal authority topology change shape is invalid') + } + if (value.paneChanges.length > TERMINAL_AUTHORITY_TOPOLOGY_MAX_CHANGE_OPERATIONS) { + reject('terminal authority topology change capacity exceeded') + } + const baseAuthorityRevision = safeInteger( + value.baseAuthorityRevision, + 'topology base authority revision' + ) + const authorityRevision = safeInteger( + value.authorityRevision, + 'topology authority revision', + baseAuthorityRevision + ) + const paneChanges = value.paneChanges.map(paneChange) + const hasRecoveryNotices = value.namespaceRecoveryNotices !== undefined + if (paneChanges.length === 0 && !hasRecoveryNotices) { + reject('terminal authority topology change is empty') + } + for (const operation of paneChanges) { + if (operation.kind === 'upsert' && operation.pane.revision > authorityRevision) { + reject('topology pane change exceeds authority revision') + } + } + const change: TerminalAuthorityTopologyChange = Object.freeze({ + protocolVersion: 1, + ...header, + baseAuthorityRevision, + authorityRevision, + changeSequence: safeInteger(value.changeSequence, 'topology change sequence', 1), + paneChanges: Object.freeze(paneChanges), + ...(hasRecoveryNotices + ? { + namespaceRecoveryNotices: parseTerminalAuthorityRecoveryNoticeProjection( + value.namespaceRecoveryNotices + ) + } + : {}) + }) + assertUniqueChangeKeys(change) + return Object.freeze({ value: change, byteLength }) +} + +export function parseTerminalAuthorityTopologyChange( + value: unknown +): TerminalAuthorityTopologyChange { + return parseTerminalAuthorityTopologyChangeWithByteLength(value).value +} + +export function sameTerminalAuthorityTopologyNamespace( + left: TerminalAuthorityNamespace, + right: TerminalAuthorityNamespace +): boolean { + return left.authorityHostId === right.authorityHostId && left.namespaceId === right.namespaceId +} diff --git a/src/shared/terminal-authority-topology-stream.test.ts b/src/shared/terminal-authority-topology-stream.test.ts new file mode 100644 index 00000000000..132089ad955 --- /dev/null +++ b/src/shared/terminal-authority-topology-stream.test.ts @@ -0,0 +1,315 @@ +import { describe, expect, it } from 'vitest' +import { + TERMINAL_AUTHORITY_TOPOLOGY_MAX_CHANGE_BYTES, + TERMINAL_AUTHORITY_TOPOLOGY_MAX_CHANGE_OPERATIONS, + TERMINAL_AUTHORITY_TOPOLOGY_MAX_SNAPSHOT_BYTES, + TERMINAL_AUTHORITY_TOPOLOGY_PTY_CAPABILITY, + TERMINAL_AUTHORITY_TOPOLOGY_STREAM_CAPABILITY, + ptyCapabilitiesOfferTerminalAuthorityTopology, + relayDaemonGrantHasTerminalAuthorityTopology, + terminalAuthorityTopologyGrantFromPtyCapabilities +} from './terminal-authority-topology-stream-contract' +import { + TerminalAuthorityTopologyStreamValidationError, + parseTerminalAuthorityTopologyChangeWithByteLength, + parseTerminalAuthorityTopologySnapshot, + parseTerminalAuthorityTopologySnapshotRequest +} from './terminal-authority-topology-stream-validation' + +const namespace = Object.freeze({ authorityHostId: 'host-a', namespaceId: 'namespace-a' }) +const binding = Object.freeze({ + ownerIncarnationId: 'owner-a', + physicalPtyId: 'pty-a', + ptyIncarnationId: 'incarnation-a' +}) + +function pane(overrides: Record = {}): Record { + return { + paneKey: 'pane-a', + paneGenerationId: 'generation-a', + status: 'open', + binding, + lastBinding: binding, + revision: 2, + ownerStatus: 'reachable', + ...overrides + } +} + +function recoveryProjection(overrides: Record = {}): Record { + return { + version: 1, + revision: 3, + notices: [ + { + recoveryKey: 'recovery-a', + status: 'unresolved', + reason: 'worker-unreachable', + preservationKind: 'worker-unreachable', + workspaceKind: 'folder', + evidenceDigest: 'digest-a', + observedAtMs: 10, + discoveredAtMs: 11, + updatedAtMs: 12 + } + ], + ...overrides + } +} + +function snapshot(overrides: Record = {}): Record { + return { + protocolVersion: 1, + subscriptionId: 'subscription-a', + streamIncarnationId: 'stream-a', + namespace, + writerEpoch: 4, + authorityRevision: 2, + appliedChangeSequence: 7, + panes: [pane()], + namespaceRecoveryNotices: recoveryProjection(), + ...overrides + } +} + +function change(overrides: Record = {}): Record { + return { + protocolVersion: 1, + subscriptionId: 'subscription-a', + streamIncarnationId: 'stream-a', + namespace, + writerEpoch: 4, + baseAuthorityRevision: 2, + authorityRevision: 3, + changeSequence: 8, + paneChanges: [ + { + kind: 'upsert', + pane: pane({ revision: 3, ownerStatus: 'owner-unreachable' }) + } + ], + ...overrides + } +} + +describe('terminal authority topology capability', () => { + it('keeps the owner hop and end-to-end PTY grants separate', () => { + expect( + relayDaemonGrantHasTerminalAuthorityTopology([TERMINAL_AUTHORITY_TOPOLOGY_STREAM_CAPABILITY]) + ).toBe(true) + expect(relayDaemonGrantHasTerminalAuthorityTopology(['terminal-session.authority.v1'])).toBe( + false + ) + expect(relayDaemonGrantHasTerminalAuthorityTopology(undefined)).toBe(false) + expect( + terminalAuthorityTopologyGrantFromPtyCapabilities({ + [TERMINAL_AUTHORITY_TOPOLOGY_PTY_CAPABILITY]: { version: 1 } + }) + ).toEqual({ version: 1 }) + expect(terminalAuthorityTopologyGrantFromPtyCapabilities({})).toBeNull() + expect( + ptyCapabilitiesOfferTerminalAuthorityTopology({ + [TERMINAL_AUTHORITY_TOPOLOGY_PTY_CAPABILITY]: { versions: [1, 2] } + }) + ).toBe(true) + expect(ptyCapabilitiesOfferTerminalAuthorityTopology(undefined)).toBe(false) + }) +}) + +describe('terminal authority topology snapshot validation', () => { + it('accepts an exact namespace snapshot and strips unknown recovery evidence', () => { + const raw = snapshot({ + namespaceRecoveryNotices: recoveryProjection({ + notices: [ + { + ...(recoveryProjection().notices as Record[])[0], + credentialFile: '/secret/credential', + paneKey: 'secret-pane', + workerId: 'secret-worker' + } + ] + }) + }) + const parsed = parseTerminalAuthorityTopologySnapshot(raw) + + expect(parsed).toMatchObject({ + namespace, + streamIncarnationId: 'stream-a', + authorityRevision: 2, + appliedChangeSequence: 7 + }) + expect(parsed.namespaceRecoveryNotices.notices[0]).toEqual({ + recoveryKey: 'recovery-a', + status: 'unresolved', + reason: 'worker-unreachable', + preservationKind: 'worker-unreachable', + workspaceKind: 'folder', + evidenceDigest: 'digest-a', + observedAtMs: 10, + discoveredAtMs: 11, + updatedAtMs: 12 + }) + expect(Object.isFrozen(parsed)).toBe(true) + expect(Object.isFrozen(parsed.panes)).toBe(true) + expect(Object.isFrozen(parsed.namespaceRecoveryNotices.notices[0])).toBe(true) + }) + + it('retains an open unbound pane without exposing spawn evidence', () => { + const parsed = parseTerminalAuthorityTopologySnapshot( + snapshot({ + panes: [pane({ binding: null, lastBinding: null, ownerStatus: null })], + allocations: [{ spawnFingerprint: 'must-not-cross-the-wire' }] + }) + ) + + expect(parsed.panes[0]).toMatchObject({ status: 'open', binding: null, ownerStatus: null }) + expect(parsed).not.toHaveProperty('allocations') + }) + + it('rejects duplicate bindings and two open generations for one pane', () => { + expect(() => + parseTerminalAuthorityTopologySnapshot( + snapshot({ + panes: [pane(), pane({ paneKey: 'pane-b', paneGenerationId: 'generation-b' })] + }) + ) + ).toThrow('binds one PTY incarnation twice') + expect(() => + parseTerminalAuthorityTopologySnapshot( + snapshot({ + panes: [ + pane({ binding: null, ownerStatus: null }), + pane({ + paneGenerationId: 'generation-b', + binding: null, + lastBinding: null, + ownerStatus: null + }) + ] + }) + ) + ).toThrow('two open pane generations') + }) + + it('rejects owner status and binding combinations that cannot be authoritative', () => { + expect(() => + parseTerminalAuthorityTopologySnapshot( + snapshot({ panes: [pane({ binding: null, ownerStatus: 'reachable' })] }) + ) + ).toThrow('owner status') + expect(() => + parseTerminalAuthorityTopologySnapshot( + snapshot({ panes: [pane({ binding, ownerStatus: null })] }) + ) + ).toThrow('owner status') + expect(() => + parseTerminalAuthorityTopologySnapshot( + snapshot({ panes: [pane({ status: 'closed', ownerStatus: 'reachable' })] }) + ) + ).toThrow('inactive topology pane retains a binding') + }) + + it('rejects the secret-rich catalog row shape instead of forwarding it', () => { + expect(() => + parseTerminalAuthorityTopologySnapshot( + snapshot({ + namespaceRecoveryNotices: { + version: 1, + revision: 1, + notices: [{ recoveryId: 'raw-id', credentialFile: '/secret' }] + } + }) + ) + ).toThrow('recoveryKey') + }) + + it('counts UTF-8 bytes, including ignored keys, before retaining a snapshot', () => { + const oversized = snapshot({ + ignored: '🙂'.repeat(Math.floor(TERMINAL_AUTHORITY_TOPOLOGY_MAX_SNAPSHOT_BYTES / 4) + 1) + }) + expect(() => parseTerminalAuthorityTopologySnapshot(oversized)).toThrow( + TerminalAuthorityTopologyStreamValidationError + ) + expect(() => parseTerminalAuthorityTopologySnapshot(oversized)).toThrow('byte capacity') + }) + + it('validates bounded exact subscription requests', () => { + expect( + parseTerminalAuthorityTopologySnapshotRequest({ + protocolVersion: 1, + subscriptionId: 'subscription-a', + namespace + }) + ).toEqual({ protocolVersion: 1, subscriptionId: 'subscription-a', namespace }) + expect(() => + parseTerminalAuthorityTopologySnapshotRequest({ + protocolVersion: 1, + subscriptionId: '', + namespace + }) + ).toThrow() + }) +}) + +describe('terminal authority topology change validation', () => { + it('accepts a bounded sequenced delta and returns its exact retained bytes', () => { + const raw = change() + const parsed = parseTerminalAuthorityTopologyChangeWithByteLength(raw) + + expect(parsed.value).toMatchObject({ + streamIncarnationId: 'stream-a', + baseAuthorityRevision: 2, + authorityRevision: 3, + changeSequence: 8 + }) + expect(parsed.byteLength).toBe(new TextEncoder().encode(JSON.stringify(raw)).byteLength) + }) + + it('allows a notice-only namespace change without inventing an authority revision', () => { + const parsed = parseTerminalAuthorityTopologyChangeWithByteLength( + change({ + authorityRevision: 2, + paneChanges: [], + namespaceRecoveryNotices: recoveryProjection({ revision: 4 }) + }) + ) + expect(parsed.value.authorityRevision).toBe(2) + expect(parsed.value.namespaceRecoveryNotices?.revision).toBe(4) + }) + + it('rejects empty, duplicate, over-capacity, and over-revision changes', () => { + expect(() => + parseTerminalAuthorityTopologyChangeWithByteLength(change({ paneChanges: [] })) + ).toThrow('empty') + expect(() => + parseTerminalAuthorityTopologyChangeWithByteLength( + change({ paneChanges: [change().paneChanges![0], change().paneChanges![0]] }) + ) + ).toThrow('duplicated') + const removals = Array.from( + { length: TERMINAL_AUTHORITY_TOPOLOGY_MAX_CHANGE_OPERATIONS + 1 }, + (_, index) => ({ + kind: 'remove', + pane: { paneKey: `pane-${index}`, paneGenerationId: `generation-${index}` } + }) + ) + expect(() => + parseTerminalAuthorityTopologyChangeWithByteLength(change({ paneChanges: removals })) + ).toThrow('capacity') + expect(() => + parseTerminalAuthorityTopologyChangeWithByteLength( + change({ paneChanges: [{ kind: 'upsert', pane: pane({ revision: 4 }) }] }) + ) + ).toThrow('exceeds authority revision') + }) + + it('counts multi-byte ignored fields against the change budget', () => { + expect(() => + parseTerminalAuthorityTopologyChangeWithByteLength( + change({ + ignored: '🙂'.repeat(Math.floor(TERMINAL_AUTHORITY_TOPOLOGY_MAX_CHANGE_BYTES / 4) + 1) + }) + ) + ).toThrow('byte capacity') + }) +}) diff --git a/src/shared/terminal-authority-topology-subscription-registry.test.ts b/src/shared/terminal-authority-topology-subscription-registry.test.ts new file mode 100644 index 00000000000..1d182ccc215 --- /dev/null +++ b/src/shared/terminal-authority-topology-subscription-registry.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { parseTerminalAuthorityTopologySnapshotRequest } from './terminal-authority-topology-stream-validation' +import { TerminalAuthorityTopologySubscriptionRegistry } from './terminal-authority-topology-subscription-registry' + +function request(subscriptionId: string, namespaceId = 'namespace-a') { + return parseTerminalAuthorityTopologySnapshotRequest({ + protocolVersion: 1, + subscriptionId, + namespace: { authorityHostId: 'host-a', namespaceId } + }) +} + +describe('terminal authority topology subscription registry', () => { + it('replaces a resnapshot subscription without growing the connection registry', () => { + const registry = new TerminalAuthorityTopologySubscriptionRegistry() + const exact = request('subscription-a') + + expect(registry.upsert(exact, 'first')).toBeNull() + expect(registry.upsert(exact, 'replacement')).toBe('first') + expect(registry.size).toBe(1) + expect(registry.remove(exact)).toBe('replacement') + expect(registry.size).toBe(0) + }) + + it('rejects cross-namespace subscription ID reuse', () => { + const registry = new TerminalAuthorityTopologySubscriptionRegistry() + registry.upsert(request('subscription-a'), 'first') + + expect(() => registry.upsert(request('subscription-a', 'namespace-b'), 'second')).toThrow( + 'identity_conflict' + ) + expect(() => registry.remove(request('subscription-a', 'namespace-b'))).toThrow( + 'identity_conflict' + ) + expect(registry.size).toBe(1) + }) + + it('bounds each connection and returns every retained subscription on close', () => { + const registry = new TerminalAuthorityTopologySubscriptionRegistry(2) + registry.upsert(request('subscription-a'), 'first') + registry.upsert(request('subscription-b'), 'second') + + expect(() => registry.upsert(request('subscription-c'), 'third')).toThrow('capacity') + expect(registry.clear()).toEqual(['first', 'second']) + expect(registry.size).toBe(0) + }) +}) diff --git a/src/shared/terminal-authority-topology-subscription-registry.ts b/src/shared/terminal-authority-topology-subscription-registry.ts new file mode 100644 index 00000000000..fd362e6d4e4 --- /dev/null +++ b/src/shared/terminal-authority-topology-subscription-registry.ts @@ -0,0 +1,67 @@ +import type { TerminalAuthorityTopologySnapshotRequest } from './terminal-authority-topology-stream-contract' +import { TERMINAL_AUTHORITY_TOPOLOGY_MAX_SUBSCRIPTIONS_PER_CONNECTION } from './terminal-authority-topology-stream-contract' + +type Subscription = Readonly<{ + request: TerminalAuthorityTopologySnapshotRequest + value: T +}> + +function sameNamespace( + left: TerminalAuthorityTopologySnapshotRequest, + right: TerminalAuthorityTopologySnapshotRequest +): boolean { + return ( + left.namespace.authorityHostId === right.namespace.authorityHostId && + left.namespace.namespaceId === right.namespace.namespaceId + ) +} + +export class TerminalAuthorityTopologySubscriptionRegistry { + private readonly subscriptions = new Map>() + + constructor( + private readonly maxSubscriptions = TERMINAL_AUTHORITY_TOPOLOGY_MAX_SUBSCRIPTIONS_PER_CONNECTION + ) { + if ( + !Number.isSafeInteger(maxSubscriptions) || + maxSubscriptions < 1 || + maxSubscriptions > TERMINAL_AUTHORITY_TOPOLOGY_MAX_SUBSCRIPTIONS_PER_CONNECTION + ) { + throw new Error('terminal_authority_topology_subscription_capacity_invalid') + } + } + + get size(): number { + return this.subscriptions.size + } + + upsert(request: TerminalAuthorityTopologySnapshotRequest, value: T): T | null { + const current = this.subscriptions.get(request.subscriptionId) + if (current && !sameNamespace(current.request, request)) { + throw new Error('terminal_authority_topology_subscription_identity_conflict') + } + if (!current && this.subscriptions.size >= this.maxSubscriptions) { + throw new Error('terminal_authority_topology_subscription_capacity') + } + this.subscriptions.set(request.subscriptionId, Object.freeze({ request, value })) + return current?.value ?? null + } + + remove(request: TerminalAuthorityTopologySnapshotRequest): T | null { + const current = this.subscriptions.get(request.subscriptionId) + if (!current) { + return null + } + if (!sameNamespace(current.request, request)) { + throw new Error('terminal_authority_topology_subscription_identity_conflict') + } + this.subscriptions.delete(request.subscriptionId) + return current.value + } + + clear(): readonly T[] { + const values = Object.freeze([...this.subscriptions.values()].map(({ value }) => value)) + this.subscriptions.clear() + return values + } +} diff --git a/src/shared/terminal-legacy-cutover-recovery-validation.ts b/src/shared/terminal-legacy-cutover-recovery-validation.ts new file mode 100644 index 00000000000..32ec0361a05 --- /dev/null +++ b/src/shared/terminal-legacy-cutover-recovery-validation.ts @@ -0,0 +1,274 @@ +import { + assertAuthorityId, + assertAuthorityNamespace, + assertAuthorityStoragePath, + assertPaneGeneration, + assertTerminalBinding, + isRecord +} from './terminal-session-authority-identity' +import { + assertTerminalAuthorityNamespaceLocator, + type TerminalAuthorityNamespaceLocator +} from './terminal-session-authority-locator' +import type { + TerminalLegacyRecoveryProjection, + TerminalLegacyRecoveryReason +} from './terminal-legacy-cutover' + +const MAX_GC_PATHS = 8_192 + +export function assertTerminalLegacyRecoveryProjection( + value: unknown +): asserts value is TerminalLegacyRecoveryProjection { + assertRecoveryBase(value) + if (!isRecord(value)) { + throw new Error('legacy recovery is invalid') + } + assertAuthorityId(value.catalogReceiptId, 'legacy catalogReceiptId') + if (value.status === 'imported') { + assertAuthorityId(value.routeId, 'legacy recovery routeId') + assertPaneGeneration(value.pane) + assertTerminalBinding(value.binding) + assertAuthorityId(value.allocationId, 'legacy recovery allocationId') + assertAuthorityId(value.spawnFingerprint, 'legacy recovery spawnFingerprint') + assertImportMatchEvidence(value.matchEvidence) + assertResolutionProvenance(value.resolvedFrom) + return + } + if ( + !['unresolved', 'acknowledged'].includes(String(value.status)) || + !isRecoveryReason(value.reason) + ) { + throw new Error('legacy recovery status is invalid') + } + assertAuthorityId(value.evidenceCode, 'legacy recovery evidenceCode') + assertPreservationFacts(value.preservation) + if (value.status === 'acknowledged') { + assertAuthorityId(value.previousCatalogReceiptId, 'legacy previousCatalogReceiptId') + assertAuthorityId(value.acknowledgementCode, 'legacy acknowledgementCode') + assertTimestamp(value.acknowledgedAtMs, 'legacy acknowledgedAtMs') + } +} + +function assertResolutionProvenance(value: unknown): void { + if (value === null) { + return + } + if (!isRecord(value) || !isRecoveryReason(value.reason)) { + throw new Error('legacy recovery resolution provenance is invalid') + } + assertAuthorityId(value.catalogReceiptId, 'legacy resolved catalogReceiptId') + assertAuthorityId(value.evidenceCode, 'legacy resolved evidenceCode') +} + +export function assertCandidateBase(value: unknown): void { + if (!isRecord(value)) { + throw new Error('legacy migration candidate is invalid') + } + assertAuthorityId(value.recoveryId, 'legacy recoveryId') + assertAuthorityNamespace(value.namespace) + assertWorkspaceEvidence(value.workspace) + assertPhysicalPtyIdentity(value.physicalPty) + assertInventoryEvidence(value.inventoryEvidence) +} + +export function assertImportMatchEvidence(value: unknown): void { + if (!isRecord(value) || !isRecord(value.localLease) || !isRecord(value.uniqueness)) { + throw new Error('legacy import match evidence is invalid') + } + for (const [field, selected] of [ + ['leaseId', value.localLease.leaseId], + ['paneKey', value.localLease.paneKey], + ['paneGenerationId', value.localLease.paneGenerationId], + ['tabId', value.localLease.tabId] + ] as const) { + assertAuthorityId(selected, `legacy local lease ${field}`) + } + if ( + !Number.isSafeInteger(value.localLease.rendererGeneration) || + Number(value.localLease.rendererGeneration) < 0 + ) { + throw new Error('legacy rendererGeneration is invalid') + } + if (value.localLease.worktreeId !== null) { + assertAuthorityStoragePath(value.localLease.worktreeId, 'legacy local lease worktreeId') + } + assertInventoryEvidence(value.remoteInventory) + if ( + !isRecord(value.remoteInventory) || + typeof value.remoteInventory.paneKey !== 'string' || + typeof value.remoteInventory.tabId !== 'string' || + typeof value.remoteInventory.serializedPtyIncarnationId !== 'string' || + !Number.isSafeInteger(value.remoteInventory.serializedProcessId) || + Number(value.remoteInventory.serializedProcessId) <= 0 || + value.uniqueness.localCandidates !== 1 || + value.uniqueness.remoteCandidates !== 1 || + value.uniqueness.endpointIdentityMatched !== true || + value.uniqueness.processIdentityMatched !== true + ) { + throw new Error('legacy import one-to-one proof is invalid') + } +} + +export function assertPreservationFacts(value: unknown): void { + if (!isRecord(value)) { + throw new Error('legacy preservation facts are invalid') + } + if (value.kind === 'isolated-grace-disabled') { + if (value.endpointIdentityRetained !== true || value.graceDisabledAcknowledged !== true) { + throw new Error('legacy isolated preservation proof is invalid') + } + return + } + if ( + !['evidence-gc-retained', 'worker-unreachable', 'unsupported-platform'].includes( + String(value.kind) + ) || + value.processPreservationUnproved !== true + ) { + throw new Error('legacy unproved preservation facts are invalid') + } +} + +export function assertGcProtection(value: unknown): void { + if (!isRecord(value)) { + throw new Error('legacy worker GC protection is invalid') + } + assertPathList(value.relayDirectories, 'legacy GC relay directory') + assertPathList(value.evidencePaths, 'legacy GC evidence path') +} + +export function assertEndpointIdentity(value: unknown): void { + if (!isRecord(value)) { + throw new Error('legacy endpoint identity is invalid') + } + if (value.kind === 'unix-socket') { + for (const selected of [value.device, value.inode, value.changedAtNs]) { + if (typeof selected !== 'string' || !/^[0-9]+$/.test(selected)) { + throw new Error('legacy Unix socket identity is invalid') + } + } + return + } + if (value.kind !== 'windows-named-pipe') { + throw new Error('legacy endpoint kind is invalid') + } + assertAuthorityStoragePath(value.pipeName, 'legacy pipeName') + assertAuthorityId(value.processCreationMarker, 'legacy processCreationMarker') +} + +export function assertPositiveInteger(value: unknown, field: string): void { + if (!Number.isSafeInteger(value) || Number(value) <= 0) { + throw new Error(`${field} is invalid`) + } +} + +export function assertTimestamp(value: unknown, field: string): void { + if (!Number.isSafeInteger(value) || Number(value) < 0) { + throw new Error(`${field} is invalid`) + } +} + +function assertRecoveryBase(value: unknown): void { + assertCandidateBase(value) + if (!isRecord(value)) { + return + } + assertTimestamp(value.discoveredAtMs, 'legacy recovery discoveredAtMs') + assertTimestamp(value.updatedAtMs, 'legacy recovery updatedAtMs') + if (Number(value.updatedAtMs) < Number(value.discoveredAtMs)) { + throw new Error('legacy recovery timestamps are inconsistent') + } +} + +function assertWorkspaceEvidence(value: unknown): void { + if (!isRecord(value)) { + throw new Error('legacy workspace evidence is invalid') + } + assertTerminalAuthorityNamespaceLocator(value.locator as TerminalAuthorityNamespaceLocator) + const locator = value.locator as TerminalAuthorityNamespaceLocator + if (value.kind === 'git-worktree') { + if (locator.kind !== 'workspace') { + throw new Error('legacy git worktree locator is invalid') + } + assertAuthorityStoragePath(value.worktreeId, 'legacy worktreeId') + return + } + if (value.kind === 'folder') { + if (locator.kind !== 'workspace') { + throw new Error('legacy folder locator is invalid') + } + return + } + if (value.kind !== 'floating' || locator.kind !== 'floating') { + throw new Error('legacy workspace evidence flavor is invalid') + } +} + +function assertPhysicalPtyIdentity(value: unknown): void { + if (!isRecord(value)) { + throw new Error('legacy physical PTY identity is invalid') + } + assertAuthorityId(value.workerId, 'legacy PTY workerId') + assertAuthorityId(value.physicalPtyId, 'legacy physicalPtyId') + if (value.ptyIncarnationId !== null) { + assertAuthorityId(value.ptyIncarnationId, 'legacy ptyIncarnationId') + } + if (value.processId !== null) { + assertPositiveInteger(value.processId, 'legacy PTY processId') + } +} + +function assertInventoryEvidence(value: unknown): void { + if (!isRecord(value)) { + throw new Error('legacy inventory evidence is invalid') + } + assertAuthorityId(value.evidenceDigest, 'legacy inventory evidenceDigest') + assertTimestamp(value.observedAtMs, 'legacy inventory observedAtMs') + for (const [field, selected] of [ + ['paneKey', value.paneKey], + ['tabId', value.tabId], + ['worktreeId', value.worktreeId], + ['cwd', value.cwd], + ['serializedPtyIncarnationId', value.serializedPtyIncarnationId] + ] as const) { + if (selected === null) { + continue + } + if (field === 'cwd' || field === 'worktreeId') { + assertAuthorityStoragePath(selected, `legacy inventory ${field}`) + } else { + assertAuthorityId(selected, `legacy inventory ${field}`) + } + } + if (value.serializedProcessId !== null) { + assertPositiveInteger(value.serializedProcessId, 'legacy inventory serializedProcessId') + } +} + +function assertPathList(value: unknown, field: string): void { + if (!Array.isArray(value) || value.length > MAX_GC_PATHS) { + throw new Error(`${field} list is invalid`) + } + const paths = new Set() + for (const selected of value) { + assertAuthorityStoragePath(selected, field) + if (paths.has(selected)) { + throw new Error(`${field} list contains a duplicate`) + } + paths.add(selected) + } +} + +const RECOVERY_REASONS = new Set([ + 'ambiguous-pane-generation', + 'endpoint-identity-unproved', + 'physical-pty-incarnation-unproved', + 'unsupported-platform', + 'worker-unreachable', + 'workspace-mismatch' +]) + +function isRecoveryReason(value: unknown): value is TerminalLegacyRecoveryReason { + return typeof value === 'string' && RECOVERY_REASONS.has(value as TerminalLegacyRecoveryReason) +} diff --git a/src/shared/terminal-legacy-cutover-request-validation.ts b/src/shared/terminal-legacy-cutover-request-validation.ts new file mode 100644 index 00000000000..97e198eea0d --- /dev/null +++ b/src/shared/terminal-legacy-cutover-request-validation.ts @@ -0,0 +1,282 @@ +import { + assertAuthorityId, + assertAuthorityStoragePath, + assertPaneGeneration, + isRecord, + type TerminalPaneGeneration +} from './terminal-session-authority-identity' +import { + assertCandidateBase, + assertEndpointIdentity, + assertGcProtection, + assertImportMatchEvidence, + assertPositiveInteger, + assertPreservationFacts, + assertTerminalLegacyRecoveryProjection, + assertTimestamp +} from './terminal-legacy-cutover-recovery-validation' +import { + TERMINAL_LEGACY_CUTOVER_VERSION, + type TerminalLegacyEndpointIdentity, + type TerminalLegacyImportMatchEvidence, + type TerminalLegacyMigrationImportRequest, + type TerminalLegacyMigrationReceipt, + type TerminalLegacyPhysicalPtyIdentity, + type TerminalLegacyUnresolvedCandidate, + type TerminalLegacyWorkerEvidence, + type TerminalLegacyWorkerRoute +} from './terminal-legacy-cutover' + +const MAX_MIGRATION_CANDIDATES = 4_096 + +export function assertTerminalLegacyMigrationReceipt( + value: unknown +): asserts value is TerminalLegacyMigrationReceipt { + if ( + !isRecord(value) || + value.version !== TERMINAL_LEGACY_CUTOVER_VERSION || + !isRecord(value.request) || + value.receiptId !== value.request.migrationId || + !Number.isSafeInteger(value.sequence) || + Number(value.sequence) < 1 || + !Number.isSafeInteger(value.committedAtMs) || + Number(value.committedAtMs) < 0 || + !Array.isArray(value.recoveries) + ) { + throw new Error('legacy migration receipt is invalid') + } + assertTerminalLegacyMigrationImportRequest(value.request) + for (const recovery of value.recoveries) { + assertTerminalLegacyRecoveryProjection(recovery) + } +} + +export function assertTerminalLegacyMigrationImportRequest( + value: unknown +): asserts value is TerminalLegacyMigrationImportRequest { + if (!isRecord(value) || value.version !== TERMINAL_LEGACY_CUTOVER_VERSION) { + throw new Error('legacy migration request version is invalid') + } + assertAuthorityId(value.migrationId, 'legacy migrationId') + assertAuthorityId(value.authorityHostId, 'legacy authorityHostId') + assertTimestamp(value.requestedAtMs, 'legacy migration requestedAtMs') + if (!Array.isArray(value.imports) || !Array.isArray(value.unresolved)) { + throw new Error('legacy migration candidates are invalid') + } + const candidateCount = value.imports.length + value.unresolved.length + if ( + candidateCount > MAX_MIGRATION_CANDIDATES || + (value.mode !== 'acknowledge' && candidateCount < 1) + ) { + throw new Error('legacy migration candidates exceed their bounded capacity') + } + assertMigrationMode(value) + for (const candidate of value.imports) { + assertImportCandidate(candidate) + } + for (const candidate of value.unresolved) { + assertUnresolvedCandidate(candidate) + assertCandidatePreservation(value.mode, candidate as TerminalLegacyUnresolvedCandidate) + } +} + +export function assertTerminalLegacyWorkerEvidence( + value: unknown +): asserts value is TerminalLegacyWorkerEvidence { + if (!isRecord(value)) { + throw new Error('legacy worker evidence is invalid') + } + assertAuthorityId(value.workerId, 'legacy evidence workerId') + if (value.buildId !== null) { + assertAuthorityId(value.buildId, 'legacy evidence buildId') + } + assertAuthorityStoragePath(value.relayDirectory, 'legacy evidence relayDirectory') + assertAuthorityStoragePath(value.endpointPath, 'legacy evidence endpointPath') + assertAuthorityStoragePath(value.credentialFile, 'legacy evidence credentialFile') + if (value.process !== null) { + if (!isRecord(value.process)) { + throw new Error('legacy evidence process is invalid') + } + assertPositiveInteger(value.process.pid, 'legacy evidence pid') + assertAuthorityId(value.process.birthMarker, 'legacy evidence birthMarker') + } + assertAuthorityId(value.inventoryDigest, 'legacy evidence inventoryDigest') + assertGcProtection(value.gcProtection) +} + +export function assertTerminalLegacyWorkerRoute( + value: unknown +): asserts value is TerminalLegacyWorkerRoute { + if (!isRecord(value) || !isRecord(value.process) || !isRecord(value.sourceOwner)) { + throw new Error('legacy worker route is invalid') + } + for (const [field, selected] of [ + ['routeId', value.routeId], + ['workerId', value.workerId], + ['ownerIncarnationId', value.ownerIncarnationId], + ['buildId', value.buildId] + ] as const) { + assertAuthorityId(selected, `legacy worker ${field}`) + } + assertAuthorityStoragePath(value.relayDirectory, 'legacy worker relayDirectory') + assertAuthorityStoragePath(value.socketPath, 'legacy worker socketPath') + assertAuthorityStoragePath(value.credentialFile, 'legacy worker credentialFile') + assertPositiveInteger(value.process.pid, 'legacy worker pid') + assertAuthorityId(value.process.birthMarker, 'legacy worker birthMarker') + assertEndpointIdentity(value.endpoint) + assertAuthorityId(value.sourceOwner.clientInstanceId, 'legacy source clientInstanceId') + assertPositiveInteger(value.sourceOwner.ownerGeneration, 'legacy source ownerGeneration') + assertAuthorityId(value.sourceOwner.ownerLease, 'legacy source ownerLease') + assertPositiveInteger( + value.sourceOwner.outputWindowSourceUnits, + 'legacy source outputWindowSourceUnits' + ) + assertGcProtection(value.gcProtection) +} + +function assertMigrationMode(value: Record): void { + if (value.mode === 'cutover') { + assertTerminalLegacyWorkerRoute(value.workerRoute) + assertTerminalLegacyCutoverProof(value.cutover) + return + } + if (value.mode === 'recovery-only') { + assertTerminalLegacyWorkerEvidence(value.workerEvidence) + if ((value.imports as unknown[]).length !== 0) { + throw new Error('recovery-only migration cannot import a PTY') + } + return + } + if (value.mode !== 'acknowledge') { + throw new Error('legacy migration mode is invalid') + } + assertAuthorityId(value.recoveryId, 'legacy acknowledged recoveryId') + assertAuthorityId(value.expectedCatalogReceiptId, 'legacy expectedCatalogReceiptId') + assertAuthorityId(value.acknowledgementCode, 'legacy acknowledgementCode') + if ((value.imports as unknown[]).length !== 0 || (value.unresolved as unknown[]).length !== 0) { + throw new Error('legacy acknowledgement cannot carry candidates') + } +} + +function assertCandidatePreservation( + mode: unknown, + candidate: TerminalLegacyUnresolvedCandidate +): void { + if (mode === 'recovery-only' && candidate.preservation.kind === 'isolated-grace-disabled') { + throw new Error('recovery-only migration cannot claim worker isolation') + } + if (mode === 'cutover' && candidate.preservation.kind !== 'isolated-grace-disabled') { + throw new Error('cutover recovery must retain the isolated worker') + } +} + +function assertImportCandidate(value: unknown): void { + assertCandidateBase(value) + if (!isRecord(value)) { + return + } + assertPaneGeneration(value.pane) + assertAuthorityId(value.allocationId, 'legacy allocationId') + assertAuthorityId(value.spawnFingerprint, 'legacy spawnFingerprint') + assertImportMatchEvidence(value.matchEvidence) + if (!isRecord(value.physicalPty) || typeof value.physicalPty.ptyIncarnationId !== 'string') { + throw new Error('legacy import requires an exact PTY incarnation') + } + const match = value.matchEvidence as TerminalLegacyImportMatchEvidence + const physical = value.physicalPty as TerminalLegacyPhysicalPtyIdentity + const pane = value.pane as TerminalPaneGeneration + if ( + match.localLease.paneKey !== pane.paneKey || + match.localLease.paneGenerationId !== pane.paneGenerationId || + match.remoteInventory.paneKey !== match.localLease.paneKey || + match.remoteInventory.tabId !== match.localLease.tabId || + match.remoteInventory.serializedPtyIncarnationId !== physical.ptyIncarnationId || + match.remoteInventory.serializedProcessId !== physical.processId + ) { + throw new Error('legacy import evidence does not match the derived binding') + } +} + +function assertUnresolvedCandidate(value: unknown): void { + assertCandidateBase(value) + if (!isRecord(value) || !RECOVERY_REASONS.has(String(value.reason))) { + throw new Error('legacy unresolved reason is invalid') + } + assertAuthorityId(value.evidenceCode, 'legacy unresolved evidenceCode') + assertPreservationFacts(value.preservation) +} + +function assertTerminalLegacyCutoverProof(value: unknown): void { + if (!isRecord(value)) { + throw new Error('legacy cutover proof is invalid') + } + assertAuthorityStoragePath(value.publicCredentialFile, 'legacy cutover publicCredentialFile') + assertAuthorityStoragePath(value.privateCredentialFile, 'legacy cutover privateCredentialFile') + assertCutoverEndpoint(value) + if (value.brokerClientCount !== 1) { + throw new Error('legacy cutover broker client count is invalid') + } + assertPositiveInteger(value.acceptedConnectionCount, 'legacy acceptedConnectionCount') + if (!Number.isSafeInteger(value.quiescenceSamples) || Number(value.quiescenceSamples) < 2) { + throw new Error('legacy quiescenceSamples is invalid') + } + if ( + !isRecord(value.connectionProof) || + !CONNECTION_PROOF_METHODS.has(String(value.connectionProof.method)) || + value.connectionProof.acceptedServerConnections !== 1 + ) { + throw new Error('legacy broker connection proof is invalid') + } + assertAuthorityId(value.connectionProof.listenerIdentity, 'legacy listenerIdentity') + assertAuthorityId( + value.connectionProof.brokerConnectionIdentity, + 'legacy brokerConnectionIdentity' + ) + if ( + !isRecord(value.graceConfiguration) || + value.graceConfiguration.capabilityVersion !== 1 || + value.graceConfiguration.configuredGraceMs !== 0 || + value.graceConfiguration.acknowledged !== true + ) { + throw new Error('legacy cutover grace configuration is invalid') + } + assertTimestamp(value.sealedAtMs, 'legacy cutover sealedAtMs') +} + +function assertCutoverEndpoint(value: Record): void { + if (value.kind === 'posix-relocated') { + assertAuthorityStoragePath(value.publicSocketPath, 'legacy cutover publicSocketPath') + assertAuthorityStoragePath(value.privateSocketPath, 'legacy cutover privateSocketPath') + assertEndpointIdentity(value.endpointIdentity) + if ((value.endpointIdentity as TerminalLegacyEndpointIdentity).kind !== 'unix-socket') { + throw new Error('legacy POSIX endpoint proof is invalid') + } + return + } + if (value.kind !== 'windows-sealed') { + throw new Error('legacy cutover proof kind is invalid') + } + assertAuthorityStoragePath(value.originalPipeName, 'legacy cutover originalPipeName') + if (value.activePipeMarkerIgnored !== true) { + throw new Error('legacy Windows active pipe marker is not fenced') + } + assertEndpointIdentity(value.endpointIdentity) + if ((value.endpointIdentity as TerminalLegacyEndpointIdentity).kind !== 'windows-named-pipe') { + throw new Error('legacy Windows endpoint proof is invalid') + } +} + +const RECOVERY_REASONS = new Set([ + 'ambiguous-pane-generation', + 'endpoint-identity-unproved', + 'physical-pty-incarnation-unproved', + 'unsupported-platform', + 'worker-unreachable', + 'workspace-mismatch' +]) + +const CONNECTION_PROOF_METHODS = new Set([ + 'linux-procfs-unix', + 'darwin-lsof-unix', + 'windows-pipe-process' +]) diff --git a/src/shared/terminal-legacy-cutover.ts b/src/shared/terminal-legacy-cutover.ts new file mode 100644 index 00000000000..4519363d165 --- /dev/null +++ b/src/shared/terminal-legacy-cutover.ts @@ -0,0 +1,196 @@ +import type { + TerminalLegacyImportCandidate, + TerminalLegacyRecoveryNotice, + TerminalLegacyRecoveryProjection, + TerminalLegacyUnresolvedCandidate +} from './terminal-legacy-recovery' + +export const TERMINAL_LEGACY_CUTOVER_VERSION = 1 +export const TERMINAL_LEGACY_CUTOVER_CAPABILITY = 'terminal-session.legacy-cutover.v1' + +export function relayDaemonGrantHasTerminalLegacyCutover( + capabilities: readonly string[] | undefined +): boolean { + return capabilities?.includes(TERMINAL_LEGACY_CUTOVER_CAPABILITY) === true +} + +export type TerminalLegacyProcessIdentity = Readonly<{ + pid: number + birthMarker: string +}> + +export type TerminalLegacyEndpointIdentity = + | Readonly<{ + kind: 'unix-socket' + device: string + inode: string + changedAtNs: string + }> + | Readonly<{ + kind: 'windows-named-pipe' + pipeName: string + processCreationMarker: string + }> + +export type TerminalLegacySourceOwner = Readonly<{ + clientInstanceId: string + ownerGeneration: number + ownerLease: string + outputWindowSourceUnits: number +}> + +export type TerminalLegacyWorkerRoute = Readonly<{ + routeId: string + workerId: string + ownerIncarnationId: string + buildId: string + relayDirectory: string + socketPath: string + credentialFile: string + process: TerminalLegacyProcessIdentity + endpoint: TerminalLegacyEndpointIdentity + sourceOwner: TerminalLegacySourceOwner + gcProtection: TerminalLegacyGcProtection +}> + +export type TerminalLegacyGcProtection = Readonly<{ + relayDirectories: readonly string[] + evidencePaths: readonly string[] +}> + +export type TerminalLegacyWorkerEvidence = Readonly<{ + workerId: string + buildId: string | null + relayDirectory: string + endpointPath: string + credentialFile: string + process: TerminalLegacyProcessIdentity | null + inventoryDigest: string + gcProtection: TerminalLegacyGcProtection +}> + +type TerminalLegacyCutoverProofBase = Readonly<{ + publicCredentialFile: string + privateCredentialFile: string + brokerClientCount: 1 + acceptedConnectionCount: number + quiescenceSamples: number + connectionProof: Readonly<{ + method: 'linux-procfs-unix' | 'darwin-lsof-unix' | 'windows-pipe-process' + listenerIdentity: string + brokerConnectionIdentity: string + acceptedServerConnections: 1 + }> + graceConfiguration: Readonly<{ + capabilityVersion: 1 + configuredGraceMs: 0 + acknowledged: true + }> + sealedAtMs: number +}> + +export type TerminalLegacyCutoverProof = TerminalLegacyCutoverProofBase & + ( + | Readonly<{ + kind: 'posix-relocated' + publicSocketPath: string + privateSocketPath: string + endpointIdentity: Extract + }> + | Readonly<{ + kind: 'windows-sealed' + originalPipeName: string + activePipeMarkerIgnored: true + endpointIdentity: Extract + }> + ) + +type TerminalLegacyMigrationRequestBase = Readonly<{ + version: typeof TERMINAL_LEGACY_CUTOVER_VERSION + migrationId: string + authorityHostId: string + requestedAtMs: number +}> + +export type TerminalLegacyMigrationImportRequest = TerminalLegacyMigrationRequestBase & + ( + | Readonly<{ + mode: 'cutover' + workerRoute: TerminalLegacyWorkerRoute + cutover: TerminalLegacyCutoverProof + imports: readonly TerminalLegacyImportCandidate[] + unresolved: readonly TerminalLegacyUnresolvedCandidate[] + }> + | Readonly<{ + mode: 'recovery-only' + workerEvidence: TerminalLegacyWorkerEvidence + imports: readonly [] + unresolved: readonly TerminalLegacyUnresolvedCandidate[] + }> + | Readonly<{ + mode: 'acknowledge' + recoveryId: string + expectedCatalogReceiptId: string + acknowledgementCode: string + imports: readonly [] + unresolved: readonly [] + }> + ) + +export type TerminalLegacyMigrationReceipt = Readonly<{ + version: typeof TERMINAL_LEGACY_CUTOVER_VERSION + receiptId: string + sequence: number + committedAtMs: number + request: TerminalLegacyMigrationImportRequest + recoveries: readonly TerminalLegacyRecoveryProjection[] +}> + +export type TerminalLegacyCutoverProjection = Readonly<{ + version: typeof TERMINAL_LEGACY_CUTOVER_VERSION + revision: number + workers: readonly TerminalLegacyWorkerRouteProjection[] + recoveries: readonly TerminalLegacyRecoveryView[] +}> + +export type TerminalLegacyWorkerRouteProjection = Readonly<{ + routeId: string + workerId: string + ownerIncarnationId: string + buildId: string + process: TerminalLegacyProcessIdentity + endpoint: + | Extract + | Readonly<{ + kind: 'windows-named-pipe' + processCreationMarker: string + }> + sourceOwner: Omit +}> + +export type TerminalLegacyRecoveryView = TerminalLegacyRecoveryProjection & + Readonly<{ + workerRoute: TerminalLegacyWorkerRouteProjection | null + }> + +export type TerminalLegacyRecoveryNoticeProjection = Readonly<{ + version: typeof TERMINAL_LEGACY_CUTOVER_VERSION + revision: number + notices: readonly TerminalLegacyRecoveryNotice[] +}> + +export type { + TerminalLegacyAcknowledgedRecovery, + TerminalLegacyImportCandidate, + TerminalLegacyImportMatchEvidence, + TerminalLegacyImportedRecovery, + TerminalLegacyInventoryEvidence, + TerminalLegacyPhysicalPtyIdentity, + TerminalLegacyPreservationFacts, + TerminalLegacyRecoveryNotice, + TerminalLegacyRecoveryProjection, + TerminalLegacyRecoveryReason, + TerminalLegacyUnresolvedCandidate, + TerminalLegacyUnresolvedRecovery, + TerminalLegacyWorkspaceEvidence +} from './terminal-legacy-recovery' diff --git a/src/shared/terminal-legacy-recovery.ts b/src/shared/terminal-legacy-recovery.ts new file mode 100644 index 00000000000..61cb3f9edcc --- /dev/null +++ b/src/shared/terminal-legacy-recovery.ts @@ -0,0 +1,185 @@ +import type { + TerminalAuthorityNamespace, + TerminalPaneGeneration, + TerminalSessionBinding +} from './terminal-session-authority-identity' +import type { TerminalAuthorityNamespaceLocator } from './terminal-session-authority-locator' + +export type TerminalLegacyWorkspaceEvidence = + | Readonly<{ + kind: 'git-worktree' + locator: Extract + worktreeId: string + }> + | Readonly<{ + kind: 'folder' + locator: Extract + }> + | Readonly<{ + kind: 'floating' + locator: Extract + }> + +export type TerminalLegacyPhysicalPtyIdentity = Readonly<{ + workerId: string + physicalPtyId: string + ptyIncarnationId: string | null + processId: number | null +}> + +export type TerminalLegacyInventoryEvidence = Readonly<{ + evidenceDigest: string + observedAtMs: number + paneKey: string | null + tabId: string | null + worktreeId: string | null + cwd: string | null + serializedPtyIncarnationId: string | null + serializedProcessId: number | null +}> + +export type TerminalLegacyImportMatchEvidence = Readonly<{ + localLease: Readonly<{ + leaseId: string + paneKey: string + paneGenerationId: string + rendererGeneration: number + tabId: string + worktreeId: string | null + }> + remoteInventory: TerminalLegacyInventoryEvidence & + Readonly<{ + paneKey: string + tabId: string + serializedPtyIncarnationId: string + serializedProcessId: number + }> + uniqueness: Readonly<{ + localCandidates: 1 + remoteCandidates: 1 + endpointIdentityMatched: true + processIdentityMatched: true + }> +}> + +export type TerminalLegacyPreservationFacts = + | Readonly<{ + kind: 'isolated-grace-disabled' + endpointIdentityRetained: true + graceDisabledAcknowledged: true + }> + | Readonly<{ + kind: 'evidence-gc-retained' + processPreservationUnproved: true + }> + | Readonly<{ + kind: 'worker-unreachable' + processPreservationUnproved: true + }> + | Readonly<{ + kind: 'unsupported-platform' + processPreservationUnproved: true + }> + +export type TerminalLegacyRecoveryReason = + | 'ambiguous-pane-generation' + | 'endpoint-identity-unproved' + | 'physical-pty-incarnation-unproved' + | 'unsupported-platform' + | 'worker-unreachable' + | 'workspace-mismatch' + +type TerminalLegacyRecoveryBase = Readonly<{ + recoveryId: string + namespace: TerminalAuthorityNamespace + workspace: TerminalLegacyWorkspaceEvidence + physicalPty: TerminalLegacyPhysicalPtyIdentity + inventoryEvidence: TerminalLegacyInventoryEvidence + discoveredAtMs: number + updatedAtMs: number +}> + +export type TerminalLegacyImportedRecovery = TerminalLegacyRecoveryBase & + Readonly<{ + status: 'imported' + routeId: string + pane: TerminalPaneGeneration + binding: TerminalSessionBinding + allocationId: string + spawnFingerprint: string + matchEvidence: TerminalLegacyImportMatchEvidence + catalogReceiptId: string + resolvedFrom: Readonly<{ + catalogReceiptId: string + reason: TerminalLegacyRecoveryReason + evidenceCode: string + }> | null + }> + +export type TerminalLegacyUnresolvedRecovery = TerminalLegacyRecoveryBase & + Readonly<{ + status: 'unresolved' + reason: TerminalLegacyRecoveryReason + evidenceCode: string + preservation: TerminalLegacyPreservationFacts + catalogReceiptId: string + }> + +export type TerminalLegacyAcknowledgedRecovery = TerminalLegacyRecoveryBase & + Readonly<{ + status: 'acknowledged' + reason: TerminalLegacyRecoveryReason + evidenceCode: string + preservation: TerminalLegacyPreservationFacts + catalogReceiptId: string + previousCatalogReceiptId: string + acknowledgementCode: string + acknowledgedAtMs: number + }> + +export type TerminalLegacyRecoveryProjection = + | TerminalLegacyImportedRecovery + | TerminalLegacyUnresolvedRecovery + | TerminalLegacyAcknowledgedRecovery + +export type TerminalLegacyImportCandidate = Readonly<{ + recoveryId: string + namespace: TerminalAuthorityNamespace + workspace: TerminalLegacyWorkspaceEvidence + physicalPty: TerminalLegacyPhysicalPtyIdentity & Readonly<{ ptyIncarnationId: string }> + pane: TerminalPaneGeneration + allocationId: string + spawnFingerprint: string + inventoryEvidence: TerminalLegacyInventoryEvidence + matchEvidence: TerminalLegacyImportMatchEvidence +}> + +export type TerminalLegacyUnresolvedCandidate = Readonly<{ + recoveryId: string + namespace: TerminalAuthorityNamespace + workspace: TerminalLegacyWorkspaceEvidence + physicalPty: TerminalLegacyPhysicalPtyIdentity + reason: TerminalLegacyRecoveryReason + evidenceCode: string + inventoryEvidence: TerminalLegacyInventoryEvidence + preservation: TerminalLegacyPreservationFacts +}> + +type TerminalLegacyRecoveryNoticeBase = Readonly<{ + recoveryKey: string + workspaceKind: TerminalLegacyWorkspaceEvidence['kind'] + evidenceDigest: string + observedAtMs: number + discoveredAtMs: number + updatedAtMs: number +}> + +export type TerminalLegacyRecoveryNotice = TerminalLegacyRecoveryNoticeBase & + ( + | Readonly<{ status: 'imported' }> + | Readonly<{ + status: 'unresolved' | 'acknowledged' + reason: TerminalLegacyRecoveryReason + preservationKind: TerminalLegacyPreservationFacts['kind'] + }> + ) diff --git a/src/shared/terminal-session-authority-boundary-identity.test.ts b/src/shared/terminal-session-authority-boundary-identity.test.ts new file mode 100644 index 00000000000..d0b5f9c7fe3 --- /dev/null +++ b/src/shared/terminal-session-authority-boundary-identity.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest' +import type { TerminalAuthorityNamespaceOutcomeBoundary } from './terminal-session-authority-consumer-transport' +import { terminalSessionAuthorityBoundaryId } from './terminal-session-authority-boundary-identity' + +type UnsignedBoundary = Omit + +describe('terminalSessionAuthorityBoundaryId', () => { + it('canonicalizes reordered boundary, namespace, and projection properties', () => { + const original = unsignedBoundary() + const reordered = { + projection: { + materializedOutcomes: [], + allocations: [], + panes: [], + revision: 1, + writerEpoch: 1, + namespace: { namespaceId: 'namespace-1', authorityHostId: 'host-1' } + }, + consumerStart: 'new-at-tail', + outcomeHighWatermark: 0, + acknowledgedSequence: 0, + namespace: { namespaceId: 'namespace-1', authorityHostId: 'host-1' }, + consumer: { + consumerIncarnationId: 'app-process:one', + consumerId: 'app-profile:one' + }, + version: 1 + } satisfies UnsignedBoundary + + expect(terminalSessionAuthorityBoundaryId(reordered)).toBe( + terminalSessionAuthorityBoundaryId(original) + ) + }) + + it('binds cursor and projection content to the identity', () => { + const original = unsignedBoundary() + + expect(terminalSessionAuthorityBoundaryId({ ...original, outcomeHighWatermark: 1 })).not.toBe( + terminalSessionAuthorityBoundaryId(original) + ) + expect( + terminalSessionAuthorityBoundaryId({ + ...original, + projection: { ...original.projection!, revision: 2 } + }) + ).not.toBe(terminalSessionAuthorityBoundaryId(original)) + }) + + it('normalizes omitted optional fields and ignores unknown top-level additions', () => { + const { + consumerStart: _consumerStart, + projection: _projection, + ...required + } = unsignedBoundary() + const absent = required satisfies UnsignedBoundary + const explicit = { ...required, consumerStart: undefined, projection: undefined } + const future = { ...required, futureOptional: 'ignored-by-v1' } as UnsignedBoundary + + expect(terminalSessionAuthorityBoundaryId(explicit)).toBe( + terminalSessionAuthorityBoundaryId(absent) + ) + expect(terminalSessionAuthorityBoundaryId(future)).toBe( + terminalSessionAuthorityBoundaryId(absent) + ) + }) + + it('rejects another identity version instead of aliasing it into v1', () => { + expect(() => + terminalSessionAuthorityBoundaryId({ ...unsignedBoundary(), version: 2 } as never) + ).toThrow('version is invalid') + }) +}) + +function unsignedBoundary(): UnsignedBoundary { + const namespace = Object.freeze({ authorityHostId: 'host-1', namespaceId: 'namespace-1' }) + return Object.freeze({ + version: 1, + consumer: Object.freeze({ + consumerId: 'app-profile:one', + consumerIncarnationId: 'app-process:one' + }), + namespace, + acknowledgedSequence: 0, + outcomeHighWatermark: 0, + consumerStart: 'new-at-tail', + projection: Object.freeze({ + namespace, + writerEpoch: 1, + revision: 1, + panes: Object.freeze([]), + allocations: Object.freeze([]), + materializedOutcomes: Object.freeze([]) + }) + }) +} diff --git a/src/shared/terminal-session-authority-boundary-identity.ts b/src/shared/terminal-session-authority-boundary-identity.ts new file mode 100644 index 00000000000..25cb4d58ee1 --- /dev/null +++ b/src/shared/terminal-session-authority-boundary-identity.ts @@ -0,0 +1,47 @@ +import { createHash } from 'node:crypto' +import type { TerminalAuthorityNamespaceOutcomeBoundary } from './terminal-session-authority-consumer-transport' + +export function terminalSessionAuthorityBoundaryId( + boundary: Omit +): string { + if (boundary.version !== 1) { + throw new Error('terminal authority boundary version is invalid') + } + const payload = { + version: boundary.version, + consumer: boundary.consumer, + namespace: boundary.namespace, + acknowledgedSequence: boundary.acknowledgedSequence, + outcomeHighWatermark: boundary.outcomeHighWatermark, + consumerStart: boundary.consumerStart ?? null, + projection: boundary.projection ?? null + } + const digest = createHash('sha256') + .update(`terminal-session-authority-boundary:v1\0${canonicalJson(payload)}`) + .digest('hex') + return `authority-boundary:${digest}` +} + +function canonicalJson(value: unknown): string { + if (value === null || typeof value === 'boolean' || typeof value === 'string') { + return JSON.stringify(value) + } + if (typeof value === 'number') { + if (!Number.isFinite(value)) { + throw new Error('terminal authority boundary contains a non-finite number') + } + return JSON.stringify(value) + } + if (Array.isArray(value)) { + return `[${value.map((entry) => (entry === undefined ? 'null' : canonicalJson(entry))).join(',')}]` + } + if (typeof value === 'object') { + const entries = Object.entries(value as Record) + .filter(([, entry]) => entry !== undefined) + .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) + return `{${entries + .map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`) + .join(',')}}` + } + throw new Error('terminal authority boundary contains an unsupported value') +} diff --git a/src/shared/terminal-session-authority-boundary-projection-validation.ts b/src/shared/terminal-session-authority-boundary-projection-validation.ts new file mode 100644 index 00000000000..cc5ae54a5aa --- /dev/null +++ b/src/shared/terminal-session-authority-boundary-projection-validation.ts @@ -0,0 +1,92 @@ +import { + assertAuthorityNamespace, + isRecord, + type TerminalAuthorityNamespace +} from './terminal-session-authority-identity' +import type { + TerminalAuthorityDurableOutcome, + TerminalAuthorityProjection +} from './terminal-session-authority-mutation' +import { parseTerminalAuthorityDurableOutcome } from './terminal-authority-durable-outcome-validation' +import { + assertAllocationRecord, + assertPaneRecord, + assertSafeInteger +} from './terminal-session-authority-record-validation' + +const MAX_MATERIALIZED_BOUNDARY_OUTCOMES = 16_384 + +export function parseTerminalAuthorityBoundaryProjection( + value: unknown, + boundaryNamespace: TerminalAuthorityNamespace, + outcomeHighWatermark: number +): TerminalAuthorityProjection | null { + if (!isRecord(value) || !Array.isArray(value.panes) || !Array.isArray(value.allocations)) { + return null + } + const materializedOutcomes = + value.materializedOutcomes === undefined + ? undefined + : parseMaterializedOutcomes( + value.materializedOutcomes, + boundaryNamespace, + outcomeHighWatermark + ) + try { + assertAuthorityNamespace(value.namespace) + assertSafeInteger(value.writerEpoch, 'projection writer epoch', 1) + assertSafeInteger(value.revision, 'projection revision') + value.panes.forEach(assertPaneRecord) + value.allocations.forEach(assertAllocationRecord) + } catch { + return null + } + if ( + !sameTerminalAuthorityOutcomeNamespace(value.namespace, boundaryNamespace) || + (value.materializedOutcomes !== undefined && !materializedOutcomes) + ) { + return null + } + return Object.freeze(structuredClone(value)) as TerminalAuthorityProjection +} + +export function sameTerminalAuthorityOutcomeNamespace( + left: TerminalAuthorityNamespace, + right: TerminalAuthorityNamespace +): boolean { + return left.authorityHostId === right.authorityHostId && left.namespaceId === right.namespaceId +} + +export function terminalAuthorityOutcomeMatchesNamespace( + outcome: TerminalAuthorityDurableOutcome, + namespace: TerminalAuthorityNamespace +): boolean { + const actual = outcome.kind === 'semantic' ? outcome.access.namespace : outcome.result.namespace + return sameTerminalAuthorityOutcomeNamespace(actual, namespace) +} + +function parseMaterializedOutcomes( + value: unknown, + namespace: TerminalAuthorityNamespace, + outcomeHighWatermark: number +): readonly TerminalAuthorityDurableOutcome[] | null { + if (!Array.isArray(value) || value.length > MAX_MATERIALIZED_BOUNDARY_OUTCOMES) { + return null + } + const outcomes = value.map(parseTerminalAuthorityDurableOutcome) + if (outcomes.some((outcome) => !outcome)) { + return null + } + let previousSequence = 0 + for (const outcome of outcomes as TerminalAuthorityDurableOutcome[]) { + if ( + outcome.sequence <= previousSequence || + outcome.sequence > outcomeHighWatermark || + !terminalAuthorityOutcomeMatchesNamespace(outcome, namespace) + ) { + return null + } + previousSequence = outcome.sequence + } + return Object.freeze(outcomes as TerminalAuthorityDurableOutcome[]) +} diff --git a/src/shared/terminal-session-authority-consumer-identity.ts b/src/shared/terminal-session-authority-consumer-identity.ts new file mode 100644 index 00000000000..52a63794b9a --- /dev/null +++ b/src/shared/terminal-session-authority-consumer-identity.ts @@ -0,0 +1,83 @@ +import { assertAuthorityId, isRecord } from './terminal-session-authority-identity' + +export const TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_VERSION = 1 as const + +export type TerminalAuthorityPolicyConsumerIdentity = Readonly<{ + consumerId: string + consumerIncarnationId: string +}> + +export type TerminalAuthorityPolicyConsumerClaim = Readonly<{ + version: typeof TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_VERSION + consumer: TerminalAuthorityPolicyConsumerIdentity + expectedConsumerIncarnationId: string | null +}> + +export function parseTerminalAuthorityPolicyConsumerClaim( + value: unknown +): TerminalAuthorityPolicyConsumerClaim | null { + if (!isVersionedRecord(value)) { + return null + } + const consumer = parseTerminalAuthorityPolicyConsumerIdentity(value.consumer) + if ( + !consumer || + (value.expectedConsumerIncarnationId !== null && + value.expectedConsumerIncarnationId !== undefined && + !isAuthorityId(value.expectedConsumerIncarnationId, 'expectedConsumerIncarnationId')) + ) { + return null + } + return Object.freeze({ + version: TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_VERSION, + consumer, + expectedConsumerIncarnationId: value.expectedConsumerIncarnationId ?? null + }) +} + +export function parseTerminalAuthorityPolicyConsumerIdentity( + value: unknown +): TerminalAuthorityPolicyConsumerIdentity | null { + if (!isRecord(value)) { + return null + } + try { + assertAuthorityId(value.consumerId, 'consumerId') + assertAuthorityId(value.consumerIncarnationId, 'consumerIncarnationId') + } catch { + return null + } + if ( + !value.consumerId.startsWith('app-profile:') && + !value.consumerId.startsWith('paired-device:') + ) { + return null + } + return Object.freeze({ + consumerId: value.consumerId, + consumerIncarnationId: value.consumerIncarnationId + }) +} + +export function sameTerminalAuthorityPolicyConsumer( + left: TerminalAuthorityPolicyConsumerIdentity, + right: TerminalAuthorityPolicyConsumerIdentity +): boolean { + return ( + left.consumerId === right.consumerId && + left.consumerIncarnationId === right.consumerIncarnationId + ) +} + +function isVersionedRecord(value: unknown): value is Record { + return isRecord(value) && value.version === TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_VERSION +} + +export function isAuthorityId(value: unknown, name: string): value is string { + try { + assertAuthorityId(value, name) + return true + } catch { + return false + } +} diff --git a/src/shared/terminal-session-authority-consumer-proof.ts b/src/shared/terminal-session-authority-consumer-proof.ts new file mode 100644 index 00000000000..d7902016b0a --- /dev/null +++ b/src/shared/terminal-session-authority-consumer-proof.ts @@ -0,0 +1,260 @@ +import { + assertAuthorityId, + assertAuthorityNamespace, + isRecord, + type TerminalAuthorityNamespace +} from './terminal-session-authority-identity' +import { + parseTerminalAuthorityPolicyConsumerIdentity, + type TerminalAuthorityPolicyConsumerIdentity +} from './terminal-session-authority-consumer-transport' + +export const TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION = 1 as const +export const TERMINAL_AUTHORITY_CONSUMER_PROOF_ALGORITHM = + 'x25519-hkdf-sha256-hmac-sha256-v1' as const +export const TERMINAL_AUTHORITY_CONSUMER_PROOF_CAPABILITY = + 'terminal-session.authority-consumer-proof.v1' as const + +export type TerminalAuthorityNamespaceAdmissionIntent = 'first' | 'resume' | 'explicit-handover' + +export type TerminalAuthorityNamespaceAdmissionStart = Readonly<{ + version: typeof TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION + algorithm: typeof TERMINAL_AUTHORITY_CONSUMER_PROOF_ALGORITHM + namespace: TerminalAuthorityNamespace + appPublicKeyB64: string + candidateProcessIncarnationId: string + candidateSessionNonce: string + requestId: string + intent: TerminalAuthorityNamespaceAdmissionIntent +}> + +export type TerminalAuthorityNamespaceAdmissionChallenge = + TerminalAuthorityNamespaceAdmissionStart & + Readonly<{ + currentAdmissionCas: string + connectionGrantId: string + authenticatedTransportPrincipal: string + authenticatedTransportCapability: string + hostEphemeralPublicKeyB64: string + expiresAtMs: number + }> + +export type TerminalAuthorityNamespaceAdmissionProof = Readonly<{ + version: typeof TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION + challenge: TerminalAuthorityNamespaceAdmissionChallenge + proofMacB64: string +}> + +export type TerminalAuthorityNamespaceAdmissionGrant = Readonly<{ + version: typeof TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION + consumer: TerminalAuthorityPolicyConsumerIdentity + namespace: TerminalAuthorityNamespace + requestId: string + connectionGrantId: string + admissionCas: string + replayed: boolean +}> + +export type TerminalAuthorityNamespaceAdmissionCancellation = Readonly<{ + version: typeof TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION + consumer: TerminalAuthorityPolicyConsumerIdentity + namespace: TerminalAuthorityNamespace + requestId: string + connectionGrantId: string +}> + +export function parseTerminalAuthorityNamespaceAdmissionStart( + value: unknown +): TerminalAuthorityNamespaceAdmissionStart | null { + if (!isRecord(value) || !hasProofVersion(value)) { + return null + } + try { + assertAuthorityNamespace(value.namespace) + assertEncodedKey(value.appPublicKeyB64, 'appPublicKeyB64') + assertAuthorityId(value.candidateProcessIncarnationId, 'candidateProcessIncarnationId') + assertAuthorityId(value.candidateSessionNonce, 'candidateSessionNonce') + assertAuthorityId(value.requestId, 'requestId') + } catch { + return null + } + if (!isAdmissionIntent(value.intent)) { + return null + } + return Object.freeze({ + version: TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION, + algorithm: TERMINAL_AUTHORITY_CONSUMER_PROOF_ALGORITHM, + namespace: Object.freeze({ ...value.namespace }), + appPublicKeyB64: value.appPublicKeyB64, + candidateProcessIncarnationId: value.candidateProcessIncarnationId, + candidateSessionNonce: value.candidateSessionNonce, + requestId: value.requestId, + intent: value.intent + }) +} + +export function parseTerminalAuthorityNamespaceAdmissionChallenge( + value: unknown +): TerminalAuthorityNamespaceAdmissionChallenge | null { + const start = parseTerminalAuthorityNamespaceAdmissionStart(value) + if (!start || !isRecord(value)) { + return null + } + try { + assertAuthorityId(value.currentAdmissionCas, 'currentAdmissionCas') + assertAuthorityId(value.connectionGrantId, 'connectionGrantId') + assertAuthorityId(value.authenticatedTransportPrincipal, 'authenticatedTransportPrincipal') + assertAuthorityId(value.authenticatedTransportCapability, 'authenticatedTransportCapability') + assertEncodedKey(value.hostEphemeralPublicKeyB64, 'hostEphemeralPublicKeyB64') + } catch { + return null + } + if (!Number.isSafeInteger(value.expiresAtMs) || Number(value.expiresAtMs) < 1) { + return null + } + return Object.freeze({ + ...start, + currentAdmissionCas: value.currentAdmissionCas, + connectionGrantId: value.connectionGrantId, + authenticatedTransportPrincipal: value.authenticatedTransportPrincipal, + authenticatedTransportCapability: value.authenticatedTransportCapability, + hostEphemeralPublicKeyB64: value.hostEphemeralPublicKeyB64, + expiresAtMs: Number(value.expiresAtMs) + }) +} + +export function parseTerminalAuthorityNamespaceAdmissionProof( + value: unknown +): TerminalAuthorityNamespaceAdmissionProof | null { + if (!isRecord(value) || value.version !== TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION) { + return null + } + const challenge = parseTerminalAuthorityNamespaceAdmissionChallenge(value.challenge) + try { + assertEncodedMac(value.proofMacB64) + } catch { + return null + } + return challenge + ? Object.freeze({ + version: TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION, + challenge, + proofMacB64: value.proofMacB64 + }) + : null +} + +export function parseTerminalAuthorityNamespaceAdmissionGrant( + value: unknown +): TerminalAuthorityNamespaceAdmissionGrant | null { + if (!isRecord(value) || value.version !== TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION) { + return null + } + const consumer = parseTerminalAuthorityPolicyConsumerIdentity(value.consumer) + try { + assertAuthorityNamespace(value.namespace) + assertAuthorityId(value.requestId, 'requestId') + assertAuthorityId(value.connectionGrantId, 'connectionGrantId') + assertAuthorityId(value.admissionCas, 'admissionCas') + } catch { + return null + } + if (!consumer || typeof value.replayed !== 'boolean') { + return null + } + return Object.freeze({ + version: TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION, + consumer, + namespace: Object.freeze({ ...value.namespace }), + requestId: value.requestId, + connectionGrantId: value.connectionGrantId, + admissionCas: value.admissionCas, + replayed: value.replayed + }) +} + +export function parseTerminalAuthorityNamespaceAdmissionCancellation( + value: unknown +): TerminalAuthorityNamespaceAdmissionCancellation | null { + if (!isRecord(value) || value.version !== TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION) { + return null + } + const consumer = parseTerminalAuthorityPolicyConsumerIdentity(value.consumer) + try { + assertAuthorityNamespace(value.namespace) + assertAuthorityId(value.requestId, 'requestId') + assertAuthorityId(value.connectionGrantId, 'connectionGrantId') + } catch { + return null + } + return consumer + ? Object.freeze({ + version: TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION, + consumer, + namespace: Object.freeze({ ...value.namespace }), + requestId: value.requestId, + connectionGrantId: value.connectionGrantId + }) + : null +} + +export function encodeTerminalAuthorityConsumerProofTranscript( + challenge: TerminalAuthorityNamespaceAdmissionChallenge +): Uint8Array { + const fields: readonly (readonly [string, string])[] = [ + ['protocol', TERMINAL_AUTHORITY_CONSUMER_PROOF_CAPABILITY], + ['version', String(challenge.version)], + ['algorithm', challenge.algorithm], + ['authenticatedTransportPrincipal', challenge.authenticatedTransportPrincipal], + ['authenticatedTransportCapability', challenge.authenticatedTransportCapability], + ['authorityHostId', challenge.namespace.authorityHostId], + ['namespaceId', challenge.namespace.namespaceId], + ['currentAdmissionCas', challenge.currentAdmissionCas], + ['candidateProcessIncarnationId', challenge.candidateProcessIncarnationId], + ['candidateSessionNonce', challenge.candidateSessionNonce], + ['requestId', challenge.requestId], + ['connectionGrantId', challenge.connectionGrantId], + ['hostEphemeralPublicKeyB64', challenge.hostEphemeralPublicKeyB64], + ['intent', challenge.intent], + ['appPublicKeyB64', challenge.appPublicKeyB64], + ['expiresAtMs', String(challenge.expiresAtMs)] + ] + const encoder = new TextEncoder() + return encoder.encode( + fields + .map( + ([name, fieldValue]) => + `${encoder.encode(name).byteLength}:${name}${encoder.encode(fieldValue).byteLength}:${fieldValue}` + ) + .join('') + ) +} + +function hasProofVersion(value: Record): boolean { + return ( + value.version === TERMINAL_AUTHORITY_CONSUMER_PROOF_VERSION && + value.algorithm === TERMINAL_AUTHORITY_CONSUMER_PROOF_ALGORITHM + ) +} + +function isAdmissionIntent(value: unknown): value is TerminalAuthorityNamespaceAdmissionIntent { + return value === 'first' || value === 'resume' || value === 'explicit-handover' +} + +function assertEncodedKey(value: unknown, field: string): asserts value is string { + assertEncodedBytes(value, field, 32) +} + +function assertEncodedMac(value: unknown): asserts value is string { + assertEncodedBytes(value, 'proofMacB64', 32) +} + +function assertEncodedBytes(value: unknown, field: string, bytes: number): asserts value is string { + if (typeof value !== 'string' || value.length !== 44 || !/^[A-Za-z0-9+/]{43}=$/.test(value)) { + throw new Error(`${field} is invalid`) + } + const decoded = Buffer.from(value, 'base64') + if (decoded.length !== bytes || decoded.toString('base64') !== value) { + throw new Error(`${field} is invalid`) + } +} diff --git a/src/shared/terminal-session-authority-consumer-retirement.ts b/src/shared/terminal-session-authority-consumer-retirement.ts new file mode 100644 index 00000000000..98914f38708 --- /dev/null +++ b/src/shared/terminal-session-authority-consumer-retirement.ts @@ -0,0 +1,270 @@ +import { + assertAuthorityId, + assertAuthorityNamespace, + isRecord, + type TerminalAuthorityNamespace +} from './terminal-session-authority-identity' + +export const TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_VERSION = 1 as const +export const TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_ALGORITHM = + 'x25519-hkdf-sha256-hmac-sha256-retirement-v1' as const +export const TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_CAPABILITY = + 'terminal-session.authority-consumer-retirement.v1' as const + +export type TerminalAuthorityConsumerRetirementStart = Readonly<{ + version: typeof TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_VERSION + algorithm: typeof TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_ALGORITHM + namespace: TerminalAuthorityNamespace + appPublicKeyB64: string + candidateProcessIncarnationId: string + candidateSessionNonce: string + requestId: string +}> + +export type TerminalAuthorityConsumerRetirementLiveAdmission = Readonly<{ + requestId: string + processIncarnationId: string + sessionNonce: string +}> + +export type TerminalAuthorityConsumerRetirementChallenge = + TerminalAuthorityConsumerRetirementStart & + Readonly<{ + consumerId: string + currentConsumerIncarnationId: string | null + retirementCas: string + connectionGrantId: string + liveAdmission: TerminalAuthorityConsumerRetirementLiveAdmission | null + authenticatedTransportPrincipal: string + authenticatedTransportCapability: string + hostEphemeralPublicKeyB64: string + expiresAtMs: number + }> + +export type TerminalAuthorityConsumerRetirementProof = Readonly<{ + version: typeof TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_VERSION + challenge: TerminalAuthorityConsumerRetirementChallenge + proofMacB64: string +}> + +export type TerminalAuthorityConsumerRetirementResult = Readonly<{ + version: typeof TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_VERSION + namespace: TerminalAuthorityNamespace + consumerId: string + retiredConsumerIncarnationId: string | null + requestId: string + candidateProcessIncarnationId: string + candidateSessionNonce: string + connectionGrantId: string + retirementCas: string + retired: true + alreadyAbsent: boolean + replayed: boolean +}> + +export function parseTerminalAuthorityConsumerRetirementStart( + value: unknown +): TerminalAuthorityConsumerRetirementStart | null { + if ( + !isRetirementRecord(value) || + value.algorithm !== TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_ALGORITHM + ) { + return null + } + try { + assertAuthorityNamespace(value.namespace) + assertEncodedBytes(value.appPublicKeyB64, 'appPublicKeyB64', 32) + assertAuthorityId(value.candidateProcessIncarnationId, 'candidateProcessIncarnationId') + assertAuthorityId(value.candidateSessionNonce, 'candidateSessionNonce') + assertAuthorityId(value.requestId, 'requestId') + } catch { + return null + } + return Object.freeze({ + version: TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_VERSION, + algorithm: TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_ALGORITHM, + namespace: Object.freeze({ ...value.namespace }), + appPublicKeyB64: value.appPublicKeyB64, + candidateProcessIncarnationId: value.candidateProcessIncarnationId, + candidateSessionNonce: value.candidateSessionNonce, + requestId: value.requestId + }) +} + +export function parseTerminalAuthorityConsumerRetirementChallenge( + value: unknown +): TerminalAuthorityConsumerRetirementChallenge | null { + const start = parseTerminalAuthorityConsumerRetirementStart(value) + if (!start || !isRecord(value)) { + return null + } + try { + assertAuthorityId(value.consumerId, 'consumerId') + if (value.currentConsumerIncarnationId !== null) { + assertAuthorityId(value.currentConsumerIncarnationId, 'currentConsumerIncarnationId') + } + assertAuthorityId(value.retirementCas, 'retirementCas') + assertAuthorityId(value.connectionGrantId, 'connectionGrantId') + assertAuthorityId(value.authenticatedTransportPrincipal, 'authenticatedTransportPrincipal') + assertAuthorityId(value.authenticatedTransportCapability, 'authenticatedTransportCapability') + assertEncodedBytes(value.hostEphemeralPublicKeyB64, 'hostEphemeralPublicKeyB64', 32) + } catch { + return null + } + const liveAdmission = parseLiveAdmission(value.liveAdmission) + if ( + (value.liveAdmission !== null && !liveAdmission) || + !Number.isSafeInteger(value.expiresAtMs) || + Number(value.expiresAtMs) < 1 + ) { + return null + } + return Object.freeze({ + ...start, + consumerId: value.consumerId, + currentConsumerIncarnationId: value.currentConsumerIncarnationId, + retirementCas: value.retirementCas, + connectionGrantId: value.connectionGrantId, + liveAdmission, + authenticatedTransportPrincipal: value.authenticatedTransportPrincipal, + authenticatedTransportCapability: value.authenticatedTransportCapability, + hostEphemeralPublicKeyB64: value.hostEphemeralPublicKeyB64, + expiresAtMs: Number(value.expiresAtMs) + }) +} + +export function parseTerminalAuthorityConsumerRetirementProof( + value: unknown +): TerminalAuthorityConsumerRetirementProof | null { + if (!isRetirementRecord(value)) { + return null + } + const challenge = parseTerminalAuthorityConsumerRetirementChallenge(value.challenge) + try { + assertEncodedBytes(value.proofMacB64, 'proofMacB64', 32) + } catch { + return null + } + return challenge + ? Object.freeze({ + version: TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_VERSION, + challenge, + proofMacB64: value.proofMacB64 + }) + : null +} + +export function parseTerminalAuthorityConsumerRetirementResult( + value: unknown +): TerminalAuthorityConsumerRetirementResult | null { + if (!isRetirementRecord(value)) { + return null + } + try { + assertAuthorityNamespace(value.namespace) + assertAuthorityId(value.consumerId, 'consumerId') + if (value.retiredConsumerIncarnationId !== null) { + assertAuthorityId(value.retiredConsumerIncarnationId, 'retiredConsumerIncarnationId') + } + assertAuthorityId(value.requestId, 'requestId') + assertAuthorityId(value.candidateProcessIncarnationId, 'candidateProcessIncarnationId') + assertAuthorityId(value.candidateSessionNonce, 'candidateSessionNonce') + assertAuthorityId(value.connectionGrantId, 'connectionGrantId') + assertAuthorityId(value.retirementCas, 'retirementCas') + } catch { + return null + } + if ( + value.retired !== true || + typeof value.alreadyAbsent !== 'boolean' || + typeof value.replayed !== 'boolean' || + value.alreadyAbsent !== (value.retiredConsumerIncarnationId === null) + ) { + return null + } + return Object.freeze({ + version: TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_VERSION, + namespace: Object.freeze({ ...value.namespace }), + consumerId: value.consumerId, + retiredConsumerIncarnationId: value.retiredConsumerIncarnationId, + requestId: value.requestId, + candidateProcessIncarnationId: value.candidateProcessIncarnationId, + candidateSessionNonce: value.candidateSessionNonce, + connectionGrantId: value.connectionGrantId, + retirementCas: value.retirementCas, + retired: true, + alreadyAbsent: value.alreadyAbsent, + replayed: value.replayed + }) +} + +export function encodeTerminalAuthorityConsumerRetirementTranscript( + challenge: TerminalAuthorityConsumerRetirementChallenge +): Uint8Array { + const live = challenge.liveAdmission + const fields: readonly (readonly [string, string])[] = [ + ['protocol', TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_CAPABILITY], + ['version', String(challenge.version)], + ['algorithm', challenge.algorithm], + ['authenticatedTransportPrincipal', challenge.authenticatedTransportPrincipal], + ['authenticatedTransportCapability', challenge.authenticatedTransportCapability], + ['authorityHostId', challenge.namespace.authorityHostId], + ['namespaceId', challenge.namespace.namespaceId], + ['consumerId', challenge.consumerId], + ['currentConsumerIncarnationId', challenge.currentConsumerIncarnationId ?? ''], + ['retirementCas', challenge.retirementCas], + ['candidateProcessIncarnationId', challenge.candidateProcessIncarnationId], + ['candidateSessionNonce', challenge.candidateSessionNonce], + ['requestId', challenge.requestId], + ['connectionGrantId', challenge.connectionGrantId], + ['liveAdmissionRequestId', live?.requestId ?? ''], + ['liveAdmissionProcessIncarnationId', live?.processIncarnationId ?? ''], + ['liveAdmissionSessionNonce', live?.sessionNonce ?? ''], + ['hostEphemeralPublicKeyB64', challenge.hostEphemeralPublicKeyB64], + ['appPublicKeyB64', challenge.appPublicKeyB64], + ['expiresAtMs', String(challenge.expiresAtMs)] + ] + const encoder = new TextEncoder() + return encoder.encode( + fields + .map( + ([name, fieldValue]) => + `${encoder.encode(name).byteLength}:${name}${encoder.encode(fieldValue).byteLength}:${fieldValue}` + ) + .join('') + ) +} + +function parseLiveAdmission( + value: unknown +): TerminalAuthorityConsumerRetirementLiveAdmission | null { + if (!isRecord(value)) { + return null + } + try { + assertAuthorityId(value.requestId, 'live admission requestId') + assertAuthorityId(value.processIncarnationId, 'live admission processIncarnationId') + assertAuthorityId(value.sessionNonce, 'live admission sessionNonce') + } catch { + return null + } + return Object.freeze({ + requestId: value.requestId, + processIncarnationId: value.processIncarnationId, + sessionNonce: value.sessionNonce + }) +} + +function isRetirementRecord(value: unknown): value is Record { + return isRecord(value) && value.version === TERMINAL_AUTHORITY_CONSUMER_RETIREMENT_VERSION +} + +function assertEncodedBytes(value: unknown, field: string, bytes: number): asserts value is string { + if (typeof value !== 'string' || value.length !== 44 || !/^[A-Za-z0-9+/]{43}=$/.test(value)) { + throw new Error(`${field} is invalid`) + } + const decoded = Buffer.from(value, 'base64') + if (decoded.length !== bytes || decoded.toString('base64') !== value) { + throw new Error(`${field} is invalid`) + } +} diff --git a/src/shared/terminal-session-authority-consumer-transport.test.ts b/src/shared/terminal-session-authority-consumer-transport.test.ts new file mode 100644 index 00000000000..f1e327b9d5a --- /dev/null +++ b/src/shared/terminal-session-authority-consumer-transport.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest' +import type { TerminalAuthorityDurableOutcome } from './terminal-session-authority-mutation' +import { terminalAuthorityOutcomeByteLength } from './terminal-session-authority-record-validation' +import { + parseTerminalAuthorityNamespaceOutcomeBoundary, + type TerminalAuthorityNamespaceOutcomeBoundary +} from './terminal-session-authority-consumer-transport' + +const namespace = Object.freeze({ authorityHostId: 'host-1', namespaceId: 'namespace-1' }) + +describe('parseTerminalAuthorityNamespaceOutcomeBoundary', () => { + it('accepts an authenticated projection at its exact namespace and high-water mark', () => { + expect(parseTerminalAuthorityNamespaceOutcomeBoundary(boundary())).toEqual(boundary()) + }) + + it('rejects a projection from another namespace', () => { + const value = boundary() + const projection = { + ...value.projection!, + namespace: { ...namespace, namespaceId: 'namespace-2' } + } + + expect(parseTerminalAuthorityNamespaceOutcomeBoundary({ ...value, projection })).toBeNull() + }) + + it('rejects materialized outcomes newer than the authenticated high-water mark', () => { + const value = boundary() + const projection = { ...value.projection!, materializedOutcomes: [semanticOutcome(8)] } + + expect(parseTerminalAuthorityNamespaceOutcomeBoundary({ ...value, projection })).toBeNull() + }) +}) + +function boundary(): TerminalAuthorityNamespaceOutcomeBoundary { + return Object.freeze({ + version: 1, + consumer: Object.freeze({ + consumerId: 'app-profile:test', + consumerIncarnationId: 'app-process:test' + }), + namespace, + acknowledgedSequence: 7, + outcomeHighWatermark: 7, + boundaryId: 'boundary-1', + consumerStart: 'new-at-tail', + projection: Object.freeze({ + namespace, + writerEpoch: 1, + revision: 7, + panes: Object.freeze([]), + allocations: Object.freeze([]), + materializedOutcomes: Object.freeze([]) + }) + }) +} + +function semanticOutcome(sequence: number): TerminalAuthorityDurableOutcome { + const base = { + kind: 'semantic' as const, + sequence, + outcomeId: `semantic-${sequence}`, + access: { + namespace, + pane: { paneKey: 'pane-1', paneGenerationId: 'generation-1' }, + binding: { + ownerIncarnationId: 'owner-1', + physicalPtyId: 'pty-1', + ptyIncarnationId: 'pty-incarnation-1' + } + }, + producerIncarnationId: 'producer-1', + producerSequence: sequence, + fact: { kind: 'bell' as const }, + appendedAtRevision: sequence + } + return Object.freeze({ ...base, byteLength: terminalAuthorityOutcomeByteLength(base) }) +} diff --git a/src/shared/terminal-session-authority-consumer-transport.ts b/src/shared/terminal-session-authority-consumer-transport.ts new file mode 100644 index 00000000000..84f1f014b05 --- /dev/null +++ b/src/shared/terminal-session-authority-consumer-transport.ts @@ -0,0 +1,257 @@ +import { + assertAuthorityId, + assertAuthorityNamespace, + isRecord, + type TerminalAuthorityNamespace +} from './terminal-session-authority-identity' +import { + TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_VERSION, + isAuthorityId, + parseTerminalAuthorityPolicyConsumerClaim, + parseTerminalAuthorityPolicyConsumerIdentity, + sameTerminalAuthorityPolicyConsumer, + type TerminalAuthorityPolicyConsumerClaim, + type TerminalAuthorityPolicyConsumerIdentity +} from './terminal-session-authority-consumer-identity' +import type { + TerminalAuthorityDurableOutcome, + TerminalAuthorityProjection +} from './terminal-session-authority-mutation' +import { parseTerminalAuthorityDurableOutcome } from './terminal-authority-durable-outcome-validation' +import { + parseTerminalAuthorityBoundaryProjection, + terminalAuthorityOutcomeMatchesNamespace +} from './terminal-session-authority-boundary-projection-validation' +import { parseTerminalAuthorityOutcomePage } from './terminal-session-authority-outcome-page-validation' + +export const TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_CAPABILITY = + 'terminal-session.authority-namespace-outcomes.v1' +export const TERMINAL_AUTHORITY_NAMESPACE_BOUNDARY_NOTIFICATION = + 'terminalAuthority.namespaceOutcomeBoundary' +export const TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_NOTIFICATION = + 'terminalAuthority.namespaceOutcome' +export const TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_ACK_METHOD = + 'terminalAuthority.ackNamespaceOutcome' +export const TERMINAL_AUTHORITY_NAMESPACE_BOUNDARY_ACCEPT_METHOD = + 'terminalAuthority.acceptNamespaceOutcomeBoundary' +export const TERMINAL_AUTHORITY_POLICY_CONSUMER_RETIRE_METHOD = + 'terminalAuthority.retirePolicyConsumer' + +export { + TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_VERSION, + parseTerminalAuthorityPolicyConsumerClaim, + parseTerminalAuthorityPolicyConsumerIdentity, + sameTerminalAuthorityPolicyConsumer +} +export type { TerminalAuthorityPolicyConsumerClaim, TerminalAuthorityPolicyConsumerIdentity } + +export type TerminalAuthorityNamespaceOutcomeBoundary = Readonly<{ + version: typeof TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_VERSION + consumer: TerminalAuthorityPolicyConsumerIdentity + namespace: TerminalAuthorityNamespace + acknowledgedSequence: number + outcomeHighWatermark: number + /** Required by authoritative consumers; optional only for structural old-peer parsing. */ + boundaryId?: string + /** Optional on the wire; authoritative app initialization requires host attestation. */ + consumerStart?: 'new-at-tail' | 'resume' + /** Optional for mixed versions; app consumers require it before ACKing. */ + projection?: TerminalAuthorityProjection +}> + +export type TerminalAuthorityNamespaceBoundaryAcceptance = Readonly<{ + version: typeof TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_VERSION + consumer: TerminalAuthorityPolicyConsumerIdentity + namespace: TerminalAuthorityNamespace + boundaryId: string + acknowledgedSequence: number + outcomeHighWatermark: number +}> + +export type TerminalAuthorityNamespaceOutcomePublication = Readonly<{ + version: typeof TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_VERSION + consumer: TerminalAuthorityPolicyConsumerIdentity + namespace: TerminalAuthorityNamespace + previousSequence: number + outcome: TerminalAuthorityDurableOutcome + /** Optional bounded page; omission represents the required one-entry page. */ + outcomes?: readonly TerminalAuthorityDurableOutcome[] +}> + +export type TerminalAuthorityNamespaceOutcomeAck = Readonly<{ + version: typeof TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_VERSION + consumer: TerminalAuthorityPolicyConsumerIdentity + namespace: TerminalAuthorityNamespace + sequence: number + outcomeId: string +}> + +export type TerminalAuthorityPolicyConsumerRetirement = Readonly<{ + version: typeof TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_VERSION + consumer: TerminalAuthorityPolicyConsumerIdentity +}> + +export function parseTerminalAuthorityNamespaceOutcomeBoundary( + value: unknown +): TerminalAuthorityNamespaceOutcomeBoundary | null { + if (!isVersionedRecord(value)) { + return null + } + const consumer = parseTerminalAuthorityPolicyConsumerIdentity(value.consumer) + try { + assertAuthorityNamespace(value.namespace) + } catch { + return null + } + if ( + !consumer || + (value.consumerStart !== undefined && + value.consumerStart !== 'new-at-tail' && + value.consumerStart !== 'resume') || + !isSequence(value.acknowledgedSequence, true) || + !isSequence(value.outcomeHighWatermark, true) || + Number(value.acknowledgedSequence) > Number(value.outcomeHighWatermark) || + (value.consumerStart === 'new-at-tail' && + value.acknowledgedSequence !== value.outcomeHighWatermark) || + (value.boundaryId !== undefined && !isAuthorityId(value.boundaryId, 'boundaryId')) + ) { + return null + } + const namespace = Object.freeze({ ...value.namespace }) + const projection = + value.projection === undefined + ? undefined + : parseTerminalAuthorityBoundaryProjection( + value.projection, + namespace, + Number(value.outcomeHighWatermark) + ) + if (value.projection !== undefined && !projection) { + return null + } + return Object.freeze({ + version: TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_VERSION, + consumer, + namespace, + acknowledgedSequence: Number(value.acknowledgedSequence), + outcomeHighWatermark: Number(value.outcomeHighWatermark), + ...(typeof value.boundaryId === 'string' ? { boundaryId: value.boundaryId } : {}), + ...(value.consumerStart ? { consumerStart: value.consumerStart } : {}), + ...(projection ? { projection } : {}) + }) +} + +export function parseTerminalAuthorityNamespaceBoundaryAcceptance( + value: unknown +): TerminalAuthorityNamespaceBoundaryAcceptance | null { + if (!isVersionedRecord(value)) { + return null + } + const consumer = parseTerminalAuthorityPolicyConsumerIdentity(value.consumer) + try { + assertAuthorityNamespace(value.namespace) + assertAuthorityId(value.boundaryId, 'boundaryId') + } catch { + return null + } + if ( + !consumer || + !isSequence(value.acknowledgedSequence, true) || + !isSequence(value.outcomeHighWatermark, true) || + Number(value.acknowledgedSequence) > Number(value.outcomeHighWatermark) + ) { + return null + } + return Object.freeze({ + version: TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_VERSION, + consumer, + namespace: Object.freeze({ ...value.namespace }), + boundaryId: value.boundaryId, + acknowledgedSequence: Number(value.acknowledgedSequence), + outcomeHighWatermark: Number(value.outcomeHighWatermark) + }) +} + +export function parseTerminalAuthorityNamespaceOutcomePublication( + value: unknown +): TerminalAuthorityNamespaceOutcomePublication | null { + if (!isVersionedRecord(value)) { + return null + } + const consumer = parseTerminalAuthorityPolicyConsumerIdentity(value.consumer) + const outcome = parseTerminalAuthorityDurableOutcome(value.outcome) + try { + assertAuthorityNamespace(value.namespace) + } catch { + return null + } + if ( + !consumer || + !outcome || + !isSequence(value.previousSequence, true) || + outcome.sequence !== Number(value.previousSequence) + 1 || + !terminalAuthorityOutcomeMatchesNamespace(outcome, value.namespace) + ) { + return null + } + const outcomes = + value.outcomes === undefined + ? null + : parseTerminalAuthorityOutcomePage(value.outcomes, outcome, value.namespace) + if (value.outcomes !== undefined && !outcomes) { + return null + } + return Object.freeze({ + version: TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_VERSION, + consumer, + namespace: Object.freeze({ ...value.namespace }), + previousSequence: Number(value.previousSequence), + outcome, + ...(outcomes ? { outcomes } : {}) + }) +} + +export function parseTerminalAuthorityNamespaceOutcomeAck( + value: unknown +): TerminalAuthorityNamespaceOutcomeAck | null { + if (!isVersionedRecord(value)) { + return null + } + const consumer = parseTerminalAuthorityPolicyConsumerIdentity(value.consumer) + try { + assertAuthorityNamespace(value.namespace) + assertAuthorityId(value.outcomeId, 'outcomeId') + } catch { + return null + } + if (!consumer || !isSequence(value.sequence, false)) { + return null + } + return Object.freeze({ + version: TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_VERSION, + consumer, + namespace: Object.freeze({ ...value.namespace }), + sequence: Number(value.sequence), + outcomeId: value.outcomeId + }) +} + +export function parseTerminalAuthorityPolicyConsumerRetirement( + value: unknown +): TerminalAuthorityPolicyConsumerRetirement | null { + if (!isVersionedRecord(value)) { + return null + } + const consumer = parseTerminalAuthorityPolicyConsumerIdentity(value.consumer) + return consumer + ? Object.freeze({ version: TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_VERSION, consumer }) + : null +} + +function isVersionedRecord(value: unknown): value is Record { + return isRecord(value) && value.version === TERMINAL_AUTHORITY_NAMESPACE_OUTCOME_VERSION +} + +function isSequence(value: unknown, allowZero: boolean): value is number { + return Number.isSafeInteger(value) && Number(value) >= (allowZero ? 0 : 1) +} diff --git a/src/shared/terminal-session-authority-identity.ts b/src/shared/terminal-session-authority-identity.ts new file mode 100644 index 00000000000..e7faec1aedf --- /dev/null +++ b/src/shared/terminal-session-authority-identity.ts @@ -0,0 +1,103 @@ +export type TerminalAuthorityNamespace = Readonly<{ + authorityHostId: string + namespaceId: string +}> + +export type TerminalPaneGeneration = Readonly<{ + paneKey: string + paneGenerationId: string +}> + +export type TerminalSessionBinding = Readonly<{ + ownerIncarnationId: string + physicalPtyId: string + ptyIncarnationId: string +}> + +export type TerminalAuthorityWriterIdentity = Readonly<{ + ownerToken: string + epoch: number +}> + +const MAX_AUTHORITY_ID_BYTES = 1_024 +const MAX_AUTHORITY_STORAGE_PATH_BYTES = 32 * 1_024 +const UNSAFE_AUTHORITY_TEXT = /[\p{Cc}\u2028\u2029]/u + +export function assertAuthorityId(value: unknown, field: string): asserts value is string { + if ( + typeof value !== 'string' || + value.trim().length === 0 || + UNSAFE_AUTHORITY_TEXT.test(value) || + new TextEncoder().encode(value).byteLength > MAX_AUTHORITY_ID_BYTES + ) { + throw new Error(`${field} is invalid`) + } +} + +export function assertAuthorityStoragePath(value: unknown, field: string): asserts value is string { + if ( + typeof value !== 'string' || + value.length === 0 || + value.includes('\0') || + value.includes('\r') || + value.includes('\n') || + new TextEncoder().encode(value).byteLength > MAX_AUTHORITY_STORAGE_PATH_BYTES + ) { + throw new Error(`${field} is invalid`) + } +} + +export function assertAuthorityNamespace( + value: unknown +): asserts value is TerminalAuthorityNamespace { + if (!isRecord(value)) { + throw new Error('authority namespace is invalid') + } + assertAuthorityId(value.authorityHostId, 'authorityHostId') + assertAuthorityId(value.namespaceId, 'namespaceId') +} + +export function assertPaneGeneration(value: unknown): asserts value is TerminalPaneGeneration { + if (!isRecord(value)) { + throw new Error('pane generation is invalid') + } + assertAuthorityId(value.paneKey, 'paneKey') + assertAuthorityId(value.paneGenerationId, 'paneGenerationId') +} + +export function assertTerminalBinding(value: unknown): asserts value is TerminalSessionBinding { + if (!isRecord(value)) { + throw new Error('terminal binding is invalid') + } + assertAuthorityId(value.ownerIncarnationId, 'ownerIncarnationId') + assertAuthorityId(value.physicalPtyId, 'physicalPtyId') + assertAuthorityId(value.ptyIncarnationId, 'ptyIncarnationId') +} + +export function terminalPaneGenerationKey(value: TerminalPaneGeneration): string { + return JSON.stringify([value.paneKey, value.paneGenerationId]) +} + +export function terminalPtyIncarnationKey(value: TerminalSessionBinding): string { + return JSON.stringify([value.ownerIncarnationId, value.physicalPtyId, value.ptyIncarnationId]) +} + +export function sameTerminalBinding( + left: TerminalSessionBinding | null, + right: TerminalSessionBinding | null +): boolean { + return ( + left === right || + Boolean( + left && + right && + left.ownerIncarnationId === right.ownerIncarnationId && + left.physicalPtyId === right.physicalPtyId && + left.ptyIncarnationId === right.ptyIncarnationId + ) + ) +} + +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/src/shared/terminal-session-authority-legacy-admission.ts b/src/shared/terminal-session-authority-legacy-admission.ts new file mode 100644 index 00000000000..ed1709efe29 --- /dev/null +++ b/src/shared/terminal-session-authority-legacy-admission.ts @@ -0,0 +1,123 @@ +import { isDeepStrictEqual } from 'node:util' +import type { + TerminalLegacyMigrationImportRequest, + TerminalLegacyRecoveryProjection, + TerminalLegacyWorkerRoute +} from './terminal-legacy-cutover' +import { failTerminalSessionAuthority } from './terminal-session-authority-mutation' +import { + legacyImportedPaneKey, + legacyPhysicalPtyKey, + legacyRouteMatchesCutover +} from './terminal-session-authority-legacy-transition' +import type { TerminalAuthorityLegacyStateLimits } from './terminal-session-authority-legacy-state' + +type TerminalAuthorityLegacyAdmissionView = Readonly<{ + limits: TerminalAuthorityLegacyStateLimits + migrationCount: number + workersByRoute: ReadonlyMap + routeByWorker: ReadonlyMap + routeByOwner: ReadonlyMap + recoveriesById: ReadonlyMap + recoveryByPhysicalPty: ReadonlyMap + importedPaneOwners: ReadonlyMap +}> + +export function assertTerminalAuthorityLegacyMigrationAdmission( + request: TerminalLegacyMigrationImportRequest, + view: TerminalAuthorityLegacyAdmissionView +): void { + assertCapacity(request, view) + if (request.mode === 'acknowledge') { + const existing = view.recoveriesById.get(request.recoveryId) + if ( + existing?.status !== 'unresolved' || + existing.catalogReceiptId !== request.expectedCatalogReceiptId + ) { + failTerminalSessionAuthority( + 'expectation-mismatch', + 'legacy recovery changed before acknowledgement' + ) + } + return + } + assertWorker(request, view) + assertCandidates(request, view) +} + +function assertCapacity( + request: TerminalLegacyMigrationImportRequest, + view: TerminalAuthorityLegacyAdmissionView +): void { + const newRows = [...request.imports, ...request.unresolved].filter( + (candidate) => !view.recoveriesById.has(candidate.recoveryId) + ).length + if ( + view.migrationCount >= view.limits.migrations || + (request.mode === 'cutover' && + !view.workersByRoute.has(request.workerRoute.routeId) && + view.workersByRoute.size >= view.limits.workers) || + view.recoveriesById.size + newRows > view.limits.recoveries + ) { + failTerminalSessionAuthority('capacity', 'legacy authority state capacity was exceeded') + } +} + +function assertWorker( + request: Exclude, + view: TerminalAuthorityLegacyAdmissionView +): void { + if (request.mode !== 'cutover') { + return + } + const route = request.workerRoute + const existingRoute = view.workersByRoute.get(route.routeId) + const existingWorkerRoute = view.routeByWorker.get(route.workerId) + const existingOwnerRoute = view.routeByOwner.get(route.ownerIncarnationId) + if ( + (existingRoute !== undefined && !isDeepStrictEqual(existingRoute, route)) || + (existingWorkerRoute !== undefined && existingWorkerRoute !== route.routeId) || + (existingOwnerRoute !== undefined && existingOwnerRoute !== route.routeId) || + !legacyRouteMatchesCutover(route, request) + ) { + failTerminalSessionAuthority('operation-conflict', 'legacy worker route is not unique') + } +} + +function assertCandidates( + request: Exclude, + view: TerminalAuthorityLegacyAdmissionView +): void { + const workerId = + request.mode === 'cutover' ? request.workerRoute.workerId : request.workerEvidence.workerId + const recoveryIds = new Set() + const physicalPtys = new Set() + const panes = new Set() + for (const candidate of [...request.imports, ...request.unresolved]) { + const physicalKey = legacyPhysicalPtyKey(candidate) + const existing = view.recoveriesById.get(candidate.recoveryId) + const physicalOwner = view.recoveryByPhysicalPty.get(physicalKey) + const updatesExisting = + existing?.status === 'unresolved' && + physicalOwner === candidate.recoveryId && + legacyPhysicalPtyKey(existing) === physicalKey + if ( + candidate.physicalPty.workerId !== workerId || + recoveryIds.has(candidate.recoveryId) || + (existing !== undefined && !updatesExisting) || + physicalPtys.has(physicalKey) || + (physicalOwner !== undefined && physicalOwner !== candidate.recoveryId) + ) { + failTerminalSessionAuthority('operation-conflict', 'legacy recovery identity is not unique') + } + recoveryIds.add(candidate.recoveryId) + physicalPtys.add(physicalKey) + if ('pane' in candidate) { + const paneKey = legacyImportedPaneKey(candidate) + if (panes.has(paneKey) || view.importedPaneOwners.has(paneKey)) { + failTerminalSessionAuthority('operation-conflict', 'legacy imported pane is duplicated') + } + panes.add(paneKey) + } + } +} diff --git a/src/shared/terminal-session-authority-legacy-mutation-fence.ts b/src/shared/terminal-session-authority-legacy-mutation-fence.ts new file mode 100644 index 00000000000..c1d2f99691a --- /dev/null +++ b/src/shared/terminal-session-authority-legacy-mutation-fence.ts @@ -0,0 +1,109 @@ +import type { + TerminalLegacyRecoveryProjection, + TerminalLegacyUnresolvedCandidate, + TerminalLegacyUnresolvedRecovery +} from './terminal-legacy-cutover' +import type { TerminalSessionBinding } from './terminal-session-authority-identity' +import { + failTerminalSessionAuthority, + type TerminalPaneAuthorityRecord, + type TerminalSessionAuthorityErrorCode, + type TerminalSessionAuthorityMutationRequest, + type TerminalSessionPtyAllocation, + type TerminalSessionPtyAllocationIdentity +} from './terminal-session-authority-mutation' + +type LegacyUnresolvedIdentity = Pick< + TerminalLegacyUnresolvedCandidate | TerminalLegacyUnresolvedRecovery, + 'physicalPty' | 'inventoryEvidence' +> + +type LegacyWorkerOwner = (workerId: string) => string | null + +export function assertTerminalAuthorityLegacyMutationAllowed( + request: TerminalSessionAuthorityMutationRequest, + recoveries: Iterable, + workerOwner: LegacyWorkerOwner +): void { + const pane = 'allocation' in request.change ? request.change.allocation.pane : request.change.pane + const allocation = 'allocation' in request.change ? request.change.allocation : null + const binding = 'expected' in request.change ? request.change.expected.binding : null + for (const recovery of recoveries) { + if (recovery.status !== 'unresolved') { + continue + } + if (recovery.inventoryEvidence.paneKey === pane.paneKey) { + failTerminalSessionAuthority( + 'expectation-mismatch', + 'unresolved legacy recovery fences the pane identity' + ) + } + if (allocation && physicalPtyMatches(recovery, allocation, workerOwner)) { + failTerminalSessionAuthority( + 'allocation-conflict', + 'unresolved legacy recovery fences the physical PTY identity' + ) + } + if (binding && bindingMatches(recovery, binding, workerOwner)) { + failTerminalSessionAuthority( + 'expectation-mismatch', + 'unresolved legacy recovery fences the PTY binding identity' + ) + } + } +} + +export function assertTerminalAuthorityLegacyTopologyAllowed( + unresolved: readonly LegacyUnresolvedIdentity[], + importedPaneKeys: readonly string[], + panes: readonly TerminalPaneAuthorityRecord[], + allocations: readonly TerminalSessionPtyAllocation[], + workerOwner: LegacyWorkerOwner, + errorCode: TerminalSessionAuthorityErrorCode +): void { + for (const recovery of unresolved) { + const paneConflict = + panes.some( + (pane) => + recovery.inventoryEvidence.paneKey === pane.paneKey || + (pane.binding !== null && bindingMatches(recovery, pane.binding, workerOwner)) + ) || importedPaneKeys.includes(recovery.inventoryEvidence.paneKey ?? '') + const allocationConflict = allocations.some((allocation) => + physicalPtyMatches(recovery, allocation, workerOwner) + ) + if (paneConflict || allocationConflict) { + failTerminalSessionAuthority( + errorCode, + 'unresolved legacy recovery conflicts with authoritative topology' + ) + } + } +} + +function physicalPtyMatches( + recovery: LegacyUnresolvedIdentity, + allocation: TerminalSessionPtyAllocationIdentity, + workerOwner: LegacyWorkerOwner +): boolean { + const ownerIncarnationId = workerOwner(recovery.physicalPty.workerId) + return ( + ownerIncarnationId !== null && + allocation.ownerIncarnationId === ownerIncarnationId && + allocation.physicalPtyId === recovery.physicalPty.physicalPtyId + ) +} + +function bindingMatches( + recovery: LegacyUnresolvedIdentity, + binding: TerminalSessionBinding, + workerOwner: LegacyWorkerOwner +): boolean { + const ownerIncarnationId = workerOwner(recovery.physicalPty.workerId) + return ( + ownerIncarnationId !== null && + binding.ownerIncarnationId === ownerIncarnationId && + binding.physicalPtyId === recovery.physicalPty.physicalPtyId && + (recovery.physicalPty.ptyIncarnationId === null || + binding.ptyIncarnationId === recovery.physicalPty.ptyIncarnationId) + ) +} diff --git a/src/shared/terminal-session-authority-legacy-state.ts b/src/shared/terminal-session-authority-legacy-state.ts new file mode 100644 index 00000000000..9eb112b8ddd --- /dev/null +++ b/src/shared/terminal-session-authority-legacy-state.ts @@ -0,0 +1,303 @@ +import { isDeepStrictEqual } from 'node:util' +import type { + TerminalLegacyImportedRecovery, + TerminalLegacyMigrationImportRequest, + TerminalLegacyRecoveryProjection, + TerminalLegacyWorkerRoute +} from './terminal-legacy-cutover' +import { assertTerminalLegacyMigrationImportRequest } from './terminal-legacy-cutover-request-validation' +import { + assertAuthorityId, + type TerminalAuthorityNamespace +} from './terminal-session-authority-identity' +import { + failTerminalSessionAuthority, + TerminalSessionAuthorityError, + type TerminalSessionAuthorityErrorCode, + type TerminalSessionAuthorityLegacyMigration, + type TerminalSessionAuthorityMutationRequest +} from './terminal-session-authority-mutation' +import { + assertTerminalAuthorityLegacyMigrationEnvelope, + deriveTerminalAuthorityLegacyMigration, + legacyImportedPaneKey, + legacyPhysicalPtyKey +} from './terminal-session-authority-legacy-transition' +import { assertTerminalAuthorityLegacyMigrationAdmission } from './terminal-session-authority-legacy-admission' +import { + assertTerminalAuthorityLegacyMutationAllowed, + assertTerminalAuthorityLegacyTopologyAllowed +} from './terminal-session-authority-legacy-mutation-fence' +import { assertRestoredTerminalAuthorityLegacyTopology } from './terminal-session-authority-legacy-topology-validation' +import type { TerminalSessionAuthorityTopology } from './terminal-session-authority-topology' +import { assertSafeInteger } from './terminal-session-authority-record-validation' +import { assertSemanticallyEqual } from './terminal-session-authority-semantic-equality' + +export type TerminalAuthorityLegacyStateLimits = Readonly<{ + migrations: number + workers: number + recoveries: number +}> + +export class TerminalSessionAuthorityLegacyState { + private readonly migrationsById = new Map() + private readonly workersByRoute = new Map() + private readonly routeByWorker = new Map() + private readonly routeByOwner = new Map() + private readonly recoveriesById = new Map() + private readonly recoveryByPhysicalPty = new Map() + private readonly importedPaneOwners = new Map() + private readonly liveOwnerIncarnations = new Set() + + constructor( + private readonly namespace: TerminalAuthorityNamespace, + private readonly authorityOwnerIncarnationId: string, + private readonly limits: TerminalAuthorityLegacyStateLimits + ) {} + + get revision(): number { + return this.migrationsById.size + } + + hasMigration(migrationId: string): boolean { + return this.migrationsById.has(migrationId) + } + + migration(migrationId: string): TerminalSessionAuthorityLegacyMigration | null { + const migration = this.migrationsById.get(migrationId) + return migration ? structuredClone(migration) : null + } + + recovery(recoveryId: string): TerminalLegacyRecoveryProjection | null { + const recovery = this.recoveriesById.get(recoveryId) + return recovery ? structuredClone(recovery) : null + } + + workerRoute(routeId: string): TerminalLegacyWorkerRoute | null { + const route = this.workersByRoute.get(routeId) + return route ? structuredClone(route) : null + } + + ownerIsReachable(ownerIncarnationId: string): boolean { + return ( + ownerIncarnationId === this.authorityOwnerIncarnationId || + this.liveOwnerIncarnations.has(ownerIncarnationId) + ) + } + + setOwnerReachable(ownerIncarnationId: string, reachable: boolean): boolean { + assertAuthorityId(ownerIncarnationId, 'legacy ownerIncarnationId') + if (ownerIncarnationId === this.authorityOwnerIncarnationId) { + failTerminalSessionAuthority('record-corrupt', 'legacy owner aliases the authority process') + } + if (reachable) { + if (this.liveOwnerIncarnations.has(ownerIncarnationId)) { + return false + } + this.liveOwnerIncarnations.add(ownerIncarnationId) + return true + } + return this.liveOwnerIncarnations.delete(ownerIncarnationId) + } + + assertMutationAllowed(request: TerminalSessionAuthorityMutationRequest): void { + assertTerminalAuthorityLegacyMutationAllowed( + request, + this.recoveriesById.values(), + (workerId) => this.workerOwner(workerId) + ) + } + + assertTopologyAllowed( + migration: TerminalSessionAuthorityLegacyMigration, + topology: TerminalSessionAuthorityTopology, + errorCode: TerminalSessionAuthorityErrorCode + ): void { + const request = migration.receipt.request + assertTerminalAuthorityLegacyTopologyAllowed( + request.unresolved, + request.imports.map((candidate) => candidate.pane.paneKey), + topology.paneSnapshot(), + topology.allocationSnapshot(), + (workerId) => + request.mode === 'cutover' && request.workerRoute.workerId === workerId + ? request.workerRoute.ownerIncarnationId + : this.workerOwner(workerId), + errorCode + ) + const imports = migration.receipt.recoveries.filter( + (row): row is TerminalLegacyImportedRecovery => row.status === 'imported' + ) + try { + topology.planLegacyImport(imports, migration.receipt.receiptId, migration.authorityRevision) + } catch (error) { + if (errorCode === 'record-corrupt') { + failTerminalSessionAuthority('record-corrupt', 'legacy import conflicts with topology') + } + throw error + } + } + + plan( + request: TerminalLegacyMigrationImportRequest, + requestDigest: string, + committedAtMs: number, + authorityRevision: number + ): Readonly<{ migration: TerminalSessionAuthorityLegacyMigration; duplicate: boolean }> { + assertTerminalLegacyMigrationImportRequest(request) + assertAuthorityId(requestDigest, 'legacy migration requestDigest') + assertSafeInteger(committedAtMs, 'legacy migration committedAtMs') + assertSafeInteger(authorityRevision, 'legacy migration authority revision', 1) + this.assertNamespace(request) + const existing = this.migrationsById.get(request.migrationId) + if (existing) { + if ( + existing.requestDigest !== requestDigest || + !isDeepStrictEqual(existing.receipt.request, request) + ) { + failTerminalSessionAuthority('operation-conflict', 'legacy migration ID was reused') + } + return Object.freeze({ migration: existing, duplicate: true }) + } + assertTerminalAuthorityLegacyMigrationAdmission(request, { + limits: this.limits, + migrationCount: this.migrationsById.size, + workersByRoute: this.workersByRoute, + routeByWorker: this.routeByWorker, + routeByOwner: this.routeByOwner, + recoveriesById: this.recoveriesById, + recoveryByPhysicalPty: this.recoveryByPhysicalPty, + importedPaneOwners: this.importedPaneOwners + }) + const migration = deriveTerminalAuthorityLegacyMigration( + request, + this.namespace, + requestDigest, + authorityRevision, + this.migrationsById.size + 1, + committedAtMs, + (recoveryId) => this.recoveriesById.get(recoveryId) + ) + return Object.freeze({ migration, duplicate: false }) + } + + apply( + migration: TerminalSessionAuthorityLegacyMigration, + topology: TerminalSessionAuthorityTopology, + importTopology = true + ): void { + const planned = this.planPersistedMigration(migration) + if (importTopology) { + this.assertTopologyAllowed(migration, topology, 'record-corrupt') + } + if (planned.duplicate) { + failTerminalSessionAuthority('record-corrupt', 'legacy migration repeats an operation') + } + assertSemanticallyEqual(planned.migration, migration, 'legacy migration is not canonical') + const imports = migration.receipt.recoveries.filter( + (row): row is TerminalLegacyImportedRecovery => row.status === 'imported' + ) + if (importTopology && imports.length > 0) { + topology.importLegacyRows(imports, migration.receipt.receiptId, migration.authorityRevision) + } + this.applyState(migration) + } + + private planPersistedMigration( + migration: TerminalSessionAuthorityLegacyMigration + ): ReturnType { + try { + assertTerminalAuthorityLegacyMigrationEnvelope(migration) + if (!sameNamespace(migration.namespace, this.namespace)) { + failTerminalSessionAuthority('record-corrupt', 'legacy migration authority changed') + } + return this.plan( + migration.receipt.request, + migration.requestDigest, + migration.receipt.committedAtMs, + migration.authorityRevision + ) + } catch (error) { + if (error instanceof TerminalSessionAuthorityError && error.code === 'record-corrupt') { + throw error + } + failTerminalSessionAuthority('record-corrupt', 'legacy migration record is invalid') + } + } + + restore( + migrations: readonly TerminalSessionAuthorityLegacyMigration[], + topology: TerminalSessionAuthorityTopology + ): void { + for (const migration of migrations) { + this.apply(migration, topology, false) + } + assertRestoredTerminalAuthorityLegacyTopology(this.recoverySnapshot(), topology, (workerId) => + this.workerOwner(workerId) + ) + } + + migrationSnapshot(): readonly TerminalSessionAuthorityLegacyMigration[] { + return Object.freeze(structuredClone([...this.migrationsById.values()])) + } + + recoverySnapshot(): readonly TerminalLegacyRecoveryProjection[] { + return Object.freeze( + structuredClone( + [...this.recoveriesById.values()].sort((left, right) => + left.recoveryId.localeCompare(right.recoveryId) + ) + ) + ) + } + + workerRouteSnapshot(): readonly TerminalLegacyWorkerRoute[] { + return Object.freeze( + structuredClone( + [...this.workersByRoute.values()].sort((left, right) => + left.routeId.localeCompare(right.routeId) + ) + ) + ) + } + + private applyState(migration: TerminalSessionAuthorityLegacyMigration): void { + this.migrationsById.set(migration.receipt.request.migrationId, migration) + if (migration.receipt.request.mode === 'cutover') { + const route = Object.freeze(structuredClone(migration.receipt.request.workerRoute)) + this.workersByRoute.set(route.routeId, route) + this.routeByWorker.set(route.workerId, route.routeId) + this.routeByOwner.set(route.ownerIncarnationId, route.routeId) + } + for (const recovery of migration.receipt.recoveries) { + this.recoveriesById.set(recovery.recoveryId, recovery) + this.recoveryByPhysicalPty.set(legacyPhysicalPtyKey(recovery), recovery.recoveryId) + if (recovery.status === 'imported') { + this.importedPaneOwners.set(legacyImportedPaneKey(recovery), recovery.recoveryId) + } + } + } + + private assertNamespace(request: TerminalLegacyMigrationImportRequest): void { + if (request.authorityHostId !== this.namespace.authorityHostId) { + failTerminalSessionAuthority('expectation-mismatch', 'legacy migration authority changed') + } + for (const candidate of [...request.imports, ...request.unresolved]) { + if (!sameNamespace(candidate.namespace, this.namespace)) { + failTerminalSessionAuthority('expectation-mismatch', 'legacy migration namespace changed') + } + } + } + + private workerOwner(workerId: string): string | null { + const routeId = this.routeByWorker.get(workerId) + return routeId ? (this.workersByRoute.get(routeId)?.ownerIncarnationId ?? null) : null + } +} + +function sameNamespace( + left: TerminalAuthorityNamespace, + right: TerminalAuthorityNamespace +): boolean { + return left.authorityHostId === right.authorityHostId && left.namespaceId === right.namespaceId +} diff --git a/src/shared/terminal-session-authority-legacy-topology-import.ts b/src/shared/terminal-session-authority-legacy-topology-import.ts new file mode 100644 index 00000000000..a356797506f --- /dev/null +++ b/src/shared/terminal-session-authority-legacy-topology-import.ts @@ -0,0 +1,92 @@ +import { terminalPaneGenerationKey } from './terminal-session-authority-identity' +import { + failTerminalSessionAuthority, + type TerminalPaneAuthorityRecord, + type TerminalSessionPtyAllocation +} from './terminal-session-authority-mutation' +import { terminalAuthorityPhysicalPtyKey } from './terminal-session-authority-transition' +import type { TerminalLegacyImportedRecovery } from './terminal-legacy-cutover' + +type TerminalAuthorityLegacyImportIndexes = Readonly<{ + paneGenerations: ReadonlyMap + paneKeys: ReadonlyMap + allocationIds: ReadonlyMap + physicalPtys: ReadonlyMap + maxPaneRecords: number +}> + +export function planTerminalAuthorityLegacyTopologyImport( + rows: readonly TerminalLegacyImportedRecovery[], + receiptId: string, + revision: number, + indexes: TerminalAuthorityLegacyImportIndexes +): Readonly<{ + panes: readonly TerminalPaneAuthorityRecord[] + allocations: readonly TerminalSessionPtyAllocation[] +}> { + assertLegacyRowsDoNotConflict(rows, indexes) + const newPaneKeys = new Set(rows.map((row) => row.pane.paneKey)) + if (indexes.paneKeys.size + newPaneKeys.size > indexes.maxPaneRecords) { + failTerminalSessionAuthority('capacity', 'legacy import panes exceed retention capacity') + } + return Object.freeze({ + panes: Object.freeze( + rows.map((row) => + Object.freeze({ + ...row.pane, + status: 'open' as const, + binding: row.binding, + lastBinding: row.binding, + revision + }) + ) + ), + allocations: Object.freeze( + rows.map((row) => + Object.freeze({ + allocationId: row.allocationId, + pane: row.pane, + ownerIncarnationId: row.binding.ownerIncarnationId, + physicalPtyId: row.binding.physicalPtyId, + spawnFingerprint: row.spawnFingerprint, + intentActorId: 'legacy-migration', + intentOperationId: `${receiptId}:${row.recoveryId}`, + preparedAtRevision: revision, + status: 'committed' as const, + binding: row.binding, + committedAtRevision: revision + }) + ) + ) + }) +} + +function assertLegacyRowsDoNotConflict( + rows: readonly TerminalLegacyImportedRecovery[], + indexes: TerminalAuthorityLegacyImportIndexes +): void { + const paneKeys = new Set() + const allocationIds = new Set() + const physicalPtys = new Set() + for (const row of rows) { + const paneKey = terminalPaneGenerationKey(row.pane) + const physicalKey = terminalAuthorityPhysicalPtyKey( + row.binding.ownerIncarnationId, + row.physicalPty.physicalPtyId + ) + if ( + indexes.paneGenerations.has(paneKey) || + indexes.paneKeys.has(row.pane.paneKey) || + indexes.allocationIds.has(row.allocationId) || + indexes.physicalPtys.has(physicalKey) || + paneKeys.has(paneKey) || + allocationIds.has(row.allocationId) || + physicalPtys.has(physicalKey) + ) { + failTerminalSessionAuthority('allocation-conflict', 'legacy import conflicts') + } + paneKeys.add(paneKey) + allocationIds.add(row.allocationId) + physicalPtys.add(physicalKey) + } +} diff --git a/src/shared/terminal-session-authority-legacy-topology-validation.ts b/src/shared/terminal-session-authority-legacy-topology-validation.ts new file mode 100644 index 00000000000..495f7f3c610 --- /dev/null +++ b/src/shared/terminal-session-authority-legacy-topology-validation.ts @@ -0,0 +1,55 @@ +import type { + TerminalLegacyImportedRecovery, + TerminalLegacyRecoveryProjection +} from './terminal-legacy-cutover' +import { sameTerminalBinding } from './terminal-session-authority-identity' +import { assertTerminalAuthorityLegacyTopologyAllowed } from './terminal-session-authority-legacy-mutation-fence' +import { failTerminalSessionAuthority } from './terminal-session-authority-mutation' +import type { TerminalSessionAuthorityTopology } from './terminal-session-authority-topology' + +export function assertRestoredTerminalAuthorityLegacyTopology( + recoveries: readonly TerminalLegacyRecoveryProjection[], + topology: TerminalSessionAuthorityTopology, + workerOwner: (workerId: string) => string | null +): void { + assertTerminalAuthorityLegacyTopologyAllowed( + recoveries.filter((row) => row.status === 'unresolved'), + [], + topology.paneSnapshot(), + topology.allocationSnapshot(), + workerOwner, + 'record-corrupt' + ) + const imported = recoveries.filter( + (row): row is TerminalLegacyImportedRecovery => row.status === 'imported' + ) + const rowsByAllocation = new Map(imported.map((row) => [row.allocationId, row])) + for (const allocation of topology.allocationSnapshot()) { + if (allocation.intentActorId !== 'legacy-migration') { + continue + } + const row = rowsByAllocation.get(allocation.allocationId) + if ( + !row || + allocation.status !== 'committed' || + allocation.intentOperationId !== `${row.catalogReceiptId}:${row.recoveryId}` || + allocation.spawnFingerprint !== row.spawnFingerprint || + !sameTerminalBinding(allocation.binding, row.binding) + ) { + failTerminalSessionAuthority('record-corrupt', 'legacy allocation changed in snapshot') + } + } + for (const row of imported) { + const pane = topology.pane(row.pane) + const allocation = topology.allocation(row.allocationId) + if ( + (pane?.lastBinding && !sameTerminalBinding(pane.lastBinding, row.binding)) || + (allocation && + (allocation.status !== 'committed' || + allocation.intentActorId !== 'legacy-migration' || + !sameTerminalBinding(allocation.binding, row.binding))) + ) { + failTerminalSessionAuthority('record-corrupt', 'legacy topology changed in snapshot') + } + } +} diff --git a/src/shared/terminal-session-authority-legacy-transition.ts b/src/shared/terminal-session-authority-legacy-transition.ts new file mode 100644 index 00000000000..b94cab62f4e --- /dev/null +++ b/src/shared/terminal-session-authority-legacy-transition.ts @@ -0,0 +1,226 @@ +import { createHash } from 'node:crypto' +import { isDeepStrictEqual } from 'node:util' +import type { + TerminalLegacyImportedRecovery, + TerminalLegacyMigrationImportRequest, + TerminalLegacyMigrationReceipt, + TerminalLegacyRecoveryProjection, + TerminalLegacyWorkerRoute, + TerminalLegacyWorkerRouteProjection +} from './terminal-legacy-cutover' +import { + assertTerminalLegacyMigrationImportRequest, + assertTerminalLegacyMigrationReceipt +} from './terminal-legacy-cutover-request-validation' +import { + assertAuthorityId, + assertAuthorityNamespace, + terminalPaneGenerationKey, + type TerminalAuthorityNamespace +} from './terminal-session-authority-identity' +import { + failTerminalSessionAuthority, + type TerminalSessionAuthorityLegacyMigration +} from './terminal-session-authority-mutation' +import { assertSafeInteger } from './terminal-session-authority-record-validation' + +export function terminalLegacyMigrationRequestDigest( + request: TerminalLegacyMigrationImportRequest +): string { + assertTerminalLegacyMigrationImportRequest(request) + return createHash('sha256').update(JSON.stringify(request)).digest('hex') +} + +export function deriveTerminalAuthorityLegacyMigration( + request: TerminalLegacyMigrationImportRequest, + namespace: TerminalAuthorityNamespace, + requestDigest: string, + authorityRevision: number, + migrationSequence: number, + committedAtMs: number, + previousRecovery: (recoveryId: string) => TerminalLegacyRecoveryProjection | undefined +): TerminalSessionAuthorityLegacyMigration { + const acknowledged = + request.mode === 'acknowledge' ? previousRecovery(request.recoveryId) : undefined + const receipt: TerminalLegacyMigrationReceipt = Object.freeze({ + version: 1, + receiptId: request.migrationId, + sequence: migrationSequence, + committedAtMs, + request: Object.freeze(structuredClone(request)), + recoveries: Object.freeze( + request.mode === 'acknowledge' + ? deriveAcknowledgement(request, committedAtMs, acknowledged) + : deriveCandidateRecoveries(request, committedAtMs, previousRecovery) + ) + }) + return Object.freeze({ + version: 1, + namespace: Object.freeze({ ...namespace }), + requestDigest, + authorityRevision, + receipt + }) +} + +export function assertTerminalAuthorityLegacyMigrationEnvelope( + migration: TerminalSessionAuthorityLegacyMigration +): void { + if (migration.version !== 1) { + failTerminalSessionAuthority('record-corrupt', 'legacy authority migration version is invalid') + } + assertAuthorityNamespace(migration.namespace) + assertAuthorityId(migration.requestDigest, 'legacy migration requestDigest') + assertSafeInteger(migration.authorityRevision, 'legacy migration authority revision', 1) + assertTerminalLegacyMigrationReceipt(migration.receipt) + for (const row of migration.receipt.recoveries) { + if (!sameNamespace(row.namespace, migration.namespace)) { + failTerminalSessionAuthority('record-corrupt', 'legacy migration namespace changed') + } + } +} + +export function legacyPhysicalPtyKey(value: { + namespace: TerminalAuthorityNamespace + physicalPty: { workerId: string; physicalPtyId: string } +}): string { + return JSON.stringify([ + value.namespace.authorityHostId, + value.physicalPty.workerId, + value.physicalPty.physicalPtyId + ]) +} + +export function legacyImportedPaneKey(value: { + namespace: TerminalAuthorityNamespace + pane: { paneKey: string; paneGenerationId: string } +}): string { + return JSON.stringify([ + value.namespace.authorityHostId, + value.namespace.namespaceId, + terminalPaneGenerationKey(value.pane) + ]) +} + +export function legacyRouteMatchesCutover( + route: TerminalLegacyWorkerRoute, + request: Extract +): boolean { + const proof = request.cutover + const endpointPath = + proof.kind === 'posix-relocated' ? proof.privateSocketPath : proof.originalPipeName + return ( + route.socketPath === endpointPath && + route.credentialFile === proof.privateCredentialFile && + isDeepStrictEqual(route.endpoint, proof.endpointIdentity) && + route.gcProtection.relayDirectories.includes(route.relayDirectory) && + route.gcProtection.evidencePaths.includes(route.socketPath) && + route.gcProtection.evidencePaths.includes(route.credentialFile) + ) +} + +export function projectLegacyWorkerRoute( + route: TerminalLegacyWorkerRoute +): TerminalLegacyWorkerRouteProjection { + const { ownerLease: _ownerLease, ...sourceOwner } = route.sourceOwner + const endpoint = + route.endpoint.kind === 'unix-socket' + ? Object.freeze({ ...route.endpoint }) + : Object.freeze({ + kind: route.endpoint.kind, + processCreationMarker: route.endpoint.processCreationMarker + }) + return Object.freeze({ + routeId: route.routeId, + workerId: route.workerId, + ownerIncarnationId: route.ownerIncarnationId, + buildId: route.buildId, + process: Object.freeze({ ...route.process }), + endpoint, + sourceOwner: Object.freeze(sourceOwner) + }) +} + +function deriveAcknowledgement( + request: Extract, + committedAtMs: number, + acknowledged: TerminalLegacyRecoveryProjection | undefined +): TerminalLegacyRecoveryProjection[] { + if (acknowledged?.status !== 'unresolved') { + failTerminalSessionAuthority('expectation-mismatch', 'legacy recovery is not unresolved') + } + return [ + Object.freeze({ + ...acknowledged, + status: 'acknowledged' as const, + catalogReceiptId: request.migrationId, + previousCatalogReceiptId: acknowledged.catalogReceiptId, + acknowledgementCode: request.acknowledgementCode, + acknowledgedAtMs: committedAtMs, + updatedAtMs: committedAtMs + }) + ] +} + +function deriveCandidateRecoveries( + request: Exclude, + committedAtMs: number, + previousRecovery: (recoveryId: string) => TerminalLegacyRecoveryProjection | undefined +): TerminalLegacyRecoveryProjection[] { + const imports = + request.mode === 'cutover' + ? request.imports.map((candidate) => + deriveImportedRecovery(request, candidate, committedAtMs, previousRecovery) + ) + : [] + const unresolved = request.unresolved.map((candidate) => { + const previous = previousRecovery(candidate.recoveryId) + return Object.freeze({ + ...structuredClone(candidate), + status: 'unresolved' as const, + catalogReceiptId: request.migrationId, + discoveredAtMs: previous?.discoveredAtMs ?? request.requestedAtMs, + updatedAtMs: committedAtMs + }) + }) + return [...imports, ...unresolved].sort((left, right) => + left.recoveryId.localeCompare(right.recoveryId) + ) +} + +function deriveImportedRecovery( + request: Extract, + candidate: (typeof request.imports)[number], + committedAtMs: number, + previousRecovery: (recoveryId: string) => TerminalLegacyRecoveryProjection | undefined +): TerminalLegacyImportedRecovery { + const previous = previousRecovery(candidate.recoveryId) + return Object.freeze({ + ...structuredClone(candidate), + status: 'imported' as const, + routeId: request.workerRoute.routeId, + binding: Object.freeze({ + ownerIncarnationId: request.workerRoute.ownerIncarnationId, + physicalPtyId: candidate.physicalPty.physicalPtyId, + ptyIncarnationId: candidate.physicalPty.ptyIncarnationId + }), + catalogReceiptId: request.migrationId, + resolvedFrom: + previous?.status === 'unresolved' + ? Object.freeze({ + catalogReceiptId: previous.catalogReceiptId, + reason: previous.reason, + evidenceCode: previous.evidenceCode + }) + : null, + discoveredAtMs: previous?.discoveredAtMs ?? request.requestedAtMs, + updatedAtMs: committedAtMs + }) +} + +function sameNamespace( + left: TerminalAuthorityNamespace, + right: TerminalAuthorityNamespace +): boolean { + return left.authorityHostId === right.authorityHostId && left.namespaceId === right.namespaceId +} diff --git a/src/shared/terminal-session-authority-locator.test.ts b/src/shared/terminal-session-authority-locator.test.ts new file mode 100644 index 00000000000..376290b2b5c --- /dev/null +++ b/src/shared/terminal-session-authority-locator.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' +import { + assertTerminalAuthorityNamespaceLocator, + terminalAuthorityNamespaceLocatorKey +} from './terminal-session-authority-locator' + +describe('terminal authority namespace locator', () => { + it('keys host-canonical workspace paths without client routing identity', () => { + expect( + terminalAuthorityNamespaceLocatorKey({ + kind: 'workspace', + canonicalPath: '/srv/repo', + pathFlavor: 'posix' + }) + ).toBe('["workspace","posix","/srv/repo"]') + expect(terminalAuthorityNamespaceLocatorKey({ kind: 'floating' })).toBe('["floating"]') + }) + + it('preserves POSIX case and Windows canonical case', () => { + expect( + terminalAuthorityNamespaceLocatorKey({ + kind: 'workspace', + canonicalPath: '/srv/Repo', + pathFlavor: 'posix' + }) + ).not.toBe( + terminalAuthorityNamespaceLocatorKey({ + kind: 'workspace', + canonicalPath: '/srv/repo', + pathFlavor: 'posix' + }) + ) + expect(() => + assertTerminalAuthorityNamespaceLocator({ + kind: 'workspace', + canonicalPath: 'C:/Users/Alice/repo', + pathFlavor: 'windows' + }) + ).not.toThrow() + }) + + it('rejects relative, control-character, and malformed Windows locators', () => { + expect(() => + assertTerminalAuthorityNamespaceLocator({ + kind: 'workspace', + canonicalPath: 'repo', + pathFlavor: 'posix' + }) + ).toThrow('absolute') + expect(() => + assertTerminalAuthorityNamespaceLocator({ + kind: 'workspace', + canonicalPath: '/srv/repo\nother', + pathFlavor: 'posix' + }) + ).toThrow('invalid') + expect(() => + assertTerminalAuthorityNamespaceLocator({ + kind: 'workspace', + canonicalPath: '/Users/Alice/repo', + pathFlavor: 'windows' + }) + ).toThrow('Windows') + }) +}) diff --git a/src/shared/terminal-session-authority-locator.ts b/src/shared/terminal-session-authority-locator.ts new file mode 100644 index 00000000000..49a3b798974 --- /dev/null +++ b/src/shared/terminal-session-authority-locator.ts @@ -0,0 +1,57 @@ +const MAX_CANONICAL_WORKSPACE_PATH_BYTES = 32 * 1024 + +export type TerminalAuthorityPathFlavor = 'posix' | 'windows' + +export type TerminalAuthorityNamespaceLocator = + | Readonly<{ + kind: 'workspace' + canonicalPath: string + pathFlavor: TerminalAuthorityPathFlavor + }> + | Readonly<{ kind: 'floating' }> + +export function assertTerminalAuthorityNamespaceLocator( + locator: TerminalAuthorityNamespaceLocator +): void { + if (locator.kind === 'floating') { + return + } + if (locator.kind !== 'workspace') { + throw new Error('terminal authority locator kind is invalid') + } + if ( + typeof locator.canonicalPath !== 'string' || + locator.canonicalPath.length === 0 || + new TextEncoder().encode(locator.canonicalPath).byteLength > + MAX_CANONICAL_WORKSPACE_PATH_BYTES || + locator.canonicalPath.includes('\0') || + locator.canonicalPath.includes('\r') || + locator.canonicalPath.includes('\n') + ) { + throw new Error('canonical workspace path is invalid') + } + if (locator.pathFlavor === 'posix') { + if (!locator.canonicalPath.startsWith('/')) { + throw new Error('canonical POSIX workspace path must be absolute') + } + return + } + if ( + locator.pathFlavor !== 'windows' || + !( + /^[A-Za-z]:\//.test(locator.canonicalPath) || + /^\/\/[^/]+\/[^/]+(?:\/|$)/.test(locator.canonicalPath) + ) + ) { + throw new Error('canonical Windows workspace path must be absolute') + } +} + +export function terminalAuthorityNamespaceLocatorKey( + locator: TerminalAuthorityNamespaceLocator +): string { + assertTerminalAuthorityNamespaceLocator(locator) + return locator.kind === 'floating' + ? '["floating"]' + : JSON.stringify(['workspace', locator.pathFlavor, locator.canonicalPath]) +} diff --git a/src/shared/terminal-session-authority-materialized-outcomes.ts b/src/shared/terminal-session-authority-materialized-outcomes.ts new file mode 100644 index 00000000000..3a1b38af32f --- /dev/null +++ b/src/shared/terminal-session-authority-materialized-outcomes.ts @@ -0,0 +1,226 @@ +import { isDeepStrictEqual } from 'node:util' +import { + sameTerminalBinding, + type TerminalPaneGeneration, + type TerminalSessionBinding +} from './terminal-session-authority-identity' +import { + failTerminalSessionAuthority, + type TerminalAuthorityDurableOutcome, + type TerminalAuthoritySemanticOutcome, + type TerminalSessionAuthoritySemanticFact, + type TerminalPaneAuthorityRecord +} from './terminal-session-authority-mutation' + +const MAX_MATERIALIZED_BELLS = 99 + +export class TerminalSessionAuthorityMaterializedOutcomes { + private readonly outcomes = new Map() + private readonly bellKeys = new Map() + private retainedBytes = 0 + + constructor( + private readonly maxEntries: number, + private readonly maxBytes: number + ) {} + + assertCanApply(outcome: TerminalAuthorityDurableOutcome): void { + const change = this.planChange(outcome) + if ( + this.outcomes.size - change.removed.length + change.added.length > this.maxEntries || + this.retainedBytes - byteLength(change.removed) + byteLength(change.added) > this.maxBytes + ) { + failTerminalSessionAuthority('capacity', 'materialized outcome projection is full') + } + } + + apply(outcome: TerminalAuthorityDurableOutcome): void { + const change = this.planChange(outcome) + this.applyChange(change) + } + + restore(outcomes: readonly TerminalAuthorityDurableOutcome[]): void { + let previousSequence = 0 + for (const outcome of outcomes) { + if (outcome.sequence <= previousSequence) { + failTerminalSessionAuthority( + 'record-corrupt', + 'materialized outcome projection is not ordered' + ) + } + this.assertCanApply(outcome) + this.apply(outcome) + previousSequence = outcome.sequence + } + if (!isDeepStrictEqual(this.projection(), outcomes)) { + failTerminalSessionAuthority( + 'record-corrupt', + 'materialized outcome projection is not canonical' + ) + } + } + + projection(): readonly TerminalAuthorityDurableOutcome[] { + return Object.freeze( + [...this.outcomes.values()].sort((left, right) => left.sequence - right.sequence) + ) + } + + private planChange(outcome: TerminalAuthorityDurableOutcome): MaterializedChange { + if (outcome.kind === 'semantic') { + return outcome.fact.kind === 'bell' + ? this.planBell(outcome) + : this.planReplacement(semanticKey(outcome), outcome) + } + if (outcome.result.kind === 'supersede') { + return { removed: this.forPane(outcome.result.pane), added: [] } + } + const exit = outcome.result.effects.find((effect) => effect.kind === 'terminal-exited') + return exit + ? this.planReplacement(exitKey(outcome.result.pane, exit.binding), outcome) + : EMPTY_CHANGE + } + + private planBell(outcome: TerminalAuthoritySemanticOutcome): MaterializedChange { + const binding = bindingKey(outcome.access.pane, outcome.access.binding) + const keys = this.bellKeys.get(binding) ?? [] + const key = `${binding}\u0000bell\u0000${outcome.sequence}` + const removed = + keys.length >= MAX_MATERIALIZED_BELLS + ? [{ key: keys[0]!, outcome: this.outcomes.get(keys[0]!)! }] + : [] + return { removed, added: [{ key, outcome }] } + } + + private planReplacement( + key: string, + outcome: TerminalAuthorityDurableOutcome + ): MaterializedChange { + const previous = this.outcomes.get(key) + return { removed: previous ? [{ key, outcome: previous }] : [], added: [{ key, outcome }] } + } + + private forPane(pane: TerminalPaneGeneration): MaterializedEntry[] { + const prefix = `${paneKey(pane)}\u0000` + return [...this.outcomes.entries()] + .filter(([key]) => key.startsWith(prefix)) + .map(([key, outcome]) => ({ key, outcome })) + } + + private applyChange(change: MaterializedChange): void { + for (const { key, outcome } of change.removed) { + this.delete(key, outcome) + } + for (const { key, outcome } of change.added) { + this.outcomes.set(key, outcome) + this.retainedBytes += outcome.byteLength + if (outcome.kind === 'semantic' && outcome.fact.kind === 'bell') { + const binding = bindingKey(outcome.access.pane, outcome.access.binding) + const keys = this.bellKeys.get(binding) ?? [] + keys.push(key) + this.bellKeys.set(binding, keys) + } + } + } + + private delete(key: string, outcome: TerminalAuthorityDurableOutcome): void { + this.outcomes.delete(key) + this.retainedBytes -= outcome.byteLength + if (outcome.kind !== 'semantic' || outcome.fact.kind !== 'bell') { + return + } + const binding = bindingKey(outcome.access.pane, outcome.access.binding) + const keys = this.bellKeys.get(binding)?.filter((candidate) => candidate !== key) ?? [] + if (keys.length === 0) { + this.bellKeys.delete(binding) + } else { + this.bellKeys.set(binding, keys) + } + } +} + +type MaterializedChange = Readonly<{ + removed: readonly MaterializedEntry[] + added: readonly MaterializedEntry[] +}> + +type MaterializedEntry = Readonly<{ key: string; outcome: TerminalAuthorityDurableOutcome }> + +const EMPTY_CHANGE: MaterializedChange = Object.freeze({ removed: [], added: [] }) + +function semanticKey(outcome: TerminalAuthoritySemanticOutcome): string { + return `${bindingKey(outcome.access.pane, outcome.access.binding)}\u0000${factDomain(outcome.fact)}` +} + +function exitKey(pane: TerminalPaneGeneration, binding: TerminalSessionBinding): string { + return `${bindingKey(pane, binding)}\u0000exit` +} + +function bindingKey(pane: TerminalPaneGeneration, binding: TerminalSessionBinding): string { + return `${paneKey(pane)}\u0000${JSON.stringify([ + binding.ownerIncarnationId, + binding.physicalPtyId, + binding.ptyIncarnationId + ])}` +} + +function paneKey(pane: TerminalPaneGeneration): string { + return JSON.stringify([pane.paneKey, pane.paneGenerationId]) +} + +function factDomain(fact: TerminalSessionAuthoritySemanticFact): string { + if (fact.kind === '2031-subscribe' || fact.kind === '2031-unsubscribe') { + return '2031-subscription' + } + return fact.kind +} + +function byteLength(entries: readonly MaterializedEntry[]): number { + return entries.reduce((total, entry) => total + entry.outcome.byteLength, 0) +} + +export function terminalAuthorityMaterializedOutcomeBinding( + outcome: TerminalAuthorityDurableOutcome +): Readonly<{ pane: TerminalPaneGeneration; binding: TerminalSessionBinding }> | null { + if (outcome.kind === 'semantic') { + return { pane: outcome.access.pane, binding: outcome.access.binding } + } + const exit = outcome.result.effects.find((effect) => effect.kind === 'terminal-exited') + return exit ? { pane: outcome.result.pane, binding: exit.binding } : null +} + +export function terminalAuthorityMaterializedOutcomeMatchesBinding( + outcome: TerminalAuthorityDurableOutcome, + pane: TerminalPaneGeneration, + binding: TerminalSessionBinding +): boolean { + const materialized = terminalAuthorityMaterializedOutcomeBinding(outcome) + return Boolean( + materialized && + materialized.pane.paneKey === pane.paneKey && + materialized.pane.paneGenerationId === pane.paneGenerationId && + sameTerminalBinding(materialized.binding, binding) + ) +} + +export function assertTerminalAuthorityMaterializedOutcomesMatchTopology( + outcomes: readonly TerminalAuthorityDurableOutcome[], + pane: (generation: TerminalPaneGeneration) => TerminalPaneAuthorityRecord | null +): void { + for (const outcome of outcomes) { + const materialized = terminalAuthorityMaterializedOutcomeBinding(outcome) + const currentPane = materialized ? pane(materialized.pane) : null + const binding = currentPane?.binding ?? currentPane?.lastBinding ?? null + if ( + !materialized || + !currentPane || + !binding || + !terminalAuthorityMaterializedOutcomeMatchesBinding(outcome, currentPane, binding) + ) { + failTerminalSessionAuthority( + 'record-corrupt', + 'materialized outcome lost its exact pane binding' + ) + } + } +} diff --git a/src/shared/terminal-session-authority-mutation.ts b/src/shared/terminal-session-authority-mutation.ts new file mode 100644 index 00000000000..4fc3a024597 --- /dev/null +++ b/src/shared/terminal-session-authority-mutation.ts @@ -0,0 +1,291 @@ +import type { + TerminalAuthorityNamespace, + TerminalPaneGeneration, + TerminalSessionBinding +} from './terminal-session-authority-identity' +import type { TerminalLegacyMigrationReceipt } from './terminal-legacy-cutover' +import type { TerminalSessionAuthorityPtyAccess } from './terminal-session-authority-pty-access' +import type { TerminalSideEffectFact } from './terminal-side-effect-facts' + +export type TerminalPaneAuthorityStatus = 'open' | 'closed' | 'superseded' | 'exited' + +export type TerminalPaneAuthorityRecord = Readonly<{ + paneKey: string + paneGenerationId: string + status: TerminalPaneAuthorityStatus + binding: TerminalSessionBinding | null + lastBinding: TerminalSessionBinding | null + revision: number +}> + +export type TerminalSessionPtyAllocationIdentity = Readonly<{ + allocationId: string + pane: TerminalPaneGeneration + ownerIncarnationId: string + physicalPtyId: string + spawnFingerprint: string +}> + +export type TerminalSessionPtyAllocation = TerminalSessionPtyAllocationIdentity & + Readonly<{ + intentActorId: string + intentOperationId: string + preparedAtRevision: number + }> & + ( + | Readonly<{ status: 'pending'; binding: null }> + | Readonly<{ + status: 'committed' + binding: TerminalSessionBinding + committedAtRevision: number + }> + ) + +export type TerminalSessionExpectation = Readonly<{ + paneGenerationId: string + binding: TerminalSessionBinding | null +}> + +export type TerminalSessionAuthorityChange = + | Readonly<{ kind: 'create'; pane: TerminalPaneGeneration }> + | Readonly<{ + kind: 'prepare-allocation' + allocation: TerminalSessionPtyAllocationIdentity + expected: TerminalSessionExpectation + }> + | Readonly<{ + kind: 'commit-allocation' + allocation: TerminalSessionPtyAllocationIdentity + ptyIncarnationId: string + expected: TerminalSessionExpectation + }> + | Readonly<{ + kind: 'cancel-allocation' + allocation: TerminalSessionPtyAllocationIdentity + expected: TerminalSessionExpectation + }> + | Readonly<{ + kind: 'close' + pane: TerminalPaneGeneration + expected: TerminalSessionExpectation + }> + | Readonly<{ + kind: 'supersede' + pane: TerminalPaneGeneration + replacementPaneGenerationId: string + expected: TerminalSessionExpectation + }> + | Readonly<{ + kind: 'exit' + pane: TerminalPaneGeneration + expected: TerminalSessionExpectation + exit: Readonly<{ + code: number | null + signal: string | null + retiredByClose?: true + }> + }> + +export type TerminalSessionAuthorityMutationRequest = Readonly<{ + actorId: string + operationId: string + baseRevision: number + outcomeId: string + change: TerminalSessionAuthorityChange +}> + +export type TerminalSessionAuthorityEffect = + | Readonly<{ + kind: 'binding-retired' + reason: 'close' | 'supersede' | 'exit' + binding: TerminalSessionBinding + }> + | Readonly<{ + kind: 'terminal-exited' + binding: TerminalSessionBinding + code: number | null + signal: string | null + }> + +export type TerminalSessionAuthorityMutationResult = Readonly<{ + namespace: TerminalAuthorityNamespace + actorId: string + operationId: string + kind: TerminalSessionAuthorityChange['kind'] + revision: number + pane: TerminalPaneAuthorityRecord + replacementPane: TerminalPaneAuthorityRecord | null + allocation: TerminalSessionPtyAllocation | null + effects: readonly TerminalSessionAuthorityEffect[] +}> + +/** Terminal exit is a topology mutation, never a duplicated semantic fact. */ +export type TerminalSessionAuthoritySemanticFact = TerminalSideEffectFact + +export type TerminalSessionAuthoritySemanticOutcomeRequest = Readonly<{ + access: TerminalSessionAuthorityPtyAccess + producerIncarnationId: string + producerSequence: number + fact: TerminalSessionAuthoritySemanticFact +}> + +type TerminalAuthorityOutcomeEnvelope = Readonly<{ + sequence: number + outcomeId: string + byteLength: number +}> + +/** Unchanged on the wire: `kind` is absent so pre-existing records stay byte-identical. */ +export type TerminalAuthorityOutcome = TerminalAuthorityOutcomeEnvelope & + Readonly<{ + kind?: undefined + request: TerminalSessionAuthorityMutationRequest + result: TerminalSessionAuthorityMutationResult + }> + +export type TerminalAuthoritySemanticOutcome = TerminalAuthorityOutcomeEnvelope & + Readonly<{ + kind: 'semantic' + access: TerminalSessionAuthorityPtyAccess + producerIncarnationId: string + producerSequence: number + fact: TerminalSessionAuthoritySemanticFact + appendedAtRevision: number + }> + +export type TerminalAuthorityDurableOutcome = + | TerminalAuthorityOutcome + | TerminalAuthoritySemanticOutcome + +export type TerminalAuthorityConsumerSnapshot = Readonly<{ + consumerId: string + activeIncarnationId: string + acknowledgedSequence: number +}> + +export type TerminalAuthoritySemanticProducerSnapshot = Readonly<{ + access: TerminalSessionAuthorityPtyAccess + producerIncarnationId: string + producerSequence: number +}> + +export type TerminalSessionAuthorityLegacyMigration = Readonly<{ + version: 1 + namespace: TerminalAuthorityNamespace + requestDigest: string + authorityRevision: number + receipt: TerminalLegacyMigrationReceipt +}> + +export type TerminalSessionAuthoritySnapshot = Readonly<{ + version: 1 + namespace: TerminalAuthorityNamespace + writerEpoch: number + revision: number + panes: readonly TerminalPaneAuthorityRecord[] + allocations: readonly TerminalSessionPtyAllocation[] + consumers: readonly TerminalAuthorityConsumerSnapshot[] + outcomeFloorSequence: number + nextOutcomeSequence: number + outcomes: readonly TerminalAuthorityDurableOutcome[] + semanticProducers: readonly TerminalAuthoritySemanticProducerSnapshot[] + materializedOutcomes?: readonly TerminalAuthorityDurableOutcome[] + legacyMigrations: readonly TerminalSessionAuthorityLegacyMigration[] +}> + +export type TerminalSessionAuthorityLogEvent = + | Readonly<{ kind: 'mutation'; outcome: TerminalAuthorityOutcome }> + | Readonly<{ kind: 'semantic-outcome'; outcome: TerminalAuthoritySemanticOutcome }> + | Readonly<{ + kind: 'legacy-migration' + migration: TerminalSessionAuthorityLegacyMigration + }> + | Readonly<{ + kind: 'consumer-claim' + consumerId: string + expectedIncarnationId: string | null + consumerIncarnationId: string + acknowledgedSequence: number + }> + | Readonly<{ + kind: 'consumer-retire' + consumerId: string + consumerIncarnationId: string + }> + | Readonly<{ + kind: 'outcome-ack' + consumerId: string + consumerIncarnationId: string + sequence: number + }> + +export type TerminalSessionAuthorityLogRecord = Readonly<{ + version: 1 + recordId: number + writerEpoch: number + event: TerminalSessionAuthorityLogEvent +}> + +export type TerminalAuthorityOutcomeRead = + | Readonly<{ kind: 'entries'; entries: readonly TerminalAuthorityDurableOutcome[] }> + | Readonly<{ + kind: 'resnapshot-required' + reason: 'cursor-compacted' | 'cursor-gap' | 'cursor-ahead' + acknowledgedSequence: number + }> + +export type TerminalPaneAuthorityProjection = TerminalPaneAuthorityRecord & + Readonly<{ ownerStatus: 'reachable' | 'owner-unreachable' | null }> + +export type TerminalAuthorityProjection = Readonly<{ + namespace: TerminalAuthorityNamespace + writerEpoch: number + revision: number + panes: readonly TerminalPaneAuthorityProjection[] + allocations: readonly TerminalSessionPtyAllocation[] + /** Optional only for restoring or parsing a peer predating authority outcome boundaries. */ + materializedOutcomes?: readonly TerminalAuthorityDurableOutcome[] +}> + +export type TerminalAuthorityConsumerProjection = Readonly<{ + authority: TerminalAuthorityProjection + acknowledgedSequence: number + outcomeHighWatermark: number +}> + +export type TerminalBindingAuthority = + | 'absent' + | 'binding-mismatch' + | 'closed' + | 'exited' + | 'owner-unreachable' + | 'reachable' + +export type TerminalSessionAuthorityErrorCode = + | 'allocation-conflict' + | 'capacity' + | 'consumer-conflict' + | 'consumer-unknown' + | 'expectation-mismatch' + | 'operation-conflict' + | 'record-corrupt' + | 'revision-conflict' + | 'writer-fenced' + +export class TerminalSessionAuthorityError extends Error { + readonly name = 'TerminalSessionAuthorityError' + + constructor( + readonly code: TerminalSessionAuthorityErrorCode, + message: string + ) { + super(message) + } +} + +export function failTerminalSessionAuthority( + code: TerminalSessionAuthorityErrorCode, + message: string +): never { + throw new TerminalSessionAuthorityError(code, message) +} diff --git a/src/shared/terminal-session-authority-operation-identity.ts b/src/shared/terminal-session-authority-operation-identity.ts new file mode 100644 index 00000000000..17799707bac --- /dev/null +++ b/src/shared/terminal-session-authority-operation-identity.ts @@ -0,0 +1,43 @@ +import { createHash } from 'node:crypto' +import { assertAuthorityId } from './terminal-session-authority-identity' +import { + failTerminalSessionAuthority, + type TerminalSessionAuthorityMutationRequest +} from './terminal-session-authority-mutation' +import { assertSafeInteger } from './terminal-session-authority-record-validation' + +const TERMINAL_AUTHORITY_OPERATION_PREFIX = 'authority-mutation:' +const TERMINAL_AUTHORITY_OPERATION_PATTERN = /^authority-mutation:\d+:[a-f0-9]{64}$/ + +export function terminalAuthorityOperationIdentity( + baseRevision: number, + correlationId: string +): Readonly<{ + operationId: string + outcomeId: string +}> { + assertSafeInteger(baseRevision, 'mutation base revision') + assertAuthorityId(correlationId, 'mutation correlationId') + if (baseRevision === Number.MAX_SAFE_INTEGER) { + failTerminalSessionAuthority('capacity', 'authority revision is exhausted') + } + const digest = createHash('sha256').update(correlationId).digest('hex') + const operationId = `${TERMINAL_AUTHORITY_OPERATION_PREFIX}${baseRevision + 1}:${digest}` + return Object.freeze({ operationId, outcomeId: operationId }) +} + +export function assertTerminalAuthorityOperationIdentity( + request: TerminalSessionAuthorityMutationRequest +): void { + if (request.baseRevision === Number.MAX_SAFE_INTEGER) { + failTerminalSessionAuthority('capacity', 'authority revision is exhausted') + } + const expectedPrefix = `${TERMINAL_AUTHORITY_OPERATION_PREFIX}${request.baseRevision + 1}:` + if ( + !TERMINAL_AUTHORITY_OPERATION_PATTERN.test(request.operationId) || + !request.operationId.startsWith(expectedPrefix) || + request.outcomeId !== request.operationId + ) { + failTerminalSessionAuthority('operation-conflict', 'mutation identity is not canonical') + } +} diff --git a/src/shared/terminal-session-authority-outcome-journal.ts b/src/shared/terminal-session-authority-outcome-journal.ts new file mode 100644 index 00000000000..113db5378ae --- /dev/null +++ b/src/shared/terminal-session-authority-outcome-journal.ts @@ -0,0 +1,315 @@ +import { + assertAuthorityId, + type TerminalAuthorityNamespace, + type TerminalSessionBinding +} from './terminal-session-authority-identity' +import { + failTerminalSessionAuthority, + type TerminalAuthorityConsumerSnapshot, + type TerminalAuthorityDurableOutcome, + type TerminalAuthorityOutcome, + type TerminalAuthorityOutcomeRead, + type TerminalAuthoritySemanticOutcome, + type TerminalSessionAuthorityLogEvent, + type TerminalSessionAuthorityMutationRequest, + type TerminalSessionAuthoritySemanticOutcomeRequest +} from './terminal-session-authority-mutation' +import { + TerminalSessionAuthorityOutcomeStream, + type TerminalAuthorityOutcomeStreamSnapshot +} from './terminal-session-authority-outcome-stream' +import { assertSafeInteger } from './terminal-session-authority-record-validation' +import { assertSemanticallyEqual } from './terminal-session-authority-semantic-equality' +import { assertConsumerSnapshotShape } from './terminal-session-authority-restore-validation' + +type MutableConsumer = { + consumerId: string + activeIncarnationId: string + acknowledgedSequence: number +} + +export type TerminalAuthorityOutcomeJournalSnapshot = TerminalAuthorityOutcomeStreamSnapshot & + Readonly<{ consumers: readonly TerminalAuthorityConsumerSnapshot[] }> + +export class TerminalSessionAuthorityOutcomeJournal { + private readonly consumers = new Map() + private readonly stream: TerminalSessionAuthorityOutcomeStream + + constructor( + private readonly maxConsumers: number, + maxEntries: number, + maxBytes: number, + maxSemanticProducers: number + ) { + this.stream = new TerminalSessionAuthorityOutcomeStream( + maxEntries, + maxBytes, + maxSemanticProducers + ) + } + + get highWatermark(): number { + return this.stream.highWatermark + } + + materializedProjection(): readonly TerminalAuthorityDurableOutcome[] { + return this.stream.materializedProjection() + } + + planClaim( + consumerId: string, + expectedIncarnationId: string | null, + consumerIncarnationId: string + ): Extract | null { + assertAuthorityId(consumerId, 'consumerId') + assertAuthorityId(consumerIncarnationId, 'consumerIncarnationId') + const current = this.consumers.get(consumerId) + if (current?.activeIncarnationId === consumerIncarnationId) { + return null + } + if ( + current + ? current.activeIncarnationId !== expectedIncarnationId + : expectedIncarnationId !== null + ) { + failTerminalSessionAuthority('consumer-conflict', 'consumer incarnation changed') + } + if (!current && this.consumers.size >= this.maxConsumers) { + failTerminalSessionAuthority('capacity', 'authority outcome consumers are full') + } + return Object.freeze({ + kind: 'consumer-claim', + consumerId, + expectedIncarnationId, + consumerIncarnationId, + acknowledgedSequence: current?.acknowledgedSequence ?? this.stream.highWatermark + }) + } + + applyClaim(event: Extract): void { + assertSemanticallyEqual( + this.planClaim(event.consumerId, event.expectedIncarnationId, event.consumerIncarnationId), + event, + 'consumer claim is not canonical' + ) + const current = this.consumers.get(event.consumerId) + if (current) { + current.activeIncarnationId = event.consumerIncarnationId + } else { + this.consumers.set(event.consumerId, { + consumerId: event.consumerId, + activeIncarnationId: event.consumerIncarnationId, + acknowledgedSequence: event.acknowledgedSequence + }) + } + this.compactAcknowledged() + } + + planRetire( + consumerId: string, + consumerIncarnationId: string + ): TerminalSessionAuthorityLogEvent | null { + assertAuthorityId(consumerId, 'consumerId') + assertAuthorityId(consumerIncarnationId, 'consumerIncarnationId') + const current = this.consumers.get(consumerId) + if (!current) { + return null + } + this.requireExactConsumer(consumerId, consumerIncarnationId) + return Object.freeze({ kind: 'consumer-retire', consumerId, consumerIncarnationId }) + } + + applyRetire(event: Extract): void { + assertSemanticallyEqual( + this.planRetire(event.consumerId, event.consumerIncarnationId), + event, + 'consumer retirement is not canonical' + ) + this.consumers.delete(event.consumerId) + this.compactAcknowledged() + } + + findMutation( + request: TerminalSessionAuthorityMutationRequest, + replayFloorRevision: number + ): TerminalAuthorityOutcome | null { + return this.stream.findMutation(request, replayFloorRevision) + } + + findSemantic( + request: TerminalSessionAuthoritySemanticOutcomeRequest + ): TerminalAuthoritySemanticOutcome | null { + return this.stream.findSemantic(request) + } + + nextSequence(outcomeId: string): number { + return this.stream.nextSequence(outcomeId) + } + + assertCanAppend(outcome: TerminalAuthorityDurableOutcome): void { + this.stream.assertCanAppend(outcome) + } + + applyOutcome(outcome: TerminalAuthorityDurableOutcome): void { + this.stream.applyOutcome(outcome) + } + + retireSemanticProducers(binding: TerminalSessionBinding): void { + this.stream.retireSemanticProducers(binding) + } + + planAck( + consumerId: string, + consumerIncarnationId: string, + sequence: number + ): TerminalSessionAuthorityLogEvent | null { + const consumer = this.requireExactConsumer(consumerId, consumerIncarnationId) + assertSafeInteger(sequence, 'outcome ACK sequence') + if (sequence <= consumer.acknowledgedSequence) { + return null + } + if (sequence > this.stream.highWatermark) { + failTerminalSessionAuthority('expectation-mismatch', 'ACK advances beyond the outcome tail') + } + for (let candidate = consumer.acknowledgedSequence + 1; candidate <= sequence; candidate++) { + if (!this.stream.has(candidate)) { + failTerminalSessionAuthority('record-corrupt', 'outcome ACK crosses a journal gap') + } + } + return Object.freeze({ kind: 'outcome-ack', consumerId, consumerIncarnationId, sequence }) + } + + applyAck(event: Extract): void { + assertSemanticallyEqual( + this.planAck(event.consumerId, event.consumerIncarnationId, event.sequence), + event, + 'outcome ACK is not canonical' + ) + this.requireConsumer(event.consumerId).acknowledgedSequence = event.sequence + this.compactAcknowledged() + } + + read( + consumerId: string, + consumerIncarnationId: string, + afterSequence: number, + limit: number + ): TerminalAuthorityOutcomeRead { + const consumer = this.requireExactConsumer(consumerId, consumerIncarnationId) + assertSafeInteger(afterSequence, 'outcome cursor') + assertSafeInteger(limit, 'outcome read limit', 1) + if (afterSequence < consumer.acknowledgedSequence) { + return resnapshot('cursor-compacted', consumer.acknowledgedSequence) + } + if (afterSequence > this.stream.highWatermark) { + return resnapshot('cursor-ahead', consumer.acknowledgedSequence) + } + const entries = this.stream.read(afterSequence, limit) + return entries + ? Object.freeze({ kind: 'entries', entries: Object.freeze(structuredClone(entries)) }) + : resnapshot('cursor-gap', consumer.acknowledgedSequence) + } + + cursor( + consumerId: string, + incarnationId: string + ): Readonly<{ acknowledgedSequence: number; outcomeHighWatermark: number }> { + const consumer = this.requireExactConsumer(consumerId, incarnationId) + return { + acknowledgedSequence: consumer.acknowledgedSequence, + outcomeHighWatermark: this.stream.highWatermark + } + } + + activeIncarnation(consumerId: string): string | null { + return this.consumers.get(consumerId)?.activeIncarnationId ?? null + } + + get hasConsumers(): boolean { + return this.consumers.size > 0 + } + + snapshot(): TerminalAuthorityOutcomeJournalSnapshot { + return Object.freeze({ + consumers: Object.freeze( + [...this.consumers.values()] + .sort((left, right) => left.consumerId.localeCompare(right.consumerId)) + .map((consumer) => Object.freeze({ ...consumer })) + ), + ...this.stream.snapshot() + }) + } + + restore( + snapshot: TerminalAuthorityOutcomeJournalSnapshot, + namespace: TerminalAuthorityNamespace, + revision: number + ): void { + this.stream.restore(snapshot, namespace, revision) + for (const consumer of snapshot.consumers) { + this.restoreConsumer(consumer) + } + // With consumers the floor is exactly their minimum cursor. With none it is wherever the last + // retirement left it — or still 0 on a namespace that has only ever been observed, since an + // observer never claims and so never compacts. + const cursorFloor = this.minimumConsumerCursor() + const floorIsDurable = + cursorFloor === null + ? this.stream.floorSequence <= this.stream.highWatermark + : cursorFloor === this.stream.floorSequence + if (!floorIsDurable) { + failTerminalSessionAuthority( + 'record-corrupt', + 'outcome floor is not the durable cursor floor' + ) + } + } + + private restoreConsumer(snapshot: TerminalAuthorityConsumerSnapshot): void { + assertConsumerSnapshotShape(snapshot) + if ( + this.consumers.has(snapshot.consumerId) || + this.consumers.size >= this.maxConsumers || + snapshot.acknowledgedSequence < this.stream.floorSequence || + snapshot.acknowledgedSequence > this.stream.highWatermark + ) { + failTerminalSessionAuthority('record-corrupt', 'consumer cursor is inconsistent') + } + this.consumers.set(snapshot.consumerId, { ...snapshot }) + } + + private compactAcknowledged(): void { + this.stream.compactThrough(this.minimumConsumerCursor() ?? this.stream.highWatermark) + } + + private minimumConsumerCursor(): number | null { + let minimum: number | null = null + for (const consumer of this.consumers.values()) { + minimum = Math.min(minimum ?? consumer.acknowledgedSequence, consumer.acknowledgedSequence) + } + return minimum + } + + private requireConsumer(consumerId: string): MutableConsumer { + const consumer = this.consumers.get(consumerId) + if (!consumer) { + failTerminalSessionAuthority('consumer-unknown', 'outcome consumer is not claimed') + } + return consumer + } + + private requireExactConsumer(consumerId: string, incarnationId: string): MutableConsumer { + const consumer = this.requireConsumer(consumerId) + if (consumer.activeIncarnationId !== incarnationId) { + failTerminalSessionAuthority('consumer-conflict', 'consumer incarnation is stale') + } + return consumer + } +} + +function resnapshot( + reason: Extract['reason'], + acknowledgedSequence: number +): TerminalAuthorityOutcomeRead { + return Object.freeze({ kind: 'resnapshot-required', reason, acknowledgedSequence }) +} diff --git a/src/shared/terminal-session-authority-outcome-keys.ts b/src/shared/terminal-session-authority-outcome-keys.ts new file mode 100644 index 00000000000..8b71f7320d9 --- /dev/null +++ b/src/shared/terminal-session-authority-outcome-keys.ts @@ -0,0 +1,51 @@ +import { isDeepStrictEqual } from 'node:util' +import type { + TerminalAuthorityDurableOutcome, + TerminalSessionAuthorityMutationRequest +} from './terminal-session-authority-mutation' +import type { TerminalSessionAuthorityPtyAccess } from './terminal-session-authority-pty-access' +import { terminalAuthorityOperationKey } from './terminal-session-authority-transition' + +export function mutationOperationKey(outcome: TerminalAuthorityDurableOutcome): string | null { + return outcome.kind === 'semantic' + ? null + : terminalAuthorityOperationKey(outcome.request.actorId, outcome.request.operationId) +} + +export function mutationRequestOperationKey( + request: TerminalSessionAuthorityMutationRequest +): string { + return terminalAuthorityOperationKey(request.actorId, request.operationId) +} + +export function semanticOutcomeOperationKey( + outcome: TerminalAuthorityDurableOutcome +): string | null { + return outcome.kind === 'semantic' + ? semanticOperationKey(outcome.access, outcome.producerIncarnationId, outcome.producerSequence) + : null +} + +export function sameOperationRequest( + left: TerminalSessionAuthorityMutationRequest, + right: TerminalSessionAuthorityMutationRequest +): boolean { + const { baseRevision: _leftRevision, ...leftOperation } = left + const { baseRevision: _rightRevision, ...rightOperation } = right + return isDeepStrictEqual(leftOperation, rightOperation) +} + +export function semanticProducerKey( + access: TerminalSessionAuthorityPtyAccess, + producerIncarnationId: string +): string { + return JSON.stringify([access.namespace, access.pane, access.binding, producerIncarnationId]) +} + +export function semanticOperationKey( + access: TerminalSessionAuthorityPtyAccess, + producerIncarnationId: string, + producerSequence: number +): string { + return JSON.stringify([semanticProducerKey(access, producerIncarnationId), producerSequence]) +} diff --git a/src/shared/terminal-session-authority-outcome-page-validation.ts b/src/shared/terminal-session-authority-outcome-page-validation.ts new file mode 100644 index 00000000000..dd7f629a05b --- /dev/null +++ b/src/shared/terminal-session-authority-outcome-page-validation.ts @@ -0,0 +1,39 @@ +import { isDeepStrictEqual } from 'node:util' +import type { TerminalAuthorityNamespace } from './terminal-session-authority-identity' +import type { TerminalAuthorityDurableOutcome } from './terminal-session-authority-mutation' +import { parseTerminalAuthorityDurableOutcome } from './terminal-authority-durable-outcome-validation' +import { terminalAuthorityOutcomeMatchesNamespace } from './terminal-session-authority-boundary-projection-validation' + +const MAX_TERMINAL_AUTHORITY_OUTCOME_PAGE_SIZE = 64 + +export function parseTerminalAuthorityOutcomePage( + value: unknown, + first: TerminalAuthorityDurableOutcome, + namespace: TerminalAuthorityNamespace +): readonly TerminalAuthorityDurableOutcome[] | null { + if ( + !Array.isArray(value) || + value.length < 1 || + value.length > MAX_TERMINAL_AUTHORITY_OUTCOME_PAGE_SIZE + ) { + return null + } + const outcomes = value.map(parseTerminalAuthorityDurableOutcome) + if (outcomes.some((outcome) => !outcome)) { + return null + } + const parsed = outcomes as TerminalAuthorityDurableOutcome[] + if (!isDeepStrictEqual(parsed[0], first)) { + return null + } + for (let index = 0; index < parsed.length; index += 1) { + const outcome = parsed[index]! + if ( + outcome.sequence !== first.sequence + index || + !terminalAuthorityOutcomeMatchesNamespace(outcome, namespace) + ) { + return null + } + } + return Object.freeze(parsed) +} diff --git a/src/shared/terminal-session-authority-outcome-stream.ts b/src/shared/terminal-session-authority-outcome-stream.ts new file mode 100644 index 00000000000..9d38d1369b9 --- /dev/null +++ b/src/shared/terminal-session-authority-outcome-stream.ts @@ -0,0 +1,307 @@ +import { + assertAuthorityId, + sameTerminalBinding, + type TerminalAuthorityNamespace, + type TerminalSessionBinding +} from './terminal-session-authority-identity' +import { + failTerminalSessionAuthority, + type TerminalAuthorityDurableOutcome, + type TerminalAuthorityOutcome, + type TerminalAuthoritySemanticOutcome, + type TerminalAuthoritySemanticProducerSnapshot, + type TerminalSessionAuthorityMutationRequest, + type TerminalSessionAuthoritySemanticOutcomeRequest +} from './terminal-session-authority-mutation' +import { assertTerminalAuthorityOperationIdentity } from './terminal-session-authority-operation-identity' +import type { TerminalSessionAuthorityPtyAccess } from './terminal-session-authority-pty-access' +import { TerminalSessionAuthorityMaterializedOutcomes } from './terminal-session-authority-materialized-outcomes' +import { assertSafeInteger } from './terminal-session-authority-record-validation' +import { + assertSemanticProducerSnapshotShape, + validateRestoredOutcome +} from './terminal-session-authority-restore-validation' +import { + mutationOperationKey, + mutationRequestOperationKey, + sameOperationRequest, + semanticOperationKey, + semanticOutcomeOperationKey, + semanticProducerKey +} from './terminal-session-authority-outcome-keys' + +type MutableSemanticProducer = { + access: TerminalSessionAuthorityPtyAccess + producerIncarnationId: string + producerSequence: number +} + +export type TerminalAuthorityOutcomeStreamSnapshot = Readonly<{ + outcomeFloorSequence: number + nextOutcomeSequence: number + outcomes: readonly TerminalAuthorityDurableOutcome[] + semanticProducers: readonly TerminalAuthoritySemanticProducerSnapshot[] + materializedOutcomes?: readonly TerminalAuthorityDurableOutcome[] +}> + +export class TerminalSessionAuthorityOutcomeStream { + private readonly outcomes = new Map() + private readonly outcomesById = new Map() + private readonly operations = new Map() + private readonly semanticOperations = new Map() + private readonly semanticProducers = new Map() + private readonly materialized: TerminalSessionAuthorityMaterializedOutcomes + private floorSequenceValue = 0 + private nextSequenceValue = 1 + private retainedBytes = 0 + + constructor( + private readonly maxEntries: number, + private readonly maxBytes: number, + private readonly maxSemanticProducers: number + ) { + this.materialized = new TerminalSessionAuthorityMaterializedOutcomes(maxEntries, maxBytes) + } + + get floorSequence(): number { + return this.floorSequenceValue + } + + get highWatermark(): number { + return this.nextSequenceValue - 1 + } + + materializedProjection(): readonly TerminalAuthorityDurableOutcome[] { + return this.materialized.projection() + } + + has(sequence: number): boolean { + return this.outcomes.has(sequence) + } + + read(afterSequence: number, limit: number): readonly TerminalAuthorityDurableOutcome[] | null { + const entries: TerminalAuthorityDurableOutcome[] = [] + const last = Math.min(this.highWatermark, afterSequence + limit) + for (let sequence = afterSequence + 1; sequence <= last; sequence++) { + const outcome = this.outcomes.get(sequence) + if (!outcome) { + return null + } + entries.push(outcome) + } + return entries + } + + findMutation( + request: TerminalSessionAuthorityMutationRequest, + replayFloorRevision: number + ): TerminalAuthorityOutcome | null { + assertTerminalAuthorityOperationIdentity(request) + const existing = this.operations.get(mutationRequestOperationKey(request)) + if (existing && !sameOperationRequest(existing.request, request)) { + failTerminalSessionAuthority('operation-conflict', 'operation ID was reused') + } + if (!existing && request.baseRevision + 1 <= replayFloorRevision) { + failTerminalSessionAuthority('operation-conflict', 'operation outcome was compacted') + } + return existing ?? null + } + + findSemantic( + request: TerminalSessionAuthoritySemanticOutcomeRequest + ): TerminalAuthoritySemanticOutcome | null { + const operationKey = semanticOperationKey( + request.access, + request.producerIncarnationId, + request.producerSequence + ) + const existing = this.semanticOperations.get(operationKey) + if (existing) { + return existing + } + const producer = this.semanticProducers.get( + semanticProducerKey(request.access, request.producerIncarnationId) + ) + if (producer && request.producerSequence <= producer.producerSequence) { + failTerminalSessionAuthority('operation-conflict', 'semantic outcome was compacted') + } + return null + } + + nextSequence(outcomeId: string): number { + assertAuthorityId(outcomeId, 'outcomeId') + if (this.outcomesById.has(outcomeId)) { + failTerminalSessionAuthority('operation-conflict', 'outcome ID was reused') + } + return this.nextSequenceValue + } + + assertCanAppend(outcome: TerminalAuthorityDurableOutcome): void { + if ( + this.outcomes.size + 1 > this.maxEntries || + this.retainedBytes + outcome.byteLength > this.maxBytes + ) { + failTerminalSessionAuthority('capacity', 'durable outcome retention is full') + } + this.materialized.assertCanApply(outcome) + if (outcome.kind !== 'semantic') { + return + } + const producer = this.semanticProducers.get( + semanticProducerKey(outcome.access, outcome.producerIncarnationId) + ) + if (!producer && this.semanticProducers.size >= this.maxSemanticProducers) { + failTerminalSessionAuthority('capacity', 'semantic outcome producers are full') + } + if (outcome.producerSequence !== (producer?.producerSequence ?? 0) + 1) { + failTerminalSessionAuthority('expectation-mismatch', 'semantic producer sequence has a gap') + } + } + + applyOutcome(outcome: TerminalAuthorityDurableOutcome): void { + if (outcome.sequence !== this.nextSequenceValue) { + failTerminalSessionAuthority('record-corrupt', 'outcome journal is not contiguous') + } + this.assertCanAppend(outcome) + this.registerOutcome(outcome) + if (outcome.kind === 'semantic') { + this.semanticProducers.set( + semanticProducerKey(outcome.access, outcome.producerIncarnationId), + { + access: outcome.access, + producerIncarnationId: outcome.producerIncarnationId, + producerSequence: outcome.producerSequence + } + ) + } + this.materialized.apply(outcome) + this.nextSequenceValue += 1 + } + + retireSemanticProducers(binding: TerminalSessionBinding): void { + for (const [key, producer] of this.semanticProducers) { + if (sameTerminalBinding(producer.access.binding, binding)) { + this.semanticProducers.delete(key) + } + } + } + + compactThrough(floor: number): void { + if (floor <= this.floorSequenceValue) { + return + } + for (let sequence = this.floorSequenceValue + 1; sequence <= floor; sequence++) { + const outcome = this.outcomes.get(sequence) + if (!outcome) { + failTerminalSessionAuthority('record-corrupt', 'outcome compaction crossed a journal gap') + } + this.removeOutcome(outcome) + } + this.floorSequenceValue = floor + } + + snapshot(): TerminalAuthorityOutcomeStreamSnapshot { + return Object.freeze({ + outcomeFloorSequence: this.floorSequenceValue, + nextOutcomeSequence: this.nextSequenceValue, + outcomes: Object.freeze([...this.outcomes.values()]), + materializedOutcomes: this.materialized.projection(), + semanticProducers: Object.freeze( + [...this.semanticProducers.values()] + .sort((left, right) => + semanticProducerKey(left.access, left.producerIncarnationId).localeCompare( + semanticProducerKey(right.access, right.producerIncarnationId) + ) + ) + .map((producer) => Object.freeze(structuredClone(producer))) + ) + }) + } + + restore( + snapshot: TerminalAuthorityOutcomeStreamSnapshot, + namespace: TerminalAuthorityNamespace, + revision: number + ): void { + assertSafeInteger(snapshot.outcomeFloorSequence, 'outcome floor sequence') + assertSafeInteger(snapshot.nextOutcomeSequence, 'next outcome sequence', 1) + if (snapshot.outcomeFloorSequence >= snapshot.nextOutcomeSequence) { + failTerminalSessionAuthority('record-corrupt', 'outcome journal cursor is inconsistent') + } + this.floorSequenceValue = snapshot.outcomeFloorSequence + this.nextSequenceValue = snapshot.nextOutcomeSequence + this.restoreSemanticProducers(snapshot.semanticProducers, namespace) + for (const outcome of snapshot.materializedOutcomes ?? []) { + validateRestoredOutcome(outcome, namespace, revision) + if (outcome.sequence >= snapshot.nextOutcomeSequence) { + failTerminalSessionAuthority( + 'record-corrupt', + 'materialized outcome exceeds the journal high-water mark' + ) + } + } + this.materialized.restore(snapshot.materializedOutcomes ?? []) + let expectedSequence = this.floorSequenceValue + 1 + for (const outcome of snapshot.outcomes) { + if (outcome.sequence !== expectedSequence) { + failTerminalSessionAuthority('record-corrupt', 'outcome snapshot contains a journal gap') + } + validateRestoredOutcome(outcome, namespace, revision) + this.registerOutcome(Object.freeze(outcome)) + expectedSequence += 1 + } + if (expectedSequence !== this.nextSequenceValue) { + failTerminalSessionAuthority('record-corrupt', 'outcome snapshot tail is inconsistent') + } + } + + private restoreSemanticProducers( + snapshots: readonly TerminalAuthoritySemanticProducerSnapshot[], + namespace: TerminalAuthorityNamespace + ): void { + for (const snapshot of snapshots) { + assertSemanticProducerSnapshotShape(snapshot, namespace) + const key = semanticProducerKey(snapshot.access, snapshot.producerIncarnationId) + if ( + this.semanticProducers.has(key) || + this.semanticProducers.size >= this.maxSemanticProducers + ) { + failTerminalSessionAuthority('record-corrupt', 'semantic producer cursor is inconsistent') + } + this.semanticProducers.set(key, structuredClone(snapshot)) + } + } + + private registerOutcome(outcome: TerminalAuthorityDurableOutcome): void { + const operation = mutationOperationKey(outcome) + const semanticOperation = semanticOutcomeOperationKey(outcome) + if ( + this.outcomes.has(outcome.sequence) || + this.outcomesById.has(outcome.outcomeId) || + (operation !== null && this.operations.has(operation)) || + (semanticOperation !== null && this.semanticOperations.has(semanticOperation)) + ) { + failTerminalSessionAuthority('record-corrupt', 'outcome identity is duplicated') + } + this.outcomes.set(outcome.sequence, outcome) + this.outcomesById.set(outcome.outcomeId, outcome) + if (outcome.kind === 'semantic') { + this.semanticOperations.set(semanticOperation!, outcome) + } else { + this.operations.set(operation!, outcome) + } + this.retainedBytes += outcome.byteLength + } + + private removeOutcome(outcome: TerminalAuthorityDurableOutcome): void { + this.outcomes.delete(outcome.sequence) + this.outcomesById.delete(outcome.outcomeId) + const semanticOperation = semanticOutcomeOperationKey(outcome) + if (semanticOperation !== null) { + this.semanticOperations.delete(semanticOperation) + } else { + this.operations.delete(mutationOperationKey(outcome)!) + } + this.retainedBytes -= outcome.byteLength + } +} diff --git a/src/shared/terminal-session-authority-owner-fence.ts b/src/shared/terminal-session-authority-owner-fence.ts new file mode 100644 index 00000000000..67a4a261bea --- /dev/null +++ b/src/shared/terminal-session-authority-owner-fence.ts @@ -0,0 +1,48 @@ +import type { TerminalSessionBinding } from './terminal-session-authority-identity' +import { failTerminalSessionAuthority } from './terminal-session-authority-mutation' + +type TerminalAuthorityOwnerFenceView = Readonly<{ + ownerIncarnationId: string + ownerIsReachable: (ownerIncarnationId: string) => boolean +}> + +export function assertCurrentAllocationOwner( + view: TerminalAuthorityOwnerFenceView, + ownerIncarnationId: string, + replayPersistedEvent: boolean +): void { + if (!replayPersistedEvent && ownerIncarnationId !== view.ownerIncarnationId) { + failTerminalSessionAuthority('allocation-conflict', 'allocation owner is unreachable') + } +} + +export function assertCurrentBindingOwner( + view: TerminalAuthorityOwnerFenceView, + binding: TerminalSessionBinding | null, + replayPersistedEvent: boolean, + operation: 'close' | 'supersede' +): void { + if ( + !replayPersistedEvent && + binding !== null && + binding.ownerIncarnationId !== view.ownerIncarnationId + ) { + failTerminalSessionAuthority( + 'expectation-mismatch', + `cannot ${operation} a binding owned by another incarnation` + ) + } +} + +export function assertReachableBindingOwner( + view: TerminalAuthorityOwnerFenceView, + binding: TerminalSessionBinding, + replayPersistedEvent: boolean +): void { + if (!replayPersistedEvent && !view.ownerIsReachable(binding.ownerIncarnationId)) { + failTerminalSessionAuthority( + 'expectation-mismatch', + 'an unreachable owner cannot publish terminal exit' + ) + } +} diff --git a/src/shared/terminal-session-authority-owner-scoped-pty-index.test.ts b/src/shared/terminal-session-authority-owner-scoped-pty-index.test.ts new file mode 100644 index 00000000000..3e73eaaa1be --- /dev/null +++ b/src/shared/terminal-session-authority-owner-scoped-pty-index.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest' +import type { + TerminalPaneAuthorityRecord, + TerminalSessionPtyAllocation +} from './terminal-session-authority-mutation' +import { TerminalSessionAuthorityTopology } from './terminal-session-authority-topology' + +describe('terminal authority owner-scoped PTY indexes', () => { + it('keeps identical physical and incarnation IDs distinct across owners', () => { + const topology = new TerminalSessionAuthorityTopology('current-owner', 4, 4, () => true) + const panes = [pane('pane-a', 'owner-a', 1), pane('pane-b', 'owner-b', 2)] + const allocations = [allocation(panes[0]!, 'owner-a', 1), allocation(panes[1]!, 'owner-b', 2)] + + topology.restore(panes, allocations, 2) + + expect(topology.ptyOwner(panes[0]!.binding!)).toBe(JSON.stringify(['pane-a', 'generation'])) + expect(topology.ptyOwner(panes[1]!.binding!)).toBe(JSON.stringify(['pane-b', 'generation'])) + expect(topology.allocationSnapshot()).toHaveLength(2) + }) +}) + +function pane( + paneKey: string, + ownerIncarnationId: string, + revision: number +): TerminalPaneAuthorityRecord { + const binding = Object.freeze({ + ownerIncarnationId, + physicalPtyId: 'pty-1', + ptyIncarnationId: 'incarnation-1' + }) + return Object.freeze({ + paneKey, + paneGenerationId: 'generation', + status: 'open', + binding, + lastBinding: binding, + revision + }) +} + +function allocation( + paneRecord: TerminalPaneAuthorityRecord, + ownerIncarnationId: string, + revision: number +): TerminalSessionPtyAllocation { + return Object.freeze({ + allocationId: `allocation-${ownerIncarnationId}`, + pane: Object.freeze({ + paneKey: paneRecord.paneKey, + paneGenerationId: paneRecord.paneGenerationId + }), + ownerIncarnationId, + physicalPtyId: 'pty-1', + spawnFingerprint: `spawn-${ownerIncarnationId}`, + intentActorId: `actor-${ownerIncarnationId}`, + intentOperationId: `operation-${ownerIncarnationId}`, + preparedAtRevision: revision, + status: 'committed', + binding: paneRecord.binding!, + committedAtRevision: revision + }) +} diff --git a/src/shared/terminal-session-authority-pane-binding-index.test.ts b/src/shared/terminal-session-authority-pane-binding-index.test.ts new file mode 100644 index 00000000000..79d7c3fbf8f --- /dev/null +++ b/src/shared/terminal-session-authority-pane-binding-index.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import type { TerminalSessionBinding } from './terminal-session-authority-identity' +import { TerminalAuthorityPaneBindingIndex } from './terminal-session-authority-pane-binding-index' + +describe('TerminalAuthorityPaneBindingIndex', () => { + it('retains owner reachability until its final pane binding retires', () => { + const index = new TerminalAuthorityPaneBindingIndex() + const first = binding('owner-a', 'pty-a', 'incarnation-a') + const second = binding('owner-a', 'pty-b', 'incarnation-b') + const replacement = binding('owner-b', 'pty-c', 'incarnation-c') + + index.replace(null, first, 'pane-a') + index.replace(null, second, 'pane-b') + expect(index.ownerHasBinding('owner-a')).toBe(true) + expect(index.ptyOwner(first)).toBe('pane-a') + expect(index.ptyOwner(second)).toBe('pane-b') + + index.replace(first, null, 'pane-a') + expect(index.ownerHasBinding('owner-a')).toBe(true) + expect(index.ptyOwner(first)).toBeNull() + + index.replace(second, replacement, 'pane-b') + expect(index.ownerHasBinding('owner-a')).toBe(false) + expect(index.ownerHasBinding('owner-b')).toBe(true) + expect(index.ptyOwner(second)).toBeNull() + expect(index.ptyOwner(replacement)).toBe('pane-b') + }) +}) + +function binding( + ownerIncarnationId: string, + physicalPtyId: string, + ptyIncarnationId: string +): TerminalSessionBinding { + return { ownerIncarnationId, physicalPtyId, ptyIncarnationId } +} diff --git a/src/shared/terminal-session-authority-pane-binding-index.ts b/src/shared/terminal-session-authority-pane-binding-index.ts new file mode 100644 index 00000000000..cd486036b16 --- /dev/null +++ b/src/shared/terminal-session-authority-pane-binding-index.ts @@ -0,0 +1,45 @@ +import { + terminalPtyIncarnationKey, + type TerminalSessionBinding +} from './terminal-session-authority-identity' + +export class TerminalAuthorityPaneBindingIndex { + private readonly paneByPtyIncarnation = new Map() + private readonly bindingCountByOwner = new Map() + + ptyOwner(binding: TerminalSessionBinding): string | null { + return this.paneByPtyIncarnation.get(terminalPtyIncarnationKey(binding)) ?? null + } + + ownerHasBinding(ownerIncarnationId: string): boolean { + return this.bindingCountByOwner.has(ownerIncarnationId) + } + + replace( + previous: TerminalSessionBinding | null, + next: TerminalSessionBinding | null, + paneGenerationKey: string + ): void { + if (previous) { + this.paneByPtyIncarnation.delete(terminalPtyIncarnationKey(previous)) + this.removeOwnerBinding(previous.ownerIncarnationId) + } + if (!next) { + return + } + this.paneByPtyIncarnation.set(terminalPtyIncarnationKey(next), paneGenerationKey) + this.bindingCountByOwner.set( + next.ownerIncarnationId, + (this.bindingCountByOwner.get(next.ownerIncarnationId) ?? 0) + 1 + ) + } + + private removeOwnerBinding(ownerIncarnationId: string): void { + const count = this.bindingCountByOwner.get(ownerIncarnationId) + if (count === undefined || count <= 1) { + this.bindingCountByOwner.delete(ownerIncarnationId) + } else { + this.bindingCountByOwner.set(ownerIncarnationId, count - 1) + } + } +} diff --git a/src/shared/terminal-session-authority-pane-history.ts b/src/shared/terminal-session-authority-pane-history.ts new file mode 100644 index 00000000000..cb3977bb4c3 --- /dev/null +++ b/src/shared/terminal-session-authority-pane-history.ts @@ -0,0 +1,24 @@ +import { terminalPaneGenerationKey } from './terminal-session-authority-identity' +import type { TerminalPaneAuthorityRecord } from './terminal-session-authority-mutation' + +export function reclaimSupersededTerminalAuthorityPanes( + panes: Map, + latestPaneByKey: ReadonlyMap +): void { + for (const [key, pane] of panes) { + if ( + pane.status === 'superseded' && + latestPaneByKey.get(pane.paneKey)?.paneGenerationId !== pane.paneGenerationId + ) { + panes.delete(key) + } + } +} + +export function sortTerminalAuthorityPanes( + panes: Iterable +): TerminalPaneAuthorityRecord[] { + return [...panes].sort((left, right) => + terminalPaneGenerationKey(left).localeCompare(terminalPaneGenerationKey(right)) + ) +} diff --git a/src/shared/terminal-session-authority-pane-projection.ts b/src/shared/terminal-session-authority-pane-projection.ts new file mode 100644 index 00000000000..6d39984549b --- /dev/null +++ b/src/shared/terminal-session-authority-pane-projection.ts @@ -0,0 +1,50 @@ +import type { + TerminalPaneAuthorityProjection, + TerminalPaneAuthorityRecord, + TerminalSessionPtyAllocation +} from './terminal-session-authority-mutation' +import type { TerminalSessionBinding } from './terminal-session-authority-identity' + +export function projectTerminalAuthorityPanes( + panes: readonly TerminalPaneAuthorityRecord[], + ownerIsReachable: (ownerIncarnationId: string) => boolean +): readonly TerminalPaneAuthorityProjection[] { + return Object.freeze( + panes.map((pane) => + Object.freeze({ + ...pane, + binding: cloneBinding(pane.binding), + lastBinding: cloneBinding(pane.lastBinding), + ownerStatus: pane.binding + ? ownerIsReachable(pane.binding.ownerIncarnationId) + ? ('reachable' as const) + : ('owner-unreachable' as const) + : null + }) + ) + ) +} + +export function projectTerminalAuthorityAllocations( + allocations: readonly TerminalSessionPtyAllocation[] +): readonly TerminalSessionPtyAllocation[] { + return Object.freeze( + allocations.map( + (allocation): TerminalSessionPtyAllocation => + allocation.status === 'pending' + ? Object.freeze({ + ...allocation, + pane: Object.freeze({ ...allocation.pane }) + }) + : Object.freeze({ + ...allocation, + pane: Object.freeze({ ...allocation.pane }), + binding: Object.freeze({ ...allocation.binding }) + }) + ) + ) +} + +function cloneBinding(binding: TerminalSessionBinding | null): TerminalSessionBinding | null { + return binding ? Object.freeze({ ...binding }) : null +} diff --git a/src/shared/terminal-session-authority-pty-access.ts b/src/shared/terminal-session-authority-pty-access.ts new file mode 100644 index 00000000000..6bbed17a0f1 --- /dev/null +++ b/src/shared/terminal-session-authority-pty-access.ts @@ -0,0 +1,50 @@ +import { + assertAuthorityNamespace, + assertPaneGeneration, + assertTerminalBinding, + isRecord, + sameTerminalBinding, + type TerminalAuthorityNamespace, + type TerminalPaneGeneration, + type TerminalSessionBinding +} from './terminal-session-authority-identity' + +export type TerminalSessionAuthorityPtyAccess = Readonly<{ + namespace: TerminalAuthorityNamespace + pane: TerminalPaneGeneration + binding: TerminalSessionBinding +}> + +export function parseTerminalSessionAuthorityPtyAccess( + value: unknown +): TerminalSessionAuthorityPtyAccess | null { + if (!isRecord(value)) { + return null + } + try { + assertAuthorityNamespace(value.namespace) + assertPaneGeneration(value.pane) + assertTerminalBinding(value.binding) + } catch { + return null + } + return Object.freeze({ + namespace: Object.freeze({ ...value.namespace }), + pane: Object.freeze({ ...value.pane }), + binding: Object.freeze({ ...value.binding }) + }) +} + +export function sameTerminalSessionAuthorityPtyAccess( + left: TerminalSessionAuthorityPtyAccess | null, + right: TerminalSessionAuthorityPtyAccess +): boolean { + return Boolean( + left && + left.namespace.authorityHostId === right.namespace.authorityHostId && + left.namespace.namespaceId === right.namespace.namespaceId && + left.pane.paneKey === right.pane.paneKey && + left.pane.paneGenerationId === right.pane.paneGenerationId && + sameTerminalBinding(left.binding, right.binding) + ) +} diff --git a/src/shared/terminal-session-authority-record-validation.ts b/src/shared/terminal-session-authority-record-validation.ts new file mode 100644 index 00000000000..e3bca58c3cf --- /dev/null +++ b/src/shared/terminal-session-authority-record-validation.ts @@ -0,0 +1,235 @@ +import { + assertAuthorityId, + assertAuthorityNamespace, + assertPaneGeneration, + assertTerminalBinding, + isRecord, + type TerminalAuthorityNamespace +} from './terminal-session-authority-identity' +import { + failTerminalSessionAuthority, + type TerminalPaneAuthorityRecord, + type TerminalSessionAuthorityMutationRequest, + type TerminalSessionAuthorityMutationResult, + type TerminalSessionPtyAllocation, + type TerminalSessionPtyAllocationIdentity +} from './terminal-session-authority-mutation' +import { assertMutationResultMatchesChange } from './terminal-session-authority-result-validation' +export { assertSemanticFact } from './terminal-session-authority-semantic-fact-validation' + +/** One canonical encoding: the outcome minus its own byte count. */ +export function terminalAuthorityOutcomeByteLength(base: object): number { + return new TextEncoder().encode(JSON.stringify(base)).byteLength +} + +export function assertSafeInteger( + value: unknown, + field: string, + minimum = 0 +): asserts value is number { + if (!Number.isSafeInteger(value) || Number(value) < minimum) { + failTerminalSessionAuthority('record-corrupt', `${field} is invalid`) + } +} + +export function assertAllocationIdentity( + value: unknown +): asserts value is TerminalSessionPtyAllocationIdentity { + if (!isRecord(value)) { + failTerminalSessionAuthority('record-corrupt', 'allocation identity is invalid') + } + assertAuthorityId(value.allocationId, 'allocationId') + assertPaneGeneration(value.pane) + assertAuthorityId(value.ownerIncarnationId, 'allocation ownerIncarnationId') + assertAuthorityId(value.physicalPtyId, 'allocation physicalPtyId') + assertAuthorityId(value.spawnFingerprint, 'allocation spawnFingerprint') +} + +export function assertPaneRecord(value: unknown): asserts value is TerminalPaneAuthorityRecord { + if (!isRecord(value)) { + failTerminalSessionAuthority('record-corrupt', 'pane record is invalid') + } + const record = value + assertAuthorityId(record.paneKey, 'paneKey') + assertAuthorityId(record.paneGenerationId, 'paneGenerationId') + if (!['open', 'closed', 'superseded', 'exited'].includes(String(record.status))) { + failTerminalSessionAuthority('record-corrupt', 'pane status is invalid') + } + if (record.binding !== null) { + assertTerminalBinding(record.binding) + } + if (record.lastBinding !== null) { + assertTerminalBinding(record.lastBinding) + } + assertSafeInteger(record.revision, 'pane revision', 1) + if (record.status !== 'open' && record.binding !== null) { + failTerminalSessionAuthority('record-corrupt', 'inactive pane retains a live binding') + } +} + +export function assertAllocationRecord( + value: unknown +): asserts value is TerminalSessionPtyAllocation { + if (!isRecord(value)) { + failTerminalSessionAuthority('record-corrupt', 'allocation is invalid') + } + const record = value + assertAllocationIdentity({ + allocationId: record.allocationId, + pane: record.pane, + ownerIncarnationId: record.ownerIncarnationId, + physicalPtyId: record.physicalPtyId, + spawnFingerprint: record.spawnFingerprint + }) + assertAuthorityId(record.intentActorId, 'allocation intentActorId') + assertAuthorityId(record.intentOperationId, 'allocation intentOperationId') + assertSafeInteger(record.preparedAtRevision, 'allocation prepared revision', 1) + if (record.status === 'pending') { + if (record.binding !== null) { + failTerminalSessionAuthority('record-corrupt', 'pending allocation has a binding') + } + return + } + if (record.status !== 'committed') { + failTerminalSessionAuthority('record-corrupt', 'allocation status is invalid') + } + assertTerminalBinding(record.binding) + assertSafeInteger(record.committedAtRevision, 'allocation committed revision', 1) + if ( + record.committedAtRevision < record.preparedAtRevision || + record.binding.ownerIncarnationId !== record.ownerIncarnationId || + record.binding.physicalPtyId !== record.physicalPtyId + ) { + failTerminalSessionAuthority('record-corrupt', 'committed allocation identity changed') + } +} + +export function assertMutationRequest( + value: unknown +): asserts value is TerminalSessionAuthorityMutationRequest { + if (!isRecord(value) || !isRecord(value.change)) { + failTerminalSessionAuthority('record-corrupt', 'mutation request is invalid') + } + assertAuthorityId(value.actorId, 'actorId') + assertAuthorityId(value.operationId, 'operationId') + assertSafeInteger(value.baseRevision, 'baseRevision') + assertAuthorityId(value.outcomeId, 'outcomeId') + assertChange(value.change) +} + +export function assertMutationResultSemantics( + namespace: TerminalAuthorityNamespace, + request: TerminalSessionAuthorityMutationRequest, + result: unknown +): asserts result is TerminalSessionAuthorityMutationResult { + if (!isRecord(result)) { + failTerminalSessionAuthority('record-corrupt', 'mutation result is invalid') + } + assertAuthorityNamespace(result.namespace) + if (!sameNamespace(namespace, result.namespace)) { + failTerminalSessionAuthority('record-corrupt', 'mutation result namespace changed') + } + if ( + result.actorId !== request.actorId || + result.operationId !== request.operationId || + result.kind !== request.change.kind + ) { + failTerminalSessionAuthority('record-corrupt', 'mutation result identity changed') + } + assertSafeInteger(result.revision, 'result revision', 1) + assertPaneRecord(result.pane) + if (result.replacementPane !== null) { + assertPaneRecord(result.replacementPane) + } + if (result.allocation !== null) { + assertAllocationRecord(result.allocation) + } + if (!Array.isArray(result.effects)) { + failTerminalSessionAuthority('record-corrupt', 'mutation effects are invalid') + } + for (const effect of result.effects) { + assertEffect(effect) + } + assertMutationResultMatchesChange( + request.change, + result as TerminalSessionAuthorityMutationResult + ) +} + +function assertChange(change: Record): void { + if (change.kind === 'create') { + assertPaneGeneration(change.pane) + return + } + if ( + ![ + 'prepare-allocation', + 'commit-allocation', + 'cancel-allocation', + 'close', + 'supersede', + 'exit' + ].includes(String(change.kind)) + ) { + failTerminalSessionAuthority('record-corrupt', 'mutation kind is invalid') + } + assertExpectation(change.expected) + if (String(change.kind).includes('allocation')) { + assertAllocationIdentity(change.allocation) + } else { + assertPaneGeneration(change.pane) + } + if (change.kind === 'commit-allocation') { + assertAuthorityId(change.ptyIncarnationId, 'ptyIncarnationId') + } else if (change.kind === 'supersede') { + assertAuthorityId(change.replacementPaneGenerationId, 'replacementPaneGenerationId') + } else if (change.kind === 'exit') { + assertExit(change.exit) + } +} + +function assertExpectation(value: unknown): void { + if (!isRecord(value)) { + failTerminalSessionAuthority('record-corrupt', 'terminal expectation is invalid') + } + assertAuthorityId(value.paneGenerationId, 'expected paneGenerationId') + if (value.binding !== null) { + assertTerminalBinding(value.binding) + } +} + +function assertExit(value: unknown): void { + if (!isRecord(value)) { + failTerminalSessionAuthority('record-corrupt', 'terminal exit is invalid') + } + if (value.retiredByClose !== undefined && value.retiredByClose !== true) { + failTerminalSessionAuthority('record-corrupt', 'terminal exit close retirement is invalid') + } + if (value.code !== null) { + assertSafeInteger(value.code, 'exit code') + } + if (value.signal !== null) { + assertAuthorityId(value.signal, 'exit signal') + } +} + +function assertEffect(value: unknown): void { + if (!isRecord(value) || !['binding-retired', 'terminal-exited'].includes(String(value.kind))) { + failTerminalSessionAuthority('record-corrupt', 'terminal effect is invalid') + } + assertTerminalBinding(value.binding) + if (value.kind === 'binding-retired') { + if (!['close', 'supersede', 'exit'].includes(String(value.reason))) { + failTerminalSessionAuthority('record-corrupt', 'binding retirement reason is invalid') + } + return + } + assertExit(value) +} + +function sameNamespace( + left: TerminalAuthorityNamespace, + right: TerminalAuthorityNamespace +): boolean { + return left.authorityHostId === right.authorityHostId && left.namespaceId === right.namespaceId +} diff --git a/src/shared/terminal-session-authority-replay-compaction.test.ts b/src/shared/terminal-session-authority-replay-compaction.test.ts new file mode 100644 index 00000000000..e69b43e9623 --- /dev/null +++ b/src/shared/terminal-session-authority-replay-compaction.test.ts @@ -0,0 +1,271 @@ +import { performance } from 'node:perf_hooks' +import { describe, expect, it } from 'vitest' +import type { + TerminalAuthorityOutcome, + TerminalSessionAuthorityChange, + TerminalSessionAuthorityMutationRequest +} from './terminal-session-authority-mutation' +import { terminalAuthorityOperationIdentity } from './terminal-session-authority-operation-identity' +import { TerminalSessionAuthorityState } from './terminal-session-authority-state' + +const namespace = Object.freeze({ authorityHostId: 'host-a', namespaceId: 'namespace-a' }) +const pane = Object.freeze({ paneKey: 'pane-a', paneGenerationId: 'generation-a' }) + +describe('terminal authority global journal compaction', () => { + it('retains one mutation result until every consumer ACKs it', () => { + const state = createState(2) + claim(state, 'consumer-a', 'incarnation-a') + claim(state, 'consumer-b', 'incarnation-b') + const created = mutate(state, 'create', { kind: 'create', pane }) + + acknowledge(state, 'consumer-a', 'incarnation-a', created.outcome.sequence) + expect(state.planMutation(created.request)).toMatchObject({ + duplicate: true, + outcome: { sequence: 1 } + }) + expect(state.readOutcomes('consumer-b', 'incarnation-b', 0)).toMatchObject({ + kind: 'entries', + entries: [{ sequence: 1 }] + }) + + acknowledge(state, 'consumer-b', 'incarnation-b', created.outcome.sequence) + expect(() => state.planMutation(created.request)).toThrowError( + expect.objectContaining({ code: 'operation-conflict' }) + ) + expect(state.snapshot()).toMatchObject({ + outcomeFloorSequence: 1, + nextOutcomeSequence: 2, + outcomes: [], + consumers: [ + { consumerId: 'consumer-a', acknowledgedSequence: 1 }, + { consumerId: 'consumer-b', acknowledgedSequence: 1 } + ] + }) + }) + + it('restores one shared unsettled result with independent cursors', () => { + const first = createState(2) + claim(first, 'consumer-a', 'incarnation-a') + claim(first, 'consumer-b', 'incarnation-b') + const created = mutate(first, 'create', { kind: 'create', pane }) + acknowledge(first, 'consumer-a', 'incarnation-a', created.outcome.sequence) + + const restored = TerminalSessionAuthorityState.restore( + first.snapshot(), + 2, + 'owner-a', + limits(2) + ) + expect(restored.planMutation(created.request)).toMatchObject({ duplicate: true }) + expect(restored.snapshotForConsumer('consumer-a', 'incarnation-a')).toMatchObject({ + acknowledgedSequence: 1, + outcomeHighWatermark: 1 + }) + expect(restored.readOutcomes('consumer-b', 'incarnation-b', 0)).toMatchObject({ + kind: 'entries', + entries: [{ sequence: 1 }] + }) + }) + + it('starts a genuinely new consumer at the tail without resetting existing cursors', () => { + const state = createState(3) + claim(state, 'consumer-a', 'incarnation-a') + mutate(state, 'create', { kind: 'create', pane }) + claim(state, 'consumer-b', 'incarnation-b') + + expect(state.snapshotForConsumer('consumer-a', 'incarnation-a')).toMatchObject({ + acknowledgedSequence: 0, + outcomeHighWatermark: 1 + }) + expect(state.snapshotForConsumer('consumer-b', 'incarnation-b')).toMatchObject({ + acknowledgedSequence: 1, + outcomeHighWatermark: 1 + }) + + const rotation = state.planConsumerClaim('consumer-a', 'incarnation-a', 'incarnation-a-next') + state.applyEvent(rotation!) + expect(state.snapshotForConsumer('consumer-a', 'incarnation-a-next')).toMatchObject({ + acknowledgedSequence: 0 + }) + }) + + it('releases an explicitly retired consumer cursor without accepting a stale incarnation', () => { + const state = createState(2) + claim(state, 'host-effects', 'host-incarnation') + claim(state, 'device-a', 'device-incarnation') + const created = mutate(state, 'create-retirement', { kind: 'create', pane }) + acknowledge(state, 'host-effects', 'host-incarnation', created.outcome.sequence) + + expect(() => state.planConsumerRetirement('device-a', 'stale-incarnation')).toThrowError( + expect.objectContaining({ code: 'consumer-conflict' }) + ) + + const retirement = state.planConsumerRetirement('device-a', 'device-incarnation') + expect(retirement).not.toBeNull() + state.applyEvent(retirement!) + + expect(state.snapshot().outcomeFloorSequence).toBe(created.outcome.sequence) + expect(state.planConsumerRetirement('device-a', 'device-incarnation')).toBeNull() + }) + + it('rejects snapshot journal gaps and cursor floors that could skip delivery', () => { + const state = createState(2) + claim(state, 'consumer-a', 'incarnation-a') + claim(state, 'consumer-b', 'incarnation-b') + mutate(state, 'create', { kind: 'create', pane }) + const snapshot = state.snapshot() + + expect(() => + TerminalSessionAuthorityState.restore({ ...snapshot, outcomes: [] }, 2, 'owner-a', limits(2)) + ).toThrowError(expect.objectContaining({ code: 'record-corrupt' })) + expect(() => + TerminalSessionAuthorityState.restore( + { ...snapshot, outcomeFloorSequence: 1 }, + 2, + 'owner-a', + limits(2) + ) + ).toThrowError(expect.objectContaining({ code: 'record-corrupt' })) + }) + + it('rejects operation and outcome identity reuse after the durable floor', () => { + const state = createState(1) + claim(state, 'consumer-a', 'incarnation-a') + const created = mutate(state, 'create', { kind: 'create', pane }) + acknowledge(state, 'consumer-a', 'incarnation-a', created.outcome.sequence) + const current = request(state, 'close', { + kind: 'close', + pane, + expected: { paneGenerationId: pane.paneGenerationId, binding: null } + }) + + expect(() => + state.planMutation({ + ...current, + operationId: created.request.operationId, + outcomeId: created.request.outcomeId + }) + ).toThrowError(expect.objectContaining({ code: 'operation-conflict' })) + expect(() => + state.planMutation({ ...current, outcomeId: created.request.outcomeId }) + ).toThrowError(expect.objectContaining({ code: 'operation-conflict' })) + }) + + it('keeps checkpoint and reopen cost independent of fully ACKed history', () => { + const state = createState(1) + claim(state, 'consumer-a', 'incarnation-a') + acknowledge( + state, + 'consumer-a', + 'incarnation-a', + mutate(state, 'create', { kind: 'create', pane }).outcome.sequence + ) + + for (let index = 0; index < 2_000; index++) { + const allocation = Object.freeze({ + allocationId: `allocation-${index}`, + pane, + ownerIncarnationId: 'owner-a', + physicalPtyId: `pty-${index}`, + spawnFingerprint: `spawn-${index}` + }) + acknowledge( + state, + 'consumer-a', + 'incarnation-a', + mutate(state, `prepare-${index}`, { + kind: 'prepare-allocation', + allocation, + expected: { paneGenerationId: pane.paneGenerationId, binding: null } + }).outcome.sequence + ) + acknowledge( + state, + 'consumer-a', + 'incarnation-a', + mutate(state, `cancel-${index}`, { + kind: 'cancel-allocation', + allocation, + expected: { paneGenerationId: pane.paneGenerationId, binding: null } + }).outcome.sequence + ) + } + + const snapshot = state.snapshot() + const snapshotBytes = Buffer.byteLength(JSON.stringify(snapshot), 'utf8') + const reopenStartedAt = performance.now() + const reopened = TerminalSessionAuthorityState.restore(snapshot, 2, 'owner-a', limits(1)) + const reopenMs = performance.now() - reopenStartedAt + + expect(snapshotBytes).toBeLessThan(4_096) + expect(reopenMs).toBeLessThan(1_000) + expect(reopened.snapshot()).toMatchObject({ + outcomeFloorSequence: 4_001, + nextOutcomeSequence: 4_002, + outcomes: [], + consumers: [{ acknowledgedSequence: 4_001 }] + }) + }) +}) + +function createState(maxRetainedOperationEntries: number): TerminalSessionAuthorityState { + return new TerminalSessionAuthorityState( + namespace, + 1, + 'owner-a', + limits(maxRetainedOperationEntries) + ) +} + +function limits(maxRetainedOperationEntries: number) { + return { maxRetainedOperationEntries, maxRetainedOperationBytes: 16 * 1024 } +} + +function claim( + state: TerminalSessionAuthorityState, + consumerId: string, + consumerIncarnationId: string +): void { + const event = state.planConsumerClaim(consumerId, null, consumerIncarnationId) + expect(event).not.toBeNull() + state.applyEvent(event!) +} + +function mutate( + state: TerminalSessionAuthorityState, + correlationId: string, + change: TerminalSessionAuthorityChange +): Readonly<{ + request: TerminalSessionAuthorityMutationRequest + outcome: TerminalAuthorityOutcome +}> { + const mutationRequest = request(state, correlationId, change) + const planned = state.planMutation(mutationRequest) + expect(planned.duplicate).toBe(false) + state.applyEvent(Object.freeze({ kind: 'mutation', outcome: planned.outcome })) + return Object.freeze({ request: mutationRequest, outcome: planned.outcome }) +} + +function request( + state: TerminalSessionAuthorityState, + correlationId: string, + change: TerminalSessionAuthorityChange +): TerminalSessionAuthorityMutationRequest { + return Object.freeze({ + actorId: 'actor-a', + ...terminalAuthorityOperationIdentity(state.revision, correlationId), + baseRevision: state.revision, + change + }) +} + +function acknowledge( + state: TerminalSessionAuthorityState, + consumerId: string, + consumerIncarnationId: string, + sequence: number +): void { + const event = state.planOutcomeAck(consumerId, consumerIncarnationId, sequence) + expect(event).not.toBeNull() + state.applyEvent(event!) +} diff --git a/src/shared/terminal-session-authority-restore-validation.ts b/src/shared/terminal-session-authority-restore-validation.ts new file mode 100644 index 00000000000..ffda0e74b28 --- /dev/null +++ b/src/shared/terminal-session-authority-restore-validation.ts @@ -0,0 +1,160 @@ +import { + assertAuthorityId, + isRecord, + sameTerminalBinding, + type TerminalAuthorityNamespace +} from './terminal-session-authority-identity' +import { + failTerminalSessionAuthority, + type TerminalPaneAuthorityRecord, + type TerminalAuthorityConsumerSnapshot, + type TerminalAuthorityDurableOutcome, + type TerminalAuthoritySemanticOutcome, + type TerminalAuthoritySemanticProducerSnapshot, + type TerminalSessionPtyAllocation, + type TerminalSessionPtyAllocationIdentity +} from './terminal-session-authority-mutation' +import { parseTerminalSessionAuthorityPtyAccess } from './terminal-session-authority-pty-access' +import { + assertAllocationRecord, + assertMutationRequest, + assertMutationResultSemantics, + assertPaneRecord, + assertSafeInteger, + assertSemanticFact, + terminalAuthorityOutcomeByteLength +} from './terminal-session-authority-record-validation' +import type { TerminalAuthorityTransitionView } from './terminal-session-authority-transition' + +type TerminalAuthorityTopologyRestoreView = Pick< + TerminalAuthorityTransitionView, + 'pane' | 'openPaneGenerationId' | 'allocation' | 'allocationConflict' | 'ptyOwner' +> & + Readonly<{ + hasIntent: (actorId: string, operationId: string) => boolean + }> + +export function assertRestorableTerminalAuthorityPane( + pane: TerminalPaneAuthorityRecord, + revision: number, + view: TerminalAuthorityTopologyRestoreView +): void { + assertPaneRecord(pane) + if (pane.revision > revision || view.pane(pane)) { + failTerminalSessionAuthority('record-corrupt', 'snapshot pane is inconsistent') + } + if (pane.status === 'open' && view.openPaneGenerationId(pane.paneKey)) { + failTerminalSessionAuthority('record-corrupt', 'snapshot has two open pane generations') + } + if (pane.binding && view.ptyOwner(pane.binding)) { + failTerminalSessionAuthority('record-corrupt', 'snapshot binds one PTY incarnation twice') + } +} + +export function assertRestorableTerminalAuthorityAllocation( + allocation: TerminalSessionPtyAllocation, + revision: number, + view: TerminalAuthorityTopologyRestoreView +): void { + assertAllocationRecord(allocation) + const pane = view.pane(allocation.pane) + if ( + !pane || + allocation.preparedAtRevision > revision || + view.allocation(allocation.allocationId) || + view.allocationConflict(allocation as TerminalSessionPtyAllocationIdentity) || + view.hasIntent(allocation.intentActorId, allocation.intentOperationId) + ) { + failTerminalSessionAuthority('record-corrupt', 'snapshot allocation is inconsistent') + } + if ( + (allocation.status === 'pending' && (pane.status !== 'open' || pane.binding !== null)) || + (allocation.status === 'committed' && + (allocation.committedAtRevision > revision || + pane.status !== 'open' || + !sameTerminalBinding(pane.binding, allocation.binding))) + ) { + failTerminalSessionAuthority('record-corrupt', 'allocation lost its exact open pane') + } +} + +export function assertConsumerSnapshotShape(snapshot: TerminalAuthorityConsumerSnapshot): void { + if (!isRecord(snapshot)) { + failTerminalSessionAuthority('record-corrupt', 'consumer snapshot is invalid') + } + assertAuthorityId(snapshot.consumerId, 'consumerId') + assertAuthorityId(snapshot.activeIncarnationId, 'consumerIncarnationId') + assertSafeInteger(snapshot.acknowledgedSequence, 'acknowledged sequence') +} + +export function validateRestoredOutcome( + outcome: TerminalAuthorityDurableOutcome, + namespace: TerminalAuthorityNamespace, + revision: number +): void { + if (!isRecord(outcome)) { + failTerminalSessionAuthority('record-corrupt', 'outcome is invalid') + } + assertAuthorityId(outcome.outcomeId, 'outcomeId') + assertSafeInteger(outcome.sequence, 'outcome sequence', 1) + const { byteLength: _byteLength, ...base } = outcome + if ( + outcome.byteLength !== terminalAuthorityOutcomeByteLength(base) || + restoredOutcomeRevision(outcome, namespace) > revision + ) { + failTerminalSessionAuthority('record-corrupt', 'outcome snapshot is inconsistent') + } +} + +function restoredOutcomeRevision( + outcome: TerminalAuthorityDurableOutcome, + namespace: TerminalAuthorityNamespace +): number { + if (outcome.kind === 'semantic') { + assertSemanticOutcomeShape(outcome, namespace) + return outcome.appendedAtRevision + } + assertMutationRequest(outcome.request) + assertMutationResultSemantics(namespace, outcome.request, outcome.result) + if (outcome.request.outcomeId !== outcome.outcomeId) { + failTerminalSessionAuthority('record-corrupt', 'outcome snapshot is inconsistent') + } + return outcome.result.revision +} + +export function assertSemanticProducerSnapshotShape( + snapshot: TerminalAuthoritySemanticProducerSnapshot, + namespace: TerminalAuthorityNamespace +): void { + if (!isRecord(snapshot)) { + failTerminalSessionAuthority('record-corrupt', 'semantic producer cursor is invalid') + } + const access = parseTerminalSessionAuthorityPtyAccess(snapshot.access) + if ( + !access || + access.namespace.authorityHostId !== namespace.authorityHostId || + access.namespace.namespaceId !== namespace.namespaceId + ) { + failTerminalSessionAuthority('record-corrupt', 'semantic producer access is invalid') + } + assertAuthorityId(snapshot.producerIncarnationId, 'producerIncarnationId') + assertSafeInteger(snapshot.producerSequence, 'producer sequence', 1) +} + +function assertSemanticOutcomeShape( + outcome: TerminalAuthoritySemanticOutcome, + namespace: TerminalAuthorityNamespace +): void { + const access = parseTerminalSessionAuthorityPtyAccess(outcome.access) + if ( + !access || + access.namespace.authorityHostId !== namespace.authorityHostId || + access.namespace.namespaceId !== namespace.namespaceId + ) { + failTerminalSessionAuthority('record-corrupt', 'semantic outcome access is invalid') + } + assertAuthorityId(outcome.producerIncarnationId, 'producerIncarnationId') + assertSafeInteger(outcome.producerSequence, 'outcome producer sequence', 1) + assertSafeInteger(outcome.appendedAtRevision, 'semantic outcome revision') + assertSemanticFact(outcome.fact) +} diff --git a/src/shared/terminal-session-authority-result-validation.ts b/src/shared/terminal-session-authority-result-validation.ts new file mode 100644 index 00000000000..377802bd5a1 --- /dev/null +++ b/src/shared/terminal-session-authority-result-validation.ts @@ -0,0 +1,170 @@ +import { + sameTerminalBinding, + type TerminalSessionBinding +} from './terminal-session-authority-identity' +import { + failTerminalSessionAuthority, + type TerminalSessionAuthorityChange, + type TerminalSessionAuthorityMutationResult, + type TerminalSessionPtyAllocationIdentity +} from './terminal-session-authority-mutation' + +export function assertMutationResultMatchesChange( + change: TerminalSessionAuthorityChange, + result: TerminalSessionAuthorityMutationResult +): void { + const pane = 'allocation' in change ? change.allocation.pane : change.pane + requireResult( + result.pane.paneKey === pane.paneKey && + result.pane.paneGenerationId === pane.paneGenerationId && + result.pane.revision === result.revision, + 'mutation result pane changed' + ) + if (change.kind === 'create') { + requireResult( + result.pane.status === 'open' && + result.pane.binding === null && + result.pane.lastBinding === null && + result.replacementPane === null && + result.allocation === null && + result.effects.length === 0, + 'create result is not canonical' + ) + return + } + if (change.kind === 'prepare-allocation') { + requireResult( + result.pane.status === 'open' && + sameTerminalBinding(result.pane.binding, change.expected.binding) && + result.replacementPane === null && + result.allocation?.status === 'pending' && + sameAllocation(result.allocation, change.allocation) && + result.allocation.intentActorId === result.actorId && + result.allocation.intentOperationId === result.operationId && + result.allocation.preparedAtRevision === result.revision && + result.effects.length === 0, + 'allocation intent result is not canonical' + ) + return + } + if (change.kind === 'commit-allocation') { + assertCommittedBinding(change.allocation, change.ptyIncarnationId, result.pane.binding) + requireResult( + result.pane.status === 'open' && + result.replacementPane === null && + result.allocation?.status === 'committed' && + sameAllocation(result.allocation, change.allocation) && + sameTerminalBinding(result.allocation.binding, result.pane.binding) && + result.allocation.committedAtRevision === result.revision && + result.effects.length === 0, + 'allocation commit result is not canonical' + ) + return + } + if (change.kind === 'cancel-allocation') { + requireResult( + result.replacementPane === null && result.allocation === null && result.effects.length === 0, + 'allocation cancellation result is not canonical' + ) + return + } + if (change.kind === 'close') { + requireResult( + result.pane.status === 'closed' && + result.pane.binding === null && + result.replacementPane === null && + result.allocation === null && + exactRetirement(result, 'close', change.expected.binding), + 'close result is not canonical' + ) + return + } + if (change.kind === 'supersede') { + requireResult( + result.pane.status === 'superseded' && + result.pane.binding === null && + result.replacementPane?.paneKey === pane.paneKey && + result.replacementPane.paneGenerationId === change.replacementPaneGenerationId && + result.replacementPane.status === 'open' && + result.replacementPane.binding === null && + sameTerminalBinding(result.replacementPane.lastBinding, change.expected.binding) && + result.replacementPane.revision === result.revision && + result.allocation === null && + exactRetirement(result, 'supersede', change.expected.binding), + 'supersede result is not canonical' + ) + return + } + const exitEffect = change.exit.retiredByClose ? result.effects[0] : result.effects[1] + requireResult( + result.pane.status === 'exited' && + result.pane.binding === null && + result.replacementPane === null && + result.allocation === null && + change.expected.binding !== null && + (change.exit.retiredByClose + ? result.effects.length === 1 + : result.effects.length === 2 && + result.effects[0]?.kind === 'binding-retired' && + result.effects[0].reason === 'exit' && + sameTerminalBinding(result.effects[0].binding, change.expected.binding)) && + exitEffect?.kind === 'terminal-exited' && + sameTerminalBinding(exitEffect.binding, change.expected.binding) && + exitEffect.code === change.exit.code && + exitEffect.signal === change.exit.signal, + 'exit result is not canonical' + ) +} + +function exactRetirement( + result: TerminalSessionAuthorityMutationResult, + reason: 'close' | 'supersede', + binding: TerminalSessionBinding | null +): boolean { + if (!binding) { + return result.effects.length === 0 + } + const effect = result.effects[0] + return ( + result.effects.length === 1 && + effect?.kind === 'binding-retired' && + effect.reason === reason && + sameTerminalBinding(effect.binding, binding) + ) +} + +function sameAllocation( + left: TerminalSessionPtyAllocationIdentity, + right: TerminalSessionPtyAllocationIdentity +): boolean { + return ( + left.allocationId === right.allocationId && + left.pane.paneKey === right.pane.paneKey && + left.pane.paneGenerationId === right.pane.paneGenerationId && + left.ownerIncarnationId === right.ownerIncarnationId && + left.physicalPtyId === right.physicalPtyId && + left.spawnFingerprint === right.spawnFingerprint + ) +} + +function assertCommittedBinding( + allocation: TerminalSessionPtyAllocationIdentity, + ptyIncarnationId: string, + binding: TerminalSessionBinding | null +): void { + requireResult( + Boolean( + binding && + binding.ownerIncarnationId === allocation.ownerIncarnationId && + binding.physicalPtyId === allocation.physicalPtyId && + binding.ptyIncarnationId === ptyIncarnationId + ), + 'allocation result binding changed' + ) +} + +function requireResult(condition: boolean, message: string): void { + if (!condition) { + failTerminalSessionAuthority('record-corrupt', message) + } +} diff --git a/src/shared/terminal-session-authority-semantic-equality.ts b/src/shared/terminal-session-authority-semantic-equality.ts new file mode 100644 index 00000000000..bd94d3101e1 --- /dev/null +++ b/src/shared/terminal-session-authority-semantic-equality.ts @@ -0,0 +1,9 @@ +import { isDeepStrictEqual } from 'node:util' +import { failTerminalSessionAuthority } from './terminal-session-authority-mutation' + +/** Node-only canonical equality for persisted authority records. */ +export function assertSemanticallyEqual(expected: unknown, actual: unknown, message: string): void { + if (!isDeepStrictEqual(expected, actual)) { + failTerminalSessionAuthority('record-corrupt', message) + } +} diff --git a/src/shared/terminal-session-authority-semantic-fact-validation.ts b/src/shared/terminal-session-authority-semantic-fact-validation.ts new file mode 100644 index 00000000000..f8add17f917 --- /dev/null +++ b/src/shared/terminal-session-authority-semantic-fact-validation.ts @@ -0,0 +1,98 @@ +import { isRecord } from './terminal-session-authority-identity' +import { failTerminalSessionAuthority } from './terminal-session-authority-mutation' +import { normalizeAgentStatusPayload } from './agent-status-types' +import { parseGitHubIssueOrPRLink } from './github-links' +import type { TerminalSideEffectFact } from './terminal-side-effect-facts' + +export function assertSemanticFact(value: unknown): asserts value is TerminalSideEffectFact { + if (!isRecord(value)) { + failTerminalSessionAuthority('record-corrupt', 'semantic outcome fact is invalid') + } + if ( + ['bell', 'agent-working', 'agent-exited', '2031-subscribe', '2031-unsubscribe'].includes( + String(value.kind) + ) + ) { + return + } + if (value.kind === 'agent-status') { + if (!normalizeAgentStatusPayload(value.payload)) { + failTerminalSessionAuthority('record-corrupt', 'agent status fact is invalid') + } + return + } + if (value.kind === 'title') { + if ( + typeof value.normalizedTitle !== 'string' || + typeof value.rawTitle !== 'string' || + (value.staleWorkingTitleClear !== undefined && value.staleWorkingTitleClear !== true) + ) { + failTerminalSessionAuthority('record-corrupt', 'terminal title fact is invalid') + } + return + } + if (value.kind === 'agent-idle') { + if ( + typeof value.title !== 'string' || + (value.staleWorkingTitleClear !== undefined && value.staleWorkingTitleClear !== true) + ) { + failTerminalSessionAuthority('record-corrupt', 'agent idle fact is invalid') + } + return + } + if (value.kind === 'command-finished') { + if (value.exitCode !== null) { + assertFactSafeInteger(value.exitCode, 'finished command exit code') + } + return + } + if (value.kind === 'pr-link') { + const link = value.link + if (!isRecord(link) || typeof link.url !== 'string') { + failTerminalSessionAuthority('record-corrupt', 'pull request link fact is invalid') + } + const parsed = parseGitHubIssueOrPRLink(link.url) + if ( + !parsed || + parsed.type !== 'pr' || + !hasExactKeys(link, ['url', 'slug', 'number']) || + !sameRepoSlug(link.slug, parsed.slug) || + link.number !== parsed.number + ) { + failTerminalSessionAuthority('record-corrupt', 'pull request link fact is invalid') + } + return + } + if ( + (value.kind === 'command-code-working' || value.kind === 'command-code-done') && + typeof value.prompt === 'string' + ) { + return + } + failTerminalSessionAuthority('record-corrupt', 'semantic outcome fact is invalid') +} + +function assertFactSafeInteger(value: unknown, field: string): asserts value is number { + if (!Number.isSafeInteger(value) || Number(value) < 0) { + failTerminalSessionAuthority('record-corrupt', `${field} is invalid`) + } +} + +function hasExactKeys(value: Record, keys: readonly string[]): boolean { + const actual = Object.keys(value).sort() + const expected = [...keys].sort() + return actual.length === expected.length && actual.every((key, index) => key === expected[index]) +} + +function sameRepoSlug( + value: unknown, + expected: Readonly<{ owner: string; repo: string; host?: string }> +): boolean { + return ( + isRecord(value) && + hasExactKeys(value, ['owner', 'repo', 'host']) && + value.owner === expected.owner && + value.repo === expected.repo && + value.host === expected.host + ) +} diff --git a/src/shared/terminal-session-authority-semantic-outcome.ts b/src/shared/terminal-session-authority-semantic-outcome.ts new file mode 100644 index 00000000000..f0b37f4eed9 --- /dev/null +++ b/src/shared/terminal-session-authority-semantic-outcome.ts @@ -0,0 +1,135 @@ +import { createHash } from 'node:crypto' +import { + assertAuthorityId, + sameTerminalBinding, + type TerminalSessionBinding +} from './terminal-session-authority-identity' +import { + failTerminalSessionAuthority, + type TerminalAuthoritySemanticOutcome, + type TerminalSessionAuthoritySemanticOutcomeRequest +} from './terminal-session-authority-mutation' +import type { TerminalSessionAuthorityOutcomeJournal } from './terminal-session-authority-outcome-journal' +import { + assertSafeInteger, + assertSemanticFact, + terminalAuthorityOutcomeByteLength +} from './terminal-session-authority-record-validation' +import { assertSemanticallyEqual } from './terminal-session-authority-semantic-equality' +import type { TerminalAuthorityTransitionView } from './terminal-session-authority-transition' + +const TERMINAL_AUTHORITY_SEMANTIC_PREFIX = 'authority-outcome:' + +/** + * Producer incarnation fences a counter that restarts at 1, so a worker restart + * or an adopted foreign binding can never collide with a durable outcome ID. + */ +export function terminalAuthoritySemanticOutcomeId( + binding: TerminalSessionBinding, + producerIncarnationId: string, + producerSequence: number +): string { + assertAuthorityId(producerIncarnationId, 'producerIncarnationId') + assertSafeInteger(producerSequence, 'outcome producer sequence', 1) + const digest = createHash('sha256') + .update( + JSON.stringify([ + binding.ownerIncarnationId, + binding.physicalPtyId, + binding.ptyIncarnationId, + producerIncarnationId + ]) + ) + .digest('hex') + return `${TERMINAL_AUTHORITY_SEMANTIC_PREFIX}${producerSequence}:${digest}` +} + +/** A retry resolves by durable outcome ID first, so a since-retired binding still returns its result. */ +export function planTerminalAuthoritySemanticOutcome( + view: TerminalAuthorityTransitionView, + journal: TerminalSessionAuthorityOutcomeJournal, + request: TerminalSessionAuthoritySemanticOutcomeRequest +): { outcome: TerminalAuthoritySemanticOutcome; duplicate: boolean } { + const outcomeId = terminalAuthoritySemanticOutcomeId( + request.access.binding, + request.producerIncarnationId, + request.producerSequence + ) + const existing = journal.findSemantic(request) + if (existing) { + assertSemanticallyEqual( + { + access: existing.access, + producerIncarnationId: existing.producerIncarnationId, + producerSequence: existing.producerSequence, + fact: existing.fact + }, + request, + 'semantic outcome retry changed its request' + ) + return { outcome: existing, duplicate: true } + } + const outcome = deriveSemanticOutcome(view, request, outcomeId, journal.nextSequence(outcomeId)) + journal.assertCanAppend(outcome) + return { outcome, duplicate: false } +} + +export function applyTerminalAuthoritySemanticRecord( + view: TerminalAuthorityTransitionView, + journal: TerminalSessionAuthorityOutcomeJournal, + outcome: TerminalAuthoritySemanticOutcome +): void { + const replanned = planTerminalAuthoritySemanticOutcome(view, journal, { + access: outcome.access, + producerIncarnationId: outcome.producerIncarnationId, + producerSequence: outcome.producerSequence, + fact: outcome.fact + }) + if (replanned.duplicate) { + failTerminalSessionAuthority('record-corrupt', 'semantic record repeats a durable outcome') + } + assertSemanticallyEqual(replanned.outcome, outcome, 'semantic outcome is not canonical') + journal.applyOutcome(outcome) +} + +/** + * Semantic facts never move topology, so they carry the current revision rather + * than minting one; the exact binding is the fence. + */ +function deriveSemanticOutcome( + view: TerminalAuthorityTransitionView, + request: TerminalSessionAuthoritySemanticOutcomeRequest, + outcomeId: string, + sequence: number +): TerminalAuthoritySemanticOutcome { + assertSemanticFact(request.fact) + const access = request.access + if ( + access.namespace.authorityHostId !== view.namespace.authorityHostId || + access.namespace.namespaceId !== view.namespace.namespaceId + ) { + failTerminalSessionAuthority('record-corrupt', 'semantic outcome names another namespace') + } + const pane = view.pane(access.pane) + if (pane?.status !== 'open' || !sameTerminalBinding(pane.binding, access.binding)) { + failTerminalSessionAuthority( + 'expectation-mismatch', + 'semantic outcome names a stale pane generation or PTY incarnation' + ) + } + const base = { + kind: 'semantic' as const, + sequence, + outcomeId, + access: Object.freeze({ + namespace: Object.freeze({ ...access.namespace }), + pane: Object.freeze({ ...access.pane }), + binding: Object.freeze({ ...access.binding }) + }), + producerIncarnationId: request.producerIncarnationId, + producerSequence: request.producerSequence, + fact: Object.freeze(structuredClone(request.fact)), + appendedAtRevision: view.revision + } + return Object.freeze({ ...base, byteLength: terminalAuthorityOutcomeByteLength(base) }) +} diff --git a/src/shared/terminal-session-authority-snapshot.ts b/src/shared/terminal-session-authority-snapshot.ts new file mode 100644 index 00000000000..e2342aa0929 --- /dev/null +++ b/src/shared/terminal-session-authority-snapshot.ts @@ -0,0 +1,47 @@ +import { assertAuthorityNamespace, isRecord } from './terminal-session-authority-identity' +import { + failTerminalSessionAuthority, + type TerminalSessionAuthoritySnapshot +} from './terminal-session-authority-mutation' +import { assertSafeInteger } from './terminal-session-authority-record-validation' + +export function assertTerminalSessionAuthoritySnapshotEnvelope( + snapshot: unknown +): asserts snapshot is TerminalSessionAuthoritySnapshot { + if (!isRecord(snapshot) || snapshot.version !== 1) { + failTerminalSessionAuthority('record-corrupt', 'authority snapshot version is invalid') + } + assertAuthorityNamespace(snapshot.namespace) + assertSafeInteger(snapshot.writerEpoch, 'snapshot writer epoch', 1) + assertSafeInteger(snapshot.revision, 'snapshot revision') + assertSafeInteger(snapshot.outcomeFloorSequence, 'outcome floor sequence') + assertSafeInteger(snapshot.nextOutcomeSequence, 'next outcome sequence', 1) + if ( + !Array.isArray(snapshot.panes) || + !Array.isArray(snapshot.allocations) || + !Array.isArray(snapshot.consumers) || + !Array.isArray(snapshot.outcomes) || + !Array.isArray(snapshot.semanticProducers) || + (snapshot.materializedOutcomes !== undefined && + !Array.isArray(snapshot.materializedOutcomes)) || + !Array.isArray(snapshot.legacyMigrations) + ) { + failTerminalSessionAuthority('record-corrupt', 'authority snapshot shape is invalid') + } + let previousLegacyRevision = 0 + for (const migration of snapshot.legacyMigrations) { + const authorityRevision = isRecord(migration) ? migration.authorityRevision : null + if ( + typeof authorityRevision !== 'number' || + !Number.isSafeInteger(authorityRevision) || + authorityRevision <= previousLegacyRevision || + authorityRevision > snapshot.revision + ) { + failTerminalSessionAuthority( + 'record-corrupt', + 'legacy migration snapshot revision is inconsistent' + ) + } + previousLegacyRevision = authorityRevision + } +} diff --git a/src/shared/terminal-session-authority-state-event-application.ts b/src/shared/terminal-session-authority-state-event-application.ts new file mode 100644 index 00000000000..6d26c2c6f9b --- /dev/null +++ b/src/shared/terminal-session-authority-state-event-application.ts @@ -0,0 +1,65 @@ +import { failTerminalSessionAuthority } from './terminal-session-authority-mutation' +import type { + TerminalAuthorityOutcome, + TerminalSessionAuthorityLogEvent, + TerminalSessionAuthorityMutationRequest +} from './terminal-session-authority-mutation' +import type { TerminalSessionAuthorityOutcomeJournal } from './terminal-session-authority-outcome-journal' +import { assertSemanticallyEqual } from './terminal-session-authority-semantic-equality' +import { applyTerminalAuthoritySemanticRecord } from './terminal-session-authority-semantic-outcome' +import type { TerminalAuthorityTransitionView } from './terminal-session-authority-transition' +import type { TerminalSessionAuthorityLegacyState } from './terminal-session-authority-legacy-state' +import type { TerminalSessionAuthorityTopology } from './terminal-session-authority-topology' + +export function applyTerminalAuthorityStateEvent(args: { + event: TerminalSessionAuthorityLogEvent + revision: number + setRevision: (revision: number) => void + legacy: TerminalSessionAuthorityLegacyState + topology: TerminalSessionAuthorityTopology + outcomes: TerminalSessionAuthorityOutcomeJournal + view: TerminalAuthorityTransitionView + planMutation: ( + request: TerminalSessionAuthorityMutationRequest, + replayPersistedEvent: boolean + ) => { outcome: TerminalAuthorityOutcome; duplicate: boolean } +}): void { + const event = args.event + if (event.kind === 'legacy-migration') { + if (event.migration.authorityRevision !== args.revision + 1) { + failTerminalSessionAuthority('record-corrupt', 'legacy migration revision is not contiguous') + } + args.legacy.apply(event.migration, args.topology) + args.setRevision(event.migration.authorityRevision) + return + } + if (event.kind === 'consumer-claim') { + args.outcomes.applyClaim(event) + return + } + if (event.kind === 'consumer-retire') { + args.outcomes.applyRetire(event) + return + } + if (event.kind === 'outcome-ack') { + args.outcomes.applyAck(event) + return + } + if (event.kind === 'semantic-outcome') { + applyTerminalAuthoritySemanticRecord(args.view, args.outcomes, event.outcome) + return + } + const planned = args.planMutation(event.outcome.request, true) + if (planned.duplicate) { + failTerminalSessionAuthority('record-corrupt', 'mutation record repeats a durable operation') + } + assertSemanticallyEqual(planned.outcome, event.outcome, 'mutation result is not canonical') + args.topology.apply(event.outcome.result) + for (const effect of event.outcome.result.effects) { + if (effect.kind === 'binding-retired') { + args.outcomes.retireSemanticProducers(effect.binding) + } + } + args.setRevision(event.outcome.result.revision) + args.outcomes.applyOutcome(event.outcome) +} diff --git a/src/shared/terminal-session-authority-state-limits.ts b/src/shared/terminal-session-authority-state-limits.ts new file mode 100644 index 00000000000..ca908740055 --- /dev/null +++ b/src/shared/terminal-session-authority-state-limits.ts @@ -0,0 +1,57 @@ +import { assertSafeInteger } from './terminal-session-authority-record-validation' + +export type TerminalSessionAuthorityStateOptions = Readonly<{ + maxConsumers?: number + maxRetainedOperationEntries?: number + maxRetainedOperationBytes?: number + maxPendingAllocations?: number + maxPaneRecords?: number + maxLegacyMigrations?: number + maxLegacyWorkers?: number + maxLegacyRecoveryRows?: number +}> + +export const TERMINAL_AUTHORITY_STATE_DEFAULT_LIMITS = Object.freeze({ + consumers: 256, + retainedOperationEntries: 16_384, + retainedOperationBytes: 64 * 1024 * 1024, + pendingAllocations: 4_096, + paneRecords: 16_384, + legacyMigrations: 512, + legacyWorkers: 256, + legacyRecoveryRows: 16_384 +}) + +export type TerminalSessionAuthorityStateLimits = Readonly< + Record +> + +export function resolveTerminalAuthorityStateLimits( + options: TerminalSessionAuthorityStateOptions +): TerminalSessionAuthorityStateLimits { + return Object.freeze({ + consumers: resolveLimit(options.maxConsumers, 'consumers'), + retainedOperationEntries: resolveLimit( + options.maxRetainedOperationEntries, + 'retainedOperationEntries' + ), + retainedOperationBytes: resolveLimit( + options.maxRetainedOperationBytes, + 'retainedOperationBytes' + ), + pendingAllocations: resolveLimit(options.maxPendingAllocations, 'pendingAllocations'), + paneRecords: resolveLimit(options.maxPaneRecords, 'paneRecords'), + legacyMigrations: resolveLimit(options.maxLegacyMigrations, 'legacyMigrations'), + legacyWorkers: resolveLimit(options.maxLegacyWorkers, 'legacyWorkers'), + legacyRecoveryRows: resolveLimit(options.maxLegacyRecoveryRows, 'legacyRecoveryRows') + }) +} + +function resolveLimit( + value: number | undefined, + key: keyof typeof TERMINAL_AUTHORITY_STATE_DEFAULT_LIMITS +): number { + const selected = value ?? TERMINAL_AUTHORITY_STATE_DEFAULT_LIMITS[key] + assertSafeInteger(selected, 'authority capacity', 1) + return selected +} diff --git a/src/shared/terminal-session-authority-state-projections.ts b/src/shared/terminal-session-authority-state-projections.ts new file mode 100644 index 00000000000..970e50b9ccc --- /dev/null +++ b/src/shared/terminal-session-authority-state-projections.ts @@ -0,0 +1,58 @@ +import type { + TerminalAuthorityConsumerProjection, + TerminalAuthorityProjection, + TerminalSessionAuthoritySnapshot +} from './terminal-session-authority-mutation' +import type { TerminalSessionAuthorityLegacyState } from './terminal-session-authority-legacy-state' +import type { TerminalSessionAuthorityOutcomeJournal } from './terminal-session-authority-outcome-journal' +import type { TerminalSessionAuthorityTopology } from './terminal-session-authority-topology' +import type { TerminalAuthorityNamespace } from './terminal-session-authority-identity' + +export function terminalAuthorityConsumerProjection( + authority: TerminalAuthorityProjection, + outcomes: TerminalSessionAuthorityOutcomeJournal, + consumerId: string, + consumerIncarnationId: string +): TerminalAuthorityConsumerProjection { + return Object.freeze({ + authority, + ...outcomes.cursor(consumerId, consumerIncarnationId) + }) +} + +export function terminalAuthorityConsumerClaimProjection( + authority: TerminalAuthorityProjection, + outcomes: TerminalSessionAuthorityOutcomeJournal, + consumerId: string, + expectedIncarnationId: string | null, + consumerIncarnationId: string +): TerminalAuthorityConsumerProjection { + const claim = outcomes.planClaim(consumerId, expectedIncarnationId, consumerIncarnationId) + const cursor = claim + ? { + acknowledgedSequence: claim.acknowledgedSequence, + outcomeHighWatermark: outcomes.highWatermark + } + : outcomes.cursor(consumerId, consumerIncarnationId) + return Object.freeze({ authority, ...cursor }) +} + +export function terminalAuthorityStateSnapshot(args: { + namespace: TerminalAuthorityNamespace + writerEpoch: number + revision: number + topology: TerminalSessionAuthorityTopology + outcomes: TerminalSessionAuthorityOutcomeJournal + legacy: TerminalSessionAuthorityLegacyState +}): TerminalSessionAuthoritySnapshot { + return Object.freeze({ + version: 1, + namespace: args.namespace, + writerEpoch: args.writerEpoch, + revision: args.revision, + panes: args.topology.paneSnapshot(), + allocations: args.topology.allocationSnapshot(), + ...args.outcomes.snapshot(), + legacyMigrations: args.legacy.migrationSnapshot() + }) +} diff --git a/src/shared/terminal-session-authority-state.ts b/src/shared/terminal-session-authority-state.ts new file mode 100644 index 00000000000..39b131a8b0c --- /dev/null +++ b/src/shared/terminal-session-authority-state.ts @@ -0,0 +1,324 @@ +import { + assertAuthorityId, + assertAuthorityNamespace, + type TerminalAuthorityNamespace, + type TerminalPaneGeneration, + type TerminalSessionBinding +} from './terminal-session-authority-identity' +import { + failTerminalSessionAuthority, + type TerminalAuthorityConsumerProjection, + type TerminalAuthorityOutcome, + type TerminalAuthorityOutcomeRead, + type TerminalAuthorityProjection, + type TerminalAuthoritySemanticOutcome, + type TerminalBindingAuthority, + type TerminalPaneAuthorityRecord, + type TerminalSessionAuthorityLegacyMigration, + type TerminalSessionAuthorityLogEvent, + type TerminalSessionAuthorityMutationRequest, + type TerminalSessionAuthoritySemanticOutcomeRequest, + type TerminalSessionAuthoritySnapshot, + type TerminalSessionPtyAllocation, + type TerminalSessionPtyAllocationIdentity +} from './terminal-session-authority-mutation' +import { TerminalSessionAuthorityOutcomeJournal } from './terminal-session-authority-outcome-journal' +import { + assertMutationRequest, + assertSafeInteger +} from './terminal-session-authority-record-validation' +import { TerminalSessionAuthorityTopology } from './terminal-session-authority-topology' +import { deriveTerminalAuthorityOutcome } from './terminal-session-authority-transition' +import { TerminalSessionAuthorityLegacyState } from './terminal-session-authority-legacy-state' +import { planTerminalAuthoritySemanticOutcome } from './terminal-session-authority-semantic-outcome' +import { assertTerminalAuthorityMaterializedOutcomesMatchTopology } from './terminal-session-authority-materialized-outcomes' +import { assertTerminalSessionAuthoritySnapshotEnvelope } from './terminal-session-authority-snapshot' +import { + terminalAuthorityConsumerClaimProjection, + terminalAuthorityConsumerProjection, + terminalAuthorityStateSnapshot +} from './terminal-session-authority-state-projections' +import { applyTerminalAuthorityStateEvent } from './terminal-session-authority-state-event-application' +import { + resolveTerminalAuthorityStateLimits, + type TerminalSessionAuthorityStateOptions +} from './terminal-session-authority-state-limits' + +export type { TerminalSessionAuthorityStateOptions } from './terminal-session-authority-state-limits' + +export class TerminalSessionAuthorityState { + readonly namespace: TerminalAuthorityNamespace + private readonly topology: TerminalSessionAuthorityTopology + private readonly outcomes: TerminalSessionAuthorityOutcomeJournal + readonly legacy: TerminalSessionAuthorityLegacyState + private revisionValue = 0 + private writerEpochValue: number + + constructor( + namespace: TerminalAuthorityNamespace, + writerEpoch: number, + readonly ownerIncarnationId: string, + options: TerminalSessionAuthorityStateOptions = {} + ) { + assertAuthorityNamespace(namespace) + this.namespace = Object.freeze({ ...namespace }) + assertAuthorityId(ownerIncarnationId, 'ownerIncarnationId') + assertSafeInteger(writerEpoch, 'writer epoch', 1) + this.writerEpochValue = writerEpoch + const limits = resolveTerminalAuthorityStateLimits(options) + this.legacy = new TerminalSessionAuthorityLegacyState(this.namespace, ownerIncarnationId, { + migrations: limits.legacyMigrations, + workers: limits.legacyWorkers, + recoveries: limits.legacyRecoveryRows + }) + this.topology = new TerminalSessionAuthorityTopology( + ownerIncarnationId, + limits.pendingAllocations, + limits.paneRecords, + (candidate) => this.legacy.ownerIsReachable(candidate) + ) + this.outcomes = new TerminalSessionAuthorityOutcomeJournal( + limits.consumers, + limits.retainedOperationEntries, + limits.retainedOperationBytes, + limits.paneRecords + ) + } + + static restore( + snapshot: TerminalSessionAuthoritySnapshot, + writerEpoch: number, + ownerIncarnationId: string, + options: TerminalSessionAuthorityStateOptions = {} + ): TerminalSessionAuthorityState { + assertTerminalSessionAuthoritySnapshotEnvelope(snapshot) + const state = new TerminalSessionAuthorityState( + snapshot.namespace, + writerEpoch, + ownerIncarnationId, + options + ) + state.revisionValue = snapshot.revision + state.topology.restore(snapshot.panes, snapshot.allocations, snapshot.revision) + state.legacy.restore(snapshot.legacyMigrations, state.topology) + state.outcomes.restore(snapshot, state.namespace, snapshot.revision) + assertTerminalAuthorityMaterializedOutcomesMatchTopology( + state.outcomes.materializedProjection(), + (generation) => state.topology.pane(generation) + ) + return state + } + + get revision(): number { + return this.revisionValue + } + + get writerEpoch(): number { + return this.writerEpochValue + } + + setWriterEpoch(epoch: number): void { + assertSafeInteger(epoch, 'writer epoch', this.writerEpochValue) + this.writerEpochValue = epoch + } + + pane(generation: TerminalPaneGeneration): TerminalPaneAuthorityRecord | null { + return this.topology.pane(generation) + } + + openPaneGenerationId(paneKey: string): string | null { + return this.topology.openPaneGenerationId(paneKey) + } + + latestPaneGenerationId(paneKey: string): string | null { + return this.topology.latestPaneGenerationId(paneKey) + } + + get paneCapacityReached(): boolean { + return this.topology.paneCapacityReached + } + + allocation(allocationId: string): TerminalSessionPtyAllocation | null { + return this.topology.allocation(allocationId) + } + + paneAllocation(pane: TerminalPaneGeneration): TerminalSessionPtyAllocation | null { + return this.topology.paneAllocation(pane) + } + + allocationConflict(allocation: TerminalSessionPtyAllocationIdentity): boolean { + return this.topology.allocationConflict(allocation) + } + + ptyOwner(binding: TerminalSessionBinding): string | null { + return this.topology.ptyOwner(binding) + } + + ownerIsReachable(ownerIncarnationId: string): boolean { + return this.legacy.ownerIsReachable(ownerIncarnationId) + } + + get hasConsumers(): boolean { + return this.outcomes.hasConsumers + } + + setLegacyOwnerReachable(ownerIncarnationId: string, reachable: boolean): boolean { + const affectsProjection = this.topology.ownerHasBinding(ownerIncarnationId) + return this.legacy.setOwnerReachable(ownerIncarnationId, reachable) && affectsProjection + } + + assertLegacyMigrationTopologyAllowed(migration: TerminalSessionAuthorityLegacyMigration): void { + this.legacy.assertTopologyAllowed(migration, this.topology, 'operation-conflict') + } + + planConsumerClaim( + consumerId: string, + expectedIncarnationId: string | null, + consumerIncarnationId: string + ): TerminalSessionAuthorityLogEvent | null { + return this.outcomes.planClaim(consumerId, expectedIncarnationId, consumerIncarnationId) + } + + planMutation( + request: TerminalSessionAuthorityMutationRequest, + replayPersistedEvent = false + ): { + outcome: TerminalAuthorityOutcome + duplicate: boolean + } { + assertMutationRequest(request) + const existing = this.outcomes.findMutation(request, this.revisionValue) + if (existing) { + return { outcome: existing, duplicate: true } + } + this.legacy.assertMutationAllowed(request) + if ( + request.change.kind === 'prepare-allocation' && + (this.topology.hasIntent(request.actorId, request.operationId) || + this.topology.pendingAllocationCapacityReached) + ) { + failTerminalSessionAuthority('allocation-conflict', 'allocation intent cannot be reserved') + } + if ( + request.change.kind === 'create' && + this.topology.latestPaneGenerationId(request.change.pane.paneKey) === null && + this.topology.paneCapacityReached + ) { + failTerminalSessionAuthority('capacity', 'terminal authority panes are full') + } + const outcome = deriveTerminalAuthorityOutcome( + this, + request, + this.outcomes.nextSequence(request.outcomeId), + replayPersistedEvent + ) + this.outcomes.assertCanAppend(outcome) + return { outcome, duplicate: false } + } + + planSemanticOutcome(request: TerminalSessionAuthoritySemanticOutcomeRequest): { + outcome: TerminalAuthoritySemanticOutcome + duplicate: boolean + } { + return planTerminalAuthoritySemanticOutcome(this, this.outcomes, request) + } + + planOutcomeAck( + consumerId: string, + consumerIncarnationId: string, + sequence: number + ): TerminalSessionAuthorityLogEvent | null { + return this.outcomes.planAck(consumerId, consumerIncarnationId, sequence) + } + + planConsumerRetirement( + consumerId: string, + consumerIncarnationId: string + ): TerminalSessionAuthorityLogEvent | null { + return this.outcomes.planRetire(consumerId, consumerIncarnationId) + } + + applyEvent(event: TerminalSessionAuthorityLogEvent): void { + applyTerminalAuthorityStateEvent({ + event, + revision: this.revisionValue, + setRevision: (revision) => { + this.revisionValue = revision + }, + legacy: this.legacy, + topology: this.topology, + outcomes: this.outcomes, + view: this, + planMutation: (request, replayPersistedEvent) => + this.planMutation(request, replayPersistedEvent) + }) + } + + readOutcomes( + consumerId: string, + consumerIncarnationId: string, + afterSequence: number, + limit = 100 + ): TerminalAuthorityOutcomeRead { + return this.outcomes.read(consumerId, consumerIncarnationId, afterSequence, limit) + } + + activeConsumerIncarnation(consumerId: string): string | null { + return this.outcomes.activeIncarnation(consumerId) + } + + snapshotForConsumer( + consumerId: string, + consumerIncarnationId: string + ): TerminalAuthorityConsumerProjection { + return terminalAuthorityConsumerProjection( + this.projection(), + this.outcomes, + consumerId, + consumerIncarnationId + ) + } + + snapshotForConsumerClaim( + consumerId: string, + expectedIncarnationId: string | null, + consumerIncarnationId: string + ): TerminalAuthorityConsumerProjection { + return terminalAuthorityConsumerClaimProjection( + this.projection(), + this.outcomes, + consumerId, + expectedIncarnationId, + consumerIncarnationId + ) + } + + projection(): TerminalAuthorityProjection { + return Object.freeze({ + namespace: this.namespace, + writerEpoch: this.writerEpochValue, + revision: this.revisionValue, + panes: this.topology.projectedPanes(), + allocations: this.topology.allocationSnapshot(), + materializedOutcomes: this.outcomes.materializedProjection() + }) + } + + bindingAuthority( + pane: TerminalPaneGeneration, + binding: TerminalSessionBinding + ): TerminalBindingAuthority { + return this.topology.bindingAuthority(pane, binding) + } + + snapshot(): TerminalSessionAuthoritySnapshot { + return terminalAuthorityStateSnapshot({ + namespace: this.namespace, + writerEpoch: this.writerEpochValue, + revision: this.revisionValue, + topology: this.topology, + outcomes: this.outcomes, + legacy: this.legacy + }) + } +} diff --git a/src/shared/terminal-session-authority-topology.ts b/src/shared/terminal-session-authority-topology.ts new file mode 100644 index 00000000000..45d7b959ce4 --- /dev/null +++ b/src/shared/terminal-session-authority-topology.ts @@ -0,0 +1,310 @@ +import { + sameTerminalBinding, + terminalPaneGenerationKey, + terminalPtyIncarnationKey, + type TerminalPaneGeneration, + type TerminalSessionBinding +} from './terminal-session-authority-identity' +import { + failTerminalSessionAuthority, + type TerminalBindingAuthority, + type TerminalPaneAuthorityProjection, + type TerminalPaneAuthorityRecord, + type TerminalSessionAuthorityMutationResult, + type TerminalSessionPtyAllocation, + type TerminalSessionPtyAllocationIdentity +} from './terminal-session-authority-mutation' +import { + terminalAuthorityOperationKey, + terminalAuthorityPhysicalPtyKey +} from './terminal-session-authority-transition' +import type { TerminalLegacyImportedRecovery } from './terminal-legacy-cutover' +import { planTerminalAuthorityLegacyTopologyImport } from './terminal-session-authority-legacy-topology-import' +import { + projectTerminalAuthorityAllocations, + projectTerminalAuthorityPanes +} from './terminal-session-authority-pane-projection' +import { TerminalAuthorityPaneBindingIndex } from './terminal-session-authority-pane-binding-index' +import { + reclaimSupersededTerminalAuthorityPanes, + sortTerminalAuthorityPanes +} from './terminal-session-authority-pane-history' +import { + assertRestorableTerminalAuthorityAllocation, + assertRestorableTerminalAuthorityPane +} from './terminal-session-authority-restore-validation' + +export class TerminalSessionAuthorityTopology { + private readonly panes = new Map() + private readonly latestPaneByKey = new Map() + private readonly openPanes = new Map() + private readonly paneBindings = new TerminalAuthorityPaneBindingIndex() + private readonly allocations = new Map() + private readonly allocationByPane = new Map() + private readonly allocationByPhysicalPty = new Map() + private readonly allocationByBinding = new Map() + private readonly allocationByIntent = new Map() + private pendingAllocationsValue = 0 + + constructor( + readonly ownerIncarnationId: string, + private readonly maxPendingAllocations: number, + private readonly maxPaneRecords: number, + private readonly ownerIsReachable: (ownerIncarnationId: string) => boolean + ) {} + + get pendingAllocationCapacityReached(): boolean { + return this.pendingAllocationsValue >= this.maxPendingAllocations + } + + get paneCapacityReached(): boolean { + return this.latestPaneByKey.size >= this.maxPaneRecords + } + + pane(generation: TerminalPaneGeneration): TerminalPaneAuthorityRecord | null { + return this.panes.get(terminalPaneGenerationKey(generation)) ?? null + } + + openPaneGenerationId(paneKey: string): string | null { + return this.openPanes.get(paneKey) ?? null + } + + latestPaneGenerationId(paneKey: string): string | null { + return this.latestPaneByKey.get(paneKey)?.paneGenerationId ?? null + } + + allocation(allocationId: string): TerminalSessionPtyAllocation | null { + return this.allocations.get(allocationId) ?? null + } + + paneAllocation(pane: TerminalPaneGeneration): TerminalSessionPtyAllocation | null { + const allocationId = this.allocationByPane.get(terminalPaneGenerationKey(pane)) + return allocationId ? (this.allocations.get(allocationId) ?? null) : null + } + + allocationConflict(allocation: TerminalSessionPtyAllocationIdentity): boolean { + return ( + this.allocationByPane.has(terminalPaneGenerationKey(allocation.pane)) || + this.allocationByPhysicalPty.has( + terminalAuthorityPhysicalPtyKey(allocation.ownerIncarnationId, allocation.physicalPtyId) + ) + ) + } + + hasIntent(actorId: string, operationId: string): boolean { + return this.allocationByIntent.has(terminalAuthorityOperationKey(actorId, operationId)) + } + + ptyOwner(binding: TerminalSessionBinding): string | null { + return this.paneBindings.ptyOwner(binding) + } + + ownerHasBinding(ownerIncarnationId: string): boolean { + return this.paneBindings.ownerHasBinding(ownerIncarnationId) + } + + apply(result: TerminalSessionAuthorityMutationResult): void { + this.setPane(result.pane) + if (result.replacementPane) { + this.setPane(result.replacementPane) + } + if (result.kind === 'supersede' && result.replacementPane) { + this.panes.delete(terminalPaneGenerationKey(result.pane)) + } + if (result.kind === 'prepare-allocation' || result.kind === 'commit-allocation') { + if (!result.allocation) { + failTerminalSessionAuthority('record-corrupt', 'allocation result is missing') + } + this.replaceAllocation(result.allocation) + return + } + if (result.kind === 'cancel-allocation') { + const allocationId = this.allocationByPane.get(terminalPaneGenerationKey(result.pane)) + if (allocationId) { + this.deleteAllocation(allocationId) + } + return + } + for (const effect of result.effects) { + if (effect.kind !== 'binding-retired') { + continue + } + const allocationId = this.allocationByBinding.get(terminalPtyIncarnationKey(effect.binding)) + if (allocationId) { + this.deleteAllocation(allocationId) + } + } + } + + restore( + panes: readonly TerminalPaneAuthorityRecord[], + allocations: readonly TerminalSessionPtyAllocation[], + revision: number + ): void { + for (const pane of panes) { + this.restorePane(pane, revision) + } + reclaimSupersededTerminalAuthorityPanes(this.panes, this.latestPaneByKey) + if (this.latestPaneByKey.size > this.maxPaneRecords) { + failTerminalSessionAuthority('capacity', 'snapshot exceeds pane retention capacity') + } + for (const allocation of allocations) { + this.restoreAllocation(allocation, revision) + } + for (const pane of this.panes.values()) { + if (!pane.binding) { + continue + } + const allocationId = this.allocationByBinding.get(terminalPtyIncarnationKey(pane.binding)) + if (this.allocations.get(allocationId ?? '')?.status !== 'committed') { + failTerminalSessionAuthority('record-corrupt', 'live binding has no spawn receipt') + } + } + } + + importLegacyRows( + rows: readonly TerminalLegacyImportedRecovery[], + receiptId: string, + revision: number + ): void { + const planned = this.planLegacyImport(rows, receiptId, revision) + planned.panes.forEach((pane) => this.setPane(pane)) + planned.allocations.forEach((allocation) => this.replaceAllocation(allocation)) + } + + planLegacyImport( + rows: readonly TerminalLegacyImportedRecovery[], + receiptId: string, + revision: number + ): ReturnType { + const planned = planTerminalAuthorityLegacyTopologyImport(rows, receiptId, revision, { + paneGenerations: this.panes, + paneKeys: this.latestPaneByKey, + allocationIds: this.allocations, + physicalPtys: this.allocationByPhysicalPty, + maxPaneRecords: this.maxPaneRecords + }) + return planned + } + + projectedPanes(): readonly TerminalPaneAuthorityProjection[] { + return projectTerminalAuthorityPanes( + sortTerminalAuthorityPanes(this.panes.values()), + this.ownerIsReachable + ) + } + + bindingAuthority( + pane: TerminalPaneGeneration, + binding: TerminalSessionBinding + ): TerminalBindingAuthority { + const record = this.pane(pane) + if (!record) { + return 'absent' + } + if (!record.binding) { + if (!sameTerminalBinding(record.lastBinding, binding)) { + return 'absent' + } + if (record.status === 'closed') { + return 'closed' + } + return record.status === 'exited' ? 'exited' : 'absent' + } + if (!sameTerminalBinding(record.binding, binding)) { + return 'binding-mismatch' + } + return this.ownerIsReachable(binding.ownerIncarnationId) ? 'reachable' : 'owner-unreachable' + } + + paneSnapshot(): readonly TerminalPaneAuthorityRecord[] { + return Object.freeze(sortTerminalAuthorityPanes(this.panes.values())) + } + + allocationSnapshot(): readonly TerminalSessionPtyAllocation[] { + return projectTerminalAuthorityAllocations( + [...this.allocations.values()].sort((left, right) => + left.allocationId.localeCompare(right.allocationId) + ) + ) + } + + private setPane(pane: TerminalPaneAuthorityRecord): void { + const paneGenerationKey = terminalPaneGenerationKey(pane) + const previous = this.panes.get(paneGenerationKey) + this.paneBindings.replace(previous?.binding ?? null, pane.binding, paneGenerationKey) + this.panes.set(paneGenerationKey, pane) + const latest = this.latestPaneByKey.get(pane.paneKey) + if ( + !latest || + pane.revision > latest.revision || + (pane.revision === latest.revision && + latest.status === 'superseded' && + pane.status !== 'superseded') + ) { + this.latestPaneByKey.set(pane.paneKey, pane) + } + if (pane.status === 'open') { + this.openPanes.set(pane.paneKey, pane.paneGenerationId) + } else if (this.openPanes.get(pane.paneKey) === pane.paneGenerationId) { + this.openPanes.delete(pane.paneKey) + } + } + + private replaceAllocation(allocation: TerminalSessionPtyAllocation): void { + this.deleteAllocation(allocation.allocationId) + this.allocations.set(allocation.allocationId, allocation) + this.allocationByPane.set(terminalPaneGenerationKey(allocation.pane), allocation.allocationId) + this.allocationByPhysicalPty.set( + terminalAuthorityPhysicalPtyKey(allocation.ownerIncarnationId, allocation.physicalPtyId), + allocation.allocationId + ) + this.allocationByIntent.set( + terminalAuthorityOperationKey(allocation.intentActorId, allocation.intentOperationId), + allocation.allocationId + ) + if (allocation.status === 'pending') { + this.pendingAllocationsValue += 1 + } else { + this.allocationByBinding.set( + terminalPtyIncarnationKey(allocation.binding), + allocation.allocationId + ) + } + } + + private deleteAllocation(allocationId: string): void { + const allocation = this.allocations.get(allocationId) + if (!allocation) { + return + } + this.allocations.delete(allocationId) + this.allocationByPane.delete(terminalPaneGenerationKey(allocation.pane)) + const physicalKey = terminalAuthorityPhysicalPtyKey( + allocation.ownerIncarnationId, + allocation.physicalPtyId + ) + this.allocationByPhysicalPty.delete(physicalKey) + this.allocationByIntent.delete( + terminalAuthorityOperationKey(allocation.intentActorId, allocation.intentOperationId) + ) + if (allocation.status === 'pending') { + this.pendingAllocationsValue -= 1 + } else { + this.allocationByBinding.delete(terminalPtyIncarnationKey(allocation.binding)) + } + } + + private restorePane(pane: TerminalPaneAuthorityRecord, revision: number): void { + assertRestorableTerminalAuthorityPane(pane, revision, this) + this.setPane(Object.freeze(pane)) + } + + private restoreAllocation(allocation: TerminalSessionPtyAllocation, revision: number): void { + assertRestorableTerminalAuthorityAllocation(allocation, revision, this) + this.replaceAllocation(Object.freeze(allocation)) + if (this.pendingAllocationsValue > this.maxPendingAllocations) { + failTerminalSessionAuthority('capacity', 'snapshot exceeds pending allocation capacity') + } + } +} diff --git a/src/shared/terminal-session-authority-transition-guards.ts b/src/shared/terminal-session-authority-transition-guards.ts new file mode 100644 index 00000000000..5927936bf14 --- /dev/null +++ b/src/shared/terminal-session-authority-transition-guards.ts @@ -0,0 +1,135 @@ +import { + sameTerminalBinding, + terminalPaneGenerationKey, + type TerminalPaneGeneration, + type TerminalSessionBinding +} from './terminal-session-authority-identity' +import { failTerminalSessionAuthority } from './terminal-session-authority-mutation' +import type { + TerminalPaneAuthorityRecord, + TerminalSessionAuthorityEffect, + TerminalSessionExpectation, + TerminalSessionPtyAllocation, + TerminalSessionPtyAllocationIdentity +} from './terminal-session-authority-mutation' +import type { TerminalAuthorityTransitionView } from './terminal-session-authority-transition' + +export function requireExpectedPane( + view: TerminalAuthorityTransitionView, + generation: TerminalPaneGeneration, + expected: TerminalSessionExpectation +): TerminalPaneAuthorityRecord { + const pane = view.pane(generation) + if ( + !pane || + expected.paneGenerationId !== generation.paneGenerationId || + !sameTerminalBinding(expected.binding, pane.binding) + ) { + failTerminalSessionAuthority('expectation-mismatch', 'pane generation or binding changed') + } + return pane +} + +export function requireExpectedClosedPane( + view: TerminalAuthorityTransitionView, + generation: TerminalPaneGeneration, + expected: TerminalSessionExpectation +): TerminalPaneAuthorityRecord { + const pane = view.pane(generation) + if ( + !pane || + pane.status !== 'closed' || + pane.binding !== null || + expected.paneGenerationId !== generation.paneGenerationId || + expected.binding === null || + !sameTerminalBinding(expected.binding, pane.lastBinding) + ) { + failTerminalSessionAuthority('expectation-mismatch', 'closed pane binding changed before exit') + } + return pane +} + +export function requireExactPendingAllocation( + view: TerminalAuthorityTransitionView, + expected: TerminalSessionPtyAllocationIdentity +): TerminalSessionPtyAllocation { + const allocation = view.allocation(expected.allocationId) + if (allocation?.status !== 'pending' || !sameAllocation(allocation, expected)) { + failTerminalSessionAuthority('allocation-conflict', 'pending allocation changed') + } + return allocation +} + +export function requireOpenUnbound(pane: TerminalPaneAuthorityRecord): void { + if (pane.status !== 'open' || pane.binding !== null) { + failTerminalSessionAuthority('expectation-mismatch', 'pane is not open and unbound') + } +} + +export function requireOpenBound( + pane: TerminalPaneAuthorityRecord +): asserts pane is TerminalPaneAuthorityRecord & { binding: TerminalSessionBinding } { + if (pane.status !== 'open' || pane.binding === null) { + failTerminalSessionAuthority('expectation-mismatch', 'pane is not an exact live binding') + } +} + +export function requireActive(pane: TerminalPaneAuthorityRecord, operation: string): void { + if (pane.status === 'closed' || pane.status === 'superseded') { + failTerminalSessionAuthority('expectation-mismatch', `cannot ${operation} an inactive pane`) + } +} + +export function requireNoPendingAllocation( + view: TerminalAuthorityTransitionView, + pane: TerminalPaneGeneration +): void { + if (view.paneAllocation(pane)?.status === 'pending') { + failTerminalSessionAuthority('allocation-conflict', 'pending allocation must be canceled first') + } +} + +export function createPane( + pane: TerminalPaneGeneration, + revision: number +): TerminalPaneAuthorityRecord { + return Object.freeze({ ...pane, status: 'open', binding: null, lastBinding: null, revision }) +} + +export function updatePaneRevision( + pane: TerminalPaneAuthorityRecord, + revision: number +): TerminalPaneAuthorityRecord { + return Object.freeze({ ...pane, revision }) +} + +export function retire( + reason: 'close' | 'supersede' | 'exit', + binding: TerminalSessionBinding | null +): readonly TerminalSessionAuthorityEffect[] { + return binding ? [Object.freeze({ kind: 'binding-retired', reason, binding })] : [] +} + +function sameAllocation( + left: TerminalSessionPtyAllocationIdentity, + right: TerminalSessionPtyAllocationIdentity +): boolean { + return ( + left.allocationId === right.allocationId && + terminalPaneGenerationKey(left.pane) === terminalPaneGenerationKey(right.pane) && + left.ownerIncarnationId === right.ownerIncarnationId && + left.physicalPtyId === right.physicalPtyId && + left.spawnFingerprint === right.spawnFingerprint + ) +} + +export function terminalAuthorityPhysicalPtyKey( + ownerIncarnationId: string, + physicalPtyId: string +): string { + return JSON.stringify([ownerIncarnationId, physicalPtyId]) +} + +export function terminalAuthorityOperationKey(actorId: string, operationId: string): string { + return JSON.stringify([actorId, operationId]) +} diff --git a/src/shared/terminal-session-authority-transition.ts b/src/shared/terminal-session-authority-transition.ts new file mode 100644 index 00000000000..24228e33d01 --- /dev/null +++ b/src/shared/terminal-session-authority-transition.ts @@ -0,0 +1,245 @@ +import { + assertAuthorityId, + terminalPaneGenerationKey, + type TerminalAuthorityNamespace, + type TerminalPaneGeneration, + type TerminalSessionBinding +} from './terminal-session-authority-identity' +import { + failTerminalSessionAuthority, + type TerminalAuthorityOutcome, + type TerminalPaneAuthorityRecord, + type TerminalSessionAuthorityEffect, + type TerminalSessionAuthorityMutationRequest, + type TerminalSessionAuthorityMutationResult, + type TerminalSessionPtyAllocation, + type TerminalSessionPtyAllocationIdentity +} from './terminal-session-authority-mutation' +import { + assertAllocationIdentity, + assertMutationRequest, + terminalAuthorityOutcomeByteLength +} from './terminal-session-authority-record-validation' +import { + assertCurrentAllocationOwner, + assertCurrentBindingOwner, + assertReachableBindingOwner +} from './terminal-session-authority-owner-fence' +import { + createPane, + requireActive, + requireExactPendingAllocation, + requireExpectedClosedPane, + requireExpectedPane, + requireNoPendingAllocation, + requireOpenBound, + requireOpenUnbound, + retire, + terminalAuthorityOperationKey, + terminalAuthorityPhysicalPtyKey, + updatePaneRevision +} from './terminal-session-authority-transition-guards' + +export { terminalAuthorityOperationKey, terminalAuthorityPhysicalPtyKey } + +export type TerminalAuthorityTransitionView = Readonly<{ + namespace: TerminalAuthorityNamespace + revision: number + ownerIncarnationId: string + pane: (pane: TerminalPaneGeneration) => TerminalPaneAuthorityRecord | null + openPaneGenerationId: (paneKey: string) => string | null + latestPaneGenerationId: (paneKey: string) => string | null + allocation: (allocationId: string) => TerminalSessionPtyAllocation | null + paneAllocation: (pane: TerminalPaneGeneration) => TerminalSessionPtyAllocation | null + allocationConflict: (allocation: TerminalSessionPtyAllocationIdentity) => boolean + ptyOwner: (binding: TerminalSessionBinding) => string | null + ownerIsReachable: (ownerIncarnationId: string) => boolean +}> + +export function deriveTerminalAuthorityOutcome( + view: TerminalAuthorityTransitionView, + request: TerminalSessionAuthorityMutationRequest, + sequence: number, + replayPersistedEvent = false +): TerminalAuthorityOutcome { + assertMutationRequest(request) + if (request.baseRevision !== view.revision) { + failTerminalSessionAuthority('revision-conflict', 'mutation base revision is stale') + } + const result = deriveResult(view, request, replayPersistedEvent) + const base = { + sequence, + outcomeId: request.outcomeId, + request, + result + } + return Object.freeze({ ...base, byteLength: terminalAuthorityOutcomeByteLength(base) }) +} + +function deriveResult( + view: TerminalAuthorityTransitionView, + request: TerminalSessionAuthorityMutationRequest, + replayPersistedEvent: boolean +): TerminalSessionAuthorityMutationResult { + const revision = view.revision + 1 + const change = request.change + if (change.kind === 'create') { + if (view.pane(change.pane) || view.latestPaneGenerationId(change.pane.paneKey)) { + failTerminalSessionAuthority('expectation-mismatch', 'pane generation is not absent') + } + return result(view, request, revision, createPane(change.pane, revision)) + } + + const generation = 'allocation' in change ? change.allocation.pane : change.pane + const current = + change.kind === 'exit' && change.exit.retiredByClose + ? requireExpectedClosedPane(view, generation, change.expected) + : requireExpectedPane(view, generation, change.expected) + if (change.kind === 'prepare-allocation') { + requireOpenUnbound(current) + assertAllocationIdentity(change.allocation) + assertCurrentAllocationOwner(view, change.allocation.ownerIncarnationId, replayPersistedEvent) + if ( + view.allocation(change.allocation.allocationId) || + view.allocationConflict(change.allocation) + ) { + failTerminalSessionAuthority('allocation-conflict', 'allocation identity is already reserved') + } + const pane = updatePaneRevision(current, revision) + const allocation: TerminalSessionPtyAllocation = Object.freeze({ + ...change.allocation, + pane: Object.freeze({ ...change.allocation.pane }), + intentActorId: request.actorId, + intentOperationId: request.operationId, + preparedAtRevision: revision, + status: 'pending', + binding: null + }) + return result(view, request, revision, pane, null, allocation) + } + + if (change.kind === 'commit-allocation') { + requireOpenUnbound(current) + const pending = requireExactPendingAllocation(view, change.allocation) + assertCurrentAllocationOwner(view, pending.ownerIncarnationId, replayPersistedEvent) + assertAuthorityId(change.ptyIncarnationId, 'ptyIncarnationId') + const binding = Object.freeze({ + ownerIncarnationId: pending.ownerIncarnationId, + physicalPtyId: pending.physicalPtyId, + ptyIncarnationId: change.ptyIncarnationId + }) + const owner = view.ptyOwner(binding) + if (owner && owner !== terminalPaneGenerationKey(current)) { + failTerminalSessionAuthority('allocation-conflict', 'PTY incarnation is already bound') + } + const pane = Object.freeze({ + ...current, + binding, + lastBinding: binding, + revision + }) + const allocation: TerminalSessionPtyAllocation = Object.freeze({ + ...pending, + status: 'committed', + binding, + committedAtRevision: revision + }) + return result(view, request, revision, pane, null, allocation) + } + + if (change.kind === 'cancel-allocation') { + const pending = requireExactPendingAllocation(view, change.allocation) + assertCurrentAllocationOwner(view, pending.ownerIncarnationId, replayPersistedEvent) + return result(view, request, revision, updatePaneRevision(current, revision)) + } + + if (change.kind === 'close') { + requireActive(current, 'close') + assertCurrentBindingOwner(view, current.binding, replayPersistedEvent, 'close') + requireNoPendingAllocation(view, change.pane) + const pane = Object.freeze({ ...current, status: 'closed' as const, binding: null, revision }) + return result(view, request, revision, pane, null, null, retire('close', current.binding)) + } + + if (change.kind === 'supersede') { + requireActive(current, 'supersede') + assertCurrentBindingOwner(view, current.binding, replayPersistedEvent, 'supersede') + requireNoPendingAllocation(view, change.pane) + assertAuthorityId(change.replacementPaneGenerationId, 'replacementPaneGenerationId') + if ( + change.replacementPaneGenerationId === current.paneGenerationId || + view.pane({ paneKey: current.paneKey, paneGenerationId: change.replacementPaneGenerationId }) + ) { + failTerminalSessionAuthority('expectation-mismatch', 'replacement generation already exists') + } + const pane = Object.freeze({ + ...current, + status: 'superseded' as const, + binding: null, + revision + }) + const replacement = Object.freeze({ + ...createPane( + { paneKey: current.paneKey, paneGenerationId: change.replacementPaneGenerationId }, + revision + ), + lastBinding: current.binding + }) + return result( + view, + request, + revision, + pane, + replacement, + null, + retire('supersede', current.binding) + ) + } + + const exitBinding = change.expected.binding + if (!exitBinding) { + failTerminalSessionAuthority('expectation-mismatch', 'terminal exit binding is missing') + } + if (!change.exit.retiredByClose) { + requireOpenBound(current) + } + assertReachableBindingOwner(view, exitBinding, replayPersistedEvent) + const pane = Object.freeze({ ...current, status: 'exited' as const, binding: null, revision }) + const exit = Object.freeze({ + kind: 'terminal-exited' as const, + binding: exitBinding, + code: change.exit.code, + signal: change.exit.signal + }) + return result( + view, + request, + revision, + pane, + null, + null, + change.exit.retiredByClose ? [exit] : [...retire('exit', exitBinding), exit] + ) +} + +function result( + view: TerminalAuthorityTransitionView, + request: TerminalSessionAuthorityMutationRequest, + revision: number, + pane: TerminalPaneAuthorityRecord, + replacementPane: TerminalPaneAuthorityRecord | null = null, + allocation: TerminalSessionPtyAllocation | null = null, + effects: readonly TerminalSessionAuthorityEffect[] = [] +): TerminalSessionAuthorityMutationResult { + return Object.freeze({ + namespace: view.namespace, + actorId: request.actorId, + operationId: request.operationId, + kind: request.change.kind, + revision, + pane, + replacementPane, + allocation, + effects: Object.freeze(effects) + }) +} diff --git a/src/shared/terminal-session-authority-wire.test.ts b/src/shared/terminal-session-authority-wire.test.ts new file mode 100644 index 00000000000..858f34a65f9 --- /dev/null +++ b/src/shared/terminal-session-authority-wire.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import { + TERMINAL_SESSION_AUTHORITY_ATTACH_VERSION, + parseTerminalSessionAuthorityAttachIdentity +} from './terminal-session-authority-wire' + +describe('terminal session authority attach wire', () => { + it('keeps missing metadata distinguishable from malformed current metadata', () => { + expect(parseTerminalSessionAuthorityAttachIdentity({})).toBeNull() + expect(() => + parseTerminalSessionAuthorityAttachIdentity({ + terminalSessionAuthorityAttachVersion: TERMINAL_SESSION_AUTHORITY_ATTACH_VERSION, + expectedWorktreeId: 'repo::/srv/repo', + expectedPaneKey: 'pane-a' + }) + ).toThrow('terminal_session_authority_attach_identity_invalid') + }) + + it('requires exact PTY identity and preserves renderer generation zero', () => { + const base = { + terminalSessionAuthorityAttachVersion: TERMINAL_SESSION_AUTHORITY_ATTACH_VERSION, + expectedWorktreeId: 'repo::/srv/repo', + expectedPaneKey: 'pane-a', + expectedPtyIncarnationId: 'incarnation-a' + } + expect(() => parseTerminalSessionAuthorityAttachIdentity(base)).toThrow( + 'terminal_session_authority_attach_identity_invalid' + ) + expect( + parseTerminalSessionAuthorityAttachIdentity({ ...base, expectedPaneGeneration: 0 }) + ).toEqual({ + worktreeId: 'repo::/srv/repo', + paneKey: 'pane-a', + paneGeneration: 0, + ptyIncarnationId: 'incarnation-a' + }) + }) +}) diff --git a/src/shared/terminal-session-authority-wire.ts b/src/shared/terminal-session-authority-wire.ts new file mode 100644 index 00000000000..08732ab43c5 --- /dev/null +++ b/src/shared/terminal-session-authority-wire.ts @@ -0,0 +1,38 @@ +export const TERMINAL_SESSION_AUTHORITY_SPAWN_VERSION = 1 +export const TERMINAL_SESSION_AUTHORITY_ATTACH_VERSION = 1 + +export function isTerminalSessionAuthorityPaneGeneration(value: unknown): value is number { + return Number.isSafeInteger(value) && Number(value) >= 0 +} + +export type TerminalSessionAuthorityAttachIdentity = Readonly<{ + worktreeId: string + paneKey: string + paneGeneration: number + ptyIncarnationId: string +}> + +export function parseTerminalSessionAuthorityAttachIdentity( + value: Record +): TerminalSessionAuthorityAttachIdentity | null { + if (value.terminalSessionAuthorityAttachVersion === undefined) { + return null + } + if ( + value.terminalSessionAuthorityAttachVersion !== TERMINAL_SESSION_AUTHORITY_ATTACH_VERSION || + typeof value.expectedWorktreeId !== 'string' || + typeof value.expectedPaneKey !== 'string' || + !isTerminalSessionAuthorityPaneGeneration(value.expectedPaneGeneration) || + typeof value.expectedPtyIncarnationId !== 'string' || + value.expectedPtyIncarnationId.length === 0 || + value.expectedPtyIncarnationId.length > 128 + ) { + throw new Error('terminal_session_authority_attach_identity_invalid') + } + return Object.freeze({ + worktreeId: value.expectedWorktreeId, + paneKey: value.expectedPaneKey, + paneGeneration: Number(value.expectedPaneGeneration), + ptyIncarnationId: value.expectedPtyIncarnationId + }) +} diff --git a/src/shared/terminal-side-effect-facts.ts b/src/shared/terminal-side-effect-facts.ts index d9c4ce6e46a..26f50aff988 100644 --- a/src/shared/terminal-side-effect-facts.ts +++ b/src/shared/terminal-side-effect-facts.ts @@ -6,6 +6,7 @@ */ import type { ParsedAgentStatusPayload } from './agent-status-types' +import type { PtyIncarnationId } from './pty-incarnation' import type { TerminalGitHubPRLink } from './terminal-github-pr-link-detector' /** Why tagged: stale-clear facts come from main's unthrottled 3s timer, not @@ -41,6 +42,8 @@ export type TerminalSideEffectFact = export type TerminalSideEffectBatch = { ptyId: string + /** Exact emitting PTY generation. Absent only for legacy producers. */ + ptyIncarnationId?: PtyIncarnationId /** PTY output byte sequence at emission. Replay batches carry the sequence * their title state was current at, so the handler can drop a replay title * older than the last live title fact it applied. */ diff --git a/tests/e2e/cross-version-wire/release-checkout.ts b/tests/e2e/cross-version-wire/release-checkout.ts index 205885a5e87..da515ffbe82 100644 --- a/tests/e2e/cross-version-wire/release-checkout.ts +++ b/tests/e2e/cross-version-wire/release-checkout.ts @@ -61,7 +61,7 @@ function compareReleaseTags(a: string, b: string): number { /** * The version point the harness pairs current code against. An explicit - * {@link BASELINE_REF_ENV} wins; otherwise the newest non-prerelease `v*` tag. + * {@link BASELINE_REF_ENV} wins; otherwise the newest stable semver release tag. * * Throws rather than skipping: a cross-version lane that quietly runs nothing is * the exact failure this harness exists to prevent. @@ -80,11 +80,11 @@ export function resolveBaselineReleaseRef(): string { `Run it inside a git checkout, or pin a ref with ${BASELINE_REF_ENV}.` ) } - const releases = tags.filter((tag) => !tag.includes('-')).sort(compareReleaseTags) + const releases = tags.filter((tag) => /^v\d+\.\d+\.\d+$/.test(tag)).sort(compareReleaseTags) const latest = releases.at(-1) if (!latest) { throw new Error( - `Cross-version harness found no release tags matching v[0-9]* (saw ${tags.length} tag(s) total). ` + + `Cross-version harness found no stable semver release tags (saw ${tags.length} v-prefixed tag(s) total). ` + 'CI checkouts default to a shallow clone with no tags: use `actions/checkout` with `fetch-depth: 0`, ' + `or pin a ref with ${BASELINE_REF_ENV}.` ) diff --git a/tests/e2e/helpers/docker-ssh-authority-clients.ts b/tests/e2e/helpers/docker-ssh-authority-clients.ts new file mode 100644 index 00000000000..31f734a4b8a --- /dev/null +++ b/tests/e2e/helpers/docker-ssh-authority-clients.ts @@ -0,0 +1,140 @@ +import type { Page } from '@stablyai/playwright-test' +import type { ConnectedDockerSshRelayTarget } from './docker-ssh-relay-connection' +import { + DOCKER_SSH_RELAY_REMOTE_REPO_PATH, + type DockerSshRelayTarget +} from './docker-ssh-relay-target' + +export type ConnectedDockerSshAuthorityClient = { + targetId: string + providerEpoch: string + connectionGeneration: number +} + +export async function connectDockerSshAuthorityClientsConcurrently( + page: Page, + target: DockerSshRelayTarget, + count = 2 +): Promise { + if (!Number.isInteger(count) || count < 2 || count > 8) { + throw new Error('Concurrent Docker SSH authority client count must be between 2 and 8') + } + return page.evaluate( + async ({ target, count }) => { + const store = window.__store + if (!store) { + throw new Error('Store unavailable') + } + const credentialUnsub = window.api.ssh.onCredentialRequest((request) => { + void window.api.ssh.submitCredential({ requestId: request.requestId, value: null }) + }) + try { + const added = await Promise.all( + Array.from({ length: count }, (_, index) => + window.api.ssh.addTarget({ + target: { + label: `Docker SSH Authority Race ${index + 1} ${Date.now()}`, + host: target.host, + port: target.port, + username: 'root', + identityFile: target.identityFile, + identitiesOnly: true, + relayGracePeriodSeconds: 300 + } + }) + ) + ) + for (const result of added) { + store.getState().recordSshRepoReadoptions(result.repoReadoptions) + } + const states = await Promise.all( + added.map(({ target: createdTarget }) => + window.api.ssh.connect({ targetId: createdTarget.id }) + ) + ) + return states.map((state, index) => { + const createdTarget = added[index]?.target + if ( + !createdTarget || + !state || + state.status !== 'connected' || + !state.providerEpoch || + !Number.isSafeInteger(state.connectionGeneration) + ) { + throw new Error(`Concurrent SSH authority client failed: ${JSON.stringify(state)}`) + } + store.getState().setSshConnectionState(createdTarget.id, state) + const labels = new Map(store.getState().sshTargetLabels) + labels.set(createdTarget.id, createdTarget.label) + store.getState().setSshTargetLabels(labels) + return { + targetId: createdTarget.id, + providerEpoch: state.providerEpoch, + connectionGeneration: state.connectionGeneration + } + }) + } finally { + credentialUnsub() + } + }, + { target, count } + ) +} + +export async function activateConnectedDockerSshRelayTarget( + page: Page, + targetId: string, + remotePath = DOCKER_SSH_RELAY_REMOTE_REPO_PATH +): Promise { + return page.evaluate( + async ({ targetId, remotePath }) => { + const store = window.__store + if (!store) { + throw new Error('Store unavailable') + } + const authority = store.getState().sshConnectionStates.get(targetId) + if ( + authority?.status !== 'connected' || + !authority.providerEpoch || + !Number.isSafeInteger(authority.connectionGeneration) + ) { + throw new Error(`SSH target is not authoritatively connected: ${targetId}`) + } + const result = await window.api.repos.addRemote({ + connectionId: targetId, + remotePath, + displayName: 'Docker SSH Authority Race E2E' + }) + if ('error' in result) { + throw new Error(result.error) + } + await store.getState().fetchRepos() + const executionHostId = `ssh:${encodeURIComponent(targetId)}` as const + const worktreeResult = await store.getState().fetchWorktrees(result.repo.id, { + executionHostId, + directSshAuthority: { + targetId, + providerEpoch: authority.providerEpoch, + connectionGeneration: authority.connectionGeneration + }, + requireAuthoritative: true + }) + if (worktreeResult.status !== 'complete') { + throw new Error(`Remote worktree hydration failed: ${JSON.stringify(worktreeResult)}`) + } + const worktree = (store.getState().worktreesByRepo[result.repo.id] ?? []).find( + (candidate) => candidate.hostId === executionHostId + ) + if (!worktree) { + throw new Error(`No remote worktree found for ${result.repo.path}`) + } + store.getState().setActiveWorktree(worktree.id) + if ((store.getState().tabsByWorktree[worktree.id] ?? []).length === 0) { + store.getState().createTab(worktree.id) + } + store.getState().setActiveTabType('terminal') + return { targetId, repoId: result.repo.id, worktreeId: worktree.id } + }, + { targetId, remotePath } + ) +} diff --git a/tests/e2e/helpers/docker-ssh-relay-daemon-signal.ts b/tests/e2e/helpers/docker-ssh-relay-daemon-signal.ts new file mode 100644 index 00000000000..929c919702b --- /dev/null +++ b/tests/e2e/helpers/docker-ssh-relay-daemon-signal.ts @@ -0,0 +1,50 @@ +import type { DockerSshRelayDaemonSnapshot } from './docker-ssh-relay-processes' +import { + execDockerSshRelayTargetCommand, + shellQuote, + type DockerSshRelayTarget +} from './docker-ssh-relay-target' + +export function signalDockerSshRelayDaemon( + target: DockerSshRelayTarget, + snapshot: DockerSshRelayDaemonSnapshot, + signal: 'TERM' | 'KILL' +): void { + const { relayPid, relayDir, role, socketPath, authorityProcessToken } = snapshot + if (!Number.isInteger(relayPid)) { + throw new Error('Docker SSH relay process ID must be an integer') + } + if (role === 'legacy-combined') { + throw new Error('Role-specific authority tests cannot signal a legacy combined relay') + } + const roleFlag = role === 'terminal-authority' ? '--terminal-authority' : '--control-adapter' + const identityChecks = [ + `proc=/proc/${relayPid}`, + '[ -r "$proc/cmdline" ]', + 'argv=()', + 'mapfile -d \'\' -t argv < "$proc/cmdline"', + '[ "${argv[1]##*/}" = relay.js ]', + '[[ " ${argv[*]:2} " = *" --detached "* ]]', + `[[ " \${argv[*]:2} " = *" ${roleFlag} "* ]]`, + `[ "$(readlink "$proc/cwd")" = ${shellQuote(relayDir)} ]`, + `expected_socket=${shellQuote(socketPath)}`, + 'matched_socket=no', + 'for ((i=2; i<${#argv[@]}; i++)); do if [ "${argv[$i]}" = --sock-path ] && [ "${argv[$((i+1))]:-}" = "$expected_socket" ]; then matched_socket=yes; fi; done', + '[ "$matched_socket" = yes ]' + ] + if (role === 'terminal-authority') { + if (!authorityProcessToken) { + throw new Error('Terminal authority snapshot has no process token') + } + identityChecks.push( + `expected_token=${shellQuote(authorityProcessToken)}`, + 'matched_token=no', + 'for ((i=2; i<${#argv[@]}; i++)); do if [ "${argv[$i]}" = --authority-process-token ] && [ "${argv[$((i+1))]:-}" = "$expected_token" ]; then matched_token=yes; fi; done', + '[ "$matched_token" = yes ]' + ) + } + execDockerSshRelayTargetCommand( + target, + [...identityChecks, `kill -${signal} ${relayPid}`].join(' && ') + ) +} diff --git a/tests/e2e/helpers/docker-ssh-relay-processes.ts b/tests/e2e/helpers/docker-ssh-relay-processes.ts index 769b0bd1372..bf48d6bb2e3 100644 --- a/tests/e2e/helpers/docker-ssh-relay-processes.ts +++ b/tests/e2e/helpers/docker-ssh-relay-processes.ts @@ -10,6 +10,14 @@ export type DockerSshRelayProcessSnapshot = { relayDir: string } +export type DockerSshRelayDaemonRole = 'control-adapter' | 'terminal-authority' | 'legacy-combined' + +export type DockerSshRelayDaemonSnapshot = DockerSshRelayProcessSnapshot & { + role: DockerSshRelayDaemonRole + socketPath: string + authorityProcessToken?: string +} + export type DockerSshRelayArtifactState = { installComplete: boolean relayWatcher: boolean @@ -20,6 +28,9 @@ type RelayProcessRow = { pid: number parentPid: number cwd: string + role: DockerSshRelayDaemonRole | null + socketPath: string | null + authorityProcessToken: string | null } const LIST_RELAY_PROCESSES_COMMAND = ` @@ -38,10 +49,24 @@ for proc in /proc/[0-9]*; do done fi [ -n "$type" ] || continue + role=- + socket=- + token=- + if [ "$type" = relay ]; then + role=legacy-combined + for ((i=2; i<\${#argv[@]}; i++)); do + case "\${argv[$i]}" in + --control-adapter) role=control-adapter ;; + --terminal-authority) role=terminal-authority ;; + --sock-path) i=$((i+1)); socket="\${argv[$i]:--}" ;; + --authority-process-token) i=$((i+1)); token="\${argv[$i]:--}" ;; + esac + done + fi pid="\${proc##*/}" ppid="$(awk '/^PPid:/{print $2}' "$proc/status" 2>/dev/null)" cwd="$(readlink "$proc/cwd" 2>/dev/null)" - printf '%s\\t%s\\t%s\\t%s\\n' "$type" "$pid" "$ppid" "$cwd" + printf '%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\n' "$type" "$pid" "$ppid" "$cwd" "$role" "$socket" "$token" done ` @@ -50,7 +75,8 @@ function parseRelayProcessRows(output: string): RelayProcessRow[] { return [] } return output.split('\n').map((line) => { - const [type, rawPid, rawParentPid, cwd] = line.split('\t') + const [type, rawPid, rawParentPid, cwd, rawRole, rawSocketPath, rawProcessToken] = + line.split('\t') // Why: Number('') is 0, so empty pid/ppid (e.g. vanished /proc status) must // throw and let expect.poll retry instead of accepting parentPid: 0. const pid = Number(rawPid) @@ -67,10 +93,75 @@ function parseRelayProcessRows(output: string): RelayProcessRow[] { ) { throw new Error(`Unexpected Docker SSH relay process row: ${line}`) } - return { type, pid, parentPid, cwd } + const role = rawRole === '-' ? null : rawRole + if ( + role !== null && + role !== 'control-adapter' && + role !== 'terminal-authority' && + role !== 'legacy-combined' + ) { + throw new Error(`Unexpected Docker SSH relay role: ${line}`) + } + return { + type, + pid, + parentPid, + cwd, + role, + socketPath: rawSocketPath === '-' ? null : rawSocketPath, + authorityProcessToken: rawProcessToken === '-' ? null : rawProcessToken + } }) } +export function readDockerSshRelayDaemonSnapshots( + target: DockerSshRelayTarget +): DockerSshRelayDaemonSnapshot[] { + const rows = parseRelayProcessRows( + execDockerSshRelayTargetCommand(target, LIST_RELAY_PROCESSES_COMMAND) + ) + return rows + .filter((row) => row.type === 'relay') + .map((relay) => { + if (!relay.role || !relay.socketPath) { + throw new Error(`Detached Docker SSH relay ${relay.pid} has incomplete role identity`) + } + const watcherPids = rows + .filter((row) => row.type === 'watcher' && row.parentPid === relay.pid) + .map((row) => row.pid) + .sort((left, right) => left - right) + return { + relayPid: relay.pid, + watcherPids, + relayDir: relay.cwd, + role: relay.role, + socketPath: relay.socketPath, + ...(relay.authorityProcessToken + ? { authorityProcessToken: relay.authorityProcessToken } + : {}) + } + }) + .sort((left, right) => left.relayPid - right.relayPid) +} + +export function readDockerSshRelayDaemonLogs(target: DockerSshRelayTarget): string { + return execDockerSshRelayTargetCommand( + target, + [ + "find /root/.orca-remote -type f \\( -name 'relay.log' -o -name 'authority*.log' \\) -print0 2>/dev/null |", + "while IFS= read -r -d '' file; do", + ' printf \'\n===== %s =====\n\' "$file"', + ' tail -n 500 "$file"', + 'done', + 'for file in /tmp/orca-terminal-authority-input.log /tmp/orca-terminal-authority-replay.complete; do', + ' [ -f "$file" ] || continue', + ' printf \'\n===== %s =====\n\' "$file"', + ' tail -n 500 "$file"', + 'done' + ].join('\n') + ) +} + export function readDockerSshRelayProcessSnapshot( target: DockerSshRelayTarget ): DockerSshRelayProcessSnapshot | null { diff --git a/tests/e2e/helpers/docker-ssh-relay-target.ts b/tests/e2e/helpers/docker-ssh-relay-target.ts index 6c6f75dfc75..b89b179d584 100644 --- a/tests/e2e/helpers/docker-ssh-relay-target.ts +++ b/tests/e2e/helpers/docker-ssh-relay-target.ts @@ -122,7 +122,30 @@ export function writeDockerSshRelayTargetFile( ) } -export function startDockerSshRelayTarget(testInfo: TestInfo): DockerSshRelayTarget { +export function blockDockerSshRelayReconnect(target: DockerSshRelayTarget): void { + execDockerSshRelayTargetCommand( + target, + 'test -f /root/.ssh/authorized_keys && ' + + 'mv /root/.ssh/authorized_keys /root/.ssh/authorized_keys.orca-e2e-blocked' + ) +} + +export function restoreDockerSshRelayReconnect(target: DockerSshRelayTarget): void { + execDockerSshRelayTargetCommand( + target, + 'if [ -f /root/.ssh/authorized_keys.orca-e2e-blocked ]; then ' + + 'mv /root/.ssh/authorized_keys.orca-e2e-blocked /root/.ssh/authorized_keys; fi' + ) +} + +export function startDockerSshRelayTarget( + testInfo: TestInfo, + options: { maxSessions?: number } = {} +): DockerSshRelayTarget { + const maxSessions = options.maxSessions ?? 10 + if (!Number.isInteger(maxSessions) || maxSessions < 1 || maxSessions > 64) { + throw new Error('Docker SSH MaxSessions must be an integer between 1 and 64') + } const host = process.env.ORCA_E2E_SSH_TARGET_HOST?.trim() || '127.0.0.1' if (host === 'localhost' || host === '::1' || host.startsWith('127.')) { if (process.env.ORCA_E2E_SSH_TARGET_HOST) { @@ -158,7 +181,7 @@ export function startDockerSshRelayTarget(testInfo: TestInfo): DockerSshRelayTar 'chmod 600 /root/.ssh/authorized_keys', 'git config --global user.email e2e@test.local', 'git config --global user.name "Orca Docker SSH E2E"', - 'exec /usr/sbin/sshd -D -e' + `exec /usr/sbin/sshd -D -e -o MaxSessions=${maxSessions}` ].join(' && ') ], { timeoutMs: 120_000 } diff --git a/tests/e2e/helpers/docker-ssh-terminal-authority.ts b/tests/e2e/helpers/docker-ssh-terminal-authority.ts new file mode 100644 index 00000000000..2d400fdef6f --- /dev/null +++ b/tests/e2e/helpers/docker-ssh-terminal-authority.ts @@ -0,0 +1,306 @@ +import { + parseSshTerminalAuthorityMarker, + type SshTerminalAuthorityMarker +} from '../../../src/shared/ssh-terminal-authority-marker' +import type { SshRemotePtyLease } from '../../../src/shared/ssh-types' +import type { Page, TestInfo } from '@stablyai/playwright-test' +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { + execDockerSshRelayTargetCommand, + shellQuote, + type DockerSshRelayTarget +} from './docker-ssh-relay-target' +import { readDockerSshRelayDaemonLogs } from './docker-ssh-relay-processes' + +export const DOCKER_SSH_TERMINAL_AUTHORITY_STATE_DIR = '/root/.orca-remote/terminal-authority' +export const DOCKER_SSH_TERMINAL_AUTHORITY_MARKER_PATH = `${DOCKER_SSH_TERMINAL_AUTHORITY_STATE_DIR}/active-endpoint` +export const DOCKER_SSH_TERMINAL_AUTHORITY_AUDIT_SCRIPT = '/tmp/orca-terminal-authority-audit.js' +export const DOCKER_SSH_TERMINAL_AUTHORITY_AUDIT_FILE = '/tmp/orca-terminal-authority-input.log' +export const DOCKER_SSH_TERMINAL_AUTHORITY_REPLAY_COMPLETE = + '/tmp/orca-terminal-authority-replay.complete' +export const DOCKER_SSH_TERMINAL_AUTHORITY_REPLAY_LINE_COUNT = 32 + +export const DOCKER_SSH_INCOMPATIBLE_AUTHORITY_REVISION = 7 + +export function dockerSshTerminalAuthorityInputFrame(value: string): string { + if (/[\r\n]/.test(value)) { + throw new Error('Docker SSH authority input frame cannot contain a newline') + } + return `${value}\n` +} + +export async function installDockerSshTerminalBindingTransitionProbe( + page: Page, + args: { targetId: string; tabId: string; leafId: string } +): Promise { + await page.evaluate(({ targetId, tabId, leafId }) => { + const holder = window as unknown as { + __sshAuthorityBindingProbe?: { entries: unknown[]; dispose: () => void } + } + holder.__sshAuthorityBindingProbe?.dispose() + const store = window.__store + if (!store) { + throw new Error('Store unavailable') + } + const entries: unknown[] = [] + let lastSignature = '' + const capture = (): void => { + const state = store.getState() + const connection = state.sshConnectionStates.get(targetId) + const tab = Object.values(state.tabsByWorktree) + .flat() + .find((candidate) => candidate.id === tabId) + const retry = state.directSshPaneRetryByTabId[tabId] + const live = state.directSshLivePtyBindingByTabId[tabId] + const transition = { + connection: connection + ? { + status: connection.status, + providerEpoch: connection.providerEpoch ?? null, + connectionGeneration: connection.connectionGeneration ?? null + } + : null, + tab: tab + ? { + generation: tab.generation ?? 0, + ptyId: tab.ptyId ?? null, + pendingActivationSpawn: Boolean(tab.pendingActivationSpawn) + } + : null, + layoutPtyId: state.terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId[leafId] ?? null, + tabPtyIds: state.ptyIdsByTabId[tabId] ?? [], + retry: retry ?? null, + live: live ?? null + } + const signature = JSON.stringify(transition) + if (signature === lastSignature) { + return + } + lastSignature = signature + entries.push({ at: Date.now(), ...transition }) + if (entries.length > 128) { + entries.shift() + } + } + capture() + const dispose = store.subscribe(capture) + holder.__sshAuthorityBindingProbe = { entries, dispose } + }, args) +} + +export function dockerSshTerminalAuthorityAuditProgram(): string { + return [ + "const fs = require('node:fs')", + `const audit = ${JSON.stringify(DOCKER_SSH_TERMINAL_AUTHORITY_AUDIT_FILE)}`, + `const complete = ${JSON.stringify(DOCKER_SSH_TERMINAL_AUTHORITY_REPLAY_COMPLETE)}`, + "const append = (line) => fs.appendFileSync(audit, line + '\\n')", + "process.stdin.setEncoding('utf8')", + 'if (process.stdin.isTTY) process.stdin.setRawMode(true)', + 'process.stdin.resume()', + "let pendingInput = ''", + "append('START:' + process.pid)", + "process.stdout.write('AUTHORITY_AUDIT_READY\\r\\n')", + "process.stdin.on('data', (data) => {", + ' pendingInput += data', + ' const frames = pendingInput.split(/[\\r\\n]/)', + " pendingInput = frames.pop() || ''", + ' for (const frame of frames) {', + ' if (!frame) continue', + " const encoded = Buffer.from(frame).toString('base64url')", + " append('INPUT:' + encoded)", + " process.stdout.write('AUTHORITY_INPUT_ACK_' + encoded + '\\r\\n')", + ' }', + '})', + "process.on('SIGHUP', () => append('SIGHUP'))", + "process.on('SIGUSR2', () => {", + ` for (let index = 1; index <= ${DOCKER_SSH_TERMINAL_AUTHORITY_REPLAY_LINE_COUNT}; index += 1) {`, + " process.stdout.write('AUTHORITY_REPLAY_' + String(index).padStart(2, '0') + '\\r\\n')", + ' }', + " fs.writeFileSync(complete, 'complete\\n')", + '})' + ].join('\n') +} + +export async function attachDockerSshTerminalAuthorityLogs( + testInfo: TestInfo, + target: DockerSshRelayTarget | null +): Promise { + if (!target) { + return + } + try { + const logPath = testInfo.outputPath('docker-ssh-relay-logs.txt') + writeFileSync(logPath, readDockerSshRelayDaemonLogs(target)) + await testInfo.attach('docker-ssh-relay-logs', { + path: logPath, + contentType: 'text/plain' + }) + } catch { + // Container cleanup still owns the failure path. + } +} + +export function readPersistedDockerSshTerminalAuthorityLease( + userDataDir: string, + targetId: string +): SshRemotePtyLease | null { + const index = readJsonFile(path.join(userDataDir, 'orca-profile-index.json')) + const activeProfileId = + isRecord(index) && typeof index.activeProfileId === 'string' ? index.activeProfileId : null + const candidates = [ + ...(activeProfileId + ? [path.join(userDataDir, 'profiles', activeProfileId, 'orca-data.json')] + : []), + path.join(userDataDir, 'orca-data.json') + ] + for (const dataFile of candidates) { + const persisted = readJsonFile(dataFile) + if (!isRecord(persisted) || !Array.isArray(persisted.sshRemotePtyLeases)) { + continue + } + const leases = persisted.sshRemotePtyLeases.filter(isSshRemotePtyLease) + const matches = leases.filter( + (lease) => + lease.targetId === targetId && + lease.state !== 'terminated' && + lease.terminalSessionAuthorityAccess !== undefined + ) + return matches.length === 1 ? matches[0]! : null + } + return null +} + +export function readDockerSshTerminalAuthorityAuditPid(target: DockerSshRelayTarget): number { + const audit = readDockerSshAuditFile(target, DOCKER_SSH_TERMINAL_AUTHORITY_AUDIT_FILE) + const starts = [...audit.matchAll(/^START:(\d+)$/gm)].map((match) => Number(match[1])) + if (starts.length !== 1 || !Number.isSafeInteger(starts[0]) || starts[0]! <= 0) { + throw new Error(`Expected one live Docker SSH authority audit process, found: ${audit}`) + } + const pid = starts[0]! + execDockerSshRelayTargetCommand(target, `test -r /proc/${pid}/cmdline`) + return pid +} + +export function readDockerSshTerminalAuthorityMarker( + target: DockerSshRelayTarget +): SshTerminalAuthorityMarker | null { + const output = execDockerSshRelayTargetCommand( + target, + `if [ -f ${shellQuote(DOCKER_SSH_TERMINAL_AUTHORITY_MARKER_PATH)} ]; then ` + + `cat ${shellQuote(DOCKER_SSH_TERMINAL_AUTHORITY_MARKER_PATH)}; fi` + ) + if (!output) { + return null + } + const marker = parseSshTerminalAuthorityMarker(JSON.parse(output)) + if (!marker) { + throw new Error('Docker SSH terminal authority marker is invalid') + } + return marker +} + +export function installDockerSshIncompatibleTerminalAuthorityMarker( + target: DockerSshRelayTarget +): void { + const ownerRelayDir = '/root/.orca-remote/relay-0.0.0+deadbeef' + const marker: SshTerminalAuthorityMarker = { + markerVersion: 1, + authorityHostId: '10000000-0000-4000-8000-000000000001', + ownerInstanceId: '20000000-0000-4000-8000-000000000002', + ownerPid: 1, + ownerProcessToken: 'incompatible_owner_token_1234', + ownerBuildId: 'incompatible-owner-build', + ownerRelayDir, + socketPath: `${DOCKER_SSH_TERMINAL_AUTHORITY_STATE_DIR}/authority.sock`, + credentialFile: `${DOCKER_SSH_TERMINAL_AUTHORITY_STATE_DIR}/endpoint.credential`, + compatibility: { + major: 999, + minMinor: 0, + maxMinor: 0, + capabilities: ['relay.rpc.v1'], + requiredCapabilities: ['relay.rpc.v1'] + }, + revision: DOCKER_SSH_INCOMPATIBLE_AUTHORITY_REVISION + } + execDockerSshRelayTargetCommand( + target, + [ + `mkdir -p ${shellQuote(ownerRelayDir)} ${shellQuote(DOCKER_SSH_TERMINAL_AUTHORITY_STATE_DIR)}`, + `chmod 700 ${shellQuote(DOCKER_SSH_TERMINAL_AUTHORITY_STATE_DIR)}`, + `printf '%s' ${shellQuote('incompatible-credential')} > ${shellQuote(marker.credentialFile)}`, + `chmod 600 ${shellQuote(marker.credentialFile)}`, + `printf '%s\\n' ${shellQuote(JSON.stringify(marker))} > ${shellQuote(DOCKER_SSH_TERMINAL_AUTHORITY_MARKER_PATH)}`, + `chmod 600 ${shellQuote(DOCKER_SSH_TERMINAL_AUTHORITY_MARKER_PATH)}` + ].join(' && ') + ) +} + +export function countDockerSshPtyChildren( + target: DockerSshRelayTarget, + authorityPid: number +): number { + if (!Number.isInteger(authorityPid)) { + throw new Error('Terminal authority PID must be an integer') + } + const output = execDockerSshRelayTargetCommand( + target, + [ + `root=${authorityPid}`, + 'count=0', + 'frontier="$root"', + 'while [ -n "$frontier" ]; do', + ' next=', + ' for parent in $frontier; do', + ' for proc in /proc/[0-9]*; do', + ' [ -r "$proc/status" ] || continue', + ' pid="${proc##*/}"', + ' ppid="$(awk \'/^PPid:/{print $2}\' "$proc/status" 2>/dev/null)"', + ' if [ "$ppid" = "$parent" ]; then count=$((count+1)); next="$next $pid"; fi', + ' done', + ' done', + ' frontier="$next"', + 'done', + 'printf "%s" "$count"' + ].join('\n') + ) + const count = Number(output) + if (!Number.isInteger(count) || count < 0) { + throw new Error(`Invalid Docker SSH PTY child count: ${output}`) + } + return count +} + +export function readDockerSshAuditFile(target: DockerSshRelayTarget, path: string): string { + return execDockerSshRelayTargetCommand( + target, + `if [ -f ${shellQuote(path)} ]; then cat ${shellQuote(path)}; fi` + ) +} + +function readJsonFile(filePath: string): unknown { + if (!existsSync(filePath)) { + return null + } + try { + return JSON.parse(readFileSync(filePath, 'utf8')) + } catch { + return null + } +} + +function isSshRemotePtyLease(value: unknown): value is SshRemotePtyLease { + if (!isRecord(value)) { + return false + } + const state = value.state + return ( + typeof value.targetId === 'string' && + typeof value.ptyId === 'string' && + (state === 'attached' || state === 'detached' || state === 'terminated' || state === 'expired') + ) +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/tests/e2e/helpers/terminal.ts b/tests/e2e/helpers/terminal.ts index ec55b702c39..80c1d38cb44 100644 --- a/tests/e2e/helpers/terminal.ts +++ b/tests/e2e/helpers/terminal.ts @@ -414,7 +414,16 @@ export async function moveTerminalPaneByLeafId( export async function sendToTerminal(page: Page, ptyId: string, text: string): Promise { await page.evaluate( ({ ptyId, text }) => { - window.api.pty.write(ptyId, text) + for (const manager of window.__paneManagers?.values() ?? []) { + const pane = manager + .getPanes() + .find((candidate) => candidate.container.dataset.ptyId === ptyId) + if (pane) { + pane.terminal.input(text) + return + } + } + throw new Error(`sendToTerminal: no mounted pane owns PTY ${ptyId}`) }, { ptyId, text } ) diff --git a/tests/e2e/ssh-docker-terminal-authority-restart.spec.ts b/tests/e2e/ssh-docker-terminal-authority-restart.spec.ts new file mode 100644 index 00000000000..015a84bb0cd --- /dev/null +++ b/tests/e2e/ssh-docker-terminal-authority-restart.spec.ts @@ -0,0 +1,295 @@ +import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import type { SshRemotePtyLease } from '../../src/shared/ssh-types' +import { toAppSshPtyId } from '../../src/shared/ssh-pty-id' +import { expect, test } from './helpers/orca-app' +import { connectDockerSshRelayTarget } from './helpers/docker-ssh-relay-connection' +import { readDockerSshRelayDaemonSnapshots } from './helpers/docker-ssh-relay-processes' +import { + cleanupDockerSshRelayTarget, + execDockerSshRelayTargetCommand, + shellQuote, + startDockerSshRelayTarget, + writeDockerSshRelayTargetFile, + type DockerSshRelayTarget +} from './helpers/docker-ssh-relay-target' +import { + attachDockerSshTerminalAuthorityLogs, + DOCKER_SSH_TERMINAL_AUTHORITY_AUDIT_FILE, + DOCKER_SSH_TERMINAL_AUTHORITY_AUDIT_SCRIPT, + DOCKER_SSH_TERMINAL_AUTHORITY_REPLAY_COMPLETE, + DOCKER_SSH_TERMINAL_AUTHORITY_REPLAY_LINE_COUNT, + dockerSshTerminalAuthorityAuditProgram, + dockerSshTerminalAuthorityInputFrame, + readDockerSshAuditFile, + readDockerSshTerminalAuthorityAuditPid, + readDockerSshTerminalAuthorityMarker, + readPersistedDockerSshTerminalAuthorityLease +} from './helpers/docker-ssh-terminal-authority' +import { createRestartSession } from './helpers/orca-restart' +import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { + execInTerminal, + getTerminalContent, + readPaneIdentitySnapshot, + sendToTerminal, + waitForActivePanePtyId, + waitForActiveTerminalManager, + waitForTerminalOutput, + type PaneIdentitySnapshot +} from './helpers/terminal' + +const RUN_DOCKER_SSH = process.env.ORCA_E2E_SSH_DOCKER === '1' +const AUTHORITY_BUDGET_MS = 90_000 + +type AuthorityRestartProof = Readonly<{ + ptyId: string + pane: PaneIdentitySnapshot + stablePaneId: string + lease: SshRemotePtyLease +}> + +async function waitForConnectedTarget(page: Page, targetId: string): Promise { + await expect + .poll( + () => + page.evaluate( + (targetId) => + window.__store?.getState().sshConnectionStates.get(targetId)?.status ?? 'missing', + targetId + ), + { timeout: AUTHORITY_BUDGET_MS, message: 'SSH target did not reconnect after app relaunch' } + ) + .toBe('connected') +} + +async function captureAuthorityRestartProof( + page: Page, + targetId: string, + userDataDir: string +): Promise { + await waitForActiveTerminalManager(page, AUTHORITY_BUDGET_MS) + const ptyId = await waitForActivePanePtyId(page, AUTHORITY_BUDGET_MS) + let pane = await readPaneIdentitySnapshot(page) + await expect + .poll( + async () => { + pane = await readPaneIdentitySnapshot(page) + return pane?.activeLeafId ?? null + }, + { timeout: AUTHORITY_BUDGET_MS, message: 'SSH pane identity did not settle' } + ) + .not.toBeNull() + let lease = readPersistedDockerSshTerminalAuthorityLease(userDataDir, targetId) + await expect + .poll( + () => { + lease = readPersistedDockerSshTerminalAuthorityLease(userDataDir, targetId) + return lease?.terminalSessionAuthorityAccess ?? null + }, + { timeout: AUTHORITY_BUDGET_MS, message: 'SSH authority access was not durably leased' } + ) + .not.toBeNull() + if (!pane?.activeLeafId || !lease?.terminalSessionAuthorityAccess) { + throw new Error('SSH authority restart proof was incomplete') + } + const activePane = pane.panes.find((candidate) => candidate.leafId === pane!.activeLeafId) + if (!activePane) { + throw new Error('SSH authority pane disappeared while capturing its identity') + } + expect(ptyId).toBe(toAppSshPtyId(targetId, lease.ptyId)) + expect(lease.terminalSessionAuthorityAccess.binding.physicalPtyId).toBe(lease.ptyId) + expect(lease.terminalSessionAuthorityAccess.binding.ptyIncarnationId).toBe(lease.incarnationId) + return Object.freeze({ ptyId, pane, stablePaneId: activePane.stablePaneId, lease }) +} + +async function waitForAuthorityPid(target: DockerSshRelayTarget): Promise { + let pid: number | null = null + await expect + .poll( + () => { + const authorities = readDockerSshRelayDaemonSnapshots(target).filter( + (snapshot) => snapshot.role === 'terminal-authority' + ) + pid = authorities.length === 1 ? authorities[0]!.relayPid : null + return pid + }, + { timeout: AUTHORITY_BUDGET_MS, message: 'Remote terminal authority did not converge' } + ) + .not.toBeNull() + if (!pid) { + throw new Error('Remote terminal authority PID disappeared') + } + return pid +} + +function assertSameAuthorityIdentity( + before: AuthorityRestartProof, + after: AuthorityRestartProof +): void { + expect(after.ptyId).toBe(before.ptyId) + expect(after.pane.tabId).toBe(before.pane.tabId) + expect(after.pane.activeLeafId).toBe(before.pane.activeLeafId) + expect(after.stablePaneId).toBe(before.stablePaneId) + expect(after.pane.ptyIdsByLeafId).toEqual(before.pane.ptyIdsByLeafId) + expect(after.lease.ptyId).toBe(before.lease.ptyId) + expect(after.lease.incarnationId).toBe(before.lease.incarnationId) + expect(after.lease.worktreeId).toBe(before.lease.worktreeId) + expect(after.lease.tabId).toBe(before.lease.tabId) + expect(after.lease.leafId).toBe(before.lease.leafId) + expect(after.lease.paneGeneration).toBe(before.lease.paneGeneration) + expect(after.lease.terminalSessionAuthorityAccess).toEqual( + before.lease.terminalSessionAuthorityAccess + ) +} + +function assertOrderedReplayExactlyOnce(content: string): void { + let previousOffset = -1 + for (let index = 1; index <= DOCKER_SSH_TERMINAL_AUTHORITY_REPLAY_LINE_COUNT; index += 1) { + const marker = `AUTHORITY_REPLAY_${String(index).padStart(2, '0')}` + expect(content.split(marker)).toHaveLength(2) + const offset = content.indexOf(marker) + expect(offset).toBeGreaterThan(previousOffset) + previousOffset = offset + } +} + +test.describe('Docker SSH terminal authority app restart', () => { + test.skip(!RUN_DOCKER_SSH, 'Set ORCA_E2E_SSH_DOCKER=1 to run Docker SSH authority tests.') + + test('preserves exact authority, process, pane, replay, and input across relaunch', async () => { + test.setTimeout(360_000) + const testInfo = test.info() + const restart = createRestartSession(testInfo) + let target: DockerSshRelayTarget | null = null + let firstApp: ElectronApplication | null = null + let secondApp: ElectronApplication | null = null + try { + target = startDockerSshRelayTarget(testInfo, { maxSessions: 1 }) + writeDockerSshRelayTargetFile( + target, + DOCKER_SSH_TERMINAL_AUTHORITY_AUDIT_SCRIPT, + dockerSshTerminalAuthorityAuditProgram() + ) + const firstLaunch = await restart.launch() + firstApp = firstLaunch.app + await waitForSessionReady(firstLaunch.page) + const remote = await connectDockerSshRelayTarget(firstLaunch.page, target, { + relayGracePeriodSeconds: 300 + }) + await expect + .poll(() => waitForActiveWorktree(firstLaunch.page), { timeout: AUTHORITY_BUDGET_MS }) + .toBe(remote.worktreeId) + const before = await captureAuthorityRestartProof( + firstLaunch.page, + remote.targetId, + restart.userDataDir + ) + await execInTerminal( + firstLaunch.page, + before.ptyId, + `node ${shellQuote(DOCKER_SSH_TERMINAL_AUTHORITY_AUDIT_SCRIPT)}` + ) + await waitForTerminalOutput( + firstLaunch.page, + 'AUTHORITY_AUDIT_READY', + 30_000, + AUTHORITY_BUDGET_MS + ) + const auditPid = readDockerSshTerminalAuthorityAuditPid(target) + const authorityPid = await waitForAuthorityPid(target) + const markerBefore = readDockerSshTerminalAuthorityMarker(target) + expect(markerBefore?.ownerPid).toBe(authorityPid) + + await firstLaunch.page.evaluate(() => window.dispatchEvent(new Event('beforeunload'))) + await expect + .poll( + async () => { + const persisted = await firstLaunch.page.evaluate(() => window.api.session.get()) + const lease = readPersistedDockerSshTerminalAuthorityLease( + restart.userDataDir, + remote.targetId + ) + return { + active: persisted.activeConnectionIdsAtShutdown?.includes(remote.targetId) === true, + access: lease?.terminalSessionAuthorityAccess ?? null + } + }, + { timeout: 15_000, message: 'SSH authority state was not durable before app close' } + ) + .toEqual({ + active: true, + access: before.lease.terminalSessionAuthorityAccess + }) + + await restart.close(firstApp) + firstApp = null + expect(await waitForAuthorityPid(target)).toBe(authorityPid) + expect(readDockerSshTerminalAuthorityAuditPid(target)).toBe(auditPid) + execDockerSshRelayTargetCommand( + target, + `kill -USR2 ${auditPid} && test -r /proc/${auditPid}/cmdline` + ) + await expect + .poll( + () => readDockerSshAuditFile(target!, DOCKER_SSH_TERMINAL_AUTHORITY_REPLAY_COMPLETE), + { timeout: 30_000, message: 'Remote PTY did not emit restart replay output' } + ) + .toContain('complete') + + const secondLaunch = await restart.launch() + secondApp = secondLaunch.app + await waitForSessionReady(secondLaunch.page, AUTHORITY_BUDGET_MS) + await expect + .poll(() => waitForActiveWorktree(secondLaunch.page), { timeout: AUTHORITY_BUDGET_MS }) + .toBe(remote.worktreeId) + await waitForConnectedTarget(secondLaunch.page, remote.targetId) + const after = await captureAuthorityRestartProof( + secondLaunch.page, + remote.targetId, + restart.userDataDir + ) + assertSameAuthorityIdentity(before, after) + expect(await waitForAuthorityPid(target)).toBe(authorityPid) + expect(readDockerSshTerminalAuthorityAuditPid(target)).toBe(auditPid) + expect(readDockerSshTerminalAuthorityMarker(target)).toEqual(markerBefore) + + await waitForTerminalOutput( + secondLaunch.page, + `AUTHORITY_REPLAY_${DOCKER_SSH_TERMINAL_AUTHORITY_REPLAY_LINE_COUNT}`, + 60_000, + 100_000 + ) + assertOrderedReplayExactlyOnce(await getTerminalContent(secondLaunch.page, 100_000)) + + const postRestartInput = `after-main-relaunch-${Date.now()}` + const encodedPostRestartInput = Buffer.from(postRestartInput).toString('base64url') + await sendToTerminal( + secondLaunch.page, + after.ptyId, + dockerSshTerminalAuthorityInputFrame(postRestartInput) + ) + await waitForTerminalOutput( + secondLaunch.page, + `AUTHORITY_INPUT_ACK_${encodedPostRestartInput}`, + 30_000, + AUTHORITY_BUDGET_MS + ) + expect( + readDockerSshAuditFile(target, DOCKER_SSH_TERMINAL_AUTHORITY_AUDIT_FILE).split( + `INPUT:${encodedPostRestartInput}` + ) + ).toHaveLength(2) + expect(readDockerSshTerminalAuthorityAuditPid(target)).toBe(auditPid) + } finally { + if (secondApp) { + await restart.close(secondApp) + } + if (firstApp) { + await restart.close(firstApp) + } + await restart.dispose() + await attachDockerSshTerminalAuthorityLogs(testInfo, target) + cleanupDockerSshRelayTarget(target) + } + }) +}) diff --git a/tests/e2e/ssh-docker-terminal-authority.spec.ts b/tests/e2e/ssh-docker-terminal-authority.spec.ts new file mode 100644 index 00000000000..e481c1b30ca --- /dev/null +++ b/tests/e2e/ssh-docker-terminal-authority.spec.ts @@ -0,0 +1,490 @@ +import type { Page } from '@stablyai/playwright-test' +import { writeFileSync } from 'node:fs' +import { expect, test } from './helpers/orca-app' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { + execInTerminal, + getTerminalContent, + readPaneIdentitySnapshot, + sendToTerminal, + waitForActivePanePtyId, + waitForActiveTerminalManager, + waitForTerminalOutput +} from './helpers/terminal' +import { + connectDockerSshRelayTarget, + reconnectDisconnectedDockerSshRelayTarget, + reconnectDockerSshRelayTarget +} from './helpers/docker-ssh-relay-connection' +import { + activateConnectedDockerSshRelayTarget, + connectDockerSshAuthorityClientsConcurrently +} from './helpers/docker-ssh-authority-clients' +import { + readDockerSshRelayDaemonSnapshots, + type DockerSshRelayDaemonSnapshot +} from './helpers/docker-ssh-relay-processes' +import { signalDockerSshRelayDaemon } from './helpers/docker-ssh-relay-daemon-signal' +import { + blockDockerSshRelayReconnect, + cleanupDockerSshRelayTarget, + execDockerSshRelayTargetCommand, + restoreDockerSshRelayReconnect, + shellQuote, + startDockerSshRelayTarget, + writeDockerSshRelayTargetFile, + type DockerSshRelayTarget +} from './helpers/docker-ssh-relay-target' +import { + attachDockerSshTerminalAuthorityLogs, + DOCKER_SSH_INCOMPATIBLE_AUTHORITY_REVISION, + DOCKER_SSH_TERMINAL_AUTHORITY_AUDIT_FILE, + DOCKER_SSH_TERMINAL_AUTHORITY_AUDIT_SCRIPT, + DOCKER_SSH_TERMINAL_AUTHORITY_REPLAY_COMPLETE, + DOCKER_SSH_TERMINAL_AUTHORITY_REPLAY_LINE_COUNT, + dockerSshTerminalAuthorityAuditProgram, + dockerSshTerminalAuthorityInputFrame, + installDockerSshTerminalBindingTransitionProbe, + installDockerSshIncompatibleTerminalAuthorityMarker, + readDockerSshAuditFile, + readDockerSshTerminalAuthorityMarker +} from './helpers/docker-ssh-terminal-authority' +import { dropDockerSshClientSessions } from './ssh-codex-reconnect-replay-driver' + +const RUN_DOCKER_SSH = process.env.ORCA_E2E_SSH_DOCKER === '1' +const CONNECTION_BUDGET_MS = 90_000 +const AUTHORITY_BUDGET_MS = 90_000 + +type RelayTopology = { + authority: DockerSshRelayDaemonSnapshot + control: DockerSshRelayDaemonSnapshot +} + +async function attachTerminalDeliveryDiagnostics( + testInfo: Parameters[0], + page: Page, + targetId: string, + ptyId: string +): Promise { + let diagnostics: unknown + try { + diagnostics = await page.evaluate( + async ({ targetId, ptyId }) => { + const debugWindow = window as unknown as { + __orcaTerminalFreezeReport?: () => Promise + __terminalPtyOutputDebug?: { snapshot: () => unknown } + __ptyConnectDiag?: string[] + __sshAuthorityBindingProbe?: { entries: unknown[] } + } + const state = window.__store?.getState() + const listedSessions = await window.api.pty + .listSessions() + .catch((error) => ({ error: error instanceof Error ? error.message : String(error) })) + const paneManagers = [...(window.__paneManagers?.entries() ?? [])].map( + ([tabId, manager]) => ({ + tabId, + activePaneId: manager.getActivePane?.()?.id ?? null, + panes: (manager.getPanes?.() ?? []).map((pane) => ({ + paneId: pane.id, + leafId: pane.leafId ?? null, + ptyId: pane.container?.dataset?.ptyId ?? null, + recoveryState: pane.container?.dataset?.ptyRecoveryState ?? null, + serialized: pane.serializeAddon?.serialize?.() ?? '', + cols: pane.terminal?.cols ?? null, + rows: pane.terminal?.rows ?? null + })) + }) + ) + return { + connection: state?.sshConnectionStates.get(targetId) ?? null, + hasPty: await window.api.pty.hasPty(ptyId), + listedSessions, + mainSnapshot: await window.api.pty.getMainBufferSnapshot(ptyId), + freezeReport: await debugWindow.__orcaTerminalFreezeReport?.(), + outputDebug: debugWindow.__terminalPtyOutputDebug?.snapshot() ?? null, + ptyConnectDiag: debugWindow.__ptyConnectDiag ?? [], + bindingTransitions: debugWindow.__sshAuthorityBindingProbe?.entries ?? [], + rendererBinding: { + activeWorktreeId: state?.activeWorktreeId ?? null, + activeTabId: state?.activeTabId ?? null, + activeTabType: state?.activeTabType ?? null, + activeTabIdByWorktree: state?.activeTabIdByWorktree ?? null, + ptyIdsByTabId: state?.ptyIdsByTabId ?? null, + terminalLayoutsByTabId: state?.terminalLayoutsByTabId ?? null, + directSshPaneRetryByTabId: state?.directSshPaneRetryByTabId ?? null, + directSshLivePtyBindingByTabId: state?.directSshLivePtyBindingByTabId ?? null, + paneManagers, + domPtyIds: [...document.querySelectorAll('[data-pty-id]')].map( + (element) => ({ + ptyId: element.dataset.ptyId ?? null, + leafId: element.dataset.leafId ?? null, + connected: element.isConnected + }) + ) + } + } + }, + { targetId, ptyId } + ) + } catch (error) { + diagnostics = { captureError: error instanceof Error ? error.message : String(error) } + } + try { + const diagnosticPath = testInfo.outputPath('terminal-delivery-diagnostics.json') + writeFileSync(diagnosticPath, JSON.stringify(diagnostics, null, 2)) + await testInfo.attach('terminal-delivery-diagnostics', { + path: diagnosticPath, + contentType: 'application/json' + }) + } catch { + // The original terminal-output failure remains the oracle. + } +} + +async function waitForRelayTopology( + target: DockerSshRelayTarget, + expectedControlCount = 1 +): Promise { + let snapshots: DockerSshRelayDaemonSnapshot[] = [] + await expect + .poll( + () => { + snapshots = readDockerSshRelayDaemonSnapshots(target) + return { + authorities: snapshots.filter((entry) => entry.role === 'terminal-authority').length, + controls: snapshots.filter((entry) => entry.role === 'control-adapter').length, + legacy: snapshots.filter((entry) => entry.role === 'legacy-combined').length + } + }, + { timeout: AUTHORITY_BUDGET_MS, message: 'SSH relay roles did not converge' } + ) + .toEqual({ authorities: 1, controls: expectedControlCount, legacy: 0 }) + return { + authority: snapshots.find((entry) => entry.role === 'terminal-authority')!, + control: snapshots.find((entry) => entry.role === 'control-adapter')! + } +} + +async function waitForAuthorityRevision( + target: DockerSshRelayTarget, + revision: number +): Promise>> { + let marker = readDockerSshTerminalAuthorityMarker(target) + await expect + .poll( + () => { + marker = readDockerSshTerminalAuthorityMarker(target) + return marker?.revision ?? null + }, + { timeout: AUTHORITY_BUDGET_MS, message: `Authority revision ${revision} was not published` } + ) + .toBe(revision) + if (!marker) { + throw new Error('Terminal authority marker disappeared after revision barrier') + } + return marker +} + +async function installConnectionCycleProbe(page: Page, targetId: string): Promise { + await page.evaluate((targetId) => { + const holder = window as unknown as { + __sshAuthorityCycleProbe?: { statuses: string[]; dispose: () => void } + } + holder.__sshAuthorityCycleProbe?.dispose() + const store = window.__store + if (!store) { + throw new Error('Store unavailable') + } + const statuses = [store.getState().sshConnectionStates.get(targetId)?.status ?? 'missing'] + const dispose = store.subscribe((state, previous) => { + const nextStatus = state.sshConnectionStates.get(targetId)?.status ?? 'missing' + const previousStatus = previous.sshConnectionStates.get(targetId)?.status ?? 'missing' + if (nextStatus !== previousStatus) { + statuses.push(nextStatus) + } + }) + holder.__sshAuthorityCycleProbe = { statuses, dispose } + }, targetId) +} + +async function waitForConnectionCycle(page: Page): Promise { + let statuses: string[] = [] + await expect + .poll( + async () => { + statuses = await page.evaluate( + () => + ( + window as unknown as { + __sshAuthorityCycleProbe?: { statuses: string[] } + } + ).__sshAuthorityCycleProbe?.statuses ?? [] + ) + return statuses.some((status) => status !== 'connected') && statuses.at(-1) === 'connected' + }, + { timeout: CONNECTION_BUDGET_MS, message: 'SSH connection did not fence and reconnect' } + ) + .toBe(true) + return statuses +} + +async function waitForPtyAvailable(page: Page, ptyId: string): Promise { + await expect + .poll( + () => + page.evaluate(async (id) => { + const hasPty = await window.api.pty.hasPty(id) + if (!hasPty) { + return false + } + // Main reattachment commits before the generation-fenced renderer remount binds its pane. + return [...(window.__paneManagers?.values() ?? [])].some((manager) => + manager.getPanes().some((pane) => pane.container.dataset.ptyId === id) + ) + }, ptyId), + { + timeout: CONNECTION_BUDGET_MS, + message: `SSH PTY ${ptyId} was not re-admitted and rebound in the renderer` + } + ) + .toBe(true) +} + +test.describe('Docker SSH terminal authority', () => { + test.skip(!RUN_DOCKER_SSH, 'Set ORCA_E2E_SSH_DOCKER=1 to run Docker SSH authority tests.') + + test('preserves the exact PTY across transport and control reconnects', async ({ + orcaPage + }, testInfo) => { + test.slow() + let target: DockerSshRelayTarget | null = null + let reconnectBlocked = false + try { + target = startDockerSshRelayTarget(testInfo, { maxSessions: 1 }) + writeDockerSshRelayTargetFile( + target, + DOCKER_SSH_TERMINAL_AUTHORITY_AUDIT_SCRIPT, + dockerSshTerminalAuthorityAuditProgram() + ) + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + const remote = await connectDockerSshRelayTarget(orcaPage, target, { + relayGracePeriodSeconds: 300 + }) + await ensureTerminalVisible(orcaPage, 45_000) + await waitForActiveTerminalManager(orcaPage, 60_000) + const ptyId = await waitForActivePanePtyId(orcaPage, 60_000) + const paneBefore = await readPaneIdentitySnapshot(orcaPage) + const oldLeafId = paneBefore?.activeLeafId + if (!paneBefore || !oldLeafId) { + throw new Error('SSH terminal pane identity was unavailable') + } + await installDockerSshTerminalBindingTransitionProbe(orcaPage, { + targetId: remote.targetId, + tabId: paneBefore.tabId, + leafId: oldLeafId + }) + + const firstTopology = await waitForRelayTopology(target) + const firstMarker = await waitForAuthorityRevision(target, 1) + expect(firstMarker.ownerPid).toBe(firstTopology.authority.relayPid) + expect(firstMarker.ownerRelayDir).toBe(firstTopology.authority.relayDir) + expect(firstMarker.ownerProcessToken).toBe(firstTopology.authority.authorityProcessToken) + + await execInTerminal( + orcaPage, + ptyId, + `node ${shellQuote(DOCKER_SSH_TERMINAL_AUTHORITY_AUDIT_SCRIPT)}` + ) + await waitForTerminalOutput(orcaPage, 'AUTHORITY_AUDIT_READY', 30_000, 80_000) + + await reconnectDockerSshRelayTarget(orcaPage, remote.targetId) + await waitForPtyAvailable(orcaPage, ptyId) + await sendToTerminal(orcaPage, ptyId, dockerSshTerminalAuthorityInputFrame('first-reconnect')) + await waitForTerminalOutput( + orcaPage, + `AUTHORITY_INPUT_ACK_${Buffer.from('first-reconnect').toString('base64url')}`, + 30_000, + 80_000 + ) + const topologyAfterReconnect = await waitForRelayTopology(target) + expect(topologyAfterReconnect.authority.relayPid).toBe(firstTopology.authority.relayPid) + + await installConnectionCycleProbe(orcaPage, remote.targetId) + signalDockerSshRelayDaemon(target, topologyAfterReconnect.control, 'KILL') + await waitForConnectionCycle(orcaPage) + const topologyAfterControlKill = await waitForRelayTopology(target) + expect(topologyAfterControlKill.control.relayPid).not.toBe( + topologyAfterReconnect.control.relayPid + ) + expect(topologyAfterControlKill.authority.relayPid).toBe(firstTopology.authority.relayPid) + expect((await waitForAuthorityRevision(target, 1)).ownerPid).toBe( + firstTopology.authority.relayPid + ) + await waitForPtyAvailable(orcaPage, ptyId) + await sendToTerminal( + orcaPage, + ptyId, + dockerSshTerminalAuthorityInputFrame('after-control-kill') + ) + await waitForTerminalOutput( + orcaPage, + `AUTHORITY_INPUT_ACK_${Buffer.from('after-control-kill').toString('base64url')}`, + 30_000, + 80_000 + ) + + blockDockerSshRelayReconnect(target) + reconnectBlocked = true + dropDockerSshClientSessions(target) + await expect + .poll( + () => + orcaPage.evaluate( + (targetId) => + window.__store?.getState().sshConnectionStates.get(targetId)?.status ?? 'missing', + remote.targetId + ), + { timeout: 30_000, message: 'SSH transport drop was not observed' } + ) + .not.toBe('connected') + execDockerSshRelayTargetCommand( + target, + `pid=$(awk -F: '/^START:/{print $2; exit}' ${shellQuote(DOCKER_SSH_TERMINAL_AUTHORITY_AUDIT_FILE)}); ` + + 'test -n "$pid" && kill -USR2 "$pid"' + ) + await expect + .poll( + () => readDockerSshAuditFile(target!, DOCKER_SSH_TERMINAL_AUTHORITY_REPLAY_COMPLETE), + { + timeout: 30_000, + message: 'Remote PTY did not produce the disconnected replay fixture' + } + ) + .toContain('complete') + restoreDockerSshRelayReconnect(target) + reconnectBlocked = false + await reconnectDisconnectedDockerSshRelayTarget(orcaPage, remote.targetId) + await waitForPtyAvailable(orcaPage, ptyId) + try { + await waitForTerminalOutput(orcaPage, 'AUTHORITY_REPLAY_32', 60_000, 100_000) + } catch (error) { + await attachTerminalDeliveryDiagnostics(testInfo, orcaPage, remote.targetId, ptyId) + throw error + } + const replayed = await getTerminalContent(orcaPage, 100_000) + for (let index = 1; index <= DOCKER_SSH_TERMINAL_AUTHORITY_REPLAY_LINE_COUNT; index += 1) { + const marker = `AUTHORITY_REPLAY_${String(index).padStart(2, '0')}` + expect(replayed.split(marker)).toHaveLength(2) + } + + const postReplayInput = 'after-ordered-replay' + const encodedPostReplayInput = Buffer.from(postReplayInput).toString('base64url') + await sendToTerminal(orcaPage, ptyId, dockerSshTerminalAuthorityInputFrame(postReplayInput)) + await waitForTerminalOutput( + orcaPage, + `AUTHORITY_INPUT_ACK_${encodedPostReplayInput}`, + 30_000, + 80_000 + ) + expect( + readDockerSshAuditFile(target, DOCKER_SSH_TERMINAL_AUTHORITY_AUDIT_FILE).split( + `INPUT:${encodedPostReplayInput}` + ) + ).toHaveLength(2) + expect((await readPaneIdentitySnapshot(orcaPage))?.ptyIdsByLeafId[oldLeafId]).toBe(ptyId) + } finally { + if (target && reconnectBlocked) { + restoreDockerSshRelayReconnect(target) + } + await attachDockerSshTerminalAuthorityLogs(testInfo, target) + cleanupDockerSshRelayTarget(target) + } + }) + + test('admits one authority owner under concurrent first-launch races', async ({ + orcaPage + }, testInfo) => { + test.slow() + let target: DockerSshRelayTarget | null = null + try { + target = startDockerSshRelayTarget(testInfo) + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + const clients = await connectDockerSshAuthorityClientsConcurrently(orcaPage, target, 2) + expect(clients).toHaveLength(2) + expect(new Set(clients.map((client) => client.targetId))).toHaveProperty('size', 2) + const topology = await waitForRelayTopology(target, clients.length) + const marker = await waitForAuthorityRevision(target, 1) + expect(marker.ownerPid).toBe(topology.authority.relayPid) + expect(marker.ownerProcessToken).toBe(topology.authority.authorityProcessToken) + + await activateConnectedDockerSshRelayTarget(orcaPage, clients[0]!.targetId) + await ensureTerminalVisible(orcaPage, 45_000) + await waitForActiveTerminalManager(orcaPage, 60_000) + const ptyId = await waitForActivePanePtyId(orcaPage, 60_000) + await execInTerminal(orcaPage, ptyId, 'echo CONCURRENT_AUTHORITY_WINNER_READY') + await waitForTerminalOutput(orcaPage, 'CONCURRENT_AUTHORITY_WINNER_READY', 30_000, 80_000) + expect((await waitForRelayTopology(target, clients.length)).authority.relayPid).toBe( + topology.authority.relayPid + ) + } finally { + await attachDockerSshTerminalAuthorityLogs(testInfo, target) + cleanupDockerSshRelayTarget(target) + } + }) + + test('rejects an incompatible stable owner before terminal mutation', async ({ + orcaPage + }, testInfo) => { + test.slow() + let target: DockerSshRelayTarget | null = null + try { + target = startDockerSshRelayTarget(testInfo, { maxSessions: 1 }) + installDockerSshIncompatibleTerminalAuthorityMarker(target) + await waitForSessionReady(orcaPage) + const result = await orcaPage.evaluate(async (target) => { + const credentialUnsub = window.api.ssh.onCredentialRequest((request) => { + void window.api.ssh.submitCredential({ requestId: request.requestId, value: null }) + }) + try { + const added = await window.api.ssh.addTarget({ + target: { + label: `Docker SSH Incompatible Authority ${Date.now()}`, + host: target.host, + port: target.port, + username: 'root', + identityFile: target.identityFile, + identitiesOnly: true, + relayGracePeriodSeconds: 300 + } + }) + try { + const state = await window.api.ssh.connect({ targetId: added.target.id }) + return { connected: state?.status === 'connected', error: '' } + } catch (error) { + return { + connected: false, + error: error instanceof Error ? error.message : String(error) + } + } + } finally { + credentialUnsub() + } + }, target) + expect(result.connected).toBe(false) + expect(result.error).toMatch(/incompatible protocol/i) + const marker = readDockerSshTerminalAuthorityMarker(target) + expect(marker?.revision).toBe(DOCKER_SSH_INCOMPATIBLE_AUTHORITY_REVISION) + expect(marker?.ownerBuildId).toBe('incompatible-owner-build') + expect( + readDockerSshRelayDaemonSnapshots(target).filter( + (entry) => entry.role === 'terminal-authority' + ) + ).toEqual([]) + } finally { + await attachDockerSshTerminalAuthorityLogs(testInfo, target) + cleanupDockerSshRelayTarget(target) + } + }) +})