Add audit-only daemon incarnation evidence (#11606)

* feat(daemon): add audit incarnation evidence

* fix(daemon): isolate audit evidence observers
This commit is contained in:
Brennan Benson 2026-07-30 16:02:08 -07:00 committed by GitHub
parent 5fe3aaf2b7
commit d4cfee76be
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 1859 additions and 73 deletions

View File

@ -15,14 +15,14 @@ function hasCode(error: unknown, code: string): boolean {
return error instanceof Error && 'code' in error && error.code === code
}
function parseLinuxStartTicks(statLine: string): string | null {
const commandEnd = statLine.lastIndexOf(') ')
export function parseLinuxStartTicks(statLine: string): string | null {
const commandEnd = statLine.lastIndexOf(')')
if (commandEnd < 0) {
return null
}
// Field 22 is index 19 after removing pid and the parenthesized command.
const startTicks = statLine
.slice(commandEnd + 2)
.slice(commandEnd + 1)
.trim()
.split(/\s+/)[19]
return startTicks ?? null
@ -115,7 +115,7 @@ async function readHostIdentity(): Promise<string> {
return runtimeHostIdentity
}
async function readBootIdentity(): Promise<string | undefined> {
export async function readBootIdentity(): Promise<string | undefined> {
if (process.platform === 'linux') {
try {
const bootId = (await readFile('/proc/sys/kernel/random/boot_id', 'utf8')).trim()

View File

@ -0,0 +1,219 @@
import { lstat } from 'node:fs/promises'
import {
probeDaemonProcessIdentity,
type DaemonEvidenceSource,
type DaemonEvidenceSources,
type DaemonProcessEvidence,
type ExactDaemonIncarnation
} from './daemon-incarnation-evidence'
import type {
DaemonAuditFailureTrigger,
DaemonAuditGoneReason
} from '../../shared/daemon-audit-eligibility'
export type { DaemonAuditTrigger } from '../../shared/daemon-audit-eligibility'
export type DaemonAuditContext = {
protocolGeneration: number
provider: 'local-daemon'
endpoint: string
tokenPath: string
endpointKind: 'unix-socket' | 'windows-named-pipe'
profileScope: string
}
export type DaemonAuditObservation =
| {
state: 'present'
reason: 'authenticated_inventory'
trigger: 'inventory_answered'
evidenceSources: DaemonEvidenceSources
context: DaemonAuditContext
exactIncarnation: ExactDaemonIncarnation | null
reachability: 'authenticated'
inventoryAuthority: 'authoritative'
processLiveness: 'unknown'
processReason: null
endpointState: DaemonEndpointState
observedAtMs: number
}
| {
state: 'gone'
reason: DaemonAuditGoneReason
trigger: DaemonAuditFailureTrigger
evidenceSources: DaemonEvidenceSources
context: DaemonAuditContext
exactIncarnation: ExactDaemonIncarnation
reachability: 'authenticated' | 'disconnected' | 'unknown'
inventoryAuthority: 'unavailable'
processLiveness: 'gone'
processReason: DaemonAuditGoneReason
endpointState: DaemonEndpointState
observedAtMs: number
}
| {
state: 'unknown'
reason: DaemonAuditFailureTrigger
trigger: DaemonAuditFailureTrigger
evidenceSources: DaemonEvidenceSources
context: DaemonAuditContext
exactIncarnation: ExactDaemonIncarnation | null
reachability: 'authenticated' | 'disconnected' | 'unknown'
inventoryAuthority: 'unavailable'
processLiveness: 'present' | 'unknown'
processReason:
| Extract<DaemonProcessEvidence, { state: 'present' | 'unknown' }>['reason']
| null
endpointState: DaemonEndpointState
observedAtMs: number
}
export type DaemonEndpointState = 'missing' | 'named-pipe' | 'non-socket' | 'socket' | 'unknown'
export type DaemonAuditClassifierDependencies = {
probeProcessIdentity?: typeof probeDaemonProcessIdentity
inspectEndpointState?: (context: DaemonAuditContext) => Promise<DaemonEndpointState>
}
export type DaemonAuditClassificationOptions = {
additionalEvidenceSources?: readonly DaemonEvidenceSource[]
endpointGoneProof?: 'windows_named_pipe_missing'
dependencies?: DaemonAuditClassifierDependencies
}
export function recordAuthenticatedInventory(
context: DaemonAuditContext,
exactIncarnation: ExactDaemonIncarnation | null
): DaemonAuditObservation {
return {
state: 'present',
reason: 'authenticated_inventory',
trigger: 'inventory_answered',
evidenceSources: ['authenticated_inventory'],
context,
exactIncarnation,
reachability: 'authenticated',
inventoryAuthority: 'authoritative',
processLiveness: 'unknown',
processReason: null,
endpointState: context.endpointKind === 'windows-named-pipe' ? 'named-pipe' : 'socket',
observedAtMs: Date.now()
}
}
export async function classifyDaemonAuditFailure(
context: DaemonAuditContext,
trigger: DaemonAuditFailureTrigger,
exactIncarnation: ExactDaemonIncarnation | null,
options: DaemonAuditClassificationOptions = {}
): Promise<DaemonAuditObservation> {
const probeProcessIdentity =
options.dependencies?.probeProcessIdentity ?? probeDaemonProcessIdentity
const inspectEndpoint = options.dependencies?.inspectEndpointState ?? inspectDaemonEndpointState
const [processEvidence, endpointState] = await Promise.all([
probeProcessIdentity(exactIncarnation, {
socketPath: context.endpoint,
tokenPath: context.tokenPath
}),
inspectEndpoint(context)
])
const reachability = reachabilityForTrigger(trigger)
const evidenceSources = combineEvidenceSources(
processEvidence.evidenceSources,
context.endpointKind === 'unix-socket' ? ['endpoint_stat'] : [],
options.additionalEvidenceSources ?? []
)
if (
options.endpointGoneProof &&
context.endpointKind === 'windows-named-pipe' &&
exactIncarnation
) {
return {
state: 'gone',
reason: options.endpointGoneProof,
trigger,
evidenceSources,
context,
exactIncarnation,
reachability,
inventoryAuthority: 'unavailable',
processLiveness: 'gone',
processReason: options.endpointGoneProof,
endpointState: 'missing',
observedAtMs: Date.now()
}
}
if (processEvidence.state === 'gone') {
return {
state: 'gone',
reason: processEvidence.reason,
trigger,
evidenceSources,
context,
exactIncarnation: processEvidence.exactIncarnation,
reachability,
inventoryAuthority: 'unavailable',
processLiveness: 'gone',
processReason: processEvidence.reason,
endpointState,
observedAtMs: Date.now()
}
}
return {
state: 'unknown',
reason: trigger,
trigger,
evidenceSources,
context,
exactIncarnation,
reachability,
inventoryAuthority: 'unavailable',
processLiveness: processEvidence.state,
processReason: processEvidence.reason,
endpointState,
observedAtMs: Date.now()
}
}
async function inspectDaemonEndpointState(
context: DaemonAuditContext
): Promise<DaemonEndpointState> {
if (context.endpointKind === 'windows-named-pipe') {
return 'named-pipe'
}
try {
const stats = await lstat(context.endpoint)
return stats.isSocket() ? 'socket' : 'non-socket'
} catch (error) {
return hasErrorCode(error, 'ENOENT') ? 'missing' : 'unknown'
}
}
function reachabilityForTrigger(
trigger: DaemonAuditFailureTrigger
): 'authenticated' | 'disconnected' | 'unknown' {
if (trigger === 'endpoint_identity_changed') {
return 'authenticated'
}
if (
trigger === 'transport_closed' ||
trigger === 'token_missing_after_authenticated_disconnect'
) {
return 'disconnected'
}
return 'unknown'
}
function combineEvidenceSources(
first: DaemonEvidenceSources,
...rest: readonly (readonly DaemonEvidenceSource[])[]
): DaemonEvidenceSources {
return [...new Set([first[0], ...first.slice(1), ...rest.flat()])] as [
DaemonEvidenceSource,
...DaemonEvidenceSource[]
]
}
function hasErrorCode(error: unknown, code: string): boolean {
return typeof error === 'object' && error !== null && 'code' in error && error.code === code
}

View File

@ -0,0 +1,51 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { validate } from '../telemetry/validator'
import { recordAuthenticatedInventory, type DaemonAuditContext } from './daemon-audit-classifier'
const { trackMock } = vi.hoisted(() => ({ trackMock: vi.fn() }))
vi.mock('../telemetry/client', () => ({ track: trackMock }))
import { trackDaemonAuditEligibility } from './daemon-audit-eligibility-event'
const context: DaemonAuditContext = {
protocolGeneration: 23,
provider: 'local-daemon',
endpoint: '/profile/daemon.sock',
tokenPath: '/profile/daemon.token',
endpointKind: 'unix-socket',
profileScope: '/profile'
}
beforeEach(() => {
trackMock.mockReset()
})
describe('daemon audit eligibility telemetry', () => {
it('uses a dedicated validator-accepted event family', () => {
trackDaemonAuditEligibility(recordAuthenticatedInventory(context, null))
expect(trackMock).toHaveBeenCalledOnce()
const [name, props] = trackMock.mock.calls[0]
expect(name).toBe('daemon_audit_eligibility')
expect(name).not.toBe('daemon_lifecycle')
expect(props).toMatchObject({
state: 'present',
reason: 'authenticated_inventory',
evidence_sources: ['authenticated_inventory'],
protocol_generation: 23,
exact_incarnation: 'unavailable',
process_reason: null
})
expect(validate('daemon_audit_eligibility', props).ok).toBe(true)
})
it('cannot affect callers when telemetry throws', () => {
trackMock.mockImplementation(() => {
throw new Error('transport failed')
})
expect(() =>
trackDaemonAuditEligibility(recordAuthenticatedInventory(context, null))
).not.toThrow()
})
})

View File

@ -0,0 +1,36 @@
import { track } from '../telemetry/client'
import type { DaemonAuditObservation } from './daemon-audit-classifier'
export function trackDaemonAuditEligibility(observation: DaemonAuditObservation): void {
try {
track('daemon_audit_eligibility', {
state: observation.state,
reason: observation.reason,
trigger: observation.trigger,
evidence_sources: [...observation.evidenceSources],
protocol_generation: observation.context.protocolGeneration,
provider: observation.context.provider,
endpoint_kind: observation.context.endpointKind,
profile_scope: observation.context.profileScope ? 'configured' : 'unspecified',
exact_incarnation: exactIncarnationKind(observation),
reachability: observation.reachability,
inventory_authority: observation.inventoryAuthority,
process_liveness: observation.processLiveness,
process_reason: observation.processReason,
endpoint_state: observation.endpointState
})
} catch {
// Audit telemetry cannot affect daemon availability.
}
}
function exactIncarnationKind(
observation: DaemonAuditObservation
): 'endpoint-identity' | 'endpoint-identity-linux-ticks' | 'unavailable' {
if (!observation.exactIncarnation) {
return 'unavailable'
}
return observation.exactIncarnation.linuxStartTicks && observation.exactIncarnation.bootId
? 'endpoint-identity-linux-ticks'
: 'endpoint-identity'
}

View File

@ -19,6 +19,7 @@ import {
} from '../providers/macos-tcc-login-shell'
import { MacosLoginSessionDeathWatch } from './macos-login-session-death-watch'
import { readCurrentProcessMacSystemResolverHealth } from '../network/macos-system-resolver-health'
import { readCurrentDaemonReadyIdentity } from './daemon-ready-identity'
export type ParsedDaemonArgs = {
socketPath: string
@ -255,9 +256,8 @@ async function main(): Promise<void> {
// Signal readiness to parent via IPC (if available)
if (process.send) {
// Why: Windows has no cheap OS query for a child's start time, so the
// daemon self-reports it here for the pid file's pid-recycling guard.
process.send({ type: 'ready', startedAtMs })
const readyIdentity = await readCurrentDaemonReadyIdentity(startedAtMs)
process.send({ type: 'ready', ...readyIdentity })
}
daemonLog.log('ready')

View File

@ -198,7 +198,10 @@ describe('parseDaemonPidFile', () => {
pid: 12345,
startedAtMs: 1_700_000_000_000,
entryPath: null,
appVersion: null
appVersion: null,
launchNonce: null,
linuxStartTicks: null,
bootId: null
})
})
@ -213,7 +216,25 @@ describe('parseDaemonPidFile', () => {
pid: 12345,
startedAtMs: 1_700_000_000_000,
entryPath: '/repo/out/main/daemon-entry.js',
appVersion: '1.2.3'
appVersion: '1.2.3',
launchNonce: null,
linuxStartTicks: null,
bootId: null
})
})
it('preserves exact-incarnation launch and Linux process identity', () => {
const serialized = serializeDaemonPidFile({
pid: 12345,
startedAtMs: 1_700_000_000_000,
launchNonce: 'launch-a',
linuxStartTicks: '4242',
bootId: 'boot-a'
})
expect(parseDaemonPidFile(serialized)).toMatchObject({
launchNonce: 'launch-a',
linuxStartTicks: '4242',
bootId: 'boot-a'
})
})
@ -224,7 +245,10 @@ describe('parseDaemonPidFile', () => {
pid: 9999,
startedAtMs: null,
entryPath: null,
appVersion: null
appVersion: null,
launchNonce: null,
linuxStartTicks: null,
bootId: null
})
})
@ -236,13 +260,19 @@ describe('parseDaemonPidFile', () => {
pid: 12345,
startedAtMs: null,
entryPath: null,
appVersion: null
appVersion: null,
launchNonce: null,
linuxStartTicks: null,
bootId: null
})
expect(parseDaemonPidFile(' 12345\n')).toEqual({
pid: 12345,
startedAtMs: null,
entryPath: null,
appVersion: null
appVersion: null,
launchNonce: null,
linuxStartTicks: null,
bootId: null
})
})

View File

@ -40,11 +40,14 @@ const WIN32_START_TIME_TOLERANCE_MS = 10_000
// also covers a live-but-wedged daemon that simply missed the RPC budget.
export type DaemonHealth = 'healthy' | 'unreachable' | 'rejected' | 'pty-spawn-unhealthy'
type ParsedDaemonPid = {
export type ParsedDaemonPid = {
pid: number
startedAtMs: number | null
entryPath: string | null
appVersion: string | null
launchNonce: string | null
linuxStartTicks: string | null
bootId: string | null
}
function canConnectSocket(socketPath: string): Promise<boolean> {
@ -301,7 +304,7 @@ export function getMacDaemonSystemResolverHealth(
})
}
function commandLineMatchesDaemon(
export function commandLineMatchesDaemon(
commandLine: string,
socketPath: string,
tokenPath: string
@ -321,6 +324,9 @@ export function parseDaemonPidFile(contents: string): ParsedDaemonPid | null {
startedAtMs?: unknown
entryPath?: unknown
appVersion?: unknown
launchNonce?: unknown
linuxStartTicks?: unknown
bootId?: unknown
}
if (typeof parsed.pid === 'number' && Number.isFinite(parsed.pid)) {
return {
@ -330,7 +336,10 @@ export function parseDaemonPidFile(contents: string): ParsedDaemonPid | null {
? parsed.startedAtMs
: null,
entryPath: typeof parsed.entryPath === 'string' ? parsed.entryPath : null,
appVersion: typeof parsed.appVersion === 'string' ? parsed.appVersion : null
appVersion: typeof parsed.appVersion === 'string' ? parsed.appVersion : null,
launchNonce: typeof parsed.launchNonce === 'string' ? parsed.launchNonce : null,
linuxStartTicks: typeof parsed.linuxStartTicks === 'string' ? parsed.linuxStartTicks : null,
bootId: typeof parsed.bootId === 'string' ? parsed.bootId : null
}
}
} catch {
@ -338,7 +347,17 @@ export function parseDaemonPidFile(contents: string): ParsedDaemonPid | null {
}
const pid = Number(trimmed)
return Number.isFinite(pid) ? { pid, startedAtMs: null, entryPath: null, appVersion: null } : null
return Number.isFinite(pid)
? {
pid,
startedAtMs: null,
entryPath: null,
appVersion: null,
launchNonce: null,
linuxStartTicks: null,
bootId: null
}
: null
}
function getLinuxProcessStartedAtMs(pid: number): number | null {

View File

@ -0,0 +1,75 @@
import type { DaemonEndpointIdentity } from './daemon-hello-protocol'
import type {
DaemonAuditGoneReason,
DaemonEvidenceSource,
DaemonProcessGoneReason,
DaemonProcessPresentReason,
DaemonProcessUnknownReason
} from '../../shared/daemon-audit-eligibility'
export const WINDOWS_CREATION_TIME_TOLERANCE_MS = 10_000
// Oracle contract validated 2026-07-29; omitted proofs remain unknown.
export const DAEMON_GONE_PROOFS = {
linux: ['pid_missing', 'linux_boot_changed', 'linux_start_ticks_mismatch', 'linux_zombie'],
darwin: ['pid_missing'],
win32: ['windows_process_missing', 'windows_creation_time_mismatch', 'windows_named_pipe_missing']
} as const satisfies Readonly<
Record<'linux' | 'darwin' | 'win32', readonly DaemonAuditGoneReason[]>
>
export type {
DaemonAuditGoneReason,
DaemonEvidenceSource,
DaemonProcessGoneReason,
DaemonProcessPresentReason,
DaemonProcessUnknownReason
} from '../../shared/daemon-audit-eligibility'
export type DaemonEvidenceSources = readonly [DaemonEvidenceSource, ...DaemonEvidenceSource[]]
export type ExactDaemonIncarnation = {
identity: DaemonEndpointIdentity
linuxStartTicks?: string
bootId?: string
}
export type DaemonProcessEvidence =
| {
state: 'present'
reason: DaemonProcessPresentReason
evidenceSources: DaemonEvidenceSources
}
| {
state: 'gone'
reason: DaemonProcessGoneReason
evidenceSources: DaemonEvidenceSources
exactIncarnation: ExactDaemonIncarnation
}
| {
state: 'unknown'
reason: DaemonProcessUnknownReason
evidenceSources: DaemonEvidenceSources
}
export type ProcessSignalEvidence = 'occupied' | 'permission_denied' | 'missing' | 'unavailable'
export type LinuxStatEvidence =
| { status: 'present'; value: string }
| { status: 'missing' }
| { status: 'unavailable' }
export type WindowsProcessEvidence =
| { status: 'present'; commandLine: string | null; startedAtMs: number | null }
| { status: 'missing' }
| { status: 'unavailable' }
export type DaemonProcessProbeDependencies = {
platform?: NodeJS.Platform
signalProcess?: (pid: number) => ProcessSignalEvidence
readLinuxStat?: (pid: number) => Promise<LinuxStatEvidence>
readBootIdentity?: () => Promise<string | undefined>
readCommandLine?: (pid: number, platform: NodeJS.Platform) => Promise<string | undefined>
readProcessStartedAtMs?: (pid: number) => number | null
queryWindowsProcess?: (pid: number) => Promise<WindowsProcessEvidence>
}

View File

@ -0,0 +1,345 @@
import { describe, expect, it, vi } from 'vitest'
import {
DAEMON_GONE_PROOFS,
probeDaemonProcessIdentity,
WINDOWS_CREATION_TIME_TOLERANCE_MS,
type DaemonProcessProbeDependencies,
type ExactDaemonIncarnation
} from './daemon-incarnation-evidence'
import {
classifyDaemonAuditFailure,
recordAuthenticatedInventory,
type DaemonAuditClassifierDependencies,
type DaemonAuditContext
} from './daemon-audit-classifier'
const endpoint = { socketPath: '/runtime/daemon.sock', tokenPath: '/runtime/daemon.token' }
const exactIncarnation: ExactDaemonIncarnation = {
identity: { pid: 42, startedAtMs: 1_700_000_000_000, launchNonce: 'launch-a' },
linuxStartTicks: '4242',
bootId: 'boot-a'
}
const auditClassifierDependencies = {
probeProcessIdentity: async () => ({
state: 'unknown',
reason: 'inspection_failed',
evidenceSources: ['process_signal']
}),
inspectEndpointState: async (context) =>
context.endpointKind === 'windows-named-pipe' ? 'named-pipe' : 'missing'
} satisfies DaemonAuditClassifierDependencies
function linuxStat(state: string, startTicks: string): string {
return `42 (orca daemon with spaces) ${[state, ...Array(18).fill('0'), startTicks].join(' ')}`
}
function linuxDependencies(
overrides: DaemonProcessProbeDependencies = {}
): DaemonProcessProbeDependencies {
return {
platform: 'linux',
signalProcess: () => 'occupied',
readLinuxStat: async () => ({ status: 'present', value: linuxStat('S', '4242') }),
readBootIdentity: async () => 'boot-a',
readCommandLine: async () =>
`node daemon-entry --socket ${endpoint.socketPath} --token ${endpoint.tokenPath}`,
...overrides
}
}
describe('daemon process identity evidence', () => {
it('pins the dated conclusive-gone oracle contract', () => {
expect(DAEMON_GONE_PROOFS).toEqual({
linux: ['pid_missing', 'linux_boot_changed', 'linux_start_ticks_mismatch', 'linux_zombie'],
darwin: ['pid_missing'],
win32: [
'windows_process_missing',
'windows_creation_time_mismatch',
'windows_named_pipe_missing'
]
})
})
it('proves Linux pid reuse from native start ticks without derived milliseconds', async () => {
const readProcessStartedAtMs = vi.fn(() => exactIncarnation.identity.startedAtMs)
await expect(
probeDaemonProcessIdentity(
exactIncarnation,
endpoint,
linuxDependencies({
readLinuxStat: async () => ({ status: 'present', value: linuxStat('S', '4243') }),
readProcessStartedAtMs
})
)
).resolves.toMatchObject({
state: 'gone',
reason: 'linux_start_ticks_mismatch'
})
expect(readProcessStartedAtMs).not.toHaveBeenCalled()
})
it('proves Linux reboot invalidated the recorded incarnation', async () => {
await expect(
probeDaemonProcessIdentity(
exactIncarnation,
endpoint,
linuxDependencies({ readBootIdentity: async () => 'boot-b' })
)
).resolves.toMatchObject({ state: 'gone', reason: 'linux_boot_changed' })
})
it('treats a matching unreaped Linux zombie as gone', async () => {
await expect(
probeDaemonProcessIdentity(
exactIncarnation,
endpoint,
linuxDependencies({
readLinuxStat: async () => ({ status: 'present', value: linuxStat('Z', '4242') })
})
)
).resolves.toMatchObject({ state: 'gone', reason: 'linux_zombie' })
})
it('keeps EPERM unknown without an independent identity match', async () => {
await expect(
probeDaemonProcessIdentity(
exactIncarnation,
endpoint,
linuxDependencies({
signalProcess: () => 'permission_denied',
readLinuxStat: async () => ({ status: 'unavailable' })
})
)
).resolves.toMatchObject({ state: 'unknown', reason: 'permission_denied' })
})
it('keeps an unreadable command line unknown even when Linux start identity matches', async () => {
await expect(
probeDaemonProcessIdentity(
exactIncarnation,
endpoint,
linuxDependencies({ readCommandLine: async () => undefined })
)
).resolves.toMatchObject({ state: 'unknown', reason: 'command_line_unavailable' })
})
it('never treats signal-zero success alone as present', async () => {
await expect(
probeDaemonProcessIdentity(
{ identity: exactIncarnation.identity },
endpoint,
linuxDependencies({
readCommandLine: async () => undefined
})
)
).resolves.toMatchObject({ state: 'unknown' })
})
it('never turns a legacy identity gap into gone', async () => {
await expect(
probeDaemonProcessIdentity(null, endpoint, linuxDependencies())
).resolves.toMatchObject({ state: 'unknown', reason: 'exact_identity_unavailable' })
})
it('uses Windows CreationDate as the primary identity regardless of command line', async () => {
const base = {
platform: 'win32' as const,
signalProcess: () => 'occupied' as const
}
await expect(
probeDaemonProcessIdentity(exactIncarnation, endpoint, {
...base,
queryWindowsProcess: async () => ({
status: 'present',
commandLine: 'unreadable',
startedAtMs:
exactIncarnation.identity.startedAtMs + WINDOWS_CREATION_TIME_TOLERANCE_MS + 1
})
})
).resolves.toMatchObject({
state: 'gone',
reason: 'windows_creation_time_mismatch'
})
await expect(
probeDaemonProcessIdentity(exactIncarnation, endpoint, {
...base,
queryWindowsProcess: async () => ({
status: 'present',
commandLine: null,
startedAtMs: exactIncarnation.identity.startedAtMs
})
})
).resolves.toMatchObject({
state: 'present',
reason: 'windows_identity_match'
})
await expect(
probeDaemonProcessIdentity(exactIncarnation, endpoint, {
...base,
queryWindowsProcess: async () => ({
status: 'present',
commandLine: 'unrelated process',
startedAtMs: exactIncarnation.identity.startedAtMs
})
})
).resolves.toMatchObject({
state: 'present',
reason: 'windows_identity_match'
})
await expect(
probeDaemonProcessIdentity(exactIncarnation, endpoint, {
...base,
queryWindowsProcess: async () => ({
status: 'present',
commandLine: 'node daemon-entry',
startedAtMs: null
})
})
).resolves.toMatchObject({
state: 'unknown',
reason: 'windows_process_start_time_unavailable'
})
await expect(
probeDaemonProcessIdentity(exactIncarnation, endpoint, {
...base,
queryWindowsProcess: async () => ({ status: 'missing' })
})
).resolves.toMatchObject({
state: 'gone',
reason: 'windows_process_missing'
})
})
it('requires Windows CIM evidence even when signal-zero reports a missing pid', async () => {
await expect(
probeDaemonProcessIdentity(exactIncarnation, endpoint, {
platform: 'win32',
signalProcess: () => 'missing',
queryWindowsProcess: async () => ({
status: 'present',
commandLine: null,
startedAtMs: exactIncarnation.identity.startedAtMs
})
})
).resolves.toMatchObject({
state: 'present',
reason: 'windows_identity_match'
})
})
it('keeps a macOS lstart mismatch unknown', async () => {
await expect(
probeDaemonProcessIdentity(exactIncarnation, endpoint, {
platform: 'darwin',
signalProcess: () => 'occupied',
readCommandLine: async () =>
`node daemon-entry --socket ${endpoint.socketPath} --token ${endpoint.tokenPath}`,
readProcessStartedAtMs: () => exactIncarnation.identity.startedAtMs + 2_500
})
).resolves.toMatchObject({ state: 'unknown', reason: 'macos_start_time_mismatch' })
})
it('accepts ESRCH as conclusive POSIX process disappearance', async () => {
await expect(
probeDaemonProcessIdentity(exactIncarnation, endpoint, {
platform: 'darwin',
signalProcess: () => 'missing'
})
).resolves.toMatchObject({ state: 'gone', reason: 'pid_missing' })
})
})
describe('daemon audit availability evidence', () => {
const context: DaemonAuditContext = {
protocolGeneration: 23,
provider: 'local-daemon',
endpoint: endpoint.socketPath,
tokenPath: endpoint.tokenPath,
endpointKind: 'unix-socket',
profileScope: '/profile'
}
it('records authoritative inventory as present without endpoint identity', () => {
expect(recordAuthenticatedInventory(context, null)).toMatchObject({
state: 'present',
reason: 'authenticated_inventory',
exactIncarnation: null,
inventoryAuthority: 'authoritative'
})
})
it('represents failed legacy inventory as unknown, never an empty process list', async () => {
const observation = await classifyDaemonAuditFailure(context, 'inventory_failed', null, {
dependencies: auditClassifierDependencies
})
expect(observation).toMatchObject({
state: 'unknown',
reason: 'inventory_failed',
inventoryAuthority: 'unavailable',
processLiveness: 'unknown'
})
expect(observation.evidenceSources.length).toBeGreaterThan(0)
expect(observation).not.toHaveProperty('processes')
})
it('keeps token removal after disconnect as contributing evidence only', async () => {
const observation = await classifyDaemonAuditFailure(
context,
'token_missing_after_authenticated_disconnect',
null,
{
additionalEvidenceSources: ['token_file'],
dependencies: auditClassifierDependencies
}
)
expect(observation.state).toBe('unknown')
expect(observation.evidenceSources).toContain('token_file')
})
it('accepts Windows named-pipe absence only with exact incarnation evidence', async () => {
const windowsContext: DaemonAuditContext = {
...context,
endpoint: '\\\\?\\pipe\\orca-daemon',
endpointKind: 'windows-named-pipe'
}
const observation = await classifyDaemonAuditFailure(
windowsContext,
'inventory_failed',
exactIncarnation,
{
additionalEvidenceSources: ['windows_named_pipe'],
endpointGoneProof: 'windows_named_pipe_missing',
dependencies: auditClassifierDependencies
}
)
expect(observation).toMatchObject({
state: 'gone',
reason: 'windows_named_pipe_missing',
endpointState: 'missing',
exactIncarnation
})
})
it('rejects named-pipe disappearance as a gone proof for Unix endpoints', async () => {
const observation = await classifyDaemonAuditFailure(
context,
'inventory_failed',
exactIncarnation,
{
additionalEvidenceSources: ['windows_named_pipe'],
endpointGoneProof: 'windows_named_pipe_missing',
dependencies: auditClassifierDependencies
}
)
expect(observation).toMatchObject({
state: 'unknown',
reason: 'inventory_failed',
processLiveness: 'unknown'
})
})
})

View File

@ -0,0 +1,267 @@
import { parseLinuxStartTicks, readBootIdentity } from '../agent-hooks/managed-hook-owner-identity'
import {
commandLineMatchesDaemon,
getProcessStartedAtMs,
startTimesWithinTolerance
} from './daemon-health'
import {
WINDOWS_CREATION_TIME_TOLERANCE_MS,
type DaemonEvidenceSources,
type DaemonProcessEvidence,
type DaemonProcessProbeDependencies,
type ExactDaemonIncarnation,
type ProcessSignalEvidence
} from './daemon-incarnation-evidence-types'
import {
inspectProcessSignal,
queryWindowsProcess,
readLinuxStat,
readProcessCommandLine
} from './daemon-process-inspection'
export {
DAEMON_GONE_PROOFS,
WINDOWS_CREATION_TIME_TOLERANCE_MS,
type DaemonEvidenceSource,
type DaemonEvidenceSources,
type DaemonProcessEvidence,
type DaemonProcessProbeDependencies,
type ExactDaemonIncarnation,
type LinuxStatEvidence,
type ProcessSignalEvidence,
type WindowsProcessEvidence
} from './daemon-incarnation-evidence-types'
const POSIX_START_TIME_TOLERANCE_MS = 1_500
export async function probeDaemonProcessIdentity(
exactIncarnation: ExactDaemonIncarnation | null,
endpoint: { socketPath: string; tokenPath: string },
dependencies: DaemonProcessProbeDependencies = {}
): Promise<DaemonProcessEvidence> {
if (!exactIncarnation) {
return unknown('exact_identity_unavailable', ['pid_record'])
}
const platform = dependencies.platform ?? process.platform
const signalProcess = dependencies.signalProcess ?? inspectProcessSignal
const signal = signalProcess(exactIncarnation.identity.pid)
if (platform !== 'win32' && signal === 'missing') {
return gone('pid_missing', ['process_signal', 'endpoint_identity'], exactIncarnation)
}
if (platform === 'linux') {
return await probeLinuxProcess(exactIncarnation, endpoint, signal, dependencies)
}
if (platform === 'win32') {
return await probeWindowsProcess(exactIncarnation, endpoint, signal, dependencies)
}
return await probeMacosProcess(exactIncarnation, endpoint, signal, dependencies)
}
async function probeLinuxProcess(
exactIncarnation: ExactDaemonIncarnation,
endpoint: { socketPath: string; tokenPath: string },
signal: ProcessSignalEvidence,
dependencies: DaemonProcessProbeDependencies
): Promise<DaemonProcessEvidence> {
const stat = await (dependencies.readLinuxStat ?? readLinuxStat)(exactIncarnation.identity.pid)
if (stat.status === 'missing') {
return gone('pid_missing', ['linux_proc_stat', 'endpoint_identity'], exactIncarnation)
}
if (stat.status === 'unavailable') {
return unknown(signal === 'permission_denied' ? 'permission_denied' : 'inspection_failed', [
'linux_proc_stat',
'process_signal'
])
}
const expectedTicks = exactIncarnation.linuxStartTicks
const expectedBootId = exactIncarnation.bootId
if (expectedTicks || expectedBootId) {
if (!expectedTicks || !expectedBootId) {
return unknown('linux_identity_incomplete', ['pid_record'])
}
const bootId = await (dependencies.readBootIdentity ?? readBootIdentity)()
if (!bootId) {
return unknown('inspection_failed', ['boot_identity'])
}
if (bootId !== expectedBootId) {
return gone(
'linux_boot_changed',
['boot_identity', 'pid_record', 'endpoint_identity'],
exactIncarnation
)
}
const currentTicks = parseLinuxStartTicks(stat.value)
if (!currentTicks) {
return unknown('inspection_failed', ['linux_proc_stat'])
}
if (currentTicks !== expectedTicks) {
return gone(
'linux_start_ticks_mismatch',
['linux_proc_stat', 'boot_identity', 'pid_record'],
exactIncarnation
)
}
if (parseLinuxProcessState(stat.value) === 'Z') {
return gone(
'linux_zombie',
['linux_proc_stat', 'boot_identity', 'pid_record'],
exactIncarnation
)
}
}
const commandLine = await (dependencies.readCommandLine ?? readProcessCommandLine)(
exactIncarnation.identity.pid,
'linux'
)
if (commandLine === undefined) {
return unknown('command_line_unavailable', ['process_command_line'])
}
if (!commandLineMatchesDaemon(commandLine, endpoint.socketPath, endpoint.tokenPath)) {
return unknown('command_line_mismatch', ['process_command_line'])
}
if (expectedTicks && expectedBootId) {
return present('linux_identity_match', [
'linux_proc_stat',
'boot_identity',
'process_command_line',
'endpoint_identity'
])
}
const startedAtMs = (dependencies.readProcessStartedAtMs ?? getProcessStartedAtMs)(
exactIncarnation.identity.pid
)
if (startedAtMs === null) {
return unknown('process_start_time_unavailable', ['process_start_time'])
}
return startTimesWithinTolerance(
startedAtMs,
exactIncarnation.identity.startedAtMs,
POSIX_START_TIME_TOLERANCE_MS
)
? present('linux_identity_match', [
'process_command_line',
'process_start_time',
'endpoint_identity'
])
: unknown('linux_identity_incomplete', ['process_start_time', 'endpoint_identity'])
}
async function probeMacosProcess(
exactIncarnation: ExactDaemonIncarnation,
endpoint: { socketPath: string; tokenPath: string },
signal: ProcessSignalEvidence,
dependencies: DaemonProcessProbeDependencies
): Promise<DaemonProcessEvidence> {
const commandLine = await (dependencies.readCommandLine ?? readProcessCommandLine)(
exactIncarnation.identity.pid,
'darwin'
)
if (commandLine === undefined) {
return unknown(
signal === 'permission_denied' ? 'permission_denied' : 'command_line_unavailable',
['process_command_line', 'process_signal']
)
}
if (!commandLineMatchesDaemon(commandLine, endpoint.socketPath, endpoint.tokenPath)) {
return unknown('command_line_mismatch', ['process_command_line'])
}
const startedAtMs = (dependencies.readProcessStartedAtMs ?? getProcessStartedAtMs)(
exactIncarnation.identity.pid
)
if (startedAtMs === null) {
return unknown('process_start_time_unavailable', ['process_start_time'])
}
return startTimesWithinTolerance(
startedAtMs,
exactIncarnation.identity.startedAtMs,
POSIX_START_TIME_TOLERANCE_MS
)
? present('macos_identity_match', [
'process_command_line',
'process_start_time',
'endpoint_identity'
])
: unknown('macos_start_time_mismatch', ['process_start_time', 'endpoint_identity'])
}
async function probeWindowsProcess(
exactIncarnation: ExactDaemonIncarnation,
endpoint: { socketPath: string; tokenPath: string },
signal: ProcessSignalEvidence,
dependencies: DaemonProcessProbeDependencies
): Promise<DaemonProcessEvidence> {
const identity = await (dependencies.queryWindowsProcess ?? queryWindowsProcess)(
exactIncarnation.identity.pid
)
if (identity.status === 'missing') {
return gone('windows_process_missing', ['windows_cim', 'endpoint_identity'], exactIncarnation)
}
if (identity.status === 'unavailable') {
return unknown(signal === 'permission_denied' ? 'permission_denied' : 'inspection_failed', [
'windows_cim',
'process_signal'
])
}
if (identity.startedAtMs === null) {
return unknown('windows_process_start_time_unavailable', ['windows_cim'])
}
if (
!startTimesWithinTolerance(
identity.startedAtMs,
exactIncarnation.identity.startedAtMs,
WINDOWS_CREATION_TIME_TOLERANCE_MS
)
) {
return gone(
'windows_creation_time_mismatch',
['windows_cim', 'endpoint_identity'],
exactIncarnation
)
}
return present(
'windows_identity_match',
identity.commandLine &&
commandLineMatchesDaemon(identity.commandLine, endpoint.socketPath, endpoint.tokenPath)
? ['windows_cim', 'process_command_line', 'endpoint_identity']
: ['windows_cim', 'endpoint_identity']
)
}
export function parseLinuxProcessState(statLine: string): string | null {
const commandEnd = statLine.lastIndexOf(')')
if (commandEnd < 0) {
return null
}
return (
statLine
.slice(commandEnd + 1)
.trim()
.split(/\s+/, 1)[0] ?? null
)
}
function present(
reason: Extract<DaemonProcessEvidence, { state: 'present' }>['reason'],
evidenceSources: DaemonEvidenceSources
): DaemonProcessEvidence {
return { state: 'present', reason, evidenceSources }
}
function gone(
reason: Extract<DaemonProcessEvidence, { state: 'gone' }>['reason'],
evidenceSources: DaemonEvidenceSources,
exactIncarnation: ExactDaemonIncarnation
): DaemonProcessEvidence {
return { state: 'gone', reason, evidenceSources, exactIncarnation }
}
function unknown(
reason: Extract<DaemonProcessEvidence, { state: 'unknown' }>['reason'],
evidenceSources: DaemonEvidenceSources
): DaemonProcessEvidence {
return { state: 'unknown', reason, evidenceSources }
}

View File

@ -241,7 +241,10 @@ vi.mock('fs', () => ({
writeFileSync: writeFileSyncMock
}))
vi.mock('child_process', () => ({ fork: forkMock }))
vi.mock('child_process', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
fork: forkMock
}))
vi.mock('net', () => ({ connect: netConnectMock }))
@ -1754,7 +1757,14 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
on(event: string, cb: (arg?: unknown) => void) {
handlers[event]?.push(cb)
if (event === 'message') {
queueMicrotask(() => cb({ type: 'ready', startedAtMs: 1_000_000 }))
queueMicrotask(() =>
cb({
type: 'ready',
startedAtMs: 1_000_000,
linuxStartTicks: '4242',
bootId: 'boot-a'
})
)
}
return this
},
@ -1782,6 +1792,8 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
expect(JSON.parse(pidContents as string)).toEqual({
pid: 12345,
startedAtMs: 1_000_000,
linuxStartTicks: '4242',
bootId: 'boot-a',
entryPath: FAKE_DAEMON_ENTRY_PATH,
appVersion: '1.2.3',
launchNonce: expect.stringMatching(/^[0-9a-f-]{36}$/)

View File

@ -53,6 +53,7 @@ import {
confirmSeededClaudeLivePtys,
hasSeededUnconfirmedClaudePtys
} from '../claude-accounts/live-pty-gate'
import { parseDaemonReadyIdentity } from './daemon-ready-identity'
// Why: daemon init runs concurrent with window load, so an in-process t timestamp (not harness stderr timing) measures cold-start.
function logDaemonMilestone(event: string, details: Record<string, unknown> = {}): void {
@ -600,14 +601,8 @@ function createOutOfProcessLauncher(
if (settled) {
return
}
const selfReported = (msg as { startedAtMs?: unknown }).startedAtMs
if (
!Number.isSafeInteger(child.pid) ||
(child.pid as number) <= 0 ||
typeof selfReported !== 'number' ||
!Number.isFinite(selfReported) ||
selfReported <= 0
) {
const readyIdentity = parseDaemonReadyIdentity(msg)
if (!Number.isSafeInteger(child.pid) || (child.pid as number) <= 0 || !readyIdentity) {
void fail(new Error('Daemon readiness identity is incomplete'))
return
}
@ -617,7 +612,7 @@ function createOutOfProcessLauncher(
pidPath,
serializeDaemonPidFile({
pid: child.pid as number,
startedAtMs: selfReported,
...readyIdentity,
entryPath,
appVersion: app.getVersion(),
launchNonce
@ -718,7 +713,9 @@ export async function initDaemonPtyProvider(
// Why: fail-open may already have spawned fallback PTYs; don't install late, but retire an empty daemon (live sessions reject it and survive).
const abortedStartupAdapter = new DaemonPtyAdapter({
socketPath: info.socketPath,
tokenPath: info.tokenPath
tokenPath: info.tokenPath,
pidPath: getDaemonPidPath(runtimeDir),
profileScope: runtimeDir
})
releaseDaemonAdoptionLease(newSpawner.getHandle())
await abortedStartupAdapter.disconnectOnly()
@ -728,6 +725,8 @@ export async function initDaemonPtyProvider(
const newAdapter = new DaemonPtyAdapter({
socketPath: info.socketPath,
tokenPath: info.tokenPath,
pidPath: getDaemonPidPath(runtimeDir),
profileScope: runtimeDir,
historyPath: getHistoryDir(),
// Why: on daemon death, ensureConnected() detects the dead socket and calls this to fork a replacement before retrying.
respawn: async (reason: DaemonRespawnReason) => {
@ -925,6 +924,8 @@ async function runRestartDaemon(): Promise<RestartDaemonResult> {
const newCurrent = new DaemonPtyAdapter({
socketPath: info.socketPath,
tokenPath: info.tokenPath,
pidPath: getDaemonPidPath(runtimeDir),
profileScope: runtimeDir,
historyPath: getHistoryDir(),
respawn: async (reason: DaemonRespawnReason) => {
// Why: attribute rather than emit — the launcher below is the one that completes the
@ -1155,6 +1156,8 @@ export async function createLegacyDaemonAdapters(
new DaemonPtyAdapter({
socketPath,
tokenPath,
pidPath: getDaemonPidPath(runtimeDir, protocolVersion),
profileScope: runtimeDir,
protocolVersion,
historyPath
})

View File

@ -0,0 +1,37 @@
import { describe, expect, it, vi } from 'vitest'
import { queryWindowsProcess, readProcessCommandLine } from './daemon-process-inspection'
describe('daemon process inspection', () => {
it('falls back to ps when Linux procfs returns an empty command line', async () => {
const readTextFile = vi.fn(async () => '')
const runCommand = vi.fn(async () => 'node daemon-entry --socket daemon.sock')
await expect(readProcessCommandLine(42, 'linux', { readTextFile, runCommand })).resolves.toBe(
'node daemon-entry --socket daemon.sock'
)
expect(readTextFile).toHaveBeenCalledWith('/proc/42/cmdline')
expect(runCommand).toHaveBeenCalledWith('ps', ['-p', '42', '-o', 'command='], 2_000)
})
it('uses a non-empty Linux procfs command line without spawning ps', async () => {
const readTextFile = vi.fn(async () => 'node\0daemon-entry')
const runCommand = vi.fn()
await expect(readProcessCommandLine(42, 'linux', { readTextFile, runCommand })).resolves.toBe(
'node\0daemon-entry'
)
expect(runCommand).not.toHaveBeenCalled()
})
it.each([0, -1, 1.5, Number.MAX_SAFE_INTEGER + 1, Number.NaN])(
'rejects unsafe Windows pid %s before command interpolation',
async (pid) => {
const runCommand = vi.fn()
await expect(queryWindowsProcess(pid, { runCommand })).resolves.toEqual({
status: 'unavailable'
})
expect(runCommand).not.toHaveBeenCalled()
}
)
})

View File

@ -0,0 +1,122 @@
import { execFile } from 'node:child_process'
import { readFile } from 'node:fs/promises'
import { promisify } from 'node:util'
import type {
LinuxStatEvidence,
ProcessSignalEvidence,
WindowsProcessEvidence
} from './daemon-incarnation-evidence-types'
const execFileAsync = promisify(execFile)
export type DaemonProcessInspectionDependencies = {
readTextFile?: (path: string) => Promise<string>
runCommand?: (file: string, args: string[], timeoutMs: number) => Promise<string>
}
export function inspectProcessSignal(pid: number): ProcessSignalEvidence {
try {
process.kill(pid, 0)
return 'occupied'
} catch (error) {
if (hasErrorCode(error, 'ESRCH')) {
return 'missing'
}
if (hasErrorCode(error, 'EPERM')) {
return 'permission_denied'
}
return 'unavailable'
}
}
export async function readLinuxStat(pid: number): Promise<LinuxStatEvidence> {
try {
return { status: 'present', value: await readFile(`/proc/${pid}/stat`, 'utf8') }
} catch (error) {
return { status: hasErrorCode(error, 'ENOENT') ? 'missing' : 'unavailable' }
}
}
export async function readProcessCommandLine(
pid: number,
platform: NodeJS.Platform,
dependencies: DaemonProcessInspectionDependencies = {}
): Promise<string | undefined> {
const readTextFile =
dependencies.readTextFile ?? (async (path: string) => await readFile(path, 'utf8'))
const runCommand = dependencies.runCommand ?? runInspectionCommand
if (platform === 'linux') {
try {
const procCommandLine = await readTextFile(`/proc/${pid}/cmdline`)
if (procCommandLine.length > 0) {
return procCommandLine
}
} catch {
// Fall through to ps for procfs privilege or mount restrictions.
}
}
try {
const stdout = await runCommand('ps', ['-p', String(pid), '-o', 'command='], 2_000)
return stdout.trim() || undefined
} catch {
return undefined
}
}
export async function queryWindowsProcess(
pid: number,
dependencies: DaemonProcessInspectionDependencies = {}
): Promise<WindowsProcessEvidence> {
if (!Number.isSafeInteger(pid) || pid <= 0) {
return { status: 'unavailable' }
}
const runCommand = dependencies.runCommand ?? runInspectionCommand
try {
const stdout = await runCommand(
'powershell.exe',
[
'-NoProfile',
'-NonInteractive',
'-Command',
`$p = Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}"; ` +
`if (!$p) { @{ exists = $false } | ConvertTo-Json -Compress } else { ` +
`$start = $null; if ($p.CreationDate) { ` +
`$start = [long]([DateTimeOffset]$p.CreationDate).ToUnixTimeMilliseconds() }; ` +
`@{ exists = $true; cmd = $p.CommandLine; start = $start } | ConvertTo-Json -Compress }`
],
3_000
)
const parsed = JSON.parse(stdout.trim()) as {
exists?: unknown
cmd?: unknown
start?: unknown
}
if (parsed.exists === false) {
return { status: 'missing' }
}
if (parsed.exists !== true) {
return { status: 'unavailable' }
}
return {
status: 'present',
commandLine: typeof parsed.cmd === 'string' && parsed.cmd ? parsed.cmd : null,
startedAtMs:
typeof parsed.start === 'number' && Number.isFinite(parsed.start) ? parsed.start : null
}
} catch {
return { status: 'unavailable' }
}
}
async function runInspectionCommand(
file: string,
args: string[],
timeoutMs: number
): Promise<string> {
const { stdout } = await execFileAsync(file, args, { encoding: 'utf8', timeout: timeoutMs })
return stdout
}
function hasErrorCode(error: unknown, code: string): boolean {
return typeof error === 'object' && error !== null && 'code' in error && error.code === code
}

View File

@ -1449,6 +1449,11 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
expect(procs[0]).toHaveProperty('title')
expect(procs[0].cwd).toBe('/repo/owned-before-osc7')
expect(procs[0].worktreeId).toBe('repo::/repo/owned-before-osc7')
expect(adapter.getLastAuditObservation()).toMatchObject({
state: 'present',
reason: 'authenticated_inventory',
inventoryAuthority: 'authoritative'
})
})
it('reports the daemon session WSL owner', async () => {
@ -1470,6 +1475,104 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
}
}
})
it('retains authenticated identity and reports replacement across a same-endpoint reconnect', async () => {
await adapter.listProcesses()
const firstIdentity = adapter.getLastAuthenticatedDaemonIdentity()
expect(firstIdentity).not.toBeNull()
const identityChanges: {
previous: NonNullable<typeof firstIdentity>
current: NonNullable<typeof firstIdentity>
}[] = []
adapter.onDaemonIdentityChanged(() => {
throw new Error('audit listener failed')
})
adapter.onDaemonIdentityChanged((event) => identityChanges.push(event))
await server.shutdown()
await waitFor(
() => !(adapter as unknown as { client: { isConnected(): boolean } }).client.isConnected()
)
expect(adapter.getLastAuthenticatedDaemonIdentity()).toEqual(firstIdentity)
server = new DaemonServer({
socketPath,
tokenPath,
launchNonce: 'replacement-launch',
startedAtMs: (firstIdentity?.startedAtMs ?? 0) + 10_000,
log: daemonLog,
spawnSubprocess: (opts) => {
lastSpawnOpts = opts
lastSubprocess = createMockSubprocess()
return lastSubprocess
}
})
await server.start()
await expect(adapter.listProcesses()).resolves.toEqual([])
expect(identityChanges).toEqual([
{
previous: firstIdentity,
current: {
pid: process.pid,
startedAtMs: (firstIdentity?.startedAtMs ?? 0) + 10_000,
launchNonce: 'replacement-launch'
}
}
])
expect(adapter.getLastAuthenticatedDaemonIdentity()).toEqual(identityChanges[0]?.current)
})
it('isolates audit observation listeners from inventory and later listeners', async () => {
const laterListener = vi.fn()
adapter.onAuditEligibilityObservation(() => {
throw new Error('audit listener failed')
})
adapter.onAuditEligibilityObservation(laterListener)
await expect(adapter.listProcesses()).resolves.toEqual([])
expect(laterListener).toHaveBeenCalledOnce()
expect(laterListener).toHaveBeenCalledWith(
expect.objectContaining({
state: 'present',
reason: 'authenticated_inventory'
})
)
expect(adapter.getLastAuditObservation()).toMatchObject({
state: 'present',
reason: 'authenticated_inventory'
})
})
it('audits token ENOENT only after an authenticated disconnect', async () => {
const observations: {
trigger: string
state: string
evidenceSources: readonly string[]
}[] = []
adapter.onAuditEligibilityObservation((observation) => observations.push(observation))
await adapter.listProcesses()
await server.shutdown()
await waitFor(
() => !(adapter as unknown as { client: { isConnected(): boolean } }).client.isConnected()
)
await expect(adapter.listProcesses()).rejects.toThrow()
await waitFor(() =>
observations.some(
(observation) => observation.trigger === 'token_missing_after_authenticated_disconnect'
)
)
expect(observations).toContainEqual(
expect.objectContaining({
trigger: 'token_missing_after_authenticated_disconnect',
state: 'unknown',
evidenceSources: expect.arrayContaining(['token_file'])
})
)
})
})
describe('hasChildProcesses / getForegroundProcess', () => {

View File

@ -1,8 +1,12 @@
/* oxlint-disable max-lines -- Why: history .catch() safety wiring spread across spawn/event-routing is tightly coupled to the adapter↔history lifecycle. */
import { basename } from 'node:path'
import { existsSync } from 'node:fs'
import { existsSync, readFileSync } from 'node:fs'
import { DaemonClient } from './client'
import { getMacDaemonSystemResolverHealth } from './daemon-health'
import {
getMacDaemonSystemResolverHealth,
parseDaemonPidFile,
type ParsedDaemonPid
} from './daemon-health'
import {
HistoryManager,
type HistoryCheckpointResult,
@ -63,6 +67,16 @@ import {
TERMINAL_HISTORY_INLINE_SEED_CODE_UNITS
} from './terminal-history-seed-chunks'
import { NdjsonLineTooLongError } from './ndjson'
import type { DaemonEndpointIdentity } from './daemon-hello-protocol'
import {
classifyDaemonAuditFailure,
recordAuthenticatedInventory,
type DaemonAuditContext,
type DaemonAuditObservation,
type DaemonAuditTrigger
} from './daemon-audit-classifier'
import type { DaemonEvidenceSource, ExactDaemonIncarnation } from './daemon-incarnation-evidence'
import { trackDaemonAuditEligibility } from './daemon-audit-eligibility-event'
type PendingDaemonSpawnOperation = {
exitsBySessionId: Map<string, { incarnationId?: string }[]>
@ -100,6 +114,8 @@ function providerSequenceForSpawn(
export type DaemonPtyAdapterOptions = {
socketPath: string
tokenPath: string
pidPath?: string
profileScope?: string
protocolVersion?: number
/** Directory for disk-based terminal history; when set, raw PTY output is written to disk for cold restore on daemon crash. */
historyPath?: string
@ -109,6 +125,11 @@ export type DaemonPtyAdapterOptions = {
export type DaemonRespawnReason = 'daemon_died' | 'unhealthy_resolver'
export type DaemonIdentityChangeEvent = {
previous: DaemonEndpointIdentity
current: DaemonEndpointIdentity
}
const MAX_TOMBSTONES = 1000
const MAX_CONCURRENT_CHECKPOINTS = 4
@ -130,7 +151,14 @@ export class DaemonPtyAdapter implements IPtyProvider {
readonly protocolVersion: number
private socketPath: string
private tokenPath: string
private pidPath: string | null
private client: DaemonClient
private auditContext: DaemonAuditContext
private lastAuthenticatedIdentity: DaemonEndpointIdentity | null = null
private exactDaemonIncarnation: ExactDaemonIncarnation | null = null
private lastAuditObservation: DaemonAuditObservation | null = null
private auditObservationListeners: ((observation: DaemonAuditObservation) => void)[] = []
private identityChangeListeners: ((event: DaemonIdentityChangeEvent) => void)[] = []
private historyManager: HistoryManager | null
private historyReader: HistoryReader | null
private respawnFn: DaemonPtyAdapterOptions['respawn'] | null
@ -219,6 +247,15 @@ export class DaemonPtyAdapter implements IPtyProvider {
this.protocolVersion = opts.protocolVersion ?? PROTOCOL_VERSION
this.socketPath = opts.socketPath
this.tokenPath = opts.tokenPath
this.pidPath = opts.pidPath ?? null
this.auditContext = {
protocolGeneration: this.protocolVersion,
provider: 'local-daemon',
endpoint: opts.socketPath,
tokenPath: opts.tokenPath,
endpointKind: process.platform === 'win32' ? 'windows-named-pipe' : 'unix-socket',
profileScope: opts.profileScope ?? ''
}
this.client = new DaemonClient({
socketPath: opts.socketPath,
tokenPath: opts.tokenPath,
@ -247,6 +284,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
this.producerResumesOwedOnReconnect.add(id)
}
this.pausedProducerSessionIds.clear()
this.observeAuditFailure('transport_closed')
})
}
@ -254,6 +292,26 @@ export class DaemonPtyAdapter implements IPtyProvider {
return this.historyManager
}
getLastAuthenticatedDaemonIdentity(): DaemonEndpointIdentity | null {
return this.lastAuthenticatedIdentity ? { ...this.lastAuthenticatedIdentity } : null
}
getLastAuditObservation(): DaemonAuditObservation | null {
return this.lastAuditObservation
}
onDaemonIdentityChanged(listener: (event: DaemonIdentityChangeEvent) => void): () => void {
this.identityChangeListeners.push(listener)
return () => removeListener(this.identityChangeListeners, listener)
}
onAuditEligibilityObservation(
listener: (observation: DaemonAuditObservation) => void
): () => void {
this.auditObservationListeners.push(listener)
return () => removeListener(this.auditObservationListeners, listener)
}
supportsAgentSessionClaims(): boolean {
return this.protocolVersion >= AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION
}
@ -1262,36 +1320,57 @@ export class DaemonPtyAdapter implements IPtyProvider {
}
async listProcesses(opts?: { deadlineMs?: number }): Promise<PtyProcessInfo[]> {
// Why: connect + listSessions share the caller's one absolute deadline so a
// wedged handshake cannot burn the whole teardown budget before the list issues.
await this.ensureConnected(opts?.deadlineMs)
const result = await this.client.request<ListSessionsResult>(
'listSessions',
undefined,
remainingRequestTimeoutMs(opts?.deadlineMs)
)
const admission = new PtyProcessListAdmission()
const processes: PtyProcessInfo[] = []
for (const session of result.sessions) {
if (!session.isAlive) {
continue
}
const { worktreeId } = parsePtySessionId(session.sessionId)
processes.push(
admission.admit({
id: session.sessionId,
...(session.incarnationId ? { incarnationId: session.incarnationId } : {}),
// Why: OSC 7 may not arrive before cleanup; spawn cwd is authoritative until the daemon reports a live cwd.
cwd: session.cwd ?? this.initialCwds.get(session.sessionId) ?? '',
title: 'shell',
...(worktreeId ? { worktreeId } : {}),
...(session.terminalHandle ? { terminalHandle: session.terminalHandle } : {}),
...(session.wslDistro !== undefined ? { wslDistro: session.wslDistro } : {}),
...this.validatedAgentSessionOwners(session.agentSessionOwners)
})
try {
// Why: connect + listSessions share the caller's one absolute deadline so a
// wedged handshake cannot burn the whole teardown budget before the list issues.
await this.ensureConnected(opts?.deadlineMs)
const result = await this.client.request<ListSessionsResult>(
'listSessions',
undefined,
remainingRequestTimeoutMs(opts?.deadlineMs)
)
const admission = new PtyProcessListAdmission()
const processes: PtyProcessInfo[] = []
for (const session of result.sessions) {
if (!session.isAlive) {
continue
}
const { worktreeId } = parsePtySessionId(session.sessionId)
processes.push(
admission.admit({
id: session.sessionId,
...(session.incarnationId ? { incarnationId: session.incarnationId } : {}),
// Why: OSC 7 may not arrive before cleanup; spawn cwd is authoritative until the daemon reports a live cwd.
cwd: session.cwd ?? this.initialCwds.get(session.sessionId) ?? '',
title: 'shell',
...(worktreeId ? { worktreeId } : {}),
...(session.terminalHandle ? { terminalHandle: session.terminalHandle } : {}),
...(session.wslDistro !== undefined ? { wslDistro: session.wslDistro } : {}),
...this.validatedAgentSessionOwners(session.agentSessionOwners)
})
)
}
this.publishAuditObservation(
recordAuthenticatedInventory(this.auditContext, this.exactDaemonIncarnation)
)
return processes
} catch (error) {
const missingAuthenticatedToken =
isMissingTokenFileError(error) && this.client.hasObservedAuthenticatedDisconnect()
const missingNamedPipe = isMissingWindowsNamedPipeError(error)
this.observeAuditFailure(
missingAuthenticatedToken
? 'token_missing_after_authenticated_disconnect'
: 'inventory_failed',
this.exactDaemonIncarnation,
[
...(missingAuthenticatedToken ? (['token_file'] as const) : []),
...(missingNamedPipe ? (['windows_named_pipe'] as const) : [])
],
missingNamedPipe ? 'windows_named_pipe_missing' : undefined
)
throw error
}
return processes
}
private validatedAgentSessionOwners(
@ -1452,6 +1531,8 @@ export class DaemonPtyAdapter implements IPtyProvider {
this.wslDistrosBySessionId.clear()
this.pausedProducerSessionIds.clear()
this.producerResumesOwedOnReconnect.clear()
this.auditObservationListeners.length = 0
this.identityChangeListeners.length = 0
this.removeEventListener?.()
this.removeEventListener = null
// Why: final checkpoints are written daemon-side (TerminalHost.dispose); here the adapter only marks sessions
@ -1470,6 +1551,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
}
// Why: an authenticated pair cancels the adoption watchdog and lets a never-used adapter retire its empty daemon on quit.
await this.client.ensureConnected()
this.recordAuthenticatedIdentity()
}
// Why: unlike dispose(), leave history files unclean (no endedAt) so the next launch treats them as crash-recoverable,
@ -1532,6 +1614,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
// Why: a respawn launcher holds a temporary pair until this adapter's permanent reconnect, preventing both gaps and leaks.
this.releasePendingRespawnAdoptionLease()
}
this.recordAuthenticatedIdentity()
// Why sampled before setupEventRouting: "no listener yet" identifies a fresh connect — the only time the
// daemon-side backgrounded set (process state lost with the old daemon) needs a resync.
const isFreshConnection = this.removeEventListener === null
@ -1543,6 +1626,73 @@ export class DaemonPtyAdapter implements IPtyProvider {
}
}
private recordAuthenticatedIdentity(): void {
const current = this.client.getDaemonIdentity()
if (!current) {
return
}
const previous = this.lastAuthenticatedIdentity
if (previous && sameEndpointIdentity(previous, current)) {
return
}
const previousExactIncarnation = this.exactDaemonIncarnation
const pidRecord = this.readMatchingPidRecord(current)
this.lastAuthenticatedIdentity = { ...current }
this.exactDaemonIncarnation = {
identity: { ...current },
...(pidRecord?.linuxStartTicks && pidRecord.bootId
? {
linuxStartTicks: pidRecord.linuxStartTicks,
bootId: pidRecord.bootId
}
: {})
}
if (!previous) {
return
}
const event = { previous: { ...previous }, current: { ...current } }
notifyAuditListeners(this.identityChangeListeners, event)
this.observeAuditFailure('endpoint_identity_changed', previousExactIncarnation, [
'endpoint_identity'
])
}
private readMatchingPidRecord(identity: DaemonEndpointIdentity): ParsedDaemonPid | null {
if (!this.pidPath) {
return null
}
try {
const parsed = parseDaemonPidFile(readFileSync(this.pidPath, 'utf8'))
return parsed?.pid === identity.pid &&
parsed.startedAtMs === identity.startedAtMs &&
parsed.launchNonce === identity.launchNonce
? parsed
: null
} catch {
return null
}
}
private observeAuditFailure(
trigger: Exclude<DaemonAuditTrigger, 'inventory_answered'>,
exactIncarnation = this.exactDaemonIncarnation,
additionalEvidenceSources: readonly DaemonEvidenceSource[] = [],
endpointGoneProof?: 'windows_named_pipe_missing'
): void {
void classifyDaemonAuditFailure(this.auditContext, trigger, exactIncarnation, {
additionalEvidenceSources,
endpointGoneProof
})
.then((observation) => this.publishAuditObservation(observation))
.catch(() => {})
}
private publishAuditObservation(observation: DaemonAuditObservation): void {
this.lastAuditObservation = observation
trackDaemonAuditEligibility(observation)
notifyAuditListeners(this.auditObservationListeners, observation)
}
private resyncBackgroundedSessions(): void {
for (const id of this.backgroundedSessionIds) {
// Harmless no-op for sessions the daemon doesn't know (yet).
@ -1829,6 +1979,13 @@ export class DaemonPtyAdapter implements IPtyProvider {
// Why: the token is removed only after an authenticated drop; an initial missing token may still hide a live daemon.
const missingRetiredEndpointToken =
isMissingTokenFileError(err) && this.client.hasObservedAuthenticatedDisconnect()
if (missingRetiredEndpointToken) {
this.observeAuditFailure(
'token_missing_after_authenticated_disconnect',
this.exactDaemonIncarnation,
['token_file']
)
}
if (
this.respawnAdoptionClosed ||
!this.respawnFn ||
@ -2135,6 +2292,34 @@ export class DaemonPtyAdapter implements IPtyProvider {
}
}
function sameEndpointIdentity(
left: DaemonEndpointIdentity,
right: DaemonEndpointIdentity
): boolean {
return (
left.pid === right.pid &&
left.startedAtMs === right.startedAtMs &&
left.launchNonce === right.launchNonce
)
}
function removeListener<T>(listeners: T[], listener: T): void {
const index = listeners.indexOf(listener)
if (index !== -1) {
listeners.splice(index, 1)
}
}
function notifyAuditListeners<T>(listeners: readonly ((value: T) => void)[], value: T): void {
for (const listener of listeners.slice()) {
try {
listener(value)
} catch {
// Audit observers cannot affect daemon operations.
}
}
}
// Why: syscall='connect' distinguishes a dead-socket ENOENT/ECONNREFUSED from token-file ENOENT (no syscall);
// message strings incl. wedged-daemon "Hello response timed out" (#8689) also warrant a respawn.
function isDaemonGoneError(err: unknown): boolean {
@ -2161,3 +2346,11 @@ function isMissingTokenFileError(err: unknown): boolean {
const errno = err as NodeJS.ErrnoException
return errno.code === 'ENOENT' && errno.syscall === 'open'
}
function isMissingWindowsNamedPipeError(err: unknown): boolean {
if (process.platform !== 'win32' || !(err instanceof Error)) {
return false
}
const errno = err as NodeJS.ErrnoException
return errno.code === 'ENOENT' && errno.syscall === 'connect'
}

View File

@ -546,6 +546,35 @@ describe('DaemonPtyRouter', () => {
await expect(router.listProcesses()).rejects.toThrow('legacy unavailable')
})
it('keeps a legacy adapter that exits after construction in fail-closed aggregates', async () => {
const current = createAdapter('current', ['current-session'])
const legacy = createAdapter('legacy', ['legacy-session'])
const router = new DaemonPtyRouter({ current, legacy: [legacy] })
await router.discoverLegacySessions()
vi.mocked(legacy.listProcesses).mockRejectedValue(new Error('legacy exited'))
await expect(router.listProcesses()).rejects.toThrow('legacy exited')
await expect(router.listProcesses()).rejects.toThrow('legacy exited')
expect(router.getLegacyAdapters()).toEqual([legacy])
expect(current.listProcesses).toHaveBeenCalledTimes(2)
})
it('pins colliding unmapped legacy ids falling through to the current daemon', async () => {
const sessionId = 'cross-generation-collision'
const current = createAdapter('current', [sessionId])
const legacy = createAdapter('legacy', [sessionId])
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
vi.mocked(legacy.listProcesses).mockRejectedValueOnce(new Error('legacy discovery failed'))
const router = new DaemonPtyRouter({ current, legacy: [legacy] })
await router.discoverLegacySessions()
router.write(sessionId, 'misrouted\n')
expect(current.write).toHaveBeenCalledWith(sessionId, 'misrouted\n')
expect(legacy.write).not.toHaveBeenCalled()
warn.mockRestore()
})
it('merges startup reconciliation and updates route mappings', async () => {
const current = createAdapter('current', [], {
alive: ['current-alive'],

View File

@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest'
import { parseDaemonReadyIdentity } from './daemon-ready-identity'
describe('parseDaemonReadyIdentity', () => {
it('accepts additive Linux incarnation identity', () => {
expect(
parseDaemonReadyIdentity({
type: 'ready',
startedAtMs: 1_700_000_000_000,
linuxStartTicks: '4242',
bootId: 'boot-a'
})
).toEqual({
startedAtMs: 1_700_000_000_000,
linuxStartTicks: '4242',
bootId: 'boot-a'
})
})
it('keeps older ready payloads compatible', () => {
expect(parseDaemonReadyIdentity({ type: 'ready', startedAtMs: 123 })).toEqual({
startedAtMs: 123
})
})
it('rejects partial Linux identity instead of persisting a false proof', () => {
expect(
parseDaemonReadyIdentity({
type: 'ready',
startedAtMs: 123,
linuxStartTicks: '4242'
})
).toBeNull()
})
})

View File

@ -0,0 +1,62 @@
import { readFileSync } from 'node:fs'
import { parseLinuxStartTicks, readBootIdentity } from '../agent-hooks/managed-hook-owner-identity'
export type DaemonReadyIdentity = {
startedAtMs: number
linuxStartTicks?: string
bootId?: string
}
export async function readCurrentDaemonReadyIdentity(
startedAtMs: number
): Promise<DaemonReadyIdentity> {
if (process.platform !== 'linux') {
return { startedAtMs }
}
try {
const linuxStartTicks = parseLinuxStartTicks(readFileSync('/proc/self/stat', 'utf8'))
const bootId = await readBootIdentity()
return linuxStartTicks && bootId ? { startedAtMs, linuxStartTicks, bootId } : { startedAtMs }
} catch {
return { startedAtMs }
}
}
export function parseDaemonReadyIdentity(message: unknown): DaemonReadyIdentity | null {
if (!message || typeof message !== 'object') {
return null
}
const value = message as {
startedAtMs?: unknown
linuxStartTicks?: unknown
bootId?: unknown
}
if (
typeof value.startedAtMs !== 'number' ||
!Number.isFinite(value.startedAtMs) ||
value.startedAtMs <= 0
) {
return null
}
const hasLinuxStartTicks = value.linuxStartTicks !== undefined
const hasBootId = value.bootId !== undefined
if (hasLinuxStartTicks !== hasBootId) {
return null
}
if (!hasLinuxStartTicks) {
return { startedAtMs: value.startedAtMs }
}
if (
typeof value.linuxStartTicks !== 'string' ||
value.linuxStartTicks.length === 0 ||
typeof value.bootId !== 'string' ||
value.bootId.length === 0
) {
return null
}
return {
startedAtMs: value.startedAtMs,
linuxStartTicks: value.linuxStartTicks,
bootId: value.bootId
}
}

View File

@ -14,6 +14,8 @@ export type DaemonPidFile = {
entryPath?: string
appVersion?: string
launchNonce?: string
linuxStartTicks?: string
bootId?: string
}
export type DaemonProcessHandle = {

View File

@ -405,4 +405,19 @@ describe('DegradedDaemonPtyProvider', () => {
expect(provider.getCurrentDaemonSessionIds()).toEqual([])
expect(provider.hasPty('legacy-session')).toBe(true)
})
it('keeps an exited legacy daemon poisoning listProcesses after construction', async () => {
const current = createDaemonAdapter('daemon', ['current-session'])
const legacy = createDaemonAdapter('legacy', ['legacy-session'])
const fallback = createProvider('fallback', ['fallback-session'])
const provider = new DegradedDaemonPtyProvider({ current, legacy: [legacy], fallback })
await provider.discoverDaemonSessions()
vi.mocked(legacy.listProcesses).mockRejectedValue(new Error('legacy exited'))
await expect(provider.listProcesses()).rejects.toThrow('legacy exited')
await expect(provider.listProcesses()).rejects.toThrow('legacy exited')
expect(provider.getLegacyAdapters()).toEqual([legacy])
expect(current.listProcesses).toHaveBeenCalledTimes(3)
expect(fallback.listProcesses).toHaveBeenCalledTimes(2)
})
})

View File

@ -58,14 +58,16 @@ describe('createProductionLauncher', () => {
it('returns a launcher function', () => {
const launcher = createProductionLauncher({
getDaemonEntryPath: () => '/fake/path.js'
getDaemonEntryPath: () => '/fake/path.js',
getAppVersion: () => '1.2.3'
})
expect(typeof launcher).toBe('function')
})
it('rejects either ownership argument without its pair before forking', async () => {
const launcher = createProductionLauncher({
getDaemonEntryPath: () => '/fake/path.js'
getDaemonEntryPath: () => '/fake/path.js',
getAppVersion: () => '1.2.3'
})
await expect(
@ -126,12 +128,18 @@ describe('createProductionLauncher', () => {
forkMock.mockReturnValueOnce(child)
const launcher = createProductionLauncher({
getDaemonEntryPath: () => join(dir, 'daemon-entry.js')
getDaemonEntryPath: () => join(dir, 'daemon-entry.js'),
getAppVersion: () => '1.2.3'
})
const pidPath = join(dir, 'daemon.pid')
const launch = launcher(socketPathFor(dir), tokenPathFor(dir), pidPath, 'launch-a')
handlers.message[0]?.({ type: 'ready', startedAtMs: 123_456 })
handlers.message[0]?.({
type: 'ready',
startedAtMs: 123_456,
linuxStartTicks: '4242',
bootId: 'boot-a'
})
const handle = await launch
expect(handle.shutdown).toEqual(expect.any(Function))
@ -143,7 +151,10 @@ describe('createProductionLauncher', () => {
expect(JSON.parse(readFileSync(pidPath, 'utf8'))).toEqual({
pid: 12345,
startedAtMs: 123_456,
linuxStartTicks: '4242',
bootId: 'boot-a',
entryPath: join(dir, 'daemon-entry.js'),
appVersion: '1.2.3',
launchNonce: 'launch-a'
})
expect(forkMock).toHaveBeenCalledWith(
@ -194,7 +205,8 @@ describe('createProductionLauncher', () => {
}
forkMock.mockReturnValueOnce(child)
const launcher = createProductionLauncher({
getDaemonEntryPath: () => join(dir, 'daemon-entry.js')
getDaemonEntryPath: () => join(dir, 'daemon-entry.js'),
getAppVersion: () => '1.2.3'
})
const launch = launcher(socketPathFor(dir), tokenPathFor(dir))
handlers.message[0]?.({ type: 'ready', startedAtMs: 123_456 })
@ -241,7 +253,8 @@ describe('createProductionLauncher', () => {
forkMock.mockReturnValueOnce(child)
const launcher = createProductionLauncher({
getDaemonEntryPath: () => join(dir, 'daemon-entry.js')
getDaemonEntryPath: () => join(dir, 'daemon-entry.js'),
getAppVersion: () => '1.2.3'
})
const launch = launcher(socketPathFor(dir), tokenPathFor(dir))
@ -302,7 +315,8 @@ describe('createProductionLauncher', () => {
forkMock.mockReturnValueOnce(child)
const launcher = createProductionLauncher({
getDaemonEntryPath: () => join(dir, 'daemon-entry.js')
getDaemonEntryPath: () => join(dir, 'daemon-entry.js'),
getAppVersion: () => '1.2.3'
})
const launch = launcher(socketPathFor(dir), tokenPathFor(dir))
handlers.message[0]?.({ type: 'ready' })
@ -359,7 +373,8 @@ describe('createProductionLauncher', () => {
const pidPath = join(dir, 'occupied.pid')
writeFileSync(pidPath, 'occupied')
const launcher = createProductionLauncher({
getDaemonEntryPath: () => join(dir, 'daemon-entry.js')
getDaemonEntryPath: () => join(dir, 'daemon-entry.js'),
getAppVersion: () => '1.2.3'
})
const launch = launcher(socketPathFor(dir), tokenPathFor(dir), pidPath, 'launch-b')

View File

@ -5,11 +5,13 @@ import {
type DaemonLauncher,
type DaemonProcessHandle
} from './daemon-spawner'
import { parseDaemonReadyIdentity, type DaemonReadyIdentity } from './daemon-ready-identity'
const READY_TIMEOUT_MS = 10_000
export type ProductionLauncherOptions = {
getDaemonEntryPath: () => string
getAppVersion: () => string
}
export function createProductionLauncher(opts: ProductionLauncherOptions): DaemonLauncher {
@ -45,9 +47,9 @@ export function createProductionLauncher(opts: ProductionLauncherOptions): Daemo
}
)
let startedAtMs: number
let readyIdentity: DaemonReadyIdentity
try {
startedAtMs = await waitForReady(child)
readyIdentity = await waitForReady(child)
} catch (error) {
return rejectAfterChildCleanup(child, error)
}
@ -60,8 +62,9 @@ export function createProductionLauncher(opts: ProductionLauncherOptions): Daemo
pidPath,
serializeDaemonPidFile({
pid: child.pid as number,
startedAtMs,
...readyIdentity,
entryPath,
appVersion: opts.getAppVersion(),
launchNonce
}),
{ mode: 0o600, flag: 'wx' }
@ -81,7 +84,7 @@ export function createProductionLauncher(opts: ProductionLauncherOptions): Daemo
}
}
function waitForReady(child: ChildProcess): Promise<number> {
function waitForReady(child: ChildProcess): Promise<DaemonReadyIdentity> {
return new Promise((resolve, reject) => {
let timeout: ReturnType<typeof setTimeout> | undefined
let settled = false
@ -106,8 +109,8 @@ function waitForReady(child: ChildProcess): Promise<number> {
if (settled) {
return
}
const startedAtMs = (msg as { startedAtMs?: unknown }).startedAtMs
if (typeof startedAtMs !== 'number' || !Number.isFinite(startedAtMs) || startedAtMs <= 0) {
const readyIdentity = parseDaemonReadyIdentity(msg)
if (!readyIdentity) {
fail(new Error('Daemon readiness identity is incomplete'))
return
}
@ -115,7 +118,7 @@ function waitForReady(child: ChildProcess): Promise<number> {
// Why: the daemon is detached after readiness, so startup listeners
// must not keep the child process closure alive for the daemon lifetime.
cleanupStartupListeners()
resolve(startedAtMs)
resolve(readyIdentity)
}
}
function onError(err: Error): void {

View File

@ -633,6 +633,7 @@ describe('killAllProcessesForWorktree', () => {
// DaemonClient method so the shared connect+RPC budget path is exercised.
ensureConnectedWithin: async () => {},
isConnected: () => true,
getDaemonIdentity: () => null,
disconnect: () => {},
notify: () => {},
request: (

View File

@ -0,0 +1,81 @@
export const DAEMON_AUDIT_STATE_VALUES = ['present', 'gone', 'unknown'] as const
export const DAEMON_AUDIT_TRIGGER_VALUES = [
'endpoint_identity_changed',
'inventory_answered',
'inventory_failed',
'token_missing_after_authenticated_disconnect',
'transport_closed'
] as const
export const DAEMON_EVIDENCE_SOURCE_VALUES = [
'authenticated_inventory',
'boot_identity',
'endpoint_identity',
'endpoint_stat',
'linux_proc_stat',
'pid_record',
'process_command_line',
'process_signal',
'process_start_time',
'token_file',
'windows_cim',
'windows_named_pipe'
] as const
export const DAEMON_PROCESS_PRESENT_REASON_VALUES = [
'linux_identity_match',
'macos_identity_match',
'windows_identity_match'
] as const
export const DAEMON_PROCESS_GONE_REASON_VALUES = [
'linux_boot_changed',
'linux_start_ticks_mismatch',
'linux_zombie',
'pid_missing',
'windows_creation_time_mismatch',
'windows_process_missing'
] as const
export const DAEMON_PROCESS_UNKNOWN_REASON_VALUES = [
'command_line_mismatch',
'command_line_unavailable',
'exact_identity_unavailable',
'inspection_failed',
'linux_identity_incomplete',
'macos_start_time_mismatch',
'permission_denied',
'process_start_time_unavailable',
'windows_process_start_time_unavailable'
] as const
export const DAEMON_AUDIT_GONE_REASON_VALUES = [
...DAEMON_PROCESS_GONE_REASON_VALUES,
'windows_named_pipe_missing'
] as const
export const DAEMON_AUDIT_REASON_VALUES = [
'authenticated_inventory',
'endpoint_identity_changed',
'inventory_failed',
'token_missing_after_authenticated_disconnect',
'transport_closed',
...DAEMON_AUDIT_GONE_REASON_VALUES
] as const
export const DAEMON_AUDIT_PROCESS_REASON_VALUES = [
...DAEMON_PROCESS_PRESENT_REASON_VALUES,
...DAEMON_PROCESS_GONE_REASON_VALUES,
...DAEMON_PROCESS_UNKNOWN_REASON_VALUES,
'windows_named_pipe_missing'
] as const
export type DaemonAuditState = (typeof DAEMON_AUDIT_STATE_VALUES)[number]
export type DaemonAuditTrigger = (typeof DAEMON_AUDIT_TRIGGER_VALUES)[number]
export type DaemonAuditFailureTrigger = Exclude<DaemonAuditTrigger, 'inventory_answered'>
export type DaemonEvidenceSource = (typeof DAEMON_EVIDENCE_SOURCE_VALUES)[number]
export type DaemonProcessPresentReason = (typeof DAEMON_PROCESS_PRESENT_REASON_VALUES)[number]
export type DaemonProcessGoneReason = (typeof DAEMON_PROCESS_GONE_REASON_VALUES)[number]
export type DaemonProcessUnknownReason = (typeof DAEMON_PROCESS_UNKNOWN_REASON_VALUES)[number]
export type DaemonAuditGoneReason = (typeof DAEMON_AUDIT_GONE_REASON_VALUES)[number]

View File

@ -27,6 +27,13 @@ import {
DAEMON_REPLACE_REASONS,
DAEMON_RETIRE_REASONS
} from './daemon-lifecycle-telemetry'
import {
DAEMON_AUDIT_PROCESS_REASON_VALUES,
DAEMON_AUDIT_REASON_VALUES,
DAEMON_AUDIT_STATE_VALUES,
DAEMON_AUDIT_TRIGGER_VALUES,
DAEMON_EVIDENCE_SOURCE_VALUES
} from './daemon-audit-eligibility'
import { SETUP_SCRIPT_IMPORT_PROVIDERS } from './setup-script-import-providers'
import { WORKSPACE_SOURCE_VALUES, type WorkspaceSource } from './workspace-source'
import { appStarSourceSchema } from './gh-star-source'
@ -418,6 +425,29 @@ const daemonLifecycleSchema = z.discriminatedUnion('transition', [
.strict()
])
const daemonAuditEligibilitySchema = z
.object({
state: z.enum(DAEMON_AUDIT_STATE_VALUES),
reason: z.enum(DAEMON_AUDIT_REASON_VALUES),
trigger: z.enum(DAEMON_AUDIT_TRIGGER_VALUES),
evidence_sources: z.array(z.enum(DAEMON_EVIDENCE_SOURCE_VALUES)).min(1).max(12),
protocol_generation: z.number().int().positive().max(1_000),
provider: z.literal('local-daemon'),
endpoint_kind: z.enum(['unix-socket', 'windows-named-pipe']),
profile_scope: z.enum(['configured', 'unspecified']),
exact_incarnation: z.enum([
'endpoint-identity',
'endpoint-identity-linux-ticks',
'unavailable'
]),
reachability: z.enum(['authenticated', 'disconnected', 'unknown']),
inventory_authority: z.enum(['authoritative', 'unavailable']),
process_liveness: z.enum(['present', 'gone', 'unknown']),
process_reason: z.enum(DAEMON_AUDIT_PROCESS_REASON_VALUES).nullable(),
endpoint_state: z.enum(['missing', 'named-pipe', 'non-socket', 'socket', 'unknown'])
})
.strict()
// Rollout signal for granting Codex hook trust via codex app-server RPCs
// instead of Orca's self-computed trusted_hash. `fallback`/`verify_failed`
// spikes mean the RPC lane is not taking; steady-state ledger skips are not
@ -1412,6 +1442,7 @@ export const eventSchemas = {
daemon_start_failed: daemonStartFailedSchema,
main_thread_hang_detected: mainThreadHangDetectedSchema,
daemon_lifecycle: daemonLifecycleSchema,
daemon_audit_eligibility: daemonAuditEligibilitySchema,
runtime_rpc_start_failed: runtimeRpcStartFailedSchema,
codex_trust_grant: codexTrustGrantSchema,