fix: require gate receipt for factory deploy
This commit is contained in:
parent
4215ac94ae
commit
933f3c8974
|
|
@ -172,6 +172,7 @@ fable-agent plinius godmode "improve explanation quality"
|
|||
- `--file <path>`
|
||||
- `factory deploy <command>`
|
||||
- `--token <token>`
|
||||
- `--gate-receipt <path>`
|
||||
- `--url <url>`
|
||||
- `--out <path>`
|
||||
- `--yes`
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import * as fs from "node:fs";
|
|||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { deployToFactory, isAllowedFactoryCommand } from "./factory-deploy.js";
|
||||
import { deployToFactory, hasPassedGateReceipt, isAllowedFactoryCommand } from "./factory-deploy.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
|
||||
|
|
@ -10,23 +10,37 @@ afterEach(() => {
|
|||
for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function tmpFile(): string {
|
||||
function tmpFile(name = "receipt.json"): string {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "fable-factory-deploy-"));
|
||||
roots.push(root);
|
||||
return path.join(root, "receipt.json");
|
||||
return path.join(root, name);
|
||||
}
|
||||
|
||||
function passedGateReceipt(): string {
|
||||
const file = tmpFile("zte-receipts.jsonl");
|
||||
fs.writeFileSync(file, `${JSON.stringify({ status: "passed", task: "gate" })}\n`);
|
||||
return file;
|
||||
}
|
||||
|
||||
describe("factory deploy", () => {
|
||||
it("blocks without explicit approval", async () => {
|
||||
const receipt = await deployToFactory({ command: "echo ok", token: "token" });
|
||||
const receipt = await deployToFactory({ command: "echo ok", token: "token", gateReceipt: passedGateReceipt() });
|
||||
|
||||
expect(receipt.status).toBe("blocked");
|
||||
expect(receipt.deploy_attempted).toBe(false);
|
||||
expect(receipt.reason).toContain("--yes");
|
||||
});
|
||||
|
||||
it("blocks without a passed gate receipt", async () => {
|
||||
const receipt = await deployToFactory({ command: "echo ok", token: "token", yes: true });
|
||||
|
||||
expect(receipt.status).toBe("blocked");
|
||||
expect(receipt.deploy_attempted).toBe(false);
|
||||
expect(receipt.reason).toContain("gate-receipt");
|
||||
});
|
||||
|
||||
it("blocks unsafe command prefixes", async () => {
|
||||
const receipt = await deployToFactory({ command: "rm -rf /", token: "token", yes: true });
|
||||
const receipt = await deployToFactory({ command: "rm -rf /", token: "token", yes: true, gateReceipt: passedGateReceipt() });
|
||||
|
||||
expect(receipt.status).toBe("blocked");
|
||||
expect(receipt.reason).toContain("command must start");
|
||||
|
|
@ -34,12 +48,13 @@ describe("factory deploy", () => {
|
|||
expect(isAllowedFactoryCommand("rm -rf /")).toBe(false);
|
||||
});
|
||||
|
||||
it("posts allowed commands with token", async () => {
|
||||
it("posts allowed commands with token and gate receipt", async () => {
|
||||
const calls: unknown[] = [];
|
||||
const receipt = await deployToFactory({
|
||||
command: "echo ok",
|
||||
token: "token",
|
||||
yes: true,
|
||||
gateReceipt: passedGateReceipt(),
|
||||
fetcher: async (_url, init) => {
|
||||
calls.push(init);
|
||||
return { status: 200, json: async () => ({ exit_code: 0 }), text: async () => "" };
|
||||
|
|
@ -52,10 +67,41 @@ describe("factory deploy", () => {
|
|||
expect(JSON.stringify(calls[0])).toContain("Bearer token");
|
||||
});
|
||||
|
||||
it("writes a receipt", async () => {
|
||||
it("marks HTTP failures as not deploy allowed", async () => {
|
||||
const out = tmpFile();
|
||||
await deployToFactory({ command: "echo ok", out });
|
||||
const receipt = await deployToFactory({
|
||||
command: "echo ok",
|
||||
token: "token",
|
||||
yes: true,
|
||||
gateReceipt: passedGateReceipt(),
|
||||
out,
|
||||
fetcher: async () => ({ status: 500, json: async () => ({ error: "nope" }), text: async () => "" }),
|
||||
});
|
||||
|
||||
expect(fs.readFileSync(out, "utf-8")).toContain("missing --yes");
|
||||
expect(receipt.status).toBe("failed");
|
||||
expect(receipt.deploy_attempted).toBe(true);
|
||||
expect(receipt.deploy_allowed).toBe(false);
|
||||
expect(fs.readFileSync(out, "utf-8")).toContain("HTTP 500");
|
||||
});
|
||||
|
||||
it("writes a failed receipt when fetch throws", async () => {
|
||||
const out = tmpFile();
|
||||
const receipt = await deployToFactory({
|
||||
command: "echo ok",
|
||||
token: "token",
|
||||
yes: true,
|
||||
gateReceipt: passedGateReceipt(),
|
||||
out,
|
||||
fetcher: async () => { throw new Error("network down"); },
|
||||
});
|
||||
|
||||
expect(receipt.status).toBe("failed");
|
||||
expect(receipt.deploy_allowed).toBe(false);
|
||||
expect(fs.readFileSync(out, "utf-8")).toContain("network down");
|
||||
});
|
||||
|
||||
it("detects passed JSONL gate receipts", () => {
|
||||
expect(hasPassedGateReceipt(passedGateReceipt())).toBe(true);
|
||||
expect(hasPassedGateReceipt(tmpFile("missing.jsonl"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ export interface FactoryDeployOptions {
|
|||
token?: string;
|
||||
url?: string;
|
||||
yes?: boolean;
|
||||
gateReceipt?: string;
|
||||
out?: string;
|
||||
now?: Date;
|
||||
fetcher?: (url: string, init: { method: string; headers: Record<string, string>; body: string }) => Promise<{ status: number; json(): Promise<unknown>; text(): Promise<string> }>;
|
||||
|
|
@ -31,23 +32,44 @@ export async function deployToFactory(opts: FactoryDeployOptions): Promise<Facto
|
|||
const blocked = blockReason(opts);
|
||||
if (blocked) return writeMaybe(opts.out, receipt(opts, url, "blocked", false, false, blocked));
|
||||
|
||||
const res = await (opts.fetcher ?? fetchJson)(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${opts.token}` },
|
||||
body: JSON.stringify({ command: opts.command }),
|
||||
});
|
||||
const body = await readBody(res);
|
||||
return writeMaybe(opts.out, {
|
||||
...receipt(opts, url, res.status >= 200 && res.status < 300 ? "posted" : "failed", true, true),
|
||||
http_status: res.status,
|
||||
response: body,
|
||||
});
|
||||
try {
|
||||
const res = await (opts.fetcher ?? fetchJson)(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${opts.token}` },
|
||||
body: JSON.stringify({ command: opts.command }),
|
||||
});
|
||||
const body = await readBody(res);
|
||||
const posted = res.status >= 200 && res.status < 300;
|
||||
return writeMaybe(opts.out, {
|
||||
...receipt(opts, url, posted ? "posted" : "failed", true, posted, posted ? undefined : `HTTP ${res.status}`),
|
||||
http_status: res.status,
|
||||
response: body,
|
||||
});
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
return writeMaybe(opts.out, receipt(opts, url, "failed", false, false, reason));
|
||||
}
|
||||
}
|
||||
|
||||
export function isAllowedFactoryCommand(command: string): boolean {
|
||||
return ALLOWED_PREFIXES.some((prefix) => command.trim().startsWith(prefix));
|
||||
}
|
||||
|
||||
export function hasPassedGateReceipt(file: string): boolean {
|
||||
if (!fs.existsSync(file)) return false;
|
||||
// ponytail: accept JSON or JSONL; ZTE currently appends JSONL receipts.
|
||||
return fs.readFileSync(file, "utf-8")
|
||||
.split(/\r?\n/)
|
||||
.filter(Boolean)
|
||||
.some((line) => {
|
||||
try {
|
||||
return JSON.parse(line).status === "passed";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function writeFactoryDeployReceipt(file: string, value: FactoryDeployReceipt): FactoryDeployReceipt {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
|
||||
|
|
@ -56,6 +78,8 @@ export function writeFactoryDeployReceipt(file: string, value: FactoryDeployRece
|
|||
|
||||
function blockReason(opts: FactoryDeployOptions): string | undefined {
|
||||
if (!opts.yes) return "missing --yes human approval";
|
||||
if (!opts.gateReceipt) return "missing --gate-receipt verifier evidence";
|
||||
if (!hasPassedGateReceipt(opts.gateReceipt)) return "gate receipt missing passed status";
|
||||
if (!opts.token) return "missing deploy token";
|
||||
if (!isAllowedFactoryCommand(opts.command)) return `command must start with one of: ${ALLOWED_PREFIXES.join(", ")}`;
|
||||
return undefined;
|
||||
|
|
|
|||
|
|
@ -1456,15 +1456,17 @@ factory
|
|||
.command("deploy <command>")
|
||||
.description("Post an approved command to the factory deploy webhook")
|
||||
.option("--token <token>", "Deploy token; defaults to FACTORY_DEPLOY_TOKEN or DEPLOY_TOKEN")
|
||||
.requiredOption("--gate-receipt <path>", "ZTE/factory gate receipt JSONL containing a passed status")
|
||||
.option("--url <url>", "Deploy webhook URL", "http://77.42.112.29:8099/deploy")
|
||||
.option("--out <path>", "Deploy receipt path")
|
||||
.option("--yes", "Confirm this is an approved deploy command")
|
||||
.action(async (command: string, opts: { token?: string; url?: string; out?: string; yes?: boolean }) => {
|
||||
.action(async (command: string, opts: { token?: string; gateReceipt: string; url?: string; out?: string; yes?: boolean }) => {
|
||||
const { deployToFactory } = await import("./fable5/factory-deploy.js");
|
||||
const out = opts.out ?? path.join(".fable", "deploy", `${new Date().toISOString().replace(/[:.]/g, "-")}.json`);
|
||||
const receipt = await deployToFactory({
|
||||
command,
|
||||
token: opts.token ?? process.env.FACTORY_DEPLOY_TOKEN ?? process.env.DEPLOY_TOKEN,
|
||||
gateReceipt: opts.gateReceipt,
|
||||
url: opts.url,
|
||||
yes: opts.yes,
|
||||
out,
|
||||
|
|
|
|||
Loading…
Reference in New Issue