From 2e32f47e4596bb0d4741ddfe412bfff8b09e5232 Mon Sep 17 00:00:00 2001 From: mesh-tools Date: Thu, 15 Oct 2026 00:18:39 +0000 Subject: [PATCH] Read branch protection as the forge's administrator, so builds register again The forge answers a repository's protection rules to its administrator alone, and the tools' account no longer is one (ADR 0365), so every delivery building a module failed at the controller's build-source check with a 403. gitea_branch_protection_get now reads the rules with the administrator account (ADR 0375); it only reads, and the protection set and every write stay refused to the tools' account. Issue 772. Co-Authored-By: Claude --- modules/gitea/client.ts | 9 ++++ modules/gitea/test/protection-read.test.ts | 59 ++++++++++++++++++++++ modules/gitea/tools/index.ts | 17 ++++--- 3 files changed, 78 insertions(+), 7 deletions(-) create mode 100644 modules/gitea/test/protection-read.test.ts diff --git a/modules/gitea/client.ts b/modules/gitea/client.ts index 00524c4c..0782397a 100644 --- a/modules/gitea/client.ts +++ b/modules/gitea/client.ts @@ -958,6 +958,15 @@ export class GiteaAdmin { throw new Error(`Gitea admin ${path}: ${res.status} ${detail}`); } + /** A repository's branch protection rules, read with the administrator account — the forge answers them to a + * repository's administrator alone, which the tools' account is not (novox/hq ADR 0365 §2, ADR 0375). Only a read. */ + async branchProtections(owner: string, repo: string): Promise { + const path = `${repoPath(owner, repo)}/branch_protections`; + const res = await this.request(path); + if (res.status !== 200) GiteaAdmin.fail(path, res); + return Array.isArray(res.body) ? res.body : []; + } + /** Ensure the npm-owner org exists. 201 created, 2xx/404-then-created, and 422/409 (a concurrent * create won the race) are all success. */ async ensureOrg(name: string): Promise { diff --git a/modules/gitea/test/protection-read.test.ts b/modules/gitea/test/protection-read.test.ts new file mode 100644 index 00000000..d2928ab9 --- /dev/null +++ b/modules/gitea/test/protection-read.test.ts @@ -0,0 +1,59 @@ +// The controller's build-source check asks gitea_branch_protection_get for a repository's rules, which the forge +// answers to a repository's administrator alone. The tools' account is not one (novox/hq ADR 0365 §2), so the read +// goes with the forge's administrator account, and only the read (ADR 0375, issue 772): a protection edit through the +// tools still gets the forge's refusal. + +import { test, after } from "node:test"; +import assert from "node:assert/strict"; +import { createServer } from "node:http"; + +import { GiteaAdmin, GiteaClient } from "../dist/client.js"; +import { ConfiguredToken } from "../dist/token.js"; +import { getGiteaTools } from "../dist/tools/index.js"; + +const adminBasic = "Basic " + Buffer.from("mesh-admin:secret").toString("base64"); +const rules = [ + { rule_name: "main", enable_push: false, enable_status_check: true, status_check_contexts: ["mesh/merge-gate"], block_admin_merge_override: true }, + { branch_name: "release", enable_push: false }, +]; +const seen: string[] = []; + +const server = createServer((req, res) => { + const path = (req.url ?? "").replace(/^\/api\/v1/, ""); + const as = req.headers.authorization === adminBasic ? "admin" : "tools"; + seen.push(`${req.method} ${path} as ${as}`); + const send = (status: number, body: unknown) => { res.writeHead(status, { "Content-Type": "application/json" }); res.end(JSON.stringify(body)); }; + if (path.includes("/branch_protections")) { + if (as !== "admin") return send(403, { message: "user should be an owner or a collaborator with admin write of a repository" }); + if (req.method === "GET") return send(200, rules); + } + send(404, { message: "not found" }); +}); +await new Promise((r) => server.listen(0, "127.0.0.1", () => r())); +after(() => new Promise((r) => server.close(() => r()))); +const { port } = server.address() as { port: number }; +const url = `http://127.0.0.1:${port}`; +const tools = getGiteaTools(new GiteaClient(url, new ConfiguredToken("tools-token")), () => new GiteaAdmin(url, "mesh-admin", "secret")); +const tool = (name: string) => tools.find((t) => t.name === name)!; + +test("gitea_branch_protection_get answers every rule, read with the administrator account the tools' token cannot read with", async () => { + const out: any = await tool("gitea_branch_protection_get").run({ owner: "novox", repo: "mesh-controller" }); + assert.deepEqual(out.rules.map((r: any) => r.rule), ["main", "release"]); +}); + +test("gitea_branch_protection_get answers the rule for a branch, null when it has none", async () => { + const main: any = await tool("gitea_branch_protection_get").run({ owner: "novox", repo: "mesh-controller", branch: "main" }); + assert.equal(main.branch, "main"); + assert.equal(main.rule.rule, "main"); + assert.equal(main.rule.admin_may_override, false); + const none: any = await tool("gitea_branch_protection_get").run({ owner: "novox", repo: "mesh-controller", branch: "dev" }); + assert.equal(none.rule, null); +}); + +test("gitea_branch_protection_set still gets the forge's refusal, as the tools' account", async () => { + await assert.rejects( + tool("gitea_branch_protection_set").run({ owner: "novox", repo: "mesh-controller", branch: "main", status_checks: "mesh/merge-gate" }), + /403/, + ); + assert.ok(!seen.some((s) => /^(PATCH|POST|PUT|DELETE) .*branch_protections.* as admin$/.test(s)), seen.join("\n")); +}); diff --git a/modules/gitea/tools/index.ts b/modules/gitea/tools/index.ts index 70d4d445..27a8c610 100644 --- a/modules/gitea/tools/index.ts +++ b/modules/gitea/tools/index.ts @@ -10,7 +10,7 @@ import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; import { emit } from "@novox/mesh-sdk/events"; -import { GiteaClient, readyAuthors, type BranchProtection } from "../client.js"; +import { GiteaAdmin, GiteaClient, readyAuthors, type BranchProtection } from "../client.js"; import { appendNote, run, viewBody, viewComment } from "../delivery.js"; import { containedFrom, isCommit, mergeableUnder, newestByContext } from "../checks.js"; import { @@ -59,13 +59,13 @@ function namesChecked(tools: ToolDefinition[]): ToolDefinition[] { })); } -export function getGiteaTools(gitea: GiteaClient): ToolDefinition[] { - return namesChecked(allGiteaTools(gitea)); +export function getGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefinition[] { + return namesChecked(allGiteaTools(gitea, admin)); } // gitea_merge_pull_request is served from the watcher's bundle (merge.ts, index.ts), beside the merge check's one // writer (novox/hq issue 456, ADR 0323). -function allGiteaTools(gitea: GiteaClient): ToolDefinition[] { +function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefinition[] { return [ // ---- Repositories ---- { @@ -401,8 +401,11 @@ function allGiteaTools(gitea: GiteaClient): ToolDefinition[] { }, run: async (args) => { const owner = String(args.owner), repo = String(args.repo); - if (!args.branch) return { rules: (await gitea.branchProtections(owner, repo)).map(summarised) }; - const rule = await gitea.branchProtection(owner, repo, String(args.branch)); + // The one read made as the forge's administrator (novox/hq ADR 0375, issue 772): the rules' list is the + // repository administrator's, which the tools' account is not. No other tool uses this account. + const rules = await (admin ? admin() : gitea).branchProtections(owner, repo); + if (!args.branch) return { rules: rules.map(summarised) }; + const rule = rules.find((p) => (p.rule_name ?? p.branch_name) === String(args.branch)) ?? null; return { branch: String(args.branch), rule: rule ? summarised(rule) : null }; }, }, @@ -615,7 +618,7 @@ function allGiteaTools(gitea: GiteaClient): ToolDefinition[] { // way, gitea contributes none rather than failing the whole runtime, and says why. registerModuleTools("gitea", (env) => { try { - return getGiteaTools(GiteaClient.fromEnv(env)); + return getGiteaTools(GiteaClient.fromEnv(env), () => GiteaAdmin.fromEnv(env)); } catch (err) { console.log(`[gitea] no tools — ${err instanceof Error ? err.message : String(err)}`); return [];