@kontextmind/kxm 0.7.96 → 0.7.97
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 +22 -0
- package/docs/concepts/trust-model.md +3 -2
- package/docs/glossary.md +1 -1
- package/docs/guides/peer-messaging.md +2 -2
- package/docs/guides/pi-workers.md +1 -1
- package/docs/guides/webhook-workflows.md +53 -18
- package/docs/operations/deploy.md +1 -1
- package/docs/operations/troubleshooting.md +3 -2
- package/docs/reference/cli-reference.md +6 -6
- package/docs/reference/configuration.md +2 -2
- package/docs/reference/http-api.md +6 -6
- package/docs/reference/tools.md +1 -1
- package/docs/reference/workflow-definitions.md +2 -2
- package/docs/start/quickstart-pi.md +1 -1
- package/examples/workflow-signal.ts +4 -5
- package/package.json +1 -1
- package/plugins/kxm/.claude-plugin/plugin.json +1 -1
- package/plugins/kxm/dist/claude-hook.js +11 -1
- package/plugins/kxm/dist/cli.js +159 -74
- package/plugins/kxm/dist/client.js +3 -1
- package/plugins/kxm/dist/core.js +11 -1
- package/plugins/kxm/dist/extension.js +45 -13
- package/plugins/kxm/dist/mcp-server.js +20 -4
- package/plugins/kxm/dist/runtime-supervisor.js +1 -3
- package/plugins/kxm/dist/runtime.js +17 -3
- package/plugins/kxm/dist/server.js +115 -20
- package/plugins/kxm/package.json +1 -1
- package/plugins/kxm/src/cli/workflows.ts +12 -7
- package/plugins/kxm/src/cli.ts +19 -5
- package/plugins/kxm/src/client.ts +4 -0
- package/plugins/kxm/src/commands.ts +23 -1
- package/plugins/kxm/src/extension.ts +20 -14
- package/plugins/kxm/src/github-watch.ts +8 -5
- package/plugins/kxm/src/hub-env.ts +19 -1
- package/plugins/kxm/src/hub.ts +105 -21
- package/plugins/kxm/src/improve-sources.ts +2 -7
- package/plugins/kxm/src/mcp-server.ts +9 -2
- package/plugins/kxm/src/runtime-store.ts +23 -0
- package/plugins/kxm/src/workflow.ts +70 -1
- package/scripts/smoke-multi-pi.mjs +5 -1
package/plugins/kxm/src/cli.ts
CHANGED
|
@@ -18,7 +18,7 @@ import { Command, CommanderError } from "commander";
|
|
|
18
18
|
import { readInstalledKxmVersion } from "./kxm-update.ts";
|
|
19
19
|
import { findKxmRepoRoot } from "./repo-root.ts";
|
|
20
20
|
import { HubClient } from "./client.ts";
|
|
21
|
-
import {
|
|
21
|
+
import { AgentProjectTokenMissingError, resolveAgentHubAuthToken } from "./hub-env.ts";
|
|
22
22
|
import { defaultProjectName } from "./project-name.ts";
|
|
23
23
|
import {
|
|
24
24
|
AGENT_COMMANDS_MAP,
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
import { discoverKxmProjectRoot } from "./project-config.ts";
|
|
28
28
|
import { JOURNAL_CATEGORIES } from "./workflow.ts";
|
|
29
29
|
import { ensureKxmSupervisor, kxmRuntimeRequest } from "./runtime-supervisor.ts";
|
|
30
|
+
import { projectRuntimeOwnsRun } from "./runtime-store.ts";
|
|
30
31
|
|
|
31
32
|
// Submodule imports
|
|
32
33
|
import {
|
|
@@ -234,14 +235,17 @@ async function ensureCliClient(runtime: Runtime): Promise<HubClient> {
|
|
|
234
235
|
const project = defaultProjectName(runtime.cwd, runtime.env);
|
|
235
236
|
const name = runtime.env.KXM_AGENT_NAME?.trim() || `cli-${process.pid}`;
|
|
236
237
|
const purpose = runtime.env.KXM_AGENT_PURPOSE?.trim() || "CLI agent client";
|
|
237
|
-
|
|
238
|
+
// These commands act as a peer agent, so they take the agent credential: never the
|
|
239
|
+
// persisted admin token, which the hub accepts for any project without its own token.
|
|
240
|
+
const authToken = resolveAgentHubAuthToken(runtime.env, project);
|
|
241
|
+
if (!authToken) throw new AgentProjectTokenMissingError(project);
|
|
238
242
|
const client = new HubClient({
|
|
239
243
|
serverUrl,
|
|
240
244
|
name,
|
|
241
245
|
project,
|
|
242
246
|
purpose,
|
|
243
247
|
fetchImpl: runtime.fetchImpl,
|
|
244
|
-
|
|
248
|
+
authToken,
|
|
245
249
|
});
|
|
246
250
|
await client.start(() => {});
|
|
247
251
|
return client;
|
|
@@ -308,11 +312,12 @@ async function dispatchAgentCliCommand(
|
|
|
308
312
|
}
|
|
309
313
|
}
|
|
310
314
|
|
|
311
|
-
//
|
|
315
|
+
// A workflow wait on a run this project's Runtime owns goes to the Runtime; any other
|
|
316
|
+
// run id, including a hub workflow run of the same shape, goes to the hub.
|
|
312
317
|
if (toolName === "kxm_workflow_wait") {
|
|
313
318
|
const runId = typeof args.runId === "string" ? args.runId : undefined;
|
|
314
319
|
const projectRoot = discoverKxmProjectRoot(runtime.cwd);
|
|
315
|
-
if (runId && projectRoot &&
|
|
320
|
+
if (runId && projectRoot && projectRuntimeOwnsRun(projectRoot, runId, runtime.env)) {
|
|
316
321
|
if (runtime.dryRun) {
|
|
317
322
|
print(runtime.io, runtime.json, { ok: true, command: "workflow wait", runId, dryRun: true }, `would wait for signal on KXM run ${runId}`);
|
|
318
323
|
return 0;
|
|
@@ -349,6 +354,15 @@ async function dispatchAgentCliCommand(
|
|
|
349
354
|
return 0;
|
|
350
355
|
} catch (error) {
|
|
351
356
|
const message = error instanceof Error ? error.message : String(error);
|
|
357
|
+
if (error instanceof AgentProjectTokenMissingError) {
|
|
358
|
+
print(
|
|
359
|
+
runtime.io,
|
|
360
|
+
runtime.json,
|
|
361
|
+
{ ok: false, error: error.code, project: error.project, nextAction: "export_kxm_auth_token", detail: message },
|
|
362
|
+
message,
|
|
363
|
+
);
|
|
364
|
+
return 2;
|
|
365
|
+
}
|
|
352
366
|
print(runtime.io, runtime.json, { ok: false, error: "command_failed", detail: message }, `command failed: ${message}`);
|
|
353
367
|
return 1;
|
|
354
368
|
} finally {
|
|
@@ -259,6 +259,8 @@ export class HubClient {
|
|
|
259
259
|
ttlMs?: number;
|
|
260
260
|
timeoutMs?: number;
|
|
261
261
|
signal?: AbortSignal;
|
|
262
|
+
hops?: number;
|
|
263
|
+
maxHops?: number;
|
|
262
264
|
}): Promise<FanoutResult[]> {
|
|
263
265
|
const targets = [...new Set(options.targets.map((target) => target.trim().toLowerCase()).filter(Boolean))];
|
|
264
266
|
if (targets.length < 1 || targets.length > 3) throw new Error("fanout requires between one and three unique targets");
|
|
@@ -280,6 +282,8 @@ export class HubClient {
|
|
|
280
282
|
),
|
|
281
283
|
} : {}),
|
|
282
284
|
...(options.ttlMs ? { ttlMs: options.ttlMs } : {}),
|
|
285
|
+
...(options.hops !== undefined ? { hops: options.hops } : {}),
|
|
286
|
+
...(options.maxHops !== undefined ? { maxHops: options.maxHops } : {}),
|
|
283
287
|
});
|
|
284
288
|
const completed = await this.awaitResponse(
|
|
285
289
|
message.id,
|
|
@@ -74,6 +74,26 @@ export interface CommandExecutionContext {
|
|
|
74
74
|
signal?: AbortSignal | undefined;
|
|
75
75
|
inbox?: Map<string, MessageRecord> | undefined;
|
|
76
76
|
notifiedInbox?: Set<string> | undefined;
|
|
77
|
+
/** Inbound requests this session is handling: the Pi extension's active request, the MCP
|
|
78
|
+
* server's open inbox. A request sent meanwhile is one more hop along their chain. */
|
|
79
|
+
handling?: readonly Pick<MessageRecord, "hops" | "maxHops">[] | undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Hop fields for a request sent while handling inbound work: one hop past the furthest
|
|
84
|
+
* handled request, under the tightest limit among them, so the hub's `hop_limit_reached`
|
|
85
|
+
* refusal bounds a chain of agents forwarding to each other. Handling nothing starts a new
|
|
86
|
+
* chain with the hub defaults. When several requests are open the furthest one counts, so a
|
|
87
|
+
* forwarding loop cannot reset its count because an unrelated request arrived beside it.
|
|
88
|
+
*/
|
|
89
|
+
export function forwardedHops(
|
|
90
|
+
handling: CommandExecutionContext["handling"],
|
|
91
|
+
): { hops: number; maxHops: number } | undefined {
|
|
92
|
+
if (!handling?.length) return undefined;
|
|
93
|
+
return {
|
|
94
|
+
hops: Math.max(...handling.map((message) => message.hops)) + 1,
|
|
95
|
+
maxHops: Math.min(...handling.map((message) => message.maxHops)),
|
|
96
|
+
};
|
|
77
97
|
}
|
|
78
98
|
|
|
79
99
|
export interface AgentCommand {
|
|
@@ -260,12 +280,13 @@ export const AGENT_COMMANDS: readonly AgentCommand[] = [
|
|
|
260
280
|
required: ["target", "content"],
|
|
261
281
|
additionalProperties: false,
|
|
262
282
|
},
|
|
263
|
-
async execute(client, args) {
|
|
283
|
+
async execute(client, args, context) {
|
|
264
284
|
const delivery = optionalString(args.delivery) as DeliveryMode | undefined;
|
|
265
285
|
const correlationId = optionalString(args.correlationId);
|
|
266
286
|
const idempotencyKey = optionalString(args.idempotencyKey);
|
|
267
287
|
const workflowContext = optionalWorkflowContext(args.workflowContext);
|
|
268
288
|
const message = await client.send({
|
|
289
|
+
...forwardedHops(context?.handling),
|
|
269
290
|
target: requiredString(args.target, "target"),
|
|
270
291
|
content: requiredString(args.content, "content"),
|
|
271
292
|
...(delivery ? { delivery } : {}),
|
|
@@ -346,6 +367,7 @@ export const AGENT_COMMANDS: readonly AgentCommand[] = [
|
|
|
346
367
|
: [];
|
|
347
368
|
return {
|
|
348
369
|
responses: await client.fanout({
|
|
370
|
+
...forwardedHops(context?.handling),
|
|
349
371
|
targets,
|
|
350
372
|
content: requiredString(args.content, "content"),
|
|
351
373
|
...(optionalString(args.correlationId) ? { correlationId: optionalString(args.correlationId)! } : {}),
|
|
@@ -6,7 +6,7 @@ import { AGENT_COMMANDS, enforceToolPolicy } from "./commands.ts";
|
|
|
6
6
|
import { HubClient, HubHttpError } from "./client.ts";
|
|
7
7
|
import { loadKxmConfig } from "./config.ts";
|
|
8
8
|
import { ensureHubRunning, hubAutoStartMode } from "./hub-autostart.ts";
|
|
9
|
-
import {
|
|
9
|
+
import { AgentProjectTokenMissingError, resolveAgentHubAuthToken } from "./hub-env.ts";
|
|
10
10
|
import { defaultProjectName } from "./project-name.ts";
|
|
11
11
|
import { nousFactoryWork, type NousRegistrationReport } from "./nous-pi.ts";
|
|
12
12
|
import {
|
|
@@ -685,13 +685,11 @@ export default function piMeshExtension(pi: ExtensionAPI): void | Promise<void>
|
|
|
685
685
|
removeMatchingLegacyRecoveryContext();
|
|
686
686
|
const purpose = process.env.KXM_AGENT_PURPOSE ?? "General-purpose coding agent";
|
|
687
687
|
const model = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined;
|
|
688
|
-
let autoStartToken: string | undefined;
|
|
689
688
|
try {
|
|
690
689
|
const config = loadKxmConfig(ctx.cwd);
|
|
691
690
|
if (hubAutoStartMode(config) === "background") {
|
|
692
691
|
const ensured = await ensureHubRunning({ config, cwd: ctx.cwd });
|
|
693
692
|
if (ensured.status === "started") {
|
|
694
|
-
autoStartToken = ensured.authToken;
|
|
695
693
|
ctx.ui.notify(
|
|
696
694
|
`kxm hub started in the background (pid ${ensured.pid}); logs: ${ensured.logPath}`
|
|
697
695
|
+ (ensured.authTokenSource === "generated" ? "; new admin token generated and persisted to user state" : ""),
|
|
@@ -704,23 +702,29 @@ export default function piMeshExtension(pi: ExtensionAPI): void | Promise<void>
|
|
|
704
702
|
} catch (error) {
|
|
705
703
|
ctx.ui.notify(`kxm hub auto-start failed: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
706
704
|
}
|
|
707
|
-
//
|
|
708
|
-
//
|
|
709
|
-
|
|
710
|
-
|
|
705
|
+
// An agent session registers with KXM_AUTH_TOKEN or this project's saved project
|
|
706
|
+
// token only. The hub admits its admin token to any project missing from its token
|
|
707
|
+
// map, so neither the persisted admin token nor the one auto-start resolved may
|
|
708
|
+
// stand in for a project token.
|
|
709
|
+
let hubAuthToken: string | undefined;
|
|
710
|
+
try {
|
|
711
|
+
hubAuthToken = resolveAgentHubAuthToken(process.env, project);
|
|
712
|
+
} catch (error) {
|
|
713
|
+
ctx.ui.notify(`kxm could not read persisted hub credentials: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
714
|
+
await applySessionChrome(ctx, event, true);
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
711
717
|
if (!hubAuthToken) {
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
ctx.ui.notify(`kxm could not read persisted hub credentials: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
716
|
-
}
|
|
718
|
+
ctx.ui.notify(new AgentProjectTokenMissingError(project).message, "error");
|
|
719
|
+
await applySessionChrome(ctx, event, true);
|
|
720
|
+
return;
|
|
717
721
|
}
|
|
718
722
|
client = new HubClient({
|
|
719
723
|
serverUrl,
|
|
720
724
|
name,
|
|
721
725
|
purpose,
|
|
722
726
|
project,
|
|
723
|
-
|
|
727
|
+
authToken: hubAuthToken,
|
|
724
728
|
...(model ? { model } : {}),
|
|
725
729
|
});
|
|
726
730
|
notify = (message, type) => ctx.ui.notify(message, type);
|
|
@@ -893,9 +897,11 @@ export default function piMeshExtension(pi: ExtensionAPI): void | Promise<void>
|
|
|
893
897
|
if (!policy.allowed) {
|
|
894
898
|
throw new Error(`tool_policy_denied: ${policy.detail ?? policy.error}`);
|
|
895
899
|
}
|
|
900
|
+
// A request sent while handling the active inbound request continues its hop chain.
|
|
901
|
+
const handling = activeInbound ? [activeInbound] : [];
|
|
896
902
|
return result(
|
|
897
903
|
await workflowCall(() =>
|
|
898
|
-
cmd.execute(requireClient(), (params ?? {}) as Record<string, unknown>, { signal }),
|
|
904
|
+
cmd.execute(requireClient(), (params ?? {}) as Record<string, unknown>, { signal, handling }),
|
|
899
905
|
),
|
|
900
906
|
);
|
|
901
907
|
},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { redactSecrets } from "./redact.ts";
|
|
3
|
-
import type
|
|
3
|
+
import { workflowWebhookHeaders, type WorkflowEvidenceInput } from "./workflow.ts";
|
|
4
4
|
|
|
5
5
|
export type WatchStatus = "passed" | "failed" | "warning";
|
|
6
6
|
|
|
@@ -109,7 +109,6 @@ export async function postWorkflowSignal(input: {
|
|
|
109
109
|
fetchImpl?: typeof fetch;
|
|
110
110
|
}): Promise<{ httpStatus: number; duplicate: boolean }> {
|
|
111
111
|
const body = JSON.stringify({ status: input.status, summary: input.summary, evidence: input.evidence });
|
|
112
|
-
const signature = `sha256=${createHmac("sha256", input.signalSecret).update(body).digest("hex")}`;
|
|
113
112
|
const endpoint = [
|
|
114
113
|
input.serverUrl.replace(/\/$/, ""),
|
|
115
114
|
"v1/webhooks",
|
|
@@ -123,8 +122,12 @@ export async function postWorkflowSignal(input: {
|
|
|
123
122
|
method: "POST",
|
|
124
123
|
headers: {
|
|
125
124
|
"content-type": "application/json",
|
|
126
|
-
|
|
127
|
-
|
|
125
|
+
...workflowWebhookHeaders({
|
|
126
|
+
secret: input.signalSecret,
|
|
127
|
+
scope: { definitionId: input.definitionId, runId: input.runId, signalKey: input.signalKey },
|
|
128
|
+
deliveryId: input.deliveryId,
|
|
129
|
+
body,
|
|
130
|
+
}),
|
|
128
131
|
},
|
|
129
132
|
body,
|
|
130
133
|
}, input.timeoutMs ?? 15_000);
|
|
@@ -236,7 +236,8 @@ export function resolveClientHubAuthToken(env: NodeJS.ProcessEnv, project: strin
|
|
|
236
236
|
}
|
|
237
237
|
|
|
238
238
|
/**
|
|
239
|
-
* Token an agent session (the Claude MCP server
|
|
239
|
+
* Token an agent session (the Claude MCP server, the Pi extension, the `kxm peer` and
|
|
240
|
+
* `kxm workflow` agent commands) registers with: explicit `KXM_AUTH_TOKEN`,
|
|
240
241
|
* else the persisted project token for `project`, else nothing. It never returns the
|
|
241
242
|
* persisted admin token. The hub accepts the admin token for any project missing from its
|
|
242
243
|
* project-token map, so an agent falling back to it would join a project nobody issued it a
|
|
@@ -251,6 +252,23 @@ export function resolveAgentHubAuthToken(env: NodeJS.ProcessEnv, project: string
|
|
|
251
252
|
return tokens[project]?.trim() || undefined;
|
|
252
253
|
}
|
|
253
254
|
|
|
255
|
+
/**
|
|
256
|
+
* Refusal for an agent session that has neither `KXM_AUTH_TOKEN` nor a saved project
|
|
257
|
+
* token for its project. The fix is the operator's, so the message names it.
|
|
258
|
+
*/
|
|
259
|
+
export class AgentProjectTokenMissingError extends Error {
|
|
260
|
+
readonly code = "project_token_missing";
|
|
261
|
+
readonly project: string;
|
|
262
|
+
|
|
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
|
+
);
|
|
267
|
+
this.name = "AgentProjectTokenMissingError";
|
|
268
|
+
this.project = project;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
254
272
|
/**
|
|
255
273
|
* Admin-scoped hub reads (`/v1/ops/snapshot`, …) are rejected with 401 by a project token,
|
|
256
274
|
* so this variant resolves the admin credential only: explicit `KXM_AUTH_TOKEN`, else the
|
package/plugins/kxm/src/hub.ts
CHANGED
|
@@ -70,6 +70,8 @@ import {
|
|
|
70
70
|
verifyWorkflowEvidenceReferences,
|
|
71
71
|
workflowDefinitionHash,
|
|
72
72
|
workflowEvidenceStrings,
|
|
73
|
+
workflowWebhookSignature,
|
|
74
|
+
WORKFLOW_WEBHOOK_MAX_SKEW_SECONDS,
|
|
73
75
|
type ImprovementArea,
|
|
74
76
|
type JournalCategory,
|
|
75
77
|
type WebhookWorkflowDefinition,
|
|
@@ -81,6 +83,7 @@ import {
|
|
|
81
83
|
type WorkflowSignalReceipt,
|
|
82
84
|
type WorkflowStageState,
|
|
83
85
|
type WorkflowVerifiedEvidence,
|
|
86
|
+
type WorkflowWebhookScope,
|
|
84
87
|
} from "./workflow.ts";
|
|
85
88
|
|
|
86
89
|
export interface RateLimitOptions {
|
|
@@ -971,20 +974,77 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
|
|
|
971
974
|
return typeof candidate === "string" && candidate.trim() ? candidate.trim() : undefined;
|
|
972
975
|
}
|
|
973
976
|
|
|
974
|
-
function
|
|
975
|
-
const signature = request.headers["x-hub-signature-256"] ?? request.headers["x-hub-signature"];
|
|
976
|
-
if (typeof signature !== "string") {
|
|
977
|
-
throw new ProtocolError(401, "webhook signature is required", "webhook_signature_missing");
|
|
978
|
-
}
|
|
977
|
+
function requireSha256Signature(signature: string): void {
|
|
979
978
|
const separator = signature.indexOf("=");
|
|
980
979
|
const algorithm = separator > 0 ? signature.slice(0, separator).toLowerCase() : "";
|
|
981
980
|
if (algorithm !== "sha256") {
|
|
982
981
|
throw new ProtocolError(401, "webhook signature must use sha256", "webhook_signature_unsupported");
|
|
983
982
|
}
|
|
984
|
-
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
/** Jira and GitHub sign only the body, so a provider delivery ID is unsigned
|
|
986
|
+
* routing data. Returns that provider's own delivery header. */
|
|
987
|
+
function verifyProviderWebhookSignature(
|
|
988
|
+
request: IncomingMessage,
|
|
989
|
+
body: Buffer,
|
|
990
|
+
definition: WebhookWorkflowDefinition,
|
|
991
|
+
): string {
|
|
992
|
+
if (definition.source === "generic") {
|
|
993
|
+
throw new ProtocolError(
|
|
994
|
+
401,
|
|
995
|
+
"generic workflow webhooks require x-kxm-signature over the timestamp, delivery ID, definition, and body",
|
|
996
|
+
"webhook_signature_missing",
|
|
997
|
+
);
|
|
998
|
+
}
|
|
999
|
+
const signature = request.headers["x-hub-signature-256"] ?? request.headers["x-hub-signature"];
|
|
1000
|
+
if (typeof signature !== "string") {
|
|
1001
|
+
throw new ProtocolError(401, "webhook signature is required", "webhook_signature_missing");
|
|
1002
|
+
}
|
|
1003
|
+
requireSha256Signature(signature);
|
|
1004
|
+
const expected = `sha256=${createHmac("sha256", definition.secret).update(body).digest("hex")}`;
|
|
985
1005
|
if (!safeTokenEqual(signature, expected)) {
|
|
986
1006
|
throw new ProtocolError(401, "webhook signature is invalid", "webhook_signature_invalid");
|
|
987
1007
|
}
|
|
1008
|
+
const deliveryHeader = definition.source === "jira"
|
|
1009
|
+
? request.headers["x-atlassian-webhook-identifier"]
|
|
1010
|
+
: request.headers["x-github-delivery"];
|
|
1011
|
+
return requireString(deliveryHeader, "webhook delivery identifier", { max: 128 });
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
/** The KXM sender contract (see workflowWebhookSignedMaterial): the
|
|
1015
|
+
* signature binds the delivery ID, timestamp, and route scope. A valid
|
|
1016
|
+
* signature outside the skew window is refused as expired. */
|
|
1017
|
+
function verifyKxmWebhookSignature(
|
|
1018
|
+
request: IncomingMessage,
|
|
1019
|
+
body: Buffer,
|
|
1020
|
+
secret: string,
|
|
1021
|
+
scope: WorkflowWebhookScope,
|
|
1022
|
+
): string {
|
|
1023
|
+
const signature = request.headers["x-kxm-signature"];
|
|
1024
|
+
if (typeof signature !== "string") {
|
|
1025
|
+
throw new ProtocolError(
|
|
1026
|
+
401,
|
|
1027
|
+
"KXM webhooks require x-kxm-signature over the timestamp, delivery ID, route, and body",
|
|
1028
|
+
"webhook_signature_missing",
|
|
1029
|
+
);
|
|
1030
|
+
}
|
|
1031
|
+
requireSha256Signature(signature);
|
|
1032
|
+
const timestamp = request.headers["x-kxm-timestamp"];
|
|
1033
|
+
if (typeof timestamp !== "string" || !/^[0-9]{1,12}$/.test(timestamp)) {
|
|
1034
|
+
throw new ProtocolError(401, "x-kxm-timestamp must be Unix seconds", "webhook_timestamp_invalid");
|
|
1035
|
+
}
|
|
1036
|
+
const deliveryId = requireString(request.headers["x-kxm-delivery-id"], "webhook delivery identifier", { max: 128 });
|
|
1037
|
+
if (!safeTokenEqual(signature, workflowWebhookSignature(secret, scope, timestamp, deliveryId, body))) {
|
|
1038
|
+
throw new ProtocolError(401, "webhook signature is invalid", "webhook_signature_invalid");
|
|
1039
|
+
}
|
|
1040
|
+
if (Math.abs(Date.now() / 1_000 - Number(timestamp)) > WORKFLOW_WEBHOOK_MAX_SKEW_SECONDS) {
|
|
1041
|
+
throw new ProtocolError(
|
|
1042
|
+
401,
|
|
1043
|
+
`webhook timestamp is outside the ${WORKFLOW_WEBHOOK_MAX_SKEW_SECONDS}-second window; re-sign at send time`,
|
|
1044
|
+
"webhook_timestamp_expired",
|
|
1045
|
+
);
|
|
1046
|
+
}
|
|
1047
|
+
return deliveryId;
|
|
988
1048
|
}
|
|
989
1049
|
|
|
990
1050
|
function workflowPrompt(
|
|
@@ -1013,7 +1073,7 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
|
|
|
1013
1073
|
stageList,
|
|
1014
1074
|
"",
|
|
1015
1075
|
`At every stage, record material knowledge with kxm_workflow_record in one of these categories: ${JOURNAL_CATEGORIES.join(", ")}. Pass the stageId the entry belongs to; the hub binds the attempt and, when you omit area, uses the stage's declared area.`,
|
|
1016
|
-
"Keep
|
|
1076
|
+
"Keep reviewed configuration as YAML directly under .kxm/ (such as .kxm/project.yaml and .kxm/workflows/), logs in .kxm/logs, and durable workflow artifacts in .kxm/assets. Never create .kxm/config, which KXM refuses, and never commit runtime logs, .kxm/state, or secrets.",
|
|
1017
1077
|
"Complete each stage with kxm_workflow_checkpoint. Supply evidence as an object whose keys exactly match the stage's required evidence keys. Unrelated keys never satisfy a requirement. A warning or failure must be corrected and checkpointed again until it passes or the attempt limit is reached.",
|
|
1018
1078
|
"For a peer-evidence requirement, send or fan out with workflowContext containing this run ID, the exact stage ID, requirement key, and current 1-based attempt. At checkpoint, cite only the returned message IDs under evidenceRefs; the hub derives producer and reply provenance.",
|
|
1019
1079
|
"When an external system must finish asynchronously, call kxm_workflow_wait with a stable signal key and any already-verified keyed evidence. That evidence is accumulated with the signed callback before the stage can pass; then settle the turn.",
|
|
@@ -1335,16 +1395,17 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
|
|
|
1335
1395
|
const definition = webhookWorkflows.get(definitionId);
|
|
1336
1396
|
if (!definition) throw new ProtocolError(404, "webhook workflow not found", "webhook_not_found");
|
|
1337
1397
|
const rawBody = await readBody(request);
|
|
1338
|
-
|
|
1398
|
+
const deliveryId = verifyKxmWebhookSignature(
|
|
1399
|
+
request,
|
|
1400
|
+
rawBody,
|
|
1401
|
+
definition.signalSecret ?? definition.secret,
|
|
1402
|
+
{ definitionId: definition.id, runId, signalKey },
|
|
1403
|
+
);
|
|
1339
1404
|
const body = parseJsonBody(rawBody);
|
|
1340
1405
|
const run = workflowRuns.get(runId);
|
|
1341
1406
|
if (!run || run.definitionId !== definition.id) {
|
|
1342
1407
|
throw new ProtocolError(404, "workflow run not found", "workflow_not_found");
|
|
1343
1408
|
}
|
|
1344
|
-
const deliveryHeader = request.headers["x-atlassian-webhook-identifier"]
|
|
1345
|
-
?? request.headers["x-github-delivery"]
|
|
1346
|
-
?? request.headers["x-kxm-delivery-id"];
|
|
1347
|
-
const deliveryId = requireString(deliveryHeader, "webhook delivery identifier", { max: 128 });
|
|
1348
1409
|
const payloadHash = createHash("sha256").update(rawBody).digest("hex");
|
|
1349
1410
|
const existingReceipt = run.signalReceipts?.find((receipt) => receipt.deliveryId === deliveryId);
|
|
1350
1411
|
if (existingReceipt) {
|
|
@@ -1491,7 +1552,13 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
|
|
|
1491
1552
|
const definition = webhookWorkflows.get(definitionId);
|
|
1492
1553
|
if (!definition) throw new ProtocolError(404, "webhook workflow not found", "webhook_not_found");
|
|
1493
1554
|
const rawBody = await readBody(request);
|
|
1494
|
-
|
|
1555
|
+
// A KXM sender signs the delivery ID and timestamp. A Jira or GitHub
|
|
1556
|
+
// delivery signs only its body, so for those the body is the replay
|
|
1557
|
+
// identity: it may start one run, under one delivery ID.
|
|
1558
|
+
const bodyOnlySignature = request.headers["x-kxm-signature"] === undefined;
|
|
1559
|
+
const deliveryId = bodyOnlySignature
|
|
1560
|
+
? verifyProviderWebhookSignature(request, rawBody, definition)
|
|
1561
|
+
: verifyKxmWebhookSignature(request, rawBody, definition.secret, { definitionId: definition.id });
|
|
1495
1562
|
const payload = parseJsonBody(rawBody);
|
|
1496
1563
|
const event = webhookEvent(request, payload);
|
|
1497
1564
|
if (definition.event && event !== definition.event) {
|
|
@@ -1502,17 +1569,30 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
|
|
|
1502
1569
|
response.writeHead(204, { "cache-control": "no-store" }).end();
|
|
1503
1570
|
return;
|
|
1504
1571
|
}
|
|
1505
|
-
const
|
|
1506
|
-
?? request.headers["x-github-delivery"]
|
|
1507
|
-
?? request.headers["x-kxm-delivery-id"];
|
|
1508
|
-
const deliveryId = requireString(deliveryHeader, "webhook delivery identifier", { max: 128 });
|
|
1572
|
+
const payloadHash = createHash("sha256").update(rawBody).digest("hex");
|
|
1509
1573
|
const existing = [...workflowRuns.values()].find(
|
|
1510
1574
|
(run) => run.definitionId === definition.id && run.deliveryId === deliveryId,
|
|
1511
1575
|
);
|
|
1512
1576
|
if (existing) {
|
|
1513
|
-
|
|
1577
|
+
if (existing.payloadHash !== payloadHash) {
|
|
1578
|
+
throw new ProtocolError(
|
|
1579
|
+
409,
|
|
1580
|
+
"webhook delivery identifier was already used for a different body",
|
|
1581
|
+
"webhook_delivery_conflict",
|
|
1582
|
+
);
|
|
1583
|
+
}
|
|
1584
|
+
json(response, 200, { duplicate: true, runId: existing.id, status: existing.status });
|
|
1514
1585
|
return;
|
|
1515
1586
|
}
|
|
1587
|
+
if (bodyOnlySignature && [...workflowRuns.values()].some(
|
|
1588
|
+
(run) => run.definitionId === definition.id && run.payloadHash === payloadHash,
|
|
1589
|
+
)) {
|
|
1590
|
+
throw new ProtocolError(
|
|
1591
|
+
409,
|
|
1592
|
+
"this signed body already started a run under another delivery identifier",
|
|
1593
|
+
"webhook_payload_replayed",
|
|
1594
|
+
);
|
|
1595
|
+
}
|
|
1516
1596
|
const target = findKnownTarget(definition.project, definition.target);
|
|
1517
1597
|
const createdAt = nowIso();
|
|
1518
1598
|
const runId = newId("run");
|
|
@@ -1562,7 +1642,7 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
|
|
|
1562
1642
|
definitionId: definition.id,
|
|
1563
1643
|
source: definition.source,
|
|
1564
1644
|
deliveryId,
|
|
1565
|
-
payloadHash
|
|
1645
|
+
payloadHash,
|
|
1566
1646
|
definitionHash: workflowDefinitionHash(definition),
|
|
1567
1647
|
...(event ? { event } : {}),
|
|
1568
1648
|
project: definition.project,
|
|
@@ -1593,7 +1673,7 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
|
|
|
1593
1673
|
project: definition.project,
|
|
1594
1674
|
target: target.id,
|
|
1595
1675
|
});
|
|
1596
|
-
json(response, 202, { run, duplicate: false });
|
|
1676
|
+
json(response, 202, { run, runId: run.id, duplicate: false });
|
|
1597
1677
|
return;
|
|
1598
1678
|
}
|
|
1599
1679
|
|
|
@@ -2693,7 +2773,11 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
|
|
|
2693
2773
|
const hops = parseBoundedInteger(body.hops, "hops", 0, 0, 100);
|
|
2694
2774
|
const maxHops = parseBoundedInteger(body.maxHops, "maxHops", DEFAULT_MAX_HOPS, 1, 20);
|
|
2695
2775
|
if (hops >= maxHops) {
|
|
2696
|
-
throw new ProtocolError(
|
|
2776
|
+
throw new ProtocolError(
|
|
2777
|
+
400,
|
|
2778
|
+
`hop limit reached (${hops}/${maxHops}): this request would extend a chain of forwarded requests past its limit; answer the inbound request directly`,
|
|
2779
|
+
"hop_limit_reached",
|
|
2780
|
+
);
|
|
2697
2781
|
}
|
|
2698
2782
|
const ttlMs = parseBoundedInteger(
|
|
2699
2783
|
body.ttlMs,
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
|
-
import {
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
3
|
import { DatabaseSync } from "./sqlite.ts";
|
|
4
4
|
import { discoverKxmProjectRoot } from "./project-config.ts";
|
|
5
|
-
import {
|
|
5
|
+
import { kxmProjectRunEventsPath } from "./runtime-store.ts";
|
|
6
6
|
import { parseRoutingRecordV2, ROUTING_RECORD_V2_SCHEMA, type RoutingRecord, type RoutingRecordV2 } from "./routing.ts";
|
|
7
7
|
import { readRoutingRecords, telemetryPath } from "./telemetry.ts";
|
|
8
8
|
|
|
@@ -45,11 +45,6 @@ const ENGINE_EVENTS_SQL = "SELECT run_id, sequence, event_type, payload FROM eve
|
|
|
45
45
|
/** The simulated producer's harness label; its attempts measure nothing. */
|
|
46
46
|
const SIMULATED_HARNESS = "driver-simulated";
|
|
47
47
|
|
|
48
|
-
/** The project's Runtime event store, derived exactly as the Runtime derives it. */
|
|
49
|
-
export function kxmProjectRunEventsPath(projectRoot: string, env: NodeJS.ProcessEnv): string {
|
|
50
|
-
return join(kxmRuntimePaths({ env }).projectsDir, projectRuntimeKey(projectRoot), "run-events.db");
|
|
51
|
-
}
|
|
52
|
-
|
|
53
48
|
interface RunLog {
|
|
54
49
|
lastStatus?: string;
|
|
55
50
|
/** Latest step.entered sequence per step. */
|
|
@@ -11,7 +11,7 @@ import { deliverInboxNotification } from "./inbox.ts";
|
|
|
11
11
|
import type { HubEvent, MessageRecord } from "./protocol.ts";
|
|
12
12
|
import { sessionTokenFixHint } from "./session-token-hint.ts";
|
|
13
13
|
|
|
14
|
-
const VERSION = "0.7.
|
|
14
|
+
const VERSION = "0.7.97";
|
|
15
15
|
const CONFIGURE_PLUGIN = "/plugin configure kxm@kxm";
|
|
16
16
|
const inbox = new Map<string, MessageRecord>();
|
|
17
17
|
const notifiedInbox = new Set<string>();
|
|
@@ -190,7 +190,14 @@ mcp.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
|
|
190
190
|
const cmd = AGENT_COMMANDS_MAP.get(request.params.name);
|
|
191
191
|
if (!cmd) throw new Error(`unknown tool: ${request.params.name}`);
|
|
192
192
|
const args = asRecord(request.params.arguments);
|
|
193
|
-
|
|
193
|
+
// Every open inbound request is work this session is handling; a request it sends
|
|
194
|
+
// meanwhile continues their hop chain.
|
|
195
|
+
const result = await cmd.execute(client, args, {
|
|
196
|
+
signal: extra.signal,
|
|
197
|
+
inbox,
|
|
198
|
+
notifiedInbox,
|
|
199
|
+
handling: [...inbox.values()],
|
|
200
|
+
});
|
|
194
201
|
return textResult(result);
|
|
195
202
|
} catch (error) {
|
|
196
203
|
return {
|
|
@@ -51,6 +51,29 @@ export function projectRuntimeKey(projectRoot: string): string {
|
|
|
51
51
|
return createHash("sha256").update(folded, "utf8").digest("hex").slice(0, 24);
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/** The project's Runtime event store, derived exactly as the Runtime derives it. */
|
|
55
|
+
export function kxmProjectRunEventsPath(projectRoot: string, env: NodeJS.ProcessEnv): string {
|
|
56
|
+
return join(kxmRuntimePaths({ env }).projectsDir, projectRuntimeKey(projectRoot), "run-events.db");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Whether this project's Runtime event store holds `runId`. Hub workflow runs and Runtime
|
|
61
|
+
* runs share the `run_<32 hex>` shape, so a command addressed by run id asks the owning
|
|
62
|
+
* store instead of guessing from the id. Read-only; a project whose Runtime never ran
|
|
63
|
+
* owns no runs.
|
|
64
|
+
*/
|
|
65
|
+
export function projectRuntimeOwnsRun(projectRoot: string, runId: string, env: NodeJS.ProcessEnv): boolean {
|
|
66
|
+
const path = kxmProjectRunEventsPath(projectRoot, env);
|
|
67
|
+
if (!existsSync(path)) return false;
|
|
68
|
+
const database = new DatabaseSync(path, { readOnly: true });
|
|
69
|
+
try {
|
|
70
|
+
database.exec("PRAGMA busy_timeout = 5000");
|
|
71
|
+
return database.prepare("SELECT 1 FROM runs WHERE run_id = ?").get(runId) !== undefined;
|
|
72
|
+
} finally {
|
|
73
|
+
database.close();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
54
77
|
function checkedParent(path: string, description: string): void {
|
|
55
78
|
const parent = dirname(path);
|
|
56
79
|
if (!existsSync(parent)) mkdirSync(parent, { recursive: true, mode: 0o700 });
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
1
|
+
import { createHash, createHmac } from "node:crypto";
|
|
2
2
|
import {
|
|
3
3
|
IMPROVEMENT_AREAS,
|
|
4
4
|
MAX_MESSAGE_TTL_MS,
|
|
@@ -1386,6 +1386,75 @@ export function workflowDefinitionHash(definition: WebhookWorkflowDefinition): s
|
|
|
1386
1386
|
return createHash("sha256").update(canonicalWorkflowDefinitionJson(definition), "utf8").digest("hex");
|
|
1387
1387
|
}
|
|
1388
1388
|
|
|
1389
|
+
/** The KXM-owned webhook sender contract: every signal callback, and any
|
|
1390
|
+
* workflow start that is not a Jira or GitHub provider delivery. The HMAC
|
|
1391
|
+
* covers a fixed-arity header block and the exact body bytes, so a captured
|
|
1392
|
+
* request cannot be replayed under another delivery ID, against another run
|
|
1393
|
+
* or signal key, or outside the timestamp window. */
|
|
1394
|
+
export const WORKFLOW_WEBHOOK_SIGNATURE_VERSION = "kxm-webhook-v1";
|
|
1395
|
+
export const WORKFLOW_WEBHOOK_MAX_SKEW_SECONDS = 300;
|
|
1396
|
+
|
|
1397
|
+
/** What a signature is bound to. A start names only the definition; a signal
|
|
1398
|
+
* callback also names the run and signal key from its route. */
|
|
1399
|
+
export type WorkflowWebhookScope =
|
|
1400
|
+
| { definitionId: string; runId?: undefined; signalKey?: undefined }
|
|
1401
|
+
| { definitionId: string; runId: string; signalKey: string };
|
|
1402
|
+
|
|
1403
|
+
/** The exact bytes a KXM webhook signature covers: seven newline-terminated
|
|
1404
|
+
* fields (version, kind, timestamp, delivery ID, definition ID, run ID, signal
|
|
1405
|
+
* key; the last two empty for a start) followed by the raw body. No field may
|
|
1406
|
+
* contain a line break, so the encoding is unambiguous. */
|
|
1407
|
+
export function workflowWebhookSignedMaterial(
|
|
1408
|
+
scope: WorkflowWebhookScope,
|
|
1409
|
+
timestamp: string,
|
|
1410
|
+
deliveryId: string,
|
|
1411
|
+
body: string | Uint8Array,
|
|
1412
|
+
): Buffer {
|
|
1413
|
+
const fields = [
|
|
1414
|
+
WORKFLOW_WEBHOOK_SIGNATURE_VERSION,
|
|
1415
|
+
scope.runId === undefined ? "start" : "signal",
|
|
1416
|
+
timestamp,
|
|
1417
|
+
deliveryId,
|
|
1418
|
+
scope.definitionId,
|
|
1419
|
+
scope.runId ?? "",
|
|
1420
|
+
scope.signalKey ?? "",
|
|
1421
|
+
];
|
|
1422
|
+
if (fields.some((field) => /[\r\n]/.test(field))) {
|
|
1423
|
+
throw new ProtocolError(400, "webhook signature fields must not contain line breaks", "webhook_signature_field_invalid");
|
|
1424
|
+
}
|
|
1425
|
+
return Buffer.concat([
|
|
1426
|
+
Buffer.from(`${fields.join("\n")}\n`, "utf8"),
|
|
1427
|
+
typeof body === "string" ? Buffer.from(body, "utf8") : Buffer.from(body),
|
|
1428
|
+
]);
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
export function workflowWebhookSignature(
|
|
1432
|
+
secret: string,
|
|
1433
|
+
scope: WorkflowWebhookScope,
|
|
1434
|
+
timestamp: string,
|
|
1435
|
+
deliveryId: string,
|
|
1436
|
+
body: string | Uint8Array,
|
|
1437
|
+
): string {
|
|
1438
|
+
return `sha256=${createHmac("sha256", secret).update(workflowWebhookSignedMaterial(scope, timestamp, deliveryId, body)).digest("hex")}`;
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
/** Headers for one KXM webhook send. Sign at send time: a transport retry
|
|
1442
|
+
* re-signs with a fresh timestamp and keeps the same delivery ID and body. */
|
|
1443
|
+
export function workflowWebhookHeaders(input: {
|
|
1444
|
+
secret: string;
|
|
1445
|
+
scope: WorkflowWebhookScope;
|
|
1446
|
+
deliveryId: string;
|
|
1447
|
+
body: string;
|
|
1448
|
+
nowMs?: number;
|
|
1449
|
+
}): Record<string, string> {
|
|
1450
|
+
const timestamp = String(Math.floor((input.nowMs ?? Date.now()) / 1_000));
|
|
1451
|
+
return {
|
|
1452
|
+
"x-kxm-delivery-id": input.deliveryId,
|
|
1453
|
+
"x-kxm-timestamp": timestamp,
|
|
1454
|
+
"x-kxm-signature": workflowWebhookSignature(input.secret, input.scope, timestamp, input.deliveryId, input.body),
|
|
1455
|
+
};
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1389
1458
|
/** Resolve a declared transition rule for a stage outcome, or undefined when
|
|
1390
1459
|
* the outcome keeps the v0.4 default edges. */
|
|
1391
1460
|
function resolveOutcomeRule(stage: WorkflowStageState, outcomeKey: string): WorkflowTransitionRule | undefined {
|