From 76dc554f672f3eefa523d6c1e78277768ce4b625 Mon Sep 17 00:00:00 2001 From: CrysisDeu Date: Sun, 25 Oct 2026 09:15:52 +0000 Subject: [PATCH] fix(desktop): stop policy provider competing with desktop updater on bundled installs Two topologies let the gateway's security policy provider and the Electron app's own updater act simultaneously on the same bundled install: 1. **Policy provider on a desktop install.** When a security_policy.json `updates` block configures `check_command`/`apply_command`, the gateway runs the provider; the Electron auto-updater also checks independently. Both can find and apply an update at the same time. Per governance.md, the provider owns the check, the badge and the Update button and is resolved before any layout's own updater or deferral: the fix makes the Electron feed lane stand down when the gateway reports the provider governs. 2. **Reused gateway (CLI service, SSH tunnel).** The About page's gateway auto-update switch was hidden in a desktop window when the effect was unknown, because `update_bundled_by_app` was not yet used to tell the bundled gateway apart from a reused one. Changes: - `website/electron/runtime/update/feed-lane.js`: add `isPolicyGoverned` parameter; `safeCheck()` returns early with `not-available` when governed, so the Electron auto-updater does not contact the feed or fire a notification. - `website/electron/auto-update.js`: add `isPolicyGoverned` parameter; pass it to `createFeedLane`. - `website/electron/ipc-registrar.js`: add `policyGoverned` variable; add `update:notify-policy-governed` IPC handler; wire `isPolicyGoverned`. - `website/electron/preload.js`: expose `notifyPolicyGoverned` on `updateAPI`. - `website/src/types/electron-bridge.d.ts`: add `notifyPolicyGoverned` to `UpdateAPI`. - `website/src/hooks/useUpdateSubscription.ts`: add `usePolicyGovernedNotification` hook that calls `notifyPolicyGoverned` whenever `update_bundled_by_app && update_managed_by === 'command'` changes. - `website/src/shell/appShell.tsx`: import and call `usePolicyGovernedNotification` after `useUpdateSubscription` (moved from App.tsx due to shell refactoring). - `website/src/pages/settings/AboutPanel.tsx` (policy-managed branch): render `AboutPanelGatewayUpdates` alongside the policy notice so a manual update path remains available even when the Electron feed lane stands down. - `website/src/components/UpdateFoundModal.tsx`: gate the desktop candidate on `!gwPolicyManaged` so the modal does not interrupt a bundled install whose updates the policy provider owns. - `website/src/pages/settings/AboutPanel.tsx`: when `isGatewayPolicyManaged`, replace the desktop update card with a "managed by policy" notice. - `website/src/utils/updateSwitches.ts` (topology 2): `updateSwitchesShown` accepts optional `bundledByApp` from the status frame; the gateway switch is shown when `bundledByApp === false` (a reused gateway that manages its own bytes and is not covered by the Electron updater). --- docs/system-specs/modules/governance.md | 8 +- .../docs/settings-registry.generated.json | 2 +- website/capture/about-policy-managed.html | 11 + website/capture/about-policy-managed.tsx | 112 ++++++++++ website/electron/auto-update.js | 7 + website/electron/gateway-supervisor.js | 1 + website/electron/ipc-registrar.js | 195 ++++++++++++++---- website/electron/package.json | 6 +- website/electron/preload.js | 4 + website/electron/runtime/update/feed-lane.js | 103 +++++++++ .../electron/runtime/update/managed-lane.js | 45 ++++ .../test/auto-update-policy-governed.test.js | 107 ++++++++++ .../electron/test/gateway-supervisor.test.js | 1 + website/electron/test/ipc-registrar.test.js | 103 ++++++++- .../scripts/capture-about-policy-managed.mjs | 47 +++++ website/src/App.tsx | 1 + .../src/components/UpdateFoundModal.test.tsx | 15 ++ website/src/components/UpdateFoundModal.tsx | 11 +- .../commandPalette/settingsRegistry.gen.ts | 2 +- website/src/hooks/useUpdateSubscription.ts | 26 ++- website/src/i18n/locales/bn.json | 8 +- website/src/i18n/locales/de.json | 8 +- website/src/i18n/locales/en-XA.json | 8 +- website/src/i18n/locales/en.manual.json | 8 +- website/src/i18n/locales/es.json | 8 +- website/src/i18n/locales/fr.json | 8 +- website/src/i18n/locales/hi.json | 8 +- website/src/i18n/locales/it.json | 8 +- website/src/i18n/locales/ja.json | 8 +- website/src/i18n/locales/ko.json | 8 +- website/src/i18n/locales/pt.json | 8 +- website/src/i18n/locales/ru.json | 8 +- website/src/i18n/locales/zh-CN.json | 8 +- website/src/pages/settings/AboutPanel.tsx | 84 +++++++- website/src/shell/appShell.tsx | 5 +- website/src/store/dashboardSlice.ts | 7 + .../test/AboutPanel.policyManaged.test.tsx | 96 +++++++++ .../AppSdkChatPanel.unlistedSlot.test.tsx | 2 +- website/src/types/electron-bridge.d.ts | 12 ++ website/src/utils/updateSwitches.test.ts | 39 ++++ website/src/utils/updateSwitches.ts | 61 +++++- 41 files changed, 1126 insertions(+), 91 deletions(-) create mode 100644 website/capture/about-policy-managed.html create mode 100644 website/capture/about-policy-managed.tsx create mode 100644 website/electron/test/auto-update-policy-governed.test.js create mode 100644 website/scripts/capture-about-policy-managed.mjs create mode 100644 website/src/test/AboutPanel.policyManaged.test.tsx diff --git a/docs/system-specs/modules/governance.md b/docs/system-specs/modules/governance.md index 553817c1f34..6f4c6b1c600 100644 --- a/docs/system-specs/modules/governance.md +++ b/docs/system-specs/modules/governance.md @@ -339,7 +339,13 @@ pins ride in the policy file for it: never run (`_shell_exec_args` returns `None`), so a Windows host with a provider never updates from the gateway. A packaged desktop app with a provider is treated as policy-managed: `_arm_packaged_app` refuses with "updates on this - host are managed by policy". + host are managed by policy". When the provider can apply on the current platform + (`can_apply` true, i.e. a trusted shell is available), the Electron desktop + updater's feed lane also stands down: it emits `not-available` and does not + contact the feed, so the provider is the one lane that finds and applies + updates. On Windows (`_shell_exec_args` returns `None`, `can_apply` false) the + provider runs nothing and the Electron updater continues as the working update + path. - **`platform_commands`** — per-platform overrides of those two commands, keyed `{sys.platform}-{machine}` (`linux-x86_64`, `darwin-arm64`, `win32-x86_64`; an unrecognised machine keeps its raw lowercased name). A matching entry diff --git a/src/kiro_crew/docs/settings-registry.generated.json b/src/kiro_crew/docs/settings-registry.generated.json index bfd3f735443..ae6cb3b96f3 100644 --- a/src/kiro_crew/docs/settings-registry.generated.json +++ b/src/kiro_crew/docs/settings-registry.generated.json @@ -19,7 +19,7 @@ "label": "Update the gateway automatically", "tab": "about", "route": "/settings/about?highlight=key%3Aauto_update", - "description": "Installs a new release once no work is running and restarts the gateway, on installs that can update themselves.", + "description": "Restarts the gateway to install a new release once no work is running. When off, it stays on its current version.", "configKey": "auto_update" }, { diff --git a/website/capture/about-policy-managed.html b/website/capture/about-policy-managed.html new file mode 100644 index 00000000000..3d66e5cc062 --- /dev/null +++ b/website/capture/about-policy-managed.html @@ -0,0 +1,11 @@ + + + + + About policy-managed notice capture + + +
+ + + diff --git a/website/capture/about-policy-managed.tsx b/website/capture/about-policy-managed.tsx new file mode 100644 index 00000000000..7aadccc66b8 --- /dev/null +++ b/website/capture/about-policy-managed.tsx @@ -0,0 +1,112 @@ +/** + * Isolated capture entry for the About panel's "managed by policy" notice + * (GH #15797). + * + * WHY ISOLATED: the notice requires (a) `window.updateAPI` (Electron shell) + * and (b) a gateway status with `update_bundled_by_app: true`, + * `update_managed_by: 'command'`, and `update_can_apply: true`. That + * three-way combination only exists on a real desktop install with a policy + * provider that can run on the current platform. The shell and gateway status + * are stubbed; everything else is the real AboutPanel and stylesheet. + * + * Scenes (?scene=): + * policy bundled install + policy provider can apply → notice replaces + * the update card; AppAutoDownloadSwitch is hidden. + * normal bundled install, no policy provider (managed_by: 'electron') → + * the regular desktop update row; no notice. The comparison frame. + * Theme: &theme=dark|light + */ +import { createRoot } from 'react-dom/client' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { Provider } from 'react-redux' +import { MemoryRouter } from 'react-router-dom' + +import { initI18n } from '../src/i18n' +import { store } from '../src/store' +import { sseStatus } from '../src/store/dashboardSlice' +import { AboutPanel } from '../src/pages/settings/AboutPanel' +import '../src/index.css' + +initI18n('en') + +const params = new URLSearchParams(location.search) +const scene = params.get('scene') === 'normal' ? 'normal' : params.get('scene') === 'reused-gateway' ? 'reused-gateway' : 'policy' +document.documentElement.setAttribute('data-theme', params.get('theme') || 'dark') + +const noop = async () => ({ ok: true }) +;(window as unknown as { updateAPI?: unknown }).updateAPI = { + onState: () => () => {}, + check: noop, + download: noop, + install: noop, + getInfo: async () => ({ + version: '0.8.0', + channel: 'stable', + stampedChannel: 'stable', + channelSwitchable: false, // policy-managed install has no channel switcher + channelPreference: 'stable', + platform: 'darwin-arm64', + packaged: true, + }), +} + +const baseStatus = { + uptime: '1d', sessions: 1, messages: 0, cron_jobs: 0, subagents: 0, lessons: 0, + version: '0.8.0', version_display: '0.8.0', + update_bundled_by_app: true, + update_check_status: 'succeeded', + update_latest_version: '0.8.1', + update_available: true, +} as const + +if (scene === 'policy') { + store.dispatch(sseStatus({ + ...baseStatus, + update_managed_by: 'command', + update_can_apply: true, + // 'notify' keeps the switch hidden in the policy scene: the switch + // is now intentionally hidden when policy governs, so this matches + // the real-code behavior and avoids an error state in the stub. + update_auto_effect: 'notify', + } as never)) +} else if (scene === 'reused-gateway') { + // Topology 2: desktop app connected to a reused (non-bundled) CLI-service or + // SSH-tunnel gateway. bundledByApp=false makes updateSwitchesShown render the + // gateway auto-update switch regardless of effect. + store.dispatch(sseStatus({ + ...baseStatus, + update_bundled_by_app: false, + update_managed_by: 'electron', + update_can_apply: false, + update_auto_effect: 'install', + } as never)) +} else { + store.dispatch(sseStatus({ + ...baseStatus, + update_managed_by: 'electron', + update_can_apply: false, + update_auto_effect: 'unknown', + } as never)) +} + +// For the reused-gateway scene, pre-populate the react-query cache so the +// GatewayAutoUpdateSwitch shows a working state rather than the +// "couldn't load" error state that appears when no gateway is present. +const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }) +if (scene === 'reused-gateway') { + qc.setQueryData(['kirocrewConfig'], { auto_update: true }) +} + +createRoot(document.getElementById('root')!).render( + + + +
+
+ +
+
+
+
+
, +) diff --git a/website/electron/auto-update.js b/website/electron/auto-update.js index bc2566092d7..7f63bab1ee7 100644 --- a/website/electron/auto-update.js +++ b/website/electron/auto-update.js @@ -980,6 +980,11 @@ function initAutoUpdate(deps) { feedBase = process.env.KIROCREW_UPDATE_FEED || DEFAULT_FEED_BASE, onUpdateState = null, log = console, + // Whether a security policy provider currently governs this install's + // updates, making the Electron feed lane stand down. Wired by the + // caller (ipc-registrar.js) to a variable the renderer updates via + // update:notify-policy-governed once the gateway status is known. + isPolicyGoverned = () => false, } = deps; // Linux install shape. Resolved once, and BEFORE getInfo() is defined: the @@ -1057,6 +1062,7 @@ function initAutoUpdate(deps) { managedPath, launchCheckDelayMs: LAUNCH_CHECK_DELAY_MS, checkIntervalMs: CHECK_INTERVAL_MS, + isPolicyGoverned, log, }); } @@ -1169,6 +1175,7 @@ function initAutoUpdate(deps) { channelForVersion, launchCheckDelayMs: LAUNCH_CHECK_DELAY_MS, checkIntervalMs: CHECK_INTERVAL_MS, + isPolicyGoverned, }); } diff --git a/website/electron/gateway-supervisor.js b/website/electron/gateway-supervisor.js index f193c1b5b6a..be31f1cf632 100644 --- a/website/electron/gateway-supervisor.js +++ b/website/electron/gateway-supervisor.js @@ -1523,6 +1523,7 @@ function createGatewaySupervisor({ fetchRemoteToken, entryUrl: dashboardEntryUrl, probePrimaryPortOwner, + isCurrentBundleGateway, stopForUpdate: stopGatewayForUpdate, stopOnQuit: stopGatewayOnQuit, // After sleep the old forward can look alive while carrying nothing, so a diff --git a/website/electron/ipc-registrar.js b/website/electron/ipc-registrar.js index 1ab6b1c956f..ca2e7f3b4cc 100644 --- a/website/electron/ipc-registrar.js +++ b/website/electron/ipc-registrar.js @@ -76,10 +76,53 @@ function createIpcRegistrar({ let shellRegistered = false; let updaterRegistered = false; let updater = null; + // True when the gateway reports that a security policy provider owns this + // install's updates and the Electron feed lane must stand down. Set by the + // renderer via update:notify-policy-governed once the gateway status lands. + let policyGoverned = false; + // Set of webContents IDs currently reporting governance=true. Governance is + // active as long as at least one sender is in the set; removing a sender's id + // (on explicit false or on destroyed) clears governance only when the set + // empties, so a second window closing does not re-enable the feed lane while + // another window on the same bundled gateway is still governed. + const governanceSenders = new Set(); + // Per-sender sequence counter to discard out-of-order async completions. + // Two notifications from the same sender (e.g. false then true at startup) + // are probed concurrently; if the probes complete in reverse order the + // earlier continuation would overwrite the later verdict. Recording the + // latest seq before awaiting and checking after guards this race condition. + const senderSeq = new Map(); // True while a native folder chooser is on screen, so a second request // answers "no choice" instead of stacking another dialog. let folderPickOpen = false; + // Shared local-dashboard gate — used by both registerShell() and + // registerUpdater() so the contract test finds a single spelling. + // See the comment block inside registerShell() for the full rationale. + const assertLocalDashboard = async (event, channel) => { + let origin = ""; + try { + origin = new URL(event.senderFrame.url).origin; + } catch { /* about:blank, malformed URL, or torn-down frame */ } + if (origin !== backendUrl) { + log(`${channel} rejected for sender origin ${origin || "(unreadable)"}`); + throw new Error(`${channel} is restricted to the local dashboard`); + } + const owner = windows.windowForWebContents(event.sender); + if (!windows.security.isGatewayLocalForWindow(owner)) { + log(`${channel} rejected for a sender window without a local gateway`); + throw new Error(`${channel} is restricted to the local dashboard`); + } + const portOwner = await gateway.probePrimaryPortOwner(); + if (portOwner !== "kirocrew" && portOwner !== "service") { + log( + `${channel} rejected: :${port} held by ${portOwner}, ` + + "not this shell's gateway", + ); + throw new Error(`${channel} is restricted to the local dashboard`); + } + }; + setGlobalHotkeyLogger(log); const summonDashboard = createSummonHandler({ getWindow: () => ( @@ -210,44 +253,7 @@ function createIpcRegistrar({ // Every rejection is LOGGED, not silent. The UI renders a sender rejection // and a genuinely empty answer ("no crashes", "no WSL install") the same // way, so diagnostics are the only place those two causes can be told apart. - const assertLocalDashboard = async (event, channel) => { - // Gate 1 — document origin: the page must have been served from this - // shell's own fixed primary gateway URL. - let origin = ""; - try { - origin = new URL(event.senderFrame.url).origin; - } catch { - // about:blank, a malformed URL, or a torn-down frame is not a dashboard. - } - if (origin !== backendUrl) { - log(`${channel} rejected for sender origin ${origin || "(unreadable)"}`); - throw new Error(`${channel} is restricted to the local dashboard`); - } - - // Gate 2 — the sending window's gateway must genuinely be on this machine. - // Loopback is necessary but insufficient because a configured remote - // gateway reached through an SSH tunnel also presents as localhost. - const owner = windows.windowForWebContents(event.sender); - if (!windows.security.isGatewayLocalForWindow(owner)) { - log(`${channel} rejected for a sender window without a local gateway`); - throw new Error(`${channel} is restricted to the local dashboard`); - } - - // Gate 3 — positive listener ownership. A MANUAL SSH tunnel can occupy the - // primary local port without appearing in the remote-host config, so only - // this shell's gateway or its service manager is accepted. Foreign - // holders, an unbound port, and an unavailable owner probe all fail - // closed. Gate 1 already fixed the sender to backendUrl, which is why the - // supervisor's fixed primary-port probe is also probing the sender's port. - const portOwner = await gateway.probePrimaryPortOwner(); - if (portOwner !== "kirocrew" && portOwner !== "service") { - log( - `${channel} rejected: :${port} held by ${portOwner}, ` - + "not this shell's gateway", - ); - throw new Error(`${channel} is restricted to the local dashboard`); - } - }; + // (assertLocalDashboard is defined at createIpcRegistrar scope, above.) // Crash artifacts left by a previous run, so the dashboard can say "this // happened" instead of leaving the user to discover it themselves. @@ -575,11 +581,15 @@ function createIpcRegistrar({ } function broadcastUpdateState(payload) { + // Include the main-process governance verdict in every state update so + // the renderer can distinguish a local bundled gateway (governed) from + // a remote one connected via SSH (not governed here, regardless of stamp). + const enriched = { ...payload, policyGoverned }; try { for (const contents of webContents.getAllWebContents()) { if (!contents.isDestroyed()) { try { - contents.send("update-state", payload); + contents.send("update-state", enriched); } catch { // The view disappeared between enumeration and dispatch. } @@ -610,6 +620,7 @@ function createIpcRegistrar({ // electron-updater's AppUpdater, NOT Electron's built-in autoUpdater: // it generates/verifies feed metadata and also supports Linux. autoUpdater: require("electron-updater").autoUpdater, + isPolicyGoverned: () => policyGoverned, dialog: electron.dialog, Notification, getFlavor: () => "stable", @@ -683,8 +694,114 @@ function createIpcRegistrar({ const updaterInfo = () => ({ ...updater.getInfo(), disabled: updater.disabled, + policyGoverned, }); + // One-way notification from the renderer: the gateway status reported that + // a security policy provider governs this install's updates. The feed lane + // stands down until notified the opposite. Fire-and-forget: no response + // expected; the lane reads policyGoverned on its next scheduled check. + // + // Three-gate guard (assertLocalDashboard): origin, window-locality, and + // port-owner proof. The `lsof` binary is required on Linux for gate 3; + // it is declared as a Debian package dependency in electron/package.json. + ipcMain.on("update:notify-policy-governed", async (_event, governed) => { + const srcId = _event?.sender?.id ?? null; + // Per-sender sequence: bump before awaiting so if a later notification + // from the same sender completes the probe first, this older continuation + // is discarded and does not overwrite the newer verdict. + const mySeq = (senderSeq.get(srcId) ?? 0) + 1; + senderSeq.set(srcId, mySeq); + + // Three-gate check via the shared assertLocalDashboard helper. + try { + await assertLocalDashboard(_event, "update:notify-policy-governed"); + } catch { + return; + } + // Gate 4 — current-bundle ownership (governance handler only). + // A second copy of the app can also hold the port as "kirocrew"; its + // policy provider would incorrectly suppress this copy's updater. + // Unlike assertLocalDashboard's three gates which apply to every shell + // channel, this check is only relevant for governance, and + // isCurrentBundleGateway returns false on Windows/dev so must not + // run for other channels. Skip when the function is unavailable. + if (typeof gateway.isCurrentBundleGateway === "function") { + let isCurrentBundle = false; + try { + isCurrentBundle = await gateway.isCurrentBundleGateway(); + } catch { /* probe failed — fail closed */ } + if (!isCurrentBundle) { + log("update:notify-policy-governed ignored: running gateway belongs to a different bundle"); + return; + } + } + // Reject senders that were destroyed while the probe was awaited. + // If the window closed during assertLocalDashboard's async port probe, + // the srcId must not be added to governanceSenders: the destroyed event + // already fired before the add, so no cleanup listener will fire. + if (_event?.sender?.isDestroyed?.()) { + log("update:notify-policy-governed: sender destroyed during probe — rejecting"); + return; + } + // Discard if a newer notification from the same sender already landed. + if (senderSeq.get(srcId) !== mySeq) { + log("update:notify-policy-governed: superseded notification discarded"); + return; + } + const prev = policyGoverned; + if (governed) { + if (srcId !== null) { + governanceSenders.add(srcId); + // Remove from the set when the window is destroyed so governance is + // lifted automatically once all governing windows close. + // Uses the captured srcId (not a live sender reference) because the + // sender object is gone by the time destroyed fires. + if (_event?.sender) { + _event.sender.once("destroyed", () => { + governanceSenders.delete(srcId); + // On macOS the app stays alive after all windows close + // (Cmd+W vs Cmd+Q). If this was the last window, do not clear + // governance: the user may reopen a window and the governance + // state re-sent by the new window's hook will re-establish it. + // On other platforms the app exits when the last window closes, + // so clearing is safe. Only clear when another window is open + // or we're not on macOS. + const { BrowserWindow } = electron; + const anyOpen = BrowserWindow && BrowserWindow.getAllWindows().length > 0; + if (anyOpen || process.platform !== "darwin") { + const wasGoverned = policyGoverned; + policyGoverned = governanceSenders.size > 0; + // Broadcast the cleared verdict to any remaining windows so + // their useIsPolicyGoverned can stop treating the old + // update-state cache value as authoritative (same as the + // explicit-false path). + if (wasGoverned && !policyGoverned) { + updater?.check?.(); + } + } + }); + } + } + policyGoverned = srcId !== null ? governanceSenders.size > 0 : true; + if (!prev) { + // Governance just became active: trigger a check immediately so the + // feed lane emits not-available and clears any stale "found" state. + updater?.check?.(); + } + } else { + // Lift governance for this sender. Other windows may still be governed. + if (srcId !== null) governanceSenders.delete(srcId); + else governanceSenders.clear(); + policyGoverned = governanceSenders.size > 0; + // On a true-to-false transition: broadcast the cleared verdict by + // triggering a check so the renderer's update-state cache updates + // (useIsPolicyGoverned reads policyGoverned from the broadcast). + if (prev && !policyGoverned) { + updater?.check?.(); + } + } + }); ipcMain.handle("update:get-info", () => updaterInfo()); ipcMain.handle("update:check", () => { updater.check(); diff --git a/website/electron/package.json b/website/electron/package.json index ab029e177fe..4888f0aa377 100644 --- a/website/electron/package.json +++ b/website/electron/package.json @@ -86,7 +86,8 @@ "libatspi2.0-0 | libatspi2.0-0t64", "libuuid1", "libsecret-1-0", - "libasound2 | libasound2t64" + "libasound2 | libasound2t64", + "lsof" ] }, "rpm": { @@ -108,7 +109,8 @@ "at-spi2-core", "libuuid", "libsecret", - "alsa-lib" + "alsa-lib", + "lsof" ] }, "dmg": { diff --git a/website/electron/preload.js b/website/electron/preload.js index f11e8577495..81fb9bf97ba 100644 --- a/website/electron/preload.js +++ b/website/electron/preload.js @@ -382,4 +382,8 @@ contextBridge.exposeInMainWorld("updateAPI", { // Auto-download opt-out (Settings > About). ON by default: a discovered // update downloads in the background and installs on the next quit. setAutoDownload: (enabled) => ipcRenderer.invoke("update:set-auto-download", enabled), + // Notify the main process that a security policy provider now governs (or no + // longer governs) this install's updates. Called from useUpdateSubscription + // whenever update_bundled_by_app and update_managed_by align. Fire-and-forget. + notifyPolicyGoverned: (governed) => ipcRenderer.send("update:notify-policy-governed", governed), }); diff --git a/website/electron/runtime/update/feed-lane.js b/website/electron/runtime/update/feed-lane.js index 5dc8c1522cb..a9bd99a97f4 100644 --- a/website/electron/runtime/update/feed-lane.js +++ b/website/electron/runtime/update/feed-lane.js @@ -67,6 +67,12 @@ function createFeedLane({ channelForVersion, launchCheckDelayMs, checkIntervalMs, + // Called before every discovery. When true the gateway's security policy + // owns updates on this install and the Electron feed lane stands down: no + // check, no download, no notification. The renderer notifies the main + // process via update:notify-policy-governed (ipc-registrar.js) when the + // gateway status reports a policy provider governs a bundled install. + isPolicyGoverned = () => false, }) { let updateReady = false; let downloading = false; @@ -203,6 +209,27 @@ function createFeedLane({ log.info("[update] check requested during install activity — skipping"); return; } + // A security policy provider owns updates on this install. Checked after + // the installing/quitHandled guard so a governance signal arriving mid- + // handoff cannot emit not-available over an in-progress install or quit. + if (isPolicyGoverned()) { + log.info("[update] policy provider governs this install — feed lane stands down"); + // Disarm any staged build. Without this a build already downloaded + // before governance was detected installs on quit while the provider + // also applies — violating the exclusive-owner goal. Mirrors the + // cleanup the update-not-available handler performs. + downloading = false; + foundVersion = null; + if (updateReady) { + log.info(`[update] policy stand-down: discarding staged ${stagedVersion}`); + } + updateReady = false; + stagedVersion = null; + stagedNotes = ""; + app.removeListener("before-quit", deferredInstallOnQuit); + emit("not-available", {}); + return; + } if (downloading) { // A download is in flight. Re-entering the check would restart the // updater's flow underneath the running download; report progress @@ -426,6 +453,15 @@ function createFeedLane({ } } log.info(`[update] downloading ${foundVersion}`); + // Re-check governance after the async freshness wait: the IPC-triggered + // safeCheck() exits early when gate.checking is set, so governance arriving + // during the wait does not clear foundVersion. Block the download here instead + // of wasting bandwidth then discarding the stage at the install guard. + if (isPolicyGoverned()) { + log.info("[update] policy governed (pre-download): suppressing download"); + emit("not-available"); + return; + } downloading = true; downloadWasAutomatic = automatic; emit("downloading", { version: pendingVersion() }); @@ -571,6 +607,16 @@ function createFeedLane({ log.info("[update] install refused: the stage is no longer the newest build — pursuing the newest instead"); return; } + // Re-check governance after the async freshness verify: policy may have + // arrived while the check was in flight (the IPC handler called updater.check() + // but safeCheck() returned early because a check was already running, so the + // new governance signal did not abort the verify). Abort here rather than + // calling quitAndInstall() on a governed install. + if (isPolicyGoverned()) { + log.info("[update] policy governed (post-verify): aborting manual install dispatch"); + emit("not-available"); + return; + } installing = true; // Tell the renderer the install is UNDERWAY before anything goes silent: // the gateway is about to be stopped on purpose, and without this state @@ -669,6 +715,16 @@ function createFeedLane({ return; } app.removeListener("before-quit", deferredInstallOnQuit); + // Re-check governance after the gateway stopped: policy may have arrived + // during the shutdown await (the IPC-triggered check exits because + // `installing` is set, so the latch is active with no further guard). + if (isPolicyGoverned()) { + log.info("[update] policy governed (post-gateway-stop): canceling manual install"); + installing = false; + try { if (onInstallFailed) onInstallFailed(); } catch { /* advisory */ } + emit("not-available"); + return; + } log.info("[update] gateway down — quitAndInstall"); quitAndInstall(); dispatched = true; @@ -715,6 +771,12 @@ function createFeedLane({ return; } if (quitHandled || !updateReady) return; + // Belt-and-suspenders: if policy governance arrived just before quit, + // skip the install. The provider will handle it. + if (isPolicyGoverned()) { + log.info("[update] policy stand-down: skipping deferred install on quit"); + return; + } // `installing` and `verifyingStage` stand for "the manual path already owns this // install". Standing down for them would land a SECOND quitAndInstall() against // the same install directory plus a second forceExitFailsafe -- but returning @@ -805,6 +867,14 @@ function createFeedLane({ app.quit(); return; } + // Re-check governance after the async freshness verify (same race as the + // manual path): policy may have arrived during the gate's network round trip. + if (isPolicyGoverned()) { + log.info("[update] policy governed (post-verify on quit): quitting without installing"); + notifyInstallCanceled(); + app.quit(); + return; + } // No onInstallDispatched here: this handler only runs from before-quit, // where main.js has already set isQuitting -- the watchdog is covered. Nor // is it needed for the failure path's sake: that path quits rather than @@ -886,6 +956,13 @@ function createFeedLane({ // gate above still awaits the feed with the flag clear (`installing` // outranks `checking`, so an early claim would misattribute that check's // own failure), and so neither quit-time refusal leaves it latched. + // Re-check governance after the gateway stopped (same race as manual path). + if (isPolicyGoverned()) { + log.info("[update] policy governed (post-gateway-stop on quit): quitting without installing"); + notifyInstallCanceled(); + app.quit(); + return; + } installing = true; deferredInstalling = true; quitAndInstall(); @@ -1185,6 +1262,15 @@ function createFeedLane({ // follows is OUR decision, made here from the preference, so the automatic // and the consent paths share one guarded entry point (startDownload). autoUpdater.on("update-available", (info) => { + // If policy governance is active while a check was in flight, discard the + // available build. The provider owns updates; the feed lane must not surface + // or auto-download. Governance arriving mid-check is the race this covers. + // Emit not-available so the renderer exits the "checking" state cleanly. + if (isPolicyGoverned()) { + log.info("[update] policy governed: ignoring update-available from in-flight check"); + emit("not-available"); + return; + } if (gate.abandonedCheck) { // Same reason as the error handler: an install is already running on the // fail-open promise this check's wait made. Dropping the stage for the @@ -1317,6 +1403,11 @@ function createFeedLane({ } }); autoUpdater.on("download-progress", (p) => { + // If policy governance became active while this download is in progress, + // suppress the progress events: emitting "downloading" here while + // update-downloaded will discard the stage would leave the badge stuck + // on "downloading" until the next scheduled check clears it. + if (isPolicyGoverned()) return; // New capability vs. the hand-rolled updater: real progress, so the card // can show a percentage instead of an indeterminate "downloading". emit("downloading", { @@ -1326,6 +1417,18 @@ function createFeedLane({ }); }); autoUpdater.on("update-downloaded", (info) => { + // If policy governance arrived while this download was in flight, discard + // the build. The policy provider owns updates on this install; installing + // from the feed lane would violate the exclusive-owner goal. + if (isPolicyGoverned()) { + log.info(`[update] policy stand-down mid-download: discarding ${info && info.version}`); + downloading = false; + // Explicitly clear any stale "downloading" badge: the progress handler + // may have been suppressed partway through, leaving the renderer on an + // intermediate state that never self-clears until the next check poll. + emit("not-available"); + return; + } updateReady = true; downloading = false; stagedVersion = (info && info.version) || null; diff --git a/website/electron/runtime/update/managed-lane.js b/website/electron/runtime/update/managed-lane.js index 115190fbf8d..0538dbfbcbb 100644 --- a/website/electron/runtime/update/managed-lane.js +++ b/website/electron/runtime/update/managed-lane.js @@ -29,6 +29,10 @@ function createManagedLane({ managedPath, launchCheckDelayMs, checkIntervalMs, + // When a security policy provider governs this install's updates, the managed + // lane must also stand down: the policy provider and the EXTERNALLY-MANAGED + // command would otherwise both try to apply updates on the same install. + isPolicyGoverned = () => false, log, }) { // MANAGED AUTO-UPDATE (marker-driven): the marker carries the very @@ -262,6 +266,12 @@ function createManagedLane({ log.info("[update] managed quit handler fired with no pending update — not applying"); return; } + // If policy governance became active since the check found a pending update, + // the policy provider owns the install. Do not apply the managed command. + if (isPolicyGoverned()) { + log.info("[update] policy governed: skipping managed deferred install on quit"); + return; + } let stillOn = false; try { stillOn = !!getAutoDownloadPreference(); } catch (err) { log.error("[update] getAutoDownloadPreference threw on quit — not installing", err); @@ -287,6 +297,13 @@ function createManagedLane({ log.error("[update] managed stop on quit errored", err); } log.info("[update] managed deferred install on quit — running update command"); + // Re-check governance after the gateway stopped: policy may have arrived + // during the shutdown await (same race as the feed-lane manual path). + if (isPolicyGoverned()) { + log.info("[update] policy governed (post-gateway-stop managed): quitting without applying"); + app.exit(0); + return; + } const { code } = await runUpdateCommand(); if (code === 0) { app.relaunch(); @@ -324,6 +341,15 @@ function createManagedLane({ // still resolving only when that run has finished. let checkInFlight = null; function managedCheck() { + // Policy stand-down: if a security policy provider governs this install, + // the managed lane's own check/apply must not run — both would otherwise + // try to apply updates on the same install. + if (isPolicyGoverned()) { + log.info("[update] policy governed: managed check suppressed"); + disarmManagedQuit(); + emit("not-available"); + return Promise.resolve(); + } // An install owns the state until it relaunches or fails, as in the feed // lane: a check requested now is skipped, re-surfacing "installing" for a // caller (the Check button) that cleared the card first. @@ -406,6 +432,16 @@ function createManagedLane({ return; } foundVersion = version; + // Re-check governance: if the policy provider became active while the + // check command was running, discard the result so two update paths do + // not compete on the same install. + if (isPolicyGoverned()) { + log.info("[update] policy governed (post-command): discarding managed check result"); + foundVersion = null; + disarmManagedQuit(); + emit("not-available"); + return; + } if (stagedVersion === version) { // The user already took Download for this version and the ready dialog // is (or was) up. Re-surface it rather than demoting the card back to @@ -453,6 +489,15 @@ function createManagedLane({ } log.error("[update] managed stop before install errored", err); } + // Re-check governance after the gateway stopped: policy may have arrived + // during the shutdown await (same race as managed deferred install on quit). + if (isPolicyGoverned()) { + log.info("[update] policy governed (post-gateway-stop managed install): canceling"); + managedInstalling = false; + try { if (onInstallFailed) onInstallFailed(); } catch { /* advisory */ } + emit("not-available"); + return; + } const { code } = await runUpdateCommand(); if (code === 0) { log.info("[update] managed install succeeded — relaunching"); diff --git a/website/electron/test/auto-update-policy-governed.test.js b/website/electron/test/auto-update-policy-governed.test.js new file mode 100644 index 00000000000..8f4955bfb39 --- /dev/null +++ b/website/electron/test/auto-update-policy-governed.test.js @@ -0,0 +1,107 @@ +// Policy provider governs the install: the Electron feed lane stands down. +// +// When the gateway's security policy configures check_command / apply_command, +// that provider owns the check, the badge and the Update button. The Electron +// feed lane must not compete: it should emit not-available and return early +// rather than contacting the feed or firing a notification. +// +// The renderer notifies the main process via update:notify-policy-governed +// (ipc-registrar.js), which sets policyGoverned and calls updater.check() so +// the feed lane emits not-available immediately. These tests drive the +// feed-lane path directly through initAutoUpdate with isPolicyGoverned wired. +const { test } = require("node:test"); +const assert = require("node:assert"); + +const { initAutoUpdate } = require("../auto-update"); + +function makeDeps({ isPolicyGoverned = () => false, appVersion = "1.0.0" } = {}) { + const handlers = {}; + const states = []; + let checkCalled = false; + const autoUpdater = { + setFeedURL: () => {}, + checkForUpdates: async () => { checkCalled = true; }, + downloadUpdate: async () => {}, + quitAndInstall: () => {}, + on: (ev, fn) => { handlers[ev] = fn; }, + }; + const deps = { + app: { + isPackaged: true, + getVersion: () => appVersion, + once: () => {}, + removeListener: () => {}, + exit: () => {}, + }, + autoUpdater, + dialog: { showMessageBox: async () => ({ response: 1 }) }, + Notification: function () { return { show: () => {} }; }, + getFlavor: () => "stable", + stopGateway: async () => {}, + osPlatform: "darwin", + feedBase: "https://cdn.example.dev/feed", + onUpdateState: (s) => states.push(s), + log: { info: () => {}, warn: () => {}, error: () => {} }, + isPolicyGoverned, + }; + return { deps, states, handlers, isCheckCalled: () => checkCalled }; +} + +test("when policy governs, check() emits not-available without contacting the feed", async () => { + let governed = true; + const { deps, states, isCheckCalled } = makeDeps({ isPolicyGoverned: () => governed }); + + const u = initAutoUpdate(deps); + await u.check(); + + // The feed must not be contacted: autoUpdater.checkForUpdates() must not run. + assert.strictEqual(isCheckCalled(), false, "feed must not be checked when policy governs"); + // The state must clear any stale "found" entry: not-available must be emitted. + const last = states[states.length - 1]; + assert.ok(last, "a state must have been pushed"); + assert.strictEqual(last.state, "not-available", "must emit not-available when policy governs"); +}); + +test("when governance is cleared, the next check contacts the feed", async () => { + let governed = true; + const { deps, states, isCheckCalled } = makeDeps({ isPolicyGoverned: () => governed }); + + const u = initAutoUpdate(deps); + await u.check(); + assert.strictEqual(isCheckCalled(), false, "feed must not run while governed"); + + governed = false; + await u.check(); + // Governance is off; check() must now reach the feed (checkForUpdates called). + assert.strictEqual(isCheckCalled(), true, "feed must run once governance is cleared"); +}); + + + +test("when policy governs and update-downloaded fires, build is discarded and before-quit is not armed", async () => { + let governed = false; + const { deps, states, handlers } = makeDeps({ isPolicyGoverned: () => governed }); + const onceCalls = []; + deps.app.once = (ev, fn) => onceCalls.push({ ev, fn }); + + initAutoUpdate(deps); + + // Governance arrives before the download finishes. + governed = true; + + // Simulate update-downloaded firing (as if a download started before governance arrived). + assert.ok(typeof handlers["update-downloaded"] === "function", + "feed-lane must register an update-downloaded handler"); + handlers["update-downloaded"]({ version: "2.0.0", notes: "" }); + + // The build must be discarded: before-quit must NOT be armed. + const beforeQuit = onceCalls.filter(c => c.ev === "before-quit"); + assert.strictEqual(beforeQuit.length, 0, + "before-quit must not be registered when policy governs at download-complete time"); + + // No state emitted from the update-downloaded path (it returned early). + assert.ok( + !states.some(s => s.state === "downloaded"), + "downloaded state must not be emitted when policy governs" + ); +}); diff --git a/website/electron/test/gateway-supervisor.test.js b/website/electron/test/gateway-supervisor.test.js index d3821c7e63c..3ae21c62a9e 100644 --- a/website/electron/test/gateway-supervisor.test.js +++ b/website/electron/test/gateway-supervisor.test.js @@ -33,6 +33,7 @@ test("module has no top-level Electron dependency and its factory accepts fakes" "fetchRemoteToken", "entryUrl", "probePrimaryPortOwner", + "isCurrentBundleGateway", "stopForUpdate", "stopOnQuit", "reopenTunnel", diff --git a/website/electron/test/ipc-registrar.test.js b/website/electron/test/ipc-registrar.test.js index e131dc2c7a3..dd4b4fa56cb 100644 --- a/website/electron/test/ipc-registrar.test.js +++ b/website/electron/test/ipc-registrar.test.js @@ -182,6 +182,7 @@ function harness({ wslOwner = { fake: "wsl-owner" }, wslGatewayLocal = true, primaryPortOwner = "kirocrew", + isCurrentBundleGateway = true, crashScan, revealThrows = false, openPathResult = "", @@ -357,6 +358,10 @@ function harness({ "probePrimaryPortOwner", Promise.resolve(primaryPortOwner), ), + isCurrentBundleGateway: recordGateway( + "isCurrentBundleGateway", + Promise.resolve(isCurrentBundleGateway), + ), }; const logs = []; const runWslDetection = (...args) => { @@ -1364,7 +1369,10 @@ test("registerUpdater owns six handlers and preserves lazy gateway hooks", async assert.strictEqual(h.registerUpdater(h.registrar), updater); assert.deepEqual([...h.handlers.keys()].sort(), UPDATE_HANDLES); - assert.equal(h.listeners.size, 0); + // One listener: update:notify-policy-governed (ipcMain.on, not ipcMain.handle). + assert.equal(h.listeners.size, 1); + assert.equal(h.listeners.has("update:notify-policy-governed"), true, + "registerUpdater must install the update:notify-policy-governed listener"); assert.equal(initCalls, 1); assert.strictEqual(deps.app, h.electron.app); assert.strictEqual(deps.autoUpdater, h.updaterPackage.autoUpdater); @@ -1385,7 +1393,7 @@ test("registerUpdater owns six handlers and preserves lazy gateway hooks", async ]); deps.onUpdateState({ phase: "available" }); - assert.deepEqual(h.sentUpdates, [["update-state", { phase: "available" }]]); + assert.deepEqual(h.sentUpdates, [["update-state", { phase: "available", policyGoverned: false }]]); deps.notifyUpdateFound("3.0.0", { autoDownload: true }); assert.equal(h.notifications.length, 1); @@ -1397,12 +1405,102 @@ test("registerUpdater owns six handlers and preserves lazy gateway hooks", async version: "2.0.0", packaged: true, disabled: "test-disabled", + policyGoverned: false, }); assert.deepEqual(h.handlers.get("update:check")(), { ok: true }); assert.deepEqual(h.handlers.get("update:download")(), { ok: true }); assert.deepEqual(await h.handlers.get("update:install")(), { ok: true }); assert.deepEqual(updater.calls, ["check", "download", "install"]); + // The policy-governed listener wires isPolicyGoverned in initAutoUpdate. + // It must be a function (not a static boolean) so the lane re-checks each call. + assert.strictEqual(typeof deps.isPolicyGoverned, "function", + "isPolicyGoverned must be a live function, not a captured boolean"); + assert.equal(deps.isPolicyGoverned(), false, "governance must start false"); + + // Build a minimal fake event that satisfies assertLocalDashboard's three gates. + // The harness's windows mock already returns true for isGatewayLocalForWindow + // and resolves "kirocrew" from probePrimaryPortOwner. + let nextId = 1; + const makeEvent = (url = "http://localhost:5476/") => { + const id = nextId++; + const destroyedListeners = []; + const sender = { + id, + getURL: () => url, + once: (event, fn) => { if (event === "destroyed") destroyedListeners.push(fn); }, + }; + return { + sender, + senderFrame: { url }, + _destroy: () => destroyedListeners.forEach(fn => fn()), + }; + }; + + // Basic set/clear from the same origin. + const ev1 = makeEvent(); + await h.listeners.get("update:notify-policy-governed")(ev1, true); + assert.equal(deps.isPolicyGoverned(), true, "listener must update the closure"); + await h.listeners.get("update:notify-policy-governed")(ev1, false); + assert.equal(deps.isPolicyGoverned(), false); + + // A sender from a different origin must NOT set governance. + const evForeign = makeEvent("http://other-host:5476/"); + await h.listeners.get("update:notify-policy-governed")(evForeign, true); + assert.equal(deps.isPolicyGoverned(), false, "foreign origin must not set governance"); + + // Destroying the source window must clear governance even without an explicit false. + const ev2 = makeEvent(); + await h.listeners.get("update:notify-policy-governed")(ev2, true); + assert.equal(deps.isPolicyGoverned(), true, "governance set before destroy"); + ev2._destroy(); + assert.equal(deps.isPolicyGoverned(), false, "destroyed source window must clear governance"); + + // Multi-window: closing one window must NOT clear governance while another window + // is still governed. This is the bug where `governanceSourceId` was a single id. + const evA = makeEvent(); + const evB = makeEvent(); + await h.listeners.get("update:notify-policy-governed")(evA, true); + await h.listeners.get("update:notify-policy-governed")(evB, true); + assert.equal(deps.isPolicyGoverned(), true, "both windows governed"); + evB._destroy(); // close the second window + assert.equal(deps.isPolicyGoverned(), true, + "closing one window must not clear governance while another remains governed"); + evA._destroy(); // close the first window too + assert.equal(deps.isPolicyGoverned(), false, "governance clears when all windows close"); + + // Remote-gateway gate: a harness that simulates a non-kirocrew port owner + // (SSH tunnel holding the primary port) must reject governance via gate 3. + { + let remoteDeps; + const remoteUpdater = updaterHandle(); + const hRemote = harness({ + primaryPortOwner: "ssh-tunnel", + initAutoUpdate(options) { remoteDeps = options; return remoteUpdater; }, + }); + assert.strictEqual(hRemote.registerUpdater(hRemote.registrar), remoteUpdater); + const evRemote = makeEvent(); + await hRemote.listeners.get("update:notify-policy-governed")(evRemote, true); + assert.equal(remoteDeps.isPolicyGoverned(), false, + "SSH tunnel on primary port must not set governance (gate 3 rejection)"); + } + + // Different-bundle gate: a harness that simulates a gateway from another + // desktop bundle (isCurrentBundleGateway = false) must reject governance. + { + let otherDeps; + const otherUpdater = updaterHandle(); + const hOther = harness({ + isCurrentBundleGateway: false, + initAutoUpdate(options) { otherDeps = options; return otherUpdater; }, + }); + assert.strictEqual(hOther.registerUpdater(hOther.registrar), otherUpdater); + const evOther = makeEvent(); + await hOther.listeners.get("update:notify-policy-governed")(evOther, true); + assert.equal(otherDeps.isPolicyGoverned(), false, + "gateway from another desktop bundle must not set governance (gate 4 rejection)"); + } + assert.deepEqual(h.handlers.get("update:set-channel")({}, "nightly"), { ok: false, error: "invalid channel: nightly", @@ -1434,6 +1532,7 @@ test("updater initialization is fail-open and still installs all IPC", async () version: "9.8.7", packaged: true, disabled: "init-failed", + policyGoverned: false, }); assert.deepEqual(h.handlers.get("update:check")(), { ok: true }); assert.deepEqual(h.handlers.get("update:download")(), { ok: true }); diff --git a/website/scripts/capture-about-policy-managed.mjs b/website/scripts/capture-about-policy-managed.mjs new file mode 100644 index 00000000000..6f6cb2b7ebb --- /dev/null +++ b/website/scripts/capture-about-policy-managed.mjs @@ -0,0 +1,47 @@ +/** + * Screenshots of the About panel's policy-managed notice (GH #15797), via the + * capture/about-policy-managed harness. Asserts the notice element is present + * before shooting the `policy` scene. + * + * Usage: node scripts/capture-about-policy-managed.mjs + */ +import { chromium } from 'playwright' + +const base = process.argv[2] || 'http://localhost:5199' +const out = process.argv[3] || '../temp-screenshots/about-policy-managed' + +const b = await chromium.launch() +const scenes = [ + ['policy', 'dark'], + ['policy', 'light'], + ['normal', 'dark'], + ['reused-gateway', 'dark'], +] +for (const [scene, theme] of scenes) { + const ctx = await b.newContext({ viewport: { width: 820, height: 480 }, deviceScaleFactor: 2 }) + const p = await ctx.newPage() + await p.goto(`${base}/capture/about-policy-managed.html?scene=${scene}&theme=${theme}`, { waitUntil: 'networkidle' }) + await p.locator('[data-capture-root]').waitFor({ timeout: 20_000 }) + if (scene === 'policy') { + // The managed-by-policy notice must be visible before shooting. + await p.locator('[data-testid="gateway-policy-managed-note"]').waitFor({ timeout: 20_000 }) + } else if (scene === 'reused-gateway') { + // The reused-gateway scene must NOT show the policy notice. + const count = await p.locator('[data-testid="gateway-policy-managed-note"]').count() + if (count > 0) throw new Error('scene=reused-gateway rendered a policy-managed notice — regression') + // Wait for the about panel to settle. + await p.locator('[data-testid="about-version"]').waitFor({ timeout: 20_000 }) + } else { + // The normal scene must NOT show the notice. + const count = await p.locator('[data-testid="gateway-policy-managed-note"]').count() + if (count > 0) throw new Error('scene=normal rendered a policy-managed notice — regression') + // Wait for the about panel to settle (version chip present). + await p.locator('[data-testid="about-version"]').waitFor({ timeout: 20_000 }) + } + const root = p.locator('[data-capture-root] > div').first() + const file = `${out}/about-${scene}-${theme}.png` + await root.screenshot({ path: file }) + console.log(`captured ${file}`) + await ctx.close() +} +await b.close() diff --git a/website/src/App.tsx b/website/src/App.tsx index 02a8bce18db..4b71b13afe4 100644 --- a/website/src/App.tsx +++ b/website/src/App.tsx @@ -51,6 +51,7 @@ import EmbedTabStrip from './components/EmbedTabStrip' // Dev-only layout-editor harness (RFC §7 PR 2). Lazy so it never weighs the main // bundle — it is a developer route, not a shipped surface. const LayoutEditorHarnessPage = lazy(() => import('./pages/LayoutEditorHarnessPage')) + import UpdateModal from './components/UpdateModal' import ComputerUseLiveView from './components/ComputerUseLiveView' diff --git a/website/src/components/UpdateFoundModal.test.tsx b/website/src/components/UpdateFoundModal.test.tsx index 3b2eefc730f..41d7e77f90e 100644 --- a/website/src/components/UpdateFoundModal.test.tsx +++ b/website/src/components/UpdateFoundModal.test.tsx @@ -643,6 +643,21 @@ describe('UpdateFoundModal — gateway source', () => { expect(mockedApi.armUpdate).not.toHaveBeenCalled() }) + it('the desktop candidate is skipped when a policy provider governs a bundled install', async () => { + // When the provider can apply (non-Windows), the Electron auto-updater + // stands down; no desktop candidate is offered. + const store = gatewayStore({ + update_bundled_by_app: true, + update_managed_by: 'command', + update_can_apply: true, + } as Partial) + withNudgeConfig({}) + const { container } = await mount(found, store) + // With the desktop candidate suppressed, the modal must not open. + expect(container.firstChild).toBeNull() + expect(mockedApi.kirocrewConfig).not.toHaveBeenCalled() + }) + it('an install with no affordance is never interrupted', async () => { const { container } = await mount(undefined, gatewayStore({ update_available: true, update_latest_version: '8.8.8', diff --git a/website/src/components/UpdateFoundModal.tsx b/website/src/components/UpdateFoundModal.tsx index 4206c5ab62e..e34d7fc33bb 100644 --- a/website/src/components/UpdateFoundModal.tsx +++ b/website/src/components/UpdateFoundModal.tsx @@ -7,6 +7,7 @@ import ErrorNotice from './ErrorNotice' import { InAppUpdateFlow } from '../pages/settings/AboutPanel' import { updateInfoQuery } from '../api/updateInfoQuery' import { failedWithNoData } from '../api/queryState' +import { useIsPolicyGoverned } from '../utils/updateSwitches' import { Download, X, Copy, Check } from 'lucide-react' import { api, ApiError } from '../api/client' @@ -99,6 +100,9 @@ export default function UpdateFoundModal({ held = false }: { held?: boolean } = const gwCommand = useAppSelector(s => s.dashboard.status?.update_command) || '' const gwRequired = useAppSelector(s => s.dashboard.status?.update_required === true) const gwMinVersion = useAppSelector(s => s.dashboard.status?.update_min_version) || '' + // True when the policy provider governs this bundled install. + // Read from the main-process-confirmed update-state (useIsPolicyGoverned). + const gwPolicyManaged = useIsPolicyGoverned() // When the user enabled background auto-download, the Electron main process // starts the download itself right after `found` — a consent popup whose @@ -139,14 +143,15 @@ export default function UpdateFoundModal({ held = false }: { held?: boolean } = || (infoQ.data !== undefined && infoQ.errorUpdatedAt > infoQ.dataUpdatedAt) // Desktop first: when the dashboard runs inside the desktop app the gateway - // defers its own check (`managed_by_app`), so the two cannot both report — - // this ordering is belt-and-braces for the transition frame, not a policy. + // defers its own check (`managed_by_app`), so the two cannot both report. + // When a security policy provider governs the install, the Electron feed lane + // stands down (feed-lane.js), so the desktop candidate is skipped here too. // Candidacy waits for a read finished since this build was found (or for it // to fail), otherwise the popup opens for a frame on a stale preference and // then vanishes when the current one lands. const preferenceSettled = foundKey !== null && readForKey === foundKey && (bridgeInfo !== undefined || preferenceUnread) let candidate: Candidate | null = null - if (desktop && buildFound && !desktop.replayed && desktop.version && desktopCanDownload() && preferenceSettled && (preferenceUnread || bridgeInfo?.autoDownload !== true)) { + if (!gwPolicyManaged && desktop && buildFound && !desktop.replayed && desktop.version && desktopCanDownload() && preferenceSettled && (preferenceUnread || bridgeInfo?.autoDownload !== true)) { // Desktop-reported version never crosses the gateway, so the fold is // computed locally, keyed on the followed channel like the backend's. candidate = { diff --git a/website/src/components/commandPalette/settingsRegistry.gen.ts b/website/src/components/commandPalette/settingsRegistry.gen.ts index a7cae17477a..07cbac05a0a 100644 --- a/website/src/components/commandPalette/settingsRegistry.gen.ts +++ b/website/src/components/commandPalette/settingsRegistry.gen.ts @@ -26,7 +26,7 @@ export const SETTINGS_REGISTRY: SettingEntry[] = "id": "about.update-the-gateway-automatically", "label": "Update the gateway automatically", "labelKey": "pages.settings.aboutPanel.automatic_updates", - "description": "Installs a new release once no work is running and restarts the gateway, on installs that can update themselves.", + "description": "Restarts the gateway to install a new release once no work is running. When off, it stays on its current version.", "tab": "about", "type": "toggle", "occurrence": 1, diff --git a/website/src/hooks/useUpdateSubscription.ts b/website/src/hooks/useUpdateSubscription.ts index 7d1b2a115bd..a98f54a521b 100644 --- a/website/src/hooks/useUpdateSubscription.ts +++ b/website/src/hooks/useUpdateSubscription.ts @@ -1,7 +1,7 @@ import { useEffect } from 'react' import { useQueryClient } from '@tanstack/react-query' -import { useAppDispatch } from '../store' -import { setDesktopUpdateAvailable } from '../store/dashboardSlice' +import { useAppDispatch, useAppSelector } from '../store' +import { setDesktopUpdateAvailable, selectPolicyGoverned } from '../store/dashboardSlice' import { updateInfoQuery } from '../api/updateInfoQuery' export type UpdateState = globalThis.UpdateState @@ -53,6 +53,28 @@ export function gatewayStopCommand(st: UpdateState | null | undefined): string | * * No-ops in the browser (window.updateAPI is only defined by the Electron preload). */ +/** + * Keeps the Electron main process informed when the gateway's security policy + * provider governs this install's updates, so the feed lane stands down and + * the Electron auto-updater does not compete with the provider. + * + * No-ops in the browser (window.updateAPI is only defined by the Electron + * preload). Also no-ops when the shell is too old to expose + * `notifyPolicyGoverned` (the field is optional; absent on older shells). + */ +export function usePolicyGovernedNotification() { + const governed = useAppSelector(selectPolicyGoverned) + // Do not send `false` before the gateway status has loaded: on a window + // reload Redux resets to null and selectPolicyGoverned returns false before + // the status arrives, which would transiently clear governance and let the + // Electron feed lane fire while the policy provider still owns the install. + const statusLoaded = useAppSelector(s => s.dashboard.status !== null) + useEffect(() => { + if (!statusLoaded && !governed) return + window.updateAPI?.notifyPolicyGoverned?.(governed) + }, [governed, statusLoaded]) +} + export function useUpdateSubscription() { const queryClient = useQueryClient() const dispatch = useAppDispatch() diff --git a/website/src/i18n/locales/bn.json b/website/src/i18n/locales/bn.json index 49e3b1373c4..b9c8da554ba 100644 --- a/website/src/i18n/locales/bn.json +++ b/website/src/i18n/locales/bn.json @@ -16025,6 +16025,7 @@ "commits_behind_one": "{{count}}টি কমিট পিছিয়ে", "commits_behind_other": "{{count}}টি কমিট পিছিয়ে", "updates_managed_by_policy": "এই ইনস্টলের আপডেট অপারেটর-নির্ধারিত আপডেট নীতি দ্বারা পরিচালিত হয়। নতুন সংস্করণ সেই ব্যবস্থার মাধ্যমে আসে।", + "updates_managed_by_policy_desktop": "এই কম্পিউটারের একটি নীতি এই অ্যাপের আপডেট পরিচালনা করে। নতুন সংস্করণ সেই নীতির মাধ্যমে আসে, তাই {{productName}} নিজে চেক করে না। এটি পরিবর্তন করতে যে এটি সেট আপ করেছে তাকে জিজ্ঞেস করো, অথবা security_policy.json সম্পাদনা করো।", "updates_managed_externally": "এই ইনস্টলের আপডেট একটি বাহ্যিক প্যাকেজ ম্যানেজার পরিচালনা করে। নতুন সংস্করণ সেই প্যাকেজ ম্যানেজার দিয়ে নিন।", "updates_managed_externally_by": "এই ইনস্টলের আপডেট {{managedBy}} পরিচালনা করে। নতুন সংস্করণ সেটি দিয়ে নিন।", "updates_managed_lane": "এই ইনস্টলের আপডেট একটি বাহ্যিক প্যাকেজ ম্যানেজার পরিচালনা করে। ম্যানুয়ালি আপডেট করতে টার্মিনালে নিচের কমান্ডটি চালান।", @@ -16039,7 +16040,9 @@ "automatic_updates": "গেটওয়ে স্বয়ংক্রিয়ভাবে আপডেট করো", "gateway_auto_update_unknown_hint": "এই সুইচটি এখানে প্রযোজ্য কি না, তা চালু হওয়ার কিছুক্ষণ পরে প্রথম আপডেট পরীক্ষার পর জানা যায়।", "gateway_auto_update_mandatory_note": "একটি নীতি এর চেয়ে নতুন সংস্করণ চায়; কোনোটি পাওয়া গেলে এই সুইচ যেমনই থাকুক সেটি ইনস্টল করা হয়।", - "gateway_auto_update_description": "কোনো কাজ চলমান না থাকলে নতুন রিলিজ ইনস্টল করে গেটওয়ে রিস্টার্ট করে, যদি এই ইনস্টলটি নিজে আপডেট হতে পারে।", + "gateway_auto_update_description": "কোনো কাজ না চলার সময় গেটওয়ে পুনরায় চালু করে নতুন রিলিজ ইনস্টল করে। বন্ধ থাকলে, এটি তার বর্তমান সংস্করণে থাকে।", + "gateway_updates_heading": "গেটওয়ে আপডেট", + "gateway_auto_update_desktop_context": "গেটওয়ে তোমার এজেন্ট এবং চ্যাট চালায়। এটি {{productName}} থেকে আলাদাভাবে ইনস্টল করা, তাই এই সুইচ গেটওয়ে আপডেট করে, এই অ্যাপটি নয়। কোনো কাজ সক্রিয় না থাকলেই এটি পুনরায় চালু হয়।", "automatic_updates_are_unavailable_in_this_build": "এই বিল্ডে স্বয়ংক্রিয় আপডেট উপলব্ধ নয়", "automatic_updates_unavailable_dev_build": "এই বিল্ডে স্বয়ংক্রিয় আপডেট উপলব্ধ নয় (ডেভেলপমেন্ট বিল্ড)।", "automatic_updates_unavailable_channel": "স্বয়ংক্রিয় আপডেট উপলব্ধ নয়, কারণ এই রিলিজ চ্যানেলে আপনার প্ল্যাটফর্মের জন্য এখনও কোনো বিল্ড নেই। আপডেট পেতে উপরে চ্যানেল পরিবর্তন করুন।", @@ -16165,7 +16168,8 @@ "app_lane_title": "তুমি যে ডেস্কটপ অ্যাপটি ইনস্টল করেছ। এর নিজস্ব সংস্করণ, নিচের সার্ভিস থেকে আলাদা।", "gateway_lane_label": "গেটওয়ে", "gateway_lane_title": "যে ব্যাকগ্রাউন্ড সার্ভিস তোমার এজেন্টগুলো চালায় এবং এই ড্যাশবোর্ড পরিবেশন করে। ব্রাঞ্চ ও কমিট চিপগুলো এর বিল্ড।", - "update_refused_dirty_worktree": "তোমার স্থানীয় সম্পাদনা সুরক্ষিত রাখতে আপডেট থামানো হয়েছে: তোমার {{productName}} ফোল্ডারে কমিট না করা পরিবর্তন আছে ({{changed}})। সেগুলো কমিট বা stash করো, তারপর আবার এখনই আপডেট করো চাপো। সবকিছু stash করতে, তোমার {{productName}} ফোল্ডারে খোলা টার্মিনালে নিচের কমান্ডটি চালাও।" + "update_refused_dirty_worktree": "তোমার স্থানীয় সম্পাদনা সুরক্ষিত রাখতে আপডেট থামানো হয়েছে: তোমার {{productName}} ফোল্ডারে কমিট না করা পরিবর্তন আছে ({{changed}})। সেগুলো কমিট বা stash করো, তারপর আবার এখনই আপডেট করো চাপো। সবকিছু stash করতে, তোমার {{productName}} ফোল্ডারে খোলা টার্মিনালে নিচের কমান্ডটি চালাও।", + "app_updates_heading": "অ্যাপ আপডেট" }, "botChannelPanel": { "allowed_user_ids": "অনুমোদিত ইউজার ID", diff --git a/website/src/i18n/locales/de.json b/website/src/i18n/locales/de.json index 742907563cd..da3af73699e 100644 --- a/website/src/i18n/locales/de.json +++ b/website/src/i18n/locales/de.json @@ -16025,6 +16025,7 @@ "commits_behind_one": "{{count}} Commit zurück", "commits_behind_other": "{{count}} Commits zurück", "updates_managed_by_policy": "Updates für diese Installation werden über eine vom Betreiber definierte Update-Richtlinie verwaltet. Neue Versionen kommen über diesen Mechanismus an.", + "updates_managed_by_policy_desktop": "Eine Richtlinie auf diesem Computer verwaltet Updates für diese App. Neue Versionen kommen über diese Richtlinie, daher prüft {{productName}} nicht selbst. Wende dich an denjenigen, der dies eingerichtet hat, oder bearbeite security_policy.json.", "updates_managed_externally": "Updates für diese Installation werden von einem externen Paketmanager verwaltet. Beziehen Sie neue Versionen über diesen Paketmanager.", "updates_managed_externally_by": "Updates für diese Installation werden von {{managedBy}} verwaltet. Beziehen Sie neue Versionen darüber.", "updates_managed_lane": "Die Updates dieser Installation werden von einem externen Paketmanager verwaltet. Um manuell zu aktualisieren, führe den folgenden Befehl in einem Terminal aus.", @@ -16039,7 +16040,9 @@ "automatic_updates": "Gateway automatisch aktualisieren", "gateway_auto_update_unknown_hint": "Ob dieser Schalter hier wirkt, zeigt sich nach der ersten Aktualisierungsprüfung kurz nach dem Start.", "gateway_auto_update_mandatory_note": "Eine Richtlinie verlangt eine neuere Version als diese; sobald eine verfügbar ist, wird sie unabhängig von diesem Schalter installiert.", - "gateway_auto_update_description": "Installiert eine neue Version, sobald keine Arbeit läuft, und startet das Gateway neu, sofern sich diese Installation selbst aktualisieren kann.", + "gateway_auto_update_description": "Startet das Gateway neu, um eine neue Version zu installieren, wenn keine Arbeit läuft. Wenn deaktiviert, bleibt es auf der aktuellen Version.", + "gateway_updates_heading": "Gateway-Updates", + "gateway_auto_update_desktop_context": "Das Gateway führt deine Agenten und Chats aus. Es ist unabhängig von {{productName}} installiert, daher aktualisiert dieser Schalter das Gateway, nicht die App. Es startet nur neu, wenn keine Arbeit aktiv ist.", "automatic_updates_are_unavailable_in_this_build": "Automatische Aktualisierungen sind in diesem Build nicht verfügbar", "automatic_updates_unavailable_dev_build": "Automatische Aktualisierungen sind in diesem Build nicht verfügbar (Entwicklungs-Build).", "automatic_updates_unavailable_channel": "Automatische Updates sind nicht verfügbar, da dieser Release-Kanal noch keine Builds für Ihre Plattform enthält. Wechseln Sie oben den Kanal, um Updates zu erhalten.", @@ -16165,7 +16168,8 @@ "app_lane_title": "Die installierte Desktop-App. Ihre eigene Version, getrennt vom Dienst darunter.", "gateway_lane_label": "Gateway", "gateway_lane_title": "Der Hintergrunddienst, der Ihre Agenten ausführt und dieses Dashboard bereitstellt. Die Branch- und Commit-Chips zeigen seinen Build.", - "update_refused_dirty_worktree": "Das Update wurde angehalten, um deine lokalen Änderungen zu schützen: Dein {{productName}}-Ordner hat nicht committete Änderungen ({{changed}}). Committe oder stashe sie und drücke dann erneut auf „Jetzt aktualisieren“. Um alles zu stashen, führe den Befehl unten in einem Terminal in deinem {{productName}}-Ordner aus." + "update_refused_dirty_worktree": "Das Update wurde angehalten, um deine lokalen Änderungen zu schützen: Dein {{productName}}-Ordner hat nicht committete Änderungen ({{changed}}). Committe oder stashe sie und drücke dann erneut auf „Jetzt aktualisieren“. Um alles zu stashen, führe den Befehl unten in einem Terminal in deinem {{productName}}-Ordner aus.", + "app_updates_heading": "App-Updates" }, "botChannelPanel": { "allowed_user_ids": "Erlaubte Benutzer-IDs", diff --git a/website/src/i18n/locales/en-XA.json b/website/src/i18n/locales/en-XA.json index c3d94e6bd79..1f3028c3bc8 100644 --- a/website/src/i18n/locales/en-XA.json +++ b/website/src/i18n/locales/en-XA.json @@ -15841,6 +15841,7 @@ "commits_behind_one": "[{{count}} çøɱɱìţ ƀèĥìñð ·············]", "commits_behind_other": "[{{count}} çøɱɱìţş ƀèĥìñð ··············]", "updates_managed_by_policy": "[Ùþðàţèş ƒøŕ ţĥìş ìñşţàĺĺ àŕè ɱàñàğèð ƀý àñ øþèŕàţøŕ-ðèƒìñèð ùþðàţè þøĺìçý. Ñèẁ ṽèŕşìøñş àŕŕìṽè ţĥŕøùğĥ ţĥè ɱèçĥàñìşɱ ìţ ðèƒìñèş. ······································]", + "updates_managed_by_policy_desktop": "[À þøĺìçý øñ ţĥìş çøɱþùţèŕ ɱàñàğèş ùþðàţèş ƒøŕ ţĥìş àþþ. Ñèẁ ṽèŕşìøñş àŕŕìṽè ţĥŕøùğĥ ţĥàţ þøĺìçý, şø {{productName}} ðøèşñ'ţ çĥèçķ ƒøŕ ùþðàţèş ìţşèĺƒ. Àşķ ẁĥøèṽèŕ şèţ ţĥìş ùþ ţø çĥàñğè ìţ, øŕ èðìţ şèçùŕìţý_þøĺìçý.ĵşøñ. ·····························································]", "updates_managed_externally": "[Ùþðàţèş ƒøŕ ţĥìş ìñşţàĺĺ àŕè ɱàñàğèð ƀý àñ èẋţèŕñàĺ þàçķàğè ɱàñàğèŕ. Ùşè ţĥàţ þàçķàğè ɱàñàğèŕ ţø ğèţ ñèẁ ṽèŕşìøñş. ··································]", "updates_managed_externally_by": "[Ùþðàţèş ƒøŕ ţĥìş ìñşţàĺĺ àŕè ɱàñàğèð ƀý {{managedBy}}. Ùşè ìţ ţø ğèţ ñèẁ ṽèŕşìøñş. ························]", "updates_managed_lane": "[Ùþðàţèş ƒøŕ ţĥìş ìñşţàĺĺ àŕè ɱàñàğèð ƀý àñ èẋţèŕñàĺ þàçķàğè ɱàñàğèŕ. Ţø ùþðàţè ɱàñùàĺĺý, ŕùñ ţĥè çøɱɱàñð ƀèĺøẁ ìñ à ţèŕɱìñàĺ. ······································]", @@ -15900,7 +15901,9 @@ "automatic_updates": "[Ùþðàţè ţĥè ğàţèẁàý àùţøɱàţìçàĺĺý ················]", "gateway_auto_update_unknown_hint": "[Ẁĥèţĥèŕ ţĥìş şẁìţçĥ àþþĺìèş ĥèŕè ìş ķñøẁñ àƒţèŕ ţĥè ƒìŕşţ ùþðàţè çĥèçķ, şĥøŕţĺý àƒţèŕ şţàŕţùþ. ····························]", "gateway_auto_update_mandatory_note": "[À þøĺìçý ŕèǫùìŕèş à ñèẁèŕ ṽèŕşìøñ ţĥàñ ţĥìş øñè; ẁĥèñ øñè ìş àṽàìĺàƀĺè ìţ ìş ìñşţàĺĺèð ẁĥàţèṽèŕ ţĥìş şẁìţçĥ şàýş. ··································]", - "gateway_auto_update_description": "[Ìñşţàĺĺş à ñèẁ ŕèĺèàşè øñçè ñø ẁøŕķ ìş ŕùññìñğ àñð ŕèşţàŕţş ţĥè ğàţèẁàý, øñ ìñşţàĺĺş ţĥàţ çàñ ùþðàţè ţĥèɱşèĺṽèş. ··································]", + "gateway_auto_update_description": "[Ŕèşţàŕţş ţĥè ğàţèẁàý ţø ìñşţàĺĺ à ñèẁ ŕèĺèàşè øñçè ñø ẁøŕķ ìş ŕùññìñğ. Ẁĥèñ øƒƒ, ìţ şţàýş øñ ìţş çùŕŕèñţ ṽèŕşìøñ. ··································]", + "gateway_updates_heading": "[Ğàţèẁàý ùþðàţèş ··············]", + "gateway_auto_update_desktop_context": "[Ţĥè ğàţèẁàý ŕùñş ýøùŕ àğèñţş àñð çĥàţş. Ìţ'ş ìñşţàĺĺèð şèþàŕàţèĺý ƒŕøɱ {{productName}}, şø ţĥìş şẁìţçĥ ùþðàţèş ìţ, ñøţ ţĥìş àþþ. Ìţ øñĺý ŕèşţàŕţş ẁĥèñ ñø ẁøŕķ ìş àçţìṽè. ··············································]", "nightly": "[Ñìğĥţĺý ···········]", "channel_help_show_all": "[▸ Ẁĥàţ'ş ţĥè ð샃èŕèñçè ƀèţẁèèñ Şţàƀĺè, Ìñşìðèŕ àñð Ñìğĥţĺý? ·····················]", "channel_explainer_nightly": "[Ùñţèşţèð ðàìĺý ƀùìĺðş ƒŕøɱ ɱàìñ. Èẋþèçţ ƀŕèàķàğè — ùşèƒùĺ ƒøŕ ţŕýìñğ à ƒìẋ ţĥè ðàý ìţ ĺàñðş. ····························]", @@ -15945,7 +15948,8 @@ "app_lane_label": "[Àþþ ·····]", "app_lane_title": "[Ţĥè ðèşķţøþ àþþ ýøù ìñşţàĺĺèð. Ìţş øẁñ ṽèŕşìøñ, şèþàŕàţè ƒŕøɱ ţĥè şèŕṽìçè ƀèĺøẁ. ························]", "gateway_lane_label": "[Ğàţèẁàý ···········]", - "gateway_lane_title": "[Ţĥè ƀàçķğŕøùñð şèŕṽìçè ţĥàţ ŕùñş ýøùŕ àğèñţş àñð şèŕṽèş ţĥìş ðàşĥƀøàŕð. Ţĥè ƀŕàñçĥ àñð çøɱɱìţ çĥìþş àŕè ìţş ƀùìĺð. ··································]" + "gateway_lane_title": "[Ţĥè ƀàçķğŕøùñð şèŕṽìçè ţĥàţ ŕùñş ýøùŕ àğèñţş àñð şèŕṽèş ţĥìş ðàşĥƀøàŕð. Ţĥè ƀŕàñçĥ àñð çøɱɱìţ çĥìþş àŕè ìţş ƀùìĺð. ··································]", + "app_updates_heading": "[Àþþ ùþðàţèş ··········]" }, "botChannelPanel": { "allowed_user_ids": "[Àĺĺøẁèð ùşèŕ ÌÐş ··············]", diff --git a/website/src/i18n/locales/en.manual.json b/website/src/i18n/locales/en.manual.json index e0fc4415dea..141faa801e2 100644 --- a/website/src/i18n/locales/en.manual.json +++ b/website/src/i18n/locales/en.manual.json @@ -7513,6 +7513,7 @@ "commits_behind_one": "{{count}} commit behind", "commits_behind_other": "{{count}} commits behind", "updates_managed_by_policy": "Updates for this install are managed by an operator-defined update policy. New versions arrive through the mechanism it defines.", + "updates_managed_by_policy_desktop": "A policy on this computer manages updates for this app. New versions arrive through that policy, so {{productName}} doesn't check for updates itself. Ask whoever set this up to change it, or edit security_policy.json.", "updates_managed_externally": "Updates for this install are managed by an external package manager. Use that package manager to get new versions.", "updates_managed_externally_by": "Updates for this install are managed by {{managedBy}}. Use it to get new versions.", "updates_managed_lane": "Updates for this install are managed by an external package manager. To update manually, run the command below in a terminal.", @@ -7572,7 +7573,9 @@ "automatic_updates": "Update the gateway automatically", "gateway_auto_update_unknown_hint": "Whether this switch applies here is known after the first update check, shortly after startup.", "gateway_auto_update_mandatory_note": "A policy requires a newer version than this one; when one is available it is installed whatever this switch says.", - "gateway_auto_update_description": "Installs a new release once no work is running and restarts the gateway, on installs that can update themselves.", + "gateway_auto_update_description": "Restarts the gateway to install a new release once no work is running. When off, it stays on its current version.", + "gateway_updates_heading": "Gateway updates", + "gateway_auto_update_desktop_context": "The gateway runs your agents and chats. It's installed separately from {{productName}}, so this switch updates it, not this app. It only restarts when no work is active.", "nightly": "Nightly", "channel_help_show_all": "▸ What's the difference between Stable, Insider and Nightly?", "channel_explainer_nightly": "Untested daily builds from main. Expect breakage — useful for trying a fix the day it lands.", @@ -7617,7 +7620,8 @@ "app_lane_label": "App", "app_lane_title": "The desktop app you installed. Its own version, separate from the service below.", "gateway_lane_label": "Gateway", - "gateway_lane_title": "The background service that runs your agents and serves this dashboard. The branch and commit chips are its build." + "gateway_lane_title": "The background service that runs your agents and serves this dashboard. The branch and commit chips are its build.", + "app_updates_heading": "App updates" }, "crewmatesSection": { "title": "Mates", diff --git a/website/src/i18n/locales/es.json b/website/src/i18n/locales/es.json index af99eed058d..fea2a9ebc31 100644 --- a/website/src/i18n/locales/es.json +++ b/website/src/i18n/locales/es.json @@ -16290,6 +16290,7 @@ "commits_behind_many": "{{count}} commits por detrás", "commits_behind_other": "{{count}} commits por detrás", "updates_managed_by_policy": "Las actualizaciones de esta instalación las gestiona una política de actualización definida por el operador. Las nuevas versiones llegan por ese mecanismo.", + "updates_managed_by_policy_desktop": "Una política en este equipo gestiona las actualizaciones de esta app. Las nuevas versiones llegan a través de esa política, por lo que {{productName}} no las busca. Pide a quien la configuró que la cambie, o edita security_policy.json.", "updates_managed_externally": "Las actualizaciones de esta instalación las gestiona un gestor de paquetes externo. Usa ese gestor de paquetes para obtener nuevas versiones.", "updates_managed_externally_by": "Las actualizaciones de esta instalación las gestiona {{managedBy}}. Úsalo para obtener nuevas versiones.", "updates_managed_lane": "Las actualizaciones de esta instalación las gestiona un administrador de paquetes externo. Para actualizar manualmente, ejecute el siguiente comando en una terminal.", @@ -16304,7 +16305,9 @@ "automatic_updates": "Actualizar el gateway automáticamente", "gateway_auto_update_unknown_hint": "Sabrás si este interruptor se aplica aquí tras la primera búsqueda de actualizaciones, poco después del inicio.", "gateway_auto_update_mandatory_note": "Una política exige una versión más reciente que esta; cuando haya una disponible, se instala sin importar este interruptor.", - "gateway_auto_update_description": "Instala una versión nueva cuando no hay trabajo en curso y reinicia el gateway, si esta instalación puede actualizarse sola.", + "gateway_auto_update_description": "Reinicia el gateway para instalar una nueva versión cuando no hay trabajo en ejecución. Cuando está desactivado, permanece en su versión actual.", + "gateway_updates_heading": "Actualizaciones del gateway", + "gateway_auto_update_desktop_context": "El gateway ejecuta tus agentes y chats. Está instalado de forma independiente a {{productName}}, por lo que este interruptor lo actualiza. Solo se reinicia cuando no hay trabajo activo.", "automatic_updates_are_unavailable_in_this_build": "Las actualizaciones automáticas no están disponibles en esta compilación", "automatic_updates_unavailable_dev_build": "Las actualizaciones automáticas no están disponibles en esta compilación (compilación de desarrollo).", "automatic_updates_unavailable_channel": "Las actualizaciones automáticas no están disponibles porque este canal de lanzamiento aún no tiene compilaciones para tu plataforma. Cambia de canal arriba para recibir actualizaciones.", @@ -16430,7 +16433,8 @@ "app_lane_title": "La aplicación de escritorio que instalaste. Su propia versión, independiente del servicio de abajo.", "gateway_lane_label": "Gateway", "gateway_lane_title": "El servicio en segundo plano que ejecuta tus agentes y sirve este panel. Los chips de rama y commit son su compilación.", - "update_refused_dirty_worktree": "La actualización se detuvo para proteger tus cambios locales: tu carpeta de {{productName}} tiene cambios sin confirmar ({{changed}}). Confírmalos o guárdalos con stash y vuelve a pulsar Actualizar ahora. Para guardarlo todo con stash, ejecuta el comando de abajo en una terminal dentro de tu carpeta de {{productName}}." + "update_refused_dirty_worktree": "La actualización se detuvo para proteger tus cambios locales: tu carpeta de {{productName}} tiene cambios sin confirmar ({{changed}}). Confírmalos o guárdalos con stash y vuelve a pulsar Actualizar ahora. Para guardarlo todo con stash, ejecuta el comando de abajo en una terminal dentro de tu carpeta de {{productName}}.", + "app_updates_heading": "Actualizaciones de la app" }, "botChannelPanel": { "allowed_user_ids": "IDs de usuario permitidos", diff --git a/website/src/i18n/locales/fr.json b/website/src/i18n/locales/fr.json index d10700a90b6..bbc45366084 100644 --- a/website/src/i18n/locales/fr.json +++ b/website/src/i18n/locales/fr.json @@ -16290,6 +16290,7 @@ "commits_behind_many": "{{count}} commits de retard", "commits_behind_other": "{{count}} commits de retard", "updates_managed_by_policy": "Les mises à jour de cette installation sont gérées par une politique de mise à jour définie par l'opérateur. Les nouvelles versions arrivent par ce mécanisme.", + "updates_managed_by_policy_desktop": "Une politique sur cet ordinateur gère les mises à jour de cette app. Les nouvelles versions arrivent via cette politique, donc {{productName}} ne les vérifie pas. Demande à celui qui l'a configurée de la modifier, ou modifie security_policy.json.", "updates_managed_externally": "Les mises à jour de cette installation sont gérées par un gestionnaire de paquets externe. Utilisez ce gestionnaire de paquets pour obtenir les nouvelles versions.", "updates_managed_externally_by": "Les mises à jour de cette installation sont gérées par {{managedBy}}. Utilisez-le pour obtenir les nouvelles versions.", "updates_managed_lane": "Les mises à jour de cette installation sont gérées par un gestionnaire de paquets externe. Pour mettre à jour manuellement, exécute la commande ci-dessous dans un terminal.", @@ -16304,7 +16305,9 @@ "automatic_updates": "Mettre à jour la passerelle automatiquement", "gateway_auto_update_unknown_hint": "On sait si ce réglage s’applique ici après la première recherche de mises à jour, peu après le démarrage.", "gateway_auto_update_mandatory_note": "Une politique exige une version plus récente que celle-ci, et dès qu’il y en a une, elle est installée quel que soit ce réglage.", - "gateway_auto_update_description": "Installe une nouvelle version dès qu’aucun travail n’est en cours et redémarre la passerelle, si cette installation peut se mettre à jour elle-même.", + "gateway_auto_update_description": "Redémarre la gateway pour installer une nouvelle version une fois qu'aucun travail n'est en cours. Désactivé, elle reste sur sa version actuelle.", + "gateway_updates_heading": "Mises à jour de la gateway", + "gateway_auto_update_desktop_context": "La gateway exécute tes agents et chats. Elle est installée séparément de {{productName}}, donc ce bouton la met à jour, pas cette app. Elle ne redémarre que lorsqu'aucun travail n'est actif.", "automatic_updates_are_unavailable_in_this_build": "Les mises à jour automatiques ne sont pas disponibles dans cette version", "automatic_updates_unavailable_dev_build": "Les mises à jour automatiques ne sont pas disponibles dans cette version (version de développement).", "automatic_updates_unavailable_channel": "Les mises à jour automatiques ne sont pas disponibles car ce canal de publication ne propose pas encore de builds pour votre plateforme. Changez de canal ci-dessus pour recevoir les mises à jour.", @@ -16430,7 +16433,8 @@ "app_lane_title": "L'application de bureau que vous avez installée. Sa propre version, distincte du service ci-dessous.", "gateway_lane_label": "Passerelle", "gateway_lane_title": "Le service en arrière-plan qui exécute vos agents et sert ce tableau de bord. Les puces de branche et de commit indiquent sa version compilée.", - "update_refused_dirty_worktree": "La mise à jour a été arrêtée pour protéger tes modifications locales : ton dossier {{productName}} contient des modifications non validées ({{changed}}). Valide-les ou mets-les de côté avec stash, puis appuie de nouveau sur Mettre à jour maintenant. Pour tout mettre de côté, exécute la commande ci-dessous dans un terminal ouvert dans ton dossier {{productName}}." + "update_refused_dirty_worktree": "La mise à jour a été arrêtée pour protéger tes modifications locales : ton dossier {{productName}} contient des modifications non validées ({{changed}}). Valide-les ou mets-les de côté avec stash, puis appuie de nouveau sur Mettre à jour maintenant. Pour tout mettre de côté, exécute la commande ci-dessous dans un terminal ouvert dans ton dossier {{productName}}.", + "app_updates_heading": "Mises à jour de l'app" }, "botChannelPanel": { "allowed_user_ids": "ID d'utilisateurs autorisés", diff --git a/website/src/i18n/locales/hi.json b/website/src/i18n/locales/hi.json index e0ac07f8fbc..698f4e9acdf 100644 --- a/website/src/i18n/locales/hi.json +++ b/website/src/i18n/locales/hi.json @@ -16025,6 +16025,7 @@ "commits_behind_one": "{{count}} कमिट पीछे", "commits_behind_other": "{{count}} कमिट पीछे", "updates_managed_by_policy": "इस इंस्टॉल के अपडेट ऑपरेटर द्वारा परिभाषित अपडेट नीति से प्रबंधित होते हैं। नए संस्करण उसी तंत्र से आते हैं।", + "updates_managed_by_policy_desktop": "इस कंप्यूटर पर एक नीति इस ऐप के अपडेट को प्रबंधित करती है। नए संस्करण उस नीति के माध्यम से आते हैं, इसलिए {{productName}} स्वयं जाँच नहीं करता। इसे बदलने के लिए इसे सेट करने वाले से पूछें, या security_policy.json संपादित करें।", "updates_managed_externally": "इस इंस्टॉल के अपडेट एक बाहरी पैकेज मैनेजर द्वारा प्रबंधित किए जाते हैं। नए संस्करण उसी पैकेज मैनेजर से प्राप्त करें।", "updates_managed_externally_by": "इस इंस्टॉल के अपडेट {{managedBy}} द्वारा प्रबंधित किए जाते हैं। नए संस्करण उसी से प्राप्त करें।", "updates_managed_lane": "इस इंस्टॉल के अपडेट एक बाहरी पैकेज मैनेजर द्वारा प्रबंधित किए जाते हैं। मैन्युअल रूप से अपडेट करने के लिए, टर्मिनल में नीचे दिया गया कमांड चलाओ।", @@ -16039,7 +16040,9 @@ "automatic_updates": "गेटवे अपने-आप अपडेट करो", "gateway_auto_update_unknown_hint": "यह स्विच यहाँ लागू होता है या नहीं, यह शुरू होने के कुछ देर बाद पहली अपडेट जाँच के बाद पता चलता है।", "gateway_auto_update_mandatory_note": "एक नीति इससे नए संस्करण की माँग करती है; जब कोई उपलब्ध हो, तो यह स्विच चाहे जैसा हो, वह इंस्टॉल किया जाता है।", - "gateway_auto_update_description": "कोई काम न चल रहा हो तब नई रिलीज़ इंस्टॉल करता है और गेटवे पुनरारंभ करता है, अगर यह इंस्टॉल स्वयं अपडेट हो सकता है।", + "gateway_auto_update_description": "जब कोई काम नहीं चल रहा हो तो गेटवे को नया रिलीज़ इंस्टॉल करने के लिए पुनः शुरू करता है। बंद होने पर, यह अपने वर्तमान संस्करण पर रहता है।", + "gateway_updates_heading": "गेटवे अपडेट", + "gateway_auto_update_desktop_context": "गेटवे तुम्हारे एजेंट और चैट चलाता है। यह {{productName}} से अलग इंस्टॉल है, इसलिए यह स्विच गेटवे को अपडेट करता है, ऐप को नहीं। यह केवल तब पुनः शुरू होता है जब कोई कार्य सक्रिय न हो।", "automatic_updates_are_unavailable_in_this_build": "इस बिल्ड में स्वचालित अपडेट उपलब्ध नहीं हैं", "automatic_updates_unavailable_dev_build": "इस बिल्ड में स्वचालित अपडेट उपलब्ध नहीं हैं (डेवलपमेंट बिल्ड)।", "automatic_updates_unavailable_channel": "स्वचालित अपडेट उपलब्ध नहीं हैं क्योंकि इस रिलीज़ चैनल में आपके प्लेटफ़ॉर्म के लिए अभी कोई बिल्ड नहीं है। अपडेट पाने के लिए ऊपर चैनल बदलें।", @@ -16165,7 +16168,8 @@ "app_lane_title": "तुमने जो डेस्कटॉप ऐप इंस्टॉल किया है। इसका अपना संस्करण, नीचे दी गई सेवा से अलग।", "gateway_lane_label": "गेटवे", "gateway_lane_title": "वह बैकग्राउंड सेवा जो तुम्हारे एजेंट चलाती है और यह डैशबोर्ड परोसती है। ब्रांच और कमिट चिप इसी का बिल्ड हैं।", - "update_refused_dirty_worktree": "तुम्हारे स्थानीय बदलावों को सुरक्षित रखने के लिए अपडेट रोक दिया गया: तुम्हारे {{productName}} फ़ोल्डर में बिना कमिट किए बदलाव हैं ({{changed}})। उन्हें कमिट या stash करो, फिर अपडेट बटन को दोबारा दबाओ। सब कुछ stash करने के लिए, अपने {{productName}} फ़ोल्डर में खुले टर्मिनल में नीचे दिया गया कमांड चलाओ।" + "update_refused_dirty_worktree": "तुम्हारे स्थानीय बदलावों को सुरक्षित रखने के लिए अपडेट रोक दिया गया: तुम्हारे {{productName}} फ़ोल्डर में बिना कमिट किए बदलाव हैं ({{changed}})। उन्हें कमिट या stash करो, फिर अपडेट बटन को दोबारा दबाओ। सब कुछ stash करने के लिए, अपने {{productName}} फ़ोल्डर में खुले टर्मिनल में नीचे दिया गया कमांड चलाओ।", + "app_updates_heading": "ऐप अपडेट" }, "botChannelPanel": { "allowed_user_ids": "अनुमत उपयोगकर्ता ID", diff --git a/website/src/i18n/locales/it.json b/website/src/i18n/locales/it.json index a44cde86d38..570a89dd6a3 100644 --- a/website/src/i18n/locales/it.json +++ b/website/src/i18n/locales/it.json @@ -16290,6 +16290,7 @@ "commits_behind_many": "{{count}} commit indietro", "commits_behind_other": "{{count}} commit indietro", "updates_managed_by_policy": "Gli aggiornamenti di questa installazione sono gestiti da una politica di aggiornamento definita dall'operatore. Le nuove versioni arrivano tramite quel meccanismo.", + "updates_managed_by_policy_desktop": "Una policy su questo computer gestisce gli aggiornamenti di questa app. Le nuove versioni arrivano tramite quella policy, quindi {{productName}} non le verifica. Chiedi a chi l'ha configurata di modificarla, o modifica security_policy.json.", "updates_managed_externally": "Gli aggiornamenti di questa installazione sono gestiti da un gestore di pacchetti esterno. Usa quel gestore di pacchetti per ottenere le nuove versioni.", "updates_managed_externally_by": "Gli aggiornamenti di questa installazione sono gestiti da {{managedBy}}. Usalo per ottenere le nuove versioni.", "updates_managed_lane": "Gli aggiornamenti di questa installazione sono gestiti da un gestore di pacchetti esterno. Per aggiornare manualmente, eseguire il comando seguente in un terminale.", @@ -16304,7 +16305,9 @@ "automatic_updates": "Aggiorna automaticamente il gateway", "gateway_auto_update_unknown_hint": "Si saprà se questo interruttore vale qui dopo il primo controllo degli aggiornamenti, poco dopo l’avvio.", "gateway_auto_update_mandatory_note": "Una policy richiede una versione più recente di questa; quando ce n’è una disponibile, viene installata qualunque sia questa impostazione.", - "gateway_auto_update_description": "Installa una nuova versione quando non ci sono attività in corso e riavvia il gateway, se questa installazione può aggiornarsi da sola.", + "gateway_auto_update_description": "Riavvia il gateway per installare una nuova versione quando non ci sono lavori in esecuzione. Disattivato, rimane sulla versione attuale.", + "gateway_updates_heading": "Aggiornamenti gateway", + "gateway_auto_update_desktop_context": "Il gateway esegue i tuoi agenti e le chat. È installato separatamente da {{productName}}, quindi questo interruttore aggiorna il gateway, non l'app. Si riavvia solo quando non c'è lavoro attivo.", "automatic_updates_are_unavailable_in_this_build": "Gli aggiornamenti automatici non sono disponibili in questa build", "automatic_updates_unavailable_dev_build": "Gli aggiornamenti automatici non sono disponibili in questa build (build di sviluppo).", "automatic_updates_unavailable_channel": "Gli aggiornamenti automatici non sono disponibili perché questo canale di rilascio non ha ancora build per la tua piattaforma. Cambia canale qui sopra per ricevere gli aggiornamenti.", @@ -16430,7 +16433,8 @@ "app_lane_title": "L'app desktop che hai installato. La sua versione, separata dal servizio qui sotto.", "gateway_lane_label": "Gateway", "gateway_lane_title": "Il servizio in background che esegue i tuoi agenti e serve questa dashboard. I chip di branch e commit indicano la sua build.", - "update_refused_dirty_worktree": "L'aggiornamento è stato interrotto per proteggere le tue modifiche locali: la tua cartella di {{productName}} ha modifiche non sottoposte a commit ({{changed}}). Esegui il commit o lo stash, poi premi di nuovo Aggiorna ora. Per mettere tutto in stash, esegui il comando qui sotto in un terminale nella tua cartella di {{productName}}." + "update_refused_dirty_worktree": "L'aggiornamento è stato interrotto per proteggere le tue modifiche locali: la tua cartella di {{productName}} ha modifiche non sottoposte a commit ({{changed}}). Esegui il commit o lo stash, poi premi di nuovo Aggiorna ora. Per mettere tutto in stash, esegui il comando qui sotto in un terminale nella tua cartella di {{productName}}.", + "app_updates_heading": "Aggiornamenti app" }, "botChannelPanel": { "allowed_user_ids": "ID utente consentiti", diff --git a/website/src/i18n/locales/ja.json b/website/src/i18n/locales/ja.json index 40c7bf4c982..17db59c8e74 100644 --- a/website/src/i18n/locales/ja.json +++ b/website/src/i18n/locales/ja.json @@ -15760,6 +15760,7 @@ "commits_ahead_other": "{{count}} コミット先行", "commits_behind_other": "{{count}} コミット遅れ", "updates_managed_by_policy": "このインストールの更新は、運用者が定義した更新ポリシーで管理されています。新しいバージョンはその仕組みで届きます。", + "updates_managed_by_policy_desktop": "このコンピューターのポリシーがこのアプリの更新を管理しています。新しいバージョンはそのポリシーを通じて届くため、{{productName}} は確認しません。変更するには設定した人に確認するか、security_policy.json を編集してください。", "updates_managed_externally": "このインストールの更新は外部のパッケージマネージャーが管理しています。新しいバージョンはそのパッケージマネージャーで取得してください。", "updates_managed_externally_by": "このインストールの更新は {{managedBy}} が管理しています。新しいバージョンはそれで取得してください。", "updates_managed_lane": "このインストールの更新は外部パッケージマネージャーによって管理されています。手動で更新するには、端末で以下のコマンドを実行してください。", @@ -15774,7 +15775,9 @@ "automatic_updates": "ゲートウェイを自動で更新", "gateway_auto_update_unknown_hint": "このスイッチがここで有効かどうかは、起動直後の最初の更新確認のあとでわかります。", "gateway_auto_update_mandatory_note": "ポリシーによりこれより新しいバージョンが必須です。利用可能になると、このスイッチの設定にかかわらずインストールされます。", - "gateway_auto_update_description": "実行中の作業がないときに新しいリリースをインストールし、ゲートウェイを再起動します。自身で更新できるインストール形態でのみ動作します。", + "gateway_auto_update_description": "作業が実行されていないときにゲートウェイを再起動して新しいリリースをインストールします。オフの場合、現在のバージョンのままになります。", + "gateway_updates_heading": "ゲートウェイの更新", + "gateway_auto_update_desktop_context": "ゲートウェイはあなたのエージェントとチャットを実行します。{{productName}}とは別にインストールされているため、このスイッチはアプリではなくゲートウェイを更新します。作業がアクティブでない場合にのみ再起動します。", "automatic_updates_are_unavailable_in_this_build": "このビルドでは自動更新を利用できません", "automatic_updates_unavailable_dev_build": "このビルドでは自動更新を利用できません(開発ビルド)。", "automatic_updates_unavailable_channel": "このリリースチャネルにはお使いのプラットフォーム向けのビルドがまだないため、自動更新は利用できません。上でチャネルを切り替えると更新を受け取れます。", @@ -15900,7 +15903,8 @@ "app_lane_title": "インストールしたデスクトップアプリ。下のサービスとは別の独自のバージョンです。", "gateway_lane_label": "ゲートウェイ", "gateway_lane_title": "エージェントを実行し、このダッシュボードを提供するバックグラウンドサービス。ブランチとコミットのチップはそのビルドです。", - "update_refused_dirty_worktree": "ローカルの編集を守るため、更新を停止しました。{{productName}} フォルダーにコミットされていない変更があります({{changed}})。コミットするか stash してから、もう一度「今すぐ更新」を押してください。すべてを stash するには、{{productName}} フォルダーで開いたターミナルで下のコマンドを実行してください。" + "update_refused_dirty_worktree": "ローカルの編集を守るため、更新を停止しました。{{productName}} フォルダーにコミットされていない変更があります({{changed}})。コミットするか stash してから、もう一度「今すぐ更新」を押してください。すべてを stash するには、{{productName}} フォルダーで開いたターミナルで下のコマンドを実行してください。", + "app_updates_heading": "アプリのアップデート" }, "botChannelPanel": { "allowed_user_ids": "許可されたユーザー ID", diff --git a/website/src/i18n/locales/ko.json b/website/src/i18n/locales/ko.json index ca388acfb03..bd1fa13c4d1 100644 --- a/website/src/i18n/locales/ko.json +++ b/website/src/i18n/locales/ko.json @@ -15760,6 +15760,7 @@ "commits_ahead_other": "{{count}}개 커밋 앞섬", "commits_behind_other": "{{count}}개 커밋 뒤처짐", "updates_managed_by_policy": "이 설치의 업데이트는 운영자가 정의한 업데이트 정책으로 관리됩니다. 새 버전은 해당 메커니즘을 통해 제공됩니다.", + "updates_managed_by_policy_desktop": "이 컴퓨터의 정책이 이 앱의 업데이트를 관리합니다. 새 버전은 해당 정책을 통해 도착하므로 {{productName}}(은/는) 직접 확인하지 않습니다. 변경하려면 설정한 사람에게 문의하거나 security_policy.json을 편집하세요.", "updates_managed_externally": "이 설치의 업데이트는 외부 패키지 관리자가 관리합니다. 새 버전은 해당 패키지 관리자로 받으세요.", "updates_managed_externally_by": "이 설치의 업데이트는 {{managedBy}}이(가) 관리합니다. 새 버전은 그것으로 받으세요.", "updates_managed_lane": "이 설치의 업데이트는 외부 패키지 관리자가 관리합니다. 수동으로 업데이트하려면 터미널에서 아래 명령을 실행하세요.", @@ -15774,7 +15775,9 @@ "automatic_updates": "게이트웨이 자동 업데이트", "gateway_auto_update_unknown_hint": "이 스위치가 여기에 적용되는지는 시작 직후 첫 업데이트 확인 후에 알 수 있습니다.", "gateway_auto_update_mandatory_note": "정책에서 이보다 새 버전을 요구합니다. 사용 가능한 버전이 있으면 이 스위치 설정과 관계없이 설치됩니다.", - "gateway_auto_update_description": "실행 중인 작업이 없을 때 새 릴리스를 설치하고 게이트웨이를 재시작합니다. 직접 업데이트할 수 있는 설치 형태에서만 동작합니다.", + "gateway_auto_update_description": "작업이 실행되지 않을 때 게이트웨이를 재시작하여 새 릴리스를 설치합니다. 꺼져 있으면 현재 버전을 유지합니다.", + "gateway_updates_heading": "게이트웨이 업데이트", + "gateway_auto_update_desktop_context": "게이트웨이는 에이전트와 채팅을 실행합니다. {{productName}}(이)과 별도로 설치되어 있으므로 이 스위치는 앱이 아닌 게이트웨이를 업데이트합니다. 작업이 활성화되지 않은 경우에만 다시 시작됩니다.", "automatic_updates_are_unavailable_in_this_build": "이 빌드에서는 자동 업데이트를 사용할 수 없습니다", "automatic_updates_unavailable_dev_build": "이 빌드에서는 자동 업데이트를 사용할 수 없습니다 (개발 빌드).", "automatic_updates_unavailable_channel": "이 릴리스 채널에는 아직 사용 중인 플랫폼용 빌드가 없어 자동 업데이트를 사용할 수 없습니다. 위에서 채널을 변경하면 업데이트를 받을 수 있습니다.", @@ -15900,7 +15903,8 @@ "app_lane_title": "설치한 데스크톱 앱입니다. 아래 서비스와는 별개인 자체 버전입니다.", "gateway_lane_label": "게이트웨이", "gateway_lane_title": "에이전트를 실행하고 이 대시보드를 제공하는 백그라운드 서비스입니다. 브랜치와 커밋 칩은 이 서비스의 빌드입니다.", - "update_refused_dirty_worktree": "로컬 편집 내용을 보호하기 위해 업데이트를 중지했습니다. {{productName}} 폴더에 커밋되지 않은 변경 사항이 있습니다({{changed}}). 커밋하거나 stash한 다음 지금 업데이트를 다시 누르세요. 모두 stash하려면 {{productName}} 폴더에서 연 터미널에서 아래 명령을 실행하세요." + "update_refused_dirty_worktree": "로컬 편집 내용을 보호하기 위해 업데이트를 중지했습니다. {{productName}} 폴더에 커밋되지 않은 변경 사항이 있습니다({{changed}}). 커밋하거나 stash한 다음 지금 업데이트를 다시 누르세요. 모두 stash하려면 {{productName}} 폴더에서 연 터미널에서 아래 명령을 실행하세요.", + "app_updates_heading": "앱 업데이트" }, "botChannelPanel": { "allowed_user_ids": "허용된 사용자 ID", diff --git a/website/src/i18n/locales/pt.json b/website/src/i18n/locales/pt.json index 57771728f40..40908343d6f 100644 --- a/website/src/i18n/locales/pt.json +++ b/website/src/i18n/locales/pt.json @@ -16290,6 +16290,7 @@ "commits_behind_many": "{{count}} commits atrás", "commits_behind_other": "{{count}} commits atrás", "updates_managed_by_policy": "As atualizações desta instalação são gerenciadas por uma política de atualização definida pelo operador. Novas versões chegam por esse mecanismo.", + "updates_managed_by_policy_desktop": "Uma política neste computador gerencia as atualizações deste app. As novas versões chegam por meio dessa política, então {{productName}} não as verifica. Peça a quem a configurou para alterar, ou edite security_policy.json.", "updates_managed_externally": "As atualizações desta instalação são gerenciadas por um gerenciador de pacotes externo. Use esse gerenciador de pacotes para obter novas versões.", "updates_managed_externally_by": "As atualizações desta instalação são gerenciadas por {{managedBy}}. Use-o para obter novas versões.", "updates_managed_lane": "As atualizações desta instalação são gerenciadas por um gerenciador de pacotes externo. Para atualizar manualmente, execute o comando abaixo em um terminal.", @@ -16304,7 +16305,9 @@ "automatic_updates": "Atualizar o gateway automaticamente", "gateway_auto_update_unknown_hint": "Dá para saber se este botão vale aqui após a primeira verificação de atualizações, logo após a inicialização.", "gateway_auto_update_mandatory_note": "Uma política exige uma versão mais recente que esta; quando houver uma disponível, ela é instalada independentemente desta opção.", - "gateway_auto_update_description": "Instala uma nova versão quando não há trabalho em andamento e reinicia o gateway, se esta instalação puder se atualizar sozinha.", + "gateway_auto_update_description": "Reinicia o gateway para instalar uma nova versão quando nenhum trabalho está em execução. Quando desativado, permanece na versão atual.", + "gateway_updates_heading": "Atualizações do gateway", + "gateway_auto_update_desktop_context": "O gateway executa seus agentes e chats. É instalado separadamente do {{productName}}, então este interruptor o atualiza, não este app. Ele só reinicia quando nenhum trabalho está ativo.", "automatic_updates_are_unavailable_in_this_build": "As atualizações automáticas não estão disponíveis nesta compilação", "automatic_updates_unavailable_dev_build": "As atualizações automáticas não estão disponíveis nesta compilação (compilação de desenvolvimento).", "automatic_updates_unavailable_channel": "As atualizações automáticas não estão disponíveis porque este canal de lançamento ainda não tem builds para a sua plataforma. Troque de canal acima para receber atualizações.", @@ -16430,7 +16433,8 @@ "app_lane_title": "O app de desktop que você instalou. Sua própria versão, separada do serviço abaixo.", "gateway_lane_label": "Gateway", "gateway_lane_title": "O serviço em segundo plano que executa seus agentes e serve este painel. Os chips de branch e commit são o build dele.", - "update_refused_dirty_worktree": "A atualização foi interrompida para proteger suas edições locais: sua pasta do {{productName}} tem alterações não confirmadas ({{changed}}). Faça commit ou stash delas e pressione Atualizar agora de novo. Para guardar tudo com stash, execute o comando abaixo em um terminal na sua pasta do {{productName}}." + "update_refused_dirty_worktree": "A atualização foi interrompida para proteger suas edições locais: sua pasta do {{productName}} tem alterações não confirmadas ({{changed}}). Faça commit ou stash delas e pressione Atualizar agora de novo. Para guardar tudo com stash, execute o comando abaixo em um terminal na sua pasta do {{productName}}.", + "app_updates_heading": "Atualizações do app" }, "botChannelPanel": { "allowed_user_ids": "IDs de usuário permitidos", diff --git a/website/src/i18n/locales/ru.json b/website/src/i18n/locales/ru.json index 6c6a13d3ec4..1c4be6bc7a4 100644 --- a/website/src/i18n/locales/ru.json +++ b/website/src/i18n/locales/ru.json @@ -16555,6 +16555,7 @@ "commits_behind_many": "{{count}} коммитов позади", "commits_behind_other": "{{count}} коммита позади", "updates_managed_by_policy": "Обновлениями этой установки управляет политика обновлений, заданная оператором. Новые версии приходят через этот механизм.", + "updates_managed_by_policy_desktop": "Политика на этом компьютере управляет обновлениями приложения. Новые версии поступают через неё, поэтому {{productName}} не проверяет самостоятельно. Обратись к настраивавшему или отредактируй security_policy.json.", "updates_managed_externally": "Обновлениями этой установки управляет внешний менеджер пакетов. Получайте новые версии через этот менеджер пакетов.", "updates_managed_externally_by": "Обновлениями этой установки управляет {{managedBy}}. Получайте новые версии через него.", "updates_managed_lane": "Обновления этой установки управляются внешним менеджером пакетов. Для ручного обновления выполни команду ниже в терминале.", @@ -16569,7 +16570,9 @@ "automatic_updates": "Автоматически обновлять шлюз", "gateway_auto_update_unknown_hint": "Действует ли этот переключатель здесь, станет известно после первой проверки обновлений вскоре после запуска.", "gateway_auto_update_mandatory_note": "Политика требует более новую версию; когда она появится, она будет установлена независимо от этого переключателя.", - "gateway_auto_update_description": "Устанавливает новый выпуск, когда нет выполняющихся задач, и перезапускает шлюз, если эта установка может обновляться сама.", + "gateway_auto_update_description": "Перезапускает шлюз для установки нового выпуска, когда нет активных задач. В отключённом состоянии остаётся на текущей версии.", + "gateway_updates_heading": "Обновления шлюза", + "gateway_auto_update_desktop_context": "Шлюз запускает агентов и чаты. Он установлен отдельно от {{productName}}, поэтому этот переключатель обновляет шлюз, не приложение. Перезапуск происходит только когда нет активной работы.", "automatic_updates_are_unavailable_in_this_build": "Автоматические обновления недоступны в этой сборке", "automatic_updates_unavailable_dev_build": "Автоматические обновления недоступны в этой сборке (сборка для разработки).", "automatic_updates_unavailable_channel": "Автоматические обновления недоступны, так как в этом канале выпуска пока нет сборок для вашей платформы. Смените канал выше, чтобы получать обновления.", @@ -16695,7 +16698,8 @@ "app_lane_title": "Установленное настольное приложение. Его собственная версия, отдельная от сервиса ниже.", "gateway_lane_label": "Шлюз", "gateway_lane_title": "Фоновый сервис, который запускает ваших агентов и обслуживает эту панель. Метки ветки и коммита — его сборка.", - "update_refused_dirty_worktree": "Обновление остановлено, чтобы сохранить твои локальные правки: в твоей папке {{productName}} есть незакоммиченные изменения ({{changed}}). Закоммить их или убери в stash, затем снова нажми «Обновить сейчас». Чтобы убрать всё в stash, выполни команду ниже в терминале в своей папке {{productName}}." + "update_refused_dirty_worktree": "Обновление остановлено, чтобы сохранить твои локальные правки: в твоей папке {{productName}} есть незакоммиченные изменения ({{changed}}). Закоммить их или убери в stash, затем снова нажми «Обновить сейчас». Чтобы убрать всё в stash, выполни команду ниже в терминале в своей папке {{productName}}.", + "app_updates_heading": "Обновления приложения" }, "botChannelPanel": { "allowed_user_ids": "Разрешённые ID пользователей", diff --git a/website/src/i18n/locales/zh-CN.json b/website/src/i18n/locales/zh-CN.json index f937693e454..ba34dea027a 100644 --- a/website/src/i18n/locales/zh-CN.json +++ b/website/src/i18n/locales/zh-CN.json @@ -15760,6 +15760,7 @@ "commits_ahead_other": "领先 {{count}} 个提交", "commits_behind_other": "落后 {{count}} 个提交", "updates_managed_by_policy": "此安装的更新由管理员定义的更新策略管理。新版本将通过该机制到达。", + "updates_managed_by_policy_desktop": "此计算机上的策略管理此应用的更新。新版本通过该策略到达,因此 {{productName}} 不会自行检查。请联系设置者进行更改,或编辑 security_policy.json。", "updates_managed_externally": "此安装的更新由外部包管理器管理。请使用该包管理器获取新版本。", "updates_managed_externally_by": "此安装的更新由 {{managedBy}} 管理。请使用它获取新版本。", "updates_managed_lane": "此安装的更新由外部包管理器管理。如需手动更新,请在终端中运行以下命令。", @@ -15774,7 +15775,9 @@ "automatic_updates": "自动更新网关", "gateway_auto_update_unknown_hint": "首次检查更新后(启动后不久)即可知道此开关是否适用于这里。", "gateway_auto_update_mandatory_note": "策略要求使用比当前更新的版本;有可用版本时,无论此开关如何设置都会安装。", - "gateway_auto_update_description": "没有任务运行时安装新版本并重启网关,仅适用于能够自行更新的安装。", + "gateway_auto_update_description": "在没有活跃工作时重新启动网关以安装新版本。关闭后,保留当前版本。", + "gateway_updates_heading": "网关更新", + "gateway_auto_update_desktop_context": "网关运行代理和聊天。它与{{productName}}分开安装,因此此开关更新网关,而不是此应用。仅在没有活动工作时才会重启。", "automatic_updates_are_unavailable_in_this_build": "此构建版本不支持自动更新", "automatic_updates_unavailable_dev_build": "此构建版本不支持自动更新(开发版本)。", "automatic_updates_unavailable_channel": "自动更新不可用,因为该发布通道尚未提供适用于你所在平台的构建。在上方切换通道即可恢复更新。", @@ -15900,7 +15903,8 @@ "app_lane_title": "你安装的桌面应用。它有自己的版本,与下方的服务相互独立。", "gateway_lane_label": "网关", "gateway_lane_title": "运行你的智能体并提供此仪表板的后台服务。分支和提交标签显示的是它的构建。", - "update_refused_dirty_worktree": "为保护本地修改,更新已停止:{{productName}} 文件夹有未提交更改({{changed}})。请先提交或 stash 这些更改,然后再次点击“立即更新”。要 stash 全部更改,请在 {{productName}} 文件夹中打开终端,运行下方命令。" + "update_refused_dirty_worktree": "为保护本地修改,更新已停止:{{productName}} 文件夹有未提交更改({{changed}})。请先提交或 stash 这些更改,然后再次点击“立即更新”。要 stash 全部更改,请在 {{productName}} 文件夹中打开终端,运行下方命令。", + "app_updates_heading": "应用更新" }, "botChannelPanel": { "allowed_user_ids": "允许的用户 ID", diff --git a/website/src/pages/settings/AboutPanel.tsx b/website/src/pages/settings/AboutPanel.tsx index ec14050f267..a1595f6682b 100644 --- a/website/src/pages/settings/AboutPanel.tsx +++ b/website/src/pages/settings/AboutPanel.tsx @@ -16,7 +16,7 @@ import ReportProblemCard from './ReportProblemCard' import { ARM_POLL_FAILURE_THRESHOLD, AGENT_REQUEST_POLL_MS, AgentUpdateRequestCard, copyCommand } from './AboutPanelUpdateFlow' import { AboutPanelGatewayUpdates, AboutPanelUpdateConfirm, useAboutPanelGatewayUpdate } from './AboutPanelGatewayUpdate' import { api } from '../../api/client' -import { gatewayAutoUpdateCopy, useGatewayAutoUpdateEffect, useUpdateSwitchesShown } from '../../utils/updateSwitches' +import { gatewayAutoUpdateCopy, useGatewayAutoUpdateEffect, useIsPolicyGoverned, useUpdateSwitchesShown } from '../../utils/updateSwitches' import { useGatewayAutoUpdate, type SwitchState } from '../../hooks/useGatewayAutoUpdate' import { useAppAutoDownload } from '../../hooks/useAppAutoDownload' import { storeAnsweredUpdateInfo, updateInfoQuery } from '../../api/updateInfoQuery' @@ -242,12 +242,19 @@ export function AppAutoDownloadSwitch({ unreadable, anchor = true, onHandoff }: * a note says so (the Privacy tab's pin note); the switch stays usable, since * a click is how a removed override is noticed. */ -export function GatewayAutoUpdateSwitch({ anchor = true, readOnMount = false, onHandoff }: { +export function GatewayAutoUpdateSwitch({ anchor = true, readOnMount = false, onHandoff, contextNote, sectionHeading }: { /** `false` for a copy outside Settings, so deep links land on About's row. */ anchor?: boolean /** Re-read the saved value on every mount, not only once it is 30 s old. */ readOnMount?: boolean onHandoff?: () => void + /** Optional sub-heading rendered inside the gateway section (below the divider), + * before the context note and toggle. Makes the gateway controls visually + * separate from the app's own update card above. */ + sectionHeading?: string + /** Optional context note rendered below the section heading, before the toggle. */ + contextNote?: string + }) { const canApply = useAppSelector(s => s.dashboard.status?.update_can_apply) === true const copy = gatewayAutoUpdateCopy(useGatewayAutoUpdateEffect(), { canApply }) @@ -260,6 +267,12 @@ export function GatewayAutoUpdateSwitch({ anchor = true, readOnMount = false, on const describedBy = [copy.hint ? noteId : '', showPinNote ? pinNoteId : ''].filter(Boolean).join(' ') || undefined return (
+ {sectionHeading && ( +

{sectionHeading}

+ )} + {contextNote && ( +

{contextNote}

+ )} s.dashboard.status?.update_managed_by) === 'command' + // True when the provider governs this bundled install and can apply on this + // platform. Shared predicate: see selectPolicyGoverned in dashboardSlice. + // True when the policy provider governs this bundled install. + // Read from the main-process-confirmed update-state (useIsPolicyGoverned). + const isGatewayPolicyManaged = useIsPolicyGoverned(infoQuery.data) // In-app arm+approve applies only where the backend probed the managed-venv // shape; managed_by alone also covers bare source installs whose arm would 409. const gwCanArm = useAppSelector(s => s.dashboard.status?.update_can_arm) === true @@ -1080,7 +1098,7 @@ export function AboutPanel() {
- {isDesktop && channel && !isExternallyManaged && ( + {isDesktop && channel && !isExternallyManaged && !isGatewayPolicyManaged && ( info?.channelSwitchable && desktopApi?.setChannel ? (
@@ -1363,8 +1381,52 @@ export function AboutPanel() { ? i18nT('pages.settings.aboutPanel.automatic_updates_unavailable_channel') : i18nT('pages.settings.aboutPanel.automatic_updates_unavailable_platform')}

+ ) : isGatewayPolicyManaged ? ( + // The gateway's security policy provider owns updates on this + // bundled install. The intro line and the Check button are hidden: + // clicking Check would return "not-available" from the feed lane + // that stands down, which is false when the provider has an update + // ready. + <> +

+ {i18nT('pages.settings.aboutPanel.updates_managed_by_policy_desktop')} +

+ {/* When the provider has a pending update, render the gateway's + check/apply controls so the user can act on it. Shown ONLY + when showUpdate is true to avoid displaying a "Check for + updates" button that contradicts the policy notice above. */} + {showUpdate && ( + + )} + ) : (
+ {/* When a gateway updates section is also shown in this window, + label the app's section so "Check for updates" is unambiguously + about this app, not the gateway. */} + {updateSwitches.gateway && ( +

+ {i18nT('pages.settings.aboutPanel.app_updates_heading')} +

+ )} {/* Managed lane: the marker names an external owner even though the updater is armed; show the managed-by notice and the copyable command above the regular update controls. */} @@ -1458,8 +1520,20 @@ export function AboutPanel() { )} {/* Outside the app updater's branches on purpose: a gateway the app does not own (a reused cli.sh service, an SSH-forwarded one) - installs on its own switch whatever this build's updater says. */} - {updateSwitches.gateway && } + installs on its own switch whatever this build's updater says. + On a policy-governed install the switch still controls whether + the provider's apply command runs unattended, as the existing + WhatsNewAutoUpdateToggle comment documents. */} + {updateSwitches.gateway && ( + + )} ) : ( About. useUpdateSubscription() + // Notify the main process whenever the gateway status reports a security + // policy provider governs this install's updates, so the feed lane stands down. + usePolicyGovernedNotification() const { branding, botName, avatar } = useShellBranding({ colorTheme, brandName, brandLogo, brandFavicon }) useRumPageView() useNotificationSound() diff --git a/website/src/store/dashboardSlice.ts b/website/src/store/dashboardSlice.ts index c55439c9342..3e2a1a562d7 100644 --- a/website/src/store/dashboardSlice.ts +++ b/website/src/store/dashboardSlice.ts @@ -1991,3 +1991,10 @@ export const selectSlotListPending = (state: { dashboard: DashboardState }): boo } export default dashboardSlice.reducer + +/** True when the local bundled gateway's policy provider governs this install. + * Shared by `usePolicyGovernedNotification`, `UpdateFoundModal`, `AboutPanel`. */ +export function selectPolicyGoverned(s: { dashboard: DashboardState }): boolean { + const st = s.dashboard.status + return st?.update_bundled_by_app === true && st?.update_managed_by === 'command' && st?.update_can_apply === true +} diff --git a/website/src/test/AboutPanel.policyManaged.test.tsx b/website/src/test/AboutPanel.policyManaged.test.tsx new file mode 100644 index 00000000000..47969e334c3 --- /dev/null +++ b/website/src/test/AboutPanel.policyManaged.test.tsx @@ -0,0 +1,96 @@ +// When the gateway's security policy provider governs updates on a bundled +// install AND can actually apply on this platform, the desktop update section +// shows a "managed by policy" notice instead of the regular update card and +// auto-download switch. +// +// Three conditions must all be true: +// update_bundled_by_app: true (gateway is the one the app bundles) +// update_managed_by: 'command' (a policy provider owns updates) +// update_can_apply: true (the provider shell exists — not Windows) +// +// On Windows the policy shell is absent (can_apply: false), so the Electron +// updater remains the working update path and the notice does not appear. +import { describe, it, expect, vi, afterEach } from 'vitest' +import { render, screen, waitFor, cleanup } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { Provider } from 'react-redux' +import { store } from '../store' +import { sseStatus } from '../store/dashboardSlice' +import { MemoryRouter } from 'react-router-dom' +import { AboutPanel } from '../pages/settings/AboutPanel' +import { i18nT } from '../i18n/t' +import type { StatusData } from '../types' + +afterEach(cleanup) + +const BASE_INFO = { + version: '0.1.0', + channel: 'stable', + stampedChannel: 'stable', + channelSwitchable: true, + channelPreference: '', + platform: 'darwin-arm64', + packaged: true, +} + +function mountWithStatus(status: Partial) { + ;(window as unknown as { updateAPI?: unknown }).updateAPI = { + onState: () => () => {}, + check: vi.fn().mockResolvedValue({ ok: true }), + download: vi.fn().mockResolvedValue({ ok: true }), + install: vi.fn().mockResolvedValue({ ok: true }), + getInfo: vi.fn().mockResolvedValue(BASE_INFO), + } + store.dispatch(sseStatus(status as StatusData)) + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + return render( + + + + + + + , + ) +} + +describe('policy-governed bundled install', () => { + it('shows the "managed by policy" notice when provider can apply on this platform', async () => { + mountWithStatus({ update_bundled_by_app: true, update_managed_by: 'command', update_can_apply: true } as Partial) + const note = await screen.findByTestId('gateway-policy-managed-note') + expect(note).toBeInTheDocument() + expect(note.textContent).toContain(i18nT('pages.settings.aboutPanel.updates_managed_by_policy_desktop')) + }) + + it('hides the auto-download switch when policy governs and provider can apply', async () => { + mountWithStatus({ update_bundled_by_app: true, update_managed_by: 'command', update_can_apply: true } as Partial) + // Wait for the panel to render the version chip so the assertion is against + // a fully settled tree, not the initial loading state. + await screen.findByTestId('gateway-policy-managed-note') + const switches = screen.queryAllByRole('switch', { name: /install app updates automatically/i }) + expect(switches).toHaveLength(0) + }) + + it('does not show the notice when the provider cannot apply on this platform (Windows)', async () => { + // On Windows the policy shell is absent: update_can_apply is false. + // The Electron updater remains the working update path. + mountWithStatus({ update_bundled_by_app: true, update_managed_by: 'command', update_can_apply: false } as Partial) + await waitFor(() => { + expect(screen.queryByTestId('gateway-policy-managed-note')).not.toBeInTheDocument() + }) + }) + + it('does not show the notice when bundled but no policy provider', async () => { + mountWithStatus({ update_bundled_by_app: true, update_managed_by: 'electron', update_can_apply: false } as Partial) + await waitFor(() => { + expect(screen.queryByTestId('gateway-policy-managed-note')).not.toBeInTheDocument() + }) + }) + + it('does not show the notice when policy provider but not bundled', async () => { + mountWithStatus({ update_bundled_by_app: false, update_managed_by: 'command', update_can_apply: true } as Partial) + await waitFor(() => { + expect(screen.queryByTestId('gateway-policy-managed-note')).not.toBeInTheDocument() + }) + }) +}) diff --git a/website/src/test/AppSdkChatPanel.unlistedSlot.test.tsx b/website/src/test/AppSdkChatPanel.unlistedSlot.test.tsx index 0e0e935b26f..0e81c386d71 100644 --- a/website/src/test/AppSdkChatPanel.unlistedSlot.test.tsx +++ b/website/src/test/AppSdkChatPanel.unlistedSlot.test.tsx @@ -33,7 +33,7 @@ vi.mock('../components/SegmentedControl', () => ({ default: () => null })) vi.mock('../pages/chat/CollapsibleToolGroup', () => ({ default: () => null })) vi.mock('../pages/chat/ActivityViewer', () => ({ default: () => null })) vi.mock('../pages/chat/SessionColorPicker', () => ({ default: () => null })) -vi.mock('../pages/chat', () => ({ ChatFooter: () => null, AssistantMessage: () => null, McpInfoButton: () => null })) +vi.mock('../pages/chat', () => ({ ChatFooter: () => null, AssistantMessage: () => null, McpInfoButton: () => null, PinnedPrompt: () => null })) vi.mock('../pages/ChatSidebar', () => ({ default: () => null, SIDEBAR_MIN: 200, SIDEBAR_MAX: 500 })) vi.mock('../pages/chat/ChatSettings', () => ({ loadChatConfig: () => ({ contentWidth: 'compact' }), CONTENT_WIDTH: { compact: { messages: '900px', input: '916px' }, comfortable: { messages: '84%', input: '85%' }, full: { messages: '92%', input: '93%' } } })) diff --git a/website/src/types/electron-bridge.d.ts b/website/src/types/electron-bridge.d.ts index cc1a3d0c51b..2dc8aee4b44 100644 --- a/website/src/types/electron-bridge.d.ts +++ b/website/src/types/electron-bridge.d.ts @@ -235,6 +235,10 @@ declare global { laneVersion?: string runningAheadOfLane?: boolean | null replayed?: boolean + /** The main process's confirmed governance verdict — true only when the + * local bundled gateway's policy provider was accepted via the three-gate + * assertLocalDashboard check. False for remote (SSH-tunneled) gateways. */ + policyGoverned?: boolean } interface UpdateInfo { @@ -252,6 +256,10 @@ declare global { updateCommand?: string laneVersion?: string runningAheadOfLane?: boolean | null + /** The main process's confirmed policy governance verdict — true only when + * the local bundled gateway's policy provider was accepted via the + * three-gate assertLocalDashboard check. */ + policyGoverned?: boolean } interface UpdateResult { @@ -268,6 +276,10 @@ declare global { getInfo?: () => Promise<(UpdateInfo & { lastState?: UpdateState | null }) | undefined> setChannel?: (channel: string) => Promise setAutoDownload?: (enabled: boolean) => Promise + /** Notify the main process that a security policy provider now governs (or + * no longer governs) this install's updates. Fire-and-forget; no return. + * Absent on shells that predate the feature. */ + notifyPolicyGoverned?: (governed: boolean) => void } interface Window { diff --git a/website/src/utils/updateSwitches.test.ts b/website/src/utils/updateSwitches.test.ts index d8956a83b87..81374e3f9e2 100644 --- a/website/src/utils/updateSwitches.test.ts +++ b/website/src/utils/updateSwitches.test.ts @@ -37,6 +37,33 @@ describe('updateSwitchesShown', () => { expect(gateway('unknown')).toBe(false) }) + it('draws the gateway row in a desktop window for a reused (non-bundled) gateway', () => { + // Topology 2 of GH #15797: a gateway the app reuses (CLI service, SSH tunnel) + // manages its own bytes; its switch is shown even when the effect is not yet + // a definite install, because the app's updater does not cover it. + for (const effect of ['notify', 'unknown'] as const) { + expect( + updateSwitchesShown({ bridge: shell(), info: {}, effect, bundledByApp: false }).gateway, + ).toBe(true) + } + }) + + it('still hides the gateway row in a desktop window for the bundled gateway', () => { + // bundledByApp: true => the app's own gateway, which ignores the switch. + for (const effect of ['notify', 'unknown'] as const) { + expect( + updateSwitchesShown({ bridge: shell(), info: {}, effect, bundledByApp: true }).gateway, + ).toBe(false) + } + }) + + it('treats an unresolved bundled status conservatively (same as before)', () => { + // bundledByApp: undefined => status not yet loaded, keep the old rule. + expect( + updateSwitchesShown({ bridge: shell(), info: {}, effect: 'notify', bundledByApp: undefined }).gateway, + ).toBe(false) + }) + it('offers the app switch on a shell that can read and write it, unless the updater is disabled', () => { const app = (bridge: UpdateAPI | undefined, info: { disabled?: string } | null | undefined) => updateSwitchesShown({ bridge, info, effect: 'notify' }).app @@ -47,6 +74,18 @@ describe('updateSwitchesShown', () => { expect(app(shell({ getInfo: undefined }), {})).toBe(false) }) + it('hides the app switch when policy governs so settings search and About agree', () => { + // A policy provider owns updates: the app's auto-download switch is not actionable + // and must not appear in settings search (leading to an absent target on About). + expect( + updateSwitchesShown({ bridge: shell(), info: {}, effect: 'install', policyGoverned: true }).app, + ).toBe(false) + // Without governance the switch still shows normally. + expect( + updateSwitchesShown({ bridge: shell(), info: {}, effect: 'install', policyGoverned: false }).app, + ).toBe(true) + }) + it.each(['dev', 'translocated', 'externally-managed'])( 'keeps a disabled (%s) updater out of the gateway switch’s rule', disabled => { diff --git a/website/src/utils/updateSwitches.ts b/website/src/utils/updateSwitches.ts index 065ed66cfce..821858d7bf0 100644 --- a/website/src/utils/updateSwitches.ts +++ b/website/src/utils/updateSwitches.ts @@ -10,12 +10,13 @@ * `update_auto_effect` and nothing about the desktop updater. In a browser * window the gateway card IS the update UI, so the row is always drawn and * says when the switch cannot install or when the gateway has not said yet. - * In a desktop window it is drawn only on a definite install or mandatory: - * the app's own gateway ignores the switch, and an unknown effect cannot - * tell that gateway from one the app merely attaches to. + * In a desktop window it is drawn when the effect is a definite install or + * mandatory, or when the status frame reports the connected gateway is not the + * bundled one (the app reuses an external gateway that manages its own bytes). */ import { useQuery } from '@tanstack/react-query' import { useAppSelector } from '../store' +import { selectPolicyGoverned } from '../store/dashboardSlice' import { hasAutoDownload, updateInfoQuery } from '../api/updateInfoQuery' import { i18nT } from '../i18n/t' import type { StatusData } from '../types' @@ -35,18 +36,62 @@ export function useGatewayAutoUpdateEffect(): Effect { return gatewayAutoUpdateEffect(useAppSelector(s => s.dashboard.status?.update_auto_effect)) } +/** + * Whether this app's Electron updater is governed by a policy provider. + * Combines the status-based check (fast) with the main-process-confirmed value + * from the `update-state` broadcast cache, which is authoritative: the main- + * process gate rejects notifications from SSH-tunnelled remote gateways, so a + * remote bundled gateway's stamp cannot suppress the local app's update surfaces. + * The update-state broadcast always wins over the older getInfo() snapshot. + * When no broadcast has arrived yet (new window), info.policyGoverned from + * getInfo() is honoured in either direction as the next-best source. + */ +export function useIsPolicyGoverned(info?: { policyGoverned?: boolean } | null): boolean { + const statusGoverned = useAppSelector(selectPolicyGoverned) + const { data: updateState } = useQuery({ queryKey: ['update-state'], enabled: false }) + // The update-state broadcast carries the most recent main-process verdict. + // If it explicitly confirms governance is active, trust it over any older snapshot. + if (updateState?.policyGoverned === true) return true + // If the main process confirmed governance is NOT active, trust it. + if (updateState?.policyGoverned === false) return false + // No broadcast verdict yet (new window): honour getInfo's policyGoverned in + // either direction as the next-best authoritative source. + if (info !== undefined && info !== null) { + if (info.policyGoverned === true) return true + if (info.policyGoverned === false) return false + } + // Fallback: use status-based predicate. + return statusGoverned +} + /** * Which update switches a window draws (see the module rule). `info` is the * updater's `getInfo()` answer, `undefined` while it has not answered. + * `bundledByApp` is `update_bundled_by_app` from the status frame: `true` when + * this gateway is the one the desktop app bundled and launched, `false` when the + * app reuses an external gateway (a CLI service, an SSH-forwarded one), and + * `undefined` while the status has not arrived. A reused gateway in a desktop + * window has its own update surface, so its switch is always drawn even when the + * effect is not yet a definite install. */ -export function updateSwitchesShown({ bridge, info, effect }: { +export function updateSwitchesShown({ bridge, info, effect, bundledByApp, policyGoverned }: { bridge: UpdateAPI | undefined info: { disabled?: string } | null | undefined effect: Effect + bundledByApp?: boolean + /** When the gateway reports a security policy provider governs this install's + * updates, the app's own auto-download switch must not be shown: the feed lane + * stands down and the preference is not actionable. Settings search and the + * What's-new modal read this too, so they agree with About on switch visibility. */ + policyGoverned?: boolean }): { app: boolean; gateway: boolean } { return { - app: hasAutoDownload(bridge) && !info?.disabled, - gateway: !bridge || effect === 'install' || effect === 'mandatory', + app: hasAutoDownload(bridge) && !info?.disabled && !policyGoverned, + // In a desktop window, show the gateway switch when the effect is a definite + // install or mandatory (the existing rule), OR when the gateway is known to be + // a reused one (not the bundled gateway): a reused gateway manages its own + // updates and the app's updater does not cover it. + gateway: !bridge || bundledByApp === false || effect === 'install' || effect === 'mandatory', } } @@ -60,7 +105,9 @@ export function updateSwitchesShown({ bridge, info, effect }: { export function useUpdateSwitchesShown({ fetch = true }: { fetch?: boolean } = {}): { app: boolean; gateway: boolean } { const bridge = window.updateAPI const { data: info } = useQuery({ ...updateInfoQuery, enabled: fetch && !!bridge }) - return updateSwitchesShown({ bridge, info, effect: useGatewayAutoUpdateEffect() }) + const bundledByApp = useAppSelector(s => s.dashboard.status?.update_bundled_by_app) + const policyGoverned = useIsPolicyGoverned(info) + return updateSwitchesShown({ bridge, info, effect: useGatewayAutoUpdateEffect(), bundledByApp, policyGoverned }) } /**