@kontextmind/kxm 0.7.167 → 0.7.169

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 (35) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +30 -0
  3. package/docs/contributing/ci-and-release.md +6 -1
  4. package/docs/operations/runtime-sync.md +2 -1
  5. package/docs/operations/troubleshooting.md +4 -0
  6. package/docs/operations/upgrade.md +18 -3
  7. package/docs/operations.md +70 -7
  8. package/docs/reference/cli-reference.md +21 -6
  9. package/docs/reference/config-reference.md +28 -13
  10. package/docs/start/install.md +1 -1
  11. package/package.json +1 -1
  12. package/plugins/kxm/.claude-plugin/plugin.json +1 -1
  13. package/plugins/kxm/dist/claude-hook.js +170 -36
  14. package/plugins/kxm/dist/cli.js +451 -132
  15. package/plugins/kxm/dist/extension.js +184 -53
  16. package/plugins/kxm/dist/mcp-server.js +97 -40
  17. package/plugins/kxm/dist/runtime-supervisor.js +240 -47
  18. package/plugins/kxm/dist/runtime.js +240 -47
  19. package/plugins/kxm/dist/server.js +9 -0
  20. package/plugins/kxm/package.json +1 -1
  21. package/plugins/kxm/skills/kxm-definitions/SKILL.md +3 -2
  22. package/plugins/kxm/skills/kxm-harness-auth/SKILL.md +4 -2
  23. package/plugins/kxm/skills/kxm-hub-ops/SKILL.md +9 -4
  24. package/plugins/kxm/src/cli/hub.ts +42 -7
  25. package/plugins/kxm/src/cli/system.ts +3 -1
  26. package/plugins/kxm/src/cli/types.ts +5 -3
  27. package/plugins/kxm/src/cli.ts +2 -1
  28. package/plugins/kxm/src/engine.ts +105 -8
  29. package/plugins/kxm/src/hub-binding.ts +301 -55
  30. package/plugins/kxm/src/hub-identity.ts +17 -9
  31. package/plugins/kxm/src/kxm-update.ts +29 -6
  32. package/plugins/kxm/src/mcp-server.ts +1 -1
  33. package/plugins/kxm/src/project-config.ts +63 -1
  34. package/plugins/kxm/src/runtime-supervisor.ts +22 -4
  35. package/scripts/kxm-publish-npm.mjs +47 -1
@@ -113,7 +113,7 @@ import {
113
113
  type KxmRunRecord,
114
114
  type KxmRunStatus,
115
115
  } from "./runtime-store.ts";
116
- import { KxmConfigError, kxmCanonicalJson, lookupKxmResource, type JsonValue, type KxmProjectBundle } from "./project-config.ts";
116
+ import { KxmConfigError, kxmCanonicalJson, lookupKxmResource, toolPresetExceedsRoleMessage, toolPresetProfile, type JsonValue, type KxmProjectBundle } from "./project-config.ts";
117
117
  import { evaluateArtifactsGate } from "./engine-artifacts.ts";
118
118
  import { createCommandObserver } from "./engine-command.ts";
119
119
  import {
@@ -1726,6 +1726,85 @@ function unsupportedAgentStepTimeout(step: KxmCompiledStep, limitMs: number): Om
1726
1726
  };
1727
1727
  }
1728
1728
 
1729
+ function narrowerPermission(left: "read-only" | "edit", right: "read-only" | "edit"): "read-only" | "edit" {
1730
+ return left === "read-only" || right === "read-only" ? "read-only" : "edit";
1731
+ }
1732
+
1733
+ function stepRepositoryPermission(step: KxmCompiledStep): "read-only" | "edit" {
1734
+ return Object.values(step.repositories).some((access) => access === "write") ? "edit" : "read-only";
1735
+ }
1736
+
1737
+ function stepAccessDetail(step: KxmCompiledStep): string {
1738
+ const entries = Object.entries(step.repositories);
1739
+ if (entries.length === 0) return "none";
1740
+ return entries
1741
+ .map(([id, access]) => `${id} ${access}`)
1742
+ .sort((left, right) => (left < right ? -1 : left > right ? 1 : 0))
1743
+ .join(", ");
1744
+ }
1745
+
1746
+ function yamlToolsPreset(doc: Record<string, unknown> | undefined): string | undefined {
1747
+ const tools = doc?.tools;
1748
+ if (!tools || typeof tools !== "object" || Array.isArray(tools)) return undefined;
1749
+ const preset = (tools as { preset?: unknown }).preset;
1750
+ return typeof preset === "string" && preset.length > 0 ? preset : undefined;
1751
+ }
1752
+
1753
+ /**
1754
+ * Dispatch permission is the narrowest of the role permission, the preset
1755
+ * profile, and the step repository access. A wider agent preset, or a write
1756
+ * step on a read-only chain, is step_unsupported.
1757
+ */
1758
+ function resolveToolDispatch(
1759
+ projectRoot: string,
1760
+ step: KxmCompiledStep,
1761
+ agentId: string,
1762
+ ): { permission: "read-only" | "edit" } | { error: Omit<KxmRunHandoff, "stepId"> } {
1763
+ const stepPermission = stepRepositoryPermission(step);
1764
+ if (step.kind !== "agent" && step.kind !== "moa") return { permission: stepPermission };
1765
+ const agentFile = findYamlBasename(join(projectRoot, ".kxm", "agents"), agentId, "agent") ?? agentId;
1766
+ const agent = readYamlFile(join(projectRoot, ".kxm", "agents", `${agentFile}.yaml`));
1767
+ const roleId = typeof agent?.role === "string" ? agent.role : "";
1768
+ if (!roleId) return { permission: stepPermission };
1769
+ const roleDoc = readYamlFile(join(projectRoot, ".kxm", "roles", `${roleId}.yaml`));
1770
+ const rawPermission = roleDoc?.permission;
1771
+ const rolePermission: "edit" | "read-only" | undefined = rawPermission === "edit" || rawPermission === "read-only" ? rawPermission : undefined;
1772
+ if (!rolePermission) return { permission: stepPermission };
1773
+ const rolePreset = yamlToolsPreset(roleDoc);
1774
+ const agentPreset = yamlToolsPreset(agent);
1775
+ const rolePresetProfile = rolePreset ? toolPresetProfile(rolePreset) : undefined;
1776
+ if (rolePreset && rolePresetProfile === "edit" && rolePermission === "read-only") {
1777
+ return {
1778
+ error: {
1779
+ reason: "step_unsupported",
1780
+ field: "tools.preset",
1781
+ detail: `tools.preset ${rolePreset} exceeds role ${roleId} permission read-only`,
1782
+ },
1783
+ };
1784
+ }
1785
+ if (agentPreset) {
1786
+ const exceeds = toolPresetExceedsRoleMessage(agentPreset, roleId, rolePermission, rolePreset);
1787
+ if (exceeds) {
1788
+ return { error: { reason: "step_unsupported", field: "tools.preset", detail: exceeds } };
1789
+ }
1790
+ }
1791
+ let chain: "read-only" | "edit" = rolePermission;
1792
+ if (rolePresetProfile) chain = narrowerPermission(chain, rolePresetProfile);
1793
+ const agentProfile = agentPreset ? toolPresetProfile(agentPreset) : undefined;
1794
+ if (agentProfile) chain = narrowerPermission(chain, agentProfile);
1795
+ const permission = narrowerPermission(chain, stepPermission);
1796
+ if (stepPermission === "edit" && permission === "read-only") {
1797
+ return {
1798
+ error: {
1799
+ reason: "step_unsupported",
1800
+ field: "permission",
1801
+ detail: `role permission ${rolePermission} resolves to ${chain}; step access ${stepAccessDetail(step)}`,
1802
+ },
1803
+ };
1804
+ }
1805
+ return { permission };
1806
+ }
1807
+
1729
1808
  function executingSingletonAttempt(
1730
1809
  plan: KxmCompiledPlan,
1731
1810
  state: KxmRunState,
@@ -1944,12 +2023,21 @@ function prepareDispatch(
1944
2023
  const wideTimeout = unsupportedAgentStepTimeout(step, projectAgentStepTimeoutMs(context, run));
1945
2024
  if (wideTimeout) return { kind: "return", state, handoff: { ...wideTimeout, stepId } };
1946
2025
 
2026
+ const firstListed = step.assignments.allowedAgents[0];
2027
+ const firstAgentId = (step.kind === "agent" || step.kind === "moa")
2028
+ ? (firstListed ? firstListed : step.agent)
2029
+ : undefined;
2030
+ if (firstAgentId) {
2031
+ const toolDispatch = resolveToolDispatch(context.projectRoot, step, firstAgentId);
2032
+ if ("error" in toolDispatch) {
2033
+ return { kind: "return", state, handoff: { ...toolDispatch.error, stepId } };
2034
+ }
2035
+ }
2036
+
1947
2037
  let resolvedRoute: ResolvedProducerRoute | undefined;
1948
2038
  if (producerId !== "driver-simulated") {
1949
- const allowed = step.assignments.allowedAgents;
1950
- const agentId = (allowed && allowed.length > 0 && allowed[0])
1951
- ? allowed[0]!
1952
- : (step.kind === "agent" || step.kind === "moa" ? step.agent : "coordinator");
2039
+ const agentId = firstAgentId
2040
+ ?? ((step.kind === "agent" || step.kind === "moa") ? step.agent : "coordinator");
1953
2041
  const routeResult = resolveProducerRoute(context.projectRoot, step, agentId);
1954
2042
  if ("error" in routeResult) {
1955
2043
  return { kind: "return", state, handoff: { ...routeResult.error, stepId } };
@@ -2092,6 +2180,10 @@ function birthMember(
2092
2180
  const agentId = (allowed && allowed.length > born && allowed[born])
2093
2181
  ? allowed[born]!
2094
2182
  : (input.step.kind === "agent" || input.step.kind === "moa" ? input.step.agent : "coordinator");
2183
+ const toolDispatch = resolveToolDispatch(context.projectRoot, input.step, agentId);
2184
+ if ("error" in toolDispatch) {
2185
+ return { handoff: { ...toolDispatch.error, stepId: input.stepId } };
2186
+ }
2095
2187
  let resolvedRoute = input.resolvedRoute;
2096
2188
  if (!resolvedRoute && input.producerId && input.producerId !== "driver-simulated") {
2097
2189
  const routeResult = resolveProducerRoute(context.projectRoot, input.step, agentId);
@@ -2183,7 +2275,7 @@ function birthMember(
2183
2275
  stepAttempt: input.stepAttempt,
2184
2276
  objective: promptText,
2185
2277
  allowedOutcomes: [...input.step.outcomes],
2186
- permissionCeiling: Object.values(input.step.repositories).some((access) => access === "write") ? "edit" : "read-only",
2278
+ permissionCeiling: toolDispatch.permission,
2187
2279
  },
2188
2280
  acceptanceCriteria: input.step.requiredEvidence.map((ev) => ({
2189
2281
  id: ev.key,
@@ -2227,7 +2319,7 @@ function birthMember(
2227
2319
  allowedOutcomes: input.step.outcomes,
2228
2320
  signal: controller.signal,
2229
2321
  prompt: input.step.instructions ? `${input.step.instructions}\n\n${generatedPrompt}` : generatedPrompt,
2230
- permission: Object.values(input.step.repositories).some((access) => access === "write") ? "edit" : "read-only",
2322
+ permission: toolDispatch.permission,
2231
2323
  ...((input.step.kind === "agent" || input.step.kind === "moa") && input.step.timeoutMs !== undefined
2232
2324
  ? { timeoutMs: input.step.timeoutMs }
2233
2325
  : {}),
@@ -3144,6 +3236,11 @@ export function kxmLiveRunPrerequisites(
3144
3236
  if (step.kind !== "agent" && step.kind !== "moa") continue;
3145
3237
  const agentIds = step.assignments.allowedAgents.length > 0 ? step.assignments.allowedAgents : [step.agent];
3146
3238
  for (const agentId of agentIds) {
3239
+ const toolDispatch = resolveToolDispatch(projectRoot, step, agentId);
3240
+ if ("error" in toolDispatch) {
3241
+ prerequisites.push({ ...toolDispatch.error, stepId });
3242
+ continue;
3243
+ }
3147
3244
  const route = resolveProducerRoute(projectRoot, step, agentId);
3148
3245
  if ("error" in route) {
3149
3246
  const detail = route.error.detail === "producer_route_unsupported: agent step model is not honored"
@@ -3153,7 +3250,7 @@ export function kxmLiveRunPrerequisites(
3153
3250
  continue;
3154
3251
  }
3155
3252
  const harness = route.harness;
3156
- const permission = Object.values(step.repositories).includes("write") ? "edit" : "read-only";
3253
+ const permission = toolDispatch.permission;
3157
3254
  if (!BUILTIN_HARNESSES.some((entry) => entry.id === harness && entry.oneShot) || !oneShotPermissionArgs(harness, permission)) {
3158
3255
  prerequisites.push({ reason: "step_unsupported", stepId, field: "harness", detail: `${agentId}: ${harness} has no audited ${permission} one-shot profile; use a supported workflow or execute this work directly in ${harness}, without substituting another harness` });
3159
3256
  continue;
@@ -74,6 +74,15 @@ export function hubBindingFile(env: NodeJS.ProcessEnv = process.env): string {
74
74
  return join(resolveUserStateRoot(env), "hub-binding.json");
75
75
  }
76
76
 
77
+ /**
78
+ * Cloud token source for a machine-wide bind. Kept out of `hub-binding.json`
79
+ * so a 0.7.159 reader, which accepts only `schema`, `url`, and `boundAt`,
80
+ * does not throw and stop syncing.
81
+ */
82
+ export function hubBindingCloudFile(env: NodeJS.ProcessEnv = process.env): string {
83
+ return join(resolveUserStateRoot(env), "hub-binding.cloud.json");
84
+ }
85
+
77
86
  export function validateHubUrl(raw: string): string {
78
87
  let parsed: URL;
79
88
  try {
@@ -247,40 +256,50 @@ function isAbortError(error: unknown): boolean {
247
256
  );
248
257
  }
249
258
 
250
- export function readHubBinding(env: NodeJS.ProcessEnv = process.env): HubBindingRecord | undefined {
251
- const file = hubBindingFile(env);
252
- if (!existsSync(file)) return undefined;
259
+ const LEGACY_BINDING_KEYS = ["schema", "url", "boundAt"] as const;
260
+ const BINDING_KEYS = new Set<string>([...LEGACY_BINDING_KEYS, "cloud", "tokenEnv", "tokenCommand"]);
261
+
262
+ export interface HubBindingLoad {
263
+ record?: HubBindingRecord;
264
+ /**
265
+ * Set when this build ignores the file and continues against the local hub.
266
+ * A newer schema, or a cloud record an older process cannot read, must not
267
+ * throw into the Runtime sync loop.
268
+ */
269
+ warning?: string;
270
+ }
271
+
272
+ function bindingFallbackWarning(file: string, reason: string): string {
273
+ return `hub binding at ${file} ${reason}; falling back to the local hub. Restart the Runtime supervisor, the hub, and workers after upgrading and before a cloud bind.`;
274
+ }
275
+
276
+ function writeBindingJson(file: string, value: unknown): void {
277
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
278
+ const temporary = join(dirname(file), `.${file.endsWith("cloud.json") ? "hub-binding-cloud" : "hub-binding"}-${process.pid}.tmp`);
279
+ try {
280
+ writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
281
+ renameSync(temporary, file);
282
+ } finally {
283
+ rmSync(temporary, { force: true });
284
+ }
285
+ }
286
+
287
+ function parseBindingObject(file: string): Record<string, unknown> | HubBindingError {
288
+ if (!existsSync(file)) return new HubBindingError(`missing hub binding at ${file}`);
253
289
  let parsed: unknown;
254
290
  try {
255
291
  parsed = JSON.parse(readFileSync(file, "utf8"));
256
292
  } catch {
257
- throw new HubBindingError(`malformed hub binding at ${file}`);
293
+ return new HubBindingError(`malformed hub binding at ${file}`);
258
294
  }
259
295
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
260
- throw new HubBindingError(`malformed hub binding at ${file}`);
261
- }
262
- const row = parsed as Record<string, unknown>;
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");
266
- if (
267
- keys.some((key) => !allowed.has(key))
268
- || (!cloudish && keys.length !== 3)
269
- || row.schema !== HUB_BINDING_SCHEMA
270
- || typeof row.url !== "string"
271
- || typeof row.boundAt !== "string"
272
- || !isIsoTimestamp(row.boundAt)
273
- ) {
274
- throw new HubBindingError(`malformed hub binding at ${file}`);
275
- }
276
- let url: string;
277
- try {
278
- url = validateHubUrl(row.url);
279
- } catch {
280
- throw new HubBindingError(`malformed hub binding at ${file}`);
296
+ return new HubBindingError(`malformed hub binding at ${file}`);
281
297
  }
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}`);
298
+ return parsed as Record<string, unknown>;
299
+ }
300
+
301
+ function cloudRecordFromRow(file: string, row: Record<string, unknown>, url: string, boundAt: string): HubBindingRecord | undefined {
302
+ if (row.cloud !== true) return undefined;
284
303
  let tokenEnv: string | undefined;
285
304
  let tokenCommand: string | undefined;
286
305
  if (row.tokenEnv !== undefined) {
@@ -302,34 +321,272 @@ export function readHubBinding(env: NodeJS.ProcessEnv = process.env): HubBinding
302
321
  return {
303
322
  schema: HUB_BINDING_SCHEMA,
304
323
  url,
305
- boundAt: row.boundAt,
324
+ boundAt,
306
325
  cloud: true,
307
326
  ...(tokenEnv ? { tokenEnv } : {}),
308
327
  ...(tokenCommand ? { tokenCommand } : {}),
309
328
  };
310
329
  }
311
330
 
312
- export function writeHubBinding(record: HubBindingRecord, env: NodeJS.ProcessEnv = process.env): string {
331
+ /**
332
+ * The reader shipped in 0.7.159. It accepts only `schema`, `url`, and
333
+ * `boundAt`. Extra keys throw `malformed hub binding`, which stopped sync.
334
+ * New cloud binds keep this file in that shape, or omit it, so this reader
335
+ * falls through to the local hub instead of throwing.
336
+ */
337
+ export function readLegacyV1HubBinding(env: NodeJS.ProcessEnv = process.env): HubBindingRecord | undefined {
313
338
  const file = hubBindingFile(env);
314
- mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
315
- const temporary = join(dirname(file), `.hub-binding-${process.pid}.tmp`);
339
+ if (!existsSync(file)) return undefined;
340
+ const parsed = parseBindingObject(file);
341
+ if (parsed instanceof HubBindingError) throw parsed;
342
+ const keys = Object.keys(parsed);
343
+ if (
344
+ keys.length !== 3
345
+ || keys.some((key) => !LEGACY_BINDING_KEYS.includes(key as typeof LEGACY_BINDING_KEYS[number]))
346
+ || parsed.schema !== HUB_BINDING_SCHEMA
347
+ || typeof parsed.url !== "string"
348
+ || typeof parsed.boundAt !== "string"
349
+ || !isIsoTimestamp(parsed.boundAt)
350
+ ) {
351
+ throw new HubBindingError(`malformed hub binding at ${file}`);
352
+ }
353
+ let url: string;
316
354
  try {
317
- writeFileSync(temporary, `${JSON.stringify(record, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
318
- renameSync(temporary, file);
319
- } finally {
320
- rmSync(temporary, { force: true });
355
+ url = validateHubUrl(parsed.url);
356
+ } catch {
357
+ throw new HubBindingError(`malformed hub binding at ${file}`);
358
+ }
359
+ return { schema: HUB_BINDING_SCHEMA, url, boundAt: parsed.boundAt };
360
+ }
361
+
362
+ function interpretBindingFile(file: string): HubBindingLoad & { legacyCloud?: boolean } {
363
+ if (!existsSync(file)) return {};
364
+ const parsed = parseBindingObject(file);
365
+ if (parsed instanceof HubBindingError) throw parsed;
366
+ const keys = Object.keys(parsed);
367
+ const unknown = keys.filter((key) => !BINDING_KEYS.has(key));
368
+ if (parsed.schema !== HUB_BINDING_SCHEMA || unknown.length > 0) {
369
+ return { warning: bindingFallbackWarning(file, `is newer than this build understands (${String(parsed.schema)})`) };
370
+ }
371
+ if (typeof parsed.url !== "string" || typeof parsed.boundAt !== "string" || !isIsoTimestamp(parsed.boundAt)) {
372
+ throw new HubBindingError(`malformed hub binding at ${file}`);
373
+ }
374
+ let url: string;
375
+ try {
376
+ url = validateHubUrl(parsed.url);
377
+ } catch {
378
+ throw new HubBindingError(`malformed hub binding at ${file}`);
379
+ }
380
+ const cloudish = keys.some((key) => key === "cloud" || key === "tokenEnv" || key === "tokenCommand");
381
+ if (!cloudish) {
382
+ if (keys.length !== 3) throw new HubBindingError(`malformed hub binding at ${file}`);
383
+ return { record: { schema: HUB_BINDING_SCHEMA, url, boundAt: parsed.boundAt } };
384
+ }
385
+ const record = cloudRecordFromRow(file, parsed, url, parsed.boundAt);
386
+ if (!record) throw new HubBindingError(`malformed hub binding at ${file}`);
387
+ return { record, legacyCloud: true };
388
+ }
389
+
390
+ /**
391
+ * Cloud metadata wins over a local `hub-binding.json`. A legacy cloud record
392
+ * that still lives in `hub-binding.json` is moved aside so a 0.7.159 process
393
+ * no longer throws on the next read.
394
+ */
395
+ export function loadHubBinding(env: NodeJS.ProcessEnv = process.env): HubBindingLoad {
396
+ const cloudFile = hubBindingCloudFile(env);
397
+ const cloud = interpretBindingFile(cloudFile);
398
+ if (cloud.warning && !cloud.record) {
399
+ const local = existsSync(hubBindingFile(env)) ? interpretBindingFile(hubBindingFile(env)) : {};
400
+ if (local.record && !local.record.cloud) return { record: local.record, warning: cloud.warning };
401
+ return { warning: cloud.warning };
402
+ }
403
+ if (cloud.record?.cloud) return { record: cloud.record, ...(cloud.warning ? { warning: cloud.warning } : {}) };
404
+ const main = interpretBindingFile(hubBindingFile(env));
405
+ if (main.legacyCloud && main.record) {
406
+ writeBindingJson(cloudFile, main.record);
407
+ rmSync(hubBindingFile(env), { force: true });
408
+ return {
409
+ record: main.record,
410
+ warning: bindingFallbackWarning(
411
+ hubBindingFile(env),
412
+ "stored a cloud bind in the pre-0.7.160 file",
413
+ ),
414
+ };
415
+ }
416
+ return { ...(main.record ? { record: main.record } : {}), ...(main.warning ? { warning: main.warning } : {}) };
417
+ }
418
+
419
+ export function readHubBinding(env: NodeJS.ProcessEnv = process.env): HubBindingRecord | undefined {
420
+ return loadHubBinding(env).record;
421
+ }
422
+
423
+ export function writeHubBinding(record: HubBindingRecord, env: NodeJS.ProcessEnv = process.env): string {
424
+ if (record.cloud) {
425
+ const file = hubBindingCloudFile(env);
426
+ writeBindingJson(file, record);
427
+ // A 0.7.159 reader throws on extra keys and stops sync. Leave a local
428
+ // three-key file alone so that reader keeps the local hub. Drop a file
429
+ // that points at this cloud URL, or that already carries cloud keys.
430
+ const main = hubBindingFile(env);
431
+ if (existsSync(main)) {
432
+ try {
433
+ const legacy = readLegacyV1HubBinding(env);
434
+ if (!legacy || legacy.url === record.url) rmSync(main, { force: true });
435
+ } catch (error) {
436
+ if (!(error instanceof HubBindingError)) throw error;
437
+ rmSync(main, { force: true });
438
+ }
439
+ }
440
+ return file;
321
441
  }
442
+ rmSync(hubBindingCloudFile(env), { force: true });
443
+ const file = hubBindingFile(env);
444
+ writeBindingJson(file, {
445
+ schema: HUB_BINDING_SCHEMA,
446
+ url: record.url,
447
+ boundAt: record.boundAt,
448
+ });
322
449
  return file;
323
450
  }
324
451
 
325
452
  export function removeHubBinding(env: NodeJS.ProcessEnv = process.env): boolean {
326
- const file = hubBindingFile(env);
453
+ const files = [hubBindingFile(env), hubBindingCloudFile(env)];
454
+ let removed = false;
455
+ for (const file of files) {
456
+ try {
457
+ rmSync(file);
458
+ removed = true;
459
+ } catch (error) {
460
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
461
+ }
462
+ }
463
+ return removed;
464
+ }
465
+
466
+ export type HubProbeKind = "hub" | "auth_proxy" | "redirect" | "not_hub" | "unreachable" | "timeout";
467
+
468
+ export interface HubProbe {
469
+ health: HubHealth;
470
+ probeMs: number;
471
+ kind: HubProbeKind;
472
+ detail: string;
473
+ status: number;
474
+ }
475
+
476
+ export interface HubEndpointVerdict {
477
+ ok: boolean;
478
+ status: number;
479
+ kind: HubProbeKind;
480
+ detail: string;
481
+ /** Hub JSON when the body matches. Never the raw HTML of a login page. */
482
+ body: unknown;
483
+ }
484
+
485
+ function contentTypeBase(header: string | null): string {
486
+ return (header ?? "").split(";")[0]!.trim().toLowerCase();
487
+ }
488
+
489
+ function looksLikeHtml(contentType: string, body: string): boolean {
490
+ if (contentType === "text/html" || contentType === "application/xhtml+xml") return true;
491
+ const sample = body.slice(0, 800).trim().toLowerCase();
492
+ return sample.startsWith("<!doctype html")
493
+ || sample.startsWith("<html")
494
+ || (sample.includes("<form") && (sample.includes("login") || sample.includes("authentik") || sample.includes("password")));
495
+ }
496
+
497
+ function jsonObject(text: string): Record<string, unknown> | undefined {
498
+ try {
499
+ const parsed = JSON.parse(text) as unknown;
500
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
501
+ return parsed as Record<string, unknown>;
502
+ } catch {
503
+ return undefined;
504
+ }
505
+ }
506
+
507
+ /** `/health` is `{ ok: true, agents: <number> }`. `/ready` adds `storage`. */
508
+ export function hubEndpointShape(role: "health" | "ready", body: Record<string, unknown>): boolean {
509
+ if (role === "health") return body.ok === true && typeof body.agents === "number";
510
+ return body.ok === true && (body.storage === "sqlite" || body.storage === "memory");
511
+ }
512
+
513
+ /**
514
+ * A kxm hub answers JSON with the documented shape and `application/json`.
515
+ * An auth proxy's 30x or HTML login page is not that, even on HTTP 200.
516
+ */
517
+ export function classifyHubResponse(input: {
518
+ role: "health" | "ready";
519
+ status: number;
520
+ contentType: string | null;
521
+ bodyText: string;
522
+ }): HubEndpointVerdict {
523
+ const contentType = contentTypeBase(input.contentType);
524
+ if (input.status >= 300 && input.status < 400) {
525
+ return {
526
+ ok: false,
527
+ status: input.status,
528
+ kind: "redirect",
529
+ detail: `HTTP ${input.status} redirect; this looks like an auth proxy or a redirect, not a kxm hub`,
530
+ body: { error: "auth_proxy", detail: `HTTP ${input.status} redirect` },
531
+ };
532
+ }
533
+ if (looksLikeHtml(contentType, input.bodyText)) {
534
+ return {
535
+ ok: false,
536
+ status: input.status,
537
+ kind: "auth_proxy",
538
+ detail: `HTTP ${input.status} returned an HTML login page${contentType ? ` (${contentType})` : ""}; this looks like an auth proxy, not a kxm hub`,
539
+ body: { error: "auth_proxy", detail: "HTML login page" },
540
+ };
541
+ }
542
+ const jsonType = contentType === "application/json" || contentType === "text/plain" || contentType === "";
543
+ const body = jsonType ? jsonObject(input.bodyText) : undefined;
544
+ if (body && hubEndpointShape(input.role, body) && input.status >= 200 && input.status < 300) {
545
+ if (contentType !== "" && contentType !== "application/json" && contentType !== "text/plain") {
546
+ return {
547
+ ok: false,
548
+ status: input.status,
549
+ kind: "not_hub",
550
+ detail: `HTTP ${input.status} content-type ${contentType} is not application/json`,
551
+ body: { error: "not_hub", detail: contentType },
552
+ };
553
+ }
554
+ return { ok: true, status: input.status, kind: "hub", detail: "kxm hub", body };
555
+ }
556
+ const typeNote = contentType && contentType !== "application/json" ? ` content-type ${contentType}` : "";
557
+ return {
558
+ ok: false,
559
+ status: input.status,
560
+ kind: "not_hub",
561
+ detail: `HTTP ${input.status}${typeNote} is not a kxm hub ${input.role} response`,
562
+ body: { error: "not_hub", detail: `HTTP ${input.status}${typeNote}` },
563
+ };
564
+ }
565
+
566
+ export async function readHubEndpoint(
567
+ url: string,
568
+ role: "health" | "ready",
569
+ fetchImpl: typeof fetch,
570
+ init: { headers?: Record<string, string>; signal?: AbortSignal } = {},
571
+ ): Promise<HubEndpointVerdict> {
327
572
  try {
328
- rmSync(file);
329
- return true;
573
+ const response = await fetchImpl(`${url}/${role}`, {
574
+ ...(init.headers ? { headers: init.headers } : {}),
575
+ ...(init.signal ? { signal: init.signal } : {}),
576
+ redirect: "manual",
577
+ });
578
+ const bodyText = await response.text();
579
+ return classifyHubResponse({
580
+ role,
581
+ status: response.status,
582
+ contentType: response.headers.get("content-type"),
583
+ bodyText,
584
+ });
330
585
  } catch (error) {
331
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
332
- throw error;
586
+ if (isAbortError(error)) {
587
+ return { ok: false, status: 0, kind: "timeout", detail: "no reply within the probe budget", body: { error: "hub_unreachable" } };
588
+ }
589
+ return { ok: false, status: 0, kind: "unreachable", detail: "nothing answered", body: { error: "hub_unreachable" } };
333
590
  }
334
591
  }
335
592
 
@@ -337,25 +594,14 @@ export async function probeHubHealth(
337
594
  url: string,
338
595
  fetchImpl: typeof fetch,
339
596
  timeoutMs = HUB_HEALTH_PROBE_MS,
340
- ): Promise<{ health: HubHealth; probeMs: number }> {
597
+ ): Promise<HubProbe> {
341
598
  const started = Date.now();
342
599
  const controller = new AbortController();
343
600
  const timer = setTimeout(() => controller.abort(), timeoutMs);
344
601
  try {
345
- const response = await fetchImpl(`${url}/health`, { signal: controller.signal });
346
- if (!response.ok) return { health: "unknown", probeMs: Date.now() - started };
347
- let body: unknown;
348
- try {
349
- body = await response.json();
350
- } catch {
351
- return { health: "unknown", probeMs: Date.now() - started };
352
- }
353
- if (body && typeof body === "object" && (body as { ok?: unknown }).ok === true) {
354
- return { health: "on", probeMs: Date.now() - started };
355
- }
356
- return { health: "unknown", probeMs: Date.now() - started };
357
- } catch (error) {
358
- return { health: isAbortError(error) ? "unknown" : "off", probeMs: Date.now() - started };
602
+ const verdict = await readHubEndpoint(url, "health", fetchImpl, { signal: controller.signal });
603
+ const health: HubHealth = verdict.kind === "hub" ? "on" : verdict.kind === "unreachable" ? "off" : "unknown";
604
+ return { health, probeMs: Date.now() - started, kind: verdict.kind, detail: verdict.detail, status: verdict.status };
359
605
  } finally {
360
606
  clearTimeout(timer);
361
607
  }
@@ -210,18 +210,26 @@ function keySourceFor(input: {
210
210
  hasSavedProjectToken?: (project: string) => boolean;
211
211
  }): string {
212
212
  const ref = input.endpoint?.key;
213
- if (input.mode === "local" && input.env.KXM_AUTH_TOKEN?.trim()) return "env:KXM_AUTH_TOKEN";
214
- if (ref?.env && input.env[ref.env]?.trim()) return `env:${ref.env}`;
215
- if (ref?.op) return `op:${ref.op}`;
216
- if (ref?.env) return `env:${ref.env}`;
213
+ const envName = (name: string | undefined): string | undefined => {
214
+ if (!name) return undefined;
215
+ return input.env[name]?.trim() ? `env:${name}` : undefined;
216
+ };
217
+ // Name the source that would actually supply a token. An unset variable is
218
+ // not that source, even when config still lists it.
217
219
  if (input.mode === "cloud") {
218
- if (input.binding?.tokenEnv) return `env:${input.binding.tokenEnv}`;
219
- if (input.binding?.tokenCommand) {
220
- const op = opReferenceFromTokenCommand(input.binding.tokenCommand);
221
- return op ? `op:${op}` : "command";
222
- }
220
+ const fromEnv = envName(ref?.env);
221
+ if (fromEnv) return fromEnv;
222
+ if (ref?.op) return `op:${ref.op}`;
223
+ const fromBindingEnv = envName(input.binding?.tokenEnv);
224
+ if (fromBindingEnv) return fromBindingEnv;
225
+ if (input.binding?.tokenCommand) return "token-command";
223
226
  return "missing";
224
227
  }
228
+ const fromAuth = envName("KXM_AUTH_TOKEN");
229
+ if (fromAuth) return fromAuth;
230
+ const fromEnv = envName(ref?.env);
231
+ if (fromEnv) return fromEnv;
232
+ if (ref?.op) return `op:${ref.op}`;
225
233
  if (input.hasSavedProjectToken?.(input.project)) return "hub-env";
226
234
  return "missing";
227
235
  }