Document that package.json aliases may target only direct subfolders of src (#21)

Follow-up to #20, which was merged before this landed.

bot-agents has no `package.json` `imports` aliases (`src/` is flat and everything imports relatively), so no code changes. This records the new rule in the docs:

- `.agents/memory/imports-aliases-barrels.md`: aliases may target only a direct subfolder of `src/` (its `index.ts` or a `#x/*` pattern); nothing outside `src/`, not `src/` itself, not deeper folders; such code imports relatively.
- `.agents/memory/tests-layout-vitest.md`: `tests/` reach the public entry through a relative import, not a `#plugin` alias.
- `.agents/skills/migrate-deno-repo/SKILL.md` and `AGENTS.md`: same rule.

`vp check`, `vp test`, and `damem doctor` pass.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Reviewed-on: #21
Reviewed-by: bot <noreply@capsulizers.com>
Co-authored-by: Danny Kim <temeddix@gmail.com>
Co-committed-by: Danny Kim <temeddix@gmail.com>
This commit was merged in pull request #21.
This commit is contained in:
Danny Kim
2026-10-18 14:37:49 +00:00
committed by temeddix
parent 006bbe6b0c
commit ccfb08f4d5
51 changed files with 1000 additions and 1486 deletions
-119
View File
@@ -1,119 +0,0 @@
// 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
@@ -1,174 +0,0 @@
// 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
@@ -1,46 +0,0 @@
// 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
@@ -1,30 +0,0 @@
// 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) };
}
+1 -1
View File
@@ -9,7 +9,7 @@ import {
retryInvalidToken,
stageCodexAuth,
} from "./auth.ts";
import { currentGid, currentUid, IS_WINDOWS, makeTempDir } from "./testing.ts";
import { currentGid, currentUid, IS_WINDOWS, makeTempDir } from "#testing";
test("retries once after an invalid refresh token", async () => {
let attempts = 0;
+7 -12
View File
@@ -60,15 +60,6 @@ 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 lstat(path);
if (!stat.isFile() || stat.isSymbolicLink()) {
@@ -84,11 +75,15 @@ async function readCodexAuth(path: string): Promise<string> {
} catch {
throw new Error("invalid Codex login");
}
const tokens = isObject(value) ? value["tokens"] : undefined;
if (
!isObject(value) ||
typeof value.auth_mode !== "string" ||
value.auth_mode === "" ||
!hasCodexTokens(value.tokens)
typeof value["auth_mode"] !== "string" ||
value["auth_mode"] === "" ||
!isObject(tokens) ||
!["access_token", "refresh_token", "id_token", "account_id"].every(
(name) => typeof tokens[name] === "string" && tokens[name] !== "",
)
) {
throw new Error("invalid Codex login");
}
+1 -1
View File
@@ -10,7 +10,7 @@ import {
readProfileFile,
} from "./isolation.ts";
import type { RuntimeProfile } from "./runtime.ts";
import { currentUid, IS_ROOT, IS_WINDOWS, makeTempDir } from "./testing.ts";
import { currentUid, IS_ROOT, IS_WINDOWS, makeTempDir } from "#testing";
const profile = (uid: number, home: string): RuntimeProfile => ({
index: uid - 20_000,
+6 -14
View File
@@ -49,27 +49,19 @@ async function profileProcessIds(uid: number): Promise<number[]> {
.map(Number);
}
function killProcess(pid: number): void {
try {
process.kill(pid, "SIGKILL");
} catch (error) {
if (!isNotFound(error)) {
throw error;
}
}
}
// 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;
}
if (ids.length === 0) return;
for (const pid of ids) {
killProcess(pid);
try {
process.kill(pid, "SIGKILL");
} catch (error) {
if (!isNotFound(error)) throw error;
}
}
await sleep(10);
await killProfileProcesses(uid, attempt + 1);
-114
View File
@@ -1,114 +0,0 @@
// 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;
}
+1 -1
View File
@@ -6,7 +6,7 @@ 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";
import { currentGid, currentUid, IS_WINDOWS, makeTempDir } from "#testing";
const context: PostContext = {
apiUrl: "https://gitea.example/api/v1",
+171 -116
View File
@@ -1,11 +1,12 @@
import { readFile, rm } from "node:fs/promises";
import type { Stats } from "node:fs";
import { lstat, readFile, rm } from "node:fs/promises";
import { join } from "node:path";
import process from "node:process";
import { isNotFound } from "./errors.ts";
import { parseAnchors, readAgentArtifact, readOutcome } from "./post-artifacts.ts";
import type { Anchor } from "./post-artifacts.ts";
import { isObject } from "./json.ts";
import { failureFooter, withProfile } from "./profiles.ts";
import type { ProfileFailure } from "./profiles.ts";
import type { FailureReason, ProfileFailure } from "./profiles.ts";
import { readRuntimeManifest } from "./runtime.ts";
import type { RuntimeManifest, RuntimeOutcome } from "./runtime.ts";
@@ -16,15 +17,11 @@ export interface PostContext {
eventName: string;
}
type SuccessOutcome = Extract<RuntimeOutcome, { ok: true }>;
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>;
interface Anchor {
path: string;
line: number;
side: "new" | "old";
body: string;
}
const ANSI = new RegExp(`${String.fromCodePoint(27)}\\[[0-9;]*[A-Za-z]`, "gu");
@@ -33,125 +30,108 @@ const VERDICTS: Record<string, [event: string, mark: string]> = {
Approved: ["APPROVED", "✅"],
"Changes requested": ["REQUEST_CHANGES", "🛑"],
};
const PROFILE_TYPES: Set<unknown> = new Set(["claude", "codex", "opencode"]);
const FAILURE_REASONS: Set<unknown> = new Set(["sign-in failed", "usage exhausted", "failed"]);
const isProfileType = (value: unknown): value is ProfileFailure["type"] => PROFILE_TYPES.has(value);
const isFailureReason = (value: unknown): value is FailureReason => FAILURE_REASONS.has(value);
const failureComment = (failures: ProfileFailure[]): string =>
`AI bot failed.\n\n${failureFooter(failures)}`;
class GiteaRequestError extends Error {}
function giteaRequest(apiUrl: string, reviewerToken: string, fetcher: typeof fetch): GiteaRequest {
return async (path, body) => {
let response: Response;
try {
response = await fetcher(`${apiUrl}/${path}`, {
method: "POST",
headers: {
Authorization: `token ${reviewerToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
throw new GiteaRequestError(`${path}: ${detail}`, { cause: error });
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) => {
const at = `${sourcePath}[${index}]`;
if (!isObject(entry)) {
throw new TypeError(`${at}: not an object`);
}
if (!response.ok) {
throw new GiteaRequestError(`${path}: ${response.status} ${await response.text()}`);
const { path, line, side = "new", body } = entry;
if (typeof path !== "string" || path === "") {
throw new TypeError(`${at}.path: expected a repository path`);
}
};
}
async function readSuccess(
manifest: RuntimeManifest,
publisher: Publisher,
): Promise<SuccessOutcome> {
let outcome: RuntimeOutcome;
try {
outcome = await readOutcome(manifest.outcomeFile);
} catch (error) {
await publisher.postComment(failureComment([]));
throw new Error("agent phase failed", { cause: error });
}
if (!outcome.ok) {
await publisher.postComment(failureComment(outcome.failures));
throw new Error("agent phase failed");
}
return outcome;
}
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 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),
),
event,
comments: entries.map(({ path, line, side, body }) => ({
path,
body: stripAnsi(withProfile(body, outcome.profile)),
new_position: side === "new" ? line : 0,
old_position: side === "old" ? line : 0,
})),
});
};
await send(anchors).catch(async (error: unknown) => {
if (anchors.length === 0) throw error;
console.error(
`inline comments rejected: ${error instanceof Error ? error.message : String(error)}`,
);
await send([]);
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 };
});
}
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;
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");
}
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 = value;
if (outcome["ok"] === false) {
// Only known labels reach the comment, never raw agent output.
const failures: unknown[] = Array.isArray(outcome["failures"]) ? outcome["failures"] : [];
return {
ok: false,
failures: failures.flatMap((entry: unknown) => {
const { type, model, reason } = isObject(entry) ? entry : {};
return isProfileType(type) && typeof model === "string" && isFailureReason(reason)
? [{ type, model, reason }]
: [];
}),
};
}
if (
outcome["ok"] !== true ||
typeof outcome["body"] !== "string" ||
typeof outcome["reviewPath"] !== "string" ||
typeof outcome["anchorsPath"] !== "string" ||
!isObject(outcome["profile"])
) {
throw new Error("invalid agent outcome");
}
const profile = outcome["profile"];
if (!isProfileType(profile["type"]) || 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"],
model: profile["model"],
},
};
const outcome = await readSuccess(manifest, publisher);
}
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 {
await publishResult(manifest, outcome, publisher);
stat = await lstat(path);
} catch (error) {
if (error instanceof GiteaRequestError) throw error;
await publisher.postComment(failureComment([{ ...outcome.profile, reason: "failed" }]));
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;
}
export async function postRuntime(
@@ -161,7 +141,82 @@ export async function postRuntime(
): Promise<void> {
const manifest = await readRuntimeManifest(manifestPath);
try {
await publish(manifest, context, fetcher);
const reviewerToken = (await readFile(manifest.reviewerFile, "utf8")).trim();
const request = async (path: string, body: unknown): Promise<void> => {
let response: Response;
try {
response = await fetcher(`${context.apiUrl}/${path}`, {
method: "POST",
headers: {
Authorization: `token ${reviewerToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
throw new GiteaRequestError(`${path}: ${detail}`, { cause: error });
}
if (!response.ok) {
throw new GiteaRequestError(`${path}: ${response.status} ${await response.text()}`);
}
};
const postComment = (body: string): Promise<void> =>
request(`repos/${context.repository}/issues/${context.issueIndex}/comments`, {
body: stripAnsi(body),
});
let outcome: RuntimeOutcome;
try {
outcome = await readOutcome(manifest.outcomeFile);
} catch (error) {
await postComment(failureComment([]));
throw new Error("agent phase failed", { cause: error });
}
if (!outcome.ok) {
await postComment(failureComment(outcome.failures));
throw new Error("agent phase failed");
}
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");
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[]): Promise<void> =>
request(`repos/${context.repository}/pulls/${context.issueIndex}/reviews`, {
body: stripAnsi(
withProfile([`${mark} ${verdict.trim()}`, ...rest].join("\n"), outcome.profile),
),
event,
comments: entries.map(({ path, line, side, body }) => ({
path,
body: stripAnsi(withProfile(body, outcome.profile)),
new_position: side === "new" ? line : 0,
old_position: side === "old" ? line : 0,
})),
});
await postReview(anchors).catch(async (error: unknown) => {
if (anchors.length === 0) throw error;
console.error(
`inline comments rejected: ${error instanceof Error ? error.message : String(error)}`,
);
await postReview([]);
});
} catch (error) {
if (error instanceof GiteaRequestError) throw error;
await postComment(failureComment([{ ...outcome.profile, reason: "failed" }]));
throw error;
}
} finally {
await rm(manifest.runtimeDir, { recursive: true }).catch((error: unknown) => {
if (!isNotFound(error)) throw error;
+1 -3
View File
@@ -5,9 +5,7 @@ import { prepareRuntime } from "./runtime.ts";
const env = (name: string): string => {
const value = process.env[name];
if (value === undefined || value === "") {
throw new Error(`${name} is not set`);
}
if (value === undefined || value === "") throw new Error(`${name} is not set`);
return value;
};
+3 -16
View File
@@ -1,18 +1,9 @@
export type ProfileType = "claude" | "codex" | "opencode";
export interface BotProfile {
type: ProfileType;
type: "claude" | "codex" | "opencode";
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}\``;
}
@@ -23,10 +14,6 @@ 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;
};
@@ -123,8 +110,8 @@ export function agentEnvironment(
token?: string,
): Record<string, string> {
const result = sanitizedEnvironment(environment);
result.HOME = profile.home;
if (profile.type === "codex") result.CODEX_HOME = `${profile.home}/.codex`;
result["HOME"] = profile.home;
if (profile.type === "codex") result["CODEX_HOME"] = `${profile.home}/.codex`;
if (token !== undefined && token !== "") {
const tokenName = {
claude: "CLAUDE_CODE_OAUTH_TOKEN",
-79
View File
@@ -1,79 +0,0 @@
// 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;
}
+387 -56
View File
@@ -1,28 +1,40 @@
// Runs one ordered agent profile. Credential decryption and reviewer posting
// live in separate processes so neither privileged secret reaches this runner.
import { chmod, readFile, writeFile } from "node:fs/promises";
import { chmod, chown, readFile, writeFile } from "node:fs/promises";
import process from "node:process";
import { createInterface } from "node:readline";
import type { Readable } from "node:stream";
import { text as readText } from "node:stream/consumers";
import { setTimeout as sleep } from "node:timers/promises";
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";
codexAuthentication,
isClaudeAuthFailure,
isCodexAuthFailure,
persistCodexAuth,
retryInvalidToken,
stageCodexAuth,
} from "./auth.ts";
import type { ClaudeFailureFields, CodexAuthentication } from "./auth.ts";
import { chunks, endWith, output, writeChunk } from "./command.ts";
import type { CommandStatus } from "./command.ts";
import { agentCommand, killProfileProcesses, readProfileFile } from "./isolation.ts";
import { isObject } from "./json.ts";
import {
collectOpenCodeOutput,
openCodeArgs,
openCodeError,
parseOpenCodeEvents,
} from "./opencode.ts";
import {
AgentAuthError,
agentEnvironment,
runProfiles,
sanitizedEnvironment,
withProfile,
} from "./profiles.ts";
import type { ProfileFailure } from "./profiles.ts";
import { readRuntimeManifest } from "./runtime.ts";
import type { RuntimeOutcome, RuntimeProfile } from "./runtime.ts";
interface AttemptResult {
@@ -32,30 +44,71 @@ interface AttemptResult {
profile: Pick<RuntimeProfile, "type" | "model">;
}
interface RenderedPrompt {
prompt: string;
reviewPath: string;
anchorsPath: string;
// Claude Code's stream-json `result` event, in its own field names.
type ClaudeResult = ClaudeFailureFields &
Partial<Record<"is_error", boolean>> & {
subtype: string;
};
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;
}
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(definedEnvironment());
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 output(command, { args, env: PROCESS_ENV, clearEnv: true });
if (!success) {
throw new Error(`${command} ${args[0]} exited with ${code}`);
}
if (!success) throw new Error(`${command} ${args[0]} exited with ${code}`);
}
async function configureGitAuthor(): Promise<void> {
const user: unknown = await (await gitea("user")).json();
if (!isObject(user) || typeof user.login !== "string" || typeof user.email !== "string") {
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]);
await runRoot("git", ["config", "user.name", user["login"]]);
await runRoot("git", ["config", "user.email", user["email"]]);
}
async function readProfileToken(profile: RuntimeProfile): Promise<string | undefined> {
@@ -63,6 +116,20 @@ async function readProfileToken(profile: RuntimeProfile): Promise<string | undef
return token;
}
async function runAgentCommand(
profile: RuntimeProfile,
command: string,
args: string[],
token?: string,
): Promise<void> {
const { success, code } = await agentCommand(profile, command, args, {
cwd: WORKSPACE,
env: agentEnvironment(PROCESS_ENV, profile, token),
clearEnv: true,
}).output();
if (!success) throw new Error(`${command} ${args[0]} exited with ${code}`);
}
async function prepareProfile(profile: RuntimeProfile): Promise<void> {
await runAgentCommand(profile, "git", [
"config",
@@ -84,21 +151,22 @@ async function prepareProfile(profile: RuntimeProfile): Promise<void> {
// 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;
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>> {
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: unknown = await (
await gitea(`repos/${REPO}/issues/${INDEX}/comments?limit=100`)
).json();
if (!Array.isArray(comments)) {
throw new TypeError("comments: unexpected Gitea response");
}
return {
const values: Record<string, string> = {
COMMENT: env("COMMENT"),
ISSUE_COMMENTS: comments.map((comment: unknown) => formatComment(comment)).join("\n"),
CODE_RULES: await (await gitea(RULES_PATH)).text(),
@@ -109,12 +177,6 @@ async function promptValues(
REVIEW_PATH: reviewPath,
ANCHORS_PATH: anchorsPath,
};
}
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.replaceAll(
@@ -126,45 +188,314 @@ async function renderPrompt(profile: RuntimeProfile): Promise<RenderedPrompt> {
};
}
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: ClaudeResult | undefined;
let error = "";
const copyError = (async (): Promise<void> => {
const decoder = new TextDecoder();
for await (const chunk of chunks(claude.stderr)) {
const part = decoder.decode(chunk, { stream: true });
error += part;
console.error(part);
}
error += decoder.decode();
})();
for await (const line of createInterface({ input: claude.stdout, crlfDelay: Infinity })) {
if (line !== "") {
const event: unknown = JSON.parse(line);
if (isObject(event)) {
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);
}
}
}
if (event["type"] === "result") {
const { is_error: isError, result: answer, subtype, api_error_status: apiStatus } = event;
result = {
is_error: typeof isError === "boolean" ? isError : undefined,
result: typeof answer === "string" ? answer : undefined,
subtype: String(subtype),
api_error_status:
typeof apiStatus === "number" || apiStatus === null ? apiStatus : undefined,
};
}
}
}
}
const [status] = await Promise.all([claude.status, copyError]);
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"}`);
}
if (!status.success) {
throw new Error(error.trim() || `claude exited with ${status.code}`);
}
return result.result;
}
// 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 collect = async (stream: Readable): Promise<void> => {
const decoder = new TextDecoder();
for await (const chunk of chunks(stream)) {
shown += decoder.decode(chunk, { stream: true });
}
shown += decoder.decode();
};
const drained = Promise.all([collect(login.stdout), collect(login.stderr)]);
const status = login.status;
await sleep(3000);
await pollForInstructions(() => shown, status);
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();
await endWith(login.stdin, `${token}\n`);
const [status, error] = await Promise.all([login.status, readText(login.stderr)]);
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 writeFile(path, "", { mode: 0o600 });
await chmod(path, 0o600);
await 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 (): Promise<{ status: CommandStatus; error: string }> => {
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 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, "") };
};
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 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);
}
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 answer: AgentAnswer = {};
let rendered: RenderedPrompt | undefined;
let body: string | undefined;
let resultPath: string | null = null;
let reviewPath: string | undefined;
let anchorsPath: string | undefined;
try {
if (authentication?.kind === "persistent") {
stagedCodexAuth = await stageCodexAuth(CODEX_AUTH, profile.home, profile.uid, profile.gid);
}
await prepareProfile(profile);
rendered = await renderPrompt(profile);
answer = await runAgent(profile, token, rendered.prompt, authentication);
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 ?? codexAuthentication(token),
);
} else {
body = await runOpenCode(profile, token ?? "", rendered.prompt);
}
} finally {
await killProfileProcesses(profile.uid);
if (stagedCodexAuth !== null) {
await persistCodexAuth(stagedCodexAuth, CODEX_AUTH);
}
}
const body =
answer.resultPath === undefined
? answer.body
: await readProfileFile(profile, answer.resultPath);
if (body === undefined || rendered === undefined) {
if (resultPath !== null) body = await readProfileFile(profile, resultPath);
if (body === undefined || reviewPath === undefined || anchorsPath === undefined) {
throw new Error("agent attempt produced no result");
}
const { reviewPath, anchorsPath } = rendered;
return { body, reviewPath, anchorsPath, profile: { type: profile.type, model: profile.model } };
return {
body,
reviewPath,
anchorsPath,
profile: { type: profile.type, model: profile.model },
};
}
async function writeOutcome(outcome: RuntimeOutcome): Promise<void> {
await writeFile(MANIFEST.outcomeFile, JSON.stringify(outcome), { mode: 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 });
+1 -1
View File
@@ -7,7 +7,7 @@ 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";
import { IS_ROOT, makeTempDir } from "#testing";
const MASTER_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8";
const ENCRYPTED = "v1:ICEiIyQlJicoKSor:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA";
+28 -27
View File
@@ -39,24 +39,6 @@ async function writePrivate(path: string, value: string, uid: number, gid: numbe
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(
actionPath: string,
runtimeRoot: string,
@@ -74,10 +56,29 @@ export async function prepareRuntime(
// 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),
],
async (previous, profile, index) => {
const prepared = await previous;
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);
}
prepared.push({
index,
type: profile.type,
model: profile.model,
uid,
gid: uid,
home,
tokenFile,
});
return prepared;
},
Promise.resolve([]),
);
@@ -100,11 +101,11 @@ export async function prepareRuntime(
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)
typeof value["runtimeDir"] === "string" &&
typeof value["manifestPath"] === "string" &&
typeof value["reviewerFile"] === "string" &&
typeof value["outcomeFile"] === "string" &&
Array.isArray(value["profiles"])
);
}
-23
View File
@@ -1,23 +0,0 @@
// 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-"));
}
+47
View File
@@ -0,0 +1,47 @@
// Test support: the current user's IDs, a fresh temporary directory, and a
// plain command runner. It imports no source module, so the repository-level
// tests in tests/, which reach src/ only through `#testing`, never touch
// internals.
import { spawn } from "node:child_process";
import { once } from "node:events";
import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import process from "node:process";
import { text } from "node:stream/consumers";
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-"));
}
export interface CommandResult {
success: boolean;
stderr: string;
}
// Runs a command with stdin closed and only the given environment, and
// collects its stderr.
export async function runCommand(
command: string,
args: string[],
env: Record<string, string>,
): Promise<CommandResult> {
const child = spawn(command, args, { env, stdio: ["ignore", "ignore", "pipe"] });
const [stderr, closed] = await Promise.all([text(child.stderr), once(child, "close")]);
const exitCode: unknown = closed[0];
return { success: exitCode === 0, stderr };
}