diff --git a/src/main/daemon/daemon-audit-classifier.ts b/src/main/daemon/daemon-audit-classifier.ts index b95408f1415..4642acb7191 100644 --- a/src/main/daemon/daemon-audit-classifier.ts +++ b/src/main/daemon/daemon-audit-classifier.ts @@ -123,14 +123,14 @@ export async function classifyDaemonAuditFailure( context.endpointKind === 'unix-socket' ? ['endpoint_stat'] : [], options.additionalEvidenceSources ?? [] ) - if ( - options.endpointGoneProof && - context.endpointKind === 'windows-named-pipe' && - exactIncarnation - ) { + const windowsNamedPipeMissing = + options.endpointGoneProof === 'windows_named_pipe_missing' && + context.endpointKind === 'windows-named-pipe' + const observedEndpointState = windowsNamedPipeMissing ? 'missing' : endpointState + if (windowsNamedPipeMissing && exactIncarnation && processEvidence.state !== 'present') { return { state: 'gone', - reason: options.endpointGoneProof, + reason: 'windows_named_pipe_missing', trigger, evidenceSources, context, @@ -138,8 +138,8 @@ export async function classifyDaemonAuditFailure( reachability, inventoryAuthority: 'unavailable', processLiveness: 'gone', - processReason: options.endpointGoneProof, - endpointState: 'missing', + processReason: 'windows_named_pipe_missing', + endpointState: observedEndpointState, observedAtMs: Date.now() } } @@ -155,7 +155,7 @@ export async function classifyDaemonAuditFailure( inventoryAuthority: 'unavailable', processLiveness: 'gone', processReason: processEvidence.reason, - endpointState, + endpointState: observedEndpointState, observedAtMs: Date.now() } } @@ -170,7 +170,7 @@ export async function classifyDaemonAuditFailure( inventoryAuthority: 'unavailable', processLiveness: processEvidence.state, processReason: processEvidence.reason, - endpointState, + endpointState: observedEndpointState, observedAtMs: Date.now() } } @@ -192,9 +192,6 @@ async function inspectDaemonEndpointState( function reachabilityForTrigger( trigger: DaemonAuditFailureTrigger ): 'authenticated' | 'disconnected' | 'unknown' { - if (trigger === 'endpoint_identity_changed') { - return 'authenticated' - } if ( trigger === 'transport_closed' || trigger === 'token_missing_after_authenticated_disconnect' diff --git a/src/main/daemon/daemon-incarnation-evidence.test.ts b/src/main/daemon/daemon-incarnation-evidence.test.ts index 0a319a346d7..73e4a337e3c 100644 --- a/src/main/daemon/daemon-incarnation-evidence.test.ts +++ b/src/main/daemon/daemon-incarnation-evidence.test.ts @@ -114,6 +114,18 @@ describe('daemon process identity evidence', () => { ).resolves.toMatchObject({ state: 'unknown', reason: 'permission_denied' }) }) + it('keeps a missing proc stat unknown when signal zero still sees the pid', async () => { + await expect( + probeDaemonProcessIdentity( + exactIncarnation, + endpoint, + linuxDependencies({ + readLinuxStat: async () => ({ status: 'missing' }) + }) + ) + ).resolves.toMatchObject({ state: 'unknown', reason: 'inspection_failed' }) + }) + it('keeps an unreadable command line unknown even when Linux start identity matches', async () => { await expect( probeDaemonProcessIdentity( @@ -324,6 +336,36 @@ describe('daemon audit availability evidence', () => { }) }) + it('keeps contradictory Windows pipe and process evidence unknown', async () => { + const windowsContext: DaemonAuditContext = { + ...context, + endpoint: '\\\\?\\pipe\\orca-daemon', + endpointKind: 'windows-named-pipe' + } + const dependencies = { + ...auditClassifierDependencies, + probeProcessIdentity: async () => ({ + state: 'present', + reason: 'windows_identity_match', + evidenceSources: ['windows_cim', 'endpoint_identity'] + }) + } satisfies DaemonAuditClassifierDependencies + + await expect( + classifyDaemonAuditFailure(windowsContext, 'inventory_failed', exactIncarnation, { + additionalEvidenceSources: ['windows_named_pipe'], + endpointGoneProof: 'windows_named_pipe_missing', + dependencies + }) + ).resolves.toMatchObject({ + state: 'unknown', + reason: 'inventory_failed', + processLiveness: 'present', + processReason: 'windows_identity_match', + endpointState: 'missing' + }) + }) + it('rejects named-pipe disappearance as a gone proof for Unix endpoints', async () => { const observation = await classifyDaemonAuditFailure( context, diff --git a/src/main/daemon/daemon-incarnation-evidence.ts b/src/main/daemon/daemon-incarnation-evidence.ts index 314254cd539..b5c5429a9f2 100644 --- a/src/main/daemon/daemon-incarnation-evidence.ts +++ b/src/main/daemon/daemon-incarnation-evidence.ts @@ -66,7 +66,10 @@ async function probeLinuxProcess( ): Promise { const stat = await (dependencies.readLinuxStat ?? readLinuxStat)(exactIncarnation.identity.pid) if (stat.status === 'missing') { - return gone('pid_missing', ['linux_proc_stat', 'endpoint_identity'], exactIncarnation) + return unknown(signal === 'permission_denied' ? 'permission_denied' : 'inspection_failed', [ + 'linux_proc_stat', + 'process_signal' + ]) } if (stat.status === 'unavailable') { return unknown(signal === 'permission_denied' ? 'permission_denied' : 'inspection_failed', [ diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts index 69ed1bdbf75..5a3d766b85d 100644 --- a/src/main/daemon/daemon-pty-adapter.test.ts +++ b/src/main/daemon/daemon-pty-adapter.test.ts @@ -19,7 +19,7 @@ import type { SubprocessHandle } from './session' import type { PendingOutputRecord } from './types' import type { DaemonFileLog } from './daemon-file-log' import type * as DaemonHealthModule from './daemon-health' -import { getDaemonSocketPath } from './daemon-spawner' +import { getDaemonSocketPath, serializeDaemonPidFile } from './daemon-spawner' import { PtyWriteUnavailableError } from '../providers/pty-write-unavailable-error' import { TERMINAL_HISTORY_INLINE_SEED_CODE_UNITS } from './terminal-history-seed-chunks' @@ -1456,6 +1456,53 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { }) }) + it('loads persisted Linux birth identity for audit observations', async () => { + adapter.dispose() + await server.shutdown() + const startedAtMs = 1_700_000_000_000 + const launchNonce = 'launch-with-linux-identity' + server = new DaemonServer({ + socketPath, + tokenPath, + startedAtMs, + launchNonce, + log: daemonLog, + spawnSubprocess: (opts) => { + lastSpawnOpts = opts + lastSubprocess = createMockSubprocess() + return lastSubprocess + } + }) + await server.start() + const pidPath = join(dir, 'daemon.pid') + writeFileSync( + pidPath, + serializeDaemonPidFile({ + pid: process.pid, + startedAtMs, + launchNonce, + linuxStartTicks: '4242', + bootId: 'boot-a' + }) + ) + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) + try { + adapter = new DaemonPtyAdapter({ socketPath, tokenPath, pidPath }) + + await expect(adapter.listProcesses()).resolves.toEqual([]) + + expect(adapter.getLastAuditObservation()?.exactIncarnation).toMatchObject({ + linuxStartTicks: '4242', + bootId: 'boot-a' + }) + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + }) + it('reports the daemon session WSL owner', async () => { const platform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index c5cc304bd10..9fcf5a8633d 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -1,6 +1,7 @@ /* 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, readFileSync } from 'node:fs' +import { readFile } from 'node:fs/promises' import { DaemonClient } from './client' import { getMacDaemonSystemResolverHealth, @@ -152,6 +153,7 @@ export class DaemonPtyAdapter implements IPtyProvider { private socketPath: string private tokenPath: string private pidPath: string | null + private pidRecord: ParsedDaemonPid | null private client: DaemonClient private auditContext: DaemonAuditContext private lastAuthenticatedIdentity: DaemonEndpointIdentity | null = null @@ -248,6 +250,7 @@ export class DaemonPtyAdapter implements IPtyProvider { this.socketPath = opts.socketPath this.tokenPath = opts.tokenPath this.pidPath = opts.pidPath ?? null + this.pidRecord = readDaemonPidRecord(this.pidPath) this.auditContext = { protocolGeneration: this.protocolVersion, provider: 'local-daemon', @@ -1635,34 +1638,61 @@ export class DaemonPtyAdapter implements IPtyProvider { 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 - } - : {}) - } + this.exactDaemonIncarnation = exactDaemonIncarnationForPidRecord(current, this.pidRecord) 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 { + private async resolveExactDaemonIncarnation( + exactIncarnation: ExactDaemonIncarnation | null + ): Promise { + if ( + !exactIncarnation || + process.platform !== 'linux' || + (exactIncarnation.linuxStartTicks && exactIncarnation.bootId) + ) { + return exactIncarnation + } + const cachedIncarnation = exactDaemonIncarnationForPidRecord( + exactIncarnation.identity, + this.pidRecord + ) + if (cachedIncarnation.linuxStartTicks && cachedIncarnation.bootId) { + return cachedIncarnation + } + const pidRecord = await this.readMatchingPidRecord(exactIncarnation.identity) + this.pidRecord = pidRecord ?? this.pidRecord + return pidRecord?.linuxStartTicks && pidRecord.bootId + ? { + identity: { ...exactIncarnation.identity }, + linuxStartTicks: pidRecord.linuxStartTicks, + bootId: pidRecord.bootId + } + : exactIncarnation + } + + private cacheExactDaemonIncarnation(exactIncarnation: ExactDaemonIncarnation | null): void { + if ( + exactIncarnation && + this.lastAuthenticatedIdentity && + sameEndpointIdentity(exactIncarnation.identity, this.lastAuthenticatedIdentity) + ) { + this.exactDaemonIncarnation = exactIncarnation + } + } + + private async readMatchingPidRecord( + identity: DaemonEndpointIdentity + ): Promise { if (!this.pidPath) { return null } try { - const parsed = parseDaemonPidFile(readFileSync(this.pidPath, 'utf8')) + const parsed = parseDaemonPidFile(await readFile(this.pidPath, 'utf8')) return parsed?.pid === identity.pid && parsed.startedAtMs === identity.startedAtMs && parsed.launchNonce === identity.launchNonce @@ -1679,10 +1709,14 @@ export class DaemonPtyAdapter implements IPtyProvider { additionalEvidenceSources: readonly DaemonEvidenceSource[] = [], endpointGoneProof?: 'windows_named_pipe_missing' ): void { - void classifyDaemonAuditFailure(this.auditContext, trigger, exactIncarnation, { - additionalEvidenceSources, - endpointGoneProof - }) + void this.resolveExactDaemonIncarnation(exactIncarnation) + .then((resolvedIncarnation) => { + this.cacheExactDaemonIncarnation(resolvedIncarnation) + return classifyDaemonAuditFailure(this.auditContext, trigger, resolvedIncarnation, { + additionalEvidenceSources, + endpointGoneProof + }) + }) .then((observation) => this.publishAuditObservation(observation)) .catch(() => {}) } @@ -2303,6 +2337,37 @@ function sameEndpointIdentity( ) } +function exactDaemonIncarnationForPidRecord( + identity: DaemonEndpointIdentity, + pidRecord: ParsedDaemonPid | null +): ExactDaemonIncarnation { + return { + identity: { ...identity }, + ...(process.platform === 'linux' && + pidRecord?.pid === identity.pid && + pidRecord.startedAtMs === identity.startedAtMs && + pidRecord.launchNonce === identity.launchNonce && + pidRecord.linuxStartTicks && + pidRecord.bootId + ? { + linuxStartTicks: pidRecord.linuxStartTicks, + bootId: pidRecord.bootId + } + : {}) + } +} + +function readDaemonPidRecord(pidPath: string | null): ParsedDaemonPid | null { + if (!pidPath) { + return null + } + try { + return parseDaemonPidFile(readFileSync(pidPath, 'utf8')) + } catch { + return null + } +} + function removeListener(listeners: T[], listener: T): void { const index = listeners.indexOf(listener) if (index !== -1) { diff --git a/src/shared/daemon-audit-eligibility.ts b/src/shared/daemon-audit-eligibility.ts index 047f95e0665..5e3bde6fcd0 100644 --- a/src/shared/daemon-audit-eligibility.ts +++ b/src/shared/daemon-audit-eligibility.ts @@ -1,7 +1,6 @@ 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', @@ -57,7 +56,6 @@ export const DAEMON_AUDIT_GONE_REASON_VALUES = [ export const DAEMON_AUDIT_REASON_VALUES = [ 'authenticated_inventory', - 'endpoint_identity_changed', 'inventory_failed', 'token_missing_after_authenticated_disconnect', 'transport_closed',