feat(server): let users withhold browser access from agents#7083
10 unresolved threads · Jump to file
- apps/server/src/provider/Layers/CodexSessionRuntime.ts:1833 · 🟡 **Medium** `Layers/CodexSessionRuntime.ts:1833` When MCP is configured through `launchArgs`, the
- apps/web/src/components/settings/IntegrationsSettings.tsx:224–224 · `NumberField` is fully controlled here (`value` passed, no `onValueChange`), so value changes the pr
- apps/web/src/browser/browserDefaults.ts:66 · <!-- MURMUR_IGNORE --> 🟡 **Medium** `browser/browserDefaults.ts:66` When `PreviewView` toggles the
- apps/server/src/provider/Layers/ProviderService.ts:250 · This is a backend behavior change (a session may now start with no MCP credential, and a stale provi
- apps/web/src/components/settings/IntegrationsSettings.tsx:183–183 · This row's select is one size step shorter than the rows directly below it. `size="sm"` is `min-h-8
- apps/web/src/components/settings/IntegrationsSettings.tsx:381 · ### Restore defaults skips agent browser access **Medium Severity** <!-- DESCRIPTION START --> `en
- apps/web/src/components/settings/SettingsPanels.tsx:671 · 🟡 **Medium** `settings/SettingsPanels.tsx:671` Confirming an unrelated restore silently resets bro
- apps/web/src/components/settings/IntegrationsSettings.tsx:371–382 · This row offers a per-setting reset, but `enableAgentBrowserAccess` isn't part of the page-level Res
- apps/server/src/provider/Layers/ProviderService.ts:253–271 · <!-- MURMUR_IGNORE --> 🟠 **High** `Layers/ProviderService.ts:253` When agent browser access is dis
- apps/server/src/provider/Layers/ProviderService.ts:254 · ### Deny path skips credential revoke **High Severity** <!-- DESCRIPTION START --> When `enableAge
apps/server/src/provider/Layers/CodexSessionRuntime.ts · Earlier revision 093a16d6 · right 1833
Original diff
@@ -1822,6 +1827,10 @@ export const makeCodexSessionRuntime = (
...(input.serviceTier ? { serviceTier: input.serviceTier } : {}),
...(input.effort ? { effort: input.effort } : {}),
...(input.interactionMode ? { interactionMode: input.interactionMode } : {}),
+ // Derived from the session's own MCP configuration rather than the
+ // setting, so the prompt describes the tools this turn actually
+ // has even if the setting changed after the session started.
+ browserToolsAvailable: hasConfiguredMcpServer(options.appServerArgs),🟡 Medium Layers/CodexSessionRuntime.ts:1833
When MCP is configured through launchArgs, the spawned session has the preview_* tools but the generated developer instructions omit them because this check reads only options.appServerArgs. Use the resolved appServerArgs value here.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
| 1 | In file @apps/server/src/provider/Layers/CodexSessionRuntime.ts around line 1833: |
| 2 | |
| 3 | When MCP is configured through `launchArgs`, the spawned session has the `preview_*` tools but the generated developer instructions omit them because this check reads only `options.appServerArgs`. Use the resolved `appServerArgs` value here. |
apps/web/src/components/settings/IntegrationsSettings.tsx · Earlier revision 90cc93c0 · right 224–224
Original diff
@@ -0,0 +1,482 @@
+/**
+ * Integrations settings - preferences for surfaces T3 Code embeds rather than
+ * owns. Browser is the first section: the defaults a preview tab opens at,
+ * applied to both hand-opened tabs and agent `preview_open` calls that don't
+ * state their own size.
+ *
+ * @module IntegrationsSettings
+ */
+import {
+ DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW,
+ DEFAULT_BROWSER_VIEWPORT,
+ DEFAULT_PREVIEW_APPEARANCE,
+ DEFAULT_UNIFIED_SETTINGS,
+ DEFAULT_PREVIEW_ZOOM_FACTOR,
+ FILL_PREVIEW_VIEWPORT,
+ PREVIEW_VIEWPORT_MAX_AREA,
+ PREVIEW_VIEWPORT_MAX_DIMENSION,
+ PREVIEW_VIEWPORT_MIN_DIMENSION,
+ PREVIEW_ZOOM_LEVELS,
+ type PreviewAppearancePreference,
+ type PreviewViewportSetting,
+} from "@t3tools/contracts";
+import { PREVIEW_VIEWPORT_PRESETS } from "@t3tools/shared/previewViewport";
+import { InfoIcon } from "lucide-react";
+import type { ReactNode } from "react";
+
+import { ScreenRotationIcon } from "~/browser/ScreenRotationIcon";
+import { isElectron } from "../../env";
+
+import { Button } from "../ui/button";
+import { NumberField, NumberFieldGroup, NumberFieldInput } from "../ui/number-field";
+import {
+ Select,
+ SelectGroup,
+ SelectGroupLabel,
+ SelectItem,
+ SelectPopup,
+ SelectTrigger,
+ SelectValue,
+} from "../ui/select";
+import { Switch } from "../ui/switch";
+import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
+import {
+ useClientSettings,
+ usePrimarySettings,
+ useUpdatePrimarySettings,
+} from "~/hooks/useSettings";
+
+import {
+ SettingResetButton,
+ SettingsPageContainer,
+ SettingsRow,
+ SettingsSection,
+} from "./settingsLayout";
+import { searchableSetting } from "./settingsSearch";
+
+const FILL_VALUE = "fill";
+const RESPONSIVE_VALUE = "responsive";
+
+/**
+ * The size a "Responsive" default falls back to when the user switches away
+ * from Fill and hasn't typed dimensions yet. Fill has no dimensions to carry
+ * over, so the picker needs something concrete to seed the inputs with.
+ */
+const RESPONSIVE_SEED_SIZE = { width: 1280, height: 800 } as const;
+
+const NO_GROUPING: Intl.NumberFormatOptions = { useGrouping: false };
+
+const APPEARANCE_LABELS: Readonly<Record<PreviewAppearancePreference, string>> = {
+ system: "System",
+ light: "Light",
+ dark: "Dark",
+};
+
+const zoomLabel = (zoomFactor: number) => `${Math.round(zoomFactor * 100)}%`;
+
+const viewportSelectValue = (viewport: PreviewViewportSetting): string => {
+ if (viewport._tag === "fill") return FILL_VALUE;
+ if (
+ viewport._tag === "preset" &&
+ PREVIEW_VIEWPORT_PRESETS.some((preset) => preset.id === viewport.presetId)
+ ) {
+ return viewport.presetId;
+ }
+ return RESPONSIVE_VALUE;
+};
+
+/**
+ * The trigger renders this rather than a bare `SelectValue`, which would fall
+ * back to printing the raw stored value ("fill") because the options are built
+ * inline instead of from an `items` map.
+ */
+const viewportSelectLabel = (viewport: PreviewViewportSetting): string => {
+ const value = viewportSelectValue(viewport);
+ if (value === FILL_VALUE) return "Fill panel";
+ if (value === RESPONSIVE_VALUE) return "Responsive";
+ return PREVIEW_VIEWPORT_PRESETS.find((preset) => preset.id === value)?.label ?? "Responsive";
+};
+
+const isValidDimension = (value: number) =>
+ Number.isInteger(value) &&
+ value >= PREVIEW_VIEWPORT_MIN_DIMENSION &&
+ value <= PREVIEW_VIEWPORT_MAX_DIMENSION;
+
+/**
+ * A sized viewport with width and height swapped. Presets keep their identity
+ * through a rotation — `resolvePreviewViewport` already stores rotated presets
+ * as the preset id plus swapped dimensions — so a rotated iPad is still an
+ * iPad, not an anonymous custom size.
+ */
+const rotateViewport = (
+ viewport: Exclude<PreviewViewportSetting, { readonly _tag: "fill" }>,
+): PreviewViewportSetting => ({
+ ...viewport,
+ width: viewport.height,
+ height: viewport.width,
+});
+
+function BrowserViewportSetting({ disabled }: { readonly disabled: boolean }) {
+ const viewport = useClientSettings((settings) => settings.browserDefaultViewport);
+ const updateSettings = useUpdatePrimarySettings();
+
+ const sized = viewport._tag === "fill" ? null : viewport;
+ const presentedSize = {
+ width: sized?.width ?? RESPONSIVE_SEED_SIZE.width,
+ height: sized?.height ?? RESPONSIVE_SEED_SIZE.height,
+ };
+
+ const selectViewport = (value: string | null) => {
+ if (value === FILL_VALUE) {
+ updateSettings({ browserDefaultViewport: FILL_PREVIEW_VIEWPORT });
+ return;
+ }
+ if (value === RESPONSIVE_VALUE) {
+ updateSettings({
+ browserDefaultViewport: {
+ _tag: "freeform",
+ width: sized?.width ?? RESPONSIVE_SEED_SIZE.width,
+ height: sized?.height ?? RESPONSIVE_SEED_SIZE.height,
+ },
+ });
+ return;
+ }
+ const preset = PREVIEW_VIEWPORT_PRESETS.find((candidate) => candidate.id === value);
+ if (!preset) return;
+ updateSettings({
+ browserDefaultViewport: {
+ _tag: "preset",
+ width: preset.width,
+ height: preset.height,
+ presetId: preset.id,
+ },
+ });
+ };
+
+ // Committed on blur rather than per keystroke: typing "2560" passes through
+ // "256", which is a legal dimension, so an onValueChange handler would
+ // persist that intermediate size and churn the settings file on every key.
+ const commitDimension = (axis: "width" | "height", value: number | null) => {
+ if (value === null || !isValidDimension(value)) return;
+ const next = { ...presentedSize, [axis]: value };
+ if (next.width * next.height > PREVIEW_VIEWPORT_MAX_AREA) return;
+ if (sized && next.width === sized.width && next.height === sized.height) return;
+ // Typing a size means the preset no longer describes it.
+ updateSettings({ browserDefaultViewport: { _tag: "freeform", ...next } });
+ };
+
+ return (
+ <SettingsRow
+ {...searchableSetting("browser-default-viewport")}
+ description="The viewport a browser tab opens at, for both you and agents. Fill sizes the page to the panel; any other choice opens the device toolbar at that size."
+ resetAction={
+ !disabled && viewport._tag !== DEFAULT_BROWSER_VIEWPORT._tag ? (
+ <SettingResetButton
+ label="default browser viewport"
+ onClick={() => updateSettings({ browserDefaultViewport: DEFAULT_BROWSER_VIEWPORT })}
+ />
+ ) : null
+ }
+ control={
+ <div className="flex w-full flex-wrap items-center justify-end gap-2 sm:w-auto">
+ <Select
+ value={viewportSelectValue(viewport)}
+ onValueChange={selectViewport}
+ disabled={disabled}
+ >
+ <SelectTrigger
+ size="sm"
+ className="w-full min-w-0 sm:w-44"
+ aria-label="Default browser viewport"
+ >
+ <SelectValue>{viewportSelectLabel(viewport)}</SelectValue>
+ </SelectTrigger>
+ <SelectPopup align="end" alignItemWithTrigger={false} className="min-w-64">
+ <SelectItem value={FILL_VALUE}>Fill panel</SelectItem>
+ <SelectItem value={RESPONSIVE_VALUE}>Responsive</SelectItem>
+ <SelectGroup>
+ <SelectGroupLabel>Standard</SelectGroupLabel>
+ {PREVIEW_VIEWPORT_PRESETS.map((preset) => (
+ <SelectItem key={preset.id} value={preset.id}>
+ <span className="flex w-full items-center justify-between gap-5">
+ <span>{preset.label}</span>
+ <span className="text-xs tabular-nums text-muted-foreground">
+ {preset.detail}
+ </span>
+ </span>
+ </SelectItem>
+ ))}
+ </SelectGroup>
+ </SelectPopup>
+ </Select>
+
+ {sized ? (
+ <div className="flex min-w-0 items-center gap-1">
+ <NumberField
+ value={presentedSize.width}
+ min={PREVIEW_VIEWPORT_MIN_DIMENSION}
+ max={PREVIEW_VIEWPORT_MAX_DIMENSION}
+ disabled={disabled}
+ // Pixel counts read as raw numbers; grouping would show "1,024".
+ format={NO_GROUPING}
+ size="sm"
+ className="w-20"
+ onValueCommitted={(value) => commitDimension("width", value)}apps/web/src/components/settings/IntegrationsSettings.tsx · right 224–224
NumberField is fully controlled here (value passed, no onValueChange), so value changes the primitive initiates itself — ArrowUp/ArrowDown stepping, scroll-wheel — are dropped: the parent never echoes them, and with no increment/decrement parts rendered, typing is the only way to change the size. Every other NumberField call site in the app pairs value with onValueChange for this reason.
Suggest keeping a local draft via onValueChange and persisting in onValueCommitted, i.e. the same draft-then-commit shape BrowserDeviceToolbar uses for the identical width/height pair — and mirroring its aria-invalid so a size commitDimension rejects (over PREVIEW_VIEWPORT_MAX_AREA) isn't silently ignored while the input keeps showing it.
Posted via Macroscope — UI Consistency
apps/web/src/browser/browserDefaults.ts · Earlier revision 90cc93c0 · right 66
Original diff
@@ -0,0 +1,114 @@
+/**
+ * Browser defaults - resolves the configured starting state for preview tabs.
+ *
+ * Settings → Integrations → Browser lets the user pick the viewport, zoom, and
+ * appearance a preview tab should open at. Those preferences apply to every
+ * entry point that opens a tab without stating its own: the user opening a
+ * browser panel, and agents calling `preview_open` with no size.
+ *
+ * The values live in client settings because the Chromium guest they configure
+ * is desktop-local, so this module reads them through the same external store
+ * the settings UI writes to, and exposes a non-hook accessor for the imperative
+ * open paths that run outside React.
+ *
+ * @module browserDefaults
+ */
+import type {
+ DesktopPreviewTabDefaults,
+ PreviewAppearancePreference,
+ PreviewViewportSetting,
+} from "@t3tools/contracts";
+
+import {
+ ensureClientSettingsHydrated,
+ getClientSettings,
+ useClientSettings,
+} from "~/hooks/useSettings";
+
+import { resolveResponsiveBrowserViewportSize } from "./browserViewportLayout";
+
+export interface BrowserDefaults {
+ readonly viewport: PreviewViewportSetting;
+ readonly zoomFactor: number;
+ readonly appearance: PreviewAppearancePreference;
+ readonly autoShowFloatingPreview: boolean;
+}
+
+const toBrowserDefaults = (settings: {
+ readonly browserDefaultViewport: PreviewViewportSetting;
+ readonly browserDefaultZoomFactor: number;
+ readonly browserDefaultAppearance: PreviewAppearancePreference;
+ readonly browserAutoShowFloatingPreview: boolean;
+}): BrowserDefaults => ({
+ viewport: settings.browserDefaultViewport,
+ zoomFactor: settings.browserDefaultZoomFactor,
+ appearance: settings.browserDefaultAppearance,
+ autoShowFloatingPreview: settings.browserAutoShowFloatingPreview,
+});
+
+/** Non-hook accessor for imperative open paths (menu actions, automation hosts). */
+export function getBrowserDefaults(): BrowserDefaults {
+ return toBrowserDefaults(getClientSettings());
+}
+
+/**
+ * The defaults, once client settings have actually loaded.
+ *
+ * Opening a preview is asynchronous anyway, and before hydration the snapshot
+ * is the schema defaults rather than the user's — a tab opened in that window
+ * would be born at the wrong viewport, zoom and appearance and never corrected.
+ */
+export async function resolveBrowserDefaults(): Promise<BrowserDefaults> {
+ await ensureClientSettingsHydrated();
+ return getBrowserDefaults();
+}
+
+export function useBrowserDefaults(): BrowserDefaults {🟡 Medium browser/browserDefaults.ts:66
When PreviewView toggles the device toolbar during startup, useBrowserDefaults returns the schema-default viewport and the toggle commits that built-in viewport instead of the user's persisted browser default; the viewport is not corrected after hydration. Unlike the imperative accessors, this hook is not hydration-gated, so make the toggle wait for client-settings hydration (or expose hydration-aware hook state) before applying the default.
No longer relevant as of 0d31f1163ef219ee4489380e5e63400c77a4ddac
apps/server/src/provider/Layers/ProviderService.ts · Earlier revision 33ee09b2 · right 250
Original diff
@@ -215,16 +216,52 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
const registry = yield* ProviderAdapterRegistry.ProviderAdapterRegistry;
const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory;
+ const serverSettings = yield* ServerSettings.ServerSettingsService;
const runtimeEventPubSub = yield* PubSub.unbounded<ProviderRuntimeEvent>();
const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
+ /**
+ * Attach the `t3-code` MCP server to the session that is about to start.
+ *
+ * This is the only place a credential is minted, so withholding one here is
+ * what disables agent browser access everywhere: every adapter already
+ * treats a missing session as "no MCP server", and the `/mcp` endpoint
+ * accepts nothing but tokens issued from this path.
+ */
+ /**
+ * Deny on an unreadable settings file rather than letting the read failure
+ * escape: adding `ServerSettingsError` to `ProviderServiceError` would widen
+ * a union every caller handles, for a branch that only decides whether one
+ * optional toolset is attached. Denying is the safe direction — an explicit
+ * "off" silently becoming "on" would violate the user's stated choice,
+ * whereas the reverse costs an agent one toolset and is visible immediately.
+ */
+ const agentBrowserAccessEnabled = serverSettings.getSettings.pipe(
+ Effect.map((settings) => settings.enableAgentBrowserAccess),
+ Effect.catch((cause) =>
+ Effect.logWarning(
+ "Could not read server settings; withholding agent browser access for this session.",
+ { cause },
+ ).pipe(Effect.as(false)),
+ ),
+ );
+
const prepareMcpSession = (threadId: ThreadId, providerInstanceId: ProviderInstanceId) =>
- McpSessionRegistry.issueActiveMcpCredential({ threadId, providerInstanceId }).pipe(
- Effect.tap((credential) =>
- credential
- ? Effect.sync(() => McpProviderSession.setMcpProviderSession(credential.config))
- : Effect.void,
- ),
- );
+ Effect.gen(function* () {
+ if (!(yield* agentBrowserAccessEnabled)) {This is a backend behavior change (a session may now start with no MCP credential, and a stale provider session is cleared), but the only new tests cover prompt text in CodexSessionRuntime.test.ts. Consider adding a focused test in ProviderService.test.ts using ServerSettings.ServerSettingsService.layerTest({ enableAgentBrowserAccess: false }) asserting that startSession issues no credential and clears the thread's MCP provider session, plus the enabled counterpart — the existing suite already wires a test settings layer, so it is a small addition.
Posted via Macroscope — Effect Service Conventions
apps/web/src/components/settings/IntegrationsSettings.tsx · Earlier revision 90cc93c0 · right 183–183
Original diff
@@ -0,0 +1,482 @@
+/**
+ * Integrations settings - preferences for surfaces T3 Code embeds rather than
+ * owns. Browser is the first section: the defaults a preview tab opens at,
+ * applied to both hand-opened tabs and agent `preview_open` calls that don't
+ * state their own size.
+ *
+ * @module IntegrationsSettings
+ */
+import {
+ DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW,
+ DEFAULT_BROWSER_VIEWPORT,
+ DEFAULT_PREVIEW_APPEARANCE,
+ DEFAULT_UNIFIED_SETTINGS,
+ DEFAULT_PREVIEW_ZOOM_FACTOR,
+ FILL_PREVIEW_VIEWPORT,
+ PREVIEW_VIEWPORT_MAX_AREA,
+ PREVIEW_VIEWPORT_MAX_DIMENSION,
+ PREVIEW_VIEWPORT_MIN_DIMENSION,
+ PREVIEW_ZOOM_LEVELS,
+ type PreviewAppearancePreference,
+ type PreviewViewportSetting,
+} from "@t3tools/contracts";
+import { PREVIEW_VIEWPORT_PRESETS } from "@t3tools/shared/previewViewport";
+import { InfoIcon } from "lucide-react";
+import type { ReactNode } from "react";
+
+import { ScreenRotationIcon } from "~/browser/ScreenRotationIcon";
+import { isElectron } from "../../env";
+
+import { Button } from "../ui/button";
+import { NumberField, NumberFieldGroup, NumberFieldInput } from "../ui/number-field";
+import {
+ Select,
+ SelectGroup,
+ SelectGroupLabel,
+ SelectItem,
+ SelectPopup,
+ SelectTrigger,
+ SelectValue,
+} from "../ui/select";
+import { Switch } from "../ui/switch";
+import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
+import {
+ useClientSettings,
+ usePrimarySettings,
+ useUpdatePrimarySettings,
+} from "~/hooks/useSettings";
+
+import {
+ SettingResetButton,
+ SettingsPageContainer,
+ SettingsRow,
+ SettingsSection,
+} from "./settingsLayout";
+import { searchableSetting } from "./settingsSearch";
+
+const FILL_VALUE = "fill";
+const RESPONSIVE_VALUE = "responsive";
+
+/**
+ * The size a "Responsive" default falls back to when the user switches away
+ * from Fill and hasn't typed dimensions yet. Fill has no dimensions to carry
+ * over, so the picker needs something concrete to seed the inputs with.
+ */
+const RESPONSIVE_SEED_SIZE = { width: 1280, height: 800 } as const;
+
+const NO_GROUPING: Intl.NumberFormatOptions = { useGrouping: false };
+
+const APPEARANCE_LABELS: Readonly<Record<PreviewAppearancePreference, string>> = {
+ system: "System",
+ light: "Light",
+ dark: "Dark",
+};
+
+const zoomLabel = (zoomFactor: number) => `${Math.round(zoomFactor * 100)}%`;
+
+const viewportSelectValue = (viewport: PreviewViewportSetting): string => {
+ if (viewport._tag === "fill") return FILL_VALUE;
+ if (
+ viewport._tag === "preset" &&
+ PREVIEW_VIEWPORT_PRESETS.some((preset) => preset.id === viewport.presetId)
+ ) {
+ return viewport.presetId;
+ }
+ return RESPONSIVE_VALUE;
+};
+
+/**
+ * The trigger renders this rather than a bare `SelectValue`, which would fall
+ * back to printing the raw stored value ("fill") because the options are built
+ * inline instead of from an `items` map.
+ */
+const viewportSelectLabel = (viewport: PreviewViewportSetting): string => {
+ const value = viewportSelectValue(viewport);
+ if (value === FILL_VALUE) return "Fill panel";
+ if (value === RESPONSIVE_VALUE) return "Responsive";
+ return PREVIEW_VIEWPORT_PRESETS.find((preset) => preset.id === value)?.label ?? "Responsive";
+};
+
+const isValidDimension = (value: number) =>
+ Number.isInteger(value) &&
+ value >= PREVIEW_VIEWPORT_MIN_DIMENSION &&
+ value <= PREVIEW_VIEWPORT_MAX_DIMENSION;
+
+/**
+ * A sized viewport with width and height swapped. Presets keep their identity
+ * through a rotation — `resolvePreviewViewport` already stores rotated presets
+ * as the preset id plus swapped dimensions — so a rotated iPad is still an
+ * iPad, not an anonymous custom size.
+ */
+const rotateViewport = (
+ viewport: Exclude<PreviewViewportSetting, { readonly _tag: "fill" }>,
+): PreviewViewportSetting => ({
+ ...viewport,
+ width: viewport.height,
+ height: viewport.width,
+});
+
+function BrowserViewportSetting({ disabled }: { readonly disabled: boolean }) {
+ const viewport = useClientSettings((settings) => settings.browserDefaultViewport);
+ const updateSettings = useUpdatePrimarySettings();
+
+ const sized = viewport._tag === "fill" ? null : viewport;
+ const presentedSize = {
+ width: sized?.width ?? RESPONSIVE_SEED_SIZE.width,
+ height: sized?.height ?? RESPONSIVE_SEED_SIZE.height,
+ };
+
+ const selectViewport = (value: string | null) => {
+ if (value === FILL_VALUE) {
+ updateSettings({ browserDefaultViewport: FILL_PREVIEW_VIEWPORT });
+ return;
+ }
+ if (value === RESPONSIVE_VALUE) {
+ updateSettings({
+ browserDefaultViewport: {
+ _tag: "freeform",
+ width: sized?.width ?? RESPONSIVE_SEED_SIZE.width,
+ height: sized?.height ?? RESPONSIVE_SEED_SIZE.height,
+ },
+ });
+ return;
+ }
+ const preset = PREVIEW_VIEWPORT_PRESETS.find((candidate) => candidate.id === value);
+ if (!preset) return;
+ updateSettings({
+ browserDefaultViewport: {
+ _tag: "preset",
+ width: preset.width,
+ height: preset.height,
+ presetId: preset.id,
+ },
+ });
+ };
+
+ // Committed on blur rather than per keystroke: typing "2560" passes through
+ // "256", which is a legal dimension, so an onValueChange handler would
+ // persist that intermediate size and churn the settings file on every key.
+ const commitDimension = (axis: "width" | "height", value: number | null) => {
+ if (value === null || !isValidDimension(value)) return;
+ const next = { ...presentedSize, [axis]: value };
+ if (next.width * next.height > PREVIEW_VIEWPORT_MAX_AREA) return;
+ if (sized && next.width === sized.width && next.height === sized.height) return;
+ // Typing a size means the preset no longer describes it.
+ updateSettings({ browserDefaultViewport: { _tag: "freeform", ...next } });
+ };
+
+ return (
+ <SettingsRow
+ {...searchableSetting("browser-default-viewport")}
+ description="The viewport a browser tab opens at, for both you and agents. Fill sizes the page to the panel; any other choice opens the device toolbar at that size."
+ resetAction={
+ !disabled && viewport._tag !== DEFAULT_BROWSER_VIEWPORT._tag ? (
+ <SettingResetButton
+ label="default browser viewport"
+ onClick={() => updateSettings({ browserDefaultViewport: DEFAULT_BROWSER_VIEWPORT })}
+ />
+ ) : null
+ }
+ control={
+ <div className="flex w-full flex-wrap items-center justify-end gap-2 sm:w-auto">
+ <Select
+ value={viewportSelectValue(viewport)}apps/web/src/components/settings/IntegrationsSettings.tsx · right 183–183
This row's select is one size step shorter than the rows directly below it. size="sm" is min-h-8 sm:min-h-7, while every other select rendered inside a SettingsRow (including BrowserZoomSetting and BrowserAppearanceSetting a few lines down, and the Appearance/General panels) uses the default min-h-9 sm:min-h-8, so the Browser section ends up with two different control heights stacked on top of each other.
Suggest dropping size="sm" here and from the two NumberFields below it (their sm height is what currently lines up with this trigger), so the whole section shares the standard control height.
Posted via Macroscope — UI Consistency
apps/web/src/components/settings/IntegrationsSettings.tsx · Earlier revision 093a16d6 · right 381
Original diff
@@ -350,6 +355,44 @@ function BrowserAppearanceSetting({ disabled }: { readonly disabled: boolean })
);
}
+function AgentBrowserAccessSetting() {
+ const settings = usePrimarySettings();
+ const updateSettings = useUpdatePrimarySettings();
+
+ return (
+ <SettingsRow
+ {...searchableSetting("agent-browser-access")}
+ 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."
+ status={
+ settings.enableAgentBrowserAccess
+ ? undefined
+ : "Applies to sessions started from now on; a running agent keeps the tools it was given."
+ }
+ resetAction={
+ settings.enableAgentBrowserAccess !== DEFAULT_UNIFIED_SETTINGS.enableAgentBrowserAccess ? (
+ <SettingResetButton
+ label="agent browser access"
+ onClick={() =>
+ updateSettings({
+ enableAgentBrowserAccess: DEFAULT_UNIFIED_SETTINGS.enableAgentBrowserAccess,
+ })
+ }
+ />
+ ) : nullRestore defaults skips agent browser access
Medium Severity
enableAgentBrowserAccess has a per-row reset on Integrations, but General → Restore defaults never dirty-tracks or resets it. Peer server toggles like enableProviderUpdateChecks and the other Browser defaults are included. After Restore defaults, agent browser access can stay off with no mention in the confirmation list.
Triggered by learned rule: Restore defaults on General settings page is intentionally global — not scoped per-page
Reviewed by Cursor Bugbot for commit 093a16d6a426c5340b4016ec16b7c9141efbb7bf. Configure here.
apps/web/src/components/settings/SettingsPanels.tsx · Earlier revision 90cc93c0 · right 671
Original diff
@@ -658,6 +668,14 @@ export function useSettingsRestore(onRestored?: () => void) {
fontSizePrompt: DEFAULT_UNIFIED_SETTINGS.fontSizePrompt,
fontSizeCode: DEFAULT_UNIFIED_SETTINGS.fontSizeCode,
fontSizeTerminal: DEFAULT_UNIFIED_SETTINGS.fontSizeTerminal,
+ browserDefaultViewport: DEFAULT_UNIFIED_SETTINGS.browserDefaultViewport,🟡 Medium settings/SettingsPanels.tsx:671
Confirming an unrelated restore silently resets browser viewport, zoom, appearance, or auto-show preferences changed in another tab while the dialog was open, even though those settings were not listed for restoration. updateSettings always writes all four browserDefault* fields after confirmation; re-read the live settings or update only fields included in the confirmed dirty snapshot.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
| 1 | In file @apps/web/src/components/settings/SettingsPanels.tsx around line 671: |
| 2 | |
| 3 | Confirming an unrelated restore silently resets browser viewport, zoom, appearance, or auto-show preferences changed in another tab while the dialog was open, even though those settings were not listed for restoration. `updateSettings` always writes all four `browserDefault*` fields after confirmation; re-read the live settings or update only fields included in the confirmed dirty snapshot. |
apps/web/src/components/settings/IntegrationsSettings.tsx · Earlier revision 093a16d6 · right 371–382
Original diff
@@ -350,6 +355,44 @@ function BrowserAppearanceSetting({ disabled }: { readonly disabled: boolean })
);
}
+function AgentBrowserAccessSetting() {
+ const settings = usePrimarySettings();
+ const updateSettings = useUpdatePrimarySettings();
+
+ return (
+ <SettingsRow
+ {...searchableSetting("agent-browser-access")}
+ 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."
+ status={
+ settings.enableAgentBrowserAccess
+ ? undefined
+ : "Applies to sessions started from now on; a running agent keeps the tools it was given."
+ }
+ resetAction={
+ settings.enableAgentBrowserAccess !== DEFAULT_UNIFIED_SETTINGS.enableAgentBrowserAccess ? (
+ <SettingResetButton
+ label="agent browser access"
+ onClick={() =>
+ updateSettings({
+ enableAgentBrowserAccess: DEFAULT_UNIFIED_SETTINGS.enableAgentBrowserAccess,
+ })
+ }
+ />
+ ) : null
+ }apps/web/src/components/settings/IntegrationsSettings.tsx · right 371–382
This row offers a per-setting reset, but enableAgentBrowserAccess isn't part of the page-level Restore defaults flow. getChangedBrowserSettingLabels (SettingsPanels.logic.ts) and the restoreDefaults patch (SettingsPanels.tsx) cover the four browser client defaults in this same section but not this toggle, so "Restore defaults" leaves agent access off and never mentions it in the confirmation dialog — inconsistent with every other reset-able row here.
Smallest fix: add enableAgentBrowserAccess to the changed-label list (e.g. "Agent browser access", plus the memo dependency) and to the updateSettings({ ... }) restore patch. If bulk restore should deliberately not re-grant agent access, a one-line comment stating that would keep the exception intentional.
Posted via Macroscope — UI Consistency
apps/server/src/provider/Layers/ProviderService.ts · Earlier revision 33ee09b2 · right 253–271
Original diff
@@ -215,16 +216,52 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
const registry = yield* ProviderAdapterRegistry.ProviderAdapterRegistry;
const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory;
+ const serverSettings = yield* ServerSettings.ServerSettingsService;
const runtimeEventPubSub = yield* PubSub.unbounded<ProviderRuntimeEvent>();
const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
+ /**
+ * Attach the `t3-code` MCP server to the session that is about to start.
+ *
+ * This is the only place a credential is minted, so withholding one here is
+ * what disables agent browser access everywhere: every adapter already
+ * treats a missing session as "no MCP server", and the `/mcp` endpoint
+ * accepts nothing but tokens issued from this path.
+ */
+ /**
+ * Deny on an unreadable settings file rather than letting the read failure
+ * escape: adding `ServerSettingsError` to `ProviderServiceError` would widen
+ * a union every caller handles, for a branch that only decides whether one
+ * optional toolset is attached. Denying is the safe direction — an explicit
+ * "off" silently becoming "on" would violate the user's stated choice,
+ * whereas the reverse costs an agent one toolset and is visible immediately.
+ */
+ const agentBrowserAccessEnabled = serverSettings.getSettings.pipe(
+ Effect.map((settings) => settings.enableAgentBrowserAccess),
+ Effect.catch((cause) =>
+ Effect.logWarning(
+ "Could not read server settings; withholding agent browser access for this session.",
+ { cause },
+ ).pipe(Effect.as(false)),
+ ),
+ );
+
const prepareMcpSession = (threadId: ThreadId, providerInstanceId: ProviderInstanceId) =>
- McpSessionRegistry.issueActiveMcpCredential({ threadId, providerInstanceId }).pipe(
- Effect.tap((credential) =>
- credential
- ? Effect.sync(() => McpProviderSession.setMcpProviderSession(credential.config))
- : Effect.void,
- ),
- );
+ Effect.gen(function* () {
+ if (!(yield* agentBrowserAccessEnabled)) {
+ // A stale session from before the toggle flipped would otherwise keep
+ // its tools for the rest of its life.
+ yield* Effect.sync(() => McpProviderSession.clearMcpProviderSession(threadId));apps/server/src/provider/Layers/ProviderService.ts · right 253–271
🟠 High Layers/ProviderService.ts:253
When agent browser access is disabled for a thread, its previously issued MCP credential remains valid and /mcp continues accepting the old token until expiry. This branch skips issueActiveMcpCredential (the normal revocation path) and only clears McpProviderSession; revoke the thread with revokeActiveMcpThread(threadId) before clearing the provider session.
✅ Resolved in b4d14de34078cf5f87d6ff04c34667954e95b4e3
apps/server/src/provider/Layers/ProviderService.ts · Earlier revision 35870696 · right 254
Original diff
@@ -215,16 +216,52 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
const registry = yield* ProviderAdapterRegistry.ProviderAdapterRegistry;
const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory;
+ const serverSettings = yield* ServerSettings.ServerSettingsService;
const runtimeEventPubSub = yield* PubSub.unbounded<ProviderRuntimeEvent>();
const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
+ /**
+ * Attach the `t3-code` MCP server to the session that is about to start.
+ *
+ * This is the only place a credential is minted, so withholding one here is
+ * what disables agent browser access everywhere: every adapter already
+ * treats a missing session as "no MCP server", and the `/mcp` endpoint
+ * accepts nothing but tokens issued from this path.
+ */
+ /**
+ * Deny on an unreadable settings file rather than letting the read failure
+ * escape: adding `ServerSettingsError` to `ProviderServiceError` would widen
+ * a union every caller handles, for a branch that only decides whether one
+ * optional toolset is attached. Denying is the safe direction — an explicit
+ * "off" silently becoming "on" would violate the user's stated choice,
+ * whereas the reverse costs an agent one toolset and is visible immediately.
+ */
+ const agentBrowserAccessEnabled = serverSettings.getSettings.pipe(
+ Effect.map((settings) => settings.enableAgentBrowserAccess),
+ Effect.catch((cause) =>
+ Effect.logWarning(
+ "Could not read server settings; withholding agent browser access for this session.",
+ { cause },
+ ).pipe(Effect.as(false)),
+ ),
+ );
+
const prepareMcpSession = (threadId: ThreadId, providerInstanceId: ProviderInstanceId) =>
- McpSessionRegistry.issueActiveMcpCredential({ threadId, providerInstanceId }).pipe(
- Effect.tap((credential) =>
- credential
- ? Effect.sync(() => McpProviderSession.setMcpProviderSession(credential.config))
- : Effect.void,
- ),
- );
+ Effect.gen(function* () {
+ if (!(yield* agentBrowserAccessEnabled)) {
+ // A stale session from before the toggle flipped would otherwise keep
+ // its tools for the rest of its life.
+ yield* Effect.sync(() => McpProviderSession.clearMcpProviderSession(threadId));
+ return undefined;Deny path skips credential revoke
High Severity
When enableAgentBrowserAccess is off, prepareMcpSession only clears the in-memory MCP map and never calls revokeActiveMcpThread. Before this change, every prepare path went through issueActiveMcpCredential, which always revokes the thread first. Session restarts (runtime mode, cwd, model) call startSession without stopSession, so they relied on that revoke. Orphaned bearer tokens stay valid, and touchActiveMcpThread on later turns refreshes them for up to the full liveness window—longer if turns keep coming. If replacing an old process fails, that agent can keep driving /mcp after the user withheld access.
Additional Locations (2)
Reviewed by Cursor Bugbot for commit 358706969b796ffbc757b5f50d68f61e2f778f24. Configure here.