diff --git a/crates/agent-gateway/web/src/agent-ui-adapters/sandboxCapability.ts b/crates/agent-gateway/web/src/agent-ui-adapters/sandboxCapability.ts index d0f484018..1b8030a10 100644 --- a/crates/agent-gateway/web/src/agent-ui-adapters/sandboxCapability.ts +++ b/crates/agent-gateway/web/src/agent-ui-adapters/sandboxCapability.ts @@ -1,3 +1,7 @@ +import type { SandboxSetupOutcome } from "@liveagent/ui/lib/kbrain/sandboxSetup"; + +export type { SandboxSetupOutcome }; + export type SandboxCapability = { supported: boolean; mechanism: string; @@ -7,9 +11,20 @@ export type SandboxCapability = { read_masking?: boolean; path_protection?: boolean; reason?: string; + /** 仅桌面端:可在本机运行 Windows 沙箱 setup。WebUI 永远不提供。 */ + setup_available?: true; + setup_hint?: string; }; /** WebUI:浏览器侧不读 K-brain health;null 表示能力未知,由 Gateway 提交前按 run_sandbox 裁决。 */ export function useSandboxCapability(): SandboxCapability | null { return null; } + +/** + * WebUI:Windows 沙箱 setup 会在运行 K-brain 的机器上弹出 UAC,远程浏览器无法完成, + * 所以从不提供(useSandboxCapability 为 null,入口也不会显示)。 + */ +export async function setUpOfflineSandbox(): Promise { + return { status: "unsupported" }; +} diff --git a/crates/agent-gui/src/agent-ui-adapters/kbrainSettings.ts b/crates/agent-gui/src/agent-ui-adapters/kbrainSettings.ts index 1c44fb46d..9777555d7 100644 --- a/crates/agent-gui/src/agent-ui-adapters/kbrainSettings.ts +++ b/crates/agent-gui/src/agent-ui-adapters/kbrainSettings.ts @@ -1,4 +1,8 @@ import { type KBrainHealth, readKBrainHealthResponse } from "@liveagent/ui/lib/kbrain/health"; +import { + readSandboxSetupResponse, + type SandboxSetupOutcome, +} from "@liveagent/ui/lib/kbrain/sandboxSetup"; import type { KBrainComputerStatus, KBrainSettingsAdapter, @@ -75,6 +79,24 @@ export function getKBrainHealth(): Promise { return send("/v1/health").then(readKBrainHealthResponse); } +/** + * Runs K-brain's Windows sandbox setup (`POST /v1/sandbox/setup`). The backend shows + * a UAC prompt on its own desktop and answers only once the user chose, so there is + * no client timeout; every status, including errors, comes back as an outcome. + */ +export async function setUpKBrainSandbox(): Promise { + const connection = getKBrainRuntimeConnection(); + if (!connection) throw new Error("K-brain backend connection is not ready"); + const response = await fetch(`${connection.baseUrl}/v1/sandbox/setup`, { + method: "POST", + headers: { + Accept: "application/json", + ...(connection.token ? { Authorization: `Bearer ${connection.token}` } : {}), + }, + }); + return readSandboxSetupResponse(response); +} + export const kbrainSettingsAdapter: KBrainSettingsAdapter = { isKbrain: true, runtimeConnection: getKBrainRuntimeConnection, diff --git a/crates/agent-gui/src/agent-ui-adapters/sandboxCapability.ts b/crates/agent-gui/src/agent-ui-adapters/sandboxCapability.ts index 0bd867e1e..dda6b5cfa 100644 --- a/crates/agent-gui/src/agent-ui-adapters/sandboxCapability.ts +++ b/crates/agent-gui/src/agent-ui-adapters/sandboxCapability.ts @@ -1,7 +1,15 @@ -import { runSandboxLacksPathProtection } from "@liveagent/ui/lib/kbrain/health"; +import { + offersRunSandboxSetup, + runSandboxLacksPathProtection, +} from "@liveagent/ui/lib/kbrain/health"; +import type { SandboxSetupOutcome } from "@liveagent/ui/lib/kbrain/sandboxSetup"; import { useEffect, useState } from "react"; import { isKBrainBrowserHost } from "../lib/host"; -import { getKBrainHealth } from "./kbrainSettings"; +import { getKBrainRuntimeConnection } from "../lib/kbrain/runtimeConnection"; +import type { KBrainRunSandboxCapability } from "../lib/kbrain/types"; +import { getKBrainHealth, setUpKBrainSandbox } from "./kbrainSettings"; + +export type { SandboxSetupOutcome }; export type SandboxCapability = { supported: boolean; @@ -17,6 +25,14 @@ export type SandboxCapability = { */ path_protection?: boolean; reason?: string; + /** + * Present (true) when K-brain can run its Windows sandbox setup from here so the + * offline sandbox can cut the network: `sandbox_setup` 1, `setup_available`, no + * network control yet, and a backend on this machine (UAC shows on its desktop). + */ + setup_available?: true; + /** K-brain's short English note about setup (run_sandbox.setup_hint). */ + setup_hint?: string; }; const BACKEND_TOO_OLD = "update K-brain to a version with run sandboxing"; @@ -28,11 +44,14 @@ const BACKEND_TOO_OLD = "update K-brain to a version with run sandboxing"; * instead of silently running commands unconfined. So does a sandbox that leaves * the protected workspace paths writable (path_protection false, e.g. Landlock): * those files are used outside the sandbox, which would let a command escape. + * `setupHost` false (a backend on another machine) never offers sandbox setup. */ -export function sandboxCapabilityFromHealth(health: { - capabilities?: Record; -}): SandboxCapability { - const raw = health.capabilities?.run_sandbox; +export function sandboxCapabilityFromHealth( + health: { capabilities?: Record }, + options: { setupHost?: boolean } = {}, +): SandboxCapability { + const capabilities = health.capabilities ?? {}; + const raw = capabilities.run_sandbox; if (!raw || typeof raw !== "object") { return { supported: false, @@ -42,12 +61,13 @@ export function sandboxCapabilityFromHealth(health: { reason: BACKEND_TOO_OLD, }; } - const value = raw as Record; + const value = raw as KBrainRunSandboxCapability & Record; const text = (key: string) => (typeof value[key] === "string" ? (value[key] as string) : ""); // Only a sandbox the backend can run reports path protection; an unsupported // one keeps the backend's own reason. const lacksPathProtection = runSandboxLacksPathProtection(value); const supported = value.supported === true && !lacksPathProtection; + const setup = options.setupHost !== false && offersRunSandboxSetup({ capabilities }); return { supported, mechanism: text("mechanism"), @@ -60,34 +80,104 @@ export function sandboxCapabilityFromHealth(health: { ? { path_protection: value.path_protection } : {}), ...(text("reason") ? { reason: text("reason") } : {}), + ...(setup ? { setup_available: true as const } : {}), + ...(setup && text("setup_hint").trim() ? { setup_hint: text("setup_hint").trim() } : {}), }; } +const LOOPBACK_HOSTS: ReadonlySet = new Set(["127.0.0.1", "localhost", "[::1]", "::1"]); + +/** Setup's UAC prompt appears where K-brain runs, so only a local backend offers it. */ +function backendIsLocal(): boolean { + const connection = getKBrainRuntimeConnection(); + if (!connection) return false; + if (connection.managedLocal) return true; + try { + return LOOPBACK_HOSTS.has(new URL(connection.baseUrl).hostname); + } catch { + return false; + } +} + let cachedCapability: SandboxCapability | null = null; let pending: Promise | null = null; +// Each read takes a number; an older read finishing late never overwrites a newer one. +let readSequence = 0; +const listeners = new Set<(capability: SandboxCapability) => void>(); + +function readCapability(): Promise { + const sequence = ++readSequence; + const read = getKBrainHealth() + .then((health) => sandboxCapabilityFromHealth(health, { setupHost: backendIsLocal() })) + .then((result) => { + if (sequence === readSequence) { + cachedCapability = result; + for (const listener of listeners) listener(result); + } + return result; + }); + pending = read; + // A failed read is not cached; the next mount or refresh asks again. + read.catch(() => { + if (pending === read) pending = null; + }); + return read; +} + +/** + * Reads the capability again (after sandbox setup bumps capabilityRevision) and + * updates every mounted selector. + */ +export function refreshSandboxCapability(): Promise { + return readCapability(); +} /** Desktop: the backend's run sandbox; null while the first health read is pending. */ export function useSandboxCapability(): SandboxCapability | null { const [capability, setCapability] = useState(cachedCapability); useEffect(() => { - if (isKBrainBrowserHost() || cachedCapability) return; - let disposed = false; - pending ??= getKBrainHealth().then(sandboxCapabilityFromHealth); - pending - .then((result) => { - cachedCapability = result; - if (!disposed) setCapability(result); - }) - .catch((error) => { - // A failed read is not cached; the next mount asks again. - pending = null; + if (isKBrainBrowserHost()) return; + listeners.add(setCapability); + if (cachedCapability) setCapability(cachedCapability); + else + (pending ?? readCapability()).catch((error) => { console.warn("K-brain sandbox capability unavailable", error); }); return () => { - disposed = true; + listeners.delete(setCapability); }; }, []); return capability; } + +/** + * Runs K-brain's Windows sandbox setup and then reads the capability again, so the + * offline sandbox becomes selectable once it works. The re-read follows every + * attempt the backend may have acted on (a dropped connection included), and its + * answer decides whether a finished setup really left the offline sandbox usable. + */ +export async function setUpOfflineSandbox(): Promise { + if (isKBrainBrowserHost()) return { status: "unsupported" }; + let outcome: SandboxSetupOutcome; + try { + outcome = await setUpKBrainSandbox(); + } catch (error) { + outcome = { status: "error", message: error instanceof Error ? error.message : String(error) }; + } + if (outcome.status === "unsupported") return outcome; + let capability: SandboxCapability; + try { + capability = await refreshSandboxCapability(); + } catch (error) { + console.warn("K-brain sandbox capability unavailable after setup", error); + return outcome; + } + if (outcome.status === "ready" || outcome.status === "incomplete") { + if (capability.network_control) return { status: "ready" }; + const hint = capability.setup_hint ?? (outcome.status === "incomplete" ? outcome.hint : ""); + return hint ? { status: "incomplete", hint } : { status: "incomplete" }; + } + return outcome; +} diff --git a/crates/agent-gui/src/lib/kbrain/types.ts b/crates/agent-gui/src/lib/kbrain/types.ts index db9c5db0a..3dc3d9909 100644 --- a/crates/agent-gui/src/lib/kbrain/types.ts +++ b/crates/agent-gui/src/lib/kbrain/types.ts @@ -341,6 +341,26 @@ export type KBrainRunOptions = { client_tools?: KBrainClientToolDefinition[]; }; +/** `capabilities.run_sandbox` in /v1/health; every field may be absent on older backends. */ +export type KBrainRunSandboxCapability = { + supported?: boolean; + version?: string; + mechanism?: string; + platform?: string; + network_control?: boolean; + read_masking?: boolean; + path_protection?: boolean; + modes?: string[]; + reason?: string; + /** + * Windows (capability `sandbox_setup` 1): `POST /v1/sandbox/setup` can install the + * sandbox accounts that let the offline sandbox cut the network. + */ + setup_available?: boolean; + /** Short English text for the user about setup; empty once `sandbox-user` works. */ + setup_hint?: string; +}; + export type KBrainClientToolDefinition = { name: string; description: string; diff --git a/crates/agent-gui/test/chat/command-safety-mode-selector.test.mjs b/crates/agent-gui/test/chat/command-safety-mode-selector.test.mjs index 0a39bb0a8..1f08e4c3e 100644 --- a/crates/agent-gui/test/chat/command-safety-mode-selector.test.mjs +++ b/crates/agent-gui/test/chat/command-safety-mode-selector.test.mjs @@ -26,6 +26,8 @@ const models = [ ]; const state = (aiApproval) => ({ aiApproval, models }); let loadCalls = 0; +// The desktop adapter's sandbox setup call; each test decides how it answers. +let runSetup = async () => ({ status: "unsupported" }); const env = await createDomTestEnv({ mocks: { @@ -34,7 +36,10 @@ const env = await createDomTestEnv({ { get: (_target, name) => (name === "__esModule" ? true : iconComponent(String(name))) }, ), "@liveagent/ui/i18n/index": { useLocale: () => ({ t: (key) => key }) }, - "@liveagent/adapters/sandboxCapability": { useSandboxCapability: () => sandboxCapability }, + "@liveagent/adapters/sandboxCapability": { + useSandboxCapability: () => sandboxCapability, + setUpOfflineSandbox: () => runSetup(), + }, "@liveagent/adapters/aiApprovalReadiness": { loadAIApprovalState: () => { loadCalls += 1; @@ -279,3 +284,170 @@ test("the reviewer configuration is read again each time the menu opens", async await view.close(); } }); + +const WINDOWS_HINT = "Run sandbox setup to enable offline sandboxing and workspace isolation"; +const windowsLowIntegrity = { + supported: true, + mechanism: "low-integrity", + platform: "windows", + network_control: false, + read_masking: true, + path_protection: true, + setup_available: true, + setup_hint: WINDOWS_HINT, +}; +const setupItem = (view) => + view.items().find((item) => item.textContent.includes("chat.safety.setupOffline")); +const dialog = () => document.querySelector('[data-testid="offline-sandbox-setup-dialog"]'); +const dialogButton = (label) => + [...(dialog()?.querySelectorAll("button") ?? [])].find((button) => button.textContent.trim() === label); + +test("the offline sandbox offers setup only when K-brain can run it and cannot cut the network yet", async () => { + loadConfig = async () => null; + try { + sandboxCapability = windowsLowIntegrity; + let view = await mount({ value: "ask" }); + try { + await view.open(); + const labels = view.items().map((item) => item.querySelector("span span")?.textContent); + assert.equal(labels[labels.indexOf("chat.safety.sandboxOffline") + 1], "chat.safety.setupOffline"); + const offline = view.items()[labels.indexOf("chat.safety.sandboxOffline")]; + assert.notEqual(offline.getAttribute("data-disabled"), null, "offline stays disabled until setup"); + assert.equal(setupItem(view).getAttribute("data-disabled"), null); + assert.equal(setupItem(view).getAttribute("title"), WINDOWS_HINT); + assert.match(setupItem(view).textContent, /chat\.safety\.setupOfflineDesc/); + } finally { + await view.close(); + } + + for (const capability of [ + // After setup, or a platform that cuts the network anyway. + { ...windowsLowIntegrity, mechanism: "sandbox-user", network_control: true }, + // Older K-brain, the WebUI (no capability) and a remote backend report no setup. + { ...windowsLowIntegrity, setup_available: undefined, setup_hint: undefined }, + null, + // Protected paths left writable stay unavailable; setup is not offered as a fix. + { ...windowsLowIntegrity, path_protection: false }, + ]) { + sandboxCapability = capability; + view = await mount({ value: "ask" }); + try { + await view.open(); + assert.equal(setupItem(view), undefined, JSON.stringify(capability)); + } finally { + await view.close(); + } + } + } finally { + sandboxCapability = null; + } +}); + +async function openSetupDialog(view) { + await view.open(); + await env.act(async () => setupItem(view).click()); + await env.act(async () => {}); + assert.ok(dialog(), "choosing setup opens the confirmation dialog"); +} + +test("setup asks for confirmation, waits on the administrator prompt and enables offline on success", async () => { + loadConfig = async () => null; + sandboxCapability = windowsLowIntegrity; + let finish; + let setupCalls = 0; + runSetup = () => { + setupCalls += 1; + return new Promise((resolve) => { + finish = resolve; + }); + }; + const view = await mount({ value: "ask" }); + try { + await openSetupDialog(view); + const text = dialog().textContent; + for (const key of [ + "chat.safety.setup.title", + "chat.safety.setup.intro", + "chat.safety.setup.accounts", + "chat.safety.setup.firewall", + "chat.safety.setup.admin", + // Reversible from a terminal: kn sandbox uninstall (filled into this message). + "chat.safety.setup.undo", + ]) { + assert.ok(text.includes(key), `the confirmation explains ${key}`); + } + assert.ok(text.includes("chat.safety.setup.backendNote"), "K-brain's hint is shown"); + assert.equal(setupCalls, 0, "nothing runs before the user confirms"); + + await env.act(async () => dialogButton("chat.safety.setup.confirm").click()); + assert.equal(setupCalls, 1); + assert.match(dialog().textContent, /chat\.safety\.setup\.running/); + assert.match(dialog().textContent, /chat\.safety\.setup\.runningDesc/); + assert.equal(dialogButton("chat.safety.setup.close"), undefined, "no way to dismiss mid-prompt"); + assert.equal(dialogButton("chat.safety.setup.cancel"), undefined); + + sandboxCapability = { ...windowsLowIntegrity, mechanism: "sandbox-user", network_control: true }; + await env.act(async () => finish({ status: "ready" })); + assert.match(dialog().textContent, /chat\.safety\.setup\.readyDesc/); + await env.act(async () => dialogButton("chat.safety.setup.useOffline").click()); + assert.deepEqual(view.calls.change, ["sandboxOffline"]); + } finally { + await view.close(); + sandboxCapability = null; + runSetup = async () => ({ status: "unsupported" }); + } +}); + +test("every setup outcome has its own message and failures can be retried", async () => { + loadConfig = async () => null; + sandboxCapability = windowsLowIntegrity; + const cases = [ + [{ status: "declined" }, ["chat.safety.setup.declined", "chat.safety.setup.declinedDesc"], true], + [{ status: "busy" }, ["chat.safety.setup.busy", "chat.safety.setup.busyDesc"], true], + [ + { status: "failed", message: "creating account KBrainSbx-1a2b3c-Off: access denied" }, + ["chat.safety.setup.failed", "chat.safety.setup.failedDesc", "creating account KBrainSbx-1a2b3c-Off: access denied"], + true, + ], + [{ status: "unsupported" }, ["chat.safety.setup.unsupported", "chat.safety.setup.unsupportedDesc"], false], + [ + { status: "incomplete", hint: "Sandbox self-test failed; rerun setup" }, + ["chat.safety.setup.incomplete", "chat.safety.setup.incompleteDesc", "Sandbox self-test failed; rerun setup"], + true, + ], + [{ status: "error", message: "Failed to fetch" }, ["chat.safety.setup.error", "Failed to fetch"], true], + ]; + try { + for (const [outcome, expected, retry] of cases) { + let calls = 0; + runSetup = async () => { + calls += 1; + return outcome; + }; + const view = await mount({ value: "ask" }); + try { + await openSetupDialog(view); + await env.act(async () => dialogButton("chat.safety.setup.confirm").click()); + await env.act(async () => {}); + for (const piece of expected) { + assert.ok(dialog().textContent.includes(piece), `${outcome.status} shows ${piece}`); + } + assert.equal(dialog().textContent.includes("chat.safety.setup.useOffline"), false); + assert.ok(dialogButton("chat.safety.setup.close"), `${outcome.status} can be closed`); + const again = dialogButton("chat.safety.setup.retry"); + assert.equal(Boolean(again), retry, `${outcome.status} retry`); + if (again) { + await env.act(async () => again.click()); + await env.act(async () => {}); + assert.equal(calls, 2); + } + assert.deepEqual(view.calls.change, [], "a failed setup never changes the mode"); + } finally { + await view.close(); + } + } + } finally { + sandboxCapability = null; + runSetup = async () => ({ status: "unsupported" }); + } +}); diff --git a/crates/agent-gui/test/chat/offline-sandbox-setup.test.mjs b/crates/agent-gui/test/chat/offline-sandbox-setup.test.mjs new file mode 100644 index 000000000..6efa60d8b --- /dev/null +++ b/crates/agent-gui/test/chat/offline-sandbox-setup.test.mjs @@ -0,0 +1,241 @@ +// Windows offline sandbox setup (K-brain capability sandbox_setup, POST /v1/sandbox/setup): +// what the desktop reads from health, how each response status is reported, and that a +// finished setup refreshes the capability every mounted selector sees. +import assert from "node:assert/strict"; +import test from "node:test"; +import { createDomTestEnv } from "../helpers/dom-test-env.mjs"; + +const env = await createDomTestEnv(); +window.__TAURI_INTERNALS__ = {}; +const { sandboxCapabilityFromHealth, setUpOfflineSandbox, useSandboxCapability } = env.loadModule( + "src/agent-ui-adapters/sandboxCapability", +); +const { offersRunSandboxSetup, supportsRunSandbox } = env.loadModule( + "@liveagent/ui/lib/kbrain/health", +); +const { sandboxSetupOutcome } = env.loadModule("@liveagent/ui/lib/kbrain/sandboxSetup"); +const { setKBrainRuntimeConnection } = env.loadModule("src/lib/kbrain/runtimeConnection"); + +const HINT = "Run sandbox setup to enable offline sandboxing and workspace isolation"; +const lowIntegrity = { + supported: true, + mechanism: "low-integrity", + platform: "windows", + network_control: false, + read_masking: true, + path_protection: true, + setup_available: true, + setup_hint: HINT, +}; +const sandboxUser = { + ...lowIntegrity, + mechanism: "sandbox-user", + network_control: true, + setup_hint: "", +}; + +test("the desktop offers setup only when K-brain can run it and the network cannot be cut yet", () => { + const windows = sandboxCapabilityFromHealth({ + capabilities: { sandbox_setup: 1, run_sandbox: lowIntegrity }, + }); + assert.deepEqual(windows, { + supported: true, + mechanism: "low-integrity", + platform: "windows", + network_control: false, + read_masking: true, + path_protection: true, + setup_available: true, + setup_hint: HINT, + }); + + // A backend that predates sandbox_setup, or reports the fields without it, offers nothing. + for (const capabilities of [ + { run_sandbox: lowIntegrity }, + { sandbox_setup: 2, run_sandbox: lowIntegrity }, + { sandbox_setup: 1, run_sandbox: { ...lowIntegrity, setup_available: false } }, + { sandbox_setup: 1, run_sandbox: { ...lowIntegrity, setup_available: undefined } }, + // After setup: sandbox-user cuts the network. + { sandbox_setup: 1, run_sandbox: sandboxUser }, + // Protected paths left writable stay unavailable (#1235); setup is no fix for that. + { sandbox_setup: 1, run_sandbox: { ...lowIntegrity, path_protection: false } }, + // Old K-brain without run_sandbox at all. + { sandbox_setup: 1 }, + ]) { + const capability = sandboxCapabilityFromHealth({ capabilities }); + assert.equal("setup_available" in capability, false, JSON.stringify(capabilities)); + assert.equal("setup_hint" in capability, false, JSON.stringify(capabilities)); + assert.equal(offersRunSandboxSetup({ capabilities }), false, JSON.stringify(capabilities)); + } + const unprotected = sandboxCapabilityFromHealth({ + capabilities: { sandbox_setup: 1, run_sandbox: { ...lowIntegrity, path_protection: false } }, + }); + assert.equal(unprotected.supported, false); + assert.equal(unprotected.path_protection, false); + + // A sandbox the backend cannot run yet may still be fixed by setup. + assert.equal( + offersRunSandboxSetup({ + capabilities: { sandbox_setup: 1, run_sandbox: { ...lowIntegrity, supported: false } }, + }), + true, + ); + // The UAC prompt appears where K-brain runs: a remote backend never offers it. + const remote = sandboxCapabilityFromHealth( + { capabilities: { sandbox_setup: 1, run_sandbox: lowIntegrity } }, + { setupHost: false }, + ); + assert.equal("setup_available" in remote, false); + // An empty hint is left out. + const noHint = sandboxCapabilityFromHealth({ + capabilities: { sandbox_setup: 1, run_sandbox: { ...lowIntegrity, setup_hint: " " } }, + }); + assert.equal(noHint.setup_available, true); + assert.equal("setup_hint" in noHint, false); + + // The run gate is unchanged: offline needs network control, path protection still counts. + assert.equal(supportsRunSandbox({ capabilities: { run_sandbox: lowIntegrity } }, { network: false }), false); + assert.equal(supportsRunSandbox({ capabilities: { run_sandbox: sandboxUser } }, { network: false }), true); +}); + +test("each setup response status maps to its own outcome", () => { + assert.deepEqual( + sandboxSetupOutcome(200, { version: "kbrain.agent.v1", status: { installed: true, active: true }, run_sandbox: sandboxUser }), + { status: "ready" }, + ); + // Installed but the self-test still fails: K-brain's hint says why. + assert.deepEqual( + sandboxSetupOutcome(200, { + status: { installed: true, active: false, problem: "offline account reached loopback" }, + run_sandbox: { ...lowIntegrity, setup_hint: "Sandbox self-test failed; rerun setup" }, + }), + { status: "incomplete", hint: "Sandbox self-test failed; rerun setup" }, + ); + assert.deepEqual( + sandboxSetupOutcome(200, { status: { problem: "offline account reached loopback" }, run_sandbox: { ...lowIntegrity, setup_hint: "" } }), + { status: "incomplete", hint: "offline account reached loopback" }, + ); + assert.deepEqual( + sandboxSetupOutcome(403, { code: "declined", error: "administrator approval was declined" }), + { status: "declined" }, + ); + assert.deepEqual(sandboxSetupOutcome(409, { code: "busy", error: "sandbox setup is already running" }), { + status: "busy", + }); + assert.deepEqual( + sandboxSetupOutcome(500, { code: "failed", error: "creating account KBrainSbx-1a2b3c-Off: access denied" }), + { status: "failed", message: "creating account KBrainSbx-1a2b3c-Off: access denied" }, + ); + assert.deepEqual(sandboxSetupOutcome(500, "setup crashed\n"), { status: "failed", message: "setup crashed" }); + assert.deepEqual(sandboxSetupOutcome(500, {}), { status: "failed" }); + assert.deepEqual(sandboxSetupOutcome(501, { code: "unsupported", error: "sandbox setup is only available on Windows" }), { + status: "unsupported", + }); + assert.deepEqual(sandboxSetupOutcome(405, ""), { status: "error", message: "K-brain request failed (405)" }); + assert.deepEqual(sandboxSetupOutcome(401, { error: "unauthorized" }), { status: "error", message: "unauthorized" }); +}); + +function backend(setupStatus, setupBody, afterSetup) { + const calls = []; + let runSandbox = lowIntegrity; + let revision = 4; + globalThis.fetch = async (url, init = {}) => { + const path = new URL(url).pathname; + calls.push({ path, method: init.method ?? "GET", auth: init.headers?.Authorization }); + if (path === "/v1/sandbox/setup") { + if (setupStatus !== 501) { + revision += 1; + if (afterSetup) runSandbox = afterSetup; + } + if (setupStatus === "drop") throw new TypeError("Failed to fetch"); + return new Response(JSON.stringify(setupBody), { status: setupStatus }); + } + if (path === "/v1/health") { + return new Response( + JSON.stringify({ + status: "ok", + version: "kbrain.agent.v1", + protocol: { name: "kbrain.agent", major: 1, minor: 3 }, + capabilityRevision: revision, + capabilities: { sandbox_setup: 1, run_sandbox: runSandbox }, + }), + { status: 200 }, + ); + } + return new Response("not found", { status: 404 }); + }; + return calls; +} + +async function mountProbe() { + const seen = []; + function Probe() { + seen.push(useSandboxCapability()); + return null; + } + const container = document.body.appendChild(document.createElement("div")); + const root = env.createRoot(container); + await env.act(async () => root.render(env.React.createElement(Probe))); + await env.act(async () => {}); + return { + latest: () => seen.at(-1), + close: async () => { + await env.act(async () => root.unmount()); + container.remove(); + }, + }; +} + +test("a successful setup re-reads health and the offline sandbox becomes selectable", async () => { + setKBrainRuntimeConnection({ baseUrl: "http://127.0.0.1:47321", token: "secret", protocolVersion: "kbrain.agent.v1" }); + const calls = backend(200, { version: "kbrain.agent.v1", status: { installed: true }, run_sandbox: sandboxUser }, sandboxUser); + const probe = await mountProbe(); + try { + assert.equal(probe.latest().setup_available, true); + assert.equal(probe.latest().network_control, false); + let outcome; + await env.act(async () => { + outcome = await setUpOfflineSandbox(); + }); + assert.deepEqual(outcome, { status: "ready" }); + const setup = calls.find((call) => call.path === "/v1/sandbox/setup"); + assert.equal(setup.method, "POST"); + assert.equal(setup.auth, "Bearer secret"); + assert.equal(calls.at(-1).path, "/v1/health", "health is read again after setup"); + assert.equal(probe.latest().network_control, true); + assert.equal(probe.latest().mechanism, "sandbox-user"); + assert.equal("setup_available" in probe.latest(), false, "the setup action goes away"); + } finally { + await probe.close(); + } +}); + +test("declined, busy and failed setups still re-read health; unsupported does not", async () => { + for (const [status, body, expected, rereads] of [ + [403, { code: "declined", error: "administrator approval was declined" }, { status: "declined" }, true], + [409, { code: "busy", error: "sandbox setup is already running" }, { status: "busy" }, true], + [500, { code: "failed", error: "WFP filter add failed" }, { status: "failed", message: "WFP filter add failed" }, true], + [501, { code: "unsupported", error: "not windows" }, { status: "unsupported" }, false], + ["drop", null, { status: "error", message: "Failed to fetch" }, true], + ]) { + const calls = backend(status, body); + const outcome = await setUpOfflineSandbox(); + assert.deepEqual(outcome, expected, String(status)); + const after = calls.slice(calls.findIndex((call) => call.path === "/v1/sandbox/setup") + 1); + assert.equal(after.some((call) => call.path === "/v1/health"), rereads, String(status)); + } +}); + +test("a finished setup whose self-test still fails reports the refreshed hint", async () => { + const stillLow = { ...lowIntegrity, setup_hint: "Sandbox self-test failed: offline account reached loopback; rerun setup" }; + backend(200, { status: { installed: true, active: false }, run_sandbox: stillLow }, stillLow); + assert.deepEqual(await setUpOfflineSandbox(), { status: "incomplete", hint: stillLow.setup_hint }); +}); + +test("a remote K-brain never offers setup", async () => { + setKBrainRuntimeConnection({ baseUrl: "http://192.168.1.20:47321", token: "t", protocolVersion: "kbrain.agent.v1" }); + backend(200, {}); + const { refreshSandboxCapability } = env.loadModule("src/agent-ui-adapters/sandboxCapability"); + const capability = await refreshSandboxCapability(); + assert.equal("setup_available" in capability, false); +}); diff --git a/crates/agent-ui/src/components/chat/CommandSafetyModeSelector.tsx b/crates/agent-ui/src/components/chat/CommandSafetyModeSelector.tsx index 98bd45ee8..97f9c77f3 100644 --- a/crates/agent-ui/src/components/chat/CommandSafetyModeSelector.tsx +++ b/crates/agent-ui/src/components/chat/CommandSafetyModeSelector.tsx @@ -1,5 +1,5 @@ import { loadAIApprovalState } from "@liveagent/adapters/aiApprovalReadiness"; -import { useSandboxCapability } from "@liveagent/adapters/sandboxCapability"; +import { setUpOfflineSandbox, useSandboxCapability } from "@liveagent/adapters/sandboxCapability"; import type { CommandSafetyMode } from "@liveagent/app/lib/settings"; import { Check, @@ -10,6 +10,7 @@ import { ShieldCheck, ShieldOff, SquarePen, + Wrench, Zap, } from "@liveagent/ui/components/IconSet"; import { Button } from "@liveagent/ui/components/ui/button"; @@ -24,6 +25,7 @@ import { COMPOSER_CONTROL_TRIGGER_CLASS } from "@liveagent/ui/lib/chat/composerC import { useAIApprovalReviewerConfigured } from "@liveagent/ui/lib/settings/aiApprovalReadiness"; import { cn } from "@liveagent/ui/lib/shared/utils"; import { useState } from "react"; +import { OfflineSandboxSetupDialog } from "./OfflineSandboxSetupDialog"; const MODE_I18N_KEYS: Record = { ask: "chat.safety.ask", @@ -127,6 +129,9 @@ export function CommandSafetyModeSelector(props: { if (open) setMenuOpenings((count) => count + 1); }; const capability = useSandboxCapability(); + // Each opening remounts the setup dialog so it starts at the confirmation. + const [setupOpenings, setSetupOpenings] = useState(0); + const [setupOpen, setSetupOpen] = useState(false); // 写围栏(sandbox):平台不支持时禁用。桌面端探测返回前(null)乐观启用,由执行层 // fail-closed 兜底;WebUI 执行端平台未知,同样交由 fail-closed。 // 受保护路径(.liveagent、.git/hooks 等)在沙箱内仍可写(path_protection=false, @@ -179,88 +184,130 @@ export function CommandSafetyModeSelector(props: { ? ` · ${t("chat.safety.autoReviewNeedsModel")}` : ""); const modes: readonly CommandSafetyMode[] = SAFETY_MODES; + // Windows without the sandbox accounts: K-brain can install them (UAC) so the + // offline sandbox can cut the network. Only the desktop adapter reports it, and + // only for a backend on this machine; a sandbox without path protection stays + // unavailable whatever setup would do. + const setupOffered = + capability?.setup_available === true && + !capability.network_control && + !sandboxLacksPathProtection; return ( - - - } - title={shortcutHint ? `${selectedLabel} (${shortcutHint})` : selectedLabel} - aria-label={`${t("chat.safety.label")}: ${selectedLabel}`} - > - {modeIcon(selected, triggerIconClass(selected, reviewerMissing))} - - - {modes.map((mode) => { - const entryDisabled = - mode === "sandbox" - ? sandboxUnavailable - : mode === "sandboxOffline" - ? offlineUnavailable - : false; - const isSelected = mode === selected; - const needsReviewer = mode === "autoReview" && reviewerMissing; - const item = ( - onChange(mode)} + <> + + - {modeIcon(mode, "mt-0.5 size-3.5 shrink-0 text-muted-foreground")} - - {t(MODE_I18N_KEYS[mode])} - - {entryDisabled - ? disabledHint - : needsReviewer - ? t("chat.safety.autoReviewNeedsModel") - : t(modeDescKey(mode))} + /> + } + title={shortcutHint ? `${selectedLabel} (${shortcutHint})` : selectedLabel} + aria-label={`${t("chat.safety.label")}: ${selectedLabel}`} + > + {modeIcon(selected, triggerIconClass(selected, reviewerMissing))} + + + {modes.map((mode) => { + const entryDisabled = + mode === "sandbox" + ? sandboxUnavailable + : mode === "sandboxOffline" + ? offlineUnavailable + : false; + const isSelected = mode === selected; + const needsReviewer = mode === "autoReview" && reviewerMissing; + const item = ( + onChange(mode)} + className={cn( + "h-auto items-start gap-2.5 py-2 text-xs whitespace-normal", + isSelected && "bg-accent text-accent-foreground", + )} + > + {modeIcon(mode, "mt-0.5 size-3.5 shrink-0 text-muted-foreground")} + + {t(MODE_I18N_KEYS[mode])} + + {entryDisabled + ? disabledHint + : needsReviewer + ? t("chat.safety.autoReviewNeedsModel") + : t(modeDescKey(mode))} + - - {isSelected ? ( - - ) : ( - - )} - - ); - if (!needsReviewer || !onConfigureAutoReview) return item; - return [ - item, - - - {t("chat.safety.configureReviewer")} - , - ]; - })} - - + {isSelected ? ( + + ) : ( + + )} + + ); + if (mode === "sandboxOffline" && entryDisabled && setupOffered) { + return [ + item, + { + setSetupOpenings((count) => count + 1); + setSetupOpen(true); + }} + title={capability?.setup_hint || undefined} + className="h-auto items-start gap-2.5 py-1.5 pl-8 text-xs whitespace-normal" + > + + + {t("chat.safety.setupOffline")} + + {t("chat.safety.setupOfflineDesc")} + + + , + ]; + } + if (!needsReviewer || !onConfigureAutoReview) return item; + return [ + item, + + + {t("chat.safety.configureReviewer")} + , + ]; + })} + + + {setupOpenings > 0 ? ( + onChange("sandboxOffline")} + /> + ) : null} + ); } diff --git a/crates/agent-ui/src/components/chat/OfflineSandboxSetupDialog.tsx b/crates/agent-ui/src/components/chat/OfflineSandboxSetupDialog.tsx new file mode 100644 index 000000000..ea7490825 --- /dev/null +++ b/crates/agent-ui/src/components/chat/OfflineSandboxSetupDialog.tsx @@ -0,0 +1,224 @@ +import { Loader2 } from "@liveagent/ui/components/IconSet"; +import { + AlertDialog, + AlertDialogActions, + AlertDialogBody, + AlertDialogClose, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@liveagent/ui/components/ui/alert-dialog"; +import { Button } from "@liveagent/ui/components/ui/button"; +import { useLocale } from "@liveagent/ui/i18n/index"; +import type { SandboxSetupOutcome } from "@liveagent/ui/lib/kbrain/sandboxSetup"; +import { type ReactNode, useEffect, useRef, useState } from "react"; + +const UNINSTALL_COMMAND = "kn sandbox uninstall"; + +type Phase = + | { kind: "confirm" } + | { kind: "running" } + | { kind: "done"; outcome: SandboxSetupOutcome }; + +/** Title and explanation for each setup outcome; detail is K-brain's own text. */ +export function sandboxSetupOutcomeMessage( + outcome: SandboxSetupOutcome, + t: (key: string) => string, +): { title: string; description: string; detail?: string } { + const withCommand = (key: string) => t(key).replace("{command}", UNINSTALL_COMMAND); + switch (outcome.status) { + case "ready": + return { title: t("chat.safety.setup.ready"), description: t("chat.safety.setup.readyDesc") }; + case "incomplete": + return { + title: t("chat.safety.setup.incomplete"), + description: t("chat.safety.setup.incompleteDesc"), + ...(outcome.hint ? { detail: outcome.hint } : {}), + }; + case "declined": + return { + title: t("chat.safety.setup.declined"), + description: t("chat.safety.setup.declinedDesc"), + }; + case "busy": + return { title: t("chat.safety.setup.busy"), description: t("chat.safety.setup.busyDesc") }; + case "failed": + return { + title: t("chat.safety.setup.failed"), + description: withCommand("chat.safety.setup.failedDesc"), + ...(outcome.message ? { detail: outcome.message } : {}), + }; + case "unsupported": + return { + title: t("chat.safety.setup.unsupported"), + description: t("chat.safety.setup.unsupportedDesc"), + }; + default: + return { title: t("chat.safety.setup.error"), description: "", detail: outcome.message }; + } +} + +function Detail(props: { children: ReactNode }) { + return ( +
+ {props.children} +
+ ); +} + +/** + * Confirms, runs and reports K-brain's Windows sandbox setup. The request waits on + * a UAC prompt, so the dialog cannot be dismissed while it runs. Give it a new key + * for each opening so it starts again at the confirmation. + */ +export function OfflineSandboxSetupDialog(props: { + open: boolean; + /** K-brain's run_sandbox.setup_hint, shown before the user decides. */ + hint?: string; + onOpenChange: (open: boolean) => void; + onRun: () => Promise; + /** Selects the offline sandbox after a successful setup. */ + onUseOffline: () => void; +}) { + const { open, hint, onOpenChange, onRun, onUseOffline } = props; + const { t } = useLocale(); + const [phase, setPhase] = useState({ kind: "confirm" }); + const mounted = useRef(true); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + + const running = phase.kind === "running"; + const run = async () => { + setPhase({ kind: "running" }); + let outcome: SandboxSetupOutcome; + try { + outcome = await onRun(); + } catch (error) { + outcome = { + status: "error", + message: error instanceof Error ? error.message : String(error), + }; + } + if (mounted.current) setPhase({ kind: "done", outcome }); + }; + + const result = phase.kind === "done" ? sandboxSetupOutcomeMessage(phase.outcome, t) : null; + const ready = phase.kind === "done" && phase.outcome.status === "ready"; + const canRetry = phase.kind === "done" && !ready && phase.outcome.status !== "unsupported"; + + const close = ( + }> + {phase.kind === "confirm" ? t("chat.safety.setup.cancel") : t("chat.safety.setup.close")} + + ); + + return ( + { + if (!next && running) return; + onOpenChange(next); + }} + > + + + + {phase.kind === "confirm" + ? t("chat.safety.setup.title") + : running + ? t("chat.safety.setup.running") + : result?.title} + + + + }> + {phase.kind === "confirm" ? ( + <> +

+ {t("chat.safety.setup.intro")} +

+
    +
  • {t("chat.safety.setup.accounts")}
  • +
  • {t("chat.safety.setup.firewall")}
  • +
  • {t("chat.safety.setup.admin")}
  • +
+

+ {t("chat.safety.setup.undo") + .split("{command}") + .flatMap((part, index) => + index === 0 + ? [part] + : [ + + {UNINSTALL_COMMAND} + , + part, + ], + )} +

+ {hint ? ( + {t("chat.safety.setup.backendNote").replace("{hint}", hint)} + ) : null} + + ) : running ? ( +
+ + {t("chat.safety.setup.runningDesc")} +
+ ) : result ? ( + <> + {result.description ? ( +

{result.description}

+ ) : null} + {result.detail ? {result.detail} : null} + + ) : null} +
+
+ + + {running ? ( + + ) : phase.kind === "confirm" ? ( + <> + {close} + + + ) : ( + <> + {close} + {ready ? ( + + ) : canRetry ? ( + + ) : null} + + )} + + +
+
+ ); +} diff --git a/crates/agent-ui/src/i18n/translations/enUSCommon.ts b/crates/agent-ui/src/i18n/translations/enUSCommon.ts index b06edf1b2..6ce8a0974 100644 --- a/crates/agent-ui/src/i18n/translations/enUSCommon.ts +++ b/crates/agent-ui/src/i18n/translations/enUSCommon.ts @@ -584,6 +584,43 @@ export const EN_US_COMMON_TRANSLATIONS = { "protected paths (.liveagent, .git/hooks) can't be kept read-only with the Landlock fallback; install bubblewrap or allow unprivileged user namespaces", "chat.safety.sandboxOfflineUnavailable": "K-brain cannot cut the sandbox off from the network on this machine", + // Windows: K-brain's sandbox setup (POST /v1/sandbox/setup) enables the offline sandbox. + "chat.safety.setupOffline": "Set up offline sandbox…", + "chat.safety.setupOfflineDesc": "Needs Windows administrator approval", + "chat.safety.setup.title": "Set up the offline sandbox?", + "chat.safety.setup.intro": + "To run sandboxed commands with no network access, K-brain will make these changes to this computer:", + "chat.safety.setup.accounts": + "Create two hidden local Windows accounts that sandboxed commands run as: one with network access, one without.", + "chat.safety.setup.firewall": + "Add Windows firewall rules that block all network access for the offline account, including services on this computer.", + "chat.safety.setup.admin": "Windows will ask you for administrator approval.", + "chat.safety.setup.undo": "You can undo all of it later by running {command} in a terminal.", + "chat.safety.setup.backendNote": "K-brain: {hint}", + "chat.safety.setup.confirm": "Set up", + "chat.safety.setup.cancel": "Cancel", + "chat.safety.setup.close": "Close", + "chat.safety.setup.retry": "Try again", + "chat.safety.setup.running": "Waiting for administrator approval…", + "chat.safety.setup.runningDesc": + "Approve the Windows prompt to continue. Setup takes a moment after you approve; keep LiveAgent open.", + "chat.safety.setup.ready": "The offline sandbox is ready", + "chat.safety.setup.readyDesc": "Commands in the offline sandbox now run with no network access.", + "chat.safety.setup.useOffline": "Use offline sandbox", + "chat.safety.setup.incomplete": "Setup finished, but the offline sandbox is still unavailable", + "chat.safety.setup.incompleteDesc": + "K-brain's sandbox self-test did not pass. Run setup again to repair it.", + "chat.safety.setup.declined": "Administrator approval was declined", + "chat.safety.setup.declinedDesc": "Nothing on this computer was changed.", + "chat.safety.setup.busy": "Sandbox setup is already running", + "chat.safety.setup.busyDesc": + "Another sandbox setup is in progress. Answer the open Windows prompt, then try again.", + "chat.safety.setup.failed": "Sandbox setup failed", + "chat.safety.setup.failedDesc": + "Running setup again repairs a partial install; {command} removes it.", + "chat.safety.setup.unsupported": "Sandbox setup is not available", + "chat.safety.setup.unsupportedDesc": "K-brain can set up the offline sandbox only on Windows.", + "chat.safety.setup.error": "Could not run sandbox setup", "chat.safety.cycleShortcut": "{keys} to switch", "chat.safety.cycleNextTurn": "next turn", "chat.safety.cycleNewMessages": "new messages", diff --git a/crates/agent-ui/src/i18n/translations/zhCNCommon.ts b/crates/agent-ui/src/i18n/translations/zhCNCommon.ts index 7370fe62a..12d58f12a 100644 --- a/crates/agent-ui/src/i18n/translations/zhCNCommon.ts +++ b/crates/agent-ui/src/i18n/translations/zhCNCommon.ts @@ -541,6 +541,39 @@ export const ZH_CN_COMMON_TRANSLATIONS = { "chat.safety.sandboxNoPathProtection": "Landlock 回退方案无法让受保护路径(.liveagent、.git/hooks)保持只读;请安装 bubblewrap 或允许非特权用户命名空间", "chat.safety.sandboxOfflineUnavailable": "K-brain 无法在本机断开沙箱网络", + // Windows:通过 K-brain 的沙箱 setup(POST /v1/sandbox/setup)启用离线沙箱。 + "chat.safety.setupOffline": "设置离线沙箱…", + "chat.safety.setupOfflineDesc": "需要 Windows 管理员授权", + "chat.safety.setup.title": "设置离线沙箱?", + "chat.safety.setup.intro": "为了让沙箱中的命令在断网状态下运行,K-brain 会对这台电脑做以下改动:", + "chat.safety.setup.accounts": + "创建两个隐藏的本地 Windows 账户,沙箱命令以它们的身份运行:一个可以联网,一个不能。", + "chat.safety.setup.firewall": + "添加 Windows 防火墙规则,禁止离线账户访问任何网络,包括本机上的服务。", + "chat.safety.setup.admin": "Windows 会请求管理员授权。", + "chat.safety.setup.undo": "以后可以在终端运行 {command} 撤销全部改动。", + "chat.safety.setup.backendNote": "K-brain:{hint}", + "chat.safety.setup.confirm": "开始设置", + "chat.safety.setup.cancel": "取消", + "chat.safety.setup.close": "关闭", + "chat.safety.setup.retry": "重试", + "chat.safety.setup.running": "正在等待管理员授权…", + "chat.safety.setup.runningDesc": + "请在 Windows 弹出的提示中确认。确认后还需要片刻完成设置,请保持 LiveAgent 打开。", + "chat.safety.setup.ready": "离线沙箱已就绪", + "chat.safety.setup.readyDesc": "离线沙箱中的命令现在会在断网状态下运行。", + "chat.safety.setup.useOffline": "使用离线沙箱", + "chat.safety.setup.incomplete": "设置已完成,但离线沙箱仍不可用", + "chat.safety.setup.incompleteDesc": "K-brain 的沙箱自检没有通过,可以重新运行设置来修复。", + "chat.safety.setup.declined": "管理员授权被拒绝", + "chat.safety.setup.declinedDesc": "这台电脑上没有任何改动。", + "chat.safety.setup.busy": "沙箱设置已在进行中", + "chat.safety.setup.busyDesc": "另一个沙箱设置正在进行。请先处理已打开的 Windows 提示,然后重试。", + "chat.safety.setup.failed": "沙箱设置失败", + "chat.safety.setup.failedDesc": "重新运行设置可以修复不完整的安装;{command} 会把它清除。", + "chat.safety.setup.unsupported": "无法设置沙箱", + "chat.safety.setup.unsupportedDesc": "K-brain 只能在 Windows 上设置离线沙箱。", + "chat.safety.setup.error": "无法运行沙箱设置", "chat.safety.cycleShortcut": "{keys} 切换", "chat.safety.cycleNextTurn": "下一轮生效", "chat.safety.cycleNewMessages": "新消息生效", diff --git a/crates/agent-ui/src/lib/kbrain/health.ts b/crates/agent-ui/src/lib/kbrain/health.ts index 03efccc27..27f5d159d 100644 --- a/crates/agent-ui/src/lib/kbrain/health.ts +++ b/crates/agent-ui/src/lib/kbrain/health.ts @@ -205,6 +205,23 @@ export function supportsRunSandbox( ); } +/** + * Whether K-brain offers to install its Windows sandbox accounts so the offline + * sandbox can cut the network (`sandbox_setup` 1 and `run_sandbox.setup_available`), + * and the run sandbox cannot do so yet. Setup never makes a sandbox without path + * protection usable, so that one stays unavailable rather than offered a fix. + */ +export function offersRunSandboxSetup(health: Pick): boolean { + if (!hasFrozenCapability(health, "sandbox_setup")) return false; + const raw = health.capabilities.run_sandbox; + if (!isObject(raw)) return false; + return ( + raw.setup_available === true && + !(raw.supported === true && raw.network_control === true) && + !runSandboxLacksPathProtection(raw) + ); +} + export function supportsPermissionMode( health: Pick, mode: string, diff --git a/crates/agent-ui/src/lib/kbrain/sandboxSetup.ts b/crates/agent-ui/src/lib/kbrain/sandboxSetup.ts new file mode 100644 index 000000000..c553c9cfd --- /dev/null +++ b/crates/agent-ui/src/lib/kbrain/sandboxSetup.ts @@ -0,0 +1,71 @@ +/** + * Result of K-brain's `POST /v1/sandbox/setup` (capability `sandbox_setup`), which + * installs the Windows sandbox accounts behind a UAC prompt on the backend's desktop. + * See K-brain docs/liveagent-backend.md, "Windows sandbox setup". + */ +export type SandboxSetupOutcome = + /** Setup finished and the offline sandbox can now cut the network. */ + | { status: "ready" } + /** Setup finished but the sandbox self-test still fails; `hint` says why. */ + | { status: "incomplete"; hint?: string } + /** 403: the user declined or closed the administrator prompt; nothing changed. */ + | { status: "declined" } + /** 409: another setup is already running. */ + | { status: "busy" } + /** 500: setup failed, possibly part-way; running it again repairs it. */ + | { status: "failed"; message?: string } + /** 501: the backend's platform has no sandbox setup. */ + | { status: "unsupported" } + /** Anything else: transport failure, auth, an unexpected status. */ + | { status: "error"; message: string }; + +type JsonObject = Record; + +function isObject(value: unknown): value is JsonObject { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function text(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +/** The sandbox from a setup response can cut the network (same rule as health). */ +function cutsNetwork(runSandbox: unknown): boolean { + return ( + isObject(runSandbox) && + runSandbox.supported === true && + runSandbox.network_control === true && + runSandbox.path_protection !== false + ); +} + +/** Maps a setup response (HTTP status and JSON body, or its raw text) to an outcome. */ +export function sandboxSetupOutcome(httpStatus: number, body: unknown): SandboxSetupOutcome { + const value = isObject(body) ? body : {}; + const message = text(value.error) ?? (typeof body === "string" ? text(body) : undefined); + if (httpStatus >= 200 && httpStatus < 300) { + if (cutsNetwork(value.run_sandbox)) return { status: "ready" }; + const runSandbox = isObject(value.run_sandbox) ? value.run_sandbox : {}; + const status = isObject(value.status) ? value.status : {}; + const hint = + text(runSandbox.setup_hint) ?? text(status.hint) ?? text(status.problem) ?? message; + return hint ? { status: "incomplete", hint } : { status: "incomplete" }; + } + if (httpStatus === 403) return { status: "declined" }; + if (httpStatus === 409) return { status: "busy" }; + if (httpStatus === 501) return { status: "unsupported" }; + if (httpStatus === 500) return message ? { status: "failed", message } : { status: "failed" }; + return { status: "error", message: message ?? `K-brain request failed (${httpStatus})` }; +} + +/** Reads a fetch Response from `POST /v1/sandbox/setup`. */ +export async function readSandboxSetupResponse(response: Response): Promise { + const raw = await response.text(); + let body: unknown = raw; + try { + body = JSON.parse(raw); + } catch { + // Keep the raw text: proxies and old backends answer errors in plain text. + } + return sandboxSetupOutcome(response.status, body); +} diff --git a/docs/kbrain-permission-modes.md b/docs/kbrain-permission-modes.md index 0870bef85..e06c0b3f0 100644 --- a/docs/kbrain-permission-modes.md +++ b/docs/kbrain-permission-modes.md @@ -25,6 +25,18 @@ The sandbox modes approve file edits automatically and run shell commands withou Commands run in K-brain, so the desktop selector reads `capabilities.run_sandbox` from K-brain's `/v1/health`, not a desktop probe; the WebUI leaves it unknown and relies on the Gateway's check at submission. On the desktop the entries are disabled, with K-brain's reason, when the capability is absent or unsupported; the offline entry also needs `network_control`, and the description drops the read-masking promise when `read_masking` is false. Both entries are also disabled when `path_protection` is false (K-brain's Linux Landlock fallback): the protected workspace paths (`.liveagent/`, `.k-brain/`, `.git/hooks`, `.git/config`) would stay writable inside the sandbox and are later used outside it. Older K-brain builds omit the field and count as protected, unless they report the `landlock` mechanism. The desktop client and the Gateway both refuse to submit a sandboxed run the backend cannot enforce, including one without path protection, and K-brain itself rejects one with `400 options.sandbox`, so a sandbox mode never runs commands unconfined. A remote/queued request cannot loosen a stricter local selection. +### Windows: setting up the offline sandbox + +On native Windows K-brain starts with the `low-integrity` sandbox, which cannot cut the network (`network_control: false`), so the offline entry is disabled. When K-brain reports `capabilities.sandbox_setup: 1` and `run_sandbox.setup_available: true` (Stack-Cairn/K-brain#165), the desktop shows **Set up offline sandbox…** under the disabled entry, with K-brain's `setup_hint` as its tooltip. Choosing it opens a confirmation that lists what setup changes on the machine: + +- two hidden local Windows accounts that sandboxed commands run as (one with network access, one without); +- Windows Filtering Platform (firewall) rules that block all network access, loopback included, for the offline account; +- a Windows administrator (UAC) prompt. + +After the user confirms, LiveAgent calls `POST /v1/sandbox/setup` with the backend token; the request waits until the UAC prompt is answered, and the dialog shows that it is waiting and cannot be dismissed meanwhile. The outcome is reported per status: `200` ready (or "finished but the self-test still fails" with K-brain's hint, when health still lacks network control), `403` approval declined (nothing changed), `409` another setup is running, `500` setup failed with K-brain's message, `501` unsupported, anything else as an error. Every attempt except `501` bumps `capabilityRevision`, so LiveAgent reads `/v1/health` again and every open selector updates; once the mechanism is `sandbox-user` with `network_control: true`, Offline sandbox becomes selectable and the dialog offers to switch to it. A sandbox without path protection stays unavailable and is never offered setup. + +The action is desktop-only and only for a K-brain on this machine (the managed backend or a loopback URL): the UAC prompt appears on the desktop of the machine that runs K-brain, so the WebUI/Gateway never shows it. Setup is undone only from a terminal with `kn sandbox uninstall`; LiveAgent has no uninstall action. + Transport ownership: ```text