Show runtime permissions in status panel

This commit is contained in:
murashit 2026-07-01 12:01:36 +09:00
parent 9842ce38c4
commit 07c29add11
38 changed files with 920 additions and 54 deletions

View file

@ -1,5 +1,6 @@
import { normalizeReasoningEffort } from "../../domain/catalog/metadata";
import type { ReasoningSummary, RuntimeConfigSnapshot, Verbosity } from "../../domain/runtime/config";
import type { RuntimeApprovalPolicy, RuntimePermissionState, RuntimeSandboxPolicy } from "../../domain/runtime/permissions";
import { approvalsReviewerOrNull, parseServiceTier } from "../../domain/runtime/policy";
interface ConfigLayerRecord {
@ -28,11 +29,59 @@ export function runtimeConfigSnapshotFromAppServerConfig(response: ConfigReadRes
verbosity: verbosityOrNull(config["model_verbosity"]),
serviceTier: parseServiceTier(config["service_tier"]),
approvalsReviewer: approvalsReviewerOrNull(config["approvals_reviewer"]),
startupPermissions: startupPermissionsFromConfig(config),
modelContextWindow: numberOrNull(config["model_context_window"]),
autoCompactTokenLimit: numberOrNull(config["model_auto_compact_token_limit"]),
};
}
function startupPermissionsFromConfig(config: Record<string, unknown>): RuntimePermissionState {
return {
approvalPolicy: approvalPolicyOrNull(config["approval_policy"]),
sandboxPolicy: sandboxPolicyFromConfig(config),
activePermissionProfile: permissionProfileFromConfig(config),
};
}
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;
}
function sandboxPolicyFromConfig(config: Record<string, unknown>): RuntimeSandboxPolicy | null {
const mode = config["sandbox_mode"];
if (mode === "danger-full-access") return { type: "dangerFullAccess" };
if (mode === "read-only") return { type: "readOnly", networkAccess: false };
if (mode !== "workspace-write") return null;
const workspaceWrite = recordOrNull(config["sandbox_workspace_write"]);
return {
type: "workspaceWrite",
writableRoots: stringArrayOrEmpty(workspaceWrite?.["writable_roots"]),
networkAccess: booleanOrFalse(workspaceWrite?.["network_access"]),
excludeTmpdirEnvVar: booleanOrFalse(workspaceWrite?.["exclude_tmpdir_env_var"]),
excludeSlashTmp: booleanOrFalse(workspaceWrite?.["exclude_slash_tmp"]),
};
}
function approvalPolicyOrNull(value: unknown): RuntimeApprovalPolicy | null {
if (value === "untrusted" || value === "on-failure" || value === "on-request" || value === "never") return value;
if (!value || typeof value !== "object") return null;
const granular = (value as Record<string, unknown>)["granular"];
if (!granular || typeof granular !== "object") return null;
const granularRecord = granular as Record<string, unknown>;
return {
granular: {
sandbox_approval: booleanOrFalse(granularRecord["sandbox_approval"]),
rules: booleanOrFalse(granularRecord["rules"]),
skill_approval: booleanOrFalse(granularRecord["skill_approval"]),
request_permissions: booleanOrFalse(granularRecord["request_permissions"]),
mcp_elicitations: booleanOrFalse(granularRecord["mcp_elicitations"]),
},
};
}
function selectedConfigProfile(layers: ConfigReadResult["layers"]): string | null {
let selected: string | null = null;
if (!layers) return null;
@ -55,6 +104,18 @@ function numberOrNull(value: unknown): number | null {
return null;
}
function recordOrNull(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" ? (value as Record<string, unknown>) : null;
}
function stringArrayOrEmpty(value: unknown): string[] {
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string" && item.length > 0) : [];
}
function booleanOrFalse(value: unknown): boolean {
return value === true;
}
function reasoningSummaryOrNull(value: unknown): ReasoningSummary | null {
return value === "auto" || value === "concise" || value === "detailed" || value === "none" ? value : null;
}

View file

@ -1,4 +1,5 @@
import { normalizeReasoningEffort } from "../../domain/catalog/metadata";
import { type RuntimePermissionState, runtimePermissionStateOrDefault } from "../../domain/runtime/permissions";
import type { ApprovalsReviewer, ServiceTier } from "../../domain/runtime/policy";
import { parseServiceTier } from "../../domain/runtime/policy";
import type { RuntimeServiceTierRequest, RuntimeSettingsPatch } from "../../domain/runtime/thread-settings";
@ -34,13 +35,14 @@ interface ThreadConversationSummaryPage {
nextCursor: string | null;
}
interface ThreadActivationResponse {
interface ThreadActivationResponse extends Partial<RuntimePermissionState> {
thread: ThreadRecord;
cwd: string;
model: string | null;
serviceTier: ServiceTier | null;
approvalsReviewer: ApprovalsReviewer | null;
reasoningEffort: string | null;
sandbox?: RuntimePermissionState["sandboxPolicy"];
}
export interface AppServerStartThreadOptions {
@ -215,6 +217,11 @@ export async function restoreArchivedThread(client: AppServerRequestClient, thre
}
export function threadActivationSnapshotFromAppServerResponse(response: ThreadActivationResponse): ThreadActivationSnapshot {
const permissions = runtimePermissionStateOrDefault({
approvalPolicy: response.approvalPolicy ?? null,
sandboxPolicy: response.sandbox ?? response.sandboxPolicy ?? null,
activePermissionProfile: response.activePermissionProfile ?? null,
});
return {
thread: threadFromThreadRecord(response.thread),
cwd: response.cwd,
@ -222,6 +229,7 @@ export function threadActivationSnapshotFromAppServerResponse(response: ThreadAc
reasoningEffort: normalizeReasoningEffort(response.reasoningEffort),
serviceTier: parseServiceTier(response.serviceTier),
approvalsReviewer: response.approvalsReviewer,
...permissions,
};
}

View file

@ -1,4 +1,5 @@
import type { ReasoningEffort } from "../catalog/metadata";
import { cloneRuntimePermissionState, initialRuntimePermissionState, type RuntimePermissionState } from "./permissions";
import type { ApprovalsReviewer, ServiceTier } from "./policy";
export type ReasoningSummary = "auto" | "concise" | "detailed" | "none";
@ -13,6 +14,7 @@ export interface RuntimeConfigSnapshot {
readonly verbosity: Verbosity | null;
readonly serviceTier: ServiceTier | null;
readonly approvalsReviewer: ApprovalsReviewer | null;
readonly startupPermissions: RuntimePermissionState;
readonly modelContextWindow: number | null;
readonly autoCompactTokenLimit: number | null;
}
@ -27,13 +29,17 @@ export function emptyRuntimeConfigSnapshot(): RuntimeConfigSnapshot {
verbosity: null,
serviceTier: null,
approvalsReviewer: null,
startupPermissions: initialRuntimePermissionState(),
modelContextWindow: null,
autoCompactTokenLimit: null,
};
}
export function cloneRuntimeConfigSnapshot(config: RuntimeConfigSnapshot): RuntimeConfigSnapshot {
return { ...config };
return {
...config,
startupPermissions: cloneRuntimePermissionState(config.startupPermissions),
};
}
export function runtimeConfigOrDefault(runtimeConfig: RuntimeConfigSnapshot | null): RuntimeConfigSnapshot {

View file

@ -0,0 +1,74 @@
export type RuntimeApprovalPolicy =
| "untrusted"
| "on-failure"
| "on-request"
| "never"
| {
granular: {
sandbox_approval: boolean;
rules: boolean;
skill_approval: boolean;
request_permissions: boolean;
mcp_elicitations: boolean;
};
};
interface RuntimeActivePermissionProfile {
readonly id: string;
readonly extends: string | null;
}
type RuntimeNetworkAccess = "restricted" | "enabled";
export type RuntimeSandboxPolicy =
| { readonly type: "dangerFullAccess" }
| { readonly type: "readOnly"; readonly networkAccess: boolean }
| { readonly type: "externalSandbox"; readonly networkAccess: RuntimeNetworkAccess }
| {
readonly type: "workspaceWrite";
readonly writableRoots: readonly string[];
readonly networkAccess: boolean;
readonly excludeTmpdirEnvVar: boolean;
readonly excludeSlashTmp: boolean;
};
export interface RuntimePermissionState {
readonly approvalPolicy: RuntimeApprovalPolicy | null;
readonly sandboxPolicy: RuntimeSandboxPolicy | null;
readonly activePermissionProfile: RuntimeActivePermissionProfile | null;
}
export function initialRuntimePermissionState(): RuntimePermissionState {
return {
approvalPolicy: null,
sandboxPolicy: null,
activePermissionProfile: null,
};
}
export function runtimePermissionStateOrDefault(value: Partial<RuntimePermissionState> | null | undefined): RuntimePermissionState {
return {
approvalPolicy: value?.approvalPolicy ? cloneRuntimeApprovalPolicy(value.approvalPolicy) : null,
sandboxPolicy: value?.sandboxPolicy ? cloneRuntimeSandboxPolicy(value.sandboxPolicy) : null,
activePermissionProfile: value?.activePermissionProfile ? { ...value.activePermissionProfile } : null,
};
}
export function cloneRuntimePermissionState(value: RuntimePermissionState): RuntimePermissionState {
return runtimePermissionStateOrDefault(value);
}
function cloneRuntimeApprovalPolicy(value: RuntimeApprovalPolicy): RuntimeApprovalPolicy {
if (typeof value === "string") return value;
return {
granular: { ...value.granular },
};
}
function cloneRuntimeSandboxPolicy(value: RuntimeSandboxPolicy): RuntimeSandboxPolicy {
if (value.type !== "workspaceWrite") return { ...value };
return {
...value,
writableRoots: [...value.writableRoots],
};
}

View file

@ -1,4 +1,5 @@
import type { ReasoningEffort } from "../catalog/metadata";
import type { RuntimeApprovalPolicy } from "./permissions";
import type { ApprovalsReviewer } from "./policy";
export type RuntimeServiceTierRequest = string | null | undefined;
@ -15,7 +16,9 @@ export interface CollaborationMode {
export interface RuntimeSettingsPatch {
cwd?: string | null;
approvalPolicy?: RuntimeApprovalPolicy | null;
approvalsReviewer?: ApprovalsReviewer | null;
permissions?: string | null;
model?: string | null;
serviceTier?: string | null;
effort?: ReasoningEffort | null;

View file

@ -1,8 +1,9 @@
import type { ReasoningEffort } from "../catalog/metadata";
import type { RuntimePermissionState } from "../runtime/permissions";
import type { ApprovalsReviewer, ServiceTier } from "../runtime/policy";
import type { Thread } from "./model";
export interface ThreadActivationSnapshot {
export interface ThreadActivationSnapshot extends RuntimePermissionState {
thread: Thread;
cwd: string;
model: string | null;

View file

@ -1,4 +1,5 @@
import { normalizeReasoningEffort, type ReasoningEffort } from "../../../../domain/catalog/metadata";
import { type RuntimePermissionState, runtimePermissionStateOrDefault } from "../../../../domain/runtime/permissions";
import { parseServiceTier, type ServiceTier } from "../../../../domain/runtime/policy";
import type { ServerInitialization } from "../../../../domain/server/initialization";
import type { ThreadActivationSnapshot } from "../../../../domain/threads/activation";
@ -19,12 +20,22 @@ interface ResumedThreadActionParams {
interface ResumedThreadFromActiveRuntimeParams {
thread: Thread;
cwd: string;
runtime: Pick<ActiveThreadRuntimeState, "model" | "reasoningEffort" | "serviceTier" | "serviceTierKnown" | "approvalsReviewer">;
runtime: Pick<
ActiveThreadRuntimeState,
| "model"
| "reasoningEffort"
| "serviceTier"
| "serviceTierKnown"
| "approvalsReviewer"
| "approvalPolicy"
| "sandboxPolicy"
| "activePermissionProfile"
>;
listedThreads?: readonly Thread[];
items?: readonly MessageStreamItem[];
}
export interface ActiveThreadResumedAction {
export interface ActiveThreadResumedAction extends RuntimePermissionState {
type: "active-thread/resumed";
thread: Thread;
cwd: string;
@ -39,7 +50,7 @@ export interface ActiveThreadResumedAction {
preserveRequestedRuntimeSettings?: boolean;
}
export interface ActiveThreadSettingsAppliedAction {
export interface ActiveThreadSettingsAppliedAction extends RuntimePermissionState {
type: "active-thread/settings-applied";
cwd: string;
model: string | null;
@ -49,7 +60,7 @@ export interface ActiveThreadSettingsAppliedAction {
approvalsReviewer: ActiveThreadRuntimeState["approvalsReviewer"];
}
export interface ActiveThreadSettingsAppliedActionSettings {
export interface ActiveThreadSettingsAppliedActionSettings extends RuntimePermissionState {
cwd: string;
model: string | null;
effort: string | null;
@ -114,6 +125,9 @@ export function resumedThreadActionFromActiveRuntime(params: ResumedThreadFromAc
reasoningEffort: params.runtime.reasoningEffort,
serviceTier: params.runtime.serviceTier,
approvalsReviewer: params.runtime.approvalsReviewer,
approvalPolicy: params.runtime.approvalPolicy,
sandboxPolicy: params.runtime.sandboxPolicy,
activePermissionProfile: params.runtime.activePermissionProfile,
},
serviceTierKnown: params.runtime.serviceTierKnown,
...(params.listedThreads ? { listedThreads: params.listedThreads } : {}),
@ -123,6 +137,7 @@ export function resumedThreadActionFromActiveRuntime(params: ResumedThreadFromAc
export function resumedThreadAction(params: ResumedThreadActionParams): ActiveThreadResumedAction {
const { response } = params;
const permissions = runtimePermissionStateOrDefault(response);
return {
type: "active-thread/resumed",
thread: response.thread,
@ -132,6 +147,7 @@ export function resumedThreadAction(params: ResumedThreadActionParams): ActiveTh
serviceTier: response.serviceTier,
serviceTierKnown: params.serviceTierKnown ?? true,
approvalsReviewer: response.approvalsReviewer,
...permissions,
...(params.items ? { items: params.items } : {}),
...(params.listedThreads ? { listedThreads: upsertThread(params.listedThreads, response.thread) } : {}),
...(params.preserveRequestedRuntimeSettings ? { preserveRequestedRuntimeSettings: true } : {}),
@ -139,6 +155,7 @@ export function resumedThreadAction(params: ResumedThreadActionParams): ActiveTh
}
export function activeThreadSettingsAppliedAction(settings: ActiveThreadSettingsAppliedActionSettings): ActiveThreadSettingsAppliedAction {
const permissions = runtimePermissionStateOrDefault(settings);
return {
type: "active-thread/settings-applied",
cwd: settings.cwd,
@ -147,5 +164,6 @@ export function activeThreadSettingsAppliedAction(settings: ActiveThreadSettings
collaborationMode: settings.collaborationMode.mode,
serviceTier: parseServiceTier(settings.serviceTier),
approvalsReviewer: settings.approvalsReviewer,
...permissions,
};
}

View file

@ -328,6 +328,9 @@ function reduceActiveThreadResumedTransition(state: ChatState, action: ActiveThr
collaborationMode: initialActiveChatRuntimeState().collaborationMode,
serviceTier: action.serviceTier,
approvalsReviewer: action.approvalsReviewer,
approvalPolicy: action.approvalPolicy,
sandboxPolicy: action.sandboxPolicy,
activePermissionProfile: action.activePermissionProfile,
},
},
turn: initialTurnState(),
@ -353,6 +356,9 @@ function reduceActiveThreadSettingsAppliedTransition(state: ChatState, action: A
collaborationMode: action.collaborationMode,
serviceTier: action.serviceTier,
approvalsReviewer: action.approvalsReviewer,
approvalPolicy: action.approvalPolicy,
sandboxPolicy: action.sandboxPolicy,
activePermissionProfile: action.activePermissionProfile,
},
pending: {
...state.runtime.pending,

View file

@ -1,6 +1,7 @@
import type { ReasoningEffort } from "../../../../domain/catalog/metadata";
import { findModelMetadataByIdOrName, type ModelMetadata, supportedEffortsForModelMetadata } from "../../../../domain/catalog/metadata";
import type { RuntimeConfigSnapshot } from "../../../../domain/runtime/config";
import { cloneRuntimePermissionState, type RuntimePermissionState } from "../../../../domain/runtime/permissions";
import type { ApprovalsReviewer, ServiceTier } from "../../../../domain/runtime/policy";
import { effectiveCollaborationMode, type PendingRuntimeIntent, type RequestedFastMode } from "./intent";
import type { RuntimeSnapshot } from "./snapshot";
@ -25,7 +26,6 @@ interface FastModeResolution {
interface AutoReviewResolution {
readonly active: boolean;
readonly reviewer: ApprovalsReviewer | null;
readonly source: RuntimeValueSource;
}
@ -37,14 +37,23 @@ interface CollaborationModeResolution {
readonly blockedReason: "missing-model" | null;
}
interface RuntimePermissionsResolution {
readonly scope: "new-thread" | "current-thread";
readonly configured: RuntimePermissionState;
readonly active: RuntimePermissionState | null;
readonly effective: RuntimePermissionState;
readonly reviewer: RuntimeLayeredValue<ApprovalsReviewer>;
readonly source: RuntimeValueSource;
}
export interface RuntimeControlsResolution {
readonly model: RuntimeLayeredValue<string>;
readonly reasoningEffort: RuntimeLayeredValue<ReasoningEffort>;
readonly approvalsReviewer: RuntimeLayeredValue<ApprovalsReviewer>;
readonly autoReview: AutoReviewResolution;
readonly serviceTier: RuntimeLayeredValue<ServiceTier>;
readonly fastMode: FastModeResolution;
readonly collaborationMode: CollaborationModeResolution;
readonly permissions: RuntimePermissionsResolution;
readonly supportedReasoningEfforts: readonly ReasoningEffort[];
}
@ -59,37 +68,100 @@ export function resolveRuntimeControls(snapshot: RuntimeSnapshot, config: Runtim
active: snapshot.active.reasoningEffort,
pending: snapshot.pending.reasoningEffort,
});
const approvalsReviewer = resolveRuntimeValue({
const reviewer = resolveRuntimeValue({
configured: config.approvalsReviewer,
active: snapshot.active.approvalsReviewer,
pending: snapshot.pending.approvalsReviewer,
pending: snapshot.pending.permissions.reviewer,
});
const autoReview = resolveAutoReview(approvalsReviewer);
const serviceTiers = findModelMetadataByIdOrName(snapshot.availableModels, model.effective)?.serviceTiers ?? [];
const serviceTier = resolveServiceTier(snapshot, config);
const fastMode = resolveFastMode(snapshot.pending.fastMode, serviceTier, serviceTiers);
const collaborationMode = resolveCollaborationMode(snapshot, model.effective);
const permissions = resolveRuntimePermissions(snapshot, config, reviewer);
const autoReview = resolveAutoReview(permissions.reviewer);
return {
model,
reasoningEffort,
approvalsReviewer,
autoReview,
serviceTier,
fastMode,
collaborationMode,
permissions,
supportedReasoningEfforts: supportedEffortsForModelMetadata(findModelMetadataByIdOrName(snapshot.availableModels, model.effective)),
};
}
function resolveAutoReview(approvalsReviewer: RuntimeLayeredValue<ApprovalsReviewer>): AutoReviewResolution {
function resolveAutoReview(reviewer: RuntimeLayeredValue<ApprovalsReviewer>): AutoReviewResolution {
return {
active: approvalsReviewer.effective === "auto_review" || approvalsReviewer.effective === "guardian_subagent",
reviewer: approvalsReviewer.effective,
source: approvalsReviewer.source,
active: reviewer.effective === "auto_review" || reviewer.effective === "guardian_subagent",
source: reviewer.source,
};
}
function resolveRuntimePermissions(
snapshot: RuntimeSnapshot,
config: RuntimeConfigSnapshot,
reviewer: RuntimeLayeredValue<ApprovalsReviewer>,
): RuntimePermissionsResolution {
const configured = cloneRuntimePermissionState(config.startupPermissions);
const scope = snapshot.activeThreadId ? "current-thread" : "new-thread";
const baseSource = snapshot.activeThreadId ? "active-thread" : "config";
if (snapshot.activeThreadId) {
const active = cloneRuntimePermissionState(snapshot.active);
const { effective, source } = resolveRuntimePermissionState(active, configured, snapshot.pending.permissions, baseSource);
return {
scope,
configured,
active,
effective,
reviewer,
source,
};
}
const { effective, source } = resolveRuntimePermissionState(configured, configured, snapshot.pending.permissions, baseSource);
return {
scope,
configured,
active: null,
effective,
reviewer,
source,
};
}
function resolveRuntimePermissionState(
base: RuntimePermissionState,
configured: RuntimePermissionState,
pending: RuntimeSnapshot["pending"]["permissions"],
baseSource: RuntimeValueSource,
): Pick<RuntimePermissionsResolution, "effective" | "source"> {
let effective = cloneRuntimePermissionState(base);
let source = baseSource;
const approvalPolicy = runtimePermissionIntentValue(pending.approvalPolicy, configured.approvalPolicy);
if (approvalPolicy !== undefined) {
effective = { ...effective, approvalPolicy };
source = "pending";
}
const configuredPermissionProfile = configured.activePermissionProfile?.id ?? null;
const permissionProfile = runtimePermissionIntentValue(pending.permissionProfile, configuredPermissionProfile);
if (permissionProfile !== undefined) {
effective = {
...effective,
sandboxPolicy: permissionProfile === configuredPermissionProfile ? configured.sandboxPolicy : null,
activePermissionProfile: permissionProfile ? { id: permissionProfile, extends: null } : null,
};
source = "pending";
}
return { effective: cloneRuntimePermissionState(effective), source };
}
function runtimePermissionIntentValue<T>(intent: PendingRuntimeIntent<T>, configured: T | null): T | null | undefined {
if (intent.kind === "set") return intent.value;
if (intent.kind === "resetToConfig") return configured;
return undefined;
}
function resolveRuntimeValue<T>(input: {
configured: T | null | undefined;
active: T | null | undefined;

View file

@ -1,4 +1,9 @@
import { normalizeReasoningEffort, type ReasoningEffort } from "../../../../domain/catalog/metadata";
import {
initialRuntimePermissionState,
type RuntimeApprovalPolicy,
type RuntimePermissionState,
} from "../../../../domain/runtime/permissions";
import { type ApprovalsReviewer, parseServiceTier, type ServiceTier } from "../../../../domain/runtime/policy";
import type { RuntimeSettingsPatch } from "../../../../domain/runtime/thread-settings";
import {
@ -16,7 +21,7 @@ export interface ChatRuntimeState {
readonly pending: PendingRuntimeIntentState;
}
export interface ActiveThreadRuntimeState {
export interface ActiveThreadRuntimeState extends RuntimePermissionState {
readonly serviceTierKnown: boolean;
readonly model: string | null;
readonly reasoningEffort: ReasoningEffort | null;
@ -28,13 +33,20 @@ export interface ActiveThreadRuntimeState {
export interface PendingRuntimeIntentState {
readonly model: PendingRuntimeIntent<string>;
readonly reasoningEffort: PendingRuntimeIntent<ReasoningEffort>;
readonly approvalsReviewer: PendingRuntimeIntent<ApprovalsReviewer>;
readonly permissions: PendingRuntimePermissionIntentState;
readonly collaborationMode: CollaborationModeSelection;
readonly fastMode: PendingRuntimeIntent<RequestedFastMode>;
}
interface PendingRuntimePermissionIntentState {
readonly approvalPolicy: PendingRuntimeIntent<RuntimeApprovalPolicy>;
readonly permissionProfile: PendingRuntimeIntent<string>;
readonly reviewer: PendingRuntimeIntent<ApprovalsReviewer>;
}
export function initialActiveChatRuntimeState(): ActiveThreadRuntimeState {
return {
...initialRuntimePermissionState(),
serviceTierKnown: false,
model: null,
reasoningEffort: null,
@ -56,12 +68,20 @@ function initialPendingRuntimeIntentState(): PendingRuntimeIntentState {
return {
model: unchangedRuntimeIntent(),
reasoningEffort: unchangedRuntimeIntent(),
approvalsReviewer: unchangedRuntimeIntent(),
permissions: initialPendingRuntimePermissionIntentState(),
collaborationMode: "default",
fastMode: unchangedRuntimeIntent(),
};
}
function initialPendingRuntimePermissionIntentState(): PendingRuntimePermissionIntentState {
return {
approvalPolicy: unchangedRuntimeIntent(),
permissionProfile: unchangedRuntimeIntent(),
reviewer: unchangedRuntimeIntent(),
};
}
export function initialChatRuntimeState(): ChatRuntimeState {
return {
active: initialActiveChatRuntimeState(),
@ -114,14 +134,20 @@ export function clearRequestedFastModeRuntimeState(state: ChatRuntimeState): Cha
export function requestApprovalsReviewerRuntimeState(state: ChatRuntimeState, approvalsReviewer: ApprovalsReviewer): ChatRuntimeState {
return {
...state,
pending: { ...state.pending, approvalsReviewer: setRuntimeIntentValue(approvalsReviewer) },
pending: {
...state.pending,
permissions: { ...state.pending.permissions, reviewer: setRuntimeIntentValue(approvalsReviewer) },
},
};
}
export function clearRequestedApprovalsReviewerRuntimeState(state: ChatRuntimeState): ChatRuntimeState {
return {
...state,
pending: { ...state.pending, approvalsReviewer: unchangedRuntimeIntent() },
pending: {
...state.pending,
permissions: { ...state.pending.permissions, reviewer: unchangedRuntimeIntent() },
},
};
}
@ -144,6 +170,7 @@ export function commitAppliedRuntimeSettingsPatchState(state: ChatRuntimeState,
...("effort" in update ? { reasoningEffort: normalizeReasoningEffort(update.effort) } : {}),
...("serviceTier" in update ? { serviceTier: parseServiceTier(update.serviceTier), serviceTierKnown: true } : {}),
...("approvalsReviewer" in update ? { approvalsReviewer: update.approvalsReviewer ?? null } : {}),
...("approvalPolicy" in update ? { approvalPolicy: update.approvalPolicy ?? null } : {}),
...(update.collaborationMode ? { collaborationMode: update.collaborationMode.mode } : {}),
},
pending: {
@ -151,7 +178,12 @@ export function commitAppliedRuntimeSettingsPatchState(state: ChatRuntimeState,
...("model" in update ? { model: unchangedRuntimeIntent<string>() } : {}),
...("effort" in update ? { reasoningEffort: unchangedRuntimeIntent<ReasoningEffort>() } : {}),
...("serviceTier" in update ? { fastMode: unchangedRuntimeIntent<RequestedFastMode>() } : {}),
...("approvalsReviewer" in update ? { approvalsReviewer: unchangedRuntimeIntent<ApprovalsReviewer>() } : {}),
permissions: {
...state.pending.permissions,
...("approvalPolicy" in update ? { approvalPolicy: unchangedRuntimeIntent<RuntimeApprovalPolicy>() } : {}),
...("permissions" in update ? { permissionProfile: unchangedRuntimeIntent<string>() } : {}),
...("approvalsReviewer" in update ? { reviewer: unchangedRuntimeIntent<ApprovalsReviewer>() } : {}),
},
},
};
}

View file

@ -60,11 +60,25 @@ export function pendingRuntimeSettingsPatch(snapshot: RuntimeSnapshot, config: R
"serviceTier",
runtimeSettingsPatchValue(serviceTierPatchIntent(snapshot, resolution, "thread-update")),
);
if (snapshot.pending.approvalsReviewer.kind !== "unchanged") {
if (snapshot.pending.permissions.approvalPolicy.kind !== "unchanged") {
applyRuntimeSettingsPatchValue(
update,
"approvalPolicy",
runtimeSettingsPatchValue(runtimeSettingsPatchIntentFromPending(snapshot.pending.permissions.approvalPolicy)),
);
}
if (snapshot.pending.permissions.permissionProfile.kind !== "unchanged") {
applyRuntimeSettingsPatchValue(
update,
"permissions",
runtimeSettingsPatchValue(runtimeSettingsPatchIntentFromPending(snapshot.pending.permissions.permissionProfile)),
);
}
if (snapshot.pending.permissions.reviewer.kind !== "unchanged") {
applyRuntimeSettingsPatchValue(
update,
"approvalsReviewer",
runtimeSettingsPatchValue(runtimeSettingsPatchIntentFromPending(snapshot.pending.approvalsReviewer)),
runtimeSettingsPatchValue(runtimeSettingsPatchIntentFromPending(snapshot.pending.permissions.reviewer)),
);
}
if (resolution.collaborationMode.dirty) {

View file

@ -6,6 +6,7 @@ import type { Thread } from "../../../../domain/threads/model";
import { threadRowCoreProjection } from "../../../threads/list/row-projection";
import type { RuntimeSnapshot } from "../../domain/runtime/snapshot";
import { appServerDiagnosticSections } from "../../presentation/runtime/diagnostic-sections";
import { runtimePermissionDetails } from "../../presentation/runtime/permission-sections";
import { rateLimitSummary } from "../../presentation/runtime/status";
import { toolInventoryDiagnosticSections } from "../../presentation/runtime/tool-inventory-diagnostic-sections";
import { Toolbar, type ToolbarActions, type ToolbarThreadRow, type ToolbarViewModel } from "../../ui/toolbar";
@ -75,6 +76,10 @@ function chatPanelToolbarProjection(input: ToolbarViewModelInput): ToolbarViewMo
const projection = toolbarStateProjection(input);
const limit = rateLimitSummary(snapshot, input.nowMs);
const diagnostics = model.diagnostics.value;
const permissions = runtimePermissionDetails({
snapshot,
vaultPath: input.vaultPath,
});
return {
newChatDisabled: projection.newChatDisabled,
chatActionsOpen: projection.chatActionsOpen,
@ -85,6 +90,8 @@ function chatPanelToolbarProjection(input: ToolbarViewModelInput): ToolbarViewMo
openPanel: projection.openPanel,
threads: projection.threads,
connectLabel: input.connected ? "Reconnect" : "Connect",
runtimePermissionsTitle: permissions.title,
runtimePermissions: permissions.sections,
diagnostics: appServerDiagnosticSections({
connected: input.connected,
configuredCommand: input.configuredCommand,

View file

@ -0,0 +1,113 @@
import { runtimeConfigOrDefault } from "../../../../domain/runtime/config";
import type { RuntimePermissionState, RuntimeSandboxPolicy } from "../../../../domain/runtime/permissions";
import { resolveRuntimeControls } from "../../domain/runtime/resolution";
import type { RuntimeSnapshot } from "../../domain/runtime/snapshot";
interface RuntimePermissionRow {
label: string;
value: string;
}
interface RuntimePermissionSection {
title: string;
rows: RuntimePermissionRow[];
}
interface RuntimePermissionDetails {
title: string;
sections: RuntimePermissionSection[];
}
interface RuntimePermissionSectionsInput {
snapshot: RuntimeSnapshot;
vaultPath: string;
}
export function runtimePermissionDetails(input: RuntimePermissionSectionsInput): RuntimePermissionDetails {
const config = runtimeConfigOrDefault(input.snapshot.runtimeConfig);
const resolution = resolveRuntimeControls(input.snapshot, config);
return {
title: permissionPanelTitle(resolution.permissions.scope),
sections: [
{
title: "",
rows: permissionRows(resolution.permissions.effective, resolution.permissions.reviewer.effective, input.vaultPath),
},
],
};
}
function permissionPanelTitle(scope: "new-thread" | "current-thread"): string {
return scope === "current-thread" ? "Permissions: Current Thread" : "Permissions: New Thread";
}
function permissionRows(permissions: RuntimePermissionState, reviewer: string | null, vaultPath: string): RuntimePermissionRow[] {
return [
{ label: "Profile", value: profileLabel(permissions) },
...extendsRows(permissions),
{ label: "Sandbox", value: sandboxLabel(permissions.sandboxPolicy) },
{ label: "Network", value: networkLabel(permissions.sandboxPolicy) },
{ label: "Extra writable roots", value: writableRootsLabel(permissions.sandboxPolicy, vaultPath) },
{ label: "Approval policy", value: approvalPolicyLabel(permissions.approvalPolicy) },
{
label: "Reviewer",
value: reviewer ?? "(Codex default)",
},
];
}
function profileLabel(permissions: RuntimePermissionState): string {
if (permissions.activePermissionProfile) return permissions.activePermissionProfile.id;
if (permissions.sandboxPolicy) return "(legacy sandbox)";
return "(not reported)";
}
function extendsRows(permissions: RuntimePermissionState): RuntimePermissionRow[] {
const extendsProfile = permissions.activePermissionProfile?.extends;
return extendsProfile ? [{ label: "Extends", value: extendsProfile }] : [];
}
function sandboxLabel(sandbox: RuntimeSandboxPolicy | null): string {
if (!sandbox) return "(not reported)";
switch (sandbox.type) {
case "dangerFullAccess":
return "danger-full-access";
case "readOnly":
return "read-only";
case "workspaceWrite":
return "workspace-write";
case "externalSandbox":
return "external sandbox";
}
}
function networkLabel(sandbox: RuntimeSandboxPolicy | null): string {
if (!sandbox) return "(not reported)";
if (sandbox.type === "dangerFullAccess") return "allowed";
if (sandbox.type === "externalSandbox") return sandbox.networkAccess;
return sandbox.networkAccess ? "allowed" : "blocked";
}
function writableRootsLabel(sandbox: RuntimeSandboxPolicy | null, vaultPath: string): string {
if (!sandbox) return "(not reported)";
if (sandbox.type === "dangerFullAccess") return "all files";
if (sandbox.type !== "workspaceWrite") return "-";
if (sandbox.writableRoots.length === 0) return "(none)";
return sandbox.writableRoots.map((root) => displayRoot(root, vaultPath)).join(", ");
}
function displayRoot(root: string, vaultPath: string): string {
if (root === vaultPath) return "Vault";
if (vaultPath && root.startsWith(`${vaultPath}/`)) return `Vault/${root.slice(vaultPath.length + 1)}`;
return root;
}
function approvalPolicyLabel(policy: RuntimePermissionState["approvalPolicy"]): string {
if (!policy) return "(not reported)";
if (typeof policy === "string") return policy;
const enabled = Object.entries(policy.granular)
.filter(([, value]) => value)
.map(([key]) => key.replace(/_/g, " "));
return enabled.length ? `granular: ${enabled.join(", ")}` : "granular";
}

View file

@ -21,15 +21,15 @@ export interface ToolbarThreadRow {
} | null;
}
interface ToolbarDiagnosticRow {
interface ToolbarStatusRow {
label: string;
value: string;
level?: "normal" | "warning" | "error";
}
interface ToolbarDiagnosticSection {
interface ToolbarStatusSection {
title: string;
rows: ToolbarDiagnosticRow[];
rows: ToolbarStatusRow[];
}
export interface ToolbarViewModel {
@ -42,8 +42,10 @@ export interface ToolbarViewModel {
openPanel: "history" | "chat-actions" | "status" | null;
threads: ToolbarThreadRow[];
connectLabel: string;
diagnostics: ToolbarDiagnosticSection[];
toolInventory: ToolbarDiagnosticSection[];
runtimePermissionsTitle: string;
runtimePermissions: ToolbarStatusSection[];
diagnostics: ToolbarStatusSection[];
toolInventory: ToolbarStatusSection[];
}
interface ToolbarPrimaryActions {
@ -194,6 +196,7 @@ function StatusPanel({ model, actions }: { model: ToolbarViewModel; actions: Too
</div>
<RateLimitPanel rateLimit={model.rateLimit} />
<DiagnosticSectionsPanel title="Connection" sections={model.diagnostics} />
<DiagnosticSectionsPanel title={model.runtimePermissionsTitle} sections={model.runtimePermissions} />
<DiagnosticSectionsPanel title="Codex capabilities" sections={model.toolInventory} />
</>
);
@ -247,7 +250,7 @@ function RateLimitPanel({ rateLimit }: { rateLimit: RateLimitSummary | null }):
);
}
function DiagnosticSectionsPanel({ title, sections }: { title: string; sections: ToolbarDiagnosticSection[] }): UiNode {
function DiagnosticSectionsPanel({ title, sections }: { title: string; sections: ToolbarStatusSection[] }): UiNode {
return (
<div className="codex-panel__connection-diagnostics">
<div className="codex-panel__connection-diagnostics-title">{title}</div>
@ -258,7 +261,7 @@ function DiagnosticSectionsPanel({ title, sections }: { title: string; sections:
);
}
function DiagnosticRows({ rows }: { rows: ToolbarDiagnosticRow[] }): UiNode {
function DiagnosticRows({ rows }: { rows: ToolbarStatusRow[] }): UiNode {
return (
<dl className="codex-panel__connection-diagnostics-list">
{rows.map((row) => (
@ -268,16 +271,16 @@ function DiagnosticRows({ rows }: { rows: ToolbarDiagnosticRow[] }): UiNode {
);
}
function DiagnosticSection({ section }: { section: ToolbarDiagnosticSection }): UiNode {
function DiagnosticSection({ section }: { section: ToolbarStatusSection }): UiNode {
return (
<>
<div className="codex-panel__connection-diagnostics-section">{section.title}</div>
{section.title ? <div className="codex-panel__connection-diagnostics-section">{section.title}</div> : null}
<DiagnosticRows rows={section.rows} />
</>
);
}
function DiagnosticRow({ row }: { row: ToolbarDiagnosticRow }): UiNode {
function DiagnosticRow({ row }: { row: ToolbarStatusRow }): UiNode {
const level = row.level ?? "normal";
return (
<div

View file

@ -17,6 +17,9 @@ describe("app-server thread activation", () => {
serviceTier: "fast",
approvalsReviewer: "user",
reasoningEffort: "high",
approvalPolicy: "on-request",
sandboxPolicy: { type: "readOnly", networkAccess: false },
activePermissionProfile: null,
});
});
});

View file

@ -54,6 +54,7 @@ describe("app-server thread settings", () => {
},
},
});
expect(appServerRuntimeSettingsPatch({ permissions: ":workspace" })).toEqual({ permissions: ":workspace" });
});
it.each([

View file

@ -105,7 +105,7 @@ describe("chat app-server actions", () => {
expect(stateStore.getState().runtime.pending.model).toEqual({ kind: "set", value: "gpt-5.5" });
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" });
expect(stateStore.getState().runtime.pending.permissions.reviewer).toEqual({ kind: "set", value: "auto_review" });
expect(stateStore.getState().runtime.pending.collaborationMode).toBe("plan");
});

View file

@ -1703,6 +1703,9 @@ describe("ChatInboundHandler", () => {
expect(handler.currentState().runtime.active.model).toBe("gpt-5.5");
expect(handler.currentState().runtime.active.serviceTier).toBe("fast");
expect(handler.currentState().runtime.active.approvalsReviewer).toBe("auto_review");
expect(handler.currentState().runtime.active.approvalPolicy).toBe("on-request");
expect(handler.currentState().runtime.active.sandboxPolicy).toEqual({ type: "readOnly", networkAccess: false });
expect(handler.currentState().runtime.active.activePermissionProfile).toBeNull();
expect(chatStateMessageStreamItems(handler.currentState())).toEqual([]);
});
@ -1713,6 +1716,9 @@ describe("ChatInboundHandler", () => {
state = chatStateWith(state, { runtime: { active: { model: "gpt-active" } } });
state = chatStateWith(state, { runtime: { active: { serviceTier: "flex" } } });
state = chatStateWith(state, { runtime: { active: { approvalsReviewer: "user" } } });
state = chatStateWith(state, { runtime: { active: { approvalPolicy: "on-request" } } });
state = chatStateWith(state, { runtime: { active: { sandboxPolicy: { type: "readOnly", networkAccess: false } } } });
state = chatStateWith(state, { runtime: { active: { activePermissionProfile: null } } });
const handler = handlerForState(state);
handler.handleNotification({
@ -1744,6 +1750,9 @@ describe("ChatInboundHandler", () => {
expect(handler.currentState().runtime.active.model).toBe("gpt-active");
expect(handler.currentState().runtime.active.serviceTier).toBe("flex");
expect(handler.currentState().runtime.active.approvalsReviewer).toBe("user");
expect(handler.currentState().runtime.active.approvalPolicy).toBe("on-request");
expect(handler.currentState().runtime.active.sandboxPolicy).toEqual({ type: "readOnly", networkAccess: false });
expect(handler.currentState().runtime.active.activePermissionProfile).toBeNull();
});
it("syncs null service tier from settings notifications", () => {

View file

@ -8,6 +8,9 @@ function createHost(overrides: Partial<ChatReconnectActionsHost> = {}) {
stateStore.dispatch({ type: "ui/panel-set", panel: "history" });
stateStore.dispatch({
type: "active-thread/resumed",
approvalPolicy: null,
sandboxPolicy: null,
activePermissionProfile: null,
thread: { id: "thread" } as never,
cwd: "/vault",
model: null,

View file

@ -151,6 +151,9 @@ describe("submitComposer", () => {
const { host, interruptTurn, showLatest, stateStore } = createHost("");
stateStore.dispatch({
type: "active-thread/resumed",
approvalPolicy: null,
sandboxPolicy: null,
activePermissionProfile: null,
thread: thread("thread"),
cwd: "/vault",
model: null,

View file

@ -30,6 +30,9 @@ const streamingPlanItem = (id: string): MessageStreamItem => ({
function resumeThread(stateStore: ChatStateStore, items: readonly MessageStreamItem[]): void {
stateStore.dispatch({
type: "active-thread/resumed",
approvalPolicy: null,
sandboxPolicy: null,
activePermissionProfile: null,
thread: { id: "thread", cliVersion: "test" } as never,
cwd: "/vault",
model: null,

View file

@ -89,6 +89,9 @@ describe("executeSlashCommandWithState", () => {
});
stateStore.dispatch({
type: "active-thread/resumed",
approvalPolicy: null,
sandboxPolicy: null,
activePermissionProfile: null,
thread: thread("thread", "Thread"),
cwd: "/vault",
model: null,
@ -106,6 +109,9 @@ describe("executeSlashCommandWithState", () => {
const { compactThread, host, stateStore } = createHost({ connectionAvailable: () => false });
stateStore.dispatch({
type: "active-thread/resumed",
approvalPolicy: null,
sandboxPolicy: null,
activePermissionProfile: null,
thread: thread("thread", "Thread"),
cwd: "/vault",
model: null,

View file

@ -60,6 +60,9 @@ function createHost(overrides: TurnSubmissionHostOverrides = {}) {
function resumeThread(stateStore: ReturnType<typeof createChatStateStore>) {
stateStore.dispatch({
type: "active-thread/resumed",
approvalPolicy: null,
sandboxPolicy: null,
activePermissionProfile: null,
thread: thread("thread"),
cwd: "/vault",
model: null,

View file

@ -52,7 +52,7 @@ describe("createChatRuntimeSettingsActions", () => {
expect(store.getState().runtime.pending.model).toEqual({ kind: "set", value: "gpt-5.5" });
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" });
expect(store.getState().runtime.pending.permissions.reviewer).toEqual({ kind: "set", value: "auto_review" });
expect(store.getState().runtime.pending.collaborationMode).toBe("default");
expect(messages).toEqual([
"Fast mode on for subsequent turns.",
@ -425,6 +425,9 @@ function threadSettings(
collaborationMode: "default",
serviceTier,
approvalsReviewer,
approvalPolicy: null,
sandboxPolicy: null,
activePermissionProfile: null,
};
}

View file

@ -18,6 +18,15 @@ describe("chat thread resume helpers", () => {
serviceTier: "fast",
serviceTierKnown: true,
approvalsReviewer: "user",
approvalPolicy: "on-request",
sandboxPolicy: {
type: "workspaceWrite",
writableRoots: ["/vault"],
networkAccess: false,
excludeTmpdirEnvVar: false,
excludeSlashTmp: false,
},
activePermissionProfile: { id: ":workspace", extends: null },
},
listedThreads: [existing],
items: [loading],
@ -32,6 +41,15 @@ describe("chat thread resume helpers", () => {
serviceTier: "fast",
serviceTierKnown: true,
approvalsReviewer: "user",
approvalPolicy: "on-request",
sandboxPolicy: {
type: "workspaceWrite",
writableRoots: ["/vault"],
networkAccess: false,
excludeTmpdirEnvVar: false,
excludeSlashTmp: false,
},
activePermissionProfile: { id: ":workspace", extends: null },
items: [loading],
});
expect(action.listedThreads?.map((thread) => thread.id)).toEqual(["thread", "existing"]);
@ -47,6 +65,15 @@ describe("chat thread resume helpers", () => {
expect(action).toMatchObject({
type: "active-thread/resumed",
approvalPolicy: "on-request",
sandboxPolicy: {
type: "workspaceWrite",
writableRoots: ["/vault"],
networkAccess: false,
excludeTmpdirEnvVar: false,
excludeSlashTmp: false,
},
activePermissionProfile: { id: ":workspace", extends: null },
thread: resumed,
preserveRequestedRuntimeSettings: true,
});
@ -62,6 +89,15 @@ function responseFixture(thread: Thread): ThreadActivationSnapshot {
cwd: "/vault",
approvalsReviewer: "user",
reasoningEffort: "high",
approvalPolicy: "on-request",
sandboxPolicy: {
type: "workspaceWrite",
writableRoots: ["/vault"],
networkAccess: false,
excludeTmpdirEnvVar: false,
excludeSlashTmp: false,
},
activePermissionProfile: { id: ":workspace", extends: null },
};
}

View file

@ -55,7 +55,7 @@ describe("chatReducer", () => {
expect(next.runtime.pending.model).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" });
expect(next.runtime.pending.permissions.reviewer).toEqual({ kind: "unchanged" });
expect(next.runtime.pending.collaborationMode).toBe("default");
expect(activeTurnId(next)).toBeNull();
expect(chatStateMessageStreamItems(next)).toEqual([]);
@ -102,6 +102,9 @@ describe("chatReducer", () => {
const next = chatReducer(state, {
type: "active-thread/resumed",
approvalPolicy: null,
sandboxPolicy: null,
activePermissionProfile: null,
thread: thread("resumed-thread"),
cwd: "/vault",
model: "gpt-5.1",
@ -142,6 +145,9 @@ describe("chatReducer", () => {
const next = chatReducer(state, {
type: "active-thread/resumed",
approvalPolicy: null,
sandboxPolicy: null,
activePermissionProfile: null,
thread: thread("started-thread"),
cwd: "/vault",
model: "gpt-5",
@ -159,7 +165,7 @@ describe("chatReducer", () => {
expect(next.runtime.pending.model).toEqual({ kind: "set", value: "gpt-5.5" });
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" });
expect(next.runtime.pending.permissions.reviewer).toEqual({ kind: "set", value: "auto_review" });
expect(next.runtime.pending.collaborationMode).toBe("plan");
expect(next.runtime.active.collaborationMode).toBeNull();
});
@ -170,6 +176,9 @@ describe("chatReducer", () => {
const next = chatReducer(state, {
type: "active-thread/resumed",
approvalPolicy: null,
sandboxPolicy: null,
activePermissionProfile: null,
thread: thread("resumed-thread"),
cwd: "/vault",
model: null,
@ -620,7 +629,7 @@ describe("chatReducer", () => {
expect(next.runtime.active.serviceTierKnown).toBe(true);
expect(next.runtime.pending.fastMode).toEqual({ kind: "unchanged" });
expect(next.runtime.active.approvalsReviewer).toBe("auto_review");
expect(next.runtime.pending.approvalsReviewer).toEqual({ kind: "unchanged" });
expect(next.runtime.pending.permissions.reviewer).toEqual({ kind: "unchanged" });
expect(next.runtime.active.collaborationMode).toBe("plan");
});
@ -641,6 +650,9 @@ describe("chatReducer", () => {
it("preserves unknown service tier state when resuming from active runtime", () => {
const state = chatReducer(chatStateFixture(), {
type: "active-thread/resumed",
approvalPolicy: null,
sandboxPolicy: null,
activePermissionProfile: null,
thread: thread("thread"),
cwd: "/vault",
model: "gpt-5.1",
@ -664,7 +676,7 @@ describe("chatReducer", () => {
expect(state.runtime.pending.fastMode).toEqual({ kind: "set", value: "enabled" });
expect(state.runtime.active.serviceTier).toBe("flex");
expect(state.runtime.pending.approvalsReviewer).toEqual({ kind: "set", value: "auto_review" });
expect(state.runtime.pending.permissions.reviewer).toEqual({ kind: "set", value: "auto_review" });
expect(state.runtime.active.approvalsReviewer).toBe("user");
});
@ -683,7 +695,7 @@ describe("chatReducer", () => {
expect(state.runtime.pending.model).toEqual({ kind: "resetToConfig" });
expect(state.runtime.pending.reasoningEffort).toEqual({ kind: "resetToConfig" });
expect(state.runtime.pending.fastMode).toEqual({ kind: "unchanged" });
expect(state.runtime.pending.approvalsReviewer).toEqual({ kind: "unchanged" });
expect(state.runtime.pending.permissions.reviewer).toEqual({ kind: "unchanged" });
});
it("stores updates through ChatStateStore without mutating the initial snapshot", () => {
@ -703,6 +715,9 @@ describe("chatReducer", () => {
panelA.dispatch({
type: "active-thread/resumed",
approvalPolicy: null,
sandboxPolicy: null,
activePermissionProfile: null,
thread: thread("thread-a"),
cwd: "/vault",
model: null,
@ -716,6 +731,9 @@ describe("chatReducer", () => {
panelB.dispatch({
type: "active-thread/resumed",
approvalPolicy: null,
sandboxPolicy: null,
activePermissionProfile: null,
thread: thread("thread-b"),
cwd: "/vault",
model: null,

View file

@ -44,6 +44,9 @@ describe("createActiveThreadIdentitySync", () => {
const { controller, host, restoredClear, stateStore } = createController();
stateStore.dispatch({
type: "active-thread/resumed",
approvalPolicy: null,
sandboxPolicy: null,
activePermissionProfile: null,
thread: thread("thread"),
cwd: "/vault",
model: null,
@ -66,6 +69,9 @@ describe("createActiveThreadIdentitySync", () => {
const { controller, host, restoredClear, stateStore } = createController();
stateStore.dispatch({
type: "active-thread/resumed",
approvalPolicy: null,
sandboxPolicy: null,
activePermissionProfile: null,
thread: thread("active"),
cwd: "/vault",
model: null,
@ -101,6 +107,9 @@ describe("createActiveThreadIdentitySync", () => {
const { controller, host, restoredRename, stateStore } = createController();
stateStore.dispatch({
type: "active-thread/resumed",
approvalPolicy: null,
sandboxPolicy: null,
activePermissionProfile: null,
thread: thread("thread", "Old"),
cwd: "/vault",
model: null,

View file

@ -187,6 +187,9 @@ describe("createGoalActions", () => {
const startThread = vi.fn().mockImplementation(async () => {
stateStore.dispatch({
type: "active-thread/resumed",
approvalPolicy: null,
sandboxPolicy: null,
activePermissionProfile: null,
thread: { id: "thread-new", name: null, preview: "Plan release", archived: false, createdAt: 1, updatedAt: 1 },
cwd: "/vault",
model: null,

View file

@ -24,6 +24,9 @@ function activation(threadId: string, overrides: Partial<ThreadResumeSnapshot> =
serviceTier: null,
approvalsReviewer: "user",
reasoningEffort: null,
approvalPolicy: null,
sandboxPolicy: null,
activePermissionProfile: null,
},
rolloutPath: null,
initialHistoryPage: null,
@ -117,6 +120,9 @@ describe("ResumeActions", () => {
const { actions, host, resumeThread, stateStore } = createActions();
stateStore.dispatch({
type: "active-thread/resumed",
approvalPolicy: null,
sandboxPolicy: null,
activePermissionProfile: null,
thread: panelThread("active"),
cwd: "/vault",
model: null,
@ -159,6 +165,9 @@ describe("ResumeActions", () => {
await actions.resumeThread("thread");
stateStore.dispatch({
type: "active-thread/resumed",
approvalPolicy: null,
sandboxPolicy: null,
activePermissionProfile: null,
thread: second.activation.thread,
cwd: "/vault",
model: null,

View file

@ -82,6 +82,9 @@ describe("thread management actions", () => {
host.threadTransport.compactThread.mockReturnValue(compact.promise);
host.stateStore.dispatch({
type: "active-thread/resumed",
approvalPolicy: null,
sandboxPolicy: null,
activePermissionProfile: null,
thread: panelThread("source"),
cwd: "/vault",
model: null,
@ -97,6 +100,9 @@ describe("thread management actions", () => {
});
host.stateStore.dispatch({
type: "active-thread/resumed",
approvalPolicy: null,
sandboxPolicy: null,
activePermissionProfile: null,
thread: panelThread("other"),
cwd: "/vault",
model: null,
@ -235,6 +241,9 @@ describe("thread management actions", () => {
host.threadTransport.forkThread.mockReturnValue(fork.promise);
host.stateStore.dispatch({
type: "active-thread/resumed",
approvalPolicy: null,
sandboxPolicy: null,
activePermissionProfile: null,
thread: panelThread("source"),
cwd: "/vault",
model: null,
@ -255,6 +264,9 @@ describe("thread management actions", () => {
});
host.stateStore.dispatch({
type: "active-thread/resumed",
approvalPolicy: null,
sandboxPolicy: null,
activePermissionProfile: null,
thread: panelThread("other"),
cwd: "/vault",
model: null,
@ -313,6 +325,9 @@ describe("thread management actions", () => {
const host = hostMock({ items: turnItems() });
host.stateStore.dispatch({
type: "active-thread/resumed",
approvalPolicy: null,
sandboxPolicy: null,
activePermissionProfile: null,
thread: panelThread("source"),
cwd: "/vault",
model: null,
@ -345,6 +360,9 @@ describe("thread management actions", () => {
host.threadTransport.rollbackThread.mockReturnValue(rollback.promise);
host.stateStore.dispatch({
type: "active-thread/resumed",
approvalPolicy: null,
sandboxPolicy: null,
activePermissionProfile: null,
thread: panelThread("source"),
cwd: "/vault",
model: null,
@ -366,6 +384,9 @@ describe("thread management actions", () => {
});
host.stateStore.dispatch({
type: "active-thread/resumed",
approvalPolicy: null,
sandboxPolicy: null,
activePermissionProfile: null,
thread: panelThread("other"),
cwd: "/vault",
model: null,
@ -391,6 +412,9 @@ describe("thread management actions", () => {
});
host.stateStore.dispatch({
type: "active-thread/resumed",
approvalPolicy: null,
sandboxPolicy: null,
activePermissionProfile: null,
thread: panelThread("source"),
cwd: "/vault",
model: null,

View file

@ -11,6 +11,9 @@ import {
function resumeThreadState(stateStore: ChatStateStore, threadId: string): void {
stateStore.dispatch({
type: "active-thread/resumed",
approvalPolicy: null,
sandboxPolicy: null,
activePermissionProfile: null,
thread: { id: threadId, cliVersion: "test" } as never,
cwd: "/vault",
model: null,

View file

@ -11,6 +11,9 @@ describe("createReconnectAction", () => {
const stateStore = createChatStateStore();
stateStore.dispatch({
type: "active-thread/resumed",
approvalPolicy: null,
sandboxPolicy: null,
activePermissionProfile: null,
thread: { id: "thread-1" } as never,
cwd: "/vault",
model: null,

View file

@ -158,6 +158,9 @@ describe("createChatPanelSessionGraph actions", () => {
});
stateStore.dispatch({
type: "active-thread/resumed",
approvalPolicy: null,
sandboxPolicy: null,
activePermissionProfile: null,
thread: threadFixture({ id: "thread-1", preview: "Active" }),
cwd: "/vault",
model: null,

View file

@ -42,6 +42,9 @@ describe("MessageStreamPresenter scroll pinning", () => {
const store = createChatStateStore(chatStateFixture());
store.dispatch({
type: "active-thread/resumed",
approvalPolicy: null,
sandboxPolicy: null,
activePermissionProfile: null,
thread: { id: "thread-1", preview: "", archived: false, createdAt: 1, updatedAt: 1, name: "Thread" },
cwd: "/repo",
model: null,

View file

@ -111,6 +111,49 @@ describe("chat panel surface projections", () => {
unmountUiRoot(parent);
});
it("renders new thread permission baseline in the status panel before a thread is active", () => {
let state = chatStateFixture();
state = chatStateWith(state, { ui: { toolbarPanel: "status-panel" } });
state = chatStateWith(state, {
connection: {
runtimeConfig: runtimeConfigFixture({
default_permissions: ":workspace",
approval_policy: "on-request",
approvals_reviewer: "user",
}),
},
});
state = chatStateWith(state, {
runtime: {
pending: {
permissions: {
approvalPolicy: { kind: "unchanged" },
permissionProfile: { kind: "unchanged" },
reviewer: { kind: "set", value: "auto_review" },
},
},
},
});
const parent = renderWithShellReadModel(state, (readModel) =>
h(ChatPanelToolbar, {
model: readModel.toolbar,
surface: toolbarSurfaceFixture(),
actions: toolbarActionsFixture(),
}),
);
expect(parent.textContent).toContain("Permissions: New Thread");
expect(
[...parent.querySelectorAll(".codex-panel__connection-diagnostics-section")].map((section) => section.textContent),
).not.toContain("New thread");
expect(parent.textContent).toContain(":workspace");
expect(parent.textContent).toContain("on-request");
expect(parent.textContent).toContain("auto_review");
expect(parent.textContent).not.toContain("user -> auto_review");
unmountUiRoot(parent);
});
it("builds composer meta from context and runtime state", () => {
let state = chatStateFixture();
state = chatStateWith(state, { activeThread: { id: "thread-1" } });

View file

@ -167,7 +167,11 @@ describe("Toolbar decisions", () => {
toolbarActions({ refreshStatus }),
);
expect(parent.querySelector(".codex-panel__connection-diagnostics-title")?.textContent).toBe("Connection");
expect([...parent.querySelectorAll(".codex-panel__connection-diagnostics-title")].map((title) => title.textContent)).toEqual([
"Connection",
"Permissions: Current Thread",
"Codex capabilities",
]);
expect(parent.textContent).toContain("Codex capabilities");
expect(parent.textContent).toContain("Tool providers");
expect(parent.textContent).toContain("Process");
@ -186,6 +190,44 @@ describe("Toolbar decisions", () => {
expect(refreshStatus).toHaveBeenCalled();
});
it("renders runtime permissions in the status menu without severity styling", () => {
const parent = document.createElement("div");
mountToolbar(
parent,
toolbarModel({
statusPanelOpen: true,
openPanel: "status",
runtimePermissionsTitle: "Permissions: Current Thread",
runtimePermissions: [
{
title: "",
rows: [
{ label: "Profile", value: ":workspace" },
{ label: "Sandbox", value: "workspace-write" },
{ label: "Network", value: "blocked" },
{ label: "Extra writable roots", value: "Vault" },
{ label: "Approval policy", value: "on-request" },
{ label: "Reviewer", value: "auto_review" },
],
},
],
toolInventory: [{ title: "Tool providers", rows: [{ label: "Tool providers", value: "loaded" }] }],
}),
toolbarActions(),
);
expect(parent.textContent).toContain("Permissions: Current Thread");
expect(
[...parent.querySelectorAll(".codex-panel__connection-diagnostics-section")].map((section) => section.textContent),
).not.toContain("Current thread");
expect(parent.textContent).toContain(":workspace");
expect(parent.textContent).toContain("workspace-write");
expect(parent.textContent).toContain("auto_review");
expect(parent.querySelector(".codex-panel__connection-diagnostics-row--warning")).toBeNull();
expect(parent.querySelector(".codex-panel__connection-diagnostics-row--error")).toBeNull();
});
it("copies raw debug details from the status menu", () => {
const parent = document.createElement("div");
const copyDebugDetails = vi.fn();
@ -362,6 +404,8 @@ function toolbarModel(overrides: Partial<ToolbarViewModel> = {}): ToolbarViewMod
openPanel: null,
threads: [{ title: "Thread", threadId: "thread", selected: true, disabled: false, canArchive: true, rename: null }],
connectLabel: "Reconnect",
runtimePermissionsTitle: "Permissions: Current Thread",
runtimePermissions: [{ title: "", rows: [{ label: "Thread", value: "(none)" }] }],
diagnostics: [{ title: "Process", rows: [{ label: "Codex App Server", value: "codex-cli/test" }] }],
toolInventory: [{ title: "Tool providers", rows: [{ label: "Tool providers", value: "not loaded", level: "warning" }] }],
...overrides,

View file

@ -31,6 +31,202 @@ describe("runtime settings", () => {
expect(compactReasoningEffortLabel(null)).toBe("default");
});
it("keeps startup permission defaults in the runtime config snapshot", () => {
expect(
runtimeConfigFixture({
default_permissions: ":workspace",
approval_policy: "on-request",
approvals_reviewer: "auto_review",
}),
).toMatchObject({
approvalsReviewer: "auto_review",
startupPermissions: {
activePermissionProfile: { id: ":workspace", extends: null },
approvalPolicy: "on-request",
sandboxPolicy: null,
},
});
});
it("deep-clones granular startup approval policy in runtime config snapshots", () => {
const config = runtimeConfigFixture({
approval_policy: {
granular: {
sandbox_approval: true,
rules: false,
skill_approval: true,
request_permissions: false,
mcp_elicitations: true,
},
},
});
const cloned = runtimeConfigOrDefault(config);
const originalPolicy = config.startupPermissions.approvalPolicy;
const clonedPolicy = cloned.startupPermissions.approvalPolicy;
expect(clonedPolicy).toEqual(originalPolicy);
if (!originalPolicy || typeof originalPolicy === "string" || !clonedPolicy || typeof clonedPolicy === "string") {
throw new Error("expected granular approval policy");
}
expect(clonedPolicy).not.toBe(originalPolicy);
expect(clonedPolicy.granular).not.toBe(originalPolicy.granular);
});
it("uses legacy sandbox config instead of default permissions when both are reported", () => {
expect(
runtimeConfigFixture({
default_permissions: ":workspace",
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: null,
approvalPolicy: "on-request",
sandboxPolicy: {
type: "workspaceWrite",
writableRoots: ["/vault"],
networkAccess: false,
},
},
});
});
it("resolves permissions through runtime controls without falling active threads back to config", () => {
const configured = runtimeSnapshot({
runtimeConfig: runtimeConfigFixture({
default_permissions: ":workspace",
approval_policy: "on-request",
}),
});
expect(resolveRuntimeControls(configured, snapshotConfig(configured)).permissions).toMatchObject({
scope: "new-thread",
source: "config",
active: null,
reviewer: { effective: null, source: "none" },
effective: {
activePermissionProfile: { id: ":workspace", extends: null },
approvalPolicy: "on-request",
sandboxPolicy: null,
},
});
const activeUnreported = runtimeSnapshot({
activeThreadId: "thread",
runtimeConfig: runtimeConfigFixture({
default_permissions: ":workspace",
approval_policy: "on-request",
}),
});
expect(resolveRuntimeControls(activeUnreported, snapshotConfig(activeUnreported)).permissions).toMatchObject({
scope: "current-thread",
source: "active-thread",
reviewer: { effective: null, source: "none" },
effective: {
activePermissionProfile: null,
approvalPolicy: null,
sandboxPolicy: null,
},
});
const activeReported = runtimeSnapshot({
activeThreadId: "thread",
active: {
approvalPolicy: "never",
sandboxPolicy: { type: "readOnly", networkAccess: false },
activePermissionProfile: { id: ":read-only", extends: null },
approvalsReviewer: "user",
},
pending: { permissions: { reviewer: setRuntimeIntentValue("auto_review") } },
});
expect(resolveRuntimeControls(activeReported, snapshotConfig(activeReported)).permissions).toMatchObject({
scope: "current-thread",
source: "active-thread",
reviewer: { effective: "auto_review", source: "pending" },
effective: {
activePermissionProfile: { id: ":read-only", extends: null },
approvalPolicy: "never",
sandboxPolicy: { type: "readOnly", networkAccess: false },
},
});
});
it("keeps permission display scope separate from pending permission source", () => {
const snapshot = runtimeSnapshot({
activeThreadId: "thread",
active: {
approvalPolicy: "on-request",
sandboxPolicy: { type: "readOnly", networkAccess: false },
activePermissionProfile: null,
},
pending: {
permissions: {
approvalPolicy: setRuntimeIntentValue("never"),
permissionProfile: setRuntimeIntentValue(":workspace"),
},
},
});
expect(resolveRuntimeControls(snapshot, snapshotConfig(snapshot)).permissions).toMatchObject({
scope: "current-thread",
source: "pending",
effective: {
activePermissionProfile: { id: ":workspace", extends: null },
approvalPolicy: "never",
sandboxPolicy: null,
},
});
expect(pendingRuntimeSettingsPatch(snapshot, snapshotConfig(snapshot))).toMatchObject({
update: {
approvalPolicy: "never",
permissions: ":workspace",
},
});
});
it("resolves permission profile reset intent back to legacy sandbox config", () => {
const snapshot = runtimeSnapshot({
activeThreadId: "thread",
runtimeConfig: runtimeConfigFixture({
sandbox_mode: "workspace-write",
sandbox_workspace_write: {
writable_roots: ["/vault"],
network_access: false,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
},
}),
active: {
activePermissionProfile: { id: ":workspace", extends: null },
sandboxPolicy: null,
},
pending: { permissions: { permissionProfile: resetRuntimeIntentToConfig() } },
});
expect(resolveRuntimeControls(snapshot, snapshotConfig(snapshot)).permissions).toMatchObject({
scope: "current-thread",
source: "pending",
effective: {
activePermissionProfile: null,
sandboxPolicy: {
type: "workspaceWrite",
writableRoots: ["/vault"],
networkAccess: false,
},
},
});
expect(pendingRuntimeSettingsPatch(snapshot, snapshotConfig(snapshot))).toMatchObject({
update: { permissions: null },
});
});
it("keeps runtime defaults, resets, and collaboration mode semantics distinct", () => {
const snapshot = runtimeSnapshot({
pending: {
@ -152,7 +348,7 @@ describe("runtime settings", () => {
const reviewerSnapshot = runtimeSnapshot({
pending: {
collaborationMode: "plan",
approvalsReviewer: setRuntimeIntentValue("auto_review"),
permissions: { reviewer: setRuntimeIntentValue("auto_review") },
},
});
const activeRuntimeSnapshot = runtimeSnapshot({
@ -187,7 +383,7 @@ describe("runtime settings", () => {
it("resolves auto-review mode from requested, active, then effective config", () => {
const requested = runtimeSnapshot({
pending: { approvalsReviewer: setRuntimeIntentValue("user") },
pending: { permissions: { reviewer: setRuntimeIntentValue("user") } },
active: { approvalsReviewer: "auto_review" },
runtimeConfig: runtimeConfigFixture({ approvals_reviewer: "guardian_subagent" }),
});
@ -223,7 +419,7 @@ describe("runtime settings", () => {
it("uses requested reviewer above active and configured reviewers", () => {
const snapshot = runtimeSnapshot({
pending: { approvalsReviewer: setRuntimeIntentValue("user") },
pending: { permissions: { reviewer: setRuntimeIntentValue("user") } },
active: { approvalsReviewer: "user" },
runtimeConfig: runtimeConfigFixture({ approvals_reviewer: "auto_review" }),
});
@ -367,7 +563,7 @@ describe("runtime settings", () => {
...active.pending,
model: setRuntimeIntentValue("gpt-pending"),
reasoningEffort: setRuntimeIntentValue("low"),
approvalsReviewer: setRuntimeIntentValue("guardian_subagent"),
permissions: { reviewer: setRuntimeIntentValue("guardian_subagent") },
fastMode: setRuntimeIntentValue("enabled"),
},
});
@ -375,19 +571,19 @@ describe("runtime settings", () => {
expect(resolveRuntimeControls(configured, snapshotConfig(configured))).toMatchObject({
model: { effective: "gpt-config", source: "config" },
reasoningEffort: { effective: "medium", source: "config" },
approvalsReviewer: { effective: "auto_review", source: "config" },
permissions: { reviewer: { effective: "auto_review", source: "config" } },
serviceTier: { effective: "fast", source: "config" },
});
expect(resolveRuntimeControls(active, snapshotConfig(active))).toMatchObject({
model: { effective: "gpt-active", source: "active-thread" },
reasoningEffort: { effective: "high", source: "active-thread" },
approvalsReviewer: { effective: "user", source: "active-thread" },
permissions: { reviewer: { effective: "user", source: "active-thread" } },
serviceTier: { effective: "flex", source: "active-thread" },
});
expect(resolveRuntimeControls(pending, snapshotConfig(pending))).toMatchObject({
model: { effective: "gpt-pending", source: "pending" },
reasoningEffort: { effective: "low", source: "pending" },
approvalsReviewer: { effective: "guardian_subagent", source: "pending" },
permissions: { reviewer: { effective: "guardian_subagent", source: "pending" } },
serviceTier: { effective: "fast", source: "pending" },
fastMode: { active: true, source: "pending", serviceTierRequestValue: "fast" },
});
@ -420,7 +616,7 @@ describe("runtime settings", () => {
});
it("resolves requested approval reviewer without adding it to turn runtime settings", () => {
const snapshot = runtimeSnapshot({ pending: { approvalsReviewer: setRuntimeIntentValue("auto_review") } });
const snapshot = runtimeSnapshot({ pending: { permissions: { reviewer: setRuntimeIntentValue("auto_review") } } });
expect(autoReviewActive(snapshot, snapshotConfig(snapshot))).toBe(true);
expect(pendingRuntimeSettingsPatch(snapshot, snapshotConfig(snapshot))).toMatchObject({
@ -663,7 +859,9 @@ describe("runtime settings", () => {
interface RuntimeSnapshotPatch extends Partial<Omit<RuntimeSnapshot, "active" | "pending">> {
active?: Partial<RuntimeSnapshot["active"]>;
pending?: Partial<RuntimeSnapshot["pending"]>;
pending?: Partial<Omit<RuntimeSnapshot["pending"], "permissions">> & {
permissions?: Partial<RuntimeSnapshot["pending"]["permissions"]>;
};
}
function runtimeSnapshot(overrides: RuntimeSnapshotPatch = {}): RuntimeSnapshot {
@ -683,11 +881,18 @@ function runtimeSnapshot(overrides: RuntimeSnapshotPatch = {}): RuntimeSnapshot
collaborationMode: null,
serviceTier: null,
approvalsReviewer: null,
approvalPolicy: null,
sandboxPolicy: null,
activePermissionProfile: null,
},
pending: {
model: { kind: "unchanged" },
reasoningEffort: { kind: "unchanged" },
approvalsReviewer: { kind: "unchanged" },
permissions: {
approvalPolicy: { kind: "unchanged" },
permissionProfile: { kind: "unchanged" },
reviewer: { kind: "unchanged" },
},
collaborationMode: "default",
fastMode: { kind: "unchanged" },
},
@ -707,6 +912,10 @@ function runtimeSnapshot(overrides: RuntimeSnapshotPatch = {}): RuntimeSnapshot
pending: {
...snapshot.pending,
...pending,
permissions: {
...snapshot.pending.permissions,
...(pending?.permissions ?? {}),
},
},
};
}

View file

@ -29,11 +29,18 @@ function fastMode(serviceTier: string | null, serviceTiers: ModelMetadata["servi
collaborationMode: "default",
serviceTier,
approvalsReviewer: null,
approvalPolicy: null,
sandboxPolicy: null,
activePermissionProfile: null,
},
pending: {
model: { kind: "unchanged" },
reasoningEffort: { kind: "unchanged" },
approvalsReviewer: { kind: "unchanged" },
permissions: {
approvalPolicy: { kind: "unchanged" },
permissionProfile: { kind: "unchanged" },
reviewer: { kind: "unchanged" },
},
collaborationMode: "default",
fastMode: { kind: "unchanged" },
},