Group the sources into aliased modules and lint with the published plugins
Agents / agent (pull_request) Has been skipped
Check / check (pull_request) Successful in 38s

This commit is contained in:
Danny Kim
2026-10-18 23:50:27 +09:00
parent c8a150fee7
commit 0a9f86a102
37 changed files with 317 additions and 155 deletions
+4 -4
View File
@@ -1,5 +1,5 @@
--- ---
description: Child processes use plain node:child_process spawn (explicit stdio, uid/gid, whole env); src/child.ts has exitCode (128+signo) and echo; no execFile maxBuffer description: Child processes use plain node:child_process spawn (explicit stdio, uid/gid, whole env); src/processes/child.ts has exitCode (128+signo) and echo; no execFile maxBuffer
--- ---
Every call site calls `spawn` from `node:child_process` itself, with Every call site calls `spawn` from `node:child_process` itself, with
@@ -15,7 +15,7 @@ everything explicit:
- No `execFile`/`exec`: their `maxBuffer` (1 MiB) kills a chatty child. Read - No `execFile`/`exec`: their `maxBuffer` (1 MiB) kills a chatty child. Read
piped streams incrementally instead. piped streams incrementally instead.
`src/child.ts` holds what the call sites share: `src/processes/child.ts` holds what the call sites share:
- **`exitCode(child)`**: call it right after `spawn`. It settles on `exit` - **`exitCode(child)`**: call it right after `spawn`. It settles on `exit`
(not `close`, which a daemonized grandchild can hold open), maps a signal to (not `close`, which a daemonized grandchild can hold open), maps a signal to
@@ -28,9 +28,9 @@ Tests use `spawnSync` for short commands.
Other port details: Other port details:
- **`src/errors.ts`** `isNotFound` checks `ENOENT` (file) or `ESRCH` (process). - **`src/common/errors.ts`** `isNotFound` checks `ENOENT` (file) or `ESRCH` (process).
- Temporary files are an exclusive `wx` create with mode `0600`. - Temporary files are an exclusive `wx` create with mode `0600`.
- `src/json.ts` holds type guards used instead of `as` casts on parsed JSON. - `src/common/json.ts` holds type guards used instead of `as` casts on parsed JSON.
- `process` and `Buffer` must come from `node:` imports: - `process` and `Buffer` must come from `node:` imports:
`no-restricted-globals` in `scripts/project-lint.ts` enforces it `no-restricted-globals` in `scripts/project-lint.ts` enforces it
([[lint-parity-never-shrinks]]). ([[lint-parity-never-shrinks]]).
@@ -1,5 +1,5 @@
--- ---
description: bot-agents migration decisions: live @main action for six repos, JSON profiles, Node 24.2+, no eslint-plugin-* packages needed description: bot-agents migration decisions: live @main action for six repos, JSON profiles, Node 24.2+, lints with eslint-plugin-import-structure and explicit-types (concrete, absence)
--- ---
Decisions for `commons/bot-agents` (Capsulizers Gitea), from the Deno to Decisions for `commons/bot-agents` (Capsulizers Gitea), from the Deno to
@@ -18,13 +18,16 @@ Node + Vite+ migration (PR #20, `node-migration`, 2026-10-15/16):
- **Node 24.2+**, not 22: `src/post.ts` uses `import.meta.main`. There is no - **Node 24.2+**, not 22: `src/post.ts` uses `import.meta.main`. There is no
`engines` (user decision 2026-10-18): the `node:24-bookworm` job image `engines` (user decision 2026-10-18): the `node:24-bookworm` job image
decides the version ([[bot-agents-runtime-no-build]]). decides the version ([[bot-agents-runtime-no-build]]).
- **No `eslint-plugin-*` packages** (`eslint-plugin-explicit-types`, - **Published lint plugins** (2026-10-18, after they reached npm):
`eslint-plugin-import-structure`, `eslint-plugin-lit-robust`; unscoped `eslint-plugin-import-structure` (`modules` config) and
since 2026-10-18, formerly `@cunarist/...`). The Deno setup enabled no `eslint-plugin-explicit-types` (`concrete` and `absence` configs) are
plugin, so this repo depends on none and CI passes without them being devDependencies, enabled in `vite.config.ts`. explicit-types' `naming`
published. Planned (2026-10-17): once the plugins are on npm, add config is left off: its `upper-snake-string-unions` and
`eslint-plugin-import-structure` (rule prefix `import-structure/`) here for `no-upper-snake-object-keys` rules fire on wire tokens this repo does not
its import checks. name (profile types such as `"claude"`, failure reasons posted to Gitea,
environment variable names), which cannot change without changing
behavior. Open question for the user. `eslint-plugin-lit-robust` does not apply
(no Lit).
- **Stricter input checks were accepted** in the migration: Gitea `/user` and - **Stricter input checks were accepted** in the migration: Gitea `/user` and
comment responses, the runtime manifest, and the outcome's profile type are comment responses, the runtime manifest, and the outcome's profile type are
validated; some type errors became `TypeError` with the same messages. validated; some type errors became `TypeError` with the same messages.
+1 -1
View File
@@ -21,5 +21,5 @@ as skipped instead of silently passing.
`runCommand`) behind the `#testing` alias. Every test imports it as `runCommand`) behind the `#testing` alias. Every test imports it as
`#testing`. It imports no source module, so `tests/` (which may not use `#testing`. It imports no source module, so `tests/` (which may not use
`../`) never reaches internals. Tests follow `../`) never reaches internals. Tests follow
[[tests-layout-vitest]] (unit tests in `src/*.test.ts`, repo-level ones in [[tests-layout-vitest]] (unit tests next to their code in `src/`, repo-level ones in
`tests/`). `tests/`).
+2 -1
View File
@@ -97,7 +97,8 @@ It is not a plugin rule.
- Every `package.json` `imports` alias points only at the `index.ts` of a - Every `package.json` `imports` alias points only at the `index.ts` of a
direct subfolder of `src/`, e.g. `"#helpers": "./src/helpers/index.ts"`. direct subfolder of `src/`, e.g. `"#helpers": "./src/helpers/index.ts"`.
No alias to a single file, a deeper folder, or anything outside `src/`. No alias to a single file, a deeper folder, or anything outside `src/`.
Here the one alias is `"#testing": "./src/testing/index.ts"`. Here the aliases are `#agents`, `#processes`, `#runtime`, `#profiles`,
`#common`, and `#testing`, in that layer order.
- Code outside `src/` (`scripts/`, `tests/`, `vite.config.ts`) gets no alias - Code outside `src/` (`scripts/`, `tests/`, `vite.config.ts`) gets no alias
of its own and imports its own files by `./` relative paths. It reaches of its own and imports its own files by `./` relative paths. It reaches
`src/` only through the aliases, since `../` is banned. `src/` only through the aliases, since `../` is banned.
+9 -4
View File
@@ -20,7 +20,7 @@ dependency. Keep the sources to erasable syntax and `.ts` imports, which
`exactOptionalPropertyTypes` off `exactOptionalPropertyTypes` off
([.agents/memory/ts-only-files-and-imports.md](.agents/memory/ts-only-files-and-imports.md)). ([.agents/memory/ts-only-files-and-imports.md](.agents/memory/ts-only-files-and-imports.md)).
Checks use Vite+: `vp check` formats, lints, and type-checks, and `vp test` Checks use Vite+: `vp check` formats, lints, and type-checks, and `vp test`
runs Vitest. Unit tests sit next to the code as `src/*.test.ts`; runs Vitest. Unit tests sit next to the code as `*.test.ts` in `src/`;
repository-level tests live in `tests/`. repository-level tests live in `tests/`.
A `#` alias in `package.json` `imports` may point anywhere and owns its A `#` alias in `package.json` `imports` may point anywhere and owns its
@@ -34,9 +34,14 @@ import is banned in every file, owned or not
(`import/no-relative-parent-imports`); runtime file URLs such as (`import/no-relative-parent-imports`); runtime file URLs such as
`new URL("../prompt.md", import.meta.url)` are paths, not imports, and stay. `new URL("../prompt.md", import.meta.url)` are paths, not imports, and stay.
Files no alias owns need no `index.ts` and no alias, and import `./` siblings Files no alias owns need no `index.ts` and no alias, and import `./` siblings
and subfolders freely. The one alias is `#testing` (`./src/testing/index.ts`): and subfolders freely. The aliases, in layer order, are `#agents` (Codex,
tests that use its helpers import it by alias, and `tests/` imports `src/` Claude Code and OpenCode specifics), `#processes` (child processes and the
only through it. Local convention, stricter than the general rule above: in isolated profile users), `#runtime` (the prepared runtime and its
credentials), `#profiles`, `#common` (error and JSON checks), and `#testing`
(test helpers), each a `src/<name>/index.ts` barrel. The phase entry points
`src/prepare.ts`, `src/run.ts`, and `src/post.ts` stay at the top of `src/`,
unowned, because `action.yml` runs them by path. Tests that use the test
helpers import `#testing`, and `tests/` imports `src/` only through it. Local convention, stricter than the general rule above: in
this repo, aliases point only at a direct subfolder of `src/` this repo, aliases point only at a direct subfolder of `src/`
(`"#helpers": "./src/helpers/index.ts"`), and code outside `src/` uses `./` (`"#helpers": "./src/helpers/index.ts"`), and code outside `src/` uses `./`
relative imports. `scripts/` is flat: one file per tool, no subfolders and no `index.ts`, imported directly by file path relative imports. `scripts/` is flat: one file per tool, no subfolders and no `index.ts`, imported directly by file path
+28
View File
@@ -9,6 +9,8 @@
"@eslint/core": "^0.17.0", "@eslint/core": "^0.17.0",
"@types/node": "^24.19.2", "@types/node": "^24.19.2",
"@typescript-eslint/types": "^8.71.2", "@typescript-eslint/types": "^8.71.2",
"eslint-plugin-explicit-types": "^0.6.1",
"eslint-plugin-import-structure": "^0.10.2",
"vite-plus": "^1.2.0", "vite-plus": "^1.2.0",
"yaml": "^2.9.1" "yaml": "^2.9.1"
} }
@@ -1887,6 +1889,32 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/eslint-plugin-explicit-types": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-explicit-types/-/eslint-plugin-explicit-types-0.6.1.tgz",
"integrity": "sha512-8JU2Ceoi1aLbhYo4LHwb9Eone/pJuMnzVP1ZlGJn/ETxVzJQFziRnJwOF2dRmInn9dGqvwq05dc3o5tVyTGP2Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint/core": "^0.17.0"
},
"engines": {
"node": ">=22.18"
}
},
"node_modules/eslint-plugin-import-structure": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/eslint-plugin-import-structure/-/eslint-plugin-import-structure-0.10.2.tgz",
"integrity": "sha512-jvx4sccxEdURGXrDtBJZtCHiKqELkHRVlLrMU5bVZIjglN0U464Joopcfn6JvmNB2zyIh2FkolaJQJaKkvy6Xw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint/core": "^0.17.0"
},
"engines": {
"node": ">=22.18"
}
},
"node_modules/estree-walker": { "node_modules/estree-walker": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+7
View File
@@ -4,12 +4,19 @@
"description": "A Gitea composite action that runs a coding agent for approved issue and pull request automation.", "description": "A Gitea composite action that runs a coding agent for approved issue and pull request automation.",
"type": "module", "type": "module",
"imports": { "imports": {
"#agents": "./src/agents/index.ts",
"#processes": "./src/processes/index.ts",
"#runtime": "./src/runtime/index.ts",
"#profiles": "./src/profiles/index.ts",
"#common": "./src/common/index.ts",
"#testing": "./src/testing/index.ts" "#testing": "./src/testing/index.ts"
}, },
"devDependencies": { "devDependencies": {
"@eslint/core": "^0.17.0", "@eslint/core": "^0.17.0",
"@types/node": "^24.19.2", "@types/node": "^24.19.2",
"@typescript-eslint/types": "^8.71.2", "@typescript-eslint/types": "^8.71.2",
"eslint-plugin-explicit-types": "^0.6.1",
"eslint-plugin-import-structure": "^0.10.2",
"vite-plus": "^1.2.0", "vite-plus": "^1.2.0",
"yaml": "^2.9.1" "yaml": "^2.9.1"
} }
+3 -3
View File
@@ -2,9 +2,9 @@
set -euo pipefail set -euo pipefail
path=$(mktemp -d "${RUNNER_TEMP}/bot-agents-source-XXXXXX") path=$(mktemp -d "${RUNNER_TEMP}/bot-agents-source-XXXXXX")
mkdir "${path}/src" cp -a "${ACTION_PATH}/src" "${path}/"
cp -a "${ACTION_PATH}"/src/*.ts "${path}/src/" # package.json makes Node load the TypeScript sources as ES modules and
# package.json makes Node load the TypeScript sources as ES modules. # resolves their `#` aliases.
cp -a "${ACTION_PATH}/package.json" "${ACTION_PATH}/profiles.json" "${ACTION_PATH}/prompt.md" "${path}/" cp -a "${ACTION_PATH}/package.json" "${ACTION_PATH}/profiles.json" "${ACTION_PATH}/prompt.md" "${path}/"
chmod -R go-w "${path}" chmod -R go-w "${path}"
printf 'path=%s\n' "${path}" >> "${GITHUB_OUTPUT}" printf 'path=%s\n' "${path}" >> "${GITHUB_OUTPUT}"
+5 -4
View File
@@ -1,5 +1,7 @@
import { expect, test } from "vite-plus/test";
import { mkdir, readFile, rm, stat, symlink, writeFile } from "node:fs/promises"; import { mkdir, readFile, rm, stat, symlink, writeFile } from "node:fs/promises";
import { expect, test } from "vite-plus/test";
import { currentGid, currentUid, IS_WINDOWS, makeTempDir } from "#testing";
import { import {
codexAuthentication, codexAuthentication,
@@ -9,7 +11,6 @@ import {
retryInvalidToken, retryInvalidToken,
stageCodexAuth, stageCodexAuth,
} from "./auth.ts"; } from "./auth.ts";
import { currentGid, currentUid, IS_WINDOWS, makeTempDir } from "#testing";
test("retries once after an invalid refresh token", async () => { test("retries once after an invalid refresh token", async () => {
let attempts = 0; let attempts = 0;
@@ -104,7 +105,7 @@ test("re-logs in after the job 9837 revoked-session stderr", async () => {
test("recognizes a rejected Claude OAuth token", () => { test("recognizes a rejected Claude OAuth token", () => {
expect( expect(
isClaudeAuthFailure({ isClaudeAuthFailure({
api_error_status: 401, apiErrorStatus: 401,
result: "Failed to authenticate. API Error: 401 OAuth access token is invalid.", result: "Failed to authenticate. API Error: 401 OAuth access token is invalid.",
}), }),
).toBe(true); ).toBe(true);
@@ -113,7 +114,7 @@ test("recognizes a rejected Claude OAuth token", () => {
result: "OAuth token has expired. Please obtain a new token or refresh your existing token.", result: "OAuth token has expired. Please obtain a new token or refresh your existing token.",
}), }),
).toBe(true); ).toBe(true);
expect(isClaudeAuthFailure({ api_error_status: 429, result: "API Error: 429" })).toBe(false); expect(isClaudeAuthFailure({ apiErrorStatus: 429, result: "API Error: 429" })).toBe(false);
}); });
test("selects API-key login only for a configured Codex token", () => { test("selects API-key login only for a configured Codex token", () => {
+22 -8
View File
@@ -12,10 +12,18 @@ import {
} from "node:fs/promises"; } from "node:fs/promises";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { isNotFound } from "./errors.ts"; import { isNotFound, isObject } from "#common";
import { isObject } from "./json.ts";
export type CodexAuthentication = { kind: "api-key"; token: string } | { kind: "persistent" }; interface CodexApiKey {
kind: "api-key";
token: string;
}
interface CodexPersistent {
kind: "persistent";
}
export type CodexAuthentication = CodexApiKey | CodexPersistent;
export function codexAuthentication(token?: string): CodexAuthentication { export function codexAuthentication(token?: string): CodexAuthentication {
return token === undefined ? { kind: "persistent" } : { kind: "api-key", token }; return token === undefined ? { kind: "persistent" } : { kind: "api-key", token };
@@ -34,21 +42,27 @@ export function isCodexAuthFailure(error: string): boolean {
// stream-json result, e.g. "Failed to authenticate. API Error: 401 OAuth // stream-json result, e.g. "Failed to authenticate. API Error: 401 OAuth
// access token is invalid." with api_error_status 401. // access token is invalid." with api_error_status 401.
// The fields of Claude Code's stream-json `result` event that tell a sign-in // The fields of Claude Code's stream-json `result` event that tell a sign-in
// failure apart, under their own names. // failure apart, as `apiErrorStatus` and `result`.
export type ClaudeFailureFields = Partial<Record<"api_error_status", number | null>> & { export interface ClaudeFailureFields {
apiErrorStatus?: number;
result?: string; result?: string;
}; }
export function isClaudeAuthFailure(result: ClaudeFailureFields): boolean { export function isClaudeAuthFailure(result: ClaudeFailureFields): boolean {
return ( return (
result.api_error_status === 401 || result.apiErrorStatus === 401 ||
/failed to authenticate|oauth (?:access )?token (?:is invalid|has expired)|invalid api key|authentication_error|please run \/login|not logged in/iu.test( /failed to authenticate|oauth (?:access )?token (?:is invalid|has expired)|invalid api key|authentication_error|please run \/login|not logged in/iu.test(
result.result ?? "", result.result ?? "",
) )
); );
} }
export async function retryInvalidToken<T extends { code: number; error: string }>( export interface CommandResult {
code: number;
error: string;
}
export async function retryInvalidToken<T extends CommandResult>(
attempt: () => Promise<T>, attempt: () => Promise<T>,
relogin: () => Promise<void>, relogin: () => Promise<void>,
): Promise<T> { ): Promise<T> {
+17
View File
@@ -0,0 +1,17 @@
// Agent command-line specifics: Codex and Claude Code sign-in handling and
// OpenCode's arguments and output.
export {
codexAuthentication,
isClaudeAuthFailure,
isCodexAuthFailure,
persistCodexAuth,
retryInvalidToken,
stageCodexAuth,
} from "./auth.ts";
export type { ClaudeFailureFields, CodexAuthentication, CommandResult } from "./auth.ts";
export {
collectOpenCodeOutput,
openCodeArgs,
openCodeError,
parseOpenCodeEvents,
} from "./opencode.ts";
@@ -1,11 +1,13 @@
import { assert, expect, test } from "vite-plus/test"; import { assert, expect, test } from "vite-plus/test";
import { AgentAuthError } from "#profiles";
import { import {
collectOpenCodeOutput, collectOpenCodeOutput,
openCodeArgs, openCodeArgs,
openCodeError, openCodeError,
parseOpenCodeEvents, parseOpenCodeEvents,
} from "./opencode.ts"; } from "./opencode.ts";
import { AgentAuthError } from "./profiles.ts";
test("builds a non-interactive auto-approved OpenCode command", () => { test("builds a non-interactive auto-approved OpenCode command", () => {
expect(openCodeArgs("openrouter/qwen/qwen3.8-27b", "Review this")).toStrictEqual([ expect(openCodeArgs("openrouter/qwen/qwen3.8-27b", "Review this")).toStrictEqual([
@@ -99,5 +101,5 @@ test("surfaces an OpenCode error event instead of empty output", () => {
expect(() => parseOpenCodeEvents(openRouterError(429, "Rate limit exceeded"))).toThrow( expect(() => parseOpenCodeEvents(openRouterError(429, "Rate limit exceeded"))).toThrow(
"API error 429", "API error 429",
); );
expect(openCodeError('{"type":"text","part":{"text":"ok"}}')).toBe(undefined); expect(openCodeError('{"type":"text","part":{"text":"ok"}}')).toBeNull();
}); });
+16 -8
View File
@@ -1,5 +1,5 @@
import { isObject } from "./json.ts"; import { isObject } from "#common";
import { AgentAuthError } from "./profiles.ts"; import { AgentAuthError } from "#profiles";
export function openCodeArgs(model: string, prompt: string): string[] { export function openCodeArgs(model: string, prompt: string): string[] {
return ["run", "--format", "json", "--auto", "--model", model, prompt]; return ["run", "--format", "json", "--auto", "--model", model, prompt];
@@ -7,13 +7,21 @@ export function openCodeArgs(model: string, prompt: string): string[] {
type ChunkWriter = (chunk: Uint8Array) => void | Promise<void>; type ChunkWriter = (chunk: Uint8Array) => void | Promise<void>;
interface OpenCodeOutput {
stdout: string;
stderr: string;
}
export async function collectOpenCodeOutput( export async function collectOpenCodeOutput(
stdout: AsyncIterable<Uint8Array>, stdout: AsyncIterable<Uint8Array>,
stderr: AsyncIterable<Uint8Array>, stderr: AsyncIterable<Uint8Array>,
writeStdout: ChunkWriter, writeStdout: ChunkWriter,
writeStderr: ChunkWriter, writeStderr: ChunkWriter,
): Promise<{ stdout: string; stderr: string }> { ): Promise<OpenCodeOutput> {
const collect = async (stream: AsyncIterable<Uint8Array>, write: ChunkWriter) => { const collect = async (
stream: AsyncIterable<Uint8Array>,
write: ChunkWriter,
): Promise<string> => {
const chunks: Uint8Array[] = []; const chunks: Uint8Array[] = [];
for await (const chunk of stream) { for await (const chunk of stream) {
chunks.push(chunk); chunks.push(chunk);
@@ -38,8 +46,8 @@ export async function collectOpenCodeOutput(
// OpenCode reports provider failures, including a rejected OpenRouter key // OpenCode reports provider failures, including a rejected OpenRouter key
// (401 "User not found."), as a JSON error event on stdout and exits 1 with an // (401 "User not found."), as a JSON error event on stdout and exits 1 with an
// empty stderr. // empty stderr.
export function openCodeError(output: string): Error | undefined { export function openCodeError(output: string): Error | null {
let failure: Error | undefined; let failure: Error | null = null;
for (const line of output.split("\n")) { for (const line of output.split("\n")) {
if (line.trim() === "") continue; if (line.trim() === "") continue;
let event: unknown; let event: unknown;
@@ -69,7 +77,7 @@ export function openCodeError(output: string): Error | undefined {
} }
export function parseOpenCodeEvents(output: string): string { export function parseOpenCodeEvents(output: string): string {
let result: string | undefined; let result: string | null = null;
for (const line of output.split("\n")) { for (const line of output.split("\n")) {
if (line.trim() === "") continue; if (line.trim() === "") continue;
const event: unknown = JSON.parse(line); const event: unknown = JSON.parse(line);
@@ -79,7 +87,7 @@ export function parseOpenCodeEvents(output: string): string {
const { text } = part; const { text } = part;
if (typeof text === "string" && text.trim() !== "") result = text; if (typeof text === "string" && text.trim() !== "") result = text;
} }
if (result === undefined) { if (result === null) {
throw openCodeError(output) ?? new Error("OpenCode produced no text output"); throw openCodeError(output) ?? new Error("OpenCode produced no text output");
} }
return result; return result;
+4
View File
@@ -0,0 +1,4 @@
// Helpers every phase shares: error and JSON value checks.
export { isNotFound } from "./errors.ts";
export { isMapping, isObject } from "./json.ts";
export type { JsonObject } from "./json.ts";
+1 -1
View File
@@ -1,5 +1,5 @@
// Type guards for values read from JSON, which arrive as `unknown`. // Type guards for values read from JSON, which arrive as `unknown`.
export type JsonObject = Record<string, unknown>; export type JsonObject = NodeJS.Dict<unknown>;
// Any non-null object, arrays included: reading a missing key yields undefined. // Any non-null object, arrays included: reading a missing key yields undefined.
export function isObject(value: unknown): value is JsonObject { export function isObject(value: unknown): value is JsonObject {
+20 -10
View File
@@ -1,13 +1,12 @@
import { chmod, mkdir, rm, stat, symlink, writeFile } from "node:fs/promises"; import { chmod, mkdir, rm, stat, symlink, writeFile } from "node:fs/promises";
import { assert, expect, test } from "vite-plus/test"; import { assert, expect, test } from "vite-plus/test";
import { isNotFound } from "./errors.ts"; import { isNotFound } from "#common";
import { postRuntime } from "./post.ts"; import type { RuntimeFailure, RuntimeManifest, RuntimeOutcome } from "#runtime";
import type { PostContext } from "./post.ts";
import type { RuntimeManifest, RuntimeOutcome } from "./runtime.ts";
import { currentGid, currentUid, IS_WINDOWS, makeTempDir } from "#testing"; import { currentGid, currentUid, IS_WINDOWS, makeTempDir } from "#testing";
import { type Fetcher, type PostContext, postRuntime } from "./post.ts";
const context: PostContext = { const context: PostContext = {
apiUrl: "https://gitea.example/api/v1", apiUrl: "https://gitea.example/api/v1",
repository: "owner/repo", repository: "owner/repo",
@@ -23,7 +22,18 @@ interface Fixture {
outcomeFile: string; outcomeFile: string;
} }
type FixtureSource = Extract<RuntimeOutcome, { ok: false }> | { ok: true; body: string }; interface FixtureSuccess {
ok: true;
body: string;
}
type FixtureSource = RuntimeFailure | FixtureSuccess;
// One request the fake Gitea received.
interface RecordedRequest {
url: string;
init: RequestInit;
}
async function writePrivate(path: string, text: string): Promise<void> { async function writePrivate(path: string, text: string): Promise<void> {
await writeFile(path, text, { mode: 0o600 }); await writeFile(path, text, { mode: 0o600 });
@@ -32,8 +42,8 @@ async function writePrivate(path: string, text: string): Promise<void> {
async function writeArtifacts( async function writeArtifacts(
outcome: RuntimeOutcome, outcome: RuntimeOutcome,
review: string | undefined, review?: string,
anchors: unknown, anchors?: unknown,
): Promise<void> { ): Promise<void> {
if (!outcome.ok) return; if (!outcome.ok) return;
if (review !== undefined) await writePrivate(outcome.reviewPath, review); if (review !== undefined) await writePrivate(outcome.reviewPath, review);
@@ -107,7 +117,7 @@ function requestBody(init?: RequestInit): unknown {
} }
// A fake Gitea that records each request body and answers with `status`. // A fake Gitea that records each request body and answers with `status`.
function recordingFetch(bodies: unknown[], status = 201): typeof fetch { function recordingFetch(bodies: unknown[], status = 201): Fetcher {
return (_url, init) => { return (_url, init) => {
bodies.push(requestBody(init)); bodies.push(requestBody(init));
return Promise.resolve(new Response(null, { status })); return Promise.resolve(new Response(null, { status }));
@@ -116,7 +126,7 @@ function recordingFetch(bodies: unknown[], status = 201): typeof fetch {
test("posts a successful issue result and removes runtime credentials", async () => { test("posts a successful issue result and removes runtime credentials", async () => {
const result = await fixture({ ok: true, body: "done" }); const result = await fixture({ ok: true, body: "done" });
const requests: { url: unknown; init: RequestInit | undefined }[] = []; const requests: RecordedRequest[] = [];
await postRuntime(result.manifestPath, context, (url, init) => { await postRuntime(result.manifestPath, context, (url, init) => {
requests.push({ url, init }); requests.push({ url, init });
+8 -8
View File
@@ -3,12 +3,9 @@ import { lstat, readFile, rm } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import process from "node:process"; import process from "node:process";
import { isNotFound } from "./errors.ts"; import { isNotFound, isObject } from "#common";
import { isObject } from "./json.ts"; import { failureFooter, type FailureReason, type ProfileFailure, withProfile } from "#profiles";
import { failureFooter, withProfile } from "./profiles.ts"; import { readRuntimeManifest, type RuntimeManifest, type RuntimeOutcome } from "#runtime";
import type { FailureReason, ProfileFailure } from "./profiles.ts";
import { readRuntimeManifest } from "./runtime.ts";
import type { RuntimeManifest, RuntimeOutcome } from "./runtime.ts";
export interface PostContext { export interface PostContext {
apiUrl: string; apiUrl: string;
@@ -26,7 +23,7 @@ interface Anchor {
const ANSI = new RegExp(`${String.fromCodePoint(27)}\\[[0-9;]*[A-Za-z]`, "gu"); const ANSI = new RegExp(`${String.fromCodePoint(27)}\\[[0-9;]*[A-Za-z]`, "gu");
const stripAnsi = (text: string): string => text.replace(ANSI, ""); const stripAnsi = (text: string): string => text.replace(ANSI, "");
const VERDICTS: Record<string, [event: string, mark: string]> = { const VERDICTS: NodeJS.Dict<[event: string, mark: string]> = {
Approved: ["APPROVED", "✅"], Approved: ["APPROVED", "✅"],
"Changes requested": ["REQUEST_CHANGES", "🛑"], "Changes requested": ["REQUEST_CHANGES", "🛑"],
}; };
@@ -37,6 +34,9 @@ const isFailureReason = (value: unknown): value is FailureReason => FAILURE_REAS
const failureComment = (failures: ProfileFailure[]): string => const failureComment = (failures: ProfileFailure[]): string =>
`AI bot failed.\n\n${failureFooter(failures)}`; `AI bot failed.\n\n${failureFooter(failures)}`;
// The `fetch` subset the reviewer posts through, which tests replace.
export type Fetcher = (url: string, init: RequestInit) => Promise<Response>;
class GiteaRequestError extends Error {} class GiteaRequestError extends Error {}
function parseAnchors(text: string, sourcePath: string): Anchor[] { function parseAnchors(text: string, sourcePath: string): Anchor[] {
@@ -137,7 +137,7 @@ async function readAgentArtifact(
export async function postRuntime( export async function postRuntime(
manifestPath: string, manifestPath: string,
context: PostContext, context: PostContext,
fetcher: typeof fetch = fetch, fetcher: Fetcher = fetch,
): Promise<void> { ): Promise<void> {
const manifest = await readRuntimeManifest(manifestPath); const manifest = await readRuntimeManifest(manifestPath);
try { try {
+1 -1
View File
@@ -1,7 +1,7 @@
import { appendFile, rm } from "node:fs/promises"; import { appendFile, rm } from "node:fs/promises";
import process from "node:process"; import process from "node:process";
import { prepareRuntime } from "./runtime.ts"; import { prepareRuntime } from "#runtime";
const env = (name: string): string => { const env = (name: string): string => {
const value = process.env[name]; const value = process.env[name];
@@ -1,8 +1,8 @@
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import { expect, test } from "vite-plus/test"; import { expect, test } from "vite-plus/test";
import { isNotFound } from "./errors.ts"; import { isNotFound } from "#common";
import { exitCode } from "./child.ts"; import { exitCode } from "./child.ts";
test("reports the exit code", async () => { test("reports the exit code", async () => {
+4
View File
@@ -0,0 +1,4 @@
// Child processes: waiting on and echoing them, and the isolated profile
// user's processes and files.
export { echo, exitCode } from "./child.ts";
export { killProfileProcesses, readProfileFile } from "./isolation.ts";
@@ -1,14 +1,14 @@
import { spawnSync } from "node:child_process"; import { spawnSync, type SpawnSyncReturns } from "node:child_process";
import type { SpawnSyncReturns } from "node:child_process";
import { expect, test } from "vite-plus/test";
import { chmod, chown, mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; import { chmod, chown, mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises";
import process from "node:process"; import process from "node:process";
import { expect, test } from "vite-plus/test";
import { isNotFound } from "./errors.ts"; import { isNotFound } from "#common";
import { isRunnableProfileProcess, killProfileProcesses, readProfileFile } from "./isolation.ts"; import type { RuntimeProfile } from "#runtime";
import type { RuntimeProfile } from "./runtime.ts";
import { currentUid, IS_ROOT, IS_WINDOWS, makeTempDir } from "#testing"; import { currentUid, IS_ROOT, IS_WINDOWS, makeTempDir } from "#testing";
import { isRunnableProfileProcess, killProfileProcesses, readProfileFile } from "./isolation.ts";
const profile = (uid: number, home: string): RuntimeProfile => ({ const profile = (uid: number, home: string): RuntimeProfile => ({
index: uid - 20_000, index: uid - 20_000,
type: "claude", type: "claude",
@@ -25,7 +25,7 @@ function runAs(
selected: RuntimeProfile, selected: RuntimeProfile,
command: string, command: string,
args: string[], args: string[],
env?: Record<string, string>, env?: NodeJS.ProcessEnv,
): SpawnSyncReturns<string> { ): SpawnSyncReturns<string> {
return spawnSync(command, args, { return spawnSync(command, args, {
cwd: "/", cwd: "/",
@@ -2,8 +2,8 @@ import { lstat, readdir, readFile } from "node:fs/promises";
import process from "node:process"; import process from "node:process";
import { setTimeout as sleep } from "node:timers/promises"; import { setTimeout as sleep } from "node:timers/promises";
import { isNotFound } from "./errors.ts"; import { isNotFound } from "#common";
import type { RuntimeProfile } from "./runtime.ts"; import type { RuntimeProfile } from "#runtime";
export function isRunnableProfileProcess(status: string, uid: number): boolean { export function isRunnableProfileProcess(status: string, uid: number): boolean {
const owner = /^Uid:\s+(\d+)/mu.exec(status)?.[1]; const owner = /^Uid:\s+(\d+)/mu.exec(status)?.[1];
+12
View File
@@ -0,0 +1,12 @@
// Bot profiles: their footers, failure reports, ordered runs, and the
// environment each agent gets.
export {
AgentAuthError,
agentEnvironment,
failureFooter,
profileFooter,
runProfiles,
sanitizedEnvironment,
withProfile,
} from "./profiles.ts";
export type { BotProfile, FailureReason, ProfileFailure, ProfileIdentity } from "./profiles.ts";
@@ -1,4 +1,5 @@
import { expect, test } from "vite-plus/test"; import { expect, test } from "vite-plus/test";
import { import {
AgentAuthError, AgentAuthError,
agentEnvironment, agentEnvironment,
+22 -12
View File
@@ -1,22 +1,32 @@
export interface BotProfile { // The agent and model a profile runs, which every report names.
export interface ProfileIdentity {
type: "claude" | "codex" | "opencode"; type: "claude" | "codex" | "opencode";
model: string; model: string;
}
export interface BotProfile extends ProfileIdentity {
token?: string; token?: string;
} }
export function profileFooter(profile: Pick<BotProfile, "type" | "model">): string { export function profileFooter(profile: ProfileIdentity): string {
return `> Profile: \`${profile.type}\` · \`${profile.model}\``; return `> Profile: \`${profile.type}\` · \`${profile.model}\``;
} }
export function withProfile(body: string, profile: Pick<BotProfile, "type" | "model">): string { export function withProfile(body: string, profile: ProfileIdentity): string {
return `${body}\n\n${profileFooter(profile)}`; return `${body}\n\n${profileFooter(profile)}`;
} }
export type FailureReason = "sign-in failed" | "usage exhausted" | "failed"; export type FailureReason = "sign-in failed" | "usage exhausted" | "failed";
export type ProfileFailure = Pick<BotProfile, "type" | "model"> & { export interface ProfileFailure extends ProfileIdentity {
reason: FailureReason; reason: FailureReason;
}; }
// Where a profile's isolated user keeps its home.
interface ProfileHome {
type: ProfileIdentity["type"];
home: string;
}
// Raised once an agent's credentials are rejected and any re-login is spent. // Raised once an agent's credentials are rejected and any re-login is spent.
export class AgentAuthError extends Error {} export class AgentAuthError extends Error {}
@@ -41,7 +51,7 @@ export function failureFooter(failures: ProfileFailure[]): string {
// Tries the profiles from `index` on, moving on only after a sign-in failure // Tries the profiles from `index` on, moving on only after a sign-in failure
// or exhausted usage, and records every profile it tried. // or exhausted usage, and records every profile it tried.
async function attemptFrom<T, P extends Pick<BotProfile, "type" | "model">>( async function attemptFrom<T, P extends ProfileIdentity>(
profiles: P[], profiles: P[],
attempt: (profile: P) => Promise<T>, attempt: (profile: P) => Promise<T>,
failures: ProfileFailure[], failures: ProfileFailure[],
@@ -62,7 +72,7 @@ async function attemptFrom<T, P extends Pick<BotProfile, "type" | "model">>(
} }
} }
export function runProfiles<T, P extends Pick<BotProfile, "type" | "model">>( export function runProfiles<T, P extends ProfileIdentity>(
profiles: P[], profiles: P[],
attempt: (profile: P) => Promise<T>, attempt: (profile: P) => Promise<T>,
failures: ProfileFailure[] = [], failures: ProfileFailure[] = [],
@@ -70,8 +80,8 @@ export function runProfiles<T, P extends Pick<BotProfile, "type" | "model">>(
return attemptFrom(profiles, attempt, failures, 0); return attemptFrom(profiles, attempt, failures, 0);
} }
export function sanitizedEnvironment(environment: Record<string, string>): Record<string, string> { export function sanitizedEnvironment(environment: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const result: Record<string, string> = {}; const result: NodeJS.ProcessEnv = {};
for (const name of [ for (const name of [
"PATH", "PATH",
"HOME", "HOME",
@@ -105,10 +115,10 @@ export function sanitizedEnvironment(environment: Record<string, string>): Recor
} }
export function agentEnvironment( export function agentEnvironment(
environment: Record<string, string>, environment: NodeJS.ProcessEnv,
profile: Pick<BotProfile, "type"> & { home: string }, profile: ProfileHome,
token?: string, token?: string,
): Record<string, string> { ): NodeJS.ProcessEnv {
const result = sanitizedEnvironment(environment); const result = sanitizedEnvironment(environment);
result["HOME"] = profile.home; result["HOME"] = profile.home;
if (profile.type === "codex") result["CODEX_HOME"] = `${profile.home}/.codex`; if (profile.type === "codex") result["CODEX_HOME"] = `${profile.home}/.codex`;
+45 -43
View File
@@ -10,46 +10,51 @@ import { finished } from "node:stream/promises";
import { setTimeout as sleep } from "node:timers/promises"; import { setTimeout as sleep } from "node:timers/promises";
import { import {
type ClaudeFailureFields,
type CodexAuthentication,
codexAuthentication, codexAuthentication,
collectOpenCodeOutput,
type CommandResult,
isClaudeAuthFailure, isClaudeAuthFailure,
isCodexAuthFailure, isCodexAuthFailure,
persistCodexAuth,
retryInvalidToken,
stageCodexAuth,
} from "./auth.ts";
import type { ClaudeFailureFields, CodexAuthentication } from "./auth.ts";
import { echo, exitCode } from "./child.ts";
import { killProfileProcesses, readProfileFile } from "./isolation.ts";
import { isObject } from "./json.ts";
import {
collectOpenCodeOutput,
openCodeArgs, openCodeArgs,
openCodeError, openCodeError,
parseOpenCodeEvents, parseOpenCodeEvents,
} from "./opencode.ts"; persistCodexAuth,
retryInvalidToken,
stageCodexAuth,
} from "#agents";
import { isObject } from "#common";
import { echo, exitCode, killProfileProcesses, readProfileFile } from "#processes";
import { import {
AgentAuthError, AgentAuthError,
agentEnvironment, agentEnvironment,
type ProfileFailure,
type ProfileIdentity,
runProfiles, runProfiles,
sanitizedEnvironment, sanitizedEnvironment,
withProfile, withProfile,
} from "./profiles.ts"; } from "#profiles";
import type { ProfileFailure } from "./profiles.ts"; import { readRuntimeManifest, type RuntimeOutcome, type RuntimeProfile } from "#runtime";
import { readRuntimeManifest } from "./runtime.ts";
import type { RuntimeOutcome, RuntimeProfile } from "./runtime.ts";
interface AttemptResult { interface AttemptResult {
body: string; body: string;
reviewPath: string; reviewPath: string;
anchorsPath: string; anchorsPath: string;
profile: Pick<RuntimeProfile, "type" | "model">; profile: ProfileIdentity;
} }
// Claude Code's stream-json `result` event, in its own field names. // Claude Code's stream-json `result` event, with camelCase field names.
type ClaudeResult = ClaudeFailureFields & interface ClaudeResult extends ClaudeFailureFields {
Partial<Record<"is_error", boolean>> & { isError?: boolean;
subtype: string; subtype: string;
}; }
interface RenderedPrompt {
prompt: string;
reviewPath: string;
anchorsPath: string;
}
const env = (name: string): string => { const env = (name: string): string => {
const value = process.env[name]; const value = process.env[name];
@@ -57,8 +62,8 @@ const env = (name: string): string => {
return value; return value;
}; };
function definedEnvironment(): Record<string, string> { function definedEnvironment(): NodeJS.ProcessEnv {
const result: Record<string, string> = {}; const result: NodeJS.ProcessEnv = {};
for (const [name, value] of Object.entries(process.env)) { for (const [name, value] of Object.entries(process.env)) {
if (value !== undefined) { if (value !== undefined) {
result[name] = value; result[name] = value;
@@ -112,8 +117,8 @@ async function configureGitAuthor(): Promise<void> {
await runRoot("git", ["config", "user.email", user["email"]]); await runRoot("git", ["config", "user.email", user["email"]]);
} }
async function readProfileToken(profile: RuntimeProfile): Promise<string | undefined> { async function readProfileToken(profile: RuntimeProfile): Promise<string | null> {
const token = profile.tokenFile === null ? undefined : await readFile(profile.tokenFile, "utf8"); const token = profile.tokenFile === null ? null : await readFile(profile.tokenFile, "utf8");
return token; return token;
} }
@@ -159,9 +164,7 @@ function formatComment(comment: unknown): string {
return `## ${String(login)} at ${String(createdAt)}\n\n${String(body)}\n`; return `## ${String(login)} at ${String(createdAt)}\n\n${String(body)}\n`;
} }
async function renderPrompt( async function renderPrompt(profile: RuntimeProfile): Promise<RenderedPrompt> {
profile: RuntimeProfile,
): Promise<{ prompt: string; reviewPath: string; anchorsPath: string }> {
const reviewPath = await makeProfileFile(profile, "review.md"); const reviewPath = await makeProfileFile(profile, "review.md");
const anchorsPath = await makeProfileFile(profile, "anchors.json"); const anchorsPath = await makeProfileFile(profile, "anchors.json");
const comments: unknown = await ( const comments: unknown = await (
@@ -170,7 +173,7 @@ async function renderPrompt(
if (!Array.isArray(comments)) { if (!Array.isArray(comments)) {
throw new TypeError("comments: unexpected Gitea response"); throw new TypeError("comments: unexpected Gitea response");
} }
const values: Record<string, string> = { const values: NodeJS.ProcessEnv = {
COMMENT: env("COMMENT"), COMMENT: env("COMMENT"),
ISSUE_COMMENTS: comments.map((comment: unknown) => formatComment(comment)).join("\n"), ISSUE_COMMENTS: comments.map((comment: unknown) => formatComment(comment)).join("\n"),
CODE_RULES: await (await gitea(RULES_PATH)).text(), CODE_RULES: await (await gitea(RULES_PATH)).text(),
@@ -217,7 +220,7 @@ async function runClaude(profile: RuntimeProfile, token: string, prompt: string)
}, },
); );
const status = exitCode(claude); const status = exitCode(claude);
let result: ClaudeResult | undefined; let result: ClaudeResult | null = null;
let error = ""; let error = "";
const copyError = (async (): Promise<void> => { const copyError = (async (): Promise<void> => {
claude.stderr.setEncoding("utf8"); claude.stderr.setEncoding("utf8");
@@ -243,18 +246,17 @@ async function runClaude(profile: RuntimeProfile, token: string, prompt: string)
if (event["type"] === "result") { if (event["type"] === "result") {
const { is_error: isError, result: answer, subtype, api_error_status: apiStatus } = event; const { is_error: isError, result: answer, subtype, api_error_status: apiStatus } = event;
result = { result = {
is_error: typeof isError === "boolean" ? isError : undefined, isError: typeof isError === "boolean" ? isError : undefined,
result: typeof answer === "string" ? answer : undefined, result: typeof answer === "string" ? answer : undefined,
subtype: String(subtype), subtype: String(subtype),
api_error_status: apiErrorStatus: typeof apiStatus === "number" ? apiStatus : undefined,
typeof apiStatus === "number" || apiStatus === null ? apiStatus : undefined,
}; };
} }
} }
} }
} }
const [code] = await Promise.all([status, copyError]); const [code] = await Promise.all([status, copyError]);
if (result?.is_error === true) { if (result?.isError === true) {
const failure = new Error(result.result ?? `claude ended with ${result.subtype}`); const failure = new Error(result.result ?? `claude ended with ${result.subtype}`);
if (isClaudeAuthFailure(result)) { if (isClaudeAuthFailure(result)) {
throw new AgentAuthError("claude sign-in failed", { cause: failure }); throw new AgentAuthError("claude sign-in failed", { cause: failure });
@@ -287,7 +289,7 @@ async function pollForInstructions(
async function codexDeviceLogin( async function codexDeviceLogin(
profile: RuntimeProfile, profile: RuntimeProfile,
environment: Record<string, string>, environment: NodeJS.ProcessEnv,
): Promise<void> { ): Promise<void> {
const login = spawn("codex", ["login", "--device-auth"], { const login = spawn("codex", ["login", "--device-auth"], {
cwd: WORKSPACE, cwd: WORKSPACE,
@@ -316,7 +318,7 @@ async function codexDeviceLogin(
async function codexApiLogin( async function codexApiLogin(
profile: RuntimeProfile, profile: RuntimeProfile,
environment: Record<string, string>, environment: NodeJS.ProcessEnv,
token: string, token: string,
): Promise<void> { ): Promise<void> {
const login = spawn("codex", ["login", "--with-api-key"], { const login = spawn("codex", ["login", "--with-api-key"], {
@@ -363,7 +365,7 @@ async function runCodex(
if ((await exitCode(check)) !== 0) await codexDeviceLogin(profile, environment); if ((await exitCode(check)) !== 0) await codexDeviceLogin(profile, environment);
} }
const outputPath = await makeProfileFile(profile, "result.md"); const outputPath = await makeProfileFile(profile, "result.md");
const attempt = async (): Promise<{ code: number; error: string }> => { const attempt = async (): Promise<CommandResult> => {
const codex = spawn( const codex = spawn(
"codex", "codex",
[ [
@@ -440,12 +442,12 @@ async function runOpenCode(
async function runProfile(profile: RuntimeProfile): Promise<AttemptResult> { async function runProfile(profile: RuntimeProfile): Promise<AttemptResult> {
const token = await readProfileToken(profile); const token = await readProfileToken(profile);
const authentication = profile.type === "codex" ? codexAuthentication(token) : null; const authentication = profile.type === "codex" ? codexAuthentication(token ?? undefined) : null;
let stagedCodexAuth: string | null = null; let stagedCodexAuth: string | null = null;
let body: string | undefined; let body: string | null = null;
let resultPath: string | null = null; let resultPath: string | null = null;
let reviewPath: string | undefined; let reviewPath: string | null = null;
let anchorsPath: string | undefined; let anchorsPath: string | null = null;
try { try {
if (authentication?.kind === "persistent") { if (authentication?.kind === "persistent") {
stagedCodexAuth = await stageCodexAuth(CODEX_AUTH, profile.home, profile.uid, profile.gid); stagedCodexAuth = await stageCodexAuth(CODEX_AUTH, profile.home, profile.uid, profile.gid);
@@ -460,7 +462,7 @@ async function runProfile(profile: RuntimeProfile): Promise<AttemptResult> {
resultPath = await runCodex( resultPath = await runCodex(
profile, profile,
rendered.prompt, rendered.prompt,
authentication ?? codexAuthentication(token), authentication ?? codexAuthentication(token ?? undefined),
); );
} else { } else {
body = await runOpenCode(profile, token ?? "", rendered.prompt); body = await runOpenCode(profile, token ?? "", rendered.prompt);
@@ -472,7 +474,7 @@ async function runProfile(profile: RuntimeProfile): Promise<AttemptResult> {
} }
} }
if (resultPath !== null) body = await readProfileFile(profile, resultPath); if (resultPath !== null) body = await readProfileFile(profile, resultPath);
if (body === undefined || reviewPath === undefined || anchorsPath === undefined) { if (body === null || reviewPath === null || anchorsPath === null) {
throw new Error("agent attempt produced no result"); throw new Error("agent attempt produced no result");
} }
return { return {
@@ -1,4 +1,5 @@
import { expect, test } from "vite-plus/test"; import { expect, test } from "vite-plus/test";
import { decryptCredential, encryptCredential, parseCredentials } from "./credentials.ts"; import { decryptCredential, encryptCredential, parseCredentials } from "./credentials.ts";
const MASTER_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"; const MASTER_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8";
@@ -1,9 +1,15 @@
import { webcrypto } from "node:crypto"; import { webcrypto } from "node:crypto";
import { isMapping } from "./json.ts"; import { isMapping } from "#common";
import type { BotProfile } from "./profiles.ts"; import type { BotProfile } from "#profiles";
const DEFAULT_MODELS: Record<BotProfile["type"], string> = { interface DefaultModels {
claude: string;
codex: string;
opencode: string;
}
const DEFAULT_MODELS: DefaultModels = {
claude: "claude-sonnet-5", claude: "claude-sonnet-5",
codex: "gpt-5.6-terra", codex: "gpt-5.6-terra",
opencode: "openrouter/qwen/qwen3.8-27b", opencode: "openrouter/qwen/qwen3.8-27b",
+10
View File
@@ -0,0 +1,10 @@
// The runtime the prepare phase writes for the later phases: decrypted
// credentials laid out per profile, described by a manifest.
export { prepareRuntime, readRuntimeManifest } from "./runtime.ts";
export type {
RuntimeFailure,
RuntimeManifest,
RuntimeOutcome,
RuntimeProfile,
RuntimeSuccess,
} from "./runtime.ts";
@@ -2,13 +2,12 @@ import { spawnSync } from "node:child_process";
import { chmod, readdir, readFile, rm, stat, writeFile } from "node:fs/promises"; import { chmod, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
import process from "node:process"; import process from "node:process";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { assert, expect, test } from "vite-plus/test"; import { assert, expect, test } from "vite-plus/test";
import { prepareRuntime } from "./runtime.ts";
import type { RuntimeProfile } from "./runtime.ts";
import { IS_ROOT, makeTempDir } from "#testing"; import { IS_ROOT, makeTempDir } from "#testing";
import { prepareRuntime, type RuntimeProfile } from "./runtime.ts";
const MASTER_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"; const MASTER_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8";
const ENCRYPTED = "v1:ICEiIyQlJicoKSor:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA"; const ENCRYPTED = "v1:ICEiIyQlJicoKSor:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA";
@@ -82,7 +81,7 @@ test.skipIf(!IS_ROOT)("removes prepared credentials when output publication fail
); );
const result = spawnSync( const result = spawnSync(
process.execPath, process.execPath,
[fileURLToPath(new URL("prepare.ts", import.meta.url))], [fileURLToPath(new URL("../prepare.ts", import.meta.url))],
{ {
env: { env: {
ACTION_PATH: action, ACTION_PATH: action,
+19 -13
View File
@@ -1,19 +1,20 @@
import { chmod, chown, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { chmod, chown, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { isMapping } from "#common";
import type { ProfileFailure, ProfileIdentity } from "#profiles";
import { parseCredentials } from "./credentials.ts"; import { parseCredentials } from "./credentials.ts";
import { isMapping } from "./json.ts";
import type { BotProfile, ProfileFailure } from "./profiles.ts";
const FIRST_PROFILE_UID = 20_000; const FIRST_PROFILE_UID = 20_000;
export type RuntimeProfile = Pick<BotProfile, "type" | "model"> & { export interface RuntimeProfile extends ProfileIdentity {
index: number; index: number;
uid: number; uid: number;
gid: number; gid: number;
home: string; home: string;
tokenFile: string | null; tokenFile: string | null;
}; }
export interface RuntimeManifest { export interface RuntimeManifest {
runtimeDir: string; runtimeDir: string;
@@ -23,15 +24,20 @@ export interface RuntimeManifest {
profiles: RuntimeProfile[]; profiles: RuntimeProfile[];
} }
export type RuntimeOutcome = export interface RuntimeSuccess {
| { ok: true;
ok: true; body: string;
body: string; reviewPath: string;
reviewPath: string; anchorsPath: string;
anchorsPath: string; profile: ProfileIdentity;
profile: Pick<RuntimeProfile, "type" | "model">; }
}
| { ok: false; failures: ProfileFailure[] }; export interface RuntimeFailure {
ok: false;
failures: ProfileFailure[];
}
export type RuntimeOutcome = RuntimeSuccess | RuntimeFailure;
async function writePrivate(path: string, value: string, uid: number, gid: number): Promise<void> { async function writePrivate(path: string, value: string, uid: number, gid: number): Promise<void> {
await writeFile(path, value, { mode: 0o600 }); await writeFile(path, value, { mode: 0o600 });
+1 -1
View File
@@ -38,7 +38,7 @@ export interface CommandResult {
export async function runCommand( export async function runCommand(
command: string, command: string,
args: string[], args: string[],
env: Record<string, string>, env: NodeJS.ProcessEnv,
): Promise<CommandResult> { ): Promise<CommandResult> {
const child = spawn(command, args, { env, stdio: ["ignore", "ignore", "pipe"] }); const child = spawn(command, args, { env, stdio: ["ignore", "ignore", "pipe"] });
const [stderr, closed] = await Promise.all([text(child.stderr), once(child, "close")]); const [stderr, closed] = await Promise.all([text(child.stderr), once(child, "close")]);
-1
View File
@@ -1,6 +1,5 @@
import process from "node:process"; import process from "node:process";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { expect, test } from "vite-plus/test"; import { expect, test } from "vite-plus/test";
import { runCommand } from "#testing"; import { runCommand } from "#testing";
+3 -2
View File
@@ -1,12 +1,11 @@
import { readdir, readFile, rm, stat } from "node:fs/promises"; import { readdir, readFile, rm, stat } from "node:fs/promises";
import process from "node:process"; import process from "node:process";
import { assert, expect, test } from "vite-plus/test"; import { assert, expect, test } from "vite-plus/test";
import { parse } from "yaml"; import { parse } from "yaml";
import { makeTempDir, runCommand } from "#testing"; import { makeTempDir, runCommand } from "#testing";
type JsonObject = Record<string, unknown>; type JsonObject = NodeJS.Dict<unknown>;
const isMapping = (value: unknown): value is JsonObject => const isMapping = (value: unknown): value is JsonObject =>
typeof value === "object" && value !== null && !Array.isArray(value); typeof value === "object" && value !== null && !Array.isArray(value);
@@ -90,6 +89,8 @@ test("the source protection script stages immutable action files", async () => {
"src/prepare.ts", "src/prepare.ts",
"src/run.ts", "src/run.ts",
"src/post.ts", "src/post.ts",
"src/runtime/index.ts",
"src/agents/auth.ts",
"package.json", "package.json",
"profiles.json", "profiles.json",
"prompt.md", "prompt.md",
+13 -2
View File
@@ -1,3 +1,5 @@
import explicitTypes from "eslint-plugin-explicit-types";
import importStructure from "eslint-plugin-import-structure";
import { defineConfig } from "vite-plus"; import { defineConfig } from "vite-plus";
import projectLint from "./scripts/project-lint.ts"; import projectLint from "./scripts/project-lint.ts";
@@ -5,9 +7,18 @@ import projectLint from "./scripts/project-lint.ts";
export default defineConfig({ export default defineConfig({
lint: { lint: {
plugins: ["typescript", "unicorn", "oxc", "import"], plugins: ["typescript", "unicorn", "oxc", "import"],
jsPlugins: ["./scripts/project-lint.ts"], jsPlugins: [
"eslint-plugin-explicit-types",
"eslint-plugin-import-structure",
"./scripts/project-lint.ts",
],
categories: { correctness: "error", suspicious: "error", pedantic: "error", perf: "error" }, categories: { correctness: "error", suspicious: "error", pedantic: "error", perf: "error" },
rules: projectLint.configs.project.rules, rules: {
...explicitTypes.configs.concrete.rules,
...explicitTypes.configs.absence.rules,
...importStructure.configs.modules.rules,
...projectLint.configs.project.rules,
},
options: { options: {
typeAware: true, typeAware: true,
typeCheck: true, typeCheck: true,