mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 08:03:33 +00:00
Strip liveness gate from AI Vault session delete
Delete now requires only path validation + user confirmation — no process roster, no liveness check, no quiescence, no ownership ledger. Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -1,179 +0,0 @@
|
||||
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 { AgentStatusIpcPayload } from '../../shared/agent-status-types'
|
||||
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'
|
||||
import { resolveAiVaultSessionLiveness } from './session-liveness'
|
||||
|
||||
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('survives an exact live local hook identity absent from managed PTY inventory', async () => {
|
||||
trashItemMock.mockImplementation((path: string) => rm(path))
|
||||
const inspectForegroundProcess = vi.fn()
|
||||
const externalLiveStatus: AgentStatusIpcPayload = {
|
||||
paneKey: 'tab:external-live',
|
||||
terminalHandle: 'term_external-live',
|
||||
connectionId: null,
|
||||
receivedAt: 1,
|
||||
stateStartedAt: 1,
|
||||
state: 'working',
|
||||
prompt: 'test',
|
||||
agentType: 'gemini',
|
||||
providerSession: { key: 'session_id', id: 'session-live' }
|
||||
}
|
||||
|
||||
const { filePath, result } = await attemptDelete(() =>
|
||||
resolveAiVaultSessionLiveness(
|
||||
{ agent: 'gemini', sessionId: 'session-live' },
|
||||
{
|
||||
listProcesses: async () => [],
|
||||
getStatusSnapshot: () => [externalLiveStatus],
|
||||
inspectForegroundProcess,
|
||||
getStatusPtyId: () => 'external-live',
|
||||
getAgentHint: () => null
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
expect.soft(result).toEqual({
|
||||
outcome: 'rejected',
|
||||
agent: 'gemini',
|
||||
reason: 'session-liveness-unknown'
|
||||
})
|
||||
expect.soft(existsSync(filePath), 'external live transcript must survive on disk').toBe(true)
|
||||
expect(inspectForegroundProcess).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('survives a dismissed live row retained as liveness-only identity', async () => {
|
||||
trashItemMock.mockImplementation((path: string) => rm(path))
|
||||
const retainedIdentity: AgentStatusIpcPayload = {
|
||||
paneKey: 'tab:external-live',
|
||||
connectionId: null,
|
||||
receivedAt: 1,
|
||||
stateStartedAt: 1,
|
||||
state: 'working',
|
||||
prompt: 'test',
|
||||
agentType: 'gemini',
|
||||
providerSession: { key: 'session_id', id: 'session-live' },
|
||||
providerSessionOnly: true
|
||||
}
|
||||
|
||||
const { filePath, result } = await attemptDelete(() =>
|
||||
resolveAiVaultSessionLiveness(
|
||||
{ agent: 'gemini', sessionId: 'session-live' },
|
||||
{
|
||||
listProcesses: async () => [],
|
||||
getStatusSnapshot: () => [retainedIdentity],
|
||||
inspectForegroundProcess: vi.fn(),
|
||||
getStatusPtyId: () => null,
|
||||
getAgentHint: () => null
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
expect.soft(result).toEqual({
|
||||
outcome: 'rejected',
|
||||
agent: 'gemini',
|
||||
reason: 'session-liveness-unknown'
|
||||
})
|
||||
expect.soft(existsSync(filePath), 'dismissed live 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)
|
||||
})
|
||||
})
|
||||
@@ -33,21 +33,13 @@ vi.mock('../wsl-unc-delete', () => ({
|
||||
WslDeleteValidationError: WslDeleteValidationErrorMock
|
||||
}))
|
||||
|
||||
import { deleteAiVaultSessionFile as deleteAiVaultSessionFileWithLiveness } from './session-delete'
|
||||
import { deleteAiVaultSessionFile } 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'
|
||||
|
||||
@@ -2,10 +2,8 @@ import { lstat, realpath } from 'node:fs/promises'
|
||||
import { shell } from 'electron'
|
||||
import type {
|
||||
AiVaultDeleteSessionResult,
|
||||
AiVaultSessionDeleteRemoval,
|
||||
AiVaultSessionLiveness
|
||||
AiVaultSessionDeleteRemoval
|
||||
} from '../../shared/ai-vault-session-deletion'
|
||||
import type { AiVaultAgent } from '../../shared/ai-vault-types'
|
||||
import { isPathInsideOrEqual } from '../../shared/cross-platform-path'
|
||||
import {
|
||||
validateAiVaultSessionDeleteTarget,
|
||||
@@ -19,14 +17,7 @@ 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 & { sessionId?: string },
|
||||
deps: {
|
||||
getSessionLiveness: (target: {
|
||||
agent: AiVaultAgent
|
||||
sessionId: string | undefined
|
||||
filePath: string
|
||||
}) => Promise<AiVaultSessionLiveness>
|
||||
}
|
||||
args: ValidateAiVaultSessionDeleteTargetArgs & { sessionId?: string }
|
||||
): Promise<AiVaultDeleteSessionResult> {
|
||||
const validation = validateAiVaultSessionDeleteTarget(args)
|
||||
if (!validation.allowed) {
|
||||
@@ -34,24 +25,6 @@ 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)
|
||||
|
||||
@@ -1,263 +0,0 @@
|
||||
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('reads hook identity after a controlled foreground-inspection barrier', async () => {
|
||||
let markInspectionStarted: () => void = () => {}
|
||||
const inspectionStarted = new Promise<void>((resolve) => {
|
||||
markInspectionStarted = resolve
|
||||
})
|
||||
let releaseInspection: (inspection: {
|
||||
available: boolean
|
||||
process: string | null
|
||||
}) => void = () => {}
|
||||
const inspection = new Promise<{ available: boolean; process: string | null }>((resolve) => {
|
||||
releaseInspection = resolve
|
||||
})
|
||||
let statuses = [status({ sessionId: 'session-other', ptyId: 'managed-pty' })]
|
||||
const deps = dependencies({
|
||||
listProcesses: async () => [processInfo('managed-pty')],
|
||||
getStatusSnapshot: () => statuses,
|
||||
inspectForegroundProcess: () => {
|
||||
markInspectionStarted()
|
||||
return inspection
|
||||
}
|
||||
})
|
||||
|
||||
const liveness = resolveAiVaultSessionLiveness(
|
||||
{ agent: 'gemini', sessionId: 'session-live' },
|
||||
deps
|
||||
)
|
||||
await inspectionStarted
|
||||
statuses = [status({ sessionId: 'session-live', ptyId: 'managed-pty' })]
|
||||
releaseInspection({ available: true, process: 'gemini' })
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -1,187 +0,0 @@
|
||||
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
|
||||
}
|
||||
|
||||
type AgentProcessInspection = 'target-agent' | 'unknown' | 'other'
|
||||
|
||||
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' }
|
||||
|
||||
/** Binds the requested identity to the validated transcript on disk. */
|
||||
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<AgentProcessInspection>,
|
||||
deadlineMs?: number
|
||||
): Promise<AgentProcessInspection[]> {
|
||||
const results: AgentProcessInspection[] = []
|
||||
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
|
||||
}
|
||||
|
||||
/** Resolves owning-runtime liveness without treating unavailable evidence as absence. */
|
||||
export async function resolveAiVaultSessionLiveness(
|
||||
target: { agent: AiVaultAgent; sessionId: string | undefined },
|
||||
deps: AiVaultSessionLivenessDependencies
|
||||
): Promise<AiVaultSessionLiveness> {
|
||||
if (!isValidSessionId(target.sessionId)) {
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
let processes: PtyProcessInfo[]
|
||||
try {
|
||||
processes = await deps.listProcesses()
|
||||
} catch {
|
||||
return 'unknown'
|
||||
}
|
||||
if (processes.length > MAX_LIVENESS_PROCESSES) {
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
const processInspections = 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'
|
||||
}
|
||||
return 'target-agent'
|
||||
},
|
||||
deps.deadlineMs
|
||||
)
|
||||
|
||||
let localStatuses: AgentStatusIpcPayload[]
|
||||
try {
|
||||
localStatuses = deps.getStatusSnapshot().filter(isLocalStatus)
|
||||
} catch {
|
||||
return 'unknown'
|
||||
}
|
||||
const processIds = new Set(processes.map((process) => process.id))
|
||||
const hasUnmatchedTargetStatus = localStatuses.some(
|
||||
(status) =>
|
||||
status.agentType === target.agent &&
|
||||
status.providerSession?.id === target.sessionId &&
|
||||
!processIds.has(deps.getStatusPtyId(status) ?? '')
|
||||
)
|
||||
if (hasUnmatchedTargetStatus) {
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
const results = processInspections.map((inspection, index) => {
|
||||
if (inspection !== 'target-agent') {
|
||||
return inspection
|
||||
}
|
||||
const identities = localStatuses.filter(
|
||||
(status) =>
|
||||
status.agentType === target.agent && deps.getStatusPtyId(status) === processes[index]!.id
|
||||
)
|
||||
if (identities.some((status) => status.providerSession?.id === target.sessionId)) {
|
||||
return 'live'
|
||||
}
|
||||
return identities.some((status) => status.providerSession) ? 'other' : 'unknown'
|
||||
})
|
||||
if (results.includes('live')) {
|
||||
return 'live'
|
||||
}
|
||||
return results.includes('unknown') ? 'unknown' : 'not-live'
|
||||
}
|
||||
@@ -8,19 +8,13 @@ import { invalidateSessionParseCacheEntry } from '../ai-vault/session-scanner-pa
|
||||
import type { AiVaultAgent } from '../../shared/ai-vault-types'
|
||||
import type {
|
||||
AiVaultDeleteSessionArgs,
|
||||
AiVaultDeleteSessionResult,
|
||||
AiVaultSessionLiveness
|
||||
AiVaultDeleteSessionResult
|
||||
} 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.
|
||||
@@ -40,16 +34,13 @@ 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,
|
||||
sessionId: args?.sessionId,
|
||||
filePath: args?.filePath ?? '',
|
||||
executionHostId: args?.executionHostId,
|
||||
wslHomeDirs
|
||||
},
|
||||
deps
|
||||
)
|
||||
const result = await deleteAiVaultSessionFile({
|
||||
agent: args?.agent as AiVaultAgent,
|
||||
sessionId: args?.sessionId,
|
||||
filePath: args?.filePath ?? '',
|
||||
executionHostId: args?.executionHostId,
|
||||
wslHomeDirs
|
||||
})
|
||||
|
||||
if (result.outcome === 'deleted') {
|
||||
// Three caches could otherwise resurrect it: the desktop per-host leg
|
||||
|
||||
@@ -722,8 +722,7 @@ describe('deleteAiVaultSession', () => {
|
||||
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)
|
||||
@@ -772,8 +771,7 @@ describe('deleteAiVaultSession', () => {
|
||||
reason: 'invalid-path'
|
||||
})
|
||||
expect(mocks.deleteAiVaultSessionFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ filePath: '' }),
|
||||
expect.objectContaining({ getSessionLiveness: expect.any(Function) })
|
||||
expect.objectContaining({ filePath: '' })
|
||||
)
|
||||
expect(mocks.invalidateAiVaultSessionListCache).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -62,7 +62,6 @@ type AiVaultHandlerOptions = AiVaultSessionSources &
|
||||
AiVaultResumeHandlerOptions & {
|
||||
getActiveRuntimeAiVaultHostInfos?: () => readonly RuntimeAiVaultHostInfo[]
|
||||
scanRuntimeAiVaultSessions?: RuntimeAiVaultScanner
|
||||
getSessionLiveness?: Parameters<typeof deleteAiVaultSession>[1]['getSessionLiveness']
|
||||
}
|
||||
|
||||
let scanCoordinator = new AiVaultScanCoordinator()
|
||||
@@ -71,10 +70,7 @@ 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,
|
||||
getSessionLiveness: (
|
||||
target: Parameters<NonNullable<AiVaultHandlerOptions['getSessionLiveness']>>[0]
|
||||
) => handlerOptions.getSessionLiveness?.(target) ?? Promise.resolve('unknown' as const)
|
||||
invalidateMultiHostListCache: invalidateAiVaultHostLegCache
|
||||
}
|
||||
|
||||
async function listAiVaultSessions(
|
||||
|
||||
@@ -223,8 +223,7 @@ 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),
|
||||
getSessionLiveness: (target) => runtime.getAiVaultSessionLiveness(target)
|
||||
prepareRuntimeAiVaultSessionResume(app.getPath('userData'), environmentId, args)
|
||||
})
|
||||
registerNativeChatHandlers()
|
||||
registerClipboardHandlers(store)
|
||||
|
||||
@@ -485,12 +485,7 @@ import {
|
||||
configureAiVaultSessionSources,
|
||||
listAiVaultSessions
|
||||
} from '../ai-vault/cached-session-list'
|
||||
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 { AiVaultListArgs, AiVaultListResult } from '../../shared/ai-vault-types'
|
||||
import type {
|
||||
AiVaultPrepareSessionResumeArgs,
|
||||
AiVaultPrepareSessionResumeResult
|
||||
@@ -4957,74 +4952,6 @@ 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> {
|
||||
|
||||
@@ -85,7 +85,7 @@ export function VaultSessionRow({
|
||||
const detailsId = getSessionDetailsId(session.id)
|
||||
const latestTurn = latestSessionConversationTurn(session)
|
||||
// Computed once so the dropdown menu and the context menu never disagree.
|
||||
const deleteBlockedReason = aiVaultSessionDeleteBlockedReason(session, liveState)
|
||||
const deleteBlockedReason = aiVaultSessionDeleteBlockedReason(session)
|
||||
const requestDelete = (): void => onRequestDelete(session)
|
||||
const detailsTooltip = detailsExpanded
|
||||
? translate('auto.components.right.sidebar.AiVaultSessionRow.hideDetails', 'Hide Details')
|
||||
|
||||
@@ -5,7 +5,6 @@ import { aiVaultSessionDeleteBlockedReason } from './ai-vault-session-deletabili
|
||||
// assertions pin the English copy as well as the gate order.
|
||||
const NON_LOCAL = 'Only sessions on this device can be deleted.'
|
||||
const SYNTHETIC = "This session can't be deleted from Orca."
|
||||
const LIVE = 'This session is still running — wait for it to finish before deleting.'
|
||||
|
||||
const localGeminiSession = {
|
||||
agent: 'gemini' as const,
|
||||
@@ -28,28 +27,6 @@ describe('aiVaultSessionDeleteBlockedReason', () => {
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('tells the user to wait while the agent is still running', () => {
|
||||
for (const live of ['working', 'blocked', 'waiting'] as const) {
|
||||
expect(aiVaultSessionDeleteBlockedReason(localGeminiSession, live)).toBe(LIVE)
|
||||
}
|
||||
})
|
||||
|
||||
it('offers Delete for a finished session (done) and one with no live state', () => {
|
||||
expect(aiVaultSessionDeleteBlockedReason(localGeminiSession, 'done')).toBeNull()
|
||||
expect(aiVaultSessionDeleteBlockedReason(localGeminiSession, null)).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the permanent reason over "running" for an unsupported live session', () => {
|
||||
// A live but unsupported agent stays "unsupported" — it would never become
|
||||
// deletable, so "wait for it to finish" would mislead.
|
||||
expect(
|
||||
aiVaultSessionDeleteBlockedReason(
|
||||
{ agent: 'codex', executionHostId: 'local', filePath: '/home/user/.codex/x.jsonl' },
|
||||
'working'
|
||||
)
|
||||
).toBe("Codex sessions can't be deleted from Orca.")
|
||||
})
|
||||
|
||||
it('blocks ssh- and runtime-hosted sessions regardless of agent', () => {
|
||||
for (const executionHostId of ['ssh:dev-box', 'runtime:gpu-box'] as const) {
|
||||
expect(aiVaultSessionDeleteBlockedReason({ ...localGeminiSession, executionHostId })).toBe(
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { isAiVaultDeletableAgent } from '../../../../shared/ai-vault-session-deletion'
|
||||
import type { AiVaultSession } from '../../../../shared/ai-vault-types'
|
||||
import type { AgentStatusState } from '../../../../shared/agent-status-types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { agentLabel } from './ai-vault-session-filters'
|
||||
import {
|
||||
@@ -8,11 +7,6 @@ import {
|
||||
isSyntheticAiVaultSessionPath
|
||||
} from './ai-vault-session-path-actions'
|
||||
|
||||
// Matches the active-dot rule in ai-vault-session-row-display.
|
||||
function isSessionLive(liveState: AgentStatusState | null | undefined): boolean {
|
||||
return liveState != null && liveState !== 'done'
|
||||
}
|
||||
|
||||
/**
|
||||
* Why Delete is unavailable for this session, as the tooltip text to show — or
|
||||
* null when it is offered. Each message says which sessions are affected, never
|
||||
@@ -25,8 +19,7 @@ function isSessionLive(liveState: AgentStatusState | null | undefined): boolean
|
||||
* it does: both consult the same shared agent set and host/synthetic predicates.
|
||||
*/
|
||||
export function aiVaultSessionDeleteBlockedReason(
|
||||
session: Pick<AiVaultSession, 'agent' | 'executionHostId' | 'filePath'>,
|
||||
liveState?: AgentStatusState | null
|
||||
session: Pick<AiVaultSession, 'agent' | 'executionHostId' | 'filePath'>
|
||||
): string | null {
|
||||
if (!canUseLocalAiVaultSessionPathActions(session.executionHostId)) {
|
||||
return translate(
|
||||
@@ -47,14 +40,5 @@ export function aiVaultSessionDeleteBlockedReason(
|
||||
{ value0: agentLabel(session.agent) }
|
||||
)
|
||||
}
|
||||
// Last, so an otherwise-deletable session reads as "wait for it to finish"
|
||||
// rather than a permanent reason. Trashing a live transcript would drop the
|
||||
// writes the agent is still appending.
|
||||
if (isSessionLive(liveState)) {
|
||||
return translate(
|
||||
'auto.components.right.sidebar.AiVaultSessionRow.deleteReasonSessionLive',
|
||||
'This session is still running — wait for it to finish before deleting.'
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -71,10 +71,6 @@ 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
|
||||
|
||||
Reference in New Issue
Block a user