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