UNPKG

@kontextmind/kxm

Version:

KXM local-first multi-agent orchestration and operator dashboard

407 lines (376 loc) • 16.7 kB
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { randomBytes } from "node:crypto"; import { dirname, isAbsolute, join, resolve } from "node:path"; import { loadHubSettings } from "./config.ts"; import { describeHubConnection, projectTokenMissingMessage, resolveKeyReference, type OpReader, type ProjectIdentity, } from "./hub-identity.ts"; import { CloudTokenError, HubBindingError, hubBindingFile, readHubBinding, resolveCloudHubToken, type HubBindingRecord, } from "./hub-binding.ts"; export const HUB_ENV_SCHEMA = "kxm.hub-env.v1" as const; export interface HubEnvRecord { schema: typeof HUB_ENV_SCHEMA; /** ISO-8601 creation time of the persisted record. */ createdAt: string; authToken?: string | undefined; projectTokens?: Record<string, string> | undefined; } export class HubEnvError extends Error { constructor(message: string) { super(message); this.name = "HubEnvError"; } } function resolveUserStateRoot(env: NodeJS.ProcessEnv): string { const explicit = env.KXM_STATE_HOME?.trim(); if (explicit) { if (!isAbsolute(explicit)) throw new HubEnvError("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 hubEnvFile(env: NodeJS.ProcessEnv = process.env): string { return join(resolveUserStateRoot(env), "hub-env.json"); } export function generateHubAuthToken(): string { return `kxm_admin_${randomBytes(24).toString("base64url")}`; } export function readHubEnvRecord(env: NodeJS.ProcessEnv = process.env): HubEnvRecord | undefined { const file = hubEnvFile(env); if (!existsSync(file)) return undefined; let parsed: unknown; try { parsed = JSON.parse(readFileSync(file, "utf8")); } catch (error) { throw new HubEnvError(`hub env file is malformed at ${file}: ${error instanceof Error ? error.message : String(error)}`); } if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { throw new HubEnvError(`hub env file is malformed at ${file}: expected a JSON object`); } const record = parsed as Partial<HubEnvRecord>; if (record.schema !== HUB_ENV_SCHEMA) { throw new HubEnvError(`hub env file at ${file} has unsupported schema ${String(record.schema)}`); } if (record.authToken !== undefined && (typeof record.authToken !== "string" || record.authToken.trim().length === 0)) { throw new HubEnvError(`hub env file at ${file} has an invalid authToken`); } if (record.projectTokens !== undefined) { const tokens = record.projectTokens; if (!tokens || typeof tokens !== "object" || Array.isArray(tokens)) { throw new HubEnvError(`hub env file at ${file} has invalid projectTokens`); } for (const [project, token] of Object.entries(tokens)) { if (!project.trim() || typeof token !== "string" || !token.trim()) { throw new HubEnvError(`hub env file at ${file} has an empty project name or token`); } } } return { schema: HUB_ENV_SCHEMA, createdAt: typeof record.createdAt === "string" ? record.createdAt : "", ...(record.authToken !== undefined ? { authToken: record.authToken } : {}), ...(record.projectTokens !== undefined ? { projectTokens: record.projectTokens } : {}), }; } export function writeHubEnvRecord(record: HubEnvRecord, env: NodeJS.ProcessEnv = process.env): string { const file = hubEnvFile(env); const directory = dirname(file); mkdirSync(directory, { recursive: true }); const temporary = join(directory, `.hub-env-${process.pid}.tmp`); writeFileSync(temporary, `${JSON.stringify(record, undefined, 2)}\n`, { encoding: "utf8", mode: 0o600 }); try { renameSync(temporary, file); } catch (error) { rmSync(temporary, { force: true }); throw error; } return file; } export interface ResolveHubCredentialsOptions { env?: NodeJS.ProcessEnv | undefined; /** Injectable token generator for tests. */ generateToken?: () => string | undefined; /** When false, never create or update the persisted file. */ persist?: boolean | undefined; now?: () => string | undefined; } export interface ResolvedHubCredentials { authToken: string | undefined; projectTokens: Record<string, string> | undefined; /** Where each value came from; `generated` values were persisted when allowed. */ authTokenSource: "env" | "file" | "generated" | "none"; projectTokensSource: "env" | "file" | "generated" | "none"; envFile: string | undefined; written: boolean; } function parseProjectTokens(raw: string | undefined): Record<string, string> | undefined | "invalid" { const trimmed = raw?.trim(); if (!trimmed) return undefined; let parsed: unknown; try { parsed = JSON.parse(trimmed); } catch { return "invalid"; } if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return "invalid"; const entries = Object.entries(parsed as Record<string, unknown>); if (entries.some(([project, token]) => !project.trim() || typeof token !== "string" || !token.trim())) return "invalid"; return Object.fromEntries(entries) as Record<string, string>; } /** Resolve hub credentials with persistence across restarts. * * Precedence: explicit environment variable, then the persisted user-state * file, then (for the admin token only) a freshly generated token that is * persisted so later restarts and workers reuse the same credential. */ export function resolveHubCredentials(options: ResolveHubCredentialsOptions = {}): ResolvedHubCredentials { const env = options.env ?? process.env; const persist = options.persist !== false; const existing = readHubEnvRecord(env); const envFile = hubEnvFile(env); const envAuthToken = env.KXM_AUTH_TOKEN?.trim(); const envProjectTokens = parseProjectTokens(env.KXM_PROJECT_TOKENS); if (envProjectTokens === "invalid") { throw new HubEnvError("KXM_PROJECT_TOKENS must be a JSON object of project names to tokens"); } let authTokenSource: ResolvedHubCredentials["authTokenSource"] = "none"; let authToken = envAuthToken || existing?.authToken; if (envAuthToken) authTokenSource = "env"; else if (authToken) authTokenSource = "file"; if (!authToken) { const generated = options.generateToken?.() ?? generateHubAuthToken(); if (generated) { authToken = generated; authTokenSource = "generated"; } } let projectTokensSource: ResolvedHubCredentials["projectTokensSource"] = "none"; let projectTokens = envProjectTokens ?? existing?.projectTokens; if (envProjectTokens) projectTokensSource = "env"; else if (projectTokens) projectTokensSource = "file"; const needsPersist = persist && (authTokenSource === "generated" || (authTokenSource === "env" && existing?.authToken !== authToken) || (projectTokensSource === "env" && JSON.stringify(existing?.projectTokens ?? undefined) !== JSON.stringify(projectTokens ?? undefined))); if (needsPersist) { const record: HubEnvRecord = { schema: HUB_ENV_SCHEMA, createdAt: existing?.createdAt || (options.now?.() ?? new Date().toISOString()), ...(authToken ? { authToken } : {}), ...(projectTokens ? { projectTokens } : {}), }; writeHubEnvRecord(record, env); } return { authToken, projectTokens, authTokenSource, projectTokensSource, envFile: needsPersist || existing ? envFile : undefined, written: needsPersist === true, }; } /** Read-only token for one-shot operator hub clients (CLI, runtime supervisor, dashboards). * * Precedence: explicit KXM_AUTH_TOKEN, then the persisted project token for * the resolved project, then the persisted admin token. Never generates or * writes; a malformed persisted record fails closed with HubEnvError. This * matches the credential precedence `kxm hub start` announces, so a hub * started fresh (generated token persisted) accepts authenticated client * commands without the operator exporting the token. */ /** * Can this machine authenticate to a hub at all, following the same precedence one-shot * clients use: explicit `KXM_AUTH_TOKEN`, else the persisted record's admin token, else a * persisted **project** token. Read-only on purpose — a refusal has to tell the operator * to configure something, not reveal that the tool quietly configured it for them. * * Pass `project` when the caller knows which project will authenticate. A record holding * only another project's token cannot authorise this one, and a guard that counts it as a * credential stores a binding that will fail exactly like the one it prevented. */ /** A cloud binding, when this machine has one. A malformed binding fails closed. */ function activeCloudBinding(env: NodeJS.ProcessEnv): HubBindingRecord | undefined { try { const binding = readHubBinding(env); return binding?.cloud ? binding : undefined; } catch (error) { if (error instanceof HubBindingError) { throw new HubEnvError( `${error.message}; refusing to guess a credential — repair or remove ${hubBindingFile(env)}`, ); } throw error; } } /** * Credential for control-plane posts that are not agent sessions (context, degrade). * Non-cloud keeps the historical rule: only the explicit `KXM_AUTH_TOKEN`, never hub-env. * A cloud binding uses its named source and nothing else. */ export function resolveControlPlaneAuthToken( env: NodeJS.ProcessEnv = process.env, cwd: string = process.cwd(), opRead?: OpReader, ): string | undefined { const cloud = cloudModeToken(cwd, env, undefined, opRead); if (cloud) return cloud; return env.KXM_AUTH_TOKEN?.trim() || undefined; } /** * Environment a pi or Claude session started through kxm should inherit. * The project id comes from the shared resolver. A cloud binding adds * `KXM_AUTH_TOKEN` in this object only — it is never written to the binding. */ function cloudModeToken( cwd: string, env: NodeJS.ProcessEnv, explicitProject?: string, opRead?: OpReader, ): string | undefined { const connection = describeHubConnection(cwd, env, explicitProject === undefined ? {} : { explicitProject }); if (connection.mode !== "cloud") return undefined; const fromConfig = resolveKeyReference(connection.endpoint?.key, env, opRead); if (fromConfig) return fromConfig.value; if (connection.binding?.cloud) return resolveCloudHubToken(connection.binding, env); throw new CloudTokenError( "cloud_token_missing", "cloud hub token is missing; the local hub-env token was not used", ); } export function agentSessionEnv( cwd: string, env: NodeJS.ProcessEnv = process.env, explicitProject?: string, opRead?: OpReader, ): NodeJS.ProcessEnv { const connection = describeHubConnection(cwd, env, explicitProject === undefined ? {} : { explicitProject }); const session: NodeJS.ProcessEnv = { KXM_PROJECT: connection.project }; if (connection.url) session.KXM_SERVER_URL = connection.url.replace(/\/$/, ""); if (connection.mode === "cloud") { const token = cloudModeToken(cwd, env, explicitProject, opRead); if (token) session.KXM_AUTH_TOKEN = token; } return session; } export function hasClientHubCredential(env: NodeJS.ProcessEnv = process.env, project?: string, cwd: string = process.cwd()): boolean { if (env.KXM_AUTH_TOKEN?.trim()) return true; const settings = loadHubSettings(cwd, env); const mode = settings.mode ?? (activeCloudBinding(env) ? "cloud" : "local"); const key = (mode === "cloud" ? settings.cloud : settings.local)?.key; if (key?.op) return true; if (key?.env && env[key.env]?.trim()) return true; let record: HubEnvRecord | undefined; try { record = readHubEnvRecord(env); } catch (error) { // A malformed or invalid record is a configuration failure, not "no credential". // Letting it surface as an uncaught throw would print neither JSON nor prose. throw new HubEnvError( `${error instanceof Error ? error.message : String(error)}; refusing to guess a credential — repair or remove ${hubEnvFile(env)}`, ); } if (record?.authToken?.trim()) return true; const tokens = record?.projectTokens ?? {}; if (project !== undefined) return typeof tokens[project] === "string" && tokens[project].trim().length > 0; return Object.values(tokens).some((token) => typeof token === "string" && token.trim().length > 0); } export function resolveClientHubAuthToken( env: NodeJS.ProcessEnv, project: string, cwd: string = process.cwd(), opRead?: OpReader, ): string | undefined { const cloud = cloudModeToken(cwd, env, undefined, opRead); if (cloud) return cloud; const envToken = env.KXM_AUTH_TOKEN?.trim(); if (envToken) return envToken; const fromConfig = resolveKeyReference(describeHubConnection(cwd, env).endpoint?.key, env, opRead); if (fromConfig) return fromConfig.value; const record = readHubEnvRecord(env); return record?.projectTokens?.[project]?.trim() || record?.authToken?.trim() || undefined; } /** * Token an agent session (the Claude MCP server, the Pi extension, the `kxm peer` and * `kxm workflow` agent commands) registers with: explicit `KXM_AUTH_TOKEN`, * else the persisted project token for `project`, else nothing. It never returns the * persisted admin token. The hub accepts the admin token for any project missing from its * project-token map, so an agent falling back to it would join a project nobody issued it a * token for. Operator tools keep `resolveClientHubAuthToken`. Throws HubEnvError on a * malformed persisted record, same as `resolveClientHubAuthToken`. */ export function resolveAgentHubAuthToken( env: NodeJS.ProcessEnv, project: string, cwd: string = process.cwd(), opRead?: OpReader, ): string | undefined { const cloud = cloudModeToken(cwd, env, undefined, opRead); // A cloud binding's token is the remote hub's credential. It is not this // machine's hub-env admin token, which an agent must still never use. if (cloud) return cloud; const envToken = env.KXM_AUTH_TOKEN?.trim(); if (envToken) return envToken; const fromConfig = resolveKeyReference(describeHubConnection(cwd, env).endpoint?.key, env, opRead); if (fromConfig) return fromConfig.value; const tokens = readHubEnvRecord(env)?.projectTokens; if (!tokens || !Object.hasOwn(tokens, project)) return undefined; return tokens[project]?.trim() || undefined; } /** * Refusal for an agent session that has neither `KXM_AUTH_TOKEN` nor a saved project * token for its project. The fix is the operator's, so the message names it. */ export class AgentProjectTokenMissingError extends Error { readonly code = "project_token_missing"; readonly project: string; readonly projectSource: ProjectIdentity["source"] | "unknown"; readonly hint: string; constructor(projectOrIdentity: string | ProjectIdentity) { const identity: ProjectIdentity = typeof projectOrIdentity === "string" ? { project: projectOrIdentity, source: "env", sourceLabel: "the project id sent to the hub" } : projectOrIdentity; const message = projectTokenMissingMessage(identity); super(message); this.name = "AgentProjectTokenMissingError"; this.project = identity.project; this.projectSource = typeof projectOrIdentity === "string" ? "unknown" : identity.source; this.hint = message; } } /** * Admin-scoped hub reads (`/v1/ops/snapshot`, …) are rejected with 401 by a project token, * so this variant resolves the admin credential only: explicit `KXM_AUTH_TOKEN`, else the * persisted record's admin token. It never falls back to a project token the route would * refuse — handing the portal a 401 dressed as "configured" is worse than handing it none. * Throws on a malformed persisted record, same as `resolveClientHubAuthToken`. */ export function resolveClientAdminAuthToken( env: NodeJS.ProcessEnv = process.env, cwd: string = process.cwd(), opRead?: OpReader, ): string | undefined { const cloud = cloudModeToken(cwd, env, undefined, opRead); if (cloud) return cloud; const envToken = env.KXM_AUTH_TOKEN?.trim(); if (envToken) return envToken; return readHubEnvRecord(env)?.authToken?.trim() || undefined; } export { CloudTokenError };