diff --git a/config/packaged-runtime-node-modules.cjs b/config/packaged-runtime-node-modules.cjs index 71ca780d1b8..83c83c33bfc 100644 --- a/config/packaged-runtime-node-modules.cjs +++ b/config/packaged-runtime-node-modules.cjs @@ -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', diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs index 3227d957b24..73c51faa250 100644 --- a/config/scripts/electron-builder-config.test.mjs +++ b/config/scripts/electron-builder-config.test.mjs @@ -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 () => { diff --git a/src/main/runtime/agent-session-process-identity-probe.test.ts b/src/main/runtime/agent-session-process-identity-probe.test.ts index ff0d0e5bc0a..9ff4697c8c2 100644 --- a/src/main/runtime/agent-session-process-identity-probe.test.ts +++ b/src/main/runtime/agent-session-process-identity-probe.test.ts @@ -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( diff --git a/src/main/runtime/agent-session-process-identity-probe.ts b/src/main/runtime/agent-session-process-identity-probe.ts index de9696591f7..e1a05ae9221 100644 --- a/src/main/runtime/agent-session-process-identity-probe.ts +++ b/src/main/runtime/agent-session-process-identity-probe.ts @@ -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' } } diff --git a/src/main/runtime/agent-session-record-store-file.ts b/src/main/runtime/agent-session-record-store-file.ts index 0be4f1652df..f490c4d3a36 100644 --- a/src/main/runtime/agent-session-record-store-file.ts +++ b/src/main/runtime/agent-session-record-store-file.ts @@ -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 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 } diff --git a/src/main/runtime/agent-session-record-store.test.ts b/src/main/runtime/agent-session-record-store.test.ts index 00f7fdcb5cc..4074dc76cc9 100644 --- a/src/main/runtime/agent-session-record-store.test.ts +++ b/src/main/runtime/agent-session-record-store.test.ts @@ -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)