authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-11 22:14:17-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-11 22:14:17-03:00
logcf1ba3d7da98357243ee056d2fd358eed8171981
tree4f470486463d6bb8c2e52b1683b46b9174fe7a46
parent4d16e6bc6f0be52c5d44b8864000c70c7825e4f7
signature Signed by PGP key B5690EEEBB952194

feat(settings): configure default permissions for new threads (#11346)


13 files changed, 237 insertions(+), 66 deletions(-)

apps/mobile/src/features/threads/new-task-flow-provider.tsx+6-2
......@@ -467,7 +467,10 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
467467 const draftStartFromOrigin = selectedProjectDraft.workspaceSelection?.startFromOrigin;
468468 const startFromOrigin =
469469 draftStartFromOrigin ?? projectSettings.settings.newWorktreesStartFromOrigin;
470 const runtimeMode = selectedProjectDraft.runtimeMode ?? DEFAULT_RUNTIME_MODE;
470 const defaultRuntimeMode = editingPendingTask
471 ? (editingPendingTask.runtimeMode ?? DEFAULT_RUNTIME_MODE)
472 : projectSettings.settings.defaultRuntimeMode;
473 const runtimeMode = selectedProjectDraft.runtimeMode ?? defaultRuntimeMode;
471474
472475 // Antigravity keeps unavailable selections so sign-out or a catalog change
473476 // cannot switch the user's model. Other providers retain their fallback
......@@ -978,7 +981,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
978981 text,
979982 attachments: draft.attachments,
980983 modelSelection: draftModelSelection,
981 runtimeMode: draft.runtimeMode ?? DEFAULT_RUNTIME_MODE,
984 runtimeMode: draft.runtimeMode ?? defaultRuntimeMode,
982985 interactionMode: resolvePendingTaskInteractionMode({
983986 preferenceLoaded: planModePreferenceLoaded,
984987 planModeEnabled: legacyPlanModeEnabled,
......@@ -1014,6 +1017,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
10141017 };
10151018 },
10161019 [
1020 defaultRuntimeMode,
10171021 editingPendingProject,
10181022 editingPendingTask,
10191023 selectedEnvironmentServerConfig,
apps/server/src/serverRuntimeStartup.test.ts+37-6
......@@ -225,12 +225,37 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa
225225});
226226
227227it.effect.each([
228 { existing: false, machineModel: null, projectModel: null },
229 { existing: false, machineModel: "claude-sonnet-4-6", projectModel: null },
230 { existing: true, machineModel: "claude-sonnet-4-6", projectModel: null },
231 { existing: true, machineModel: "claude-sonnet-4-6", projectModel: "gpt-5.4" },
232])("auto-bootstrap model precedence: %j", ({ existing, machineModel, projectModel }) =>
228 {
229 existing: false,
230 machineModel: null,
231 projectModel: null,
232 machineMode: "full-access",
233 projectMode: null,
234 },
235 {
236 existing: false,
237 machineModel: "claude-sonnet-4-6",
238 projectModel: null,
239 machineMode: "approval-required",
240 projectMode: null,
241 },
242 {
243 existing: true,
244 machineModel: "claude-sonnet-4-6",
245 projectModel: null,
246 machineMode: "auto",
247 projectMode: null,
248 },
249 {
250 existing: true,
251 machineModel: "claude-sonnet-4-6",
252 projectModel: "gpt-5.4",
253 machineMode: "full-access",
254 projectMode: "auto-accept-edits",
255 },
256] as const)("auto-bootstrap model and permissions precedence: %j", (options) =>
233257 Effect.gen(function* () {
258 const { existing, machineModel, projectModel, machineMode, projectMode } = options;
234259 const machineSelection = machineModel
235260 ? { instanceId: ProviderInstanceId.make("claude-code"), model: machineModel }
236261 : null;
......@@ -242,16 +267,21 @@ it.effect.each([
242267 readonly type: string;
243268 readonly defaultModelSelection?: unknown;
244269 readonly modelSelection?: unknown;
270 readonly runtimeMode?: unknown;
245271 }>
246272 >([]);
247273 const targets = yield* ServerRuntimeStartup.resolveAutoBootstrapWelcomeTargets.pipe(
248274 Effect.provide(
249275 ServerSettings.layerTest({
250276 defaultModelSelection: machineSelection,
277 defaultRuntimeMode: machineMode,
251278 projectSettingsOverrides:
252279 existing && projectSelection
253280 ? {
254 [ProjectId.make("existing-project")]: { defaultModelSelection: projectSelection },
281 [ProjectId.make("existing-project")]: {
282 defaultModelSelection: projectSelection,
283 ...(projectMode ? { defaultRuntimeMode: projectMode } : {}),
284 },
255285 }
256286 : {},
257287 }),
......@@ -321,6 +351,7 @@ it.effect.each([
321351 existing ? ["thread.create"] : ["project.create", "thread.create"],
322352 );
323353 if (!existing) assert.equal("defaultModelSelection" in commands[0]!, false);
354 assert.equal(commands.at(-1)?.runtimeMode, projectMode ?? machineMode);
324355 assert.deepStrictEqual(
325356 commands.at(-1)?.modelSelection,
326357 projectSelection ??
apps/server/src/serverRuntimeStartup.ts+2-1
......@@ -248,7 +248,8 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () {
248248 title: "New thread",
249249 modelSelection: nextThreadModelSelection,
250250 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
251 runtimeMode: "full-access",
251 runtimeMode: resolveProjectSettings(settings, nextProjectId).settings
252 .defaultRuntimeMode,
252253 branch: null,
253254 worktreePath: null,
254255 createdAt,
apps/web/src/components/ChatView.tsx+11-6
......@@ -154,7 +154,6 @@ import {
154154} from "../proposedPlan";
155155import {
156156 DEFAULT_INTERACTION_MODE,
157 DEFAULT_RUNTIME_MODE,
158157 DEFAULT_THREAD_TERMINAL_ID,
159158 MAX_TERMINALS_PER_GROUP,
160159 type ChatMessage,
......@@ -1839,7 +1838,11 @@ export default function ChatView(props: ChatViewProps) {
18391838 // session.lastError. Bump a tick so the banner hides immediately. Mirrors
18401839 // the branch mismatch banner.
18411840 const [, setThreadErrorBannerDismissTick] = useState(0);
1842 const runtimeMode = composerRuntimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE;
1841 const defaultRuntimeMode = resolveProjectSettings(settings, activeThread?.projectId ?? null)
1842 .settings.defaultRuntimeMode;
1843 // Implicit drafts follow their current project/environment, including retargets.
1844 // Explicit composer choices and existing server threads retain their permissions.
1845 const runtimeMode = composerRuntimeMode ?? activeServerThread?.runtimeMode ?? defaultRuntimeMode;
18431846 const isLocalDraftThread = !isServerThread && localDraftThread !== undefined;
18441847 const canCheckoutPullRequestIntoThread = isLocalDraftThread;
18451848 const activeThreadId = activeThread?.id ?? null;
......@@ -2293,7 +2296,8 @@ export default function ChatView(props: ChatViewProps) {
22932296 setLogicalProjectDraftThreadId(logicalProjectKey, activeProjectRef, nextDraftId, {
22942297 threadId: nextThreadId,
22952298 createdAt: new Date().toISOString(),
2296 runtimeMode: DEFAULT_RUNTIME_MODE,
2299 runtimeMode: resolveProjectSettings(settings, activeProject.id, activeProject).settings
2300 .defaultRuntimeMode,
22972301 interactionMode: DEFAULT_INTERACTION_MODE,
22982302 ...input,
22992303 });
......@@ -2312,6 +2316,7 @@ export default function ChatView(props: ChatViewProps) {
23122316 navigate,
23132317 projectGroupingSettings,
23142318 routeKind,
2319 settings,
23152320 setDraftThreadContext,
23162321 setLogicalProjectDraftThreadId,
23172322 ],
......@@ -7890,7 +7895,7 @@ export default function ChatView(props: ChatViewProps) {
78907895 projectId: activeProject.id,
78917896 title: nextThreadTitle,
78927897 modelSelection: nextThreadModelSelection,
7893 runtimeMode,
7898 runtimeMode: defaultRuntimeMode,
78947899 interactionMode: "default",
78957900 branch: activeThreadBranch,
78967901 worktreePath: activeThread.worktreePath,
......@@ -7913,7 +7918,7 @@ export default function ChatView(props: ChatViewProps) {
79137918 },
79147919 modelSelection: ctxSelectedModelSelection,
79157920 titleSeed: nextThreadTitle,
7916 runtimeMode,
7921 runtimeMode: defaultRuntimeMode,
79177922 interactionMode: "default",
79187923 sourceProposedPlan: {
79197924 threadId: activeThread.id,
......@@ -7987,7 +7992,7 @@ export default function ChatView(props: ChatViewProps) {
79877992 isServerThread,
79887993 navigate,
79897994 resetLocalDispatch,
7990 runtimeMode,
7995 defaultRuntimeMode,
79917996 startThreadTurn,
79927997 environmentId,
79937998 composerRef,
apps/web/src/components/chat/ChatComposer.tsx+1-32
......@@ -1,3 +1,4 @@
1import { runtimeModeConfig, runtimeModeOptions } from "./runtimeModeConfig";
12import { RefreshIcon } from "~/components/ui/refresh-icon";
23import {
34 questionAttachmentDraftId,
......@@ -850,11 +851,6 @@ import {
850851 PaperclipIcon,
851852 PencilRulerIcon,
852853 PlayIcon,
853 type LucideIcon,
854 LockIcon,
855 LockOpenIcon,
856 PenLineIcon,
857 SparklesIcon,
858854 XIcon,
859855} from "lucide-react";
860856import { proposedPlanTitle } from "../../proposedPlan";
......@@ -898,33 +894,6 @@ import type { ReviewCommentContext } from "../../reviewCommentContext";
898894
899895const WORKSPACE_SNAPSHOT_RETRY_COOLDOWN_MS = 10_000;
900896
901const runtimeModeConfig: Record<
902 RuntimeMode,
903 { label: string; description: string; icon: LucideIcon }
904> = {
905 "approval-required": {
906 label: "Supervised",
907 description: "Ask before commands and file changes.",
908 icon: LockIcon,
909 },
910 "auto-accept-edits": {
911 label: "Auto-accept edits",
912 description: "Auto-approve edits, ask before other actions.",
913 icon: PenLineIcon,
914 },
915 auto: {
916 label: "Auto",
917 description: "Supported providers approve routine actions; others still ask.",
918 icon: SparklesIcon,
919 },
920 "full-access": {
921 label: "Full access",
922 description: "Allow commands and edits without prompts.",
923 icon: LockOpenIcon,
924 },
925};
926
927const runtimeModeOptions = Object.keys(runtimeModeConfig) as RuntimeMode[];
928897const extendReplacementRangeForTrailingSpace = (
929898 text: string,
930899 rangeEnd: number,
apps/web/src/components/chat/runtimeModeConfig.ts created+30
......@@ -0,0 +1,30 @@
1import type { RuntimeMode } from "@t3tools/contracts";
2import { type LucideIcon, LockIcon, LockOpenIcon, PenLineIcon, SparklesIcon } from "lucide-react";
3
4export const runtimeModeConfig: Record<
5 RuntimeMode,
6 { label: string; description: string; icon: LucideIcon }
7> = {
8 "approval-required": {
9 label: "Supervised",
10 description: "Ask before commands and file changes.",
11 icon: LockIcon,
12 },
13 "auto-accept-edits": {
14 label: "Auto-accept edits",
15 description: "Auto-approve edits, ask before other actions.",
16 icon: PenLineIcon,
17 },
18 auto: {
19 label: "Auto",
20 description: "Supported providers approve routine actions; others still ask.",
21 icon: SparklesIcon,
22 },
23 "full-access": {
24 label: "Full access",
25 description: "Allow commands and edits without prompts.",
26 icon: LockOpenIcon,
27 },
28};
29
30export const runtimeModeOptions = Object.keys(runtimeModeConfig) as RuntimeMode[];
apps/web/src/components/settings/ProjectDefaultsSettings.tsx+64
......@@ -19,6 +19,7 @@ import { useEnvironments } from "../../state/environments";
1919import { EMPTY_SERVER_PROVIDERS } from "../../state/server";
2020import { resolveEnvModeLabel } from "../BranchToolbar.logic";
2121import { ProviderModelPicker } from "../chat/ProviderModelPicker";
22import { runtimeModeConfig, runtimeModeOptions } from "../chat/runtimeModeConfig";
2223import { PULL_REQUEST_MERGE_METHOD_LABELS } from "../pullRequest/pullRequestDetail.logic";
2324import { TraitsPicker } from "../chat/TraitsPicker";
2425import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
......@@ -67,6 +68,8 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting
6768 );
6869 const activeEntry = entries.find((entry) => entry.instanceId === selection?.instanceId);
6970 const mixedModel = useScopedSettingsMixed(["defaultModelSelection"]);
71 const mixedPermissions = useScopedSettingsMixed(["defaultRuntimeMode"]);
72 const PermissionIcon = runtimeModeConfig[settings.defaultRuntimeMode].icon;
7073 const mixedWorkspace = useScopedSettingsMixed(["defaultThreadEnvMode"]);
7174 const mixedBrowser = useScopedSettingsMixed(["enableAgentBrowserAccess"]);
7275 const mixedAutoPull = useScopedSettingsMixed(["defaultAutoPull"]);
......@@ -220,6 +223,67 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting
220223 )
221224 }
222225 />
226 <SettingsRow
227 serverScoped
228 settingKeys={["defaultRuntimeMode"]}
229 mixed={mixedPermissions}
230 {...searchableSetting("default-permissions")}
231 description={
232 isProjectScope
233 ? "Permissions for new threads in this project."
234 : "Default permissions for new threads. Projects can override them."
235 }
236 resetAction={
237 settings.defaultRuntimeMode !== DEFAULT_SERVER_SETTINGS.defaultRuntimeMode ? (
238 <SettingResetButton
239 label="default permissions"
240 onClick={() =>
241 updateSettings({
242 defaultRuntimeMode: DEFAULT_SERVER_SETTINGS.defaultRuntimeMode,
243 })
244 }
245 />
246 ) : null
247 }
248 control={
249 <Select
250 value={mixedPermissions ? null : settings.defaultRuntimeMode}
251 onValueChange={(value) => {
252 if (value) updateSettings({ defaultRuntimeMode: value });
253 }}
254 >
255 <SelectTrigger size="sm" aria-label="Default permissions">
256 {!mixedPermissions && (
257 <PermissionIcon className="size-3.5 shrink-0 text-muted-foreground" />
258 )}
259 <SelectValue>
260 {mixedPermissions
261 ? "Mixed"
262 : runtimeModeConfig[settings.defaultRuntimeMode].label}
263 </SelectValue>
264 </SelectTrigger>
265 <SelectPopup align="end" alignItemWithTrigger={false}>
266 {runtimeModeOptions.map((mode) => {
267 const option = runtimeModeConfig[mode];
268 const Icon = option.icon;
269 return (
270 <SelectItem key={mode} value={mode} className="min-w-64 py-2">
271 <div className="grid gap-0.5">
272 <span className="inline-flex items-center gap-1.5 font-medium">
273 <Icon className="size-3.5 shrink-0 text-muted-foreground" />
274 {option.label}
275 </span>
276 <span className="text-xs leading-4 text-muted-foreground">
277 {option.description}
278 </span>
279 </div>
280 </SelectItem>
281 );
282 })}
283 </SelectPopup>
284 </Select>
285 }
286 />
223287 <SettingsRow
224288 serverScoped
225289 settingKeys={["defaultThreadEnvMode"]}
apps/web/src/components/settings/settingsSearch.ts+9
......@@ -108,6 +108,15 @@ export const SETTINGS_SEARCH_ITEMS = [
108108 scope: "project-defaults",
109109 searchTerms: ["new thread project provider reasoning effort"],
110110 },
111 {
112 id: "default-permissions",
113 title: "Permissions",
114 to: "/settings/general",
115 scope: "project-defaults",
116 searchTerms: [
117 "new thread default runtime mode supervised approvals auto accept edits full access",
118 ],
119 },
111120 {
112121 id: "color-scheme",
113122 title: "Color scheme",
apps/web/src/hooks/useHandleNewThread.test.ts+25
......@@ -1,4 +1,5 @@
11import { describe, expect, it, vi } from "vite-plus/test";
2import type { RuntimeMode } from "@t3tools/contracts";
23
34const testState = vi.hoisted(() => {
45 let completeProjectFileRead: (value: null) => void = () => undefined;
......@@ -7,6 +8,7 @@ const testState = vi.hoisted(() => {
78 defaultThreadEnvMode: "local" as "local" | "worktree",
89 newWorktreesStartFromOrigin: false,
910 defaultModelSelection: null,
11 defaultRuntimeMode: "full-access" as RuntimeMode,
1012 };
1113 let storedDraft: {
1214 readonly draftId: string;
......@@ -55,6 +57,7 @@ const testState = vi.hoisted(() => {
5557 defaultThreadEnvMode: workspaceDefaults.envMode,
5658 newWorktreesStartFromOrigin: workspaceDefaults.startFromOrigin,
5759 defaultModelSelection: null,
60 defaultRuntimeMode: "full-access",
5861 };
5962 router.state.location.href = "/";
6063 router.navigate.mockClear();
......@@ -184,6 +187,28 @@ describe.each([
184187 },
185188 ],
186189])("useNewThreadHandler with a %s draft", (_, draft) => {
190 it.each(["approval-required", "auto-accept-edits", "auto", "full-access"] as const)(
191 "uses the target environment's %s permissions for new threads",
192 async (runtimeMode) => {
193 testState.reset(draft);
194 testState.targetSettings.defaultRuntimeMode = runtimeMode;
195 const projectRef = {
196 environmentId: "environment-ssh",
197 projectId: "project-remote",
198 } as never;
199 const pendingOpen = useNewThreadHandler()(projectRef);
200 testState.completeProjectFileRead(null);
201 const opened = await pendingOpen;
202
203 expect(testState.draftStore.setLogicalProjectDraftThreadId).toHaveBeenCalledWith(
204 "remote-project",
205 projectRef,
206 opened!.draftId,
207 expect.objectContaining({ runtimeMode }),
208 );
209 },
210 );
211
187212 it("abandons a delayed draft open when the user navigates elsewhere", async () => {
188213 testState.reset(draft);
189214 const openThread = useNewThreadHandler();
apps/web/src/hooks/useHandleNewThread.ts+7-16
......@@ -4,12 +4,7 @@ import {
44 scopeProjectRef,
55 scopeThreadRef,
66} from "@t3tools/client-runtime/environment";
7import {
8 DEFAULT_RUNTIME_MODE,
9 DEFAULT_SERVER_SETTINGS,
10 type ScopedProjectRef,
11 type ThreadId,
12} from "@t3tools/contracts";
7import { DEFAULT_SERVER_SETTINGS, type ScopedProjectRef, type ThreadId } from "@t3tools/contracts";
138import { useParams, useRouter } from "@tanstack/react-router";
149import { useCallback, useMemo } from "react";
1510import {
......@@ -100,8 +95,8 @@ export function useNewThreadHandler() {
10095 const routeChangedSinceRequest = () => router.state.location.href !== requestingRouteHref;
10196 const currentRouteTarget = getCurrentRouteTarget();
10297 // A new thread carries the user's working mode from the thread being
103 // viewed. The target project's configured model still wins; runtime and
104 // interaction modes carry independently. Branch, worktree, and env mode
98 // viewed. The target project's configured model still wins; interaction
99 // mode carries independently. Permissions, branch, worktree, and env mode
105100 // come from configured defaults unless the caller passes them explicitly.
106101 const carrySourceShell =
107102 currentRouteTarget?.kind === "server"
......@@ -124,11 +119,6 @@ export function useNewThreadHandler() {
124119 : null;
125120 const carryModelSelection =
126121 composerModelSelection ?? carrySourceShell?.modelSelection ?? null;
127 const carryRuntimeMode =
128 carrySourceComposer?.runtimeMode ??
129 carrySourceShell?.runtimeMode ??
130 carrySourceDraft?.runtimeMode ??
131 null;
132122 const carryInteractionMode =
133123 carrySourceComposer?.interactionMode ??
134124 carrySourceShell?.interactionMode ??
......@@ -147,6 +137,7 @@ export function useNewThreadHandler() {
147137 project,
148138 );
149139 const projectDefaultModelSelection = projectSettings.settings.defaultModelSelection;
140 const defaultRuntimeMode = projectSettings.settings.defaultRuntimeMode;
150141 const projectThreadEnvMode =
151142 projectSettings.sources.defaultThreadEnvMode === "project"
152143 ? projectSettings.settings.defaultThreadEnvMode
......@@ -276,7 +267,7 @@ export function useNewThreadHandler() {
276267 if (workspaceContext) {
277268 setDraftThreadContext(emptyStoredDraftThread.draftId, {
278269 ...workspaceContext,
279 ...(carryRuntimeMode ? { runtimeMode: carryRuntimeMode } : {}),
270 ...(!isDraftAlreadyOpen ? { runtimeMode: defaultRuntimeMode } : {}),
280271 ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}),
281272 });
282273 }
......@@ -313,7 +304,7 @@ export function useNewThreadHandler() {
313304 {
314305 threadId: emptyStoredDraftThread.threadId,
315306 ...workspaceContext,
316 ...(carryRuntimeMode ? { runtimeMode: carryRuntimeMode } : {}),
307 ...(!isDraftAlreadyOpen ? { runtimeMode: defaultRuntimeMode } : {}),
317308 ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}),
318309 },
319310 );
......@@ -426,7 +417,7 @@ export function useNewThreadHandler() {
426417 envMode: initialEnvMode,
427418 newWorktreesStartFromOrigin: projectSettings.settings.newWorktreesStartFromOrigin,
428419 }),
429 runtimeMode: carryRuntimeMode ?? DEFAULT_RUNTIME_MODE,
420 runtimeMode: defaultRuntimeMode,
430421 ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}),
431422 });
432423 applyStickyState(draftId);
docs/user/permission-modes.md+4-2
......@@ -3,8 +3,10 @@
33Permission modes control when an agent needs your approval to act. Choose a mode in the message
44composer; it applies to that thread.
55
6New threads start in **Full access** unless you choose another mode before sending. A thread
7created from another thread inherits its mode.
6Set the default for new threads in **Settings → General → New threads → Permissions**.
7Projects can override the environment default. New threads use this setting rather than the
8mode of the thread you were viewing. The initial default is **Full access**; existing threads
9and modes you choose in a draft keep their permissions.
810
911| Mode | Behavior |
1012| --------------------- | ------------------------------------------------------------------------------------- |
packages/contracts/src/settings.test.ts+29
......@@ -20,6 +20,35 @@ const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch);
2020const encodeServerSettings = Schema.encodeSync(ServerSettings);
2121const decodeClaudeSettings = Schema.decodeUnknownSync(ClaudeSettings);
2222
23describe("ServerSettings default permissions", () => {
24 it("keeps full access for settings saved before a default was configured", () => {
25 expect(decodeServerSettings({}).defaultRuntimeMode).toBe("full-access");
26 expect(DEFAULT_SERVER_SETTINGS.defaultRuntimeMode).toBe("full-access");
27 });
28
29 it.each(["approval-required", "auto-accept-edits", "auto", "full-access"])(
30 "round-trips %s as an environment default and project override",
31 (defaultRuntimeMode) => {
32 const input = {
33 defaultRuntimeMode,
34 projectSettingsOverrides: { project: { defaultRuntimeMode } },
35 };
36 expect(encodeServerSettings(decodeServerSettings(input))).toMatchObject(input);
37 expect(decodeServerSettingsPatch(input)).toEqual(input);
38 },
39 );
40
41 it("rejects unsupported permission defaults", () => {
42 expect(() => decodeServerSettings({ defaultRuntimeMode: "unsupported" })).toThrow();
43 expect(() => decodeServerSettingsPatch({ defaultRuntimeMode: "unsupported" })).toThrow();
44 expect(() =>
45 decodeServerSettingsPatch({
46 projectSettingsOverrides: { project: { defaultRuntimeMode: "unsupported" } },
47 }),
48 ).toThrow();
49 });
50});
51
2352describe("ServerSettings usage price overrides", () => {
2453 const prices = { inputCostPerMillionTokens: 2, outputCostPerMillionTokens: 8 };
2554
packages/contracts/src/settings.ts+12-1
......@@ -18,7 +18,12 @@ import {
1818 DEFAULT_TEXT_GENERATION_REASONING_EFFORT,
1919 ProviderOptionSelections,
2020} from "./model.ts";
21import { ModelSelection, ProjectScript } from "./orchestration.ts";
21import {
22 DEFAULT_RUNTIME_MODE,
23 ModelSelection,
24 ProjectScript,
25 RuntimeMode,
26} from "./orchestration.ts";
2227import { BrowserProfile, BrowserProfileId, DEFAULT_BROWSER_PROFILE_ID } from "./browserProfile.ts";
2328import {
2429 DEFAULT_PREVIEW_APPEARANCE,
......@@ -934,6 +939,7 @@ export type BackgroundActivitySettings = typeof BackgroundActivitySettings.Type;
934939 */
935940export const PROJECT_SCOPED_SERVER_SETTING_KEYS = [
936941 "defaultModelSelection",
942 "defaultRuntimeMode",
937943 "defaultThreadEnvMode",
938944 "newWorktreesStartFromOrigin",
939945 "defaultAutoPull",
......@@ -958,6 +964,7 @@ export type ProjectScopedServerSettingKey = (typeof PROJECT_SCOPED_SERVER_SETTIN
958964 */
959965export const ProjectSettingsOverrides = Schema.Struct({
960966 defaultModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)),
967 defaultRuntimeMode: Schema.optionalKey(RuntimeMode),
961968 defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode),
962969 newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean),
963970 defaultAutoPull: Schema.optionalKey(Schema.Boolean),
......@@ -1015,6 +1022,9 @@ export const ServerSettings = Schema.Struct({
10151022 defaultModelSelection: Schema.NullOr(ModelSelection).pipe(
10161023 Schema.withDecodingDefault(Effect.succeed(null)),
10171024 ),
1025 defaultRuntimeMode: RuntimeMode.pipe(
1026 Schema.withDecodingDefault(Effect.succeed(DEFAULT_RUNTIME_MODE)),
1027 ),
10181028 /**
10191029 * Per-project overrides of the keys in `PROJECT_SCOPED_SERVER_SETTING_KEYS`.
10201030 * The source of truth for project settings; `projectAgentBrowserAccessOverrides`,
......@@ -1328,6 +1338,7 @@ export const ServerSettingsPatch = Schema.Struct({
13281338 Schema.Record(ProjectId, Schema.NullOr(Schema.Boolean)),
13291339 ),
13301340 defaultModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)),
1341 defaultRuntimeMode: Schema.optionalKey(RuntimeMode),
13311342 /**
13321343 * Per-project entry replacement: each entry replaces that project's whole
13331344 * override set and `null` removes it. Clearing one override means resending