From 8e2fcfd68131a26a63d9b267b927fb494fc2cbcd Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Fri, 7 Aug 2026 20:16:45 -0400 Subject: [PATCH] fix(ai-vault): block deletion of live sessions --- config/reliability-gates.jsonc | 86 +++++++ .../session-delete-liveness.repro.test.ts | 105 ++++++++ src/main/ai-vault/session-delete.test.ts | 10 +- src/main/ai-vault/session-delete.ts | 31 ++- src/main/ai-vault/session-liveness.test.ts | 230 ++++++++++++++++++ src/main/ai-vault/session-liveness.ts | 163 +++++++++++++ src/main/ipc/ai-vault-delete.ts | 24 +- src/main/ipc/ai-vault.test.ts | 8 +- src/main/ipc/ai-vault.ts | 8 +- src/main/ipc/register-core-handlers.ts | 3 +- src/main/runtime/orca-runtime.ts | 75 +++++- .../ai-vault-session-delete-action.ts | 1 + src/shared/ai-vault-session-deletion.ts | 6 + tests/e2e/ai-vault-session-delete.spec.ts | 1 + 14 files changed, 736 insertions(+), 15 deletions(-) create mode 100644 src/main/ai-vault/session-delete-liveness.repro.test.ts create mode 100644 src/main/ai-vault/session-liveness.test.ts create mode 100644 src/main/ai-vault/session-liveness.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 056d295c677..46aba4f4ac6 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -10,6 +10,92 @@ } }, "gates": [ + { + "id": "ai-vault.live-session-delete-authority", + "title": "AI Vault deletion fails closed against owning-runtime session liveness", + "maturity": "experimental", + "protection": "partial", + "owner": "ai-vault-runtime", + "layer": "main-process-filesystem-mutation", + "surfaces": [ + "AI Vault transcript deletion", + "local and WSL agent sessions", + "paired-client-owned host sessions" + ], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "wsl", "ssh", "paired-runtime"], + "coveredPlatforms": ["macos", "linux", "windows"], + "coveredProviders": ["local", "wsl", "ssh", "paired-runtime"], + "coverageNotes": "Platform-independent contracts cover local and WSL authority, paired-owned sessions absent from renderer state, SSH exclusion, unavailable and ambiguous inventory, bounded inspection concurrency, oversized inventories, returned rejection, and real temporary-file survival. Live headed/headless paired-runtime, physical Windows/WSL, and Docker SSH journeys remain uncollected.", + "motivatingLinks": ["https://github.com/stablyai/orca/releases/tag/v1.4.177-rc.0"], + "invariant": "Main may delete a local AI transcript only after the owning runtime freshly proves that no live local or WSL PTY owns the same agent provider-session identity; live and unknown liveness preserve the transcript regardless of renderer freshness.", + "oracle": "Open confirmation against a stale empty renderer snapshot, transition the authoritative session to live before main authorization, and separately model a paired owner absent from that snapshot. In both cases require a session-live rejection and the real temporary transcript to remain. Make process inventory unavailable or ambiguous and require session-liveness-unknown with the file preserved; prove known other sessions permit deletion and inspection concurrency stays at or below eight.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/main/ai-vault/session-delete-liveness.repro.test.ts src/main/ai-vault/session-liveness.test.ts --reporter=dot" + ], + "testFiles": [ + "src/main/ai-vault/session-delete-liveness.repro.test.ts", + "src/main/ai-vault/session-liveness.test.ts" + ], + "assertionRefs": [ + { + "file": "src/main/ai-vault/session-delete-liveness.repro.test.ts", + "assertions": [ + "confirmation TOCTOU returns session-live and preserves the real transcript", + "paired ownership missing from the renderer snapshot returns session-live and preserves the real transcript", + "unknown authoritative liveness fails closed and preserves the real transcript" + ] + }, + { + "file": "src/main/ai-vault/session-liveness.test.ts", + "assertions": [ + "local, WSL, paired-runtime, and SSH host partitions resolve without local fallback", + "renderer session identity must match the identity parsed from the validated transcript", + "hook identity is captured after the controlled process-inventory barrier", + "missing identity, unavailable inventory, unattributed ownership, and oversized inventory remain unknown", + "foreground inspection concurrency never exceeds eight and stops scheduling at the shared deadline" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-08-07", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ai-vault/session-delete-liveness.repro.test.ts src/main/ai-vault/session-liveness.test.ts --reporter=dot", + "result": "passed", + "durationSeconds": 1, + "summary": "Fifteen deterministic deletion, identity-binding, inventory-ordering, ownership, unknown-state, filesystem-survival, host-partition, deadline, and bounded-concurrency assertions passed." + } + ], + "runtimeBudget": { + "p95Seconds": 2, + "scope": "focused main-process liveness and temporary-filesystem contracts" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "One local deterministic run is recorded; CI and soak history have not started." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "The byte-identical two-scenario oracle failed on v1.4.177-rc.0 (9e948fbdf4) and latest main (7a867f12aa): both returned deleted and both real transcripts were removed. The candidate passed all 15 assertions, including hook identity captured after a controlled process-inventory barrier; temporarily disabling only the liveness decision made all three deletion-safety tests red again, returning deleted and removing both live transcripts. Restoring the gate returned the same command to green." + }, + "performanceBudget": { + "required": true, + "evidence": "Deletion parses only the validated target through the incremental parse cache, performs one local-provider process listing, makes no SSH or paired-runtime request, caps inventory at 512 PTYs, bounds foreground inspections to eight concurrent calls under one shared three-second fail-closed deadline, and adds no polling or background work." + }, + "promotionCriteria": [ + "Collect CI soak history for the deterministic gate.", + "Collect headed and headless paired-runtime deletion-refusal evidence.", + "Collect physical Windows/WSL and Docker SSH host-partition evidence." + ], + "knownGaps": [ + "No live headed/headless paired-runtime journey was run.", + "No physical Windows/WSL or Docker SSH journey was run.", + "The authorization check cannot make an external provider process start atomic with filesystem trashing; it closes the reported renderer-confirmation window at the main mutation boundary." + ], + "demotionRule": "Keep experimental or demote if live or unknown ownership can reach trashing, remote ownership falls back locally, process inspection becomes unbounded, or the deterministic filesystem oracle flakes without a product or harness defect." + }, { "id": "editor.restored-sibling-owner-reparent", "title": "Restored sibling tabs migrate filesystem authority before becoming editable", diff --git a/src/main/ai-vault/session-delete-liveness.repro.test.ts b/src/main/ai-vault/session-delete-liveness.repro.test.ts new file mode 100644 index 00000000000..74f4ba8b174 --- /dev/null +++ b/src/main/ai-vault/session-delete-liveness.repro.test.ts @@ -0,0 +1,105 @@ +import { existsSync } from 'node:fs' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AiVaultDeleteSessionResult } from '../../shared/ai-vault-session-deletion' +import type { ValidateAiVaultSessionDeleteTargetArgs } from './session-delete-target' + +const { trashItemMock } = vi.hoisted(() => ({ trashItemMock: vi.fn() })) + +vi.mock('electron', () => ({ shell: { trashItem: trashItemMock } })) +vi.mock('../wsl-unc-delete', () => ({ tryDeleteWslUncPath: vi.fn().mockResolvedValue(false) })) + +import { deleteAiVaultSessionFile } from './session-delete' + +type AiVaultSessionLiveness = 'live' | 'not-live' | 'unknown' + +type DeleteWithLiveness = ( + args: ValidateAiVaultSessionDeleteTargetArgs & { sessionId: string }, + deps: { getSessionLiveness: () => Promise } +) => Promise + +const deleteWithLiveness = deleteAiVaultSessionFile as unknown as DeleteWithLiveness +const fixtureRoots: string[] = [] + +async function createTranscript(): Promise<{ filePath: string; root: string }> { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-live-delete-repro-')) + fixtureRoots.push(root) + const filePath = join(root, 'session-live.json') + await writeFile(filePath, '{"sessionId":"session-live"}\n') + return { filePath, root } +} + +async function attemptDelete( + getSessionLiveness: () => Promise +): Promise<{ filePath: string; result: AiVaultDeleteSessionResult }> { + const { filePath, root } = await createTranscript() + const result = await deleteWithLiveness( + { + agent: 'gemini', + sessionId: 'session-live', + filePath, + executionHostId: 'local', + rootOptions: { geminiSessionsDir: root } + }, + { getSessionLiveness } + ) + return { filePath, result } +} + +describe('live AI Vault session delete safety invariant', () => { + afterEach(async () => { + trashItemMock.mockReset() + await Promise.all( + fixtureRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })) + ) + }) + + it('survives when the session becomes live after confirmation but before main authorization', async () => { + trashItemMock.mockImplementation((path: string) => rm(path)) + const rendererPaneSnapshot = new Set() + expect(rendererPaneSnapshot.has('session-live')).toBe(false) + + let authoritativeLiveness: AiVaultSessionLiveness = 'not-live' + const confirmationOpened = true + authoritativeLiveness = 'live' + expect(confirmationOpened).toBe(true) + + const { filePath, result } = await attemptDelete(async () => authoritativeLiveness) + + expect.soft(result).toEqual({ outcome: 'rejected', agent: 'gemini', reason: 'session-live' }) + expect.soft(existsSync(filePath), 'live transcript must survive on disk').toBe(true) + }) + + it('survives when a paired owner is absent from the renderer pane snapshot', async () => { + trashItemMock.mockImplementation((path: string) => rm(path)) + const rendererPaneSnapshot = new Set() + const authoritativeOwner = { + runtimeId: 'host-runtime', + connectionId: 'paired-client', + generation: 'generation-1', + sessionId: 'session-live', + liveness: 'live' as const + } + expect(rendererPaneSnapshot.has(authoritativeOwner.sessionId)).toBe(false) + + const { filePath, result } = await attemptDelete(async () => authoritativeOwner.liveness) + + expect.soft(result).toEqual({ outcome: 'rejected', agent: 'gemini', reason: 'session-live' }) + expect.soft(existsSync(filePath), 'externally owned transcript must survive on disk').toBe(true) + }) + + it('fails closed when authoritative liveness is unavailable', async () => { + trashItemMock.mockImplementation((path: string) => rm(path)) + + const { filePath, result } = await attemptDelete(async () => 'unknown') + + expect(result).toEqual({ + outcome: 'rejected', + agent: 'gemini', + reason: 'session-liveness-unknown' + }) + expect(existsSync(filePath), 'unknown-liveness transcript must survive on disk').toBe(true) + }) +}) diff --git a/src/main/ai-vault/session-delete.test.ts b/src/main/ai-vault/session-delete.test.ts index eb25b11a820..93d26e96908 100644 --- a/src/main/ai-vault/session-delete.test.ts +++ b/src/main/ai-vault/session-delete.test.ts @@ -21,13 +21,21 @@ vi.mock('../wsl-unc-delete', () => ({ tryDeleteWslUncPath: tryDeleteWslUncPathMock })) -import { deleteAiVaultSessionFile } from './session-delete' +import { deleteAiVaultSessionFile as deleteAiVaultSessionFileWithLiveness } from './session-delete' const HOME = join('/tmp', 'orca-ai-vault-delete-exec-fixture-home') const GEMINI_ROOT = join(HOME, '.gemini', 'tmp') const CLAUDE_ROOT = join(HOME, '.claude', 'projects') const ROVO_ROOT = join(HOME, '.rovodev', 'sessions') +function deleteAiVaultSessionFile( + args: Parameters[0] +) { + return deleteAiVaultSessionFileWithLiveness(args, { + getSessionLiveness: async () => 'not-live' + }) +} + function enoent(): NodeJS.ErrnoException { const error = new Error('not found') as NodeJS.ErrnoException error.code = 'ENOENT' diff --git a/src/main/ai-vault/session-delete.ts b/src/main/ai-vault/session-delete.ts index d52725ff0fb..2e050a488e7 100644 --- a/src/main/ai-vault/session-delete.ts +++ b/src/main/ai-vault/session-delete.ts @@ -2,8 +2,10 @@ import { lstat, realpath } from 'node:fs/promises' import { shell } from 'electron' import type { AiVaultDeleteSessionResult, - AiVaultSessionDeleteRemoval + AiVaultSessionDeleteRemoval, + AiVaultSessionLiveness } from '../../shared/ai-vault-session-deletion' +import type { AiVaultAgent } from '../../shared/ai-vault-types' import { isPathInsideOrEqual } from '../../shared/cross-platform-path' import { validateAiVaultSessionDeleteTarget, @@ -17,7 +19,14 @@ import { isENOENT } from '../ipc/filesystem-auth' // Never throws: IPC payloads are untyped at runtime, so a bad input and an fs // error both resolve to a discriminated result the handler can render. export async function deleteAiVaultSessionFile( - args: ValidateAiVaultSessionDeleteTargetArgs + args: ValidateAiVaultSessionDeleteTargetArgs & { sessionId?: string }, + deps: { + getSessionLiveness: (target: { + agent: AiVaultAgent + sessionId: string | undefined + filePath: string + }) => Promise + } ): Promise { const validation = validateAiVaultSessionDeleteTarget(args) if (!validation.allowed) { @@ -25,6 +34,24 @@ export async function deleteAiVaultSessionFile( } const { agent, removals } = validation + let liveness: AiVaultSessionLiveness = 'unknown' + try { + liveness = await deps.getSessionLiveness({ + agent, + sessionId: args.sessionId, + filePath: validation.resolvedPath + }) + } catch { + // Inspection failure is unknown, never permission to delete. + } + if (liveness !== 'not-live') { + return { + outcome: 'rejected', + agent, + reason: liveness === 'live' ? 'session-live' : 'session-liveness-unknown' + } + } + try { for (const removal of removals) { const rejection = await removeOne(removal) diff --git a/src/main/ai-vault/session-liveness.test.ts b/src/main/ai-vault/session-liveness.test.ts new file mode 100644 index 00000000000..c45f61b6803 --- /dev/null +++ b/src/main/ai-vault/session-liveness.test.ts @@ -0,0 +1,230 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' +import type { PtyProcessInfo } from '../providers/pty-process-info' +import { + readAiVaultSessionIdentity, + resolveAiVaultSessionLiveness, + type AiVaultSessionLivenessDependencies +} from './session-liveness' + +function processInfo(id: string, terminalHandle = `term_${id}`): PtyProcessInfo { + return { id, terminalHandle, cwd: '/workspace', title: 'terminal' } +} + +function status(args: { + sessionId: string + ptyId: string + connectionId?: string | null +}): AgentStatusIpcPayload { + return { + paneKey: `tab:${args.ptyId}`, + terminalHandle: `term_${args.ptyId}`, + connectionId: args.connectionId ?? null, + receivedAt: 1, + stateStartedAt: 1, + state: 'working', + prompt: 'test', + agentType: 'gemini', + providerSession: { key: 'session_id', id: args.sessionId } + } +} + +function dependencies( + overrides: Partial = {} +): AiVaultSessionLivenessDependencies { + return { + listProcesses: async () => [], + getStatusSnapshot: () => [], + inspectForegroundProcess: async () => ({ available: true, process: 'zsh' }), + getStatusPtyId: (row) => row.terminalHandle?.slice('term_'.length) ?? null, + getAgentHint: () => null, + ...overrides + } +} + +describe('resolveAiVaultSessionLiveness', () => { + it('binds renderer identity to the session parsed from the validated transcript', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-delete-identity-')) + const filePath = join(root, 'session.json') + await writeFile( + filePath, + JSON.stringify({ + sessionId: 'authoritative-session', + startTime: '2026-08-07T00:00:00.000Z', + lastUpdated: '2026-08-07T00:01:00.000Z', + messages: [{ type: 'user', content: 'test' }] + }) + ) + try { + await expect( + readAiVaultSessionIdentity({ + agent: 'gemini', + sessionId: 'spoofed-session', + filePath + }) + ).resolves.toEqual({ outcome: 'unknown' }) + await expect( + readAiVaultSessionIdentity({ + agent: 'gemini', + sessionId: 'authoritative-session', + filePath + }) + ).resolves.toEqual({ outcome: 'found', sessionId: 'authoritative-session' }) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + it('finds a paired-owned live session without consulting a renderer pane snapshot', async () => { + const deps = dependencies({ + listProcesses: async () => [processInfo('paired-pty')], + getStatusSnapshot: () => [status({ sessionId: 'session-live', ptyId: 'paired-pty' })], + inspectForegroundProcess: async () => ({ available: true, process: 'gemini' }) + }) + + await expect( + resolveAiVaultSessionLiveness({ agent: 'gemini', sessionId: 'session-live' }, deps) + ).resolves.toBe('live') + }) + + it('reads hook identity after a controlled process-inventory barrier', async () => { + let releaseInventory: (processes: PtyProcessInfo[]) => void = () => {} + const inventory = new Promise((resolve) => { + releaseInventory = resolve + }) + let statuses = [status({ sessionId: 'session-other', ptyId: 'paired-pty' })] + const deps = dependencies({ + listProcesses: () => inventory, + getStatusSnapshot: () => statuses, + inspectForegroundProcess: async () => ({ available: true, process: 'gemini' }) + }) + + const liveness = resolveAiVaultSessionLiveness( + { agent: 'gemini', sessionId: 'session-live' }, + deps + ) + statuses = [status({ sessionId: 'session-live', ptyId: 'paired-pty' })] + releaseInventory([processInfo('paired-pty')]) + + await expect(liveness).resolves.toBe('live') + }) + + it('treats WSL hook ownership as local authority', async () => { + const deps = dependencies({ + listProcesses: async () => [processInfo('wsl-pty')], + getStatusSnapshot: () => [ + status({ sessionId: 'session-live', ptyId: 'wsl-pty', connectionId: 'wsl:Ubuntu' }) + ], + inspectForegroundProcess: async () => ({ available: true, process: 'gemini' }) + }) + + await expect( + resolveAiVaultSessionLiveness({ agent: 'gemini', sessionId: 'session-live' }, deps) + ).resolves.toBe('live') + }) + + it('does not use SSH identity as local liveness evidence', async () => { + const deps = dependencies({ + listProcesses: async () => [processInfo('local-pty')], + getStatusSnapshot: () => [ + status({ sessionId: 'session-live', ptyId: 'local-pty', connectionId: 'ssh:dev-box' }) + ], + inspectForegroundProcess: async () => ({ available: true, process: 'gemini' }) + }) + + await expect( + resolveAiVaultSessionLiveness({ agent: 'gemini', sessionId: 'session-live' }, deps) + ).resolves.toBe('unknown') + }) + + it('returns not-live when every local Gemini process owns another known session', async () => { + const deps = dependencies({ + listProcesses: async () => [processInfo('other-pty')], + getStatusSnapshot: () => [status({ sessionId: 'session-other', ptyId: 'other-pty' })], + inspectForegroundProcess: async () => ({ available: true, process: 'gemini' }) + }) + + await expect( + resolveAiVaultSessionLiveness({ agent: 'gemini', sessionId: 'session-target' }, deps) + ).resolves.toBe('not-live') + }) + + it.each([ + ['missing session identity', dependencies(), undefined], + [ + 'unavailable process inventory', + dependencies({ + listProcesses: async () => { + throw new Error('daemon offline') + } + }), + 'session-live' + ], + [ + 'unattributed live agent process', + dependencies({ + listProcesses: async () => [processInfo('unknown-pty')], + inspectForegroundProcess: async () => ({ available: true, process: 'gemini' }) + }), + 'session-live' + ] + ])('preserves unknown for %s', async (_name, deps, sessionId) => { + await expect(resolveAiVaultSessionLiveness({ agent: 'gemini', sessionId }, deps)).resolves.toBe( + 'unknown' + ) + }) + + it('bounds foreground inspection concurrency', async () => { + let active = 0 + let maxActive = 0 + const inspectForegroundProcess = vi.fn(async () => { + active += 1 + maxActive = Math.max(maxActive, active) + await new Promise((resolve) => queueMicrotask(resolve)) + active -= 1 + return { available: true, process: 'zsh' } + }) + const deps = dependencies({ + listProcesses: async () => + Array.from({ length: 20 }, (_, index) => processInfo(`shell-${index}`)), + inspectForegroundProcess + }) + + await expect( + resolveAiVaultSessionLiveness({ agent: 'gemini', sessionId: 'session-target' }, deps) + ).resolves.toBe('not-live') + expect(inspectForegroundProcess).toHaveBeenCalledTimes(20) + expect(maxActive).toBeLessThanOrEqual(8) + }) + + it('fails closed before inspecting an oversized inventory', async () => { + const inspectForegroundProcess = vi.fn() + const deps = dependencies({ + listProcesses: async () => + Array.from({ length: 513 }, (_, index) => processInfo(`shell-${index}`)), + inspectForegroundProcess + }) + + await expect( + resolveAiVaultSessionLiveness({ agent: 'gemini', sessionId: 'session-target' }, deps) + ).resolves.toBe('unknown') + expect(inspectForegroundProcess).not.toHaveBeenCalled() + }) + + it('does not start foreground inspections after the shared deadline', async () => { + const inspectForegroundProcess = vi.fn() + const deps = dependencies({ + deadlineMs: 0, + listProcesses: async () => [processInfo('late-pty')], + inspectForegroundProcess + }) + + await expect( + resolveAiVaultSessionLiveness({ agent: 'gemini', sessionId: 'session-target' }, deps) + ).resolves.toBe('unknown') + expect(inspectForegroundProcess).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ai-vault/session-liveness.ts b/src/main/ai-vault/session-liveness.ts new file mode 100644 index 00000000000..6540d5fd08b --- /dev/null +++ b/src/main/ai-vault/session-liveness.ts @@ -0,0 +1,163 @@ +import { isShellProcess } from '../../shared/agent-detection' +import { recognizeAgentProcess } from '../../shared/agent-process-recognition' +import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' +import type { AiVaultSessionLiveness } from '../../shared/ai-vault-session-deletion' +import type { AiVaultAgent } from '../../shared/ai-vault-types' +import type { PtyProcessInfo } from '../providers/pty-process-info' +import { isWslHookRelayConnectionId } from '../../shared/wsl-hook-relay-contract' +import { lstat } from 'node:fs/promises' +import { parseAgentSessionFileCached } from './session-scanner-parse-cache' + +const MAX_LIVENESS_PROCESSES = 512 +const PROCESS_INSPECTION_CONCURRENCY = 8 + +type ProcessInspection = { + available: boolean + process: string | null +} + +export type AiVaultSessionLivenessDependencies = { + deadlineMs?: number + listProcesses: () => Promise + getStatusSnapshot: () => AgentStatusIpcPayload[] + inspectForegroundProcess: (ptyId: string) => Promise + getStatusPtyId: (status: AgentStatusIpcPayload) => string | null + getAgentHint: (process: PtyProcessInfo) => string | null +} + +export type AiVaultSessionIdentityRead = + | { outcome: 'found'; sessionId: string } + | { outcome: 'missing' } + | { outcome: 'unknown' } + +export async function readAiVaultSessionIdentity(target: { + agent: AiVaultAgent + sessionId: string | undefined + filePath: string +}): Promise { + try { + const fileStats = await lstat(target.filePath) + if (!fileStats.isFile()) { + return { outcome: 'unknown' } + } + const session = await parseAgentSessionFileCached( + { + agent: target.agent, + file: { + path: target.filePath, + mtimeMs: fileStats.mtimeMs, + modifiedAt: fileStats.mtime.toISOString(), + sizeBytes: fileStats.size + }, + codexHome: null + }, + process.platform + ) + return session?.sessionId && session.sessionId === target.sessionId + ? { outcome: 'found', sessionId: session.sessionId } + : { outcome: 'unknown' } + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'ENOENT' + ? { outcome: 'missing' } + : { outcome: 'unknown' } + } +} + +function isLocalStatus(status: AgentStatusIpcPayload): boolean { + return status.connectionId === null || isWslHookRelayConnectionId(status.connectionId) +} + +function isValidSessionId(sessionId: string | undefined): sessionId is string { + return ( + typeof sessionId === 'string' && + sessionId.length > 0 && + sessionId.length <= 512 && + sessionId.trim() === sessionId + ) +} + +async function inspectInBatches( + values: readonly T[], + inspect: (value: T) => Promise<'live' | 'unknown' | 'other'>, + deadlineMs?: number +): Promise<('live' | 'unknown' | 'other')[]> { + const results: ('live' | 'unknown' | 'other')[] = [] + let nextIndex = 0 + const worker = async (): Promise => { + while (nextIndex < values.length) { + const index = nextIndex + nextIndex += 1 + if (deadlineMs !== undefined && Date.now() >= deadlineMs) { + results[index] = 'unknown' + continue + } + results[index] = await inspect(values[index]!) + } + } + await Promise.all( + Array.from({ length: Math.min(PROCESS_INSPECTION_CONCURRENCY, values.length) }, worker) + ) + return results +} + +export async function resolveAiVaultSessionLiveness( + target: { agent: AiVaultAgent; sessionId: string | undefined }, + deps: AiVaultSessionLivenessDependencies +): Promise { + if (!isValidSessionId(target.sessionId)) { + return 'unknown' + } + + let processes: PtyProcessInfo[] + let statuses: AgentStatusIpcPayload[] + try { + processes = await deps.listProcesses() + statuses = deps.getStatusSnapshot() + } catch { + return 'unknown' + } + if (processes.length > MAX_LIVENESS_PROCESSES) { + return 'unknown' + } + + const localStatuses = statuses.filter(isLocalStatus) + const results = await inspectInBatches( + processes, + async (process) => { + let inspection: ProcessInspection + try { + inspection = await deps.inspectForegroundProcess(process.id) + } catch { + return 'unknown' + } + if (!inspection.available) { + return 'unknown' + } + + const recognizedAgent = recognizeAgentProcess(inspection.process)?.agent ?? null + const hintedAgent = deps.getAgentHint(process) + const ownsTargetAgent = + recognizedAgent === target.agent || + (recognizedAgent === null && + (inspection.process === null || !isShellProcess(inspection.process)) && + hintedAgent === target.agent) + if (!ownsTargetAgent) { + return 'other' + } + + const identities = localStatuses.filter( + (status) => status.agentType === target.agent && deps.getStatusPtyId(status) === process.id + ) + if (identities.some((status) => status.providerSession?.id === target.sessionId)) { + return 'live' + } + return identities.some((status) => status.providerSession) ? 'other' : 'unknown' + }, + deps.deadlineMs + ) + + if (results.includes('live')) { + return 'live' + } + return results.includes('unknown') ? 'unknown' : 'not-live' +} diff --git a/src/main/ipc/ai-vault-delete.ts b/src/main/ipc/ai-vault-delete.ts index de3507d034e..3f3a1c8abd4 100644 --- a/src/main/ipc/ai-vault-delete.ts +++ b/src/main/ipc/ai-vault-delete.ts @@ -8,13 +8,19 @@ import { invalidateSessionParseCacheEntry } from '../ai-vault/session-scanner-pa import type { AiVaultAgent } from '../../shared/ai-vault-types' import type { AiVaultDeleteSessionArgs, - AiVaultDeleteSessionResult + AiVaultDeleteSessionResult, + AiVaultSessionLiveness } from '../../shared/ai-vault-session-deletion' // Which cache backs the multi-host list is ai-vault.ts's concern, so its // invalidation is injected rather than reached into from here. type AiVaultDeleteDeps = { invalidateMultiHostListCache: () => void + getSessionLiveness: (target: { + agent: AiVaultAgent + sessionId: string | undefined + filePath: string + }) => Promise } // Binds the delete orchestration to the caller's cache-invalidation seam. @@ -34,12 +40,16 @@ export async function deleteAiVaultSession( // The validator tolerates a malformed agent/filePath but destructures `args`, // so an absent payload is defaulted here to keep the never-throws boundary. const wslHomeDirs = await getAiVaultWslHomeDirs() - const result = await deleteAiVaultSessionFile({ - agent: args?.agent as AiVaultAgent, - filePath: args?.filePath ?? '', - executionHostId: args?.executionHostId, - wslHomeDirs - }) + const result = await deleteAiVaultSessionFile( + { + agent: args?.agent as AiVaultAgent, + sessionId: args?.sessionId, + filePath: args?.filePath ?? '', + executionHostId: args?.executionHostId, + wslHomeDirs + }, + deps + ) if (result.outcome === 'deleted') { // Three caches could otherwise resurrect it: the desktop per-host leg diff --git a/src/main/ipc/ai-vault.test.ts b/src/main/ipc/ai-vault.test.ts index ac0427e061e..c696ea71dfd 100644 --- a/src/main/ipc/ai-vault.test.ts +++ b/src/main/ipc/ai-vault.test.ts @@ -705,6 +705,7 @@ describe('listAiVaultSubagentSessions gating', () => { describe('deleteAiVaultSession', () => { const args = { agent: 'gemini' as const, + sessionId: 'session-1', filePath: '/home/ada/.gemini/tmp/sess.json', executionHostId: 'local' as const } @@ -718,9 +719,11 @@ describe('deleteAiVaultSession', () => { expect(mocks.deleteAiVaultSessionFile).toHaveBeenCalledWith( expect.objectContaining({ agent: 'gemini', + sessionId: args.sessionId, filePath: args.filePath, executionHostId: 'local' - }) + }), + expect.objectContaining({ getSessionLiveness: expect.any(Function) }) ) expect(mocks.invalidateAiVaultSessionListCache).toHaveBeenCalledTimes(1) expect(mocks.invalidateSessionParseCacheEntry).toHaveBeenCalledWith(args.filePath) @@ -769,7 +772,8 @@ describe('deleteAiVaultSession', () => { reason: 'invalid-path' }) expect(mocks.deleteAiVaultSessionFile).toHaveBeenCalledWith( - expect.objectContaining({ filePath: '' }) + expect.objectContaining({ filePath: '' }), + expect.objectContaining({ getSessionLiveness: expect.any(Function) }) ) expect(mocks.invalidateAiVaultSessionListCache).not.toHaveBeenCalled() }) diff --git a/src/main/ipc/ai-vault.ts b/src/main/ipc/ai-vault.ts index 4db28cf3cef..5701b97f230 100644 --- a/src/main/ipc/ai-vault.ts +++ b/src/main/ipc/ai-vault.ts @@ -62,6 +62,7 @@ type AiVaultHandlerOptions = AiVaultSessionSources & AiVaultResumeHandlerOptions & { getActiveRuntimeAiVaultHostInfos?: () => readonly RuntimeAiVaultHostInfo[] scanRuntimeAiVaultSessions?: RuntimeAiVaultScanner + getSessionLiveness?: Parameters[1]['getSessionLiveness'] } let scanCoordinator = new AiVaultScanCoordinator() @@ -69,7 +70,12 @@ let handlerOptions: AiVaultHandlerOptions = {} const listCancellations = createSenderScopedRequestCancellations() // Shared by the IPC registration and the test internals: a delete must drop // the multi-host leg cache, which this module owns the only caller of. -const aiVaultDeleteDeps = { invalidateMultiHostListCache: invalidateAiVaultHostLegCache } +const aiVaultDeleteDeps = { + invalidateMultiHostListCache: invalidateAiVaultHostLegCache, + getSessionLiveness: ( + target: Parameters>[0] + ) => handlerOptions.getSessionLiveness?.(target) ?? Promise.resolve('unknown' as const) +} async function listAiVaultSessions( args?: AiVaultListArgs, diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index ef903dcae05..90f1a0e647d 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -223,7 +223,8 @@ export function registerCoreHandlers( scanRuntimeAiVaultSessions: async (environmentId, args, options) => scanRuntimeAiVaultSessions(app.getPath('userData'), environmentId, args, options), prepareRuntimeSessionResume: async (environmentId, args) => - prepareRuntimeAiVaultSessionResume(app.getPath('userData'), environmentId, args) + prepareRuntimeAiVaultSessionResume(app.getPath('userData'), environmentId, args), + getSessionLiveness: (target) => runtime.getAiVaultSessionLiveness(target) }) registerNativeChatHandlers() registerClipboardHandlers(store) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 72bd2b0c12f..495ddcbecfb 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -476,7 +476,12 @@ import { configureAiVaultSessionSources, listAiVaultSessions } from '../ai-vault/cached-session-list' -import type { AiVaultListArgs, AiVaultListResult } from '../../shared/ai-vault-types' +import { + readAiVaultSessionIdentity, + resolveAiVaultSessionLiveness +} from '../ai-vault/session-liveness' +import type { AiVaultAgent, AiVaultListArgs, AiVaultListResult } from '../../shared/ai-vault-types' +import type { AiVaultSessionLiveness } from '../../shared/ai-vault-session-deletion' import type { AiVaultPrepareSessionResumeArgs, AiVaultPrepareSessionResumeResult @@ -4909,6 +4914,74 @@ export class OrcaRuntimeService { return listAiVaultSessions(args) } + async getAiVaultSessionLiveness(target: { + agent: AiVaultAgent + sessionId: string | undefined + filePath: string + }): Promise { + const provider = this.getLocalProviderFn?.() + if (!provider || !this.getAgentProviderSessionSnapshotFn) { + return 'unknown' + } + const identity = await readAiVaultSessionIdentity(target) + if (identity.outcome !== 'found') { + return 'unknown' + } + const deadlineMs = Date.now() + 3_000 + return await resolveAiVaultSessionLiveness( + { + agent: target.agent, + sessionId: identity.sessionId + }, + { + deadlineMs, + listProcesses: async () => { + const result = await withTimeoutResult( + provider.listProcesses({ deadlineMs }), + Math.max(1, deadlineMs - Date.now()) + ) + if (!result.ok) { + throw new Error('agent_session_ownership_unknown') + } + return result.value + }, + getStatusSnapshot: this.getAgentProviderSessionSnapshotFn, + inspectForegroundProcess: async (ptyId) => { + const result = await withTimeoutResult( + provider.getForegroundProcess(ptyId), + Math.max(1, deadlineMs - Date.now()) + ) + return result.ok + ? { available: true, process: result.value } + : { available: false, process: null } + }, + getStatusPtyId: (status) => { + if (status.terminalHandle) { + const live = this.getLivePtyForHandle(status.terminalHandle) + if (live) { + return live.pty.ptyId + } + for (const [ptyId, handle] of this.handleByPtyId) { + if (handle === status.terminalHandle) { + return ptyId + } + } + } + return this.getPtyRecordForPaneKey(status.paneKey)?.ptyId ?? null + }, + getAgentHint: (process) => { + const pty = this.ptysById.get(process.id) + const runtimeHint = pty?.foregroundAgent ?? pty?.launchAgent + if (runtimeHint) { + return runtimeHint + } + const ownerAgents = new Set(process.agentSessionOwners?.map((owner) => owner.claim.agent)) + return ownerAgents.size === 1 ? ([...ownerAgents][0] ?? null) : null + } + } + ) + } + prepareAiVaultSessionResume( args: AiVaultPrepareSessionResumeArgs ): Promise { diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-delete-action.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-delete-action.ts index 629165a1a0c..fcd13403a53 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-delete-action.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-delete-action.ts @@ -40,6 +40,7 @@ export function useAiVaultSessionDeleteAction({ try { const result = await window.api.aiVault.deleteSession({ agent: session.agent, + sessionId: session.sessionId, filePath: session.filePath, executionHostId: session.executionHostId }) diff --git a/src/shared/ai-vault-session-deletion.ts b/src/shared/ai-vault-session-deletion.ts index ce605022236..7fdde68b0e4 100644 --- a/src/shared/ai-vault-session-deletion.ts +++ b/src/shared/ai-vault-session-deletion.ts @@ -4,6 +4,8 @@ import type { ExecutionHostId } from './execution-host' // IPC payload for aiVault:deleteSession. export type AiVaultDeleteSessionArgs = { agent: AiVaultAgent + // Optional for mixed renderer/main versions; main rejects missing identity. + sessionId?: string filePath: string // The session's host; only a local session may be deleted. executionHostId?: ExecutionHostId @@ -69,6 +71,10 @@ export type AiVaultSessionDeleteRejectionCode = // fs-side guard: lstat disagrees with the removal's declared kind (a symlink, // or a file where the plan expects a directory). | 'unexpected-target-kind' + | 'session-live' + | 'session-liveness-unknown' + +export type AiVaultSessionLiveness = 'live' | 'not-live' | 'unknown' // One path the executor removes. A `kind` mismatch on disk is a rejection, // never a coerced delete. `roots` are what the path's realpath must still diff --git a/tests/e2e/ai-vault-session-delete.spec.ts b/tests/e2e/ai-vault-session-delete.spec.ts index db2b387e957..363b3c85eca 100644 --- a/tests/e2e/ai-vault-session-delete.spec.ts +++ b/tests/e2e/ai-vault-session-delete.spec.ts @@ -39,6 +39,7 @@ function deleteSession(page: Page, session: AiVaultSession): Promise window.api.aiVault.deleteSession({ agent: target.agent, + sessionId: target.sessionId, filePath: target.filePath, executionHostId: target.executionHostId }),