@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.
Files changed (45) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +25 -0
  3. package/README.md +2 -0
  4. package/docs/README.md +1 -0
  5. package/docs/architecture/inventory.md +4 -3
  6. package/docs/concepts/trust-model.md +1 -1
  7. package/docs/glossary.md +4 -4
  8. package/docs/operations/troubleshooting.md +4 -1
  9. package/docs/operations.md +208 -0
  10. package/docs/reference/cli-reference.md +12 -11
  11. package/docs/reference/config-reference.md +36 -11
  12. package/docs/reference/configuration.md +11 -1
  13. package/docs/reference/tools.md +1 -1
  14. package/docs/start/quickstart-claude-code.md +2 -2
  15. package/package.json +1 -1
  16. package/plugins/kxm/.claude-plugin/plugin.json +2 -2
  17. package/plugins/kxm/README.md +4 -4
  18. package/plugins/kxm/dist/claude-hook.js +511 -56
  19. package/plugins/kxm/dist/cli.js +15661 -14775
  20. package/plugins/kxm/dist/extension.js +645 -133
  21. package/plugins/kxm/dist/mcp-server.js +8152 -81
  22. package/plugins/kxm/dist/runtime-supervisor.js +804 -155
  23. package/plugins/kxm/dist/runtime.js +851 -202
  24. package/plugins/kxm/dist/server.js +466 -58
  25. package/plugins/kxm/package.json +1 -1
  26. package/plugins/kxm/skills/kxm-hub-ops/SKILL.md +17 -5
  27. package/plugins/kxm/src/cli/context-skills.ts +64 -10
  28. package/plugins/kxm/src/cli/hub.ts +192 -22
  29. package/plugins/kxm/src/cli/project.ts +7 -3
  30. package/plugins/kxm/src/cli/types.ts +14 -1
  31. package/plugins/kxm/src/cli/workflows.ts +12 -1
  32. package/plugins/kxm/src/cli.ts +63 -11
  33. package/plugins/kxm/src/config.ts +246 -3
  34. package/plugins/kxm/src/extension.ts +12 -7
  35. package/plugins/kxm/src/hub-autostart.ts +23 -0
  36. package/plugins/kxm/src/hub-binding.ts +182 -2
  37. package/plugins/kxm/src/hub-env.ts +132 -9
  38. package/plugins/kxm/src/hub-identity.ts +337 -0
  39. package/plugins/kxm/src/mcp-server.ts +17 -14
  40. package/plugins/kxm/src/project-name.ts +6 -20
  41. package/plugins/kxm/src/runtime-supervisor.ts +9 -3
  42. package/plugins/kxm/src/server.ts +22 -4
  43. package/plugins/kxm/src/tenant-status.ts +9 -2
  44. package/scripts/build-runtime.mjs +5 -0
  45. package/scripts/kxm-hub.mjs +5 -0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-plugin",
