From ee2ef2b6f4f6b8798a5e1e01e50e7a0f3caab3f7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 15 Sep 2026 22:05:05 -0700 Subject: [PATCH] fix(agent-status): admit verified process discoveries --- .../server-ingest-terminal-status.test.ts | 19 ++ .../agent-hooks/server/server-lifecycle.ts | 6 +- src/main/agent-hooks/server/server-state.ts | 2 +- .../server/server-status-binding.ts | 10 +- .../server/server-status-inference.ts | 6 + .../server/server-status-retries.ts | 2 +- .../server/server-status-update.ts | 8 +- src/main/orcad/orcad-entry.ts | 4 + src/main/providers/pty-process-info.ts | 3 + .../pty-process-list-admission.test.ts | 58 ++++ .../providers/pty-process-list-admission.ts | 15 + .../runtime-agent-discovery-admission.ts | 29 ++ ...t-session-inventory-reconciliation.test.ts | 2 +- ...-agent-session-inventory-reconciliation.ts | 5 +- .../runtime/runtime-terminal-contracts.ts | 3 + .../startup/main-process-runtime-service.ts | 7 + .../agent-status-discovery-validation.ts | 143 ++++++++++ .../agent-status-verified-discovery.test.ts | 208 ++++++++++++++ src/shared/agent-status-verified-discovery.ts | 256 ++++++++++++++++++ 19 files changed, 772 insertions(+), 14 deletions(-) create mode 100644 src/main/runtime/runtime-agent-discovery-admission.ts create mode 100644 src/shared/agent-status-discovery-validation.ts create mode 100644 src/shared/agent-status-verified-discovery.test.ts create mode 100644 src/shared/agent-status-verified-discovery.ts diff --git a/src/main/agent-hooks/server-ingest-terminal-status.test.ts b/src/main/agent-hooks/server-ingest-terminal-status.test.ts index 6687086f04c..b76bc8658d7 100644 --- a/src/main/agent-hooks/server-ingest-terminal-status.test.ts +++ b/src/main/agent-hooks/server-ingest-terminal-status.test.ts @@ -119,6 +119,25 @@ describe('AgentHookServer ingestTerminalStatus', () => { }) }) + it('suppresses an inherited child claim before a root row exists', () => { + const server = new AgentHookServer() + server.setExecutionBindingResolver(() => null) + + server.ingestRemote( + { + paneKey: PANE, + source: 'codex', + worktreeId: 'repo::/tmp/worktree', + emitterRole: 'child', + reportedExecutionBinding: { runId: 'run-inherited', executionId: 'execution-root' }, + payload: { state: 'working', prompt: 'inherited child', agentType: 'codex' } + }, + 'conn-1' + ) + + expect(server.getStatusSnapshot()).toEqual([]) + }) + it('keeps hook monitoring mode across an equivalent OSC ping until a hook clears it', () => { const server = new AgentHookServer() diff --git a/src/main/agent-hooks/server/server-lifecycle.ts b/src/main/agent-hooks/server/server-lifecycle.ts index e7f68829f3b..7b0fcdeb297 100644 --- a/src/main/agent-hooks/server/server-lifecycle.ts +++ b/src/main/agent-hooks/server/server-lifecycle.ts @@ -116,8 +116,10 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv } this.recordCurrentAuthorityObservation(event) const enriched = this.applyNormalizedStatus(event, normalized.onAccepted) - this.scheduleAssistantMessageRetry(source, aliasedBody, enriched) - this.scheduleCodexSubagentPoll(source, aliasedBody, enriched) + if (enriched) { + this.scheduleAssistantMessageRetry(source, aliasedBody, enriched) + this.scheduleCodexSubagentPoll(source, aliasedBody, enriched) + } } res.writeHead(204) res.end() diff --git a/src/main/agent-hooks/server/server-state.ts b/src/main/agent-hooks/server/server-state.ts index 1e439008250..957f1c43c67 100644 --- a/src/main/agent-hooks/server/server-state.ts +++ b/src/main/agent-hooks/server/server-state.ts @@ -171,7 +171,7 @@ export abstract class AgentHookServerState { origin?: AgentStatusObservationOrigin, observedAt?: number, mutationBefore?: EnrichedAgentHookEventPayload - ): EnrichedAgentHookEventPayload + ): EnrichedAgentHookEventPayload | null protected abstract emitEnrichedStatus(enriched: EnrichedAgentHookEventPayload): void protected abstract clearAssistantMessageRetry(paneKey: string): void protected abstract clearCodexSubagentPoll(paneKey: string): void diff --git a/src/main/agent-hooks/server/server-status-binding.ts b/src/main/agent-hooks/server/server-status-binding.ts index fa9273f4f71..f0feba75fd8 100644 --- a/src/main/agent-hooks/server/server-status-binding.ts +++ b/src/main/agent-hooks/server/server-status-binding.ts @@ -41,12 +41,12 @@ export function resolveAgentStatusBinding(args: { reported }) : null + if (reported && args.payload.emitterRole === 'child' && !resolved) { + // An inherited root claim is not child identity. Without a verified child-work + // admission, suppress even a first child event so it cannot create a pane fallback. + return { payload, previous, suppress: true, replacement: false } + } if (reported && !resolved && previous?.runId && previous.executionId) { - if (args.payload.emitterRole === 'child') { - // A nested emitter may inherit the root env claim. It must not mutate the - // root row; a verified child-work admission owns that progress instead. - return { payload, previous, suppress: true, replacement: false } - } // A delayed prior owner or inherited claim cannot rewrite the confirmed subject. return { payload, previous, suppress: true, replacement: false } } diff --git a/src/main/agent-hooks/server/server-status-inference.ts b/src/main/agent-hooks/server/server-status-inference.ts index 49575fe223e..3f57e8d87d2 100644 --- a/src/main/agent-hooks/server/server-status-inference.ts +++ b/src/main/agent-hooks/server/server-status-inference.ts @@ -111,6 +111,9 @@ export abstract class AgentHookServerStatusInference extends AgentHookServerRowO ...(payload.subagents ? { subagents: payload.subagents } : {}) } }) + if (!inferred) { + return false + } console.debug('[agent-hooks] inferred interrupted agent status', { paneKey: inferred.paneKey, agentType, @@ -172,6 +175,9 @@ export abstract class AgentHookServerStatusInference extends AgentHookServerRowO ...(payload.subagents ? { subagents: payload.subagents } : {}) } }) + if (!inferred) { + return false + } console.debug('[agent-hooks] inferred resolved question status', { paneKey: inferred.paneKey, state: inferred.payload.state diff --git a/src/main/agent-hooks/server/server-status-retries.ts b/src/main/agent-hooks/server/server-status-retries.ts index 2757806b293..f439a3d1906 100644 --- a/src/main/agent-hooks/server/server-status-retries.ts +++ b/src/main/agent-hooks/server/server-status-retries.ts @@ -76,7 +76,7 @@ export abstract class AgentHookServerStatusRetries extends AgentHookServerStatus } const subagentsChanged = JSON.stringify(normalized.payload.subagents) !== JSON.stringify(original.payload.subagents) - const next = subagentsChanged ? this.applyNormalizedStatus(normalized) : original + const next = subagentsChanged ? (this.applyNormalizedStatus(normalized) ?? original) : original this.scheduleCodexSubagentPoll(source, body, next) } diff --git a/src/main/agent-hooks/server/server-status-update.ts b/src/main/agent-hooks/server/server-status-update.ts index 1e352583889..ddb1b09f95a 100644 --- a/src/main/agent-hooks/server/server-status-update.ts +++ b/src/main/agent-hooks/server/server-status-update.ts @@ -35,14 +35,16 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA origin: AgentStatusObservationOrigin = 'hook', observedAt?: number, mutationBefore?: EnrichedAgentHookEventPayload - ): EnrichedAgentHookEventPayload { + ): EnrichedAgentHookEventPayload | null { const binding = resolveAgentStatusBinding({ payload, previousCandidate: this.state.lastStatusByPaneKey.get(payload.paneKey), resolver: this.executionBindingResolver }) - if (binding.suppress && binding.previous) { - return binding.previous + if (binding.suppress) { + // A suppressed first event has no row to return; callers must not schedule + // retries or project a synthetic status for an unadmitted child. + return binding.previous ?? null } payload = binding.payload const previousBeforeIdentity = binding.previous diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index 3a03febcd75..fb9e64e101f 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -26,6 +26,7 @@ import { import { acquireOrcadInstanceLock, OrcadInstanceLockError } from './orcad-instance-lock' import { startOrcadWithLifecycle } from './orcad-lifecycle' import { parseArgs } from './orcad-command-arguments' +import { admitLocalVerifiedAgentDiscoveries } from '../runtime/runtime-agent-discovery-admission' export { parseArgs } @@ -245,6 +246,9 @@ async function startOrcadRuntime( ? { connectionId: reconciliation.connectionId } : {}) }) + if (reconciliation.connectionId === null) { + admitLocalVerifiedAgentDiscoveries(reconciliation.discoveries) + } }, // Why here too and not only on the desktop: orcad serves `worktree.ps` and `agentSession.*`, // so without these a headless host publishes its structured chats nowhere and lists no agents. diff --git a/src/main/providers/pty-process-info.ts b/src/main/providers/pty-process-info.ts index 942cab84f73..6f49ba5784e 100644 --- a/src/main/providers/pty-process-info.ts +++ b/src/main/providers/pty-process-info.ts @@ -1,6 +1,7 @@ import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-authority' import type { PtyIncarnationId } from '../../shared/pty-incarnation' import type { ForegroundProcessEvidence } from '../../shared/foreground-process-evidence' +import type { VerifiedAgentDiscovery } from '../../shared/agent-status-verified-discovery' export type PtyProcessInfo = { id: string @@ -17,6 +18,8 @@ export type PtyProcessInfo = { wslDistro?: string | null /** Optional host-side process evidence attached to an inventory seed. */ foregroundProcessEvidence?: ForegroundProcessEvidence + /** Host-verified manual/adopted agent identity; cwd/title/token are never sufficient. */ + verifiedAgentDiscovery?: VerifiedAgentDiscovery agentSessionOwners?: AgentSessionOwnerBinding[] /** Age measured on the OWNING host's clock. Absent means the host did not measure it, which is * not the same as "new" or "old" — a reader that needs an age must defer instead of assuming. */ diff --git a/src/main/providers/pty-process-list-admission.test.ts b/src/main/providers/pty-process-list-admission.test.ts index 8b56ba26b1f..2ee5778cbec 100644 --- a/src/main/providers/pty-process-list-admission.test.ts +++ b/src/main/providers/pty-process-list-admission.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it, vi } from 'vitest' +import { createEphemeralAgentSessionClaimSigner } from '../runtime/agent-session-claim-identity' +import type { VerifiedAgentDiscovery } from '../../shared/agent-status-verified-discovery' import { MAX_AGGREGATED_PTY_PROCESS_LIST_BYTES, MAX_AGGREGATED_PTY_PROCESS_LIST_ENTRIES, @@ -29,6 +31,62 @@ describe('PtyProcessListAdmission', () => { expect(admitted.foregroundProcessEvidence).not.toBe(evidence) }) + it('preserves and clones only a host-verified discovery contract', () => { + const signer = createEphemeralAgentSessionClaimSigner('pty-list-admission-test') + const verified: VerifiedAgentDiscovery = { + claim: signer.createFreshClaim({ + namespace: { + machine: 'native:darwin', + principal: 'uid:1', + container: 'native', + providerRoot: 'profile-default:codex' + }, + agent: 'codex', + launchIdentity: 'manual-1', + canonicalWorktreeId: 'repo::/tmp/worktree' + }), + surface: { + worktreeId: 'repo::/tmp/worktree', + tabId: 'tab-1', + leafId: '11111111-1111-4111-8111-111111111111', + terminalHandle: `term_${'a'.repeat(32)}` + }, + evidence: { + verdict: 'live', + processName: 'codex', + authorityGeneration: 'host-generation-1', + observationEpoch: 1, + capturedAgeMs: 0, + ptyId: 'pty-1', + ptyIncarnationId: '22222222-2222-4222-8222-222222222222', + fence: { + platform: 'posix', + shellPid: 100, + shellStartTime: 'shell-start-1', + tty: '/dev/ttys001', + foregroundPgid: 200, + process: { pid: 200, startTime: 'agent-start-1' } + } + }, + providerIdentity: { agent: 'codex', source: 'process' }, + ancestry: { + parent: { pid: 100, startTime: 'shell-start-1' }, + chain: [{ pid: 100, startTime: 'shell-start-1' }], + relation: 'direct-child' + }, + process: { pid: 200, startTime: 'agent-start-1', parentPid: 100 } + } + const admitted = new PtyProcessListAdmission().admit({ + id: 'pty-1', + cwd: '/repo', + title: 'shell', + verifiedAgentDiscovery: verified + }) + expect(admitted.verifiedAgentDiscovery).toEqual(verified) + expect(admitted.verifiedAgentDiscovery).not.toBe(verified) + expect(admitted.verifiedAgentDiscovery?.evidence).not.toBe(verified.evidence) + }) + it('rejects malformed foreground evidence instead of stripping it', () => { expect(() => new PtyProcessListAdmission().admit({ diff --git a/src/main/providers/pty-process-list-admission.ts b/src/main/providers/pty-process-list-admission.ts index 9548f63b927..b3f7361cf49 100644 --- a/src/main/providers/pty-process-list-admission.ts +++ b/src/main/providers/pty-process-list-admission.ts @@ -7,6 +7,10 @@ import { isForegroundProcessEvidence } from '../../shared/foreground-process-evidence' import type { PtyProcessInfo } from './types' +import { + cloneVerifiedAgentDiscovery, + isVerifiedAgentDiscovery +} from '../../shared/agent-status-verified-discovery' export const MAX_AGGREGATED_PTY_PROCESS_LIST_ENTRIES = 4096 export const MAX_AGGREGATED_PTY_PROCESS_LIST_BYTES = 32 * 1024 * 1024 @@ -67,6 +71,12 @@ export class PtyProcessListAdmission { : isForegroundProcessEvidence(value.foregroundProcessEvidence) ? Buffer.byteLength(JSON.stringify(value.foregroundProcessEvidence), 'utf8') : null + const discoveryBytes = + value.verifiedAgentDiscovery === undefined + ? 0 + : isVerifiedAgentDiscovery(value.verifiedAgentDiscovery) + ? Buffer.byteLength(JSON.stringify(value.verifiedAgentDiscovery), 'utf8') + : null if ( idBytes === null || cwdBytes === null || @@ -75,6 +85,7 @@ export class PtyProcessListAdmission { terminalHandleBytes === null || wslDistroBytes === null || evidenceBytes === null || + discoveryBytes === null || (value.rootProcessId !== undefined && (!Number.isSafeInteger(value.rootProcessId) || value.rootProcessId <= 0)) || (value.incarnationId !== undefined && !isPtyIncarnationId(value.incarnationId)) || @@ -109,6 +120,7 @@ export class PtyProcessListAdmission { terminalHandleBytes + wslDistroBytes + evidenceBytes + + discoveryBytes + ownerBytes if ( nextEntries > MAX_AGGREGATED_PTY_PROCESS_LIST_ENTRIES || @@ -137,6 +149,9 @@ export class PtyProcessListAdmission { ) } : {}), + ...(value.verifiedAgentDiscovery !== undefined + ? { verifiedAgentDiscovery: cloneVerifiedAgentDiscovery(value.verifiedAgentDiscovery) } + : {}), ...(normalizedOwners !== undefined ? { agentSessionOwners: normalizedOwners } : {}) } } diff --git a/src/main/runtime/runtime-agent-discovery-admission.ts b/src/main/runtime/runtime-agent-discovery-admission.ts new file mode 100644 index 00000000000..9d272c17cea --- /dev/null +++ b/src/main/runtime/runtime-agent-discovery-admission.ts @@ -0,0 +1,29 @@ +import { admitVerifiedAgentDiscovery } from '../../shared/agent-status-verified-discovery' +import type { VerifiedAgentDiscovery } from '../../shared/agent-status-verified-discovery' +import { makePaneKey } from '../../shared/stable-pane-id' +import { agentHookServer } from '../agent-hooks/server' +import { agentSessionOwners } from '../ipc/pty/pane/agent-session-owners' + +/** Admit discoveries proven by the local execution host through the canonical owner transaction. */ +export function admitLocalVerifiedAgentDiscoveries( + discoveries: readonly VerifiedAgentDiscovery[] +): void { + for (const discovery of discoveries) { + void admitVerifiedAgentDiscovery({ owners: agentSessionOwners, discovery }).then((result) => { + if (!result.admitted) { + return + } + const paneKey = makePaneKey(result.owner.surface.tabId, result.owner.surface.leafId) + agentHookServer.admitAgentSessionOwner({ + owner: result.owner, + paneKey, + tabId: result.owner.surface.tabId, + worktreeId: result.owner.surface.worktreeId, + connectionId: null, + terminalHandle: result.owner.surface.terminalHandle, + agentType: result.owner.claim.agent, + disposition: result.disposition + }) + }) + } +} diff --git a/src/main/runtime/runtime-agent-session-inventory-reconciliation.test.ts b/src/main/runtime/runtime-agent-session-inventory-reconciliation.test.ts index 1a8015e76ff..a990b466b7e 100644 --- a/src/main/runtime/runtime-agent-session-inventory-reconciliation.test.ts +++ b/src/main/runtime/runtime-agent-session-inventory-reconciliation.test.ts @@ -44,7 +44,7 @@ describe('runtime launch-owner inventory reconciliation', () => { }) expect(onReconciled).toHaveBeenCalledWith( - expect.objectContaining({ complete: true, connectionId: null }) + expect.objectContaining({ complete: true, connectionId: null, discoveries: [] }) ) expect(onReconciled.mock.calls[0]?.[0].owners).toEqual([owner]) }) diff --git a/src/main/runtime/runtime-agent-session-inventory-reconciliation.ts b/src/main/runtime/runtime-agent-session-inventory-reconciliation.ts index 5a23be1abec..ca8395eecdf 100644 --- a/src/main/runtime/runtime-agent-session-inventory-reconciliation.ts +++ b/src/main/runtime/runtime-agent-session-inventory-reconciliation.ts @@ -30,6 +30,9 @@ export function reconcileRuntimeAgentSessionInventory(args: { ? agentSessionOwners.list().filter((owner) => getPtyExecutionHost(owner.ptyId) === null) : []) ] + const discoveries = args.sessions.flatMap((session) => + session.verifiedAgentDiscovery ? [session.verifiedAgentDiscovery] : [] + ) const complete = args.connectionId !== undefined || [...args.knownHostIds].every((hostId) => { @@ -37,7 +40,7 @@ export function reconcileRuntimeAgentSessionInventory(args: { return kind === 'runtime' || args.queriedHostIds.has(hostId) }) try { - args.onReconciled({ owners, complete, connectionId: args.connectionId }) + args.onReconciled({ owners, discoveries, complete, connectionId: args.connectionId }) } catch (error) { // Inventory-derived bookkeeping must not make a terminal listing fail. console.warn('[runtime] launch membership reconciliation failed:', error) diff --git a/src/main/runtime/runtime-terminal-contracts.ts b/src/main/runtime/runtime-terminal-contracts.ts index ebcc9e44934..fa35da7f7e8 100644 --- a/src/main/runtime/runtime-terminal-contracts.ts +++ b/src/main/runtime/runtime-terminal-contracts.ts @@ -20,6 +20,7 @@ import type { RuntimeTerminalWriteOptions } from './runtime-terminal-writer' import type { RuntimePtyController } from './runtime-pty-controller-contract' import type { RuntimeAgentRowSnapshot } from './runtime-worktree-agent-rows' import type { WorkerTerminalHostScope } from './orchestration/worker-terminal-process-liveness' +import type { VerifiedAgentDiscovery } from '../../shared/agent-status-verified-discovery' export type TerminalCreateOptions = { command?: string @@ -80,6 +81,8 @@ export type RuntimeAgentSessionCommit = { export type RuntimeAgentSessionInventoryReconciliation = { owners: readonly unknown[] + /** Host-verified manual/adopted processes; consumers still admit through the owner registry. */ + discoveries: readonly VerifiedAgentDiscovery[] complete: boolean /** `undefined` is an aggregate census; null is the local host; a string is one SSH host. */ connectionId?: string | null diff --git a/src/main/startup/main-process-runtime-service.ts b/src/main/startup/main-process-runtime-service.ts index 7c153cd80ce..8e64400c762 100644 --- a/src/main/startup/main-process-runtime-service.ts +++ b/src/main/startup/main-process-runtime-service.ts @@ -6,6 +6,7 @@ import { getLocalPtyProvider, getSshPtyProvider, clearProviderPtyState } from '. import { agentHookServer } from '../agent-hooks/server' import { browserManager } from '../browser/browser-manager' import { loadAgentSessionClaimSigner } from '../runtime/agent-session-claim-identity' +import { admitLocalVerifiedAgentDiscoveries } from '../runtime/runtime-agent-discovery-admission' import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' import { prepareCodexAiVaultSessionResume } from '../codex/codex-ai-vault-session-resume' import { resolveHostCodexSessionSourceHome } from '../codex/codex-session-source-home' @@ -99,6 +100,12 @@ export function initializeMainProcessRuntime(): OrcaRuntimeService { ? { connectionId: reconciliation.connectionId } : {}) }) + // A manual process can only be adopted by the execution host that supplied the + // process/ancestry proof. Remote inventories remain unverifiable until their host + // exposes the same admission transaction. + if (reconciliation.connectionId === null) { + admitLocalVerifiedAgentDiscoveries(reconciliation.discoveries) + } }, // 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) => { diff --git a/src/shared/agent-status-discovery-validation.ts b/src/shared/agent-status-discovery-validation.ts new file mode 100644 index 00000000000..be84b4edf68 --- /dev/null +++ b/src/shared/agent-status-discovery-validation.ts @@ -0,0 +1,143 @@ +import { + isAgentSessionExecutionClaim, + isAgentSessionSurfaceBinding +} from './agent-session-host-authority' +import { isRemoteForegroundEvidence } from './foreground-process-evidence' +import { isResumableTuiAgent, normalizeAgentProviderSession } from './agent-session-resume' +import type { VerifiedAgentDiscovery } from './agent-status-verified-discovery' + +function validProcessMarker(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 && value.length <= 256 +} + +function validProcessId(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 +} + +type DiscoveryCandidate = { + claim: unknown + surface: unknown + evidence: unknown + providerIdentity: unknown + ancestry: unknown + process: unknown +} + +function isDiscoveryCandidate(value: unknown): value is DiscoveryCandidate { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return false + } + return ( + 'claim' in value && + 'surface' in value && + 'evidence' in value && + 'providerIdentity' in value && + 'ancestry' in value && + 'process' in value + ) +} + +function parseProviderIdentity(value: unknown): VerifiedAgentDiscovery['providerIdentity'] | null { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return null + } + if ( + !('agent' in value) || + !isResumableTuiAgent(value.agent) || + !('source' in value) || + (value.source !== 'process' && value.source !== 'provider-session') + ) { + return null + } + if (!('session' in value) || value.session === undefined) { + return value.source === 'process' ? { agent: value.agent, source: value.source } : null + } + const session = normalizeAgentProviderSession(value.session) + return session ? { agent: value.agent, source: value.source, session } : null +} + +function parseAncestry(value: unknown): VerifiedAgentDiscovery['ancestry'] | null { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return null + } + if (!('parent' in value) || !('chain' in value) || !('relation' in value)) { + return null + } + if ( + typeof value.parent !== 'object' || + value.parent === null || + Array.isArray(value.parent) || + !('pid' in value.parent) || + !('startTime' in value.parent) || + !validProcessId(value.parent.pid) || + !validProcessMarker(value.parent.startTime) || + !Array.isArray(value.chain) || + !value.chain.every( + (entry) => + typeof entry === 'object' && + entry !== null && + !Array.isArray(entry) && + 'pid' in entry && + 'startTime' in entry && + validProcessId(entry.pid) && + validProcessMarker(entry.startTime) + ) || + (value.relation !== 'direct-child' && + value.relation !== 'descendant' && + value.relation !== 'multiplexer-child' && + value.relation !== 'automation-child') + ) { + return null + } + return { + parent: { pid: value.parent.pid, startTime: value.parent.startTime }, + chain: value.chain.map((entry) => ({ pid: entry.pid, startTime: entry.startTime })), + relation: value.relation + } +} + +function parseProcess(value: unknown): VerifiedAgentDiscovery['process'] | null { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return null + } + if ( + !('pid' in value) || + !('startTime' in value) || + !('parentPid' in value) || + !validProcessId(value.pid) || + !validProcessMarker(value.startTime) || + !validProcessId(value.parentPid) + ) { + return null + } + return { pid: value.pid, startTime: value.startTime, parentPid: value.parentPid } +} + +/** Parse a process-inventory candidate before it reaches owner admission. */ +export function parseVerifiedAgentDiscovery(value: unknown): VerifiedAgentDiscovery | null { + if (!isDiscoveryCandidate(value)) { + return null + } + if (!isAgentSessionExecutionClaim(value.claim) || !isAgentSessionSurfaceBinding(value.surface)) { + return null + } + if (!isRemoteForegroundEvidence(value.evidence)) { + return null + } + const providerIdentity = parseProviderIdentity(value.providerIdentity) + const ancestry = parseAncestry(value.ancestry) + const process = parseProcess(value.process) + if (!providerIdentity || !ancestry || !process) { + return null + } + return { + claim: value.claim, + surface: value.surface, + evidence: value.evidence, + providerIdentity, + ancestry, + process + } +} + +export { validProcessId, validProcessMarker } diff --git a/src/shared/agent-status-verified-discovery.test.ts b/src/shared/agent-status-verified-discovery.test.ts new file mode 100644 index 00000000000..62f2fe8cf2f --- /dev/null +++ b/src/shared/agent-status-verified-discovery.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, it } from 'vitest' +import { createEphemeralAgentSessionClaimSigner } from '../main/runtime/agent-session-claim-identity' +import { ClaimedAgentPtyOwnerRegistry } from './claimed-agent-pty-owner' +import { + admitVerifiedAgentDiscovery, + isVerifiedAgentDiscovery, + type VerifiedAgentDiscovery +} from './agent-status-verified-discovery' + +const signer = createEphemeralAgentSessionClaimSigner('verified-discovery-test') +const claim = signer.createFreshClaim({ + namespace: { + machine: 'native:darwin', + principal: 'uid:1', + container: 'native', + providerRoot: 'profile-default:codex' + }, + agent: 'codex', + launchIdentity: 'manual-process-1', + canonicalWorktreeId: 'repo::/tmp/worktree' +}) + +const surface = { + worktreeId: 'repo::/tmp/worktree', + tabId: 'tab-1', + leafId: '11111111-1111-4111-8111-111111111111', + terminalHandle: `term_${'a'.repeat(32)}` +} + +function discovery(overrides: Partial = {}): VerifiedAgentDiscovery { + return { + claim, + surface, + evidence: { + verdict: 'live', + processName: 'codex', + authorityGeneration: 'host-generation-1', + observationEpoch: 7, + capturedAgeMs: 0, + ptyId: 'pty-1', + ptyIncarnationId: '22222222-2222-4222-8222-222222222222', + fence: { + platform: 'posix', + shellPid: 100, + shellStartTime: 'shell-start-1', + tty: '/dev/ttys001', + foregroundPgid: 200, + process: { pid: 200, startTime: 'agent-start-1' } + } + }, + providerIdentity: { agent: 'codex', source: 'process' }, + ancestry: { + parent: { pid: 100, startTime: 'shell-start-1' }, + chain: [{ pid: 100, startTime: 'shell-start-1' }], + relation: 'direct-child' + }, + process: { pid: 200, startTime: 'agent-start-1', parentPid: 100 }, + ...overrides + } +} + +describe('verified agent discovery admission', () => { + it('rejects malformed inventory values before admission', () => { + expect(isVerifiedAgentDiscovery({ verdict: 'live' })).toBe(false) + }) + + it('adopts a live process through the existing owner transaction', async () => { + const owners = new ClaimedAgentPtyOwnerRegistry() + const result = await admitVerifiedAgentDiscovery({ owners, discovery: discovery() }) + + expect(result).toMatchObject({ admitted: true, disposition: 'adopted' }) + if (!result.admitted) { + return + } + expect(result.owner.ptyId).toBe('pty-1') + expect(result.owner.surface).toEqual(surface) + expect(result.owner.statusBinding.role).toBe('root') + expect(owners.find(claim)?.statusBinding).toEqual(result.owner.statusBinding) + }) + + it('accepts a multiplexer child only with an explicit host ancestry chain', async () => { + const owners = new ClaimedAgentPtyOwnerRegistry() + const candidate = discovery({ + ancestry: { + parent: { pid: 150, startTime: 'tmux-start-1' }, + chain: [ + { pid: 100, startTime: 'shell-start-1' }, + { pid: 150, startTime: 'tmux-start-1' } + ], + relation: 'multiplexer-child' + }, + process: { pid: 200, startTime: 'agent-start-1', parentPid: 150 } + }) + + await expect( + admitVerifiedAgentDiscovery({ owners, discovery: candidate }) + ).resolves.toMatchObject({ + admitted: true + }) + }) + + it('rejects title/process-name mismatches and incomplete ancestry without touching owners', async () => { + const owners = new ClaimedAgentPtyOwnerRegistry() + const liveEvidence = discovery().evidence + if (liveEvidence.verdict !== 'live') { + throw new Error('expected live fixture') + } + if (liveEvidence.fence.platform !== 'posix') { + throw new Error('expected posix fixture') + } + const foreign = discovery({ + evidence: { ...liveEvidence, processName: 'claude' } + }) + const incomplete = discovery({ + ancestry: { + parent: { pid: 150, startTime: 'tmux-start-1' }, + chain: [{ pid: 100, startTime: 'shell-start-1' }], + relation: 'descendant' + } + }) + + await expect( + admitVerifiedAgentDiscovery({ owners, discovery: foreign }) + ).resolves.toMatchObject({ + admitted: false, + reason: 'provider_identity_mismatch' + }) + await expect( + admitVerifiedAgentDiscovery({ owners, discovery: incomplete }) + ).resolves.toMatchObject({ admitted: false, reason: 'ancestry_proof_incomplete' }) + expect(owners.list()).toEqual([]) + }) + + it('preserves unverifiable and exited verdicts instead of admitting them', async () => { + const owners = new ClaimedAgentPtyOwnerRegistry() + const unverifiable = discovery({ + evidence: { + ...discovery().evidence, + verdict: 'unverifiable', + reason: 'process_table_unreadable' + } + }) + const exited = discovery({ + evidence: { + ...discovery().evidence, + verdict: 'exited', + reason: 'pty_exit_1' + } + }) + + await expect(admitVerifiedAgentDiscovery({ owners, discovery: unverifiable })).resolves.toEqual( + { + admitted: false, + verdict: 'unverifiable', + reason: 'process_table_unreadable' + } + ) + await expect(admitVerifiedAgentDiscovery({ owners, discovery: exited })).resolves.toEqual({ + admitted: false, + verdict: 'exited', + reason: 'pty_exit_1' + }) + expect(owners.list()).toEqual([]) + }) + + it('replaces a stale incarnation without allowing the old generation to settle it', async () => { + const owners = new ClaimedAgentPtyOwnerRegistry() + const first = await admitVerifiedAgentDiscovery({ owners, discovery: discovery() }) + if (!first.admitted) { + throw new Error('expected first admission') + } + const liveEvidence = discovery().evidence + if (liveEvidence.verdict !== 'live') { + throw new Error('expected live fixture') + } + if (liveEvidence.fence.platform !== 'posix') { + throw new Error('expected posix fixture') + } + const replacement = discovery({ + evidence: { + ...liveEvidence, + ptyIncarnationId: '33333333-3333-4333-8333-333333333333', + fence: { + ...liveEvidence.fence, + process: { pid: 200, startTime: 'agent-start-2' } + } + }, + process: { pid: 200, startTime: 'agent-start-2', parentPid: 100 } + }) + const second = await admitVerifiedAgentDiscovery({ + owners, + discovery: replacement, + isLive: (() => { + let checks = 0 + return () => { + checks += 1 + return checks > 1 + } + })() + }) + if (!second.admitted) { + throw new Error('expected replacement admission') + } + expect(second.owner.generation).not.toBe(first.owner.generation) + owners.release(first.owner.ptyId, first.owner.generation) + expect(owners.find(claim)?.generation).toBe(second.owner.generation) + }) +}) diff --git a/src/shared/agent-status-verified-discovery.ts b/src/shared/agent-status-verified-discovery.ts new file mode 100644 index 00000000000..416656674dc --- /dev/null +++ b/src/shared/agent-status-verified-discovery.ts @@ -0,0 +1,256 @@ +import type { + AgentSessionExecutionClaim, + AgentSessionOwnerBinding, + AgentSessionSurfaceBinding +} from './agent-session-host-authority' +import { + isAgentSessionExecutionClaim, + isAgentSessionSurfaceBinding +} from './agent-session-host-authority' +import type { AgentProviderSessionMetadata, ResumableTuiAgent } from './agent-session-resume' +import type { RemoteForegroundEvidence } from './foreground-process-evidence' +import { + parseVerifiedAgentDiscovery, + validProcessId, + validProcessMarker +} from './agent-status-discovery-validation' +import { recognizeAgentProcess } from './agent-process-recognition' +import type { ClaimedAgentPtyOwnerRegistry } from './claimed-agent-pty-owner' +import type { PtyIncarnationId } from './pty-incarnation' +import { isPtyIncarnationId } from './pty-incarnation' + +/** A process identity proven by the execution host, not by a pane label or cwd. */ +export type VerifiedAgentDiscovery = { + claim: AgentSessionExecutionClaim + surface: AgentSessionSurfaceBinding + evidence: RemoteForegroundEvidence + providerIdentity: { + agent: ResumableTuiAgent + source: 'process' | 'provider-session' + session?: AgentProviderSessionMetadata + } + ancestry: { + /** The final parent in the host-owned process chain. */ + parent: { pid: number; startTime: string } + /** Every entry starts at the PTY shell and ends at `parent`. */ + chain: readonly { pid: number; startTime: string }[] + relation: 'direct-child' | 'descendant' | 'multiplexer-child' | 'automation-child' + } + process: { + pid: number + startTime: string + parentPid: number + } +} + +export type VerifiedAgentDiscoveryAdmission = + | { + admitted: true + disposition: 'created' | 'adopted' + owner: AgentSessionOwnerBinding + } + | { + admitted: false + verdict: 'unverifiable' | 'exited' + reason: string + } + +const MAX_REASON_LENGTH = 256 + +function invalid(reason: string): VerifiedAgentDiscoveryAdmission { + return { + admitted: false, + verdict: 'unverifiable', + reason: reason.slice(0, MAX_REASON_LENGTH) + } +} + +function validateDiscovery(discoveryValue: unknown): VerifiedAgentDiscoveryAdmission | null { + const discovery = parseVerifiedAgentDiscovery(discoveryValue) + if (!discovery) { + return invalid('discovery_shape_invalid') + } + if (!isAgentSessionExecutionClaim(discovery.claim)) { + return invalid('claim_invalid') + } + if (!isAgentSessionSurfaceBinding(discovery.surface)) { + return invalid('surface_invalid') + } + const evidence = discovery.evidence + if (evidence.verdict !== 'live') { + return { + admitted: false, + verdict: evidence.verdict, + reason: evidence.verdict === 'exited' ? evidence.reason : evidence.reason + } + } + if (!isPtyIncarnationId(evidence.ptyIncarnationId) || !validProcessMarker(evidence.ptyId)) { + return invalid('pty_identity_missing') + } + if (discovery.surface.terminalHandle.length === 0) { + return invalid('terminal_handle_missing') + } + if ( + !validProcessMarker(evidence.authorityGeneration) || + !Number.isSafeInteger(evidence.observationEpoch) || + evidence.observationEpoch < 0 || + !Number.isSafeInteger(evidence.capturedAgeMs) || + evidence.capturedAgeMs < 0 + ) { + return invalid('host_observation_invalid') + } + const fence = evidence.fence + if (fence.platform !== 'posix') { + return invalid('process_fence_missing') + } + const fencedProcess = fence.process + if ( + !validProcessId(fence.shellPid) || + !validProcessMarker(fence.shellStartTime) || + !validProcessMarker(fence.tty) || + !validProcessId(fence.foregroundPgid) || + !fencedProcess || + !validProcessId(fencedProcess.pid) || + !validProcessMarker(fencedProcess.startTime) + ) { + return invalid('process_fence_incomplete') + } + if ( + !validProcessId(discovery.process.pid) || + discovery.process.pid !== fencedProcess.pid || + !validProcessMarker(discovery.process.startTime) || + discovery.process.startTime !== fencedProcess.startTime || + !validProcessId(discovery.process.parentPid) + ) { + return invalid('process_incarnation_mismatch') + } + const chain = discovery.ancestry.chain + if ( + !Array.isArray(chain) || + chain.length === 0 || + !chain.every((entry) => validProcessId(entry.pid) && validProcessMarker(entry.startTime)) || + chain[0]?.pid !== fence.shellPid || + chain[0]?.startTime !== fence.shellStartTime || + chain.at(-1)?.pid !== discovery.ancestry.parent.pid || + chain.at(-1)?.startTime !== discovery.ancestry.parent.startTime || + discovery.process.parentPid !== discovery.ancestry.parent.pid || + !['direct-child', 'descendant', 'multiplexer-child', 'automation-child'].includes( + discovery.ancestry.relation + ) + ) { + return invalid('ancestry_proof_incomplete') + } + const processIdentity = recognizeAgentProcess(evidence.processName) + if (!processIdentity || processIdentity.agent !== discovery.providerIdentity.agent) { + return invalid('provider_identity_mismatch') + } + if ( + discovery.providerIdentity.source === 'provider-session' && + (!discovery.providerIdentity.session || discovery.providerIdentity.session.id.length === 0) + ) { + return invalid('provider_session_identity_missing') + } + if (discovery.claim.agent !== discovery.providerIdentity.agent) { + return invalid('claim_provider_mismatch') + } + return null +} + +/** Runtime boundary check for an optional process-inventory field. */ +export function isVerifiedAgentDiscovery(value: unknown): value is VerifiedAgentDiscovery { + return validateDiscovery(value) === null +} + +export function cloneVerifiedAgentDiscovery( + discovery: VerifiedAgentDiscovery +): VerifiedAgentDiscovery { + return { + ...discovery, + claim: { + ...discovery.claim + }, + surface: { + ...discovery.surface + }, + evidence: { + ...discovery.evidence, + ...(discovery.evidence.verdict === 'live' + ? { + fence: + discovery.evidence.fence.platform === 'posix' + ? { + ...discovery.evidence.fence, + ...(discovery.evidence.fence.process + ? { process: { ...discovery.evidence.fence.process } } + : {}) + } + : { + ...discovery.evidence.fence, + ...(discovery.evidence.fence.process + ? { process: { ...discovery.evidence.fence.process } } + : {}) + } + } + : {}) + }, + providerIdentity: { + ...discovery.providerIdentity, + ...(discovery.providerIdentity.session + ? { session: { ...discovery.providerIdentity.session } } + : {}) + }, + ancestry: { + ...discovery.ancestry, + parent: { ...discovery.ancestry.parent }, + chain: discovery.ancestry.chain.map((entry) => ({ ...entry })) + }, + process: { ...discovery.process } + } +} + +/** + * Admit an execution discovered outside Orca's launch path through the same owner transaction as + * fresh launches. The callback is an adoption hook: it never starts a process. All identity and + * ancestry checks happen before the registry can mint a status binding. + */ +export async function admitVerifiedAgentDiscovery(args: { + owners: ClaimedAgentPtyOwnerRegistry + discovery: VerifiedAgentDiscovery + isLive?: (ptyId: string, incarnationId: PtyIncarnationId) => boolean | Promise +}): Promise { + const validation = validateDiscovery(args.discovery) + if (validation) { + return validation + } + const { discovery } = args + const existing = args.owners.find(discovery.claim) + if ( + existing && + (existing.ptyId !== discovery.evidence.ptyId || + existing.surface.terminalHandle !== discovery.surface.terminalHandle) + ) { + return invalid('owner_surface_conflict') + } + try { + const result = await args.owners.ensure({ + claim: discovery.claim, + surface: discovery.surface, + spawn: async () => ({ ptyId: discovery.evidence.ptyId, disposition: 'adopted' as const }), + isLive: async (owner) => { + if (owner.ptyId !== discovery.evidence.ptyId) { + return false + } + return args.isLive + ? await args.isLive(owner.ptyId, discovery.evidence.ptyIncarnationId) + : true + } + }) + return { + admitted: true, + disposition: result.disposition, + owner: result.owner + } + } catch (error) { + return invalid(error instanceof Error ? error.message : 'owner_admission_failed') + } +}