diff --git a/docs/assets/pr/quick-settings-browser-tools.png b/docs/assets/pr/quick-settings-browser-tools.png new file mode 100644 index 00000000000..27738579118 Binary files /dev/null and b/docs/assets/pr/quick-settings-browser-tools.png differ diff --git a/ui/src/styles/config-quick.css b/ui/src/styles/config-quick.css index 2fb160ae1f8..70a6b23abe6 100644 --- a/ui/src/styles/config-quick.css +++ b/ui/src/styles/config-quick.css @@ -214,6 +214,23 @@ color: var(--accent); } +.qs-row--tool-profile { + align-items: stretch; + flex-direction: column; + gap: 8px; + padding-block: 10px; +} + +.qs-row--tool-profile .qs-row__label { + flex: 0 0 auto; + width: 100%; +} + +.qs-row--tool-profile .qs-segmented { + align-self: stretch; + justify-content: flex-start; +} + .qs-field { display: grid; gap: 6px; diff --git a/ui/src/ui/app-render.assistant-avatar.test.ts b/ui/src/ui/app-render.assistant-avatar.test.ts index 14d06c4d195..c05cf162ddc 100644 --- a/ui/src/ui/app-render.assistant-avatar.test.ts +++ b/ui/src/ui/app-render.assistant-avatar.test.ts @@ -241,17 +241,29 @@ describe("renderApp assistant avatar routing", () => { expect(shell?.style.getPropertyValue("--chat-message-max-width")).toBe("min(1280px, 82%)"); }); - it("passes tools.exec.security to Quick Settings", () => { - renderApp( - createState({ - configForm: { - tools: { exec: { security: "full" } }, - agents: { defaults: { exec: { security: "deny" } } }, - }, - }), - ); + it("passes security quick setting fields to Quick Settings", () => { + const state = createState({ + configForm: { + browser: { enabled: false }, + tools: { profile: "messaging", exec: { security: "full" } }, + agents: { defaults: { exec: { security: "deny" } } }, + }, + }); + + renderApp(state); expect(quickSettingsProps.current?.security.execPolicy).toBe("full"); + expect(quickSettingsProps.current?.security.browserEnabled).toBe(false); + expect(quickSettingsProps.current?.security.toolProfile).toBe("messaging"); + + quickSettingsProps.current?.onBrowserEnabledToggle?.(true); + quickSettingsProps.current?.onToolProfileChange?.("full"); + + expect(state.configForm?.browser).toEqual({ enabled: true }); + expect(state.configForm?.tools).toMatchObject({ + profile: "full", + exec: { security: "full" }, + }); }); it("renders stale cron state containing a job without a payload", () => { diff --git a/ui/src/ui/app-render.exec-policy.test.ts b/ui/src/ui/app-render.exec-policy.test.ts index 5414b566d46..1833557c241 100644 --- a/ui/src/ui/app-render.exec-policy.test.ts +++ b/ui/src/ui/app-render.exec-policy.test.ts @@ -31,6 +31,22 @@ describe("extractQuickSettingsSecurity", () => { ); }); + it("reads browser enabled and tool profile from canonical config paths", () => { + const result = extractQuickSettingsSecurity( + makeState({ browser: { enabled: false }, tools: { profile: "messaging" } }), + ); + + expect(result.browserEnabled).toBe(false); + expect(result.toolProfile).toBe("messaging"); + }); + + it("uses effective quick settings defaults when browser and tool profile are unset", () => { + const result = extractQuickSettingsSecurity(makeState({})); + + expect(result.browserEnabled).toBe(true); + expect(result.toolProfile).toBe("full"); + }); + it("ignores agents.defaults.exec.security because it is not a schema path", () => { const result = extractQuickSettingsSecurity( makeState({ @@ -58,5 +74,8 @@ describe("extractQuickSettingsSecurity", () => { expect( extractQuickSettingsSecurity(makeState({ tools: { exec: { security: " " } } })).execPolicy, ).toBe("allowlist"); + expect( + extractQuickSettingsSecurity(makeState({ tools: { profile: " coding " } })).toolProfile, + ).toBe("coding"); }); }); diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 5a17c30f7f8..948d52fe26b 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -549,10 +549,18 @@ export function extractQuickSettingsSecurity(state: AppViewState): { gatewayAuth: string; execPolicy: string; deviceAuth: boolean; + browserEnabled: boolean; + toolProfile: string; } { const config = state.configForm ?? state.configSnapshot?.config; if (!config || typeof config !== "object") { - return { gatewayAuth: "unknown", execPolicy: "unknown", deviceAuth: false }; + return { + gatewayAuth: "unknown", + execPolicy: "unknown", + deviceAuth: false, + browserEnabled: true, + toolProfile: "full", + }; } const cfg = config; const gateway = @@ -579,8 +587,16 @@ export function extractQuickSettingsSecurity(state: AppViewState): { } } let execPolicy = "allowlist"; + let toolProfile = "full"; const tools = cfg.tools; if (tools && typeof tools === "object") { + const profile = (tools as Record).profile; + if (typeof profile === "string") { + const trimmedProfile = profile.trim(); + if (trimmedProfile) { + toolProfile = trimmedProfile; + } + } const exec = (tools as Record).exec; if (exec && typeof exec === "object") { const security = (exec as Record).security; @@ -592,6 +608,14 @@ export function extractQuickSettingsSecurity(state: AppViewState): { } } } + let browserEnabled = true; + const browser = + "browser" in cfg && cfg.browser && typeof cfg.browser === "object" + ? (cfg.browser as Record) + : null; + if (browser && typeof browser.enabled === "boolean") { + browserEnabled = browser.enabled; + } let deviceAuth = true; if (gateway) { const controlUi = @@ -602,7 +626,7 @@ export function extractQuickSettingsSecurity(state: AppViewState): { deviceAuth = false; } } - return { gatewayAuth, execPolicy, deviceAuth }; + return { gatewayAuth, execPolicy, deviceAuth, browserEnabled, toolProfile }; } function resolveQuickSettingsSessionRow(state: AppViewState) { @@ -1100,6 +1124,14 @@ export function renderApp(state: AppViewState) { state.configActiveSection = "auth"; requestHostUpdate?.(); }, + onBrowserEnabledToggle: (enabled) => { + updateConfigFormValue(state, ["browser", "enabled"], enabled); + requestHostUpdate?.(); + }, + onToolProfileChange: (profile) => { + updateConfigFormValue(state, ["tools", "profile"], profile); + requestHostUpdate?.(); + }, theme: state.theme, themeMode: state.themeMode, hasCustomTheme: Boolean(state.settings.customTheme), diff --git a/ui/src/ui/views/config-quick.test.ts b/ui/src/ui/views/config-quick.test.ts index 330e4236caf..974fd4a74a8 100644 --- a/ui/src/ui/views/config-quick.test.ts +++ b/ui/src/ui/views/config-quick.test.ts @@ -43,8 +43,12 @@ function createProps(overrides: Partial = {}): QuickSettings gatewayAuth: "Unknown", execPolicy: "Allowlist", deviceAuth: true, + browserEnabled: true, + toolProfile: "coding", }, onSecurityConfigure: vi.fn(), + onBrowserEnabledToggle: vi.fn(), + onToolProfileChange: vi.fn(), theme: "claw", themeMode: "system", hasCustomTheme: false, @@ -109,6 +113,45 @@ describe("renderQuickSettings", () => { expect(container.querySelectorAll(".qs-card--span-all")).toHaveLength(1); }); + it("lets operators change browser and tool profile from Security quick settings", () => { + const onBrowserEnabledToggle = vi.fn(); + const onToolProfileChange = vi.fn(); + const container = document.createElement("div"); + + render( + renderQuickSettings( + createProps({ + security: { + gatewayAuth: "token", + execPolicy: "allowlist", + deviceAuth: true, + browserEnabled: false, + toolProfile: "messaging", + }, + onBrowserEnabledToggle, + onToolProfileChange, + }), + ), + container, + ); + + const browserInput = Array.from(container.querySelectorAll("input")).find((input) => + input.closest(".qs-row")?.textContent?.includes("Browser enabled"), + ); + expect(browserInput).toBeInstanceOf(HTMLInputElement); + expect((browserInput as HTMLInputElement).checked).toBe(false); + + (browserInput as HTMLInputElement).checked = true; + browserInput?.dispatchEvent(new Event("change")); + expect(onBrowserEnabledToggle).toHaveBeenCalledWith(true); + + expectButtonByText(container, "full").click(); + expect(onToolProfileChange).toHaveBeenCalledWith("full"); + expect(expectButtonByText(container, "messaging").classList).toContain( + "qs-segmented__btn--active", + ); + }); + it("keeps the local user name fixed and shows the assistant identity", () => { const container = document.createElement("div"); diff --git a/ui/src/ui/views/config-quick.ts b/ui/src/ui/views/config-quick.ts index e1b4f1ad05d..dbc7e1ee115 100644 --- a/ui/src/ui/views/config-quick.ts +++ b/ui/src/ui/views/config-quick.ts @@ -47,6 +47,8 @@ export type QuickSettingsSecurity = { gatewayAuth: string; execPolicy: string; deviceAuth: boolean; + browserEnabled: boolean; + toolProfile: string; }; export type QuickSettingsProps = { @@ -71,6 +73,8 @@ export type QuickSettingsProps = { // Security security: QuickSettingsSecurity; onSecurityConfigure?: () => void; + onBrowserEnabledToggle?: (enabled: boolean) => void; + onToolProfileChange?: (profile: string) => void; // Appearance theme: ThemeName; @@ -136,6 +140,7 @@ const BORDER_RADIUS_STOPS: Array<{ value: BorderRadiusStop; label: string }> = [ ]; const THINKING_LEVELS = ["off", "low", "medium", "high"]; +const TOOL_PROFILES = ["minimal", "coding", "messaging", "full"]; const LOCAL_USER_LABEL = "You"; // Keep raw uploads comfortably below the 2 MB persisted data URL limit after // base64 expansion and a small MIME/header prefix are added. @@ -503,7 +508,11 @@ function renderAutomationsCard(props: QuickSettingsProps) { } function renderSecurityCard(props: QuickSettingsProps) { - const { gatewayAuth, execPolicy, deviceAuth } = props.security; + const { gatewayAuth, execPolicy, deviceAuth, browserEnabled, toolProfile } = props.security; + const normalizedToolProfile = toolProfile.trim() || "full"; + const toolProfiles = TOOL_PROFILES.includes(normalizedToolProfile) + ? TOOL_PROFILES + : [...TOOL_PROFILES, normalizedToolProfile]; return html`
@@ -525,6 +534,37 @@ function renderSecurityCard(props: QuickSettingsProps) { Exec policy ${execPolicy}
+
+ Browser enabled + +
+
+ Tool profile +
+ ${toolProfiles.map( + (profile) => html` + + `, + )} +
+
Device auth