@kontextmind/kxm 0.7.96 → 0.7.98
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/.kxm/workflows/default.yaml +2 -0
- package/CHANGELOG.md +61 -2
- package/docs/concepts/architecture.md +1 -1
- package/docs/concepts/data-and-storage.md +1 -1
- package/docs/concepts/trust-model.md +3 -2
- package/docs/contracts/routing.md +1 -1
- package/docs/contributing/test-matrix.md +6 -5
- 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/backup-and-restore.md +43 -24
- package/docs/operations/deploy.md +2 -2
- package/docs/operations/troubleshooting.md +3 -2
- package/docs/reference/cli-reference.md +65 -30
- package/docs/reference/config-reference.md +24 -13
- package/docs/reference/configuration.md +2 -2
- package/docs/reference/harness-routing.md +3 -3
- package/docs/reference/http-api.md +7 -7
- package/docs/reference/tools.md +1 -1
- package/docs/reference/workflow-definitions.md +2 -2
- package/docs/start/first-workflow.md +4 -4
- package/docs/start/quickstart-claude-code.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 +552 -228
- package/plugins/kxm/dist/client.js +3 -1
- package/plugins/kxm/dist/core.js +16 -3
- package/plugins/kxm/dist/extension.js +45 -13
- package/plugins/kxm/dist/mcp-server.js +20 -4
- package/plugins/kxm/dist/runtime-supervisor.js +232 -51
- package/plugins/kxm/dist/runtime.js +432 -95
- package/plugins/kxm/dist/server.js +122 -20
- package/plugins/kxm/package.json +1 -1
- package/plugins/kxm/skills/kxm-hub-ops/SKILL.md +4 -3
- package/plugins/kxm/skills/kxm-project-setup/SKILL.md +3 -2
- package/plugins/kxm/skills/kxm-routing-improve/SKILL.md +3 -0
- package/plugins/kxm/skills/kxm-runs/SKILL.md +8 -7
- package/plugins/kxm/src/cli/project.ts +22 -13
- package/plugins/kxm/src/cli/system.ts +22 -1
- package/plugins/kxm/src/cli/workflows.ts +12 -7
- package/plugins/kxm/src/cli.ts +30 -8
- package/plugins/kxm/src/client.ts +4 -0
- package/plugins/kxm/src/commands.ts +23 -1
- package/plugins/kxm/src/database.ts +210 -36
- package/plugins/kxm/src/engine.ts +117 -2
- package/plugins/kxm/src/extension.ts +20 -14
- package/plugins/kxm/src/github-watch.ts +8 -5
- package/plugins/kxm/src/harness.ts +29 -0
- 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/init-guide-setup.ts +43 -28
- package/plugins/kxm/src/mcp-server.ts +9 -2
- package/plugins/kxm/src/oneshot-producer.ts +16 -8
- package/plugins/kxm/src/prices.ts +33 -2
- package/plugins/kxm/src/routing.ts +13 -7
- package/plugins/kxm/src/runtime-store.ts +23 -0
- package/plugins/kxm/src/studio-layout.ts +5 -4
- package/plugins/kxm/src/template.ts +31 -0
- package/plugins/kxm/src/workflow.ts +70 -1
- package/plugins/kxm/src/worktree-witness.ts +71 -0
- package/schemas/backup-manifest.schema.json +33 -0
- package/scripts/smoke-multi-pi.mjs +5 -1
|
@@ -3,6 +3,8 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { parse } from "yaml";
|
|
5
5
|
import { isRouteAdmitted, listRoleBindings } from "./routes.ts";
|
|
6
|
+
import { oneShotWriterArgs } from "./harness.ts";
|
|
7
|
+
import { applyAuthoringWitness, captureWorktreeWitness } from "./worktree-witness.ts";
|
|
6
8
|
import {
|
|
7
9
|
buildFormalContextPacket,
|
|
8
10
|
buildHandoffManifest,
|
|
@@ -153,6 +155,8 @@ export interface KxmProducerRequest {
|
|
|
153
155
|
readonly thinking?: string | undefined;
|
|
154
156
|
readonly agentRole?: string | undefined;
|
|
155
157
|
readonly harness?: string | undefined;
|
|
158
|
+
/** Live producers select an audited argv profile from this ceiling. */
|
|
159
|
+
readonly permission?: "read-only" | "edit" | undefined;
|
|
156
160
|
readonly contextPacket?: FormalContextPacketV2 | undefined;
|
|
157
161
|
readonly handoffManifest?: HandoffManifestV1 | undefined;
|
|
158
162
|
}
|
|
@@ -1593,6 +1597,14 @@ function prepareDispatch(
|
|
|
1593
1597
|
return { kind: "return", state, handoff: { ...routeResult.error, stepId } };
|
|
1594
1598
|
}
|
|
1595
1599
|
resolvedRoute = routeResult;
|
|
1600
|
+
const writeRefusal = unsupportedLiveWrite(
|
|
1601
|
+
context.projectRoot,
|
|
1602
|
+
step,
|
|
1603
|
+
agentId,
|
|
1604
|
+
resolvedRoute.selector,
|
|
1605
|
+
loadKxmRunPlanEnvelope(context.eventStore, run).projectLimits.maxConcurrentRuns,
|
|
1606
|
+
);
|
|
1607
|
+
if (writeRefusal) return { kind: "return", state, handoff: { ...writeRefusal, stepId } };
|
|
1596
1608
|
}
|
|
1597
1609
|
|
|
1598
1610
|
const used = state.stepAttempts[stepId] ?? 0;
|
|
@@ -1825,6 +1837,7 @@ function birthMember(
|
|
|
1825
1837
|
signal: controller.signal,
|
|
1826
1838
|
prompt: input.step.instructions ? `${input.step.instructions}\n\n${generatedPrompt}` : generatedPrompt,
|
|
1827
1839
|
thinking: input.stepAttempt <= 1 ? "low" : "medium",
|
|
1840
|
+
permission: Object.values(input.step.repositories).some((access) => access === "write") ? "edit" : "read-only",
|
|
1828
1841
|
contextPacket,
|
|
1829
1842
|
...(resolvedRoute ? { provider: resolvedRoute.provider, model: resolvedRoute.model } : {}),
|
|
1830
1843
|
},
|
|
@@ -1998,7 +2011,18 @@ async function drivePanel(
|
|
|
1998
2011
|
if (!executingBound()) return { attemptId: member.attemptId, invoked: false, skipped: true };
|
|
1999
2012
|
kxmPanelDispatchSeams.beforeInvoke?.(member);
|
|
2000
2013
|
if (!executingBound()) return { attemptId: member.attemptId, invoked: false, skipped: true };
|
|
2014
|
+
const live = member.producerId !== "driver-simulated";
|
|
2015
|
+
const writes = Object.values(member.step.repositories).some((access) => access === "write");
|
|
2016
|
+
const before = live ? captureWorktreeWitness(context.projectRoot) : undefined;
|
|
2001
2017
|
const produced = await invokeProducer(producer, member.request);
|
|
2018
|
+
if (live && produced.result && before) {
|
|
2019
|
+
const after = captureWorktreeWitness(context.projectRoot);
|
|
2020
|
+
return {
|
|
2021
|
+
attemptId: member.attemptId,
|
|
2022
|
+
invoked: true,
|
|
2023
|
+
produced: { ...produced, result: applyAuthoringWitness(produced.result, { writes, before, after }) },
|
|
2024
|
+
};
|
|
2025
|
+
}
|
|
2002
2026
|
return { attemptId: member.attemptId, invoked: true, produced };
|
|
2003
2027
|
} catch (error) {
|
|
2004
2028
|
member.controller.abort();
|
|
@@ -2722,11 +2746,102 @@ function unsupportedStep(
|
|
|
2722
2746
|
return { reason: "step_unsupported", field: "repositories", detail: `invalid repository access '${access}' on ${repoId}` };
|
|
2723
2747
|
}
|
|
2724
2748
|
}
|
|
2725
|
-
|
|
2749
|
+
return undefined;
|
|
2750
|
+
}
|
|
2751
|
+
|
|
2752
|
+
function readYamlRecord(path: string): Record<string, unknown> | undefined {
|
|
2753
|
+
if (!existsSync(path)) return undefined;
|
|
2754
|
+
try {
|
|
2755
|
+
const parsed = parse(readFileSync(path, "utf8")) as unknown;
|
|
2756
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed as Record<string, unknown>;
|
|
2757
|
+
} catch {
|
|
2758
|
+
return undefined;
|
|
2759
|
+
}
|
|
2760
|
+
return undefined;
|
|
2761
|
+
}
|
|
2762
|
+
|
|
2763
|
+
function agentHarness(projectRoot: string, agentId: string): string | undefined {
|
|
2764
|
+
const harness = readYamlRecord(join(projectRoot, ".kxm", "agents", `${agentId}.yaml`))?.harness;
|
|
2765
|
+
return typeof harness === "string" && harness.length > 0 ? harness : undefined;
|
|
2766
|
+
}
|
|
2767
|
+
|
|
2768
|
+
function projectDefaultHarness(projectRoot: string): string {
|
|
2769
|
+
const harness = readYamlRecord(join(projectRoot, ".kxm", "project.yaml"))?.defaultHarness;
|
|
2770
|
+
return typeof harness === "string" && harness.length > 0 ? harness : "pi";
|
|
2771
|
+
}
|
|
2772
|
+
|
|
2773
|
+
/**
|
|
2774
|
+
* Live write steps run only on an audited writer profile, and only when the
|
|
2775
|
+
* developer roster (when present) lists that harness and model as an edit writer.
|
|
2776
|
+
* A missing roster is a fresh project: route admission is the other gate.
|
|
2777
|
+
*
|
|
2778
|
+
* The authoring witness fingerprints the one project checkout around each
|
|
2779
|
+
* spawn, so it can only attribute a change to a lone writer. A write step with
|
|
2780
|
+
* more than one assignment would run several writers there (and member n runs
|
|
2781
|
+
* as allowedAgents[n], which the writer check below never sees), and a
|
|
2782
|
+
* project that admits concurrent runs lets another run's writer edit the tree
|
|
2783
|
+
* mid-attempt. Both hand off.
|
|
2784
|
+
*/
|
|
2785
|
+
function unsupportedLiveWrite(
|
|
2786
|
+
projectRoot: string,
|
|
2787
|
+
step: KxmCompiledStep,
|
|
2788
|
+
agentId: string,
|
|
2789
|
+
selector: string,
|
|
2790
|
+
maxConcurrentRuns: number,
|
|
2791
|
+
): Omit<KxmRunHandoff, "stepId"> | undefined {
|
|
2792
|
+
if (!Object.values(step.repositories).some((access) => access === "write")) return undefined;
|
|
2793
|
+
if (step.assignments.maximum !== 1) {
|
|
2794
|
+
return {
|
|
2795
|
+
reason: "step_unsupported",
|
|
2796
|
+
field: "assignments.maximum",
|
|
2797
|
+
detail: "live write steps run a single assignment; the checkout witness cannot attribute edits between writers",
|
|
2798
|
+
};
|
|
2799
|
+
}
|
|
2800
|
+
if (maxConcurrentRuns !== 1) {
|
|
2801
|
+
return {
|
|
2802
|
+
reason: "step_unsupported",
|
|
2803
|
+
field: "limits.maxConcurrentRuns",
|
|
2804
|
+
detail: "live write steps require limits.maxConcurrentRuns of 1; concurrent runs share one checkout",
|
|
2805
|
+
};
|
|
2806
|
+
}
|
|
2807
|
+
const harness = agentHarness(projectRoot, agentId) ?? projectDefaultHarness(projectRoot);
|
|
2808
|
+
if (!oneShotWriterArgs(harness)) {
|
|
2726
2809
|
return {
|
|
2727
2810
|
reason: "step_unsupported",
|
|
2728
2811
|
field: "repositories",
|
|
2729
|
-
detail:
|
|
2812
|
+
detail: `live write steps require an audited writer profile; ${harness} has none`,
|
|
2813
|
+
};
|
|
2814
|
+
}
|
|
2815
|
+
const rosterPath = join(projectRoot, ".kxm", "roster.yaml");
|
|
2816
|
+
if (!existsSync(rosterPath)) return undefined;
|
|
2817
|
+
const roster = readYamlRecord(rosterPath);
|
|
2818
|
+
if (!roster || roster.schema !== "kxm.developer-roster.v1") {
|
|
2819
|
+
return { reason: "step_unsupported", field: "model", detail: "live write steps require a readable kxm.developer-roster.v1" };
|
|
2820
|
+
}
|
|
2821
|
+
const routes = roster.routes;
|
|
2822
|
+
const lineup = roster.lineup;
|
|
2823
|
+
const writerIds = lineup && typeof lineup === "object" && !Array.isArray(lineup)
|
|
2824
|
+
? (lineup as Record<string, unknown>).writer
|
|
2825
|
+
: undefined;
|
|
2826
|
+
if (!routes || typeof routes !== "object" || Array.isArray(routes) || !Array.isArray(writerIds)) {
|
|
2827
|
+
return { reason: "step_unsupported", field: "model", detail: "developer roster has no writer lineup" };
|
|
2828
|
+
}
|
|
2829
|
+
const allowed = writerIds.some((id) => {
|
|
2830
|
+
if (typeof id !== "string") return false;
|
|
2831
|
+
const route = (routes as Record<string, unknown>)[id];
|
|
2832
|
+
if (!route || typeof route !== "object" || Array.isArray(route)) return false;
|
|
2833
|
+
const record = route as Record<string, unknown>;
|
|
2834
|
+
if (record.harness !== harness || record.status !== "admitted") return false;
|
|
2835
|
+
if (!Array.isArray(record.permissions) || !record.permissions.includes("edit")) return false;
|
|
2836
|
+
const model = typeof record.model === "string" ? record.model : "";
|
|
2837
|
+
const vendor = typeof record.vendor === "string" ? record.vendor : "";
|
|
2838
|
+
return model === selector || (vendor.length > 0 && `${vendor}/${model}` === selector);
|
|
2839
|
+
});
|
|
2840
|
+
if (!allowed) {
|
|
2841
|
+
return {
|
|
2842
|
+
reason: "step_unsupported",
|
|
2843
|
+
field: "model",
|
|
2844
|
+
detail: `live write route ${selector} on ${harness} is not on the developer roster writer lineup`,
|
|
2730
2845
|
};
|
|
2731
2846
|
}
|
|
2732
2847
|
return undefined;
|
|
@@ -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);
|
|
@@ -518,6 +518,35 @@ export function oneShotReadOnlyArgs(harness: string): readonly string[] | undefi
|
|
|
518
518
|
return Object.hasOwn(READ_ONLY_ONESHOT_ARGS, harness) ? READ_ONLY_ONESHOT_ARGS[harness as keyof typeof READ_ONLY_ONESHOT_ARGS] : undefined;
|
|
519
519
|
}
|
|
520
520
|
|
|
521
|
+
/**
|
|
522
|
+
* Audited edit profiles. These are a writer containment, not read-only flags
|
|
523
|
+
* with the sandbox removed. They match the assignment helper
|
|
524
|
+
* (`scripts/harness-run.mjs` `buildArgv` for `permission: "edit"` with hooks
|
|
525
|
+
* and skills refused):
|
|
526
|
+
*
|
|
527
|
+
* - Pi: `-a` auto-approves tools so the process can edit the checkout, while
|
|
528
|
+
* extensions, skills, prompt templates, and session persistence stay off.
|
|
529
|
+
* `--no-tools` is the read-only profile and is not used here.
|
|
530
|
+
* - Grok: `--always-approve` so edits are not an interactive prompt, with
|
|
531
|
+
* subagents and web search disabled. Grok has no audited read-only edit
|
|
532
|
+
* mix; the read-only one-shot flags stay on the read-only profile.
|
|
533
|
+
*
|
|
534
|
+
* Claude, Codex, agy, and Kimi stay read-only. A live write step on a harness
|
|
535
|
+
* without an entry here hands off instead of spawning unconstrained.
|
|
536
|
+
*/
|
|
537
|
+
const WRITER_ONESHOT_ARGS = Object.freeze({
|
|
538
|
+
pi: Object.freeze(["-a", "--no-extensions", "--no-skills", "--no-prompt-templates", "--no-session"]),
|
|
539
|
+
grok: Object.freeze(["--always-approve", "--no-subagents", "--disable-web-search"]),
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
export function oneShotWriterArgs(harness: string): readonly string[] | undefined {
|
|
543
|
+
return Object.hasOwn(WRITER_ONESHOT_ARGS, harness) ? WRITER_ONESHOT_ARGS[harness as keyof typeof WRITER_ONESHOT_ARGS] : undefined;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
export function oneShotPermissionArgs(harness: string, permission: "read-only" | "edit"): readonly string[] | undefined {
|
|
547
|
+
return permission === "edit" ? oneShotWriterArgs(harness) : oneShotReadOnlyArgs(harness);
|
|
548
|
+
}
|
|
549
|
+
|
|
521
550
|
export const BUILTIN_HARNESSES: readonly HarnessCatalogEntry[] = Object.freeze([
|
|
522
551
|
{
|
|
523
552
|
id: "pi",
|
|
@@ -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. */
|