@kontextmind/kxm 0.7.164 → 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 +25 -0
- package/README.md +2 -0
- package/docs/README.md +1 -0
- package/docs/architecture/inventory.md +4 -3
- package/docs/concepts/trust-model.md +1 -1
- package/docs/glossary.md +4 -4
- package/docs/operations/troubleshooting.md +4 -1
- package/docs/operations.md +208 -0
- package/docs/reference/cli-reference.md +12 -11
- package/docs/reference/config-reference.md +36 -11
- package/docs/reference/configuration.md +11 -1
- 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 +2 -2
- package/plugins/kxm/README.md +4 -4
- package/plugins/kxm/dist/claude-hook.js +511 -56
- package/plugins/kxm/dist/cli.js +15661 -14775
- package/plugins/kxm/dist/extension.js +645 -133
- package/plugins/kxm/dist/mcp-server.js +8152 -81
- package/plugins/kxm/dist/runtime-supervisor.js +804 -155
- package/plugins/kxm/dist/runtime.js +851 -202
- package/plugins/kxm/dist/server.js +466 -58
- package/plugins/kxm/package.json +1 -1
- package/plugins/kxm/skills/kxm-hub-ops/SKILL.md +17 -5
- package/plugins/kxm/src/cli/context-skills.ts +64 -10
- package/plugins/kxm/src/cli/hub.ts +192 -22
- package/plugins/kxm/src/cli/project.ts +7 -3
- package/plugins/kxm/src/cli/types.ts +14 -1
- package/plugins/kxm/src/cli/workflows.ts +12 -1
- package/plugins/kxm/src/cli.ts +63 -11
- package/plugins/kxm/src/config.ts +246 -3
- package/plugins/kxm/src/extension.ts +12 -7
- package/plugins/kxm/src/hub-autostart.ts +23 -0
- package/plugins/kxm/src/hub-binding.ts +182 -2
- package/plugins/kxm/src/hub-env.ts +132 -9
- package/plugins/kxm/src/hub-identity.ts +337 -0
- package/plugins/kxm/src/mcp-server.ts +17 -14
- package/plugins/kxm/src/project-name.ts +6 -20
- package/plugins/kxm/src/runtime-supervisor.ts +9 -3
- package/plugins/kxm/src/server.ts +22 -4
- package/plugins/kxm/src/tenant-status.ts +9 -2
- package/scripts/build-runtime.mjs +5 -0
- package/scripts/kxm-hub.mjs +5 -0
package/plugins/kxm/src/cli.ts
CHANGED
|
@@ -17,9 +17,11 @@ import { fileURLToPath } from "node:url";
|
|
|
17
17
|
import { Command, CommanderError } from "commander";
|
|
18
18
|
import { readInstalledKxmVersion } from "./kxm-update.ts";
|
|
19
19
|
import { findKxmRepoRoot } from "./repo-root.ts";
|
|
20
|
-
import { HubClient } from "./client.ts";
|
|
20
|
+
import { HubClient, HubHttpError } from "./client.ts";
|
|
21
21
|
import { AgentProjectTokenMissingError, resolveAgentHubAuthToken } from "./hub-env.ts";
|
|
22
|
-
import {
|
|
22
|
+
import { CloudTokenError } from "./hub-binding.ts";
|
|
23
|
+
import { KxmHubConfigError } from "./config.ts";
|
|
24
|
+
import { resolveProjectIdentity } from "./hub-identity.ts";
|
|
23
25
|
import {
|
|
24
26
|
AGENT_COMMANDS_MAP,
|
|
25
27
|
enforceToolPolicy,
|
|
@@ -255,13 +257,14 @@ function addGlobalOptions(command: Command): Command {
|
|
|
255
257
|
|
|
256
258
|
async function ensureCliClient(runtime: Runtime): Promise<HubClient> {
|
|
257
259
|
const serverUrl = runtime.serverUrl;
|
|
258
|
-
const
|
|
260
|
+
const identity = resolveProjectIdentity(runtime.cwd, runtime.env);
|
|
261
|
+
const project = identity.project;
|
|
259
262
|
const name = runtime.env.KXM_AGENT_NAME?.trim() || `cli-${process.pid}`;
|
|
260
263
|
const purpose = runtime.env.KXM_AGENT_PURPOSE?.trim() || "CLI agent client";
|
|
261
264
|
// These commands act as a peer agent, so they take the agent credential: never the
|
|
262
265
|
// persisted admin token, which the hub accepts for any project without its own token.
|
|
263
|
-
const authToken = resolveAgentHubAuthToken(runtime.env, project);
|
|
264
|
-
if (!authToken) throw new AgentProjectTokenMissingError(
|
|
266
|
+
const authToken = resolveAgentHubAuthToken(runtime.env, project, runtime.cwd);
|
|
267
|
+
if (!authToken) throw new AgentProjectTokenMissingError(identity);
|
|
265
268
|
const client = new HubClient({
|
|
266
269
|
serverUrl,
|
|
267
270
|
name,
|
|
@@ -310,6 +313,9 @@ async function dispatchAgentCliCommand(
|
|
|
310
313
|
return 2;
|
|
311
314
|
}
|
|
312
315
|
|
|
316
|
+
const explicitProject = typeof rawArgs.project === "string" ? rawArgs.project.trim() : "";
|
|
317
|
+
if (explicitProject) runtime = { ...runtime, env: { ...runtime.env, KXM_PROJECT: explicitProject } };
|
|
318
|
+
|
|
313
319
|
let args: Record<string, unknown> = {};
|
|
314
320
|
if (typeof rawArgs.payload === "string" && rawArgs.payload.trim()) {
|
|
315
321
|
try {
|
|
@@ -324,7 +330,7 @@ async function dispatchAgentCliCommand(
|
|
|
324
330
|
}
|
|
325
331
|
|
|
326
332
|
for (const [key, val] of Object.entries(rawArgs)) {
|
|
327
|
-
if (val !== undefined && key !== "payload") {
|
|
333
|
+
if (val !== undefined && key !== "payload" && key !== "project") {
|
|
328
334
|
if (key === "timeoutMs" || key === "ttlMs" || key === "attempt") {
|
|
329
335
|
args[key] = Number(val);
|
|
330
336
|
} else if (key === "targets" && typeof val === "string") {
|
|
@@ -383,7 +389,43 @@ async function dispatchAgentCliCommand(
|
|
|
383
389
|
print(
|
|
384
390
|
runtime.io,
|
|
385
391
|
runtime.json,
|
|
386
|
-
{
|
|
392
|
+
{
|
|
393
|
+
ok: false,
|
|
394
|
+
error: error.code,
|
|
395
|
+
project: error.project,
|
|
396
|
+
projectSource: error.projectSource,
|
|
397
|
+
nextAction: "configure_hub_project",
|
|
398
|
+
hint: error.hint,
|
|
399
|
+
detail: message,
|
|
400
|
+
},
|
|
401
|
+
message,
|
|
402
|
+
);
|
|
403
|
+
return 2;
|
|
404
|
+
}
|
|
405
|
+
if (error instanceof HubHttpError && error.code === "project_token_missing") {
|
|
406
|
+
const identity = resolveProjectIdentity(runtime.cwd, runtime.env);
|
|
407
|
+
const missing = new AgentProjectTokenMissingError(identity);
|
|
408
|
+
print(
|
|
409
|
+
runtime.io,
|
|
410
|
+
runtime.json,
|
|
411
|
+
{
|
|
412
|
+
ok: false,
|
|
413
|
+
error: missing.code,
|
|
414
|
+
project: identity.project,
|
|
415
|
+
projectSource: identity.source,
|
|
416
|
+
nextAction: "configure_hub_project",
|
|
417
|
+
hint: missing.hint,
|
|
418
|
+
detail: missing.message,
|
|
419
|
+
},
|
|
420
|
+
missing.message,
|
|
421
|
+
);
|
|
422
|
+
return 2;
|
|
423
|
+
}
|
|
424
|
+
if (error instanceof CloudTokenError) {
|
|
425
|
+
print(
|
|
426
|
+
runtime.io,
|
|
427
|
+
runtime.json,
|
|
428
|
+
{ ok: false, error: error.code, detail: message },
|
|
387
429
|
message,
|
|
388
430
|
);
|
|
389
431
|
return 2;
|
|
@@ -649,8 +691,9 @@ function createProgram(ctx: CliContext, result: { code: number }, argv: readonly
|
|
|
649
691
|
const tenantCmd = addGlobalOptions(program.command("tenant").description("Composed tenant reads for machine clients (portal)"));
|
|
650
692
|
tenantCmd.helpCommand("help", "Show tenant help");
|
|
651
693
|
addGlobalOptions(tenantCmd.command("status").description("Read hub metadata and authoritative Runtime run state as one labeled view"))
|
|
652
|
-
.
|
|
653
|
-
|
|
694
|
+
.option("--project <id>", "Hub project; overrides KXM_PROJECT for this command")
|
|
695
|
+
.action(async function tenantStatusAction(this: Command, options: { project?: string }) {
|
|
696
|
+
result.code = await cmdTenantStatus(runtimeFrom(ctx, this), options);
|
|
654
697
|
});
|
|
655
698
|
|
|
656
699
|
const pricesCmd = addGlobalOptions(program.command("prices").description("Stamp the local list-price catalog. Estimates stay unknown until today's stamp"));
|
|
@@ -816,6 +859,7 @@ function createProgram(ctx: CliContext, result: { code: number }, argv: readonly
|
|
|
816
859
|
peer.helpCommand("help", "Show peer help");
|
|
817
860
|
|
|
818
861
|
addGlobalOptions(peer.command("list").description("List peer agents in this project's hub pool with host and presence"))
|
|
862
|
+
.option("--project <id>", "Hub project; overrides KXM_PROJECT for this command")
|
|
819
863
|
.option("--include-offline", "Also list registered peers whose hub lease has expired")
|
|
820
864
|
.option("--payload <json>", "JSON payload")
|
|
821
865
|
.action(async function peerListAction(this: Command, opts?: Record<string, unknown>) {
|
|
@@ -1326,8 +1370,12 @@ function createProgram(ctx: CliContext, result: { code: number }, argv: readonly
|
|
|
1326
1370
|
result.code = await cmdStop(runtimeFrom(ctx, this), options.waitMs);
|
|
1327
1371
|
});
|
|
1328
1372
|
addGlobalOptions(hub.command("bind").description("Bind this machine to a running hub").argument("<url>", "Hub base URL (http or https)"))
|
|
1329
|
-
.
|
|
1330
|
-
|
|
1373
|
+
.option("--cloud", "Mark the hub remote even when the URL is a loopback forward")
|
|
1374
|
+
.option("--token-env <name>", "Environment variable that holds the hub token; the token is not stored")
|
|
1375
|
+
.option("--token-command <command>", "Program that prints the hub token; the token is not stored")
|
|
1376
|
+
.option("--key-op <ref>", "op:// reference for the hub token; resolved with op read, never stored")
|
|
1377
|
+
.action(async function hubBindAction(this: Command, url: string, options: { cloud?: boolean; tokenEnv?: string; tokenCommand?: string; keyOp?: string }) {
|
|
1378
|
+
result.code = await cmdHubBind(runtimeFrom(ctx, this), url, options);
|
|
1331
1379
|
});
|
|
1332
1380
|
addGlobalOptions(hub.command("unbind").description("Remove this machine's hub binding")).action(bind(cmdHubUnbind));
|
|
1333
1381
|
addGlobalOptions(program.command("dash").description("Live screens for headless agents, tasks, workflows, and plans")
|
|
@@ -1544,6 +1592,10 @@ export async function runCli(
|
|
|
1544
1592
|
} catch (error) {
|
|
1545
1593
|
if (error instanceof CommanderError) return mapCommanderError(error, argv, program, io);
|
|
1546
1594
|
if (error instanceof DryRunRefused) return error.code;
|
|
1595
|
+
if (error instanceof KxmHubConfigError) {
|
|
1596
|
+
print(io, hasJsonFlag(argv), { ok: false, error: error.code, detail: error.message }, error.message);
|
|
1597
|
+
return 2;
|
|
1598
|
+
}
|
|
1547
1599
|
throw error;
|
|
1548
1600
|
}
|
|
1549
1601
|
}
|
|
@@ -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,7 +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 {
|
|
10
|
+
import { CloudTokenError } from "./hub-binding.ts";
|
|
11
|
+
import { describeHubConnection, resolveProjectIdentity } from "./hub-identity.ts";
|
|
11
12
|
import { nousFactoryWork, type NousRegistrationReport } from "./nous-pi.ts";
|
|
12
13
|
import {
|
|
13
14
|
antigravityRegistrationNotice,
|
|
@@ -346,7 +347,7 @@ export default function piMeshExtension(pi: ExtensionAPI): void | Promise<void>
|
|
|
346
347
|
}
|
|
347
348
|
|
|
348
349
|
async function showKxmHub(ctx: { ui: { notify: (message: string, type: "info" | "warning" | "error") => void } }): Promise<void> {
|
|
349
|
-
const url = (process.
|
|
350
|
+
const url = describeHubConnection(process.cwd(), process.env).url;
|
|
350
351
|
let health = "unreachable";
|
|
351
352
|
try {
|
|
352
353
|
const response = await fetch(`${url}/health`);
|
|
@@ -676,8 +677,9 @@ export default function piMeshExtension(pi: ExtensionAPI): void | Promise<void>
|
|
|
676
677
|
return;
|
|
677
678
|
}
|
|
678
679
|
currentPiSessionId = ctx.sessionManager?.getSessionId();
|
|
679
|
-
const
|
|
680
|
-
const
|
|
680
|
+
const identity = resolveProjectIdentity(ctx.cwd, process.env);
|
|
681
|
+
const serverUrl = describeHubConnection(ctx.cwd, process.env).url;
|
|
682
|
+
const project = identity.project;
|
|
681
683
|
const name = process.env.KXM_AGENT_NAME ?? pi.getSessionName() ?? `pi-${process.pid}`;
|
|
682
684
|
agentName = name;
|
|
683
685
|
projectName = project;
|
|
@@ -708,14 +710,17 @@ export default function piMeshExtension(pi: ExtensionAPI): void | Promise<void>
|
|
|
708
710
|
// stand in for a project token.
|
|
709
711
|
let hubAuthToken: string | undefined;
|
|
710
712
|
try {
|
|
711
|
-
hubAuthToken = resolveAgentHubAuthToken(process.env, project);
|
|
713
|
+
hubAuthToken = resolveAgentHubAuthToken(process.env, project, ctx.cwd);
|
|
712
714
|
} catch (error) {
|
|
713
|
-
|
|
715
|
+
const detail = error instanceof CloudTokenError
|
|
716
|
+
? error.message
|
|
717
|
+
: `kxm could not read persisted hub credentials: ${error instanceof Error ? error.message : String(error)}`;
|
|
718
|
+
ctx.ui.notify(detail, "error");
|
|
714
719
|
await applySessionChrome(ctx, event, true);
|
|
715
720
|
return;
|
|
716
721
|
}
|
|
717
722
|
if (!hubAuthToken) {
|
|
718
|
-
ctx.ui.notify(new AgentProjectTokenMissingError(
|
|
723
|
+
ctx.ui.notify(new AgentProjectTokenMissingError(identity).message, "error");
|
|
719
724
|
await applySessionChrome(ctx, event, true);
|
|
720
725
|
return;
|
|
721
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";
|
|
@@ -133,6 +134,28 @@ export async function ensureHubRunning(options: EnsureHubOptions): Promise<Ensur
|
|
|
133
134
|
if (binding) {
|
|
134
135
|
const { health } = await probeHubHealth(binding.url, fetchImpl);
|
|
135
136
|
if (health === "on") return { status: "bound-healthy", url: binding.url };
|
|
137
|
+
// A cloud binding names a remote hub. A dead forward must not start a local hub
|
|
138
|
+
// that would then authenticate with this machine's hub-env token.
|
|
139
|
+
if (binding.cloud) return { status: "failed", reason: "cloud_hub_unreachable", logPath: "" };
|
|
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
|
+
}
|
|
136
159
|
}
|
|
137
160
|
|
|
138
161
|
// 2. A live local claim means a hub (started any way) already owns this
|