6 Commits

Author SHA1 Message Date
Danny Kim b97fffcebd Bump eslint-plugin-import-structure to 0.12.0
Agents / agent (pull_request) Has been skipped
Check / check (pull_request) Successful in 55s
2026-10-29 14:15:53 +09:00
Danny Kim 3d42665864 Try Claude before Codex in the central profiles (#25)
Co-authored-by: Danny Kim <temeddix@gmail.com>
Co-committed-by: Danny Kim <temeddix@gmail.com>
2026-10-20 14:27:19 +00:00
Danny Kim 063eb09750 Replace NodeJS dict aliases with maps and named field reads
Co-authored-by: Danny Kim <temeddix@gmail.com>
Co-committed-by: Danny Kim <temeddix@gmail.com>
2026-10-20 05:01:49 +00: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
Danny Kim c8a150fee7 Drop Deno from the agents and spawn child processes directly (#22)
Co-authored-by: Danny Kim <temeddix@gmail.com>
Co-committed-by: Danny Kim <temeddix@gmail.com>
2026-10-18 14:40:48 +00:00
Danny Kim ccfb08f4d5 Document that package.json aliases may target only direct subfolders of src (#21)
Follow-up to #20, which was merged before this landed.

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

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

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

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

Reviewed-on: #21
Reviewed-by: bot <noreply@capsulizers.com>
Co-authored-by: Danny Kim <temeddix@gmail.com>
Co-committed-by: Danny Kim <temeddix@gmail.com>
2026-10-18 14:37:49 +00:00
85 changed files with 3944 additions and 2952 deletions
-36
View File
@@ -1,36 +0,0 @@
---
description: Containerfiles build the frontend in a node:24-alpine stage with npm ci and vp build; CI uses setup-vp and runs vp check, vp build, vp test (plus lit-check)
---
## Containerfiles
- The frontend build stage is `docker.io/library/node:24-alpine`, replacing
`denoland/deno:alpine`. Vite+ publishes `linux-x64-musl` binaries, so Alpine
works. (`ghcr.io/voidzero-dev/vite-plus` is a glibc alternative that would
need a pinned digest; not used.)
- The stage copies `package.json` and `package-lock.json` first, runs
`npm ci`, then copies the sources and configs and runs `npx vp build`.
- The runtime stage copies the built `js/` and `css/` directories (not single
files) from the node stage.
- No Deno binary, `deno install`, or `deno task` remains in any stage.
```dockerfile
FROM docker.io/library/node:24-alpine AS frontend
WORKDIR /src/frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
RUN npx vp build
```
## CI
- GitHub repos (temeddix): `.github/workflows`. Capsulizers repos (Gitea):
`.gitea/workflows`. Don't move a repo's CI between hosts.
- Install with `voidzero-dev/setup-vp`, then run `vp check`, `vp build`, and
`vp test`; Lit repos also run `npm run lit-check`
([[lit-analyzer-ts6]]).
- Repos that ran CI on a self-hosted runner keep it.
- Until `@cunarist/eslint-plugin-*` are on npm, `npm ci` fails in CI. That is
expected and stated in the PR; run `npm install` once they are published
and commit the refreshed lockfile.
-20
View File
@@ -1,20 +0,0 @@
---
description: Code that must ship as standalone JS (memona framed_find.js, brain stream-recovery.js) is written as .ts, compiled at build time, and its output gitignored
---
Some apps ship plain JavaScript that runs outside the main bundle: memona's
`framed_find.js` and brain's `stream-recovery.js`. Under the TS-only rule
([[ts-only-files-and-imports]]) they are handled like this (2026-10-16):
- The source is a `.ts` file, linted and type-checked like everything else.
- The `.js` is produced at build time by the existing build (a Vite input or
a build step in `vite.config.ts` or `scripts/`), never committed.
- The output path is gitignored and, if it lands in a linted folder, listed
in `lint.ignorePatterns`; never in `overrides`, never with disable comments.
- Whatever loads the file (Rust `include_str!`, a container `COPY`, an HTML
`<script>`) keeps the same path and name as before, so behavior is
unchanged. Builds that need the file must run the compile first; check
that the Containerfile and CI order the steps that way.
Don't commit a hand-maintained `.js` copy "for convenience", and don't turn
it into `.mjs` or `.cjs`.
@@ -1,41 +0,0 @@
---
description: Apps use the npm lint plugins' configs, port their old deno-lint-plugin.ts rules into scripts/project-lint.ts, never override source files, and refactor instead of raising limits
---
## Plugins
Apps consume the published plugins as npm devDependencies, through `jsPlugins`
and the configs equivalent to what their Deno setup enabled:
- `@cunarist/eslint-plugin-explicit-types`: `concrete`, `naming`, `absence`
- `@cunarist/eslint-plugin-import-check`: `import-check`
- `@cunarist/eslint-plugin-lit`: `core`, `strict`, `dom-ref`,
`reactive-controller`, `naming`
## Repo-local rules
The local `scripts/deno-lint-plugin.ts` that home, eximium, stockvibe, brain,
memona, and memona-home carried is ported into `scripts/project-lint.ts`
([[project-lint-plugin]]) under the `project` namespace, same rule names and
behavior. Depending on the repo: `no-self-sync`, `no-sync-in-render`,
`app-module-conventions`, `always-wildcard-export`, `no-class-extends`,
`no-lit-nothing`, `no-title-attribute`, `prefer-wa-components`,
`no-satisfies`, `no-unknown`, `restricted-types`, `no-line-lint-ignore`,
`no-app-disposables-in-component`, `no-crepe-react-ui`. `@hugoalh` rules map to
Oxlint built-ins in `configs.project`.
memona's `deno-lint-plugin.ts` and its `coding-standards` memory are the house
style the explicit-types rules came from; they win any conflict.
## No overrides, refactor instead
Apps use **no `overrides` for source files at all** (the rule-registry
exception exists only in plugin repos). Oxlint's default limits stay, and
violations are refactored without behavior change in the same PR: barrels for
`max-dependencies`, splits for `max-lines`/`max-lines-per-function`/
`max-classes-per-file`, an options object for `max-params`. Long Lit
`render()` templates are split into smaller template functions or
components. See [[lint-overrides-policy]].
If a hand-written exception still looks truly necessary, ask the user first;
when they're away, leave it failing or refactor, and log the question.
-43
View File
@@ -1,43 +0,0 @@
---
description: Frontends build with vp build in Vite app mode, keeping the exact output paths the Rust backend and HTML expect; emptyOutDir false when outDir holds committed files
---
`deno bundle` was replaced by `vp build` in **Vite app mode** (not
`build.lib`). Behavior must not change, so the output keeps the file names and
locations the backend serves and the HTML references.
Typical shape for the Rust-backed frontends (home, eximium, stockvibe,
memona-home), where `frontend/` builds into `backend/static`:
```ts
build: {
outDir: "../backend/static",
emptyOutDir: false, // the folder holds committed files (index.html, ...)
rollupOptions: {
input: { bundle: "src/index.ts" },
output: { entryFileNames: "js/[name].js" }, // -> js/bundle.js
},
},
```
- **Fixed names, no hashes:** set `entryFileNames` (and `assetFileNames`
where assets are emitted) explicitly. Vite's hashed defaults would 404,
since Rust and the templates request `js/bundle.js` and `css/theme.css`.
- **`emptyOutDir: false` whenever `outDir` is outside the Vite root or holds
committed files.** Never let it default to true there: it would wipe them.
- Output stays gitignored, so `.gitignore`, `.containerignore`, and the Rust
side need no edits; home's startup check for `js/bundle.js` and
`css/theme.css` keeps passing.
- **CSS is never imported from TS** (no `import "./theme.css"`); the HTML
links the stylesheet. How the CSS file is produced is per repo; read the
repo's `vite.config.ts` before changing it.
- Copy whole `js/` and `css/` directories in Containerfiles, not single files,
so code-split chunks ship too ([[app-containers-and-ci]]).
- Where a repo serves an `index.html` entry (brain's dashboard), use plain app
mode with `index.html` as input, `base` set to the mount path, and `outDir:
"dist"` with the default `emptyOutDir`.
- `vp dev` must work too, and the decorators plugin must cover build, dev, and
test ([[decorators-standard-accessor]]).
Check the repo's own migration PR for its final values before editing; these
are the conventions it followed, not a config to paste.
@@ -0,0 +1,39 @@
---
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
everything explicit:
- **`uid`/`gid`** of the profile for anything an agent profile runs (each
profile is its own UID, 20000+). Never drop them.
- **`env`** is the whole environment the child gets (a sanitized one for
agents); nothing from the coordinator is merged in.
- **`stdio`**: stdin is `"ignore"` so an agent CLI never waits on input
(only `codex login --with-api-key` pipes the token in). Output that is not
read is `"ignore"`; output that is read is `"pipe"` and streamed.
- No `execFile`/`exec`: their `maxBuffer` (1 MiB) kills a chatty child. Read
piped streams incrementally instead.
`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
`128 + signo` as a shell does, and rejects on a failed start. The rejection
is pre-caught so it is not reported as unhandled before the caller awaits it.
- **`echo(stream, chunk)`**: writes and waits for `drain` when the buffer is
full, so echoing never outruns a slow reader.
Tests use `spawnSync` for short commands.
Other port details:
- **`src/common/errors.ts`** `isNotFound` checks `ENOENT` (file) or `ESRCH` (process).
- Temporary files are an exclusive `wx` create with mode `0600`.
- `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]]).
Related: how each agent CLI reports bad credentials is in
[[agent-cli-auth-failures]].
@@ -1,20 +0,0 @@
---
description: bot-agents lint has prefer-readonly-parameter-types off (Node/web types can't satisfy it) and one scripts/** override for no-unsafe-enum-comparison; nothing else
---
The repo follows [[lint-config-shape]] with two recorded exceptions, both
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 one `overrides` entry, for `scripts/**` only:
`typescript/no-unsafe-enum-comparison` off, because the lint plugin compares
AST node `type`s, which the AST types declare as an enum. This is the
tooling exception [[lint-overrides-policy]] allows.
Oxlint's default size limits apply to `src/`; the migration split `run.ts`
into `run-context.ts`, `agents.ts`, and `agent-{claude,codex,opencode}.ts`,
and `post.ts` into `post.ts` + `post-artifacts.ts`, rather than raising them.
Sequential awaits in loops became recursion or a `reduce` chain 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+, Deno kept only for agents, no @cunarist plugins needed description: bot-agents migration decisions: live @main action for six repos, JSON profiles, Node 24.2+, lints with eslint-plugin-import-structure and explicit-types (concrete, absence)
--- ---
Decisions for `commons/bot-agents` (Capsulizers Gitea), from the Deno to Decisions for `commons/bot-agents` (Capsulizers Gitea), from the Deno to
@@ -12,16 +12,20 @@ Node + Vite+ migration (PR #20, `node-migration`, 2026-10-15/16):
consumer (`uses: .../commons/bot-agents@<branch>`) on a throwaway PR. consumer (`uses: .../commons/bot-agents@<branch>`) on a throwaway PR.
- **Profiles are JSON.** `profiles.yml` became `profiles.json` with the same - **Profiles are JSON.** `profiles.yml` became `profiles.json` with the same
content (encrypted `v1:` values unchanged), read with `JSON.parse`. That content (encrypted `v1:` values unchanged), read with `JSON.parse`. That
removed the only runtime dependency (`@std/yaml`); keep it dependency-free. removed the only runtime dependency (`@std/yaml`); keep it free of runtime
- **Node 24.2+**, not 22: `src/post.ts` uses `import.meta.main`. `engines.node` dependencies (`@eslint/core` and the other devDependencies serve only the
is `>=24.2`, which the user accepted for this repo tooling).
([[bot-agents-runtime-no-build]]). - **Node 24.2+**, not 22: `src/post/index.ts` uses `import.meta.main`. There is no
- **Deno stays in the action's global npm install** (`action.yml`), only `engines` (user decision 2026-10-18): the `node:24-bookworm` job image
because the agents still check Deno-based consumer repos (memona, decides the version ([[bot-agents-runtime-no-build]]).
memona-home). Remove it once those repos have moved; it is not a - **Published lint plugins** (2026-10-18, after they reached npm):
dependency of this repo's TypeScript. `eslint-plugin-import-structure` (`modules` config) and
- **No `@cunarist/eslint-plugin-*`.** The Deno setup enabled no plugin, so `eslint-plugin-explicit-types` (`concrete` and `absence` configs) are
this repo depends on none and CI passes without them being published. devDependencies, enabled in `vite.config.ts` through their `all` configs.
`upper-snake-string-unions` was off while the user decided; since
2026-10-19 it is on and profile types, failure reasons, and anchor sides
are `UPPER_SNAKE` ([[enum-values-upper-snake]]). `eslint-plugin-lit-robust`
does not apply (no Lit).
- **Stricter input checks were accepted** in the migration: Gitea `/user` and - **Stricter input checks were accepted** in the migration: Gitea `/user` and
comment responses, the runtime manifest, and the outcome's profile type are comment responses, the runtime manifest, and the outcome's profile type are
validated; some type errors became `TypeError` with the same messages. validated; some type errors became `TypeError` with the same messages.
@@ -1,25 +0,0 @@
---
description: src/command.ts reproduces Deno.Command on child_process.spawn (stdio defaults, clearEnv, uid/gid, 128+signo); src/errors.ts maps NotFound to ENOENT/ESRCH
---
The Deno code was ported one for one; two small modules keep the old shapes
so callers barely changed:
- **`src/command.ts`** wraps `child_process.spawn` with `Deno.Command`'s API:
`output()` and `spawn()`, `clearEnv`, `uid`/`gid` (the agent isolation runs
each profile as its own UID, 20000+), and Deno's stdio defaults: stdin
inherited; stdout/stderr piped for `output()`, inherited for `spawn()`. A
signal exit maps to status `128 + signo`. Status settles on process exit,
not on stream close. Use it instead of calling `node:child_process`
directly so isolation and status handling stay uniform.
- **`src/errors.ts`** replaces `Deno.errors.NotFound` checks with an
`ENOENT`/`ESRCH` code check.
- `makeTempFile` became an exclusive `wx` create with mode `0600`.
- `src/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` maps Deno's
`no-window`/`no-process-global`/`no-node-globals` there
([[lint-parity-never-shrinks]]).
Related: how each agent CLI reports bad credentials is in
[[agent-cli-auth-failures]].
@@ -10,22 +10,22 @@ dependency**. Hence `tsconfig.json` has `erasableSyntaxOnly` and
properties, no decorators) and relative `.ts` imports. properties, no decorators) and relative `.ts` imports.
- **Image:** the steps assume the `node:24-bookworm` job container. Node must - **Image:** the steps assume the `node:24-bookworm` job container. Node must
be 24.2+ for `import.meta.main` in `post.ts`; `check.yml` prints be 24.2+ for `import.meta.main` in `src/post/index.ts`. That image is the only Node
`node --version` to confirm the cached image's minor. 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 - **Root-owned source copy:** `scripts/protect-source.sh` copies the action
into `$RUNNER_TEMP` so an agent editing the checkout can't swap a later into `$RUNNER_TEMP` so an agent editing the checkout can't swap a later
phase. It copies exactly `src/*.ts`, `package.json` (`"type": "module"` phase. It copies exactly `src/*.ts`, `package.json` (`"type": "module"`
makes Node load the sources as ES modules), `profiles.json`, and makes Node load the sources as ES modules), `profiles.json`, and
`prompt.md`. **A new runtime file or subdirectory must be added there**, or `prompt.md`. **A new runtime file or subdirectory must be added there**, or
it is missing at run time. `src/` is flat; a subfolder would not be copied. it is missing at run time. Runtime code in `src/` is flat; a subfolder would
not be copied. The only subfolder, `src/testing/`, is test-only.
- `tests/entrypoints.test.ts` runs each phase under real Node with a cleared - `tests/entrypoints.test.ts` runs each phase under real Node with a cleared
environment and expects its first "X is not set" error, which proves the environment and expects its first "X is not set" error, which proves the
whole import graph loads without a build. Keep it in sync with the phases. whole import graph loads without a build. Keep it in sync with the phases.
- **None of the app build conventions apply here:** no `vp build`, no - **None of the app build conventions apply here:** no `vp build`, no
frontend or Rust/Tauri output paths, no Containerfile, no decorators frontend or Rust/Tauri output paths, no Containerfile, no decorators
plugin, no lit-check, no generated JS ([[app-vite-build-output]], plugin, no lit-check, no generated JS. Don't add any of them.
[[app-containers-and-ci]], [[app-generated-js-as-ts]] describe the other
apps). Don't add any of them.
- **CI** (`.gitea/workflows/check.yml`) runs on the self-hosted runner's - **CI** (`.gitea/workflows/check.yml`) runs on the self-hosted runner's
default image as root: `npm ci`, `npx vp check`, `npx vp test`. It does not default image as root: `npm ci`, `npx vp check`, `npx vp test`. It does not
use `setup-vp`. YAML stays declarative ([[yaml-stays-declarative]]). use `setup-vp`. YAML stays declarative ([[yaml-stays-declarative]]).
+13 -10
View File
@@ -1,22 +1,25 @@
--- ---
description: Isolation and credential tests need root and use test.skipIf(!IS_ROOT); locally 5 skip, CI runs as root so all 61 run; verify isolation changes in node:24-bookworm as root description: Isolation and credential tests need root and use test.skipIf(!IS_ROOT); locally 5 skip, CI runs as root so all 66 run; verify isolation changes in node:24-bookworm as root
--- ---
Some tests exercise real UID isolation, process cleanup, and `prepare.ts` Some tests exercise real UID isolation, process cleanup, and the prepare phase's
credential cleanup. They need root and are written as credential cleanup. They need root and are written as
`test.skipIf(!IS_ROOT)(...)` (`IS_ROOT` from `src/testing.ts`), so they show `test.skipIf(!IS_ROOT)(...)` (`IS_ROOT` from `#testing`), so they show
as skipped instead of silently passing. as skipped instead of silently passing.
- As a normal user `vp test` passes 56 and skips 5. In CI (self-hosted - As a normal user `vp test` passes 61 and skips 5. In CI (self-hosted
runner, root) all 61 run. runner, root) all 66 run.
- When changing `isolation.ts`, `runtime.ts`, `credentials.ts`, `prepare.ts`, - When changing `isolation.ts`, `runtime.ts`, `credentials.ts`, `prepare/index.ts`,
or `command.ts`, run the suite as root in `node:24-bookworm` (e.g. a or `child.ts`, run the suite as root in `node:24-bookworm` (e.g. a
container with the repo mounted) before pushing; a green non-root run container with the repo mounted) before pushing; a green non-root run
proves nothing about those paths. proves nothing about those paths.
- The migration also ran an end-to-end smoke as root with fake - The migration also ran an end-to-end smoke as root with fake
`codex`/`claude`/`npx` binaries and a fake Gitea through `codex`/`claude`/`npx` binaries and a fake Gitea through
`protect-source.sh` -> `prepare.ts` -> `run.ts` -> `post.ts`; repeat that `protect-source.sh` -> `prepare/index.ts` -> `run/index.ts` -> `post/index.ts`; repeat that
for changes to the phase flow ([[bot-agents-runtime-no-build]]). for changes to the phase flow ([[bot-agents-runtime-no-build]]).
- `src/testing.ts` holds the shared test helpers; tests follow - `src/testing/index.ts` holds the shared test helpers (user IDs, temp dirs,
[[tests-layout-vitest]] (unit tests in `src/*.test.ts`, repo-level ones in `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 next to their code in `src/`, repo-level ones in
`tests/`). `tests/`).
@@ -1,44 +0,0 @@
---
description: Keep TC39 standard decorators with accessor; Oxc can't lower them, so scripts/decorators.ts (esbuild Vite plugin) does; never legacy decorators
---
Applies to every repo that uses decorators (the Lit frontends).
**Decision (2026-10-16):** keep standard (TC39) decorators with `accessor`,
exactly as the Deno code had them. No legacy decorators.
```ts
@customElement("app-card")
export class AppCard extends LitElement {
@property() accessor name = ""; // keep this form
}
```
**Gotcha, verified 2026-10-16:** Vite 8's Oxc transformer parses standard
decorators but can **not** lower them, so `vp build`, `vp dev`, and `vp test`
emit decorator syntax browsers and Node don't run. Check again on each Vite+
upgrade; when Oxc can lower them, delete the plugin and the `esbuild`
devDependency.
**Fix in use:** `scripts/decorators.ts`, a small Vite plugin backed by
`esbuild` (devDependency), registered in `vite.config.ts` `plugins` so it
covers build, dev server, and Vitest alike:
- `enforce: "pre"`, so it runs before Oxc;
- transforms only `.ts` modules whose source contains `@`;
- `esbuild.transform(code, { loader: "ts", target: "es2022", sourcemap,
sourcefile: id })`, returning `{ code, map }`.
It is tooling under `scripts/`: no tests, and it must pass every lint rule.
**The options as presented to the user:**
| Option | What | Why not |
| ------ | ------------------------------------------------------------------------------------------------------- | ----------------------------------------------- |
| A | Legacy decorators: two tsconfig flags (`experimentalDecorators`, ...) and a codemod removing `accessor` | the user ruled out legacy decorators |
| B | Wait for Oxc to lower `accessor` decorators | not available yet; blocks the migration |
| C | No decorators: `static properties = {...}` + `declare` fields | rewrites every component |
| D | A transform step in the Vite build | chosen, with esbuild as the transformer (above) |
Don't add `experimentalDecorators` to `tsconfig.json`, and don't strip
`accessor` from fields.
-45
View File
@@ -1,45 +0,0 @@
---
description: Every repo moved from Deno to Node + Vite+ in Oct 2026 because Cloudflare bought Deno; nothing goes back to Deno
---
On 2026-10-12 the user moved every project from Deno to Node.js + Vite+
(`vp`). Cloudflare bought Deno on 2026-10-09; the runtime gets roughly 12
months of maintenance and Deno Deploy shuts down around April 2027. None of the
repos used Deploy or KV, so the move is about the toolchain, not hosting.
Do not reintroduce `deno.json`, `deno.lock`, `Deno.*` APIs, `jsr:` or `npm:`
specifiers, `mod.ts` entries, or `deno task` anywhere, docs and scripts
included.
## Order the migration followed
1. The custom Deno lint plugins were ported to npm first, as
`@cunarist/eslint-plugin-explicit-types`, `-import-check`, and `-lit`.
`cunarist/eslint-plugin-explicit-types` is the template every other repo
copies (config shape, `scripts/project-lint.ts`, AGENTS.md, notes).
2. Then `commons/bot-agents` (six repos use it as an action), then the apps:
home, eximium, stockvibe, brain, memona, memona-home.
3. deno-practice, interview-practice, web-landing, and vemium were left out
(archivable). Old `cunarist/deno-*` repos are archived one at a time, and
only after their `eslint-plugin-*` replacement is published to npm.
## What "migrated" means
- Same behavior. A migration PR changes tooling, not features.
- Lint coverage is at least what Deno enforced: [[lint-parity-never-shrinks]].
- Only three config files and only `.ts`: [[toolchain-node-vite-plus]],
[[ts-only-files-and-imports]].
- Deno.test suites became Vitest: [[tests-layout-vitest]].
The custom plugins' Deno names map like this:
| Deno | npm |
| ----------------------------------------------- | ---------------------------------------- |
| `jsr:@cunarist/deno-lint-plugin-explicit-types` | `@cunarist/eslint-plugin-explicit-types` |
| `jsr:@cunarist/deno-import-check` (`./lint`) | `@cunarist/eslint-plugin-import-check` |
| `jsr:@cunarist/deno-lint-plugin-lit` | `@cunarist/eslint-plugin-lit` |
| `scripts/deno-lint-plugin.ts` (per app) | `scripts/project-lint.ts` (per app) |
Until those packages are on npm, a repo that depends on them fails `npm ci` in
CI; that is expected and stated in the PR, never worked around by committing
tarballs or `file:` dependencies.
+48
View File
@@ -0,0 +1,48 @@
---
description: Enum-like string values are UPPER_SNAKE everywhere this repo owns the format, JSON included; external standards are converted at the boundary with explicit switch functions
---
Decided 2026-10-19 (all repos). An enum-like string value, a member of a
string-literal union, is `UPPER_SNAKE` wherever this repo owns the format:
TypeScript unions, `profiles.json`, the runtime outcome file, and the
`anchors.json` the agent writes per `prompt.md`. explicit-types
`upper-snake-string-unions` enforces it and stays on.
A value an outside party defines (a CLI flag, an environment variable name,
an HTTP API field, a human-readable message) is converted at the boundary by
an explicit `switch` function, never by renaming our own values to match it:
```ts
export interface ProfileIdentity {
type: "CLAUDE" | "CODEX" | "OPENCODE";
model: string;
}
// The agent name the skills installer knows each profile type by.
function skillsAgent(type: RuntimeProfile["type"]): string {
switch (type) {
case "CLAUDE":
return "claude-code";
case "CODEX":
return "codex";
case "OPENCODE":
return "opencode";
}
}
```
In this repo:
- Profile `type`: `CLAUDE`, `CODEX`, `OPENCODE` (also in `profiles.json`).
`skillsAgent()` (run phase) and `tokenVariable()` (profiles) map them to the
installer's agent name and the credential environment variable;
`defaultModel()` (credentials) picks a model.
- `FailureReason`: `SIGN_IN_FAILED`, `USAGE_EXHAUSTED`, `FAILED`; `toMessage()`
gives the words that logs and posted comments show.
- Anchor `side`: `NEW` or `OLD` (`prompt.md` tells the agent so); the Gitea
review call turns it into `new_position` / `old_position`.
A lookup object keyed by enum values would need `UPPER_SNAKE` keys, which
explicit-types `no-upper-snake-object-keys` bans, so use a `switch`. TypeScript
checks such a switch for exhaustiveness through the declared return type; no
`default:` branch ([[lint-config]]).
-1
View File
@@ -9,7 +9,6 @@ description: Work on a branch, plain imperative commits without AI trailers, one
- Commit messages are plain imperative sentences ("Port the import-check - Commit messages are plain imperative sentences ("Port the import-check
rules to an ESLint plugin"). No conventional-commit prefixes, no AI rules to an ESLint plugin"). No conventional-commit prefixes, no AI
trailers (`Co-Authored-By: Claude`, "Generated with ..."), no emoji. trailers (`Co-Authored-By: Claude`, "Generated with ..."), no emoji.
- A version bump is its own commit titled `Version X.Y.Z` ([[publishing-ci-only]]).
- When several agents share a branch, each works in its own `git worktree`, - When several agents share a branch, each works in its own `git worktree`,
runs `git pull --rebase` before pushing, and retries a rejected push after runs `git pull --rebase` before pushing, and retries a rejected push after
rebasing. Remove the worktree and temp branch afterwards. rebasing. Remove the worktree and temp branch afterwards.
-43
View File
@@ -1,43 +0,0 @@
---
description: Icons are .ts modules exporting a typed Lit svg template (memona's style), one per file behind an index.ts; never .svg imports as text
---
memona's way of declaring icons is the standard (2026-10-14). An icon is a
`.ts` module that exports a Lit `TemplateResult` holding the inline SVG:
```ts
// src/icons/lock.ts
import { html, type TemplateResult } from "lit";
/** Font Awesome Free's solid `lock`, as its SVG source. */
export const lockIcon: TemplateResult = html`<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 384 512"
>
<path fill="currentColor" d="M128 96l0 64 ..." />
</svg>`;
```
```ts
// usage
import { lockIcon } from "#icons";
render() { return html`<span class="icon">${lockIcon}</span>`; }
```
- One icon per file, camelCase name with an `Icon` suffix, a doc comment
saying what it depicts, exported through `src/icons/index.ts`.
- Use `fill="currentColor"` so CSS controls the colour.
- Keep a third-party licence comment that the SVG source carries (Font
Awesome Free's `<!--! ... -->` notice) inside the template.
**Replaced pattern:** eximium, stockvibe, and memona-home imported Font
Awesome `.svg` files as text in `icons.ts`:
```ts
import lock from "@fortawesome/fontawesome-free/svgs/solid/lock.svg" with { type: "text" }; // no
```
That breaks [[ts-only-files-and-imports]] (non-`.ts` import plus import
attributes) and was converted to memona's style during the frontend
migration. Before writing new icon code in any repo, read how that repo (or
memona) already declares icons and match it.
+82 -25
View File
@@ -1,48 +1,105 @@
--- ---
description: Shared code comes through # aliases in package.json imports, each directory's entry is index.ts, barrels keep imports few; never ../ out of a directory description: An alias owns its target (folder index.ts = folder and below, any other file = that file); no overlap; outside an area only the exact alias reaches it; ../ is banned in every file; locally, aliases target only direct src/ subfolders
--- ---
## `#` aliases ## `#` aliases own their targets
Shared code is imported through Node subpath imports declared in Shared code is imported through Node subpath imports declared in
`package.json` `imports`, never by a relative path that climbs out of a `package.json` `imports`. Node, TypeScript, Vite, Vite+'s config bundler, and
directory: Oxlint's plugin loader all resolve them natively, so `.ts` source runs
unbuilt. `vp pack` inlines them, so they never reach package consumers.
One rule for every file, never tied to folder or file names (user rule
2026-10-17; it replaces the earlier "aliases only point at direct `src/`
subfolders" rule as the general rule, and the "import-structure plugin
checks only aliased folders" scope; this repo keeps the stricter rule as a
local convention, see the last section):
- **An alias may point anywhere** and owns its target. An alias to a
folder's `index.ts` owns that folder and everything below it; an alias to
any other file owns only that file.
- **No overlap.** No file may be owned by 2 aliases: no alias inside another
alias's area, and no 2 aliases with the same target. The import-structure
plugin's `enforce-layer-order` reports it once at the top of every file 2
aliases claim. Fix by merging the aliases or moving files apart.
- **Outside an area, only the exact alias reaches it.** A deeper alias
specifier (`#a/one.ts`, `no-barrel-bypass`) and a relative path into the
area (`./a/one.ts`, `no-relative-bypass`; `./b/f.ts` to a file alias,
`prefer-alias-import`, fixed to the alias) are bypasses.
- **Inside an area, relative imports of siblings and subfolders are free.**
- **A `../` import is banned in every file**, owned or not (`no-parent-import`).
Runtime file URLs (`new URL("../prompt.md", import.meta.url)`) are not
imports and are allowed.
Anything above a file needs an alias.
- **Files no alias owns** need no `index.ts` and no alias, and import `./`
siblings and subfolders freely.
- Unchanged: no `/*` subpath patterns, the key order of `imports` is the
layer order `enforce-layer-order` checks (add a new alias at the right
layer), and entry files are `index.ts` only.
```jsonc ```jsonc
"imports": { "imports": {
"#helpers": "./src/helpers/index.ts", "#a": "./a/index.ts", // owns a/**
"#components/*": "./src/components/*" "#f": "./b/f.ts", // owns only b/f.ts
"#a-sub": "./a/sub/index.ts", // no: inside #a
"#a2": "./a/index.ts" // no: same target as #a
} }
``` ```
```ts ```ts
import { typeNameTail } from "#helpers"; // yes // in main.ts, which no alias owns
import { typeNameTail } from "../helpers/ast.ts"; // no: import/no-relative-parent-imports import { a } from "#a"; // yes
import { one } from "#a/one.ts"; // no: deeper alias specifier
import { one } from "./a/one.ts"; // no: relative path into #a
import { f } from "#f"; // yes
import { f } from "./b/f.ts"; // no: relative path to a file alias
import { g } from "./b/g.ts"; // yes: unowned
// in a/one.ts, owned by #a
import { two } from "./two.ts"; // yes: sibling
import { deep } from "./sub/deep.ts"; // yes: subfolder of the same area
import { x } from "../x.ts"; // no: no ../ anywhere
``` ```
Node, TypeScript, Vite, Vite+'s config bundler, and Oxlint's plugin loader
all resolve them natively, so `.ts` source runs unbuilt. `vp pack` inlines them,
so they never reach package consumers.
- Deno's trailing-slash mappings (`"#components/"`) don't exist in Node; they
become patterns, `"#components/*": "./src/components/*"`.
- Aliases point at `.ts` files only. There is no `#package` alias - Aliases point at `.ts` files only. There is no `#package` alias
([[ts-only-files-and-imports]]). ([[ts-only-files-and-imports]]).
- In repos linted by import-check, the key order of `imports` is the layer - Lint `jsPlugins` entries in `vite.config.ts` stay path strings, even when
order `enforce-layer-order` checks; add a new alias at the right layer. the file has an alias.
- Keep test-only helpers in their own alias (e.g. `#testing`) so they never - Keep test-only helpers in their own alias (e.g. `#testing`) so they never
land in a bundle. land in a bundle.
## `index.ts` entries and barrels ## Barrels
- Every directory's entry file is `index.ts`, never `mod.ts` (decided - An area used from outside exposes its API through its alias target, and
2026-10-12; import-check's `enforce-mod-file` now requires `index.ts`). callers import the alias (`#ui`), not files inside it.
- A directory used from outside exposes its API through its `index.ts`
barrel, and callers import the barrel (`#ui`), not files inside it
(`no-barrel-bypass`).
- Barrels are also the standard fix for `import/max-dependencies`: one barrel - Barrels are also the standard fix for `import/max-dependencies`: one barrel
import replaces many direct ones ([[lint-overrides-policy]]). import replaces many direct ones ([[lint-config]]).
- `import/no-cycle` is on; a barrel must not create a cycle. - `import/no-cycle` is on; a barrel must not create a cycle.
Relative imports inside one directory (`./sibling.ts`) are fine, with the Relative imports are written with the `.ts` extension.
`.ts` extension written out.
## `scripts/` is flat (user rule, 2026-10-16)
- `scripts/` has no subfolders and no `index.ts`.
- Each tool is one file, `scripts/<name>.ts`, imported directly by file:
`import projectLint from "./scripts/project-lint.ts";` in
`vite.config.ts`. No alias owns `scripts/`, so these `./` imports are free.
- Code in a former `scripts/<tool>/` folder is merged or renamed into flat
`scripts/<name>.ts` files. Non-TS assets (HTML, images, fixtures) move to
where they belong (next to the tool's output, `.agents/`, or a gitignored
scratch dir). A subfolder that is really an app moves under an alias.
## Local convention: aliases target direct `src/` subfolders
This is a convention of this repo only, stricter than the general rule above
(which the published import-structure plugin checks and which allows more).
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 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.
- No lint rule checks this; check `imports` by hand when adding an alias.
-47
View File
@@ -1,47 +0,0 @@
---
description: vite.config.ts lint uses Oxlint categories at error with default 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: ["@cunarist/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.
- **Default limits stay** (2026-10-16): pedantic's `max-lines` 300,
`max-lines-per-function` 50, `max-classes-per-file` 1,
`max-dependencies` 10, `max-params` 3, `complexity` 20. Don't raise them to
make code pass; refactor (see [[lint-overrides-policy]]). Apps follow this
strictly. The plugin template already turns off `max-lines`,
`max-lines-per-function`, `typescript/prefer-readonly-parameter-types`, and
`typescript/no-unsafe-enum-comparison` because AST visitors fight them; 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; import-check'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.
+102
View File
@@ -0,0 +1,102 @@
---
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,
```
## Rule decisions (2026-10-19, all repos)
- `typescript/promise-function-async` is on with `require-await` and
`typescript/return-await`. A function that returns a promise is `async`
and awaits it: `async f() { const x = await g(); return x; }`. Test
doubles do the same (`const resolved = await Promise.resolve(v); return
resolved;`), and `expect(promise).rejects` takes the promise itself.
`src/processes/child.ts` is exempt in `projectOverrides`: `exitCode` must
return the very promise it marks handled, or a failed spawn would surface
as an unhandled rejection before callers await it.
- `init-declarations` is on: an uninitialized `let` becomes
`let x: T | null = null;` (`unknown` already includes `null`); a `try` that
assigns and a `catch` that exits still narrow it.
- `no-use-before-define` is on with only `{ functions: false }`.
- `no-await-in-loop` is off (sequential awaits are intentional) and
`unicorn/no-array-reduce` is on: sequential steps are plain `for` loops,
never `reduce` chains or recursion.
- `consistent-return`, `typescript/switch-exhaustiveness-check`, and
`default-case` are off: TypeScript's checks cover them, and no `default:`
branch is added to an exhaustive switch.
- `unknown` is allowed; `any` is banned and the `typescript/no-unsafe-*`
rules are on. Untrusted data is `unknown` plus a type guard.
- `unicorn/no-array-for-each` (use `for...of`),
`unicorn/prefer-structured-clone`, and `typescript/prefer-nullish-coalescing`
(defaults) stay on.
- explicit-types `upper-snake-string-unions` is on
([[enum-values-upper-snake]]); `unicorn/custom-error-definition` is on, so a
custom error has a constructor that sets `this.name`.
- `src/post/` and `src/profiles/` (code and tests) are exempt from
`project/prefer-ascii` in `projectOverrides`: posted Gitea text shows emoji
verdict marks and a middle dot.
## Rule decisions (2026-10-20, all repos)
- `unicorn/number-literal-case` is off: Oxfmt lowercases hex digits back.
- `typescript/consistent-return` is off too: it misfires on complete
switches, like `consistent-return`.
- On with defaults, no offs: `prefer-object-spread`, `unicorn/prefer-spread`,
`unicorn/no-useless-spread`, `typescript/no-misused-spread`, `no-plusplus`
(`x += 1`), `unicorn/no-array-method-this-argument`,
`typescript/method-signature-style` ("property"),
`typescript/unified-signatures`.
- `unicorn/import-style` uses its default: `import nodePath from "node:path"`
and `nodePath.join(...)` (`nodePath`, since `path` is a common variable).
- `no-template-curly-in-string` is on; only `tests/workflow.test.ts`, which
checks literal Gitea `${{ }}` expressions, is exempt in `projectOverrides`.
- Rule defaults are preferred over option tweaks unless a tweak is needed.
- `fmt` matches `deno fmt`: `printWidth: 80`, `quoteProps: "preserve"`,
`embeddedLanguageFormatting: "off"`. `max-lines-per-function` is 124 after
the narrower width.
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]].
-45
View File
@@ -1,45 +0,0 @@
---
description: No lint overrides for source files; narrow ones only for scripts/**; generated files go in ignorePatterns; refactor (barrels, splits) instead of raising limits
---
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
Oxlint's defaults stay; violations are refactored without behavior change,
covered by the tests, inside the same migration PR.
| Rule | Usual fix |
| ------------------------- | ------------------------------------------------------- |
| `import/max-dependencies` | import a barrel (`#ui`) instead of many direct modules |
| `max-lines` | split the file by responsibility |
| `max-lines-per-function` | extract helpers; split a long Lit template into parts |
| `max-classes-per-file` | one class per file |
| `max-params` | pass one options object |
| `prefer-set-has` | `const K: ReadonlySet<string> = new Set([...])`, `.has` |
```ts
// before: 14 imports, over the limit of 10
import { a } from "#ui/button";
import { b } from "#ui/dialog";
// after: one barrel
import { a, b } from "#ui";
```
Measured on memona before the move (Oxlint 1.87, default limits): 921
pedantic hits in 274 files, of which `max-lines-per-function` 206,
`max-lines` 145, `max-classes-per-file` 59, `max-dependencies` 45 (median 14
imports, worst 29). The user chose refactoring over raising limits or
matching memona's old hugoalh limits (2048 lines per file, no function limit).
+1 -1
View File
@@ -29,7 +29,7 @@ Before removing any line from `configs.project`, find what else covers it.
in packages. in packages.
- **Vacuous:** JSX rules (no `.tsx` exists) and Deno-only rules. - **Vacuous:** JSX rules (no `.tsx` exists) and Deno-only rules.
- **`@hugoalh` rules** map to Oxlint built-ins in `configs.project`. - **`@hugoalh` rules** map to Oxlint built-ins in `configs.project`.
- **import-check's CLI cycle check** → `import/no-cycle`. - **deno-import-check's CLI cycle check** → `import/no-cycle`.
The template's list in `configs.project` is the baseline to copy: `curly`, The template's list in `configs.project` is the baseline to copy: `curly`,
`guard-for-in`, `no-console`, `no-empty`, `no-regex-spaces`, `guard-for-in`, `no-console`, `no-empty`, `no-regex-spaces`,
-40
View File
@@ -1,40 +0,0 @@
---
description: lit-analyzer runs from scripts/lit-check.ts, which maps "typescript" to the aliased typescript-6 devDependency; npm run lit-check in CI
---
Applies to Lit repos that ran `cunarist/deno-lit-analyzer` under Deno.
lit-analyzer is built on TypeScript's JavaScript compiler API and is run
against TypeScript 6 (the open question was whether it works with TS 7 /
tsgo). The repo installs TS 6 under another name and redirects lit-analyzer
to it, so the rest of the toolchain is unaffected (decided 2026-10-16):
```jsonc
// package.json
"scripts": { "lit-check": "node scripts/lit-check.ts" },
"devDependencies": { "typescript-6": "npm:typescript@^6", "lit-analyzer": "..." }
```
```ts
// scripts/lit-check.ts (shape)
import { registerHooks } from "node:module";
registerHooks({
resolve(specifier, context, next) {
// when lit-analyzer asks for "typescript", hand it TS 6
return next(specifier === "typescript" ? "typescript-6" : specifier, context);
},
});
// then run lit-analyzer
```
- Node runs the `.ts` script directly (type stripping), so no build step.
- **Gotcha:** the obvious `await import("lit-analyzer/cli.js")` fails
`project/no-non-ts-imports` (a `.js` subpath). Import the package entry, or
spawn the package's bin with `node:child_process`, instead.
- CI runs `npm run lit-check` as its own step.
- It is tooling under `scripts/`: no tests; a narrow `overrides` entry for it
(e.g. `no-console`) is allowed if truly needed.
Don't replace this with a global TypeScript downgrade, a second
`tsconfig`, or a `.js` wrapper script.
+17 -28
View File
@@ -1,46 +1,29 @@
--- ---
description: package.json key order is metadata, scripts, exports, imports, devDependencies, dependencies; standard fields only; no peerDependencies or packageManager description: package.json keys follow Oxfmt's standard order (vp check --fix); standard fields only; no peerDependencies or packageManager
--- ---
## Key order (2026-10-14) ## Key order (2026-10-16)
Metadata first, then `scripts`, `exports`, `imports`, `devDependencies`, Oxfmt's standard `package.json` sorting is accepted: there is no
`dependencies`. Oxfmt would re-sort the file, so `vite.config.ts` sets `fmt: { sortPackageJson: false }`, and `vp check --fix` sets the order. This
`fmt: { sortPackageJson: false }`; keep it, and keep the order by hand. replaces the earlier hand-written order (metadata, `scripts`, `exports`,
`imports`, `devDependencies`, `dependencies`).
```jsonc The `imports` key order can carry meaning: the import-structure plugin's
{
"name": "@cunarist/eslint-plugin-x",
"version": "0.1.0",
"description": "...",
"keywords": ["eslint", "eslint-plugin", "eslintplugin", "oxlint"],
"license": "MIT",
"repository": { "type": "git", "url": "git+https://github.com/..." },
"type": "module",
"files": ["dist"],
"engines": { "node": ">=22" },
"eslint-doc-generator": {/* tool options that can only live here */},
"scripts": { "docs": "eslint-doc-generator" },
"exports": { ".": "./dist/index.js" },
"imports": { "#helpers": "./src/helpers/index.ts" },
"devDependencies": { "vite-plus": "^1.1.0" },
"dependencies": {},
}
```
The `imports` key order can carry meaning: import-check's
`enforce-layer-order` reads it as the layer order. Never reorder it casually. `enforce-layer-order` reads it as the layer order. Never reorder it casually.
## Fields ## Fields
- **Standard fields only.** `engines` has only `node` (never `engines.bun`). - **Standard fields only.** App repos have no `engines` field (user decision
2026-10-18; images pin Node, [[toolchain-node-vite-plus]]). Published
packages keep `engines` with only `node` (never `engines.bun`).
A tool-owned key (`eslint-doc-generator`) is allowed only because the tool A tool-owned key (`eslint-doc-generator`) is allowed only because the tool
reads nothing else and no other config file is allowed. reads nothing else and no other config file is allowed.
- **No `packageManager`.** npm is detected from `package-lock.json`. - **No `packageManager`.** npm is detected from `package-lock.json`.
- **No `peerDependencies`, ever** (user preference, 2026-10-14). If something - **No `peerDependencies`, ever** (user preference, 2026-10-14). If something
is needed at runtime, it goes in `dependencies`. is needed at runtime, it goes in `dependencies`.
- **`dependencies`** = what runs or is type-referenced at runtime by consumers - **`dependencies`** = what runs or is type-referenced at runtime by consumers
(the lit plugin ships `parse5`, `oxc-parser`, `oxc-resolver`, and (the lit-robust plugin ships `parse5`, `oxc-parser`, `oxc-resolver`, and
`@eslint/core` for its public types; they stay external, never bundled). `@eslint/core` for its public types; they stay external, never bundled).
- **`devDependencies`** = everything else: `vite-plus`, `eslint`, - **`devDependencies`** = everything else: `vite-plus`, `eslint`,
`@typescript-eslint/parser`, `eslint-doc-generator`, `esbuild`, `@typescript-eslint/parser`, `eslint-doc-generator`, `esbuild`,
@@ -49,3 +32,9 @@ The `imports` key order can carry meaning: import-check's
Refresh the lockfile with `npm install` (or `npm install Refresh the lockfile with `npm install` (or `npm install
--package-lock-only` for a version bump) and commit it with the change. --package-lock-only` for a version bump) and commit it with the change.
Alias rule: an alias may point anywhere and owns its target (a folder's
`index.ts` owns the folder and everything below it; any other file owns only
itself). No two aliases may own the same file, outside an area only the exact
alias reaches it, and `../` is banned in every file
([[imports-aliases-barrels]]).
+14 -6
View File
@@ -11,11 +11,19 @@ Oxlint plugin with `meta.name` `"project"`, loaded through `jsPlugins` in
plugin configs: Deno parity rules, extras, per-rule options, and opt-outs, plugin configs: Deno parity rules, extras, per-rule options, and opt-outs,
built-in Oxlint ids included (`curly`, `id-match`, `import/no-cycle`, ...). built-in Oxlint ids included (`curly`, `id-match`, `import/no-cycle`, ...).
- **Custom rules**, enabled as `project/<rule>`. Every repo has - **Custom rules**, enabled as `project/<rule>`. Every repo has
`no-disable-comments` and `no-non-ts-imports`. Apps also port the rules of `no-disable-comments` and `no-asset-imports`. Since 2026-10-19 every repo
their old `scripts/deno-lint-plugin.ts` here under the same names and also has `prefer-ascii`, `no-nan` (a "no number" value is `null`, never
behavior (`no-self-sync`, `app-module-conventions`, `restricted-types`, ...). `NaN`), and the hugoalh `recommended` ports from
eslint-plugin-import-structure's own lint: `no-duplicate-typeofs`,
`no-empty-yield`, `no-unsafe-number`, `no-useless-continue`,
`no-duplicate-interfaces`, `no-duplicate-types`, `no-useless-type`, and
`no-modifier-private`. `no-duplicate-awaits` and `no-duplicate-voids` are
covered by `unicorn/no-unnecessary-await` and
`typescript/no-meaningless-void-operator`, and `no-enum` by explicit-types.
Rules and plugin stay in this one file; don't add `scripts/rules/`. `scripts/` stays flat; don't add `scripts/rules/`. All rules live in the one
`scripts/project-lint.ts` (no file splitting), next to the plugin object and
the rule list.
```ts ```ts
import type { DummyRuleMap } from "vite-plus/lint"; import type { DummyRuleMap } from "vite-plus/lint";
@@ -51,5 +59,5 @@ export default plugin;
and every repo should look the same. and every repo should look the same.
`scripts/` is tooling, not production code: never packed or published, and `scripts/` is tooling, not production code: never packed or published, and
no tests. Narrow `overrides` for `scripts/**` are allowed when a script truly no tests. Its offs sit in the Node code entry of `projectOverrides`; see
needs one (e.g. `no-console`); see [[lint-overrides-policy]]. [[lint-config]].
-37
View File
@@ -1,37 +0,0 @@
---
description: Packages publish only from CI when a GitHub release vX.Y.Z is published, via npm trusted publishing; never npm publish by hand; notes live only on the release page
---
Standing convention (2026-10-12), applied to every package:
- **Only CI publishes.** Publishing a GitHub release `vX.Y.Z` triggers
`.github/workflows/publish.yml`. Never run `npm publish` locally and never
push a bare `v*` tag. Use the `release` skill, and only when the user asked
for a release.
- **The workflow** checks that the tag equals `v` + `package.json` `version`,
runs the full CI as a reusable workflow (every Node version in the matrix),
then `vp pack` and `npm publish --access public --provenance`. A release
marked pre-release publishes under the `next` dist-tag, others `latest`.
It runs on Node 24 (npm 11, which trusted publishing needs) with no cache.
- **Auth is npm trusted publishing** (OIDC, `id-token: write`), which also
attaches provenance. npm only allows a trusted publisher on a package that
already exists, so the very first release bootstraps with a short-lived
token in the `NPM_TOKEN` repo secret (the workflow uses it only when set).
Afterwards the user configures the trusted publisher for `publish.yml`,
deletes the secret, and sets the package to disallow tokens.
- **Release notes live only on the GitHub release page**
(`--generate-notes`, edited there). There is no `CHANGELOG.md`; never add
one.
- **The version lives in two places in plugin packages:** `package.json` and
`meta.version` in `src/index.ts`; `tests/package-meta.test.ts` fails if they
differ.
- **Before 1.0**, a change that breaks users (renamed rule id, a rule that
fires on more code, removed behavior) is a minor bump.
- **Before a release**, pack the package and lint a known-bad and a known-good
file from separate consumer projects, once through `vp lint` with
`jsPlugins` and once through ESLint 9 with the configs. On the lit plugin
this caught two bugs that 313 unit tests missed.
The cunarist lint plugins (explicit-types, import-check, lit) are published
together, with a token the user provides; the old `deno-*` repos are archived
only after that, one at a time.
+8 -5
View File
@@ -1,5 +1,5 @@
--- ---
description: Vitest via vp test; unit tests sit next to code as *.test.ts, regression/integration tests in tests/ via the public entry; scripts/ has no tests description: Vitest via vp test; unit tests sit next to code as *.test.ts, regression/integration tests in tests/ via the public entry (through its # alias, never ../); scripts/ has no tests
--- ---
Decided 2026-10-14 for all repos. Decided 2026-10-14 for all repos.
@@ -11,12 +11,15 @@ Decided 2026-10-14 for all repos.
config needed. Packages publish only `dist/`, so colocated tests cost nothing config needed. Packages publish only `dist/`, so colocated tests cost nothing
in the tarball. in the tarball.
- **Regression and integration tests** live in the top-level `tests/` folder - **Regression and integration tests** live in the top-level `tests/` folder
and use only the public entry point (an alias like `#plugin`), never and use only the public entry point, imported through its `#` alias (e.g.
internals. `vp test tests` runs just these. `#plugin` for `./src/plugin/index.ts`), never internals and never `../`,
which is banned in every file. `vp test tests` runs just these
([[imports-aliases-barrels]]). In bot-agents, `tests/` imports only
`#testing` (`./src/testing/index.ts`), plain helpers that import no source
module.
- **No in-source tests** (`import.meta.vitest`). - **No in-source tests** (`import.meta.vitest`).
- **`scripts/` gets no tests** (2026-10-15): it is repo tooling, not - **`scripts/` gets no tests** (2026-10-15): it is repo tooling, not
production code (`project-lint.ts`, `decorators.ts`, `lit-check.ts`, production code (`project-lint.ts`, `protect-source.sh`).
release helpers).
- Test files follow every lint rule; no relaxed overrides for tests. - Test files follow every lint rule; no relaxed overrides for tests.
## Migrating Deno tests ## Migrating Deno tests
+14 -6
View File
@@ -1,12 +1,20 @@
--- ---
description: Node.js 22+ with Vite+ (vp) and npm; only package.json, tsconfig.json, vite.config.ts plus a committed package-lock.json description: Node.js (no engines in apps; images pin 24) with Vite+ (vp) and npm; only package.json, tsconfig.json, vite.config.ts plus a committed package-lock.json
--- ---
## The stack ## The stack
- **Runtime:** Node.js 22 or newer. `engines` is `{ "node": ">=22" }` and - **Runtime:** Node.js. App repos have no `engines` field (user decision
nothing else. Containers and the publish job use Node 24 (LTS); a repo may 2026-10-18): the container or runner image decides the version, so the
need more where a feature demands it (bot-agents uses Node 24.2+). Containerfile base image (`node:24-alpine`) and CI's `node-version: "24"`
are the pins. Published packages (the lint plugins) keep their own
`engines.node` floor. `npm run lit-check` runs
`node scripts/lit-check.ts`, which needs Node 22.18+ (unflagged type
stripping and `module.registerHooks`); Node 24 satisfies it. Without
`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/index.ts` needs 24.2+ (`import.meta.main`).
- **Bun is not supported** (dropped 2026-10-15). No Bun CI legs, docs, or - **Bun is not supported** (dropped 2026-10-15). No Bun CI legs, docs, or
runtime branches. Oxlint's `RuleTester` refuses to run under Bun anyway, and runtime branches. Oxlint's `RuleTester` refuses to run under Bun anyway, and
`vp` brings its own Node, the way uv brings its own Python. `vp` brings its own Node, the way uv brings its own Python.
@@ -44,8 +52,8 @@ before any push. CI installs the toolchain with `voidzero-dev/setup-vp`.
- `vp check` does **not** report `isolatedDeclarations` errors (TS9010); only - `vp check` does **not** report `isolatedDeclarations` errors (TS9010); only
`vp pack` does, so packages run `vp pack` in CI on every PR. `vp pack` does, so packages run `vp pack` in CI on every PR.
- Oxfmt sorts `package.json` keys by default, which destroys the required key - Oxfmt sorts `package.json` keys by default; that order is accepted, so
order. Set `fmt: { sortPackageJson: false }` ([[package-json-shape]]). there is no `fmt` option for it ([[package-json-shape]]).
- Oxlint and Oxfmt skip what `.gitignore` lists; that is the only reason - Oxlint and Oxfmt skip what `.gitignore` lists; that is the only reason
`dist/` and `node_modules/` are not linted. Keep build output gitignored. `dist/` and `node_modules/` are not linted. Keep build output gitignored.
- Vite+ bundles `vite.config.ts` itself and resolves `#` imports in it. - Vite+ bundles `vite.config.ts` itself and resolves `#` imports in it.
+25 -14
View File
@@ -1,28 +1,39 @@
--- ---
description: Every file is .ts (no js/mjs/cjs/jsx/tsx, configs included) and source imports only .ts; CSS comes from HTML, JSON/SVG become .ts modules description: Every file is .ts (no js/mjs/cjs/jsx/tsx, configs included); imports name only .ts files or .js/.mts/.mjs package subpaths; CSS comes from HTML, JSON/SVG become .ts modules
--- ---
The user's rule, from the Deno days and restated for Node (2026-10-12): "No The user's rule, from the Deno days and restated for Node (2026-10-12): "No
JS, not even JSX/TSX, only TS." Source, scripts, tests, and configs are all JS, not even JSX/TSX, only TS." Source, scripts, tests, and configs are all
plain `.ts`. Build output in a gitignored folder is the only JS. plain `.ts`. Build output in a gitignored folder is the only JS.
`tsconfig.json`: no `allowJs`, no `jsx`, and the template's strict flags `tsconfig.json`: no `allowJs`, no `jsx`, and exactly this strictness set (user
(`verbatimModuleSyntax`, `erasableSyntaxOnly`, `allowImportingTsExtensions`, decision, 2026-10-18): `strict` (written out), `noUncheckedIndexedAccess`,
`isolatedDeclarations` in packages, `exactOptionalPropertyTypes`, `noPropertyAccessFromIndexSignature`, `noImplicitReturns`, `noUnusedLocals`,
`noUncheckedIndexedAccess`). If a tool or framework seems to need a JS or TSX `noUnusedParameters`, `noImplicitOverride`, `noFallthroughCasesInSwitch`,
file, stop and flag it instead of adding one. `erasableSyntaxOnly`, and `allowUnreachableCode: false` plus
`allowUnusedLabels: false` (user decision, 2026-10-18; on in the Deno setup),
plus the mechanical `verbatimModuleSyntax`, `allowImportingTsExtensions`, and
`noEmit` (packages also keep `declaration` and `isolatedDeclarations`).
`exactOptionalPropertyTypes` is off everywhere: write `{ key: maybeUndefined }`
rather than leaving the key out to please it. If a tool or framework seems to
need a JS or TSX file, stop and flag it instead of adding one.
## Only `.ts` imports (2026-10-14) ## No asset imports (2026-10-14, widened 2026-10-16 and 2026-10-17)
`project/no-non-ts-imports` (in `scripts/project-lint.ts`) fails: `project/no-asset-imports` (in `scripts/project-lint.ts`; it was
`no-non-ts-imports` before 2026-10-16) fails:
- a relative, absolute, `#`, or package-subpath specifier whose last segment - a relative, absolute, `#`, or package-subpath specifier whose last segment
has a non-`.ts` extension: `.css`, `.json`, `.svg`, `.js`, `.mjs`, `.tsx`; has an extension other than `.ts`, `.js`, `.mts`, or `.mjs`: `.css`,
`.json`, `.svg`, `.jsx`, `.tsx`, and every other asset;
- any `?query` suffix (`?raw`, `?url`, `?inline`); - any `?query` suffix (`?raw`, `?url`, `?inline`);
- any import attributes (`with { type: "json" }`, `with { type: "text" }`). - any import attributes (`with { type: "json" }`, `with { type: "text" }`).
Bare packages (`lit`, `@types/json-schema`, `lodash.debounce`), `#` aliases, `.js`, `.mts`, and `.mjs` are allowed (user decision, 2026-10-17) for package
and `node:` builtins pass. `@fortawesome/x/svgs/a.svg` fails. subpaths such as `lit/decorators.js` or `pdfjs-dist/legacy/build/pdf.mjs`;
our own files are still `.ts` only. `.jsx` and `.tsx` stay banned. Bare
packages (`lit`, `@types/json-schema`, `lodash.debounce`), `#` aliases, and
`node:` builtins pass. `@fortawesome/x/svgs/a.svg` fails.
```ts ```ts
import "./theme.css"; // no import "./theme.css"; // no
@@ -37,12 +48,12 @@ import { scenes } from "#data/scenes"; // yes: scenes.ts
import CSS from TS, not even for Vite. (This also removes most of the import CSS from TS, not even for Vite. (This also removes most of the
`import/no-unassigned-import` hits the `suspicious` category reports.) `import/no-unassigned-import` hits the `suspicious` category reports.)
- **JSON data:** turn it into a `.ts` module exporting a typed constant. - **JSON data:** turn it into a `.ts` module exporting a typed constant.
- **SVG:** a `.ts` module exporting a Lit template; see [[icons-memona-style]]. - **SVG:** a `.ts` module exporting the markup.
- **`package.json` fields at runtime:** never `import pkg from - **`package.json` fields at runtime:** never `import pkg from
"./package.json"` and there is no `#package` alias. Write the value out in "./package.json"` and there is no `#package` alias. Write the value out in
`.ts` and add a test in `tests/` that reads `package.json` with `node:fs` `.ts` and add a test in `tests/` that reads `package.json` with `node:fs`
and fails on drift (the plugin repos do this for `meta.name`/`meta.version`). and fails on drift (the plugin repos do this for `meta.name`/`meta.version`).
- **Reading a file is not importing it.** `readFileSync` + `JSON.parse` is - **Reading a file is not importing it.** `readFileSync` + `JSON.parse` is
fine in tooling and tests. fine in tooling and tests.
- **A tool's `.js` entry:** don't `import("pkg/cli.js")`; use the package - **A tool's CLI file:** prefer the package entry or spawning the package's
entry or spawn the package's bin ([[lit-analyzer-ts6]]). bin over importing `pkg/cli.js`, which runs the CLI as a side effect.
-69
View File
@@ -1,69 +0,0 @@
---
name: migrate-deno-repo
description: Checklist for moving a Deno repo (or a leftover Deno part of one) to Node 22+ + Vite+ with npm, same behavior, full lint parity, one PR
---
Template: `cunarist/eslint-plugin-explicit-types` on `main`. Read its
`AGENTS.md`, `vite.config.ts`, `scripts/project-lint.ts`, `package.json`, and
CI before starting, and copy their shape. Never ask the user mid-way when
they're away: decide by the memories, record the choice in the PR.
Branch `node-migration` (apps) or `initial-port` (new plugin repos); one PR
with everything.
**Inventory first**
- [ ] List every `deno.json` task, import-map entry, lint rule (tags,
includes, `@hugoalh`, custom plugins), test, `Deno.*` call, JSON/CSS/SVG
import, and every place the output is consumed (Rust, HTML,
Containerfile, CI).
**Toolchain**
- [ ] `package.json` in the required key order, `engines.node ">=22"`, no
`packageManager`, no `peerDependencies`; `npm install`; commit
`package-lock.json`.
- [ ] `tsconfig.json` from the template; `vite.config.ts` with `fmt:
{ sortPackageJson: false }` and the category-based `lint` block.
- [ ] Delete `deno.json`, `deno.lock`, and Deno-only files. `mod.ts` →
`index.ts`; `deno.json` imports → `package.json` `imports` (`#x/*`
patterns for trailing-slash mappings); `jsr:`/`npm:` → npm packages.
**Code**
- [ ] Only `.ts` files; no non-`.ts` imports: CSS via `<link>`, JSON and SVG
as `.ts` modules, icons in memona's style.
- [ ] `Deno.*` → `node:*` / `process`. Generated JS written as `.ts`, built,
gitignored.
- [ ] Decorators stay standard with `accessor`; add `scripts/decorators.ts`
if the repo has any. Lit repos: `scripts/lit-check.ts` + `npm run
lit-check`.
**Lint**
- [ ] `scripts/project-lint.ts` copied; old local Deno lint rules ported into
it; parity mapping for every former rule; `id-match`; no disable
comments; no source `overrides`; refactor size-rule hits.
**Tests**
- [ ] `Deno.test` → Vitest (codemod, then finish TODOs); unit tests next to
code, integration in `tests/`; no case dropped.
**Build, ship, CI**
- [ ] `vp build` in app mode with the same output paths, or `vp pack` for
packages. Containerfile node stage on `node:24-alpine`. CI with
`setup-vp`: `vp check`, build, `vp test` (+ lit-check). Packages:
release-triggered `publish.yml`.
- [ ] `AGENTS.md` with the damem header, `.agents/` notes from the damem kit,
README updated (Node.js 22+, no Deno instructions). `damem doctor`
clean.
**Finish**
- [ ] `vp check`, `vp test`, build all pass locally; the app runs.
- [ ] Commit in plain imperative sentences; open the PR (summary, changes,
parity mapping, deviations/risks, "CI fails until @cunarist/eslint-plugin-*
are published" if relevant, verification, footer line).
- [ ] Run `review-cycle`. Never merge, publish, or archive.
+1 -1
View File
@@ -10,7 +10,7 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
# The action runs on this same image; src/post.ts needs Node 24.2+. # The action runs on this same image; src/post/index.ts needs Node 24.2+.
- run: node --version - run: node --version
- run: npm ci - run: npm ci
+41 -10
View File
@@ -13,18 +13,49 @@ explicit permission.
# Runtime and checks # Runtime and checks
The action's TypeScript runs directly on the job image's Node.js (24.2 or The action's TypeScript runs directly on the job image's Node.js (24.2 or
newer), which strips the types itself: there is no build step and no runtime newer; `package.json` has no `engines`, the `node:24-bookworm` image pins it),
which strips the types itself: there is no build step and no runtime
dependency. Keep the sources to erasable syntax and `.ts` imports, which dependency. Keep the sources to erasable syntax and `.ts` imports, which
`tsconfig.json` enforces. Checks use Vite+: `vp check` formats, lints, and `tsconfig.json` enforces. Its strictness set is fixed and leaves
type-checks, and `vp test` runs Vitest. Unit tests sit next to the code as `exactOptionalPropertyTypes` off
`src/*.test.ts`; repository-level tests live in `tests/`. ([.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 `*.test.ts` in `src/`;
repository-level tests live in `tests/`.
A `#` alias in `package.json` `imports` may point anywhere and owns its
target: an alias to a folder's `index.ts` owns that folder and everything
below it; an alias to any other file owns only that file. No overlap: no file
may be owned by 2 aliases, so no alias inside another alias's area and no 2
aliases with the same target. Outside an area, only the exact alias reaches
it; a deeper alias specifier or a relative path into the area is a bypass.
Inside an area, relative imports of siblings and subfolders are free. A `../`
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. 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 The only config files are `package.json`, `tsconfig.json`, and
`vite.config.ts`, plus `package-lock.json`. Repo-local lint rules and the rule `vite.config.ts`, plus `package-lock.json`. Repo-local lint rules, the rule
list live in `scripts/project-lint.ts`. Lint disable comments are not allowed; list, and the `overrides` array (`projectOverrides`) live in
fix the source instead. `scripts/project-lint.ts`
([.agents/memory/lint-config.md](.agents/memory/lint-config.md)). Lint disable
comments are not allowed; fix the source instead.
The composite action uses npm only to install the externally distributed The composite action uses npm only to install the externally distributed
Claude Code, Codex, and OpenCode command-line tools, plus Deno for agents that Claude Code, Codex, and OpenCode command-line tools; do not treat those
check Deno-based repositories; do not treat those vendor executables as vendor executables as repository TypeScript dependencies.
repository TypeScript dependencies.
+5 -6
View File
@@ -16,11 +16,10 @@ runs:
fetch-depth: 0 fetch-depth: 0
token: ${{ gitea.token }} token: ${{ gitea.token }}
# This step assumes this is `node:24-bookworm` container. The action runs # This step assumes this is `node:24-bookworm` container. The action runs
# its TypeScript on that Node directly; Deno stays for the agents, which # its TypeScript on that Node directly.
# still check Deno-based repositories with it.
- name: Install dependencies - name: Install dependencies
shell: bash shell: bash
run: npm install -g @openai/codex @anthropic-ai/claude-code opencode-ai deno run: npm install -g @openai/codex @anthropic-ai/claude-code opencode-ai
# A local action lives inside the writable checkout. Execute a root-owned # A local action lives inside the writable checkout. Execute a root-owned
# copy so an agent changing this repository cannot replace a later phase. # copy so an agent changing this repository cannot replace a later phase.
- name: Protect action source - name: Protect action source
@@ -36,7 +35,7 @@ runs:
- name: Prepare credentials - name: Prepare credentials
id: prepare id: prepare
shell: bash shell: bash
run: node "${{ steps.source.outputs.path }}/src/prepare.ts" run: node "${{ steps.source.outputs.path }}/src/prepare/index.ts"
env: env:
ACTION_PATH: ${{ steps.source.outputs.path }} ACTION_PATH: ${{ steps.source.outputs.path }}
BOT_AGENTS_MASTER_KEY: ${{ inputs.master-key }} BOT_AGENTS_MASTER_KEY: ${{ inputs.master-key }}
@@ -44,7 +43,7 @@ runs:
- name: Run bot - name: Run bot
id: agent id: agent
shell: bash shell: bash
run: node "${{ steps.source.outputs.path }}/src/run.ts" run: node "${{ steps.source.outputs.path }}/src/run/index.ts"
env: env:
BOT_AGENTS_MANIFEST: ${{ steps.prepare.outputs.manifest }} BOT_AGENTS_MANIFEST: ${{ steps.prepare.outputs.manifest }}
GITEA_TOKEN: ${{ gitea.token }} GITEA_TOKEN: ${{ gitea.token }}
@@ -57,7 +56,7 @@ runs:
id: post id: post
if: always() && steps.prepare.outputs.manifest != '' if: always() && steps.prepare.outputs.manifest != ''
shell: bash shell: bash
run: node "${{ steps.source.outputs.path }}/src/post.ts" run: node "${{ steps.source.outputs.path }}/src/post/index.ts"
env: env:
BOT_AGENTS_MANIFEST: ${{ steps.prepare.outputs.manifest }} BOT_AGENTS_MANIFEST: ${{ steps.prepare.outputs.manifest }}
GITEA_API_URL: ${{ gitea.api_url }} GITEA_API_URL: ${{ gitea.api_url }}
+36 -3
View File
@@ -9,11 +9,10 @@
"@eslint/core": "^0.17.0", "@eslint/core": "^0.17.0",
"@types/node": "^24.19.2", "@types/node": "^24.19.2",
"@typescript-eslint/types": "^8.71.2", "@typescript-eslint/types": "^8.71.2",
"eslint-plugin-explicit-types": "^0.7.2",
"eslint-plugin-import-structure": "^0.12.0",
"vite-plus": "^1.2.0", "vite-plus": "^1.2.0",
"yaml": "^2.9.1" "yaml": "^2.9.1"
},
"engines": {
"node": ">=24.2"
} }
}, },
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
@@ -1890,6 +1889,40 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/eslint-plugin-explicit-types": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/eslint-plugin-explicit-types/-/eslint-plugin-explicit-types-0.7.2.tgz",
"integrity": "sha512-ffHVVuyBH2esZwwRItcz2sDnL5xvSRDAoLjSqmQbevT6ETcjtfThtLoWoc+cv5pvtxa6YiXEK1P2D+x/yUBc1g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint/core": "^0.17.0"
},
"engines": {
"node": ">=22.18"
}
},
"node_modules/eslint-plugin-import-structure": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-import-structure/-/eslint-plugin-import-structure-0.12.0.tgz",
"integrity": "sha512-axo8TH0mik9NmsnW+J5rNwNXlOxxqOhULwN7zE4EJaNRdk/Ci/aRac3BAeeIi/SDUyPgN6Ig34l+mFlLQYWkfw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint/core": "^0.17.0"
},
"engines": {
"node": ">=22.18"
},
"peerDependencies": {
"eslint": ">=9"
},
"peerDependenciesMeta": {
"eslint": {
"optional": true
}
}
},
"node_modules/estree-walker": { "node_modules/estree-walker": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+13 -3
View File
@@ -1,15 +1,25 @@
{ {
"name": "bot-agents", "name": "bot-agents",
"description": "A Gitea composite action that runs a coding agent for approved issue and pull request automation.",
"private": true, "private": true,
"description": "A Gitea composite action that runs a coding agent for approved issue and pull request automation.",
"type": "module", "type": "module",
"engines": { "imports": {
"node": ">=24.2" "#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": { "devDependencies": {
"@eslint/core": "^0.17.0", "@eslint/core": "^0.17.0",
"@types/node": "^24.19.2", "@types/node": "^24.19.2",
"@typescript-eslint/types": "^8.71.2", "@typescript-eslint/types": "^8.71.2",
"eslint-plugin-explicit-types": "^0.7.2",
"eslint-plugin-import-structure": "^0.12.0",
"vite-plus": "^1.2.0", "vite-plus": "^1.2.0",
"yaml": "^2.9.1" "yaml": "^2.9.1"
} }
+6 -6
View File
@@ -2,16 +2,16 @@
"reviewer-token": "v1:XFUvCI9PnXVaW-ig:_3S9BvXywUp29cVYGA_EzFScKwC5oHkV9x2Ld8k-V_uiG7iA8n3fmjXCArzh2Lz-JDgy3IMTHoY", "reviewer-token": "v1:XFUvCI9PnXVaW-ig:_3S9BvXywUp29cVYGA_EzFScKwC5oHkV9x2Ld8k-V_uiG7iA8n3fmjXCArzh2Lz-JDgy3IMTHoY",
"profiles": [ "profiles": [
{ {
"type": "codex", "type": "CLAUDE",
"model": "gpt-5.6-terra"
},
{
"type": "claude",
"model": "claude-sonnet-5", "model": "claude-sonnet-5",
"token": "v1:-dyfF6ZicEhjPk4C:WCuMaGd09tYjtzUCl7abloRzPITvP5C35WpR372w3O_tcaD-ls-yV5sJBdU8iIVApZVCC98sY2gOyM3o9RMa8ciJq8Fuys7KxlYOp8QiluL9AYMSQi6rEsSH71EPWnxSACgo3uDQY8bWQpUlEZvXH9O-ZN9A58dh6b3vRg" "token": "v1:-dyfF6ZicEhjPk4C:WCuMaGd09tYjtzUCl7abloRzPITvP5C35WpR372w3O_tcaD-ls-yV5sJBdU8iIVApZVCC98sY2gOyM3o9RMa8ciJq8Fuys7KxlYOp8QiluL9AYMSQi6rEsSH71EPWnxSACgo3uDQY8bWQpUlEZvXH9O-ZN9A58dh6b3vRg"
}, },
{ {
"type": "opencode", "type": "CODEX",
"model": "gpt-5.6-terra"
},
{
"type": "OPENCODE",
"model": "openrouter/qwen/qwen3.8-27b", "model": "openrouter/qwen/qwen3.8-27b",
"token": "v1:4qdfJCdUx9jfiabC:8X0ic_J82lPlq_R3uTLaEbQy7UmEh7BgtG0bDKAcc8FeJ0xvEwkGCKaDoLmT5nta03hgjGdcWKjDdOSMi1kSDfp8J7tRTLtMRzDT8GX8OU_PVDPVahQIAU0" "token": "v1:4qdfJCdUx9jfiabC:8X0ic_J82lPlq_R3uTLaEbQy7UmEh7BgtG0bDKAcc8FeJ0xvEwkGCKaDoLmT5nta03hgjGdcWKjDdOSMi1kSDfp8J7tRTLtMRzDT8GX8OU_PVDPVahQIAU0"
} }
+3 -3
View File
@@ -47,9 +47,9 @@ achieved.
Every finding that belongs to one line of the diff goes on that line instead of Every finding that belongs to one line of the diff goes on that line instead of
into the body. Write those to `${ANCHORS_PATH}` as a JSON array, each entry into the body. Write those to `${ANCHORS_PATH}` as a JSON array, each entry
`{"path": "<path from the repository root>", "line": <number>, "side": "new" | `{"path": "<path from the repository root>", "line": <number>, "side": "NEW" |
"old", "body": "<the finding>"}`. "OLD", "body": "<the finding>"}`.
`side` is `new` for a line in the head file and `old` for one only in the base `side` is `NEW` for a line in the head file and `OLD` for one only in the base
file; `line` is that file's own line number, and it must be a line the diff file; `line` is that file's own line number, and it must be a line the diff
touches, or Gitea refuses the anchor. Write the file only when there is touches, or Gitea refuses the anchor. Write the file only when there is
something to anchor, and keep each body to the what, the why, and the how, with something to anchor, and keep each body to the what, the why, and the how, with
+800 -73
View File
@@ -1,21 +1,29 @@
/** /**
* `project` — lint rules this repository holds its own source to. Deno lint * `project` - this repository's changes to the Oxlint categories in
* ran here with its `recommended` rules and no plugins; the Oxlint categories * `vite.config.ts`. Its rules of its own are `no-disable-comments`,
* in `vite.config.ts` plus the rule list below keep at least that coverage. * `no-asset-imports`, `prefer-ascii`, and ports of the hugoalh `recommended`
* Its rules of its own are `no-disable-comments` and `no-non-ts-imports`. * rules Oxlint lacks, as in eslint-plugin-import-structure's own lint.
* *
* Loaded by `vite.config.ts` as an Oxlint `jsPlugins` entry. * Loaded by `vite.config.ts` as an Oxlint `jsPlugins` entry.
* *
* @module * @module
*/ */
import type { LanguageOptions, RuleDefinition, RuleVisitor, TextSourceCode } from "@eslint/core"; import type {
import type { TSESTree } from "@typescript-eslint/types"; LanguageOptions,
import type { DummyRuleMap } from "vite-plus/lint"; Position,
RuleDefinition,
RuleTextEdit,
RuleVisitor,
TextSourceCode,
} from "@eslint/core";
import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/types";
import type { DummyRuleMap, OxlintOverride } from "vite-plus/lint";
/** The source code both hosts hand a rule, with the comment list. */ /** The source code both hosts hand a rule, with the comment list and index lookup. */
interface ProjectSourceCode extends TextSourceCode { interface ProjectSourceCode extends TextSourceCode {
getAllComments(): TSESTree.Comment[]; getAllComments: () => TSESTree.Comment[];
getLocFromIndex: (index: number) => Position;
} }
/** ESLint's default rule types, reporting TypeScript-ESTree nodes. */ /** ESLint's default rule types, reporting TypeScript-ESTree nodes. */
@@ -39,7 +47,7 @@ const DIRECTIVE_PATTERN: RegExp = /^\s*(?:eslint|oxlint)-(?:disable|enable)/u;
const RULE_NAME: string = "no-disable-comments"; const RULE_NAME: string = "no-disable-comments";
/** /**
* `no-disable-comments` — rejects every `eslint-disable*`, `eslint-enable*`, * `no-disable-comments` - rejects every `eslint-disable*`, `eslint-enable*`,
* `oxlint-disable*`, and `oxlint-enable*` comment, line or block. Oxlint has * `oxlint-disable*`, and `oxlint-enable*` comment, line or block. Oxlint has
* no `noInlineConfig` and no `eslint-comments` rules, so this repo bans them * no `noInlineConfig` and no `eslint-comments` rules, so this repo bans them
* itself: when a rule fires, fix the source. * itself: when a rule fires, fix the source.
@@ -54,13 +62,18 @@ const noDisableCommentsRule: ProjectRule = {
type: "problem", type: "problem",
docs: { description: "Disallow lint disable and enable comments" }, docs: { description: "Disallow lint disable and enable comments" },
schema: [], schema: [],
messages: { directive: "Lint disable comments are not allowed; fix the source." }, messages: {
directive: "Lint disable comments are not allowed; fix the source.",
},
}, },
create(context) { create(context) {
return { return {
Program(_node: TSESTree.Program): void { Program(_node: TSESTree.Program): void {
for (const comment of context.sourceCode.getAllComments()) { for (const comment of context.sourceCode.getAllComments()) {
if (DIRECTIVE_PATTERN.test(comment.value) && !comment.value.includes(RULE_NAME)) { if (
DIRECTIVE_PATTERN.test(comment.value) &&
!comment.value.includes(RULE_NAME)
) {
context.report({ loc: comment.loc, messageId: "directive" }); context.report({ loc: comment.loc, messageId: "directive" });
} }
} }
@@ -69,15 +82,587 @@ const noDisableCommentsRule: ProjectRule = {
}, },
}; };
/** The highest code point `prefer-ascii` lets through. */
const MAX_ASCII_CODE_POINT: number = 127;
/**
* `prefer-ascii` - every character of the source is ASCII. A file is exempted
* only through an entry in `projectOverrides`.
*/
const preferAsciiRule: ProjectRule = {
meta: {
type: "suggestion",
docs: { description: "Require the source to be ASCII" },
schema: [],
messages: {
nonAscii:
"Non-ASCII character '{{character}}' (U+{{codePoint}}) is not allowed.",
},
},
create(context) {
return {
Program(_node: TSESTree.Program): void {
const { sourceCode } = context;
let index: number = 0;
for (const character of sourceCode.text) {
const codePoint: number = character.codePointAt(0) ?? 0;
if (codePoint > MAX_ASCII_CODE_POINT) {
context.report({
loc: {
start: sourceCode.getLocFromIndex(index),
end: sourceCode.getLocFromIndex(index + character.length),
},
messageId: "nonAscii",
data: {
character,
codePoint: codePoint
.toString(16)
.toUpperCase()
.padStart(4, "0"),
},
});
}
index += character.length;
}
},
};
},
};
/** `no-duplicate-typeofs` - `typeof typeof x` is always `"string"`. */
const noDuplicateTypeofsRule: ProjectRule = {
meta: {
type: "problem",
docs: { description: "Disallow `typeof` applied twice in a row" },
schema: [],
messages: {
doubled:
'Multiple `typeof`s always return `"string"`, possibly not intended.',
},
},
create(context) {
return {
UnaryExpression(node: TSESTree.UnaryExpression): void {
if (
node.operator === "typeof" &&
node.argument.type === AST_NODE_TYPES.UnaryExpression &&
node.argument.operator === "typeof"
) {
context.report({ node, messageId: "doubled" });
}
},
};
},
};
/** `no-empty-yield` - a `yield` with no expression, likely missing one. */
const noEmptyYieldRule: ProjectRule = {
meta: {
type: "problem",
docs: { description: "Disallow `yield` without an expression" },
schema: [],
messages: {
empty: "Empty `yield` is not allowed, possibly missing the expression.",
},
},
create(context) {
return {
YieldExpression(node: TSESTree.YieldExpression): void {
if (node.argument === null) {
context.report({ node, messageId: "empty" });
}
},
};
},
};
/** The globals `no-nan` sees through, as in `globalThis.Number.NaN`. */
const GLOBAL_NAMES: ReadonlySet<string> = new Set<string>([
"globalThis",
"self",
"window",
]);
/** The name a member expression's property spells, computed or not. */
function propertyName(node: TSESTree.MemberExpression): string | null {
if (node.property.type === AST_NODE_TYPES.Identifier && !node.computed) {
return node.property.name;
}
if (
node.property.type === AST_NODE_TYPES.Literal &&
typeof node.property.value === "string"
) {
return node.property.value;
}
return null;
}
/** Whether `node` is a global object, such as `globalThis` or `globalThis.self`. */
function isGlobalChain(node: TSESTree.Expression): boolean {
if (node.type === AST_NODE_TYPES.Identifier) {
return GLOBAL_NAMES.has(node.name);
}
return (
node.type === AST_NODE_TYPES.MemberExpression &&
GLOBAL_NAMES.has(propertyName(node) ?? "") &&
isGlobalChain(node.object)
);
}
/**
* Whether a member expression reads the global `NaN`: `Number.NaN`,
* `globalThis.NaN`, or `globalThis.Number.NaN`.
*/
function isNanMember(node: TSESTree.MemberExpression): boolean {
if (propertyName(node) !== "NaN") {
return false;
}
const { object } = node;
if (object.type === AST_NODE_TYPES.Identifier && object.name === "Number") {
return true;
}
return (
isGlobalChain(object) ||
(object.type === AST_NODE_TYPES.MemberExpression &&
propertyName(object) === "Number" &&
isGlobalChain(object.object))
);
}
/** `no-nan` - a `NaN` value is usually an error; use `null` for "no number". */
const noNanRule: ProjectRule = {
meta: {
type: "problem",
docs: { description: "Disallow `NaN` as a value" },
schema: [],
messages: {
nan: "Using `NaN` as a value is usually an error and not intended.",
},
},
create(context) {
return {
Identifier(node: TSESTree.Identifier): void {
if (
node.name === "NaN" &&
node.parent.type !== AST_NODE_TYPES.MemberExpression
) {
context.report({ node, messageId: "nan" });
}
},
MemberExpression(node: TSESTree.MemberExpression): void {
if (isNanMember(node)) {
context.report({ node, messageId: "nan" });
}
},
};
},
};
/** `no-unsafe-number` - a number literal outside the safe integer range. */
const noUnsafeNumberRule: ProjectRule = {
meta: {
type: "problem",
docs: {
description: "Disallow number literals outside the safe integer range",
},
schema: [],
messages: {
unsafe:
"Number literals with values equal to 2^53 or greater are too large to be represented accurately.",
},
},
create(context) {
return {
Literal(node: TSESTree.Literal): void {
if (
typeof node.value === "number" &&
(node.value < Number.MIN_SAFE_INTEGER ||
node.value > Number.MAX_SAFE_INTEGER)
) {
context.report({ node, messageId: "unsafe" });
}
},
};
},
};
/** A loop statement, whose last `continue` `no-useless-continue` checks. */
type LoopStatement =
| TSESTree.DoWhileStatement
| TSESTree.ForInStatement
| TSESTree.ForOfStatement
| TSESTree.ForStatement
| TSESTree.WhileStatement;
/** A loop's trailing unlabeled `continue`, and whether removing it is a safe fix. */
interface TrailingContinue {
statement: TSESTree.ContinueStatement;
removable: boolean;
}
/**
* The unlabeled `continue` a loop ends with, and whether removing it is a
* safe fix: it is in a block, rather than the loop's whole body.
*/
function trailingContinue(node: LoopStatement): TrailingContinue | null {
if (node.body.type === AST_NODE_TYPES.BlockStatement) {
const last = node.body.body.at(-1);
return last?.type === AST_NODE_TYPES.ContinueStatement &&
last.label === null
? { statement: last, removable: true }
: null;
}
if (
node.body.type === AST_NODE_TYPES.ContinueStatement &&
node.body.label === null
) {
return { statement: node.body, removable: false };
}
return null;
}
/**
* `no-useless-continue` - an unlabeled `continue` that ends a loop body. A
* trailing `continue;` in a block is removed by the fix; a loop whose whole
* body is `continue;` is only reported.
*/
const noUselessContinueRule: ProjectRule = {
meta: {
type: "suggestion",
docs: {
description: "Disallow an unlabeled `continue` at the end of a loop",
},
fixable: "code",
schema: [],
messages: {
useless:
"The statement `continue` at the end of the loop and without a label is useless.",
},
},
create(context) {
function check(node: LoopStatement): void {
const found: TrailingContinue | null = trailingContinue(node);
if (found === null) {
return;
}
if (found.removable) {
context.report({
node: found.statement,
messageId: "useless",
fix: (fixer): RuleTextEdit => fixer.remove(found.statement),
});
} else {
context.report({ node: found.statement, messageId: "useless" });
}
}
return {
DoWhileStatement: check,
ForInStatement: check,
ForOfStatement: check,
ForStatement: check,
WhileStatement: check,
};
},
};
/** Node keys that say where a node is, not what it is, in ESLint and Oxlint. */
const POSITION_KEYS: ReadonlySet<string> = new Set<string>([
"parent",
"range",
"loc",
"start",
"end",
]);
/** Node types whose member lists mean the same in any order. */
const UNORDERED_TYPES: ReadonlySet<string> = new Set<string>([
"TSInterfaceBody",
"TSTypeLiteral",
"TSUnionType",
"TSIntersectionType",
]);
/** Type annotations too simple for an alias of their own (`no-useless-type`). */
const SIMPLE_TYPES: ReadonlySet<string> = new Set<string>([
"TSAnyKeyword",
"TSBigIntKeyword",
"TSBooleanKeyword",
"TSIntrinsicKeyword",
"TSNeverKeyword",
"TSNullKeyword",
"TSNumberKeyword",
"TSObjectKeyword",
"TSStringKeyword",
"TSSymbolKeyword",
"TSUndefinedKeyword",
"TSUnknownKeyword",
"TSVoidKeyword",
]);
/**
* A node's shape as text, without positions, so two declarations compare
* alike wherever they sit. Members of a type literal, interface body,
* union, or intersection are sorted, since their order means nothing.
*/
function serialize(value: unknown): string {
if (Array.isArray(value)) {
return `[${value.map((item: unknown) => serialize(item)).join(",")}]`;
}
if (typeof value !== "object" || value === null) {
return JSON.stringify(value) ?? "undefined";
}
const unordered: boolean =
"type" in value && UNORDERED_TYPES.has(String(value.type));
const parts: string[] = Object.entries(value)
.filter(([key]: [string, unknown]) => !POSITION_KEYS.has(key))
.map(([key, child]: [string, unknown]) => {
const text: string =
unordered && Array.isArray(child)
? `[${child
.map((item: unknown) => serialize(item))
.toSorted()
.join(",")}]`
: serialize(child);
return `${key}:${text}`;
});
return `{${parts.join(",")}}`;
}
/** The top-level declarations in a block, exported or not, that `matches`. */
function declarationsOf<T extends TSESTree.Node>(
statements: TSESTree.Node[],
matches: (node: TSESTree.Node) => node is T,
): T[] {
const found: T[] = [];
for (const statement of statements) {
const declaration: TSESTree.Node | null =
statement.type === AST_NODE_TYPES.ExportNamedDeclaration
? statement.declaration
: statement;
if (declaration !== null && matches(declaration)) {
found.push(declaration);
}
}
return found;
}
/** Whether a node is an interface declaration. */
function isInterface(
node: TSESTree.Node,
): node is TSESTree.TSInterfaceDeclaration {
return node.type === AST_NODE_TYPES.TSInterfaceDeclaration;
}
/** Whether a node is a type alias declaration. */
function isTypeAlias(
node: TSESTree.Node,
): node is TSESTree.TSTypeAliasDeclaration {
return node.type === AST_NODE_TYPES.TSTypeAliasDeclaration;
}
/** Groups nodes by a key and returns the groups with more than one node. */
function duplicateGroups<T>(nodes: T[], keyOf: (node: T) => string): T[][] {
const groups: Map<string, T[]> = Map.groupBy(nodes, keyOf);
return [...groups.values()].filter((group: T[]) => group.length > 1);
}
/** What an interface declares, its `extends` list included. */
function interfaceShape(node: TSESTree.TSInterfaceDeclaration): string {
return `${serialize(node.extends)}&${serialize(node.body)}`;
}
/** An interface's name. */
function interfaceName(node: TSESTree.TSInterfaceDeclaration): string {
return node.id.name;
}
/** What a type alias stands for. */
function typeAliasShape(node: TSESTree.TSTypeAliasDeclaration): string {
return serialize(node.typeAnnotation);
}
/**
* `no-duplicate-interfaces` - two interfaces in one block with the same
* name, which merge silently, or with the same shape, which could be one.
*/
const noDuplicateInterfacesRule: ProjectRule = {
meta: {
type: "suggestion",
docs: {
description:
"Disallow interfaces with the same name or shape in one block",
},
schema: [],
messages: {
sameShape:
"Found multiple interfaces with the same shape, possibly mergeable.",
sameName:
"Found multiple interfaces named '{{name}}', possibly not intended.",
},
},
create(context) {
function check(statements: TSESTree.Node[]): void {
const nodes: TSESTree.TSInterfaceDeclaration[] = declarationsOf(
statements,
isInterface,
);
for (const node of duplicateGroups(nodes, interfaceShape).flat()) {
context.report({ node, messageId: "sameShape" });
}
for (const node of duplicateGroups(nodes, interfaceName).flat()) {
context.report({
node,
messageId: "sameName",
data: { name: node.id.name },
});
}
}
return {
BlockStatement(node: TSESTree.BlockStatement): void {
check(node.body);
},
Program(node: TSESTree.Program): void {
check(node.body);
},
};
},
};
/** `no-duplicate-types` - two type aliases in one block with the same type. */
const noDuplicateTypesRule: ProjectRule = {
meta: {
type: "suggestion",
docs: {
description: "Disallow type aliases with the same type in one block",
},
schema: [],
messages: {
sameShape:
"Found multiple types with the same shape, possibly mergeable.",
},
},
create(context) {
function check(statements: TSESTree.Node[]): void {
const nodes: TSESTree.TSTypeAliasDeclaration[] = declarationsOf(
statements,
isTypeAlias,
);
for (const node of duplicateGroups(nodes, typeAliasShape).flat()) {
context.report({ node, messageId: "sameShape" });
}
}
return {
BlockStatement(node: TSESTree.BlockStatement): void {
check(node.body);
},
Program(node: TSESTree.Program): void {
check(node.body);
},
};
},
};
/** A class member that can carry the `private` keyword. */
type AccessibilityCarrier =
| TSESTree.AccessorProperty
| TSESTree.MethodDefinition
| TSESTree.PropertyDefinition
| TSESTree.TSAbstractAccessorProperty
| TSESTree.TSAbstractMethodDefinition
| TSESTree.TSAbstractPropertyDefinition
| TSESTree.TSParameterProperty;
/**
* `no-modifier-private` - class members are made private with `#`, never the
* `private` keyword, which only TypeScript enforces. A `private constructor`
* has no `#` form and stays allowed.
*/
const noModifierPrivateRule: ProjectRule = {
meta: {
type: "suggestion",
docs: { description: "Disallow the private modifier" },
schema: [],
messages: {
private: "Use a # private name instead of the private modifier.",
},
},
create(context) {
function check(node: AccessibilityCarrier): void {
const isConstructor: boolean =
node.type === AST_NODE_TYPES.MethodDefinition &&
node.kind === "constructor";
if (node.accessibility === "private" && !isConstructor) {
context.report({ node, messageId: "private" });
}
}
return {
AccessorProperty: check,
MethodDefinition: check,
PropertyDefinition: check,
TSAbstractAccessorProperty: check,
TSAbstractMethodDefinition: check,
TSAbstractPropertyDefinition: check,
TSParameterProperty: check,
};
},
};
/** Whether a type is too simple to need an alias: a keyword, literal, or bare name. */
function isSimpleType(node: TSESTree.TypeNode): boolean {
if (SIMPLE_TYPES.has(node.type)) {
return true;
}
if (node.type === AST_NODE_TYPES.TSLiteralType) {
return node.literal.type === AST_NODE_TYPES.Literal;
}
return (
node.type === AST_NODE_TYPES.TSTypeReference &&
node.typeName.type === AST_NODE_TYPES.Identifier &&
node.typeArguments === undefined
);
}
/** `no-useless-type` - a type alias for a keyword, literal, or bare type name. */
const noUselessTypeRule: ProjectRule = {
meta: {
type: "suggestion",
docs: { description: "Disallow type aliases too simple to need a name" },
schema: [],
messages: { simple: "The type is too simple to need an alias." },
},
create(context) {
return {
TSTypeAliasDeclaration(node: TSESTree.TSTypeAliasDeclaration): void {
if (isSimpleType(node.typeAnnotation)) {
context.report({ node, messageId: "simple" });
}
},
};
},
};
/** A module specifier read as a path: relative, absolute, or a `#` alias. */ /** A module specifier read as a path: relative, absolute, or a `#` alias. */
const PATH_SPECIFIER_PATTERN: RegExp = /^[./#]/u; const PATH_SPECIFIER_PATTERN: RegExp = /^[./#]/u;
/** /**
* Whether `source` names something other than a `.ts` module: a query string * Script module extensions an import may name: our `.ts` files and `.js`,
* such as `?raw`, or a final path segment with any other extension. Bare * `.mts`, or `.mjs` package subpaths. `.cts`, `.cjs`, `.tsx`, and `.jsx` are
* package names are left alone, dots and all, as are extensionless paths. * not among them.
*/ */
function isNonTsSource(source: string): boolean { const SCRIPT_EXTENSIONS: ReadonlySet<string> = new Set([
".ts",
".js",
".mts",
".mjs",
]);
/**
* Whether `source` names an asset rather than a script module: a query string
* such as `?raw`, or a final path segment whose extension, from its last dot,
* is not `.ts`, `.js`, `.mts`, or `.mjs`. Bare package names are left alone,
* dots and all, as are extensionless paths.
*/
function isAssetSource(source: string): boolean {
if (source.includes("?")) { if (source.includes("?")) {
return true; return true;
} }
@@ -89,33 +674,44 @@ function isNonTsSource(source: string): boolean {
} }
} }
const lastSegment: string = segments.at(-1) ?? ""; const lastSegment: string = segments.at(-1) ?? "";
if (lastSegment === "." || lastSegment === ".." || !lastSegment.includes(".")) { if (
lastSegment === "." ||
lastSegment === ".." ||
!lastSegment.includes(".")
) {
return false; return false;
} }
return !lastSegment.endsWith(".ts"); const extension: string = lastSegment.slice(lastSegment.lastIndexOf("."));
return !SCRIPT_EXTENSIONS.has(extension);
} }
/** /**
* `no-non-ts-imports` — rejects importing anything but a `.ts` module: a * `no-asset-imports` - rejects importing anything but a script module: a
* source path, static or a dynamic `import()` with a string literal, ending in * source path, static or a dynamic `import()` with a string literal, ending in
* another extension (`.css`, `.json`, `.svg`, `.js`, `.tsx`) or carrying a * an extension other than `.ts`, `.js`, `.mts`, or `.mjs` (`.css`, `.json`,
* query string (`?raw`, `?url`), and any import or export with import * `.svg`, `.jsx`, `.tsx`, `.cjs`) or carrying a query string (`?raw`, `?url`),
* attributes. Bare package names, `#` aliases, and `node:` builtins without an * and any import or export with import attributes. `.js`, `.mts`, and `.mjs`
* extension pass; package subpaths are checked like paths. * cover package subpaths such as `lit/decorators.js` or
* `pdfjs-dist/legacy/build/pdf.mjs`; our own files are still `.ts` only. Bare
* package names, `#` aliases, and `node:` builtins
* without an extension pass; package subpaths are checked like paths.
*/ */
const noNonTsImportsRule: ProjectRule = { const noAssetImportsRule: ProjectRule = {
meta: { meta: {
type: "problem", type: "problem",
docs: { description: "Disallow importing modules other than .ts files" }, docs: {
description:
"Disallow importing assets instead of .ts, .js, .mts, or .mjs modules",
},
schema: [], schema: [],
messages: { messages: {
extension: "Only .ts modules may be imported.", extension: "Only .ts, .js, .mts and .mjs modules may be imported.",
attributes: "Import attributes are not allowed.", attributes: "Import attributes are not allowed.",
}, },
}, },
create(context) { create(context) {
function checkSource(source: TSESTree.StringLiteral): void { function checkSource(source: TSESTree.StringLiteral): void {
if (isNonTsSource(source.value)) { if (isAssetSource(source.value)) {
context.report({ node: source, messageId: "extension" }); context.report({ node: source, messageId: "extension" });
} }
} }
@@ -138,7 +734,10 @@ const noNonTsImportsRule: ProjectRule = {
ExportNamedDeclaration: checkDeclaration, ExportNamedDeclaration: checkDeclaration,
ExportAllDeclaration: checkDeclaration, ExportAllDeclaration: checkDeclaration,
ImportExpression(node: TSESTree.ImportExpression): void { ImportExpression(node: TSESTree.ImportExpression): void {
if (node.source.type === "Literal" && typeof node.source.value === "string") { if (
node.source.type === AST_NODE_TYPES.Literal &&
typeof node.source.value === "string"
) {
checkSource(node.source); checkSource(node.source);
} }
if (node.options !== null) { if (node.options !== null) {
@@ -150,10 +749,9 @@ const noNonTsImportsRule: ProjectRule = {
}; };
/** /**
* This repository's own rule list, spread by `vite.config.ts`: Deno's * This repository's changes to the Oxlint categories in `vite.config.ts`,
* `recommended` rules that sit outside the Oxlint categories, plus the * which turn every non-nursery category on: its own rules, the category
* template's extras. Built-in Oxlint rule ids sit here too, so * rules it turns off, and options that differ from a rule's defaults.
* `vite.config.ts` lists no rule by name.
*/ */
const rules: DummyRuleMap = { const rules: DummyRuleMap = {
// The explicit-types template's `camelcase` stand-in: no `_` inside a name // The explicit-types template's `camelcase` stand-in: no `_` inside a name
@@ -162,35 +760,14 @@ const rules: DummyRuleMap = {
"id-match": [ "id-match": [
"error", "error",
"^_*[^_]*_*$|^[^a-z]*$", "^_*[^_]*_*$|^[^a-z]*$",
{ properties: false, classFields: false, onlyDeclarations: false, ignoreDestructuring: true }, {
properties: false,
classFields: false,
onlyDeclarations: false,
ignoreDestructuring: true,
},
], ],
// Deno's `recommended` rules outside the categories. // On Node, these come from an explicit `node:` import instead of the global scope.
"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",
// Deno's `no-window`, `no-process-global`, and `no-node-globals`: on Node,
// these come from an explicit `node:` import instead of the global scope.
"no-restricted-globals": [ "no-restricted-globals": [
"error", "error",
"window", "window",
@@ -200,22 +777,162 @@ const rules: DummyRuleMap = {
"setImmediate", "setImmediate",
"clearImmediate", "clearImmediate",
], ],
// The explicit-types template's extras. // Single capital type parameters, which explicit-types requires, may be one letter.
"import/no-cycle": "error", "id-length": ["error", { exceptionPatterns: ["^[A-Z]$"] }],
"typescript/consistent-type-imports": "error", // Option tweaks.
"typescript/no-import-type-side-effects": "error", "one-var": ["error", "never"],
"typescript/no-non-null-assertion": "error", "no-use-before-define": ["error", { functions: false }],
// Deno's `ban-untagged-ignore` and `ban-unused-ignore`, made stricter: no "func-style": ["error", "declaration", { allowArrowFunctions: true }],
// disable comments at all. // Each limit is the smallest value the current code passes.
complexity: ["error", 26],
"max-depth": ["error", 6],
"max-lines": [
"error",
{ max: 2048, skipBlankLines: false, skipComments: false },
],
"max-lines-per-function": ["error", 124],
"max-params": ["error", 4],
"max-statements": ["error", 32],
"import/max-dependencies": ["error", { max: 13 }],
"unicorn/max-nested-calls": ["error", { max: 4 }],
// No lint suppression and only `.ts` imports, as in the plugin template.
"project/no-disable-comments": "error", "project/no-disable-comments": "error",
"project/no-non-ts-imports": "error", "project/no-asset-imports": "error",
"unicorn/no-abusive-eslint-disable": "error", // Ports of the hugoalh `recommended` rules Oxlint lacks, and ASCII-only source.
// Pedantic, but Node's and the web's own types (`Response`, `Stats`, "project/prefer-ascii": "error",
// `RequestInit`) are mutable, so no parameter of theirs can pass; the "project/no-duplicate-typeofs": "error",
// explicit-types template and the app repos turn it off too. "project/no-empty-yield": "error",
"project/no-nan": "error",
"project/no-unsafe-number": "error",
"project/no-useless-continue": "error",
"project/no-duplicate-interfaces": "error",
"project/no-duplicate-types": "error",
"project/no-modifier-private": "error",
"project/no-useless-type": "error",
// 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", "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 Oxfmt, which lowercases hex digits back.
"unicorn/number-literal-case": "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",
// Sequential awaits are intentional; workarounds cost more than they save.
"no-await-in-loop": "off",
// Not type-aware; TypeScript's `noImplicitReturns` covers it.
"consistent-return": "off",
// Misfires on complete switches, like `consistent-return`; TypeScript covers it.
"typescript/consistent-return": "off",
// TypeScript's type checking covers exhaustive switches.
"typescript/switch-exhaustiveness-check": "off",
// TypeScript checks completeness; a forced `default:` hides missing cases.
"default-case": "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",
};
/**
* 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,
},
// exitCode must return the promise it marks handled; an async wrapper's
// promise would reject unhandled before callers await it.
{
files: ["src/processes/child.ts"],
rules: { "typescript/promise-function-async": "off" },
},
// Posted Gitea text shows emoji verdict marks and a middle dot.
{
files: [
"src/post/post.ts",
"src/post/post.test.ts",
"src/profiles/profiles.ts",
"src/profiles/profiles.test.ts",
],
rules: { "project/prefer-ascii": "off" },
},
// The workflow test checks literal Gitea Actions `${{ }}` expressions.
{
files: ["tests/workflow.test.ts"],
rules: { "no-template-curly-in-string": "off" },
},
];
/** /**
* The repo-local plugin, namespace `project`: its own rules, enabled as * The repo-local plugin, namespace `project`: its own rules, enabled as
* `project/<rule>`, and `configs.project`, the rule list above. * `project/<rule>`, and `configs.project`, the rule list above.
@@ -224,7 +941,17 @@ const plugin = {
meta: { name: "project" }, meta: { name: "project" },
rules: { rules: {
"no-disable-comments": noDisableCommentsRule, "no-disable-comments": noDisableCommentsRule,
"no-non-ts-imports": noNonTsImportsRule, "no-asset-imports": noAssetImportsRule,
"prefer-ascii": preferAsciiRule,
"no-duplicate-typeofs": noDuplicateTypeofsRule,
"no-empty-yield": noEmptyYieldRule,
"no-nan": noNanRule,
"no-unsafe-number": noUnsafeNumberRule,
"no-useless-continue": noUselessContinueRule,
"no-duplicate-interfaces": noDuplicateInterfacesRule,
"no-duplicate-types": noDuplicateTypesRule,
"no-modifier-private": noModifierPrivateRule,
"no-useless-type": noUselessTypeRule,
}, },
configs: { project: { rules } }, configs: { project: { rules } },
}; };
+3 -3
View File
@@ -2,9 +2,9 @@
set -euo pipefail set -euo pipefail
path=$(mktemp -d "${RUNNER_TEMP}/bot-agents-source-XXXXXX") path=$(mktemp -d "${RUNNER_TEMP}/bot-agents-source-XXXXXX")
mkdir "${path}/src" cp -a "${ACTION_PATH}/src" "${path}/"
cp -a "${ACTION_PATH}"/src/*.ts "${path}/src/" # package.json makes Node load the TypeScript sources as ES modules and
# package.json makes Node load the TypeScript sources as ES modules. # resolves their `#` aliases.
cp -a "${ACTION_PATH}/package.json" "${ACTION_PATH}/profiles.json" "${ACTION_PATH}/prompt.md" "${path}/" cp -a "${ACTION_PATH}/package.json" "${ACTION_PATH}/profiles.json" "${ACTION_PATH}/prompt.md" "${path}/"
chmod -R go-w "${path}" chmod -R go-w "${path}"
printf 'path=%s\n' "${path}" >> "${GITHUB_OUTPUT}" printf 'path=%s\n' "${path}" >> "${GITHUB_OUTPUT}"
-119
View File
@@ -1,119 +0,0 @@
// Runs Claude Code for one profile and returns its final result text.
import { createInterface } from "node:readline";
import type { Readable } from "node:stream";
import { isClaudeAuthFailure } from "./auth.ts";
import type { ClaudeFailureFields } from "./auth.ts";
import { chunks } from "./command.ts";
import { agentCommand } from "./isolation.ts";
import { isObject } from "./json.ts";
import type { JsonObject } from "./json.ts";
import { AgentAuthError, agentEnvironment } from "./profiles.ts";
import { PROCESS_ENV, WORKSPACE } from "./run-context.ts";
import type { RuntimeProfile } from "./runtime.ts";
// Claude Code's stream-json `result` event, in its own field names.
type ClaudeResult = ClaudeFailureFields &
Partial<Record<"is_error", boolean>> & {
subtype: string;
};
// Echoes stderr as it arrives and resolves with all of it.
async function copyError(stream: Readable): Promise<string> {
const decoder = new TextDecoder();
let error = "";
for await (const chunk of chunks(stream)) {
const text = decoder.decode(chunk, { stream: true });
error += text;
console.error(text);
}
return error + decoder.decode();
}
// Prints the visible text of each streamed message part.
function printParts(event: JsonObject): void {
const content = isObject(event.message) ? event.message.content : undefined;
for (const part of Array.isArray(content) ? content : []) {
if (isObject(part)) {
const text = part.thinking ?? part.text ?? part.name;
if (typeof text === "string" && text !== "") {
console.log(text);
}
}
}
}
function toResult(event: JsonObject): ClaudeResult {
const { is_error: isError, result, subtype, api_error_status: apiErrorStatus } = event;
return {
...(typeof isError === "boolean" && { is_error: isError }),
...(typeof result === "string" && { result }),
subtype: String(subtype),
...((typeof apiErrorStatus === "number" || apiErrorStatus === null) && {
api_error_status: apiErrorStatus,
}),
};
}
// Prints each streamed message part and returns the final `result` event.
async function readEvents(stream: Readable): Promise<ClaudeResult | undefined> {
let result: ClaudeResult | undefined;
for await (const line of createInterface({ input: stream, crlfDelay: Infinity })) {
if (line !== "") {
const event: unknown = JSON.parse(line);
if (isObject(event)) {
printParts(event);
if (event.type === "result") {
result = toResult(event);
}
}
}
}
return result;
}
function checkResult(result: ClaudeResult | undefined, error: string): string {
if (result?.is_error === true) {
const failure = new Error(result.result ?? `claude ended with ${result.subtype}`);
if (isClaudeAuthFailure(result)) {
throw new AgentAuthError("claude sign-in failed", { cause: failure });
}
throw failure;
}
if (result?.result === undefined) {
throw new Error(error.trim() || `claude ended with ${result?.subtype ?? "no result"}`);
}
return result.result;
}
export async function runClaude(
profile: RuntimeProfile,
token: string,
prompt: string,
): Promise<string> {
const args = [
"--print",
"--dangerously-skip-permissions",
"--model",
profile.model,
"--output-format",
"stream-json",
"--verbose",
prompt,
];
const claude = agentCommand(profile, "claude", args, {
cwd: WORKSPACE,
env: { ...agentEnvironment(PROCESS_ENV, profile, token), IS_SANDBOX: "1" },
clearEnv: true,
stdout: "piped",
stderr: "piped",
}).spawn();
const errorText = copyError(claude.stderr);
const result = await readEvents(claude.stdout);
const [status, error] = await Promise.all([claude.status, errorText]);
const body = checkResult(result, error);
if (!status.success) {
throw new Error(error.trim() || `claude exited with ${status.code}`);
}
return body;
}
-174
View File
@@ -1,174 +0,0 @@
// Signs Codex in for one profile, runs it, and returns its result file path.
import process from "node:process";
import type { Readable } from "node:stream";
import { text } from "node:stream/consumers";
import { setTimeout as sleep } from "node:timers/promises";
import { isCodexAuthFailure, retryInvalidToken } from "./auth.ts";
import type { CodexAuthentication } from "./auth.ts";
import { chunks, endWith, writeChunk } from "./command.ts";
import type { CommandStatus } from "./command.ts";
import { agentCommand } from "./isolation.ts";
import { AgentAuthError, agentEnvironment, withProfile } from "./profiles.ts";
import {
makeProfileFile,
postAuthorComment,
PROCESS_ENV,
runAgentCommand,
WORKSPACE,
} from "./run-context.ts";
import type { RuntimeProfile } from "./runtime.ts";
interface CodexAttempt {
status: CommandStatus;
error: string;
}
async function collectText(stream: Readable, append: (chunk: string) => void): Promise<void> {
const decoder = new TextDecoder();
for await (const chunk of chunks(stream)) {
append(decoder.decode(chunk, { stream: true }));
}
append(decoder.decode());
}
// Polls every 100 ms until the login prints something, failing if it exits first.
async function pollForInstructions(
shownText: () => string,
status: Promise<CommandStatus>,
): Promise<void> {
if (shownText().trim() !== "") {
return;
}
const exited = await Promise.race([status.then(() => true), sleep(100, false)]);
if (exited) {
throw new AgentAuthError("AI bot login produced no instructions");
}
await pollForInstructions(shownText, status);
}
async function codexDeviceLogin(
profile: RuntimeProfile,
environment: Record<string, string>,
): Promise<void> {
const login = agentCommand(profile, "codex", ["login", "--device-auth"], {
cwd: WORKSPACE,
env: environment,
clearEnv: true,
stdout: "piped",
stderr: "piped",
}).spawn();
let shown = "";
const append = (chunk: string): void => {
shown += chunk;
};
const drained = Promise.all([
collectText(login.stdout, append),
collectText(login.stderr, append),
]);
await sleep(3000);
await pollForInstructions(() => shown, login.status);
await postAuthorComment(withProfile(shown, profile));
const [result] = await Promise.all([login.status, drained]);
if (!result.success) {
throw new AgentAuthError(`codex login exited with ${result.code}`);
}
}
async function codexApiLogin(
profile: RuntimeProfile,
environment: Record<string, string>,
token: string,
): Promise<void> {
const login = agentCommand(profile, "codex", ["login", "--with-api-key"], {
cwd: WORKSPACE,
env: environment,
clearEnv: true,
stdin: "piped",
stderr: "piped",
}).spawn();
await endWith(login.stdin, `${token}\n`);
const [status, error] = await Promise.all([login.status, text(login.stderr)]);
if (!status.success) {
throw new Error(error.trim() || `codex login exited with ${status.code}`);
}
}
async function signIn(
profile: RuntimeProfile,
environment: Record<string, string>,
authentication: CodexAuthentication,
): Promise<void> {
if (authentication.kind === "api-key") {
await codexApiLogin(profile, environment, authentication.token);
return;
}
const loggedIn = await agentCommand(profile, "codex", ["login", "status"], {
cwd: WORKSPACE,
env: environment,
clearEnv: true,
}).output();
if (!loggedIn.success) {
await codexDeviceLogin(profile, environment);
}
}
async function execCodex(
profile: RuntimeProfile,
environment: Record<string, string>,
prompt: string,
outputPath: string,
): Promise<CodexAttempt> {
const args = [
"exec",
"--model",
profile.model,
"--dangerously-bypass-approvals-and-sandbox",
"--output-last-message",
outputPath,
prompt,
];
const codex = agentCommand(profile, "codex", args, {
cwd: WORKSPACE,
env: environment,
clearEnv: true,
stdout: "inherit",
stderr: "piped",
}).spawn();
const decoder = new TextDecoder();
let error = "";
for await (const chunk of chunks(codex.stderr)) {
await writeChunk(process.stderr, chunk);
error += decoder.decode(chunk, { stream: true });
}
error += decoder.decode();
// Codex echoes the prompt, whose comments may quote auth errors.
return { status: await codex.status, error: error.replaceAll(prompt, "") };
}
export async function runCodex(
profile: RuntimeProfile,
prompt: string,
authentication: CodexAuthentication,
): Promise<string> {
const token = authentication.kind === "api-key" ? authentication.token : undefined;
const environment = agentEnvironment(PROCESS_ENV, profile, token);
await signIn(profile, environment, authentication);
const outputPath = await makeProfileFile(profile, "result.md");
const attempt = (): Promise<CodexAttempt> => execCodex(profile, environment, prompt, outputPath);
const result =
authentication.kind === "api-key"
? await attempt()
: await retryInvalidToken(attempt, async () => {
await runAgentCommand(profile, "codex", ["logout"]);
await codexDeviceLogin(profile, environment);
});
if (!result.status.success) {
const failure = new Error(result.error.trim() || `codex exited with ${result.status.code}`);
if (isCodexAuthFailure(result.error)) {
throw new AgentAuthError("codex sign-in failed", { cause: failure });
}
throw failure;
}
return outputPath;
}
-46
View File
@@ -1,46 +0,0 @@
// Runs OpenCode for one profile and returns its final visible text.
import process from "node:process";
import { chunks, writeChunk } from "./command.ts";
import { agentCommand } from "./isolation.ts";
import {
collectOpenCodeOutput,
openCodeArgs,
openCodeError,
parseOpenCodeEvents,
} from "./opencode.ts";
import { agentEnvironment } from "./profiles.ts";
import { PROCESS_ENV, WORKSPACE } from "./run-context.ts";
import type { RuntimeProfile } from "./runtime.ts";
export async function runOpenCode(
profile: RuntimeProfile,
token: string,
prompt: string,
): Promise<string> {
const child = agentCommand(profile, "opencode", openCodeArgs(profile.model, prompt), {
cwd: WORKSPACE,
env: agentEnvironment(PROCESS_ENV, profile, token),
clearEnv: true,
stdout: "piped",
stderr: "piped",
}).spawn();
const collected = collectOpenCodeOutput(
chunks(child.stdout),
chunks(child.stderr),
async (chunk) => {
await writeChunk(process.stdout, chunk);
},
async (chunk) => {
await writeChunk(process.stderr, chunk);
},
);
const [status, streams] = await Promise.all([child.status, collected]);
const error = streams.stderr.trim();
if (!status.success) {
throw (
openCodeError(streams.stdout) ?? new Error(error || `opencode exited with ${status.code}`)
);
}
return parseOpenCodeEvents(streams.stdout);
}
-30
View File
@@ -1,30 +0,0 @@
// Dispatches one attempt to the agent CLI its profile names.
import { runClaude } from "./agent-claude.ts";
import { runCodex } from "./agent-codex.ts";
import { runOpenCode } from "./agent-opencode.ts";
import { codexAuthentication } from "./auth.ts";
import type { CodexAuthentication } from "./auth.ts";
import type { RuntimeProfile } from "./runtime.ts";
export interface AgentAnswer {
body?: string;
resultPath?: string;
}
// Runs the selected agent; Codex leaves its answer in a file, the others return it.
export async function runAgent(
profile: RuntimeProfile,
token: string | undefined,
prompt: string,
authentication: CodexAuthentication | null,
): Promise<AgentAnswer> {
if (profile.type === "claude") {
return { body: await runClaude(profile, token ?? "", prompt) };
}
if (profile.type === "codex") {
return {
resultPath: await runCodex(profile, prompt, authentication ?? codexAuthentication(token)),
};
}
return { body: await runOpenCode(profile, token ?? "", prompt) };
}
+105 -70
View File
@@ -1,5 +1,14 @@
import {
mkdir,
readFile,
rm,
stat,
symlink,
writeFile,
} from "node:fs/promises";
import { expect, test } from "vite-plus/test"; import { expect, test } from "vite-plus/test";
import { mkdir, readFile, rm, stat, symlink, writeFile } from "node:fs/promises";
import { currentGid, currentUid, IS_WINDOWS, makeTempDir } from "#testing";
import { import {
codexAuthentication, codexAuthentication,
@@ -9,20 +18,24 @@ import {
retryInvalidToken, retryInvalidToken,
stageCodexAuth, stageCodexAuth,
} from "./auth.ts"; } from "./auth.ts";
import { currentGid, currentUid, IS_WINDOWS, makeTempDir } from "./testing.ts";
test("retries once after an invalid refresh token", async () => { test("retries once after an invalid refresh token", async () => {
let attempts = 0; let attempts = 0;
let relogins = 0; let relogins = 0;
const result = await retryInvalidToken( const result = await retryInvalidToken(
() => async () => {
Promise.resolve({ const resolved = await Promise.resolve({
status: { success: false }, code: 1,
error: attempts++ === 0 ? "ERROR: invalid_refresh_token" : "still failed", error:
}), (attempts += 1) === 1
() => { ? "ERROR: invalid_refresh_token"
relogins++; : "still failed",
return Promise.resolve(); });
return resolved;
},
async () => {
relogins += 1;
await Promise.resolve();
}, },
); );
@@ -37,16 +50,17 @@ test("does not retry an unrelated failure", async () => {
let attempts = 0; let attempts = 0;
let relogins = 0; let relogins = 0;
await retryInvalidToken( await retryInvalidToken(
() => { async () => {
attempts++; attempts += 1;
return Promise.resolve({ const resolved = await Promise.resolve({
status: { success: false }, code: 1,
error: "rate limited", error: "rate limited",
}); });
return resolved;
}, },
() => { async () => {
relogins++; relogins += 1;
return Promise.resolve(); await Promise.resolve();
}, },
); );
@@ -84,36 +98,42 @@ test("re-logs in after the job 9837 revoked-session stderr", async () => {
let relogins = 0; let relogins = 0;
let attempts = 0; let attempts = 0;
const result = await retryInvalidToken( const result = await retryInvalidToken(
() => async () => {
Promise.resolve({ const resolved = await Promise.resolve({
status: { success: attempts++ > 0 }, code: (attempts += 1) > 1 ? 0 : 1,
error: attempts === 1 ? REVOKED_CODEX.join("\n") : "", error: attempts === 1 ? REVOKED_CODEX.join("\n") : "",
}), });
() => { return resolved;
relogins++; },
return Promise.resolve(); async () => {
relogins += 1;
await Promise.resolve();
}, },
); );
expect({ relogins, success: result.status.success }).toStrictEqual({ expect({ relogins, code: result.code }).toStrictEqual({
relogins: 1, relogins: 1,
success: true, code: 0,
}); });
}); });
test("recognizes a rejected Claude OAuth token", () => { test("recognizes a rejected Claude OAuth token", () => {
expect( expect(
isClaudeAuthFailure({ isClaudeAuthFailure({
api_error_status: 401, apiErrorStatus: 401,
result: "Failed to authenticate. API Error: 401 OAuth access token is invalid.", result:
"Failed to authenticate. API Error: 401 OAuth access token is invalid.",
}), }),
).toBe(true); ).toBe(true);
expect( expect(
isClaudeAuthFailure({ isClaudeAuthFailure({
result: "OAuth token has expired. Please obtain a new token or refresh your existing token.", result:
"OAuth token has expired. Please obtain a new token or refresh your existing token.",
}), }),
).toBe(true); ).toBe(true);
expect(isClaudeAuthFailure({ api_error_status: 429, result: "API Error: 429" })).toBe(false); expect(
isClaudeAuthFailure({ apiErrorStatus: 429, result: "API Error: 429" }),
).toBe(false);
}); });
test("selects API-key login only for a configured Codex token", () => { test("selects API-key login only for a configured Codex token", () => {
@@ -132,47 +152,62 @@ const AUTH_TOKENS = {
}; };
const AUTH = JSON.stringify({ auth_mode: "chatgpt", tokens: AUTH_TOKENS }); const AUTH = JSON.stringify({ auth_mode: "chatgpt", tokens: AUTH_TOKENS });
test.skipIf(IS_WINDOWS)("stages and persists only a regular private Codex login", async () => { test.skipIf(IS_WINDOWS)(
const root = await makeTempDir(); "stages and persists only a regular private Codex login",
try { async () => {
const canonical = `${root}/persistent/auth.json`; const root = await makeTempDir();
await mkdir(`${root}/persistent`); try {
await writeFile(canonical, AUTH, { mode: 0o600 }); const canonical = `${root}/persistent/auth.json`;
const home = `${root}/profile`; await mkdir(`${root}/persistent`);
await mkdir(home); await writeFile(canonical, AUTH, { mode: 0o600 });
const home = `${root}/profile`;
await mkdir(home);
const staged = await stageCodexAuth(canonical, home, currentUid(), currentGid()); const staged = await stageCodexAuth(
expect(await readFile(staged, "utf8")).toStrictEqual(AUTH); canonical,
expect((await stat(staged)).mode & 0o777).toBe(0o600); home,
currentUid(),
currentGid(),
);
expect(await readFile(staged, "utf8")).toStrictEqual(AUTH);
const stagedStat = await stat(staged);
expect(stagedStat.mode & 0o777).toBe(0o600);
const refreshed = JSON.stringify({ const refreshed = JSON.stringify({
auth_mode: "chatgpt", auth_mode: "chatgpt",
tokens: { ...AUTH_TOKENS, access_token: "refreshed" }, tokens: { ...AUTH_TOKENS, access_token: "refreshed" },
}); });
await writeFile(staged, refreshed); await writeFile(staged, refreshed);
await persistCodexAuth(staged, canonical); await persistCodexAuth(staged, canonical);
expect(await readFile(canonical, "utf8")).toStrictEqual(refreshed); expect(await readFile(canonical, "utf8")).toStrictEqual(refreshed);
expect((await stat(canonical)).mode & 0o777).toBe(0o600); const canonicalStat = await stat(canonical);
} finally { expect(canonicalStat.mode & 0o777).toBe(0o600);
await rm(root, { recursive: true }); } finally {
} await rm(root, { recursive: true });
}); }
},
);
test.skipIf(IS_WINDOWS)("rejects a symlink or malformed Codex login", async () => { test.skipIf(IS_WINDOWS)(
const root = await makeTempDir(); "rejects a symlink or malformed Codex login",
try { async () => {
const canonical = `${root}/auth.json`; const root = await makeTempDir();
const malformed = `${root}/malformed.json`; try {
const linked = `${root}/linked.json`; const canonical = `${root}/auth.json`;
await writeFile(canonical, AUTH, { mode: 0o600 }); const malformed = `${root}/malformed.json`;
await writeFile(malformed, '{"tokens":{}}', { mode: 0o600 }); const linked = `${root}/linked.json`;
await symlink(canonical, linked); await writeFile(canonical, AUTH, { mode: 0o600 });
await writeFile(malformed, '{"tokens":{}}', { mode: 0o600 });
await symlink(canonical, linked);
await expect(() => persistCodexAuth(linked, canonical)).rejects.toThrow("regular file"); await expect(persistCodexAuth(linked, canonical)).rejects.toThrow(
await expect(() => persistCodexAuth(malformed, canonical)).rejects.toThrow( "regular file",
"invalid Codex login", );
); await expect(persistCodexAuth(malformed, canonical)).rejects.toThrow(
} finally { "invalid Codex login",
await rm(root, { recursive: true }); );
} } finally {
}); await rm(root, { recursive: true });
}
},
);
+54 -33
View File
@@ -10,15 +10,25 @@ import {
rm, rm,
writeFile, writeFile,
} from "node:fs/promises"; } from "node:fs/promises";
import { dirname, join } from "node:path"; import nodePath from "node:path";
import { isNotFound } from "./errors.ts"; import { field, isNotFound, isObject } from "#common";
import { isObject } from "./json.ts";
export type CodexAuthentication = { kind: "api-key"; token: string } | { kind: "persistent" }; interface CodexApiKey {
kind: "api-key";
token: string;
}
interface CodexPersistent {
kind: "persistent";
}
export type CodexAuthentication = CodexApiKey | CodexPersistent;
export function codexAuthentication(token?: string): CodexAuthentication { export function codexAuthentication(token?: string): CodexAuthentication {
return token === undefined ? { kind: "persistent" } : { kind: "api-key", token }; return token === undefined
? { kind: "persistent" }
: { kind: "api-key", token };
} }
// Codex logs revoked, expired, or reused ChatGPT sign-ins and rejected API keys // Codex logs revoked, expired, or reused ChatGPT sign-ins and rejected API keys
@@ -34,41 +44,38 @@ export function isCodexAuthFailure(error: string): boolean {
// stream-json result, e.g. "Failed to authenticate. API Error: 401 OAuth // stream-json result, e.g. "Failed to authenticate. API Error: 401 OAuth
// access token is invalid." with api_error_status 401. // access token is invalid." with api_error_status 401.
// The fields of Claude Code's stream-json `result` event that tell a sign-in // The fields of Claude Code's stream-json `result` event that tell a sign-in
// failure apart, under their own names. // failure apart, as `apiErrorStatus` and `result`.
export type ClaudeFailureFields = Partial<Record<"api_error_status", number | null>> & { export interface ClaudeFailureFields {
apiErrorStatus?: number;
result?: string; result?: string;
}; }
export function isClaudeAuthFailure(result: ClaudeFailureFields): boolean { export function isClaudeAuthFailure(result: ClaudeFailureFields): boolean {
return ( return (
result.api_error_status === 401 || result.apiErrorStatus === 401 ||
/failed to authenticate|oauth (?:access )?token (?:is invalid|has expired)|invalid api key|authentication_error|please run \/login|not logged in/iu.test( /failed to authenticate|oauth (?:access )?token (?:is invalid|has expired)|invalid api key|authentication_error|please run \/login|not logged in/iu.test(
result.result ?? "", result.result ?? "",
) )
); );
} }
export async function retryInvalidToken<T extends { status: { success: boolean }; error: string }>( export interface CommandResult {
code: number;
error: string;
}
export async function retryInvalidToken<T extends CommandResult>(
attempt: () => Promise<T>, attempt: () => Promise<T>,
relogin: () => Promise<void>, relogin: () => Promise<void>,
): Promise<T> { ): Promise<T> {
let result = await attempt(); let result = await attempt();
if (!result.status.success && isCodexAuthFailure(result.error)) { if (result.code !== 0 && isCodexAuthFailure(result.error)) {
await relogin(); await relogin();
result = await attempt(); result = await attempt();
} }
return result; return result;
} }
function hasCodexTokens(tokens: unknown): boolean {
return (
isObject(tokens) &&
["access_token", "refresh_token", "id_token", "account_id"].every(
(name) => typeof tokens[name] === "string" && tokens[name] !== "",
)
);
}
async function readCodexAuth(path: string): Promise<string> { async function readCodexAuth(path: string): Promise<string> {
const stat = await lstat(path); const stat = await lstat(path);
if (!stat.isFile() || stat.isSymbolicLink()) { if (!stat.isFile() || stat.isSymbolicLink()) {
@@ -78,17 +85,24 @@ async function readCodexAuth(path: string): Promise<string> {
throw new Error("Codex login must be private"); throw new Error("Codex login must be private");
} }
const text = await readFile(path, "utf8"); const text = await readFile(path, "utf8");
let value: unknown; let value: unknown = null;
try { try {
value = JSON.parse(text); value = JSON.parse(text);
} catch { } catch {
throw new Error("invalid Codex login"); throw new Error("invalid Codex login");
} }
const authMode = field(value, "auth_mode");
const tokens = field(value, "tokens");
if ( if (
!isObject(value) || typeof authMode !== "string" ||
typeof value.auth_mode !== "string" || authMode === "" ||
value.auth_mode === "" || !isObject(tokens) ||
!hasCodexTokens(value.tokens) !["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"); throw new Error("invalid Codex login");
} }
@@ -101,32 +115,39 @@ export async function stageCodexAuth(
uid: number, uid: number,
gid: number, gid: number,
): Promise<string> { ): Promise<string> {
const codexHome = join(profileHome, ".codex"); const codexHome = nodePath.join(profileHome, ".codex");
await mkdir(codexHome, { recursive: true, mode: 0o700 }); await mkdir(codexHome, { recursive: true, mode: 0o700 });
await chmod(codexHome, 0o700); await chmod(codexHome, 0o700);
await chown(codexHome, uid, gid); await chown(codexHome, uid, gid);
const stagedPath = join(codexHome, "auth.json"); const stagedPath = nodePath.join(codexHome, "auth.json");
try { try {
await writeFile(stagedPath, await readCodexAuth(canonicalPath), { mode: 0o600 }); await writeFile(stagedPath, await readCodexAuth(canonicalPath), {
mode: 0o600,
});
await chmod(stagedPath, 0o600); await chmod(stagedPath, 0o600);
await chown(stagedPath, uid, gid); await chown(stagedPath, uid, gid);
} catch (error) { } catch (error) {
if (!isNotFound(error)) throw error; if (!isNotFound(error)) {
throw error;
}
} }
return stagedPath; return stagedPath;
} }
// Creates an empty private file under a fresh name, as Deno.makeTempFile did. // Creates an empty private file under a fresh name.
async function makeTempFile(directory: string): Promise<string> { async function makeTempFile(directory: string): Promise<string> {
const path = join(directory, `.tmp-${randomUUID()}`); const path = nodePath.join(directory, `.tmp-${randomUUID()}`);
const handle = await open(path, "wx", 0o600); const handle = await open(path, "wx", 0o600);
await handle.close(); await handle.close();
return path; return path;
} }
export async function persistCodexAuth(stagedPath: string, canonicalPath: string): Promise<void> { export async function persistCodexAuth(
stagedPath: string,
canonicalPath: string,
): Promise<void> {
const text = await readCodexAuth(stagedPath); const text = await readCodexAuth(stagedPath);
const directory = dirname(canonicalPath); const directory = nodePath.dirname(canonicalPath);
await mkdir(directory, { recursive: true, mode: 0o700 }); await mkdir(directory, { recursive: true, mode: 0o700 });
const temporary = await makeTempFile(directory); const temporary = await makeTempFile(directory);
try { try {
+21
View File
@@ -0,0 +1,21 @@
// 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,14 +1,18 @@
import { assert, expect, test } from "vite-plus/test"; import { assert, expect, test } from "vite-plus/test";
import { AgentAuthError } from "#profiles";
import { import {
collectOpenCodeOutput, collectOpenCodeOutput,
openCodeArgs, openCodeArgs,
openCodeError, openCodeError,
parseOpenCodeEvents, parseOpenCodeEvents,
} from "./opencode.ts"; } from "./opencode.ts";
import { AgentAuthError } from "./profiles.ts";
test("builds a non-interactive auto-approved OpenCode command", () => { test("builds a non-interactive auto-approved OpenCode command", () => {
expect(openCodeArgs("openrouter/qwen/qwen3.8-27b", "Review this")).toStrictEqual([ expect(
openCodeArgs("openrouter/qwen/qwen3.8-27b", "Review this"),
).toStrictEqual([
"run", "run",
"--format", "--format",
"json", "json",
@@ -68,7 +72,10 @@ test("streams OpenCode output while collecting it", async () => {
}, },
); );
expect(result).toStrictEqual({ stdout: "first\nsecond\n", stderr: "warning\n" }); expect(result).toStrictEqual({
stdout: "first\nsecond\n",
stderr: "warning\n",
});
expect(shown).toStrictEqual(["out:first\n", "err:warning\n", "out:second\n"]); expect(shown).toStrictEqual(["out:first\n", "err:warning\n", "out:second\n"]);
}); });
@@ -92,12 +99,14 @@ test("reports a rejected OpenRouter key as a sign-in failure", () => {
assert.instanceOf(error, AgentAuthError); assert.instanceOf(error, AgentAuthError);
expect(error.message).toBe("opencode sign-in failed"); expect(error.message).toBe("opencode sign-in failed");
assert.instanceOf(error.cause, Error); assert.instanceOf(error.cause, Error);
expect(error.cause.message).toBe("opencode APIError (API error 401): User not found."); expect(error.cause.message).toBe(
"opencode APIError (API error 401): User not found.",
);
}); });
test("surfaces an OpenCode error event instead of empty output", () => { test("surfaces an OpenCode error event instead of empty output", () => {
expect(() => parseOpenCodeEvents(openRouterError(429, "Rate limit exceeded"))).toThrow( expect(() =>
"API error 429", 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();
}); });
+40 -22
View File
@@ -1,5 +1,5 @@
import { isObject } from "./json.ts"; import { field, isObject } from "#common";
import { AgentAuthError } from "./profiles.ts"; import { AgentAuthError } from "#profiles";
export function openCodeArgs(model: string, prompt: string): string[] { export function openCodeArgs(model: string, prompt: string): string[] {
return ["run", "--format", "json", "--auto", "--model", model, prompt]; return ["run", "--format", "json", "--auto", "--model", model, prompt];
@@ -7,13 +7,21 @@ export function openCodeArgs(model: string, prompt: string): string[] {
type ChunkWriter = (chunk: Uint8Array) => void | Promise<void>; type ChunkWriter = (chunk: Uint8Array) => void | Promise<void>;
interface OpenCodeOutput {
stdout: string;
stderr: string;
}
export async function collectOpenCodeOutput( export async function collectOpenCodeOutput(
stdout: AsyncIterable<Uint8Array>, stdout: AsyncIterable<Uint8Array>,
stderr: AsyncIterable<Uint8Array>, stderr: AsyncIterable<Uint8Array>,
writeStdout: ChunkWriter, writeStdout: ChunkWriter,
writeStderr: ChunkWriter, writeStderr: ChunkWriter,
): Promise<{ stdout: string; stderr: string }> { ): Promise<OpenCodeOutput> {
const collect = async (stream: AsyncIterable<Uint8Array>, write: ChunkWriter) => { const collect = async (
stream: AsyncIterable<Uint8Array>,
write: ChunkWriter,
): Promise<string> => {
const chunks: Uint8Array[] = []; const chunks: Uint8Array[] = [];
for await (const chunk of stream) { for await (const chunk of stream) {
chunks.push(chunk); chunks.push(chunk);
@@ -38,23 +46,26 @@ export async function collectOpenCodeOutput(
// OpenCode reports provider failures, including a rejected OpenRouter key // OpenCode reports provider failures, including a rejected OpenRouter key
// (401 "User not found."), as a JSON error event on stdout and exits 1 with an // (401 "User not found."), as a JSON error event on stdout and exits 1 with an
// empty stderr. // empty stderr.
export function openCodeError(output: string): Error | undefined { export function openCodeError(output: string): Error | null {
let failure: Error | undefined; let failure: Error | null = null;
for (const line of output.split("\n")) { for (const line of output.split("\n")) {
if (line.trim() === "") continue; if (line.trim() === "") {
let event: unknown; continue;
}
let event: unknown = null;
try { try {
event = JSON.parse(line); event = JSON.parse(line);
} catch { } catch {
continue; continue;
} }
if (!isObject(event)) continue; const error = field(event, "error");
const { type, error } = event; if (field(event, "type") !== "error" || !isObject(error)) {
if (type !== "error" || !isObject(error)) {
continue; continue;
} }
const { name, data } = error; const name = field(error, "name");
const { message, statusCode } = isObject(data) ? data : {}; const data = field(error, "data");
const message = field(data, "message");
const statusCode = field(data, "statusCode");
const detail = `opencode ${typeof name === "string" ? name : "error"}${ const detail = `opencode ${typeof name === "string" ? name : "error"}${
typeof statusCode === "number" ? ` (API error ${statusCode})` : "" typeof statusCode === "number" ? ` (API error ${statusCode})` : ""
}: ${typeof message === "string" ? message : "unknown error"}`; }: ${typeof message === "string" ? message : "unknown error"}`;
@@ -69,18 +80,25 @@ export function openCodeError(output: string): Error | undefined {
} }
export function parseOpenCodeEvents(output: string): string { export function parseOpenCodeEvents(output: string): string {
let result: string | undefined; let result: string | null = null;
for (const line of output.split("\n")) { for (const line of output.split("\n")) {
if (line.trim() === "") continue; if (line.trim() === "") {
continue;
}
const event: unknown = JSON.parse(line); const event: unknown = JSON.parse(line);
if (!isObject(event)) continue; const part = field(event, "part");
const { type, part } = event; if (field(event, "type") !== "text" || !isObject(part)) {
if (type !== "text" || !isObject(part)) continue; continue;
const { text } = part; }
if (typeof text === "string" && text.trim() !== "") result = text; const text = field(part, "text");
if (typeof text === "string" && text.trim() !== "") {
result = text;
}
} }
if (result === undefined) { if (result === null) {
throw openCodeError(output) ?? new Error("OpenCode produced no text output"); throw (
openCodeError(output) ?? new Error("OpenCode produced no text output")
);
} }
return result; return result;
} }
-160
View File
@@ -1,160 +0,0 @@
// A child process with the call shapes the runner was reviewed with: `output()`
// collects both streams, `spawn()` hands them out, and `clearEnv` passes only
// the given environment. Streams default as Deno's did: stdin is inherited,
// stdout and stderr are piped for `output()` and inherited for `spawn()`.
import { spawn as spawnProcess } from "node:child_process";
import type { ChildProcess, StdioOptions } from "node:child_process";
import { Buffer } from "node:buffer";
import { constants } from "node:os";
import process from "node:process";
import type { Readable, Writable } from "node:stream";
import { buffer } from "node:stream/consumers";
import { finished } from "node:stream/promises";
export type StdioMode = "inherit" | "piped" | "null";
export interface CommandOptions {
args?: string[];
cwd?: string;
env?: Record<string, string>;
clearEnv?: boolean;
uid?: number;
gid?: number;
stdin?: StdioMode;
stdout?: StdioMode;
stderr?: StdioMode;
}
export interface CommandStatus {
success: boolean;
code: number;
}
export interface CommandOutput extends CommandStatus {
stdout: Uint8Array;
stderr: Uint8Array;
}
export interface CommandChild {
status: Promise<CommandStatus>;
stdin: Writable;
stdout: Readable;
stderr: Readable;
}
const STDIO: Record<StdioMode, "inherit" | "pipe" | "ignore"> = {
inherit: "inherit",
piped: "pipe",
null: "ignore",
};
function signalNumber(signal: NodeJS.Signals): number {
return constants.signals[signal];
}
function startProcess(
command: string,
options: CommandOptions,
outputDefault: StdioMode,
): ChildProcess {
const stdio: StdioOptions = [
STDIO[options.stdin ?? "inherit"],
STDIO[options.stdout ?? outputDefault],
STDIO[options.stderr ?? outputDefault],
];
const environment = options.clearEnv === true ? options.env : { ...process.env, ...options.env };
return spawnProcess(command, options.args ?? [], {
stdio,
...(options.cwd !== undefined && { cwd: options.cwd }),
...(environment !== undefined && { env: environment }),
...(options.uid !== undefined && { uid: options.uid }),
...(options.gid !== undefined && { gid: options.gid }),
});
}
// Settles when the process exits, before its streams close, as Deno's did. A
// signal-killed process reports 128 plus the signal number, as a shell does.
function waitForStatus(child: ChildProcess): Promise<CommandStatus> {
return new Promise((resolve, reject) => {
child.once("error", reject);
child.once("exit", (code, signal) => {
const exitCode = code ?? (signal === null ? 1 : 128 + signalNumber(signal));
resolve({ success: exitCode === 0, code: exitCode });
});
});
}
function piped<T>(stream: T | null, name: string): T {
if (stream === null) {
throw new TypeError(`${name} is not piped`);
}
return stream;
}
function collect(stream: Readable | null): Promise<Uint8Array> {
return stream === null ? Promise.resolve(new Uint8Array()) : buffer(stream);
}
// A stream's chunks as bytes, whether or not it has an encoding set.
export async function* chunks(stream: Readable): AsyncGenerator<Uint8Array> {
for await (const chunk of stream) {
const value: unknown = chunk;
if (value instanceof Uint8Array) {
yield value;
} else if (typeof value === "string") {
yield Buffer.from(value);
}
}
}
export function spawn(command: string, options: CommandOptions = {}): CommandChild {
const child = startProcess(command, options, "inherit");
const status = waitForStatus(child);
// A failed start rejects `status`; the caller sees it once it awaits that.
status.catch(() => {});
return {
status,
get stdin(): Writable {
return piped(child.stdin, "stdin");
},
get stdout(): Readable {
return piped(child.stdout, "stdout");
},
get stderr(): Readable {
return piped(child.stderr, "stderr");
},
};
}
export async function output(
command: string,
options: CommandOptions = {},
): Promise<CommandOutput> {
const child = startProcess(command, options, "piped");
const [status, stdout, stderr] = await Promise.all([
waitForStatus(child),
collect(child.stdout),
collect(child.stderr),
]);
return { ...status, stdout, stderr };
}
// Writes one chunk and settles once the stream has taken it, as Deno's
// `Deno.stdout.write` did.
export async function writeChunk(stream: Writable, chunk: Uint8Array): Promise<void> {
await new Promise<void>((resolve, reject) => {
stream.write(chunk, (error) => {
if (error) {
reject(error);
} else {
resolve();
}
});
});
}
// Writes the last chunk and settles once the stream has flushed and closed.
export async function endWith(stream: Writable, chunk: string): Promise<void> {
stream.end(chunk);
await finished(stream);
}
+1 -2
View File
@@ -1,5 +1,4 @@
// Node reports a missing file as ENOENT and a missing process as ESRCH, where // A missing file or a missing process: Node reports them as ENOENT and ESRCH.
// Deno threw `Deno.errors.NotFound` for both.
export function isNotFound(error: unknown): boolean { export function isNotFound(error: unknown): boolean {
if (!(error instanceof Error) || !("code" in error)) { if (!(error instanceof Error) || !("code" in error)) {
return false; return false;
+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";
+25
View File
@@ -0,0 +1,25 @@
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;
}
-112
View File
@@ -1,112 +0,0 @@
import { expect, test } from "vite-plus/test";
import { chmod, chown, mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises";
import process from "node:process";
import { isNotFound } from "./errors.ts";
import {
agentCommand,
isRunnableProfileProcess,
killProfileProcesses,
readProfileFile,
} from "./isolation.ts";
import type { RuntimeProfile } from "./runtime.ts";
import { currentUid, IS_ROOT, IS_WINDOWS, makeTempDir } from "./testing.ts";
const profile = (uid: number, home: string): RuntimeProfile => ({
index: uid - 20_000,
type: "claude",
model: "test",
uid,
gid: uid,
home,
tokenFile: null,
});
test("zombie process entries are already quiescent", () => {
const status = "State:\tZ (zombie)\nUid:\t20010\t20010\t20010\t20010\n";
expect(isRunnableProfileProcess(status, 20_010)).toBe(false);
});
test.skipIf(IS_WINDOWS)("refuses to read a profile result symlink as root", async () => {
const root = await makeTempDir();
try {
const secret = `${root}/reviewer-token`;
const result = `${root}/result.md`;
await writeFile(secret, "reviewer-secret", { mode: 0o600 });
await symlink(secret, result);
await expect(() => readProfileFile(profile(currentUid(), root), result)).rejects.toThrow(
"regular private file",
);
} finally {
await rm(root, { recursive: true });
}
});
test.skipIf(!IS_ROOT)("a profile process can read only its own private files", async () => {
const root = await makeTempDir();
await chmod(root, 0o711);
try {
const homeA = `${root}/a`;
const homeB = `${root}/b`;
await mkdir(homeA, { mode: 0o700 });
await mkdir(homeB, { mode: 0o700 });
await chown(homeA, 20_000, 20_000);
await chown(homeB, 20_001, 20_001);
await writeFile(`${homeA}/token`, "own", { mode: 0o600 });
await chown(`${homeA}/token`, 20_000, 20_000);
await writeFile(`${homeB}/token`, "sibling", { mode: 0o600 });
await chown(`${homeB}/token`, 20_001, 20_001);
await writeFile(`${root}/reviewer`, "root", { mode: 0o600 });
const selected = profile(20_000, homeA);
const own = await agentCommand(selected, "/bin/cat", [`${homeA}/token`], {
cwd: "/",
}).output();
const sibling = await agentCommand(selected, "/bin/cat", [`${homeB}/token`], {
cwd: "/",
}).output();
const reviewer = await agentCommand(selected, "/bin/cat", [`${root}/reviewer`], {
cwd: "/",
}).output();
expect(new TextDecoder().decode(own.stdout)).toBe("own");
expect(own.success).toBe(true);
expect(sibling.success).toBe(false);
expect(reviewer.success).toBe(false);
} finally {
await rm(root, { recursive: true });
}
});
test.skipIf(!IS_ROOT)("cleanup leaves no runnable daemonized process", async () => {
const selected = profile(20_010, "/tmp");
const launched = await agentCommand(
selected,
"/bin/sh",
["-c", "sleep 60 >/dev/null 2>&1 & echo $!"],
{ cwd: "/" },
).output();
const pid = Number(new TextDecoder().decode(launched.stdout).trim());
expect(Number.isInteger(pid)).toBe(true);
await killProfileProcesses(selected.uid);
const status = await readFile(`/proc/${pid}/status`, "utf8").catch((error: unknown) => {
if (isNotFound(error)) return null;
throw error;
});
expect(status === null || !isRunnableProfileProcess(status, selected.uid)).toBe(true);
});
test.skipIf(!IS_ROOT)("the agent process cannot read its coordinator environment", async () => {
const selected = profile(20_020, "/tmp");
const result = await agentCommand(selected, "/usr/bin/cat", [`/proc/${process.pid}/environ`], {
cwd: "/",
env: { PARENT_SECRET: "must-not-cross-boundary" },
clearEnv: true,
stderr: "piped",
}).output();
expect(result.success).toBe(false);
});
-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);
}
-114
View File
@@ -1,114 +0,0 @@
// Reads what the agent phase left behind: its outcome, and the review and
// anchors files it wrote into a profile home.
import type { Stats } from "node:fs";
import { lstat, readFile } from "node:fs/promises";
import { join } from "node:path";
import { isNotFound } from "./errors.ts";
import { isObject } from "./json.ts";
import { isFailureReason, isProfileType } from "./profiles.ts";
import type { ProfileFailure } from "./profiles.ts";
import type { RuntimeManifest, RuntimeOutcome } from "./runtime.ts";
export interface Anchor {
path: string;
line: number;
side: "new" | "old";
body: string;
}
function parseAnchor(entry: unknown, at: string): Anchor {
if (!isObject(entry)) {
throw new TypeError(`${at}: not an object`);
}
const { path, line, side = "new", body } = entry;
if (typeof path !== "string" || path === "") {
throw new TypeError(`${at}.path: expected a repository path`);
}
if (typeof line !== "number" || !Number.isInteger(line) || line < 1) {
throw new TypeError(`${at}.line: expected a line number`);
}
if (side !== "new" && side !== "old") {
throw new Error(`${at}.side: expected "new" or "old"`);
}
if (typeof body !== "string" || body.trim() === "") {
throw new TypeError(`${at}.body: expected the finding`);
}
return { path, line, side, body };
}
export function parseAnchors(text: string, sourcePath: string): Anchor[] {
const entries: unknown = JSON.parse(text);
if (!Array.isArray(entries)) {
throw new TypeError(`${sourcePath}: not an array`);
}
return entries.map((entry: unknown, index) => parseAnchor(entry, `${sourcePath}[${index}]`));
}
// Only known labels reach the comment, never raw agent output.
function knownFailures(failures: unknown): ProfileFailure[] {
return (Array.isArray(failures) ? failures : []).flatMap((entry: unknown) => {
if (!isObject(entry)) {
return [];
}
const { type, model, reason } = entry;
return isProfileType(type) && typeof model === "string" && isFailureReason(reason)
? [{ type, model, reason }]
: [];
});
}
export async function readOutcome(path: string): Promise<RuntimeOutcome> {
const value: unknown = JSON.parse(await readFile(path, "utf8"));
if (!isObject(value) || !("ok" in value)) {
throw new Error("invalid agent outcome");
}
if (value.ok === false) {
return { ok: false, failures: knownFailures(value.failures) };
}
const { ok, body, reviewPath, anchorsPath, profile } = value;
if (
ok !== true ||
typeof body !== "string" ||
typeof reviewPath !== "string" ||
typeof anchorsPath !== "string" ||
!isObject(profile) ||
!isProfileType(profile.type) ||
typeof profile.model !== "string"
) {
throw new Error("invalid agent outcome");
}
return {
ok: true,
body,
reviewPath,
anchorsPath,
profile: { type: profile.type, model: profile.model },
};
}
export async function readAgentArtifact(
manifest: RuntimeManifest,
path: string,
name: string,
): Promise<string | null> {
const profile = manifest.profiles.find((entry) => join(entry.home, name) === path);
if (profile === undefined) throw new Error(`${name}: outside profile home`);
let stat: Stats;
try {
stat = await lstat(path);
} catch (error) {
if (isNotFound(error)) return null;
throw error;
}
if (
!stat.isFile() ||
stat.isSymbolicLink() ||
stat.uid !== profile.uid ||
(stat.mode & 0o077) !== 0
) {
throw new Error(`${name}: expected a profile-owned regular private file`);
}
const text = await readFile(path, "utf8");
return text === "" ? null : text;
}
-185
View File
@@ -1,185 +0,0 @@
import { readFile, rm } from "node:fs/promises";
import process from "node:process";
import { isNotFound } from "./errors.ts";
import { parseAnchors, readAgentArtifact, readOutcome } from "./post-artifacts.ts";
import type { Anchor } from "./post-artifacts.ts";
import { failureFooter, withProfile } from "./profiles.ts";
import type { ProfileFailure } from "./profiles.ts";
import { readRuntimeManifest } from "./runtime.ts";
import type { RuntimeManifest, RuntimeOutcome } from "./runtime.ts";
export interface PostContext {
apiUrl: string;
repository: string;
issueIndex: string;
eventName: string;
}
type SuccessOutcome = Extract<RuntimeOutcome, { ok: true }>;
type GiteaRequest = (path: string, body: unknown) => Promise<void>;
// The reviewer's view of one issue or pull request.
interface Publisher {
context: PostContext;
request: GiteaRequest;
postComment(body: string): Promise<void>;
}
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 failureComment = (failures: ProfileFailure[]): string =>
`AI bot failed.\n\n${failureFooter(failures)}`;
class GiteaRequestError extends Error {}
function giteaRequest(apiUrl: string, reviewerToken: string, fetcher: typeof fetch): GiteaRequest {
return async (path, body) => {
let response: Response;
try {
response = await fetcher(`${apiUrl}/${path}`, {
method: "POST",
headers: {
Authorization: `token ${reviewerToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
throw new GiteaRequestError(`${path}: ${detail}`, { cause: error });
}
if (!response.ok) {
throw new GiteaRequestError(`${path}: ${response.status} ${await response.text()}`);
}
};
}
async function readSuccess(
manifest: RuntimeManifest,
publisher: Publisher,
): Promise<SuccessOutcome> {
let outcome: RuntimeOutcome;
try {
outcome = await readOutcome(manifest.outcomeFile);
} catch (error) {
await publisher.postComment(failureComment([]));
throw new Error("agent phase failed", { cause: error });
}
if (!outcome.ok) {
await publisher.postComment(failureComment(outcome.failures));
throw new Error("agent phase failed");
}
return outcome;
}
async function postReview(
publisher: Publisher,
outcome: SuccessOutcome,
review: string,
anchors: Anchor[],
): Promise<void> {
const { context, request } = publisher;
const [verdict = "", ...rest] = review.split("\n");
const [event, mark] = VERDICTS[verdict.trim()] ?? ["COMMENT", "💬"];
const send = async (entries: Anchor[]): Promise<void> => {
await request(`repos/${context.repository}/pulls/${context.issueIndex}/reviews`, {
body: stripAnsi(
withProfile([`${mark} ${verdict.trim()}`, ...rest].join("\n"), outcome.profile),
),
event,
comments: entries.map(({ path, line, side, body }) => ({
path,
body: stripAnsi(withProfile(body, outcome.profile)),
new_position: side === "new" ? line : 0,
old_position: side === "old" ? line : 0,
})),
});
};
await send(anchors).catch(async (error: unknown) => {
if (anchors.length === 0) throw error;
console.error(
`inline comments rejected: ${error instanceof Error ? error.message : String(error)}`,
);
await send([]);
});
}
async function publishResult(
manifest: RuntimeManifest,
outcome: SuccessOutcome,
publisher: Publisher,
): Promise<void> {
const review = await readAgentArtifact(manifest, outcome.reviewPath, "review.md");
if (review === null) {
if (publisher.context.eventName === "pull_request") {
throw new Error("agent did not write a pull request review");
}
await publisher.postComment(withProfile(outcome.body, outcome.profile));
return;
}
const anchorText = await readAgentArtifact(manifest, outcome.anchorsPath, "anchors.json");
const anchors = anchorText === null ? [] : parseAnchors(anchorText, outcome.anchorsPath);
await postReview(publisher, outcome, review, anchors);
}
async function publish(
manifest: RuntimeManifest,
context: PostContext,
fetcher: typeof fetch,
): Promise<void> {
const reviewerToken = (await readFile(manifest.reviewerFile, "utf8")).trim();
const request = giteaRequest(context.apiUrl, reviewerToken, fetcher);
const publisher: Publisher = {
context,
request,
postComment: async (body) => {
await request(`repos/${context.repository}/issues/${context.issueIndex}/comments`, {
body: stripAnsi(body),
});
},
};
const outcome = await readSuccess(manifest, publisher);
try {
await publishResult(manifest, outcome, publisher);
} catch (error) {
if (error instanceof GiteaRequestError) throw error;
await publisher.postComment(failureComment([{ ...outcome.profile, reason: "failed" }]));
throw error;
}
}
export async function postRuntime(
manifestPath: string,
context: PostContext,
fetcher: typeof fetch = fetch,
): Promise<void> {
const manifest = await readRuntimeManifest(manifestPath);
try {
await publish(manifest, context, fetcher);
} finally {
await rm(manifest.runtimeDir, { recursive: true }).catch((error: unknown) => {
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"),
});
}
+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"),
});
}
+94 -57
View File
@@ -1,12 +1,11 @@
import { chmod, mkdir, rm, stat, symlink, writeFile } from "node:fs/promises"; import { chmod, mkdir, rm, stat, symlink, writeFile } from "node:fs/promises";
import { assert, expect, test } from "vite-plus/test"; import { assert, expect, test } from "vite-plus/test";
import { isNotFound } from "./errors.ts"; import { isNotFound } from "#common";
import { postRuntime } from "./post.ts"; import type { RuntimeFailure, RuntimeManifest, RuntimeOutcome } from "#runtime";
import type { PostContext } from "./post.ts"; import { currentGid, currentUid, IS_WINDOWS, makeTempDir } from "#testing";
import type { RuntimeManifest, RuntimeOutcome } from "./runtime.ts";
import { currentGid, currentUid, IS_WINDOWS, makeTempDir } from "./testing.ts"; import { type Fetcher, type PostContext, postRuntime } from "./post.ts";
const context: PostContext = { const context: PostContext = {
apiUrl: "https://gitea.example/api/v1", apiUrl: "https://gitea.example/api/v1",
@@ -23,7 +22,18 @@ interface Fixture {
outcomeFile: string; outcomeFile: string;
} }
type FixtureSource = Extract<RuntimeOutcome, { ok: false }> | { ok: true; body: string }; interface FixtureSuccess {
ok: true;
body: string;
}
type FixtureSource = RuntimeFailure | FixtureSuccess;
// One request the fake Gitea received.
interface RecordedRequest {
url: string;
init: RequestInit;
}
async function writePrivate(path: string, text: string): Promise<void> { async function writePrivate(path: string, text: string): Promise<void> {
await writeFile(path, text, { mode: 0o600 }); await writeFile(path, text, { mode: 0o600 });
@@ -32,12 +42,18 @@ async function writePrivate(path: string, text: string): Promise<void> {
async function writeArtifacts( async function writeArtifacts(
outcome: RuntimeOutcome, outcome: RuntimeOutcome,
review: string | undefined, review?: string,
anchors: unknown, anchors?: unknown,
): Promise<void> { ): Promise<void> {
if (!outcome.ok) return; if (!outcome.ok) {
if (review !== undefined) await writePrivate(outcome.reviewPath, review); return;
if (anchors !== undefined) await writePrivate(outcome.anchorsPath, JSON.stringify(anchors)); }
if (review !== undefined) {
await writePrivate(outcome.reviewPath, review);
}
if (anchors !== undefined) {
await writePrivate(outcome.anchorsPath, JSON.stringify(anchors));
}
} }
function manifestFor(runtimeDir: string, profileHome: string): RuntimeManifest { function manifestFor(runtimeDir: string, profileHome: string): RuntimeManifest {
@@ -49,7 +65,7 @@ function manifestFor(runtimeDir: string, profileHome: string): RuntimeManifest {
profiles: [ profiles: [
{ {
index: 0, index: 0,
type: "claude", type: "CLAUDE",
model: "test", model: "test",
uid: currentUid(), uid: currentUid(),
gid: currentGid(), gid: currentGid(),
@@ -73,7 +89,7 @@ async function fixture(
const outcome: RuntimeOutcome = source.ok const outcome: RuntimeOutcome = source.ok
? { ? {
...source, ...source,
profile: { type: "codex", model: "gpt-test" }, profile: { type: "CODEX", model: "gpt-test" },
reviewPath: `${profileHome}/review.md`, reviewPath: `${profileHome}/review.md`,
anchorsPath: `${profileHome}/anchors.json`, anchorsPath: `${profileHome}/anchors.json`,
} }
@@ -96,7 +112,9 @@ const missing = async (path: string): Promise<boolean> => {
await stat(path); await stat(path);
return false; return false;
} catch (error) { } catch (error) {
if (isNotFound(error)) return true; if (isNotFound(error)) {
return true;
}
throw error; throw error;
} }
}; };
@@ -107,25 +125,29 @@ function requestBody(init?: RequestInit): unknown {
} }
// A fake Gitea that records each request body and answers with `status`. // A fake Gitea that records each request body and answers with `status`.
function recordingFetch(bodies: unknown[], status = 201): typeof fetch { function recordingFetch(bodies: unknown[], status = 201): Fetcher {
return (_url, init) => { return async (_url, init) => {
bodies.push(requestBody(init)); bodies.push(requestBody(init));
return Promise.resolve(new Response(null, { status })); const resolved = await Promise.resolve(new Response(null, { status }));
return resolved;
}; };
} }
test("posts a successful issue result and removes runtime credentials", async () => { test("posts a successful issue result and removes runtime credentials", async () => {
const result = await fixture({ ok: true, body: "done" }); const result = await fixture({ ok: true, body: "done" });
const requests: { url: unknown; init: RequestInit | undefined }[] = []; const requests: RecordedRequest[] = [];
await postRuntime(result.manifestPath, context, (url, init) => { await postRuntime(result.manifestPath, context, async (url, init) => {
requests.push({ url, init }); requests.push({ url, init });
return Promise.resolve(new Response(null, { status: 201 })); const resolved = await Promise.resolve(new Response(null, { status: 201 }));
return resolved;
}); });
expect(requests.length).toBe(1); expect(requests.length).toBe(1);
const [request] = requests; const [request] = requests;
expect(request?.url).toStrictEqual(`${context.apiUrl}/repos/owner/repo/issues/7/comments`); expect(request?.url).toStrictEqual(
`${context.apiUrl}/repos/owner/repo/issues/7/comments`,
);
expect(requestBody(request?.init)).toStrictEqual({ expect(requestBody(request?.init)).toStrictEqual({
body: "done\n\n> Profile: `codex` · `gpt-test`", body: "done\n\n> Profile: `codex` · `gpt-test`",
}); });
@@ -137,9 +159,11 @@ test("posts a successful issue result and removes runtime credentials", async ()
}); });
test("retries a rejected inline review without anchors", async () => { test("retries a rejected inline review without anchors", async () => {
const result = await fixture({ ok: true, body: "ignored final response" }, "Approved\nReady", [ const result = await fixture(
{ path: "run.ts", line: 1, side: "new", body: "Finding" }, { ok: true, body: "ignored final response" },
]); "Approved\nReady",
[{ path: "run.ts", line: 1, side: "NEW", body: "Finding" }],
);
const bodies: unknown[] = []; const bodies: unknown[] = [];
await postRuntime( await postRuntime(
@@ -148,9 +172,12 @@ test("retries a rejected inline review without anchors", async () => {
...context, ...context,
eventName: "pull_request", eventName: "pull_request",
}, },
(_url, init) => { async (_url, init) => {
bodies.push(requestBody(init)); bodies.push(requestBody(init));
return Promise.resolve(new Response(null, { status: bodies.length === 1 ? 422 : 200 })); const resolved = await Promise.resolve(
new Response(null, { status: bodies.length === 1 ? 422 : 200 }),
);
return resolved;
}, },
); );
@@ -168,14 +195,14 @@ test("reports each tried profile and keeps the action failed", async () => {
const result = await fixture({ const result = await fixture({
ok: false, ok: false,
failures: [ failures: [
{ type: "codex", model: "gpt-test", reason: "sign-in failed" }, { type: "CODEX", model: "gpt-test", reason: "SIGN_IN_FAILED" },
{ type: "claude", model: "claude-test", reason: "usage exhausted" }, { type: "CLAUDE", model: "claude-test", reason: "USAGE_EXHAUSTED" },
{ type: "opencode", model: "openrouter/test", reason: "failed" }, { type: "OPENCODE", model: "openrouter/test", reason: "FAILED" },
], ],
}); });
const bodies: unknown[] = []; const bodies: unknown[] = [];
await expect(() => await expect(
postRuntime(result.manifestPath, context, recordingFetch(bodies)), postRuntime(result.manifestPath, context, recordingFetch(bodies)),
).rejects.toThrow("agent phase failed"); ).rejects.toThrow("agent phase failed");
@@ -198,15 +225,15 @@ test("drops failure entries that are not known labels", async () => {
JSON.stringify({ JSON.stringify({
ok: false, ok: false,
failures: [ failures: [
{ type: "codex", model: "gpt-test", reason: "token sk-secret leaked" }, { type: "CODEX", model: "gpt-test", reason: "token sk-secret leaked" },
{ type: "shell", model: "x", reason: "failed" }, { type: "shell", model: "x", reason: "FAILED" },
{ type: "claude", model: "claude-test", reason: "sign-in failed" }, { type: "CLAUDE", model: "claude-test", reason: "SIGN_IN_FAILED" },
], ],
}), }),
); );
const bodies: unknown[] = []; const bodies: unknown[] = [];
await expect(() => await expect(
postRuntime(result.manifestPath, context, recordingFetch(bodies)), postRuntime(result.manifestPath, context, recordingFetch(bodies)),
).rejects.toThrow("agent phase failed"); ).rejects.toThrow("agent phase failed");
@@ -222,7 +249,7 @@ test("reports a generic failure when the runner wrote no outcome", async () => {
await rm(result.outcomeFile); await rm(result.outcomeFile);
const bodies: unknown[] = []; const bodies: unknown[] = [];
await expect(() => await expect(
postRuntime(result.manifestPath, context, recordingFetch(bodies)), postRuntime(result.manifestPath, context, recordingFetch(bodies)),
).rejects.toThrow("agent phase failed"); ).rejects.toThrow("agent phase failed");
@@ -233,10 +260,13 @@ test("reports a generic failure when the runner wrote no outcome", async () => {
test("removes runtime credentials when posting fails", async () => { test("removes runtime credentials when posting fails", async () => {
const result = await fixture({ ok: true, body: "done" }); const result = await fixture({ ok: true, body: "done" });
await expect(() => await expect(
postRuntime(result.manifestPath, context, () => postRuntime(result.manifestPath, context, async () => {
Promise.resolve(new Response("failed", { status: 500 })), const resolved = await Promise.resolve(
), new Response("failed", { status: 500 }),
);
return resolved;
}),
).rejects.toThrow("500"); ).rejects.toThrow("500");
assert(await missing(result.runtimeDir)); assert(await missing(result.runtimeDir));
}); });
@@ -245,10 +275,11 @@ test("preserves a Gitea transport failure without retrying a comment", async ()
const result = await fixture({ ok: true, body: "done" }); const result = await fixture({ ok: true, body: "done" });
let requests = 0; let requests = 0;
await expect(() => await expect(
postRuntime(result.manifestPath, context, () => { postRuntime(result.manifestPath, context, async () => {
requests += 1; requests += 1;
return Promise.reject(new Error(`network failure ${requests}`)); await Promise.resolve();
throw new Error(`network failure ${requests}`);
}), }),
).rejects.toThrow("network failure 1"); ).rejects.toThrow("network failure 1");
@@ -256,27 +287,33 @@ test("preserves a Gitea transport failure without retrying a comment", async ()
assert(await missing(result.runtimeDir)); assert(await missing(result.runtimeDir));
}); });
test.skipIf(IS_WINDOWS)("refuses to follow an agent artifact symlink", async () => { test.skipIf(IS_WINDOWS)(
const result = await fixture({ ok: true, body: "done" }); "refuses to follow an agent artifact symlink",
await symlink(result.reviewerFile, result.reviewPath); async () => {
let requests = 0; const result = await fixture({ ok: true, body: "done" });
await symlink(result.reviewerFile, result.reviewPath);
let requests = 0;
await expect(() => await expect(
postRuntime(result.manifestPath, context, () => { postRuntime(result.manifestPath, context, async () => {
requests += 1; requests += 1;
return Promise.resolve(new Response(null, { status: 200 })); const resolved = await Promise.resolve(
}), new Response(null, { status: 200 }),
).rejects.toThrow("regular private file"); );
return resolved;
}),
).rejects.toThrow("regular private file");
expect(requests).toBe(1); expect(requests).toBe(1);
assert(await missing(result.runtimeDir)); assert(await missing(result.runtimeDir));
}); },
);
test("reports an attributed failure when a pull request review is missing", async () => { test("reports an attributed failure when a pull request review is missing", async () => {
const result = await fixture({ ok: true, body: "done" }); const result = await fixture({ ok: true, body: "done" });
const bodies: unknown[] = []; const bodies: unknown[] = [];
await expect(() => await expect(
postRuntime( postRuntime(
result.manifestPath, result.manifestPath,
{ {
+298
View File
@@ -0,0 +1,298 @@
import type { Stats } from "node:fs";
import { lstat, readFile, rm } from "node:fs/promises";
import nodePath from "node:path";
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;
repository: string;
issueIndex: string;
eventName: string;
}
interface Anchor {
path: string;
line: number;
side: "NEW" | "OLD";
body: string;
}
const ANSI = new RegExp(`${String.fromCodePoint(27)}\\[[0-9;]*[A-Za-z]`, "gu");
const stripAnsi = (text: string): string => text.replace(ANSI, "");
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",
]);
// Anchors written before the UPPER_SNAKE switch spell sides in lowercase.
const OLD_SIDES = new Map<unknown, Anchor["side"]>([
["new", "NEW"],
["old", "OLD"],
]);
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 {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
this.name = "GiteaRequestError";
}
}
function parseAnchors(text: string, sourcePath: string): Anchor[] {
const entries: unknown = JSON.parse(text);
if (!Array.isArray(entries)) {
throw new TypeError(`${sourcePath}: not an array`);
}
return entries.map((entry: unknown, index) => {
const at = `${sourcePath}[${index}]`;
if (!isObject(entry)) {
throw new TypeError(`${at}: not an object`);
}
const path = field(entry, "path");
const line = field(entry, "line");
const givenSide = field(entry, "side");
const side =
givenSide === undefined ? "NEW" : (OLD_SIDES.get(givenSide) ?? givenSide);
const body = field(entry, "body");
if (typeof path !== "string" || path === "") {
throw new TypeError(`${at}.path: expected a repository path`);
}
if (typeof line !== "number" || !Number.isInteger(line) || line < 1) {
throw new TypeError(`${at}.line: expected a line number`);
}
if (side !== "NEW" && side !== "OLD") {
throw new Error(`${at}.side: expected "NEW" or "OLD"`);
}
if (typeof body !== "string" || body.trim() === "") {
throw new TypeError(`${at}.body: expected the finding`);
}
return { path, line, side, body };
});
}
async function readOutcome(path: string): Promise<RuntimeOutcome> {
const value: unknown = JSON.parse(await readFile(path, "utf8"));
if (!isObject(value) || !("ok" in value)) {
throw new Error("invalid agent outcome");
}
const ok = field(value, "ok");
if (ok === false) {
// Only known labels reach the comment, never raw agent output.
const listed = field(value, "failures");
const failures: unknown[] = Array.isArray(listed) ? listed : [];
return {
ok: false,
failures: failures.flatMap((entry: unknown) => {
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 (
ok !== true ||
typeof body !== "string" ||
typeof reviewPath !== "string" ||
typeof anchorsPath !== "string" ||
!isObject(profile)
) {
throw new Error("invalid agent outcome");
}
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, reviewPath, anchorsPath, profile: { type, model } };
}
async function readAgentArtifact(
manifest: RuntimeManifest,
path: string,
name: string,
): Promise<string | null> {
const profile = manifest.profiles.find(
(entry) => nodePath.join(entry.home, name) === path,
);
if (profile === undefined) {
throw new Error(`${name}: outside profile home`);
}
let stat: Stats | null = null;
try {
stat = await lstat(path);
} catch (error) {
if (isNotFound(error)) {
return null;
}
throw error;
}
if (
!stat.isFile() ||
stat.isSymbolicLink() ||
stat.uid !== profile.uid ||
(stat.mode & 0o077) !== 0
) {
throw new Error(`${name}: expected a profile-owned regular private file`);
}
const text = await readFile(path, "utf8");
return text === "" ? null : text;
}
export async function postRuntime(
manifestPath: string,
context: PostContext,
fetcher: Fetcher = fetch,
): Promise<void> {
const manifest = await readRuntimeManifest(manifestPath);
try {
const reviewerText = await readFile(manifest.reviewerFile, "utf8");
const reviewerToken = reviewerText.trim();
const request = async (path: string, body: unknown): Promise<void> => {
let response: Response | null = null;
try {
response = await fetcher(`${context.apiUrl}/${path}`, {
method: "POST",
headers: {
Authorization: `token ${reviewerToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
throw new GiteaRequestError(`${path}: ${detail}`, { cause: error });
}
if (!response.ok) {
throw new GiteaRequestError(
`${path}: ${response.status} ${await response.text()}`,
);
}
};
const postComment = async (body: string): Promise<void> => {
await request(
`repos/${context.repository}/issues/${context.issueIndex}/comments`,
{
body: stripAnsi(body),
},
);
};
let outcome: RuntimeOutcome | null = null;
try {
outcome = await readOutcome(manifest.outcomeFile);
} catch (error) {
await postComment(failureComment([]));
throw new Error("agent phase failed", { cause: error });
}
if (!outcome.ok) {
await postComment(failureComment(outcome.failures));
throw new Error("agent phase failed");
}
try {
const review = await readAgentArtifact(
manifest,
outcome.reviewPath,
"review.md",
);
if (review === null) {
if (context.eventName === "pull_request") {
throw new Error("agent did not write a pull request review");
}
await postComment(withProfile(outcome.body, outcome.profile));
return;
}
const [verdict = "", ...rest] = review.split("\n");
const [event, mark] = VERDICTS.get(verdict.trim()) ?? ["COMMENT", "💬"];
const anchorText = await readAgentArtifact(
manifest,
outcome.anchorsPath,
"anchors.json",
);
const anchors =
anchorText === null
? []
: parseAnchors(anchorText, outcome.anchorsPath);
const postReview = async (entries: Anchor[]): Promise<void> => {
await request(
`repos/${context.repository}/pulls/${context.issueIndex}/reviews`,
{
body: stripAnsi(
withProfile(
[`${mark} ${verdict.trim()}`, ...rest].join("\n"),
outcome.profile,
),
),
event,
comments: entries.map(({ path, line, side, body }) => ({
path,
body: stripAnsi(withProfile(body, outcome.profile)),
new_position: side === "NEW" ? line : 0,
old_position: side === "OLD" ? line : 0,
})),
},
);
};
await postReview(anchors).catch(async (error: unknown) => {
if (anchors.length === 0) {
throw error;
}
console.error(
`inline comments rejected: ${error instanceof Error ? error.message : String(error)}`,
);
await postReview([]);
});
} catch (error) {
if (error instanceof GiteaRequestError) {
throw error;
}
await postComment(
failureComment([{ ...outcome.profile, reason: "FAILED" }]),
);
throw error;
}
} finally {
await rm(manifest.runtimeDir, { recursive: true }).catch(
(error: unknown) => {
if (!isNotFound(error)) {
throw error;
}
},
);
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { appendFile, rm } from "node:fs/promises"; import { appendFile, rm } from "node:fs/promises";
import process from "node:process"; import process from "node:process";
import { prepareRuntime } from "./runtime.ts"; import { prepareRuntime } from "#runtime";
const env = (name: string): string => { const env = (name: string): string => {
const value = process.env[name]; const value = process.env[name];
+41
View File
@@ -0,0 +1,41 @@
import { spawn } from "node:child_process";
import { expect, test } from "vite-plus/test";
import { isNotFound } from "#common";
import { exitCode } from "./child.ts";
test("reports the exit code", async () => {
expect(
await exitCode(spawn("/bin/sh", ["-c", "exit 0"], { stdio: "ignore" })),
).toBe(0);
expect(
await exitCode(spawn("/bin/sh", ["-c", "exit 3"], { stdio: "ignore" })),
).toBe(3);
});
test("reports a signal as 128 plus its number", async () => {
const child = spawn("/bin/sh", ["-c", "kill -KILL $$"], { stdio: "ignore" });
expect(await exitCode(child)).toBe(137);
});
test("resolves on exit while a grandchild still holds the output open", async () => {
const child = spawn("/bin/sh", ["-c", "sleep 2 & exit 5"], {
stdio: ["ignore", "pipe", "pipe"],
});
const started = Date.now();
expect(await exitCode(child)).toBe(5);
expect(Date.now() - started).toBeLessThan(1500);
child.stdout.destroy();
child.stderr.destroy();
});
test("rejects when the command does not exist", async () => {
const child = spawn("/nonexistent/command", [], { stdio: "ignore" });
const code = exitCode(child);
// The rejection must stay unhandled-safe until it is awaited.
await new Promise((resolve) => {
setTimeout(resolve, 50);
});
await expect(code).rejects.toSatisfy(isNotFound);
});
+33
View File
@@ -0,0 +1,33 @@
// What the child process call sites share: the exit status, read right after
// `spawn` so the exit event cannot pass unseen while the caller reads the
// streams, and echoing output without outrunning a slow reader.
import type { ChildProcess } from "node:child_process";
import { once } from "node:events";
import { constants } from "node:os";
import type { Writable } from "node:stream";
// Resolves once the process exits, before its streams close, with its exit
// code, or with 128 plus the signal number if a signal killed it, as a shell
// reports. A process that fails to start rejects with the spawn error.
export function exitCode(child: ChildProcess): Promise<number> {
const code = new Promise<number>((resolve, reject) => {
child.once("error", reject);
child.once("exit", (exit, signal) => {
resolve(exit ?? (signal === null ? 1 : 128 + constants.signals[signal]));
});
});
// Callers await it after draining the streams; a failed start must not be
// reported as an unhandled rejection before then.
code.catch(() => {});
return code;
}
// Writes a chunk, waiting for the stream to drain when its buffer is full.
export async function echo(
stream: Writable,
chunk: string | Uint8Array,
): Promise<void> {
if (!stream.write(chunk)) {
await once(stream, "drain");
}
}
+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";
+148
View File
@@ -0,0 +1,148 @@
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 "#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",
model: "test",
uid,
gid: uid,
home,
tokenFile: null,
});
// Runs a command as the profile's user from the root directory. Without `env`
// it inherits this process's environment.
function runAs(
selected: RuntimeProfile,
command: string,
args: string[],
env?: ReadonlyMap<string, string>,
): SpawnSyncReturns<string> {
return spawnSync(command, args, {
cwd: "/",
uid: selected.uid,
gid: selected.gid,
encoding: "utf8",
...(env !== undefined && { env: Object.fromEntries(env) }),
});
}
test("zombie process entries are already quiescent", () => {
const status = "State:\tZ (zombie)\nUid:\t20010\t20010\t20010\t20010\n";
expect(isRunnableProfileProcess(status, 20_010)).toBe(false);
});
test.skipIf(IS_WINDOWS)(
"refuses to read a profile result symlink as root",
async () => {
const root = await makeTempDir();
try {
const secret = `${root}/reviewer-token`;
const result = `${root}/result.md`;
await writeFile(secret, "reviewer-secret", { mode: 0o600 });
await symlink(secret, result);
await expect(
readProfileFile(profile(currentUid(), root), result),
).rejects.toThrow("regular private file");
} finally {
await rm(root, { recursive: true });
}
},
);
test.skipIf(!IS_ROOT)(
"a profile process can read only its own private files",
async () => {
const root = await makeTempDir();
await chmod(root, 0o711);
try {
const homeA = `${root}/a`;
const homeB = `${root}/b`;
await mkdir(homeA, { mode: 0o700 });
await mkdir(homeB, { mode: 0o700 });
await chown(homeA, 20_000, 20_000);
await chown(homeB, 20_001, 20_001);
await writeFile(`${homeA}/token`, "own", { mode: 0o600 });
await chown(`${homeA}/token`, 20_000, 20_000);
await writeFile(`${homeB}/token`, "sibling", { mode: 0o600 });
await chown(`${homeB}/token`, 20_001, 20_001);
await writeFile(`${root}/reviewer`, "root", { mode: 0o600 });
const selected = profile(20_000, homeA);
const own = runAs(selected, "/bin/cat", [`${homeA}/token`]);
const sibling = runAs(selected, "/bin/cat", [`${homeB}/token`]);
const reviewer = runAs(selected, "/bin/cat", [`${root}/reviewer`]);
expect(own.stdout).toBe("own");
expect(own.status).toBe(0);
expect(sibling.status).not.toBe(0);
expect(reviewer.status).not.toBe(0);
} finally {
await rm(root, { recursive: true });
}
},
);
test.skipIf(!IS_ROOT)(
"cleanup leaves no runnable daemonized process",
async () => {
const selected = profile(20_010, "/tmp");
const launched = runAs(selected, "/bin/sh", [
"-c",
"sleep 60 >/dev/null 2>&1 & echo $!",
]);
const pid = Number(launched.stdout.trim());
expect(Number.isInteger(pid)).toBe(true);
await killProfileProcesses(selected.uid);
const status = await readFile(`/proc/${pid}/status`, "utf8").catch(
(error: unknown) => {
if (isNotFound(error)) {
return null;
}
throw error;
},
);
expect(
status === null || !isRunnableProfileProcess(status, selected.uid),
).toBe(true);
},
);
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`],
new Map([["PARENT_SECRET", "must-not-cross-boundary"]]),
);
expect(result.status).not.toBe(0);
},
);
+30 -41
View File
@@ -2,34 +2,12 @@ import { lstat, readdir, readFile } from "node:fs/promises";
import process from "node:process"; import process from "node:process";
import { setTimeout as sleep } from "node:timers/promises"; import { setTimeout as sleep } from "node:timers/promises";
import { output, spawn } from "./command.ts"; import { isNotFound } from "#common";
import type { CommandChild, CommandOptions, CommandOutput } from "./command.ts"; import type { RuntimeProfile } from "#runtime";
import { isNotFound } from "./errors.ts";
import type { RuntimeProfile } from "./runtime.ts";
export type AgentCommandOptions = Omit<CommandOptions, "args" | "uid" | "gid">;
export interface AgentCommand {
output(): Promise<CommandOutput>;
spawn(): CommandChild;
}
export function agentCommand(
profile: RuntimeProfile,
command: string,
args: string[],
options: AgentCommandOptions = {},
): AgentCommand {
const full: CommandOptions = { ...options, args, uid: profile.uid, gid: profile.gid };
return {
output: () => output(command, full),
spawn: () => spawn(command, full),
};
}
export function isRunnableProfileProcess(status: string, uid: number): boolean { export function isRunnableProfileProcess(status: string, uid: number): boolean {
const owner = /^Uid:\s+(\d+)/mu.exec(status)?.[1]; const owner = /^Uid:\s+(?<uid>\d+)/mu.exec(status)?.groups?.["uid"];
const state = /^State:\s+(\w)/mu.exec(status)?.[1]; const state = /^State:\s+(?<state>\w)/mu.exec(status)?.groups?.["state"];
return owner !== undefined && Number(owner) === uid && state !== "Z"; return owner !== undefined && Number(owner) === uid && state !== "Z";
} }
@@ -39,28 +17,30 @@ async function profileProcessIds(uid: number): Promise<number[]> {
.filter((entry) => entry.isDirectory() && /^\d+$/u.test(entry.name)) .filter((entry) => entry.isDirectory() && /^\d+$/u.test(entry.name))
.map((entry) => entry.name); .map((entry) => entry.name);
const statuses = await Promise.all( const statuses = await Promise.all(
pids.map((pid) => readFile(`/proc/${pid}/status`, "utf8").catch(() => null)), pids.map(async (pid) => {
const status = await readFile(`/proc/${pid}/status`, "utf8").catch(
() => null,
);
return status;
}),
); );
return pids return pids
.filter((_pid, index) => { .filter((_pid, index) => {
const status = statuses[index]; const status = statuses[index];
return status !== null && status !== undefined && isRunnableProfileProcess(status, uid); return (
status !== null &&
status !== undefined &&
isRunnableProfileProcess(status, uid)
);
}) })
.map(Number); .map(Number);
} }
function killProcess(pid: number): void {
try {
process.kill(pid, "SIGKILL");
} catch (error) {
if (!isNotFound(error)) {
throw error;
}
}
}
// Kills the UID's processes every 10 ms until none is left, for up to 100 rounds. // Kills the UID's processes every 10 ms until none is left, for up to 100 rounds.
export async function killProfileProcesses(uid: number, attempt = 0): Promise<void> { export async function killProfileProcesses(
uid: number,
attempt = 0,
): Promise<void> {
if (attempt === 100) { if (attempt === 100) {
throw new Error(`profile UID ${uid} still owns processes after cleanup`); throw new Error(`profile UID ${uid} still owns processes after cleanup`);
} }
@@ -69,13 +49,22 @@ export async function killProfileProcesses(uid: number, attempt = 0): Promise<vo
return; return;
} }
for (const pid of ids) { for (const pid of ids) {
killProcess(pid); try {
process.kill(pid, "SIGKILL");
} catch (error) {
if (!isNotFound(error)) {
throw error;
}
}
} }
await sleep(10); await sleep(10);
await killProfileProcesses(uid, attempt + 1); await killProfileProcesses(uid, attempt + 1);
} }
export async function readProfileFile(profile: RuntimeProfile, path: string): Promise<string> { export async function readProfileFile(
profile: RuntimeProfile,
path: string,
): Promise<string> {
const stat = await lstat(path); const stat = await lstat(path);
if ( if (
!stat.isFile() || !stat.isFile() ||
-228
View File
@@ -1,228 +0,0 @@
import { expect, test } from "vite-plus/test";
import {
AgentAuthError,
agentEnvironment,
failureFooter,
type ProfileFailure,
profileFooter,
runProfiles,
sanitizedEnvironment,
} from "./profiles.ts";
test("formats profile attribution as a Markdown quote", () => {
expect(profileFooter({ type: "opencode", model: "openrouter/qwen/qwen3.8-27b" })).toBe(
"> Profile: `opencode` · `openrouter/qwen/qwen3.8-27b`",
);
});
test("falls through only after usage exhaustion", async () => {
const visited: string[] = [];
const result = await runProfiles(
[
{ type: "claude", model: "claude-sonnet-5", token: "first" },
{ type: "codex", model: "gpt-5.6-terra" },
],
(profile) => {
visited.push(profile.type);
if (profile.type === "claude") {
throw new Error("You've hit your usage limit");
}
return Promise.resolve("done");
},
);
expect(result).toBe("done");
expect(visited).toStrictEqual(["claude", "codex"]);
});
test("recognizes Claude's weekly limit message", async () => {
const result = await runProfiles(
[
{ type: "claude", model: "claude-sonnet-5", token: "first" },
{ type: "codex", model: "gpt-5.6-terra" },
],
(profile) => {
if (profile.type === "claude") {
throw new Error("You've hit your weekly limit · resets 12am (UTC)");
}
return Promise.resolve("fallback");
},
);
expect(result).toBe("fallback");
});
test("recognizes provider API exhaustion status", async () => {
const results = await Promise.all(
["API Error: 402 Insufficient funds", "HTTP error: 429"].map((message) =>
runProfiles(
[
{ type: "claude", model: "claude-sonnet-5", token: "first" },
{ type: "codex", model: "gpt-5.6-terra" },
],
(profile) => {
if (profile.type === "claude") throw new Error(message);
return Promise.resolve("fallback");
},
),
),
);
expect(results).toStrictEqual(["fallback", "fallback"]);
});
test("falls through after a sign-in failure and records each try", async () => {
const failures: ProfileFailure[] = [];
const result = await runProfiles(
[
{ type: "codex", model: "gpt-5.6-terra" },
{ type: "claude", model: "claude-sonnet-5", token: "first" },
{ type: "opencode", model: "openrouter/qwen/qwen3.8-27b" },
],
(profile) => {
if (profile.type === "codex") {
throw new AgentAuthError("codex sign-in failed");
}
if (profile.type === "claude") throw new Error("API Error: 429");
return Promise.resolve("fallback");
},
failures,
);
expect(result).toBe("fallback");
expect(failures).toStrictEqual([
{ type: "codex", model: "gpt-5.6-terra", reason: "sign-in failed" },
{ type: "claude", model: "claude-sonnet-5", reason: "usage exhausted" },
]);
expect(failureFooter(failures)).toStrictEqual(
"> Tried: `codex` · `gpt-5.6-terra` (sign-in failed), " +
"`claude` · `claude-sonnet-5` (usage exhausted)",
);
});
test("throws the last sign-in failure when every profile fails", async () => {
const failures: ProfileFailure[] = [];
await expect(() =>
runProfiles(
[
{ type: "codex", model: "gpt-5.6-terra" },
{ type: "claude", model: "claude-sonnet-5", token: "first" },
],
(profile) => {
throw new AgentAuthError(`${profile.type} sign-in failed`);
},
failures,
),
).rejects.toThrow(
expect.toSatisfy(
(e: unknown) => e instanceof AgentAuthError && e.message.includes("claude sign-in failed"),
),
);
expect(failures.map(({ reason }) => reason)).toStrictEqual(["sign-in failed", "sign-in failed"]);
});
test("preserves an ordinary agent failure", async () => {
const visited: string[] = [];
await expect(() =>
runProfiles(
[
{ type: "claude", model: "claude-sonnet-5", token: "first" },
{ type: "codex", model: "gpt-5.6-terra" },
],
(profile) => {
visited.push(profile.type);
throw new Error("tests failed");
},
),
).rejects.toThrow("tests failed");
expect(visited).toStrictEqual(["claude"]);
});
test("does not mistake incidental quota text for exhaustion", async () => {
const visited: string[] = [];
await expect(() =>
runProfiles(
[
{ type: "claude", model: "claude-sonnet-5", token: "first" },
{ type: "codex", model: "gpt-5.6-terra" },
],
(profile) => {
visited.push(profile.type);
throw new Error("quota parser test failed at line 429");
},
),
).rejects.toThrow("quota parser test failed");
expect(visited).toStrictEqual(["claude"]);
});
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 profile = {
index: 0,
type: "claude" as const,
model: "claude-sonnet-5",
uid: 20_000,
gid: 20_000,
home: "/runtime/profile-0",
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",
});
});
test("uses a private Codex home without inventing an API credential", () => {
const profile = {
index: 1,
type: "codex" as const,
model: "gpt-5.6-terra",
uid: 20_001,
gid: 20_001,
home: "/runtime/profile-1",
tokenFile: null,
};
expect(agentEnvironment({ PATH: "/bin" }, profile)).toStrictEqual({
PATH: "/bin",
HOME: "/runtime/profile-1",
CODEX_HOME: "/runtime/profile-1/.codex",
});
});
test("exposes an OpenRouter key only to the OpenCode profile", () => {
const profile = {
index: 2,
type: "opencode" as const,
model: "openrouter/qwen/qwen3.8-27b",
uid: 20_002,
gid: 20_002,
home: "/runtime/profile-2",
tokenFile: "/runtime/profile-2/token",
};
expect(agentEnvironment({ PATH: "/bin" }, profile, "selected")).toStrictEqual({
PATH: "/bin",
HOME: "/runtime/profile-2",
OPENROUTER_API_KEY: "selected",
});
});
-137
View File
@@ -1,137 +0,0 @@
export type ProfileType = "claude" | "codex" | "opencode";
export interface BotProfile {
type: ProfileType;
model: string;
token?: string;
}
const PROFILE_TYPES: Set<unknown> = new Set(["claude", "codex", "opencode"]);
const FAILURE_REASONS: Set<unknown> = new Set(["sign-in failed", "usage exhausted", "failed"]);
export function isProfileType(value: unknown): value is ProfileType {
return PROFILE_TYPES.has(value);
}
export function profileFooter(profile: Pick<BotProfile, "type" | "model">): string {
return `> Profile: \`${profile.type}\` · \`${profile.model}\``;
}
export function withProfile(body: string, profile: Pick<BotProfile, "type" | "model">): string {
return `${body}\n\n${profileFooter(profile)}`;
}
export type FailureReason = "sign-in failed" | "usage exhausted" | "failed";
export function isFailureReason(value: unknown): value is FailureReason {
return FAILURE_REASONS.has(value);
}
export type ProfileFailure = Pick<BotProfile, "type" | "model"> & {
reason: FailureReason;
};
// Raised once an agent's credentials are rejected and any re-login is spent.
export class AgentAuthError extends Error {}
function isUsageExhausted(error: unknown): boolean {
return /(?:usage|rate) limit|hit your (?:weekly )?limit|insufficient_quota|exceeded your current quota|too many requests|(?:api error|http error|http|status(?: code)?)[ :]+4(?:02|29)\b/iu.test(
String(error),
);
}
function failureReason(error: unknown): FailureReason {
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";
return `> Tried: ${failures
.map(({ type, model, reason }) => `\`${type}\` · \`${model}\` (${reason})`)
.join(", ")}`;
}
// 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">>(
profiles: P[],
attempt: (profile: P) => Promise<T>,
failures: ProfileFailure[],
index: number,
): Promise<T> {
const profile = profiles[index];
if (profile === undefined) {
throw new Error("profiles must contain at least one profile");
}
try {
return await attempt(profile);
} catch (error) {
const reason = failureReason(error);
failures.push({ type: profile.type, model: profile.model, reason });
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">>(
profiles: P[],
attempt: (profile: P) => Promise<T>,
failures: ProfileFailure[] = [],
): Promise<T> {
return attemptFrom(profiles, attempt, failures, 0);
}
export function sanitizedEnvironment(environment: Record<string, string>): Record<string, string> {
const result: Record<string, string> = {};
for (const name of [
"PATH",
"HOME",
"LANG",
"LANGUAGE",
"LC_ALL",
"TERM",
"COLORTERM",
"CI",
"TMPDIR",
"GITEA_TOKEN",
"GITEA_API_URL",
"GITEA_REPOSITORY",
"GITHUB_WORKSPACE",
"HTTP_PROXY",
"HTTPS_PROXY",
"ALL_PROXY",
"NO_PROXY",
"http_proxy",
"https_proxy",
"all_proxy",
"no_proxy",
"SSL_CERT_FILE",
"SSL_CERT_DIR",
"NODE_EXTRA_CA_CERTS",
]) {
const value = environment[name];
if (value !== undefined) result[name] = value;
}
return result;
}
export function agentEnvironment(
environment: Record<string, string>,
profile: Pick<BotProfile, "type"> & { home: string },
token?: string,
): Record<string, string> {
const result = sanitizedEnvironment(environment);
result.HOME = profile.home;
if (profile.type === "codex") result.CODEX_HOME = `${profile.home}/.codex`;
if (token !== undefined && token !== "") {
const tokenName = {
claude: "CLAUDE_CODE_OAUTH_TOKEN",
codex: "OPENAI_API_KEY",
opencode: "OPENROUTER_API_KEY",
}[profile.type];
result[tokenName] = token;
}
return result;
}
+17
View File
@@ -0,0 +1,17 @@
// 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";
+255
View File
@@ -0,0 +1,255 @@
import { expect, test } from "vite-plus/test";
import {
AgentAuthError,
agentEnvironment,
failureFooter,
type ProfileFailure,
profileFooter,
runProfiles,
sanitizedEnvironment,
} from "./profiles.ts";
test("formats profile attribution as a Markdown quote", () => {
expect(
profileFooter({ type: "OPENCODE", model: "openrouter/qwen/qwen3.8-27b" }),
).toBe("> Profile: `opencode` · `openrouter/qwen/qwen3.8-27b`");
});
test("falls through only after usage exhaustion", async () => {
const visited: string[] = [];
const result = await runProfiles(
[
{ type: "CLAUDE", model: "claude-sonnet-5", token: "first" },
{ type: "CODEX", model: "gpt-5.6-terra" },
],
async (profile) => {
visited.push(profile.type);
if (profile.type === "CLAUDE") {
throw new Error("You've hit your usage limit");
}
const resolved = await Promise.resolve("done");
return resolved;
},
);
expect(result).toBe("done");
expect(visited).toStrictEqual(["CLAUDE", "CODEX"]);
});
test("recognizes Claude's weekly limit message", async () => {
const result = await runProfiles(
[
{ type: "CLAUDE", model: "claude-sonnet-5", token: "first" },
{ type: "CODEX", model: "gpt-5.6-terra" },
],
async (profile) => {
if (profile.type === "CLAUDE") {
throw new Error("You've hit your weekly limit · resets 12am (UTC)");
}
const resolved = await Promise.resolve("fallback");
return resolved;
},
);
expect(result).toBe("fallback");
});
test("recognizes provider API exhaustion status", async () => {
const results = await Promise.all(
["API Error: 402 Insufficient funds", "HTTP error: 429"].map(
async (message) => {
const result = await runProfiles(
[
{ type: "CLAUDE", model: "claude-sonnet-5", token: "first" },
{ type: "CODEX", model: "gpt-5.6-terra" },
],
async (profile) => {
if (profile.type === "CLAUDE") {
throw new Error(message);
}
const resolved = await Promise.resolve("fallback");
return resolved;
},
);
return result;
},
),
);
expect(results).toStrictEqual(["fallback", "fallback"]);
});
test("falls through after a sign-in failure and records each try", async () => {
const failures: ProfileFailure[] = [];
const result = await runProfiles(
[
{ type: "CODEX", model: "gpt-5.6-terra" },
{ type: "CLAUDE", model: "claude-sonnet-5", token: "first" },
{ type: "OPENCODE", model: "openrouter/qwen/qwen3.8-27b" },
],
async (profile) => {
if (profile.type === "CODEX") {
throw new AgentAuthError("codex sign-in failed");
}
if (profile.type === "CLAUDE") {
throw new Error("API Error: 429");
}
const resolved = await Promise.resolve("fallback");
return resolved;
},
failures,
);
expect(result).toBe("fallback");
expect(failures).toStrictEqual([
{ type: "CODEX", model: "gpt-5.6-terra", reason: "SIGN_IN_FAILED" },
{ type: "CLAUDE", model: "claude-sonnet-5", reason: "USAGE_EXHAUSTED" },
]);
expect(failureFooter(failures)).toStrictEqual(
"> Tried: `codex` · `gpt-5.6-terra` (sign-in failed), " +
"`claude` · `claude-sonnet-5` (usage exhausted)",
);
});
test("throws the last sign-in failure when every profile fails", async () => {
const failures: ProfileFailure[] = [];
await expect(
runProfiles(
[
{ type: "CODEX", model: "gpt-5.6-terra" },
{ type: "CLAUDE", model: "claude-sonnet-5", token: "first" },
],
(profile) => {
throw new AgentAuthError(`${profile.type} sign-in failed`);
},
failures,
),
).rejects.toThrow(
expect.toSatisfy(
(error: unknown) =>
error instanceof AgentAuthError &&
error.message.includes("CLAUDE sign-in failed"),
),
);
expect(failures.map(({ reason }) => reason)).toStrictEqual([
"SIGN_IN_FAILED",
"SIGN_IN_FAILED",
]);
});
test("preserves an ordinary agent failure", async () => {
const visited: string[] = [];
await expect(
runProfiles(
[
{ type: "CLAUDE", model: "claude-sonnet-5", token: "first" },
{ type: "CODEX", model: "gpt-5.6-terra" },
],
(profile) => {
visited.push(profile.type);
throw new Error("tests failed");
},
),
).rejects.toThrow("tests failed");
expect(visited).toStrictEqual(["CLAUDE"]);
});
test("does not mistake incidental quota text for exhaustion", async () => {
const visited: string[] = [];
await expect(
runProfiles(
[
{ type: "CLAUDE", model: "claude-sonnet-5", token: "first" },
{ type: "CODEX", model: "gpt-5.6-terra" },
],
(profile) => {
visited.push(profile.type);
throw new Error("quota parser test failed at line 429");
},
),
).rejects.toThrow("quota parser test failed");
expect(visited).toStrictEqual(["CLAUDE"]);
});
test("exposes only the selected profile credential to the agent", () => {
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,
model: "claude-sonnet-5",
uid: 20_000,
gid: 20_000,
home: "/runtime/profile-0",
tokenFile: "/runtime/profile-0/token",
};
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", () => {
const profile = {
index: 1,
type: "CODEX" as const,
model: "gpt-5.6-terra",
uid: 20_001,
gid: 20_001,
home: "/runtime/profile-1",
tokenFile: null,
};
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", () => {
const profile = {
index: 2,
type: "OPENCODE" as const,
model: "openrouter/qwen/qwen3.8-27b",
uid: 20_002,
gid: 20_002,
home: "/runtime/profile-2",
tokenFile: "/runtime/profile-2/token",
};
expect(
agentEnvironment(new Map([["PATH", "/bin"]]), profile, "selected"),
).toStrictEqual(
new Map([
["PATH", "/bin"],
["HOME", "/runtime/profile-2"],
["OPENROUTER_API_KEY", "selected"],
]),
);
});
+179
View File
@@ -0,0 +1,179 @@
// 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;
}
// The lowercase name a profile type reads as in posted comments.
function profileName(type: ProfileIdentity["type"]): string {
switch (type) {
case "CLAUDE":
return "claude";
case "CODEX":
return "codex";
case "OPENCODE":
return "opencode";
}
}
export function profileFooter(profile: ProfileIdentity): string {
return `> Profile: \`${profileName(profile.type)}\` · \`${profile.model}\``;
}
export function withProfile(body: string, profile: ProfileIdentity): string {
return `${body}\n\n${profileFooter(profile)}`;
}
export type FailureReason = "SIGN_IN_FAILED" | "USAGE_EXHAUSTED" | "FAILED";
// The words a failure reason reads as in logs and posted comments.
function toMessage(reason: FailureReason): string {
switch (reason) {
case "SIGN_IN_FAILED":
return "sign-in failed";
case "USAGE_EXHAUSTED":
return "usage exhausted";
case "FAILED":
return "failed";
}
}
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 {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
this.name = "AgentAuthError";
}
}
function isUsageExhausted(error: unknown): boolean {
return /(?:usage|rate) limit|hit your (?:weekly )?limit|insufficient_quota|exceeded your current quota|too many requests|(?:api error|http error|http|status(?: code)?)[ :]+4(?:02|29)\b/iu.test(
String(error),
);
}
function failureReason(error: unknown): FailureReason {
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";
}
return `> Tried: ${failures
.map(
({ type, model, reason }) =>
`\`${profileName(type)}\` · \`${model}\` (${toMessage(reason)})`,
)
.join(", ")}`;
}
// Tries the profiles in order, moving on only after a sign-in failure or
// exhausted usage, and records every profile it tried.
export async function runProfiles<T, P extends ProfileIdentity>(
profiles: P[],
attempt: (profile: P) => Promise<T>,
failures: ProfileFailure[] = [],
): Promise<T> {
for (const [index, profile] of profiles.entries()) {
try {
return await attempt(profile);
} catch (error) {
const reason = failureReason(error);
failures.push({ type: profile.type, model: profile.model, reason });
if (reason === "FAILED" || index === profiles.length - 1) {
throw error;
}
console.error(
`${profile.type} ${toMessage(reason)}; trying next profile`,
);
}
}
throw new Error("profiles must contain at least one profile");
}
// 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",
"LANG",
"LANGUAGE",
"LC_ALL",
"TERM",
"COLORTERM",
"CI",
"TMPDIR",
"GITEA_TOKEN",
"GITEA_API_URL",
"GITEA_REPOSITORY",
"GITHUB_WORKSPACE",
"HTTP_PROXY",
"HTTPS_PROXY",
"ALL_PROXY",
"NO_PROXY",
"http_proxy",
"https_proxy",
"all_proxy",
"no_proxy",
"SSL_CERT_FILE",
"SSL_CERT_DIR",
"NODE_EXTRA_CA_CERTS",
]) {
const value = environment.get(name);
if (value !== undefined) {
result.set(name, value);
}
}
return result;
}
// The environment variable each agent CLI reads its credential from.
function tokenVariable(type: ProfileIdentity["type"]): string {
switch (type) {
case "CLAUDE":
return "CLAUDE_CODE_OAUTH_TOKEN";
case "CODEX":
return "OPENAI_API_KEY";
case "OPENCODE":
return "OPENROUTER_API_KEY";
}
}
export function agentEnvironment(
environment: ReadonlyMap<string, string>,
profile: ProfileHome,
token?: string,
): Map<string, string> {
const result = sanitizedEnvironment(environment);
result.set("HOME", profile.home);
if (profile.type === "CODEX") {
result.set("CODEX_HOME", `${profile.home}/.codex`);
}
if (token !== undefined && token !== "") {
const tokenName = tokenVariable(profile.type);
result.set(tokenName, token);
}
return result;
}
-79
View File
@@ -1,79 +0,0 @@
// The agent phase's environment and the helpers every agent runner shares. It
// is read once, when the runner starts, as the single-file runner did.
import { chmod, chown, writeFile } from "node:fs/promises";
import process from "node:process";
import { agentCommand } from "./isolation.ts";
import { agentEnvironment, sanitizedEnvironment } from "./profiles.ts";
import { readRuntimeManifest } from "./runtime.ts";
import type { RuntimeProfile } from "./runtime.ts";
export const env = (name: string): string => {
const value = process.env[name];
if (value === undefined) {
throw new Error(`${name} is not set`);
}
return value;
};
function definedEnvironment(): Record<string, string> {
const result: Record<string, string> = {};
for (const [name, value] of Object.entries(process.env)) {
if (value !== undefined) {
result[name] = value;
}
}
return result;
}
export const API = env("GITEA_API_URL");
export const REPO = env("GITEA_REPOSITORY");
export const INDEX = env("ISSUE_INDEX");
export const EVENT = env("EVENT_NAME");
const AUTHOR_TOKEN = env("GITEA_TOKEN");
export const WORKSPACE = env("GITHUB_WORKSPACE");
export const MANIFEST = await readRuntimeManifest(env("BOT_AGENTS_MANIFEST"));
export const PROCESS_ENV = sanitizedEnvironment(definedEnvironment());
export async function gitea(path: string, body?: unknown): Promise<Response> {
const response = await fetch(`${API}/${path}`, {
method: body === undefined ? "GET" : "POST",
headers: {
Authorization: `token ${AUTHOR_TOKEN}`,
"Content-Type": "application/json",
},
body: body === undefined ? null : JSON.stringify(body),
});
if (!response.ok) {
throw new Error(`${path}: ${response.status} ${await response.text()}`);
}
return response;
}
export async function postAuthorComment(body: string): Promise<void> {
await gitea(`repos/${REPO}/issues/${INDEX}/comments`, { body });
}
export async function runAgentCommand(
profile: RuntimeProfile,
command: string,
args: string[],
token?: string,
): Promise<void> {
const { success, code } = await agentCommand(profile, command, args, {
cwd: WORKSPACE,
env: agentEnvironment(PROCESS_ENV, profile, token),
clearEnv: true,
}).output();
if (!success) {
throw new Error(`${command} ${args[0]} exited with ${code}`);
}
}
export async function makeProfileFile(profile: RuntimeProfile, name: string): Promise<string> {
const path = `${profile.home}/${name}`;
await writeFile(path, "", { mode: 0o600 });
await chmod(path, 0o600);
await chown(path, profile.uid, profile.gid);
return path;
}
-172
View File
@@ -1,172 +0,0 @@
// Runs one ordered agent profile. Credential decryption and reviewer posting
// live in separate processes so neither privileged secret reaches this runner.
import { chmod, readFile, writeFile } from "node:fs/promises";
import { codexAuthentication, persistCodexAuth, stageCodexAuth } from "./auth.ts";
import { runAgent } from "./agents.ts";
import type { AgentAnswer } from "./agents.ts";
import { output } from "./command.ts";
import { killProfileProcesses, readProfileFile } from "./isolation.ts";
import { isObject } from "./json.ts";
import { runProfiles } from "./profiles.ts";
import type { ProfileFailure } from "./profiles.ts";
import {
API,
env,
EVENT,
gitea,
INDEX,
makeProfileFile,
MANIFEST,
PROCESS_ENV,
REPO,
runAgentCommand,
WORKSPACE,
} from "./run-context.ts";
import type { RuntimeOutcome, RuntimeProfile } from "./runtime.ts";
interface AttemptResult {
body: string;
reviewPath: string;
anchorsPath: string;
profile: Pick<RuntimeProfile, "type" | "model">;
}
interface RenderedPrompt {
prompt: string;
reviewPath: string;
anchorsPath: string;
}
const RULES_PATH = "repos/commons/code-rules/raw/README.md";
const CODEX_AUTH = "/root/.codex/auth.json";
const SKILLS_INSTALL = "skills add obra/superpowers";
async function runRoot(command: string, args: string[]): Promise<void> {
const { success, code } = await output(command, { args, env: PROCESS_ENV, clearEnv: true });
if (!success) {
throw new Error(`${command} ${args[0]} exited with ${code}`);
}
}
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");
}
await runRoot("git", ["config", "user.name", user.login]);
await runRoot("git", ["config", "user.email", user.email]);
}
async function readProfileToken(profile: RuntimeProfile): Promise<string | undefined> {
const token = profile.tokenFile === null ? undefined : await readFile(profile.tokenFile, "utf8");
return token;
}
async function prepareProfile(profile: RuntimeProfile): Promise<void> {
await runAgentCommand(profile, "git", [
"config",
"--global",
"--add",
"safe.directory",
WORKSPACE,
]);
await runAgentCommand(profile, "npx", [
"-y",
...SKILLS_INSTALL.split(" "),
"--global",
"--agent",
profile.type === "claude" ? "claude-code" : profile.type,
"--yes",
]);
}
// 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;
return `## ${String(login)} at ${String(createdAt)}\n\n${String(body)}\n`;
}
async function promptValues(
reviewPath: string,
anchorsPath: string,
): Promise<Record<string, string>> {
const comments: unknown = await (
await gitea(`repos/${REPO}/issues/${INDEX}/comments?limit=100`)
).json();
if (!Array.isArray(comments)) {
throw new TypeError("comments: unexpected Gitea response");
}
return {
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,
};
}
async function renderPrompt(profile: RuntimeProfile): Promise<RenderedPrompt> {
const reviewPath = await makeProfileFile(profile, "review.md");
const anchorsPath = await makeProfileFile(profile, "anchors.json");
const values = await promptValues(reviewPath, anchorsPath);
const template = await readFile(new URL("../prompt.md", import.meta.url), "utf8");
return {
prompt: template.replaceAll(
/\$\{(\w+)\}/gu,
(match: string, name: string) => values[name] ?? match,
),
reviewPath,
anchorsPath,
};
}
async function runProfile(profile: RuntimeProfile): Promise<AttemptResult> {
const token = await readProfileToken(profile);
const authentication = profile.type === "codex" ? codexAuthentication(token) : null;
let stagedCodexAuth: string | null = null;
let answer: AgentAnswer = {};
let rendered: RenderedPrompt | undefined;
try {
if (authentication?.kind === "persistent") {
stagedCodexAuth = await stageCodexAuth(CODEX_AUTH, profile.home, profile.uid, profile.gid);
}
await prepareProfile(profile);
rendered = await renderPrompt(profile);
answer = await runAgent(profile, token, rendered.prompt, authentication);
} finally {
await killProfileProcesses(profile.uid);
if (stagedCodexAuth !== null) {
await persistCodexAuth(stagedCodexAuth, CODEX_AUTH);
}
}
const body =
answer.resultPath === undefined
? answer.body
: await readProfileFile(profile, answer.resultPath);
if (body === undefined || rendered === undefined) {
throw new Error("agent attempt produced no result");
}
const { reviewPath, anchorsPath } = rendered;
return { body, reviewPath, anchorsPath, profile: { type: profile.type, model: profile.model } };
}
async function writeOutcome(outcome: RuntimeOutcome): Promise<void> {
await writeFile(MANIFEST.outcomeFile, JSON.stringify(outcome), { mode: 0o600 });
await chmod(MANIFEST.outcomeFile, 0o600);
}
const failures: ProfileFailure[] = [];
try {
await configureGitAuthor();
await writeOutcome({ ok: true, ...(await runProfiles(MANIFEST.profiles, runProfile, failures)) });
} catch (error) {
console.error(error);
await writeOutcome({ ok: false, failures });
throw error;
}
+593
View File
@@ -0,0 +1,593 @@
// Runs one ordered agent profile. Credential decryption and reviewer posting
// live in separate processes so neither privileged secret reaches this runner.
import { spawn } from "node:child_process";
import { chmod, chown, readFile, writeFile } from "node:fs/promises";
import process from "node:process";
import { createInterface } from "node:readline";
import type { Readable } from "node:stream";
import { text as readText } from "node:stream/consumers";
import { finished } from "node:stream/promises";
import { setTimeout as sleep } from "node:timers/promises";
import {
type ClaudeFailureFields,
type CodexAuthentication,
codexAuthentication,
collectOpenCodeOutput,
type CommandResult,
isClaudeAuthFailure,
isCodexAuthFailure,
openCodeArgs,
openCodeError,
parseOpenCodeEvents,
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";
import {
readRuntimeManifest,
type RuntimeOutcome,
type RuntimeProfile,
} from "#runtime";
interface AttemptResult {
body: string;
reviewPath: string;
anchorsPath: string;
profile: ProfileIdentity;
}
// 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`);
}
return value;
};
function definedEnvironment(): 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;
}
const API = env("GITEA_API_URL");
const REPO = env("GITEA_REPOSITORY");
const INDEX = env("ISSUE_INDEX");
const EVENT = env("EVENT_NAME");
const AUTHOR_TOKEN = env("GITEA_TOKEN");
const WORKSPACE = env("GITHUB_WORKSPACE");
const MANIFEST = await readRuntimeManifest(env("BOT_AGENTS_MANIFEST"));
const PROCESS_ENV = sanitizedEnvironment(definedEnvironment());
const RULES_PATH = "repos/commons/code-rules/raw/README.md";
const CODEX_AUTH = "/root/.codex/auth.json";
const SKILLS_INSTALL = "skills add obra/superpowers";
async function gitea(path: string, body?: unknown): Promise<Response> {
const response = await fetch(`${API}/${path}`, {
method: body === undefined ? "GET" : "POST",
headers: {
Authorization: `token ${AUTHOR_TOKEN}`,
"Content-Type": "application/json",
},
body: body === undefined ? undefined : JSON.stringify(body),
});
if (!response.ok) {
throw new Error(`${path}: ${response.status} ${await response.text()}`);
}
return response;
}
async function postAuthorComment(body: string): Promise<void> {
await gitea(`repos/${REPO}/issues/${INDEX}/comments`, { body });
}
async function runRoot(command: string, args: string[]): Promise<void> {
const 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 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", login]);
await runRoot("git", ["config", "user.email", email]);
}
async function readProfileToken(
profile: RuntimeProfile,
): Promise<string | null> {
const token =
profile.tokenFile === null
? null
: await readFile(profile.tokenFile, "utf8");
return token;
}
async function runAgentCommand(
profile: RuntimeProfile,
command: string,
args: string[],
token?: string,
): Promise<void> {
const child = spawn(command, args, {
cwd: WORKSPACE,
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}`);
}
}
// The agent name the skills installer knows each profile type by.
function skillsAgent(type: RuntimeProfile["type"]): string {
switch (type) {
case "CLAUDE":
return "claude-code";
case "CODEX":
return "codex";
case "OPENCODE":
return "opencode";
}
}
async function prepareProfile(profile: RuntimeProfile): Promise<void> {
await runAgentCommand(profile, "git", [
"config",
"--global",
"--add",
"safe.directory",
WORKSPACE,
]);
await runAgentCommand(profile, "npx", [
"-y",
...SKILLS_INSTALL.split(" "),
"--global",
"--agent",
skillsAgent(profile.type),
"--yes",
]);
}
// One issue comment as the prompt quotes it.
function formatComment(comment: unknown): string {
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<RenderedPrompt> {
const reviewPath = await makeProfileFile(profile, "review.md");
const anchorsPath = await makeProfileFile(profile, "anchors.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 rulesResponse = await gitea(RULES_PATH);
const codeRules = await rulesResponse.text();
const values = new Map<string, string>([
["COMMENT", env("COMMENT")],
[
"ISSUE_COMMENTS",
comments.map((comment: unknown) => formatComment(comment)).join("\n"),
],
["CODE_RULES", codeRules],
["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(
/\$\{(?<name>\w+)\}/gu,
(match: string, ...args: unknown[]) => {
const groups = args.at(-1);
const name = isObject(groups) ? field(groups, "name") : undefined;
return typeof name === "string" ? (values.get(name) ?? match) : match;
},
),
reviewPath,
anchorsPath,
};
}
async function runClaude(
profile: RuntimeProfile,
token: string,
prompt: string,
): Promise<string> {
const claude = spawn(
"claude",
[
"--print",
"--dangerously-skip-permissions",
"--model",
profile.model,
"--output-format",
"stream-json",
"--verbose",
prompt,
],
{
cwd: WORKSPACE,
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 | null = null;
let error = "";
const copyError = (async (): Promise<void> => {
claude.stderr.setEncoding("utf8");
for await (const chunk of claude.stderr) {
const part = String(chunk);
error += part;
console.error(part);
}
})();
for await (const line of createInterface({
input: claude.stdout,
crlfDelay: Infinity,
})) {
if (line !== "") {
const event: unknown = JSON.parse(line);
if (isObject(event)) {
const content = field(field(event, "message"), "content");
for (const part of Array.isArray(content) ? content : []) {
if (isObject(part)) {
const text =
field(part, "thinking") ??
field(part, "text") ??
field(part, "name");
if (typeof text === "string" && text !== "") {
console.log(text);
}
}
}
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 = {
isError: typeof isError === "boolean" ? isError : undefined,
result: typeof answer === "string" ? answer : undefined,
subtype: String(subtype),
apiErrorStatus:
typeof apiStatus === "number" ? apiStatus : undefined,
};
}
}
}
}
const [code] = await Promise.all([status, copyError]);
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 });
}
throw failure;
}
if (result?.result === undefined) {
throw new Error(
error.trim() || `claude ended with ${result?.subtype ?? "no result"}`,
);
}
if (code !== 0) {
throw new Error(error.trim() || `claude exited with ${code}`);
}
return result.result;
}
async function codexDeviceLogin(
profile: RuntimeProfile,
environment: ReadonlyMap<string, string>,
): Promise<void> {
const login = spawn("codex", ["login", "--device-auth"], {
cwd: WORKSPACE,
env: Object.fromEntries(environment),
uid: profile.uid,
gid: profile.gid,
stdio: ["ignore", "pipe", "pipe"],
});
const status = exitCode(login);
let shown = "";
const collect = async (stream: Readable): Promise<void> => {
stream.setEncoding("utf8");
for await (const chunk of stream) {
shown += String(chunk);
}
};
const drained = Promise.all([collect(login.stdout), collect(login.stderr)]);
await sleep(3000);
while (shown.trim() === "") {
const exited = await Promise.race([
status.then(() => true),
sleep(100, false),
]);
if (exited) {
throw new AgentAuthError("AI bot login produced no instructions");
}
}
await postAuthorComment(withProfile(shown, profile));
const [code] = await Promise.all([status, drained]);
if (code !== 0) {
throw new AgentAuthError(`codex login exited with ${code}`);
}
}
async function codexApiLogin(
profile: RuntimeProfile,
environment: ReadonlyMap<string, string>,
token: string,
): Promise<void> {
const login = spawn("codex", ["login", "--with-api-key"], {
cwd: WORKSPACE,
env: Object.fromEntries(environment),
uid: profile.uid,
gid: profile.gid,
stdio: ["pipe", "inherit", "pipe"],
});
const status = exitCode(login);
login.stdin.end(`${token}\n`);
await finished(login.stdin);
const [code, error] = await Promise.all([status, readText(login.stderr)]);
if (code !== 0) {
throw new Error(error.trim() || `codex login exited with ${code}`);
}
}
async function makeProfileFile(
profile: RuntimeProfile,
name: string,
): Promise<string> {
const path = `${profile.home}/${name}`;
await writeFile(path, "", { mode: 0o600 });
await chmod(path, 0o600);
await chown(path, profile.uid, profile.gid);
return path;
}
async function runCodex(
profile: RuntimeProfile,
prompt: string,
authentication: CodexAuthentication,
): Promise<string> {
const token =
authentication.kind === "api-key" ? authentication.token : undefined;
const environment = agentEnvironment(PROCESS_ENV, profile, token);
if (authentication.kind === "api-key") {
await codexApiLogin(profile, environment, authentication.token);
} else {
const check = spawn("codex", ["login", "status"], {
cwd: WORKSPACE,
env: Object.fromEntries(environment),
uid: profile.uid,
gid: profile.gid,
stdio: "ignore",
});
if ((await exitCode(check)) !== 0) {
await codexDeviceLogin(profile, environment);
}
}
const outputPath = await makeProfileFile(profile, "result.md");
const attempt = async (): Promise<CommandResult> => {
const codex = spawn(
"codex",
[
"exec",
"--model",
profile.model,
"--dangerously-bypass-approvals-and-sandbox",
"--output-last-message",
outputPath,
prompt,
],
{
cwd: WORKSPACE,
env: Object.fromEntries(environment),
uid: profile.uid,
gid: profile.gid,
stdio: ["ignore", "inherit", "pipe"],
},
);
const status = exitCode(codex);
codex.stderr.setEncoding("utf8");
let error = "";
for await (const chunk of codex.stderr) {
const part = String(chunk);
await echo(process.stderr, part);
error += part;
}
// Codex echoes the prompt, whose comments may quote auth errors.
return { code: await status, error: error.replaceAll(prompt, "") };
};
const result =
authentication.kind === "api-key"
? await attempt()
: await retryInvalidToken(attempt, async () => {
await runAgentCommand(profile, "codex", ["logout"]);
await codexDeviceLogin(profile, environment);
});
if (result.code !== 0) {
const failure = new Error(
result.error.trim() || `codex exited with ${result.code}`,
);
if (isCodexAuthFailure(result.error)) {
throw new AgentAuthError("codex sign-in failed", { cause: failure });
}
throw failure;
}
return outputPath;
}
async function runOpenCode(
profile: RuntimeProfile,
token: string,
prompt: string,
): Promise<string> {
const child = spawn("opencode", openCodeArgs(profile.model, prompt), {
cwd: WORKSPACE,
env: Object.fromEntries(agentEnvironment(PROCESS_ENV, profile, token)),
uid: profile.uid,
gid: profile.gid,
stdio: ["ignore", "pipe", "pipe"],
});
const status = exitCode(child);
const collected = collectOpenCodeOutput(
child.stdout,
child.stderr,
async (chunk) => {
await echo(process.stdout, chunk);
},
async (chunk) => {
await echo(process.stderr, chunk);
},
);
const [code, streams] = await Promise.all([status, collected]);
const error = streams.stderr.trim();
if (code !== 0) {
throw (
openCodeError(streams.stdout) ??
new Error(error || `opencode exited with ${code}`)
);
}
return parseOpenCodeEvents(streams.stdout);
}
async function runProfile(profile: RuntimeProfile): Promise<AttemptResult> {
const token = await readProfileToken(profile);
const authentication =
profile.type === "CODEX" ? codexAuthentication(token ?? undefined) : null;
let stagedCodexAuth: string | null = null;
let body: string | null = null;
let resultPath: string | null = null;
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,
);
}
await prepareProfile(profile);
const rendered = await renderPrompt(profile);
reviewPath = rendered.reviewPath;
anchorsPath = rendered.anchorsPath;
if (profile.type === "CLAUDE") {
body = await runClaude(profile, token ?? "", rendered.prompt);
} else if (profile.type === "CODEX") {
resultPath = await runCodex(
profile,
rendered.prompt,
authentication ?? codexAuthentication(token ?? undefined),
);
} else {
body = await runOpenCode(profile, token ?? "", rendered.prompt);
}
} finally {
await killProfileProcesses(profile.uid);
if (stagedCodexAuth !== null) {
await persistCodexAuth(stagedCodexAuth, CODEX_AUTH);
}
}
if (resultPath !== null) {
body = await readProfileFile(profile, resultPath);
}
if (body === null || reviewPath === null || anchorsPath === null) {
throw new Error("agent attempt produced no result");
}
return {
body,
reviewPath,
anchorsPath,
profile: { type: profile.type, model: profile.model },
};
}
async function writeOutcome(outcome: RuntimeOutcome): Promise<void> {
await writeFile(MANIFEST.outcomeFile, JSON.stringify(outcome), {
mode: 0o600,
});
await chmod(MANIFEST.outcomeFile, 0o600);
}
const failures: ProfileFailure[] = [];
try {
await configureGitAuthor();
await writeOutcome({
ok: true,
...(await runProfiles(MANIFEST.profiles, runProfile, failures)),
});
} catch (error) {
console.error(error);
await writeOutcome({ ok: false, failures });
throw error;
}
-105
View File
@@ -1,105 +0,0 @@
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 { output } from "./command.ts";
import { prepareRuntime } from "./runtime.ts";
import type { RuntimeProfile } from "./runtime.ts";
import { IS_ROOT, makeTempDir } from "./testing.ts";
const MASTER_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8";
const ENCRYPTED = "v1:ICEiIyQlJicoKSor:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA";
// Runs `command` as the profile's user, from the root directory.
async function runAs(profile: RuntimeProfile, command: string, path: string): Promise<boolean> {
const result = await output(command, {
args: [path],
cwd: "/",
uid: profile.uid,
gid: profile.gid,
});
return result.success;
}
async function expectPrivate(path: string, uid: number, mode: number): Promise<void> {
const info = await stat(path);
expect(info.uid).toStrictEqual(uid);
expect(info.mode & 0o777).toBe(mode);
}
test.skipIf(!IS_ROOT)("prepares root-only reviewer and isolated profile credentials", async () => {
const root = await makeTempDir();
await chmod(root, 0o711);
const action = await makeTempDir();
try {
await writeFile(
`${action}/profiles.json`,
JSON.stringify({
"reviewer-token": ENCRYPTED,
profiles: [{ type: "claude", token: ENCRYPTED }, { type: "codex" }],
}),
);
const manifest = await prepareRuntime(action, root, MASTER_KEY);
const serialized = await readFile(manifest.manifestPath, "utf8");
assert(!serialized.includes("secret-token"));
expect(await readFile(manifest.reviewerFile, "utf8")).toBe("secret-token");
await expectPrivate(manifest.reviewerFile, 0, 0o600);
const [claude, codex] = manifest.profiles;
assert(claude !== undefined && codex !== undefined);
expect(claude.uid).not.toStrictEqual(codex.uid);
await expectPrivate(claude.home, claude.uid, 0o700);
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);
expect(codex.tokenFile).toBe(null);
const ownToken = await output("/bin/cat", {
args: [claude.tokenFile],
cwd: "/",
uid: claude.uid,
gid: claude.gid,
});
expect(ownToken.success).toBe(true);
expect(new TextDecoder().decode(ownToken.stdout)).toBe("secret-token");
expect(await runAs(claude, "/bin/cat", manifest.reviewerFile)).toBe(false);
expect(await runAs(claude, "/bin/ls", codex.home)).toBe(false);
} finally {
await rm(root, { recursive: true });
await rm(action, { recursive: true });
}
});
test.skipIf(!IS_ROOT)("removes prepared credentials when output publication fails", async () => {
const root = await makeTempDir();
const action = await makeTempDir();
try {
await writeFile(
`${action}/profiles.json`,
JSON.stringify({
"reviewer-token": ENCRYPTED,
profiles: [{ type: "claude", token: ENCRYPTED }],
}),
);
const result = await output(process.execPath, {
args: [fileURLToPath(new URL("prepare.ts", import.meta.url))],
env: {
ACTION_PATH: action,
RUNNER_TEMP: root,
BOT_AGENTS_MASTER_KEY: MASTER_KEY,
GITHUB_OUTPUT: action,
},
clearEnv: true,
});
expect(result.success).toBe(false);
expect(await readdir(root)).toStrictEqual([]);
} finally {
await rm(root, { recursive: true });
await rm(action, { recursive: true });
}
});
-117
View File
@@ -1,117 +0,0 @@
import { chmod, chown, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
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"> & {
index: number;
uid: number;
gid: number;
home: string;
tokenFile: string | null;
};
export interface RuntimeManifest {
runtimeDir: string;
manifestPath: string;
reviewerFile: string;
outcomeFile: string;
profiles: RuntimeProfile[];
}
export type RuntimeOutcome =
| {
ok: true;
body: string;
reviewPath: string;
anchorsPath: string;
profile: Pick<RuntimeProfile, "type" | "model">;
}
| { ok: false; failures: ProfileFailure[] };
async function writePrivate(path: string, value: string, uid: number, gid: number): Promise<void> {
await writeFile(path, value, { mode: 0o600 });
await chmod(path, 0o600);
await chown(path, uid, gid);
}
async function prepareProfile(
runtimeDir: string,
profile: BotProfile,
index: number,
): Promise<RuntimeProfile> {
const uid = FIRST_PROFILE_UID + index;
const home = join(runtimeDir, `profile-${index}`);
await mkdir(home, { mode: 0o700 });
await chmod(home, 0o700);
await chown(home, uid, uid);
const tokenFile =
profile.token === undefined || profile.token === "" ? null : join(home, "token");
if (tokenFile !== null && profile.token !== undefined) {
await writePrivate(tokenFile, profile.token, uid, uid);
}
return { index, type: profile.type, model: profile.model, uid, gid: uid, home, tokenFile };
}
export async function prepareRuntime(
actionPath: string,
runtimeRoot: string,
masterKey: string,
): Promise<RuntimeManifest> {
const credentials = await parseCredentials(
await readFile(join(actionPath, "profiles.json"), "utf8"),
masterKey,
);
const runtimeDir = await mkdtemp(join(runtimeRoot, "bot-agents-"));
try {
await chmod(runtimeDir, 0o711);
const reviewerFile = join(runtimeDir, "reviewer-token");
await writePrivate(reviewerFile, credentials.reviewerToken, 0, 0);
// One profile at a time, in order, as each claims the next UID.
const profiles = await credentials.profiles.reduce<Promise<RuntimeProfile[]>>(
async (previous, profile, index) => [
...(await previous),
await prepareProfile(runtimeDir, profile, index),
],
Promise.resolve([]),
);
const manifestPath = join(runtimeDir, "manifest.json");
const manifest: RuntimeManifest = {
runtimeDir,
manifestPath,
reviewerFile,
outcomeFile: join(runtimeDir, "outcome.json"),
profiles,
};
await writePrivate(manifestPath, JSON.stringify(manifest), 0, 0);
return manifest;
} catch (error) {
await rm(runtimeDir, { recursive: true }).catch(() => {});
throw error;
}
}
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)
);
}
export async function readRuntimeManifest(path: string): Promise<RuntimeManifest> {
const value: unknown = JSON.parse(await readFile(path, "utf8"));
if (!isRuntimeManifest(value)) {
throw new Error(`${path}: invalid runtime manifest`);
}
return value;
}
@@ -1,9 +1,14 @@
import { expect, test } from "vite-plus/test"; import { expect, test } from "vite-plus/test";
import { decryptCredential, encryptCredential, parseCredentials } from "./credentials.ts";
import {
decryptCredential,
encryptCredential,
parseCredentials,
} from "./credentials.ts";
const MASTER_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"; const MASTER_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8";
const OTHER_KEY = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE"; const OTHER_KEY = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE";
const NONCE = Uint8Array.from({ length: 12 }, (_, index) => index + 32); const NONCE = Uint8Array.from({ length: 12 }, (_value, index) => index + 32);
const ENCRYPTED = "v1:ICEiIyQlJicoKSor:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA"; const ENCRYPTED = "v1:ICEiIyQlJicoKSor:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA";
test("decrypts a known AES-256-GCM credential", async () => { test("decrypts a known AES-256-GCM credential", async () => {
@@ -11,16 +16,21 @@ test("decrypts a known AES-256-GCM credential", async () => {
}); });
test("encrypts a credential with a fresh supplied nonce", async () => { test("encrypts a credential with a fresh supplied nonce", async () => {
expect(await encryptCredential("secret-token", MASTER_KEY, NONCE)).toStrictEqual(ENCRYPTED); expect(
await encryptCredential("secret-token", MASTER_KEY, NONCE),
).toStrictEqual(ENCRYPTED);
}); });
test("rejects a credential encrypted by another master key", async () => { test("rejects a credential encrypted by another master key", async () => {
await expect(() => decryptCredential(ENCRYPTED, OTHER_KEY)).rejects.toThrow(); await expect(decryptCredential(ENCRYPTED, OTHER_KEY)).rejects.toThrow();
}); });
test("rejects a credential with a nonstandard nonce", async () => { test("rejects a credential with a nonstandard nonce", async () => {
await expect(() => await expect(
decryptCredential("v1:AA:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA", MASTER_KEY), decryptCredential(
"v1:AA:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA",
MASTER_KEY,
),
).rejects.toThrow("nonce must be 12 bytes"); ).rejects.toThrow("nonce must be 12 bytes");
}); });
@@ -30,9 +40,9 @@ test("decrypts the central reviewer token and ordered profiles", async () => {
JSON.stringify({ JSON.stringify({
"reviewer-token": ENCRYPTED, "reviewer-token": ENCRYPTED,
profiles: [ profiles: [
{ type: "codex", model: "gpt-custom" }, { type: "CODEX", model: "gpt-custom" },
{ type: "claude", token: ENCRYPTED }, { type: "CLAUDE", token: ENCRYPTED },
{ type: "opencode", token: ENCRYPTED }, { type: "OPENCODE", token: ENCRYPTED },
], ],
}), }),
MASTER_KEY, MASTER_KEY,
@@ -40,14 +50,14 @@ test("decrypts the central reviewer token and ordered profiles", async () => {
).toStrictEqual({ ).toStrictEqual({
reviewerToken: "secret-token", reviewerToken: "secret-token",
profiles: [ profiles: [
{ type: "codex", model: "gpt-custom" }, { type: "CODEX", model: "gpt-custom" },
{ {
type: "claude", type: "CLAUDE",
model: "claude-sonnet-5", model: "claude-sonnet-5",
token: "secret-token", token: "secret-token",
}, },
{ {
type: "opencode", type: "OPENCODE",
model: "openrouter/qwen/qwen3.8-27b", model: "openrouter/qwen/qwen3.8-27b",
token: "secret-token", token: "secret-token",
}, },
@@ -56,13 +66,30 @@ test("decrypts the central reviewer token and ordered profiles", async () => {
}); });
test("rejects malformed central credentials", async () => { test("rejects malformed central credentials", async () => {
await expect(() => parseCredentials('{"profiles": []}', MASTER_KEY)).rejects.toThrow( await expect(
"reviewer-token", parseCredentials('{"profiles": []}', MASTER_KEY),
); ).rejects.toThrow("reviewer-token");
await expect(() => await expect(
parseCredentials( parseCredentials(
JSON.stringify({ "reviewer-token": ENCRYPTED, profiles: [{ type: "claude" }] }), JSON.stringify({
"reviewer-token": ENCRYPTED,
profiles: [{ type: "CLAUDE" }],
}),
MASTER_KEY, MASTER_KEY,
), ),
).rejects.toThrow("profiles[0].token"); ).rejects.toThrow("profiles[0].token");
}); });
test("reads lowercase profile types from older credentials", async () => {
const credentials = await parseCredentials(
JSON.stringify({
"reviewer-token": ENCRYPTED,
profiles: [{ type: "codex" }, { type: "claude", token: ENCRYPTED }],
}),
MASTER_KEY,
);
expect(credentials.profiles.map(({ type }) => type)).toStrictEqual([
"CODEX",
"CLAUDE",
]);
});
@@ -1,13 +1,19 @@
import { webcrypto } from "node:crypto"; import { webcrypto } from "node:crypto";
import { isMapping } from "./json.ts"; import { field, isMapping } from "#common";
import type { BotProfile } from "./profiles.ts"; import type { BotProfile } from "#profiles";
const DEFAULT_MODELS: Record<BotProfile["type"], string> = { // The model a profile runs when profiles.json names none.
claude: "claude-sonnet-5", function defaultModel(type: BotProfile["type"]): string {
codex: "gpt-5.6-terra", switch (type) {
opencode: "openrouter/qwen/qwen3.8-27b", case "CLAUDE":
}; return "claude-sonnet-5";
case "CODEX":
return "gpt-5.6-terra";
case "OPENCODE":
return "openrouter/qwen/qwen3.8-27b";
}
}
export interface BotCredentials { export interface BotCredentials {
reviewerToken: string; reviewerToken: string;
@@ -17,31 +23,61 @@ export interface BotCredentials {
function decodeBase64Url(value: string): Uint8Array { function decodeBase64Url(value: string): Uint8Array {
const base64 = value.replaceAll("-", "+").replaceAll("_", "/"); const base64 = value.replaceAll("-", "+").replaceAll("_", "/");
const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "="); const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "=");
return Uint8Array.from(atob(padded), (character) => character.codePointAt(0) ?? 0); return Uint8Array.from(
atob(padded),
(character) => character.codePointAt(0) ?? 0,
);
} }
function encodeBase64Url(value: Uint8Array): string { function encodeBase64Url(value: Uint8Array): string {
const binary = Array.from(value, (byte) => String.fromCodePoint(byte)).join(""); const binary = Array.from(value, (byte) => String.fromCodePoint(byte)).join(
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); "",
);
return btoa(binary)
.replaceAll("+", "-")
.replaceAll("/", "_")
.replaceAll("=", "");
} }
const buffer = (value: Uint8Array): ArrayBuffer => Uint8Array.from(value).buffer; const buffer = (value: Uint8Array): ArrayBuffer =>
Uint8Array.from(value).buffer;
function importMasterKey(encoded: string, usage: webcrypto.KeyUsage): Promise<webcrypto.CryptoKey> { async function importMasterKey(
encoded: string,
usage: webcrypto.KeyUsage,
): Promise<webcrypto.CryptoKey> {
const bytes = decodeBase64Url(encoded); const bytes = decodeBase64Url(encoded);
if (bytes.length !== 32) { if (bytes.length !== 32) {
throw new Error("master-key must be 32 bytes encoded as base64url"); throw new Error("master-key must be 32 bytes encoded as base64url");
} }
return webcrypto.subtle.importKey("raw", buffer(bytes), "AES-GCM", false, [usage]); const key = await webcrypto.subtle.importKey(
"raw",
buffer(bytes),
"AES-GCM",
false,
[usage],
);
return key;
} }
export async function decryptCredential(encrypted: string, masterKey: string): Promise<string> { export async function decryptCredential(
const [version, encodedNonce = "", encodedCiphertext = "", extra = ""] = encrypted.split(":"); encrypted: string,
if (version !== "v1" || encodedNonce === "" || encodedCiphertext === "" || extra !== "") { masterKey: string,
): Promise<string> {
const [version, encodedNonce = "", encodedCiphertext = "", extra = ""] =
encrypted.split(":");
if (
version !== "v1" ||
encodedNonce === "" ||
encodedCiphertext === "" ||
extra !== ""
) {
throw new Error("credential must use v1:<nonce>:<ciphertext>"); throw new Error("credential must use v1:<nonce>:<ciphertext>");
} }
const nonce = decodeBase64Url(encodedNonce); const nonce = decodeBase64Url(encodedNonce);
if (nonce.length !== 12) throw new Error("credential nonce must be 12 bytes"); if (nonce.length !== 12) {
throw new Error("credential nonce must be 12 bytes");
}
const plaintext = await webcrypto.subtle.decrypt( const plaintext = await webcrypto.subtle.decrypt(
{ name: "AES-GCM", iv: buffer(nonce) }, { name: "AES-GCM", iv: buffer(nonce) },
await importMasterKey(masterKey, "decrypt"), await importMasterKey(masterKey, "decrypt"),
@@ -55,7 +91,9 @@ export async function encryptCredential(
masterKey: string, masterKey: string,
nonce: Uint8Array = webcrypto.getRandomValues(new Uint8Array(12)), nonce: Uint8Array = webcrypto.getRandomValues(new Uint8Array(12)),
): Promise<string> { ): Promise<string> {
if (nonce.length !== 12) throw new Error("AES-GCM nonce must be 12 bytes"); if (nonce.length !== 12) {
throw new Error("AES-GCM nonce must be 12 bytes");
}
const ciphertext = await webcrypto.subtle.encrypt( const ciphertext = await webcrypto.subtle.encrypt(
{ name: "AES-GCM", iv: buffer(nonce) }, { name: "AES-GCM", iv: buffer(nonce) },
await importMasterKey(masterKey, "encrypt"), await importMasterKey(masterKey, "encrypt"),
@@ -64,7 +102,22 @@ export async function encryptCredential(
return `v1:${encodeBase64Url(nonce)}:${encodeBase64Url(new Uint8Array(ciphertext))}`; return `v1:${encodeBase64Url(nonce)}:${encodeBase64Url(new Uint8Array(ciphertext))}`;
} }
async function parseProfile(entry: unknown, index: number, masterKey: string): Promise<BotProfile> { // profiles.json files written before the UPPER_SNAKE switch spell types in lowercase.
const OLD_PROFILE_TYPES = new Map<unknown, BotProfile["type"]>([
["claude", "CLAUDE"],
["codex", "CODEX"],
["opencode", "OPENCODE"],
]);
function currentSpelling(type: unknown): unknown {
return OLD_PROFILE_TYPES.get(type) ?? type;
}
async function parseProfile(
entry: unknown,
index: number,
masterKey: string,
): Promise<BotProfile> {
const at = `profiles[${index}]`; const at = `profiles[${index}]`;
if (!isMapping(entry)) { if (!isMapping(entry)) {
throw new TypeError(`${at}: expected a mapping`); throw new TypeError(`${at}: expected a mapping`);
@@ -74,14 +127,16 @@ async function parseProfile(entry: unknown, index: number, masterKey: string): P
throw new Error(`${at}.${key}: unknown field`); throw new Error(`${at}.${key}: unknown field`);
} }
} }
const { type, model, token } = entry; const type = currentSpelling(field(entry, "type"));
if (type !== "claude" && type !== "codex" && type !== "opencode") { const model = field(entry, "model");
throw new Error(`${at}.type: expected "claude", "codex", or "opencode"`); const token = field(entry, "token");
if (type !== "CLAUDE" && type !== "CODEX" && type !== "OPENCODE") {
throw new Error(`${at}.type: expected "CLAUDE", "CODEX", or "OPENCODE"`);
} }
if (model !== undefined && (typeof model !== "string" || model === "")) { if (model !== undefined && (typeof model !== "string" || model === "")) {
throw new Error(`${at}.model: expected a model name`); throw new Error(`${at}.model: expected a model name`);
} }
if ((type === "claude" || type === "opencode") && typeof token !== "string") { if ((type === "CLAUDE" || type === "OPENCODE") && typeof token !== "string") {
throw new Error(`${at}.token: expected an encrypted credential`); throw new Error(`${at}.token: expected an encrypted credential`);
} }
if (token !== undefined && typeof token !== "string") { if (token !== undefined && typeof token !== "string") {
@@ -89,14 +144,17 @@ async function parseProfile(entry: unknown, index: number, masterKey: string): P
} }
return { return {
type, type,
model: model ?? DEFAULT_MODELS[type], model: model ?? defaultModel(type),
...(typeof token === "string" && { ...(typeof token === "string" && {
token: await decryptCredential(token, masterKey), token: await decryptCredential(token, masterKey),
}), }),
}; };
} }
export async function parseCredentials(text: string, masterKey: string): Promise<BotCredentials> { export async function parseCredentials(
text: string,
masterKey: string,
): Promise<BotCredentials> {
const value: unknown = JSON.parse(text); const value: unknown = JSON.parse(text);
if (!isMapping(value)) { if (!isMapping(value)) {
throw new TypeError("credentials must be a mapping"); throw new TypeError("credentials must be a mapping");
@@ -106,7 +164,8 @@ export async function parseCredentials(text: string, masterKey: string): Promise
throw new Error(`${key}: unknown field`); throw new Error(`${key}: unknown field`);
} }
} }
const { "reviewer-token": reviewerToken, profiles } = value; const reviewerToken = field(value, "reviewer-token");
const profiles = field(value, "profiles");
if (typeof reviewerToken !== "string") { if (typeof reviewerToken !== "string") {
throw new TypeError("reviewer-token: expected an encrypted credential"); throw new TypeError("reviewer-token: expected an encrypted credential");
} }
@@ -114,7 +173,13 @@ export async function parseCredentials(text: string, masterKey: string): Promise
throw new Error("profiles must contain at least one profile"); throw new Error("profiles must contain at least one profile");
} }
const parsed = await Promise.all( const parsed = await Promise.all(
profiles.map((entry: unknown, index) => parseProfile(entry, index, masterKey)), profiles.map(async (entry: unknown, index) => {
const profile = await parseProfile(entry, index, masterKey);
return profile;
}),
); );
return { reviewerToken: await decryptCredential(reviewerToken, masterKey), profiles: parsed }; return {
reviewerToken: await decryptCredential(reviewerToken, masterKey),
profiles: parsed,
};
} }
+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";
+129
View File
@@ -0,0 +1,129 @@
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 { IS_ROOT, makeTempDir } from "#testing";
import { prepareRuntime, type RuntimeProfile } from "./runtime.ts";
const MASTER_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8";
const ENCRYPTED = "v1:ICEiIyQlJicoKSor:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA";
// Runs `command` as the profile's user, from the root directory.
function runAs(
profile: RuntimeProfile,
command: string,
path: string,
): boolean {
const result = spawnSync(command, [path], {
cwd: "/",
uid: profile.uid,
gid: profile.gid,
});
return result.status === 0;
}
async function expectPrivate(
path: string,
uid: number,
mode: number,
): Promise<void> {
const info = await stat(path);
expect(info.uid).toStrictEqual(uid);
expect(info.mode & 0o777).toBe(mode);
}
test.skipIf(!IS_ROOT)(
"prepares root-only reviewer and isolated profile credentials",
async () => {
const root = await makeTempDir();
await chmod(root, 0o711);
const action = await makeTempDir();
try {
await writeFile(
`${action}/profiles.json`,
JSON.stringify({
"reviewer-token": ENCRYPTED,
profiles: [{ type: "CLAUDE", token: ENCRYPTED }, { type: "CODEX" }],
}),
);
const manifest = await prepareRuntime(action, root, MASTER_KEY);
const serialized = await readFile(manifest.manifestPath, "utf8");
assert(!serialized.includes("secret-token"));
expect(await readFile(manifest.reviewerFile, "utf8")).toBe(
"secret-token",
);
await expectPrivate(manifest.reviewerFile, 0, 0o600);
const [claude, codex] = manifest.profiles;
assert(claude !== undefined && codex !== undefined);
expect(claude.uid).not.toStrictEqual(codex.uid);
await expectPrivate(claude.home, claude.uid, 0o700);
assert(claude.tokenFile !== null);
expect(await readFile(claude.tokenFile, "utf8")).toBe("secret-token");
await expectPrivate(claude.tokenFile, claude.uid, 0o600);
const codexHome = await stat(codex.home);
expect(codexHome.uid).toStrictEqual(codex.uid);
expect(codex.tokenFile).toBe(null);
const ownToken = spawnSync("/bin/cat", [claude.tokenFile], {
cwd: "/",
uid: claude.uid,
gid: claude.gid,
encoding: "utf8",
});
expect(ownToken.status).toBe(0);
expect(ownToken.stdout).toBe("secret-token");
expect(runAs(claude, "/bin/cat", manifest.reviewerFile)).toBe(false);
expect(runAs(claude, "/bin/ls", codex.home)).toBe(false);
} finally {
await rm(root, { recursive: true });
await rm(action, { recursive: true });
}
},
);
test.skipIf(!IS_ROOT)(
"removes prepared credentials when output publication fails",
async () => {
const root = await makeTempDir();
const action = await makeTempDir();
try {
await writeFile(
`${action}/profiles.json`,
JSON.stringify({
"reviewer-token": ENCRYPTED,
profiles: [{ type: "CLAUDE", token: ENCRYPTED }],
}),
);
const result = spawnSync(
process.execPath,
[fileURLToPath(new URL("../prepare/index.ts", import.meta.url))],
{
env: Object.fromEntries([
["ACTION_PATH", action],
["RUNNER_TEMP", root],
["BOT_AGENTS_MASTER_KEY", MASTER_KEY],
["GITHUB_OUTPUT", action],
]),
},
);
expect(result.status).not.toBe(0);
expect(await readdir(root)).toStrictEqual([]);
} finally {
await rm(root, { recursive: true });
await rm(action, { recursive: true });
}
},
);
+137
View File
@@ -0,0 +1,137 @@
import {
chmod,
chown,
mkdir,
mkdtemp,
readFile,
rm,
writeFile,
} from "node:fs/promises";
import nodePath from "node:path";
import { field, isMapping } from "#common";
import type { ProfileFailure, ProfileIdentity } from "#profiles";
import { parseCredentials } from "./credentials.ts";
const FIRST_PROFILE_UID = 20_000;
export interface RuntimeProfile extends ProfileIdentity {
index: number;
uid: number;
gid: number;
home: string;
tokenFile: string | null;
}
export interface RuntimeManifest {
runtimeDir: string;
manifestPath: string;
reviewerFile: string;
outcomeFile: string;
profiles: RuntimeProfile[];
}
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 });
await chmod(path, 0o600);
await chown(path, uid, gid);
}
export async function prepareRuntime(
actionPath: string,
runtimeRoot: string,
masterKey: string,
): Promise<RuntimeManifest> {
const credentials = await parseCredentials(
await readFile(nodePath.join(actionPath, "profiles.json"), "utf8"),
masterKey,
);
const runtimeDir = await mkdtemp(nodePath.join(runtimeRoot, "bot-agents-"));
try {
await chmod(runtimeDir, 0o711);
const reviewerFile = nodePath.join(runtimeDir, "reviewer-token");
await writePrivate(reviewerFile, credentials.reviewerToken, 0, 0);
// One profile at a time, in order, as each claims the next UID.
const profiles: RuntimeProfile[] = [];
for (const [index, profile] of credentials.profiles.entries()) {
const uid = FIRST_PROFILE_UID + index;
const home = nodePath.join(runtimeDir, `profile-${index}`);
await mkdir(home, { mode: 0o700 });
await chmod(home, 0o700);
await chown(home, uid, uid);
const tokenFile =
profile.token === undefined || profile.token === ""
? null
: nodePath.join(home, "token");
if (tokenFile !== null && profile.token !== undefined) {
await writePrivate(tokenFile, profile.token, uid, uid);
}
profiles.push({
index,
type: profile.type,
model: profile.model,
uid,
gid: uid,
home,
tokenFile,
});
}
const manifestPath = nodePath.join(runtimeDir, "manifest.json");
const manifest: RuntimeManifest = {
runtimeDir,
manifestPath,
reviewerFile,
outcomeFile: nodePath.join(runtimeDir, "outcome.json"),
profiles,
};
await writePrivate(manifestPath, JSON.stringify(manifest), 0, 0);
return manifest;
} catch (error) {
await rm(runtimeDir, { recursive: true }).catch(() => {});
throw error;
}
}
function isRuntimeManifest(value: unknown): value is RuntimeManifest {
return (
isMapping(value) &&
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"))
);
}
export async function readRuntimeManifest(
path: string,
): Promise<RuntimeManifest> {
const value: unknown = JSON.parse(await readFile(path, "utf8"));
if (!isRuntimeManifest(value)) {
throw new Error(`${path}: invalid runtime manifest`);
}
return value;
}
-23
View File
@@ -1,23 +0,0 @@
// Test support: the current user's IDs and a fresh temporary directory.
import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import process from "node:process";
export function currentUid(): number {
return process.getuid?.() ?? -1;
}
export function currentGid(): number {
return process.getgid?.() ?? -1;
}
// The isolation tests switch UIDs, which only root may do.
export const IS_ROOT: boolean = currentUid() === 0;
// Symlinks and POSIX modes, as these tests use them, need a Unix host.
export const IS_WINDOWS: boolean = process.platform === "win32";
export function makeTempDir(): Promise<string> {
return mkdtemp(join(tmpdir(), "bot-agents-test-"));
}
+65
View File
@@ -0,0 +1,65 @@
// 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";
import { once } from "node:events";
import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import nodePath from "node:path";
import process from "node:process";
import { text } from "node:stream/consumers";
export function currentUid(): number {
return process.getuid?.() ?? -1;
}
export function currentGid(): number {
return process.getgid?.() ?? -1;
}
// The isolation tests switch UIDs, which only root may do.
export const IS_ROOT: boolean = currentUid() === 0;
// Symlinks and POSIX modes, as these tests use them, need a Unix host.
export const IS_WINDOWS: boolean = process.platform === "win32";
export async function makeTempDir(): Promise<string> {
const path = await mkdtemp(nodePath.join(tmpdir(), "bot-agents-test-"));
return path;
}
export interface CommandResult {
success: boolean;
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: ReadonlyMap<string, string>,
): Promise<CommandResult> {
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 };
}
+11 -11
View File
@@ -1,11 +1,10 @@
import process from "node:process"; import process from "node:process";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { expect, test } from "vite-plus/test"; import { expect, test } from "vite-plus/test";
import { output } from "../src/command.ts"; import { runCommand } from "#testing";
// Each phase runs as `node src/<phase>.ts` with no build step. Node loads the // Each phase runs as `node src/<phase>/index.ts` with no build step. Node loads the
// whole import graph, stripping types, before the phase reads its first // whole import graph, stripping types, before the phase reads its first
// variable, so failing on that variable proves every module loads. // variable, so failing on that variable proves every module loads.
const PHASES: [phase: string, firstVariable: string][] = [ const PHASES: [phase: string, firstVariable: string][] = [
@@ -15,13 +14,14 @@ const PHASES: [phase: string, firstVariable: string][] = [
]; ];
test.each(PHASES)("Node loads the %s phase", async (phase, firstVariable) => { test.each(PHASES)("Node loads the %s phase", async (phase, firstVariable) => {
const path = fileURLToPath(new URL(`../src/${phase}.ts`, import.meta.url)); const path = fileURLToPath(
const result = await output(process.execPath, { new URL(`../src/${phase}/index.ts`, import.meta.url),
args: [path], );
env: { PATH: process.env.PATH ?? "" }, const result = await runCommand(
clearEnv: true, process.execPath,
stdin: "null", [path],
}); new Map([["PATH", process.env["PATH"] ?? ""]]),
);
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(new TextDecoder().decode(result.stderr)).toContain(`${firstVariable} is not set`); expect(result.stderr).toContain(`${firstVariable} is not set`);
}); });
+134 -62
View File
@@ -1,26 +1,29 @@
import { readdir, readFile, rm, stat } from "node:fs/promises"; import { readdir, readFile, rm, stat } from "node:fs/promises";
import process from "node:process"; import process from "node:process";
import { assert, expect, test } from "vite-plus/test"; import { assert, expect, test } from "vite-plus/test";
import { parse } from "yaml"; import { parse } from "yaml";
import { output } from "../src/command.ts"; import { inheritedEnvironment, makeTempDir, runCommand } from "#testing";
import { isNotFound } from "../src/errors.ts";
import { isMapping } from "../src/json.ts";
import type { JsonObject } from "../src/json.ts";
import { makeTempDir } from "../src/testing.ts";
const mapping = (value: unknown, at: string): JsonObject => { // A parsed YAML or JSON mapping, keyed by its dynamic field names.
assert(isMapping(value), at); type Mapping = Map<string, unknown>;
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); assert(Array.isArray(value), at);
return value.map((entry: unknown, index) => mapping(entry, `${at}[${index}]`)); return value.map((entry: unknown, index) =>
mapping(entry, `${at}[${index}]`),
);
}; };
async function yaml(path: string): Promise<JsonObject> { async function yaml(path: string): Promise<Mapping> {
const value: unknown = parse(await readFile(path, "utf8")); const value: unknown = parse(await readFile(path, "utf8"));
return mapping(value, path); return mapping(value, path);
} }
@@ -33,8 +36,8 @@ async function yamlFiles(): Promise<string[]> {
.filter((path) => !/^(?:\.git|\.superpowers|node_modules)\//u.test(path)); .filter((path) => !/^(?:\.git|\.superpowers|node_modules)\//u.test(path));
} }
const findStep = (steps: JsonObject[], key: string, value: string): JsonObject => { const findStep = (steps: Mapping[], key: string, value: string): Mapping => {
const step = steps.find((entry) => entry[key] === value); const step = steps.find((entry) => entry.get(key) === value);
assert(step !== undefined, `${key}: ${value}`); assert(step !== undefined, `${key}: ${value}`);
return step; return step;
}; };
@@ -42,16 +45,26 @@ const findStep = (steps: JsonObject[], key: string, value: string): JsonObject =
test("all committed YAML parses", async () => { test("all committed YAML parses", async () => {
const files = await yamlFiles(); const files = await yamlFiles();
assert(files.length > 0); assert(files.length > 0);
await Promise.all(files.map((file) => yaml(file))); await Promise.all(
files.map(async (file) => {
await yaml(file);
}),
);
}); });
test("the action exposes only the master key and scopes it to preparation", async () => { test("the action exposes only the master key and scopes it to preparation", async () => {
const action = await yaml("action.yml"); const action = await yaml("action.yml");
expect(Object.keys(mapping(action.inputs, "inputs"))).toStrictEqual(["master-key"]); expect([...mapping(action.get("inputs"), "inputs").keys()]).toStrictEqual([
const steps = sequence(mapping(action.runs, "runs").steps, "runs.steps"); "master-key",
]);
const steps = sequence(
mapping(action.get("runs"), "runs").get("steps"),
"runs.steps",
);
for (const step of steps) { for (const step of steps) {
if (typeof step.run === "string") { const run = step.get("run");
expect(step.run.includes("\n"), String(step.name)).toBe(false); if (typeof run === "string") {
expect(run.includes("\n"), String(step.get("name"))).toBe(false);
} }
} }
const checkout = findStep(steps, "uses", "actions/checkout@v4"); const checkout = findStep(steps, "uses", "actions/checkout@v4");
@@ -59,16 +72,24 @@ test("the action exposes only the master key and scopes it to preparation", asyn
const agent = findStep(steps, "id", "agent"); const agent = findStep(steps, "id", "agent");
const post = findStep(steps, "id", "post"); const post = findStep(steps, "id", "post");
findStep(steps, "id", "source"); findStep(steps, "id", "source");
expect(mapping(checkout.with, "checkout.with").token).toBe("${{ gitea.token }}"); expect(mapping(checkout.get("with"), "checkout.with").get("token")).toBe(
expect(mapping(prepare.env, "prepare.env").BOT_AGENTS_MASTER_KEY).toBe( "${{ gitea.token }}",
"${{ inputs.master-key }}",
); );
expect(JSON.stringify(agent).includes("BOT_AGENTS_MASTER_KEY")).toBe(false); expect(
expect(JSON.stringify(post).includes("BOT_AGENTS_MASTER_KEY")).toBe(false); mapping(prepare.get("env"), "prepare.env").get("BOT_AGENTS_MASTER_KEY"),
expect(mapping(agent.env, "agent.env").GITEA_TOKEN).toBe("${{ gitea.token }}"); ).toBe("${{ inputs.master-key }}");
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]) { 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"));
} }
}); });
@@ -76,23 +97,35 @@ test("the source protection script stages immutable action files", async () => {
const temp = await makeTempDir(); const temp = await makeTempDir();
const outputFile = `${temp}/output`; const outputFile = `${temp}/output`;
try { try {
const result = await output("/bin/bash", { const environment = inheritedEnvironment();
args: ["scripts/protect-source.sh"], environment.set("ACTION_PATH", process.cwd());
env: { ACTION_PATH: process.cwd(), GITHUB_OUTPUT: outputFile, RUNNER_TEMP: temp }, environment.set("GITHUB_OUTPUT", outputFile);
stderr: "piped", environment.set("RUNNER_TEMP", temp);
}); const result = await runCommand(
assert(result.success, new TextDecoder().decode(result.stderr)); "/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 = [ const files = [
"src/prepare.ts", "src/prepare/index.ts",
"src/run.ts", "src/run/index.ts",
"src/post.ts", "src/post/index.ts",
"src/runtime/index.ts",
"src/agents/auth.ts",
"package.json", "package.json",
"profiles.json", "profiles.json",
"prompt.md", "prompt.md",
]; ];
const stats = await Promise.all(files.map((file) => stat(`${path}/${file}`))); const stats = await Promise.all(
files.map(async (file) => {
const info = await stat(`${path}/${file}`);
return info;
}),
);
for (const [index, info] of stats.entries()) { for (const [index, info] of stats.entries()) {
assert(info.isFile(), files[index]); assert(info.isFile(), files[index]);
expect(info.mode & 0o022, files[index]).toStrictEqual(0); expect(info.mode & 0o022, files[index]).toStrictEqual(0);
@@ -103,12 +136,17 @@ test("the source protection script stages immutable action files", async () => {
}); });
test("module-relative runtime assets exist", async () => { test("module-relative runtime assets exist", async () => {
const module = new URL("../src/run.ts", import.meta.url); const module = new URL("../src/run/index.ts", import.meta.url);
const source = await readFile(module, "utf8"); const source = await readFile(module, "utf8");
const assets = [...source.matchAll(/new URL\("([^"]+)", import\.meta\.url\)/gu)].map( const assets = [
([, asset = ""]) => new URL(asset, module), ...source.matchAll(/new URL\("(?<asset>[^"]+)", import\.meta\.url\)/gu),
].map(([, asset = ""]) => new URL(asset, module));
const stats = await Promise.all(
assets.map(async (asset) => {
const info = await stat(asset);
return info;
}),
); );
const stats = await Promise.all(assets.map((asset) => stat(asset)));
for (const [index, info] of stats.entries()) { for (const [index, info] of stats.entries()) {
assert(info.isFile(), assets[index]?.pathname); assert(info.isFile(), assets[index]?.pathname);
} }
@@ -116,27 +154,46 @@ test("module-relative runtime assets exist", async () => {
test("the repository uses its trusted main action", async () => { test("the repository uses its trusted main action", async () => {
const workflow = await yaml(".gitea/workflows/agents.yml"); const workflow = await yaml(".gitea/workflows/agents.yml");
const job = mapping(mapping(workflow.jobs, "jobs").agent, "jobs.agent"); const job = mapping(
const permissions = mapping(job.permissions, "jobs.agent.permissions"); mapping(workflow.get("jobs"), "jobs").get("agent"),
expect(permissions).toStrictEqual({ "jobs.agent",
);
const permissions = mapping(job.get("permissions"), "jobs.agent.permissions");
expect(Object.fromEntries(permissions)).toStrictEqual({
code: "write", code: "write",
issues: "write", issues: "write",
"pull-requests": "write", "pull-requests": "write",
}); });
const steps = sequence(job.steps, "jobs.agent.steps"); const steps = sequence(job.get("steps"), "jobs.agent.steps");
expect(steps.findIndex((step) => step.uses === "./")).toStrictEqual(-1); expect(steps.findIndex((step) => step.get("uses") === "./")).toStrictEqual(
const trusted = findStep(steps, "uses", "https://git.capsulizers.com/commons/bot-agents@main"); -1,
expect(mapping(trusted.with, "trusted.with")["master-key"]).toBe( );
const trusted = findStep(
steps,
"uses",
"https://git.capsulizers.com/commons/bot-agents@main",
);
expect(mapping(trusted.get("with"), "trusted.with").get("master-key")).toBe(
"${{ secrets.BOT_AGENTS_MASTER_KEY }}", "${{ secrets.BOT_AGENTS_MASTER_KEY }}",
); );
expect(JSON.stringify(workflow).includes("gitea.actor != 'bot'")).toBe(true); expect(
JSON.stringify(Object.fromEntries(workflow)).includes(
"gitea.actor != 'bot'",
),
).toBe(true);
}); });
test("CI checks every phase and the root isolation suite", async () => { test("CI checks every phase and the root isolation suite", async () => {
const workflow = await yaml(".gitea/workflows/check.yml"); const workflow = await yaml(".gitea/workflows/check.yml");
const job = mapping(mapping(workflow.jobs, "jobs").check, "jobs.check"); const job = mapping(
const commands = sequence(job.steps, "jobs.check.steps") mapping(workflow.get("jobs"), "jobs").get("check"),
.map((step) => (typeof step.run === "string" ? step.run : "")) "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"); .join("\n");
// `vp check` type-checks every phase, and `vp test` runs each of them under // `vp check` type-checks every phase, and `vp test` runs each of them under
// Node as well as the root-only isolation tests. // Node as well as the root-only isolation tests.
@@ -146,11 +203,12 @@ test("CI checks every phase and the root isolation suite", async () => {
}); });
test("uses the standard Skills installer without custom discovery", async () => { test("uses the standard Skills installer without custom discovery", async () => {
const runner = await readFile("src/run.ts", "utf8"); const runner = await readFile("src/run/index.ts", "utf8");
assert(runner.includes("skills add obra/superpowers")); assert(runner.includes("skills add obra/superpowers"));
const removed = await stat("superpowers.ts").then( const removed = await stat("superpowers.ts").then(
() => false, () => false,
(error: unknown) => isNotFound(error), (error: unknown) =>
error instanceof Error && "code" in error && error.code === "ENOENT",
); );
assert(removed, "superpowers.ts should be removed"); assert(removed, "superpowers.ts should be removed");
}); });
@@ -160,16 +218,30 @@ test("installs the OpenCode CLI", async () => {
assert(action.includes("opencode-ai")); assert(action.includes("opencode-ai"));
}); });
test("does not install Deno", async () => {
const action = await readFile("action.yml", "utf8");
expect(action).not.toMatch(/\bdeno\b/iu);
});
test("profile preparation does not receive provider credentials", async () => { test("profile preparation does not receive provider credentials", async () => {
const runner = await readFile("src/run.ts", "utf8"); const runner = await readFile("src/run/index.ts", "utf8");
assert(runner.includes("await prepareProfile(profile);")); assert(runner.includes("await prepareProfile(profile);"));
expect(runner.includes("prepareProfile(profile, token)")).toBe(false); expect(runner.includes("prepareProfile(profile, token)")).toBe(false);
}); });
test("central profiles fall back from Codex to Claude to OpenCode", async () => { test("central profiles fall back from Claude to Codex to OpenCode", async () => {
const credentials: unknown = JSON.parse(await readFile("profiles.json", "utf8")); const credentials: unknown = JSON.parse(
const profiles = sequence(mapping(credentials, "profiles.json").profiles, "profiles"); await readFile("profiles.json", "utf8"),
expect(profiles.map((profile) => profile.type)).toStrictEqual(["codex", "claude", "opencode"]); );
expect(profiles[2]?.model).toBe("openrouter/qwen/qwen3.8-27b"); const profiles = sequence(
assert(String(profiles[2]?.token).startsWith("v1:")); mapping(credentials, "profiles.json").get("profiles"),
"profiles",
);
expect(profiles.map((profile) => profile.get("type"))).toStrictEqual([
"CLAUDE",
"CODEX",
"OPENCODE",
]);
expect(profiles[2]?.get("model")).toBe("openrouter/qwen/qwen3.8-27b");
assert(String(profiles[2]?.get("token")).startsWith("v1:"));
}); });
+1 -1
View File
@@ -9,8 +9,8 @@
"erasableSyntaxOnly": true, "erasableSyntaxOnly": true,
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
"strict": true, "strict": true,
"exactOptionalPropertyTypes": true,
"noUncheckedIndexedAccess": true, "noUncheckedIndexedAccess": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitOverride": true, "noImplicitOverride": true,
"noImplicitReturns": true, "noImplicitReturns": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
+26 -10
View File
@@ -1,20 +1,36 @@
import explicitTypes from "eslint-plugin-explicit-types";
import importStructure from "eslint-plugin-import-structure";
import { defineConfig } from "vite-plus"; import { defineConfig } from "vite-plus";
import projectLint from "./scripts/project-lint.ts"; import projectLint, { projectOverrides } from "./scripts/project-lint.ts";
export default defineConfig({ export default defineConfig({
// Keep package.json in its hand-written order. fmt: {
fmt: { sortPackageJson: false }, printWidth: 80,
quoteProps: "preserve",
embeddedLanguageFormatting: "off",
},
lint: { lint: {
plugins: ["typescript", "unicorn", "oxc", "import"], plugins: ["typescript", "unicorn", "oxc", "import"],
jsPlugins: ["./scripts/project-lint.ts"], jsPlugins: [
categories: { correctness: "error", suspicious: "error", pedantic: "error", perf: "error" }, "eslint-plugin-explicit-types",
rules: projectLint.configs.project.rules, "eslint-plugin-import-structure",
// Repo tooling, not app code: the lint plugin compares AST node `type`s, "./scripts/project-lint.ts",
// which the AST types declare as an enum.
overrides: [
{ files: ["scripts/**"], rules: { "typescript/no-unsafe-enum-comparison": "off" } },
], ],
categories: {
correctness: "error",
suspicious: "error",
pedantic: "error",
perf: "error",
style: "error",
restriction: "error",
},
rules: {
...explicitTypes.configs.all.rules,
...importStructure.configs.all.rules,
...projectLint.configs.project.rules,
},
overrides: projectOverrides,
options: { options: {
typeAware: true, typeAware: true,
typeCheck: true, typeCheck: true,