fix(agent-session): fail closed on persisted authority

This commit is contained in:
Brennan Benson
2026-08-07 14:47:13 -07:00
parent 7c4904898d
commit b82f73a260
6 changed files with 53 additions and 13 deletions
+1
View File
@@ -22,6 +22,7 @@ const PACKAGED_RUNTIME_PACKAGE_ROOTS = [
'jsonc-parser',
'node-pty',
'posthog-node',
'proper-lockfile',
// serve-sim (for CLI JS entry + closure + state/middleware + to make packaged require('serve-sim') + its internal relatives work; mirrors other runtime JS like ws/yaml/zod. Natives/dylibs still via extraResources + the node_modules/serve-sim copy in resources from builder. Client if added too.
'serve-sim',
'qrcode',
@@ -488,7 +488,7 @@ describe('electron-builder config', () => {
}
})
it('includes @parcel/watcher in the packaged runtime closure', () => {
it('includes external main dependencies in the packaged runtime closure', () => {
// Why: the main process imports '@parcel/watcher' for filesystem change
// events; if it is absent from the packaged closure the serve host silently
// stops propagating file changes to clients (regression guard for #4851).
@@ -500,6 +500,7 @@ describe('electron-builder config', () => {
target.startsWith(join('node_modules', '@parcel', 'watcher-'))
)
).toBe(true)
expect(packagedTargets).toContain(join('node_modules', 'proper-lockfile'))
})
it('prunes non-target @parcel/watcher architecture subpackages', async () => {
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import type { AgentSessionProcessIdentity } from '../../shared/agent-session-record'
import {
PROCESS_START_TIME_TOLERANCE_MS,
@@ -51,6 +51,21 @@ describe('owner identity probe', () => {
).resolves.toEqual({ outcome: 'pid-absent' })
})
it('does not call an unexpected host probe error proof of death', async () => {
const kill = vi.spyOn(process, 'kill').mockImplementation(() => {
throw Object.assign(new Error('host probe unavailable'), { code: 'EIO' })
})
try {
const probe = await probeAgentSessionProcessIdentity({
identity: { ...IDENTITY, processStartTimeMs: null },
deps: { readEchoedSpawnToken: async () => null }
})
expect(probe.outcome).toBe('indeterminate')
} finally {
kill.mockRestore()
}
})
it('catches pid reuse through the spawn token', async () => {
// The pid is live and started at the recorded time, but it is a different process.
await expect(
@@ -38,8 +38,8 @@ function defaultIsPidPresent(pid: number): boolean {
process.kill(pid, 0)
return true
} catch (error) {
// Why: EPERM proves the pid exists under another user; only ESRCH proves absence.
return (error as NodeJS.ErrnoException)?.code === 'EPERM'
// Why: only ESRCH proves absence; permission and transient host failures must fail closed.
return (error as NodeJS.ErrnoException)?.code !== 'ESRCH'
}
}
@@ -11,6 +11,7 @@ import { createHash } from 'node:crypto'
import { mkdir, readFile, rename, rm } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import {
agentSessionOperationKey,
isAgentSessionOperationRow,
type AgentSessionOperationRow
} from '../../shared/agent-session-operation-ledger'
@@ -92,21 +93,22 @@ function parseState(raw: string, hostId: string): { state: AgentSessionStoreStat
) {
return null
}
const schemaVersion = file.schemaVersion as number
if (
(file.schemaVersion as number) <= AGENT_SESSION_STORE_SCHEMA_VERSION &&
schemaVersion <= AGENT_SESSION_STORE_SCHEMA_VERSION &&
(typeof file.records !== 'object' ||
file.records === null ||
Array.isArray(file.records) ||
typeof file.operations !== 'object' ||
file.operations === null ||
Array.isArray(file.operations) ||
((file.schemaVersion as number) === AGENT_SESSION_STORE_SCHEMA_VERSION &&
(schemaVersion === AGENT_SESSION_STORE_SCHEMA_VERSION &&
!Array.isArray(file.retiredClaimKeys)))
) {
return null
}
const state = emptyState(hostId)
state.schemaVersion = file.schemaVersion as number
state.schemaVersion = schemaVersion
state.hostId = file.hostId
if (typeof file.records === 'object' && file.records !== null) {
for (const [sessionId, value] of Object.entries(file.records)) {
@@ -121,21 +123,37 @@ function parseState(raw: string, hostId: string): { state: AgentSessionStoreStat
}
if (typeof file.operations === 'object' && file.operations !== null) {
for (const [key, value] of Object.entries(file.operations)) {
if (isAgentSessionOperationRow(value)) {
state.operations.set(key, value)
if (!isAgentSessionOperationRow(value)) {
if (schemaVersion <= AGENT_SESSION_STORE_SCHEMA_VERSION) {
return null
}
continue
}
if (key !== agentSessionOperationKey(value.callerKey, value.operationId)) {
if (schemaVersion <= AGENT_SESSION_STORE_SCHEMA_VERSION) {
return null
}
continue
}
state.operations.set(key, value)
}
}
if (Array.isArray(file.retiredClaimKeys)) {
for (const entry of file.retiredClaimKeys) {
const key = entry as Partial<RetiredAgentSessionClaimKey>
if (
typeof key?.keyId === 'string' &&
typeof key.retiredAt === 'number' &&
Number.isSafeInteger(key.retiredAt)
typeof key?.keyId !== 'string' ||
key.keyId.length === 0 ||
key.keyId.length > 512 ||
!Number.isSafeInteger(key.retiredAt) ||
(key.retiredAt as number) < 0
) {
state.retiredClaimKeys.push({ keyId: key.keyId, retiredAt: key.retiredAt })
if (schemaVersion <= AGENT_SESSION_STORE_SCHEMA_VERSION) {
return null
}
continue
}
state.retiredClaimKeys.push({ keyId: key.keyId, retiredAt: key.retiredAt as number })
}
}
return { state }
@@ -38,6 +38,9 @@ const FOLDER: AgentSessionExecutionLocation = {
const MATCHED: AgentSessionOwnerProbe = { outcome: 'identity-matched', matchedOn: ['spawn-token'] }
const INDETERMINATE: AgentSessionOwnerProbe = { outcome: 'indeterminate', reason: 'no answer' }
const UNUSED: AgentSessionOwnerProbe = { outcome: 'reservation-unused' }
const BAD_OP_STORE = '{"schemaVersion":0,"hostId":"","records":{},"operations":{"x":0}}'
const BAD_KEY_STORE =
'{"schemaVersion":1,"hostId":"","records":{},"operations":{},"retiredClaimKeys":[0]}'
let counter = 0
@@ -834,6 +837,8 @@ describe('orphans, claim keys, checkpoints, and unreadable rows', () => {
it.each([
['corrupt', ['{ truncated']],
['missing required collections', ['{"schemaVersion":1,"hostId":"local"}']],
['invalid operation row', [BAD_OP_STORE]],
['invalid retired key', [BAD_KEY_STORE]],
['corrupt in both committed copies', ['{ truncated', '{ also truncated']]
])('fails closed when the store is %s', async (_name, copies) => {
const filePath = agentSessionStorePath(directory)