From 91be66a2d76fb176e8f375a224a607f9b52e14df Mon Sep 17 00:00:00 2001 From: Tyler <109685178+tlongwell-block@users.noreply.github.com> Date: Wed, 15 Jul 2026 22:54:30 -0400 Subject: [PATCH 1/3] Use selected relay for desktop pairing socket (#1934) Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> --- desktop/src-tauri/src/commands/pairing.rs | 70 ++++++++++++++++++----- 1 file changed, 56 insertions(+), 14 deletions(-) diff --git a/desktop/src-tauri/src/commands/pairing.rs b/desktop/src-tauri/src/commands/pairing.rs index 6bddca047c..639ae16da3 100644 --- a/desktop/src-tauri/src/commands/pairing.rs +++ b/desktop/src-tauri/src/commands/pairing.rs @@ -92,19 +92,9 @@ pub async fn start_pairing( // own NIP-11 declaration of NIP-43 support rather than `auth_required`, // which is also true for plain NIP-42 / NIP-OA relays where the main // relay is reachable. - let qr_relay_url = match probe_pairing_relay(&ws_url).await { - PairingRelay::Configured(url) => url, - PairingRelay::LegacyPath => { - let mut url = - url::Url::parse(&ws_url).map_err(|e| format!("invalid relay URL: {e}"))?; - let path = url.path().trim_end_matches('/').to_string(); - url.set_path(&format!("{path}/pair")); - url.to_string() - } - PairingRelay::MainRelay => ws_url.clone(), - }; + let pairing_relay_url = resolve_pairing_relay_url(&ws_url, probe_pairing_relay(&ws_url).await)?; - let (session, qr_payload) = PairingSession::new_source(qr_relay_url); + let (session, qr_payload) = PairingSession::new_source(pairing_relay_url.clone()); let qr_uri = encode_qr(&qr_payload); let payload_json = serde_json::json!({ @@ -128,7 +118,7 @@ pub async fn start_pairing( let session_arc = Arc::clone(&pairing.session); tauri::async_runtime::spawn(pairing_ws_task( - ws_url, + pairing_relay_url, session_arc, cancel, outbound_rx, @@ -459,6 +449,23 @@ async fn probe_pairing_relay(relay_url: &str) -> PairingRelay { pairing_relay_from_nip11(&json) } +fn resolve_pairing_relay_url( + main_relay_url: &str, + pairing_relay: PairingRelay, +) -> Result { + match pairing_relay { + PairingRelay::Configured(url) => Ok(url), + PairingRelay::LegacyPath => { + let mut url = + url::Url::parse(main_relay_url).map_err(|e| format!("invalid relay URL: {e}"))?; + let path = url.path().trim_end_matches('/').to_string(); + url.set_path(&format!("{path}/pair")); + Ok(url.to_string()) + } + PairingRelay::MainRelay => Ok(main_relay_url.to_string()), + } +} + fn pairing_relay_from_nip11(json: &serde_json::Value) -> PairingRelay { if let Some(value) = json .get("pairing_relay_url") @@ -522,7 +529,9 @@ where #[cfg(test)] mod pairing_relay_tests { - use super::{pairing_relay_from_nip11, probe_pairing_relay, PairingRelay}; + use super::{ + pairing_relay_from_nip11, probe_pairing_relay, resolve_pairing_relay_url, PairingRelay, + }; use tokio::io::{AsyncReadExt, AsyncWriteExt}; #[tokio::test] @@ -591,4 +600,37 @@ mod pairing_relay_tests { assert_eq!(pairing_relay_from_nip11(&document), PairingRelay::MainRelay); } + + #[test] + fn configured_pairing_relay_resolves_to_configured_url() { + let resolved = resolve_pairing_relay_url( + "wss://flint.communities.buzz.xyz", + PairingRelay::Configured("wss://pairing.buzz.xyz".to_string()), + ) + .expect("resolve configured pairing relay"); + + assert_eq!(resolved, "wss://pairing.buzz.xyz"); + } + + #[test] + fn legacy_pairing_relay_appends_pair_path() { + let resolved = resolve_pairing_relay_url( + "wss://flint.communities.buzz.xyz/community", + PairingRelay::LegacyPath, + ) + .expect("resolve legacy pairing relay"); + + assert_eq!(resolved, "wss://flint.communities.buzz.xyz/community/pair"); + } + + #[test] + fn main_relay_pairing_uses_main_relay_url() { + let resolved = resolve_pairing_relay_url( + "wss://sprout-oss.stage.blox.sqprod.co", + PairingRelay::MainRelay, + ) + .expect("resolve main pairing relay"); + + assert_eq!(resolved, "wss://sprout-oss.stage.blox.sqprod.co"); + } } From ccf7f1a2d0c3fdafc3f61dd1b88161f271df8cb8 Mon Sep 17 00:00:00 2001 From: Kalvin C Date: Wed, 15 Jul 2026 20:03:01 -0700 Subject: [PATCH 2/3] feat(desktop): return nostr binding proof in browser fragment (#1933) Signed-off-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@sprout-oss.stage.blox.sqprod.co> --- desktop/src-tauri/src/deep_link.rs | 31 ++++++++++- desktop/src-tauri/src/nostr_bind.rs | 3 +- .../profile/lib/nostrBindCallback.test.mjs | 48 ++++++++++++++++ .../features/profile/lib/nostrBindCallback.ts | 28 ++++++++++ .../profile/ui/NostrBindConsentDialog.tsx | 46 ++++++++++++++-- desktop/src/shared/deep-link.ts | 2 +- desktop/src/testing/e2eBridge.ts | 2 + desktop/tests/e2e/nostr-bind.spec.ts | 55 +++++++++++++++++++ 8 files changed, 206 insertions(+), 9 deletions(-) create mode 100644 desktop/src/features/profile/lib/nostrBindCallback.test.mjs create mode 100644 desktop/src/features/profile/lib/nostrBindCallback.ts diff --git a/desktop/src-tauri/src/deep_link.rs b/desktop/src-tauri/src/deep_link.rs index f90d2f390a..bc65a49095 100644 --- a/desktop/src-tauri/src/deep_link.rs +++ b/desktop/src-tauri/src/deep_link.rs @@ -155,8 +155,13 @@ fn parse_nostr_bind_deep_link(url: &Url) -> Result {} + nostr_bind::RETURN_MODE_BROWSER_FRAGMENT_V1 if callback_url.is_some() => {} + nostr_bind::RETURN_MODE_BROWSER_FRAGMENT_V1 => { + return Err("browser_fragment_v1 requires callback_url".into()); + } + _ => return Err("unsupported return mode".into()), } if let Some(callback_url) = callback_url.as_deref() { validate_nostr_bind_callback_url(callback_url, &origin)?; @@ -389,6 +394,28 @@ mod tests { ); } + #[test] + fn parse_nostr_bind_deep_link_accepts_browser_fragment_return() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=browser_fragment_v1&callback_url=https%3A%2F%2Fexample.com%2Fbuzz").unwrap(); + let payload = parse_nostr_bind_deep_link(&url).unwrap(); + + assert_eq!(payload.return_mode, "browser_fragment_v1"); + assert_eq!( + payload.callback_url.as_deref(), + Some("https://example.com/buzz") + ); + } + + #[test] + fn parse_nostr_bind_deep_link_requires_callback_for_browser_fragment_return() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=browser_fragment_v1").unwrap(); + + assert_eq!( + parse_nostr_bind_deep_link(&url).unwrap_err(), + "browser_fragment_v1 requires callback_url" + ); + } + #[test] fn parse_nostr_bind_deep_link_rejects_cross_origin_callback_url() { let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard&callback_url=https%3A%2F%2Fevil.example%2Fbuzz").unwrap(); diff --git a/desktop/src-tauri/src/nostr_bind.rs b/desktop/src-tauri/src/nostr_bind.rs index 69926466cc..d19675ab46 100644 --- a/desktop/src-tauri/src/nostr_bind.rs +++ b/desktop/src-tauri/src/nostr_bind.rs @@ -6,7 +6,8 @@ pub(crate) const ACTION: &str = "bind_nostr_identity"; pub(crate) const CONTENT: &str = ""; pub(crate) const KIND: u16 = buzz_core_pkg::kind::KIND_NOSTR_IDENTITY_BINDING as u16; pub(crate) const PROTOCOL: &str = "buzz-nostr-identity"; -pub(crate) const RETURN_MODE: &str = "clipboard"; +pub(crate) const RETURN_MODE_CLIPBOARD: &str = "clipboard"; +pub(crate) const RETURN_MODE_BROWSER_FRAGMENT_V1: &str = "browser_fragment_v1"; pub(crate) const VERSION: &str = "1"; const NONCE_CHARS: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-"; diff --git a/desktop/src/features/profile/lib/nostrBindCallback.test.mjs b/desktop/src/features/profile/lib/nostrBindCallback.test.mjs new file mode 100644 index 0000000000..1413ade831 --- /dev/null +++ b/desktop/src/features/profile/lib/nostrBindCallback.test.mjs @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buildNostrBindCallbackUrl } from "./nostrBindCallback.ts"; + +function decodePayload(callbackUrl) { + const encoded = new URL(callbackUrl).hash.split("v1.")[1]; + const padded = encoded + .replaceAll("-", "+") + .replaceAll("_", "/") + .padEnd(Math.ceil(encoded.length / 4) * 4, "="); + return new TextDecoder().decode( + Uint8Array.from(atob(padded), (character) => character.charCodeAt(0)), + ); +} + +test("buildNostrBindCallbackUrl returns a UTF-8 base64url payload in the fragment", () => { + const response = JSON.stringify({ content: "Buzz ⚡", sig: "+/=" }); + const result = buildNostrBindCallbackUrl( + "https://example.com/buzz?source=bind", + response, + ); + const url = new URL(result); + + assert.equal(url.origin, "https://example.com"); + assert.equal(url.pathname, "/buzz"); + assert.equal(url.search, "?source=bind"); + assert.match(url.hash, /^#buzz_bind=v1\.[A-Za-z0-9_-]+$/); + assert.equal(decodePayload(result), response); + assert.equal(url.searchParams.has("buzz_bind"), false); +}); + +test("buildNostrBindCallbackUrl replaces an existing fragment", () => { + const result = buildNostrBindCallbackUrl( + "https://example.com/buzz#stale-fragment", + "signed", + ); + + assert.equal(new URL(result).hash, "#buzz_bind=v1.c2lnbmVk"); +}); + +test("buildNostrBindCallbackUrl rejects callback URLs beyond the opener ceiling", () => { + assert.throws( + () => + buildNostrBindCallbackUrl("https://example.com/buzz", "x".repeat(4_096)), + /too large/, + ); +}); diff --git a/desktop/src/features/profile/lib/nostrBindCallback.ts b/desktop/src/features/profile/lib/nostrBindCallback.ts new file mode 100644 index 0000000000..9bfac803c4 --- /dev/null +++ b/desktop/src/features/profile/lib/nostrBindCallback.ts @@ -0,0 +1,28 @@ +const MAX_CALLBACK_URL_LENGTH = 4_096; +const CALLBACK_FRAGMENT_KEY = "buzz_bind"; +const CALLBACK_PAYLOAD_VERSION = "v1"; + +function encodeBase64Url(value: string): string { + const bytes = new TextEncoder().encode(value); + let binary = ""; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary) + .replaceAll("+", "-") + .replaceAll("/", "_") + .replace(/=+$/, ""); +} + +export function buildNostrBindCallbackUrl( + callbackUrl: string, + signedResponse: string, +): string { + const url = new URL(callbackUrl); + url.hash = `${CALLBACK_FRAGMENT_KEY}=${CALLBACK_PAYLOAD_VERSION}.${encodeBase64Url(signedResponse)}`; + const result = url.toString(); + if (result.length > MAX_CALLBACK_URL_LENGTH) { + throw new Error("Signed response is too large to return to the browser."); + } + return result; +} diff --git a/desktop/src/features/profile/ui/NostrBindConsentDialog.tsx b/desktop/src/features/profile/ui/NostrBindConsentDialog.tsx index e475179d71..b56b34858a 100644 --- a/desktop/src/features/profile/ui/NostrBindConsentDialog.tsx +++ b/desktop/src/features/profile/ui/NostrBindConsentDialog.tsx @@ -9,6 +9,7 @@ import type { Identity } from "@/shared/api/types"; import type { NostrBindDeepLinkPayload } from "@/shared/deep-link"; import { listenForNostrBindDeepLinks } from "@/shared/deep-link"; import { OnboardingSlideTransition } from "@/features/onboarding/ui/OnboardingSlideTransition"; +import { buildNostrBindCallbackUrl } from "@/features/profile/lib/nostrBindCallback"; import { signNostrIdentityBinding } from "@/features/profile/lib/nostrIdentityBinding"; import { cn } from "@/shared/lib/cn"; import { useSystemColorScheme } from "@/shared/theme/useSystemColorScheme"; @@ -121,6 +122,19 @@ async function notifySignedResponseReady(callbackUrl: string | undefined) { } } +async function returnSignedResponseToBrowser( + callbackUrl: string, + signedResponse: string, +): Promise { + try { + await openUrl(buildNostrBindCallbackUrl(callbackUrl, signedResponse)); + return null; + } catch (error) { + console.warn("return signed nostr binding response failed:", error); + return "Could not open the browser. Copy the response below to finish manually."; + } +} + export function NostrBindConsentDialog() { const isPreview = isNostrBindPreviewEnabled(); const [payload, setPayload] = React.useState( @@ -448,6 +462,11 @@ export function NostrBindConsentDialog() { expiresAt: payload.expiresAt, }); setSignedResponse(signed); + if (payload.returnMode === "browser_fragment_v1" && payload.callbackUrl) { + setError( + await returnSignedResponseToBrowser(payload.callbackUrl, signed), + ); + } } catch (error) { setError(formatError(error) || "Failed to sign binding response."); } finally { @@ -473,14 +492,22 @@ export function NostrBindConsentDialog() { setCopyFailed(!copied); if (copied) { showCopiedState(); - await notifySignedResponseReady(payload?.callbackUrl); + if (payload?.returnMode === "clipboard") { + await notifySignedResponseReady(payload.callbackUrl); + } toast.success( isPreview ? PREVIEW_COPY_SUCCESS_MESSAGE : COPY_SUCCESS_MESSAGE, ); } else { toast.warning(COPY_FAILURE_MESSAGE); } - }, [isPreview, payload?.callbackUrl, showCopiedState, signedResponse]); + }, [ + isPreview, + payload?.callbackUrl, + payload?.returnMode, + showCopiedState, + signedResponse, + ]); return ( - Finish on the Buzz website + {payload.returnMode === "browser_fragment_v1" + ? "Continue in your browser" + : "Finish on the Buzz website"} - Copy the response below, then paste it into the Buzz website - to finish verification. + {payload.returnMode === "browser_fragment_v1" + ? "Buzz opened your browser to finish verification. If it did not open, copy the response below." + : "Copy the response below, then paste it into the Buzz website to finish verification."} + {error ? ( +

+ {error} +

+ ) : null} +
[0],
         );
