snapshot: terminal session authority construction tree (unshippable, for salvage)

Preserves the full uncommitted construction tree from the prior agent's
comprehensive redesign so it can be mined during decomposition.
Not intended to ship: +60.9k net production LOC, 0/8 goalposts, 0/13 journeys.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil
2026-08-07 17:32:53 -07:00
co-authored by Orca
parent a7ffb244e4
commit fddb19f697
814 changed files with 114773 additions and 7496 deletions
+53
View File
@@ -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
+3
View File
@@ -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)
@@ -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)
})
@@ -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<typeof createPreloadSandboxBuiltinGuardPlugin>,
bundle: Record<string, OutputChunk>
): 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')
})
})
@@ -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 = /(?<![\w$.])(?:[\w$]*require|import)\s*\(\s*(['"])([^'"]*)\1\s*\)/g
const NONLITERAL_MODULE_CALL_RE =
/(?<![\w$.])(?:[\w$]*require|import)\s*\((?!\s*(['"])[^'"]*\1\s*\))/g
// Anchored at statement position so a quoted `from` inside bundled data can't match.
const STATIC_MODULE_SOURCE_RE =
/(?:^|[;}])\s*(?:import|export)\s+(?:[^'";()]*?\bfrom\s*)?(['"])([^'"]*)\1/gm
export type PreloadSandboxViolationKind =
| 'node-builtin'
| 'unsupported-external'
| 'helper-chunk'
| 'nonliteral-module-request'
export type PreloadSandboxViolation = Readonly<{
kind: PreloadSandboxViolationKind
/** The module specifier, or the call source for a non-literal request. */
detail: string
}>
const VIOLATION_LABELS: Record<PreloadSandboxViolationKind, string> = {
'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<string, PreloadSandboxViolation>,
violation: PreloadSandboxViolation | null
): void {
if (violation) {
violations.set(`${violation.kind}:${violation.detail}`, violation)
}
}
export function findPreloadSandboxViolations(code: string): PreloadSandboxViolation[] {
const violations = new Map<string, PreloadSandboxViolation>()
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<PreloadSandboxViolationKind, string[]>()
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<string, PreloadSandboxViolation>()
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(', ')}`
)
}
}
}
}
+108 -1
View File
@@ -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",
@@ -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',
@@ -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)
+1
View File
@@ -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',
@@ -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.
@@ -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.
@@ -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:<authorityHostId>`), 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).
+20 -2
View File
@@ -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()]
}
}
},
+1
View File
@@ -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",
@@ -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()
})
})
@@ -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<string | undefined> {
export async function readLinuxPidNamespace(pid: number | 'self'): Promise<string | undefined> {
try {
const namespace = await readlink(`/proc/${pid}/ns/pid`)
return namespace.trim() || undefined
@@ -37,6 +37,29 @@ async function readLinuxPidNamespace(pid: number): Promise<string | undefined> {
}
}
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<string | undefined> {
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<string | undefined> {
try {
const stats = await lstat(path)
@@ -112,6 +135,9 @@ async function readHostIdentity(): Promise<string> {
return runtimeHostIdentity
}
}
if (process.platform === 'win32') {
return (await readWindowsHostIdentity()) ?? runtimeHostIdentity
}
return runtimeHostIdentity
}
+117 -2
View File
@@ -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<void> {
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 () => {
+110 -22
View File
@@ -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<typeof setTimeout>
}
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<void> | null = null
private connectionAttemptGeneration = 0
private daemonIdentity: DaemonEndpointIdentity | null = null
private negotiatedCapabilities: DaemonHelloCapabilities = {}
private observedAuthenticatedDisconnect = false
private pendingRequests = new Map<string, PendingRequest>()
@@ -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<void> {
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<DaemonEndpointIdentity | null> {
timeoutMs: number,
requestedCapabilities: DaemonHelloCapabilities = this.requestedHelloCapabilities()
): Promise<AuthenticatedDaemonHello> {
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<typeof setTimeout> | 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) {
@@ -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)
@@ -32,6 +32,7 @@ export const BACKGROUND_STREAM_DROP_ENABLED = process.env.ORCA_DAEMON_BACKGROUND
export class BackgroundTransientFactRelay {
private trackersBySessionId = new Map<string, TerminalTitleTracker>()
private factEmittersBySessionId = new Map<string, (fact: DaemonTransientFact) => void>()
// Why: shadow foreground bytes so a provisional subscribe survives either scan-authority handoff.
private mode2031ReplyScanStateBySessionId = new Map<string, Mode2031ReplyScanState>()
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)
}
}
@@ -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: {
+14 -2
View File
@@ -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<void> {
// 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<void> => {
const shutdown = async (reason: string, exitCode = 0): Promise<void> => {
// SIGTERM and SIGINT can both fire; guard against a double daemon.shutdown().
if (shuttingDown) {
return
@@ -193,7 +196,7 @@ async function main(): Promise<void> {
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<void> {
})
: 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<void> {
}
: {}),
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(),
@@ -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)
})
})
+152
View File
@@ -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<DaemonHelloCapabilities> = 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])
)
)
}
+13
View File
@@ -248,6 +248,8 @@ type MockAdapter = {
shutdown: ReturnType<typeof vi.fn>
dispose: ReturnType<typeof vi.fn>
disconnectOnly: ReturnType<typeof vi.fn>
terminalAuthorityAppHostTransport: ReturnType<typeof vi.fn>
installTerminalAuthorityAppAdmission: ReturnType<typeof vi.fn>
onData: ReturnType<typeof vi.fn>
onExit: ReturnType<typeof vi.fn>
// 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<typeof vi.fn>
readonly dispose: ReturnType<typeof vi.fn>
readonly disconnectOnly: ReturnType<typeof vi.fn>
readonly terminalAuthorityAppHostTransport: ReturnType<typeof vi.fn>
readonly installTerminalAuthorityAppAdmission: ReturnType<typeof vi.fn>
readonly onData: ReturnType<typeof vi.fn>
readonly onExit: ReturnType<typeof vi.fn>
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
+33
View File
@@ -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<string, unknown> = {}): 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<void> {
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<void> {
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<RestartDaemonResult> {
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<RestartDaemonResult> {
// 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 =
+22 -1
View File
@@ -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<DaemonHandl
...(opts.log ? { log: opts.log } : {}),
...(opts.onIdleShutdown ? { onIdleShutdown: opts.onIdleShutdown } : {}),
...(opts.onRpcShutdown ? { onRpcShutdown: opts.onRpcShutdown } : {}),
...(opts.onTerminalSessionAuthorityFailure
? { onTerminalSessionAuthorityFailure: opts.onTerminalSessionAuthorityFailure }
: {}),
...(opts.initialAdoptionTestConfig
? { initialAdoptionTestConfig: opts.initialAdoptionTestConfig }
: {}),
...(opts.terminalSessionAuthorityRuntime
? {
terminalSessionAuthority: {
ptyOwner: opts.terminalSessionAuthorityRuntime.ptyOwner,
authorityHostId: opts.terminalSessionAuthorityRuntime.authorityHostId
},
terminalSessionAuthorityCapabilityReadiness: {
hostEffectConsumerInstalled: () =>
opts.terminalSessionAuthorityRuntime!.ptyOwner.hostEffectConsumerInstalled()
}
}
: {})
})
await server.start()
return {
shutdown: () => server.shutdown()
shutdown: async () => {
await server.shutdown()
await opts.terminalSessionAuthorityRuntime?.close()
}
}
}
@@ -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.
+23 -3
View File
@@ -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
}
@@ -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<string, unknown>
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
}
@@ -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<typeof vi.spyOn>
notify: ReturnType<typeof vi.spyOn>
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
}
}
}
@@ -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()
})
})
+213 -7
View File
@@ -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()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,25 @@
import type { TerminalSessionAuthorityPtyAccess } from '../../shared/terminal-session-authority-pty-access'
type DaemonPtyRequest<TType extends string, TPayload extends object> = {
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>
@@ -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<void> {
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' }])
})
})
@@ -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
@@ -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<boolean> {
return (
(await this.exactProviderFor(id)?.acquireExactHeldProducerPause?.(
id,
incarnationId,
token
)) ?? false
)
}
async releaseExactHeldProducerPause(
id: string,
incarnationId: string,
token: PtyHeldProducerPauseToken
): Promise<boolean> {
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<boolean> {
return (await this.exactProviderFor(id)?.killExact?.(id, incarnationId, opts)) ?? false
}
async killAuthorityExact(
id: string,
authorityAccess: TerminalSessionAuthorityPtyAccess,
opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number }
): Promise<boolean> {
return (
(await this.exactProviderFor(id)?.killAuthorityExact?.(id, authorityAccess, opts)) ?? false
)
}
async sendSignalExact(id: string, incarnationId: string, signal: string): Promise<boolean> {
return (await this.exactProviderFor(id)?.sendSignalExact?.(id, incarnationId, signal)) ?? false
}
async sendSignalAuthorityExact(
id: string,
authorityAccess: TerminalSessionAuthorityPtyAccess,
signal: string
): Promise<boolean> {
return (
(await this.exactProviderFor(id)?.sendSignalAuthorityExact?.(id, authorityAccess, signal)) ??
false
)
}
async clearBufferExact(id: string, incarnationId: string): Promise<boolean> {
return (await this.exactProviderFor(id)?.clearBufferExact?.(id, incarnationId)) ?? false
}
async clearBufferAuthorityExact(
id: string,
authorityAccess: TerminalSessionAuthorityPtyAccess
): Promise<boolean> {
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
}
}
}
@@ -0,0 +1,70 @@
import type { IPtyProvider, PtyProcessInfo } from '../providers/types'
export type DaemonPtyProviderInventory<TProvider extends IPtyProvider> = Readonly<{
provider: TProvider
processes: readonly PtyProcessInfo[]
}>
type DaemonPtyInventoryRouting<TProvider extends IPtyProvider> = Readonly<{
existingRouteIds: Iterable<string>
recordRoute: (id: string, provider: TProvider, process: PtyProcessInfo) => void
forgetRoute: (id: string) => void
}>
export function reconcileDaemonPtyProviderInventory<TProvider extends IPtyProvider>(
inventories: readonly DaemonPtyProviderInventory<TProvider>[],
routing: DaemonPtyInventoryRouting<TProvider>
): PtyProcessInfo[] {
const candidatesById = new Map<string, { provider: TProvider; process: PtyProcessInfo }[]>()
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
}
}
+68
View File
@@ -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'])
+18 -3
View File
@@ -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<string, DaemonPtyAdapter>()
@@ -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<string> {
@@ -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) ??
@@ -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()
})
})
@@ -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<string, PendingStreamBinding>()
private readonly currentBySessionId = new Map<string, BoundPtyStreamSource>()
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
)
}
@@ -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<unknown>
}
type DaemonServerAuthorityState = DaemonServerRouter & {
releaseAuthenticatedPolicyTransport(client: unknown): void
clients: Map<string, unknown>
}
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<void> => {
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<typeof createTerminalAuthorityConsumerProof>
} {
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) }
}
File diff suppressed because it is too large Load Diff
@@ -38,6 +38,50 @@ function nonSentinelWrites(streamSocket: { write: ReturnType<typeof vi.fn> }): 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<string, number> }
>
}
).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 {
+82 -176
View File
@@ -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<string, PendingStreamDataBatch>()
private pendingSettlementsByClient = new Map<string, Set<(error: Error | null) => 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<void> {
const client = this.getClient(clientId)
if (!client?.streamSocket || client.streamSocket.destroyed) {
return Promise.reject(new Error('daemon stream consumer is unavailable'))
}
return new Promise<void>((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<string>()
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<string>()
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)
}
}
}
+150
View File
@@ -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<string, PendingStreamDataBatch>
refillArmedClients: Set<string>
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<string>()
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)))
}
}
@@ -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<typeof vi.fn>; writableLength: number } {
const controlSocket = { destroy: vi.fn() } as unknown as Socket
const streamSocket = {
@@ -94,11 +125,41 @@ function addClient(
write: ReturnType<typeof vi.fn>
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
}
}
}
+31
View File
@@ -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
@@ -24,6 +24,7 @@ export type StreamQueueEntry = {
seq?: number
transformed?: boolean
control?: DaemonEvent
settle?: (error: Error | null) => void
}
export type PendingStreamDataBatch = {
@@ -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 }
}
@@ -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<TerminalAuthorityNamespaceOutcomeBoundary>()
const releaseBoundary = deferred<void>()
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<void>()
const releaseClaim = deferred<void>()
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<void>()
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<AuthorityHarness> {
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<typeof createTerminalAuthorityProofEphemeralKeypair>,
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<T>(pending: Promise<T>, timeoutMs: number, detail: () => string): Promise<T> {
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<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((settle) => {
resolve = settle
})
return { promise, resolve }
}
@@ -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<TerminalAuthorityAppAdmissionIntentRequiredError>)
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<string, ReturnType<typeof admission>>([
[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<unknown>,
private readonly retirementSupported = true
) {}
async ensureConnected(): Promise<void> {}
terminalSessionAuthorityConsumerProofHostId(): string {
return HOST_ID
}
terminalSessionAuthorityConsumerRetirementSupported(): boolean {
return this.retirementSupported
}
async request<T>(type: string, payload: unknown): Promise<T> {
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
})
}
@@ -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<void>
terminalSessionAuthorityConsumerProofHostId(): string | null
terminalSessionAuthorityConsumerRetirementSupported?(): boolean
request<T>(type: string, payload: unknown, timeoutMs?: number): Promise<T>
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<TerminalAuthorityAppOutcomeHostConnection> {
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<string, DaemonTerminalAuthorityAppNamespaceConnection>()
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<TerminalAuthorityAppOutcomeNamespaceConnection> {
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<TerminalAuthorityNamespace> {
this.assertActive()
const value = await this.options.client.request<unknown>(
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<TerminalAuthorityNamespaceAdmissionChallenge> {
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])
}
@@ -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<TerminalAuthorityConsumerRetirementResult>
isCurrent: () => boolean
remove: () => void
}>
export class DaemonTerminalAuthorityAppNamespaceConnection implements TerminalAuthorityAppOutcomeNamespaceConnection {
readonly expectedConsumer
private readonly buffered: DaemonTerminalAuthorityAppNamespaceEvent[] = []
private installedGrant: TerminalAuthorityNamespaceAdmissionGrant | null = null
private deliveryTail: Promise<void> = 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<void> {
this.assertActive()
let unsafeGrant: unknown
try {
unsafeGrant = await this.options.client.request<unknown>(
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<void> {
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<void> {
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<number> {
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<TerminalAuthorityConsumerRetirementResult> {
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<void> {
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
}
@@ -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
}
@@ -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
}>
@@ -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
@@ -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<DaemonServerOptions['terminalSessionAuthority']> {
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<HelloResponse> {
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<string, unknown> {
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<HelloResponse> {
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<Socket> {
return new Promise((resolve, reject) => {
const socket = connect(socketPath)
socket.once('connect', () => resolve(socket))
socket.once('error', reject)
})
}
function readHelloResponse(socket: Socket): Promise<HelloResponse> {
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)
})
}
@@ -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' }])
})
})
@@ -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')
@@ -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<boolean>
}) {
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<boolean | null> {
return await this.ownerRecovery.probe(id)
}
probePtyLiveness = (id: string): Promise<boolean | null> => 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<PtyProcessInfo[]> {
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<string> {
@@ -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)
}
@@ -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<typeof createSubprocess>
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)
})
})
+97 -6
View File
@@ -126,7 +126,10 @@ export class Session {
private pendingOutputSeq = 0
private outputSequence = 0
private producerPaused = false
private legacyProducerPauseActive = false
private producerPauseFailsafeTimer: ReturnType<typeof setTimeout> | null = null
private heldProducerPauseCount = 0
private heldProducerPauseTokensByOwner = new Map<string, Set<string>>()
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<string>()
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 {
@@ -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 }
}
@@ -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
}
}
@@ -0,0 +1,25 @@
export class TerminalHostAuthorityRequestQueue {
private readonly queues = new Map<string, Promise<void>>()
enqueue<T>(sessionId: string, request: () => Promise<T>): Promise<T> {
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<void> | null {
return this.queues.size > 0
? Promise.allSettled(this.queues.values()).then(() => undefined)
: null
}
}
@@ -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<string, Session>,
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<boolean> {
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
}
}
}
@@ -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'
)
})
})
@@ -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<TerminalHostOptions['terminalSessionAuthority']>
export type TerminalHostAuthoritySessionDependencies = Readonly<{
sessions: ReadonlyMap<string, Session>
createPhysical(
options: InternalCreateOrAttachOptions,
creationHooks?: TerminalHostSessionCreationHooks
): Promise<CreateOrAttachResult>
releaseExited(sessionId: string, generation: string | undefined): void
fenceCreation(): void
}>
export class TerminalHostAuthoritySessions {
private readonly ptyOwner: AuthorityOptions['ptyOwner'] | null
private readonly accessBySession = new Map<string, TerminalSessionAuthorityPtyAccess>()
private readonly exitFinalizations = new Set<Promise<void>>()
private readonly pendingSessions = new Set<Session>()
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<T>(sessionId: string, request: () => Promise<T>): Promise<T> {
return this.requestQueue.enqueue(sessionId, request)
}
async createOrAttach(options: InternalCreateOrAttachOptions): Promise<CreateOrAttachResult> {
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<boolean> {
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<boolean> {
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<void> | null {
return this.requestQueue.settlement()
}
unpublishedSessions(): readonly Session[] {
return [...this.pendingSessions].filter((session) => !this.deps.sessions.has(session.sessionId))
}
async finishDispose(): Promise<void> {
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<CreateOrAttachResult> {
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<CreateOrAttachResult> {
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<CreateOrAttachResult> {
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<void> {
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)
}
}
@@ -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<string, TerminalSessionAuthorityPtyAccess>
pendingSessions: Set<Session>
fail: (error: unknown) => void
}): Promise<CreateOrAttachResult> {
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
)
}
@@ -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<string | null> {
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()
}
}
@@ -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<void>
}
/** 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
)
}
+26
View File
@@ -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<string, Session>
authoritySessions: TerminalHostAuthoritySessions
killedTombstones: TerminalHostTombstones
onFinalCheckpoint: TerminalHostOptions['onFinalCheckpoint']
}): Promise<void> {
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()
}
@@ -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()
}
})
})
+6
View File
@@ -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
}
@@ -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<string, Session>,
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<void> {
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<boolean> {
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<boolean> {
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<void> {
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<void> {
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)
}
}
+93 -64
View File
@@ -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<TerminalHostOptions['spawnSubprocess']>
) => ReturnType<TerminalHostOptions['spawnSubprocess']>
onSpawnFailure: (error: unknown) => Promise<void>
beforePublish: (session: Session) => Promise<TerminalSessionAuthorityPtyAccess>
afterPublish: (session: Session) => void
onPostDispatchFailure: (error: unknown) => void
}>
type TerminalHostSessionCreateDependencies = {
sessions: Map<string, Session>
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<TerminalHostOptions['spawnSubprocess']>
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
}
}
@@ -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<string, Session>,
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,
@@ -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<string, Session>,
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<string | null> {
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<string | null> {
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)
)
}
}
+215 -111
View File
@@ -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<void> | 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<CreateOrAttachResult> {
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<CreateOrAttachResult> {
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<boolean> {
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<void> {
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<boolean> {
return await this.mutations.killExact(sessionId, incarnationId, opts)
}
async killAuthorityExact(
sessionId: string,
access: TerminalSessionAuthorityPtyAccess,
policyConsumer: TerminalAuthorityPolicyConsumerConnection,
opts: { immediate?: boolean } = {}
): Promise<boolean> {
return await this.mutations.killAuthorityExact(sessionId, access, policyConsumer, opts)
}
ensureAuthorityBindingRetired(
access: TerminalSessionAuthorityPtyAccess,
reason: TerminalAuthorityBindingRetiredEffect['reason']
): Promise<void> {
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<string | null> {
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<string | null> {
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<void> {
@@ -235,15 +343,11 @@ export class TerminalHost {
}
private async disposeSessions(): Promise<void> {
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
})
}
}
+26 -34
View File
@@ -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
@@ -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<typeof NodeFs>('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 })
}
})
+1 -32
View File
@@ -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<void> {
}
}
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))
}
+14 -10
View File
@@ -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)
+11
View File
@@ -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,
@@ -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<string, (event: { sender: object }, value: unknown) => 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
}
}
+292
View File
@@ -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<TerminalAuthorityAppProjectionStore>
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<TerminalAuthorityConsumerRetirementResult>
}>
let runtimeSnapshot: AppProjectionRuntime | null = null
let runtimeReady: Promise<AppProjectionRuntime> | null = null
type PendingHostInstallation = {
slot: TerminalAuthorityAppOutcomeHostTransportSlot
registration: TerminalAuthorityAppOutcomeHostRegistration | null
ready: Promise<TerminalAuthorityAppOutcomeHostRegistration>
}
const hostInstallations = new Map<string, PendingHostInstallation>()
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<AppProjectionRuntime> {
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')
}
}
@@ -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
}
}
@@ -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
}
}
@@ -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<void> = 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<TerminalAuthorityAppProjectionSnapshot> {
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<T>(operation: () => T): Promise<T> {
const result = this.operationQueue.then(operation)
this.operationQueue = result.then(
() => undefined,
() => undefined
)
return result
}
}
+253 -15
View File
@@ -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<typeof vi.fn>
shutdown: ReturnType<typeof vi.fn>
killExact: ReturnType<typeof vi.fn>
killAuthorityExact: ReturnType<typeof vi.fn>
getPtyMutationRouteToken: ReturnType<typeof vi.fn>
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<void>
shutdownImpl?: (id: string, opts: { immediate?: boolean }) => Promise<void>
): 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<unknown>
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()
+91 -22
View File
@@ -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<DaemonSessionInfo[]> {
type DaemonSessionInventory = Readonly<{
sessions: DaemonSessionInfo[]
failedProtocolVersions: number[]
}>
async function collectSessionInventory(
adapters: DaemonPtyAdapter[]
): Promise<DaemonSessionInventory> {
const results = await Promise.allSettled(
adapters.map(async (adapter) => {
const sessions = await adapter.listSessions()
@@ -48,7 +61,56 @@ async function collectSessions(adapters: DaemonPtyAdapter[]): Promise<DaemonSess
}))
})
)
return results.flatMap((r) => (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<DaemonSessionInfo[]> {
return (await collectSessionInventory(adapters)).sessions
}
async function collectSessionsExact(adapters: DaemonPtyAdapter[]): Promise<DaemonSessionInfo[]> {
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<boolean> {
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 }
}
@@ -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)])
})
})
+299
View File
@@ -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<string, PtyRendererBindingFence>()
private readonly latestClaims = new Map<string, PtyRendererBindingClaim>()
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))
}
}
}
@@ -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)
})
})
@@ -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<boolean>
releaseExactHeldProducerPause: (
id: string,
incarnationId: PtyIncarnationId,
token: string
) => Promise<boolean>
}
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<ExactHeldPauseProvider>
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<string, Set<PtyRendererBindingHeldPauseLease>>()
private readonly nextSerialByProvider = new WeakMap<IPtyProvider, number>()
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<PtyRendererBindingHeldPauseLease | null> {
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<boolean> {
return await this.releaseProviderLease(lease)
}
async release(id: string): Promise<boolean> {
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<boolean> {
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<boolean> {
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
}
}
}
@@ -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()
})
})
@@ -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<string, PendingSideEffectRelease>()
private readonly drainingReleases = new Set<string>()
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)
}
}
}

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