Add audit-only daemon incarnation evidence (#11606)
* feat(daemon): add audit incarnation evidence * fix(daemon): isolate audit evidence observers
This commit is contained in:
parent
5fe3aaf2b7
commit
d4cfee76be
|
|
@ -15,14 +15,14 @@ function hasCode(error: unknown, code: string): boolean {
|
|||
return error instanceof Error && 'code' in error && error.code === code
|
||||
}
|
||||
|
||||
function parseLinuxStartTicks(statLine: string): string | null {
|
||||
const commandEnd = statLine.lastIndexOf(') ')
|
||||
export function parseLinuxStartTicks(statLine: string): string | null {
|
||||
const commandEnd = statLine.lastIndexOf(')')
|
||||
if (commandEnd < 0) {
|
||||
return null
|
||||
}
|
||||
// Field 22 is index 19 after removing pid and the parenthesized command.
|
||||
const startTicks = statLine
|
||||
.slice(commandEnd + 2)
|
||||
.slice(commandEnd + 1)
|
||||
.trim()
|
||||
.split(/\s+/)[19]
|
||||
return startTicks ?? null
|
||||
|
|
@ -115,7 +115,7 @@ async function readHostIdentity(): Promise<string> {
|
|||
return runtimeHostIdentity
|
||||
}
|
||||
|
||||
async function readBootIdentity(): Promise<string | undefined> {
|
||||
export async function readBootIdentity(): Promise<string | undefined> {
|
||||
if (process.platform === 'linux') {
|
||||
try {
|
||||
const bootId = (await readFile('/proc/sys/kernel/random/boot_id', 'utf8')).trim()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,219 @@
|
|||
import { lstat } from 'node:fs/promises'
|
||||
import {
|
||||
probeDaemonProcessIdentity,
|
||||
type DaemonEvidenceSource,
|
||||
type DaemonEvidenceSources,
|
||||
type DaemonProcessEvidence,
|
||||
type ExactDaemonIncarnation
|
||||
} from './daemon-incarnation-evidence'
|
||||
import type {
|
||||
DaemonAuditFailureTrigger,
|
||||
DaemonAuditGoneReason
|
||||
} from '../../shared/daemon-audit-eligibility'
|
||||
|
||||
export type { DaemonAuditTrigger } from '../../shared/daemon-audit-eligibility'
|
||||
|
||||
export type DaemonAuditContext = {
|
||||
protocolGeneration: number
|
||||
provider: 'local-daemon'
|
||||
endpoint: string
|
||||
tokenPath: string
|
||||
endpointKind: 'unix-socket' | 'windows-named-pipe'
|
||||
profileScope: string
|
||||
}
|
||||
|
||||
export type DaemonAuditObservation =
|
||||
| {
|
||||
state: 'present'
|
||||
reason: 'authenticated_inventory'
|
||||
trigger: 'inventory_answered'
|
||||
evidenceSources: DaemonEvidenceSources
|
||||
context: DaemonAuditContext
|
||||
exactIncarnation: ExactDaemonIncarnation | null
|
||||
reachability: 'authenticated'
|
||||
inventoryAuthority: 'authoritative'
|
||||
processLiveness: 'unknown'
|
||||
processReason: null
|
||||
endpointState: DaemonEndpointState
|
||||
observedAtMs: number
|
||||
}
|
||||
| {
|
||||
state: 'gone'
|
||||
reason: DaemonAuditGoneReason
|
||||
trigger: DaemonAuditFailureTrigger
|
||||
evidenceSources: DaemonEvidenceSources
|
||||
context: DaemonAuditContext
|
||||
exactIncarnation: ExactDaemonIncarnation
|
||||
reachability: 'authenticated' | 'disconnected' | 'unknown'
|
||||
inventoryAuthority: 'unavailable'
|
||||
processLiveness: 'gone'
|
||||
processReason: DaemonAuditGoneReason
|
||||
endpointState: DaemonEndpointState
|
||||
observedAtMs: number
|
||||
}
|
||||
| {
|
||||
state: 'unknown'
|
||||
reason: DaemonAuditFailureTrigger
|
||||
trigger: DaemonAuditFailureTrigger
|
||||
evidenceSources: DaemonEvidenceSources
|
||||
context: DaemonAuditContext
|
||||
exactIncarnation: ExactDaemonIncarnation | null
|
||||
reachability: 'authenticated' | 'disconnected' | 'unknown'
|
||||
inventoryAuthority: 'unavailable'
|
||||
processLiveness: 'present' | 'unknown'
|
||||
processReason:
|
||||
| Extract<DaemonProcessEvidence, { state: 'present' | 'unknown' }>['reason']
|
||||
| null
|
||||
endpointState: DaemonEndpointState
|
||||
observedAtMs: number
|
||||
}
|
||||
|
||||
export type DaemonEndpointState = 'missing' | 'named-pipe' | 'non-socket' | 'socket' | 'unknown'
|
||||
|
||||
export type DaemonAuditClassifierDependencies = {
|
||||
probeProcessIdentity?: typeof probeDaemonProcessIdentity
|
||||
inspectEndpointState?: (context: DaemonAuditContext) => Promise<DaemonEndpointState>
|
||||
}
|
||||
|
||||
export type DaemonAuditClassificationOptions = {
|
||||
additionalEvidenceSources?: readonly DaemonEvidenceSource[]
|
||||
endpointGoneProof?: 'windows_named_pipe_missing'
|
||||
dependencies?: DaemonAuditClassifierDependencies
|
||||
}
|
||||
|
||||
export function recordAuthenticatedInventory(
|
||||
context: DaemonAuditContext,
|
||||
exactIncarnation: ExactDaemonIncarnation | null
|
||||
): DaemonAuditObservation {
|
||||
return {
|
||||
state: 'present',
|
||||
reason: 'authenticated_inventory',
|
||||
trigger: 'inventory_answered',
|
||||
evidenceSources: ['authenticated_inventory'],
|
||||
context,
|
||||
exactIncarnation,
|
||||
reachability: 'authenticated',
|
||||
inventoryAuthority: 'authoritative',
|
||||
processLiveness: 'unknown',
|
||||
processReason: null,
|
||||
endpointState: context.endpointKind === 'windows-named-pipe' ? 'named-pipe' : 'socket',
|
||||
observedAtMs: Date.now()
|
||||
}
|
||||
}
|
||||
|
||||
export async function classifyDaemonAuditFailure(
|
||||
context: DaemonAuditContext,
|
||||
trigger: DaemonAuditFailureTrigger,
|
||||
exactIncarnation: ExactDaemonIncarnation | null,
|
||||
options: DaemonAuditClassificationOptions = {}
|
||||
): Promise<DaemonAuditObservation> {
|
||||
const probeProcessIdentity =
|
||||
options.dependencies?.probeProcessIdentity ?? probeDaemonProcessIdentity
|
||||
const inspectEndpoint = options.dependencies?.inspectEndpointState ?? inspectDaemonEndpointState
|
||||
const [processEvidence, endpointState] = await Promise.all([
|
||||
probeProcessIdentity(exactIncarnation, {
|
||||
socketPath: context.endpoint,
|
||||
tokenPath: context.tokenPath
|
||||
}),
|
||||
inspectEndpoint(context)
|
||||
])
|
||||
const reachability = reachabilityForTrigger(trigger)
|
||||
const evidenceSources = combineEvidenceSources(
|
||||
processEvidence.evidenceSources,
|
||||
context.endpointKind === 'unix-socket' ? ['endpoint_stat'] : [],
|
||||
options.additionalEvidenceSources ?? []
|
||||
)
|
||||
if (
|
||||
options.endpointGoneProof &&
|
||||
context.endpointKind === 'windows-named-pipe' &&
|
||||
exactIncarnation
|
||||
) {
|
||||
return {
|
||||
state: 'gone',
|
||||
reason: options.endpointGoneProof,
|
||||
trigger,
|
||||
evidenceSources,
|
||||
context,
|
||||
exactIncarnation,
|
||||
reachability,
|
||||
inventoryAuthority: 'unavailable',
|
||||
processLiveness: 'gone',
|
||||
processReason: options.endpointGoneProof,
|
||||
endpointState: 'missing',
|
||||
observedAtMs: Date.now()
|
||||
}
|
||||
}
|
||||
if (processEvidence.state === 'gone') {
|
||||
return {
|
||||
state: 'gone',
|
||||
reason: processEvidence.reason,
|
||||
trigger,
|
||||
evidenceSources,
|
||||
context,
|
||||
exactIncarnation: processEvidence.exactIncarnation,
|
||||
reachability,
|
||||
inventoryAuthority: 'unavailable',
|
||||
processLiveness: 'gone',
|
||||
processReason: processEvidence.reason,
|
||||
endpointState,
|
||||
observedAtMs: Date.now()
|
||||
}
|
||||
}
|
||||
return {
|
||||
state: 'unknown',
|
||||
reason: trigger,
|
||||
trigger,
|
||||
evidenceSources,
|
||||
context,
|
||||
exactIncarnation,
|
||||
reachability,
|
||||
inventoryAuthority: 'unavailable',
|
||||
processLiveness: processEvidence.state,
|
||||
processReason: processEvidence.reason,
|
||||
endpointState,
|
||||
observedAtMs: Date.now()
|
||||
}
|
||||
}
|
||||
|
||||
async function inspectDaemonEndpointState(
|
||||
context: DaemonAuditContext
|
||||
): Promise<DaemonEndpointState> {
|
||||
if (context.endpointKind === 'windows-named-pipe') {
|
||||
return 'named-pipe'
|
||||
}
|
||||
try {
|
||||
const stats = await lstat(context.endpoint)
|
||||
return stats.isSocket() ? 'socket' : 'non-socket'
|
||||
} catch (error) {
|
||||
return hasErrorCode(error, 'ENOENT') ? 'missing' : 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
function reachabilityForTrigger(
|
||||
trigger: DaemonAuditFailureTrigger
|
||||
): 'authenticated' | 'disconnected' | 'unknown' {
|
||||
if (trigger === 'endpoint_identity_changed') {
|
||||
return 'authenticated'
|
||||
}
|
||||
if (
|
||||
trigger === 'transport_closed' ||
|
||||
trigger === 'token_missing_after_authenticated_disconnect'
|
||||
) {
|
||||
return 'disconnected'
|
||||
}
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
function combineEvidenceSources(
|
||||
first: DaemonEvidenceSources,
|
||||
...rest: readonly (readonly DaemonEvidenceSource[])[]
|
||||
): DaemonEvidenceSources {
|
||||
return [...new Set([first[0], ...first.slice(1), ...rest.flat()])] as [
|
||||
DaemonEvidenceSource,
|
||||
...DaemonEvidenceSource[]
|
||||
]
|
||||
}
|
||||
|
||||
function hasErrorCode(error: unknown, code: string): boolean {
|
||||
return typeof error === 'object' && error !== null && 'code' in error && error.code === code
|
||||
}
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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'
|
||||
}
|
||||
|
|
@ -19,6 +19,7 @@ import {
|
|||
} from '../providers/macos-tcc-login-shell'
|
||||
import { MacosLoginSessionDeathWatch } from './macos-login-session-death-watch'
|
||||
import { readCurrentProcessMacSystemResolverHealth } from '../network/macos-system-resolver-health'
|
||||
import { readCurrentDaemonReadyIdentity } from './daemon-ready-identity'
|
||||
|
||||
export type ParsedDaemonArgs = {
|
||||
socketPath: string
|
||||
|
|
@ -255,9 +256,8 @@ async function main(): Promise<void> {
|
|||
|
||||
// Signal readiness to parent via IPC (if available)
|
||||
if (process.send) {
|
||||
// Why: Windows has no cheap OS query for a child's start time, so the
|
||||
// daemon self-reports it here for the pid file's pid-recycling guard.
|
||||
process.send({ type: 'ready', startedAtMs })
|
||||
const readyIdentity = await readCurrentDaemonReadyIdentity(startedAtMs)
|
||||
process.send({ type: 'ready', ...readyIdentity })
|
||||
}
|
||||
daemonLog.log('ready')
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -40,11 +40,14 @@ const WIN32_START_TIME_TOLERANCE_MS = 10_000
|
|||
// also covers a live-but-wedged daemon that simply missed the RPC budget.
|
||||
export type DaemonHealth = 'healthy' | 'unreachable' | 'rejected' | 'pty-spawn-unhealthy'
|
||||
|
||||
type ParsedDaemonPid = {
|
||||
export type ParsedDaemonPid = {
|
||||
pid: number
|
||||
startedAtMs: number | null
|
||||
entryPath: string | null
|
||||
appVersion: string | null
|
||||
launchNonce: string | null
|
||||
linuxStartTicks: string | null
|
||||
bootId: string | null
|
||||
}
|
||||
|
||||
function canConnectSocket(socketPath: string): Promise<boolean> {
|
||||
|
|
@ -301,7 +304,7 @@ export function getMacDaemonSystemResolverHealth(
|
|||
})
|
||||
}
|
||||
|
||||
function commandLineMatchesDaemon(
|
||||
export function commandLineMatchesDaemon(
|
||||
commandLine: string,
|
||||
socketPath: string,
|
||||
tokenPath: string
|
||||
|
|
@ -321,6 +324,9 @@ export function parseDaemonPidFile(contents: string): ParsedDaemonPid | null {
|
|||
startedAtMs?: unknown
|
||||
entryPath?: unknown
|
||||
appVersion?: unknown
|
||||
launchNonce?: unknown
|
||||
linuxStartTicks?: unknown
|
||||
bootId?: unknown
|
||||
}
|
||||
if (typeof parsed.pid === 'number' && Number.isFinite(parsed.pid)) {
|
||||
return {
|
||||
|
|
@ -330,7 +336,10 @@ export function parseDaemonPidFile(contents: string): ParsedDaemonPid | null {
|
|||
? parsed.startedAtMs
|
||||
: null,
|
||||
entryPath: typeof parsed.entryPath === 'string' ? parsed.entryPath : null,
|
||||
appVersion: typeof parsed.appVersion === 'string' ? parsed.appVersion : null
|
||||
appVersion: typeof parsed.appVersion === 'string' ? parsed.appVersion : null,
|
||||
launchNonce: typeof parsed.launchNonce === 'string' ? parsed.launchNonce : null,
|
||||
linuxStartTicks: typeof parsed.linuxStartTicks === 'string' ? parsed.linuxStartTicks : null,
|
||||
bootId: typeof parsed.bootId === 'string' ? parsed.bootId : null
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
|
|
@ -338,7 +347,17 @@ export function parseDaemonPidFile(contents: string): ParsedDaemonPid | null {
|
|||
}
|
||||
|
||||
const pid = Number(trimmed)
|
||||
return Number.isFinite(pid) ? { pid, startedAtMs: null, entryPath: null, appVersion: null } : null
|
||||
return Number.isFinite(pid)
|
||||
? {
|
||||
pid,
|
||||
startedAtMs: null,
|
||||
entryPath: null,
|
||||
appVersion: null,
|
||||
launchNonce: null,
|
||||
linuxStartTicks: null,
|
||||
bootId: null
|
||||
}
|
||||
: null
|
||||
}
|
||||
|
||||
function getLinuxProcessStartedAtMs(pid: number): number | null {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,75 @@
|
|||
import type { DaemonEndpointIdentity } from './daemon-hello-protocol'
|
||||
import type {
|
||||
DaemonAuditGoneReason,
|
||||
DaemonEvidenceSource,
|
||||
DaemonProcessGoneReason,
|
||||
DaemonProcessPresentReason,
|
||||
DaemonProcessUnknownReason
|
||||
} from '../../shared/daemon-audit-eligibility'
|
||||
|
||||
export const WINDOWS_CREATION_TIME_TOLERANCE_MS = 10_000
|
||||
|
||||
// Oracle contract validated 2026-07-29; omitted proofs remain unknown.
|
||||
export const DAEMON_GONE_PROOFS = {
|
||||
linux: ['pid_missing', 'linux_boot_changed', 'linux_start_ticks_mismatch', 'linux_zombie'],
|
||||
darwin: ['pid_missing'],
|
||||
win32: ['windows_process_missing', 'windows_creation_time_mismatch', 'windows_named_pipe_missing']
|
||||
} as const satisfies Readonly<
|
||||
Record<'linux' | 'darwin' | 'win32', readonly DaemonAuditGoneReason[]>
|
||||
>
|
||||
|
||||
export type {
|
||||
DaemonAuditGoneReason,
|
||||
DaemonEvidenceSource,
|
||||
DaemonProcessGoneReason,
|
||||
DaemonProcessPresentReason,
|
||||
DaemonProcessUnknownReason
|
||||
} from '../../shared/daemon-audit-eligibility'
|
||||
|
||||
export type DaemonEvidenceSources = readonly [DaemonEvidenceSource, ...DaemonEvidenceSource[]]
|
||||
|
||||
export type ExactDaemonIncarnation = {
|
||||
identity: DaemonEndpointIdentity
|
||||
linuxStartTicks?: string
|
||||
bootId?: string
|
||||
}
|
||||
|
||||
export type DaemonProcessEvidence =
|
||||
| {
|
||||
state: 'present'
|
||||
reason: DaemonProcessPresentReason
|
||||
evidenceSources: DaemonEvidenceSources
|
||||
}
|
||||
| {
|
||||
state: 'gone'
|
||||
reason: DaemonProcessGoneReason
|
||||
evidenceSources: DaemonEvidenceSources
|
||||
exactIncarnation: ExactDaemonIncarnation
|
||||
}
|
||||
| {
|
||||
state: 'unknown'
|
||||
reason: DaemonProcessUnknownReason
|
||||
evidenceSources: DaemonEvidenceSources
|
||||
}
|
||||
|
||||
export type ProcessSignalEvidence = 'occupied' | 'permission_denied' | 'missing' | 'unavailable'
|
||||
|
||||
export type LinuxStatEvidence =
|
||||
| { status: 'present'; value: string }
|
||||
| { status: 'missing' }
|
||||
| { status: 'unavailable' }
|
||||
|
||||
export type WindowsProcessEvidence =
|
||||
| { status: 'present'; commandLine: string | null; startedAtMs: number | null }
|
||||
| { status: 'missing' }
|
||||
| { status: 'unavailable' }
|
||||
|
||||
export type DaemonProcessProbeDependencies = {
|
||||
platform?: NodeJS.Platform
|
||||
signalProcess?: (pid: number) => ProcessSignalEvidence
|
||||
readLinuxStat?: (pid: number) => Promise<LinuxStatEvidence>
|
||||
readBootIdentity?: () => Promise<string | undefined>
|
||||
readCommandLine?: (pid: number, platform: NodeJS.Platform) => Promise<string | undefined>
|
||||
readProcessStartedAtMs?: (pid: number) => number | null
|
||||
queryWindowsProcess?: (pid: number) => Promise<WindowsProcessEvidence>
|
||||
}
|
||||
|
|
@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,267 @@
|
|||
import { parseLinuxStartTicks, readBootIdentity } from '../agent-hooks/managed-hook-owner-identity'
|
||||
import {
|
||||
commandLineMatchesDaemon,
|
||||
getProcessStartedAtMs,
|
||||
startTimesWithinTolerance
|
||||
} from './daemon-health'
|
||||
import {
|
||||
WINDOWS_CREATION_TIME_TOLERANCE_MS,
|
||||
type DaemonEvidenceSources,
|
||||
type DaemonProcessEvidence,
|
||||
type DaemonProcessProbeDependencies,
|
||||
type ExactDaemonIncarnation,
|
||||
type ProcessSignalEvidence
|
||||
} from './daemon-incarnation-evidence-types'
|
||||
import {
|
||||
inspectProcessSignal,
|
||||
queryWindowsProcess,
|
||||
readLinuxStat,
|
||||
readProcessCommandLine
|
||||
} from './daemon-process-inspection'
|
||||
|
||||
export {
|
||||
DAEMON_GONE_PROOFS,
|
||||
WINDOWS_CREATION_TIME_TOLERANCE_MS,
|
||||
type DaemonEvidenceSource,
|
||||
type DaemonEvidenceSources,
|
||||
type DaemonProcessEvidence,
|
||||
type DaemonProcessProbeDependencies,
|
||||
type ExactDaemonIncarnation,
|
||||
type LinuxStatEvidence,
|
||||
type ProcessSignalEvidence,
|
||||
type WindowsProcessEvidence
|
||||
} from './daemon-incarnation-evidence-types'
|
||||
|
||||
const POSIX_START_TIME_TOLERANCE_MS = 1_500
|
||||
|
||||
export async function probeDaemonProcessIdentity(
|
||||
exactIncarnation: ExactDaemonIncarnation | null,
|
||||
endpoint: { socketPath: string; tokenPath: string },
|
||||
dependencies: DaemonProcessProbeDependencies = {}
|
||||
): Promise<DaemonProcessEvidence> {
|
||||
if (!exactIncarnation) {
|
||||
return unknown('exact_identity_unavailable', ['pid_record'])
|
||||
}
|
||||
const platform = dependencies.platform ?? process.platform
|
||||
const signalProcess = dependencies.signalProcess ?? inspectProcessSignal
|
||||
const signal = signalProcess(exactIncarnation.identity.pid)
|
||||
if (platform !== 'win32' && signal === 'missing') {
|
||||
return gone('pid_missing', ['process_signal', 'endpoint_identity'], exactIncarnation)
|
||||
}
|
||||
|
||||
if (platform === 'linux') {
|
||||
return await probeLinuxProcess(exactIncarnation, endpoint, signal, dependencies)
|
||||
}
|
||||
if (platform === 'win32') {
|
||||
return await probeWindowsProcess(exactIncarnation, endpoint, signal, dependencies)
|
||||
}
|
||||
return await probeMacosProcess(exactIncarnation, endpoint, signal, dependencies)
|
||||
}
|
||||
|
||||
async function probeLinuxProcess(
|
||||
exactIncarnation: ExactDaemonIncarnation,
|
||||
endpoint: { socketPath: string; tokenPath: string },
|
||||
signal: ProcessSignalEvidence,
|
||||
dependencies: DaemonProcessProbeDependencies
|
||||
): Promise<DaemonProcessEvidence> {
|
||||
const stat = await (dependencies.readLinuxStat ?? readLinuxStat)(exactIncarnation.identity.pid)
|
||||
if (stat.status === 'missing') {
|
||||
return gone('pid_missing', ['linux_proc_stat', 'endpoint_identity'], exactIncarnation)
|
||||
}
|
||||
if (stat.status === 'unavailable') {
|
||||
return unknown(signal === 'permission_denied' ? 'permission_denied' : 'inspection_failed', [
|
||||
'linux_proc_stat',
|
||||
'process_signal'
|
||||
])
|
||||
}
|
||||
|
||||
const expectedTicks = exactIncarnation.linuxStartTicks
|
||||
const expectedBootId = exactIncarnation.bootId
|
||||
if (expectedTicks || expectedBootId) {
|
||||
if (!expectedTicks || !expectedBootId) {
|
||||
return unknown('linux_identity_incomplete', ['pid_record'])
|
||||
}
|
||||
const bootId = await (dependencies.readBootIdentity ?? readBootIdentity)()
|
||||
if (!bootId) {
|
||||
return unknown('inspection_failed', ['boot_identity'])
|
||||
}
|
||||
if (bootId !== expectedBootId) {
|
||||
return gone(
|
||||
'linux_boot_changed',
|
||||
['boot_identity', 'pid_record', 'endpoint_identity'],
|
||||
exactIncarnation
|
||||
)
|
||||
}
|
||||
const currentTicks = parseLinuxStartTicks(stat.value)
|
||||
if (!currentTicks) {
|
||||
return unknown('inspection_failed', ['linux_proc_stat'])
|
||||
}
|
||||
if (currentTicks !== expectedTicks) {
|
||||
return gone(
|
||||
'linux_start_ticks_mismatch',
|
||||
['linux_proc_stat', 'boot_identity', 'pid_record'],
|
||||
exactIncarnation
|
||||
)
|
||||
}
|
||||
if (parseLinuxProcessState(stat.value) === 'Z') {
|
||||
return gone(
|
||||
'linux_zombie',
|
||||
['linux_proc_stat', 'boot_identity', 'pid_record'],
|
||||
exactIncarnation
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const commandLine = await (dependencies.readCommandLine ?? readProcessCommandLine)(
|
||||
exactIncarnation.identity.pid,
|
||||
'linux'
|
||||
)
|
||||
if (commandLine === undefined) {
|
||||
return unknown('command_line_unavailable', ['process_command_line'])
|
||||
}
|
||||
if (!commandLineMatchesDaemon(commandLine, endpoint.socketPath, endpoint.tokenPath)) {
|
||||
return unknown('command_line_mismatch', ['process_command_line'])
|
||||
}
|
||||
if (expectedTicks && expectedBootId) {
|
||||
return present('linux_identity_match', [
|
||||
'linux_proc_stat',
|
||||
'boot_identity',
|
||||
'process_command_line',
|
||||
'endpoint_identity'
|
||||
])
|
||||
}
|
||||
|
||||
const startedAtMs = (dependencies.readProcessStartedAtMs ?? getProcessStartedAtMs)(
|
||||
exactIncarnation.identity.pid
|
||||
)
|
||||
if (startedAtMs === null) {
|
||||
return unknown('process_start_time_unavailable', ['process_start_time'])
|
||||
}
|
||||
return startTimesWithinTolerance(
|
||||
startedAtMs,
|
||||
exactIncarnation.identity.startedAtMs,
|
||||
POSIX_START_TIME_TOLERANCE_MS
|
||||
)
|
||||
? present('linux_identity_match', [
|
||||
'process_command_line',
|
||||
'process_start_time',
|
||||
'endpoint_identity'
|
||||
])
|
||||
: unknown('linux_identity_incomplete', ['process_start_time', 'endpoint_identity'])
|
||||
}
|
||||
|
||||
async function probeMacosProcess(
|
||||
exactIncarnation: ExactDaemonIncarnation,
|
||||
endpoint: { socketPath: string; tokenPath: string },
|
||||
signal: ProcessSignalEvidence,
|
||||
dependencies: DaemonProcessProbeDependencies
|
||||
): Promise<DaemonProcessEvidence> {
|
||||
const commandLine = await (dependencies.readCommandLine ?? readProcessCommandLine)(
|
||||
exactIncarnation.identity.pid,
|
||||
'darwin'
|
||||
)
|
||||
if (commandLine === undefined) {
|
||||
return unknown(
|
||||
signal === 'permission_denied' ? 'permission_denied' : 'command_line_unavailable',
|
||||
['process_command_line', 'process_signal']
|
||||
)
|
||||
}
|
||||
if (!commandLineMatchesDaemon(commandLine, endpoint.socketPath, endpoint.tokenPath)) {
|
||||
return unknown('command_line_mismatch', ['process_command_line'])
|
||||
}
|
||||
const startedAtMs = (dependencies.readProcessStartedAtMs ?? getProcessStartedAtMs)(
|
||||
exactIncarnation.identity.pid
|
||||
)
|
||||
if (startedAtMs === null) {
|
||||
return unknown('process_start_time_unavailable', ['process_start_time'])
|
||||
}
|
||||
return startTimesWithinTolerance(
|
||||
startedAtMs,
|
||||
exactIncarnation.identity.startedAtMs,
|
||||
POSIX_START_TIME_TOLERANCE_MS
|
||||
)
|
||||
? present('macos_identity_match', [
|
||||
'process_command_line',
|
||||
'process_start_time',
|
||||
'endpoint_identity'
|
||||
])
|
||||
: unknown('macos_start_time_mismatch', ['process_start_time', 'endpoint_identity'])
|
||||
}
|
||||
|
||||
async function probeWindowsProcess(
|
||||
exactIncarnation: ExactDaemonIncarnation,
|
||||
endpoint: { socketPath: string; tokenPath: string },
|
||||
signal: ProcessSignalEvidence,
|
||||
dependencies: DaemonProcessProbeDependencies
|
||||
): Promise<DaemonProcessEvidence> {
|
||||
const identity = await (dependencies.queryWindowsProcess ?? queryWindowsProcess)(
|
||||
exactIncarnation.identity.pid
|
||||
)
|
||||
if (identity.status === 'missing') {
|
||||
return gone('windows_process_missing', ['windows_cim', 'endpoint_identity'], exactIncarnation)
|
||||
}
|
||||
if (identity.status === 'unavailable') {
|
||||
return unknown(signal === 'permission_denied' ? 'permission_denied' : 'inspection_failed', [
|
||||
'windows_cim',
|
||||
'process_signal'
|
||||
])
|
||||
}
|
||||
if (identity.startedAtMs === null) {
|
||||
return unknown('windows_process_start_time_unavailable', ['windows_cim'])
|
||||
}
|
||||
if (
|
||||
!startTimesWithinTolerance(
|
||||
identity.startedAtMs,
|
||||
exactIncarnation.identity.startedAtMs,
|
||||
WINDOWS_CREATION_TIME_TOLERANCE_MS
|
||||
)
|
||||
) {
|
||||
return gone(
|
||||
'windows_creation_time_mismatch',
|
||||
['windows_cim', 'endpoint_identity'],
|
||||
exactIncarnation
|
||||
)
|
||||
}
|
||||
return present(
|
||||
'windows_identity_match',
|
||||
identity.commandLine &&
|
||||
commandLineMatchesDaemon(identity.commandLine, endpoint.socketPath, endpoint.tokenPath)
|
||||
? ['windows_cim', 'process_command_line', 'endpoint_identity']
|
||||
: ['windows_cim', 'endpoint_identity']
|
||||
)
|
||||
}
|
||||
|
||||
export function parseLinuxProcessState(statLine: string): string | null {
|
||||
const commandEnd = statLine.lastIndexOf(')')
|
||||
if (commandEnd < 0) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
statLine
|
||||
.slice(commandEnd + 1)
|
||||
.trim()
|
||||
.split(/\s+/, 1)[0] ?? null
|
||||
)
|
||||
}
|
||||
|
||||
function present(
|
||||
reason: Extract<DaemonProcessEvidence, { state: 'present' }>['reason'],
|
||||
evidenceSources: DaemonEvidenceSources
|
||||
): DaemonProcessEvidence {
|
||||
return { state: 'present', reason, evidenceSources }
|
||||
}
|
||||
|
||||
function gone(
|
||||
reason: Extract<DaemonProcessEvidence, { state: 'gone' }>['reason'],
|
||||
evidenceSources: DaemonEvidenceSources,
|
||||
exactIncarnation: ExactDaemonIncarnation
|
||||
): DaemonProcessEvidence {
|
||||
return { state: 'gone', reason, evidenceSources, exactIncarnation }
|
||||
}
|
||||
|
||||
function unknown(
|
||||
reason: Extract<DaemonProcessEvidence, { state: 'unknown' }>['reason'],
|
||||
evidenceSources: DaemonEvidenceSources
|
||||
): DaemonProcessEvidence {
|
||||
return { state: 'unknown', reason, evidenceSources }
|
||||
}
|
||||
|
|
@ -241,7 +241,10 @@ vi.mock('fs', () => ({
|
|||
writeFileSync: writeFileSyncMock
|
||||
}))
|
||||
|
||||
vi.mock('child_process', () => ({ fork: forkMock }))
|
||||
vi.mock('child_process', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
fork: forkMock
|
||||
}))
|
||||
|
||||
vi.mock('net', () => ({ connect: netConnectMock }))
|
||||
|
||||
|
|
@ -1754,7 +1757,14 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
|
|||
on(event: string, cb: (arg?: unknown) => void) {
|
||||
handlers[event]?.push(cb)
|
||||
if (event === 'message') {
|
||||
queueMicrotask(() => cb({ type: 'ready', startedAtMs: 1_000_000 }))
|
||||
queueMicrotask(() =>
|
||||
cb({
|
||||
type: 'ready',
|
||||
startedAtMs: 1_000_000,
|
||||
linuxStartTicks: '4242',
|
||||
bootId: 'boot-a'
|
||||
})
|
||||
)
|
||||
}
|
||||
return this
|
||||
},
|
||||
|
|
@ -1782,6 +1792,8 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
|
|||
expect(JSON.parse(pidContents as string)).toEqual({
|
||||
pid: 12345,
|
||||
startedAtMs: 1_000_000,
|
||||
linuxStartTicks: '4242',
|
||||
bootId: 'boot-a',
|
||||
entryPath: FAKE_DAEMON_ENTRY_PATH,
|
||||
appVersion: '1.2.3',
|
||||
launchNonce: expect.stringMatching(/^[0-9a-f-]{36}$/)
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ import {
|
|||
confirmSeededClaudeLivePtys,
|
||||
hasSeededUnconfirmedClaudePtys
|
||||
} from '../claude-accounts/live-pty-gate'
|
||||
import { parseDaemonReadyIdentity } from './daemon-ready-identity'
|
||||
|
||||
// Why: daemon init runs concurrent with window load, so an in-process t timestamp (not harness stderr timing) measures cold-start.
|
||||
function logDaemonMilestone(event: string, details: Record<string, unknown> = {}): void {
|
||||
|
|
@ -600,14 +601,8 @@ function createOutOfProcessLauncher(
|
|||
if (settled) {
|
||||
return
|
||||
}
|
||||
const selfReported = (msg as { startedAtMs?: unknown }).startedAtMs
|
||||
if (
|
||||
!Number.isSafeInteger(child.pid) ||
|
||||
(child.pid as number) <= 0 ||
|
||||
typeof selfReported !== 'number' ||
|
||||
!Number.isFinite(selfReported) ||
|
||||
selfReported <= 0
|
||||
) {
|
||||
const readyIdentity = parseDaemonReadyIdentity(msg)
|
||||
if (!Number.isSafeInteger(child.pid) || (child.pid as number) <= 0 || !readyIdentity) {
|
||||
void fail(new Error('Daemon readiness identity is incomplete'))
|
||||
return
|
||||
}
|
||||
|
|
@ -617,7 +612,7 @@ function createOutOfProcessLauncher(
|
|||
pidPath,
|
||||
serializeDaemonPidFile({
|
||||
pid: child.pid as number,
|
||||
startedAtMs: selfReported,
|
||||
...readyIdentity,
|
||||
entryPath,
|
||||
appVersion: app.getVersion(),
|
||||
launchNonce
|
||||
|
|
@ -718,7 +713,9 @@ export async function initDaemonPtyProvider(
|
|||
// Why: fail-open may already have spawned fallback PTYs; don't install late, but retire an empty daemon (live sessions reject it and survive).
|
||||
const abortedStartupAdapter = new DaemonPtyAdapter({
|
||||
socketPath: info.socketPath,
|
||||
tokenPath: info.tokenPath
|
||||
tokenPath: info.tokenPath,
|
||||
pidPath: getDaemonPidPath(runtimeDir),
|
||||
profileScope: runtimeDir
|
||||
})
|
||||
releaseDaemonAdoptionLease(newSpawner.getHandle())
|
||||
await abortedStartupAdapter.disconnectOnly()
|
||||
|
|
@ -728,6 +725,8 @@ export async function initDaemonPtyProvider(
|
|||
const newAdapter = new DaemonPtyAdapter({
|
||||
socketPath: info.socketPath,
|
||||
tokenPath: info.tokenPath,
|
||||
pidPath: getDaemonPidPath(runtimeDir),
|
||||
profileScope: runtimeDir,
|
||||
historyPath: getHistoryDir(),
|
||||
// Why: on daemon death, ensureConnected() detects the dead socket and calls this to fork a replacement before retrying.
|
||||
respawn: async (reason: DaemonRespawnReason) => {
|
||||
|
|
@ -925,6 +924,8 @@ async function runRestartDaemon(): Promise<RestartDaemonResult> {
|
|||
const newCurrent = new DaemonPtyAdapter({
|
||||
socketPath: info.socketPath,
|
||||
tokenPath: info.tokenPath,
|
||||
pidPath: getDaemonPidPath(runtimeDir),
|
||||
profileScope: runtimeDir,
|
||||
historyPath: getHistoryDir(),
|
||||
respawn: async (reason: DaemonRespawnReason) => {
|
||||
// Why: attribute rather than emit — the launcher below is the one that completes the
|
||||
|
|
@ -1155,6 +1156,8 @@ export async function createLegacyDaemonAdapters(
|
|||
new DaemonPtyAdapter({
|
||||
socketPath,
|
||||
tokenPath,
|
||||
pidPath: getDaemonPidPath(runtimeDir, protocolVersion),
|
||||
profileScope: runtimeDir,
|
||||
protocolVersion,
|
||||
historyPath
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
import { execFile } from 'node:child_process'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { promisify } from 'node:util'
|
||||
import type {
|
||||
LinuxStatEvidence,
|
||||
ProcessSignalEvidence,
|
||||
WindowsProcessEvidence
|
||||
} from './daemon-incarnation-evidence-types'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
export type DaemonProcessInspectionDependencies = {
|
||||
readTextFile?: (path: string) => Promise<string>
|
||||
runCommand?: (file: string, args: string[], timeoutMs: number) => Promise<string>
|
||||
}
|
||||
|
||||
export function inspectProcessSignal(pid: number): ProcessSignalEvidence {
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return 'occupied'
|
||||
} catch (error) {
|
||||
if (hasErrorCode(error, 'ESRCH')) {
|
||||
return 'missing'
|
||||
}
|
||||
if (hasErrorCode(error, 'EPERM')) {
|
||||
return 'permission_denied'
|
||||
}
|
||||
return 'unavailable'
|
||||
}
|
||||
}
|
||||
|
||||
export async function readLinuxStat(pid: number): Promise<LinuxStatEvidence> {
|
||||
try {
|
||||
return { status: 'present', value: await readFile(`/proc/${pid}/stat`, 'utf8') }
|
||||
} catch (error) {
|
||||
return { status: hasErrorCode(error, 'ENOENT') ? 'missing' : 'unavailable' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function readProcessCommandLine(
|
||||
pid: number,
|
||||
platform: NodeJS.Platform,
|
||||
dependencies: DaemonProcessInspectionDependencies = {}
|
||||
): Promise<string | undefined> {
|
||||
const readTextFile =
|
||||
dependencies.readTextFile ?? (async (path: string) => await readFile(path, 'utf8'))
|
||||
const runCommand = dependencies.runCommand ?? runInspectionCommand
|
||||
if (platform === 'linux') {
|
||||
try {
|
||||
const procCommandLine = await readTextFile(`/proc/${pid}/cmdline`)
|
||||
if (procCommandLine.length > 0) {
|
||||
return procCommandLine
|
||||
}
|
||||
} catch {
|
||||
// Fall through to ps for procfs privilege or mount restrictions.
|
||||
}
|
||||
}
|
||||
try {
|
||||
const stdout = await runCommand('ps', ['-p', String(pid), '-o', 'command='], 2_000)
|
||||
return stdout.trim() || undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export async function queryWindowsProcess(
|
||||
pid: number,
|
||||
dependencies: DaemonProcessInspectionDependencies = {}
|
||||
): Promise<WindowsProcessEvidence> {
|
||||
if (!Number.isSafeInteger(pid) || pid <= 0) {
|
||||
return { status: 'unavailable' }
|
||||
}
|
||||
const runCommand = dependencies.runCommand ?? runInspectionCommand
|
||||
try {
|
||||
const stdout = await runCommand(
|
||||
'powershell.exe',
|
||||
[
|
||||
'-NoProfile',
|
||||
'-NonInteractive',
|
||||
'-Command',
|
||||
`$p = Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}"; ` +
|
||||
`if (!$p) { @{ exists = $false } | ConvertTo-Json -Compress } else { ` +
|
||||
`$start = $null; if ($p.CreationDate) { ` +
|
||||
`$start = [long]([DateTimeOffset]$p.CreationDate).ToUnixTimeMilliseconds() }; ` +
|
||||
`@{ exists = $true; cmd = $p.CommandLine; start = $start } | ConvertTo-Json -Compress }`
|
||||
],
|
||||
3_000
|
||||
)
|
||||
const parsed = JSON.parse(stdout.trim()) as {
|
||||
exists?: unknown
|
||||
cmd?: unknown
|
||||
start?: unknown
|
||||
}
|
||||
if (parsed.exists === false) {
|
||||
return { status: 'missing' }
|
||||
}
|
||||
if (parsed.exists !== true) {
|
||||
return { status: 'unavailable' }
|
||||
}
|
||||
return {
|
||||
status: 'present',
|
||||
commandLine: typeof parsed.cmd === 'string' && parsed.cmd ? parsed.cmd : null,
|
||||
startedAtMs:
|
||||
typeof parsed.start === 'number' && Number.isFinite(parsed.start) ? parsed.start : null
|
||||
}
|
||||
} catch {
|
||||
return { status: 'unavailable' }
|
||||
}
|
||||
}
|
||||
|
||||
async function runInspectionCommand(
|
||||
file: string,
|
||||
args: string[],
|
||||
timeoutMs: number
|
||||
): Promise<string> {
|
||||
const { stdout } = await execFileAsync(file, args, { encoding: 'utf8', timeout: timeoutMs })
|
||||
return stdout
|
||||
}
|
||||
|
||||
function hasErrorCode(error: unknown, code: string): boolean {
|
||||
return typeof error === 'object' && error !== null && 'code' in error && error.code === code
|
||||
}
|
||||
|
|
@ -1449,6 +1449,11 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
|
|||
expect(procs[0]).toHaveProperty('title')
|
||||
expect(procs[0].cwd).toBe('/repo/owned-before-osc7')
|
||||
expect(procs[0].worktreeId).toBe('repo::/repo/owned-before-osc7')
|
||||
expect(adapter.getLastAuditObservation()).toMatchObject({
|
||||
state: 'present',
|
||||
reason: 'authenticated_inventory',
|
||||
inventoryAuthority: 'authoritative'
|
||||
})
|
||||
})
|
||||
|
||||
it('reports the daemon session WSL owner', async () => {
|
||||
|
|
@ -1470,6 +1475,104 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
|
|||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('retains authenticated identity and reports replacement across a same-endpoint reconnect', async () => {
|
||||
await adapter.listProcesses()
|
||||
const firstIdentity = adapter.getLastAuthenticatedDaemonIdentity()
|
||||
expect(firstIdentity).not.toBeNull()
|
||||
const identityChanges: {
|
||||
previous: NonNullable<typeof firstIdentity>
|
||||
current: NonNullable<typeof firstIdentity>
|
||||
}[] = []
|
||||
adapter.onDaemonIdentityChanged(() => {
|
||||
throw new Error('audit listener failed')
|
||||
})
|
||||
adapter.onDaemonIdentityChanged((event) => identityChanges.push(event))
|
||||
|
||||
await server.shutdown()
|
||||
await waitFor(
|
||||
() => !(adapter as unknown as { client: { isConnected(): boolean } }).client.isConnected()
|
||||
)
|
||||
expect(adapter.getLastAuthenticatedDaemonIdentity()).toEqual(firstIdentity)
|
||||
server = new DaemonServer({
|
||||
socketPath,
|
||||
tokenPath,
|
||||
launchNonce: 'replacement-launch',
|
||||
startedAtMs: (firstIdentity?.startedAtMs ?? 0) + 10_000,
|
||||
log: daemonLog,
|
||||
spawnSubprocess: (opts) => {
|
||||
lastSpawnOpts = opts
|
||||
lastSubprocess = createMockSubprocess()
|
||||
return lastSubprocess
|
||||
}
|
||||
})
|
||||
await server.start()
|
||||
|
||||
await expect(adapter.listProcesses()).resolves.toEqual([])
|
||||
|
||||
expect(identityChanges).toEqual([
|
||||
{
|
||||
previous: firstIdentity,
|
||||
current: {
|
||||
pid: process.pid,
|
||||
startedAtMs: (firstIdentity?.startedAtMs ?? 0) + 10_000,
|
||||
launchNonce: 'replacement-launch'
|
||||
}
|
||||
}
|
||||
])
|
||||
expect(adapter.getLastAuthenticatedDaemonIdentity()).toEqual(identityChanges[0]?.current)
|
||||
})
|
||||
|
||||
it('isolates audit observation listeners from inventory and later listeners', async () => {
|
||||
const laterListener = vi.fn()
|
||||
adapter.onAuditEligibilityObservation(() => {
|
||||
throw new Error('audit listener failed')
|
||||
})
|
||||
adapter.onAuditEligibilityObservation(laterListener)
|
||||
|
||||
await expect(adapter.listProcesses()).resolves.toEqual([])
|
||||
|
||||
expect(laterListener).toHaveBeenCalledOnce()
|
||||
expect(laterListener).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
state: 'present',
|
||||
reason: 'authenticated_inventory'
|
||||
})
|
||||
)
|
||||
expect(adapter.getLastAuditObservation()).toMatchObject({
|
||||
state: 'present',
|
||||
reason: 'authenticated_inventory'
|
||||
})
|
||||
})
|
||||
|
||||
it('audits token ENOENT only after an authenticated disconnect', async () => {
|
||||
const observations: {
|
||||
trigger: string
|
||||
state: string
|
||||
evidenceSources: readonly string[]
|
||||
}[] = []
|
||||
adapter.onAuditEligibilityObservation((observation) => observations.push(observation))
|
||||
await adapter.listProcesses()
|
||||
|
||||
await server.shutdown()
|
||||
await waitFor(
|
||||
() => !(adapter as unknown as { client: { isConnected(): boolean } }).client.isConnected()
|
||||
)
|
||||
await expect(adapter.listProcesses()).rejects.toThrow()
|
||||
await waitFor(() =>
|
||||
observations.some(
|
||||
(observation) => observation.trigger === 'token_missing_after_authenticated_disconnect'
|
||||
)
|
||||
)
|
||||
|
||||
expect(observations).toContainEqual(
|
||||
expect.objectContaining({
|
||||
trigger: 'token_missing_after_authenticated_disconnect',
|
||||
state: 'unknown',
|
||||
evidenceSources: expect.arrayContaining(['token_file'])
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasChildProcesses / getForegroundProcess', () => {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
/* oxlint-disable max-lines -- Why: history .catch() safety wiring spread across spawn/event-routing is tightly coupled to the adapter↔history lifecycle. */
|
||||
import { basename } from 'node:path'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { DaemonClient } from './client'
|
||||
import { getMacDaemonSystemResolverHealth } from './daemon-health'
|
||||
import {
|
||||
getMacDaemonSystemResolverHealth,
|
||||
parseDaemonPidFile,
|
||||
type ParsedDaemonPid
|
||||
} from './daemon-health'
|
||||
import {
|
||||
HistoryManager,
|
||||
type HistoryCheckpointResult,
|
||||
|
|
@ -63,6 +67,16 @@ import {
|
|||
TERMINAL_HISTORY_INLINE_SEED_CODE_UNITS
|
||||
} from './terminal-history-seed-chunks'
|
||||
import { NdjsonLineTooLongError } from './ndjson'
|
||||
import type { DaemonEndpointIdentity } from './daemon-hello-protocol'
|
||||
import {
|
||||
classifyDaemonAuditFailure,
|
||||
recordAuthenticatedInventory,
|
||||
type DaemonAuditContext,
|
||||
type DaemonAuditObservation,
|
||||
type DaemonAuditTrigger
|
||||
} from './daemon-audit-classifier'
|
||||
import type { DaemonEvidenceSource, ExactDaemonIncarnation } from './daemon-incarnation-evidence'
|
||||
import { trackDaemonAuditEligibility } from './daemon-audit-eligibility-event'
|
||||
|
||||
type PendingDaemonSpawnOperation = {
|
||||
exitsBySessionId: Map<string, { incarnationId?: string }[]>
|
||||
|
|
@ -100,6 +114,8 @@ function providerSequenceForSpawn(
|
|||
export type DaemonPtyAdapterOptions = {
|
||||
socketPath: string
|
||||
tokenPath: string
|
||||
pidPath?: string
|
||||
profileScope?: string
|
||||
protocolVersion?: number
|
||||
/** Directory for disk-based terminal history; when set, raw PTY output is written to disk for cold restore on daemon crash. */
|
||||
historyPath?: string
|
||||
|
|
@ -109,6 +125,11 @@ export type DaemonPtyAdapterOptions = {
|
|||
|
||||
export type DaemonRespawnReason = 'daemon_died' | 'unhealthy_resolver'
|
||||
|
||||
export type DaemonIdentityChangeEvent = {
|
||||
previous: DaemonEndpointIdentity
|
||||
current: DaemonEndpointIdentity
|
||||
}
|
||||
|
||||
const MAX_TOMBSTONES = 1000
|
||||
const MAX_CONCURRENT_CHECKPOINTS = 4
|
||||
|
||||
|
|
@ -130,7 +151,14 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
|||
readonly protocolVersion: number
|
||||
private socketPath: string
|
||||
private tokenPath: string
|
||||
private pidPath: string | null
|
||||
private client: DaemonClient
|
||||
private auditContext: DaemonAuditContext
|
||||
private lastAuthenticatedIdentity: DaemonEndpointIdentity | null = null
|
||||
private exactDaemonIncarnation: ExactDaemonIncarnation | null = null
|
||||
private lastAuditObservation: DaemonAuditObservation | null = null
|
||||
private auditObservationListeners: ((observation: DaemonAuditObservation) => void)[] = []
|
||||
private identityChangeListeners: ((event: DaemonIdentityChangeEvent) => void)[] = []
|
||||
private historyManager: HistoryManager | null
|
||||
private historyReader: HistoryReader | null
|
||||
private respawnFn: DaemonPtyAdapterOptions['respawn'] | null
|
||||
|
|
@ -219,6 +247,15 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
|||
this.protocolVersion = opts.protocolVersion ?? PROTOCOL_VERSION
|
||||
this.socketPath = opts.socketPath
|
||||
this.tokenPath = opts.tokenPath
|
||||
this.pidPath = opts.pidPath ?? null
|
||||
this.auditContext = {
|
||||
protocolGeneration: this.protocolVersion,
|
||||
provider: 'local-daemon',
|
||||
endpoint: opts.socketPath,
|
||||
tokenPath: opts.tokenPath,
|
||||
endpointKind: process.platform === 'win32' ? 'windows-named-pipe' : 'unix-socket',
|
||||
profileScope: opts.profileScope ?? ''
|
||||
}
|
||||
this.client = new DaemonClient({
|
||||
socketPath: opts.socketPath,
|
||||
tokenPath: opts.tokenPath,
|
||||
|
|
@ -247,6 +284,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
|||
this.producerResumesOwedOnReconnect.add(id)
|
||||
}
|
||||
this.pausedProducerSessionIds.clear()
|
||||
this.observeAuditFailure('transport_closed')
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -254,6 +292,26 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
|||
return this.historyManager
|
||||
}
|
||||
|
||||
getLastAuthenticatedDaemonIdentity(): DaemonEndpointIdentity | null {
|
||||
return this.lastAuthenticatedIdentity ? { ...this.lastAuthenticatedIdentity } : null
|
||||
}
|
||||
|
||||
getLastAuditObservation(): DaemonAuditObservation | null {
|
||||
return this.lastAuditObservation
|
||||
}
|
||||
|
||||
onDaemonIdentityChanged(listener: (event: DaemonIdentityChangeEvent) => void): () => void {
|
||||
this.identityChangeListeners.push(listener)
|
||||
return () => removeListener(this.identityChangeListeners, listener)
|
||||
}
|
||||
|
||||
onAuditEligibilityObservation(
|
||||
listener: (observation: DaemonAuditObservation) => void
|
||||
): () => void {
|
||||
this.auditObservationListeners.push(listener)
|
||||
return () => removeListener(this.auditObservationListeners, listener)
|
||||
}
|
||||
|
||||
supportsAgentSessionClaims(): boolean {
|
||||
return this.protocolVersion >= AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION
|
||||
}
|
||||
|
|
@ -1262,36 +1320,57 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
|||
}
|
||||
|
||||
async listProcesses(opts?: { deadlineMs?: number }): Promise<PtyProcessInfo[]> {
|
||||
// Why: connect + listSessions share the caller's one absolute deadline so a
|
||||
// wedged handshake cannot burn the whole teardown budget before the list issues.
|
||||
await this.ensureConnected(opts?.deadlineMs)
|
||||
const result = await this.client.request<ListSessionsResult>(
|
||||
'listSessions',
|
||||
undefined,
|
||||
remainingRequestTimeoutMs(opts?.deadlineMs)
|
||||
)
|
||||
const admission = new PtyProcessListAdmission()
|
||||
const processes: PtyProcessInfo[] = []
|
||||
for (const session of result.sessions) {
|
||||
if (!session.isAlive) {
|
||||
continue
|
||||
}
|
||||
const { worktreeId } = parsePtySessionId(session.sessionId)
|
||||
processes.push(
|
||||
admission.admit({
|
||||
id: session.sessionId,
|
||||
...(session.incarnationId ? { incarnationId: session.incarnationId } : {}),
|
||||
// Why: OSC 7 may not arrive before cleanup; spawn cwd is authoritative until the daemon reports a live cwd.
|
||||
cwd: session.cwd ?? this.initialCwds.get(session.sessionId) ?? '',
|
||||
title: 'shell',
|
||||
...(worktreeId ? { worktreeId } : {}),
|
||||
...(session.terminalHandle ? { terminalHandle: session.terminalHandle } : {}),
|
||||
...(session.wslDistro !== undefined ? { wslDistro: session.wslDistro } : {}),
|
||||
...this.validatedAgentSessionOwners(session.agentSessionOwners)
|
||||
})
|
||||
try {
|
||||
// Why: connect + listSessions share the caller's one absolute deadline so a
|
||||
// wedged handshake cannot burn the whole teardown budget before the list issues.
|
||||
await this.ensureConnected(opts?.deadlineMs)
|
||||
const result = await this.client.request<ListSessionsResult>(
|
||||
'listSessions',
|
||||
undefined,
|
||||
remainingRequestTimeoutMs(opts?.deadlineMs)
|
||||
)
|
||||
const admission = new PtyProcessListAdmission()
|
||||
const processes: PtyProcessInfo[] = []
|
||||
for (const session of result.sessions) {
|
||||
if (!session.isAlive) {
|
||||
continue
|
||||
}
|
||||
const { worktreeId } = parsePtySessionId(session.sessionId)
|
||||
processes.push(
|
||||
admission.admit({
|
||||
id: session.sessionId,
|
||||
...(session.incarnationId ? { incarnationId: session.incarnationId } : {}),
|
||||
// Why: OSC 7 may not arrive before cleanup; spawn cwd is authoritative until the daemon reports a live cwd.
|
||||
cwd: session.cwd ?? this.initialCwds.get(session.sessionId) ?? '',
|
||||
title: 'shell',
|
||||
...(worktreeId ? { worktreeId } : {}),
|
||||
...(session.terminalHandle ? { terminalHandle: session.terminalHandle } : {}),
|
||||
...(session.wslDistro !== undefined ? { wslDistro: session.wslDistro } : {}),
|
||||
...this.validatedAgentSessionOwners(session.agentSessionOwners)
|
||||
})
|
||||
)
|
||||
}
|
||||
this.publishAuditObservation(
|
||||
recordAuthenticatedInventory(this.auditContext, this.exactDaemonIncarnation)
|
||||
)
|
||||
return processes
|
||||
} catch (error) {
|
||||
const missingAuthenticatedToken =
|
||||
isMissingTokenFileError(error) && this.client.hasObservedAuthenticatedDisconnect()
|
||||
const missingNamedPipe = isMissingWindowsNamedPipeError(error)
|
||||
this.observeAuditFailure(
|
||||
missingAuthenticatedToken
|
||||
? 'token_missing_after_authenticated_disconnect'
|
||||
: 'inventory_failed',
|
||||
this.exactDaemonIncarnation,
|
||||
[
|
||||
...(missingAuthenticatedToken ? (['token_file'] as const) : []),
|
||||
...(missingNamedPipe ? (['windows_named_pipe'] as const) : [])
|
||||
],
|
||||
missingNamedPipe ? 'windows_named_pipe_missing' : undefined
|
||||
)
|
||||
throw error
|
||||
}
|
||||
return processes
|
||||
}
|
||||
|
||||
private validatedAgentSessionOwners(
|
||||
|
|
@ -1452,6 +1531,8 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
|||
this.wslDistrosBySessionId.clear()
|
||||
this.pausedProducerSessionIds.clear()
|
||||
this.producerResumesOwedOnReconnect.clear()
|
||||
this.auditObservationListeners.length = 0
|
||||
this.identityChangeListeners.length = 0
|
||||
this.removeEventListener?.()
|
||||
this.removeEventListener = null
|
||||
// Why: final checkpoints are written daemon-side (TerminalHost.dispose); here the adapter only marks sessions
|
||||
|
|
@ -1470,6 +1551,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
|||
}
|
||||
// Why: an authenticated pair cancels the adoption watchdog and lets a never-used adapter retire its empty daemon on quit.
|
||||
await this.client.ensureConnected()
|
||||
this.recordAuthenticatedIdentity()
|
||||
}
|
||||
|
||||
// Why: unlike dispose(), leave history files unclean (no endedAt) so the next launch treats them as crash-recoverable,
|
||||
|
|
@ -1532,6 +1614,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
|||
// Why: a respawn launcher holds a temporary pair until this adapter's permanent reconnect, preventing both gaps and leaks.
|
||||
this.releasePendingRespawnAdoptionLease()
|
||||
}
|
||||
this.recordAuthenticatedIdentity()
|
||||
// Why sampled before setupEventRouting: "no listener yet" identifies a fresh connect — the only time the
|
||||
// daemon-side backgrounded set (process state lost with the old daemon) needs a resync.
|
||||
const isFreshConnection = this.removeEventListener === null
|
||||
|
|
@ -1543,6 +1626,73 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
|||
}
|
||||
}
|
||||
|
||||
private recordAuthenticatedIdentity(): void {
|
||||
const current = this.client.getDaemonIdentity()
|
||||
if (!current) {
|
||||
return
|
||||
}
|
||||
const previous = this.lastAuthenticatedIdentity
|
||||
if (previous && sameEndpointIdentity(previous, current)) {
|
||||
return
|
||||
}
|
||||
const previousExactIncarnation = this.exactDaemonIncarnation
|
||||
const pidRecord = this.readMatchingPidRecord(current)
|
||||
this.lastAuthenticatedIdentity = { ...current }
|
||||
this.exactDaemonIncarnation = {
|
||||
identity: { ...current },
|
||||
...(pidRecord?.linuxStartTicks && pidRecord.bootId
|
||||
? {
|
||||
linuxStartTicks: pidRecord.linuxStartTicks,
|
||||
bootId: pidRecord.bootId
|
||||
}
|
||||
: {})
|
||||
}
|
||||
if (!previous) {
|
||||
return
|
||||
}
|
||||
const event = { previous: { ...previous }, current: { ...current } }
|
||||
notifyAuditListeners(this.identityChangeListeners, event)
|
||||
this.observeAuditFailure('endpoint_identity_changed', previousExactIncarnation, [
|
||||
'endpoint_identity'
|
||||
])
|
||||
}
|
||||
|
||||
private readMatchingPidRecord(identity: DaemonEndpointIdentity): ParsedDaemonPid | null {
|
||||
if (!this.pidPath) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const parsed = parseDaemonPidFile(readFileSync(this.pidPath, 'utf8'))
|
||||
return parsed?.pid === identity.pid &&
|
||||
parsed.startedAtMs === identity.startedAtMs &&
|
||||
parsed.launchNonce === identity.launchNonce
|
||||
? parsed
|
||||
: null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private observeAuditFailure(
|
||||
trigger: Exclude<DaemonAuditTrigger, 'inventory_answered'>,
|
||||
exactIncarnation = this.exactDaemonIncarnation,
|
||||
additionalEvidenceSources: readonly DaemonEvidenceSource[] = [],
|
||||
endpointGoneProof?: 'windows_named_pipe_missing'
|
||||
): void {
|
||||
void classifyDaemonAuditFailure(this.auditContext, trigger, exactIncarnation, {
|
||||
additionalEvidenceSources,
|
||||
endpointGoneProof
|
||||
})
|
||||
.then((observation) => this.publishAuditObservation(observation))
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
private publishAuditObservation(observation: DaemonAuditObservation): void {
|
||||
this.lastAuditObservation = observation
|
||||
trackDaemonAuditEligibility(observation)
|
||||
notifyAuditListeners(this.auditObservationListeners, observation)
|
||||
}
|
||||
|
||||
private resyncBackgroundedSessions(): void {
|
||||
for (const id of this.backgroundedSessionIds) {
|
||||
// Harmless no-op for sessions the daemon doesn't know (yet).
|
||||
|
|
@ -1829,6 +1979,13 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
|||
// Why: the token is removed only after an authenticated drop; an initial missing token may still hide a live daemon.
|
||||
const missingRetiredEndpointToken =
|
||||
isMissingTokenFileError(err) && this.client.hasObservedAuthenticatedDisconnect()
|
||||
if (missingRetiredEndpointToken) {
|
||||
this.observeAuditFailure(
|
||||
'token_missing_after_authenticated_disconnect',
|
||||
this.exactDaemonIncarnation,
|
||||
['token_file']
|
||||
)
|
||||
}
|
||||
if (
|
||||
this.respawnAdoptionClosed ||
|
||||
!this.respawnFn ||
|
||||
|
|
@ -2135,6 +2292,34 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
|||
}
|
||||
}
|
||||
|
||||
function sameEndpointIdentity(
|
||||
left: DaemonEndpointIdentity,
|
||||
right: DaemonEndpointIdentity
|
||||
): boolean {
|
||||
return (
|
||||
left.pid === right.pid &&
|
||||
left.startedAtMs === right.startedAtMs &&
|
||||
left.launchNonce === right.launchNonce
|
||||
)
|
||||
}
|
||||
|
||||
function removeListener<T>(listeners: T[], listener: T): void {
|
||||
const index = listeners.indexOf(listener)
|
||||
if (index !== -1) {
|
||||
listeners.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function notifyAuditListeners<T>(listeners: readonly ((value: T) => void)[], value: T): void {
|
||||
for (const listener of listeners.slice()) {
|
||||
try {
|
||||
listener(value)
|
||||
} catch {
|
||||
// Audit observers cannot affect daemon operations.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Why: syscall='connect' distinguishes a dead-socket ENOENT/ECONNREFUSED from token-file ENOENT (no syscall);
|
||||
// message strings incl. wedged-daemon "Hello response timed out" (#8689) also warrant a respawn.
|
||||
function isDaemonGoneError(err: unknown): boolean {
|
||||
|
|
@ -2161,3 +2346,11 @@ function isMissingTokenFileError(err: unknown): boolean {
|
|||
const errno = err as NodeJS.ErrnoException
|
||||
return errno.code === 'ENOENT' && errno.syscall === 'open'
|
||||
}
|
||||
|
||||
function isMissingWindowsNamedPipeError(err: unknown): boolean {
|
||||
if (process.platform !== 'win32' || !(err instanceof Error)) {
|
||||
return false
|
||||
}
|
||||
const errno = err as NodeJS.ErrnoException
|
||||
return errno.code === 'ENOENT' && errno.syscall === 'connect'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
import { readFileSync } from 'node:fs'
|
||||
import { parseLinuxStartTicks, readBootIdentity } from '../agent-hooks/managed-hook-owner-identity'
|
||||
|
||||
export type DaemonReadyIdentity = {
|
||||
startedAtMs: number
|
||||
linuxStartTicks?: string
|
||||
bootId?: string
|
||||
}
|
||||
|
||||
export async function readCurrentDaemonReadyIdentity(
|
||||
startedAtMs: number
|
||||
): Promise<DaemonReadyIdentity> {
|
||||
if (process.platform !== 'linux') {
|
||||
return { startedAtMs }
|
||||
}
|
||||
try {
|
||||
const linuxStartTicks = parseLinuxStartTicks(readFileSync('/proc/self/stat', 'utf8'))
|
||||
const bootId = await readBootIdentity()
|
||||
return linuxStartTicks && bootId ? { startedAtMs, linuxStartTicks, bootId } : { startedAtMs }
|
||||
} catch {
|
||||
return { startedAtMs }
|
||||
}
|
||||
}
|
||||
|
||||
export function parseDaemonReadyIdentity(message: unknown): DaemonReadyIdentity | null {
|
||||
if (!message || typeof message !== 'object') {
|
||||
return null
|
||||
}
|
||||
const value = message as {
|
||||
startedAtMs?: unknown
|
||||
linuxStartTicks?: unknown
|
||||
bootId?: unknown
|
||||
}
|
||||
if (
|
||||
typeof value.startedAtMs !== 'number' ||
|
||||
!Number.isFinite(value.startedAtMs) ||
|
||||
value.startedAtMs <= 0
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const hasLinuxStartTicks = value.linuxStartTicks !== undefined
|
||||
const hasBootId = value.bootId !== undefined
|
||||
if (hasLinuxStartTicks !== hasBootId) {
|
||||
return null
|
||||
}
|
||||
if (!hasLinuxStartTicks) {
|
||||
return { startedAtMs: value.startedAtMs }
|
||||
}
|
||||
if (
|
||||
typeof value.linuxStartTicks !== 'string' ||
|
||||
value.linuxStartTicks.length === 0 ||
|
||||
typeof value.bootId !== 'string' ||
|
||||
value.bootId.length === 0
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
startedAtMs: value.startedAtMs,
|
||||
linuxStartTicks: value.linuxStartTicks,
|
||||
bootId: value.bootId
|
||||
}
|
||||
}
|
||||
|
|
@ -14,6 +14,8 @@ export type DaemonPidFile = {
|
|||
entryPath?: string
|
||||
appVersion?: string
|
||||
launchNonce?: string
|
||||
linuxStartTicks?: string
|
||||
bootId?: string
|
||||
}
|
||||
|
||||
export type DaemonProcessHandle = {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -5,11 +5,13 @@ import {
|
|||
type DaemonLauncher,
|
||||
type DaemonProcessHandle
|
||||
} from './daemon-spawner'
|
||||
import { parseDaemonReadyIdentity, type DaemonReadyIdentity } from './daemon-ready-identity'
|
||||
|
||||
const READY_TIMEOUT_MS = 10_000
|
||||
|
||||
export type ProductionLauncherOptions = {
|
||||
getDaemonEntryPath: () => string
|
||||
getAppVersion: () => string
|
||||
}
|
||||
|
||||
export function createProductionLauncher(opts: ProductionLauncherOptions): DaemonLauncher {
|
||||
|
|
@ -45,9 +47,9 @@ export function createProductionLauncher(opts: ProductionLauncherOptions): Daemo
|
|||
}
|
||||
)
|
||||
|
||||
let startedAtMs: number
|
||||
let readyIdentity: DaemonReadyIdentity
|
||||
try {
|
||||
startedAtMs = await waitForReady(child)
|
||||
readyIdentity = await waitForReady(child)
|
||||
} catch (error) {
|
||||
return rejectAfterChildCleanup(child, error)
|
||||
}
|
||||
|
|
@ -60,8 +62,9 @@ export function createProductionLauncher(opts: ProductionLauncherOptions): Daemo
|
|||
pidPath,
|
||||
serializeDaemonPidFile({
|
||||
pid: child.pid as number,
|
||||
startedAtMs,
|
||||
...readyIdentity,
|
||||
entryPath,
|
||||
appVersion: opts.getAppVersion(),
|
||||
launchNonce
|
||||
}),
|
||||
{ mode: 0o600, flag: 'wx' }
|
||||
|
|
@ -81,7 +84,7 @@ export function createProductionLauncher(opts: ProductionLauncherOptions): Daemo
|
|||
}
|
||||
}
|
||||
|
||||
function waitForReady(child: ChildProcess): Promise<number> {
|
||||
function waitForReady(child: ChildProcess): Promise<DaemonReadyIdentity> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined
|
||||
let settled = false
|
||||
|
|
@ -106,8 +109,8 @@ function waitForReady(child: ChildProcess): Promise<number> {
|
|||
if (settled) {
|
||||
return
|
||||
}
|
||||
const startedAtMs = (msg as { startedAtMs?: unknown }).startedAtMs
|
||||
if (typeof startedAtMs !== 'number' || !Number.isFinite(startedAtMs) || startedAtMs <= 0) {
|
||||
const readyIdentity = parseDaemonReadyIdentity(msg)
|
||||
if (!readyIdentity) {
|
||||
fail(new Error('Daemon readiness identity is incomplete'))
|
||||
return
|
||||
}
|
||||
|
|
@ -115,7 +118,7 @@ function waitForReady(child: ChildProcess): Promise<number> {
|
|||
// Why: the daemon is detached after readiness, so startup listeners
|
||||
// must not keep the child process closure alive for the daemon lifetime.
|
||||
cleanupStartupListeners()
|
||||
resolve(startedAtMs)
|
||||
resolve(readyIdentity)
|
||||
}
|
||||
}
|
||||
function onError(err: Error): void {
|
||||
|
|
|
|||
|
|
@ -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: (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,81 @@
|
|||
export const DAEMON_AUDIT_STATE_VALUES = ['present', 'gone', 'unknown'] as const
|
||||
|
||||
export const DAEMON_AUDIT_TRIGGER_VALUES = [
|
||||
'endpoint_identity_changed',
|
||||
'inventory_answered',
|
||||
'inventory_failed',
|
||||
'token_missing_after_authenticated_disconnect',
|
||||
'transport_closed'
|
||||
] as const
|
||||
|
||||
export const DAEMON_EVIDENCE_SOURCE_VALUES = [
|
||||
'authenticated_inventory',
|
||||
'boot_identity',
|
||||
'endpoint_identity',
|
||||
'endpoint_stat',
|
||||
'linux_proc_stat',
|
||||
'pid_record',
|
||||
'process_command_line',
|
||||
'process_signal',
|
||||
'process_start_time',
|
||||
'token_file',
|
||||
'windows_cim',
|
||||
'windows_named_pipe'
|
||||
] as const
|
||||
|
||||
export const DAEMON_PROCESS_PRESENT_REASON_VALUES = [
|
||||
'linux_identity_match',
|
||||
'macos_identity_match',
|
||||
'windows_identity_match'
|
||||
] as const
|
||||
|
||||
export const DAEMON_PROCESS_GONE_REASON_VALUES = [
|
||||
'linux_boot_changed',
|
||||
'linux_start_ticks_mismatch',
|
||||
'linux_zombie',
|
||||
'pid_missing',
|
||||
'windows_creation_time_mismatch',
|
||||
'windows_process_missing'
|
||||
] as const
|
||||
|
||||
export const DAEMON_PROCESS_UNKNOWN_REASON_VALUES = [
|
||||
'command_line_mismatch',
|
||||
'command_line_unavailable',
|
||||
'exact_identity_unavailable',
|
||||
'inspection_failed',
|
||||
'linux_identity_incomplete',
|
||||
'macos_start_time_mismatch',
|
||||
'permission_denied',
|
||||
'process_start_time_unavailable',
|
||||
'windows_process_start_time_unavailable'
|
||||
] as const
|
||||
|
||||
export const DAEMON_AUDIT_GONE_REASON_VALUES = [
|
||||
...DAEMON_PROCESS_GONE_REASON_VALUES,
|
||||
'windows_named_pipe_missing'
|
||||
] as const
|
||||
|
||||
export const DAEMON_AUDIT_REASON_VALUES = [
|
||||
'authenticated_inventory',
|
||||
'endpoint_identity_changed',
|
||||
'inventory_failed',
|
||||
'token_missing_after_authenticated_disconnect',
|
||||
'transport_closed',
|
||||
...DAEMON_AUDIT_GONE_REASON_VALUES
|
||||
] as const
|
||||
|
||||
export const DAEMON_AUDIT_PROCESS_REASON_VALUES = [
|
||||
...DAEMON_PROCESS_PRESENT_REASON_VALUES,
|
||||
...DAEMON_PROCESS_GONE_REASON_VALUES,
|
||||
...DAEMON_PROCESS_UNKNOWN_REASON_VALUES,
|
||||
'windows_named_pipe_missing'
|
||||
] as const
|
||||
|
||||
export type DaemonAuditState = (typeof DAEMON_AUDIT_STATE_VALUES)[number]
|
||||
export type DaemonAuditTrigger = (typeof DAEMON_AUDIT_TRIGGER_VALUES)[number]
|
||||
export type DaemonAuditFailureTrigger = Exclude<DaemonAuditTrigger, 'inventory_answered'>
|
||||
export type DaemonEvidenceSource = (typeof DAEMON_EVIDENCE_SOURCE_VALUES)[number]
|
||||
export type DaemonProcessPresentReason = (typeof DAEMON_PROCESS_PRESENT_REASON_VALUES)[number]
|
||||
export type DaemonProcessGoneReason = (typeof DAEMON_PROCESS_GONE_REASON_VALUES)[number]
|
||||
export type DaemonProcessUnknownReason = (typeof DAEMON_PROCESS_UNKNOWN_REASON_VALUES)[number]
|
||||
export type DaemonAuditGoneReason = (typeof DAEMON_AUDIT_GONE_REASON_VALUES)[number]
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue