@kontextmind/kxm 0.7.50 → 0.7.52
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 +85 -8
- package/docs/configuration.md +1 -1
- package/docs/operations.md +195 -19
- package/package.json +1 -1
- package/plugins/kxm/.claude-plugin/plugin.json +1 -1
- package/plugins/kxm/dist/cli.js +100 -8
- package/plugins/kxm/dist/extension.js +4 -3
- package/plugins/kxm/dist/mcp-server.js +1 -1
- package/plugins/kxm/dist/runtime-supervisor.js +74 -3
- package/plugins/kxm/dist/runtime.js +76 -3
- package/plugins/kxm/dist/server.js +66 -2
- package/plugins/kxm/package.json +1 -1
- package/plugins/kxm/src/cli/hub.ts +84 -5
- package/plugins/kxm/src/database.ts +206 -1
- package/plugins/kxm/src/hub-binding.ts +22 -0
- package/plugins/kxm/src/hub-env.ts +28 -0
- package/plugins/kxm/src/intake.ts +86 -35
- package/plugins/kxm/src/mcp-server.ts +1 -1
- package/plugins/kxm/src/runtime-store.ts +8 -1
- package/plugins/kxm/src/session-work.ts +9 -3
|
@@ -7,6 +7,15 @@ export const HUB_HEALTH_PROBE_MS = 300;
|
|
|
7
7
|
|
|
8
8
|
export type HubHealth = "on" | "off" | "unknown";
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Where a bound hub sits relative to this machine — a trust question, not a cosmetic one.
|
|
12
|
+
* A loopback URL never puts a bearer on a network; a remote URL means the operator chose
|
|
13
|
+
* to. `kxm hub bind` therefore refuses a remote URL it cannot authenticate, mirroring the
|
|
14
|
+
* rule the hub applies to its own listener ("KXM_AUTH_TOKEN is required when binding
|
|
15
|
+
* beyond localhost").
|
|
16
|
+
*/
|
|
17
|
+
export type HubBindingScope = "loopback" | "remote";
|
|
18
|
+
|
|
10
19
|
export interface HubBindingRecord {
|
|
11
20
|
schema: typeof HUB_BINDING_SCHEMA;
|
|
12
21
|
url: string;
|
|
@@ -62,6 +71,19 @@ export function validateHubUrl(raw: string): string {
|
|
|
62
71
|
return parsed.href.replace(/\/$/, "");
|
|
63
72
|
}
|
|
64
73
|
|
|
74
|
+
/** Loopback literals only; `0.0.0.0`, a LAN address or a hostname are all remote. */
|
|
75
|
+
export function hubBindingScope(url: string): HubBindingScope {
|
|
76
|
+
let host: string;
|
|
77
|
+
try {
|
|
78
|
+
host = new URL(url).hostname.toLowerCase();
|
|
79
|
+
} catch {
|
|
80
|
+
return "remote";
|
|
81
|
+
}
|
|
82
|
+
if (host === "localhost" || host === "::1" || host === "[::1]" || host.endsWith(".localhost")) return "loopback";
|
|
83
|
+
const v4 = /^127\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$/.exec(host);
|
|
84
|
+
return v4 && [v4[1], v4[2], v4[3]].every((part) => Number(part) <= 255) ? "loopback" : "remote";
|
|
85
|
+
}
|
|
86
|
+
|
|
65
87
|
function isIsoTimestamp(value: string): boolean {
|
|
66
88
|
if (Number.isNaN(Date.parse(value))) return false;
|
|
67
89
|
return value === new Date(value).toISOString();
|
|
@@ -200,6 +200,34 @@ export function resolveHubCredentials(options: ResolveHubCredentialsOptions = {}
|
|
|
200
200
|
* matches the credential precedence `kxm hub start` announces, so a hub
|
|
201
201
|
* started fresh (generated token persisted) accepts authenticated client
|
|
202
202
|
* commands without the operator exporting the token. */
|
|
203
|
+
/**
|
|
204
|
+
* Can this machine authenticate to a hub at all, following the same precedence one-shot
|
|
205
|
+
* clients use: explicit `KXM_AUTH_TOKEN`, else the persisted record's admin token, else a
|
|
206
|
+
* persisted **project** token. Read-only on purpose — a refusal has to tell the operator
|
|
207
|
+
* to configure something, not reveal that the tool quietly configured it for them.
|
|
208
|
+
*
|
|
209
|
+
* Pass `project` when the caller knows which project will authenticate. A record holding
|
|
210
|
+
* only another project's token cannot authorise this one, and a guard that counts it as a
|
|
211
|
+
* credential stores a binding that will fail exactly like the one it prevented.
|
|
212
|
+
*/
|
|
213
|
+
export function hasClientHubCredential(env: NodeJS.ProcessEnv = process.env, project?: string): boolean {
|
|
214
|
+
if (env.KXM_AUTH_TOKEN?.trim()) return true;
|
|
215
|
+
let record: HubEnvRecord | undefined;
|
|
216
|
+
try {
|
|
217
|
+
record = readHubEnvRecord(env);
|
|
218
|
+
} catch (error) {
|
|
219
|
+
// A malformed or invalid record is a configuration failure, not "no credential".
|
|
220
|
+
// Letting it surface as an uncaught throw would print neither JSON nor prose.
|
|
221
|
+
throw new HubEnvError(
|
|
222
|
+
`${error instanceof Error ? error.message : String(error)}; refusing to guess a credential — repair or remove ${hubEnvFile(env)}`,
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
if (record?.authToken?.trim()) return true;
|
|
226
|
+
const tokens = record?.projectTokens ?? {};
|
|
227
|
+
if (project !== undefined) return typeof tokens[project] === "string" && tokens[project].trim().length > 0;
|
|
228
|
+
return Object.values(tokens).some((token) => typeof token === "string" && token.trim().length > 0);
|
|
229
|
+
}
|
|
230
|
+
|
|
203
231
|
export function resolveClientHubAuthToken(env: NodeJS.ProcessEnv, project: string): string | undefined {
|
|
204
232
|
const envToken = env.KXM_AUTH_TOKEN?.trim();
|
|
205
233
|
if (envToken) return envToken;
|
|
@@ -98,7 +98,45 @@ const ACTOR_ID_RE = /^.{1,200}$/;
|
|
|
98
98
|
|
|
99
99
|
/** Canonical SHA-256 over the authority ceiling — the coordinator's fingerprint. */
|
|
100
100
|
export function kxmCeilingHash(authority: KxmCoordinatorAuthority): string {
|
|
101
|
-
return `sha256:${createHash("sha256").update(stableStringify(authority), "utf8").digest("hex")}`;
|
|
101
|
+
return `sha256:${createHash("sha256").update(stableStringify(normalizeAuthority(authority)), "utf8").digest("hex")}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Does an already-stored coordinator express this ceiling?
|
|
106
|
+
*
|
|
107
|
+
* A row written before set normalisation existed carries a fingerprint that
|
|
108
|
+
* `kxmCeilingHash` no longer reproduces, so comparing the stored hash alone is
|
|
109
|
+
* not enough: recompute over the authority it kept. Every path that asks this
|
|
110
|
+
* question — the initial slot lookup and **both** lost-write read-backs — must
|
|
111
|
+
* go through here, or an upgrade makes the same row equivalent on lookup and a
|
|
112
|
+
* `coordinator_write_lost` conflict on the race path.
|
|
113
|
+
*
|
|
114
|
+
* A legacy row is returned as stored, so its `ceilingHash` is historical: a
|
|
115
|
+
* caller must not assume every persisted hash uses today's algorithm.
|
|
116
|
+
*/
|
|
117
|
+
function ceilingsMatch(stored: KxmCoordinatorRecord, ceilingHash: string): boolean {
|
|
118
|
+
return stored.ceilingHash === ceilingHash || kxmCeilingHash(stored.authority) === ceilingHash;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function normalizeAuthority(authority: KxmCoordinatorAuthority): KxmCoordinatorAuthority {
|
|
122
|
+
const tools = authority.tools;
|
|
123
|
+
return {
|
|
124
|
+
repositoryAccess: authority.repositoryAccess,
|
|
125
|
+
effects: canonicalSet(authority.effects ?? []),
|
|
126
|
+
...(tools !== undefined
|
|
127
|
+
? {
|
|
128
|
+
tools: {
|
|
129
|
+
...(tools.preset !== undefined ? { preset: tools.preset } : {}),
|
|
130
|
+
...(tools.allow !== undefined ? { allow: canonicalSet(tools.allow) } : {}),
|
|
131
|
+
...(tools.deny !== undefined ? { deny: canonicalSet(tools.deny) } : {}),
|
|
132
|
+
},
|
|
133
|
+
}
|
|
134
|
+
: {}),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function canonicalSet(values: readonly string[]): string[] {
|
|
139
|
+
return [...new Set(values)].sort();
|
|
102
140
|
}
|
|
103
141
|
|
|
104
142
|
/**
|
|
@@ -131,7 +169,7 @@ export function bindKxmCoordinator(
|
|
|
131
169
|
|
|
132
170
|
if (existing) {
|
|
133
171
|
const current = JSON.parse(existing.record) as KxmCoordinatorRecord;
|
|
134
|
-
if (current
|
|
172
|
+
if (ceilingsMatch(current, ceilingHash)) {
|
|
135
173
|
return { coordinator: current, created: false };
|
|
136
174
|
}
|
|
137
175
|
if (!input.rebind) {
|
|
@@ -169,11 +207,11 @@ export function bindKxmCoordinator(
|
|
|
169
207
|
record: kxmCanonicalJson(record as unknown as JsonValue),
|
|
170
208
|
});
|
|
171
209
|
if (!replaced) {
|
|
172
|
-
// Another process
|
|
173
|
-
//
|
|
210
|
+
// Another process reached the same ceiling first. Return its identity when it
|
|
211
|
+
// is the ceiling we asked for; anything else is a real conflict.
|
|
174
212
|
const winner = context.eventStore.coordinatorInSlot(context.projectId, role, channel);
|
|
175
213
|
const record2 = winner ? (JSON.parse(winner.record) as KxmCoordinatorRecord) : undefined;
|
|
176
|
-
if (record2 && record2
|
|
214
|
+
if (record2 && ceilingsMatch(record2, ceilingHash)) {
|
|
177
215
|
return { coordinator: record2, created: false };
|
|
178
216
|
}
|
|
179
217
|
throw runtimeError("coordinator_write_lost", existing.coordinatorId, "the coordinator slot changed underneath this rebind");
|
|
@@ -196,7 +234,7 @@ export function bindKxmCoordinator(
|
|
|
196
234
|
configRevision: context.configRevision,
|
|
197
235
|
ceilingHash,
|
|
198
236
|
};
|
|
199
|
-
return
|
|
237
|
+
return persistCoordinator(context, record);
|
|
200
238
|
}
|
|
201
239
|
|
|
202
240
|
/** Resolve a coordinator by id; unknown identity fails closed. */
|
|
@@ -210,10 +248,15 @@ export function resolveKxmCoordinator(context: KxmRuntimeContext, coordinatorId:
|
|
|
210
248
|
* Accept one internal message at the intake boundary.
|
|
211
249
|
*
|
|
212
250
|
* Duplicate ingress (same idempotency key, same content) returns the original
|
|
213
|
-
* record and cannot
|
|
251
|
+
* record and cannot produce a second admission record. This layer deduplicates
|
|
252
|
+
* *intake and admission*; creating a task is the M2 consumer's job, so nothing here can
|
|
253
|
+
* promise anything about tasks. The same key with **different** content
|
|
214
254
|
* is rejected: a reused key must not smuggle a different payload. Payloads
|
|
215
|
-
* classified `secret` are never persisted — only their hash, so
|
|
216
|
-
*
|
|
255
|
+
* classified `secret` are never persisted — only their hash, so a caller that
|
|
256
|
+
* classifies honestly gets a store that will not hold that payload. The scope is
|
|
257
|
+
* exactly that: classification is caller-asserted, so this is a storage decision
|
|
258
|
+
* under a label, not secret detection, and intake is still not a place to keep
|
|
259
|
+
* credentials. While the project is paused the message is durable with
|
|
217
260
|
* a held dispatch intent instead of being dropped.
|
|
218
261
|
*/
|
|
219
262
|
export function acceptKxmIntakeMessage(
|
|
@@ -429,28 +472,45 @@ export function isKxmProjectPaused(context: KxmRuntimeContext): boolean {
|
|
|
429
472
|
return context.eventStore.projectControl(context.projectId)?.paused === true;
|
|
430
473
|
}
|
|
431
474
|
|
|
475
|
+
/** How many held rows one drain page reads. Bounds a page, not the whole drain. */
|
|
476
|
+
const INTAKE_DRAIN_PAGE_ROWS = 500;
|
|
477
|
+
|
|
432
478
|
function releaseHeldIntake(context: KxmRuntimeContext, now: string): KxmIntakeMessage[] {
|
|
433
479
|
const released: KxmIntakeMessage[] = [];
|
|
434
480
|
// Drain every held row. A paging loop, not a single capped page: stranding the
|
|
435
481
|
// 501st message behind a "resume releases held intent" claim is a lie of omission.
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
482
|
+
//
|
|
483
|
+
// What is still true after that fix: the loop holds one write transaction and
|
|
484
|
+
// retains every released message, so total work and memory grow with the held
|
|
485
|
+
// backlog even though each read is bounded. Measured on an in-memory store: 150k
|
|
486
|
+
// held rows with 64-byte payloads took 4.5 s synchronously and ~95 MiB of heap;
|
|
487
|
+
// 500k maximum-size payloads would retain ~7.6 GiB before any database overhead,
|
|
488
|
+
// and an allocation failure will not reliably arrive as `intake_drain_stalled`.
|
|
489
|
+
// Resume is finite because the write lock keeps ingress out of the loop, so this
|
|
490
|
+
// is a throughput and memory limit, not a correctness hole. Bounding it is a
|
|
491
|
+
// pre-condition of M2 sustained traffic, not of this contract — see "Still open".
|
|
492
|
+
for (;;) {
|
|
493
|
+
const held = context.eventStore.intakeInStates(context.projectId, ["held_paused"], INTAKE_DRAIN_PAGE_ROWS);
|
|
494
|
+
if (held.length === 0) return released;
|
|
495
|
+
let progressed = false;
|
|
439
496
|
for (const row of held) {
|
|
440
497
|
const message = JSON.parse(row.record) as KxmIntakeMessage;
|
|
441
498
|
const next: KxmIntakeMessage = { ...message, dispatch: { state: "ready", updatedAt: now } };
|
|
442
499
|
validateIntakeMessage(next, next.messageId);
|
|
443
500
|
if (context.eventStore.updateIntakeDispatch(row.messageId, "held_paused", { state: "ready", record: kxmCanonicalJson(next as unknown as JsonValue) })) {
|
|
444
501
|
released.push(next);
|
|
502
|
+
progressed = true;
|
|
445
503
|
}
|
|
446
504
|
}
|
|
447
|
-
|
|
448
|
-
|
|
505
|
+
if (!progressed) {
|
|
506
|
+
// Cannot move anything: fail loudly inside the caller's transaction, which
|
|
507
|
+
// rolls the resume back, rather than half-resuming and leaving rows held.
|
|
508
|
+
throw runtimeError("intake_drain_stalled", context.projectId, `held intake did not drain; ${String(held.length)} row(s) still held`);
|
|
509
|
+
}
|
|
449
510
|
}
|
|
450
|
-
return released;
|
|
451
511
|
}
|
|
452
512
|
|
|
453
|
-
function persistCoordinator(context: KxmRuntimeContext, record: KxmCoordinatorRecord): { coordinator: KxmCoordinatorRecord } {
|
|
513
|
+
function persistCoordinator(context: KxmRuntimeContext, record: KxmCoordinatorRecord): { coordinator: KxmCoordinatorRecord; created: boolean } {
|
|
454
514
|
validateCoordinator(record, record.coordinatorId);
|
|
455
515
|
const inserted = context.eventStore.insertCoordinatorIfAbsent({
|
|
456
516
|
coordinatorId: record.coordinatorId,
|
|
@@ -462,13 +522,13 @@ function persistCoordinator(context: KxmRuntimeContext, record: KxmCoordinatorRe
|
|
|
462
522
|
boundAt: record.boundAt,
|
|
463
523
|
record: kxmCanonicalJson(record as unknown as JsonValue),
|
|
464
524
|
});
|
|
465
|
-
if (inserted) return { coordinator: record };
|
|
525
|
+
if (inserted) return { coordinator: record, created: true };
|
|
466
526
|
// Lost the race for an empty slot: binding is create-once, so the winner is the
|
|
467
527
|
// answer whenever it reached the same ceiling. Only a different ceiling is a
|
|
468
|
-
// conflict worth reporting.
|
|
528
|
+
// conflict worth reporting. The loser must not report that it created anything.
|
|
469
529
|
const winner = context.eventStore.coordinatorInSlot(record.projectId, record.role, record.channel);
|
|
470
530
|
const won = winner ? (JSON.parse(winner.record) as KxmCoordinatorRecord) : undefined;
|
|
471
|
-
if (won && won
|
|
531
|
+
if (won && ceilingsMatch(won, record.ceilingHash)) return { coordinator: won, created: false };
|
|
472
532
|
throw runtimeError("coordinator_write_lost", record.coordinatorId, "the coordinator slot was claimed by a different ceiling");
|
|
473
533
|
}
|
|
474
534
|
|
|
@@ -502,6 +562,12 @@ function assertCeilingNotWidened(
|
|
|
502
562
|
if (newlyAllowed.length > 0) {
|
|
503
563
|
throw runtimeError("coordinator_rebind_widens_tools", coordinatorId, `a rebind may not allow new tools: ${newlyAllowed.join(", ")}`);
|
|
504
564
|
}
|
|
565
|
+
// Tool evaluation applies no allowlist restriction when the list is empty or
|
|
566
|
+
// absent (`commands.ts` gates only on a non-empty allow list), so dropping a
|
|
567
|
+
// populated list is a widening even though every entry it named is gone.
|
|
568
|
+
if (allowedBefore.size > 0 && (nextTools.allow ?? []).length === 0) {
|
|
569
|
+
throw runtimeError("coordinator_rebind_clears_allowlist", coordinatorId, "a rebind may not drop or empty a populated allow list; an absent allow list imposes no restriction");
|
|
570
|
+
}
|
|
505
571
|
const deniedBefore = new Set(previousTools.deny ?? []);
|
|
506
572
|
const undenied = [...deniedBefore].filter((tool) => !(nextTools.deny ?? []).includes(tool));
|
|
507
573
|
if (undenied.length > 0) {
|
|
@@ -540,24 +606,9 @@ function validateAuthority(authority: KxmCoordinatorAuthority): KxmCoordinatorAu
|
|
|
540
606
|
}
|
|
541
607
|
// Sets are stored canonically: order and repeats carry no authority, and leaving
|
|
542
608
|
// them as supplied would let an equivalent ceiling masquerade as a rebind.
|
|
543
|
-
return
|
|
544
|
-
repositoryAccess: authority.repositoryAccess,
|
|
545
|
-
effects: canonicalSet(effects),
|
|
546
|
-
...(tools !== undefined
|
|
547
|
-
? {
|
|
548
|
-
tools: {
|
|
549
|
-
...(tools.preset !== undefined ? { preset: tools.preset } : {}),
|
|
550
|
-
...(tools.allow !== undefined ? { allow: canonicalSet(tools.allow) } : {}),
|
|
551
|
-
...(tools.deny !== undefined ? { deny: canonicalSet(tools.deny) } : {}),
|
|
552
|
-
},
|
|
553
|
-
}
|
|
554
|
-
: {}),
|
|
555
|
-
};
|
|
609
|
+
return normalizeAuthority(authority);
|
|
556
610
|
}
|
|
557
611
|
|
|
558
|
-
function canonicalSet(values: readonly string[]): string[] {
|
|
559
|
-
return [...new Set(values)].sort();
|
|
560
|
-
}
|
|
561
612
|
|
|
562
613
|
function validateActor<T extends { kind: KxmCoordinatorRecord["boundBy"]["kind"]; id: string }>(actor: T, field: string): T {
|
|
563
614
|
const kinds: string[] = ["human", "runtime", "hub", "agent", "adapter"];
|
|
@@ -8,7 +8,7 @@ import { AGENT_COMMANDS_MAP, enforceToolPolicy, getMcpTools, reconcileInbox } fr
|
|
|
8
8
|
import { deliverInboxNotification } from "./inbox.ts";
|
|
9
9
|
import type { HubEvent, MessageRecord } from "./protocol.ts";
|
|
10
10
|
|
|
11
|
-
const VERSION = "0.7.
|
|
11
|
+
const VERSION = "0.7.52";
|
|
12
12
|
const inbox = new Map<string, MessageRecord>();
|
|
13
13
|
const notifiedInbox = new Set<string>();
|
|
14
14
|
let meshClient: HubClient | undefined;
|
|
@@ -1255,7 +1255,14 @@ export class KxmRunEventStore {
|
|
|
1255
1255
|
return row ? intakeFromRow(row) : undefined;
|
|
1256
1256
|
}
|
|
1257
1257
|
|
|
1258
|
-
/**
|
|
1258
|
+
/**
|
|
1259
|
+
* Intake rows in the given dispatch states, in arrival order (replay-safe).
|
|
1260
|
+
*
|
|
1261
|
+
* `rowid` gives same-store arrival order, which is what queue priority needs
|
|
1262
|
+
* here. It is **not** a durable sequence: this repository backs stores up with
|
|
1263
|
+
* `VACUUM INTO`, and a vacuum may renumber implicit rowids. An explicit
|
|
1264
|
+
* immutable arrival sequence is tracked in the plan's schema-v6 follow-ups.
|
|
1265
|
+
*/
|
|
1259
1266
|
intakeInStates(projectId: string, states: readonly KxmIntakeMessageRow["dispatchState"][], limit = 100): KxmIntakeMessageRow[] {
|
|
1260
1267
|
if (states.length === 0) return [];
|
|
1261
1268
|
const placeholders = states.map(() => "?").join(", ");
|
|
@@ -42,6 +42,8 @@ export interface SessionHubStatus {
|
|
|
42
42
|
evidence: "probed" | "bound" | "cached" | "unconfigured" | "process" | "timeout";
|
|
43
43
|
online?: boolean;
|
|
44
44
|
url?: string;
|
|
45
|
+
/** Loopback or remote, so a brief never reads the same on both. */
|
|
46
|
+
scope?: "loopback" | "remote";
|
|
45
47
|
}
|
|
46
48
|
|
|
47
49
|
export interface SessionShipStatus {
|
|
@@ -65,9 +67,13 @@ export interface SessionBrief {
|
|
|
65
67
|
}
|
|
66
68
|
|
|
67
69
|
function hubPrefix(hub?: Partial<SessionHubStatus>): string {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
70
|
+
// A remote hub carries a bearer over a network; a loopback one does not. The JSON
|
|
71
|
+
// said so while every text surface printed the same bytes for both, which is the
|
|
72
|
+
// distinction an operator actually reads.
|
|
73
|
+
const suffix = hub?.scope === "remote" ? "/remote" : "";
|
|
74
|
+
if (hub?.state === "on" || (hub?.state === undefined && hub?.online === true)) return `kxm hub:on${suffix}`;
|
|
75
|
+
if (hub?.state === "off" || (hub?.state === undefined && hub?.online === false)) return `kxm hub:off${suffix}`;
|
|
76
|
+
if (hub?.state === "unknown") return `kxm hub:unknown${suffix}`;
|
|
71
77
|
return "kxm";
|
|
72
78
|
}
|
|
73
79
|
|