feat: add guarded factory deploy command
This commit is contained in:
parent
93e5e06a9f
commit
4215ac94ae
|
|
@ -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>`
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
22
src/index.ts
22
src/index.ts
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Reference in New Issue