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

merged4
main
browser-agent-access
openedAug 15, 2026byJJulius Marminge
View on GitHubTwo-way sync enabledPR last imported 5d ago
10 unresolved threads · Jump to file
Selected thread: apps/web/src/components/settings/IntegrationsSettings.tsx: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. <sup>Posted via Macroscope — <a href="https://github.com/pingdotgg/t3code/runs/95254962932">UI Consistency</a></sup>
0 / 9 viewed9 files changedLine totals unavailable

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),
M
macroscopeappBotAug 17, 2026

🟡 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:
text
1In file @apps/server/src/provider/Layers/CodexSessionRuntime.ts around line 1833:
2
3When 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

M
macroscopeappBotAug 17, 2026

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 {
M
macroscopeappBotAug 17, 2026

🟡 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)) {
M
macroscopeappBotAug 15, 2026

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

M
macroscopeappBotAug 17, 2026

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,
+              })
+            }
+          />
+        ) : null
C
cursorBotAug 17, 2026

Restore 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,
M
macroscopeappBotAug 17, 2026

🟡 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:
text
1In file @apps/web/src/components/settings/SettingsPanels.tsx around line 671:
2
3Confirming 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

M
macroscopeappBotAug 17, 2026

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

M
macroscopeappBotAug 15, 2026

🟠 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;
C
cursorBotAug 15, 2026

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.