UNPKG

@kontextmind/kxm

Version:

KXM local-first multi-agent orchestration and operator dashboard

346 lines (323 loc) • 12.4 kB
import { spawnSync } from "node:child_process"; import { existsSync, readFileSync } from "node:fs"; import { basename, dirname, join, resolve } from "node:path"; import { parse as parseYaml } from "yaml"; import { type HubEndpointConfig, type HubKeyRef, type KxmHubMode, isHubOpReference, loadHubSettings, } from "./config.ts"; import { CloudTokenError, type HubBindingRecord, type HubBindingScope, HubBindingError, hubBindingScope, readHubBinding, splitTokenCommand, validateHubUrl, } from "./hub-binding.ts"; /** Where the project id presented to the hub came from. */ export type ProjectIdentitySource = "flag" | "env" | "config" | "project.yaml" | "package.json" | "directory"; export interface ProjectIdentity { project: string; source: ProjectIdentitySource; /** Safe to print. Names the file or variable, never a secret. */ sourceLabel: string; } export type OpReader = (reference: string, env: NodeJS.ProcessEnv) => string; export interface ResolvedKey { value: string; /** `env:<name>` or `op:<op://...>`. Never the token. */ source: string; } export interface HubConnection { mode: KxmHubMode; modeSource: "config" | "binding" | "default"; url: string; urlSource: "env" | "config" | "binding" | "default"; scope: HubBindingScope; endpoint?: HubEndpointConfig; binding?: HubBindingRecord; /** Source that would supply the key. Never the key. */ keySource: string; } const OP_TIMEOUT_MS = 15_000; const OP_MAX_BUFFER = 4096; function normalizeUrl(raw: string): string | undefined { try { return validateHubUrl(raw); } catch { return undefined; } } function projectYamlId(start: string): string | undefined { let dir = resolve(start); for (;;) { const file = join(dir, ".kxm", "project.yaml"); if (existsSync(file)) { try { const doc = parseYaml(readFileSync(file, "utf8")) as { id?: unknown } | null; if (doc && typeof doc === "object" && !Array.isArray(doc) && typeof doc.id === "string" && doc.id.trim()) { return doc.id.trim(); } } catch { // Malformed YAML falls through to package.json of the original directory. } return undefined; } const parent = dirname(dir); if (parent === dir) return undefined; dir = parent; } } function packageName(cwd: string): string | undefined { try { const pkg = JSON.parse(readFileSync(join(cwd, "package.json"), "utf8")) as { name?: unknown }; if (typeof pkg.name === "string" && pkg.name.trim().length > 0) return pkg.name.trim(); } catch { // No readable package.json: fall through to the directory name. } return undefined; } function activeBinding(env: NodeJS.ProcessEnv): HubBindingRecord | undefined { try { return readHubBinding(env); } catch (error) { if (error instanceof HubBindingError) return undefined; throw error; } } function endpointFor(mode: KxmHubMode, settings: ReturnType<typeof loadHubSettings>): HubEndpointConfig | undefined { return mode === "cloud" ? settings.cloud : settings.local; } /** * Project id presented to the hub. * * Order: flag, then `KXM_PROJECT`, then the active mode's `project` in * `.kxm/config.yaml`, then the `id` in the nearest `.kxm/project.yaml`, then * the workspace `package.json` name, then the directory name. */ export function resolveProjectIdentity( cwd: string, env: NodeJS.ProcessEnv = process.env, explicit?: string, ): ProjectIdentity { const fromExplicit = explicit?.trim(); if (fromExplicit) return { project: fromExplicit, source: "flag", sourceLabel: "--project" }; const fromEnv = env.KXM_PROJECT?.trim(); if (fromEnv) return { project: fromEnv, source: "env", sourceLabel: "KXM_PROJECT" }; const settings = loadHubSettings(cwd, env); const mode = settings.mode ?? (activeBinding(env)?.cloud ? "cloud" : "local"); const fromConfig = endpointFor(mode, settings)?.project?.trim(); if (fromConfig) { return { project: fromConfig, source: "config", sourceLabel: `.kxm/config.yaml hub.${mode}.project` }; } const fromProject = projectYamlId(cwd); if (fromProject) return { project: fromProject, source: "project.yaml", sourceLabel: ".kxm/project.yaml id" }; const fromPackage = packageName(cwd); if (fromPackage) return { project: fromPackage, source: "package.json", sourceLabel: "package.json name" }; return { project: basename(cwd), source: "directory", sourceLabel: "directory name" }; } /** Same order as {@link resolveProjectIdentity}. One function, string form. */ export function defaultProjectName(cwd: string, env: NodeJS.ProcessEnv = process.env, explicit?: string): string { return resolveProjectIdentity(cwd, env, explicit).project; } export function opReferenceFromTokenCommand(command: string): string | undefined { try { const args = splitTokenCommand(command); if (args[0] === "op" && args[1] === "read" && args[2] && isHubOpReference(args[2])) return args[2]; } catch { return undefined; } return undefined; } function defaultOpRead(reference: string, env: NodeJS.ProcessEnv): string { if (!isHubOpReference(reference)) { throw new CloudTokenError("cloud_token_command_invalid", `refusing to resolve ${reference}; an op:// reference is required`); } const result = spawnSync("op", ["read", reference], { encoding: "utf8", shell: false, timeout: OP_TIMEOUT_MS, maxBuffer: OP_MAX_BUFFER, env, windowsHide: true, }); if (result.error || result.status !== 0) { throw new CloudTokenError( "cloud_token_command_failed", `op read failed for ${reference}; the token was not saved`, ); } const line = (result.stdout ?? "").trim(); if (!line || line.split(/\r?\n/).length !== 1) { throw new CloudTokenError( "cloud_token_command_failed", `op read did not print a single token for ${reference}; the token was not saved`, ); } return line; } /** * Resolve a key reference. A non-empty env var wins; otherwise `op read` runs. * The value is returned to the caller and never written. */ export function resolveKeyReference( ref: HubKeyRef | undefined, env: NodeJS.ProcessEnv, opRead: OpReader = defaultOpRead, ): ResolvedKey | undefined { if (!ref) return undefined; if (ref.env) { const value = env[ref.env]?.trim(); if (value) return { value, source: `env:${ref.env}` }; } if (ref.op) { const value = opRead(ref.op, env).trim(); if (!value || value.split(/\r?\n/).length !== 1) { throw new CloudTokenError("cloud_token_command_failed", `op read did not print a single token for ${ref.op}`); } return { value, source: `op:${ref.op}` }; } return undefined; } function keySourceFor(input: { mode: KxmHubMode; env: NodeJS.ProcessEnv; endpoint?: HubEndpointConfig; binding?: HubBindingRecord; project: string; hasSavedProjectToken?: (project: string) => boolean; }): string { const ref = input.endpoint?.key; const envName = (name: string | undefined): string | undefined => { if (!name) return undefined; return input.env[name]?.trim() ? `env:${name}` : undefined; }; // Name the source that would actually supply a token. An unset variable is // not that source, even when config still lists it. if (input.mode === "cloud") { const fromEnv = envName(ref?.env); if (fromEnv) return fromEnv; if (ref?.op) return `op:${ref.op}`; const fromBindingEnv = envName(input.binding?.tokenEnv); if (fromBindingEnv) return fromBindingEnv; if (input.binding?.tokenCommand) return "token-command"; return "missing"; } const fromAuth = envName("KXM_AUTH_TOKEN"); if (fromAuth) return fromAuth; const fromEnv = envName(ref?.env); if (fromEnv) return fromEnv; if (ref?.op) return `op:${ref.op}`; if (input.hasSavedProjectToken?.(input.project)) return "hub-env"; return "missing"; } /** * Mode, URL, and key source for this checkout. The key source names an env var * or an `op://` reference. It is never the token. */ export function describeHubConnection( cwd: string, env: NodeJS.ProcessEnv = process.env, hooks: { hasSavedProjectToken?: (project: string) => boolean; explicitProject?: string } = {}, ): HubConnection & ProjectIdentity { const settings = loadHubSettings(cwd, env); const binding = activeBinding(env); const mode: KxmHubMode = settings.mode ?? (binding?.cloud ? "cloud" : "local"); const modeSource: HubConnection["modeSource"] = settings.mode ? "config" : binding?.cloud ? "binding" : "default"; const endpoint = endpointFor(mode, settings); const fromEnv = env.KXM_SERVER_URL?.trim(); const fromConfig = endpoint?.url?.trim(); const fromBinding = binding?.url; let url = "http://127.0.0.1:7331"; let urlSource: HubConnection["urlSource"] = "default"; if (fromEnv) { url = fromEnv.replace(/\/$/, ""); urlSource = "env"; } else if (fromConfig) { url = fromConfig.replace(/\/$/, ""); urlSource = "config"; } else if (fromBinding) { url = fromBinding; urlSource = "binding"; } const identity = resolveProjectIdentity(cwd, env, hooks.explicitProject); const configuredCloudUrl = settings.cloud?.url ?? (binding?.cloud ? binding.url : undefined); let scope: HubBindingScope; if (mode === "cloud") { const override = fromEnv ? normalizeUrl(fromEnv) : undefined; const named = configuredCloudUrl ? normalizeUrl(configuredCloudUrl) : undefined; scope = override && named && override !== named ? hubBindingScope(url) : "remote"; } else { scope = hubBindingScope(url); } return { ...identity, mode, modeSource, url, urlSource, scope, ...(endpoint ? { endpoint } : {}), ...(binding ? { binding } : {}), keySource: keySourceFor({ mode, env, ...(endpoint ? { endpoint } : {}), ...(binding ? { binding } : {}), project: identity.project, ...(hooks.hasSavedProjectToken ? { hasSavedProjectToken: hooks.hasSavedProjectToken } : {}), }), }; } export function projectTokenMissingMessage(identity: ProjectIdentity): string { return [ `kxm has no project token for project ${identity.project} (from ${identity.sourceLabel}).`, `Ask the user to set this project's token. Set KXM_AUTH_TOKEN, or set hub.local.project or hub.cloud.project in .kxm/config.yaml to the project key the hub already knows, or add a key reference under hub.projects.${identity.project} (key.op is an op:// reference, key.env is an environment variable name, never the token) and restart the hub.`, "KXM_PROJECT or --project overrides the id for one command.", "An agent never uses the hub admin token.", ].join(" "); } export interface HubProjectTokenSelection { tokens: Record<string, string> | undefined; source: "env" | "config" | "file" | "none"; } /** `KXM_PROJECT_TOKENS` replaces config, which replaces the saved file map. */ export function selectHubProjectTokenMap(input: { explicit: Record<string, string> | undefined; configured: Record<string, string> | undefined; file: Record<string, string> | undefined; }): HubProjectTokenSelection { if (input.explicit) return { tokens: input.explicit, source: "env" }; if (input.configured && Object.keys(input.configured).length > 0) return { tokens: input.configured, source: "config" }; if (input.file && Object.keys(input.file).length > 0) return { tokens: input.file, source: "file" }; return { tokens: undefined, source: "none" }; } /** * Resolve `hub.projects` key references. Returns undefined when the map is * absent. Does not write the resolved tokens. */ export function resolveConfiguredHubProjectTokens( cwd: string, env: NodeJS.ProcessEnv = process.env, opRead: OpReader = defaultOpRead, ): Record<string, string> | undefined { const projects = loadHubSettings(cwd, env).projects; if (!projects || Object.keys(projects).length === 0) return undefined; const tokens: Record<string, string> = {}; for (const [project, ref] of Object.entries(projects)) { const resolved = resolveKeyReference(ref, env, opRead); if (!resolved?.value) { throw new CloudTokenError( "cloud_token_missing", `hub.projects.${project} did not resolve; set ${ref.env ?? "the named variable"} or fix the op:// reference. The token was not saved`, ); } tokens[project] = resolved.value; } return tokens; }