mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 08:03:33 +00:00
fix(agent-session): fail closed on persisted authority
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user