3
- "version": "0.7.164",
3
+ "version": "0.7.166",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "engines": {
@@ -46,7 +46,7 @@ the hub admin token or a project token.
46
46
  |---|---|---|
47
47
  | `kxm hub start` | Start the hub in the foreground | `--json` |
48
48
  | `kxm hub stop` | Request managed hub and worker shutdown | `--wait-ms <ms>` |
49
- | `kxm hub bind <url>` | Bind this machine to a running hub | http or https URL |
49
+ | `kxm hub bind <url>` | Bind this machine to a running hub and record the non-secret endpoint in `.kxm/config.yaml` | http or https URL. `--token-env` and `--key-op` for either mode. `--cloud` with `--token-env`, `--token-command`, or `--key-op` for an SSH forward to loopback. The token is not stored |
50
50
  | `kxm hub unbind` | Remove this machine's hub binding | `--json` |
51
51
  | `kxm restore <manifest>` | Replace this project's live stores from a verified backup manifest | `--all-projects`, `--json` |
52
52
 
@@ -60,15 +60,27 @@ kxm restore .kxm/backups/pre-upgrade/manifest.json
60
60
  `KXM_AUTH_TOKEN` is unset it generates a long random admin token once and
61
61
  persists it in `hub-env.json` (schema `kxm.hub-env.v1`, mode `0600`) under
62
62
  the user state root, then reuses it on every restart.
63
- - `KXM_PROJECT_TOKENS` must list every project's token. It replaces the saved
64
- token map rather than merging with it, and the replacement is saved. When a
65
- hub already serves other projects, build the full map with the merge
66
- command in the KXM documentation's Claude Code quick start
63
+ - `KXM_PROJECT_TOKENS` must list every project's token when it is set. It
64
+ replaces the saved token map rather than merging with it, and the
65
+ replacement is saved. The same map can instead live in `.kxm/config.yaml`
66
+ as `hub.projects`, with `op://` references or environment variable names.
67
+ That map is resolved in memory and is not saved. When a hub already serves
68
+ other projects, build the full map with the merge command in the KXM
69
+ documentation's Claude Code quick start
67
70
  (docs/start/quickstart-claude-code.md, section "Start the hub") before
68
71
  restarting the hub.
72
+ - `kxm hub view` prints the mode, URL, project id, and key source. It does
73
+ not print the token. The project id is the flag, then `KXM_PROJECT`, then
74
+ `hub.local.project` or `hub.cloud.project`, then `.kxm/project.yaml`, then
75
+ the package name, then the directory name.
69
76
  - `kxm hub bind` to a remote (non-loopback) URL fails closed with
70
77
  `hub_bind_unauthenticated` unless a credential for the current project
71
78
  resolves from `KXM_AUTH_TOKEN` or the persisted `hub-env.json`.
79
+ - `kxm hub bind --cloud` is the bind for a loopback SSH forward of a remote
80
+ hub. It never falls back to this machine's hub-env token. Pass
81
+ `--token-env KXMD_HUB_TOKEN`, `--key-op op://Private/kxmd/project-token`,
82
+ or `--token-command "op read op://..."`. The non-secret fields are written
83
+ to `.kxm/config.yaml`. See docs/operations.md, section "Cross-box peer attach".
72
84
  - Hub PID claims record the wrapper and server child PID. A dead wrapper's
73
85
  claim is reclaimed automatically, an orphaned server is terminated first,
74
86
  and `kxm hub stop` recovers such orphans directly.
@@ -10,6 +10,8 @@ import {
10
10
  } from "../memory.ts";
11
11
  import { SkillLifecycle, type SkillEvaluationKind, type SkillState } from "../skills.ts";
12
12
  import { writeCompiledWiki } from "../wiki.ts";
13
+ import { CloudTokenError } from "../hub-binding.ts";
14
+ import { resolveControlPlaneAuthToken } from "../hub-env.ts";
13
15
  import { print, printPlan, type Runtime } from "./types.ts";
14
16
 
15
17
  /** Authenticated hub POST for context operations. The CLI operates as the
@@ -19,13 +21,33 @@ export async function hubContextPost(input: {
19
21
  path: string;
20
22
  body: Record<string, unknown>;
21
23
  authToken?: string | undefined;
24
+ /** When set, a cloud binding's token source replaces the explicit env token. */
25
+ env?: NodeJS.ProcessEnv | undefined;
26
+ command?: string | undefined;
27
+ io?: Runtime["io"] | undefined;
28
+ json?: boolean | undefined;
22
29
  fetchImpl: typeof fetch;
23
- }): Promise<{ ok: boolean; status: number; body: unknown }> {
30
+ }): Promise<{ ok: boolean; status: number; body: unknown; exitCode?: number }> {
31
+ let authToken = input.authToken;
32
+ if (input.env) {
33
+ try {
34
+ authToken = resolveControlPlaneAuthToken(input.env);
35
+ } catch (error) {
36
+ if (error instanceof CloudTokenError) {
37
+ const command = input.command ?? "context";
38
+ if (input.io) {
39
+ print(input.io, Boolean(input.json), { ok: false, command, error: error.code, detail: error.message }, error.message);
40
+ }
41
+ return { ok: false, status: 0, body: { error: error.code, detail: error.message }, exitCode: 2 };
42
+ }
43
+ throw error;
44
+ }
45
+ }
24
46
  const response = await input.fetchImpl(`${input.serverUrl.replace(/\/$/, "")}${input.path}`, {
25
47
  method: "POST",
26
48
  headers: {
27
49
  "content-type": "application/json",
28
- ...(input.authToken ? { authorization: `Bearer ${input.authToken}` } : {}),
50
+ ...(authToken ? { authorization: `Bearer ${authToken}` } : {}),
29
51
  },
30
52
  body: JSON.stringify(input.body),
31
53
  });
@@ -64,9 +86,13 @@ export async function cmdContextGet(runtime: Runtime, project: string, options:
64
86
  serverUrl: runtime.serverUrl,
65
87
  path: "/v1/context/get",
66
88
  body,
67
- ...(runtime.env.KXM_AUTH_TOKEN?.trim() ? { authToken: runtime.env.KXM_AUTH_TOKEN.trim() } : {}),
89
+ env: runtime.env,
90
+ command: "context",
91
+ io: runtime.io,
92
+ json: runtime.json,
68
93
  fetchImpl: runtime.fetchImpl,
69
94
  });
95
+ if (response.exitCode) return response.exitCode;
70
96
  print(runtime.io, runtime.json, { ok: response.ok, command: "context get", status: response.status, ...(response.body as object) }, `context get ${response.ok ? "assembled" : `failed (${response.status})`}`);
71
97
  return response.ok ? 0 : 1;
72
98
  }
@@ -81,9 +107,13 @@ export async function cmdContextRecall(runtime: Runtime, project: string, option
81
107
  serverUrl: runtime.serverUrl,
82
108
  path: "/v1/context/recall",
83
109
  body,
84
- ...(runtime.env.KXM_AUTH_TOKEN?.trim() ? { authToken: runtime.env.KXM_AUTH_TOKEN.trim() } : {}),
110
+ env: runtime.env,
111
+ command: "context",
112
+ io: runtime.io,
113
+ json: runtime.json,
85
114
  fetchImpl: runtime.fetchImpl,
86
115
  });
116
+ if (response.exitCode) return response.exitCode;
87
117
  print(runtime.io, runtime.json, { ok: response.ok, command: "context recall", status: response.status, ...(response.body as object) }, `context recall ${response.ok ? "complete" : `failed (${response.status})`}`);
88
118
  return response.ok ? 0 : 1;
89
119
  }
