fix(agent-status): admit verified process discoveries

This commit is contained in:
Brennan Benson
2026-09-15 22:05:05 -07:00
parent 4f51c3815f
commit ee2ef2b6f4
19 changed files with 772 additions and 14 deletions
@@ -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()
@@ -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()
+1 -1
View File
@@ -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
@@ -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 }
}
@@ -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
@@ -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)
}
@@ -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
+4
View File
@@ -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.
+3
View File
@@ -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. */
@@ -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({
@@ -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 } : {})
}
}
@@ -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
})
})
}
}
@@ -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])
})
@@ -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)
@@ -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
@@ -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) => {
@@ -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 }
@@ -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> = {}): 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)
})
})
@@ -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<boolean>
}): Promise<VerifiedAgentDiscoveryAdmission> {
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')
}
}