You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by as...@apache.org on 2026/08/21 13:02:22 UTC
(maka) branch refactor/permission-single-authority updated (500572388 -> 942f7179e)
This is an automated email from the ASF dual-hosted git repository.
Astro-Han pushed a change to branch refactor/permission-single-authority
in repository https://gitbox.apache.org/repos/asf/maka.git
omit 500572388 fix(core): canonicalize retired permission modes in subagent tool results
omit 166a86ac9 fix(storage): decode stored Automations instead of trusting the record
omit 8b5ef1fc3 refactor: remove the `execute` permission mode
omit 92d0a455b fix(desktop): keep the composer's new-task permission choice
omit c89311c44 fix(cli): start a new Session in the Host's configured permission mode
omit 8c511f3e5 refactor(desktop): drop the unused session-creation permission fallback
add 7e16dd976 refactor(cli): move the mid-turn slash disposition onto the command spec (#3379)
add 3b9a2e226 fix(storage): stop a credential deletion from bumping a retired row (#3415)
add 8707b89ef fix(desktop): centre the request-header remove button on its field (#3346)
new 7ca8996ca refactor(desktop): drop the unused session-creation permission fallback
new 26c97c1be fix(cli): start a new Session in the Host's configured permission mode
new fa933eaab fix(desktop): keep the composer's new-task permission choice
new e0a7f648d refactor: remove the `execute` permission mode
new 5b0724a6b fix(storage): decode stored Automations instead of trusting the record
new 942f7179e fix(core): canonicalize retired permission modes in subagent tool results
This update added new revisions after undoing existing revisions.
That is to say, some revisions that were in the old version of the
branch are not in the new version. This situation occurs
when a user --force pushes a change and generates a repository
containing something like this:
* -- * -- B -- O -- O -- O (500572388)
\
N -- N -- N refs/heads/refactor/permission-single-authority (942f7179e)
You should already have received notification emails for all of the O
revisions, and so the following emails describe only the N revisions
from the common base, B.
Any revisions marked "omit" are not gone; other references still
refer to them. Any revisions marked "discard" are gone forever.
The 6 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
Summary of changes:
apps/desktop/e2e/fixtures.ts | 14 +++
.../e2e/request-header-row-contract.spec.ts | 59 ++++++++++++
apps/desktop/src/renderer/styles/settings/rows.css | 4 +-
packages/cli/src/pi-tui-pickers.ts | 19 ++++
packages/cli/src/pi-tui-runner.ts | 55 +++++++++---
.../runtime-host/src/__tests__/protocol.test.ts | 11 ++-
.../src/__tests__/runtime-policy-stores.test.ts | 100 +++++++++++++++++++++
.../runtime-policy/connection-catalog-document.ts | 9 +-
8 files changed, 257 insertions(+), 14 deletions(-)
create mode 100644 apps/desktop/e2e/request-header-row-contract.spec.ts
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org
(maka) 02/06: fix(cli): start a new Session in the Host's configured permission mode
Posted by as...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
Astro-Han pushed a commit to branch refactor/permission-single-authority
in repository https://gitbox.apache.org/repos/asf/maka.git
commit 26c97c1beb7194ca6fa3bb3f065d9928a7ff0972
Author: AstroHan <le...@outlook.com>
AuthorDate: Fri Aug 21 15:59:22 2026 +0800
fix(cli): start a new Session in the Host's configured permission mode
`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 +++---
packages/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 e0279c795..54921f2b1 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<SessionSummary>;
+ createSession(input: CreateSessionRequest): Promise<SessionSummary>;
readExecutionBoundary(sessionId: string): Promise<ExecutionBoundaryReadModel>;
sendMessage(sessionId: string, input: UserMessageInput): AsyncIterable<SessionEvent>;
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 f9cfebfed..d446c26de 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<RuntimeHostConnection, 'request'>,
+): Promise<ChatDefaultPermissionMode> {
+ 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 10de166c7..249b9891a 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<SessionSummary> {
+ async createSession(input: CreateSessionRequest): Promise<SessionSummary> {
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 e568ea2a0..10adca036 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<SessionSummary>;
+ createSession(input: CreateSessionRequest): Promise<SessionSummary>;
readMessages(): Promise<StoredMessage[]>;
resumeLatest(): AsyncIterable<SessionEvent>;
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<SessionSummary> {
+ async createSession(input: CreateSessionRequest): Promise<SessionSummary> {
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 aafeae689..b6c0134e6 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 ecb49cd7b..9015abccc 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<GoalProjection | null>;
getContextDiagnostics?(): Promise<ContextDiagnostics>;
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<CreateSessionInput, 'permissionMode'> & {
+ permissionMode?: PermissionMode;
+};
+
export type MakaTranscriptReplacementReason = 'terminal' | 'reconnect';
export type SessionResumeAvailability = { available: true } | { available: false; reason: string };
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org
(maka) 03/06: fix(desktop): keep the composer's new-task permission choice
Posted by as...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
Astro-Han pushed a commit to branch refactor/permission-single-authority
in repository https://gitbox.apache.org/repos/asf/maka.git
commit fa933eaab65c157f46732b81c7e2f4e2b959b72a
Author: AstroHan <le...@outlook.com>
AuthorDate: Fri Aug 21 16:02:27 2026 +0800
fix(desktop): keep the composer's new-task permission choice
Picking full access in the composer before starting a task lasted exactly
one draft: `useNewTaskChoice` held it in an in-memory Map keyed by the draft,
so the next new task was back on Auto. The setting that does persist —
`chatDefaults.permissionMode` — was only reachable from Settings, which is
where users were not looking.
Before a Session exists, "the mode this task will start in" and "the
configured default" are the same fact, so the draft-local copy could only
ever be the stale one. The picker now reads the Host's value and writes it,
which is what makes the choice survive to the next task.
It writes to the Host that would run the task rather than to whichever Host
is otherwise selected. Those differ once more than one Host is connected,
and the mode shown in the composer belongs to the first one.
The shell's second copy of the same setting goes with it. `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.
Renderer behavior has no unit-test surface in this app (desktop tests cover
`main`), so this is typecheck- and lint-verified only; the persistence is
observable through the existing Settings picker.
Refs #3385
Generated-by: Claude Code
---
apps/desktop/src/renderer/app-shell-overlays.tsx | 12 +++++--
.../renderer/app-shell-session-settings-actions.ts | 5 +--
apps/desktop/src/renderer/app-shell.tsx | 41 ++++++++++++++++------
apps/desktop/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 a92d5ef52..61ca22989 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 0fb3cdb03..b6a7e4d59 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<DesktopSessionSummary[]>;
- setNewTaskPermissionMode: (mode: ChatDefaultPermissionMode) => void;
+ /** Persists the chat default; awaited so a failure surfaces as one. */
+ setNewTaskPermissionMode: (mode: ChatDefaultPermissionMode) => void | Promise<void>;
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 4e854995c..d6c442c4c 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<Record<string, boolean>>({});
const [pendingOrchestrationModeBySession, setPendingOrchestrationModeBySession] = useState<Record<string, boolean>>({});
- const [newTaskPermissionChoice, setNewTaskPermissionChoice] =
- useNewTaskChoice<ChatDefaultPermissionMode>(currentNewTaskDraftKey);
const [historyLoadPendingSessionId, setHistoryLoadPendingSessionId] = useState<string>();
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
@@ -3647,7 +3664,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 d22672816..07237ccdc 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<ThemePalette>('default');
const [uiLocaleUpdateGate] = useState(createUiLocaleUpdateGate);
const [userLabel, setUserLabel] = useState<string>('');
- // 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<ChatDefaultPermissionMode>('ask');
// undefined = the user expressed no preference, so each model uses its own.
const [defaultThinkingLevel, setDefaultThinkingLevel] = useState<ThinkingLevel | undefined>(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,
};
}
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org
(maka) 01/06: refactor(desktop): drop the unused session-creation permission fallback
Posted by as...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
Astro-Han pushed a commit to branch refactor/permission-single-authority
in repository https://gitbox.apache.org/repos/asf/maka.git
commit 7ca8996caf0e12cc57f686ae668a4b4dd67f06ff
Author: AstroHan <le...@outlook.com>
AuthorDate: Fri Aug 21 15:59:13 2026 +0800
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
---
.../main/__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 85a0d456f..d95a1b7b2 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 98823941d..6bbd51966 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<ResolvedCreateSessionInput, 'permissionMode'> {
- 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<AppSettings> },
-): Promise<ResolvedCreateSessionInput> {
- 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)),
- };
-}
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org
(maka) 05/06: fix(storage): decode stored Automations instead of trusting the record
Posted by as...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
Astro-Han pushed a commit to branch refactor/permission-single-authority
in repository https://gitbox.apache.org/repos/asf/maka.git
commit 5b0724a6b7d7c6f1c964f73edf7223ab1e053bc3
Author: AstroHan <le...@outlook.com>
AuthorDate: Fri Aug 21 16:18:46 2026 +0800
fix(storage): decode stored Automations instead of trusting the record
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
---
packages/core/src/__tests__/scheduled-task.test.ts | 53 ++++++++++++++++++++++
packages/core/src/scheduled-task.ts | 30 +++++++++++-
packages/storage/src/scheduled-task-store.ts | 3 +-
3 files changed, 84 insertions(+), 2 deletions(-)
diff --git a/packages/core/src/__tests__/scheduled-task.test.ts b/packages/core/src/__tests__/scheduled-task.test.ts
index 249164a72..c3fcbf9e6 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,
@@ -200,3 +201,55 @@ 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',
+ backend: 'fake',
+ 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 8409b712a..3b73a9b75 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';
import type { PersistedBackendKind } from './session.js';
@@ -644,3 +648,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 8d78f32fa..39f31d44a 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(`
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org
(maka) 06/06: fix(core): canonicalize retired permission modes in subagent tool results
Posted by as...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
Astro-Han pushed a commit to branch refactor/permission-single-authority
in repository https://gitbox.apache.org/repos/asf/maka.git
commit 942f7179e40e5fd2c9003d9b61ee4f17ab800730
Author: AstroHan <le...@outlook.com>
AuthorDate: Fri Aug 21 18:35:03 2026 +0800
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
---
.../core/src/__tests__/tool-result-preview.test.ts | 27 +++++++++++++++
.../__tests__/tool-result-record-schema.test.ts | 38 ++++++++++++++++++++++
packages/core/src/tool-result-preview.ts | 4 +--
packages/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 02d9cd5d3..c9afa2dab 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 01232eff8..52e1a8403 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 80b5009bb..119426efc 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 53e1a7a51..aeacb3544 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 {
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org
(maka) 04/06: refactor: remove the `execute` permission mode
Posted by as...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
Astro-Han pushed a commit to branch refactor/permission-single-authority
in repository https://gitbox.apache.org/repos/asf/maka.git
commit e0a7f648d5c137282dfa95221eba7edc66a8377a
Author: AstroHan <le...@outlook.com>
AuthorDate: Fri Aug 21 16:13:24 2026 +0800
refactor: remove the `execute` permission mode
`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 +-
.../main/__tests__/pending-session-view.test.ts | 2 +-
.../runtime-host-client-operations.test.ts | 10 +++---
.../main/__tests__/runtime-host-client-uds.test.ts | 4 +--
apps/desktop/src/renderer/locales/shell-copy.ts | 10 ------
packages/cli/src/activation-command.ts | 20 +++++++++---
packages/cli/src/pi-tui-pickers.ts | 5 ++-
.../__tests__/permission-profile-compiler.test.ts | 10 ++----
.../src/__tests__/subagent-session-parent.test.ts | 1 -
packages/core/src/agent-run.ts | 12 ++++++--
packages/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 +--
packages/core/src/tool-result-record-schema.ts | 4 +--
.../runtime-host/src/__tests__/protocol.test.ts | 11 ++++++-
.../src/__tests__/root-turn-coordinator.test.ts | 4 +--
.../__tests__/session-catalog-coordinator.test.ts | 2 +-
.../session-retirement-coordinator.test.ts | 6 ++--
packages/runtime-host/src/protocol/index.ts | 4 ++-
.../runtime/src/__tests__/builtin-tools.test.ts | 22 ++++++-------
.../src/__tests__/deferred-tools-backend.test.ts | 2 +-
.../src/__tests__/filesystem-worker-client.test.ts | 8 ++---
.../src/__tests__/linux-sandbox-smoke.test.ts | 8 ++---
.../__tests__/runtime-continuation-crash.test.ts | 4 +--
.../src/__tests__/sandbox-diagnostics.test.ts | 2 +-
.../runtime/src/__tests__/session-manager.test.ts | 30 +++++++++---------
.../runtime/src/__tests__/subagent-tools.test.ts | 2 +-
packages/runtime/src/agent-catalog.ts | 4 +--
.../src/__tests__/artifact-writer-lock.test.ts | 2 +-
.../src/__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, 156 insertions(+), 127 deletions(-)
diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts
index 48af56b4e..bbc577455 100644
--- a/apps/desktop/e2e/fixtures.ts
+++ b/apps/desktop/e2e/fixtures.ts
@@ -145,7 +145,7 @@ async function seedParentRemovalSessions(userDataDir: string): Promise<void> {
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 30b1f601a..03c31673b 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 9620780a9..f86a50d78 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 b4233e4e1..6c891945c 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 342143bd6..4961c050c 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 f290a7d7c..46614e0ea 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<PermissionMode, 'ask'>;
+ /**
+ * `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',
diff --git a/packages/cli/src/pi-tui-pickers.ts b/packages/cli/src/pi-tui-pickers.ts
index 139d3e8c9..b2ba2c696 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 bed89be78..ab558d62e 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 83c34578d..972325d91 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 1d4459df4..b512b4214 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 a2eba2cfa..db23f0d66 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 8c5e3d7c9..95925bc7a 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<Record<string, PermissionMode>> = {
+ 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 80944d359..bb6b68712 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 db4a2ba9e..8dac3f783 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 119426efc..80b5009bb 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 0fb5321f1..53e1a7a51 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<string, unknown>): 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 ebb0af7d1..a736efc02 100644
--- a/packages/runtime-host/src/__tests__/protocol.test.ts
+++ b/packages/runtime-host/src/__tests__/protocol.test.ts
@@ -99,7 +99,16 @@ describe('Runtime Host bootstrap protocol', () => {
});
test('publishes a new compatibility epoch for sandbox failure results', () => {
- assert.equal(RUNTIME_HOST_COMPATIBILITY_EPOCH, 33);
+ // 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 the retired execute permission mode', () => {
+ // Epoch 33 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 > 33);
});
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 bcd4e0d9d..4e587014e 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 f4b63dc29..b73349b73 100644
--- a/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts
+++ b/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts
@@ -677,7 +677,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 48bdb4a41..92d71b205 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 ddf51faa1..1f25da5d2 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 = 33 as const;
+export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 34 as const;
+// 34: `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.
// 33: Live tool results may carry the bounded sandbox failure reason. Older
// Clients reject that closed-frame addition, so mixed peers must not connect.
// 32: `request_authorization_code` leaves the OAuth presentation wire. An older
diff --git a/packages/runtime/src/__tests__/builtin-tools.test.ts b/packages/runtime/src/__tests__/builtin-tools.test.ts
index 84e1bb232..04cc8e375 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 3d5a454e7..ef6f12ee3 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 ff12ce1c4..bdc9af934 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 9217d1f16..643dbfdc1 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 0a6975708..47290914b 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<void> {
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 8db778d7d..902096465 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 be00dd12f..38d90174d 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 768844f2c..402aeadf9 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 3322957ef..39ac5ef85 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 039a5e52f..52700a1b5 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 04f203742..db61829a2 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 4dc5e36d5..d6a66c145 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 c16434a0a..f4a04b22d 100644
--- a/packages/ui/src/conversation-copy.ts
+++ b/packages/ui/src/conversation-copy.ts
@@ -413,7 +413,6 @@ const CONVERSATION_COPY = {
mode: {
explore: { label: '只读', hint: '只读搜索,不写文件、不上网;需要时先问你。' },
ask: { label: '自动', hint: '保护层内自动执行,越权先问你。' },
- execute: { label: '自动执行', hint: '常见工具直接执行;危险操作仍会确认。' },
bypass: { label: '完全权限', hint: '直接访问文件和网络,仅限可信任务。' },
},
modeAriaLabel: (label) => `权限模式:${label}`,
@@ -552,7 +551,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 acf8a71dd..0769e1dc1 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,
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org