5 Commits

Author SHA1 Message Date
Danny Kim f2de92067f Export the whole overrides array from project-lint and document the lint config
Check / check (pull_request) Successful in 1m28s
2026-10-19 16:49:57 +09:00
Danny Kim df66bea5e4 Enable each lint plugin through its all config and share the Node code override
Check / check (pull_request) Successful in 42s
2026-10-19 14:20:24 +09:00
Danny Kim d09806daad Lint with every Oxlint category and list only the offs and options
Check / check (pull_request) Successful in 51s
2026-10-19 11:26:48 +09:00
Danny Kim c8ffb9cc6f Replace NodeJS dict aliases with maps and named field reads
Agents / agent (pull_request) Has been skipped
Check / check (pull_request) Successful in 49s
2026-10-19 02:30:54 +09:00
Danny Kim 32806120b3 Group the sources into aliased modules and lint with the published plugins (#23)
Groups the flat `src/` into cohesive modules and turns on the published lint plugins. No behavior change.

## Module layout

Each module is a direct `src/` subfolder with an `index.ts` barrel and a `#` alias in `package.json` `imports` (layer order, top first):

| Alias | Files |
| --- | --- |
| `#prepare` | `index.ts` (the prepare phase entry point) |
| `#run` | `index.ts` (the run phase entry point) |
| `#post` | `index.ts` (the post phase entry point), `post.ts`, `post.test.ts` |
| `#agents` | `auth.ts`, `opencode.ts` (Codex / Claude Code sign-in, OpenCode args and output) |
| `#processes` | `child.ts`, `isolation.ts` |
| `#runtime` | `runtime.ts`, `credentials.ts` |
| `#profiles` | `profiles.ts` |
| `#common` | `errors.ts`, `json.ts` |
| `#testing` | `index.ts` (unchanged) |

Every top-level entry of `src/` is an aliased module folder. Tests stay next to their code as `*.test.ts`. Each phase's `index.ts` is its entry point, and `action.yml` now runs `node src/<phase>/index.ts`. The post phase's logic moved to `src/post/post.ts` so its test can import it; `index.ts` keeps the env reading and the `import.meta.main` call. `run`'s prompt URL is now `../../prompt.md`. `scripts/protect-source.sh` now copies `src/` recursively (it used to copy `src/*.ts` only), and the workflow test checks that module files get staged too.

## Lint plugins

- New devDependencies: `eslint-plugin-explicit-types@^0.6.1` and `eslint-plugin-import-structure@^0.10.2`, both in `jsPlugins`. The lockfile comes from a real `npm install`.
- Enabled configs: `importStructure.configs.modules`, `explicitTypes.configs.concrete`, `explicitTypes.configs.absence`.
- **explicit-types `naming` is off** (the other app repos turn it on). Its `upper-snake-string-unions` and `no-upper-snake-object-keys` rules fire on wire tokens this repo doesn't choose: profile types `"claude" | "codex" | "opencode"` from `profiles.json`, failure reasons posted to Gitea, Gitea's `"new" | "old"` side, and environment variable names such as `PATH`. Satisfying them would change behavior or need opt-outs. This is logged as an open question for the user.

Lint fixes, all type-level:
- `Pick<BotProfile, "type" | "model">` became a named `ProfileIdentity` interface that `BotProfile`, `ProfileFailure`, and `RuntimeProfile` extend. `RuntimeOutcome` is now `RuntimeSuccess | RuntimeFailure`.
- Inline object types became named interfaces.
- `Record<string, string>` env maps became `NodeJS.ProcessEnv`. `JsonObject` and the verdict table became `NodeJS.Dict<…>`.
- `T | undefined` locals and returns became `T | null` with matching checks. `typeof fetch` became a `Fetcher` type.
- Claude's result fields are held in camelCase (`isError`, `apiErrorStatus`) after parsing, because `id-match` rejects snake_case interface members.
- Duplicate import sources are merged (`import-structure/no-duplicate-import-source`).

## Verification

- `vp check`: clean.
- `vp test`: 61 passed and 5 skipped as a normal user. All 66 passed as root in `node:24-bookworm`.
- Each phase (`node src/<phase>/index.ts`) run from a `protect-source.sh` copy loads its full alias graph under Node.

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

Reviewed-on: #23
Reviewed-by: bot <noreply@capsulizers.com>
Co-authored-by: Danny Kim <temeddix@gmail.com>
Co-committed-by: Danny Kim <temeddix@gmail.com>
2026-10-18 15:00:16 +00:00
50 changed files with 958 additions and 609 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,21 +0,0 @@
---
description: bot-agents lint has prefer-readonly-parameter-types and the size/complexity rules off (Deno had no limits) and no overrides; nothing else
---
The repo follows [[lint-config-shape]] with one recorded exception, present
when the migration PR was approved:
- `typescript/prefer-readonly-parameter-types` is `"off"` in
`scripts/project-lint.ts`, as in the explicit-types template, because
Node/web types (`Response`, `Stats`, `RequestInit`) can never satisfy it.
- `vite.config.ts` has no `overrides`. The `scripts/**` override for
`typescript/no-unsafe-enum-comparison` was removed (2026-10-16):
`scripts/project-lint.ts` compares node types with `AST_NODE_TYPES` members
from `@typescript-eslint/types` instead of string literals
([[lint-overrides-policy]]).
Size and complexity limits match Deno, which had none here, so every Oxlint
limit rule is `"off"` ([[lint-config-shape]]). `run.ts` and `post.ts` keep
main's single-file layout (2026-10-17 revert of the size splits). Sequential
awaits in loops became recursion or a `reduce` chain (`no-await-in-loop`) to
stay sequential. Add no other opt-out without the user.
@@ -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
@@ -15,16 +15,19 @@ Node + Vite+ migration (PR #20, `node-migration`, 2026-10-15/16):
removed the only runtime dependency (`@std/yaml`); keep it free of runtime removed the only runtime dependency (`@std/yaml`); keep it free of runtime
dependencies (`@eslint/core` and the other devDependencies serve only the dependencies (`@eslint/core` and the other devDependencies serve only the
tooling). tooling).
- **Node 24.2+**, not 22: `src/post.ts` uses `import.meta.main`. There is no - **Node 24.2+**, not 22: `src/post/index.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.
@@ -10,7 +10,7 @@ dependency**. Hence `tsconfig.json` has `erasableSyntaxOnly` and
properties, no decorators) and relative `.ts` imports. properties, no decorators) and relative `.ts` imports.
- **Image:** the steps assume the `node:24-bookworm` job container. Node must - **Image:** the steps assume the `node:24-bookworm` job container. Node must
be 24.2+ for `import.meta.main` in `post.ts`. That image is the only Node be 24.2+ for `import.meta.main` in `src/post/index.ts`. That image is the only Node
pin (`package.json` has no `engines`); `check.yml` prints `node --version` pin (`package.json` has no `engines`); `check.yml` prints `node --version`
to confirm the cached image's minor. to confirm the cached image's minor.
- **Root-owned source copy:** `scripts/protect-source.sh` copies the action - **Root-owned source copy:** `scripts/protect-source.sh` copies the action
+4 -4
View File
@@ -2,24 +2,24 @@
description: Isolation and credential tests need root and use test.skipIf(!IS_ROOT); locally 5 skip, CI runs as root so all 66 run; verify isolation changes in node:24-bookworm as root description: Isolation and credential tests need root and use test.skipIf(!IS_ROOT); locally 5 skip, CI runs as root so all 66 run; verify isolation changes in node:24-bookworm as root
--- ---
Some tests exercise real UID isolation, process cleanup, and `prepare.ts` Some tests exercise real UID isolation, process cleanup, and the prepare phase's
credential cleanup. They need root and are written as credential cleanup. They need root and are written as
`test.skipIf(!IS_ROOT)(...)` (`IS_ROOT` from `#testing`), so they show `test.skipIf(!IS_ROOT)(...)` (`IS_ROOT` from `#testing`), so they show
as skipped instead of silently passing. as skipped instead of silently passing.
- As a normal user `vp test` passes 61 and skips 5. In CI (self-hosted - As a normal user `vp test` passes 61 and skips 5. In CI (self-hosted
runner, root) all 66 run. runner, root) all 66 run.
- When changing `isolation.ts`, `runtime.ts`, `credentials.ts`, `prepare.ts`, - When changing `isolation.ts`, `runtime.ts`, `credentials.ts`, `prepare/index.ts`,
or `child.ts`, run the suite as root in `node:24-bookworm` (e.g. a or `child.ts`, run the suite as root in `node:24-bookworm` (e.g. a
container with the repo mounted) before pushing; a green non-root run container with the repo mounted) before pushing; a green non-root run
proves nothing about those paths. proves nothing about those paths.
- The migration also ran an end-to-end smoke as root with fake - The migration also ran an end-to-end smoke as root with fake
`codex`/`claude`/`npx` binaries and a fake Gitea through `codex`/`claude`/`npx` binaries and a fake Gitea through
`protect-source.sh` -> `prepare.ts` -> `run.ts` -> `post.ts`; repeat that `protect-source.sh` -> `prepare/index.ts` -> `run/index.ts` -> `post/index.ts`; repeat that
for changes to the phase flow ([[bot-agents-runtime-no-build]]). for changes to the phase flow ([[bot-agents-runtime-no-build]]).
- `src/testing/index.ts` holds the shared test helpers (user IDs, temp dirs, - `src/testing/index.ts` holds the shared test helpers (user IDs, temp dirs,
`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/`).
+3 -2
View File
@@ -72,7 +72,7 @@ import { x } from "../x.ts"; // no: no ../ anywhere
- An area used from outside exposes its API through its alias target, and - An area used from outside exposes its API through its alias target, and
callers import the alias (`#ui`), not files inside it. callers import the alias (`#ui`), not files inside it.
- Barrels are also the standard fix for `import/max-dependencies`: one barrel - Barrels are also the standard fix for `import/max-dependencies`: one barrel
import replaces many direct ones ([[lint-overrides-policy]]). import replaces many direct ones ([[lint-config]]).
- `import/no-cycle` is on; a barrel must not create a cycle. - `import/no-cycle` is on; a barrel must not create a cycle.
Relative imports are written with the `.ts` extension. Relative imports are written with the `.ts` extension.
@@ -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 `#prepare`, `#run`, `#post`, `#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.
-48
View File
@@ -1,48 +0,0 @@
---
description: vite.config.ts lint uses Oxlint categories at error with Deno size limits, names no rule itself, and spreads configs from the plugins and scripts/project-lint.ts
---
The lint config is short and category-driven (user decisions 2026-10-14/15):
turn on whole categories, then list only what parity or a real need adds.
```ts
lint: {
plugins: ["typescript", "unicorn", "oxc", "import"],
jsPlugins: ["eslint-plugin-explicit-types", "./scripts/project-lint.ts"],
categories: { correctness: "error", suspicious: "error", pedantic: "error", perf: "error" },
rules: { ...explicitTypes.configs.concrete.rules, ...projectLint.configs.project.rules },
ignorePatterns: ["src/generated/**"], // generated files only, see below
options: { typeAware: true, typeCheck: true, maxWarnings: 0, reportUnusedDisableDirectives: "error" },
},
```
- **Categories:** `correctness`, `suspicious`, `pedantic`, `perf` at `error`.
`style` and `restriction` are never turned on wholesale; pick single rules
from them in `configs.project`. Extra rules that categories bring are fine.
- **Size limits match Deno** (2026-10-17, replaces the 2026-10-16 "default
limits stay"): repos behave as they did on Deno. bot-agents ran `deno lint`
with no config, so it had no size or complexity limit, and every Oxlint
limit rule (`complexity`, `max-lines`, `max-params`, `id-length`,
`no-nested-ternary`, `max-lines-per-function`, `max-classes-per-file`,
`max-depth`, `max-nested-callbacks`, `max-statements`,
`import/max-dependencies`, `unicorn/max-nested-calls`) is `"off"` in
`configs.project`. No file is split for size. Do not add new opt-outs
anywhere without asking the user.
- **`vite.config.ts` names no rule.** Every rule id, built-in ones included,
lives in `configs.project` of `scripts/project-lint.ts`
([[project-lint-plugin]]). `vite.config.ts` only spreads configs.
- **Published plugins are consumed through their configs**: ESLint flat config
uses `plugin.configs.<name>`; Oxlint lists the package in `jsPlugins` and
spreads `plugin.configs.<name>.rules`, since Oxlint plugins carry no configs.
Use the configs equivalent to what the repo enabled under Deno.
- **`maxWarnings: 0`** keeps Deno's "every diagnostic fails" behaviour.
- **`ignorePatterns`** is only for generated files (compiled output, codegen).
Hand-written code is never ignored.
- **No lint disable comments**: [[lint-no-disable-comments]].
- **Parity:** [[lint-parity-never-shrinks]]. **Naming:** [[naming-id-match]].
- Don't add a second import sorter; the import-structure plugin's
`enforce-import-order` is the only one, and Oxfmt's `sortImports` stays off.
`perf` came last: in the template it flagged nine `.includes` on constant
arrays, fixed as `ReadonlySet` + `.has()` rather than by turning a rule off.
That is the expected response to any new rule: change the code.
+51
View File
@@ -0,0 +1,51 @@
---
description: Lint config shape: every non-nursery Oxlint category on, plugins via `all`, a blacklist of offs in configs.project, per-file exceptions only in projectOverrides, ignorePatterns only for generated output
---
Policy (2026-10-19): a blacklist, not a whitelist. Replaces the older
category-subset and "no overrides" notes.
- **Categories:** `correctness`, `suspicious`, `pedantic`, `perf`, `style`,
`restriction` at `error`. `nursery` stays off (no line for it).
- **Plugins** are enabled whole through their `all` config
(`explicitTypes.configs.all.rules`, `importStructure.configs.all.rules`).
- **Blacklist:** repo-wide offs and option tweaks live in
`projectLint.configs.project.rules` in `scripts/project-lint.ts`, spread
last in `rules`, each with a one-line reason. Never re-list a rule a
category already turns on. bot-agents runs on Node everywhere, so
`import/no-nodejs-modules`, `unicorn/no-process-exit`, and `no-console` are
off repo-wide.
- **Per-file exceptions** only via `projectOverrides`, the whole `overrides`
array exported from `scripts/project-lint.ts`: the Node code entry
(`vite.config.ts`, `scripts/**`, `nodeRules`) first, then one entry per
exception with a why-comment. `vite.config.ts` has exactly
`overrides: projectOverrides`.
- **No `allowFiles`** options in custom rules, and no disable comments
([[lint-no-disable-comments]]).
- **`ignorePatterns`** only for generated or build output, never hand-written
code. A hand-written file is linted, with override entries for only what it
legitimately breaks. bot-agents has no generated code, so none.
- **Precedence:** categories < `rules` (a later spread wins) < `overrides` (a
later entry wins).
- Size and complexity limits (`complexity`, `max-lines`, ...) are set to the
smallest value the current code passes.
```ts
// scripts/project-lint.ts
export const projectOverrides: OxlintOverride[] = [
{ files: ["vite.config.ts", "scripts/**"], rules: nodeRules },
// Why: src/example.ts must ... (a per-file exception, if one is ever needed)
// { files: ["src/example.ts"], rules: { "some-rule": "off" } },
];
// vite.config.ts
rules: {
...explicitTypes.configs.all.rules,
...importStructure.configs.all.rules,
...projectLint.configs.project.rules,
},
overrides: projectOverrides,
```
A new category hit is fixed in the code; an off or exception is added only
for a real collision or churn, with its reason. Rules: [[project-lint-plugin]].
-33
View File
@@ -1,33 +0,0 @@
---
description: No lint overrides for source files; narrow ones only for scripts/**; generated files go in ignorePatterns; size limits match Deno, so nothing is split for size
---
Final policy (2026-10-16). The user considers per-file `overrides` bad
practice.
- **Source files: never.** No `overrides` entry may target `src/**` or any
other hand-written production or test code.
- **The one exception** is in lint-plugin repos: `import/max-dependencies`
off for `src/rules/index.ts`, the rule registry, which must import every
rule. It is not a precedent for apps.
- **`scripts/**`:** a narrow override is allowed when a tooling script truly
needs it, e.g. `no-console` in a CLI script.
- **Generated files:** list them in `ignorePatterns`, never `overrides`.
- **Anything else needs the user's approval first.** When the user is away,
don't add it: refactor, or leave the violation failing and log the question.
## Fix the code instead
Category rule violations are fixed in the code without behavior change,
covered by the tests, inside the same migration PR; e.g. `prefer-set-has`
becomes `const K: ReadonlySet<string> = new Set([...])` with `.has`.
Size and complexity limits are not refactored for: they match what Deno
enforced (2026-10-17, replacing the earlier "refactor to Oxlint's defaults"),
which for bot-agents is none, so those rules are `"off"`
([[lint-config-shape]]).
Alias rule: an alias may point anywhere and owns its target (a folder's
`index.ts` owns the folder and below, any other file owns only itself); no
two aliases may own the same file, and `../` is banned everywhere
([[imports-aliases-barrels]]).
+2 -2
View File
@@ -52,5 +52,5 @@ export default plugin;
and every repo should look the same. and every repo should look the same.
`scripts/` is tooling, not production code: never packed or published, and `scripts/` is tooling, not production code: never packed or published, and
no tests. Narrow `overrides` for `scripts/**` are allowed when a script truly no tests. Its offs sit in the Node code entry of `projectOverrides`; see
needs one (e.g. `no-console`); see [[lint-overrides-policy]]. [[lint-config]].
+1 -1
View File
@@ -14,7 +14,7 @@ description: Node.js (no engines in apps; images pin 24) with Vite+ (vp) and npm
`engines` or a `.node-version`, local `vp` uses its default Node `engines` or a `.node-version`, local `vp` uses its default Node
(`vp env default`). bot-agents has no Containerfile or setup step: the (`vp env default`). bot-agents has no Containerfile or setup step: the
self-hosted runner's `node:24-bookworm` job image runs the action and its self-hosted runner's `node:24-bookworm` job image runs the action and its
checks, and `src/post.ts` needs 24.2+ (`import.meta.main`). checks, and `src/post/index.ts` needs 24.2+ (`import.meta.main`).
- **Bun is not supported** (dropped 2026-10-15). No Bun CI legs, docs, or - **Bun is not supported** (dropped 2026-10-15). No Bun CI legs, docs, or
runtime branches. Oxlint's `RuleTester` refuses to run under Bun anyway, and runtime branches. Oxlint's `RuleTester` refuses to run under Bun anyway, and
`vp` brings its own Node, the way uv brings its own Python. `vp` brings its own Node, the way uv brings its own Python.
+1 -1
View File
@@ -10,7 +10,7 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
# The action runs on this same image; src/post.ts needs Node 24.2+. # The action runs on this same image; src/post/index.ts needs Node 24.2+.
- run: node --version - run: node --version
- run: npm ci - run: npm ci
+16 -7
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,18 +34,27 @@ 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. Every top-level entry of `src/` is an aliased module
tests that use its helpers import it by alias, and `tests/` imports `src/` folder. The aliases, in layer order, are the three phases `#prepare`, `#run`,
only through it. Local convention, stricter than the general rule above: in and `#post`, then `#agents` (Codex,
Claude Code and OpenCode specifics), `#processes` (child processes and the
isolated profile users), `#runtime` (the prepared runtime and its
credentials), `#profiles`, `#common` (error and JSON checks), and `#testing`
(test helpers), each with a `src/<name>/index.ts` barrel. A phase's
`index.ts` is also its entry point, which `action.yml` runs as
`node src/<phase>/index.ts`. 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
([.agents/memory/imports-aliases-barrels.md](.agents/memory/imports-aliases-barrels.md)). ([.agents/memory/imports-aliases-barrels.md](.agents/memory/imports-aliases-barrels.md)).
The only config files are `package.json`, `tsconfig.json`, and The only config files are `package.json`, `tsconfig.json`, and
`vite.config.ts`, plus `package-lock.json`. Repo-local lint rules and the rule `vite.config.ts`, plus `package-lock.json`. Repo-local lint rules, the rule
list live in `scripts/project-lint.ts`. Lint disable comments are not allowed; list, and the `overrides` array (`projectOverrides`) live in
fix the source instead. `scripts/project-lint.ts`
([.agents/memory/lint-config.md](.agents/memory/lint-config.md)). Lint disable
comments are not allowed; fix the source instead.
The composite action uses npm only to install the externally distributed The composite action uses npm only to install the externally distributed
Claude Code, Codex, and OpenCode command-line tools; do not treat those Claude Code, Codex, and OpenCode command-line tools; do not treat those
+3 -3
View File
@@ -35,7 +35,7 @@ runs:
- name: Prepare credentials - name: Prepare credentials
id: prepare id: prepare
shell: bash shell: bash
run: node "${{ steps.source.outputs.path }}/src/prepare.ts" run: node "${{ steps.source.outputs.path }}/src/prepare/index.ts"
env: env:
ACTION_PATH: ${{ steps.source.outputs.path }} ACTION_PATH: ${{ steps.source.outputs.path }}
BOT_AGENTS_MASTER_KEY: ${{ inputs.master-key }} BOT_AGENTS_MASTER_KEY: ${{ inputs.master-key }}
@@ -43,7 +43,7 @@ runs:
- name: Run bot - name: Run bot
id: agent id: agent
shell: bash shell: bash
run: node "${{ steps.source.outputs.path }}/src/run.ts" run: node "${{ steps.source.outputs.path }}/src/run/index.ts"
env: env:
BOT_AGENTS_MANIFEST: ${{ steps.prepare.outputs.manifest }} BOT_AGENTS_MANIFEST: ${{ steps.prepare.outputs.manifest }}
GITEA_TOKEN: ${{ gitea.token }} GITEA_TOKEN: ${{ gitea.token }}
@@ -56,7 +56,7 @@ runs:
id: post id: post
if: always() && steps.prepare.outputs.manifest != '' if: always() && steps.prepare.outputs.manifest != ''
shell: bash shell: bash
run: node "${{ steps.source.outputs.path }}/src/post.ts" run: node "${{ steps.source.outputs.path }}/src/post/index.ts"
env: env:
BOT_AGENTS_MANIFEST: ${{ steps.prepare.outputs.manifest }} BOT_AGENTS_MANIFEST: ${{ steps.prepare.outputs.manifest }}
GITEA_API_URL: ${{ gitea.api_url }} GITEA_API_URL: ${{ gitea.api_url }}
+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",
+10
View File
@@ -4,12 +4,22 @@
"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": {
"#prepare": "./src/prepare/index.ts",
"#run": "./src/run/index.ts",
"#post": "./src/post/index.ts",
"#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"
} }
+134 -69
View File
@@ -1,7 +1,7 @@
/** /**
* `project` — lint rules this repository holds its own source to, on top of * `project` — this repository's changes to the Oxlint categories in
* the Oxlint categories in `vite.config.ts`. Its rules of its own are * `vite.config.ts`. Its rules of its own are `no-disable-comments` and
* `no-disable-comments` and `no-asset-imports`. * `no-asset-imports`.
* *
* Loaded by `vite.config.ts` as an Oxlint `jsPlugins` entry. * Loaded by `vite.config.ts` as an Oxlint `jsPlugins` entry.
* *
@@ -10,11 +10,11 @@
import type { LanguageOptions, RuleDefinition, RuleVisitor, TextSourceCode } from "@eslint/core"; import type { LanguageOptions, RuleDefinition, RuleVisitor, TextSourceCode } from "@eslint/core";
import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/types"; import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/types";
import type { DummyRuleMap } from "vite-plus/lint"; import type { DummyRuleMap, OxlintOverride } from "vite-plus/lint";
/** The source code both hosts hand a rule, with the comment list. */ /** The source code both hosts hand a rule, with the comment list. */
interface ProjectSourceCode extends TextSourceCode { interface ProjectSourceCode extends TextSourceCode {
getAllComments(): TSESTree.Comment[]; getAllComments: () => TSESTree.Comment[];
} }
/** ESLint's default rule types, reporting TypeScript-ESTree nodes. */ /** ESLint's default rule types, reporting TypeScript-ESTree nodes. */
@@ -161,9 +161,9 @@ const noAssetImportsRule: ProjectRule = {
}; };
/** /**
* This repository's own rule list, spread by `vite.config.ts`: rules that * This repository's changes to the Oxlint categories in `vite.config.ts`,
* sit outside the Oxlint categories, plus the template's extras. Built-in * which turn every non-nursery category on: its own rules, the category
* Oxlint rule ids sit here too, so `vite.config.ts` lists no rule by name. * rules it turns off, and options that differ from a rule's defaults.
*/ */
const rules: DummyRuleMap = { const rules: DummyRuleMap = {
// The explicit-types template's `camelcase` stand-in: no `_` inside a name // The explicit-types template's `camelcase` stand-in: no `_` inside a name
@@ -174,31 +174,6 @@ const rules: DummyRuleMap = {
"^_*[^_]*_*$|^[^a-z]*$", "^_*[^_]*_*$|^[^a-z]*$",
{ properties: false, classFields: false, onlyDeclarations: false, ignoreDestructuring: true }, { properties: false, classFields: false, onlyDeclarations: false, ignoreDestructuring: true },
], ],
// Rules outside the categories.
"no-array-constructor": "error",
"no-case-declarations": "error",
"no-empty": "error",
"no-fallthrough": "error",
"no-inner-declarations": "error",
"no-prototype-builtins": "error",
"no-redeclare": "error",
"no-regex-spaces": "error",
"no-var": "error",
"prefer-const": "error",
"require-await": "error",
"require-yield": "error",
"typescript/adjacent-overload-signatures": "error",
"typescript/ban-ts-comment": "error",
"typescript/no-empty-interface": "error",
"typescript/no-empty-object-type": "error",
"typescript/no-explicit-any": "error",
"typescript/no-namespace": "error",
"typescript/no-non-null-asserted-optional-chain": "error",
"typescript/no-unsafe-function-type": "error",
"typescript/no-wrapper-object-types": "error",
"typescript/prefer-as-const": "error",
"typescript/prefer-namespace-keyword": "error",
"typescript/triple-slash-reference": "error",
// On Node, these come from an explicit `node:` import instead of the global scope. // On Node, these come from an explicit `node:` import instead of the global scope.
"no-restricted-globals": [ "no-restricted-globals": [
"error", "error",
@@ -209,47 +184,137 @@ const rules: DummyRuleMap = {
"setImmediate", "setImmediate",
"clearImmediate", "clearImmediate",
], ],
// The explicit-types template's extras. // Single capital type parameters, which explicit-types requires, may be one letter.
"import/no-cycle": "error", "id-length": ["error", { exceptionPatterns: ["^[A-Z]$"] }],
// `../` is banned in every file; anything above a file needs a `#` alias. // Option tweaks.
"import/no-relative-parent-imports": "error", "one-var": ["error", "never"],
"typescript/consistent-type-imports": "error", "no-use-before-define": ["error", { functions: false }],
"typescript/no-import-type-side-effects": "error", "func-style": ["error", "declaration", { allowArrowFunctions: true }],
"typescript/no-non-null-assertion": "error", "unicorn/import-style": ["error", { styles: { "node:path": { named: true } } }],
// No disable comments at all. // Each limit is the smallest value the current code passes.
complexity: ["error", 26],
"max-depth": ["error", 6],
"max-lines": ["error", { max: 2048, skipBlankLines: false, skipComments: false }],
"max-lines-per-function": ["error", 96],
"max-params": ["error", 4],
"max-statements": ["error", 32],
"import/max-dependencies": ["error", { max: 13 }],
"unicorn/max-nested-calls": ["error", { max: 4 }],
// No lint suppression and only `.ts` imports, as in the plugin template.
"project/no-disable-comments": "error", "project/no-disable-comments": "error",
"project/no-asset-imports": "error", "project/no-asset-imports": "error",
"unicorn/no-abusive-eslint-disable": "error", // Off, collides with a repo or plugin rule, Oxfmt, or another category rule:
// Size and complexity limits match Deno's: this repo's `deno lint` ran the // Collides with `#private`: members are public unless marked so.
// `recommended` set with no config file, so no size or complexity rule "typescript/explicit-member-accessibility": "off",
// applied and none applies here. Each Oxlint limit rule, from a category or // Collides with `typescript/prefer-readonly-parameter-types` being off.
// not, is off: "typescript/prefer-readonly": "off",
// - `complexity`: Deno had no `max-complexity`. // Collides with Node's and the web's mutable types (`Response`, `Stats`).
// - `max-lines`: Deno had no `max-file-lines`.
// - `max-params`: Deno had no `max-params`.
// - `id-length`: Deno had no `max-identifier-length`.
// - `no-nested-ternary`: Deno had no `max-nest-ternaries`.
// - `max-lines-per-function`, `max-classes-per-file`, `max-depth`,
// `max-nested-callbacks`, `max-statements`, `import/max-dependencies`,
// `unicorn/max-nested-calls`: Deno had none of these.
complexity: "off",
"max-lines": "off",
"max-params": "off",
"id-length": "off",
"no-nested-ternary": "off",
"max-lines-per-function": "off",
"max-classes-per-file": "off",
"max-depth": "off",
"max-nested-callbacks": "off",
"max-statements": "off",
"import/max-dependencies": "off",
"unicorn/max-nested-calls": "off",
// Pedantic, but Node's and the web's own types (`Response`, `Stats`,
// `RequestInit`) are mutable, so no parameter of theirs can pass; the
// explicit-types template and the app repos turn it off too.
"typescript/prefer-readonly-parameter-types": "off", "typescript/prefer-readonly-parameter-types": "off",
// Collides with explicit-types `no-utility-types`, which bans `Record`.
"typescript/consistent-indexed-object-style": "off",
// Collides with explicitly annotated declarations.
"typescript/no-inferrable-types": "off",
// Collides with import-structure `enforce-import-order`.
"sort-imports": "off",
// Collides with import-structure `enforce-mod-file` barrels.
"oxc/no-barrel-file": "off",
// Collides with exporting each declaration where it stands.
"import/group-exports": "off",
// Collides with exporting each declaration where it stands.
"import/exports-last": "off",
// Collides with `import/no-default-export`.
"import/prefer-default-export": "off",
// Collides with `unicorn/prefer-ternary`.
"no-ternary": "off",
// Collides with `require-await` and `promise-function-async`.
"oxc/no-async-await": "off",
// Collides with `typescript/prefer-optional-chain`.
"oxc/no-optional-chaining": "off",
// Collides with file mode checks such as `mode & 0o077`.
"no-bitwise": "off",
// Collides with explicit-types `no-optional-null`, which makes `undefined` the absent value.
"no-undefined": "off",
// Collides with `typescript/no-floating-promises`, which `void` satisfies.
"no-void": "off",
// Collides with JSON and Node APIs, which return `null`.
"unicorn/no-null": "off",
// Collides with `import/no-default-export`.
"import/no-named-export": "off",
// Collides with `typescript/consistent-type-imports`.
"import/consistent-type-specifier-style": "off",
// Collides with `require-await`, which fails an `async` wrapper with no `await`.
"typescript/promise-function-async": "off",
// Collides with explicit-types `no-undefined-type`: a `let` that `try`
// assigns would need `T | undefined`.
"init-declarations": "off",
// Collides with `no-await-in-loop`: sequential steps chain through `reduce`.
"unicorn/no-array-reduce": "off",
// Collides with literal Gitea Actions `${{ }}` expressions in workflow strings.
"no-template-curly-in-string": "off",
// Node code, everywhere: this action runs on Node.
// Node built-ins are the action's runtime.
"import/no-nodejs-modules": "off",
// Command-line entry points set their exit status.
"unicorn/no-process-exit": "off",
// The action reports to the job log through the console.
"no-console": "off",
// PENDING USER: union members are values in profiles.json, the Gitea API,
// and posted comments; renaming them changes behavior.
"explicit-types/upper-snake-string-unions": "off",
// PENDING USER: a fitting `name` needs a constructor and changes logged
// error names.
"unicorn/custom-error-definition": "off",
// Off, too much churn:
// Literal numbers read better than a constant per use.
"no-magic-numbers": "off",
// Keys follow meaning, not the alphabet.
"sort-keys": "off",
// Comments may start with code or lowercase identifiers.
"capitalized-comments": "off",
// explicit-types `require-return-type` covers return types.
"typescript/explicit-function-return-type": "off",
// explicit-types `require-return-type` covers return types.
"typescript/explicit-module-boundary-types": "off",
// Empty callbacks are legitimate no-ops.
"no-empty-function": "off",
// Methods may not need `this`.
"class-methods-use-this": "off",
// Braces only where a case declares.
"unicorn/switch-case-braces": "off",
// Plain property reads are fine.
"prefer-destructuring": "off",
// Object rest and spread are idiomatic.
"oxc/no-rest-spread-properties": "off",
// Short numbers need no separators.
"unicorn/numeric-separators-style": "off",
// `continue` keeps loop bodies flat.
"no-continue": "off",
}; };
/** Node code (`vite.config.ts` and `scripts/`): the first `projectOverrides` entry. */
const nodeRules: DummyRuleMap = {
// Vite and the Oxlint plugin host read a default export.
"import/no-default-export": "off",
// Node code: tooling imports Node's built-ins.
"import/no-nodejs-modules": "off",
// Node code: a CLI exits with a status.
"unicorn/no-process-exit": "off",
// Node code: a CLI reports through the console.
"no-console": "off",
};
/**
* The whole `overrides` array of `vite.config.ts`: the Node code entry first,
* then each per-file exception, each with a comment saying why. Later entries
* win over earlier ones, and all of them over `rules`.
*/
export const projectOverrides: OxlintOverride[] = [
{
files: ["vite.config.ts", "scripts/**"],
rules: nodeRules,
},
];
/** /**
* The repo-local plugin, namespace `project`: its own rules, enabled as * The repo-local plugin, namespace `project`: its own rules, enabled as
* `project/<rule>`, and `configs.project`, the rule list above. * `project/<rule>`, and `configs.project`, the rule list above.
+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}"
+15 -12
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;
@@ -18,10 +19,10 @@ test("retries once after an invalid refresh token", async () => {
() => () =>
Promise.resolve({ Promise.resolve({
code: 1, code: 1,
error: attempts++ === 0 ? "ERROR: invalid_refresh_token" : "still failed", error: (attempts += 1) === 1 ? "ERROR: invalid_refresh_token" : "still failed",
}), }),
() => { () => {
relogins++; relogins += 1;
return Promise.resolve(); return Promise.resolve();
}, },
); );
@@ -38,14 +39,14 @@ test("does not retry an unrelated failure", async () => {
let relogins = 0; let relogins = 0;
await retryInvalidToken( await retryInvalidToken(
() => { () => {
attempts++; attempts += 1;
return Promise.resolve({ return Promise.resolve({
code: 1, code: 1,
error: "rate limited", error: "rate limited",
}); });
}, },
() => { () => {
relogins++; relogins += 1;
return Promise.resolve(); return Promise.resolve();
}, },
); );
@@ -86,11 +87,11 @@ test("re-logs in after the job 9837 revoked-session stderr", async () => {
const result = await retryInvalidToken( const result = await retryInvalidToken(
() => () =>
Promise.resolve({ Promise.resolve({
code: attempts++ > 0 ? 0 : 1, code: (attempts += 1) > 1 ? 0 : 1,
error: attempts === 1 ? REVOKED_CODEX.join("\n") : "", error: attempts === 1 ? REVOKED_CODEX.join("\n") : "",
}), }),
() => { () => {
relogins++; relogins += 1;
return Promise.resolve(); return Promise.resolve();
}, },
); );
@@ -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", () => {
@@ -143,7 +144,8 @@ test.skipIf(IS_WINDOWS)("stages and persists only a regular private Codex login"
const staged = await stageCodexAuth(canonical, home, currentUid(), currentGid()); const staged = await stageCodexAuth(canonical, home, currentUid(), currentGid());
expect(await readFile(staged, "utf8")).toStrictEqual(AUTH); expect(await readFile(staged, "utf8")).toStrictEqual(AUTH);
expect((await stat(staged)).mode & 0o777).toBe(0o600); const stagedStat = await stat(staged);
expect(stagedStat.mode & 0o777).toBe(0o600);
const refreshed = JSON.stringify({ const refreshed = JSON.stringify({
auth_mode: "chatgpt", auth_mode: "chatgpt",
@@ -152,7 +154,8 @@ test.skipIf(IS_WINDOWS)("stages and persists only a regular private Codex login"
await writeFile(staged, refreshed); await writeFile(staged, refreshed);
await persistCodexAuth(staged, canonical); await persistCodexAuth(staged, canonical);
expect(await readFile(canonical, "utf8")).toStrictEqual(refreshed); expect(await readFile(canonical, "utf8")).toStrictEqual(refreshed);
expect((await stat(canonical)).mode & 0o777).toBe(0o600); const canonicalStat = await stat(canonical);
expect(canonicalStat.mode & 0o777).toBe(0o600);
} finally { } finally {
await rm(root, { recursive: true }); await rm(root, { recursive: true });
} }
+33 -16
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 { field, 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> {
@@ -75,15 +89,16 @@ async function readCodexAuth(path: string): Promise<string> {
} catch { } catch {
throw new Error("invalid Codex login"); throw new Error("invalid Codex login");
} }
const tokens = isObject(value) ? value["tokens"] : undefined; const authMode = field(value, "auth_mode");
const tokens = field(value, "tokens");
if ( if (
!isObject(value) || typeof authMode !== "string" ||
typeof value["auth_mode"] !== "string" || authMode === "" ||
value["auth_mode"] === "" ||
!isObject(tokens) || !isObject(tokens) ||
!["access_token", "refresh_token", "id_token", "account_id"].every( !["access_token", "refresh_token", "id_token", "account_id"].every((name) => {
(name) => typeof tokens[name] === "string" && tokens[name] !== "", const token = field(tokens, name);
) return typeof token === "string" && token !== "";
})
) { ) {
throw new Error("invalid Codex login"); throw new Error("invalid Codex login");
} }
@@ -106,7 +121,9 @@ export async function stageCodexAuth(
await chmod(stagedPath, 0o600); await chmod(stagedPath, 0o600);
await chown(stagedPath, uid, gid); await chown(stagedPath, uid, gid);
} catch (error) { } catch (error) {
if (!isNotFound(error)) throw error; if (!isNotFound(error)) {
throw error;
}
} }
return stagedPath; return stagedPath;
} }
+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();
}); });
+36 -20
View File
@@ -1,5 +1,5 @@
import { isObject } from "./json.ts"; import { field, 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,23 +46,26 @@ 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;
try { try {
event = JSON.parse(line); event = JSON.parse(line);
} catch { } catch {
continue; continue;
} }
if (!isObject(event)) continue; const error = field(event, "error");
const { type, error } = event; if (field(event, "type") !== "error" || !isObject(error)) {
if (type !== "error" || !isObject(error)) {
continue; continue;
} }
const { name, data } = error; const name = field(error, "name");
const { message, statusCode } = isObject(data) ? data : {}; const data = field(error, "data");
const message = field(data, "message");
const statusCode = field(data, "statusCode");
const detail = `opencode ${typeof name === "string" ? name : "error"}${ const detail = `opencode ${typeof name === "string" ? name : "error"}${
typeof statusCode === "number" ? ` (API error ${statusCode})` : "" typeof statusCode === "number" ? ` (API error ${statusCode})` : ""
}: ${typeof message === "string" ? message : "unknown error"}`; }: ${typeof message === "string" ? message : "unknown error"}`;
@@ -69,17 +80,22 @@ 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);
if (!isObject(event)) continue; const part = field(event, "part");
const { type, part } = event; if (field(event, "type") !== "text" || !isObject(part)) {
if (type !== "text" || !isObject(part)) continue; continue;
const { text } = part; }
if (typeof text === "string" && text.trim() !== "") result = text; const text = field(part, "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;
+3
View File
@@ -0,0 +1,3 @@
// Helpers every phase shares: error and JSON value checks.
export { isNotFound } from "./errors.ts";
export { field, isMapping, isObject } from "./json.ts";
+23
View File
@@ -0,0 +1,23 @@
import { expect, test } from "vite-plus/test";
import { field, isMapping, isObject } from "./json.ts";
test("reads a named field of a parsed object", () => {
const value: unknown = JSON.parse('{"login":"bot","user":{"login":"nested"}}');
expect(field(value, "login")).toBe("bot");
expect(field(field(value, "user"), "login")).toBe("nested");
expect(field(value, "missing")).toBeUndefined();
});
test("reads no field of a value that is not an object", () => {
for (const value of [null, undefined, "text", 1, true]) {
expect(field(value, "login")).toBeUndefined();
}
});
test("counts arrays as objects but not as mappings", () => {
expect(isObject([])).toBe(true);
expect(isMapping([])).toBe(false);
expect(isMapping({})).toBe(true);
expect(field(["first"], "0")).toBe("first");
});
+19
View File
@@ -0,0 +1,19 @@
// Checks and field reads for values parsed from JSON, which arrive as
// `unknown`. Callers read the fields they know by name into their own
// declared interfaces.
// Any non-null object, arrays included.
export function isObject(value: unknown): value is object {
return typeof value === "object" && value !== null;
}
// A plain mapping, which an array is not.
export function isMapping(value: unknown): value is object {
return isObject(value) && !Array.isArray(value);
}
// The named field of an object, or undefined for a missing field or a value
// that is not an object.
export function field(value: unknown, name: string): unknown {
return isObject(value) ? Reflect.get(value, name) : undefined;
}
-12
View File
@@ -1,12 +0,0 @@
// Type guards for values read from JSON, which arrive as `unknown`.
export type JsonObject = Record<string, unknown>;
// Any non-null object, arrays included: reading a missing key yields undefined.
export function isObject(value: unknown): value is JsonObject {
return typeof value === "object" && value !== null;
}
// A plain mapping, which an array is not.
export function isMapping(value: unknown): value is JsonObject {
return isObject(value) && !Array.isArray(value);
}
+24
View File
@@ -0,0 +1,24 @@
// The post phase: posts the agent's result as the reviewer and removes the
// runtime credentials.
import process from "node:process";
import { postRuntime } from "./post.ts";
export { type Fetcher, type PostContext, postRuntime } from "./post.ts";
function required(name: string): string {
const value = process.env[name];
if (value === undefined || value === "") {
throw new Error(`${name} is not set`);
}
return value;
}
if (import.meta.main) {
await postRuntime(required("BOT_AGENTS_MANIFEST"), {
apiUrl: required("GITEA_API_URL"),
repository: required("GITEA_REPOSITORY"),
issueIndex: required("ISSUE_INDEX"),
eventName: required("EVENT_NAME"),
});
}
+32 -14
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,12 +42,18 @@ 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) {
if (review !== undefined) await writePrivate(outcome.reviewPath, review); return;
if (anchors !== undefined) await writePrivate(outcome.anchorsPath, JSON.stringify(anchors)); }
if (review !== undefined) {
await writePrivate(outcome.reviewPath, review);
}
if (anchors !== undefined) {
await writePrivate(outcome.anchorsPath, JSON.stringify(anchors));
}
} }
function manifestFor(runtimeDir: string, profileHome: string): RuntimeManifest { function manifestFor(runtimeDir: string, profileHome: string): RuntimeManifest {
@@ -96,7 +112,9 @@ const missing = async (path: string): Promise<boolean> => {
await stat(path); await stat(path);
return false; return false;
} catch (error) { } catch (error) {
if (isNotFound(error)) return true; if (isNotFound(error)) {
return true;
}
throw error; throw error;
} }
}; };
@@ -107,7 +125,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 +134,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 });
+59 -59
View File
@@ -1,14 +1,10 @@
import type { Stats } from "node:fs"; import type { Stats } from "node:fs";
import { lstat, readFile, rm } from "node:fs/promises"; import { lstat, readFile, rm } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import process from "node:process";
import { isNotFound } from "./errors.ts"; import { field, 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,28 +22,37 @@ 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 = new Map<string, [event: string, mark: string]>([
Approved: ["APPROVED", "✅"], ["Approved", ["APPROVED", "✅"]],
"Changes requested": ["REQUEST_CHANGES", "🛑"], ["Changes requested", ["REQUEST_CHANGES", "🛑"]],
}; ]);
const PROFILE_TYPES: Set<unknown> = new Set(["claude", "codex", "opencode"]); const PROFILE_TYPES = new Set<unknown>(["claude", "codex", "opencode"]);
const FAILURE_REASONS: Set<unknown> = new Set(["sign-in failed", "usage exhausted", "failed"]); const FAILURE_REASONS = new Set<unknown>(["sign-in failed", "usage exhausted", "failed"]);
const isProfileType = (value: unknown): value is ProfileFailure["type"] => PROFILE_TYPES.has(value); const isProfileType = (value: unknown): value is ProfileFailure["type"] => PROFILE_TYPES.has(value);
const isFailureReason = (value: unknown): value is FailureReason => FAILURE_REASONS.has(value); const isFailureReason = (value: unknown): value is FailureReason => FAILURE_REASONS.has(value);
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[] {
const entries: unknown = JSON.parse(text); const entries: unknown = JSON.parse(text);
if (!Array.isArray(entries)) throw new TypeError(`${sourcePath}: not an array`); if (!Array.isArray(entries)) {
throw new TypeError(`${sourcePath}: not an array`);
}
return entries.map((entry: unknown, index) => { return entries.map((entry: unknown, index) => {
const at = `${sourcePath}[${index}]`; const at = `${sourcePath}[${index}]`;
if (!isObject(entry)) { if (!isObject(entry)) {
throw new TypeError(`${at}: not an object`); throw new TypeError(`${at}: not an object`);
} }
const { path, line, side = "new", body } = entry; const path = field(entry, "path");
const line = field(entry, "line");
const givenSide = field(entry, "side");
const side = givenSide === undefined ? "new" : givenSide;
const body = field(entry, "body");
if (typeof path !== "string" || path === "") { if (typeof path !== "string" || path === "") {
throw new TypeError(`${at}.path: expected a repository path`); throw new TypeError(`${at}.path: expected a repository path`);
} }
@@ -69,43 +74,42 @@ async function readOutcome(path: string): Promise<RuntimeOutcome> {
if (!isObject(value) || !("ok" in value)) { if (!isObject(value) || !("ok" in value)) {
throw new Error("invalid agent outcome"); throw new Error("invalid agent outcome");
} }
const outcome = value; const ok = field(value, "ok");
if (outcome["ok"] === false) { if (ok === false) {
// Only known labels reach the comment, never raw agent output. // Only known labels reach the comment, never raw agent output.
const failures: unknown[] = Array.isArray(outcome["failures"]) ? outcome["failures"] : []; const listed = field(value, "failures");
const failures: unknown[] = Array.isArray(listed) ? listed : [];
return { return {
ok: false, ok: false,
failures: failures.flatMap((entry: unknown) => { failures: failures.flatMap((entry: unknown) => {
const { type, model, reason } = isObject(entry) ? entry : {}; const type = field(entry, "type");
const model = field(entry, "model");
const reason = field(entry, "reason");
return isProfileType(type) && typeof model === "string" && isFailureReason(reason) return isProfileType(type) && typeof model === "string" && isFailureReason(reason)
? [{ type, model, reason }] ? [{ type, model, reason }]
: []; : [];
}), }),
}; };
} }
const body = field(value, "body");
const reviewPath = field(value, "reviewPath");
const anchorsPath = field(value, "anchorsPath");
const profile = field(value, "profile");
if ( if (
outcome["ok"] !== true || ok !== true ||
typeof outcome["body"] !== "string" || typeof body !== "string" ||
typeof outcome["reviewPath"] !== "string" || typeof reviewPath !== "string" ||
typeof outcome["anchorsPath"] !== "string" || typeof anchorsPath !== "string" ||
!isObject(outcome["profile"]) !isObject(profile)
) { ) {
throw new Error("invalid agent outcome"); throw new Error("invalid agent outcome");
} }
const profile = outcome["profile"]; const type = field(profile, "type");
if (!isProfileType(profile["type"]) || typeof profile["model"] !== "string") { const model = field(profile, "model");
if (!isProfileType(type) || typeof model !== "string") {
throw new Error("invalid agent outcome"); throw new Error("invalid agent outcome");
} }
return { return { ok: true, body, reviewPath, anchorsPath, profile: { type, model } };
ok: true,
body: outcome["body"],
reviewPath: outcome["reviewPath"],
anchorsPath: outcome["anchorsPath"],
profile: {
type: profile["type"],
model: profile["model"],
},
};
} }
async function readAgentArtifact( async function readAgentArtifact(
@@ -114,12 +118,16 @@ async function readAgentArtifact(
name: string, name: string,
): Promise<string | null> { ): Promise<string | null> {
const profile = manifest.profiles.find((entry) => join(entry.home, name) === path); const profile = manifest.profiles.find((entry) => join(entry.home, name) === path);
if (profile === undefined) throw new Error(`${name}: outside profile home`); if (profile === undefined) {
throw new Error(`${name}: outside profile home`);
}
let stat: Stats; let stat: Stats;
try { try {
stat = await lstat(path); stat = await lstat(path);
} catch (error) { } catch (error) {
if (isNotFound(error)) return null; if (isNotFound(error)) {
return null;
}
throw error; throw error;
} }
if ( if (
@@ -137,11 +145,12 @@ 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 {
const reviewerToken = (await readFile(manifest.reviewerFile, "utf8")).trim(); const reviewerText = await readFile(manifest.reviewerFile, "utf8");
const reviewerToken = reviewerText.trim();
const request = async (path: string, body: unknown): Promise<void> => { const request = async (path: string, body: unknown): Promise<void> => {
let response: Response; let response: Response;
try { try {
@@ -189,7 +198,7 @@ export async function postRuntime(
} }
const [verdict = "", ...rest] = review.split("\n"); const [verdict = "", ...rest] = review.split("\n");
const [event, mark] = VERDICTS[verdict.trim()] ?? ["COMMENT", "💬"]; const [event, mark] = VERDICTS.get(verdict.trim()) ?? ["COMMENT", "💬"];
const anchorText = await readAgentArtifact(manifest, outcome.anchorsPath, "anchors.json"); const anchorText = await readAgentArtifact(manifest, outcome.anchorsPath, "anchors.json");
const anchors = anchorText === null ? [] : parseAnchors(anchorText, outcome.anchorsPath); const anchors = anchorText === null ? [] : parseAnchors(anchorText, outcome.anchorsPath);
const postReview = (entries: Anchor[]): Promise<void> => const postReview = (entries: Anchor[]): Promise<void> =>
@@ -206,35 +215,26 @@ export async function postRuntime(
})), })),
}); });
await postReview(anchors).catch(async (error: unknown) => { await postReview(anchors).catch(async (error: unknown) => {
if (anchors.length === 0) throw error; if (anchors.length === 0) {
throw error;
}
console.error( console.error(
`inline comments rejected: ${error instanceof Error ? error.message : String(error)}`, `inline comments rejected: ${error instanceof Error ? error.message : String(error)}`,
); );
await postReview([]); await postReview([]);
}); });
} catch (error) { } catch (error) {
if (error instanceof GiteaRequestError) throw error; if (error instanceof GiteaRequestError) {
throw error;
}
await postComment(failureComment([{ ...outcome.profile, reason: "failed" }])); await postComment(failureComment([{ ...outcome.profile, reason: "failed" }]));
throw error; throw error;
} }
} finally { } finally {
await rm(manifest.runtimeDir, { recursive: true }).catch((error: unknown) => { await rm(manifest.runtimeDir, { recursive: true }).catch((error: unknown) => {
if (!isNotFound(error)) throw error; if (!isNotFound(error)) {
throw error;
}
}); });
} }
} }
function required(name: string): string {
const value = process.env[name];
if (value === undefined || value === "") throw new Error(`${name} is not set`);
return value;
}
if (import.meta.main) {
await postRuntime(required("BOT_AGENTS_MANIFEST"), {
apiUrl: required("GITEA_API_URL"),
repository: required("GITEA_REPOSITORY"),
issueIndex: required("ISSUE_INDEX"),
eventName: required("EVENT_NAME"),
});
}
+4 -2
View File
@@ -1,11 +1,13 @@
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];
if (value === undefined || value === "") throw new Error(`${name} is not set`); if (value === undefined || value === "") {
throw new Error(`${name} is not set`);
}
return value; return value;
}; };
@@ -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,14 +25,14 @@ function runAs(
selected: RuntimeProfile, selected: RuntimeProfile,
command: string, command: string,
args: string[], args: string[],
env?: Record<string, string>, env?: ReadonlyMap<string, string>,
): SpawnSyncReturns<string> { ): SpawnSyncReturns<string> {
return spawnSync(command, args, { return spawnSync(command, args, {
cwd: "/", cwd: "/",
uid: selected.uid, uid: selected.uid,
gid: selected.gid, gid: selected.gid,
encoding: "utf8", encoding: "utf8",
...(env !== undefined && { env }), ...(env !== undefined && { env: Object.fromEntries(env) }),
}); });
} }
@@ -96,7 +96,9 @@ test.skipIf(!IS_ROOT)("cleanup leaves no runnable daemonized process", async ()
await killProfileProcesses(selected.uid); await killProfileProcesses(selected.uid);
const status = await readFile(`/proc/${pid}/status`, "utf8").catch((error: unknown) => { const status = await readFile(`/proc/${pid}/status`, "utf8").catch((error: unknown) => {
if (isNotFound(error)) return null; if (isNotFound(error)) {
return null;
}
throw error; throw error;
}); });
expect(status === null || !isRunnableProfileProcess(status, selected.uid)).toBe(true); expect(status === null || !isRunnableProfileProcess(status, selected.uid)).toBe(true);
@@ -104,9 +106,12 @@ test.skipIf(!IS_ROOT)("cleanup leaves no runnable daemonized process", async ()
test.skipIf(!IS_ROOT)("the agent process cannot read its coordinator environment", () => { test.skipIf(!IS_ROOT)("the agent process cannot read its coordinator environment", () => {
const selected = profile(20_020, "/tmp"); const selected = profile(20_020, "/tmp");
const result = runAs(selected, "/usr/bin/cat", [`/proc/${process.pid}/environ`], { const result = runAs(
PARENT_SECRET: "must-not-cross-boundary", selected,
}); "/usr/bin/cat",
[`/proc/${process.pid}/environ`],
new Map([["PARENT_SECRET", "must-not-cross-boundary"]]),
);
expect(result.status).not.toBe(0); expect(result.status).not.toBe(0);
}); });
@@ -2,12 +2,12 @@ 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+(?<uid>\d+)/mu.exec(status)?.[1];
const state = /^State:\s+(\w)/mu.exec(status)?.[1]; const state = /^State:\s+(?<state>\w)/mu.exec(status)?.[1];
return owner !== undefined && Number(owner) === uid && state !== "Z"; return owner !== undefined && Number(owner) === uid && state !== "Z";
} }
@@ -33,12 +33,16 @@ export async function killProfileProcesses(uid: number, attempt = 0): Promise<vo
throw new Error(`profile UID ${uid} still owns processes after cleanup`); throw new Error(`profile UID ${uid} still owns processes after cleanup`);
} }
const ids = await profileProcessIds(uid); const ids = await profileProcessIds(uid);
if (ids.length === 0) return; if (ids.length === 0) {
return;
}
for (const pid of ids) { for (const pid of ids) {
try { try {
process.kill(pid, "SIGKILL"); process.kill(pid, "SIGKILL");
} catch (error) { } catch (error) {
if (!isNotFound(error)) throw error; if (!isNotFound(error)) {
throw error;
}
} }
} }
await sleep(10); await sleep(10);
+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,
@@ -61,7 +62,9 @@ test("recognizes provider API exhaustion status", async () => {
{ type: "codex", model: "gpt-5.6-terra" }, { type: "codex", model: "gpt-5.6-terra" },
], ],
(profile) => { (profile) => {
if (profile.type === "claude") throw new Error(message); if (profile.type === "claude") {
throw new Error(message);
}
return Promise.resolve("fallback"); return Promise.resolve("fallback");
}, },
), ),
@@ -83,7 +86,9 @@ test("falls through after a sign-in failure and records each try", async () => {
if (profile.type === "codex") { if (profile.type === "codex") {
throw new AgentAuthError("codex sign-in failed"); throw new AgentAuthError("codex sign-in failed");
} }
if (profile.type === "claude") throw new Error("API Error: 429"); if (profile.type === "claude") {
throw new Error("API Error: 429");
}
return Promise.resolve("fallback"); return Promise.resolve("fallback");
}, },
failures, failures,
@@ -115,7 +120,8 @@ test("throws the last sign-in failure when every profile fails", async () => {
), ),
).rejects.toThrow( ).rejects.toThrow(
expect.toSatisfy( expect.toSatisfy(
(e: unknown) => e instanceof AgentAuthError && e.message.includes("claude sign-in failed"), (error: unknown) =>
error instanceof AgentAuthError && error.message.includes("claude sign-in failed"),
), ),
); );
expect(failures.map(({ reason }) => reason)).toStrictEqual(["sign-in failed", "sign-in failed"]); expect(failures.map(({ reason }) => reason)).toStrictEqual(["sign-in failed", "sign-in failed"]);
@@ -156,20 +162,20 @@ test("does not mistake incidental quota text for exhaustion", async () => {
}); });
test("exposes only the selected profile credential to the agent", () => { test("exposes only the selected profile credential to the agent", () => {
const base = { const base = new Map([
PATH: "/usr/bin", ["PATH", "/usr/bin"],
HOME: "/home/bot", ["HOME", "/home/bot"],
UNRELATED_SECRET: "must-not-reach-agent", ["UNRELATED_SECRET", "must-not-reach-agent"],
BOT_AGENTS_MASTER_KEY: "master", ["BOT_AGENTS_MASTER_KEY", "master"],
PROFILES: "contains every secret", ["PROFILES", "contains every secret"],
REVIEWER_TOKEN: "reviewer", ["REVIEWER_TOKEN", "reviewer"],
CLAUDE_CODE_OAUTH_TOKEN: "old-claude", ["CLAUDE_CODE_OAUTH_TOKEN", "old-claude"],
ANTHROPIC_API_KEY: "old-anthropic-key", ["ANTHROPIC_API_KEY", "old-anthropic-key"],
ANTHROPIC_AUTH_TOKEN: "old-anthropic-token", ["ANTHROPIC_AUTH_TOKEN", "old-anthropic-token"],
OPENAI_API_KEY: "old-openai", ["OPENAI_API_KEY", "old-openai"],
CODEX_API_KEY: "old-codex-key", ["CODEX_API_KEY", "old-codex-key"],
CODEX_ACCESS_TOKEN: "old-codex-token", ["CODEX_ACCESS_TOKEN", "old-codex-token"],
}; ]);
const profile = { const profile = {
index: 0, index: 0,
type: "claude" as const, type: "claude" as const,
@@ -180,15 +186,19 @@ test("exposes only the selected profile credential to the agent", () => {
tokenFile: "/runtime/profile-0/token", tokenFile: "/runtime/profile-0/token",
}; };
expect(agentEnvironment(base, profile, "selected")).toStrictEqual({ expect(agentEnvironment(base, profile, "selected")).toStrictEqual(
PATH: "/usr/bin", new Map([
HOME: "/runtime/profile-0", ["PATH", "/usr/bin"],
CLAUDE_CODE_OAUTH_TOKEN: "selected", ["HOME", "/runtime/profile-0"],
}); ["CLAUDE_CODE_OAUTH_TOKEN", "selected"],
expect(sanitizedEnvironment(base)).toStrictEqual({ ]),
PATH: "/usr/bin", );
HOME: "/home/bot", expect(sanitizedEnvironment(base)).toStrictEqual(
}); new Map([
["PATH", "/usr/bin"],
["HOME", "/home/bot"],
]),
);
}); });
test("uses a private Codex home without inventing an API credential", () => { test("uses a private Codex home without inventing an API credential", () => {
@@ -202,11 +212,13 @@ test("uses a private Codex home without inventing an API credential", () => {
tokenFile: null, tokenFile: null,
}; };
expect(agentEnvironment({ PATH: "/bin" }, profile)).toStrictEqual({ expect(agentEnvironment(new Map([["PATH", "/bin"]]), profile)).toStrictEqual(
PATH: "/bin", new Map([
HOME: "/runtime/profile-1", ["PATH", "/bin"],
CODEX_HOME: "/runtime/profile-1/.codex", ["HOME", "/runtime/profile-1"],
}); ["CODEX_HOME", "/runtime/profile-1/.codex"],
]),
);
}); });
test("exposes an OpenRouter key only to the OpenCode profile", () => { test("exposes an OpenRouter key only to the OpenCode profile", () => {
@@ -220,9 +232,11 @@ test("exposes an OpenRouter key only to the OpenCode profile", () => {
tokenFile: "/runtime/profile-2/token", tokenFile: "/runtime/profile-2/token",
}; };
expect(agentEnvironment({ PATH: "/bin" }, profile, "selected")).toStrictEqual({ expect(agentEnvironment(new Map([["PATH", "/bin"]]), profile, "selected")).toStrictEqual(
PATH: "/bin", new Map([
HOME: "/runtime/profile-2", ["PATH", "/bin"],
OPENROUTER_API_KEY: "selected", ["HOME", "/runtime/profile-2"],
}); ["OPENROUTER_API_KEY", "selected"],
]),
);
}); });
+44 -20
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 {}
@@ -28,12 +38,16 @@ function isUsageExhausted(error: unknown): boolean {
} }
function failureReason(error: unknown): FailureReason { function failureReason(error: unknown): FailureReason {
if (error instanceof AgentAuthError) return "sign-in failed"; if (error instanceof AgentAuthError) {
return "sign-in failed";
}
return isUsageExhausted(error) ? "usage exhausted" : "failed"; return isUsageExhausted(error) ? "usage exhausted" : "failed";
} }
export function failureFooter(failures: ProfileFailure[]): string { export function failureFooter(failures: ProfileFailure[]): string {
if (failures.length === 0) return "> Profile: none"; if (failures.length === 0) {
return "> Profile: none";
}
return `> Tried: ${failures return `> Tried: ${failures
.map(({ type, model, reason }) => `\`${type}\` · \`${model}\` (${reason})`) .map(({ type, model, reason }) => `\`${type}\` · \`${model}\` (${reason})`)
.join(", ")}`; .join(", ")}`;
@@ -41,7 +55,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[],
@@ -56,13 +70,15 @@ async function attemptFrom<T, P extends Pick<BotProfile, "type" | "model">>(
} catch (error) { } catch (error) {
const reason = failureReason(error); const reason = failureReason(error);
failures.push({ type: profile.type, model: profile.model, reason }); failures.push({ type: profile.type, model: profile.model, reason });
if (reason === "failed" || index === profiles.length - 1) throw error; if (reason === "failed" || index === profiles.length - 1) {
throw error;
}
console.error(`${profile.type} ${reason}; trying next profile`); console.error(`${profile.type} ${reason}; trying next profile`);
return attemptFrom(profiles, attempt, failures, index + 1); return attemptFrom(profiles, attempt, failures, index + 1);
} }
} }
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 +86,12 @@ 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> { // Environments are maps from variable name to value, converted to the object
const result: Record<string, string> = {}; // form only where a child process is spawned.
export function sanitizedEnvironment(
environment: ReadonlyMap<string, string>,
): Map<string, string> {
const result = new Map<string, string>();
for (const name of [ for (const name of [
"PATH", "PATH",
"HOME", "HOME",
@@ -98,27 +118,31 @@ export function sanitizedEnvironment(environment: Record<string, string>): Recor
"SSL_CERT_DIR", "SSL_CERT_DIR",
"NODE_EXTRA_CA_CERTS", "NODE_EXTRA_CA_CERTS",
]) { ]) {
const value = environment[name]; const value = environment.get(name);
if (value !== undefined) result[name] = value; if (value !== undefined) {
result.set(name, value);
}
} }
return result; return result;
} }
export function agentEnvironment( export function agentEnvironment(
environment: Record<string, string>, environment: ReadonlyMap<string, string>,
profile: Pick<BotProfile, "type"> & { home: string }, profile: ProfileHome,
token?: string, token?: string,
): Record<string, string> { ): Map<string, string> {
const result = sanitizedEnvironment(environment); const result = sanitizedEnvironment(environment);
result["HOME"] = profile.home; result.set("HOME", profile.home);
if (profile.type === "codex") result["CODEX_HOME"] = `${profile.home}/.codex`; if (profile.type === "codex") {
result.set("CODEX_HOME", `${profile.home}/.codex`);
}
if (token !== undefined && token !== "") { if (token !== undefined && token !== "") {
const tokenName = { const tokenName = {
claude: "CLAUDE_CODE_OAUTH_TOKEN", claude: "CLAUDE_CODE_OAUTH_TOKEN",
codex: "OPENAI_API_KEY", codex: "OPENAI_API_KEY",
opencode: "OPENROUTER_API_KEY", opencode: "OPENROUTER_API_KEY",
}[profile.type]; }[profile.type];
result[tokenName] = token; result.set(tokenName, token);
} }
return result; return result;
} }
+104 -87
View File
@@ -10,58 +10,65 @@ 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 { field, 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];
if (value === undefined) throw new Error(`${name} is not set`); if (value === undefined) {
throw new Error(`${name} is not set`);
}
return value; return value;
}; };
function definedEnvironment(): Record<string, string> { function definedEnvironment(): Map<string, string> {
const result: Record<string, string> = {}; const result = new Map<string, string>();
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.set(name, value);
} }
} }
return result; return result;
@@ -99,21 +106,28 @@ async function postAuthorComment(body: string): Promise<void> {
} }
async function runRoot(command: string, args: string[]): Promise<void> { async function runRoot(command: string, args: string[]): Promise<void> {
const code = await exitCode(spawn(command, args, { env: PROCESS_ENV, stdio: "ignore" })); const code = await exitCode(
if (code !== 0) throw new Error(`${command} ${args[0]} exited with ${code}`); spawn(command, args, { env: Object.fromEntries(PROCESS_ENV), stdio: "ignore" }),
);
if (code !== 0) {
throw new Error(`${command} ${args[0]} exited with ${code}`);
}
} }
async function configureGitAuthor(): Promise<void> { async function configureGitAuthor(): Promise<void> {
const user: unknown = await (await gitea("user")).json(); const userResponse = await gitea("user");
if (!isObject(user) || typeof user["login"] !== "string" || typeof user["email"] !== "string") { const user: unknown = await userResponse.json();
throw new Error("user: unexpected Gitea response"); const login = field(user, "login");
const email = field(user, "email");
if (typeof login !== "string" || typeof email !== "string") {
throw new TypeError("user: unexpected Gitea response");
} }
await runRoot("git", ["config", "user.name", user["login"]]); await runRoot("git", ["config", "user.name", login]);
await runRoot("git", ["config", "user.email", user["email"]]); await runRoot("git", ["config", "user.email", 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;
} }
@@ -125,13 +139,15 @@ async function runAgentCommand(
): Promise<void> { ): Promise<void> {
const child = spawn(command, args, { const child = spawn(command, args, {
cwd: WORKSPACE, cwd: WORKSPACE,
env: agentEnvironment(PROCESS_ENV, profile, token), env: Object.fromEntries(agentEnvironment(PROCESS_ENV, profile, token)),
uid: profile.uid, uid: profile.uid,
gid: profile.gid, gid: profile.gid,
stdio: "ignore", stdio: "ignore",
}); });
const code = await exitCode(child); const code = await exitCode(child);
if (code !== 0) throw new Error(`${command} ${args[0]} exited with ${code}`); if (code !== 0) {
throw new Error(`${command} ${args[0]} exited with ${code}`);
}
} }
async function prepareProfile(profile: RuntimeProfile): Promise<void> { async function prepareProfile(profile: RuntimeProfile): Promise<void> {
@@ -154,38 +170,36 @@ async function prepareProfile(profile: RuntimeProfile): Promise<void> {
// One issue comment as the prompt quotes it. // One issue comment as the prompt quotes it.
function formatComment(comment: unknown): string { function formatComment(comment: unknown): string {
const { user, created_at: createdAt, body } = isObject(comment) ? comment : {}; const login = field(field(comment, "user"), "login");
const login = isObject(user) ? user["login"] : undefined; const createdAt = field(comment, "created_at");
const body = field(comment, "body");
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 commentsResponse = await gitea(`repos/${REPO}/issues/${INDEX}/comments?limit=100`);
await gitea(`repos/${REPO}/issues/${INDEX}/comments?limit=100`) const comments: unknown = await commentsResponse.json();
).json();
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 = new Map<string, string>([
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 gitea(RULES_PATH).then((response) => response.text())],
EVENT_NAME: EVENT, ["EVENT_NAME", EVENT],
GITEA_API_URL: API, ["GITEA_API_URL", API],
GITEA_REPOSITORY: REPO, ["GITEA_REPOSITORY", REPO],
ISSUE_INDEX: INDEX, ["ISSUE_INDEX", INDEX],
REVIEW_PATH: reviewPath, ["REVIEW_PATH", reviewPath],
ANCHORS_PATH: anchorsPath, ["ANCHORS_PATH", anchorsPath],
}; ]);
const template = await readFile(new URL("../prompt.md", import.meta.url), "utf8"); const template = await readFile(new URL("../../prompt.md", import.meta.url), "utf8");
return { return {
prompt: template.replaceAll( prompt: template.replaceAll(
/\$\{(\w+)\}/gu, /\$\{(?<name>\w+)\}/gu,
(match: string, name: string) => values[name] ?? match, (match: string, name: string) => values.get(name) ?? match,
), ),
reviewPath, reviewPath,
anchorsPath, anchorsPath,
@@ -207,17 +221,14 @@ async function runClaude(profile: RuntimeProfile, token: string, prompt: string)
], ],
{ {
cwd: WORKSPACE, cwd: WORKSPACE,
env: { env: Object.fromEntries(agentEnvironment(PROCESS_ENV, profile, token).set("IS_SANDBOX", "1")),
...agentEnvironment(PROCESS_ENV, profile, token),
IS_SANDBOX: "1",
},
uid: profile.uid, uid: profile.uid,
gid: profile.gid, gid: profile.gid,
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],
}, },
); );
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");
@@ -231,30 +242,32 @@ async function runClaude(profile: RuntimeProfile, token: string, prompt: string)
if (line !== "") { if (line !== "") {
const event: unknown = JSON.parse(line); const event: unknown = JSON.parse(line);
if (isObject(event)) { if (isObject(event)) {
const content = isObject(event["message"]) ? event["message"]["content"] : undefined; const content = field(field(event, "message"), "content");
for (const part of Array.isArray(content) ? content : []) { for (const part of Array.isArray(content) ? content : []) {
if (isObject(part)) { if (isObject(part)) {
const text = part["thinking"] ?? part["text"] ?? part["name"]; const text = field(part, "thinking") ?? field(part, "text") ?? field(part, "name");
if (typeof text === "string" && text !== "") { if (typeof text === "string" && text !== "") {
console.log(text); console.log(text);
} }
} }
} }
if (event["type"] === "result") { if (field(event, "type") === "result") {
const { is_error: isError, result: answer, subtype, api_error_status: apiStatus } = event; const isError = field(event, "is_error");
const answer = field(event, "result");
const subtype = field(event, "subtype");
const apiStatus = field(event, "api_error_status");
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,11 +300,11 @@ async function pollForInstructions(
async function codexDeviceLogin( async function codexDeviceLogin(
profile: RuntimeProfile, profile: RuntimeProfile,
environment: Record<string, string>, environment: ReadonlyMap<string, string>,
): Promise<void> { ): Promise<void> {
const login = spawn("codex", ["login", "--device-auth"], { const login = spawn("codex", ["login", "--device-auth"], {
cwd: WORKSPACE, cwd: WORKSPACE,
env: environment, env: Object.fromEntries(environment),
uid: profile.uid, uid: profile.uid,
gid: profile.gid, gid: profile.gid,
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],
@@ -316,12 +329,12 @@ async function codexDeviceLogin(
async function codexApiLogin( async function codexApiLogin(
profile: RuntimeProfile, profile: RuntimeProfile,
environment: Record<string, string>, environment: ReadonlyMap<string, string>,
token: string, token: string,
): Promise<void> { ): Promise<void> {
const login = spawn("codex", ["login", "--with-api-key"], { const login = spawn("codex", ["login", "--with-api-key"], {
cwd: WORKSPACE, cwd: WORKSPACE,
env: environment, env: Object.fromEntries(environment),
uid: profile.uid, uid: profile.uid,
gid: profile.gid, gid: profile.gid,
stdio: ["pipe", "inherit", "pipe"], stdio: ["pipe", "inherit", "pipe"],
@@ -355,15 +368,17 @@ async function runCodex(
} else { } else {
const check = spawn("codex", ["login", "status"], { const check = spawn("codex", ["login", "status"], {
cwd: WORKSPACE, cwd: WORKSPACE,
env: environment, env: Object.fromEntries(environment),
uid: profile.uid, uid: profile.uid,
gid: profile.gid, gid: profile.gid,
stdio: "ignore", stdio: "ignore",
}); });
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",
[ [
@@ -377,7 +392,7 @@ async function runCodex(
], ],
{ {
cwd: WORKSPACE, cwd: WORKSPACE,
env: environment, env: Object.fromEntries(environment),
uid: profile.uid, uid: profile.uid,
gid: profile.gid, gid: profile.gid,
stdio: ["ignore", "inherit", "pipe"], stdio: ["ignore", "inherit", "pipe"],
@@ -418,7 +433,7 @@ async function runOpenCode(
): Promise<string> { ): Promise<string> {
const child = spawn("opencode", openCodeArgs(profile.model, prompt), { const child = spawn("opencode", openCodeArgs(profile.model, prompt), {
cwd: WORKSPACE, cwd: WORKSPACE,
env: agentEnvironment(PROCESS_ENV, profile, token), env: Object.fromEntries(agentEnvironment(PROCESS_ENV, profile, token)),
uid: profile.uid, uid: profile.uid,
gid: profile.gid, gid: profile.gid,
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],
@@ -440,12 +455,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 +475,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);
@@ -471,8 +486,10 @@ async function runProfile(profile: RuntimeProfile): Promise<AttemptResult> {
await persistCodexAuth(stagedCodexAuth, CODEX_AUTH); await persistCodexAuth(stagedCodexAuth, CODEX_AUTH);
} }
} }
if (resultPath !== null) body = await readProfileFile(profile, resultPath); if (resultPath !== null) {
if (body === undefined || reviewPath === undefined || anchorsPath === undefined) { body = await readProfileFile(profile, resultPath);
}
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,9 +1,10 @@
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";
const OTHER_KEY = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE"; const OTHER_KEY = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE";
const NONCE = Uint8Array.from({ length: 12 }, (_, index) => index + 32); const NONCE = Uint8Array.from({ length: 12 }, (_value, index) => index + 32);
const ENCRYPTED = "v1:ICEiIyQlJicoKSor:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA"; const ENCRYPTED = "v1:ICEiIyQlJicoKSor:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA";
test("decrypts a known AES-256-GCM credential", async () => { test("decrypts a known AES-256-GCM credential", async () => {
@@ -1,9 +1,15 @@
import { webcrypto } from "node:crypto"; import { webcrypto } from "node:crypto";
import { isMapping } from "./json.ts"; import { field, 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",
@@ -41,7 +47,9 @@ export async function decryptCredential(encrypted: string, masterKey: string): P
throw new Error("credential must use v1:<nonce>:<ciphertext>"); throw new Error("credential must use v1:<nonce>:<ciphertext>");
} }
const nonce = decodeBase64Url(encodedNonce); const nonce = decodeBase64Url(encodedNonce);
if (nonce.length !== 12) throw new Error("credential nonce must be 12 bytes"); if (nonce.length !== 12) {
throw new Error("credential nonce must be 12 bytes");
}
const plaintext = await webcrypto.subtle.decrypt( const plaintext = await webcrypto.subtle.decrypt(
{ name: "AES-GCM", iv: buffer(nonce) }, { name: "AES-GCM", iv: buffer(nonce) },
await importMasterKey(masterKey, "decrypt"), await importMasterKey(masterKey, "decrypt"),
@@ -55,7 +63,9 @@ export async function encryptCredential(
masterKey: string, masterKey: string,
nonce: Uint8Array = webcrypto.getRandomValues(new Uint8Array(12)), nonce: Uint8Array = webcrypto.getRandomValues(new Uint8Array(12)),
): Promise<string> { ): Promise<string> {
if (nonce.length !== 12) throw new Error("AES-GCM nonce must be 12 bytes"); if (nonce.length !== 12) {
throw new Error("AES-GCM nonce must be 12 bytes");
}
const ciphertext = await webcrypto.subtle.encrypt( const ciphertext = await webcrypto.subtle.encrypt(
{ name: "AES-GCM", iv: buffer(nonce) }, { name: "AES-GCM", iv: buffer(nonce) },
await importMasterKey(masterKey, "encrypt"), await importMasterKey(masterKey, "encrypt"),
@@ -74,7 +84,9 @@ async function parseProfile(entry: unknown, index: number, masterKey: string): P
throw new Error(`${at}.${key}: unknown field`); throw new Error(`${at}.${key}: unknown field`);
} }
} }
const { type, model, token } = entry; const type = field(entry, "type");
const model = field(entry, "model");
const token = field(entry, "token");
if (type !== "claude" && type !== "codex" && type !== "opencode") { if (type !== "claude" && type !== "codex" && type !== "opencode") {
throw new Error(`${at}.type: expected "claude", "codex", or "opencode"`); throw new Error(`${at}.type: expected "claude", "codex", or "opencode"`);
} }
@@ -106,7 +118,8 @@ export async function parseCredentials(text: string, masterKey: string): Promise
throw new Error(`${key}: unknown field`); throw new Error(`${key}: unknown field`);
} }
} }
const { "reviewer-token": reviewerToken, profiles } = value; const reviewerToken = field(value, "reviewer-token");
const profiles = field(value, "profiles");
if (typeof reviewerToken !== "string") { if (typeof reviewerToken !== "string") {
throw new TypeError("reviewer-token: expected an encrypted credential"); throw new TypeError("reviewer-token: expected an encrypted credential");
} }
+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";
@@ -50,7 +49,8 @@ test.skipIf(!IS_ROOT)("prepares root-only reviewer and isolated profile credenti
assert(claude.tokenFile !== null); assert(claude.tokenFile !== null);
expect(await readFile(claude.tokenFile, "utf8")).toBe("secret-token"); expect(await readFile(claude.tokenFile, "utf8")).toBe("secret-token");
await expectPrivate(claude.tokenFile, claude.uid, 0o600); await expectPrivate(claude.tokenFile, claude.uid, 0o600);
expect((await stat(codex.home)).uid).toStrictEqual(codex.uid); const codexHome = await stat(codex.home);
expect(codexHome.uid).toStrictEqual(codex.uid);
expect(codex.tokenFile).toBe(null); expect(codex.tokenFile).toBe(null);
const ownToken = spawnSync("/bin/cat", [claude.tokenFile], { const ownToken = spawnSync("/bin/cat", [claude.tokenFile], {
@@ -82,14 +82,14 @@ 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/index.ts", import.meta.url))],
{ {
env: { env: Object.fromEntries([
ACTION_PATH: action, ["ACTION_PATH", action],
RUNNER_TEMP: root, ["RUNNER_TEMP", root],
BOT_AGENTS_MASTER_KEY: MASTER_KEY, ["BOT_AGENTS_MASTER_KEY", MASTER_KEY],
GITHUB_OUTPUT: action, ["GITHUB_OUTPUT", action],
}, ]),
}, },
); );
+24 -18
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 { field, 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 });
@@ -101,11 +107,11 @@ export async function prepareRuntime(
function isRuntimeManifest(value: unknown): value is RuntimeManifest { function isRuntimeManifest(value: unknown): value is RuntimeManifest {
return ( return (
isMapping(value) && isMapping(value) &&
typeof value["runtimeDir"] === "string" && typeof field(value, "runtimeDir") === "string" &&
typeof value["manifestPath"] === "string" && typeof field(value, "manifestPath") === "string" &&
typeof value["reviewerFile"] === "string" && typeof field(value, "reviewerFile") === "string" &&
typeof value["outcomeFile"] === "string" && typeof field(value, "outcomeFile") === "string" &&
Array.isArray(value["profiles"]) Array.isArray(field(value, "profiles"))
); );
} }
+18 -4
View File
@@ -1,5 +1,5 @@
// Test support: the current user's IDs, a fresh temporary directory, and a // Test support: the current user's IDs, a fresh temporary directory, the
// plain command runner. It imports no source module, so the repository-level // inherited environment, 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 // tests in tests/, which reach src/ only through `#testing`, never touch
// internals. // internals.
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
@@ -33,14 +33,28 @@ export interface CommandResult {
stderr: string; stderr: string;
} }
// This process's environment as a map from variable name to value.
export function inheritedEnvironment(): Map<string, string> {
const result = new Map<string, string>();
for (const [name, value] of Object.entries(process.env)) {
if (value !== undefined) {
result.set(name, value);
}
}
return result;
}
// Runs a command with stdin closed and only the given environment, and // Runs a command with stdin closed and only the given environment, and
// collects its stderr. // collects its stderr.
export async function runCommand( export async function runCommand(
command: string, command: string,
args: string[], args: string[],
env: Record<string, string>, env: ReadonlyMap<string, string>,
): Promise<CommandResult> { ): Promise<CommandResult> {
const child = spawn(command, args, { env, stdio: ["ignore", "ignore", "pipe"] }); const child = spawn(command, args, {
env: Object.fromEntries(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")]);
const exitCode: unknown = closed[0]; const exitCode: unknown = closed[0];
return { success: exitCode === 0, stderr }; return { success: exitCode === 0, stderr };
+7 -4
View File
@@ -1,11 +1,10 @@
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";
// Each phase runs as `node src/<phase>.ts` with no build step. Node loads the // Each phase runs as `node src/<phase>/index.ts` with no build step. Node loads the
// whole import graph, stripping types, before the phase reads its first // whole import graph, stripping types, before the phase reads its first
// variable, so failing on that variable proves every module loads. // variable, so failing on that variable proves every module loads.
const PHASES: [phase: string, firstVariable: string][] = [ const PHASES: [phase: string, firstVariable: string][] = [
@@ -15,8 +14,12 @@ const PHASES: [phase: string, firstVariable: string][] = [
]; ];
test.each(PHASES)("Node loads the %s phase", async (phase, firstVariable) => { test.each(PHASES)("Node loads the %s phase", async (phase, firstVariable) => {
const path = fileURLToPath(new URL(`../src/${phase}.ts`, import.meta.url)); const path = fileURLToPath(new URL(`../src/${phase}/index.ts`, import.meta.url));
const result = await runCommand(process.execPath, [path], { PATH: process.env["PATH"] ?? "" }); const result = await runCommand(
process.execPath,
[path],
new Map([["PATH", process.env["PATH"] ?? ""]]),
);
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(result.stderr).toContain(`${firstVariable} is not set`); expect(result.stderr).toContain(`${firstVariable} is not set`);
}); });
+59 -52
View File
@@ -1,27 +1,24 @@
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 { inheritedEnvironment, makeTempDir, runCommand } from "#testing";
type JsonObject = Record<string, unknown>; // A parsed YAML or JSON mapping, keyed by its dynamic field names.
type Mapping = Map<string, unknown>;
const isMapping = (value: unknown): value is JsonObject => const mapping = (value: unknown, at: string): Mapping => {
typeof value === "object" && value !== null && !Array.isArray(value); assert(typeof value === "object" && value !== null && !Array.isArray(value), at);
return new Map(Object.entries(value));
const mapping = (value: unknown, at: string): JsonObject => {
assert(isMapping(value), at);
return value;
}; };
const sequence = (value: unknown, at: string): JsonObject[] => { const sequence = (value: unknown, at: string): Mapping[] => {
assert(Array.isArray(value), at); assert(Array.isArray(value), at);
return value.map((entry: unknown, index) => mapping(entry, `${at}[${index}]`)); return value.map((entry: unknown, index) => mapping(entry, `${at}[${index}]`));
}; };
async function yaml(path: string): Promise<JsonObject> { async function yaml(path: string): Promise<Mapping> {
const value: unknown = parse(await readFile(path, "utf8")); const value: unknown = parse(await readFile(path, "utf8"));
return mapping(value, path); return mapping(value, path);
} }
@@ -34,8 +31,8 @@ async function yamlFiles(): Promise<string[]> {
.filter((path) => !/^(?:\.git|\.superpowers|node_modules)\//u.test(path)); .filter((path) => !/^(?:\.git|\.superpowers|node_modules)\//u.test(path));
} }
const findStep = (steps: JsonObject[], key: string, value: string): JsonObject => { const findStep = (steps: Mapping[], key: string, value: string): Mapping => {
const step = steps.find((entry) => entry[key] === value); const step = steps.find((entry) => entry.get(key) === value);
assert(step !== undefined, `${key}: ${value}`); assert(step !== undefined, `${key}: ${value}`);
return step; return step;
}; };
@@ -48,11 +45,12 @@ test("all committed YAML parses", async () => {
test("the action exposes only the master key and scopes it to preparation", async () => { test("the action exposes only the master key and scopes it to preparation", async () => {
const action = await yaml("action.yml"); const action = await yaml("action.yml");
expect(Object.keys(mapping(action["inputs"], "inputs"))).toStrictEqual(["master-key"]); expect([...mapping(action.get("inputs"), "inputs").keys()]).toStrictEqual(["master-key"]);
const steps = sequence(mapping(action["runs"], "runs")["steps"], "runs.steps"); const steps = sequence(mapping(action.get("runs"), "runs").get("steps"), "runs.steps");
for (const step of steps) { for (const step of steps) {
if (typeof step["run"] === "string") { const run = step.get("run");
expect(step["run"].includes("\n"), String(step["name"])).toBe(false); if (typeof run === "string") {
expect(run.includes("\n"), String(step.get("name"))).toBe(false);
} }
} }
const checkout = findStep(steps, "uses", "actions/checkout@v4"); const checkout = findStep(steps, "uses", "actions/checkout@v4");
@@ -60,16 +58,16 @@ test("the action exposes only the master key and scopes it to preparation", asyn
const agent = findStep(steps, "id", "agent"); const agent = findStep(steps, "id", "agent");
const post = findStep(steps, "id", "post"); const post = findStep(steps, "id", "post");
findStep(steps, "id", "source"); findStep(steps, "id", "source");
expect(mapping(checkout["with"], "checkout.with")["token"]).toBe("${{ gitea.token }}"); expect(mapping(checkout.get("with"), "checkout.with").get("token")).toBe("${{ gitea.token }}");
expect(mapping(prepare["env"], "prepare.env")["BOT_AGENTS_MASTER_KEY"]).toBe( expect(mapping(prepare.get("env"), "prepare.env").get("BOT_AGENTS_MASTER_KEY")).toBe(
"${{ inputs.master-key }}", "${{ inputs.master-key }}",
); );
expect(JSON.stringify(agent).includes("BOT_AGENTS_MASTER_KEY")).toBe(false); expect(JSON.stringify(Object.fromEntries(agent)).includes("BOT_AGENTS_MASTER_KEY")).toBe(false);
expect(JSON.stringify(post).includes("BOT_AGENTS_MASTER_KEY")).toBe(false); expect(JSON.stringify(Object.fromEntries(post)).includes("BOT_AGENTS_MASTER_KEY")).toBe(false);
expect(mapping(agent["env"], "agent.env")["GITEA_TOKEN"]).toBe("${{ gitea.token }}"); expect(mapping(agent.get("env"), "agent.env").get("GITEA_TOKEN")).toBe("${{ gitea.token }}");
assert(String(post["if"]).includes("always()")); assert(String(post.get("if")).includes("always()"));
for (const step of [prepare, agent, post]) { for (const step of [prepare, agent, post]) {
assert(String(step["run"]).includes("steps.source.outputs.path")); assert(String(step.get("run")).includes("steps.source.outputs.path"));
} }
}); });
@@ -77,19 +75,21 @@ test("the source protection script stages immutable action files", async () => {
const temp = await makeTempDir(); const temp = await makeTempDir();
const outputFile = `${temp}/output`; const outputFile = `${temp}/output`;
try { try {
const result = await runCommand("/bin/bash", ["scripts/protect-source.sh"], { const environment = inheritedEnvironment();
...process.env, environment.set("ACTION_PATH", process.cwd());
ACTION_PATH: process.cwd(), environment.set("GITHUB_OUTPUT", outputFile);
GITHUB_OUTPUT: outputFile, environment.set("RUNNER_TEMP", temp);
RUNNER_TEMP: temp, const result = await runCommand("/bin/bash", ["scripts/protect-source.sh"], environment);
});
assert(result.success, result.stderr); assert(result.success, result.stderr);
const path = (await readFile(outputFile, "utf8")).trim().slice(5); const output = await readFile(outputFile, "utf8");
const path = output.trim().slice(5);
const files = [ const files = [
"src/prepare.ts", "src/prepare/index.ts",
"src/run.ts", "src/run/index.ts",
"src/post.ts", "src/post/index.ts",
"src/runtime/index.ts",
"src/agents/auth.ts",
"package.json", "package.json",
"profiles.json", "profiles.json",
"prompt.md", "prompt.md",
@@ -105,9 +105,9 @@ test("the source protection script stages immutable action files", async () => {
}); });
test("module-relative runtime assets exist", async () => { test("module-relative runtime assets exist", async () => {
const module = new URL("../src/run.ts", import.meta.url); const module = new URL("../src/run/index.ts", import.meta.url);
const source = await readFile(module, "utf8"); const source = await readFile(module, "utf8");
const assets = [...source.matchAll(/new URL\("([^"]+)", import\.meta\.url\)/gu)].map( const assets = [...source.matchAll(/new URL\("(?<asset>[^"]+)", import\.meta\.url\)/gu)].map(
([, asset = ""]) => new URL(asset, module), ([, asset = ""]) => new URL(asset, module),
); );
const stats = await Promise.all(assets.map((asset) => stat(asset))); const stats = await Promise.all(assets.map((asset) => stat(asset)));
@@ -118,27 +118,30 @@ test("module-relative runtime assets exist", async () => {
test("the repository uses its trusted main action", async () => { test("the repository uses its trusted main action", async () => {
const workflow = await yaml(".gitea/workflows/agents.yml"); const workflow = await yaml(".gitea/workflows/agents.yml");
const job = mapping(mapping(workflow["jobs"], "jobs")["agent"], "jobs.agent"); const job = mapping(mapping(workflow.get("jobs"), "jobs").get("agent"), "jobs.agent");
const permissions = mapping(job["permissions"], "jobs.agent.permissions"); const permissions = mapping(job.get("permissions"), "jobs.agent.permissions");
expect(permissions).toStrictEqual({ expect(Object.fromEntries(permissions)).toStrictEqual({
code: "write", code: "write",
issues: "write", issues: "write",
"pull-requests": "write", "pull-requests": "write",
}); });
const steps = sequence(job["steps"], "jobs.agent.steps"); const steps = sequence(job.get("steps"), "jobs.agent.steps");
expect(steps.findIndex((step) => step["uses"] === "./")).toStrictEqual(-1); expect(steps.findIndex((step) => step.get("uses") === "./")).toStrictEqual(-1);
const trusted = findStep(steps, "uses", "https://git.capsulizers.com/commons/bot-agents@main"); const trusted = findStep(steps, "uses", "https://git.capsulizers.com/commons/bot-agents@main");
expect(mapping(trusted["with"], "trusted.with")["master-key"]).toBe( expect(mapping(trusted.get("with"), "trusted.with").get("master-key")).toBe(
"${{ secrets.BOT_AGENTS_MASTER_KEY }}", "${{ secrets.BOT_AGENTS_MASTER_KEY }}",
); );
expect(JSON.stringify(workflow).includes("gitea.actor != 'bot'")).toBe(true); expect(JSON.stringify(Object.fromEntries(workflow)).includes("gitea.actor != 'bot'")).toBe(true);
}); });
test("CI checks every phase and the root isolation suite", async () => { test("CI checks every phase and the root isolation suite", async () => {
const workflow = await yaml(".gitea/workflows/check.yml"); const workflow = await yaml(".gitea/workflows/check.yml");
const job = mapping(mapping(workflow["jobs"], "jobs")["check"], "jobs.check"); const job = mapping(mapping(workflow.get("jobs"), "jobs").get("check"), "jobs.check");
const commands = sequence(job["steps"], "jobs.check.steps") const commands = sequence(job.get("steps"), "jobs.check.steps")
.map((step) => (typeof step["run"] === "string" ? step["run"] : "")) .map((step) => {
const run = step.get("run");
return typeof run === "string" ? run : "";
})
.join("\n"); .join("\n");
// `vp check` type-checks every phase, and `vp test` runs each of them under // `vp check` type-checks every phase, and `vp test` runs each of them under
// Node as well as the root-only isolation tests. // Node as well as the root-only isolation tests.
@@ -148,7 +151,7 @@ test("CI checks every phase and the root isolation suite", async () => {
}); });
test("uses the standard Skills installer without custom discovery", async () => { test("uses the standard Skills installer without custom discovery", async () => {
const runner = await readFile("src/run.ts", "utf8"); const runner = await readFile("src/run/index.ts", "utf8");
assert(runner.includes("skills add obra/superpowers")); assert(runner.includes("skills add obra/superpowers"));
const removed = await stat("superpowers.ts").then( const removed = await stat("superpowers.ts").then(
() => false, () => false,
@@ -168,15 +171,19 @@ test("does not install Deno", async () => {
}); });
test("profile preparation does not receive provider credentials", async () => { test("profile preparation does not receive provider credentials", async () => {
const runner = await readFile("src/run.ts", "utf8"); const runner = await readFile("src/run/index.ts", "utf8");
assert(runner.includes("await prepareProfile(profile);")); assert(runner.includes("await prepareProfile(profile);"));
expect(runner.includes("prepareProfile(profile, token)")).toBe(false); expect(runner.includes("prepareProfile(profile, token)")).toBe(false);
}); });
test("central profiles fall back from Codex to Claude to OpenCode", async () => { test("central profiles fall back from Codex to Claude to OpenCode", async () => {
const credentials: unknown = JSON.parse(await readFile("profiles.json", "utf8")); const credentials: unknown = JSON.parse(await readFile("profiles.json", "utf8"));
const profiles = sequence(mapping(credentials, "profiles.json")["profiles"], "profiles"); const profiles = sequence(mapping(credentials, "profiles.json").get("profiles"), "profiles");
expect(profiles.map((profile) => profile["type"])).toStrictEqual(["codex", "claude", "opencode"]); expect(profiles.map((profile) => profile.get("type"))).toStrictEqual([
expect(profiles[2]?.["model"]).toBe("openrouter/qwen/qwen3.8-27b"); "codex",
assert(String(profiles[2]?.["token"]).startsWith("v1:")); "claude",
"opencode",
]);
expect(profiles[2]?.get("model")).toBe("openrouter/qwen/qwen3.8-27b");
assert(String(profiles[2]?.get("token")).startsWith("v1:"));
}); });
+22 -4
View File
@@ -1,13 +1,31 @@
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, { projectOverrides } 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: [
categories: { correctness: "error", suspicious: "error", pedantic: "error", perf: "error" }, "eslint-plugin-explicit-types",
rules: projectLint.configs.project.rules, "eslint-plugin-import-structure",
"./scripts/project-lint.ts",
],
categories: {
correctness: "error",
suspicious: "error",
pedantic: "error",
perf: "error",
style: "error",
restriction: "error",
},
rules: {
...explicitTypes.configs.all.rules,
...importStructure.configs.all.rules,
...projectLint.configs.project.rules,
},
overrides: projectOverrides,
options: { options: {
typeAware: true, typeAware: true,
typeCheck: true, typeCheck: true,