Move the action from Deno to Node.js and Vite+
Agents / agent (pull_request) Has been skipped
Check / check (pull_request) Successful in 58s

This commit is contained in:
Danny Kim
2026-10-16 02:20:49 +09:00
parent 9b55e98d8c
commit 8f23da2b42
45 changed files with 5664 additions and 1731 deletions
+9 -8
View File
@@ -5,17 +5,18 @@ on:
branches: [main]
jobs:
deno:
check:
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- run: apt-get update && apt-get install -y unzip
# The action runs on this same image; src/post.ts needs Node 24.2+.
- run: node --version
- uses: denoland/setup-deno@v2
- run: npm ci
- run: >-
deno fmt --check . &&
deno lint . &&
deno check src/prepare.ts src/run.ts src/post.ts &&
deno test --allow-all
# Formatting, lint, and type checks, then every test. The job runs as
# root, so the isolation tests run rather than skip.
- run: npx vp check
- run: npx vp test
+2
View File
@@ -0,0 +1,2 @@
node_modules/
coverage/
+18 -3
View File
@@ -10,6 +10,21 @@ comment `@bot` to request an AI-bot review. Wait for green CI and the bot's
approval on the current head before merging. Never merge without the user's
explicit permission.
Repository TypeScript and checks use Deno. The composite action uses npm only to
install the externally distributed Claude Code and Codex command-line tools; do
not treat those vendor executables as repository TypeScript dependencies.
# Runtime and checks
The action's TypeScript runs directly on the job image's Node.js (24.2 or
newer), which strips the types itself: there is no build step and no runtime
dependency. Keep the sources to erasable syntax and `.ts` imports, which
`tsconfig.json` enforces. Checks use Vite+: `vp check` formats, lints, and
type-checks, and `vp test` runs Vitest. Unit tests sit next to the code as
`src/*.test.ts`; repository-level tests live in `tests/`.
The only config files are `package.json`, `tsconfig.json`, and
`vite.config.ts`, plus `package-lock.json`. Repo-local lint rules and the rule
list live in `scripts/project-lint.ts`. Lint disable comments are not allowed;
fix the source instead.
The composite action uses npm only to install the externally distributed
Claude Code, Codex, and OpenCode command-line tools, plus Deno for agents that
check Deno-based repositories; do not treat those vendor executables as
repository TypeScript dependencies.
+6 -4
View File
@@ -15,7 +15,9 @@ runs:
with:
fetch-depth: 0
token: ${{ gitea.token }}
# This step assumes this is `node:24-bookworm` container.
# This step assumes this is `node:24-bookworm` container. The action runs
# its TypeScript on that Node directly; Deno stays for the agents, which
# still check Deno-based repositories with it.
- name: Install dependencies
shell: bash
run: npm install -g @openai/codex @anthropic-ai/claude-code opencode-ai deno
@@ -34,7 +36,7 @@ runs:
- name: Prepare credentials
id: prepare
shell: bash
run: deno run -A "${{ steps.source.outputs.path }}/src/prepare.ts"
run: node "${{ steps.source.outputs.path }}/src/prepare.ts"
env:
ACTION_PATH: ${{ steps.source.outputs.path }}
BOT_AGENTS_MASTER_KEY: ${{ inputs.master-key }}
@@ -42,7 +44,7 @@ runs:
- name: Run bot
id: agent
shell: bash
run: deno run -A "${{ steps.source.outputs.path }}/src/run.ts"
run: node "${{ steps.source.outputs.path }}/src/run.ts"
env:
BOT_AGENTS_MANIFEST: ${{ steps.prepare.outputs.manifest }}
GITEA_TOKEN: ${{ gitea.token }}
@@ -55,7 +57,7 @@ runs:
id: post
if: always() && steps.prepare.outputs.manifest != ''
shell: bash
run: deno run -A "${{ steps.source.outputs.path }}/src/post.ts"
run: node "${{ steps.source.outputs.path }}/src/post.ts"
env:
BOT_AGENTS_MANIFEST: ${{ steps.prepare.outputs.manifest }}
GITEA_API_URL: ${{ gitea.api_url }}
+3083
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
{
"name": "bot-agents",
"description": "A Gitea composite action that runs a coding agent for approved issue and pull request automation.",
"private": true,
"type": "module",
"engines": {
"node": ">=24.2"
},
"devDependencies": {
"@eslint/core": "^0.17.0",
"@types/node": "^24.19.2",
"@typescript-eslint/types": "^8.71.2",
"vite-plus": "^1.2.0",
"yaml": "^2.9.1"
}
}
+19
View File
@@ -0,0 +1,19 @@
{
"reviewer-token": "v1:XFUvCI9PnXVaW-ig:_3S9BvXywUp29cVYGA_EzFScKwC5oHkV9x2Ld8k-V_uiG7iA8n3fmjXCArzh2Lz-JDgy3IMTHoY",
"profiles": [
{
"type": "codex",
"model": "gpt-5.6-terra"
},
{
"type": "claude",
"model": "claude-sonnet-5",
"token": "v1:-dyfF6ZicEhjPk4C:WCuMaGd09tYjtzUCl7abloRzPITvP5C35WpR372w3O_tcaD-ls-yV5sJBdU8iIVApZVCC98sY2gOyM3o9RMa8ciJq8Fuys7KxlYOp8QiluL9AYMSQi6rEsSH71EPWnxSACgo3uDQY8bWQpUlEZvXH9O-ZN9A58dh6b3vRg"
},
{
"type": "opencode",
"model": "openrouter/qwen/qwen3.8-27b",
"token": "v1:4qdfJCdUx9jfiabC:8X0ic_J82lPlq_R3uTLaEbQy7UmEh7BgtG0bDKAcc8FeJ0xvEwkGCKaDoLmT5nta03hgjGdcWKjDdOSMi1kSDfp8J7tRTLtMRzDT8GX8OU_PVDPVahQIAU0"
}
]
}
-10
View File
@@ -1,10 +0,0 @@
reviewer-token: v1:XFUvCI9PnXVaW-ig:_3S9BvXywUp29cVYGA_EzFScKwC5oHkV9x2Ld8k-V_uiG7iA8n3fmjXCArzh2Lz-JDgy3IMTHoY
profiles:
- type: codex
model: gpt-5.6-terra
- type: claude
model: claude-sonnet-5
token: v1:-dyfF6ZicEhjPk4C:WCuMaGd09tYjtzUCl7abloRzPITvP5C35WpR372w3O_tcaD-ls-yV5sJBdU8iIVApZVCC98sY2gOyM3o9RMa8ciJq8Fuys7KxlYOp8QiluL9AYMSQi6rEsSH71EPWnxSACgo3uDQY8bWQpUlEZvXH9O-ZN9A58dh6b3vRg
- type: opencode
model: openrouter/qwen/qwen3.8-27b
token: v1:4qdfJCdUx9jfiabC:8X0ic_J82lPlq_R3uTLaEbQy7UmEh7BgtG0bDKAcc8FeJ0xvEwkGCKaDoLmT5nta03hgjGdcWKjDdOSMi1kSDfp8J7tRTLtMRzDT8GX8OU_PVDPVahQIAU0
+232
View File
@@ -0,0 +1,232 @@
/**
* `project` — lint rules this repository holds its own source to. Deno lint
* ran here with its `recommended` rules and no plugins; the Oxlint categories
* in `vite.config.ts` plus the rule list below keep at least that coverage.
* Its rules of its own are `no-disable-comments` and `no-non-ts-imports`.
*
* Loaded by `vite.config.ts` as an Oxlint `jsPlugins` entry.
*
* @module
*/
import type { LanguageOptions, RuleDefinition, RuleVisitor, TextSourceCode } from "@eslint/core";
import type { TSESTree } from "@typescript-eslint/types";
import type { DummyRuleMap } from "vite-plus/lint";
/** The source code both hosts hand a rule, with the comment list. */
interface ProjectSourceCode extends TextSourceCode {
getAllComments(): TSESTree.Comment[];
}
/** ESLint's default rule types, reporting TypeScript-ESTree nodes. */
interface ProjectRuleTypes {
LangOptions: LanguageOptions;
Code: ProjectSourceCode;
RuleOptions: [];
Visitor: RuleVisitor;
Node: TSESTree.Node;
MessageIds: string;
ExtRuleDocs: unknown;
}
/** A repo-local rule, in ESLint's standard `create` form. */
type ProjectRule = RuleDefinition<ProjectRuleTypes>;
/** A comment that turns lint rules off or back on, in ESLint or Oxlint form. */
const DIRECTIVE_PATTERN: RegExp = /^\s*(?:eslint|oxlint)-(?:disable|enable)/u;
/** This rule's own name, which a directive may not use to silence it. */
const RULE_NAME: string = "no-disable-comments";
/**
* `no-disable-comments` — rejects every `eslint-disable*`, `eslint-enable*`,
* `oxlint-disable*`, and `oxlint-enable*` comment, line or block. Oxlint has
* no `noInlineConfig` and no `eslint-comments` rules, so this repo bans them
* itself: when a rule fires, fix the source.
*
* A directive can silence the report on itself, so two built-in checks close
* the gaps: `unicorn/no-abusive-eslint-disable` fails one that names no rule,
* and this rule skips one that names it, which then silences nothing and fails
* `reportUnusedDisableDirectives` in `vite.config.ts`.
*/
const noDisableCommentsRule: ProjectRule = {
meta: {
type: "problem",
docs: { description: "Disallow lint disable and enable comments" },
schema: [],
messages: { directive: "Lint disable comments are not allowed; fix the source." },
},
create(context) {
return {
Program(_node: TSESTree.Program): void {
for (const comment of context.sourceCode.getAllComments()) {
if (DIRECTIVE_PATTERN.test(comment.value) && !comment.value.includes(RULE_NAME)) {
context.report({ loc: comment.loc, messageId: "directive" });
}
}
},
};
},
};
/** A module specifier read as a path: relative, absolute, or a `#` alias. */
const PATH_SPECIFIER_PATTERN: RegExp = /^[./#]/u;
/**
* Whether `source` names something other than a `.ts` module: a query string
* such as `?raw`, or a final path segment with any other extension. Bare
* package names are left alone, dots and all, as are extensionless paths.
*/
function isNonTsSource(source: string): boolean {
if (source.includes("?")) {
return true;
}
const segments: string[] = source.split("/");
if (!PATH_SPECIFIER_PATTERN.test(source)) {
const nameLength: number = source.startsWith("@") ? 2 : 1;
if (segments.length <= nameLength) {
return false;
}
}
const lastSegment: string = segments.at(-1) ?? "";
if (lastSegment === "." || lastSegment === ".." || !lastSegment.includes(".")) {
return false;
}
return !lastSegment.endsWith(".ts");
}
/**
* `no-non-ts-imports` — rejects importing anything but a `.ts` module: a
* source path, static or a dynamic `import()` with a string literal, ending in
* another extension (`.css`, `.json`, `.svg`, `.js`, `.tsx`) or carrying a
* query string (`?raw`, `?url`), and any import or export with import
* attributes. Bare package names, `#` aliases, and `node:` builtins without an
* extension pass; package subpaths are checked like paths.
*/
const noNonTsImportsRule: ProjectRule = {
meta: {
type: "problem",
docs: { description: "Disallow importing modules other than .ts files" },
schema: [],
messages: {
extension: "Only .ts modules may be imported.",
attributes: "Import attributes are not allowed.",
},
},
create(context) {
function checkSource(source: TSESTree.StringLiteral): void {
if (isNonTsSource(source.value)) {
context.report({ node: source, messageId: "extension" });
}
}
function checkDeclaration(
node:
| TSESTree.ImportDeclaration
| TSESTree.ExportNamedDeclaration
| TSESTree.ExportAllDeclaration,
): void {
if (node.source === null) {
return;
}
checkSource(node.source);
if (node.attributes.length > 0) {
context.report({ node, messageId: "attributes" });
}
}
return {
ImportDeclaration: checkDeclaration,
ExportNamedDeclaration: checkDeclaration,
ExportAllDeclaration: checkDeclaration,
ImportExpression(node: TSESTree.ImportExpression): void {
if (node.source.type === "Literal" && typeof node.source.value === "string") {
checkSource(node.source);
}
if (node.options !== null) {
context.report({ node, messageId: "attributes" });
}
},
};
},
};
/**
* This repository's own rule list, spread by `vite.config.ts`: Deno's
* `recommended` rules that sit outside the Oxlint categories, plus the
* template's extras. Built-in Oxlint rule ids sit here too, so
* `vite.config.ts` lists no rule by name.
*/
const rules: DummyRuleMap = {
// The explicit-types template's `camelcase` stand-in: no `_` inside a name
// once leading and trailing ones are trimmed, unless it has no lowercase
// ASCII letter (UPPER_SNAKE).
"id-match": [
"error",
"^_*[^_]*_*$|^[^a-z]*$",
{ properties: false, classFields: false, onlyDeclarations: false, ignoreDestructuring: true },
],
// Deno's `recommended` rules outside the categories.
"no-array-constructor": "error",
"no-case-declarations": "error",
"no-empty": "error",
"no-fallthrough": "error",
"no-inner-declarations": "error",
"no-prototype-builtins": "error",
"no-redeclare": "error",
"no-regex-spaces": "error",
"no-var": "error",
"prefer-const": "error",
"require-await": "error",
"require-yield": "error",
"typescript/adjacent-overload-signatures": "error",
"typescript/ban-ts-comment": "error",
"typescript/no-empty-interface": "error",
"typescript/no-empty-object-type": "error",
"typescript/no-explicit-any": "error",
"typescript/no-namespace": "error",
"typescript/no-non-null-asserted-optional-chain": "error",
"typescript/no-unsafe-function-type": "error",
"typescript/no-wrapper-object-types": "error",
"typescript/prefer-as-const": "error",
"typescript/prefer-namespace-keyword": "error",
"typescript/triple-slash-reference": "error",
// Deno's `no-window`, `no-process-global`, and `no-node-globals`: on Node,
// these come from an explicit `node:` import instead of the global scope.
"no-restricted-globals": [
"error",
"window",
"process",
"Buffer",
"global",
"setImmediate",
"clearImmediate",
],
// The explicit-types template's extras.
"import/no-cycle": "error",
"typescript/consistent-type-imports": "error",
"typescript/no-import-type-side-effects": "error",
"typescript/no-non-null-assertion": "error",
// Deno's `ban-untagged-ignore` and `ban-unused-ignore`, made stricter: no
// disable comments at all.
"project/no-disable-comments": "error",
"project/no-non-ts-imports": "error",
"unicorn/no-abusive-eslint-disable": "error",
// Pedantic, but Node's and the web's own types (`Response`, `Stats`,
// `RequestInit`) are mutable, so no parameter of theirs can pass; the
// explicit-types template and the app repos turn it off too.
"typescript/prefer-readonly-parameter-types": "off",
};
/**
* The repo-local plugin, namespace `project`: its own rules, enabled as
* `project/<rule>`, and `configs.project`, the rule list above.
*/
const plugin = {
meta: { name: "project" },
rules: {
"no-disable-comments": noDisableCommentsRule,
"no-non-ts-imports": noNonTsImportsRule,
},
configs: { project: { rules } },
};
export default plugin;
+2 -1
View File
@@ -4,6 +4,7 @@ set -euo pipefail
path=$(mktemp -d "${RUNNER_TEMP}/bot-agents-source-XXXXXX")
mkdir "${path}/src"
cp -a "${ACTION_PATH}"/src/*.ts "${path}/src/"
cp -a "${ACTION_PATH}/profiles.yml" "${ACTION_PATH}/prompt.md" "${path}/"
# package.json makes Node load the TypeScript sources as ES modules.
cp -a "${ACTION_PATH}/package.json" "${ACTION_PATH}/profiles.json" "${ACTION_PATH}/prompt.md" "${path}/"
chmod -R go-w "${path}"
printf 'path=%s\n' "${path}" >> "${GITHUB_OUTPUT}"
+119
View File
@@ -0,0 +1,119 @@
// Runs Claude Code for one profile and returns its final result text.
import { createInterface } from "node:readline";
import type { Readable } from "node:stream";
import { isClaudeAuthFailure } from "./auth.ts";
import type { ClaudeFailureFields } from "./auth.ts";
import { chunks } from "./command.ts";
import { agentCommand } from "./isolation.ts";
import { isObject } from "./json.ts";
import type { JsonObject } from "./json.ts";
import { AgentAuthError, agentEnvironment } from "./profiles.ts";
import { PROCESS_ENV, WORKSPACE } from "./run-context.ts";
import type { RuntimeProfile } from "./runtime.ts";
// Claude Code's stream-json `result` event, in its own field names.
type ClaudeResult = ClaudeFailureFields &
Partial<Record<"is_error", boolean>> & {
subtype: string;
};
// Echoes stderr as it arrives and resolves with all of it.
async function copyError(stream: Readable): Promise<string> {
const decoder = new TextDecoder();
let error = "";
for await (const chunk of chunks(stream)) {
const text = decoder.decode(chunk, { stream: true });
error += text;
console.error(text);
}
return error + decoder.decode();
}
// Prints the visible text of each streamed message part.
function printParts(event: JsonObject): void {
const content = isObject(event.message) ? event.message.content : undefined;
for (const part of Array.isArray(content) ? content : []) {
if (isObject(part)) {
const text = part.thinking ?? part.text ?? part.name;
if (typeof text === "string" && text !== "") {
console.log(text);
}
}
}
}
function toResult(event: JsonObject): ClaudeResult {
const { is_error: isError, result, subtype, api_error_status: apiErrorStatus } = event;
return {
...(typeof isError === "boolean" && { is_error: isError }),
...(typeof result === "string" && { result }),
subtype: String(subtype),
...((typeof apiErrorStatus === "number" || apiErrorStatus === null) && {
api_error_status: apiErrorStatus,
}),
};
}
// Prints each streamed message part and returns the final `result` event.
async function readEvents(stream: Readable): Promise<ClaudeResult | undefined> {
let result: ClaudeResult | undefined;
for await (const line of createInterface({ input: stream, crlfDelay: Infinity })) {
if (line !== "") {
const event: unknown = JSON.parse(line);
if (isObject(event)) {
printParts(event);
if (event.type === "result") {
result = toResult(event);
}
}
}
}
return result;
}
function checkResult(result: ClaudeResult | undefined, error: string): string {
if (result?.is_error === true) {
const failure = new Error(result.result ?? `claude ended with ${result.subtype}`);
if (isClaudeAuthFailure(result)) {
throw new AgentAuthError("claude sign-in failed", { cause: failure });
}
throw failure;
}
if (result?.result === undefined) {
throw new Error(error.trim() || `claude ended with ${result?.subtype ?? "no result"}`);
}
return result.result;
}
export async function runClaude(
profile: RuntimeProfile,
token: string,
prompt: string,
): Promise<string> {
const args = [
"--print",
"--dangerously-skip-permissions",
"--model",
profile.model,
"--output-format",
"stream-json",
"--verbose",
prompt,
];
const claude = agentCommand(profile, "claude", args, {
cwd: WORKSPACE,
env: { ...agentEnvironment(PROCESS_ENV, profile, token), IS_SANDBOX: "1" },
clearEnv: true,
stdout: "piped",
stderr: "piped",
}).spawn();
const errorText = copyError(claude.stderr);
const result = await readEvents(claude.stdout);
const [status, error] = await Promise.all([claude.status, errorText]);
const body = checkResult(result, error);
if (!status.success) {
throw new Error(error.trim() || `claude exited with ${status.code}`);
}
return body;
}
+174
View File
@@ -0,0 +1,174 @@
// Signs Codex in for one profile, runs it, and returns its result file path.
import process from "node:process";
import type { Readable } from "node:stream";
import { text } from "node:stream/consumers";
import { setTimeout as sleep } from "node:timers/promises";
import { isCodexAuthFailure, retryInvalidToken } from "./auth.ts";
import type { CodexAuthentication } from "./auth.ts";
import { chunks, endWith, writeChunk } from "./command.ts";
import type { CommandStatus } from "./command.ts";
import { agentCommand } from "./isolation.ts";
import { AgentAuthError, agentEnvironment, withProfile } from "./profiles.ts";
import {
makeProfileFile,
postAuthorComment,
PROCESS_ENV,
runAgentCommand,
WORKSPACE,
} from "./run-context.ts";
import type { RuntimeProfile } from "./runtime.ts";
interface CodexAttempt {
status: CommandStatus;
error: string;
}
async function collectText(stream: Readable, append: (chunk: string) => void): Promise<void> {
const decoder = new TextDecoder();
for await (const chunk of chunks(stream)) {
append(decoder.decode(chunk, { stream: true }));
}
append(decoder.decode());
}
// Polls every 100 ms until the login prints something, failing if it exits first.
async function pollForInstructions(
shownText: () => string,
status: Promise<CommandStatus>,
): Promise<void> {
if (shownText().trim() !== "") {
return;
}
const exited = await Promise.race([status.then(() => true), sleep(100, false)]);
if (exited) {
throw new AgentAuthError("AI bot login produced no instructions");
}
await pollForInstructions(shownText, status);
}
async function codexDeviceLogin(
profile: RuntimeProfile,
environment: Record<string, string>,
): Promise<void> {
const login = agentCommand(profile, "codex", ["login", "--device-auth"], {
cwd: WORKSPACE,
env: environment,
clearEnv: true,
stdout: "piped",
stderr: "piped",
}).spawn();
let shown = "";
const append = (chunk: string): void => {
shown += chunk;
};
const drained = Promise.all([
collectText(login.stdout, append),
collectText(login.stderr, append),
]);
await sleep(3000);
await pollForInstructions(() => shown, login.status);
await postAuthorComment(withProfile(shown, profile));
const [result] = await Promise.all([login.status, drained]);
if (!result.success) {
throw new AgentAuthError(`codex login exited with ${result.code}`);
}
}
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();
await endWith(login.stdin, `${token}\n`);
const [status, error] = await Promise.all([login.status, text(login.stderr)]);
if (!status.success) {
throw new Error(error.trim() || `codex login exited with ${status.code}`);
}
}
async function signIn(
profile: RuntimeProfile,
environment: Record<string, string>,
authentication: CodexAuthentication,
): Promise<void> {
if (authentication.kind === "api-key") {
await codexApiLogin(profile, environment, authentication.token);
return;
}
const loggedIn = await agentCommand(profile, "codex", ["login", "status"], {
cwd: WORKSPACE,
env: environment,
clearEnv: true,
}).output();
if (!loggedIn.success) {
await codexDeviceLogin(profile, environment);
}
}
async function execCodex(
profile: RuntimeProfile,
environment: Record<string, string>,
prompt: string,
outputPath: string,
): Promise<CodexAttempt> {
const args = [
"exec",
"--model",
profile.model,
"--dangerously-bypass-approvals-and-sandbox",
"--output-last-message",
outputPath,
prompt,
];
const codex = agentCommand(profile, "codex", args, {
cwd: WORKSPACE,
env: environment,
clearEnv: true,
stdout: "inherit",
stderr: "piped",
}).spawn();
const decoder = new TextDecoder();
let error = "";
for await (const chunk of chunks(codex.stderr)) {
await writeChunk(process.stderr, chunk);
error += decoder.decode(chunk, { stream: true });
}
error += decoder.decode();
// Codex echoes the prompt, whose comments may quote auth errors.
return { status: await codex.status, error: error.replaceAll(prompt, "") };
}
export 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);
await signIn(profile, environment, authentication);
const outputPath = await makeProfileFile(profile, "result.md");
const attempt = (): Promise<CodexAttempt> => execCodex(profile, environment, prompt, outputPath);
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) {
const failure = new Error(result.error.trim() || `codex exited with ${result.status.code}`);
if (isCodexAuthFailure(result.error)) {
throw new AgentAuthError("codex sign-in failed", { cause: failure });
}
throw failure;
}
return outputPath;
}
+46
View File
@@ -0,0 +1,46 @@
// Runs OpenCode for one profile and returns its final visible text.
import process from "node:process";
import { chunks, writeChunk } from "./command.ts";
import { agentCommand } from "./isolation.ts";
import {
collectOpenCodeOutput,
openCodeArgs,
openCodeError,
parseOpenCodeEvents,
} from "./opencode.ts";
import { agentEnvironment } from "./profiles.ts";
import { PROCESS_ENV, WORKSPACE } from "./run-context.ts";
import type { RuntimeProfile } from "./runtime.ts";
export async function runOpenCode(
profile: RuntimeProfile,
token: string,
prompt: string,
): Promise<string> {
const child = agentCommand(profile, "opencode", openCodeArgs(profile.model, prompt), {
cwd: WORKSPACE,
env: agentEnvironment(PROCESS_ENV, profile, token),
clearEnv: true,
stdout: "piped",
stderr: "piped",
}).spawn();
const collected = collectOpenCodeOutput(
chunks(child.stdout),
chunks(child.stderr),
async (chunk) => {
await writeChunk(process.stdout, chunk);
},
async (chunk) => {
await writeChunk(process.stderr, chunk);
},
);
const [status, streams] = await Promise.all([child.status, collected]);
const error = streams.stderr.trim();
if (!status.success) {
throw (
openCodeError(streams.stdout) ?? new Error(error || `opencode exited with ${status.code}`)
);
}
return parseOpenCodeEvents(streams.stdout);
}
+30
View File
@@ -0,0 +1,30 @@
// Dispatches one attempt to the agent CLI its profile names.
import { runClaude } from "./agent-claude.ts";
import { runCodex } from "./agent-codex.ts";
import { runOpenCode } from "./agent-opencode.ts";
import { codexAuthentication } from "./auth.ts";
import type { CodexAuthentication } from "./auth.ts";
import type { RuntimeProfile } from "./runtime.ts";
export interface AgentAnswer {
body?: string;
resultPath?: string;
}
// Runs the selected agent; Codex leaves its answer in a file, the others return it.
export async function runAgent(
profile: RuntimeProfile,
token: string | undefined,
prompt: string,
authentication: CodexAuthentication | null,
): Promise<AgentAnswer> {
if (profile.type === "claude") {
return { body: await runClaude(profile, token ?? "", prompt) };
}
if (profile.type === "codex") {
return {
resultPath: await runCodex(profile, prompt, authentication ?? codexAuthentication(token)),
};
}
return { body: await runOpenCode(profile, token ?? "", prompt) };
}
+54 -62
View File
@@ -1,4 +1,6 @@
import { assertEquals, assertRejects } from "jsr:@std/assert@1";
import { expect, test } from "vite-plus/test";
import { mkdir, readFile, rm, stat, symlink, writeFile } from "node:fs/promises";
import {
codexAuthentication,
isClaudeAuthFailure,
@@ -7,8 +9,9 @@ import {
retryInvalidToken,
stageCodexAuth,
} from "./auth.ts";
import { currentGid, currentUid, IS_WINDOWS, makeTempDir } from "./testing.ts";
Deno.test("retries once after an invalid refresh token", async () => {
test("retries once after an invalid refresh token", async () => {
let attempts = 0;
let relogins = 0;
const result = await retryInvalidToken(
@@ -23,17 +26,14 @@ Deno.test("retries once after an invalid refresh token", async () => {
},
);
assertEquals(
{ attempts, relogins, error: result.error },
{
expect({ attempts, relogins, error: result.error }).toStrictEqual({
attempts: 2,
relogins: 1,
error: "still failed",
},
);
});
});
Deno.test("does not retry an unrelated failure", async () => {
test("does not retry an unrelated failure", async () => {
let attempts = 0;
let relogins = 0;
await retryInvalidToken(
@@ -50,7 +50,7 @@ Deno.test("does not retry an unrelated failure", async () => {
},
);
assertEquals({ attempts, relogins }, { attempts: 1, relogins: 0 });
expect({ attempts, relogins }).toStrictEqual({ attempts: 1, relogins: 0 });
});
// Verbatim stderr lines from the revoked-login job on lead/memona-home#36.
@@ -61,27 +61,26 @@ const REVOKED_CODEX = [
"ERROR: workspace routing discovery unauthorized (401)",
];
Deno.test("recognizes each Codex sign-in failure line", () => {
test("recognizes each Codex sign-in failure line", () => {
for (const line of [
...REVOKED_CODEX,
`2026-10-12T02:28:05Z ERROR codex_login::auth::manager: Failed to refresh token status=401 Unauthorized detail=TokenErrorDetail { error_code: Some("refresh_token_expired"), .. }`,
`ERROR: Your access token could not be refreshed because your refresh token was already used. Please log out and sign in again. (refresh_token_reused)`,
"ERROR: unexpected status 401 Unauthorized: Incorrect API key provided: sk-inval*d123., auth error: 401, auth error code: invalid_api_key",
]) {
assertEquals(isCodexAuthFailure(`exec\n${line}\n`), true, line);
expect(isCodexAuthFailure(`exec\n${line}\n`), line).toBe(true);
}
});
Deno.test("ignores auth words outside Codex error lines", () => {
assertEquals(
test("ignores auth words outside Codex error lines", () => {
expect(
isCodexAuthFailure(
"exec\ncurl returned 401 Unauthorized\ntoken_revoked in fixture\n" + "ERROR: tests failed\n",
"exec\ncurl returned 401 Unauthorized\ntoken_revoked in fixture\nERROR: tests failed\n",
),
false,
);
).toBe(false);
});
Deno.test("re-logs in after the job 9837 revoked-session stderr", async () => {
test("re-logs in after the job 9837 revoked-session stderr", async () => {
let relogins = 0;
let attempts = 0;
const result = await retryInvalidToken(
@@ -96,91 +95,84 @@ Deno.test("re-logs in after the job 9837 revoked-session stderr", async () => {
},
);
assertEquals(
{ relogins, success: result.status.success },
{
expect({ relogins, success: result.status.success }).toStrictEqual({
relogins: 1,
success: true,
},
);
});
});
Deno.test("recognizes a rejected Claude OAuth token", () => {
assertEquals(
test("recognizes a rejected Claude OAuth token", () => {
expect(
isClaudeAuthFailure({
api_error_status: 401,
result: "Failed to authenticate. API Error: 401 OAuth access token is invalid.",
}),
true,
);
assertEquals(
).toBe(true);
expect(
isClaudeAuthFailure({
result: "OAuth token has expired. Please obtain a new token or refresh your existing token.",
}),
true,
);
assertEquals(isClaudeAuthFailure({ api_error_status: 429, result: "API Error: 429" }), false);
).toBe(true);
expect(isClaudeAuthFailure({ api_error_status: 429, result: "API Error: 429" })).toBe(false);
});
Deno.test("selects API-key login only for a configured Codex token", () => {
assertEquals(codexAuthentication("selected-key"), {
test("selects API-key login only for a configured Codex token", () => {
expect(codexAuthentication("selected-key")).toStrictEqual({
kind: "api-key",
token: "selected-key",
});
assertEquals(codexAuthentication(undefined), { kind: "persistent" });
expect(codexAuthentication()).toStrictEqual({ kind: "persistent" });
});
const AUTH = JSON.stringify({
auth_mode: "chatgpt",
tokens: {
const AUTH_TOKENS = {
access_token: "access",
refresh_token: "refresh",
id_token: "id",
account_id: "account",
},
});
};
const AUTH = JSON.stringify({ auth_mode: "chatgpt", tokens: AUTH_TOKENS });
Deno.test("stages and persists only a regular private Codex login", async () => {
if (Deno.build.os === "windows") return;
const root = await Deno.makeTempDir();
test.skipIf(IS_WINDOWS)("stages and persists only a regular private Codex login", async () => {
const root = await makeTempDir();
try {
const canonical = `${root}/persistent/auth.json`;
await Deno.mkdir(`${root}/persistent`);
await Deno.writeTextFile(canonical, AUTH, { mode: 0o600 });
await mkdir(`${root}/persistent`);
await writeFile(canonical, AUTH, { mode: 0o600 });
const home = `${root}/profile`;
await Deno.mkdir(home);
await 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 staged = await stageCodexAuth(canonical, home, currentUid(), currentGid());
expect(await readFile(staged, "utf8")).toStrictEqual(AUTH);
expect((await stat(staged)).mode & 0o777).toBe(0o600);
const refreshed = JSON.stringify({
...JSON.parse(AUTH),
tokens: { ...JSON.parse(AUTH).tokens, access_token: "refreshed" },
auth_mode: "chatgpt",
tokens: { ...AUTH_TOKENS, access_token: "refreshed" },
});
await Deno.writeTextFile(staged, refreshed);
await writeFile(staged, refreshed);
await persistCodexAuth(staged, canonical);
assertEquals(await Deno.readTextFile(canonical), refreshed);
assertEquals((await Deno.stat(canonical)).mode! & 0o777, 0o600);
expect(await readFile(canonical, "utf8")).toStrictEqual(refreshed);
expect((await stat(canonical)).mode & 0o777).toBe(0o600);
} finally {
await Deno.remove(root, { recursive: true });
await rm(root, { recursive: true });
}
});
Deno.test("rejects a symlink or malformed Codex login", async () => {
if (Deno.build.os === "windows") return;
const root = await Deno.makeTempDir();
test.skipIf(IS_WINDOWS)("rejects a symlink or malformed Codex login", async () => {
const root = await 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 writeFile(canonical, AUTH, { mode: 0o600 });
await writeFile(malformed, '{"tokens":{}}', { mode: 0o600 });
await symlink(canonical, linked);
await assertRejects(() => persistCodexAuth(linked, canonical), Error, "regular file");
await assertRejects(() => persistCodexAuth(malformed, canonical), Error, "invalid Codex login");
await expect(() => persistCodexAuth(linked, canonical)).rejects.toThrow("regular file");
await expect(() => persistCodexAuth(malformed, canonical)).rejects.toThrow(
"invalid Codex login",
);
} finally {
await Deno.remove(root, { recursive: true });
await rm(root, { recursive: true });
}
});
+62 -36
View File
@@ -1,4 +1,19 @@
import { dirname, join } from "jsr:@std/path@1";
import { randomUUID } from "node:crypto";
import {
chmod,
chown,
lstat,
mkdir,
open,
readFile,
rename,
rm,
writeFile,
} from "node:fs/promises";
import { dirname, join } from "node:path";
import { isNotFound } from "./errors.ts";
import { isObject } from "./json.ts";
export type CodexAuthentication = { kind: "api-key"; token: string } | { kind: "persistent" };
@@ -9,7 +24,7 @@ export function codexAuthentication(token?: string): CodexAuthentication {
// Codex logs revoked, expired, or reused ChatGPT sign-ins and rejected API keys
// on its own ERROR lines; tool output and the echoed prompt must not count.
const CODEX_AUTH_FAILURE =
/^(?:\S+\s+)?ERROR\b.*(?:\b401\b|unauthorized|invalid_refresh_token|refresh_token_(?:expired|reused|invalidated)|token_(?:revoked|expired)|invalid_api_key|(?:log|sign) in again)/im;
/^(?:\S+\s+)?ERROR\b.*(?:\b401\b|unauthorized|invalid_refresh_token|refresh_token_(?:expired|reused|invalidated)|token_(?:revoked|expired)|invalid_api_key|(?:log|sign) in again)/imu;
export function isCodexAuthFailure(error: string): boolean {
return CODEX_AUTH_FAILURE.test(error);
@@ -18,13 +33,16 @@ export function isCodexAuthFailure(error: string): boolean {
// Claude Code reports a rejected CLAUDE_CODE_OAUTH_TOKEN as an is_error
// stream-json result, e.g. "Failed to authenticate. API Error: 401 OAuth
// access token is invalid." with api_error_status 401.
export function isClaudeAuthFailure(result: {
api_error_status?: number | null;
// The fields of Claude Code's stream-json `result` event that tell a sign-in
// failure apart, under their own names.
export type ClaudeFailureFields = Partial<Record<"api_error_status", number | null>> & {
result?: string;
}): boolean {
};
export function isClaudeAuthFailure(result: ClaudeFailureFields): boolean {
return (
result.api_error_status === 401 ||
/failed to authenticate|oauth (?:access )?token (?:is invalid|has expired)|invalid api key|authentication_error|please run \/login|not logged in/i.test(
/failed to authenticate|oauth (?:access )?token (?:is invalid|has expired)|invalid api key|authentication_error|please run \/login|not logged in/iu.test(
result.result ?? "",
)
);
@@ -42,33 +60,35 @@ export async function retryInvalidToken<T extends { status: { success: boolean }
return result;
}
function hasCodexTokens(tokens: unknown): boolean {
return (
isObject(tokens) &&
["access_token", "refresh_token", "id_token", "account_id"].every(
(name) => typeof tokens[name] === "string" && tokens[name] !== "",
)
);
}
async function readCodexAuth(path: string): Promise<string> {
const stat = await Deno.lstat(path);
if (!stat.isFile || stat.isSymlink) {
const stat = await lstat(path);
if (!stat.isFile() || stat.isSymbolicLink()) {
throw new Error("Codex login must be a regular file");
}
if ((stat.mode! & 0o077) !== 0) {
if ((stat.mode & 0o077) !== 0) {
throw new Error("Codex login must be private");
}
const text = await Deno.readTextFile(path);
const text = await readFile(path, "utf8");
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] !== "",
)
!isObject(value) ||
typeof value.auth_mode !== "string" ||
value.auth_mode === "" ||
!hasCodexTokens(value.tokens)
) {
throw new Error("invalid Codex login");
}
@@ -82,33 +102,39 @@ export async function stageCodexAuth(
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);
await mkdir(codexHome, { recursive: true, mode: 0o700 });
await chmod(codexHome, 0o700);
await 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);
await writeFile(stagedPath, await readCodexAuth(canonicalPath), { mode: 0o600 });
await chmod(stagedPath, 0o600);
await chown(stagedPath, uid, gid);
} catch (error) {
if (!(error instanceof Deno.errors.NotFound)) throw error;
if (!isNotFound(error)) throw error;
}
return stagedPath;
}
// Creates an empty private file under a fresh name, as Deno.makeTempFile did.
async function makeTempFile(directory: string): Promise<string> {
const path = join(directory, `.tmp-${randomUUID()}`);
const handle = await open(path, "wx", 0o600);
await handle.close();
return path;
}
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 });
await mkdir(directory, { recursive: true, mode: 0o700 });
const temporary = await makeTempFile(directory);
try {
await Deno.writeTextFile(temporary, text, { mode: 0o600 });
await Deno.chmod(temporary, 0o600);
await Deno.rename(temporary, canonicalPath);
await writeFile(temporary, text, { mode: 0o600 });
await chmod(temporary, 0o600);
await rename(temporary, canonicalPath);
} catch (error) {
await Deno.remove(temporary).catch(() => undefined);
await rm(temporary).catch(() => {});
throw error;
}
}
+160
View File
@@ -0,0 +1,160 @@
// A child process with the call shapes the runner was reviewed with: `output()`
// collects both streams, `spawn()` hands them out, and `clearEnv` passes only
// the given environment. Streams default as Deno's did: stdin is inherited,
// stdout and stderr are piped for `output()` and inherited for `spawn()`.
import { spawn as spawnProcess } from "node:child_process";
import type { ChildProcess, StdioOptions } from "node:child_process";
import { Buffer } from "node:buffer";
import { constants } from "node:os";
import process from "node:process";
import type { Readable, Writable } from "node:stream";
import { buffer } from "node:stream/consumers";
import { finished } from "node:stream/promises";
export type StdioMode = "inherit" | "piped" | "null";
export interface CommandOptions {
args?: string[];
cwd?: string;
env?: Record<string, string>;
clearEnv?: boolean;
uid?: number;
gid?: number;
stdin?: StdioMode;
stdout?: StdioMode;
stderr?: StdioMode;
}
export interface CommandStatus {
success: boolean;
code: number;
}
export interface CommandOutput extends CommandStatus {
stdout: Uint8Array;
stderr: Uint8Array;
}
export interface CommandChild {
status: Promise<CommandStatus>;
stdin: Writable;
stdout: Readable;
stderr: Readable;
}
const STDIO: Record<StdioMode, "inherit" | "pipe" | "ignore"> = {
inherit: "inherit",
piped: "pipe",
null: "ignore",
};
function signalNumber(signal: NodeJS.Signals): number {
return constants.signals[signal];
}
function startProcess(
command: string,
options: CommandOptions,
outputDefault: StdioMode,
): ChildProcess {
const stdio: StdioOptions = [
STDIO[options.stdin ?? "inherit"],
STDIO[options.stdout ?? outputDefault],
STDIO[options.stderr ?? outputDefault],
];
const environment = options.clearEnv === true ? options.env : { ...process.env, ...options.env };
return spawnProcess(command, options.args ?? [], {
stdio,
...(options.cwd !== undefined && { cwd: options.cwd }),
...(environment !== undefined && { env: environment }),
...(options.uid !== undefined && { uid: options.uid }),
...(options.gid !== undefined && { gid: options.gid }),
});
}
// Settles when the process exits, before its streams close, as Deno's did. A
// signal-killed process reports 128 plus the signal number, as a shell does.
function waitForStatus(child: ChildProcess): Promise<CommandStatus> {
return new Promise((resolve, reject) => {
child.once("error", reject);
child.once("exit", (code, signal) => {
const exitCode = code ?? (signal === null ? 1 : 128 + signalNumber(signal));
resolve({ success: exitCode === 0, code: exitCode });
});
});
}
function piped<T>(stream: T | null, name: string): T {
if (stream === null) {
throw new TypeError(`${name} is not piped`);
}
return stream;
}
function collect(stream: Readable | null): Promise<Uint8Array> {
return stream === null ? Promise.resolve(new Uint8Array()) : buffer(stream);
}
// A stream's chunks as bytes, whether or not it has an encoding set.
export async function* chunks(stream: Readable): AsyncGenerator<Uint8Array> {
for await (const chunk of stream) {
const value: unknown = chunk;
if (value instanceof Uint8Array) {
yield value;
} else if (typeof value === "string") {
yield Buffer.from(value);
}
}
}
export function spawn(command: string, options: CommandOptions = {}): CommandChild {
const child = startProcess(command, options, "inherit");
const status = waitForStatus(child);
// A failed start rejects `status`; the caller sees it once it awaits that.
status.catch(() => {});
return {
status,
get stdin(): Writable {
return piped(child.stdin, "stdin");
},
get stdout(): Readable {
return piped(child.stdout, "stdout");
},
get stderr(): Readable {
return piped(child.stderr, "stderr");
},
};
}
export async function output(
command: string,
options: CommandOptions = {},
): Promise<CommandOutput> {
const child = startProcess(command, options, "piped");
const [status, stdout, stderr] = await Promise.all([
waitForStatus(child),
collect(child.stdout),
collect(child.stderr),
]);
return { ...status, stdout, stderr };
}
// Writes one chunk and settles once the stream has taken it, as Deno's
// `Deno.stdout.write` did.
export async function writeChunk(stream: Writable, chunk: Uint8Array): Promise<void> {
await new Promise<void>((resolve, reject) => {
stream.write(chunk, (error) => {
if (error) {
reject(error);
} else {
resolve();
}
});
});
}
// Writes the last chunk and settles once the stream has flushed and closed.
export async function endWith(stream: Writable, chunk: string): Promise<void> {
stream.end(chunk);
await finished(stream);
}
+68
View File
@@ -0,0 +1,68 @@
import { expect, test } from "vite-plus/test";
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";
test("decrypts a known AES-256-GCM credential", async () => {
expect(await decryptCredential(ENCRYPTED, MASTER_KEY)).toBe("secret-token");
});
test("encrypts a credential with a fresh supplied nonce", async () => {
expect(await encryptCredential("secret-token", MASTER_KEY, NONCE)).toStrictEqual(ENCRYPTED);
});
test("rejects a credential encrypted by another master key", async () => {
await expect(() => decryptCredential(ENCRYPTED, OTHER_KEY)).rejects.toThrow();
});
test("rejects a credential with a nonstandard nonce", async () => {
await expect(() =>
decryptCredential("v1:AA:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA", MASTER_KEY),
).rejects.toThrow("nonce must be 12 bytes");
});
test("decrypts the central reviewer token and ordered profiles", async () => {
expect(
await parseCredentials(
JSON.stringify({
"reviewer-token": ENCRYPTED,
profiles: [
{ type: "codex", model: "gpt-custom" },
{ type: "claude", token: ENCRYPTED },
{ type: "opencode", token: ENCRYPTED },
],
}),
MASTER_KEY,
),
).toStrictEqual({
reviewerToken: "secret-token",
profiles: [
{ type: "codex", model: "gpt-custom" },
{
type: "claude",
model: "claude-sonnet-5",
token: "secret-token",
},
{
type: "opencode",
model: "openrouter/qwen/qwen3.8-27b",
token: "secret-token",
},
],
});
});
test("rejects malformed central credentials", async () => {
await expect(() => parseCredentials('{"profiles": []}', MASTER_KEY)).rejects.toThrow(
"reviewer-token",
);
await expect(() =>
parseCredentials(
JSON.stringify({ "reviewer-token": ENCRYPTED, profiles: [{ type: "claude" }] }),
MASTER_KEY,
),
).rejects.toThrow("profiles[0].token");
});
+43 -43
View File
@@ -1,4 +1,6 @@
import { parse } from "jsr:@std/yaml@1";
import { webcrypto } from "node:crypto";
import { isMapping } from "./json.ts";
import type { BotProfile } from "./profiles.ts";
const DEFAULT_MODELS: Record<BotProfile["type"], string> = {
@@ -7,40 +9,40 @@ const DEFAULT_MODELS: Record<BotProfile["type"], string> = {
opencode: "openrouter/qwen/qwen3.8-27b",
};
export type BotCredentials = {
export interface 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));
return Uint8Array.from(atob(padded), (character) => character.codePointAt(0) ?? 0);
}
function encodeBase64Url(value: Uint8Array): string {
const binary = Array.from(value, (byte) => String.fromCharCode(byte)).join("");
const binary = Array.from(value, (byte) => String.fromCodePoint(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> {
function importMasterKey(encoded: string, usage: webcrypto.KeyUsage): Promise<webcrypto.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]);
return webcrypto.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) {
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(
const plaintext = await webcrypto.subtle.decrypt(
{ name: "AES-GCM", iv: buffer(nonce) },
await importMasterKey(masterKey, "decrypt"),
buffer(decodeBase64Url(encodedCiphertext)),
@@ -51,10 +53,10 @@ export async function decryptCredential(encrypted: string, masterKey: string): P
export async function encryptCredential(
plaintext: string,
masterKey: string,
nonce: Uint8Array = crypto.getRandomValues(new Uint8Array(12)),
nonce: Uint8Array = webcrypto.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(
const ciphertext = await webcrypto.subtle.encrypt(
{ name: "AES-GCM", iv: buffer(nonce) },
await importMasterKey(masterKey, "encrypt"),
new TextEncoder().encode(plaintext),
@@ -62,38 +64,19 @@ export async function encryptCredential(
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> => {
async function parseProfile(entry: unknown, index: number, masterKey: string): Promise<BotProfile> {
const at = `profiles[${index}]`;
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
throw new Error(`${at}: expected a mapping`);
if (!isMapping(entry)) {
throw new TypeError(`${at}: expected a mapping`);
}
const profile = entry as Record<string, unknown>;
for (const key of Object.keys(profile)) {
for (const key of Object.keys(entry)) {
if (key !== "type" && key !== "model" && key !== "token") {
throw new Error(`${at}.${key}: unknown field`);
}
}
const { type, model, token } = profile;
const { type, model, token } = entry;
if (type !== "claude" && type !== "codex" && type !== "opencode") {
throw new Error(`${at}.type: expected \"claude\", \"codex\", or \"opencode\"`);
throw new Error(`${at}.type: expected "claude", "codex", or "opencode"`);
}
if (model !== undefined && (typeof model !== "string" || model === "")) {
throw new Error(`${at}.model: expected a model name`);
@@ -111,10 +94,27 @@ export async function parseCredentials(text: string, masterKey: string): Promise
token: await decryptCredential(token, masterKey),
}),
};
}),
);
return {
reviewerToken: await decryptCredential(record["reviewer-token"], masterKey),
profiles,
};
}
export async function parseCredentials(text: string, masterKey: string): Promise<BotCredentials> {
const value: unknown = JSON.parse(text);
if (!isMapping(value)) {
throw new TypeError("credentials must be a mapping");
}
for (const key of Object.keys(value)) {
if (key !== "reviewer-token" && key !== "profiles") {
throw new Error(`${key}: unknown field`);
}
}
const { "reviewer-token": reviewerToken, profiles } = value;
if (typeof reviewerToken !== "string") {
throw new TypeError("reviewer-token: expected an encrypted credential");
}
if (!Array.isArray(profiles) || profiles.length === 0) {
throw new Error("profiles must contain at least one profile");
}
const parsed = await Promise.all(
profiles.map((entry: unknown, index) => parseProfile(entry, index, masterKey)),
);
return { reviewerToken: await decryptCredential(reviewerToken, masterKey), profiles: parsed };
}
-70
View File
@@ -1,70 +0,0 @@
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: codex
model: gpt-custom
- type: claude
token: ${ENCRYPTED}
- type: opencode
token: ${ENCRYPTED}
`,
MASTER_KEY,
),
{
reviewerToken: "secret-token",
profiles: [
{ type: "codex", model: "gpt-custom" },
{
type: "claude",
model: "claude-sonnet-5",
token: "secret-token",
},
{
type: "opencode",
model: "openrouter/qwen/qwen3.8-27b",
token: "secret-token",
},
],
},
);
});
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",
);
});
+8
View File
@@ -0,0 +1,8 @@
// Node reports a missing file as ENOENT and a missing process as ESRCH, where
// Deno threw `Deno.errors.NotFound` for both.
export function isNotFound(error: unknown): boolean {
if (!(error instanceof Error) || !("code" in error)) {
return false;
}
return error.code === "ENOENT" || error.code === "ESRCH";
}
+112
View File
@@ -0,0 +1,112 @@
import { expect, test } from "vite-plus/test";
import { chmod, chown, mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises";
import process from "node:process";
import { isNotFound } from "./errors.ts";
import {
agentCommand,
isRunnableProfileProcess,
killProfileProcesses,
readProfileFile,
} from "./isolation.ts";
import type { RuntimeProfile } from "./runtime.ts";
import { currentUid, IS_ROOT, IS_WINDOWS, makeTempDir } from "./testing.ts";
const profile = (uid: number, home: string): RuntimeProfile => ({
index: uid - 20_000,
type: "claude",
model: "test",
uid,
gid: uid,
home,
tokenFile: null,
});
test("zombie process entries are already quiescent", () => {
const status = "State:\tZ (zombie)\nUid:\t20010\t20010\t20010\t20010\n";
expect(isRunnableProfileProcess(status, 20_010)).toBe(false);
});
test.skipIf(IS_WINDOWS)("refuses to read a profile result symlink as root", async () => {
const root = await makeTempDir();
try {
const secret = `${root}/reviewer-token`;
const result = `${root}/result.md`;
await writeFile(secret, "reviewer-secret", { mode: 0o600 });
await symlink(secret, result);
await expect(() => readProfileFile(profile(currentUid(), root), result)).rejects.toThrow(
"regular private file",
);
} finally {
await rm(root, { recursive: true });
}
});
test.skipIf(!IS_ROOT)("a profile process can read only its own private files", async () => {
const root = await makeTempDir();
await chmod(root, 0o711);
try {
const homeA = `${root}/a`;
const homeB = `${root}/b`;
await mkdir(homeA, { mode: 0o700 });
await mkdir(homeB, { mode: 0o700 });
await chown(homeA, 20_000, 20_000);
await chown(homeB, 20_001, 20_001);
await writeFile(`${homeA}/token`, "own", { mode: 0o600 });
await chown(`${homeA}/token`, 20_000, 20_000);
await writeFile(`${homeB}/token`, "sibling", { mode: 0o600 });
await chown(`${homeB}/token`, 20_001, 20_001);
await writeFile(`${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();
expect(new TextDecoder().decode(own.stdout)).toBe("own");
expect(own.success).toBe(true);
expect(sibling.success).toBe(false);
expect(reviewer.success).toBe(false);
} finally {
await rm(root, { recursive: true });
}
});
test.skipIf(!IS_ROOT)("cleanup leaves no runnable daemonized process", async () => {
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());
expect(Number.isInteger(pid)).toBe(true);
await killProfileProcesses(selected.uid);
const status = await readFile(`/proc/${pid}/status`, "utf8").catch((error: unknown) => {
if (isNotFound(error)) return null;
throw error;
});
expect(status === null || !isRunnableProfileProcess(status, selected.uid)).toBe(true);
});
test.skipIf(!IS_ROOT)("the agent process cannot read its coordinator environment", async () => {
const selected = profile(20_020, "/tmp");
const result = await agentCommand(selected, "/usr/bin/cat", [`/proc/${process.pid}/environ`], {
cwd: "/",
env: { PARENT_SECRET: "must-not-cross-boundary" },
clearEnv: true,
stderr: "piped",
}).output();
expect(result.success).toBe(false);
});
+63 -31
View File
@@ -1,57 +1,89 @@
import { lstat, readdir, readFile } from "node:fs/promises";
import process from "node:process";
import { setTimeout as sleep } from "node:timers/promises";
import { output, spawn } from "./command.ts";
import type { CommandChild, CommandOptions, CommandOutput } from "./command.ts";
import { isNotFound } from "./errors.ts";
import type { RuntimeProfile } from "./runtime.ts";
export type AgentCommandOptions = Omit<CommandOptions, "args" | "uid" | "gid">;
export interface AgentCommand {
output(): Promise<CommandOutput>;
spawn(): CommandChild;
}
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,
});
options: AgentCommandOptions = {},
): AgentCommand {
const full: CommandOptions = { ...options, args, uid: profile.uid, gid: profile.gid };
return {
output: () => output(command, full),
spawn: () => spawn(command, full),
};
}
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];
const owner = /^Uid:\s+(\d+)/mu.exec(status)?.[1];
const state = /^State:\s+(\w)/mu.exec(status)?.[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;
const entries = await readdir("/proc", { withFileTypes: true });
const pids = entries
.filter((entry) => entry.isDirectory() && /^\d+$/u.test(entry.name))
.map((entry) => entry.name);
const statuses = await Promise.all(
pids.map((pid) => readFile(`/proc/${pid}/status`, "utf8").catch(() => null)),
);
return pids
.filter((_pid, index) => {
const status = statuses[index];
return status !== null && status !== undefined && isRunnableProfileProcess(status, uid);
})
.map(Number);
}
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) {
function killProcess(pid: number): void {
try {
Deno.kill(pid, "SIGKILL");
process.kill(pid, "SIGKILL");
} catch (error) {
if (!(error instanceof Deno.errors.NotFound)) throw error;
if (!isNotFound(error)) {
throw error;
}
}
await new Promise((resolve) => setTimeout(resolve, 10));
}
}
// Kills the UID's processes every 10 ms until none is left, for up to 100 rounds.
export async function killProfileProcesses(uid: number, attempt = 0): Promise<void> {
if (attempt === 100) {
throw new Error(`profile UID ${uid} still owns processes after cleanup`);
}
const ids = await profileProcessIds(uid);
if (ids.length === 0) {
return;
}
for (const pid of ids) {
killProcess(pid);
}
await sleep(10);
await killProfileProcesses(uid, attempt + 1);
}
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) {
const stat = await lstat(path);
if (
!stat.isFile() ||
stat.isSymbolicLink() ||
stat.uid !== profile.uid ||
(stat.mode & 0o077) !== 0
) {
throw new Error(`${path}: expected a profile-owned regular private file`);
}
return await Deno.readTextFile(path);
return readFile(path, "utf8");
}
-129
View File
@@ -1,129 +0,0 @@
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);
},
});
+12
View File
@@ -0,0 +1,12 @@
// Type guards for values read from JSON, which arrive as `unknown`.
export type JsonObject = Record<string, unknown>;
// Any non-null object, arrays included: reading a missing key yields undefined.
export function isObject(value: unknown): value is JsonObject {
return typeof value === "object" && value !== null;
}
// A plain mapping, which an array is not.
export function isMapping(value: unknown): value is JsonObject {
return isObject(value) && !Array.isArray(value);
}
+22 -30
View File
@@ -1,4 +1,4 @@
import { assertEquals, assertInstanceOf, assertRejects, assertThrows } from "jsr:@std/assert@1";
import { assert, expect, test } from "vite-plus/test";
import {
collectOpenCodeOutput,
openCodeArgs,
@@ -7,8 +7,8 @@ import {
} from "./opencode.ts";
import { AgentAuthError } from "./profiles.ts";
Deno.test("builds a non-interactive auto-approved OpenCode command", () => {
assertEquals(openCodeArgs("openrouter/qwen/qwen3.8-27b", "Review this"), [
test("builds a non-interactive auto-approved OpenCode command", () => {
expect(openCodeArgs("openrouter/qwen/qwen3.8-27b", "Review this")).toStrictEqual([
"run",
"--format",
"json",
@@ -19,8 +19,8 @@ Deno.test("builds a non-interactive auto-approved OpenCode command", () => {
]);
});
Deno.test("returns the final visible OpenCode text event", () => {
assertEquals(
test("returns the final visible OpenCode text event", () => {
expect(
parseOpenCodeEvents(
[
JSON.stringify({ type: "step_start", part: { type: "step-start" } }),
@@ -29,23 +29,19 @@ Deno.test("returns the final visible OpenCode text event", () => {
JSON.stringify({ type: "step_finish", part: { type: "step-finish" } }),
].join("\n"),
),
"done",
);
).toBe("done");
});
Deno.test("rejects a successful OpenCode run without visible text", async () => {
await assertRejects(
async () => {
test("rejects a successful OpenCode run without visible text", async () => {
await expect(async () => {
parseOpenCodeEvents('{"type":"step_start"}');
await Promise.resolve();
},
Error,
"no text output",
);
}).rejects.toThrow("no text output");
});
Deno.test("streams OpenCode output while collecting it", async () => {
const encode = (value: string) => new TextEncoder().encode(value);
const encode = (value: string): Uint8Array => new TextEncoder().encode(value);
test("streams OpenCode output while collecting it", async () => {
const stdout = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encode("first\n"));
@@ -72,8 +68,8 @@ Deno.test("streams OpenCode output while collecting it", async () => {
},
);
assertEquals(result, { stdout: "first\nsecond\n", stderr: "warning\n" });
assertEquals(shown, ["out:first\n", "err:warning\n", "out:second\n"]);
expect(result).toStrictEqual({ stdout: "first\nsecond\n", stderr: "warning\n" });
expect(shown).toStrictEqual(["out:first\n", "err:warning\n", "out:second\n"]);
});
const openRouterError = (statusCode: number, message: string): string =>
@@ -91,21 +87,17 @@ const openRouterError = (statusCode: number, message: string): string =>
},
});
Deno.test("reports a rejected OpenRouter key as a sign-in failure", () => {
test("reports a rejected OpenRouter key as a sign-in failure", () => {
const error = openCodeError(openRouterError(401, "User not found."));
assertInstanceOf(error, AgentAuthError);
assertEquals(error.message, "opencode sign-in failed");
assertEquals(
(error.cause as Error).message,
"opencode APIError (API error 401): User not found.",
);
assert.instanceOf(error, AgentAuthError);
expect(error.message).toBe("opencode sign-in failed");
assert.instanceOf(error.cause, Error);
expect(error.cause.message).toBe("opencode APIError (API error 401): User not found.");
});
Deno.test("surfaces an OpenCode error event instead of empty output", () => {
assertThrows(
() => parseOpenCodeEvents(openRouterError(429, "Rate limit exceeded")),
Error,
test("surfaces an OpenCode error event instead of empty output", () => {
expect(() => parseOpenCodeEvents(openRouterError(429, "Rate limit exceeded"))).toThrow(
"API error 429",
);
assertEquals(openCodeError('{"type":"text","part":{"text":"ok"}}'), undefined);
expect(openCodeError('{"type":"text","part":{"text":"ok"}}')).toBe(undefined);
});
+13 -12
View File
@@ -1,3 +1,4 @@
import { isObject } from "./json.ts";
import { AgentAuthError } from "./profiles.ts";
export function openCodeArgs(model: string, prompt: string): string[] {
@@ -7,12 +8,12 @@ export function openCodeArgs(model: string, prompt: string): string[] {
type ChunkWriter = (chunk: Uint8Array) => void | Promise<void>;
export async function collectOpenCodeOutput(
stdout: ReadableStream<Uint8Array>,
stderr: ReadableStream<Uint8Array>,
stdout: AsyncIterable<Uint8Array>,
stderr: AsyncIterable<Uint8Array>,
writeStdout: ChunkWriter,
writeStderr: ChunkWriter,
): Promise<{ stdout: string; stderr: string }> {
const collect = async (stream: ReadableStream<Uint8Array>, write: ChunkWriter) => {
const collect = async (stream: AsyncIterable<Uint8Array>, write: ChunkWriter) => {
const chunks: Uint8Array[] = [];
for await (const chunk of stream) {
chunks.push(chunk);
@@ -47,13 +48,13 @@ export function openCodeError(output: string): Error | undefined {
} catch {
continue;
}
if (typeof event !== "object" || event === null) continue;
const { type, error } = event as Record<string, unknown>;
if (type !== "error" || typeof error !== "object" || error === null) {
if (!isObject(event)) continue;
const { type, error } = event;
if (type !== "error" || !isObject(error)) {
continue;
}
const { name, data } = error as Record<string, unknown>;
const { message, statusCode } = (data ?? {}) as Record<string, unknown>;
const { name, data } = error;
const { message, statusCode } = isObject(data) ? data : {};
const detail = `opencode ${typeof name === "string" ? name : "error"}${
typeof statusCode === "number" ? ` (API error ${statusCode})` : ""
}: ${typeof message === "string" ? message : "unknown error"}`;
@@ -72,10 +73,10 @@ export function parseOpenCodeEvents(output: string): string {
for (const line of output.split("\n")) {
if (line.trim() === "") continue;
const event: unknown = JSON.parse(line);
if (typeof event !== "object" || event === null) continue;
const { type, part } = event as Record<string, unknown>;
if (type !== "text" || typeof part !== "object" || part === null) continue;
const text = (part as Record<string, unknown>).text;
if (!isObject(event)) continue;
const { type, part } = event;
if (type !== "text" || !isObject(part)) continue;
const { text } = part;
if (typeof text === "string" && text.trim() !== "") result = text;
}
if (result === undefined) {
+114
View File
@@ -0,0 +1,114 @@
// Reads what the agent phase left behind: its outcome, and the review and
// anchors files it wrote into a profile home.
import type { Stats } from "node:fs";
import { lstat, readFile } from "node:fs/promises";
import { join } from "node:path";
import { isNotFound } from "./errors.ts";
import { isObject } from "./json.ts";
import { isFailureReason, isProfileType } from "./profiles.ts";
import type { ProfileFailure } from "./profiles.ts";
import type { RuntimeManifest, RuntimeOutcome } from "./runtime.ts";
export interface Anchor {
path: string;
line: number;
side: "new" | "old";
body: string;
}
function parseAnchor(entry: unknown, at: string): Anchor {
if (!isObject(entry)) {
throw new TypeError(`${at}: not an object`);
}
const { path, line, side = "new", body } = entry;
if (typeof path !== "string" || path === "") {
throw new TypeError(`${at}.path: expected a repository path`);
}
if (typeof line !== "number" || !Number.isInteger(line) || line < 1) {
throw new TypeError(`${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 TypeError(`${at}.body: expected the finding`);
}
return { path, line, side, body };
}
export function parseAnchors(text: string, sourcePath: string): Anchor[] {
const entries: unknown = JSON.parse(text);
if (!Array.isArray(entries)) {
throw new TypeError(`${sourcePath}: not an array`);
}
return entries.map((entry: unknown, index) => parseAnchor(entry, `${sourcePath}[${index}]`));
}
// Only known labels reach the comment, never raw agent output.
function knownFailures(failures: unknown): ProfileFailure[] {
return (Array.isArray(failures) ? failures : []).flatMap((entry: unknown) => {
if (!isObject(entry)) {
return [];
}
const { type, model, reason } = entry;
return isProfileType(type) && typeof model === "string" && isFailureReason(reason)
? [{ type, model, reason }]
: [];
});
}
export async function readOutcome(path: string): Promise<RuntimeOutcome> {
const value: unknown = JSON.parse(await readFile(path, "utf8"));
if (!isObject(value) || !("ok" in value)) {
throw new Error("invalid agent outcome");
}
if (value.ok === false) {
return { ok: false, failures: knownFailures(value.failures) };
}
const { ok, body, reviewPath, anchorsPath, profile } = value;
if (
ok !== true ||
typeof body !== "string" ||
typeof reviewPath !== "string" ||
typeof anchorsPath !== "string" ||
!isObject(profile) ||
!isProfileType(profile.type) ||
typeof profile.model !== "string"
) {
throw new Error("invalid agent outcome");
}
return {
ok: true,
body,
reviewPath,
anchorsPath,
profile: { type: profile.type, model: profile.model },
};
}
export 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: Stats;
try {
stat = await lstat(path);
} catch (error) {
if (isNotFound(error)) return null;
throw error;
}
if (
!stat.isFile() ||
stat.isSymbolicLink() ||
stat.uid !== profile.uid ||
(stat.mode & 0o077) !== 0
) {
throw new Error(`${name}: expected a profile-owned regular private file`);
}
const text = await readFile(path, "utf8");
return text === "" ? null : text;
}
+296
View File
@@ -0,0 +1,296 @@
import { chmod, mkdir, rm, stat, symlink, writeFile } from "node:fs/promises";
import { assert, expect, test } from "vite-plus/test";
import { isNotFound } from "./errors.ts";
import { postRuntime } from "./post.ts";
import type { PostContext } from "./post.ts";
import type { RuntimeManifest, RuntimeOutcome } from "./runtime.ts";
import { currentGid, currentUid, IS_WINDOWS, makeTempDir } from "./testing.ts";
const context: PostContext = {
apiUrl: "https://gitea.example/api/v1",
repository: "owner/repo",
issueIndex: "7",
eventName: "issue_comment",
};
interface Fixture {
manifestPath: string;
runtimeDir: string;
reviewerFile: string;
reviewPath: string;
outcomeFile: string;
}
type FixtureSource = Extract<RuntimeOutcome, { ok: false }> | { ok: true; body: string };
async function writePrivate(path: string, text: string): Promise<void> {
await writeFile(path, text, { mode: 0o600 });
await chmod(path, 0o600);
}
async function writeArtifacts(
outcome: RuntimeOutcome,
review: string | undefined,
anchors: unknown,
): Promise<void> {
if (!outcome.ok) return;
if (review !== undefined) await writePrivate(outcome.reviewPath, review);
if (anchors !== undefined) await writePrivate(outcome.anchorsPath, JSON.stringify(anchors));
}
function manifestFor(runtimeDir: string, profileHome: string): RuntimeManifest {
return {
runtimeDir,
manifestPath: `${runtimeDir}/manifest.json`,
reviewerFile: `${runtimeDir}/reviewer-token`,
outcomeFile: `${runtimeDir}/outcome.json`,
profiles: [
{
index: 0,
type: "claude",
model: "test",
uid: currentUid(),
gid: currentGid(),
home: profileHome,
tokenFile: null,
},
],
};
}
async function fixture(
source: FixtureSource,
review?: string,
anchors?: unknown,
): Promise<Fixture> {
const runtimeDir = await makeTempDir();
const profileHome = `${runtimeDir}/profile-0`;
const manifest = manifestFor(runtimeDir, profileHome);
await mkdir(profileHome);
await writeFile(manifest.reviewerFile, "reviewer-secret");
const outcome: RuntimeOutcome = source.ok
? {
...source,
profile: { type: "codex", model: "gpt-test" },
reviewPath: `${profileHome}/review.md`,
anchorsPath: `${profileHome}/anchors.json`,
}
: source;
await writeFile(manifest.outcomeFile, JSON.stringify(outcome));
await writeArtifacts(outcome, review, anchors);
await writeFile(manifest.manifestPath, JSON.stringify(manifest));
const { manifestPath, reviewerFile, outcomeFile } = manifest;
return {
manifestPath,
runtimeDir,
reviewerFile,
reviewPath: `${profileHome}/review.md`,
outcomeFile,
};
}
const missing = async (path: string): Promise<boolean> => {
try {
await stat(path);
return false;
} catch (error) {
if (isNotFound(error)) return true;
throw error;
}
};
// The JSON body of one request the fake Gitea received.
function requestBody(init?: RequestInit): unknown {
return typeof init?.body === "string" ? JSON.parse(init.body) : undefined;
}
// A fake Gitea that records each request body and answers with `status`.
function recordingFetch(bodies: unknown[], status = 201): typeof fetch {
return (_url, init) => {
bodies.push(requestBody(init));
return Promise.resolve(new Response(null, { status }));
};
}
test("posts a successful issue result and removes runtime credentials", async () => {
const result = await fixture({ ok: true, body: "done" });
const requests: { url: unknown; init: RequestInit | undefined }[] = [];
await postRuntime(result.manifestPath, context, (url, init) => {
requests.push({ url, init });
return Promise.resolve(new Response(null, { status: 201 }));
});
expect(requests.length).toBe(1);
const [request] = requests;
expect(request?.url).toStrictEqual(`${context.apiUrl}/repos/owner/repo/issues/7/comments`);
expect(requestBody(request?.init)).toStrictEqual({
body: "done\n\n> Profile: `codex` · `gpt-test`",
});
expect(request?.init?.headers).toStrictEqual({
Authorization: "token reviewer-secret",
"Content-Type": "application/json",
});
assert(await missing(result.runtimeDir));
});
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(requestBody(init));
return Promise.resolve(new Response(null, { status: bodies.length === 1 ? 422 : 200 }));
},
);
expect(bodies[0]).toMatchObject({
comments: [{ body: "Finding\n\n> Profile: `codex` · `gpt-test`" }],
});
expect(bodies[1]).toMatchObject({
comments: [],
body: "✅ Approved\nReady\n\n> Profile: `codex` · `gpt-test`",
});
assert(await missing(result.runtimeDir));
});
test("reports each tried profile and keeps the action failed", async () => {
const result = await fixture({
ok: false,
failures: [
{ type: "codex", model: "gpt-test", reason: "sign-in failed" },
{ type: "claude", model: "claude-test", reason: "usage exhausted" },
{ type: "opencode", model: "openrouter/test", reason: "failed" },
],
});
const bodies: unknown[] = [];
await expect(() =>
postRuntime(result.manifestPath, context, recordingFetch(bodies)),
).rejects.toThrow("agent phase failed");
expect(bodies).toStrictEqual([
{
body:
"AI bot failed.\n\n> Tried: `codex` · `gpt-test` (sign-in failed), " +
"`claude` · `claude-test` (usage exhausted), " +
"`opencode` · `openrouter/test` (failed)",
},
]);
assert(!JSON.stringify(bodies).includes("reviewer-secret"));
assert(await missing(result.runtimeDir));
});
test("drops failure entries that are not known labels", async () => {
const result = await fixture({ ok: false, failures: [] });
await writeFile(
result.outcomeFile,
JSON.stringify({
ok: false,
failures: [
{ type: "codex", model: "gpt-test", reason: "token sk-secret leaked" },
{ type: "shell", model: "x", reason: "failed" },
{ type: "claude", model: "claude-test", reason: "sign-in failed" },
],
}),
);
const bodies: unknown[] = [];
await expect(() =>
postRuntime(result.manifestPath, context, recordingFetch(bodies)),
).rejects.toThrow("agent phase failed");
expect(bodies).toStrictEqual([
{
body: "AI bot failed.\n\n> Tried: `claude` · `claude-test` (sign-in failed)",
},
]);
});
test("reports a generic failure when the runner wrote no outcome", async () => {
const result = await fixture({ ok: false, failures: [] });
await rm(result.outcomeFile);
const bodies: unknown[] = [];
await expect(() =>
postRuntime(result.manifestPath, context, recordingFetch(bodies)),
).rejects.toThrow("agent phase failed");
expect(bodies).toStrictEqual([{ body: "AI bot failed.\n\n> Profile: none" }]);
assert(await missing(result.runtimeDir));
});
test("removes runtime credentials when posting fails", async () => {
const result = await fixture({ ok: true, body: "done" });
await expect(() =>
postRuntime(result.manifestPath, context, () =>
Promise.resolve(new Response("failed", { status: 500 })),
),
).rejects.toThrow("500");
assert(await missing(result.runtimeDir));
});
test("preserves a Gitea transport failure without retrying a comment", async () => {
const result = await fixture({ ok: true, body: "done" });
let requests = 0;
await expect(() =>
postRuntime(result.manifestPath, context, () => {
requests += 1;
return Promise.reject(new Error(`network failure ${requests}`));
}),
).rejects.toThrow("network failure 1");
expect(requests).toBe(1);
assert(await missing(result.runtimeDir));
});
test.skipIf(IS_WINDOWS)("refuses to follow an agent artifact symlink", async () => {
const result = await fixture({ ok: true, body: "done" });
await symlink(result.reviewerFile, result.reviewPath);
let requests = 0;
await expect(() =>
postRuntime(result.manifestPath, context, () => {
requests += 1;
return Promise.resolve(new Response(null, { status: 200 }));
}),
).rejects.toThrow("regular private file");
expect(requests).toBe(1);
assert(await missing(result.runtimeDir));
});
test("reports an attributed failure when a pull request review is missing", async () => {
const result = await fixture({ ok: true, body: "done" });
const bodies: unknown[] = [];
await expect(() =>
postRuntime(
result.manifestPath,
{
...context,
eventName: "pull_request",
},
recordingFetch(bodies),
),
).rejects.toThrow("did not write");
expect(bodies).toStrictEqual([
{
body: "AI bot failed.\n\n> Tried: `codex` · `gpt-test` (failed)",
},
]);
assert(await missing(result.runtimeDir));
});
+109 -157
View File
@@ -1,147 +1,48 @@
import { join } from "jsr:@std/path@1";
import { readRuntimeManifest, type RuntimeManifest, type RuntimeOutcome } from "./runtime.ts";
import { failureFooter, type FailureReason, type ProfileFailure, withProfile } from "./profiles.ts";
import { readFile, rm } from "node:fs/promises";
import process from "node:process";
export type PostContext = {
import { isNotFound } from "./errors.ts";
import { parseAnchors, readAgentArtifact, readOutcome } from "./post-artifacts.ts";
import type { Anchor } from "./post-artifacts.ts";
import { failureFooter, withProfile } from "./profiles.ts";
import type { ProfileFailure } from "./profiles.ts";
import { readRuntimeManifest } from "./runtime.ts";
import type { RuntimeManifest, RuntimeOutcome } from "./runtime.ts";
export interface PostContext {
apiUrl: string;
repository: string;
issueIndex: string;
eventName: string;
};
}
type Anchor = {
path: string;
line: number;
side: "new" | "old";
body: string;
};
type SuccessOutcome = Extract<RuntimeOutcome, { ok: true }>;
const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*[A-Za-z]`, "g");
type GiteaRequest = (path: string, body: unknown) => Promise<void>;
// The reviewer's view of one issue or pull request.
interface Publisher {
context: PostContext;
request: GiteaRequest;
postComment(body: string): Promise<void>;
}
const ANSI = new RegExp(`${String.fromCodePoint(27)}\\[[0-9;]*[A-Za-z]`, "gu");
const stripAnsi = (text: string): string => text.replace(ANSI, "");
const VERDICTS: Record<string, [event: string, mark: string]> = {
Approved: ["APPROVED", "✅"],
"Changes requested": ["REQUEST_CHANGES", "🛑"],
};
const PROFILE_TYPES = ["claude", "codex", "opencode"];
const FAILURE_REASONS = ["sign-in failed", "usage exhausted", "failed"];
const failureComment = (failures: ProfileFailure[]): string =>
`AI bot failed.\n\n${failureFooter(failures)}`;
class GiteaRequestError extends Error {}
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) {
// Only known labels reach the comment, never raw agent output.
const failures = Array.isArray(outcome.failures) ? outcome.failures : [];
return {
ok: false,
failures: failures.flatMap((entry: unknown) => {
const { type, model, reason } = (entry ?? {}) as Record<string, unknown>;
return typeof type === "string" &&
PROFILE_TYPES.includes(type) &&
typeof model === "string" &&
typeof reason === "string" &&
FAILURE_REASONS.includes(reason)
? [
{
type: type as ProfileFailure["type"],
model,
reason: reason as FailureReason,
},
]
: [];
}),
};
}
if (
outcome.ok !== true ||
typeof outcome.body !== "string" ||
typeof outcome.reviewPath !== "string" ||
typeof outcome.anchorsPath !== "string" ||
typeof outcome.profile !== "object" ||
outcome.profile === null
) {
throw new Error("invalid agent outcome");
}
const profile = outcome.profile as Record<string, unknown>;
if (typeof profile.type !== "string" || typeof profile.model !== "string") {
throw new Error("invalid agent outcome");
}
return {
ok: true,
body: outcome.body,
reviewPath: outcome.reviewPath,
anchorsPath: outcome.anchorsPath,
profile: {
type: profile.type as "claude" | "codex" | "opencode",
model: profile.model,
},
};
}
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> => {
function giteaRequest(apiUrl: string, reviewerToken: string, fetcher: typeof fetch): GiteaRequest {
return async (path, body) => {
let response: Response;
try {
response = await fetcher(`${context.apiUrl}/${path}`, {
response = await fetcher(`${apiUrl}/${path}`, {
method: "POST",
headers: {
Authorization: `token ${reviewerToken}`,
@@ -157,39 +58,37 @@ export async function postRuntime(
throw new GiteaRequestError(`${path}: ${response.status} ${await response.text()}`);
}
};
const postComment = (body: string) =>
request(`repos/${context.repository}/issues/${context.issueIndex}/comments`, {
body: stripAnsi(body),
});
}
async function readSuccess(
manifest: RuntimeManifest,
publisher: Publisher,
): Promise<SuccessOutcome> {
let outcome: RuntimeOutcome;
try {
outcome = await readOutcome(manifest.outcomeFile);
} catch (error) {
await postComment(failureComment([]));
await publisher.postComment(failureComment([]));
throw new Error("agent phase failed", { cause: error });
}
if (!outcome.ok) {
await postComment(failureComment(outcome.failures));
await publisher.postComment(failureComment(outcome.failures));
throw new Error("agent phase failed");
}
return outcome;
}
try {
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(withProfile(outcome.body, outcome.profile));
return;
}
const [verdict, ...rest] = review.split("\n");
async function postReview(
publisher: Publisher,
outcome: SuccessOutcome,
review: string,
anchors: Anchor[],
): Promise<void> {
const { context, request } = publisher;
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`, {
const send = async (entries: Anchor[]): Promise<void> => {
await request(`repos/${context.repository}/pulls/${context.issueIndex}/reviews`, {
body: stripAnsi(
withProfile([`${mark} ${verdict.trim()}`, ...rest].join("\n"), outcome.profile),
),
@@ -201,29 +100,82 @@ export async function postRuntime(
old_position: side === "old" ? line : 0,
})),
});
await postReview(anchors).catch(async (error: Error) => {
};
await send(anchors).catch(async (error: unknown) => {
if (anchors.length === 0) throw error;
console.error(`inline comments rejected: ${error.message}`);
await postReview([]);
console.error(
`inline comments rejected: ${error instanceof Error ? error.message : String(error)}`,
);
await send([]);
});
}
async function publishResult(
manifest: RuntimeManifest,
outcome: SuccessOutcome,
publisher: Publisher,
): Promise<void> {
const review = await readAgentArtifact(manifest, outcome.reviewPath, "review.md");
if (review === null) {
if (publisher.context.eventName === "pull_request") {
throw new Error("agent did not write a pull request review");
}
await publisher.postComment(withProfile(outcome.body, outcome.profile));
return;
}
const anchorText = await readAgentArtifact(manifest, outcome.anchorsPath, "anchors.json");
const anchors = anchorText === null ? [] : parseAnchors(anchorText, outcome.anchorsPath);
await postReview(publisher, outcome, review, anchors);
}
async function publish(
manifest: RuntimeManifest,
context: PostContext,
fetcher: typeof fetch,
): Promise<void> {
const reviewerToken = (await readFile(manifest.reviewerFile, "utf8")).trim();
const request = giteaRequest(context.apiUrl, reviewerToken, fetcher);
const publisher: Publisher = {
context,
request,
postComment: async (body) => {
await request(`repos/${context.repository}/issues/${context.issueIndex}/comments`, {
body: stripAnsi(body),
});
},
};
const outcome = await readSuccess(manifest, publisher);
try {
await publishResult(manifest, outcome, publisher);
} catch (error) {
if (error instanceof GiteaRequestError) throw error;
await postComment(failureComment([{ ...outcome.profile, reason: "failed" }]));
await publisher.postComment(failureComment([{ ...outcome.profile, reason: "failed" }]));
throw error;
}
}
export async function postRuntime(
manifestPath: string,
context: PostContext,
fetcher: typeof fetch = fetch,
): Promise<void> {
const manifest = await readRuntimeManifest(manifestPath);
try {
await publish(manifest, context, fetcher);
} finally {
await Deno.remove(manifest.runtimeDir, { recursive: true }).catch((error) => {
if (!(error instanceof Deno.errors.NotFound)) throw error;
await rm(manifest.runtimeDir, { recursive: true }).catch((error: unknown) => {
if (!isNotFound(error)) 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`);
function required(name: string): string {
const value = process.env[name];
if (value === undefined || value === "") throw new Error(`${name} is not set`);
return value;
};
}
if (import.meta.main) {
await postRuntime(required("BOT_AGENTS_MANIFEST"), {
apiUrl: required("GITEA_API_URL"),
repository: required("GITEA_REPOSITORY"),
-303
View File
@@ -1,303 +0,0 @@
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: Extract<RuntimeOutcome, { 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,
profile: { type: "codex", model: "gpt-test" },
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\n\n> Profile: `codex` · `gpt-test`",
});
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[0] as { comments: Array<{ body: string }> }).comments[0].body,
"Finding\n\n> Profile: `codex` · `gpt-test`",
);
assertEquals((bodies[1] as { comments: unknown[] }).comments, []);
assertEquals(
(bodies[1] as { body: string }).body,
"✅ Approved\nReady\n\n> Profile: `codex` · `gpt-test`",
);
assert(await missing(result.runtimeDir));
});
Deno.test("reports each tried profile and keeps the action failed", async () => {
const result = await fixture({
ok: false,
failures: [
{ type: "codex", model: "gpt-test", reason: "sign-in failed" },
{ type: "claude", model: "claude-test", reason: "usage exhausted" },
{ type: "opencode", model: "openrouter/test", reason: "failed" },
],
});
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.\n\n> Tried: `codex` · `gpt-test` (sign-in failed), " +
"`claude` · `claude-test` (usage exhausted), " +
"`opencode` · `openrouter/test` (failed)",
},
]);
assert(!JSON.stringify(bodies).includes("reviewer-secret"));
assert(await missing(result.runtimeDir));
});
Deno.test("drops failure entries that are not known labels", async () => {
const result = await fixture({ ok: false, failures: [] });
await Deno.writeTextFile(
result.outcomeFile,
JSON.stringify({
ok: false,
failures: [
{ type: "codex", model: "gpt-test", reason: "token sk-secret leaked" },
{ type: "shell", model: "x", reason: "failed" },
{ type: "claude", model: "claude-test", reason: "sign-in failed" },
],
}),
);
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.\n\n> Tried: `claude` · `claude-test` (sign-in failed)",
},
]);
});
Deno.test("reports a generic failure when the runner wrote no outcome", async () => {
const result = await fixture({ ok: false, failures: [] });
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.\n\n> Profile: none" }]);
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("preserves a Gitea transport failure without retrying a comment", async () => {
const result = await fixture({ ok: true, body: "done" });
let requests = 0;
await assertRejects(
() =>
postRuntime(result.manifestPath, context, () => {
requests++;
return Promise.reject(new Error(`network failure ${requests}`));
}),
Error,
"network failure 1",
);
assertEquals(requests, 1);
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, 1);
assert(await missing(result.runtimeDir));
});
Deno.test("reports an attributed failure when a pull request review is missing", async () => {
const result = await fixture({ ok: true, body: "done" });
const bodies: unknown[] = [];
await assertRejects(
() =>
postRuntime(
result.manifestPath,
{
...context,
eventName: "pull_request",
},
(_url, init) => {
bodies.push(JSON.parse(String(init?.body)));
return Promise.resolve(new Response(null, { status: 201 }));
},
),
Error,
"did not write",
);
assertEquals(bodies, [
{
body: "AI bot failed.\n\n> Tried: `codex` · `gpt-test` (failed)",
},
]);
assert(await missing(result.runtimeDir));
});
+9 -6
View File
@@ -1,8 +1,13 @@
import { appendFile, rm } from "node:fs/promises";
import process from "node:process";
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`);
const value = process.env[name];
if (value === undefined || value === "") {
throw new Error(`${name} is not set`);
}
return value;
};
@@ -12,10 +17,8 @@ const manifest = await prepareRuntime(
env("BOT_AGENTS_MASTER_KEY"),
);
try {
await Deno.writeTextFile(env("GITHUB_OUTPUT"), `manifest=${manifest.manifestPath}\n`, {
append: true,
});
await appendFile(env("GITHUB_OUTPUT"), `manifest=${manifest.manifestPath}\n`);
} catch (error) {
await Deno.remove(manifest.runtimeDir, { recursive: true }).catch(() => undefined);
await rm(manifest.runtimeDir, { recursive: true }).catch(() => {});
throw error;
}
+36 -46
View File
@@ -1,4 +1,4 @@
import { assertEquals, assertRejects } from "jsr:@std/assert@1";
import { expect, test } from "vite-plus/test";
import {
AgentAuthError,
agentEnvironment,
@@ -9,14 +9,13 @@ import {
sanitizedEnvironment,
} from "./profiles.ts";
Deno.test("formats profile attribution as a Markdown quote", () => {
assertEquals(
profileFooter({ type: "opencode", model: "openrouter/qwen/qwen3.8-27b" }),
test("formats profile attribution as a Markdown quote", () => {
expect(profileFooter({ type: "opencode", model: "openrouter/qwen/qwen3.8-27b" })).toBe(
"> Profile: `opencode` · `openrouter/qwen/qwen3.8-27b`",
);
});
Deno.test("falls through only after usage exhaustion", async () => {
test("falls through only after usage exhaustion", async () => {
const visited: string[] = [];
const result = await runProfiles(
[
@@ -32,11 +31,11 @@ Deno.test("falls through only after usage exhaustion", async () => {
},
);
assertEquals(result, "done");
assertEquals(visited, ["claude", "codex"]);
expect(result).toBe("done");
expect(visited).toStrictEqual(["claude", "codex"]);
});
Deno.test("recognizes Claude's weekly limit message", async () => {
test("recognizes Claude's weekly limit message", async () => {
const result = await runProfiles(
[
{ type: "claude", model: "claude-sonnet-5", token: "first" },
@@ -50,10 +49,10 @@ Deno.test("recognizes Claude's weekly limit message", async () => {
},
);
assertEquals(result, "fallback");
expect(result).toBe("fallback");
});
Deno.test("recognizes provider API exhaustion status", async () => {
test("recognizes provider API exhaustion status", async () => {
const results = await Promise.all(
["API Error: 402 Insufficient funds", "HTTP error: 429"].map((message) =>
runProfiles(
@@ -69,10 +68,10 @@ Deno.test("recognizes provider API exhaustion status", async () => {
),
);
assertEquals(results, ["fallback", "fallback"]);
expect(results).toStrictEqual(["fallback", "fallback"]);
});
Deno.test("falls through after a sign-in failure and records each try", async () => {
test("falls through after a sign-in failure and records each try", async () => {
const failures: ProfileFailure[] = [];
const result = await runProfiles(
[
@@ -90,22 +89,20 @@ Deno.test("falls through after a sign-in failure and records each try", async ()
failures,
);
assertEquals(result, "fallback");
assertEquals(failures, [
expect(result).toBe("fallback");
expect(failures).toStrictEqual([
{ type: "codex", model: "gpt-5.6-terra", reason: "sign-in failed" },
{ type: "claude", model: "claude-sonnet-5", reason: "usage exhausted" },
]);
assertEquals(
failureFooter(failures),
expect(failureFooter(failures)).toStrictEqual(
"> Tried: `codex` · `gpt-5.6-terra` (sign-in failed), " +
"`claude` · `claude-sonnet-5` (usage exhausted)",
);
});
Deno.test("throws the last sign-in failure when every profile fails", async () => {
test("throws the last sign-in failure when every profile fails", async () => {
const failures: ProfileFailure[] = [];
await assertRejects(
() =>
await expect(() =>
runProfiles(
[
{ type: "codex", model: "gpt-5.6-terra" },
@@ -116,19 +113,17 @@ Deno.test("throws the last sign-in failure when every profile fails", async () =
},
failures,
),
AgentAuthError,
"claude sign-in failed",
);
assertEquals(
failures.map(({ reason }) => reason),
["sign-in failed", "sign-in failed"],
).rejects.toThrow(
expect.toSatisfy(
(e: unknown) => e instanceof AgentAuthError && e.message.includes("claude sign-in failed"),
),
);
expect(failures.map(({ reason }) => reason)).toStrictEqual(["sign-in failed", "sign-in failed"]);
});
Deno.test("preserves an ordinary agent failure", async () => {
test("preserves an ordinary agent failure", async () => {
const visited: string[] = [];
await assertRejects(
() =>
await expect(() =>
runProfiles(
[
{ type: "claude", model: "claude-sonnet-5", token: "first" },
@@ -139,16 +134,13 @@ Deno.test("preserves an ordinary agent failure", async () => {
throw new Error("tests failed");
},
),
Error,
"tests failed",
);
assertEquals(visited, ["claude"]);
).rejects.toThrow("tests failed");
expect(visited).toStrictEqual(["claude"]);
});
Deno.test("does not mistake incidental quota text for exhaustion", async () => {
test("does not mistake incidental quota text for exhaustion", async () => {
const visited: string[] = [];
await assertRejects(
() =>
await expect(() =>
runProfiles(
[
{ type: "claude", model: "claude-sonnet-5", token: "first" },
@@ -159,13 +151,11 @@ Deno.test("does not mistake incidental quota text for exhaustion", async () => {
throw new Error("quota parser test failed at line 429");
},
),
Error,
"quota parser test failed",
);
assertEquals(visited, ["claude"]);
).rejects.toThrow("quota parser test failed");
expect(visited).toStrictEqual(["claude"]);
});
Deno.test("exposes only the selected profile credential to the agent", () => {
test("exposes only the selected profile credential to the agent", () => {
const base = {
PATH: "/usr/bin",
HOME: "/home/bot",
@@ -190,18 +180,18 @@ Deno.test("exposes only the selected profile credential to the agent", () => {
tokenFile: "/runtime/profile-0/token",
};
assertEquals(agentEnvironment(base, profile, "selected"), {
expect(agentEnvironment(base, profile, "selected")).toStrictEqual({
PATH: "/usr/bin",
HOME: "/runtime/profile-0",
CLAUDE_CODE_OAUTH_TOKEN: "selected",
});
assertEquals(sanitizedEnvironment(base), {
expect(sanitizedEnvironment(base)).toStrictEqual({
PATH: "/usr/bin",
HOME: "/home/bot",
});
});
Deno.test("uses a private Codex home without inventing an API credential", () => {
test("uses a private Codex home without inventing an API credential", () => {
const profile = {
index: 1,
type: "codex" as const,
@@ -212,14 +202,14 @@ Deno.test("uses a private Codex home without inventing an API credential", () =>
tokenFile: null,
};
assertEquals(agentEnvironment({ PATH: "/bin" }, profile), {
expect(agentEnvironment({ PATH: "/bin" }, profile)).toStrictEqual({
PATH: "/bin",
HOME: "/runtime/profile-1",
CODEX_HOME: "/runtime/profile-1/.codex",
});
});
Deno.test("exposes an OpenRouter key only to the OpenCode profile", () => {
test("exposes an OpenRouter key only to the OpenCode profile", () => {
const profile = {
index: 2,
type: "opencode" as const,
@@ -230,7 +220,7 @@ Deno.test("exposes an OpenRouter key only to the OpenCode profile", () => {
tokenFile: "/runtime/profile-2/token",
};
assertEquals(agentEnvironment({ PATH: "/bin" }, profile, "selected"), {
expect(agentEnvironment({ PATH: "/bin" }, profile, "selected")).toStrictEqual({
PATH: "/bin",
HOME: "/runtime/profile-2",
OPENROUTER_API_KEY: "selected",
+38 -11
View File
@@ -1,8 +1,17 @@
export type BotProfile = {
type: "claude" | "codex" | "opencode";
export type ProfileType = "claude" | "codex" | "opencode";
export interface BotProfile {
type: ProfileType;
model: string;
token?: string;
};
}
const PROFILE_TYPES: Set<unknown> = new Set(["claude", "codex", "opencode"]);
const FAILURE_REASONS: Set<unknown> = new Set(["sign-in failed", "usage exhausted", "failed"]);
export function isProfileType(value: unknown): value is ProfileType {
return PROFILE_TYPES.has(value);
}
export function profileFooter(profile: Pick<BotProfile, "type" | "model">): string {
return `> Profile: \`${profile.type}\` · \`${profile.model}\``;
@@ -14,6 +23,10 @@ export function withProfile(body: string, profile: Pick<BotProfile, "type" | "mo
export type FailureReason = "sign-in failed" | "usage exhausted" | "failed";
export function isFailureReason(value: unknown): value is FailureReason {
return FAILURE_REASONS.has(value);
}
export type ProfileFailure = Pick<BotProfile, "type" | "model"> & {
reason: FailureReason;
};
@@ -22,7 +35,7 @@ export type ProfileFailure = Pick<BotProfile, "type" | "model"> & {
export class AgentAuthError extends Error {}
function isUsageExhausted(error: unknown): boolean {
return /(?:usage|rate) limit|hit your (?:weekly )?limit|insufficient_quota|exceeded your current quota|too many requests|(?:api error|http error|http|status(?: code)?)[ :]+4(?:02|29)\b/i.test(
return /(?:usage|rate) limit|hit your (?:weekly )?limit|insufficient_quota|exceeded your current quota|too many requests|(?:api error|http error|http|status(?: code)?)[ :]+4(?:02|29)\b/iu.test(
String(error),
);
}
@@ -39,12 +52,18 @@ export function failureFooter(failures: ProfileFailure[]): string {
.join(", ")}`;
}
export async function runProfiles<T, P extends Pick<BotProfile, "type" | "model">>(
// Tries the profiles from `index` on, moving on only after a sign-in failure
// or exhausted usage, and records every profile it tried.
async function attemptFrom<T, P extends Pick<BotProfile, "type" | "model">>(
profiles: P[],
attempt: (profile: P) => Promise<T>,
failures: ProfileFailure[] = [],
failures: ProfileFailure[],
index: number,
): Promise<T> {
for (const [index, profile] of profiles.entries()) {
const profile = profiles[index];
if (profile === undefined) {
throw new Error("profiles must contain at least one profile");
}
try {
return await attempt(profile);
} catch (error) {
@@ -52,9 +71,16 @@ export async function runProfiles<T, P extends Pick<BotProfile, "type" | "model"
failures.push({ type: profile.type, model: profile.model, reason });
if (reason === "failed" || index === profiles.length - 1) throw error;
console.error(`${profile.type} ${reason}; trying next profile`);
return attemptFrom(profiles, attempt, failures, index + 1);
}
}
throw new Error("profiles must contain at least one profile");
}
export function runProfiles<T, P extends Pick<BotProfile, "type" | "model">>(
profiles: P[],
attempt: (profile: P) => Promise<T>,
failures: ProfileFailure[] = [],
): Promise<T> {
return attemptFrom(profiles, attempt, failures, 0);
}
export function sanitizedEnvironment(environment: Record<string, string>): Record<string, string> {
@@ -85,7 +111,8 @@ export function sanitizedEnvironment(environment: Record<string, string>): Recor
"SSL_CERT_DIR",
"NODE_EXTRA_CA_CERTS",
]) {
if (environment[name] !== undefined) result[name] = environment[name];
const value = environment[name];
if (value !== undefined) result[name] = value;
}
return result;
}
@@ -98,7 +125,7 @@ export function agentEnvironment(
const result = sanitizedEnvironment(environment);
result.HOME = profile.home;
if (profile.type === "codex") result.CODEX_HOME = `${profile.home}/.codex`;
if (token) {
if (token !== undefined && token !== "") {
const tokenName = {
claude: "CLAUDE_CODE_OAUTH_TOKEN",
codex: "OPENAI_API_KEY",
+79
View File
@@ -0,0 +1,79 @@
// The agent phase's environment and the helpers every agent runner shares. It
// is read once, when the runner starts, as the single-file runner did.
import { chmod, chown, writeFile } from "node:fs/promises";
import process from "node:process";
import { agentCommand } from "./isolation.ts";
import { agentEnvironment, sanitizedEnvironment } from "./profiles.ts";
import { readRuntimeManifest } from "./runtime.ts";
import type { RuntimeProfile } from "./runtime.ts";
export const env = (name: string): string => {
const value = process.env[name];
if (value === undefined) {
throw new Error(`${name} is not set`);
}
return value;
};
function definedEnvironment(): Record<string, string> {
const result: Record<string, string> = {};
for (const [name, value] of Object.entries(process.env)) {
if (value !== undefined) {
result[name] = value;
}
}
return result;
}
export const API = env("GITEA_API_URL");
export const REPO = env("GITEA_REPOSITORY");
export const INDEX = env("ISSUE_INDEX");
export const EVENT = env("EVENT_NAME");
const AUTHOR_TOKEN = env("GITEA_TOKEN");
export const WORKSPACE = env("GITHUB_WORKSPACE");
export const MANIFEST = await readRuntimeManifest(env("BOT_AGENTS_MANIFEST"));
export const PROCESS_ENV = sanitizedEnvironment(definedEnvironment());
export async function gitea(path: string, body?: unknown): Promise<Response> {
const response = await fetch(`${API}/${path}`, {
method: body === undefined ? "GET" : "POST",
headers: {
Authorization: `token ${AUTHOR_TOKEN}`,
"Content-Type": "application/json",
},
body: body === undefined ? null : JSON.stringify(body),
});
if (!response.ok) {
throw new Error(`${path}: ${response.status} ${await response.text()}`);
}
return response;
}
export async function postAuthorComment(body: string): Promise<void> {
await gitea(`repos/${REPO}/issues/${INDEX}/comments`, { body });
}
export 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}`);
}
}
export async function makeProfileFile(profile: RuntimeProfile, name: string): Promise<string> {
const path = `${profile.home}/${name}`;
await writeFile(path, "", { mode: 0o600 });
await chmod(path, 0o600);
await chown(path, profile.uid, profile.gid);
return path;
}
+84 -364
View File
@@ -1,113 +1,66 @@
// Runs one ordered agent profile. Credential decryption and reviewer posting
// live in separate processes so neither privileged secret reaches this runner.
import { TextLineStream } from "jsr:@std/streams@1/text-line-stream";
import {
type CodexAuthentication,
codexAuthentication,
isClaudeAuthFailure,
isCodexAuthFailure,
persistCodexAuth,
retryInvalidToken,
stageCodexAuth,
} from "./auth.ts";
import { agentCommand, killProfileProcesses, readProfileFile } from "./isolation.ts";
import {
AgentAuthError,
agentEnvironment,
type ProfileFailure,
runProfiles,
sanitizedEnvironment,
withProfile,
} from "./profiles.ts";
import {
collectOpenCodeOutput,
openCodeArgs,
openCodeError,
parseOpenCodeEvents,
} from "./opencode.ts";
import { readRuntimeManifest, type RuntimeOutcome, type RuntimeProfile } from "./runtime.ts";
import { chmod, readFile, writeFile } from "node:fs/promises";
type GiteaUser = { login: string; email: string };
type GiteaComment = { user: GiteaUser; created_at: string; body: string };
type AttemptResult = {
import { codexAuthentication, persistCodexAuth, stageCodexAuth } from "./auth.ts";
import { runAgent } from "./agents.ts";
import type { AgentAnswer } from "./agents.ts";
import { output } from "./command.ts";
import { killProfileProcesses, readProfileFile } from "./isolation.ts";
import { isObject } from "./json.ts";
import { runProfiles } from "./profiles.ts";
import type { ProfileFailure } from "./profiles.ts";
import {
API,
env,
EVENT,
gitea,
INDEX,
makeProfileFile,
MANIFEST,
PROCESS_ENV,
REPO,
runAgentCommand,
WORKSPACE,
} from "./run-context.ts";
import type { RuntimeOutcome, RuntimeProfile } from "./runtime.ts";
interface AttemptResult {
body: string;
reviewPath: string;
anchorsPath: string;
profile: Pick<RuntimeProfile, "type" | "model">;
};
}
const env = (name: string): string => {
const value = Deno.env.get(name);
if (value === undefined) throw new Error(`${name} is not set`);
return value;
};
interface RenderedPrompt {
prompt: string;
reviewPath: string;
anchorsPath: string;
}
const API = env("GITEA_API_URL");
const REPO = env("GITEA_REPOSITORY");
const INDEX = env("ISSUE_INDEX");
const EVENT = env("EVENT_NAME");
const AUTHOR_TOKEN = env("GITEA_TOKEN");
const WORKSPACE = env("GITHUB_WORKSPACE");
const MANIFEST = await readRuntimeManifest(env("BOT_AGENTS_MANIFEST"));
const PROCESS_ENV = sanitizedEnvironment(Deno.env.toObject());
const RULES_PATH = "repos/commons/code-rules/raw/README.md";
const CODEX_AUTH = "/root/.codex/auth.json";
const SKILLS_INSTALL = "skills add obra/superpowers";
async function gitea(path: string, body?: unknown): Promise<Response> {
const response = await fetch(`${API}/${path}`, {
method: body === undefined ? "GET" : "POST",
headers: {
Authorization: `token ${AUTHOR_TOKEN}`,
"Content-Type": "application/json",
},
body: body === undefined ? undefined : JSON.stringify(body),
});
if (!response.ok) {
throw new Error(`${path}: ${response.status} ${await response.text()}`);
}
return response;
}
async function postAuthorComment(body: string): Promise<void> {
await gitea(`repos/${REPO}/issues/${INDEX}/comments`, { body });
}
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}`);
const { success, code } = await output(command, { args, env: PROCESS_ENV, clearEnv: true });
if (!success) {
throw new Error(`${command} ${args[0]} exited with ${code}`);
}
}
async function configureGitAuthor(): Promise<void> {
const user: GiteaUser = await (await gitea("user")).json();
for (const [key, value] of [
["name", user.login],
["email", user.email],
]) {
await runRoot("git", ["config", `user.${key}`, value]);
const user: unknown = await (await gitea("user")).json();
if (!isObject(user) || typeof user.login !== "string" || typeof user.email !== "string") {
throw new Error("user: unexpected Gitea response");
}
await runRoot("git", ["config", "user.name", user.login]);
await runRoot("git", ["config", "user.email", user.email]);
}
async function readProfileToken(profile: RuntimeProfile): Promise<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}`);
const token = profile.tokenFile === null ? undefined : await readFile(profile.tokenFile, "utf8");
return token;
}
async function prepareProfile(profile: RuntimeProfile): Promise<void> {
@@ -128,19 +81,26 @@ async function prepareProfile(profile: RuntimeProfile): Promise<void> {
]);
}
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 (
// One issue comment as the prompt quotes it.
function formatComment(comment: unknown): string {
const { user, created_at: createdAt, body } = isObject(comment) ? comment : {};
const login = isObject(user) ? user.login : undefined;
return `## ${String(login)} at ${String(createdAt)}\n\n${String(body)}\n`;
}
async function promptValues(
reviewPath: string,
anchorsPath: string,
): Promise<Record<string, string>> {
const comments: unknown = await (
await gitea(`repos/${REPO}/issues/${INDEX}/comments?limit=100`)
).json();
const values: Record<string, string> = {
if (!Array.isArray(comments)) {
throw new TypeError("comments: unexpected Gitea response");
}
return {
COMMENT: env("COMMENT"),
ISSUE_COMMENTS: comments
.map((comment) => `## ${comment.user.login} at ${comment.created_at}\n\n${comment.body}\n`)
.join("\n"),
ISSUE_COMMENTS: comments.map((comment: unknown) => formatComment(comment)).join("\n"),
CODE_RULES: await (await gitea(RULES_PATH)).text(),
EVENT_NAME: EVENT,
GITEA_API_URL: API,
@@ -149,302 +109,62 @@ async function renderPrompt(
REVIEW_PATH: reviewPath,
ANCHORS_PATH: anchorsPath,
};
const template = await Deno.readTextFile(new URL("../prompt.md", import.meta.url));
}
async function renderPrompt(profile: RuntimeProfile): Promise<RenderedPrompt> {
const reviewPath = await makeProfileFile(profile, "review.md");
const anchorsPath = await makeProfileFile(profile, "anchors.json");
const values = await promptValues(reviewPath, anchorsPath);
const template = await readFile(new URL("../prompt.md", import.meta.url), "utf8");
return {
prompt: template.replace(/\$\{(\w+)\}/g, (match, name) => values[name] ?? match),
prompt: template.replaceAll(
/\$\{(\w+)\}/gu,
(match: string, name: string) => values[name] ?? match,
),
reviewPath,
anchorsPath,
};
}
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:
| {
is_error?: boolean;
result?: string;
subtype: string;
api_error_status?: number | null;
}
| undefined;
let error = "";
const copyError = claude.stderr.pipeThrough(new TextDecoderStream()).pipeTo(
new WritableStream({
write(chunk) {
error += chunk;
console.error(chunk);
},
}),
);
const lines = claude.stdout
.pipeThrough(new TextDecoderStream())
.pipeThrough(new TextLineStream());
for await (const line of lines) {
if (!line) continue;
const event = JSON.parse(line);
for (const part of event.message?.content ?? []) {
const text = part.thinking ?? part.text ?? part.name;
if (text) console.log(text);
}
if (event.type === "result") result = event;
}
const [status] = await Promise.all([claude.status, copyError]);
if (result?.is_error) {
const failure = new Error(result.result ?? `claude ended with ${result.subtype}`);
if (isClaudeAuthFailure(result)) {
throw new AgentAuthError("claude sign-in failed", { cause: failure });
}
throw failure;
}
if (result?.result === undefined) {
throw new Error(error.trim() || `claude ended with ${result?.subtype ?? "no result"}`);
}
if (!status.success) {
throw new Error(error.trim() || `claude exited with ${status.code}`);
}
return result.result;
}
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();
let shown = "";
const collect = (stream: ReadableStream<Uint8Array>): Promise<void> =>
stream
.pipeThrough(new TextDecoderStream())
.pipeTo(new WritableStream({ write: (chunk) => void (shown += chunk) }));
const drained = Promise.all([collect(login.stdout), collect(login.stderr)]);
const status = login.status;
await new Promise((resolve) => setTimeout(resolve, 3000));
while (shown.trim() === "") {
const exited = await Promise.race([
status.then(() => true),
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 100)),
]);
if (exited) {
throw new AgentAuthError("AI bot login produced no instructions");
}
}
await postAuthorComment(withProfile(shown, profile));
const [result] = await Promise.all([status, drained]);
if (!result.success) {
throw new AgentAuthError(`codex login exited with ${result.code}`);
}
}
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 = agentCommand(
profile,
"codex",
[
"exec",
"--model",
profile.model,
"--dangerously-bypass-approvals-and-sandbox",
"--output-last-message",
outputPath,
prompt,
],
{
cwd: WORKSPACE,
env: environment,
clearEnv: true,
stdout: "inherit",
stderr: "piped",
},
).spawn();
const decoder = new TextDecoder();
let error = "";
for await (const chunk of codex.stderr) {
await Deno.stderr.write(chunk);
error += decoder.decode(chunk, { stream: true });
}
error += decoder.decode();
// Codex echoes the prompt, whose comments may quote auth errors.
return { status: await codex.status, error: error.replaceAll(prompt, "") };
};
const result =
authentication.kind === "api-key"
? await attempt()
: await retryInvalidToken(attempt, async () => {
await runAgentCommand(profile, "codex", ["logout"]);
await codexDeviceLogin(profile, environment);
});
if (!result.status.success) {
const failure = new Error(result.error.trim() || `codex exited with ${result.status.code}`);
if (isCodexAuthFailure(result.error)) {
throw new AgentAuthError("codex sign-in failed", { cause: failure });
}
throw failure;
}
return outputPath;
}
async function runOpenCode(
profile: RuntimeProfile,
token: string,
prompt: string,
): Promise<string> {
const process = agentCommand(profile, "opencode", openCodeArgs(profile.model, prompt), {
cwd: WORKSPACE,
env: agentEnvironment(PROCESS_ENV, profile, token),
clearEnv: true,
stdout: "piped",
stderr: "piped",
}).spawn();
const output = collectOpenCodeOutput(
process.stdout,
process.stderr,
async (chunk) => {
await Deno.stdout.write(chunk);
},
async (chunk) => {
await Deno.stderr.write(chunk);
},
);
const [status, streams] = await Promise.all([process.status, output]);
const error = streams.stderr.trim();
if (!status.success) {
throw (
openCodeError(streams.stdout) ?? new Error(error || `opencode exited with ${status.code}`)
);
}
return parseOpenCodeEvents(streams.stdout);
}
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;
let answer: AgentAnswer = {};
let rendered: RenderedPrompt | undefined;
try {
if (authentication?.kind === "persistent") {
stagedCodexAuth = await stageCodexAuth(CODEX_AUTH, profile.home, profile.uid, profile.gid);
}
await prepareProfile(profile);
const rendered = await renderPrompt(profile);
reviewPath = rendered.reviewPath;
anchorsPath = rendered.anchorsPath;
if (profile.type === "claude") {
body = await runClaude(profile, token ?? "", rendered.prompt);
} else if (profile.type === "codex") {
resultPath = await runCodex(profile, rendered.prompt, authentication!);
} else {
body = await runOpenCode(profile, token ?? "", rendered.prompt);
}
rendered = await renderPrompt(profile);
answer = await runAgent(profile, token, 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) {
const body =
answer.resultPath === undefined
? answer.body
: await readProfileFile(profile, answer.resultPath);
if (body === undefined || rendered === undefined) {
throw new Error("agent attempt produced no result");
}
return {
body,
reviewPath,
anchorsPath,
profile: { type: profile.type, model: profile.model },
};
const { reviewPath, anchorsPath } = rendered;
return { body, reviewPath, anchorsPath, profile: { type: profile.type, model: profile.model } };
}
async function writeOutcome(outcome: RuntimeOutcome): Promise<void> {
await Deno.writeTextFile(MANIFEST.outcomeFile, JSON.stringify(outcome), {
mode: 0o600,
});
await Deno.chmod(MANIFEST.outcomeFile, 0o600);
await writeFile(MANIFEST.outcomeFile, JSON.stringify(outcome), { mode: 0o600 });
await chmod(MANIFEST.outcomeFile, 0o600);
}
const failures: ProfileFailure[] = [];
try {
await configureGitAuthor();
await writeOutcome({
ok: true,
...(await runProfiles(MANIFEST.profiles, runProfile, failures)),
});
await writeOutcome({ ok: true, ...(await runProfiles(MANIFEST.profiles, runProfile, failures)) });
} catch (error) {
console.error(error);
await writeOutcome({ ok: false, failures });
+105
View File
@@ -0,0 +1,105 @@
import { chmod, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
import process from "node:process";
import { fileURLToPath } from "node:url";
import { assert, expect, test } from "vite-plus/test";
import { output } from "./command.ts";
import { prepareRuntime } from "./runtime.ts";
import type { RuntimeProfile } from "./runtime.ts";
import { IS_ROOT, makeTempDir } from "./testing.ts";
const MASTER_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8";
const ENCRYPTED = "v1:ICEiIyQlJicoKSor:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA";
// Runs `command` as the profile's user, from the root directory.
async function runAs(profile: RuntimeProfile, command: string, path: string): Promise<boolean> {
const result = await output(command, {
args: [path],
cwd: "/",
uid: profile.uid,
gid: profile.gid,
});
return result.success;
}
async function expectPrivate(path: string, uid: number, mode: number): Promise<void> {
const info = await stat(path);
expect(info.uid).toStrictEqual(uid);
expect(info.mode & 0o777).toBe(mode);
}
test.skipIf(!IS_ROOT)("prepares root-only reviewer and isolated profile credentials", async () => {
const root = await makeTempDir();
await chmod(root, 0o711);
const action = await makeTempDir();
try {
await writeFile(
`${action}/profiles.json`,
JSON.stringify({
"reviewer-token": ENCRYPTED,
profiles: [{ type: "claude", token: ENCRYPTED }, { type: "codex" }],
}),
);
const manifest = await prepareRuntime(action, root, MASTER_KEY);
const serialized = await readFile(manifest.manifestPath, "utf8");
assert(!serialized.includes("secret-token"));
expect(await readFile(manifest.reviewerFile, "utf8")).toBe("secret-token");
await expectPrivate(manifest.reviewerFile, 0, 0o600);
const [claude, codex] = manifest.profiles;
assert(claude !== undefined && codex !== undefined);
expect(claude.uid).not.toStrictEqual(codex.uid);
await expectPrivate(claude.home, claude.uid, 0o700);
assert(claude.tokenFile !== null);
expect(await readFile(claude.tokenFile, "utf8")).toBe("secret-token");
await expectPrivate(claude.tokenFile, claude.uid, 0o600);
expect((await stat(codex.home)).uid).toStrictEqual(codex.uid);
expect(codex.tokenFile).toBe(null);
const ownToken = await output("/bin/cat", {
args: [claude.tokenFile],
cwd: "/",
uid: claude.uid,
gid: claude.gid,
});
expect(ownToken.success).toBe(true);
expect(new TextDecoder().decode(ownToken.stdout)).toBe("secret-token");
expect(await runAs(claude, "/bin/cat", manifest.reviewerFile)).toBe(false);
expect(await runAs(claude, "/bin/ls", codex.home)).toBe(false);
} finally {
await rm(root, { recursive: true });
await rm(action, { recursive: true });
}
});
test.skipIf(!IS_ROOT)("removes prepared credentials when output publication fails", async () => {
const root = await makeTempDir();
const action = await makeTempDir();
try {
await writeFile(
`${action}/profiles.json`,
JSON.stringify({
"reviewer-token": ENCRYPTED,
profiles: [{ type: "claude", token: ENCRYPTED }],
}),
);
const result = await output(process.execPath, {
args: [fileURLToPath(new URL("prepare.ts", import.meta.url))],
env: {
ACTION_PATH: action,
RUNNER_TEMP: root,
BOT_AGENTS_MASTER_KEY: MASTER_KEY,
GITHUB_OUTPUT: action,
},
clearEnv: true,
});
expect(result.success).toBe(false);
expect(await readdir(root)).toStrictEqual([]);
} finally {
await rm(root, { recursive: true });
await rm(action, { recursive: true });
}
});
+56 -36
View File
@@ -1,5 +1,8 @@
import { join } from "jsr:@std/path@1";
import { chmod, chown, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { parseCredentials } from "./credentials.ts";
import { isMapping } from "./json.ts";
import type { BotProfile, ProfileFailure } from "./profiles.ts";
const FIRST_PROFILE_UID = 20_000;
@@ -12,13 +15,13 @@ export type RuntimeProfile = Pick<BotProfile, "type" | "model"> & {
tokenFile: string | null;
};
export type RuntimeManifest = {
export interface RuntimeManifest {
runtimeDir: string;
manifestPath: string;
reviewerFile: string;
outcomeFile: string;
profiles: RuntimeProfile[];
};
}
export type RuntimeOutcome =
| {
@@ -31,9 +34,27 @@ export type RuntimeOutcome =
| { ok: false; failures: ProfileFailure[] };
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);
await writeFile(path, value, { mode: 0o600 });
await chmod(path, 0o600);
await chown(path, uid, gid);
}
async function prepareProfile(
runtimeDir: string,
profile: BotProfile,
index: number,
): Promise<RuntimeProfile> {
const uid = FIRST_PROFILE_UID + index;
const home = join(runtimeDir, `profile-${index}`);
await mkdir(home, { mode: 0o700 });
await chmod(home, 0o700);
await chown(home, uid, uid);
const tokenFile =
profile.token === undefined || profile.token === "" ? null : join(home, "token");
if (tokenFile !== null && profile.token !== undefined) {
await writePrivate(tokenFile, profile.token, uid, uid);
}
return { index, type: profile.type, model: profile.model, uid, gid: uid, home, tokenFile };
}
export async function prepareRuntime(
@@ -42,39 +63,23 @@ export async function prepareRuntime(
masterKey: string,
): Promise<RuntimeManifest> {
const credentials = await parseCredentials(
await Deno.readTextFile(join(actionPath, "profiles.yml")),
await readFile(join(actionPath, "profiles.json"), "utf8"),
masterKey,
);
const runtimeDir = await Deno.makeTempDir({
dir: runtimeRoot,
prefix: "bot-agents-",
});
const runtimeDir = await mkdtemp(join(runtimeRoot, "bot-agents-"));
try {
await Deno.chmod(runtimeDir, 0o711);
await 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,
});
}
// One profile at a time, in order, as each claims the next UID.
const profiles = await credentials.profiles.reduce<Promise<RuntimeProfile[]>>(
async (previous, profile, index) => [
...(await previous),
await prepareProfile(runtimeDir, profile, index),
],
Promise.resolve([]),
);
const manifestPath = join(runtimeDir, "manifest.json");
const manifest: RuntimeManifest = {
@@ -87,11 +92,26 @@ export async function prepareRuntime(
await writePrivate(manifestPath, JSON.stringify(manifest), 0, 0);
return manifest;
} catch (error) {
await Deno.remove(runtimeDir, { recursive: true }).catch(() => undefined);
await rm(runtimeDir, { recursive: true }).catch(() => {});
throw error;
}
}
export async function readRuntimeManifest(path: string): Promise<RuntimeManifest> {
return JSON.parse(await Deno.readTextFile(path));
function isRuntimeManifest(value: unknown): value is RuntimeManifest {
return (
isMapping(value) &&
typeof value.runtimeDir === "string" &&
typeof value.manifestPath === "string" &&
typeof value.reviewerFile === "string" &&
typeof value.outcomeFile === "string" &&
Array.isArray(value.profiles)
);
}
export async function readRuntimeManifest(path: string): Promise<RuntimeManifest> {
const value: unknown = JSON.parse(await readFile(path, "utf8"));
if (!isRuntimeManifest(value)) {
throw new Error(`${path}: invalid runtime manifest`);
}
return value;
}
-102
View File
@@ -1,102 +0,0 @@
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 });
}
},
});
+23
View File
@@ -0,0 +1,23 @@
// Test support: the current user's IDs and a fresh temporary directory.
import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import process from "node:process";
export function currentUid(): number {
return process.getuid?.() ?? -1;
}
export function currentGid(): number {
return process.getgid?.() ?? -1;
}
// The isolation tests switch UIDs, which only root may do.
export const IS_ROOT: boolean = currentUid() === 0;
// Symlinks and POSIX modes, as these tests use them, need a Unix host.
export const IS_WINDOWS: boolean = process.platform === "win32";
export function makeTempDir(): Promise<string> {
return mkdtemp(join(tmpdir(), "bot-agents-test-"));
}
-174
View File
@@ -1,174 +0,0 @@
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");
for (const step of steps) {
if (typeof step.run === "string") {
assertEquals(step.run.includes("\n"), false, String(step.name));
}
}
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 source protection script stages immutable action files", async () => {
const temp = await Deno.makeTempDir();
const outputFile = `${temp}/output`;
try {
const output = await new Deno.Command("/bin/bash", {
args: ["scripts/protect-source.sh"],
env: {
ACTION_PATH: Deno.cwd(),
GITHUB_OUTPUT: outputFile,
RUNNER_TEMP: temp,
},
stderr: "piped",
}).output();
assert(output.success, new TextDecoder().decode(output.stderr));
const path = (await Deno.readTextFile(outputFile)).trim().slice(5);
for (const file of [
"src/prepare.ts",
"src/run.ts",
"src/post.ts",
"profiles.yml",
"prompt.md",
]) {
const stat = await Deno.stat(`${path}/${file}`);
assert(stat.isFile, file);
assertEquals(stat.mode! & 0o022, 0, file);
}
} finally {
await Deno.remove(temp, { recursive: true });
}
});
Deno.test("module-relative runtime assets exist", async () => {
const module = new URL("run.ts", import.meta.url);
const source = await Deno.readTextFile(module);
for (const match of source.matchAll(/new URL\("([^"]+)", import\.meta\.url\)/g)) {
const asset = new URL(match[1], module);
const stat = await Deno.stat(asset);
assert(stat.isFile, asset.pathname);
}
});
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 ["src/prepare.ts", "src/run.ts", "src/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("src/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);
}
});
Deno.test("installs the OpenCode CLI", async () => {
const action = await Deno.readTextFile("action.yml");
assert(action.includes("opencode-ai"));
});
Deno.test("profile preparation does not receive provider credentials", async () => {
const runner = await Deno.readTextFile("src/run.ts");
assert(runner.includes("await prepareProfile(profile);"));
assertEquals(runner.includes("prepareProfile(profile, token)"), false);
});
Deno.test("central profiles fall back from Codex to Claude to OpenCode", async () => {
const credentials = await yaml("profiles.yml");
const profiles = sequence(credentials.profiles, "profiles");
assertEquals(
profiles.map((profile) => profile.type),
["codex", "claude", "opencode"],
);
assertEquals(profiles[2].model, "openrouter/qwen/qwen3.8-27b");
assert(String(profiles[2].token).startsWith("v1:"));
});
+27
View File
@@ -0,0 +1,27 @@
import process from "node:process";
import { fileURLToPath } from "node:url";
import { expect, test } from "vite-plus/test";
import { output } from "../src/command.ts";
// Each phase runs as `node src/<phase>.ts` with no build step. Node loads the
// whole import graph, stripping types, before the phase reads its first
// variable, so failing on that variable proves every module loads.
const PHASES: [phase: string, firstVariable: string][] = [
["prepare", "ACTION_PATH"],
["run", "GITEA_API_URL"],
["post", "BOT_AGENTS_MANIFEST"],
];
test.each(PHASES)("Node loads the %s phase", async (phase, firstVariable) => {
const path = fileURLToPath(new URL(`../src/${phase}.ts`, import.meta.url));
const result = await output(process.execPath, {
args: [path],
env: { PATH: process.env.PATH ?? "" },
clearEnv: true,
stdin: "null",
});
expect(result.success).toBe(false);
expect(new TextDecoder().decode(result.stderr)).toContain(`${firstVariable} is not set`);
});
+175
View File
@@ -0,0 +1,175 @@
import { readdir, readFile, rm, stat } from "node:fs/promises";
import process from "node:process";
import { assert, expect, test } from "vite-plus/test";
import { parse } from "yaml";
import { output } from "../src/command.ts";
import { isNotFound } from "../src/errors.ts";
import { isMapping } from "../src/json.ts";
import type { JsonObject } from "../src/json.ts";
import { makeTempDir } from "../src/testing.ts";
const mapping = (value: unknown, at: string): JsonObject => {
assert(isMapping(value), at);
return value;
};
const sequence = (value: unknown, at: string): JsonObject[] => {
assert(Array.isArray(value), at);
return value.map((entry: unknown, index) => mapping(entry, `${at}[${index}]`));
};
async function yaml(path: string): Promise<JsonObject> {
const value: unknown = parse(await readFile(path, "utf8"));
return mapping(value, path);
}
async function yamlFiles(): Promise<string[]> {
const entries = await readdir(".", { recursive: true, withFileTypes: true });
return entries
.filter((entry) => entry.isFile() && /\.ya?ml$/u.test(entry.name))
.map((entry) => `${entry.parentPath}/${entry.name}`.replace(/^\.\//u, ""))
.filter((path) => !/^(?:\.git|\.superpowers|node_modules)\//u.test(path));
}
const findStep = (steps: JsonObject[], key: string, value: string): JsonObject => {
const step = steps.find((entry) => entry[key] === value);
assert(step !== undefined, `${key}: ${value}`);
return step;
};
test("all committed YAML parses", async () => {
const files = await yamlFiles();
assert(files.length > 0);
await Promise.all(files.map((file) => yaml(file)));
});
test("the action exposes only the master key and scopes it to preparation", async () => {
const action = await yaml("action.yml");
expect(Object.keys(mapping(action.inputs, "inputs"))).toStrictEqual(["master-key"]);
const steps = sequence(mapping(action.runs, "runs").steps, "runs.steps");
for (const step of steps) {
if (typeof step.run === "string") {
expect(step.run.includes("\n"), String(step.name)).toBe(false);
}
}
const checkout = findStep(steps, "uses", "actions/checkout@v4");
const prepare = findStep(steps, "id", "prepare");
const agent = findStep(steps, "id", "agent");
const post = findStep(steps, "id", "post");
findStep(steps, "id", "source");
expect(mapping(checkout.with, "checkout.with").token).toBe("${{ gitea.token }}");
expect(mapping(prepare.env, "prepare.env").BOT_AGENTS_MASTER_KEY).toBe(
"${{ inputs.master-key }}",
);
expect(JSON.stringify(agent).includes("BOT_AGENTS_MASTER_KEY")).toBe(false);
expect(JSON.stringify(post).includes("BOT_AGENTS_MASTER_KEY")).toBe(false);
expect(mapping(agent.env, "agent.env").GITEA_TOKEN).toBe("${{ gitea.token }}");
assert(String(post.if).includes("always()"));
for (const step of [prepare, agent, post]) {
assert(String(step.run).includes("steps.source.outputs.path"));
}
});
test("the source protection script stages immutable action files", async () => {
const temp = await makeTempDir();
const outputFile = `${temp}/output`;
try {
const result = await output("/bin/bash", {
args: ["scripts/protect-source.sh"],
env: { ACTION_PATH: process.cwd(), GITHUB_OUTPUT: outputFile, RUNNER_TEMP: temp },
stderr: "piped",
});
assert(result.success, new TextDecoder().decode(result.stderr));
const path = (await readFile(outputFile, "utf8")).trim().slice(5);
const files = [
"src/prepare.ts",
"src/run.ts",
"src/post.ts",
"package.json",
"profiles.json",
"prompt.md",
];
const stats = await Promise.all(files.map((file) => stat(`${path}/${file}`)));
for (const [index, info] of stats.entries()) {
assert(info.isFile(), files[index]);
expect(info.mode & 0o022, files[index]).toStrictEqual(0);
}
} finally {
await rm(temp, { recursive: true });
}
});
test("module-relative runtime assets exist", async () => {
const module = new URL("../src/run.ts", import.meta.url);
const source = await readFile(module, "utf8");
const assets = [...source.matchAll(/new URL\("([^"]+)", import\.meta\.url\)/gu)].map(
([, asset = ""]) => new URL(asset, module),
);
const stats = await Promise.all(assets.map((asset) => stat(asset)));
for (const [index, info] of stats.entries()) {
assert(info.isFile(), assets[index]?.pathname);
}
});
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");
expect(permissions).toStrictEqual({
code: "write",
issues: "write",
"pull-requests": "write",
});
const steps = sequence(job.steps, "jobs.agent.steps");
expect(steps.findIndex((step) => step.uses === "./")).toStrictEqual(-1);
const trusted = findStep(steps, "uses", "https://git.capsulizers.com/commons/bot-agents@main");
expect(mapping(trusted.with, "trusted.with")["master-key"]).toBe(
"${{ secrets.BOT_AGENTS_MASTER_KEY }}",
);
expect(JSON.stringify(workflow).includes("gitea.actor != 'bot'")).toBe(true);
});
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").check, "jobs.check");
const commands = sequence(job.steps, "jobs.check.steps")
.map((step) => (typeof step.run === "string" ? step.run : ""))
.join("\n");
// `vp check` type-checks every phase, and `vp test` runs each of them under
// Node as well as the root-only isolation tests.
assert(commands.includes("npm ci"));
assert(commands.includes("vp check"));
assert(commands.includes("vp test"));
});
test("uses the standard Skills installer without custom discovery", async () => {
const runner = await readFile("src/run.ts", "utf8");
assert(runner.includes("skills add obra/superpowers"));
const removed = await stat("superpowers.ts").then(
() => false,
(error: unknown) => isNotFound(error),
);
assert(removed, "superpowers.ts should be removed");
});
test("installs the OpenCode CLI", async () => {
const action = await readFile("action.yml", "utf8");
assert(action.includes("opencode-ai"));
});
test("profile preparation does not receive provider credentials", async () => {
const runner = await readFile("src/run.ts", "utf8");
assert(runner.includes("await prepareProfile(profile);"));
expect(runner.includes("prepareProfile(profile, token)")).toBe(false);
});
test("central profiles fall back from Codex to Claude to OpenCode", async () => {
const credentials: unknown = JSON.parse(await readFile("profiles.json", "utf8"));
const profiles = sequence(mapping(credentials, "profiles.json").profiles, "profiles");
expect(profiles.map((profile) => profile.type)).toStrictEqual(["codex", "claude", "opencode"]);
expect(profiles[2]?.model).toBe("openrouter/qwen/qwen3.8-27b");
assert(String(profiles[2]?.token).startsWith("v1:"));
});
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "es2024",
"module": "nodenext",
"lib": ["es2024"],
"types": ["node"],
"noEmit": true,
"allowImportingTsExtensions": true,
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true,
"strict": true,
"exactOptionalPropertyTypes": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"allowUnreachableCode": false,
"allowUnusedLabels": false
}
}
+25
View File
@@ -0,0 +1,25 @@
import { defineConfig } from "vite-plus";
import projectLint from "./scripts/project-lint.ts";
export default defineConfig({
// Keep package.json in its hand-written order.
fmt: { sortPackageJson: false },
lint: {
plugins: ["typescript", "unicorn", "oxc", "import"],
jsPlugins: ["./scripts/project-lint.ts"],
categories: { correctness: "error", suspicious: "error", pedantic: "error", perf: "error" },
rules: projectLint.configs.project.rules,
// Repo tooling, not app code: the lint plugin compares AST node `type`s,
// which the AST types declare as an enum.
overrides: [
{ files: ["scripts/**"], rules: { "typescript/no-unsafe-enum-comparison": "off" } },
],
options: {
typeAware: true,
typeCheck: true,
maxWarnings: 0,
reportUnusedDisableDirectives: "error",
},
},
});