From 449f00c736543edab774855ebd3c2864dc31c3e1 Mon Sep 17 00:00:00 2001 From: Danny Kim Date: Wed, 23 Sep 2026 20:41:18 +0900 Subject: [PATCH] Add ordered bot profiles --- action.yml | 23 ++--- profiles.ts | 167 +++++++++++++++++++++++++++++++++ profiles_test.ts | 236 +++++++++++++++++++++++++++++++++++++++++++++++ run.ts | 194 +++++++++++++++++++++++++------------- 4 files changed, 543 insertions(+), 77 deletions(-) create mode 100644 profiles.ts create mode 100644 profiles_test.ts diff --git a/action.yml b/action.yml index 0f02c2c..167491c 100644 --- a/action.yml +++ b/action.yml @@ -2,8 +2,14 @@ name: Bot agents description: Run a coding agent for approved Gitea issue/PR automation inputs: - bot-type: - description: Which bot to run, either `codex` or `claude` + profiles-file: + description: >- + Repository-relative path to an ordered YAML sequence of bot profiles. + Each profile has `type` (`claude` or `codex`), an optional `model`, and + `token-env` naming a `BOT_PROFILE_...` environment variable containing + its credential. Codex may omit `token-env` to use its persisted device + login. A profile is tried only after the previous one exhausts its usage + allowance. required: true author-token: description: >- @@ -17,15 +23,6 @@ inputs: reviews, with the `write:issue` and `write:repository` scopes. Never exposed to the agent, so it must differ from the author. required: true - bot-token: - description: API key or token for the selected bot - required: false - model: - description: >- - Model for the selected bot. Defaults to `claude-sonnet-5` or - `gpt-5.6-terra`, the mid tiers, which cover reviews and fixes. - required: false - runs: using: composite steps: @@ -43,9 +40,7 @@ runs: run: deno run -A "${ACTION_PATH}/run.ts" env: ACTION_PATH: ${{ gitea.action_path }} - BOT_TYPE: ${{ inputs.bot-type }} - BOT_TOKEN: ${{ inputs.bot-token }} - MODEL: ${{ inputs.model }} + PROFILES_FILE: ${{ inputs.profiles-file }} GITEA_API_URL: ${{ gitea.api_url }} GITEA_REPOSITORY: ${{ gitea.repository }} GITEA_TOKEN: ${{ inputs.author-token }} diff --git a/profiles.ts b/profiles.ts new file mode 100644 index 0000000..f2874f0 --- /dev/null +++ b/profiles.ts @@ -0,0 +1,167 @@ +import { parse } from "jsr:@std/yaml@1"; +import { isAbsolute, relative, resolve } from "jsr:@std/path@1"; + +export type BotProfile = { + type: "claude" | "codex"; + model: string; + token?: string; + tokenEnv?: string; +}; + +const DEFAULT_MODELS: Record = { + claude: "claude-sonnet-5", + codex: "gpt-5.6-terra", +}; + +export type ProfileFileSystem = { + realPath(path: string): Promise; + stat(path: string): Promise<{ isFile: boolean }>; + readTextFile(path: string): Promise; +}; + +const systemFileSystem: ProfileFileSystem = { + realPath: Deno.realPath, + async stat(path) { + return { isFile: (await Deno.stat(path)).isFile }; + }, + readTextFile: Deno.readTextFile, +}; + +export async function readProfilesFile( + file: string, + workspace: string, + fileSystem: ProfileFileSystem = systemFileSystem, +): Promise { + if (isAbsolute(file)) { + throw new Error("profiles-file must be repository-relative"); + } + const root = await fileSystem.realPath(workspace); + const target = await fileSystem.realPath(resolve(root, file)); + const pathFromRoot = relative(root, target); + if ( + pathFromRoot === "" || pathFromRoot === ".." || + pathFromRoot.startsWith("../") || pathFromRoot.startsWith("..\\") || + isAbsolute(pathFromRoot) + ) { + throw new Error("profiles-file must stay inside the repository"); + } + if (!(await fileSystem.stat(target)).isFile) { + throw new Error("profiles-file must name a file"); + } + return await fileSystem.readTextFile(target); +} + +export function parseProfiles( + text: string, + environment: Record, +): BotProfile[] { + const value: unknown = parse(text); + if (!Array.isArray(value) || value.length === 0) { + throw new Error("profiles must contain at least one profile"); + } + return value.map((entry: unknown, index) => { + const at = `profiles[${index}]`; + if (typeof entry !== "object" || entry === null) { + throw new Error(`${at}: expected a mapping`); + } + const allowed = new Set(["type", "model", "token-env"]); + for (const key of Object.keys(entry)) { + if (!allowed.has(key)) throw new Error(`${at}.${key}: unknown field`); + } + const { type, model, "token-env": tokenEnv } = entry as Record< + string, + unknown + >; + if (type !== "claude" && type !== "codex") { + throw new Error(`${at}.type: expected "claude" or "codex"`); + } + if (model !== undefined && (typeof model !== "string" || model === "")) { + throw new Error(`${at}.model: expected a model name`); + } + if ( + tokenEnv !== undefined && + (typeof tokenEnv !== "string" || + !/^BOT_PROFILE_[A-Z0-9_]+$/.test(tokenEnv)) + ) { + throw new Error(`${at}.token-env: must start with BOT_PROFILE_`); + } + if (type === "claude" && tokenEnv === undefined) { + throw new Error(`${at}.token-env: required for Claude`); + } + const token = tokenEnv === undefined ? undefined : environment[tokenEnv]; + if (tokenEnv !== undefined && !token) { + throw new Error(`${at}.token-env: ${tokenEnv} is not set`); + } + return { + type, + model: model ?? DEFAULT_MODELS[type], + ...(token && { token }), + ...(tokenEnv && { tokenEnv }), + }; + }); +} + +function isUsageExhausted(error: unknown): boolean { + return /(?:usage|rate) limit|hit your limit|insufficient_quota|exceeded your current quota|too many requests|(?:api error|http error|http|status(?: code)?)[ :]+4(?:02|29)\b/i + .test(String(error)); +} + +export async function runProfiles( + profiles: BotProfile[], + attempt: (profile: BotProfile) => Promise, +): Promise { + for (const [index, profile] of profiles.entries()) { + try { + return await attempt(profile); + } catch (error) { + if (!isUsageExhausted(error) || index === profiles.length - 1) { + throw error; + } + console.error(`${profile.type} usage exhausted; trying next profile`); + } + } + throw new Error("profiles must contain at least one profile"); +} + +export function sanitizedEnvironment( + environment: Record, + profiles: BotProfile[], +): Record { + const result = { ...environment }; + for ( + const name of [ + "PROFILES", + "REVIEWER_TOKEN", + "BOT_TOKEN", + "CLAUDE_CODE_OAUTH_TOKEN", + "ANTHROPIC_API_KEY", + "ANTHROPIC_AUTH_TOKEN", + "OPENAI_API_KEY", + "CODEX_API_KEY", + "CODEX_ACCESS_TOKEN", + ] + ) { + delete result[name]; + } + for (const name of Object.keys(result)) { + if (name.startsWith("BOT_PROFILE_")) delete result[name]; + } + for (const profile of profiles) { + if (profile.tokenEnv) delete result[profile.tokenEnv]; + } + return result; +} + +export function agentEnvironment( + environment: Record, + profile: BotProfile, + profiles: BotProfile[], +): Record { + const result = sanitizedEnvironment(environment, profiles); + if (profile.token) { + result[ + profile.type === "claude" ? "CLAUDE_CODE_OAUTH_TOKEN" : "OPENAI_API_KEY" + ] = profile.token; + } + return result; +} diff --git a/profiles_test.ts b/profiles_test.ts new file mode 100644 index 0000000..2022038 --- /dev/null +++ b/profiles_test.ts @@ -0,0 +1,236 @@ +import { assertEquals, assertRejects, assertThrows } from "jsr:@std/assert@1"; +import { + agentEnvironment, + parseProfiles, + readProfilesFile, + runProfiles, + sanitizedEnvironment, +} from "./profiles.ts"; + +Deno.test("parses profiles in fallback order with model defaults", () => { + assertEquals( + parseProfiles( + ` +- type: claude + token-env: BOT_PROFILE_CLAUDE_PRIMARY +- type: codex + model: gpt-custom +`, + { BOT_PROFILE_CLAUDE_PRIMARY: "claude-token" }, + ), + [ + { + type: "claude", + model: "claude-sonnet-5", + token: "claude-token", + tokenEnv: "BOT_PROFILE_CLAUDE_PRIMARY", + }, + { type: "codex", model: "gpt-custom" }, + ], + ); +}); + +Deno.test("rejects an empty profile list", () => { + assertThrows(() => parseProfiles("[]", {}), Error, "at least one profile"); +}); + +Deno.test("rejects a Claude profile without its token environment variable", () => { + assertThrows( + () => parseProfiles("- type: claude", {}), + Error, + "profiles[0].token-env", + ); + assertThrows( + () => + parseProfiles( + "- type: claude\n token-env: BOT_PROFILE_CLAUDE_PRIMARY", + {}, + ), + Error, + "BOT_PROFILE_CLAUDE_PRIMARY is not set", + ); +}); + +Deno.test("rejects non-profile credentials and unknown fields", () => { + assertThrows( + () => + parseProfiles( + "- type: claude\n token-env: REVIEWER_TOKEN", + { REVIEWER_TOKEN: "secret" }, + ), + Error, + "must start with BOT_PROFILE_", + ); + assertThrows( + () => + parseProfiles( + "- type: codex\n token: committed-secret", + {}, + ), + Error, + "profiles[0].token: unknown field", + ); +}); + +Deno.test("reads only a profile file contained by the workspace", async () => { + const fileSystem = { + realPath(path: string) { + return Promise.resolve(path === "/repo/escaped.yml" ? "/outside" : path); + }, + stat(_path: string) { + return Promise.resolve({ isFile: true }); + }, + readTextFile(_path: string) { + return Promise.resolve("- type: codex\n"); + }, + }; + assertEquals( + await readProfilesFile("profiles.yml", "/repo", fileSystem), + "- type: codex\n", + ); + await assertRejects( + () => readProfilesFile("/outside", "/repo", fileSystem), + Error, + "repository-relative", + ); + await assertRejects( + () => readProfilesFile("escaped.yml", "/repo", fileSystem), + Error, + "must stay inside", + ); +}); + +Deno.test("falls through only after usage exhaustion", async () => { + const visited: string[] = []; + const result = await runProfiles( + [ + { type: "claude", model: "claude-sonnet-5", token: "first" }, + { type: "codex", model: "gpt-5.6-terra" }, + ], + (profile) => { + visited.push(profile.type); + if (profile.type === "claude") { + throw new Error("You've hit your usage limit"); + } + return Promise.resolve("done"); + }, + ); + + assertEquals(result, "done"); + assertEquals(visited, ["claude", "codex"]); +}); + +Deno.test("recognizes Claude's weekly limit message", async () => { + const result = await runProfiles( + [ + { type: "claude", model: "claude-sonnet-5", token: "first" }, + { type: "codex", model: "gpt-5.6-terra" }, + ], + (profile) => { + if (profile.type === "claude") { + throw new Error("You've hit your limit ยท resets Monday"); + } + return Promise.resolve("fallback"); + }, + ); + + assertEquals(result, "fallback"); +}); + +Deno.test("recognizes provider API exhaustion status", async () => { + const results = await Promise.all( + ["API Error: 402 Insufficient funds", "HTTP error: 429"] + .map((message) => + runProfiles( + [ + { type: "claude", model: "claude-sonnet-5", token: "first" }, + { type: "codex", model: "gpt-5.6-terra" }, + ], + (profile) => { + if (profile.type === "claude") throw new Error(message); + return Promise.resolve("fallback"); + }, + ) + ), + ); + + assertEquals(results, ["fallback", "fallback"]); +}); + +Deno.test("preserves an ordinary agent failure", async () => { + const visited: string[] = []; + await assertRejects( + () => + runProfiles( + [ + { type: "claude", model: "claude-sonnet-5", token: "first" }, + { type: "codex", model: "gpt-5.6-terra" }, + ], + (profile) => { + visited.push(profile.type); + throw new Error("tests failed"); + }, + ), + Error, + "tests failed", + ); + assertEquals(visited, ["claude"]); +}); + +Deno.test("does not mistake incidental quota text for exhaustion", async () => { + const visited: string[] = []; + await assertRejects( + () => + runProfiles( + [ + { type: "claude", model: "claude-sonnet-5", token: "first" }, + { type: "codex", model: "gpt-5.6-terra" }, + ], + (profile) => { + visited.push(profile.type); + throw new Error("quota parser test failed at line 429"); + }, + ), + Error, + "quota parser test failed", + ); + assertEquals(visited, ["claude"]); +}); + +Deno.test("exposes only the selected profile credential to the agent", () => { + const base = { + HOME: "/home/bot", + PROFILES: "contains every secret", + REVIEWER_TOKEN: "reviewer", + BOT_TOKEN: "legacy", + CLAUDE_CODE_OAUTH_TOKEN: "old-claude", + ANTHROPIC_API_KEY: "old-anthropic-key", + ANTHROPIC_AUTH_TOKEN: "old-anthropic-token", + OPENAI_API_KEY: "old-openai", + CODEX_API_KEY: "old-codex-key", + CODEX_ACCESS_TOKEN: "old-codex-token", + BOT_PROFILE_CLAUDE_PRIMARY: "selected", + BOT_PROFILE_CLAUDE_FALLBACK: "fallback", + BOT_PROFILE_UNUSED: "unused", + }; + const profiles = [ + { + type: "claude" as const, + model: "claude-sonnet-5", + token: "selected", + tokenEnv: "BOT_PROFILE_CLAUDE_PRIMARY", + }, + { + type: "claude" as const, + model: "claude-sonnet-5", + token: "fallback", + tokenEnv: "BOT_PROFILE_CLAUDE_FALLBACK", + }, + ]; + + assertEquals( + agentEnvironment(base, profiles[0], profiles), + { HOME: "/home/bot", CLAUDE_CODE_OAUTH_TOKEN: "selected" }, + ); + assertEquals(sanitizedEnvironment(base, profiles), { HOME: "/home/bot" }); +}); diff --git a/run.ts b/run.ts index 7d99e66..bc62a23 100644 --- a/run.ts +++ b/run.ts @@ -4,6 +4,14 @@ // so it appears as the bot account. import { TextLineStream } from "jsr:@std/streams@1/text-line-stream"; import { retryInvalidToken } from "./auth.ts"; +import { + agentEnvironment, + type BotProfile, + parseProfiles, + readProfilesFile, + runProfiles, + sanitizedEnvironment, +} from "./profiles.ts"; import { type BotType, loadSuperpowersReviewGuide, @@ -25,20 +33,15 @@ const API = env("GITEA_API_URL"); const REPO = env("GITEA_REPOSITORY"); const INDEX = env("ISSUE_INDEX"); const EVENT = env("EVENT_NAME"); -const BOT = parseBotType(env("BOT_TYPE")); +const PROCESS_ENV = Deno.env.toObject(); +const PROFILES = parseProfiles( + await readProfilesFile(env("PROFILES_FILE"), env("GITHUB_WORKSPACE")), + PROCESS_ENV, +); const AUTHOR_TOKEN = env("GITEA_TOKEN"); const REVIEWER_TOKEN = env("REVIEWER_TOKEN"); const RULES_PATH = "repos/commons/code-rules/raw/README.md"; -// The mid tiers: a run follows a fixed template and the project's checks. -const DEFAULT_MODELS: Record = { - claude: "claude-sonnet-5", - codex: "gpt-5.6-terra", -}; -const model = (bot: string): string => - Deno.env.get("MODEL") || DEFAULT_MODELS[bot]; - -// The reviewer token is withheld so the agent cannot approve as the bot. -const { REVIEWER_TOKEN: _, ...agentEnv } = Deno.env.toObject(); +const HELPER_ENV = sanitizedEnvironment(PROCESS_ENV, PROFILES); async function gitea( token: string, @@ -150,7 +153,11 @@ async function postResult(body: string): Promise { } async function run(command: string, args: string[]): Promise { - const { success, code } = await new Deno.Command(command, { args }).output(); + const { success, code } = await new Deno.Command(command, { + args, + env: HELPER_ENV, + clearEnv: true, + }).output(); if (!success) throw new Error(`${command} ${args[0]} exited with ${code}`); } @@ -213,31 +220,40 @@ async function renderPrompt(guide: SuperpowersReviewGuide): Promise { ); } -async function runClaude(): Promise { - const token = Deno.env.get("BOT_TOKEN"); - if (!token) { - throw new Error( - "Run `claude setup-token` locally and set the `bot-token` action input.", - ); - } +async function runClaude(profile: BotProfile): Promise { const prompt = await renderPrompt(await prepareSuperpowers("claude")); const claude = new Deno.Command("claude", { args: [ "--print", "--dangerously-skip-permissions", "--model", - model("claude"), + profile.model, "--output-format", "stream-json", "--verbose", prompt, ], // Claude refuses --dangerously-skip-permissions as root outside a sandbox. - env: { ...agentEnv, CLAUDE_CODE_OAUTH_TOKEN: token, IS_SANDBOX: "1" }, + env: { + ...agentEnvironment(PROCESS_ENV, profile, PROFILES), + IS_SANDBOX: "1", + }, clearEnv: true, stdout: "piped", + stderr: "piped", }).spawn(); - let result: { result?: string; subtype: string } | undefined; + 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); + }, + }), + ); // Print events as they stream so the runner does not kill the job as a zombie. const lines = claude.stdout .pipeThrough(new TextDecoderStream()) @@ -251,9 +267,17 @@ async function runClaude(): Promise { } if (event.type === "result") result = event; } - await claude.status; + 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(`claude ended with ${result?.subtype ?? "no result"}`); + 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; } @@ -262,6 +286,8 @@ async function runClaude(): Promise { async function codexDeviceLogin(): Promise { const login = new Deno.Command("codex", { args: ["login", "--device-auth"], + env: HELPER_ENV, + clearEnv: true, stdout: "piped", stderr: "piped", }).spawn(); @@ -284,50 +310,92 @@ async function codexDeviceLogin(): Promise { await Promise.all([status, drained]); } -async function runCodex(): Promise { - const loggedIn = await new Deno.Command("codex", { - args: ["login", "status"], - }) - .output(); - if (!loggedIn.success) await codexDeviceLogin(); - const prompt = await renderPrompt(await prepareSuperpowers("codex")); - const file = await Deno.makeTempFile(); - const attempt = async () => { - const codex = new Deno.Command("codex", { - args: [ - "exec", - "--model", - model("codex"), - "--dangerously-bypass-approvals-and-sandbox", - "--output-last-message", - file, - prompt, - ], - env: agentEnv, - 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 }); +async function codexApiLogin(token: string, codexHome: string): Promise { + const login = new Deno.Command("codex", { + args: ["login", "--with-api-key"], + env: { ...HELPER_ENV, CODEX_HOME: codexHome }, + clearEnv: true, + stdin: "piped", + }).spawn(); + const writer = login.stdin.getWriter(); + await writer.write(new TextEncoder().encode(`${token}\n`)); + await writer.close(); + const status = await login.status; + if (!status.success) { + throw new Error(`codex login exited with ${status.code}`); + } +} + +async function runCodex(profile: BotProfile): Promise { + const currentEnv = agentEnvironment(PROCESS_ENV, profile, PROFILES); + const temporaryHome = profile.token ? await Deno.makeTempDir() : null; + if (temporaryHome !== null) currentEnv.CODEX_HOME = temporaryHome; + try { + if (profile.token) { + await codexApiLogin(profile.token, temporaryHome!); + } else { + const loggedIn = await new Deno.Command("codex", { + args: ["login", "status"], + env: HELPER_ENV, + clearEnv: true, + }).output(); + if (!loggedIn.success) await codexDeviceLogin(); } - error += decoder.decode(); - return { status: await codex.status, error }; - }; - const { status } = await retryInvalidToken(attempt, async () => { - await run("codex", ["logout"]); - await codexDeviceLogin(); - }); - if (!status.success) throw new Error(`codex exited with ${status.code}`); - return await Deno.readTextFile(file); + const prompt = await renderPrompt(await prepareSuperpowers("codex")); + const file = await Deno.makeTempFile(); + const attempt = async () => { + const codex = new Deno.Command("codex", { + args: [ + "exec", + "--model", + profile.model, + "--dangerously-bypass-approvals-and-sandbox", + "--output-last-message", + file, + prompt, + ], + env: currentEnv, + 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 = profile.token + ? await attempt() + : await retryInvalidToken(attempt, async () => { + await run("codex", ["logout"]); + await codexDeviceLogin(); + }); + if (!result.status.success) { + throw new Error( + result.error.trim() || `codex exited with ${result.status.code}`, + ); + } + return await Deno.readTextFile(file); + } finally { + if (temporaryHome !== null) { + await Deno.remove(temporaryHome, { recursive: true }); + } + } } try { await configureGitAuthor(); - const result = BOT === "claude" ? await runClaude() : await runCodex(); + const result = await runProfiles( + PROFILES, + (profile) => + parseBotType(profile.type) === "claude" + ? runClaude(profile) + : runCodex(profile), + ); await postResult(result); } catch (error) { await postComment(`Bot failed: ${error}`);