@@ -95,9 +125,13 @@ export async function cmdContextState(runtime: Runtime, project: string, key: st
95
125
  serverUrl: runtime.serverUrl,
96
126
  path: "/v1/context/state",
97
127
  body,
98
- ...(runtime.env.KXM_AUTH_TOKEN?.trim() ? { authToken: runtime.env.KXM_AUTH_TOKEN.trim() } : {}),
128
+ env: runtime.env,
129
+ command: "context",
130
+ io: runtime.io,
131
+ json: runtime.json,
99
132
  fetchImpl: runtime.fetchImpl,
100
133
  });
134
+ if (response.exitCode) return response.exitCode;
101
135
  print(runtime.io, runtime.json, { ok: response.ok, command: "context state", status: response.status, ...(response.body as object) }, `context state ${response.ok ? "resolved" : `failed (${response.status})`}`);
102
136
  return response.ok ? 0 : 1;
103
137
  }
@@ -109,9 +143,13 @@ export async function cmdContextEpisode(runtime: Runtime, project: string, optio
109
143
  serverUrl: runtime.serverUrl,
110
144
  path: "/v1/context/episode",
111
145
  body,
112
- ...(runtime.env.KXM_AUTH_TOKEN?.trim() ? { authToken: runtime.env.KXM_AUTH_TOKEN.trim() } : {}),
146
+ env: runtime.env,
147
+ command: "context",
148
+ io: runtime.io,
149
+ json: runtime.json,
113
150
  fetchImpl: runtime.fetchImpl,
114
151
  });
152
+ if (response.exitCode) return response.exitCode;
115
153
  print(runtime.io, runtime.json, { ok: response.ok, command: "context episode", status: response.status, ...(response.body as object) }, `context episode ${response.ok ? "complete" : `failed (${response.status})`}`);
116
154
  return response.ok ? 0 : 1;
117
155
  }
@@ -135,9 +173,13 @@ export async function cmdContextPromote(runtime: Runtime, project: string, propo
135
173
  serverUrl: runtime.serverUrl,
136
174
  path: "/v1/context/state/promote",
137
175
  body: { project, proposalId, evidence },
138
- ...(runtime.env.KXM_AUTH_TOKEN?.trim() ? { authToken: runtime.env.KXM_AUTH_TOKEN.trim() } : {}),
176
+ env: runtime.env,
177
+ command: "context",
178
+ io: runtime.io,
179
+ json: runtime.json,
139
180
  fetchImpl: runtime.fetchImpl,
140
181
  });
182
+ if (response.exitCode) return response.exitCode;
141
183
  print(runtime.io, runtime.json, { ok: response.ok, command: "context promote", status: response.status, ...(response.body as object) }, `context promote ${response.ok ? "recorded" : `failed (${response.status})`}`);
142
184
  return response.ok ? 0 : 1;
143
185
  }
@@ -147,9 +189,13 @@ export async function cmdContextExplain(runtime: Runtime, project: string, itemI
147
189
  serverUrl: runtime.serverUrl,
148
190
  path: "/v1/context/explain",
149
191
  body: { project, id: itemId },
150
- ...(runtime.env.KXM_AUTH_TOKEN?.trim() ? { authToken: runtime.env.KXM_AUTH_TOKEN.trim() } : {}),
192
+ env: runtime.env,
193
+ command: "context",
194
+ io: runtime.io,
195
+ json: runtime.json,
151
196
  fetchImpl: runtime.fetchImpl,
152
197
  });
198
+ if (response.exitCode) return response.exitCode;
153
199
  print(runtime.io, runtime.json, { ok: response.ok, command: "context explain", status: response.status, ...(response.body as object) }, `context explain ${response.ok ? "complete" : `failed (${response.status})`}`);
154
200
  return response.ok ? 0 : 1;
155
201
  }
@@ -159,9 +205,13 @@ export async function cmdContextWikiCompile(runtime: Runtime, project: string, o
159
205
  serverUrl: runtime.serverUrl,
160
206
  path: "/v1/context/wiki/compile",
161
207
  body: { project },
162
- ...(runtime.env.KXM_AUTH_TOKEN?.trim() ? { authToken: runtime.env.KXM_AUTH_TOKEN.trim() } : {}),
208
+ env: runtime.env,
209
+ command: "context",
210
+ io: runtime.io,
211
+ json: runtime.json,
163
212
  fetchImpl: runtime.fetchImpl,
164
213
  });
214
+ if (response.exitCode) return response.exitCode;
165
215
  if (!response.ok) {
166
216
  print(runtime.io, runtime.json, { ok: false, command: "context wiki-compile", status: response.status, body: response.body }, `wiki compile failed (${response.status})`);
167
217
  return 1;
@@ -191,9 +241,13 @@ export async function cmdContextWikiLint(runtime: Runtime, project: string): Pro
191
241
  serverUrl: runtime.serverUrl,
192
242
  path: "/v1/context/wiki/compile",
193
243
  body: { project },
194
- ...(runtime.env.KXM_AUTH_TOKEN?.trim() ? { authToken: runtime.env.KXM_AUTH_TOKEN.trim() } : {}),
244
+ env: runtime.env,
245
+ command: "context",
246
+ io: runtime.io,
247
+ json: runtime.json,
195
248
  fetchImpl: runtime.fetchImpl,
196
249
  });
