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 }) } /**