Centralize encrypted bot profiles
Check / deno (pull_request) Failing after 42s

This commit is contained in:
Danny Kim
2026-09-24 04:56:11 +09:00
parent ba9e0c0787
commit 80f3d922d1
22 changed files with 1973 additions and 526 deletions
+299 -214
View File
@@ -1,19 +1,36 @@
// Runs a coding agent for one Gitea issue or pull request event and posts its
// final response back. The agent works as the author account through
// GITEA_TOKEN; everything this script posts goes through the reviewer token,
// so it appears as the bot account.
// 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 { retryInvalidToken } from "./auth.ts";
import {
type BotType,
loadSuperpowersReviewGuide,
parseBotType,
superpowersInstallCommands,
type SuperpowersReviewGuide,
} from "./superpowers.ts";
type CodexAuthentication,
codexAuthentication,
persistCodexAuth,
retryInvalidToken,
stageCodexAuth,
} from "./auth.ts";
import {
agentCommand,
killProfileProcesses,
readProfileFile,
} from "./isolation.ts";
import {
agentEnvironment,
runProfiles,
sanitizedEnvironment,
} from "./profiles.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;
};
const env = (name: string): string => {
const value = Deno.env.get(name);
@@ -25,30 +42,19 @@ 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 AUTHOR_TOKEN = env("GITEA_TOKEN");
const REVIEWER_TOKEN = env("REVIEWER_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";
// The mid tiers: a run follows a fixed template and the project's checks.
const DEFAULT_MODELS: Record<string, string> = {
claude: "claude-sonnet-5",
codex: "gpt-5.6-terra",
};
const model = (bot: string): string =>
Deno.env.get("MODEL") || DEFAULT_MODELS[bot];
const CODEX_AUTH = "/root/.codex/auth.json";
const SKILLS_INSTALL = "skills add obra/superpowers";
// The reviewer token is withheld so the agent cannot approve as the bot.
const { REVIEWER_TOKEN: _, ...agentEnv } = Deno.env.toObject();
async function gitea(
token: string,
path: string,
body?: unknown,
): Promise<Response> {
async function gitea(path: string, body?: unknown): Promise<Response> {
const response = await fetch(`${API}/${path}`, {
method: body === undefined ? "GET" : "POST",
headers: {
Authorization: `token ${token}`,
Authorization: `token ${AUTHOR_TOKEN}`,
"Content-Type": "application/json",
},
body: body === undefined ? undefined : JSON.stringify(body),
@@ -59,186 +65,141 @@ async function gitea(
return response;
}
const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*[A-Za-z]`, "g");
const stripAnsi = (text: string): string => text.replace(ANSI, "");
async function postComment(body: string): Promise<void> {
await gitea(REVIEWER_TOKEN, `repos/${REPO}/issues/${INDEX}/comments`, {
body: stripAnsi(body),
});
async function postAuthorComment(body: string): Promise<void> {
await gitea(`repos/${REPO}/issues/${INDEX}/comments`, { body });
}
// A review is posted whenever the agent wrote one, whether a review request or
// a comment asked for it. It comes through a file, because a final chat message
// picks up narration while a file's first line is written on purpose. That line
// is the verdict, matched whole; anything unexpected only comments, never
// approves. The mark in front is added here, so it is never part of the match.
const REVIEW_DIR = await Deno.makeTempDir();
const REVIEW_PATH = `${REVIEW_DIR}/review.md`;
const VERDICTS: Record<string, [event: string, mark: string]> = {
Approved: ["APPROVED", "✅"],
"Changes requested": ["REQUEST_CHANGES", "🛑"],
};
// A finding about one line is posted on that line of the diff rather than as
// `file:line` prose in the body. Those anchors come as JSON, so the file and
// line are structured instead of parsed back out of English; a malformed entry
// fails the run, because a silently dropped finding is worse than a red run.
const ANCHORS_PATH = `${REVIEW_DIR}/anchors.json`;
type Anchor = { path: string; line: number; side: "new" | "old"; body: string };
function parseAnchors(text: string): Anchor[] {
const entries: unknown = JSON.parse(text);
if (!Array.isArray(entries)) throw new Error(`${ANCHORS_PATH}: not an array`);
return entries.map((entry: unknown, index) => {
const at = `${ANCHORS_PATH}[${index}]`;
if (typeof entry !== "object" || entry === null) {
throw new Error(`${at}: not an object`);
}
const { path, line, side = "new", body } = entry as Record<string, unknown>;
if (typeof path !== "string" || path === "") {
throw new Error(`${at}.path: expected a repository path`);
}
if (typeof line !== "number" || !Number.isInteger(line) || line < 1) {
throw new Error(`${at}.line: expected a line number`);
}
if (side !== "new" && side !== "old") {
throw new Error(`${at}.side: expected "new" or "old"`);
}
if (typeof body !== "string" || body.trim() === "") {
throw new Error(`${at}.body: expected the finding`);
}
return { path, line, side, body };
});
}
async function readAnchors(): Promise<Anchor[]> {
const written = await Deno.readTextFile(ANCHORS_PATH).catch(() => null);
return written === null ? [] : parseAnchors(written);
}
async function postResult(body: string): Promise<void> {
const review = await Deno.readTextFile(REVIEW_PATH).catch(() => null);
if (review === null) {
if (EVENT === "pull_request") {
throw new Error(`no review was written to ${REVIEW_PATH}`);
}
return postComment(body);
}
const [verdict, ...rest] = review.split("\n");
const [event, mark] = VERDICTS[verdict.trim()] ?? ["COMMENT", "💬"];
const post = (anchors: Anchor[]) =>
gitea(REVIEWER_TOKEN, `repos/${REPO}/pulls/${INDEX}/reviews`, {
body: stripAnsi([`${mark} ${verdict.trim()}`, ...rest].join("\n")),
event,
comments: anchors.map(({ path, line, side, body }) => ({
path,
body: stripAnsi(body),
new_position: side === "new" ? line : 0,
old_position: side === "old" ? line : 0,
})),
});
const anchors = await readAnchors();
// Gitea rejects the whole review when an anchor names a line outside the
// diff, and a verdict that never lands blocks the pull request, so the body
// goes up alone rather than not at all.
await post(anchors).catch(async (error: Error) => {
if (anchors.length === 0) throw error;
console.error(`inline comments rejected: ${error.message}`);
await post([]);
});
}
async function run(command: string, args: string[]): Promise<void> {
const { success, code } = await new Deno.Command(command, { args }).output();
async function runRoot(command: string, args: string[]): Promise<void> {
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}`);
}
// The reviewer uses Superpowers' requesting-code-review template. Both plugin
// installers are idempotent on the persisted bot home.
async function installSuperpowers(bot: BotType): Promise<void> {
for (const { command, args } of superpowersInstallCommands(bot)) {
await run(command, args);
}
}
async function prepareSuperpowers(
bot: BotType,
): Promise<SuperpowersReviewGuide> {
await installSuperpowers(bot);
const guide = await loadSuperpowersReviewGuide(bot, env("HOME"));
console.log(
`Loaded Superpowers requesting-code-review ${guide.version} from ${guide.skillPath}`,
);
console.log(
`Loaded Superpowers reviewer template from ${guide.templatePath}`,
);
return guide;
}
// Commits belong to the same account as the pull request they end up in.
async function configureGitAuthor(): Promise<void> {
const user: GiteaUser = await (await gitea(AUTHOR_TOKEN, "user")).json();
const user: GiteaUser = await (await gitea("user")).json();
for (const [key, value] of [["name", user.login], ["email", user.email]]) {
await run("git", ["config", "--global", `user.${key}`, value]);
await runRoot("git", ["config", `user.${key}`, value]);
}
}
async function renderPrompt(guide: SuperpowersReviewGuide): Promise<string> {
async function readProfileToken(
profile: RuntimeProfile,
): Promise<string | undefined> {
return profile.tokenFile === null
? undefined
: await Deno.readTextFile(profile.tokenFile);
}
async function runAgentCommand(
profile: RuntimeProfile,
command: string,
args: string[],
token?: string,
): Promise<void> {
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,
token: string | undefined,
): Promise<void> {
await runAgentCommand(profile, "git", [
"config",
"--global",
"--add",
"safe.directory",
WORKSPACE,
], token);
await runAgentCommand(profile, "npx", [
"-y",
...SKILLS_INSTALL.split(" "),
"--global",
"--agent",
profile.type === "claude" ? "claude-code" : "codex",
"--yes",
], token);
}
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(
REVIEWER_TOKEN,
`repos/${REPO}/issues/${INDEX}/comments?limit=100`,
)).json();
const values: Record<string, string> = {
COMMENT: env("COMMENT"),
ISSUE_COMMENTS: comments
.map((c) => `## ${c.user.login} at ${c.created_at}\n\n${c.body}\n`)
.map((comment) =>
`## ${comment.user.login} at ${comment.created_at}\n\n${comment.body}\n`
)
.join("\n"),
CODE_RULES: await (await gitea(REVIEWER_TOKEN, RULES_PATH)).text(),
CODE_RULES: await (await gitea(RULES_PATH)).text(),
EVENT_NAME: EVENT,
GITEA_API_URL: API,
GITEA_REPOSITORY: REPO,
ISSUE_INDEX: INDEX,
REVIEW_PATH,
ANCHORS_PATH,
SUPERPOWERS_REVIEW_SKILL: guide.skill,
SUPERPOWERS_REVIEW_TEMPLATE: guide.template,
REVIEW_PATH: reviewPath,
ANCHORS_PATH: anchorsPath,
};
const template = await Deno.readTextFile(
new URL("prompt.md", import.meta.url),
);
return template.replace(
/\$\{(\w+)\}/g,
(match, name) => values[name] ?? match,
);
return {
prompt: template.replace(
/\$\{(\w+)\}/g,
(match, name) => values[name] ?? match,
),
reviewPath,
anchorsPath,
};
}
async function runClaude(): Promise<string> {
const token = Deno.env.get("BOT_TOKEN");
if (!token) {
throw new Error(
"Run `claude setup-token` locally and set the `bot-token` action input.",
);
}
const prompt = await renderPrompt(await prepareSuperpowers("claude"));
const claude = new Deno.Command("claude", {
args: [
"--print",
"--dangerously-skip-permissions",
"--model",
model("claude"),
"--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" },
async function runClaude(
profile: RuntimeProfile,
token: string,
prompt: string,
): Promise<string> {
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: { result?: string; subtype: string } | undefined;
// Print events as they stream so the runner does not kill the job as a zombie.
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());
@@ -251,17 +212,29 @@ async function runClaude(): Promise<string> {
}
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;
}
// Posts the device code so a human can finish the login on the persisted home.
async function codexDeviceLogin(): Promise<void> {
const login = new Deno.Command("codex", {
args: ["login", "--device-auth"],
async function codexDeviceLogin(
profile: RuntimeProfile,
environment: Record<string, string>,
): Promise<void> {
const login = agentCommand(profile, "codex", ["login", "--device-auth"], {
cwd: WORKSPACE,
env: environment,
clearEnv: true,
stdout: "piped",
stderr: "piped",
}).spawn();
@@ -280,30 +253,83 @@ async function codexDeviceLogin(): Promise<void> {
]);
if (exited) throw new Error("AI bot login produced no instructions");
}
await postComment(shown);
await Promise.all([status, drained]);
await postAuthorComment(shown);
const [result] = await Promise.all([status, drained]);
if (!result.success) {
throw new Error(`codex login exited with ${result.code}`);
}
}
async function runCodex(): Promise<string> {
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();
async function codexApiLogin(
profile: RuntimeProfile,
environment: Record<string, string>,
token: string,
): Promise<void> {
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<string> {
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<string> {
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 = new Deno.Command("codex", {
args: [
"exec",
"--model",
model("codex"),
"--dangerously-bypass-approvals-and-sandbox",
"--output-last-message",
file,
prompt,
],
env: agentEnv,
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",
@@ -317,19 +343,78 @@ async function runCodex(): Promise<string> {
error += decoder.decode();
return { status: await codex.status, error };
};
const { status } = await retryInvalidToken(attempt, async () => {
await run("codex", ["logout"]);
await codexDeviceLogin();
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 runProfile(profile: RuntimeProfile): Promise<AttemptResult> {
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, token);
const rendered = await renderPrompt(profile);
reviewPath = rendered.reviewPath;
anchorsPath = rendered.anchorsPath;
if (profile.type === "claude") {
body = await runClaude(profile, token ?? "", rendered.prompt);
} else {
resultPath = await runCodex(profile, rendered.prompt, authentication!);
}
} 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 };
}
async function writeOutcome(outcome: RuntimeOutcome): Promise<void> {
await Deno.writeTextFile(MANIFEST.outcomeFile, JSON.stringify(outcome), {
mode: 0o600,
});
if (!status.success) throw new Error(`codex exited with ${status.code}`);
return await Deno.readTextFile(file);
await Deno.chmod(MANIFEST.outcomeFile, 0o600);
}
try {
await configureGitAuthor();
const result = BOT === "claude" ? await runClaude() : await runCodex();
await postResult(result);
await writeOutcome({
ok: true,
...await runProfiles(MANIFEST.profiles, runProfile),
});
} catch (error) {
await postComment(`Bot failed: ${error}`);
console.error(error);
await writeOutcome({ ok: false });
throw error;
}