feat: add guarded factory deploy command

This commit is contained in:
artale 2026-06-18 02:34:12 +02:00
parent 93e5e06a9f
commit 4215ac94ae
5 changed files with 173 additions and 0 deletions

View File

@ -170,6 +170,11 @@ fable-agent plinius godmode "improve explanation quality"
- `factory check`
- `factory capabilities`
- `--file <path>`
- `factory deploy <command>`
- `--token <token>`
- `--url <url>`
- `--out <path>`
- `--yes`
- `factory gate <task>`
- `--repo <path>`
- `--host <host>`

View File

@ -0,0 +1,61 @@
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";
const roots: string[] = [];
afterEach(() => {
for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true });
});
function tmpFile(): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "fable-factory-deploy-"));
roots.push(root);
return path.join(root, "receipt.json");
}
describe("factory deploy", () => {
it("blocks without explicit approval", async () => {
const receipt = await deployToFactory({ command: "echo ok", token: "token" });
expect(receipt.status).toBe("blocked");
expect(receipt.deploy_attempted).toBe(false);
expect(receipt.reason).toContain("--yes");
});
it("blocks unsafe command prefixes", async () => {
const receipt = await deployToFactory({ command: "rm -rf /", token: "token", yes: true });
expect(receipt.status).toBe("blocked");
expect(receipt.reason).toContain("command must start");
expect(isAllowedFactoryCommand("docker ps")).toBe(true);
expect(isAllowedFactoryCommand("rm -rf /")).toBe(false);
});
it("posts allowed commands with token", async () => {
const calls: unknown[] = [];
const receipt = await deployToFactory({
command: "echo ok",
token: "token",
yes: true,
fetcher: async (_url, init) => {
calls.push(init);
return { status: 200, json: async () => ({ exit_code: 0 }), text: async () => "" };
},
});
expect(receipt.status).toBe("posted");
expect(receipt.deploy_attempted).toBe(true);
expect(receipt.deploy_allowed).toBe(true);
expect(JSON.stringify(calls[0])).toContain("Bearer token");
});
it("writes a receipt", async () => {
const out = tmpFile();
await deployToFactory({ command: "echo ok", out });
expect(fs.readFileSync(out, "utf-8")).toContain("missing --yes");
});
});

View File

@ -0,0 +1,82 @@
import * as fs from "node:fs";
import * as path from "node:path";
export interface FactoryDeployOptions {
command: string;
token?: string;
url?: string;
yes?: boolean;
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> }>;
}
export interface FactoryDeployReceipt {
created_at: string;
url: string;
command: string;
deploy_attempted: boolean;
deploy_allowed: boolean;
status: "blocked" | "posted" | "failed";
http_status?: number;
response?: unknown;
reason?: string;
}
const DEFAULT_URL = "http://77.42.112.29:8099/deploy";
const ALLOWED_PREFIXES = ["docker ", "echo ", "curl ", "cat "];
export async function deployToFactory(opts: FactoryDeployOptions): Promise<FactoryDeployReceipt> {
const url = opts.url ?? DEFAULT_URL;
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,
});
}
export function isAllowedFactoryCommand(command: string): boolean {
return ALLOWED_PREFIXES.some((prefix) => command.trim().startsWith(prefix));
}
export function writeFactoryDeployReceipt(file: string, value: FactoryDeployReceipt): FactoryDeployReceipt {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
return value;
}
function blockReason(opts: FactoryDeployOptions): string | undefined {
if (!opts.yes) return "missing --yes human approval";
if (!opts.token) return "missing deploy token";
if (!isAllowedFactoryCommand(opts.command)) return `command must start with one of: ${ALLOWED_PREFIXES.join(", ")}`;
return undefined;
}
function receipt(opts: FactoryDeployOptions, url: string, status: FactoryDeployReceipt["status"], attempted: boolean, allowed: boolean, reason?: string): FactoryDeployReceipt {
return { created_at: (opts.now ?? new Date()).toISOString(), url, command: opts.command, deploy_attempted: attempted, deploy_allowed: allowed, status, reason };
}
function writeMaybe(file: string | undefined, value: FactoryDeployReceipt): FactoryDeployReceipt {
return file ? writeFactoryDeployReceipt(file, value) : value;
}
async function fetchJson(url: string, init: { method: string; headers: Record<string, string>; body: string }) {
return fetch(url, init);
}
async function readBody(res: { json(): Promise<unknown>; text(): Promise<string> }): Promise<unknown> {
try {
return await res.json();
} catch {
return await res.text();
}
}

View File

@ -47,6 +47,9 @@ export type { ZteReceipt, ZteSpec, ZteSpecOptions } from "./zte-protocol.js";
export { formatCapabilityReport, loadFactoryCapabilities, parseFactoryCapabilities, probeFactoryCapabilities } from "./factory-capabilities.js";
export type { CapabilityProbeResult, CapabilityReport, FactoryCapabilityService } from "./factory-capabilities.js";
export { deployToFactory, isAllowedFactoryCommand, writeFactoryDeployReceipt } from "./factory-deploy.js";
export type { FactoryDeployOptions, FactoryDeployReceipt } from "./factory-deploy.js";
export { FLUE_INTEROP_ROWS, formatFlueInteropReport, summarizeFlueInterop } from "./flue-interop.js";
export type { FlueInteropRow, FlueInteropStatus } from "./flue-interop.js";

View File

@ -1452,6 +1452,28 @@ factory
if (!report.requiredOk) process.exit(1);
});
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")
.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 }) => {
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,
url: opts.url,
yes: opts.yes,
out,
});
console.log(JSON.stringify(receipt, null, 2));
console.log(`\n Receipt: ${out}\n`);
if (receipt.status !== "posted") process.exit(1);
});
factory
.command("gate <task>")
.description("Run factory check plus local ZTE/repo verification gate")