Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions config/scripts/vitest-sqlite-runtime-files.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ export const SQLITE_RUNTIME_INCLUDE = [
'src/main/runtime/agent-session-record-options.test.ts',
'src/main/runtime/agent-session-record-store-tab-table.test.ts',
'src/main/runtime/agent-session-record-store.test.ts',
'src/main/runtime/agent-session-permission-backfill.test.ts',
'src/main/runtime/agent-session-released-reservation-replay.test.ts',
'src/main/runtime/agent-session-runtime-attribution.test.ts',
'src/main/runtime/agent-session-store-transactions.test.ts',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ async function startHost(): Promise<void> {
...(readAcquisitionOptions ? { readAcquisitionOptions } : {})
},
modelCatalog: {
read: vi.fn(),
read: vi.fn(async () => ({ origin: 'unknown' as const })),
recordLiveListing: vi.fn(),
prewarm: vi.fn(async () => {}),
stop: vi.fn(),
Expand Down Expand Up @@ -637,3 +637,24 @@ describe('the attempt an acquire runs under', () => {
)
})
})

describe('a chat after startup permission backfill', () => {
it.each(['ask', 'full-access'] as const)(
'keeps %s before and after the first start, regardless of later Arguments',
async (mode) => {
await host.close(SESSION, 'evict')
await store.backfillChatPermissionModes(() => mode === 'full-access')
expect((await host.readOptions(SESSION)).current.permissionMode).toBe(mode)
await store.backfillChatPermissionModes(() => mode === 'ask')
await restartWith(publishFirst)
await heldBehindStart('hello')
expect(acquire.mock.calls.at(-1)![0].permissionMode).toBe(mode)
await host.handleAdapterEvent(startedEvent())
await vi.advanceTimersByTimeAsync(50)
await host.flushStreamedEvents(SESSION)
expect(store.getRecord(SESSION)?.options?.permissionMode).toBe(mode)
await store.backfillChatPermissionModes(() => mode === 'ask')
expect(store.getRecord(SESSION)?.options?.permissionMode).toBe(mode)
}
)
})
203 changes: 203 additions & 0 deletions src/main/runtime/agent-session-permission-backfill.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding'
import { foundAgentSessionRecord } from './agent-session-record-founding'
import {
openTestAgentSessionRecordStore,
seedTestAgentSessionRecordStore,
seedTestAgentSessionStoreFromNewerBuild
} from './agent-session-record-store-test-harness'
import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support'

let root: string
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'orca-permission-backfill-'))
})
afterEach(async () => {
await rm(root, { recursive: true, force: true })

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Close the test database before removing root.

The test database registry keeps its SQLite connection open after each test. On Windows, the open database can block rm(root), so the cleanup fails. Call closeTestJournalHostDatabases() before rm, as the adjacent store-transaction tests do. (raw.githubusercontent.com)

})

function record(provider: string, options?: Record<string, string>) {
return foundAgentSessionRecord(
{
sessionId: `session-${provider}`,
provider,
location: {
executionHostId: 'local',
workspaceId: 'folder-1',
workspaceKind: 'folder',
wslDistro: null
},
accountHome: {
variable: provider === 'claude' ? 'CLAUDE_CONFIG_DIR' : 'CODEX_HOME',
path: '/account'
},
...(options ? { options } : {})
},
{ claimKeyId: 'key-1', now: 1 }
)
}

