@kontextmind/kxm
Version:
KXM local-first multi-agent orchestration and operator dashboard
407 lines (376 loc) • 16.7 kB
text/typescript
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 };