Move the action from Deno to Node.js and Vite+ (#20)

## Summary

Moves `commons/bot-agents` off Deno. The action's TypeScript now runs directly on the job image's Node.js (`node src/<phase>.ts`, using Node's built-in type stripping), with no build step and still no runtime dependency. Repository checks move to Vite+ (`vp check`, `vp test`).

## What changed

- **Runtime.** `action.yml` runs `prepare.ts`, `run.ts`, and `post.ts` with `node` instead of `deno run -A`. `package.json` (`"type": "module"`, `engines.node >=24.2` for `import.meta.main`) is now copied into the root-owned source copy by `scripts/protect-source.sh`.
- **Profiles.** `profiles.yml` → `profiles.json`, with the same content (encrypted values unchanged), parsed with `JSON.parse`. This drops the only runtime dependency (`@std/yaml`).
- **Deno APIs → Node.**
  - `src/command.ts` wraps `child_process.spawn` with the old `Deno.Command` shapes: `output()`/`spawn()`, `clearEnv`, `uid`/`gid`, and Deno's stdio defaults (stdin inherited; stdout/stderr piped for `output()`, inherited for `spawn()`). A signal exit maps to `128 + signo`. Status settles on process exit.
  - `src/errors.ts` maps `NotFound` to `ENOENT`/`ESRCH`.
  - fs, path, stream, and readline calls are mapped one for one. `makeTempFile` becomes an exclusive `wx` 0600 create.
- **Size refactors.** These are needed for Oxlint's default limits. There are no behavior changes and no overrides for source files.
  - `run.ts` is split into `run-context.ts`, `agents.ts`, and `agent-{claude,codex,opencode}.ts`.
  - `post.ts` is split into `post.ts` and `post-artifacts.ts`.
  - `parseCredentials`, `readOutcome`, and `postRuntime` are broken into helpers.
  - Loops that awaited sequentially became recursion or a `reduce` chain, so they stay sequential. `/proc` status reads became `Promise.all`.
  - `src/json.ts` adds type guards in place of `as` casts.
- **Tests.** The 58 `Deno.test` cases were converted to Vitest with the codemod, and its TODOs were finished by hand.
  - Unit tests are `src/*.test.ts`; the repository-level workflow test is `tests/workflow.test.ts`.
  - Root-only tests are now `test.skipIf(!IS_ROOT)`, so they show as skipped instead of silently passing.
  - New `tests/entrypoints.test.ts` runs each phase under real Node to prove its whole import graph loads.
- **Config.** The only config files are `package.json`, `tsconfig.json`, and `vite.config.ts`, plus `package-lock.json`.
  - Lint uses Oxlint correctness/suspicious/pedantic/perf at error, with default limits and type-aware checks.
  - `scripts/project-lint.ts` holds the rule list. It maps Deno's `recommended` rules that fall outside those categories, adds the template's `id-match`, `no-disable-comments`, and `no-non-ts-imports`, and maps Deno's `no-window`/`no-process-global`/`no-node-globals` to `no-restricted-globals` (so `process`/`Buffer` come from `node:` imports).
- **CI.** `.gitea/workflows/check.yml` runs `node --version`, `npm ci`, `npx vp check`, and `npx vp test` in the default runner image.
- **Formatting.** Oxfmt formatting is a separate first commit, so the second commit shows only the semantic diff.
- **AGENTS.md** is updated for the new toolchain.

## Deviations / risks

- **Live `@main` switch.** Six consumer repos use `commons/bot-agents@main`, so merging switches all of them at once. Suggested before merging: tag the current `main` (e.g. `deno-final`) as a rollback target, and canary this branch from one consumer (`uses: …/commons/bot-agents@node-migration`) on a throwaway PR.
- **Node version.** `post.ts` relies on `import.meta.main`, which needs Node ≥ 24.2. DANNYEC's cached `node:24-bookworm` minor is unknown (`forcePull=false`), and the CI job now prints `node --version` to confirm it.
- **Deno stays in the global install.** `deno` remains in the action's global npm install because agents still verify Deno-based consumers (memona, memona-home). Remove it once those repos move.
- **`prefer-readonly-parameter-types` is off.** This pedantic rule is turned off, as in the explicit-types template and the app repos, because Node/web types (`Response`, `Stats`, `RequestInit`) can never satisfy it. `scripts/**` has one narrow override (`no-unsafe-enum-comparison`) for AST node `type` comparisons.
- **Stricter validation of malformed input.** For valid input, behavior is unchanged. A few validations are stricter where the old code would have used a malformed value:
  - Gitea `/user` and comment responses are checked.
  - The runtime manifest and the outcome's profile type are checked against known values.
  - Some type-check errors are now `TypeError` (same messages).
- **@cunarist lint plugins.** "CI fails until @cunarist/eslint-plugin-* are published" does **not** apply here. This repo's Deno setup enabled no plugin, so it depends on none of `@cunarist/eslint-plugin-*`, and CI can pass as is.

## Verification

- `vp check`: formatting, lint (0 warnings), and type check all pass.
- `vp test` as a normal user: 56 passed, 5 root-only skipped.
- `vp test` as root in `node:24-bookworm` (Node v24.22.0, the CI image): all 61 passed, including the UID isolation, process cleanup, and `prepare.ts` cleanup tests.
- End-to-end smoke as root in `node:24-bookworm`, with fake `codex`/`claude`/`npx` binaries and a fake Gitea, through `protect-source.sh` → `prepare.ts` → `run.ts` → `post.ts`:
  - Codex hit "usage limit" and fell through to Claude.
  - Agents ran as UIDs 20000/20001 without the master key.
  - The Codex login was persisted to `/root/.codex/auth.json` (0600).
  - The PR review with an inline comment was posted with the reviewer token.
  - Runtime credentials were removed and the git author was configured.

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

Reviewed-on: #20
Reviewed-by: bot <noreply@capsulizers.com>
Co-authored-by: Danny Kim <temeddix@gmail.com>
Co-committed-by: Danny Kim <temeddix@gmail.com>
This commit was merged in pull request #20.
This commit is contained in:
Danny Kim
2026-10-16 12:43:37 +00:00
committed by temeddix
parent f001a71c61
commit 006bbe6b0c
78 changed files with 7186 additions and 2231 deletions
+39
View File
@@ -0,0 +1,39 @@
---
description: Every repo has AGENTS.md starting with the damem recall header; durable context lives in .agents/memory (flat, one fact per file) and .agents/skills
---
Standing convention (2026-10-12): every repo has an `AGENTS.md` whose first
lines are exactly the damem header (cunarist/damem):
```markdown
At the start of every new session, run `damem recall` in the terminal and treat
its output as the context for this repository. If the `damem` command is not
found, read [INSTALL.md](https://github.com/cunarist/damem/blob/main/INSTALL.md)
and follow it before reasoning or responding.
```
Below it: `# AGENTS.md`, then `## Project` (what the repo is, runtime,
toolchain, where it came from) and `## Rules` (short bullets, each pointing at
a memory where the reason lives). No `CLAUDE.md`, `GEMINI.md`, or other
per-agent files; `AGENTS.md` is the single entry point.
## `.agents/` layout
- `memory/*.md`: one fact per file, flat (subdirectories are not listed),
frontmatter with a one-line `description` that states the fact. Link
related notes with double-bracket wiki links using the file name without
`.md`; a link to a missing note fails `damem doctor`.
- `skills/<name>/SKILL.md`: frontmatter `name` and `description` (when to use
it), body says how. Helper scripts live next to it and are Python 3.12+.
- `scratch/`: holds a `.gitignore` containing `*`.
- Every memory and `SKILL.md` stays under 8 KB.
## Keeping it right
- Update an existing note instead of adding a near-duplicate; delete notes
that turn out wrong.
- Write a note when the user corrects something, states a preference, or
when understanding something took reading many files.
- Run `damem doctor` before finishing and fix what it reports.
- Changing anything outside `.agents/` (source, config, `AGENTS.md`) follows
the normal branch and PR flow ([[git-pr-workflow]]).
+36
View File
@@ -0,0 +1,36 @@
---
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
@@ -0,0 +1,20 @@
---
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`.
@@ -0,0 +1,41 @@
---
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.
@@ -0,0 +1,29 @@
---
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
---
Decisions for `commons/bot-agents` (Capsulizers Gitea), from the Deno to
Node + Vite+ migration (PR #20, `node-migration`, 2026-10-15/16):
- **Live action.** Six consumer repos use `commons/bot-agents@main`, so a merge
switches all of them at once. It changes only through a PR and a careful
review. Suggested before merging a risky change: tag the current `main`
(e.g. `deno-final`) as a rollback target and canary the branch from one
consumer (`uses: .../commons/bot-agents@<branch>`) on a throwaway PR.
- **Profiles are JSON.** `profiles.yml` became `profiles.json` with the same
content (encrypted `v1:` values unchanged), read with `JSON.parse`. That
removed the only runtime dependency (`@std/yaml`); keep it dependency-free.
- **Node 24.2+**, not 22: `src/post.ts` uses `import.meta.main`. `engines.node`
is `>=24.2`, which the user accepted for this repo
([[bot-agents-runtime-no-build]]).
- **Deno stays in the action's global npm install** (`action.yml`), only
because the agents still check Deno-based consumer repos (memona,
memona-home). Remove it once those repos have moved; it is not a
dependency of this repo's TypeScript.
- **No `@cunarist/eslint-plugin-*`.** The Deno setup enabled no plugin, so
this repo depends on none and CI passes without them being published.
- **Stricter input checks were accepted** in the migration: Gitea `/user` and
comment responses, the runtime manifest, and the outcome's profile type are
validated; some type errors became `TypeError` with the same messages.
- Merging is the user's call; the `bot` account's approval is required first
([[git-pr-workflow]]).
+43
View File
@@ -0,0 +1,43 @@
---
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,20 @@
---
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.
@@ -0,0 +1,25 @@
---
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]].
@@ -0,0 +1,31 @@
---
description: The action runs src/<phase>.ts directly with node on node:24-bookworm (no build); protect-source.sh copies only src/*.ts, package.json, profiles.json, prompt.md
---
`action.yml` is a composite action running three phases as
`node "<source>/src/{prepare,run,post}.ts"`. Node's built-in type stripping
runs the TypeScript; there is **no build step, no bundle, and no runtime
dependency**. Hence `tsconfig.json` has `erasableSyntaxOnly` and
`allowImportingTsExtensions`: only erasable syntax (no `enum`, no parameter
properties, no decorators) and relative `.ts` imports.
- **Image:** the steps assume the `node:24-bookworm` job container. Node must
be 24.2+ for `import.meta.main` in `post.ts`; `check.yml` prints
`node --version` to confirm the cached image's minor.
- **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
phase. It copies exactly `src/*.ts`, `package.json` (`"type": "module"`
makes Node load the sources as ES modules), `profiles.json`, and
`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.
- `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
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
frontend or Rust/Tauri output paths, no Containerfile, no decorators
plugin, no lit-check, no generated JS ([[app-vite-build-output]],
[[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
default image as root: `npm ci`, `npx vp check`, `npx vp test`. It does not
use `setup-vp`. YAML stays declarative ([[yaml-stays-declarative]]).
@@ -0,0 +1,22 @@
---
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
---
Some tests exercise real UID isolation, process cleanup, and `prepare.ts`
credential cleanup. They need root and are written as
`test.skipIf(!IS_ROOT)(...)` (`IS_ROOT` from `src/testing.ts`), so they show
as skipped instead of silently passing.
- As a normal user `vp test` passes 56 and skips 5. In CI (self-hosted
runner, root) all 61 run.
- When changing `isolation.ts`, `runtime.ts`, `credentials.ts`, `prepare.ts`,
or `command.ts`, run the suite as root in `node:24-bookworm` (e.g. a
container with the repo mounted) before pushing; a green non-root run
proves nothing about those paths.
- The migration also ran an end-to-end smoke as root with fake
`codex`/`claude`/`npx` binaries and a fake Gitea through
`protect-source.sh` -> `prepare.ts` -> `run.ts` -> `post.ts`; repeat that
for changes to the phase flow ([[bot-agents-runtime-no-build]]).
- `src/testing.ts` holds the shared test helpers; tests follow
[[tests-layout-vitest]] (unit tests in `src/*.test.ts`, repo-level ones in
`tests/`).
@@ -0,0 +1,44 @@
---
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
@@ -0,0 +1,45 @@
---
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.
+52
View File
@@ -0,0 +1,52 @@
---
description: Work on a branch, plain imperative commits without AI trailers, one PR with the Claude Code footer, review until approved, never merge yourself; squash only
---
## Branches and commits
- Never push to `main`. Every change goes through a branch and a PR, even a
one-line fix.
- Commit messages are plain imperative sentences ("Port the import-check
rules to an ESLint plugin"). No conventional-commit prefixes, no AI
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`,
runs `git pull --rebase` before pushing, and retries a rejected push after
rebasing. Remove the worktree and temp branch afterwards.
- `vp check`, `vp test`, and the build pass locally before every push.
## Pull requests
- One PR per repo for a migration, everything included (size-rule refactors
too).
- The body covers: summary, what changed, deviations and risks, verification
done, and open questions. It ends with exactly this line:
```
🤖 Generated with [Claude Code](https://claude.com/claude-code)
```
- Then run the review cycle (skill `review-cycle`): on Capsulizers Gitea repos
that have `.gitea/workflows/agents.yml`, comment `@bot` and address the `bot`
account's review; elsewhere, a subagent reviews the diff and posts the
review as a PR comment. Repeat until fully approved, then ask the user to
review.
## Merging
- **Never merge on your own.** The user merges, unless they explicitly allow
it for one specific PR.
- GitHub repos are squash-merge only, so a PR lands as one commit titled after
the PR.
- Also never, without the user: publish, archive a repo, or delete a branch
the user may still use.
## Hosts
- **cunarist** and **temeddix**: GitHub only, CI in `.github/workflows`, use
the `gh` CLI.
- **Capsulizers**: Gitea at git.capsulizers.com (Gitea 1.26), CI in
`.gitea/workflows`, PRs through the Gitea API. Push and open PRs as the
user's own account (credentials in the user's environment; source them in a
subshell and never print them); `bot` is a separate review account. The
org's codified rules live in the `commons/code-rules` README.
+43
View File
@@ -0,0 +1,43 @@
---
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.
+48
View File
@@ -0,0 +1,48 @@
---
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
---
## `#` aliases
Shared code is imported through Node subpath imports declared in
`package.json` `imports`, never by a relative path that climbs out of a
directory:
```jsonc
"imports": {
"#helpers": "./src/helpers/index.ts",
"#components/*": "./src/components/*"
}
```
```ts
import { typeNameTail } from "#helpers"; // yes
import { typeNameTail } from "../helpers/ast.ts"; // no: import/no-relative-parent-imports
```
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
([[ts-only-files-and-imports]]).
- In repos linted by import-check, the key order of `imports` is the layer
order `enforce-layer-order` checks; add a new alias at the right layer.
- Keep test-only helpers in their own alias (e.g. `#testing`) so they never
land in a bundle.
## `index.ts` entries and barrels
- Every directory's entry file is `index.ts`, never `mod.ts` (decided
2026-10-12; import-check's `enforce-mod-file` now requires `index.ts`).
- 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
import replaces many direct ones ([[lint-overrides-policy]]).
- `import/no-cycle` is on; a barrel must not create a cycle.
Relative imports inside one directory (`./sibling.ts`) are fine, with the
`.ts` extension written out.
+47
View File
@@ -0,0 +1,47 @@
---
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.
@@ -0,0 +1,30 @@
---
description: Every eslint-/oxlint-disable or -enable comment is banned in every form; three checks enforce it together, keep all three
---
Banned (2026-10-16): `eslint-disable`, `eslint-disable-line`,
`eslint-disable-next-line`, `eslint-enable`, and the same with `oxlint-`, as
line or block comments, anywhere in the repo. Fix the code; if the code truly
can't be fixed, ask the user, don't silence the rule. Old
`deno-lint-ignore` lines are removed during the migration and their cause
fixed.
Oxlint (1.87) has no `noInlineConfig` and no `eslint-comments` rules, and
`respectEslintDisableDirectives: false` only ignores `eslint-*` directives. So
three checks enforce the ban together, and none is redundant:
1. `project/no-disable-comments` (custom, in `scripts/project-lint.ts`) fails
any directive.
2. `unicorn/no-abusive-eslint-disable` fails a directive that names no rule,
which would otherwise silence the custom rule on its own line.
3. `options.reportUnusedDisableDirectives: "error"` fails a directive naming
`project/no-disable-comments` (the custom rule skips that one, so the
directive silences nothing and is reported as unused).
Every form was probed and fails `vp check`.
A rule that fires on legitimate code is a rule problem: change the rule or its
options in `configs.project` with the user's agreement, not the call site.
Known example: rule records are `Map`s keyed by id rather than object
literals with kebab-case keys, which keeps naming rules happy without any
directive.
+45
View File
@@ -0,0 +1,45 @@
---
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).
@@ -0,0 +1,46 @@
---
description: Lint coverage never drops below what Deno enforced; every Deno rule maps to an Oxlint rule, a plugin rule, the compiler, or our own rule
---
Hard rule (2026-10-12): after the move, a repo has at least the lint coverage
it had under Deno: built-in rules (the `recommended` and `jsr` tags plus
opt-ins like `camelcase`, `eqeqeq`, `guard-for-in`, `no-console`,
`no-eval`, `no-throw-literal`, `no-non-null-assertion`,
`verbatim-module-syntax`), `@hugoalh` rules, and the custom plugins. Rules may
be added, never lost. Formatting may change. "Gap" is not an acceptable end
state: a Deno rule Oxlint lacks is replaced by a plugin rule or our own rule in
[[project-lint-plugin]].
Before removing any line from `configs.project`, find what else covers it.
## How the template mapped Deno's 97 rules
- **Same rule** in `configs.project` at `error`.
- **Renamed equivalent:** `no-new-symbol` → `no-new-native-nonconstructor`;
`no-window` → `no-restricted-globals: ["error", "window"]`;
`ban-untagged-ignore` → `unicorn/no-abusive-eslint-disable`;
`ban-unused-ignore`, `ban-unknown-rule-code` →
`reportUnusedDisableDirectives`; `camelcase` → built-in `id-match`
([[naming-id-match]]).
- **Caught by the parser or `typeCheck`:** `no-dupe-args`, `no-octal`,
`no-await-in-sync-fn`, `no-import-assertions` (TS2880), `no-sloppy-imports`
(TS2835), `verbatim-module-syntax` (TS1484).
- **`no-slow-types`:** `isolatedDeclarations`, enforced by `vp pack` (TS9010)
in packages.
- **Vacuous:** JSX rules (no `.tsx` exists) and Deno-only rules.
- **`@hugoalh` rules** map to Oxlint built-ins in `configs.project`.
- **import-check's CLI cycle check** → `import/no-cycle`.
The template's list in `configs.project` is the baseline to copy: `curly`,
`guard-for-in`, `no-console`, `no-empty`, `no-regex-spaces`,
`no-restricted-globals`, `no-var`, `prefer-const`, `import/no-cycle`,
`import/no-relative-parent-imports`, `typescript/adjacent-overload-signatures`,
`typescript/consistent-type-imports`, `typescript/no-empty-interface`,
`typescript/no-explicit-any`, `typescript/no-namespace`,
`typescript/no-non-null-assertion`, the two `project/*` rules,
`unicorn/no-abusive-eslint-disable`, `id-match`, and
`typescript/switch-exhaustiveness-check` with
`considerDefaultExhaustiveForUnions`.
Each migration PR states its parity mapping (or links the template's table in
cunarist/eslint-plugin-explicit-types#1) so the user can check it.
+40
View File
@@ -0,0 +1,40 @@
---
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.
+48
View File
@@ -0,0 +1,48 @@
---
description: Deno's camelcase is Oxlint id-match ^_*[^_]*_*$|^[^a-z]*$; leading _ for unused vars; rename snake_case imports; PascalCase types; single-letter type params
---
Oxlint has no `camelcase`. Its built-in `id-match` replaces it, set in
`configs.project` (user decision 2026-10-16, replacing a ~400-line port):
```ts
"id-match": ["error", "^_*[^_]*_*$|^[^a-z]*$", {
properties: false, classFields: false, onlyDeclarations: false,
ignoreDestructuring: true,
}],
```
**Meaning** (Deno's `is_underscored`): after trimming leading and trailing
underscores, the name has no `_`, **or** the name has no lowercase ASCII
letter. `id-match` can't tell what kind of name it sees, so one regex accepts
every valid style; other rules decide which style goes where.
```ts
const userName = 1; // ok
class UserStore {} // ok here; pascal-case-types enforces PascalCase
const MAX_SIZE = 10; // ok: no lowercase, so UPPER_SNAKE passes
const _unused = 1; // ok: leading _ marks an unused binding
const user_name = 1; // error
```
- **Leading underscores stay allowed** for unused parameters and variables
(`items.map((_item, index) => index)`). The user considered banning them and
kept them for exactly this.
- **Rename snake_case imports at the import** (user wants this flagged):
`import { parse_args as parseArgs } from "some-lib";`
- `declare` lines and snake_case globals are rare and accepted as they are.
- **Types:** classes, interfaces, type aliases, enums are PascalCase
(`pascal-case-types` from explicit-types' `naming` config); string-union
members are UPPER_SNAKE_CASE; no `enum`.
- **Type parameters:** the user wants single capital letters (`T`, `K`), not
`Item`; explicit-types PR 2 adds `single-capital-type-params` to `naming`.
Write new code that way already.
- No autofix exists for `id-match`; rename by hand.
**Known differences from Deno's `camelcase`** (against its 130 test cases):
it misses 9 (unquoted object keys like `{ bar_baz: 1 }` and `{ bar_baz }`,
shorthand destructuring in parameters, `{ a_b: a_b }`,
`{ foo: bar_baz = 1 }`) and over-reports 17 (non-renamed imports, assignments
to unresolved globals, 11 ambient `declare` cases). Beyond Deno it also checks
type parameters, interface and type-literal members, class methods, and
labels. The user accepted these differences.
+51
View File
@@ -0,0 +1,51 @@
---
description: package.json key order is metadata, scripts, exports, imports, devDependencies, dependencies; standard fields only; no peerDependencies or packageManager
---
## Key order (2026-10-14)
Metadata first, then `scripts`, `exports`, `imports`, `devDependencies`,
`dependencies`. Oxfmt would re-sort the file, so `vite.config.ts` sets
`fmt: { sortPackageJson: false }`; keep it, and keep the order by hand.
```jsonc
{
"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.
## Fields
- **Standard fields only.** `engines` has only `node` (never `engines.bun`).
A tool-owned key (`eslint-doc-generator`) is allowed only because the tool
reads nothing else and no other config file is allowed.
- **No `packageManager`.** npm is detected from `package-lock.json`.
- **No `peerDependencies`, ever** (user preference, 2026-10-14). If something
is needed at runtime, it goes in `dependencies`.
- **`dependencies`** = what runs or is type-referenced at runtime by consumers
(the lit plugin ships `parse5`, `oxc-parser`, `oxc-resolver`, and
`@eslint/core` for its public types; they stay external, never bundled).
- **`devDependencies`** = everything else: `vite-plus`, `eslint`,
`@typescript-eslint/parser`, `eslint-doc-generator`, `esbuild`,
`typescript-6`, `@types/node`. ESLint is a devDependency only; plugins need
nothing from it at runtime.
Refresh the lockfile with `npm install` (or `npm install
--package-lock-only` for a version bump) and commit it with the change.
+55
View File
@@ -0,0 +1,55 @@
---
description: scripts/project-lint.ts is the repo-local plugin "project" holding the repo's rule list (typed DummyRuleMap) and repo-only rules; never published, no tests
---
Every repo has `scripts/project-lint.ts`, copied from
`cunarist/eslint-plugin-explicit-types` and extended. It is a repo-local
Oxlint plugin with `meta.name` `"project"`, loaded through `jsPlugins` in
`vite.config.ts`. It holds:
- **`configs.project.rules`**: the repo's whole rule list beyond the published
plugin configs: Deno parity rules, extras, per-rule options, and opt-outs,
built-in Oxlint ids included (`curly`, `id-match`, `import/no-cycle`, ...).
- **Custom rules**, enabled as `project/<rule>`. Every repo has
`no-disable-comments` and `no-non-ts-imports`. Apps also port the rules of
their old `scripts/deno-lint-plugin.ts` here under the same names and
behavior (`no-self-sync`, `app-module-conventions`, `restricted-types`, ...).
Rules and plugin stay in this one file; don't add `scripts/rules/`.
```ts
import type { DummyRuleMap } from "vite-plus/lint";
const rules: DummyRuleMap = {
"id-match": ["error", "^_*[^_]*_*$|^[^a-z]*$", {/* ... */}],
curly: "error",
"project/no-disable-comments": "error",
};
const plugin = {
meta: { name: "project" },
rules: { "no-disable-comments": noDisableCommentsRule /* ... */ },
configs: { project: { rules } },
};
export default plugin;
```
## Why `DummyRuleMap` and an unannotated plugin (decided 2026-10-15)
- `DummyRuleMap` (re-exported by `vite-plus/lint`) is Oxlint's type for
`OxlintConfig.rules`. Despite the name it lists every built-in rule with its
options, so a typo in a rule id or option is a type error, not just a CI
failure. Without it, TypeScript widens `"error"` to `string`; `as const`
doesn't help because Oxlint wants mutable tuples.
- `@eslint/core`'s `Plugin` is too loose to read back: `configs` is optional,
any key may be missing, and a config may be an array, so
`projectLint.configs.project.rules` fails in `vite.config.ts` (and `!` is
banned). Optional chaining still leaves the array case.
- Oxlint's own `Plugin` type (`vite-plus/lint/plugins`) has no `configs`.
- Two hand-written interfaces worked but were dropped as noise; a plain
config object was rejected because custom rules need the plugin form anyway
and every repo should look the same.
`scripts/` is tooling, not production code: never packed or published, and
no tests. Narrow `overrides` for `scripts/**` are allowed when a script truly
needs one (e.g. `no-console`); see [[lint-overrides-policy]].
+37
View File
@@ -0,0 +1,37 @@
---
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.
+36
View File
@@ -0,0 +1,36 @@
---
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
---
Decided 2026-10-14 for all repos.
- **Runner:** Vitest through `vp test`. Import test APIs from
`vite-plus/test` (`describe`, `expect`, `it`).
- **Unit tests** sit next to the code: `src/foo/bar.ts` is tested in
`src/foo/bar.test.ts`. Vitest's default include finds them; no `test`
config needed. Packages publish only `dist/`, so colocated tests cost nothing
in the tarball.
- **Regression and integration tests** live in the top-level `tests/` folder
and use only the public entry point (an alias like `#plugin`), never
internals. `vp test tests` runs just these.
- **No in-source tests** (`import.meta.vitest`).
- **`scripts/` gets no tests** (2026-10-15): it is repo tooling, not
production code (`project-lint.ts`, `decorators.ts`, `lit-check.ts`,
release helpers).
- Test files follow every lint rule; no relaxed overrides for tests.
## Migrating Deno tests
`Deno.test` suites were converted by a codemod
(`~/.local/share/deno-migration/src/convert.ts` on the user's machine), then
its TODOs finished by hand. Watch for:
- `@std/assert` → `expect` (`assertEquals` → `toStrictEqual` or `toBe`,
`assertThrows` → `toThrow`, `assertRejects` → `rejects.toThrow`);
- `Deno.*` file, env, and process APIs → `node:fs`, `process.env`,
`node:child_process`;
- `t.step` → nested `describe`/`it`;
- sanitizer options, which Vitest doesn't have.
A migration PR must keep every test case; a dropped case is lost coverage.
Tests must pass locally before any push.
@@ -0,0 +1,53 @@
---
description: Node.js 22+ with Vite+ (vp) and npm; only package.json, tsconfig.json, vite.config.ts plus a committed package-lock.json
---
## The stack
- **Runtime:** Node.js 22 or newer. `engines` is `{ "node": ">=22" }` and
nothing else. Containers and the publish job use Node 24 (LTS); a repo may
need more where a feature demands it (bot-agents uses Node 24.2+).
- **Bun is not supported** (dropped 2026-10-15). No Bun CI legs, docs, or
runtime branches. Oxlint's `RuleTester` refuses to run under Bun anyway, and
`vp` brings its own Node, the way uv brings its own Python.
- **Toolchain:** Vite+ (`vite-plus` devDependency, command `vp`): Vite,
Vitest, Oxlint, Oxfmt, type checking, tsdown.
- **Package manager:** npm everywhere. `package-lock.json` is committed in
apps _and_ libraries (user agreed 2026-10-14: `npm ci` needs it). There is no
`packageManager` field; Vite+ detects npm from the lockfile.
## Config files
Exactly three, plus the lockfile: `package.json`, `tsconfig.json`,
`vite.config.ts`. No `.oxlintrc.json`, `.prettierrc`, `eslint.config.*`,
`vitest.config.*`, `.npmrc`, or `tsdown.config.*`. Tool options go into
`vite.config.ts` keys (`lint`, `fmt`, `test`, `pack`) or, when a tool only
reads `package.json` (eslint-doc-generator), into a `package.json` key.
The target is a short `vite.config.ts`, around 30 lines (not strict).
## Commands
| Command | Does |
| ------------------------ | ---------------------------------------------------------- |
| `vp check` | format check, Oxlint (type-aware), and type check together |
| `vp check --fix` | the same, applying formatting and safe fixes |
| `vp test` | Vitest; `vp test tests` runs only the top-level `tests/` |
| `vp build` | Vite production build (apps) |
| `vp dev` | Vite dev server (apps) |
| `vp pack` | tsdown library build into `dist/` (packages) |
| `npm ci` / `npm install` | install; never another package manager |
`vp check`, `vp test`, and the build (`vp build` or `vp pack`) must all pass
before any push. CI installs the toolchain with `voidzero-dev/setup-vp`.
## Gotchas found during the migration
- `vp check` does **not** report `isolatedDeclarations` errors (TS9010); only
`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
order. Set `fmt: { sortPackageJson: false }` ([[package-json-shape]]).
- Oxlint and Oxfmt skip what `.gitignore` lists; that is the only reason
`dist/` and `node_modules/` are not linted. Keep build output gitignored.
- Vite+ bundles `vite.config.ts` itself and resolves `#` imports in it.
- Oxlint loads a `.ts` plugin file directly on Node 24 (`jsPlugins`), so
repo-local lint plugins need no build step.
@@ -0,0 +1,48 @@
---
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
---
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
plain `.ts`. Build output in a gitignored folder is the only JS.
`tsconfig.json`: no `allowJs`, no `jsx`, and the template's strict flags
(`verbatimModuleSyntax`, `erasableSyntaxOnly`, `allowImportingTsExtensions`,
`isolatedDeclarations` in packages, `exactOptionalPropertyTypes`,
`noUncheckedIndexedAccess`). 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)
`project/no-non-ts-imports` (in `scripts/project-lint.ts`) fails:
- a relative, absolute, `#`, or package-subpath specifier whose last segment
has a non-`.ts` extension: `.css`, `.json`, `.svg`, `.js`, `.mjs`, `.tsx`;
- any `?query` suffix (`?raw`, `?url`, `?inline`);
- any import attributes (`with { type: "json" }`, `with { type: "text" }`).
Bare packages (`lit`, `@types/json-schema`, `lodash.debounce`), `#` aliases,
and `node:` builtins pass. `@fortawesome/x/svgs/a.svg` fails.
```ts
import "./theme.css"; // no
import data from "./scenes.json" with { type: "json" }; // no
import icon from "./lock.svg?raw"; // no
import { scenes } from "#data/scenes"; // yes: scenes.ts
```
## What to do instead
- **CSS:** load it from HTML with `<link rel="stylesheet" href="...">`. Never
import CSS from TS, not even for Vite. (This also removes most of the
`import/no-unassigned-import` hits the `suspicious` category reports.)
- **JSON data:** turn it into a `.ts` module exporting a typed constant.
- **SVG:** a `.ts` module exporting a Lit template; see [[icons-memona-style]].
- **`package.json` fields at runtime:** never `import pkg from
"./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`
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
fine in tooling and tests.
- **A tool's `.js` entry:** don't `import("pkg/cli.js")`; use the package
entry or spawn the package's bin ([[lit-analyzer-ts6]]).
+53
View File
@@ -0,0 +1,53 @@
---
description: Ask one decision at a time with a code example, follow ESLint/ecosystem conventions and ask before deviating, keep config minimal, and never block when the user is away
---
## Asking
- **One question at a time.** Each decision question stands alone, with the
options and a recommendation.
- **Show code.** The user is new to the ESLint ecosystem and asked for
example code blocks whenever possible (2026-10-14): before/after snippets
or config examples, not prose alone.
```ts
// A. per-file override // B. refactor with a barrel
overrides: [{ files: [...] }] import { a, b } from "#ui";
```
- **Keep answers short.** State the decision and what changed; detail belongs
in `.agents/memory/`, not chat.
- The user often probes with "why?" and "is this the simplest form?". Have
the reason ready, and prefer the option with fewer lines, fewer files, and
fewer custom types when the result is equivalent.
## Conventions over invention
- Follow ESLint's and the wider ecosystem's conventions (typescript-eslint,
unicorn as references): `docs/rules/<rule>.md`, one plugin per package,
flat configs at `plugin.configs`, `meta.docs.url`, `index.ts` entries.
- **Any deviation is a question, not a default**, even one carried over from
the Deno version. The user was unhappy when rule docs were quietly placed in
`src/` ("You should have asked me about this though. We should respect
ESLint's way").
- Before finishing structural work, compare against well-known projects and
list every difference for the user.
## When the user is away
- **Never block on the user.** No approval requests, no waiting. Pick the
conservative option consistent with these notes, write the choice and its
reason in the PR or report, and continue. (An idle approval request once
cost hours; the user was emphatic.)
- Log open questions where the user will look: the PR body, and the shared
questions file the coordinator names (during the migration,
`~/.local/share/deno-migration/morning-questions.md`).
- Hard limits still hold without asking: no merging, no publishing, no
archiving, no pushing to `main`, no external state changes.
## Working style the user expects from agents
- Keep the main conversation free: repo edits, commits, and long checks run
in background subagents.
- Fan independent work out to many parallel subagents (more than five when it
splits), each in its own worktree when they share a branch.
+1
View File
@@ -0,0 +1 @@
*
+69
View File
@@ -0,0 +1,69 @@
---
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.
+40
View File
@@ -0,0 +1,40 @@
---
name: review-cycle
description: After opening a PR, run review rounds until fully approved; @bot on Capsulizers Gitea repos, an adversarial subagent review posted as a PR comment elsewhere; then hand to the user, never merge
---
Every PR gets reviewed before the user sees it. Repeat rounds until the
review is a full approval (cap: 6 rounds; if still not approved, stop and
report what remains). Never approve to finish early, and never merge.
## Capsulizers repos (git.capsulizers.com) with `.gitea/workflows/agents.yml`
1. Comment `@bot` on the PR through the Gitea API, as the user's own account
(credentials from the user's environment, sourced in a subshell, never
printed).
2. Wait for the review from the `bot` account (poll about every 2 minutes,
up to about 30 minutes per round).
3. For each point: fix valid ones with commits on the PR branch; reply
briefly with reasons to ones that are wrong.
4. Re-run `vp check`, the build, and `vp test`, push, and comment `@bot`
again. Done when the bot approves.
## Other repos (GitHub, or Gitea without the bot)
1. Have a separate subagent review the **full PR diff** adversarially:
correctness, behavior changes, missed Deno APIs, broken build paths, lint
parity, and violations of the repo's `.agents/memory` rules.
2. Post the review as a PR comment headed `Automated review (round N)`,
ending with exactly one verdict line: `VERDICT: APPROVED` or
`VERDICT: CHANGES REQUESTED`.
3. Address every point with commits, re-run checks, push, and reply to the
review comment summarizing the fixes.
4. Next round reviews from scratch, not just the fixes. Done when a round's
verdict is `APPROVED`.
## After approval
- Ask the user to review (or, when the user is away, list the PR and any open
questions in the report and the questions file). Don't wait idle.
- The user merges (squash). Don't merge even after approval unless the user
explicitly allowed it for this PR.
+9 -8
View File
@@ -5,17 +5,18 @@ on:
branches: [main] branches: [main]
jobs: jobs:
deno: check:
runs-on: self-hosted runs-on: self-hosted
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- run: apt-get update && apt-get install -y unzip # The action runs on this same image; src/post.ts needs Node 24.2+.
- run: node --version
- uses: denoland/setup-deno@v2 - run: npm ci
- run: >- # Formatting, lint, and type checks, then every test. The job runs as
deno fmt --check . && # root, so the isolation tests run rather than skip.
deno lint . && - run: npx vp check
deno check src/prepare.ts src/run.ts src/post.ts &&
deno test --allow-all - run: npx vp test
+2
View File
@@ -0,0 +1,2 @@
node_modules/
coverage/
+22 -7
View File
@@ -1,7 +1,7 @@
At the start of every new session, run `damem recall` and treat its output as At the start of every new session, run `damem recall` in the terminal and treat
the repository context. If `damem` is unavailable, read and follow its output as the context for this repository. If the `damem` command is not
[its installation instructions](https://github.com/cunarist/damem/blob/main/INSTALL.md) found, read [INSTALL.md](https://github.com/cunarist/damem/blob/main/INSTALL.md)
before reasoning or responding. and follow it before reasoning or responding.
# Pull requests # Pull requests
@@ -10,6 +10,21 @@ comment `@bot` to request an AI-bot review. Wait for green CI and the bot's
approval on the current head before merging. Never merge without the user's approval on the current head before merging. Never merge without the user's
explicit permission. explicit permission.
Repository TypeScript and checks use Deno. The composite action uses npm only to # Runtime and checks
install the externally distributed Claude Code and Codex command-line tools; do
not treat those vendor executables as repository TypeScript dependencies. 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
dependency. Keep the sources to erasable syntax and `.ts` imports, which
`tsconfig.json` enforces. Checks use Vite+: `vp check` formats, lints, and
type-checks, and `vp test` runs Vitest. Unit tests sit next to the code as
`src/*.test.ts`; repository-level tests live in `tests/`.
The only config files are `package.json`, `tsconfig.json`, and
`vite.config.ts`, plus `package-lock.json`. Repo-local lint rules and the rule
list live in `scripts/project-lint.ts`. Lint disable comments are not allowed;
fix the source instead.
The composite action uses npm only to install the externally distributed
Claude Code, Codex, and OpenCode command-line tools, plus Deno for agents that
check Deno-based repositories; do not treat those vendor executables as
repository TypeScript dependencies.
+6 -4
View File
@@ -15,7 +15,9 @@ runs:
with: with:
fetch-depth: 0 fetch-depth: 0
token: ${{ gitea.token }} token: ${{ gitea.token }}
# This step assumes this is `node:24-bookworm` container. # This step assumes this is `node:24-bookworm` container. The action runs
# its TypeScript on that Node directly; Deno stays for the agents, which
# 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 deno
@@ -34,7 +36,7 @@ runs:
- name: Prepare credentials - name: Prepare credentials
id: prepare id: prepare
shell: bash shell: bash
run: deno run -A "${{ steps.source.outputs.path }}/src/prepare.ts" run: node "${{ steps.source.outputs.path }}/src/prepare.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 }}
@@ -42,7 +44,7 @@ runs:
- name: Run bot - name: Run bot
id: agent id: agent
shell: bash shell: bash
run: deno run -A "${{ steps.source.outputs.path }}/src/run.ts" run: node "${{ steps.source.outputs.path }}/src/run.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 }}
@@ -55,7 +57,7 @@ runs:
id: post id: post
if: always() && steps.prepare.outputs.manifest != '' if: always() && steps.prepare.outputs.manifest != ''
shell: bash shell: bash
run: deno run -A "${{ steps.source.outputs.path }}/src/post.ts" run: node "${{ steps.source.outputs.path }}/src/post.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 }}
+3083
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
{
"name": "bot-agents",
"description": "A Gitea composite action that runs a coding agent for approved issue and pull request automation.",
"private": true,
"type": "module",
"engines": {
"node": ">=24.2"
},
"devDependencies": {
"@eslint/core": "^0.17.0",
"@types/node": "^24.19.2",
"@typescript-eslint/types": "^8.71.2",
"vite-plus": "^1.2.0",
"yaml": "^2.9.1"
}
}
+19
View File
@@ -0,0 +1,19 @@
{
"reviewer-token": "v1:XFUvCI9PnXVaW-ig:_3S9BvXywUp29cVYGA_EzFScKwC5oHkV9x2Ld8k-V_uiG7iA8n3fmjXCArzh2Lz-JDgy3IMTHoY",
"profiles": [
{
"type": "codex",
"model": "gpt-5.6-terra"
},
{
"type": "claude",
"model": "claude-sonnet-5",
"token": "v1:-dyfF6ZicEhjPk4C:WCuMaGd09tYjtzUCl7abloRzPITvP5C35WpR372w3O_tcaD-ls-yV5sJBdU8iIVApZVCC98sY2gOyM3o9RMa8ciJq8Fuys7KxlYOp8QiluL9AYMSQi6rEsSH71EPWnxSACgo3uDQY8bWQpUlEZvXH9O-ZN9A58dh6b3vRg"
},
{
"type": "opencode",
"model": "openrouter/qwen/qwen3.8-27b",
"token": "v1:4qdfJCdUx9jfiabC:8X0ic_J82lPlq_R3uTLaEbQy7UmEh7BgtG0bDKAcc8FeJ0xvEwkGCKaDoLmT5nta03hgjGdcWKjDdOSMi1kSDfp8J7tRTLtMRzDT8GX8OU_PVDPVahQIAU0"
}
]
}
-10
View File
@@ -1,10 +0,0 @@
reviewer-token: v1:XFUvCI9PnXVaW-ig:_3S9BvXywUp29cVYGA_EzFScKwC5oHkV9x2Ld8k-V_uiG7iA8n3fmjXCArzh2Lz-JDgy3IMTHoY
profiles:
- type: codex
model: gpt-5.6-terra
- type: claude
model: claude-sonnet-5
token: v1:-dyfF6ZicEhjPk4C:WCuMaGd09tYjtzUCl7abloRzPITvP5C35WpR372w3O_tcaD-ls-yV5sJBdU8iIVApZVCC98sY2gOyM3o9RMa8ciJq8Fuys7KxlYOp8QiluL9AYMSQi6rEsSH71EPWnxSACgo3uDQY8bWQpUlEZvXH9O-ZN9A58dh6b3vRg
- type: opencode
model: openrouter/qwen/qwen3.8-27b
token: v1:4qdfJCdUx9jfiabC:8X0ic_J82lPlq_R3uTLaEbQy7UmEh7BgtG0bDKAcc8FeJ0xvEwkGCKaDoLmT5nta03hgjGdcWKjDdOSMi1kSDfp8J7tRTLtMRzDT8GX8OU_PVDPVahQIAU0
+232
View File
@@ -0,0 +1,232 @@
/**
* `project` — lint rules this repository holds its own source to. Deno lint
* ran here with its `recommended` rules and no plugins; the Oxlint categories
* in `vite.config.ts` plus the rule list below keep at least that coverage.
* Its rules of its own are `no-disable-comments` and `no-non-ts-imports`.
*
* Loaded by `vite.config.ts` as an Oxlint `jsPlugins` entry.
*
* @module
*/
import type { LanguageOptions, RuleDefinition, RuleVisitor, TextSourceCode } from "@eslint/core";
import type { TSESTree } from "@typescript-eslint/types";
import type { DummyRuleMap } from "vite-plus/lint";
/** The source code both hosts hand a rule, with the comment list. */
interface ProjectSourceCode extends TextSourceCode {
getAllComments(): TSESTree.Comment[];
}
/** ESLint's default rule types, reporting TypeScript-ESTree nodes. */
interface ProjectRuleTypes {
LangOptions: LanguageOptions;
Code: ProjectSourceCode;
RuleOptions: [];
Visitor: RuleVisitor;
Node: TSESTree.Node;
MessageIds: string;
ExtRuleDocs: unknown;
}
/** A repo-local rule, in ESLint's standard `create` form. */
type ProjectRule = RuleDefinition<ProjectRuleTypes>;
/** A comment that turns lint rules off or back on, in ESLint or Oxlint form. */
const DIRECTIVE_PATTERN: RegExp = /^\s*(?:eslint|oxlint)-(?:disable|enable)/u;
/** This rule's own name, which a directive may not use to silence it. */
const RULE_NAME: string = "no-disable-comments";
/**
* `no-disable-comments` — rejects every `eslint-disable*`, `eslint-enable*`,
* `oxlint-disable*`, and `oxlint-enable*` comment, line or block. Oxlint has
* no `noInlineConfig` and no `eslint-comments` rules, so this repo bans them
* itself: when a rule fires, fix the source.
*
* A directive can silence the report on itself, so two built-in checks close
* the gaps: `unicorn/no-abusive-eslint-disable` fails one that names no rule,
* and this rule skips one that names it, which then silences nothing and fails
* `reportUnusedDisableDirectives` in `vite.config.ts`.
*/
const noDisableCommentsRule: ProjectRule = {
meta: {
type: "problem",
docs: { description: "Disallow lint disable and enable comments" },
schema: [],
messages: { directive: "Lint disable comments are not allowed; fix the source." },
},
create(context) {
return {
Program(_node: TSESTree.Program): void {
for (const comment of context.sourceCode.getAllComments()) {
if (DIRECTIVE_PATTERN.test(comment.value) && !comment.value.includes(RULE_NAME)) {
context.report({ loc: comment.loc, messageId: "directive" });
}
}
},
};
},
};
/** A module specifier read as a path: relative, absolute, or a `#` alias. */
const PATH_SPECIFIER_PATTERN: RegExp = /^[./#]/u;
/**
* Whether `source` names something other than a `.ts` module: a query string
* such as `?raw`, or a final path segment with any other extension. Bare
* package names are left alone, dots and all, as are extensionless paths.
*/
function isNonTsSource(source: string): boolean {
if (source.includes("?")) {
return true;
}
const segments: string[] = source.split("/");
if (!PATH_SPECIFIER_PATTERN.test(source)) {
const nameLength: number = source.startsWith("@") ? 2 : 1;
if (segments.length <= nameLength) {
return false;
}
}
const lastSegment: string = segments.at(-1) ?? "";
if (lastSegment === "." || lastSegment === ".." || !lastSegment.includes(".")) {
return false;
}
return !lastSegment.endsWith(".ts");
}
/**
* `no-non-ts-imports` — rejects importing anything but a `.ts` module: a
* source path, static or a dynamic `import()` with a string literal, ending in
* another extension (`.css`, `.json`, `.svg`, `.js`, `.tsx`) or carrying a
* query string (`?raw`, `?url`), and any import or export with import
* attributes. Bare package names, `#` aliases, and `node:` builtins without an
* extension pass; package subpaths are checked like paths.
*/
const noNonTsImportsRule: ProjectRule = {
meta: {
type: "problem",
docs: { description: "Disallow importing modules other than .ts files" },
schema: [],
messages: {
extension: "Only .ts modules may be imported.",
attributes: "Import attributes are not allowed.",
},
},
create(context) {
function checkSource(source: TSESTree.StringLiteral): void {
if (isNonTsSource(source.value)) {
context.report({ node: source, messageId: "extension" });
}
}
function checkDeclaration(
node:
| TSESTree.ImportDeclaration
| TSESTree.ExportNamedDeclaration
| TSESTree.ExportAllDeclaration,
): void {
if (node.source === null) {
return;
}
checkSource(node.source);
if (node.attributes.length > 0) {
context.report({ node, messageId: "attributes" });
}
}
return {
ImportDeclaration: checkDeclaration,
ExportNamedDeclaration: checkDeclaration,
ExportAllDeclaration: checkDeclaration,
ImportExpression(node: TSESTree.ImportExpression): void {
if (node.source.type === "Literal" && typeof node.source.value === "string") {
checkSource(node.source);
}
if (node.options !== null) {
context.report({ node, messageId: "attributes" });
}
},
};
},
};
/**
* This repository's own rule list, spread by `vite.config.ts`: Deno's
* `recommended` rules that sit outside the Oxlint categories, plus the
* template's extras. Built-in Oxlint rule ids sit here too, so
* `vite.config.ts` lists no rule by name.
*/
const rules: DummyRuleMap = {
// The explicit-types template's `camelcase` stand-in: no `_` inside a name
// once leading and trailing ones are trimmed, unless it has no lowercase
// ASCII letter (UPPER_SNAKE).
"id-match": [
"error",
"^_*[^_]*_*$|^[^a-z]*$",
{ properties: false, classFields: false, onlyDeclarations: false, ignoreDestructuring: true },
],
// Deno's `recommended` rules outside the categories.
"no-array-constructor": "error",
"no-case-declarations": "error",
"no-empty": "error",
"no-fallthrough": "error",
"no-inner-declarations": "error",
"no-prototype-builtins": "error",
"no-redeclare": "error",
"no-regex-spaces": "error",
"no-var": "error",
"prefer-const": "error",
"require-await": "error",
"require-yield": "error",
"typescript/adjacent-overload-signatures": "error",
"typescript/ban-ts-comment": "error",
"typescript/no-empty-interface": "error",
"typescript/no-empty-object-type": "error",
"typescript/no-explicit-any": "error",
"typescript/no-namespace": "error",
"typescript/no-non-null-asserted-optional-chain": "error",
"typescript/no-unsafe-function-type": "error",
"typescript/no-wrapper-object-types": "error",
"typescript/prefer-as-const": "error",
"typescript/prefer-namespace-keyword": "error",
"typescript/triple-slash-reference": "error",
// 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": [
"error",
"window",
"process",
"Buffer",
"global",
"setImmediate",
"clearImmediate",
],
// The explicit-types template's extras.
"import/no-cycle": "error",
"typescript/consistent-type-imports": "error",
"typescript/no-import-type-side-effects": "error",
"typescript/no-non-null-assertion": "error",
// Deno's `ban-untagged-ignore` and `ban-unused-ignore`, made stricter: no
// disable comments at all.
"project/no-disable-comments": "error",
"project/no-non-ts-imports": "error",
"unicorn/no-abusive-eslint-disable": "error",
// Pedantic, but Node's and the web's own types (`Response`, `Stats`,
// `RequestInit`) are mutable, so no parameter of theirs can pass; the
// explicit-types template and the app repos turn it off too.
"typescript/prefer-readonly-parameter-types": "off",
};
/**
* The repo-local plugin, namespace `project`: its own rules, enabled as
* `project/<rule>`, and `configs.project`, the rule list above.
*/
const plugin = {
meta: { name: "project" },
rules: {
"no-disable-comments": noDisableCommentsRule,
"no-non-ts-imports": noNonTsImportsRule,
},
configs: { project: { rules } },
};
export default plugin;
+2 -1
View File
@@ -4,6 +4,7 @@ set -euo pipefail
path=$(mktemp -d "${RUNNER_TEMP}/bot-agents-source-XXXXXX") path=$(mktemp -d "${RUNNER_TEMP}/bot-agents-source-XXXXXX")
mkdir "${path}/src" mkdir "${path}/src"
cp -a "${ACTION_PATH}"/src/*.ts "${path}/src/" cp -a "${ACTION_PATH}"/src/*.ts "${path}/src/"
cp -a "${ACTION_PATH}/profiles.yml" "${ACTION_PATH}/prompt.md" "${path}/" # package.json makes Node load the TypeScript sources as ES modules.
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
@@ -0,0 +1,119 @@
// 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
@@ -0,0 +1,174 @@
// 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
@@ -0,0 +1,46 @@
// 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
@@ -0,0 +1,30 @@
// 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) };
}
+178
View File
@@ -0,0 +1,178 @@
import { expect, test } from "vite-plus/test";
import { mkdir, readFile, rm, stat, symlink, writeFile } from "node:fs/promises";
import {
codexAuthentication,
isClaudeAuthFailure,
isCodexAuthFailure,
persistCodexAuth,
retryInvalidToken,
stageCodexAuth,
} from "./auth.ts";
import { currentGid, currentUid, IS_WINDOWS, makeTempDir } from "./testing.ts";
test("retries once after an invalid refresh token", async () => {
let attempts = 0;
let relogins = 0;
const result = await retryInvalidToken(
() =>
Promise.resolve({
status: { success: false },
error: attempts++ === 0 ? "ERROR: invalid_refresh_token" : "still failed",
}),
() => {
relogins++;
return Promise.resolve();
},
);
expect({ attempts, relogins, error: result.error }).toStrictEqual({
attempts: 2,
relogins: 1,
error: "still failed",
});
});
test("does not retry an unrelated failure", async () => {
let attempts = 0;
let relogins = 0;
await retryInvalidToken(
() => {
attempts++;
return Promise.resolve({
status: { success: false },
error: "rate limited",
});
},
() => {
relogins++;
return Promise.resolve();
},
);
expect({ attempts, relogins }).toStrictEqual({ attempts: 1, relogins: 0 });
});
// Verbatim stderr lines from the revoked-login job on lead/memona-home#36.
const REVOKED_CODEX = [
`2026-10-12T02:28:04.702126Z ERROR codex_models_manager::manager: failed to refresh available models: unexpected status 401 Unauthorized: Encountered invalidated oauth token for user, failing request, url: https://chatgpt.com/backend-api/codex/models?client_version=0.162.1, cf-ray: a492ab48fdefea25-ICN, request id: req_4caa104fe9184a63bb8877f42817172d, auth error: 401, auth error code: token_revoked`,
`2026-10-12T02:28:05.082447Z ERROR codex_login::auth::manager: Failed to refresh token status=401 Unauthorized detail=TokenErrorDetail { error_code: Some("refresh_token_invalidated"), error_message: Some("Your session has ended. Please log in again."), .. }`,
`2026-10-12T02:28:05.451678Z ERROR rmcp::transport::worker: worker quit with fatal: Transport channel closed, when UnexpectedServerResponse("HTTP 401: {\\n \\"error\\": {\\n \\"message\\": \\"Encountered invalidated oauth token for user, failing request\\",\\n \\"type\\": null,\\n \\"code\\": \\"token_revoked\\",\\n \\"param\\": null\\n },\\n \\"status\\": 401\\n}")`,
"ERROR: workspace routing discovery unauthorized (401)",
];
test("recognizes each Codex sign-in failure line", () => {
for (const line of [
...REVOKED_CODEX,
`2026-10-12T02:28:05Z ERROR codex_login::auth::manager: Failed to refresh token status=401 Unauthorized detail=TokenErrorDetail { error_code: Some("refresh_token_expired"), .. }`,
`ERROR: Your access token could not be refreshed because your refresh token was already used. Please log out and sign in again. (refresh_token_reused)`,
"ERROR: unexpected status 401 Unauthorized: Incorrect API key provided: sk-inval*d123., auth error: 401, auth error code: invalid_api_key",
]) {
expect(isCodexAuthFailure(`exec\n${line}\n`), line).toBe(true);
}
});
test("ignores auth words outside Codex error lines", () => {
expect(
isCodexAuthFailure(
"exec\ncurl returned 401 Unauthorized\ntoken_revoked in fixture\nERROR: tests failed\n",
),
).toBe(false);
});
test("re-logs in after the job 9837 revoked-session stderr", async () => {
let relogins = 0;
let attempts = 0;
const result = await retryInvalidToken(
() =>
Promise.resolve({
status: { success: attempts++ > 0 },
error: attempts === 1 ? REVOKED_CODEX.join("\n") : "",
}),
() => {
relogins++;
return Promise.resolve();
},
);
expect({ relogins, success: result.status.success }).toStrictEqual({
relogins: 1,
success: true,
});
});
test("recognizes a rejected Claude OAuth token", () => {
expect(
isClaudeAuthFailure({
api_error_status: 401,
result: "Failed to authenticate. API Error: 401 OAuth access token is invalid.",
}),
).toBe(true);
expect(
isClaudeAuthFailure({
result: "OAuth token has expired. Please obtain a new token or refresh your existing token.",
}),
).toBe(true);
expect(isClaudeAuthFailure({ api_error_status: 429, result: "API Error: 429" })).toBe(false);
});
test("selects API-key login only for a configured Codex token", () => {
expect(codexAuthentication("selected-key")).toStrictEqual({
kind: "api-key",
token: "selected-key",
});
expect(codexAuthentication()).toStrictEqual({ kind: "persistent" });
});
const AUTH_TOKENS = {
access_token: "access",
refresh_token: "refresh",
id_token: "id",
account_id: "account",
};
const AUTH = JSON.stringify({ auth_mode: "chatgpt", tokens: AUTH_TOKENS });
test.skipIf(IS_WINDOWS)("stages and persists only a regular private Codex login", async () => {
const root = await makeTempDir();
try {
const canonical = `${root}/persistent/auth.json`;
await mkdir(`${root}/persistent`);
await writeFile(canonical, AUTH, { mode: 0o600 });
const home = `${root}/profile`;
await mkdir(home);
const staged = await stageCodexAuth(canonical, home, currentUid(), currentGid());
expect(await readFile(staged, "utf8")).toStrictEqual(AUTH);
expect((await stat(staged)).mode & 0o777).toBe(0o600);
const refreshed = JSON.stringify({
auth_mode: "chatgpt",
tokens: { ...AUTH_TOKENS, access_token: "refreshed" },
});
await writeFile(staged, refreshed);
await persistCodexAuth(staged, canonical);
expect(await readFile(canonical, "utf8")).toStrictEqual(refreshed);
expect((await stat(canonical)).mode & 0o777).toBe(0o600);
} finally {
await rm(root, { recursive: true });
}
});
test.skipIf(IS_WINDOWS)("rejects a symlink or malformed Codex login", async () => {
const root = await makeTempDir();
try {
const canonical = `${root}/auth.json`;
const malformed = `${root}/malformed.json`;
const linked = `${root}/linked.json`;
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(malformed, canonical)).rejects.toThrow(
"invalid Codex login",
);
} finally {
await rm(root, { recursive: true });
}
});
+75 -48
View File
@@ -1,19 +1,30 @@
import { dirname, join } from "jsr:@std/path@1"; import { randomUUID } from "node:crypto";
import {
chmod,
chown,
lstat,
mkdir,
open,
readFile,
rename,
rm,
writeFile,
} from "node:fs/promises";
import { dirname, join } from "node:path";
export type CodexAuthentication = import { isNotFound } from "./errors.ts";
| { kind: "api-key"; token: string } import { isObject } from "./json.ts";
| { kind: "persistent" };
export type CodexAuthentication = { kind: "api-key"; token: string } | { kind: "persistent" };
export function codexAuthentication(token?: string): CodexAuthentication { export function codexAuthentication(token?: string): CodexAuthentication {
return token === undefined return token === undefined ? { kind: "persistent" } : { kind: "api-key", token };
? { 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
// on its own ERROR lines; tool output and the echoed prompt must not count. // on its own ERROR lines; tool output and the echoed prompt must not count.
const CODEX_AUTH_FAILURE = const CODEX_AUTH_FAILURE =
/^(?:\S+\s+)?ERROR\b.*(?:\b401\b|unauthorized|invalid_refresh_token|refresh_token_(?:expired|reused|invalidated)|token_(?:revoked|expired)|invalid_api_key|(?:log|sign) in again)/im; /^(?:\S+\s+)?ERROR\b.*(?:\b401\b|unauthorized|invalid_refresh_token|refresh_token_(?:expired|reused|invalidated)|token_(?:revoked|expired)|invalid_api_key|(?:log|sign) in again)/imu;
export function isCodexAuthFailure(error: string): boolean { export function isCodexAuthFailure(error: string): boolean {
return CODEX_AUTH_FAILURE.test(error); return CODEX_AUTH_FAILURE.test(error);
@@ -22,17 +33,25 @@ export function isCodexAuthFailure(error: string): boolean {
// Claude Code reports a rejected CLAUDE_CODE_OAUTH_TOKEN as an is_error // Claude Code reports a rejected CLAUDE_CODE_OAUTH_TOKEN as an is_error
// 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.
export function isClaudeAuthFailure( // The fields of Claude Code's stream-json `result` event that tell a sign-in
result: { api_error_status?: number | null; result?: string }, // failure apart, under their own names.
): boolean { export type ClaudeFailureFields = Partial<Record<"api_error_status", number | null>> & {
return result.api_error_status === 401 || result?: string;
/failed to authenticate|oauth (?:access )?token (?:is invalid|has expired)|invalid api key|authentication_error|please run \/login|not logged in/i };
.test(result.result ?? "");
export function isClaudeAuthFailure(result: ClaudeFailureFields): boolean {
return (
result.api_error_status === 401 ||
/failed to authenticate|oauth (?:access )?token (?:is invalid|has expired)|invalid api key|authentication_error|please run \/login|not logged in/iu.test(
result.result ?? "",
)
);
} }
export async function retryInvalidToken< export async function retryInvalidToken<T extends { status: { success: boolean }; error: string }>(
T extends { status: { success: boolean }; error: string }, attempt: () => Promise<T>,
>(attempt: () => Promise<T>, relogin: () => Promise<void>): Promise<T> { relogin: () => Promise<void>,
): Promise<T> {
let result = await attempt(); let result = await attempt();
if (!result.status.success && isCodexAuthFailure(result.error)) { if (!result.status.success && isCodexAuthFailure(result.error)) {
await relogin(); await relogin();
@@ -41,30 +60,35 @@ export async function retryInvalidToken<
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 Deno.lstat(path); const stat = await lstat(path);
if (!stat.isFile || stat.isSymlink) { if (!stat.isFile() || stat.isSymbolicLink()) {
throw new Error("Codex login must be a regular file"); throw new Error("Codex login must be a regular file");
} }
if ((stat.mode! & 0o077) !== 0) { if ((stat.mode & 0o077) !== 0) {
throw new Error("Codex login must be private"); throw new Error("Codex login must be private");
} }
const text = await Deno.readTextFile(path); const text = await readFile(path, "utf8");
let value: unknown; let value: unknown;
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 record = value as Record<string, unknown> | null;
const tokens = record?.tokens as Record<string, unknown> | null;
if ( if (
record === null || typeof record !== "object" || !isObject(value) ||
typeof record.auth_mode !== "string" || record.auth_mode === "" || typeof value.auth_mode !== "string" ||
tokens === null || typeof tokens !== "object" || value.auth_mode === "" ||
!["access_token", "refresh_token", "id_token", "account_id"].every( !hasCodexTokens(value.tokens)
(name) => typeof tokens[name] === "string" && tokens[name] !== "",
)
) { ) {
throw new Error("invalid Codex login"); throw new Error("invalid Codex login");
} }
@@ -78,36 +102,39 @@ export async function stageCodexAuth(
gid: number, gid: number,
): Promise<string> { ): Promise<string> {
const codexHome = join(profileHome, ".codex"); const codexHome = join(profileHome, ".codex");
await Deno.mkdir(codexHome, { recursive: true, mode: 0o700 }); await mkdir(codexHome, { recursive: true, mode: 0o700 });
await Deno.chmod(codexHome, 0o700); await chmod(codexHome, 0o700);
await Deno.chown(codexHome, uid, gid); await chown(codexHome, uid, gid);
const stagedPath = join(codexHome, "auth.json"); const stagedPath = join(codexHome, "auth.json");
try { try {
await Deno.writeTextFile(stagedPath, await readCodexAuth(canonicalPath), { await writeFile(stagedPath, await readCodexAuth(canonicalPath), { mode: 0o600 });
mode: 0o600, await chmod(stagedPath, 0o600);
}); await chown(stagedPath, uid, gid);
await Deno.chmod(stagedPath, 0o600);
await Deno.chown(stagedPath, uid, gid);
} catch (error) { } catch (error) {
if (!(error instanceof Deno.errors.NotFound)) throw error; if (!isNotFound(error)) throw error;
} }
return stagedPath; return stagedPath;
} }
export async function persistCodexAuth( // Creates an empty private file under a fresh name, as Deno.makeTempFile did.
stagedPath: string, async function makeTempFile(directory: string): Promise<string> {
canonicalPath: string, const path = join(directory, `.tmp-${randomUUID()}`);
): Promise<void> { const handle = await open(path, "wx", 0o600);
await handle.close();
return path;
}
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 = dirname(canonicalPath);
await Deno.mkdir(directory, { recursive: true, mode: 0o700 }); await mkdir(directory, { recursive: true, mode: 0o700 });
const temporary = await Deno.makeTempFile({ dir: directory }); const temporary = await makeTempFile(directory);
try { try {
await Deno.writeTextFile(temporary, text, { mode: 0o600 }); await writeFile(temporary, text, { mode: 0o600 });
await Deno.chmod(temporary, 0o600); await chmod(temporary, 0o600);
await Deno.rename(temporary, canonicalPath); await rename(temporary, canonicalPath);
} catch (error) { } catch (error) {
await Deno.remove(temporary).catch(() => undefined); await rm(temporary).catch(() => {});
throw error; throw error;
} }
} }
-203
View File
@@ -1,203 +0,0 @@
import { assertEquals, assertRejects } from "jsr:@std/assert@1";
import {
codexAuthentication,
isClaudeAuthFailure,
isCodexAuthFailure,
persistCodexAuth,
retryInvalidToken,
stageCodexAuth,
} from "./auth.ts";
Deno.test("retries once after an invalid refresh token", async () => {
let attempts = 0;
let relogins = 0;
const result = await retryInvalidToken(
() =>
Promise.resolve({
status: { success: false },
error: attempts++ === 0
? "ERROR: invalid_refresh_token"
: "still failed",
}),
() => {
relogins++;
return Promise.resolve();
},
);
assertEquals({ attempts, relogins, error: result.error }, {
attempts: 2,
relogins: 1,
error: "still failed",
});
});
Deno.test("does not retry an unrelated failure", async () => {
let attempts = 0;
let relogins = 0;
await retryInvalidToken(
() => {
attempts++;
return Promise.resolve({
status: { success: false },
error: "rate limited",
});
},
() => {
relogins++;
return Promise.resolve();
},
);
assertEquals({ attempts, relogins }, { attempts: 1, relogins: 0 });
});
// Verbatim stderr lines from the revoked-login job on lead/memona-home#36.
const REVOKED_CODEX = [
`2026-10-12T02:28:04.702126Z ERROR codex_models_manager::manager: failed to refresh available models: unexpected status 401 Unauthorized: Encountered invalidated oauth token for user, failing request, url: https://chatgpt.com/backend-api/codex/models?client_version=0.162.1, cf-ray: a492ab48fdefea25-ICN, request id: req_4caa104fe9184a63bb8877f42817172d, auth error: 401, auth error code: token_revoked`,
`2026-10-12T02:28:05.082447Z ERROR codex_login::auth::manager: Failed to refresh token status=401 Unauthorized detail=TokenErrorDetail { error_code: Some("refresh_token_invalidated"), error_message: Some("Your session has ended. Please log in again."), .. }`,
`2026-10-12T02:28:05.451678Z ERROR rmcp::transport::worker: worker quit with fatal: Transport channel closed, when UnexpectedServerResponse("HTTP 401: {\\n \\"error\\": {\\n \\"message\\": \\"Encountered invalidated oauth token for user, failing request\\",\\n \\"type\\": null,\\n \\"code\\": \\"token_revoked\\",\\n \\"param\\": null\\n },\\n \\"status\\": 401\\n}")`,
"ERROR: workspace routing discovery unauthorized (401)",
];
Deno.test("recognizes each Codex sign-in failure line", () => {
for (
const line of [
...REVOKED_CODEX,
`2026-10-12T02:28:05Z ERROR codex_login::auth::manager: Failed to refresh token status=401 Unauthorized detail=TokenErrorDetail { error_code: Some("refresh_token_expired"), .. }`,
`ERROR: Your access token could not be refreshed because your refresh token was already used. Please log out and sign in again. (refresh_token_reused)`,
"ERROR: unexpected status 401 Unauthorized: Incorrect API key provided: sk-inval*d123., auth error: 401, auth error code: invalid_api_key",
]
) {
assertEquals(isCodexAuthFailure(`exec\n${line}\n`), true, line);
}
});
Deno.test("ignores auth words outside Codex error lines", () => {
assertEquals(
isCodexAuthFailure(
"exec\ncurl returned 401 Unauthorized\ntoken_revoked in fixture\n" +
"ERROR: tests failed\n",
),
false,
);
});
Deno.test("re-logs in after the job 9837 revoked-session stderr", async () => {
let relogins = 0;
let attempts = 0;
const result = await retryInvalidToken(
() =>
Promise.resolve({
status: { success: attempts++ > 0 },
error: attempts === 1 ? REVOKED_CODEX.join("\n") : "",
}),
() => {
relogins++;
return Promise.resolve();
},
);
assertEquals({ relogins, success: result.status.success }, {
relogins: 1,
success: true,
});
});
Deno.test("recognizes a rejected Claude OAuth token", () => {
assertEquals(
isClaudeAuthFailure({
api_error_status: 401,
result:
"Failed to authenticate. API Error: 401 OAuth access token is invalid.",
}),
true,
);
assertEquals(
isClaudeAuthFailure({
result:
"OAuth token has expired. Please obtain a new token or refresh your existing token.",
}),
true,
);
assertEquals(
isClaudeAuthFailure({ api_error_status: 429, result: "API Error: 429" }),
false,
);
});
Deno.test("selects API-key login only for a configured Codex token", () => {
assertEquals(codexAuthentication("selected-key"), {
kind: "api-key",
token: "selected-key",
});
assertEquals(codexAuthentication(undefined), { kind: "persistent" });
});
const AUTH = JSON.stringify({
auth_mode: "chatgpt",
tokens: {
access_token: "access",
refresh_token: "refresh",
id_token: "id",
account_id: "account",
},
});
Deno.test("stages and persists only a regular private Codex login", async () => {
if (Deno.build.os === "windows") return;
const root = await Deno.makeTempDir();
try {
const canonical = `${root}/persistent/auth.json`;
await Deno.mkdir(`${root}/persistent`);
await Deno.writeTextFile(canonical, AUTH, { mode: 0o600 });
const home = `${root}/profile`;
await Deno.mkdir(home);
const staged = await stageCodexAuth(
canonical,
home,
Deno.uid()!,
Deno.gid()!,
);
assertEquals(await Deno.readTextFile(staged), AUTH);
assertEquals((await Deno.stat(staged)).mode! & 0o777, 0o600);
const refreshed = JSON.stringify({
...JSON.parse(AUTH),
tokens: { ...JSON.parse(AUTH).tokens, access_token: "refreshed" },
});
await Deno.writeTextFile(staged, refreshed);
await persistCodexAuth(staged, canonical);
assertEquals(await Deno.readTextFile(canonical), refreshed);
assertEquals((await Deno.stat(canonical)).mode! & 0o777, 0o600);
} finally {
await Deno.remove(root, { recursive: true });
}
});
Deno.test("rejects a symlink or malformed Codex login", async () => {
if (Deno.build.os === "windows") return;
const root = await Deno.makeTempDir();
try {
const canonical = `${root}/auth.json`;
const malformed = `${root}/malformed.json`;
const linked = `${root}/linked.json`;
await Deno.writeTextFile(canonical, AUTH, { mode: 0o600 });
await Deno.writeTextFile(malformed, '{"tokens":{}}', { mode: 0o600 });
await Deno.symlink(canonical, linked);
await assertRejects(
() => persistCodexAuth(linked, canonical),
Error,
"regular file",
);
await assertRejects(
() => persistCodexAuth(malformed, canonical),
Error,
"invalid Codex login",
);
} finally {
await Deno.remove(root, { recursive: true });
}
});
+160
View File
@@ -0,0 +1,160 @@
// 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);
}
+68
View File
@@ -0,0 +1,68 @@
import { expect, test } from "vite-plus/test";
import { decryptCredential, encryptCredential, parseCredentials } from "./credentials.ts";
const MASTER_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8";
const OTHER_KEY = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE";
const NONCE = Uint8Array.from({ length: 12 }, (_, index) => index + 32);
const ENCRYPTED = "v1:ICEiIyQlJicoKSor:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA";
test("decrypts a known AES-256-GCM credential", async () => {
expect(await decryptCredential(ENCRYPTED, MASTER_KEY)).toBe("secret-token");
});
test("encrypts a credential with a fresh supplied nonce", async () => {
expect(await encryptCredential("secret-token", MASTER_KEY, NONCE)).toStrictEqual(ENCRYPTED);
});
test("rejects a credential encrypted by another master key", async () => {
await expect(() => decryptCredential(ENCRYPTED, OTHER_KEY)).rejects.toThrow();
});
test("rejects a credential with a nonstandard nonce", async () => {
await expect(() =>
decryptCredential("v1:AA:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA", MASTER_KEY),
).rejects.toThrow("nonce must be 12 bytes");
});
test("decrypts the central reviewer token and ordered profiles", async () => {
expect(
await parseCredentials(
JSON.stringify({
"reviewer-token": ENCRYPTED,
profiles: [
{ type: "codex", model: "gpt-custom" },
{ type: "claude", token: ENCRYPTED },
{ type: "opencode", token: ENCRYPTED },
],
}),
MASTER_KEY,
),
).toStrictEqual({
reviewerToken: "secret-token",
profiles: [
{ type: "codex", model: "gpt-custom" },
{
type: "claude",
model: "claude-sonnet-5",
token: "secret-token",
},
{
type: "opencode",
model: "openrouter/qwen/qwen3.8-27b",
token: "secret-token",
},
],
});
});
test("rejects malformed central credentials", async () => {
await expect(() => parseCredentials('{"profiles": []}', MASTER_KEY)).rejects.toThrow(
"reviewer-token",
);
await expect(() =>
parseCredentials(
JSON.stringify({ "reviewer-token": ENCRYPTED, profiles: [{ type: "claude" }] }),
MASTER_KEY,
),
).rejects.toThrow("profiles[0].token");
});
+62 -86
View File
@@ -1,4 +1,6 @@
import { parse } from "jsr:@std/yaml@1"; import { webcrypto } from "node:crypto";
import { isMapping } from "./json.ts";
import type { BotProfile } from "./profiles.ts"; import type { BotProfile } from "./profiles.ts";
const DEFAULT_MODELS: Record<BotProfile["type"], string> = { const DEFAULT_MODELS: Record<BotProfile["type"], string> = {
@@ -7,54 +9,40 @@ const DEFAULT_MODELS: Record<BotProfile["type"], string> = {
opencode: "openrouter/qwen/qwen3.8-27b", opencode: "openrouter/qwen/qwen3.8-27b",
}; };
export type BotCredentials = { export interface BotCredentials {
reviewerToken: string; reviewerToken: string;
profiles: BotProfile[]; profiles: BotProfile[];
}; }
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.charCodeAt(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.fromCharCode(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 => const buffer = (value: Uint8Array): ArrayBuffer => Uint8Array.from(value).buffer;
Uint8Array.from(value).buffer;
async function importMasterKey( function importMasterKey(encoded: string, usage: webcrypto.KeyUsage): Promise<webcrypto.CryptoKey> {
encoded: string,
usage: KeyUsage,
): Promise<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 await crypto.subtle.importKey("raw", buffer(bytes), "AES-GCM", false, [ return webcrypto.subtle.importKey("raw", buffer(bytes), "AES-GCM", false, [usage]);
usage,
]);
} }
export async function decryptCredential( export async function decryptCredential(encrypted: string, masterKey: string): Promise<string> {
encrypted: string, const [version, encodedNonce = "", encodedCiphertext = "", extra = ""] = encrypted.split(":");
masterKey: string, if (version !== "v1" || encodedNonce === "" || encodedCiphertext === "" || extra !== "") {
): 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 crypto.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"),
buffer(decodeBase64Url(encodedCiphertext)), buffer(decodeBase64Url(encodedCiphertext)),
@@ -65,80 +53,68 @@ export async function decryptCredential(
export async function encryptCredential( export async function encryptCredential(
plaintext: string, plaintext: string,
masterKey: string, masterKey: string,
nonce: Uint8Array = crypto.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 crypto.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"),
new TextEncoder().encode(plaintext), new TextEncoder().encode(plaintext),
); );
return `v1:${encodeBase64Url(nonce)}:${ return `v1:${encodeBase64Url(nonce)}:${encodeBase64Url(new Uint8Array(ciphertext))}`;
encodeBase64Url(new Uint8Array(ciphertext))
}`;
} }
export async function parseCredentials( async function parseProfile(entry: unknown, index: number, masterKey: string): Promise<BotProfile> {
text: string, const at = `profiles[${index}]`;
masterKey: string, if (!isMapping(entry)) {
): Promise<BotCredentials> { throw new TypeError(`${at}: expected a mapping`);
const value: unknown = parse(text);
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error("credentials must be a mapping");
} }
const record = value as Record<string, unknown>; for (const key of Object.keys(entry)) {
for (const key of Object.keys(record)) { if (key !== "type" && key !== "model" && key !== "token") {
throw new Error(`${at}.${key}: unknown field`);
}
}
const { type, model, token } = entry;
if (type !== "claude" && type !== "codex" && type !== "opencode") {
throw new Error(`${at}.type: expected "claude", "codex", or "opencode"`);
}
if (model !== undefined && (typeof model !== "string" || model === "")) {
throw new Error(`${at}.model: expected a model name`);
}
if ((type === "claude" || type === "opencode") && typeof token !== "string") {
throw new Error(`${at}.token: expected an encrypted credential`);
}
if (token !== undefined && typeof token !== "string") {
throw new Error(`${at}.token: expected an encrypted credential`);
}
return {
type,
model: model ?? DEFAULT_MODELS[type],
...(typeof token === "string" && {
token: await decryptCredential(token, masterKey),
}),
};
}
export async function parseCredentials(text: string, masterKey: string): Promise<BotCredentials> {
const value: unknown = JSON.parse(text);
if (!isMapping(value)) {
throw new TypeError("credentials must be a mapping");
}
for (const key of Object.keys(value)) {
if (key !== "reviewer-token" && key !== "profiles") { if (key !== "reviewer-token" && key !== "profiles") {
throw new Error(`${key}: unknown field`); throw new Error(`${key}: unknown field`);
} }
} }
if (typeof record["reviewer-token"] !== "string") { const { "reviewer-token": reviewerToken, profiles } = value;
throw new Error("reviewer-token: expected an encrypted credential"); if (typeof reviewerToken !== "string") {
throw new TypeError("reviewer-token: expected an encrypted credential");
} }
if (!Array.isArray(record.profiles) || record.profiles.length === 0) { if (!Array.isArray(profiles) || profiles.length === 0) {
throw new Error("profiles must contain at least one profile"); throw new Error("profiles must contain at least one profile");
} }
const profiles = await Promise.all( const parsed = await Promise.all(
record.profiles.map(async (entry: unknown, index): Promise<BotProfile> => { profiles.map((entry: unknown, index) => parseProfile(entry, index, masterKey)),
const at = `profiles[${index}]`;
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
throw new Error(`${at}: expected a mapping`);
}
const profile = entry as Record<string, unknown>;
for (const key of Object.keys(profile)) {
if (key !== "type" && key !== "model" && key !== "token") {
throw new Error(`${at}.${key}: unknown field`);
}
}
const { type, model, token } = profile;
if (type !== "claude" && type !== "codex" && type !== "opencode") {
throw new Error(
`${at}.type: expected \"claude\", \"codex\", or \"opencode\"`,
);
}
if (model !== undefined && (typeof model !== "string" || model === "")) {
throw new Error(`${at}.model: expected a model name`);
}
if (
(type === "claude" || type === "opencode") &&
typeof token !== "string"
) {
throw new Error(`${at}.token: expected an encrypted credential`);
}
if (token !== undefined && typeof token !== "string") {
throw new Error(`${at}.token: expected an encrypted credential`);
}
return {
type,
model: model ?? DEFAULT_MODELS[type],
...(typeof token === "string" && {
token: await decryptCredential(token, masterKey),
}),
};
}),
); );
return { return { reviewerToken: await decryptCredential(reviewerToken, masterKey), profiles: parsed };
reviewerToken: await decryptCredential(record["reviewer-token"], masterKey),
profiles,
};
} }
-92
View File
@@ -1,92 +0,0 @@
import { assertEquals, assertRejects } from "jsr:@std/assert@1";
import {
decryptCredential,
encryptCredential,
parseCredentials,
} from "./credentials.ts";
const MASTER_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8";
const OTHER_KEY = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE";
const NONCE = Uint8Array.from({ length: 12 }, (_, index) => index + 32);
const ENCRYPTED = "v1:ICEiIyQlJicoKSor:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA";
Deno.test("decrypts a known AES-256-GCM credential", async () => {
assertEquals(
await decryptCredential(ENCRYPTED, MASTER_KEY),
"secret-token",
);
});
Deno.test("encrypts a credential with a fresh supplied nonce", async () => {
assertEquals(
await encryptCredential("secret-token", MASTER_KEY, NONCE),
ENCRYPTED,
);
});
Deno.test("rejects a credential encrypted by another master key", async () => {
await assertRejects(() => decryptCredential(ENCRYPTED, OTHER_KEY));
});
Deno.test("rejects a credential with a nonstandard nonce", async () => {
await assertRejects(
() =>
decryptCredential(
"v1:AA:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA",
MASTER_KEY,
),
Error,
"nonce must be 12 bytes",
);
});
Deno.test("decrypts the central reviewer token and ordered profiles", async () => {
assertEquals(
await parseCredentials(
`
reviewer-token: ${ENCRYPTED}
profiles:
- type: codex
model: gpt-custom
- type: claude
token: ${ENCRYPTED}
- type: opencode
token: ${ENCRYPTED}
`,
MASTER_KEY,
),
{
reviewerToken: "secret-token",
profiles: [
{ type: "codex", model: "gpt-custom" },
{
type: "claude",
model: "claude-sonnet-5",
token: "secret-token",
},
{
type: "opencode",
model: "openrouter/qwen/qwen3.8-27b",
token: "secret-token",
},
],
},
);
});
Deno.test("rejects malformed central credentials", async () => {
await assertRejects(
() => parseCredentials("profiles: []", MASTER_KEY),
Error,
"reviewer-token",
);
await assertRejects(
() =>
parseCredentials(
`reviewer-token: ${ENCRYPTED}\nprofiles:\n - type: claude`,
MASTER_KEY,
),
Error,
"profiles[0].token",
);
});
+8
View File
@@ -0,0 +1,8 @@
// Node reports a missing file as ENOENT and a missing process as ESRCH, where
// Deno threw `Deno.errors.NotFound` for both.
export function isNotFound(error: unknown): boolean {
if (!(error instanceof Error) || !("code" in error)) {
return false;
}
return error.code === "ENOENT" || error.code === "ESRCH";
}
+112
View File
@@ -0,0 +1,112 @@
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);
});
+66 -45
View File
@@ -1,68 +1,89 @@
import { lstat, readdir, readFile } from "node:fs/promises";
import process from "node:process";
import { setTimeout as sleep } from "node:timers/promises";
import { output, spawn } from "./command.ts";
import type { CommandChild, CommandOptions, CommandOutput } from "./command.ts";
import { isNotFound } from "./errors.ts";
import type { RuntimeProfile } from "./runtime.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( export function agentCommand(
profile: RuntimeProfile, profile: RuntimeProfile,
command: string, command: string,
args: string[], args: string[],
options: Omit<Deno.CommandOptions, "args" | "uid" | "gid"> = {}, options: AgentCommandOptions = {},
): Deno.Command { ): AgentCommand {
return new Deno.Command(command, { const full: CommandOptions = { ...options, args, uid: profile.uid, gid: profile.gid };
...options, return {
args, output: () => output(command, full),
uid: profile.uid, spawn: () => spawn(command, full),
gid: profile.gid, };
});
} }
export function isRunnableProfileProcess( export function isRunnableProfileProcess(status: string, uid: number): boolean {
status: string, const owner = /^Uid:\s+(\d+)/mu.exec(status)?.[1];
uid: number, const state = /^State:\s+(\w)/mu.exec(status)?.[1];
): boolean {
const owner = status.match(/^Uid:\s+(\d+)/m)?.[1];
const state = status.match(/^State:\s+(\w)/m)?.[1];
return owner !== undefined && Number(owner) === uid && state !== "Z"; return owner !== undefined && Number(owner) === uid && state !== "Z";
} }
async function profileProcessIds(uid: number): Promise<number[]> { async function profileProcessIds(uid: number): Promise<number[]> {
const ids: number[] = []; const entries = await readdir("/proc", { withFileTypes: true });
for await (const entry of Deno.readDir("/proc")) { const pids = entries
if (!entry.isDirectory || !/^\d+$/.test(entry.name)) continue; .filter((entry) => entry.isDirectory() && /^\d+$/u.test(entry.name))
const status = await Deno.readTextFile(`/proc/${entry.name}/status`).catch( .map((entry) => entry.name);
() => null, const statuses = await Promise.all(
); pids.map((pid) => readFile(`/proc/${pid}/status`, "utf8").catch(() => null)),
if (status !== null && isRunnableProfileProcess(status, uid)) { );
ids.push(Number(entry.name)); return pids
} .filter((_pid, index) => {
} const status = statuses[index];
return ids; return status !== null && status !== undefined && isRunnableProfileProcess(status, uid);
})
.map(Number);
} }
export async function killProfileProcesses(uid: number): Promise<void> { function killProcess(pid: number): void {
for (let attempt = 0; attempt < 100; attempt++) { try {
const ids = await profileProcessIds(uid); process.kill(pid, "SIGKILL");
if (ids.length === 0) return; } catch (error) {
for (const pid of ids) { if (!isNotFound(error)) {
try { throw error;
Deno.kill(pid, "SIGKILL");
} catch (error) {
if (!(error instanceof Deno.errors.NotFound)) throw error;
}
} }
await new Promise((resolve) => setTimeout(resolve, 10));
} }
throw new Error(`profile UID ${uid} still owns processes after cleanup`);
} }
export async function readProfileFile( // Kills the UID's processes every 10 ms until none is left, for up to 100 rounds.
profile: RuntimeProfile, export async function killProfileProcesses(uid: number, attempt = 0): Promise<void> {
path: string, if (attempt === 100) {
): Promise<string> { throw new Error(`profile UID ${uid} still owns processes after cleanup`);
const stat = await Deno.lstat(path); }
const ids = await profileProcessIds(uid);
if (ids.length === 0) {
return;
}
for (const pid of ids) {
killProcess(pid);
}
await sleep(10);
await killProfileProcesses(uid, attempt + 1);
}
export async function readProfileFile(profile: RuntimeProfile, path: string): Promise<string> {
const stat = await lstat(path);
if ( if (
!stat.isFile || stat.isSymlink || stat.uid !== profile.uid || !stat.isFile() ||
(stat.mode! & 0o077) !== 0 stat.isSymbolicLink() ||
stat.uid !== profile.uid ||
(stat.mode & 0o077) !== 0
) { ) {
throw new Error(`${path}: expected a profile-owned regular private file`); throw new Error(`${path}: expected a profile-owned regular private file`);
} }
return await Deno.readTextFile(path); return readFile(path, "utf8");
} }
-134
View File
@@ -1,134 +0,0 @@
import { assertEquals, assertRejects } from "jsr:@std/assert@1";
import {
agentCommand,
isRunnableProfileProcess,
killProfileProcesses,
readProfileFile,
} from "./isolation.ts";
import type { RuntimeProfile } from "./runtime.ts";
const profile = (uid: number, home: string): RuntimeProfile => ({
index: uid - 20_000,
type: "claude",
model: "test",
uid,
gid: uid,
home,
tokenFile: null,
});
Deno.test("zombie process entries are already quiescent", () => {
const status = "State:\tZ (zombie)\nUid:\t20010\t20010\t20010\t20010\n";
assertEquals(isRunnableProfileProcess(status, 20_010), false);
});
Deno.test({
name: "refuses to read a profile result symlink as root",
permissions: { read: true, write: true, sys: ["uid"] },
async fn() {
if (Deno.build.os === "windows") return;
const root = await Deno.makeTempDir();
try {
const secret = `${root}/reviewer-token`;
const result = `${root}/result.md`;
await Deno.writeTextFile(secret, "reviewer-secret", { mode: 0o600 });
await Deno.symlink(secret, result);
await assertRejects(
() => readProfileFile(profile(Deno.uid()!, root), result),
Error,
"regular private file",
);
} finally {
await Deno.remove(root, { recursive: true });
}
},
});
Deno.test({
name: "a profile process can read only its own private files",
permissions: { read: true, write: true, run: true, sys: ["uid"] },
async fn() {
if (Deno.uid() !== 0) return;
const root = await Deno.makeTempDir();
await Deno.chmod(root, 0o711);
try {
const homeA = `${root}/a`;
const homeB = `${root}/b`;
await Deno.mkdir(homeA, { mode: 0o700 });
await Deno.mkdir(homeB, { mode: 0o700 });
await Deno.chown(homeA, 20_000, 20_000);
await Deno.chown(homeB, 20_001, 20_001);
await Deno.writeTextFile(`${homeA}/token`, "own", { mode: 0o600 });
await Deno.chown(`${homeA}/token`, 20_000, 20_000);
await Deno.writeTextFile(`${homeB}/token`, "sibling", { mode: 0o600 });
await Deno.chown(`${homeB}/token`, 20_001, 20_001);
await Deno.writeTextFile(`${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();
assertEquals(new TextDecoder().decode(own.stdout), "own");
assertEquals(own.success, true);
assertEquals(sibling.success, false);
assertEquals(reviewer.success, false);
} finally {
await Deno.remove(root, { recursive: true });
}
},
});
Deno.test({
name: "cleanup leaves no runnable daemonized process",
permissions: "inherit",
async fn() {
if (Deno.uid() !== 0) return;
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());
assertEquals(Number.isInteger(pid), true);
await killProfileProcesses(selected.uid);
const status = await Deno.readTextFile(`/proc/${pid}/status`).catch(
(error) => {
if (error instanceof Deno.errors.NotFound) return null;
throw error;
},
);
assertEquals(
status === null || !isRunnableProfileProcess(status, selected.uid),
true,
);
},
});
Deno.test({
name: "the agent process cannot read its coordinator environment",
permissions: { run: true, sys: ["uid"] },
async fn() {
if (Deno.uid() !== 0) return;
const selected = profile(20_020, "/tmp");
const result = await agentCommand(selected, "/usr/bin/cat", [
`/proc/${Deno.pid}/environ`,
], {
cwd: "/",
env: { PARENT_SECRET: "must-not-cross-boundary" },
clearEnv: true,
stderr: "piped",
}).output();
assertEquals(result.success, false);
},
});
+12
View File
@@ -0,0 +1,12 @@
// 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);
}
+103
View File
@@ -0,0 +1,103 @@
import { assert, expect, test } from "vite-plus/test";
import {
collectOpenCodeOutput,
openCodeArgs,
openCodeError,
parseOpenCodeEvents,
} from "./opencode.ts";
import { AgentAuthError } from "./profiles.ts";
test("builds a non-interactive auto-approved OpenCode command", () => {
expect(openCodeArgs("openrouter/qwen/qwen3.8-27b", "Review this")).toStrictEqual([
"run",
"--format",
"json",
"--auto",
"--model",
"openrouter/qwen/qwen3.8-27b",
"Review this",
]);
});
test("returns the final visible OpenCode text event", () => {
expect(
parseOpenCodeEvents(
[
JSON.stringify({ type: "step_start", part: { type: "step-start" } }),
JSON.stringify({ type: "text", part: { type: "text", text: "draft" } }),
JSON.stringify({ type: "text", part: { type: "text", text: "done" } }),
JSON.stringify({ type: "step_finish", part: { type: "step-finish" } }),
].join("\n"),
),
).toBe("done");
});
test("rejects a successful OpenCode run without visible text", async () => {
await expect(async () => {
parseOpenCodeEvents('{"type":"step_start"}');
await Promise.resolve();
}).rejects.toThrow("no text output");
});
const encode = (value: string): Uint8Array => new TextEncoder().encode(value);
test("streams OpenCode output while collecting it", async () => {
const stdout = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encode("first\n"));
controller.enqueue(encode("second\n"));
controller.close();
},
});
const stderr = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encode("warning\n"));
controller.close();
},
});
const shown: string[] = [];
const result = await collectOpenCodeOutput(
stdout,
stderr,
(chunk: Uint8Array) => {
shown.push(`out:${new TextDecoder().decode(chunk)}`);
},
(chunk: Uint8Array) => {
shown.push(`err:${new TextDecoder().decode(chunk)}`);
},
);
expect(result).toStrictEqual({ stdout: "first\nsecond\n", stderr: "warning\n" });
expect(shown).toStrictEqual(["out:first\n", "err:warning\n", "out:second\n"]);
});
const openRouterError = (statusCode: number, message: string): string =>
JSON.stringify({
type: "error",
sessionID: "ses_test",
error: {
name: "APIError",
data: {
message,
statusCode,
isRetryable: false,
responseBody: JSON.stringify({ error: { message, code: statusCode } }),
},
},
});
test("reports a rejected OpenRouter key as a sign-in failure", () => {
const error = openCodeError(openRouterError(401, "User not found."));
assert.instanceOf(error, AgentAuthError);
expect(error.message).toBe("opencode sign-in failed");
assert.instanceOf(error.cause, Error);
expect(error.cause.message).toBe("opencode APIError (API error 401): User not found.");
});
test("surfaces an OpenCode error event instead of empty output", () => {
expect(() => parseOpenCodeEvents(openRouterError(429, "Rate limit exceeded"))).toThrow(
"API error 429",
);
expect(openCodeError('{"type":"text","part":{"text":"ok"}}')).toBe(undefined);
});
+21 -31
View File
@@ -1,29 +1,19 @@
import { isObject } from "./json.ts";
import { AgentAuthError } from "./profiles.ts"; import { AgentAuthError } from "./profiles.ts";
export function openCodeArgs(model: string, prompt: string): string[] { export function openCodeArgs(model: string, prompt: string): string[] {
return [ return ["run", "--format", "json", "--auto", "--model", model, prompt];
"run",
"--format",
"json",
"--auto",
"--model",
model,
prompt,
];
} }
type ChunkWriter = (chunk: Uint8Array) => void | Promise<void>; type ChunkWriter = (chunk: Uint8Array) => void | Promise<void>;
export async function collectOpenCodeOutput( export async function collectOpenCodeOutput(
stdout: ReadableStream<Uint8Array>, stdout: AsyncIterable<Uint8Array>,
stderr: ReadableStream<Uint8Array>, stderr: AsyncIterable<Uint8Array>,
writeStdout: ChunkWriter, writeStdout: ChunkWriter,
writeStderr: ChunkWriter, writeStderr: ChunkWriter,
): Promise<{ stdout: string; stderr: string }> { ): Promise<{ stdout: string; stderr: string }> {
const collect = async ( const collect = async (stream: AsyncIterable<Uint8Array>, write: ChunkWriter) => {
stream: ReadableStream<Uint8Array>,
write: ChunkWriter,
) => {
const chunks: Uint8Array[] = []; const chunks: Uint8Array[] = [];
for await (const chunk of stream) { for await (const chunk of stream) {
chunks.push(chunk); chunks.push(chunk);
@@ -58,21 +48,22 @@ export function openCodeError(output: string): Error | undefined {
} catch { } catch {
continue; continue;
} }
if (typeof event !== "object" || event === null) continue; if (!isObject(event)) continue;
const { type, error } = event as Record<string, unknown>; const { type, error } = event;
if (type !== "error" || typeof error !== "object" || error === null) { if (type !== "error" || !isObject(error)) {
continue; continue;
} }
const { name, data } = error as Record<string, unknown>; const { name, data } = error;
const { message, statusCode } = (data ?? {}) as Record<string, unknown>; const { message, statusCode } = isObject(data) ? data : {};
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"}`;
failure = statusCode === 401 || statusCode === 403 failure =
? new AgentAuthError("opencode sign-in failed", { statusCode === 401 || statusCode === 403
cause: new Error(detail), ? new AgentAuthError("opencode sign-in failed", {
}) cause: new Error(detail),
: new Error(detail); })
: new Error(detail);
} }
return failure; return failure;
} }
@@ -82,15 +73,14 @@ export function parseOpenCodeEvents(output: string): string {
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 (typeof event !== "object" || event === null) continue; if (!isObject(event)) continue;
const { type, part } = event as Record<string, unknown>; const { type, part } = event;
if (type !== "text" || typeof part !== "object" || part === null) continue; if (type !== "text" || !isObject(part)) continue;
const text = (part as Record<string, unknown>).text; const { text } = part;
if (typeof text === "string" && text.trim() !== "") result = text; if (typeof text === "string" && text.trim() !== "") result = text;
} }
if (result === undefined) { if (result === undefined) {
throw openCodeError(output) ?? throw openCodeError(output) ?? new Error("OpenCode produced no text output");
new Error("OpenCode produced no text output");
} }
return result; return result;
} }
-120
View File
@@ -1,120 +0,0 @@
import {
assertEquals,
assertInstanceOf,
assertRejects,
assertThrows,
} from "jsr:@std/assert@1";
import {
collectOpenCodeOutput,
openCodeArgs,
openCodeError,
parseOpenCodeEvents,
} from "./opencode.ts";
import { AgentAuthError } from "./profiles.ts";
Deno.test("builds a non-interactive auto-approved OpenCode command", () => {
assertEquals(
openCodeArgs("openrouter/qwen/qwen3.8-27b", "Review this"),
[
"run",
"--format",
"json",
"--auto",
"--model",
"openrouter/qwen/qwen3.8-27b",
"Review this",
],
);
});
Deno.test("returns the final visible OpenCode text event", () => {
assertEquals(
parseOpenCodeEvents([
JSON.stringify({ type: "step_start", part: { type: "step-start" } }),
JSON.stringify({ type: "text", part: { type: "text", text: "draft" } }),
JSON.stringify({ type: "text", part: { type: "text", text: "done" } }),
JSON.stringify({ type: "step_finish", part: { type: "step-finish" } }),
].join("\n")),
"done",
);
});
Deno.test("rejects a successful OpenCode run without visible text", async () => {
await assertRejects(
async () => {
parseOpenCodeEvents('{"type":"step_start"}');
await Promise.resolve();
},
Error,
"no text output",
);
});
Deno.test("streams OpenCode output while collecting it", async () => {
const encode = (value: string) => new TextEncoder().encode(value);
const stdout = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encode("first\n"));
controller.enqueue(encode("second\n"));
controller.close();
},
});
const stderr = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encode("warning\n"));
controller.close();
},
});
const shown: string[] = [];
const result = await collectOpenCodeOutput(
stdout,
stderr,
(chunk: Uint8Array) => {
shown.push(`out:${new TextDecoder().decode(chunk)}`);
},
(chunk: Uint8Array) => {
shown.push(`err:${new TextDecoder().decode(chunk)}`);
},
);
assertEquals(result, { stdout: "first\nsecond\n", stderr: "warning\n" });
assertEquals(shown, ["out:first\n", "err:warning\n", "out:second\n"]);
});
const openRouterError = (statusCode: number, message: string): string =>
JSON.stringify({
type: "error",
sessionID: "ses_test",
error: {
name: "APIError",
data: {
message,
statusCode,
isRetryable: false,
responseBody: JSON.stringify({ error: { message, code: statusCode } }),
},
},
});
Deno.test("reports a rejected OpenRouter key as a sign-in failure", () => {
const error = openCodeError(openRouterError(401, "User not found."));
assertInstanceOf(error, AgentAuthError);
assertEquals(error.message, "opencode sign-in failed");
assertEquals(
(error.cause as Error).message,
"opencode APIError (API error 401): User not found.",
);
});
Deno.test("surfaces an OpenCode error event instead of empty output", () => {
assertThrows(
() => parseOpenCodeEvents(openRouterError(429, "Rate limit exceeded")),
Error,
"API error 429",
);
assertEquals(
openCodeError('{"type":"text","part":{"text":"ok"}}'),
undefined,
);
});
+114
View File
@@ -0,0 +1,114 @@
// 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;
}
+296
View File
@@ -0,0 +1,296 @@
import { chmod, mkdir, rm, stat, symlink, writeFile } from "node:fs/promises";
import { assert, expect, test } from "vite-plus/test";
import { isNotFound } from "./errors.ts";
import { postRuntime } from "./post.ts";
import type { PostContext } from "./post.ts";
import type { RuntimeManifest, RuntimeOutcome } from "./runtime.ts";
import { currentGid, currentUid, IS_WINDOWS, makeTempDir } from "./testing.ts";
const context: PostContext = {
apiUrl: "https://gitea.example/api/v1",
repository: "owner/repo",
issueIndex: "7",
eventName: "issue_comment",
};
interface Fixture {
manifestPath: string;
runtimeDir: string;
reviewerFile: string;
reviewPath: string;
outcomeFile: string;
}
type FixtureSource = Extract<RuntimeOutcome, { ok: false }> | { ok: true; body: string };
async function writePrivate(path: string, text: string): Promise<void> {
await writeFile(path, text, { mode: 0o600 });
await chmod(path, 0o600);
}
async function writeArtifacts(
outcome: RuntimeOutcome,
review: string | undefined,
anchors: unknown,
): Promise<void> {
if (!outcome.ok) return;
if (review !== undefined) await writePrivate(outcome.reviewPath, review);
if (anchors !== undefined) await writePrivate(outcome.anchorsPath, JSON.stringify(anchors));
}
function manifestFor(runtimeDir: string, profileHome: string): RuntimeManifest {
return {
runtimeDir,
manifestPath: `${runtimeDir}/manifest.json`,
reviewerFile: `${runtimeDir}/reviewer-token`,
outcomeFile: `${runtimeDir}/outcome.json`,
profiles: [
{
index: 0,
type: "claude",
model: "test",
uid: currentUid(),
gid: currentGid(),
home: profileHome,
tokenFile: null,
},
],
};
}
async function fixture(
source: FixtureSource,
review?: string,
anchors?: unknown,
): Promise<Fixture> {
const runtimeDir = await makeTempDir();
const profileHome = `${runtimeDir}/profile-0`;
const manifest = manifestFor(runtimeDir, profileHome);
await mkdir(profileHome);
await writeFile(manifest.reviewerFile, "reviewer-secret");
const outcome: RuntimeOutcome = source.ok
? {
...source,
profile: { type: "codex", model: "gpt-test" },
reviewPath: `${profileHome}/review.md`,
anchorsPath: `${profileHome}/anchors.json`,
}
: source;
await writeFile(manifest.outcomeFile, JSON.stringify(outcome));
await writeArtifacts(outcome, review, anchors);
await writeFile(manifest.manifestPath, JSON.stringify(manifest));
const { manifestPath, reviewerFile, outcomeFile } = manifest;
return {
manifestPath,
runtimeDir,
reviewerFile,
reviewPath: `${profileHome}/review.md`,
outcomeFile,
};
}
const missing = async (path: string): Promise<boolean> => {
try {
await stat(path);
return false;
} catch (error) {
if (isNotFound(error)) return true;
throw error;
}
};
// The JSON body of one request the fake Gitea received.
function requestBody(init?: RequestInit): unknown {
return typeof init?.body === "string" ? JSON.parse(init.body) : undefined;
}
// A fake Gitea that records each request body and answers with `status`.
function recordingFetch(bodies: unknown[], status = 201): typeof fetch {
return (_url, init) => {
bodies.push(requestBody(init));
return Promise.resolve(new Response(null, { status }));
};
}
test("posts a successful issue result and removes runtime credentials", async () => {
const result = await fixture({ ok: true, body: "done" });
const requests: { url: unknown; init: RequestInit | undefined }[] = [];
await postRuntime(result.manifestPath, context, (url, init) => {
requests.push({ url, init });
return Promise.resolve(new Response(null, { status: 201 }));
});
expect(requests.length).toBe(1);
const [request] = requests;
expect(request?.url).toStrictEqual(`${context.apiUrl}/repos/owner/repo/issues/7/comments`);
expect(requestBody(request?.init)).toStrictEqual({
body: "done\n\n> Profile: `codex` · `gpt-test`",
});
expect(request?.init?.headers).toStrictEqual({
Authorization: "token reviewer-secret",
"Content-Type": "application/json",
});
assert(await missing(result.runtimeDir));
});
test("retries a rejected inline review without anchors", async () => {
const result = await fixture({ ok: true, body: "ignored final response" }, "Approved\nReady", [
{ path: "run.ts", line: 1, side: "new", body: "Finding" },
]);
const bodies: unknown[] = [];
await postRuntime(
result.manifestPath,
{
...context,
eventName: "pull_request",
},
(_url, init) => {
bodies.push(requestBody(init));
return Promise.resolve(new Response(null, { status: bodies.length === 1 ? 422 : 200 }));
},
);
expect(bodies[0]).toMatchObject({
comments: [{ body: "Finding\n\n> Profile: `codex` · `gpt-test`" }],
});
expect(bodies[1]).toMatchObject({
comments: [],
body: "✅ Approved\nReady\n\n> Profile: `codex` · `gpt-test`",
});
assert(await missing(result.runtimeDir));
});
test("reports each tried profile and keeps the action failed", async () => {
const result = await fixture({
ok: false,
failures: [
{ type: "codex", model: "gpt-test", reason: "sign-in failed" },
{ type: "claude", model: "claude-test", reason: "usage exhausted" },
{ type: "opencode", model: "openrouter/test", reason: "failed" },
],
});
const bodies: unknown[] = [];
await expect(() =>
postRuntime(result.manifestPath, context, recordingFetch(bodies)),
).rejects.toThrow("agent phase failed");
expect(bodies).toStrictEqual([
{
body:
"AI bot failed.\n\n> Tried: `codex` · `gpt-test` (sign-in failed), " +
"`claude` · `claude-test` (usage exhausted), " +
"`opencode` · `openrouter/test` (failed)",
},
]);
assert(!JSON.stringify(bodies).includes("reviewer-secret"));
assert(await missing(result.runtimeDir));
});
test("drops failure entries that are not known labels", async () => {
const result = await fixture({ ok: false, failures: [] });
await writeFile(
result.outcomeFile,
JSON.stringify({
ok: false,
failures: [
{ type: "codex", model: "gpt-test", reason: "token sk-secret leaked" },
{ type: "shell", model: "x", reason: "failed" },
{ type: "claude", model: "claude-test", reason: "sign-in failed" },
],
}),
);
const bodies: unknown[] = [];
await expect(() =>
postRuntime(result.manifestPath, context, recordingFetch(bodies)),
).rejects.toThrow("agent phase failed");
expect(bodies).toStrictEqual([
{
body: "AI bot failed.\n\n> Tried: `claude` · `claude-test` (sign-in failed)",
},
]);
});
test("reports a generic failure when the runner wrote no outcome", async () => {
const result = await fixture({ ok: false, failures: [] });
await rm(result.outcomeFile);
const bodies: unknown[] = [];
await expect(() =>
postRuntime(result.manifestPath, context, recordingFetch(bodies)),
).rejects.toThrow("agent phase failed");
expect(bodies).toStrictEqual([{ body: "AI bot failed.\n\n> Profile: none" }]);
assert(await missing(result.runtimeDir));
});
test("removes runtime credentials when posting fails", async () => {
const result = await fixture({ ok: true, body: "done" });
await expect(() =>
postRuntime(result.manifestPath, context, () =>
Promise.resolve(new Response("failed", { status: 500 })),
),
).rejects.toThrow("500");
assert(await missing(result.runtimeDir));
});
test("preserves a Gitea transport failure without retrying a comment", async () => {
const result = await fixture({ ok: true, body: "done" });
let requests = 0;
await expect(() =>
postRuntime(result.manifestPath, context, () => {
requests += 1;
return Promise.reject(new Error(`network failure ${requests}`));
}),
).rejects.toThrow("network failure 1");
expect(requests).toBe(1);
assert(await missing(result.runtimeDir));
});
test.skipIf(IS_WINDOWS)("refuses to follow an agent artifact symlink", async () => {
const result = await fixture({ ok: true, body: "done" });
await symlink(result.reviewerFile, result.reviewPath);
let requests = 0;
await expect(() =>
postRuntime(result.manifestPath, context, () => {
requests += 1;
return Promise.resolve(new Response(null, { status: 200 }));
}),
).rejects.toThrow("regular private file");
expect(requests).toBe(1);
assert(await missing(result.runtimeDir));
});
test("reports an attributed failure when a pull request review is missing", async () => {
const result = await fixture({ ok: true, body: "done" });
const bodies: unknown[] = [];
await expect(() =>
postRuntime(
result.manifestPath,
{
...context,
eventName: "pull_request",
},
recordingFetch(bodies),
),
).rejects.toThrow("did not write");
expect(bodies).toStrictEqual([
{
body: "AI bot failed.\n\n> Tried: `codex` · `gpt-test` (failed)",
},
]);
assert(await missing(result.runtimeDir));
});
+137 -218
View File
@@ -1,144 +1,157 @@
import { join } from "jsr:@std/path@1"; import { readFile, rm } from "node:fs/promises";
import { import process from "node:process";
readRuntimeManifest,
type RuntimeManifest,
type RuntimeOutcome,
} from "./runtime.ts";
import {
failureFooter,
type FailureReason,
type ProfileFailure,
withProfile,
} from "./profiles.ts";
export type PostContext = { 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; apiUrl: string;
repository: string; repository: string;
issueIndex: string; issueIndex: string;
eventName: string; eventName: string;
}; }
type Anchor = { type SuccessOutcome = Extract<RuntimeOutcome, { ok: true }>;
path: string;
line: number;
side: "new" | "old";
body: string;
};
const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*[A-Za-z]`, "g"); 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 stripAnsi = (text: string): string => text.replace(ANSI, "");
const VERDICTS: Record<string, [event: string, mark: string]> = { const VERDICTS: Record<string, [event: string, mark: string]> = {
Approved: ["APPROVED", "✅"], Approved: ["APPROVED", "✅"],
"Changes requested": ["REQUEST_CHANGES", "🛑"], "Changes requested": ["REQUEST_CHANGES", "🛑"],
}; };
const PROFILE_TYPES = ["claude", "codex", "opencode"];
const FAILURE_REASONS = ["sign-in failed", "usage exhausted", "failed"];
const failureComment = (failures: ProfileFailure[]): string => const failureComment = (failures: ProfileFailure[]): string =>
`AI bot failed.\n\n${failureFooter(failures)}`; `AI bot failed.\n\n${failureFooter(failures)}`;
class GiteaRequestError extends Error {} class GiteaRequestError extends Error {}
function parseAnchors(text: string, sourcePath: string): Anchor[] { function giteaRequest(apiUrl: string, reviewerToken: string, fetcher: typeof fetch): GiteaRequest {
const entries: unknown = JSON.parse(text); return async (path, body) => {
if (!Array.isArray(entries)) throw new Error(`${sourcePath}: not an array`); let response: Response;
return entries.map((entry: unknown, index) => { try {
const at = `${sourcePath}[${index}]`; response = await fetcher(`${apiUrl}/${path}`, {
if (typeof entry !== "object" || entry === null) { method: "POST",
throw new Error(`${at}: not an object`); 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 });
} }
const { path, line, side = "new", body } = entry as Record<string, unknown>; if (!response.ok) {
if (typeof path !== "string" || path === "") { throw new GiteaRequestError(`${path}: ${response.status} ${await response.text()}`);
throw new Error(`${at}.path: expected a repository path`);
} }
if (typeof line !== "number" || !Number.isInteger(line) || line < 1) {
throw new Error(`${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 Error(`${at}.body: expected the finding`);
}
return { path, line, side, body };
});
}
async function readOutcome(path: string): Promise<RuntimeOutcome> {
const value: unknown = JSON.parse(await Deno.readTextFile(path));
if (typeof value !== "object" || value === null || !("ok" in value)) {
throw new Error("invalid agent outcome");
}
const outcome = value as Record<string, unknown>;
if (outcome.ok === false) {
// Only known labels reach the comment, never raw agent output.
const failures = Array.isArray(outcome.failures) ? outcome.failures : [];
return {
ok: false,
failures: failures.flatMap((entry: unknown) => {
const { type, model, reason } = (entry ?? {}) as Record<
string,
unknown
>;
return typeof type === "string" && PROFILE_TYPES.includes(type) &&
typeof model === "string" && typeof reason === "string" &&
FAILURE_REASONS.includes(reason)
? [{
type: type as ProfileFailure["type"],
model,
reason: reason as FailureReason,
}]
: [];
}),
};
}
if (
outcome.ok !== true || typeof outcome.body !== "string" ||
typeof outcome.reviewPath !== "string" ||
typeof outcome.anchorsPath !== "string" ||
typeof outcome.profile !== "object" || outcome.profile === null
) {
throw new Error("invalid agent outcome");
}
const profile = outcome.profile as Record<string, unknown>;
if (typeof profile.type !== "string" || typeof profile.model !== "string") {
throw new Error("invalid agent outcome");
}
return {
ok: true,
body: outcome.body,
reviewPath: outcome.reviewPath,
anchorsPath: outcome.anchorsPath,
profile: {
type: profile.type as "claude" | "codex" | "opencode",
model: profile.model,
},
}; };
} }
async function readAgentArtifact( async function readSuccess(
manifest: RuntimeManifest, manifest: RuntimeManifest,
path: string, publisher: Publisher,
name: string, ): Promise<SuccessOutcome> {
): Promise<string | null> { let outcome: RuntimeOutcome;
const profile = manifest.profiles.find((entry) =>
join(entry.home, name) === path
);
if (profile === undefined) throw new Error(`${name}: outside profile home`);
let stat: Deno.FileInfo;
try { try {
stat = await Deno.lstat(path); outcome = await readOutcome(manifest.outcomeFile);
} catch (error) { } catch (error) {
if (error instanceof Deno.errors.NotFound) return null; 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; throw error;
} }
if (
!stat.isFile || stat.isSymlink || stat.uid !== profile.uid ||
(stat.mode! & 0o077) !== 0
) {
throw new Error(`${name}: expected a profile-owned regular private file`);
}
const text = await Deno.readTextFile(path);
return text === "" ? null : text;
} }
export async function postRuntime( export async function postRuntime(
@@ -148,115 +161,21 @@ export async function postRuntime(
): Promise<void> { ): Promise<void> {
const manifest = await readRuntimeManifest(manifestPath); const manifest = await readRuntimeManifest(manifestPath);
try { try {
const reviewerToken = (await Deno.readTextFile(manifest.reviewerFile)) await publish(manifest, context, fetcher);
.trim();
const request = async (path: string, body: unknown): Promise<void> => {
let response: Response;
try {
response = await fetcher(`${context.apiUrl}/${path}`, {
method: "POST",
headers: {
Authorization: `token ${reviewerToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
throw new GiteaRequestError(`${path}: ${detail}`, { cause: error });
}
if (!response.ok) {
throw new GiteaRequestError(
`${path}: ${response.status} ${await response.text()}`,
);
}
};
const postComment = (body: string) =>
request(
`repos/${context.repository}/issues/${context.issueIndex}/comments`,
{ body: stripAnsi(body) },
);
let outcome: RuntimeOutcome;
try {
outcome = await readOutcome(manifest.outcomeFile);
} catch (error) {
await postComment(failureComment([]));
throw new Error("agent phase failed", { cause: error });
}
if (!outcome.ok) {
await postComment(failureComment(outcome.failures));
throw new Error("agent phase failed");
}
try {
const review = await readAgentArtifact(
manifest,
outcome.reviewPath,
"review.md",
);
if (review === null) {
if (context.eventName === "pull_request") {
throw new Error("agent did not write a pull request review");
}
await postComment(withProfile(outcome.body, outcome.profile));
return;
}
const [verdict, ...rest] = review.split("\n");
const [event, mark] = VERDICTS[verdict.trim()] ?? ["COMMENT", "💬"];
const anchorText = await readAgentArtifact(
manifest,
outcome.anchorsPath,
"anchors.json",
);
const anchors = anchorText === null
? []
: parseAnchors(anchorText, outcome.anchorsPath);
const postReview = (entries: Anchor[]) =>
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: Error) => {
if (anchors.length === 0) throw error;
console.error(`inline comments rejected: ${error.message}`);
await postReview([]);
});
} catch (error) {
if (error instanceof GiteaRequestError) throw error;
await postComment(
failureComment([{ ...outcome.profile, reason: "failed" }]),
);
throw error;
}
} finally { } finally {
await Deno.remove(manifest.runtimeDir, { recursive: true }).catch( await rm(manifest.runtimeDir, { recursive: true }).catch((error: unknown) => {
(error) => { if (!isNotFound(error)) throw error;
if (!(error instanceof Deno.errors.NotFound)) 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) { if (import.meta.main) {
const required = (name: string): string => {
const value = Deno.env.get(name);
if (!value) throw new Error(`${name} is not set`);
return value;
};
await postRuntime(required("BOT_AGENTS_MANIFEST"), { await postRuntime(required("BOT_AGENTS_MANIFEST"), {
apiUrl: required("GITEA_API_URL"), apiUrl: required("GITEA_API_URL"),
repository: required("GITEA_REPOSITORY"), repository: required("GITEA_REPOSITORY"),
-296
View File
@@ -1,296 +0,0 @@
import { assert, assertEquals, assertRejects } from "jsr:@std/assert@1";
import { type PostContext, postRuntime } from "./post.ts";
import type { RuntimeManifest, RuntimeOutcome } from "./runtime.ts";
const context: PostContext = {
apiUrl: "https://gitea.example/api/v1",
repository: "owner/repo",
issueIndex: "7",
eventName: "issue_comment",
};
async function fixture(
source: Extract<RuntimeOutcome, { ok: false }> | { ok: true; body: string },
review?: string,
anchors?: unknown,
): Promise<{
manifestPath: string;
runtimeDir: string;
reviewerFile: string;
reviewPath: string;
outcomeFile: string;
}> {
const runtimeDir = await Deno.makeTempDir();
const reviewerFile = `${runtimeDir}/reviewer-token`;
const outcomeFile = `${runtimeDir}/outcome.json`;
const profileHome = `${runtimeDir}/profile-0`;
await Deno.mkdir(profileHome);
await Deno.writeTextFile(reviewerFile, "reviewer-secret");
const outcome: RuntimeOutcome = source.ok
? {
...source,
profile: { type: "codex", model: "gpt-test" },
reviewPath: `${profileHome}/review.md`,
anchorsPath: `${profileHome}/anchors.json`,
}
: source;
await Deno.writeTextFile(outcomeFile, JSON.stringify(outcome));
if (outcome.ok) {
if (review !== undefined) {
await Deno.writeTextFile(outcome.reviewPath, review, { mode: 0o600 });
await Deno.chmod(outcome.reviewPath, 0o600);
}
if (anchors !== undefined) {
await Deno.writeTextFile(outcome.anchorsPath, JSON.stringify(anchors), {
mode: 0o600,
});
await Deno.chmod(outcome.anchorsPath, 0o600);
}
}
const manifestPath = `${runtimeDir}/manifest.json`;
const manifest: RuntimeManifest = {
runtimeDir,
manifestPath,
reviewerFile,
outcomeFile,
profiles: [{
index: 0,
type: "claude",
model: "test",
uid: Deno.uid()!,
gid: Deno.gid()!,
home: profileHome,
tokenFile: null,
}],
};
await Deno.writeTextFile(manifestPath, JSON.stringify(manifest));
return {
manifestPath,
runtimeDir,
reviewerFile,
reviewPath: `${profileHome}/review.md`,
outcomeFile,
};
}
const missing = async (path: string): Promise<boolean> => {
try {
await Deno.stat(path);
return false;
} catch (error) {
if (error instanceof Deno.errors.NotFound) return true;
throw error;
}
};
Deno.test("posts a successful issue result and removes runtime credentials", async () => {
const result = await fixture({ ok: true, body: "done" });
const requests: Array<{ url: string; init?: RequestInit }> = [];
await postRuntime(result.manifestPath, context, (url, init) => {
requests.push({ url: String(url), init });
return Promise.resolve(new Response(null, { status: 201 }));
});
assertEquals(requests.length, 1);
assertEquals(
requests[0].url,
`${context.apiUrl}/repos/owner/repo/issues/7/comments`,
);
assertEquals(JSON.parse(String(requests[0].init?.body)), {
body: "done\n\n> Profile: `codex` · `gpt-test`",
});
assertEquals(requests[0].init?.headers, {
Authorization: "token reviewer-secret",
"Content-Type": "application/json",
});
assert(await missing(result.runtimeDir));
});
Deno.test("retries a rejected inline review without anchors", async () => {
const result = await fixture(
{ ok: true, body: "ignored final response" },
"Approved\nReady",
[{ path: "run.ts", line: 1, side: "new", body: "Finding" }],
);
const bodies: unknown[] = [];
await postRuntime(result.manifestPath, {
...context,
eventName: "pull_request",
}, (_url, init) => {
bodies.push(JSON.parse(String(init?.body)));
return Promise.resolve(
new Response(null, { status: bodies.length === 1 ? 422 : 200 }),
);
});
assertEquals((bodies[0] as { comments: unknown[] }).comments.length, 1);
assertEquals(
(bodies[0] as { comments: Array<{ body: string }> }).comments[0].body,
"Finding\n\n> Profile: `codex` · `gpt-test`",
);
assertEquals((bodies[1] as { comments: unknown[] }).comments, []);
assertEquals(
(bodies[1] as { body: string }).body,
"✅ Approved\nReady\n\n> Profile: `codex` · `gpt-test`",
);
assert(await missing(result.runtimeDir));
});
Deno.test("reports each tried profile and keeps the action failed", async () => {
const result = await fixture({
ok: false,
failures: [
{ type: "codex", model: "gpt-test", reason: "sign-in failed" },
{ type: "claude", model: "claude-test", reason: "usage exhausted" },
{ type: "opencode", model: "openrouter/test", reason: "failed" },
],
});
const bodies: unknown[] = [];
await assertRejects(
() =>
postRuntime(result.manifestPath, context, (_url, init) => {
bodies.push(JSON.parse(String(init?.body)));
return Promise.resolve(new Response(null, { status: 201 }));
}),
Error,
"agent phase failed",
);
assertEquals(bodies, [{
body: "AI bot failed.\n\n> Tried: `codex` · `gpt-test` (sign-in failed), " +
"`claude` · `claude-test` (usage exhausted), " +
"`opencode` · `openrouter/test` (failed)",
}]);
assert(!JSON.stringify(bodies).includes("reviewer-secret"));
assert(await missing(result.runtimeDir));
});
Deno.test("drops failure entries that are not known labels", async () => {
const result = await fixture({ ok: false, failures: [] });
await Deno.writeTextFile(
result.outcomeFile,
JSON.stringify({
ok: false,
failures: [
{ type: "codex", model: "gpt-test", reason: "token sk-secret leaked" },
{ type: "shell", model: "x", reason: "failed" },
{ type: "claude", model: "claude-test", reason: "sign-in failed" },
],
}),
);
const bodies: unknown[] = [];
await assertRejects(
() =>
postRuntime(result.manifestPath, context, (_url, init) => {
bodies.push(JSON.parse(String(init?.body)));
return Promise.resolve(new Response(null, { status: 201 }));
}),
Error,
"agent phase failed",
);
assertEquals(bodies, [{
body:
"AI bot failed.\n\n> Tried: `claude` · `claude-test` (sign-in failed)",
}]);
});
Deno.test("reports a generic failure when the runner wrote no outcome", async () => {
const result = await fixture({ ok: false, failures: [] });
await Deno.remove(result.outcomeFile);
const bodies: unknown[] = [];
await assertRejects(
() =>
postRuntime(result.manifestPath, context, (_url, init) => {
bodies.push(JSON.parse(String(init?.body)));
return Promise.resolve(new Response(null, { status: 201 }));
}),
Error,
"agent phase failed",
);
assertEquals(bodies, [{ body: "AI bot failed.\n\n> Profile: none" }]);
assert(await missing(result.runtimeDir));
});
Deno.test("removes runtime credentials when posting fails", async () => {
const result = await fixture({ ok: true, body: "done" });
await assertRejects(
() =>
postRuntime(
result.manifestPath,
context,
() => Promise.resolve(new Response("failed", { status: 500 })),
),
Error,
"500",
);
assert(await missing(result.runtimeDir));
});
Deno.test("preserves a Gitea transport failure without retrying a comment", async () => {
const result = await fixture({ ok: true, body: "done" });
let requests = 0;
await assertRejects(
() =>
postRuntime(result.manifestPath, context, () => {
requests++;
return Promise.reject(new Error(`network failure ${requests}`));
}),
Error,
"network failure 1",
);
assertEquals(requests, 1);
assert(await missing(result.runtimeDir));
});
Deno.test("refuses to follow an agent artifact symlink", async () => {
if (Deno.build.os === "windows") return;
const result = await fixture({ ok: true, body: "done" });
await Deno.symlink(result.reviewerFile, result.reviewPath);
let requests = 0;
await assertRejects(
() =>
postRuntime(result.manifestPath, context, () => {
requests++;
return Promise.resolve(new Response(null, { status: 200 }));
}),
Error,
"regular private file",
);
assertEquals(requests, 1);
assert(await missing(result.runtimeDir));
});
Deno.test("reports an attributed failure when a pull request review is missing", async () => {
const result = await fixture({ ok: true, body: "done" });
const bodies: unknown[] = [];
await assertRejects(
() =>
postRuntime(result.manifestPath, {
...context,
eventName: "pull_request",
}, (_url, init) => {
bodies.push(JSON.parse(String(init?.body)));
return Promise.resolve(new Response(null, { status: 201 }));
}),
Error,
"did not write",
);
assertEquals(bodies, [{
body: "AI bot failed.\n\n> Tried: `codex` · `gpt-test` (failed)",
}]);
assert(await missing(result.runtimeDir));
});
+9 -10
View File
@@ -1,8 +1,13 @@
import { appendFile, rm } from "node:fs/promises";
import process from "node:process";
import { prepareRuntime } from "./runtime.ts"; import { prepareRuntime } from "./runtime.ts";
const env = (name: string): string => { const env = (name: string): string => {
const value = Deno.env.get(name); const value = process.env[name];
if (!value) throw new Error(`${name} is not set`); if (value === undefined || value === "") {
throw new Error(`${name} is not set`);
}
return value; return value;
}; };
@@ -12,14 +17,8 @@ const manifest = await prepareRuntime(
env("BOT_AGENTS_MASTER_KEY"), env("BOT_AGENTS_MASTER_KEY"),
); );
try { try {
await Deno.writeTextFile( await appendFile(env("GITHUB_OUTPUT"), `manifest=${manifest.manifestPath}\n`);
env("GITHUB_OUTPUT"),
`manifest=${manifest.manifestPath}\n`,
{ append: true },
);
} catch (error) { } catch (error) {
await Deno.remove(manifest.runtimeDir, { recursive: true }).catch(() => await rm(manifest.runtimeDir, { recursive: true }).catch(() => {});
undefined
);
throw error; throw error;
} }
+81 -95
View File
@@ -1,4 +1,4 @@
import { assertEquals, assertRejects } from "jsr:@std/assert@1"; import { expect, test } from "vite-plus/test";
import { import {
AgentAuthError, AgentAuthError,
agentEnvironment, agentEnvironment,
@@ -9,14 +9,13 @@ import {
sanitizedEnvironment, sanitizedEnvironment,
} from "./profiles.ts"; } from "./profiles.ts";
Deno.test("formats profile attribution as a Markdown quote", () => { test("formats profile attribution as a Markdown quote", () => {
assertEquals( expect(profileFooter({ type: "opencode", model: "openrouter/qwen/qwen3.8-27b" })).toBe(
profileFooter({ type: "opencode", model: "openrouter/qwen/qwen3.8-27b" }),
"> Profile: `opencode` · `openrouter/qwen/qwen3.8-27b`", "> Profile: `opencode` · `openrouter/qwen/qwen3.8-27b`",
); );
}); });
Deno.test("falls through only after usage exhaustion", async () => { test("falls through only after usage exhaustion", async () => {
const visited: string[] = []; const visited: string[] = [];
const result = await runProfiles( const result = await runProfiles(
[ [
@@ -32,11 +31,11 @@ Deno.test("falls through only after usage exhaustion", async () => {
}, },
); );
assertEquals(result, "done"); expect(result).toBe("done");
assertEquals(visited, ["claude", "codex"]); expect(visited).toStrictEqual(["claude", "codex"]);
}); });
Deno.test("recognizes Claude's weekly limit message", async () => { test("recognizes Claude's weekly limit message", async () => {
const result = await runProfiles( const result = await runProfiles(
[ [
{ type: "claude", model: "claude-sonnet-5", token: "first" }, { type: "claude", model: "claude-sonnet-5", token: "first" },
@@ -50,30 +49,29 @@ Deno.test("recognizes Claude's weekly limit message", async () => {
}, },
); );
assertEquals(result, "fallback"); expect(result).toBe("fallback");
}); });
Deno.test("recognizes provider API exhaustion status", async () => { test("recognizes provider API exhaustion status", async () => {
const results = await Promise.all( const results = await Promise.all(
["API Error: 402 Insufficient funds", "HTTP error: 429"] ["API Error: 402 Insufficient funds", "HTTP error: 429"].map((message) =>
.map((message) => runProfiles(
runProfiles( [
[ { type: "claude", model: "claude-sonnet-5", token: "first" },
{ type: "claude", model: "claude-sonnet-5", token: "first" }, { type: "codex", model: "gpt-5.6-terra" },
{ type: "codex", model: "gpt-5.6-terra" }, ],
], (profile) => {
(profile) => { if (profile.type === "claude") throw new Error(message);
if (profile.type === "claude") throw new Error(message); return Promise.resolve("fallback");
return Promise.resolve("fallback"); },
},
)
), ),
),
); );
assertEquals(results, ["fallback", "fallback"]); expect(results).toStrictEqual(["fallback", "fallback"]);
}); });
Deno.test("falls through after a sign-in failure and records each try", async () => { test("falls through after a sign-in failure and records each try", async () => {
const failures: ProfileFailure[] = []; const failures: ProfileFailure[] = [];
const result = await runProfiles( const result = await runProfiles(
[ [
@@ -91,82 +89,73 @@ Deno.test("falls through after a sign-in failure and records each try", async ()
failures, failures,
); );
assertEquals(result, "fallback"); expect(result).toBe("fallback");
assertEquals(failures, [ expect(failures).toStrictEqual([
{ type: "codex", model: "gpt-5.6-terra", reason: "sign-in failed" }, { type: "codex", model: "gpt-5.6-terra", reason: "sign-in failed" },
{ type: "claude", model: "claude-sonnet-5", reason: "usage exhausted" }, { type: "claude", model: "claude-sonnet-5", reason: "usage exhausted" },
]); ]);
assertEquals( expect(failureFooter(failures)).toStrictEqual(
failureFooter(failures),
"> Tried: `codex` · `gpt-5.6-terra` (sign-in failed), " + "> Tried: `codex` · `gpt-5.6-terra` (sign-in failed), " +
"`claude` · `claude-sonnet-5` (usage exhausted)", "`claude` · `claude-sonnet-5` (usage exhausted)",
); );
}); });
Deno.test("throws the last sign-in failure when every profile fails", async () => { test("throws the last sign-in failure when every profile fails", async () => {
const failures: ProfileFailure[] = []; const failures: ProfileFailure[] = [];
await assertRejects( await expect(() =>
() => runProfiles(
runProfiles( [
[ { type: "codex", model: "gpt-5.6-terra" },
{ type: "codex", model: "gpt-5.6-terra" }, { type: "claude", model: "claude-sonnet-5", token: "first" },
{ type: "claude", model: "claude-sonnet-5", token: "first" }, ],
], (profile) => {
(profile) => { throw new AgentAuthError(`${profile.type} sign-in failed`);
throw new AgentAuthError(`${profile.type} sign-in failed`); },
}, failures,
failures, ),
), ).rejects.toThrow(
AgentAuthError, expect.toSatisfy(
"claude sign-in failed", (e: unknown) => e instanceof AgentAuthError && e.message.includes("claude sign-in failed"),
),
); );
assertEquals(failures.map(({ reason }) => reason), [ expect(failures.map(({ reason }) => reason)).toStrictEqual(["sign-in failed", "sign-in failed"]);
"sign-in failed",
"sign-in failed",
]);
}); });
Deno.test("preserves an ordinary agent failure", async () => { test("preserves an ordinary agent failure", async () => {
const visited: string[] = []; const visited: string[] = [];
await assertRejects( await expect(() =>
() => runProfiles(
runProfiles( [
[ { type: "claude", model: "claude-sonnet-5", token: "first" },
{ type: "claude", model: "claude-sonnet-5", token: "first" }, { type: "codex", model: "gpt-5.6-terra" },
{ type: "codex", model: "gpt-5.6-terra" }, ],
], (profile) => {
(profile) => { visited.push(profile.type);
visited.push(profile.type); throw new Error("tests failed");
throw new Error("tests failed"); },
}, ),
), ).rejects.toThrow("tests failed");
Error, expect(visited).toStrictEqual(["claude"]);
"tests failed",
);
assertEquals(visited, ["claude"]);
}); });
Deno.test("does not mistake incidental quota text for exhaustion", async () => { test("does not mistake incidental quota text for exhaustion", async () => {
const visited: string[] = []; const visited: string[] = [];
await assertRejects( await expect(() =>
() => runProfiles(
runProfiles( [
[ { type: "claude", model: "claude-sonnet-5", token: "first" },
{ type: "claude", model: "claude-sonnet-5", token: "first" }, { type: "codex", model: "gpt-5.6-terra" },
{ type: "codex", model: "gpt-5.6-terra" }, ],
], (profile) => {
(profile) => { visited.push(profile.type);
visited.push(profile.type); throw new Error("quota parser test failed at line 429");
throw new Error("quota parser test failed at line 429"); },
}, ),
), ).rejects.toThrow("quota parser test failed");
Error, expect(visited).toStrictEqual(["claude"]);
"quota parser test failed",
);
assertEquals(visited, ["claude"]);
}); });
Deno.test("exposes only the selected profile credential to the agent", () => { test("exposes only the selected profile credential to the agent", () => {
const base = { const base = {
PATH: "/usr/bin", PATH: "/usr/bin",
HOME: "/home/bot", HOME: "/home/bot",
@@ -191,21 +180,18 @@ Deno.test("exposes only the selected profile credential to the agent", () => {
tokenFile: "/runtime/profile-0/token", tokenFile: "/runtime/profile-0/token",
}; };
assertEquals( expect(agentEnvironment(base, profile, "selected")).toStrictEqual({
agentEnvironment(base, profile, "selected"), PATH: "/usr/bin",
{ HOME: "/runtime/profile-0",
PATH: "/usr/bin", CLAUDE_CODE_OAUTH_TOKEN: "selected",
HOME: "/runtime/profile-0", });
CLAUDE_CODE_OAUTH_TOKEN: "selected", expect(sanitizedEnvironment(base)).toStrictEqual({
},
);
assertEquals(sanitizedEnvironment(base), {
PATH: "/usr/bin", PATH: "/usr/bin",
HOME: "/home/bot", HOME: "/home/bot",
}); });
}); });
Deno.test("uses a private Codex home without inventing an API credential", () => { test("uses a private Codex home without inventing an API credential", () => {
const profile = { const profile = {
index: 1, index: 1,
type: "codex" as const, type: "codex" as const,
@@ -216,14 +202,14 @@ Deno.test("uses a private Codex home without inventing an API credential", () =>
tokenFile: null, tokenFile: null,
}; };
assertEquals(agentEnvironment({ PATH: "/bin" }, profile), { expect(agentEnvironment({ PATH: "/bin" }, profile)).toStrictEqual({
PATH: "/bin", PATH: "/bin",
HOME: "/runtime/profile-1", HOME: "/runtime/profile-1",
CODEX_HOME: "/runtime/profile-1/.codex", CODEX_HOME: "/runtime/profile-1/.codex",
}); });
}); });
Deno.test("exposes an OpenRouter key only to the OpenCode profile", () => { test("exposes an OpenRouter key only to the OpenCode profile", () => {
const profile = { const profile = {
index: 2, index: 2,
type: "opencode" as const, type: "opencode" as const,
@@ -234,7 +220,7 @@ Deno.test("exposes an OpenRouter key only to the OpenCode profile", () => {
tokenFile: "/runtime/profile-2/token", tokenFile: "/runtime/profile-2/token",
}; };
assertEquals(agentEnvironment({ PATH: "/bin" }, profile, "selected"), { expect(agentEnvironment({ PATH: "/bin" }, profile, "selected")).toStrictEqual({
PATH: "/bin", PATH: "/bin",
HOME: "/runtime/profile-2", HOME: "/runtime/profile-2",
OPENROUTER_API_KEY: "selected", OPENROUTER_API_KEY: "selected",
+79 -67
View File
@@ -1,24 +1,32 @@
export type BotProfile = { export type ProfileType = "claude" | "codex" | "opencode";
type: "claude" | "codex" | "opencode";
export interface BotProfile {
type: ProfileType;
model: string; model: string;
token?: string; token?: string;
}; }
export function profileFooter( const PROFILE_TYPES: Set<unknown> = new Set(["claude", "codex", "opencode"]);
profile: Pick<BotProfile, "type" | "model">, const FAILURE_REASONS: Set<unknown> = new Set(["sign-in failed", "usage exhausted", "failed"]);
): string {
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}\``; return `> Profile: \`${profile.type}\` · \`${profile.model}\``;
} }
export function withProfile( export function withProfile(body: string, profile: Pick<BotProfile, "type" | "model">): string {
body: string,
profile: Pick<BotProfile, "type" | "model">,
): string {
return `${body}\n\n${profileFooter(profile)}`; return `${body}\n\n${profileFooter(profile)}`;
} }
export type FailureReason = "sign-in failed" | "usage exhausted" | "failed"; export type FailureReason = "sign-in failed" | "usage exhausted" | "failed";
export function isFailureReason(value: unknown): value is FailureReason {
return FAILURE_REASONS.has(value);
}
export type ProfileFailure = Pick<BotProfile, "type" | "model"> & { export type ProfileFailure = Pick<BotProfile, "type" | "model"> & {
reason: FailureReason; reason: FailureReason;
}; };
@@ -27,8 +35,9 @@ export type ProfileFailure = Pick<BotProfile, "type" | "model"> & {
export class AgentAuthError extends Error {} export class AgentAuthError extends Error {}
function isUsageExhausted(error: unknown): boolean { 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/i 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(
.test(String(error)); String(error),
);
} }
function failureReason(error: unknown): FailureReason { function failureReason(error: unknown): FailureReason {
@@ -38,69 +47,72 @@ function failureReason(error: unknown): FailureReason {
export function failureFooter(failures: ProfileFailure[]): string { export function failureFooter(failures: ProfileFailure[]): string {
if (failures.length === 0) return "> Profile: none"; if (failures.length === 0) return "> Profile: none";
return `> Tried: ${ return `> Tried: ${failures
failures .map(({ type, model, reason }) => `\`${type}\` · \`${model}\` (${reason})`)
.map(({ type, model, reason }) => .join(", ")}`;
`\`${type}\` · \`${model}\` (${reason})`
)
.join(", ")
}`;
} }
export async function runProfiles< // Tries the profiles from `index` on, moving on only after a sign-in failure
T, // or exhausted usage, and records every profile it tried.
P extends Pick<BotProfile, "type" | "model">, 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[], profiles: P[],
attempt: (profile: P) => Promise<T>, attempt: (profile: P) => Promise<T>,
failures: ProfileFailure[] = [], failures: ProfileFailure[] = [],
): Promise<T> { ): Promise<T> {
for (const [index, profile] of profiles.entries()) { return attemptFrom(profiles, attempt, failures, 0);
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`);
}
}
throw new Error("profiles must contain at least one profile");
} }
export function sanitizedEnvironment( export function sanitizedEnvironment(environment: Record<string, string>): Record<string, string> {
environment: Record<string, string>,
): Record<string, string> {
const result: Record<string, string> = {}; const result: Record<string, string> = {};
for ( for (const name of [
const name of [ "PATH",
"PATH", "HOME",
"HOME", "LANG",
"LANG", "LANGUAGE",
"LANGUAGE", "LC_ALL",
"LC_ALL", "TERM",
"TERM", "COLORTERM",
"COLORTERM", "CI",
"CI", "TMPDIR",
"TMPDIR", "GITEA_TOKEN",
"GITEA_TOKEN", "GITEA_API_URL",
"GITEA_API_URL", "GITEA_REPOSITORY",
"GITEA_REPOSITORY", "GITHUB_WORKSPACE",
"GITHUB_WORKSPACE", "HTTP_PROXY",
"HTTP_PROXY", "HTTPS_PROXY",
"HTTPS_PROXY", "ALL_PROXY",
"ALL_PROXY", "NO_PROXY",
"NO_PROXY", "http_proxy",
"http_proxy", "https_proxy",
"https_proxy", "all_proxy",
"all_proxy", "no_proxy",
"no_proxy", "SSL_CERT_FILE",
"SSL_CERT_FILE", "SSL_CERT_DIR",
"SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS",
"NODE_EXTRA_CA_CERTS", ]) {
] const value = environment[name];
) { if (value !== undefined) result[name] = value;
if (environment[name] !== undefined) result[name] = environment[name];
} }
return result; return result;
} }
@@ -113,7 +125,7 @@ export function agentEnvironment(
const result = sanitizedEnvironment(environment); const result = sanitizedEnvironment(environment);
result.HOME = profile.home; result.HOME = profile.home;
if (profile.type === "codex") result.CODEX_HOME = `${profile.home}/.codex`; if (profile.type === "codex") result.CODEX_HOME = `${profile.home}/.codex`;
if (token) { if (token !== undefined && token !== "") {
const tokenName = { const tokenName = {
claude: "CLAUDE_CODE_OAUTH_TOKEN", claude: "CLAUDE_CODE_OAUTH_TOKEN",
codex: "OPENAI_API_KEY", codex: "OPENAI_API_KEY",
+79
View File
@@ -0,0 +1,79 @@
// 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;
}
+89 -410
View File
@@ -1,127 +1,69 @@
// Runs one ordered agent profile. Credential decryption and reviewer posting // Runs one ordered agent profile. Credential decryption and reviewer posting
// live in separate processes so neither privileged secret reaches this runner. // live in separate processes so neither privileged secret reaches this runner.
import { TextLineStream } from "jsr:@std/streams@1/text-line-stream"; import { chmod, readFile, writeFile } from "node:fs/promises";
import {
type CodexAuthentication,
codexAuthentication,
isClaudeAuthFailure,
isCodexAuthFailure,
persistCodexAuth,
retryInvalidToken,
stageCodexAuth,
} from "./auth.ts";
import {
agentCommand,
killProfileProcesses,
readProfileFile,
} from "./isolation.ts";
import {
AgentAuthError,
agentEnvironment,
type ProfileFailure,
runProfiles,
sanitizedEnvironment,
withProfile,
} from "./profiles.ts";
import {
collectOpenCodeOutput,
openCodeArgs,
openCodeError,
parseOpenCodeEvents,
} from "./opencode.ts";
import {
readRuntimeManifest,
type RuntimeOutcome,
type RuntimeProfile,
} from "./runtime.ts";
type GiteaUser = { login: string; email: string }; import { codexAuthentication, persistCodexAuth, stageCodexAuth } from "./auth.ts";
type GiteaComment = { user: GiteaUser; created_at: string; body: string }; import { runAgent } from "./agents.ts";
type AttemptResult = { 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; body: string;
reviewPath: string; reviewPath: string;
anchorsPath: string; anchorsPath: string;
profile: Pick<RuntimeProfile, "type" | "model">; profile: Pick<RuntimeProfile, "type" | "model">;
}; }
const env = (name: string): string => { interface RenderedPrompt {
const value = Deno.env.get(name); prompt: string;
if (value === undefined) throw new Error(`${name} is not set`); reviewPath: string;
return value; anchorsPath: string;
}; }
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(Deno.env.toObject());
const RULES_PATH = "repos/commons/code-rules/raw/README.md"; const RULES_PATH = "repos/commons/code-rules/raw/README.md";
const CODEX_AUTH = "/root/.codex/auth.json"; const CODEX_AUTH = "/root/.codex/auth.json";
const SKILLS_INSTALL = "skills add obra/superpowers"; 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> { async function runRoot(command: string, args: string[]): Promise<void> {
const { success, code } = await new Deno.Command(command, { const { success, code } = await output(command, { args, env: PROCESS_ENV, clearEnv: true });
args, if (!success) {
env: PROCESS_ENV, throw new Error(`${command} ${args[0]} exited with ${code}`);
clearEnv: true, }
}).output();
if (!success) throw new Error(`${command} ${args[0]} exited with ${code}`);
} }
async function configureGitAuthor(): Promise<void> { async function configureGitAuthor(): Promise<void> {
const user: GiteaUser = await (await gitea("user")).json(); const user: unknown = await (await gitea("user")).json();
for (const [key, value] of [["name", user.login], ["email", user.email]]) { if (!isObject(user) || typeof user.login !== "string" || typeof user.email !== "string") {
await runRoot("git", ["config", `user.${key}`, value]); 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( async function readProfileToken(profile: RuntimeProfile): Promise<string | undefined> {
profile: RuntimeProfile, const token = profile.tokenFile === null ? undefined : await readFile(profile.tokenFile, "utf8");
): Promise<string | undefined> { return token;
return profile.tokenFile === null
? undefined
: await Deno.readTextFile(profile.tokenFile);
} }
async function runAgentCommand( async function prepareProfile(profile: RuntimeProfile): Promise<void> {
profile: RuntimeProfile,
command: string,
args: string[],
token?: string,
): Promise<void> {
const { success, code } = await agentCommand(profile, command, args, {
cwd: WORKSPACE,
env: agentEnvironment(PROCESS_ENV, profile, token),
clearEnv: true,
}).output();
if (!success) throw new Error(`${command} ${args[0]} exited with ${code}`);
}
async function prepareProfile(
profile: RuntimeProfile,
): Promise<void> {
await runAgentCommand(profile, "git", [ await runAgentCommand(profile, "git", [
"config", "config",
"--global", "--global",
@@ -139,21 +81,26 @@ async function prepareProfile(
]); ]);
} }
async function renderPrompt( // One issue comment as the prompt quotes it.
profile: RuntimeProfile, function formatComment(comment: unknown): string {
): Promise<{ prompt: string; reviewPath: string; anchorsPath: string }> { const { user, created_at: createdAt, body } = isObject(comment) ? comment : {};
const reviewPath = await makeProfileFile(profile, "review.md"); const login = isObject(user) ? user.login : undefined;
const anchorsPath = await makeProfileFile(profile, "anchors.json"); return `## ${String(login)} at ${String(createdAt)}\n\n${String(body)}\n`;
const comments: GiteaComment[] = await (await gitea( }
`repos/${REPO}/issues/${INDEX}/comments?limit=100`,
)).json(); async function promptValues(
const values: Record<string, string> = { 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"), COMMENT: env("COMMENT"),
ISSUE_COMMENTS: comments ISSUE_COMMENTS: comments.map((comment: unknown) => formatComment(comment)).join("\n"),
.map((comment) =>
`## ${comment.user.login} at ${comment.created_at}\n\n${comment.body}\n`
)
.join("\n"),
CODE_RULES: await (await gitea(RULES_PATH)).text(), CODE_RULES: await (await gitea(RULES_PATH)).text(),
EVENT_NAME: EVENT, EVENT_NAME: EVENT,
GITEA_API_URL: API, GITEA_API_URL: API,
@@ -162,330 +109,62 @@ async function renderPrompt(
REVIEW_PATH: reviewPath, REVIEW_PATH: reviewPath,
ANCHORS_PATH: anchorsPath, ANCHORS_PATH: anchorsPath,
}; };
const template = await Deno.readTextFile( }
new URL("../prompt.md", import.meta.url),
); 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 { return {
prompt: template.replace( prompt: template.replaceAll(
/\$\{(\w+)\}/g, /\$\{(\w+)\}/gu,
(match, name) => values[name] ?? match, (match: string, name: string) => values[name] ?? match,
), ),
reviewPath, reviewPath,
anchorsPath, anchorsPath,
}; };
} }
async function runClaude(
profile: RuntimeProfile,
token: string,
prompt: string,
): Promise<string> {
const claude = agentCommand(profile, "claude", [
"--print",
"--dangerously-skip-permissions",
"--model",
profile.model,
"--output-format",
"stream-json",
"--verbose",
prompt,
], {
cwd: WORKSPACE,
env: {
...agentEnvironment(PROCESS_ENV, profile, token),
IS_SANDBOX: "1",
},
clearEnv: true,
stdout: "piped",
stderr: "piped",
}).spawn();
let result:
| {
is_error?: boolean;
result?: string;
subtype: string;
api_error_status?: number | null;
}
| undefined;
let error = "";
const copyError = claude.stderr.pipeThrough(new TextDecoderStream()).pipeTo(
new WritableStream({
write(chunk) {
error += chunk;
console.error(chunk);
},
}),
);
const lines = claude.stdout
.pipeThrough(new TextDecoderStream())
.pipeThrough(new TextLineStream());
for await (const line of lines) {
if (!line) continue;
const event = JSON.parse(line);
for (const part of event.message?.content ?? []) {
const text = part.thinking ?? part.text ?? part.name;
if (text) console.log(text);
}
if (event.type === "result") result = event;
}
const [status] = await Promise.all([claude.status, copyError]);
if (result?.is_error) {
const failure = new Error(
result.result ?? `claude ended with ${result.subtype}`,
);
if (isClaudeAuthFailure(result)) {
throw new AgentAuthError("claude sign-in failed", { cause: failure });
}
throw failure;
}
if (result?.result === undefined) {
throw new Error(
error.trim() || `claude ended with ${result?.subtype ?? "no result"}`,
);
}
if (!status.success) {
throw new Error(error.trim() || `claude exited with ${status.code}`);
}
return result.result;
}
async function codexDeviceLogin(
profile: RuntimeProfile,
environment: Record<string, string>,
): Promise<void> {
const login = agentCommand(profile, "codex", ["login", "--device-auth"], {
cwd: WORKSPACE,
env: environment,
clearEnv: true,
stdout: "piped",
stderr: "piped",
}).spawn();
let shown = "";
const collect = (stream: ReadableStream<Uint8Array>): Promise<void> =>
stream.pipeThrough(new TextDecoderStream()).pipeTo(
new WritableStream({ write: (chunk) => void (shown += chunk) }),
);
const drained = Promise.all([collect(login.stdout), collect(login.stderr)]);
const status = login.status;
await new Promise((resolve) => setTimeout(resolve, 3000));
while (shown.trim() === "") {
const exited = await Promise.race([
status.then(() => true),
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 100)),
]);
if (exited) {
throw new AgentAuthError("AI bot login produced no instructions");
}
}
await postAuthorComment(withProfile(shown, profile));
const [result] = await Promise.all([status, drained]);
if (!result.success) {
throw new AgentAuthError(`codex login exited with ${result.code}`);
}
}
async function codexApiLogin(
profile: RuntimeProfile,
environment: Record<string, string>,
token: string,
): Promise<void> {
const login = agentCommand(profile, "codex", ["login", "--with-api-key"], {
cwd: WORKSPACE,
env: environment,
clearEnv: true,
stdin: "piped",
stderr: "piped",
}).spawn();
const writer = login.stdin.getWriter();
await writer.write(new TextEncoder().encode(`${token}\n`));
await writer.close();
const [status, error] = await Promise.all([
login.status,
new Response(login.stderr).text(),
]);
if (!status.success) {
throw new Error(error.trim() || `codex login exited with ${status.code}`);
}
}
async function makeProfileFile(
profile: RuntimeProfile,
name: string,
): Promise<string> {
const path = `${profile.home}/${name}`;
await Deno.writeTextFile(path, "", { mode: 0o600 });
await Deno.chmod(path, 0o600);
await Deno.chown(path, profile.uid, profile.gid);
return path;
}
async function runCodex(
profile: RuntimeProfile,
prompt: string,
authentication: CodexAuthentication,
): Promise<string> {
const token = authentication.kind === "api-key"
? authentication.token
: undefined;
const environment = agentEnvironment(PROCESS_ENV, profile, token);
if (authentication.kind === "api-key") {
await codexApiLogin(profile, environment, authentication.token);
} else {
const loggedIn = await agentCommand(profile, "codex", [
"login",
"status",
], {
cwd: WORKSPACE,
env: environment,
clearEnv: true,
}).output();
if (!loggedIn.success) await codexDeviceLogin(profile, environment);
}
const outputPath = await makeProfileFile(profile, "result.md");
const attempt = async () => {
const codex = agentCommand(profile, "codex", [
"exec",
"--model",
profile.model,
"--dangerously-bypass-approvals-and-sandbox",
"--output-last-message",
outputPath,
prompt,
], {
cwd: WORKSPACE,
env: environment,
clearEnv: true,
stdout: "inherit",
stderr: "piped",
}).spawn();
const decoder = new TextDecoder();
let error = "";
for await (const chunk of codex.stderr) {
await Deno.stderr.write(chunk);
error += decoder.decode(chunk, { stream: true });
}
error += decoder.decode();
// Codex echoes the prompt, whose comments may quote auth errors.
return { status: await codex.status, error: error.replaceAll(prompt, "") };
};
const result = authentication.kind === "api-key"
? await attempt()
: await retryInvalidToken(attempt, async () => {
await runAgentCommand(profile, "codex", ["logout"]);
await codexDeviceLogin(profile, environment);
});
if (!result.status.success) {
const failure = new Error(
result.error.trim() || `codex exited with ${result.status.code}`,
);
if (isCodexAuthFailure(result.error)) {
throw new AgentAuthError("codex sign-in failed", { cause: failure });
}
throw failure;
}
return outputPath;
}
async function runOpenCode(
profile: RuntimeProfile,
token: string,
prompt: string,
): Promise<string> {
const process = agentCommand(
profile,
"opencode",
openCodeArgs(profile.model, prompt),
{
cwd: WORKSPACE,
env: agentEnvironment(PROCESS_ENV, profile, token),
clearEnv: true,
stdout: "piped",
stderr: "piped",
},
).spawn();
const output = collectOpenCodeOutput(
process.stdout,
process.stderr,
async (chunk) => {
await Deno.stdout.write(chunk);
},
async (chunk) => {
await Deno.stderr.write(chunk);
},
);
const [status, streams] = await Promise.all([process.status, output]);
const error = streams.stderr.trim();
if (!status.success) {
throw openCodeError(streams.stdout) ??
new Error(error || `opencode exited with ${status.code}`);
}
return parseOpenCodeEvents(streams.stdout);
}
async function runProfile(profile: RuntimeProfile): Promise<AttemptResult> { async function runProfile(profile: RuntimeProfile): Promise<AttemptResult> {
const token = await readProfileToken(profile); const token = await readProfileToken(profile);
const authentication = profile.type === "codex" const authentication = profile.type === "codex" ? codexAuthentication(token) : null;
? codexAuthentication(token)
: null;
let stagedCodexAuth: string | null = null; let stagedCodexAuth: string | null = null;
let body: string | undefined; let answer: AgentAnswer = {};
let resultPath: string | null = null; let rendered: RenderedPrompt | undefined;
let reviewPath: string | undefined;
let anchorsPath: string | undefined;
try { try {
if (authentication?.kind === "persistent") { if (authentication?.kind === "persistent") {
stagedCodexAuth = await stageCodexAuth( stagedCodexAuth = await stageCodexAuth(CODEX_AUTH, profile.home, profile.uid, profile.gid);
CODEX_AUTH,
profile.home,
profile.uid,
profile.gid,
);
} }
await prepareProfile(profile); await prepareProfile(profile);
const rendered = await renderPrompt(profile); rendered = await renderPrompt(profile);
reviewPath = rendered.reviewPath; answer = await runAgent(profile, token, rendered.prompt, authentication);
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!);
} else {
body = await runOpenCode(profile, token ?? "", rendered.prompt);
}
} finally { } finally {
await killProfileProcesses(profile.uid); await killProfileProcesses(profile.uid);
if (stagedCodexAuth !== null) { if (stagedCodexAuth !== null) {
await persistCodexAuth(stagedCodexAuth, CODEX_AUTH); await persistCodexAuth(stagedCodexAuth, CODEX_AUTH);
} }
} }
if (resultPath !== null) body = await readProfileFile(profile, resultPath); const body =
if ( answer.resultPath === undefined
body === undefined || reviewPath === undefined || anchorsPath === undefined ? answer.body
) { : await readProfileFile(profile, answer.resultPath);
if (body === undefined || rendered === undefined) {
throw new Error("agent attempt produced no result"); throw new Error("agent attempt produced no result");
} }
return { const { reviewPath, anchorsPath } = rendered;
body, return { body, reviewPath, anchorsPath, profile: { type: profile.type, model: profile.model } };
reviewPath,
anchorsPath,
profile: { type: profile.type, model: profile.model },
};
} }
async function writeOutcome(outcome: RuntimeOutcome): Promise<void> { async function writeOutcome(outcome: RuntimeOutcome): Promise<void> {
await Deno.writeTextFile(MANIFEST.outcomeFile, JSON.stringify(outcome), { await writeFile(MANIFEST.outcomeFile, JSON.stringify(outcome), { mode: 0o600 });
mode: 0o600, await chmod(MANIFEST.outcomeFile, 0o600);
});
await Deno.chmod(MANIFEST.outcomeFile, 0o600);
} }
const failures: ProfileFailure[] = []; const failures: ProfileFailure[] = [];
try { try {
await configureGitAuthor(); await configureGitAuthor();
await writeOutcome({ await writeOutcome({ ok: true, ...(await runProfiles(MANIFEST.profiles, runProfile, failures)) });
ok: true,
...await runProfiles(MANIFEST.profiles, runProfile, failures),
});
} catch (error) { } catch (error) {
console.error(error); console.error(error);
await writeOutcome({ ok: false, failures }); await writeOutcome({ ok: false, failures });
+105
View File
@@ -0,0 +1,105 @@
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 });
}
});
+63 -50
View File
@@ -1,5 +1,8 @@
import { join } from "jsr:@std/path@1"; import { chmod, chown, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { parseCredentials } from "./credentials.ts"; import { parseCredentials } from "./credentials.ts";
import { isMapping } from "./json.ts";
import type { BotProfile, ProfileFailure } from "./profiles.ts"; import type { BotProfile, ProfileFailure } from "./profiles.ts";
const FIRST_PROFILE_UID = 20_000; const FIRST_PROFILE_UID = 20_000;
@@ -12,33 +15,46 @@ export type RuntimeProfile = Pick<BotProfile, "type" | "model"> & {
tokenFile: string | null; tokenFile: string | null;
}; };
export type RuntimeManifest = { export interface RuntimeManifest {
runtimeDir: string; runtimeDir: string;
manifestPath: string; manifestPath: string;
reviewerFile: string; reviewerFile: string;
outcomeFile: string; outcomeFile: string;
profiles: RuntimeProfile[]; profiles: RuntimeProfile[];
}; }
export type RuntimeOutcome = export type RuntimeOutcome =
| { | {
ok: true; ok: true;
body: string; body: string;
reviewPath: string; reviewPath: string;
anchorsPath: string; anchorsPath: string;
profile: Pick<RuntimeProfile, "type" | "model">; profile: Pick<RuntimeProfile, "type" | "model">;
} }
| { ok: false; failures: ProfileFailure[] }; | { ok: false; failures: ProfileFailure[] };
async function writePrivate( async function writePrivate(path: string, value: string, uid: number, gid: number): Promise<void> {
path: string, await writeFile(path, value, { mode: 0o600 });
value: string, await chmod(path, 0o600);
uid: number, await chown(path, uid, gid);
gid: number, }
): Promise<void> {
await Deno.writeTextFile(path, value, { mode: 0o600 }); async function prepareProfile(
await Deno.chmod(path, 0o600); runtimeDir: string,
await Deno.chown(path, uid, gid); 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( export async function prepareRuntime(
@@ -47,39 +63,23 @@ export async function prepareRuntime(
masterKey: string, masterKey: string,
): Promise<RuntimeManifest> { ): Promise<RuntimeManifest> {
const credentials = await parseCredentials( const credentials = await parseCredentials(
await Deno.readTextFile(join(actionPath, "profiles.yml")), await readFile(join(actionPath, "profiles.json"), "utf8"),
masterKey, masterKey,
); );
const runtimeDir = await Deno.makeTempDir({ const runtimeDir = await mkdtemp(join(runtimeRoot, "bot-agents-"));
dir: runtimeRoot,
prefix: "bot-agents-",
});
try { try {
await Deno.chmod(runtimeDir, 0o711); await chmod(runtimeDir, 0o711);
const reviewerFile = join(runtimeDir, "reviewer-token"); const reviewerFile = join(runtimeDir, "reviewer-token");
await writePrivate(reviewerFile, credentials.reviewerToken, 0, 0); await writePrivate(reviewerFile, credentials.reviewerToken, 0, 0);
const profiles: RuntimeProfile[] = []; // One profile at a time, in order, as each claims the next UID.
for (const [index, profile] of credentials.profiles.entries()) { const profiles = await credentials.profiles.reduce<Promise<RuntimeProfile[]>>(
const uid = FIRST_PROFILE_UID + index; async (previous, profile, index) => [
const home = join(runtimeDir, `profile-${index}`); ...(await previous),
await Deno.mkdir(home, { mode: 0o700 }); await prepareProfile(runtimeDir, profile, index),
await Deno.chmod(home, 0o700); ],
await Deno.chown(home, uid, uid); Promise.resolve([]),
const tokenFile = profile.token ? join(home, "token") : null; );
if (tokenFile !== null) {
await writePrivate(tokenFile, profile.token!, uid, uid);
}
profiles.push({
index,
type: profile.type,
model: profile.model,
uid,
gid: uid,
home,
tokenFile,
});
}
const manifestPath = join(runtimeDir, "manifest.json"); const manifestPath = join(runtimeDir, "manifest.json");
const manifest: RuntimeManifest = { const manifest: RuntimeManifest = {
@@ -92,13 +92,26 @@ export async function prepareRuntime(
await writePrivate(manifestPath, JSON.stringify(manifest), 0, 0); await writePrivate(manifestPath, JSON.stringify(manifest), 0, 0);
return manifest; return manifest;
} catch (error) { } catch (error) {
await Deno.remove(runtimeDir, { recursive: true }).catch(() => undefined); await rm(runtimeDir, { recursive: true }).catch(() => {});
throw error; throw error;
} }
} }
export async function readRuntimeManifest( function isRuntimeManifest(value: unknown): value is RuntimeManifest {
path: string, return (
): Promise<RuntimeManifest> { isMapping(value) &&
return JSON.parse(await Deno.readTextFile(path)); 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;
} }
-108
View File
@@ -1,108 +0,0 @@
import { assert, assertEquals, assertNotEquals } from "jsr:@std/assert@1";
import { prepareRuntime } from "./runtime.ts";
const MASTER_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8";
const ENCRYPTED = "v1:ICEiIyQlJicoKSor:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA";
Deno.test({
name: "prepares root-only reviewer and isolated profile credentials",
permissions: { read: true, write: true, run: true, sys: ["uid"] },
async fn() {
if (Deno.uid() !== 0) return;
const root = await Deno.makeTempDir();
await Deno.chmod(root, 0o711);
const action = await Deno.makeTempDir();
try {
await Deno.writeTextFile(
`${action}/profiles.yml`,
`reviewer-token: ${ENCRYPTED}\nprofiles:\n - type: claude\n token: ${ENCRYPTED}\n - type: codex\n`,
);
const manifest = await prepareRuntime(action, root, MASTER_KEY);
const serialized = await Deno.readTextFile(manifest.manifestPath);
assert(!serialized.includes("secret-token"));
assertEquals(
await Deno.readTextFile(manifest.reviewerFile),
"secret-token",
);
assertEquals(
(await Deno.stat(manifest.reviewerFile)).mode! & 0o777,
0o600,
);
assertEquals((await Deno.stat(manifest.reviewerFile)).uid, 0);
const [claude, codex] = manifest.profiles;
assertNotEquals(claude.uid, codex.uid);
assertEquals((await Deno.stat(claude.home)).uid, claude.uid);
assertEquals((await Deno.stat(claude.home)).mode! & 0o777, 0o700);
assert(claude.tokenFile !== null);
assertEquals(await Deno.readTextFile(claude.tokenFile), "secret-token");
assertEquals((await Deno.stat(claude.tokenFile)).uid, claude.uid);
assertEquals((await Deno.stat(claude.tokenFile)).mode! & 0o777, 0o600);
assertEquals((await Deno.stat(codex.home)).uid, codex.uid);
assertEquals(codex.tokenFile, null);
const ownToken = await new Deno.Command("/bin/cat", {
args: [claude.tokenFile],
cwd: "/",
uid: claude.uid,
gid: claude.gid,
}).output();
assertEquals(ownToken.success, true);
assertEquals(new TextDecoder().decode(ownToken.stdout), "secret-token");
const reviewerToken = await new Deno.Command("/bin/cat", {
args: [manifest.reviewerFile],
cwd: "/",
uid: claude.uid,
gid: claude.gid,
}).output();
assertEquals(reviewerToken.success, false);
const siblingHome = await new Deno.Command("/bin/ls", {
args: [codex.home],
cwd: "/",
uid: claude.uid,
gid: claude.gid,
}).output();
assertEquals(siblingHome.success, false);
} finally {
await Deno.remove(root, { recursive: true });
await Deno.remove(action, { recursive: true });
}
},
});
Deno.test({
name: "removes prepared credentials when output publication fails",
permissions: "inherit",
async fn() {
if (Deno.uid() !== 0) return;
const root = await Deno.makeTempDir();
const action = await Deno.makeTempDir();
try {
await Deno.writeTextFile(
`${action}/profiles.yml`,
`reviewer-token: ${ENCRYPTED}\nprofiles:\n - type: claude\n token: ${ENCRYPTED}\n`,
);
const result = await new Deno.Command(Deno.execPath(), {
args: ["run", "-A", new URL("./prepare.ts", import.meta.url).pathname],
env: {
ACTION_PATH: action,
RUNNER_TEMP: root,
BOT_AGENTS_MASTER_KEY: MASTER_KEY,
GITHUB_OUTPUT: action,
},
clearEnv: true,
}).output();
assertEquals(result.success, false);
const entries = [];
for await (const entry of Deno.readDir(root)) entries.push(entry.name);
assertEquals(entries, []);
} finally {
await Deno.remove(root, { recursive: true });
await Deno.remove(action, { recursive: true });
}
},
});
+23
View File
@@ -0,0 +1,23 @@
// 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-"));
}
-188
View File
@@ -1,188 +0,0 @@
import { assert, assertEquals } from "jsr:@std/assert@1";
import { parse } from "jsr:@std/yaml@1";
type Mapping = Record<string, unknown>;
const mapping = (value: unknown, at: string): Mapping => {
assert(
typeof value === "object" && value !== null && !Array.isArray(value),
at,
);
return value as Mapping;
};
const sequence = (value: unknown, at: string): Mapping[] => {
assert(Array.isArray(value), at);
return value.map((entry, index) => mapping(entry, `${at}[${index}]`));
};
async function yaml(path: string): Promise<Mapping> {
return mapping(parse(await Deno.readTextFile(path)), path);
}
async function yamlFiles(directory = "."): Promise<string[]> {
const result: string[] = [];
for await (const entry of Deno.readDir(directory)) {
if (entry.name === ".git" || entry.name === ".superpowers") continue;
const path = directory === "." ? entry.name : `${directory}/${entry.name}`;
if (entry.isDirectory) result.push(...await yamlFiles(path));
if (entry.isFile && /\.ya?ml$/.test(entry.name)) result.push(path);
}
return result;
}
Deno.test("all committed YAML parses", async () => {
const files = await yamlFiles();
assert(files.length > 0);
for (const file of files) await yaml(file);
});
Deno.test("the action exposes only the master key and scopes it to preparation", async () => {
const action = await yaml("action.yml");
assertEquals(Object.keys(mapping(action.inputs, "inputs")), ["master-key"]);
const steps = sequence(mapping(action.runs, "runs").steps, "runs.steps");
for (const step of steps) {
if (typeof step.run === "string") {
assertEquals(step.run.includes("\n"), false, String(step.name));
}
}
const checkout = steps.find((step) => step.uses === "actions/checkout@v4");
const prepare = steps.find((step) => step.id === "prepare");
const agent = steps.find((step) => step.id === "agent");
const post = steps.find((step) => step.id === "post");
const source = steps.find((step) => step.id === "source");
assert(checkout && source && prepare && agent && post);
assertEquals(
mapping(checkout.with, "checkout.with").token,
"${{ gitea.token }}",
);
assertEquals(
mapping(prepare.env, "prepare.env").BOT_AGENTS_MASTER_KEY,
"${{ inputs.master-key }}",
);
assertEquals(JSON.stringify(agent).includes("BOT_AGENTS_MASTER_KEY"), false);
assertEquals(JSON.stringify(post).includes("BOT_AGENTS_MASTER_KEY"), false);
assertEquals(
mapping(agent.env, "agent.env").GITEA_TOKEN,
"${{ gitea.token }}",
);
assert(String(post.if).includes("always()"));
for (const step of [prepare, agent, post]) {
assert(String(step.run).includes("steps.source.outputs.path"));
}
});
Deno.test("the source protection script stages immutable action files", async () => {
const temp = await Deno.makeTempDir();
const outputFile = `${temp}/output`;
try {
const output = await new Deno.Command("/bin/bash", {
args: ["scripts/protect-source.sh"],
env: {
ACTION_PATH: Deno.cwd(),
GITHUB_OUTPUT: outputFile,
RUNNER_TEMP: temp,
},
stderr: "piped",
}).output();
assert(output.success, new TextDecoder().decode(output.stderr));
const path = (await Deno.readTextFile(outputFile)).trim().slice(5);
for (
const file of [
"src/prepare.ts",
"src/run.ts",
"src/post.ts",
"profiles.yml",
"prompt.md",
]
) {
const stat = await Deno.stat(`${path}/${file}`);
assert(stat.isFile, file);
assertEquals(stat.mode! & 0o022, 0, file);
}
} finally {
await Deno.remove(temp, { recursive: true });
}
});
Deno.test("module-relative runtime assets exist", async () => {
const module = new URL("run.ts", import.meta.url);
const source = await Deno.readTextFile(module);
for (
const match of source.matchAll(/new URL\("([^"]+)", import\.meta\.url\)/g)
) {
const asset = new URL(match[1], module);
const stat = await Deno.stat(asset);
assert(stat.isFile, asset.pathname);
}
});
Deno.test("the repository uses its trusted main action", async () => {
const workflow = await yaml(".gitea/workflows/agents.yml");
const job = mapping(mapping(workflow.jobs, "jobs").agent, "jobs.agent");
const permissions = mapping(job.permissions, "jobs.agent.permissions");
assertEquals(permissions, {
code: "write",
issues: "write",
"pull-requests": "write",
});
const steps = sequence(job.steps, "jobs.agent.steps");
const local = steps.findIndex((step) => step.uses === "./");
assertEquals(local, -1);
const trusted = steps.findIndex((step) =>
step.uses === "https://git.capsulizers.com/commons/bot-agents@main"
);
assert(trusted >= 0);
assertEquals(
mapping(steps[trusted].with, "trusted.with")["master-key"],
"${{ secrets.BOT_AGENTS_MASTER_KEY }}",
);
assertEquals(JSON.stringify(workflow).includes("gitea.actor != 'bot'"), true);
});
Deno.test("CI checks every phase and the root isolation suite", async () => {
const workflow = await yaml(".gitea/workflows/check.yml");
const job = mapping(mapping(workflow.jobs, "jobs").deno, "jobs.deno");
const commands = sequence(job.steps, "jobs.deno.steps")
.map((step) => typeof step.run === "string" ? step.run : "")
.join("\n");
for (const entrypoint of ["src/prepare.ts", "src/run.ts", "src/post.ts"]) {
assert(commands.includes(entrypoint), entrypoint);
}
assert(commands.includes("deno test --allow-all"));
});
Deno.test("uses the standard Skills installer without custom discovery", async () => {
const runner = await Deno.readTextFile("src/run.ts");
assert(runner.includes("skills add obra/superpowers"));
try {
await Deno.stat("superpowers.ts");
assert(false, "superpowers.ts should be removed");
} catch (error) {
assert(error instanceof Deno.errors.NotFound);
}
});
Deno.test("installs the OpenCode CLI", async () => {
const action = await Deno.readTextFile("action.yml");
assert(action.includes("opencode-ai"));
});
Deno.test("profile preparation does not receive provider credentials", async () => {
const runner = await Deno.readTextFile("src/run.ts");
assert(runner.includes("await prepareProfile(profile);"));
assertEquals(runner.includes("prepareProfile(profile, token)"), false);
});
Deno.test("central profiles fall back from Codex to Claude to OpenCode", async () => {
const credentials = await yaml("profiles.yml");
const profiles = sequence(credentials.profiles, "profiles");
assertEquals(profiles.map((profile) => profile.type), [
"codex",
"claude",
"opencode",
]);
assertEquals(profiles[2].model, "openrouter/qwen/qwen3.8-27b");
assert(String(profiles[2].token).startsWith("v1:"));
});
+27
View File
@@ -0,0 +1,27 @@
import process from "node:process";
import { fileURLToPath } from "node:url";
import { expect, test } from "vite-plus/test";
import { output } from "../src/command.ts";
// Each phase runs as `node src/<phase>.ts` with no build step. Node loads the
// whole import graph, stripping types, before the phase reads its first
// variable, so failing on that variable proves every module loads.
const PHASES: [phase: string, firstVariable: string][] = [
["prepare", "ACTION_PATH"],
["run", "GITEA_API_URL"],
["post", "BOT_AGENTS_MANIFEST"],
];
test.each(PHASES)("Node loads the %s phase", async (phase, firstVariable) => {
const path = fileURLToPath(new URL(`../src/${phase}.ts`, import.meta.url));
const result = await output(process.execPath, {
args: [path],
env: { PATH: process.env.PATH ?? "" },
clearEnv: true,
stdin: "null",
});
expect(result.success).toBe(false);
expect(new TextDecoder().decode(result.stderr)).toContain(`${firstVariable} is not set`);
});
+175
View File
@@ -0,0 +1,175 @@
import { readdir, readFile, rm, stat } from "node:fs/promises";
import process from "node:process";
import { assert, expect, test } from "vite-plus/test";
import { parse } from "yaml";
import { output } from "../src/command.ts";
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 => {
assert(isMapping(value), at);
return value;
};
const sequence = (value: unknown, at: string): JsonObject[] => {
assert(Array.isArray(value), at);
return value.map((entry: unknown, index) => mapping(entry, `${at}[${index}]`));
};
async function yaml(path: string): Promise<JsonObject> {
const value: unknown = parse(await readFile(path, "utf8"));
return mapping(value, path);
}
async function yamlFiles(): Promise<string[]> {
const entries = await readdir(".", { recursive: true, withFileTypes: true });
return entries
.filter((entry) => entry.isFile() && /\.ya?ml$/u.test(entry.name))
.map((entry) => `${entry.parentPath}/${entry.name}`.replace(/^\.\//u, ""))
.filter((path) => !/^(?:\.git|\.superpowers|node_modules)\//u.test(path));
}
const findStep = (steps: JsonObject[], key: string, value: string): JsonObject => {
const step = steps.find((entry) => entry[key] === value);
assert(step !== undefined, `${key}: ${value}`);
return step;
};
test("all committed YAML parses", async () => {
const files = await yamlFiles();
assert(files.length > 0);
await Promise.all(files.map((file) => yaml(file)));
});
test("the action exposes only the master key and scopes it to preparation", async () => {
const action = await yaml("action.yml");
expect(Object.keys(mapping(action.inputs, "inputs"))).toStrictEqual(["master-key"]);
const steps = sequence(mapping(action.runs, "runs").steps, "runs.steps");
for (const step of steps) {
if (typeof step.run === "string") {
expect(step.run.includes("\n"), String(step.name)).toBe(false);
}
}
const checkout = findStep(steps, "uses", "actions/checkout@v4");
const prepare = findStep(steps, "id", "prepare");
const agent = findStep(steps, "id", "agent");
const post = findStep(steps, "id", "post");
findStep(steps, "id", "source");
expect(mapping(checkout.with, "checkout.with").token).toBe("${{ gitea.token }}");
expect(mapping(prepare.env, "prepare.env").BOT_AGENTS_MASTER_KEY).toBe(
"${{ inputs.master-key }}",
);
expect(JSON.stringify(agent).includes("BOT_AGENTS_MASTER_KEY")).toBe(false);
expect(JSON.stringify(post).includes("BOT_AGENTS_MASTER_KEY")).toBe(false);
expect(mapping(agent.env, "agent.env").GITEA_TOKEN).toBe("${{ gitea.token }}");
assert(String(post.if).includes("always()"));
for (const step of [prepare, agent, post]) {
assert(String(step.run).includes("steps.source.outputs.path"));
}
});
test("the source protection script stages immutable action files", async () => {
const temp = await makeTempDir();
const outputFile = `${temp}/output`;
try {
const result = await output("/bin/bash", {
args: ["scripts/protect-source.sh"],
env: { ACTION_PATH: process.cwd(), GITHUB_OUTPUT: outputFile, RUNNER_TEMP: temp },
stderr: "piped",
});
assert(result.success, new TextDecoder().decode(result.stderr));
const path = (await readFile(outputFile, "utf8")).trim().slice(5);
const files = [
"src/prepare.ts",
"src/run.ts",
"src/post.ts",
"package.json",
"profiles.json",
"prompt.md",
];
const stats = await Promise.all(files.map((file) => stat(`${path}/${file}`)));
for (const [index, info] of stats.entries()) {
assert(info.isFile(), files[index]);
expect(info.mode & 0o022, files[index]).toStrictEqual(0);
}
} finally {
await rm(temp, { recursive: true });
}
});
test("module-relative runtime assets exist", async () => {
const module = new URL("../src/run.ts", import.meta.url);
const source = await readFile(module, "utf8");
const assets = [...source.matchAll(/new URL\("([^"]+)", import\.meta\.url\)/gu)].map(
([, asset = ""]) => new URL(asset, module),
);
const stats = await Promise.all(assets.map((asset) => stat(asset)));
for (const [index, info] of stats.entries()) {
assert(info.isFile(), assets[index]?.pathname);
}
});
test("the repository uses its trusted main action", async () => {
const workflow = await yaml(".gitea/workflows/agents.yml");
const job = mapping(mapping(workflow.jobs, "jobs").agent, "jobs.agent");
const permissions = mapping(job.permissions, "jobs.agent.permissions");
expect(permissions).toStrictEqual({
code: "write",
issues: "write",
"pull-requests": "write",
});
const steps = sequence(job.steps, "jobs.agent.steps");
expect(steps.findIndex((step) => step.uses === "./")).toStrictEqual(-1);
const trusted = findStep(steps, "uses", "https://git.capsulizers.com/commons/bot-agents@main");
expect(mapping(trusted.with, "trusted.with")["master-key"]).toBe(
"${{ secrets.BOT_AGENTS_MASTER_KEY }}",
);
expect(JSON.stringify(workflow).includes("gitea.actor != 'bot'")).toBe(true);
});
test("CI checks every phase and the root isolation suite", async () => {
const workflow = await yaml(".gitea/workflows/check.yml");
const job = mapping(mapping(workflow.jobs, "jobs").check, "jobs.check");
const commands = sequence(job.steps, "jobs.check.steps")
.map((step) => (typeof step.run === "string" ? step.run : ""))
.join("\n");
// `vp check` type-checks every phase, and `vp test` runs each of them under
// Node as well as the root-only isolation tests.
assert(commands.includes("npm ci"));
assert(commands.includes("vp check"));
assert(commands.includes("vp test"));
});
test("uses the standard Skills installer without custom discovery", async () => {
const runner = await readFile("src/run.ts", "utf8");
assert(runner.includes("skills add obra/superpowers"));
const removed = await stat("superpowers.ts").then(
() => false,
(error: unknown) => isNotFound(error),
);
assert(removed, "superpowers.ts should be removed");
});
test("installs the OpenCode CLI", async () => {
const action = await readFile("action.yml", "utf8");
assert(action.includes("opencode-ai"));
});
test("profile preparation does not receive provider credentials", async () => {
const runner = await readFile("src/run.ts", "utf8");
assert(runner.includes("await prepareProfile(profile);"));
expect(runner.includes("prepareProfile(profile, token)")).toBe(false);
});
test("central profiles fall back from Codex to Claude to OpenCode", async () => {
const credentials: unknown = JSON.parse(await readFile("profiles.json", "utf8"));
const profiles = sequence(mapping(credentials, "profiles.json").profiles, "profiles");
expect(profiles.map((profile) => profile.type)).toStrictEqual(["codex", "claude", "opencode"]);
expect(profiles[2]?.model).toBe("openrouter/qwen/qwen3.8-27b");
assert(String(profiles[2]?.token).startsWith("v1:"));
});
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "es2024",
"module": "nodenext",
"lib": ["es2024"],
"types": ["node"],
"noEmit": true,
"allowImportingTsExtensions": true,
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true,
"strict": true,
"exactOptionalPropertyTypes": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"allowUnreachableCode": false,
"allowUnusedLabels": false
}
}
+25
View File
@@ -0,0 +1,25 @@
import { defineConfig } from "vite-plus";
import projectLint from "./scripts/project-lint.ts";
export default defineConfig({
// Keep package.json in its hand-written order.
fmt: { sortPackageJson: false },
lint: {
plugins: ["typescript", "unicorn", "oxc", "import"],
jsPlugins: ["./scripts/project-lint.ts"],
categories: { correctness: "error", suspicious: "error", pedantic: "error", perf: "error" },
rules: projectLint.configs.project.rules,
// Repo tooling, not app code: the lint plugin compares AST node `type`s,
// which the AST types declare as an enum.
overrides: [
{ files: ["scripts/**"], rules: { "typescript/no-unsafe-enum-comparison": "off" } },
],
options: {
typeAware: true,
typeCheck: true,
maxWarnings: 0,
reportUnusedDisableDirectives: "error",
},
},
});