fix(ai-vault): block deletion of live sessions

This commit is contained in:
Jinwoo-H
2026-08-07 20:16:45 -04:00
parent 7a867f12aa
commit 8e2fcfd681
14 changed files with 736 additions and 15 deletions
+86
View File
@@ -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",
@@ -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<AiVaultSessionLiveness> }
) => Promise<AiVaultDeleteSessionResult>
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<AiVaultSessionLiveness>
): 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<string>()
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<string>()
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)
})
})
+9 -1
View File
@@ -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<typeof deleteAiVaultSessionFileWithLiveness>[0]
) {
return deleteAiVaultSessionFileWithLiveness(args, {
getSessionLiveness: async () => 'not-live'
})
}
function enoent(): NodeJS.ErrnoException {
const error = new Error('not found') as NodeJS.ErrnoException
error.code = 'ENOENT'
+29 -2
View File
@@ -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<AiVaultSessionLiveness>
}
): Promise<AiVaultDeleteSessionResult> {
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)
+230
View File
@@ -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> = {}
): 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<PtyProcessInfo[]>((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<void>((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()
})
})
+163
View File
@@ -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<PtyProcessInfo[]>
getStatusSnapshot: () => AgentStatusIpcPayload[]
inspectForegroundProcess: (ptyId: string) => Promise<ProcessInspection>
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<AiVaultSessionIdentityRead> {
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<T>(
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<void> => {
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<AiVaultSessionLiveness> {
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'
}
+17 -7
View File
@@ -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<AiVaultSessionLiveness>
}
// 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
+6 -2
View File
@@ -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()
})
+7 -1
View File
@@ -62,6 +62,7 @@ type AiVaultHandlerOptions = AiVaultSessionSources &
AiVaultResumeHandlerOptions & {
getActiveRuntimeAiVaultHostInfos?: () => readonly RuntimeAiVaultHostInfo[]
scanRuntimeAiVaultSessions?: RuntimeAiVaultScanner
getSessionLiveness?: Parameters<typeof deleteAiVaultSession>[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<NonNullable<AiVaultHandlerOptions['getSessionLiveness']>>[0]
) => handlerOptions.getSessionLiveness?.(target) ?? Promise.resolve('unknown' as const)
}
async function listAiVaultSessions(
args?: AiVaultListArgs,
+2 -1
View File
@@ -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)
+74 -1
View File
@@ -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<AiVaultSessionLiveness> {
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<AiVaultPrepareSessionResumeResult> {
@@ -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
})
+6
View File
@@ -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
@@ -39,6 +39,7 @@ function deleteSession(page: Page, session: AiVaultSession): Promise<AiVaultDele
async (target) =>
window.api.aiVault.deleteSession({
agent: target.agent,
sessionId: target.sessionId,
filePath: target.filePath,
executionHostId: target.executionHostId
}),