@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +30 -0
- package/docs/contributing/ci-and-release.md +6 -1
- package/docs/operations/runtime-sync.md +2 -1
- package/docs/operations/troubleshooting.md +4 -0
- package/docs/operations/upgrade.md +18 -3
- package/docs/operations.md +70 -7
- package/docs/reference/cli-reference.md +21 -6
- package/docs/reference/config-reference.md +28 -13
- package/docs/start/install.md +1 -1
- package/package.json +1 -1
- package/plugins/kxm/.claude-plugin/plugin.json +1 -1
- package/plugins/kxm/dist/claude-hook.js +170 -36
- package/plugins/kxm/dist/cli.js +451 -132
- package/plugins/kxm/dist/extension.js +184 -53
- package/plugins/kxm/dist/mcp-server.js +97 -40
- package/plugins/kxm/dist/runtime-supervisor.js +240 -47
- package/plugins/kxm/dist/runtime.js +240 -47
- package/plugins/kxm/dist/server.js +9 -0
- package/plugins/kxm/package.json +1 -1
- package/plugins/kxm/skills/kxm-definitions/SKILL.md +3 -2
- package/plugins/kxm/skills/kxm-harness-auth/SKILL.md +4 -2
- package/plugins/kxm/skills/kxm-hub-ops/SKILL.md +9 -4
- package/plugins/kxm/src/cli/hub.ts +42 -7
- package/plugins/kxm/src/cli/system.ts +3 -1
- package/plugins/kxm/src/cli/types.ts +5 -3
- package/plugins/kxm/src/cli.ts +2 -1
- package/plugins/kxm/src/engine.ts +105 -8
- package/plugins/kxm/src/hub-binding.ts +301 -55
- package/plugins/kxm/src/hub-identity.ts +17 -9
- package/plugins/kxm/src/kxm-update.ts +29 -6
- package/plugins/kxm/src/mcp-server.ts +1 -1
- package/plugins/kxm/src/project-config.ts +63 -1
- package/plugins/kxm/src/runtime-supervisor.ts +22 -4
- 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
|
|
1950
|
-
|
|
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:
|
|
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:
|
|
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 =
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
293
|
+
return new HubBindingError(`malformed hub binding at ${file}`);
|
|
258
294
|
}
|
|
259
295
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
260
|
-
|
|
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
|
-
|
|
283
|
-
|
|
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
|
|
324
|
+
boundAt,
|
|
306
325
|
cloud: true,
|
|
307
326
|
...(tokenEnv ? { tokenEnv } : {}),
|
|
308
327
|
...(tokenCommand ? { tokenCommand } : {}),
|
|
309
328
|
};
|
|
310
329
|
}
|
|
311
330
|
|
|
312
|
-
|
|
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
|
-
|
|
315
|
-
const
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
|
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
|
-
|
|
329
|
-
|
|
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
|
|
332
|
-
|
|
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<
|
|
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
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
219
|
-
if (
|
|
220
|
-
|
|
221
|
-
|
|
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
|
}
|