From 3d0a72c58e5070e0729f32ea094ec51a5e39c4f7 Mon Sep 17 00:00:00 2001 From: Param Harrison Date: Sat, 31 Oct 2026 00:09:26 +0200 Subject: [PATCH] Pin the factory config at run start so a build cannot change the gate commands Co-Authored-By: Claude Sonnet 5.5 --- factory/engine/cli.ts | 2 ++ factory/engine/models.test.ts | 18 +++++++++++++++++- factory/engine/portable.ts | 7 +++++++ 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/factory/engine/cli.ts b/factory/engine/cli.ts index 051ba72..4d7a1e5 100644 --- a/factory/engine/cli.ts +++ b/factory/engine/cli.ts @@ -4,6 +4,7 @@ import { join } from "node:path"; import { loadConfig } from "../config.ts"; import { runWorkflow } from "./interpret.ts"; import { type Metrics, metricsFor, metricsSchema, rollup } from "./metrics.ts"; +import { pinPortable } from "./portable.ts"; import { realHost } from "./real-host.ts"; import { loadWorkflow } from "./workflow.ts"; @@ -12,6 +13,7 @@ const gh = (...a: string[]) => execFileSync("gh", a, { encoding: "utf8" }); // Runs one issue through a named workflow on the current checkout and saves metrics.json. export async function runIssue(n: number, name: string) { + pinPortable(); const wf = loadWorkflow(readFileSync(`.factory/workflows/${name}.yaml`, "utf8")); const issue = JSON.parse(gh("issue", "view", String(n), "--json", "title,body")); const base = execFileSync("git", ["merge-base", "HEAD", "origin/main"], { diff --git a/factory/engine/models.test.ts b/factory/engine/models.test.ts index 16debfc..82b146f 100644 --- a/factory/engine/models.test.ts +++ b/factory/engine/models.test.ts @@ -1,10 +1,13 @@ +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { loadConfig } from "../config.ts"; import { badPlanLines } from "./artifacts.ts"; import { fakeHost } from "./fake-host.ts"; import { runWorkflow } from "./interpret.ts"; import { resolveModel } from "./models.ts"; -import { globToRegExp, loadPortable } from "./portable.ts"; +import { globToRegExp, loadPortable, pinPortable, portable } from "./portable.ts"; import { loadWorkflow } from "./workflow.ts"; const cfg = loadConfig(); @@ -84,3 +87,16 @@ describe("portable config", () => { expect(globToRegExp("a/*.ts").test("a/b/c.ts")).toBe(false); }); }); + +describe("pinned portable config", () => { + it("keeps the commands it read at the start when the file changes later", () => { + const dir = mkdtempSync(join(tmpdir(), "pin-")); + const file = join(dir, "config.yaml"); + const original = readFileSync(".factory/config.yaml", "utf8"); + writeFileSync(file, original); + const pinned = pinPortable(file); + writeFileSync(file, original.replace(/gate:.*/, "gate: [true]")); + expect(portable().gate).toEqual(pinned.gate); + pinPortable(); + }); +}); diff --git a/factory/engine/portable.ts b/factory/engine/portable.ts index fb961de..d062b99 100644 --- a/factory/engine/portable.ts +++ b/factory/engine/portable.ts @@ -32,6 +32,13 @@ export const portable = (): Portable => { return cached; }; +// Called before the first agent runs, so a build that edits the file cannot change the commands +// the gate runs: the working tree is never read for this again. +export const pinPortable = (path?: string): Portable => { + cached = loadPortable(path); + return cached; +}; + // ** crosses folders, * stays inside one. export function globToRegExp(glob: string): RegExp { const body = glob