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
This commit is contained in:
Brennan Benson
2026-10-13 12:34:27 -07:00
committed by GitHub
parent 24bda717f4
commit c5d7628686
14 changed files with 432 additions and 3 deletions
@@ -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',
@@ -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(),
@@ -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)
}
)
})
@@ -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<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
)
})
})
@@ -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 }
})
}
}
})
}
@@ -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', () => {
@@ -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<void> =>
backfillAgentSessionPermissionModes(this.transactions, ranWithFullAccess)
getRecord = (sessionId: string): AgentSessionRecord | null =>
this.state.records.get(sessionId) ?? null
@@ -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,
@@ -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)
@@ -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<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 }
}
@@ -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
})
)
}
)
})
@@ -330,6 +330,7 @@ beforeEach(async () => {
getRuntimeId: () => 'runtime-1',
getClientSettings: () => ({ experimentalNativeChat: true }),
getStructuredAgentSessionCreateSupport: async () => ({ supported: true }),
structuredAgentSessionLaunchSeedOptions: () => undefined,
resolveStructuredAgentSessionCreateIntent: async () => {
const {
envelope: _envelope,
@@ -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({
@@ -121,7 +121,7 @@ export type StructuredAgentSessionRuntimeDeps = {
resolveLaunchArgs: (provider: AgentSessionRecord['provider']) => Promise<string[]> | string[]
/** Required, and asserted at install time — an absent policy must not degrade to a guess. */
resolveClaudeAuthPolicy: () => Promise<ClaudeStructuredAuthPolicy> | 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<string, string>
@@ -263,6 +263,18 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise<Install
hostId: deps.hostId,
logger
})
try {
// Without the Arguments source there is nothing to fill from; missing modes run Ask.
if (deps.resolveAgentFullAccess) {
await recordStore.store.backfillChatPermissionModes(deps.resolveAgentFullAccess)
}
} catch (error) {
// Missing choices safely run Ask; the next host install retries this pass.
logger.warn('saving existing chat permission choices failed', {
scope: 'permission-mode-backfill',
error
})
}
return await installOnJournal({ ...deps, logger }, recordStore)
}
@@ -236,6 +236,7 @@ export async function openStructuredCodexRpcHarness(
getRuntimeId: () => 'runtime-1',
getClientSettings: () => ({ experimentalStructuredNativeChat: true }),
getStructuredAgentSessionCreateSupport: async () => ({ supported: true }),
structuredAgentSessionLaunchSeedOptions: () => undefined,
resolveStructuredAgentSessionCreateIntent: async () => {
const {
envelope: _envelope,