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:
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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"])
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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-"));
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user