// Runs one ordered agent profile. Credential decryption and reviewer posting // live in separate processes so neither privileged secret reaches this runner. import { spawn } from "node:child_process"; import { chmod, chown, readFile, writeFile } from "node:fs/promises"; import process from "node:process"; import { createInterface } from "node:readline"; import type { Readable } from "node:stream"; import { text as readText } from "node:stream/consumers"; import { finished } from "node:stream/promises"; import { setTimeout as sleep } from "node:timers/promises"; import { codexAuthentication, isClaudeAuthFailure, isCodexAuthFailure, persistCodexAuth, retryInvalidToken, stageCodexAuth, } from "./auth.ts"; import type { ClaudeFailureFields, CodexAuthentication } from "./auth.ts"; import { echo, exitCode } from "./child.ts"; import { killProfileProcesses, readProfileFile } from "./isolation.ts"; import { isObject } from "./json.ts"; import { collectOpenCodeOutput, openCodeArgs, openCodeError, parseOpenCodeEvents, } from "./opencode.ts"; import { AgentAuthError, agentEnvironment, runProfiles, sanitizedEnvironment, withProfile, } from "./profiles.ts"; import type { ProfileFailure } from "./profiles.ts"; import { readRuntimeManifest } from "./runtime.ts"; import type { RuntimeOutcome, RuntimeProfile } from "./runtime.ts"; interface AttemptResult { body: string; reviewPath: string; anchorsPath: string; profile: Pick; } // Claude Code's stream-json `result` event, in its own field names. type ClaudeResult = ClaudeFailureFields & Partial> & { subtype: string; }; const env = (name: string): string => { const value = process.env[name]; if (value === undefined) throw new Error(`${name} is not set`); return value; }; function definedEnvironment(): Record { const result: Record = {}; for (const [name, value] of Object.entries(process.env)) { if (value !== undefined) { result[name] = value; } } return result; } const API = env("GITEA_API_URL"); const REPO = env("GITEA_REPOSITORY"); const INDEX = env("ISSUE_INDEX"); const EVENT = env("EVENT_NAME"); const AUTHOR_TOKEN = env("GITEA_TOKEN"); const WORKSPACE = env("GITHUB_WORKSPACE"); const MANIFEST = await readRuntimeManifest(env("BOT_AGENTS_MANIFEST")); const PROCESS_ENV = sanitizedEnvironment(definedEnvironment()); const RULES_PATH = "repos/commons/code-rules/raw/README.md"; const CODEX_AUTH = "/root/.codex/auth.json"; const SKILLS_INSTALL = "skills add obra/superpowers"; async function gitea(path: string, body?: unknown): Promise { const response = await fetch(`${API}/${path}`, { method: body === undefined ? "GET" : "POST", headers: { Authorization: `token ${AUTHOR_TOKEN}`, "Content-Type": "application/json", }, body: body === undefined ? undefined : JSON.stringify(body), }); if (!response.ok) { throw new Error(`${path}: ${response.status} ${await response.text()}`); } return response; } async function postAuthorComment(body: string): Promise { await gitea(`repos/${REPO}/issues/${INDEX}/comments`, { body }); } async function runRoot(command: string, args: string[]): Promise { const code = await exitCode(spawn(command, args, { env: PROCESS_ENV, stdio: "ignore" })); if (code !== 0) throw new Error(`${command} ${args[0]} exited with ${code}`); } async function configureGitAuthor(): Promise { const user: unknown = await (await gitea("user")).json(); if (!isObject(user) || typeof user["login"] !== "string" || typeof user["email"] !== "string") { throw new Error("user: unexpected Gitea response"); } await runRoot("git", ["config", "user.name", user["login"]]); await runRoot("git", ["config", "user.email", user["email"]]); } async function readProfileToken(profile: RuntimeProfile): Promise { const token = profile.tokenFile === null ? undefined : await readFile(profile.tokenFile, "utf8"); return token; } async function runAgentCommand( profile: RuntimeProfile, command: string, args: string[], token?: string, ): Promise { const child = spawn(command, args, { cwd: WORKSPACE, env: agentEnvironment(PROCESS_ENV, profile, token), uid: profile.uid, gid: profile.gid, stdio: "ignore", }); const code = await exitCode(child); if (code !== 0) throw new Error(`${command} ${args[0]} exited with ${code}`); } async function prepareProfile(profile: RuntimeProfile): Promise { await runAgentCommand(profile, "git", [ "config", "--global", "--add", "safe.directory", WORKSPACE, ]); await runAgentCommand(profile, "npx", [ "-y", ...SKILLS_INSTALL.split(" "), "--global", "--agent", profile.type === "claude" ? "claude-code" : profile.type, "--yes", ]); } // One issue comment as the prompt quotes it. function formatComment(comment: unknown): string { const { user, created_at: createdAt, body } = isObject(comment) ? comment : {}; const login = isObject(user) ? user["login"] : undefined; return `## ${String(login)} at ${String(createdAt)}\n\n${String(body)}\n`; } async function renderPrompt( profile: RuntimeProfile, ): Promise<{ prompt: string; reviewPath: string; anchorsPath: string }> { const reviewPath = await makeProfileFile(profile, "review.md"); const anchorsPath = await makeProfileFile(profile, "anchors.json"); const comments: unknown = await ( await gitea(`repos/${REPO}/issues/${INDEX}/comments?limit=100`) ).json(); if (!Array.isArray(comments)) { throw new TypeError("comments: unexpected Gitea response"); } const values: Record = { COMMENT: env("COMMENT"), ISSUE_COMMENTS: comments.map((comment: unknown) => formatComment(comment)).join("\n"), CODE_RULES: await (await gitea(RULES_PATH)).text(), EVENT_NAME: EVENT, GITEA_API_URL: API, GITEA_REPOSITORY: REPO, ISSUE_INDEX: INDEX, REVIEW_PATH: reviewPath, ANCHORS_PATH: anchorsPath, }; const template = await readFile(new URL("../prompt.md", import.meta.url), "utf8"); return { prompt: template.replaceAll( /\$\{(\w+)\}/gu, (match: string, name: string) => values[name] ?? match, ), reviewPath, anchorsPath, }; } async function runClaude(profile: RuntimeProfile, token: string, prompt: string): Promise { const claude = spawn( "claude", [ "--print", "--dangerously-skip-permissions", "--model", profile.model, "--output-format", "stream-json", "--verbose", prompt, ], { cwd: WORKSPACE, env: { ...agentEnvironment(PROCESS_ENV, profile, token), IS_SANDBOX: "1", }, uid: profile.uid, gid: profile.gid, stdio: ["ignore", "pipe", "pipe"], }, ); const status = exitCode(claude); let result: ClaudeResult | undefined; let error = ""; const copyError = (async (): Promise => { claude.stderr.setEncoding("utf8"); for await (const chunk of claude.stderr) { const part = String(chunk); error += part; console.error(part); } })(); for await (const line of createInterface({ input: claude.stdout, crlfDelay: Infinity })) { if (line !== "") { const event: unknown = JSON.parse(line); if (isObject(event)) { const content = isObject(event["message"]) ? event["message"]["content"] : undefined; for (const part of Array.isArray(content) ? content : []) { if (isObject(part)) { const text = part["thinking"] ?? part["text"] ?? part["name"]; if (typeof text === "string" && text !== "") { console.log(text); } } } if (event["type"] === "result") { const { is_error: isError, result: answer, subtype, api_error_status: apiStatus } = event; result = { is_error: typeof isError === "boolean" ? isError : undefined, result: typeof answer === "string" ? answer : undefined, subtype: String(subtype), api_error_status: typeof apiStatus === "number" || apiStatus === null ? apiStatus : undefined, }; } } } } const [code] = await Promise.all([status, copyError]); if (result?.is_error === true) { const failure = new Error(result.result ?? `claude ended with ${result.subtype}`); if (isClaudeAuthFailure(result)) { throw new AgentAuthError("claude sign-in failed", { cause: failure }); } throw failure; } if (result?.result === undefined) { throw new Error(error.trim() || `claude ended with ${result?.subtype ?? "no result"}`); } if (code !== 0) { throw new Error(error.trim() || `claude exited with ${code}`); } return result.result; } // Polls every 100 ms until the login prints something, failing if it exits first. async function pollForInstructions( shownText: () => string, status: Promise, ): Promise { if (shownText().trim() !== "") { return; } const exited = await Promise.race([status.then(() => true), sleep(100, false)]); if (exited) { throw new AgentAuthError("AI bot login produced no instructions"); } await pollForInstructions(shownText, status); } async function codexDeviceLogin( profile: RuntimeProfile, environment: Record, ): Promise { const login = spawn("codex", ["login", "--device-auth"], { cwd: WORKSPACE, env: environment, uid: profile.uid, gid: profile.gid, stdio: ["ignore", "pipe", "pipe"], }); const status = exitCode(login); let shown = ""; const collect = async (stream: Readable): Promise => { stream.setEncoding("utf8"); for await (const chunk of stream) { shown += String(chunk); } }; const drained = Promise.all([collect(login.stdout), collect(login.stderr)]); await sleep(3000); await pollForInstructions(() => shown, status); await postAuthorComment(withProfile(shown, profile)); const [code] = await Promise.all([status, drained]); if (code !== 0) { throw new AgentAuthError(`codex login exited with ${code}`); } } async function codexApiLogin( profile: RuntimeProfile, environment: Record, token: string, ): Promise { const login = spawn("codex", ["login", "--with-api-key"], { cwd: WORKSPACE, env: environment, uid: profile.uid, gid: profile.gid, stdio: ["pipe", "inherit", "pipe"], }); const status = exitCode(login); login.stdin.end(`${token}\n`); await finished(login.stdin); const [code, error] = await Promise.all([status, readText(login.stderr)]); if (code !== 0) { throw new Error(error.trim() || `codex login exited with ${code}`); } } async function makeProfileFile(profile: RuntimeProfile, name: string): Promise { const path = `${profile.home}/${name}`; await writeFile(path, "", { mode: 0o600 }); await chmod(path, 0o600); await chown(path, profile.uid, profile.gid); return path; } async function runCodex( profile: RuntimeProfile, prompt: string, authentication: CodexAuthentication, ): Promise { const token = authentication.kind === "api-key" ? authentication.token : undefined; const environment = agentEnvironment(PROCESS_ENV, profile, token); if (authentication.kind === "api-key") { await codexApiLogin(profile, environment, authentication.token); } else { const check = spawn("codex", ["login", "status"], { cwd: WORKSPACE, env: environment, uid: profile.uid, gid: profile.gid, stdio: "ignore", }); if ((await exitCode(check)) !== 0) await codexDeviceLogin(profile, environment); } const outputPath = await makeProfileFile(profile, "result.md"); const attempt = async (): Promise<{ code: number; error: string }> => { const codex = spawn( "codex", [ "exec", "--model", profile.model, "--dangerously-bypass-approvals-and-sandbox", "--output-last-message", outputPath, prompt, ], { cwd: WORKSPACE, env: environment, uid: profile.uid, gid: profile.gid, stdio: ["ignore", "inherit", "pipe"], }, ); const status = exitCode(codex); codex.stderr.setEncoding("utf8"); let error = ""; for await (const chunk of codex.stderr) { const part = String(chunk); await echo(process.stderr, part); error += part; } // Codex echoes the prompt, whose comments may quote auth errors. return { code: await status, error: error.replaceAll(prompt, "") }; }; const result = authentication.kind === "api-key" ? await attempt() : await retryInvalidToken(attempt, async () => { await runAgentCommand(profile, "codex", ["logout"]); await codexDeviceLogin(profile, environment); }); if (result.code !== 0) { const failure = new Error(result.error.trim() || `codex exited with ${result.code}`); if (isCodexAuthFailure(result.error)) { throw new AgentAuthError("codex sign-in failed", { cause: failure }); } throw failure; } return outputPath; } async function runOpenCode( profile: RuntimeProfile, token: string, prompt: string, ): Promise { const child = spawn("opencode", openCodeArgs(profile.model, prompt), { cwd: WORKSPACE, env: agentEnvironment(PROCESS_ENV, profile, token), uid: profile.uid, gid: profile.gid, stdio: ["ignore", "pipe", "pipe"], }); const status = exitCode(child); const collected = collectOpenCodeOutput( child.stdout, child.stderr, (chunk) => echo(process.stdout, chunk), (chunk) => echo(process.stderr, chunk), ); const [code, streams] = await Promise.all([status, collected]); const error = streams.stderr.trim(); if (code !== 0) { throw openCodeError(streams.stdout) ?? new Error(error || `opencode exited with ${code}`); } return parseOpenCodeEvents(streams.stdout); } async function runProfile(profile: RuntimeProfile): Promise { const token = await readProfileToken(profile); const authentication = profile.type === "codex" ? codexAuthentication(token) : null; let stagedCodexAuth: string | null = null; let body: string | undefined; let resultPath: string | null = null; let reviewPath: string | undefined; let anchorsPath: string | undefined; try { if (authentication?.kind === "persistent") { stagedCodexAuth = await stageCodexAuth(CODEX_AUTH, profile.home, profile.uid, profile.gid); } await prepareProfile(profile); const rendered = await renderPrompt(profile); reviewPath = rendered.reviewPath; anchorsPath = rendered.anchorsPath; if (profile.type === "claude") { body = await runClaude(profile, token ?? "", rendered.prompt); } else if (profile.type === "codex") { resultPath = await runCodex( profile, rendered.prompt, authentication ?? codexAuthentication(token), ); } else { body = await runOpenCode(profile, token ?? "", rendered.prompt); } } finally { await killProfileProcesses(profile.uid); if (stagedCodexAuth !== null) { await persistCodexAuth(stagedCodexAuth, CODEX_AUTH); } } if (resultPath !== null) body = await readProfileFile(profile, resultPath); if (body === undefined || reviewPath === undefined || anchorsPath === undefined) { throw new Error("agent attempt produced no result"); } return { body, reviewPath, anchorsPath, profile: { type: profile.type, model: profile.model }, }; } async function writeOutcome(outcome: RuntimeOutcome): Promise { await writeFile(MANIFEST.outcomeFile, JSON.stringify(outcome), { mode: 0o600, }); await chmod(MANIFEST.outcomeFile, 0o600); } const failures: ProfileFailure[] = []; try { await configureGitAuthor(); await writeOutcome({ ok: true, ...(await runProfiles(MANIFEST.profiles, runProfile, failures)), }); } catch (error) { console.error(error); await writeOutcome({ ok: false, failures }); throw error; }