From 4215ac94aec6016139cd7c71db1e32161a5cd815 Mon Sep 17 00:00:00 2001 From: artale Date: Thu, 18 Jun 2026 02:34:12 +0200 Subject: [PATCH] feat: add guarded factory deploy command --- COMMANDS.md | 5 ++ src/fable5/factory-deploy.test.ts | 61 +++++++++++++++++++++++ src/fable5/factory-deploy.ts | 82 +++++++++++++++++++++++++++++++ src/fable5/index.ts | 3 ++ src/index.ts | 22 +++++++++ 5 files changed, 173 insertions(+) create mode 100644 src/fable5/factory-deploy.test.ts create mode 100644 src/fable5/factory-deploy.ts diff --git a/COMMANDS.md b/COMMANDS.md index 342290b..446f463 100644 --- a/COMMANDS.md +++ b/COMMANDS.md @@ -170,6 +170,11 @@ fable-agent plinius godmode "improve explanation quality" - `factory check` - `factory capabilities` - `--file ` +- `factory deploy ` + - `--token ` + - `--url ` + - `--out ` + - `--yes` - `factory gate ` - `--repo ` - `--host ` diff --git a/src/fable5/factory-deploy.test.ts b/src/fable5/factory-deploy.test.ts new file mode 100644 index 0000000..ef8c43c --- /dev/null +++ b/src/fable5/factory-deploy.test.ts @@ -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"); + }); +}); diff --git a/src/fable5/factory-deploy.ts b/src/fable5/factory-deploy.ts new file mode 100644 index 0000000..e42ad7d --- /dev/null +++ b/src/fable5/factory-deploy.ts @@ -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; body: string }) => Promise<{ status: number; json(): Promise; text(): Promise }>; +} + +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 { + 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; body: string }) { + return fetch(url, init); +} + +async function readBody(res: { json(): Promise; text(): Promise }): Promise { + try { + return await res.json(); + } catch { + return await res.text(); + } +} diff --git a/src/fable5/index.ts b/src/fable5/index.ts index a758dc7..dbc0b1c 100644 --- a/src/fable5/index.ts +++ b/src/fable5/index.ts @@ -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"; diff --git a/src/index.ts b/src/index.ts index 31bbb29..9de9f9e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1452,6 +1452,28 @@ factory if (!report.requiredOk) process.exit(1); }); +factory + .command("deploy ") + .description("Post an approved command to the factory deploy webhook") + .option("--token ", "Deploy token; defaults to FACTORY_DEPLOY_TOKEN or DEPLOY_TOKEN") + .option("--url ", "Deploy webhook URL", "http://77.42.112.29:8099/deploy") + .option("--out ", "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 ") .description("Run factory check plus local ZTE/repo verification gate")