Add permission profile runtime selection

This commit is contained in:
murashit 2026-07-02 13:43:19 +09:00
parent 60dba267ba
commit d2cb61e097
37 changed files with 570 additions and 38 deletions

View file

@ -46,7 +46,7 @@ Each panel can keep a separate conversation, so related work can stay open side
The composer understands Obsidian links. It suggests vault files and recent notes, keeps wikilinks readable, includes linked files with your prompt, and opens file links from Codex replies back in Obsidian. Use `@active-note` or `@selection` to include the active note or current Markdown selection without pasting it manually. Paste or drop files to save them in the attachment folder and reference them from the same prompt.
Completions cover slash commands, `$skill-name` skills, recent threads, models, and reasoning levels. Use `/help` for the current command list.
Completions cover slash commands, `$skill-name` skills, recent threads, models, reasoning levels, and permission profiles. Use `/help` for the current command list.
While a turn is running, the panel streams the conversation, reasoning, tool activity, file changes, plans, and agent activity. You can answer Codex questions, approve commands, respond to requests, steer or interrupt the turn, and manage active goals above the message stream.

View file

@ -13,6 +13,7 @@ import type { HooksListResponse } from "../../generated/app-server/v2/HooksListR
import type { ListMcpServerStatusResponse } from "../../generated/app-server/v2/ListMcpServerStatusResponse";
import type { ModelListResponse } from "../../generated/app-server/v2/ModelListResponse";
import type { ModelProviderCapabilitiesReadResponse } from "../../generated/app-server/v2/ModelProviderCapabilitiesReadResponse";
import type { PermissionProfileListResponse } from "../../generated/app-server/v2/PermissionProfileListResponse";
import type { PluginInstalledResponse } from "../../generated/app-server/v2/PluginInstalledResponse";
import type { PluginReadResponse } from "../../generated/app-server/v2/PluginReadResponse";
import type { SkillsListResponse } from "../../generated/app-server/v2/SkillsListResponse";
@ -82,6 +83,7 @@ export interface ClientResponseByMethod {
"plugin/installed": PluginInstalledResponse;
"plugin/read": PluginReadResponse;
"model/list": ModelListResponse;
"permissionProfile/list": PermissionProfileListResponse;
"account/rateLimits/read": GetAccountRateLimitsResponse;
"mcpServerStatus/list": ListMcpServerStatusResponse;
"collaborationMode/list": CollaborationModeListResponse;

View file

@ -36,15 +36,15 @@ export function runtimeConfigSnapshotFromAppServerConfig(response: ConfigReadRes
}
function startupPermissionsFromConfig(config: Record<string, unknown>): RuntimePermissionState {
const activePermissionProfile = permissionProfileFromConfig(config);
return {
approvalPolicy: approvalPolicyOrNull(config["approval_policy"]),
sandboxPolicy: sandboxPolicyFromConfig(config),
activePermissionProfile: permissionProfileFromConfig(config),
sandboxPolicy: activePermissionProfile ? null : sandboxPolicyFromConfig(config),
activePermissionProfile,
};
}
function permissionProfileFromConfig(config: Record<string, unknown>): RuntimePermissionState["activePermissionProfile"] {
if (typeof config["sandbox_mode"] === "string") return null;
const profile = nonEmptyStringOrNull(config["default_permissions"]);
return profile ? { id: profile, extends: null } : null;
}

View file

@ -19,7 +19,7 @@ import {
archivedThreadsQueryKey,
cloneAppServerQueryContext,
} from "./keys";
import { readRateLimitMetadataProbe, readSkillMetadataProbe } from "./metadata-probes";
import { readPermissionProfileMetadataProbe, readRateLimitMetadataProbe, readSkillMetadataProbe } from "./metadata-probes";
import type { ObservedResult, ObservedResultListener } from "./observed-result";
import { cloneModelMetadata, cloneSharedServerMetadata, cloneThreads } from "./snapshots";
@ -194,6 +194,8 @@ export class AppServerQueryCache {
...metadata,
availableModels: probes.models.status === "ok" ? metadata.availableModels : (this.modelsSnapshot(context) ?? []),
availableSkills: probes.skills.status === "ok" ? metadata.availableSkills : (previous?.availableSkills ?? []),
availablePermissionProfiles:
probes.permissionProfiles.status === "ok" ? metadata.availablePermissionProfiles : (previous?.availablePermissionProfiles ?? []),
rateLimit: probes.rateLimits.status === "ok" ? metadata.rateLimit : (previous?.rateLimit ?? null),
});
this.client.setQueryData(appServerMetadataQueryKey(context), cloneSharedServerMetadata(next));
@ -273,12 +275,13 @@ export class AppServerQueryCache {
queryFn: async (): Promise<SharedServerMetadata> => {
return this.runWithClient(refreshContext, async (client) => {
const runtimeConfig = runtimeConfigSnapshotFromAppServerConfig(await readEffectiveConfig(client, refreshContext.vaultPath));
const [models, skills, rateLimit] = await Promise.all([
const [models, skills, permissionProfiles, rateLimit] = await Promise.all([
this.readModelMetadataProbe(refreshContext, client),
readSkillMetadataProbe(client, refreshContext.vaultPath, options.forceSkills ?? false),
readPermissionProfileMetadataProbe(client, refreshContext.vaultPath),
readRateLimitMetadataProbe(client),
]);
const diagnostics = [models.probe, skills.probe, rateLimit.probe].reduce(
const diagnostics = [models.probe, skills.probe, permissionProfiles.probe, rateLimit.probe].reduce(
(current, probe) => diagnosticsWithProbe(current, probe),
this.appServerMetadataSnapshot(refreshContext)?.serverDiagnostics ?? createServerDiagnostics(),
);
@ -286,6 +289,7 @@ export class AppServerQueryCache {
runtimeConfig,
availableModels: models.value,
availableSkills: skills.value,
availablePermissionProfiles: permissionProfiles.value,
rateLimit: rateLimit.value,
serverDiagnostics: diagnostics,
};

View file

@ -1,8 +1,9 @@
import type { SkillMetadata } from "../../domain/catalog/metadata";
import type { RateLimitSnapshot } from "../../domain/runtime/metrics";
import type { RuntimePermissionProfileSummary } from "../../domain/runtime/permissions";
import { type Diagnostics, diagnosticProbeError, diagnosticProbeOk } from "../../domain/server/diagnostics";
import { accountRateLimitsSummaryFromResponse, rateLimitSnapshotFromAccountRateLimitsResponse } from "../protocol/runtime-metrics";
import { listSkillCatalog } from "../services/catalog";
import { listPermissionProfiles, listSkillCatalog } from "../services/catalog";
import type { AppServerRequestClient } from "../services/request-client";
import { readAccountRateLimits } from "../services/runtime-metadata";
@ -12,6 +13,7 @@ interface MetadataProbeResult<T, K extends keyof Diagnostics["probes"]> {
}
export type SkillMetadataProbeResult = MetadataProbeResult<SkillMetadata[], "skills">;
export type PermissionProfileMetadataProbeResult = MetadataProbeResult<RuntimePermissionProfileSummary[], "permissionProfiles">;
export type RateLimitMetadataProbeResult = MetadataProbeResult<RateLimitSnapshot | null, "rateLimits">;
export async function readSkillMetadataProbe(
@ -30,6 +32,24 @@ export async function readSkillMetadataProbe(
}
}
export async function readPermissionProfileMetadataProbe(
client: AppServerRequestClient | null,
vaultPath: string,
): Promise<PermissionProfileMetadataProbeResult> {
if (!client) {
return {
value: [],
probe: diagnosticProbeError("permissionProfiles", new Error("Codex app-server is not connected."), Date.now()),
};
}
try {
const profiles = await listPermissionProfiles(client, vaultPath);
return { value: profiles, probe: diagnosticProbeOk("permissionProfiles", `${String(profiles.length)} profiles`, Date.now()) };
} catch (error) {
return { value: [], probe: diagnosticProbeError("permissionProfiles", error, Date.now()) };
}
}
export async function readRateLimitMetadataProbe(client: AppServerRequestClient | null): Promise<RateLimitMetadataProbeResult> {
if (!client) {
return {

View file

@ -26,6 +26,7 @@ export function cloneSharedServerMetadata(metadata: SharedServerMetadata): Share
rateLimit: metadata.rateLimit ? cloneRateLimitSnapshot(metadata.rateLimit) : null,
availableModels: cloneModelMetadata(metadata.availableModels),
availableSkills: metadata.availableSkills.map((skill) => ({ ...skill })),
availablePermissionProfiles: metadata.availablePermissionProfiles.map((profile) => ({ ...profile })),
serverDiagnostics: {
probes: { ...metadata.serverDiagnostics.probes },
mcpServers: metadata.serverDiagnostics.mcpServers.map((server) => ({ ...server })),

View file

@ -1,4 +1,6 @@
import type { HookItem, ModelMetadata, SkillMetadata } from "../../domain/catalog/metadata";
import type { RuntimePermissionProfileSummary } from "../../domain/runtime/permissions";
import type { ClientResponseByMethod } from "../connection/client";
import type { ClientRequestParams } from "../connection/rpc-messages";
import {
type AppServerHookOperation,
@ -27,6 +29,29 @@ export async function listModelMetadata(client: ModelMetadataClient, options: {
return modelMetadataFromCatalogModels(response.data);
}
export async function listPermissionProfiles(client: AppServerRequestClient, cwd: string): Promise<RuntimePermissionProfileSummary[]> {
const profiles: RuntimePermissionProfileSummary[] = [];
const seenCursors = new Set<string>();
let cursor: string | null = null;
for (;;) {
const response: ClientResponseByMethod["permissionProfile/list"] = await client.request("permissionProfile/list", {
cwd,
cursor,
limit: 100,
});
profiles.push(...response.data.map((profile) => ({ ...profile })));
cursor = response.nextCursor ?? null;
if (!cursor) break;
if (seenCursors.has(cursor)) {
throw new Error("Codex app-server returned a repeated permission profile list cursor.");
}
seenCursors.add(cursor);
}
return profiles;
}
export async function listSkillCatalog(
client: AppServerRequestClient,
cwd: string,

View file

@ -48,6 +48,7 @@ interface ThreadActivationResponse extends Partial<RuntimePermissionState> {
export interface AppServerStartThreadOptions {
cwd: string;
serviceTier?: RuntimeServiceTierRequest;
permissions?: RuntimeSettingsPatch["permissions"];
}
export interface AppServerStartEphemeralThreadOptions {
@ -66,11 +67,12 @@ export function startThread(
client: AppServerRequestClient,
options: AppServerStartThreadOptions,
): Promise<ClientResponseByMethod["thread/start"]> {
const { cwd, serviceTier } = options;
const { cwd, serviceTier, permissions } = options;
return client.request("thread/start", {
cwd,
serviceName: "codex-panel",
...(serviceTier !== undefined ? { serviceTier } : {}),
...(permissions !== undefined ? { permissions } : {}),
});
}

View file

@ -44,6 +44,12 @@ export interface RuntimePermissionKnownState {
readonly permissionProfileKnown: boolean;
}
export interface RuntimePermissionProfileSummary {
readonly id: string;
readonly description: string | null;
readonly allowed: boolean;
}
export function initialRuntimePermissionState(): RuntimePermissionState {
return {
approvalPolicy: null,

View file

@ -5,6 +5,7 @@ import { cloneToolInventorySnapshot, type ToolInventorySnapshot } from "./tool-i
const DIAGNOSTIC_PROBE_DEFINITIONS = {
models: { label: "Models" },
skills: { label: "Skills" },
permissionProfiles: { label: "Permission profiles" },
apps: { label: "Apps" },
plugins: { label: "Plugins" },
rateLimits: { label: "Rate limits" },

View file

@ -1,12 +1,14 @@
import type { ModelMetadata, SkillMetadata } from "../catalog/metadata";
import type { RuntimeConfigSnapshot } from "../runtime/config";
import type { RateLimitSnapshot } from "../runtime/metrics";
import type { RuntimePermissionProfileSummary } from "../runtime/permissions";
import type { Diagnostics } from "./diagnostics";
export interface SharedServerMetadata {
runtimeConfig: RuntimeConfigSnapshot | null;
availableModels: readonly ModelMetadata[];
availableSkills: readonly SkillMetadata[];
availablePermissionProfiles: readonly RuntimePermissionProfileSummary[];
rateLimit: RateLimitSnapshot | null;
serverDiagnostics: Diagnostics;
}

View file

@ -61,6 +61,7 @@ function applyAppServerMetadata(host: ChatServerMetadataActionsHost, metadata: S
runtimeConfig: metadata.runtimeConfig,
availableModels: metadata.availableModels,
availableSkills: metadata.availableSkills,
availablePermissionProfiles: metadata.availablePermissionProfiles,
rateLimit: metadata.rateLimit,
serverDiagnostics: metadata.serverDiagnostics,
});

View file

@ -8,7 +8,7 @@ import type { ThreadCatalogEvent } from "../../../threads/catalog/thread-catalog
import { resumedThreadAction } from "../../application/state/actions";
import type { ChatState } from "../../application/state/root-reducer";
import type { RuntimeSnapshot } from "../../domain/runtime/snapshot";
import { serviceTierRequestForThreadStart } from "../../domain/runtime/thread-settings-patch";
import { permissionProfileRequestForThreadStart, serviceTierRequestForThreadStart } from "../../domain/runtime/thread-settings-patch";
import { type ChatServerActionsHost, captureChatServerClientScope } from "./host";
interface StartedThreadSummary {
@ -47,11 +47,11 @@ async function startThread(
const scope = captureChatServerClientScope(host);
if (!scope.client) return null;
const requestState = host.stateStore.getState();
const serviceTier = serviceTierRequestForThreadStart(
host.runtimeSnapshotForState(requestState),
runtimeConfigOrDefault(requestState.connection.runtimeConfig),
);
const response = await startAppServerThread(scope.client, { cwd: host.vaultPath, serviceTier });
const runtimeSnapshot = host.runtimeSnapshotForState(requestState);
const runtimeConfig = runtimeConfigOrDefault(requestState.connection.runtimeConfig);
const serviceTier = serviceTierRequestForThreadStart(runtimeSnapshot, runtimeConfig);
const permissions = permissionProfileRequestForThreadStart(runtimeSnapshot, runtimeConfig);
const response = await startAppServerThread(scope.client, { cwd: host.vaultPath, serviceTier, permissions });
if (scope.isStale()) return null;
const state = host.stateStore.getState();
const fallbackPreview = preview?.trim();

View file

@ -131,10 +131,10 @@ export const SLASH_COMMANDS = [
},
{
command: "/permissions",
usage: "/permissions",
argsKind: "none",
surface: "diagnostic",
detail: "Show current permissions and approval settings.",
usage: "/permissions [profile|default]",
argsKind: "showOrSet",
surface: "threadSetting",
detail: "Show or set the permission profile for subsequent turns.",
},
{
command: "/doctor",

View file

@ -1,6 +1,7 @@
import { prepareFuzzySearch, type SearchResult, sortSearchResults } from "obsidian";
import type { ModelMetadata, SkillMetadata } from "../../../../domain/catalog/metadata";
import { findModelMetadataByIdOrName, sortedModelMetadata, supportedEffortsForModelMetadata } from "../../../../domain/catalog/metadata";
import type { RuntimePermissionProfileSummary } from "../../../../domain/runtime/permissions";
import { shortThreadId } from "../../../../domain/threads/id";
import type { Thread } from "../../../../domain/threads/model";
import { threadDisplayTitle } from "../../../../domain/threads/title";
@ -29,6 +30,7 @@ export interface ComposerSuggestion {
export interface ComposerSuggestionOptions {
activeThreadId?: string | null;
contextReferences?: ComposerContextReferences;
permissionProfiles?: readonly RuntimePermissionProfileSummary[];
}
export interface NoteCandidate {
@ -109,6 +111,7 @@ export function activeComposerSuggestions(
activeThreadCommandSuggestions(beforeCursor, threads, options.activeThreadId ?? null) ??
modelOverrideSuggestions(beforeCursor, models) ??
reasoningEffortOverrideSuggestions(beforeCursor, models, currentModel) ??
permissionProfileOverrideSuggestions(beforeCursor, options.permissionProfiles ?? []) ??
activeSlashCommandSuggestions(beforeCursor) ??
activeSkillSuggestions(beforeCursor, skills) ??
[]
@ -488,15 +491,65 @@ function reasoningEffortOverrideSuggestions(
return suggestions.filter((item) => item.display.toLowerCase().startsWith(query)).slice(0, 8);
}
function activeCommandArgumentCompletionQuery(beforeCursor: string, pattern: RegExp): { query: string; start: number } | null {
function permissionProfileOverrideSuggestions(
beforeCursor: string,
profiles: readonly RuntimePermissionProfileSummary[],
): ComposerSuggestion[] | null {
const completion = activeCommandArgumentCompletionQuery(beforeCursor, /^\/permissions\s+([^\n]{0,120})$/);
if (!completion) return null;
const { query, rawQuery, start } = completion;
const allowedProfiles = profiles.filter((profile) => profile.allowed);
if (query === "default" || allowedProfiles.some((profile) => profile.id === rawQuery)) return null;
const suggestions = [
{
display: "default",
detail: "Reset permission profile",
replacement: "default",
start,
appendSpaceOnInsert: true,
},
...allowedProfiles.map((profile) => ({
display: profile.id,
detail: profile.description ?? "Permission profile",
replacement: profile.id,
start,
appendSpaceOnInsert: true,
})),
];
return permissionProfileSuggestionsForQuery(suggestions, query).slice(0, 8);
}
function permissionProfileSuggestionsForQuery(suggestions: ComposerSuggestion[], query: string): ComposerSuggestion[] {
if (query.length === 0) return suggestions;
return suggestions
.map((suggestion) => ({ suggestion, score: permissionProfileSuggestionScore(suggestion, query) }))
.filter((item): item is { suggestion: ComposerSuggestion; score: number } => item.score !== null)
.sort((left, right) => left.score - right.score)
.map((item) => item.suggestion);
}
function permissionProfileSuggestionScore(suggestion: ComposerSuggestion, query: string): number | null {
if (suggestion.display.toLowerCase().startsWith(query)) return 0;
if (suggestion.display === "default") return null;
return suggestion.detail.toLowerCase().includes(query) ? 1 : null;
}
function activeCommandArgumentCompletionQuery(
beforeCursor: string,
pattern: RegExp,
): { query: string; rawQuery: string; start: number } | null {
const match = pattern.exec(beforeCursor);
if (!match) return null;
const rawQuery = match[1];
if (rawQuery === undefined) return null;
const query = rawQuery.trim().toLowerCase();
const trimmedQuery = rawQuery.trim();
const query = trimmedQuery.toLowerCase();
if (query.length > 0 && /\s$/.test(rawQuery)) return null;
return { query, start: beforeCursor.length - rawQuery.length };
return { query, rawQuery: trimmedQuery, start: beforeCursor.length - rawQuery.length };
}
function activeSkillSuggestions(beforeCursor: string, skills: readonly SkillMetadata[]): ComposerSuggestion[] | null {

View file

@ -7,7 +7,7 @@ import type { Thread } from "../../../../domain/threads/model";
import type { ReferencedThreadMetadata } from "../../../../domain/threads/reference";
import { threadDisplayTitle } from "../../../../domain/threads/title";
import type { MessageStreamAuditFact, MessageStreamNoticeSection } from "../../domain/message-stream/items";
import { modelOverrideMessage, reasoningEffortOverrideMessage } from "../../domain/runtime/labels";
import { modelOverrideMessage, permissionProfileOverrideMessage, reasoningEffortOverrideMessage } from "../../domain/runtime/labels";
import {
type SlashCommandName,
type SlashCommandSubcommandDefinition,
@ -42,6 +42,8 @@ export interface SlashCommandExecutionPorts {
toggleAutoReview: ChatRuntimeSettingsActions["toggleAutoReview"];
requestModel: ChatRuntimeSettingsActions["requestModel"];
resetModelToConfig: ChatRuntimeSettingsActions["resetModelToConfig"];
requestPermissionProfile: ChatRuntimeSettingsActions["requestPermissionProfile"];
resetPermissionProfileToConfig: ChatRuntimeSettingsActions["resetPermissionProfileToConfig"];
requestReasoningEffort: ChatRuntimeSettingsActions["requestReasoningEffort"];
resetReasoningEffortToConfig: ChatRuntimeSettingsActions["resetReasoningEffortToConfig"];
};
@ -184,9 +186,17 @@ export async function executeSlashCommand(
case "status":
context.addStructuredSystemMessage("Thread status", detailsFromLines(context.statusSummaryLines()));
return;
case "permissions":
case "permissions": {
const requested = parsePermissionProfileOverride(args);
if (requested !== undefined) {
const applied = await applyPermissionProfileOverride(context, requested);
if (applied === false) return;
context.addSystemMessage(permissionProfileOverrideMessage(requested));
return;
}
context.addStructuredSystemMessage("Permissions & Approvals", context.permissionDetails());
return;
}
case "doctor":
context.addStructuredSystemMessage("Connection diagnostics", context.connectionDiagnosticDetails());
return;
@ -238,6 +248,15 @@ function applyModelOverride(
return requested === null ? context.runtimeSettings.resetModelToConfig() : context.runtimeSettings.requestModel(requested);
}
function applyPermissionProfileOverride(
context: SlashCommandExecutionContext,
requested: string | null,
): boolean | undefined | Promise<boolean | undefined> {
return requested === null
? context.runtimeSettings.resetPermissionProfileToConfig()
: context.runtimeSettings.requestPermissionProfile(requested);
}
function applyReasoningEffortOverride(
context: SlashCommandExecutionContext,
requested: ReasoningEffort | null,
@ -254,6 +273,13 @@ function parseModelOverride(args: string): string | null | undefined {
return model;
}
function parsePermissionProfileOverride(args: string): string | null | undefined {
const profile = args.trim();
if (!profile) return undefined;
if (profile.toLowerCase() === "default") return null;
return profile;
}
function parseReasoningEffortOverride(args: string): ReasoningEffort | null | undefined {
const effort = args.trim();
if (!effort) return undefined;

View file

@ -38,6 +38,8 @@ export interface ChatRuntimeSettingsActions {
resetReasoningEffortToConfig: () => Promise<boolean>;
requestReasoningEffortFromUi: (effort: ReasoningEffort) => Promise<void>;
resetReasoningEffortToConfigFromUi: () => Promise<void>;
requestPermissionProfile: (permissionProfile: string) => Promise<boolean>;
resetPermissionProfileToConfig: () => Promise<boolean>;
enableFastMode: () => Promise<void>;
disableFastMode: () => Promise<void>;
toggleFastMode: () => Promise<void>;
@ -59,6 +61,8 @@ export function createChatRuntimeSettingsActions(host: RuntimeSettingsActionsHos
resetReasoningEffortToConfig: () => resetReasoningEffortToConfig(host),
requestReasoningEffortFromUi: (effort) => requestReasoningEffortFromUi(host, effort),
resetReasoningEffortToConfigFromUi: () => resetReasoningEffortToConfigFromUi(host),
requestPermissionProfile: (permissionProfile) => requestPermissionProfile(host, permissionProfile),
resetPermissionProfileToConfig: () => resetPermissionProfileToConfig(host),
enableFastMode: () => setFastMode(host, "enabled"),
disableFastMode: () => setFastMode(host, "disabled"),
toggleFastMode: () => toggleFastMode(host),
@ -135,6 +139,16 @@ async function resetReasoningEffortToConfigFromUi(host: RuntimeSettingsActionsHo
await runRuntimeUiCommand(host, () => resetReasoningEffortToConfig(host), reasoningEffortOverrideMessage(null));
}
async function requestPermissionProfile(host: RuntimeSettingsActionsHost, permissionProfile: string): Promise<boolean> {
dispatch(host, { type: "runtime/permission-profile-requested", permissionProfile });
return applyPendingThreadSettings(host);
}
async function resetPermissionProfileToConfig(host: RuntimeSettingsActionsHost): Promise<boolean> {
dispatch(host, { type: "runtime/permission-profile-reset-to-config" });
return applyPendingThreadSettings(host);
}
async function toggleFastMode(host: RuntimeSettingsActionsHost): Promise<void> {
const { snapshot, config } = runtimeProjection(host);
await setFastMode(host, resolveRuntimeControls(snapshot, config).fastMode.active ? "disabled" : "enabled");

View file

@ -2,6 +2,7 @@ import type { ModelMetadata, ReasoningEffort, SkillMetadata } from "../../../../
import type { PendingRequestId } from "../../../../domain/pending-requests/model";
import type { RuntimeConfigSnapshot } from "../../../../domain/runtime/config";
import type { RateLimitSnapshot, ThreadTokenUsage } from "../../../../domain/runtime/metrics";
import type { RuntimePermissionProfileSummary } from "../../../../domain/runtime/permissions";
import type { ApprovalsReviewer } from "../../../../domain/runtime/policy";
import type { RuntimeSettingsPatch } from "../../../../domain/runtime/thread-settings";
import type { Diagnostics } from "../../../../domain/server/diagnostics";
@ -21,8 +22,10 @@ import {
requestApprovalsReviewerRuntimeState,
requestFastModeRuntimeState,
requestModelRuntimeState,
requestPermissionProfileRuntimeState,
requestReasoningEffortRuntimeState,
resetModelToConfigRuntimeState,
resetPermissionProfileToConfigRuntimeState,
resetReasoningEffortToConfigRuntimeState,
setSelectedCollaborationModeRuntimeState,
} from "../../domain/runtime/state";
@ -93,6 +96,7 @@ interface ChatConnectionState {
readonly serverDiagnostics: Diagnostics;
readonly availableModels: readonly ModelMetadata[];
readonly availableSkills: readonly SkillMetadata[];
readonly availablePermissionProfiles: readonly RuntimePermissionProfileSummary[];
}
interface ChatThreadListState {
@ -136,6 +140,7 @@ type ConnectionAction =
runtimeConfig?: RuntimeConfigSnapshot | null;
availableModels?: readonly ModelMetadata[];
availableSkills?: readonly SkillMetadata[];
availablePermissionProfiles?: readonly RuntimePermissionProfileSummary[];
rateLimit?: RateLimitSnapshot | null;
serverDiagnostics?: Diagnostics;
};
@ -151,6 +156,8 @@ type RuntimeAction =
| { type: "runtime/model-reset-to-config" }
| { type: "runtime/reasoning-effort-requested"; effort: ReasoningEffort }
| { type: "runtime/reasoning-effort-reset-to-config" }
| { type: "runtime/permission-profile-requested"; permissionProfile: string }
| { type: "runtime/permission-profile-reset-to-config" }
| { type: "runtime/fast-mode-requested"; fastMode: RequestedFastMode }
| { type: "runtime/fast-mode-request-cleared" }
| { type: "runtime/approvals-reviewer-requested"; approvalsReviewer: ApprovalsReviewer }
@ -495,6 +502,7 @@ function clearConnectionScopedState(state: ChatState): ChatState {
rateLimit: null,
availableModels: [],
availableSkills: [],
availablePermissionProfiles: [],
},
threadList: initialThreadListState(),
});
@ -525,6 +533,7 @@ function reduceConnectionSlice(state: ChatConnectionState, action: ChatSliceActi
...definedPatch("runtimeConfig", action.runtimeConfig),
...definedPatch("availableModels", action.availableModels),
...definedPatch("availableSkills", action.availableSkills),
...definedPatch("availablePermissionProfiles", action.availablePermissionProfiles),
...definedPatch("rateLimit", action.rateLimit),
...definedPatch("serverDiagnostics", action.serverDiagnostics),
});
@ -562,6 +571,10 @@ function reduceRuntimeSlice(state: ChatRuntimeState, action: ChatSliceAction): C
return patchObject(state, requestReasoningEffortRuntimeState(state, action.effort));
case "runtime/reasoning-effort-reset-to-config":
return patchObject(state, resetReasoningEffortToConfigRuntimeState(state));
case "runtime/permission-profile-requested":
return patchObject(state, requestPermissionProfileRuntimeState(state, action.permissionProfile));
case "runtime/permission-profile-reset-to-config":
return patchObject(state, resetPermissionProfileToConfigRuntimeState(state));
case "runtime/fast-mode-requested":
return patchObject(state, requestFastModeRuntimeState(state, action.fastMode));
case "runtime/fast-mode-request-cleared":
@ -619,6 +632,7 @@ function initialConnectionState(): ChatConnectionState {
rateLimit: null,
availableModels: [],
availableSkills: [],
availablePermissionProfiles: [],
};
}

View file

@ -35,6 +35,7 @@ function cloneChatState(state: ChatState): ChatState {
...state.connection,
availableModels: [...state.connection.availableModels],
availableSkills: [...state.connection.availableSkills],
availablePermissionProfiles: state.connection.availablePermissionProfiles.map((profile) => ({ ...profile })),
},
threadList: {
listedThreads: [...state.threadList.listedThreads],

View file

@ -21,6 +21,12 @@ export function reasoningEffortOverrideMessage(effort: ReasoningEffort | null):
: `Reasoning effort set to ${effort} for subsequent turns.`;
}
export function permissionProfileOverrideMessage(permissionProfile: string | null): string {
return permissionProfile === null
? "Permission profile reset to default for subsequent turns."
: `Permission profile set to ${permissionProfile} for subsequent turns.`;
}
export function pendingRuntimeSettingLabel(
setting: { kind: "unchanged" } | { kind: "set"; value: unknown } | { kind: "resetToConfig" },
): string {

View file

@ -113,6 +113,20 @@ export function resetReasoningEffortToConfigRuntimeState(state: ChatRuntimeState
};
}
export function requestPermissionProfileRuntimeState(state: ChatRuntimeState, permissionProfile: string): ChatRuntimeState {
return {
...state,
pending: { ...state.pending, permissionProfile: setRuntimeIntentValue(permissionProfile) },
};
}
export function resetPermissionProfileToConfigRuntimeState(state: ChatRuntimeState): ChatRuntimeState {
return {
...state,
pending: { ...state.pending, permissionProfile: resetRuntimeIntentToConfig() },
};
}
export function requestFastModeRuntimeState(state: ChatRuntimeState, fastMode: RequestedFastMode): ChatRuntimeState {
return {
...state,

View file

@ -36,6 +36,11 @@ export function serviceTierRequestForThreadStart(snapshot: RuntimeSnapshot, conf
return runtimeSettingsPatchValue(serviceTierPatchIntent(snapshot, resolveRuntimeControls(snapshot, config), "thread-start"));
}
export function permissionProfileRequestForThreadStart(snapshot: RuntimeSnapshot, config: RuntimeConfigSnapshot): string | undefined {
if (snapshot.pending.permissionProfile.kind === "set") return snapshot.pending.permissionProfile.value;
return config.startupPermissions.activePermissionProfile?.id ?? undefined;
}
export function pendingRuntimeSettingsPatch(snapshot: RuntimeSnapshot, config: RuntimeConfigSnapshot): PendingRuntimeSettingsPatch {
const update: RuntimeSettingsPatch = {};
const resolution = resolveRuntimeControls(snapshot, config);

View file

@ -257,7 +257,11 @@ export class ChatComposerController {
state.threadList.listedThreads,
state.connection.availableModels,
this.options.currentModelForSuggestions(),
{ activeThreadId: state.activeThread.id, contextReferences: this.contextReferences() },
{
activeThreadId: state.activeThread.id,
contextReferences: this.contextReferences(),
permissionProfiles: state.connection.availablePermissionProfiles,
},
);
this.dispatchSuggestions({
@ -302,7 +306,11 @@ export class ChatComposerController {
state.threadList.listedThreads,
state.connection.availableModels,
this.options.currentModelForSuggestions(),
{ activeThreadId: state.activeThread.id, contextReferences: this.contextReferences() },
{
activeThreadId: state.activeThread.id,
contextReferences: this.contextReferences(),
permissionProfiles: state.connection.availablePermissionProfiles,
},
);
return {
suggestions,

View file

@ -15,7 +15,7 @@ export function runtimePermissionSections(input: RuntimePermissionSectionsInput)
return [
{
title: "Permissions",
rows: accessRows(resolution.permissionProfile.confirmed, resolution.sandboxPolicy.confirmed, input.vaultPath),
rows: accessRows(resolution.permissionProfile.effective, resolution.sandboxPolicy.effective, input.vaultPath),
},
{
title: "Approvals",

View file

@ -28,7 +28,15 @@ describe("app-server diagnostics", () => {
it("creates generic capability probe defaults", () => {
const diagnostics = createServerDiagnostics();
expect(Object.keys(diagnostics.probes)).toEqual(["models", "skills", "apps", "plugins", "rateLimits", "mcpServers"]);
expect(Object.keys(diagnostics.probes)).toEqual([
"models",
"skills",
"permissionProfiles",
"apps",
"plugins",
"rateLimits",
"mcpServers",
]);
expect(diagnostics.probes.models).toMatchObject({
id: "models",
status: "unknown",
@ -37,6 +45,7 @@ describe("app-server diagnostics", () => {
checkedAt: null,
});
expect(diagnosticProbeLabel("models")).toBe("Models");
expect(diagnosticProbeLabel("permissionProfiles")).toBe("Permission profiles");
});
it("classifies ok and failed capability probes", () => {

View file

@ -5,6 +5,7 @@ import type { AppServerQueryContext } from "../../src/app-server/query/keys";
import type { ModelMetadata, SkillMetadata } from "../../src/domain/catalog/metadata";
import { emptyRuntimeConfigSnapshot, type RuntimeConfigSnapshot } from "../../src/domain/runtime/config";
import type { RateLimitSnapshot } from "../../src/domain/runtime/metrics";
import type { RuntimePermissionProfileSummary } from "../../src/domain/runtime/permissions";
import {
createServerDiagnostics,
diagnosticProbeError,
@ -19,6 +20,7 @@ describe("AppServerQueryCache", () => {
const context = cacheContext();
const goodMetadata = metadata({
availableSkills: [skillMetadata("writer")],
availablePermissionProfiles: [permissionProfile(":workspace")],
rateLimit: rateLimit(42),
});
@ -30,14 +32,17 @@ describe("AppServerQueryCache", () => {
metadata({
availableModels: [modelMetadata("gpt-5.6")],
availableSkills: [skillMetadata("failed-skill")],
availablePermissionProfiles: [permissionProfile(":failed")],
rateLimit: rateLimit(90),
skillsProbeStatus: "failed",
permissionProfilesProbeStatus: "failed",
rateLimitProbeStatus: "failed",
}),
);
expect(cache.appServerMetadataSnapshot(context)?.availableModels.map((model) => model.model)).toEqual(["gpt-5.6"]);
expect(cache.appServerMetadataSnapshot(context)?.availableSkills.map((skill) => skill.name)).toEqual(["writer"]);
expect(cache.appServerMetadataSnapshot(context)?.availablePermissionProfiles.map((profile) => profile.id)).toEqual([":workspace"]);
expect(cache.appServerMetadataSnapshot(context)?.rateLimit?.primary?.usedPercent).toBe(42);
expect(cache.appServerMetadataSnapshot(context)?.serverDiagnostics.probes.skills.status).toBe("failed");
expect(cache.appServerMetadataSnapshot(context)?.serverDiagnostics.probes.rateLimits.status).toBe("failed");
@ -56,9 +61,11 @@ describe("AppServerQueryCache", () => {
metadata({
availableModels: [modelMetadata("failed-model")],
availableSkills: [skillMetadata("writer")],
availablePermissionProfiles: [permissionProfile(":workspace")],
rateLimit: rateLimit(90),
modelProbeStatus: "failed",
skillsProbeStatus: "failed",
permissionProfilesProbeStatus: "failed",
rateLimitProbeStatus: "failed",
}),
);
@ -68,6 +75,7 @@ describe("AppServerQueryCache", () => {
expect(cached?.serverDiagnostics.probes.models.status).toBe("failed");
expect(cached?.availableModels).toEqual([]);
expect(cached?.availableSkills).toEqual([]);
expect(cached?.availablePermissionProfiles).toEqual([]);
expect(cached?.rateLimit).toBeNull();
expect(cache.modelsSnapshot(context)).toBeNull();
});
@ -184,6 +192,7 @@ describe("AppServerQueryCache", () => {
"config/read": vi.fn().mockResolvedValue({}),
"model/list": vi.fn().mockResolvedValue({ data: [catalogModel("gpt-meta")] }),
"skills/list": vi.fn().mockResolvedValue({ data: [{ skills: [catalogSkill("writer")] }] }),
"permissionProfile/list": vi.fn().mockResolvedValue({ data: [permissionProfile(":workspace")], nextCursor: null }),
"account/rateLimits/read": vi.fn().mockResolvedValue({ rateLimits: appServerRateLimit(64), rateLimitsByLimitId: null }),
});
@ -191,6 +200,7 @@ describe("AppServerQueryCache", () => {
expect(metadata?.availableModels.map((model) => model.model)).toEqual(["gpt-meta"]);
expect(metadata?.availableSkills.map((skill) => skill.name)).toEqual(["writer"]);
expect(metadata?.availablePermissionProfiles.map((profile) => profile.id)).toEqual([":workspace"]);
expect(metadata?.rateLimit?.primary?.usedPercent).toBe(64);
expect(metadata?.serverDiagnostics.probes.models.status).toBe("ok");
expect(cache.modelsSnapshot(context)?.map((model) => model.model)).toEqual(["gpt-meta"]);
@ -204,6 +214,7 @@ describe("AppServerQueryCache", () => {
"config/read": vi.fn().mockResolvedValue({}),
"model/list": listModels,
"skills/list": vi.fn().mockResolvedValue({ data: [{ skills: [] }] }),
"permissionProfile/list": vi.fn().mockResolvedValue({ data: [], nextCursor: null }),
"account/rateLimits/read": vi.fn().mockResolvedValue({ rateLimits: appServerRateLimit(0), rateLimitsByLimitId: null }),
});
@ -230,6 +241,7 @@ describe("AppServerQueryCache", () => {
"config/read": vi.fn().mockResolvedValue({}),
"model/list": vi.fn().mockRejectedValue(new Error("offline")),
"skills/list": vi.fn().mockResolvedValue({ data: [{ skills: [] }] }),
"permissionProfile/list": vi.fn().mockResolvedValue({ data: [], nextCursor: null }),
"account/rateLimits/read": vi.fn().mockResolvedValue({ rateLimits: appServerRateLimit(0), rateLimitsByLimitId: null }),
});
cache.writeAppServerMetadata(context, metadata({ availableModels: [modelMetadata("gpt-cached")] }));
@ -318,10 +330,12 @@ function metadata(
overrides: {
availableModels?: readonly ModelMetadata[];
availableSkills?: readonly SkillMetadata[];
availablePermissionProfiles?: readonly RuntimePermissionProfileSummary[];
rateLimit?: RateLimitSnapshot | null;
runtimeConfig?: RuntimeConfigSnapshot | null;
modelProbeStatus?: "ok" | "failed";
skillsProbeStatus?: "ok" | "failed";
permissionProfilesProbeStatus?: "ok" | "failed";
rateLimitProbeStatus?: "ok" | "failed";
} = {},
): SharedServerMetadata {
@ -344,10 +358,17 @@ function metadata(
? diagnosticProbeError("rateLimits", new Error("offline"), 1)
: diagnosticProbeOk("rateLimits", "available", 1),
);
diagnostics = diagnosticsWithProbe(
diagnostics,
overrides.permissionProfilesProbeStatus === "failed"
? diagnosticProbeError("permissionProfiles", new Error("offline"), 1)
: diagnosticProbeOk("permissionProfiles", "0 profiles", 1),
);
return {
runtimeConfig: overrides.runtimeConfig ?? emptyRuntimeConfigSnapshot(),
availableModels: overrides.availableModels ?? [modelMetadata("gpt-5.5")],
availableSkills: overrides.availableSkills ?? [],
availablePermissionProfiles: overrides.availablePermissionProfiles ?? [],
rateLimit: overrides.rateLimit ?? null,
serverDiagnostics: diagnostics,
};
@ -357,6 +378,10 @@ function skillMetadata(name: string): SkillMetadata {
return { name, description: "", path: `/tmp/${name}`, enabled: true };
}
function permissionProfile(id: string): RuntimePermissionProfileSummary {
return { id, description: null, allowed: true };
}
function rateLimit(usedPercent: number): RateLimitSnapshot {
return {
limitId: "codex",

View file

@ -199,6 +199,7 @@ function serverMetadata(overrides: Partial<SharedServerMetadata> = {}): SharedSe
runtimeConfig: null,
availableModels: [],
availableSkills: [],
availablePermissionProfiles: [],
rateLimit: null,
serverDiagnostics: diagnostics,
...overrides,

View file

@ -71,6 +71,7 @@ describe("chat app-server actions", () => {
it("keeps empty-panel runtime reservations when starting the first thread", async () => {
const stateStore = createChatStateStore(chatStateFixture());
stateStore.dispatch({ type: "runtime/model-requested", model: "gpt-5.5" });
stateStore.dispatch({ type: "runtime/permission-profile-requested", permissionProfile: ":workspace" });
stateStore.dispatch({ type: "runtime/reasoning-effort-requested", effort: "high" });
stateStore.dispatch({ type: "runtime/fast-mode-requested", fastMode: "enabled" });
stateStore.dispatch({ type: "runtime/approvals-reviewer-requested", approvalsReviewer: "auto_review" });
@ -99,11 +100,13 @@ describe("chat app-server actions", () => {
expect(startThread).toHaveBeenCalledWith({
cwd: "/vault",
permissions: ":workspace",
serviceName: "codex-panel",
serviceTier: "fast",
});
expect(stateStore.getState().runtime.active.model).toBe("gpt-5");
expect(stateStore.getState().runtime.pending.model).toEqual({ kind: "set", value: "gpt-5.5" });
expect(stateStore.getState().runtime.pending.permissionProfile).toEqual({ kind: "set", value: ":workspace" });
expect(stateStore.getState().runtime.pending.reasoningEffort).toEqual({ kind: "set", value: "high" });
expect(stateStore.getState().runtime.pending.fastMode).toEqual({ kind: "set", value: "enabled" });
expect(stateStore.getState().runtime.pending.approvalsReviewer).toEqual({ kind: "set", value: "auto_review" });
@ -172,6 +175,49 @@ describe("chat app-server actions", () => {
});
});
it("starts threads with permission profile from explicit config", async () => {
let state = chatStateFixture();
state = chatStateWith(state, {
connection: {
runtimeConfig: {
...emptyRuntimeConfigSnapshot(),
startupPermissions: {
...emptyRuntimeConfigSnapshot().startupPermissions,
activePermissionProfile: { id: ":workspace", extends: null },
},
},
},
});
const stateStore = createChatStateStore(state);
const started = threadFixture("started");
const startThread = vi.fn().mockResolvedValue({
thread: started,
cwd: "/vault",
model: "gpt-5",
serviceTier: null,
approvalsReviewer: null,
reasoningEffort: null,
});
const client = startThreadClient(startThread);
const actions = createChatServerThreadActions({
stateStore,
vaultPath: "/vault",
currentClient: () => client,
runtimeSnapshotForState: runtimeSnapshotForChatState,
applyThreadCatalogEvent: vi.fn(),
syncThreadGoal: vi.fn(),
});
await actions.startThread();
expect(startThread).toHaveBeenCalledWith({
cwd: "/vault",
permissions: ":workspace",
serviceName: "codex-panel",
});
});
it("keeps app-server preview when newly started threads already have one", async () => {
const stateStore = createChatStateStore(chatStateFixture());
const started = threadFixture("started", { preview: "server preview" });
@ -757,6 +803,7 @@ function serverMetadataFixture(overrides: Partial<SharedServerMetadata> = {}): S
runtimeConfig: emptyRuntimeConfigSnapshot(),
availableModels: [],
availableSkills: [],
availablePermissionProfiles: [],
rateLimit: null,
serverDiagnostics: createServerDiagnostics(),
...overrides,

View file

@ -124,6 +124,7 @@ describe("composer suggestions", () => {
{ input: "/plan", expected: { command: "plan", args: "" } },
{ input: "/plan OK、実装してください", expected: { command: "plan", args: "OK、実装してください" } },
{ input: "/model gpt-5.5", expected: { command: "model", args: "gpt-5.5" } },
{ input: "/permissions :workspace", expected: { command: "permissions", args: ":workspace" } },
{ input: "/reasoning high", expected: { command: "reasoning", args: "high" } },
{ input: "/new", expected: null },
{ input: "/unknown", expected: null },
@ -446,6 +447,52 @@ describe("composer suggestions", () => {
expect(activeComposerSuggestions("/reasoning high ", notes, [], [], models, "gpt-5.5")).toEqual([]);
});
it("suggests existing allowed permission profiles for /permissions", () => {
const permissionProfiles = [
{ id: ":read-only", description: "Read only", allowed: true },
{ id: ":workspace", description: "Workspace write", allowed: true },
{ id: "DevProfile", description: "Developer profile", allowed: true },
{ id: "reset", description: "Reset-like profile", allowed: true },
{ id: "blocked", description: null, allowed: false },
];
expect(
suggestionReplacements(
activeComposerSuggestions("/permissions ", notes, [], [], [], null, {
permissionProfiles,
}),
),
).toEqual(["default", ":read-only", ":workspace", "DevProfile", "reset"]);
expect(
activeComposerSuggestions("/permissions :r", notes, [], [], [], null, {
permissionProfiles,
})[0],
).toMatchObject({
display: ":read-only",
detail: "Read only",
replacement: ":read-only",
appendSpaceOnInsert: true,
});
expect(
activeComposerSuggestions("/permissions work", notes, [], [], [], null, {
permissionProfiles,
})[0],
).toMatchObject({
replacement: ":workspace",
});
expect(activeComposerSuggestions("/permissions blocked", notes, [], [], [], null, { permissionProfiles })).toEqual([]);
expect(activeComposerSuggestions("/permissions devprofile", notes, [], [], [], null, { permissionProfiles })[0]).toMatchObject({
replacement: "DevProfile",
});
expect(activeComposerSuggestions("/permissions res", notes, [], [], [], null, { permissionProfiles })[0]).toMatchObject({
replacement: "reset",
});
expect(activeComposerSuggestions("/permissions DevProfile", notes, [], [], [], null, { permissionProfiles })).toEqual([]);
expect(activeComposerSuggestions("/permissions reset", notes, [], [], [], null, { permissionProfiles })).toEqual([]);
expect(activeComposerSuggestions("/permissions :read-only", notes, [], [], [], null, { permissionProfiles })).toEqual([]);
expect(activeComposerSuggestions("/permissions :read-only ", notes, [], [], [], null, { permissionProfiles })).toEqual([]);
});
it("does not suggest fixed reasoning effort fallbacks without model support metadata", () => {
expect(activeComposerSuggestions("/reasoning h", notes, [], [], [], null)).toEqual([]);
expect(activeComposerSuggestions("/reasoning ", notes, [], [], [model("gpt-5.5", [])], "gpt-5.5")).toEqual([

View file

@ -36,6 +36,8 @@ function context(overrides: Partial<SlashCommandExecutionContext> = {}): SlashCo
toggleAutoReview: vi.fn(),
requestModel: vi.fn(),
resetModelToConfig: vi.fn(),
requestPermissionProfile: vi.fn(),
resetPermissionProfileToConfig: vi.fn(),
requestReasoningEffort: vi.fn(),
resetReasoningEffortToConfig: vi.fn(),
},
@ -508,6 +510,7 @@ describe("slash commands", () => {
{ key: "/plan [message]", value: "Toggle Plan mode, optionally with a message." },
{ key: "/goal", value: "Show or manage the current thread goal." },
{ key: "/goal set <objective>", value: "Create or update the thread goal." },
{ key: "/permissions [profile|default]", value: "Show or set the permission profile for subsequent turns." },
{ key: "/model [model|default]", value: "Show or set the model for subsequent turns." },
]),
},
@ -573,13 +576,45 @@ describe("slash commands", () => {
expect(ctx.addStructuredSystemMessage).toHaveBeenCalledWith("Permissions & Approvals", details);
});
it("rejects /permissions arguments", async () => {
it("sets the permission profile for /permissions arguments", async () => {
const ctx = context();
await executeSlashCommand("permissions", "anything", ctx);
await executeSlashCommand("permissions", ":workspace", ctx);
expect(ctx.addStructuredSystemMessage).not.toHaveBeenCalled();
expect(ctx.addSystemMessage).toHaveBeenCalledWith("/permissions does not take arguments. Usage: /permissions");
expect(ctx.runtimeSettings.requestPermissionProfile).toHaveBeenCalledWith(":workspace");
expect(ctx.runtimeSettings.resetPermissionProfileToConfig).not.toHaveBeenCalled();
expect(ctx.addSystemMessage).toHaveBeenCalledWith("Permission profile set to :workspace for subsequent turns.");
});
it("routes default permission profile through reset", async () => {
const ctx = context();
await executeSlashCommand("permissions", "default", ctx);
expect(ctx.runtimeSettings.resetPermissionProfileToConfig).toHaveBeenCalledOnce();
expect(ctx.runtimeSettings.requestPermissionProfile).not.toHaveBeenCalled();
expect(ctx.addSystemMessage).toHaveBeenCalledWith("Permission profile reset to default for subsequent turns.");
});
it.each(["reset", "clear", "off"])("treats permission profile alias-like value %s as a profile id", async (profile) => {
const ctx = context();
await executeSlashCommand("permissions", profile, ctx);
expect(ctx.runtimeSettings.requestPermissionProfile).toHaveBeenCalledWith(profile);
expect(ctx.runtimeSettings.resetPermissionProfileToConfig).not.toHaveBeenCalled();
expect(ctx.addSystemMessage).toHaveBeenCalledWith(`Permission profile set to ${profile} for subsequent turns.`);
});
it("does not announce permission profile changes when applying them fails", async () => {
const ctx = context();
ctx.runtimeSettings.requestPermissionProfile = vi.fn().mockResolvedValue(false);
await executeSlashCommand("permissions", ":workspace", ctx);
expect(ctx.runtimeSettings.requestPermissionProfile).toHaveBeenCalledWith(":workspace");
expect(ctx.addSystemMessage).not.toHaveBeenCalled();
});
it("shows doctor diagnostics as shared structured sections", async () => {
@ -619,7 +654,7 @@ describe("slash commands", () => {
it("documents status and doctor as separate commands", () => {
expect(slashCommandHelpValue("/status")).toBe("Show current thread, context, and usage limits.");
expect(slashCommandHelpValue("/permissions")).toBe("Show current permissions and approval settings.");
expect(slashCommandHelpValue("/permissions [profile|default]")).toBe("Show or set the permission profile for subsequent turns.");
expect(slashCommandHelpValue("/doctor")).toBe("Show Codex CLI and Codex App Server diagnostics.");
});

View file

@ -49,6 +49,8 @@ function createHost(overrides: SlashCommandExecutorHostOverrides = {}) {
toggleAutoReview: vi.fn(),
requestModel: vi.fn(),
resetModelToConfig: vi.fn(),
requestPermissionProfile: vi.fn(),
resetPermissionProfileToConfig: vi.fn(),
requestReasoningEffort: vi.fn(),
resetReasoningEffortToConfig: vi.fn(),
},

View file

@ -66,6 +66,33 @@ describe("createChatRuntimeSettingsActions", () => {
expect(messages).toEqual([]);
});
it("requests and resets permission profiles through thread settings", async () => {
let state = chatStateFixture();
state = chatStateWith(state, { activeThread: { id: "thread" } });
state = chatStateWith(state, {
runtime: {
active: {
activePermissionProfile: { id: ":read-only", extends: null },
sandboxPolicy: { type: "readOnly", networkAccess: false },
},
},
});
const store = createChatStateStore(state);
const transport = settingsTransportFixture();
const messages: string[] = [];
const actions = runtimeActionsFixture(store, transport, messages);
await expect(actions.requestPermissionProfile(":workspace")).resolves.toBe(true);
await expect(actions.resetPermissionProfileToConfig()).resolves.toBe(true);
expect(transport.updateThreadSettings).toHaveBeenNthCalledWith(1, "thread", { permissions: ":workspace" });
expect(transport.updateThreadSettings).toHaveBeenNthCalledWith(2, "thread", { permissions: null });
expect(store.getState().runtime.pending.permissionProfile).toEqual({ kind: "unchanged" });
expect(store.getState().runtime.active.activePermissionProfile).toBeNull();
expect(store.getState().runtime.active.permissionProfileKnown).toBe(false);
expect(messages).toEqual([]);
});
it("reserves thread runtime settings when no thread is active", async () => {
const store = createChatStateStore(chatStateFixture());
const transport = settingsTransportFixture();
@ -73,6 +100,7 @@ describe("createChatRuntimeSettingsActions", () => {
const actions = runtimeActionsFixture(store, transport, messages);
await expect(actions.requestModel("gpt-5.5")).resolves.toBe(true);
await expect(actions.requestPermissionProfile(":workspace")).resolves.toBe(true);
await expect(actions.requestReasoningEffort("high")).resolves.toBe(true);
await actions.enableFastMode();
await actions.enableAutoReview();
@ -81,6 +109,7 @@ describe("createChatRuntimeSettingsActions", () => {
expect(transport.updateThreadSettings).not.toHaveBeenCalled();
expect(store.getState().runtime.pending.model).toEqual({ kind: "set", value: "gpt-5.5" });
expect(store.getState().runtime.pending.permissionProfile).toEqual({ kind: "set", value: ":workspace" });
expect(store.getState().runtime.pending.reasoningEffort).toEqual({ kind: "set", value: "high" });
expect(store.getState().runtime.pending.fastMode).toEqual({ kind: "set", value: "enabled" });
expect(store.getState().runtime.pending.approvalsReviewer).toEqual({ kind: "set", value: "auto_review" });

View file

@ -6,7 +6,7 @@ import { messageStreamItems } from "../../../../../src/features/chat/application
import { type ChatState, chatReducer } from "../../../../../src/features/chat/application/state/root-reducer";
import { createChatStateStore } from "../../../../../src/features/chat/application/state/store";
import type { MessageStreamItem } from "../../../../../src/features/chat/domain/message-stream/items";
import { setCollaborationModeIntent } from "../../../../../src/features/chat/domain/runtime/intent";
import { setCollaborationModeIntent, setRuntimeIntentValue } from "../../../../../src/features/chat/domain/runtime/intent";
import { chatStateMessageStreamItems, withChatStateMessageStreamItems } from "../../support/message-stream";
import { chatStateFixture, chatStateWith } from "../../support/state";
@ -19,6 +19,7 @@ describe("chatReducer", () => {
state = chatStateWith(state, { runtime: { active: { reasoningEffort: "high" } } });
state = chatStateWith(state, { runtime: { active: { serviceTier: "fast" } } });
state = chatStateWith(state, { runtime: { active: { approvalsReviewer: "auto_review" } } });
state = chatStateWith(state, { runtime: { active: { activePermissionProfile: { id: ":workspace", extends: null } } } });
state = chatStateWith(state, { runtime: { active: { collaborationMode: "plan" } } });
state = chatStateWith(state, { runtime: { pending: { collaborationMode: setCollaborationModeIntent("plan") } } });
state = chatStateWith(state, { activeThread: { goal: goal("thread") } });
@ -40,6 +41,7 @@ describe("chatReducer", () => {
ui: { goalEditor: { kind: "editing", threadId: "thread", objectiveDraft: "draft", tokenBudgetDraft: null } },
});
let pendingState = chatReducer(state, { type: "runtime/model-requested", model: "gpt-5.2" });
pendingState = chatReducer(pendingState, { type: "runtime/permission-profile-requested", permissionProfile: ":read-only" });
pendingState = chatReducer(pendingState, { type: "runtime/reasoning-effort-requested", effort: "medium" });
pendingState = chatReducer(pendingState, { type: "runtime/fast-mode-requested", fastMode: "disabled" });
pendingState = chatReducer(pendingState, { type: "runtime/approvals-reviewer-requested", approvalsReviewer: "user" });
@ -52,8 +54,10 @@ describe("chatReducer", () => {
expect(next.runtime.active.reasoningEffort).toBeNull();
expect(next.runtime.active.serviceTier).toBeNull();
expect(next.runtime.active.approvalsReviewer).toBeNull();
expect(next.runtime.active.activePermissionProfile).toBeNull();
expect(next.runtime.active.collaborationMode).toBeNull();
expect(next.runtime.pending.model).toEqual({ kind: "unchanged" });
expect(next.runtime.pending.permissionProfile).toEqual({ kind: "unchanged" });
expect(next.runtime.pending.reasoningEffort).toEqual({ kind: "unchanged" });
expect(next.runtime.pending.fastMode).toEqual({ kind: "unchanged" });
expect(next.runtime.pending.approvalsReviewer).toEqual({ kind: "unchanged" });
@ -142,6 +146,7 @@ describe("chatReducer", () => {
it("preserves empty-panel runtime reservations when thread activation explicitly requests it", () => {
let state = chatStateFixture();
state = chatReducer(state, { type: "runtime/model-requested", model: "gpt-5.5" });
state = chatReducer(state, { type: "runtime/permission-profile-requested", permissionProfile: ":workspace" });
state = chatReducer(state, { type: "runtime/reasoning-effort-requested", effort: "high" });
state = chatReducer(state, { type: "runtime/fast-mode-requested", fastMode: "enabled" });
state = chatReducer(state, { type: "runtime/approvals-reviewer-requested", approvalsReviewer: "auto_review" });
@ -170,6 +175,7 @@ describe("chatReducer", () => {
expect(next.runtime.active.serviceTier).toBe("fast");
expect(next.runtime.active.approvalsReviewer).toBe("user");
expect(next.runtime.pending.model).toEqual({ kind: "set", value: "gpt-5.5" });
expect(next.runtime.pending.permissionProfile).toEqual({ kind: "set", value: ":workspace" });
expect(next.runtime.pending.reasoningEffort).toEqual({ kind: "set", value: "high" });
expect(next.runtime.pending.fastMode).toEqual({ kind: "set", value: "enabled" });
expect(next.runtime.pending.approvalsReviewer).toEqual({ kind: "set", value: "auto_review" });
@ -177,6 +183,18 @@ describe("chatReducer", () => {
expect(next.runtime.active.collaborationMode).toBeNull();
});
it("keeps reset and set permission profile requests explicit", () => {
let state = chatStateFixture();
state = chatReducer(state, { type: "runtime/permission-profile-requested", permissionProfile: ":workspace" });
expect(state.runtime.pending.permissionProfile).toEqual(setRuntimeIntentValue(":workspace"));
state = chatReducer(state, { type: "runtime/permission-profile-reset-to-config" });
expect(state.runtime.pending.permissionProfile).toEqual({ kind: "resetToConfig" });
});
it("starts resumed threads with empty display state when no history items are supplied", () => {
let state = chatStateFixture();
state = withChatStateMessageStreamItems(state, [message("previous-message")]);

View file

@ -150,6 +150,7 @@ describe("CodexChatView connection lifecycle", () => {
expectRequestTimes(client, "config/read", 1);
expect(fetchModels).toHaveBeenCalledTimes(1);
expectRequestTimes(client, "skills/list", 1);
expectRequestTimes(client, "permissionProfile/list", 1);
expectRequestTimes(client, "account/rateLimits/read", 1);
expectRequestTimes(client, "thread/list", 1);
});
@ -421,6 +422,7 @@ describe("CodexChatView connection lifecycle", () => {
expectRequestTimes(client, "config/read", 1);
expect(fetchModels).toHaveBeenCalledOnce();
expectRequestTimes(client, "skills/list", 1);
expectRequestTimes(client, "permissionProfile/list", 1);
expectRequestTimes(client, "account/rateLimits/read", 1);
expect(client.request).toHaveBeenCalledWith("thread/list", {
cwd: "/vault",
@ -446,6 +448,7 @@ describe("CodexChatView connection lifecycle", () => {
runtimeConfig: { ...emptyRuntimeConfigSnapshot(), model: "gpt-cached" },
availableModels: [],
availableSkills: [{ name: "writer", enabled: true }],
availablePermissionProfiles: [],
rateLimit: null,
serverDiagnostics: createServerDiagnostics(),
}) as never,
@ -771,6 +774,7 @@ function baseClientHandlers(): RequestHandlers {
"config/read": vi.fn().mockResolvedValue({}),
"model/list": vi.fn().mockResolvedValue({ data: [] }),
"skills/list": vi.fn().mockResolvedValue({ data: [] }),
"permissionProfile/list": vi.fn().mockResolvedValue({ data: [], nextCursor: null }),
"account/rateLimits/read": vi.fn().mockResolvedValue({ rateLimits: null }),
"thread/list": vi.fn().mockResolvedValue({ data: [] }),
"thread/start": vi.fn().mockResolvedValue(startedThread("thread-new")),
@ -1049,6 +1053,11 @@ function chatHost(overrides: ChatHostFixtureOverrides = {}): TestCodexChatHost {
data: { skills: { name: string; description?: string; path?: string; enabled?: boolean }[] }[];
};
if (!connectionStillCurrent()) return null;
const permissionProfilesResponse = (await client.request("permissionProfile/list", { cwd: vaultPath, cursor: null, limit: 100 })) as {
data: { id: string; description: string | null; allowed: boolean }[];
nextCursor: string | null;
};
if (!connectionStillCurrent()) return null;
await client.request("account/rateLimits/read", undefined);
if (!connectionStillCurrent()) return null;
return {
@ -1063,6 +1072,7 @@ function chatHost(overrides: ChatHostFixtureOverrides = {}): TestCodexChatHost {
enabled: skill.enabled ?? true,
})),
),
availablePermissionProfiles: permissionProfilesResponse.data.map((profile) => ({ ...profile })),
rateLimit: null,
serverDiagnostics: createServerDiagnostics(),
};

View file

@ -123,6 +123,61 @@ describe("createChatPanelRuntimeProjection", () => {
},
]);
});
it("shows pending permission profile reservations in an empty panel", () => {
const state = chatStateWith(chatStateFixture(), {
runtime: {
pending: {
permissionProfile: { kind: "set", value: ":workspace" },
},
},
});
const projection = createChatPanelRuntimeProjection({
state: () => state,
connected: () => true,
configuredCommand: () => "codex",
vaultPath: () => "/vault",
nowMs: () => 0,
});
expect(projection.permissionDetails()[0]?.auditFacts).toEqual([
{ key: "Profile", value: ":workspace" },
{ key: "Sandbox", value: "(not reported)" },
{ key: "Network", value: "(not reported)" },
{ key: "Extra writable roots", value: "(not reported)" },
]);
});
it("does not report legacy sandbox details for configured permission profiles in an empty panel", () => {
const state = chatStateWith(chatStateFixture(), {
connection: {
runtimeConfig: runtimeConfigFixture({
default_permissions: "DevProfile",
sandbox_mode: "workspace-write",
sandbox_workspace_write: {
writable_roots: ["/vault"],
network_access: false,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
},
}),
},
});
const projection = createChatPanelRuntimeProjection({
state: () => state,
connected: () => true,
configuredCommand: () => "codex",
vaultPath: () => "/vault",
nowMs: () => 0,
});
expect(projection.permissionDetails()[0]?.auditFacts).toEqual([
{ key: "Profile", value: "DevProfile" },
{ key: "Sandbox", value: "(not reported)" },
{ key: "Network", value: "(not reported)" },
{ key: "Extra writable roots", value: "(not reported)" },
]);
});
});
function runtimeConfigFixture(config: Record<string, unknown>): RuntimeConfigSnapshot {

View file

@ -18,6 +18,7 @@ import { resolveRuntimeControls } from "../../src/features/chat/domain/runtime/r
import type { RuntimeSnapshot } from "../../src/features/chat/domain/runtime/snapshot";
import {
pendingRuntimeSettingsPatch,
permissionProfileRequestForThreadStart,
serviceTierRequestForThreadStart,
} from "../../src/features/chat/domain/runtime/thread-settings-patch";
import { contextSummary, rateLimitSummary } from "../../src/features/chat/presentation/runtime/status";
@ -78,10 +79,31 @@ describe("runtime settings", () => {
expect(clonedPolicy.granular).not.toBe(originalPolicy.granular);
});
it("uses legacy sandbox config instead of default permissions when both are reported", () => {
it("keeps default permissions separate from legacy sandbox fields", () => {
expect(
runtimeConfigFixture({
default_permissions: "DevProfile",
approval_policy: "on-request",
sandbox_mode: "workspace-write",
sandbox_workspace_write: {
writable_roots: ["/vault"],
network_access: false,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
},
}),
).toMatchObject({
startupPermissions: {
activePermissionProfile: { id: "DevProfile", extends: null },
approvalPolicy: "on-request",
sandboxPolicy: null,
},
});
});
it("uses legacy sandbox config when default permissions are not reported", () => {
expect(
runtimeConfigFixture({
default_permissions: ":workspace",
approval_policy: "on-request",
sandbox_mode: "workspace-write",
sandbox_workspace_write: {
@ -235,6 +257,33 @@ describe("runtime settings", () => {
});
});
it("selects config permission profiles for empty-panel thread starts", () => {
const configured = runtimeSnapshot({
runtimeConfig: runtimeConfigFixture({
default_permissions: ":workspace",
}),
});
expect(permissionProfileRequestForThreadStart(configured, snapshotConfig(configured))).toBe(":workspace");
const pending = runtimeSnapshot({
runtimeConfig: runtimeConfigFixture({
default_permissions: ":workspace",
}),
pending: { permissionProfile: setRuntimeIntentValue(":read-only") },
});
expect(permissionProfileRequestForThreadStart(pending, snapshotConfig(pending))).toBe(":read-only");
const legacySandbox = runtimeSnapshot({
runtimeConfig: runtimeConfigFixture({
sandbox_mode: "workspace-write",
}),
});
expect(permissionProfileRequestForThreadStart(legacySandbox, snapshotConfig(legacySandbox))).toBeUndefined();
});
it("keeps runtime defaults, resets, and collaboration mode semantics distinct", () => {
const snapshot = runtimeSnapshot({
pending: {
@ -598,8 +647,8 @@ describe("runtime settings", () => {
expect(resolveRuntimeControls(configured, snapshotConfig(configured))).toMatchObject({
model: { effective: "gpt-config", source: "config" },
reasoningEffort: { effective: "medium", source: "config" },
permissionProfile: { effective: null, source: "none" },
sandboxPolicy: { effective: { type: "workspaceWrite", writableRoots: ["/vault"], networkAccess: false }, source: "config" },
permissionProfile: { effective: ":workspace", source: "config" },
sandboxPolicy: { effective: null, source: "none" },
approvalPolicy: { effective: "on-request", source: "config" },
approvalsReviewer: { effective: "auto_review", source: "config" },
serviceTier: { effective: "fast", source: "config" },

View file

@ -15,7 +15,7 @@ export function deferred<T>(): Deferred<T> {
}
export async function waitForAsyncWork(assertion: () => void): Promise<void> {
for (let attempt = 0; attempt < 20; attempt += 1) {
for (let attempt = 0; attempt < 50; attempt += 1) {
try {
assertion();
return;