UNPKG

@kontextmind/kxm

Version:

KXM local-first multi-agent orchestration and operator dashboard

609 lines (564 loc) • 22.5 kB
import { spawnSync } from "node:child_process"; import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, isAbsolute, join, resolve } from "node:path"; export const HUB_BINDING_SCHEMA = "kxm.hub-binding.v1" as const; export const HUB_HEALTH_PROBE_MS = 300; export type HubHealth = "on" | "off" | "unknown"; /** * Where a bound hub sits relative to this machine — a trust question, not a cosmetic one. * A loopback URL never puts a bearer on a network; a remote URL means the operator chose * to. `kxm hub bind` therefore refuses a remote URL it cannot authenticate, mirroring the * rule the hub applies to its own listener ("KXM_AUTH_TOKEN is required when binding * beyond localhost"). */ export type HubBindingScope = "loopback" | "remote"; export interface HubBindingRecord { schema: typeof HUB_BINDING_SCHEMA; url: string; boundAt: string; /** A forwarded or otherwise remote hub. Loopback in the URL does not make this local. */ cloud?: true; /** Environment variable that holds the hub token. The token itself is never stored. */ tokenEnv?: string; /** Program plus arguments that print the hub token. Not a shell pipeline, and not the token. */ tokenCommand?: string; } export type CloudTokenErrorCode = "cloud_token_missing" | "cloud_token_command_failed" | "cloud_token_command_invalid"; /** The cloud binding named a token source and that source did not produce a token. */ export class CloudTokenError extends Error { readonly code: CloudTokenErrorCode; constructor(code: CloudTokenErrorCode, message: string) { super(message); this.name = "CloudTokenError"; this.code = code; } } const TOKEN_ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/; const TOKEN_COMMAND_TIMEOUT_MS = 15_000; const TOKEN_COMMAND_MAX_BUFFER = 4096; export class HubBindingError extends Error { constructor(message: string) { super(message); this.name = "HubBindingError"; } } function resolveUserStateRoot(env: NodeJS.ProcessEnv): string { const explicit = env.KXM_STATE_HOME?.trim(); if (explicit) { if (!isAbsolute(explicit)) throw new HubBindingError("local_state_root_not_absolute"); return resolve(explicit); } if (process.platform === "win32") { const localAppData = env.LOCALAPPDATA?.trim(); const base = localAppData && isAbsolute(localAppData) ? localAppData : join(homedir(), "AppData", "Local"); return resolve(base, "KXM"); } if (process.platform === "darwin") return resolve(homedir(), "Library", "Application Support", "KXM"); const xdgState = env.XDG_STATE_HOME?.trim(); const base = xdgState && isAbsolute(xdgState) ? xdgState : join(homedir(), ".local", "state"); return resolve(base, "kxm"); } export function hubBindingFile(env: NodeJS.ProcessEnv = process.env): string { return join(resolveUserStateRoot(env), "hub-binding.json"); } /** * Cloud token source for a machine-wide bind. Kept out of `hub-binding.json` * so a 0.7.159 reader, which accepts only `schema`, `url`, and `boundAt`, * does not throw and stop syncing. */ export function hubBindingCloudFile(env: NodeJS.ProcessEnv = process.env): string { return join(resolveUserStateRoot(env), "hub-binding.cloud.json"); } export function validateHubUrl(raw: string): string { let parsed: URL; try { parsed = new URL(raw); } catch { throw new HubBindingError("hub_url_invalid"); } if ( (parsed.protocol !== "http:" && parsed.protocol !== "https:") || parsed.username !== "" || parsed.password !== "" || parsed.search !== "" || parsed.hash !== "" || raw.includes("?") || raw.includes("#") ) { throw new HubBindingError("hub_url_invalid"); } return parsed.href.replace(/\/$/, ""); } /** A cloud binding's token command is a program and arguments, never a shell. */ export function splitTokenCommand(raw: string): string[] { if (/[|&;<>$`\n\r]/.test(raw)) { throw new CloudTokenError( "cloud_token_command_invalid", "token command must be a program and arguments, not a shell pipeline", ); } const args: string[] = []; let current = ""; let quote: "'" | "\"" | undefined; for (const ch of raw) { if (quote) { if (ch === quote) quote = undefined; else current += ch; continue; } if (ch === "'" || ch === "\"") { quote = ch; continue; } if (ch === " " || ch === "\t") { if (current.length > 0) { args.push(current); current = ""; } continue; } current += ch; } if (quote) { throw new CloudTokenError("cloud_token_command_invalid", "token command has an unclosed quote"); } if (current.length > 0) args.push(current); if (args.length === 0) { throw new CloudTokenError("cloud_token_command_invalid", "token command is empty"); } return args; } export function isCloudTokenEnvName(value: string): boolean { return TOKEN_ENV_NAME.test(value); } /** * Where this command should say the hub sits. A cloud binding is remote even when its * URL is a loopback forward, but only for the URL the binding names. `KXM_SERVER_URL` * pointing somewhere else keeps that URL's own scope. */ export function effectiveHubBindingScope(url: string, env: NodeJS.ProcessEnv = process.env): HubBindingScope { try { const binding = readHubBinding(env); if (binding?.cloud) { try { if (validateHubUrl(url) === binding.url) return "remote"; } catch { // An unparsable override is not the cloud binding. } } } catch (error) { if (!(error instanceof HubBindingError)) throw error; } return hubBindingScope(url); } /** Hub URL for a session: explicit env, else the binding, else the local default. */ export function resolveHubServerUrl(env: NodeJS.ProcessEnv = process.env): string { const fromEnv = env.KXM_SERVER_URL?.trim(); if (fromEnv) return fromEnv.replace(/\/$/, ""); try { const binding = readHubBinding(env); if (binding?.url) return binding.url; } catch (error) { if (!(error instanceof HubBindingError)) throw error; } return "http://127.0.0.1:7331"; } /** * Resolve the token a cloud binding names. Never reads hub-env and never uses * `KXM_AUTH_TOKEN` unless `tokenEnv` is that name. `tokenEnv` wins when it is non-empty; * otherwise the command runs. The command is not cached. */ export function resolveCloudHubToken(binding: HubBindingRecord, env: NodeJS.ProcessEnv = process.env): string { if (!binding.cloud) { throw new CloudTokenError("cloud_token_missing", "cloud hub token is missing; the local hub-env token was not used"); } if (binding.tokenEnv) { const value = env[binding.tokenEnv]?.trim(); if (value) return value; } if (binding.tokenCommand) return runTokenCommand(binding.tokenCommand, env); const source = binding.tokenEnv ? `${binding.tokenEnv} is unset or empty` : "no token source is configured"; throw new CloudTokenError( "cloud_token_missing", `cloud hub token is missing: ${source}; the local hub-env token was not used`, ); } function runTokenCommand(command: string, env: NodeJS.ProcessEnv): string { const argv = splitTokenCommand(command); const [program, ...args] = argv; const result = spawnSync(program!, args, { encoding: "utf8", shell: false, timeout: TOKEN_COMMAND_TIMEOUT_MS, maxBuffer: TOKEN_COMMAND_MAX_BUFFER, env, windowsHide: true, }); if (result.error || result.status !== 0) { throw new CloudTokenError( "cloud_token_command_failed", "token command failed; the local hub-env token was not used", ); } const stdout = result.stdout ?? ""; if (stdout.trim().length === 0 || stdout.trim() !== stdout.trim().split(/\r?\n/)[0]) { throw new CloudTokenError( "cloud_token_command_failed", "token command did not print a single token; the local hub-env token was not used", ); } return stdout.trim(); } /** Loopback literals only; `0.0.0.0`, a LAN address or a hostname are all remote. */ export function hubBindingScope(url: string): HubBindingScope { let host: string; try { host = new URL(url).hostname.toLowerCase(); } catch { return "remote"; } if (host === "localhost" || host === "::1" || host === "[::1]" || host.endsWith(".localhost")) return "loopback"; const v4 = /^127\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$/.exec(host); return v4 && [v4[1], v4[2], v4[3]].every((part) => Number(part) <= 255) ? "loopback" : "remote"; } function isIsoTimestamp(value: string): boolean { if (Number.isNaN(Date.parse(value))) return false; return value === new Date(value).toISOString(); } function isAbortError(error: unknown): boolean { return Boolean( error && typeof error === "object" && (("name" in error && error.name === "AbortError") || ("code" in error && error.code === "ABORT_ERR")), ); } const LEGACY_BINDING_KEYS = ["schema", "url", "boundAt"] as const; const BINDING_KEYS = new Set<string>([...LEGACY_BINDING_KEYS, "cloud", "tokenEnv", "tokenCommand"]); export interface HubBindingLoad { record?: HubBindingRecord; /** * Set when this build ignores the file and continues against the local hub. * A newer schema, or a cloud record an older process cannot read, must not * throw into the Runtime sync loop. */ warning?: string; } function bindingFallbackWarning(file: string, reason: string): string { return `hub binding at ${file} ${reason}; falling back to the local hub. Restart the Runtime supervisor, the hub, and workers after upgrading and before a cloud bind.`; } function writeBindingJson(file: string, value: unknown): void { mkdirSync(dirname(file), { recursive: true, mode: 0o700 }); const temporary = join(dirname(file), `.${file.endsWith("cloud.json") ? "hub-binding-cloud" : "hub-binding"}-${process.pid}.tmp`); try { writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { encoding: "utf8", mode: 0o600 }); renameSync(temporary, file); } finally { rmSync(temporary, { force: true }); } } function parseBindingObject(file: string): Record<string, unknown> | HubBindingError { if (!existsSync(file)) return new HubBindingError(`missing hub binding at ${file}`); let parsed: unknown; try { parsed = JSON.parse(readFileSync(file, "utf8")); } catch { return new HubBindingError(`malformed hub binding at ${file}`); } if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { return new HubBindingError(`malformed hub binding at ${file}`); } return parsed as Record<string, unknown>; } function cloudRecordFromRow(file: string, row: Record<string, unknown>, url: string, boundAt: string): HubBindingRecord | undefined { if (row.cloud !== true) return undefined; let tokenEnv: string | undefined; let tokenCommand: string | undefined; if (row.tokenEnv !== undefined) { if (typeof row.tokenEnv !== "string" || !isCloudTokenEnvName(row.tokenEnv)) { throw new HubBindingError(`malformed hub binding at ${file}`); } tokenEnv = row.tokenEnv; } if (row.tokenCommand !== undefined) { if (typeof row.tokenCommand !== "string") throw new HubBindingError(`malformed hub binding at ${file}`); try { splitTokenCommand(row.tokenCommand); } catch { throw new HubBindingError(`malformed hub binding at ${file}`); } tokenCommand = row.tokenCommand; } if (!tokenEnv && !tokenCommand) throw new HubBindingError(`malformed hub binding at ${file}`); return { schema: HUB_BINDING_SCHEMA, url, boundAt, cloud: true, ...(tokenEnv ? { tokenEnv } : {}), ...(tokenCommand ? { tokenCommand } : {}), }; } /** * The reader shipped in 0.7.159. It accepts only `schema`, `url`, and * `boundAt`. Extra keys throw `malformed hub binding`, which stopped sync. * New cloud binds keep this file in that shape, or omit it, so this reader * falls through to the local hub instead of throwing. */ export function readLegacyV1HubBinding(env: NodeJS.ProcessEnv = process.env): HubBindingRecord | undefined { const file = hubBindingFile(env); if (!existsSync(file)) return undefined; const parsed = parseBindingObject(file); if (parsed instanceof HubBindingError) throw parsed; const keys = Object.keys(parsed); if ( keys.length !== 3 || keys.some((key) => !LEGACY_BINDING_KEYS.includes(key as typeof LEGACY_BINDING_KEYS[number])) || parsed.schema !== HUB_BINDING_SCHEMA || typeof parsed.url !== "string" || typeof parsed.boundAt !== "string" || !isIsoTimestamp(parsed.boundAt) ) { throw new HubBindingError(`malformed hub binding at ${file}`); } let url: string; try { url = validateHubUrl(parsed.url); } catch { throw new HubBindingError(`malformed hub binding at ${file}`); } return { schema: HUB_BINDING_SCHEMA, url, boundAt: parsed.boundAt }; } function interpretBindingFile(file: string): HubBindingLoad & { legacyCloud?: boolean } { if (!existsSync(file)) return {}; const parsed = parseBindingObject(file); if (parsed instanceof HubBindingError) throw parsed; const keys = Object.keys(parsed); const unknown = keys.filter((key) => !BINDING_KEYS.has(key)); if (parsed.schema !== HUB_BINDING_SCHEMA || unknown.length > 0) { return { warning: bindingFallbackWarning(file, `is newer than this build understands (${String(parsed.schema)})`) }; } if (typeof parsed.url !== "string" || typeof parsed.boundAt !== "string" || !isIsoTimestamp(parsed.boundAt)) { throw new HubBindingError(`malformed hub binding at ${file}`); } let url: string; try { url = validateHubUrl(parsed.url); } catch { throw new HubBindingError(`malformed hub binding at ${file}`); } const cloudish = keys.some((key) => key === "cloud" || key === "tokenEnv" || key === "tokenCommand"); if (!cloudish) { if (keys.length !== 3) throw new HubBindingError(`malformed hub binding at ${file}`); return { record: { schema: HUB_BINDING_SCHEMA, url, boundAt: parsed.boundAt } }; } const record = cloudRecordFromRow(file, parsed, url, parsed.boundAt); if (!record) throw new HubBindingError(`malformed hub binding at ${file}`); return { record, legacyCloud: true }; } /** * Cloud metadata wins over a local `hub-binding.json`. A legacy cloud record * that still lives in `hub-binding.json` is moved aside so a 0.7.159 process * no longer throws on the next read. */ export function loadHubBinding(env: NodeJS.ProcessEnv = process.env): HubBindingLoad { const cloudFile = hubBindingCloudFile(env); const cloud = interpretBindingFile(cloudFile); if (cloud.warning && !cloud.record) { const local = existsSync(hubBindingFile(env)) ? interpretBindingFile(hubBindingFile(env)) : {}; if (local.record && !local.record.cloud) return { record: local.record, warning: cloud.warning }; return { warning: cloud.warning }; } if (cloud.record?.cloud) return { record: cloud.record, ...(cloud.warning ? { warning: cloud.warning } : {}) }; const main = interpretBindingFile(hubBindingFile(env)); if (main.legacyCloud && main.record) { writeBindingJson(cloudFile, main.record); rmSync(hubBindingFile(env), { force: true }); return { record: main.record, warning: bindingFallbackWarning( hubBindingFile(env), "stored a cloud bind in the pre-0.7.160 file", ), }; } return { ...(main.record ? { record: main.record } : {}), ...(main.warning ? { warning: main.warning } : {}) }; } export function readHubBinding(env: NodeJS.ProcessEnv = process.env): HubBindingRecord | undefined { return loadHubBinding(env).record; } export function writeHubBinding(record: HubBindingRecord, env: NodeJS.ProcessEnv = process.env): string { if (record.cloud) { const file = hubBindingCloudFile(env); writeBindingJson(file, record); // A 0.7.159 reader throws on extra keys and stops sync. Leave a local // three-key file alone so that reader keeps the local hub. Drop a file // that points at this cloud URL, or that already carries cloud keys. const main = hubBindingFile(env); if (existsSync(main)) { try { const legacy = readLegacyV1HubBinding(env); if (!legacy || legacy.url === record.url) rmSync(main, { force: true }); } catch (error) { if (!(error instanceof HubBindingError)) throw error; rmSync(main, { force: true }); } } return file; } rmSync(hubBindingCloudFile(env), { force: true }); const file = hubBindingFile(env); writeBindingJson(file, { schema: HUB_BINDING_SCHEMA, url: record.url, boundAt: record.boundAt, }); return file; } export function removeHubBinding(env: NodeJS.ProcessEnv = process.env): boolean { const files = [hubBindingFile(env), hubBindingCloudFile(env)]; let removed = false; for (const file of files) { try { rmSync(file); removed = true; } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } } return removed; } export type HubProbeKind = "hub" | "auth_proxy" | "redirect" | "not_hub" | "unreachable" | "timeout"; export interface HubProbe { health: HubHealth; probeMs: number; kind: HubProbeKind; detail: string; status: number; } export interface HubEndpointVerdict { ok: boolean; status: number; kind: HubProbeKind; detail: string; /** Hub JSON when the body matches. Never the raw HTML of a login page. */ body: unknown; } function contentTypeBase(header: string | null): string { return (header ?? "").split(";")[0]!.trim().toLowerCase(); } function looksLikeHtml(contentType: string, body: string): boolean { if (contentType === "text/html" || contentType === "application/xhtml+xml") return true; const sample = body.slice(0, 800).trim().toLowerCase(); return sample.startsWith("<!doctype html") || sample.startsWith("<html") || (sample.includes("<form") && (sample.includes("login") || sample.includes("authentik") || sample.includes("password"))); } function jsonObject(text: string): Record<string, unknown> | undefined { try { const parsed = JSON.parse(text) as unknown; if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined; return parsed as Record<string, unknown>; } catch { return undefined; } } /** `/health` is `{ ok: true, agents: <number> }`. `/ready` adds `storage`. */ export function hubEndpointShape(role: "health" | "ready", body: Record<string, unknown>): boolean { if (role === "health") return body.ok === true && typeof body.agents === "number"; return body.ok === true && (body.storage === "sqlite" || body.storage === "memory"); } /** * A kxm hub answers JSON with the documented shape and `application/json`. * An auth proxy's 30x or HTML login page is not that, even on HTTP 200. */ export function classifyHubResponse(input: { role: "health" | "ready"; status: number; contentType: string | null; bodyText: string; }): HubEndpointVerdict { const contentType = contentTypeBase(input.contentType); if (input.status >= 300 && input.status < 400) { return { ok: false, status: input.status, kind: "redirect", detail: `HTTP ${input.status} redirect; this looks like an auth proxy or a redirect, not a kxm hub`, body: { error: "auth_proxy", detail: `HTTP ${input.status} redirect` }, }; } if (looksLikeHtml(contentType, input.bodyText)) { return { ok: false, status: input.status, kind: "auth_proxy", detail: `HTTP ${input.status} returned an HTML login page${contentType ? ` (${contentType})` : ""}; this looks like an auth proxy, not a kxm hub`, body: { error: "auth_proxy", detail: "HTML login page" }, }; } const jsonType = contentType === "application/json" || contentType === "text/plain" || contentType === ""; const body = jsonType ? jsonObject(input.bodyText) : undefined; if (body && hubEndpointShape(input.role, body) && input.status >= 200 && input.status < 300) { if (contentType !== "" && contentType !== "application/json" && contentType !== "text/plain") { return { ok: false, status: input.status, kind: "not_hub", detail: `HTTP ${input.status} content-type ${contentType} is not application/json`, body: { error: "not_hub", detail: contentType }, }; } return { ok: true, status: input.status, kind: "hub", detail: "kxm hub", body }; } const typeNote = contentType && contentType !== "application/json" ? ` content-type ${contentType}` : ""; return { ok: false, status: input.status, kind: "not_hub", detail: `HTTP ${input.status}${typeNote} is not a kxm hub ${input.role} response`, body: { error: "not_hub", detail: `HTTP ${input.status}${typeNote}` }, }; } export async function readHubEndpoint( url: string, role: "health" | "ready", fetchImpl: typeof fetch, init: { headers?: Record<string, string>; signal?: AbortSignal } = {}, ): Promise<HubEndpointVerdict> { try { const response = await fetchImpl(`${url}/${role}`, { ...(init.headers ? { headers: init.headers } : {}), ...(init.signal ? { signal: init.signal } : {}), redirect: "manual", }); const bodyText = await response.text(); return classifyHubResponse({ role, status: response.status, contentType: response.headers.get("content-type"), bodyText, }); } catch (error) { if (isAbortError(error)) { return { ok: false, status: 0, kind: "timeout", detail: "no reply within the probe budget", body: { error: "hub_unreachable" } }; } return { ok: false, status: 0, kind: "unreachable", detail: "nothing answered", body: { error: "hub_unreachable" } }; } } export async function probeHubHealth( url: string, fetchImpl: typeof fetch, timeoutMs = HUB_HEALTH_PROBE_MS, ): Promise<HubProbe> { const started = Date.now(); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { const verdict = await readHubEndpoint(url, "health", fetchImpl, { signal: controller.signal }); const health: HubHealth = verdict.kind === "hub" ? "on" : verdict.kind === "unreachable" ? "off" : "unknown"; return { health, probeMs: Date.now() - started, kind: verdict.kind, detail: verdict.detail, status: verdict.status }; } finally { clearTimeout(timer); } }