describe('existing chat permission backfill', () => {
it.each(['full-access', 'ask'] as const)(
"saves %s from the agent's own Arguments for old Claude and Codex rows, keeping other options",
async (mode) => {
await seedTestAgentSessionRecordStore(root, {
records: [record('claude', { model: 'opus', effort: 'high' }), record('codex')]
})
const store = await openTestAgentSessionRecordStore(root)
const beforeClaude = store.getRecord('session-claude')
const beforeCodex = store.getRecord('session-codex')
await store.backfillChatPermissionModes(() => mode === 'full-access')
expect(store.getRecord('session-claude')?.options).toEqual({
model: 'opus',
effort: 'high',
permissionMode: mode
})
expect(store.getRecord('session-codex')?.options).toEqual({ permissionMode: mode })
expect(store.getRecord('session-claude')).toEqual({
...beforeClaude,
options: { model: 'opus', effort: 'high', permissionMode: mode }
})
expect(store.getRecord('session-codex')).toEqual({
...beforeCodex,
options: { permissionMode: mode }
})
const reopened = await openTestAgentSessionRecordStore(root)
expect(reopened.getRecord('session-claude')?.options).toEqual(
store.getRecord('session-claude')?.options
)
expect(reopened.getRecord('session-codex')?.options).toEqual(
store.getRecord('session-codex')?.options
)
}
)

it.each(['ask', 'full-access', 'invalid-saved-mode'] as const)(
'never replaces saved %s or options on other providers',
async (mode) => {
await seedTestAgentSessionRecordStore(root, {
records: [
record('claude', { permissionMode: mode }),
record('codex', { permissionMode: mode }),
record('grok', { model: 'grok' })
]
})
const store = await openTestAgentSessionRecordStore(root)
const before = store.listRecords()
await store.backfillChatPermissionModes(() => mode !== 'full-access')
expect(store.listRecords()).toEqual(before)
expect(store.getRecord('session-grok')?.options).toEqual({ model: 'grok' })
}
)

it('does no row write on its second pass, even with changed Arguments', async () => {
await seedTestAgentSessionRecordStore(root, { records: [record('claude'), record('codex')] })
const store = await openTestAgentSessionRecordStore(root)
await store.backfillChatPermissionModes(() => true)
const before = store.listRecords()
const db = openTestJournalHostDatabase(root).db
db.exec(
"CREATE TRIGGER refuse_backfill_write BEFORE UPDATE ON agent_session_records BEGIN SELECT RAISE(ABORT, 'unexpected second write'); END"
)
await expect(store.backfillChatPermissionModes(() => false)).resolves.toBeUndefined()
expect(store.listRecords()).toEqual(before)
expect(store.getRecord('session-claude')).toBe(before.find((row) => row.provider === 'claude'))
expect(store.getRecord('session-codex')?.options?.permissionMode).toBe('full-access')
})

it('skips a store written by a newer build without an error or in-memory change', async () => {
await seedTestAgentSessionRecordStore(root, { records: [record('claude')] })
await seedTestAgentSessionStoreFromNewerBuild(root)
const store = await openTestAgentSessionRecordStore(root)
expect(store.readOnly).toBe(true)
expect(store.getRecord('session-claude')).not.toBeNull()
await expect(store.backfillChatPermissionModes(() => true)).resolves.toBeUndefined()
expect(store.getRecord('session-claude')?.options).toBeUndefined()
})

it.each([
['claude', 'full-access', 'ask'],
['codex', 'ask', 'full-access']
] as const)(
"fills each row from its own agent's Arguments when only %s skips prompts",
async (bypassing, claude, codex) => {
await seedTestAgentSessionRecordStore(root, {
records: [record('claude'), record('codex'), record('grok')]
})
const store = await openTestAgentSessionRecordStore(root)
await store.backfillChatPermissionModes((provider) => provider === bypassing)
expect(store.getRecord('session-claude')?.options).toEqual({ permissionMode: claude })
expect(store.getRecord('session-codex')?.options).toEqual({ permissionMode: codex })
expect(store.getRecord('session-grok')?.options).toBeUndefined()
}
)

it('preserves the existing owner identity, fence, timestamps and provider history', async () => {
const old = record('codex', { model: 'old-model', effort: 'high' })
await seedTestAgentSessionRecordStore(root, {
records: [
{
...old,
lease: {
...old.lease,
runtimeFence: 7,
claimStatus: 'reserved',
handoffStage: 'new-owner-proving',
reservedSpawnToken: 'old-spawn',
ownerProcess: {
hostId: 'local',
pid: 42,
processStartTimeMs: 1,
spawnToken: 'old-spawn'
}
},
providerHandleChain: [
{
linkId: 'old-link',
handle: codexProviderHandle('old-thread'),
origin: 'created',
mintedAtFence: 6,
observedAt: 1
}
]
}
]
})
const store = await openTestAgentSessionRecordStore(root)
const before = store.getRecord('session-codex')
expect(before).not.toBeNull()
await store.backfillChatPermissionModes(() => true)
expect(store.getRecord('session-codex')).toEqual({
...before,
options: {
model: 'old-model',
effort: 'high',
permissionMode: 'full-access'
}
})
})

it('rolls back every row if saving fails, then succeeds on the next startup pass', async () => {
await seedTestAgentSessionRecordStore(root, { records: [record('claude'), record('codex')] })
const store = await openTestAgentSessionRecordStore(root)
const db = openTestJournalHostDatabase(root).db
db.exec(
"CREATE TRIGGER refuse_backfill_write BEFORE UPDATE ON agent_session_records WHEN NEW.session_id = 'session-codex' BEGIN SELECT RAISE(ABORT, 'disk full'); END"
)
await expect(store.backfillChatPermissionModes(() => true)).rejects.toThrow('disk full')
expect(store.listRecords().every((row) => row.options === undefined)).toBe(true)
expect(
(await openTestAgentSessionRecordStore(root))
.listRecords()
.every((row) => row.options === undefined)
).toBe(true)
db.exec('DROP TRIGGER refuse_backfill_write')
await store.backfillChatPermissionModes(() => true)
expect(store.listRecords().every((row) => row.options?.permissionMode === 'full-access')).toBe(
true
)
})
})
32 changes: 32 additions & 0 deletions src/main/runtime/agent-session-permission-backfill.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import {
AGENT_CHAT_PERMISSION_MODE_OPTION_ID,
newChatPermissionMode
} from '../../shared/agent-chat-permission-mode'
import type { AgentSessionStoreTransactions } from './agent-session-store-transactions'

