diff --git a/src/main/agent-hooks/managed-hook-owner-identity.ts b/src/main/agent-hooks/managed-hook-owner-identity.ts index e9801c3c4..85941007e 100644 --- a/src/main/agent-hooks/managed-hook-owner-identity.ts +++ b/src/main/agent-hooks/managed-hook-owner-identity.ts @@ -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 { return runtimeHostIdentity } -async function readBootIdentity(): Promise { +export async function readBootIdentity(): Promise { if (process.platform === 'linux') { try { const bootId = (await readFile('/proc/sys/kernel/random/boot_id', 'utf8')).trim() diff --git a/src/main/daemon/daemon-audit-classifier.ts b/src/main/daemon/daemon-audit-classifier.ts new file mode 100644 index 000000000..b95408f14 --- /dev/null +++ b/src/main/daemon/daemon-audit-classifier.ts @@ -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['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 +} + +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 { + 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 { + 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 +} diff --git a/src/main/daemon/daemon-audit-eligibility-event.test.ts b/src/main/daemon/daemon-audit-eligibility-event.test.ts new file mode 100644 index 000000000..b17b5ba38 --- /dev/null +++ b/src/main/daemon/daemon-audit-eligibility-event.test.ts @@ -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() + }) +}) diff --git a/src/main/daemon/daemon-audit-eligibility-event.ts b/src/main/daemon/daemon-audit-eligibility-event.ts new file mode 100644 index 000000000..0511f7a38 --- /dev/null +++ b/src/main/daemon/daemon-audit-eligibility-event.ts @@ -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' +} diff --git a/src/main/daemon/daemon-entry.ts b/src/main/daemon/daemon-entry.ts index ba98af21c..215786fb4 100644 --- a/src/main/daemon/daemon-entry.ts +++ b/src/main/daemon/daemon-entry.ts @@ -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 { // 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') diff --git a/src/main/daemon/daemon-health.test.ts b/src/main/daemon/daemon-health.test.ts index 152ed0190..ccfb7d1f0 100644 --- a/src/main/daemon/daemon-health.test.ts +++ b/src/main/daemon/daemon-health.test.ts @@ -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 }) }) diff --git a/src/main/daemon/daemon-health.ts b/src/main/daemon/daemon-health.ts index 2989f9b22..71bc7f327 100644 --- a/src/main/daemon/daemon-health.ts +++ b/src/main/daemon/daemon-health.ts @@ -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 { @@ -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 { diff --git a/src/main/daemon/daemon-incarnation-evidence-types.ts b/src/main/daemon/daemon-incarnation-evidence-types.ts new file mode 100644 index 000000000..537a03fc1 --- /dev/null +++ b/src/main/daemon/daemon-incarnation-evidence-types.ts @@ -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 + readBootIdentity?: () => Promise + readCommandLine?: (pid: number, platform: NodeJS.Platform) => Promise + readProcessStartedAtMs?: (pid: number) => number | null + queryWindowsProcess?: (pid: number) => Promise +} diff --git a/src/main/daemon/daemon-incarnation-evidence.test.ts b/src/main/daemon/daemon-incarnation-evidence.test.ts new file mode 100644 index 000000000..0a319a346 --- /dev/null +++ b/src/main/daemon/daemon-incarnation-evidence.test.ts @@ -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' + }) + }) +}) diff --git a/src/main/daemon/daemon-incarnation-evidence.ts b/src/main/daemon/daemon-incarnation-evidence.ts new file mode 100644 index 000000000..314254cd5 --- /dev/null +++ b/src/main/daemon/daemon-incarnation-evidence.ts @@ -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 { + 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 { + 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 { + 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 { + 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['reason'], + evidenceSources: DaemonEvidenceSources +): DaemonProcessEvidence { + return { state: 'present', reason, evidenceSources } +} + +function gone( + reason: Extract['reason'], + evidenceSources: DaemonEvidenceSources, + exactIncarnation: ExactDaemonIncarnation +): DaemonProcessEvidence { + return { state: 'gone', reason, evidenceSources, exactIncarnation } +} + +function unknown( + reason: Extract['reason'], + evidenceSources: DaemonEvidenceSources +): DaemonProcessEvidence { + return { state: 'unknown', reason, evidenceSources } +} diff --git a/src/main/daemon/daemon-init.test.ts b/src/main/daemon/daemon-init.test.ts index 77881045c..444627f59 100644 --- a/src/main/daemon/daemon-init.test.ts +++ b/src/main/daemon/daemon-init.test.ts @@ -241,7 +241,10 @@ vi.mock('fs', () => ({ writeFileSync: writeFileSyncMock })) -vi.mock('child_process', () => ({ fork: forkMock })) +vi.mock('child_process', async (importOriginal) => ({ + ...(await importOriginal>()), + 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}$/) diff --git a/src/main/daemon/daemon-init.ts b/src/main/daemon/daemon-init.ts index 24fc9ef4d..d7fae0823 100644 --- a/src/main/daemon/daemon-init.ts +++ b/src/main/daemon/daemon-init.ts @@ -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 = {}): 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 { 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 }) diff --git a/src/main/daemon/daemon-process-inspection.test.ts b/src/main/daemon/daemon-process-inspection.test.ts new file mode 100644 index 000000000..2b612fd06 --- /dev/null +++ b/src/main/daemon/daemon-process-inspection.test.ts @@ -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() + } + ) +}) diff --git a/src/main/daemon/daemon-process-inspection.ts b/src/main/daemon/daemon-process-inspection.ts new file mode 100644 index 000000000..6dbc8ed55 --- /dev/null +++ b/src/main/daemon/daemon-process-inspection.ts @@ -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 + runCommand?: (file: string, args: string[], timeoutMs: number) => Promise +} + +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 { + 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 { + 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 { + 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 { + 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 +} diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts index cadee006f..69ed1bdbf 100644 --- a/src/main/daemon/daemon-pty-adapter.test.ts +++ b/src/main/daemon/daemon-pty-adapter.test.ts @@ -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 + current: NonNullable + }[] = [] + 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', () => { diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index 4be7931be..c5cc304bd 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -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 @@ -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 { - // 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( - '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( + '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, + 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(listeners: T[], listener: T): void { + const index = listeners.indexOf(listener) + if (index !== -1) { + listeners.splice(index, 1) + } +} + +function notifyAuditListeners(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' +} diff --git a/src/main/daemon/daemon-pty-router.test.ts b/src/main/daemon/daemon-pty-router.test.ts index 44df6bbaf..17ad0dd83 100644 --- a/src/main/daemon/daemon-pty-router.test.ts +++ b/src/main/daemon/daemon-pty-router.test.ts @@ -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'], diff --git a/src/main/daemon/daemon-ready-identity.test.ts b/src/main/daemon/daemon-ready-identity.test.ts new file mode 100644 index 000000000..a2ffb2022 --- /dev/null +++ b/src/main/daemon/daemon-ready-identity.test.ts @@ -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() + }) +}) diff --git a/src/main/daemon/daemon-ready-identity.ts b/src/main/daemon/daemon-ready-identity.ts new file mode 100644 index 000000000..49a02bffd --- /dev/null +++ b/src/main/daemon/daemon-ready-identity.ts @@ -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 { + 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 + } +} diff --git a/src/main/daemon/daemon-spawner.ts b/src/main/daemon/daemon-spawner.ts index ba0ea239c..68b2a5afc 100644 --- a/src/main/daemon/daemon-spawner.ts +++ b/src/main/daemon/daemon-spawner.ts @@ -14,6 +14,8 @@ export type DaemonPidFile = { entryPath?: string appVersion?: string launchNonce?: string + linuxStartTicks?: string + bootId?: string } export type DaemonProcessHandle = { diff --git a/src/main/daemon/degraded-daemon-pty-provider.test.ts b/src/main/daemon/degraded-daemon-pty-provider.test.ts index 79c71945c..fbbfdfe44 100644 --- a/src/main/daemon/degraded-daemon-pty-provider.test.ts +++ b/src/main/daemon/degraded-daemon-pty-provider.test.ts @@ -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) + }) }) diff --git a/src/main/daemon/production-launcher.test.ts b/src/main/daemon/production-launcher.test.ts index 12edb3b8a..490f15124 100644 --- a/src/main/daemon/production-launcher.test.ts +++ b/src/main/daemon/production-launcher.test.ts @@ -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') diff --git a/src/main/daemon/production-launcher.ts b/src/main/daemon/production-launcher.ts index b9fe704a4..79bf439ee 100644 --- a/src/main/daemon/production-launcher.ts +++ b/src/main/daemon/production-launcher.ts @@ -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 { +function waitForReady(child: ChildProcess): Promise { return new Promise((resolve, reject) => { let timeout: ReturnType | undefined let settled = false @@ -106,8 +109,8 @@ function waitForReady(child: ChildProcess): Promise { 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 { // 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 { diff --git a/src/main/runtime/worktree-teardown.test.ts b/src/main/runtime/worktree-teardown.test.ts index 55f33f9d0..eac1820a0 100644 --- a/src/main/runtime/worktree-teardown.test.ts +++ b/src/main/runtime/worktree-teardown.test.ts @@ -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: ( diff --git a/src/shared/daemon-audit-eligibility.ts b/src/shared/daemon-audit-eligibility.ts new file mode 100644 index 000000000..047f95e06 --- /dev/null +++ b/src/shared/daemon-audit-eligibility.ts @@ -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 +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] diff --git a/src/shared/telemetry-events.ts b/src/shared/telemetry-events.ts index 007e48cc7..bcf92ef54 100644 --- a/src/shared/telemetry-events.ts +++ b/src/shared/telemetry-events.ts @@ -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,