diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts index 48af56b4e8..bbc5774550 100644 --- a/apps/desktop/e2e/fixtures.ts +++ b/apps/desktop/e2e/fixtures.ts @@ -145,7 +145,7 @@ async function seedParentRemovalSessions(userDataDir: string): Promise { cwd: path.join(userDataDir, 'project'), llmConnectionSlug: 'e2e', model: 'claude-sonnet-4-5-20250929', - permissionMode: 'execute', + permissionMode: 'ask', name: PARENT_REMOVAL_CHILD_NAME, labels: [], subagentParent: { diff --git a/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts b/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts index e3ddbb407d..c3d7c9b5fa 100644 --- a/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts @@ -90,7 +90,7 @@ function createActionsDeps() { upsertSessionSummary: () => undefined, newChatModel: null, pendingNewChatThinkingLevel: null, - newChatPermissionMode: 'ask' as const, + newChatPermissionChoice: undefined, newChatCollaborationMode: 'agent' as const, newChatOrchestrationMode: 'default' as const, newTaskTarget: { profileId: 'local', hostId: 'host-local', projectId: null }, diff --git a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts index 7807296c97..e03ce2497c 100644 --- a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts @@ -90,8 +90,8 @@ function createActionsDeps() { toastApi: { error: () => undefined, info: () => undefined }, upsertSessionSummary: () => undefined, newChatModel: null, - newChatPermissionMode: 'ask' as const, pendingNewChatThinkingLevel: null, + newChatPermissionChoice: undefined, newChatCollaborationMode: 'agent' as const, newChatOrchestrationMode: 'default' as const, newTaskTarget: { profileId: 'local', hostId: 'host-local', projectId: null }, @@ -155,7 +155,6 @@ describe('composer first-send cleanup', () => { try { const deps = { ...createActionsDeps(), - newChatPermissionMode: 'bypass' as const, newChatModel: { llmConnectionSlug: 'opencode-free', model: 'mimo-v2.5-free', @@ -171,7 +170,53 @@ describe('composer first-send cleanup', () => { 'opencode-free', ); assert.equal((createInput as { model?: unknown }).model, 'mimo-v2.5-free'); + // Ordinary creation carries no permission mode: the Host applies its own + // `chatDefaults`. Sending the offered default back as an explicit override + // would make a cached snapshot the authority and could create a full-access + // Session from a value another client already lowered. + assert.ok(!('permissionMode' in (createInput as Record))); + }); + + it('sends a composer permission choice once without writing it to the Host default', async () => { + let createInput: unknown; + let settingsUpdates = 0; + const restoreWindow = installWindow({ + newTasks: { + create: async (_target: unknown, input: unknown) => { + createInput = input; + return { id: 'session-1' }; + }, + }, + settings: { + update: async () => { + settingsUpdates += 1; + return {}; + }, + }, + sessions: { + send: async () => ({ + ok: true, + attachments: [], + skillInvocation: { loaded: [], failed: [] }, + }), + }, + }); + + try { + const deps = { + ...createActionsDeps(), + newChatPermissionChoice: 'bypass' as const, + }; + assert.equal(await createAppShellChatActions(deps).send('hello'), true); + } finally { + restoreWindow(); + } + + // An explicit choice for this draft is a per-Session override: it reaches + // the created Session, and it does not become the Host's default for every + // later task. Only the Settings surface writes `chatDefaults`. assert.equal((createInput as { permissionMode?: unknown }).permissionMode, 'bypass'); + assert.equal(settingsUpdates, 0); }); it('creates the first session on the selected Runtime Host and project', async () => { diff --git a/apps/desktop/src/main/__tests__/create-session-input.test.ts b/apps/desktop/src/main/__tests__/create-session-input.test.ts index 85a0d456fe..d95a1b7b22 100644 --- a/apps/desktop/src/main/__tests__/create-session-input.test.ts +++ b/apps/desktop/src/main/__tests__/create-session-input.test.ts @@ -3,38 +3,44 @@ * distinct job was turning a product mode into session fields. The IPC is * gone; this is the part that survived, and the gates it used to carry are * pinned here as behavior rather than as regexes over the handler's source. + * + * What a product mode expands into — its boundary, name and labels — belongs + * to the Runtime Host, which receives `mode` verbatim and owns the expansion. + * This module only decides what reaches the wire, so that is all it pins. */ import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; -import type { AppSettings, ChatDefaultPermissionMode } from '@maka/core/settings'; import { DEEP_RESEARCH_SESSION_LABEL } from '@maka/core/explore-agent'; import { DEFAULT_SESSION_NAME } from '@maka/core/session-name'; import { type CreateSessionRequest, - resolveCreateSessionInput, resolveCreateSessionRequest, } from '../create-session-input.js'; -function settings(permissionMode: ChatDefaultPermissionMode) { - return async () => (({ - chatDefaults: { permissionMode }, - }) as AppSettings); -} - /** Anything the renderer can put on the wire, including what the type forbids: * `sessions:create` is an IPC boundary, so the type is a hint, not a gate. */ -function resolve(input: unknown, readSettings = settings('ask')) { - return resolveCreateSessionInput(input as CreateSessionRequest | undefined, { readSettings }); +function resolve(input: unknown) { + return resolveCreateSessionRequest(input as CreateSessionRequest | undefined); } -describe('resolveCreateSessionInput', () => { - it('leaves an ordinary default permission choice to the owning runtime', () => { - assert.equal(resolveCreateSessionRequest(undefined).permissionMode, undefined); - assert.equal(resolveCreateSessionRequest({ permissionMode: 'bypass' }).permissionMode, 'bypass'); - assert.deepEqual(resolveCreateSessionRequest({ mode: 'deep_research' }), { +describe('resolveCreateSessionRequest', () => { + /** + * An omitted mode must stay omitted all the way to the Host: the Host + * resolves it from its own `chatDefaults`, and substituting a literal here + * would make this module a second authority over the starting boundary. + */ + it('leaves an omitted permission choice to the owning runtime', () => { + assert.equal(resolve(undefined).permissionMode, undefined); + assert.equal(resolve({}).permissionMode, undefined); + assert.equal(resolve({ permissionMode: 'bypass' }).permissionMode, 'bypass'); + assert.equal(resolve({ permissionMode: 'ask' }).permissionMode, 'ask'); + }); + + it('passes a product mode through verbatim for the Host to expand', () => { + assert.deepEqual(resolve({ mode: 'deep_research' }), { mode: 'deep_research', collaborationMode: 'agent', orchestrationMode: 'default', @@ -43,26 +49,6 @@ describe('resolveCreateSessionInput', () => { }); }); - it('forces the read-only boundary for Deep Research', async () => { - const resolved = await resolve({ mode: 'deep_research' }); - assert.equal(resolved.permissionMode, 'explore'); - assert.equal(resolved.name, 'Deep Research'); - assert.deepEqual(resolved.labels, [DEEP_RESEARCH_SESSION_LABEL]); - }); - - /** - * Deep Research is a read-only boundary, so it must outrank BOTH the - * renderer's own request and the configured default — otherwise the mode is - * a suggestion, and the session it names is not the session you get. - */ - it("a mode's boundary outranks the renderer's request and the configured default", async () => { - const resolved = await resolve( - { mode: 'deep_research', permissionMode: 'bypass' }, - settings('ask'), - ); - assert.equal(resolved.permissionMode, 'explore'); - }); - /** * `explore` is a boundary a mode confers, never one a caller may open a * session at — core names the pickable set `ChatDefaultPermissionMode`. @@ -72,66 +58,33 @@ describe('resolveCreateSessionInput', () => { * separate, deliberate path for moving an EXISTING session (the quote * companion relies on it), so the guard belongs on creation only. */ - it('refuses a directly-requested explore boundary', async () => { - await assert.rejects(() => resolve({ permissionMode: 'explore' }), TypeError); - await assert.rejects(() => resolve({ permissionMode: 'nonsense' }), TypeError); + it('refuses a directly-requested explore boundary', () => { + assert.throws(() => resolve({ permissionMode: 'explore' }), TypeError); + assert.throws(() => resolve({ permissionMode: 'nonsense' }), TypeError); }); - it('rejects an invalid collaboration or orchestration mode', async () => { - await assert.rejects(() => resolve({ collaborationMode: 'nonsense' }), TypeError); - await assert.rejects(() => resolve({ orchestrationMode: 'nonsense' }), TypeError); + it('rejects an invalid collaboration or orchestration mode', () => { + assert.throws(() => resolve({ collaborationMode: 'nonsense' }), TypeError); + assert.throws(() => resolve({ orchestrationMode: 'nonsense' }), TypeError); }); /** - * The mode is a closed mapping, exercised with the raw values a renderer can - * actually put on the wire. An unrecognized mode must confer no boundary, no - * name and no label — it simply is not a mode. + * The mode is a closed set, exercised with the raw values a renderer can + * actually put on the wire. An unrecognized mode must not reach the Host as + * one — it simply is not a mode. */ - it('cannot be reached by an unrecognized mode from the renderer', async () => { + it('drops an unrecognized mode from the renderer', () => { for (const mode of ['explore', 'deep-reseach', 'chat', 'admin', '', null, 42, {}]) { - const resolved = await resolve({ mode }, settings('ask')); - assert.equal(resolved.permissionMode, 'ask', `mode ${JSON.stringify(mode)} conferred a boundary`); + const resolved = resolve({ mode }); + assert.equal(resolved.mode, undefined, `mode ${JSON.stringify(mode)} reached the wire`); assert.equal(resolved.name, DEFAULT_SESSION_NAME); assert.equal(resolved.labels, undefined); } }); - it('falls back to the configured default when neither a mode nor the caller says otherwise', async () => { - assert.equal((await resolve(undefined, settings('ask'))).permissionMode, 'ask'); - assert.equal((await resolve({}, settings('bypass'))).permissionMode, 'bypass'); - assert.equal((await resolve({ permissionMode: 'ask' }, settings('bypass'))).permissionMode, 'ask'); - }); - - /** - * The pre-feature fallback was a synchronous `'ask'` literal that could - * never fail. Reading the configured default must not change that: a - * corrupted settings.json must not reject session creation. - */ - it('never rejects when settings cannot be read', async () => { - const resolved = await resolve({}, async () => { - throw new Error('EACCES: settings.json'); - }); - assert.equal(resolved.permissionMode, 'ask'); - }); - - /** - * The one input no caller sends today: both a mode and a name. The mode wins, - * matching what `quickChat:start` did (it never let the renderer name a Deep - * Research session at all) and matching `permissionMode`, where the mode also - * outranks the request. Pinned because the type accepts the combination, so - * "whichever the expression happened to list first" is not an answer. - */ - it('a mode names the session even when the caller also sent a name', async () => { - const resolved = await resolve({ mode: 'deep_research', name: 'Release notes' }); - assert.equal(resolved.name, 'Deep Research'); - }); - - it("adds the mode's label to the caller's rather than replacing them", async () => { - const resolved = await resolve({ - mode: 'deep_research', - labels: ['pinned', DEEP_RESEARCH_SESSION_LABEL], - }); + it("carries the caller's name and labels when no mode overrides them", () => { + const resolved = resolve({ name: 'Release notes', labels: ['pinned', DEEP_RESEARCH_SESSION_LABEL] }); + assert.equal(resolved.name, 'Release notes'); assert.deepEqual(resolved.labels, ['pinned', DEEP_RESEARCH_SESSION_LABEL]); }); - }); diff --git a/apps/desktop/src/main/__tests__/pending-session-view.test.ts b/apps/desktop/src/main/__tests__/pending-session-view.test.ts index 30b1f601a3..03c31673b3 100644 --- a/apps/desktop/src/main/__tests__/pending-session-view.test.ts +++ b/apps/desktop/src/main/__tests__/pending-session-view.test.ts @@ -28,7 +28,7 @@ test('the pending chat view matches no offered model choice', () => { const view = pendingSessionView({ sessionId: 'session-2', name: '新任务', - permissionMode: 'execute', + permissionMode: 'ask', }); const offered = [ { connectionSlug: 'anthropic', model: 'claude-sonnet-4-5-20250929' }, diff --git a/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts b/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts index 9620780a9a..f86a50d783 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts @@ -159,16 +159,16 @@ test('merges a configuration patch into each fresh CAS projection', async () => kind: 'committed', session: session('session-1', 12, { collaborationMode: 'plan', - permissionMode: 'execute', + permissionMode: 'ask', }), }, ]); const updated = await client.updateSessionConfiguration('session-1', { - permissionMode: 'execute', + permissionMode: 'ask', }); - assert.equal(updated.permissionMode, 'execute'); + assert.equal(updated.permissionMode, 'ask'); assert.equal(updated.collaborationMode, 'plan'); assert.deepEqual( requests @@ -185,7 +185,7 @@ test('merges a configuration patch into each fresh CAS projection', async () => model: 'test-model', }, thinkingLevel: null, - permissionMode: 'execute', + permissionMode: 'ask', collaborationMode: 'agent', orchestrationMode: 'default', }, @@ -200,7 +200,7 @@ test('merges a configuration patch into each fresh CAS projection', async () => model: 'test-model', }, thinkingLevel: null, - permissionMode: 'execute', + permissionMode: 'ask', collaborationMode: 'plan', orchestrationMode: 'default', }, diff --git a/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts b/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts index b4233e4e17..6c891945c0 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts @@ -263,10 +263,10 @@ test('drives the renderer Session catalog facade through real UDS framing', asyn ); } assert.equal( - (await ipc.invoke('sessions:setPermissionMode', 'session-ipc', 'execute') as { + (await ipc.invoke('sessions:setPermissionMode', 'session-ipc', 'bypass') as { permissionMode: string; }).permissionMode, - 'execute', + 'bypass', ); await ipc.invoke('sessions:archive', 'session-ipc'); assert.equal((await ipc.invoke('sessions:list') as Array<{ isArchived: boolean }>)[0]?.isArchived, true); diff --git a/apps/desktop/src/main/create-session-input.ts b/apps/desktop/src/main/create-session-input.ts index 98823941d7..6bbd519662 100644 --- a/apps/desktop/src/main/create-session-input.ts +++ b/apps/desktop/src/main/create-session-input.ts @@ -9,15 +9,15 @@ * `emitSessionsChanged('created')` — so only the derivation survived. * * It lives here as a pure function rather than inside the handler because the - * handler is an `ipcMain.handle` closure no test can call. Every invariant - * below — the mode's boundary outranking both the renderer's request and the - * configured default, the refusal of a directly-requested `explore`, the - * settings-backed fallback that must never reject — would otherwise only be - * assertable by regex over the handler's source. + * handler is an `ipcMain.handle` closure no test can call. The invariants + * below — the refusal of a directly-requested `explore`, and leaving an + * omitted mode omitted — would otherwise only be assertable by regex over the + * handler's source. + * + * The permission mode a session actually starts in is resolved by the Runtime + * Host from its own `chatDefaults`, so an omitted mode stays omitted here. */ -import type { AppSettings } from '@maka/core/settings'; - import type { CollaborationMode } from '@maka/core/collaboration'; import type { OrchestrationMode } from '@maka/core/orchestration'; @@ -33,9 +33,7 @@ import { isCollaborationMode } from '@maka/core/collaboration'; import { isOrchestrationMode } from '@maka/core/orchestration'; -import { isSessionStartMode, sessionStartModeSpec } from '@maka/core/explore-agent'; - -import { resolveDefaultPermissionMode } from './permission-mode-default.js'; +import { isSessionStartMode } from '@maka/core/explore-agent'; /** * `unknown`, because this is an IPC boundary and the renderer's type is a @@ -52,20 +50,15 @@ export interface CreateSessionRequest { labels?: string[]; } -export interface ResolvedCreateSessionInput { - permissionMode: PermissionMode; +export interface ResolvedCreateSessionRequest { + mode?: SessionStartMode; + permissionMode?: PermissionMode; collaborationMode: CollaborationMode; orchestrationMode: OrchestrationMode; name: string; labels: string[] | undefined; } -export interface ResolvedCreateSessionRequest - extends Omit { - mode?: SessionStartMode; - permissionMode?: PermissionMode; -} - export function resolveCreateSessionRequest( input: CreateSessionRequest | undefined, ): ResolvedCreateSessionRequest { @@ -92,24 +85,3 @@ export function resolveCreateSessionRequest( labels: input?.labels, }; } - -export async function resolveCreateSessionInput( - input: CreateSessionRequest | undefined, - deps: { readSettings: () => Promise }, -): Promise { - const request = resolveCreateSessionRequest(input); - const mode = request.mode === undefined ? undefined : sessionStartModeSpec(request.mode); - return { - collaborationMode: request.collaborationMode, - orchestrationMode: request.orchestrationMode, - name: mode?.name ?? request.name, - labels: - mode === undefined - ? request.labels - : [...new Set([...(request.labels ?? []), ...mode.labels])], - permissionMode: - mode?.permissionMode ?? - request.permissionMode ?? - (await resolveDefaultPermissionMode(deps.readSettings)), - }; -} diff --git a/apps/desktop/src/renderer/app-shell-chat-actions.ts b/apps/desktop/src/renderer/app-shell-chat-actions.ts index 73b5cd32b9..31797726c2 100644 --- a/apps/desktop/src/renderer/app-shell-chat-actions.ts +++ b/apps/desktop/src/renderer/app-shell-chat-actions.ts @@ -1,3 +1,4 @@ +import type { ChatDefaultPermissionMode } from '@maka/core/settings'; import type { CollaborationMode } from '@maka/core/collaboration'; import type { DesktopNewTaskTarget } from '../preload/bridge-contract.js'; import type { InlineReference, QuoteRef } from '@maka/core/events'; @@ -5,7 +6,6 @@ import type { OrchestrationMode } from '@maka/core/orchestration'; import type { SandboxBoundaryResponse } from '@maka/core/sandbox-boundary'; import type { StoredMessage } from '@maka/core/session'; import type { ThinkingLevel } from '@maka/core/model-thinking'; -import type { ChatDefaultPermissionMode } from '@maka/core/settings'; import type { TurnOrchestration } from '@maka/core/runtime-inputs'; import type { UiLocale } from '@maka/core/ui-locale'; import type { DesktopSessionSummary } from '../preload/bridge-contract.js'; @@ -169,7 +169,12 @@ export function createAppShellChatActions(deps: { upsertSessionSummary: (session: DesktopSessionSummary) => void; newChatModel: PendingNewChatModel; pendingNewChatThinkingLevel: PendingNewChatThinkingLevel; - newChatPermissionMode: ChatDefaultPermissionMode; + /** + * The user's explicit choice for this draft, or undefined when they made + * none. Undefined omits the field on create so the Host applies its own + * `chatDefaults`; a value is a real per-Session override and is sent once. + */ + newChatPermissionChoice: ChatDefaultPermissionMode | undefined; newChatCollaborationMode: CollaborationMode; newChatOrchestrationMode: OrchestrationMode; newTaskTarget: DesktopNewTaskTarget | undefined; @@ -201,7 +206,7 @@ export function createAppShellChatActions(deps: { upsertSessionSummary, newChatModel, pendingNewChatThinkingLevel, - newChatPermissionMode, + newChatPermissionChoice, newChatCollaborationMode, newChatOrchestrationMode, newTaskTarget, @@ -388,7 +393,7 @@ export function createAppShellChatActions(deps: { } : {}), ...(pendingNewChatThinkingLevel ? { thinkingLevel: pendingNewChatThinkingLevel } : {}), - permissionMode: newChatPermissionMode, + ...(newChatPermissionChoice ? { permissionMode: newChatPermissionChoice } : {}), collaborationMode: newChatCollaborationMode, orchestrationMode: newChatOrchestrationMode, }); diff --git a/apps/desktop/src/renderer/app-shell-overlays.tsx b/apps/desktop/src/renderer/app-shell-overlays.tsx index a92d5ef524..61ca22989b 100644 --- a/apps/desktop/src/renderer/app-shell-overlays.tsx +++ b/apps/desktop/src/renderer/app-shell-overlays.tsx @@ -43,7 +43,13 @@ export function AppShellOverlays(props: { setUiLocalePreference: (preference: UiLocalePreference) => void; uiLocaleUpdateGate: UiLocaleUpdateGate; setUserLabel(userLabel: string): void; - setDefaultPermissionMode(mode: ChatDefaultPermissionMode): void; + /** + * Settings changed a chat default the composer also shows. The shell + * re-reads it from the Host rather than being handed the new value: the + * Host owns it, and a value passed along here would be a second copy that + * can disagree the moment anything else writes the setting. + */ + refreshChatDefaults(): void; settingsRequestedSection: SettingsSection | undefined; settingsProviderCatalogOpen: boolean; settingsConnectionDetailSlug: string | undefined; @@ -85,7 +91,7 @@ export function AppShellOverlays(props: { setUiLocalePreference, uiLocaleUpdateGate, setUserLabel, - setDefaultPermissionMode, + refreshChatDefaults, themePalette, themePref, onExternalSessionImported, @@ -107,7 +113,7 @@ export function AppShellOverlays(props: { onUiLocalePreferenceChange={setUiLocalePreference} uiLocaleUpdateGate={uiLocaleUpdateGate} onUserLabelChange={setUserLabel} - onDefaultPermissionModeChange={setDefaultPermissionMode} + onDefaultPermissionModeChange={() => refreshChatDefaults()} requestedSection={settingsRequestedSection} openProviderCatalog={settingsProviderCatalogOpen} initialConnectionSlug={settingsConnectionDetailSlug} diff --git a/apps/desktop/src/renderer/app-shell-session-settings-actions.ts b/apps/desktop/src/renderer/app-shell-session-settings-actions.ts index 0fb3cdb033..b6a7e4d593 100644 --- a/apps/desktop/src/renderer/app-shell-session-settings-actions.ts +++ b/apps/desktop/src/renderer/app-shell-session-settings-actions.ts @@ -43,7 +43,8 @@ export function createAppShellSessionSettingsActions(deps: { model: { llmConnectionSlug: string; model: string }; }) => void; sessionsRef: RefBox; - setNewTaskPermissionMode: (mode: ChatDefaultPermissionMode) => void; + /** Persists the chat default; awaited so a failure surfaces as one. */ + setNewTaskPermissionMode: (mode: ChatDefaultPermissionMode) => void | Promise; setPendingPermissionModeBySession: BooleanRecordUpdater; setPendingSessionModelBySession: BooleanRecordUpdater; setSessions: ( @@ -122,7 +123,7 @@ export function createAppShellSessionSettingsActions(deps: { prev.map((session) => (session.id === sessionId ? next : session)), ); } else { - setNewTaskPermissionMode(mode); + await setNewTaskPermissionMode(mode); } toastApi.success( copy.permissionSwitched(copy.permissionLabels[nextMode]), diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 0708c2996e..58a9ae54c3 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -551,8 +551,8 @@ function AppShellContent({ uiLocaleUpdateGate, userLabel, setUserLabel, - defaultPermissionMode, - setDefaultPermissionMode, + + refreshShellSettings, } = useShellAppearance({ toastApi, @@ -565,10 +565,16 @@ function AppShellContent({ const desktopConversationCopy = getDesktopConversationCopy(uiLocale); const terminalPanelCopy = desktopConversationCopy.terminalPanel; const workbarCopy = desktopConversationCopy.workbar; + /** + * What this draft would start in: the user's choice for it if they made one, + * otherwise the Host default it will inherit by omission. + * + * The choice stays local to the draft. Picking Full access for one task is + * not a statement about every later task, so it is sent once on create and + * never written back to `chatDefaults` — the Settings surface owns that. + */ const newTaskPermissionMode = - newTaskPermissionChoice ?? - newTask.selectedHost?.chatDefaults.permissionMode ?? - 'ask'; + newTaskPermissionChoice ?? newTask.selectedHost?.chatDefaults.permissionMode ?? 'ask'; const setNewTaskPermissionMode = setNewTaskPermissionChoice; useEffect(() => { if (!isAppUpdateInstallFailure(appUpdateStatus)) { @@ -1306,7 +1312,7 @@ function AppShellContent({ ? pendingSessionView({ sessionId: activeId, name: shellCopy.newConversation, - permissionMode: defaultPermissionMode, + permissionMode: newTaskPermissionMode, }) : undefined); // Each control reads its own field. There is nothing to project and nothing @@ -2124,7 +2130,7 @@ function AppShellContent({ upsertSessionSummary, newChatModel: newChatModel ?? null, pendingNewChatThinkingLevel: newChatThinkingLevel ?? null, - newChatPermissionMode: newTaskPermissionMode, + newChatPermissionChoice: newTaskPermissionChoice, newChatCollaborationMode: newChatPlanModeActive ? 'plan' : 'agent', newChatOrchestrationMode: newChatOrchestrationMode, newTaskTarget: newTask.target, @@ -3651,7 +3657,9 @@ function AppShellContent({ setUiLocalePreference={setUiLocalePreference} uiLocaleUpdateGate={uiLocaleUpdateGate} setUserLabel={setUserLabel} - setDefaultPermissionMode={setDefaultPermissionMode} + refreshChatDefaults={() => { + void newTask.refresh(); + }} settingsRequestedSection={settingsRequestedSection} settingsProviderCatalogOpen={settingsProviderCatalogOpen} settingsConnectionDetailSlug={settingsConnectionDetailSlug} diff --git a/apps/desktop/src/renderer/locales/shell-copy.ts b/apps/desktop/src/renderer/locales/shell-copy.ts index 342143bd67..4961c050c4 100644 --- a/apps/desktop/src/renderer/locales/shell-copy.ts +++ b/apps/desktop/src/renderer/locales/shell-copy.ts @@ -950,7 +950,6 @@ const SHELL_COPY_BY_LOCALE = { permissionDescriptions: { explore: '只读:只读取和搜索,写入文件和访问网络会先来问你。', ask: '自动:在 Maka 的保护层内执行;需要超出当前权限范围时会先来问你。', - execute: '兼容模式:等同于自动。', bypass: '本地工具直接访问你的文件和网络,不经 Maka 的保护层。', }, bypassConfirmTitle: '切换到完全权限?', @@ -1034,10 +1033,6 @@ const SHELL_COPY_BY_LOCALE = { permissionModes: { explore: { label: '权限 · 只读', hint: '读取和搜索直通,写入和网络仍需确认' }, ask: { label: '权限 · 自动', hint: '在 Maka 的保护层内运行;需要超出当前权限范围时再询问' }, - execute: { - label: '权限 · 自动执行', - hint: '常见工具直通,破坏性操作仍确认', - }, bypass: { label: '权限 · 完全权限', hint: '不经 Maka 的保护层,直接访问你的文件和网络', @@ -1452,7 +1447,6 @@ const SHELL_COPY_BY_LOCALE = { permissionDescriptions: { explore: 'Read only: reads and searches only; writing files and network access ask you first.', ask: "Auto: runs inside Maka's protection layer and asks before anything goes beyond the current permissions.", - execute: 'Compatibility mode: same as Auto.', bypass: "Local tools reach your files and your network directly, outside Maka's protection layer.", }, bypassConfirmTitle: 'Switch to full access?', @@ -1542,10 +1536,6 @@ const SHELL_COPY_BY_LOCALE = { label: 'Permissions · Auto', hint: "Run inside Maka's protection layer; ask before going beyond the current permissions", }, - execute: { - label: 'Permissions · Auto execute', - hint: 'Run common tools; confirm destructive actions', - }, bypass: { label: 'Permissions · Full access', hint: "Reach your files and your network directly, outside Maka's protection layer", diff --git a/apps/desktop/src/renderer/use-shell-appearance.ts b/apps/desktop/src/renderer/use-shell-appearance.ts index d226728163..07237ccdc3 100644 --- a/apps/desktop/src/renderer/use-shell-appearance.ts +++ b/apps/desktop/src/renderer/use-shell-appearance.ts @@ -1,5 +1,5 @@ import { useState, type Dispatch, type SetStateAction } from 'react'; -import type { ChatDefaultPermissionMode, ThemePalette, ThemePreference } from '@maka/core/settings'; +import type { ThemePalette, ThemePreference } from '@maka/core/settings'; import type { ThinkingLevel } from '@maka/core/model-thinking'; import type { UiLocale, UiLocalePreference } from '@maka/core/ui-locale'; import { createUiLocaleUpdateGate } from './settings/ui-locale-update-gate'; @@ -11,15 +11,19 @@ type ToastApi = { }; /** - * Owns the appearance / personalization / default-permission-mode slice - * (issue #1043): the theme + palette + UI-locale + user-label + default - * permission mode state, plus the `refreshShellSettings` IPC pull. Desktop - * appearance and locale are hydrated independently from the default Host's - * chat defaults, so an offline Host cannot block the local UI preferences. + * Owns the appearance / personalization slice (issue #1043): the theme + + * palette + UI-locale + user-label state, plus the `refreshShellSettings` IPC + * pull. Desktop appearance and locale are hydrated independently from the + * default Host's chat defaults, so an offline Host cannot block the local UI + * preferences. + * + * The default permission mode is deliberately absent. It is per-Host and the + * composer reads it from the Host that would run the task; a copy hydrated + * here from the *default* Host would name a different Host's setting as soon + * as more than one is connected. * * `closeSettings` stays in AppShell: on close it calls `refreshShellSettings()` - * so display mirrors (default permission mode) catch up without an app restart. - * The full settings hydration lives here. + * so the remaining display mirrors catch up without an app restart. */ export function useShellAppearance({ toastApi, @@ -36,13 +40,6 @@ export function useShellAppearance({ const [themePalette, setThemePalette] = useState('default'); const [uiLocaleUpdateGate] = useState(createUiLocaleUpdateGate); const [userLabel, setUserLabel] = useState(''); - // Settings -> 通用 -> 默认权限模式 - DISPLAY-ONLY mirror. The composer's - // picker shows it before the user makes a per-session choice; the actual - // authority for a new session's mode is main.ts's sessions:create fallback - // (the renderer omits permissionMode unless the user explicitly picked), - // so a stale value here can briefly mislabel the chip but never changes - // which mode a session is created with. - const [defaultPermissionMode, setDefaultPermissionMode] = useState('ask'); // undefined = the user expressed no preference, so each model uses its own. const [defaultThinkingLevel, setDefaultThinkingLevel] = useState(undefined); @@ -91,7 +88,6 @@ export function useShellAppearance({ if (runtimeHostResult.ok) { const next = runtimeHostResult.settings; setUserLabel(next.personalization.displayName ?? ''); - setDefaultPermissionMode(next.chatDefaults.permissionMode ?? 'ask'); setDefaultThinkingLevel(next.chatDefaults.thinkingLevel); } } @@ -104,9 +100,7 @@ export function useShellAppearance({ uiLocaleUpdateGate, userLabel, setUserLabel, - defaultPermissionMode, defaultThinkingLevel, - setDefaultPermissionMode, refreshShellSettings, }; } diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index 0414e7c6e9..5f6d95c41c 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -3042,7 +3042,6 @@ describe('Maka Pi TUI runner', () => { agentName: 'Local Read', profile: 'local_read', toolNames: ['Read', 'Glob', 'Grep'], - permissionCeiling: 'ask' as const, }, }; const driver = new SlashCommandDriver([parent, child]); diff --git a/packages/cli/src/activation-command.ts b/packages/cli/src/activation-command.ts index f290a7d7cb..22d1823a03 100644 --- a/packages/cli/src/activation-command.ts +++ b/packages/cli/src/activation-command.ts @@ -34,7 +34,14 @@ export interface MakaActivationOptions { input: string; timeoutMs?: number; maxSteps?: number; - permissionMode?: Exclude; + /** + * `ask` was excluded here while `execute` existed, but the two compiled to + * the same workspace-write profile and the same confirmation behavior — the + * exclusion separated the names, not the boundaries. With `execute` gone, + * `ask` is that boundary, and an activation asking for it gets exactly what + * `--permission-mode execute` always gave it. + */ + permissionMode?: PermissionMode; connection?: string; model?: string; } @@ -205,14 +212,19 @@ export function parseMakaActivateArgs(argv: readonly string[]): ParseMakaActivat if (parsedMaxSteps !== undefined && (!Number.isInteger(parsedMaxSteps) || parsedMaxSteps < 1)) { return { kind: 'error', message: '--max-steps must be a positive integer' }; } - const permissionMode = values.get('permission-mode'); + // `execute` stays accepted as an alias for `ask`: this is a public + // subcommand whose callers live outside this repo, and the two named the + // same boundary for as long as both existed. It is not offered in the error + // message, so nothing new learns to send it. + const requestedPermissionMode = values.get('permission-mode'); + const permissionMode = requestedPermissionMode === 'execute' ? 'ask' : requestedPermissionMode; if ( permissionMode !== undefined && permissionMode !== 'explore' && - permissionMode !== 'execute' && + permissionMode !== 'ask' && permissionMode !== 'bypass' ) { - return { kind: 'error', message: '--permission-mode must be explore, execute, or bypass' }; + return { kind: 'error', message: '--permission-mode must be explore, ask, or bypass' }; } return { kind: 'activate', @@ -772,7 +784,7 @@ function makaActivateHelpText(): string { ' --input JSON request file, or - for stdin (default: -)', ' --timeout Invocation timeout', ' --max-steps Tool-step cap', - ' --permission-mode explore|execute|bypass', + ' --permission-mode explore|ask|bypass', ' --connection Model connection override', ' --model Model override', ].join('\n'); diff --git a/packages/cli/src/pi-tui-pickers.ts b/packages/cli/src/pi-tui-pickers.ts index 139d3e8c93..b2ba2c6961 100644 --- a/packages/cli/src/pi-tui-pickers.ts +++ b/packages/cli/src/pi-tui-pickers.ts @@ -728,11 +728,10 @@ export class ModelSearchOverlay implements Component { * #1611: `current` marks an option that is genuinely in force, so choosing it * is a no-op. A read-only session is neither of these options, and marking * Auto as current there turned "confirm what I already have" into a silent - * widening of the boundary. Legacy `execute` has no boundary of its own and - * really does resolve to Auto, so it still marks Auto. + * widening of the boundary. */ export function permissionModePickerItems(currentMode: PermissionMode): SelectItem[] { - const autoIsCurrent = currentMode === 'ask' || currentMode === 'execute'; + const autoIsCurrent = currentMode === 'ask'; return [ { value: 'auto', diff --git a/packages/cli/src/run-command-core.ts b/packages/cli/src/run-command-core.ts index e0279c7957..54921f2b17 100644 --- a/packages/cli/src/run-command-core.ts +++ b/packages/cli/src/run-command-core.ts @@ -3,10 +3,11 @@ import { realpath, stat } from 'node:fs/promises'; import { resolve } from 'node:path'; import type { SessionEvent } from '@maka/core/events'; import { isThinkingLevel, type ThinkingLevel } from '@maka/core/model-thinking'; -import type { CreateSessionInput, UserMessageInput } from '@maka/core/runtime-inputs'; +import type { UserMessageInput } from '@maka/core/runtime-inputs'; import type { ExecutionBoundaryReadModel } from '@maka/core/sandbox-boundary'; import type { SessionSummary } from '@maka/core/session'; import { normalizeUserSessionName } from '@maka/core/session-name'; +import type { CreateSessionRequest } from './session-driver.js'; import { selectMakaRunSession } from './run-session-selection.js'; import { sessionEventSandboxBoundaryFailureReason } from './sandbox-boundary-failure.js'; import { resolveMakaWorkspaceRoot } from './workspace-root.js'; @@ -35,7 +36,7 @@ export type ParseMakaRunArgsResult = | { kind: 'error'; message: string }; export interface MakaRunRuntime { - createSession(input: CreateSessionInput): Promise; + createSession(input: CreateSessionRequest): Promise; readExecutionBoundary(sessionId: string): Promise; sendMessage(sessionId: string, input: UserMessageInput): AsyncIterable; respondToSandboxBoundary( @@ -316,7 +317,10 @@ export async function runMakaTextCliCore( name: makaRunSessionName(prompt), llmConnectionSlug: context.target.connection.slug, model: context.target.model, - permissionMode: parsed.options.yolo ? 'bypass' : 'ask', + // `--yolo` is a one-shot elevation, not one half of a choice. + // Omitting the field lets the Session start in the Host's + // configured default instead of forcing Auto onto every run. + ...(parsed.options.yolo ? { permissionMode: 'bypass' as const } : {}), ...(parsed.options.thinking !== undefined ? { thinkingLevel: parsed.options.thinking } : {}), diff --git a/packages/cli/src/runtime-host-cli-context.ts b/packages/cli/src/runtime-host-cli-context.ts index f9cfebfed9..d446c26ded 100644 --- a/packages/cli/src/runtime-host-cli-context.ts +++ b/packages/cli/src/runtime-host-cli-context.ts @@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto'; import { join } from 'node:path'; import { NO_REAL_CONNECTION_CODE } from '@maka/core/connection-error-copy'; import type { ConnectionCatalogEntry, ConnectionCatalogSnapshot } from '@maka/core/runtime-policy'; +import type { ChatDefaultPermissionMode } from '@maka/core/settings'; import { connectOrSpawnRuntimeHost, connectRemoteRuntimeHostProfile, @@ -26,6 +27,32 @@ import { } from '@maka/runtime-host/protocol'; import { resolveMakaClientDataRoot } from '@maka/storage'; +/** + * The mode a new Session starts in belongs to the Host: `session.create` + * falls back to `chatDefaults.permissionMode` in the Runtime Policy whenever a + * client omits the field, so that policy value is the single authority. + * + * The CLI reads it rather than assuming Auto, because its pickers and its + * status indicator name the mode a new Session will *actually* get. Assuming + * Auto against a Host configured for full access would understate the + * boundary, which is the one direction that must never happen. + * + * Falls back to `ask` when the policy cannot be read: an unreachable policy + * must not stop the CLI from starting, and understating our own knowledge is + * safe here — the Host still applies its own default to the Session it + * creates. + */ +export async function readHostChatDefaultPermissionMode( + connection: Pick, +): Promise { + try { + return (await connection.request('runtime.policy.query', {})).policy.chatDefaults + .permissionMode; + } catch { + return 'ask'; + } +} + export class RuntimeHostCliConflictError extends RuntimeHostPermanentReconnectError { readonly code = 'RUNTIME_HOST_RESTART_REQUIRED'; diff --git a/packages/cli/src/runtime-host-run-command.ts b/packages/cli/src/runtime-host-run-command.ts index 10de166c72..249b9891ab 100644 --- a/packages/cli/src/runtime-host-run-command.ts +++ b/packages/cli/src/runtime-host-run-command.ts @@ -1,7 +1,7 @@ import { failureClassFromCompleteStopReason, type SessionEvent } from '@maka/core/events'; import { findProjectByIdentity } from '@maka/core/project'; import { type StoredMessage } from '@maka/core/session'; -import type { CreateSessionInput, UserMessageInput } from '@maka/core/runtime-inputs'; +import type { UserMessageInput } from '@maka/core/runtime-inputs'; import type { ExecutionBoundaryReadModel } from '@maka/core/sandbox-boundary'; import type { SessionSummary } from '@maka/core/session'; import { @@ -31,7 +31,7 @@ import { runtimeHostSessionSummary, type RuntimeHostMakaSessionDriver, } from './runtime-host-session-driver.js'; -import type { MakaPreparedSessionTurn } from './session-driver.js'; +import type { CreateSessionRequest, MakaPreparedSessionTurn } from './session-driver.js'; import { formatRuntimeHostCliTaskBlockers, isRuntimeHostCliTaskBlocked, @@ -150,7 +150,6 @@ export function createRuntimeHostRunContext( cwd: input.cwd, llmConnectionSlug: target.connection.slug, model: target.model, - permissionMode: 'ask', executionLocation: !input.hostProfileId || input.hostProfileId === 'local' ? { kind: 'client_path' } @@ -273,7 +272,7 @@ class RuntimeHostRunRuntime implements MakaRunRuntime { ); } - async createSession(input: CreateSessionInput): Promise { + async createSession(input: CreateSessionRequest): Promise { const created = await this.#driver.createSession(input); this.#sessionId = created.id; return created; diff --git a/packages/cli/src/runtime-host-session-driver.ts b/packages/cli/src/runtime-host-session-driver.ts index e568ea2a0d..10adca036f 100644 --- a/packages/cli/src/runtime-host-session-driver.ts +++ b/packages/cli/src/runtime-host-session-driver.ts @@ -1,4 +1,5 @@ import { randomUUID } from 'node:crypto'; +import type { CreateSessionInput } from '@maka/core/runtime-inputs'; import { DEFAULT_SESSION_NAME } from '@maka/core/session-name'; import { decodeStoredMessage, @@ -14,7 +15,7 @@ import { } from '@maka/core/events'; import type { OrchestrationMode } from '@maka/core/orchestration'; import type { PermissionMode } from '@maka/core/permission'; -import type { CreateSessionInput } from '@maka/core/runtime-inputs'; + import { executionBoundaryDisplayMode } from '@maka/core/sandbox-boundary'; import type { SandboxBoundaryResponse } from '@maka/core/sandbox-boundary'; import type { ThinkingLevel } from '@maka/core/model-thinking'; @@ -58,6 +59,7 @@ import type { MakaSessionSwitchOptions, MakaSessionSwitchResult, MakaTranscriptReplacementReason, + CreateSessionRequest, RewindTarget, SessionResumeAvailability, } from './session-driver.js'; @@ -93,7 +95,7 @@ type RuntimeHostSessionDriverConnection = Pick< >; export interface RuntimeHostMakaSessionDriver extends MakaSessionDriver { - createSession(input: CreateSessionInput): Promise; + createSession(input: CreateSessionRequest): Promise; readMessages(): Promise; resumeLatest(): AsyncIterable; subscribePendingInteractions(listener: (pending: InteractionPendingSnapshot) => void): () => void; @@ -135,8 +137,14 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { // elevations (the picker, a resumed Session's boundary) update // `#permissionMode` only; `startNewSession` falls back to this so Full // access never leaks into a fresh Session (#3020). - readonly #defaultPermissionMode: PermissionMode; - #permissionMode: PermissionMode; + // + // `undefined` means the client has no claim on the starting mode and the + // Host resolves it from its Runtime Policy `chatDefaults`. That is a + // stronger guarantee than the old literal `ask`, not a weaker one: a + // fresh Session cannot inherit the previous one's elevation either way, + // and the mode it does start in is now the configured one. + readonly #defaultPermissionMode: PermissionMode | undefined; + #permissionMode: PermissionMode | undefined; #activeBoundaryDisplayMode: PermissionMode | undefined; #orchestrationMode: OrchestrationMode; #channel: RuntimeHostSessionChannel | undefined; @@ -179,7 +187,7 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { }; this.#model = input.model; this.#llmConnectionSlug = input.llmConnectionSlug; - this.#defaultPermissionMode = input.permissionMode ?? 'ask'; + this.#defaultPermissionMode = input.permissionMode; this.#permissionMode = this.#defaultPermissionMode; this.#orchestrationMode = input.orchestrationMode ?? 'default'; } @@ -188,7 +196,7 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { return loadCurrentMessages(this.#connection, this.#requireSession('read messages')); } - async createSession(input: CreateSessionInput): Promise { + async createSession(input: CreateSessionRequest): Promise { if (this.#sessionId) throw new Error('Cannot create a Session while another is active.'); if (!input.model) throw new Error('Runtime Host Session creation requires an explicit model'); this.#workspace = { @@ -198,7 +206,10 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { this.#llmConnectionSlug = input.llmConnectionSlug; this.#model = input.model; this.#thinkingLevel = input.thinkingLevel; - this.#permissionMode = input.permissionMode ?? 'ask'; + // An omitted mode stays omitted: the Host applies its configured default. + // Substituting a literal `ask` here would make the CLI a second authority + // over the starting boundary and silently override that default. + this.#permissionMode = input.permissionMode ?? this.#defaultPermissionMode; const session = await this.#createSession(input.name ?? DEFAULT_SESSION_NAME); return runtimeHostSessionSummary(session); } @@ -718,7 +729,7 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { return this.#orchestrationMode; } - getPermissionMode(): PermissionMode { + getPermissionMode(): PermissionMode | undefined { return this.#activeBoundaryDisplayMode ?? this.#permissionMode; } @@ -743,7 +754,7 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { connectionSlug: this.#llmConnectionSlug, model: this.#model, }, - permissionMode: this.#permissionMode, + ...(this.#permissionMode === undefined ? {} : { permissionMode: this.#permissionMode }), ...(this.#orchestrationMode === 'default' ? {} : { orchestrationMode: this.#orchestrationMode }), diff --git a/packages/cli/src/runtime-host-tui-command.ts b/packages/cli/src/runtime-host-tui-command.ts index b189813fbe..7d16fda656 100644 --- a/packages/cli/src/runtime-host-tui-command.ts +++ b/packages/cli/src/runtime-host-tui-command.ts @@ -69,7 +69,7 @@ export async function runRuntimeHostTui(input: RunRuntimeHostTuiInput): Promise< connectionSlug: context.connectionSlug, providerType: context.providerType, modelContextWindow: context.modelContextWindow, - permissionMode: 'ask', + permissionMode: context.prospectivePermissionMode, turnActivity: context.turnActivity, listSkills: context.listSkills, agentGraphHistory: context.agentGraphHistory, diff --git a/packages/cli/src/runtime-host-tui-context.ts b/packages/cli/src/runtime-host-tui-context.ts index aafeae6897..5106042a55 100644 --- a/packages/cli/src/runtime-host-tui-context.ts +++ b/packages/cli/src/runtime-host-tui-context.ts @@ -1,5 +1,9 @@ import { randomUUID } from 'node:crypto'; import type { PermissionMode } from '@maka/core/permission'; +import { + createGenesisExecutionBoundary, + executionBoundaryDisplayMode, +} from '@maka/core/sandbox-boundary'; import { findProjectByIdentity } from '@maka/core/project'; import type { ConnectionCatalogEntry, ConnectionCatalogSnapshot } from '@maka/core/runtime-policy'; import { SessionActivityRegistry } from '@maka/runtime/goal-turn-lifecycle'; @@ -13,7 +17,11 @@ import { type RuntimeHostProfile, } from '@maka/runtime-host/client'; import type { AgentGraphClientSnapshot, WorkspaceTarget } from '@maka/runtime-host/protocol'; -import { connectRuntimeHostCli, resolveRuntimeHostCliTarget } from './runtime-host-cli-context.js'; +import { + connectRuntimeHostCli, + readHostChatDefaultPermissionMode, + resolveRuntimeHostCliTarget, +} from './runtime-host-cli-context.js'; import type { MakaPiTuiTurnActivitySurface, ModelChoice, @@ -38,6 +46,13 @@ export interface RuntimeHostTuiContext { readonly model: string; readonly modelContextWindow?: number; readonly modelChoices: readonly ModelChoice[]; + /** + * Mode a Session created right now would start in, for display only. The + * driver never receives it: an omitted create field is what lets the Host + * stay the authority, and this snapshot goes stale the moment another client + * changes the setting. + */ + readonly prospectivePermissionMode: PermissionMode; readonly turnActivity: MakaPiTuiTurnActivitySurface; readonly listSkills: (cwd: string) => Promise; readonly agentGraphHistory: { @@ -76,12 +91,18 @@ export async function createRuntimeHostTuiContext( ? await resolveResumeTarget(connection, catalog, input.resumeSessionId) : resolveTarget(catalog); const modelChoices = projectRuntimeHostModelChoices(catalog); + // Display state, never a create input. Deriving it through the same + // boundary mapping every other surface uses keeps a prospective Session and + // a live one from ever labelling the same permissions differently. + const prospectivePermissionMode = + executionBoundaryDisplayMode( + createGenesisExecutionBoundary(await readHostChatDefaultPermissionMode(connection)), + ) ?? 'ask'; const driverInput: RuntimeHostMakaSessionDriverInput = { connection, cwd: input.cwd, llmConnectionSlug: target.connection.slug, model: target.model, - permissionMode: 'ask', executionLocation: connected.profile.kind === 'local' ? { kind: 'client_path' } : { kind: 'host' }, ...(workspace ? { workspace } : {}), @@ -98,6 +119,7 @@ export async function createRuntimeHostTuiContext( modelContextWindow: target.connection.models.find((model) => model.id === target.model) ?.contextWindow, modelChoices, + prospectivePermissionMode, turnActivity: createHostOwnedTurnActivity(), listSkills: (cwd) => listStablePresentedSkills( @@ -105,7 +127,7 @@ export async function createRuntimeHostTuiContext( driver.getSessionId(), workspace ?? (connected.profile.kind === 'local' ? { kind: 'host_path', path: cwd } : undefined), - driver.getPermissionMode?.() ?? 'ask', + driver.getPermissionMode?.() ?? prospectivePermissionMode, ), agentGraphHistory: createRuntimeHostAgentGraphHistory(connection), recap: createRuntimeHostRecapGenerator(connection), diff --git a/packages/cli/src/session-driver.ts b/packages/cli/src/session-driver.ts index ecb49cd7bb..9015abccc3 100644 --- a/packages/cli/src/session-driver.ts +++ b/packages/cli/src/session-driver.ts @@ -5,7 +5,7 @@ import type { PermissionMode } from '@maka/core/permission'; import type { SandboxBoundaryResponse } from '@maka/core/sandbox-boundary'; import type { SessionSummary, StoredMessage } from '@maka/core/session'; import type { ThinkingLevel } from '@maka/core/model-thinking'; -import type { TurnOrchestration } from '@maka/core/runtime-inputs'; +import type { CreateSessionInput, TurnOrchestration } from '@maka/core/runtime-inputs'; import type { UserQuestionResponse } from '@maka/core/user-question'; import type { ContextDiagnostics } from '@maka/runtime/context-diagnostics'; import type { SkillInvocationResult } from '@maka/core/skill-invocation'; @@ -133,9 +133,30 @@ export interface MakaSessionDriver { controlGoal?(action: GoalControlAction): Promise; getContextDiagnostics?(): Promise; getOrchestrationMode?(): OrchestrationMode; - getPermissionMode?(): PermissionMode; + /** + * The mode in force, or `undefined` when no Session exists yet and the + * driver has no local claim on what a new one will start in — the owning + * runtime resolves that from its own configured default. Callers that must + * render something choose their own stand-in rather than being handed an + * invented mode here. + */ + getPermissionMode?(): PermissionMode | undefined; } +/** + * A create request whose permission mode may be left to the owning runtime. + * + * `CreateSessionInput` requires a mode because the local runtime writes it + * straight onto the Session header. A client speaking to a Runtime Host is in + * a different position: the Host resolves an omitted mode from its Runtime + * Policy `chatDefaults`. Omitting the field is how a client says "no explicit + * choice", and it is the only way the configured default can apply — sending + * a literal would silently override it. + */ +export type CreateSessionRequest = Omit & { + permissionMode?: PermissionMode; +}; + export type MakaTranscriptReplacementReason = 'terminal' | 'reconnect'; export type SessionResumeAvailability = { available: true } | { available: false; reason: string }; diff --git a/packages/core/src/__tests__/permission-profile-compiler.test.ts b/packages/core/src/__tests__/permission-profile-compiler.test.ts index bed89be784..ab558d62e3 100644 --- a/packages/core/src/__tests__/permission-profile-compiler.test.ts +++ b/packages/core/src/__tests__/permission-profile-compiler.test.ts @@ -16,20 +16,14 @@ describe('compilePermissionProfile', () => { assert.deepEqual(compiled.network, { kind: 'restricted' }); }); - it('maps ask and execute to the same workspace-write profile while preserving mode', () => { + it('maps ask to the workspace-write profile while preserving mode', () => { const ask = compilePermissionProfile({ mode: 'ask', cwd: '/repo' }); - const execute = compilePermissionProfile({ mode: 'execute', cwd: '/repo' }); assert.equal(ask.mode, 'ask'); - assert.equal(execute.mode, 'execute'); assert.equal(ask.profileName, 'workspace-write'); - assert.equal(execute.profileName, 'workspace-write'); assert.equal(ask.profile.type, 'managed'); - assert.equal(execute.profile.type, 'managed'); assert.equal(ask.profile.name, 'workspace-write'); - assert.equal(execute.profile.name, 'workspace-write'); assert.deepEqual(ask.network, { kind: 'restricted' }); - assert.deepEqual(execute.network, { kind: 'restricted' }); }); it('maps bypass to danger-full-access', () => { @@ -45,7 +39,7 @@ describe('compilePermissionProfile', () => { it('uses explicit workspaceRoots when provided', () => { const compiled = compilePermissionProfile({ - mode: 'execute', + mode: 'ask', cwd: '/repo', workspaceRoots: ['/repo', '/other-repo'], }); diff --git a/packages/core/src/__tests__/scheduled-task.test.ts b/packages/core/src/__tests__/scheduled-task.test.ts index 7b7e782fc6..2a441b5720 100644 --- a/packages/core/src/__tests__/scheduled-task.test.ts +++ b/packages/core/src/__tests__/scheduled-task.test.ts @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import { computeNextFireAt, + decodePersistedScheduledTask, isScheduledTaskDue, nextScheduledTaskStateAfterFire, normalizeCreateScheduledTaskInput, @@ -208,3 +209,54 @@ describe('scheduled-task catalog', () => { } }); }); + +describe('decodePersistedScheduledTask', () => { + const base: ScheduledTask = { + id: 't1', + title: 'Nightly', + intent: { kind: 'text', body: 'run it' }, + schedule: { kind: 'once', runAt: 1000 }, + effect: { + kind: 'agent_run', + execution: { + cwd: '/repo', + llmConnectionSlug: 'anthropic', + model: 'claude', + permissionMode: 'ask', + collaborationMode: 'agent', + orchestrationMode: 'default', + }, + }, + status: 'active', + nextFireAt: 1000, + lastFireAt: null, + fireCount: 0, + maxFires: null, + expiresAt: null, + createdBy: { kind: 'user' }, + createdAt: 0, + updatedAt: 0, + runs: [], + lastError: null, + }; + + it('folds a retired permission mode to its live equivalent', () => { + const stored = JSON.parse( + JSON.stringify(base).replace('"permissionMode":"ask"', '"permissionMode":"execute"'), + ) as ScheduledTask; + const decoded = decodePersistedScheduledTask(stored); + assert.equal( + decoded.effect.kind === 'agent_run' ? decoded.effect.execution.permissionMode : undefined, + 'ask', + ); + }); + + it('returns the same task when nothing needs folding', () => { + assert.equal(decodePersistedScheduledTask(base), base); + }); + + it('leaves effects without an execution template alone', () => { + const notify: ScheduledTask = { ...base, effect: { kind: 'notify', channel: 'local' } }; + assert.equal(decodePersistedScheduledTask(notify), notify); + }); +}); diff --git a/packages/core/src/__tests__/subagent-session-parent.test.ts b/packages/core/src/__tests__/subagent-session-parent.test.ts index 83c34578d3..2772848a61 100644 --- a/packages/core/src/__tests__/subagent-session-parent.test.ts +++ b/packages/core/src/__tests__/subagent-session-parent.test.ts @@ -11,7 +11,6 @@ import { projectLinkedSessionTree, subagentSessionRuntimeSummary, } from '../session.js'; -import { isPermissionModeWithinCeiling } from '../permission.js'; const relation: SubagentSessionParent = { kind: 'subagent', @@ -186,3 +185,31 @@ function summary(id: string, overrides: Partial = {}): SessionSu ...overrides, }; } + +describe('legacy child execution snapshots', () => { + const runtime = { + schemaVersion: 1, + definitionVersion: 1, + agentId: 'agent-1', + agentName: 'Reader', + profile: 'local_read', + systemPrompt: 'Read only.', + toolNames: ['Read'], + categoryPolicy: { read: 'allow' }, + } as const; + + test('accepts a snapshot carrying a retired key', () => { + // Written before `permissionCeiling` was dropped. Rejecting it would make + // the whole child Session unreadable, and nothing reads the value. + assert.equal(isSubagentSessionRuntime({ ...runtime, permissionCeiling: 'execute' }), true); + assert.equal(isSubagentSessionRuntime({ ...runtime, permissionCeiling: 'ask' }), true); + }); + + test('accepts a current snapshot without the key', () => { + assert.equal(isSubagentSessionRuntime(runtime), true); + }); + + test('still rejects a key that was never part of the shape', () => { + assert.equal(isSubagentSessionRuntime({ ...runtime, notAField: 'x' }), false); + }); +}); diff --git a/packages/core/src/__tests__/tool-result-preview.test.ts b/packages/core/src/__tests__/tool-result-preview.test.ts index 02d9cd5d3a..c9afa2dabd 100644 --- a/packages/core/src/__tests__/tool-result-preview.test.ts +++ b/packages/core/src/__tests__/tool-result-preview.test.ts @@ -39,3 +39,30 @@ describe('tool_result_preview open-facts', () => { ); }); }); + +describe('tool_result_preview permission modes', () => { + const preview = { + kind: 'subagent', + childSessionId: 'child-1', + agentName: 'Explore', + turnId: 't', + status: 'running', + permissionMode: 'ask', + } as const; + + it('rejects a retired mode on the live wire', () => { + // Live open facts, not a stored record: the compatibility epoch already + // refuses a peer old enough to send a retired mode, so accepting one here + // would only hide a handshake that should never have succeeded. + assert.throws(() => decodeToolResultPreviewContent({ ...preview, permissionMode: 'execute' })); + }); + + it('accepts every live mode', () => { + for (const permissionMode of ['explore', 'ask', 'bypass'] as const) { + assert.deepEqual(decodeToolResultPreviewContent({ ...preview, permissionMode }), { + ...preview, + permissionMode, + }); + } + }); +}); diff --git a/packages/core/src/__tests__/tool-result-record-schema.test.ts b/packages/core/src/__tests__/tool-result-record-schema.test.ts index 01232eff8f..52e1a8403e 100644 --- a/packages/core/src/__tests__/tool-result-record-schema.test.ts +++ b/packages/core/src/__tests__/tool-result-record-schema.test.ts @@ -107,6 +107,44 @@ describe('uncertain tool outcome metadata', () => { }); }); +describe('retired permission modes in stored subagent results', () => { + const stored = { + kind: 'subagent', + childSessionId: 'child-1', + agentName: 'Explore', + turnId: 'turn-1', + status: 'completed', + permissionMode: 'execute', + summary: 'done', + artifactIds: [], + } as const; + + test('folds a legacy mode to its live equivalent instead of returning it verbatim', () => { + const decoded = decodeCanonicalToolResultContent(stored); + assert.equal(decoded.kind === 'subagent' ? decoded.permissionMode : undefined, 'ask'); + assert.deepEqual(decoded, { ...stored, permissionMode: 'ask' }); + }); + + test('folds through the stored-message decoder as well', () => { + assert.deepEqual(toolResultContent(decodeStoredMessage(storedToolResult(stored))), { + ...stored, + permissionMode: 'ask', + }); + }); + + test('leaves a live mode untouched', () => { + const live = { ...stored, permissionMode: 'bypass' } as const; + assert.deepEqual(decodeCanonicalToolResultContent(live), live); + }); + + test('still rejects a mode that never existed', () => { + assert.throws( + () => decodeCanonicalToolResultContent({ ...stored, permissionMode: 'nonsense' }), + /Invalid tool result content/, + ); + }); +}); + function storedToolResult(content: unknown) { return { type: 'tool_result', diff --git a/packages/core/src/agent-run.ts b/packages/core/src/agent-run.ts index 1d4459df4a..b512b42141 100644 --- a/packages/core/src/agent-run.ts +++ b/packages/core/src/agent-run.ts @@ -1,4 +1,4 @@ -import { isPermissionMode, type PermissionMode } from './permission.js'; +import { decodePersistedPermissionMode, type PermissionMode } from './permission.js'; import { isCollaborationMode, type CollaborationMode } from './collaboration.js'; import { isAgentSwarmAuthorizationSource, @@ -588,6 +588,10 @@ export function decodeAgentRunHeader(value: unknown): AgentRunHeader { } const status = value.status === 'waiting_permission' ? ('waiting_for_user' as const) : value.status; + // Same shape as the status fold above: a run written before a mode was + // retired is old, not malformed, so it decodes to the live equivalent + // rather than making the run unreadable. + const permissionMode = decodePersistedPermissionMode(value.permissionMode); const valid = typeof value.runId === 'string' && typeof value.sessionId === 'string' && @@ -597,7 +601,7 @@ export function decodeAgentRunHeader(value: unknown): AgentRunHeader { typeof value.llmConnectionSlug === 'string' && typeof value.modelId === 'string' && typeof value.cwd === 'string' && - isPermissionMode(value.permissionMode) && + permissionMode !== undefined && (value.collaborationMode === undefined || isCollaborationMode(value.collaborationMode)) && (value.orchestrationMode === undefined || isOrchestrationMode(value.orchestrationMode)) && (value.orchestrationSource === undefined || @@ -641,7 +645,9 @@ export function decodeAgentRunHeader(value: unknown): AgentRunHeader { (value.continuationSource === undefined || isAgentRunContinuationSource(value.continuationSource)); if (!valid) throw new Error('Invalid AgentRun header schema'); - if (status !== value.status) return { ...value, status } as unknown as AgentRunHeader; + if (status !== value.status || permissionMode !== value.permissionMode) { + return { ...value, status, permissionMode } as unknown as AgentRunHeader; + } return value as unknown as AgentRunHeader; } diff --git a/packages/core/src/permission-profile-compiler.ts b/packages/core/src/permission-profile-compiler.ts index a2eba2cfa5..db23f0d666 100644 --- a/packages/core/src/permission-profile-compiler.ts +++ b/packages/core/src/permission-profile-compiler.ts @@ -34,7 +34,6 @@ export function compilePermissionProfile( case 'explore': return compileManaged(input.mode, createReadOnlyPermissionProfile(), workspaceRoots); case 'ask': - case 'execute': return compileManaged(input.mode, createWorkspaceWritePermissionProfile(), workspaceRoots); case 'bypass': return compileManaged(input.mode, createDangerFullAccessPermissionProfile(), workspaceRoots); diff --git a/packages/core/src/permission.ts b/packages/core/src/permission.ts index 8c5e3d7c9c..95925bc7a5 100644 --- a/packages/core/src/permission.ts +++ b/packages/core/src/permission.ts @@ -4,9 +4,35 @@ // Mode + Tool categories // ============================================================================ -export const PERMISSION_MODES = ['explore', 'ask', 'execute', 'bypass'] as const; +export const PERMISSION_MODES = ['explore', 'ask', 'bypass'] as const; export type PermissionMode = (typeof PERMISSION_MODES)[number]; +/** + * A mode that was removed but still appears in records written before the + * removal. It never had behavior of its own — `execute` compiled to the same + * profile as `ask`, displayed as `ask`, and produced the same execution + * boundary — so folding it costs nothing and is not a downgrade. + */ +const RETIRED_PERMISSION_MODES: Readonly> = { + execute: 'ask', +}; + +/** + * A permission mode read back from a persisted record, or `undefined` when the + * value is not one. + * + * Decoders use this instead of {@link isPermissionMode} so a retired mode + * stays readable: the record is old, not malformed, and refusing it would make + * the Session, run or task it belongs to unopenable. New input and wire values + * use the strict check — nothing should still be *sending* a retired mode. + */ +export function decodePersistedPermissionMode(value: unknown): PermissionMode | undefined { + if (typeof value !== 'string') return undefined; + const retired = RETIRED_PERMISSION_MODES[value]; + if (retired !== undefined) return retired; + return isPermissionMode(value) ? value : undefined; +} + export const APPROVALS_REVIEWERS = ['user', 'auto_review'] as const; export type ApprovalsReviewer = (typeof APPROVALS_REVIEWERS)[number]; @@ -17,14 +43,6 @@ export function isPermissionMode(value: unknown): value is PermissionMode { return typeof value === 'string' && (PERMISSION_MODES as readonly string[]).includes(value); } -/** Whether a requested mode stays within an immutable creation-time ceiling. */ -export function isPermissionModeWithinCeiling( - mode: PermissionMode, - ceiling: PermissionMode, -): boolean { - return PERMISSION_MODES.indexOf(mode) <= PERMISSION_MODES.indexOf(ceiling); -} - /** Canonical category names use Claude SDK terminology. Pi adapter MUST * translate Pi-native tool names into these before they reach the runtime. */ export type ToolCategory = diff --git a/packages/core/src/sandbox-boundary.ts b/packages/core/src/sandbox-boundary.ts index 80944d359d..bb6b687125 100644 --- a/packages/core/src/sandbox-boundary.ts +++ b/packages/core/src/sandbox-boundary.ts @@ -176,8 +176,6 @@ export type ExecutionBoundarySummary = export type ExecutionBoundaryReadModel = ExecutionBoundary | ExecutionBoundarySummary; -export type LegacyPermissionMode = 'ask' | 'execute' | 'explore' | 'bypass'; - /** * The permission mode a boundary should be *presented* as (#1611). * @@ -209,7 +207,7 @@ export function executionBoundaryDisplayMode( return readOnly ? 'explore' : 'ask'; } -export function createGenesisExecutionBoundary(mode: LegacyPermissionMode): ExecutionBoundary { +export function createGenesisExecutionBoundary(mode: PermissionMode): ExecutionBoundary { if (mode === 'bypass') return { kind: 'bypass', revision: 0 }; return { kind: 'managed', diff --git a/packages/core/src/scheduled-task.ts b/packages/core/src/scheduled-task.ts index 804cf01813..3f376bf899 100644 --- a/packages/core/src/scheduled-task.ts +++ b/packages/core/src/scheduled-task.ts @@ -9,7 +9,11 @@ import { compileCronExpression } from './cron-expression.js'; import { isCollaborationMode, type CollaborationMode } from './collaboration.js'; import { isOrchestrationMode, type OrchestrationMode } from './orchestration.js'; import { isThinkingLevel, type ThinkingLevel } from './model-thinking.js'; -import { isPermissionMode, type PermissionMode } from './permission.js'; +import { + decodePersistedPermissionMode, + isPermissionMode, + type PermissionMode, +} from './permission.js'; import { isBotDeliveryProvider, type BotProvider } from './bot-chat-settings.js'; export const SCHEDULED_TASK_TITLE_MAX_CHARS = 120; @@ -633,3 +637,27 @@ function addMonthsClamped(anchor: Date, base: Date, offset: number): number { function fail(message: string): { ok: false; message: string } { return { ok: false, message }; } + +/** + * Fold retired representations in a stored ScheduledTask to their live + * equivalents. + * + * Stored tasks are read back with `JSON.parse` and never pass through + * `normalizeCreateScheduledTaskInput`, which validates *new* input and is + * deliberately strict. Without this fold a task written before a value was + * retired would carry that value straight into execution, where nothing + * recognizes it any more. This is not a schema validator: a record that is + * malformed in any other way stays as stored. + */ +export function decodePersistedScheduledTask(task: ScheduledTask): ScheduledTask { + const { effect } = task; + if (effect.kind !== 'agent_run') return task; + const permissionMode = decodePersistedPermissionMode(effect.execution.permissionMode); + if (permissionMode === undefined || permissionMode === effect.execution.permissionMode) { + return task; + } + return { + ...task, + effect: { ...effect, execution: { ...effect.execution, permissionMode } }, + }; +} diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 3711a20632..1f84d1fdd7 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -107,8 +107,6 @@ export interface SubagentSessionRuntime { systemPrompt: string; toolNames: string[]; categoryPolicy: Partial>; - /** Legacy decode-only metadata. Current child sessions do not write it. */ - permissionCeiling?: PermissionMode; } /** @@ -409,8 +407,18 @@ const SUBAGENT_SESSION_RUNTIME_SHAPE = defineObjectShape 'toolNames', 'categoryPolicy', ], - ['permissionCeiling', 'presetId'], + ['presetId'], ); + +/** + * Keys older child sessions wrote that this type no longer has. + * + * `hasExactShape` rejects unknown keys, so without this a record written before + * the key was dropped would fail validation and make the whole child Session + * unreadable. Nothing reads the values, and they stay in the stored JSON as + * written — this only stops their presence from being treated as corruption. + */ +const RETIRED_SUBAGENT_RUNTIME_KEYS: readonly string[] = ['permissionCeiling']; const SUBAGENT_SESSION_SPAWN_IDENTITY_SHAPE = defineObjectShape()( ['schemaVersion', 'requestFingerprint', 'initialTurnId', 'initialRunId'], [], @@ -458,11 +466,20 @@ export function isSubagentSessionParent(value: unknown): value is SubagentSessio return swarmValid && graphValid && !(value.swarm && value.graph); } +function withoutRetiredSubagentRuntimeKeys( + value: Record, +): Record { + if (!RETIRED_SUBAGENT_RUNTIME_KEYS.some((key) => Object.hasOwn(value, key))) return value; + return Object.fromEntries( + Object.entries(value).filter(([key]) => !RETIRED_SUBAGENT_RUNTIME_KEYS.includes(key)), + ); +} + /** Strict decoder guard for the persisted child execution snapshot. */ export function isSubagentSessionRuntime(value: unknown): value is SubagentSessionRuntime { if ( !isRecord(value) || - !hasExactShape(value, SUBAGENT_SESSION_RUNTIME_SHAPE) || + !hasExactShape(withoutRetiredSubagentRuntimeKeys(value), SUBAGENT_SESSION_RUNTIME_SHAPE) || value.schemaVersion !== SUBAGENT_SESSION_RUNTIME_SCHEMA_VERSION || !Number.isSafeInteger(value.definitionVersion) || (value.definitionVersion as number) < 1 || @@ -485,7 +502,7 @@ export function isSubagentSessionRuntime(value: unknown): value is SubagentSessi ) { return false; } - return value.permissionCeiling === undefined || isPermissionMode(value.permissionCeiling); + return true; } /** Strict decoder guard for durable child-spawn idempotency metadata. */ diff --git a/packages/core/src/settings.ts b/packages/core/src/settings.ts index db4a2ba9e8..8dac3f7836 100644 --- a/packages/core/src/settings.ts +++ b/packages/core/src/settings.ts @@ -16,6 +16,7 @@ import { } from './web-search.js'; import { defaultLocalMemorySettings, normalizeLocalMemorySettings } from './local-memory.js'; import type { PermissionMode } from './permission.js'; +import { decodePersistedPermissionMode } from './permission.js'; import { UI_LOCALE_PREFERENCES, isUiLocalePreference, @@ -676,12 +677,14 @@ function normalizeChatDefaultsSettings(settings: ChatDefaultsSettings): ChatDefa // drops to "no preference" (the model's own default) rather than reaching // session creation as a rung no picker recognizes. thinkingLevel: isThinkingLevel(settings.thinkingLevel) ? settings.thinkingLevel : undefined, - permissionMode: - (settings.permissionMode as unknown) === 'execute' - ? 'ask' - : isChatDefaultPermissionMode(settings.permissionMode) - ? settings.permissionMode - : 'ask', + // A retired mode is decoded (not rejected) so an existing settings file + // keeps working; knowing which modes are retired lives in one place. + // Anything that decodes to a mode outside the pickable set — including + // `explore`, which only a product mode confers — still falls back. + permissionMode: (() => { + const mode = decodePersistedPermissionMode(settings.permissionMode); + return mode !== undefined && isChatDefaultPermissionMode(mode) ? mode : 'ask'; + })(), }; } diff --git a/packages/core/src/tool-result-record-schema.ts b/packages/core/src/tool-result-record-schema.ts index 0fb5321f12..aeacb35442 100644 --- a/packages/core/src/tool-result-record-schema.ts +++ b/packages/core/src/tool-result-record-schema.ts @@ -2,7 +2,7 @@ import { decodeCanonicalShellToolResultContent, isSandboxDenialSignal, } from './shell-run-result.js'; -import { isPermissionMode } from './permission.js'; +import { decodePersistedPermissionMode } from './permission.js'; import { isStorageRef, type ToolResultContent } from './events.js'; import { validateSandboxBoundaryExpansion } from './sandbox-boundary.js'; import { @@ -196,7 +196,21 @@ export function decodeCanonicalToolResultContent(value: unknown): ToolResultCont if (!isNonShellToolResultContent(value)) { throw new Error('Invalid tool result content'); } - return value; + return foldRetiredPermissionMode(value); +} + +/** + * Transcript records written before a permission mode was retired still carry + * the old spelling. The shape validators accept it on purpose — rejecting would + * make the Turn unreadable — so canonicalize it here, at the single exit every + * stored tool result passes through, rather than leaving a value the return + * type forbids. + */ +function foldRetiredPermissionMode(content: ToolResultContent): ToolResultContent { + if (content.kind !== 'subagent') return content; + const permissionMode = decodePersistedPermissionMode(content.permissionMode); + if (permissionMode === undefined || permissionMode === content.permissionMode) return content; + return { ...content, permissionMode }; } function isNonShellToolResultContent(value: unknown): value is ToolResultContent { @@ -305,7 +319,7 @@ function hasValidSubagentResultFields(value: Record): boolean { typeof value.agentName === 'string' && typeof value.turnId === 'string' && isOptionalString(value.runId) && - isPermissionMode(value.permissionMode) && + decodePersistedPermissionMode(value.permissionMode) !== undefined && typeof value.summary === 'string' && isStringArray(value.artifactIds) && isOptionalFiniteNumber(value.startedAt) && diff --git a/packages/core/src/workspace.ts b/packages/core/src/workspace.ts deleted file mode 100644 index a7b9f82cf7..0000000000 --- a/packages/core/src/workspace.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Workspace config types. - */ - -import type { PersistedBackendKind } from './session.js'; -import type { PermissionMode } from './permission.js'; - -export interface WorkspaceConfig { - id: string; - name: string; - /** Absolute path: ~/.maka/workspaces/{id}/ */ - rootPath: string; - createdAt: number; - defaults: { - permissionMode: PermissionMode; - backend: PersistedBackendKind; - llmConnectionSlug?: string; - model?: string; - }; -} diff --git a/packages/runtime-host/src/__tests__/execution-composition.test.ts b/packages/runtime-host/src/__tests__/execution-composition.test.ts index 6e474e8c61..8876739125 100644 --- a/packages/runtime-host/src/__tests__/execution-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-composition.test.ts @@ -981,7 +981,6 @@ async function createClaimedGraphChild(input: { systemPrompt: LOCAL_READ_AGENT_DEFINITION.systemPrompt, toolNames: [...LOCAL_READ_AGENT_DEFINITION.tools], categoryPolicy: {}, - permissionCeiling: 'ask', }, subagentSpawn: { schemaVersion: 1, diff --git a/packages/runtime-host/src/__tests__/fixtures/execution-host-suite.ts b/packages/runtime-host/src/__tests__/fixtures/execution-host-suite.ts index 88289e779a..32d75f45c3 100644 --- a/packages/runtime-host/src/__tests__/fixtures/execution-host-suite.ts +++ b/packages/runtime-host/src/__tests__/fixtures/execution-host-suite.ts @@ -498,7 +498,6 @@ export class ExecutionFixture { systemPrompt: 'Read the assigned workspace task.', toolNames: ['Read', 'Glob', 'Grep'], categoryPolicy: { read: 'allow' }, - permissionCeiling: 'ask', }, subagentSpawn: { schemaVersion: 1, diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts index 6f23d9ff8e..417fb2b8b1 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -99,19 +99,34 @@ describe('Runtime Host bootstrap protocol', () => { }); test('publishes a new compatibility epoch for sandbox failure results', () => { + // Epoch 32 rejects the bounded sandbox failure reason on live tool results, + // so mixed-version peers must fail the handshake. Asserted as a floor, like + // the epochs above: pinning an exact value breaks on every later bump. assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 32); }); test('publishes a new compatibility epoch for backend-free ScheduledTask templates', () => { + // Epoch 33 Clients require the `backend` field these templates no longer + // emit. Also a floor, for the same reason as above. assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 33); }); test('publishes a new compatibility epoch for Session trace pagination', () => { + // Epoch 34 peers cannot exchange the paged trace and usage frames. Also a + // floor, for the same reason as above. assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 34); }); test('publishes a new compatibility epoch for TraceTotals removal', () => { - assert.equal(RUNTIME_HOST_COMPATIBILITY_EPOCH, 36); + // Epoch 35 peers still transport aggregate TraceTotals. Also a floor, for + // the same reason as above. + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 35); + }); + + test('publishes a new compatibility epoch for the retired execute permission mode', () => { + // Epoch 36 still speaks `execute`. Frame decoders now reject it, so such a + // peer would fail mid-Session rather than at connect. + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 36); }); test('selects the highest mutually supported protocol and rejects a gap', () => { diff --git a/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts b/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts index bcd4e0d9d3..4e587014e5 100644 --- a/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts @@ -1009,7 +1009,7 @@ test('linked child Sessions reject public safe-boundary continuation', async () cwd: parent.cwd, llmConnectionSlug: 'fake', model: 'fake-model', - permissionMode: 'execute', + permissionMode: 'ask', collaborationMode: 'agent', orchestrationMode: 'default', subagentParent: { @@ -1152,7 +1152,7 @@ test('worktree child Sessions reject roots outside managed child execution', asy cwd: binding.worktreePath, llmConnectionSlug: 'fake', model: 'fake-model', - permissionMode: 'execute', + permissionMode: 'ask', collaborationMode: 'agent', orchestrationMode: 'default', subagentParent: { diff --git a/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts b/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts index f59fb1b367..ac8bf1be87 100644 --- a/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts @@ -724,7 +724,7 @@ test('creation materializes Deep Research semantics inside the Host transaction' name: 'Caller override', labels: ['customer-label'], modelTarget: { kind: 'default' }, - permissionMode: 'execute', + permissionMode: 'ask', }, context, ); diff --git a/packages/runtime-host/src/__tests__/session-retirement-coordinator.test.ts b/packages/runtime-host/src/__tests__/session-retirement-coordinator.test.ts index 48bdb4a410..92d71b2057 100644 --- a/packages/runtime-host/src/__tests__/session-retirement-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/session-retirement-coordinator.test.ts @@ -507,7 +507,7 @@ describe('Host Session retirement coordinator', () => { }; const { header: child } = await harness.store.createSubagent( sessionInput('Worktree child', { - permissionMode: 'execute', + permissionMode: 'ask', subagentParent: { kind: 'subagent', parentSessionId: harness.rootId, @@ -1253,7 +1253,7 @@ async function createClosedSubagent( const seed = index.toString(16).padStart(64, '0'); const { header } = await harness.store.createSubagent( sessionInput(`Subagent ${index}`, { - permissionMode: 'execute', + permissionMode: 'ask', subagentParent: { kind: 'subagent', parentSessionId, @@ -1331,7 +1331,7 @@ async function createClosedGraphOperator( }; const child = await harness.store.createAgentGraphOperator( sessionInput('Graph operator', { - permissionMode: 'execute', + permissionMode: 'ask', subagentParent: { kind: 'subagent', parentSessionId: rootSessionId, diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 8391aeb26c..fd08bca62c 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -72,7 +72,9 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 36 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 37 as const; +// 37: `execute` is no longer a permission mode. Frame decoders reject it, so a +// peer that still sends it would fail mid-Session rather than at connect. // 36: Session trace inspection no longer transports aggregate TraceTotals. // 35: Session trace inspection uses cursor pages and Session usage has its own // invalidation domain. Older peers cannot safely exchange those frames. diff --git a/packages/runtime/src/__tests__/builtin-tools.test.ts b/packages/runtime/src/__tests__/builtin-tools.test.ts index 84e1bb232c..04cc8e3759 100644 --- a/packages/runtime/src/__tests__/builtin-tools.test.ts +++ b/packages/runtime/src/__tests__/builtin-tools.test.ts @@ -472,7 +472,7 @@ describe('builtin Bash streaming output', () => { turnId: 'turn-1', toolCallId: 'tool-1', cwd: '/workspace', - permissionMode: 'execute', + permissionMode: 'ask', abortSignal: new AbortController().signal, emitOutput: () => {}, }, @@ -607,7 +607,7 @@ describe('builtin Bash streaming output', () => { turnId: 'turn-1', toolCallId: 'tool-1', cwd: workspace, - permissionMode: 'execute', + permissionMode: 'ask', abortSignal: new AbortController().signal, emitOutput: () => {}, executionBoundary: { @@ -810,7 +810,7 @@ describe('builtin Bash streaming output', () => { turnId: 'turn-1', toolCallId: 'tool-1', cwd, - permissionMode: 'execute', + permissionMode: 'ask', executionBoundary: { kind: 'managed', revision: 1, @@ -867,7 +867,7 @@ describe('builtin Bash streaming output', () => { turnId: 'turn-1', toolCallId: 'tool-1', cwd: '/workspace', - permissionMode: 'execute', + permissionMode: 'ask', abortSignal: new AbortController().signal, emitOutput: () => {}, executionBoundary: { @@ -886,7 +886,7 @@ describe('builtin Bash streaming output', () => { turnId: 'turn-1', toolCallId: 'tool-2', cwd: '/workspace', - permissionMode: 'execute', + permissionMode: 'ask', abortSignal: new AbortController().signal, emitOutput: () => {}, executionBoundary: { kind: 'bypass', revision: 1 }, @@ -1012,7 +1012,7 @@ describe('builtin Bash streaming output', () => { turnId: 'turn-1', toolCallId: 'tool-1', cwd: '/workspace', - permissionMode: 'execute', + permissionMode: 'ask', abortSignal: new AbortController().signal, emitOutput: () => {}, }, @@ -1109,7 +1109,7 @@ describe('builtin Bash streaming output', () => { turnId: 'turn-1', toolCallId: 'tool-1', cwd: canonicalWorkspace, - permissionMode: 'execute', + permissionMode: 'ask', abortSignal: new AbortController().signal, emitOutput: () => {}, executionBoundary: { @@ -1187,7 +1187,7 @@ describe('builtin Bash streaming output', () => { turnId: 'turn-1', toolCallId: 'tool-1', cwd, - permissionMode: 'execute', + permissionMode: 'ask', abortSignal: new AbortController().signal, emitOutput: () => {}, executionBoundary: { @@ -1237,7 +1237,7 @@ describe('builtin Bash streaming output', () => { turnId: 'turn-1', toolCallId: 'tool-1', cwd, - permissionMode: 'execute', + permissionMode: 'ask', abortSignal: new AbortController().signal, emitOutput: () => {}, executionBoundary: { @@ -1303,7 +1303,7 @@ describe('builtin Bash streaming output', () => { turnId: 'turn-1', toolCallId: 'tool-1', cwd: workspaceAlias, - permissionMode: 'execute', + permissionMode: 'ask', abortSignal: new AbortController().signal, emitOutput: () => {}, }, @@ -2445,7 +2445,7 @@ async function linuxMissingExactWriteFixture() { turnId: 'turn-1', toolCallId: 'tool-1', cwd: canonicalWorkspace, - permissionMode: 'execute' as const, + permissionMode: 'ask' as const, abortSignal: new AbortController().signal, emitOutput: () => {}, executionBoundary: { diff --git a/packages/runtime/src/__tests__/deferred-tools-backend.test.ts b/packages/runtime/src/__tests__/deferred-tools-backend.test.ts index 3d5a454e7d..ef6f12ee3b 100644 --- a/packages/runtime/src/__tests__/deferred-tools-backend.test.ts +++ b/packages/runtime/src/__tests__/deferred-tools-backend.test.ts @@ -544,7 +544,7 @@ describe('AiSdkBackend deferred agent tools', () => { const spawnCalls: unknown[] = []; await drainWithDurableTurn( agentBackend(loadAgentThenSpawnModel(captured), spawnCalls, { - permissionMode: 'execute', + permissionMode: 'ask', durable, }).send(durable.sendInput({ runId: 'parent-run' })), durable, diff --git a/packages/runtime/src/__tests__/filesystem-worker-client.test.ts b/packages/runtime/src/__tests__/filesystem-worker-client.test.ts index ff12ce1c49..bdc9af934b 100644 --- a/packages/runtime/src/__tests__/filesystem-worker-client.test.ts +++ b/packages/runtime/src/__tests__/filesystem-worker-client.test.ts @@ -83,7 +83,7 @@ describe('filesystem worker client permission snapshots', () => { operation: { kind: 'write', path: 'allowed-by-legacy-mode.txt', content: kind }, cwd: workspace, executionBoundary: { kind, revision: 1 }, - mode: 'execute', + mode: 'ask', }), (error: unknown) => { assert.ok(error instanceof FilesystemWorkerClientError); @@ -135,7 +135,7 @@ describe('filesystem worker client permission snapshots', () => { client.execute({ operation: { kind: 'write', path: 'blocked.txt', content: 'blocked' }, cwd: workspace, - mode: 'execute', + mode: 'ask', permissionProfile: createReadOnlyPermissionProfile(), }), isPathDenied, @@ -164,7 +164,7 @@ describe('filesystem worker client permission snapshots', () => { client.execute({ operation: { kind: 'write', path: target, content: 'blocked' }, cwd: workspace, - mode: 'execute', + mode: 'ask', permissionProfile: profile, }), isPathDenied, @@ -265,7 +265,7 @@ describe('filesystem worker operation-scoped Seatbelt profile', () => { await client.execute({ operation: { kind: 'write', path: target, content: 'target' }, cwd: workspace, - mode: 'execute', + mode: 'ask', }); const transform = transforms[0]; diff --git a/packages/runtime/src/__tests__/linux-sandbox-smoke.test.ts b/packages/runtime/src/__tests__/linux-sandbox-smoke.test.ts index 9217d1f160..643dbfdc1f 100644 --- a/packages/runtime/src/__tests__/linux-sandbox-smoke.test.ts +++ b/packages/runtime/src/__tests__/linux-sandbox-smoke.test.ts @@ -213,7 +213,7 @@ describe('Linux sandbox smoke', () => { turnId: 'turn-1', toolCallId: 'tool-1', cwd: workspace, - permissionMode: 'execute', + permissionMode: 'ask', abortSignal: new AbortController().signal, emitOutput: () => {}, }, @@ -273,7 +273,7 @@ describe('Linux sandbox smoke', () => { turnId: 'turn-1', toolCallId: 'tool-additional', cwd: workspace, - permissionMode: 'execute', + permissionMode: 'ask', executionBoundary: expandedBoundary, abortSignal: new AbortController().signal, emitOutput: () => {}, @@ -297,7 +297,7 @@ describe('Linux sandbox smoke', () => { turnId: 'turn-1', toolCallId: 'tool-unrelated', cwd: workspace, - permissionMode: 'execute', + permissionMode: 'ask', executionBoundary: expandedBoundary, abortSignal: new AbortController().signal, emitOutput: () => {}, @@ -313,7 +313,7 @@ describe('Linux sandbox smoke', () => { turnId: 'turn-1', toolCallId: 'tool-escalation', cwd: workspace, - permissionMode: 'execute', + permissionMode: 'ask', executionBoundary: { kind: 'bypass' as const, revision: 2 }, abortSignal: new AbortController().signal, emitOutput: () => {}, diff --git a/packages/runtime/src/__tests__/runtime-continuation-crash.test.ts b/packages/runtime/src/__tests__/runtime-continuation-crash.test.ts index 0a69757089..47290914b9 100644 --- a/packages/runtime/src/__tests__/runtime-continuation-crash.test.ts +++ b/packages/runtime/src/__tests__/runtime-continuation-crash.test.ts @@ -175,7 +175,7 @@ async function runCrashChild(): Promise { cwd: workspaceRoot, llmConnectionSlug: 'fake', model: 'fake-model', - permissionMode: 'execute', + permissionMode: 'ask', name: 'continuation crash child', }); await runStore.createRun(sourceHeader(session.id, workspaceRoot)); @@ -355,7 +355,7 @@ function sourceHeader(sessionId: string, cwd: string): AgentRunHeader { modelId: 'fake-model', cwd, workspaceIdentity: 'workspace-1', - permissionMode: 'execute', + permissionMode: 'ask', createdAt: 1, updatedAt: 2, completedAt: 2, diff --git a/packages/runtime/src/__tests__/sandbox-diagnostics.test.ts b/packages/runtime/src/__tests__/sandbox-diagnostics.test.ts index 8db778d7de..9020964657 100644 --- a/packages/runtime/src/__tests__/sandbox-diagnostics.test.ts +++ b/packages/runtime/src/__tests__/sandbox-diagnostics.test.ts @@ -18,7 +18,7 @@ describe('sandbox diagnostics', () => { canonicalizePath: async (path) => path, }); const unsupportedSnapshot = await unsupported.resolve({ - mode: 'execute', + mode: 'ask', cwd: 'C:\\workspace', }); assert.deepEqual(unsupportedSnapshot.capabilities.command.failure, { diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index be00dd12f6..0dd5d30f48 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -903,7 +903,7 @@ describe('SessionManager graph operator provisioning', () => { expect(provisioned).toHaveLength(1); expect(result.header.projectId).toBe('project-1'); - expect(result.header.permissionMode).toBe('execute'); + expect(result.header.permissionMode).toBe('ask'); expect( result.header.subagentRuntime ? 'permissionCeiling' in result.header.subagentRuntime @@ -942,7 +942,7 @@ describe('SessionManager graph operator provisioning', () => { const { header: child } = await store.createSubagent( makeInput({ cwd: binding.worktreePath, - permissionMode: 'execute', + permissionMode: 'ask', subagentParent: { kind: 'subagent', parentSessionId: parent.id, @@ -982,7 +982,7 @@ describe('SessionManager graph operator provisioning', () => { completedAt: 20, updatedAt: 20, cwd: binding.worktreePath, - permissionMode: 'execute', + permissionMode: 'ask', agentId: IMPLEMENTATION_AGENT_ID, agentName: IMPLEMENTATION_AGENT_DEFINITION.name, }), @@ -1057,7 +1057,7 @@ describe('SessionManager graph operator provisioning', () => { completedAt: 60, updatedAt: 60, cwd: binding.worktreePath, - permissionMode: 'execute', + permissionMode: 'ask', agentId: IMPLEMENTATION_AGENT_ID, agentName: IMPLEMENTATION_AGENT_DEFINITION.name, resumedFromRunId: 'child-run', @@ -2260,8 +2260,8 @@ describe('SessionManager child-session runtime primitive', () => { (message) => message.type === 'user' && message.text === 'inspect the storage boundary', ), ).toBe(true); - await manager.setPermissionMode(result.childSessionId, 'execute'); - expect((await store.readHeader(result.childSessionId)).permissionMode).toBe('execute'); + await manager.setPermissionMode(result.childSessionId, 'bypass'); + expect((await store.readHeader(result.childSessionId)).permissionMode).toBe('bypass'); const projection = await manager.listChildAgents(parent.id); expect(projection.runs).toEqual([]); expect(projection.executions).toHaveLength(1); @@ -3153,7 +3153,6 @@ describe('SessionManager child-session runtime primitive', () => { systemPrompt: LOCAL_READ_AGENT_DEFINITION.systemPrompt, toolNames: [...LOCAL_READ_AGENT_DEFINITION.tools], categoryPolicy: {}, - permissionCeiling: 'ask', }, subagentSpawn: { schemaVersion: 1, @@ -3476,7 +3475,6 @@ describe('SessionManager child-session runtime primitive', () => { systemPrompt: LOCAL_READ_AGENT_DEFINITION.systemPrompt, toolNames: [...LOCAL_READ_AGENT_DEFINITION.tools], categoryPolicy: {}, - permissionCeiling: 'ask', }, subagentSpawn: { schemaVersion: 1, @@ -3749,7 +3747,6 @@ describe('SessionManager child-session runtime primitive', () => { systemPrompt: LOCAL_READ_AGENT_DEFINITION.systemPrompt, toolNames: ['Read', 'Glob', 'Grep'], categoryPolicy: { read: 'allow' }, - permissionCeiling: 'ask', }, subagentSpawn: { schemaVersion: 1, @@ -5087,7 +5084,7 @@ describe('SessionManager permission mode updates', () => { expect(afterFirstRuns.find((run) => run.turnId === 'turn-1')?.status).toBe('completed'); expect(afterFirstRuns.find((run) => run.turnId === 'turn-2')?.status).toBe('running'); - await expectRejects(manager.setPermissionMode(session.id, 'execute'), /当前任务正在运行/); + await expectRejects(manager.setPermissionMode(session.id, 'bypass'), /当前任务正在运行/); secondGate.release(); await second.next(); @@ -5103,8 +5100,8 @@ describe('SessionManager permission mode updates', () => { expect(firstEvents.map((event) => event.type)).toContain('run_started'); expect(firstEvents.map((event) => event.type)).toContain('run_completed'); - const summary = await manager.setPermissionMode(session.id, 'execute'); - expect(summary.permissionMode).toBe('execute'); + const summary = await manager.setPermissionMode(session.id, 'bypass'); + expect(summary.permissionMode).toBe('bypass'); }); test('leaving explore clears the deep research label so visible read-only copy stays truthful', async () => { @@ -8842,7 +8839,7 @@ describe('SessionManager permission mode updates', () => { id: 'legacy-note', ts: 104, kind: 'mode_change', - data: { from: 'ask', to: 'execute' }, + data: { from: 'ask', to: 'bypass' }, }; await store.appendMessage(session.id, legacyNote); @@ -10597,8 +10594,8 @@ describe('SessionManager permission mode updates', () => { if (!childRun) throw new Error('child run was not recorded'); expect(childRun.status).toBe('cancelled'); expect(store.disposeCount).toBe(1); - await manager.setPermissionMode(session.id, 'execute'); - expect((await store.readHeader(session.id)).permissionMode).toBe('execute'); + await manager.setPermissionMode(session.id, 'bypass'); + expect((await store.readHeader(session.id)).permissionMode).toBe('bypass'); }); test('stopSession waits for a child start blocked before Run reservation', async () => { @@ -11543,7 +11540,7 @@ describe('SessionManager permission mode updates', () => { now: nextNow(6_847), runtimeSource: 'test', }); - const session = await manager.createSession(makeInput({ permissionMode: 'execute' })); + const session = await manager.createSession(makeInput({ permissionMode: 'ask' })); await drain(manager.sendMessage(session.id, { turnId: 'parent-turn', text: 'parent context' })); const [parentRun] = await runStore.listSessionRuns(session.id); if (!parentRun) throw new Error('parent run was not recorded'); @@ -11617,7 +11614,7 @@ describe('SessionManager permission mode updates', () => { now: nextNow(6_848), runtimeSource: 'test', }); - const session = await manager.createSession(makeInput({ permissionMode: 'execute' })); + const session = await manager.createSession(makeInput({ permissionMode: 'ask' })); await seedRuntimeRun( runStore, makeRunHeader({ @@ -11803,7 +11800,7 @@ describe('SessionManager permission mode updates', () => { now: nextNow(6_900), runtimeSource: 'test', }); - const session = await manager.createSession(makeInput({ permissionMode: 'execute' })); + const session = await manager.createSession(makeInput({ permissionMode: 'ask' })); await seedRuntimeRun( runStore, makeRunHeader({ @@ -18358,7 +18355,6 @@ function createGraphOperatorSession( systemPrompt: LOCAL_READ_AGENT_DEFINITION.systemPrompt, toolNames: [...LOCAL_READ_AGENT_DEFINITION.tools], categoryPolicy: {}, - permissionCeiling: 'ask', }, }), ); diff --git a/packages/runtime/src/__tests__/subagent-tools.test.ts b/packages/runtime/src/__tests__/subagent-tools.test.ts index 768844f2cc..402aeadf98 100644 --- a/packages/runtime/src/__tests__/subagent-tools.test.ts +++ b/packages/runtime/src/__tests__/subagent-tools.test.ts @@ -894,7 +894,7 @@ describe('subagent tools', () => { turnId: 'child-turn', runId: 'child-run', status: 'completed', - permissionMode: 'execute', + permissionMode: 'ask', summary: 'done', artifactIds: [], }; diff --git a/packages/runtime/src/agent-catalog.ts b/packages/runtime/src/agent-catalog.ts index 3322957ef5..39ac5ef85e 100644 --- a/packages/runtime/src/agent-catalog.ts +++ b/packages/runtime/src/agent-catalog.ts @@ -153,7 +153,7 @@ export const WEB_RESEARCH_AGENT_DEFINITION: AgentDefinition = { defaultWriteBack: AGENT_WRITE_BACK_SUMMARY, supportedWriteBack: [AGENT_WRITE_BACK_SUMMARY], }, - permissionMode: 'execute', + permissionMode: 'ask', tools: ['WebSearch'], systemPrompt: [ 'You are a foreground web-research child agent.', @@ -178,7 +178,7 @@ export const IMPLEMENTATION_AGENT_DEFINITION: AgentDefinition = { defaultWriteBack: AGENT_WRITE_BACK_PATCH, supportedWriteBack: [AGENT_WRITE_BACK_PATCH], }, - permissionMode: 'execute', + permissionMode: 'ask', tools: [ 'Read', 'Glob', diff --git a/packages/storage/src/__tests__/artifact-writer-lock.test.ts b/packages/storage/src/__tests__/artifact-writer-lock.test.ts index 039a5e52f7..52700a1b5c 100644 --- a/packages/storage/src/__tests__/artifact-writer-lock.test.ts +++ b/packages/storage/src/__tests__/artifact-writer-lock.test.ts @@ -489,7 +489,7 @@ function sessionInput() { backend: 'fake' as const, llmConnectionSlug: 'fixture', model: 'fixture-model', - permissionMode: 'execute' as const, + permissionMode: 'ask' as const, name: 'Selected', }; } diff --git a/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts b/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts index d8bccad8b2..6b1d013f9a 100644 --- a/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts +++ b/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts @@ -1855,7 +1855,6 @@ describe('SqliteSessionMetadataStore', () => { systemPrompt: 'Read the assigned workspace task.', toolNames: ['Read', 'Glob', 'Grep'], categoryPolicy: { read: 'allow' as const }, - permissionCeiling: 'ask' as const, }; const subagentSpawn = { schemaVersion: 1 as const, @@ -1954,7 +1953,6 @@ describe('SqliteSessionMetadataStore', () => { systemPrompt: 'Original durable prompt.', toolNames: ['Read'], categoryPolicy: { read: 'allow' as const }, - permissionCeiling: 'ask' as const, }; const childHeader = (overrides: Partial): SessionHeader => fullHeader({ @@ -3067,7 +3065,6 @@ function graphChildHeader(overrides: Partial = {}): SessionHeader systemPrompt: 'Read only.', toolNames: ['Read'], categoryPolicy: { read: 'allow' }, - permissionCeiling: 'ask', }, subagentSpawn: { schemaVersion: 1, diff --git a/packages/storage/src/__tests__/sqlite-workflow-store.test.ts b/packages/storage/src/__tests__/sqlite-workflow-store.test.ts index 04f2037423..db61829a2f 100644 --- a/packages/storage/src/__tests__/sqlite-workflow-store.test.ts +++ b/packages/storage/src/__tests__/sqlite-workflow-store.test.ts @@ -344,7 +344,7 @@ describe('SQLite workflow stores', () => { backend: 'ai-sdk', llmConnectionSlug: 'default', model: 'test-model', - permissionMode: 'execute', + permissionMode: 'ask', collaborationMode: 'agent', orchestrationMode: 'default', }, diff --git a/packages/storage/src/scheduled-task-store.ts b/packages/storage/src/scheduled-task-store.ts index 8d78f32fa3..c92c1c4d14 100644 --- a/packages/storage/src/scheduled-task-store.ts +++ b/packages/storage/src/scheduled-task-store.ts @@ -3,6 +3,7 @@ import { resolve } from 'node:path'; import { compareScheduledTasksForList, computeNextFireAt, + decodePersistedScheduledTask, isScheduledTaskDue, nextScheduledTaskStateAfterFire, normalizeCreateScheduledTaskInput, @@ -577,7 +578,7 @@ class SqliteScheduledTaskStore implements ScheduledTaskStore { if (typeof row.record_json !== 'string') { throw new Error(`Invalid scheduled task at row ${index + 1}`); } - return JSON.parse(row.record_json) as ScheduledTask; + return decodePersistedScheduledTask(JSON.parse(row.record_json) as ScheduledTask); }); const claimRows = this.#lease.database .prepare(` @@ -590,7 +591,8 @@ class SqliteScheduledTaskStore implements ScheduledTaskStore { if (typeof row.record_json !== 'string') { throw new Error(`Invalid scheduled task fire claim at row ${index + 1}`); } - return JSON.parse(row.record_json) as ScheduledTaskFireClaim; + const claim = JSON.parse(row.record_json) as ScheduledTaskFireClaim; + return { ...claim, task: decodePersistedScheduledTask(claim.task) }; }); return { tasks, claims }; } diff --git a/packages/storage/src/session-store.ts b/packages/storage/src/session-store.ts index 4dc5e36d5b..d6a66c145c 100644 --- a/packages/storage/src/session-store.ts +++ b/packages/storage/src/session-store.ts @@ -30,7 +30,7 @@ import { } from '@maka/core/session'; import { isCollaborationMode } from '@maka/core/collaboration'; import { isOrchestrationMode } from '@maka/core/orchestration'; -import { isPermissionMode } from '@maka/core/permission'; +import { decodePersistedPermissionMode } from '@maka/core/permission'; import { isSubagentWorkspaceBinding } from '@maka/core/subagent-workspace'; import { WORKSPACE_AUTHORITY_SESSION_ID } from '@maka/core/workspace-version-authority'; import type { @@ -1062,6 +1062,10 @@ export function normalizeSessionHeader( header: SessionHeader, sessionId: string = header.id, ): SessionHeader { + // A retired mode decodes to its live equivalent rather than failing the + // header: such a record is old, not malformed, and rejecting it would make + // the Session unopenable. + const permissionMode = decodePersistedPermissionMode(header.permissionMode); const valid = header.id === sessionId && typeof header.workspaceRoot === 'string' && @@ -1095,7 +1099,7 @@ export function normalizeSessionHeader( typeof header.connectionLocked === 'boolean' && typeof header.model === 'string' && (header.toolProfile === undefined || isSessionToolProfile(header.toolProfile)) && - isPermissionMode(header.permissionMode) && + permissionMode !== undefined && isCollaborationMode(header.collaborationMode) && isOrchestrationMode(header.orchestrationMode) && (header.transcriptLedgerVersion === undefined || @@ -1108,9 +1112,9 @@ export function normalizeSessionHeader( const normalizedName = normalizeSessionName(header.name); if (header.blockedReason === undefined) { const { blockedReason: _blockedReason, ...withoutBlockedReason } = header; - return { ...withoutBlockedReason, name: normalizedName }; + return { ...withoutBlockedReason, name: normalizedName, permissionMode }; } - return { ...header, name: normalizedName }; + return { ...header, name: normalizedName, permissionMode }; } function isValidSessionExternalOrigin(origin: SessionHeader['externalOrigin']): boolean { diff --git a/packages/ui/src/conversation-copy.ts b/packages/ui/src/conversation-copy.ts index e7697cb894..dac4d36df4 100644 --- a/packages/ui/src/conversation-copy.ts +++ b/packages/ui/src/conversation-copy.ts @@ -418,7 +418,6 @@ const CONVERSATION_COPY = { mode: { explore: { label: '只读', hint: '只读搜索,不写文件、不上网;需要时先问你。' }, ask: { label: '自动', hint: '保护层内自动执行,越权先问你。' }, - execute: { label: '自动执行', hint: '常见工具直接执行;危险操作仍会确认。' }, bypass: { label: '完全权限', hint: '直接访问文件和网络,仅限可信任务。' }, }, modeAriaLabel: (label) => `权限模式:${label}`, @@ -559,7 +558,6 @@ const CONVERSATION_COPY = { mode: { explore: { label: 'Read only', hint: 'Read and search only; asks before write or network.' }, ask: { label: 'Auto', hint: "Runs inside Maka's protection; asks before going further." }, - execute: { label: 'Auto execute', hint: 'Common tools run; risky actions still confirm.' }, bypass: { label: 'Full access', hint: 'Direct file and network access. Trust-only tasks.' }, }, modeAriaLabel: (label) => `Permission mode: ${label}`, diff --git a/packages/ui/src/permission-mode-menu.tsx b/packages/ui/src/permission-mode-menu.tsx index acf8a71dd1..0769e1dc1b 100644 --- a/packages/ui/src/permission-mode-menu.tsx +++ b/packages/ui/src/permission-mode-menu.tsx @@ -31,10 +31,10 @@ export interface PermissionModeMeta { } /** - * Sessions may run under a read-only (`explore`) boundary and legacy records - * may contain `execute`, so metadata remains complete for the persisted - * PermissionMode union. User-facing pickers offer only Auto (`ask`) and full - * access (`bypass`), but any mode can be the state being displayed. + * Sessions may run under a read-only (`explore`) boundary, so metadata stays + * complete for the whole PermissionMode union. User-facing pickers offer only + * Auto (`ask`) and full access (`bypass`), but any mode can be the state being + * displayed. * * This module is the one home for the mode table and shared picker: both the * composer and Settings render from it so labels, hints, and markup cannot @@ -77,11 +77,9 @@ export function PermissionModeSelect(props: { const locale = useUiLocale(); const permissionCopy = getConversationCopy(locale).permissions; const modeMeta = getPermissionModeMeta(locale); - // #1611: only legacy `execute` collapses to Auto. `explore` is a real - // read-only boundary the user is running under, so it shows its own label - // and hint instead of borrowing Auto's. - const displayMode: PermissionMode = - props.activeMode === 'execute' ? 'ask' : props.activeMode; + // #1611: `explore` is a real read-only boundary the user is running under, + // so it shows its own label and hint instead of borrowing Auto's. + const displayMode: PermissionMode = props.activeMode; const meta = modeMeta[displayMode]; const selectedValue: ChatDefaultPermissionMode | undefined = PERMISSION_MODE_ORDER.includes( displayMode as ChatDefaultPermissionMode,