Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import type { SandboxSetupOutcome } from "@liveagent/ui/lib/kbrain/sandboxSetup";

export type { SandboxSetupOutcome };

export type SandboxCapability = {
supported: boolean;
mechanism: string;
Expand All @@ -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<SandboxSetupOutcome> {
return { status: "unsupported" };
}
22 changes: 22 additions & 0 deletions crates/agent-gui/src/agent-ui-adapters/kbrainSettings.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -75,6 +79,24 @@ export function getKBrainHealth(): Promise<KBrainHealth> {
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<SandboxSetupOutcome> {
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,
Expand Down
128 changes: 109 additions & 19 deletions crates/agent-gui/src/agent-ui-adapters/sandboxCapability.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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";
Expand All @@ -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<string, unknown>;
}): SandboxCapability {
const raw = health.capabilities?.run_sandbox;
export function sandboxCapabilityFromHealth(
health: { capabilities?: Record<string, unknown> },
options: { setupHost?: boolean } = {},
): SandboxCapability {
const capabilities = health.capabilities ?? {};
const raw = capabilities.run_sandbox;
if (!raw || typeof raw !== "object") {
return {
supported: false,
Expand All @@ -42,12 +61,13 @@ export function sandboxCapabilityFromHealth(health: {
reason: BACKEND_TOO_OLD,
};
}
const value = raw as Record<string, unknown>;
const value = raw as KBrainRunSandboxCapability & Record<string, unknown>;
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"),
Expand All @@ -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<string> = 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<SandboxCapability> | 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<SandboxCapability> {
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<SandboxCapability> {
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<SandboxCapability | null>(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<SandboxSetupOutcome> {
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;
}
20 changes: 20 additions & 0 deletions crates/agent-gui/src/lib/kbrain/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading