@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,3 +1,4 @@
1
+ import { spawnSync } from "node:child_process";
1
2
  import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
3
  import { homedir } from "node:os";
3
4
  import { dirname, isAbsolute, join, resolve } from "node:path";
@@ -20,8 +21,31 @@ export interface HubBindingRecord {
20
21
  schema: typeof HUB_BINDING_SCHEMA;
21
22
  url: string;
22
23
  boundAt: string;
24
+ /** A forwarded or otherwise remote hub. Loopback in the URL does not make this local. */
25
+ cloud?: true;
26
+ /** Environment variable that holds the hub token. The token itself is never stored. */
27
+ tokenEnv?: string;
28
+ /** Program plus arguments that print the hub token. Not a shell pipeline, and not the token. */
29
+ tokenCommand?: string;
23
30
  }
24
31
 
32
+ export type CloudTokenErrorCode = "cloud_token_missing" | "cloud_token_command_failed" | "cloud_token_command_invalid";
33
+
34
+ /** The cloud binding named a token source and that source did not produce a token. */
35
+ export class CloudTokenError extends Error {
36
+ readonly code: CloudTokenErrorCode;
37
+
38
+ constructor(code: CloudTokenErrorCode, message: string) {
39
+ super(message);
40
+ this.name = "CloudTokenError";
41
+ this.code = code;
42
+ }
43
+ }
44
+
45
+ const TOKEN_ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/;
46
+ const TOKEN_COMMAND_TIMEOUT_MS = 15_000;
47
+ const TOKEN_COMMAND_MAX_BUFFER = 4096;
48
+
25
49
  export class HubBindingError extends Error {
26
50
  constructor(message: string) {
27
51
  super(message);
@@ -71,6 +95,132 @@ export function validateHubUrl(raw: string): string {
71
95
  return parsed.href.replace(/\/$/, "");
72
96
  }
73
97
 
98
+ /** A cloud binding's token command is a program and arguments, never a shell. */
99
+ export function splitTokenCommand(raw: string): string[] {
100
+ if (/[|&;<>$`\n\r]/.test(raw)) {
101
+ throw new CloudTokenError(
102
+ "cloud_token_command_invalid",
103
+ "token command must be a program and arguments, not a shell pipeline",
104
+ );
105
+ }
106
+ const args: string[] = [];
107
+ let current = "";
108
+ let quote: "'" | "\"" | undefined;
109
+ for (const ch of raw) {
110
+ if (quote) {
111
+ if (ch === quote) quote = undefined;
112
+ else current += ch;
113
+ continue;
114
+ }
115
+ if (ch === "'" || ch === "\"") {
116
+ quote = ch;
117
+ continue;
118
+ }
119
+ if (ch === " " || ch === "\t") {
120
+ if (current.length > 0) {
121
+ args.push(current);
122
+ current = "";
123
+ }
124
+ continue;
125
+ }
126
+ current += ch;
127
+ }
128
+ if (quote) {
129
+ throw new CloudTokenError("cloud_token_command_invalid", "token command has an unclosed quote");
130
+ }
131
+ if (current.length > 0) args.push(current);
132
+ if (args.length === 0) {
133
+ throw new CloudTokenError("cloud_token_command_invalid", "token command is empty");
134
+ }
135
+ return args;
136
+ }
137
+
138
+ export function isCloudTokenEnvName(value: string): boolean {
139
+ return TOKEN_ENV_NAME.test(value);
140
+ }
141
+
142
+ /**
143
+ * Where this command should say the hub sits. A cloud binding is remote even when its
144
+ * URL is a loopback forward, but only for the URL the binding names. `KXM_SERVER_URL`
145
+ * pointing somewhere else keeps that URL's own scope.
146
+ */
147
+ export function effectiveHubBindingScope(url: string, env: NodeJS.ProcessEnv = process.env): HubBindingScope {
148
+ try {
149
+ const binding = readHubBinding(env);
150
+ if (binding?.cloud) {
151
+ try {
152
+ if (validateHubUrl(url) === binding.url) return "remote";
153
+ } catch {
154
+ // An unparsable override is not the cloud binding.
155
+ }
156
+ }
157
+ } catch (error) {
158
+ if (!(error instanceof HubBindingError)) throw error;
159
+ }
160
+ return hubBindingScope(url);
161
+ }
162
+
163
+ /** Hub URL for a session: explicit env, else the binding, else the local default. */
164
+ export function resolveHubServerUrl(env: NodeJS.ProcessEnv = process.env): string {
165
+ const fromEnv = env.KXM_SERVER_URL?.trim();
166
+ if (fromEnv) return fromEnv.replace(/\/$/, "");
167
+ try {
168
+ const binding = readHubBinding(env);
169
+ if (binding?.url) return binding.url;
170
+ } catch (error) {
171
+ if (!(error instanceof HubBindingError)) throw error;
172
+ }
173
+ return "http://127.0.0.1:7331";
174
+ }
175
+
176
+ /**
177
+ * Resolve the token a cloud binding names. Never reads hub-env and never uses
178
+ * `KXM_AUTH_TOKEN` unless `tokenEnv` is that name. `tokenEnv` wins when it is non-empty;
179
+ * otherwise the command runs. The command is not cached.
180
+ */
181
+ export function resolveCloudHubToken(binding: HubBindingRecord, env: NodeJS.ProcessEnv = process.env): string {
182
+ if (!binding.cloud) {
183
+ throw new CloudTokenError("cloud_token_missing", "cloud hub token is missing; the local hub-env token was not used");
184
+ }
185
+ if (binding.tokenEnv) {
186
+ const value = env[binding.tokenEnv]?.trim();
187
+ if (value) return value;
188
+ }
189
+ if (binding.tokenCommand) return runTokenCommand(binding.tokenCommand, env);
190
+ const source = binding.tokenEnv ? `${binding.tokenEnv} is unset or empty` : "no token source is configured";
191
+ throw new CloudTokenError(
192
+ "cloud_token_missing",
193
+ `cloud hub token is missing: ${source}; the local hub-env token was not used`,
194
+ );
195
+ }
196
+
197
+ function runTokenCommand(command: string, env: NodeJS.ProcessEnv): string {
198
+ const argv = splitTokenCommand(command);
199
+ const [program, ...args] = argv;
200
+ const result = spawnSync(program!, args, {
201
+ encoding: "utf8",
202
+ shell: false,
203
+ timeout: TOKEN_COMMAND_TIMEOUT_MS,
204
+ maxBuffer: TOKEN_COMMAND_MAX_BUFFER,
205
+ env,
206
+ windowsHide: true,
207
+ });
208
+ if (result.error || result.status !== 0) {
209
+ throw new CloudTokenError(
210
+ "cloud_token_command_failed",
211
+ "token command failed; the local hub-env token was not used",
212
+ );
213
+ }
214
+ const stdout = result.stdout ?? "";
215
+ if (stdout.trim().length === 0 || stdout.trim() !== stdout.trim().split(/\r?\n/)[0]) {
216
+ throw new CloudTokenError(
217
+ "cloud_token_command_failed",
218
+ "token command did not print a single token; the local hub-env token was not used",
219
+ );
220
+ }
221
+ return stdout.trim();
222
+ }
223
+
74
224
  /** Loopback literals only; `0.0.0.0`, a LAN address or a hostname are all remote. */
75
225
  export function hubBindingScope(url: string): HubBindingScope {
76
226
  let host: string;
@@ -111,8 +261,11 @@ export function readHubBinding(env: NodeJS.ProcessEnv = process.env): HubBinding
111
261
  }
112
262
  const row = parsed as Record<string, unknown>;
113
263
  const keys = Object.keys(row);
264
+ const allowed = new Set(["schema", "url", "boundAt", "cloud", "tokenEnv", "tokenCommand"]);
265
+ const cloudish = keys.some((key) => key === "cloud" || key === "tokenEnv" || key === "tokenCommand");
114
266
  if (
115
- keys.length !== 3
267
+ keys.some((key) => !allowed.has(key))
268
+ || (!cloudish && keys.length !== 3)
116
269
  || row.schema !== HUB_BINDING_SCHEMA
117
270
  || typeof row.url !== "string"
118
271
  || typeof row.boundAt !== "string"
@@ -126,7 +279,34 @@ export function readHubBinding(env: NodeJS.ProcessEnv = process.env): HubBinding
126
279
  } catch {
127
280
  throw new HubBindingError(`malformed hub binding at ${file}`);
128
281
  }
129
- return { schema: HUB_BINDING_SCHEMA, url, boundAt: row.boundAt };
282
+ if (!cloudish) return { schema: HUB_BINDING_SCHEMA, url, boundAt: row.boundAt };
283
+ if (row.cloud !== true) throw new HubBindingError(`malformed hub binding at ${file}`);
284
+ let tokenEnv: string | undefined;
285
+ let tokenCommand: string | undefined;
286
+ if (row.tokenEnv !== undefined) {
287
+ if (typeof row.tokenEnv !== "string" || !isCloudTokenEnvName(row.tokenEnv)) {
288
+ throw new HubBindingError(`malformed hub binding at ${file}`);
289
+ }
290
+ tokenEnv = row.tokenEnv;
291
+ }
292
+ if (row.tokenCommand !== undefined) {
293
+ if (typeof row.tokenCommand !== "string") throw new HubBindingError(`malformed hub binding at ${file}`);
294
+ try {
295
+ splitTokenCommand(row.tokenCommand);
296
+ } catch {
297
+ throw new HubBindingError(`malformed hub binding at ${file}`);
298
+ }
299
+ tokenCommand = row.tokenCommand;
300
+ }
301
+ if (!tokenEnv && !tokenCommand) throw new HubBindingError(`malformed hub binding at ${file}`);
302
+ return {
303
+ schema: HUB_BINDING_SCHEMA,
304
+ url,
305
+ boundAt: row.boundAt,
306
+ cloud: true,
307
+ ...(tokenEnv ? { tokenEnv } : {}),
308
+ ...(tokenCommand ? { tokenCommand } : {}),
309
+ };
130
310
  }
131
311
 
132
312
  export function writeHubBinding(record: HubBindingRecord, env: NodeJS.ProcessEnv = process.env): string {
@@ -2,6 +2,22 @@ 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";
13
+ import {
14
+ CloudTokenError,
15
+ HubBindingError,
16
+ hubBindingFile,
17
+ readHubBinding,
18
+ resolveCloudHubToken,
19
+ type HubBindingRecord,
20
+ } from "./hub-binding.ts";
5
21
 
6
22
  export const HUB_ENV_SCHEMA = "kxm.hub-env.v1" as const;
7
23
 
@@ -210,8 +226,81 @@ export function resolveHubCredentials(options: ResolveHubCredentialsOptions = {}
210
226
  * only another project's token cannot authorise this one, and a guard that counts it as a
211
227
  * credential stores a binding that will fail exactly like the one it prevented.
212
228
  */
213
- export function hasClientHubCredential(env: NodeJS.ProcessEnv = process.env, project?: string): boolean {
229
+ /** A cloud binding, when this machine has one. A malformed binding fails closed. */
230
+ function activeCloudBinding(env: NodeJS.ProcessEnv): HubBindingRecord | undefined {
231
+ try {
232
+ const binding = readHubBinding(env);
233
+ return binding?.cloud ? binding : undefined;
234
+ } catch (error) {
235
+ if (error instanceof HubBindingError) {
236
+ throw new HubEnvError(
237
+ `${error.message}; refusing to guess a credential — repair or remove ${hubBindingFile(env)}`,
238
+ );
239
+ }
240
+ throw error;
241
+ }
242
+ }
243
+
244
+ /**
245
+ * Credential for control-plane posts that are not agent sessions (context, degrade).
246
+ * Non-cloud keeps the historical rule: only the explicit `KXM_AUTH_TOKEN`, never hub-env.
247
+ * A cloud binding uses its named source and nothing else.
248
+ */
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;
256
+ return env.KXM_AUTH_TOKEN?.trim() || undefined;
257
+ }
258
+
259
+ /**
260
+ * Environment a pi or Claude session started through kxm should inherit.
261
+ * The project id comes from the shared resolver. A cloud binding adds
262
+ * `KXM_AUTH_TOKEN` in this object only — it is never written to the binding.
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
+
281
+ export function agentSessionEnv(
282
+ cwd: string,
283
+ env: NodeJS.ProcessEnv = process.env,
284
+ explicitProject?: string,
285
+ opRead?: OpReader,
286
+ ): NodeJS.ProcessEnv {
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;
293
+ }
294
+ return session;
295
+ }
296
+
297
+ export function hasClientHubCredential(env: NodeJS.ProcessEnv = process.env, project?: string, cwd: string = process.cwd()): boolean {
214
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;
215
304
  let record: HubEnvRecord | undefined;
216
305
  try {
217
306
  record = readHubEnvRecord(env);
@@ -228,9 +317,18 @@ export function hasClientHubCredential(env: NodeJS.ProcessEnv = process.env, pro
228
317
  return Object.values(tokens).some((token) => typeof token === "string" && token.trim().length > 0);
229
318
  }
230
319
 
231
- export function resolveClientHubAuthToken(env: NodeJS.ProcessEnv, project: string): string | undefined {
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;
232
328
  const envToken = env.KXM_AUTH_TOKEN?.trim();
233
329
  if (envToken) return envToken;
330
+ const fromConfig = resolveKeyReference(describeHubConnection(cwd, env).endpoint?.key, env, opRead);
331
+ if (fromConfig) return fromConfig.value;
234
332
  const record = readHubEnvRecord(env);
235
333
  return record?.projectTokens?.[project]?.trim() || record?.authToken?.trim() || undefined;
236
334
  }
@@ -244,9 +342,20 @@ export function resolveClientHubAuthToken(env: NodeJS.ProcessEnv, project: strin
244
342
  * token for. Operator tools keep `resolveClientHubAuthToken`. Throws HubEnvError on a
245
343
  * malformed persisted record, same as `resolveClientHubAuthToken`.
246
344
  */
247
- export function resolveAgentHubAuthToken(env: NodeJS.ProcessEnv, project: string): string | undefined {
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);
352
+ // A cloud binding's token is the remote hub's credential. It is not this
353
+ // machine's hub-env admin token, which an agent must still never use.
354
+ if (cloud) return cloud;
248
355
  const envToken = env.KXM_AUTH_TOKEN?.trim();
249
356
  if (envToken) return envToken;
357
+ const fromConfig = resolveKeyReference(describeHubConnection(cwd, env).endpoint?.key, env, opRead);
358
+ if (fromConfig) return fromConfig.value;
250
359
  const tokens = readHubEnvRecord(env)?.projectTokens;
251
360
  if (!tokens || !Object.hasOwn(tokens, project)) return undefined;
252
361
  return tokens[project]?.trim() || undefined;
@@ -259,13 +368,19 @@ export function resolveAgentHubAuthToken(env: NodeJS.ProcessEnv, project: string
259
368
  export class AgentProjectTokenMissingError extends Error {
260
369
  readonly code = "project_token_missing";
261
370
  readonly project: string;
371
+ readonly projectSource: ProjectIdentity["source"] | "unknown";
372
+ readonly hint: string;
262
373
 
263
- constructor(project: string) {
264
- super(
265
- `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.`,
266
- );
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);
267
380
  this.name = "AgentProjectTokenMissingError";
268
- this.project = project;
381
+ this.project = identity.project;
382
+ this.projectSource = typeof projectOrIdentity === "string" ? "unknown" : identity.source;
383
+ this.hint = message;
269
384
  }
270
385
  }
271
386
 
@@ -276,8 +391,16 @@ export class AgentProjectTokenMissingError extends Error {
276
391
  * refuse — handing the portal a 401 dressed as "configured" is worse than handing it none.
277
392
  * Throws on a malformed persisted record, same as `resolveClientHubAuthToken`.
278
393
  */
279
- export function resolveClientAdminAuthToken(env: NodeJS.ProcessEnv = process.env): string | undefined {
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;
280
401
  const envToken = env.KXM_AUTH_TOKEN?.trim();
281
402
  if (envToken) return envToken;
282
403
  return readHubEnvRecord(env)?.authToken?.trim() || undefined;
283
404
  }
405
+
406
+ export { CloudTokenError };