authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-08-17 10:26:48+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-17 10:26:48+02:00
logcd096b9ad5a4156ffeab85de617cbb219057007f
tree82a90217847ba32e7303e3130687c2af167493e2
parent13458e65106005ca183c02b5c84f9355b67feadb
signature Signed by PGP key B5690EEEBB952194

feat(server): let users withhold browser access from agents (#7083)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

9 files changed, 293 insertions(+), 25 deletions(-)

apps/server/src/provider/CodexDeveloperInstructions.ts+26-6
......@@ -11,7 +11,19 @@ For browser work, first call \`preview_status\`. If no automation-capable previe
1111Do not switch to global browser skills, Chrome, Node REPL browser automation, standalone Playwright, or agent-browser merely because the preview is initially closed or a first call fails. Use an alternative browser system only when the T3 preview tools are absent, the user explicitly requests another browser, or \`preview_open\` returns an explicit unsupported/unavailable error. A failed T3 preview tool call should be inspected and retried with corrected arguments when the error is actionable.
1212`;
1313
14export const CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS = `<collaboration_mode># Plan Mode (Conversational)
14/**
15 * The browser block is omitted entirely when the preview tools aren't attached.
16 * Describing `preview_*` tools that aren't in the turn's tool list would be
17 * worse than saying nothing: the instructions actively steer the model away
18 * from Playwright and agent-browser, so leaving them in would talk it out of
19 * the only browser automation it still has.
20 */
21const browserToolInstructions = (browserToolsAvailable: boolean): string =>
22 browserToolsAvailable ? T3_CODE_BROWSER_TOOL_INSTRUCTIONS : "";
23
24export const codexPlanModeDeveloperInstructions = (
25 browserToolsAvailable: boolean,
26): string => `<collaboration_mode># Plan Mode (Conversational)
1527
1628You work in 3 phases, and you should *chat your way* to a great plan before finalizing it. A great plan is very detailed-intent- and implementation-wise-so that it can be handed to another engineer or agent to be implemented right away. It must be **decision complete**, where the implementer does not need to make any decisions.
1729
......@@ -139,10 +151,12 @@ Do not ask "should I proceed?" in the final output. The user can easily switch o
139151Only produce at most one \`<proposed_plan>\` block per turn, and only when you are presenting a complete spec.
140152
141153If the user stays in Plan mode and asks for revisions after a prior \`<proposed_plan>\`, any new \`<proposed_plan>\` must be a complete replacement. If the user indicates that the prior plan is not acceptable but does not provide enough information to produce a complete replacement, address the concern and continue planning without producing a \`<proposed_plan>\` block. If the follow-up neither requires changes nor calls the plan into question (e.g. clarifying question), answer it before the block, then reproduce the prior \`<proposed_plan>\` unchanged.
142${T3_CODE_BROWSER_TOOL_INSTRUCTIONS}
154${browserToolInstructions(browserToolsAvailable)}
143155</collaboration_mode>`;
144156
145export const CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS = `<collaboration_mode># Collaboration Mode: Default
157export const codexDefaultModeDeveloperInstructions = (
158 browserToolsAvailable: boolean,
159): string => `<collaboration_mode># Collaboration Mode: Default
146160
147161You are now in Default mode. Any previous instructions for other modes (e.g. Plan mode) are no longer active.
148162
......@@ -153,7 +167,7 @@ Your active mode changes only when new developer instructions with a different \
153167Use the \`request_user_input\` tool only when it is listed in the available tools for this turn.
154168
155169In Default mode, strongly prefer making reasonable assumptions and executing the user's request rather than stopping to ask questions. If you absolutely must ask a question because the answer cannot be discovered from local context and a reasonable assumption would be risky, ask the user directly with a concise plain-text question. Never write a multiple choice question as a textual assistant message.
156${T3_CODE_BROWSER_TOOL_INSTRUCTIONS}
170${browserToolInstructions(browserToolsAvailable)}
157171</collaboration_mode>`;
158172
159173export interface CodexRuntimeInfo {
......@@ -169,11 +183,17 @@ function toSingleLine(value: string): string {
169183export function buildCodexDeveloperInstructions(
170184 interactionMode: ProviderInteractionMode,
171185 runtime: CodexRuntimeInfo,
186 /**
187 * Whether the `t3-code` MCP server is attached to this turn. Callers derive
188 * it from the session's actual MCP configuration rather than re-reading the
189 * setting, so the prompt cannot claim tools the turn doesn't have.
190 */
191 browserToolsAvailable = true,
172192): string {
173193 const base =
174194 interactionMode === "plan"
175 ? CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS
176 : CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS;
195 ? codexPlanModeDeveloperInstructions(browserToolsAvailable)
196 : codexDefaultModeDeveloperInstructions(browserToolsAvailable);
177197 return `${base}
178198
179199<runtime_info>In case you're asked: you are running in T3 Code through the Codex harness, as ${toSingleLine(runtime.model)} with ${toSingleLine(runtime.reasoningEffort)} reasoning effort. No need to mention this otherwise.</runtime_info>`;
apps/server/src/provider/Layers/CodexSessionRuntime.test.ts+32-6
......@@ -11,8 +11,8 @@ import * as EffectCodexSchema from "effect-codex-app-server/schema";
1111
1212import {
1313 buildCodexDeveloperInstructions,
14 CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS,
15 CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS,
14 codexDefaultModeDeveloperInstructions,
15 codexPlanModeDeveloperInstructions,
1616} from "../CodexDeveloperInstructions.ts";
1717import { codexSessionAppServerArgs } from "./codexLaunchArgs.ts";
1818import {
......@@ -255,7 +255,7 @@ describe("buildCodexDeveloperInstructions", () => {
255255 reasoningEffort: "high",
256256 });
257257
258 NodeAssert.ok(instructions.startsWith(CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS));
258 NodeAssert.ok(instructions.startsWith(codexDefaultModeDeveloperInstructions(true)));
259259 NodeAssert.match(instructions, /T3 Code/);
260260 NodeAssert.match(instructions, /Codex harness/);
261261 NodeAssert.match(instructions, /as gpt-5\.3-codex with high reasoning effort/);
......@@ -267,7 +267,7 @@ describe("buildCodexDeveloperInstructions", () => {
267267 reasoningEffort: "medium",
268268 });
269269
270 NodeAssert.ok(instructions.startsWith(CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS));
270 NodeAssert.ok(instructions.startsWith(codexPlanModeDeveloperInstructions(true)));
271271 NodeAssert.match(instructions, /as gpt-5\.3-codex with medium reasoning effort/);
272272 });
273273
......@@ -298,8 +298,8 @@ describe("buildCodexDeveloperInstructions", () => {
298298describe("T3 browser developer instructions", () => {
299299 it("prefers the product-native preview tools in both collaboration modes", () => {
300300 for (const instructions of [
301 CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS,
302 CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS,
301 codexDefaultModeDeveloperInstructions(true),
302 codexPlanModeDeveloperInstructions(true),
303303 ]) {
304304 NodeAssert.match(instructions, /t3-code/);
305305 NodeAssert.match(instructions, /preview_status/);
......@@ -307,6 +307,32 @@ describe("T3 browser developer instructions", () => {
307307 NodeAssert.match(instructions, /Do not switch to global browser skills/);
308308 }
309309 });
310
311 it("omits the browser block entirely when the preview tools are not attached", () => {
312 for (const instructions of [
313 codexDefaultModeDeveloperInstructions(false),
314 codexPlanModeDeveloperInstructions(false),
315 ]) {
316 NodeAssert.doesNotMatch(instructions, /preview_status/);
317 NodeAssert.doesNotMatch(instructions, /preview_open/);
318 NodeAssert.doesNotMatch(instructions, /T3 Code collaborative browser/);
319 // Steering away from other browser automation must go with the tools;
320 // keeping it would leave the model talked out of its only option.
321 NodeAssert.doesNotMatch(instructions, /Do not switch to global browser skills/);
322 // The rest of the collaboration mode is untouched.
323 NodeAssert.match(instructions, /<collaboration_mode>/);
324 NodeAssert.match(instructions, /<\/collaboration_mode>/);
325 }
326 });
327
328 it("tracks the turn's MCP configuration rather than defaulting to on", () => {
329 const runtime = { model: "gpt-5.3-codex", reasoningEffort: "high" };
330 NodeAssert.match(buildCodexDeveloperInstructions("default", runtime, true), /preview_open/);
331 NodeAssert.doesNotMatch(
332 buildCodexDeveloperInstructions("default", runtime, false),
333 /preview_open/,
334 );
335 });
310336});
311337
312338describe("hasConfiguredMcpServer", () => {
apps/server/src/provider/Layers/CodexSessionRuntime.ts+13-4
......@@ -340,6 +340,7 @@ function buildCodexCollaborationMode(input: {
340340 readonly interactionMode?: ProviderInteractionMode;
341341 readonly model?: string;
342342 readonly effort?: EffectCodexSchema.V2TurnStartParams__ReasoningEffort;
343 readonly browserToolsAvailable?: boolean;
343344}): EffectCodexSchema.V2TurnStartParams__CollaborationMode | undefined {
344345 if (input.interactionMode === undefined) {
345346 return undefined;
......@@ -351,10 +352,11 @@ function buildCodexCollaborationMode(input: {
351352 settings: {
352353 model,
353354 reasoning_effort: reasoningEffort,
354 developer_instructions: buildCodexDeveloperInstructions(input.interactionMode, {
355 model,
356 reasoningEffort,
357 }),
355 developer_instructions: buildCodexDeveloperInstructions(
356 input.interactionMode,
357 { model, reasoningEffort },
358 input.browserToolsAvailable ?? true,
359 ),
358360 },
359361 };
360362}
......@@ -371,6 +373,8 @@ export function buildTurnStartParams(input: {
371373 readonly serviceTier?: CodexServiceTier;
372374 readonly effort?: EffectCodexSchema.V2TurnStartParams__ReasoningEffort;
373375 readonly interactionMode?: ProviderInteractionMode;
376 /** Defaults to true so callers that predate the agent-access gate are unchanged. */
377 readonly browserToolsAvailable?: boolean;
374378}): Effect.Effect<
375379 CodexTurnStartParamsWithCollaborationMode,
376380 CodexErrors.CodexAppServerProtocolParseError
......@@ -391,6 +395,7 @@ export function buildTurnStartParams(input: {
391395 ...(input.interactionMode ? { interactionMode: input.interactionMode } : {}),
392396 ...(input.model ? { model: input.model } : {}),
393397 ...(input.effort ? { effort: input.effort } : {}),
398 browserToolsAvailable: input.browserToolsAvailable ?? true,
394399 });
395400
396401 return decodeCodexTurnStartParamsWithCollaborationMode({
......@@ -1822,6 +1827,10 @@ export const makeCodexSessionRuntime = (
18221827 ...(input.serviceTier ? { serviceTier: input.serviceTier } : {}),
18231828 ...(input.effort ? { effort: input.effort } : {}),
18241829 ...(input.interactionMode ? { interactionMode: input.interactionMode } : {}),
1830 // Derived from the session's own MCP configuration rather than the
1831 // setting, so the prompt describes the tools this turn actually
1832 // has even if the setting changed after the session started.
1833 browserToolsAvailable: hasConfiguredMcpServer(options.appServerArgs),
18251834 });
18261835 const rawResponse = yield* client.raw.request("turn/start", params);
18271836 const response = yield* decodeV2TurnStartResponse(rawResponse).pipe(
apps/server/src/provider/Layers/ProviderService.test.ts+89-1
......@@ -12,6 +12,7 @@ import type {
1212} from "@t3tools/contracts";
1313import {
1414 ApprovalRequestId,
15 EnvironmentId,
1516 EventId,
1617 ProviderDriverKind,
1718 ProviderInstanceId,
......@@ -20,7 +21,7 @@ import {
2021 TurnId,
2122} from "@t3tools/contracts";
2223import { createModelSelection } from "@t3tools/shared/model";
23import { it, assert, vi } from "@effect/vitest";
24import { it, assert, describe, vi } from "@effect/vitest";
2425
2526import * as Effect from "effect/Effect";
2627import * as Exit from "effect/Exit";
......@@ -1957,3 +1958,90 @@ validation.layer("ProviderServiceLive validation", (it) => {
19571958 }),
19581959 );
19591960});
1961
1962describe("agent browser access", () => {
1963 const revokedThreads: Array<ThreadId> = [];
1964
1965 const startSessionWith = (enableAgentBrowserAccess: boolean, threadId: ThreadId) =>
1966 Effect.gen(function* () {
1967 const issued: Array<ThreadId> = [];
1968 const codex = makeFakeCodexAdapter();
1969 const providerAdapterLayer = Layer.succeed(
1970 ProviderAdapterRegistry.ProviderAdapterRegistry,
1971 makeAdapterRegistryMock({ [CODEX_DRIVER]: codex.adapter }),
1972 );
1973 const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe(
1974 Layer.provide(SqlitePersistenceMemory),
1975 );
1976 const directoryLayer = ProviderSessionDirectoryLive.pipe(
1977 Layer.provide(runtimeRepositoryLayer),
1978 );
1979 const providerLayer = makeProviderServiceLive({
1980 issueMcpCredential: (request) =>
1981 Effect.sync(() => {
1982 issued.push(request.threadId);
1983 return undefined;
1984 }),
1985 revokeMcpCredential: (revoked) => Effect.sync(() => void revokedThreads.push(revoked)),
1986 }).pipe(
1987 Layer.provide(providerAdapterLayer),
1988 Layer.provide(directoryLayer),
1989 Layer.provide(ServerSettings.ServerSettingsService.layerTest({ enableAgentBrowserAccess })),
1990 Layer.provide(serverConfigTestLayer),
1991 Layer.provide(AnalyticsService.layerTest),
1992 Layer.provide(
1993 Layer.succeed(
1994 ProviderEventLoggers.ProviderEventLoggers,
1995 ProviderEventLoggers.NoOpProviderEventLoggers,
1996 ),
1997 ),
1998 );
1999
2000 yield* Effect.gen(function* () {
2001 const provider = yield* ProviderService.ProviderService;
2002 return yield* provider.startSession(threadId, {
2003 provider: CODEX_DRIVER,
2004 providerInstanceId: codexInstanceId,
2005 threadId,
2006 runtimeMode: "full-access",
2007 });
2008 }).pipe(Effect.provide(providerLayer));
2009
2010 return issued;
2011 });
2012
2013 // Credential issuance is the observable that matters: it is the only place a
2014 // credential is minted, and `/mcp` accepts nothing else, so withholding it is
2015 // what actually denies every provider and external MCP client.
2016 it.effect("requests no MCP credential when agent browser access is off", () =>
2017 Effect.gen(function* () {
2018 const issued = yield* startSessionWith(false, asThreadId("thread-browser-off"));
2019
2020 assert.deepEqual(issued, []);
2021 }).pipe(Effect.provide(NodeServices.layer)),
2022 );
2023
2024 it.effect("revokes an already-issued credential when access is off", () =>
2025 Effect.gen(function* () {
2026 const threadId = asThreadId("thread-browser-revoke");
2027 revokedThreads.length = 0;
2028
2029 yield* startSessionWith(false, threadId);
2030
2031 // Clearing the in-memory map is not enough: a token issued before the
2032 // toggle flipped stays valid against `/mcp` for its whole liveness
2033 // window, and later turns refresh it.
2034 assert.deepEqual(revokedThreads, [threadId]);
2035 }).pipe(Effect.provide(NodeServices.layer)),
2036 );
2037
2038 it.effect("requests an MCP credential when agent browser access is on", () =>
2039 Effect.gen(function* () {
2040 const threadId = asThreadId("thread-browser-on");
2041
2042 const issued = yield* startSessionWith(true, threadId);
2043
2044 assert.deepEqual(issued, [threadId]);
2045 }).pipe(Effect.provide(NodeServices.layer)),
2046 );
2047});
apps/server/src/provider/Layers/ProviderService.ts+59-7
......@@ -57,6 +57,7 @@ import * as ProviderEventLoggers from "./ProviderEventLoggers.ts";
5757import * as AnalyticsService from "../../telemetry/AnalyticsService.ts";
5858import * as McpProviderSession from "../../mcp/McpProviderSession.ts";
5959import * as McpSessionRegistry from "../../mcp/McpSessionRegistry.ts";
60import * as ServerSettings from "../../serverSettings.ts";
6061const isModelSelection = Schema.is(ModelSelection);
6162
6263/**
......@@ -66,6 +67,15 @@ const isModelSelection = Schema.is(ModelSelection);
6667 */
6768export interface ProviderServiceLiveOptions {
6869 readonly canonicalEventLogger?: EventNdjsonLogger;
70 /**
71 * Overrides MCP credential issuance. The real issuer reads a module-global
72 * registry that only a running MCP server installs, which makes the
73 * agent-browser-access gate unobservable from a unit test; this seam lets a
74 * test see whether a credential was requested at all.
75 */
76 readonly issueMcpCredential?: typeof McpSessionRegistry.issueActiveMcpCredential;
77 /** Same seam as `issueMcpCredential`, for observing the deny path's revoke. */
78 readonly revokeMcpCredential?: typeof McpSessionRegistry.revokeActiveMcpThread;
6979}
7080
7181type ProviderServiceMethod<Name extends keyof ProviderService.ProviderService["Service"]> =
......@@ -215,16 +225,58 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
215225
216226 const registry = yield* ProviderAdapterRegistry.ProviderAdapterRegistry;
217227 const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory;
228 const serverSettings = yield* ServerSettings.ServerSettingsService;
229 const issueMcpCredential =
230 options?.issueMcpCredential ?? McpSessionRegistry.issueActiveMcpCredential;
231 const revokeMcpCredential =
232 options?.revokeMcpCredential ?? McpSessionRegistry.revokeActiveMcpThread;
218233 const runtimeEventPubSub = yield* PubSub.unbounded<ProviderRuntimeEvent>();
219234 const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
235 /**
236 * Attach the `t3-code` MCP server to the session that is about to start.
237 *
238 * This is the only place a credential is minted, so withholding one here is
239 * what disables agent browser access everywhere: every adapter already
240 * treats a missing session as "no MCP server", and the `/mcp` endpoint
241 * accepts nothing but tokens issued from this path.
242 */
243 /**
244 * Deny on an unreadable settings file rather than letting the read failure
245 * escape: adding `ServerSettingsError` to `ProviderServiceError` would widen
246 * a union every caller handles, for a branch that only decides whether one
247 * optional toolset is attached. Denying is the safe direction — an explicit
248 * "off" silently becoming "on" would violate the user's stated choice,
249 * whereas the reverse costs an agent one toolset and is visible immediately.
250 */
251 const agentBrowserAccessEnabled = serverSettings.getSettings.pipe(
252 Effect.map((settings) => settings.enableAgentBrowserAccess),
253 Effect.catch((cause) =>
254 Effect.logWarning(
255 "Could not read server settings; withholding agent browser access for this session.",
256 { cause },
257 ).pipe(Effect.as(false)),
258 ),
259 );
260
220261 const prepareMcpSession = (threadId: ThreadId, providerInstanceId: ProviderInstanceId) =>
221 McpSessionRegistry.issueActiveMcpCredential({ threadId, providerInstanceId }).pipe(
222 Effect.tap((credential) =>
223 credential
224 ? Effect.sync(() => McpProviderSession.setMcpProviderSession(credential.config))
225 : Effect.void,
226 ),
227 );
262 Effect.gen(function* () {
263 if (!(yield* agentBrowserAccessEnabled)) {
264 // Revoke as well as clear. Every other prepare path reaches
265 // `issueActiveMcpCredential`, which revokes the thread first, so
266 // skipping it here would leave a previously issued bearer token valid
267 // against `/mcp` for the rest of its liveness window — and later turns
268 // would keep refreshing it. A session restart (runtime mode, cwd,
269 // model) re-prepares without stopping, so it relies on this.
270 yield* revokeMcpCredential(threadId);
271 yield* Effect.sync(() => McpProviderSession.clearMcpProviderSession(threadId));
272 return undefined;
273 }
274 const credential = yield* issueMcpCredential({ threadId, providerInstanceId });
275 if (credential) {
276 yield* Effect.sync(() => McpProviderSession.setMcpProviderSession(credential.config));
277 }
278 return credential;
279 });
228280 const clearMcpSession = (threadId: ThreadId) =>
229281 McpSessionRegistry.revokeActiveMcpThread(threadId).pipe(
230282 Effect.tap(() => Effect.sync(() => McpProviderSession.clearMcpProviderSession(threadId))),
apps/web/src/components/settings/IntegrationsSettings.tsx+47-1
......@@ -10,6 +10,7 @@ import {
1010 DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW,
1111 DEFAULT_BROWSER_VIEWPORT,
1212 DEFAULT_PREVIEW_APPEARANCE,
13 DEFAULT_UNIFIED_SETTINGS,
1314 DEFAULT_PREVIEW_ZOOM_FACTOR,
1415 FILL_PREVIEW_VIEWPORT,
1516 PREVIEW_VIEWPORT_MAX_AREA,
......@@ -39,7 +40,11 @@ import {
3940} from "../ui/select";
4041import { Switch } from "../ui/switch";
4142import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
42import { useClientSettings, useUpdatePrimarySettings } from "~/hooks/useSettings";
43import {
44 useClientSettings,
45 usePrimarySettings,
46 useUpdatePrimarySettings,
47} from "~/hooks/useSettings";
4348
4449import {
4550 SettingResetButton,
......@@ -350,6 +355,44 @@ function BrowserAppearanceSetting({ disabled }: { readonly disabled: boolean })
350355 );
351356}
352357
358function AgentBrowserAccessSetting() {
359 const settings = usePrimarySettings();
360 const updateSettings = useUpdatePrimarySettings();
361
362 return (
363 <SettingsRow
364 {...searchableSetting("agent-browser-access")}
365 description="Let agents open and drive the preview browser. When off, the browser tools and the instructions describing them are withheld from agent sessions. Your own browser panel is unaffected."
366 status={
367 settings.enableAgentBrowserAccess
368 ? undefined
369 : "Applies to sessions started from now on; a running agent keeps the tools it was given."
370 }
371 resetAction={
372 settings.enableAgentBrowserAccess !== DEFAULT_UNIFIED_SETTINGS.enableAgentBrowserAccess ? (
373 <SettingResetButton
374 label="agent browser access"
375 onClick={() =>
376 updateSettings({
377 enableAgentBrowserAccess: DEFAULT_UNIFIED_SETTINGS.enableAgentBrowserAccess,
378 })
379 }
380 />
381 ) : null
382 }
383 control={
384 <Switch
385 checked={settings.enableAgentBrowserAccess}
386 onCheckedChange={(checked) =>
387 updateSettings({ enableAgentBrowserAccess: Boolean(checked) })
388 }
389 aria-label="Allow agent browser access"
390 />
391 }
392 />
393 );
394}
395
353396function BrowserAutoShowFloatingPreviewSetting({ disabled }: { readonly disabled: boolean }) {
354397 const autoShow = useClientSettings((settings) => settings.browserAutoShowFloatingPreview);
355398 const updateSettings = useUpdatePrimarySettings();
......@@ -425,6 +468,9 @@ export function IntegrationsSettingsPanel() {
425468 return (
426469 <SettingsPageContainer>
427470 <SettingsSection id="browser" title="Browser">
471 {/* Server-authoritative, so it stays editable on every client and sits
472 outside the block covering the desktop-only defaults. */}
473 <AgentBrowserAccessSetting />
428474 {previewDefaultsDisabled ? (
429475 <DesktopOnlyBrowserDefaults>{previewDefaults}</DesktopOnlyBrowserDefaults>
430476 ) : (
apps/web/src/components/settings/SettingsPanels.tsx+8
......@@ -531,6 +531,9 @@ export function useSettingsRestore(onRestored?: () => void) {
531531 : []),
532532 ...(isTextGenerationModelDirty ? ["Text generation model"] : []),
533533 ...getChangedBrowserSettingLabels(settings),
534 ...(settings.enableAgentBrowserAccess !== DEFAULT_UNIFIED_SETTINGS.enableAgentBrowserAccess
535 ? ["Agent browser access"]
536 : []),
534537 ],
535538 [
536539 isTextGenerationModelDirty,
......@@ -539,6 +542,7 @@ export function useSettingsRestore(onRestored?: () => void) {
539542 settings.browserDefaultZoomFactor,
540543 settings.browserDefaultAppearance,
541544 settings.browserAutoShowFloatingPreview,
545 settings.enableAgentBrowserAccess,
542546 settings.confirmQuit,
543547 settings.confirmThreadArchive,
544548 settings.confirmThreadDelete,
......@@ -667,6 +671,10 @@ export function useSettingsRestore(onRestored?: () => void) {
667671 browserDefaultZoomFactor: DEFAULT_UNIFIED_SETTINGS.browserDefaultZoomFactor,
668672 browserDefaultAppearance: DEFAULT_UNIFIED_SETTINGS.browserDefaultAppearance,
669673 browserAutoShowFloatingPreview: DEFAULT_UNIFIED_SETTINGS.browserAutoShowFloatingPreview,
674 // Re-granted like any other default. The confirmation dialog lists it by
675 // name, so a user restoring defaults is told the agent regains access
676 // rather than discovering it later.
677 enableAgentBrowserAccess: DEFAULT_UNIFIED_SETTINGS.enableAgentBrowserAccess,
670678 });
671679 onRestored?.();
672680 }, [
apps/web/src/components/settings/settingsSearch.ts+6
......@@ -197,6 +197,12 @@ export const SETTINGS_SEARCH_ITEMS = [
197197 title: "Providers",
198198 to: "/settings/providers",
199199 },
200 {
201 id: "agent-browser-access",
202 title: "Agent browser access",
203 to: "/settings/integrations",
204 targetId: "browser",
205 },
200206 {
201207 id: "browser-default-viewport",
202208 title: "Default browser viewport",
packages/contracts/src/settings.ts+13
......@@ -584,6 +584,18 @@ export const ServerSettings = Schema.Struct({
584584 Schema.withDecodingDefault(Effect.succeed(false)),
585585 ),
586586 enableProviderUpdateChecks: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
587 /**
588 * Whether agents may drive the in-app preview browser. Turning this off
589 * withholds the MCP credential, so the `t3-code` server (and with it every
590 * `preview_*` tool) is never attached to a provider session, and the prompt
591 * text describing those tools is dropped along with them. The user's own
592 * browser panel is unaffected — this gates agent access only.
593 *
594 * Server-authoritative rather than client-local: tool injection and prompt
595 * construction both happen on the server, and the answer must not differ
596 * between a desktop window and a phone attached to the same server.
597 */
598 enableAgentBrowserAccess: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
587599 backgroundActivity: BackgroundActivitySettings,
588600 // Legacy flat fields retained for old settings files and old clients. New
589601 // consumers should resolve `backgroundActivity` instead.
......@@ -747,6 +759,7 @@ export const ServerSettingsPatch = Schema.Struct({
747759 // Server settings
748760 enableLegacyTokenStreaming: Schema.optionalKey(Schema.Boolean),
749761 enableProviderUpdateChecks: Schema.optionalKey(Schema.Boolean),
762 enableAgentBrowserAccess: Schema.optionalKey(Schema.Boolean),
750763 backgroundActivity: Schema.optionalKey(
751764 Schema.Struct({
752765 schemaVersion: Schema.optionalKey(Schema.Literal(1)),