@kontextmind/kxm 0.7.165 → 0.7.167

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.
Files changed (41) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +16 -0
  3. package/docs/README.md +1 -0
  4. package/docs/architecture/hosted-direction.md +9 -4
  5. package/docs/architecture/inventory.md +1 -0
  6. package/docs/concepts/trust-model.md +1 -1
  7. package/docs/contributing/decisions.md +153 -0
  8. package/docs/contributing/learnings.md +54 -53
  9. package/docs/contributing/operating-rules.md +19 -6
  10. package/docs/glossary.md +2 -2
  11. package/docs/operations/troubleshooting.md +2 -2
  12. package/docs/operations.md +62 -8
  13. package/docs/reference/cli-reference.md +11 -11
  14. package/docs/reference/config-reference.md +36 -11
  15. package/docs/reference/configuration.md +2 -2
  16. package/docs/reference/tools.md +1 -1
  17. package/docs/start/quickstart-claude-code.md +2 -2
  18. package/package.json +1 -1
  19. package/plugins/kxm/.claude-plugin/plugin.json +1 -1
  20. package/plugins/kxm/README.md +3 -3
  21. package/plugins/kxm/dist/claude-hook.js +417 -66
  22. package/plugins/kxm/dist/cli.js +1399 -900
  23. package/plugins/kxm/dist/extension.js +399 -64
  24. package/plugins/kxm/dist/mcp-server.js +622 -135
  25. package/plugins/kxm/dist/runtime-supervisor.js +668 -166
  26. package/plugins/kxm/dist/runtime.js +713 -211
  27. package/plugins/kxm/dist/server.js +466 -58
  28. package/plugins/kxm/package.json +1 -1
  29. package/plugins/kxm/skills/kxm-hub-ops/SKILL.md +15 -7
  30. package/plugins/kxm/src/cli/hub.ts +80 -21
  31. package/plugins/kxm/src/cli/types.ts +8 -1
  32. package/plugins/kxm/src/cli.ts +41 -7
  33. package/plugins/kxm/src/config.ts +246 -3
  34. package/plugins/kxm/src/extension.ts +8 -7
  35. package/plugins/kxm/src/hub-autostart.ts +20 -0
  36. package/plugins/kxm/src/hub-env.ts +83 -31
  37. package/plugins/kxm/src/hub-identity.ts +337 -0
  38. package/plugins/kxm/src/mcp-server.ts +17 -15
  39. package/plugins/kxm/src/project-name.ts +6 -47
  40. package/plugins/kxm/src/server.ts +22 -4
  41. 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
- return { autoStart: autoStart === "off" || autoStart === "background" ? autoStart : DEFAULT_KXM_CONFIG.hub.autoStart };
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, resolveHubServerUrl } from "./hub-binding.ts";
11
- import { defaultProjectName } from "./project-name.ts";
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 = resolveHubServerUrl(process.env);
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 serverUrl = resolveHubServerUrl(process.env);
681
- const project = defaultProjectName(ctx.cwd, process.env);
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(project).message, "error");
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(env: NodeJS.ProcessEnv = process.env): string | undefined {
243
- const cloud = activeCloudBinding(env);
244
- if (cloud) return resolveCloudHubToken(cloud, env);
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 project = defaultProjectName(cwd, env, explicitProject);
259
- const cloud = activeCloudBinding(env);
260
- let serverUrl = env.KXM_SERVER_URL?.trim();
261
- if (!serverUrl) {
262
- try {
263
- serverUrl = readHubBinding(env)?.url;
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(env: NodeJS.ProcessEnv, project: string): string | undefined {
293
- const cloud = activeCloudBinding(env);
294
- if (cloud) return resolveCloudHubToken(cloud, env);
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(env: NodeJS.ProcessEnv, project: string): string | undefined {
311
- const cloud = activeCloudBinding(env);
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 resolveCloudHubToken(cloud, env);
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(project: string) {
331
- super(
332
- `kxm has no project token for project ${project} on this machine. Set KXM_AUTH_TOKEN to that project's token, or add ${project} to the hub KXM_PROJECT_TOKENS (list every existing project too, because that variable replaces the saved map). An agent never uses the hub admin token.`,
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(env: NodeJS.ProcessEnv = process.env): string | undefined {
347
- const cloud = activeCloudBinding(env);
348
- if (cloud) return resolveCloudHubToken(cloud, env);
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;