From f5172b310fffd739612aef7a3abf7cb698ce5f81 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 21 Aug 2026 15:59:13 +0800 Subject: [PATCH 1/8] refactor(desktop): drop the unused session-creation permission fallback `resolveCreateSessionInput` re-derived a new Session's permission mode, name and labels in the desktop main process, but nothing called it. The `sessions:create` handler uses the synchronous `resolveCreateSessionRequest` and forwards `mode` to the Runtime Host verbatim, which is what expands a product mode and what falls back to `chatDefaults.permissionMode`. The function survived only because its own tests kept it compiling, so it read like a second authority over the starting boundary while having no say in any Session actually created. Its tests move with it, except those that pin what still reaches the wire: an omitted mode staying omitted, the refusal of a directly-requested `explore`, and an unrecognized mode conferring nothing. The Deep Research expansion they also covered belongs to the Host, which owns it and tests it. `permission-mode-default.ts` stays: `runtime-host-boot` passes `resolveDefaultPermissionMode` to the skills IPC, which needs a concrete mode to predict what a new Session will start in and filter invocable skills accordingly. Refs #3385 Generated-by: Claude Code --- .../__tests__/create-session-input.test.ts | 119 ++++++------------ apps/desktop/src/main/create-session-input.ts | 50 ++------ 2 files changed, 47 insertions(+), 122 deletions(-) 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/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)), - }; -} From 5502e8e0985c5767a73e1c2465ca43e834cdc66f Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 21 Aug 2026 15:59:22 +0800 Subject: [PATCH 2/8] fix(cli): start a new Session in the Host's configured permission mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `maka run` sent `yolo ? 'bypass' : 'ask'` and the session driver defaulted to `'ask'` twice more, so the CLI always put an explicit mode on the wire. An explicit mode overrides `chatDefaults.permissionMode`, which meant the configured default could never apply to a CLI Session and `--yolo` read as one half of a choice rather than a one-shot elevation. An omitted mode now stays omitted all the way to `session.create`, where the Runtime Host resolves it from its own Runtime Policy — the single authority for what a new Session starts in. `CreateSessionRequest` makes that sayable: `CreateSessionInput` requires a mode because the local runtime writes it straight onto the header, but a client talking to a Host is in a different position and needs a way to express "no explicit choice". The TUI reads the same policy value at startup instead of assuming Auto. Its indicator names the mode a new Session will actually get, so a Host configured for full access is no longer displayed as protected — the one direction the label must never be wrong in. `startNewSession` still falls back to the construction-time default rather than carrying a previous Session's elevation (#3020); that default is now `undefined` for `maka run`, which resolves to the configured mode instead of a hardcoded one. Refs #3385 Generated-by: Claude Code --- packages/cli/src/run-command-core.ts | 10 +++++-- packages/cli/src/runtime-host-cli-context.ts | 27 +++++++++++++++++ packages/cli/src/runtime-host-run-command.ts | 7 ++--- .../cli/src/runtime-host-session-driver.ts | 29 +++++++++++++------ packages/cli/src/runtime-host-tui-context.ts | 11 +++++-- packages/cli/src/session-driver.ts | 25 ++++++++++++++-- 6 files changed, 88 insertions(+), 21 deletions(-) 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-context.ts b/packages/cli/src/runtime-host-tui-context.ts index aafeae6897..b6c0134e6d 100644 --- a/packages/cli/src/runtime-host-tui-context.ts +++ b/packages/cli/src/runtime-host-tui-context.ts @@ -13,7 +13,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, @@ -76,12 +80,13 @@ export async function createRuntimeHostTuiContext( ? await resolveResumeTarget(connection, catalog, input.resumeSessionId) : resolveTarget(catalog); const modelChoices = projectRuntimeHostModelChoices(catalog); + const hostDefaultPermissionMode = await readHostChatDefaultPermissionMode(connection); const driverInput: RuntimeHostMakaSessionDriverInput = { connection, cwd: input.cwd, llmConnectionSlug: target.connection.slug, model: target.model, - permissionMode: 'ask', + permissionMode: hostDefaultPermissionMode, executionLocation: connected.profile.kind === 'local' ? { kind: 'client_path' } : { kind: 'host' }, ...(workspace ? { workspace } : {}), @@ -105,7 +110,7 @@ export async function createRuntimeHostTuiContext( driver.getSessionId(), workspace ?? (connected.profile.kind === 'local' ? { kind: 'host_path', path: cwd } : undefined), - driver.getPermissionMode?.() ?? 'ask', + driver.getPermissionMode?.() ?? hostDefaultPermissionMode, ), 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 }; From f2460c05cf3835dc10363196a1aea5d6e522e417 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 21 Aug 2026 16:02:27 +0800 Subject: [PATCH 3/8] fix(desktop): collapse the shell's duplicate copies of the Host default `app-shell` read the selected Host's `chatDefaults` in one place and a global copy hydrated from the *default* Host in another, so with several Hosts connected the placeholder session view could name a different Host's mode than the picker directly above it. Settings now asks the shell to re-read the value instead of handing it a third copy. The composer picker moves onto the selected Host's value here as well. Which authority it writes to is settled in "stop clients from sending the Host default back as an explicit choice" later in this branch: the choice stays local to the draft and is sent once on create, and only the Settings surface writes `chatDefaults`. Renderer behavior has no unit-test surface in this app (desktop tests cover `main`), so this part is typecheck- and lint-verified only. Refs #3385 Generated-by: Claude Code --- .../src/renderer/app-shell-overlays.tsx | 12 ++++-- .../app-shell-session-settings-actions.ts | 5 ++- apps/desktop/src/renderer/app-shell.tsx | 41 ++++++++++++++----- .../src/renderer/use-shell-appearance.ts | 30 ++++++-------- 4 files changed, 54 insertions(+), 34 deletions(-) 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..b6bd3aa6ea 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -87,7 +87,6 @@ import { stageCompanionQuote, } from './quote-companion-panel-state'; import { UNRESOLVED_NEW_TASK_DRAFT_KEY } from './new-task-reload-intent'; -import { useNewTaskChoice } from './use-new-task-choice'; import { sideChatTitleFromPrompt } from './side-chat-command'; import { parseDesktopSlashCommand } from './desktop-slash-command'; import { @@ -427,8 +426,6 @@ function AppShellContent({ const [scheduledTaskCreateRequestNonce, setScheduledTaskCreateRequestNonce] = useState(0); const [pendingCollaborationModeBySession, setPendingCollaborationModeBySession] = useState>({}); const [pendingOrchestrationModeBySession, setPendingOrchestrationModeBySession] = useState>({}); - const [newTaskPermissionChoice, setNewTaskPermissionChoice] = - useNewTaskChoice(currentNewTaskDraftKey); const [historyLoadPendingSessionId, setHistoryLoadPendingSessionId] = useState(); const [transcriptTurnIndex, setTranscriptTurnIndex] = useState<{ sessionId: string; @@ -551,8 +548,8 @@ function AppShellContent({ uiLocaleUpdateGate, userLabel, setUserLabel, - defaultPermissionMode, - setDefaultPermissionMode, + + refreshShellSettings, } = useShellAppearance({ toastApi, @@ -565,11 +562,31 @@ function AppShellContent({ const desktopConversationCopy = getDesktopConversationCopy(uiLocale); const terminalPanelCopy = desktopConversationCopy.terminalPanel; const workbarCopy = desktopConversationCopy.workbar; + /** + * What a new task starts in, read straight from the Host that would run it. + * + * There is no draft-local copy: before a Session exists, "the mode this + * task will start in" and "the configured default" are the same fact, and + * a second copy of it could only be the stale one. Picking a mode here + * therefore writes the setting — which is also why the choice survives to + * the next new task instead of lasting one draft. + */ const newTaskPermissionMode = - newTaskPermissionChoice ?? - newTask.selectedHost?.chatDefaults.permissionMode ?? - 'ask'; - const setNewTaskPermissionMode = setNewTaskPermissionChoice; + newTask.selectedHost?.chatDefaults.permissionMode ?? 'ask'; + const setNewTaskPermissionMode = useCallback( + async (mode: ChatDefaultPermissionMode) => { + const host = newTask.selectedHost; + // Write to the Host that would run the task, not to whichever Host is + // otherwise selected: with several connected they are not the same, and + // the mode shown here belongs to this one. + await window.maka.settings.update( + { chatDefaults: { permissionMode: mode } }, + host ? { profileId: host.profile.id, hostId: host.hostId } : undefined, + ); + await newTask.refresh(); + }, + [newTask], + ); useEffect(() => { if (!isAppUpdateInstallFailure(appUpdateStatus)) { notifiedInstallErrorRef.current = null; @@ -1306,7 +1323,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 @@ -3651,7 +3668,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/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, }; } From 6f6acc36ce7e6abc649b53499ab7d3a68e8ceebe Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 21 Aug 2026 16:13:24 +0800 Subject: [PATCH 4/8] refactor: remove the `execute` permission mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `execute` had no behavior of its own. It compiled to the same workspace-write profile as `ask`, produced the same execution boundary, displayed as `ask`, and `executionBoundaryDisplayMode` — the single source for what is actually in force — could not return it at all. Five production sites existed only to fold it back into `ask`. Its two live writers were the Web Research and Implementation subagent definitions, where `'ask'` is behaviorally identical. Records written before this still carry it, so `decodePersistedPermissionMode` folds a stored `execute` to `ask` at the four persistence decode sites: session headers, agent run headers, subagent tool-result records, and chat default settings. Knowing which modes are retired now lives in one place instead of five ad-hoc comparisons. New input and wire values keep the strict check: the three protocol frame decoders and the desktop `sessions:setPermissionMode` IPC should reject a retired mode outright rather than quietly accept it. The compatibility epoch moves to 30 so a peer that still speaks `execute` is refused at the handshake instead of failing mid-Session. `maka activate --permission-mode` keeps accepting `execute` as an alias for `ask` — it is a public subcommand whose callers live outside this repo — and now offers `ask` by name. Its options type no longer excludes `ask`: that exclusion separated the two names while `execute` existed, but never the boundaries, which were always the same one. `LegacyPermissionMode` was a second spelling of the same member set and folds into `PermissionMode`. `isPermissionModeWithinCeiling` goes too: it had no production caller, and its only importer never called it. That also retires the implicit contract that `PERMISSION_MODES` array order encodes privilege strength. Tests that used `execute` as "a second mode that is not bypass" now say `ask` or `bypass` directly. One graph-provisioning test loses a distinction it was relying on — `ask` and `execute` were the only pair naming one boundary under two names — and now narrows from `bypass`, which is a real narrowing. Refs #3385 Generated-by: Claude Code --- apps/desktop/e2e/fixtures.ts | 2 +- .../__tests__/pending-session-view.test.ts | 2 +- .../runtime-host-client-operations.test.ts | 10 +++--- .../__tests__/runtime-host-client-uds.test.ts | 4 +-- .../src/renderer/locales/shell-copy.ts | 10 ------ packages/cli/src/activation-command.ts | 22 +++++++++--- packages/cli/src/pi-tui-pickers.ts | 5 ++- .../permission-profile-compiler.test.ts | 10 ++---- .../__tests__/subagent-session-parent.test.ts | 1 - packages/core/src/agent-run.ts | 12 +++++-- .../core/src/permission-profile-compiler.ts | 1 - packages/core/src/permission.ts | 36 ++++++++++++++----- packages/core/src/sandbox-boundary.ts | 4 +-- packages/core/src/settings.ts | 15 ++++---- packages/core/src/tool-result-preview.ts | 4 +-- .../core/src/tool-result-record-schema.ts | 4 +-- .../src/__tests__/protocol.test.ts | 17 ++++++++- .../__tests__/root-turn-coordinator.test.ts | 4 +-- .../session-catalog-coordinator.test.ts | 2 +- .../session-retirement-coordinator.test.ts | 6 ++-- packages/runtime-host/src/protocol/index.ts | 4 ++- .../src/__tests__/builtin-tools.test.ts | 22 ++++++------ .../__tests__/deferred-tools-backend.test.ts | 2 +- .../filesystem-worker-client.test.ts | 8 ++--- .../src/__tests__/linux-sandbox-smoke.test.ts | 8 ++--- .../runtime-continuation-crash.test.ts | 4 +-- .../src/__tests__/sandbox-diagnostics.test.ts | 2 +- .../src/__tests__/session-manager.test.ts | 30 ++++++++-------- .../src/__tests__/subagent-tools.test.ts | 2 +- packages/runtime/src/agent-catalog.ts | 4 +-- .../__tests__/artifact-writer-lock.test.ts | 2 +- .../__tests__/sqlite-workflow-store.test.ts | 2 +- packages/storage/src/session-store.ts | 12 ++++--- packages/ui/src/conversation-copy.ts | 2 -- packages/ui/src/permission-mode-menu.tsx | 16 ++++----- 35 files changed, 163 insertions(+), 128 deletions(-) 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__/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/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/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/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__/subagent-session-parent.test.ts b/packages/core/src/__tests__/subagent-session-parent.test.ts index 83c34578d3..972325d913 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', 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/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-preview.ts b/packages/core/src/tool-result-preview.ts index 119426efca..80b5009bba 100644 --- a/packages/core/src/tool-result-preview.ts +++ b/packages/core/src/tool-result-preview.ts @@ -4,7 +4,7 @@ */ import type { ToolResultContent, ToolResultPreviewContent } from './events.js'; -import { isPermissionMode } from './permission.js'; +import { decodePersistedPermissionMode } from './permission.js'; import { defineObjectShape, hasExactShape, isOptionalString, isRecord } from './record-schema.js'; const SUBAGENT_PREVIEW_SHAPE = defineObjectShape< @@ -58,6 +58,6 @@ function isSubagentPreview( typeof value.turnId === 'string' && isOptionalString(value.runId) && value.status === 'running' && - isPermissionMode(value.permissionMode) + decodePersistedPermissionMode(value.permissionMode) !== undefined ); } diff --git a/packages/core/src/tool-result-record-schema.ts b/packages/core/src/tool-result-record-schema.ts index 0fb5321f12..53e1a7a51a 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 { @@ -305,7 +305,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/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..38d90174d9 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); @@ -5087,7 +5087,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 +5103,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 +8842,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 +10597,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 +11543,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 +11617,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 +11803,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({ 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-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/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, From 3aeb7751054cc7b7dd35ebc659d44ee8d7c99eaa Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 21 Aug 2026 16:18:46 +0800 Subject: [PATCH 5/8] fix(storage): decode stored Automations instead of trusting the record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scheduled tasks are read back with a bare `JSON.parse(...) as ScheduledTask`, so a record written before a permission mode was retired carried that value straight into `compilePermissionProfile`, which no longer has a branch for it. `normalizeCreateScheduledTaskInput` could not catch this: it validates new input and stored records never pass through it. Add `decodePersistedScheduledTask` next to the type it decodes and call it on the store's read path. It folds retired representations to their live equivalents and leaves everything else as stored — it is a compatibility fold, not a schema validator. Refs #3385 Generated-by: Claude Code --- .../core/src/__tests__/scheduled-task.test.ts | 52 +++++++++++++++++++ packages/core/src/scheduled-task.ts | 30 ++++++++++- packages/storage/src/scheduled-task-store.ts | 6 ++- 3 files changed, 85 insertions(+), 3 deletions(-) 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/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/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 }; } From f72a515466e1534e4a192e6770ae32a0b436c0ac Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 21 Aug 2026 18:35:03 +0800 Subject: [PATCH 6/8] fix(core): canonicalize retired permission modes in subagent tool results `decodeCanonicalToolResultContent` accepted a stored `execute` and returned the record verbatim, so the decoder produced a value its own return type forbids and downstream readers could still observe the retired spelling. Fold it at the single exit every stored tool result passes through. `decodeToolResultPreviewContent` goes the other way: it decodes live open facts, never a stored record, and the compatibility epoch already refuses a peer old enough to send a retired mode. Accepting one there would only mask a handshake that should not have succeeded, so it returns to strict validation. Refs #3385 Generated-by: Claude Code --- .../src/__tests__/tool-result-preview.test.ts | 27 +++++++++++++ .../tool-result-record-schema.test.ts | 38 +++++++++++++++++++ packages/core/src/tool-result-preview.ts | 4 +- .../core/src/tool-result-record-schema.ts | 16 +++++++- 4 files changed, 82 insertions(+), 3 deletions(-) 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/tool-result-preview.ts b/packages/core/src/tool-result-preview.ts index 80b5009bba..119426efca 100644 --- a/packages/core/src/tool-result-preview.ts +++ b/packages/core/src/tool-result-preview.ts @@ -4,7 +4,7 @@ */ import type { ToolResultContent, ToolResultPreviewContent } from './events.js'; -import { decodePersistedPermissionMode } from './permission.js'; +import { isPermissionMode } from './permission.js'; import { defineObjectShape, hasExactShape, isOptionalString, isRecord } from './record-schema.js'; const SUBAGENT_PREVIEW_SHAPE = defineObjectShape< @@ -58,6 +58,6 @@ function isSubagentPreview( typeof value.turnId === 'string' && isOptionalString(value.runId) && value.status === 'running' && - decodePersistedPermissionMode(value.permissionMode) !== undefined + isPermissionMode(value.permissionMode) ); } diff --git a/packages/core/src/tool-result-record-schema.ts b/packages/core/src/tool-result-record-schema.ts index 53e1a7a51a..aeacb35442 100644 --- a/packages/core/src/tool-result-record-schema.ts +++ b/packages/core/src/tool-result-record-schema.ts @@ -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 { From a619793475636aa3d69245c2a4d662cddf2593fc Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 22 Aug 2026 02:31:39 +0800 Subject: [PATCH 7/8] fix: stop clients from sending the Host default back as an explicit choice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Desktop and the TUI both read `chatDefaults.permissionMode` for display and then passed that snapshot as the create input. The Host's own `prepared.permissionMode ?? chatDefaults` could therefore never reach its right side: a cached value became the authority, and a Session could start with full access from a setting another client had already lowered. The TUI was worse in two further ways — the snapshot never refreshed across `/new`, and a failed policy query was converted to `ask` and then sent explicitly, overriding the Host with a value nobody configured. Ordinary creation now omits the field. Desktop drops `newChatPermissionMode` entirely, since the create input was its only consumer. The TUI keeps the value as `prospectivePermissionMode`, used for display and skill prediction but never handed to the driver, and the launcher shows it instead of a hardcoded `ask`, so a Host configured for Bypass no longer displays as Auto. That prospective value is derived through `createGenesisExecutionBoundary` and `executionBoundaryDisplayMode`, the same mapping live Sessions already use, so a Session before and after creation cannot label one set of permissions two ways. Refs #3385 Generated-by: Claude Code --- .../app-shell-busy-race-settlement.test.ts | 2 +- .../app-shell-first-send-cleanup.test.ts | 49 ++++++++++++++++++- .../src/renderer/app-shell-chat-actions.ts | 13 +++-- apps/desktop/src/renderer/app-shell.tsx | 33 +++++-------- packages/cli/src/runtime-host-tui-command.ts | 2 +- packages/cli/src/runtime-host-tui-context.ts | 23 +++++++-- 6 files changed, 89 insertions(+), 33 deletions(-) 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/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.tsx b/apps/desktop/src/renderer/app-shell.tsx index b6bd3aa6ea..58a9ae54c3 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -87,6 +87,7 @@ import { stageCompanionQuote, } from './quote-companion-panel-state'; import { UNRESOLVED_NEW_TASK_DRAFT_KEY } from './new-task-reload-intent'; +import { useNewTaskChoice } from './use-new-task-choice'; import { sideChatTitleFromPrompt } from './side-chat-command'; import { parseDesktopSlashCommand } from './desktop-slash-command'; import { @@ -426,6 +427,8 @@ function AppShellContent({ const [scheduledTaskCreateRequestNonce, setScheduledTaskCreateRequestNonce] = useState(0); const [pendingCollaborationModeBySession, setPendingCollaborationModeBySession] = useState>({}); const [pendingOrchestrationModeBySession, setPendingOrchestrationModeBySession] = useState>({}); + const [newTaskPermissionChoice, setNewTaskPermissionChoice] = + useNewTaskChoice(currentNewTaskDraftKey); const [historyLoadPendingSessionId, setHistoryLoadPendingSessionId] = useState(); const [transcriptTurnIndex, setTranscriptTurnIndex] = useState<{ sessionId: string; @@ -563,30 +566,16 @@ function AppShellContent({ const terminalPanelCopy = desktopConversationCopy.terminalPanel; const workbarCopy = desktopConversationCopy.workbar; /** - * What a new task starts in, read straight from the Host that would run it. + * 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. * - * There is no draft-local copy: before a Session exists, "the mode this - * task will start in" and "the configured default" are the same fact, and - * a second copy of it could only be the stale one. Picking a mode here - * therefore writes the setting — which is also why the choice survives to - * the next new task instead of lasting one draft. + * 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 = - newTask.selectedHost?.chatDefaults.permissionMode ?? 'ask'; - const setNewTaskPermissionMode = useCallback( - async (mode: ChatDefaultPermissionMode) => { - const host = newTask.selectedHost; - // Write to the Host that would run the task, not to whichever Host is - // otherwise selected: with several connected they are not the same, and - // the mode shown here belongs to this one. - await window.maka.settings.update( - { chatDefaults: { permissionMode: mode } }, - host ? { profileId: host.profile.id, hostId: host.hostId } : undefined, - ); - await newTask.refresh(); - }, - [newTask], - ); + newTaskPermissionChoice ?? newTask.selectedHost?.chatDefaults.permissionMode ?? 'ask'; + const setNewTaskPermissionMode = setNewTaskPermissionChoice; useEffect(() => { if (!isAppUpdateInstallFailure(appUpdateStatus)) { notifiedInstallErrorRef.current = null; @@ -2141,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, 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 b6c0134e6d..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'; @@ -42,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: { @@ -80,13 +91,18 @@ export async function createRuntimeHostTuiContext( ? await resolveResumeTarget(connection, catalog, input.resumeSessionId) : resolveTarget(catalog); const modelChoices = projectRuntimeHostModelChoices(catalog); - const hostDefaultPermissionMode = await readHostChatDefaultPermissionMode(connection); + // 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: hostDefaultPermissionMode, executionLocation: connected.profile.kind === 'local' ? { kind: 'client_path' } : { kind: 'host' }, ...(workspace ? { workspace } : {}), @@ -103,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( @@ -110,7 +127,7 @@ export async function createRuntimeHostTuiContext( driver.getSessionId(), workspace ?? (connected.profile.kind === 'local' ? { kind: 'host_path', path: cwd } : undefined), - driver.getPermissionMode?.() ?? hostDefaultPermissionMode, + driver.getPermissionMode?.() ?? prospectivePermissionMode, ), agentGraphHistory: createRuntimeHostAgentGraphHistory(connection), recap: createRuntimeHostRecapGenerator(connection), From 83d008cf72921704e7ff263d86c951fecb014c9e Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 22 Aug 2026 02:31:46 +0800 Subject: [PATCH 8/8] refactor: delete the permission-mode declarations nothing consumes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more places declared a permission mode that no production code reads. `SubagentSessionRuntime.permissionCeiling` was documented as decode-only and had no reader, yet `isSubagentSessionRuntime` still validated it with strict `isPermissionMode` — so a child Session written before a mode was retired was rejected as malformed and became unopenable. The field leaves the type; the guard tolerates the key on stored records instead of validating it, which is what keeps those records readable without pretending the value means anything. `packages/core/src/workspace.ts` declared `defaults.permissionMode` on a `WorkspaceConfig` with no references anywhere in the repository and no entry in the package exports. The whole file goes. Refs #3385 Generated-by: Claude Code --- .../cli/src/__tests__/pi-tui-runner.test.ts | 1 - .../__tests__/subagent-session-parent.test.ts | 28 +++++++++++++++++++ packages/core/src/session.ts | 27 ++++++++++++++---- packages/core/src/workspace.ts | 20 ------------- .../__tests__/execution-composition.test.ts | 1 - .../fixtures/execution-host-suite.ts | 1 - .../src/__tests__/session-manager.test.ts | 4 --- .../sqlite-session-metadata-store.test.ts | 3 -- 8 files changed, 50 insertions(+), 35 deletions(-) delete mode 100644 packages/core/src/workspace.ts 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/core/src/__tests__/subagent-session-parent.test.ts b/packages/core/src/__tests__/subagent-session-parent.test.ts index 972325d913..2772848a61 100644 --- a/packages/core/src/__tests__/subagent-session-parent.test.ts +++ b/packages/core/src/__tests__/subagent-session-parent.test.ts @@ -185,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/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/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/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index 38d90174d9..0dd5d30f48 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -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, @@ -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/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,