diff --git a/README.md b/README.md index 4df3c5bd..68f6d23c 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/app-server/connection/client.ts b/src/app-server/connection/client.ts index 96850c00..9ecb21f4 100644 --- a/src/app-server/connection/client.ts +++ b/src/app-server/connection/client.ts @@ -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; diff --git a/src/app-server/protocol/runtime-config.ts b/src/app-server/protocol/runtime-config.ts index 2c422fe0..f17cd9a3 100644 --- a/src/app-server/protocol/runtime-config.ts +++ b/src/app-server/protocol/runtime-config.ts @@ -36,15 +36,15 @@ export function runtimeConfigSnapshotFromAppServerConfig(response: ConfigReadRes } function startupPermissionsFromConfig(config: Record): 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): RuntimePermissionState["activePermissionProfile"] { - if (typeof config["sandbox_mode"] === "string") return null; const profile = nonEmptyStringOrNull(config["default_permissions"]); return profile ? { id: profile, extends: null } : null; } diff --git a/src/app-server/query/cache.ts b/src/app-server/query/cache.ts index f60906f5..0a762817 100644 --- a/src/app-server/query/cache.ts +++ b/src/app-server/query/cache.ts @@ -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 => { 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, }; diff --git a/src/app-server/query/metadata-probes.ts b/src/app-server/query/metadata-probes.ts index e9cb8b0c..75c61d01 100644 --- a/src/app-server/query/metadata-probes.ts +++ b/src/app-server/query/metadata-probes.ts @@ -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 { } export type SkillMetadataProbeResult = MetadataProbeResult; +export type PermissionProfileMetadataProbeResult = MetadataProbeResult; export type RateLimitMetadataProbeResult = MetadataProbeResult; export async function readSkillMetadataProbe( @@ -30,6 +32,24 @@ export async function readSkillMetadataProbe( } } +export async function readPermissionProfileMetadataProbe( + client: AppServerRequestClient | null, + vaultPath: string, +): Promise { + 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 { if (!client) { return { diff --git a/src/app-server/query/snapshots.ts b/src/app-server/query/snapshots.ts index 841aba6d..4890434d 100644 --- a/src/app-server/query/snapshots.ts +++ b/src/app-server/query/snapshots.ts @@ -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 })), diff --git a/src/app-server/services/catalog.ts b/src/app-server/services/catalog.ts index 1fe53aef..24ffa3fc 100644 --- a/src/app-server/services/catalog.ts +++ b/src/app-server/services/catalog.ts @@ -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 { + const profiles: RuntimePermissionProfileSummary[] = []; + const seenCursors = new Set(); + 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, diff --git a/src/app-server/services/threads.ts b/src/app-server/services/threads.ts index 0943c586..cd605900 100644 --- a/src/app-server/services/threads.ts +++ b/src/app-server/services/threads.ts @@ -48,6 +48,7 @@ interface ThreadActivationResponse extends Partial { 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 { - const { cwd, serviceTier } = options; + const { cwd, serviceTier, permissions } = options; return client.request("thread/start", { cwd, serviceName: "codex-panel", ...(serviceTier !== undefined ? { serviceTier } : {}), + ...(permissions !== undefined ? { permissions } : {}), }); } diff --git a/src/domain/runtime/permissions.ts b/src/domain/runtime/permissions.ts index 0cb2e237..437ef219 100644 --- a/src/domain/runtime/permissions.ts +++ b/src/domain/runtime/permissions.ts @@ -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, diff --git a/src/domain/server/diagnostics.ts b/src/domain/server/diagnostics.ts index 3fba7838..d00a5a70 100644 --- a/src/domain/server/diagnostics.ts +++ b/src/domain/server/diagnostics.ts @@ -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" }, diff --git a/src/domain/server/metadata.ts b/src/domain/server/metadata.ts index ee7282be..b20d60ca 100644 --- a/src/domain/server/metadata.ts +++ b/src/domain/server/metadata.ts @@ -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; } diff --git a/src/features/chat/app-server/actions/metadata.ts b/src/features/chat/app-server/actions/metadata.ts index 3f514a68..6cb11c73 100644 --- a/src/features/chat/app-server/actions/metadata.ts +++ b/src/features/chat/app-server/actions/metadata.ts @@ -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, }); diff --git a/src/features/chat/app-server/actions/threads.ts b/src/features/chat/app-server/actions/threads.ts index 637e9929..c5635e39 100644 --- a/src/features/chat/app-server/actions/threads.ts +++ b/src/features/chat/app-server/actions/threads.ts @@ -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(); diff --git a/src/features/chat/application/composer/slash-commands.ts b/src/features/chat/application/composer/slash-commands.ts index 10b3e77d..903b5834 100644 --- a/src/features/chat/application/composer/slash-commands.ts +++ b/src/features/chat/application/composer/slash-commands.ts @@ -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", diff --git a/src/features/chat/application/composer/suggestions.ts b/src/features/chat/application/composer/suggestions.ts index d465a3d0..65f28851 100644 --- a/src/features/chat/application/composer/suggestions.ts +++ b/src/features/chat/application/composer/suggestions.ts @@ -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 { diff --git a/src/features/chat/application/conversation/slash-command-execution.ts b/src/features/chat/application/conversation/slash-command-execution.ts index 7c370416..76708e8d 100644 --- a/src/features/chat/application/conversation/slash-command-execution.ts +++ b/src/features/chat/application/conversation/slash-command-execution.ts @@ -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 { + 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; diff --git a/src/features/chat/application/runtime/settings-actions.ts b/src/features/chat/application/runtime/settings-actions.ts index 2994f2e4..7daf2a02 100644 --- a/src/features/chat/application/runtime/settings-actions.ts +++ b/src/features/chat/application/runtime/settings-actions.ts @@ -38,6 +38,8 @@ export interface ChatRuntimeSettingsActions { resetReasoningEffortToConfig: () => Promise; requestReasoningEffortFromUi: (effort: ReasoningEffort) => Promise; resetReasoningEffortToConfigFromUi: () => Promise; + requestPermissionProfile: (permissionProfile: string) => Promise; + resetPermissionProfileToConfig: () => Promise; enableFastMode: () => Promise; disableFastMode: () => Promise; toggleFastMode: () => Promise; @@ -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 { + dispatch(host, { type: "runtime/permission-profile-requested", permissionProfile }); + return applyPendingThreadSettings(host); +} + +async function resetPermissionProfileToConfig(host: RuntimeSettingsActionsHost): Promise { + dispatch(host, { type: "runtime/permission-profile-reset-to-config" }); + return applyPendingThreadSettings(host); +} + async function toggleFastMode(host: RuntimeSettingsActionsHost): Promise { const { snapshot, config } = runtimeProjection(host); await setFastMode(host, resolveRuntimeControls(snapshot, config).fastMode.active ? "disabled" : "enabled"); diff --git a/src/features/chat/application/state/root-reducer.ts b/src/features/chat/application/state/root-reducer.ts index a97ab8a2..f32de361 100644 --- a/src/features/chat/application/state/root-reducer.ts +++ b/src/features/chat/application/state/root-reducer.ts @@ -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: [], }; } diff --git a/src/features/chat/application/state/store.ts b/src/features/chat/application/state/store.ts index cfaed870..9538e1ff 100644 --- a/src/features/chat/application/state/store.ts +++ b/src/features/chat/application/state/store.ts @@ -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], diff --git a/src/features/chat/domain/runtime/labels.ts b/src/features/chat/domain/runtime/labels.ts index eac3bf79..07fc47a1 100644 --- a/src/features/chat/domain/runtime/labels.ts +++ b/src/features/chat/domain/runtime/labels.ts @@ -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 { diff --git a/src/features/chat/domain/runtime/state.ts b/src/features/chat/domain/runtime/state.ts index b7390354..a7bb18df 100644 --- a/src/features/chat/domain/runtime/state.ts +++ b/src/features/chat/domain/runtime/state.ts @@ -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, diff --git a/src/features/chat/domain/runtime/thread-settings-patch.ts b/src/features/chat/domain/runtime/thread-settings-patch.ts index e9885fee..3e28151a 100644 --- a/src/features/chat/domain/runtime/thread-settings-patch.ts +++ b/src/features/chat/domain/runtime/thread-settings-patch.ts @@ -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); diff --git a/src/features/chat/panel/composer-controller.ts b/src/features/chat/panel/composer-controller.ts index fcf62398..22a19350 100644 --- a/src/features/chat/panel/composer-controller.ts +++ b/src/features/chat/panel/composer-controller.ts @@ -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, diff --git a/src/features/chat/presentation/runtime/permission-sections.ts b/src/features/chat/presentation/runtime/permission-sections.ts index 407a0a43..96e19285 100644 --- a/src/features/chat/presentation/runtime/permission-sections.ts +++ b/src/features/chat/presentation/runtime/permission-sections.ts @@ -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", diff --git a/tests/app-server/app-server-diagnostics.test.ts b/tests/app-server/app-server-diagnostics.test.ts index 751eb7c7..7a2b1c0a 100644 --- a/tests/app-server/app-server-diagnostics.test.ts +++ b/tests/app-server/app-server-diagnostics.test.ts @@ -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", () => { diff --git a/tests/app-server/query-cache.test.ts b/tests/app-server/query-cache.test.ts index c900ad28..e1644d45 100644 --- a/tests/app-server/query-cache.test.ts +++ b/tests/app-server/query-cache.test.ts @@ -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", diff --git a/tests/app-server/shared-queries.test.ts b/tests/app-server/shared-queries.test.ts index 71ad29eb..7f83e507 100644 --- a/tests/app-server/shared-queries.test.ts +++ b/tests/app-server/shared-queries.test.ts @@ -199,6 +199,7 @@ function serverMetadata(overrides: Partial = {}): SharedSe runtimeConfig: null, availableModels: [], availableSkills: [], + availablePermissionProfiles: [], rateLimit: null, serverDiagnostics: diagnostics, ...overrides, diff --git a/tests/features/chat/app-server/actions/actions.test.ts b/tests/features/chat/app-server/actions/actions.test.ts index 358258c7..5a45312d 100644 --- a/tests/features/chat/app-server/actions/actions.test.ts +++ b/tests/features/chat/app-server/actions/actions.test.ts @@ -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 = {}): S runtimeConfig: emptyRuntimeConfigSnapshot(), availableModels: [], availableSkills: [], + availablePermissionProfiles: [], rateLimit: null, serverDiagnostics: createServerDiagnostics(), ...overrides, diff --git a/tests/features/chat/application/composer/suggestions.test.ts b/tests/features/chat/application/composer/suggestions.test.ts index 3d225268..daccc9d9 100644 --- a/tests/features/chat/application/composer/suggestions.test.ts +++ b/tests/features/chat/application/composer/suggestions.test.ts @@ -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([ diff --git a/tests/features/chat/application/conversation/slash-command-execution.test.ts b/tests/features/chat/application/conversation/slash-command-execution.test.ts index 81b3843d..d66ed7a0 100644 --- a/tests/features/chat/application/conversation/slash-command-execution.test.ts +++ b/tests/features/chat/application/conversation/slash-command-execution.test.ts @@ -36,6 +36,8 @@ function context(overrides: Partial = {}): 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 ", 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."); }); diff --git a/tests/features/chat/application/conversation/slash-command-executor.test.ts b/tests/features/chat/application/conversation/slash-command-executor.test.ts index 3d039c70..7d9fbba9 100644 --- a/tests/features/chat/application/conversation/slash-command-executor.test.ts +++ b/tests/features/chat/application/conversation/slash-command-executor.test.ts @@ -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(), }, diff --git a/tests/features/chat/application/runtime/settings-actions.test.ts b/tests/features/chat/application/runtime/settings-actions.test.ts index 2464d7f5..8695b91d 100644 --- a/tests/features/chat/application/runtime/settings-actions.test.ts +++ b/tests/features/chat/application/runtime/settings-actions.test.ts @@ -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" }); diff --git a/tests/features/chat/application/state/root-reducer.test.ts b/tests/features/chat/application/state/root-reducer.test.ts index bd38d6d2..fd47ec00 100644 --- a/tests/features/chat/application/state/root-reducer.test.ts +++ b/tests/features/chat/application/state/root-reducer.test.ts @@ -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")]); diff --git a/tests/features/chat/host/view-connection.test.ts b/tests/features/chat/host/view-connection.test.ts index b03470f0..046f8180 100644 --- a/tests/features/chat/host/view-connection.test.ts +++ b/tests/features/chat/host/view-connection.test.ts @@ -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(), }; diff --git a/tests/features/chat/panel/runtime-status-projection.test.ts b/tests/features/chat/panel/runtime-status-projection.test.ts index a0908a18..14eccf56 100644 --- a/tests/features/chat/panel/runtime-status-projection.test.ts +++ b/tests/features/chat/panel/runtime-status-projection.test.ts @@ -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): RuntimeConfigSnapshot { diff --git a/tests/runtime/runtime-settings.test.ts b/tests/runtime/runtime-settings.test.ts index 5cf6bffb..c5dfb206 100644 --- a/tests/runtime/runtime-settings.test.ts +++ b/tests/runtime/runtime-settings.test.ts @@ -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" }, diff --git a/tests/support/async.ts b/tests/support/async.ts index 97ba21b2..d2605672 100644 --- a/tests/support/async.ts +++ b/tests/support/async.ts @@ -15,7 +15,7 @@ export function deferred(): Deferred { } export async function waitForAsyncWork(assertion: () => void): Promise { - for (let attempt = 0; attempt < 20; attempt += 1) { + for (let attempt = 0; attempt < 50; attempt += 1) { try { assertion(); return;