250
+ if (response.exitCode) return response.exitCode;
197
251
  if (!response.ok) {
198
252
  print(runtime.io, runtime.json, { ok: false, command: "context wiki-lint", status: response.status, body: response.body }, `wiki lint failed (${response.status})`);
199
253
  return 1;
@@ -3,20 +3,27 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync
3
3
  import { join, resolve } from "node:path";
4
4
  import { redactSecrets } from "../redact.ts";
5
5
  import { defaultProjectName } from "../project-name.ts";
6
- import { hasClientHubCredential, resolveClientHubAuthToken } from "../hub-env.ts";
6
+ import { agentSessionEnv, hasClientHubCredential, HubEnvError, readHubEnvRecord, resolveClientHubAuthToken } from "../hub-env.ts";
7
+ import { deleteKxmConfigValue, findConfigRoot, isHubOpReference, KxmHubConfigError, writeHubEndpoint } from "../config.ts";
8
+ import { describeHubConnection, opReferenceFromTokenCommand, resolveProjectIdentity } from "../hub-identity.ts";
7
9
  import { agentWorker, type Worker } from "../envelope.ts";
8
10
  import { MESH_TUI_PANELS, runMeshTui, type MeshTuiPanel } from "../tui.ts";
9
11
  import { formatSessionBriefText, loadSessionBriefAsync, readCachedSessionBrief, type SessionHubStatus } from "../session-work.ts";
10
12
  import {
11
13
  HUB_BINDING_SCHEMA,
14
+ CloudTokenError,
12
15
  HubBindingError,
13
16
  hubBindingFile,
14
17
  probeHubHealth,
15
18
  hubBindingScope,
19
+ effectiveHubBindingScope,
20
+ isCloudTokenEnvName,
16
21
  readHubBinding,
17
22
  removeHubBinding,
23
+ splitTokenCommand,
18
24
  validateHubUrl,
19
25
  writeHubBinding,
26
+ type HubBindingRecord,
20
27
  type HubHealth,
21
28
  } from "../hub-binding.ts";
22
29
  import {
@@ -57,9 +64,13 @@ import {
57
64
  type Runtime,
58
65
  } from "./types.ts";
59
66
 
60
- export async function hubGet(url: string, fetchImpl: typeof fetch): Promise<{ ok: boolean; status: number; body: unknown }> {
67
+ export async function hubGet(
68
+ url: string,
69
+ fetchImpl: typeof fetch,
70
+ headers?: Record<string, string>,
71
+ ): Promise<{ ok: boolean; status: number; body: unknown }> {
61
72
  try {
62
- const response = await fetchImpl(url);
73
+ const response = await fetchImpl(url, headers ? { headers } : undefined);
63
74
  const text = redactSecrets((await response.text()).slice(0, 8_000));
64
75
  let body: unknown = text;
65
76
  try {
@@ -147,24 +158,59 @@ export async function refreshKxmUpdateNotice(
147
158
  }
148
159
 
149
160
  export async function cmdStatus(runtime: Runtime): Promise<number> {
150
- const health = await hubGet(`${runtime.serverUrl}/health`, runtime.fetchImpl);
151
- const ready = await hubGet(`${runtime.serverUrl}/ready`, runtime.fetchImpl);
161
+ const connection = describeHubConnection(runtime.dirs.workdir, runtime.env, {
162
+ hasSavedProjectToken: (project) => {
163
+ try {
164
+ const token = readHubEnvRecord(runtime.env)?.projectTokens?.[project];
165
+ return typeof token === "string" && token.trim().length > 0;
166
+ } catch {
167
+ return false;
168
+ }
169
+ },
170
+ });
171
+ let headers: Record<string, string> | undefined;
172
+ try {
173
+ if (connection.mode === "cloud") {
174
+ const token = resolveClientHubAuthToken(runtime.env, connection.project, runtime.dirs.workdir);
175
+ if (token) headers = { authorization: `Bearer ${token}` };
176
+ }
177
+ } catch (error) {
178
+ if (error instanceof CloudTokenError) {
179
+ print(runtime.io, runtime.json, { ok: false, command: "hub view", error: error.code, detail: error.message }, error.message);
180
+ return 2;
181
+ }
182
+ if (!(error instanceof HubBindingError)) throw error;
183
+ }
184
+ const health = await hubGet(`${runtime.serverUrl}/health`, runtime.fetchImpl, headers);
185
+ const ready = await hubGet(`${runtime.serverUrl}/ready`, runtime.fetchImpl, headers);
152
186
  // Scope on the status line deliberately: "attached across a network" and "attached on
153
187
  // this box" are otherwise indistinguishable, and only one of them ships a token.
154
188
  // Scope is a property of the URL actually contacted, not of whichever file the
155
189
  // binding came from: KXM_SERVER_URL overrides the binding, and labelling the binding
156
190
  // while probing an override would report "loopback" about a remote request.
157
- const effectiveScope = hubBindingScope(runtime.serverUrl);
158
- const overridden = Boolean(runtime.boundHubUrl && runtime.boundHubUrl !== runtime.serverUrl);
191
+ // A cloud mode is remote when the contacted URL is that endpoint, including a
192
+ // loopback SSH forward.
193
+ const effectiveScope = connection.scope;
194
+ const overridden = connection.urlSource === "env";
195
+ const identity = {
196
+ mode: connection.mode,
197
+ modeSource: connection.modeSource,
198
+ url: connection.url,
199
+ urlSource: connection.urlSource,
200
+ project: connection.project,
201
+ projectSource: connection.source,
202
+ keySource: connection.keySource,
203
+ };
159
204
  const payload = {
160
205
  ok: health.ok && ready.ok,
161
206
  command: "hub view",
162
207
  target: { url: runtime.serverUrl, scope: effectiveScope, ...(overridden ? { source: "env" } : {}) },
208
+ identity,
163
209
  health: health.body,
164
210
  ready: ready.body,
165
211
  };
166
212
  print(runtime.io, runtime.json, payload,
167
- `hub health=${health.ok} ready=${ready.ok} · ${effectiveScope} hub${overridden ? " (KXM_SERVER_URL)" : ""}`);
213
+ `hub health=${health.ok} ready=${ready.ok} · ${effectiveScope} hub${overridden ? " (KXM_SERVER_URL)" : ""} · mode=${identity.mode} project=${identity.project} (${connection.sourceLabel}) key=${identity.keySource}`);
168
214
  return payload.ok ? 0 : 1;
169
215
  }
170
216
 
@@ -191,8 +237,17 @@ export async function cmdDash(runtime: Runtime, options: { screen?: string | und
191
237
  runtime.io.stderr("kxm dash does not support --json; use kxm hub view\n");
192
238
  return 2;
193
239
  }
194
- const project = defaultProjectName(runtime.dirs.workdir, runtime.env) || "project";
195
- const authToken = resolveClientHubAuthToken(runtime.env, project);
240
+ const project = resolveProjectIdentity(runtime.dirs.workdir, runtime.env).project || "project";
241
+ let authToken: string | undefined;
242
+ try {
243
+ authToken = resolveClientHubAuthToken(runtime.env, project, runtime.dirs.workdir);
244
+ } catch (error) {
245
+ if (error instanceof CloudTokenError) {
246
+ print(runtime.io, runtime.json, { ok: false, command: "dash", error: error.code, detail: error.message }, error.message);
247
+ return 2;
248
+ }
249
+ throw error;
250
+ }
196
251
  return await runMeshTui({
197
252
  serverUrl: runtime.serverUrl,
198
253
  dataPath,
@@ -254,7 +309,14 @@ export function formatHubBindHealth(health: HubHealth): string {
254
309
  const HUB_BIND_UNAUTHENTICATED_HINT =
255
310
  "export KXM_AUTH_TOKEN (or point KXM_STATE_HOME at the hub-env record that already holds one), then re-run; the hub itself requires a token beyond loopback";
256
311
 
257
- export async function cmdHubBind(runtime: Runtime, rawUrl: string): Promise<number> {
312
+ export interface HubBindOptions {
313
+ cloud?: boolean | undefined;
314
+ tokenEnv?: string | undefined;
315
+ tokenCommand?: string | undefined;
316
+ keyOp?: string | undefined;
317
+ }
318
+
319
+ export async function cmdHubBind(runtime: Runtime, rawUrl: string, options: HubBindOptions = {}): Promise<number> {
258
320
  let url: string;
259
321
  try {
260
322
  url = validateHubUrl(rawUrl);
@@ -270,7 +332,61 @@ export async function cmdHubBind(runtime: Runtime, rawUrl: string): Promise<numb
270
332
  }
271
333
  throw error;
272
334
  }
273
- const scope = hubBindingScope(url);
335
+ const cloud = options.cloud === true;
336
+ const tokenEnv = options.tokenEnv?.trim() || undefined;
337
+ const tokenCommand = options.tokenCommand?.trim() || undefined;
338
+ const keyOp = options.keyOp?.trim() || opReferenceFromTokenCommand(tokenCommand ?? "") || undefined;
339
+ if (options.keyOp?.trim() && !isHubOpReference(options.keyOp.trim())) {
340
+ print(
341
+ runtime.io,
342
+ runtime.json,
343
+ { ok: false, command: "hub bind", error: "hub_config_invalid", url },
344
+ "--key-op must be an op://vault/item/field reference, not a token; no binding was written",
345
+ );
346
+ return 2;
347
+ }
348
+ if (tokenCommand && !cloud) {
349
+ print(
350
+ runtime.io,
351
+ runtime.json,
352
+ { ok: false, command: "hub bind", error: "cloud_flag_required", url },
353
+ "--token-command requires --cloud; no binding was written",
354
+ );
355
+ return 2;
356
+ }
357
+ if (cloud && !tokenEnv && !tokenCommand && !keyOp) {
358
+ print(
359
+ runtime.io,
360
+ runtime.json,
361
+ { ok: false, command: "hub bind", error: "cloud_token_source_required", url },
362
+ "--cloud needs --token-env, --token-command, --key-op, or a combination; the token is not stored",
363
+ );
364
+ return 2;
365
+ }
366
+ if (tokenEnv && !isCloudTokenEnvName(tokenEnv)) {
367
+ print(
368
+ runtime.io,
369
+ runtime.json,
370
+ { ok: false, command: "hub bind", error: "cloud_token_env_invalid", url },
371
+ "--token-env must be an environment variable name; no binding was written",
372
+ );
373
+ return 2;
374
+ }
375
+ if (cloud && tokenCommand) {
376
+ try {
377
+ splitTokenCommand(tokenCommand);
378
+ } catch (error) {
379
+ const detail = error instanceof Error ? error.message : String(error);
380
+ print(
381
+ runtime.io,
382
+ runtime.json,
383
+ { ok: false, command: "hub bind", error: "cloud_token_command_invalid", url, detail },
384
+ `${detail}; no binding was written`,
385
+ );
386
+ return 2;
387
+ }
388
+ }
389
+ const scope = cloud ? "remote" : hubBindingScope(url);
274
390
  // Scope-scoped on purpose, and only for **this command**. A remote binding puts a
275
391
  // bearer on a network path, so it is refused when nothing can authenticate it; a
276
392
  // stored-but-unusable URL otherwise reads later like a network fault and gets debugged
@@ -280,13 +396,13 @@ export async function cmdHubBind(runtime: Runtime, rawUrl: string): Promise<numb
280
396
  // fail `kxm peer list` on loopback. Narrowing the claim is the point; the first version
281
397
  // of this guard checked the record *before* the scope and refused loopback binds that had
282
398
  // always worked, which is a regression against behaviour predating this slice.
283
- if (scope === "remote") {
399
+ if (!cloud && scope === "remote") {
284
400
  // The project that will actually authenticate: a record holding only another
285
401
  // project's token cannot authorise this one.
286
- const bindProject = defaultProjectName(runtime.dirs.workdir, runtime.env) || "project";
402
+ const bindProject = resolveProjectIdentity(runtime.dirs.workdir, runtime.env).project || "project";
287
403
  let credentialReady = false;
288
404
  try {
289
- credentialReady = hasClientHubCredential(runtime.env, bindProject);
405
+ credentialReady = hasClientHubCredential(runtime.env, bindProject, runtime.dirs.workdir);
290
406
  } catch (error) {
291
407
  // A malformed record is a readable configuration failure, not an uncaught throw
292
408
  // past a user-facing entry point.
@@ -329,14 +445,55 @@ export async function cmdHubBind(runtime: Runtime, rawUrl: string): Promise<numb
329
445
  }
330
446
  }
331
447
  const file = hubBindingFile(runtime.env);
448
+ const bindingCommand = tokenCommand ?? (cloud && keyOp ? `op read ${keyOp}` : undefined);
449
+ const cloudFields = cloud
450
+ ? { cloud: true as const, ...(tokenEnv ? { tokenEnv } : {}), ...(bindingCommand ? { tokenCommand: bindingCommand } : {}) }
451
+ : {};
452
+ const record: HubBindingRecord = {
453
+ schema: HUB_BINDING_SCHEMA,
454
+ url,
455
+ boundAt: new Date().toISOString(),
456
+ ...cloudFields,
457
+ };
458
+ const identity = resolveProjectIdentity(runtime.dirs.workdir, runtime.env);
459
+ const configFile = join(findConfigRoot(runtime.dirs.workdir), ".kxm", "config.yaml");
460
+ const key = {
461
+ ...(keyOp ? { op: keyOp } : {}),
462
+ ...(tokenEnv ? { env: tokenEnv } : {}),
463
+ };
464
+ const cloudNote = cloud ? " · token is not stored" : "";
332
465
  if (runtime.dryRun) {
333
- print(runtime.io, runtime.json, { ok: true, command: "hub bind", dryRun: true, url, scope, file }, `would bind hub ${url} (${scope})`);
466
+ print(
467
+ runtime.io,
468
+ runtime.json,
469
+ { ok: true, command: "hub bind", dryRun: true, url, scope, file, configFile, project: identity.project, ...cloudFields },
470
+ `would bind hub ${url} (${scope})${cloudNote}`,
471
+ );
334
472
  return 0;
335
473
  }
336
- writeHubBinding({ schema: HUB_BINDING_SCHEMA, url, boundAt: new Date().toISOString() }, runtime.env);
474
+ try {
475
+ writeHubEndpoint(runtime.dirs.workdir, {
476
+ mode: cloud ? "cloud" : "local",
477
+ url,
478
+ project: identity.project,
479
+ ...(key.op || key.env ? { key } : {}),
480
+ });
481
+ } catch (error) {
482
+ if (error instanceof KxmHubConfigError) {
483
+ print(
484
+ runtime.io,
485
+ runtime.json,
486
+ { ok: false, command: "hub bind", error: error.code, detail: error.message },
487
+ `${error.message}; no binding was written`,
488
+ );
489
+ return 2;
490
+ }
491
+ throw error;
492
+ }
493
+ writeHubBinding({ ...record, boundAt: new Date().toISOString() }, runtime.env);
337
494
  const { health, probeMs } = await probeHubHealth(url, runtime.fetchImpl);
338
- print(runtime.io, runtime.json, { ok: true, command: "hub bind", url, scope, file, health, probeMs },
339
- `bound hub ${url} · ${scope} · ${formatHubBindHealth(health)}${scope === "remote" ? " · token leaves this machine" : ""}`);
495
+ print(runtime.io, runtime.json, { ok: true, command: "hub bind", url, scope, file, configFile, project: identity.project, health, probeMs, ...cloudFields },
496
+ `bound hub ${url} · ${scope} · ${formatHubBindHealth(health)}${cloud ? cloudNote : scope === "remote" ? " · token leaves this machine" : ""}`);
340
497
  return 0;
341
498
  }
342
499
 
@@ -364,6 +521,7 @@ export async function cmdHubUnbind(runtime: Runtime): Promise<number> {
364
521
  return 1;
365
522
  }
366
523
  removeHubBinding(runtime.env);
524
+ deleteKxmConfigValue(findConfigRoot(runtime.dirs.workdir), "hub.mode");
367
525
  if (malformed) {
368
526
  print(runtime.io, runtime.json, { ok: true, command: "hub unbind", file }, "unbound hub (record was malformed)");
369
527
  return 0;
@@ -383,7 +541,7 @@ export async function cmdWorker(runtime: Runtime, options: {
383
541
  freshStart?: boolean | undefined;
384
542
  }): Promise<number> {
385
543
  const name = options.name?.trim() || runtime.env.KXM_AGENT_NAME?.trim();
386
- const project = options.project?.trim() || runtime.env.KXM_PROJECT?.trim();
544
+ const project = defaultProjectName(runtime.dirs.workdir, runtime.env, options.project);
387
545
  const model = options.model?.trim() || runtime.env.KXM_WORKER_MODEL?.trim();
388
546
  const fallbackModels = options.fallbackModels?.trim() || runtime.env.KXM_WORKER_FALLBACK_MODELS?.trim();
389
547
  const tools = options.tools?.trim() || runtime.env.KXM_WORKER_TOOLS?.trim();
@@ -392,10 +550,22 @@ export async function cmdWorker(runtime: Runtime, options: {
392
550
  runtime.io.stderr("worker --session-isolation must be workflow or off\n");
393
551
  return 2;
394
552
  }
553
+ let sessionEnv: NodeJS.ProcessEnv = { KXM_PROJECT: project };
554
+ if (!runtime.dryRun) {
555
+ try {
556
+ sessionEnv = agentSessionEnv(runtime.dirs.workdir, runtime.env, options.project);
557
+ } catch (error) {
558
+ if (error instanceof CloudTokenError || error instanceof HubEnvError) {
559
+ runtime.io.stderr(`${error.message}\n`);
560
+ return 2;
561
+ }
562
+ throw error;
563
+ }
564
+ }
395
565
  const extraEnv = {
396
566
  ...workspaceEnv(runtime),
567
+ ...sessionEnv,
397
568
  ...(name ? { KXM_AGENT_NAME: name } : {}),
398
- ...(project ? { KXM_PROJECT: project } : {}),
399
569
  ...(model ? { KXM_WORKER_MODEL: model } : {}),
400
570
  ...(fallbackModels ? { KXM_WORKER_FALLBACK_MODELS: fallbackModels } : {}),
401
571
  ...(tools ? { KXM_WORKER_TOOLS: tools } : {}),
@@ -675,7 +845,7 @@ export async function cmdSessionBrief(runtime: Runtime, options: { status?: bool
675
845
  evidence: health === "unknown" ? "timeout" : "probed",
676
846
  online: health === "on",
677
847
  url: targetUrl,
678
- scope: hubBindingScope(targetUrl),
848
+ scope: effectiveHubBindingScope(targetUrl, runtime.env),
679
849
  };
680
850
  } else {
681
851
  hub = { state: "off", evidence: "unconfigured", online: false };
@@ -29,6 +29,8 @@ import {
29
29
  import { kxmRuntimePaths, runtimeError } from "../runtime-store.ts";
30
30
  import { assembleTenantStatus, formatTenantStatus } from "../tenant-status.ts";
31
31
  import { resolveClientAdminAuthToken } from "../hub-env.ts";
32
+ import { defaultProjectName } from "../project-name.ts";
33
+ import { effectiveHubBindingScope } from "../hub-binding.ts";
32
34
  import {
33
35
  formatHarnessInventory,
34
36
  probeHarnessesAsync,
@@ -779,7 +781,7 @@ export async function cmdKxmRunList(runtime: Runtime): Promise<number> {
779
781
  }
780
782
  }
781
783
 
782
- export async function cmdTenantStatus(runtime: Runtime): Promise<number> {
784
+ export async function cmdTenantStatus(runtime: Runtime, options: { project?: string | undefined } = {}): Promise<number> {
783
785
  let projectRoot: string | undefined;
784
786
  let projectId: string;
785
787
  try {
@@ -789,7 +791,8 @@ export async function cmdTenantStatus(runtime: Runtime): Promise<number> {
789
791
  return 1;
790
792
  }
791
793
  const bundle = loadKxmProject(projectRoot, {});
792
- projectId = String(bundle.project.value.id);
794
+ const yamlProjectId = String(bundle.project.value.id);
795
+ projectId = defaultProjectName(runtime.cwd, runtime.env, options.project);
793
796
  const root = projectRoot;
794
797
 
795
798
  const payload = await assembleTenantStatus({
@@ -799,6 +802,7 @@ export async function cmdTenantStatus(runtime: Runtime): Promise<number> {
799
802
  // and resolve it inside the hub source so a malformed persisted record degrades that
800
803
  // one source instead of aborting the Runtime read with it.
801
804
  resolveAdminToken: () => resolveClientAdminAuthToken(runtime.env),
805
+ bindingScope: effectiveHubBindingScope(runtime.serverUrl, runtime.env),
802
806
  fetchImpl: runtime.fetchImpl,
803
807
  runtime: {
804
808
  listRuns: async () => {
@@ -808,7 +812,7 @@ export async function cmdTenantStatus(runtime: Runtime): Promise<number> {
808
812
  if (!handle) {
809
813
  throw runtimeError("runtime_supervisor_not_running", "runtime", "no live runtime supervisor on this box");
810
814
  }
811
- const result = await kxmRuntimeRequest(handle, "GET", `/v1/projects/${encodeURIComponent(projectId)}/runs?projectRoot=${encodeURIComponent(root)}`);
815
+ const result = await kxmRuntimeRequest(handle, "GET", `/v1/projects/${encodeURIComponent(yamlProjectId)}/runs?projectRoot=${encodeURIComponent(root)}`);
812
816
  const runs = (result.runs ?? []) as Array<Record<string, unknown>>;
813
817
  return runs.map((run) => ({
814
818
  runId: String(run.runId ?? ""),
@@ -8,9 +8,11 @@ import { gateWorker, workerResult, type Worker, type WorkerOutcome } from "../en
8
8
  import { canonicalWorkflowEvidenceKey, type WorkflowEvidenceInput } from "../workflow.ts";
9
9
  import {
10
10
  HubBindingError,
11
+ effectiveHubBindingScope,
11
12
  hubBindingFile,
12
13
  readHubBinding,
13
14
  } from "../hub-binding.ts";
15
+ import { describeHubConnection } from "../hub-identity.ts";
14
16
  import type { InstallProbe } from "../kxm-install-kind.ts";
15
17
 
16
18
  export interface CliSpawnResult {
@@ -165,6 +167,16 @@ export function printWorker(
165
167
  }
166
168
 
167
169
  export function hostMode(runtime: Runtime): "local" | "hub" {
170
+ try {
171
+ if (describeHubConnection(runtime.cwd, runtime.env).scope === "remote") return "hub";
172
+ } catch {
173
+ // A malformed binding is classified from the URL below.
174
+ }
175
+ try {
176
+ if (effectiveHubBindingScope(runtime.serverUrl, runtime.env) === "remote") return "hub";
177
+ } catch {
178
+ // A malformed binding is classified from the URL below.
179
+ }
168
180
  try {
169
181
  const hostname = new URL(runtime.serverUrl).hostname;
170
182
  if (hostname === "127.0.0.1" || hostname === "localhost" || hostname === "::1") return "local";
@@ -276,13 +288,14 @@ export function runtimeFrom(ctx: CliContext, command: Command): Runtime {
276
288
  throw error;
277
289
  }
278
290
  }
291
+ const connection = describeHubConnection(ctx.cwd, ctx.env);
279
292
  return {
280
293
  ...ctx,
281
294
  json: Boolean(opts.json),
282
295
  dryRun: Boolean(opts.dryRun),
283
296
  ...(opts.workspace === undefined ? {} : { workspaceFlag: opts.workspace }),
284
297
  dirs: workspaceDirs(ctx.cwd, opts.workspace, ctx.env),
285
- serverUrl: envServerUrl || boundHubUrl || "http://127.0.0.1:7331",
298
+ serverUrl: connection.url || envServerUrl || boundHubUrl || "http://127.0.0.1:7331",
286
299
  ...(boundHubUrl ? { boundHubUrl } : {}),
287
300
  fetchImpl: ctx.io.fetchImpl ?? fetch,
288
301
  };
@@ -37,6 +37,8 @@ import {
37
37
  type Runtime,
38
38
  } from "./types.ts";
39
39
  import { resolvePickItem, type PickCandidate } from "./roles.ts";
40
+ import { CloudTokenError } from "../hub-binding.ts";
41
+ import { resolveControlPlaneAuthToken } from "../hub-env.ts";
40
42
 
41
43
  const CLI_NAME = "kxm";
42
44
 
@@ -480,7 +482,16 @@ export async function cmdWorkflowStart(runtime: Runtime, definitionIdArg: string
480
482
  export async function cmdWorkflowDegrade(runtime: Runtime, runId: string, stageId: string, options: { requirement?: string | undefined; reason?: string | undefined }): Promise<number> {
481
483
  const requirementKey = options.requirement?.trim() ?? "";
482
484
  const reason = options.reason?.trim() ?? "";
483
- const adminToken = runtime.env.KXM_AUTH_TOKEN?.trim();
485
+ let adminToken: string | undefined;
486
+ try {
487
+ adminToken = resolveControlPlaneAuthToken(runtime.env);
488
+ } catch (error) {
489
+ if (error instanceof CloudTokenError) {
490
+ runtime.io.stderr(`${error.message}\n`);
491
+ return 2;
492
+ }
493
+ throw error;
494
+ }
484
495
  if (!runId || !stageId || !requirementKey || !reason || !adminToken) {
485
496
  runtime.io.stderr("workflow degrade requires <runId> <stageId>, --requirement, --reason, and KXM_AUTH_TOKEN\n");
486
497
  return 2;