@kontextmind/kxm 0.7.165 → 0.7.166
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +16 -0
- package/docs/concepts/trust-model.md +1 -1
- package/docs/glossary.md +2 -2
- package/docs/operations/troubleshooting.md +2 -2
- package/docs/operations.md +62 -8
- package/docs/reference/cli-reference.md +11 -11
- package/docs/reference/config-reference.md +36 -11
- package/docs/reference/configuration.md +2 -2
- package/docs/reference/tools.md +1 -1
- package/docs/start/quickstart-claude-code.md +2 -2
- package/package.json +1 -1
- package/plugins/kxm/.claude-plugin/plugin.json +1 -1
- package/plugins/kxm/README.md +3 -3
- package/plugins/kxm/dist/claude-hook.js +417 -66
- package/plugins/kxm/dist/cli.js +1399 -900
- package/plugins/kxm/dist/extension.js +399 -64
- package/plugins/kxm/dist/mcp-server.js +622 -135
- package/plugins/kxm/dist/runtime-supervisor.js +668 -166
- package/plugins/kxm/dist/runtime.js +713 -211
- package/plugins/kxm/dist/server.js +466 -58
- package/plugins/kxm/package.json +1 -1
- package/plugins/kxm/skills/kxm-hub-ops/SKILL.md +15 -7
- package/plugins/kxm/src/cli/hub.ts +80 -21
- package/plugins/kxm/src/cli/types.ts +8 -1
- package/plugins/kxm/src/cli.ts +41 -7
- package/plugins/kxm/src/config.ts +246 -3
- package/plugins/kxm/src/extension.ts +8 -7
- package/plugins/kxm/src/hub-autostart.ts +20 -0
- package/plugins/kxm/src/hub-env.ts +83 -31
- package/plugins/kxm/src/hub-identity.ts +337 -0
- package/plugins/kxm/src/mcp-server.ts +17 -15
- package/plugins/kxm/src/project-name.ts +6 -47
- package/plugins/kxm/src/server.ts +22 -4
- package/scripts/kxm-hub.mjs +5 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { homedir } from "node:os";
|
|
2
|
+
import { tmpdir, homedir } from "node:os";
|
|
3
3
|
import { dirname, join, resolve } from "node:path";
|
|
4
4
|
import { parse, stringify } from "yaml";
|
|
5
5
|
import { redactSecrets } from "./redact.ts";
|
|
@@ -47,10 +47,55 @@ export interface KxmSyncTrackerConfig {
|
|
|
47
47
|
|
|
48
48
|
export type KxmHubAutoStart = "off" | "background";
|
|
49
49
|
|
|
50
|
+
export type KxmHubMode = "local" | "cloud";
|
|
51
|
+
|
|
52
|
+
/** A secret reference. `op` is an `op://` reference resolved with `op read` at
|
|
53
|
+
* use time. `env` is an environment variable name. Neither field holds a token. */
|
|
54
|
+
export interface HubKeyRef {
|
|
55
|
+
op?: string;
|
|
56
|
+
env?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** One hub endpoint. `project` is the id presented to that hub. `key` is a
|
|
60
|
+
* reference, never a literal token. */
|
|
61
|
+
export interface HubEndpointConfig {
|
|
62
|
+
url?: string;
|
|
63
|
+
project?: string;
|
|
64
|
+
key?: HubKeyRef;
|
|
65
|
+
}
|
|
66
|
+
|
|
50
67
|
export interface KxmHubConfig {
|
|
51
68
|
/** Pi extension hub startup: `background` starts a detached hub when no
|
|
52
69
|
* healthy bound hub or live local claim exists; `off` never starts one. */
|
|
53
70
|
autoStart: KxmHubAutoStart;
|
|
71
|
+
/** Which endpoint is in effect. Unset means local unless a cloud binding says otherwise. */
|
|
72
|
+
mode?: KxmHubMode;
|
|
73
|
+
local?: HubEndpointConfig;
|
|
74
|
+
cloud?: HubEndpointConfig;
|
|
75
|
+
/** Hub-process project id to key reference. Resolved in memory; never persisted. */
|
|
76
|
+
projects?: Record<string, HubKeyRef>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A hub config value is a token, or a key reference that is not an `op://` ref or an env var name. */
|
|
80
|
+
export class KxmHubConfigError extends Error {
|
|
81
|
+
readonly code = "hub_config_invalid";
|
|
82
|
+
|
|
83
|
+
constructor(message: string) {
|
|
84
|
+
super(message);
|
|
85
|
+
this.name = "KxmHubConfigError";
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const HUB_ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/;
|
|
90
|
+
const HUB_OP_REF = /^op:\/\/\S+\/\S+\/\S+$/;
|
|
91
|
+
const LITERAL_SECRET_KEY = /^(token|secret|password|authToken|auth_token|apiKey|api_key|api_token|bearer|credential|access_token)$/i;
|
|
92
|
+
|
|
93
|
+
export function isHubKeyEnvName(value: string): boolean {
|
|
94
|
+
return HUB_ENV_NAME.test(value);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function isHubOpReference(value: string): boolean {
|
|
98
|
+
return HUB_OP_REF.test(value);
|
|
54
99
|
}
|
|
55
100
|
|
|
56
101
|
export type ImprovementPromotionPolicy = "manual_pr" | "critic_quorum" | "auto_threshold";
|
|
@@ -191,11 +236,174 @@ function deepMerge<T extends Record<string, unknown>>(target: T, source: Record<
|
|
|
191
236
|
return result as T;
|
|
192
237
|
}
|
|
193
238
|
|
|
239
|
+
function literalSecretMessage(path: string): string {
|
|
240
|
+
return `refusing literal hub token at ${path}: store an op:// reference (key.op) or an environment variable name (key.env), never the token`;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function rejectLiteralHubSecrets(value: unknown, path: string): void {
|
|
244
|
+
if (Array.isArray(value)) {
|
|
245
|
+
value.forEach((item, index) => rejectLiteralHubSecrets(item, `${path}[${index}]`));
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (!value || typeof value !== "object") return;
|
|
249
|
+
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
|
|
250
|
+
const childPath = `${path}.${key}`;
|
|
251
|
+
if (typeof child === "string" && (key === "key" || LITERAL_SECRET_KEY.test(key))) {
|
|
252
|
+
throw new KxmHubConfigError(literalSecretMessage(childPath));
|
|
253
|
+
}
|
|
254
|
+
if (child && typeof child === "object") rejectLiteralHubSecrets(child, childPath);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function parseKeyRef(raw: unknown, path: string): HubKeyRef | undefined {
|
|
259
|
+
if (raw === undefined) return undefined;
|
|
260
|
+
if (typeof raw === "string") throw new KxmHubConfigError(literalSecretMessage(path));
|
|
261
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
262
|
+
throw new KxmHubConfigError(`${path} must be a key reference with op and/or env, never a token`);
|
|
263
|
+
}
|
|
264
|
+
const row = raw as Record<string, unknown>;
|
|
265
|
+
for (const key of Object.keys(row)) {
|
|
266
|
+
if (key !== "op" && key !== "env") {
|
|
267
|
+
throw new KxmHubConfigError(literalSecretMessage(`${path}.${key}`));
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
const ref: HubKeyRef = {};
|
|
271
|
+
if (row.op !== undefined) {
|
|
272
|
+
if (typeof row.op !== "string" || !isHubOpReference(row.op)) {
|
|
273
|
+
throw new KxmHubConfigError(`${path}.op must be an op://vault/item/field reference, not a token`);
|
|
274
|
+
}
|
|
275
|
+
ref.op = row.op;
|
|
276
|
+
}
|
|
277
|
+
if (row.env !== undefined) {
|
|
278
|
+
if (typeof row.env !== "string" || !isHubKeyEnvName(row.env)) {
|
|
279
|
+
throw new KxmHubConfigError(`${path}.env must be an environment variable name, not a token`);
|
|
280
|
+
}
|
|
281
|
+
ref.env = row.env;
|
|
282
|
+
}
|
|
283
|
+
if (!ref.op && !ref.env) throw new KxmHubConfigError(`${path} needs key.op, key.env, or both`);
|
|
284
|
+
return ref;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function parseEndpoint(raw: unknown, path: string): HubEndpointConfig | undefined {
|
|
288
|
+
if (raw === undefined) return undefined;
|
|
289
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
290
|
+
throw new KxmHubConfigError(`${path} must be an object with url, project, and key`);
|
|
291
|
+
}
|
|
292
|
+
const row = raw as Record<string, unknown>;
|
|
293
|
+
const endpoint: HubEndpointConfig = {};
|
|
294
|
+
if (row.url !== undefined) {
|
|
295
|
+
if (typeof row.url !== "string" || !isHubUrl(row.url)) {
|
|
296
|
+
throw new KxmHubConfigError(`${path}.url must be an http or https URL without credentials, query, or fragment`);
|
|
297
|
+
}
|
|
298
|
+
endpoint.url = row.url.replace(/\/$/, "");
|
|
299
|
+
}
|
|
300
|
+
if (row.project !== undefined) {
|
|
301
|
+
if (typeof row.project !== "string" || !isHubProjectId(row.project)) {
|
|
302
|
+
throw new KxmHubConfigError(`${path}.project must be a single-line project id, not a token`);
|
|
303
|
+
}
|
|
304
|
+
endpoint.project = row.project.trim();
|
|
305
|
+
}
|
|
306
|
+
const key = parseKeyRef(row.key, `${path}.key`);
|
|
307
|
+
if (key) endpoint.key = key;
|
|
308
|
+
for (const name of Object.keys(row)) {
|
|
309
|
+
if (name !== "url" && name !== "project" && name !== "key") {
|
|
310
|
+
if (typeof row[name] === "string") throw new KxmHubConfigError(literalSecretMessage(`${path}.${name}`));
|
|
311
|
+
throw new KxmHubConfigError(`${path}.${name} is not a hub endpoint field`);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return endpoint;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function isHubUrl(raw: string): boolean {
|
|
318
|
+
try {
|
|
319
|
+
const parsed = new URL(raw);
|
|
320
|
+
return (parsed.protocol === "http:" || parsed.protocol === "https:")
|
|
321
|
+
&& parsed.username === ""
|
|
322
|
+
&& parsed.password === ""
|
|
323
|
+
&& parsed.search === ""
|
|
324
|
+
&& parsed.hash === ""
|
|
325
|
+
&& !raw.includes("?")
|
|
326
|
+
&& !raw.includes("#");
|
|
327
|
+
} catch {
|
|
328
|
+
return false;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function isHubProjectId(raw: string): boolean {
|
|
333
|
+
const value = raw.trim();
|
|
334
|
+
return value.length > 0 && value.length <= 200 && !/[\s\r\n]/.test(value);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function parseProjectMap(raw: unknown): Record<string, HubKeyRef> | undefined {
|
|
338
|
+
if (raw === undefined) return undefined;
|
|
339
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
340
|
+
throw new KxmHubConfigError("hub.projects must be a map of project id to key reference, never a token");
|
|
341
|
+
}
|
|
342
|
+
const projects: Record<string, HubKeyRef> = {};
|
|
343
|
+
for (const [project, value] of Object.entries(raw as Record<string, unknown>)) {
|
|
344
|
+
if (!isHubProjectId(project)) {
|
|
345
|
+
throw new KxmHubConfigError(`hub.projects key ${JSON.stringify(project)} must be a project id, not a token`);
|
|
346
|
+
}
|
|
347
|
+
const ref = parseKeyRef(value, `hub.projects.${project}`);
|
|
348
|
+
if (!ref) throw new KxmHubConfigError(`hub.projects.${project} needs an op:// reference or an environment variable name`);
|
|
349
|
+
projects[project.trim()] = ref;
|
|
350
|
+
}
|
|
351
|
+
return projects;
|
|
352
|
+
}
|
|
353
|
+
|
|
194
354
|
/** Unknown `hub.autoStart` values fail closed to the default so a config typo
|
|
195
|
-
* never silently changes hub startup behavior.
|
|
355
|
+
* never silently changes hub startup behavior. Literal tokens under `hub` are
|
|
356
|
+
* refused. Key references stay unresolved. */
|
|
196
357
|
function normalizeHubConfig(raw: unknown): KxmHubConfig {
|
|
197
358
|
const autoStart = (raw as { autoStart?: unknown } | undefined)?.autoStart;
|
|
198
|
-
|
|
359
|
+
const hub: KxmHubConfig = {
|
|
360
|
+
autoStart: autoStart === "off" || autoStart === "background" ? autoStart : DEFAULT_KXM_CONFIG.hub.autoStart,
|
|
361
|
+
};
|
|
362
|
+
if (raw === undefined) return hub;
|
|
363
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
364
|
+
throw new KxmHubConfigError("hub must be an object");
|
|
365
|
+
}
|
|
366
|
+
rejectLiteralHubSecrets(raw, "hub");
|
|
367
|
+
const row = raw as Record<string, unknown>;
|
|
368
|
+
if (row.mode !== undefined) {
|
|
369
|
+
if (row.mode !== "local" && row.mode !== "cloud") {
|
|
370
|
+
throw new KxmHubConfigError("hub.mode must be local or cloud");
|
|
371
|
+
}
|
|
372
|
+
hub.mode = row.mode;
|
|
373
|
+
}
|
|
374
|
+
const local = parseEndpoint(row.local, "hub.local");
|
|
375
|
+
const cloud = parseEndpoint(row.cloud, "hub.cloud");
|
|
376
|
+
const projects = parseProjectMap(row.projects);
|
|
377
|
+
if (local) hub.local = local;
|
|
378
|
+
if (cloud) hub.cloud = cloud;
|
|
379
|
+
if (projects && Object.keys(projects).length > 0) hub.projects = projects;
|
|
380
|
+
return hub;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** Directory that holds this checkout's `.kxm/` config: the nearest
|
|
384
|
+
* `project.yaml` or `config.yaml`, otherwise `start`. */
|
|
385
|
+
export function findConfigRoot(start: string): string {
|
|
386
|
+
let dir = resolve(start);
|
|
387
|
+
for (;;) {
|
|
388
|
+
if (existsSync(join(dir, ".kxm", "project.yaml")) || existsSync(join(dir, ".kxm", "config.yaml"))) return dir;
|
|
389
|
+
const parent = dirname(dir);
|
|
390
|
+
if (parent === dir) return resolve(start);
|
|
391
|
+
dir = parent;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** User config directory for this env. A partial env (tests, a command env
|
|
396
|
+
* that is not the process env) does not read `~/.config/kxm`. */
|
|
397
|
+
export function userConfigDirForEnv(env: NodeJS.ProcessEnv): string | undefined {
|
|
398
|
+
const explicit = env.KXM_USER_CONFIG_DIR?.trim();
|
|
399
|
+
if (explicit) return explicit;
|
|
400
|
+
if (env === process.env) return undefined;
|
|
401
|
+
return join(tmpdir(), "kxm-no-user-config");
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
export function loadHubSettings(start: string, env: NodeJS.ProcessEnv = process.env): KxmHubConfig {
|
|
405
|
+
const userConfigDir = userConfigDirForEnv(env);
|
|
406
|
+
return loadKxmConfig(findConfigRoot(start), userConfigDir === undefined ? {} : { userConfigDir }).hub;
|
|
199
407
|
}
|
|
200
408
|
|
|
201
409
|
function recordOf(raw: unknown): Record<string, unknown> {
|
|
@@ -352,10 +560,45 @@ export function setKxmConfigValue(
|
|
|
352
560
|
cursor = cursor[p] as Record<string, unknown>;
|
|
353
561
|
}
|
|
354
562
|
cursor[parts[parts.length - 1]!] = value;
|
|
563
|
+
if (parts[0] === "hub") normalizeHubConfig(existing.hub);
|
|
355
564
|
if (!options.dryRun) writeConfigFile(targetFile, existing);
|
|
356
565
|
return { file: targetFile };
|
|
357
566
|
}
|
|
358
567
|
|
|
568
|
+
/** Record the non-secret parts of a hub bind: mode, URL, project id, and key
|
|
569
|
+
* reference. The resolved token is never written. */
|
|
570
|
+
export function writeHubEndpoint(
|
|
571
|
+
start: string,
|
|
572
|
+
endpoint: { mode: KxmHubMode; url: string; project: string; key?: HubKeyRef },
|
|
573
|
+
): { file: string } {
|
|
574
|
+
if (!isHubUrl(endpoint.url)) {
|
|
575
|
+
throw new KxmHubConfigError("hub url must be an http or https URL without credentials, query, or fragment");
|
|
576
|
+
}
|
|
577
|
+
if (!isHubProjectId(endpoint.project)) {
|
|
578
|
+
throw new KxmHubConfigError("hub project id must be a single-line project id, not a token");
|
|
579
|
+
}
|
|
580
|
+
if (endpoint.key) parseKeyRef(endpoint.key, `hub.${endpoint.mode}.key`);
|
|
581
|
+
const root = findConfigRoot(start);
|
|
582
|
+
const file = join(root, ".kxm", "config.yaml");
|
|
583
|
+
const existing = readConfigFile(file);
|
|
584
|
+
const hub = recordOf(existing.hub);
|
|
585
|
+
const current = recordOf(hub[endpoint.mode]);
|
|
586
|
+
current.url = endpoint.url.replace(/\/$/, "");
|
|
587
|
+
current.project = endpoint.project.trim();
|
|
588
|
+
if (endpoint.key && (endpoint.key.op || endpoint.key.env)) {
|
|
589
|
+
const key = recordOf(current.key);
|
|
590
|
+
if (endpoint.key.op) key.op = endpoint.key.op;
|
|
591
|
+
if (endpoint.key.env) key.env = endpoint.key.env;
|
|
592
|
+
current.key = key;
|
|
593
|
+
}
|
|
594
|
+
hub[endpoint.mode] = current;
|
|
595
|
+
hub.mode = endpoint.mode;
|
|
596
|
+
existing.hub = hub;
|
|
597
|
+
normalizeHubConfig(hub);
|
|
598
|
+
writeConfigFile(file, existing);
|
|
599
|
+
return { file };
|
|
600
|
+
}
|
|
601
|
+
|
|
359
602
|
/**
|
|
360
603
|
* Remove a key so the next scope, then the built-in default, applies.
|
|
361
604
|
*
|
|
@@ -7,8 +7,8 @@ import { HubClient, HubHttpError } from "./client.ts";
|
|
|
7
7
|
import { loadKxmConfig } from "./config.ts";
|
|
8
8
|
import { ensureHubRunning, hubAutoStartMode } from "./hub-autostart.ts";
|
|
9
9
|
import { AgentProjectTokenMissingError, resolveAgentHubAuthToken } from "./hub-env.ts";
|
|
10
|
-
import { CloudTokenError
|
|
11
|
-
import {
|
|
10
|
+
import { CloudTokenError } from "./hub-binding.ts";
|
|
11
|
+
import { describeHubConnection, resolveProjectIdentity } from "./hub-identity.ts";
|
|
12
12
|
import { nousFactoryWork, type NousRegistrationReport } from "./nous-pi.ts";
|
|
13
13
|
import {
|
|
14
14
|
antigravityRegistrationNotice,
|
|
@@ -347,7 +347,7 @@ export default function piMeshExtension(pi: ExtensionAPI): void | Promise<void>
|
|
|
347
347
|
}
|
|
348
348
|
|
|
349
349
|
async function showKxmHub(ctx: { ui: { notify: (message: string, type: "info" | "warning" | "error") => void } }): Promise<void> {
|
|
350
|
-
const url =
|
|
350
|
+
const url = describeHubConnection(process.cwd(), process.env).url;
|
|
351
351
|
let health = "unreachable";
|
|
352
352
|
try {
|
|
353
353
|
const response = await fetch(`${url}/health`);
|
|
@@ -677,8 +677,9 @@ export default function piMeshExtension(pi: ExtensionAPI): void | Promise<void>
|
|
|
677
677
|
return;
|
|
678
678
|
}
|
|
679
679
|
currentPiSessionId = ctx.sessionManager?.getSessionId();
|
|
680
|
-
const
|
|
681
|
-
const
|
|
680
|
+
const identity = resolveProjectIdentity(ctx.cwd, process.env);
|
|
681
|
+
const serverUrl = describeHubConnection(ctx.cwd, process.env).url;
|
|
682
|
+
const project = identity.project;
|
|
682
683
|
const name = process.env.KXM_AGENT_NAME ?? pi.getSessionName() ?? `pi-${process.pid}`;
|
|
683
684
|
agentName = name;
|
|
684
685
|
projectName = project;
|
|
@@ -709,7 +710,7 @@ export default function piMeshExtension(pi: ExtensionAPI): void | Promise<void>
|
|
|
709
710
|
// stand in for a project token.
|
|
710
711
|
let hubAuthToken: string | undefined;
|
|
711
712
|
try {
|
|
712
|
-
hubAuthToken = resolveAgentHubAuthToken(process.env, project);
|
|
713
|
+
hubAuthToken = resolveAgentHubAuthToken(process.env, project, ctx.cwd);
|
|
713
714
|
} catch (error) {
|
|
714
715
|
const detail = error instanceof CloudTokenError
|
|
715
716
|
? error.message
|
|
@@ -719,7 +720,7 @@ export default function piMeshExtension(pi: ExtensionAPI): void | Promise<void>
|
|
|
719
720
|
return;
|
|
720
721
|
}
|
|
721
722
|
if (!hubAuthToken) {
|
|
722
|
-
ctx.ui.notify(new AgentProjectTokenMissingError(
|
|
723
|
+
ctx.ui.notify(new AgentProjectTokenMissingError(identity).message, "error");
|
|
723
724
|
await applySessionChrome(ctx, event, true);
|
|
724
725
|
return;
|
|
725
726
|
}
|
|
@@ -2,6 +2,7 @@ import { spawn } from "node:child_process";
|
|
|
2
2
|
import { closeSync, mkdirSync, openSync, readFileSync } from "node:fs";
|
|
3
3
|
import { join, resolve } from "node:path";
|
|
4
4
|
import { probeHubHealth, readHubBinding } from "./hub-binding.ts";
|
|
5
|
+
import { describeHubConnection } from "./hub-identity.ts";
|
|
5
6
|
import { resolveHubCredentials } from "./hub-env.ts";
|
|
6
7
|
import { findKxmRepoRoot } from "./repo-root.ts";
|
|
7
8
|
import { redactSecrets } from "./redact.ts";
|
|
@@ -138,6 +139,25 @@ export async function ensureHubRunning(options: EnsureHubOptions): Promise<Ensur
|
|
|
138
139
|
if (binding.cloud) return { status: "failed", reason: "cloud_hub_unreachable", logPath: "" };
|
|
139
140
|
}
|
|
140
141
|
|
|
142
|
+
// A cloud endpoint in config is the same rule when no cloud binding is on disk:
|
|
143
|
+
// a dead forward must not start a local hub.
|
|
144
|
+
if (!binding?.cloud) {
|
|
145
|
+
try {
|
|
146
|
+
const connection = describeHubConnection(options.cwd, env);
|
|
147
|
+
if (connection.mode === "cloud") {
|
|
148
|
+
const { health } = await probeHubHealth(connection.url, fetchImpl);
|
|
149
|
+
if (health === "on") return { status: "bound-healthy", url: connection.url };
|
|
150
|
+
return { status: "failed", reason: "cloud_hub_unreachable", logPath: "" };
|
|
151
|
+
}
|
|
152
|
+
} catch (error) {
|
|
153
|
+
return {
|
|
154
|
+
status: "failed",
|
|
155
|
+
reason: error instanceof Error ? error.message : "hub_config_invalid",
|
|
156
|
+
logPath: "",
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
141
161
|
// 2. A live local claim means a hub (started any way) already owns this
|
|
142
162
|
// workspace state — do not start a second one.
|
|
143
163
|
const workdir = resolve(env.KXM_WORKDIR?.trim() || options.cwd);
|
|
@@ -2,6 +2,14 @@ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync
|
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { randomBytes } from "node:crypto";
|
|
4
4
|
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
5
|
+
import { loadHubSettings } from "./config.ts";
|
|
6
|
+
import {
|
|
7
|
+
describeHubConnection,
|
|
8
|
+
projectTokenMissingMessage,
|
|
9
|
+
resolveKeyReference,
|
|
10
|
+
type OpReader,
|
|
11
|
+
type ProjectIdentity,
|
|
12
|
+
} from "./hub-identity.ts";
|
|
5
13
|
import {
|
|
6
14
|
CloudTokenError,
|
|
7
15
|
HubBindingError,
|
|
@@ -10,7 +18,6 @@ import {
|
|
|
10
18
|
resolveCloudHubToken,
|
|
11
19
|
type HubBindingRecord,
|
|
12
20
|
} from "./hub-binding.ts";
|
|
13
|
-
import { defaultProjectName } from "./project-name.ts";
|
|
14
21
|
|
|
15
22
|
export const HUB_ENV_SCHEMA = "kxm.hub-env.v1" as const;
|
|
16
23
|
|
|
@@ -239,9 +246,13 @@ function activeCloudBinding(env: NodeJS.ProcessEnv): HubBindingRecord | undefine
|
|
|
239
246
|
* Non-cloud keeps the historical rule: only the explicit `KXM_AUTH_TOKEN`, never hub-env.
|
|
240
247
|
* A cloud binding uses its named source and nothing else.
|
|
241
248
|
*/
|
|
242
|
-
export function resolveControlPlaneAuthToken(
|
|
243
|
-
|
|
244
|
-
|
|
249
|
+
export function resolveControlPlaneAuthToken(
|
|
250
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
251
|
+
cwd: string = process.cwd(),
|
|
252
|
+
opRead?: OpReader,
|
|
253
|
+
): string | undefined {
|
|
254
|
+
const cloud = cloudModeToken(cwd, env, undefined, opRead);
|
|
255
|
+
if (cloud) return cloud;
|
|
245
256
|
return env.KXM_AUTH_TOKEN?.trim() || undefined;
|
|
246
257
|
}
|
|
247
258
|
|
|
@@ -250,29 +261,46 @@ export function resolveControlPlaneAuthToken(env: NodeJS.ProcessEnv = process.en
|
|
|
250
261
|
* The project id comes from the shared resolver. A cloud binding adds
|
|
251
262
|
* `KXM_AUTH_TOKEN` in this object only — it is never written to the binding.
|
|
252
263
|
*/
|
|
264
|
+
function cloudModeToken(
|
|
265
|
+
cwd: string,
|
|
266
|
+
env: NodeJS.ProcessEnv,
|
|
267
|
+
explicitProject?: string,
|
|
268
|
+
opRead?: OpReader,
|
|
269
|
+
): string | undefined {
|
|
270
|
+
const connection = describeHubConnection(cwd, env, explicitProject === undefined ? {} : { explicitProject });
|
|
271
|
+
if (connection.mode !== "cloud") return undefined;
|
|
272
|
+
const fromConfig = resolveKeyReference(connection.endpoint?.key, env, opRead);
|
|
273
|
+
if (fromConfig) return fromConfig.value;
|
|
274
|
+
if (connection.binding?.cloud) return resolveCloudHubToken(connection.binding, env);
|
|
275
|
+
throw new CloudTokenError(
|
|
276
|
+
"cloud_token_missing",
|
|
277
|
+
"cloud hub token is missing; the local hub-env token was not used",
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
253
281
|
export function agentSessionEnv(
|
|
254
282
|
cwd: string,
|
|
255
283
|
env: NodeJS.ProcessEnv = process.env,
|
|
256
284
|
explicitProject?: string,
|
|
285
|
+
opRead?: OpReader,
|
|
257
286
|
): NodeJS.ProcessEnv {
|
|
258
|
-
const
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
if (
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
} catch (error) {
|
|
265
|
-
if (!(error instanceof HubBindingError)) throw error;
|
|
266
|
-
}
|
|
287
|
+
const connection = describeHubConnection(cwd, env, explicitProject === undefined ? {} : { explicitProject });
|
|
288
|
+
const session: NodeJS.ProcessEnv = { KXM_PROJECT: connection.project };
|
|
289
|
+
if (connection.url) session.KXM_SERVER_URL = connection.url.replace(/\/$/, "");
|
|
290
|
+
if (connection.mode === "cloud") {
|
|
291
|
+
const token = cloudModeToken(cwd, env, explicitProject, opRead);
|
|
292
|
+
if (token) session.KXM_AUTH_TOKEN = token;
|
|
267
293
|
}
|
|
268
|
-
const session: NodeJS.ProcessEnv = { KXM_PROJECT: project };
|
|
269
|
-
if (serverUrl) session.KXM_SERVER_URL = serverUrl.replace(/\/$/, "");
|
|
270
|
-
if (cloud) session.KXM_AUTH_TOKEN = resolveCloudHubToken(cloud, env);
|
|
271
294
|
return session;
|
|
272
295
|
}
|
|
273
296
|
|
|
274
|
-
export function hasClientHubCredential(env: NodeJS.ProcessEnv = process.env, project?: string): boolean {
|
|
297
|
+
export function hasClientHubCredential(env: NodeJS.ProcessEnv = process.env, project?: string, cwd: string = process.cwd()): boolean {
|
|
275
298
|
if (env.KXM_AUTH_TOKEN?.trim()) return true;
|
|
299
|
+
const settings = loadHubSettings(cwd, env);
|
|
300
|
+
const mode = settings.mode ?? (activeCloudBinding(env) ? "cloud" : "local");
|
|
301
|
+
const key = (mode === "cloud" ? settings.cloud : settings.local)?.key;
|
|
302
|
+
if (key?.op) return true;
|
|
303
|
+
if (key?.env && env[key.env]?.trim()) return true;
|
|
276
304
|
let record: HubEnvRecord | undefined;
|
|
277
305
|
try {
|
|
278
306
|
record = readHubEnvRecord(env);
|
|
@@ -289,11 +317,18 @@ export function hasClientHubCredential(env: NodeJS.ProcessEnv = process.env, pro
|
|
|
289
317
|
return Object.values(tokens).some((token) => typeof token === "string" && token.trim().length > 0);
|
|
290
318
|
}
|
|
291
319
|
|
|
292
|
-
export function resolveClientHubAuthToken(
|
|
293
|
-
|
|
294
|
-
|
|
320
|
+
export function resolveClientHubAuthToken(
|
|
321
|
+
env: NodeJS.ProcessEnv,
|
|
322
|
+
project: string,
|
|
323
|
+
cwd: string = process.cwd(),
|
|
324
|
+
opRead?: OpReader,
|
|
325
|
+
): string | undefined {
|
|
326
|
+
const cloud = cloudModeToken(cwd, env, undefined, opRead);
|
|
327
|
+
if (cloud) return cloud;
|
|
295
328
|
const envToken = env.KXM_AUTH_TOKEN?.trim();
|
|
296
329
|
if (envToken) return envToken;
|
|
330
|
+
const fromConfig = resolveKeyReference(describeHubConnection(cwd, env).endpoint?.key, env, opRead);
|
|
331
|
+
if (fromConfig) return fromConfig.value;
|
|
297
332
|
const record = readHubEnvRecord(env);
|
|
298
333
|
return record?.projectTokens?.[project]?.trim() || record?.authToken?.trim() || undefined;
|
|
299
334
|
}
|
|
@@ -307,13 +342,20 @@ export function resolveClientHubAuthToken(env: NodeJS.ProcessEnv, project: strin
|
|
|
307
342
|
* token for. Operator tools keep `resolveClientHubAuthToken`. Throws HubEnvError on a
|
|
308
343
|
* malformed persisted record, same as `resolveClientHubAuthToken`.
|
|
309
344
|
*/
|
|
310
|
-
export function resolveAgentHubAuthToken(
|
|
311
|
-
|
|
345
|
+
export function resolveAgentHubAuthToken(
|
|
346
|
+
env: NodeJS.ProcessEnv,
|
|
347
|
+
project: string,
|
|
348
|
+
cwd: string = process.cwd(),
|
|
349
|
+
opRead?: OpReader,
|
|
350
|
+
): string | undefined {
|
|
351
|
+
const cloud = cloudModeToken(cwd, env, undefined, opRead);
|
|
312
352
|
// A cloud binding's token is the remote hub's credential. It is not this
|
|
313
353
|
// machine's hub-env admin token, which an agent must still never use.
|
|
314
|
-
if (cloud) return
|
|
354
|
+
if (cloud) return cloud;
|
|
315
355
|
const envToken = env.KXM_AUTH_TOKEN?.trim();
|
|
316
356
|
if (envToken) return envToken;
|
|
357
|
+
const fromConfig = resolveKeyReference(describeHubConnection(cwd, env).endpoint?.key, env, opRead);
|
|
358
|
+
if (fromConfig) return fromConfig.value;
|
|
317
359
|
const tokens = readHubEnvRecord(env)?.projectTokens;
|
|
318
360
|
if (!tokens || !Object.hasOwn(tokens, project)) return undefined;
|
|
319
361
|
return tokens[project]?.trim() || undefined;
|
|
@@ -326,13 +368,19 @@ export function resolveAgentHubAuthToken(env: NodeJS.ProcessEnv, project: string
|
|
|
326
368
|
export class AgentProjectTokenMissingError extends Error {
|
|
327
369
|
readonly code = "project_token_missing";
|
|
328
370
|
readonly project: string;
|
|
371
|
+
readonly projectSource: ProjectIdentity["source"] | "unknown";
|
|
372
|
+
readonly hint: string;
|
|
329
373
|
|
|
330
|
-
constructor(
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
374
|
+
constructor(projectOrIdentity: string | ProjectIdentity) {
|
|
375
|
+
const identity: ProjectIdentity = typeof projectOrIdentity === "string"
|
|
376
|
+
? { project: projectOrIdentity, source: "env", sourceLabel: "the project id sent to the hub" }
|
|
377
|
+
: projectOrIdentity;
|
|
378
|
+
const message = projectTokenMissingMessage(identity);
|
|
379
|
+
super(message);
|
|
334
380
|
this.name = "AgentProjectTokenMissingError";
|
|
335
|
-
this.project = project;
|
|
381
|
+
this.project = identity.project;
|
|
382
|
+
this.projectSource = typeof projectOrIdentity === "string" ? "unknown" : identity.source;
|
|
383
|
+
this.hint = message;
|
|
336
384
|
}
|
|
337
385
|
}
|
|
338
386
|
|
|
@@ -343,9 +391,13 @@ export class AgentProjectTokenMissingError extends Error {
|
|
|
343
391
|
* refuse — handing the portal a 401 dressed as "configured" is worse than handing it none.
|
|
344
392
|
* Throws on a malformed persisted record, same as `resolveClientHubAuthToken`.
|
|
345
393
|
*/
|
|
346
|
-
export function resolveClientAdminAuthToken(
|
|
347
|
-
|
|
348
|
-
|
|
394
|
+
export function resolveClientAdminAuthToken(
|
|
395
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
396
|
+
cwd: string = process.cwd(),
|
|
397
|
+
opRead?: OpReader,
|
|
398
|
+
): string | undefined {
|
|
399
|
+
const cloud = cloudModeToken(cwd, env, undefined, opRead);
|
|
400
|
+
if (cloud) return cloud;
|
|
349
401
|
const envToken = env.KXM_AUTH_TOKEN?.trim();
|
|
350
402
|
if (envToken) return envToken;
|
|
351
403
|
return readHubEnvRecord(env)?.authToken?.trim() || undefined;
|