mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 00:03:45 +00:00
fix(daemon): isolate audit evidence observers
This commit is contained in:
@@ -6,13 +6,12 @@ import {
|
||||
type DaemonProcessEvidence,
|
||||
type ExactDaemonIncarnation
|
||||
} from './daemon-incarnation-evidence'
|
||||
import type {
|
||||
DaemonAuditFailureTrigger,
|
||||
DaemonAuditGoneReason
|
||||
} from '../../shared/daemon-audit-eligibility'
|
||||
|
||||
export type DaemonAuditTrigger =
|
||||
| 'endpoint_identity_changed'
|
||||
| 'inventory_answered'
|
||||
| 'inventory_failed'
|
||||
| 'token_missing_after_authenticated_disconnect'
|
||||
| 'transport_closed'
|
||||
export type { DaemonAuditTrigger } from '../../shared/daemon-audit-eligibility'
|
||||
|
||||
export type DaemonAuditContext = {
|
||||
protocolGeneration: number
|
||||
@@ -23,10 +22,6 @@ export type DaemonAuditContext = {
|
||||
profileScope: string
|
||||
}
|
||||
|
||||
type DaemonAuditGoneReason =
|
||||
| Extract<DaemonProcessEvidence, { state: 'gone' }>['reason']
|
||||
| 'windows_named_pipe_missing'
|
||||
|
||||
export type DaemonAuditObservation =
|
||||
| {
|
||||
state: 'present'
|
||||
@@ -45,7 +40,7 @@ export type DaemonAuditObservation =
|
||||
| {
|
||||
state: 'gone'
|
||||
reason: DaemonAuditGoneReason
|
||||
trigger: Exclude<DaemonAuditTrigger, 'inventory_answered'>
|
||||
trigger: DaemonAuditFailureTrigger
|
||||
evidenceSources: DaemonEvidenceSources
|
||||
context: DaemonAuditContext
|
||||
exactIncarnation: ExactDaemonIncarnation
|
||||
@@ -58,8 +53,8 @@ export type DaemonAuditObservation =
|
||||
}
|
||||
| {
|
||||
state: 'unknown'
|
||||
reason: Exclude<DaemonAuditTrigger, 'inventory_answered'>
|
||||
trigger: Exclude<DaemonAuditTrigger, 'inventory_answered'>
|
||||
reason: DaemonAuditFailureTrigger
|
||||
trigger: DaemonAuditFailureTrigger
|
||||
evidenceSources: DaemonEvidenceSources
|
||||
context: DaemonAuditContext
|
||||
exactIncarnation: ExactDaemonIncarnation | null
|
||||
@@ -75,6 +70,17 @@ export type DaemonAuditObservation =
|
||||
|
||||
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
|
||||
@@ -97,28 +103,34 @@ export function recordAuthenticatedInventory(
|
||||
|
||||
export async function classifyDaemonAuditFailure(
|
||||
context: DaemonAuditContext,
|
||||
trigger: Exclude<DaemonAuditTrigger, 'inventory_answered'>,
|
||||
trigger: DaemonAuditFailureTrigger,
|
||||
exactIncarnation: ExactDaemonIncarnation | null,
|
||||
additionalEvidenceSources: readonly DaemonEvidenceSource[] = [],
|
||||
endpointGoneProof?: 'windows_named_pipe_missing'
|
||||
options: DaemonAuditClassificationOptions = {}
|
||||
): Promise<DaemonAuditObservation> {
|
||||
const probeProcessIdentity =
|
||||
options.dependencies?.probeProcessIdentity ?? probeDaemonProcessIdentity
|
||||
const inspectEndpoint = options.dependencies?.inspectEndpointState ?? inspectDaemonEndpointState
|
||||
const [processEvidence, endpointState] = await Promise.all([
|
||||
probeDaemonProcessIdentity(exactIncarnation, {
|
||||
probeProcessIdentity(exactIncarnation, {
|
||||
socketPath: context.endpoint,
|
||||
tokenPath: context.tokenPath
|
||||
}),
|
||||
inspectEndpointState(context)
|
||||
inspectEndpoint(context)
|
||||
])
|
||||
const reachability = reachabilityForTrigger(trigger)
|
||||
const evidenceSources = combineEvidenceSources(
|
||||
processEvidence.evidenceSources,
|
||||
context.endpointKind === 'unix-socket' ? ['endpoint_stat'] : [],
|
||||
additionalEvidenceSources
|
||||
options.additionalEvidenceSources ?? []
|
||||
)
|
||||
if (endpointGoneProof && exactIncarnation) {
|
||||
if (
|
||||
options.endpointGoneProof &&
|
||||
context.endpointKind === 'windows-named-pipe' &&
|
||||
exactIncarnation
|
||||
) {
|
||||
return {
|
||||
state: 'gone',
|
||||
reason: endpointGoneProof,
|
||||
reason: options.endpointGoneProof,
|
||||
trigger,
|
||||
evidenceSources,
|
||||
context,
|
||||
@@ -126,7 +138,7 @@ export async function classifyDaemonAuditFailure(
|
||||
reachability,
|
||||
inventoryAuthority: 'unavailable',
|
||||
processLiveness: 'gone',
|
||||
processReason: endpointGoneProof,
|
||||
processReason: options.endpointGoneProof,
|
||||
endpointState: 'missing',
|
||||
observedAtMs: Date.now()
|
||||
}
|
||||
@@ -163,7 +175,9 @@ export async function classifyDaemonAuditFailure(
|
||||
}
|
||||
}
|
||||
|
||||
async function inspectEndpointState(context: DaemonAuditContext): Promise<DaemonEndpointState> {
|
||||
async function inspectDaemonEndpointState(
|
||||
context: DaemonAuditContext
|
||||
): Promise<DaemonEndpointState> {
|
||||
if (context.endpointKind === 'windows-named-pipe') {
|
||||
return 'named-pipe'
|
||||
}
|
||||
@@ -176,7 +190,7 @@ async function inspectEndpointState(context: DaemonAuditContext): Promise<Daemon
|
||||
}
|
||||
|
||||
function reachabilityForTrigger(
|
||||
trigger: Exclude<DaemonAuditTrigger, 'inventory_answered'>
|
||||
trigger: DaemonAuditFailureTrigger
|
||||
): 'authenticated' | 'disconnected' | 'unknown' {
|
||||
if (trigger === 'endpoint_identity_changed') {
|
||||
return 'authenticated'
|
||||
|
||||
@@ -1,32 +1,30 @@
|
||||
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',
|
||||
'boot_identity_changed',
|
||||
'raw_start_ticks_mismatch',
|
||||
'matching_identity_zombie'
|
||||
],
|
||||
linux: ['pid_missing', 'linux_boot_changed', 'linux_start_ticks_mismatch', 'linux_zombie'],
|
||||
darwin: ['pid_missing'],
|
||||
win32: ['cim_process_missing', 'cim_creation_time_mismatch', 'named_pipe_missing']
|
||||
} as const
|
||||
win32: ['windows_process_missing', 'windows_creation_time_mismatch', 'windows_named_pipe_missing']
|
||||
} as const satisfies Readonly<
|
||||
Record<'linux' | 'darwin' | 'win32', readonly DaemonAuditGoneReason[]>
|
||||
>
|
||||
|
||||
export type DaemonEvidenceSource =
|
||||
| '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'
|
||||
export type {
|
||||
DaemonAuditGoneReason,
|
||||
DaemonEvidenceSource,
|
||||
DaemonProcessGoneReason,
|
||||
DaemonProcessPresentReason,
|
||||
DaemonProcessUnknownReason
|
||||
} from '../../shared/daemon-audit-eligibility'
|
||||
|
||||
export type DaemonEvidenceSources = readonly [DaemonEvidenceSource, ...DaemonEvidenceSource[]]
|
||||
|
||||
@@ -39,34 +37,18 @@ export type ExactDaemonIncarnation = {
|
||||
export type DaemonProcessEvidence =
|
||||
| {
|
||||
state: 'present'
|
||||
reason: 'linux_identity_match' | 'macos_identity_match' | 'windows_identity_match'
|
||||
reason: DaemonProcessPresentReason
|
||||
evidenceSources: DaemonEvidenceSources
|
||||
}
|
||||
| {
|
||||
state: 'gone'
|
||||
reason:
|
||||
| 'linux_boot_changed'
|
||||
| 'linux_start_ticks_mismatch'
|
||||
| 'linux_zombie'
|
||||
| 'pid_missing'
|
||||
| 'windows_creation_time_mismatch'
|
||||
| 'windows_process_missing'
|
||||
reason: DaemonProcessGoneReason
|
||||
evidenceSources: DaemonEvidenceSources
|
||||
exactIncarnation: ExactDaemonIncarnation
|
||||
}
|
||||
| {
|
||||
state: 'unknown'
|
||||
reason:
|
||||
| '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_command_line_unavailable'
|
||||
| 'windows_process_start_time_unavailable'
|
||||
reason: DaemonProcessUnknownReason
|
||||
evidenceSources: DaemonEvidenceSources
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import {
|
||||
classifyDaemonAuditFailure,
|
||||
recordAuthenticatedInventory,
|
||||
type DaemonAuditClassifierDependencies,
|
||||
type DaemonAuditContext
|
||||
} from './daemon-audit-classifier'
|
||||
|
||||
@@ -18,6 +19,15 @@ const exactIncarnation: ExactDaemonIncarnation = {
|
||||
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(' ')}`
|
||||
@@ -40,14 +50,13 @@ function linuxDependencies(
|
||||
describe('daemon process identity evidence', () => {
|
||||
it('pins the dated conclusive-gone oracle contract', () => {
|
||||
expect(DAEMON_GONE_PROOFS).toEqual({
|
||||
linux: [
|
||||
'pid_missing',
|
||||
'boot_identity_changed',
|
||||
'raw_start_ticks_mismatch',
|
||||
'matching_identity_zombie'
|
||||
],
|
||||
linux: ['pid_missing', 'linux_boot_changed', 'linux_start_ticks_mismatch', 'linux_zombie'],
|
||||
darwin: ['pid_missing'],
|
||||
win32: ['cim_process_missing', 'cim_creation_time_mismatch', 'named_pipe_missing']
|
||||
win32: [
|
||||
'windows_process_missing',
|
||||
'windows_creation_time_mismatch',
|
||||
'windows_named_pipe_missing'
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
@@ -133,7 +142,7 @@ describe('daemon process identity evidence', () => {
|
||||
).resolves.toMatchObject({ state: 'unknown', reason: 'exact_identity_unavailable' })
|
||||
})
|
||||
|
||||
it('uses Windows CreationDate mismatch as gone while null fields stay unknown', async () => {
|
||||
it('uses Windows CreationDate as the primary identity regardless of command line', async () => {
|
||||
const base = {
|
||||
platform: 'win32' as const,
|
||||
signalProcess: () => 'occupied' as const
|
||||
@@ -162,8 +171,8 @@ describe('daemon process identity evidence', () => {
|
||||
})
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
state: 'unknown',
|
||||
reason: 'windows_command_line_unavailable'
|
||||
state: 'present',
|
||||
reason: 'windows_identity_match'
|
||||
})
|
||||
await expect(
|
||||
probeDaemonProcessIdentity(exactIncarnation, endpoint, {
|
||||
@@ -175,8 +184,8 @@ describe('daemon process identity evidence', () => {
|
||||
})
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
state: 'unknown',
|
||||
reason: 'command_line_mismatch'
|
||||
state: 'present',
|
||||
reason: 'windows_identity_match'
|
||||
})
|
||||
await expect(
|
||||
probeDaemonProcessIdentity(exactIncarnation, endpoint, {
|
||||
@@ -214,8 +223,8 @@ describe('daemon process identity evidence', () => {
|
||||
})
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
state: 'unknown',
|
||||
reason: 'windows_command_line_unavailable'
|
||||
state: 'present',
|
||||
reason: 'windows_identity_match'
|
||||
})
|
||||
})
|
||||
|
||||
@@ -261,7 +270,9 @@ describe('daemon audit availability evidence', () => {
|
||||
})
|
||||
|
||||
it('represents failed legacy inventory as unknown, never an empty process list', async () => {
|
||||
const observation = await classifyDaemonAuditFailure(context, 'inventory_failed', null)
|
||||
const observation = await classifyDaemonAuditFailure(context, 'inventory_failed', null, {
|
||||
dependencies: auditClassifierDependencies
|
||||
})
|
||||
|
||||
expect(observation).toMatchObject({
|
||||
state: 'unknown',
|
||||
@@ -278,7 +289,10 @@ describe('daemon audit availability evidence', () => {
|
||||
context,
|
||||
'token_missing_after_authenticated_disconnect',
|
||||
null,
|
||||
['token_file']
|
||||
{
|
||||
additionalEvidenceSources: ['token_file'],
|
||||
dependencies: auditClassifierDependencies
|
||||
}
|
||||
)
|
||||
|
||||
expect(observation.state).toBe('unknown')
|
||||
@@ -295,8 +309,11 @@ describe('daemon audit availability evidence', () => {
|
||||
windowsContext,
|
||||
'inventory_failed',
|
||||
exactIncarnation,
|
||||
['windows_named_pipe'],
|
||||
'windows_named_pipe_missing'
|
||||
{
|
||||
additionalEvidenceSources: ['windows_named_pipe'],
|
||||
endpointGoneProof: 'windows_named_pipe_missing',
|
||||
dependencies: auditClassifierDependencies
|
||||
}
|
||||
)
|
||||
|
||||
expect(observation).toMatchObject({
|
||||
@@ -306,4 +323,23 @@ describe('daemon audit availability evidence', () => {
|
||||
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'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -222,13 +222,13 @@ async function probeWindowsProcess(
|
||||
exactIncarnation
|
||||
)
|
||||
}
|
||||
if (!identity.commandLine) {
|
||||
return unknown('windows_command_line_unavailable', ['windows_cim'])
|
||||
}
|
||||
if (!commandLineMatchesDaemon(identity.commandLine, endpoint.socketPath, endpoint.tokenPath)) {
|
||||
return unknown('command_line_mismatch', ['windows_cim'])
|
||||
}
|
||||
return present('windows_identity_match', ['windows_cim', 'endpoint_identity'])
|
||||
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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -9,6 +9,11 @@ import type {
|
||||
|
||||
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)
|
||||
@@ -34,29 +39,40 @@ export async function readLinuxStat(pid: number): Promise<LinuxStatEvidence> {
|
||||
|
||||
export async function readProcessCommandLine(
|
||||
pid: number,
|
||||
platform: NodeJS.Platform
|
||||
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 {
|
||||
return await readFile(`/proc/${pid}/cmdline`, 'utf8')
|
||||
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 execFileAsync('ps', ['-p', String(pid), '-o', 'command='], {
|
||||
encoding: 'utf8',
|
||||
timeout: 2_000
|
||||
})
|
||||
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): Promise<WindowsProcessEvidence> {
|
||||
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 execFileAsync(
|
||||
const stdout = await runCommand(
|
||||
'powershell.exe',
|
||||
[
|
||||
'-NoProfile',
|
||||
@@ -68,7 +84,7 @@ export async function queryWindowsProcess(pid: number): Promise<WindowsProcessEv
|
||||
`$start = [long]([DateTimeOffset]$p.CreationDate).ToUnixTimeMilliseconds() }; ` +
|
||||
`@{ exists = $true; cmd = $p.CommandLine; start = $start } | ConvertTo-Json -Compress }`
|
||||
],
|
||||
{ encoding: 'utf8', timeout: 3_000 }
|
||||
3_000
|
||||
)
|
||||
const parsed = JSON.parse(stdout.trim()) as {
|
||||
exists?: unknown
|
||||
@@ -92,6 +108,15 @@ export async function queryWindowsProcess(pid: number): Promise<WindowsProcessEv
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1484,6 +1484,9 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
|
||||
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()
|
||||
@@ -1505,7 +1508,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
|
||||
})
|
||||
await server.start()
|
||||
|
||||
await adapter.listProcesses()
|
||||
await expect(adapter.listProcesses()).resolves.toEqual([])
|
||||
|
||||
expect(identityChanges).toEqual([
|
||||
{
|
||||
@@ -1520,6 +1523,28 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
|
||||
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
|
||||
|
||||
@@ -1651,10 +1651,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
return
|
||||
}
|
||||
const event = { previous: { ...previous }, current: { ...current } }
|
||||
// oxlint-disable-next-line unicorn/no-useless-spread -- listeners may unsubscribe during dispatch
|
||||
for (const listener of [...this.identityChangeListeners]) {
|
||||
listener(event)
|
||||
}
|
||||
notifyAuditListeners(this.identityChangeListeners, event)
|
||||
this.observeAuditFailure('endpoint_identity_changed', previousExactIncarnation, [
|
||||
'endpoint_identity'
|
||||
])
|
||||
@@ -1682,13 +1679,10 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
additionalEvidenceSources: readonly DaemonEvidenceSource[] = [],
|
||||
endpointGoneProof?: 'windows_named_pipe_missing'
|
||||
): void {
|
||||
void classifyDaemonAuditFailure(
|
||||
this.auditContext,
|
||||
trigger,
|
||||
exactIncarnation,
|
||||
void classifyDaemonAuditFailure(this.auditContext, trigger, exactIncarnation, {
|
||||
additionalEvidenceSources,
|
||||
endpointGoneProof
|
||||
)
|
||||
})
|
||||
.then((observation) => this.publishAuditObservation(observation))
|
||||
.catch(() => {})
|
||||
}
|
||||
@@ -1696,10 +1690,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
private publishAuditObservation(observation: DaemonAuditObservation): void {
|
||||
this.lastAuditObservation = observation
|
||||
trackDaemonAuditEligibility(observation)
|
||||
// oxlint-disable-next-line unicorn/no-useless-spread -- listeners may unsubscribe during dispatch
|
||||
for (const listener of [...this.auditObservationListeners]) {
|
||||
listener(observation)
|
||||
}
|
||||
notifyAuditListeners(this.auditObservationListeners, observation)
|
||||
}
|
||||
|
||||
private resyncBackgroundedSessions(): void {
|
||||
@@ -2319,6 +2310,16 @@ function removeListener<T>(listeners: T[], listener: T): void {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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'
|
||||
@@ -420,47 +427,10 @@ const daemonLifecycleSchema = z.discriminatedUnion('transition', [
|
||||
|
||||
const daemonAuditEligibilitySchema = z
|
||||
.object({
|
||||
state: z.enum(['present', 'gone', 'unknown']),
|
||||
reason: z.enum([
|
||||
'authenticated_inventory',
|
||||
'endpoint_identity_changed',
|
||||
'inventory_failed',
|
||||
'linux_boot_changed',
|
||||
'linux_start_ticks_mismatch',
|
||||
'linux_zombie',
|
||||
'pid_missing',
|
||||
'token_missing_after_authenticated_disconnect',
|
||||
'transport_closed',
|
||||
'windows_creation_time_mismatch',
|
||||
'windows_named_pipe_missing',
|
||||
'windows_process_missing'
|
||||
]),
|
||||
trigger: z.enum([
|
||||
'endpoint_identity_changed',
|
||||
'inventory_answered',
|
||||
'inventory_failed',
|
||||
'token_missing_after_authenticated_disconnect',
|
||||
'transport_closed'
|
||||
]),
|
||||
evidence_sources: z
|
||||
.array(
|
||||
z.enum([
|
||||
'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'
|
||||
])
|
||||
)
|
||||
.min(1)
|
||||
.max(12),
|
||||
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']),
|
||||
@@ -473,30 +443,7 @@ const daemonAuditEligibilitySchema = z
|
||||
reachability: z.enum(['authenticated', 'disconnected', 'unknown']),
|
||||
inventory_authority: z.enum(['authoritative', 'unavailable']),
|
||||
process_liveness: z.enum(['present', 'gone', 'unknown']),
|
||||
process_reason: z
|
||||
.enum([
|
||||
'command_line_mismatch',
|
||||
'command_line_unavailable',
|
||||
'exact_identity_unavailable',
|
||||
'inspection_failed',
|
||||
'linux_boot_changed',
|
||||
'linux_identity_incomplete',
|
||||
'linux_identity_match',
|
||||
'linux_start_ticks_mismatch',
|
||||
'linux_zombie',
|
||||
'macos_identity_match',
|
||||
'macos_start_time_mismatch',
|
||||
'permission_denied',
|
||||
'pid_missing',
|
||||
'process_start_time_unavailable',
|
||||
'windows_command_line_unavailable',
|
||||
'windows_creation_time_mismatch',
|
||||
'windows_identity_match',
|
||||
'windows_named_pipe_missing',
|
||||
'windows_process_missing',
|
||||
'windows_process_start_time_unavailable'
|
||||
])
|
||||
.nullable(),
|
||||
process_reason: z.enum(DAEMON_AUDIT_PROCESS_REASON_VALUES).nullable(),
|
||||
endpoint_state: z.enum(['missing', 'named-pipe', 'non-socket', 'socket', 'unknown'])
|
||||
})
|
||||
.strict()
|
||||
|
||||
Reference in New Issue
Block a user