Compare commits
3 Commits
v0.1.0
..
7bae0d7237
| Author | SHA1 | Date | |
|---|---|---|---|
| 7bae0d7237 | |||
| ba9e0c0787 | |||
| 58d3c12c25 |
@@ -0,0 +1,48 @@
|
||||
name: Agents
|
||||
run-name: >-
|
||||
AI bot at
|
||||
#${{ gitea.event.issue.number || gitea.event.pull_request.number }}
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
pull_request:
|
||||
types: [opened, review_requested]
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
|
||||
concurrency:
|
||||
group: >-
|
||||
agents-${{ gitea.event.sender.login == 'bot' && gitea.run_id ||
|
||||
gitea.event.issue.number || gitea.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
agent:
|
||||
if: >-
|
||||
gitea.actor != 'bot' && gitea.event.sender.login != 'bot' &&
|
||||
((gitea.event_name == 'issues' &&
|
||||
contains(gitea.event.issue.body, '@bot')) ||
|
||||
(gitea.event_name == 'pull_request' &&
|
||||
((gitea.event.action == 'opened' &&
|
||||
contains(gitea.event.pull_request.body, '@bot')) ||
|
||||
gitea.event.requested_reviewer.login == 'bot')) ||
|
||||
(gitea.event_name == 'issue_comment' &&
|
||||
contains(gitea.event.comment.body, '@bot')) ||
|
||||
(gitea.event_name == 'pull_request_review_comment' &&
|
||||
contains(gitea.event.review.content, '@bot')))
|
||||
permissions:
|
||||
code: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
runs-on: self-hosted
|
||||
container:
|
||||
volumes:
|
||||
- bot-home:/root
|
||||
steps:
|
||||
- name: Run AI bot
|
||||
uses: https://git.capsulizers.com/commons/bot-agents@main
|
||||
with:
|
||||
master-key: ${{ secrets.BOT_AGENTS_MASTER_KEY }}
|
||||
@@ -14,4 +14,8 @@ jobs:
|
||||
|
||||
- uses: denoland/setup-deno@v2
|
||||
|
||||
- run: deno fmt --check . && deno lint . && deno check run.ts
|
||||
- run: >-
|
||||
deno fmt --check . &&
|
||||
deno lint . &&
|
||||
deno check prepare.ts run.ts post.ts &&
|
||||
deno test --allow-all
|
||||
|
||||
+44
-30
@@ -2,30 +2,11 @@ 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`
|
||||
required: true
|
||||
author-token:
|
||||
master-key:
|
||||
description: >-
|
||||
Gitea token of the account that commits, pushes, and opens pull requests,
|
||||
with the `read:user`, `write:issue`, and `write:repository` scopes. The
|
||||
agent sees it as `GITEA_TOKEN`.
|
||||
Base64url-encoded 256-bit key used to decrypt the central bot profiles
|
||||
and reviewer credential.
|
||||
required: true
|
||||
reviewer-token:
|
||||
description: >-
|
||||
Gitea token of the bot account that posts comments and pull request
|
||||
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:
|
||||
@@ -33,23 +14,56 @@ runs:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ inputs.author-token }}
|
||||
token: ${{ gitea.token }}
|
||||
# This step assumes this is `node:24-bookworm` container.
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
run: npm install -g @openai/codex @anthropic-ai/claude-code deno
|
||||
- name: Run bot
|
||||
# A local action lives inside the writable checkout. Execute a root-owned
|
||||
# copy so an agent changing this repository cannot replace a later phase.
|
||||
- name: Protect action source
|
||||
id: source
|
||||
shell: bash
|
||||
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 }}
|
||||
RUNNER_TEMP: ${{ runner.temp }}
|
||||
run: |
|
||||
path=$(mktemp -d "${RUNNER_TEMP}/bot-agents-source-XXXXXX")
|
||||
cp -a "${ACTION_PATH}"/*.ts "${ACTION_PATH}/profiles.yml" \
|
||||
"${ACTION_PATH}/prompt.md" "${path}/"
|
||||
chmod -R go-w "${path}"
|
||||
echo "path=${path}" >> "${GITHUB_OUTPUT}"
|
||||
- name: Allow isolated profiles to edit the checkout
|
||||
shell: bash
|
||||
run: chmod -R a+rwX "${GITHUB_WORKSPACE}"
|
||||
- name: Prepare credentials
|
||||
id: prepare
|
||||
shell: bash
|
||||
run: deno run -A "${{ steps.source.outputs.path }}/prepare.ts"
|
||||
env:
|
||||
ACTION_PATH: ${{ steps.source.outputs.path }}
|
||||
BOT_AGENTS_MASTER_KEY: ${{ inputs.master-key }}
|
||||
RUNNER_TEMP: ${{ runner.temp }}
|
||||
- name: Run bot
|
||||
id: agent
|
||||
shell: bash
|
||||
run: deno run -A "${{ steps.source.outputs.path }}/run.ts"
|
||||
env:
|
||||
BOT_AGENTS_MANIFEST: ${{ steps.prepare.outputs.manifest }}
|
||||
GITEA_TOKEN: ${{ gitea.token }}
|
||||
GITEA_API_URL: ${{ gitea.api_url }}
|
||||
GITEA_REPOSITORY: ${{ gitea.repository }}
|
||||
GITEA_TOKEN: ${{ inputs.author-token }}
|
||||
REVIEWER_TOKEN: ${{ inputs.reviewer-token }}
|
||||
EVENT_NAME: ${{ gitea.event_name }}
|
||||
ISSUE_INDEX: ${{ gitea.event.issue.number || gitea.event.pull_request.number }}
|
||||
COMMENT: ${{ toJSON(gitea.event.comment || gitea.event.review) }}
|
||||
- name: Post result and remove credentials
|
||||
id: post
|
||||
if: always() && steps.prepare.outputs.manifest != ''
|
||||
shell: bash
|
||||
run: deno run -A "${{ steps.source.outputs.path }}/post.ts"
|
||||
env:
|
||||
BOT_AGENTS_MANIFEST: ${{ steps.prepare.outputs.manifest }}
|
||||
GITEA_API_URL: ${{ gitea.api_url }}
|
||||
GITEA_REPOSITORY: ${{ gitea.repository }}
|
||||
EVENT_NAME: ${{ gitea.event_name }}
|
||||
ISSUE_INDEX: ${{ gitea.event.issue.number || gitea.event.pull_request.number }}
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
import { dirname, join } from "jsr:@std/path@1";
|
||||
|
||||
export type CodexAuthentication =
|
||||
| { kind: "api-key"; token: string }
|
||||
| { kind: "persistent" };
|
||||
|
||||
export function codexAuthentication(token?: string): CodexAuthentication {
|
||||
return token === undefined
|
||||
? { kind: "persistent" }
|
||||
: { kind: "api-key", token };
|
||||
}
|
||||
|
||||
export async function retryInvalidToken<
|
||||
T extends { status: { success: boolean }; error: string },
|
||||
>(attempt: () => Promise<T>, relogin: () => Promise<void>): Promise<T> {
|
||||
@@ -10,3 +22,74 @@ export async function retryInvalidToken<
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function readCodexAuth(path: string): Promise<string> {
|
||||
const stat = await Deno.lstat(path);
|
||||
if (!stat.isFile || stat.isSymlink) {
|
||||
throw new Error("Codex login must be a regular file");
|
||||
}
|
||||
if ((stat.mode! & 0o077) !== 0) {
|
||||
throw new Error("Codex login must be private");
|
||||
}
|
||||
const text = await Deno.readTextFile(path);
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error("invalid Codex login");
|
||||
}
|
||||
const record = value as Record<string, unknown> | null;
|
||||
const tokens = record?.tokens as Record<string, unknown> | null;
|
||||
if (
|
||||
record === null || typeof record !== "object" ||
|
||||
typeof record.auth_mode !== "string" || record.auth_mode === "" ||
|
||||
tokens === null || typeof tokens !== "object" ||
|
||||
!["access_token", "refresh_token", "id_token", "account_id"].every(
|
||||
(name) => typeof tokens[name] === "string" && tokens[name] !== "",
|
||||
)
|
||||
) {
|
||||
throw new Error("invalid Codex login");
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
export async function stageCodexAuth(
|
||||
canonicalPath: string,
|
||||
profileHome: string,
|
||||
uid: number,
|
||||
gid: number,
|
||||
): Promise<string> {
|
||||
const codexHome = join(profileHome, ".codex");
|
||||
await Deno.mkdir(codexHome, { recursive: true, mode: 0o700 });
|
||||
await Deno.chmod(codexHome, 0o700);
|
||||
await Deno.chown(codexHome, uid, gid);
|
||||
const stagedPath = join(codexHome, "auth.json");
|
||||
try {
|
||||
await Deno.writeTextFile(stagedPath, await readCodexAuth(canonicalPath), {
|
||||
mode: 0o600,
|
||||
});
|
||||
await Deno.chmod(stagedPath, 0o600);
|
||||
await Deno.chown(stagedPath, uid, gid);
|
||||
} catch (error) {
|
||||
if (!(error instanceof Deno.errors.NotFound)) throw error;
|
||||
}
|
||||
return stagedPath;
|
||||
}
|
||||
|
||||
export async function persistCodexAuth(
|
||||
stagedPath: string,
|
||||
canonicalPath: string,
|
||||
): Promise<void> {
|
||||
const text = await readCodexAuth(stagedPath);
|
||||
const directory = dirname(canonicalPath);
|
||||
await Deno.mkdir(directory, { recursive: true, mode: 0o700 });
|
||||
const temporary = await Deno.makeTempFile({ dir: directory });
|
||||
try {
|
||||
await Deno.writeTextFile(temporary, text, { mode: 0o600 });
|
||||
await Deno.chmod(temporary, 0o600);
|
||||
await Deno.rename(temporary, canonicalPath);
|
||||
} catch (error) {
|
||||
await Deno.remove(temporary).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
+83
-2
@@ -1,5 +1,10 @@
|
||||
import { assertEquals } from "jsr:@std/assert@1";
|
||||
import { retryInvalidToken } from "./auth.ts";
|
||||
import { assertEquals, assertRejects } from "jsr:@std/assert@1";
|
||||
import {
|
||||
codexAuthentication,
|
||||
persistCodexAuth,
|
||||
retryInvalidToken,
|
||||
stageCodexAuth,
|
||||
} from "./auth.ts";
|
||||
|
||||
Deno.test("retries once after an invalid refresh token", async () => {
|
||||
let attempts = 0;
|
||||
@@ -42,3 +47,79 @@ Deno.test("does not retry an unrelated failure", async () => {
|
||||
|
||||
assertEquals({ attempts, relogins }, { attempts: 1, relogins: 0 });
|
||||
});
|
||||
|
||||
Deno.test("selects API-key login only for a configured Codex token", () => {
|
||||
assertEquals(codexAuthentication("selected-key"), {
|
||||
kind: "api-key",
|
||||
token: "selected-key",
|
||||
});
|
||||
assertEquals(codexAuthentication(undefined), { kind: "persistent" });
|
||||
});
|
||||
|
||||
const AUTH = JSON.stringify({
|
||||
auth_mode: "chatgpt",
|
||||
tokens: {
|
||||
access_token: "access",
|
||||
refresh_token: "refresh",
|
||||
id_token: "id",
|
||||
account_id: "account",
|
||||
},
|
||||
});
|
||||
|
||||
Deno.test("stages and persists only a regular private Codex login", async () => {
|
||||
if (Deno.build.os === "windows") return;
|
||||
const root = await Deno.makeTempDir();
|
||||
try {
|
||||
const canonical = `${root}/persistent/auth.json`;
|
||||
await Deno.mkdir(`${root}/persistent`);
|
||||
await Deno.writeTextFile(canonical, AUTH, { mode: 0o600 });
|
||||
const home = `${root}/profile`;
|
||||
await Deno.mkdir(home);
|
||||
|
||||
const staged = await stageCodexAuth(
|
||||
canonical,
|
||||
home,
|
||||
Deno.uid()!,
|
||||
Deno.gid()!,
|
||||
);
|
||||
assertEquals(await Deno.readTextFile(staged), AUTH);
|
||||
assertEquals((await Deno.stat(staged)).mode! & 0o777, 0o600);
|
||||
|
||||
const refreshed = JSON.stringify({
|
||||
...JSON.parse(AUTH),
|
||||
tokens: { ...JSON.parse(AUTH).tokens, access_token: "refreshed" },
|
||||
});
|
||||
await Deno.writeTextFile(staged, refreshed);
|
||||
await persistCodexAuth(staged, canonical);
|
||||
assertEquals(await Deno.readTextFile(canonical), refreshed);
|
||||
assertEquals((await Deno.stat(canonical)).mode! & 0o777, 0o600);
|
||||
} finally {
|
||||
await Deno.remove(root, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("rejects a symlink or malformed Codex login", async () => {
|
||||
if (Deno.build.os === "windows") return;
|
||||
const root = await Deno.makeTempDir();
|
||||
try {
|
||||
const canonical = `${root}/auth.json`;
|
||||
const malformed = `${root}/malformed.json`;
|
||||
const linked = `${root}/linked.json`;
|
||||
await Deno.writeTextFile(canonical, AUTH, { mode: 0o600 });
|
||||
await Deno.writeTextFile(malformed, '{"tokens":{}}', { mode: 0o600 });
|
||||
await Deno.symlink(canonical, linked);
|
||||
|
||||
await assertRejects(
|
||||
() => persistCodexAuth(linked, canonical),
|
||||
Error,
|
||||
"regular file",
|
||||
);
|
||||
await assertRejects(
|
||||
() => persistCodexAuth(malformed, canonical),
|
||||
Error,
|
||||
"invalid Codex login",
|
||||
);
|
||||
} finally {
|
||||
await Deno.remove(root, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
import { parse } from "jsr:@std/yaml@1";
|
||||
import type { BotProfile } from "./profiles.ts";
|
||||
|
||||
const DEFAULT_MODELS: Record<BotProfile["type"], string> = {
|
||||
claude: "claude-sonnet-5",
|
||||
codex: "gpt-5.6-terra",
|
||||
};
|
||||
|
||||
export type BotCredentials = {
|
||||
reviewerToken: string;
|
||||
profiles: BotProfile[];
|
||||
};
|
||||
|
||||
function decodeBase64Url(value: string): Uint8Array {
|
||||
const base64 = value.replaceAll("-", "+").replaceAll("_", "/");
|
||||
const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "=");
|
||||
return Uint8Array.from(atob(padded), (character) => character.charCodeAt(0));
|
||||
}
|
||||
|
||||
function encodeBase64Url(value: Uint8Array): string {
|
||||
const binary = Array.from(value, (byte) => String.fromCharCode(byte)).join(
|
||||
"",
|
||||
);
|
||||
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_")
|
||||
.replaceAll("=", "");
|
||||
}
|
||||
|
||||
const buffer = (value: Uint8Array): ArrayBuffer =>
|
||||
Uint8Array.from(value).buffer;
|
||||
|
||||
async function importMasterKey(
|
||||
encoded: string,
|
||||
usage: KeyUsage,
|
||||
): Promise<CryptoKey> {
|
||||
const bytes = decodeBase64Url(encoded);
|
||||
if (bytes.length !== 32) {
|
||||
throw new Error("master-key must be 32 bytes encoded as base64url");
|
||||
}
|
||||
return await crypto.subtle.importKey("raw", buffer(bytes), "AES-GCM", false, [
|
||||
usage,
|
||||
]);
|
||||
}
|
||||
|
||||
export async function decryptCredential(
|
||||
encrypted: string,
|
||||
masterKey: string,
|
||||
): Promise<string> {
|
||||
const [version, encodedNonce, encodedCiphertext, extra] = encrypted.split(
|
||||
":",
|
||||
);
|
||||
if (version !== "v1" || !encodedNonce || !encodedCiphertext || extra) {
|
||||
throw new Error("credential must use v1:<nonce>:<ciphertext>");
|
||||
}
|
||||
const nonce = decodeBase64Url(encodedNonce);
|
||||
if (nonce.length !== 12) throw new Error("credential nonce must be 12 bytes");
|
||||
const plaintext = await crypto.subtle.decrypt(
|
||||
{ name: "AES-GCM", iv: buffer(nonce) },
|
||||
await importMasterKey(masterKey, "decrypt"),
|
||||
buffer(decodeBase64Url(encodedCiphertext)),
|
||||
);
|
||||
return new TextDecoder().decode(plaintext);
|
||||
}
|
||||
|
||||
export async function encryptCredential(
|
||||
plaintext: string,
|
||||
masterKey: string,
|
||||
nonce: Uint8Array = crypto.getRandomValues(new Uint8Array(12)),
|
||||
): Promise<string> {
|
||||
if (nonce.length !== 12) throw new Error("AES-GCM nonce must be 12 bytes");
|
||||
const ciphertext = await crypto.subtle.encrypt(
|
||||
{ name: "AES-GCM", iv: buffer(nonce) },
|
||||
await importMasterKey(masterKey, "encrypt"),
|
||||
new TextEncoder().encode(plaintext),
|
||||
);
|
||||
return `v1:${encodeBase64Url(nonce)}:${
|
||||
encodeBase64Url(new Uint8Array(ciphertext))
|
||||
}`;
|
||||
}
|
||||
|
||||
export async function parseCredentials(
|
||||
text: string,
|
||||
masterKey: string,
|
||||
): Promise<BotCredentials> {
|
||||
const value: unknown = parse(text);
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
throw new Error("credentials must be a mapping");
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
for (const key of Object.keys(record)) {
|
||||
if (key !== "reviewer-token" && key !== "profiles") {
|
||||
throw new Error(`${key}: unknown field`);
|
||||
}
|
||||
}
|
||||
if (typeof record["reviewer-token"] !== "string") {
|
||||
throw new Error("reviewer-token: expected an encrypted credential");
|
||||
}
|
||||
if (!Array.isArray(record.profiles) || record.profiles.length === 0) {
|
||||
throw new Error("profiles must contain at least one profile");
|
||||
}
|
||||
const profiles = await Promise.all(
|
||||
record.profiles.map(async (entry: unknown, index): Promise<BotProfile> => {
|
||||
const at = `profiles[${index}]`;
|
||||
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
|
||||
throw new Error(`${at}: expected a mapping`);
|
||||
}
|
||||
const profile = entry as Record<string, unknown>;
|
||||
for (const key of Object.keys(profile)) {
|
||||
if (key !== "type" && key !== "model" && key !== "token") {
|
||||
throw new Error(`${at}.${key}: unknown field`);
|
||||
}
|
||||
}
|
||||
const { type, model, token } = profile;
|
||||
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 (type === "claude" && typeof token !== "string") {
|
||||
throw new Error(`${at}.token: expected an encrypted credential`);
|
||||
}
|
||||
if (token !== undefined && typeof token !== "string") {
|
||||
throw new Error(`${at}.token: expected an encrypted credential`);
|
||||
}
|
||||
return {
|
||||
type,
|
||||
model: model ?? DEFAULT_MODELS[type],
|
||||
...(typeof token === "string" && {
|
||||
token: await decryptCredential(token, masterKey),
|
||||
}),
|
||||
};
|
||||
}),
|
||||
);
|
||||
return {
|
||||
reviewerToken: await decryptCredential(record["reviewer-token"], masterKey),
|
||||
profiles,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { assertEquals, assertRejects } from "jsr:@std/assert@1";
|
||||
import {
|
||||
decryptCredential,
|
||||
encryptCredential,
|
||||
parseCredentials,
|
||||
} from "./credentials.ts";
|
||||
|
||||
const MASTER_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8";
|
||||
const OTHER_KEY = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE";
|
||||
const NONCE = Uint8Array.from({ length: 12 }, (_, index) => index + 32);
|
||||
const ENCRYPTED = "v1:ICEiIyQlJicoKSor:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA";
|
||||
|
||||
Deno.test("decrypts a known AES-256-GCM credential", async () => {
|
||||
assertEquals(
|
||||
await decryptCredential(ENCRYPTED, MASTER_KEY),
|
||||
"secret-token",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("encrypts a credential with a fresh supplied nonce", async () => {
|
||||
assertEquals(
|
||||
await encryptCredential("secret-token", MASTER_KEY, NONCE),
|
||||
ENCRYPTED,
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("rejects a credential encrypted by another master key", async () => {
|
||||
await assertRejects(() => decryptCredential(ENCRYPTED, OTHER_KEY));
|
||||
});
|
||||
|
||||
Deno.test("rejects a credential with a nonstandard nonce", async () => {
|
||||
await assertRejects(
|
||||
() =>
|
||||
decryptCredential(
|
||||
"v1:AA:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA",
|
||||
MASTER_KEY,
|
||||
),
|
||||
Error,
|
||||
"nonce must be 12 bytes",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("decrypts the central reviewer token and ordered profiles", async () => {
|
||||
assertEquals(
|
||||
await parseCredentials(
|
||||
`
|
||||
reviewer-token: ${ENCRYPTED}
|
||||
profiles:
|
||||
- type: claude
|
||||
token: ${ENCRYPTED}
|
||||
- type: codex
|
||||
model: gpt-custom
|
||||
`,
|
||||
MASTER_KEY,
|
||||
),
|
||||
{
|
||||
reviewerToken: "secret-token",
|
||||
profiles: [
|
||||
{
|
||||
type: "claude",
|
||||
model: "claude-sonnet-5",
|
||||
token: "secret-token",
|
||||
},
|
||||
{ type: "codex", model: "gpt-custom" },
|
||||
],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("rejects malformed central credentials", async () => {
|
||||
await assertRejects(
|
||||
() => parseCredentials("profiles: []", MASTER_KEY),
|
||||
Error,
|
||||
"reviewer-token",
|
||||
);
|
||||
await assertRejects(
|
||||
() =>
|
||||
parseCredentials(
|
||||
`reviewer-token: ${ENCRYPTED}\nprofiles:\n - type: claude`,
|
||||
MASTER_KEY,
|
||||
),
|
||||
Error,
|
||||
"profiles[0].token",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { RuntimeProfile } from "./runtime.ts";
|
||||
|
||||
export function agentCommand(
|
||||
profile: RuntimeProfile,
|
||||
command: string,
|
||||
args: string[],
|
||||
options: Omit<Deno.CommandOptions, "args" | "uid" | "gid"> = {},
|
||||
): Deno.Command {
|
||||
return new Deno.Command(command, {
|
||||
...options,
|
||||
args,
|
||||
uid: profile.uid,
|
||||
gid: profile.gid,
|
||||
});
|
||||
}
|
||||
|
||||
export function isRunnableProfileProcess(
|
||||
status: string,
|
||||
uid: number,
|
||||
): boolean {
|
||||
const owner = status.match(/^Uid:\s+(\d+)/m)?.[1];
|
||||
const state = status.match(/^State:\s+(\w)/m)?.[1];
|
||||
return owner !== undefined && Number(owner) === uid && state !== "Z";
|
||||
}
|
||||
|
||||
async function profileProcessIds(uid: number): Promise<number[]> {
|
||||
const ids: number[] = [];
|
||||
for await (const entry of Deno.readDir("/proc")) {
|
||||
if (!entry.isDirectory || !/^\d+$/.test(entry.name)) continue;
|
||||
const status = await Deno.readTextFile(`/proc/${entry.name}/status`).catch(
|
||||
() => null,
|
||||
);
|
||||
if (status !== null && isRunnableProfileProcess(status, uid)) {
|
||||
ids.push(Number(entry.name));
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
export async function killProfileProcesses(uid: number): Promise<void> {
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
const ids = await profileProcessIds(uid);
|
||||
if (ids.length === 0) return;
|
||||
for (const pid of ids) {
|
||||
try {
|
||||
Deno.kill(pid, "SIGKILL");
|
||||
} catch (error) {
|
||||
if (!(error instanceof Deno.errors.NotFound)) throw error;
|
||||
}
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
throw new Error(`profile UID ${uid} still owns processes after cleanup`);
|
||||
}
|
||||
|
||||
export async function readProfileFile(
|
||||
profile: RuntimeProfile,
|
||||
path: string,
|
||||
): Promise<string> {
|
||||
const stat = await Deno.lstat(path);
|
||||
if (
|
||||
!stat.isFile || stat.isSymlink || stat.uid !== profile.uid ||
|
||||
(stat.mode! & 0o077) !== 0
|
||||
) {
|
||||
throw new Error(`${path}: expected a profile-owned regular private file`);
|
||||
}
|
||||
return await Deno.readTextFile(path);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { assertEquals, assertRejects } from "jsr:@std/assert@1";
|
||||
import {
|
||||
agentCommand,
|
||||
isRunnableProfileProcess,
|
||||
killProfileProcesses,
|
||||
readProfileFile,
|
||||
} from "./isolation.ts";
|
||||
import type { RuntimeProfile } from "./runtime.ts";
|
||||
|
||||
const profile = (uid: number, home: string): RuntimeProfile => ({
|
||||
index: uid - 20_000,
|
||||
type: "claude",
|
||||
model: "test",
|
||||
uid,
|
||||
gid: uid,
|
||||
home,
|
||||
tokenFile: null,
|
||||
});
|
||||
|
||||
Deno.test("zombie process entries are already quiescent", () => {
|
||||
const status = "State:\tZ (zombie)\nUid:\t20010\t20010\t20010\t20010\n";
|
||||
assertEquals(isRunnableProfileProcess(status, 20_010), false);
|
||||
});
|
||||
|
||||
Deno.test({
|
||||
name: "refuses to read a profile result symlink as root",
|
||||
permissions: { read: true, write: true, sys: ["uid"] },
|
||||
async fn() {
|
||||
if (Deno.build.os === "windows") return;
|
||||
const root = await Deno.makeTempDir();
|
||||
try {
|
||||
const secret = `${root}/reviewer-token`;
|
||||
const result = `${root}/result.md`;
|
||||
await Deno.writeTextFile(secret, "reviewer-secret", { mode: 0o600 });
|
||||
await Deno.symlink(secret, result);
|
||||
|
||||
await assertRejects(
|
||||
() => readProfileFile(profile(Deno.uid()!, root), result),
|
||||
Error,
|
||||
"regular private file",
|
||||
);
|
||||
} finally {
|
||||
await Deno.remove(root, { recursive: true });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
Deno.test({
|
||||
name: "a profile process can read only its own private files",
|
||||
permissions: { read: true, write: true, run: true, sys: ["uid"] },
|
||||
async fn() {
|
||||
if (Deno.uid() !== 0) return;
|
||||
const root = await Deno.makeTempDir();
|
||||
await Deno.chmod(root, 0o711);
|
||||
try {
|
||||
const homeA = `${root}/a`;
|
||||
const homeB = `${root}/b`;
|
||||
await Deno.mkdir(homeA, { mode: 0o700 });
|
||||
await Deno.mkdir(homeB, { mode: 0o700 });
|
||||
await Deno.chown(homeA, 20_000, 20_000);
|
||||
await Deno.chown(homeB, 20_001, 20_001);
|
||||
await Deno.writeTextFile(`${homeA}/token`, "own", { mode: 0o600 });
|
||||
await Deno.chown(`${homeA}/token`, 20_000, 20_000);
|
||||
await Deno.writeTextFile(`${homeB}/token`, "sibling", { mode: 0o600 });
|
||||
await Deno.chown(`${homeB}/token`, 20_001, 20_001);
|
||||
await Deno.writeTextFile(`${root}/reviewer`, "root", { mode: 0o600 });
|
||||
|
||||
const selected = profile(20_000, homeA);
|
||||
const own = await agentCommand(selected, "/bin/cat", [`${homeA}/token`], {
|
||||
cwd: "/",
|
||||
}).output();
|
||||
const sibling = await agentCommand(selected, "/bin/cat", [
|
||||
`${homeB}/token`,
|
||||
], { cwd: "/" }).output();
|
||||
const reviewer = await agentCommand(selected, "/bin/cat", [
|
||||
`${root}/reviewer`,
|
||||
], { cwd: "/" }).output();
|
||||
|
||||
assertEquals(new TextDecoder().decode(own.stdout), "own");
|
||||
assertEquals(own.success, true);
|
||||
assertEquals(sibling.success, false);
|
||||
assertEquals(reviewer.success, false);
|
||||
} finally {
|
||||
await Deno.remove(root, { recursive: true });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
Deno.test({
|
||||
name: "cleanup leaves no runnable daemonized process",
|
||||
permissions: "inherit",
|
||||
async fn() {
|
||||
if (Deno.uid() !== 0) return;
|
||||
const selected = profile(20_010, "/tmp");
|
||||
const launched = await agentCommand(selected, "/bin/sh", [
|
||||
"-c",
|
||||
"sleep 60 >/dev/null 2>&1 & echo $!",
|
||||
], { cwd: "/" }).output();
|
||||
const pid = Number(new TextDecoder().decode(launched.stdout).trim());
|
||||
assertEquals(Number.isInteger(pid), true);
|
||||
|
||||
await killProfileProcesses(selected.uid);
|
||||
|
||||
const status = await Deno.readTextFile(`/proc/${pid}/status`).catch(
|
||||
(error) => {
|
||||
if (error instanceof Deno.errors.NotFound) return null;
|
||||
throw error;
|
||||
},
|
||||
);
|
||||
assertEquals(
|
||||
status === null || !isRunnableProfileProcess(status, selected.uid),
|
||||
true,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
Deno.test({
|
||||
name: "the agent process cannot read its coordinator environment",
|
||||
permissions: { run: true, sys: ["uid"] },
|
||||
async fn() {
|
||||
if (Deno.uid() !== 0) return;
|
||||
const selected = profile(20_020, "/tmp");
|
||||
const result = await agentCommand(selected, "/usr/bin/cat", [
|
||||
`/proc/${Deno.pid}/environ`,
|
||||
], {
|
||||
cwd: "/",
|
||||
env: { PARENT_SECRET: "must-not-cross-boundary" },
|
||||
clearEnv: true,
|
||||
stderr: "piped",
|
||||
}).output();
|
||||
|
||||
assertEquals(result.success, false);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
import { join } from "jsr:@std/path@1";
|
||||
import {
|
||||
readRuntimeManifest,
|
||||
type RuntimeManifest,
|
||||
type RuntimeOutcome,
|
||||
} from "./runtime.ts";
|
||||
|
||||
export type PostContext = {
|
||||
apiUrl: string;
|
||||
repository: string;
|
||||
issueIndex: string;
|
||||
eventName: string;
|
||||
};
|
||||
|
||||
type Anchor = {
|
||||
path: string;
|
||||
line: number;
|
||||
side: "new" | "old";
|
||||
body: string;
|
||||
};
|
||||
|
||||
const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*[A-Za-z]`, "g");
|
||||
const stripAnsi = (text: string): string => text.replace(ANSI, "");
|
||||
const VERDICTS: Record<string, [event: string, mark: string]> = {
|
||||
Approved: ["APPROVED", "✅"],
|
||||
"Changes requested": ["REQUEST_CHANGES", "🛑"],
|
||||
};
|
||||
|
||||
function parseAnchors(text: string, sourcePath: string): Anchor[] {
|
||||
const entries: unknown = JSON.parse(text);
|
||||
if (!Array.isArray(entries)) throw new Error(`${sourcePath}: not an array`);
|
||||
return entries.map((entry: unknown, index) => {
|
||||
const at = `${sourcePath}[${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 readOutcome(path: string): Promise<RuntimeOutcome> {
|
||||
const value: unknown = JSON.parse(await Deno.readTextFile(path));
|
||||
if (typeof value !== "object" || value === null || !("ok" in value)) {
|
||||
throw new Error("invalid agent outcome");
|
||||
}
|
||||
const outcome = value as Record<string, unknown>;
|
||||
if (outcome.ok === false) return { ok: false };
|
||||
if (
|
||||
outcome.ok !== true || typeof outcome.body !== "string" ||
|
||||
typeof outcome.reviewPath !== "string" ||
|
||||
typeof outcome.anchorsPath !== "string"
|
||||
) {
|
||||
throw new Error("invalid agent outcome");
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
body: outcome.body,
|
||||
reviewPath: outcome.reviewPath,
|
||||
anchorsPath: outcome.anchorsPath,
|
||||
};
|
||||
}
|
||||
|
||||
async function readAgentArtifact(
|
||||
manifest: RuntimeManifest,
|
||||
path: string,
|
||||
name: string,
|
||||
): Promise<string | null> {
|
||||
const profile = manifest.profiles.find((entry) =>
|
||||
join(entry.home, name) === path
|
||||
);
|
||||
if (profile === undefined) throw new Error(`${name}: outside profile home`);
|
||||
let stat: Deno.FileInfo;
|
||||
try {
|
||||
stat = await Deno.lstat(path);
|
||||
} catch (error) {
|
||||
if (error instanceof Deno.errors.NotFound) return null;
|
||||
throw error;
|
||||
}
|
||||
if (
|
||||
!stat.isFile || stat.isSymlink || stat.uid !== profile.uid ||
|
||||
(stat.mode! & 0o077) !== 0
|
||||
) {
|
||||
throw new Error(`${name}: expected a profile-owned regular private file`);
|
||||
}
|
||||
const text = await Deno.readTextFile(path);
|
||||
return text === "" ? null : text;
|
||||
}
|
||||
|
||||
export async function postRuntime(
|
||||
manifestPath: string,
|
||||
context: PostContext,
|
||||
fetcher: typeof fetch = fetch,
|
||||
): Promise<void> {
|
||||
const manifest = await readRuntimeManifest(manifestPath);
|
||||
try {
|
||||
const reviewerToken = (await Deno.readTextFile(manifest.reviewerFile))
|
||||
.trim();
|
||||
const request = async (path: string, body: unknown): Promise<void> => {
|
||||
const response = await fetcher(`${context.apiUrl}/${path}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `token ${reviewerToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`${path}: ${response.status} ${await response.text()}`);
|
||||
}
|
||||
};
|
||||
const postComment = (body: string) =>
|
||||
request(
|
||||
`repos/${context.repository}/issues/${context.issueIndex}/comments`,
|
||||
{ body: stripAnsi(body) },
|
||||
);
|
||||
|
||||
let outcome: RuntimeOutcome;
|
||||
try {
|
||||
outcome = await readOutcome(manifest.outcomeFile);
|
||||
} catch (error) {
|
||||
await postComment("AI bot failed.");
|
||||
throw new Error("agent phase failed", { cause: error });
|
||||
}
|
||||
if (!outcome.ok) {
|
||||
await postComment("AI bot failed.");
|
||||
throw new Error("agent phase failed");
|
||||
}
|
||||
|
||||
const review = await readAgentArtifact(
|
||||
manifest,
|
||||
outcome.reviewPath,
|
||||
"review.md",
|
||||
);
|
||||
if (review === null) {
|
||||
if (context.eventName === "pull_request") {
|
||||
throw new Error("agent did not write a pull request review");
|
||||
}
|
||||
await postComment(outcome.body);
|
||||
return;
|
||||
}
|
||||
|
||||
const [verdict, ...rest] = review.split("\n");
|
||||
const [event, mark] = VERDICTS[verdict.trim()] ?? ["COMMENT", "💬"];
|
||||
const anchorText = await readAgentArtifact(
|
||||
manifest,
|
||||
outcome.anchorsPath,
|
||||
"anchors.json",
|
||||
);
|
||||
const anchors = anchorText === null
|
||||
? []
|
||||
: parseAnchors(anchorText, outcome.anchorsPath);
|
||||
const postReview = (entries: Anchor[]) =>
|
||||
request(
|
||||
`repos/${context.repository}/pulls/${context.issueIndex}/reviews`,
|
||||
{
|
||||
body: stripAnsi([`${mark} ${verdict.trim()}`, ...rest].join("\n")),
|
||||
event,
|
||||
comments: entries.map(({ path, line, side, body }) => ({
|
||||
path,
|
||||
body: stripAnsi(body),
|
||||
new_position: side === "new" ? line : 0,
|
||||
old_position: side === "old" ? line : 0,
|
||||
})),
|
||||
},
|
||||
);
|
||||
await postReview(anchors).catch(async (error: Error) => {
|
||||
if (anchors.length === 0) throw error;
|
||||
console.error(`inline comments rejected: ${error.message}`);
|
||||
await postReview([]);
|
||||
});
|
||||
} finally {
|
||||
await Deno.remove(manifest.runtimeDir, { recursive: true }).catch(
|
||||
(error) => {
|
||||
if (!(error instanceof Deno.errors.NotFound)) throw error;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
const required = (name: string): string => {
|
||||
const value = Deno.env.get(name);
|
||||
if (!value) throw new Error(`${name} is not set`);
|
||||
return value;
|
||||
};
|
||||
await postRuntime(required("BOT_AGENTS_MANIFEST"), {
|
||||
apiUrl: required("GITEA_API_URL"),
|
||||
repository: required("GITEA_REPOSITORY"),
|
||||
issueIndex: required("ISSUE_INDEX"),
|
||||
eventName: required("EVENT_NAME"),
|
||||
});
|
||||
}
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
import { assert, assertEquals, assertRejects } from "jsr:@std/assert@1";
|
||||
import { type PostContext, postRuntime } from "./post.ts";
|
||||
import type { RuntimeManifest, RuntimeOutcome } from "./runtime.ts";
|
||||
|
||||
const context: PostContext = {
|
||||
apiUrl: "https://gitea.example/api/v1",
|
||||
repository: "owner/repo",
|
||||
issueIndex: "7",
|
||||
eventName: "issue_comment",
|
||||
};
|
||||
|
||||
async function fixture(
|
||||
source: { ok: false } | { ok: true; body: string },
|
||||
review?: string,
|
||||
anchors?: unknown,
|
||||
): Promise<{
|
||||
manifestPath: string;
|
||||
runtimeDir: string;
|
||||
reviewerFile: string;
|
||||
reviewPath: string;
|
||||
outcomeFile: string;
|
||||
}> {
|
||||
const runtimeDir = await Deno.makeTempDir();
|
||||
const reviewerFile = `${runtimeDir}/reviewer-token`;
|
||||
const outcomeFile = `${runtimeDir}/outcome.json`;
|
||||
const profileHome = `${runtimeDir}/profile-0`;
|
||||
await Deno.mkdir(profileHome);
|
||||
await Deno.writeTextFile(reviewerFile, "reviewer-secret");
|
||||
const outcome: RuntimeOutcome = source.ok
|
||||
? {
|
||||
...source,
|
||||
reviewPath: `${profileHome}/review.md`,
|
||||
anchorsPath: `${profileHome}/anchors.json`,
|
||||
}
|
||||
: source;
|
||||
await Deno.writeTextFile(outcomeFile, JSON.stringify(outcome));
|
||||
if (outcome.ok) {
|
||||
if (review !== undefined) {
|
||||
await Deno.writeTextFile(outcome.reviewPath, review, { mode: 0o600 });
|
||||
await Deno.chmod(outcome.reviewPath, 0o600);
|
||||
}
|
||||
if (anchors !== undefined) {
|
||||
await Deno.writeTextFile(outcome.anchorsPath, JSON.stringify(anchors), {
|
||||
mode: 0o600,
|
||||
});
|
||||
await Deno.chmod(outcome.anchorsPath, 0o600);
|
||||
}
|
||||
}
|
||||
const manifestPath = `${runtimeDir}/manifest.json`;
|
||||
const manifest: RuntimeManifest = {
|
||||
runtimeDir,
|
||||
manifestPath,
|
||||
reviewerFile,
|
||||
outcomeFile,
|
||||
profiles: [{
|
||||
index: 0,
|
||||
type: "claude",
|
||||
model: "test",
|
||||
uid: Deno.uid()!,
|
||||
gid: Deno.gid()!,
|
||||
home: profileHome,
|
||||
tokenFile: null,
|
||||
}],
|
||||
};
|
||||
await Deno.writeTextFile(manifestPath, JSON.stringify(manifest));
|
||||
return {
|
||||
manifestPath,
|
||||
runtimeDir,
|
||||
reviewerFile,
|
||||
reviewPath: `${profileHome}/review.md`,
|
||||
outcomeFile,
|
||||
};
|
||||
}
|
||||
|
||||
const missing = async (path: string): Promise<boolean> => {
|
||||
try {
|
||||
await Deno.stat(path);
|
||||
return false;
|
||||
} catch (error) {
|
||||
if (error instanceof Deno.errors.NotFound) return true;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
Deno.test("posts a successful issue result and removes runtime credentials", async () => {
|
||||
const result = await fixture({ ok: true, body: "done" });
|
||||
const requests: Array<{ url: string; init?: RequestInit }> = [];
|
||||
|
||||
await postRuntime(result.manifestPath, context, (url, init) => {
|
||||
requests.push({ url: String(url), init });
|
||||
return Promise.resolve(new Response(null, { status: 201 }));
|
||||
});
|
||||
|
||||
assertEquals(requests.length, 1);
|
||||
assertEquals(
|
||||
requests[0].url,
|
||||
`${context.apiUrl}/repos/owner/repo/issues/7/comments`,
|
||||
);
|
||||
assertEquals(JSON.parse(String(requests[0].init?.body)), { body: "done" });
|
||||
assertEquals(requests[0].init?.headers, {
|
||||
Authorization: "token reviewer-secret",
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
assert(await missing(result.runtimeDir));
|
||||
});
|
||||
|
||||
Deno.test("retries a rejected inline review without anchors", async () => {
|
||||
const result = await fixture(
|
||||
{ ok: true, body: "ignored final response" },
|
||||
"Approved\nReady",
|
||||
[{ path: "run.ts", line: 1, side: "new", body: "Finding" }],
|
||||
);
|
||||
const bodies: unknown[] = [];
|
||||
|
||||
await postRuntime(result.manifestPath, {
|
||||
...context,
|
||||
eventName: "pull_request",
|
||||
}, (_url, init) => {
|
||||
bodies.push(JSON.parse(String(init?.body)));
|
||||
return Promise.resolve(
|
||||
new Response(null, { status: bodies.length === 1 ? 422 : 200 }),
|
||||
);
|
||||
});
|
||||
|
||||
assertEquals((bodies[0] as { comments: unknown[] }).comments.length, 1);
|
||||
assertEquals((bodies[1] as { comments: unknown[] }).comments, []);
|
||||
assert(await missing(result.runtimeDir));
|
||||
});
|
||||
|
||||
Deno.test("reports a generic agent failure and keeps the action failed", async () => {
|
||||
const result = await fixture({ ok: false });
|
||||
const bodies: unknown[] = [];
|
||||
|
||||
await assertRejects(
|
||||
() =>
|
||||
postRuntime(result.manifestPath, context, (_url, init) => {
|
||||
bodies.push(JSON.parse(String(init?.body)));
|
||||
return Promise.resolve(new Response(null, { status: 201 }));
|
||||
}),
|
||||
Error,
|
||||
"agent phase failed",
|
||||
);
|
||||
|
||||
assertEquals(bodies, [{ body: "AI bot failed." }]);
|
||||
assert(!JSON.stringify(bodies).includes("reviewer-secret"));
|
||||
assert(await missing(result.runtimeDir));
|
||||
});
|
||||
|
||||
Deno.test("reports a generic failure when the runner wrote no outcome", async () => {
|
||||
const result = await fixture({ ok: false });
|
||||
await Deno.remove(result.outcomeFile);
|
||||
const bodies: unknown[] = [];
|
||||
|
||||
await assertRejects(
|
||||
() =>
|
||||
postRuntime(result.manifestPath, context, (_url, init) => {
|
||||
bodies.push(JSON.parse(String(init?.body)));
|
||||
return Promise.resolve(new Response(null, { status: 201 }));
|
||||
}),
|
||||
Error,
|
||||
"agent phase failed",
|
||||
);
|
||||
|
||||
assertEquals(bodies, [{ body: "AI bot failed." }]);
|
||||
assert(await missing(result.runtimeDir));
|
||||
});
|
||||
|
||||
Deno.test("removes runtime credentials when posting fails", async () => {
|
||||
const result = await fixture({ ok: true, body: "done" });
|
||||
|
||||
await assertRejects(
|
||||
() =>
|
||||
postRuntime(
|
||||
result.manifestPath,
|
||||
context,
|
||||
() => Promise.resolve(new Response("failed", { status: 500 })),
|
||||
),
|
||||
Error,
|
||||
"500",
|
||||
);
|
||||
assert(await missing(result.runtimeDir));
|
||||
});
|
||||
|
||||
Deno.test("refuses to follow an agent artifact symlink", async () => {
|
||||
if (Deno.build.os === "windows") return;
|
||||
const result = await fixture({ ok: true, body: "done" });
|
||||
await Deno.symlink(result.reviewerFile, result.reviewPath);
|
||||
let requests = 0;
|
||||
|
||||
await assertRejects(
|
||||
() =>
|
||||
postRuntime(result.manifestPath, context, () => {
|
||||
requests++;
|
||||
return Promise.resolve(new Response(null, { status: 200 }));
|
||||
}),
|
||||
Error,
|
||||
"regular private file",
|
||||
);
|
||||
|
||||
assertEquals(requests, 0);
|
||||
assert(await missing(result.runtimeDir));
|
||||
});
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import { prepareRuntime } from "./runtime.ts";
|
||||
|
||||
const env = (name: string): string => {
|
||||
const value = Deno.env.get(name);
|
||||
if (!value) throw new Error(`${name} is not set`);
|
||||
return value;
|
||||
};
|
||||
|
||||
const manifest = await prepareRuntime(
|
||||
env("ACTION_PATH"),
|
||||
env("RUNNER_TEMP"),
|
||||
env("BOT_AGENTS_MASTER_KEY"),
|
||||
);
|
||||
try {
|
||||
await Deno.writeTextFile(
|
||||
env("GITHUB_OUTPUT"),
|
||||
`manifest=${manifest.manifestPath}\n`,
|
||||
{ append: true },
|
||||
);
|
||||
} catch (error) {
|
||||
await Deno.remove(manifest.runtimeDir, { recursive: true }).catch(() =>
|
||||
undefined
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
export type BotProfile = {
|
||||
type: "claude" | "codex";
|
||||
model: string;
|
||||
token?: string;
|
||||
};
|
||||
|
||||
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<
|
||||
T,
|
||||
P extends Pick<BotProfile, "type" | "model">,
|
||||
>(
|
||||
profiles: P[],
|
||||
attempt: (profile: P) => Promise<T>,
|
||||
): Promise<T> {
|
||||
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<string, string>,
|
||||
): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (
|
||||
const name of [
|
||||
"PATH",
|
||||
"HOME",
|
||||
"LANG",
|
||||
"LANGUAGE",
|
||||
"LC_ALL",
|
||||
"TERM",
|
||||
"COLORTERM",
|
||||
"CI",
|
||||
"TMPDIR",
|
||||
"GITEA_TOKEN",
|
||||
"GITEA_API_URL",
|
||||
"GITEA_REPOSITORY",
|
||||
"GITHUB_WORKSPACE",
|
||||
"HTTP_PROXY",
|
||||
"HTTPS_PROXY",
|
||||
"ALL_PROXY",
|
||||
"NO_PROXY",
|
||||
"http_proxy",
|
||||
"https_proxy",
|
||||
"all_proxy",
|
||||
"no_proxy",
|
||||
"SSL_CERT_FILE",
|
||||
"SSL_CERT_DIR",
|
||||
"NODE_EXTRA_CA_CERTS",
|
||||
]
|
||||
) {
|
||||
if (environment[name] !== undefined) result[name] = environment[name];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function agentEnvironment(
|
||||
environment: Record<string, string>,
|
||||
profile: Pick<BotProfile, "type"> & { home: string },
|
||||
token?: string,
|
||||
): Record<string, string> {
|
||||
const result = sanitizedEnvironment(environment);
|
||||
result.HOME = profile.home;
|
||||
if (profile.type === "codex") result.CODEX_HOME = `${profile.home}/.codex`;
|
||||
if (token) {
|
||||
result[
|
||||
profile.type === "claude" ? "CLAUDE_CODE_OAUTH_TOKEN" : "OPENAI_API_KEY"
|
||||
] = token;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
reviewer-token: v1:XFUvCI9PnXVaW-ig:_3S9BvXywUp29cVYGA_EzFScKwC5oHkV9x2Ld8k-V_uiG7iA8n3fmjXCArzh2Lz-JDgy3IMTHoY
|
||||
profiles:
|
||||
- type: claude
|
||||
model: claude-sonnet-5
|
||||
token: v1:-dyfF6ZicEhjPk4C:WCuMaGd09tYjtzUCl7abloRzPITvP5C35WpR372w3O_tcaD-ls-yV5sJBdU8iIVApZVCC98sY2gOyM3o9RMa8ciJq8Fuys7KxlYOp8QiluL9AYMSQi6rEsSH71EPWnxSACgo3uDQY8bWQpUlEZvXH9O-ZN9A58dh6b3vRg
|
||||
- type: codex
|
||||
model: gpt-5.6-terra
|
||||
@@ -0,0 +1,160 @@
|
||||
import { assertEquals, assertRejects } from "jsr:@std/assert@1";
|
||||
import {
|
||||
agentEnvironment,
|
||||
runProfiles,
|
||||
sanitizedEnvironment,
|
||||
} from "./profiles.ts";
|
||||
|
||||
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 = {
|
||||
PATH: "/usr/bin",
|
||||
HOME: "/home/bot",
|
||||
UNRELATED_SECRET: "must-not-reach-agent",
|
||||
BOT_AGENTS_MASTER_KEY: "master",
|
||||
PROFILES: "contains every secret",
|
||||
REVIEWER_TOKEN: "reviewer",
|
||||
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",
|
||||
};
|
||||
const profile = {
|
||||
index: 0,
|
||||
type: "claude" as const,
|
||||
model: "claude-sonnet-5",
|
||||
uid: 20_000,
|
||||
gid: 20_000,
|
||||
home: "/runtime/profile-0",
|
||||
tokenFile: "/runtime/profile-0/token",
|
||||
};
|
||||
|
||||
assertEquals(
|
||||
agentEnvironment(base, profile, "selected"),
|
||||
{
|
||||
PATH: "/usr/bin",
|
||||
HOME: "/runtime/profile-0",
|
||||
CLAUDE_CODE_OAUTH_TOKEN: "selected",
|
||||
},
|
||||
);
|
||||
assertEquals(sanitizedEnvironment(base), {
|
||||
PATH: "/usr/bin",
|
||||
HOME: "/home/bot",
|
||||
});
|
||||
});
|
||||
|
||||
Deno.test("uses a private Codex home without inventing an API credential", () => {
|
||||
const profile = {
|
||||
index: 1,
|
||||
type: "codex" as const,
|
||||
model: "gpt-5.6-terra",
|
||||
uid: 20_001,
|
||||
gid: 20_001,
|
||||
home: "/runtime/profile-1",
|
||||
tokenFile: null,
|
||||
};
|
||||
|
||||
assertEquals(agentEnvironment({ PATH: "/bin" }, profile), {
|
||||
PATH: "/bin",
|
||||
HOME: "/runtime/profile-1",
|
||||
CODEX_HOME: "/runtime/profile-1/.codex",
|
||||
});
|
||||
});
|
||||
@@ -19,19 +19,26 @@ author's push credentials. Read the code there, run its checks and tests when
|
||||
they bear on the task, and push from there.
|
||||
|
||||
For a `pull_request` event, and for a comment on a pull request that asks you to
|
||||
review it, review the PR without changing code against its base and head. Run
|
||||
the project's checks on the head and treat a failure as at least Important.
|
||||
Check the whole repository against the code rules at the end of this prompt, not
|
||||
only the diff; a violation is at least Important even when the diff did not
|
||||
cause it. Write the complete review, and nothing else, to the file
|
||||
`${REVIEW_PATH}`: it is posted verbatim as a pull request review from the bot
|
||||
account, and your final response is not posted at all. The file's first line
|
||||
must be exactly the verdict and nothing else: `Approved` when the head is ready
|
||||
to merge, `Changes requested` otherwise. The mark in front of it is added when
|
||||
posting, so write the words alone; any other first line is posted as a plain
|
||||
comment, which wastes the run. Minor issues alone never block, and neither does
|
||||
a finding the author has answered in the comment history below as intended or a
|
||||
false alarm, once the code or docs make that clear. When the verdict is
|
||||
review it, use the installed `requesting-code-review` skill before inspecting
|
||||
the PR. You are the reviewer that has already been dispatched, so run the
|
||||
skill's code reviewer template yourself instead of dispatching another reviewer.
|
||||
Use the pull request and triggering instruction as its description and
|
||||
requirements, and review the exact base and head SHAs without changing code. Run
|
||||
the project's required checks on the head and treat a real failure as at least
|
||||
Important. The bot automation workflow itself is not a project check: ignore its
|
||||
skipped or canceled duplicate/automatic runs, and never reject a PR because the
|
||||
current review run is unfinished. Only a failed required check for the reviewed
|
||||
head blocks approval. Check the whole repository against the code rules at the
|
||||
end of this prompt, not only the diff; a violation is at least Important even
|
||||
when the diff did not cause it. Write the complete review, and nothing else, to
|
||||
the file `${REVIEW_PATH}`: it is posted verbatim as a pull request review from
|
||||
the bot account, and your final response is not posted at all. The file's first
|
||||
line must be exactly the verdict and nothing else: `Approved` when the head is
|
||||
ready to merge, `Changes requested` otherwise. The mark in front of it is added
|
||||
when posting, so write the words alone; any other first line is posted as a
|
||||
plain comment, which wastes the run. Minor issues alone never block, and neither
|
||||
does a finding the author has answered in the comment history below as intended
|
||||
or a false alarm, once the code or docs make that clear. When the verdict is
|
||||
`Changes requested`, the second line names what must change in one line,
|
||||
addressed to the author; the author's own agent picks the fixes up, so never ask
|
||||
`@bot` to make them. For UI changes, check that the result is aligned, clean,
|
||||
|
||||
@@ -1,12 +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 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);
|
||||
@@ -19,28 +43,18 @@ const REPO = env("GITEA_REPOSITORY");
|
||||
const INDEX = env("ISSUE_INDEX");
|
||||
const EVENT = env("EVENT_NAME");
|
||||
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),
|
||||
@@ -51,161 +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}`);
|
||||
}
|
||||
|
||||
// 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(): 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,
|
||||
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(prompt: string): 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 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());
|
||||
@@ -218,17 +212,29 @@ async function runClaude(prompt: string): 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();
|
||||
@@ -247,29 +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(prompt: string): Promise<string> {
|
||||
const loggedIn = await new Deno.Command("codex", {
|
||||
args: ["login", "status"],
|
||||
})
|
||||
.output();
|
||||
if (!loggedIn.success) await codexDeviceLogin();
|
||||
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",
|
||||
@@ -283,22 +343,78 @@ async function runCodex(prompt: string): 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 prompt = await renderPrompt();
|
||||
const result = env("BOT_TYPE") === "claude"
|
||||
? await runClaude(prompt)
|
||||
: await runCodex(prompt);
|
||||
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;
|
||||
}
|
||||
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
import { join } from "jsr:@std/path@1";
|
||||
import { parseCredentials } from "./credentials.ts";
|
||||
import type { BotProfile } from "./profiles.ts";
|
||||
|
||||
const FIRST_PROFILE_UID = 20_000;
|
||||
|
||||
export type RuntimeProfile = Pick<BotProfile, "type" | "model"> & {
|
||||
index: number;
|
||||
uid: number;
|
||||
gid: number;
|
||||
home: string;
|
||||
tokenFile: string | null;
|
||||
};
|
||||
|
||||
export type RuntimeManifest = {
|
||||
runtimeDir: string;
|
||||
manifestPath: string;
|
||||
reviewerFile: string;
|
||||
outcomeFile: string;
|
||||
profiles: RuntimeProfile[];
|
||||
};
|
||||
|
||||
export type RuntimeOutcome =
|
||||
| {
|
||||
ok: true;
|
||||
body: string;
|
||||
reviewPath: string;
|
||||
anchorsPath: string;
|
||||
}
|
||||
| { ok: false };
|
||||
|
||||
async function writePrivate(
|
||||
path: string,
|
||||
value: string,
|
||||
uid: number,
|
||||
gid: number,
|
||||
): Promise<void> {
|
||||
await Deno.writeTextFile(path, value, { mode: 0o600 });
|
||||
await Deno.chmod(path, 0o600);
|
||||
await Deno.chown(path, uid, gid);
|
||||
}
|
||||
|
||||
export async function prepareRuntime(
|
||||
actionPath: string,
|
||||
runtimeRoot: string,
|
||||
masterKey: string,
|
||||
): Promise<RuntimeManifest> {
|
||||
const credentials = await parseCredentials(
|
||||
await Deno.readTextFile(join(actionPath, "profiles.yml")),
|
||||
masterKey,
|
||||
);
|
||||
const runtimeDir = await Deno.makeTempDir({
|
||||
dir: runtimeRoot,
|
||||
prefix: "bot-agents-",
|
||||
});
|
||||
try {
|
||||
await Deno.chmod(runtimeDir, 0o711);
|
||||
const reviewerFile = join(runtimeDir, "reviewer-token");
|
||||
await writePrivate(reviewerFile, credentials.reviewerToken, 0, 0);
|
||||
|
||||
const profiles: RuntimeProfile[] = [];
|
||||
for (const [index, profile] of credentials.profiles.entries()) {
|
||||
const uid = FIRST_PROFILE_UID + index;
|
||||
const home = join(runtimeDir, `profile-${index}`);
|
||||
await Deno.mkdir(home, { mode: 0o700 });
|
||||
await Deno.chmod(home, 0o700);
|
||||
await Deno.chown(home, uid, uid);
|
||||
const tokenFile = profile.token ? join(home, "token") : null;
|
||||
if (tokenFile !== null) {
|
||||
await writePrivate(tokenFile, profile.token!, uid, uid);
|
||||
}
|
||||
profiles.push({
|
||||
index,
|
||||
type: profile.type,
|
||||
model: profile.model,
|
||||
uid,
|
||||
gid: uid,
|
||||
home,
|
||||
tokenFile,
|
||||
});
|
||||
}
|
||||
|
||||
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 Deno.remove(runtimeDir, { recursive: true }).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readRuntimeManifest(
|
||||
path: string,
|
||||
): Promise<RuntimeManifest> {
|
||||
return JSON.parse(await Deno.readTextFile(path));
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
import { assert, assertEquals, assertNotEquals } from "jsr:@std/assert@1";
|
||||
import { prepareRuntime } from "./runtime.ts";
|
||||
|
||||
const MASTER_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8";
|
||||
const ENCRYPTED = "v1:ICEiIyQlJicoKSor:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA";
|
||||
|
||||
Deno.test({
|
||||
name: "prepares root-only reviewer and isolated profile credentials",
|
||||
permissions: { read: true, write: true, run: true, sys: ["uid"] },
|
||||
async fn() {
|
||||
if (Deno.uid() !== 0) return;
|
||||
const root = await Deno.makeTempDir();
|
||||
await Deno.chmod(root, 0o711);
|
||||
const action = await Deno.makeTempDir();
|
||||
try {
|
||||
await Deno.writeTextFile(
|
||||
`${action}/profiles.yml`,
|
||||
`reviewer-token: ${ENCRYPTED}\nprofiles:\n - type: claude\n token: ${ENCRYPTED}\n - type: codex\n`,
|
||||
);
|
||||
|
||||
const manifest = await prepareRuntime(action, root, MASTER_KEY);
|
||||
const serialized = await Deno.readTextFile(manifest.manifestPath);
|
||||
assert(!serialized.includes("secret-token"));
|
||||
assertEquals(
|
||||
await Deno.readTextFile(manifest.reviewerFile),
|
||||
"secret-token",
|
||||
);
|
||||
assertEquals(
|
||||
(await Deno.stat(manifest.reviewerFile)).mode! & 0o777,
|
||||
0o600,
|
||||
);
|
||||
assertEquals((await Deno.stat(manifest.reviewerFile)).uid, 0);
|
||||
|
||||
const [claude, codex] = manifest.profiles;
|
||||
assertNotEquals(claude.uid, codex.uid);
|
||||
assertEquals((await Deno.stat(claude.home)).uid, claude.uid);
|
||||
assertEquals((await Deno.stat(claude.home)).mode! & 0o777, 0o700);
|
||||
assert(claude.tokenFile !== null);
|
||||
assertEquals(await Deno.readTextFile(claude.tokenFile), "secret-token");
|
||||
assertEquals((await Deno.stat(claude.tokenFile)).uid, claude.uid);
|
||||
assertEquals((await Deno.stat(claude.tokenFile)).mode! & 0o777, 0o600);
|
||||
assertEquals((await Deno.stat(codex.home)).uid, codex.uid);
|
||||
assertEquals(codex.tokenFile, null);
|
||||
|
||||
const ownToken = await new Deno.Command("/bin/cat", {
|
||||
args: [claude.tokenFile],
|
||||
cwd: "/",
|
||||
uid: claude.uid,
|
||||
gid: claude.gid,
|
||||
}).output();
|
||||
assertEquals(ownToken.success, true);
|
||||
assertEquals(new TextDecoder().decode(ownToken.stdout), "secret-token");
|
||||
|
||||
const reviewerToken = await new Deno.Command("/bin/cat", {
|
||||
args: [manifest.reviewerFile],
|
||||
cwd: "/",
|
||||
uid: claude.uid,
|
||||
gid: claude.gid,
|
||||
}).output();
|
||||
assertEquals(reviewerToken.success, false);
|
||||
|
||||
const siblingHome = await new Deno.Command("/bin/ls", {
|
||||
args: [codex.home],
|
||||
cwd: "/",
|
||||
uid: claude.uid,
|
||||
gid: claude.gid,
|
||||
}).output();
|
||||
assertEquals(siblingHome.success, false);
|
||||
} finally {
|
||||
await Deno.remove(root, { recursive: true });
|
||||
await Deno.remove(action, { recursive: true });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
Deno.test({
|
||||
name: "removes prepared credentials when output publication fails",
|
||||
permissions: "inherit",
|
||||
async fn() {
|
||||
if (Deno.uid() !== 0) return;
|
||||
const root = await Deno.makeTempDir();
|
||||
const action = await Deno.makeTempDir();
|
||||
try {
|
||||
await Deno.writeTextFile(
|
||||
`${action}/profiles.yml`,
|
||||
`reviewer-token: ${ENCRYPTED}\nprofiles:\n - type: claude\n token: ${ENCRYPTED}\n`,
|
||||
);
|
||||
const result = await new Deno.Command(Deno.execPath(), {
|
||||
args: ["run", "-A", new URL("./prepare.ts", import.meta.url).pathname],
|
||||
env: {
|
||||
ACTION_PATH: action,
|
||||
RUNNER_TEMP: root,
|
||||
BOT_AGENTS_MASTER_KEY: MASTER_KEY,
|
||||
GITHUB_OUTPUT: action,
|
||||
},
|
||||
clearEnv: true,
|
||||
}).output();
|
||||
|
||||
assertEquals(result.success, false);
|
||||
const entries = [];
|
||||
for await (const entry of Deno.readDir(root)) entries.push(entry.name);
|
||||
assertEquals(entries, []);
|
||||
} finally {
|
||||
await Deno.remove(root, { recursive: true });
|
||||
await Deno.remove(action, { recursive: true });
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { assert, assertEquals } from "jsr:@std/assert@1";
|
||||
import { parse } from "jsr:@std/yaml@1";
|
||||
|
||||
type Mapping = Record<string, unknown>;
|
||||
|
||||
const mapping = (value: unknown, at: string): Mapping => {
|
||||
assert(
|
||||
typeof value === "object" && value !== null && !Array.isArray(value),
|
||||
at,
|
||||
);
|
||||
return value as Mapping;
|
||||
};
|
||||
|
||||
const sequence = (value: unknown, at: string): Mapping[] => {
|
||||
assert(Array.isArray(value), at);
|
||||
return value.map((entry, index) => mapping(entry, `${at}[${index}]`));
|
||||
};
|
||||
|
||||
async function yaml(path: string): Promise<Mapping> {
|
||||
return mapping(parse(await Deno.readTextFile(path)), path);
|
||||
}
|
||||
|
||||
async function yamlFiles(directory = "."): Promise<string[]> {
|
||||
const result: string[] = [];
|
||||
for await (const entry of Deno.readDir(directory)) {
|
||||
if (entry.name === ".git" || entry.name === ".superpowers") continue;
|
||||
const path = directory === "." ? entry.name : `${directory}/${entry.name}`;
|
||||
if (entry.isDirectory) result.push(...await yamlFiles(path));
|
||||
if (entry.isFile && /\.ya?ml$/.test(entry.name)) result.push(path);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Deno.test("all committed YAML parses", async () => {
|
||||
const files = await yamlFiles();
|
||||
assert(files.length > 0);
|
||||
for (const file of files) await yaml(file);
|
||||
});
|
||||
|
||||
Deno.test("the action exposes only the master key and scopes it to preparation", async () => {
|
||||
const action = await yaml("action.yml");
|
||||
assertEquals(Object.keys(mapping(action.inputs, "inputs")), ["master-key"]);
|
||||
const steps = sequence(mapping(action.runs, "runs").steps, "runs.steps");
|
||||
const checkout = steps.find((step) => step.uses === "actions/checkout@v4");
|
||||
const prepare = steps.find((step) => step.id === "prepare");
|
||||
const agent = steps.find((step) => step.id === "agent");
|
||||
const post = steps.find((step) => step.id === "post");
|
||||
const source = steps.find((step) => step.id === "source");
|
||||
assert(checkout && source && prepare && agent && post);
|
||||
assertEquals(
|
||||
mapping(checkout.with, "checkout.with").token,
|
||||
"${{ gitea.token }}",
|
||||
);
|
||||
assertEquals(
|
||||
mapping(prepare.env, "prepare.env").BOT_AGENTS_MASTER_KEY,
|
||||
"${{ inputs.master-key }}",
|
||||
);
|
||||
assertEquals(JSON.stringify(agent).includes("BOT_AGENTS_MASTER_KEY"), false);
|
||||
assertEquals(JSON.stringify(post).includes("BOT_AGENTS_MASTER_KEY"), false);
|
||||
assertEquals(
|
||||
mapping(agent.env, "agent.env").GITEA_TOKEN,
|
||||
"${{ gitea.token }}",
|
||||
);
|
||||
assert(String(post.if).includes("always()"));
|
||||
for (const step of [prepare, agent, post]) {
|
||||
assert(String(step.run).includes("steps.source.outputs.path"));
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("the repository uses its trusted main action", async () => {
|
||||
const workflow = await yaml(".gitea/workflows/agents.yml");
|
||||
const job = mapping(mapping(workflow.jobs, "jobs").agent, "jobs.agent");
|
||||
const permissions = mapping(job.permissions, "jobs.agent.permissions");
|
||||
assertEquals(permissions, {
|
||||
code: "write",
|
||||
issues: "write",
|
||||
"pull-requests": "write",
|
||||
});
|
||||
const steps = sequence(job.steps, "jobs.agent.steps");
|
||||
const local = steps.findIndex((step) => step.uses === "./");
|
||||
assertEquals(local, -1);
|
||||
const trusted = steps.findIndex((step) =>
|
||||
step.uses === "https://git.capsulizers.com/commons/bot-agents@main"
|
||||
);
|
||||
assert(trusted >= 0);
|
||||
assertEquals(
|
||||
mapping(steps[trusted].with, "trusted.with")["master-key"],
|
||||
"${{ secrets.BOT_AGENTS_MASTER_KEY }}",
|
||||
);
|
||||
assertEquals(JSON.stringify(workflow).includes("gitea.actor != 'bot'"), true);
|
||||
});
|
||||
|
||||
Deno.test("CI checks every phase and the root isolation suite", async () => {
|
||||
const workflow = await yaml(".gitea/workflows/check.yml");
|
||||
const job = mapping(mapping(workflow.jobs, "jobs").deno, "jobs.deno");
|
||||
const commands = sequence(job.steps, "jobs.deno.steps")
|
||||
.map((step) => typeof step.run === "string" ? step.run : "")
|
||||
.join("\n");
|
||||
for (const entrypoint of ["prepare.ts", "run.ts", "post.ts"]) {
|
||||
assert(commands.includes(entrypoint), entrypoint);
|
||||
}
|
||||
assert(commands.includes("deno test --allow-all"));
|
||||
});
|
||||
|
||||
Deno.test("uses the standard Skills installer without custom discovery", async () => {
|
||||
const runner = await Deno.readTextFile("run.ts");
|
||||
assert(runner.includes("skills add obra/superpowers"));
|
||||
try {
|
||||
await Deno.stat("superpowers.ts");
|
||||
assert(false, "superpowers.ts should be removed");
|
||||
} catch (error) {
|
||||
assert(error instanceof Deno.errors.NotFound);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user