mirror of
https://github.com/stablyai/orca.git
synced 2026-10-01 16:02:03 +00:00
fix(agent-status): admit verified process discoveries
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user