fable-agent/src/fable5/factory-deploy.ts

127 lines
4.7 KiB
TypeScript

import * as fs from "node:fs";
import * as path from "node:path";
import { CANONICAL_DEPLOY_ROUTE } from "./factory-routes.js";
import { validateGateReceipt } from "./zte-protocol.js";
export interface FactoryDeployOptions {
command: string;
token?: string;
url?: string;
yes?: boolean;
gateReceipt?: string;
planReceipt?: 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> }>;
}
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;
provenance?: { gateReceipt?: string; plan?: string };
}
const DEFAULT_URL = CANONICAL_DEPLOY_ROUTE;
const SAFE_COMMANDS = [
/^echo [A-Za-z0-9 _.,:;@/+\-=]+$/,
/^cat \/tmp\/[A-Za-z0-9._/-]+$/,
/^docker ps(?: --format '[A-Za-z0-9 {}_.:;@/+\-=|]+')?$/,
/^docker logs --tail \d{1,4} [A-Za-z0-9_.-]+$/,
/^docker inspect [A-Za-z0-9_.-]+$/,
/^curl -fsS -m \d{1,2} http:\/\/127\.0\.0\.1:8099\/(?:health|factory-status)$/,
];
export async function deployToFactory(opts: FactoryDeployOptions): Promise<FactoryDeployReceipt> {
const url = opts.url ?? DEFAULT_URL;
const blocked = blockReason(opts, url);
if (blocked) return writeMaybe(opts.out, receipt(opts, url, "blocked", false, false, blocked));
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 SAFE_COMMANDS.some((pattern) => pattern.test(command.trim()));
}
export function isAllowedDeployUrl(url: string): boolean {
return url === DEFAULT_URL;
}
export function hasPassedGateReceipt(file: string, planReceipt?: string): boolean {
return validateGateReceipt(file, { requiredCommands: ["factory gate", "fable5 verify"], requiredPlanReceipt: planReceipt }).ok;
}
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, url: string): string | undefined {
if (!opts.yes) return "missing --yes human approval";
if (!isAllowedDeployUrl(url)) return "deploy url must be the guarded 8099 git-proxy route";
if (!isAllowedFactoryCommand(opts.command)) return "command is outside the guarded deploy allowlist";
if (!opts.gateReceipt) return "missing --gate-receipt verifier evidence";
if (!hasPassedGateReceipt(opts.gateReceipt, opts.planReceipt)) return "gate receipt missing passed verifier status";
if (!opts.token) return "missing deploy token";
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,
provenance: deployProvenance(opts),
};
}
function deployProvenance(opts: FactoryDeployOptions): FactoryDeployReceipt["provenance"] {
if (!opts.gateReceipt) return undefined;
const validation = validateGateReceipt(opts.gateReceipt, { requiredCommands: ["factory gate", "fable5 verify"] });
return { gateReceipt: opts.gateReceipt, plan: validation.receipt?.provenance?.plan };
}
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();
}
}