// Runs one ordered agent profile. Credential decryption and reviewer posting // live in separate processes so neither privileged secret reaches this runner. import { TextLineStream } from "jsr:@std/streams@1/text-line-stream"; import { type CodexAuthentication, codexAuthentication, persistCodexAuth, retryInvalidToken, stageCodexAuth, } from "./auth.ts"; import { agentCommand, killProfileProcesses, readProfileFile, } from "./isolation.ts"; import { agentEnvironment, runProfiles, sanitizedEnvironment, withProfile, } from "./profiles.ts"; import { collectOpenCodeOutput, openCodeArgs, parseOpenCodeEvents, } from "./opencode.ts"; import { readRuntimeManifest, type RuntimeOutcome, type RuntimeProfile, } from "./runtime.ts"; type GiteaUser = { login: string; email: string }; type GiteaComment = { user: GiteaUser; created_at: string; body: string }; type AttemptResult = { body: string; reviewPath: string; anchorsPath: string; profile: Pick; }; const env = (name: string): string => { const value = Deno.env.get(name); if (value === undefined) throw new Error(`${name} is not set`); return value; }; 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(Deno.env.toObject()); 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 { success, code } = await new Deno.Command(command, { args, env: PROCESS_ENV, clearEnv: true, }).output(); if (!success) throw new Error(`${command} ${args[0]} exited with ${code}`); } async function configureGitAuthor(): Promise { const user: GiteaUser = await (await gitea("user")).json(); for (const [key, value] of [["name", user.login], ["email", user.email]]) { await runRoot("git", ["config", `user.${key}`, value]); } } async function readProfileToken( profile: RuntimeProfile, ): Promise { return profile.tokenFile === null ? undefined : await Deno.readTextFile(profile.tokenFile); } async function runAgentCommand( profile: RuntimeProfile, command: string, args: string[], token?: string, ): Promise { const { success, code } = await agentCommand(profile, command, args, { cwd: WORKSPACE, env: agentEnvironment(PROCESS_ENV, profile, token), clearEnv: true, }).output(); if (!success) 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", ]); } 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: GiteaComment[] = await (await gitea( `repos/${REPO}/issues/${INDEX}/comments?limit=100`, )).json(); const values: Record = { COMMENT: env("COMMENT"), ISSUE_COMMENTS: comments .map((comment) => `## ${comment.user.login} at ${comment.created_at}\n\n${comment.body}\n` ) .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 Deno.readTextFile( new URL("../prompt.md", import.meta.url), ); return { prompt: template.replace( /\$\{(\w+)\}/g, (match, name) => values[name] ?? match, ), reviewPath, anchorsPath, }; } async function runClaude( profile: RuntimeProfile, token: string, prompt: string, ): Promise { const claude = agentCommand(profile, "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", }, clearEnv: true, stdout: "piped", stderr: "piped", }).spawn(); let result: | { is_error?: boolean; result?: string; subtype: string } | undefined; let error = ""; const copyError = claude.stderr.pipeThrough(new TextDecoderStream()).pipeTo( new WritableStream({ write(chunk) { error += chunk; console.error(chunk); }, }), ); const lines = claude.stdout .pipeThrough(new TextDecoderStream()) .pipeThrough(new TextLineStream()); for await (const line of lines) { if (!line) continue; const event = JSON.parse(line); for (const part of event.message?.content ?? []) { const text = part.thinking ?? part.text ?? part.name; if (text) console.log(text); } if (event.type === "result") result = event; } const [status] = await Promise.all([claude.status, copyError]); if (result?.is_error) { throw new Error(result.result ?? `claude ended with ${result.subtype}`); } if (result?.result === undefined) { throw new Error( error.trim() || `claude ended with ${result?.subtype ?? "no result"}`, ); } if (!status.success) { throw new Error(error.trim() || `claude exited with ${status.code}`); } return result.result; } async function codexDeviceLogin( profile: RuntimeProfile, environment: Record, ): Promise { const login = agentCommand(profile, "codex", ["login", "--device-auth"], { cwd: WORKSPACE, env: environment, clearEnv: true, stdout: "piped", stderr: "piped", }).spawn(); let shown = ""; const collect = (stream: ReadableStream): Promise => stream.pipeThrough(new TextDecoderStream()).pipeTo( new WritableStream({ write: (chunk) => void (shown += chunk) }), ); const drained = Promise.all([collect(login.stdout), collect(login.stderr)]); const status = login.status; await new Promise((resolve) => setTimeout(resolve, 3000)); while (shown.trim() === "") { const exited = await Promise.race([ status.then(() => true), new Promise((resolve) => setTimeout(() => resolve(false), 100)), ]); if (exited) throw new Error("AI bot login produced no instructions"); } await postAuthorComment(withProfile(shown, profile)); const [result] = await Promise.all([status, drained]); if (!result.success) { throw new Error(`codex login exited with ${result.code}`); } } async function codexApiLogin( profile: RuntimeProfile, environment: Record, token: string, ): Promise { const login = agentCommand(profile, "codex", ["login", "--with-api-key"], { cwd: WORKSPACE, env: environment, clearEnv: true, stdin: "piped", stderr: "piped", }).spawn(); const writer = login.stdin.getWriter(); await writer.write(new TextEncoder().encode(`${token}\n`)); await writer.close(); const [status, error] = await Promise.all([ login.status, new Response(login.stderr).text(), ]); if (!status.success) { throw new Error(error.trim() || `codex login exited with ${status.code}`); } } async function makeProfileFile( profile: RuntimeProfile, name: string, ): Promise { const path = `${profile.home}/${name}`; await Deno.writeTextFile(path, "", { mode: 0o600 }); await Deno.chmod(path, 0o600); await Deno.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 loggedIn = await agentCommand(profile, "codex", [ "login", "status", ], { cwd: WORKSPACE, env: environment, clearEnv: true, }).output(); if (!loggedIn.success) await codexDeviceLogin(profile, environment); } const outputPath = await makeProfileFile(profile, "result.md"); const attempt = async () => { const codex = agentCommand(profile, "codex", [ "exec", "--model", profile.model, "--dangerously-bypass-approvals-and-sandbox", "--output-last-message", outputPath, prompt, ], { cwd: WORKSPACE, env: environment, clearEnv: true, stdout: "inherit", stderr: "piped", }).spawn(); const decoder = new TextDecoder(); let error = ""; for await (const chunk of codex.stderr) { await Deno.stderr.write(chunk); error += decoder.decode(chunk, { stream: true }); } error += decoder.decode(); return { status: await codex.status, error }; }; const result = authentication.kind === "api-key" ? await attempt() : await retryInvalidToken(attempt, async () => { await runAgentCommand(profile, "codex", ["logout"]); await codexDeviceLogin(profile, environment); }); if (!result.status.success) { throw new Error( result.error.trim() || `codex exited with ${result.status.code}`, ); } return outputPath; } async function runOpenCode( profile: RuntimeProfile, token: string, prompt: string, ): Promise { const process = agentCommand( profile, "opencode", openCodeArgs(profile.model, prompt), { cwd: WORKSPACE, env: agentEnvironment(PROCESS_ENV, profile, token), clearEnv: true, stdout: "piped", stderr: "piped", }, ).spawn(); const output = collectOpenCodeOutput( process.stdout, process.stderr, async (chunk) => { await Deno.stdout.write(chunk); }, async (chunk) => { await Deno.stderr.write(chunk); }, ); const [status, streams] = await Promise.all([process.status, output]); const error = streams.stderr.trim(); if (!status.success) { throw new Error(error || `opencode exited with ${status.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!); } 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 Deno.writeTextFile(MANIFEST.outcomeFile, JSON.stringify(outcome), { mode: 0o600, }); await Deno.chmod(MANIFEST.outcomeFile, 0o600); } try { await configureGitAuthor(); await writeOutcome({ ok: true, ...await runProfiles(MANIFEST.profiles, runProfile), }); } catch (error) { console.error(error); await writeOutcome({ ok: false }); throw error; }