/** One transaction as the chat host installs; before modes, chats ran their own agent's Arguments. */
export function backfillAgentSessionPermissionModes(
store: AgentSessionStoreTransactions,
ranWithFullAccess: (provider: string) => boolean
): Promise<void> {
if (store.readOnly) {
return Promise.resolve()
}
return store.transact((draft) => {
for (const [sessionId, record] of draft.records) {
if (record.options?.[AGENT_CHAT_PERMISSION_MODE_OPTION_ID] !== undefined) {
continue
}
const permissionMode = newChatPermissionMode(
record.provider,
ranWithFullAccess(record.provider) ? 'full-access' : 'ask'
)
if (permissionMode) {
draft.records.set(sessionId, {
...record,
options: { ...record.options, [AGENT_CHAT_PERMISSION_MODE_OPTION_ID]: permissionMode }
})
}
}
})
}
15 changes: 15 additions & 0 deletions src/main/runtime/agent-session-record-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,21 @@ describe('acquisition path', () => {
)
).rejects.toThrow('agent_session_conflict')
})

it("takes request options only for a record it creates, so an existing chat's are kept", async () => {
const store = await open()
await establishOwner(store, { options: { permissionMode: 'ask' } })
await store.reserveOwner(
reserveRequest({
options: { permissionMode: 'full-access' },
expectedFence: 1,
probe: { outcome: 'pid-absent' },
operation: { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-2' }
})
)
expect(store.getRecord('session-alpha')?.lease.runtimeFence).toBe(2)
expect(store.getRecord('session-alpha')?.options).toEqual({ permissionMode: 'ask' })
})
})

describe('concurrent claims', () => {
Expand Down
4 changes: 4 additions & 0 deletions src/main/runtime/agent-session-record-store.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/** Durable single-writer session records and their operation ledger, as rows in the host's chat
* journal database. */

import { backfillAgentSessionPermissionModes } from './agent-session-permission-backfill'
import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals'
import { createAgentSessionConversationReceipts } from './agent-session-conversation-receipts'
import { commitConversationCommandRecord } from './agent-session-conversation-command-record'
Expand Down Expand Up @@ -115,6 +116,9 @@ export class AgentSessionRecordStore {
return this.transactions.readOnly
}

backfillChatPermissionModes = (ranWithFullAccess: (provider: string) => boolean): Promise<void> =>
backfillAgentSessionPermissionModes(this.transactions, ranWithFullAccess)

getRecord = (sessionId: string): AgentSessionRecord | null =>
this.state.records.get(sessionId) ?? null

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ beforeEach(async () => {
getRuntimeId: () => 'runtime-1',
getClientSettings: () => ({ experimentalNativeChat: true }),
getStructuredAgentSessionCreateSupport: async () => ({ supported: true }),
structuredAgentSessionLaunchSeedOptions: () => undefined,
resolveStructuredAgentSessionCreateIntent: async (input: { envelope: unknown }) => ({
...ensureParams(1),
envelope: input.envelope,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ describe('execution host structured launch settings wiring', () => {
settings.agentDefaultArgs = { claude: '--model second', codex: '' }
expect(await deps?.resolveLaunchArgs?.('claude')).toEqual(['--model', 'second'])
expect(await deps?.resolveLaunchArgs?.('codex')).toEqual([])
settings.agentDefaultArgs = { claude: '--dangerously-skip-permissions', codex: '' }
expect(deps?.resolveAgentFullAccess?.('claude')).toBe(true)
expect(deps?.resolveAgentFullAccess?.('codex')).toBe(false)
const notRunnable = expect.objectContaining({ reason: 'agentCommandNotRunnable' })
settings.agentCmdOverrides = { claude: 'wrapper --arg' }
expect(() => deps?.resolveClaudeCommand?.()).toThrow(notRunnable)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from '../../../../shared/agent-chat-permission-mode'
import type { z } from 'zod'
import { agentSessionRefusalError } from '../../../../shared/agent-session-wire-refusals'
import { agentSessionFingerprintConflict } from '../../../../shared/agent-session-mutation-envelope'
Expand Down Expand Up @@ -32,7 +33,16 @@ async function resolveClientSuppliedAttach(params: z.infer<typeof AttachParams>,
reason: 'hostUnsupported'
})
}
const attachParams: AgentSessionAttachParams = params
// Reservation applies options only to a record it creates, so an existing chat is never reseeded.
const permissionMode = ctx.runtime.structuredAgentSessionLaunchSeedOptions(params.agent)?.[
AGENT_CHAT_PERMISSION_MODE_OPTION_ID
]
const attachParams: AgentSessionAttachParams = {
...params,
...(permissionMode
? { options: { [AGENT_CHAT_PERMISSION_MODE_OPTION_ID]: permissionMode } }
: {})
}
return { host, attachParams }
}

Expand Down
Loading
Loading