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
@@ -15,7 +15,7 @@ everything explicit:
- No `execFile`/`exec`: their `maxBuffer` (1 MiB) kills a chatty child. Read
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`
(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:
- **`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`.
- `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:
`no-restricted-globals` in `scripts/project-lint.ts` enforces it
([[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
@@ -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
dependencies (`@eslint/core` and the other devDependencies serve only the
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
decides the version ([[bot-agents-runtime-no-build]]).
- **No `eslint-plugin-*` packages** (`eslint-plugin-explicit-types`,
`eslint-plugin-import-structure`, `eslint-plugin-lit-robust`; unscoped
since 2026-10-18, formerly `@cunarist/...`). The Deno setup enabled no
plugin, so this repo depends on none and CI passes without them being
published. Planned (2026-10-17): once the plugins are on npm, add
`eslint-plugin-import-structure` (rule prefix `import-structure/`) here for
its import checks.
- **Published lint plugins** (2026-10-18, after they reached npm):
`eslint-plugin-import-structure` (`modules` config) and
`eslint-plugin-explicit-types` (`concrete` and `absence` configs) are
devDependencies, enabled in `vite.config.ts`. explicit-types' `naming`
config is left off: its `upper-snake-string-unions` and
`no-upper-snake-object-keys` rules fire on wire tokens this repo does not
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
comment responses, the runtime manifest, and the outcome's profile type are
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.
- **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`
to confirm the cached image's minor.
- **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
---
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
`test.skipIf(!IS_ROOT)(...)` (`IS_ROOT` from `#testing`), so they show
as skipped instead of silently passing.
- As a normal user `vp test` passes 61 and skips 5. In CI (self-hosted
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
container with the repo mounted) before pushing; a green non-root run
proves nothing about those paths.
- The migration also ran an end-to-end smoke as root with fake
`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]]).
- `src/testing/index.ts` holds the shared test helpers (user IDs, temp dirs,
`runCommand`) behind the `#testing` alias. Every test imports it as
`#testing`. It imports no source module, so `tests/` (which may not use
`../`) 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/`).
+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
callers import the alias (`#ui`), not files inside it.
- 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.
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
direct subfolder of `src/`, e.g. `"#helpers": "./src/helpers/index.ts"`.
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
of its own and imports its own files by `./` relative paths. It reaches
`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.
`scripts/` is tooling, not production code: never packed or published, and
no tests. Narrow `overrides` for `scripts/**` are allowed when a script truly
needs one (e.g. `no-console`); see [[lint-overrides-policy]].
no tests. Its offs sit in the Node code entry of `projectOverrides`; see
[[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
(`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
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
runtime branches. Oxlint's `RuleTester` refuses to run under Bun anyway, and
`vp` brings its own Node, the way uv brings its own Python.
+1 -1
View File
@@ -10,7 +10,7 @@ jobs:
steps:
- 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: npm ci
+16 -7
View File
@@ -20,7 +20,7 @@ dependency. Keep the sources to erasable syntax and `.ts` imports, which
`exactOptionalPropertyTypes` off
([.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`
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/`.
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
`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
and subfolders freely. The one alias is `#testing` (`./src/testing/index.ts`):
tests that use its helpers import it by alias, and `tests/` imports `src/`
only through it. Local convention, stricter than the general rule above: in
and subfolders freely. Every top-level entry of `src/` is an aliased module
folder. The aliases, in layer order, are the three phases `#prepare`, `#run`,
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/`
(`"#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
([.agents/memory/imports-aliases-barrels.md](.agents/memory/imports-aliases-barrels.md)).
The only config files are `package.json`, `tsconfig.json`, and
`vite.config.ts`, plus `package-lock.json`. Repo-local lint rules and the rule
list live in `scripts/project-lint.ts`. Lint disable comments are not allowed;
fix the source instead.
`vite.config.ts`, plus `package-lock.json`. Repo-local lint rules, the rule
list, and the `overrides` array (`projectOverrides`) live in
`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
Claude Code, Codex, and OpenCode command-line tools; do not treat those
+3 -3
View File
@@ -35,7 +35,7 @@ runs:
- name: Prepare credentials
id: prepare
shell: bash
run: node "${{ steps.source.outputs.path }}/src/prepare.ts"
run: node "${{ steps.source.outputs.path }}/src/prepare/index.ts"
env:
ACTION_PATH: ${{ steps.source.outputs.path }}
BOT_AGENTS_MASTER_KEY: ${{ inputs.master-key }}
@@ -43,7 +43,7 @@ runs:
- name: Run bot
id: agent
shell: bash
run: node "${{ steps.source.outputs.path }}/src/run.ts"
run: node "${{ steps.source.outputs.path }}/src/run/index.ts"
env:
BOT_AGENTS_MANIFEST: ${{ steps.prepare.outputs.manifest }}
GITEA_TOKEN: ${{ gitea.token }}
@@ -56,7 +56,7 @@ runs:
id: post
if: always() && steps.prepare.outputs.manifest != ''
shell: bash
run: node "${{ steps.source.outputs.path }}/src/post.ts"
run: node "${{ steps.source.outputs.path }}/src/post/index.ts"
env:
BOT_AGENTS_MANIFEST: ${{ steps.prepare.outputs.manifest }}
GITEA_API_URL: ${{ gitea.api_url }}
+28
View File
@@ -9,6 +9,8 @@
"@eslint/core": "^0.17.0",
"@types/node": "^24.19.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",
"yaml": "^2.9.1"
}
@@ -1887,6 +1889,32 @@
"dev": true,
"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": {
"version": "3.0.3",
"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.",
"type": "module",
"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"
},
"devDependencies": {
"@eslint/core": "^0.17.0",
"@types/node": "^24.19.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",
"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
* the Oxlint categories in `vite.config.ts`. Its rules of its own are
* `no-disable-comments` and `no-asset-imports`.
* `project` — this repository's changes to the Oxlint categories in
* `vite.config.ts`. Its rules of its own are `no-disable-comments` and
* `no-asset-imports`.
*
* Loaded by `vite.config.ts` as an Oxlint `jsPlugins` entry.
*
@@ -10,11 +10,11 @@
import type { LanguageOptions, RuleDefinition, RuleVisitor, TextSourceCode } from "@eslint/core";
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. */
interface ProjectSourceCode extends TextSourceCode {
getAllComments(): TSESTree.Comment[];
getAllComments: () => TSESTree.Comment[];
}
/** 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
* sit outside the Oxlint categories, plus the template's extras. Built-in
* Oxlint rule ids sit here too, so `vite.config.ts` lists no rule by name.
* This repository's changes to the Oxlint categories in `vite.config.ts`,
* which turn every non-nursery category on: its own rules, the category
* rules it turns off, and options that differ from a rule's defaults.
*/
const rules: DummyRuleMap = {
// The explicit-types template's `camelcase` stand-in: no `_` inside a name
@@ -174,31 +174,6 @@ const rules: DummyRuleMap = {
"^_*[^_]*_*$|^[^a-z]*$",
{ 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.
"no-restricted-globals": [
"error",
@@ -209,47 +184,137 @@ const rules: DummyRuleMap = {
"setImmediate",
"clearImmediate",
],
// The explicit-types template's extras.
"import/no-cycle": "error",
// `../` is banned in every file; anything above a file needs a `#` alias.
"import/no-relative-parent-imports": "error",
"typescript/consistent-type-imports": "error",
"typescript/no-import-type-side-effects": "error",
"typescript/no-non-null-assertion": "error",
// No disable comments at all.
// Single capital type parameters, which explicit-types requires, may be one letter.
"id-length": ["error", { exceptionPatterns: ["^[A-Z]$"] }],
// Option tweaks.
"one-var": ["error", "never"],
"no-use-before-define": ["error", { functions: false }],
"func-style": ["error", "declaration", { allowArrowFunctions: true }],
"unicorn/import-style": ["error", { styles: { "node:path": { named: true } } }],
// 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-asset-imports": "error",
"unicorn/no-abusive-eslint-disable": "error",
// Size and complexity limits match Deno's: this repo's `deno lint` ran the
// `recommended` set with no config file, so no size or complexity rule
// applied and none applies here. Each Oxlint limit rule, from a category or
// not, is off:
// - `complexity`: Deno had no `max-complexity`.
// - `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.
// Off, collides with a repo or plugin rule, Oxfmt, or another category rule:
// Collides with `#private`: members are public unless marked so.
"typescript/explicit-member-accessibility": "off",
// Collides with `typescript/prefer-readonly-parameter-types` being off.
"typescript/prefer-readonly": "off",
// Collides with Node's and the web's mutable types (`Response`, `Stats`).
"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
* `project/<rule>`, and `configs.project`, the rule list above.
+3 -3
View File
@@ -2,9 +2,9 @@
set -euo pipefail
path=$(mktemp -d "${RUNNER_TEMP}/bot-agents-source-XXXXXX")
mkdir "${path}/src"
cp -a "${ACTION_PATH}"/src/*.ts "${path}/src/"
# package.json makes Node load the TypeScript sources as ES modules.
cp -a "${ACTION_PATH}/src" "${path}/"
# package.json makes Node load the TypeScript sources as ES modules and
# resolves their `#` aliases.
cp -a "${ACTION_PATH}/package.json" "${ACTION_PATH}/profiles.json" "${ACTION_PATH}/prompt.md" "${path}/"
chmod -R go-w "${path}"
printf 'path=%s\n' "${path}" >> "${GITHUB_OUTPUT}"
+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 { expect, test } from "vite-plus/test";
import { currentGid, currentUid, IS_WINDOWS, makeTempDir } from "#testing";
import {
codexAuthentication,
@@ -9,7 +11,6 @@ import {
retryInvalidToken,
stageCodexAuth,
} from "./auth.ts";
import { currentGid, currentUid, IS_WINDOWS, makeTempDir } from "#testing";
test("retries once after an invalid refresh token", async () => {
let attempts = 0;
@@ -18,10 +19,10 @@ test("retries once after an invalid refresh token", async () => {
() =>
Promise.resolve({
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();
},
);
@@ -38,14 +39,14 @@ test("does not retry an unrelated failure", async () => {
let relogins = 0;
await retryInvalidToken(
() => {
attempts++;
attempts += 1;
return Promise.resolve({
code: 1,
error: "rate limited",
});
},
() => {
relogins++;
relogins += 1;
return Promise.resolve();
},
);
@@ -86,11 +87,11 @@ test("re-logs in after the job 9837 revoked-session stderr", async () => {
const result = await retryInvalidToken(
() =>
Promise.resolve({
code: attempts++ > 0 ? 0 : 1,
code: (attempts += 1) > 1 ? 0 : 1,
error: attempts === 1 ? REVOKED_CODEX.join("\n") : "",
}),
() => {
relogins++;
relogins += 1;
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", () => {
expect(
isClaudeAuthFailure({
api_error_status: 401,
apiErrorStatus: 401,
result: "Failed to authenticate. API Error: 401 OAuth access token is invalid.",
}),
).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.",
}),
).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", () => {
@@ -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());
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({
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 persistCodexAuth(staged, canonical);
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 {
await rm(root, { recursive: true });
}
+33 -16
View File
@@ -12,10 +12,18 @@ import {
} from "node:fs/promises";
import { dirname, join } from "node:path";
import { isNotFound } from "./errors.ts";
import { isObject } from "./json.ts";
import { field, isNotFound, isObject } from "#common";
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 {
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
// access token is invalid." with api_error_status 401.
// The fields of Claude Code's stream-json `result` event that tell a sign-in
// failure apart, under their own names.
export type ClaudeFailureFields = Partial<Record<"api_error_status", number | null>> & {
// failure apart, as `apiErrorStatus` and `result`.
export interface ClaudeFailureFields {
apiErrorStatus?: number;
result?: string;
};
}
export function isClaudeAuthFailure(result: ClaudeFailureFields): boolean {
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(
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>,
relogin: () => Promise<void>,
): Promise<T> {
@@ -75,15 +89,16 @@ async function readCodexAuth(path: string): Promise<string> {
} catch {
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 (
!isObject(value) ||
typeof value["auth_mode"] !== "string" ||
value["auth_mode"] === "" ||
typeof authMode !== "string" ||
authMode === "" ||
!isObject(tokens) ||
!["access_token", "refresh_token", "id_token", "account_id"].every(
(name) => typeof tokens[name] === "string" && tokens[name] !== "",
)
!["access_token", "refresh_token", "id_token", "account_id"].every((name) => {
const token = field(tokens, name);
return typeof token === "string" && token !== "";
})
) {
throw new Error("invalid Codex login");
}
@@ -106,7 +121,9 @@ export async function stageCodexAuth(
await chmod(stagedPath, 0o600);
await chown(stagedPath, uid, gid);
} catch (error) {
if (!isNotFound(error)) throw error;
if (!isNotFound(error)) {
throw error;
}
}
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 { AgentAuthError } from "#profiles";
import {
collectOpenCodeOutput,
openCodeArgs,
openCodeError,
parseOpenCodeEvents,
} from "./opencode.ts";
import { AgentAuthError } from "./profiles.ts";
test("builds a non-interactive auto-approved OpenCode command", () => {
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(
"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 { AgentAuthError } from "./profiles.ts";
import { field, isObject } from "#common";
import { AgentAuthError } from "#profiles";
export function openCodeArgs(model: string, prompt: string): string[] {
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>;
interface OpenCodeOutput {
stdout: string;
stderr: string;
}
export async function collectOpenCodeOutput(
stdout: AsyncIterable<Uint8Array>,
stderr: AsyncIterable<Uint8Array>,
writeStdout: ChunkWriter,
writeStderr: ChunkWriter,
): Promise<{ stdout: string; stderr: string }> {
const collect = async (stream: AsyncIterable<Uint8Array>, write: ChunkWriter) => {
): Promise<OpenCodeOutput> {
const collect = async (
stream: AsyncIterable<Uint8Array>,
write: ChunkWriter,
): Promise<string> => {
const chunks: Uint8Array[] = [];
for await (const chunk of stream) {
chunks.push(chunk);
@@ -38,23 +46,26 @@ export async function collectOpenCodeOutput(
// 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
// empty stderr.
export function openCodeError(output: string): Error | undefined {
let failure: Error | undefined;
export function openCodeError(output: string): Error | null {
let failure: Error | null = null;
for (const line of output.split("\n")) {
if (line.trim() === "") continue;
if (line.trim() === "") {
continue;
}
let event: unknown;
try {
event = JSON.parse(line);
} catch {
continue;
}
if (!isObject(event)) continue;
const { type, error } = event;
if (type !== "error" || !isObject(error)) {
const error = field(event, "error");
if (field(event, "type") !== "error" || !isObject(error)) {
continue;
}
const { name, data } = error;
const { message, statusCode } = isObject(data) ? data : {};
const name = field(error, "name");
const data = field(error, "data");
const message = field(data, "message");
const statusCode = field(data, "statusCode");
const detail = `opencode ${typeof name === "string" ? name : "error"}${
typeof statusCode === "number" ? ` (API error ${statusCode})` : ""
}: ${typeof message === "string" ? message : "unknown error"}`;
@@ -69,17 +80,22 @@ export function openCodeError(output: string): Error | undefined {
}
export function parseOpenCodeEvents(output: string): string {
let result: string | undefined;
let result: string | null = null;
for (const line of output.split("\n")) {
if (line.trim() === "") continue;
if (line.trim() === "") {
continue;
}
const event: unknown = JSON.parse(line);
if (!isObject(event)) continue;
const { type, part } = event;
if (type !== "text" || !isObject(part)) continue;
const { text } = part;
if (typeof text === "string" && text.trim() !== "") result = text;
const part = field(event, "part");
if (field(event, "type") !== "text" || !isObject(part)) {
continue;
}
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");
}
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 { assert, expect, test } from "vite-plus/test";
import { isNotFound } from "./errors.ts";
import { postRuntime } from "./post.ts";
import type { PostContext } from "./post.ts";
import type { RuntimeManifest, RuntimeOutcome } from "./runtime.ts";
import { isNotFound } from "#common";
import type { RuntimeFailure, RuntimeManifest, RuntimeOutcome } from "#runtime";
import { currentGid, currentUid, IS_WINDOWS, makeTempDir } from "#testing";
import { type Fetcher, type PostContext, postRuntime } from "./post.ts";
const context: PostContext = {
apiUrl: "https://gitea.example/api/v1",
repository: "owner/repo",
@@ -23,7 +22,18 @@ interface Fixture {
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> {
await writeFile(path, text, { mode: 0o600 });
@@ -32,12 +42,18 @@ async function writePrivate(path: string, text: string): Promise<void> {
async function writeArtifacts(
outcome: RuntimeOutcome,
review: string | undefined,
anchors: unknown,
review?: string,
anchors?: unknown,
): Promise<void> {
if (!outcome.ok) return;
if (review !== undefined) await writePrivate(outcome.reviewPath, review);
if (anchors !== undefined) await writePrivate(outcome.anchorsPath, JSON.stringify(anchors));
if (!outcome.ok) {
return;
}
if (review !== undefined) {
await writePrivate(outcome.reviewPath, review);
}
if (anchors !== undefined) {
await writePrivate(outcome.anchorsPath, JSON.stringify(anchors));
}
}
function manifestFor(runtimeDir: string, profileHome: string): RuntimeManifest {
@@ -96,7 +112,9 @@ const missing = async (path: string): Promise<boolean> => {
await stat(path);
return false;
} catch (error) {
if (isNotFound(error)) return true;
if (isNotFound(error)) {
return true;
}
throw error;
}
};
@@ -107,7 +125,7 @@ function requestBody(init?: RequestInit): unknown {
}
// 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) => {
bodies.push(requestBody(init));
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 () => {
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) => {
requests.push({ url, init });
+59 -59
View File
@@ -1,14 +1,10 @@
import type { Stats } from "node:fs";
import { lstat, readFile, rm } from "node:fs/promises";
import { join } from "node:path";
import process from "node:process";
import { isNotFound } from "./errors.ts";
import { isObject } from "./json.ts";
import { failureFooter, withProfile } from "./profiles.ts";
import type { FailureReason, ProfileFailure } from "./profiles.ts";
import { readRuntimeManifest } from "./runtime.ts";
import type { RuntimeManifest, RuntimeOutcome } from "./runtime.ts";
import { field, isNotFound, isObject } from "#common";
import { failureFooter, type FailureReason, type ProfileFailure, withProfile } from "#profiles";
import { readRuntimeManifest, type RuntimeManifest, type RuntimeOutcome } from "#runtime";
export interface PostContext {
apiUrl: string;
@@ -26,28 +22,37 @@ interface Anchor {
const ANSI = new RegExp(`${String.fromCodePoint(27)}\\[[0-9;]*[A-Za-z]`, "gu");
const stripAnsi = (text: string): string => text.replace(ANSI, "");
const VERDICTS: Record<string, [event: string, mark: string]> = {
Approved: ["APPROVED", "✅"],
"Changes requested": ["REQUEST_CHANGES", "🛑"],
};
const PROFILE_TYPES: Set<unknown> = new Set(["claude", "codex", "opencode"]);
const FAILURE_REASONS: Set<unknown> = new Set(["sign-in failed", "usage exhausted", "failed"]);
const VERDICTS = new Map<string, [event: string, mark: string]>([
["Approved", ["APPROVED", "✅"]],
["Changes requested", ["REQUEST_CHANGES", "🛑"]],
]);
const PROFILE_TYPES = new Set<unknown>(["claude", "codex", "opencode"]);
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 isFailureReason = (value: unknown): value is FailureReason => FAILURE_REASONS.has(value);
const failureComment = (failures: ProfileFailure[]): string =>
`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 {}
function parseAnchors(text: string, sourcePath: string): Anchor[] {
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) => {
const at = `${sourcePath}[${index}]`;
if (!isObject(entry)) {
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 === "") {
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)) {
throw new Error("invalid agent outcome");
}
const outcome = value;
if (outcome["ok"] === false) {
const ok = field(value, "ok");
if (ok === false) {
// 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 {
ok: false,
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)
? [{ type, model, reason }]
: [];
}),
};
}
const body = field(value, "body");
const reviewPath = field(value, "reviewPath");
const anchorsPath = field(value, "anchorsPath");
const profile = field(value, "profile");
if (
outcome["ok"] !== true ||
typeof outcome["body"] !== "string" ||
typeof outcome["reviewPath"] !== "string" ||
typeof outcome["anchorsPath"] !== "string" ||
!isObject(outcome["profile"])
ok !== true ||
typeof body !== "string" ||
typeof reviewPath !== "string" ||
typeof anchorsPath !== "string" ||
!isObject(profile)
) {
throw new Error("invalid agent outcome");
}
const profile = outcome["profile"];
if (!isProfileType(profile["type"]) || typeof profile["model"] !== "string") {
const type = field(profile, "type");
const model = field(profile, "model");
if (!isProfileType(type) || typeof model !== "string") {
throw new Error("invalid agent outcome");
}
return {
ok: true,
body: outcome["body"],
reviewPath: outcome["reviewPath"],
anchorsPath: outcome["anchorsPath"],
profile: {
type: profile["type"],
model: profile["model"],
},
};
return { ok: true, body, reviewPath, anchorsPath, profile: { type, model } };
}
async function readAgentArtifact(
@@ -114,12 +118,16 @@ async function readAgentArtifact(
name: string,
): Promise<string | null> {
const profile = manifest.profiles.find((entry) => join(entry.home, name) === path);
if (profile === undefined) throw new Error(`${name}: outside profile home`);
if (profile === undefined) {
throw new Error(`${name}: outside profile home`);
}
let stat: Stats;
try {
stat = await lstat(path);
} catch (error) {
if (isNotFound(error)) return null;
if (isNotFound(error)) {
return null;
}
throw error;
}
if (
@@ -137,11 +145,12 @@ async function readAgentArtifact(
export async function postRuntime(
manifestPath: string,
context: PostContext,
fetcher: typeof fetch = fetch,
fetcher: Fetcher = fetch,
): Promise<void> {
const manifest = await readRuntimeManifest(manifestPath);
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> => {
let response: Response;
try {
@@ -189,7 +198,7 @@ export async function postRuntime(
}
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 anchors = anchorText === null ? [] : parseAnchors(anchorText, outcome.anchorsPath);
const postReview = (entries: Anchor[]): Promise<void> =>
@@ -206,35 +215,26 @@ export async function postRuntime(
})),
});
await postReview(anchors).catch(async (error: unknown) => {
if (anchors.length === 0) throw error;
if (anchors.length === 0) {
throw error;
}
console.error(
`inline comments rejected: ${error instanceof Error ? error.message : String(error)}`,
);
await postReview([]);
});
} catch (error) {
if (error instanceof GiteaRequestError) throw error;
if (error instanceof GiteaRequestError) {
throw error;
}
await postComment(failureComment([{ ...outcome.profile, reason: "failed" }]));
throw error;
}
} finally {
await rm(manifest.runtimeDir, { recursive: true }).catch((error: unknown) => {
if (!isNotFound(error)) throw error;
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 process from "node:process";
import { prepareRuntime } from "./runtime.ts";
import { prepareRuntime } from "#runtime";
const env = (name: string): string => {
const value = process.env[name];
if (value === undefined || value === "") throw new Error(`${name} is not set`);
if (value === undefined || value === "") {
throw new Error(`${name} is not set`);
}
return value;
};
@@ -1,8 +1,8 @@
import { spawn } from "node:child_process";
import { expect, test } from "vite-plus/test";
import { isNotFound } from "./errors.ts";
import { isNotFound } from "#common";
import { exitCode } from "./child.ts";
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 type { SpawnSyncReturns } from "node:child_process";
import { expect, test } from "vite-plus/test";
import { spawnSync, type SpawnSyncReturns } from "node:child_process";
import { chmod, chown, mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises";
import process from "node:process";
import { expect, test } from "vite-plus/test";
import { isNotFound } from "./errors.ts";
import { isRunnableProfileProcess, killProfileProcesses, readProfileFile } from "./isolation.ts";
import type { RuntimeProfile } from "./runtime.ts";
import { isNotFound } from "#common";
import type { RuntimeProfile } from "#runtime";
import { currentUid, IS_ROOT, IS_WINDOWS, makeTempDir } from "#testing";
import { isRunnableProfileProcess, killProfileProcesses, readProfileFile } from "./isolation.ts";
const profile = (uid: number, home: string): RuntimeProfile => ({
index: uid - 20_000,
type: "claude",
@@ -25,14 +25,14 @@ function runAs(
selected: RuntimeProfile,
command: string,
args: string[],
env?: Record<string, string>,
env?: ReadonlyMap<string, string>,
): SpawnSyncReturns<string> {
return spawnSync(command, args, {
cwd: "/",
uid: selected.uid,
gid: selected.gid,
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);
const status = await readFile(`/proc/${pid}/status`, "utf8").catch((error: unknown) => {
if (isNotFound(error)) return null;
if (isNotFound(error)) {
return null;
}
throw error;
});
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", () => {
const selected = profile(20_020, "/tmp");
const result = runAs(selected, "/usr/bin/cat", [`/proc/${process.pid}/environ`], {
PARENT_SECRET: "must-not-cross-boundary",
});
const result = runAs(
selected,
"/usr/bin/cat",
[`/proc/${process.pid}/environ`],
new Map([["PARENT_SECRET", "must-not-cross-boundary"]]),
);
expect(result.status).not.toBe(0);
});
@@ -2,12 +2,12 @@ import { lstat, readdir, readFile } from "node:fs/promises";
import process from "node:process";
import { setTimeout as sleep } from "node:timers/promises";
import { isNotFound } from "./errors.ts";
import type { RuntimeProfile } from "./runtime.ts";
import { isNotFound } from "#common";
import type { RuntimeProfile } from "#runtime";
export function isRunnableProfileProcess(status: string, uid: number): boolean {
const owner = /^Uid:\s+(\d+)/mu.exec(status)?.[1];
const state = /^State:\s+(\w)/mu.exec(status)?.[1];
const owner = /^Uid:\s+(?<uid>\d+)/mu.exec(status)?.[1];
const state = /^State:\s+(?<state>\w)/mu.exec(status)?.[1];
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`);
}
const ids = await profileProcessIds(uid);
if (ids.length === 0) return;
if (ids.length === 0) {
return;
}
for (const pid of ids) {
try {
process.kill(pid, "SIGKILL");
} catch (error) {
if (!isNotFound(error)) throw error;
if (!isNotFound(error)) {
throw error;
}
}
}
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 {
AgentAuthError,
agentEnvironment,
@@ -61,7 +62,9 @@ test("recognizes provider API exhaustion status", async () => {
{ type: "codex", model: "gpt-5.6-terra" },
],
(profile) => {
if (profile.type === "claude") throw new Error(message);
if (profile.type === "claude") {
throw new Error(message);
}
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") {
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");
},
failures,
@@ -115,7 +120,8 @@ test("throws the last sign-in failure when every profile fails", async () => {
),
).rejects.toThrow(
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"]);
@@ -156,20 +162,20 @@ test("does not mistake incidental quota text for exhaustion", async () => {
});
test("exposes only the selected profile credential to the agent", () => {
const base = {
PATH: "/usr/bin",
HOME: "/home/bot",
UNRELATED_SECRET: "must-not-reach-agent",
BOT_AGENTS_MASTER_KEY: "master",
PROFILES: "contains every secret",
REVIEWER_TOKEN: "reviewer",
CLAUDE_CODE_OAUTH_TOKEN: "old-claude",
ANTHROPIC_API_KEY: "old-anthropic-key",
ANTHROPIC_AUTH_TOKEN: "old-anthropic-token",
OPENAI_API_KEY: "old-openai",
CODEX_API_KEY: "old-codex-key",
CODEX_ACCESS_TOKEN: "old-codex-token",
};
const base = new Map([
["PATH", "/usr/bin"],
["HOME", "/home/bot"],
["UNRELATED_SECRET", "must-not-reach-agent"],
["BOT_AGENTS_MASTER_KEY", "master"],
["PROFILES", "contains every secret"],
["REVIEWER_TOKEN", "reviewer"],
["CLAUDE_CODE_OAUTH_TOKEN", "old-claude"],
["ANTHROPIC_API_KEY", "old-anthropic-key"],
["ANTHROPIC_AUTH_TOKEN", "old-anthropic-token"],
["OPENAI_API_KEY", "old-openai"],
["CODEX_API_KEY", "old-codex-key"],
["CODEX_ACCESS_TOKEN", "old-codex-token"],
]);
const profile = {
index: 0,
type: "claude" as const,
@@ -180,15 +186,19 @@ test("exposes only the selected profile credential to the agent", () => {
tokenFile: "/runtime/profile-0/token",
};
expect(agentEnvironment(base, profile, "selected")).toStrictEqual({
PATH: "/usr/bin",
HOME: "/runtime/profile-0",
CLAUDE_CODE_OAUTH_TOKEN: "selected",
});
expect(sanitizedEnvironment(base)).toStrictEqual({
PATH: "/usr/bin",
HOME: "/home/bot",
});
expect(agentEnvironment(base, profile, "selected")).toStrictEqual(
new Map([
["PATH", "/usr/bin"],
["HOME", "/runtime/profile-0"],
["CLAUDE_CODE_OAUTH_TOKEN", "selected"],
]),
);
expect(sanitizedEnvironment(base)).toStrictEqual(
new Map([
["PATH", "/usr/bin"],
["HOME", "/home/bot"],
]),
);
});
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,
};
expect(agentEnvironment({ PATH: "/bin" }, profile)).toStrictEqual({
PATH: "/bin",
HOME: "/runtime/profile-1",
CODEX_HOME: "/runtime/profile-1/.codex",
});
expect(agentEnvironment(new Map([["PATH", "/bin"]]), profile)).toStrictEqual(
new Map([
["PATH", "/bin"],
["HOME", "/runtime/profile-1"],
["CODEX_HOME", "/runtime/profile-1/.codex"],
]),
);
});
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",
};
expect(agentEnvironment({ PATH: "/bin" }, profile, "selected")).toStrictEqual({
PATH: "/bin",
HOME: "/runtime/profile-2",
OPENROUTER_API_KEY: "selected",
});
expect(agentEnvironment(new Map([["PATH", "/bin"]]), profile, "selected")).toStrictEqual(
new Map([
["PATH", "/bin"],
["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";
model: string;
}
export interface BotProfile extends ProfileIdentity {
token?: string;
}
export function profileFooter(profile: Pick<BotProfile, "type" | "model">): string {
export function profileFooter(profile: ProfileIdentity): string {
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)}`;
}
export type FailureReason = "sign-in failed" | "usage exhausted" | "failed";
export type ProfileFailure = Pick<BotProfile, "type" | "model"> & {
export interface ProfileFailure extends ProfileIdentity {
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.
export class AgentAuthError extends Error {}
@@ -28,12 +38,16 @@ function isUsageExhausted(error: unknown): boolean {
}
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";
}
export function failureFooter(failures: ProfileFailure[]): string {
if (failures.length === 0) return "> Profile: none";
if (failures.length === 0) {
return "> Profile: none";
}
return `> Tried: ${failures
.map(({ type, model, reason }) => `\`${type}\` · \`${model}\` (${reason})`)
.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
// 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[],
attempt: (profile: P) => Promise<T>,
failures: ProfileFailure[],
@@ -56,13 +70,15 @@ async function attemptFrom<T, P extends Pick<BotProfile, "type" | "model">>(
} catch (error) {
const reason = failureReason(error);
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`);
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[],
attempt: (profile: P) => Promise<T>,
failures: ProfileFailure[] = [],
@@ -70,8 +86,12 @@ export function runProfiles<T, P extends Pick<BotProfile, "type" | "model">>(
return attemptFrom(profiles, attempt, failures, 0);
}
export function sanitizedEnvironment(environment: Record<string, string>): Record<string, string> {
const result: Record<string, string> = {};
// Environments are maps from variable name to value, converted to the object
// 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 [
"PATH",
"HOME",
@@ -98,27 +118,31 @@ export function sanitizedEnvironment(environment: Record<string, string>): Recor
"SSL_CERT_DIR",
"NODE_EXTRA_CA_CERTS",
]) {
const value = environment[name];
if (value !== undefined) result[name] = value;
const value = environment.get(name);
if (value !== undefined) {
result.set(name, value);
}
}
return result;
}
export function agentEnvironment(
environment: Record<string, string>,
profile: Pick<BotProfile, "type"> & { home: string },
environment: ReadonlyMap<string, string>,
profile: ProfileHome,
token?: string,
): Record<string, string> {
): Map<string, string> {
const result = sanitizedEnvironment(environment);
result["HOME"] = profile.home;
if (profile.type === "codex") result["CODEX_HOME"] = `${profile.home}/.codex`;
result.set("HOME", profile.home);
if (profile.type === "codex") {
result.set("CODEX_HOME", `${profile.home}/.codex`);
}
if (token !== undefined && token !== "") {
const tokenName = {
claude: "CLAUDE_CODE_OAUTH_TOKEN",
codex: "OPENAI_API_KEY",
opencode: "OPENROUTER_API_KEY",
}[profile.type];
result[tokenName] = token;
result.set(tokenName, token);
}
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 {
type ClaudeFailureFields,
type CodexAuthentication,
codexAuthentication,
collectOpenCodeOutput,
type CommandResult,
isClaudeAuthFailure,
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,
openCodeError,
parseOpenCodeEvents,
} from "./opencode.ts";
persistCodexAuth,
retryInvalidToken,
stageCodexAuth,
} from "#agents";
import { field, isObject } from "#common";
import { echo, exitCode, killProfileProcesses, readProfileFile } from "#processes";
import {
AgentAuthError,
agentEnvironment,
type ProfileFailure,
type ProfileIdentity,
runProfiles,
sanitizedEnvironment,
withProfile,
} from "./profiles.ts";
import type { ProfileFailure } from "./profiles.ts";
import { readRuntimeManifest } from "./runtime.ts";
import type { RuntimeOutcome, RuntimeProfile } from "./runtime.ts";
} from "#profiles";
import { readRuntimeManifest, type RuntimeOutcome, type RuntimeProfile } from "#runtime";
interface AttemptResult {
body: string;
reviewPath: string;
anchorsPath: string;
profile: Pick<RuntimeProfile, "type" | "model">;
profile: ProfileIdentity;
}
// Claude Code's stream-json `result` event, in its own field names.
type ClaudeResult = ClaudeFailureFields &
Partial<Record<"is_error", boolean>> & {
subtype: string;
};
// Claude Code's stream-json `result` event, with camelCase field names.
interface ClaudeResult extends ClaudeFailureFields {
isError?: boolean;
subtype: string;
}
interface RenderedPrompt {
prompt: string;
reviewPath: string;
anchorsPath: string;
}
const env = (name: string): string => {
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;
};
function definedEnvironment(): Record<string, string> {
const result: Record<string, string> = {};
function definedEnvironment(): Map<string, string> {
const result = new Map<string, string>();
for (const [name, value] of Object.entries(process.env)) {
if (value !== undefined) {
result[name] = value;
result.set(name, value);
}
}
return result;
@@ -99,21 +106,28 @@ async function postAuthorComment(body: string): Promise<void> {
}
async function runRoot(command: string, args: string[]): Promise<void> {
const code = await exitCode(spawn(command, args, { env: PROCESS_ENV, stdio: "ignore" }));
if (code !== 0) throw new Error(`${command} ${args[0]} exited with ${code}`);
const code = await exitCode(
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> {
const user: unknown = await (await gitea("user")).json();
if (!isObject(user) || typeof user["login"] !== "string" || typeof user["email"] !== "string") {
throw new Error("user: unexpected Gitea response");
const userResponse = await gitea("user");
const user: unknown = await userResponse.json();
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.email", user["email"]]);
await runRoot("git", ["config", "user.name", login]);
await runRoot("git", ["config", "user.email", email]);
}
async function readProfileToken(profile: RuntimeProfile): Promise<string | undefined> {
const token = profile.tokenFile === null ? undefined : await readFile(profile.tokenFile, "utf8");
async function readProfileToken(profile: RuntimeProfile): Promise<string | null> {
const token = profile.tokenFile === null ? null : await readFile(profile.tokenFile, "utf8");
return token;
}
@@ -125,13 +139,15 @@ async function runAgentCommand(
): Promise<void> {
const child = spawn(command, args, {
cwd: WORKSPACE,
env: agentEnvironment(PROCESS_ENV, profile, token),
env: Object.fromEntries(agentEnvironment(PROCESS_ENV, profile, token)),
uid: profile.uid,
gid: profile.gid,
stdio: "ignore",
});
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> {
@@ -154,38 +170,36 @@ async function prepareProfile(profile: RuntimeProfile): Promise<void> {
// One issue comment as the prompt quotes it.
function formatComment(comment: unknown): string {
const { user, created_at: createdAt, body } = isObject(comment) ? comment : {};
const login = isObject(user) ? user["login"] : undefined;
const login = field(field(comment, "user"), "login");
const createdAt = field(comment, "created_at");
const body = field(comment, "body");
return `## ${String(login)} at ${String(createdAt)}\n\n${String(body)}\n`;
}
async function renderPrompt(
profile: RuntimeProfile,
): Promise<{ prompt: string; reviewPath: string; anchorsPath: string }> {
async function renderPrompt(profile: RuntimeProfile): Promise<RenderedPrompt> {
const reviewPath = await makeProfileFile(profile, "review.md");
const anchorsPath = await makeProfileFile(profile, "anchors.json");
const comments: unknown = await (
await gitea(`repos/${REPO}/issues/${INDEX}/comments?limit=100`)
).json();
const commentsResponse = await gitea(`repos/${REPO}/issues/${INDEX}/comments?limit=100`);
const comments: unknown = await commentsResponse.json();
if (!Array.isArray(comments)) {
throw new TypeError("comments: unexpected Gitea response");
}
const values: Record<string, string> = {
COMMENT: env("COMMENT"),
ISSUE_COMMENTS: comments.map((comment: unknown) => formatComment(comment)).join("\n"),
CODE_RULES: await (await gitea(RULES_PATH)).text(),
EVENT_NAME: EVENT,
GITEA_API_URL: API,
GITEA_REPOSITORY: REPO,
ISSUE_INDEX: INDEX,
REVIEW_PATH: reviewPath,
ANCHORS_PATH: anchorsPath,
};
const template = await readFile(new URL("../prompt.md", import.meta.url), "utf8");
const values = new Map<string, string>([
["COMMENT", env("COMMENT")],
["ISSUE_COMMENTS", comments.map((comment: unknown) => formatComment(comment)).join("\n")],
["CODE_RULES", await gitea(RULES_PATH).then((response) => response.text())],
["EVENT_NAME", EVENT],
["GITEA_API_URL", API],
["GITEA_REPOSITORY", REPO],
["ISSUE_INDEX", INDEX],
["REVIEW_PATH", reviewPath],
["ANCHORS_PATH", anchorsPath],
]);
const template = await readFile(new URL("../../prompt.md", import.meta.url), "utf8");
return {
prompt: template.replaceAll(
/\$\{(\w+)\}/gu,
(match: string, name: string) => values[name] ?? match,
/\$\{(?<name>\w+)\}/gu,
(match: string, name: string) => values.get(name) ?? match,
),
reviewPath,
anchorsPath,
@@ -207,17 +221,14 @@ async function runClaude(profile: RuntimeProfile, token: string, prompt: string)
],
{
cwd: WORKSPACE,
env: {
...agentEnvironment(PROCESS_ENV, profile, token),
IS_SANDBOX: "1",
},
env: Object.fromEntries(agentEnvironment(PROCESS_ENV, profile, token).set("IS_SANDBOX", "1")),
uid: profile.uid,
gid: profile.gid,
stdio: ["ignore", "pipe", "pipe"],
},
);
const status = exitCode(claude);
let result: ClaudeResult | undefined;
let result: ClaudeResult | null = null;
let error = "";
const copyError = (async (): Promise<void> => {
claude.stderr.setEncoding("utf8");
@@ -231,30 +242,32 @@ async function runClaude(profile: RuntimeProfile, token: string, prompt: string)
if (line !== "") {
const event: unknown = JSON.parse(line);
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 : []) {
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 !== "") {
console.log(text);
}
}
}
if (event["type"] === "result") {
const { is_error: isError, result: answer, subtype, api_error_status: apiStatus } = event;
if (field(event, "type") === "result") {
const isError = field(event, "is_error");
const answer = field(event, "result");
const subtype = field(event, "subtype");
const apiStatus = field(event, "api_error_status");
result = {
is_error: typeof isError === "boolean" ? isError : undefined,
isError: typeof isError === "boolean" ? isError : undefined,
result: typeof answer === "string" ? answer : undefined,
subtype: String(subtype),
api_error_status:
typeof apiStatus === "number" || apiStatus === null ? apiStatus : undefined,
apiErrorStatus: typeof apiStatus === "number" ? apiStatus : undefined,
};
}
}
}
}
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}`);
if (isClaudeAuthFailure(result)) {
throw new AgentAuthError("claude sign-in failed", { cause: failure });
@@ -287,11 +300,11 @@ async function pollForInstructions(
async function codexDeviceLogin(
profile: RuntimeProfile,
environment: Record<string, string>,
environment: ReadonlyMap<string, string>,
): Promise<void> {
const login = spawn("codex", ["login", "--device-auth"], {
cwd: WORKSPACE,
env: environment,
env: Object.fromEntries(environment),
uid: profile.uid,
gid: profile.gid,
stdio: ["ignore", "pipe", "pipe"],
@@ -316,12 +329,12 @@ async function codexDeviceLogin(
async function codexApiLogin(
profile: RuntimeProfile,
environment: Record<string, string>,
environment: ReadonlyMap<string, string>,
token: string,
): Promise<void> {
const login = spawn("codex", ["login", "--with-api-key"], {
cwd: WORKSPACE,
env: environment,
env: Object.fromEntries(environment),
uid: profile.uid,
gid: profile.gid,
stdio: ["pipe", "inherit", "pipe"],
@@ -355,15 +368,17 @@ async function runCodex(
} else {
const check = spawn("codex", ["login", "status"], {
cwd: WORKSPACE,
env: environment,
env: Object.fromEntries(environment),
uid: profile.uid,
gid: profile.gid,
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 attempt = async (): Promise<{ code: number; error: string }> => {
const attempt = async (): Promise<CommandResult> => {
const codex = spawn(
"codex",
[
@@ -377,7 +392,7 @@ async function runCodex(
],
{
cwd: WORKSPACE,
env: environment,
env: Object.fromEntries(environment),
uid: profile.uid,
gid: profile.gid,
stdio: ["ignore", "inherit", "pipe"],
@@ -418,7 +433,7 @@ async function runOpenCode(
): Promise<string> {
const child = spawn("opencode", openCodeArgs(profile.model, prompt), {
cwd: WORKSPACE,
env: agentEnvironment(PROCESS_ENV, profile, token),
env: Object.fromEntries(agentEnvironment(PROCESS_ENV, profile, token)),
uid: profile.uid,
gid: profile.gid,
stdio: ["ignore", "pipe", "pipe"],
@@ -440,12 +455,12 @@ async function runOpenCode(
async function runProfile(profile: RuntimeProfile): Promise<AttemptResult> {
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 body: string | undefined;
let body: string | null = null;
let resultPath: string | null = null;
let reviewPath: string | undefined;
let anchorsPath: string | undefined;
let reviewPath: string | null = null;
let anchorsPath: string | null = null;
try {
if (authentication?.kind === "persistent") {
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(
profile,
rendered.prompt,
authentication ?? codexAuthentication(token),
authentication ?? codexAuthentication(token ?? undefined),
);
} else {
body = await runOpenCode(profile, token ?? "", rendered.prompt);
@@ -471,8 +486,10 @@ async function runProfile(profile: RuntimeProfile): Promise<AttemptResult> {
await persistCodexAuth(stagedCodexAuth, CODEX_AUTH);
}
}
if (resultPath !== null) body = await readProfileFile(profile, resultPath);
if (body === undefined || reviewPath === undefined || anchorsPath === undefined) {
if (resultPath !== null) {
body = await readProfileFile(profile, resultPath);
}
if (body === null || reviewPath === null || anchorsPath === null) {
throw new Error("agent attempt produced no result");
}
return {
@@ -1,9 +1,10 @@
import { expect, test } from "vite-plus/test";
import { decryptCredential, encryptCredential, parseCredentials } from "./credentials.ts";
const MASTER_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8";
const OTHER_KEY = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE";
const NONCE = Uint8Array.from({ length: 12 }, (_, index) => index + 32);
const NONCE = Uint8Array.from({ length: 12 }, (_value, index) => index + 32);
const ENCRYPTED = "v1:ICEiIyQlJicoKSor:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA";
test("decrypts a known AES-256-GCM credential", async () => {
@@ -1,9 +1,15 @@
import { webcrypto } from "node:crypto";
import { isMapping } from "./json.ts";
import type { BotProfile } from "./profiles.ts";
import { field, isMapping } from "#common";
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",
codex: "gpt-5.6-terra",
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>");
}
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(
{ name: "AES-GCM", iv: buffer(nonce) },
await importMasterKey(masterKey, "decrypt"),
@@ -55,7 +63,9 @@ export async function encryptCredential(
masterKey: string,
nonce: Uint8Array = webcrypto.getRandomValues(new Uint8Array(12)),
): 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(
{ name: "AES-GCM", iv: buffer(nonce) },
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`);
}
}
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") {
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`);
}
}
const { "reviewer-token": reviewerToken, profiles } = value;
const reviewerToken = field(value, "reviewer-token");
const profiles = field(value, "profiles");
if (typeof reviewerToken !== "string") {
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 process from "node:process";
import { fileURLToPath } from "node:url";
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 { prepareRuntime, type RuntimeProfile } from "./runtime.ts";
const MASTER_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8";
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);
expect(await readFile(claude.tokenFile, "utf8")).toBe("secret-token");
await expectPrivate(claude.tokenFile, claude.uid, 0o600);
expect((await stat(codex.home)).uid).toStrictEqual(codex.uid);
const codexHome = await stat(codex.home);
expect(codexHome.uid).toStrictEqual(codex.uid);
expect(codex.tokenFile).toBe(null);
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(
process.execPath,
[fileURLToPath(new URL("prepare.ts", import.meta.url))],
[fileURLToPath(new URL("../prepare/index.ts", import.meta.url))],
{
env: {
ACTION_PATH: action,
RUNNER_TEMP: root,
BOT_AGENTS_MASTER_KEY: MASTER_KEY,
GITHUB_OUTPUT: action,
},
env: Object.fromEntries([
["ACTION_PATH", action],
["RUNNER_TEMP", root],
["BOT_AGENTS_MASTER_KEY", MASTER_KEY],
["GITHUB_OUTPUT", action],
]),
},
);
+24 -18
View File
@@ -1,19 +1,20 @@
import { chmod, chown, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { field, isMapping } from "#common";
import type { ProfileFailure, ProfileIdentity } from "#profiles";
import { parseCredentials } from "./credentials.ts";
import { isMapping } from "./json.ts";
import type { BotProfile, ProfileFailure } from "./profiles.ts";
const FIRST_PROFILE_UID = 20_000;
export type RuntimeProfile = Pick<BotProfile, "type" | "model"> & {
export interface RuntimeProfile extends ProfileIdentity {
index: number;
uid: number;
gid: number;
home: string;
tokenFile: string | null;
};
}
export interface RuntimeManifest {
runtimeDir: string;
@@ -23,15 +24,20 @@ export interface RuntimeManifest {
profiles: RuntimeProfile[];
}
export type RuntimeOutcome =
| {
ok: true;
body: string;
reviewPath: string;
anchorsPath: string;
profile: Pick<RuntimeProfile, "type" | "model">;
}
| { ok: false; failures: ProfileFailure[] };
export interface RuntimeSuccess {
ok: true;
body: string;
reviewPath: string;
anchorsPath: string;
profile: ProfileIdentity;
}
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> {
await writeFile(path, value, { mode: 0o600 });
@@ -101,11 +107,11 @@ export async function prepareRuntime(
function isRuntimeManifest(value: unknown): value is RuntimeManifest {
return (
isMapping(value) &&
typeof value["runtimeDir"] === "string" &&
typeof value["manifestPath"] === "string" &&
typeof value["reviewerFile"] === "string" &&
typeof value["outcomeFile"] === "string" &&
Array.isArray(value["profiles"])
typeof field(value, "runtimeDir") === "string" &&
typeof field(value, "manifestPath") === "string" &&
typeof field(value, "reviewerFile") === "string" &&
typeof field(value, "outcomeFile") === "string" &&
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
// plain command runner. It imports no source module, so the repository-level
// Test support: the current user's IDs, a fresh temporary directory, the
// 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
// internals.
import { spawn } from "node:child_process";
@@ -33,14 +33,28 @@ export interface CommandResult {
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
// collects its stderr.
export async function runCommand(
command: string,
args: string[],
env: Record<string, string>,
env: ReadonlyMap<string, string>,
): 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 exitCode: unknown = closed[0];
return { success: exitCode === 0, stderr };
+7 -4
View File
@@ -1,11 +1,10 @@
import process from "node:process";
import { fileURLToPath } from "node:url";
import { expect, test } from "vite-plus/test";
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
// variable, so failing on that variable proves every module loads.
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) => {
const path = fileURLToPath(new URL(`../src/${phase}.ts`, import.meta.url));
const result = await runCommand(process.execPath, [path], { PATH: process.env["PATH"] ?? "" });
const path = fileURLToPath(new URL(`../src/${phase}/index.ts`, import.meta.url));
const result = await runCommand(
process.execPath,
[path],
new Map([["PATH", process.env["PATH"] ?? ""]]),
);
expect(result.success).toBe(false);
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 process from "node:process";
import { assert, expect, test } from "vite-plus/test";
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 =>
typeof value === "object" && value !== null && !Array.isArray(value);
const mapping = (value: unknown, at: string): JsonObject => {
assert(isMapping(value), at);
return value;
const mapping = (value: unknown, at: string): Mapping => {
assert(typeof value === "object" && value !== null && !Array.isArray(value), at);
return new Map(Object.entries(value));
};
const sequence = (value: unknown, at: string): JsonObject[] => {
const sequence = (value: unknown, at: string): Mapping[] => {
assert(Array.isArray(value), at);
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"));
return mapping(value, path);
}
@@ -34,8 +31,8 @@ async function yamlFiles(): Promise<string[]> {
.filter((path) => !/^(?:\.git|\.superpowers|node_modules)\//u.test(path));
}
const findStep = (steps: JsonObject[], key: string, value: string): JsonObject => {
const step = steps.find((entry) => entry[key] === value);
const findStep = (steps: Mapping[], key: string, value: string): Mapping => {
const step = steps.find((entry) => entry.get(key) === value);
assert(step !== undefined, `${key}: ${value}`);
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 () => {
const action = await yaml("action.yml");
expect(Object.keys(mapping(action["inputs"], "inputs"))).toStrictEqual(["master-key"]);
const steps = sequence(mapping(action["runs"], "runs")["steps"], "runs.steps");
expect([...mapping(action.get("inputs"), "inputs").keys()]).toStrictEqual(["master-key"]);
const steps = sequence(mapping(action.get("runs"), "runs").get("steps"), "runs.steps");
for (const step of steps) {
if (typeof step["run"] === "string") {
expect(step["run"].includes("\n"), String(step["name"])).toBe(false);
const run = step.get("run");
if (typeof run === "string") {
expect(run.includes("\n"), String(step.get("name"))).toBe(false);
}
}
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 post = findStep(steps, "id", "post");
findStep(steps, "id", "source");
expect(mapping(checkout["with"], "checkout.with")["token"]).toBe("${{ gitea.token }}");
expect(mapping(prepare["env"], "prepare.env")["BOT_AGENTS_MASTER_KEY"]).toBe(
expect(mapping(checkout.get("with"), "checkout.with").get("token")).toBe("${{ gitea.token }}");
expect(mapping(prepare.get("env"), "prepare.env").get("BOT_AGENTS_MASTER_KEY")).toBe(
"${{ inputs.master-key }}",
);
expect(JSON.stringify(agent).includes("BOT_AGENTS_MASTER_KEY")).toBe(false);
expect(JSON.stringify(post).includes("BOT_AGENTS_MASTER_KEY")).toBe(false);
expect(mapping(agent["env"], "agent.env")["GITEA_TOKEN"]).toBe("${{ gitea.token }}");
assert(String(post["if"]).includes("always()"));
expect(JSON.stringify(Object.fromEntries(agent)).includes("BOT_AGENTS_MASTER_KEY")).toBe(false);
expect(JSON.stringify(Object.fromEntries(post)).includes("BOT_AGENTS_MASTER_KEY")).toBe(false);
expect(mapping(agent.get("env"), "agent.env").get("GITEA_TOKEN")).toBe("${{ gitea.token }}");
assert(String(post.get("if")).includes("always()"));
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 outputFile = `${temp}/output`;
try {
const result = await runCommand("/bin/bash", ["scripts/protect-source.sh"], {
...process.env,
ACTION_PATH: process.cwd(),
GITHUB_OUTPUT: outputFile,
RUNNER_TEMP: temp,
});
const environment = inheritedEnvironment();
environment.set("ACTION_PATH", process.cwd());
environment.set("GITHUB_OUTPUT", outputFile);
environment.set("RUNNER_TEMP", temp);
const result = await runCommand("/bin/bash", ["scripts/protect-source.sh"], environment);
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 = [
"src/prepare.ts",
"src/run.ts",
"src/post.ts",
"src/prepare/index.ts",
"src/run/index.ts",
"src/post/index.ts",
"src/runtime/index.ts",
"src/agents/auth.ts",
"package.json",
"profiles.json",
"prompt.md",
@@ -105,9 +105,9 @@ test("the source protection script stages immutable action files", 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 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),
);
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 () => {
const workflow = await yaml(".gitea/workflows/agents.yml");
const job = mapping(mapping(workflow["jobs"], "jobs")["agent"], "jobs.agent");
const permissions = mapping(job["permissions"], "jobs.agent.permissions");
expect(permissions).toStrictEqual({
const job = mapping(mapping(workflow.get("jobs"), "jobs").get("agent"), "jobs.agent");
const permissions = mapping(job.get("permissions"), "jobs.agent.permissions");
expect(Object.fromEntries(permissions)).toStrictEqual({
code: "write",
issues: "write",
"pull-requests": "write",
});
const steps = sequence(job["steps"], "jobs.agent.steps");
expect(steps.findIndex((step) => step["uses"] === "./")).toStrictEqual(-1);
const steps = sequence(job.get("steps"), "jobs.agent.steps");
expect(steps.findIndex((step) => step.get("uses") === "./")).toStrictEqual(-1);
const trusted = findStep(steps, "uses", "https://git.capsulizers.com/commons/bot-agents@main");
expect(mapping(trusted["with"], "trusted.with")["master-key"]).toBe(
expect(mapping(trusted.get("with"), "trusted.with").get("master-key")).toBe(
"${{ 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 () => {
const workflow = await yaml(".gitea/workflows/check.yml");
const job = mapping(mapping(workflow["jobs"], "jobs")["check"], "jobs.check");
const commands = sequence(job["steps"], "jobs.check.steps")
.map((step) => (typeof step["run"] === "string" ? step["run"] : ""))
const job = mapping(mapping(workflow.get("jobs"), "jobs").get("check"), "jobs.check");
const commands = sequence(job.get("steps"), "jobs.check.steps")
.map((step) => {
const run = step.get("run");
return typeof run === "string" ? run : "";
})
.join("\n");
// `vp check` type-checks every phase, and `vp test` runs each of them under
// Node as well as the root-only isolation tests.
@@ -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 () => {
const runner = await readFile("src/run.ts", "utf8");
const runner = await readFile("src/run/index.ts", "utf8");
assert(runner.includes("skills add obra/superpowers"));
const removed = await stat("superpowers.ts").then(
() => false,
@@ -168,15 +171,19 @@ test("does not install Deno", 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);"));
expect(runner.includes("prepareProfile(profile, token)")).toBe(false);
});
test("central profiles fall back from Codex to Claude to OpenCode", async () => {
const credentials: unknown = JSON.parse(await readFile("profiles.json", "utf8"));
const profiles = sequence(mapping(credentials, "profiles.json")["profiles"], "profiles");
expect(profiles.map((profile) => profile["type"])).toStrictEqual(["codex", "claude", "opencode"]);
expect(profiles[2]?.["model"]).toBe("openrouter/qwen/qwen3.8-27b");
assert(String(profiles[2]?.["token"]).startsWith("v1:"));
const profiles = sequence(mapping(credentials, "profiles.json").get("profiles"), "profiles");
expect(profiles.map((profile) => profile.get("type"))).toStrictEqual([
"codex",
"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 projectLint from "./scripts/project-lint.ts";
import projectLint, { projectOverrides } from "./scripts/project-lint.ts";
export default defineConfig({
lint: {
plugins: ["typescript", "unicorn", "oxc", "import"],
jsPlugins: ["./scripts/project-lint.ts"],
categories: { correctness: "error", suspicious: "error", pedantic: "error", perf: "error" },
rules: projectLint.configs.project.rules,
jsPlugins: [
"eslint-plugin-explicit-types",
"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: {
typeAware: true,
typeCheck: true,