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>`
|
- `--file <path>`
|
||||||
- `factory deploy <command>`
|
- `factory deploy <command>`
|
||||||
- `--token <token>`
|
- `--token <token>`
|
||||||
|
- `--gate-receipt <path>`
|
||||||
- `--url <url>`
|
- `--url <url>`
|
||||||
- `--out <path>`
|
- `--out <path>`
|
||||||
- `--yes`
|
- `--yes`
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import * as fs from "node:fs";
|
||||||
import * as os from "node:os";
|
import * as os from "node:os";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
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[] = [];
|
const roots: string[] = [];
|
||||||
|
|
||||||
|
|
@ -10,23 +10,37 @@ afterEach(() => {
|
||||||
for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true });
|
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-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "fable-factory-deploy-"));
|
||||||
roots.push(root);
|
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", () => {
|
describe("factory deploy", () => {
|
||||||
it("blocks without explicit approval", async () => {
|
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.status).toBe("blocked");
|
||||||
expect(receipt.deploy_attempted).toBe(false);
|
expect(receipt.deploy_attempted).toBe(false);
|
||||||
expect(receipt.reason).toContain("--yes");
|
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 () => {
|
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.status).toBe("blocked");
|
||||||
expect(receipt.reason).toContain("command must start");
|
expect(receipt.reason).toContain("command must start");
|
||||||
|
|
@ -34,12 +48,13 @@ describe("factory deploy", () => {
|
||||||
expect(isAllowedFactoryCommand("rm -rf /")).toBe(false);
|
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 calls: unknown[] = [];
|
||||||
const receipt = await deployToFactory({
|
const receipt = await deployToFactory({
|
||||||
command: "echo ok",
|
command: "echo ok",
|
||||||
token: "token",
|
token: "token",
|
||||||
yes: true,
|
yes: true,
|
||||||
|
gateReceipt: passedGateReceipt(),
|
||||||
fetcher: async (_url, init) => {
|
fetcher: async (_url, init) => {
|
||||||
calls.push(init);
|
calls.push(init);
|
||||||
return { status: 200, json: async () => ({ exit_code: 0 }), text: async () => "" };
|
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");
|
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();
|
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;
|
token?: string;
|
||||||
url?: string;
|
url?: string;
|
||||||
yes?: boolean;
|
yes?: boolean;
|
||||||
|
gateReceipt?: string;
|
||||||
out?: string;
|
out?: string;
|
||||||
now?: Date;
|
now?: Date;
|
||||||
fetcher?: (url: string, init: { method: string; headers: Record<string, string>; body: string }) => Promise<{ status: number; json(): Promise<unknown>; text(): Promise<string> }>;
|
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);
|
const blocked = blockReason(opts);
|
||||||
if (blocked) return writeMaybe(opts.out, receipt(opts, url, "blocked", false, false, blocked));
|
if (blocked) return writeMaybe(opts.out, receipt(opts, url, "blocked", false, false, blocked));
|
||||||
|
|
||||||
|
try {
|
||||||
const res = await (opts.fetcher ?? fetchJson)(url, {
|
const res = await (opts.fetcher ?? fetchJson)(url, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${opts.token}` },
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${opts.token}` },
|
||||||
body: JSON.stringify({ command: opts.command }),
|
body: JSON.stringify({ command: opts.command }),
|
||||||
});
|
});
|
||||||
const body = await readBody(res);
|
const body = await readBody(res);
|
||||||
|
const posted = res.status >= 200 && res.status < 300;
|
||||||
return writeMaybe(opts.out, {
|
return writeMaybe(opts.out, {
|
||||||
...receipt(opts, url, res.status >= 200 && res.status < 300 ? "posted" : "failed", true, true),
|
...receipt(opts, url, posted ? "posted" : "failed", true, posted, posted ? undefined : `HTTP ${res.status}`),
|
||||||
http_status: res.status,
|
http_status: res.status,
|
||||||
response: body,
|
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 {
|
export function isAllowedFactoryCommand(command: string): boolean {
|
||||||
return ALLOWED_PREFIXES.some((prefix) => command.trim().startsWith(prefix));
|
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 {
|
export function writeFactoryDeployReceipt(file: string, value: FactoryDeployReceipt): FactoryDeployReceipt {
|
||||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||||
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
|
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 {
|
function blockReason(opts: FactoryDeployOptions): string | undefined {
|
||||||
if (!opts.yes) return "missing --yes human approval";
|
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 (!opts.token) return "missing deploy token";
|
||||||
if (!isAllowedFactoryCommand(opts.command)) return `command must start with one of: ${ALLOWED_PREFIXES.join(", ")}`;
|
if (!isAllowedFactoryCommand(opts.command)) return `command must start with one of: ${ALLOWED_PREFIXES.join(", ")}`;
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|
|
||||||
|
|
@ -1456,15 +1456,17 @@ factory
|
||||||
.command("deploy <command>")
|
.command("deploy <command>")
|
||||||
.description("Post an approved command to the factory deploy webhook")
|
.description("Post an approved command to the factory deploy webhook")
|
||||||
.option("--token <token>", "Deploy token; defaults to FACTORY_DEPLOY_TOKEN or DEPLOY_TOKEN")
|
.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("--url <url>", "Deploy webhook URL", "http://77.42.112.29:8099/deploy")
|
||||||
.option("--out <path>", "Deploy receipt path")
|
.option("--out <path>", "Deploy receipt path")
|
||||||
.option("--yes", "Confirm this is an approved deploy command")
|
.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 { deployToFactory } = await import("./fable5/factory-deploy.js");
|
||||||
const out = opts.out ?? path.join(".fable", "deploy", `${new Date().toISOString().replace(/[:.]/g, "-")}.json`);
|
const out = opts.out ?? path.join(".fable", "deploy", `${new Date().toISOString().replace(/[:.]/g, "-")}.json`);
|
||||||
const receipt = await deployToFactory({
|
const receipt = await deployToFactory({
|
||||||
command,
|
command,
|
||||||
token: opts.token ?? process.env.FACTORY_DEPLOY_TOKEN ?? process.env.DEPLOY_TOKEN,
|
token: opts.token ?? process.env.FACTORY_DEPLOY_TOKEN ?? process.env.DEPLOY_TOKEN,
|
||||||
|
gateReceipt: opts.gateReceipt,
|
||||||
url: opts.url,
|
url: opts.url,
|
||||||
yes: opts.yes,
|
yes: opts.yes,
|
||||||
out,
|
out,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue