From c5d7628686c19d23d50296595061af0962fa3c3d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:34:27 -0700 Subject: [PATCH] fix(native-chat): chats from before permission modes keep the mode they were running with (#28099) * fix(native-chat): chats saved before permission modes run the new-chat default A Claude or Codex chat created before chats had their own permission mode has none saved, and it ran Ask, so every existing chat of a Yolo user started asking for approval after the update. A chat with no saved mode now runs Settings -> Chat -> Permissions, read from the host's settings on each launch, read and restart check. * fix(native-chat): an older chat keeps the new-chat mode its first start ran with A chat saved before chats had permission modes ran the new-chat default on every start, so it kept following that setting. Its first start now writes the mode it ran into the chat's saved options, through the same write that already carries the chat's other options over a start; from then on it is a chat with its own mode. * fix(native-chat): save older chats' permission mode once at host startup Replace the save-at-first-start path with one startup pass: before the chat host is exposed, every Claude and Codex record with no saved permissionMode gets the host's new-chat default, in one store transaction. Saved values, including invalid ones, and every other option are left alone; a read-only store is skipped and a failure is logged and retried next start. The runtime fallback in launch, acquisition, provider-started and attach is restored to main's behaviour. A client-supplied attach that creates a new record now gets the same new-chat seed as every other create path. * fix(native-chat): fill each older chat from its own agent's arguments An older Claude or Codex chat ran with that agent's own Arguments before permission modes existed, so the startup pass now saves full access or ask per agent instead of the combined new-chat default. A user with Claude on bypass and Codex on Manual keeps Claude chats on full access. The attach create path always passes its permission seed; the reservation already applies options only when it creates the record. * refactor(native-chat): read each chat's agent arguments directly in the permission backfill * test(native-chat): give stub runtimes the launch seed the attach path reads --- .../scripts/vitest-sqlite-runtime-files.mjs | 1 + ...agent-session-startup-attempt-host.test.ts | 23 +- .../agent-session-permission-backfill.test.ts | 203 ++++++++++++++++++ .../agent-session-permission-backfill.ts | 32 +++ .../agent-session-record-store.test.ts | 15 ++ .../runtime/agent-session-record-store.ts | 4 + ...ude-structured-session-integration.test.ts | 1 + ...-structured-launch-settings-wiring.test.ts | 3 + ...structured-agent-session-create-handler.ts | 12 +- ...ed-agent-session-permission-create.test.ts | 42 ++++ ...ructured-agent-session-integration.test.ts | 1 + .../structured-agent-session-runtime.test.ts | 83 +++++++ .../structured-agent-session-runtime.ts | 14 +- ...ructured-codex-session-rpc-test-harness.ts | 1 + 14 files changed, 432 insertions(+), 3 deletions(-) create mode 100644 src/main/runtime/agent-session-permission-backfill.test.ts create mode 100644 src/main/runtime/agent-session-permission-backfill.ts create mode 100644 src/main/runtime/rpc/methods/structured-agent-session-permission-create.test.ts diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index f4fdaeeff5e..918845d8d7c 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -208,6 +208,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', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts index 02bed410f40..631ff303cbd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts @@ -91,7 +91,7 @@ async function startHost(): Promise { ...(readAcquisitionOptions ? { readAcquisitionOptions } : {}) }, modelCatalog: { - read: vi.fn(), + read: vi.fn(async () => ({ origin: 'unknown' as const })), recordLiveListing: vi.fn(), prewarm: vi.fn(async () => {}), stop: vi.fn(), @@ -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) + } + ) +}) diff --git a/src/main/runtime/agent-session-permission-backfill.test.ts b/src/main/runtime/agent-session-permission-backfill.test.ts new file mode 100644 index 00000000000..f7bc9bda36a --- /dev/null +++ b/src/main/runtime/agent-session-permission-backfill.test.ts @@ -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 }) +}) + +function record(provider: string, options?: Record) { + 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 + ) + }) +}) diff --git a/src/main/runtime/agent-session-permission-backfill.ts b/src/main/runtime/agent-session-permission-backfill.ts new file mode 100644 index 00000000000..ca7088d7537 --- /dev/null +++ b/src/main/runtime/agent-session-permission-backfill.ts @@ -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 { + 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 } + }) + } + } + }) +} diff --git a/src/main/runtime/agent-session-record-store.test.ts b/src/main/runtime/agent-session-record-store.test.ts index cc0a83cc55f..b3f19910cb4 100644 --- a/src/main/runtime/agent-session-record-store.test.ts +++ b/src/main/runtime/agent-session-record-store.test.ts @@ -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', () => { diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index 5601d2a2e00..02a1ab2f63a 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -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' @@ -115,6 +116,9 @@ export class AgentSessionRecordStore { return this.transactions.readOnly } + backfillChatPermissionModes = (ranWithFullAccess: (provider: string) => boolean): Promise => + backfillAgentSessionPermissionModes(this.transactions, ranWithFullAccess) + getRecord = (sessionId: string): AgentSessionRecord | null => this.state.records.get(sessionId) ?? null diff --git a/src/main/runtime/claude-structured-session-integration.test.ts b/src/main/runtime/claude-structured-session-integration.test.ts index dedde503349..45baed003b1 100644 --- a/src/main/runtime/claude-structured-session-integration.test.ts +++ b/src/main/runtime/claude-structured-session-integration.test.ts @@ -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, diff --git a/src/main/runtime/orca-runtime-structured-launch-settings-wiring.test.ts b/src/main/runtime/orca-runtime-structured-launch-settings-wiring.test.ts index dd81e303b41..f3442ce6f6e 100644 --- a/src/main/runtime/orca-runtime-structured-launch-settings-wiring.test.ts +++ b/src/main/runtime/orca-runtime-structured-launch-settings-wiring.test.ts @@ -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) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-create-handler.ts b/src/main/runtime/rpc/methods/structured-agent-session-create-handler.ts index a917eff7eef..335648ecd87 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-create-handler.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-create-handler.ts @@ -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' @@ -32,7 +33,16 @@ async function resolveClientSuppliedAttach(params: z.infer, 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 } } diff --git a/src/main/runtime/rpc/methods/structured-agent-session-permission-create.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-permission-create.test.ts new file mode 100644 index 00000000000..46a9b75fcd7 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-permission-create.test.ts @@ -0,0 +1,42 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + attachParams, + call, + clearStructuredHostStub, + hostCalls, + installStructuredHostStub, + STRUCTURED_CLIENT +} from './structured-agent-session-rpc.test-fixture' + +beforeEach(() => { + installStructuredHostStub() +}) +afterEach(() => { + clearStructuredHostStub() +}) + +describe('legacy attach-shaped permission creation', () => { + it.each(['agentSession.create', 'agentSession.ensure'] as const)( + '%s gives a new record only the host permission seed', + async (method) => { + const seed = vi.fn(() => ({ + permissionMode: 'full-access', + model: 'host-model', + effort: 'high' + })) + const params = attachParams() + expect( + await call(method, params, STRUCTURED_CLIENT, { + structuredAgentSessionLaunchSeedOptions: seed + }) + ).toMatchObject({ ok: true }) + expect(hostCalls.attach).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + options: { permissionMode: 'full-access' }, + envelope: params.envelope + }) + ) + } + ) +}) diff --git a/src/main/runtime/structured-agent-session-integration.test.ts b/src/main/runtime/structured-agent-session-integration.test.ts index deaee6763c3..6f6b38378cd 100644 --- a/src/main/runtime/structured-agent-session-integration.test.ts +++ b/src/main/runtime/structured-agent-session-integration.test.ts @@ -330,6 +330,7 @@ beforeEach(async () => { getRuntimeId: () => 'runtime-1', getClientSettings: () => ({ experimentalNativeChat: true }), getStructuredAgentSessionCreateSupport: async () => ({ supported: true }), + structuredAgentSessionLaunchSeedOptions: () => undefined, resolveStructuredAgentSessionCreateIntent: async () => { const { envelope: _envelope, diff --git a/src/main/runtime/structured-agent-session-runtime.test.ts b/src/main/runtime/structured-agent-session-runtime.test.ts index 947b73dfdd0..c48b1d46674 100644 --- a/src/main/runtime/structured-agent-session-runtime.test.ts +++ b/src/main/runtime/structured-agent-session-runtime.test.ts @@ -23,6 +23,10 @@ import { hasPersistedStructuredAgentSessionStore, stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' +import { seedTestAgentSessionRecordStore } from './agent-session-record-store-test-harness' +import { foundAgentSessionRecord } from './agent-session-record-founding' +import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' +import { recordingStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' const HOST_ID = 'local' @@ -220,6 +224,85 @@ describe('structured agent-session runtime install', () => { vi.restoreAllMocks() }) + function oldChat(provider: 'claude' | 'codex'): AgentSessionRecord { + return foundAgentSessionRecord( + { + sessionId: `old-${provider}`, + provider, + location: { + executionHostId: HOST_ID, + workspaceId: 'folder-1', + workspaceKind: 'folder', + wslDistro: null + }, + accountHome: { + variable: provider === 'claude' ? 'CLAUDE_CONFIG_DIR' : 'CODEX_HOME', + path: '/account' + } + }, + { claimKeyId: 'key-1', now: 1 } + ) + } + + async function installOver( + fullAccessAgents: readonly string[], + logger = recordingStructuredAgentSessionLogger().logger + ) { + return await ensureStructuredAgentSessionHost({ + logger, + stateDirectory: stateDirectory!, + hostId: HOST_ID, + claimKeyId: 'key-1', + resolveWorkspacePath: async () => stateDirectory!, + resolveLaunchArgs: () => [], + resolveClaudeAuthPolicy: () => ({ account: 'managed' }), + resolveAgentFullAccess: (agent) => fullAccessAgents.includes(agent), + resolveEnvironment: async () => ({}) + }) + } + + it.each([false, true])( + 'backfills before the host is readable and keeps startup nonfatal on a failed write (%s)', + async (failWrite) => { + stateDirectory = await mkdtemp(join(tmpdir(), 'orca-structured-runtime-backfill-')) + await seedTestAgentSessionRecordStore(stateDirectory, { records: [oldChat('codex')] }) + if (failWrite) { + openTestJournalHostDatabase(stateDirectory).db.exec( + "CREATE TRIGGER fail_permission_backfill BEFORE UPDATE ON agent_session_records BEGIN SELECT RAISE(ABORT, 'disk full'); END" + ) + } + const logged = recordingStructuredAgentSessionLogger() + const host = await installOver(['codex'], logged.logger) + expect(host.deps.store.getRecord('old-codex')?.options?.permissionMode).toBe( + failWrite ? undefined : 'full-access' + ) + expect((await host.readOptions('old-codex')).current.permissionMode).toBe( + failWrite ? 'ask' : 'full-access' + ) + if (failWrite) { + expect(logged.scopes()).toContain('permission-mode-backfill') + } else { + expect(logged.scopes()).not.toContain('permission-mode-backfill') + } + } + ) + + it.each([ + [['claude'], 'full-access', 'ask'], + [['codex'], 'ask', 'full-access'] + ] as const)( + "fills each old chat from its own agent's arguments when %j skip prompts", + async (fullAccessAgents, claude, codex) => { + stateDirectory = await mkdtemp(join(tmpdir(), 'orca-structured-runtime-backfill-')) + await seedTestAgentSessionRecordStore(stateDirectory, { + records: [oldChat('claude'), oldChat('codex')] + }) + const host = await installOver(fullAccessAgents) + expect(host.deps.store.getRecord('old-claude')?.options?.permissionMode).toBe(claude) + expect(host.deps.store.getRecord('old-codex')?.options?.permissionMode).toBe(codex) + } + ) + it('holds stop until the model catalog has written its coalesced save', async () => { stateDirectory = await mkdtemp(join(tmpdir(), 'orca-structured-runtime-')) await ensureStructuredAgentSessionHost({ diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 11afc2d16f4..34a68277adc 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -121,7 +121,7 @@ export type StructuredAgentSessionRuntimeDeps = { resolveLaunchArgs: (provider: AgentSessionRecord['provider']) => Promise | string[] /** Required, and asserted at install time — an absent policy must not degrade to a guess. */ resolveClaudeAuthPolicy: () => Promise | ClaudeStructuredAuthPolicy - /** Agent Permissions for an ACP agent; Claude and Codex chats run their own permission mode. */ + /** Agent Permissions for an ACP agent; also fills Claude and Codex chats saved before modes. */ resolveAgentFullAccess?: (agent: string) => boolean /** The user's per-agent environment overlay from settings, laid over every agent's base env. */ resolveAgentLaunchEnv?: (agent: string) => Record @@ -263,6 +263,18 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise 'runtime-1', getClientSettings: () => ({ experimentalStructuredNativeChat: true }), getStructuredAgentSessionCreateSupport: async () => ({ supported: true }), + structuredAgentSessionLaunchSeedOptions: () => undefined, resolveStructuredAgentSessionCreateIntent: async () => { const { envelope: _envelope,