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

125 lines
4.7 KiB
TypeScript

import * as fs from "node:fs";
import * as path from "node:path";
export interface FactoryDeployOptions {
command: string;
token?: string;
url?: string;
yes?: boolean;
gateReceipt?: 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;
}
const DEFAULT_URL = "http://77.42.112.29:8099/deploy";
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): boolean {
if (!fs.existsSync(file)) return false;
return fs.readFileSync(file, "utf-8")
.split(/\r?\n/)
.filter(Boolean)
.some((line) => {
try {
const json = JSON.parse(line) as { status?: unknown; task?: unknown; repo?: unknown; commands?: unknown; createdAt?: unknown };
return json.status === "passed"
&& typeof json.task === "string"
&& typeof json.repo === "string"
&& Array.isArray(json.commands)
&& json.commands.some((cmd) => typeof cmd === "string" && cmd.includes("fable5 verify"))
&& typeof json.createdAt === "string"
&& Number.isFinite(Date.parse(json.createdAt));
} catch {
return false;
}
});
}
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 (!opts.gateReceipt) return "missing --gate-receipt verifier evidence";
if (!hasPassedGateReceipt(opts.gateReceipt)) return "gate receipt missing passed verifier status";
if (!opts.token) return "missing deploy token";
if (!isAllowedFactoryCommand(opts.command)) return "command is outside the guarded deploy allowlist";
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();
}
}