Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d3f81c2c0 |
@@ -0,0 +1,111 @@
|
||||
// **mesh/check is written from the forge bridge's own event, never from a tool** (review finding K1 of R4e; novox/hq issue
|
||||
// 1301; ADR 0447 §1, ADR 0503 §3, ADR 0513 §4: main requires mesh/check alone).
|
||||
//
|
||||
// A tool is told no caller, and the console every agent session speaks through (node-tools) invokes every tool, so a tool
|
||||
// that wrote mesh/check could be called by any session to pass a merge past the checker: the fault issue 293 closed for
|
||||
// the two statuses before it. So the checker's forge bridge emits `check.status` instead. The bus grants the subject
|
||||
// mesh.mod.mesh-delivery-forge-bridge.event.check.status to the bridge's own account alone (its manifest's `emits`; a
|
||||
// module publishes under its own name only), and the bridge runs on that account, never carried by a machine's tool
|
||||
// runner. This module consumes the event, as it consumes mesh-controller.checked, and writes the status.
|
||||
//
|
||||
// The event's body: owner, repo, sha (the head, whole), state, description, target_url, key (the checker's owed key) and
|
||||
// revision (that key's revision in the checker's bucket). Its source is checked against the bridge too: the bus already
|
||||
// said it, and a body read from elsewhere says it again. Delivery is at-least-once and a failed write is delivered again
|
||||
// later, so a head's revision older than one already written is dropped: a stale pending never hides a verdict.
|
||||
|
||||
import type { CommitStatus } from "./client.js";
|
||||
import { checkName } from "./guard.js";
|
||||
import { CHECK_STATUS } from "./pulls.js";
|
||||
|
||||
/** The module that alone writes mesh/check, and the event it writes it with. */
|
||||
export const CHECK_WRITER = "mesh-delivery-forge-bridge";
|
||||
export const CHECK_EVENT = `${CHECK_WRITER}.check.status`;
|
||||
|
||||
const states = new Set(["pending", "success", "error", "failure", "warning"]);
|
||||
|
||||
/** What writing the check needs of the forge. */
|
||||
export interface CheckForge {
|
||||
setCommitStatus(owner: string, repo: string, sha: string, status: CommitStatus): Promise<void>;
|
||||
}
|
||||
|
||||
/** An event as the SDK's `on` hands it. */
|
||||
export interface CheckEvent {
|
||||
source: string;
|
||||
id?: string;
|
||||
body: unknown;
|
||||
}
|
||||
|
||||
/** The status a check.status asks for, checked; or why it is not one. */
|
||||
export function checkStatusOf(body: unknown): { owner: string; repo: string; sha: string; revision: number; status: CommitStatus } {
|
||||
if (!body || typeof body !== "object") throw new Error("the event carries no body");
|
||||
const b = body as Record<string, unknown>;
|
||||
const owner = checkName("owner", b.owner), repo = checkName("name", b.repo);
|
||||
const sha = String(b.sha ?? "");
|
||||
if (!/^([0-9a-f]{40}|[0-9a-f]{64})$/i.test(sha)) throw new Error(`${JSON.stringify(b.sha)} is not a head's whole sha`);
|
||||
if (b.context !== undefined && b.context !== null && b.context !== CHECK_STATUS) {
|
||||
throw new Error(`${JSON.stringify(b.context)}: check.status writes ${CHECK_STATUS} alone`);
|
||||
}
|
||||
const state = String(b.state ?? "");
|
||||
if (!states.has(state)) throw new Error(`${JSON.stringify(b.state)} is not a status the forge keeps`);
|
||||
const revision = b.revision;
|
||||
if (typeof revision !== "number" || !Number.isSafeInteger(revision) || revision < 0) {
|
||||
throw new Error(`${JSON.stringify(revision)} is not a revision of the checker's owed key`);
|
||||
}
|
||||
let description = String(b.description ?? "").replace(/\s+/g, " ").trim();
|
||||
if (description.length > 140) description = description.slice(0, 139) + "…";
|
||||
const target = typeof b.target_url === "string" && /^https?:\/\//.test(b.target_url) ? b.target_url : undefined;
|
||||
const status: CommitStatus = { state: state as CommitStatus["state"], context: CHECK_STATUS, description,
|
||||
...(target ? { target_url: target } : {}) };
|
||||
return { owner, repo, sha, revision, status };
|
||||
}
|
||||
|
||||
/** The one writer of mesh/check: each check.status the bridge emits, in revision order per head. */
|
||||
export class CheckStatuses {
|
||||
/** The newest revision written on each head, owner/repo@sha; the oldest heads go past what any forge holds open. */
|
||||
private written = new Map<string, number>();
|
||||
/** The turn each head's writes wait on. */
|
||||
private turns = new Map<string, Promise<void>>();
|
||||
|
||||
private forge: CheckForge;
|
||||
private say: (line: string) => void;
|
||||
|
||||
constructor(forge: CheckForge, say: (line: string) => void = (l) => console.error(l)) {
|
||||
this.forge = forge;
|
||||
this.say = say;
|
||||
}
|
||||
|
||||
/** Write the event's status: "written", or "dropped" for one that is not the bridge's, malformed or stale. A forge
|
||||
* that refuses throws, so the bus delivers the event again. */
|
||||
async apply(event: CheckEvent): Promise<"written" | "dropped"> {
|
||||
if (event.source !== CHECK_WRITER) {
|
||||
this.say(`[gitea] a check.status from ${JSON.stringify(event.source)}, not the forge bridge, is dropped: mesh/check is the bridge's alone`);
|
||||
return "dropped";
|
||||
}
|
||||
let asked;
|
||||
try {
|
||||
asked = checkStatusOf(event.body);
|
||||
} catch (err) {
|
||||
this.say(`[gitea] the forge bridge's check.status ${event.id ?? ""} is dropped: ${err instanceof Error ? err.message : err}`);
|
||||
return "dropped";
|
||||
}
|
||||
const head = `${asked.owner}/${asked.repo}@${asked.sha}`.toLowerCase();
|
||||
// One head's writes take turns, so two deliveries at once are weighed one after the other.
|
||||
const before = this.turns.get(head) ?? Promise.resolve();
|
||||
const turn = before.then(async (): Promise<"written" | "dropped"> => {
|
||||
const newest = this.written.get(head);
|
||||
if (newest !== undefined && asked.revision < newest) {
|
||||
this.say(`[gitea] ${asked.owner}/${asked.repo}@${asked.sha.slice(0, 12)}: mesh/check at revision ${asked.revision} is older than ${newest}, already written, and is dropped`);
|
||||
return "dropped";
|
||||
}
|
||||
await this.forge.setCommitStatus(asked.owner, asked.repo, asked.sha, asked.status);
|
||||
this.written.delete(head);
|
||||
this.written.set(head, asked.revision);
|
||||
if (this.written.size > 2000) this.written.delete(this.written.keys().next().value as string);
|
||||
return "written";
|
||||
});
|
||||
const settled = turn.then(() => {}, () => {});
|
||||
this.turns.set(head, settled);
|
||||
void settled.then(() => { if (this.turns.get(head) === settled) this.turns.delete(head); });
|
||||
return turn;
|
||||
}
|
||||
}
|
||||
@@ -101,10 +101,10 @@ export function deliveryStatus(context: string, state: string, description: stri
|
||||
if (!/^mesh\/[a-z-]+$/.test(context)) throw new Error(`${context} is not a status of the mesh's own`);
|
||||
if (!states.has(state)) throw new Error(`${state} is not a status the forge keeps`);
|
||||
// mesh/check is the checker's verdict and the one status main requires (ADR 0447 §1): no hand sets it in any
|
||||
// state, as issue 293 closed for the two before it. The forge bridge writes it through gitea_check_status, a tool
|
||||
// only its account may call (issue 1301).
|
||||
// state, as issue 293 closed for the two before it. No tool writes it, since a tool is told no caller: the forge
|
||||
// bridge emits check.status on a subject only its own account may publish, and check.ts writes it (R4e finding K1).
|
||||
if (context === CHECK_STATUS) {
|
||||
throw new Error(`${CHECK_STATUS} is the checker's status: the forge bridge writes it through gitea_check_status, never this tool`);
|
||||
throw new Error(`${CHECK_STATUS} is the checker's status: it is written from the forge bridge's check.status event alone, never by a tool`);
|
||||
}
|
||||
// The merge check's statuses are its verdict's, set when the controller says it, and a branch may require
|
||||
// them: never set to a verdict by hand, so nothing passes — or fails — a merge past the check (novox/hq
|
||||
@@ -136,7 +136,7 @@ export interface StatusWriters {
|
||||
export function commitStatusTool(writers: StatusWriters): ToolDefinition {
|
||||
return {
|
||||
name: "gitea_commit_status",
|
||||
description: "Set one of the mesh's statuses on a commit (mesh/delivery, mesh/delivery-group): pending, success, error, failure or warning, a short description, and the page it links to. mesh/check, the status main requires, is refused: the checker's forge bridge writes it through gitea_check_status. hq's mesh/repo-check (and mesh/merge-gate, kept for a revert) take pending only — a recheck putting them back until the fresh verdict sets them — on the head's whole sha, in their turn after the check's earlier writes.",
|
||||
description: "Set one of the mesh's statuses on a commit (mesh/delivery, mesh/delivery-group): pending, success, error, failure or warning, a short description, and the page it links to. mesh/check, the status main requires, is refused: no tool writes it, only the checker's forge bridge's check.status event. hq's mesh/repo-check (and mesh/merge-gate, kept for a revert) take pending only — a recheck putting them back until the fresh verdict sets them — on the head's whole sha, in their turn after the check's earlier writes.",
|
||||
input: {
|
||||
owner: { type: "string", description: "the repository owner" },
|
||||
repo: { type: "string", description: "the repository name" },
|
||||
@@ -163,48 +163,3 @@ export function commitStatusTool(writers: StatusWriters): ToolDefinition {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** What the check's status tool writes with: the forge. */
|
||||
export interface CheckStatusWriter {
|
||||
setCommitStatus(owner: string, repo: string, sha: string, status: CommitStatus): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `gitea_check_status` tool (novox/hq issue 1301, ADR 0447 §1): the one way `mesh/check` is written. Its arguments
|
||||
* are gitea_commit_status's, so the forge bridge sends an owed status's arguments unchanged to it: owner, repo, sha
|
||||
* (the head's whole sha), state, description, target_url, and context, which may be left out and is mesh/check when
|
||||
* given. **Who may write it is the bus's grant**: the runtime tells a tool no caller, so only a module that lists
|
||||
* `gitea.gitea_check_status` in its manifest's `invokes` may call it, and the forge bridge alone does. Served beside
|
||||
* gitea_commit_status, from the watcher's bundle.
|
||||
*/
|
||||
export function checkStatusTool(writer: CheckStatusWriter): ToolDefinition {
|
||||
return {
|
||||
name: "gitea_check_status",
|
||||
description: "Set mesh/check, the checker's verdict and the one status main requires, on a pull request's head: pending, success, error, failure or warning, a short description, and the page it links to. The checker's forge bridge alone may call it (its manifest's invokes); no other status is written here, and no other tool writes this one.",
|
||||
input: {
|
||||
owner: { type: "string", description: "the repository owner" },
|
||||
repo: { type: "string", description: "the repository name" },
|
||||
sha: { type: "string", description: "the head's whole sha" },
|
||||
context: { type: "string", description: "mesh/check, or left out" },
|
||||
state: { type: "string", description: "pending | success | error | failure | warning" },
|
||||
description: { type: "string", description: "one line" },
|
||||
target_url: { type: "string", description: "the page it links to (the check's report)" },
|
||||
},
|
||||
run: async (args) => {
|
||||
const owner = checkName("owner", args.owner), repo = checkName("name", args.repo), sha = String(args.sha ?? "");
|
||||
if (args.context !== undefined && args.context !== null && String(args.context) !== CHECK_STATUS) {
|
||||
throw new Error(`${JSON.stringify(args.context)}: this tool writes ${CHECK_STATUS} alone. Nothing was sent`);
|
||||
}
|
||||
if (!/^([0-9a-f]{40}|[0-9a-f]{64})$/i.test(sha)) {
|
||||
throw new Error(`${JSON.stringify(args.sha)} is not a head's whole sha: ${CHECK_STATUS} names the head whole. Nothing was sent`);
|
||||
}
|
||||
// deliveryStatus checks the state and cuts the description; it refuses mesh/check, so it is asked for a
|
||||
// context of the mesh's own and the status is named mesh/check after.
|
||||
const checked = deliveryStatus("mesh/delivery", String(args.state), String(args.description ?? ""),
|
||||
args.target_url ? String(args.target_url) : undefined);
|
||||
const status = { ...checked, context: CHECK_STATUS };
|
||||
await writer.setCommitStatus(owner, repo, sha, status);
|
||||
return { set: status };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ module gitea
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021114014-66e8fdaa62e2
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
golang.org/x/crypto v0.57.0
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9 h1:Ufmd2cYEXICQ817IGflccP9bexABE4WhIO49HPbaoSo=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021114014-66e8fdaa62e2 h1:Cdl5N56QPfmFBoLHrDfvvyc5Q+PUnTjf3fgYD7imcco=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021114014-66e8fdaa62e2/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
|
||||
+15
-5
@@ -13,8 +13,11 @@
|
||||
// commit's statuses: `mesh/merge-gate`, the modules of the mesh's graph the change touches, and
|
||||
// `mesh/repo-check`, the repository's own merge-check.sh (novox/hq ADR 0237 as amended); with a comment
|
||||
// saying why when the gate is not a pass or the repository's own check failed. Of the two, only those the base
|
||||
// still requires are written (issue 1301): main requires `mesh/check` — the checker's, written by its forge bridge
|
||||
// through gitea_check_status — everywhere but hq, whose main requires `mesh/repo-check` alone (ADR 0503 §5).
|
||||
// still requires are written (issue 1301): main requires `mesh/check` — the checker's — everywhere but hq, whose main
|
||||
// requires `mesh/repo-check` alone (ADR 0503 §5).
|
||||
//
|
||||
// Consumes mesh-delivery-forge-bridge.check.status — the checker's forge bridge's mesh/check on a head — and writes it
|
||||
// (check.ts). No tool writes mesh/check: the bus lets the bridge's own account alone publish that event (R4e finding K1).
|
||||
//
|
||||
// Consumes node-build-agent.started — a build machine took an ask — and says, on the head a pending check is of,
|
||||
// which machine builds it since when (novox/hq issue 446). This module is the one writer of mesh/merge-gate.
|
||||
@@ -41,7 +44,8 @@
|
||||
import { emit, on } from "@novox/mesh-sdk/events";
|
||||
import { registerModuleTools } from "@novox/mesh-sdk/tools";
|
||||
import { statesAnnouncement } from "./kept.js";
|
||||
import { checkStatusTool, commitStatusTool } from "./delivery.js";
|
||||
import { commitStatusTool } from "./delivery.js";
|
||||
import { CHECK_EVENT, CheckStatuses } from "./check.js";
|
||||
import { announce, keepWatermark, readWatermark } from "./announce.js";
|
||||
import { mergeTool } from "./merge.js";
|
||||
import { GiteaAdmin, GiteaClient, movedSince } from "./client.js";
|
||||
@@ -475,6 +479,11 @@ const gates = new GateWriter({
|
||||
headChecks: (owner, repo, sha) => (gitea as GiteaClient).headChecks(owner, repo, sha),
|
||||
});
|
||||
|
||||
// The one writer of mesh/check, from the forge bridge's check.status (check.ts, R4e finding K1).
|
||||
const checks = new CheckStatuses({
|
||||
setCommitStatus: (owner, repo, sha, status) => (gitea as GiteaClient).setCommitStatus(owner, repo, sha, status),
|
||||
});
|
||||
|
||||
// **The status tool is served here, beside the writer** (novox/hq issue 456): the runtime starts each bundle as its own
|
||||
// process, so a recheck's pending written from the tools' bundle bypassed this writer's chain. Without a forge to
|
||||
// reach, nothing is served, as the tools' bundle serves nothing then.
|
||||
@@ -491,8 +500,6 @@ await emit("states", statesAnnouncement(process.env.MESH_MODULE || "gitea", proc
|
||||
registerModuleTools("gitea", () => [eventsTool(pace), ...(gitea ? [commitStatusTool({
|
||||
setCommitStatus: (owner, repo, sha, status) => (gitea as GiteaClient).setCommitStatus(owner, repo, sha, status),
|
||||
gates,
|
||||
}), checkStatusTool({
|
||||
setCommitStatus: (owner, repo, sha, status) => (gitea as GiteaClient).setCommitStatus(owner, repo, sha, status),
|
||||
}), mergeTool(gitea, { gates })] : [])]);
|
||||
|
||||
// **A build machine took an ask** (the build seat's `started`, novox/hq ADR 0157): said on the head it checks, when
|
||||
@@ -620,5 +627,8 @@ if (gitea) {
|
||||
return setVerdict(client, event);
|
||||
})],
|
||||
["node-build-agent.started", () => on("node-build-agent.started", buildStarted)],
|
||||
// The checker's mesh/check, from the one account the bus lets publish it (check.ts): a forge that refuses throws,
|
||||
// and the event is delivered again.
|
||||
[CHECK_EVENT, () => on(CHECK_EVENT, async (event) => { await checks.apply(event); })],
|
||||
], () => console.log("[gitea] watching for new repositories and merged pull requests"));
|
||||
}
|
||||
|
||||
@@ -87,7 +87,8 @@
|
||||
],
|
||||
"consumes": [
|
||||
"mesh-controller.checked",
|
||||
"node-build-agent.started"
|
||||
"node-build-agent.started",
|
||||
"mesh-delivery-forge-bridge.check.status"
|
||||
],
|
||||
"listens": [
|
||||
{
|
||||
|
||||
@@ -36,3 +36,60 @@ test("importing the bundle resolves while the broker keeps refusing the binds",
|
||||
clearTimeout(timer);
|
||||
assert.match(out, /imported/, `the import did not resolve within 3 s while a bind was refused; stderr: ${err.slice(0, 600)}`);
|
||||
});
|
||||
|
||||
// **The bundle binds the forge bridge's check.status and serves no tool that writes mesh/check** (review finding K1 of R4e;
|
||||
// issue 1301 S4; ADR 0503 §3). The bundle is imported whole, as the runtime imports it; the consumer it binds is handed the
|
||||
// bridge's event and one from another source, and the forge stand-in sees mesh/check written for the bridge's alone.
|
||||
test("the bundle writes mesh/check from the bridge's check.status alone, and serves no gitea_check_status", async () => {
|
||||
const index = pathToFileURL(join(here, "..", "dist", "index.js")).href;
|
||||
const sha = "0123456789abcdef0123456789abcdef01234567";
|
||||
const script = `
|
||||
const http = await import("node:http");
|
||||
const statuses = [];
|
||||
const forge = http.createServer((req, res) => {
|
||||
let b = ""; req.on("data", (d) => (b += d)); req.on("end", () => {
|
||||
if (req.method === "POST" && /\\/statuses\\//.test(req.url)) statuses.push(req.url + " " + b);
|
||||
res.setHeader("content-type", "application/json"); res.end(req.method === "GET" ? "[]" : "{}");
|
||||
});
|
||||
});
|
||||
await new Promise((r) => forge.listen(0, "127.0.0.1", r));
|
||||
process.env.MESH_GITEA_URL = "http://127.0.0.1:" + forge.address().port;
|
||||
const bound = {};
|
||||
const { useBroker } = await import("@novox/mesh-sdk/messaging");
|
||||
useBroker(() => ({
|
||||
subscribe: async (pattern, handler) => { bound[pattern] = handler; return () => {}; },
|
||||
publish: async () => {}, request: async () => { throw new Error("no responders"); },
|
||||
handle: async () => () => {}, close: async () => {},
|
||||
}));
|
||||
await import(${JSON.stringify(index)});
|
||||
const { collectTools } = await import("@novox/mesh-sdk/tools");
|
||||
const tools = collectTools().flatMap((r) => r.tools.map((t) => t.name));
|
||||
for (let i = 0; i < 100 && !bound["mesh-delivery-forge-bridge.check.status"]; i++) await new Promise((r) => setTimeout(r, 20));
|
||||
const h = bound["mesh-delivery-forge-bridge.check.status"];
|
||||
const env = (source, state) => ({ key: "check.status", node: "n1",
|
||||
headers: { "x-event-id": "e", "x-source": source, "x-node": "n1", "x-time": "", "content-type": "application/json" },
|
||||
body: { owner: "novox", repo: "mesh-sdk", sha: ${JSON.stringify(sha)}, state, description: "by " + source, revision: 1 } });
|
||||
if (h) { await h(env("node-tools", "success")); await h(env("mesh-delivery-forge-bridge", "pending")); }
|
||||
console.log(JSON.stringify({ tools, bound: Object.keys(bound), statuses }));
|
||||
process.exit(0);`;
|
||||
const env = { ...process.env, MESH_GITEA_TOKEN: "x" };
|
||||
for (const k of ["MESH_GITEA_STATE_DIR", "MESH_GITEA_ADMIN_USER", "MESH_GITEA_ADMIN_PASSWORD", "MESH_SERVED_MODULE", "MESH_GITEA_URL"]) delete env[k];
|
||||
const child = spawn(process.execPath, ["--input-type=module", "-e", script], { cwd: join(here, ".."), env, stdio: ["ignore", "pipe", "pipe"] });
|
||||
let out = "";
|
||||
let err = "";
|
||||
child.stdout.on("data", (d) => (out += d));
|
||||
child.stderr.on("data", (d) => (err += d));
|
||||
const ended = new Promise<void>((r) => child.on("exit", () => r()));
|
||||
const timer = setTimeout(() => child.kill("SIGKILL"), 8000);
|
||||
await ended;
|
||||
clearTimeout(timer);
|
||||
const line = out.split("\n").find((l) => l.startsWith("{"));
|
||||
assert.ok(line, `the bundle said nothing; stdout: ${out.slice(0, 400)} stderr: ${err.slice(0, 600)}`);
|
||||
const got = JSON.parse(line!) as { tools: string[]; bound: string[]; statuses: string[] };
|
||||
assert.ok(got.tools.includes("gitea_commit_status"), `the bundle's tools: ${got.tools}`);
|
||||
assert.ok(!got.tools.includes("gitea_check_status"), "no tool writes mesh/check");
|
||||
assert.ok(got.bound.includes("mesh-delivery-forge-bridge.check.status"), `bound: ${got.bound}`);
|
||||
assert.equal(got.statuses.length, 1, `statuses written: ${got.statuses}`);
|
||||
assert.match(got.statuses[0], new RegExp(`/repos/novox/mesh-sdk/statuses/${sha} .*"context":"mesh/check"`));
|
||||
assert.match(got.statuses[0], /"state":"pending".*by mesh-delivery-forge-bridge/);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// **mesh/check is written from the forge bridge's own event, never from a tool** (review finding K1 of R4e; issue 1301 S4;
|
||||
// ADR 0503 §3, ADR 0513 §4). A tool cannot know its caller, and node-tools invokes every tool ("*"), so any session could
|
||||
// have set mesh/check through gitea_check_status. The bus grants the subject mesh.mod.mesh-delivery-forge-bridge.event.
|
||||
// check.status to the bridge's own account alone; this module consumes it and writes the status.
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const sha = "0123456789abcdef0123456789abcdef01234567";
|
||||
const body = (over: Record<string, unknown> = {}) => ({ owner: "novox", repo: "mesh-sdk", sha, state: "success",
|
||||
description: "pass 0123456789ab on aaaaaaaaaaaa", target_url: "https://view.example/checks/g.1",
|
||||
key: `novox/mesh-sdk.12.${sha}.status=mesh~check`, revision: 7, ...over });
|
||||
const event = (b: unknown, source = "mesh-delivery-forge-bridge") => ({ type: "check.status", id: "x", source, node: "n1", at: "", body: b });
|
||||
|
||||
function forge() {
|
||||
const sent: string[] = [];
|
||||
let fail = false;
|
||||
return {
|
||||
sent,
|
||||
failNext: () => { fail = true; },
|
||||
setCommitStatus: async (o: string, r: string, s: string, st: any) => {
|
||||
if (fail) { fail = false; throw new Error("Gitea API /repos/x/statuses: 502 bad gateway"); }
|
||||
sent.push(`${o}/${r}@${s.slice(0, 8)} ${st.context} ${st.state} ${st.description}${st.target_url ? " " + st.target_url : ""}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("the bridge's check.status event is the subject this module consumes, and the bridge is its one source", async () => {
|
||||
const { CHECK_EVENT, CHECK_WRITER } = await import("../check.ts");
|
||||
assert.equal(CHECK_EVENT, "mesh-delivery-forge-bridge.check.status");
|
||||
assert.equal(CHECK_WRITER, "mesh-delivery-forge-bridge");
|
||||
const manifest = JSON.parse(readFileSync(new URL("../module.json", import.meta.url), "utf8"));
|
||||
assert.ok(manifest.consumes.includes(CHECK_EVENT), "gitea's manifest consumes the bridge's check.status");
|
||||
});
|
||||
|
||||
test("a check.status from the bridge writes mesh/check on the head, its state, its line cut and its link", async () => {
|
||||
const { CheckStatuses } = await import("../check.ts");
|
||||
const f = forge();
|
||||
const checks = new CheckStatuses(f, () => {});
|
||||
assert.equal(await checks.apply(event(body())), "written");
|
||||
assert.equal(await checks.apply(event(body({ sha: sha.replace(/0/g, "f"), state: "pending", description: "y".repeat(300), target_url: "javascript:x", revision: 8 }))), "written");
|
||||
assert.equal(f.sent[0], "novox/mesh-sdk@01234567 mesh/check success pass 0123456789ab on aaaaaaaaaaaa https://view.example/checks/g.1");
|
||||
assert.ok(f.sent[1].startsWith("novox/mesh-sdk@f1234567 mesh/check pending yyyy"), f.sent[1]);
|
||||
assert.ok(f.sent[1].length < 200, "the line is cut as every status's is");
|
||||
assert.ok(!f.sent[1].includes("javascript"), "a link that is no web page is not written");
|
||||
});
|
||||
|
||||
test("an event whose source is not the bridge writes nothing", async () => {
|
||||
const { CheckStatuses } = await import("../check.ts");
|
||||
const f = forge();
|
||||
const said: string[] = [];
|
||||
const checks = new CheckStatuses(f, (l) => said.push(l));
|
||||
for (const source of ["node-tools", "gitea", "unknown", ""]) assert.equal(await checks.apply(event(body(), source)), "dropped", source);
|
||||
assert.deepEqual(f.sent, []);
|
||||
assert.match(said[0], /not the forge bridge/);
|
||||
});
|
||||
|
||||
test("a malformed event writes nothing and is not asked for again", async () => {
|
||||
const { CheckStatuses } = await import("../check.ts");
|
||||
const f = forge();
|
||||
const checks = new CheckStatuses(f, () => {});
|
||||
for (const b of [body({ sha: sha.slice(0, 12) }), body({ sha: "../../admin" }), body({ state: "green" }), body({ owner: "../x" }),
|
||||
body({ repo: ".." }), body({ revision: undefined }), body({ revision: -1 }), body({ revision: "7" }), null, "text", body({ context: "mesh/delivery" })]) {
|
||||
assert.equal(await checks.apply(event(b)), "dropped", JSON.stringify(b));
|
||||
}
|
||||
assert.deepEqual(f.sent, []);
|
||||
// The context may be named, and is mesh/check then.
|
||||
assert.equal(await checks.apply(event(body({ context: "mesh/check" }))), "written");
|
||||
});
|
||||
|
||||
test("a revision older than one written on the head is dropped; the same one is written again (a redelivery)", async () => {
|
||||
const { CheckStatuses } = await import("../check.ts");
|
||||
const f = forge();
|
||||
const checks = new CheckStatuses(f, () => {});
|
||||
await checks.apply(event(body({ state: "success", revision: 10 })));
|
||||
assert.equal(await checks.apply(event(body({ state: "pending", revision: 9 }))), "dropped", "a stale pending never hides the verdict");
|
||||
assert.equal(await checks.apply(event(body({ state: "success", revision: 10 }))), "written");
|
||||
// Another head is its own.
|
||||
assert.equal(await checks.apply(event(body({ sha: sha.replace(/1/g, "2"), state: "pending", revision: 3 }))), "written");
|
||||
assert.equal(await checks.apply(event(body({ state: "pending", revision: 11 }))), "written");
|
||||
assert.deepEqual(f.sent.map((s) => s.split(" ").slice(0, 3).join(" ")), ["novox/mesh-sdk@01234567 mesh/check success",
|
||||
"novox/mesh-sdk@01234567 mesh/check success", "novox/mesh-sdk@02234567 mesh/check pending", "novox/mesh-sdk@01234567 mesh/check pending"]);
|
||||
});
|
||||
|
||||
test("a forge that refuses throws, so the bus delivers the event again, and the revision is not taken as written", async () => {
|
||||
const { CheckStatuses } = await import("../check.ts");
|
||||
const f = forge();
|
||||
const checks = new CheckStatuses(f, () => {});
|
||||
f.failNext();
|
||||
await assert.rejects(checks.apply(event(body({ revision: 5 }))), /502/);
|
||||
assert.equal(await checks.apply(event(body({ state: "pending", revision: 4 }))), "written", "nothing of 5 was kept");
|
||||
assert.equal(await checks.apply(event(body({ revision: 5 }))), "written");
|
||||
});
|
||||
|
||||
test("no tool writes mesh/check: gitea_check_status is gone and gitea_commit_status refuses it", async () => {
|
||||
const delivery = await import("../delivery.ts");
|
||||
assert.equal((delivery as Record<string, unknown>).checkStatusTool, undefined, "delivery.ts serves no gitea_check_status");
|
||||
assert.throws(() => delivery.deliveryStatus("mesh/check", "success", "x"), /check\.status/);
|
||||
const index = readFileSync(new URL("../index.ts", import.meta.url), "utf8");
|
||||
assert.ok(!/gitea_check_status|checkStatusTool/.test(index), "index.ts serves no gitea_check_status");
|
||||
});
|
||||
@@ -62,37 +62,20 @@ test("one view per pull request, found by its marker; only the mesh's statuses",
|
||||
assert.equal(deliveryStatus("mesh/delivery", "success", "x", "javascript:alert(1)").target_url, undefined);
|
||||
});
|
||||
|
||||
// Issue 1301-S4 (ADR 0447 §1; the lead's choice A): main requires mesh/check, so no hand may set it. The status tool
|
||||
// every caller may use refuses it in every state; it is written only by gitea_check_status, which the forge bridge
|
||||
// alone lists in its manifest's `invokes`, so only its account may call it.
|
||||
test("mesh/check is refused by the status tool and written by the check's own tool alone", async () => {
|
||||
const { commitStatusTool, checkStatusTool, deliveryStatus, CHECK_STATUS } = await import("../delivery.ts");
|
||||
// Issue 1301-S4 (ADR 0447 §1; R4e finding K1): main requires mesh/check, so no hand may set it. The status tool every
|
||||
// caller may use refuses it in every state; no tool writes it at all: it is written from the forge bridge's check.status
|
||||
// event (check.ts, test/check-event.test.ts).
|
||||
test("mesh/check is refused by the status tool in every state", async () => {
|
||||
const { commitStatusTool, deliveryStatus, CHECK_STATUS } = await import("../delivery.ts");
|
||||
assert.equal(CHECK_STATUS, "mesh/check");
|
||||
const sha = "0123456789abcdef0123456789abcdef01234567";
|
||||
const sent: string[] = [];
|
||||
const setCommitStatus = async (o: string, r: string, s: string, st: any) =>
|
||||
void sent.push(`${o}/${r}@${s.slice(0, 8)} ${st.context} ${st.state} ${st.description}${st.target_url ? " " + st.target_url : ""}`);
|
||||
const setCommitStatus = async (o: string, r: string, s: string, st: any) => void sent.push(`${o}/${r}@${s} ${st.context}`);
|
||||
const gates = { rechecked: async () => { throw new Error("not the gate writer's"); } };
|
||||
for (const state of ["pending", "success", "failure", "error", "warning"]) {
|
||||
assert.throws(() => deliveryStatus("mesh/check", state, "x"), /mesh\/check is the checker's status/);
|
||||
await assert.rejects(commitStatusTool({ setCommitStatus, gates } as any).run({ owner: "novox", repo: "mesh-sdk", sha,
|
||||
context: "mesh/check", state, description: "by hand" }), /gitea_check_status/);
|
||||
context: "mesh/check", state, description: "by hand" }), /check\.status/);
|
||||
}
|
||||
assert.deepEqual(sent, [], "nothing was sent for a hand-set mesh/check");
|
||||
|
||||
const tool = checkStatusTool({ setCommitStatus });
|
||||
assert.equal(tool.name, "gitea_check_status");
|
||||
for (const k of ["owner", "repo", "sha", "state", "description", "target_url", "context"]) assert.ok(k in tool.input, k);
|
||||
// The bridge's arguments as it sends them for gitea_commit_status: the context may be given, and is mesh/check.
|
||||
await tool.run({ owner: "novox", repo: "mesh-sdk", sha, context: "mesh/check", state: "success", description: "pass",
|
||||
target_url: "https://forge.invalid/novox/mesh-sdk/pulls/1" });
|
||||
await tool.run({ owner: "novox", repo: "mesh-sdk", sha, state: "pending", description: "y".repeat(300) });
|
||||
assert.equal(sent[0], "novox/mesh-sdk@01234567 mesh/check success pass https://forge.invalid/novox/mesh-sdk/pulls/1");
|
||||
assert.ok(sent[1].startsWith("novox/mesh-sdk@01234567 mesh/check pending yyyy"), sent[1]);
|
||||
assert.ok(sent[1].length < 200, "the description is cut as every status's is");
|
||||
await assert.rejects(tool.run({ owner: "novox", repo: "mesh-sdk", sha, context: "mesh/delivery", state: "success" }), /mesh\/check alone/);
|
||||
await assert.rejects(tool.run({ owner: "novox", repo: "mesh-sdk", sha: sha.slice(0, 12), state: "success" }), /whole sha/);
|
||||
await assert.rejects(tool.run({ owner: "novox", repo: "mesh-sdk", sha, state: "green" }), /not a status/);
|
||||
await assert.rejects(tool.run({ owner: "../x", repo: "mesh-sdk", sha, state: "success" }));
|
||||
assert.equal(sent.length, 2, "nothing more was sent");
|
||||
});
|
||||
|
||||
@@ -15,7 +15,7 @@ import { GiteaClient } from "../dist/client.js";
|
||||
import { ConfiguredToken } from "../dist/token.js";
|
||||
import { getGiteaTools } from "../dist/tools/index.js";
|
||||
import { mergeTool } from "../dist/merge.js";
|
||||
import { checkStatusTool, commitStatusTool } from "../dist/delivery.js";
|
||||
import { commitStatusTool } from "../dist/delivery.js";
|
||||
import { GateWriter } from "../dist/gate.js";
|
||||
import { judgeApiCall, judgeProtectionChange, ruleCovers, routedPath } from "../dist/guard.js";
|
||||
|
||||
@@ -168,7 +168,7 @@ const gates = { rechecked: (o: string, r: string, sha: string, s: any) => {
|
||||
} };
|
||||
const tools = [...getGiteaTools(client), commitStatusTool({
|
||||
setCommitStatus: (o, r, sha, s) => client.setCommitStatus(o, r, sha, s), gates: writer,
|
||||
}), checkStatusTool({ setCommitStatus: (o, r, sha, s) => client.setCommitStatus(o, r, sha, s) }), mergeTool(client, { gates, stateDir, emit: async (type, body) => { state.emitted.push({ type, body }); },
|
||||
}), mergeTool(client, { gates, stateDir, emit: async (type, body) => { state.emitted.push({ type, body }); },
|
||||
now: () => new Date(2026, 9, 12, 15, 1) })];
|
||||
const tool = (name: string) => tools.find((t) => t.name === name)!;
|
||||
const writes = () => state.sent.map((s) => `${s.method} ${s.path}`);
|
||||
@@ -568,9 +568,6 @@ test("no tool's caller text becomes another path or another user: `..` and `?sud
|
||||
["gitea_get_file", { ...repo, path: "README.md?sudo=jschoubben" }],
|
||||
["gitea_commit_status", { ...repo, sha: "abc1234?sudo=jschoubben", context: "mesh/delivery", state: "success" }],
|
||||
["gitea_commit_status", { ...repo, sha: "../../../admin/users", context: "mesh/delivery", state: "success" }],
|
||||
["gitea_check_status", { ...repo, sha: "abc1234?sudo=jschoubben", state: "success" }],
|
||||
["gitea_check_status", { ...repo, sha: "../../../admin/users", state: "success" }],
|
||||
["gitea_check_status", { owner: "novox", repo: "..", sha: "0123456789abcdef0123456789abcdef01234567", state: "success" }],
|
||||
["gitea_commit_status", { ...repo, sha: "0123456789abcdef0123456789abcdef01234567", context: "mesh/check", state: "success" }],
|
||||
["gitea_commit_statuses", { ...repo, sha: ".." }],
|
||||
["gitea_commit_statuses", { ...repo, sha: "main?sudo=jschoubben" }],
|
||||
|
||||
@@ -92,7 +92,7 @@ func (m *Module) build(ctx context.Context, args map[string]any) (any, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("no module in %q of workspace %s: %w", module, workspace, err)
|
||||
}
|
||||
manifest, err := catalogue.ParseManifestForInputs(raw)
|
||||
manifest, err := catalogue.ParseManifest(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the manifest in %q: %w", module, err)
|
||||
}
|
||||
|
||||
@@ -237,21 +237,3 @@ func TestABuildOutputDirectoryLinkedOutsideTheModuleIsRefused(t *testing.T) {
|
||||
t.Errorf("a file outside the module was touched: %q, %v", body, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1751 (ADR 0514 §2, the review's K2): a manifest whose config value falls back to a predefined key builds. The
|
||||
// builder holds no published list of the keys, so it reads the manifest for its inputs (ParseManifestForInputs), not
|
||||
// as a judge of config values; claude-code's real manifest (capturedClaudeCodeManifest) is the fixture.
|
||||
func TestAManifestFallingBackToAPredefinedKeyBuilds(t *testing.T) {
|
||||
raw := []byte(capturedClaudeCodeManifest)
|
||||
if !strings.Contains(string(raw), `"predefined": "agent-managed-settings"`) {
|
||||
t.Fatal("the captured manifest no longer falls back to a predefined key")
|
||||
}
|
||||
m, states, _ := buildModule(t, string(raw), "binary")
|
||||
res, st := runBuild(t, m, states, "tools")
|
||||
if st.State != delivery.StepPassed {
|
||||
t.Fatalf("the step ended %s: %s", st.State, st.Reason)
|
||||
}
|
||||
if res.Artifact != "tools" || res.Binary != "claude-code" || !strings.HasPrefix(res.Digest, "sha256:") {
|
||||
t.Errorf("result = %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,257 +0,0 @@
|
||||
package main
|
||||
|
||||
// capturedClaudeCodeManifest is modules/claude-code/module.json of this catalogue at 56c68be7, copied verbatim on
|
||||
// 2026-10-22 for issue 1751 (the R4e review's finding K2): a real manifest whose config value managed_settings falls
|
||||
// back to the predefined key agent-managed-settings (ADR 0348 §7), which the builder holds no published list for. It
|
||||
// lives in a test file, not under testdata, because the catalogue's paths check (ADR 0260) reads every other file of a
|
||||
// module and the manifest names claude-code's own paths.
|
||||
const capturedClaudeCodeManifest = `{
|
||||
"module": "claude-code",
|
||||
"version": "1",
|
||||
"moved": true,
|
||||
"slug": "agent",
|
||||
"capabilities": [
|
||||
"package-manager"
|
||||
],
|
||||
"requires": [
|
||||
"mcp-endpoint",
|
||||
{
|
||||
"name": "gitea-read",
|
||||
"optional": true
|
||||
}
|
||||
],
|
||||
"binds": {
|
||||
"mcp-endpoint": "${dir:state}/mcp-endpoint.json"
|
||||
},
|
||||
"secrets": {
|
||||
"gitea-read": "${dir:state}/gitea-read.secret"
|
||||
},
|
||||
"uses": [
|
||||
"operator-channel"
|
||||
],
|
||||
"state": [
|
||||
"servers",
|
||||
"holdings",
|
||||
"config",
|
||||
"proposals",
|
||||
"questions",
|
||||
"memory",
|
||||
"waits"
|
||||
],
|
||||
"reads": [
|
||||
"claude-licence-manager.bindings"
|
||||
],
|
||||
"tools": [
|
||||
"claude_code_status",
|
||||
"claude_code_render",
|
||||
"claude_code_guard",
|
||||
"claude_code_pull",
|
||||
"claude_code_grant",
|
||||
"claude_code_add_api_key",
|
||||
"claude_code_registrations",
|
||||
"claude_code_memory",
|
||||
"claude_code_mcp_list",
|
||||
"claude_code_mcp_register",
|
||||
"claude_code_mcp_unregister",
|
||||
"claude_code_skill_list",
|
||||
"claude_code_skill_register",
|
||||
"claude_code_skill_unregister",
|
||||
"claude_code_agent_list",
|
||||
"claude_code_agent_register",
|
||||
"claude_code_agent_unregister",
|
||||
"claude_code_command_list",
|
||||
"claude_code_command_register",
|
||||
"claude_code_command_unregister",
|
||||
"claude_code_hook_list",
|
||||
"claude_code_hook_register",
|
||||
"claude_code_hook_unregister",
|
||||
"claude_code_output_style_list",
|
||||
"claude_code_output_style_register",
|
||||
"claude_code_output_style_unregister",
|
||||
"claude_code_instructions_list",
|
||||
"claude_code_instructions_register",
|
||||
"claude_code_instructions_unregister",
|
||||
"claude_code_instructions_propose",
|
||||
"claude_code_proposals",
|
||||
"claude_code_ask",
|
||||
"claude_code_answer",
|
||||
"claude_code_wait",
|
||||
"claude_code_settings_get",
|
||||
"claude_code_settings_set",
|
||||
"claude_code_settings_clear",
|
||||
"claude_code_permission_add",
|
||||
"claude_code_permission_remove",
|
||||
"claude_code_config_list",
|
||||
"claude_code_config_show",
|
||||
"claude_code_config_status",
|
||||
"claude_code_config_import",
|
||||
"claude_code_home_show",
|
||||
"claude_code_home_remove",
|
||||
"claude_code_home_removed",
|
||||
"claude_code_home_restore",
|
||||
"claude_code_judge",
|
||||
"claude_code_judged"
|
||||
],
|
||||
"data": {
|
||||
"own": [
|
||||
{
|
||||
"id": "agent-home",
|
||||
"path": "${dir:agent-home}",
|
||||
"class": "valuable",
|
||||
"why": "the operator's agent's own files: its memory, history and projects"
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {
|
||||
"role": {
|
||||
"type": "string",
|
||||
"description": "What this machine is for, in a sentence, written into the instructions every Claude Code session on it reads; empty says nothing.",
|
||||
"trusted": true,
|
||||
"default": ""
|
||||
},
|
||||
"mcp_servers": {
|
||||
"type": "object",
|
||||
"description": "The tool servers every session on this machine is given beside the mesh's own, by name, each as Claude Code's managed MCP configuration takes it; empty adds none.",
|
||||
"trusted": true,
|
||||
"default": {}
|
||||
},
|
||||
"managed_settings": {
|
||||
"type": "object",
|
||||
"description": "Keys of Claude Code's managed settings every session on this machine obeys, such as spinnerTipsEnabled, laid over the mesh's own; empty adds none. Falls back to agent-managed-settings, the mesh's or this node's.",
|
||||
"trusted": true,
|
||||
"predefined": "agent-managed-settings",
|
||||
"default": {}
|
||||
},
|
||||
"instructions": {
|
||||
"type": "object",
|
||||
"description": "The instructions every session on this machine reads, by name, each a text as one line or a list of its lines; empty adds none.",
|
||||
"trusted": true,
|
||||
"default": {}
|
||||
},
|
||||
"output_styles": {
|
||||
"type": "object",
|
||||
"description": "The output styles offered to every session on this machine, by name, each a text as one line or a list of its lines; empty adds none.",
|
||||
"trusted": true,
|
||||
"default": {}
|
||||
},
|
||||
"skills": {
|
||||
"type": "object",
|
||||
"description": "The skills offered to every session on this machine, by name, each its files by path, a text as one line or a list of its lines; a skill without its SKILL.md is left out; empty adds none.",
|
||||
"trusted": true,
|
||||
"default": {}
|
||||
},
|
||||
"commands": {
|
||||
"type": "object",
|
||||
"description": "The slash commands offered to every session on this machine, by name, each a text as one line or a list of its lines; empty adds none.",
|
||||
"trusted": true,
|
||||
"default": {}
|
||||
},
|
||||
"agents": {
|
||||
"type": "object",
|
||||
"description": "The agents offered to every session on this machine, by name, each a text as one line or a list of its lines; empty adds none.",
|
||||
"trusted": true,
|
||||
"default": {}
|
||||
}
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"id": "package",
|
||||
"type": "package",
|
||||
"package": "claude-code"
|
||||
},
|
||||
{
|
||||
"id": "managed",
|
||||
"type": "directory",
|
||||
"path": "/etc/claude-code",
|
||||
"mode": "0755",
|
||||
"unknown": "program"
|
||||
},
|
||||
{
|
||||
"id": "start",
|
||||
"type": "file",
|
||||
"path": "/usr/local/bin/claude-agent",
|
||||
"mode": "0755",
|
||||
"template": "files/claude-agent"
|
||||
},
|
||||
{
|
||||
"id": "agent-account-name",
|
||||
"type": "file",
|
||||
"path": "/etc/claude-code/agent-account",
|
||||
"mode": "0644",
|
||||
"template": "files/agent-account"
|
||||
},
|
||||
{
|
||||
"id": "agent-account",
|
||||
"type": "user",
|
||||
"name": "${machine:agent-account}",
|
||||
"home": "${machine:agent-home}",
|
||||
"root": "${machine:agent-root}"
|
||||
},
|
||||
{
|
||||
"id": "agent-home",
|
||||
"type": "directory",
|
||||
"path": "${machine:agent-home}/.claude",
|
||||
"mode": "0700",
|
||||
"owner": "${machine:agent-account}",
|
||||
"unknown": "module"
|
||||
},
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"owner": "${machine:account}",
|
||||
"place": ".",
|
||||
"unknown": "program"
|
||||
},
|
||||
{
|
||||
"id": "facts",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/facts.json",
|
||||
"mode": "0600",
|
||||
"owner": "${machine:account}",
|
||||
"template": "files/facts.json"
|
||||
},
|
||||
{
|
||||
"id": "settings",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/settings.json",
|
||||
"mode": "0600",
|
||||
"owner": "${machine:account}",
|
||||
"takes-config": ["role", "mcp_servers", "managed_settings", "instructions", "output_styles", "skills", "commands", "agents"],
|
||||
"merge": "json",
|
||||
"template": "files/settings.json"
|
||||
}
|
||||
],
|
||||
"items": [
|
||||
{
|
||||
"collection": "shell:zsh:normal",
|
||||
"template": "files/zsh-normal"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "tools",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/claude-code",
|
||||
"binary": "claude-code",
|
||||
"loads": [
|
||||
"claude-code"
|
||||
],
|
||||
"env": {
|
||||
"MESH_CLAUDE_CODE_STATE": "${dir:state}",
|
||||
"MESH_CLAUDE_CODE_FACTS": "${dir:state}/facts.json",
|
||||
"MESH_CLAUDE_CODE_SETTINGS": "${dir:state}/settings.json"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "files",
|
||||
"kind": "archive",
|
||||
"from": "files"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
`
|
||||
@@ -126,7 +126,7 @@ func (m *Module) build(ctx context.Context, args map[string]any) (any, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("no module in %q of workspace %s: %w", module, workspace, err)
|
||||
}
|
||||
manifest, err := catalogue.ParseManifestForInputs(raw)
|
||||
manifest, err := catalogue.ParseManifest(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the manifest in %q: %w", module, err)
|
||||
}
|
||||
|
||||
@@ -198,53 +198,3 @@ func TestBuildFailsForWhatIsNotAnImageOrNotThere(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1751 (ADR 0514 §2, the review's K2): a manifest whose config value falls back to a predefined key builds. The
|
||||
// builder holds no published list of the keys, so it reads the manifest for its inputs (ParseManifestForInputs), not
|
||||
// as a judge of config values. The manifest is claude-code's real one (capturedClaudeCodeManifest) with the image fixture's
|
||||
// base and artifact added to its build, since no image module of the catalogue falls back to a predefined key today.
|
||||
func TestAManifestFallingBackToAPredefinedKeyBuilds(t *testing.T) {
|
||||
raw := []byte(capturedClaudeCodeManifest)
|
||||
if !strings.Contains(string(raw), `"predefined": "agent-managed-settings"`) {
|
||||
t.Fatal("the captured manifest no longer falls back to a predefined key")
|
||||
}
|
||||
m, _, states := setup(t, func(fixture string) string {
|
||||
var real, image map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &real); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal([]byte(fixture), &image); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The image fixture's base and artifact join claude-code's own artifacts (its archive of files/ stays).
|
||||
var realBuild, imageBuild struct {
|
||||
On json.RawMessage `json:"on,omitempty"`
|
||||
Artifacts []json.RawMessage `json:"artifacts"`
|
||||
}
|
||||
if err := json.Unmarshal(real["build"], &realBuild); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal(image["build"], &imageBuild); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
realBuild.On = imageBuild.On
|
||||
realBuild.Artifacts = append(realBuild.Artifacts, imageBuild.Artifacts...)
|
||||
b, err := json.Marshal(realBuild)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
real["build"] = b
|
||||
out, err := json.Marshal(real)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(out)
|
||||
})
|
||||
res, st := runBuild(t, m, states, nil)
|
||||
if st.State != delivery.StepPassed {
|
||||
t.Fatalf("step = %+v", st)
|
||||
}
|
||||
if res.Digest != imageDigest || res.Artifact != "server" {
|
||||
t.Errorf("result = %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,257 +0,0 @@
|
||||
package main
|
||||
|
||||
// capturedClaudeCodeManifest is modules/claude-code/module.json of this catalogue at 56c68be7, copied verbatim on
|
||||
// 2026-10-22 for issue 1751 (the R4e review's finding K2): a real manifest whose config value managed_settings falls
|
||||
// back to the predefined key agent-managed-settings (ADR 0348 §7), which the builder holds no published list for. It
|
||||
// lives in a test file, not under testdata, because the catalogue's paths check (ADR 0260) reads every other file of a
|
||||
// module and the manifest names claude-code's own paths.
|
||||
const capturedClaudeCodeManifest = `{
|
||||
"module": "claude-code",
|
||||
"version": "1",
|
||||
"moved": true,
|
||||
"slug": "agent",
|
||||
"capabilities": [
|
||||
"package-manager"
|
||||
],
|
||||
"requires": [
|
||||
"mcp-endpoint",
|
||||
{
|
||||
"name": "gitea-read",
|
||||
"optional": true
|
||||
}
|
||||
],
|
||||
"binds": {
|
||||
"mcp-endpoint": "${dir:state}/mcp-endpoint.json"
|
||||
},
|
||||
"secrets": {
|
||||
"gitea-read": "${dir:state}/gitea-read.secret"
|
||||
},
|
||||
"uses": [
|
||||
"operator-channel"
|
||||
],
|
||||
"state": [
|
||||
"servers",
|
||||
"holdings",
|
||||
"config",
|
||||
"proposals",
|
||||
"questions",
|
||||
"memory",
|
||||
"waits"
|
||||
],
|
||||
"reads": [
|
||||
"claude-licence-manager.bindings"
|
||||
],
|
||||
"tools": [
|
||||
"claude_code_status",
|
||||
"claude_code_render",
|
||||
"claude_code_guard",
|
||||
"claude_code_pull",
|
||||
"claude_code_grant",
|
||||
"claude_code_add_api_key",
|
||||
"claude_code_registrations",
|
||||
"claude_code_memory",
|
||||
"claude_code_mcp_list",
|
||||
"claude_code_mcp_register",
|
||||
"claude_code_mcp_unregister",
|
||||
"claude_code_skill_list",
|
||||
"claude_code_skill_register",
|
||||
"claude_code_skill_unregister",
|
||||
"claude_code_agent_list",
|
||||
"claude_code_agent_register",
|
||||
"claude_code_agent_unregister",
|
||||
"claude_code_command_list",
|
||||
"claude_code_command_register",
|
||||
"claude_code_command_unregister",
|
||||
"claude_code_hook_list",
|
||||
"claude_code_hook_register",
|
||||
"claude_code_hook_unregister",
|
||||
"claude_code_output_style_list",
|
||||
"claude_code_output_style_register",
|
||||
"claude_code_output_style_unregister",
|
||||
"claude_code_instructions_list",
|
||||
"claude_code_instructions_register",
|
||||
"claude_code_instructions_unregister",
|
||||
"claude_code_instructions_propose",
|
||||
"claude_code_proposals",
|
||||
"claude_code_ask",
|
||||
"claude_code_answer",
|
||||
"claude_code_wait",
|
||||
"claude_code_settings_get",
|
||||
"claude_code_settings_set",
|
||||
"claude_code_settings_clear",
|
||||
"claude_code_permission_add",
|
||||
"claude_code_permission_remove",
|
||||
"claude_code_config_list",
|
||||
"claude_code_config_show",
|
||||
"claude_code_config_status",
|
||||
"claude_code_config_import",
|
||||
"claude_code_home_show",
|
||||
"claude_code_home_remove",
|
||||
"claude_code_home_removed",
|
||||
"claude_code_home_restore",
|
||||
"claude_code_judge",
|
||||
"claude_code_judged"
|
||||
],
|
||||
"data": {
|
||||
"own": [
|
||||
{
|
||||
"id": "agent-home",
|
||||
"path": "${dir:agent-home}",
|
||||
"class": "valuable",
|
||||
"why": "the operator's agent's own files: its memory, history and projects"
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {
|
||||
"role": {
|
||||
"type": "string",
|
||||
"description": "What this machine is for, in a sentence, written into the instructions every Claude Code session on it reads; empty says nothing.",
|
||||
"trusted": true,
|
||||
"default": ""
|
||||
},
|
||||
"mcp_servers": {
|
||||
"type": "object",
|
||||
"description": "The tool servers every session on this machine is given beside the mesh's own, by name, each as Claude Code's managed MCP configuration takes it; empty adds none.",
|
||||
"trusted": true,
|
||||
"default": {}
|
||||
},
|
||||
"managed_settings": {
|
||||
"type": "object",
|
||||
"description": "Keys of Claude Code's managed settings every session on this machine obeys, such as spinnerTipsEnabled, laid over the mesh's own; empty adds none. Falls back to agent-managed-settings, the mesh's or this node's.",
|
||||
"trusted": true,
|
||||
"predefined": "agent-managed-settings",
|
||||
"default": {}
|
||||
},
|
||||
"instructions": {
|
||||
"type": "object",
|
||||
"description": "The instructions every session on this machine reads, by name, each a text as one line or a list of its lines; empty adds none.",
|
||||
"trusted": true,
|
||||
"default": {}
|
||||
},
|
||||
"output_styles": {
|
||||
"type": "object",
|
||||
"description": "The output styles offered to every session on this machine, by name, each a text as one line or a list of its lines; empty adds none.",
|
||||
"trusted": true,
|
||||
"default": {}
|
||||
},
|
||||
"skills": {
|
||||
"type": "object",
|
||||
"description": "The skills offered to every session on this machine, by name, each its files by path, a text as one line or a list of its lines; a skill without its SKILL.md is left out; empty adds none.",
|
||||
"trusted": true,
|
||||
"default": {}
|
||||
},
|
||||
"commands": {
|
||||
"type": "object",
|
||||
"description": "The slash commands offered to every session on this machine, by name, each a text as one line or a list of its lines; empty adds none.",
|
||||
"trusted": true,
|
||||
"default": {}
|
||||
},
|
||||
"agents": {
|
||||
"type": "object",
|
||||
"description": "The agents offered to every session on this machine, by name, each a text as one line or a list of its lines; empty adds none.",
|
||||
"trusted": true,
|
||||
"default": {}
|
||||
}
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"id": "package",
|
||||
"type": "package",
|
||||
"package": "claude-code"
|
||||
},
|
||||
{
|
||||
"id": "managed",
|
||||
"type": "directory",
|
||||
"path": "/etc/claude-code",
|
||||
"mode": "0755",
|
||||
"unknown": "program"
|
||||
},
|
||||
{
|
||||
"id": "start",
|
||||
"type": "file",
|
||||
"path": "/usr/local/bin/claude-agent",
|
||||
"mode": "0755",
|
||||
"template": "files/claude-agent"
|
||||
},
|
||||
{
|
||||
"id": "agent-account-name",
|
||||
"type": "file",
|
||||
"path": "/etc/claude-code/agent-account",
|
||||
"mode": "0644",
|
||||
"template": "files/agent-account"
|
||||
},
|
||||
{
|
||||
"id": "agent-account",
|
||||
"type": "user",
|
||||
"name": "${machine:agent-account}",
|
||||
"home": "${machine:agent-home}",
|
||||
"root": "${machine:agent-root}"
|
||||
},
|
||||
{
|
||||
"id": "agent-home",
|
||||
"type": "directory",
|
||||
"path": "${machine:agent-home}/.claude",
|
||||
"mode": "0700",
|
||||
"owner": "${machine:agent-account}",
|
||||
"unknown": "module"
|
||||
},
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"owner": "${machine:account}",
|
||||
"place": ".",
|
||||
"unknown": "program"
|
||||
},
|
||||
{
|
||||
"id": "facts",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/facts.json",
|
||||
"mode": "0600",
|
||||
"owner": "${machine:account}",
|
||||
"template": "files/facts.json"
|
||||
},
|
||||
{
|
||||
"id": "settings",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/settings.json",
|
||||
"mode": "0600",
|
||||
"owner": "${machine:account}",
|
||||
"takes-config": ["role", "mcp_servers", "managed_settings", "instructions", "output_styles", "skills", "commands", "agents"],
|
||||
"merge": "json",
|
||||
"template": "files/settings.json"
|
||||
}
|
||||
],
|
||||
"items": [
|
||||
{
|
||||
"collection": "shell:zsh:normal",
|
||||
"template": "files/zsh-normal"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "tools",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/claude-code",
|
||||
"binary": "claude-code",
|
||||
"loads": [
|
||||
"claude-code"
|
||||
],
|
||||
"env": {
|
||||
"MESH_CLAUDE_CODE_STATE": "${dir:state}",
|
||||
"MESH_CLAUDE_CODE_FACTS": "${dir:state}/facts.json",
|
||||
"MESH_CLAUDE_CODE_SETTINGS": "${dir:state}/settings.json"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "files",
|
||||
"kind": "archive",
|
||||
"from": "files"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
`
|
||||
Reference in New Issue
Block a user