+      case "plugin:opener|open_url":
+        return null;
       case "plugin:window|show":
       case "plugin:window|unminimize":
       case "plugin:window|set_focus":
diff --git a/desktop/tests/e2e/nostr-bind.spec.ts b/desktop/tests/e2e/nostr-bind.spec.ts
index f2360f98dd..b6f3cf86ba 100644
--- a/desktop/tests/e2e/nostr-bind.spec.ts
+++ b/desktop/tests/e2e/nostr-bind.spec.ts
@@ -4,6 +4,7 @@ import { installMockBridge } from "../helpers/bridge";
 type NostrBindPayload = {
   action: string;
   audience: string;
+  callbackUrl?: string;
   challengeId: string;
   expiresAt: string;
   nonce: string;
@@ -297,6 +298,60 @@ test("signs a valid request, shows the response, and copies it", async ({
     .toBe(signedResponse);
 });
 
+test("returns a signed response in the callback fragment after consent", async ({
+  page,
+}) => {
+  await openNostrBind(page, {
+    ...VALID_REQUEST,
+    callbackUrl: "https://admin.example.com/buzz?source=bind#stale",
+    returnMode: "browser_fragment_v1",
+  });
+
+  await expect
+    .poll(async () =>
+      page.evaluate(
+        () =>
+          (
+            (
+              window as Window & {
+                __BUZZ_E2E_COMMAND_LOG__?: Array<{ command: string }>;
+              }
+            ).__BUZZ_E2E_COMMAND_LOG__ ?? []
+          ).filter(({ command }) => command === "plugin:opener|open_url")
+            .length,
+      ),
+    )
+    .toBe(0);
+  await pasteCode(
+    page.getByTestId("nostr-bind-code-digit-1"),
+    VALID_REQUEST.verificationCode,
+  );
+  await page.getByTestId("nostr-bind-sign-and-copy").click();
+
+  await expect(
+    page.getByRole("heading", { name: "Continue in your browser" }),
+  ).toBeVisible();
+  const callback = await page.evaluate(() => {
+    const command = (
+      (
+        window as Window & {
+          __BUZZ_E2E_COMMAND_LOG__?: Array<{
+            command: string;
+            payload: { url?: string };
+          }>;
+        }
+      ).__BUZZ_E2E_COMMAND_LOG__ ?? []
+    ).find(({ command }) => command === "plugin:opener|open_url");
+    return command?.payload.url;
+  });
+  const callbackUrl = new URL(callback ?? "");
+  expect(callbackUrl.origin).toBe("https://admin.example.com");
+  expect(callbackUrl.pathname).toBe("/buzz");
+  expect(callbackUrl.search).toBe("?source=bind");
+  expect(callbackUrl.searchParams.has("buzz_bind")).toBe(false);
+  expect(callbackUrl.hash).toMatch(/^#buzz_bind=v1\.[A-Za-z0-9_-]+$/);
+});
+
 test("keeps the signed response available when clipboard access fails", async ({
   page,
 }) => {

From 0722346b1f9efb0e4b3bc6c9d9ff038d79b1b282 Mon Sep 17 00:00:00 2001
From: Tyler <109685178+tlongwell-block@users.noreply.github.com>
Date: Wed, 15 Jul 2026 23:11:44 -0400
Subject: [PATCH 3/3] Add Buzz-managed Node runtime for agent installs (#1930)

Signed-off-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co>
---
 desktop/scripts/check-file-sizes.mjs          |  10 +-
 desktop/src-tauri/Cargo.lock                  |   2 +
 desktop/src-tauri/Cargo.toml                  |   3 +-
 .../src-tauri/src/commands/agent_discovery.rs | 370 +++-----------
 .../commands/agent_discovery/managed_node.rs  | 468 ++++++++++++++++++
 .../src-tauri/src/managed_agents/discovery.rs |  43 +-
 .../src/managed_agents/discovery/tests.rs     |   2 +
 .../tests/managed_path_resolution.rs          |  64 +++
 .../src/managed_agents/managed_node_paths.rs  |  86 ++++
 desktop/src-tauri/src/managed_agents/mod.rs   |   2 +
 .../src-tauri/src/managed_agents/readiness.rs |  20 +-
 .../src/managed_agents/readiness/cli_login.rs |   6 +-
 .../src/managed_agents/runtime/path.rs        |  61 ++-
 desktop/src-tauri/src/managed_agents/types.rs |   2 +-
 14 files changed, 799 insertions(+), 340 deletions(-)
 create mode 100644 desktop/src-tauri/src/commands/agent_discovery/managed_node.rs
 create mode 100644 desktop/src-tauri/src/managed_agents/discovery/tests/managed_path_resolution.rs
 create mode 100644 desktop/src-tauri/src/managed_agents/managed_node_paths.rs

diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs
index d346116132..117cf60c1b 100644
--- a/desktop/scripts/check-file-sizes.mjs
+++ b/desktop/scripts/check-file-sizes.mjs
@@ -279,7 +279,10 @@ const overrides = new Map([
   // +13: fetch_login_shell_path_inner Windows guard (POSIX PATH → None).
   // resolve_git_bash made pub(crate) for Windows test access.
   // +1: login_shell_candidates doc comment expanded for resolve_bash_path.
-  ["src-tauri/src/managed_agents/discovery.rs", 1366],
+  // Buzz-managed Node path helpers and resolution tests moved to
+  // managed_node_paths.rs and discovery/tests/managed_path_resolution.rs;
+  // ratcheting 1366 -> 1392 after adding the managed-path probes to discovery.
+  ["src-tauri/src/managed_agents/discovery.rs", 1393],
   // rebase over codex-acp-package-swap: its version-probe tests union with the
   // doctor-install-reliability nvm/login-shell/semver tests — each side alone
   // stayed under the 1000 default; the union exceeds it.
@@ -289,9 +292,8 @@ const overrides = new Map([
   // candidates, cli_install_commands_for_os PowerShell selection, login_shell_path
   // None regression, .cmd shim resolution, no-git-bash error hint.
   // +32: deterministic .cmd resolver + no-registry + install_shell_from tests.
-  // team-instructions-first-class: record_with test fixture gained the new
-  // ManagedAgentRecord.team_id field (+1 line) alongside persona_team_dir.
-  ["src-tauri/src/managed_agents/discovery/tests.rs", 1271],
+  // Managed-path resolution test split to discovery/tests/managed_path_resolution.rs.
+  ["src-tauri/src/managed_agents/discovery/tests.rs", 1273],
   // identity-import-keyring: the identity resolution state machine's behavioral
   // matrix (46 tests over FakeIdentityStore — probe × marker × file cells,
   // adoption / read-back-corruption / marker-failure arms, recovery-mode
diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock
index 49f6c9939c..651280bb42 100644
--- a/desktop/src-tauri/Cargo.lock
+++ b/desktop/src-tauri/Cargo.lock
@@ -970,6 +970,7 @@ dependencies = [
  "dirs",
  "earshot",
  "ed25519-dalek",
+ "flate2",
  "futures-util",
  "hex",
  "image",
@@ -7747,6 +7748,7 @@ dependencies = [
  "base64 0.22.1",
  "bytes",
  "encoding_rs",
+ "futures-channel",
  "futures-core",
  "futures-util",
  "h2",
diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml
index 8d2539c18c..2b2ccd978e 100644
--- a/desktop/src-tauri/Cargo.toml
+++ b/desktop/src-tauri/Cargo.toml
@@ -80,7 +80,7 @@ serde_yaml = "0.9"
 toml = "0.8"
 nostr = { version = "0.44", features = ["nip44"] }
 zeroize = "1"
-reqwest = { version = "0.13", features = ["json", "query", "stream"] }
+reqwest = { version = "0.13", features = ["json", "query", "stream", "blocking"] }
 url = "2"
 buzz_core_pkg = { package = "buzz-core", path = "../../crates/buzz-core" }
 buzz_persona_pkg = { package = "buzz-persona", path = "../../crates/buzz-persona" }
@@ -107,6 +107,7 @@ png = "0.18"
 arboard = "3"
 image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "gif"] }
 zip = "8"
+flate2 = "1"
 sherpa-onnx = "1.12"
 regex = "1"
 rusqlite = { version = "0.37", features = ["bundled"] }
diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs
index bf4ba73152..89de8ec97a 100644
--- a/desktop/src-tauri/src/commands/agent_discovery.rs
+++ b/desktop/src-tauri/src/commands/agent_discovery.rs
@@ -145,11 +145,9 @@ fn install_acp_runtime_blocking(runtime_id: &str) -> Result Result command,
+                Ok(None) => cmd.to_string(),
+                Err(step) => {
+                    steps.push(*step);
                     return Ok(InstallRuntimeResult {
                         success: false,
                         steps,
@@ -192,8 +210,9 @@ fn install_acp_runtime_blocking(runtime_id: &str) -> Result Result
     let mut cmd = std::process::Command::new(&shell);
     cmd.args(["-l", "-c", command]);
 
-    // Strip hermit env vars so npm/node use the user's normal registry and
-    // global prefix rather than the project-local hermit-managed paths.
+    // Strip hermit env vars so npm/node use the user's normal registry rather
+    // than the project-local hermit-managed paths, then give npm defaults for
+    // Buzz-owned app data. Adapter install commands also pass --prefix
+    // explicitly; these env vars keep subprocesses/cache/corepack aligned.
     cmd.env_remove("NPM_CONFIG_PREFIX");
     cmd.env_remove("NPM_CONFIG_CACHE");
     cmd.env_remove("COREPACK_HOME");
 
+    if let Some(prefix) = crate::managed_agents::buzz_managed_npm_prefix() {
+        cmd.env("NPM_CONFIG_PREFIX", &prefix);
+        cmd.env("npm_config_prefix", &prefix);
+        cmd.env("COREPACK_HOME", prefix.join("corepack"));
+        cmd.env("NPM_CONFIG_CACHE", prefix.join("cache"));
+        cmd.env("npm_config_cache", prefix.join("cache"));
+    }
+
+    let mut path_parts = Vec::new();
+    if let Some(managed_node_bin) = crate::managed_agents::buzz_managed_node_bin_dir() {
+        path_parts.push(managed_node_bin);
+    }
+    if let Some(managed_bin) = crate::managed_agents::buzz_managed_npm_bin_dir() {
+        path_parts.push(managed_bin);
+    }
     if let Some(ref path) = crate::managed_agents::login_shell_path() {
-        cmd.env("PATH", path);
+        path_parts.extend(std::env::split_paths(path));
+    }
+    if !path_parts.is_empty() {
+        if let Ok(path) = std::env::join_paths(path_parts) {
+            cmd.env("PATH", path);
+        }
     }
 
     // Detach from the controlling terminal so install scripts that read from
@@ -790,232 +832,12 @@ fn floor_char_boundary(s: &str, mut index: usize) -> usize {
     index
 }
 
-// ── npm EACCES preflight ──────────────────────────────────────────────────────
-
-/// Guidance text for the EACCES / unwritable-prefix case.
-fn npm_eacces_guidance(command: &str) -> String {
-    format!(
-        "npm's global install directory isn't writable by your user.\n\
-\n\
-Fix (no sudo):\n\
-  1. Run:  npm config set prefix ~/.npm-global\n\
-  2. Add to ~/.zprofile:  export PATH=\"$HOME/.npm-global/bin:$PATH\"\n\
-  3. Restart Buzz, then click Install again.\n\
-\n\
-Or install manually, then click Refresh:\n\
-  sudo {command}"
-    )
-}
-
-/// Guidance text shown when npm / Node.js is not found in the login-shell PATH.
-const NPM_MISSING_HINT: &str = "Node.js / npm was not found. Install Node.js \
-(https://nodejs.org or your version manager), restart Buzz, then click Install again.\n\
-If npm works in your terminal, make sure your Node version manager is initialized in \
-~/.zprofile (not only ~/.zshrc) — Buzz resolves tools via non-interactive login shells.";
-
-/// Result of probing `npm prefix -g` in the hermit-stripped login shell.
-#[cfg(unix)]
-enum NpmPrefix {
-    /// npm responded with a parseable prefix path.
-    Found(std::path::PathBuf),
-    /// npm was not found, the spawn failed, the command returned a non-zero
-    /// exit, or the output could not be parsed.
-    Unavailable,
-    /// The probe exceeded the 30-second deadline (e.g. a version-manager init
-    /// that blocks on `/dev/tty`). The install should proceed so the stderr
-    /// classifier remains the backstop.
-    TimedOut,
-}
-
-/// Spawn the same login shell used by `run_install_command` and run
-/// `npm prefix -g` to discover where npm would install global packages.
-#[cfg(unix)]
-fn resolve_npm_prefix() -> NpmPrefix {
-    let mut cmd = match install_shell_command("npm prefix -g") {
-        Ok(cmd) => cmd,
-        Err(_) => return NpmPrefix::Unavailable,
-    };
-    let mut child = match cmd
-        .stdin(std::process::Stdio::null())
-        .stdout(std::process::Stdio::piped())
-        .stderr(std::process::Stdio::piped())
-        .spawn()
-    {
-        Ok(c) => c,
-        Err(_) => return NpmPrefix::Unavailable,
-    };
-
-    // Drain stdout/stderr on background threads to prevent pipe-buffer deadlock.
-    let stdout_pipe = child.stdout.take();
-    let stderr_pipe = child.stderr.take();
-    let stdout_thread = std::thread::spawn(move || {
-        let mut buf = Vec::new();
-        if let Some(mut pipe) = stdout_pipe {
-            let _ = pipe.read_to_end(&mut buf);
-        }
-        buf
-    });
-    let stderr_thread = std::thread::spawn(move || {
-        // Drain stderr so the child doesn't block on a full pipe.
-        if let Some(mut pipe) = stderr_pipe {
-            let _ = std::io::copy(&mut pipe, &mut std::io::sink());
-        }
-    });
-
-    let child_pid = child.id();
-    let (tx, rx) = std::sync::mpsc::channel();
-    let wait_thread = std::thread::spawn(move || {
-        let status = child.wait();
-        let _ = tx.send(status);
-    });
-
-    // 30-second timeout — plenty for `npm prefix -g`; intentionally shorter
-    // than the 5-minute install budget in `run_install_command`.
-    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
-    let raw_bytes: Option> = loop {
-        let remaining = deadline.saturating_duration_since(std::time::Instant::now());
-        if remaining.is_zero() {
-            // Timed out: send SIGTERM, clean up threads, signal the caller to
-            // fall through to the install path rather than abort.
-            unsafe { libc::kill(child_pid as i32, libc::SIGTERM) };
-            drop(rx);
-            let _ = wait_thread.join();
-            let _ = stdout_thread.join();
-            let _ = stderr_thread.join();
-            eprintln!(
-                "buzz: npm prefix probe timed out after 30s; \
-                 proceeding to install (stderr classifier is the backstop)"
-            );
-            return NpmPrefix::TimedOut;
-        }
-        match rx.recv_timeout(std::time::Duration::from_millis(200).min(remaining)) {
-            Ok(Ok(status)) => {
-                let _ = wait_thread.join();
-                let stdout = stdout_thread.join().unwrap_or_default();
-                let _ = stderr_thread.join();
-                break if status.success() { Some(stdout) } else { None };
-            }
-            Ok(Err(_)) | Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
-                let _ = wait_thread.join();
-                let _ = stdout_thread.join();
-                let _ = stderr_thread.join();
-                break None;
-            }
-            Err(std::sync::mpsc::RecvTimeoutError::Timeout) => continue,
-        }
-    };
-
-    let bytes = match raw_bytes {
-        Some(b) => b,
-        None => return NpmPrefix::Unavailable,
-    };
-    let raw = String::from_utf8_lossy(&bytes).into_owned();
-    // Version managers can print banner lines before the real prefix — take the
-    // last non-empty line to skip any preamble.
-    let prefix = match raw.lines().rfind(|l| !l.trim().is_empty()) {
-        Some(l) => l.trim().to_string(),
-        None => return NpmPrefix::Unavailable,
-    };
-    if prefix.is_empty() {
-        return NpmPrefix::Unavailable;
-    }
-    NpmPrefix::Found(std::path::PathBuf::from(prefix))
-}
-
-/// Check write access to a file-system path using the POSIX `access(2)` syscall.
-#[cfg(unix)]
-fn unix_is_writable(path: &std::path::Path) -> bool {
-    use std::os::unix::ffi::OsStrExt;
-    let bytes = path.as_os_str().as_bytes();
-    let Ok(c_path) = std::ffi::CString::new(bytes) else {
-        return false;
-    };
-    // SAFETY: `access` is a pure read-only syscall; we pass a valid NUL-terminated
-    // path and a standard flag constant.  This mirrors the existing `setsid`/`kill`
-    // usage in this file.
-    unsafe { libc::access(c_path.as_ptr(), libc::W_OK) == 0 }
-}
-
-/// Returns true when the directory where npm would write global packages is
-/// writable by the current process user.
-///
-/// On non-unix platforms always returns `true` — the EACCES preflight is a
-/// no-op there; the stderr classifier still applies.
-fn npm_install_target_is_writable(prefix: &std::path::Path) -> bool {
-    #[cfg(unix)]
-    {
-        // Probe the most specific candidate that exists; fall back up the tree.
-        for candidate in &[
-            prefix.join("lib/node_modules"),
-            prefix.join("lib"),
-            prefix.to_path_buf(),
-        ] {
-            if candidate.exists() {
-                return unix_is_writable(candidate);
-            }
-        }
-        // Nothing exists — npm couldn't create it either.
-        unix_is_writable(prefix)
-    }
-    #[cfg(not(unix))]
-    {
-        let _ = prefix;
-        true
-    }
-}
-
-/// Inspect `stderr` for known npm EACCES patterns and return actionable
-/// guidance if matched, or `None` when the error is unrelated.
-fn npm_eacces_hint(stderr: &str, command: &str) -> Option {
-    if stderr.contains("EACCES: permission denied") || stderr.contains("npm error EACCES") {
-        Some(npm_eacces_guidance(command))
-    } else {
-        None
-    }
-}
-
-/// Run the npm preflight before executing an npm global install command.
-/// Returns `Some(failed InstallStepResult)` to abort, or `None` to proceed.
-fn npm_preflight_check(step: &str, command: &str) -> Option {
-    #[cfg(unix)]
-    {
-        match resolve_npm_prefix() {
-            NpmPrefix::Unavailable => Some(InstallStepResult {
-                step: step.to_string(),
-                command: command.to_string(),
-                success: false,
-                stdout: String::new(),
-                stderr: String::new(),
-                exit_code: None,
-                hint: Some(NPM_MISSING_HINT.to_string()),
-            }),
-            NpmPrefix::Found(prefix) if !npm_install_target_is_writable(&prefix) => {
-                Some(InstallStepResult {
-                    step: step.to_string(),
-                    command: command.to_string(),
-                    success: false,
-                    stdout: String::new(),
-                    stderr: format!(
-                        "npm global prefix '{}' is not writable by the current user.",
-                        prefix.display()
-                    ),
-                    exit_code: None,
-                    hint: Some(npm_eacces_guidance(command)),
-                })
-            }
-            // `Found` + writable, or `TimedOut` — proceed; let the install run and
-            // the stderr classifier serve as the backstop.
-            _ => None,
-        }
-    }
-    #[cfg(not(unix))]
-    {
-        let _ = (step, command);
-        None
-    }
-}
-
-// ── end npm preflight ─────────────────────────────────────────────────────────
+// ── managed Node/npm runtime ──────────────────────────────────────────────────
+mod managed_node;
+use managed_node::{
+    ensure_managed_node_runtime_blocking, managed_node_runtime_supported, managed_npm_command,
+    npm_eacces_hint,
+};
 
 #[tauri::command]
 pub async fn discover_managed_agent_prereqs(
@@ -1090,6 +912,13 @@ mod tests {
         assert!(is_npm_global_install("npm i -g some-package"));
     }
 
+    #[test]
+    fn test_is_npm_global_install_accepts_uninstall() {
+        assert!(is_npm_global_install(
+            "npm uninstall -g @zed-industries/codex-acp"
+        ));
+    }
+
     #[test]
     fn test_is_npm_global_install_accepts_leading_whitespace() {
         assert!(is_npm_global_install("  npm install -g foo"));
@@ -1132,61 +961,6 @@ mod tests {
         assert!(npm_eacces_hint(stderr, "npm install -g no-such-pkg").is_none());
     }
 
-    #[test]
-    fn test_npm_eacces_hint_guidance_contains_npm_global_path() {
-        let hint = npm_eacces_hint("EACCES: permission denied", "npm install -g foo").unwrap();
-        assert!(hint.contains("~/.npm-global"), "hint: {hint}");
-    }
-
-    #[test]
-    fn test_npm_eacces_hint_guidance_contains_zprofile() {
-        let hint = npm_eacces_hint("EACCES: permission denied", "npm install -g foo").unwrap();
-        assert!(hint.contains("~/.zprofile"), "hint: {hint}");
-    }
-
-    #[test]
-    fn test_npm_eacces_hint_guidance_contains_sudo_command() {
-        let hint = npm_eacces_hint("EACCES: permission denied", "npm install -g foo").unwrap();
-        assert!(hint.contains("sudo npm install -g foo"), "hint: {hint}");
-    }
-
-    // ── npm_install_target_is_writable ────────────────────────────────────────
-
-    #[cfg(unix)]
-    #[test]
-    fn test_npm_install_target_is_writable_true_on_writable_dir() {
-        let dir = tempfile::tempdir().unwrap();
-        assert!(npm_install_target_is_writable(dir.path()));
-    }
-
-    #[cfg(unix)]
-    #[test]
-    fn test_npm_install_target_is_writable_false_when_lib_node_modules_unwritable() {
-        use std::os::unix::fs::PermissionsExt;
-        let dir = tempfile::tempdir().unwrap();
-        let lib = dir.path().join("lib");
-        let node_modules = lib.join("node_modules");
-        std::fs::create_dir_all(&node_modules).unwrap();
-        // Make node_modules read-only.
-        std::fs::set_permissions(&node_modules, std::fs::Permissions::from_mode(0o555)).unwrap();
-        let result = npm_install_target_is_writable(dir.path());
-        // Restore before the dir is dropped so cleanup can delete it.
-        std::fs::set_permissions(&node_modules, std::fs::Permissions::from_mode(0o755)).unwrap();
-        // Skip this assertion when running as root (root can write to 0o555).
-        if unsafe { libc::getuid() } != 0 {
-            assert!(!result);
-        }
-    }
-
-    #[cfg(unix)]
-    #[test]
-    fn test_npm_install_target_is_writable_walks_up_to_lib() {
-        let dir = tempfile::tempdir().unwrap();
-        // Create only `lib/` — no `lib/node_modules`.
-        std::fs::create_dir(dir.path().join("lib")).unwrap();
-        assert!(npm_install_target_is_writable(dir.path()));
-    }
-
     // ── adapter_needs_install (codex version gate) ────────────────────────────
 
     /// plan_adapter_install is the pure install-plan seam used by
diff --git a/desktop/src-tauri/src/commands/agent_discovery/managed_node.rs b/desktop/src-tauri/src/commands/agent_discovery/managed_node.rs
new file mode 100644
index 0000000000..fca5a602b0
--- /dev/null
+++ b/desktop/src-tauri/src/commands/agent_discovery/managed_node.rs
@@ -0,0 +1,468 @@
+use sha2::{Digest, Sha256};
+use std::sync::{Mutex, OnceLock};
+use std::time::Duration;
+use std::{io::Read, io::Write};
+
+use crate::managed_agents::{is_npm_global_install, InstallStepResult};
+
+const MANAGED_NODE_VERSION: &str = "v24.11.0";
+const MANAGED_NODE_MAX_BYTES: u64 = 90 * 1024 * 1024;
+
+#[derive(Debug, Clone, Copy)]
+struct ManagedNodeArtifact {
+    platform: &'static str,
+    filename: &'static str,
+    sha256: &'static str,
+}
+
+#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
+const MANAGED_NODE_ARTIFACT: Option = Some(ManagedNodeArtifact {
+    platform: "darwin-arm64",
+    filename: "node-v24.11.0-darwin-arm64.tar.gz",
+    sha256: "0be2ab2816a4fa02d1acff014a434f29f56d8d956f5af6a98b70ced6c5f4d201",
+});
+
+#[cfg(all(target_os = "macos", target_arch = "x86_64"))]
+const MANAGED_NODE_ARTIFACT: Option = Some(ManagedNodeArtifact {
+    platform: "darwin-x64",
+    filename: "node-v24.11.0-darwin-x64.tar.gz",
+    sha256: "3884671e87f46f773832d98a0a6cabcc5ec4f637084f0f3515b69e66ea27f2f1",
+});
+
+#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
+const MANAGED_NODE_ARTIFACT: Option = Some(ManagedNodeArtifact {
+    platform: "linux-x64",
+    filename: "node-v24.11.0-linux-x64.tar.gz",
+    sha256: "b3c071cdf47aab867c3b2aa287257df12ec5d7c962bf922b32fd33226c4295fd",
+});
+
+#[cfg(all(target_os = "linux", target_arch = "aarch64"))]
+const MANAGED_NODE_ARTIFACT: Option = Some(ManagedNodeArtifact {
+    platform: "linux-arm64",
+    filename: "node-v24.11.0-linux-arm64.tar.gz",
+    sha256: "4786d00c4d259d3ff0b2328307f764ef3ced65f2d6e9502d433e68d66238509d",
+});
+
+#[cfg(not(any(
+    all(target_os = "macos", target_arch = "aarch64"),
+    all(target_os = "macos", target_arch = "x86_64"),
+    all(target_os = "linux", target_arch = "x86_64"),
+    all(target_os = "linux", target_arch = "aarch64")
+)))]
+const MANAGED_NODE_ARTIFACT: Option = None;
+
+fn managed_node_unsupported_step() -> InstallStepResult {
+    InstallStepResult {
+        step: "node".to_string(),
+        command: "managed Node.js runtime".to_string(),
+        success: false,
+        stdout: String::new(),
+        stderr: format!(
+            "Buzz does not provide a managed Node.js runtime for {}-{} yet",
+            std::env::consts::OS,
+            std::env::consts::ARCH
+        ),
+        exit_code: None,
+        hint: Some(
+            "Install Node.js from https://nodejs.org, restart Buzz, then click Install again."
+                .to_string(),
+        ),
+    }
+}
+
+fn managed_node_install_hint() -> String {
+    "Buzz could not install its private Node.js runtime. Check your network and app-data directory permissions, then click Install again.".to_string()
+}
+
+fn managed_node_failed_step(stderr: String) -> InstallStepResult {
+    InstallStepResult {
+        step: "node".to_string(),
+        command: "managed Node.js runtime".to_string(),
+        success: false,
+        stdout: String::new(),
+        stderr,
+        exit_code: None,
+        hint: Some(managed_node_install_hint()),
+    }
+}
+
+fn managed_node_runtime_ready() -> bool {
+    let Some(node) = crate::managed_agents::buzz_managed_node_bin_path() else {
+        return false;
+    };
+    if !node.is_file() {
+        return false;
+    }
+    let output = std::process::Command::new(&node)
+        .arg("--version")
+        .stdin(std::process::Stdio::null())
+        .stdout(std::process::Stdio::piped())
+        .stderr(std::process::Stdio::null())
+        .output();
+    output
+        .ok()
+        .filter(|output| output.status.success())
+        .map(|output| String::from_utf8_lossy(&output.stdout).trim() == MANAGED_NODE_VERSION)
+        .unwrap_or(false)
+}
+
+fn managed_node_install_lock() -> &'static Mutex<()> {
+    static LOCK: OnceLock> = OnceLock::new();
+    LOCK.get_or_init(|| Mutex::new(()))
+}
+
+pub(super) fn managed_node_runtime_supported() -> bool {
+    MANAGED_NODE_ARTIFACT.is_some() && crate::managed_agents::buzz_managed_node_bin_dir().is_some()
+}
+
+pub(super) fn ensure_managed_node_runtime_blocking() -> Result<(), Box> {
+    if managed_node_runtime_ready() {
+        return Ok(());
+    }
+
+    let Some(artifact) = MANAGED_NODE_ARTIFACT else {
+        return Err(Box::new(managed_node_unsupported_step()));
+    };
+    let Some(root) = crate::managed_agents::buzz_managed_node_root() else {
+        return Err(Box::new(managed_node_failed_step(
+            "failed to resolve Buzz app-data directory for private Node.js runtime".to_string(),
+        )));
+    };
+
+    let _guard = managed_node_install_lock().lock().map_err(|_| {
+        Box::new(managed_node_failed_step(
+            "managed Node.js install lock poisoned".to_string(),
+        ))
+    })?;
+
+    if managed_node_runtime_ready() {
+        return Ok(());
+    }
+
+    install_managed_node_runtime(&root, artifact)
+        .map_err(|err| Box::new(managed_node_failed_step(err)))?;
+    if managed_node_runtime_ready() {
+        Ok(())
+    } else {
+        Err(Box::new(managed_node_failed_step(
+            "managed Node.js runtime did not pass readiness after install".to_string(),
+        )))
+    }
+}
+
+fn install_managed_node_runtime(
+    root: &std::path::Path,
+    artifact: ManagedNodeArtifact,
+) -> Result<(), String> {
+    let final_dir = root.join(MANAGED_NODE_VERSION).join(artifact.platform);
+    let temp_dir = root.join(format!(
+        "{}.{}.tmp",
+        MANAGED_NODE_VERSION, artifact.platform
+    ));
+    let archive_path = root.join(format!("{}.download", artifact.filename));
+
+    if temp_dir.exists() {
+        std::fs::remove_dir_all(&temp_dir).map_err(|e| format!("remove stale temp dir: {e}"))?;
+    }
+    std::fs::create_dir_all(root).map_err(|e| format!("create runtime root: {e}"))?;
+    if let Some(parent) = final_dir.parent() {
+        std::fs::create_dir_all(parent).map_err(|e| format!("create runtime version dir: {e}"))?;
+    }
+
+    let url = format!(
+        "https://nodejs.org/dist/{MANAGED_NODE_VERSION}/{}",
+        artifact.filename
+    );
+    download_managed_node_archive(&url, &archive_path, artifact.sha256)?;
+
+    std::fs::create_dir_all(&temp_dir).map_err(|e| format!("create temp dir: {e}"))?;
+    extract_managed_node_archive(&archive_path, &temp_dir, artifact.filename)?;
+    let _ = std::fs::remove_file(&archive_path);
+
+    let extracted_dir = temp_dir.join(artifact.filename.trim_end_matches(".tar.gz"));
+    let source_dir = if extracted_dir.is_dir() {
+        extracted_dir
+    } else {
+        temp_dir.clone()
+    };
+    verify_node_tree(&source_dir)?;
+
+    let old_dir = final_dir.with_extension("old");
+    if old_dir.exists() {
+        std::fs::remove_dir_all(&old_dir).map_err(|e| format!("remove stale old dir: {e}"))?;
+    }
+    if final_dir.exists() {
+        std::fs::rename(&final_dir, &old_dir)
+            .map_err(|e| format!("stage previous runtime: {e}"))?;
+    }
+    if let Err(error) = std::fs::rename(&source_dir, &final_dir) {
+        if old_dir.exists() {
+            let _ = std::fs::rename(&old_dir, &final_dir);
+        }
+        return Err(format!("install runtime: {error}"));
+    }
+    let _ = std::fs::remove_dir_all(&old_dir);
+    let _ = std::fs::remove_dir_all(&temp_dir);
+    Ok(())
+}
+
+fn download_managed_node_archive(
+    url: &str,
+    dest: &std::path::Path,
+    expected_sha256: &str,
+) -> Result<(), String> {
+    let client = reqwest::blocking::Client::builder()
+        .timeout(Duration::from_secs(5 * 60))
+        .build()
+        .map_err(|e| format!("build Node.js download client: {e}"))?;
+    let response = client
+        .get(url)
+        .send()
+        .map_err(|e| format!("download Node.js request failed: {e}"))?;
+    if !response.status().is_success() {
+        return Err(format!(
+            "download Node.js HTTP {}: {}",
+            response.status().as_u16(),
+            response.status().canonical_reason().unwrap_or("unknown")
+        ));
+    }
+    if let Some(total) = response.content_length() {
+        if total > MANAGED_NODE_MAX_BYTES {
+            return Err(format!(
+                "download Node.js too large: {total} bytes (max {MANAGED_NODE_MAX_BYTES})"
+            ));
+        }
+    }
+
+    let mut response = response;
+    let mut file =
+        std::fs::File::create(dest).map_err(|e| format!("create Node.js archive: {e}"))?;
+    let mut hasher = Sha256::new();
+    let mut downloaded = 0_u64;
+    let mut buffer = [0_u8; 64 * 1024];
+    loop {
+        let read = response
+            .read(&mut buffer)
+            .map_err(|e| format!("download Node.js stream error: {e}"))?;
+        if read == 0 {
+            break;
+        }
+        downloaded += read as u64;
+        if downloaded > MANAGED_NODE_MAX_BYTES {
+            let _ = std::fs::remove_file(dest);
+            return Err(format!(
+                "download Node.js exceeded max size: {downloaded} bytes (max {MANAGED_NODE_MAX_BYTES})"
+            ));
+        }
+        file.write_all(&buffer[..read])
+            .map_err(|e| format!("write Node.js archive: {e}"))?;
+        hasher.update(&buffer[..read]);
+    }
+    file.flush()
+        .map_err(|e| format!("flush Node.js archive: {e}"))?;
+
+    let actual = hex::encode(hasher.finalize());
+    if actual != expected_sha256 {
+        let _ = std::fs::remove_file(dest);
+        return Err(format!(
+            "download Node.js hash mismatch: expected {expected_sha256}, got {actual}"
+        ));
+    }
+    Ok(())
+}
+
+fn extract_managed_node_archive(
+    archive_path: &std::path::Path,
+    dest_dir: &std::path::Path,
+    filename: &str,
+) -> Result<(), String> {
+    if !filename.ends_with(".tar.gz") {
+        return Err(format!("unsupported managed Node.js archive: {filename}"));
+    }
+    let file =
+        std::fs::File::open(archive_path).map_err(|e| format!("open Node.js archive: {e}"))?;
+    let decoder = flate2::read::GzDecoder::new(file);
+    let mut archive = tar::Archive::new(decoder);
+    validate_managed_node_archive_entries(&mut archive)?;
+
+    let file = std::fs::File::open(archive_path)
+        .map_err(|e| format!("open Node.js archive for extraction: {e}"))?;
+    let decoder = flate2::read::GzDecoder::new(file);
+    let mut archive = tar::Archive::new(decoder);
+    archive
+        .unpack(dest_dir)
+        .map_err(|e| format!("extract Node.js archive: {e}"))
+}
+
+fn validate_managed_node_archive_entries(
+    archive: &mut tar::Archive,
+) -> Result<(), String> {
+    let entries = archive
+        .entries()
+        .map_err(|e| format!("read Node.js archive entries: {e}"))?;
+    for entry in entries {
+        let entry = entry.map_err(|e| format!("Node.js archive entry: {e}"))?;
+        let path = entry
+            .path()
+            .map_err(|e| format!("Node.js archive entry path: {e}"))?;
+        let path_str = path.to_string_lossy();
+        if path.is_absolute() {
+            return Err(format!(
+                "Node.js archive contains absolute path: {path_str}"
+            ));
+        }
+        if path
+            .components()
+            .any(|component| matches!(component, std::path::Component::ParentDir))
+        {
+            return Err(format!(
+                "Node.js archive contains path traversal: {path_str}"
+            ));
+        }
+    }
+    Ok(())
+}
+
+fn verify_node_tree(dir: &std::path::Path) -> Result<(), String> {
+    let node = dir.join("bin").join("node");
+    let npm = dir.join("bin").join("npm");
+    if !node.is_file() {
+        return Err("Node.js archive missing bin/node".to_string());
+    }
+    if !npm.is_file() {
+        return Err("Node.js archive missing bin/npm".to_string());
+    }
+    Ok(())
+}
+
+// ── managed npm adapter installs ──────────────────────────────────────────────
+
+/// Guidance text shown when the Buzz-private npm prefix is not available.
+fn managed_npm_prefix_hint() -> String {
+    "Buzz could not create its private Node tools directory. Check app-data directory permissions, restart Buzz, then click Install again.".to_string()
+}
+
+pub(super) fn managed_npm_command(command: &str) -> Result, Box> {
+    if !is_npm_global_install(command) {
+        return Ok(None);
+    }
+
+    let Some(prefix) = crate::managed_agents::buzz_managed_npm_prefix() else {
+        return Err(Box::new(InstallStepResult {
+            step: "adapter".to_string(),
+            command: command.to_string(),
+            success: false,
+            stdout: String::new(),
+            stderr: "failed to resolve Buzz app-data directory for private npm prefix".to_string(),
+            exit_code: None,
+            hint: Some(managed_npm_prefix_hint()),
+        }));
+    };
+    if let Err(error) = std::fs::create_dir_all(&prefix) {
+        return Err(Box::new(InstallStepResult {
+            step: "adapter".to_string(),
+            command: command.to_string(),
+            success: false,
+            stdout: String::new(),
+            stderr: format!(
+                "failed to create Buzz private npm prefix '{}': {error}",
+                prefix.display()
+            ),
+            exit_code: None,
+            hint: Some(managed_npm_prefix_hint()),
+        }));
+    }
+
+    let prefix_arg = shell_quote(&prefix);
+    Ok(Some(rewrite_npm_global_install(command, &prefix_arg)))
+}
+
+fn rewrite_npm_global_install(command: &str, quoted_prefix: &str) -> String {
+    let trimmed = command.trim_start();
+    if let Some(rest) = trimmed.strip_prefix("npm install -g ") {
+        format!("npm install --global --prefix {quoted_prefix} {rest}")
+    } else if let Some(rest) = trimmed.strip_prefix("npm i -g ") {
+        format!("npm i --global --prefix {quoted_prefix} {rest}")
+    } else if let Some(rest) = trimmed.strip_prefix("npm uninstall -g ") {
+        format!("npm uninstall --global --prefix {quoted_prefix} {rest}")
+    } else {
+        trimmed.to_string()
+    }
+}
+
+fn shell_quote(path: &std::path::Path) -> String {
+    let value = path.to_string_lossy();
+    format!("'{}'", value.replace('\'', "'\\''"))
+}
+
+/// Inspect `stderr` for known npm EACCES patterns and return actionable
+/// guidance if matched, or `None` when the error is unrelated.
+pub(super) fn npm_eacces_hint(stderr: &str, _command: &str) -> Option {
+    if stderr.contains("EACCES: permission denied") || stderr.contains("npm error EACCES") {
+        Some(
+            "npm could not write to Buzz's private Node tools directory. Check app-data directory permissions, restart Buzz, then click Install again."
+                .to_string(),
+        )
+    } else {
+        None
+    }
+}
+
+// ── end managed npm adapter installs ──────────────────────────────────────────
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_npm_eacces_hint_guidance_mentions_buzz_private_dir() {
+        let hint = npm_eacces_hint("EACCES: permission denied", "npm install -g foo").unwrap();
+        assert!(
+            hint.contains("Buzz's private Node tools directory"),
+            "hint: {hint}"
+        );
+    }
+
+    #[test]
+    fn test_rewrite_npm_install_uses_private_prefix() {
+        assert_eq!(
+            rewrite_npm_global_install(
+                "npm install -g @agentclientprotocol/codex-acp",
+                "'/tmp/Buzz Node'"
+            ),
+            "npm install --global --prefix '/tmp/Buzz Node' @agentclientprotocol/codex-acp"
+        );
+    }
+
+    #[test]
+    fn test_rewrite_npm_i_uses_private_prefix() {
+        assert_eq!(
+            rewrite_npm_global_install("npm i -g some-package", "'/tmp/buzz'"),
+            "npm i --global --prefix '/tmp/buzz' some-package"
+        );
+    }
+
+    #[test]
+    fn test_rewrite_npm_uninstall_uses_private_prefix() {
+        assert_eq!(
+            rewrite_npm_global_install("npm uninstall -g @zed-industries/codex-acp", "'/tmp/buzz'"),
+            "npm uninstall --global --prefix '/tmp/buzz' @zed-industries/codex-acp"
+        );
+    }
+
+    #[test]
+    fn test_rewrite_ignores_non_global_command() {
+        assert_eq!(
+            rewrite_npm_global_install("npm install foo", "'/tmp/buzz'"),
+            "npm install foo"
+        );
+    }
+
+    #[test]
+    fn test_shell_quote_escapes_single_quotes() {
+        assert_eq!(
+            shell_quote(std::path::Path::new("/tmp/Buzz's Node")),
+            "'/tmp/Buzz'\\''s Node'"
+        );
+    }
+}
diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs
index a368f4f7c5..27717a3604 100644
--- a/desktop/src-tauri/src/managed_agents/discovery.rs
+++ b/desktop/src-tauri/src/managed_agents/discovery.rs
@@ -1,9 +1,11 @@
 use std::io::Read;
 use std::path::{Path, PathBuf};
 use std::process::Command;
+use std::sync::OnceLock;
 use std::time::{Duration, Instant};
 
 use crate::managed_agents::{
+    buzz_managed_command_path, buzz_managed_node_bin_dir, buzz_managed_npm_bin_dir,
     AcpAvailabilityStatus, AcpRuntimeCatalogEntry, AuthStatus, CommandAvailabilityInfo,
 };
 
@@ -94,7 +96,6 @@ const BUZZ_AGENT_AVATAR_URL: &str =
     "https://raw.githubusercontent.com/block/buzz/refs/heads/main/crates/buzz-agent/buzz-agent.png";
 
 fn common_binary_paths() -> &'static [PathBuf] {
-    use std::sync::OnceLock;
     static PATHS: OnceLock> = OnceLock::new();
     PATHS.get_or_init(|| {
         let mut paths = vec![
@@ -103,6 +104,12 @@ fn common_binary_paths() -> &'static [PathBuf] {
             PathBuf::from("/usr/bin"),
             PathBuf::from("/home/linuxbrew/.linuxbrew/bin"),
         ];
+        if let Some(managed_node_bin) = buzz_managed_node_bin_dir() {
+            paths.insert(0, managed_node_bin);
+        }
+        if let Some(managed_bin) = buzz_managed_npm_bin_dir() {
+            paths.insert(0, managed_bin);
+        }
         if let Some(home) = dirs::home_dir() {
             paths.extend([
                 home.join(".local/share/mise/shims"),
@@ -514,6 +521,10 @@ fn resolve_cache() -> &'static std::sync::Mutex Option {
+    if let Some(managed) = resolve_buzz_managed_command(command) {
+        return Some(managed);
+    }
+
     let cache = resolve_cache();
 
     // Fast path: return cached result without allocating a key.
@@ -628,16 +639,29 @@ fn command_basenames(command: &str) -> Vec {
     candidates
 }
 
+fn resolve_buzz_managed_command(command: &str) -> Option {
+    let basenames = command_basenames(command);
+    basenames
+        .iter()
+        .find_map(|basename| buzz_managed_command_path(command, basename))
+}
+
 fn resolve_command_uncached(command: &str) -> Option {
     if let Some(path) = resolve_workspace_command(command) {
         return Some(path);
     }
 
+    let basenames = command_basenames(command);
+
     if command_looks_like_path(command) {
         let path = PathBuf::from(command);
         return path.exists().then_some(path);
     }
 
+    if let Some(managed) = resolve_buzz_managed_command(command) {
+        return Some(managed);
+    }
+
     for candidate in path_candidates_from_env(command) {
         if is_executable_file(&candidate) {
             return Some(candidate);
@@ -659,7 +683,6 @@ fn resolve_command_uncached(command: &str) -> Option {
     if let Some(path) = find_via_login_shell(command) {
         return Some(path);
     }
-    let basenames = command_basenames(command);
     for dir in common_binary_paths() {
         for basename in &basenames {
             let candidate = dir.join(basename);
@@ -937,13 +960,16 @@ fn runtime_needs_npm(runtime: &KnownAcpRuntime) -> bool {
         .any(|cmd| is_npm_global_install(cmd))
 }
 
-/// Returns `true` when `cmd` is an `npm install -g` invocation.
+/// Returns `true` when `cmd` is an npm global install/uninstall invocation.
 ///
-/// Used by Doctor to determine whether Node.js is required before running an
-/// install step, and by the npm EACCES preflight in the install command path.
+/// Buzz rewrites these catalog commands to an app-private npm prefix before
+/// execution; the global shape remains in the catalog so existing install plans
+/// and Doctor's Node.js-required detection stay simple.
 pub(crate) fn is_npm_global_install(cmd: &str) -> bool {
     let t = cmd.trim_start();
-    t.starts_with("npm install -g ") || t.starts_with("npm i -g ")
+    t.starts_with("npm install -g ")
+        || t.starts_with("npm i -g ")
+        || t.starts_with("npm uninstall -g ")
 }
 
 /// Run a CLI auth probe with a 10-second process-level timeout.
@@ -1270,11 +1296,14 @@ pub fn discover_acp_runtimes() -> Vec {
                 }
             };
 
-            // node_required: an npm adapter step is pending AND node/npm are absent.
+            // node_required now means Buzz cannot provide npm for this platform.
+            // On supported desktop platforms, Buzz downloads a private Node/npm
+            // runtime into app data before running npm-backed adapter installs.
             let node_required = matches!(
                 availability,
                 AcpAvailabilityStatus::AdapterMissing | AcpAvailabilityStatus::NotInstalled
             ) && runtime_needs_npm(runtime)
+                && buzz_managed_node_bin_dir().is_none()
                 && resolve_command("npm").is_none()
                 && resolve_command("node").is_none();
 
diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs
index 608b511fa6..0ed4fe0f6a 100644
--- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs
+++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs
@@ -609,6 +609,8 @@ fn apply_agent_command_update_concrete_pin_keeps_materialized_runtime() {
 
 // ── probe_codex_acp_major_version ─────────────────────────────────────────────
 
+mod managed_path_resolution;
+
 #[cfg(unix)]
 #[test]
 fn probe_codex_acp_major_version_parses_1x_output() {
diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests/managed_path_resolution.rs b/desktop/src-tauri/src/managed_agents/discovery/tests/managed_path_resolution.rs
new file mode 100644
index 0000000000..2f6b038deb
--- /dev/null
+++ b/desktop/src-tauri/src/managed_agents/discovery/tests/managed_path_resolution.rs
@@ -0,0 +1,64 @@
+use crate::managed_agents::discovery::{clear_resolve_cache, resolve_command};
+
+#[cfg(unix)]
+#[test]
+fn resolve_command_prefers_buzz_managed_npm_shim_over_path() {
+    use std::os::unix::fs::PermissionsExt;
+
+    let _guard = crate::managed_agents::lock_path_mutex();
+    let temp = tempfile::tempdir().expect("tempdir");
+    let home = temp.path().join("home");
+    let xdg_data = temp.path().join("xdg-data");
+    let global_bin = temp.path().join("global-bin");
+    std::fs::create_dir_all(&home).expect("create home");
+    std::fs::create_dir_all(&xdg_data).expect("create xdg data");
+    std::fs::create_dir_all(&global_bin).expect("create global bin");
+
+    let old_home = std::env::var_os("HOME");
+    let old_xdg_data = std::env::var_os("XDG_DATA_HOME");
+    let old_path = std::env::var_os("PATH").unwrap_or_default();
+
+    std::env::set_var("HOME", &home);
+    std::env::set_var("XDG_DATA_HOME", &xdg_data);
+    let managed_bin = dirs::data_dir()
+        .expect("data dir")
+        .join("Buzz")
+        .join("node-tools")
+        .join("bin");
+    std::fs::create_dir_all(&managed_bin).expect("create managed bin");
+
+    let managed_shim = managed_bin.join("codex-acp");
+    let global_shim = global_bin.join("codex-acp");
+    std::fs::write(&managed_shim, "#!/bin/sh\necho managed\n").expect("write managed shim");
+    std::fs::write(&global_shim, "#!/bin/sh\necho global\n").expect("write global shim");
+    std::fs::set_permissions(&managed_shim, std::fs::Permissions::from_mode(0o755))
+        .expect("chmod managed shim");
+    std::fs::set_permissions(&global_shim, std::fs::Permissions::from_mode(0o755))
+        .expect("chmod global shim");
+
+    let new_path = std::env::join_paths(
+        std::iter::once(global_bin.clone()).chain(std::env::split_paths(&old_path)),
+    )
+    .expect("join PATH");
+    std::env::set_var("PATH", new_path);
+    clear_resolve_cache();
+
+    let resolved = resolve_command("codex-acp");
+
+    std::env::set_var("PATH", &old_path);
+    match old_home {
+        Some(value) => std::env::set_var("HOME", value),
+        None => std::env::remove_var("HOME"),
+    }
+    match old_xdg_data {
+        Some(value) => std::env::set_var("XDG_DATA_HOME", value),
+        None => std::env::remove_var("XDG_DATA_HOME"),
+    }
+    clear_resolve_cache();
+
+    assert_eq!(
+        resolved.as_deref(),
+        Some(managed_shim.as_path()),
+        "Buzz-managed npm shim must win over PATH/global shims"
+    );
+}
diff --git a/desktop/src-tauri/src/managed_agents/managed_node_paths.rs b/desktop/src-tauri/src/managed_agents/managed_node_paths.rs
new file mode 100644
index 0000000000..6533f4fbe6
--- /dev/null
+++ b/desktop/src-tauri/src/managed_agents/managed_node_paths.rs
@@ -0,0 +1,86 @@
+use std::path::PathBuf;
+
+pub(crate) fn buzz_managed_npm_prefix() -> Option {
+    dirs::data_dir().map(|dir| dir.join("Buzz").join("node-tools"))
+}
+
+const BUZZ_MANAGED_NODE_VERSION: &str = "v24.11.0";
+
+pub(crate) fn buzz_managed_node_root() -> Option {
+    dirs::data_dir().map(|dir| dir.join("Buzz").join("runtimes").join("node"))
+}
+
+pub(crate) fn buzz_managed_node_bin_dir() -> Option {
+    let platform = match (std::env::consts::OS, std::env::consts::ARCH) {
+        ("macos", "aarch64") => "darwin-arm64",
+        ("macos", "x86_64") => "darwin-x64",
+        ("linux", "x86_64") => "linux-x64",
+        ("linux", "aarch64") => "linux-arm64",
+        _ => return None,
+    };
+    buzz_managed_node_root().map(|root| {
+        root.join(BUZZ_MANAGED_NODE_VERSION)
+            .join(platform)
+            .join("bin")
+    })
+}
+
+pub(crate) fn buzz_managed_node_bin_path() -> Option {
+    buzz_managed_node_bin_dir().map(|bin| bin.join("node"))
+}
+
+pub(crate) fn buzz_managed_npm_bin_dir() -> Option {
+    buzz_managed_npm_prefix().map(|prefix| {
+        #[cfg(windows)]
+        {
+            prefix
+        }
+        #[cfg(not(windows))]
+        {
+            prefix.join("bin")
+        }
+    })
+}
+
+pub(crate) fn buzz_managed_command_path(command: &str, basename: &str) -> Option {
+    if command.contains(std::path::MAIN_SEPARATOR)
+        || !matches!(
+            command,
+            "codex-acp" | "claude-agent-acp" | "claude-code-acp" | "node" | "npm"
+        )
+    {
+        return None;
+    }
+
+    let mut dirs = Vec::new();
+    if let Some(managed_bin) = buzz_managed_npm_bin_dir() {
+        dirs.push(managed_bin);
+    }
+    if let Some(managed_node_bin) = buzz_managed_node_bin_dir() {
+        dirs.push(managed_node_bin);
+    }
+
+    dirs.into_iter()
+        .map(|dir| dir.join(basename))
+        .find(|candidate| is_executable_file(candidate))
+}
+
+fn is_executable_file(path: &std::path::Path) -> bool {
+    let Ok(metadata) = path.metadata() else {
+        return false;
+    };
+    if !metadata.is_file() {
+        return false;
+    }
+
+    #[cfg(unix)]
+    {
+        use std::os::unix::fs::PermissionsExt;
+        metadata.permissions().mode() & 0o111 != 0
+    }
+
+    #[cfg(not(unix))]
+    {
+        true
+    }
+}
diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs
index afe7f6f405..3a9c66b1ab 100644
--- a/desktop/src-tauri/src/managed_agents/mod.rs
+++ b/desktop/src-tauri/src/managed_agents/mod.rs
@@ -11,6 +11,7 @@ mod discovery;
 mod env_vars;
 pub(crate) mod git_bash;
 pub(crate) mod global_config;
+mod managed_node_paths;
 mod nest;
 mod persona_avatars;
 pub(crate) mod persona_events;
@@ -50,6 +51,7 @@ pub(crate) use global_config::{
     load_global_agent_config, resolve_effective_model_provider, save_global_agent_config,
     validate_global_config, GlobalAgentConfig,
 };
+pub(crate) use managed_node_paths::*;
 pub use nest::*;
 pub use personas::*;
 #[cfg(windows)]
diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs
index 95f20d1df5..87ee6241ee 100644
--- a/desktop/src-tauri/src/managed_agents/readiness.rs
+++ b/desktop/src-tauri/src/managed_agents/readiness.rs
@@ -1105,6 +1105,12 @@ mod tests {
         (dir, original_path)
     }
 
+    #[cfg(unix)]
+    fn leaked_adapter_commands(bin: &std::path::Path) -> &'static [&'static str] {
+        let command = Box::leak(bin.display().to_string().into_boxed_str());
+        Box::leak(vec![command as &'static str].into_boxed_slice())
+    }
+
     /// Restore PATH and clear the resolve cache after a PATH-mutating test.
     #[cfg(unix)]
     fn restore_path(original: &str) {
@@ -1121,8 +1127,13 @@ mod tests {
 
         let (dir, orig) = setup_temp_codex_acp("#!/bin/sh\nexit 1\n");
         let exe = present_binary_str();
-        // underlying_cli = running test binary (always present, never probed)
-        let rt = make_codex_runtime(&["codex-acp"], Some(exe));
+        // Use the fixture's absolute adapter path here. Bare `codex-acp`
+        // intentionally prefers Buzz's managed npm shim when it exists, which
+        // would make this version-gate regression test depend on machine state.
+        let rt = make_codex_runtime(
+            leaked_adapter_commands(&dir.path().join("codex-acp")),
+            Some(exe),
+        );
         let reqs = cli_login::requirements(
             &[exe, "--buzz-probe-must-not-run-xyz"],
             "run `codex login`",
@@ -1159,7 +1170,10 @@ mod tests {
 
         let (dir, orig) = setup_temp_codex_acp("#!/bin/sh\necho 'not a version string'\nexit 0\n");
         let exe = present_binary_str();
-        let rt = make_codex_runtime(&["codex-acp"], Some(exe));
+        let rt = make_codex_runtime(
+            leaked_adapter_commands(&dir.path().join("codex-acp")),
+            Some(exe),
+        );
         let reqs = cli_login::requirements(
             &[exe, "--buzz-probe-must-not-run-xyz"],
             "run `codex login`",
diff --git a/desktop/src-tauri/src/managed_agents/readiness/cli_login.rs b/desktop/src-tauri/src/managed_agents/readiness/cli_login.rs
index 47b1740cf7..4036d9f239 100644
--- a/desktop/src-tauri/src/managed_agents/readiness/cli_login.rs
+++ b/desktop/src-tauri/src/managed_agents/readiness/cli_login.rs
@@ -25,11 +25,9 @@ pub(super) fn requirements(
         .map(|cli| find_command(cli).is_some())
         .unwrap_or(false);
 
-    let (availability, cmd, adapter_path) =
+    let (availability, _cmd, adapter_path) =
         classify_runtime(adapter_result, runtime.underlying_cli, underlying_cli_found);
-    let availability = if runtime.id == "codex"
-        && availability == AcpAvailabilityStatus::Available
-        && cmd.as_deref() == Some("codex-acp")
+    let availability = if runtime.id == "codex" && availability == AcpAvailabilityStatus::Available
     {
         adapter_path
             .as_deref()
diff --git a/desktop/src-tauri/src/managed_agents/runtime/path.rs b/desktop/src-tauri/src/managed_agents/runtime/path.rs
index af2d2e96da..c1baadda7f 100644
--- a/desktop/src-tauri/src/managed_agents/runtime/path.rs
+++ b/desktop/src-tauri/src/managed_agents/runtime/path.rs
@@ -6,9 +6,11 @@ use std::path::PathBuf;
 ///
 /// Concatenates, in priority order:
 ///   1. `/.local/bin` — bundled CLI symlink
-///   2. `nvm_bin` — nvm's default Node.js bin dir (if the user uses nvm)
-///   3. exe parent dir — DMG sidecars under `Contents/MacOS/`
-///   4. user's login-shell `PATH` — runtimes like node/python from other managers
+///   2. Buzz-managed npm prefix bin dir — app-private ACP adapter shims
+///   3. Buzz-managed Node.js bin dir — app-private Node/npm runtime
+///   4. `nvm_bin` — nvm's default Node.js bin dir (if the user uses nvm)
+///   5. exe parent dir — DMG sidecars under `Contents/MacOS/`
+///   6. user's login-shell `PATH` — runtimes like node/python from other managers
 ///
 /// `shell_path` is the raw colon-delimited string from a login shell, so it is
 /// split into individual entries before joining. Pushing it as a single segment
@@ -23,9 +25,21 @@ pub(in crate::managed_agents) fn build_augmented_path(
     nvm_bin: Option,
 ) -> Option {
     let mut parts: Vec = Vec::new();
+    let home_added = home.is_some();
     if let Some(home) = home {
         parts.push(home.join(".local").join("bin"));
     }
+    // Only add managed runtime dirs when a home or executable context exists.
+    // This keeps tests/utility callers that intentionally pass no local context
+    // from manufacturing a PATH out of ambient platform dirs alone.
+    if home_added || exe_parent.is_some() {
+        if let Some(managed_npm_bin) = crate::managed_agents::buzz_managed_npm_bin_dir() {
+            parts.push(managed_npm_bin);
+        }
+        if let Some(managed_node_bin) = crate::managed_agents::buzz_managed_node_bin_dir() {
+            parts.push(managed_node_bin);
+        }
+    }
     if let Some(nvm_bin) = nvm_bin {
         parts.push(nvm_bin);
     }
@@ -62,12 +76,15 @@ mod tests {
             Some("/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin".to_string()),
             None,
         );
-        assert_eq!(
-            result.as_deref(),
-            Some(
-                "/home/agent/.local/bin:/Applications/Buzz.app/Contents/MacOS:\
-/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin"
-            ),
+        let result = result.expect("path");
+        assert!(result.starts_with("/home/agent/.local/bin:"), "{result}");
+        assert!(
+            result.contains(":/Applications/Buzz.app/Contents/MacOS:"),
+            "{result}"
+        );
+        assert!(
+            result.ends_with(":/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin"),
+            "{result}"
         );
     }
 
@@ -92,15 +109,16 @@ mod tests {
             Some("/usr/bin:/bin".to_string()),
             Some(PathBuf::from("/home/user/.nvm/versions/node/v20.0.0/bin")),
         );
-        assert_eq!(
-            result.as_deref(),
-            Some(
-                "/home/user/.local/bin:\
-/home/user/.nvm/versions/node/v20.0.0/bin:\
-/Applications/Buzz.app/Contents/MacOS:\
-/usr/bin:/bin"
-            ),
-        );
+        let result = result.expect("path");
+        let local = result.find("/home/user/.local/bin").unwrap();
+        let nvm = result
+            .find("/home/user/.nvm/versions/node/v20.0.0/bin")
+            .unwrap();
+        let exe = result
+            .find("/Applications/Buzz.app/Contents/MacOS")
+            .unwrap();
+        assert!(local < nvm && nvm < exe, "{result}");
+        assert!(result.ends_with(":/usr/bin:/bin"), "{result}");
     }
 
     #[cfg(unix)]
@@ -112,9 +130,8 @@ mod tests {
             None,
             None,
         );
-        assert_eq!(
-            result.as_deref(),
-            Some("/home/user/.local/bin:/usr/local/bin"),
-        );
+        let result = result.expect("path");
+        assert!(result.starts_with("/home/user/.local/bin:"), "{result}");
+        assert!(result.ends_with(":/usr/local/bin"), "{result}");
     }
 }
diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs
index 9cb2354765..bb0781b643 100644
--- a/desktop/src-tauri/src/managed_agents/types.rs
+++ b/desktop/src-tauri/src/managed_agents/types.rs
@@ -597,7 +597,7 @@ pub struct InstallStepResult {
     pub stderr: String,
     pub exit_code: Option,
     /// Actionable guidance shown in the UI when this step failed due to a
-    /// recognized condition (e.g. EACCES on a root-owned npm global prefix).
+    /// recognized condition (e.g. EACCES writing Buzz's private npm prefix).
     /// `None` when the step succeeded or no pattern matched.
     #[serde(skip_serializing_if = "Option::is_none")]
     pub hint: Option,