import { chmod, chown, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { parseCredentials } from "./credentials.ts"; import { isMapping } from "./json.ts"; import type { BotProfile, ProfileFailure } from "./profiles.ts"; const FIRST_PROFILE_UID = 20_000; export type RuntimeProfile = Pick & { index: number; uid: number; gid: number; home: string; tokenFile: string | null; }; export interface RuntimeManifest { runtimeDir: string; manifestPath: string; reviewerFile: string; outcomeFile: string; profiles: RuntimeProfile[]; } export type RuntimeOutcome = | { ok: true; body: string; reviewPath: string; anchorsPath: string; profile: Pick; } | { ok: false; failures: ProfileFailure[] }; async function writePrivate(path: string, value: string, uid: number, gid: number): Promise { await writeFile(path, value, { mode: 0o600 }); await chmod(path, 0o600); await chown(path, uid, gid); } export async function prepareRuntime( actionPath: string, runtimeRoot: string, masterKey: string, ): Promise { const credentials = await parseCredentials( await readFile(join(actionPath, "profiles.json"), "utf8"), masterKey, ); const runtimeDir = await mkdtemp(join(runtimeRoot, "bot-agents-")); try { await chmod(runtimeDir, 0o711); const reviewerFile = join(runtimeDir, "reviewer-token"); await writePrivate(reviewerFile, credentials.reviewerToken, 0, 0); // One profile at a time, in order, as each claims the next UID. const profiles = await credentials.profiles.reduce>( async (previous, profile, index) => { const prepared = await previous; const uid = FIRST_PROFILE_UID + index; const home = join(runtimeDir, `profile-${index}`); await mkdir(home, { mode: 0o700 }); await chmod(home, 0o700); await chown(home, uid, uid); const tokenFile = profile.token === undefined || profile.token === "" ? null : join(home, "token"); if (tokenFile !== null && profile.token !== undefined) { await writePrivate(tokenFile, profile.token, uid, uid); } prepared.push({ index, type: profile.type, model: profile.model, uid, gid: uid, home, tokenFile, }); return prepared; }, Promise.resolve([]), ); const manifestPath = join(runtimeDir, "manifest.json"); const manifest: RuntimeManifest = { runtimeDir, manifestPath, reviewerFile, outcomeFile: join(runtimeDir, "outcome.json"), profiles, }; await writePrivate(manifestPath, JSON.stringify(manifest), 0, 0); return manifest; } catch (error) { await rm(runtimeDir, { recursive: true }).catch(() => {}); throw error; } } function isRuntimeManifest(value: unknown): value is RuntimeManifest { return ( isMapping(value) && typeof value["runtimeDir"] === "string" && typeof value["manifestPath"] === "string" && typeof value["reviewerFile"] === "string" && typeof value["outcomeFile"] === "string" && Array.isArray(value["profiles"]) ); } export async function readRuntimeManifest(path: string): Promise { const value: unknown = JSON.parse(await readFile(path, "utf8")); if (!isRuntimeManifest(value)) { throw new Error(`${path}: invalid runtime manifest`); } return value; }