diff --git a/config/scripts/package-electron-runtime-contract.test.mjs b/config/scripts/package-electron-runtime-contract.test.mjs index 31d9b7a8d57..b5874a47c03 100644 --- a/config/scripts/package-electron-runtime-contract.test.mjs +++ b/config/scripts/package-electron-runtime-contract.test.mjs @@ -25,7 +25,14 @@ describe('Electron runtime package contract', () => { } it('keeps root postinstall as the single Electron binary install owner', () => { - expect(packageJson.scripts.postinstall).toBe('node config/scripts/rebuild-native-deps.mjs') + // Why not an exact match: the invariant is that the root postinstall owns the Electron + // binary install, not that nothing else may run after it. Pinning the whole string made + // any unrelated chained step (a lint-plugin sync, say) a CI failure for every open PR. + const postinstall = packageJson.scripts.postinstall + const steps = postinstall.split('&&').map((step) => step.trim()) + expect(steps[0]).toBe('node config/scripts/rebuild-native-deps.mjs') + // No later step may take over the Electron install the first step owns. + expect(steps.slice(1).join(' ')).not.toMatch(/electron/i) expect(pnpmWorkspace.allowBuilds).not.toHaveProperty('electron') }) diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index a4191395e41..8a4e9697938 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 56m + + downloads: 58m @@ -15,7 +15,7 @@ downloads downloads - 56m - 56m + 58m + 58m diff --git a/package.json b/package.json index 96d8e00b97f..9d37cb13cba 100644 --- a/package.json +++ b/package.json @@ -97,7 +97,7 @@ "build": "pnpm run build:desktop && pnpm run build:native", "build:release": "pnpm run build:relay && pnpm run build:native && pnpm run verify:computer-native && pnpm run build:cli && pnpm run build:electron-vite && pnpm run verify:built-skills-cli && pnpm run build:web-from-renderer", "build:release:parallel": "pnpm run build:relay && pnpm run build:native && pnpm run verify:computer-native && pnpm run build:cli && pnpm run build:electron-vite:parallel && pnpm run verify:built-skills-cli && pnpm run build:web-from-renderer", - "postinstall": "node config/scripts/rebuild-native-deps.mjs && node config/scripts/sync-anti-slop-plugin.mjs", + "postinstall": "node config/scripts/rebuild-native-deps.mjs", "rebuild:electron": "node config/scripts/rebuild-native-deps.mjs", "reclaim:electron-dists": "node config/scripts/reclaim-electron-dists.mjs", "reclaim:dev-bundles": "node config/scripts/reclaim-dev-electron-bundles.mjs", diff --git a/src/main/runtime/orca-runtime-bind-pty-incarnation-handle.ts b/src/main/runtime/orca-runtime-bind-pty-incarnation-handle.ts index 3f1a79beb77..6ab9eec8563 100644 --- a/src/main/runtime/orca-runtime-bind-pty-incarnation-handle.ts +++ b/src/main/runtime/orca-runtime-bind-pty-incarnation-handle.ts @@ -54,15 +54,17 @@ export class OrcaRuntimeWithBindPtyIncarnationHandle extends OrcaRuntimeWithBuil for (const [ptyId, retained] of this.handleByPtyIncarnation) { const pty = this.ptysById.get(ptyId) const leaves = this.getLeavesForPty(ptyId) - if ( - !pty || - pty.incarnationId !== retained.incarnationId || - leaves.length !== 1 || - this.handleByPtyId.has(ptyId) - ) { + // Why: a handle issued before the host reported the incarnation is un-fenced, so + // learning it is not a replacement; only a known-to-different incarnation is. + const incarnationReplaced = + retained.incarnationId !== null && + pty !== undefined && + pty.incarnationId !== retained.incarnationId + if (!pty || incarnationReplaced || leaves.length !== 1 || this.handleByPtyId.has(ptyId)) { this.invalidatePtyIncarnationHandle(ptyId) continue } + retained.incarnationId = pty.incarnationId this.bindPtyIncarnationHandle(retained, leaves[0]) } } diff --git a/src/main/runtime/orca-runtime-terminal-handle-incarnation.test.ts b/src/main/runtime/orca-runtime-terminal-handle-incarnation.test.ts index 9765465fdf0..bac59e614b2 100644 --- a/src/main/runtime/orca-runtime-terminal-handle-incarnation.test.ts +++ b/src/main/runtime/orca-runtime-terminal-handle-incarnation.test.ts @@ -107,6 +107,40 @@ describe('runtime terminal handle incarnation fencing', () => { await expect(runtime.readTerminal(handle)).resolves.toMatchObject({ handle, status: 'running' }) }) + it('keeps a listed handle when graph sync learns the incarnation after issue', async () => { + // Daemon-hosted PTYs are recorded from first output before the spawn commit reports an + // incarnation, so the handle is issued un-fenced and must survive learning it. + const { runtime } = makeRuntime() + runtime.registerPty(PTY_ID, WORKTREE_ID, 'target', { tabId: TAB_ID, leafId: LEAF_ID }) + syncGraph(runtime) + const [listed] = (await runtime.listTerminals()).terminals + + register(runtime, 'incarnation-learned') + syncGraph(runtime) + + await expect(runtime.readTerminal(listed.handle)).resolves.toMatchObject({ + handle: listed.handle, + status: 'running' + }) + }) + + it('stales a listed handle when graph sync sees a replaced incarnation', async () => { + const { runtime } = makeRuntime() + register(runtime, 'incarnation-old') + syncGraph(runtime) + const [listed] = (await runtime.listTerminals()).terminals + + // Rotate the record directly so reconcile is the only fence exercised. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: test reaches the runtime's protected pty record map to bypass the registerPty fence. + const internals = runtime as unknown as { + ptysById: Map + } + internals.ptysById.get(PTY_ID)!.incarnationId = 'incarnation-new' + syncGraph(runtime) + + await expect(runtime.readTerminal(listed.handle)).rejects.toThrow('terminal_handle_stale') + }) + it('invalidates a direct handle when a reused PTY id gets a new incarnation', async () => { const { runtime, writes } = makeRuntime() const staleHandle = runtime.preAllocateHandleForPty(PTY_ID) diff --git a/src/main/runtime/structured-worker-identity.test.ts b/src/main/runtime/structured-worker-identity.test.ts index 9b678ebb0eb..2a66536291a 100644 --- a/src/main/runtime/structured-worker-identity.test.ts +++ b/src/main/runtime/structured-worker-identity.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it, beforeEach } from 'vitest' import { isTerminalLeafId, parsePaneKey } from '../../shared/stable-pane-id' -import { structuredAgentSessionPaneKey } from '../../shared/structured-agent-session-projection' +import { + structuredAgentSessionPaneKey, + structuredAgentSessionTabId +} from '../../shared/structured-agent-session-projection' import { selectExactWorkerProviderSession } from './orchestration/worker-provider-session' import { structuredWorkerChildIdentityEnv } from './structured-worker-child-identity-env' import { @@ -85,12 +88,57 @@ describe('structured worker identity', () => { ) }) - it("accepts a persisted pane key for its own session and rejects another session's", () => { + it('accepts only the registered pane key for its session', () => { + const handle = mintStructuredWorkerHandle() const paneKey = mintStructuredWorkerPaneKey(SESSION_ID) - expect(structuredWorkerPaneKeyBelongsToSession(paneKey, SESSION_ID)).toBe(true) - expect(structuredWorkerPaneKeyBelongsToSession(paneKey, 'another-session-id')).toBe(false) - expect(structuredWorkerPaneKeyBelongsToSession('not-a-pane-key', SESSION_ID)).toBe(false) - expect(structuredWorkerPaneKeyBelongsToSession(null, SESSION_ID)).toBe(false) + structuredWorkerIdentities.register({ + handle, + sessionId: SESSION_ID, + agent: 'claude', + paneKey, + processIncarnation: structuredWorkerProcessIncarnation(SESSION_ID), + worktreeId: 'wt_1', + hostScope: { kind: 'local', hostId: 'local' } + }) + try { + expect(structuredWorkerPaneKeyBelongsToSession(paneKey, SESSION_ID)).toBe(true) + expect( + structuredWorkerPaneKeyBelongsToSession(mintStructuredWorkerPaneKey(SESSION_ID), SESSION_ID) + ).toBe(false) + expect(structuredWorkerPaneKeyBelongsToSession(paneKey, 'another-session-id')).toBe(false) + expect(structuredWorkerPaneKeyBelongsToSession('not-a-pane-key', SESSION_ID)).toBe(false) + expect(structuredWorkerPaneKeyBelongsToSession(null, SESSION_ID)).toBe(false) + } finally { + structuredWorkerIdentities.forget(handle) + } + }) + + it('rejects the deterministic public status key even for a registered worker', () => { + const handle = mintStructuredWorkerHandle() + const paneKey = mintStructuredWorkerPaneKey(SESSION_ID) + structuredWorkerIdentities.register({ + handle, + sessionId: SESSION_ID, + agent: 'claude', + paneKey, + processIncarnation: structuredWorkerProcessIncarnation(SESSION_ID), + worktreeId: 'wt_1', + hostScope: { kind: 'local', hostId: 'local' } + }) + try { + const statusPaneKey = structuredAgentSessionPaneKey( + structuredAgentSessionTabId(SESSION_ID), + SESSION_ID + ) + expect(structuredWorkerPaneKeyBelongsToSession(statusPaneKey, SESSION_ID)).toBe(false) + } finally { + structuredWorkerIdentities.forget(handle) + } + }) + + it('fails closed when the session has no registry record', () => { + const paneKey = mintStructuredWorkerPaneKey(SESSION_ID) + expect(structuredWorkerPaneKeyBelongsToSession(paneKey, SESSION_ID)).toBe(false) }) it('derives a pane key whose leaf passes the terminal leaf check', () => { @@ -169,6 +217,21 @@ describe('structured worker identity registry', () => { ).toBeNull() }) + it('refuses to rehydrate the deterministic public status key as a worker credential', () => { + expect( + registry.rehydrate({ + terminal_handle: mintStructuredWorkerHandle(), + pane_key: structuredAgentSessionPaneKey( + structuredAgentSessionTabId(SESSION_ID), + SESSION_ID + ), + process_incarnation: structuredWorkerProcessIncarnation(SESSION_ID), + worktree_id: 'wt_1', + host_scope: JSON.stringify({ kind: 'local', hostId: 'local' }) + }) + ).toBeNull() + }) + it('forgets both indexes', () => { const handle = mintStructuredWorkerHandle() registry.register({ diff --git a/src/main/runtime/structured-worker-identity.ts b/src/main/runtime/structured-worker-identity.ts index 161ae55dd5d..b29d68c297a 100644 --- a/src/main/runtime/structured-worker-identity.ts +++ b/src/main/runtime/structured-worker-identity.ts @@ -20,7 +20,10 @@ import type { AgentSessionRecord } from '../../shared/agent-session-record' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' -import { structuredAgentSessionTabId } from '../../shared/structured-agent-session-projection' +import { + structuredAgentSessionPaneKey, + structuredAgentSessionTabId +} from '../../shared/structured-agent-session-projection' import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../shared/stable-pane-id' import { parseWorkerTerminalHostScope, @@ -67,13 +70,30 @@ export function mintStructuredWorkerPaneKey(sessionId: string): string { return makePaneKey(structuredAgentSessionTabId(sessionId), randomUUID()) } -/** Integrity check for a persisted pane key: same session's tab, and a real terminal leaf. */ +/** Credential check: only the pane key registered for this session can prove its identity. */ export function structuredWorkerPaneKeyBelongsToSession( paneKey: string | null | undefined, sessionId: string ): boolean { + const registered = structuredWorkerIdentities.getBySessionId(sessionId) const parsed = paneKey ? parsePaneKey(paneKey) : null return Boolean( + registered && + registered.paneKey === paneKey && + parsed && + parsed.tabId === structuredAgentSessionTabId(sessionId) + ) +} + +/** Bootstrap validation for a durable row before its key can enter the registry. */ +function persistedStructuredWorkerPaneKeyIsValid( + paneKey: string | null | undefined, + sessionId: string +): paneKey is string { + const parsed = paneKey ? parsePaneKey(paneKey) : null + return Boolean( + paneKey && + paneKey !== structuredAgentSessionPaneKey(structuredAgentSessionTabId(sessionId), sessionId) && parsed && parsed.tabId === structuredAgentSessionTabId(sessionId) && isTerminalLeafId(parsed.leafId) @@ -176,9 +196,8 @@ export class StructuredWorkerIdentityRegistry { !hostScope || !row.worktree_id || !isStructuredWorkerHandle(row.terminal_handle) || - // The leaf is random, so the row IS the only source for it; verify only that it is a real - // leaf under this session's tab rather than trying to re-derive it. - !structuredWorkerPaneKeyBelongsToSession(row.pane_key, sessionId) + // The durable row bootstraps the registry after restart, so validate it before registration. + !persistedStructuredWorkerPaneKeyIsValid(row.pane_key, sessionId) ) { return null } @@ -187,7 +206,7 @@ export class StructuredWorkerIdentityRegistry { sessionId, // The row does not carry the provider; callers that need it read the durable record. agent: null, - paneKey: row.pane_key as string, + paneKey: row.pane_key, processIncarnation: structuredWorkerProcessIncarnation(sessionId), worktreeId: row.worktree_id, hostScope